diff --git a/docs/experiment_round39_try3.json b/docs/experiment_round39_try3.json new file mode 100644 index 0000000..10b7435 --- /dev/null +++ b/docs/experiment_round39_try3.json @@ -0,0 +1,77 @@ +{ + "config": { + "ckpt": "ml/runs/base_dag2_dag1/best.pt", + "samples": "ml/data/samples", + "tasks": null, + "split": "val", + "n": 256, + "K": 8, + "commit": 2, + "fb": 2.2, + "ki": 0.0, + "fb_scale": 0.0, + "fb_near": 0.2, + "fb_fallback": 0.0, + "kdecay": 0.9, + "kimax": 0.5, + "fb_every": 1, + "safety_try": 3, + "sel_w": 0.1, + "dls_safety": 1, + "fb_clear": 0.0, + "fb_cmin": 0.3, + "sub_refresh": 0, + "fb_block_gain": 0.5, + "dump": null, + "out": "docs/experiment_round39_try3.json", + "trace": null, + "select": "endpoint", + "replan": "empty", + "safety": 1, + "partial": 1, + "inc_cap": 1, + "gap_margin": 0.0, + "cert_batch": 0, + "steps": 1, + "z_scale": 1.0, + "len_w": 0.0, + "backoff": 0, + "max_sec": null, + "seed": 0, + "dls": "analytic", + "device": "cuda" + }, + "batch_size": 256, + "batch_seed_rule": "same seed per batch", + "checkpoint_sha256": "f7f686fd8ee92fb6948fbcc4ee954636065ad87bb54f661c4f8f575bc16c2ce1", + "tasks_sha256": "249bd93457825cda98c59e64d9a096d3473c69aeb3553de1dea6d9e56be71772", + "kin_fp": "2ebcbf17", + "act_scale": 1.0, + "metrics": { + "n": 256, + "n_ref": 256, + "success": 0.45703125, + "collision_free_success": 0.45703125, + "time_ok_1p3": 0.23828125, + "collision_rate": 0.0, + "collision_steps_mean": 0.0, + "travel_ratio_median": 2.529658794403076, + "time_mean_s": 2.3210256099700928, + "brakes_mean": 37.75390625, + "start_fail": 0.0, + "goal_ratio_2s": 0.3564552068710327, + "err_fail_med": 220.24862670898438, + "err_success_med": 0.23727791011333466, + "dls_share": 0.56640625, + "time_budget": "teacher×2(≥3 s)", + "speed_ratio_p50": 1.0000016689300537, + "speed_ratio_p95": 1.0000101327896118, + "speed_ratio_max": 1.0000101327896118, + "over_vmax_rate": 0.0, + "accel_ratio_p95": 1.0000016689300537, + "accel_ratio_max": 1.0000101327896118, + "reverse_p50": 0.22442243993282318, + "reverse_mean": 0.23038837313652039 + }, + "simulation_wall_s": 170.3933407000004 +} diff --git a/docs/探索日志.md b/docs/探索日志.md index 156bf96..b6bf64f 100644 --- a/docs/探索日志.md +++ b/docs/探索日志.md @@ -107,6 +107,22 @@ - **对 `backoff` 的预期与判据**:全拒意味着"连第 1 步的端点或中点已经穿进障碍",缩步能不能救取决于**穿得有多深**(穿 0.1 mm 救得回,穿 3 mm 救不回)。这个数转储里没记(只记被选中那条),所以直接由 `backoff_mean`/`reject_all_mean` 来量: `reject_all_mean ≈ 原全拒次数`(=backoff_mean+brakes_mean,与轮次 33 cap 的 19.55 可比),`backoff_mean/(backoff_mean+brakes_mean)` 就是"缩步能救回的比例"。这是补跑(#22)要读的第一个数。 +## try3 反向档位 + 换向指标的稀释修正(轮次 39 续) + +- `--safety_try 3`(只允许安全层试按分数排序的前 3 条候选,其余不变、仍 `--inc_cap 1`):到达 **0.4570**(对照 0.5234,−6.6 pp)、时限内 0.2383(−2.7 pp)、**刹车 37.75**(对照 19.55,+93%)、行程比 2.530、碰撞仍 0(`docs/experiment_round39_try3.json`)。 +- 把"允许试几条候选"这条轴摆开,是**单调**的: + + | 试候选数 | 到达 | 时限内 | 刹车/题 | + |---|---|---|---| + | 3 | 0.4570 | 0.2383 | 37.75 | + | 8(对照) | 0.5234 | 0.2656 | 19.55 | + | 16 | 0.5625 | 0.2969 | 13.77 | + + ⇒ "全拒是没备选而不是真没路"这条假设有**双向**证据(减档更差、加档更好),不只是单点相关。边际收益在递减:3→8 值 +6.6 pp,8→16 只值 +3.9 pp。 +- **但不能当部署结论**:认证点数与候选数成正比(K=8 每次规划 32 个点 → K=16 是 64 个),而 GPU 周期 P50 已经是 33.5 ms、超 20 ms 预算。所以接下来必须先量 `K=8 vs 16` 的 B=1 独占微基准(`ml/runs/_kcost_probe.py`,已验证调用签名);如果 K=16 把周期推到 45 ms 以上,这条路就只能靠"一次批量调用覆盖全部候选"把常数项压回去(轮次 34 已证批量把最坏 80.2→37.1 ms)。 +- **换向指标被刹停稀释,已修**:try3 报 `reverse_p50=0.2244`,比对照的离线同窗口值 0.247 **还低**,而它恰恰是站住最多的臂——因为分母 `nact` 把站住的步也算进去了。补了两个口径:`reverse_rate_moving`(只算动过的步)与 `moving_share`(动步占比,=1−站住比例)。 + 配套写了**可手算的负对照**:`+0.9 → 站一拍 → −0.9` 这一条轨迹给 1/18(全步口径)vs 1/12(动步口径)、moving_share=2/3 ⇒ 稀释效应是真的,不是猜的。写夹具时又踩一次自己的坑:`commit_n=1` 时每拍都重新规划、永远只执行候选的第 0 步,序列里的 0 与 −0.9 根本没被执行到(末步报 3.24° 而不是 0°)——夹具必须按"一次规划提交 3 步"来构造。测试 42→43 项全绿。 + ## K16:候选数翻倍是本轮第一个正向结果(轮次 39 续) - 同一 256 题、同 seed、`--inc_cap 1`、其余配方不变,只把 `K` 从 8 加到 16(`docs/experiment_round39_K16.json`): diff --git a/ml/eval/eval_cl.py b/ml/eval/eval_cl.py index d3587e6..a7d9c2f 100644 --- a/ml/eval/eval_cl.py +++ b/ml/eval/eval_cl.py @@ -51,7 +51,9 @@ def run(model, kin, tasks, device, bs=256, **kw): 'accel_ratio_p95': acc.quantile(0.95).item(), 'accel_ratio_max': acc.max().item()}) if 'reverse_rate' in flat: # 换向步占比:行程超出主要是同一轴来回抖(轮次 39 分解),所以每个臂都要直接报它 rr = flat['reverse_rate'] - out.update({'reverse_p50': median(rr), 'reverse_mean': rr.mean().item()}) + out.update({'reverse_p50': median(rr), 'reverse_mean': rr.mean().item(), + 'reverse_moving_p50': median(flat['reverse_rate_moving']), # 只在动了的步上算,不被刹停稀释 + 'moving_share_p50': median(flat['moving_share'])}) # 活动窗内动了的步占比(=1−站住比例) if 'backoff' in flat and flat['backoff'].any(): # backoff=缩步顶上的次数;brakes 现在只记连最小档都不安全的 out['backoff_mean'] = flat['backoff'].mean().item() # ⇒ 原来的"全拒"事件数 = 两者之和 out['reject_all_mean'] = (flat['backoff'] + flat['brakes']).mean().item() diff --git a/ml/sim/closed_loop.py b/ml/sim/closed_loop.py index 3d75dc1..c1b751a 100644 --- a/ml/sim/closed_loop.py +++ b/ml/sim/closed_loop.py @@ -203,6 +203,7 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, # 2026-10-20(轮次33):速度/加速度按「实际执行位移」算,不看指令——第30轮证明反馈是在限幅之后叠加的 vratio = torch.zeros(B, device=device); aratio = torch.zeros(B, device=device); prev_exec = torch.zeros(B, 6, device=device) rev = torch.zeros(B, device=device); nact = torch.zeros(B, device=device); prev_sgn = torch.zeros(B, 6, device=device) + nmov = torch.zeros(B, device=device) traj = [q.clone()] if return_traj else None plans = {k: [] for k in ('it', 'r', 'qe', 'commit', 'points', 'midpoints', 'gaps', 'certified')} if return_traj else None n_start = int(round(0.5 / DT)); moved_start = None # R2 起步失败:0.5 s 内关节总行程 < 1° @@ -479,10 +480,11 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, vratio = torch.maximum(vratio, (exec_d / step).max(-1).values * act_) # 实际速度 / VMAX aratio = torch.maximum(aratio, ((exec_d - prev_exec) / step).max(-1).values * act_) # 每控制步的速度变化 / VMAX prev_exec = exec_d - sgn = torch.sign(q - qp) # 换向率:轮次 39 的分解证明"行程超出 2.4 倍"主要是同一轴来回抖,不是绕远 + sgn = torch.sign(q - qp) # 换向率:轮次 39 的分解证明"行程超出 2.4 倍"里同轴来回占大头 rev += ((sgn != 0) & (prev_sgn != 0) & (sgn == -prev_sgn)).float().sum(-1) * act_ prev_sgn = torch.where(sgn != 0, sgn, prev_sgn) # 零步不重置方向(站住再走同向不算换向) nact += act_ + nmov += act_ * (exec_d.sum(-1) > 1e-6).float() # 只在"动了"的步上算换向:站住会稀释分母(刹停越多的臂会显得越不抖) e, oe = goal_err(kin, q, T, R) if it + 1 == k2s: e2 = e.clone() newly = (~done) & (e < 1) & (oe < 0.5) @@ -513,7 +515,9 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, 'side': side.cpu(), 'start_fail': start_fail.cpu(), 'brakes': brakes.cpu(), 'backoff': bo.cpu(), 'first_hit': first_hit.cpu(), 'dls_it': dls_it.cpu(), 'goal_ratio_2s': ratio2.cpu(), 'speed_ratio': vratio.cpu(), 'accel_ratio': aratio.cpu(), # 口径与离线分解一致:换向的 (步,轴) 对数 / 活动的 (步,轴) 对数 ∈ [0,1](除以 6 个轴) - 'reverse_rate': (rev / (6.0 * nact.clamp_min(1.0))).cpu()} + 'reverse_rate': (rev / (6.0 * nact.clamp_min(1.0))).cpu(), + 'reverse_rate_moving': (rev / (6.0 * nmov.clamp_min(1.0))).cpu(), + 'moving_share': (nmov / nact.clamp_min(1.0)).cpu()} if return_traj: per['traj'] = torch.stack(traj, 1).cpu() per['plans'] = {k: torch.tensor(v) if k in ('it', 'r') else torch.stack(v, 1).cpu() for k, v in plans.items()} diff --git a/tests/test_eval_cl.py b/tests/test_eval_cl.py index 09fcd86..a1d5006 100644 --- a/tests/test_eval_cl.py +++ b/tests/test_eval_cl.py @@ -824,6 +824,9 @@ class TravelPenaltySelectTest(unittest.TestCase): # 口径 = 换向的(步,轴)对 / 活动的(步,轴)对;匀速条一条都不翻。 self.assertAlmostEqual(float(p0['reverse_rate'][0]), 2.0 / 18.0, places=5) self.assertLess(float(p1['reverse_rate'][0]), 1e-6) + # 这条夹具全程都在动(没有站住的步),所以"只算动过的步"的口径必须与旧口径相同、动步占比 = 1 + self.assertAlmostEqual(float(p0['reverse_rate_moving'][0]), float(p0['reverse_rate'][0]), places=6) + self.assertAlmostEqual(float(p0['moving_share'][0]), 1.0, places=6) def test_waste_penalty_also_separates_them(self): """换成"白费比例"(1 − 同轴净位移/累计行程)也必须能分开,且罚度按当前误差缩放: @@ -862,5 +865,29 @@ class TravelPenaltySelectTest(unittest.TestCase): torch.testing.assert_close(out[0]['traj'], out[1]['traj'], rtol=0, atol=0) + def test_standing_steps_dilute_the_all_step_version(self): + """负对照(可手算):+0.9 → 站一拍 → −0.9。方向翻转发生在"停住"两侧, + 全步口径给 1/(3×6)=1/18,只算动过的步给 1/(2×6)=1/12 ⇒ 证明"刹停越多显得越不抖"这个稀释效应是真的。""" + class SafeKin: + def clamp_box(self, q): + return q + + def gaps(self, q, cap): + return {'gm': torch.full((len(q), 49), 1.0, device=q.device)} + + seq = [[0.9, 0, 0, 0, 0, 0], [0.0, 0, 0, 0, 0, 0], [-0.9, 0, 0, 0, 0, 0]] + [[0, 0, 0, 0, 0, 0]] * 13 + acts = torch.tensor([seq], dtype=torch.float32) # 只有 1 条候选 + with patch('ml.sim.closed_loop.features', return_value=torch.zeros(1, 83)), \ + patch('ml.sim.closed_loop.goal_err', + side_effect=lambda kin, q, T, R: (torch.full((q.shape[0],), 10.0), torch.zeros(q.shape[0]))): + _, per = rollout(self.StubModel(acts), SafeKin(), [{'q0': [0] * 6, 'T': [10, 0, 0]}], 'cpu', + K=1, safety=True, max_sec=0.06, return_traj=True, use_sub=False, + select='endpoint', replan='empty', commit_n=3, safety_partial=3) + self.assertAlmostEqual(float(per['traj'][0, 3, 0]), 0.0, places=5) # 1.08 → 停 → 回 0 + self.assertAlmostEqual(float(per['reverse_rate'][0]), 1.0 / 18.0, places=5) + self.assertAlmostEqual(float(per['reverse_rate_moving'][0]), 1.0 / 12.0, places=5) + self.assertAlmostEqual(float(per['moving_share'][0]), 2.0 / 3.0, places=5) + + if __name__ == '__main__': unittest.main()