#!/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,近似部署)的到达率 / 刹停次数。 E. 速度:开环动作幅度 / 老师幅度、抹掉 dq 后的幅度(是否「抄上一帧速度」); 从老师轨迹中段起步的闭环,带老师速度 vs 静止起步(是否只坏在起步)。 用法(仓库根目录): 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 import torch 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 # 83 维特征分组(与 features.py 拼接顺序一致) GROUPS = [('sincos', 0, 12), ('joint_norm', 12, 15), ('dq', 15, 21), ('p', 21, 24), ('Rq', 24, 30), ('T', 30, 33), ('R', 33, 39), ('T-p', 39, 42), ('T-p_clamp', 42, 45), ('mode', 45, 47), ('prefix', 47, 65), ('mask', 65, 68), ('sub', 68, 74), ('sub_flag', 74, 75), ('gaps', 75, 83)] PROG_T = (0.5, 1.0, 2.0) # 目标误差 / 起点误差 的取样时刻(s) def pct(v, p): v = sorted(v) return v[min(len(v) - 1, int(len(v) * p))] if v else None def main(): ap = argparse.ArgumentParser() ap.add_argument('--ckpt', required=True); ap.add_argument('--samples', default='ml/data/samples') 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() kin = Kin(device=dev) out = {'ckpt': a.ckpt, 'step': ck.get('step')} tasks = [t for t in load_tasks([a.samples]) if t['split'] == 'val'][:a.n] by_tid = {t['tid']: t for t in tasks} # ---------- A / B:起点特征口径 ---------- vd = Samples(a.samples, device=dev, split='val') tid = vd.TID.long() first = torch.ones_like(tid, dtype=torch.bool); first[1:] = tid[1:] != tid[:-1] # 每题第一行 = t=0 样本 idx = [i for i in first.nonzero().flatten().tolist() if int(tid[i]) in by_tid] X0 = vd.X[idx].float() tk = [by_tid[int(tid[i])] for i in idx] q0 = torch.tensor([t['q0'] for t in tk], dtype=torch.float32, device=dev) qx = torch.atan2(X0[:, 0:12:2], X0[:, 1:12:2]) * 180 / torch.pi dqx = torch.remainder(qx - q0 + 180, 360) - 180 clean = dqx.abs().max(-1).values < 1e-2 # 未被噪声增广的起点 out['A_rows'] = int(clean.sum()); out['A_rows_aug_skipped'] = int((~clean).sum()) if clean.any(): Xc = X0[clean]; qc = q0[clean]; tc = [t for t, c in zip(tk, clean.tolist()) if c] 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] 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) 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 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:闭环失败形态 ---------- 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) 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] 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)) e = e.view(B, Tn) 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 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() 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), 'within_0.1s': sum(t <= 0.1 for t in first_t) / max(1, len(first_t))}, 'col_steps': {'p50': pct(col_steps, .5), 'p90': pct(col_steps, .9)}, 'first_hit_pair(group a-b)': dict(sorted(pairs.items(), key=lambda kv: -kv[1])[:8]), '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 = 在靠近目标 # ---------- E:速度与「抄速度」(2026-10-19 加) ---------- # 上一轮 D 段:不用子目标 / 用老师真实途经点,结果与基线完全一样(0% / 99.6%),子目标排除。 # C 段 2 s 只把目标误差缩小 18.5%:方向对、但走得极慢。开环(数据集里的老师状态)却几乎精确。 # 嫌疑:特征里的 dq(上一帧速度)在老师数据里总是满速,模型学会「照上一帧速度往下走」; # 闭环从静止起步 dq=0,模型就给小动作,小动作又喂回 dq,一直慢,慢慢挤进自碰撞。 stepv = torch.tensor(STEP, device=dev) gen = torch.Generator(device=dev).manual_seed(11) # E0:开环,val 全部时刻随机 4096 行。动作幅度 / 老师幅度;把 dq 抹成 0 后幅度与 16 步末端差 ri = torch.randperm(vd.X.shape[0], generator=torch.Generator().manual_seed(3))[:4096].to(dev) Xr, Yr = vd.X[ri].float(), vd.Y[ri].float() zr = torch.randn(len(ri), 1, H, 6, device=dev, generator=gen) def amp(X): with torch.no_grad(): ac, _, _ = model.sample_k(X, 1, zr) return ac[:, 0].clamp(-1, 1) a_full = amp(Xr); X0dq = Xr.clone(); X0dq[:, 15:21] = 0; a_0dq = amp(X0dq) ymag = Yr.abs().mean().item() moving = Xr[:, 15:21].abs().max(-1).values > 0.3 # 老师此刻在动(|dq| > 30% 满速) out['E0_openloop'] = {'n': len(ri), 'teacher_amp': ymag, 'policy_amp_ratio': a_full.abs().mean().item() / ymag, 'dq_zeroed_amp_ratio': a_0dq.abs().mean().item() / ymag, 'dq_zeroed_amp_ratio_on_moving_rows': a_0dq[moving].abs().mean().item() / max(1e-6, Yr[moving].abs().mean().item()), 'dq_zeroed_end_diff_deg_p50': pct(((a_full - a_0dq) * stepv).sum(1).abs().max(-1).values.tolist(), .5), 'start_rows_amp_ratio': None} st = first.nonzero().flatten()[:4096] with torch.no_grad(): a_st, _, _ = model.sample_k(vd.X[st].float(), 1, torch.randn(len(st), 1, H, 6, device=dev, generator=gen)) out['E0_openloop']['start_rows_amp_ratio'] = a_st.clamp(-1, 1).abs().mean().item() / max(1e-6, vd.Y[st].float().abs().mean().item()) # E1:从老师轨迹中段(每题第 30% 行)起步闭环,带老师上一帧速度 vs 静止起步,同一批题同一噪声 tid_all = vd.TID.long(); starts = first.nonzero().flatten().tolist() + [len(tid_all)] mid_rows = [] for s0, s1 in zip(starts[:-1], starts[1:]): if int(tid_all[s0]) in by_tid and s1 - s0 >= 4: mid_rows.append(s0 + int(0.3 * (s1 - s0))) mid_rows = mid_rows[:a.n] Xm = vd.X[mid_rows].float() qm = torch.atan2(Xm[:, 0:12:2], Xm[:, 1:12:2]) * 180 / torch.pi qm = torch.where(kin.cont, qm, kin.clamp_box(qm)) qpm = qm - Xm[:, 15:21] * stepv mtasks = [] for r_, q_ in zip(mid_rows, qm.tolist()): t = dict(by_tid[int(tid_all[r_])]); t['q0'] = q_; t['exec_s'] = float(vd.V[r_]) * 5; mtasks.append(t) rem = [t['exec_s'] for t in mtasks] ym = vd.Y[mid_rows].float().abs().mean().item() for tag, use_v in (('E1_mid_teacher_vel', True), ('E1_mid_zero_vel', False)): rc = cc_ = 0; ratio1 = []; spd = [] for i in range(0, len(mtasks), a.bs): ch = mtasks[i:i + a.bs] with torch.no_grad(): _, pp = rollout(model, kin, ch, dev, K=8, safety=False, seed=i, return_traj=True, qp0=qpm[i:i + a.bs] if use_v else None) tr = pp['traj'].to(dev); B, Tn, _ = tr.shape 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); e1, _ = goal_err(kin, tr[:, k1], Tt, Rt) ratio1 += (e1 / e0.clamp(min=1e-3)).tolist() dd = (tr[:, 1:k1 + 1] - tr[:, :k1]) / stepv spd.append(dd.abs().mean().item()) rc += int(pp['done'].sum()); cc_ += int((pp['col'] > 0).sum()) nm = len(mtasks) out[tag] = {'n': nm, 'teacher_remaining_s_p50': pct(rem, .5), 'reach': rc / nm, 'collided': cc_ / nm, 'goal_err_ratio_1s_p50': pct(ratio1, .5), 'speed_ratio_first_1s': sum(spd) / len(spd) / max(1e-6, ym)} 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) if __name__ == '__main__': main()