Anatomy of a Failure: Loss Converged, Behavior Collapsed, Part 2
把一个在分布 (官方 target_point 分布)上训练好的模型,用来自分布 (自写算法)的数据做 LoRA 微调,结果随扰动强度分三个区间:
本案落在最坏区间的配置是:三分之一数据(约 250 条路线、7,204 个样本)、单 epoch、LoRA 秩不变。这个”剂量”是工程上为了快速验证链路选的,恰好够制造一次完整的分布迁移。
直觉上 LoRA 只动 2.72% 参数(17,596,416 个),应该”掰不动”一个 10 亿参数的模型。但低秩不等于小步长: 的有效幅度由缩放系数 和学习率决定,低秩只限制方向数( 维子空间),不限制沿这些方向走多远。而适配新任务恰恰只需要少数几个方向——这正是 LoRA 高效的原因,也是它能高效地把模型掰歪的原因。
微调剂量需要与数据可信度匹配:数据字段未验证同源之前,用”快速档”探路探通的只是工程链路,探不出分布错配——loss 曲线在这个档位上反而最具欺骗性,因为小数据 + 单 epoch 拟合错误分布的速度同样很快。
修复工具的根因写在头注释里:采集时用自写 7.5/15 m 截距算法生成 target_point,与评测链官方 planner 的分布不同。修复思路是不再自写任何几何,直接 import 官方类。以下是真实代码(节选)的逐段解读。
from team_code.nav_planner import RoutePlanner # 官方类,零语义漂移
核心决策:不重新实现官方算法,而是把官方代码当库调用。任何”照着论文/注释重写”都会再次引入语义漂移风险。
planner = RoutePlanner(7.5, 50.0) # agent 的 route_planner_min/max_distance
# 复刻 set_route(carla_map=None 分支)
planner.route = deque((np.array([p[0], p[1], p[2]]), 4) for p in route3)
planner.route_distances.clear()
planner.route_distances.append(0.0)
for i in range(1, len(planner.route)):
d = planner.route[i][0] - planner.route[i - 1][0]
planner.route_distances.append(float((d[0]**2 + d[1]**2) ** 0.5))
重建 planner 的内部状态:点队列 route(每个元素是坐标 + RoadOption 标记,4 即 LANEFOLLOW)和相邻点距离缓存 route_distances。参数 7.5/50.0 必须与评测 agent 的构造参数逐位一致。
M = np.array(d['ego_matrix'])
ego_xy = M[:2, 3]
planner.run_step(np.array([ego_xy[0], ego_xy[1], 0.0])) # 状态跨帧顺序演进
r = planner.route
tp_w = np.array([r[1][0][0], r[1][0][1]]) # target = 剩余队列第 2 点
np_w = np.array([r[2][0][0], r[2][0][1]]) if len(r) > 2 else tp_w
按测量文件的时间序逐帧调 run_step——顺序就是语义:planner 内部弹出距 ego ≤7.5 m 的点,队列游标只进不退。打乱帧序会破坏这个不变量。
R = M[:2, :2]
t1 = (R.T @ (tp_w - ego_xy)).tolist() # 世界系 → ego 系
坐标变换:,先减自车位置(平移),再左乘旋转矩阵的转置(即逆旋转),把世界系目标点转到自车系——与评测链喂给模型的约定一致。
if abs(d.get('target_point', [9e9])[0] - t1[0]) > 0.3 or \
abs(d.get('target_point', [9e9])[1] - t1[1]) > 0.3:
n_changed += 1
if not check_only: # 幂等:check 模式只统计不改写
d['target_point'] = [round(v, 4) for v in t1]
...
幂等两段式:--check 先统计错配率(实测 86–96% 帧差异 >0.3 m),确认后才去掉 check 全量改写 55 万帧。缺省值 [9e9] 是防御性的:字段缺失时必然被计入错配,不会静默跳过。
怀疑”评测链本身坏了”时,排查设计是教科书式的单变量对照(controlled experiment):
| 要素 | 崩溃臂 | 对照臂 |
|---|---|---|
| 评测链 | 同一条 | 同一条 |
| 路线集 | 同一批 | 同一批 |
| CARLA server | 同一个 | 同一个 |
| 权重 | 微调权重 | 官方 zero-shot 权重 |
唯一变量是权重。结果:对照臂路线 10000 Completed——同一条链、同一条路线,换官方权重就能跑完。评测链当场无罪释放,嫌疑面从”整条流水线”收缩到”那 2.72% 的 LoRA 参数”。
对照实验本身也需要验证:怎么知道官方权重是真在推理,而不是复用了缓存结果?证据是墙钟/仿真时间比 = 11×:
11× 落在”真推理”区间,对照实验结论可信。每个对照臂都要附带这样的真伪判据,否则对照本身可能是假成功——这个项目此前就被 autopilot 的假成功骗过一轮。
它只花一次评测的时间,却一次性隔离了最大的嫌疑面(server、评测链、路线、环境全部洗清)。本案实际在第三天做;复盘结论是”第一天做能省半天”。排查清单的第一条永远是:有没有一个已知良好的部件可以做对照?有,就最先做。
用 DeepSpeed ZeRO(Zero Redundancy Optimizer)训练时,优化器状态/梯度/参数按数据并行 rank 分片,checkpoint 落盘的是一堆分片文件,不能直接 load_state_dict。官方提供 zero_to_fp32.py 把分片合并还原成完整 fp32 权重。本案 v1 导出走了另一条路:训练进程内手动取 module 的权重落盘。两条路都可能对,也可能各有各的坑(比如手动路径漏剥 module. 前缀、漏 merge LoRA)——所以嫌疑五要实证排除。
手动导出 ckpt vs zero_to_fp32 导出 ckpt
抽样 200 个 keys,逐一对比张量数值
相对差 ~2×10⁻⁴ → 精度噪声级 → 两种导出等价,无罪
判据的物理意义:相对差 量级低于 bf16 的表示精度。bf16 是 8 位指数 + 7 位尾数,相对舍入误差上界为 ——训练全程在 bf16 下进行,两条导出路径经历的舍入次序不同,末位自然抖动。 比 bf16 噪声还小一个量级,说明两条路径在数学上输出同一个权重。
反过来,如果导出真有问题(键错位、层缺失、精度截断),相对差会是 甚至更大的量级,不可能藏进 里。数值抽样的灵敏度远超”加载不报错”——后者什么都证明不了。
不是防导出本身(大概率没问题),而是防修复过程中引入新变量。本案在修 target_point 的同时重训了 v2,如果导出方式也顺手换掉,那 v2 仍崩时就无法回答”是修复没生效,还是新导出引入了新病”。每个嫌疑独立定罪/排除,是为了让下一轮实验的变量个数始终为 1。
嫌疑六的担心是:LoRA 适配器根本没挂上,实际在全量微调(fine-tuning)主干——这会同时改变显存需求、训练动力学和结论解释。排除证据来自训练日志的标准打印:
trainable params: 17,596,416 || all params: ... || trainable%: 2.72
17,596,416 恰好是官方口径的 2.72%(PEFT 的 print_trainable_parameters() 输出),LoRA 确认挂载,嫌疑排除。
同一轮训练,Lightning 的模型摘要一度显示 327M 可训练参数——与 17.6M 差出约 18 倍。追查结论:这是框架统计口径膨胀,不是配置错误。PEFT 的 LoRA 实现把被适配模块包装成 LoraLayer 容器,内部同时持有冻结的原始权重和可训练的 LoRA 旁路;框架按模块树上某些字段(如 requires_grad 分组或封装层整体)统计时,会把包装模块整组计入可训练一侧。
真正可训练的参数量应逐张量按 requires_grad 过滤求和:
n = sum(p.numel() for p in model.parameters() if p.requires_grad)
“同一个指标,不同日志差出十八倍”不是矛盾,是两个不同定义的同名指标。规矩:
p.requires_grad 逐张量数一遍——这是唯一没有口径歧义的口径三个口径并存:
cut_bottom_quarter)机制性后果有两层。第一,评测时模型看到的图像下缘少了 30%,而 LoRA 微调时底部区域是完整可见的——LoRA 在底部学到的任何特征,推理时被整个裁掉,等于那部分适配增量静默失效。第二更隐蔽:底部(引擎盖)在训练时是一个强稳定特征(它永远不动、永远出现),模型若把部分权重押在这个区域上,评测时输入突变,上部视野的表征也被连带扰乱。
v4 修复:cut_bottom_quarter = True,训练裁剪与官方口径对齐(25%,逼近评测链的 ~30%)。
快速档用的 250 条”路线”,实际是 10 条几何路线的循环重复——不同起点终点的组合,几何上只有 10 种。对单 epoch 训练,模型把 10 条路线各见了 25 遍,等价于在极小支撑集上过拟合:转弯几何、路口类型、光照分布的覆盖面都严重不足。
v4 修复:换全量干净数据,19.1 万样本(751 条训练路线),三万余步;另跑一个 250 条 + 裁剪对齐的精简版做交叉验证,双保险。
注意嫌疑七、八与已实锤的 target_point 错配,形态完全一样:训练一个分布,评测另一个分布。这不是巧合,是闭环微调管线的一般性风险——数据管线里每一个”看似合理的自实现/默认配置”都可能悄悄改分布。这也解释了排查策略:不再逐个猜病根,而是把训练/评测之间所有已知字段逐位对齐,一次清场。
| 档 | DS | 重跑噪声 |
|---|---|---|
| UE4 基线(CARLA 0.9.15) | 49.59 | 12.91 |
| UE5 zero-shot(CARLA 0.10) | 56.35 | 20.74 |
| UE5 LoRA 微调 | N/A(三版失败) | — |
UE5 比 UE4 高 6.76 分,但最小可检测效应(MDE,Minimum Detectable Effect)为 20.63——差值落在噪声带内,统计上不显著,不能宣称”UE5 更好”,只能说”没有证据表明渲染升级伤害了 zero-shot 迁移”。
SimLingo 的重跑噪声结构很特殊:10 条路线里只有 2 条贡献了几乎全部方差——路线 10000 重跑 +64.7、路线 10011 重跑 −44.3,是”卡死↔完成”的二元翻转;其余 8 条 。
这推翻了两个常见默认假设:
对照组:AutoMoT 在 UE5 侧 40/40 全完成且重跑逐路线零差异——模型能力越强,行为噪声越小,评测管线本身(UE5 侧)反而高度确定。这也反过来证明 SimLingo 的噪声来自模型行为,不是评测链缺陷。
微调档记 N/A 并附完整根因排除链,核心命题仍然被回答:前两档给出”渲染保真度提升对 zero-shot 迁移影响不显著”的实测,第三档的失败机理给出”域内微调对错配极端敏感”的边界条件。两个结论拼起来,比一次侥幸成功的微调信息量更大。
训练 loss 是对所给训练分布拟合度的度量:
是数据落盘那一刻就被固化的经验分布。loss 降到 0.0149、验证集 0.00019,证明的是 在 的支撑集上几乎没有残差。它从不担保这个分布本身是对的,也不担保它与评测分布相同——后者是数据管线的责任,不在任何训练指标的视野内。
直觉上”学得好一点总没坏处”,在分布错配下恰好相反。把策略看成条件映射 :训练分布 错时,训练是在求解”在 上最优”的策略 。loss 越低, 越逼近 ;而评测在 上进行, 在 输入下的行为完全无担保——残差越小,错得越自信而稳定。本案三张训练曲线(收敛到 量级)张张教科书,三版权重张张 16/16 超时,就是这个命题的实测注脚。
| 指标 | 视野 | 本案表现 |
|---|---|---|
| train loss | 内部 | 0.0149,健康 |
| val loss | 的留出子集 | 0.00019,更健康——但 val 与 train 同源,错配原样继承 |
| 过拟合检测 | train/val 差距 | 无差距,无过拟合——也不是答案 |
验证集防的是”记住训练集”,防不了”训练集本身标错”。val 划分是从同一批数据里随机切的,字段错配在划分之前就写进了每一帧。能发现错配的唯一指标在闭环评测那一侧——这就是为什么”训练侧一切正常”在闭环系统里是没有信息量的陈述。
训练数据的每一个字段,必须与评测链逐位同源(bit-level provenance):同一个算法、同一份代码、同一组参数生成。“语义上等价”不够——两个各自正确的实现,只要分布不同,就是错配。
1. target_point 用官方 planner 现算,不自写几何。
任何”前方固定距离截距”式的自实现都与官方的有状态队列弹出语义分布不同(直道纵向偏移 3–5 m、弯道弦弧发散)。修复方式不是重写得更像,而是直接 import 官方 RoutePlanner 离线重算。
2. 图像裁剪逐像素对齐 agent 预处理。 评测链裁底 ~30%、官方训练裁 25%、自采训练图裁 0%,就是三档不同分布。裁剪不只是”少看点内容”:被裁区域在训练时是可见特征,LoRA 在其上学到的增量推理时整体失效,并连带扰乱保留区域的表征。
3. 坐标系、通道、归一化、顺序,逐字段核对。 target_point 在 ego 系还是世界系? 的 是哪一帧的朝向?图像是 BGR 还是 RGB?归一化常数是 ImageNet 均值还是官方配置?多帧队列的时间序是新在前还是旧在前?每一项都是独立的错配源,且全部静默——不报错、不出 NaN。
4. 凡是”看似合理的自实现”,必须与官方逐位对齐。 错配不会以代码缺陷的形式出现:两个实现都可以独立通过代码评审、单元测试、肉眼抽查。唯一可靠的验证是数值对账——同输入跑两个实现,逐字段比数值,容差按物理意义设(如 target_point 的 0.3 m),而不是按”看起来差不多”。
开环任务里字段错配表现为指标变差,有迹可循;闭环里错配驱动行为、行为改变下一帧输入,系统迅速进入训练分布之外的自激状态,呈现的是”整体崩溃”而非”指标劣化”。字段级逐位同源,是闭环微调区别于一般迁移学习的硬要求。
泛化自本案两起真实事故(SimLingo 微调、AutoMoT 加载),按排查优先级排列。每一项都附失败机制——知道机制才知道怎么核。
1. 标签/监督信号的生成算法。 target_point 案:自写截距 vs 官方队列弹出,86–96% 帧差异 >0.3 m。核对方式:用评测侧同款实现离线重算,逐帧数值对账。
2. 图像预处理。 裁剪、resize、归一化常数必须在训练/评测两侧逐像素一致。本案裁底 0% / 25% / ~30% 三口径并存。核对方式:同一帧原始图分别过两条预处理链,逐像素 diff。
3. 坐标系与单位。 ego 系/世界系、左手系/右手系、米/厘米、弧度/角度。几乎人人翻过车,因为每个坐标变换在”大部分直行场景”里都近似无害,只在特定几何下爆发。核对方式:构造已知几何(如正前方 10 m 的虚拟目标),验证变换输出。
4. 通道与顺序。 BGR vs RGB(OpenCV 与 PyTorch 默认相反)、CHW vs HWC、多帧队列时间序。错一个维度,模型看到的是训练时从未见过的纹理统计。核对方式:保存一张预处理后的张量反解为图片,人眼确认色彩与方向。
5. 有状态组件的第一帧与跨帧行为。 planner 队列、PID 积分项、滤波器内部状态。第一帧(空状态)与稳态行为不同;跨帧顺序演进意味着乱序/丢帧会污染后续所有输出。核对方式:首帧单元测试 + 状态演进的不变量检查(如游标单调)。
6. checkpoint 键名。
跨代际改名(transfuser_proj.* → bev_encoder_proj.*)让 shape 一致的层静默随机初始化。加载日志里的 missing/unexpected 必须当硬错误读:
result = model.load_state_dict(ckpt, strict=False)
assert not result.missing_keys and not result.unexpected_keys
AutoMoT 案的 loader 只打印不报错,事故信号被一行 print 淹没。
共同模式:这些雷全部静默——不抛异常、不出 NaN、不挡训练收敛。发现它们靠的不是更好的日志,而是逐字段数值对账的习惯。