自动驾驶轨迹预测这个方向,我从早期做规则-based的卡尔曼滤波跟踪开始,到后来转深度学习方案,前后折腾了差不多四年。中间踩过的坑、推翻的方案、重写的训练脚本,加起来能堆满一个移动硬盘。今天聊的这个"基于深度卷积网络的多模态轨迹预测",是我认为目前工程落地性价比最高的一条路线——它不像Transformer方案那样吃数据吃到令人绝望,也不像纯LSTM那样在长时域预测上拉胯,而是在特征提取效率和多模态输出能力之间找到了一个很实用的平衡点。
这篇文章主要面向两类人:一是刚进入自动驾驶预测模块的算法工程师,想快速搞清楚多模态轨迹预测到底在做什么、怎么做;二是已经有一定深度学习基础,但对"多模态"这个概念的工程实现还比较模糊的开发者。我会从问题定义开始,把深度卷积网络在轨迹预测中的具体角色、多模态输出的实现机制、数据集的选择与处理、训练中的实际坑点,以及部署时的性能取舍,全部拆开讲一遍。不会只给公式,也不会只给代码,而是把"为什么这样做"和"实际做下来什么感受"都讲清楚。
1. 多模态轨迹预测到底在预测什么
1.1 从单模态到多模态:一个本质的认知转变
很多人刚接触轨迹预测时,第一反应是把它当成一个回归问题:输入历史轨迹,输出未来轨迹。用MSE损失训一个LSTM或者时序卷积网络,跑出来一条曲线,任务就算完成了。这个思路在简单场景下能跑通,但一上路就出问题。
原因很简单:未来本身就不是确定的。一个车在前方路口,它可能直行、可能左转、可能右转,甚至可能掉头。你让模型只输出一条轨迹,它学到的必然是所有可能性的"平均值"——一条既不直行也不转弯的、物理上根本不存在的轨迹。这就是所谓的**模式平均(mode averaging)**问题,是单模态回归方案的致命伤。
多模态轨迹预测的核心思路是:不要求模型输出唯一答案,而是让它输出一组候选轨迹,每条轨迹代表一种合理的未来可能性,同时给出每条轨迹的置信度。这样一来,下游的规划模块就可以根据这些候选轨迹做风险评估和决策,而不是被一条"平均轨迹"误导。
这个转变看似简单,但在工程实现上涉及一整套设计:输出头怎么设计、损失函数怎么定义、训练时怎么分配标签、推理时怎么筛选轨迹。后面我会逐一展开。
1.2 深度卷积网络在时序建模中的独特优势
说到时序建模,大多数人第一反应是RNN、LSTM或者Transformer。卷积网络不是天然为序列设计的,那为什么在轨迹预测里用卷积?
这里有几个实际考量。第一,卷积的感受野可以通过堆叠和膨胀(dilation)灵活控制。轨迹预测需要同时捕捉短时运动特征(比如最近几帧的加速度变化)和长时意图特征(比如过去几秒的行驶方向趋势),膨胀卷积恰好能在一个网络里同时覆盖这两个尺度,而不像LSTM那样所有信息都压缩在一个隐状态里。
第二,卷积是并行计算的。训练时LSTM必须按时间步串行展开,GPU利用率上不去;卷积网络可以一次性处理整个时间窗口,训练效率高出一大截。我在实际项目里做过对比,同样的数据量,一维卷积网络的单epoch训练时间大约是LSTM的60%到70%。
第三,卷积对局部模式的捕捉非常高效。轨迹数据本质上是局部平滑的,相邻帧之间的变化量很小,卷积核天然适合提取这种局部运动模式。而Transformer的自注意力机制虽然表达能力强,但在数据量不够大的时候容易过拟合,而且计算复杂度是序列长度的平方,对长时域预测不太友好。
当然,卷积也有它的局限——对全局依赖的建模不如注意力机制直接。所以现在很多方案会采用卷积+注意力的混合结构,但纯卷积方案在中等数据规模下依然是性价比很高的选择。
1.3 多模态输出的三种主流实现路径
多模态轨迹预测的输出机制,目前主流有三种做法,各有各的适用场景。
第一种是锚点回归(Anchor-based Regression)。预先定义一组锚点轨迹(比如用K-means对训练集里的真实轨迹聚类得到),模型不直接输出轨迹坐标,而是输出对每个锚点的偏移量和置信度。这种做法训练稳定,收敛快,但锚点的质量和数量直接决定了模型的上限。锚点太少覆盖不全,太多则计算量大且容易冗余。
第二种是高斯混合模型(GMM)输出。模型输出多个高斯分布的参数(均值、方差、相关系数)以及每个分布的权重,最终预测就是这些高斯分布的混合。这种做法理论上更优雅,能建模轨迹的不确定性,但训练时容易出现数值不稳定,需要仔细调参。
第三种是直接多分支输出。网络末端接K个独立的回归头,每个头输出一条完整轨迹和一个置信度分数。这种做法实现最简单,但训练时存在"哪个头负责哪条轨迹"的分配问题,通常需要用Winner-Takes-All(WTA)损失或者EM-style的迭代优化。
我在实际项目中用得最多的是第一种和第三种结合:用锚点做初始化,但允许模型输出锚点之外的偏移,同时用多个回归头增加多样性。这样既保证了训练稳定性,又保留了一定的灵活性。
2. 网络架构设计:从输入编码到多模态解码
2.1 输入表示:不只是历史轨迹坐标
很多人做轨迹预测,输入就是过去N帧的(x, y)坐标,然后直接喂给网络。这样做不是不行,但信息利用率太低。实际路上跑的车,它的未来行为不仅取决于自己的历史位置,还取决于周围车辆、车道线、交通信号等环境因素。
一个完整的输入表示通常包含以下几类信息:
- 自车历史轨迹:过去2到3秒的位置序列,通常以0.1秒或0.2秒为采样间隔。坐标需要做归一化,一般以当前时刻自车位置为原点,车头方向为x轴正方向,这样可以让模型不受绝对坐标的影响。
- 邻居车辆历史轨迹:周围一定范围内(比如50米)其他车辆的历史轨迹。数量不固定,需要做padding和mask处理。
- 地图信息:车道线的几何形状、车道连接关系、限速信息等。这部分通常用折线或者栅格化的方式编码。
- 交通信号状态:如果预测的是路口场景,信号灯状态是非常强的先验。
在实际工程中,输入信息的丰富程度直接决定了模型性能的上限。我做过一组消融实验,只用车轨迹、加上地图、再加上信号灯,三组配置的minADE指标差距能达到30%以上。所以如果条件允许,尽量把能拿到的信息都喂进去。
2.2 一维卷积编码器的具体设计
输入数据准备好之后,下一步是用卷积网络做特征提取。这里我详细讲一下我常用的一维卷积编码器结构。
整个编码器由若干个残差卷积块组成,每个块包含两层一维卷积、批归一化和ReLU激活。卷积核大小通常选3或5,膨胀率逐层递增(1, 2, 4, 8...),这样可以在不增加参数量的情况下扩大感受野。
具体来说,假设输入序列长度为T=20(过去2秒,采样间隔0.1秒),特征维度为D(包括坐标、速度、加速度、朝向角等),那么:
import torch import torch.nn as nn class ResidualConvBlock(nn.Module): def __init__(self, in_channels, out_channels, kernel_size=3, dilation=1): super().__init__() padding = (kernel_size - 1) * dilation // 2 self.conv1 = nn.Conv1d(in_channels, out_channels, kernel_size, padding=padding, dilation=dilation) self.bn1 = nn.BatchNorm1d(out_channels) self.conv2 = nn.Conv1d(out_channels, out_channels, kernel_size, padding=padding, dilation=dilation) self.bn2 = nn.BatchNorm1d(out_channels) self.relu = nn.ReLU(inplace=True) self.downsample = nn.Conv1d(in_channels, out_channels, 1) \ if in_channels != out_channels else nn.Identity() def forward(self, x): residual = self.downsample(x) out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) return self.relu(out + residual)这个结构看起来简单,但有几个细节值得注意。批归一化放在卷积之后、激活之前,这是标准做法,但在轨迹预测任务中,如果batch size比较小(比如小于16),BatchNorm的效果会不稳定,这时候可以考虑用LayerNorm替代。残差连接是必须的,否则深层网络训练时梯度会衰减得很厉害。膨胀率的选择要跟预测时域匹配,如果预测未来3秒,感受野至少要覆盖过去2秒加上未来3秒对应的运动尺度。
编码器的输出是一个形状为(batch_size, feature_dim, T)的特征图,接下来需要做一个时间维度的聚合,把整个序列压缩成一个固定长度的上下文向量。常用的做法是注意力池化或者最后时间步取特征。我倾向于用注意力池化,因为它能给不同时间步分配不同的权重,让模型自己决定哪些历史时刻更重要。
2.3 多模态解码器的三种输出头实现
解码器部分是多模态预测的核心。我以锚点回归方案为例,讲一下具体实现。
假设我们预先通过K-means聚类得到了K=6个锚点轨迹,每个锚点是一条未来T_f=30帧(3秒)的轨迹。解码器的任务是:对每个锚点,预测一个偏移量和一个置信度。
class MultiModalDecoder(nn.Module): def __init__(self, context_dim, num_modes=6, future_len=30): super().__init__() self.num_modes = num_modes self.future_len = future_len # 每个模态的偏移量预测头 self.offset_head = nn.Sequential( nn.Linear(context_dim, 256), nn.ReLU(), nn.Linear(256, future_len * 2) ) # 每个模态的置信度预测头 self.confidence_head = nn.Sequential( nn.Linear(context_dim, 128), nn.ReLU(), nn.Linear(128, 1) ) def forward(self, context, anchors): # context: (B, context_dim) # anchors: (K, future_len, 2) B = context.shape[0] offsets = self.offset_head(context).view(B, self.num_modes, self.future_len, 2) confidences = self.confidence_head(context).view(B, self.num_modes) # 最终轨迹 = 锚点 + 偏移量 trajectories = anchors.unsqueeze(0) + offsets return trajectories, confidences这里有个关键设计:偏移量而不是绝对坐标。直接预测绝对坐标的话,不同场景下坐标范围差异很大,模型很难学。预测相对于锚点的偏移量,数值范围小且分布集中,训练稳定得多。
置信度头输出的是logits,推理时需要做softmax归一化。训练时通常用交叉熵损失,让模型学会给更接近真实轨迹的模态更高的置信度。
2.4 特征融合:如何处理多源异构输入
实际场景中,输入信息来自不同来源,维度和语义都不一样。自车轨迹是时序坐标,地图是折线集合,信号灯是离散状态。怎么把这些异构信息融合到一起?
我的做法是分路编码、后期融合。每一类输入用独立的编码器处理,得到各自的embedding,然后通过拼接或者注意力机制融合。
具体来说,自车轨迹和邻居轨迹用共享的一维卷积编码器处理,地图折线用一个轻量级的PointNet或者简单的MLP编码,信号灯状态直接做embedding。融合时,我用的是多头注意力:以自车特征为query,其他特征为key和value,让模型自适应地选择最相关的信息。
这种融合方式的好处是模块化程度高,增加或减少某类输入时不需要大改网络结构。缺点是多了一层注意力计算,推理延迟会略有增加。在实际部署中,如果算力紧张,可以把注意力融合换成简单的拼接+全连接,性能损失通常在5%以内。
3. 数据集选择与预处理中的实际问题
3.1 主流数据集的特点与适用场景
轨迹预测领域有几个常用的公开数据集,各有各的特点。
Argoverse是我用得最多的一个。它包含美国多个城市的行驶数据,有高清地图和丰富的车道线标注,场景类型也比较多样。它的预测任务设定是:给定过去2秒的轨迹,预测未来3秒。数据量大约在30万条左右,对于中等规模的模型来说够用。缺点是场景主要集中在美国城市道路,如果要做国内场景的落地,需要额外做域适应。
nuScenes的数据量更大,传感器配置也更丰富(包含激光雷达和毫米波雷达),但它的轨迹预测任务设定和Argoverse略有不同,采样频率是2Hz,比Argoverse的10Hz低不少。低采样率意味着运动细节丢失较多,对模型的特征提取能力要求更高。
INTERACTION数据集专注于交互场景,包含环岛、无保护左转等复杂交互场景,适合研究车辆之间的博弈行为。但它的数据量相对较小,如果模型参数量大,容易过拟合。
Waymo Open Motion Dataset是目前规模最大的之一,场景丰富度和标注质量都很高,但对算力要求也最高。如果实验室资源有限,建议先从Argoverse入手。
| 数据集 | 采样频率 | 预测时域 | 数据量 | 地图信息 | 适用场景 |
|---|---|---|---|---|---|
| Argoverse | 10Hz | 3s | ~30万 | 高清车道线 | 城市道路通用 |
| nuScenes | 2Hz | 6s | ~100万 | 语义地图 | 多传感器融合 |
| INTERACTION | 10Hz | 3s | ~4万 | 车道连接 | 交互博弈 |
| Waymo Open | 10Hz | 8s | ~50万 | 高清地图 | 大规模训练 |
3.2 数据预处理中最容易踩的坑
数据预处理看起来是脏活累活,但它对最终性能的影响远超很多人的想象。我在这上面踩过的坑,比在网络结构上踩的还多。
第一个坑是坐标系不统一。Argoverse的数据里,不同场景的坐标系原点不一样,有的以地图原点为准,有的以自车起始位置为准。如果不做统一,模型学到的就是一堆混乱的坐标映射。我的做法是:统一以"当前时刻自车位置为原点,车头朝向为x轴正方向"做变换。这样每条轨迹的起点都是(0, 0),朝向都是0度,模型只需要学相对运动模式。
第二个坑是异常值处理。真实数据里总有一些跳变点,可能是GPS漂移或者标注错误。如果不处理,这些异常值会在训练时产生巨大的梯度,把模型带偏。我通常用速度阈值做过滤:如果相邻两帧之间的位移超过物理上可能的加速度对应的距离,就标记为异常并做插值修复。
第三个坑是类别不平衡。直行场景占了绝大多数,转弯和变道场景相对稀少。如果直接按原始分布训练,模型会倾向于预测直行,对转弯场景的预测能力很差。解决办法有两种:一是对稀有场景做过采样,二是在损失函数里给稀有场景更高的权重。我一般用后者,实现更简单,效果也不错。
3.3 数据增强:哪些操作真正有效
轨迹预测的数据增强不像图像领域那么直观,不能随便旋转翻转,因为交通规则和道路结构是有方向性的。以下是我实测有效的几种增强方式:
- 全局旋转:把整个场景(包括地图和所有车辆轨迹)绕原点旋转一个小角度(比如±10度)。这个操作模拟的是不同朝向的道路场景,不改变交通规则的语义。
- 时间缩放:把轨迹的时间轴做轻微缩放(比如0.9倍到1.1倍),模拟不同驾驶风格下的速度差异。注意缩放后要重新计算速度和加速度。
- 邻居车辆dropout:随机丢弃一部分邻居车辆的历史轨迹,模拟传感器漏检的情况。这个增强能提升模型对不完整输入的鲁棒性。
- 轨迹加噪:在历史轨迹上叠加小幅高斯噪声,模拟定位误差。噪声的标准差要控制在实际定位精度的范围内(通常0.1到0.3米)。
需要提醒的是,不要做镜像翻转。左右翻转会改变左转和右转的语义,在靠右行驶的交通规则下,这会产生不合理的训练样本。
4. 训练策略与损失函数设计
4.1 多模态损失的组合方式
多模态轨迹预测的损失函数设计,是整个训练过程中最需要仔细斟酌的部分。单一损失很难同时兼顾轨迹精度和模态多样性。
我通常用三个损失加权组合:
第一是回归损失,衡量预测轨迹和真实轨迹之间的偏差。常用的是Smooth L1损失或者Huber损失,比纯MSE对异常值更鲁棒。但这里有个关键问题:多模态输出有K条轨迹,应该用哪条来计算回归损失?
答案是只用在当前模型下最接近真实轨迹的那条,这就是Winner-Takes-All策略。具体做法是:先计算K条预测轨迹和真实轨迹的距离,选出距离最小的那条,只对它做回归损失。这样每个模态可以专注于学习一类特定的未来模式,而不是所有模态都去拟合同一条轨迹。
第二是分类损失,让模型学会判断哪个模态最可能接近真实轨迹。用交叉熵损失,标签是距离最小的那个模态的索引。这里有个细节:如果多条轨迹距离真实值都很近,硬标签可能会让模型困惑。我试过用soft label(根据距离做softmax得到软标签),效果比硬标签略好,但提升幅度不大。
第三是多样性损失,防止所有模态收敛到同一条轨迹。最简单的做法是惩罚模态之间的重叠:如果两条预测轨迹过于相似,就加一个惩罚项。更优雅的做法是使用EM-style的迭代优化,但实现复杂度高不少。
def multimodal_loss(predictions, confidences, ground_truth, anchors): # predictions: (B, K, T, 2) # confidences: (B, K) # ground_truth: (B, T, 2) B, K, T, _ = predictions.shape # 计算每条预测轨迹与真实轨迹的距离 distances = torch.norm(predictions - ground_truth.unsqueeze(1), dim=-1).mean(dim=-1) # (B, K) # Winner-Takes-All: 选出距离最小的模态 best_mode = distances.argmin(dim=1) # (B,) best_pred = predictions[torch.arange(B), best_mode] # (B, T, 2) # 回归损失 reg_loss = nn.SmoothL1Loss()(best_pred, ground_truth) # 分类损失 cls_loss = nn.CrossEntropyLoss()(confidences, best_mode) # 多样性损失:惩罚模态之间的过度重叠 diversity_loss = 0 for i in range(K): for j in range(i+1, K): dist_ij = torch.norm(predictions[:, i] - predictions[:, j], dim=-1).mean() diversity_loss += torch.exp(-dist_ij) diversity_loss = diversity_loss / (K * (K-1) / 2) total_loss = reg_loss + 0.5 * cls_loss + 0.1 * diversity_loss return total_loss三个损失的权重需要根据具体任务调。我的经验是:回归损失权重设为1,分类损失在0.3到0.8之间,多样性损失在0.05到0.2之间。多样性损失不能太大,否则会牺牲预测精度来换取多样性,得不偿失。
4.2 学习率调度与训练稳定性
轨迹预测模型的训练,学习率调度非常关键。我一般用余弦退火+热重启的策略:初始学习率设为1e-3,每训练20个epoch做一次余弦退火到1e-5,然后热重启回1e-3。这样可以让模型在训练后期跳出局部最优,找到更好的解。
另外,梯度裁剪是必须的。轨迹预测的损失函数在某些困难样本上会产生很大的梯度,如果不裁剪,一次异常更新就可能毁掉之前所有的训练成果。我通常把梯度范数裁剪到1.0。
还有一个容易被忽略的点:BatchNorm的动量参数。默认值是0.1,但在轨迹预测任务中,由于batch内样本差异较大,默认动量可能导致running statistics不稳定。我一般调到0.01,让统计量更新更平滑。
4.3 训练过程中的监控指标
训练时不能只看loss,还要监控几个关键指标:
- minADE(最小平均位移误差):K条预测轨迹中,与真实轨迹最接近的那条的平均位移误差。这个指标衡量的是"模型是否至少给出了一条靠谱的预测"。
- minFDE(最小最终位移误差):最接近的那条轨迹在终点处的位移误差。衡量的是长时域预测能力。
- Miss Rate:如果所有K条预测轨迹的终点都偏离真实终点超过2米,就算一次miss。这个指标衡量的是"模型是否完全预测失败"。
- 模态利用率:统计每个模态被选为最优模态的频率。如果某个模态几乎从不被选中,说明它是冗余的,可以考虑减少模态数量。
我在训练时会把minADE和minFDE作为主要监控指标,每5个epoch在验证集上跑一次。如果连续3次验证指标不下降,就触发学习率衰减或者早停。
5. 推理部署中的性能优化与工程取舍
5.1 模型量化与推理加速
训练好的模型要上车载平台,推理效率是绕不开的问题。一个标准的卷积轨迹预测模型,参数量大约在2M到10M之间,在服务器GPU上推理延迟通常在10ms以内,但到了车载嵌入式平台,可能就变成50ms甚至100ms了。
我常用的加速手段有三种:
第一是INT8量化。把模型权重和激活值从FP32量化到INT8,推理速度可以提升2到3倍,精度损失通常在1%到3%之间。关键是量化校准集的选择——要用有代表性的真实场景数据,不能随便拿训练集的一个子集凑数。
第二是算子融合。把卷积、批归一化、ReLU融合成一个算子,减少内存访问次数。大部分推理框架(如TensorRT、ONNX Runtime)都支持自动融合,但需要确保模型导出时结构是干净的。
第三是剪枝。去掉不重要的通道或层。轨迹预测模型里,解码器部分的冗余通常比编码器大,因为多模态输出头之间有很多重复计算。我试过对解码器做结构化剪枝,去掉30%的通道,minADE只下降了2%左右,但推理速度提升了近40%。
5.2 多模态输出的后处理策略
模型输出K条轨迹和对应的置信度之后,不能直接丢给下游规划模块,还需要做后处理。
第一步是置信度过滤。把置信度低于阈值的轨迹直接丢弃。阈值的选择需要根据实际场景调,我一般设在0.05到0.1之间。太低起不到过滤作用,太高可能把一些合理的预测也丢掉。
第二步是轨迹平滑。模型输出的轨迹可能在时间上不够平滑,有轻微的抖动。可以用样条插值或者简单的移动平均做平滑。但要注意,平滑不能改变轨迹的整体形状,否则可能把转弯轨迹平滑成直行。
第三步是物理约束检查。检查预测轨迹是否满足车辆运动学约束:曲率是否超过最大转向能力、加速度是否在合理范围内、是否与地图上的不可行驶区域重叠。不满足约束的轨迹要降权或者直接剔除。
第四步是NMS(非极大值抑制)。如果两条预测轨迹过于相似,保留置信度高的那条,抑制另一条。这样可以在不减少模态数量的情况下,让最终输出的轨迹更具多样性。
5.3 实际部署中的延迟与精度平衡
在实际项目中,延迟和精度永远是一对矛盾。我的经验是:先确定延迟预算,再在这个预算内最大化精度。
假设整个预测模块的延迟预算是30ms,那么模型推理本身不能超过20ms,剩下10ms留给预处理和后处理。在这个约束下,如果INT8量化后的模型推理需要15ms,那就可以考虑用更大的模型或者更多的输入特征来提升精度;如果量化后还需要25ms,那就必须做剪枝或者减少模态数量。
还有一个实用技巧:根据场景复杂度动态调整计算量。在简单场景(比如高速直行)下,用轻量级模型或者减少模态数量;在复杂场景(比如城市路口)下,切换到完整模型。这种动态推理策略可以在平均延迟和精度之间取得更好的平衡。
6. 几个我踩过的坑和对应的解决方案
6.1 模态坍缩:所有预测轨迹变成同一条
这是多模态训练中最常见的问题。训练一段时间后,你会发现K条预测轨迹几乎完全重合,模型退化成了单模态。
根本原因是WTA策略下的梯度分配不均。训练初期,某个模态可能偶然比其他模态更接近真实轨迹,于是它获得更多的梯度更新,变得更好;更好的表现又让它更容易被选为winner,形成正反馈。最终,一个模态主导了所有预测,其他模态因为长期得不到有效梯度而"饿死"。
解决办法有几种。我试过最有效的是在训练初期使用soft WTA:不是只对距离最小的模态做回归,而是对所有模态按距离的softmax加权做回归。这样每个模态都能获得一定的梯度,避免早期分化。训练到后期再逐渐过渡到硬WTA。
另一种方法是定期重置模态:每隔一定epoch,把表现最差的模态的参数重新初始化,给它一个"重新开始"的机会。这个做法简单粗暴,但在实践中确实有效。
6.2 长时域预测的误差累积
预测时域越长,误差累积越严重。预测未来1秒的时候minFDE可能只有0.5米,到未来3秒就变成2米以上了。
这个问题本质上是因为模型在自回归式的预测中,每一步的误差都会传递到下一步。但我们的模型是一次性输出整条轨迹的,不存在自回归的问题。那误差累积来自哪里?
来自特征表示的模糊性。当预测时域变长时,历史轨迹中包含的信息不足以唯一确定未来,模型的不确定性增加。解决办法是显式建模不确定性:让模型不仅输出轨迹,还输出每条轨迹的方差。这样下游模块可以根据方差来判断预测的可靠程度。
我在解码器里加了一个方差预测头,输出每条轨迹在每个时间步的方差。训练时用高斯负对数似然损失。这样做虽然增加了输出维度,但对长时域预测的可靠性提升很明显。
6.3 地图信息利用不充分
早期我做模型的时候,地图信息只是简单地做embedding然后拼接。后来发现这样做效果很差,模型基本忽略了地图信息,主要靠历史轨迹做预测。
问题在于地图信息的编码方式太粗糙。车道线是折线结构,直接做embedding会丢失几何信息。后来我改成用折线编码器:把每条车道线表示为一个点序列,用一维卷积或者PointNet提取特征,然后再和轨迹特征做注意力融合。
另一个关键是地图信息的粒度。太粗(比如只给车道中心线)信息不够,太细(比如给每个车道边界点)计算量太大。我的经验是:给车道中心线加上车道连接关系,这个粒度对大多数场景够用了。
6.4 不同场景下的性能波动
模型在直行场景下minADE可能只有0.3米,到了复杂路口就变成1.5米以上。这种性能波动在实车测试中非常明显。
原因是场景分布不均衡。直行场景多,模型见得多,学得好;路口场景少,模型见得少,学得差。解决办法除了前面提到的过采样和加权损失,还有一个很有效的策略:场景自适应归一化。
具体做法是:根据场景类型(直行、转弯、路口等)对输入特征做不同的归一化。比如在路口场景下,把速度特征单独做标准化,因为路口的速度分布和直行差异很大。这个操作实现简单,但效果提升明显。我在一个项目里加了场景自适应归一化后,路口场景的minADE下降了约15%。
7. 从实验到落地:一些工程经验
7.1 离线指标好不代表线上效果好
这是我在实际项目中最深刻的体会。离线评测时minADE做到0.5米,觉得已经很不错了,但上车测试后发现规划模块经常做出不合理的决策。
后来分析发现,问题出在离线指标和线上需求的错位。离线指标衡量的是平均精度,但规划模块关心的是"最坏情况下的安全性"。一条平均误差很小但偶尔会预测出危险轨迹的模型,在线上是不可接受的。
所以后来我在离线评测中增加了安全性指标:统计预测轨迹与真实轨迹的偏差超过安全阈值的频率,以及预测轨迹是否会导致规划模块做出急刹车或急转向的决策。这些指标比单纯的minADE更能反映实际表现。
7.2 数据闭环的重要性
模型上线后,最重要的不是继续调网络结构,而是建立数据闭环。把线上遇到的困难场景收集回来,标注后加入训练集,重新训练模型。这个循环跑得越快,模型迭代的效率越高。
我在项目中搭建了一个简单的数据闭环流程:线上模型推理时,记录预测置信度低或者与真实轨迹偏差大的场景片段,定期导出并做人工标注,然后加入训练集。每跑一轮闭环,模型在困难场景下的表现都有明显提升。
7.3 模型版本管理与回滚机制
轨迹预测模型直接关系到行车安全,所以版本管理和回滚机制必须做好。我的做法是:每次模型更新都保留完整的训练配置、数据版本和评测报告;上线新模型时先做影子模式(shadow mode),让新模型和旧模型并行推理,对比输出差异;如果新模型在某些场景下表现异常,可以快速回滚到旧版本。
这套机制看起来增加了工作量,但在实际项目中帮我避免了好几次潜在的事故。特别是当训练数据分布发生变化时,新模型可能会在某些边缘场景下产生完全错误的预测,如果没有回滚机制,后果会很严重。
7.4 关于模态数量的选择
最后一个实际问题是:K应该设多少?理论上K越大,覆盖的未来可能性越多,但计算量也越大,而且模态之间的冗余也会增加。
我试过K=3、K=6、K=9三种配置。K=3在简单场景下够用,但复杂路口容易漏掉一些可能性;K=6是比较平衡的选择,能覆盖大多数场景的主要可能性;K=9的边际收益已经很小了,minADE相比K=6只提升了不到3%,但推理时间增加了近50%。
所以我的建议是:从K=6开始,根据实际场景的复杂度和算力预算做调整。如果算力充裕且场景特别复杂,可以上到K=8或K=9;如果算力紧张,K=4或K=5也能凑合用,但要做好复杂场景下预测不全的心理准备。
另外,模态数量不一定非要固定。可以根据场景复杂度动态调整:简单场景用3个模态,复杂场景用6个或更多。这种动态模态策略在工程上完全可行,只需要在推理时根据场景分类结果选择不同的解码器分支即可。