From 406f180caba0f9d9dbe4ec80e2c362dc8a51222f Mon Sep 17 00:00:00 2001 From: Hark Date: Mon, 19 Oct 2026 10:43:05 +0000 Subject: [PATCH] =?UTF-8?q?diag:=20H=20=E6=AE=B5=EF=BC=88=E8=80=81?= =?UTF-8?q?=E5=B8=88=20oracle=20=E8=B5=B0=20rollout=20/=20=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E6=B6=88=E8=9E=8D=20/=20=E5=AD=90=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E8=B6=8A=E8=BF=87=E9=A1=B6=E7=82=B9=E8=A7=84=E5=88=99=20/=20?= =?UTF-8?q?=E5=88=86=E8=BD=B4=E4=B8=8E=E5=88=86=E7=B1=BB=E5=88=AB=E6=8C=87?= =?UTF-8?q?=E6=A0=87=EF=BC=89=EF=BC=9B=E8=AE=AD=E7=BB=83=EF=BC=9AEMA?= =?UTF-8?q?=E3=80=81=E9=80=89=E6=A8=A1=E4=B8=8D=E8=90=BD=E5=9C=A8=20p=5Fne?= =?UTF-8?q?q=20=E9=A2=84=E7=83=AD=E3=80=81=E9=97=AD=E7=8E=AF=E5=88=86?= =?UTF-8?q?=E6=95=B0=E9=80=89=E6=A8=A1=E3=80=8124000=20=E6=AD=A5=E4=BD=99?= =?UTF-8?q?=E5=BC=A6=E3=80=81p=5Fneq=200.5=20+=20p=5Fedge=200.1=E3=80=81?= =?UTF-8?q?=E5=AD=90=E7=9B=AE=E6=A0=87=E5=A4=B4=E5=88=87=E6=96=AD=E6=8A=84?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E3=80=81=E5=89=8D=E7=BC=80/dq=20=E5=A2=9E?= =?UTF-8?q?=E5=B9=BF=EF=BC=9B=E4=BB=BF=E7=9C=9F=EF=BC=9Alast=20=E8=AE=B0?= =?UTF-8?q?=E7=9C=9F=E6=AD=A3=E6=8F=90=E4=BA=A4=E7=9A=84=E5=80=99=E9=80=89?= =?UTF-8?q?=E3=80=81=E7=A2=B0=E6=92=9E=E4=B8=8E=E5=88=B0=E8=BE=BE=E6=8C=89?= =?UTF-8?q?=E6=AF=8F=E9=A2=98=E6=97=B6=E9=99=90=E3=80=81=E5=9B=9E=E6=94=BE?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E8=AF=BB=20pass+=E6=8C=87=E7=BA=B9=E5=B9=B6?= =?UTF-8?q?=E8=B5=B0=20rollout=EF=BC=9B=E6=95=B0=E6=8D=AE=EF=BC=9A?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E6=A0=87=E7=AD=BE=E6=8C=89=201.25=C2=B7STEP?= =?UTF-8?q?=20=E5=BD=92=E4=B8=80=E5=8C=96=EF=BC=88act=5Fscale=20=E9=9A=8F?= =?UTF-8?q?=20meta/ckpt/ONNX=20=E4=BC=A0=E9=80=92=EF=BC=8C=E6=97=A7?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=201.0=EF=BC=89=E3=80=81=E6=94=B6=E5=9B=9E?= =?UTF-8?q?=E4=BA=8F=E7=A9=BA=E7=BB=93=E8=BD=AC=E3=80=81DAgger=20=E5=B8=A6?= =?UTF-8?q?=20qPrev=20=E4=B8=8E=E8=B5=B7=E6=AD=A5=E9=80=9F=E5=BA=A6?= =?UTF-8?q?=E3=80=81=E6=89=93=E5=8C=85=E7=AD=BE=E5=90=8D=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8D=E6=89=93=EF=BC=9Bik-policy.js=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=EF=BC=88actScale=E3=80=81=E5=AD=90=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E8=A7=84=E5=88=99=E3=80=81DLS=20=E6=97=B6=E6=B8=85=20?= =?UTF-8?q?lastPlan=EF=BC=89=EF=BC=9B=E6=9C=AC=E6=9C=BA=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E4=B8=8E=20README=20=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/本机执行清单.md | 87 +++++------ ml/README.md | 36 +++-- ml/apg/apg_finetune.py | 46 +++--- ml/common/ckpt.py | 30 ++++ ml/configs/base.json | 5 +- ml/configs/small.json | 5 +- ml/configs/wta.json | 5 +- ml/dagger/collect.py | 12 +- ml/dagger/run.sh | 10 +- ml/data.py | 73 ++++++++- ml/eval/acceptance.py | 8 +- ml/eval/check_r2.py | 5 +- ml/eval/diag_cl.py | 286 +++++++++++++++++++++++++++--------- ml/eval/diag_tail.py | 6 +- ml/eval/replay_teacher.py | 117 +++++++++++++-- ml/export_onnx.py | 10 +- ml/gen/gen_dataset.cjs | 12 +- ml/gen/timelaw.cjs | 23 ++- ml/mining/gate.py | 4 +- ml/mining/mine_round.py | 4 +- ml/model/meanflow.py | 12 +- ml/monitor.py | 3 +- ml/pipeline.sh | 24 ++- ml/prep/build_dataset.cjs | 47 ++++-- ml/prep/run_prep.sh | 7 +- ml/pstate.py | 2 + ml/sim/closed_loop.py | 200 +++++++++++++++++++------ ml/train.py | 111 +++++++++----- tests/policy_bench.cjs | 2 +- webapp/js/ik-policy-glue.js | 2 +- webapp/js/ik-policy.js | 46 ++++-- 31 files changed, 923 insertions(+), 317 deletions(-) diff --git a/docs/本机执行清单.md b/docs/本机执行清单.md index 5991c3e..3c084aa 100644 --- a/docs/本机执行清单.md +++ b/docs/本机执行清单.md @@ -3,68 +3,69 @@ 本文件是 Hark 对本机计算的唯一要求清单。按顺序执行,每一步把「回传」里的文件或输出原样交给用户,不要跳步,不要自行改代码。 所有命令在仓库根目录(G:\Robot_v2\机器人手臂)的 Git Bash 里运行。完成的条目由 Hark 在下一次提交里删掉。 -## 已知结论(截至 2026-10-19 03:37 回传) +## 已知结论(截至 2026-10-19 05:35 回传) -- 特征口径 Python 与 JS 一致;子目标、抄上一帧速度、起步都已排除;gaps 显存已过。 -- 一步采样残留噪声真实(抖动 42×、残差-噪声相关 −0.86),2 段以上基本修掉;4 段闭环碰撞 99.6%→93%、2 s 误差比 0.815→0.610,到达仍 0%。零噪声 0%/100%(均值样本偏老师 ~4°)。 -- 新发现的错误:闭环一致性打分错位一步(对齐上一计划的第 3 个点,应为第 4 个),等于每次重规划奖励「比上一计划慢一步」的候选。Python 闭环和浏览器部署已同时修正。第 1 步的 C 段和 G 段都按修正后口径跑。 +- 现有模型 `ml/runs/base/last.pt` 闭环 0% 到达:1 段采样碰撞 99.6%;4 段采样 94%;4 段 + 仿真安全层碰撞 0.78%,到达仍 0%。consist / value / first 三种选法结果几乎一样,问题出在候选本身,不在挑哪条。 +- 三份代码审查逐行核对了滚动索引、前缀、提交、标签与执行的对齐,没有找到错位;但之前没有任何测试真正走过 `rollout()`。下面第 1 步的 H 段把「仿真错了」「前缀照抄」「子目标切换规则」「多峰」几种解释逐个分开。 +- 之前「前 1 s 速度比 2.31×」按 6 轴角度直接相加,被 J5/J6 抖动和 pos 任务冻结的 J6 放大。H 段改为分轴、净位移、TCP 三种口径。 ## 第 0 步:同步代码 ```bash -git status --short # 有未提交改动就先 commit,不要丢 +git status --short # 有未提交的改动先 commit(git add -A && git commit -m "本机改动"),不要丢 git pull --rebase gitea main -git log --oneline -1 # 应是 Hark 的「fix(closed_loop): 一致性打分错位 …」 +git log --oneline -1 # 应是 Hark 的「diag: H 段(老师 oracle / 前缀消融 / 子目标规则 / 分轴指标)…」 ``` -- 训练控制台若开着先点「停止」,确认没有 python 训练进程在跑。 -- 不要启动训练 / DAgger / M5,等 Hark 看完第 1 步结果再定。 +- 训练控制台若开着,先点「停止」,确认没有 python 训练进程在跑。 +- 不要启动训练、DAgger、M5。 -## 第 1 步:闭环失败定位(含新加的 G 段,约 10–15 分钟) +## 第 1 步:闭环失败定位 H 段(约 35–50 分钟) ```bash PYTHONUTF8=1 python -m ml.eval.diag_cl --ckpt ml/runs/base/last.pt --n 256 --out ml/runs/diag_cl.json ``` -- 专用显存超过 22 GB 就 Ctrl+C 并回传终端输出。 -- 新增输出:`G_1step_consist`、`G_4step_consist`、`G_4step_value`、`G_4step_first`、`G_4step_consist_safety`,每项含到达、碰撞、2 s 目标误差比、末端误差、首次碰撞时刻、前 1 s 速度比、刹停次数。F 段已删。 +- 专用显存超过 22 GB 就 Ctrl+C,回传终端最后 30 行和已写出的 `ml/runs/diag_cl.json`(每段跑完就写一次,中断也保留已完成的段)。 +- 现有 checkpoint 没有 EMA、没有 `act_scale`:脚本按原始权重、动作尺度 1.0 载入(输出里 `weights`=`model`、`act_scale_ckpt`=1.0),与之前各轮同口径。 - 回传:`ml/runs/diag_cl.json`。 +### 输出键 + +| 键 | 内容 | +|---|---| +| `A_*`、`B_act_diff_deg` | 起点特征 Python 与数据集逐组差;两份特征下的动作差(与之前相同) | +| `C` | 旧部署口径(1 段采样、子目标旧规则 timeout)的失败形态。到达与碰撞改为只计每题自己的时限内,数字可能比上一轮略低 | +| `H1_oracle` | 老师当策略走完整 `rollout()`。`oracle` 字段:`max_dev_deg_before_dls`(DLS 接管前与老师轨迹的最大关节偏差,p50 / p99 / max)、`reach`、`collision`、`finish_minus_teacher_s`(完成时刻 − 老师时长)、`dls_used` | +| `H1_oracle_noise_0.05` / `_0.1` / `_0.2` | 老师动作每次规划加 N(0, σ²)(σ 以 STEP 为单位) | +| `H2_open_loop` | r = 3 的 val 行:`s{1,4}_prefix_orig`(前缀原样,执行窗口 3..5 的误差,另含 `prefix_copy_err_deg_mean`)vs `s{1,4}_prefix_zeroed`(前缀置零) | +| `H2_replan_empty_s1` / `_s4` | 队列空了才规划(每次 r = 0),其余同新部署默认 | +| `H3_headsub_cross_s{1,4}` | 子目标头 + 越过顶点规则(新部署默认) | +| `H3_headsub_timeout_s{1,4}` | 子目标头 + 旧规则(< 5° 或 1.5 s 超时,旧部署) | +| `H3_teachersub_cross_s{1,4}` / `H3_teachersub_timeout_s{1,4}` | 老师真实途经点全表,两种切换规则 | +| `H3_nosub_s{1,4}` | 不用子目标 | +| `H4_open_loop` | 按前缀长度 r = 0..3 分组的执行窗口误差 \|Σ(act − Y)·STEP\|(度),1 段与 4 段采样 | + +每个闭环变体(H1–H3)都带同一组指标:`reach`、`collision`(cat ≠ D、时限内)、`goal_ratio_2s_p50`、`end_goal_err_mm_p50`、`first_hit_s_p50`、`t_done_over_teacher_p50`、`brakes_p50`、`joint_amp_ratio`(6 轴前 1 s 关节行程 策略/老师)、`joint_net_ratio`(6 轴前 1 s 净位移 策略/老师)、`j6_pos_amp_deg_p50`、`tcp_path_ratio_p50`、`tcp_net_ratio_p50`、`sign_flip_rate` 与 `sign_flip_rate_teacher`(6 轴前 2 s 相邻 3 步块变号比例)、`by_cat_mode`(各类别 n / reach / collision / goal_ratio_2s_p50)、`D`(D 类单列)。`_s1` = 1 段采样(部署),`_s4` = 4 段采样。 + +### 判读 + +| 现象 | 结论 | +|---|---| +| `H1_oracle` 的 reach < 0.95、collision > 0.01、或 `max_dev_deg_before_dls.p99` > 0.1° | 仿真 / 滚动本身有错,之前所有闭环 0% 都不可信。先修仿真,不看下面各项 | +| `H1_oracle` 全部正常 | 仿真没问题,失败在策略输出。再对照 `H1_oracle_noise_*`:σ = 0.05 时 reach 就大幅下降,说明闭环对残差噪声极其敏感,单靠降噪不够 | +| `H2_open_loop` 的 `prefix_zeroed` 执行窗口误差明显小于 `prefix_orig`,或 `H2_replan_empty` 明显好于同段数的 `H3_headsub_cross`(reach > 0,或 `goal_ratio_2s_p50` 明显更低、`sign_flip_rate` 明显更低) | 前缀照抄(copycat)是主因 | +| `H3_teachersub_cross` 明显好于 `H3_teachersub_timeout`(或 `H3_headsub_cross` 好于 `H3_headsub_timeout`) | 子目标切换规则是主因之一 | +| `H3_teachersub_*` 明显好于 `H3_headsub_*` | 子目标头给的途经点不可用 | +| `H3_nosub` 不比 `H3_headsub_cross` 差 | 子目标通道可以整体去掉 | +| `by_cat_mode` 里 `A/pos`(老师就是一条直线)reach 也是 0 | 失败与多峰无关 | +| `joint_amp_ratio` 远大于 `joint_net_ratio`,且 `sign_flip_rate` 远高于 `sign_flip_rate_teacher` | 「动得多、走得少」是来回抖,不是速度律学快了 | + +重新打包和重训(`FRESH_FROM=prep`)要等 Hark 看完这一步的结果再定,这一步之后不要自行启动。 + ## 回传汇总 | 步骤 | 回传内容 | |---|---| | 1 | ml/runs/diag_cl.json | | 任一步出错 | 终端最后 30 行输出 | - ---- - -## 执行回传(2026-10-19 05:35,本地 AI) - -### 第 0 步:同步代码 - -pull 前无未提交 tracked 改动;两个 untracked 文件原位保留。log 顶部 = `0f5b84b`「fix(closed_loop): 一致性打分错位 …」✓。无训练进程。 - -### 第 1 步:闭环失败定位(G 段;完整 JSON 已更新到 ml/runs/diag_cl.json) - -**C 段(修正后同口径)**:到达 **0%** / 碰撞 **99.6%** / 2 s 误差比 **0.755**(修正前 0.815);首撞 p50 1.42 s、主碰撞对 J3-J5 95 次——**一致性错位修复没有改变闭环结果**(数值微动但形态不变)。 - -**G 段(5 个变体,n=256)**: - -| 变体 | 到达 | 碰撞 | 2s 误差比 | 末端误差 mm | 首撞 s | 前 1s 速度比 | 刹停 | -|---|---|---|---|---|---|---|---| -| G_1step_consist(部署口径) | 0% | 99.6% | 0.755 | 420 | 1.42 | **2.31** | 0 | -| G_4step_consist | 0% | 94.1% | 0.591 | 430 | 1.80 | 1.26 | 0 | -| G_4step_value | 0% | 94.5% | 0.580 | 410 | 1.66 | 1.31 | 0 | -| G_4step_first | 0% | 96.1% | 0.762 | 465 | 1.98 | 1.17 | 0 | -| G_4step_consist_safety | 0% | **0.78%** | 0.720 | 458 | 0.54 | 1.17 | 9 | - -三个要点: - -1. **候选选择机制无关紧要**:consist / value / first(完全不选)三者到达都是 0%、碰撞 94–96%、误差比 0.58–0.76——换选择器救不了,问题在候选本身(模型输出),不在「挑哪条」。 -2. **1step 闭环速度比 2.31×**:策略前 1 s 走出老师的 2.3 倍速度(4 段采样后降到 1.26×),但 2 s 目标误差只缩 25%——「动得多、走得少」在修掉一致性错位后依然成立。 -3. **4step + 安全层:碰撞几乎归零(0.78%,对比基线 safety 6.6%)但到达仍 0%**、刹停中位 9 次——能防撞,到不了目标。 - -### 本轮无出错,无终端报错段。 - ---- diff --git a/ml/README.md b/ml/README.md index 7c480e6..cda1c0e 100644 --- a/ml/README.md +++ b/ml/README.md @@ -24,7 +24,7 @@ | 3 老师门禁 | `bash ml/pipeline.sh gate1` | 25–30 分钟(单核) | `ml/data/consistency.json` | | 4 生成数据 | `bash ml/pipeline.sh gen` | 不共识约 1 小时;共识约 4–5 小时 | `ml/data/shard_*.jsonl`、`stats.json` | | 5 打包样本 | `bash ml/pipeline.sh prep` | 15–30 分钟 | `ml/data/samples/`、`ml/data/r2.json` | -| 6 预训练 | `bash ml/pipeline.sh train` | 测速 2000 步约 6 分钟,正式约 3 小时 | `ml/runs/base/best.pt` | +| 6 预训练 | `bash ml/pipeline.sh train` | 测速 2000 步约 6 分钟,正式 24000 步约 1.5–2.5 小时(未实测:fp32 MF 分支后的吞吐以测速为准) | `ml/runs/base/best.pt` | | 6′ DAgger 纠偏 | `bash ml/pipeline.sh dagger` | 未实测,估 3–4 小时:2 轮 ×(采起点 5 分钟 + 老师重规划约 30 分钟 + 打包 + 微调 15000 步) | `ml/runs/base_dagger.ckpt`、`ml/runs/base_dag2/best.pt` | | 7 M5 门禁 | `bash ml/pipeline.sh m5` | 估算 10–20 分钟 | `ml/runs/m5_gate_base.json` | | 8 APG 微调 | `bash ml/pipeline.sh apg` | 未实测:展开长度 60–150 次推理随批变化,以监控页前 50 步换算的 ETA 为准 | `ml/runs/apg/best.pt` | @@ -115,7 +115,9 @@ bash ml/pipeline.sh gen # 默认 5 万条、进程数 = CPU bash ml/pipeline.sh prep ``` -每条轨迹按 50 Hz 时间律重放,每 3 步取一个样本(特征与部署同一份 JS 代码计算),30% 样本加 σ = 1° 噪声增广(加噪后间隙 < 3 mm 的不用)。 +每条轨迹按 50 Hz 时间律重放,每 3 步取一个样本(特征与部署同一份 JS 代码计算)。40% 样本对 q 加 σ = 3° 噪声增广,标签改为 12 步内线性收回老师轨迹(加噪后间隙 < 3 mm 或收回途中有碰撞的不用)。 +- **动作标签**:Y = 每步增量 / (act_scale · STEP),夹在 ±1,`act_scale` = 1.25(`ACT_SCALE`)。老师巡航时主导轴每步正好 1 个 STEP,标签最大 0.8,执行端的 ±1 只防越界;被 ±1 截掉的收回量在下一步补上。运行时增量 = clamp(act, ±1) · act_scale · STEP,`act_scale` 写在 checkpoint 和 ONNX meta 里,仿真、APG、浏览器运行时都按它反归一化;没有这个字段的旧模型按 1.0 处理。 +- 每个 shard 打包产物带 `.meta.json`(数据版本 `data_ver`=2、`act_scale`、增广参数、签名 `sig`)。`ACT_SCALE`、`AUG_P`、`AUG_SIGMA`、`AUG_BACK`、`MIN_GAP_MM` 任一变化,签名就变,`run_prep.sh` 只重打签名不符的 shard;没有 meta 的旧打包一律重打。训练读 meta 取 `act_scale`,同一次训练的各目录(主数据 + DAgger)必须一致,否则报错。 认证最小间隙 < 3 mm 的老师轨迹不进训练集(`MIN_GAP_MM=3`,与 `stats.json` 的 `gapDrop` 同一口径):模型只学离障碍 ≥ 3 mm 的走法,运行时投影护栏仍保持 5 mm。 检查 `ml/data/samples/index.json`:`train_samples` 约 170 万、`val_samples` 约 9 万(5% 任务划为 val)。 `MIN_GAP_MM` 必须与生成阶段相同(`stats.json` 的 `minGapFilterMm`),不一致时这一步直接报错(返回码 8)。 @@ -128,15 +130,22 @@ bash ml/pipeline.sh prep bash ml/pipeline.sh train # MeanFlow 头 → ml/runs/bench_base/、ml/runs/base/ ``` -开训前先跑老师轨迹回放自检(`ml/runs/replay_teacher.json`,约 1 分钟):老师轨迹放进 PyTorch 仿真口径,到达 ≥ 95%、碰撞 ≤ 1% 才开训,否则 rc 5 停——口径对不上时闭环指标不可信。 -闭环指标长期 0% 而开环(ol_fk_mm / ol_best_mm / ol_div)正常时,跑 `PYTHONUTF8=1 python -m ml.eval.diag_cl --ckpt ml/runs/base/last.pt --n 256`(约 2–4 分钟,可与训练并行)→ `ml/runs/diag_cl.json`:A 起点特征 Python 与 JS 逐组差、B 两份特征下动作差、C 首次碰撞时刻 / 组对 / 最小目标误差 / 开安全层到达率。 +开训前先跑老师轨迹回放自检(`ml/runs/replay_teacher.json`,约 1–3 分钟),两部分都过才开训: +- 运动学口径:老师 50 Hz 轨迹直接放进 PyTorch 运动学 / 碰撞 / 到达判据,到达 ≥ 95%、碰撞 ≤ 1%; +- 滚动口径:老师当策略(`TeacherOracle`)走完整的闭环仿真 `rollout()`(队列、前缀、提交 3 步、标签 clamp、DLS、时限都与策略同一条路径),时限内到达 ≥ 95%、碰撞 ≤ 1%、DLS 接管前与老师轨迹的最大关节偏差 p99 ≤ 0.1°。 +流程读 json 的 `pass` 字段,并核对 `fp`(当前 `kin.json`)与 `data_fp`(数据 shard 记录的碰撞模型指纹);任一不符就重跑一次回放,仍不过 rc 5 停。 -先跑 2000 步测吞吐(`ml/runs/bench_base/log.jsonl` 的 `samples_per_s`,`eta_h` 是剩余小时数),再正式训练到 60000 步或闭环到达率连续 5 次不升为止。 +闭环指标长期 0% 而开环正常时,跑 `PYTHONUTF8=1 python -m ml.eval.diag_cl --ckpt ml/runs/base/last.pt --n 256`(约 35–50 分钟)→ `ml/runs/diag_cl.json`:A 起点特征 Python 与 JS 逐组差、B 两份特征下动作差、C 旧部署口径的失败形态、H 老师 oracle / 前缀消融 / 子目标规则 / 分轴与分类别指标(各键含义见 `ml/eval/diag_cl.py` 文件头与 `docs/本机执行清单.md`)。 -- **通过线(R10):`samples_per_s` ≥ 5000,即 60000 步 ≤ 3.5 小时**。参考:3090、batch 1024 实测 6.2 k 样本/s ≈ 2.8 小时。MeanFlow 的 JVP 双前向使训练受算力限制,加大 batch 不会提高样本/s;低于通过线时把 `configs/base.json` 的 `fk_caps` 从 96 降到 48,仍不够再把 `steps` 降到 40000 -- 训练中每 2000 步打印一行 `EVAL {...}`:val 任务闭环(部署节奏、不开安全层)`success`、`collision_rate`、`time_ok_1p3`、`start_fail`。时限 = 老师时长 × 2(至少 3 s);另有开环三项(val 样本、老师状态上):`ol_fk_mm` 候选 0 的 16 步末端误差、`ol_best_mm` 8 个候选里最好的一条、`ol_div` 候选多样性(候选间标准差 / 动作幅度,塌成单峰时接近 0)。best.pt 与早停按「到达率 − 0.001 × ol_fk_mm」 -- 损失(2026-10-18 首次正式预训练后改):FK / 平滑只加在 MF 分支 r = t 且 t ≤ 0.3 的配对去噪估计 x̂ = z − t·u 上(原来加在随机噪声的一步采样上,等于回归条件均值,多峰塌成单峰);时间嵌入最高角频率 50(原 1000);MF 分支与 JVP 走 fp32(TF32 矩阵乘);前 5000 步 p_neq = 0。fp32 会让吞吐低于原 6.2 k/s,R10 以新实测为准 -- 数据增广:40% 样本加 σ = 3° 噪声、12 步收回(原 30% / 1° / 8 步),收回途中须无碰 +先跑 2000 步测吞吐(`ml/runs/bench_base/log.jsonl` 的 `samples_per_s`,`eta_h` 是剩余小时数),再正式训练 24000 步(`configs/base.json` 的 `steps`,余弦学习率按这个预算走完)。 + +- **通过线(R10):`samples_per_s` ≥ 5000**(24000 步 × batch 1024 ≈ 1.4 小时)。MeanFlow 的 JVP 双前向使训练受算力限制,加大 batch 不会提高样本/s;低于通过线时把 `configs/base.json` 的 `fk_caps` 从 96 降到 48 +- **EMA**:训练同时维护权重的指数滑动平均(`ema` 0.999),checkpoint 里存 `model`(原始权重)与 `ema`。评估、DAgger、APG 起点、M5、挖错、验收、导出一律用 EMA 权重(没有 `ema` 的旧 checkpoint 用 `model`) +- 训练中每 2000 步用 EMA 权重打印一行 `EVAL {...}`:val 任务闭环(部署节奏、不开安全层)`success`、`collision_rate`、`goal_ratio_2s`(2 s 时目标误差 / 起点误差的中位数)、`time_ok_1p3`、`start_fail`,时限 = 老师时长 × 2(至少 3 s),碰撞与到达只计时限内;开环三项(val 样本、老师状态上):`ol_fk_mm` 候选 0 的 16 步末端误差、`ol_best_mm` 8 个候选里最好的一条、`ol_div` 候选多样性。 +- **选模**:`score` = success + 0.05·(1 − collision_rate) + 0.05·(1 − min(1, goal_ratio_2s)) − 1e-4·min(ol_best_mm, 500),到达率全 0 时闭环指标仍能排序。p_neq 预热期内和预热后第一次评估之前不存 best.pt(一步映射还没训过);早停只在 max(预热 + 2 次评估, 总步数一半) 之后计数,连续 5 次不升即停 +- 损失:FK / 平滑只加在 MF 分支 r = t 且 t ≤ 0.3 的配对去噪估计 x̂ = z − t·u 上;时间嵌入最高角频率 50;MF 分支与 JVP 走 fp32(TF32 矩阵乘);前 5000 步 p_neq = 0,之后 p_neq = 0.5,另有 10% 样本固定在部署用的 (r = 0, t = 1)(`p_edge`) +- 子目标头只用把 sub 特征置零的输入训练(sub 存在时子目标标签与输入是同一个量,原样抄就能答对);运行时子目标头只在冷启动、sub 为空时使用,口径一致。价值头用完整输入 +- 条件输入增广(训练时现做,`aug_pre_drop` 0.15 / `aug_pre_sigma` 0.1 / `aug_dq_sigma` 0.3):前缀随机清空(变成 r = 0 样本,标签不变);前缀加噪声,标签前 r 步同步加、第 r 步扣回;速度特征 dq 加噪声。目的是让模型不能靠照抄上一块和上一帧速度出动作 ## 6′. DAgger 纠偏 @@ -144,7 +153,8 @@ bash ml/pipeline.sh train # MeanFlow 头 → ml/runs/b bash ml/pipeline.sh dagger ``` -用预训练 best.pt 在 train 任务上闭环跑(关安全层),每题取「首次碰撞前 0.2 s」和一个随机时刻的状态做起点(须无碰、间隙 > `MIN_GAP_MM`、离目标 > 5 mm),老师从这些起点重新规划(`gen_dataset.cjs --tasks`,与主数据同认证、同时间律),打包后与主数据一起微调。默认 2 轮(`DAG_ROUNDS`),每轮 8000 题(`DAG_N`)、微调 15000 步(`DAG_STEPS`,lr 3e-4)。老师只用于训练标注,部署仍无老师。M5 门禁读 `ml/runs/<头>_dagger.ckpt` 指向的模型;APG / 挖错的 BC 项也用 DAgger 样本。可断点续跑(采集、老师 shard、打包、微调各有完成标记)。 +起点模型:预训练 best.pt 的闭环 success > 0 时用它,否则用 last.pt(`python -m ml.common.ckpt pick ml/runs/<头>`),都取 EMA 权重;之后每轮按同一规则取上一轮的结果。微调时只有起点已走过自己的 p_neq 预热才不再预热。 +用起点模型在 train 任务上闭环跑(关安全层),每题取「首次碰撞前 0.2 s」和一个随机时刻的状态做起点(须无碰、间隙 > `MIN_GAP_MM`、离目标 > 5 mm),同时记下该时刻的上一帧。老师从这些起点重新规划(`gen_dataset.cjs --tasks`,与主数据同认证),时间律从当前速度在首段方向上的分量起步(`v0`),t = 0 样本的速度特征用真实上一帧;打包后与主数据一起微调。微调的 val 只取主数据,DAgger 样本全部算 train。默认 2 轮(`DAG_ROUNDS`),每轮 8000 题(`DAG_N`)、微调 15000 步(`DAG_STEPS`,lr 3e-4)。老师只用于训练标注,部署仍无老师。M5 门禁读 `ml/runs/<头>_dagger.ckpt` 指向的模型;APG / 挖错的 BC 项也用 DAgger 样本。可断点续跑(采集、老师 shard、打包、微调各有完成标记)。 - 显存预算**浮动**:整卡留 1 GB 给系统(`VRAM_RESERVE_GIB`,默认 1),训练与 APG 进程启动时按"整卡 − nvidia-smi 已用 − 余量"和"CUDA 空闲 − 余量"两者取小自设本进程上限(`ml/common/vram.py`)。占显存的应用多,上限就自动变小,不用改配置;想按定值对拍给 `VRAM_FRAC`(旧口径 0.78)。日志 `vram` 字段记峰值;预训练 `batch=1024` 实测峰值 2.76 GiB、APG `batch=48` 13.08 GiB。显存不够把 `batch` 改成 512。中断后再跑 `bash ml/pipeline.sh train`(或 `all`)会自动从 `ml/runs/<头>/last.pt` 续训(每 10 分钟存一次),测速那 2000 步做过就跳过。训练发散时不会用发散的权重覆盖 `last.pt` / `best.pt`。 ## 7. M5 门禁:选定动作头 @@ -191,7 +201,7 @@ bash ml/pipeline.sh mine # 默认 6 轮;ROUNDS=3 APG_STEPS=1000 MIN ## 10. 导出 ```bash -bash ml/pipeline.sh export # fp32 ONNX + meta.json → webapp/models/(约 20 MB) +bash ml/pipeline.sh export # fp32 ONNX + meta.json(含 act_scale)→ webapp/models/(约 20 MB),导出 EMA 权重 ``` 默认 fp32:部署走 wasm 与 Node CPU,fp16 在 CPU 后端上不保证更快。只有第 13 步用 `&ep=webgpu` 实测 `__POLICY.latency()` 明显受益时,才另导出 fp16 做对比: @@ -234,6 +244,7 @@ python ml/serve.py # 带跨源隔离头的本地服务,wasm - 控制台 `__POLICY.latency()` 查看推理、前瞻、安全层、整次规划的 P50/P99 与节拍;`__POLICY.stats()` 查看刹停、换向、精修次数 - `&autotest=30` 自动跑与 bench 同一批贴柱题,结果在 `__POLICY.autotest` - 运行时投影护栏 5 mm:候选动作离障碍 < 5 mm 时沿间隙梯度外推(训练数据的过滤线是 3 mm,护栏比数据更保守) +- 动作尺度读模型 meta 的 `act_scale`;子目标按「越过顶点」切换(与训练标签同口径:沿拐角平分面越过当前途经点即换下一个,没有超时),与仿真 `closed_loop.sub_advance` 同一规则 - 工具偏移 ≠ 0 时自动交回原规划器 - 离线环境:`bash ml/prep/vendor_ort.sh` 把 onnxruntime-web 放到 `webapp/vendor/ort/`,地址加 `&ortLocal=1`(WebGPU 同样可用) @@ -314,7 +325,8 @@ ml/ sim/closed_loop.py 部署节奏闭环仿真(验证 / 挖错 / 门禁) apg/apg_finetune.py APG 可微微调 mining/ tasks.py(无老师出题)、mine_round.py、gate.py(含 R7)、loop.sh - eval/ dump_parity.cjs + check_parity.py(JS↔PyTorch 对拍)、check_r2.py(R2)、r4_probe.py(R4 无头浏览器探针)、acceptance.py(验收汇总) + eval/ dump_parity.cjs + check_parity.py(JS↔PyTorch 对拍)、check_r2.py(R2)、r4_probe.py(R4 无头浏览器探针)、acceptance.py(验收汇总)、 + replay_teacher.py(开训前回放自检,含老师 oracle 走 rollout)、diag_cl.py(闭环失败定位 A/B/C/H) toy/toy_bimodal.py 2D 双模态对照实验(动作头选型依据) export_onnx.py serve.py configs/ base.json(MeanFlow)、wta.json(回退)、small.json(R4 不过时的 d=128)、apg.json diff --git a/ml/apg/apg_finetune.py b/ml/apg/apg_finetune.py index 59cffe4..fcacee7 100644 --- a/ml/apg/apg_finetune.py +++ b/ml/apg/apg_finetune.py @@ -6,7 +6,10 @@ 2026-10-18:展开长度不再定长 60 次推理(3.6 s,会截掉老师时长 > 3.6 s 的绕远题),改为覆盖本批 exec_s p90×1.2,上限 plans_max; "到达"仍是每 seg_plans 段末的距离项(无到达即停),属有意设计:段末项给出持续的接近梯度。 margin_train 3 mm:与训练数据过滤线一致(认证最小间隙 < 3 mm 的老师轨迹不进训练集),碰撞项不与 BC 标签对抗; - 运行时投影护栏独立保持 5 mm。端点与中点两个铰链取均值,与只查端点同量纲,w_col 无需重标。""" + 运行时投影护栏独立保持 5 mm。端点与中点两个铰链取均值,与只查端点同量纲,w_col 无需重标。 +2026-10-19:起点取 EMA 权重(ckpt.load_policy);动作反归一化乘 act_scale(与数据同一尺度,不一致报错),产物存 act_scale; + 子目标推进改用 closed_loop.sub_advance(默认「越过顶点」,与标签同口径);子目标头 BC 项用 sub 特征置零的输入(同 train.py); + val 只取主数据目录(第一个 --samples)。""" import argparse, json, math, os, random, sys, time import torch @@ -15,7 +18,7 @@ from ml.common.features import features, STEP, H, wrap180 from ml.data import Samples, load_tasks from ml.model.policy import build, wta_loss from ml.model.meanflow import meanflow_loss -from ml.sim.closed_loop import evaluate, tasks_to_tensors, goal_err +from ml.sim.closed_loop import evaluate, tasks_to_tensors, goal_err, sub_advance from ml.common.ckpt import Saver, atomic_save, rng_state, set_rng_state from ml.common import vram @@ -41,18 +44,23 @@ def main(): dev = a.device; torch.manual_seed(a.seed); random.seed(a.seed) vram.limit() os.makedirs(a.out, exist_ok=True) - ck = torch.load(a.init, map_location=dev) - mcfg = ck['cfg'] - model = build(mcfg).to(dev); model.load_state_dict(ck['model']) + from ml.common.ckpt import load_policy + model, ck = load_policy(a.init, dev); mcfg = ck['cfg'] kin = Kin(device=dev) - data = Samples(a.samples, device=dev, split='train') + data = Samples(a.samples, device=dev, split='train', val_dirs=a.samples[:1]) + act_scale = float(ck.get('act_scale', 1.0)) + if abs(act_scale - data.act_scale) > 1e-9: + raise SystemExit(f'起点 act_scale={act_scale} 与样本 act_scale={data.act_scale} 不一致(BC 项标签尺度不同),换同一版数据训练的模型') + model.act_scale = act_scale all_tasks = load_tasks(a.samples) - train_tasks = [t for t in all_tasks if t['split'] == 'train'] + load_pool(a.pool) - val_tasks = [t for t in all_tasks if t['split'] == 'val'][:C['eval_tasks']] + main_tid = {t['tid'] for t in load_tasks(a.samples[:1]) if t['split'] == 'val'} + train_tasks = [t for t in all_tasks if t['tid'] not in main_tid] + load_pool(a.pool) + val_tasks = [t for t in load_tasks(a.samples[:1]) if t['split'] == 'val'][:C['eval_tasks']] w = [C['cat_weights'].get(t.get('cat', 'A'), 1.0) * (2.0 if t.get('hard') else 1.0) for t in train_tasks] opt = torch.optim.AdamW(model.parameters(), lr=C['lr'], betas=(0.9, 0.95), weight_decay=0.0) amp = dev.startswith('cuda') and C.get('amp') == 'bf16' step = torch.tensor(STEP, device=dev) + astep = step * act_scale # 动作 → 度 it0 = 0 if a.resume and os.path.exists(a.resume): rk = torch.load(a.resume, map_location=dev); model.load_state_dict(rk['model']) @@ -68,11 +76,11 @@ def main(): base, _ = evaluate(model.eval(), kin, val_tasks, dev) print('EVAL0', json.dumps(base), flush=True); log.write(json.dumps({'eval': base, 'step': 0}) + '\n') best = base['success'] - atomic_save({'model': model.state_dict(), 'cfg': mcfg, 'step': 0, 'fp': kin.fp, 'eval': base}, os.path.join(a.out, 'best.pt')) + atomic_save({'model': model.state_dict(), 'cfg': mcfg, 'step': 0, 'fp': kin.fp, 'eval': base, 'act_scale': act_scale}, os.path.join(a.out, 'best.pt')) M = C['margin_train_mm']; t0 = time.time() saver = Saver(C.get('ckpt_min', 10)) # 2026-10-18:每 10 分钟 + 收到停止请求时存 last.pt(不再只在每 500 次验证时存) def save_last(n, ev=None): - atomic_save({'model': model.state_dict(), 'opt': opt.state_dict(), 'cfg': mcfg, 'step': n, 'fp': kin.fp, 'eval': ev, 'rng': rng_state()}, os.path.join(a.out, 'last.pt')); saver.mark() + atomic_save({'model': model.state_dict(), 'opt': opt.state_dict(), 'cfg': mcfg, 'step': n, 'fp': kin.fp, 'eval': ev, 'rng': rng_state(), 'act_scale': act_scale}, os.path.join(a.out, 'last.pt')); saver.mark() for it in range(it0, C['steps']): model.train() batch = random.choices(train_tasks, weights=w, k=C['batch']) @@ -81,7 +89,7 @@ def main(): z = torch.randn(B, H, 6, device=dev) q, qp = q0.clone(), q0.clone() queue = torch.zeros(B, 0, 6, device=dev) - subs = torch.zeros(B, 2, 6, device=dev); nsub = torch.zeros(B, dtype=torch.long, device=dev); sub_t = torch.zeros(B, device=dev) + subs = torch.zeros(B, 2, 6, device=dev); nsub = torch.zeros(B, dtype=torch.long, device=dev); sub_t = torch.zeros(B, device=dev); sub_prev = q0.clone() opt.zero_grad(set_to_none=True) stats = {'col': 0.0, 'tgt': 0.0, 'smooth': 0.0} # 展开长度随本批任务时长:覆盖本批 exec_s 的 p90 × 1.2(无老师参考题按 6 s),夹在 [plans, plans_max] @@ -95,11 +103,7 @@ def main(): pre = torch.zeros(B, 3, 6, device=dev); pre[:, :r] = queue[:, :r] qe = q + queue.sum(1) with torch.no_grad(): - if (nsub > 0).any(): - cur = subs[:, 0]; dd = torch.where(kin.cont, wrap180(cur - qe), cur - qe).abs().max(-1).values - adv = (nsub > 0) & ((dd < 5) | (tnow - sub_t > 1.5)) - subs = torch.where(adv[:, None, None], torch.cat([subs[:, 1:], subs[:, 1:]], 1), subs) - nsub = torch.where(adv, nsub - 1, nsub); sub_t = torch.where(adv, torch.full_like(sub_t, tnow), sub_t) + subs, nsub, sub_prev, sub_t = sub_advance(kin, subs, nsub, sub_prev, sub_t, qe.detach(), tnow) feat = features(kin, q, qp, T, R, pre, torch.full((B,), r, device=dev), subs[:, 0], (nsub > 0).float()) with torch.autocast('cuda', dtype=torch.bfloat16, enabled=amp): act, val, sub = model.sample_k(feat, 1, z) @@ -108,8 +112,8 @@ def main(): with torch.no_grad(): need = sub[:, 12].float() > 0.5 subs = q.detach()[:, None] + sub[:, :12].float().view(B, 2, 6) * 180 - nsub = torch.where(need, torch.full_like(nsub, 2), torch.zeros_like(nsub)) - inc = act.clamp(-1, 1) * step # [B,16,6] + nsub = torch.where(need, torch.full_like(nsub, 2), torch.zeros_like(nsub)); sub_prev = q.detach().clone() + inc = act.clamp(-1, 1) * astep # [B,16,6] commit = inc[:, r:r + 3] if r == 0: # 冷启动:先提交一块,下一轮才开始执行 queue = commit; continue @@ -141,7 +145,9 @@ def main(): with torch.autocast('cuda', dtype=torch.bfloat16, enabled=amp): mem = model.encode(X) l_bc = wta_loss(model, mem, Y)[0] if model.head == 'wta' else meanflow_loss(model, mem, Y)[0] - v, s = model.heads(mem) + v, _ = model.heads(mem) + Xs = X.clone(); Xs[:, 68:75] = 0 + _, s = model.heads(model.encode(Xs)) l_bc = l_bc + 0.1 * (v.float() - V).abs().mean() + 0.1 * (s.float() - SG).abs().mean() (C['w_bc'] * l_bc).backward() gn = torch.nn.utils.clip_grad_norm_(model.parameters(), C['grad_clip']) @@ -160,7 +166,7 @@ def main(): res, _ = evaluate(model.eval(), kin, val_tasks, dev) res['it'] = it + 1; res['t'] = time.time() print('EVAL', json.dumps(res), flush=True); log.write(json.dumps({'eval': res}) + '\n'); log.flush() - sd = {'model': model.state_dict(), 'cfg': mcfg, 'step': it + 1, 'fp': kin.fp, 'eval': res} + sd = {'model': model.state_dict(), 'cfg': mcfg, 'step': it + 1, 'fp': kin.fp, 'eval': res, 'act_scale': act_scale} save_last(it + 1, res) # last 带优化器状态供续跑;best 不带(导出/门禁用) if res['success'] > best + 1e-4: best = res['success']; atomic_save(sd, os.path.join(a.out, 'best.pt')) diff --git a/ml/common/ckpt.py b/ml/common/ckpt.py index 818d0cd..f3a714a 100644 --- a/ml/common/ckpt.py +++ b/ml/common/ckpt.py @@ -60,3 +60,33 @@ def set_rng_state(d): try: torch.cuda.set_rng_state_all([t.cpu() for t in d['cuda']]) except Exception: pass return True + + +def load_policy(path, device, prefer_ema=True): + """载入策略(2026-10-19,审查 review_model_train P0-2):优先取 EMA 权重 ck['ema'](train.py 起存),没有就用 ck['model'](旧 checkpoint、APG 产物)。 + model.act_scale = ck['act_scale'](动作标签归一化倍数,见 ml/prep/build_dataset.cjs ACT_SCALE);旧 checkpoint 缺省 1.0 = 原口径。 + 闭环仿真 / DAgger / APG 起点 / 挖错 / 门禁 / 验收 / 导出都经这里载入,保证同一份权重、同一个动作尺度。返回 (model.eval(), ck)。""" + from ml.model.policy import build + ck = torch.load(path, map_location=device) + m = build(ck['cfg']).to(device) + sd = ck.get('ema') if prefer_ema and ck.get('ema') is not None else ck['model'] + m.load_state_dict(sd) + m.act_scale = float(ck.get('act_scale', 1.0)) + return m.eval(), ck + + +def pick_start(run_dir): + """DAgger 起点(2026-10-19,审查 P0-2):best.pt 的闭环 success > 0 才用 best.pt;否则用 last.pt + (success 全 0 时 best.pt 只由开环误差决定,可能落在 p_neq 预热期,一步映射没训过)。载入时取 EMA。""" + best, last = os.path.join(run_dir, 'best.pt'), os.path.join(run_dir, 'last.pt') + if os.path.exists(best): + ev = torch.load(best, map_location='cpu').get('eval') or {} + if (ev.get('success') or 0) > 0 or not os.path.exists(last): return best + return last if os.path.exists(last) else best + + +if __name__ == '__main__': + # python -m ml.common.ckpt pick <运行目录> → 打印 DAgger 起点 checkpoint 路径 + import sys + if len(sys.argv) == 3 and sys.argv[1] == 'pick': print(pick_start(sys.argv[2])) + else: raise SystemExit('usage: python -m ml.common.ckpt pick ') diff --git a/ml/configs/base.json b/ml/configs/base.json index 20269b3..3d48d3b 100644 --- a/ml/configs/base.json +++ b/ml/configs/base.json @@ -1,10 +1,11 @@ { "model": {"d": 192, "heads": 8, "ff": 768, "enc": 4, "dec": 4}, "train": { - "batch": 1024, "lr": 1e-3, "warmup": 1000, "steps": 60000, "wd": 0.01, "betas": [0.9, 0.95], + "batch": 1024, "lr": 1e-3, "warmup": 1000, "steps": 24000, "wd": 0.01, "betas": [0.9, 0.95], "amp": "bf16", "grad_clip": 1.0, "w_mf": 1.0, "w_fk": 0.5, "w_smooth": 0.05, "w_value": 0.1, "w_sub": 0.1, - "p_neq": 0.25, "p_neq_warm": 5000, "fk_tmax": 0.3, "fk_caps": 96, + "p_neq": 0.5, "p_edge": 0.1, "p_neq_warm": 5000, "fk_tmax": 0.3, "fk_caps": 96, + "ema": 0.999, "aug_pre_drop": 0.15, "aug_pre_sigma": 0.1, "aug_dq_sigma": 0.3, "ckpt_min": 10, "eval_every": 2000, "eval_tasks": 512, "log_every": 50, "patience_evals": 5 } } diff --git a/ml/configs/small.json b/ml/configs/small.json index 2c6c3b0..c025574 100644 --- a/ml/configs/small.json +++ b/ml/configs/small.json @@ -1,10 +1,11 @@ { "model": {"d": 128, "heads": 8, "ff": 512, "enc": 4, "dec": 4}, "train": { - "batch": 1024, "lr": 1e-3, "warmup": 1000, "steps": 60000, "wd": 0.01, "betas": [0.9, 0.95], + "batch": 1024, "lr": 1e-3, "warmup": 1000, "steps": 24000, "wd": 0.01, "betas": [0.9, 0.95], "amp": "bf16", "grad_clip": 1.0, "w_mf": 1.0, "w_fk": 0.5, "w_smooth": 0.05, "w_value": 0.1, "w_sub": 0.1, - "p_neq": 0.25, "p_neq_warm": 5000, "fk_tmax": 0.3, "fk_caps": 96, + "p_neq": 0.5, "p_edge": 0.1, "p_neq_warm": 5000, "fk_tmax": 0.3, "fk_caps": 96, + "ema": 0.999, "aug_pre_drop": 0.15, "aug_pre_sigma": 0.1, "aug_dq_sigma": 0.3, "ckpt_min": 10, "eval_every": 2000, "eval_tasks": 512, "log_every": 50, "patience_evals": 5 } } diff --git a/ml/configs/wta.json b/ml/configs/wta.json index 91c8897..2b5b680 100644 --- a/ml/configs/wta.json +++ b/ml/configs/wta.json @@ -1,10 +1,11 @@ { "model": {"d": 192, "heads": 8, "ff": 768, "enc": 4, "dec": 4, "head": "wta", "K": 8}, "train": { - "batch": 1024, "lr": 1e-3, "warmup": 1000, "steps": 60000, "wd": 0.01, "betas": [0.9, 0.95], + "batch": 1024, "lr": 1e-3, "warmup": 1000, "steps": 24000, "wd": 0.01, "betas": [0.9, 0.95], "amp": "bf16", "grad_clip": 1.0, "w_mf": 1.0, "w_fk": 0.5, "w_smooth": 0.05, "w_value": 0.1, "w_sub": 0.1, - "p_neq": 0.25, "p_neq_warm": 5000, "fk_tmax": 0.3, "fk_caps": 96, + "p_neq": 0.5, "p_edge": 0.1, "p_neq_warm": 5000, "fk_tmax": 0.3, "fk_caps": 96, + "ema": 0.999, "aug_pre_drop": 0.15, "aug_pre_sigma": 0.1, "aug_dq_sigma": 0.3, "ckpt_min": 10, "eval_every": 2000, "eval_tasks": 512, "log_every": 50, "patience_evals": 5 } } diff --git a/ml/dagger/collect.py b/ml/dagger/collect.py index 8ea3b3b..d601e79 100644 --- a/ml/dagger/collect.py +++ b/ml/dagger/collect.py @@ -5,7 +5,9 @@ - 撞了的题:首次碰撞前 0.2 s 的状态(策略"快要撞上"的地方,最有用); - 另取一个 [0.3 s, 首撞前或结束] 内均匀随机时刻的状态(偏离老师的一般状态)。 起点必须无碰且间隙 > MIN_GAP_MM(老师从碰撞态出发规划不出来,与 build_dataset 的增广接受同一地板),且离目标 > 5 mm(否则 DLS 接管,不需要老师)。 -输出 tasks_XX.jsonl(按 --parts 均分,供 gen_dataset.cjs --tasks 并行),每行 {q0, T, R, cat, src}。 +输出 tasks_XX.jsonl(按 --parts 均分,供 gen_dataset.cjs --tasks 并行),每行 {q0, qPrev, T, R, cat, src}。 +2026-10-19(审查 F7 / C10):同时记录策略在该时刻的上一帧 qPrev;老师重规划的时间律从当前速度(v0)起步, + t=0 样本的 dq 特征用真实上一帧——原来一律按静止起步标注,DAgger 样本不是策略真正到过的 (q, dq) 状态。 为什么需要:纯行为克隆在闭环里会偏出老师分布(首次 21850 步闭环到达 0%),原方案指望 APG/挖错修,但它们没法从 0% 起步。 老师只用于训练标注,部署仍然无老师。""" import argparse, json, os, random @@ -30,9 +32,9 @@ def main(): gap_min = float(os.environ.get('MIN_GAP_MM') or 3) os.makedirs(a.out, exist_ok=True) kin = Kin(device=a.device) - ck = torch.load(a.ckpt, map_location=a.device) + from ml.common.ckpt import load_policy # 2026-10-19:EMA 权重 + act_scale + model, ck = load_policy(a.ckpt, a.device) assert ck.get('fp') in (None, kin.fp), '碰撞模型指纹与 checkpoint 不符' - model = build(ck['cfg']).to(a.device); model.load_state_dict(ck['model']); model.eval() pool = [t for t in load_tasks(a.samples[:1]) if t['split'] == 'train' and t.get('cat') not in ('D',)] rng = random.Random(a.seed); rng.shuffle(pool); tasks = pool[:a.n] out, stat = [], {'tasks': len(tasks), 'collided': 0, 'states': 0, 'rej_gap': 0, 'rej_near': 0} @@ -61,10 +63,10 @@ def main(): if c - 1 > lo: cand.append(rng.randint(lo, c - 1)) for k in cand: if g[b, k] <= gap_min: stat['rej_gap'] += 1; continue - q = tr[b, k:k + 1] + q = tr[b, k:k + 1]; qprev = tr[b, max(0, k - 1)] e, oe = goal_err(kin, q, T[b:b + 1], R[b:b + 1]) if e.item() < 5 and (not t.get('R') or oe.item() < 5): stat['rej_near'] += 1; continue - out.append({'q0': [round(v, 3) for v in q[0].tolist()], 'T': t['T'], 'R': t.get('R'), 'cat': t.get('cat', 'A'), + out.append({'q0': [round(v, 3) for v in q[0].tolist()], 'qPrev': [round(v, 3) for v in qprev.tolist()], 'T': t['T'], 'R': t.get('R'), 'cat': t.get('cat', 'A'), 'src': {'tid': t['tid'], 'k': k, 'col': bool(hit.numel())}}) del tr, g, per if a.device.startswith('cuda'): torch.cuda.empty_cache() diff --git a/ml/dagger/run.sh b/ml/dagger/run.sh index 080d4fe..daa72af 100644 --- a/ml/dagger/run.sh +++ b/ml/dagger/run.sh @@ -8,6 +8,9 @@ # 最终模型路径写进 ml/runs/_dagger.ckpt(m5 读它)。 # 断点续跑:采集以 collect.json 为完成标记;老师 shard 先写 .part 再改名、未写完的从断点续写;打包以 .tasks.jsonl 为完成标记; # 微调用该轮 last.pt 续训;整轮完成打 ml/runs/_dag/done。 +# 2026-10-19(审查 review_model_train P0-2):每轮起点 = python -m ml.common.ckpt pick <目录>:best.pt 的闭环 success > 0 才用它, +# 否则用 last.pt(success 全 0 时 best.pt 只由开环误差决定、可能落在 p_neq 预热期);train.py --init 载入 EMA 权重, +# 只有起点已走过自己的 p_neq 预热时才不再预热。微调的 val 只取主数据(DAgger 样本全部算 train)。 set -e -o pipefail cd "$(dirname "$0")/../.." PY=${PYTHON:-python} @@ -19,7 +22,7 @@ SAMPLES="ml/data/samples" for ((r=1; r<=R; r++)); do D=ml/data/dag_${TAG}_r$r; SD=ml/data/samples_dag_${TAG}_r$r; OUT=ml/runs/${TAG}_dag$r SAMPLES="$SAMPLES $SD" - if [ -f $OUT/done ]; then echo "[dagger] 第 $r 轮已完成,跳过"; CK=$OUT/best.pt; continue; fi + if [ -f $OUT/done ]; then echo "[dagger] 第 $r 轮已完成,跳过"; CK=$($PY -m ml.common.ckpt pick $OUT); continue; fi if [ -f $D/collect.json ]; then echo "[dagger] 第 $r 轮起点已采集,跳过" else echo "[dagger] 第 $r 轮:用 $CK 闭环采起点($N 题)"; $PY -m ml.dagger.collect --ckpt $CK --samples ml/data/samples --n $N --seed $r --parts $P --out $D; fi cat $D/collect.json @@ -49,13 +52,14 @@ for ((r=1; r<=R; r++)); do done fi mkdir -p $SD - todo=(); for sh in $D/shard_dag_*.jsonl; do [ -e "$sh" ] || continue; b=$(basename $sh .jsonl); [ -f $SD/$b.tasks.jsonl ] || todo+=("$sh"); done + SIG=$(node ml/prep/build_dataset.cjs --sig) # 2026-10-19:与主数据同一套打包签名,参数变了重打(见 run_prep.sh) + todo=(); for sh in $D/shard_dag_*.jsonl; do [ -e "$sh" ] || continue; b=$(basename $sh .jsonl); grep -qF "\"sig\":\"$SIG\"" $SD/$b.meta.json 2>/dev/null && [ -f $SD/$b.tasks.jsonl ] || todo+=("$sh"); done [ ${#todo[@]} = 0 ] || printf '%s\n' "${todo[@]}" | xargs -P "$P" -I{} node ml/prep/build_dataset.cjs {} $SD $PY -m ml.data --index $SD | tail -n 3 res=""; [ -f $OUT/last.pt ] && res="--resume $OUT/last.pt" echo "[dagger] 第 $r 轮:微调 $STEPS 步(样本:$SAMPLES)" $PY -m ml.train --samples $SAMPLES --out $OUT --config $CFGF --init $CK --steps $STEPS --lr $LR --warmup 500 $res - touch $OUT/done; CK=$OUT/best.pt + touch $OUT/done; CK=$($PY -m ml.common.ckpt pick $OUT) done echo "$CK" > ml/runs/${TAG}_dagger.ckpt echo "[dagger] 完成:后续阶段使用 $CK" diff --git a/ml/data.py b/ml/data.py index 9a905f0..b678e06 100644 --- a/ml/data.py +++ b/ml/data.py @@ -1,12 +1,17 @@ -"""样本加载:ml/data/samples/*.{X,Y,V,SG,TID}.npy + *.tasks.jsonl(ml/prep/build_dataset.cjs 产出) +"""样本加载:ml/data/samples/*.{X,Y,V,SG,TID}.npy + *.tasks.jsonl + *.meta.json(ml/prep/build_dataset.cjs 产出) python -m ml.data --index ml/data/samples # 汇总样本/任务数,写 index.json -全部载入内存(200 万样本约 1.5 GB),按任务 split 字段划分 train / val。""" +全部载入内存(200 万样本约 1.5 GB),按任务 split 字段划分 train / val。 +动作尺度(2026-10-19):Y = 每步增量 / (act_scale·STEP)。act_scale 取自各 shard 的 meta.json(DATA_VER 2 起写; + 旧数据没有 meta → act_scale 1.0、data_ver 1,即原口径)。同一次训练的所有目录(主数据 + DAgger)必须同一个 act_scale。""" import argparse, glob, json, os import numpy as np import torch +PREFIX_SL, MASK_SL, DQ_SL, SUB_SL = slice(47, 65), slice(65, 68), slice(15, 21), slice(68, 75) # 特征分组(features.py 拼接顺序) + def load_tasks(dirs): + if isinstance(dirs, str): dirs = [dirs] tasks = [] for d in dirs: for f in sorted(glob.glob(os.path.join(d, '*.tasks.jsonl'))): @@ -16,10 +21,28 @@ def load_tasks(dirs): return tasks +def read_meta(d): + """目录内各 shard 的打包参数(data_ver、act_scale、增广参数、sig)。没有 meta 的旧打包 → {'data_ver': 1, 'act_scale': 1.0}。 + 一部分有、一部分没有,或 act_scale 不一致 → 拒绝(新旧标签尺度混在一起会让动作幅度错 25%)。""" + xs = sorted(glob.glob(os.path.join(d, '*.X.npy'))) + ms = [json.load(open(f[:-6] + '.meta.json', encoding='utf-8')) for f in xs if os.path.exists(f[:-6] + '.meta.json')] + if not ms: return {'data_ver': 1, 'act_scale': 1.0} + if len(ms) != len(xs): raise SystemExit(f'{d}:{len(xs) - len(ms)} 个 shard 缺 meta.json(新旧打包混在一起),重跑 prep') + sc = {float(m.get('act_scale', 1.0)) for m in ms} + if len(sc) != 1: raise SystemExit(f'{d}:各 shard 的 act_scale 不一致 {sorted(sc)},重跑 prep') + return ms[0] + + class Samples: - def __init__(self, dirs, device='cpu', split=None): + def __init__(self, dirs, device='cpu', split=None, val_dirs=None): + """val_dirs:只用这些目录的 val 任务划 val(默认 = 全部 dirs)。2026-10-19(审查 review_closedloop F11): + DAgger 目录的任务起点带速度、来自策略状态,不该混进预训练的 val;train.py / apg 传主数据目录,DAgger 样本全部算 train。""" if isinstance(dirs, str): dirs = [dirs] xs, ys, vs, ss, ts = [], [], [], [], [] + metas = [read_meta(d) for d in dirs] + sc = {float(m['act_scale']) for m in metas} + if len(sc) > 1: raise SystemExit(f'样本目录的 act_scale 不一致 {sorted(sc)}({dirs}):DAgger 样本须与主数据同一版打包') + self.act_scale = sc.pop(); self.data_ver = int(metas[0].get('data_ver', 1)) if metas else 1 for d in dirs: for fx in sorted(glob.glob(os.path.join(d, '*.X.npy'))): b = fx[:-6] @@ -27,7 +50,7 @@ class Samples: ss.append(np.load(b + '.SG.npy')); ts.append(np.load(b + '.TID.npy')) if not xs: raise SystemExit(f'没有样本:{dirs}(先跑 ml/prep/run_prep.sh)') self.tasks = load_tasks(dirs) - val_tid = {t['tid'] for t in self.tasks if t['split'] == 'val'} + val_tid = {t['tid'] for t in load_tasks(val_dirs if val_dirs is not None else dirs) if t['split'] == 'val'} X, Y, V, SG, TID = map(np.concatenate, (xs, ys, vs, ss, ts)) is_val = np.isin(TID, np.fromiter(val_tid, dtype=np.int64)) if val_tid else np.zeros(len(TID), bool) sel = {'train': ~is_val, 'val': is_val, None: np.ones(len(TID), bool)}[split] @@ -35,9 +58,43 @@ class Samples: self.X, self.Y, self.V, self.SG, self.TID = t(X), t(Y), t(V), t(SG), t(TID) self.n = self.X.shape[0] - def batch(self, bs, gen=None): + def batch(self, bs, gen=None, aug=None): + """aug:训练配置(train 段),按 aug_pre_drop / aug_pre_sigma / aug_dq_sigma 做条件输入增广(见 augment);None = 原样。""" i = torch.randint(0, self.n, (bs,), device=self.X.device, generator=gen) - return self.X[i], self.Y[i], self.V[i], self.SG[i] + X, Y = self.X[i], self.Y[i] + if aug: X, Y = self.augment(X.clone(), Y.clone(), aug, gen) + return X, Y, self.V[i], self.SG[i] + + def augment(self, X, Y, cfg, gen=None): + """条件输入增广(2026-10-19,审查 review_data_actions C3 / review_model_train P1-4:前缀与 dq 的 copycat 捷径)。 + 训练集里 r=3 时要执行的 Y[3:6] 几乎就是前缀第 3 步的延续,dq 又永远是老师的干净速度,模型容易学成「照抄上一块」, + 闭环里自己的误差经前缀 / dq 原样带回。三种扰动,标签都保持自洽: + - 前缀丢弃(aug_pre_drop):r>0 的行以该概率清空前缀与掩码 → 变成 r=0 样本。Y 本来就从当前 q 起算,r=0 时标签不变; + - 前缀噪声(aug_pre_sigma,STEP 为单位):r>0 的行一半加噪声 ε。前缀 = 已提交、一定会执行的步,所以标签前 r 步同样 +ε, + 第 r 步减去 Σε 把位置拉回老师轨迹(亏空在下一步补齐,与增广收回同理); + - dq 噪声(aug_dq_sigma,STEP 为单位):一半的行给速度特征加噪声,状态与标签不变。""" + B, dev = X.shape[0], X.device + rnd = lambda *s: torch.rand(*s, device=dev, generator=gen) + rn = lambda *s: torch.randn(*s, device=dev, generator=gen) + pl = X[:, MASK_SL].sum(-1).round().long() + pd = float(cfg.get('aug_pre_drop', 0.0)) + if pd > 0: + m = (rnd(B) < pd) & (pl > 0) + X[:, 47:68] = torch.where(m[:, None], torch.zeros_like(X[:, 47:68]), X[:, 47:68]); pl = torch.where(m, torch.zeros_like(pl), pl) + ps = float(cfg.get('aug_pre_sigma', 0.0)) + if ps > 0: + m = (rnd(B) < 0.5) & (pl > 0) + km = (torch.arange(3, device=dev)[None] < pl[:, None]) & m[:, None] # [B,3] 前缀有效步 + eps = rn(B, 3, 6) * ps * km[..., None].to(X.dtype) # STEP 为单位 + X[:, PREFIX_SL] = X[:, PREFIX_SL] + eps.reshape(B, 18) + Y[:, :3] = Y[:, :3] + eps / self.act_scale # Y 以 act_scale·STEP 为单位 + Y[torch.arange(B, device=dev), pl.clamp(max=Y.shape[1] - 1)] -= eps.sum(1) / self.act_scale + Y.clamp_(-1, 1) + ds = float(cfg.get('aug_dq_sigma', 0.0)) + if ds > 0: + m = (rnd(B) < 0.5).to(X.dtype) + X[:, DQ_SL] = X[:, DQ_SL] + rn(B, 6) * ds * m[:, None] + return X, Y def main(): @@ -46,7 +103,9 @@ def main(): tasks = load_tasks([a.index]) cats = {} for t in tasks: cats[t['cat'] + '/' + t['mode']] = cats.get(t['cat'] + '/' + t['mode'], 0) + 1 - info = {'train_samples': tr.n, 'val_samples': va.n, 'tasks': len(tasks), 'val_tasks': sum(t['split'] == 'val' for t in tasks), 'by_cat_mode': cats} + meta = read_meta(a.index) + info = {'train_samples': tr.n, 'val_samples': va.n, 'tasks': len(tasks), 'val_tasks': sum(t['split'] == 'val' for t in tasks), 'by_cat_mode': cats, + 'data_ver': int(meta.get('data_ver', 1)), 'act_scale': float(meta.get('act_scale', 1.0)), 'sig': meta.get('sig')} json.dump(info, open(os.path.join(a.index, 'index.json'), 'w'), indent=1) print(json.dumps(info, indent=1)) diff --git a/ml/eval/acceptance.py b/ml/eval/acceptance.py index b5d1429..5fe759d 100644 --- a/ml/eval/acceptance.py +++ b/ml/eval/acceptance.py @@ -4,7 +4,7 @@ 其余项读已有文件(缺文件记为 missing,不算通过): ml/runs/policy_bench.json 部署形态:各集合穿模帧 = 0;near 30/30(pos、pose);route 全部到达;nc1000 0 失败;刹停率 < 2%;R8 恢复 ≥ 95%、活锁 = 0 ml/data/r2.json R2 近邻标签 std < 10% 步长 - ml/runs/bench_*/log.jsonl R10 吞吐 ≥ 5000 样本/s(≈ 60000 步 ≤ 3.5 h) + ml/runs/bench_*/log.jsonl R10 吞吐 ≥ 5000 样本/s(24000 步 batch 1024 ≈ 1.4 h) ml/runs/m5_gate_*.json M5 头门禁 ml/runs/mine/r*/gate.json R7 最后一轮左右比例 / 行程门禁 ml/runs/r4.json R4 浏览器推理 batch=8 P99 < 15 ms(r4 探针) @@ -37,8 +37,8 @@ def main(): items.append({'item': name, 'value': value, 'pass': (None if ok is None else bool(ok)), 'rule': rule}) # 1) held-out 闭环(仿真安全层) - ck = torch.load(a.ckpt, map_location=a.device) - model = build(ck['cfg']).to(a.device); model.load_state_dict(ck['model']); model.eval() + from ml.common.ckpt import load_policy # 2026-10-19:EMA 权重 + act_scale + model, ck = load_policy(a.ckpt, a.device) kin = Kin(device=a.device) val = [t for t in load_tasks([a.samples]) if t['split'] == 'val'] reach = [t for t in val if t.get('reached') and t['cat'] != 'D'] @@ -97,7 +97,7 @@ def main(): for f in sorted(glob.glob('ml/runs/bench_*/log.jsonl')): recs = [json.loads(l) for l in open(f) if '"samples_per_s"' in l] if recs: sps = max(sps or 0, recs[-1]['samples_per_s']) - add('R10 训练吞吐(样本/s)', sps, sps is not None and sps >= 5000, '≥ 5000(≈ 60000 步 ≤ 3.5 h)') + add('R10 训练吞吐(样本/s)', sps, sps is not None and sps >= 5000, '≥ 5000(24000 步 ≈ 1.4 h)') m5 = [jload(f) for f in sorted(glob.glob('ml/runs/m5_gate_*.json'))] m5 = [m for m in m5 if m] add('M5 头门禁', [{'head': m['head'], 'collision_rate': m['policy_only']['collision_rate']} for m in m5] or 'missing', diff --git a/ml/eval/check_r2.py b/ml/eval/check_r2.py index 8fb5ef3..1928e3f 100644 --- a/ml/eval/check_r2.py +++ b/ml/eval/check_r2.py @@ -60,14 +60,15 @@ def measure(d, a, pool): # 与池无关的口径:全部有效近邻对(取距离较近的一半,排除远处非线性段)上回归 std ~ a + b·dist,截距 a dd = best_d[fin]; jj = best_j[fin]; qf = torch.nonzero(fin).squeeze(1) half = dd <= torch.quantile(dd, 0.5) - Ya, Yb = d.Y[qi][qf[half], :3].float(), d.Y[pi][jj[half], :3].float() + # 2026-10-19:Y 以 act_scale·STEP 为单位(DATA_VER 2 起 1.25),乘回 act_scale 让判据仍是「STEP 的比例」,与旧数据可比 + Ya, Yb = d.Y[qi][qf[half], :3].float() * d.act_scale, d.Y[pi][jj[half], :3].float() * d.act_scale sa = (Ya - Yb).pow(2).mean(-1).mean(-1).sqrt() / math.sqrt(2) A = torch.stack([torch.ones_like(dd[half]), dd[half]], 1) coef = torch.linalg.lstsq(A.cpu(), sa.cpu()[:, None]).solution.squeeze(1) std_norm = max(0.0, coef[0].item()) thr = torch.quantile(best_d[fin], a.frac) m = fin & (best_d <= thr) - Yq, Yp = d.Y[qi][m, :3].float(), d.Y[pi][best_j[m], :3].float() + Yq, Yp = d.Y[qi][m, :3].float() * d.act_scale, d.Y[pi][best_j[m], :3].float() * d.act_scale rms = (Yq - Yp).pow(2).mean(-1).mean(-1).sqrt() # 每对、6 轴 × 3 步平均 std = (rms / math.sqrt(2)) return {'pairs': int(m.sum()), 'pool': P, 'queries': Q, 'nn_dist_threshold': thr.item(), diff --git a/ml/eval/diag_cl.py b/ml/eval/diag_cl.py index cdfed88..efbd890 100644 --- a/ml/eval/diag_cl.py +++ b/ml/eval/diag_cl.py @@ -1,26 +1,34 @@ #!/usr/bin/env python -"""闭环失败定位(只读,手动运行,约 5–8 分钟;训练进行中也可以另开终端跑,显存约 2–3 GB)。 - -2026-10-19 新增:第二次预训练(979a581)开环已经正常(候选0 末端误差 ~6 mm、K 中最好 ~2.9 mm、多样性 0.65), -闭环却仍 100% 碰撞、0% 到达。开环用的是 JS 生成的特征 X,闭环用的是 Python features() 现算的特征, -两者之间只要有一处口径不同,模型在闭环里看到的就是没见过的输入。本脚本回答三件事: - A. 特征口径:val 任务起点(非增广、老师状态)上,Python features() 与数据集 X 的逐组最大差; - B. 同一噪声下,用两份特征得到的动作差(16 步末端关节差,度)——A 有差但 B 很小说明差异无害; - C. 闭环失败形态:首次碰撞时刻分布、碰撞步数、首次碰撞的组对、全程最小目标误差、目标误差进度比; - 以及同一批题开安全层(safety=True,近似部署)的到达率 / 刹停次数。 - G. 候选选择与采样段数:一致性打分错位修正后,1 段/4 段采样 × consist/value/first 三种选法,以及 4 段 + 安全层的闭环对照。 -用法(仓库根目录): +"""闭环失败定位(只读,手动运行,n=256 约 35–50 分钟,显存约 3–6 GB)。 PYTHONUTF8=1 python -m ml.eval.diag_cl --ckpt ml/runs/base/last.pt --n 256 --out ml/runs/diag_cl.json -""" -import argparse, json, os +任务 = val 集前 n 题;老师轨迹按任务 id 从 ml/data/shard_*.jsonl 取(取不到的题不进 H 段,数量记在 H_tasks_missing_teacher)。 +模型经 ckpt.load_policy 载入(有 EMA 用 EMA;act_scale 随 checkpoint,旧 checkpoint = 1.0)。 + +段落: + A. 特征口径:val 任务起点(非增广、老师状态)上,Python features() 与数据集 X 的逐组最大差 / 平均差。 + B. 同一噪声下两份特征得到的动作差(16 步末端关节差,度)。 + C. 旧部署口径(1 段采样、一致性选择、子目标旧规则 timeout)的失败形态:首撞时刻、碰撞步数、首撞组对、最小目标误差、 + 目标误差进度比、开安全层的到达 / 碰撞 / 刹停。到达 = 时限内到达,碰撞只计时限内(2026-10-19 起)。 + H(2026-10-19,三份审查 review_closedloop / review_model_train / review_data_actions 的决定性实验): + H1 老师 oracle 走完整 rollout()(closed_loop.TeacherOracle;select='first'、use_sub=False): + H1_oracle:额外含 oracle 字段——DLS 接管前与老师轨迹的最大关节偏差、到达 / 碰撞、完成时刻 − 老师时长; + H1_oracle_noise_<σ>(σ = 0.05 / 0.1 / 0.2,STEP 为单位,每次规划重采):闭环对残差噪声的敏感度。 + H2 前缀消融:H2_open_loop(r=3 的 val 行,前缀 47:68 原样 vs 置零,执行窗口 3..5 的误差); + H2_replan_empty_s{1,4}:队列空了才规划(每次 r=0、提交 3 步),其余同部署。 + H3 子目标:H3_headsub_cross_s{1,4}(子目标头 + 越过顶点规则 = 新部署默认)、H3_headsub_timeout_s{1,4}(旧规则 = 旧部署)、 + H3_teachersub_cross_s{1,4} / H3_teachersub_timeout_s{1,4}(老师真实途经点全表)、H3_nosub_s{1,4}(不用子目标)。 + H4 指标(上面每个闭环变体都输出同一组键,见 metrics());H4_open_loop:按前缀长度 r=0..3 的执行窗口误差。 + _s1 / _s4 = MeanFlow 1 段(部署)/ 4 段采样。""" +import argparse, json, math, os import torch +from ml.common.ckpt import load_policy from ml.common.kin_torch import Kin from ml.common.features import features, STEP, H -from ml.data import Samples, load_tasks -from ml.model.policy import build -from ml.sim.closed_loop import rollout, goal_err, DT +from ml.data import Samples, load_tasks, read_meta +from ml.sim.closed_loop import rollout, goal_err, DT, TeacherOracle +from ml.eval.replay_teacher import load_teacher, pad_traj, oracle_raw, oracle_summary # 83 维特征分组(与 features.py 拼接顺序一致) GROUPS = [('sincos', 0, 12), ('joint_norm', 12, 15), ('dq', 15, 21), ('p', 21, 24), ('Rq', 24, 30), ('T', 30, 33), @@ -29,26 +37,134 @@ GROUPS = [('sincos', 0, 12), ('joint_norm', 12, 15), ('dq', 15, 21), ('p', 21, 2 PROG_T = (0.5, 1.0, 2.0) # 目标误差 / 起点误差 的取样时刻(s) +K1, K2 = int(round(1.0 / DT)), int(round(2.0 / DT)) # 1 s / 2 s 对应的步数 def pct(v, p): - v = sorted(v) + v = sorted(x for x in v if x is not None and x == x) return v[min(len(v) - 1, int(len(v) * p))] if v else None +def qt(t, p): + t = t[torch.isfinite(t)] + return round(float(t.kthvalue(max(1, min(len(t), int(math.ceil(p * len(t)))))).values), 4) if len(t) else None + + +def rnd(x, n=4): + if isinstance(x, list): return [rnd(v, n) for v in x] + return None if x is None or x != x else round(float(x), n) + + +def pad_len(tr, n): + """轨迹 [B,T,6] 末帧重复补到至少 n 帧(到达后冻结,补帧与冻结等价)。""" + if tr.shape[1] >= n: return tr + return torch.cat([tr, tr[:, -1:].expand(-1, n - tr.shape[1], -1)], 1) + + +def batch_stats(kin, ch, per, TR): + """一批的逐题量(CPU)。窗口:幅度 / 净位移 / TCP 用前 1 s,变号率用前 2 s 的 3 步块。""" + dev = TR.device + stp = torch.tensor(STEP, device=dev) + tr = pad_len(per['traj'].to(dev), K2 + 1); te = pad_len(TR, K2 + 1) + B = tr.shape[0] + amp = lambda x: (x[:, 1:K1 + 1] - x[:, :K1]).abs().sum(1) # [B,6] 前 1 s 关节行程 + net = lambda x: (x[:, K1] - x[:, 0]).abs() # [B,6] 前 1 s 净位移 + def tcp(x): + p = kin.fk(x[:, :K1 + 1].reshape(-1, 6)).view(B, K1 + 1, 3) + return (p[:, 1:] - p[:, :-1]).norm(dim=-1).sum(1), (p[:, -1] - p[:, 0]).norm(dim=-1) + def flips(x): + idx = torch.arange(0, K2 + 1, 3, device=dev) + b = x[:, idx[1:]] - x[:, idx[:-1]] # [B,nb,6] 3 步块增量 + big = b.abs() > 0.1 * stp + v = big[:, 1:] & big[:, :-1] + f = v & (torch.sign(b[:, 1:]) != torch.sign(b[:, :-1])) + return f.sum(1).float(), v.sum(1).float() + tl_p, tn_p = tcp(tr); tl_t, tn_t = tcp(te) + fp_, vp_ = flips(tr); ft_, vt_ = flips(te) + ex = torch.tensor([t.get('exec_s') or float('nan') for t in ch]) + return {'ok': per['ok'], 'col': per['col'] > 0, 'isD': torch.tensor([t.get('cat') == 'D' for t in ch]), + 'pose': torch.tensor([bool(t.get('R')) for t in ch]), 'key': [t.get('cat', '?') + '/' + t.get('mode', '?') for t in ch], + 'r2': per['goal_ratio_2s'], 'err': per['err'], 'first_hit': per['first_hit'], 'brakes': per['brakes'], + 'tratio': per['t_done'] / ex, + 'amp_p': amp(tr).cpu(), 'amp_t': amp(te).cpu(), 'net_p': net(tr).cpu(), 'net_t': net(te).cpu(), + 'tl_p': tl_p.cpu(), 'tl_t': tl_t.cpu(), 'tn_p': tn_p.cpu(), 'tn_t': tn_t.cpu(), + 'fl_p': fp_.cpu(), 'fv_p': vp_.cpu(), 'fl_t': ft_.cpu(), 'fv_t': vt_.cpu()} + + +def metrics(stats): + """H4 指标(每个闭环变体同一组键): + reach / collision:cat≠D、时限内(D 类目标不可达,单列在 D); + goal_ratio_2s_p50:2 s 时目标误差 / 起点误差;end_goal_err_mm_p50;first_hit_s_p50(撞了的题);t_done_over_teacher_p50(到达的题); + joint_amp_ratio[6]:前 1 s 各轴关节行程 Σ|Δq| 策略 / 老师(全体题合计;J6 只算 pose 题,pos 题老师冻结 J6); + joint_net_ratio[6]:前 1 s 各轴净位移 |q(1s) − q(0)| 策略 / 老师(同上);j6_pos_amp_deg_p50:pos 题策略 J6 前 1 s 行程(度,老师为 0); + tcp_path_ratio_p50 / tcp_net_ratio_p50:前 1 s TCP 路程 / 净位移 策略 / 老师(逐题比,老师 > 1 mm 的题); + sign_flip_rate[6] / sign_flip_rate_teacher[6]:前 2 s 相邻 3 步块增量变号的比例(两块都 > 0.1·STEP 才计); + by_cat_mode:各 cat/mode 的 n、reach、collision、goal_ratio_2s_p50(不含 D);D:n、collision、end_goal_err_mm_p50、goal_ratio_2s_p50。""" + c = {k: (sum((s[k] for s in stats), []) if isinstance(stats[0][k], list) else torch.cat([s[k] for s in stats])) for k in stats[0]} + keys = c['key'] + m = ~c['isD']; pose = c['pose'] + def jr(a, b): + out = [] + for j in range(6): + sel = m & pose if j == 5 else m + den = float(b[sel, j].sum()); out.append(float(a[sel, j].sum()) / den if den > 1e-6 else None) + return rnd(out) + tl_ok = m & (c['tl_t'] > 1); tn_ok = m & (c['tn_t'] > 1) + out = {'n': int(m.sum()), + 'reach': rnd(c['ok'][m].float().mean()) if m.any() else None, + 'collision': rnd(c['col'][m].float().mean()) if m.any() else None, + 'goal_ratio_2s_p50': qt(c['r2'][m], .5), 'end_goal_err_mm_p50': qt(c['err'][m], .5), + 'first_hit_s_p50': qt(c['first_hit'][m], .5), 't_done_over_teacher_p50': qt(c['tratio'][m & c['ok']], .5), + 'brakes_p50': qt(c['brakes'][m], .5), + 'joint_amp_ratio': jr(c['amp_p'], c['amp_t']), 'joint_net_ratio': jr(c['net_p'], c['net_t']), + 'j6_pos_amp_deg_p50': qt(c['amp_p'][m & ~pose, 5], .5), + 'tcp_path_ratio_p50': qt(c['tl_p'][tl_ok] / c['tl_t'][tl_ok], .5), 'tcp_net_ratio_p50': qt(c['tn_p'][tn_ok] / c['tn_t'][tn_ok], .5), + 'sign_flip_rate': rnd((c['fl_p'][m].sum(0) / c['fv_p'][m].sum(0).clamp(min=1)).tolist()), + 'sign_flip_rate_teacher': rnd((c['fl_t'][m].sum(0) / c['fv_t'][m].sum(0).clamp(min=1)).tolist())} + by = {} + for i, k in enumerate(keys): + if not bool(m[i]): continue + by.setdefault(k, []).append(i) + out['by_cat_mode'] = {k: {'n': len(ix), 'reach': rnd(c['ok'][ix].float().mean()), 'collision': rnd(c['col'][ix].float().mean()), + 'goal_ratio_2s_p50': qt(c['r2'][ix], .5)} for k, ix in sorted(by.items())} + d = c['isD'] + out['D'] = {'n': int(d.sum()), 'collision': rnd(c['col'][d].float().mean()) if d.any() else None, + 'end_goal_err_mm_p50': qt(c['err'][d], .5), 'goal_ratio_2s_p50': qt(c['r2'][d], .5)} + return out + + +def teacher_subs(recs, device): + """老师真实途经点全表(path 去掉起终点)→ (subs [B,M,6], n [B]),供 rollout(sub_override=...)。""" + vs = [r['path'][1:-1] for r in recs] + M = max(1, max(len(v) for v in vs)) + subs = torch.zeros(len(vs), M, 6, device=device); n = torch.zeros(len(vs), dtype=torch.long, device=device) + for i, v in enumerate(vs): + if v: + x = torch.tensor(v, dtype=torch.float32, device=device); subs[i, :len(v)] = x; subs[i, len(v):] = x[-1]; n[i] = len(v) + return subs, n + + def main(): ap = argparse.ArgumentParser() ap.add_argument('--ckpt', required=True); ap.add_argument('--samples', default='ml/data/samples') + ap.add_argument('--shards', default='ml/data/shard_*.jsonl') ap.add_argument('--n', type=int, default=256); ap.add_argument('--bs', type=int, default=64) ap.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu'); ap.add_argument('--out', default='ml/runs/diag_cl.json') a = ap.parse_args() dev = a.device - ck = torch.load(a.ckpt, map_location=dev) - model = build(ck['cfg']).to(dev); model.load_state_dict(ck['model']); model.eval() + model, ck = load_policy(a.ckpt, dev) kin = Kin(device=dev) - out = {'ckpt': a.ckpt, 'step': ck.get('step')} + astep = torch.tensor(STEP, device=dev) * model.act_scale # 动作 → 度 + data_scale = float(read_meta(a.samples).get('act_scale', 1.0)) + out = {'ckpt': a.ckpt, 'step': ck.get('step'), 'weights': 'ema' if ck.get('ema') is not None else 'model', + 'act_scale_ckpt': model.act_scale, 'act_scale_data': data_scale} tasks = [t for t in load_tasks([a.samples]) if t['split'] == 'val'][:a.n] by_tid = {t['tid']: t for t in tasks} + def save(): + s = json.dumps(out, indent=1, ensure_ascii=False) + os.makedirs(os.path.dirname(a.out) or '.', exist_ok=True) + open(a.out + '.tmp', 'w', encoding='utf-8').write(s); os.replace(a.out + '.tmp', a.out) + return s # ---------- A / B:起点特征口径 ---------- vd = Samples(a.samples, device=dev, split='val') @@ -67,35 +183,69 @@ def main(): T = torch.tensor([t['T'] for t in tc], dtype=torch.float32, device=dev) R = torch.tensor([t['R'] if t.get('R') else [0.] * 9 for t in tc], dtype=torch.float32, device=dev) B = qc.shape[0] + qpc = torch.tensor([t.get('qp0') or t['q0'] for t in tc], dtype=torch.float32, device=dev) pre = Xc[:, 47:65].view(B, 3, 6) * torch.tensor(STEP, device=dev) # 前缀 / 子目标直接取数据集里的,只比状态相关部分 pl = Xc[:, 65:68].sum(-1).round() sf = Xc[:, 74] sub = qc + Xc[:, 68:74] * 180 - Fp = features(kin, qc, qc, T, R, pre, pl, sub, sf) + Fp = features(kin, qc, qpc, T, R, pre, pl, sub, sf) out['A_maxdiff'] = {g: (Fp[:, s:e] - Xc[:, s:e]).abs().max().item() for g, s, e in GROUPS} out['A_meandiff'] = {g: (Fp[:, s:e] - Xc[:, s:e]).abs().mean().item() for g, s, e in GROUPS} with torch.no_grad(): z = torch.randn(B * 8, H, 6, device=dev, generator=torch.Generator(device=dev).manual_seed(7)) a1, _, _ = model.sample_k(Xc, 8, z); a2, _, _ = model.sample_k(Fp, 8, z) - stepv = torch.tensor(STEP, device=dev) - d = ((a1.clamp(-1, 1) - a2.clamp(-1, 1)) * stepv).sum(2).abs().max(-1).values # [B,K] 16 步末端关节差(度) - mag = (a1.clamp(-1, 1) * stepv).sum(2).abs().max(-1).values + d = ((a1.clamp(-1, 1) - a2.clamp(-1, 1)) * astep).sum(2).abs().max(-1).values # [B,K] 16 步末端关节差(度) + mag = (a1.clamp(-1, 1) * astep).sum(2).abs().max(-1).values out['B_act_diff_deg'] = {'mean': d.mean().item(), 'p90': d.flatten().kthvalue(max(1, int(0.9 * d.numel()))).values.item(), 'ref_motion_deg_mean': mag.mean().item()} - # ---------- C:闭环失败形态 ---------- + # ---------- H4 / H2 开环:执行窗口误差(val 行,老师状态) ---------- + # 闭环稳态 r=3,真正执行的是 act[r:r+3];原来的 ol_fk_mm 是 16 步终点误差,前缀那几步几乎不会错,看不出执行窗口。 + with torch.no_grad(): + pl_all = vd.X[:, 65:68].sum(-1).round().long() + gsel = torch.Generator(device=dev); gsel.manual_seed(321) + def rows_with(r, n=1024): + ix = (pl_all == r).nonzero().flatten() + return ix[torch.randperm(len(ix), device=dev, generator=gsel)[:n]] if len(ix) else ix + def win_err(X, Y, r, steps, k0=None): + k0 = r if k0 is None else k0 + z = torch.randn(X.shape[0] * 8, H, 6, device=dev, generator=torch.Generator(device=dev).manual_seed(11)) + act, _, _ = model.sample_k(X, 8, z, steps=steps); act = act.float().clamp(-1, 1) + e = ((act[:, :, k0:k0 + 3] - Y[:, None, k0:k0 + 3]) * astep).sum(2).abs() # [N,K,6] 窗口累计误差(度) + emax = e.max(-1).values.flatten() + res = {'n_rows': int(X.shape[0]), 'deg_mean': rnd(emax.mean()), 'deg_p90': qt(emax, .9), 'per_joint_deg_mean': rnd(e.mean((0, 1)).tolist())} + if r > 0 and k0 == r: + ce = ((act[:, :, :r] - Y[:, None, :r]) * astep).abs().max(-1).values.max(-1).values.flatten() + res['prefix_copy_err_deg_mean'] = rnd(ce.mean()) + return res + ol4, ol2 = {}, {} + for steps in (1, 4): + for r in range(4): + ix = rows_with(r) + if len(ix): ol4['s%d_r%d' % (steps, r)] = win_err(vd.X[ix].float(), vd.Y[ix].float(), r, steps) + ix = rows_with(3) + if len(ix): + X3 = vd.X[ix].float(); Y3 = vd.Y[ix].float(); X3z = X3.clone(); X3z[:, 47:68] = 0 + ol2['s%d_prefix_orig' % steps] = win_err(X3, Y3, 3, steps) + ol2['s%d_prefix_zeroed' % steps] = win_err(X3z, Y3, 0, steps, k0=3) + out['H4_open_loop'] = ol4; out['H2_open_loop'] = ol2 + del vd + if dev.startswith('cuda'): torch.cuda.empty_cache() + + # ---------- C:旧部署口径的闭环失败形态(子目标旧规则,便于与前几轮对比) ---------- first_t, col_steps, pairs, min_err, reach = [], [], {}, [], 0 prog = {ts: [] for ts in PROG_T} reach_s, brakes_s, col_s = 0, [], 0 for i in range(0, len(tasks), a.bs): ch = tasks[i:i + a.bs] with torch.no_grad(): - _, per = rollout(model, kin, ch, dev, K=8, safety=False, seed=i, return_traj=True) + _, per = rollout(model, kin, ch, dev, K=8, safety=False, seed=i, return_traj=True, sub_rule='timeout') tr = per['traj'].to(dev) # [B,Tn,6] B, Tn, _ = tr.shape flat = tr.reshape(-1, 6) # 分块 ≤1024 点(同 dagger/collect.py 的显存修复) gm = torch.cat([kin.gaps(flat[s:s + 1024], 0.5)['gm'] for s in range(0, flat.shape[0], 1024)]).view(B, Tn, 49) - hit = gm.min(-1).values <= 0 # [B,Tn] + inlim = (torch.arange(Tn, device=dev)[None] * DT) <= per['limit'].to(dev)[:, None] + 1e-6 + hit = (gm.min(-1).values <= 0) & inlim # [B,Tn] 只计时限内 Tt = torch.tensor([t['T'] for t in ch], dtype=torch.float32, device=dev) Rt = torch.tensor([t['R'] if t.get('R') else [0.] * 9 for t in ch], dtype=torch.float32, device=dev) e, _ = goal_err(kin, tr.reshape(-1, 6), Tt.repeat_interleave(Tn, 0), Rt.repeat_interleave(Tn, 0)) @@ -103,16 +253,15 @@ def main(): for b in range(B): for ts in PROG_T: k = min(Tn - 1, int(round(ts / DT))); prog[ts].append((e[b, k] / e[b, 0].clamp(min=1e-3)).item()) - # 到达后轨迹被冻结(d=0),之后不再计 for b in range(B): - if per['done'][b]: reach += 1 + if per['ok'][b]: reach += 1 min_err.append(e[b].min().item()); col_steps.append(int(per['col'][b])) h = hit[b].nonzero().flatten() if len(h): k = int(h[0]); first_t.append(k * DT) pr = int(gm[b, k].argmin()); key = '%d-%d' % (pr // 7, pr % 7); pairs[key] = pairs.get(key, 0) + 1 - rs, ps = rollout(model, kin, ch, dev, K=8, safety=True, seed=i) - reach_s += int(ps['done'].sum()); col_s += int((ps['col'] > 0).sum()); brakes_s += ps['brakes'].tolist() + rs, ps = rollout(model, kin, ch, dev, K=8, safety=True, seed=i, sub_rule='timeout') + reach_s += int(ps['ok'].sum()); col_s += int((ps['col'] > 0).sum()); brakes_s += ps['brakes'].tolist() n = len(tasks) out['C'] = {'n': n, 'reach': reach / n, 'collided': len(first_t) / n, 'first_hit_s': {'p10': pct(first_t, .1), 'p50': pct(first_t, .5), 'p90': pct(first_t, .9), @@ -122,46 +271,45 @@ def main(): 'min_goal_err_mm': {'p10': pct(min_err, .1), 'p50': pct(min_err, .5), 'p90': pct(min_err, .9)}, 'safety_on': {'reach': reach_s / n, 'collided': col_s / n, 'brakes_p50': pct(brakes_s, .5)}} out['C']['goal_err_ratio_p50'] = {'%.1fs' % ts: pct(v, .5) for ts, v in prog.items()} # <1 = 在靠近目标 + print(save(), flush=True) - # ---------- G:候选选择与采样段数(2026-10-19 加) ---------- - # 第四轮 F 段:一步采样残留噪声真实(抖动 42×、残差-噪声相关 −0.86),4 段采样闭环碰撞 99.6%→93%,但到达仍 0%; - # 零噪声(8 条候选完全相同,选择不起作用)0%/100%——均值样本本身偏老师 ~4°(多峰平均)。 - # 复查闭环发现一致性打分错位一步(对齐 last[3],应为 last[4]),等于每次重规划奖励「比上一计划慢一步」的候选; - # 已在 closed_loop.py 与 ik-policy.js 修正,上面 C 段即修正后的一步采样基线。 - # G 段在同一批题上比较:4 段采样 × 三种选法(consist 部署口径 / value 每次价值前瞻 / first 不选),以及 4 段 + 安全层。 - # 若 consist≈first,说明选择不是主因;若 value 明显好,说明一致性选法本身把闭环带偏;三者都 0% 则问题在策略本身(转向协变量偏移)。 - def variant(**kw): - rc = cc_ = 0; me, r2, fh, sp, br = [], [], [], [], [] - for i in range(0, len(tasks), a.bs): - ch = tasks[i:i + a.bs] + # ---------- H:闭环变体(同一批 val 题,老师轨迹按 id 取) ---------- + teach = load_teacher(a.shards, [t['id'] for t in tasks]) + tH = [t for t in tasks if t['id'] in teach] + out['H_tasks'] = len(tH); out['H_tasks_missing_teacher'] = len(tasks) - len(tH) + + def variant(oracle_sigma=None, teacher_sub=False, **kw): + stats, raws = [], [] + for i in range(0, len(tH), a.bs): + ch = tH[i:i + a.bs]; recs = [teach[t['id']] for t in ch] + TR = pad_traj([r['traj'] for r in recs], dev) + kw2 = dict(kw) + if oracle_sigma is not None: + kw2.update(oracle=TeacherOracle(TR, act_scale=data_scale, sigma=oracle_sigma), select='first', use_sub=False) + if teacher_sub: kw2['sub_override'] = teacher_subs(recs, dev) with torch.no_grad(): - _, pp = rollout(model, kin, ch, dev, K=8, seed=i, return_traj=True, **kw) - tr = pp['traj'].to(dev); B, Tn, _ = tr.shape - k2 = min(Tn - 1, int(round(2.0 / DT))); k1 = min(Tn - 1, int(round(1.0 / DT))) - Tt = torch.tensor([t['T'] for t in ch], dtype=torch.float32, device=dev) - Rt = torch.tensor([t['R'] if t.get('R') else [0.] * 9 for t in ch], dtype=torch.float32, device=dev) - e0, _ = goal_err(kin, tr[:, 0], Tt, Rt); e2, _ = goal_err(kin, tr[:, k2], Tt, Rt) - r2 += (e2 / e0.clamp(min=1e-3)).tolist() - # 前 1 s 关节行程速度 / 老师平均速度(travel/exec_s) - v1 = (tr[:, 1:k1 + 1] - tr[:, :k1]).abs().sum((1, 2)) / (k1 * DT) - sp += [float(v1[b]) / max(1e-6, ch[b]['travel'] / ch[b]['exec_s']) for b in range(B) if ch[b].get('travel') and ch[b].get('exec_s')] - flat = tr.reshape(-1, 6) - gm = torch.cat([kin.gaps(flat[s0:s0 + 1024], 0.5)['gm'] for s0 in range(0, flat.shape[0], 1024)]).view(B, Tn, 49) - hit = gm.min(-1).values <= 0 - for b in range(B): - h = hit[b].nonzero().flatten() - if len(h): fh.append(int(h[0]) * DT) - rc += int(pp['done'].sum()); cc_ += int((pp['col'] > 0).sum()); me += pp['err'].tolist(); br += pp['brakes'].tolist() - return {'reach': rc / n, 'collided': cc_ / n, 'goal_err_ratio_2s_p50': pct(r2, .5), 'end_goal_err_mm_p50': pct(me, .5), - 'first_hit_s_p50': pct(fh, .5), 'speed_ratio_1s_p50': pct(sp, .5), 'brakes_p50': pct(br, .5)} - out['G_1step_consist'] = variant(steps=1, select='consist') - out['G_4step_consist'] = variant(steps=4, select='consist') - out['G_4step_value'] = variant(steps=4, select='value') - out['G_4step_first'] = variant(steps=4, select='first') - out['G_4step_consist_safety'] = variant(steps=4, select='consist', safety=True) - s = json.dumps(out, indent=1, ensure_ascii=False); print(s) - os.makedirs(os.path.dirname(a.out) or '.', exist_ok=True) - open(a.out, 'w', encoding='utf-8').write(s) + _, per = rollout(None if oracle_sigma is not None else model, kin, ch, dev, K=8, seed=i, return_traj=True, **kw2) + stats.append(batch_stats(kin, ch, per, TR)) + if oracle_sigma is not None: raws.append(oracle_raw(per, TR, [len(r['traj']) for r in recs], ch)) + del per + res = metrics(stats) + if oracle_sigma is not None and oracle_sigma == 0: res['oracle'] = oracle_summary(raws) + if dev.startswith('cuda'): torch.cuda.empty_cache() + return res + + plan = [('H1_oracle', dict(oracle_sigma=0.0))] + plan += [('H1_oracle_noise_%g' % s, dict(oracle_sigma=s)) for s in (0.05, 0.1, 0.2)] + for st in (1, 4): + plan += [('H3_headsub_cross_s%d' % st, dict(steps=st, sub_rule='cross')), + ('H3_headsub_timeout_s%d' % st, dict(steps=st, sub_rule='timeout')), + ('H3_teachersub_cross_s%d' % st, dict(steps=st, sub_rule='cross', teacher_sub=True)), + ('H3_teachersub_timeout_s%d' % st, dict(steps=st, sub_rule='timeout', teacher_sub=True)), + ('H3_nosub_s%d' % st, dict(steps=st, use_sub=False)), + ('H2_replan_empty_s%d' % st, dict(steps=st, sub_rule='cross', replan='empty'))] + for name, kw in plan: + out[name] = variant(**kw) + save(); print('[diag] %s 完成' % name, flush=True) + print(save(), flush=True) if __name__ == '__main__': diff --git a/ml/eval/diag_tail.py b/ml/eval/diag_tail.py index f532ad1..05b1825 100644 --- a/ml/eval/diag_tail.py +++ b/ml/eval/diag_tail.py @@ -43,10 +43,8 @@ def main(): from ml.model.policy import build from ml.sim.closed_loop import rollout, goal_err - ck = torch.load(a.ckpt, map_location=a.device) - model = build(ck['cfg']).to(a.device) - model.load_state_dict(ck['model']) - model.eval() + from ml.common.ckpt import load_policy # 2026-10-19:EMA 权重 + act_scale + model, ck = load_policy(a.ckpt, a.device) kin = Kin(device=a.device) if ck.get('fp') and ck['fp'] != kin.fp: sys.exit('checkpoint 的碰撞模型指纹 %s 与当前 kin.json %s 不符' % (ck['fp'], kin.fp)) diff --git a/ml/eval/replay_teacher.py b/ml/eval/replay_teacher.py index 5448414..ae65dce 100644 --- a/ml/eval/replay_teacher.py +++ b/ml/eval/replay_teacher.py @@ -1,19 +1,90 @@ -"""老师轨迹回放自检(2026-10-18 首次正式预训练后新增;train 阶段开训前自动跑,约 1 分钟): -把 gen 写出的老师 50 Hz 轨迹(keep=true)直接放进闭环仿真用的 PyTorch 运动学 / 碰撞 / 到达判据里检查。 -老师轨迹在 JS 侧已认证无碰、已到达;如果这里判出碰撞或未到达,说明仿真口径(kin.json、间隙、到达门槛)和数据对不上, -那么闭环指标本身不可信,训练多少步都没用 —— 直接停下(rc 5)。 +"""老师轨迹回放自检(train 阶段开训前自动跑,约 1–3 分钟): python -m ml.eval.replay_teacher --n 512 --out ml/runs/replay_teacher.json -判据:到达率(cat≠D)≥ 0.95,碰撞率 ≤ 0.01。""" -import argparse, glob, json, os, sys +两部分,都过才 pass(pipeline.sh 读 pass 字段与指纹,不过就停 rc 5): + 1. 运动学口径(2026-10-18):gen 写出的老师 50 Hz 轨迹(keep=true、cat≠D)直接放进 PyTorch 运动学 / 碰撞 / 到达判据: + 到达率 ≥ 0.95、碰撞率 ≤ 0.01。验证的是「仿真口径(kin.json、间隙、到达门槛)与 JS 认证过的数据一致」。 + 2. 滚动口径(2026-10-19,审查 review_closedloop F1/D1、review_data_actions C1):上面那条完全不经过 rollout(), + 队列 / 前缀 / 提交 3 步 / qe / 标签 clamp / DLS / done / 时限上的错位它都发现不了。这里让老师当策略(closed_loop.TeacherOracle: + 每次规划从当前时间下标取老师 16 步增量,按标签口径 clamp)原样走 rollout()(select='first'、use_sub=False): + 到达(时限内)≥ 0.95、碰撞 ≤ 0.01、DLS 接管前与老师轨迹的最大关节偏差 p99 ≤ 0.1°(shard 轨迹按 1e-3° 取整,标签 clamp 会截掉取整的余量)。 +输出另带 fp(当前 kin.json)、data_fp(shard 里记录的碰撞模型指纹)、ver=2;pipeline 三者都核对。 +load_teacher / pad_traj / oracle_raw / oracle_summary 供 ml/eval/diag_cl.py 的 H1 段复用。""" +import argparse, glob, json, math, os, sys import torch from ml.common.kin_torch import Kin -from ml.sim.closed_loop import goal_err +from ml.sim.closed_loop import goal_err, rollout, TeacherOracle, DT + +DEV_TOL_DEG = 0.1 + + +def load_teacher(shards, ids): + """按任务 id 从 shard jsonl 取老师记录(traj、path、exec_s…)。只解析 id 命中的行。""" + want, out = set(ids), {} + for f in sorted(glob.glob(shards)): + for line in open(f, encoding='utf-8'): + k = line.find('"id":"') + if k < 0: continue + rid = line[k + 6:line.find('"', k + 6)] + if rid in want and rid not in out: + r = json.loads(line) + if r.get('traj'): out[rid] = r + if len(out) == len(want): break + return out + + +def pad_traj(trajs, device, extra=20): + """[B,Tmax+extra,6]:各题老师轨迹末帧重复补齐(oracle 在轨迹末尾之后给零增量)。""" + Tm = max(len(t) for t in trajs) + extra + out = torch.zeros(len(trajs), Tm, 6, device=device) + for i, t in enumerate(trajs): + x = torch.tensor(t, dtype=torch.float32, device=device) + out[i, :len(t)] = x; out[i, len(t):] = x[-1] + return out + + +def oracle_raw(per, TR, lens, tasks): + """oracle 滚动的逐题量:DLS 接管前(且到达前、老师轨迹结束前)与老师的最大关节偏差 mdev(度)、时限内到达 ok、 + 时限内碰撞 col、是否计入到达统计(cat≠D)、完成时刻 − 老师时长 dt(s)、是否用过 DLS、2 s 目标误差比。""" + tr = per['traj'].to(TR.device); B, Tn, _ = tr.shape + L = min(Tn, TR.shape[1]) + dev = (tr[:, :L] - TR[:, :L]).abs().max(-1).values # [B,L] + k = torch.arange(L, device=TR.device)[None] + kd = torch.tensor(lens, device=TR.device)[:, None] - 1 + dls = per['dls_it'].to(TR.device)[:, None] + td = per['t_done'].to(TR.device) + kdone = torch.where(torch.isfinite(td), (td / DT).round(), torch.full_like(td, 1e9)).long()[:, None] + valid = (k <= kd) & ((dls < 0) | (k <= dls)) & (k <= kdone) + ex = torch.tensor([t.get('exec_s') or float('nan') for t in tasks]) + return {'mdev': torch.where(valid, dev, torch.zeros_like(dev)).max(-1).values.cpu(), 'ok': per['ok'], 'col': per['col'] > 0, + 'reach_m': torch.tensor([t.get('cat') != 'D' for t in tasks]), 'dt': per['t_done'] - ex, + 'dls': per['dls_it'] >= 0, 'r2': per['goal_ratio_2s']} + + +def _q(v, p): + v = v[torch.isfinite(v)] + return float(v.kthvalue(max(1, min(len(v), int(math.ceil(p * len(v)))))).values) if len(v) else None + + +def oracle_summary(raws): + c = {k: torch.cat([r[k] for r in raws]) for k in raws[0]} + m = c['reach_m']; ok = c['ok'] + dt = c['dt'][ok & m] + return {'n': int(len(m)), 'n_reach_tasks': int(m.sum()), + 'reach': float(ok[m].float().mean()) if m.any() else None, + 'collision': float(c['col'][m].float().mean()) if m.any() else None, + 'D': {'n': int((~m).sum()), 'collision': float(c['col'][~m].float().mean()) if (~m).any() else None}, + 'max_dev_deg_before_dls': {'p50': _q(c['mdev'], .5), 'p99': _q(c['mdev'], .99), 'max': float(c['mdev'].max())}, + 'dls_used': float(c['dls'].float().mean()), + 'finish_minus_teacher_s': {'p50': _q(dt, .5), 'p90': _q(dt, .9), 'max': float(dt.max()) if len(dt) else None}, + 'goal_ratio_2s_p50': _q(c['r2'], .5)} def main(): ap = argparse.ArgumentParser() ap.add_argument('--shards', default='ml/data/shard_*.jsonl'); ap.add_argument('--n', type=int, default=512) + ap.add_argument('--samples', default='ml/data/samples', help='读打包 meta 的 act_scale(oracle 按同一标签口径 clamp);没有就按 1.0') + ap.add_argument('--bs', type=int, default=128) ap.add_argument('--out', default='ml/runs/replay_teacher.json') ap.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu') a = ap.parse_args() @@ -26,6 +97,7 @@ def main(): if len(recs) >= a.n: break if len(recs) >= a.n: break if not recs: sys.exit('没有可回放的老师轨迹(先跑 gen)') + # ---- 1. 运动学口径 reach = col = 0; worst_gap = [] for r in recs: q = torch.tensor(r['traj'], dtype=torch.float32, device=a.device) @@ -37,11 +109,34 @@ def main(): e, oe = goal_err(kin, q[-1:], T, R) reach += bool((e < 1).item() and (oe < 0.5).item()) n = len(recs) - res = {'n': n, 'reach': reach / n, 'collision': col / n, 'min_gap_mm_p10': sorted(worst_gap)[n // 10], - 'pass': reach / n >= 0.95 and col / n <= 0.01, 'fp': kin.fp} + kin_ok = reach / n >= 0.95 and col / n <= 0.01 + # ---- 2. 滚动口径:老师 oracle 走完整 rollout() + try: + from ml.data import read_meta + act_scale = float(read_meta(a.samples).get('act_scale', 1.0)) if os.path.isdir(a.samples) else 1.0 + except SystemExit: + act_scale = 1.0 + tasks = [{'q0': r['q0'], 'T': r['T'], 'R': r.get('R'), 'exec_s': r.get('exec_s') or (len(r['traj']) - 1) * DT, + 'travel': r.get('travel_deg'), 'cat': r.get('cat')} for r in recs] + raws = [] + for i in range(0, n, a.bs): + tb, rb = tasks[i:i + a.bs], recs[i:i + a.bs] + TR = pad_traj([r['traj'] for r in rb], a.device) + _, per = rollout(None, kin, tb, a.device, K=1, select='first', use_sub=False, return_traj=True, seed=i, + oracle=TeacherOracle(TR, act_scale=act_scale)) + raws.append(oracle_raw(per, TR, [len(r['traj']) for r in rb], tb)) + orc = oracle_summary(raws); orc['act_scale'] = act_scale + orc_ok = (orc['reach'] or 0) >= 0.95 and (orc['collision'] or 0) <= 0.01 and orc['max_dev_deg_before_dls']['p99'] <= DEV_TOL_DEG + data_fps = {r.get('fp') for r in recs} + data_fp = data_fps.pop() if len(data_fps) == 1 else 'mixed' + res = {'ver': 2, 'n': n, 'reach': reach / n, 'collision': col / n, 'min_gap_mm_p10': sorted(worst_gap)[n // 10], 'kin_pass': kin_ok, + 'oracle': orc, 'oracle_pass': orc_ok, 'fp': kin.fp, 'data_fp': data_fp, + 'pass': bool(kin_ok and orc_ok and data_fp in (None, kin.fp))} os.makedirs(os.path.dirname(a.out) or '.', exist_ok=True) - json.dump(res, open(a.out, 'w', encoding='utf-8'), indent=1) - print('老师轨迹回放:到达 %.1f%%、碰撞 %.1f%%(%d 条)' % (100 * res['reach'], 100 * res['collision'], n), flush=True) + json.dump(res, open(a.out, 'w', encoding='utf-8'), indent=1, ensure_ascii=False) + print('老师轨迹回放:到达 %.1f%%、碰撞 %.1f%%(%d 条);oracle 走 rollout:到达 %.1f%%、碰撞 %.1f%%、DLS 前最大偏差 p99 %.4f°;指纹 %s' % ( + 100 * res['reach'], 100 * res['collision'], n, 100 * orc['reach'], 100 * orc['collision'], + orc['max_dev_deg_before_dls']['p99'], '一致' if data_fp in (None, kin.fp) else '不符'), flush=True) sys.exit(0 if res['pass'] else 5) diff --git a/ml/export_onnx.py b/ml/export_onnx.py index 9977abd..c04260b 100644 --- a/ml/export_onnx.py +++ b/ml/export_onnx.py @@ -1,7 +1,7 @@ """导出部署包(方案 §8.4): python -m ml.export_onnx --ckpt ml/runs/current.pt --out webapp/models/traj_policy.onnx [--fp16] 输出 traj_policy.onnx(输入 feat[1 或 K,83]、z[K,16,6];输出 act[K,16,6] 归一化增量、value[K]、sub[K,13]) -与 traj_policy.meta.json(碰撞模型指纹、特征版本、维度、enc_once)。导出后用 onnxruntime 与 PyTorch 对拍。 +与 traj_policy.meta.json(碰撞模型指纹、特征版本、维度、enc_once、act_scale)。导出后用 onnxruntime 与 PyTorch 对拍。 2026-10-18 第五轮:改为"编码一次、解码 K 次"(Policy.sample_shared),meta.enc_once=true;运行时据此只送 1 行特征。 2026-10-18 第六轮:feat 轴改为动态(1 行 = 主推理编码一次;K 行 = 前瞻逐行编码),meta.feat_dyn=true。 第五轮的 enc_once 模型 feat 固定 1 行,运行时前瞻只能逐行推(ik-policy.js ortInfer 已兼容),重新导出即可。 @@ -32,8 +32,8 @@ def main(): torch.manual_seed(0); ck = {'cfg': json.load(open(a.random)), 'random_init': True} m = build(ck['cfg']); m.eval() elif a.ckpt: - ck = torch.load(a.ckpt, map_location='cpu') - m = build(ck['cfg']); m.load_state_dict(ck['model']); m.eval() + from ml.common.ckpt import load_policy # 2026-10-19:导出 EMA 权重(审查 P1-5:原来导的是原始权重) + m, ck = load_policy(a.ckpt, 'cpu') else: raise SystemExit('需要 --ckpt 或 --random <配置>') kin = Kin() if ck.get('fp') and ck['fp'] != kin.fp: raise SystemExit(f"checkpoint 指纹 {ck['fp']} ≠ 当前碰撞模型 {kin.fp}:须重训") @@ -62,8 +62,10 @@ def main(): err_rows = max(float(np.abs(o8[i] - r8[i].numpy()).max()) for i in range(3)) assert err_rows < 1e-3, f'feat 为 K 行时与 sample 不一致:{err_rows}' err = max(err, err_rows) + # 2026-10-19:act_scale = 动作标签归一化倍数,运行时 inc = clamp(act, ±1)·act_scale·STEP(ik-policy.js Runner 的 actScale 读它) meta = {'fp': kin.fp, 'tcp0': kin.tcp0.tolist(), 'head': m.head, 'feat_ver': FEAT_VER, 'f_dim': F_DIM, 'h': H, 'step': STEP, 'fp16': a.fp16, - 'ckpt': os.path.abspath(a.ckpt) if a.ckpt else None, 'random_init': bool(a.random), 'enc_once': True, 'feat_dyn': True, 'eval': ck.get('eval'), 'max_abs_err_vs_torch': err} + 'ckpt': os.path.abspath(a.ckpt) if a.ckpt else None, 'random_init': bool(a.random), 'enc_once': True, 'feat_dyn': True, + 'act_scale': float(ck.get('act_scale', 1.0)), 'eval': ck.get('eval'), 'max_abs_err_vs_torch': err} json.dump(meta, open(a.out.replace('.onnx', '.meta.json'), 'w'), indent=1) print(json.dumps({'onnx': a.out, 'MB': os.path.getsize(a.out) / 1e6, 'max_abs_err_vs_torch': err})) diff --git a/ml/gen/gen_dataset.cjs b/ml/gen/gen_dataset.cjs index cbe9525..a069485 100644 --- a/ml/gen/gen_dataset.cjs +++ b/ml/gen/gen_dataset.cjs @@ -5,7 +5,9 @@ * 每行一条任务:采样 → 老师 → 距离下界认证过滤 → 局部时间律(50 Hz)→ 写出。 * 认证不过的样本也写出(keep=false + 原因),供 stats 统计丢弃率;训练只用 keep=true。 * 2026-10-18 断点续写:第 i 条任务的随机数只由 (seed, i) 决定(sampler.reseed);输出文件已存在时保留其中完整的行、 - * 截掉写了一半的最后一行,从下一条接着写 —— 生成几小时被中断,重跑只补剩下的,不重来、不重复。 */ + * 截掉写了一半的最后一行,从下一条接着写 —— 生成几小时被中断,重跑只补剩下的,不重来、不重复。 + * 2026-10-19:--tasks 的任务带 qPrev(DAgger 采集时策略的上一帧)时,记录 qPrev 与起步速度 v0(timelaw.initSpeed), + * 时间律从 v0 起步;build_dataset.cjs 用它们做 t=0 样本的 dq 特征与标签。 */ 'use strict'; const fs = require('fs'), path = require('path'); const args = {}; for (let i = 2; i < process.argv.length; i += 2) args[process.argv[i].replace(/^--/, '')] = process.argv[i + 1]; @@ -19,7 +21,7 @@ const K_CONS = +args.consensus || 0; const GIVEN = args.tasks ? fs.readFileSync(args.tasks, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : null; const NT = GIVEN ? GIVEN.length : N; const cert = require('./cert.cjs')(S, {}); -const { timeParam } = require('./timelaw.cjs'); +const { timeParam, initSpeed } = require('./timelaw.cjs'); const fp = S.modelFingerprint ? S.modelFingerprint() : null; fs.mkdirSync(path.dirname(OUT), { recursive: true }); let done = 0; @@ -63,7 +65,11 @@ for (let i = done; i < NT; i++) { if (valid >= 2 && agree * 2 < valid) { rec.keep = false; rec.why = 'consensus'; } } if (t.poseRelaxed && task.mode === 'pose') { rec.mode = 'pos'; rec.R = null; rec.demoted = true; rec.reached = t.err < 1; } - if (rec.keep) { const tp = timeParam(t.pts); rec.traj = tp.traj.map(q => q.map(r3)); rec.exec_s = r3(tp.steps * 0.02); } + if (rec.keep && task.qPrev) { + rec.qPrev = task.qPrev.map(r3); + rec.v0 = r3(initSpeed(t.pts, task.qPrev, S.wrap180, S.ORDER.map(id => !!S.LIM[id].cont))); + } + if (rec.keep) { const tp = timeParam(t.pts, { v0: rec.v0 || 0 }); rec.traj = tp.traj.map(q => q.map(r3)); rec.exec_s = r3(tp.steps * 0.02); } fs.writeSync(fd, JSON.stringify(rec) + '\n'); if ((i + 1) % 25 === 0) process.stderr.write(`[seed ${SEED}] ${i + 1}/${NT} ${((performance.now() - t00) / 1000 / (i + 1 - done)).toFixed(2)} s/条\n`); } diff --git a/ml/gen/timelaw.cjs b/ml/gen/timelaw.cjs index eb4790e..336b1f6 100644 --- a/ml/gen/timelaw.cjs +++ b/ml/gen/timelaw.cjs @@ -1,7 +1,10 @@ /* 局部可观测时间律(2026-10-17,方案 §4.5) * 折线按"巡航时间" τ 参数化:每段 τ = max_j |Δq_j| / VMAX_j(各轴同步、主导轴满速)。 * 归一化速度 v∈[0,1](1 = 巡航):v ← min(1, v + A·dt, sqrt(2·A·剩余 τ)),只依赖当前速度与剩余路程。 - * 输出 50 Hz(dt = 20 ms)状态序列,末点精确落在终点。 */ + * 输出 50 Hz(dt = 20 ms)状态序列,末点精确落在终点。 + * 2026-10-19(审查 review_closedloop F7 / review_data_actions C10):opt.v0 = 起步归一化速度(默认 0 = 静止起步)。 + * DAgger 重新标注的起点来自策略闭环、本来就在运动,原来一律从静止加速,标签与策略真正到达的 (q, dq) 状态不符。 + * initSpeed(P, qPrev) 把当前速度投影到首段方向上得到 v0(gen_dataset.cjs 用)。 */ 'use strict'; const VMAX = [60, 60, 90, 90, 120, 180]; // °/s,与 tests/route_bench.cjs 同(轴速比 0.6/0.6/0.9/0.9/1.2/1.8) const A = 5.0; // 1/s²:0→巡航约 0.2 s(a_max 待真机参数) @@ -19,7 +22,7 @@ function timeParam(P, opt) { const u = segT[i] > 0 ? (tau - cum[i]) / segT[i] : 1; return P[i].map((v, j) => v + (P[i + 1][j] - v) * u); }; - const out = [P[0].slice()], taus = [0]; let tau = 0, v = 0, guard = 0; + const out = [P[0].slice()], taus = [0]; let tau = 0, v = Math.max(0, Math.min(1, opt.v0 || 0)), guard = 0; while (tau < total - 1e-9 && guard++ < 100000) { const rem = total - tau; const v1 = Math.min(1, v + a * dt, Math.sqrt(2 * a * Math.max(0, rem - 0.5 * v * dt)) + 1e-9); @@ -30,4 +33,18 @@ function timeParam(P, opt) { } return { traj: out, taus, cum, total, steps: out.length - 1 }; } -module.exports = { timeParam, VMAX, A, DT }; +/* 起步速度:w_j = 当前每步增量 / STEP_j(qPrev → P[0]),u_j = 巡航时首段每步增量 / STEP_j(max|u_j| = 1), + v0 = clamp(⟨w,u⟩/⟨u,u⟩, 0, 1)——当前速度在首段方向上的分量;反向或首段为零时 0。wrap:连续轴差值的最短弧函数(可缺省)。 */ +function initSpeed(P, qPrev, wrap, cont) { + if (!qPrev || !P || P.length < 2) return 0; + const dd = (a, b, j) => (wrap && cont && cont[j]) ? wrap(a - b) : a - b; + let seg = 0; for (let j = 0; j < 6; j++) seg = Math.max(seg, Math.abs(P[1][j] - P[0][j]) / VMAX[j]); + if (seg < 1e-9) return 0; + let wu = 0, uu = 0; + for (let j = 0; j < 6; j++) { + const u = (P[1][j] - P[0][j]) / VMAX[j] / seg, w = dd(P[0][j], qPrev[j], j) / (VMAX[j] * DT); + wu += w * u; uu += u * u; + } + return Math.max(0, Math.min(1, wu / uu)); +} +module.exports = { timeParam, initSpeed, VMAX, A, DT }; diff --git a/ml/mining/gate.py b/ml/mining/gate.py index e7900e8..3bc4bc9 100644 --- a/ml/mining/gate.py +++ b/ml/mining/gate.py @@ -12,8 +12,8 @@ from ml.sim.closed_loop import evaluate def load(path, dev): - ck = torch.load(path, map_location=dev) - m = build(ck['cfg']).to(dev); m.load_state_dict(ck['model']); return m.eval() + from ml.common.ckpt import load_policy # 2026-10-19:EMA 权重 + act_scale + return load_policy(path, dev)[0] def per_task_success(pers): diff --git a/ml/mining/mine_round.py b/ml/mining/mine_round.py index d364cee..f3a4865 100644 --- a/ml/mining/mine_round.py +++ b/ml/mining/mine_round.py @@ -28,8 +28,8 @@ def main(): with open(os.path.join(a.out, a.name), 'w') as f: for t in ts: f.write(json.dumps(t) + '\n') print('gate tasks', len(ts)); return - ck = torch.load(a.ckpt, map_location=a.device) - model = build(ck['cfg']).to(a.device); model.load_state_dict(ck['model']); model.eval() + from ml.common.ckpt import load_policy # 2026-10-19:EMA 权重 + act_scale + model, ck = load_policy(a.ckpt, a.device) tasks = sample_tasks(kin, a.n, a.seed) g = torch.Generator(); g.manual_seed(a.seed) cls = {k: [] for k in ('E1', 'E2', 'E3', 'E4', 'E5', 'ok')} diff --git a/ml/model/meanflow.py b/ml/model/meanflow.py index 3b0324f..4d4e3df 100644 --- a/ml/model/meanflow.py +++ b/ml/model/meanflow.py @@ -11,20 +11,26 @@ import torch from torch.func import jvp -def sample_rt(B, device, dtype, p_neq=0.25, mu=-0.4, sigma=1.0): +def sample_rt(B, device, dtype, p_neq=0.25, mu=-0.4, sigma=1.0, p_edge=0.0): + """2026-10-19(审查 review_model_train P0-3):部署只在 (r=0, t=1) 推理一次,而 logit-normal 取 max/min 后 + P(t>0.95) 约 0.08%、P(r<0.05) 约 0.5%,这个角几乎从未被训练到(一步残差与噪声相关 −0.86 即外推失准)。 + p_neq > 0 时另以 p_edge 的概率把样本直接放在 (r=0, t=1);预热期(p_neq=0)不放,那时一步映射本就不训。""" t = torch.sigmoid(torch.randn(B, device=device, dtype=dtype) * sigma + mu) r = torch.sigmoid(torch.randn(B, device=device, dtype=dtype) * sigma + mu) t, r = torch.maximum(t, r), torch.minimum(t, r) eq = torch.rand(B, device=device) >= p_neq r = torch.where(eq, t, r) + if p_neq > 0 and p_edge > 0: + edge = torch.rand(B, device=device) < p_edge + t = torch.where(edge, torch.ones_like(t), t); r = torch.where(edge, torch.zeros_like(r), r) return r, t -def meanflow_loss(model, mem, x, p_neq=0.25, ada_p=1.0, ada_c=1e-3, return_aux=False): +def meanflow_loss(model, mem, x, p_neq=0.25, ada_p=1.0, ada_c=1e-3, return_aux=False, p_edge=0.0): mem, x = mem.float(), x.float() with torch.autocast('cuda', enabled=False): B = x.shape[0] - r, t = sample_rt(B, x.device, x.dtype, p_neq) + r, t = sample_rt(B, x.device, x.dtype, p_neq, p_edge=p_edge) e = torch.randn_like(x) z = (1 - t)[:, None, None] * x + t[:, None, None] * e v = e - x diff --git a/ml/monitor.py b/ml/monitor.py index 297cce6..328beb0 100644 --- a/ml/monitor.py +++ b/ml/monitor.py @@ -276,7 +276,8 @@ def stage_progress(st, s): if m and m.get('total'): return {'frac': (bs + m['step']) / (bs + m['total']), 'label': f'正式训练 {m["step"]:,} / {m["total"]:,} 步', 'run': tag, 'eta_s': m['eta_h'] * 3600 if m.get('eta_h') else None} # 训练脚本自己按实测样本/s 算的 - if b: return {'frac': b['step'] / (bs + 60000), 'label': f'测速 {b["step"]:,} / {bs:,} 步', 'run': f'bench_{tag}'} + if b: return {'frac': b['step'] / (bs + 24000), # 2026-10-19:正式步数按 configs/base.json 的 24000 估 + 'label': f'测速 {b["step"]:,} / {bs:,} 步', 'run': f'bench_{tag}'} return {'frac': 0, 'label': '加载数据中'} if st == 'dagger': # 2026-10-18 新增:每轮 = 采起点 → 老师重规划 → 打包 → 微调(微调占大头,进度按 0.1/0.25/0.05/0.6 分) tag, R = info.get('tag', 'base'), int(info.get('rounds', 2)) diff --git a/ml/pipeline.sh b/ml/pipeline.sh index 123de82..66e7453 100755 --- a/ml/pipeline.sh +++ b/ml/pipeline.sh @@ -78,19 +78,33 @@ on_exit() { trap on_exit EXIT trap 'exit 143' TERM INT HUP # 被停止 / 超时:先走 on_exit 记返回码、放锁 # ---- 训练与门禁的单元(m5 自动分支要对两种头各调一次) +replay_ok() { # 回放自检结果有效:pass 为真、版本含 oracle 滚动(ver ≥ 2)、指纹 = 当前 kin.json 且 = 数据 shard 的指纹 + $PY - <<'PY' +import json, sys +try: + r = json.load(open('ml/runs/replay_teacher.json', encoding='utf-8')); k = json.load(open('ml/assets/kin.json', encoding='utf-8'))['fp'] +except Exception: sys.exit(1) +sys.exit(0 if r.get('pass') is True and r.get('ver', 1) >= 2 and r.get('fp') == k and r.get('data_fp') in (None, k) else 1) +PY +} train_one() { # $1 = 配置文件 local cfg=$1 tag; tag=$(basename "$1" .json) # 2026-10-18:开训前先把老师轨迹放进 PyTorch 仿真口径里回放——口径对不上时闭环指标不可信,训多少步都白训(rc 5 停) - [ -f ml/runs/replay_teacher.json ] || run $PY -m ml.eval.replay_teacher --n 512 --out ml/runs/replay_teacher.json + # 2026-10-19(审查 review_closedloop F6):原来只看文件在不在——replay 失败时也写了文件(pass=false)再 rc 5,用户直接重跑 train 门禁就被跳过。 + # 现在读 pass 字段 + 指纹(kin.json、数据 shard),不过就重跑一次,仍不过 rc 5;回放同时走完整 rollout()(老师 oracle),覆盖滚动路径。 + replay_ok || run $PY -m ml.eval.replay_teacher --n 512 --out ml/runs/replay_teacher.json || true + replay_ok || { echo "老师回放自检未通过(ml/runs/replay_teacher.json 的 pass / 指纹),闭环指标不可信,不开训"; exit 5; } if [ -f ml/runs/bench_$tag/last.pt ]; then echo "吞吐摸底 ml/runs/bench_$tag 已有,跳过" else run $PY -m ml.train --samples ml/data/samples --out ml/runs/bench_$tag --steps ${BENCH_STEPS:-2000} --no-eval --config $cfg; fi local res=""; [ -f ml/runs/$tag/last.pt ] && { res="--resume ml/runs/$tag/last.pt"; echo "发现 ml/runs/$tag/last.pt → 续训"; } run $PY -m ml.train --samples ml/data/samples --out ml/runs/$tag --config $cfg $res } -dagger_one() { # $1 = 头标签:从该头的预训练 best.pt 起做 DAgger,最终模型路径写进 ml/runs/_dagger.ckpt - local tag=$1 - [ -f ml/runs/$tag/best.pt ] || { echo "DAgger:ml/runs/$tag/best.pt 不存在(预训练没完成)"; return 3; } - run bash ml/dagger/run.sh $tag ml/runs/$tag/best.pt +dagger_one() { # $1 = 头标签:从该头的预训练结果起做 DAgger,最终模型路径写进 ml/runs/_dagger.ckpt + local tag=$1 ck + [ -f ml/runs/$tag/best.pt ] || [ -f ml/runs/$tag/last.pt ] || { echo "DAgger:ml/runs/$tag/ 下没有 checkpoint(预训练没完成)"; return 3; } + # 2026-10-19(审查 P0-2):闭环 success > 0 用 best.pt,否则 last.pt(都取 EMA 权重) + ck=$($PY -m ml.common.ckpt pick ml/runs/$tag) + run bash ml/dagger/run.sh $tag $ck } m5_one() { # $1 = 头标签;返回 0 通过 / 3 不通过。起点模型:CKPT > DAgger 结果 > 预训练 best.pt local tag=$1 ck o diff --git a/ml/prep/build_dataset.cjs b/ml/prep/build_dataset.cjs index 0a88787..1930d0b 100644 --- a/ml/prep/build_dataset.cjs +++ b/ml/prep/build_dataset.cjs @@ -2,18 +2,33 @@ * node ml/prep/build_dataset.cjs ml/data/shard_0001.jsonl [outDir=ml/data/samples] * 每条 keep=true 的轨迹按 50 Hz 时间律重放,每 3 步取一个样本: * X [N,83] 特征(webapp/js/ik-policy.js features(),与部署同一份代码) - * Y [N,16,6] 未来 16 步每步增量 / STEP(到终点后补 0) + * Y [N,16,6] 未来 16 步每步增量 / (ACT_SCALE·STEP),夹在 ±1(到终点后补 0) * V [N] 剩余时间(s)/ 5 * SG [N,13] 子目标标签:剩余两个途经点相对当前 q 的差 / 180(不足用终点补)+ 是否需要 * TID[N] 任务编号(tasks.jsonl 行号所在的全局 id,用于划分与闭环验证) * 增广(2026-10-18 首次正式预训练后加大):40% 样本对 q_t 加 σ=AUG_SIGMA(默认 3°)噪声,标签改为 AUG_BACK(默认 12)步内线性收回老师轨迹; * 噪声态须无干涉且间隙 > MIN_GAP_MM,收回途中第 3/6/9/12 步也须无干涉(σ 加大后直接线性拉回可能穿过障碍)。 * 原为 30%、σ=1°、8 步:闭环一偏出 1° 就是没见过的状态,首次 21850 步闭环到达 0%。更大的偏离由 DAgger 阶段补(ml/dagger)。 - * 已提交前缀长度 r ∈ {0..3} 均匀随机,前缀取老师接下来 r 步的增量。 */ + * 已提交前缀长度 r ∈ {0..3} 均匀随机,前缀取老师接下来 r 步的增量。 + * meta [.meta.json] 打包参数:data_ver、act_scale、增广参数、sig(run_prep.sh 据 sig 判断要不要重新打包)。 + * 2026-10-19(审查 review_data_actions C2): + * - 标签按 ACT_SCALE·STEP 归一化(默认 1.25,DATA_VER 2)。原来按 STEP 归一化,巡航时主导轴标签恰好 = ±1、执行端又夹在 ±1, + * 模型在 ±1 附近的对称误差有一半被截掉,主导轴系统性偏慢;留 25% 余量后老师标签最大 0.8,执行端的 ±1 只防越界。 + * 运行时 inc = clamp(act, ±1)·act_scale·STEP(closed_loop.py / apg / ik-policy.js 同口径,act_scale 经 checkpoint 与 ONNX meta 传递); + * - 增广收回标签的亏空结转:被 ±1 截掉的部分记在 prevP 里、下一步补,原来 prevP = 目标点,截掉就丢了(教的是「落后了别追」); + * - DAgger 任务带 qPrev / v0(gen_dataset.cjs 写入):t=0 样本用真实上一帧,时间律从当前速度起步。 + * 特征(83 维)定义不变,FEAT_VER 不变;数据版本由 meta.data_ver 区分,旧打包没有 meta → 训练按 act_scale 1.0 读。 */ 'use strict'; const fs = require('fs'), path = require('path'); +/* 打包参数(环境变量)与签名:run_prep.sh 用 `node build_dataset.cjs --sig` 取当前签名,与已打包 shard 的 meta.sig 不同就重新打包 */ +const DATA_VER = 2; +const MIN_GAP_MM = +(process.env.MIN_GAP_MM || 3); +const AUG_P = +(process.env.AUG_P || 0.4), AUG_SIGMA = +(process.env.AUG_SIGMA || 3), AUG_BACK = +(process.env.AUG_BACK || 12); +const ACT_SCALE = +(process.env.ACT_SCALE || 1.25); +const SIG = ['v' + DATA_VER, 's' + ACT_SCALE, 'p' + AUG_P, 'sg' + AUG_SIGMA, 'b' + AUG_BACK, 'g' + MIN_GAP_MM].join('|'); +if (process.argv[2] === '--sig') { console.log(SIG); process.exit(0); } const IN = process.argv[2], OUTDIR = process.argv[3] || path.join(__dirname, '../data/samples'); -if (!IN) { console.error('usage: build_dataset.cjs [outDir]'); process.exit(1); } +if (!IN) { console.error('usage: build_dataset.cjs [outDir] | --sig'); process.exit(1); } const { S } = require('../../tests/route_bench_setup.cjs')(); const POL = require('../../webapp/js/ik-policy.js'); const { timeParam } = require('../gen/timelaw.cjs'); @@ -28,9 +43,8 @@ const hash = s => { let h = 2166136261; for (const c of s) h = Math.imul(h ^ c.c const X = [], Y = [], V = [], SG = [], TID = [], tasks = []; const lines = fs.readFileSync(IN, 'utf8').split('\n').filter(Boolean); let nAug = 0, nAugRej = 0, nGapDrop = 0; -/* 2026-10-18:认证最小间隙 < 3 mm 的老师轨迹不进训练集;噪声增广接受地板同步为 3 mm(原 1 mm)。 */ -const MIN_GAP_MM = +(process.env.MIN_GAP_MM || 3); -const AUG_P = +(process.env.AUG_P || 0.4), AUG_SIGMA = +(process.env.AUG_SIGMA || 3), AUG_BACK = +(process.env.AUG_BACK || 12); +/* 2026-10-18:认证最小间隙 < 3 mm 的老师轨迹不进训练集(MIN_GAP_MM);噪声增广接受地板同步为 3 mm(原 1 mm)。 */ +const ASTEP = STEP.map(v => v * ACT_SCALE); // 每单位动作对应的度数 const NT = lines.length, PSTEP = Math.max(1, Math.floor(NT / 10)); for (let li = 0; li < NT; li++) { if (li % PSTEP === 0) console.log(`[prep] ${base} ${li}/${NT}`); // 每 10% 一行,28 个分片并行也看得见进度 @@ -41,13 +55,13 @@ for (let li = 0; li < NT; li++) { const tid = hash(r.id) & 0x7fffffff; const split = (hash(r.id + '#split') % 100) < 5 ? 'val' : 'train'; const P = r.path; - const tp = timeParam(P); + const tp = timeParam(P, { v0: r.v0 || 0 }); // DAgger 起点带速度:时间律从 v0 起步(gen_dataset.cjs 写 v0) const traj = tp.traj, taus = tp.taus, N = traj.length - 1; - tasks.push({ tid, id: r.id, cat: r.cat, mode: r.mode, q0: r.q0, T: r.T, R: r.R, exec_s: N * POL.DT, travel: r.travel_deg, reached: r.reached, err: r.err, split }); + tasks.push({ tid, id: r.id, cat: r.cat, mode: r.mode, q0: r.q0, qp0: r.qPrev || null, T: r.T, R: r.R, exec_s: N * POL.DT, travel: r.travel_deg, reached: r.reached, err: r.err, split }); const vt = tp.cum; // 顶点 τ const inter = P.slice(1, -1).map((p, i) => ({ p, tau: vt[i + 1] })); for (let t = 0; t <= N; t += 3) { - let q = traj[t].slice(), qPrev = (t > 0 ? traj[t - 1] : traj[0]).slice(); + let q = traj[t].slice(), qPrev = (t > 0 ? traj[t - 1] : (r.qPrev || traj[0])).slice(); let off = [0, 0, 0, 0, 0, 0], aug = false; if (rnd() < AUG_P) { const qn = q.map((v, j) => { let x = v + AUG_SIGMA * gauss(); const t2 = L[O[j]]; if (!t2.cont) x = Math.max(t2.lo, Math.min(t2.hi, x)); return x; }); @@ -60,10 +74,14 @@ for (let li = 0; li < NT; li++) { } const pos = k => { const i = Math.min(N, t + k); const w = aug ? Math.max(0, 1 - k / AUG_BACK) : 0; return traj[i].map((v, j) => v + off[j] * w); }; const y = new Float32Array(H * 6); - let prevP = q; - for (let k = 1; k <= H; k++) { const pk = pos(k); for (let j = 0; j < 6; j++) y[(k - 1) * 6 + j] = Math.max(-1, Math.min(1, (pk[j] - prevP[j]) / STEP[j])); prevP = pk; } + /* prevP = 按标签实际会走到的位置:被 ±1 截掉的亏空留在 prevP 与目标之差里,下一步接着补(2026-10-19,C2) */ + let prevP = q.slice(); + for (let k = 1; k <= H; k++) { + const pk = pos(k); + for (let j = 0; j < 6; j++) { const v = Math.max(-1, Math.min(1, (pk[j] - prevP[j]) / ASTEP[j])); y[(k - 1) * 6 + j] = v; prevP[j] += v * ASTEP[j]; } + } const rr = Math.floor(rnd() * 4), prefix = []; - for (let k = 0; k < rr; k++) { const row = []; for (let j = 0; j < 6; j++) row.push(y[k * 6 + j] * STEP[j]); prefix.push(row); } + for (let k = 0; k < rr; k++) { const row = []; for (let j = 0; j < 6; j++) row.push(y[k * 6 + j] * ASTEP[j]); prefix.push(row); } const remain = inter.filter(v => v.tau > taus[t] + 0.02); const sub = remain.length && rnd() < 0.8 ? remain[0].p : null; const gm = S.gapMatrix(q, POL.GAP_CAP); @@ -83,7 +101,10 @@ writeNpy(path.join(OUTDIR, base + '.Y.npy'), cat(Y, H * 6), [n, H, 6]); writeNpy(path.join(OUTDIR, base + '.V.npy'), Float32Array.from(V), [n]); writeNpy(path.join(OUTDIR, base + '.SG.npy'), cat(SG, 13), [n, 13]); writeNpy(path.join(OUTDIR, base + '.TID.npy'), TID, [n], 'int32'); +/* meta 在 tasks.jsonl 之前写:tasks.jsonl 仍是「本 shard 打包完成」的标记 */ +fs.writeFileSync(path.join(OUTDIR, base + '.meta.json'), JSON.stringify({ data_ver: DATA_VER, act_scale: ACT_SCALE, aug_p: AUG_P, aug_sigma: AUG_SIGMA, + aug_back: AUG_BACK, min_gap_mm: MIN_GAP_MM, aug_carry: true, sig: SIG })); // 2026-10-18 断点续跑:tasks.jsonl 最后写、先写 .tmp 再改名 —— 它存在就代表这个 shard 的 5 个 npy 都已写完,run_prep.sh 据此跳过 const tf = path.join(OUTDIR, base + '.tasks.jsonl'); fs.writeFileSync(tf + '.tmp', tasks.map(t => JSON.stringify(t)).join('\n') + '\n'); fs.renameSync(tf + '.tmp', tf); -console.log(JSON.stringify({ shard: base, tasks: tasks.length, samples: n, aug: nAug, augRejected: nAugRej, gapDrop: nGapDrop, featVer: POL.FEAT_VER })); +console.log(JSON.stringify({ shard: base, tasks: tasks.length, samples: n, aug: nAug, augRejected: nAugRej, gapDrop: nGapDrop, featVer: POL.FEAT_VER, dataVer: DATA_VER, actScale: ACT_SCALE })); diff --git a/ml/prep/run_prep.sh b/ml/prep/run_prep.sh index 382492c..bd48496 100755 --- a/ml/prep/run_prep.sh +++ b/ml/prep/run_prep.sh @@ -2,6 +2,8 @@ # 并行打包样本:bash ml/prep/run_prep.sh [进程数=CPU 核数] # 2026-10-18 断点续跑:samples/.tasks.jsonl 存在且比 shard 新 → 该 shard 已打包完,跳过; # shard 已不存在的旧打包结果删掉(不让上一批数据混进来)。中断后重跑只补没打完的 shard。 +# 2026-10-19(审查 review_closedloop F11):打包参数(数据版本、ACT_SCALE、AUG_P/AUG_SIGMA/AUG_BACK、MIN_GAP_MM)变了也要重打: +# build_dataset.cjs --sig 给出当前签名,已打包 shard 的 .meta.json 里 sig 不同(或没有 meta 的旧打包)→ 重新打包。 set -e cd "$(dirname "$0")/../.." P=${1:-$(nproc)} @@ -11,10 +13,13 @@ for t in $D/*.tasks.jsonl; do [ -e "$t" ] || continue; b=$(basename "$t" .tasks.jsonl) [ -f ml/data/$b.jsonl ] || { echo "[prep] 删除旧打包 $b(对应 shard 已不存在)"; rm -f $D/$b.*; } done +SIG=$(node ml/prep/build_dataset.cjs --sig) +echo "[prep] 打包签名 $SIG" todo=() for f in ml/data/shard_*.jsonl; do b=$(basename "$f" .jsonl) - if [ -f $D/$b.tasks.jsonl ] && [ $D/$b.tasks.jsonl -nt "$f" ]; then continue; fi + if [ -f $D/$b.tasks.jsonl ] && [ $D/$b.tasks.jsonl -nt "$f" ] && grep -qF "\"sig\":\"$SIG\"" $D/$b.meta.json 2>/dev/null; then continue; fi + rm -f $D/$b.tasks.jsonl # 先撤完成标记:重打中途断了不会被当成已完成 todo+=("$f") done echo "[prep] 共 $(ls ml/data/shard_*.jsonl | wc -l) 个 shard,待打包 ${#todo[@]} 个" diff --git a/ml/pstate.py b/ml/pstate.py index 173c0dc..045831a 100644 --- a/ml/pstate.py +++ b/ml/pstate.py @@ -40,6 +40,8 @@ AFFECTS = {**{k: _from('gen') for k in ('N_TASKS', 'NPROC', 'CONSENSUS', 'MIN_GA # TARGET_SAMPLES 只决定"补量到多少":不作废 gen 的已有分片(重跑 gen 会跳过写完的 shard、按差额补量), # 但样本集会变,所以 prep 及之后作废。2026-10-18 实测:把它当"从 gen 作废"会让 5 小时的 5 万条分片白跑。 'TARGET_SAMPLES': ['gen'] + _from('prep'), + # 2026-10-19:打包参数(标签尺度、增广)变了 → prep 起作废;run_prep.sh 另按 meta.sig 只重打参数不符的 shard + **{k: _from('prep') for k in ('ACT_SCALE', 'AUG_P', 'AUG_SIGMA', 'AUG_BACK')}, 'CFG': ['r4'] + _from('train'), **{k: _from('dagger') for k in ('DAG_ROUNDS', 'DAG_N', 'DAG_STEPS')}, **{k: _from('mine') for k in ('ROUNDS', 'APG_STEPS', 'MINE_N')}} PARAM_KEYS = list(AFFECTS) diff --git a/ml/sim/closed_loop.py b/ml/sim/closed_loop.py index 6cc3aac..4823a82 100644 --- a/ml/sim/closed_loop.py +++ b/ml/sim/closed_loop.py @@ -1,19 +1,23 @@ """闭环仿真(验证 / 挖错 / 门禁共用,PyTorch 批量,无梯度)——按部署节奏运行: 50 Hz 执行;队列 ≤ 3 步时推理一次(已提交前缀 r = 队列长度),提交接下来 3 步; -冷启动用价值头对候选末态打分(短前瞻),之后按与上一计划的一致性选候选; -接近目标 < 5 mm 时用局部 DLS 精修;子目标头在新目标时给出途经点(到达 < 5° 或 1.5 s 超时后切下一个)。 +冷启动用价值头对候选末态打分(短前瞻),之后按与上一条**真正提交**的计划的一致性选候选; +接近目标 < 5 mm 时用局部 DLS 精修;子目标头在新目标时给出途经点,按「越过顶点」规则切下一个(与标签同口径,见 sub_advance)。 safety=False 时不做任何碰撞拦截(暴露真实错误,挖错用);safety=True 时近似部署安全层(候选提交步有干涉则换下一条,全不过则停一步)。 +动作反归一化:inc = clamp(act, −1, 1) · act_scale · STEP;act_scale 来自 checkpoint(旧模型缺省 1.0,见 ml/common/ckpt.load_policy)。 +碰撞与到达只在每题自己的时限内计(短题与长题同批时不把超时后的部分算进去)。 python -m ml.sim.closed_loop --ckpt ml/runs/base/best.pt --samples ml/data/samples --split val --n 1000 [--safety]""" import argparse, json, math, os import torch from ml.common.kin_torch import Kin -from ml.common.features import features, STEP, H, GAP_CAP, D2R, wrap180 +from ml.common.features import features, STEP, VMAX, H, GAP_CAP, D2R, wrap180 DT = 0.02 FIRST_DEG = 5.0 # R7 首次方向判定阈值(度):小于它的抖动不算选边 +SUB_LEAD_S = 0.02 # 子目标「越过顶点」提前量(s):标签在老师 τ > τ_顶点 − 0.02 时就换到下一个顶点(build_dataset.cjs remain 过滤) +SUB_REACH_DEG, SUB_TIMEOUT_S = 5.0, 1.5 # 旧规则(sub_rule='timeout'):到达 < 5° 或 1.5 s 超时 def tasks_to_tensors(tasks, device): q0 = torch.tensor([t['q0'] for t in tasks], dtype=torch.float32, device=device) @@ -28,6 +32,12 @@ def tasks_to_tensors(tasks, device): NOREF_SEC = 6.0 # 无老师参考题的定长时限(s) +def time_limit(ex, max_sec=None): + """每题时限(s):有老师 = 老师时长 × 2(至少 3 s);无老师 = NOREF_SEC;max_sec 覆盖全部。""" + if max_sec is not None: return torch.full_like(ex, max_sec) + return torch.where(torch.isfinite(ex), torch.clamp(2 * ex, min=3.0), torch.full_like(ex, NOREF_SEC)) + + def goal_err(kin, q, T, R): xf = kin.cum_xf(q) p = kin.fk(q, xf); Rq = kin.orient(q, xf).reshape(-1, 9) @@ -64,58 +74,139 @@ def dls_steps(kin, q, T, R, n=3, scale=0.3, lam=0.5): return torch.stack(out, 1) +def jdiff(kin, a, b): + """a − b(度),连续轴取最短弧。""" + d = a - b + return torch.where(kin.cont, wrap180(d), d) + + +def sub_advance(kin, subs, nsub, sub_prev, sub_t, qe, tnow, rule='cross'): + """子目标推进(每次规划前调用,每次最多推进一个)。subs [B,M,6] 待走途经点(绝对角),nsub [B] 剩余个数, + sub_prev [B,6] 上一个已越过的点(冷启动时 = 起点),sub_t [B] 当前子目标开始时刻。返回 (subs, nsub, sub_prev, sub_t)。 + rule='cross'(默认):qe 沿拐角平分面法向越过当前顶点(提前 SUB_LEAD_S 秒)即切到下一个。 + 2026-10-19(审查 review_data_actions C5 / review_model_train P0-1):标签里 sub = 老师「下一个尚未经过的顶点」, + 老师 τ 一过顶点(−0.02 s)立刻换;旧规则要等最大关节差 < 5° 或 1.5 s 超时,策略在顶点附近 5–10° 进不去时 + sub 一直指着身后的点,这种「sub 已在身后」的特征序列训练里从未出现。运行时没有老师 τ,用几何等价物: + 进入方向 a = 顶点 − 上一点、离开方向 b = 下一顶点 − 顶点(最后一个顶点取 a),各轴按 VMAX 折成秒(与 τ 同量纲), + 法向 n = â + b̂;(qe − 顶点)·n̂ ≥ −SUB_LEAD_S 判为越过。没有超时(标签也没有)。 + rule='timeout':旧部署口径,到达 < 5° 或 1.5 s 超时(诊断对照用)。 + ik-policy.js Runner(subRule)与 apg_finetune.py 用同一规则,三处同步改。""" + has = nsub > 0 + if not bool(has.any()): return subs, nsub, sub_prev, sub_t + cur = subs[:, 0] + if rule == 'timeout': + dd = jdiff(kin, cur, qe).abs().max(-1).values + adv = has & ((dd < SUB_REACH_DEG) | (tnow - sub_t > SUB_TIMEOUT_S)) + elif rule == 'cross': + vmax = torch.tensor(VMAX, device=qe.device, dtype=qe.dtype) + un = lambda v: v / v.norm(dim=-1, keepdim=True).clamp(min=1e-9) + a = jdiff(kin, cur, sub_prev) / vmax + nxt = subs[:, 1] if subs.shape[1] > 1 else cur + b = torch.where((nsub > 1)[:, None], jdiff(kin, nxt, cur) / vmax, a) + n = un(un(a) + un(b)) + proj = (jdiff(kin, qe, cur) / vmax * n).sum(-1) + adv = has & (proj >= -SUB_LEAD_S) + else: + raise ValueError('sub_rule: ' + str(rule)) + subs = torch.where(adv[:, None, None], torch.cat([subs[:, 1:], subs[:, -1:]], 1), subs) + sub_prev = torch.where(adv[:, None], cur, sub_prev) + nsub = torch.where(adv, nsub - 1, nsub); sub_t = torch.where(adv, torch.full_like(sub_t, tnow), sub_t) + return subs, nsub, sub_prev, sub_t + + +class TeacherOracle: + """老师当策略(2026-10-19,审查 review_closedloop D1 / review_data_actions §4.1): + 规划时从当前时间下标 it 取老师 50 Hz 轨迹的 16 步增量,按与 build_dataset.cjs 相同的标签口径 + act = clamp(Δ / (act_scale·STEP), −1, 1) 交给 rollout(),其余(队列、前缀、提交、qe、DLS、done、碰撞、时限)一行不改。 + traj [B,Tmax,6]:每题老师轨迹,末帧重复补齐(按 id 从 ml/data/shard_*.jsonl 取)。 + sigma > 0:每次规划给 act 加 N(0, σ²)(σ 以 STEP 为单位,各候选独立),测闭环对残差噪声的敏感度。 + 跟随时第 it 步的 q 应等于 traj[:, it](DLS 接管或到达冻结之后除外)。""" + head = 'oracle' + + def __init__(self, traj, act_scale=1.0, sigma=0.0): + self.traj, self.act_scale, self.sigma = traj, float(act_scale), float(sigma) + + def plan(self, it, K, g): + B, Tm, _ = self.traj.shape + dev = self.traj.device + idx = (it + torch.arange(H + 1, device=dev)).clamp(max=Tm - 1) + seg = self.traj[:, idx] + astep = torch.tensor(STEP, device=dev) * self.act_scale + act = ((seg[:, 1:] - seg[:, :-1]) / astep).clamp(-1, 1) + act = act[:, None].expand(B, K, H, 6).contiguous() + if self.sigma > 0: + act = act + torch.randn(B, K, H, 6, device=dev, generator=g) * (self.sigma / self.act_scale) + return act + + @torch.no_grad() -def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, return_traj=False, use_sub=True, sub_override=None, qp0=None, steps=1, z_scale=1.0, select='consist'): - """use_sub=False:不用子目标(sf=0);sub_override=(subs [B,2,6] 绝对关节角, need [B] bool):冷启动时用给定途经点代替子目标头;qp0 [B,6]:起点的上一帧(默认 = q0,即静止起步)。steps:MeanFlow 采样段数;z_scale:噪声缩放(0 = 只取「均值」样本);select:consist(部署口径:冷启动价值前瞻、之后一致性)/ value(每次都价值前瞻)/ first(不选,取第 0 条)。均为诊断用""" +def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, return_traj=False, use_sub=True, sub_override=None, + qp0=None, steps=1, z_scale=1.0, select='consist', oracle=None, replan='r3', sub_rule='cross'): + """use_sub=False:不用子目标(sf=0);sub_override=(subs [B,M,6] 绝对关节角, n [B] 个数 或 need [B] bool):冷启动时用给定途经点代替子目标头; + qp0 [B,6]:起点的上一帧(默认 = q0,即静止起步)。steps:MeanFlow 采样段数;z_scale:噪声缩放; + select:consist(部署口径:冷启动价值前瞻、之后与上一条已提交计划的一致性)/ value(每次都价值前瞻)/ first(不选,取第 0 条)。 + oracle:TeacherOracle,代替 model 出动作(model 可为 None,须配 select='first'、use_sub=False)。 + replan:'r3'(部署:队列 ≤ 3 步就规划,稳态 r=3)/ 'empty'(队列空了才规划,每次 r=0,前缀消融用)。 + sub_rule:子目标推进规则,见 sub_advance。除默认值外均为诊断用。""" g = torch.Generator(device=device); g.manual_seed(seed) q0, T, R, ex, tr = tasks_to_tensors(tasks, device) B = q0.shape[0] step = torch.tensor(STEP, device=device) + act_scale = oracle.act_scale if oracle is not None else float(getattr(model, 'act_scale', 1.0)) + astep = step * act_scale # 动作反归一化:每单位 act 对应的度数 has_ref = torch.isfinite(ex) - limit = torch.where(has_ref, torch.clamp(2 * ex, min=3.0), torch.full_like(ex, NOREF_SEC)) if max_sec is None else torch.full_like(ex, max_sec) + limit = time_limit(ex, max_sec) nmax = int(math.ceil(limit.max().item() / DT)) q, qp = q0.clone(), (qp0.to(device) if qp0 is not None else q0.clone()) queue = torch.zeros(B, 0, 6, device=device) - last = None - subs = torch.zeros(B, 2, 6, device=device); nsub = torch.zeros(B, dtype=torch.long, device=device); sub_t = torch.zeros(B, device=device) + last = None; last_ok = torch.zeros(B, dtype=torch.bool, device=device) + subs = torch.zeros(B, 2, 6, device=device); nsub = torch.zeros(B, dtype=torch.long, device=device) + sub_t = torch.zeros(B, device=device); sub_prev = q0.clone() done = torch.zeros(B, dtype=torch.bool, device=device); t_done = torch.full((B,), float('nan'), device=device) col = torch.zeros(B, device=device); travel = torch.zeros(B, device=device); brakes = torch.zeros(B, device=device) + first_hit = torch.full((B,), float('nan'), device=device); dls_it = torch.full((B,), -1, dtype=torch.long, device=device) traj = [q.clone()] if return_traj else None n_start = int(round(0.5 / DT)); moved_start = None # R2 起步失败:0.5 s 内关节总行程 < 1° + k2s = int(round(2.0 / DT)); e2 = None + e0, _ = goal_err(kin, q0, T, R) j1_acc = torch.zeros(B, device=device); j1_max = torch.zeros(B, device=device); j1_min = torch.zeros(B, device=device) j1_first = torch.zeros(B, device=device) # 2026-10-18 第五轮(审查 N-3):J1 累计偏离首次超过 FIRST_DEG 时的方向(真正的"首次方向") cold = True + ar = torch.arange(B, device=device) for it in range(nmax): tnow = it * DT - if queue.shape[1] <= 3: + if (queue.shape[1] <= 3) if replan == 'r3' else (queue.shape[1] == 0): r = queue.shape[1] pre = torch.zeros(B, 3, 6, device=device); pre[:, :r] = queue[:, :r] qe = q + queue.sum(1) e, oe = goal_err(kin, qe, T, R) near = (e < 5) & ((R.abs().sum(-1) == 0) | (oe < 5)) - # 子目标推进 - if (nsub > 0).any(): - cur = subs[:, 0] - dd = torch.where(kin.cont, wrap180(cur - qe), cur - qe).abs().max(-1).values - adv = (nsub > 0) & ((dd < 5) | (tnow - sub_t > 1.5)) - subs = torch.where(adv[:, None, None], torch.cat([subs[:, 1:], subs[:, 1:]], 1), subs) - nsub = torch.where(adv, nsub - 1, nsub); sub_t = torch.where(adv, torch.full_like(sub_t, tnow), sub_t) + subs, nsub, sub_prev, sub_t = sub_advance(kin, subs, nsub, sub_prev, sub_t, qe, tnow, sub_rule) sf = (nsub > 0).float() feat = features(kin, q, qp, T, R, pre, torch.full((B,), r, device=device), subs[:, 0], sf) - z = torch.randn(B * K, H, 6, device=device, generator=g) * z_scale - act, val, sub = model.sample_k(feat, K, z, steps=steps) - inc = act.clamp(-1, 1) * step + if oracle is not None: + act = oracle.plan(it, K, g); sub = torch.zeros(B, 13, device=device) + else: + z = torch.randn(B * K, H, 6, device=device, generator=g) * z_scale + act, val, sub = model.sample_k(feat, K, z, steps=steps) + inc = act.float().clamp(-1, 1) * astep if cold: - sub0 = sub - need = sub0[:, 12] > 0.5 - subs = q[:, None] + sub0[:, :12].view(B, 2, 6) * 180 - if sub_override is not None: subs, need = sub_override[0].to(device), sub_override[1].to(device) - if not use_sub: need = torch.zeros_like(need) - nsub = torch.where(need, torch.full_like(nsub, 2), torch.zeros_like(nsub)); sub_t.fill_(tnow) + need = sub[:, 12] > 0.5 + subs = q[:, None] + sub[:, :12].float().view(B, 2, 6) * 180 + if sub_override is not None: + subs = sub_override[0].to(device).float() + n_ov = sub_override[1].to(device) + nsub_ov = n_ov.long() if n_ov.dtype != torch.bool else n_ov.long() * subs.shape[1] + need = nsub_ov > 0 + nsub = nsub_ov + else: + nsub = torch.where(need, torch.full_like(nsub, 2), torch.zeros_like(nsub)) + if not use_sub: nsub = torch.zeros_like(nsub) + sub_t.fill_(tnow); sub_prev = q.clone() fut = qe[:, None, None] + torch.cumsum(inc[:, :, r:], 2) # [B,K,H-r,6] if select == 'first': score = torch.zeros(B, K, device=device); score[:, 0] = 1 - elif last is None or select == 'value': + elif cold or select == 'value': # 短前瞻:候选末态的价值 qa = fut[:, :, -1].reshape(B * K, 6); qb = fut[:, :, -2].reshape(B * K, 6) f2 = features(kin, qa, qb, T.repeat_interleave(K, 0), R.repeat_interleave(K, 0), torch.zeros(B * K, 3, 6, device=device), @@ -124,32 +215,38 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, score = -v2.view(B, K) else: # 2026-10-19 修错位:last[i] = 上一计划从 qe_old 走 i 步后的位置;提交 3 步后 qe_new = last[3], - # 新候选 fut[0] 是 qe_new 再走 1 步 = 对应 last[4]。原来对齐 last[3],等于奖励「比上一计划慢一步」的候选, - # 每次重规划都挑最拖的那条,闭环越走越慢。JS 部署(ik-policy.js)同一处同样修正。 + # 新候选 fut[0] 是 qe_new 再走 1 步 = 对应 last[4]。JS 部署(ik-policy.js)同一处同样修正。 n = min(fut.shape[2], last.shape[1] - 4) w = 0.85 ** torch.arange(n, device=device, dtype=torch.float32) d2 = (((fut[:, :, :n] - last[:, None, 4:4 + n]) / step) ** 2).sum(-1) - score = -(d2 * w).sum(-1) - order = score.argsort(-1, descending=True) + # 2026-10-19(审查 review_closedloop F5):上一次是刹停或 DLS 接管的行没有「已提交计划」, + # 与 JS(lastPlan = null → 一致性项缺席、各候选同分 → 取第 0 条)一致:同分,稳定排序取第 0 条 + score = torch.where(last_ok[:, None], -(d2 * w).sum(-1), torch.zeros_like(d2[..., 0])) + order = torch.sort(score, dim=-1, descending=True, stable=True).indices pick = order[:, 0] - commit = inc[torch.arange(B), pick, r:r + 3] + commit = inc[ar, pick, r:r + 3] + committed = torch.ones(B, dtype=torch.bool, device=device) if safety: ok_any = torch.zeros(B, dtype=torch.bool, device=device) - chosen = torch.zeros(B, 3, 6, device=device) + chosen = torch.zeros(B, 3, 6, device=device); chosen_k = pick.clone() for kk in range(K): - ck = inc[torch.arange(B), order[:, kk], r:r + 3] + ck = inc[ar, order[:, kk], r:r + 3] pts = qe[:, None] + torch.cumsum(ck, 1) mids = torch.cat([qe[:, None], pts[:, :-1]], 1) + 0.5 * ck allq = torch.cat([pts, mids], 1).reshape(-1, 6) okk = (kin.gaps(allq, 0.5)['gm'].min(-1).values > 0).view(B, 6).all(-1) take = okk & ~ok_any - chosen = torch.where(take[:, None, None], ck, chosen); ok_any |= okk + chosen = torch.where(take[:, None, None], ck, chosen); chosen_k = torch.where(take, order[:, kk], chosen_k); ok_any |= okk commit = torch.where(ok_any[:, None, None], chosen, torch.zeros_like(chosen)) brakes += (~ok_any).float() + pick = chosen_k; committed = ok_any if near.any(): ref = dls_steps(kin, qe, T, R) commit = torch.where(near[:, None, None], ref, commit) - last = torch.cat([qe[:, None], fut[torch.arange(B), pick]], 1) + dls_it = torch.where(near & (dls_it < 0), torch.full_like(dls_it, it), dls_it) + committed = committed & ~near + # 2026-10-19(F5):last 记真正提交的那条候选(安全层换了候选时是换后的那条);刹停 / DLS 接管的行无效 + last = torch.cat([qe[:, None], fut[ar, pick]], 1); last_ok = committed queue = torch.cat([queue, commit], 1) cold = False d = queue[:, 0]; queue = queue[:, 1:] @@ -162,15 +259,21 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, if return_traj: traj.append(q.clone()) mid = qp + 0.5 * (q - qp) gm = kin.gaps(torch.cat([q, mid], 0), 0.5)['gm'].min(-1).values - hit = ((gm[:B] <= 0) | (gm[B:] <= 0)) & ~done + tn = (it + 1) * DT + # 2026-10-19(审查 review_closedloop F4):只计每题自己时限内的碰撞(同批长题会把短题多仿真几秒) + hit = ((gm[:B] <= 0) | (gm[B:] <= 0)) & ~done & (tn <= limit + 1e-6) col += hit.float() + first_hit = torch.where(hit & torch.isnan(first_hit), torch.full_like(first_hit, tn), first_hit) e, oe = goal_err(kin, q, T, R) + if it + 1 == k2s: e2 = e.clone() newly = (~done) & (e < 1) & (oe < 0.5) - t_done = torch.where(newly, torch.full_like(t_done, (it + 1) * DT), t_done); done |= newly + t_done = torch.where(newly, torch.full_like(t_done, tn), t_done); done |= newly if done.all(): break - ok = done & (t_done <= limit) + ok = done & (t_done <= limit + 1e-6) if moved_start is None: moved_start = travel.clone() e_end, _ = goal_err(kin, q, T, R) + if e2 is None: e2 = e_end + ratio2 = e2 / e0.clamp(min=1e-3) start_fail = (moved_start < 1.0) & ~(done & (t_done <= 0.5)) okr = ok & has_ref & torch.isfinite(tr) res = {'n': B, 'n_ref': int(has_ref.sum()), 'success': ok.float().mean().item(), @@ -178,20 +281,22 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, 'collision_rate': (col > 0).float().mean().item(), 'collision_steps_mean': col.mean().item(), 'travel_ratio_median': (travel[okr] / tr[okr].clamp(min=1)).median().item() if okr.any() else None, 'time_mean_s': t_done[ok].mean().item() if ok.any() else None, 'brakes_mean': brakes.mean().item(), - 'start_fail': start_fail.float().mean().item(), + 'start_fail': start_fail.float().mean().item(), 'goal_ratio_2s': ratio2.median().item(), 'time_budget': 'teacher×2(≥3 s)' if bool(has_ref.all()) else ('fixed %.0f s' % (max_sec or NOREF_SEC) if not has_ref.any() else 'mixed')} # 绕行侧:J1 累计位移的正向/负向极值哪个更大(+1 / −1),供 R7 左右比例门禁 # 首次方向;全程没偏离到 FIRST_DEG 的(基本不绕 J1)退回净方向 side = torch.where(j1_first != 0, j1_first, torch.where(j1_max >= -j1_min, torch.ones_like(j1_max), -torch.ones_like(j1_max))) - per = {'done': done.cpu(), 't_done': t_done.cpu(), 'col': col.cpu(), 'travel': travel.cpu(), 'err': e_end.cpu(), - 'side': side.cpu(), 'start_fail': start_fail.cpu(), 'brakes': brakes.cpu()} + per = {'done': done.cpu(), 'ok': ok.cpu(), 't_done': t_done.cpu(), 'limit': limit.cpu(), 'col': col.cpu(), 'travel': travel.cpu(), 'err': e_end.cpu(), + 'side': side.cpu(), 'start_fail': start_fail.cpu(), 'brakes': brakes.cpu(), 'first_hit': first_hit.cpu(), 'dls_it': dls_it.cpu(), + 'goal_ratio_2s': ratio2.cpu()} if return_traj: per['traj'] = torch.stack(traj, 1).cpu() return res, per def evaluate(model, kin, tasks, device, K=8, safety=False, bs=256, seed=0): if not tasks: # 小样本摸底时 val 可能为空 - return {'n': 0, 'success': float('nan'), 'collision_rate': float('nan'), 'start_fail': float('nan'), 'brakes_mean': float('nan')}, [] + return {'n': 0, 'success': float('nan'), 'collision_rate': float('nan'), 'start_fail': float('nan'), 'brakes_mean': float('nan'), + 'goal_ratio_2s': float('nan')}, [] agg, pers = {}, [] for i in range(0, len(tasks), bs): r, p = rollout(model, kin, tasks[i:i + bs], device, K=K, safety=safety, seed=seed + i) @@ -202,12 +307,14 @@ def evaluate(model, kin, tasks, device, K=8, safety=False, bs=256, seed=0): n = sum(r['n'] for r, _ in pers) out = {k: v / n for k, v in agg.items()}; out['n'] = n out['n_ref'] = sum(r['n_ref'] for r, _ in pers) + # 中位数不能按批加权平均:全体任务重算(2026-10-19,选模分数用它) + out['goal_ratio_2s'] = torch.cat([p['goal_ratio_2s'] for _, p in pers]).median().item() out['time_budget'] = pers[0][0]['time_budget'] if len({r['time_budget'] for r, _ in pers}) == 1 else 'mixed' return out, pers def main(): - from ml.model.policy import build + from ml.common.ckpt import load_policy from ml.data import load_tasks ap = argparse.ArgumentParser() ap.add_argument('--ckpt', required=True); ap.add_argument('--samples', default='ml/data/samples') @@ -216,8 +323,7 @@ def main(): ap.add_argument('--safety', action='store_true'); ap.add_argument('--K', type=int, default=8) ap.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu'); ap.add_argument('--out', default=None) a = ap.parse_args() - ck = torch.load(a.ckpt, map_location=a.device) - model = build(ck['cfg']).to(a.device); model.load_state_dict(ck['model']); model.eval() + model, ck = load_policy(a.ckpt, a.device) kin = Kin(device=a.device) assert ck.get('fp') in (None, kin.fp), '碰撞模型指纹与 checkpoint 不符' if a.tasks: tasks = [json.loads(l) for l in open(a.tasks, encoding='utf-8') if l.strip()] @@ -225,8 +331,8 @@ def main(): tasks = tasks[:a.n] res, pers = evaluate(model, kin, tasks, a.device, K=a.K, safety=a.safety) by = {} - flat_done = torch.cat([p['done'] for _, p in pers]); flat_col = torch.cat([p['col'] for _, p in pers]) - for t, d, c in zip(tasks, flat_done.tolist(), flat_col.tolist()): + flat_ok = torch.cat([p['ok'] for _, p in pers]); flat_col = torch.cat([p['col'] for _, p in pers]) + for t, d, c in zip(tasks, flat_ok.tolist(), flat_col.tolist()): k = t.get('cat', '?') + '/' + t.get('mode', '?'); b = by.setdefault(k, [0, 0, 0]); b[0] += 1; b[1] += int(d); b[2] += int(c > 0) res['by_cat_mode'] = {k: {'n': v[0], 'success': v[1] / v[0], 'collision': v[2] / v[0]} for k, v in by.items()} s = json.dumps(res, indent=1); print(s) diff --git a/ml/train.py b/ml/train.py index d6e1ada..ad6ddf6 100644 --- a/ml/train.py +++ b/ml/train.py @@ -1,15 +1,27 @@ """预训练(方案 §6):MeanFlow 一步头 + FK 损失 + 平滑 + 价值头 + 子目标头。 python -m ml.train --samples ml/data/samples --out ml/runs/base [--config ml/configs/base.json] 吞吐摸底:python -m ml.train --samples ml/data/samples --out ml/runs/bench_base --steps 2000 --no-eval - 通过线(方案 R10):全程 60000 步 ≤ 3.5 h,即 samples_per_s ≥ 5000(3090 实测 6.2 k/s ≈ 2.8 h);日志 eta_h 为剩余小时数。 -每 eval_every 步做一次闭环验证(ml/sim/closed_loop.py,部署节奏),按 success 存 best.pt;连续 patience_evals 次不升即停。 + 通过线(方案 R10):samples_per_s ≥ 5000;日志 eta_h 为剩余小时数。 +每 eval_every 步用 EMA 权重做一次闭环验证(ml/sim/closed_loop.py,部署节奏),按选模分数存 best.pt;min_step 之后连续 patience_evals 次不升即停。 2026-10-18 首次正式预训练(21850 步,闭环到达 0–0.2%、穿柱 77–97%)后改: - FK / 平滑损失不再加在 one_step(随机噪声) 上(那样等于对任意噪声回归同一个 Y → 条件均值,多峰头塌成单峰), 改加在 MF 分支里 r==t 且 t ≤ fk_tmax 的样本的配对去噪估计 x̂ = z − t·u 上(见 ml/model/meanflow.py); - p_neq 前 p_neq_warm 步为 0(纯 flow matching 先把瞬时速度场学稳,再开 r≠t 的自举目标); - 每次评估加开环指标(val 样本上一步采样的 16 步末端 FK 误差 mm、K 个候选的多样性), - 塌缩或学偏当场可见;best / 早停按 success 为主、开环误差为次(success 同为 0 时也能分出好坏,不会 10000 步就误停)。""" -import argparse, json, math, os, sys, time + 塌缩或学偏当场可见。 +2026-10-19(审查 review_model_train P0-2/P0-3、review_data_actions C3): + - EMA(train.ema,默认 0.999,前期按 (1+s)/(10+s) 放宽):评估、best/last 里的 'ema' 都是 EMA 权重;下游一律经 ckpt.load_policy 取 ck.get('ema', ck['model']); + - 选模:p_neq 预热期内及预热后第一次评估之前不存 best.pt(那时一步映射 u(z,0,1) 还没训过); + 分数 = success + 0.05·(1 − collision_rate) + 0.05·(1 − min(1, goal_ratio_2s)) − 1e-4·min(ol_best_mm, 500),success 全 0 时闭环指标仍能排序; + 早停只在 min_step = max(预热 + 2·eval_every, steps/2) 之后计数; + - 余弦学习率按真实预算走完(configs 的 steps 改为 24000,原 60000 从来跑不到,14k 步早停时学习率还有 0.87×); + - MeanFlow 采样:p_neq 0.5、p_edge 0.1(固定 (r=0,t=1) 的部署点,见 meanflow.sample_rt); + - 子目标头只看 sub 特征(68:75)置零的输入:sub 存在时 SG[0:6] 与输入 sd 是同一个量,原来 80% 的样本可以直接抄(标签泄露); + 运行时子目标头只在冷启动 sf=0 时用,这样训练与使用同口径。价值头仍用完整输入(稳态推理时带 sub); + - 条件输入增广 aug_pre_drop / aug_pre_sigma / aug_dq_sigma(见 ml/data.Samples.augment); + - 动作尺度 act_scale 取自数据(ml/data.read_meta),存进 checkpoint;续训 / 微调起点与数据不一致直接报错。 + - DAgger 微调(--init):只有起点 checkpoint 已走过自己的 p_neq 预热时才把预热置 0;起点优先取 EMA 权重。""" +import argparse, copy, json, math, os, sys, time import torch from ml.common.kin_torch import Kin @@ -36,6 +48,7 @@ def q_from_feat(X): @torch.no_grad() def open_loop(model, kin, vdata, stepv, fk_idx, n=1024, K=8): + """stepv:每单位动作对应的度数(= act_scale·STEP)。""" """开环指标(val 样本,老师状态上): ol_fk_mm 候选 0 的 16 步末端与老师 Y 末端的胶囊点平均距离(mm); ol_best_mm K 个候选里最接近老师的那条的同一误差(多峰时它应明显小于 ol_fk_mm); @@ -59,7 +72,7 @@ def main(): ap.add_argument('--samples', nargs='+', default=['ml/data/samples']); ap.add_argument('--out', default='ml/runs/base') ap.add_argument('--config', default='ml/configs/base.json'); ap.add_argument('--steps', type=int, default=None) ap.add_argument('--resume', default=None); ap.add_argument('--no-eval', action='store_true') - ap.add_argument('--init', default=None, help='只载入权重、优化器与步数从 0 开始(DAgger 微调用)') + ap.add_argument('--init', default=None, help='只载入权重(优先 EMA)、优化器与步数从 0 开始(DAgger 微调用)') ap.add_argument('--lr', type=float, default=None); ap.add_argument('--warmup', type=int, default=None) ap.add_argument('--stop-at', type=int, default=0, help='自检用:跑到第 N 步按停止请求处理(存 last.pt、rc 143)') ap.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu'); ap.add_argument('--seed', type=int, default=0) @@ -68,7 +81,14 @@ def main(): if a.steps: tc['steps'] = a.steps if a.lr: tc['lr'] = a.lr if a.warmup is not None: tc['warmup'] = a.warmup - if a.init: tc['p_neq_warm'] = 0 # 微调起点已训过 r≠t,不再预热 + init_ck = None + if a.init: + init_ck = torch.load(a.init, map_location='cpu') + # 2026-10-19(审查 P0-2):原来 --init 一律置 p_neq_warm=0,前提「起点已训过 r≠t」;best.pt 落在预热期(第 4000 步)时前提不成立, + # DAgger 一上来就用没训过的场做 JVP 自举。现在只有起点已走过它自己的预热才置 0。 + init_warm = (init_ck.get('cfg') or {}).get('train', {}).get('p_neq_warm', 5000) + if init_ck.get('step', 0) >= init_warm: tc['p_neq_warm'] = 0 + print(f'微调起点第 {init_ck.get("step")} 步(其预热 {init_warm} 步)→ 本次 p_neq_warm = {tc.get("p_neq_warm", 5000)}', flush=True) os.makedirs(a.out, exist_ok=True) torch.manual_seed(a.seed) dev = a.device @@ -76,37 +96,55 @@ def main(): if dev.startswith('cuda'): torch.backends.cuda.matmul.allow_tf32 = True; torch.backends.cudnn.allow_tf32 = True # MF 分支改 fp32 后用 TF32 保吞吐 kin = Kin(device=dev) - data = Samples(a.samples, device=dev, split='train') - val_tasks = [t for t in load_tasks(a.samples) if t['split'] == 'val'][:tc['eval_tasks']] - try: vdata = Samples(a.samples, device=dev, split='val') + # 2026-10-19(审查 F11):val 只取主数据目录(第一个 --samples);DAgger 目录的样本全部当 train + data = Samples(a.samples, device=dev, split='train', val_dirs=a.samples[:1]) + act_scale = data.act_scale + val_tasks = [t for t in load_tasks(a.samples[:1]) if t['split'] == 'val'][:tc['eval_tasks']] + try: vdata = Samples(a.samples[:1], device=dev, split='val') except SystemExit: vdata = None if vdata is not None and vdata.n == 0: vdata = None - print(f'train samples {data.n}, val tasks {len(val_tasks)}, device {dev}', flush=True) - model = build(cfg).to(dev) + print(f'train samples {data.n}, val tasks {len(val_tasks)}, act_scale {act_scale}, data_ver {data.data_ver}, device {dev}', flush=True) + model = build(cfg).to(dev); model.act_scale = act_scale print(f'params {sum(p.numel() for p in model.parameters()) / 1e6:.2f} M', flush=True) opt = torch.optim.AdamW(model.parameters(), lr=tc['lr'], betas=tuple(tc['betas']), weight_decay=tc['wd']) step0, best, bad_evals = 0, -1.0, 0 - if a.init and not a.resume: - ck = torch.load(a.init, map_location=dev); model.load_state_dict(ck['model']) - print(f'微调:权重取自 {a.init}(第 {ck.get("step")} 步),优化器重置', flush=True) + def check_scale(ck, src): + s0 = float(ck.get('act_scale', 1.0)) + if abs(s0 - act_scale) > 1e-9: + raise SystemExit(f'{src} 的 act_scale={s0} 与样本 act_scale={act_scale} 不一致:标签尺度变了,须从头训练(FRESH_FROM=prep)') + if init_ck is not None and not a.resume: + check_scale(init_ck, a.init) + model.load_state_dict(init_ck.get('ema') if init_ck.get('ema') is not None else init_ck['model']) + print(f'微调:权重取自 {a.init}({"EMA" if init_ck.get("ema") is not None else "原始"}权重),优化器重置', flush=True) + ema = copy.deepcopy(model).eval(); ema.act_scale = act_scale + for p_ in ema.parameters(): p_.requires_grad_(False) if a.resume: - ck = torch.load(a.resume, map_location=dev); model.load_state_dict(ck['model']) + ck = torch.load(a.resume, map_location=dev); check_scale(ck, a.resume) + model.load_state_dict(ck['model']) + ema.load_state_dict(ck['ema'] if ck.get('ema') is not None else ck['model']) if 'opt' in ck: opt.load_state_dict(ck['opt']) step0, best, bad_evals = ck.get('step', 0), ck.get('best', -1.0), ck.get('bad_evals', 0) if set_rng_state(ck.get('rng')): print('续训:随机数状态已恢复(batch 顺序与不断点一致)', flush=True) - print(f'续训:从第 {step0} 步继续(共 {tc["steps"]} 步),当前最好 success {best:.4f}', flush=True) + print(f'续训:从第 {step0} 步继续(共 {tc["steps"]} 步),当前最好分数 {best:.4f}', flush=True) + del init_ck total = tc['steps'] lr_at = lambda s: tc['lr'] * (s / tc['warmup'] if s < tc['warmup'] else 0.5 * (1 + math.cos(math.pi * (s - tc['warmup']) / max(1, total - tc['warmup'])))) amp = dev.startswith('cuda') and tc.get('amp') == 'bf16' stepv = torch.tensor(STEP, device=dev) + astep = stepv * act_scale # 动作 → 度 fk_idx = torch.linspace(0, kin.cap_a.shape[0] - 1, tc['fk_caps'], device=dev).long() + ema_d = float(tc.get('ema', 0.999)) + warm = 0 if model.head == 'wta' else int(tc.get('p_neq_warm', 5000)) + sel_from = warm + tc['eval_every'] # 第一次可以存 best.pt 的步数:预热结束后的第一次评估 + min_step = max(warm + 2 * tc['eval_every'], total // 2) # 早停计数起点 log = open(os.path.join(a.out, 'log.jsonl'), 'a' if a.resume else 'w') # 新训练覆盖旧日志,续训才追加 if not val_tasks and not a.no_eval: print('没有 val 任务(样本太少),本次跳过闭环验证', flush=True); a.no_eval = True t0, seen, diverged = time.time(), 0, False saver = Saver(tc.get('ckpt_min', 10)) # 2026-10-18:每 10 分钟 + 收到停止请求时存 last.pt(原子写) def save(name, step, extra=None): - d = {'model': model.state_dict(), 'opt': opt.state_dict(), 'cfg': cfg, 'step': step, 'best': best, 'bad_evals': bad_evals, 'fp': kin.fp} + d = {'model': model.state_dict(), 'ema': ema.state_dict(), 'opt': opt.state_dict(), 'cfg': cfg, 'step': step, 'best': best, + 'bad_evals': bad_evals, 'fp': kin.fp, 'act_scale': act_scale, 'data_ver': data.data_ver} if name == 'last.pt': d['rng'] = rng_state() if extra: d.update(extra) atomic_save(d, os.path.join(a.out, name)) @@ -114,19 +152,21 @@ def main(): step = step0 - 1 # 续训时已训满:循环一次不跑,下面照常收尾 for step in range(step0, total): for gpar in opt.param_groups: gpar['lr'] = lr_at(step) - X, Y, V, SG = data.batch(tc['batch']) + X, Y, V, SG = data.batch(tc['batch'], aug=tc) model.train() with torch.autocast('cuda', dtype=torch.bfloat16, enabled=amp): mem = model.encode(X) if model.head == 'wta': l_mf, xh = wta_loss(model, mem, Y); mf_raw = l_mf.detach(); xh = xh.float() else: - p_neq = tc['p_neq'] if step >= tc.get('p_neq_warm', 5000) else 0.0 - l_mf, mf_raw, aux = meanflow_loss(model, mem, Y, p_neq=p_neq, return_aux=True) + p_neq = tc['p_neq'] if step >= warm else 0.0 + l_mf, mf_raw, aux = meanflow_loss(model, mem, Y, p_neq=p_neq, return_aux=True, p_edge=tc.get('p_edge', 0.0)) sel = ((aux['r'] == aux['t']) & (aux['t'] <= tc.get('fk_tmax', 0.3))).nonzero(as_tuple=True)[0] tt = aux['t'][sel][:, None, None] xh = aux['z'][sel] - tt * aux['u'][sel] # 配对去噪估计(fp32) - v, s = model.heads(mem) + v, _ = model.heads(mem) + Xs = X.clone(); Xs[:, 68:75] = 0 # 子目标头:sub 特征置零(切断抄输入的捷径,见文件头) + _, s = model.heads(model.encode(Xs)) l_val = (v.float() - V).abs().mean(); l_sub = (s.float() - SG).abs().mean() Xf, Yf = (X, Y) if model.head == 'wta' else (X[sel], Y[sel]) l_sm = torch.zeros((), device=dev); l_fk = torch.zeros((), device=dev) @@ -136,8 +176,8 @@ def main(): if tc['w_fk'] > 0 and xh.shape[0] > 0: q = q_from_feat(Xf) ks = torch.tensor([3, 7, 11, 15], device=dev) - qh = q[:, None] + torch.cumsum(xh.clamp(-1, 1) * stepv, 1)[:, ks] - qt = q[:, None] + torch.cumsum(Yf * stepv, 1)[:, ks] + qh = q[:, None] + torch.cumsum(xh.clamp(-1, 1) * astep, 1)[:, ks] + qt = q[:, None] + torch.cumsum(Yf * astep, 1)[:, ks] B4 = qh.shape[0] * 4 ph = fk_points(kin, qh.reshape(B4, 6), fk_idx); pt = fk_points(kin, qt.reshape(B4, 6), fk_idx) l_fk = ((ph - pt).norm(dim=-1) / 10).mean() @@ -148,6 +188,9 @@ def main(): if not torch.isfinite(loss): print('非有限损失 → 训练发散(M5 门禁:触发 WTA 回退条件)', flush=True); save('diverged.pt', step); diverged = True; break opt.step(); seen += X.shape[0] + with torch.no_grad(): # EMA;前期按 (1+s)/(10+s) 放宽,避免随机初值占权重 + dk = min(ema_d, (1 + step) / (10 + step)) + for pe, pm in zip(ema.parameters(), model.parameters()): pe.lerp_(pm.detach(), 1 - dk) if saver.stop() or (a.stop_at and step + 1 >= a.stop_at): save('last.pt', step + 1); log.close() print(f'收到停止请求:已存档 last.pt(第 {step + 1} 步),下次运行自动续训', flush=True); sys.exit(143) @@ -159,25 +202,25 @@ def main(): 'eta_h': (total - step - 1) * tc['batch'] / max(seen / max(el, 1e-6), 1e-6) / 3600, 'vram': vram.peak_gib()} print(json.dumps(rec), flush=True); log.write(json.dumps(rec) + '\n'); log.flush() if not a.no_eval and (step + 1) % tc['eval_every'] == 0: - model.eval() # bs=64 + 归还缓存池:WDDM 卡满时 15.9 GiB 的评估峰值会被驱动换出到内存,eval 从 2 分钟变 1 小时 - res, _ = evaluate(model, kin, val_tasks, dev, bs=64) - if vdata is not None: res.update(open_loop(model, kin, vdata, stepv, fk_idx)) + res, _ = evaluate(ema, kin, val_tasks, dev, bs=64) + if vdata is not None: res.update(open_loop(ema, kin, vdata, astep, fk_idx)) torch.cuda.empty_cache() - res['step'] = step + 1; res['t'] = time.time() + sc = res['success'] + 0.05 * (1 - res['collision_rate']) + 0.05 * (1 - min(1.0, res.get('goal_ratio_2s', 1.0))) \ + - 1e-4 * min(res.get('ol_best_mm', 0.0), 500.0) + res['step'] = step + 1; res['t'] = time.time(); res['score'] = sc; res['weights'] = 'ema' print('EVAL', json.dumps(res), flush=True); log.write(json.dumps({'eval': res}) + '\n'); log.flush() - save('last.pt', step + 1) - sc = res['success'] - 1e-3 * min(res.get('ol_fk_mm', 0.0), 500.0) # 到达率为主,开环误差(mm)只在到达率打平时起作用 - if sc > best + 1e-5: + if step + 1 >= sel_from and sc > best + 1e-5: best = sc; bad_evals = 0; save('best.pt', step + 1, {'eval': res}) - else: + elif step + 1 >= min_step: bad_evals += 1 - if bad_evals >= tc['patience_evals']: print('闭环到达率不再上升,停止', flush=True); break + if bad_evals >= tc['patience_evals']: save('last.pt', step + 1); print('闭环分数不再上升,停止', flush=True); break + save('last.pt', step + 1) if diverged: # 发散的权重不能覆盖 last.pt / best.pt:last.pt 保留发散前最后一次存档,M5 门禁会因此判不过 → 自动 WTA print('训练发散,last.pt / best.pt 保留发散前的存档', flush=True); return save('last.pt', step + 1) - if a.no_eval: save('best.pt', step + 1) - print(f'done, best score(到达率 − 0.001×开环 mm){best:.4f}, {(time.time() - t0) / 3600:.2f} h', flush=True) + if a.no_eval or best <= -1.0: save('best.pt', step + 1) # 没评估过 / 总步数不到 sel_from:best = last + print(f'done, best score {best:.4f}, {(time.time() - t0) / 3600:.2f} h', flush=True) if __name__ == '__main__': diff --git a/tests/policy_bench.cjs b/tests/policy_bench.cjs index 8105135..10612dc 100644 --- a/tests/policy_bench.cjs +++ b/tests/policy_bench.cjs @@ -71,7 +71,7 @@ const pct = (a, p) => { if (!a.length) return null; const s = a.slice().sort((x, for (let ci = 0; ci < cases.length; ci++) { const c = cases[ci]; const R = mode === 'pose' ? c.R : null; - const r = new POL.Runner(S, infer, { seed: 1000 + ci }); + const r = new POL.Runner(S, infer, { seed: 1000 + ci, actScale: meta.act_scale || 1 }); // 2026-10-19:动作尺度随模型 r.reset(c.q0); r.setGoal(c.T, R); let prev = c.q0.slice(), cf = 0, tLock = null; for (let k = 0; k < 500 && !r.done; k++) { diff --git a/webapp/js/ik-policy-glue.js b/webapp/js/ik-policy-glue.js index 6064709..d4ebb66 100644 --- a/webapp/js/ik-policy-glue.js +++ b/webapp/js/ik-policy-glue.js @@ -55,7 +55,7 @@ async function boot() { } if (!sess) sess = await ort.InferenceSession.create(MODEL, { executionProviders: ep, graphOptimizationLevel: 'all' }); const infer = ArmPolicy.ortInfer(ort, sess, { encOnce: !!meta.enc_once, featDyn: !!meta.feat_dyn }); - runner = new ArmPolicy.Runner(S, infer, {}); + runner = new ArmPolicy.Runner(S, infer, { actScale: meta.act_scale || 1 }); // 2026-10-19:动作尺度随模型(旧模型无此字段 = 1) /* 预热:首次推理有编译开销 */ await infer(new Float32Array(8 * ArmPolicy.F_DIM), new Float32Array(8 * ArmPolicy.H * 6), 8); const tick = URL.createObjectURL(new Blob(['let h=null;onmessage=e=>{clearInterval(h);if(e.data>0)h=setInterval(()=>postMessage(0),e.data)}'], { type: 'text/javascript' })); diff --git a/webapp/js/ik-policy.js b/webapp/js/ik-policy.js index 47a3e00..af6bcd0 100644 --- a/webapp/js/ik-policy.js +++ b/webapp/js/ik-policy.js @@ -178,8 +178,10 @@ class Runner { this.S = S; this.infer = infer; this.o = Object.assign({ K: 8, commit: 3, margin: 5, /* 2026-10-18:运行时投影余量保持 5 mm(真机护栏,不迁就数据分布);数据侧另行过滤认证间隙 < 3 mm 的老师轨迹,APG 训练余量 3 mm */ projMax: 0.5, refineMm: 5, arriveMm: 1, arriveDeg: 0.5, - stuckPlans: 5, lockSec: 1.0, subReachDeg: 5, subTimeout: 1.5, seed: 12345, lookahead: true, - certBudget: 40, safetyMs: 30, firstMs: 45 }, opts || {}); // 每条候选最多 40 次距离查询;安全层总预算 30 ms,第 1 条候选可用到 45 ms(超出按刹停处理) + stuckPlans: 5, lockSec: 1.0, subReachDeg: 5, subTimeout: 1.5, subRule: 'cross', subLeadSec: 0.02, seed: 12345, lookahead: true, + actScale: 1, certBudget: 40, safetyMs: 30, firstMs: 45 }, opts || {}); + /* 2026-10-19:actScale = 模型 meta.act_scale(动作标签按 act_scale·STEP 归一化,旧模型 1);增量 = clamp(act, ±1)·actScale·STEP, + 与 ml/sim/closed_loop.py 同口径。subRule:'cross'(默认,越过顶点即切,与训练标签同口径;见 _subAdvance)/ 'timeout'(旧:< subReachDeg 或 subTimeout 超时)。 */ // 每条候选最多 40 次距离查询;安全层总预算 30 ms,第 1 条候选可用到 45 ms(超出按刹停处理) this.cert = makeCert(S, { leverTable: this.o.leverTable, cap: 20 }); this.rng = makeRng(this.o.seed); /* 2026-10-18 第六轮(审查 B-1/B-2):耗时拆开记——inferMs 主推理、lookaheadMs 前瞻(第二次推理 + 8 份特征)、safetyMs 只含认证、 @@ -190,7 +192,7 @@ class Runner { reset(q0) { this.q = q0 ? q0.slice() : null; this.qPrev = this.q ? this.q.slice() : null; this.queue = []; this.brakeTail = []; this.lastPlan = null; this.goal = null; - this.subs = []; this.subT = 0; this.vals = []; this.brakeRun = 0; this.lock = null; this.t = 0; this.cold = true; this.done = false; + this.subs = []; this.subT = 0; this.subPrev = null; this.vals = []; this.brakeRun = 0; this.lock = null; this.t = 0; this.cold = true; this.done = false; } setGoal(T, R) { const jump = !this.goal || Math.hypot(T[0] - this.goal.T[0], T[1] - this.goal.T[1], T[2] - this.goal.T[2]) > 50 || (!!R !== !!this.goal.R); @@ -220,10 +222,34 @@ class Runner { return this.q; } _cand(act, k, r) { // 第 k 条候选:增量(°),丢掉模型的前 r 步(已提交前缀),取接下来的步 - const inc = []; - for (let s = 0; s < H; s++) { const row = []; for (let j = 0; j < 6; j++) row.push(Math.max(-1, Math.min(1, act[(k * H + s) * 6 + j])) * STEP[j]); inc.push(row); } + const inc = [], sc = this.o.actScale || 1; + for (let s = 0; s < H; s++) { const row = []; for (let j = 0; j < 6; j++) row.push(Math.max(-1, Math.min(1, act[(k * H + s) * 6 + j])) * sc * STEP[j]); inc.push(row); } return inc; } + /* 子目标推进(与 ml/sim/closed_loop.py sub_advance 同一规则,三处同步改)。 + 2026-10-19(审查 C5 / P0-1):训练标签里 sub = 老师「下一个尚未经过的顶点」,老师 τ 一过顶点(−0.02 s)就换; + 旧规则要 < 5° 或 1.5 s 超时,策略在顶点附近进不去时 sub 一直指着身后的点(训练里从未出现的特征序列)。 + 'cross':进入方向 a = 顶点 − 上一点,离开方向 b = 下一顶点 − 顶点(最后一个取 a),各轴 /VMAX 折成秒; + 法向 n = â + b̂,(qe − 顶点)·n̂ ≥ −subLeadSec 即越过。没有超时(标签也没有)。 */ + _subAdvance(qe) { + if (!this.subs.length) return; + const S = this.S, o = this.o, sg = this.subs[0]; + const df = (a, b, j) => (S.LIM[S.ORDER[j]].cont ? wrap180(a - b) : a - b); + let adv = false; + if (o.subRule === 'timeout') { + let m = 0; for (let j = 0; j < 6; j++) m = Math.max(m, Math.abs(df(sg[j], qe[j], j))); + adv = m < o.subReachDeg || this.t - this.subT > o.subTimeout; + } else { + const unit = v => { const n = Math.hypot.apply(null, v); return n > 1e-9 ? v.map(x => x / n) : v.map(() => 0); }; + const prev = this.subPrev || this.q; + const a = [0, 1, 2, 3, 4, 5].map(j => df(sg[j], prev[j], j) / VMAX[j]); + const b = this.subs.length > 1 ? [0, 1, 2, 3, 4, 5].map(j => df(this.subs[1][j], sg[j], j) / VMAX[j]) : a; + const ua = unit(a), ub = unit(b), n = unit(ua.map((x, j) => x + ub[j])); + let proj = 0; for (let j = 0; j < 6; j++) proj += df(qe[j], sg[j], j) / VMAX[j] * n[j]; + adv = proj >= -o.subLeadSec; + } + if (adv) { this.subPrev = this.subs.shift(); this.subT = this.t; } + } _certCommit(qe, steps) { // 认证:提交步 + 刹停段(先用离散判定快速剔除,再做距离下界认证) const pts = [qe.slice()]; let q = qe.slice(); for (const d of steps) { q = q.map((v, j) => v + d[j]); pts.push(q.slice()); } @@ -257,13 +283,11 @@ class Runner { const steps = []; let q = qe.slice(); for (let k = 0; k < o.commit; k++) { const rs = refineStep(S, q, this.goal.T, this.goal.R, 0.3); steps.push(rs.dq); q = q.map((v, j) => v + rs.dq[j]); } const c = this._certCommit(qe, steps); - if (c.ok) { this.queue.push(...steps); this.brakeTail = c.tail; this.stats.refine++; return; } + /* 2026-10-19(审查 F5):DLS 接管时没有「已提交的模型计划」,lastPlan 置空(原来保留上一条,下次一致性去对齐一条没执行的轨迹;仿真同步) */ + if (c.ok) { this.queue.push(...steps); this.brakeTail = c.tail; this.stats.refine++; this.lastPlan = null; return; } } /* 子目标推进 */ - if (this.subs.length) { - const sg = this.subs[0]; let m = 0; for (let j = 0; j < 6; j++) m = Math.max(m, Math.abs((S.LIM[S.ORDER[j]].cont ? wrap180(sg[j] - qe[j]) : sg[j] - qe[j]))); - if (m < o.subReachDeg || this.t - this.subT > o.subTimeout) { this.subs.shift(); this.subT = this.t; } - } + this._subAdvance(qe); const gm = S.gapMatrix(this.q, GAP_CAP); const st = { q: this.q, qPrev: this.qPrev, T: this.goal.T, R: this.goal.R, prefix, sub: this.subs[0] || null }; const f = features(S, st, gm); @@ -279,7 +303,7 @@ class Runner { if (this.needSub) { this.needSub = false; this.subT = this.t; const sb = out.sub.subarray(best * 13, best * 13 + 13); - if (sb[12] > 0.5) this.subs = [0, 1].map(v => this.q.map((x, j) => x + sb[v * 6 + j] * 180)); + if (sb[12] > 0.5) { this.subs = [0, 1].map(v => this.q.map((x, j) => x + sb[v * 6 + j] * 180)); this.subPrev = this.q.slice(); } } /* 候选:去掉前缀后的提交步,投影,打分 */ const cands = [], near = S.criticalPairs(qe, o.margin + 10).slice(0, 64);