做轨迹预测评估这些年,我越发觉得,leaderboard 上那个光鲜的 ADE 数字,有时候更像一种安慰剂。模型报到 0.3、0.4,名次好看,但一跑到高架出口、交叉口左转、大车切入这类场景,行为就像刚拿驾照的新手——该刹车的时候犹豫,该让行的时候硬挤。问题不一定出在模型身上,更多时候是我们拿来"量"它的那把尺子钝了。这篇内容想聊的就是一把更靠谱的尺子:场景驱动评估(Scenario-Driven Evaluation)。
它不是要推翻 ADE、FDE 这些传统指标,而是换一种统计视角,把"平均成绩"拆成一本"场景账本",告诉你在哪类场景下模型真的可靠、在哪类场景下它在裸泳。下面这些内容,都是我在实际项目中踩过坑、改过版、最后沉淀下来的经验。
1. 先搞清楚:我们到底在测什么
1.1 ADE/FDE 的数学本质与视觉错觉
自动驾驶里评估轨迹预测器,最常见的两个指标就是 ADE(Average Displacement Error)和 FDE(Final Displacement Error)。从数学形式上并不复杂:
- ADE:预测轨迹与真值轨迹在每一时刻的平均点误差,公式可以写成
[ ADE = \frac{1}{N} \sum_{i=1}^{N} \frac{1}{T} \sum_{t=1}^{T} \left| \hat{p}_i(t) - p_i(t) \right|_2 ]
- FDE:只看终点时刻 T 的误差
[ FDE = \frac{1}{N} \sum_{i=1}^{N} \left| \hat{p}_i(T) - p_i(T) \right|_2 ]
这套指标本身没有错,错的是我们使用它的方式。它会带来一个很隐蔽的视觉错觉:数字越小,模型越好。但"所有样本的平均误差小",完全不能等价于"在关键场景下模型安全"。
举个我做过的对比测试。同样的预测模型,在直行跟车场景里 ADE 能做到 0.12 米,几乎贴着真值走;但换成一次突然的 cut-in 场景,车辆要在极短时间内重新规划目标车道,同一个模型的 ADE 直接飙到 1.6 米,而且预测轨迹有两次明显的方向跳变。两者平均之后,ADE 可能仍然在 0.35 左右,"看起来还不错",可这正是问题所在——平均过程把最危险的现象稀释掉了。
1.2 平均指标掩盖了什么:一个具体的失败案例
我在某次版本迭代中遇到过一件特别典型的事。两个候选模型,模型 A 和模型 B,在同一个验证集上的全局 ADE 几乎一致,A 是 0.321,B 是 0.325,肉眼几乎看不出差别。如果按传统评估流程,工程师很可能直接选 A 上线,因为在 leaderboard 上 A 排名更高。
但当我把验证集按道路拓扑和交互对象拆开统计后,分布完全不同:
| 场景子集 | 样本占比 | 模型A ADE | 模型B ADE |
|---|---|---|---|
| 高速巡航 | 55% | 0.08 | 0.11 |
| 城市路口直行 | 20% | 0.25 | 0.24 |
| 交叉口左转 | 15% | 0.43 | 0.31 |
| 匝道汇入 | 7% | 0.58 | 0.36 |
| 公交站台附近 | 3% | 1.22 | 0.52 |
模型 A 之所以全局分数好看,是因为它在占比最大的高速巡航场景里优势明显,可到了公交站台、匝道汇入这类安全敏感场景,它的表现是灾难级的。如果只看全局 ADE,这个问题根本不会被发现;一旦落地到包含公交站台的城市道路,模型 A 可能频繁触发舒适性制动,甚至出现误判。
这就是我想强调的第一件事:评估粒度决定你发现问题的能力。全局平均指标是"体检报告上的总胆固醇",只能告诉你偏高还是偏低,却说不清到底是哪一环出了问题。
2. 场景驱动评估框架的设计思路
2.1 场景驱动评估的核心主张
场景驱动评估的基本主张可以用一句话概括:先把测试集切成有意义的场景片段,再在每个片段内单独计算指标,最后按安全重要度加权汇总。
为什么要切片?因为轨迹预测模型本质上是"输入历史状态,输出未来分布"的条件模型,它对不同上下文的条件化能力差异很大。一个模型可能对"道路跟着直走"非常自信,对"旁边有一辆大车正在压线靠近"却无能为力。把这两类样本放在同一个统计池子里,等于把模型的能力缺陷平均掉了。
切片的粒度也不是越细越好。按照我在项目里的经验,场景定义至少要满足两个条件:
- 语义上可解释。每一类场景都能说清楚"为什么是重要的",比如"雨天高架出口"、"无保护左转"、"行人近距离穿越"。
- 统计上可计算。每个场景类别在测试集里要有足够样本量,否则单独指标没有置信度。
如果切得过细,每类样本只有十几个,算出来的 ADE 波动会非常大,反而失去参考价值。我们后来定的原则是:每个场景类别在验证集中至少有 50 个预测片段,否则就合并到"其他交互类"。
2.2 场景目录怎么建:规则分类与数据驱动聚类
场景目录的构建,行业内大致有两条技术路线:规则分类和数据驱动聚类。
规则分类是"老派但可靠"的办法。工程师根据地图拓扑、动态障碍物状态、天气与光照、道路属性这些字段,用 if-else 去划分场景类型。比如:
- 如果自车道所在 link 的 curvature 变化率超过阈值,就归为"弯道";
- 如果车辆所在区域与道路 boundary 距离小于 1 米且相对横摆角大于 10 度,可能标记为"车道紧急修正";
- 如果目标障碍物是行人且距离小于 5 米,则归为"近距离 VRU 交互"。
这种方法的优点是逻辑清晰、可审计,出问题的时候能顺着规则直接溯源;缺点是规则之间容易重叠,同一个样本可能同时满足"匝道汇入"和"变道超车"的分类条件,需要人工定义优先级。
数据驱动聚类正好相反。你可以把每个预测时刻周围的环境编码成向量,例如使用自车位置、速度、加速度、周围障碍物的结构化状态,甚至拉一个已经训练好的场景编码器,然后用 k-means 或 HDBSCAN 聚类,再给每个簇人工命名。这种方法的优点是能自动发现一些没被规则定义过的长尾场景,比如"前车两秒内连续三次点刹"这种微妙模式;缺点是聚类结果不稳定,语料变化后簇的分布会漂移,命名也非常依赖人工判读。
我在实践中的做法是两者结合:先用规则分类作为主骨架,保证每一类场景都稳定可靠;再用聚类结果作为"场景雷达",不定期发现潜在的新类型,人工审核后补充进规则库。这样既有可解释性,又能不断扩展场景覆盖。
2.3 赋权策略:不是所有场景都该被一视同仁
场景驱动评估的第二步是"加权"。加权逻辑很简单:不同场景对安全的影响等级不同,统计损失时不能简单平均。
我们的默认权重由三部分乘出来:
- 风险等级 R:由安全团队给每个场景打 1 到 5 分,5 分代表存在高概率碰撞风险或已发生碰撞判定。
- 逆频率系数 I:频率越低的场景权重越高,避免长尾场景被大头样本淹没。具体可以取[ I_c = \log\left(\frac{N}{\ n_c}\right) ]再归一化。
- 任务优先级 T:如果近期版本重点要解决"路口左转"的缺陷,那这个场景的临时权重可以上调。
最终评估分数是:
[ E_{总} = \frac{\sum_{c} R_c \cdot I_c \cdot T_c \cdot E_c}{\sum_{c} R_c \cdot I_c \cdot T_c} ]
这样算出来的总分,和传统全局指标比,会更贴近"安全关键场景的实际表现"。它的另一层价值在于:当我们向管理层或合作方汇报模型能力时,可以直接给出"在风险等级 4 以上的场景,FDE 平均是 0.57 米,其中三个版本下降了 22%"这类有业务含义的数字,比一个冷冰冰的全局 ADE 更有说服力。
3. 实操:搭建一套可复用的场景驱动评估流水线
3.1 数据准备与场景标签构建
以我在 Waymo Open Motion Dataset 和 nuScenes 上的实践为例。这两套数据都有比较丰富的语义标注,特别是 Waymo 的 sdc_scenario 结构里直接带有场景类型标签,可以直接作为起步。
我会先做一步"场景标签扩展":把原始标签、地图结构化信息和动态目标状态拼成一张宽表,每一行是"一个预测目标的一个历史片段",列包括:
- scenario_id、object_id、预测时刻
- scenario_type(原始标签或自定义规则结果)
- 自车与目标车的速度、加速度、转向角
- 最近障碍物距离、相对速度
- 所在道路曲率、限速、车道数
- 是否包含行人、自行车
- 天气与光照(如果数据里有)
- 人工标注的风险等级
这一步其实最花时间。我的建议是先把"场景标签判定逻辑"写成独立模块,输出一份 CSV,再做评估时直接 join,不要每跑一次实验都重新生成一遍标签。标签不稳定是评估结果反复横跳的最大来源之一。
3.2 分场景指标的代码实现
下面这个代码片段是我项目里的一个简化版评估器,核心功能是输入预测结果、真值轨迹和场景标签,输出每个场景的 ADE/FDE、样本数和置信区间。
import numpy as np from scipy import stats class ScenarioEvaluator: def __init__(self, scenario_names, weights=None): self.scenario_names = scenario_names self.weights = weights or {} @staticmethod def mean_ci(data, confidence=0.95): data = np.asarray(data, dtype=np.float64) n = len(data) if n < 2: return float(np.mean(data)), 0.0 mean = np.mean(data) se = np.std(data, ddof=1) / np.sqrt(n) h = se * stats.t.ppf((1 + confidence) / 2, n - 1) return float(mean), float(h) def _compute_ade_fde(self, preds, gts, horizon=1.0): ade_list, fde_list = [], [] for pred, gt in zip(preds, gts): err = np.linalg.norm(pred - gt, axis=-1) ade_list.append(np.mean(err)) fde_list.append(err[-1]) return np.array(ade_list), np.array(fde_list) def evaluate(self, preds, gts, scenario_ids): results = {} ade_all, fde_all = [], [] for sid in self.scenario_names: mask = (scenario_ids == sid) count = int(mask.sum()) if count == 0: results[sid] = {"count": 0} continue ade_arr, fde_arr = self._compute_ade_fde( preds[mask], gts[mask] ) ade_mean, ade_ci = self.mean_ci(ade_arr) fde_mean, fde_ci = self.mean_ci(fde_arr) results[sid] = { "count": count, "ADE": ade_mean, "ADE_ci": ade_ci, "FDE": fde_mean, "FDE_ci": fde_ci, } ade_all.extend(ade_arr) fde_all.extend(fde_arr) # 全局值仍然保留,用于对照 results["global"] = { "count": len(ade_all), "ADE": float(np.mean(ade_all)), "FDE": float(np.mean(fde_all)), } return results这段代码的逻辑很直接:通过布尔掩码把每个场景类别下的样本筛出来,分别计算误差和置信区间。实际工程中,我还会把结果封装成 DataFrame,方便下一步绘图和对比多个模型版本。
需要特别提醒:预测轨迹长度和采样间隔必须一致,否则 ADE 和 FDE 在不同模型之间没有可比性。我遇到过不少团队在评估脚本里把 6 秒轨迹和 3 秒轨迹混在一起算,最后得到的"提升"其实是长度差异造成的假象。
3.3 安全关键场景的专项评估
分场景指标只是第一步。真正让这套评估产生决策价值的,是"安全关键场景专项评估"。这类场景我会单独定义一份清单,不参与全局加权,而是作为硬性门禁条件。
比如我心中的安全关键场景至少包括四类:
- 切入博弈(cut-in):旁车道目标在预测时段内跨越车道线并自车前方形成冲突点。
- 无保护左转(unprotected left turn):对向直行车流持续,左转车辆需要判断可穿越间隙。
- 弱势道路使用者交互(VRU interaction):行人或骑行者位于自车轨迹走廊内,距离小于 3 米。
- 拥堵收窄(congestion merge):多车道汇入一车道,存在频繁加减速和礼让博弈。
对这四类场景,评估标准不能只看 ADE,我会额外关注两个"行为合理性"指标:碰撞距离余量(distance margin)和百公里期望危险事件数(expected hazard rate)。前者计算预测轨迹与目标物未来最近距离的中位数,后者统计预测轨迹与真值差异导致危险事件的比例。只有当场景样本量和置信区间都达标,模型版本才被允许进入下一轮实车测试。
用这种方式做专项评估之后,我们内部对"模型是否 Ready"的讨论明确了很多。以前是"A 版本比 B 版本分数低 0.02,A 上",现在变成"A 版本在 cut-in 场景的危险事件率是 3.1‰,B 版本是 1.7‰,A 不能上"。这两个讨论的性质完全不一样。
3.4 结果可视化与分析
评估结果一定要可视化。别小看这一步,一张好的"场景 x 模型"热力图,往往比十页指标表格更能说服协作团队。
我常用的做法是画一张矩阵热力图:
- 行:场景类别,按风险等级从低到高排序;
- 列:模型版本(比如 v1.0、v1.1、v2.0);
- 单元格颜色:归一化后的 FDE 或危险事件率。
推荐用 matplotlib 的 imshow 或 seaborn 的 heatmap 都能快速实现。每周跑完评估后直接把这张图挂在 CI 展示墙上,工程师一眼就能看出新版本到底改善了哪个场景、又在哪里退步了。
可视化还有一个隐藏价值:它能帮我们跟数据团队对齐"场景定义是否合理"。如果某个场景类别的直方图非常稀疏,就说明场景标签太严格或样本采集不足;如果某个类别的误差分布呈现双峰,说明这一类内部还混着两种不同性质的子场景,需要再往下拆。
4. 踩坑实录与常见问题排查
4.1 场景定义漂移:同一场景在不同实验下不一致
这是我最先踩到的坑。第一版评估脚本里,场景标签写在评估函数内部,每迭代一次代码,标签判定逻辑就跟着改一点。结果同一个模型在两个 commit 下的"intersection"场景 ADE 一次是 0.31,一次是 0.26,我一开始还以为是模型优化有效,后来发现只是标签判断条件变了。
解决办法只有一个:把场景目录与标签定义固定成独立的配置文件,纳入版本管理。任何想调整场景定义的动作,都必须走正式变更流程,并在评估报告里标明 taxonomy_version。只有这样,跨版本、跨模型的横向对比才有意义。
类似的坑还包括:不同线程里加载数据顺序不一致导致场景 ID 错位;真值轨迹坐标没有统一到同一坐标系;预测输出的频率和真值采样频率不一致。这些我都会在评估流程里加入显式的 assert 检查,宁可报错也不让脏数据悄悄通过。
4.2 长尾场景样本太少怎么统计
场景驱动评估做深之后,一定会遇到长尾场景样本不足的问题。比如"夜间横穿马路的行人"这类高风险场景,整个验证集里可能就只有 20 来个片段,算出来的 FDE 置信区间非常大,没法和其他场景并列比较。
我处理这种情况有三个策略:
- 只报告不排名。样本量少于 30 的场景只展示平均值和样本数,不参与模型间排名,免得被噪声误导。
- Bootstrap 重采样。对每个长尾场景做 1000 次有放回抽样,输出误差分布的分位数,而不是单点均值。
- 案例库沉淀。把表现差的典型样本做成固定回归测试集,虽然数量少,但每个都经过人工确认,专门用来捕捉模型在特定长尾场景的退化。
这三个策略可以组合使用。不要因为"样本太少"就直接放弃长尾场景的评估,它们往往恰恰是安全性差异最大的地方。
4.3 权重设定的敏感性与公平对比
权重策略引入后,又一个现实问题出现了:同一个模型,在权重方案 A 下排名第一,在权重方案 B 下却排到第三。怎么办?权重本质上是评估者的价值判断,没有标准答案,但我们可以让判断过程更透明。
我每次发评估报告时,会附带一个简单的敏感性分析表格,展示权重在正负 30% 范围内波动时,模型总排名的变化。如果排名对权重非常敏感,说明两个模型的优势场景正好错开,这时候不能只看总分,而要看"安全关键场景专项结果"谁更好。如果排名对权重不敏感,那说明排名结果比较稳健,可以放心使用。
| 常见现象 | 可能原因 | 解决方式 |
|---|---|---|
| 同一模型两次实验分数波动大 | 场景标签判定逻辑变化 | 冻结 taxonomy 配置文件,纳入版本管理 |
| 长尾场景指标时高时低 | 样本量不足,噪声主导 | 使用置信区间或 bootstrap 分位数,样本少于 30 只报告不排名 |
| 加权总分与全局 ADE 排名矛盾 | 不同模型优势场景错开 | 披露权重来源与敏感性分析,参考安全专项结果 |
| 两个模型 FDE 接近但安全表现差异大 | 平均指标掩盖极端异常 | 增加碰撞距离余量和危险事件率等行为指标 |
| 可视化分布出现双峰 | 场景内部混有未拆分子类型 | 继续拆分场景或重新定义边界 |
4.4 关于这套方法,我的一些实话
用场景驱动评估将近一年后,我的总体感受是:它把"模型好不好"这个问题,从模糊的玄学变成了可以讨论的工程问题。
但我也要泼一盆冷水。场景驱动评估不是银弹,它解决的是"测量粒度"的问题,不能解决"测试分布本身不够"的问题。如果验证集里本身就没有暴雨、没有故障车、没有宠物突然窜出,那再精细的评估也无济于事,该补数据还是要补数据。
另外,也不要一开始就追求大而全的场景目录。我见过一个团队第一版就定了 40 多个场景类别,结果每个类别样本都稀稀拉拉,统计结果完全没法用。更好的节奏是:先定 8-10 个你最关心的高风险场景,跑通流程,让评估结果真正影响一次模型版本决策,再逐步扩展目录。少而准,好过多而乱。
最后分享一个实操中的小技巧:在评估流水线里保留原始预测 json 和场景标签的 join 字段,比如 scenario_id 和 object_id。这个习惯看似平凡,却能让很多事后分析变得可能——当你发现某个版本在某个场景下退步时,能直接溯源到具体预测样本,而不是面对一堆聚合后无法拆解的数字。真正做到"测量什么,就改进什么"。