Expert Data Collection: How the Coach Car Drives, Part 2
SimLingo 的训练范式是行为克隆(behavior cloning,模仿学习的一种):把专家演示整理成 (观测, 动作) 对,用监督学习拟合映射 。训练损失
衡量的是模型输出与专家标签的差距——这是理解毒性的关键:损失函数里根本没有”开得好不好”这个量,只有”像不像专家”。
专家常撞墙,标签里就包含”看见墙也不减速”的动作示范。模型把这些示范学得越好,loss 越低。坏数据和好数据在 loss 曲线上不可区分——拟合坏分布同样收敛、同样平滑。这就是”垃圾进,垃圾出”(garbage in, garbage out)在模仿学习里的精确含义:毒性不表现在训练指标上,只在闭环评测暴露(CARLA 闭环里碰撞直接扣驾驶分 DS)。
本项目后续微调失败攻坚中反复验证过这一点:loss 收敛到 0.0149 / val 0.00019 的同时,评测 16/16 路线超时——“训练正常”与”行为正确”是两回事(M4 复盘方法论沉淀)。
行为克隆还有协变量偏移(covariate shift)问题:模型按自己的动作行驶,遇到的状态分布会偏离专家数据分布,偏出去后学来的坏动作没有纠偏能力。脏数据把坏动作写进权重,协变量偏移负责把模型推到这些坏动作恰好生效的境地里。DAgger 等迭代式算法就是为缓解后者而生,但对前者(专家本身就坏)无解——只能在数据侧净化。
对应到本项目的流水线设计:
三问都过,数据才允许进训练。
正式采数前先跑 200 帧冒烟(单路线,data/ue5_r1_smoke/route_10000_0,EXP-T4.1 交付记录)。冒烟一次通过,但当场揪出两个隐藏问题:
若直接放飞 55 万帧才发现,全部数据作废重采。冒烟的全部价值就在这:用 200 帧的成本,替 55 万帧挡雷。
正确公式(朝向第二点):
buggy 版本写成了 。注意恒等式
(对任意非零向量成立,mod )。代入得:
转置加 -90° 补偿恰好等于正确朝向的镜像——车出生时就朝向路线方向的镜面反射方位。后果不是原地报错,而是起步后 PurePursuit 持续大角度纠偏:车一直在”追线”,均速被拖垮。这个 bug 从 M3 采图起波及 t31/t41/t42 三个脚本,因为症状(慢)被容忍为”控制器保守”,横跨两个里程碑才在 T4.1 冒烟里被归因(M4 复盘时间线 09-03)。
col_bp = bpl.find("sensor.other.collision")
col = world.spawn_actor(col_bp, carla.Transform(), attach_to=hero)
collisions = []
col.listen(lambda e: collisions.append(
{"impulse": e.normal_impulse.length(), # 冲击力度
"other": str(e.other_actor.type_id) if e.other_actor else "static"})) # 撞了谁
(tools/t42_collect_full.py 第 126–131 行。)
impulse:碰撞瞬间的法向冲量(normal impulse)模长,单位 N·s。冲量 ,把接触力在碰撞时长上积分,综合反映”撞得多狠”。实测量级:轻轻蹭一下几十,实撞上去以千计——量纲上的巨大分离让阈值分类非常可靠。other:other_actor.type_id 区分撞的是背景车(vehicle.*)还是静态物(建筑/栅栏/标志/植被)。撞不上任何 actor 时记 "static"。这个字段后来成为根因诊断的直接证据:碰撞对象分布里静态物占绝对多数,指认了”避障只管动态车”的设计缺陷。整条路线的碰撞事件攒在一个列表里,路线收官时一次性算出真值分数写进 results.json.gz。这意味着质检不需要重跑仿真——所有判罚证据在采集当刻已落盘。“采集时同步记碰撞”也因此被写进 M4 坑账,作为自建专家的三条可复用设计之首。
T4.2 放飞中途,一次 actor destroy 触发 C++ abort,整个采集进程直接退出(M4 复盘时间线 09-03~04)。三层保障在此兑现(设计见 ep19p1 B18 深读):
进程级崩溃在数天级任务里是必然事件,区别只在于恢复是自动还是人肉。
排查中断原因时踩了连环坑里最经典的一个:pgrep -f 的模式会匹配到发起命令的 shell 自身。机制:
pgrep -f <pattern> 扫描所有进程的完整命令行做子串匹配;pkill -f t42_collect_full,执行这条命令的复合 shell(比如 bash -c "... pkill -f t42_collect_full ...")的命令行里也含有同样的字符串——于是模式命中发起者自己;同一族坑在 T1.2 评测期还出现过变体:pgrep -f leaderboard_evaluator 同时匹配 conda run 包装进程,head -1 只杀了包装、真进程漏杀,tee 管道不关导致 harness 卡死——必须杀全部匹配,且先排除自身(AGENTS.md T1.2/T3.0 坑账)。
pgrep -af 人工确认清单,或按精确 PID(PID 文件)杀;这条规矩在 M4 一个里程碑内就再踩 3 次(累计 5+)才被钉死——它属于”知道原理也会再犯”的坑,只能靠流程禁令而非自觉。
driven = float(np.sum(dists)) if dists else 0.0 # 逐帧位移累加的实际里程
rl = sum(math.hypot(route_xy[i+1][0]-route_xy[i][0],
route_xy[i+1][1]-route_xy[i][1])
for i in range(len(route_xy)-1)) # 路线设计总长
rc_pct = min(100.0, 100.0 * driven / max(rl, 1e-6))
big_cols = [c for c in collisions if c["impulse"] > 200.0] # 重碰撞筛选
score = rc_pct * (0.60 if big_cols else 1.0) # 重碰撞打六折
(tools/t42_collect_full.py 第 232–245 行,路线收官时执行。)
分子是逐帧位移累加的实际行驶里程,分母是路线折线总长。两个设计决定值得注意:
idx 看着像进度,但车可以”推进了索引却没好好走线”。里程对绕路、倒车、画龙都如实反映——凡是车轮滚过的路都计入分子;这与 CARLA 官方 leaderboard 的 route completion 定义同口径,评测体系里 RC 同时是驾驶分 DS 的乘性因子()。
分数落盘为 results.json.gz,字段名(scores.score_composed / score_route)与官方数据结构完全同构,filter 零改动直读。
collisions 上),跨路线污染不会发生;dists 从第二个 tick 才开始累加(last_xy 初始化),spawn 瞬间的坐标跳变不会计入里程。教练车的安全机制只有一层:前向避障扇区(实现见 ep19p1 B19 深读)。而它的遍历对象是 bg.actors——TrafficManager 背景车列表,仅此而已:
for bv in bg.actors: # 墙、栅栏、灯杆不在这个列表里
...
if 2.0 < fwd < 7.0 and lat < 2.5:
tgt_spd = min(tgt_spd, max(0.0, fwd - 4.0))
墙、栅栏、标志杆、植被这些静态物在 CARLA 里同样是 actor(或有碰撞体的环境网格),但采集脚本从未把它们放进感知范围。与此同时 PurePursuit 的全部职责是贴线——它不知道线的旁边有没有墙。“贴线”与”撞墙”之间,系统里没有第三道仲裁。
碰撞对象的 other 字段分布直接指认了根因(碰撞传感器两个字段见 B06 深读):建筑、栅栏、标志、植被都有,静态物占绝对多数,背景车反而很少。这个分布本身就是诊断:
不是实现 bug,是需求建模时的覆盖缺口:设计避障时脑子里的事故模型是”追尾前车”,于是只实现了动态让行;真实世界里教练车的主要威胁是”贴线过弯刮到路边静态物”。测试对象错位的典型形态——逻辑通过了它设计范围内的全部场景,缺陷全部落在范围之外。
修复方向明确(扇区扩到静态物、紧凑路口动态缩前瞻),但都属于教练车质量优化,不阻塞数据净化主线:脏路线由 filter 剔除即可(B16 深读),改进补采随时可增量追加。
纯追踪的控制律是”画一条过前瞻点的圆弧”(推导见 ep19p1 B04 深读)。前瞻距离 越大,前瞻点落得越远。在紧凑路口:
弯心 apex
×
/ ← 路线实际转弯处
/
────┘ L_d 小: 前瞻点落在弯前, 弧线贴线
L_d 大: 前瞻点已越过弯心 →→→ 弧线内切
m 在 Town10 的紧凑路口意味着什么:车还没进弯,前瞻点已经落到弯道出口的直线段上。控制器照章办事——画一条到那个点的平滑弧——这条弧必然抄近道切过弯心内侧。内侧有什么,就刮什么:墙角、栅栏、路缘。
放宽前瞻距离的动机是真实的(ep19p1 B07 深读):t42 提速后 m 增益过高会抖,放宽到 8 m 换高速下的转向平滑。曲率律 里 就是增益的倒数,平滑与贴线不可兼得。
但这个权衡有一条隐含的适用边界:路口曲率半径远大于前瞻距离时,内切量可忽略;Town10 的路口不满足。于是同一个参数在直道上是优化、在弯角上是事故——速度提上去了,碰撞跟着上来了。73% 路线带重碰撞的质检结果里,这一根因与”避障不感知静态物”(B13 深读)是乘法关系:几何制造了刮蹭机会,感知缺口保证没有最后一道防线。
SimLingo(carla_garage 血统)的数据加载器 dataset_base 在 filter_infractions_per_route=True 时读取每条路线的 results.json.gz:
(字段约定见 EXP-T4.1 notes 的目录约定节:filter_infractions 时 score_composed < 100 被过滤。)
裁决的原子单位是路线目录:{data_path}/data/simlingo/routes_training/town10/r{rid}_s{seed}/Town10HD/ 一级,连目录带帧整体出局,不存在”路线内挑帧”的概念。
本项目自建了打分(B09 深读),但裁决逻辑一行都没写——直接让官方 dataloader 的既有 filter 读我们写的真值分数。这个”复用官方语义”的选择有三个好处:
scores.score_composed / score_route,字段名完全同构),官方代码读分数,语义完全同源;这是项目级的既定实践(路线集、相机口径同样复用官方语义),在质检环节又一次兑现。
官方 filter 在 3,511 条采集路线上剔除 2,543 条污染路线,剩 834 条干净路线入库(CP4 报告口径)——剔除力度之大正说明打分纪律的必要:如果打分放水(比如重碰撞不折扣),这些路线会静默混进训练集,毒性在 loss 上不可见(B02 深读)。
“只剪掉碰撞前后几秒,其余帧保留”看起来数据折损小:一次刮蹭通常只有几秒,剪掉它路线还剩上百帧。834/3,511 的保留率本可以高得多。
1. 碰撞前后,车的姿态已经歪了。 刮蹭不是瞬时事件:蹭墙之后车往往压在路肩/斜插在车道上,之后的”纠偏动作”——大角度回舵、低速蠕行——同样是带伤教学。剪掉碰撞冲量出现的几秒,这些次生坏动作仍然留在数据里,而且失去了碰撞记录这个标记,更隐蔽。
2. 剪多剪少没有客观判据。 前 3 秒还是前 5 秒?后 2 秒还是后 10 秒?每个阈值都是拍脑袋,不同路线还不一样。没有判据的过滤规则本身会成为新的分布畸变源——你在用一套主观规则改写训练分布。
3. 原子单位已有官方定义。 官方 filter 的剔除粒度就是路线目录(B16 深读),frame 级剪枝需要自己发明并实现一套新机制,回到”自造语义”的老路上。
帧是可再生资源(转换器天然支持增量补采,随时能加),而混进训练集的坏动作以权重形式固化后,没有任何”过滤”能事后摘除——只能重训。整条剔除的”浪费”,本质是用可再生的帧,买不可再生的语义纯净度。
官方 filter 裁决依赖每条路线的 results.json.gz(score_composed < 100 剔除,见 B16 深读)。但冒烟阶段等场景下部分路线没有真值分数文件,dataloader 拼路径会失败。转换器 t40_convert.py 的对策是写一份满分占位:
elif score_placeholder and not os.path.exists(os.path.join(dst_dir, "results.json.gz")):
res = {"scores": {"score_composed": 100.0, "score_route": 100.0}, ...}
(tools/t40_convert.py 第 58–63 行;有真值时优先硬链接真值文件,见第 50–57 行。)
有一次流程顺序反了:占位文件先落盘,真值文件后到。而转换器两处写盘都带 if not os.path.exists(...) 的幂等保护——占位的满分文件已经存在,真值永远不会覆盖它。filter 一看全是 100 分,污染路线全部放行,垃圾进了训练集。
这类事故的阴险之处在于每个局部决策都合理:幂等保护防重复写是对的,占位防缺文件是对的,只是两个”对”拼在一起,顺序语义错了。
修复很简单:先链接真值,再补占位——占位只在确认真值不存在时才写,且永远可被后到的真值覆盖(M4 坑账原文:“占位满分会被 convert 覆盖(先 link 真值再占位)”)。
教训一句话:默认值的顺序本身就是语义。只要默认值是有业务含义的数值(这里是”满分”= 放行),就必须保证:
这个坑与”check 缺失即满分”是同类反模式:任何”文件不存在 → 当作通过”的短路逻辑,都会把上游的故障静默翻译成下游的绿灯。