The DS/SR Scoring System: Who Judges Driving Quality, Part 1
评分器(scorer)不在仿真运行时做任何判断。执行器(route executor)逐 tick(仿真步)把原始观测落盘成流水账,评分是事后对这份记录的纯离线鉴定。这种”先记账、后算分”的结构是事件溯源(event sourcing)思路在评测管线里的应用:
MySim 的 TickRecord(tools/ue5harness/route_executor.py)每个 tick 记一组字段,评分真正消费的是这九类:
| 字段 | 含义 | 用于哪条判定 |
|---|---|---|
collision_impulse | 碰撞冲量,None 表示无碰撞 | 碰撞是否发生 |
collision_actor | 碰撞对象类型字符串 | 碰撞分类(行人/车辆/静态) |
traffic_light_state | 影响本车的灯态:Red/Green/Off/Unknown | 闯红灯 |
road_id / lane_id | 车道几何标识 | 路线偏离 |
is_junction | 是否在路口内 | 闯红灯的误报抑制 |
ego_speed | 车速 m/s | 闯红灯、堵路 |
sim_time / timestamp | 仿真时间与墙钟时间戳 | 堵路时长 |
route_completion | 路线完成度 0–100 | DS 的 RC 项、堵路前提 |
采集侧代码(T2.6 注入脚本里的实际构造):
record = TickRecord(
tick=executor.tick_count,
sim_time=sim_time, # 仿真内时间,非墙钟
ego_speed=speed,
collision_impulse=executor.latest_collision["impulse"] if executor.latest_collision else None,
collision_actor=executor.latest_collision["actor"] if executor.latest_collision else None,
traffic_light_state=tl_state, # 影响本车的那盏灯
road_id=waypoint.road_id if waypoint else None,
is_junction=waypoint.is_junction if waypoint else False,
route_completion=executor._get_route_progress(),
)
注意两个设计细节:
traffic_light_state 存的是”影响本车的灯”,由 executor 的 _get_traffic_light_state() 负责从全场灯里选出对 ego 车有约束的那一盏。选择语义在执行侧固化,评分侧拿到的已经是可归因的灯态,不用再做空间匹配。executor.latest_collision = None)。CARLA 的碰撞传感器是事件型的,一个物理碰撞可能跨 tick 持续,不清理会把同一次碰撞记成多笔。驾驶分数 DS(Driving Score)把一条路线压成一个 0–100 的数:
两个维度缺一不可: 回答”开完了多少”,罚分积回答”开得有多干净”。只开一半但零违章,和开完但一路乱撞,都得不了高分。
MySim 的实现(tools/ue5harness/scoring_core.py 的 compute_route_score)里公式就一行:
score_route = norm_ticks[-1].get("route_completion", 0.0) # RC:最后一个 tick 的完成度
score_composed = max(score_route * score_penalty, 0.0) # DS = RC × 罚分积
score_penalty 在违章检测循环里逐笔累乘:
score_penalty = 1.0 # 从 1.0 出发
# 每笔碰撞:
score_penalty *= PENALTY_VALUE_DICT[ctype] # 连乘,不打折下限
两个容易看错的点:
statistics_manager 口径一致,不是本项目自创。这套指标是闭环 E2E 评测的事实标准,跨论文比 DS 时默认的就是它。设违章 的扣分权重为 ,减法方案的分数是 。
乘法方案里每笔违章把当前分数乘以 :
max 只是兜底。连乘下第 笔违章的绝对伤害是 ,前面罚得越狠,后面同种违章扣得越少(100→60 扣 40,36→21.6 只扣 14.4)。这不是缺陷而是态度表达:第一笔违章是”事故”,第十笔同类违章只是”又事故了一次”——评价体系对重复犯错边际脱敏,但对首次犯错极重。想让总分雪崩,靠的不是单次权重,而是犯错次数的指数累积。
MySim tools/ue5harness/scoring_core.py 中的完整系数表,与 Bench2Drive statistics_manager.py 逐项一致:
PENALTY_VALUE_DICT = {
"collision_pedestrian": 0.5, # 碰撞行人
"collision_vehicle": 0.6, # 碰撞车辆
"collision_static": 0.65, # 碰撞静态物(墙/灯杆/护栏,collisions_layout)
"red_light": 0.7, # 闯红灯
"stop_infraction": 0.8, # 闯停止牌(STOP sign)
"scenario_timeout": 0.7, # 场景/路线超时
"yield_emergency": 0.7, # 未让行应急车辆
}
另有比例型罚分表 PENALTY_PERC_DICT(outside_route_lanes 与 min_speed),语义不同,单独讲。
| 系数 | 违章 | 罚后剩余 |
|---|---|---|
| 0.5 | 碰撞行人 | 50% |
| 0.6 | 碰撞车辆 | 60% |
| 0.65 | 碰撞静态物 | 65% |
| 0.7 | 闯红灯 / 超时 / 不让应急车 | 70% |
| 0.8 | 闯停止牌 | 80% |
系数越小罚得越重。排序的规则是对他人造成的风险:行人是最脆弱的道路使用者(vulnerable road user),所以 0.5 全表最重;静止物体不会受伤,垫底轻罚。系数表本质是安全价值观的数字化编码。
两个读表时容易错的地方:
MySim 的 UE4 侧走官方 Bench2Drive 管线,UE5 侧走自建 harness,但两侧的评分适配层——scoring.py(UE5)与 scoring_ue4_adapter.py(UE4)——都 import 同一份 scoring_core.py。系数表在整个项目里只存在一个物理副本,文件头注释写明与 Bench2Drive leaderboard/utils/statistics_manager.py 完全一致。
这是单一事实源(single source of truth)模式:同一份知识只允许有一处权威定义,其余位置全部引用而非复制。违反它的典型事故是”两张表”:某次实验改了 UE5 侧的系数忘了改 UE4 侧,此后所有跨侧差值都不可信。
这个项目的核心测量是 ,用来回答”渲染保真度是否影响 E2E 模型”。这是一个差值测量,成立的前提是两侧尺子完全相同。
假想 UE5 侧把碰撞车辆系数从 0.6 改成 0.65:一条碰一次车的路线,UE4 得 60 分、UE5 得 65 分, 凭空多出 5 分。这个差值与渲染毫无关系,是尺子本身的差。在受控实验里,评测协议属于必须冻结的变量——同模型、同路线、同指标,两侧只允许渲染/物理不同。系数表一旦分叉,UE4/UE5 的对比就变成鸡同鸭讲,且这种污染混在每个数字里,事后无法剔除。
选择 Bench2Drive 作锚点还有第二层收益:DS/SR 是闭环 E2E 评测的事实标准,系数对齐意味着本项目的绝对分数与已发表文献(SimLingo、AutoMoT 官方报告值)在同一刻度上,可以横向参照。
CARLA 的碰撞传感器返回的是”撞到了哪个 actor”,评分器拿到的只是对象的类型字符串(如 vehicle.tesla.model3、walker.pedestrian.0001)。分类函数(tools/ue5harness/scoring_core.py):
def _classify_collision(actor_type):
if actor_type is None:
return "collision_static" # 连对象都没识别出来,也记静态
actor_type = actor_type.lower()
if "pedestrian" in actor_type or "walker" in actor_type:
return "collision_pedestrian" # 先查行人,罚最重
if "vehicle" in actor_type:
return "collision_vehicle"
return "collision_static" # 兜底:其余一切归静态
顺序敏感。判据是子串包含,不是精确匹配,所以检查顺序决定归类:含 walker 的字符串也可能出现在别的上下文中,先人后车保证行人永远优先生效。若把 vehicle 检查放前面,撞到一个推车里的行人就可能被错记成车辆。
认不出的也罚。None(传感器没给出对象类型)和任何不认识的字符串都落到 collision_static。这是故障安全(fail-closed)取向:分类器的错误方向是”多罚”而不是”漏罚”。如果反过来——认不出就丢弃——新型资产包里的对象会成为免费碰撞,评分器对资产漂移失去防御。
为什么用字符串而不是类型 ID。CARLA 蓝图 ID 本身就是带命名空间的字符串(vehicle.* / walker.* / static.*),命名即分类。子串匹配是对这个命名约定最便宜的利用,代价是依赖约定的稳定性——CARLA 改命名规则那天,这张分类表要跟着审一遍。
name = {
"collision_pedestrian": "collisions_pedestrian", # × 0.5
"collision_vehicle": "collisions_vehicle", # × 0.6
"collision_static": "collisions_layout", # × 0.65,官方叫法 layout
}[ctype]
score_penalty *= PENALTY_VALUE_DICT[ctype]
注意输出侧用的官方字段名 collisions_layout(墙、灯杆、护栏等环境布局件),与内部类型名 collision_static 不同——这是为了汇总字段与 Bench2Drive 报表对齐。
MySim _detect_red_light_violation(tools/ue5harness/scoring_core.py)的判定:
traffic_light_state == "Red"。注意这个字段存的是”影响本车的那盏灯”的状态(由执行侧的 _get_traffic_light_state() 选出),全场红灯千千万,只认约束 ego 车的这一盏。ego_speed > 1.0 m/s。红灯前减速蠕行不算闯,这条把”刹停前的滑行”排除掉。is_junction == True。路口几何外的红灯下移动(比如跟在转弯车流里)不记。这条是误报抑制的主力。if (t.get("traffic_light_state") == "Red"
and t.get("ego_speed", 0.0) > min_speed_threshold # 1.0 m/s
and t.get("is_junction", False)):
sustained = True
for j in range(i + 1, min(i + 1 + min_ticks, n)): # min_ticks = 3
if ticks[j].get("ego_speed", 0.0) <= min_speed_threshold:
sustained = False
break
if sustained:
violations.append(InfractionEvent(type="red_light", ...))
记下一笔后,扫描指针直接跳到这段红灯的末尾:
j = i + 1
while j < n and ticks[j].get("traffic_light_state") == "Red":
j += 1
i = j
不去重的话,一个 10 秒红灯(20Hz 下约 200 个 tick)会被记成几十笔违章,连乘罚分直接归零。去重的语义依据:法律上”一次闯红灯”是一个行为事件,不是每帧一罪。灯变绿再变红才构成新周期,才可能记第二笔。
四个条件全部朝”少记”方向设计:阈值内的慢速、路口外的移动、瞬时压线,一律不记。代价是存在漏报空间(例如以 0.9 m/s 蠕行闯灯完全合法化),收益是凡记账的违章几乎无争议——这对评测器比对执法系统更重要:分数争议的代价比漏检大得多。
_detect_blocked(tools/ue5harness/scoring_core.py)扫描 tick 序列,找”连续低速段”:
def _detect_blocked(ticks, speed_threshold=0.05, min_duration=30.0):
while i < n:
t = ticks[i]
if t.get("ego_speed", 0.0) < speed_threshold \
and t.get("route_completion", 0.0) < 99.0: # 闸门一、二
start = i
start_time = t["sim_time"]
while i < n and ticks[i].get("ego_speed", 0.0) < speed_threshold:
i += 1 # 扫完整个低速段
duration = ticks[i - 1]["sim_time"] - start_time
if duration >= min_duration: # 闸门三
violations.append(InfractionEvent(type="vehicle_blocked", ...))
else:
i += 1
三个参数各是一道闸门:
sim_time)而不是 tick 数计量。正常等红灯、礼让行人的停车很难连续撑满 30 秒,阈值取得刻意保守。blocked 是全表最特殊的一类:检测到也不乘系数,代码注释写明与 Bench2Drive 的处理一致。它的实际作用是改变路线终局——状态置为 Failed - Agent got blocked,执行器终止路线,随后预算耗尽按超时罚 0.7。
设计动机:低速本身是无罪的(等灯、礼让、堵车都低速),所以”低速”这个信号只配做终止条件,不配做罚分项。罚分由超时承接,语义上变成”你没有在预算内开完”,而不是”你停过车”。
这套保守参数让 blocked 成为全评测里最二元的信号:正常路线几乎不触发,卡死路线必然触发。MySim 实测(M3 复盘):SimLingo 重跑噪声 12.91 分主要由 2/10 条路线的”卡死↔完成”翻转主导——一辆车恰好挣扎出 0.05 m/s 的微小位移,或恰好停够 30 秒,结果在”Completed(分数保留)“和”blocked→timeout(×0.7 且 RC 截断)“之间整体跳变。blocked 的阈值设计越保守,这条分数悬崖就越陡峭。
PENALTY_PERC_DICT = {
"outside_route_lanes": [0.0, "increases"],
"min_speed": [0.7, "unused"], # Bench2Drive 0.9.15 版标记 unused
}
与 PENALTY_VALUE_DICT(每发生一次乘一次系数)不同,这张表是比例型罚分:罚多少取决于违章的”量”,值是个二元组 [基准系数, 模式标记]。
[0.0, "increases"] 的语义(沿袭 CARLA leaderboard):统计车辆行驶在路线车道之外的里程占全程的比例 ,罚分随 单调上升。它不是事件计数——压线 1 米和骑着对向车道开 500 米是不同量级的错。increases 标记表示”此类违章的罚分随违章程度加深”,与按次连乘的表形成对照。
在 MySim 的实现里,路线偏离的实际处理走的是另一条路:_detect_route_deviation 检测”连续 ≥5 个 tick 不在路线的 road_id 集合上”,记 route_dev 事件并终止路线,不直接乘系数(与 Bench2Drive 中 route_dev 只终止不罚分的处理一致)。PENALTY_PERC_DICT 的 outside_route_lanes 项保留在表里是为了与官方报表字段对齐,当前判定路径并不消费它。
[0.7, "unused"]:低于最低限速行驶本应罚 0.7,但 Bench2Drive 基于的 0.9.15 版 leaderboard 把它标记为 unused——龟速行驶有 blocked/超时兜底,再单设罚项会重复惩罚同一行为。MySim 原样保留这个条目和标记,不启用。保留的价值是语义存档:后人读表时知道”官方考虑过龟速罚、又主动关掉了”,而不是”这个项目漏掉了龟速罚”。
读这张表最容易犯的错是把 [0.0, "increases"] 的 0.0 读成”罚到零分”。0.0 是基准系数不是罚分结果,真正的罚分由偏离比例决定;而 unused 项则是完全不参与计算。两个标记的生效路径不同,不能混读。
条路线各自评出 DS 与状态后,compute_global_scores(tools/ue5harness/scoring_core.py)汇总成全局指标:
其中 是状态为 Perfect 或 Completed 的路线数。
两者组合能区分两种典型失败模式:DS 低 SR 高 = 开得完但开得脏;DS 低 SR 也低 = 大量路线开不完。只看其中一个指标会漏掉一半的信息。这也是 Bench2Drive/CARLA leaderboard 同时报告两者的原因。
代码里是样本标准差(分母 ,Bessel 校正):
std_composed = math.sqrt(
sum((r.score_composed - mean_composed) ** 2 for r in route_scores) / (n - 1)
)
路线集只是全部可能路线的一个样本, 校正的是用样本均值代替总体均值引入的偏差。注意这个 std 度量的是路线间的难度差异(离散度),不是同一路线重跑的噪声——后者要靠多 seed 重跑同一路线才能估计。MySim 的 M3 实验正是靠重跑发现:SimLingo 的逐路线重跑噪声高达 12.91 分,由个别路线的”卡死↔完成”翻转主导,这种噪声在单次评测的 std 里完全不可见。