From 1cf428f38420e85c13bf94d54a15eb59cac66805 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Fri, 14 Aug 2026 21:38:14 +0000 Subject: [PATCH] =?UTF-8?q?feat(actpred):=20log=5Frange=20=E5=BD=92?= =?UTF-8?q?=E4=B8=80=E5=8C=96=E2=80=94=E2=80=94spk=20=E4=B8=A4=E5=88=97?= =?UTF-8?q?=E5=AF=B9=E6=95=B0=E5=B0=BA=E5=BA=A6=E4=BF=AE=E6=AD=A3=E7=B2=BE?= =?UTF-8?q?=E5=BA=A6=E9=94=99=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixed_range 线性 min-max 下常用值挤在低端(10Hz→0.002),闭环反馈 中小绝对误差=大相对误差(模型 C/D 发散根因)。新增 normalize_mode= log_range:spk.F/spk.delay 用 log10(1+x) 对数尺度(10Hz→0.27), 舵机列保持线性。全链路接入(dataset/pretrain/predict/onnx/hailo/ calibration/export),与 v3 审计 none 模式/元数据自动识别合并。 --- actpred/action_norm.py | 42 ++++++++- actpred/config.py | 11 ++- actpred/dataset.py | 17 ++-- actpred/export_deploy.py | 13 ++- actpred/gen_calibration.py | 12 ++- actpred/hailo_infer.py | 22 +++-- actpred/onnx_infer.py | 26 +++-- actpred/predict.py | 26 +++-- actpred/pretrain_mt.py | 27 ++++-- actpred/test_actpred.py | 94 ++++++++++++++++++- ...pred_onehot_multihead_dev_plan_20260814.md | 82 ++++++++++++++++ 11 files changed, 306 insertions(+), 66 deletions(-) create mode 100644 docs/reports/actpred_onehot_multihead_dev_plan_20260814.md diff --git a/actpred/action_norm.py b/actpred/action_norm.py index 143e52e..4214d56 100644 --- a/actpred/action_norm.py +++ b/actpred/action_norm.py @@ -25,6 +25,17 @@ ACTION_HI = np.array( [1.0] * 6, # 12-17 保留位(恒 0,span=1.0 保持归一化后为 0) dtype=np.float32) +# 归一化方式:fixed_range 线性 min-max;log_range 在 spk 两列上用 +# log10(1+x) 对数尺度,修正低端聚集导致的精度错配 +# (10Hz 在线性尺度占 0.002、对数尺度占 0.27) +RANGE_MODES = ("fixed_range", "log_range") +LOG_COLS = (10, 11) # spk.F / spk.delay + + +def is_range_mode(mode: str) -> bool: + """normalize_mode 是否为固定范围类(fixed_range / log_range)""" + return mode in RANGE_MODES + def action_bounds(action_dim: int = 18): """(lo, hi) 物理界:action_dim ≤ 18 取前缀;> 18 的超出列按 [0, 1] 补足""" @@ -35,21 +46,42 @@ def action_bounds(action_dim: int = 18): np.concatenate([ACTION_HI, np.ones(pad, dtype=np.float32)])) -def normalize_action(action, action_dim: int = None) -> np.ndarray: +def normalize_action(action, action_dim: int = None, + mode: str = "fixed_range") -> np.ndarray: """原始动作向量 → [0,1] 归一化向量(clamp 防御越界值) action: (..., action_dim) 任意前导维;action_dim 缺省取末维长度。 + mode="fixed_range":全列线性 min-max; + mode="log_range":LOG_COLS 列用 log10(1+x)(负值先截 0),其余列线性。 """ a = np.asarray(action, dtype=np.float32) lo, hi = action_bounds(action_dim or a.shape[-1]) - return np.clip((a - lo) / (hi - lo), 0.0, 1.0) + out = np.clip((a - lo) / (hi - lo), 0.0, 1.0) + if mode == "log_range": + for c in LOG_COLS: + if c < a.shape[-1]: + hc = float(hi[c]) + col = np.clip(a[..., c], 0.0, None) + out[..., c] = np.log10(col + 1.0) / np.log10(hc + 1.0) + return out -def denormalize_action(norm_action, action_dim: int = None) -> np.ndarray: - """[0,1] 归一化向量 → 原始动作向量(不 clamp,保留模型外推能力)""" +def denormalize_action(norm_action, action_dim: int = None, + mode: str = "fixed_range") -> np.ndarray: + """[0,1] 归一化向量 → 原始动作向量(不 clamp,保留模型外推能力) + + mode="log_range" 时 LOG_COLS 列指数还原:x = 10^(n·log10(hi+1)) - 1。 + """ n = np.asarray(norm_action, dtype=np.float32) lo, hi = action_bounds(action_dim or n.shape[-1]) - return n * (hi - lo) + lo + out = n * (hi - lo) + lo + if mode == "log_range": + for c in LOG_COLS: + if c < n.shape[-1]: + hc = float(hi[c]) + out[..., c] = (np.power(10.0, n[..., c] * np.log10(hc + 1.0)) + - 1.0) + return out def default_column_weights(action_dim: int = 18) -> np.ndarray: diff --git a/actpred/config.py b/actpred/config.py index 928e2c9..f1dbc83 100644 --- a/actpred/config.py +++ b/actpred/config.py @@ -17,6 +17,8 @@ class DataConfig: # "zscore" 数据统计 z-score(现状,依赖 action_stats.npy) # "fixed_range" 固定物理范围 min-max → [0,1](免 stats 文件,零状态部署, # 常量表见 action_norm.py;越界值 clamp 防御) + # "log_range" 同上,但 spk.F/spk.delay 两列用 log10(1+x) 对数尺度, + # 修正低端聚集导致的精度错配(见实验报告) normalize_mode: str = "zscore" # 逐列损失权重(仅训练损失用,推理端无感知):None → action_norm # .default_column_weights(action_dim)(舵机 1.0 / spk 0.2 / 保留位 0.0)。 @@ -94,9 +96,10 @@ def build_argparser(description: str = "") -> argparse.ArgumentParser: parser.add_argument("--normalize-actions", action=argparse.BooleanOptionalAction, default=None) parser.add_argument("--action-stats-path", type=str, default=None) parser.add_argument("--normalize-mode", type=str, default=None, - choices=["zscore", "fixed_range"], + choices=["zscore", "fixed_range", "log_range"], help="动作归一化方式:zscore=数据统计(依赖 action_stats.npy);" - "fixed_range=固定物理范围 min-max→[0,1](免 stats 文件)") + "fixed_range=固定物理范围 min-max→[0,1](免 stats 文件);" + "log_range=spk 两列对数尺度(免 stats 文件)") parser.add_argument("--column-weights", type=str, default=None, help="逐列损失权重(逗号分隔 action_dim 个浮点)," "缺省用默认表(舵机1.0/spk0.2/保留位0.0);仅影响训练损失") @@ -172,10 +175,10 @@ def validate_config_values(data_cfg: DataConfig, model_cfg: ModelConfig, f"attn_dim={model_cfg.attn_dim} 必须能被 attn_heads=" f"{model_cfg.attn_heads} 整除") - if data_cfg.normalize_mode not in ("zscore", "fixed_range"): + if not (data_cfg.normalize_mode in ("zscore", "fixed_range", "log_range")): raise ValueError( f"normalize_mode={data_cfg.normalize_mode!r} 非法," - f"应为 'zscore' 或 'fixed_range'") + f"应为 'zscore'、'fixed_range' 或 'log_range'") if data_cfg.column_weights is not None: w = data_cfg.column_weights diff --git a/actpred/dataset.py b/actpred/dataset.py index 492c946..d66cf5e 100644 --- a/actpred/dataset.py +++ b/actpred/dataset.py @@ -11,7 +11,7 @@ from torchvision import transforms from torchvision.transforms import functional as TF from imu_norm import imu_scales -from action_norm import normalize_action +from action_norm import normalize_action, is_range_mode def _natural_key(p): @@ -29,8 +29,11 @@ class ActionDataset(Dataset): imu_dim: int = 0, normalize_mode: str = "zscore"): self.history_len = history_len self.action_dim = action_dim - # 固定物理范围归一化(免 stats 文件);仅 normalize_actions=True 生效 - self._fixed_range = normalize_actions and normalize_mode == "fixed_range" + # 固定范围类归一化(fixed_range / log_range,免 stats 文件); + # 仅 normalize_actions=True 生效 + self._range_mode = (normalize_mode + if (normalize_actions and is_range_mode(normalize_mode)) + else None) # R2 方案一:>0 时样本带 "imu" 键(归一化姿态向量,缺失 episode 补零) self.imu_dim = imu_dim self.image_width = image_width @@ -87,7 +90,7 @@ class ActionDataset(Dataset): self._action_mean = None self._action_std = None - if normalize_actions and not self._fixed_range: + if normalize_actions and not self._range_mode: if action_stats_path: if not os.path.exists(action_stats_path): raise FileNotFoundError( @@ -119,11 +122,11 @@ class ActionDataset(Dataset): def _norm(self, a): """动作归一化(numpy → torch 前在 numpy 空间做,保持 float32) - fixed_range:固定物理范围 min-max → [0,1](clamp 防御,免 stats); + fixed_range / log_range:固定物理范围 → [0,1](clamp 防御,免 stats); 否则 z-score(_action_mean 为 None 时直通原始值)。 """ - if self._fixed_range: - return normalize_action(a, self.action_dim) + if self._range_mode: + return normalize_action(a, self.action_dim, mode=self._range_mode) if self._action_mean is None: return a return (a - self._action_mean) / self._action_std diff --git a/actpred/export_deploy.py b/actpred/export_deploy.py index a698e95..557f446 100644 --- a/actpred/export_deploy.py +++ b/actpred/export_deploy.py @@ -173,15 +173,17 @@ def check_token_ranges(wrapper: "DeployWrapper", frame_len: int, height: int, 全部量化成 0),HEF 输出对任何输入恒定。必须在编译前拦截。 归一化模型(normalize_actions)的 HEF 输入是归一化后的历史, - 检查输入须与推理端一致(zscore → z 空间采样;fixed_range → [0,1])。 + 检查输入须与推理端一致(zscore → z 空间采样;fixed_range / + log_range → [0,1])。 """ rng = np.random.default_rng(42) raw = rng.integers(0, 256, size=(height, width, 3), dtype=np.uint8) norm = (raw.astype(np.float32) / 255.0 - IMAGENET_MEAN) / IMAGENET_STD frames = [torch.tensor(norm[None]) for _ in range(frame_len)] history = rng.uniform(0.0, 500.0, size=(1, 1, history_len, action_dim)) - if normalize_mode == "fixed_range": - # fixed_range 归一化空间即 [0,1],直接在归一化空间采样 + from action_norm import is_range_mode + if is_range_mode(normalize_mode): + # fixed_range / log_range 归一化空间即 [0,1],直接在归一化空间采样 history = rng.uniform(0.0, 1.0, size=(1, 1, history_len, action_dim)) elif action_stats_path is not None: stats = np.load(action_stats_path) @@ -235,9 +237,10 @@ def main(): h, w = cfg.data.image_height, cfg.data.image_width f = cfg.data.frame_len stats_path = None - if cfg.data.normalize_actions and cfg.data.normalize_mode != "fixed_range": + from action_norm import is_range_mode + if cfg.data.normalize_actions and not is_range_mode(cfg.data.normalize_mode): # 与 predict.py 语义一致:相对路径按 checkpoint 目录解析 - # (fixed_range 模式免 stats 文件,跳过) + # (fixed_range / log_range 模式免 stats 文件,跳过) stats_path = cfg.data.action_stats_path if not stats_path: stats_path = os.path.join( diff --git a/actpred/gen_calibration.py b/actpred/gen_calibration.py index 3b5ef6d..6103dee 100644 --- a/actpred/gen_calibration.py +++ b/actpred/gen_calibration.py @@ -101,11 +101,13 @@ def build_deploy_set(args): history = build_action_history_samples( args.mt_dir, args.samples, cfg.history_len, cfg.action_dim, rng) history = history[:, None, :, :] # (N, 1, H_len, D) - if cfg.normalize_actions and cfg.normalize_mode == "fixed_range": - # 固定物理范围归一化:校准数据与推理端同一变换(免 stats 文件) - from action_norm import normalize_action - history = normalize_action(history, cfg.action_dim) - print(" action_history: 已 fixed_range 归一化(固定物理范围)") + if cfg.normalize_actions and is_range_mode(cfg.normalize_mode): + # 固定范围类归一化:校准数据与推理端同一变换(免 stats 文件) + from action_norm import normalize_action, is_range_mode + history = normalize_action(history, cfg.action_dim, + mode=cfg.normalize_mode) + print(f" action_history: 已 {cfg.normalize_mode} 归一化" + f"(固定物理范围)") elif cfg.normalize_actions: # z-score 归一化模型的 HEF 输入是 z-score 历史(hailo_infer --stats 同语义), # 校准数据必须走同一变换,否则量化范围与推理分布不匹配 diff --git a/actpred/hailo_infer.py b/actpred/hailo_infer.py index ccc39dc..7641c46 100644 --- a/actpred/hailo_infer.py +++ b/actpred/hailo_infer.py @@ -36,7 +36,8 @@ import numpy as np from PIL import Image from imu_norm import normalize_imu -from action_norm import normalize_action, denormalize_action +from action_norm import (normalize_action, denormalize_action, + is_range_mode) from utils import parse_action_history IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) @@ -69,13 +70,14 @@ class HailoPredictor: pred_token: np.ndarray 或 .npy 路径;export_deploy.py --pred-token-out 产物。缺省时喂零(仅适合 pred_token 恰好全零的模型)。 normalize_mode: 与训练时 DataConfig.normalize_mode 一致。 - "zscore"(默认)需传 stats_path;"fixed_range" 免 stats 文件, - 按固定物理范围 min-max 归一化(action_norm.py)。 + "zscore"(默认)需传 stats_path;"fixed_range" / "log_range" + 免 stats 文件,按固定物理范围归一化(action_norm.py)。 """ from hailo_platform import HEF, VDevice self._predict_delta = predict_delta - self._fixed_range = normalize_mode == "fixed_range" + self._range_mode = (normalize_mode + if is_range_mode(normalize_mode) else None) self._default_pred_token = None if pred_token is not None: if isinstance(pred_token, str): @@ -105,7 +107,7 @@ class HailoPredictor: self._action_mean = None self._action_std = None - if stats_path is not None and not self._fixed_range: + if stats_path is not None and not self._range_mode: if not os.path.exists(stats_path): raise FileNotFoundError(f"统计文件不存在: {stats_path}") stats = np.load(stats_path) @@ -247,8 +249,9 @@ class HailoPredictor: raise ValueError( f"action_history 形状应为 {expected},实际为 {action_history.shape}") - if self._fixed_range: - action_history = normalize_action(action_history, self.action_dim) + if self._range_mode: + action_history = normalize_action(action_history, self.action_dim, + mode=self._range_mode) elif self._action_mean is not None: action_history = (action_history - self._action_mean) / self._action_std @@ -310,8 +313,9 @@ class HailoPredictor: if self._predict_delta: # 残差模式:Δ̂ + a_{t-1}(归一化空间),与 predict.py 对齐 result = result + action_history[-1] - if self._fixed_range: - result = denormalize_action(result, self.action_dim) + if self._range_mode: + result = denormalize_action(result, self.action_dim, + mode=self._range_mode) elif self._action_mean is not None: result = result * self._action_std + self._action_mean if not np.all(np.isfinite(result)): diff --git a/actpred/onnx_infer.py b/actpred/onnx_infer.py index 10ba1ff..9e2ed6b 100644 --- a/actpred/onnx_infer.py +++ b/actpred/onnx_infer.py @@ -18,7 +18,8 @@ import numpy as np from PIL import Image from imu_norm import normalize_imu -from action_norm import normalize_action, denormalize_action +from action_norm import (normalize_action, denormalize_action, + is_range_mode) IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) @@ -75,10 +76,12 @@ class OnnxPredictor: meta = self._read_metadata() if normalize_mode is None: normalize_mode = meta.get("normalize_mode") or "zscore" - if normalize_mode not in ("zscore", "fixed_range", "none"): + if normalize_mode not in ("zscore", "fixed_range", "none", + "log_range"): raise ValueError(f"normalize_mode={normalize_mode!r} 非法," - "应为 zscore/fixed_range/none") - self._fixed_range = normalize_mode == "fixed_range" + "应为 zscore/fixed_range/none/log_range") + self._range_mode = (normalize_mode + if is_range_mode(normalize_mode) else None) self._no_norm = normalize_mode == "none" self.normalize_mode = normalize_mode # 解析后的最终模式(供调用方判断) # R1-C 残差预测:元数据 predict_delta=1 时模型输出 Δ̂, @@ -88,7 +91,7 @@ class OnnxPredictor: self._action_mean = None self._action_std = None - if stats_path is not None and not (self._fixed_range or self._no_norm): + if stats_path is not None and not (self._range_mode or self._no_norm): if not os.path.exists(stats_path): raise FileNotFoundError(f"统计文件不存在: {stats_path}") stats = np.load(stats_path) @@ -158,8 +161,9 @@ class OnnxPredictor: raise ValueError( f"action_history 形状应为 {expected},实际为 {action_history.shape}") - if self._fixed_range: - action_history = normalize_action(action_history, self.action_dim) + if self._range_mode: + action_history = normalize_action(action_history, self.action_dim, + mode=self._range_mode) elif self._action_mean is not None: action_history = (action_history - self._action_mean) / self._action_std @@ -175,8 +179,9 @@ class OnnxPredictor: if self._predict_delta: # 残差:Δ̂ + a_{t-1}(归一化空间,与 predict.py 对齐) result = result + action_history[-1] - if self._fixed_range: - result = denormalize_action(result, self.action_dim) + if self._range_mode: + result = denormalize_action(result, self.action_dim, + mode=self._range_mode) elif self._action_mean is not None: result = result * self._action_std + self._action_mean return result @@ -200,7 +205,8 @@ def main(): help="冒号分隔各步,每步 action_dim 个值") parser.add_argument("--stats", default=None, help="action_stats.npy 路径(zscore 归一化训练才需要)") - parser.add_argument("--normalize-mode", choices=["zscore", "fixed_range", "none"], + parser.add_argument("--normalize-mode", + choices=["zscore", "fixed_range", "none", "log_range"], default=None, help="与训练时 normalize_mode 一致;缺省从 ONNX 元数据" "自动识别(旧模型回退 zscore)") diff --git a/actpred/predict.py b/actpred/predict.py index 0dd6336..70e0bb3 100644 --- a/actpred/predict.py +++ b/actpred/predict.py @@ -8,7 +8,8 @@ from torchvision import transforms from config import parse_checkpoint_config from imu_norm import normalize_imu -from action_norm import normalize_action, denormalize_action +from action_norm import (normalize_action, denormalize_action, + is_range_mode) from model import ActionPredictor from utils import parse_action_history @@ -50,11 +51,14 @@ class ActionPredictorInference: self._action_mean = None self._action_std = None - # fixed_range:固定物理范围 min-max(免 stats 文件);旧 checkpoint 无 - # normalize_mode 键,parse_checkpoint_config 默认 "zscore",行为不变 - self._fixed_range = (self._data_cfg.normalize_actions - and self._data_cfg.normalize_mode == "fixed_range") - if self._data_cfg.normalize_actions and not self._fixed_range: + # fixed_range / log_range:固定物理范围 → [0,1](免 stats 文件); + # 旧 checkpoint 无 normalize_mode 键,parse_checkpoint_config + # 默认 "zscore",行为不变 + self._range_mode = (self._data_cfg.normalize_mode + if (self._data_cfg.normalize_actions + and is_range_mode(self._data_cfg.normalize_mode)) + else None) + if self._data_cfg.normalize_actions and not self._range_mode: # 优先使用 checkpoint config 记录的 stats 路径(相对路径按 checkpoint 目录解析), # 旧 checkpoint 无该字段时回退到 checkpoint 同目录的 action_stats.npy stats_path = self._data_cfg.action_stats_path @@ -100,9 +104,10 @@ class ActionPredictorInference: frames.append(self._transform(img)) img_tensor = torch.stack(frames).unsqueeze(0).to(self._device) # (1, F, 3, S, S) history = torch.tensor(action_history, dtype=torch.float32) - if self._fixed_range: + if self._range_mode: history = torch.from_numpy( - normalize_action(action_history, self.action_dim)) + normalize_action(action_history, self.action_dim, + mode=self._range_mode)) elif self._action_mean is not None: history = (history - self._action_mean) / self._action_std history = history.unsqueeze(0).to(self._device) @@ -115,8 +120,9 @@ class ActionPredictorInference: if self._predict_delta: # 残差:Δ̂ 加回 a_{t-1}(history 末步,同为归一化空间)→ 绝对动作 pred = history[0, -1] + pred - if self._fixed_range: - result = denormalize_action(pred.cpu().numpy(), self.action_dim) + if self._range_mode: + result = denormalize_action(pred.cpu().numpy(), self.action_dim, + mode=self._range_mode) else: if self._action_mean is not None: pred = pred * self._action_std + self._action_mean diff --git a/actpred/pretrain_mt.py b/actpred/pretrain_mt.py index 9ab5b45..33d08d3 100644 --- a/actpred/pretrain_mt.py +++ b/actpred/pretrain_mt.py @@ -33,7 +33,8 @@ from config import (Config, build_argparser, apply_overrides, validate_config_values, warn_init_from_mismatch) from model import ActionPredictor from mt_parser import parse_mt_directory -from action_norm import normalize_action, default_column_weights +from action_norm import (normalize_action, default_column_weights, + is_range_mode) from train import _weighted_mse _IMAGENET_MEAN = [0.485, 0.456, 0.406] @@ -54,9 +55,12 @@ class MTActionDataset(Dataset): self.action_dim = action_dim self._action_mean = action_mean self._action_std = action_std - # 固定物理范围归一化(免统计量;action_mean 传入时优先 z-score) - self._fixed_range = (normalize_mode == "fixed_range" - and action_mean is None) + # 固定范围类归一化(fixed_range / log_range,免统计量; + # action_mean 传入时优先 z-score) + self._range_mode = (normalize_mode + if (is_range_mode(normalize_mode) + and action_mean is None) + else None) self.samples = [] # 复用调用方已解析的结果(避免 train/val 各自重复解析同一目录) @@ -89,9 +93,11 @@ class MTActionDataset(Dataset): def __getitem__(self, idx): history, target = self.samples[idx] - if self._fixed_range: - history = normalize_action(history, self.action_dim) - target = normalize_action(target, self.action_dim) + if self._range_mode: + history = normalize_action(history, self.action_dim, + mode=self._range_mode) + target = normalize_action(target, self.action_dim, + mode=self._range_mode) history = torch.from_numpy(history.copy()) target = torch.from_numpy(target.copy()) if self._action_mean is not None: @@ -220,9 +226,10 @@ def main(): # targets 计算并保存到 checkpoint_dir,验证集复用同一 stats; # fixed_range 模式免统计量,跳过此段 action_mean = action_std = None - if cfg.data.normalize_actions and cfg.data.normalize_mode == "fixed_range": - # 构造时 action_mean=None 已置 _fixed_range,这里只打印口径 - print("动作归一化: fixed_range(固定物理范围 min-max→[0,1],免 stats)") + if cfg.data.normalize_actions and is_range_mode(cfg.data.normalize_mode): + # 构造时 action_mean=None 已置 _range_mode,这里只打印口径 + print(f"动作归一化: {cfg.data.normalize_mode}" + f"(固定物理范围→[0,1],免 stats)") elif cfg.data.normalize_actions: if cfg.data.action_stats_path: if not os.path.exists(cfg.data.action_stats_path): diff --git a/actpred/test_actpred.py b/actpred/test_actpred.py index 140bf38..32cf776 100644 --- a/actpred/test_actpred.py +++ b/actpred/test_actpred.py @@ -2074,7 +2074,7 @@ class TestFixedRangeNorm(unittest.TestCase): image_width=32, image_height=32, frame_len=3, normalize_actions=True) self.assertIsNotNone(ds._action_mean) - self.assertFalse(ds._fixed_range) + self.assertIsNone(ds._range_mode) def test_mt_dataset_fixed_range(self): """MTActionDataset fixed_range:免统计量,按物理范围归一化""" @@ -2254,6 +2254,98 @@ class TestR2ImuNorm(unittest.TestCase): out, ([1.0, -1.0] + [1.0] * 6) * 2, atol=1e-6) +class TestLogRangeNorm(unittest.TestCase): + """log_range 归一化:spk 两列对数尺度,其余列与 fixed_range 一致""" + + def test_spk_cols_log_normalized(self): + """spk.F/spk.delay 按 log10(1+x)/log10(1+hi) 归一化,低端值铺开""" + from action_norm import normalize_action, action_bounds + a = np.zeros(18, dtype=np.float32) + a[10] = 10.0 # 常见频率:线性尺度 0.002 → 对数 0.27 + a[11] = 10.0 # 常见时延:线性尺度 0.005 → 对数 0.31 + out = normalize_action(a, mode="log_range") + _, hi = action_bounds(18) + self.assertAlmostEqual(float(out[10]), + float(np.log10(11.0) / np.log10(hi[10] + 1.0)), + places=6) + self.assertAlmostEqual(float(out[11]), + float(np.log10(11.0) / np.log10(hi[11] + 1.0)), + places=6) + self.assertGreater(float(out[10]), 0.25) # 不再挤在 0.002 + self.assertGreater(float(out[11]), 0.25) + + def test_servo_cols_stay_linear(self): + """log_range 下舵机列与 fixed_range 完全一致""" + from action_norm import normalize_action + a = np.zeros(18, dtype=np.float32) + a[:10] = [0.0, 45.0, 90.0, 135.0, 180.0, 0.0, 45.0, 90.0, 135.0, 180.0] + lin = normalize_action(a, mode="fixed_range") + log = normalize_action(a, mode="log_range") + np.testing.assert_allclose(log[:10], lin[:10], atol=1e-7) + np.testing.assert_allclose(log[12:], lin[12:], atol=1e-7) + + def test_log_roundtrip(self): + """log_range 归一化→反归一化往返一致(spk 列含 0 与常规值)""" + from action_norm import normalize_action, denormalize_action + rng = np.random.RandomState(1) + a = np.zeros((9, 18), dtype=np.float32) + a[:, :10] = rng.uniform(0, 180, (9, 10)) + a[:, 10] = [0.0, 10.0, 100.0, 1000.0, 5000.0, 0.0, 10.0, 100.0, 1000.0] + a[:, 11] = [0.0, 10.0, 200.0, 2000.0, 0.0, 10.0, 200.0, 2000.0, 500.0] + rt = denormalize_action(normalize_action(a, mode="log_range"), + mode="log_range") + np.testing.assert_allclose(rt, a, rtol=1e-5, atol=1e-4) + + def test_negative_log_col_clipped(self): + """log 列负值先截 0(log10(1+x) 定义域 x>-1),不产生 NaN""" + from action_norm import normalize_action + a = np.zeros(18, dtype=np.float32) + a[10] = -50.0 + a[11] = -1.0 + out = normalize_action(a, mode="log_range") + self.assertEqual(float(out[10]), 0.0) + self.assertEqual(float(out[11]), 0.0) + self.assertTrue(np.all(np.isfinite(out))) + + def test_dataset_log_range_mode(self): + """dataset normalize_mode="log_range" 时目标按对数尺度归一化""" + with tempfile.TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + ep_dir = base / "episode_0001" + images_dir = ep_dir / "images" + images_dir.mkdir(parents=True) + for i in range(8): + Image.fromarray(np.random.randint( + 0, 255, (16, 16, 3), dtype=np.uint8)).save( + images_dir / f"{i:04d}.jpg") + actions = np.zeros((8, 12), dtype=np.float32) + actions[:, 0] = 90.0 # 舵机 0.5 + actions[:, 10] = 10.0 # spk.F → log 尺度 + actions[:, 11] = 10.0 # spk.delay → log 尺度 + np.save(ep_dir / "actions.npy", actions) + ds = ActionDataset(tmpdir, history_len=3, action_dim=12, + image_width=16, image_height=16, frame_len=3, + normalize_actions=True, + normalize_mode="log_range") + self.assertIsNone(ds._action_mean) + self.assertIsNone(ds._action_std) + s = ds[3] + tgt = s["target"].numpy() + self.assertAlmostEqual(float(tgt[0]), 0.5, places=6) + self.assertAlmostEqual(float(tgt[10]), + float(np.log10(11.0) / np.log10(5001.0)), + places=6) + + def test_config_accepts_log_range(self): + """normalize_mode="log_range" 通过校验;非法模式拒绝""" + cfg = Config() + cfg.data.normalize_mode = "log_range" + validate_config_values(cfg.data, cfg.model) + cfg.data.normalize_mode = "log10" + with self.assertRaises(ValueError): + validate_config_values(cfg.data, cfg.model) + + class TestR2ImuModel(unittest.TestCase): """R2 方案一模型:独立 IMU token 注入(序列 F+H+2)""" diff --git a/docs/reports/actpred_onehot_multihead_dev_plan_20260814.md b/docs/reports/actpred_onehot_multihead_dev_plan_20260814.md new file mode 100644 index 0000000..54b51d7 --- /dev/null +++ b/docs/reports/actpred_onehot_multihead_dev_plan_20260814.md @@ -0,0 +1,82 @@ +# actpred 多头 one-hot 分类输出开发方案 + +> 文档类型:开发方案 +> 模块:actpred(联动 pybot 数据契约) +> 作者:自动生成 +> 日期:2026-08-14 +> 前置实验:`pybot_actpred_dataset_training_eval_20260814.md`、 +> `actpred_fixed_range_weighted_experiment_20260814.md`(模型 A-E 系列) + +--- + +## 1. 背景与动机 + +模型 A-E 系列实验定位到两个结构性难题: + +1. **尺度放大**:fixed_range 线性 min-max 下 spk.F 的 1 个归一化单位 = 5000Hz 原始量,闭环反馈中误差滚雪球(模型 C/D 发散根因之一) +2. **精度错配**:常用值(10Hz → 0.002)挤在线性尺度的低端,[0,1] 精度预算被浪费(log_range 正在验证的对数尺度解法) + +多头 one-hot 是本问题的**结构性解法**:把 18 维连续回归换成 18 组独立分类头,每组输出一个 one-hot 分布。分类天然消除尺度放大(每箱等距)与物理界问题(箱只覆盖物理范围),且多列 softmax 是 NPU(Hailo)最擅长的计算形态。 + +## 2. 方案设计 + +### 2.1 分箱设计(每组 one-hot 的维度) + +| 列组 | 分箱方式 | 箱数/列 | 依据 | +|---|---|---|---| +| 舵机 0-9(10 列) | 均匀 **1°/箱** | 180 | 匹配数据粒度:微动作插值 0.75°/步,5° 箱会把相邻步量化进同一箱、毁掉插值结构 | +| spk.F(10) | 对数分箱 | 32 | 频率对数感知,低端聚集;log10(1+x) 均匀切 | +| spk.delay(11) | 对数分箱 | 32 | 同左 | +| 保留位 12-17 | 无头 | 0 | 恒 0,不参与 | + +**输出总量**:10×180 + 32 + 32 = **1864 logits**(当前 18)。最后层 128→1864 ≈ 238K 参数,模型 409K → ~650K,仍属轻量。 + +### 2.2 各层改动 + +| 文件 | 改动 | +|---|---| +| `actpred/bins.py`(新增) | 分箱表 + `encode(x)→one-hot` / `decode(p)→连续值`(含对数箱);纯 numpy,供推理/部署复用 | +| `model.py` | PredictionHead 输出 1864 维 → reshape (B, 18, bins) → 逐组 softmax | +| `dataset.py` | target 从 (B,18) 连续值 → 每组 one-hot;**软标签**:真值高斯扩散到 ±1-2 箱 | +| `train.py` | 损失 = Σ 列权重 × CE(组输出, one-hot 真值);列权重沿用 default_column_weights | +| `predict.py` | 输出 decode:**期望值** `Σ pᵢ·箱中心`(推荐)或 argmax 箱中心 → 18 维原始量纲 | +| `evaluate.py` | 无感——predict 返回仍是 18 维原始量纲 | +| `export_deploy` / Hailo | 多组 softmax 为 NPU 友好形态 | + +### 2.3 解码策略(执行时如何取连续值) + +| 方式 | 行为 | 评估 | +|---|---|---| +| **期望值**(推荐) | `Σ pᵢ × 箱中心` | 平滑连续,分辨率超越箱宽(1° 箱可出 46.4°);单峰假设 | +| argmax | 取最大概率箱中心 | 确定性,量化到 1° | +| 按分布采样 | 随机抽箱 | 探索用,后续离线 RL 可复用 | + +## 3. 关键决策点与理由 + +1. **舵机 1°/箱而非 5°/箱**:录制数据的微动作插值粒度为 0.75°,箱宽必须匹配数据粒度;5° 箱会把 45.0°/45.75° 压进同一箱,模型学不到特意录制的插值结构 +2. **软标签**:804 样本对 1864 logits 偏少,多数箱极少被访问。真值 one-hot 高斯扩散到邻近箱,未访问箱也能获得梯度 +3. **期望值解码补序数信息**:one-hot 丢失"45° 和 46° 相邻"先验,期望值解码在推理端部分恢复;训练端靠软标签缓解 +4. **pybot 契约零变化**:predict 输出仍是 18 维原始量纲,闭环与录制数据无感知 +5. **与现有归一化链路的兼容**:one-hot 方案独立于 zscore/fixed_range/log_range——分箱编码替代归一化,不叠加使用 + +## 4. 实施步骤 + +| 步骤 | 内容 | 产出 | +|---|---|---| +| 1 | `bins.py`:分箱表 + encode/decode + 对数箱 + 软标签工具 | 独立纯 numpy 模块 + 单测 | +| 2 | `dataset.py`:目标 one-hot 化(软标签可开关) | 数据链路 + 单测 | +| 3 | `model.py` + `train.py`:多头 softmax + 加权 CE | 模型与训练 + 单测 | +| 4 | `predict.py`:期望值/argmax 解码集成 + 冒烟 | 推理链路 + 单测 | +| 5 | 训练模型 F,与 A-E 闭环五方对比 | 对比报告 | + +## 5. 与其他方案的定位 + +- **模型 E(log_range)正在训练**:若对数尺度已解决精度错配与闭环发散,one-hot 降级为可选优化(分类头天然有界、免归一化常量表的优势仍在) +- 若 E 仍失败:one-hot 是下一个强候选(结构性消除尺度放大) +- **互补关系**:one-hot 方案与逐列权重、R1-B rollout 正交,可叠加(rollout 治 exposure bias 的结论不变,任何损失形式都需要它) + +## 6. 决策记录 + +- **箱宽**:初拟 5°/箱(保守,顾虑数据量),讨论后定为 1°/箱——数据插值粒度 0.75° 决定箱宽下限;spk 两列对数 32 箱 +- **单头多选 vs 多头单选**:softmax 单头在数学上是单选择分布;"多组 one-hot" = 18 个独立 softmax 头并行,每列一个选择 +- **多峰分布**:当前数据单峰为主,期望值解码够用;若未来出现双峰(如跨脚本姿态冲突),升级为逐箱 sigmoid 或 MDN