fix(pybot×actpred): v3 审计 P2 清零——验收链 fixed_range + 导出断言 + overrun 节流
- v3-P2-1: _npu_test/_onnx_validate 按 normalize_mode 分支(fixed_range 免 stats, HailoPredictor 透传模式,torch 参考同口径反归一化) - v3-P2-2: export_deploy/export_onnx 一致性 diff 超 1e-4 即 sys.exit(P2-f 闭环) - v3-P2-3: run_loop overrun 告警节流(第1次+每100次),降 hz 建议仅 >10% 输出 - P2-a1: default_column_weights 扩展列(action_dim>18)默认 1.0 - P2-c: 补 predictor 异常清理路径测试(异常传播 + finally 落盘) - P2-d/P2-e: Resize 口径与 resume scheduler T_max 语义文档化 - R2-4: _set.sh 安装后断言 CPU 版 torch,误装 CUDA 即拦截 测试: pybot 89 passed (+1)、actpred 148 passed/18 skipped
This commit is contained in:
1 parent
ad3a624b4c
commit
9179281420
13 files changed
+161
-54
No files matched your search
+1
-1
@@ -179,7 +179,7 @@ recorder.flush()
|
||||
| `--num-workers` | 2 | DataLoader 工作进程数 |
|
||||
| `--checkpoint-dir` | ./checkpoints | 检查点保存目录 |
|
||||
| `--init-from` | 无 | 预训练 checkpoint 路径,作为微调起点(strict=False) |
|
||||
| `--resume` | 无 | 从 last.pt 断点续训练(恢复 model/optimizer/scheduler/epoch) |
|
||||
| `--resume` | 无 | 从 last.pt 断点续训练(恢复 model/optimizer/scheduler/epoch;scheduler 的 T_max 一并恢复,resume 时改 `--epochs` 不影响 LR 余弦曲线,只影响循环边界) |
|
||||
| `--save-config` | 无 | 保存配置到 JSON 文件 |
|
||||
| `--predict-delta` | False | R1-C 残差预测(target 改 Δ,推理端自动加回 a_{t-1}) |
|
||||
| `--rollout-len` | 1 | R1-B 多步展开步数 K(>1 时 scheduled sampling) |
|
||||
|
||||
+21
-7
@@ -57,11 +57,13 @@ model.eval()
|
||||
pred_learned = model.pred_token.data.unsqueeze(1).numpy() # (1,1,1,d)
|
||||
imu_dim = cfg.data.imu_dim
|
||||
|
||||
# 归一化模型:torch 参考在喂入前 z-score;HailoPredictor 传 stats_path
|
||||
# 由封装内部归一化(hist_real/hist_zero 保持原始值,避免双重归一化)
|
||||
# 归一化模型:torch 参考在喂入前归一化;HailoPredictor 传 normalize_mode
|
||||
# 由封装内部归一化(hist_real/hist_zero 保持原始值,避免双重归一化)。
|
||||
# fixed_range 免 action_stats.npy(该模式训练不产出统计文件)。
|
||||
import os
|
||||
stats_path = None
|
||||
if cfg.data.normalize_actions:
|
||||
norm_mode = cfg.data.normalize_mode if cfg.data.normalize_actions else "none"
|
||||
if norm_mode == "zscore":
|
||||
stats_path = cfg.data.action_stats_path or os.path.join(
|
||||
os.path.dirname(os.path.abspath(CKPT)), "action_stats.npy")
|
||||
if not os.path.isabs(stats_path):
|
||||
@@ -70,6 +72,8 @@ if cfg.data.normalize_actions:
|
||||
if not os.path.exists(stats_path):
|
||||
raise FileNotFoundError(f"统计文件不存在: {stats_path}")
|
||||
print(f"HailoPredictor stats: {stats_path}")
|
||||
elif norm_mode == "fixed_range":
|
||||
print("fixed_range 归一化:免 action_stats.npy,按固定物理范围 min-max")
|
||||
|
||||
from torchvision import transforms
|
||||
tf = transforms.Compose([
|
||||
@@ -82,9 +86,12 @@ tf = transforms.Compose([
|
||||
def torch_pred(imgs, hist, use_learned_pred=True, imu=None):
|
||||
x = torch.stack([tf(Image.fromarray(im)) for im in imgs]).unsqueeze(0)
|
||||
h = torch.tensor(hist).unsqueeze(0)
|
||||
if stats_path is not None:
|
||||
if norm_mode == "zscore" and stats_path is not None:
|
||||
stats = np.load(stats_path)
|
||||
h = (h - torch.tensor(stats[0])) / torch.tensor(stats[1])
|
||||
elif norm_mode == "fixed_range":
|
||||
from action_norm import normalize_action
|
||||
h = torch.from_numpy(normalize_action(h.numpy(), cfg.data.action_dim))
|
||||
ii = None
|
||||
if imu_dim > 0:
|
||||
# 与 HailoPredictor 同口径:原始姿态 → 内部归一化前需手动归一
|
||||
@@ -104,7 +111,10 @@ def torch_pred(imgs, hist, use_learned_pred=True, imu=None):
|
||||
# R2 方案一:imu_dim>0 时每步传原始姿态(HailoPredictor 内部归一化)
|
||||
imu_real = np.array([5.0, -3.0] + [0.0] * 6, dtype=np.float32)
|
||||
|
||||
with HailoPredictor(HEF_PATH, stats_path=stats_path) as p:
|
||||
# normalize_actions=False 时 normalize_mode 无意义(stats_path=None 即不归一化)
|
||||
with HailoPredictor(HEF_PATH, stats_path=stats_path,
|
||||
normalize_mode=(norm_mode if norm_mode != "none"
|
||||
else "zscore")) as p:
|
||||
print(f"\ndims: frame_len={p.frame_len} img={p.image_width}x{p.image_height} "
|
||||
f"history={p.history_len} action_dim={p.action_dim}")
|
||||
t0 = time.perf_counter()
|
||||
@@ -130,9 +140,13 @@ with HailoPredictor(HEF_PATH, stats_path=stats_path) as p:
|
||||
|
||||
ref = torch_pred(imgs_real, hist_real, True, imu_real)
|
||||
ref_zero_pred = torch_pred(imgs_real, hist_real, False, imu_real)
|
||||
# torch 参考是模型原始 z 空间输出;HailoPredictor 带 stats 时已反归一化
|
||||
# torch 参考是模型原始归一化空间输出;HailoPredictor 已反归一化
|
||||
# 到原始动作空间,比较前须同空间对齐,否则归一化模型下 max_diff 无意义
|
||||
if stats_path is not None:
|
||||
if norm_mode == "fixed_range":
|
||||
from action_norm import denormalize_action
|
||||
ref = denormalize_action(ref, cfg.data.action_dim)
|
||||
ref_zero_pred = denormalize_action(ref_zero_pred, cfg.data.action_dim)
|
||||
elif stats_path is not None:
|
||||
s = np.load(stats_path)
|
||||
ref = ref * s[1] + s[0]
|
||||
ref_zero_pred = ref_zero_pred * s[1] + s[0]
|
||||
|
||||
@@ -25,7 +25,8 @@ model.load_state_dict(ck["model_state_dict"])
|
||||
model.eval()
|
||||
|
||||
action_stats = None
|
||||
if cfg.data.normalize_actions:
|
||||
norm_mode = cfg.data.normalize_mode if cfg.data.normalize_actions else "none"
|
||||
if norm_mode == "zscore":
|
||||
import os
|
||||
stats_path = cfg.data.action_stats_path or os.path.join(
|
||||
os.path.dirname(os.path.abspath(CKPT)), "action_stats.npy")
|
||||
@@ -37,6 +38,8 @@ if cfg.data.normalize_actions:
|
||||
raise ValueError(f"action stats 形状应为 (2, {cfg.data.action_dim}),"
|
||||
f"实际为 {action_stats.shape}: {stats_path}")
|
||||
print(f"history 将 z-score 归一化({stats_path})")
|
||||
elif norm_mode == "fixed_range":
|
||||
print("history 将 fixed_range min-max 归一化(action_norm.py,免 stats)")
|
||||
|
||||
sess = ort.InferenceSession(ONNX)
|
||||
in_names = [i.name for i in sess.get_inputs()]
|
||||
@@ -62,7 +65,11 @@ hist_real = np.array([
|
||||
], dtype=np.float32)
|
||||
hist_zero = np.zeros((5, 18), dtype=np.float32)
|
||||
|
||||
if action_stats is not None:
|
||||
if norm_mode == "fixed_range":
|
||||
from action_norm import normalize_action
|
||||
hist_real = normalize_action(hist_real, cfg.data.action_dim)
|
||||
hist_zero = normalize_action(hist_zero, cfg.data.action_dim)
|
||||
elif action_stats is not None:
|
||||
hist_real = (hist_real - action_stats[0]) / action_stats[1]
|
||||
hist_zero = (hist_zero - action_stats[0]) / action_stats[1]
|
||||
|
||||
|
||||
@@ -16,6 +16,17 @@ pip install onnxscript onnxruntime -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
pip check
|
||||
mkdir -p checkpoints data
|
||||
|
||||
# R2-4 防呆:确认装到的是 CPU 版 torch——requirements.txt 含 torch,
|
||||
# 若被镜像源重装成 CUDA 版(无 GPU 部署会被 CUDA 依赖拖垮)在此拦截
|
||||
python3 -c "
|
||||
import torch
|
||||
assert torch.version.cuda is None, (
|
||||
f'检测到 CUDA 版 torch({torch.__version__}),'
|
||||
'请用 _set.sh 的 CPU 源重装: pip install torch torchvision '
|
||||
'--index-url https://download.pytorch.org/whl/cpu --force-reinstall')
|
||||
print('torch CPU build OK:', torch.__version__)
|
||||
"
|
||||
|
||||
# Pre-download MobileNetV2 weights for offline fine-tuning (optional, skip if backbone=simple)
|
||||
python3 -c "
|
||||
import torch, sys, os
|
||||
|
||||
@@ -54,9 +54,13 @@ def denormalize_action(norm_action, action_dim: int = None) -> np.ndarray:
|
||||
|
||||
def default_column_weights(action_dim: int = 18) -> np.ndarray:
|
||||
"""逐列损失权重默认表:舵机 1.0(安全关键)、spk 0.2(保留梯度)、
|
||||
保留位 0.0(恒 0 纯噪声,参与只会稀释有效梯度)"""
|
||||
保留位 0.0(恒 0 纯噪声,参与只会稀释有效梯度)。
|
||||
action_dim>18 的扩展列默认 1.0:超出当前 18 列语义布局的通道视为
|
||||
新执行器,静默给 0 会让其完全不参与训练(P2-a1)。"""
|
||||
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
|
||||
if action_dim > 18:
|
||||
w[18:] = 1.0
|
||||
return w
|
||||
@@ -43,6 +43,10 @@ class ActionDataset(Dataset):
|
||||
self.rollout_len = max(1, rollout_len)
|
||||
|
||||
self.transform = transforms.Compose([
|
||||
# Resize 不保持宽高比(直接拉伸)是有意为之:推理端
|
||||
# (predict/onnx_infer/_onnx_validate)使用完全相同的预处理,
|
||||
# 训练/推理口径必须一致;改为 letterbox 会使既有 checkpoint
|
||||
# 输入分布失配,如需变更须训练/推理同步修改并重训(P2-d)
|
||||
transforms.Resize((image_height, image_width)),
|
||||
transforms.ToTensor(),
|
||||
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
|
||||
|
||||
@@ -13,6 +13,7 @@ Reshape 序列。此脚本逐项改造(权重数值完全不变):
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -21,6 +22,10 @@ import torch.nn as nn
|
||||
from config import parse_checkpoint_config
|
||||
from model import ActionPredictor
|
||||
|
||||
# 一致性校验阈值:wrapper 改造/ORT 转换与原模型输出差异超过即拦截退出,
|
||||
# 防止 ManualAttnBlock 依赖的 PyTorch 布局约定变更静默穿透(v3-P2-2)
|
||||
MAX_TOLERATED_DIFF = 1e-4
|
||||
|
||||
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)
|
||||
|
||||
@@ -280,7 +285,11 @@ def main():
|
||||
with torch.no_grad():
|
||||
orig_out = model(stacked, dummy_history.squeeze(1),
|
||||
dummy_imu.squeeze(1).squeeze(1) if imu_dim > 0 else None)
|
||||
print(f"wrapper 与原模型最大差异: {(ref.squeeze(1) - orig_out).abs().max():.6e}")
|
||||
wrap_diff = (ref.squeeze(1) - orig_out).abs().max().item()
|
||||
print(f"wrapper 与原模型最大差异: {wrap_diff:.6e}")
|
||||
if wrap_diff > MAX_TOLERATED_DIFF:
|
||||
sys.exit(f"错误: wrapper 与原模型差异 {wrap_diff:.3e} > "
|
||||
f"{MAX_TOLERATED_DIFF:.0e}(ManualAttnBlock 布局约定可能已失效)")
|
||||
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
@@ -292,7 +301,11 @@ def main():
|
||||
if imu_dim > 0:
|
||||
feeds["imu"] = dummy_imu.numpy()
|
||||
ort_out = sess.run(None, feeds)[0]
|
||||
print(f"ONNX 一致性验证通过(最大差异: {np.abs(ort_out - ref.numpy()).max():.6e})")
|
||||
ort_diff = np.abs(ort_out - ref.numpy()).max()
|
||||
if ort_diff > MAX_TOLERATED_DIFF:
|
||||
sys.exit(f"错误: ONNX 与 wrapper 差异 {ort_diff:.3e} > "
|
||||
f"{MAX_TOLERATED_DIFF:.0e}")
|
||||
print(f"ONNX 一致性验证通过(最大差异: {ort_diff:.6e})")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
import torch
|
||||
from torch.export import Dim
|
||||
from config import parse_checkpoint_config
|
||||
from model import ActionPredictor
|
||||
|
||||
# 一致性校验阈值:ORT 转换与原模型输出差异超过即拦截退出(v3-P2-2)
|
||||
MAX_TOLERATED_DIFF = 1e-4
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Export actpred model to ONNX")
|
||||
@@ -75,6 +80,9 @@ def main():
|
||||
with torch.no_grad():
|
||||
torch_out = model(dummy_images, dummy_history, *extra_inputs).numpy()
|
||||
max_diff = np.abs(ort_out - torch_out).max()
|
||||
if max_diff > MAX_TOLERATED_DIFF:
|
||||
sys.exit(f"错误: ONNX 与 PyTorch 输出差异 {max_diff:.3e} > "
|
||||
f"{MAX_TOLERATED_DIFF:.0e}")
|
||||
print(f"ONNX 一致性验证通过(最大差异: {max_diff:.6e})")
|
||||
except ImportError:
|
||||
print("提示: 安装 onnxruntime 可验证导出模型一致性 (pip install onnxruntime)")
|
||||
|
||||
@@ -2011,6 +2011,10 @@ class TestFixedRangeNorm(unittest.TestCase):
|
||||
# 12 列:舵机 1.0 + spk 0.2
|
||||
w12 = default_column_weights(12)
|
||||
np.testing.assert_allclose(w12[10:], 0.2)
|
||||
# 20 列:超出 18 列语义布局的扩展列默认 1.0(新执行器须参与训练)
|
||||
w20 = default_column_weights(20)
|
||||
np.testing.assert_allclose(w20[12:18], 0.0)
|
||||
np.testing.assert_allclose(w20[18:], 1.0)
|
||||
|
||||
# ---------------- dataset 链路 ----------------
|
||||
|
||||
|
||||
@@ -246,6 +246,9 @@ def main():
|
||||
model.load_state_dict(resume_ckpt["model_state_dict"])
|
||||
optimizer.load_state_dict(resume_ckpt["optimizer_state_dict"])
|
||||
scheduler.load_state_dict(resume_ckpt["scheduler_state_dict"])
|
||||
# 注意:scheduler 状态(含 T_max)随 checkpoint 恢复,LR 余弦曲线
|
||||
# 按原调度继续;resume 时改 --epochs 只影响循环边界,不重塑 LR
|
||||
# 曲线(如需新曲线请重新训练,勿 resume)
|
||||
start_epoch = resume_ckpt["epoch"] + 1
|
||||
best_val_loss = resume_ckpt.get("best_val_loss", float("inf"))
|
||||
patience_counter = resume_ckpt.get("patience_counter", 0)
|
||||
|
||||
Reference in new issue
Block a user