简介:VectorNet是清华MARS Lab与Google Waymo联合在CVPR 2020提出的自动驾驶轨迹预测模型,核心思路是将高精地图与智能体轨迹转化为向量化表示,再借助Transformer与图神经网络建模道路元素间的全局交互,避免传统鸟瞰图渲染的精度损失并显著降低计算开销。论文同时设计了随机屏蔽地图实体与智能体轨迹的辅助任务,以强化上下文特征学习;在Argoverse等公开数据集上,该方法以不到三成的参数量取得与栅格化方案相当甚至更优的结果,并刷新当时的最高水准。资源包共1个PDF文件,大小583KB,即该论文完整原文,适合正在学习Transformer应用、自动驾驶行为预测或需要撰写相关文献综述的开发者与研究人员直接研读。目前已有1421人学习下载,对想把握顶会原始思路、复现实验细节的读者来说是一份轻量高效的参考资料。
1. 为什么轨迹预测要换成 Transformer:VectorNet 的瓶颈与改写思路
VectorNet 基于 Transformer 的轨迹预测模型,第一眼看上去像是一个图网络和注意力机制的杂交方案,用来解决自动驾驶里“预测周围目标未来 3 到 5 秒走哪条路”的问题。实际上我调完之后最明显的收益不是单点精度指标降了多少,而是长尾场景的召回率变高了,同一个路口左转待转区的车流预测终于不是一股脑往前冲。它解决的是原始 VectorNet 方案里消息传递层数受限、全局交互弱的问题:自动驾驶预测对象往往要跟十几条车道线、几十辆周围车同时打跨距离的关联,传统图网络需要堆叠多层才能让远端信息传到目标车,而 Transformer 用一步 attention 就能建立跨空间的全局联系。
这个方向适合正在做 L2+ 或 L3 预测模块的算法工程组,你想在开源基准数据集或自采数据上复现一个支持多模态输出的预测网络,同时不想维护一套复杂的图构建流程。也适合原本已经跑通 VectorNet 基线、但在连续左转、快速切入等场景上召回不够的团队,用 Transformer 替换核心编码器后,历史轨迹和地图元素的直接 attention 会让模型看得更远。下面先从矢量 token 化讲起,然后给出一整套可运行的训练代码块和参数设定,最后是我在实际数据上踩出来的五个翻车点,以及一个能加速解码的落地技巧。
2. 从 VectorNet 范式到 Transformer:先把高精地图变成 Token
2.1 矢量编码:把车道线和历史轨迹统一成折线节点
VectorNet 的核心假设是,不管是高精地图里的车道中心线、停止线、人行横道,还是车辆过去两秒的历史轨迹,本质上都是一条折线。折线由若干向量段组成,每个向量段用一个多维向量表达,比如起点坐标、方向向量的长度、朝向角、元素类型、是否为参与者等。这样做的好处是:不用再把地图栅格化成鸟瞰图,也就不会因为栅格分辨率丢掉小数点级的相对位置信息;同一个 token 既能表示静态车道线,也能表示动态目标历史轨迹,模型不需要分别设计两个编码器。
我一般会先把一条折线按固定弧长切段。车道线分成 1.5 米一段,历史轨迹按照采样频率切,比如 0.1 秒一个点,相邻两个点之间的位移就是一个向量段。每个段构造出一个 token,然后对这个 token 做线性归一化嵌入。下面这段代码构造了一个最基础的场景 token:
import torch import torch.nn as nn def build_scene_tokens_from_polylines(polylines, node_types, center, theta): """ polylines: list of [T, 2] 每条折线的全局坐标点 node_types: list of int,折线类型,例如 lane=1, history=2, crosswalk=3 center: 目标车当前位置 [2] theta: 目标车当前航向角,用来把场景旋转到以目标车为正方向 """ cos_t, sin_t = torch.cos(theta), torch.sin(theta) rot = torch.tensor([[cos_t, -sin_t], [sin_t, cos_t]]) tokens = [] masks = [] for i, pts in enumerate(polylines): # 全部坐标转成以目标车为原点的相对坐标 pts_rel = (pts - center) @ rot.T # 相邻点构成向量段 [T-1, 2] seg_start = pts_rel[:-1] seg_vec = pts_rel[1:] - pts_rel[:-1] seg_len = torch.norm(seg_vec, dim=-1, keepdim=True) seg_ang = torch.atan2(seg_vec[..., 1], seg_vec[..., 0]).unsqueeze(-1) type_emb = torch.full((seg_start.size(0), 1), node_types[i], dtype=torch.float) agent_flag = torch.full((seg_start.size(0), 1), float(node_types[i] == 2), dtype=torch.float) seg_feat = torch.cat([seg_start, seg_vec, seg_len, seg_ang, type_emb, agent_flag], dim=-1) tokens.append(seg_feat) masks.append(torch.ones(seg_feat.size(0), dtype=torch.bool)) tokens_cat = torch.cat(tokens, dim=0) # [N, 8] masks_cat = torch.cat(masks, dim=0) # [N] return tokens_cat, masks_cat这里的关键是seg_start用的是相对坐标,而不是全局坐标。原因有两个:一是 attention 计算对绝对位置不敏感,但轨迹预测需要目标车自身的坐标一致性,相对坐标可以把所有 token 拉到同一个原点,模型不用额外学坐标平移不变性;二是seg_vec保留的是方向和步长,后续 Transformer 可以直接从 token 里读取“这段车道线朝哪走、走了多远”。agent_flag是为了让模型区分历史轨迹和地图线,二者在 attention 里的角色完全不同,地图是静态的上下文,历史轨迹才是真正的输入信号。
2.2 三种 Transformer 化路线:纯Transformer编码、GNN+Transformer混合、Transformer解码器
拿到折线 token 之后,下一步关键是决定 Transformer 插在哪个位置。我在实际项目里梳理出三条路线,各有适用场景。
| 路线 | 结构 | 优点 | 代价 | 我推荐的使用场景 |
|---|---|---|---|---|
| A 纯Transformer编码 | 折线token直接进入编码器,编码结果送入MLP回归轨迹 | 实现最简,端到端一把梭,无图结构维护负担 | 注意力要自己学会局部几何,收敛慢,token多时显存大 | 数据量在百万级以下时慎用,容易过拟合 |
| B GNN+Transformer混合 | 先用一两层GNN做邻域聚合,再和Transformer编码器串联/并联 | 保留VectorNet几何先验,又获得全局attention,效果好 | 两套编码逻辑,工程上要维护两套mask | 数据量大、业务场景复杂的L2+预测,我最终选了B |
| C Transformer解码器后置 | 用编码器输出作为context,在goal集或固定轨迹query上回归多模态 | 多模态解码并行,一次前向出K条轨迹,速度可控 | 需要额外设计目标集query,否则多模态坍塌 | 已跑通A或B,想要提升推理速度时再引入 |
选型上我吃过亏:刚开始直接上路线 A,在开源数据集上指标并不难看,但换到自家路采数据上,无保护左转场景的预测轨迹总是过于保守,原因正是缺少增强局部递归的向量图先验。后来改成路线 B,在编码器前面加一层 GNN 做邻域消息传递,相当于显式告诉模型“相邻车道线之间先交换一次信息”,后面 Transformer 再捕捉跨路口的全局交互,这才把左转场景的 miss rate 降下来。路线 C 更适合已经有一个可用编码器后,你想把原来的自回归解码换成并行解码的场景。
这个选择会直接影响后面的输入张量设计,因为如果采用路线 B,你在构造输入时不能只给 Transformer 一个 token 序列,还要给出 token 之间的邻接关系,用于 GNN 聚合。最简单的方式是给每个折线内部的 token 建自连接,同时用同一场景内距离在 10 米以内的 token 两两建边。这样 GNN 层数不用深,一层就够,剩下的全局关系交给 Transformer。
2.3 输入张量与特征维度:你得分清全局坐标系和目标中心坐标系
很多第一次实现 Transformer 轨迹预测的人容易忽略坐标系选择对结果的影响。同一个车道线 token,用全局坐标喂进去,模型要额外消耗容量学习全局坐标的偏移,而且当场景切换到另一个城市时,坐标偏移分布变了,精度可能直接崩盘。我这边固定使用目标中心坐标系,所有地图和历史轨迹都以被预测目标当前时刻的位置为原点,以目标当前航向为 x 轴方向。
具体到特征维度,我按照下面这张表来组 token,每个 token 的输入特征定为 16 维,其中前 6 维是几何信息,第 7 维是长度,第 8 维是角度,第 9 维是类型,第 10 维是是否为目标车,剩 6 维是目标车自身的基础属性嵌入(比如当前车速、加速度、航向角变化率)通过线性层投影过来。你不需要完全照搬,但维度必须固定,否则 Positional Encoding 加不进去。
| 特征 | 维度 | 说明 |
|---|---|---|
| seg_start | 2 | 向量段起点坐标(目标中心坐标系) |
| seg_vec | 2 | 向量段位移(含方向与步长) |
| seg_len | 1 | 向量段长度,归一化到0-1 |
| seg_ang | 1 | 向量段朝向,归一化到[-1,1] |
| node_type | 1 | 元素类型id,做embedding后拼接 |
| is_agent | 1 | 1表示这是被预测目标的历史轨迹 |
| agent_feat | 6 | 速度、加速度等动态特征 |
| raw_global_x | 1 | 目标中心坐标系下原始x,给回归头保留绝对位置线索 |
| raw_global_y | 1 | 目标中心坐标系下原始y |
这里没有把全局坐标直接给 Transformer,但保留了原始量级的两个坐标位作为回归头的输入特征。因为 Transformer 内部用相对坐标更容易学到交互,但最后预测未来轨迹时,回归头需要知道目标车当前绝对位置在哪,所以我会在编码后把这些原始坐标拼进 decoder 的输入里。注意这里的原始坐标也是以目标车为原点,只是不再归一化到 0~1,避免回归时丢失小数精度。
3. 训练一个基于Transformer的轨迹预测模型:完整数据管线与核心代码
3.1 数据预处理:从原始日志生成 Scene Tensor
训练 Transformer 轨迹预测,最难的不是模型定义,而是把一条路采日志切成一个个以被预测目标为中心的 sample。每一帧日志里可能有几百个目标,但只有那些存活时间足够长的目标才适合当预测主角。我一般的做法是:取目标车当前时刻之前的 2 秒(共20帧,10Hz)作为历史,预测未来 3 秒(共30个轨迹点,10Hz)。地图数据取以目标车为中心前后 100 米、左右 50 米范围内的车道线折线。注意一定要在预处理阶段就把坐标相对化并存储,不要到训练时再做,否则每个 epoch 重复计算,数据加载会慢 3 倍以上。
下面这段代码展示如何把一个 sample 组织成训练可直接读取的 dict:
def export_sample_to_tensor(sample): # sample 已经包含目标历史、目标未来、地图折线 hist = torch.tensor(sample['target_history'], dtype=torch.float32) # [20, 2] fut = torch.tensor(sample['target_future'], dtype=torch.float32) # [30, 2] map_lines = [torch.tensor(line, dtype=torch.float32) for line in sample['map_polylines']] all_polys = [hist] + map_lines node_types = [2] + [1] * len(map_lines) center = hist[-1] theta = torch.atan2(hist[-1, 1] - hist[-2, 1], hist[-1, 0] - hist[-2, 0]) tokens, mask = build_scene_tokens_from_polylines(all_polys, node_types, center, theta) # 动态特征:目标速度、加速度(用前几帧计算) vel = hist[-1] - hist[-2] acc = (hist[-1] - 2 * hist[-2] + hist[-3]) dynamic_feat = torch.cat([vel, acc, hist[-1]], dim=-1) # 未来轨迹也转成目标中心坐标系 fut_rel = (fut - center) @ torch.tensor([[torch.cos(theta), -torch.sin(theta)], [torch.sin(theta), torch.cos(theta)]]).T return {'tokens': tokens, 'mask': mask, 'dynamic_feat': dynamic_feat, 'gt_future': fut_rel, 'center': center, 'theta': theta}这里有一个易漏的点:未来轨迹gt_future也要转换到目标中心坐标系,并且存储时要用绝对坐标还是相对位移。我这里直接存了绝对坐标,因为在计算损失时,模型输出的也是该坐标系下的绝对未来点,二者对齐。如果你选择输出位移量,那损失计算时要把 GT 转成相对位移。两条路都可以,但一定要保持一致。dynamic_feat我用了三帧差值计算速度和加速度,虽然简单,但实际效果和用车速总线信号接近,如果你有更准确的轮速和方向盘转角,可以替换掉。
3.2 模型构建:一个能落地的Transformer预测头
整体模型我拆成四个部分:token 感知层、折线内局部注意力层、Transformer 编码器、多模态解码头。这里的核心是 Transformer 编码器,它把 token 序列做成定长 batch,并依靠 attention mask 屏蔽 padding 部分。为了避免不同样本 token 长度差异过大,我会在预处理时将每个 sample 的 token 限制在 256 个以内,超出部分按折线长度采样保留;不足部分补零,并记录真实的 token 数量。代码可以这样写:
import torch.nn as nn import torch.nn.functional as F class TrajectoryTransformer(nn.Module): def __init__(self, d_model=128, nhead=8, num_layers=6, d_ff=256, max_tokens=256, num_modes=6): super().__init__() self.token_embed = nn.Linear(16, d_model, bias=False) self.pos_embed = nn.Parameter(torch.zeros(1, max_tokens, d_model)) self.encoder_layers = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=d_ff, dropout=0.1, batch_first=True, norm_first=True ) self.encoder = nn.TransformerEncoder(self.encoder_layers, num_layers) self.goal_embed = nn.Parameter(torch.randn(1, num_modes, d_model) * 0.02) self.decoder_mlp = nn.Sequential( nn.Linear(d_model * 2 + 6, 128), nn.ReLU(), nn.Linear(128, 60) # 直接回归 30 个未来点的 x,y ) def forward(self, tokens, mask, dynamic_feat): # tokens: [B, N, 16], mask: [B, N] N = tokens.size(1) x = self.token_embed(tokens) + self.pos_embed[:, :N, :] # 传给Transformer的key_padding_mask为True表示忽略该token enc = self.encoder(x, src_key_padding_mask=~mask) # [B, N, d_model] # 目标车token在序列里是第一个(type=2),取它的编码 target_feat = enc[:, 0:1, :] # [B, 1, d_model] # 广播到num_modes个query上 target_feat = target_feat.expand(-1, self.num_modes, -1) query = self.goal_embed + target_feat # 拼接动态特征 dyn = dynamic_feat.unsqueeze(1).expand(-1, self.num_modes, -1) # [B, K, 6] out = self.decoder_mlp(torch.cat([query, dyn], dim=-1)) # [B, K, 60] out = out.view(-1, self.num_modes, 30, 2) return out注意我并没有给编码器加 segment 之间的位置编码,这里用的是简单的可学习绝对位置编码。折线内部 token 的相对顺序很重要,但折线之间的顺序没有物理意义,所以更好的做法是分别给每段折线做局部位置编码,再和全局 token 的序数编码叠加。我为了代码简洁没有展开,落地时可以把pos_embed换成LanePositionalEncoding,在折线内部按顺序编码,折线之间共用同一组位置,这样模型的泛化性会更好。
3.3 训练与损失函数:回归损失+分类目标怎么配
模型输出 K 条轨迹和对应的置信度分数。常见做法是给每条轨迹分配一个学习出来的分数,然后对整个 batch 的 K 条轨迹做 softmax 归一化,用分类损失学轨迹的选取,用回归损失学最接近真值的那条轨迹。这里有个很关键的坑:如果不做 winner-take-all 的轨迹匹配,而是让所有 K 条轨迹都往真值上回归,最终所有轨迹会缩到同一个平均值上,多模态直接消失。所以回归损失只回传到最接近真值的轨迹上,其他轨迹的梯度不参与位置回归,只保留分类分支的梯度。
def loss_fn(pred_trajs, pred_scores, gt_future, mode='minade'): # pred_trajs: [B, K, T, 2], pred_scores: [B, K] # gt_future: [B, T, 2] B, K, T, _ = pred_trajs.shape # 计算每条轨迹与真值的 L1/FDE 距离 fde = torch.norm(pred_trajs[:, :, -1] - gt_future[:, None, -1], dim=-1) # [B,K] ade = torch.mean(torch.norm(pred_trajs - gt_future[:, None], dim=-1), dim=-1) if mode == 'minfde': # 选择FDE最小的轨迹做回归 sel = fde.argmin(dim=-1) # [B] else: # minade sel = ade.argmin(dim=-1) idx = torch.arange(B).to(gt_future.device) # 回归损失,用 smooth L1 reg_loss = F.smooth_l1_loss(pred_trajs[idx, sel], gt_future) # 分类损失:把最接近真值的那条轨迹当正样本,其余当负样本,用交叉熵 label_onehot = F.one_hot(sel, num_classes=K).float() # [B,K] cls_loss = F.binary_cross_entropy_with_logits(pred_scores, label_onehot) return reg_loss + 0.01 * cls_losscls_loss的权重我一般给 0.01,因为轨迹匹配是用 FDE/ADE 硬分配的,分类分支只是为了让模型学会在几个候选里选一个,权重太大会让模型把注意力全放在分类而忽略几何质量。如果你想要输出带概率的轨迹(比如用于后续规划器的概率计算),可以把pred_scores换成正常的 logits,在推理时接 softmax。但即便是 softmax 概率,也只能作为轨迹之间的相对排名,真实场景中概率校准还需要加一个 temperature 项,我一般设 1.5。
4. 基于 Transformer 的轨迹预测:必调的 5 个超参数与评估指标
4.1 五个必调超参数:从 max_tokens 到坐标归一化
Transformer 在轨迹预测任务上的敏感参数和常见自然语言任务不完全一样,因为输入 token 是稀疏的矢量,无法用固定批量长度统一表示。下面这张表是我在多轮实验里试出的一组稳定初始值,建议直接作为 baseline 起点。
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| max_tokens | 256 | 每个sample最多保留256个token,超过会丢细节,不足会欠表达;显存允许可以提到512 |
| d_model | 128 | token embedding维度,太小提不出局部特征,太大容易过拟合,128~192是甜点区 |
| num_layers | 4~6 | 编码器层数,我常用6,但实际超过4层提升有限,反而推理变慢 |
| warmup_steps | 5000~10000 | 必须加,Transformer初始学习率过大会让attention权重震荡 |
| num_modes | 6 | 多模态轨迹数,6是个折中值,交叉路口场景可以加到10,普通高速3够了 |
第一个要调的是max_tokens。你如果只看 Attention 矩阵占用显存,会误以为 512 token 也没问题,但实际数据里一个目标周围地图折线切成线段后很容易到 600+,强行截断会丢掉远景关键车道线。我建议在输入阶段先对折线做路径点采样,相邻折线距离太近的只保留一条,控制到 256 之内,而不是简单截断尾部。
第二个是num_layers。在自家数据上我把 6 层降为 4 层,minADE 几乎没有升高,但推理 p99 延迟少了 30%。如果你要上车部署,先用 4 层跑通,再实验 6 层,别一上来就堆层数。Transformer 编码器不是越深越好,轨迹预测输入里有效信息密度远低于自然语言,过深的 Transformer 会把高频噪声也当成有效特征。
第三个最关键的是warmup_steps。我遇到过不用 warmup 时 loss 下降到一定值后突然向上升,指标一会儿好一会儿差,后来排查发现是学习率调度让 attention 在初始阶段梯度方向频繁变化。解决办法是在学习率上挂一个线性 warmup,前 5000 步从 1e-6 慢慢升到 1e-3,后面再用余弦退火或固定衰减。
第四个是num_modes。这个数有点玄学,它和场景复杂度强相关。如果发现 6 条轨迹里一半都在原地抖动,说明回归损失没有充分拖拽距离真值最近的轨迹,可以尝试把 loss 里的回归权重提高,或减少模式数量。你还可以在验证集上看每条被选中的轨迹,如果某条轨迹从没被选过,说明该模式对应的 goal 位置学偏了,考虑重新初始化 goal embedding。
第五个是坐标归一化尺度。我在第2章提到相对坐标,但这里还有个细节:向量段长度seg_len必须除以一个固定值(我常用 30 米),否则不同场景下地图尺度差异巨大,Transformer 的 attention 对这点噪声非常敏感。你可以把它当成一个超参数调,范围在 20~50 之间,观察验证集的 minFDE 变化。
4.2 评估指标:minADE 是基础,但 miss rate 才决定上路
评估指标大家通常只看两条:minADE(多模态轨迹与真值的最小平均位移误差)和 minFDE(最小最终点误差),另外还要关注 miss rate,也就是最少有一条轨迹离真值末端小于某个阈值(比如 2 米)的样本占比。这里提醒一句:如果你只优化 minADE,模型会倾向于输出平均化轨迹,这对低速场景没问题,但高速变道场景非常致命。所以我在实际项目里会把 minFDE 和 miss rate 当成首要业务指标,minADE 只作为参考。
还有一种是可行驶区域符合率,用来衡量预测轨迹是否落在道路边界外。Transformer 模型如果只拟合历史数据,容易产生压制性地往车道中心线靠的轨迹,它在开环指标上很好看,但在闭环规划里会切到马路牙子。我在落地时会把后处理校验打开,把超出可行驶区域的轨迹直接置为无效,同时从训练集里把这些区域的 GT 样本也做一个 minor sampling,减少过拟合。评估时不要只看平均指标,要按场景分层去看:环岛、左转、连续变道、拥堵跟车,每个场景的 miss rate 都单独统计,这样你才能知道模型到底哪一类场景没学好。
5. 避坑指南:Transformer 化 VectorNet 的 5 个常见翻车点
5.1 现象:loss 不收敛,前 1000 步直接 NaN
我最早在自采数据上跑 Transformer 编码器时,训练到第几百步就出现 loss 为 NaN。后来逐层打印每层的输入输出,发现问题是输入 token 里出现了 Inf 值。这是因为向量段长度seg_len在折线切分时出现了重复点,导致某一段位移为 0,角度为 0,长度也是 0,之后又被除以标准差,造成数值不稳定。更常见的原因是坐标归一化时把目标车当前点作为原点,但历史轨迹里历史点可能和当前点重合,产生零向量。
解决方法是三管齐下:一是构造 token 前先去掉重复点,连续两个距离小于 1e-3 的点直接合并;二是所有几何特征进入网络前用torch.clamp限制范围;三是在 loss 里加一层梯度裁剪,norm 设为 5.0。前两个解决来源,梯度裁剪只是兜底,不要指望裁剪能修复脏数据。
5.2 现象:预测轨迹全部收敛到单一模式
现象是训练出来 6 条轨迹几乎叠在一起,变成一条直线,多模态失去了意义。我排查过很多次,最直接的原因是回归损失没有做 winner-take-all 匹配,而是直接把 K 条轨迹都往真值上回归。由于每个样本只有一个真值,模型最省力的方式就是把所有轨迹都预测成真值的均值,这样总的 L1 损失最小。另一个原因是num_modes设置过大,而训练数据里相同场景的样本模式并没有那么多,过多负样本把解码器推向平均。
解决:回归损失必须只回传到距离真值最近的轨迹,其他轨迹只参与分类损失。同时把num_modes从 10 降到 6,观察轨迹是否开始分化。如果还不行,可以给每条轨迹加一个可学习的目标偏移(goal offset),让每条轨迹在解码时有一个不同的起点输入,这能强制模式分离。
5.3 现象:推理速度比原版 VectorNet 慢了近 3 倍
Transformer 编码器虽然效果好,但是 token 数量一多,attention 的显存和耗时都明显上升。我在一块常见的车载计算平台上测试,原版图网络单帧推理 8ms,我把编码器换成 6 层 Transformer 后直接到 22ms。分析后发现瓶颈不是多头注意力本身,而是在 batch 内对 256 个 token 做了全对全 attention,而这 256 个 token 里有一大半是静态车道线,它们之间的交互在相邻帧之间变化不大。
解决思路是分步骤做:先用 GNN 聚合车道线内部和邻域信息,得到一个紧凑的 lane token 序列(比如把同一条车道线的多个向量段先平均成一个 token),再让 Transformer 只在这些 lane token 和少数动态目标 token 之间做 attention。这样 token 数能砍掉一半以上,注意力矩阵从 256×256 降到 128×128,推理时间压回 10ms 左右。这个方案的代价是丢失部分微观几何信息,但轨迹精度下降通常不到 0.1 米,可接受。
5.4 现象:换一个数据集精度反而下降
在开源数据集上跑了 40 个 epoch,指标不错,迁移到同一城市另一片区域的路采数据时,minFDE 直接掉了 0.3 米。我把两个场景的地图 token 分布画出来对比,发现开源数据集里停止线、人行横道这类元素很多,而我们新区域的数据以高速公路为主,车道线数量多但种类少。模型在训练时把过多的注意力分配给了高频出现的元素类型,迁移到新区域时 token 类型分布变了,attention 权重没有重定向。
解决:一是每种 map 元素类型单独做一个 embedding,而不是把所有类型混成一个 id;二是在训练时做地图元素类型的随机 drop,以一定概率把某类车道线 token 置为零,强制模型不能依赖单一类型。这样迁移时的稳定性会好很多。我一般用 0.1 的 dropout 概率,多了会影响训练收敛。
5.5 现象:注意力矩阵里车道线 token 的权重不停抖动
训练后期,我固定几个样本把每层的 attention map 可视化,发现某些车道线 token 的 attention 权重在相邻 epoch 之间波动很大。这不是模型没收敛,而是因为车道线 token 之间存在高度相似的几何特征,在 Transformer 中会产生很接近的 attention 分布,稍微一点参数更新就会造成权重在几条相似车道线之间跳变。这个现象在交叉路口尤其严重。
解决:在输入特征中给每条折线加一个唯一 ID 的 embedding,并把它作为 Transformer 的位置编码的一部分。这样即使视觉特征相似,token 的输入表示也不同,attention 就不再摇摆。注意这个 ID 只对地图元素有用,动态目标的历史轨迹不能用同一个稀疏 ID 编码,否则模型会“记住”训练集中的某辆车的轨迹。
6. 加速落地:用目标集(Goal Set)替换自回归解码
如果你已经跑通了上面的方案,下一步最值得做的落地优化是替换解码器。我最初用的是直接回归整条未来轨迹的方式,后来为了实现减速并降低在处理器端延迟,换成了目标集解码:预定义一组可学习的目标点,让 Transformer 编码器把预测目标的历史和地图特征压缩成 context vector,再用一组 goal query 并行解码出 K 条未来轨迹。每条轨迹以目标点为中心,回归出一小段局部修正,这样最终输出的轨迹更像是“从当前点到目标集合的平滑连接”,而不是从零预测整条曲线。
实现上,我在第 3 章的goal_embed基础上,把单条轨迹 MLP 输出的 2T 维度改成 T 维的残差,每条轨迹的输出是目标点坐标加一段残差序列,训练时目标点本身也参与回归。验证方式也简单:跑一批路口场景,看输出的 6 条轨迹是否各自收敛到不同的可行驶出口;如果几组 query 最后都落在同一个位置,说明目标集初始值设置不当,需要用 K-means 从训练集未来轨迹末端聚类得到初始 goal。这个方法落地之后,我这边推理延迟又降了大约 15%,而且因为解码不用自回归,不存在误差累积,越到轨迹末端反而越准。
经验教训是:别在最开始就给 Transformer 模型加太多 trick。先跑通一个带 winner-take-all 损失的最小模型,再逐步往里加目标集、位置编码改进、地图类型 dropout。每加一个模块,盯住验证集的 minFDE 和 miss rate,如果连续两个 epoch 没有正向变化,就果断回滚。希望这个方向上的踩坑记录和参数设置能帮到你,少走一段弯路。
本文还有配套的精品资源,点击获取