The DS/SR Scoring System: Who Judges Driving Quality, Part 2
T2.6 注入脚本(tools/ue5harness/t26_inject_violations.py)最关键的设计不是注入本身,而是被测代码就是生产代码:
TickRecord 与正式评测逐字段相同(同一个 dataclass,从 route_executor import);scoring_core.compute_route_score:score = scoring_core.compute_route_score(
route_id=route_config.route_id,
ticks=executor.tick_records, # 真实跑出来的 tick 记录,非合成
route_length=executor.route_length,
timeout=timeout,
)
测试圈最常见的坑是”测的是副本,跑的是另一份”:为测试重写一份简化版判定逻辑,两边各自演化,测试绿了生产照样错。复用生产路径把这个发散风险直接消除——注入实验验证的就是将来给模型打分的那份代码。
注入前的基础驾驶交给 CARLA Traffic Manager(TM)autopilot,并显式压制其随机违章概率:
executor.ego.set_autopilot(True, args.tm_port)
executor.tm.ignore_lights_percentage(executor.ego, 0.0) # 闯灯概率压到 0
executor.tm.ignore_signs_percentage(executor.ego, 0.0) # 无视标志概率压到 0
TM autopilot 默认有小概率随机违规(模拟人类不完美驾驶),不显式清零的话,“守规矩基线”本身就可能带着违章,注入实验的对照就脏了。这是测试设计里容易漏的一步:空白对照必须先证明自身干净。
注入循环跑在 CARLA 同步模式(20 Hz,dt=0.05)下,每帧显式 world.tick() 推进。同步模式的收益是确定性:tick 计数、注入时机、记录顺序全部可控,不会出现异步模式下”客户端还没反应过来,仿真已经跑出去半秒”的竞态。违章注入要精确到”tick 30 准时动手”,只有同步模式做得到。
collision 注入在 tick 30 准时触发(tools/ue5harness/t26_inject_violations.py):
if inject_type == "collision" and not injection_done and executor.tick_count == 30:
executor.ego.set_autopilot(False, args.tm_port) # 夺回控制权
control = carla.VehicleControl(throttle=1.0, steer=-1.0, brake=0.0)
executor.ego.apply_control(control) # 满油门 + 满右舵
injection_done = True
CARLA 的 VehicleControl(油门/刹车/转向三通道)是持续状态:apply_control 写入后一直保持,直到下一次写入覆盖。它不是”这一帧踩一脚油门”的脉冲指令——没有死 man’s switch,没有每 tick 清零。
这条语义对注入脚本反而是便利:只需写一次 throttle=1.0, steer=-1.0,车就会保持满舵满油冲出去,不用每 tick 重复下发。但对一般控制代码它是经典坑源:
set_autopilot(False) 之后若忘了立刻 apply_control,车保持 TM 最后下发的控制量继续跑,短暂”无人驾驶滑行”。steer=-1.0 是满右舵(左手系,负值右转)。写注入脚本时方向搞反,剧本里的”撞右边墙”会变成”甩尾去左边”。剧本(满舵撞墙)敌不过物理:注入点恰好位于空旷路段,车在空地上画了一个大圈后绕回路线压过终点,RC 到 100,零碰撞,DS 100 仍为 Perfect。教训落在注入脚本自身:注入动作发生了不等于注入结果发生了,脚本必须自检目标事件(碰撞冲量非 None)是否真的出现,否则空地和墙从分数上看不出区别。
red_light 注入不依赖定时,等三个前置条件同时成立才动手(tools/ue5harness/t26_inject_violations.py):
elif inject_type == "red_light" and not red_light_injected \
and tl_state == "Red" and speed > 2.0 and waypoint.is_junction:
executor.ego.set_autopilot(False, args.tm_port)
control = carla.VehicleControl(throttle=1.0, brake=0.0, steer=0.0) # 地板油直冲
executor.ego.apply_control(control)
red_light_injected = True
注入触发三条件(灯红、车速 > 2、路口内)与评分器闯红灯判定的前三个条件(灯红、车速 > 1、路口内)结构上完全对齐——注入器用检测器的同款判据来制造违章。这是故意的:用同一把尺子造违章、量违章,只要环境配合,检测器几乎没有不记的理由,测试灵敏度最高。
但同构也埋下循环论证风险:注入器和检测器共享同一套灯态/路口判定,如果这套输入本身错了(比如 _get_traffic_light_state() 选错了灯),注入和检测会一起错、彼此印证,测试照样绿。用同一把尺子,验证的是”检测逻辑对给定输入的反应”,验证不了”输入本身是否正确”。
这组测试实际翻车了:所选百米短路线全程灯态只有 None 和 Green,红灯从未出现,注入条件永不满足,车 15.5 秒干净完赛,DS=100。结果与”检测器漏报了闯红灯”在分数上完全无法区分——这是条件触发注入的固有弱点:
因此注入实验的合格收尾必须包含注入是否触发的自检(本脚本打印了 [inject] tick N: 注入闯红灯 日志,结果卡核对日志与分数的一致性)。两次翻车(撞墙组、闯灯组)都翻在环境不配合,而不是评分器判错——翻车反向证明了判定的保守性:没有记录在案的事实,评分器一分不罚。
T2.6 最初的验证方案是双实现对拍(differential testing):让官方 CARLA leaderboard 和自建 scoring_core 分别跑/判同一批路线,逐路线比对 DS。两套独立实现给出相同结果,是验证正确性的强证据——两边同时错成一样的概率极低。
对拍在 MySim 里死于两个现实问题(state/tasks/T2.6.md、state/CP2-report.md):
放弃严格对拍后,验收改用三条独立证据的组合:
| 层 | 证据 | 验证什么 |
|---|---|---|
| 单元测试 | test_scoring_core.py 六个用例全过 | 每类违章的判定逻辑对合成输入正确 |
| 系数集一致性 | PENALTY_VALUE_DICT 与 Bench2Drive statistics_manager.py 逐项比对 | 罚分参数与官方零偏差 |
| 实跑无违章路线 | UE4 侧 6 条对齐路线全 Perfect,DS=99.25 ± 0.09 | 端到端链路上不误报、不丢分 |
每层单独看都有盲区(单测不测真实数据分布,系数比对不测判定逻辑,实跑不测违章路径),三层叠起来覆盖了”判定对、参数对、链路对”三个正交维度。这是验证工程里常见的权衡:当最强的单一证据不可得时,用多条较弱但互相独立的证据组合,其联合说服力可以接近对拍——前提是各层的失败模式不相关。
顺带说明 99.25 而不是 100 的含义:某条路线完成度停在 99.x(控制误差,未触发 99 容差线的完美判定),如实报出而非凑整。评测系统的可信度恰恰体现在这种”不圆的数字”上。
tools/ue5harness/test_scoring_core.py 的碰撞用例:
def make_tick(tick, sim_time, speed=5.0, completion=0.0, collision=None,
tl_state=None, is_junction=False, road_id=1):
return {
"tick": tick, "sim_time": sim_time, "ego_speed": speed,
"collision_impulse": collision,
"collision_actor": "vehicle.tesla.model3" if collision else None,
"traffic_light_state": tl_state, "is_junction": is_junction,
"road_id": road_id, "route_completion": completion,
# ... 其余字段给无害默认值
}
def test_collision():
ticks = [make_tick(i, i * 0.05, completion=i * 100.0 / 100) for i in range(101)]
ticks[50]["collision_impulse"] = 100.0 # 第 50 tick 塞一笔碰撞
ticks[50]["collision_actor"] = "vehicle.tesla.model3"
score = scoring_core.compute_route_score(0, ticks, 100.0)
assert score.score_penalty == 0.6 # 精确等于,不是约等于
assert score.score_composed == 60.0
assert len(score.infractions["collisions_vehicle"]) == 1
tick 工厂(make_tick)。评分器是纯函数,输入只是一串 dict——所以测试不需要 CARLA、不需要仿真、不需要真的撞车。工厂函数把所有字段填成”无违章基线”,用例只改与目标行为相关的一两个字段(第 50 个 tick 的碰撞冲量)。活体注入实验里要等一晚上才能遇到的一次碰撞,合成数据一行就位,且每次运行逐位相同。
精确相等断言。== 0.6 而非 abs(x - 0.6) < 1e-6。这里成立是因为计算链是纯乘法和查表:1.0 * 0.6 与字面量 0.6 在 IEEE 754 下是同一个双精度值(0.6 的浮点表示唯一),100.0 * 0.6 恰好等于 60.0。评分器没有累加循环、没有除法,不存在误差累积,所以精确断言反而是更强的测试——任何多记一笔、重复乘系数的回归都会立刻让断言炸掉。浮点代码里”能不能用 ==” 取决于运算路径是否保距,不是一律禁止。
事件计数断言。第三个断言检查 collisions_vehicle 列表长度恰好为 1——罚分系数对了还不够,事件账也要对。分数断言和账本断言覆盖的是不同的失效模式:前者抓算错,后者抓漏记/重复记。
同一文件里 test_red_light 用同样手法:连续手写 10 个”红灯+路口内+5 m/s”的 tick,断言 penalty == 0.7 且 red_light 事件只有 1 条——验证同一红灯周期不重复计罪的去重逻辑。test_timeout 则把 timeout=True 直接传入,不用真等 120 秒墙钟:超时判定在执行侧,评分器只消费布尔,单测里这个职责边界被直接利用。
MySim M3 实验(docs/log/M3-retrospective.md、docs/log/2026-09-06-status.md)对 SimLingo 做同条件重跑,逐路线比较两次 DS:
翻整条路线的根子在评分体系的事件链上。一辆车卡在路口边缘(实测案例:绿灯下满刹+死舵),结局取决于物理微扰落在哪一侧:
60% 完成度处卡死,两种结局的分差约 分。blocked 判定(速度 < 0.05 m/s 持续 30 秒)设计得越保守,悬崖两侧的结局差就越大——判定本身是二元的,分数就被迫二元化。模型行为在卡死边界附近的混沌(同样的输入两次跑出不同轨迹)经这条悬崖放大成几十分的 DS 跳变。
这解释了为什么 SimLingo 的 UE5 提升(+6.76,49.59 → 56.35)不能直接采信:单次均值差远小于单条路线的翻转幅度(±44~65 分),10 条路线的均值里混着一两个硬币翻转的结果。均值差和噪声同源同量级,显著性无从谈起——这也是必须逐路线配对、盯翻转率而不是盯均值的原因。
MySim M3 三档对比(docs/log/M3-retrospective.md、experiments/EXP-T3.5-cp3/summary.json):
| 模型 | UE4 DS | UE5 DS | Δ | 重跑噪声 UE4/UE5 | MDE | 判定 |
|---|---|---|---|---|---|---|
| SimLingo | 49.59 | 56.35 | +6.76 | 12.91 / 20.74 | 20.63 | 不显著 |
| AutoMoT | 67.10 | 100.00 | +32.90 | 1.90 / 0.00 | 1.89 | 显著 |
MDE(Minimum Detectable Effect,最小可检测效应):给定样本量与噪声水平,能被统计检验分辨出来的最小真实差值。SimLingo 的 Δ=6.76 连自身噪声带都出不去;AutoMoT 的 Δ=32.90 是 MDE 的 17 倍,且 UE5 侧重跑逐路线零差异——同样一套仿真管线,噪声水平是模型属性,不是仿真器属性。
高斯噪声的隐含假设是大量独立小扰动的叠加(中心极限定理)。闭环驾驶的重跑差异不满足这个结构:差异主要由离散的结局翻转贡献——某条路线这次卡死、下次没卡死,单次事件就能改变 40~60 分。逐路线 ΔDS 的分布是”8 个小值 + 2 个巨值”的长尾/多峰形态,而不是围绕零点的钟形。机制上,这是动力系统的混沌敏感性(初始条件的微小差异被非线性放大)经由评分体系里的二元事件(blocked、超时)固化成离散结局。
把非高斯噪声当高斯处理会系统性误判:
模型能力与行为稳定性在这个实验里呈正相关(强模型 AutoMoT 零噪声),这本身是结论的一部分:渲染域差距存在,但跨域失败主要发生在弱模型的行为混沌上,而非画面分布偏移上。