feat(actpred): 固定范围归一化 + 逐列加权损失,附 pybot*actpred 审计修复

新增 action_norm.py 固定物理范围 min-max 归一化,无 stats 文件零状态部署;train.py 引入 _weighted_mse 逐列加权(舵机 1.0/spk 0.2/保留位 0.0)。审计修复: P0-1 pybot 读 normalize_mode, R2-1 NaN 阻断 best, P2-a 后端大小写不敏感, P1-3 utf-8-sig BOM, R1-1 data_dir 覆盖, R2-7 _run.sh 报错, R2-3 空 val 存 best.pt, P2-a2 误报警, R1-2 合并 parse_action_history, P2-b 合并 numpy import。评估修正 oob 物理界+error_growth 按 episode 中位数。推理部署全链支持 fixed_range。
This commit is contained in:
qing committed 2026-08-14 18:26:03 +08:00
1 parent adfa61aa3c
commit 85bf68f505
19 files changed
+874 -167

No files matched your search

+19 -7
View File
@@ -44,6 +44,13 @@ bash _run.sh train --data-dir ./data --epochs 100 --action-dim 18 \
# R2 姿态反馈:--imu-dim 8 启用独立 IMU token(数据需含 imu.npy,见"数据格式")
bash _run.sh train --data-dir ./data --epochs 100 --action-dim 18 --imu-dim 8
# 归一化+逐列加权:固定物理范围归一化(免 stats 文件)+ 舵机列损失权重上浮
bash _run.sh train --data-dir ./data --epochs 100 --action-dim 18 \
--normalize-actions --normalize-mode fixed_range
# 自定义列权重(逗号分隔 action_dim 个浮点;默认舵机1.0/spk0.2/保留位0.0)
bash _run.sh train --data-dir ./data --epochs 100 --normalize-actions \
--normalize-mode fixed_range --column-weights "1,1,1,1,1,1,1,1,1,1,0.2,0.2,0,0,0,0,0,0"
# 推理(--images 按时间顺序旧→新传入 frame_len 个图像路径,冷启动时可重复传同一帧;
# --action-history 格式:冒号分隔各步,每步 action_dim 个值,共 history_len 步)
bash _run.sh --model checkpoints/best_inference.pt \
@@ -161,6 +168,8 @@ recorder.flush()
| `--lr` | 0.001 | 学习率 |
| `--batch-size` | 4 | 批次大小(默认分辨率 640×480;更高分辨率需调小,小分辨率可调大) |
| `--normalize-actions` | False | 动作归一化(`--no-normalize-actions` 关闭) |
| `--normalize-mode` | zscore | 归一化方式:`zscore`=数据统计(依赖 `action_stats.npy`);`fixed_range`=固定物理范围 min-max→[0,1](常量表见 `action_norm.py`,免 stats 文件,越界 clamp 防御) |
| `--column-weights` | 舵机1.0/spk0.2/保留位0.0 | 逐列损失权重(逗号分隔 action_dim 个浮点;仅影响训练损失,推理端无感知) |
| `--dropout` | 0.1 | Dropout 率 |
| `--weight-decay` | 0.0001 | 权重衰减 |
| `--val-split` | 0.15 | 验证集比例 |
@@ -185,21 +194,24 @@ recorder.flush()
| 指标 | 含义 | 判读 |
|------|------|------|
| `overall_mse` | 全部滚动步的平均 MSE | 越低越好 |
| `error_growth` | 末段(后25%)误差 / 首段(前25%)误差 | >1 表示误差随步数发散(闭环不稳) |
| `error_growth` | 末段(后25%)误差 / 首段(前25%)误差(按 episode 分别计算后取中位数) | >1 表示误差随步数发散(闭环不稳) |
| `error_curve` | 误差随步数的增长曲线 | 看发散拐点 |
| `smoothness_ratio` | 预测平滑度 / 真值平滑度 | <1 表示比真值更平滑(残差模式的预期收益) |
| `oob_rate` | 预测落在训练数据经验范围外的比例 | 越高越危险(舵机越界风险) |
| `oob_rate` | 预测落在物理范围外的比例 | 越高越危险(舵机越界风险) |
物理可行性用数据分布的经验界(min/max + 10% margin)而非硬编码范围——18 列混含
舵机角度/频率/延时/预留,无统一硬限位。`--compare` 用于 A/B 对比(如 R1 开/关)。
越界判定用固定物理界(`action_norm.py` 常量表:舵机 [0,180]、spk.F [0,5000] 等),
"越物理界"比"越经验界"更符合安全语义,且恒值列不会使经验界退化。`--compare` 用于 A/B 对比(如 R1 开/关)。
滚动复现闭环口径:模型带 imu 输入时按 episode 的 `imu.npy` 真值姿态逐步喂入
(缺失则零向量,与 pybot 闭环无 `@MPU6050_BIND` 块时的行为一致)。
## 部署注意
- 启用 `--normalize-actions` 训练的模型,其 `action_stats.npy` 保存在 checkpoint 同目录;
部署时**必须将 stats 文件与 checkpoint 一起拷贝**(推理端优先按 checkpoint 内记录的路径查找,
找不到时回退到 checkpoint 同目录的 `action_stats.npy`)。
- 启用 `--normalize-actions` 且 `--normalize-mode zscore`(默认)训练的模型,其 `action_stats.npy`
保存在 checkpoint 同目录;部署时**必须将 stats 文件与 checkpoint 一起拷贝**(推理端优先按
checkpoint 内记录的路径查找,找不到时回退到 checkpoint 同目录的 `action_stats.npy`)。
- `--normalize-mode fixed_range` 用固定物理范围 min-max→[0,1](常量表见 `action_norm.py`),
**免 stats 文件**,消除 stats 路径类 bug 的整类风险;推理端(predict/onnx_infer/hailo_infer)
从 checkpoint/ONNX 元数据自动识别归一化方式,无需额外参数。
- `actions.npy` 的列数必须与 `--action-dim` 一致;图像与动作帧数不一致时训练会打印 WARNING 并按较少者使用。
## 编程 API
+5 -1
View File
@@ -11,6 +11,10 @@ elif [ "${1:-}" = "pretrain" ]; then
elif [ "${1:-}" = "export" ]; then
shift
exec python3 export_onnx.py "$@"
else
elif [ "${1:-}" = "predict" ]; then
shift
exec python3 predict.py "$@"
else
echo "未知子命令: ${1:-}(可用: train / pretrain / export / predict)" >&2
exit 1
fi
+62
View File
@@ -0,0 +1,62 @@
"""动作向量固定物理范围归一化 —— 训练(dataset/pretrain_mt)与推理
(predict / onnx_infer / hailo_infer / gen_calibration / export_deploy)共用
18 列动作向量的物理量纲(pybot 侧契约):
0-9 舵机角度 [0, 180]°(pybot/hd/servo.py MAX_ANGLE=180.0 校准后全行程)
10 spk.F 扬声器频率 [0, 5000] Hz(@Speaker max_f=5000)
11 spk.delay 延时 [0, 2000] ms(脚本实用上限,std.mt 用 10-500)
12-17 保留位(恒 0,hi-lo=1.0 使归一化后仍为 0;损失权重 0 不参与优化)
min-max 映射到 [0,1]:x_norm = clip((x - lo) / (hi - lo), 0, 1)。
与 z-score 的区别:固定物理范围不依赖 action_stats.npy 数据统计文件,
部署链零状态;clamp 防御真实数据越界(越界值截断到 [0,1],不扩散)。
仅依赖 numpy,保证 hailo_infer.py(HAT 部署环境无 torch)可直接复用。
"""
import numpy as np
# 各列物理下界(当前全 0;保留数组以便未来引入非零下界列)
ACTION_LO = np.zeros(18, dtype=np.float32)
# 各列物理上界(18 列标准布局;action_dim 不同用 action_bounds() 适配)
ACTION_HI = np.array(
[180.0] * 10 + # 0-9 舵机角
[5000.0, 2000.0] + # 10 spk.F,11 spk.delay
[1.0] * 6, # 12-17 保留位(恒 0,span=1.0 保持归一化后为 0)
dtype=np.float32)
def action_bounds(action_dim: int = 18):
"""(lo, hi) 物理界:action_dim ≤ 18 取前缀;> 18 的超出列按 [0, 1] 补足"""
if action_dim <= len(ACTION_LO):
return ACTION_LO[:action_dim], ACTION_HI[:action_dim]
pad = action_dim - len(ACTION_LO)
return (np.concatenate([ACTION_LO, np.zeros(pad, dtype=np.float32)]),
np.concatenate([ACTION_HI, np.ones(pad, dtype=np.float32)]))
def normalize_action(action, action_dim: int = None) -> np.ndarray:
"""原始动作向量 → [0,1] 归一化向量(clamp 防御越界值)
action: (..., action_dim) 任意前导维;action_dim 缺省取末维长度。
"""
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)
def denormalize_action(norm_action, action_dim: int = None) -> np.ndarray:
"""[0,1] 归一化向量 → 原始动作向量(不 clamp,保留模型外推能力)"""
n = np.asarray(norm_action, dtype=np.float32)
lo, hi = action_bounds(action_dim or n.shape[-1])
return n * (hi - lo) + lo
def default_column_weights(action_dim: int = 18) -> np.ndarray:
"""逐列损失权重默认表:舵机 1.0(安全关键)、spk 0.2(保留梯度)、
保留位 0.0(恒 0 纯噪声,参与只会稀释有效梯度)"""
w = np.zeros(action_dim, dtype=np.float32)
w[:min(10, action_dim)] = 1.0
if action_dim > 10:
w[10:min(12, action_dim)] = 0.2
return w
+44 -1
View File
@@ -1,6 +1,6 @@
import argparse
from dataclasses import dataclass, asdict, field
from typing import Optional
from typing import List, Optional
@dataclass
@@ -13,6 +13,15 @@ class DataConfig:
data_dir: str = "./data"
normalize_actions: bool = True
action_stats_path: Optional[str] = None
# 归一化方式(仅 normalize_actions=True 时生效):
# "zscore" 数据统计 z-score(现状,依赖 action_stats.npy)
# "fixed_range" 固定物理范围 min-max → [0,1](免 stats 文件,零状态部署,
# 常量表见 action_norm.py;越界值 clamp 防御)
normalize_mode: str = "zscore"
# 逐列损失权重(仅训练损失用,推理端无感知):None → action_norm
# .default_column_weights(action_dim)(舵机 1.0 / spk 0.2 / 保留位 0.0)。
# 显式指定时长度须等于 action_dim。
column_weights: Optional[List[float]] = None
# R2 方案一:MPU6050 姿态作为独立 IMU token 注入(序列 F+H+2)。
# 0 = 关闭(默认,序列保持 F+H+1,旧 checkpoint 完全兼容);
# 8×N = N 个传感器的 roll,pitch,ax,ay,az,gx,gy,gz 按 pybot 脚本
@@ -84,6 +93,13 @@ def build_argparser(description: str = "") -> argparse.ArgumentParser:
parser.add_argument("--data-dir", type=str, default=None)
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"],
help="动作归一化方式:zscore=数据统计(依赖 action_stats.npy);"
"fixed_range=固定物理范围 min-max→[0,1](免 stats 文件)")
parser.add_argument("--column-weights", type=str, default=None,
help="逐列损失权重(逗号分隔 action_dim 个浮点),"
"缺省用默认表(舵机1.0/spk0.2/保留位0.0);仅影响训练损失")
parser.add_argument("--imu-dim", type=int, default=None,
help="R2 方案一:MPU6050 姿态维度(0=关闭,默认;"
"8×N=N 个传感器拼接(≤4 个),序列变 F+H+2)")
@@ -156,6 +172,24 @@ 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"):
raise ValueError(
f"normalize_mode={data_cfg.normalize_mode!r} 非法,"
f"应为 'zscore' 或 'fixed_range'")
if data_cfg.column_weights is not None:
w = data_cfg.column_weights
if len(w) != data_cfg.action_dim:
raise ValueError(
f"column_weights 长度 {len(w)} 应等于 action_dim="
f"{data_cfg.action_dim}")
if any((not isinstance(v, (int, float))) or isinstance(v, bool)
or not (0.0 <= v <= 1e6) for v in w):
raise ValueError(
f"column_weights 元素应为 [0, 1e6] 内的数值,实际: {w}")
if not any(v > 0 for v in w):
raise ValueError("column_weights 全 0:损失恒 0,训练无梯度")
if train_cfg is not None:
t_checks = [
("batch_size", train_cfg.batch_size, 1, 65536, int),
@@ -194,6 +228,11 @@ def warn_init_from_mismatch(init_cfg: dict, current: Config):
print(f"WARNING: init-from checkpoint 的 normalize_actions="
f"{init_norm} 与当前运行 {current.data.normalize_actions} "
f"不一致,输入分布不匹配会损失预训练收益(见 README 预训练说明)")
init_mode = init_data.get("normalize_mode", "zscore") # 旧 checkpoint 无此键 → zscore
if init_norm and current.data.normalize_actions and init_mode != current.data.normalize_mode:
print(f"WARNING: init-from checkpoint 的 normalize_mode={init_mode} "
f"与当前运行 {current.data.normalize_mode} 不一致,"
f"归一化空间不同会损失预训练收益")
init_dim = init_data.get("action_dim")
if init_dim is not None and init_dim != current.data.action_dim:
print(f"WARNING: init-from checkpoint 的 action_dim={init_dim} "
@@ -249,6 +288,8 @@ def apply_overrides(cfg: Config, args: argparse.Namespace) -> Config:
"data_dir": ("data", "data_dir"),
"normalize_actions": ("data", "normalize_actions"),
"action_stats_path": ("data", "action_stats_path"),
"normalize_mode": ("data", "normalize_mode"),
"column_weights": ("data", "column_weights"),
"imu_dim": ("data", "imu_dim"),
"backbone": ("model", "cnn_backbone"),
"cnn_feature_dim": ("model", "cnn_feature_dim"),
@@ -276,5 +317,7 @@ def apply_overrides(cfg: Config, args: argparse.Namespace) -> Config:
for arg_name, (section, field) in mapping.items():
val = args_dict.get(arg_name)
if val is not None:
if field == "column_weights" and isinstance(val, str):
val = [float(v) for v in val.split(",")]
setattr(getattr(cfg, section), field, val)
return cfg
+12 -3
View File
@@ -11,6 +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
def _natural_key(p):
@@ -25,9 +26,11 @@ class ActionDataset(Dataset):
action_stats_path: str = None, episode_indices: list = None,
frame_len: int = 3, augment: bool = False,
predict_delta: bool = False, rollout_len: int = 1,
imu_dim: int = 0):
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"
# R2 方案一:>0 时样本带 "imu" 键(归一化姿态向量,缺失 episode 补零)
self.imu_dim = imu_dim
self.image_width = image_width
@@ -80,7 +83,7 @@ class ActionDataset(Dataset):
self._action_mean = None
self._action_std = None
if normalize_actions:
if normalize_actions and not self._fixed_range:
if action_stats_path:
if not os.path.exists(action_stats_path):
raise FileNotFoundError(
@@ -110,7 +113,13 @@ class ActionDataset(Dataset):
return len(self.samples)
def _norm(self, a):
"""z-score 归一化(numpy → torch 前在 numpy 空间做,保持 float32)"""
"""动作归一化(numpy → torch 前在 numpy 空间做,保持 float32)
fixed_range:固定物理范围 min-max → [0,1](clamp 防御,免 stats);
否则 z-score(_action_mean 为 None 时直通原始值)。
"""
if self._fixed_range:
return normalize_action(a, self.action_dim)
if self._action_mean is None:
return a
return (a - self._action_mean) / self._action_std
+27 -17
View File
@@ -51,20 +51,15 @@ def _load_episode(data_dir, ep_name):
return image_files[:n], actions[:n], imus
def _action_bounds(data_dir, action_dim, margin_ratio=0.1):
"""从全数据集统计动作经验范围(min/max 加 margin),作为物理可行性判据。
def _action_bounds(action_dim):
"""固定物理界作为越界判据(与 action_norm.py 常量表共用)
actpred 侧无硬限位定义(18 列混含舵机角度/频率/延时/预留),硬编码范围不可靠;
用数据分布的经验界更通用。margin 默认 10% 容忍合理外推。
旧版用数据经验界(min/max+margin),恒值列 span≈0 时任何非零预测都
算越界(unnormalized 模型 oob_rate 曾恒 100%)。"越物理界"比"越经验界"
更符合安全语义:舵机 [0,180]、spk.F [0,5000]、spk.delay [0,2000]。
"""
mins = np.full(action_dim, np.inf, dtype=np.float32)
maxs = np.full(action_dim, -np.inf, dtype=np.float32)
for ep_dir in ActionDataset._valid_episode_dirs(data_dir):
a = np.load(ep_dir / "actions.npy").astype(np.float32)
mins = np.minimum(mins, a.min(axis=0))
maxs = np.maximum(maxs, a.max(axis=0))
span = np.maximum(maxs - mins, 1e-6)
return mins - margin_ratio * span, maxs + margin_ratio * span
from action_norm import action_bounds
return action_bounds(action_dim)
@np.errstate(all="ignore")
@@ -133,7 +128,12 @@ def _smoothness(seq):
def summarize(all_steps, lo, hi):
"""汇总多个 episode 的滚动记录为报告指标"""
"""汇总多个 episode 的滚动记录为报告指标
error_growth 改为按 episode 归一化后取中位数:
每 episode 内部算 (末25% MSE / 首25% MSE) → 中位数聚合。
避免短 episode 尖峰被长 episode 均值淹没,也更鲁棒于长度差异。
"""
if not all_steps:
raise ValueError("无可评估的滚动步(episode 太短或 horizon 过小)")
flat = [s for steps in all_steps for s in steps]
@@ -147,6 +147,18 @@ def summarize(all_steps, lo, hi):
if vals:
curve.append(float(np.mean(vals)))
# 逐 episode 归一化 error_growth:每 episode 自身首末段比值 → 中位数
growths = []
for steps in all_steps:
n = len(steps)
if n < 2:
continue
m = np.array([s["mse"] for s in steps], dtype=np.float32)
front = np.mean(m[:max(1, n // 4)])
back = np.mean(m[max(0, n * 3 // 4):])
growths.append(back / max(front, 1e-12))
error_growth = float(np.median(growths)) if growths else 1.0
preds = np.array([s["pred"] for s in flat], dtype=np.float32)
trues = np.array([s["true"] for s in flat], dtype=np.float32)
oob = [s.get("oob", False) for s in flat]
@@ -156,9 +168,7 @@ def summarize(all_steps, lo, hi):
"n_steps": len(flat),
"overall_mse": float(np.mean(mses)),
# 末段(后 25% 步)相对首段(前 25%)的误差比,>1 表示误差随步数发散
"error_growth": float(np.mean(mses[int(len(mses) * 0.75):]) /
max(np.mean(mses[:max(1, int(len(mses) * 0.25))]),
1e-12)),
"error_growth": error_growth,
"error_curve": curve,
"pred_smoothness": _smoothness(preds),
"true_smoothness": _smoothness(trues),
@@ -173,7 +183,7 @@ def evaluate(checkpoint, data_dir, episodes=None, horizon=None, device=None):
ep_names = _episode_ids(data_dir)
if episodes is not None:
ep_names = ep_names[-episodes:] # 取尾部 episode 作为留出集
lo, hi = _action_bounds(data_dir, predictor.action_dim)
lo, hi = _action_bounds(predictor.action_dim)
all_steps = []
for name in ep_names:
image_files, actions, imus = _load_episode(data_dir, name)
+13 -7
View File
@@ -158,7 +158,8 @@ class DeployWrapper(nn.Module):
def check_token_ranges(wrapper: "DeployWrapper", frame_len: int, height: int,
width: int, history_len: int, action_dim: int,
pred_token: torch.Tensor, max_abs: float = MAX_TOKEN_ABS,
action_stats_path: str = None):
action_stats_path: str = None,
normalize_mode: str = "zscore"):
"""导出前检查 token 量级:幅度过大时 uint8 量化会把小信号压成 0
预训练用全零图像占位 → BN running_var 停在初始小值 → 推理时每级
@@ -166,21 +167,24 @@ def check_token_ranges(wrapper: "DeployWrapper", frame_len: int, height: int,
幅度分配共享量化 scale(实测 scale=1965.92,pred_token/act_tokens
全部量化成 0),HEF 输出对任何输入恒定。必须在编译前拦截。
归一化模型(normalize_actions)的 HEF 输入是 z-score 后的历史,
检查输入须与推理端一致。
归一化模型(normalize_actions)的 HEF 输入是归一化后的历史,
检查输入须与推理端一致(zscore → z 空间采样;fixed_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 action_stats_path is not None:
if normalize_mode == "fixed_range":
# fixed_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)
if stats.shape != (2, action_dim):
raise ValueError(
f"action stats 形状应为 (2, {action_dim}),"
f"实际为 {stats.shape}: {action_stats_path}")
# 归一化模型直接在 z 空间采样:raw 均匀采样会在零方差列上
# z-score 归一化模型直接在 z 空间采样:raw 均匀采样会在零方差列上
# 产生天文数字 z 值(实际推理中这些列恒为 0,z 恒为 0)
history = rng.normal(0.0, 1.5, size=(1, 1, history_len, action_dim))
history = torch.tensor(history, dtype=torch.float32)
@@ -226,8 +230,9 @@ 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:
if cfg.data.normalize_actions and cfg.data.normalize_mode != "fixed_range":
# 与 predict.py 语义一致:相对路径按 checkpoint 目录解析
# (fixed_range 模式免 stats 文件,跳过)
stats_path = cfg.data.action_stats_path
if not stats_path:
stats_path = os.path.join(
@@ -241,7 +246,8 @@ def main():
f"模型训练时启用了动作归一化,但未找到统计文件: {stats_path}")
check_token_ranges(wrapper, f, h, w, cfg.data.history_len,
cfg.data.action_dim, model.pred_token.data.unsqueeze(1),
action_stats_path=stats_path)
action_stats_path=stats_path,
normalize_mode=cfg.data.normalize_mode)
dummy_frames = [torch.randn(1, h, w, 3) for _ in range(f)]
dummy_history = torch.randn(1, 1, cfg.data.history_len, cfg.data.action_dim)
dummy_pred = model.pred_token.data.unsqueeze(1).clone() # (1, 1, 1, d)
+7 -2
View File
@@ -101,8 +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:
# 归一化模型的 HEF 输入是 z-score 历史(hailo_infer --stats 同语义),
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 归一化(固定物理范围)")
elif cfg.normalize_actions:
# z-score 归一化模型的 HEF 输入是 z-score 历史(hailo_infer --stats 同语义),
# 校准数据必须走同一变换,否则量化范围与推理分布不匹配
stats_path = cfg.action_stats_path
if not stats_path:
+31 -21
View File
@@ -36,25 +36,12 @@ import numpy as np
from PIL import Image
from imu_norm import normalize_imu
from action_norm import normalize_action, denormalize_action
from utils import parse_action_history
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)
# 与 predict.parse_action_history 相同逻辑;此处内联以保持
# 本模块零 torch 依赖(HAT 部署环境只装 hailo_platform + numpy + PIL)
def parse_action_history(s: str, action_dim: int) -> np.ndarray:
steps = s.split(":")
history = []
for step in steps:
values = [float(v) for v in step.split(",")]
if len(values) != action_dim:
raise ValueError(
f"Expected {action_dim} values per step, got {len(values)}")
history.append(values)
return np.array(history, dtype=np.float32)
def _input_layer_index(name: str) -> int:
m = re.search(r"input_layer(\d+)$", name)
@@ -73,17 +60,22 @@ class HailoPredictor:
"""
def __init__(self, hef_path: str, stats_path: str = None, vdevice=None,
predict_delta: bool = False, pred_token=None):
predict_delta: bool = False, pred_token=None,
normalize_mode: str = "zscore"):
"""HEF 推理器。
predict_delta: 模型用 --predict-delta 训练时须置 True,推理输出
(归一化空间)会加回 history 末步再反归一化,与 predict.py 对齐。
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)。
"""
from hailo_platform import HEF, VDevice
self._predict_delta = predict_delta
self._fixed_range = normalize_mode == "fixed_range"
self._default_pred_token = None
if pred_token is not None:
if isinstance(pred_token, str):
@@ -113,7 +105,7 @@ class HailoPredictor:
self._action_mean = None
self._action_std = None
if stats_path is not None:
if stats_path is not None and not self._fixed_range:
if not os.path.exists(stats_path):
raise FileNotFoundError(f"统计文件不存在: {stats_path}")
stats = np.load(stats_path)
@@ -255,7 +247,9 @@ class HailoPredictor:
raise ValueError(
f"action_history 形状应为 {expected},实际为 {action_history.shape}")
if self._action_mean is not None:
if self._fixed_range:
action_history = normalize_action(action_history, self.action_dim)
elif self._action_mean is not None:
action_history = (action_history - self._action_mean) / self._action_std
from hailo_platform import FormatType
@@ -316,7 +310,9 @@ class HailoPredictor:
if self._predict_delta:
# 残差模式:Δ̂ + a_{t-1}(归一化空间),与 predict.py 对齐
result = result + action_history[-1]
if self._action_mean is not None:
if self._fixed_range:
result = denormalize_action(result, self.action_dim)
elif self._action_mean is not None:
result = result * self._action_std + self._action_mean
if not np.all(np.isfinite(result)):
print("WARNING: HEF output contains NaN/Inf, replacing with zeros")
@@ -370,7 +366,11 @@ def main():
parser.add_argument("--action-history", required=True,
help="冒号分隔各步,每步 action_dim 个值")
parser.add_argument("--stats", default=None,
help="action_stats.npy 路径(训练时启用归一化才需要)")
help="action_stats.npy 路径(zscore 归一化训练才需要)")
parser.add_argument("--normalize-mode", choices=["zscore", "fixed_range"],
default=None,
help="与训练时 normalize_mode 一致;fixed_range 免 --stats。"
"缺省按 --ckpt 配置自动判定,无 --ckpt 时为 zscore")
parser.add_argument("--pred-token", default=None,
help="pred_token .npy 路径(export_deploy.py "
"--pred-token-out 产物;缺省喂零)")
@@ -391,6 +391,7 @@ def main():
dtype=np.float32)
predict_delta = False
normalize_mode = args.normalize_mode or "zscore"
if args.ckpt:
import torch
ck = torch.load(args.ckpt, map_location="cpu", weights_only=True)
@@ -398,9 +399,18 @@ def main():
predict_delta = cfg.get("train", {}).get("predict_delta", False)
if predict_delta:
print("predict_delta=True: HEF 输出将加回历史末步(残差模式)")
if args.normalize_mode is None:
# 未显式指定时按 checkpoint 配置判定归一化方式(旧 checkpoint 无
# normalize_mode 键,默认 zscore,行为不变)
data_cfg = cfg.get("data", {})
if (data_cfg.get("normalize_actions")
and data_cfg.get("normalize_mode") == "fixed_range"):
normalize_mode = "fixed_range"
print("normalize_mode=fixed_range: 固定物理范围归一化(免 stats)")
with HailoPredictor(args.hef, stats_path=args.stats,
predict_delta=predict_delta) as predictor:
predict_delta=predict_delta,
normalize_mode=normalize_mode) as predictor:
if len(args.images) != predictor.frame_len:
raise SystemExit(
f"--images 需传入 {predictor.frame_len} 个路径(旧→新),"
+1 -1
View File
@@ -30,7 +30,7 @@ def parse_mt_action(path) -> np.ndarray:
Raises:
ValueError: 文件中无 @Action 块、数据行不足、行列不一致或数值非法
"""
text = Path(path).read_text(encoding="utf-8")
text = Path(path).read_text(encoding="utf-8-sig")
# 先去除整行注释(# 开头),避免注释中的 @Action 干扰正则匹配
text = re.sub(r"(?m)^[ \t]*#.*$", "", text)
+23 -16
View File
@@ -18,21 +18,13 @@ import numpy as np
from PIL import Image
from imu_norm import normalize_imu
from action_norm import normalize_action, denormalize_action
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)
def parse_action_history(s: str, action_dim: int) -> np.ndarray:
steps = s.split(":")
history = []
for step in steps:
values = [float(v) for v in step.split(",")]
if len(values) != action_dim:
raise ValueError(
f"Expected {action_dim} values per step, got {len(values)}")
history.append(values)
return np.array(history, dtype=np.float32)
from utils import parse_action_history
class OnnxPredictor:
@@ -44,7 +36,13 @@ class OnnxPredictor:
"""
def __init__(self, onnx_path: str, stats_path: str = None,
providers=None):
providers=None, normalize_mode: str = "zscore"):
"""ONNX 推理器。
normalize_mode: 与训练时 DataConfig.normalize_mode 一致。
"zscore"(默认)需传 stats_path;"fixed_range" 免 stats 文件,
按固定物理范围 min-max 归一化(action_norm.py)。
"""
import onnxruntime as ort
if providers is None:
@@ -70,7 +68,8 @@ class OnnxPredictor:
self._action_mean = None
self._action_std = None
if stats_path is not None:
self._fixed_range = normalize_mode == "fixed_range"
if stats_path is not None and not self._fixed_range:
if not os.path.exists(stats_path):
raise FileNotFoundError(f"统计文件不存在: {stats_path}")
stats = np.load(stats_path)
@@ -133,7 +132,9 @@ class OnnxPredictor:
raise ValueError(
f"action_history 形状应为 {expected},实际为 {action_history.shape}")
if self._action_mean is not None:
if self._fixed_range:
action_history = normalize_action(action_history, self.action_dim)
elif self._action_mean is not None:
action_history = (action_history - self._action_mean) / self._action_std
frames = np.stack([self._preprocess(img) for img in images])
@@ -145,7 +146,9 @@ class OnnxPredictor:
feeds[self._imu_name] = normalize_imu(
imu, self.imu_dim)[np.newaxis] # (1,imu_dim)
result = self._session.run(None, feeds)[0].reshape(-1).astype(np.float32)
if self._action_mean is not None:
if self._fixed_range:
result = denormalize_action(result, self.action_dim)
elif self._action_mean is not None:
result = result * self._action_std + self._action_mean
return result
@@ -167,7 +170,10 @@ def main():
parser.add_argument("--action-history", required=True,
help="冒号分隔各步,每步 action_dim 个值")
parser.add_argument("--stats", default=None,
help="action_stats.npy 路径(训练时启用归一化才需要)")
help="action_stats.npy 路径(zscore 归一化训练才需要)")
parser.add_argument("--normalize-mode", choices=["zscore", "fixed_range"],
default="zscore",
help="与训练时 normalize_mode 一致;fixed_range 免 --stats")
parser.add_argument("--imu", type=str, default=None,
help="可选姿态向量(逗号分隔,原始量纲:roll,pitch 度 / "
"ax..gz int16 原始值);模型含 imu 输入且缺省时按零向量")
@@ -175,7 +181,8 @@ def main():
default="json")
args = parser.parse_args()
with OnnxPredictor(args.model, stats_path=args.stats) as predictor:
with OnnxPredictor(args.model, stats_path=args.stats,
normalize_mode=args.normalize_mode) as predictor:
if len(args.images) != predictor.frame_len:
raise SystemExit(
f"--images 需传入 {predictor.frame_len} 个路径(旧→新),"
+17 -17
View File
@@ -8,7 +8,9 @@ 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 model import ActionPredictor
from utils import parse_action_history
class ActionPredictorInference:
@@ -48,7 +50,11 @@ class ActionPredictorInference:
self._action_mean = None
self._action_std = None
if self._data_cfg.normalize_actions:
# 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:
# 优先使用 checkpoint config 记录的 stats 路径(相对路径按 checkpoint 目录解析),
# 旧 checkpoint 无该字段时回退到 checkpoint 同目录的 action_stats.npy
stats_path = self._data_cfg.action_stats_path
@@ -94,7 +100,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._action_mean is not None:
if self._fixed_range:
history = torch.from_numpy(
normalize_action(action_history, self.action_dim))
elif self._action_mean is not None:
history = (history - self._action_mean) / self._action_std
history = history.unsqueeze(0).to(self._device)
imu_tensor = None
@@ -106,27 +115,18 @@ class ActionPredictorInference:
if self._predict_delta:
# 残差:Δ̂ 加回 a_{t-1}(history 末步,同为归一化空间)→ 绝对动作
pred = history[0, -1] + pred
if self._action_mean is not None:
pred = pred * self._action_std + self._action_mean
result = pred.cpu().numpy()
if self._fixed_range:
result = denormalize_action(pred.cpu().numpy(), self.action_dim)
else:
if self._action_mean is not None:
pred = pred * self._action_std + self._action_mean
result = pred.cpu().numpy()
if not np.all(np.isfinite(result)):
print("WARNING: predict output contains NaN/Inf, replacing with zeros")
result = np.nan_to_num(result, nan=0.0, posinf=0.0, neginf=0.0)
return result
def parse_action_history(s: str, action_dim: int) -> np.ndarray:
steps = s.split(":")
history = []
for step in steps:
values = [float(v) for v in step.split(",")]
if len(values) != action_dim:
raise ValueError(
f"Expected {action_dim} values per step, got {len(values)}")
history.append(values)
return np.array(history, dtype=np.float32)
def main():
parser = argparse.ArgumentParser(description="actpred inference")
parser.add_argument("--model", required=True, help="checkpoint path")
+57 -30
View File
@@ -33,6 +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 train import _weighted_mse
_IMAGENET_MEAN = [0.485, 0.456, 0.406]
_IMAGENET_STD = [0.229, 0.224, 0.225]
@@ -46,11 +48,15 @@ class MTActionDataset(Dataset):
def __init__(self, mt_dir: str, history_len: int, action_dim: int,
file_indices: list = None, files: list = None,
action_mean=None, action_std=None):
action_mean=None, action_std=None,
normalize_mode: str = "zscore"):
self.history_len = history_len
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)
self.samples = []
# 复用调用方已解析的结果(避免 train/val 各自重复解析同一目录)
@@ -83,6 +89,9 @@ 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)
history = torch.from_numpy(history.copy())
target = torch.from_numpy(target.copy())
if self._action_mean is not None:
@@ -110,7 +119,7 @@ def _placeholder_images(batch, frame_len, image_height, image_width,
def pretrain_one_epoch(model, loader, optimizer, device, image_width,
image_height, frame_len,
max_grad_norm=None, noise_std=0.0,
random_images: bool = False):
random_images: bool = False, weights=None):
model.train()
total_loss = 0.0
count = 0
@@ -127,7 +136,7 @@ def pretrain_one_epoch(model, loader, optimizer, device, image_width,
device, random_images)
optimizer.zero_grad()
pred = model(images, history)
loss = torch.nn.functional.mse_loss(pred, target)
loss = _weighted_mse(pred, target, weights)
loss.backward()
if max_grad_norm:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm)
@@ -139,7 +148,7 @@ def pretrain_one_epoch(model, loader, optimizer, device, image_width,
@torch.no_grad()
def validate(model, loader, device, image_width, image_height, frame_len,
random_images: bool = False):
random_images: bool = False, weights=None):
model.eval()
total_loss = 0.0
count = 0
@@ -152,7 +161,7 @@ def validate(model, loader, device, image_width, image_height, frame_len,
history.size(0), frame_len, image_height, image_width,
device, random_images)
pred = model(images, history)
loss = torch.nn.functional.mse_loss(pred, target)
loss = _weighted_mse(pred, target, weights)
total_loss += loss.item() * history.size(0)
count += history.size(0)
if count == 0:
@@ -173,7 +182,6 @@ def main():
cfg = apply_overrides(Config(), args)
validate_config_values(cfg.data, cfg.model, cfg.train)
cfg.data.data_dir = args.mt_dir
os.makedirs(cfg.train.checkpoint_dir, exist_ok=True)
@@ -205,12 +213,17 @@ def main():
train_ds = MTActionDataset(
args.mt_dir, cfg.data.history_len, cfg.data.action_dim,
file_indices=train_file_indices, files=all_files,
normalize_mode=cfg.data.normalize_mode,
)
# 动作归一化(与 train.py 语义一致):无 stats 文件时按训练集
# targets 计算并保存到 checkpoint_dir,验证集复用同一 stats
# 动作归一化(与 train.py 语义一致):zscore 模式无 stats 文件时按训练集
# targets 计算并保存到 checkpoint_dir,验证集复用同一 stats;
# fixed_range 模式免统计量,跳过此段
action_mean = action_std = None
if cfg.data.normalize_actions:
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)")
elif cfg.data.normalize_actions:
if cfg.data.action_stats_path:
if not os.path.exists(cfg.data.action_stats_path):
raise FileNotFoundError(
@@ -248,6 +261,7 @@ def main():
args.mt_dir, cfg.data.history_len, cfg.data.action_dim,
file_indices=val_file_indices, files=all_files,
action_mean=action_mean, action_std=action_std,
normalize_mode=cfg.data.normalize_mode,
)
print(f"Files: {len(train_file_indices)} train, {len(val_file_indices)} val")
@@ -272,6 +286,13 @@ def main():
model = ActionPredictor(cfg.model, cfg.data, pretrained=False).to(device)
n_params = sum(p.numel() for p in model.parameters())
print(f"Model params: {n_params:,}")
# 逐列损失权重(与 train.py 同口径;仅训练损失用)
col_w = cfg.data.column_weights
if col_w is None:
col_w = default_column_weights(cfg.data.action_dim)
weights_t = torch.tensor(np.asarray(col_w, dtype=np.float32), device=device)
print(f"Column weights: {[round(float(v), 4) for v in weights_t.tolist()]}")
print(f"Config: action_dim={cfg.data.action_dim}, "
f"history_len={cfg.data.history_len}, "
f"frame_len={cfg.data.frame_len}, "
@@ -328,34 +349,40 @@ def main():
model, train_loader, optimizer, device,
cfg.data.image_width, cfg.data.image_height, cfg.data.frame_len,
cfg.train.max_grad_norm, args.noise_std, args.random_images,
weights=weights_t,
)
val_loss = validate(model, val_loader, device,
cfg.data.image_width, cfg.data.image_height,
cfg.data.frame_len, args.random_images)
cfg.data.frame_len, args.random_images,
weights=weights_t)
scheduler.step()
print(f" train_loss={train_loss:.6f} val_loss={val_loss:.6f}")
if val_loss < best_val_loss:
best_val_loss = val_loss
patience_counter = 0
torch.save(
{
"epoch": epoch,
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"val_loss": val_loss,
"config": cfg.to_dict(),
},
best_path,
)
torch.save(
{
"model_state_dict": model.state_dict(),
"config": cfg.to_dict(),
},
inference_path,
)
print(f" -> saved best (val_loss={val_loss:.6f})")
if not np.isfinite(val_loss):
print(f"WARNING: val_loss 非有限值({val_loss}),跳过 best 更新")
patience_counter += 1
else:
best_val_loss = val_loss
patience_counter = 0
torch.save(
{
"epoch": epoch,
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"val_loss": val_loss,
"config": cfg.to_dict(),
},
best_path,
)
torch.save(
{
"model_state_dict": model.state_dict(),
"config": cfg.to_dict(),
},
inference_path,
)
print(f" -> saved best (val_loss={val_loss:.6f})")
else:
patience_counter += 1
if patience_counter >= cfg.train.patience:
+273 -9
View File
@@ -1790,17 +1790,281 @@ class TestR4Evaluate(unittest.TestCase):
self.assertGreater(rep["error_growth"], 1.0)
self.assertEqual(len(rep["error_curve"]), 40)
def test_action_bounds_margin(self):
def test_action_bounds_physical(self):
"""越界判据为固定物理界(与 action_norm 常量表共用),不再依赖数据统计"""
import evaluate
lo, hi = evaluate._action_bounds(12)
np.testing.assert_allclose(lo, np.zeros(12), atol=1e-6)
np.testing.assert_allclose(hi[:10], [180.0] * 10, atol=1e-6)
np.testing.assert_allclose(hi[10:], [5000.0, 2000.0], atol=1e-6)
# action_dim > 18 的超出列按 [0,1] 补足
lo20, hi20 = evaluate._action_bounds(20)
self.assertEqual(len(hi20), 20)
np.testing.assert_allclose(hi20[18:], [1.0, 1.0], atol=1e-6)
def test_error_growth_per_episode_median(self):
"""error_growth 按 episode 归一化取中位数,不被长短混叠主导"""
import evaluate
# ep1: 40 步线性增长 → g1 = mean(31..40)/mean(1..10) = 35.5/5.5
ep1 = [{"t": k, "mse": float(k + 1), "pred": np.zeros(2),
"true": np.zeros(2), "oob": False} for k in range(40)]
# ep2: 8 步末段尖峰 → g2 = 100/1
ep2 = [{"t": k, "mse": (1.0 if k < 6 else 100.0), "pred": np.zeros(2),
"true": np.zeros(2), "oob": False} for k in range(8)]
rep = evaluate.summarize([ep1, ep2], None, None)
g1 = 35.5 / 5.5
g2 = 100.0
self.assertAlmostEqual(rep["error_growth"], float(np.median([g1, g2])),
places=5)
class TestFixedRangeNorm(unittest.TestCase):
"""固定物理范围归一化 + 逐列加权:action_norm 常量表、dataset/predict 链路、
config 校验、加权损失"""
# ---------------- action_norm.py ----------------
def test_normalize_known_values(self):
from action_norm import normalize_action
a = np.zeros(18, dtype=np.float32)
a[0] = 90.0 # 舵机中位
a[10] = 2500.0 # spk.F 中位
a[11] = 1000.0 # spk.delay 中位
out = normalize_action(a)
self.assertAlmostEqual(float(out[0]), 0.5, places=6)
self.assertAlmostEqual(float(out[10]), 0.5, places=6)
self.assertAlmostEqual(float(out[11]), 0.5, places=6)
# 保留位恒 0 → 归一化后仍 0
np.testing.assert_allclose(out[12:], 0.0, atol=1e-7)
def test_normalize_clamps_out_of_range(self):
from action_norm import normalize_action
a = np.array([-10.0, 270.0] + [0.0] * 16, dtype=np.float32)
out = normalize_action(a)
self.assertEqual(float(out[0]), 0.0)
self.assertEqual(float(out[1]), 1.0)
def test_denormalize_roundtrip(self):
from action_norm import normalize_action, denormalize_action
rng = np.random.RandomState(0)
a = np.zeros((7, 18), dtype=np.float32)
a[:, :10] = rng.uniform(0, 180, (7, 10))
a[:, 10] = rng.uniform(0, 5000, 7)
a[:, 11] = rng.uniform(0, 2000, 7)
np.testing.assert_allclose(
denormalize_action(normalize_action(a)), a, rtol=1e-5, atol=1e-4)
def test_action_dim_prefix_and_padding(self):
from action_norm import normalize_action, action_bounds
# action_dim < 18 取前缀
lo4, hi4 = action_bounds(4)
np.testing.assert_allclose(hi4, [180.0] * 4)
out = normalize_action(np.array([90.0, 45.0, 0.0, 180.0],
dtype=np.float32))
np.testing.assert_allclose(out, [0.5, 0.25, 0.0, 1.0], atol=1e-6)
# action_dim > 18 按 [0,1] 补足
lo20, hi20 = action_bounds(20)
self.assertEqual(len(lo20), 20)
np.testing.assert_allclose(hi20[18:], [1.0, 1.0])
def test_default_column_weights(self):
from action_norm import default_column_weights
w18 = default_column_weights(18)
np.testing.assert_allclose(w18[:10], 1.0)
np.testing.assert_allclose(w18[10:12], 0.2)
np.testing.assert_allclose(w18[12:], 0.0)
# 小 action_dim:全部落在舵机段 → 全 1.0
np.testing.assert_allclose(default_column_weights(4), 1.0)
# 12 列:舵机 1.0 + spk 0.2
w12 = default_column_weights(12)
np.testing.assert_allclose(w12[10:], 0.2)
# ---------------- dataset 链路 ----------------
def test_dataset_fixed_range_no_stats_needed(self):
"""fixed_range 模式不需要 action_stats.npy,目标值按物理范围归一化"""
with tempfile.TemporaryDirectory() as tmpdir:
self._create_episode(Path(tmpdir), "episode_0001",
np.zeros((5, 2), dtype=np.float32))
self._create_episode(Path(tmpdir), "episode_0002",
np.full((5, 2), 10.0, dtype=np.float32))
lo, hi = evaluate._action_bounds(tmpdir, 2, margin_ratio=0.1)
# min=0,max=10,span=10,margin=1 → lo=-1, hi=11
np.testing.assert_allclose(lo, [-1.0, -1.0], atol=1e-6)
np.testing.assert_allclose(hi, [11.0, 11.0], atol=1e-6)
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, 4), dtype=np.float32)
actions[:, 0] = 90.0 # 舵机中位 → 0.5
actions[:, 1] = 180.0 # 满行程 → 1.0
np.save(ep_dir / "actions.npy", actions)
ds = ActionDataset(tmpdir, history_len=3, action_dim=4,
image_width=16, image_height=16, frame_len=3,
normalize_actions=True,
normalize_mode="fixed_range")
sample = ds[0]
np.testing.assert_allclose(sample["target"].numpy()[:2],
[0.5, 1.0], atol=1e-6)
np.testing.assert_allclose(
sample["action_history"].numpy()[:, 0], 0.5, atol=1e-6)
# fixed_range 不产生统计量
self.assertIsNone(ds.get_action_stats())
def test_dataset_fixed_range_clamps(self):
"""越界值 clamp 到 [0,1](防御口径)"""
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, 4), dtype=np.float32)
actions[:, 0] = 999.0 # 远超 180 → clamp 1.0
np.save(ep_dir / "actions.npy", actions)
ds = ActionDataset(tmpdir, history_len=3, action_dim=4,
image_width=16, image_height=16, frame_len=3,
normalize_actions=True,
normalize_mode="fixed_range")
self.assertEqual(float(ds[0]["target"][0]), 1.0)
def test_dataset_zscore_unchanged(self):
"""zscore 默认模式行为不变(仍计算/加载统计量)"""
with tempfile.TemporaryDirectory() as tmpdir:
TestActionDataset._create_dummy_episode(
Path(tmpdir), n_frames=10, action_dim=4)
ds = ActionDataset(tmpdir, history_len=3, action_dim=4,
image_width=32, image_height=32, frame_len=3,
normalize_actions=True)
self.assertIsNotNone(ds._action_mean)
self.assertFalse(ds._fixed_range)
def test_mt_dataset_fixed_range(self):
"""MTActionDataset fixed_range:免统计量,按物理范围归一化"""
with tempfile.TemporaryDirectory() as tmpdir:
_write_mt(tmpdir, "a.mt", _make_seq_mt(6))
with contextlib.redirect_stdout(io.StringIO()):
ds = MTActionDataset(tmpdir, history_len=3, action_dim=3,
normalize_mode="fixed_range")
sample = ds[0]
# _make_seq_mt 第 i 行 [i, 10i, 100i],target 为第 4 行 [3,30,300]
# 除数 180 → [3/180, 30/180, 300/180(clamp 1.0)]
np.testing.assert_allclose(
sample["target"].numpy(),
[3 / 180, 30 / 180, 1.0], atol=1e-6)
# ---------------- config 校验与 CLI ----------------
def test_cli_normalize_mode_and_column_weights(self):
parser = build_argparser()
args = parser.parse_args(
["--normalize-mode", "fixed_range",
"--column-weights", "1,1,1,0.2"])
cfg = apply_overrides(Config(), args)
cfg.data.action_dim = 4
self.assertEqual(cfg.data.normalize_mode, "fixed_range")
self.assertEqual(cfg.data.column_weights, [1.0, 1.0, 1.0, 0.2])
validate_config_values(cfg.data, cfg.model) # 不应抛异常
def test_bad_normalize_mode_raises(self):
cfg = Config()
cfg.data.normalize_mode = "minmax" # 非法值
with self.assertRaisesRegex(ValueError, "normalize_mode"):
validate_config_values(cfg.data, cfg.model)
def test_column_weights_length_mismatch_raises(self):
cfg = Config()
cfg.data.column_weights = [1.0, 0.2] # action_dim=18 不匹配
with self.assertRaisesRegex(ValueError, "column_weights"):
validate_config_values(cfg.data, cfg.model)
def test_column_weights_all_zero_raises(self):
cfg = Config()
cfg.data.column_weights = [0.0] * 18
with self.assertRaisesRegex(ValueError, "column_weights"):
validate_config_values(cfg.data, cfg.model)
def test_checkpoint_roundtrip_new_keys(self):
"""新键随 checkpoint 序列化/解析往返一致;旧 checkpoint 缺省兼容"""
cfg = Config()
cfg.data.normalize_mode = "fixed_range"
cfg.data.column_weights = [1.0] * 10 + [0.2] * 2 + [0.0] * 6
parsed = parse_checkpoint_config(cfg.to_dict())
self.assertEqual(parsed.data.normalize_mode, "fixed_range")
self.assertEqual(parsed.data.column_weights,
[1.0] * 10 + [0.2] * 2 + [0.0] * 6)
# 旧 checkpoint 无新键 → 默认 zscore / None,行为不变
d = cfg.to_dict()
del d["data"]["normalize_mode"]
del d["data"]["column_weights"]
parsed = parse_checkpoint_config(d)
self.assertEqual(parsed.data.normalize_mode, "zscore")
self.assertIsNone(parsed.data.column_weights)
def test_warn_init_from_normalize_mode_mismatch(self):
from config import warn_init_from_mismatch
cfg = Config()
cfg.data.normalize_mode = "fixed_range"
init_cfg = {"data": {"normalize_actions": True,
"normalize_mode": "zscore"}}
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
warn_init_from_mismatch(init_cfg, cfg)
self.assertIn("normalize_mode", buf.getvalue())
# ---------------- 加权损失 ----------------
def test_weighted_mse(self):
from train import _weighted_mse
pred = torch.tensor([[1.0, 2.0, 3.0]])
target = torch.zeros(1, 3)
# 等权 → 与普通 MSE 一致
w1 = torch.ones(3)
self.assertAlmostEqual(
float(_weighted_mse(pred, target, w1)),
float(torch.nn.functional.mse_loss(pred, target)), places=6)
# 只留第 0 列权重:loss = (1·1²)/(1) = 1
w = torch.tensor([1.0, 0.0, 0.0])
self.assertAlmostEqual(float(_weighted_mse(pred, target, w)), 1.0,
places=6)
# None 退化为普通 MSE
self.assertAlmostEqual(
float(_weighted_mse(pred, target, None)),
float(torch.nn.functional.mse_loss(pred, target)), places=6)
# ---------------- predict 链路 ----------------
def test_predict_fixed_range_no_stats(self):
"""fixed_range checkpoint 无需 action_stats.npy,归一化口径与
action_norm 一致(与 raw 模型 + 手工归一化对照)"""
from action_norm import normalize_action, denormalize_action
with tempfile.TemporaryDirectory() as tmpdir:
raw_dir = os.path.join(tmpdir, "raw")
fixed_dir = os.path.join(tmpdir, "fixed")
os.makedirs(raw_dir)
os.makedirs(fixed_dir)
torch.manual_seed(0)
path, model, cfg = \
TestActionPredictorInference._make_checkpoint(raw_dir)
# 同一份权重另存为 fixed_range 归一化 checkpoint(无 stats 文件)
cfg.data.normalize_actions = True
cfg.data.normalize_mode = "fixed_range"
fixed_path = os.path.join(fixed_dir, "best_inference.pt")
torch.save({"model_state_dict": model.state_dict(),
"config": cfg.to_dict()}, fixed_path)
image = np.random.randint(0, 255, (32, 32, 3), dtype=np.uint8)
history = np.random.uniform(0, 180, (3, 4)).astype(np.float32)
# fixed_range 端:内部 normalize → 模型 → denormalize
pred_fixed = ActionPredictorInference(
fixed_path, device="cpu").predict([image] * 3, history)
# 对照:raw 模型直接吃手工归一化的历史,再手工反归一化
pred_raw = ActionPredictorInference(raw_dir + "/best_inference.pt",
device="cpu").predict(
[image] * 3, normalize_action(history, 4))
np.testing.assert_allclose(
pred_fixed, denormalize_action(pred_raw, 4),
rtol=1e-5, atol=1e-5)
class TestR2ImuNorm(unittest.TestCase):
+63 -26
View File
@@ -10,6 +10,7 @@ from config import (Config, build_argparser, apply_overrides,
validate_config_values, warn_init_from_mismatch)
from model import ActionPredictor
from dataset import ActionDataset
from action_norm import default_column_weights
def split_episodes(data_dir: str, val_ratio: float, seed: int = 42):
@@ -21,6 +22,17 @@ def split_episodes(data_dir: str, val_ratio: float, seed: int = 42):
return indices[val_count:], indices[:val_count]
def _weighted_mse(pred: torch.Tensor, target: torch.Tensor,
weights: torch.Tensor = None) -> torch.Tensor:
"""逐列加权 MSE:mean(w·(pred-target)²)/mean(w),weights None 时退化为普通 MSE。
除以 mean(w) 保持损失量级与未加权口径可比(w 全 1 时与普通 MSE 完全一致)。
"""
if weights is None:
return torch.nn.functional.mse_loss(pred, target)
return (weights * (pred - target) ** 2).mean() / weights.mean()
def _collate(batch):
"""多步样本({"steps":[...]})逐 k 聚合成 batch 列表;单步样本走默认 collate。
@@ -46,7 +58,7 @@ def _shift_history(history: torch.Tensor, new_action: torch.Tensor) -> torch.Ten
def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = None,
rollout_len: int = 1, ss_prob: float = 1.0,
predict_delta: bool = False):
predict_delta: bool = False, weights: torch.Tensor = None):
model.train()
total_loss = 0.0
count = 0
@@ -59,7 +71,7 @@ def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = Non
target = batch["target"].to(device)
imu = batch["imu"].to(device) if "imu" in batch else None
pred = model(images, history, imu)
loss = torch.nn.functional.mse_loss(pred, target)
loss = _weighted_mse(pred, target, weights)
bs = images.size(0)
else:
# 多步 scheduled sampling:首步必用真历史;后续步以 ss_prob 用真历史、
@@ -77,7 +89,7 @@ def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = Non
history = true_history
# 否则沿用上一轮回传的 history(自预测构造)
pred = model(images, history, imu)
loss = loss + torch.nn.functional.mse_loss(pred, target)
loss = loss + _weighted_mse(pred, target, weights)
bs = images.size(0)
# 回传:残差模式先把 Δ̂ 加回 a_{t-1} 得到绝对动作,再移入历史
with torch.no_grad():
@@ -94,7 +106,8 @@ def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = Non
@torch.no_grad()
def validate(model, loader, device, rollout_len: int = 1, predict_delta: bool = False):
def validate(model, loader, device, rollout_len: int = 1, predict_delta: bool = False,
weights: torch.Tensor = None):
model.eval()
total_loss = 0.0
count = 0
@@ -105,7 +118,7 @@ def validate(model, loader, device, rollout_len: int = 1, predict_delta: bool =
target = batch["target"].to(device)
imu = batch["imu"].to(device) if "imu" in batch else None
pred = model(images, history, imu)
loss = torch.nn.functional.mse_loss(pred, target)
loss = _weighted_mse(pred, target, weights)
bs = images.size(0)
else:
# 验证固定全真历史(确定性,不带 scheduled sampling 随机性),
@@ -118,7 +131,7 @@ def validate(model, loader, device, rollout_len: int = 1, predict_delta: bool =
target = step["target"].to(device)
imu = step["imu"].to(device) if "imu" in step else None
pred = model(images, history, imu)
loss = loss + torch.nn.functional.mse_loss(pred, target)
loss = loss + _weighted_mse(pred, target, weights)
bs = images.size(0)
loss = loss / len(batch["steps"])
total_loss += loss.item() * bs
@@ -156,10 +169,12 @@ def main():
cfg.data.action_stats_path, train_indices, cfg.data.frame_len,
augment=cfg.train.augment,
predict_delta=cfg.train.predict_delta, rollout_len=cfg.train.rollout_len,
imu_dim=cfg.data.imu_dim)
imu_dim=cfg.data.imu_dim, normalize_mode=cfg.data.normalize_mode)
auto_stats = False
if cfg.data.normalize_actions and not cfg.data.action_stats_path:
if (cfg.data.normalize_actions and cfg.data.normalize_mode == "zscore"
and not cfg.data.action_stats_path):
# 归一化统计量必须只用训练集计算,再供验证集复用,否则 val loss 不可信
# (fixed_range 模式免 stats 文件,跳过此段)
stats_path = os.path.join(cfg.train.checkpoint_dir, "action_stats.npy")
np.save(stats_path, train_ds.get_action_stats())
print(f"Action stats saved to: {stats_path}")
@@ -171,7 +186,7 @@ def main():
cfg.data.image_width, cfg.data.image_height, cfg.data.normalize_actions,
cfg.data.action_stats_path, val_indices, cfg.data.frame_len,
predict_delta=cfg.train.predict_delta, rollout_len=cfg.train.rollout_len,
imu_dim=cfg.data.imu_dim)
imu_dim=cfg.data.imu_dim, normalize_mode=cfg.data.normalize_mode)
if auto_stats:
# checkpoint 配置只存裸文件名:推理端按 checkpoint 目录解析相对路径
cfg.data.action_stats_path = "action_stats.npy"
@@ -198,6 +213,13 @@ def main():
n_params = sum(p.numel() for p in model.parameters())
print(f"Model params: {n_params:,}")
# 逐列损失权重(仅训练/验证损失用,推理端无感知)
col_w = cfg.data.column_weights
if col_w is None:
col_w = default_column_weights(cfg.data.action_dim)
weights_t = torch.tensor(np.asarray(col_w, dtype=np.float32), device=device)
print(f"Column weights: {[round(float(v), 4) for v in weights_t.tolist()]}")
if args.init_from:
init_ckpt = torch.load(args.init_from, map_location="cpu", weights_only=True)
warn_init_from_mismatch(init_ckpt.get("config", {}), cfg)
@@ -236,29 +258,35 @@ def main():
cfg.train.max_grad_norm,
rollout_len=cfg.train.rollout_len,
ss_prob=cfg.train.ss_prob,
predict_delta=cfg.train.predict_delta)
predict_delta=cfg.train.predict_delta,
weights=weights_t)
val_loss = validate(model, val_loader, device,
rollout_len=cfg.train.rollout_len,
predict_delta=cfg.train.predict_delta)
predict_delta=cfg.train.predict_delta,
weights=weights_t)
scheduler.step()
print(f" train_loss={train_loss:.6f} val_loss={val_loss:.6f}")
if val_loss < best_val_loss:
best_val_loss = val_loss
patience_counter = 0
torch.save({
"epoch": epoch,
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"val_loss": val_loss,
"config": cfg.to_dict(),
}, best_path)
inference_path = os.path.join(cfg.train.checkpoint_dir, "best_inference.pt")
torch.save({
"model_state_dict": model.state_dict(),
"config": cfg.to_dict(),
}, inference_path)
print(f" -> saved best (val_loss={val_loss:.6f})")
if not np.isfinite(val_loss):
print(f"WARNING: val_loss 非有限值({val_loss}),跳过 best 更新")
patience_counter += 1
else:
best_val_loss = val_loss
patience_counter = 0
torch.save({
"epoch": epoch,
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"val_loss": val_loss,
"config": cfg.to_dict(),
}, best_path)
inference_path = os.path.join(cfg.train.checkpoint_dir, "best_inference.pt")
torch.save({
"model_state_dict": model.state_dict(),
"config": cfg.to_dict(),
}, inference_path)
print(f" -> saved best (val_loss={val_loss:.6f})")
else:
patience_counter += 1
if patience_counter >= cfg.train.patience:
@@ -284,6 +312,15 @@ def main():
"model_state_dict": model.state_dict(),
"config": cfg.to_dict(),
}, os.path.join(cfg.train.checkpoint_dir, "best_inference.pt"))
torch.save({
"epoch": epoch,
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"scheduler_state_dict": scheduler.state_dict(),
"best_val_loss": best_val_loss,
"patience_counter": patience_counter,
"config": cfg.to_dict(),
}, os.path.join(cfg.train.checkpoint_dir, "best.pt"))
print("WARNING: saved final weights as best_inference.pt (no valid validation loss)")
else:
print(f"Best model saved to: {best_path}")
+16
View File
@@ -0,0 +1,16 @@
"""actpred 共享工具函数(无 torch 依赖,推理/部署文件可复用)"""
import numpy as np
def parse_action_history(s: str, action_dim: int) -> np.ndarray:
"""解析冒号分隔的动作历史字符串为 (H, D) float32 数组"""
steps = s.split(":")
history = []
for step in steps:
values = [float(v) for v in step.split(",")]
if len(values) != action_dim:
raise ValueError(
f"Expected {action_dim} values per step, got {len(values)}")
history.append(values)
return np.array(history, dtype=np.float32)
@@ -0,0 +1,191 @@
# pybot & actpred 审计报告(第二版 —— 归一化+逐列加权 落地后)
> 审查日期:2026-08-14 (Asia/Shanghai)
> 审查人:AI Assistant(SOLO Coder)
> 审查范围:`pybot/`(机器人运行时)与 `actpred/`(动作预测)两模块
> 代码基线:归一化+逐列加权方案落地后(12 个文件改动,531 行新增)
>
> 本报告对照三份历史审计报告追踪修复状态,并审查本轮新增代码。
---
## 一、审计结论摘要
| 级别 | 新发现 | 历史遗留 | 说明 |
|------|--------|---------|------|
| **P0(崩溃/数据损坏)** | 2 | 0 | 见 §二 |
| P1(功能风险) | 0 | 5 | 见 §三 |
| P2(代码质量) | 3 | 9 | 见 §四 |
**重点:2 个 P0 必须在提交前修复。**
---
## 二、P0 新发现
### P0-1 pybot HailoPredictor 缺失 normalize_mode 传递
**位置**:[pybot/main.py:180-183](../../pybot/pybot/main.py#L180-L183)
```python
return _P(checkpoint, stats_path=stats_path,
predict_delta=predict_delta,
pred_token=pred_token)
```
**问题**:`HailoPredictor.__init__` 的 `normalize_mode` 参数默认 `"zscore"`,pybot 调用时未传入此参数。对于 `fixed_range` 训练的 HEF,推理端应按 `action_norm.py` 固定物理范围对历史动作做 min-max→[0,1] 归一化,但 pybot 未传参导致 `_fixed_range=False`,历史动作以原始量纲送入 HEF。
**影响**:`fixed_range` 模式训练的 HEF 在 pybot 闭环中推理输出完全错误(舵机会被推到安全范围外)。`zscore` 模式不受影响(默认值偶然正确)。
**修复建议**:pybot 从同目录 checkpoint 读取 `normalize_mode` 并传入 `HailoPredictor`:
```python
# 在 _load_predictor 中,从 companion checkpoint 读取 normalize_mode
normalize_mode = "zscore"
for name in ("pretrain_best_inference.pt", "best_inference.pt"):
ckpt_path = os.path.join(hef_dir, name)
if os.path.exists(ckpt_path):
try:
import torch
ck = torch.load(ckpt_path, map_location="cpu", weights_only=True)
normalize_mode = ck.get("config", {}).get("data", {}).get(
"normalize_mode", "zscore")
except Exception:
pass
break
return _P(checkpoint, stats_path=stats_path,
predict_delta=predict_delta,
pred_token=pred_token,
normalize_mode=normalize_mode)
```
### P0-2 DataConfig normalize_actions 默认值变更,与 README 不一致
**位置**:[actpred/config.py:14](../../actpred/config.py#L14)
```python
normalize_actions: bool = True # 旧默认值:False
```
**问题**:默认值从 `False` 改为 `True`,但 [README.md 配置表](../../actpred/README.md#L163) 仍标注 `False`。CLI 不经 `--normalize-actions` 参数时,`apply_overrides` 会使用 dataclass 默认值 `True`,导致行为变更:
- 旧脚本(`bash _run.sh train --data-dir ./data --epochs 100`)之前不归一化,现在开启 zscore 归一化,且会自动计算 `action_stats.npy`。
- 对预训练(`pretrain_mt.py`)同样影响:预训练数据从 .mt 脚本提取,zscore 归一化后分布与旧 checkpoint 不同。
**影响**:所有无 `--normalize-actions` 或无 `--no-normalize-actions` 的训练命令行为改变;旧 checkpoint 做 `--init-from` 时 `warn_init_from_mismatch` 会警告但不会阻止。
**修复建议**:二选一:
- **方案 A**:恢复默认值 `False`(保持向后兼容,推荐);
- **方案 B**:改 README 为 `True`,并在 CHANGELOG 中标注 breaking change。
---
## 三、P1 历史遗留(未修复)
| # | 来源 | 位置 | 问题 | 优先级 |
|---|------|------|------|--------|
| P1-3 | 闭环审计 | [actpred/mt_parser.py:33](../../actpred/mt_parser.py#L33) | `read_text(encoding="utf-8")` 不剥离 BOM,`@Action` 为首块时表头列名可能粘附 `` | 低(低概率触发) |
| P2-a | 闭环审计 | [pybot/main.py:143](../../pybot/pybot/main.py#L143) | `_load_predictor` 仅用 `endswith(".hef")` 区分后端,大小写敏感,无 ONNX 分支 | 中 |
| R2-1 | actpred 审计 | [actpred/train.py:270](../../actpred/train.py#L270) | NaN val_loss 永久阻断 best checkpoint 保存(`NaN < x` 恒 False) | 中 |
| R1-1 | actpred 审计 | [actpred/pretrain_mt.py:185](../../actpred/pretrain_mt.py#L185) | `cfg.data.data_dir = args.mt_dir` 覆盖,`--save-config` 导出语义错误 JSON | 低 |
| R2-7 | actpred 审计 | [actpred/_run.sh:14-16](../../actpred/_run.sh#L14-L16) | 未知子命令静默回退到 `predict.py`,拼写错误难以发现 | 低 |
---
## 四、P2 历史遗留(未修复)
| # | 来源 | 位置 | 问题 |
|---|------|------|------|
| P2-b | 闭环审计 | pybot/robot.py 等 3 处 | numpy 在闭环路径 3 个函数各自 `import` |
| P2-c | 闭环审计 | pybot/tests/test_loop.py | 未覆盖闭环 + recorder 集成、predictor 异常清理 |
| P2-d | 闭环审计 | actpred/dataset.py | 图像 Resize 不保持宽高比 |
| P2-e | 闭环审计 | actpred/train.py | resume 时改 `--epochs` 会重算 lr scheduler |
| P2-f | 闭环审计 | actpred/export_deploy.py | ManualAttnBlock 依赖 PyTorch 内部布局约定 |
| R2-3 | actpred 审计 | actpred/train.py | 空验证集时 `best.pt` 从不创建 |
| R2-4 | actpred 审计 | actpred/_set.sh | CUDA 版 torch 误装风险 |
| R1-2 | actpred 审计 | predict/onnx_infer/hailo_infer | `parse_action_history` 三处重复 |
| R2-9 | actpred 审计 | actpred/predict.py | `--action-dim` override 参数无用且是陷阱 |
---
## 五、已完成修复的审计项
| # | 来源 | 修复内容 |
|---|------|---------|
| P1-1 | 闭环审计 | 闭环节拍 overrun 告警已实现([robot.py:307-320](../../pybot/pybot/robot.py#L307-L320)),含比例统计 |
| P1-2 | 闭环审计 | 闭环帧源改走 `_loop_recent_frames`([robot.py:323-338](../../pybot/pybot/robot.py#L323-L338)),从守护线程队列取帧 |
| P1-4 | 闭环审计 | DataRecorder output_dir 已有 `mkdir(parents=True, exist_ok=True)`([recorder.py:46](../../pybot/pybot/recorder.py#L46)) |
| R2-2 | actpred 审计 | ActionDataset 外部 stats 文件已补 shape 校验([dataset.py:92-95](../../actpred/dataset.py#L92-L95)) |
---
## 六、本轮新增代码审查(归一化+逐列加权方案)
### 6.1 action_norm.py — 通过,有 1 处 P2 建议
**总体评价**:常量表清晰、文档完善、clamp 防御到位。18 个单测覆盖正常值/边界/越界/多维度。
**P2-a1(建议)**:`default_column_weights` 的 `action_dim` 默认为 18,但与 `action_bounds` 的行为不同——`action_bounds` 对 `action_dim > 18` 自动补零,`default_column_weights` 对此类情况返回全零权重(因为 `min(10, action_dim)` 和 `min(12, action_dim)` 仅覆盖前 12 列,其余列保持 `np.zeros` 初始值)。建议对齐行为:`action_dim > 18` 时对超出列也赋默认权重(如 0.2)。
### 6.2 config.py — 通过,有 1 处 P2
**总体评价**:`normalize_mode` 和 `column_weights` 的 CLI 解析、值域校验、checkpoint 兼容性警告均实现完整。
**P2-a2(建议)**:`warn_init_from_mismatch` 中 `init_mode = init_data.get("normalize_mode", "zscore")` 对旧 checkpoint 正确回退。但旧 checkpoint 的 `normalize_actions=False` 时,`normalize_mode` 无意义,此时警告 `init_mode != current.data.normalize_mode` 是误报。建议仅在 `init_norm and current.data.normalize_actions` 时比较 `normalize_mode`。
### 6.3 dataset.py — 通过
固定范围归一化集成正确:`_fixed_range` 标志位在 `__init__` 中设置,`_norm` 方法按标志位分发。stats shape 校验(R2-2 修复)已补。
### 6.4 train.py — 通过,有 1 处 P2 行为变更
**总体评价**:`_weighted_mse` 实现正确,`weights.mean()` 归一化保证加权 Loss 与无权重 Loss 量级可比。`column_weights` 为 None 时自动取 `default_column_weights`。
**P2-a3(行为变更)**:`column_weights` 现在**始终生效**(None 时取默认值),即使 `normalize_actions=False`。这意味着不归一化训练的模型也会按列加权损失——模型对舵机列(权重 1.0)的优化力度高于 spk 列(0.2)。这并非 bug,但行为变更需在 README 注明。
### 6.5 evaluate.py — 通过
物理界 OOB 与 `action_norm.py` 常量表共用(`_action_bounds` 调用 `action_bounds`)。error_growth 按 episode 分别计算取中位数,消除长短 episode 混杂偏差。实现正确。
### 6.6 推理/部署链 — 通过,P0-1 除外
| 文件 | 审查结论 |
|------|---------|
| predict.py | `_fixed_range` 从 checkpoint config 读取,normalize/denormalize 正确 |
| onnx_infer.py | `normalize_mode` 构造参数,predict 中 normalize/denormalize 正确 |
| hailo_infer.py | `normalize_mode` 构造参数,predict 中 normalize/denormalize 正确 |
| gen_calibration.py | `fixed_range` 模式校准数据走 `normalize_action` 变换,与推理端一致 |
| export_deploy.py | `check_token_ranges` 按 `normalize_mode` 在正确空间采样,stats_path 逻辑正确 |
**唯独 pybot 的 `_load_predictor` 未传 `normalize_mode` 给 HailoPredictor(P0-1)**。
### 6.7 test_actpred.py — 通过
新增 `TestFixedRangeNorm` 类 18 个测试用例,覆盖:
- 已知值归一化(舵机中位/边界、spk 中位)
- 越界 clamp(>180→1.0、<0→0.0)
- denormalize 往返一致性
- 默认列权重结构
- 自定义列权重
- 多维度批次归一化
- 全 0 输出验证
全量 148 passed / 11 skipped。
---
## 七、修复优先级
| 优先级 | 项 | 理由 |
|--------|-----|------|
| **P0** | P0-1 HailoPredictor normalize_mode 缺失 | `fixed_range` HEF 在 pybot 闭环中推理完全错误 |
| **P0** | P0-2 normalize_actions 默认值变更 | 所有无此参数的命令行为改变,README 不一致 |
| P1 | R2-1 NaN val_loss | 训练健壮性,一行 isfinite 修复 |
| P1 | P2-a 后端检测 | 路径大小写敏感,ONNX 无分支 |
| P2 | P2-a2 误报警告 | 旧 checkpoint 的不必要警告 |
| P2 | P2-a1 action_dim>18 | 未来扩展性 |
| P2 | P2-a3 行为变更文档 | README 注明 column_weights 始终生效 |
---
*本报告为归一化+逐列加权方案落地后的专项审计。建议先修复 2 个 P0 再提交。*
+11 -2
View File
@@ -141,7 +141,8 @@ def _load_predictor(checkpoint):
"""
import os
try:
if checkpoint.endswith(".hef"):
lower = checkpoint.lower()
if lower.endswith(".hef"):
from actpred.hailo_infer import HailoPredictor as _P
hef_dir = os.path.dirname(os.path.abspath(checkpoint))
@@ -160,6 +161,7 @@ def _load_predictor(checkpoint):
# predict_delta:从同目录 checkpoint 读取训练配置
predict_delta = False
normalize_mode = "zscore"
for name in ("pretrain_best_inference.pt",
"best_inference.pt"):
ckpt_path = os.path.join(hef_dir, name)
@@ -171,9 +173,15 @@ def _load_predictor(checkpoint):
cfg = ck.get("config", {})
predict_delta = cfg.get("train", {}).get(
"predict_delta", False)
normalize_mode = cfg.get("data", {}).get(
"normalize_mode", "zscore")
if predict_delta:
logging.info(
"predict_delta=True from %s", ckpt_path)
if normalize_mode != "zscore":
logging.info(
"normalize_mode=%s from %s",
normalize_mode, ckpt_path)
except Exception:
pass
break
@@ -181,7 +189,8 @@ def _load_predictor(checkpoint):
try:
return _P(checkpoint, stats_path=stats_path,
predict_delta=predict_delta,
pred_token=pred_token)
pred_token=pred_token,
normalize_mode=normalize_mode)
except Exception:
logging.exception("加载 HEF 推理器失败: %s", checkpoint)
return None
+2 -7
View File
@@ -14,6 +14,8 @@ import logging
import time
from collections import deque
import numpy as np
from . import plugin
from .hardvars import (AliasVar, CameraVar, I2CVar, MPU6050Var, ServoVar,
SpeakerVar)
@@ -238,7 +240,6 @@ class RobotStruct:
(会阻塞 ~1/framerate 秒,把 hz 锁死在相机帧率之下)。dry_run
或队列空时回退 _grab_frame 合成帧。
"""
import numpy as np
camera_var = self._find_camera()
if camera_var is None and not self.dry_run:
raise ValueError("闭环需要 @Camera 块(或使用 --dry-run 生成合成帧)")
@@ -357,7 +358,6 @@ class RobotStruct:
帧源与主循环一致(_loop_recent_frames),避免冷启动与运行期帧源
不一致。history 用零向量填满是保守初值,避免随机姿态跳变损坏舵机。
"""
import numpy as np
frames.extend(self._loop_recent_frames(camera_var, frames.maxlen))
for _ in range(history.maxlen):
history.append(np.zeros(action_dim, dtype=np.float32))
@@ -368,7 +368,6 @@ class RobotStruct:
SpeakerVar.play 强制要求 delay 键;闭环已剔除 delay 列(避免发声
阻塞卡死节拍),此处为其补 delay=0 —— 发声但不阻塞,节拍由 hz 掌控。
"""
import numpy as np
if not np.all(np.isfinite(act)):
log.warning("action vector contains NaN/Inf, skipping step")
return
@@ -442,7 +441,6 @@ class RobotStruct:
"""
if not imu_vars:
return None
import numpy as np
nk = len(IMU_KEYS)
out = np.zeros(nk * len(imu_vars), dtype=np.float32)
for k, var in enumerate(imu_vars):
@@ -458,7 +456,6 @@ class RobotStruct:
@staticmethod
def _pad_imu(vec, imu_dim):
"""拼接姿态向量 → pad/truncate 到 predictor 的 imu_dim(None → 全零)"""
import numpy as np
out = np.zeros(imu_dim, dtype=np.float32)
if vec is not None:
src = np.asarray(vec, dtype=np.float32).ravel()
@@ -468,7 +465,6 @@ class RobotStruct:
def _build_action_vec(self, vals, action_dim):
"""从值列表构建动作向量,pad/truncate 到 action_dim"""
import numpy as np
vec = np.zeros(action_dim, dtype=np.float32)
n = min(len(vals), action_dim)
vec[:n] = vals[:n]
@@ -476,7 +472,6 @@ class RobotStruct:
def _grab_frame(self, camera_var):
"""从摄像头同步抓一帧;dry_run 或无帧时返回随机噪声帧"""
import numpy as np
if camera_var is not None:
frame = camera_var.camera.capture()
if frame is not None: