自动驾驶轨迹预测这个方向,我从早期做规则-based的卡尔曼滤波跟踪开始,到后来转深度学习方案,踩过的坑确实不少。今天想聊的这个项目,核心是用深度卷积网络做多模态轨迹预测——说白了,就是让车不仅能猜出前方行人或车辆下一步往哪走,还要给出多种可能的走向,而不是只给一条死板的预测线。这件事为什么重要?因为现实世界里,一个行人站在路边,他可能继续站着、可能突然横穿、也可能往回走,如果你只预测一条轨迹,下游的规划模块很容易做出错误决策。多模态预测就是要把这些可能性都覆盖到,并且给出每种可能性的概率。
这个项目适合谁看?如果你正在做自动驾驶感知或预测模块的工程落地,或者你在做多模态融合、时序数据建模相关的研究,再或者你只是想搞清楚深度卷积网络怎么用在轨迹预测这种时序问题上,那这篇内容应该能给你一些可以直接参考的东西。我会从整体设计思路讲起,然后拆解核心细节、实操过程,最后把我自己遇到过的问题和排查方法整理出来。
1. 项目整体设计与思路拆解
1.1 为什么选深度卷积网络而不是RNN或Transformer
轨迹预测本质上是一个时序建模问题。输入是过去若干帧的目标位置、速度、加速度等信息,输出是未来若干帧的轨迹分布。很多人第一反应是用LSTM或者Transformer,这没错,但深度卷积网络在这个任务上有它独特的优势。
卷积网络的核心能力是提取局部模式。在轨迹预测场景里,目标的运动轨迹在时间维度上具有强烈的局部相关性——相邻几帧之间的运动变化是平滑的,突然的剧烈变化往往意味着异常。一维卷积沿时间轴滑动,可以非常高效地捕捉这种局部时序模式。而且卷积是并行计算的,训练速度比RNN快很多,这在需要大规模数据集迭代的时候非常关键。
另一个考虑是,卷积网络对输入长度的适应性更好。RNN在处理变长序列时有天然的递归依赖,容易出现梯度消失或爆炸。卷积网络通过堆叠多层、使用膨胀卷积来扩大感受野,可以灵活控制模型看到多长的历史信息。我在实际项目里试过用膨胀卷积堆到感受野覆盖过去3秒的轨迹,效果比同等参数量的LSTM稳定不少。
当然,卷积网络也有短板。它对全局依赖的建模能力不如Transformer,所以在一些需要长距离交互的场景(比如多车博弈)里,纯卷积可能不够。我的做法是在卷积主干之后接一个轻量的注意力模块,既保留了卷积的局部建模优势,又补上了全局交互的能力。这个混合结构在多个数据集上都比纯卷积或纯Transformer要好。
1.2 多模态输出的实现方式
多模态轨迹预测的核心难点在于:如何让模型输出多个合理的未来轨迹,而不是只输出一个平均值。如果直接用MSE损失训练,模型会倾向于输出所有可能轨迹的均值,这在多模态场景下是灾难性的——比如行人可能向左也可能向右,均值就是直着走,这显然不合理。
常见的多模态输出方案有三种。第一种是Mixture Density Network,让模型输出多个高斯分布的参数,每个分布对应一个模态。第二种是Multiple Choice Learning,训练多个预测头,每个头负责一个模态,通过竞争机制让不同的头专注于不同的模式。第三种是Anchor-based方法,预定义一组轨迹锚点,模型预测每个锚点的偏移量和概率。
我在这个项目里用的是第二种和第三种结合的方式。具体来说,我预设了K个轨迹锚点(K通常取6到8),每个锚点代表一种典型的运动模式,比如直行、左转、右转、急停等。模型在卷积特征的基础上,为每个锚点预测一个偏移量和一个置信度。这样做的好处是训练稳定,而且锚点的设计可以融入领域知识——比如根据车道线的方向来初始化锚点,让模型从一开始就有合理的先验。
注意:锚点的数量不是越多越好。我试过K=16,结果很多锚点退化成了几乎相同的轨迹,反而增加了计算量。K=6到8在大多数场景下已经能覆盖主要的运动模式。
1.3 多模态融合的输入设计
这个项目的输入不是单一模态的数据,而是融合了多种信息源。具体包括:
- 目标历史轨迹:过去2秒的位置序列,采样频率10Hz,共20帧。
- 目标属性:类型(行人、自行车、车辆)、尺寸、朝向。
- 局部地图信息:目标周围一定范围内的车道线、人行横道、路沿等静态元素。
- 交互信息:周围其他目标的历史轨迹和当前状态。
这些信息通过不同的编码分支处理后,在特征层面进行融合。历史轨迹用一维卷积编码,地图信息用二维卷积编码成鸟瞰图特征,交互信息用图神经网络或者简单的池化操作处理。融合的方式我试过早期融合、晚期融合和中期融合,最终选择的是中期融合——每个分支先独立编码,然后在中间层拼接,再经过几层卷积做进一步的特征交互。
为什么选中中期融合?早期融合在输入层就拼接,不同模态的数据尺度和语义差异太大,模型很难学好。晚期融合在输出层才合并,各分支之间缺乏交互,对于需要跨模态推理的场景(比如根据地图判断轨迹是否合理)效果不好。中期融合兼顾了两者,既保留了各模态的独立编码能力,又提供了足够的交互空间。
2. 核心细节解析与实操要点
2.1 数据准备与预处理的关键细节
数据是这个项目里最耗时的部分,没有之一。我用的数据集主要是Argoverse和nuScenes,这两个都是自动驾驶领域常用的公开数据集。Argoverse的轨迹预测赛道提供了详细的轨迹和地图数据,nuScenes则在传感器配置和场景多样性上更丰富。
预处理的第一步是坐标归一化。原始数据里的坐标是全局坐标系下的绝对位置,直接输入网络会导致数值范围过大,训练不稳定。我的做法是以目标当前时刻的位置为原点,朝向为x轴正方向,建立一个局部坐标系,把所有历史轨迹和地图元素都转换到这个坐标系下。这样输入数值都在一个合理的范围内,而且模型学到的模式与目标的绝对位置无关,泛化能力更强。
第二步是轨迹采样和插值。不同数据集的采样频率不一样,Argoverse是10Hz,nuScenes是2Hz。为了统一,我需要把nuScenes的数据插值到10Hz。这里用的是三次样条插值,比线性插值更平滑,能更好地保留运动的高阶信息。插值之后还要做一次平滑滤波,去掉传感器噪声带来的抖动。
第三步是地图栅格化。把目标周围一定范围(我设的是左右各30米,前后各60米)内的地图元素渲染成一张鸟瞰图。车道线、人行横道、路沿用不同的通道表示,形成多通道的栅格图像。分辨率我设的是0.2米/像素,这样一张图大概是300x600的大小,既能保留足够的细节,又不会让计算量爆炸。
实操心得:地图栅格化的分辨率需要根据实际场景调整。城市道路场景下0.2米/像素够用,但如果是高速公路场景,目标移动速度快,可能需要降低到0.5米/像素来覆盖更大的范围。
2.2 网络结构设计与参数选择
网络的主干是一个多层一维卷积网络,处理历史轨迹序列。具体结构是:输入层接收20帧x 6维的特征(位置x、y,速度vx、vy,加速度ax、ay),经过三层一维卷积,每层的卷积核大小分别是5、3、3,通道数分别是64、128、256。每层后面接BatchNorm和ReLU激活。三层之后,感受野已经覆盖了全部20帧的历史信息。
地图分支用的是二维卷积网络,输入是3通道的鸟瞰图,经过四层卷积,每层后面接最大池化,最终得到一个空间特征图。然后通过全局平均池化得到一个固定长度的特征向量。
交互分支我用的是一种简化的图注意力机制。每个目标作为一个节点,节点特征包括历史轨迹编码和属性信息。边的关系根据目标之间的距离动态构建,距离越近的目标之间边权重越大。经过两层图注意力层之后,每个目标得到一个融合了周围目标信息的特征向量。
三个分支的输出拼接在一起,经过两层全连接层,然后分成K个预测头,每个头输出一个轨迹偏移量和一个置信度分数。轨迹偏移量是一个T x 2的矩阵,T是预测的未来帧数(我设的是30帧,对应3秒),2是x和y方向的偏移。置信度经过softmax归一化,保证所有模态的概率和为1。
损失函数用的是Winner-Takes-All加上分类损失。具体来说,对于每个样本,只计算与真实轨迹最接近的那个模态的回归损失,同时所有模态都计算分类损失,让模型学会给正确的模态更高的置信度。回归损失用的是Smooth L1,比MSE对异常值更鲁棒。
2.3 训练策略与调参经验
训练这个网络有几个关键点。首先是学习率的设置,我用的是余弦退火加上warmup。前5个epoch做线性warmup,学习率从1e-5升到1e-3,然后余弦退火到1e-6。这样做的原因是卷积网络在训练初期对大的学习率比较敏感,warmup可以让模型先稳定下来。
Batch size我设的是64,在单卡上刚好能跑满显存。如果显存不够,可以用梯度累积来模拟更大的batch size。优化器用的是AdamW,权重衰减设的是1e-4。AdamW比Adam在卷积网络上的泛化性能更好,这一点在多个任务上都得到了验证。
训练轮数我设的是100个epoch,但实际上在60到70个epoch的时候验证集损失就基本不再下降了。我加了早停机制,如果连续10个epoch验证损失没有改善就停止训练。最终模型大概在65个epoch左右收敛。
注意:多模态预测的评估指标不能只看ADE和FDE。这两个指标衡量的是预测轨迹与真实轨迹的平均距离和终点距离,但多模态场景下更重要的是minADE和minFDE,也就是在所有预测模态中取与真实轨迹最接近的那个来计算。另外还要看Miss Rate,衡量的是所有模态都没有预测准的比例。
3. 实操过程与核心环节实现
3.1 环境搭建与依赖安装
这个项目的代码基于PyTorch实现,我用的版本是1.13,CUDA版本是11.7。以下是核心依赖的安装步骤:
conda create -n traj_pred python=3.9 conda activate traj_pred pip install torch==1.13.1+cu117 torchvision==0.14.1+cu117 -f https://download.pytorch.org/whl/torch_stable.html pip install numpy pandas scikit-learn matplotlib pip install tensorboard pip install argoverse-apiArgoverse的API需要单独安装,它提供了数据加载和地图可视化的工具。nuScenes的数据加载我用的是nuscenes-devkit,安装方式和Argoverse类似。
环境搭好之后,先跑一个小的数据加载测试,确认数据路径和格式没问题。这一步看起来简单,但我见过太多人因为路径配置错误浪费半天时间。建议写一个简单的脚本,加载一个样本,把轨迹和地图可视化出来,肉眼确认数据是对的。
3.2 数据加载器的实现
数据加载器是整个训练流程的入口,它的效率直接影响训练速度。我实现了一个自定义的Dataset类,核心逻辑如下:
class TrajectoryDataset(Dataset): def __init__(self, data_root, mode='train', obs_len=20, pred_len=30): self.data_root = data_root self.obs_len = obs_len self.pred_len = pred_len self.samples = self._load_samples(mode) def _load_samples(self, mode): # 加载轨迹数据、地图数据、交互数据 # 返回样本列表 pass def __getitem__(self, idx): sample = self.samples[idx] # 坐标归一化 obs_traj, pred_traj = self._normalize(sample) # 地图栅格化 map_feat = self._rasterize_map(sample) # 交互特征 inter_feat = self._extract_interaction(sample) return obs_traj, map_feat, inter_feat, pred_traj def __len__(self): return len(self.samples)这里有几个优化点。第一,地图栅格化是比较耗时的操作,我在训练前会预计算好所有样本的地图特征,存成npy文件,训练时直接加载,省去了重复计算。第二,交互特征的提取涉及到目标之间的配对,我用了一个空间索引来加速最近邻搜索,比暴力遍历快了一个数量级。
DataLoader的num_workers我设的是8,prefetch_factor设的是4。这两个参数需要根据CPU核心数和内存大小调整。如果num_workers设得太大,反而会因为进程切换开销导致速度下降。
3.3 模型定义与训练循环
模型定义的核心部分如下:
class TrajectoryPredictor(nn.Module): def __init__(self, obs_len=20, pred_len=30, num_modes=6): super().__init__() self.obs_encoder = nn.Sequential( nn.Conv1d(6, 64, kernel_size=5, padding=2), nn.BatchNorm1d(64), nn.ReLU(), nn.Conv1d(64, 128, kernel_size=3, padding=1), nn.BatchNorm1d(128), nn.ReLU(), nn.Conv1d(128, 256, kernel_size=3, padding=1), nn.BatchNorm1d(256), nn.ReLU() ) self.map_encoder = MapEncoder() self.interaction_encoder = InteractionEncoder() self.fusion = nn.Sequential( nn.Linear(256 + 128 + 128, 512), nn.ReLU(), nn.Linear(512, 256), nn.ReLU() ) self.traj_heads = nn.ModuleList([ nn.Linear(256, pred_len * 2) for _ in range(num_modes) ]) self.confidence_head = nn.Linear(256, num_modes) def forward(self, obs_traj, map_feat, inter_feat): obs_feat = self.obs_encoder(obs_traj.permute(0, 2, 1)) obs_feat = obs_feat.mean(dim=-1) map_feat = self.map_encoder(map_feat) inter_feat = self.interaction_encoder(inter_feat) fused = self.fusion(torch.cat([obs_feat, map_feat, inter_feat], dim=-1)) trajs = torch.stack([head(fused) for head in self.traj_heads], dim=1) confs = self.confidence_head(fused) return trajs, confs训练循环里,每个batch的处理逻辑是:前向传播得到K个轨迹和对应的置信度,计算每个模态与真实轨迹的L2距离,选出距离最小的模态作为winner,只对winner计算回归损失,对所有模态计算分类损失。分类损失用的是交叉熵,标签是winner的索引。
def train_step(model, batch, optimizer, criterion): obs_traj, map_feat, inter_feat, pred_traj = batch trajs, confs = model(obs_traj, map_feat, inter_feat) # 计算每个模态与真实轨迹的距离 distances = torch.norm(trajs - pred_traj.unsqueeze(1), dim=-1).mean(dim=-1) winner_idx = distances.argmin(dim=1) # 回归损失只计算winner reg_loss = criterion(trajs[range(len(winner_idx)), winner_idx], pred_traj) # 分类损失计算所有模态 cls_loss = F.cross_entropy(confs, winner_idx) loss = reg_loss + 0.5 * cls_loss optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() return loss.item()梯度裁剪是必须的,多模态训练有时候会出现梯度爆炸的情况,max_norm设1.0比较稳妥。
3.4 评估与可视化
评估部分我实现了minADE、minFDE和Miss Rate三个指标。minADE的计算方式是:对于每个样本,计算K个预测轨迹与真实轨迹的ADE,取最小值。minFDE类似,只是计算终点距离。Miss Rate是minFDE大于2米的比例。
可视化我用matplotlib实现了一个简单的工具,把历史轨迹、预测轨迹、真实轨迹和地图元素画在一起。预测轨迹用不同颜色表示不同的模态,透明度表示置信度。这个工具在调试的时候非常有用,可以直观地看到模型到底学到了什么。
实操心得:可视化的时候一定要把地图画出来。我遇到过模型预测的轨迹穿过了人行横道或者冲上了路沿,但只看轨迹数据是发现不了的。加上地图之后,这些问题一目了然。
4. 常见问题与排查技巧实录
4.1 训练损失不下降或震荡
这是最常见的问题。可能的原因和排查方法如下:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 损失从一开始就不下降 | 学习率过大或过小 | 打印梯度范数,观察是否爆炸或消失 | 调整学习率,加warmup |
| 损失下降后震荡 | Batch size太小 | 观察不同batch的损失方差 | 增大batch size或用梯度累积 |
| 损失突然变成NaN | 梯度爆炸 | 检查梯度范数 | 加梯度裁剪,降低学习率 |
| 验证损失上升 | 过拟合 | 对比训练和验证损失曲线 | 加Dropout,增大权重衰减 |
我遇到过一次损失震荡特别严重的情况,排查后发现是数据加载器里的坐标归一化有问题。部分样本的朝向角度计算错误,导致归一化后的轨迹方向完全乱了。修复之后损失曲线就平滑了。所以数据预处理一定要仔细检查,最好写单元测试。
4.2 多模态退化问题
多模态退化是指模型输出的K个模态变得几乎一样,失去了多样性。这个问题在多模态预测里非常普遍。我试过以下几种解决方法:
第一种是增加多样性损失。在损失函数里加一项,惩罚不同模态之间的相似度。具体来说,计算两两模态之间的距离,如果距离太小就加惩罚。这个方法有效,但权重需要仔细调,太大会影响预测精度。
第二种是使用Multiple Choice Learning的变体。不是只让winner更新,而是让top-k个模态都更新,但权重不同。这样可以让多个模态都有机会学习到不同的模式。
第三种是锚点初始化。我前面提到的锚点方案,本质上就是给模型一个多样性的先验。锚点初始化得好,模态退化的问题会轻很多。
实测下来,锚点初始化加上轻度的多样性损失,效果最好。模态之间的差异明显,而且预测精度没有明显下降。
4.3 推理速度优化
自动驾驶对推理速度的要求很高,通常需要在100毫秒以内完成一次预测。我最初的模型在V100上推理一次要200多毫秒,后来做了以下优化:
第一,把卷积层的通道数从256降到192,精度损失不到1%,但速度提升了约20%。第二,把地图分支的全局平均池化改成自适应池化,减少了计算量。第三,用TensorRT对模型进行量化,FP16精度下速度提升了近一倍,精度损失在可接受范围内。
最终模型在V100上的推理时间降到了约60毫秒,满足了实时性要求。如果部署在边缘设备上,可能还需要进一步压缩,比如用MobileNet风格的轻量卷积替换标准卷积。
注意:量化之后一定要重新评估精度。我遇到过量化后minADE上升了0.3米的情况,虽然整体指标还能接受,但在一些关键场景(比如行人密集区域)的预测质量明显下降。所以量化后的模型需要在各种场景下都做充分测试。
4.4 跨数据集泛化问题
在Argoverse上训练好的模型,直接拿到nuScenes上测试,性能会下降不少。主要原因是两个数据集的场景分布不一样——Argoverse主要是城市道路,nuScenes包含了更多的高速和停车场场景。另外,两个数据集的传感器配置和标注规范也有差异。
我的解决方法是做领域自适应。具体来说,在训练时同时使用两个数据集,但给每个数据集一个可学习的领域嵌入向量,加到特征上。这样模型可以学到领域相关的特征,同时共享底层的运动模式。另外,我还用了数据增强,比如随机旋转、缩放、加噪声,来提升模型的鲁棒性。
实测下来,加了领域自适应之后,跨数据集的minADE下降了约15%。如果目标场景有少量标注数据,还可以做微调,效果会更好。
4.5 常见问题速查表
| 问题 | 排查方向 | 快速解决 |
|---|---|---|
| 训练不收敛 | 学习率、数据归一化、损失函数 | 检查数据预处理,调整学习率 |
| 模态退化 | 多样性损失、锚点设计 | 加多样性损失,重新设计锚点 |
| 推理太慢 | 模型大小、量化、算子优化 | 减小通道数,用TensorRT量化 |
| 跨数据集性能下降 | 领域差异、数据增强 | 加领域嵌入,做数据增强 |
| 预测轨迹不合理 | 地图特征、物理约束 | 检查地图编码,加物理约束损失 |
| 显存不够 | Batch size、模型大小 | 减小batch size,用梯度累积 |
最后分享一个我在实际项目中总结的小技巧:多模态轨迹预测的评估不能只看数字指标,一定要做可视化。我遇到过minADE指标很好,但可视化后发现模型在某些场景下预测的轨迹虽然距离真实轨迹很近,但明显违反了交通规则(比如逆行)。这种问题只有通过可视化才能发现。所以建议在评估流程里强制加入可视化环节,每个epoch都随机抽一些样本画出来看看。