Anatomy of a Failure: Loss Converged, Behavior Collapsed, Part 1
训练一个参数为 的模型,本质是在做经验风险最小化(Empirical Risk Minimization):
其中 采样自训练分布 , 是逐样本损失(如 L1)。loss 收敛到 所证明的命题只有一个: 在 的支撑集上拟合得很好。它对分布本身的正确性、对分布之外的行为,不做任何陈述。
评测时输入来自另一个分布 。只要 ,训练 loss 与评测行为之间就没有数学上的传导关系——拟合得越准,只是在错误的分布上错得越自信。
本案例属于协变量偏移(covariate shift):标签生成逻辑不变,但输入特征的边缘分布变了。具体是导航目标点 这一个字段:
实测 与 在 86–96% 的帧上差异超过 0.3 米。模型学到的条件策略是 ,评测喂进来的却是 的点——绝大多数推理输入落在训练支撑集之外,输出完全无担保。
开环预测里,分布偏移的伤害是一次性的;闭环控制里它会复利式累积。模仿学习(compounding error)的经典结论是:若专家策略在每个状态被以概率 误模仿, 步轨迹的累计误差可以达到 量级,而不是 ——因为每一步偏差把车辆推向训练时没见过的状态,在新状态下模型更容易再错,误差滚雪球。
这解释了观测到的崩溃形态:起步两秒内输入尚在训练分布附近,加速正常;一旦目标点偏差把行为推离分布,输出饱和(满油门、死舵),车辆顶着障碍物不再移动,输入从此永远停留在分布之外,永不恢复。
驾驶分(Driving Score,DS)是 CARLA Leaderboard 系评测的核心指标,为路线完成率与违规罚分的乘积:
多条路线的 DS 取均值。两个推论:一是超时但无违规的路线 DS 不会被罚没,只按完成率计——所以”顶在绿化带上蹭出 6.6% 完成率”的路线仍有微小的分;二是 DS 对碰撞类违规是乘法惩罚,一次严重碰撞可以打掉一大截分数。
| 档 | 渲染 | 权重 | DS |
|---|---|---|---|
| UE4 基线 | CARLA 0.9.15(UE4) | 官方 zero-shot | 49.59 |
| UE5 zero-shot | CARLA 0.10(UE5) | 官方 zero-shot | 56.35 |
| UE5 微调 | CARLA 0.10(UE5) | 自采数据 LoRA 微调 | N/A(三版失败) |
前两档的唯一变量是渲染/物理管线:同模型、同路线集、同指标体系。UE4 侧实测同步 97.6 FPS、UE5 侧 42.3 FPS,是渲染管线的真实性能差,与模型无关。
第三档想回答”用 UE5 域内数据微调能否把域差距收回来”,档位设计本身没有问题——问题出在执行:微调数据的一个字段(target_point)语义与评测链错配,三版微调全部 16/16 路线超时,最终以负结果收官记 N/A。
UE4→UE5 的 ,但同链重跑噪声分别为 12.91(UE4)和 20.74(UE5),最小可检测效应(MDE,Minimum Detectable Effect)为 20.63—— 落在噪声带内。注意这里的噪声不是高斯测量噪声,而是行为混沌:少数路线在”卡死↔完成”之间二元翻转(如路线 10000 重跑 +64.7、10011 重跑 −44.3),其余路线 。因此两档之差必须逐路线配对解读,只比均值会误判。
SimLingo(CVPR 2025 Highlight,CARLA Challenge 2024 冠军)把端到端驾驶建模为视觉语言模型的条件生成问题。底座是 InternVL2-1B——约十亿参数的纯视觉语言模型(vision-language model,VLM),只用前视相机,没有激光雷达和 BEV 融合。这正是渲染保真度研究选它的原因:输入全部经过渲染管线,渲染域差距不会被几何传感器稀释。
每帧的输入三元组:
模型前向一次,输出未来若干 waypoints(路径点),再由一个 PID 控制器把 waypoints 解码成油门/刹车/转向,逐帧闭环执行,没有任何规则兜底。因此输入分布一旦漂移,没有任何安全网。
target_point 字段的特殊性在于:它由仿真侧代码生成、写入数据,再由评测侧代码在闭环中实时生成。两处代码必须语义逐位同源,否则模型在 A 分布上训练、在 B 分布上考试。SimLingo 官方数据里该字段来自其官方 route planner(有状态的队列弹出算法);任何”看起来等价”的自写几何算法(如无状态的前方截距)都会引入系统性分布偏移。86–96% 帧差异 >0.3 m 的实测说明,这个偏移足以让 loss 完美收敛的同时行为全崩。
LoRA(Low-Rank Adaptation)冻结预训练权重 ,把增量约束为两个低秩矩阵的乘积:
其中 、,秩 , 是缩放系数。前向计算变为
两条路径并行,推理时可以把 合并回 (merge),不增加任何延迟。
参数量从 降到 。以 、 为例:全量 参数,LoRA 只需 个,压缩约 64 倍。
标准实现里 用高斯随机初始化, 初始化为零——保证训练起点处 ,模型行为与预训练权重逐位一致,微调从恒等映射出发逐步偏离。这个约定的反面教材:如果某层权重加载失败被静默随机初始化(参见 AutoMoT 的 ckpt 键改名事故),模型从第一步起就在垃圾参数上工作。
LoRA 的低秩约束是一把双刃剑。它把模型的可移动范围限制在一个低维子空间里:扰动太轻,模型留在官方权重学到的分布上,微调等于没做;扰动方向正确,恰好完成域适配;方向错误——比如数据里的 target_point 字段本身来自错误分布——LoRA 会以同样高的效率把模型掰向错误分布,而且 2.72% 的容量刚好够完成这次偏移、又不足以让模型对两种分布都鲁棒。方向错了,loss 依然完美收敛,因为它拟合的是被污染的训练分布本身。
闭环评测跑在 CARLA 的同步模式(synchronous mode)下:仿真器固定步长推进,每 tick 一次 fixed_delta_seconds = 0.05 秒,即每秒仿真时间 = 20 个 tick。传感器与车辆状态按 tick 对齐落盘,形成 ticks 日志——这是崩溃尸检的原始证据。
掌握这个换算,曲线上的每个数字都能定位:
顶死 200 秒耗尽预算与”碰撞出局”是两种完全不同的死法:前者车辆一直活着但不动,后者触发违规罚分提前终止。16 条路线全部 Route timeout,说明模型没有撞车,而是丧失了让车动起来的能力。
路线 10000 的 4000 帧记录里,三个数字构成完整的排除链:
如果病灶在工程层(端口、坐标变换、权重加载失败),典型形态是输出恒零、随机抖动或直接抛异常,不会有”先正常两秒再饱和”的瞬态。这个瞬态恰恰说明:车辆起步时输入尚在训练分布附近,一旦目标点偏差把车推离分布,输出即崩溃——行为证据指向权重/数据分布层,而非工程层。
车辆控制量有物理量程:油门/刹车归一化到 ,转向归一化到 。正常驾驶时这些量连续变化、极少触边;**饱和(saturation)**指输出长期钉在量程边界上。本案 4000 帧控制日志的浓缩结果:
| 通道 | 观测 | 占比 |
|---|---|---|
| throttle | 恒 1.0 | 3991 / 4000 帧 = 99.8% |
| steer | 非 −1 即 +1,永远打满 | ~100% |
| speed | 中位 0.01 m/s | — |
“想走但走不了”(油门满 + 速度零)加上”转向永远极值”,意味着网络输出的 logits/回归值远远超出有效区间,被裁剪或 tanh 压到边界——决策能力整体丧失的形态。
饱和指纹的价值在于它能一刀切分两大类故障:
| 故障层 | 典型形态 | 本案? |
|---|---|---|
| 工程层(端口/坐标/加载失败) | 输出恒零不动、随机抖动、直接抛异常 | 否 |
| 权重层(权重坏或分布错配) | 起步短暂正常 → 输出饱和且永不恢复 | 是 |
判据的关键是起步瞬态:工程层故障从第 0 帧起就是坏的;而”前 2 秒正常加速 → 突变饱和”说明前向通路完好,崩溃是输入分布滑出训练支撑集后权重行为的崩溃。这是两个独立真实案例(本案 + AutoMoT BEV 垃圾事故)共同验证的指纹。
排查中有一个看似合理的假设:专家数据里停车样本太多,模型学会了”动不动就停”。数据画像可以证伪它——训练集速度中位数 3.74 m/s,低于 0.5 m/s 的样本仅占 9%(红绿灯停等的正常水平)。更重要的是行为层面的反证:学成惯性停车的车会怠速不动,油门输出接近 0;而实测油门恒 1.0。指纹方向正好相反,假设当场排除。
不要在评测器里下断点。先问:模型此刻看到的输入分布,和它训练时学过的分布,还是同一个吗?——逐字段核对(导航点、图像裁剪、坐标系、通道顺序),比单步调试收敛快得多。
AutoMoT(ICML’26)的 release 权重(2026-05-17)与当代代码跨代际改名:BEV 投影层在 ckpt 里叫 transfuser_proj.*,代码里叫 bev_encoder_proj.*,shape 完全一致、名字完全不同。流式 loader 按名字匹配权重,匹配不上的层不会报错——它保留随机初始化值继续跑。
后果链:投影层随机初始化 → 64 个 BEV token 全是垃圾 → 模型”想走但看不见障碍” → 满油门顶死障碍物。18 条路线 meanDS 只有 27.5,而官方口径 87.34。
修复是加前缀重映射表 _CKPT_KEY_RENAMES(注意:文件读取用的 key 与模型查表用的 key 必须分离,混用会触发 SafetensorError)。修复后冒烟三条路线 DS 从 25/59/13 变为全部 100。
PyTorch 的 load_state_dict 返回两个列表:
result = model.load_state_dict(ckpt, strict=False)
result.missing_keys # 模型需要、ckpt 里没有 → 这些层保留随机初始化!
result.unexpected_keys # ckpt 里有、模型没有 → 权重被丢弃
strict=False 时加载不会抛异常,很多 loader 只 print 这两个列表就继续。打印不等于处理:missing 列表非空意味着有层在随机初始化状态下参与推理——对 shape 一致的改名层,这是唯一能发现事故的信号。工程规矩:
assert not result.missing_keys, f"missing: {result.missing_keys}"
assert not result.unexpected_keys, f"unexpected: {result.unexpected_keys}"
本案微调权重的排查(嫌疑一)就是按这个规矩做的:992 个 keys 与官方 ckpt 完全同构,strict 模式加载零 missing、零 unexpected,一小时排除导出错位的嫌疑。
| AutoMoT 案 | 本案(SimLingo 微调) | |
|---|---|---|
| 病灶 | 加载期:键名不匹配,层随机初始化 | 训练期:数据字段分布错配,LoRA 掰歪 |
| 权重数值 | 部分层从未加载 | 全部正常加载,数值”健康” |
| 症状 | 满油门顶障碍、饱和输出 | 满油门死舵、饱和输出 |
病灶完全不同,死法相同——这正是”饱和输出指纹”可跨案例复用的原因:先按指纹定性到权重层,再沿”加载坏 / 训练歪”两个分支分叉排查。
怀疑某个数据字段的生成算法与评测链不一致时,不要争论哪个算法”更合理”——直接用评测链同款实现离线重算一遍,和落盘的值逐帧对比。本案的 check 工具做的是:
RoutePlannertarget_point 字段比较,差异超过 0.3 m 记一帧错配结果:86%–96% 的帧错配。也就是说训练数据里的导航目标点,和评测时实际喂给模型的,绝大多数根本不是同一个点。
0.3 m 不是精度噪声,是语义差异的下界。两个”都正确”的实现如果语义相同,数值差应在浮点/舍入量级( m 级);差到分米级,说明它们计算的本来就不是同一个几何对象。反过来,阈值也不能定得太大——直道上两种算法的 target 纵向位置接近,只有弯道才几何发散,阈值过大会漏掉直道上的系统性偏移。
工具设计成两个模式,这是个值得抄的工程习惯:
--check:只统计差异比例,不改任何数据先量化、后动手,把”我以为有问题”变成”86–96% 帧差异 >0.3 m”的可复查证据。排查链上的每一步都要能留下这样的数字,否则两天后没人(包括自己)能复核当时的判断。
确立:target_point 字段存在系统性语义错配,是本案第一个实锤。没确立:它是唯一病根——这正是后来 v2 修复后仍然崩溃时,全案最难接受也最有信息量的事实(真问题,但非唯一问题)。
SimLingo 官方评测链里,target_point 由 RoutePlanner 类逐帧滚动生成(构造参数 min_distance=7.5, max_distance=50.0,对应 agent 的 route_planner_min/max_distance)。核心语义:
# 语义示意(非源码逐字)
planner.route = deque(路线点列) # 有状态,跨帧保持
def run_step(ego_pos):
while 距 ego ≤ 7.5 m 的点: # 贪心弹出
route.popleft()
return route
target = route[1] # 剩余队列第 2 个点,约 7.5–10 m 外
target_next = route[2] # 再往后一个点,约 +2 m
三个要点:
popleft 掉,target 始终维持在”前方 7.5–10 m”的窗口里路线是折线点列,自车沿路线前进时,需要一个”我已经走到哪了”的单调游标。队列弹出天然保证游标只前进不后退:即使定位噪声让 ego 位置小幅回跳,已弹出的点不会复活,target 不会突然跳到车尾方向。无状态的重算算法没有这个性质——它的输出完全由当前帧位置/朝向决定,任何瞬时扰动都会原样传导到 target。
这个 planner 的输出分布有自己的”形状”:直道上 target 几乎总在车头正前方 7.5–10 m,弯道上 target 贴着路线弧线(因为路线点本身就沿弧布设)。模型在官方数据上训练时,学到的是对这个特定分布的条件响应 。任何自写替代算法——哪怕”看起来更准”——只要输出分布不同,就是在给模型换一种它没学过的语言。
采集侧写入 target_point 用的是自写几何算法:无状态,每帧独立计算,与前后帧无关:
# 语义示意
heading = 自车朝向单位向量
target = ego_pos + heading * 7.5 # 车头前方固定 7.5 m
target_next = ego_pos + heading * 15.0 # 车头前方固定 15.0 m
不看路线点列,不维护游标,取的是”车头朝向射线上的两个截距点”。
设道路是半径 的圆弧,自车沿弧行驶。官方 planner 的 target 取自弧上(路线点沿弧布设);截距算法的 target 在车头切线方向,即弦(割线)上。车头方向与弧相切,截距点沿切线走出去,而路线向内弯——两者的横向偏差就是圆的矢高(sagitta)。弧长 对应的圆心角 ,横向偏差:
代入数值感受一下量级:
| 转弯半径 | m | m |
|---|---|---|
| 50 m(缓弯) | 0.56 m | 2.25 m |
| 20 m(急弯) | 1.41 m | 5.6 m |
曲率越大( 越小),两算法几何发散越严重——这正是”直道上看不出问题、一转弯就离谱”的数学来源。
即使在直道上, 仍有 3–5 m 的系统性纵向偏移:官方语义里 next 是”target 之后再一个路线点”(约 9.5–12 m 处),而截距算法固定取 15 m。差值恒定、方向恒定——不是噪声,是 bias。模型可以把 bias 学进权重里(训练 loss 照样收敛),但评测时官方分布没有这个 bias,等于每个导航指令被整体平移。
两个算法单独评审都能过:一个贴弧、一个走弦,都是合理的导航点定义。问题在于它们定义的是两个分布,而模仿学习对输入字段的要求不是”合理”,是同源。采集侧自写算法时没有任何报错、没有任何 NaN、曲线照样收敛——分布错配不会以任何形式报警,这正是它必须靠逐位对账(而不是代码评审)来发现的原因。