From cd6913b136526631a0adf195e986e5d6d4bd4e49 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Tue, 20 Oct 2026 22:05:12 +0800 Subject: [PATCH] =?UTF-8?q?feat+exp:=20=E8=BD=AE=E6=AC=A139=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=80=99=E9=80=89=E6=A1=A3=E4=BD=8D=E5=BC=80=E5=85=B3?= =?UTF-8?q?=20--z=5Fscale=20=E4=B8=8E=E8=A1=8C=E7=A8=8B=E6=83=A9=E7=BD=9A?= =?UTF-8?q?=20select=3Dep=5Flen/--len=5Fw=EF=BC=88=E9=9B=B6=E6=9D=83?= =?UTF-8?q?=E9=87=8D=E4=B8=8E=20endpoint=20=E9=80=90=E4=BD=8D=E7=9B=B8?= =?UTF-8?q?=E5=90=8C=EF=BC=8C2=20=E6=9D=A1=E6=9C=BA=E5=88=B6=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/探索日志.md | 10 +++++++ ml/eval/eval_cl.py | 6 ++-- ml/sim/closed_loop.py | 9 ++++-- tests/test_eval_cl.py | 64 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/docs/探索日志.md b/docs/探索日志.md index 53ada4d..5303af8 100644 --- a/docs/探索日志.md +++ b/docs/探索日志.md @@ -38,6 +38,16 @@ | 5 | 速度/余量口径(#11/#14) | `--inc_cap 1` 代价 −5.1 pp 到达、−12.5 pp 时限;`gap_margin 0.5 mm` 只付 3 题 | 已量化,投影式余量未移植 | | 6 | 循环内共享 FK(#19)、FP16/BF16、流水线复用 | 实测分别只有 0.03 ms/次、负收益、−3.1 ms 且扰动特征 | **已判掉** | +## 轮次 39:两条没人碰过的算法杠杆——候选档位与行程惩罚(机制已单测,A/B 排队) + +- **为什么是这两条**:轮次 38 的失败分类说清楚了两件事——① 到达率的缺口在"K 条候选全被拒 ⇒ 原地冻住"(63/109),② §9.2 要"关节行程 ≤ 规划器×1.10",实测行程比中位 **2.4~2.6**,而现在**没有任何机制在管行程**(选择器只看末态误差,安全层只看间隙)。另外 `--steps` 那组数顺带给了一条线索:多段推理把行程比从 2.62 拉到 1.87,说明行程主要由候选选择/动作形状决定,不是物理必然。 +- **杠杆 1:候选档位与多样性(#15)**。`ml/eval/eval_cl.py` 补 `--z_scale`(rollout 早就有这个参数,只是评估没接)。四臂对照轮次 33 cap:`K=16`、`K=8 --safety_try 3`、`K=8 --safety_try 1`、`K=8 --z_scale 1.5`。要回答的是:**全拒是"真没路"还是"没备选"**——若 K=16 或 z_scale 拉大能把到达/刹车买回来,那是候选池的问题;若 `safety_try 1` 几乎不掉到达,那说明第 1 条候选就够,后面试 7 条只是在花几何查询的钱(这对 20 ms 预算直接相关)。 +- **杠杆 2:行程惩罚(`select='ep_len'` + `--len_w`)**。分数从 `-末态误差` 改成 `-末态误差 − len_w·Σ_t Σ_j |Δ_j|/STEP_j`(每轴按自己的满速步长归一,所以 6 轴可比)。 + - 机制单测 2 条(`tests/test_eval_cl.py::TravelPenaltySelectTest`,40 项全过):造两条**末态相同、累计位移相同**的候选,一条来回抖(归一行程 10.8)一条匀速(3.6),`goal_err` 打成常数 ⇒ 只有行程能把它们分开。实测 `len_w=0` 选到抖动那条(第 1 步 1.08°)、`len_w=1.0` 选到匀速那条(第 1 步 0.27°);另有一条**同分零权重必须与 `select='endpoint'` 逐位相同**的回归断言(rtol=atol=0),保证默认行为没被这次改动碰到。 + - 待测:`len_w ∈ {0.1, 0.3, 1.0}` 三臂,看行程比能压到多少、到达与时限各付多少。这条杠杆与"到达率"是相互冲突的目标(惩罚行程=主动走慢),所以必须成对汇报,不能只报行程变好。 + - 单位口径要说清:`len_w` 的量纲是"mm 误差 / 一个归一 STEP 的累计行程",16 步满速在 3 条轴上大约是 48,所以 0.1~1.0 对应的惩罚是 5~50 mm 当量——这是扫这三个值的原因,不是随手取的。 +- 测试 `tests/test_eval_cl.py` 38→40 项、全套 43→45 项(`python -m unittest discover -s tests`,2.2 s 全绿)。GPU 队列顺序:backoff A/B → `--safety 0` 判别 → 候选档位 → 行程惩罚。 + ## 轮次 38:把 `--steps` 打通之后,闭环反而变差——瓶颈从"动作质量"移到了"安全层拒绝率" diff --git a/ml/eval/eval_cl.py b/ml/eval/eval_cl.py index 5863496..a5823d7 100644 --- a/ml/eval/eval_cl.py +++ b/ml/eval/eval_cl.py @@ -94,6 +94,8 @@ def main(): ap.add_argument('--gap_margin', type=float, default=0.0, help='安全层要求的间隙余量 mm(>0 时宽相位 cap 自动抬到 margin+0.5)') ap.add_argument('--cert_batch', type=int, default=0, help='1=一次 gaps 调用覆盖全部候选(压最坏情况,典型路径略贵)') ap.add_argument('--steps', type=int, default=1, help='MeanFlow 采样段数;1=线上一口径(历史发布数字全是 1)') + ap.add_argument('--z_scale', type=float, default=1.0, help='候选噪声缩放:>1 让 K 条候选更分散(测"全拒是没有备选"还是"真的没路")') + ap.add_argument('--len_w', type=float, default=0.0, help="select=ep_len 时行程惩罚权重(mm / 每轴归一 STEP 的累计路程)") ap.add_argument('--backoff', type=int, default=0, help='全拒时把首选候选按 1/2、1/4…缩步重试的档数(需 --cert_batch 1)') ap.add_argument('--max_sec', type=float, default=None) ap.add_argument('--seed', type=int, default=0) @@ -117,11 +119,11 @@ def main(): 'kin_fp': kin.fp, 'act_scale': float(model.act_scale), } t0 = time.perf_counter() - r, pers = run(model, kin, tasks, a.device, K=a.K, safety=bool(a.safety), seed=a.seed, max_sec=a.max_sec, steps=a.steps, + r, pers = run(model, kin, tasks, a.device, K=a.K, safety=bool(a.safety), seed=a.seed, max_sec=a.max_sec, steps=a.steps, z_scale=a.z_scale, safety_partial=(a.commit if (a.safety and a.partial) else 0), commit_n=a.commit, select=a.select, replan=a.replan, fb_gain=a.fb, fb_scale=a.fb_scale, fb_near=a.fb_near, fb_fallback=a.fb_fallback, fb_ki=a.ki, fb_decay=a.kdecay, fb_imax=a.kimax, fb_every=a.fb_every, safety_try=a.safety_try, sel_w=a.sel_w, dls_safety=a.dls_safety, fb_clear=a.fb_clear, fb_cmin=a.fb_cmin, sub_refresh=a.sub_refresh, fb_block_gain=a.fb_block_gain, - inc_cap=a.inc_cap, gap_margin=a.gap_margin, cert_batch=a.cert_batch, backoff=a.backoff, return_traj=bool(a.trace)) + inc_cap=a.inc_cap, gap_margin=a.gap_margin, cert_batch=a.cert_batch, backoff=a.backoff, len_w=a.len_w, return_traj=bool(a.trace)) wall = time.perf_counter() - t0 result = {**meta, 'metrics': r, 'simulation_wall_s': wall} print(json.dumps(json_finite(result), ensure_ascii=False, allow_nan=False), flush=True) diff --git a/ml/sim/closed_loop.py b/ml/sim/closed_loop.py index a691ea7..4728fe8 100644 --- a/ml/sim/closed_loop.py +++ b/ml/sim/closed_loop.py @@ -163,7 +163,7 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, qp0=None, steps=1, z_scale=1.0, select='consist', oracle=None, replan='r3', sub_rule='cross', fb_gain=0.0, fb_scale=0.0, fb_near=0.2, fb_fallback=0.0, safety_partial=0, commit_n=3, collect=None, fb_exec=0.0, exec_safety=0, fb_ki=0.0, fb_decay=0.9, fb_imax=0.5, fb_every=1, safety_try=0, sel_w=0.1, dls_safety=0, fb_clear=0.0, fb_cmin=0.3, sub_refresh=0, fb_block_gain=1.0, - inc_cap=0, gap_margin=0.0, cert_batch=0, backoff=0, progress=None): + inc_cap=0, gap_margin=0.0, cert_batch=0, backoff=0, len_w=0.0, progress=None): """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 条)。 @@ -174,6 +174,8 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, gap_margin>0:安全层要求间隙大于该毫米余量而不是 >0;宽相位 cap 自动抬到 margin+0.5,否则 0.5 的截顶值会让余量门限恒假。 backoff>0:全拒(连第 1 步都不安全)时不再站住,把首选候选按 1/2、1/4…(共 backoff 档)缩步一起送进同一次认证, 取能通过的第 1 档执行;缩步候选不参与"前缀更长者优先"的比较,只在原候选全被拒时顶上。需 cert_batch=1。 + select='ep_len' + len_w>0:在末态误差打分上再减 len_w·Σ|Δ|/STEP(每轴归一的候选累计行程)。§9.2 要行程≤规划器×1.10, + 而实测行程比 2.4~2.6 没有任何机制在管;len_w=0 与 select='endpoint' 逐位相同(有回归断言)。 两者默认值下行为与第32轮逐位相同。""" if backoff and not cert_batch: raise ValueError('backoff 目前只实现在 cert_batch=1 口径') g = torch.Generator(device=device); g.manual_seed(seed) @@ -282,10 +284,13 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, 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 select in ('endpoint', 'adapt'): + elif select in ('endpoint', 'adapt', 'ep_len'): qa = fut[:, :, -1].reshape(B * K, 6) e_end, _ = goal_err(kin, qa, T.repeat_interleave(K, 0), R.repeat_interleave(K, 0)) score = -e_end.view(B, K) + if select == 'ep_len' and len_w > 0: # 行程惩罚:每轴按 STEP 归一的累计路程。§9.2 要"行程≤规划器×1.10", + ln = ((fut[:, :, 1:] - fut[:, :, :-1]).abs() / step).sum(-1).sum(-1) # 而现在 2.4 倍没人管 + score = score - len_w * ln if select == 'adapt' and last is not None: n = min(fut.shape[2], last.shape[1] - (commit_n + 1)) w = 0.85 ** torch.arange(n, device=device, dtype=torch.float32) diff --git a/tests/test_eval_cl.py b/tests/test_eval_cl.py index 8bdb0b2..629e2da 100644 --- a/tests/test_eval_cl.py +++ b/tests/test_eval_cl.py @@ -781,5 +781,69 @@ class BackoffTest(unittest.TestCase): self.run1(acts, 1, cert_batch=0) +class TravelPenaltySelectTest(unittest.TestCase): + """#17b:候选打分加"归一化行程"惩罚(§9.2 要行程≤规划器×1.10,现在 2.4 倍没人管)。 + 两条候选末态相同(goal_err 打成常数)、累计位移相同,但一条来回抖、一条匀速 ⇒ 只可能靠行程分开。""" + class StubModel: + act_scale = 1.0 + + def __init__(self, act): + self.act = act + + def sample_k(self, feat, K, z, steps=1): + B = feat.shape[0] + return (self.act.expand(B, K, 16, 6).clone(), torch.zeros(B, device=feat.device), + torch.zeros(B, 13, device=feat.device)) + + def run1(self, len_w): + 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)} + + zig = ([[0.9, 0, 0, 0, 0, 0], [-0.45, 0, 0, 0, 0, 0]] * 8) # 16 步,合计 3.6、行程 10.8 + smooth = ([[0.225, 0, 0, 0, 0, 0]] * 16) # 合计 3.6、行程 3.6 + acts = torch.tensor([zig, smooth], dtype=torch.float32) # [K=2,16,6] + tasks = [{'q0': [0] * 6, 'T': [10, 0, 0]}] + 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(), tasks, 'cpu', K=2, safety=True, max_sec=0.06, + return_traj=True, use_sub=False, select='ep_len', replan='empty', commit_n=2, + safety_partial=1, len_w=len_w) + return per + + def test_penalty_prefers_the_shorter_path(self): + p0 = self.run1(0.0) # 不加惩罚:同分 ⇒ 稳定排序取第 0 条(来回抖那条) + self.assertAlmostEqual(p0['traj'][0, 1, 0].item(), 0.9 * 1.2, places=5) + p1 = self.run1(1.0) # 加惩罚:行程 10.8 vs 3.6 ⇒ 选匀速那条 + self.assertAlmostEqual(p1['traj'][0, 1, 0].item(), 0.225 * 1.2, places=5) + + def test_zero_weight_is_the_old_endpoint_path(self): + """len_w=0 且 select='ep_len' 必须与 select='endpoint' 逐位相同(不许悄悄改默认行为)""" + 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)} + + zig = ([[0.9, 0, 0, 0, 0, 0], [-0.45, 0, 0, 0, 0, 0]] * 8) + acts = torch.tensor([zig, zig], dtype=torch.float32) + tasks = [{'q0': [0] * 6, 'T': [10, 0, 0]}] + out = [] + for sel in ('endpoint', 'ep_len'): + 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(), tasks, 'cpu', K=2, safety=True, max_sec=0.06, + return_traj=True, use_sub=False, select=sel, replan='empty', commit_n=2, + safety_partial=1, len_w=0.0) + out.append(per) + torch.testing.assert_close(out[0]['traj'], out[1]['traj'], rtol=0, atol=0) + + if __name__ == '__main__': unittest.main()