fix(pybot×actpred): v3 审计 P1 修复 3 项——零依赖承诺 + ONNX 元数据/残差全链路

- robot.py numpy 恢复函数级 lazy import,纯 .mt 脚本执行不再需要 numpy(v3-P1-1)
- export_onnx 写入训练配置元数据;onnx_infer 自动识别 normalize_mode/predict_delta 并支持残差加回(v3-P1-2)
- pybot --loop 补 .onnx 后端分支,zscore 缺 action_stats.npy 时告警(P2-a 闭环)
- 回归测试:actpred TestOnnxMetadata +7、pybot 零依赖导入 +1(88/148 全绿)
- 附 v3 审计报告:13 项 v2 修复回归核对 + 7 项新发现(P0-2 遗留 + P2/P3 六项)
This commit is contained in:
qing committed 2026-08-14 22:13:06 +08:00
1 parent b73628b2e5
commit 35ee614ca9
8 files changed
+427 -14

No files matched your search

+133
View File
@@ -1087,6 +1087,139 @@ class TestOnnxPredictor(unittest.TestCase):
OnnxPredictor(path, stats_path=stats_path)
@unittest.skipUnless(
importlib.util.find_spec("onnxscript") is not None
and importlib.util.find_spec("onnxruntime") is not None,
"ONNX 元数据测试需要 onnxscript + onnxruntime")
class TestOnnxMetadata(unittest.TestCase):
"""v3-P1-2:export_onnx 写入训练配置元数据,onnx_infer 自动识别
覆盖 normalize_mode(zscore/fixed_range/none)与 predict_delta 的
元数据写入、自动识别、显式参数覆盖与残差加回数值语义。
"""
@staticmethod
def _write_meta(path, meta):
import onnx
m = onnx.load(path)
for k, v in meta.items():
p = m.metadata_props.add()
p.key, p.value = k, v
onnx.save(m, path)
@staticmethod
def _read_meta(path):
import onnx
return {p.key: p.value for p in onnx.load(path).metadata_props}
def _frames(self, n):
return [np.random.randint(0, 255, (48, 64, 3), dtype=np.uint8)
for _ in range(n)]
def _export_via_script(self, tmpdir, normalize_actions=False,
normalize_mode="zscore", predict_delta=False):
"""经 export_onnx.main 导出小模型,返回 ONNX 路径"""
import export_onnx
cfg = Config()
cfg.data.image_width = 32
cfg.data.image_height = 32
cfg.data.action_dim = 4
cfg.data.history_len = 3
cfg.data.frame_len = 3
cfg.data.normalize_actions = normalize_actions
cfg.data.normalize_mode = normalize_mode
cfg.train.predict_delta = predict_delta
model = ActionPredictor(cfg.model, cfg.data)
model.eval()
ckpt = os.path.join(tmpdir, "best_inference.pt")
torch.save({"model_state_dict": model.state_dict(),
"config": cfg.to_dict()}, ckpt)
out = os.path.join(tmpdir, "model.onnx")
with mock.patch.object(
sys, "argv",
["export_onnx.py", "--model", ckpt, "--output", out]):
with contextlib.redirect_stdout(io.StringIO()):
export_onnx.main()
return out
def test_export_writes_metadata_unnormalized(self):
with tempfile.TemporaryDirectory() as tmpdir:
meta = self._read_meta(self._export_via_script(tmpdir))
self.assertEqual(meta["normalize_mode"], "none") # 未归一化训练
self.assertEqual(meta["predict_delta"], "0")
self.assertEqual(meta["action_dim"], "4")
self.assertEqual(meta["imu_dim"], "0")
def test_export_writes_metadata_fixed_range_delta(self):
with tempfile.TemporaryDirectory() as tmpdir:
meta = self._read_meta(self._export_via_script(
tmpdir, normalize_actions=True,
normalize_mode="fixed_range", predict_delta=True))
self.assertEqual(meta["normalize_mode"], "fixed_range")
self.assertEqual(meta["predict_delta"], "1")
def test_export_metadata_roundtrip_predictor(self):
"""导出的 ONNX 直接被 OnnxPredictor 自动识别,无需显式参数"""
from onnx_infer import OnnxPredictor
with tempfile.TemporaryDirectory() as tmpdir:
out = self._export_via_script(
tmpdir, normalize_actions=True,
normalize_mode="fixed_range", predict_delta=True)
with OnnxPredictor(out) as p:
self.assertEqual(p.normalize_mode, "fixed_range")
self.assertTrue(p._predict_delta)
def test_predictor_metadata_fallback_old_model(self):
"""旧版无元数据 ONNX:回退 zscore + 非残差"""
from onnx_infer import OnnxPredictor
with tempfile.TemporaryDirectory() as tmpdir:
path, _ = TestOnnxPredictor._export_small_onnx(tmpdir)
with OnnxPredictor(path) as p:
self.assertEqual(p.normalize_mode, "zscore")
self.assertFalse(p._predict_delta)
def test_explicit_param_overrides_metadata(self):
from onnx_infer import OnnxPredictor
with tempfile.TemporaryDirectory() as tmpdir:
path, _ = TestOnnxPredictor._export_small_onnx(tmpdir)
self._write_meta(path, {"normalize_mode": "fixed_range",
"predict_delta": "1"})
with OnnxPredictor(path, normalize_mode="zscore",
predict_delta=False) as p:
self.assertEqual(p.normalize_mode, "zscore")
self.assertFalse(p._predict_delta)
def test_predict_delta_adds_last_history(self):
"""残差输出 = 普通输出 + history 末步(none 模式下口径直接可比)"""
from onnx_infer import OnnxPredictor
with tempfile.TemporaryDirectory() as tmpdir:
path, _ = TestOnnxPredictor._export_small_onnx(tmpdir)
frames = self._frames(3)
history = np.random.randn(3, 4).astype(np.float32)
with OnnxPredictor(path, normalize_mode="none") as plain:
base = plain.predict(frames, history)
with OnnxPredictor(path, normalize_mode="none",
predict_delta=True) as delta:
out = delta.predict(frames, history)
np.testing.assert_allclose(out, base + history[-1], atol=1e-5)
def test_none_mode_ignores_stats(self):
"""none 模式(训练未归一化):即使传 stats 也不做归一化"""
from onnx_infer import OnnxPredictor
with tempfile.TemporaryDirectory() as tmpdir:
path, _ = TestOnnxPredictor._export_small_onnx(tmpdir)
stats_path = os.path.join(tmpdir, "action_stats.npy")
np.save(stats_path, np.stack([np.full(4, 0.5), np.full(4, 2.0)]))
frames = self._frames(3)
history = np.random.randn(3, 4).astype(np.float32)
with OnnxPredictor(path, stats_path=stats_path,
normalize_mode="none") as p:
out = p.predict(frames, history)
with OnnxPredictor(path, normalize_mode="none") as q:
ref = q.predict(frames, history)
np.testing.assert_allclose(out, ref, atol=1e-6)
# ---------------- HailoPredictor 测试用 fake hailo_platform ----------------
class _FakeFormatType: