LSTM高速公路车辆轨迹预测:PyTorch实现与工程避坑指南
简介这是一份基于PyTorch与LSTM的高速公路车辆轨迹预测项目面向计算机相关专业学生与从业者可作为课程设计、期末大作业或毕业设计的完整参考。压缩包共15个文件含9个Python脚本、5张可视化图片和1个说明文档整体仅311KB结构紧凑。Python脚本覆盖NGSIM数据预处理、MTF-LSTM模型构建、训练与测试等环节图片展示不同步骤的预测结果教程文档则对运行流程进行说明。项目配套数据集并经过调试可确保运行便于读者快速复现高速公路轨迹预测实验。目前已有5214人学习下载适合需要掌握LSTM时序建模、轨迹预测工程实现或准备相关项目的开发者。1. LSTM 高速公路车辆轨迹预测这个高分项目到底在解决什么问题高速公路上开车最紧张的不是自己踩油门而是旁边车道那台车会不会在 3 秒后突然并入你的车道。辅助驾驶系统要是能提前预测周围车辆的未来位置就能早一点减速或者避让。这个基于 LSTM 的车辆轨迹预测项目做的就是这件事拿过去几秒的轨迹坐标预测未来几秒的位置输入是历史序列输出是未来轨迹。用 PyTorch 实现配套数据集和完整源码作为课程设计、毕设开题或者论文实验的底座都很合适——它把一套可复现的高分工程结构摆在你面前数据怎么洗、模型怎么搭、指标怎么算全部能照着跑通。2. 轨迹预测问题先立住LSTM 为什么是高速公路场景的第一选择2.1 问题本质把历史轨迹映射成未来坐标车辆轨迹预测在数学上是一个序列到序列的回归问题。每辆车在每一帧都有一个状态常见的是横坐标 x、纵坐标 y、速度 v、航向角 heading有时还会加上所在车道 lane_id。给定过去 T_obs 帧的状态序列模型要输出未来 T_pred 帧的位置序列。高速公路场景和城市路口最大的区别在于车的运动受车道约束强速度变化相对平滑换道行为是少数突变点。这意味着大部分时间轨迹是强相关的时序数据很适合用循环神经网络建模。我一般会把预测目标定义成未来每个时间步的 (x, y) 坐标而不是速度或者加速度。原因很简单评价指标 ADE、FDE 都是在坐标上算的直接回归坐标可以让训练目标和评价指标对齐。如果先预测速度再积分得到位置误差会随时间累积训练时模型也很难感知到「位置错了」这个信号。坐标单位用米不需要做单位换算代码写起来也省事。2.2 模型选型对比LSTM、GRU 与 Transformer 的取舍很多初学者一上来就问现在 Transformer 这么火为什么不直接用我自己的经验是在小规模轨迹数据上LSTM 依然是性价比最高的选择。Transformer 需要足够的数据量才能发挥注意力机制的优势而一个课程设计级别的数据集通常只有几万条轨迹片段强行上 Transformer 很容易过拟合。LSTM 的门控结构对中等长度序列10 到 30 帧的时序依赖建模已经足够而且训练稳定调参空间小。GRU 是 LSTM 的简化版参数更少在小数据集上训练更快但在我做过的对比实验里GRU 在换道场景下的预测精度略低于 LSTM。原因可能是 LSTM 的输入门和遗忘门分离能更精细地控制「记住多少历史位置、遗忘多少旧信息」而换道发生时车辆的运动模式突然变化这种精细控制恰好有用。所以这个项目用 LSTM 而不是 GRU不是跟风是实测出来的选择。2.3 输入输出设计绝对坐标、相对坐标与时间步长的确定输入特征不是越多越好。我见过有人把十几个特征全部塞进去结果模型在验证集上的表现反而不如只用坐标加速度。高速公路场景下最关键的特征就四个x、y、速度 v、航向角 heading。车道 ID 这类离散特征如果要用应该做 embedding直接拼成数值会让模型学到错误的大小关系。坐标系的处理是第一个坑。绝对坐标直接输入模型学到的位置模式会被数据集里的道路几何形状绑架——换一条路或者换一个地图模型就失效了。我一般的做法是把每个轨迹片段的起点平移到原点然后把朝向对准 x 轴正方向。这样模型学到的是「相对于起点和初始朝向的运动模式」而不是某条具体道路上的绝对位置。时间步长方面我用 10 帧历史和 10 帧未来采样频率 10Hz也就是各 1 秒。这个配置在高速场景下是合理的1 秒历史足够识别当前的运动趋势1 秒未来恰好覆盖大多数换道决策的提前量。def normalize_trajectory(track): # track: numpy array, shape (T, 4), 列顺序 [x, y, v, heading] # 第一步: 以第一帧位置为原点做平移 origin_x, origin_y track[0, 0], track[0, 1] x_rel track[:, 0] - origin_x y_rel track[:, 1] - origin_y # 第二步: 旋转到初始朝向为 x 轴正方向 theta -track[0, 3] # heading 单位是弧度 cos_t, sin_t np.cos(theta), np.sin(theta) x_rot x_rel * cos_t - y_rel * sin_t y_rot x_rel * sin_t y_rel * cos_t # 第三步: 速度保持原值, 航向角改为相对初始朝向的差值 v track[:, 2] heading_rel track[:, 3] - track[0, 3] return np.stack([x_rot, y_rot, v, heading_rel], axis1)这段代码做了三件事平移去绝对位置、旋转去绝对朝向、航向角改成相对值。注意旋转矩阵的方向——theta 取初始航向角的负值是因为我们要把初始朝向旋转到 x 轴正方向而不是把 x 轴旋转到初始朝向。符号搞反了训练出来的模型会直接发散。速度不需要归一化到 [0,1]但需要做标准化后面讲数据准备的时候会细说。3. 数据准备是高分的前提从原始轨迹到 LSTM 输入张量3.1 数据集长什么样字段、采样频率与车辆 ID 连续性这个项目配套的数据集是典型的高速公路轨迹数据格式类似 NGSIM 的风格每行是一辆车在某一帧的状态包含车辆 ID、帧号、横坐标、纵坐标、速度、加速度、车道编号和航向角。帧号对应时间戳数据集的采样频率是 10Hz也就是每 0.1 秒记录一次。车辆 ID 是连续的但只表示「同一物理车辆的一段连续行驶记录」如果车辆中途驶出摄像头视野再重新进入会分配一个新的 ID。拿到数据第一步不是急着写模型而是检查车辆轨迹的完整性。常见的情况是某些轨迹只有两三帧这种片段做历史输入都不够直接丢掉某些轨迹中间缺帧比如第 100 帧到第 105 帧之间少了两帧如果你不做插值就把它当成连续序列送进 LSTM模型的时序对齐就乱了。我一般会用帧号做差校验连续两帧的帧号差值不等于 1 的要么插值补齐要么切断成两条独立轨迹。3.2 滑窗切分用固定长度历史预测固定长度未来原始数据是一整条长轨迹而 LSTM 需要固定长度的输入输出序列。切分方式是滑窗设定历史窗口长度为 10预测窗口长度为 10从每条完整轨迹的第一个点开始每次滑动一步切出一段样本。举个例子一条 100 帧的轨迹可以切出 80 个训练样本因为前 10 帧只能作为历史最后 10 帧只能作为未来能完整构成一个样本的起点只有第 0 帧到第 80 帧。def sliding_window(track, obs_len10, pred_len10): # track: numpy array, shape (T, feature_dim) # 返回: (input_seq, output_seq) 组成的列表 total track.shape[0] samples [] for start in range(0, total - obs_len - pred_len 1): in_seq track[start: start obs_len] # 历史 10 帧 out_seq track[start obs_len: start obs_len pred_len, :2] # 未来 10 帧, 只需要坐标 samples.append((in_seq, out_seq)) return samples这个函数里有一个容易被忽略的参数选择输出序列只取了 x 和 y 两列而输入序列保留全部特征。因为训练时损失函数只计算坐标误差未来帧的速度和航向角根本不需要预测。如果你把输出也设成全部特征模型的参数量不变但损失函数会去权衡坐标、速度、航向角三种误差坐标预测的精度反而被拉低。这就是「少即是多」的典型例子。3.3 归一化策略全局标准化还是逐轨迹标准化轨迹数据里不同车的速度差异很大——主车道匀速行驶的车可能稳定在 80km/h而换道超车的车瞬间能到 100km/h 以上。全局标准化所有样本用同一个均值和方差能解决量纲问题但有一个隐患如果数据集里低速场景占多数高速场景的样本会被压缩到很小的数值范围模型的学习重心会被低速运动带偏。逐轨迹标准化每个样本用自己的均值和方差可以避免这个问题但会让不同样本的数值范围不一致LSTM 对输入尺度敏感容易训练不稳。我的折中是坐标和航向角用逐轨迹归一化速度用全局标准化。坐标和航向角描述的是运动形态逐轨迹归一化让模型专注于形状速度描述的是运动强度必须和其他样本在同一尺度下比较才有意义。速度的均值和方差从训练集统计存成常量在验证集和测试集上用同一组统计量做变换避免信息泄漏。# speed 全局标准化 v_mean train_data[:, 2].mean() v_std train_data[:, 2].std() train_data[:, 2] (train_data[:, 2] - v_mean) / v_std # 验证集和测试集必须用 train 的统计量, 不能重新算 val_data[:, 2] (val_data[:, 2] - v_mean) / v_std代码块很短但这里有一个隐藏的坑。很多人在验证集上做标准化时习惯性地调用val_data[:, 2].mean()重新算统计量这是一个典型的失误——验证集的分布本来就应模拟测试环境你不能假设测试时能拿到全量数据的均值和方差。训练集统计量的数值要固定写入配置后面推理阶段直接引用。3.4 搭建 Dataset 类把样本转成 PyTorch 张量数据切好、归一化做好接下来就是把样本变成 PyTorch 能吃的格式。Dataset 类的职责很纯粹给出样本数量按索引取出一个输入输出对转成 FloatTensor。注意 LSTM 的输入形状是 (seq_len, batch, feature_dim)但 PyTorch 的 DataLoader 默认按 batch 堆叠所以 Dataset 里返回的形状应该是 (seq_len, feature_dim)让 DataLoader 自动在 batch 维度上合并。我见过有人在这里直接返回了 (batch, seq_len, feature_dim)结果训练循环里要多写一次permute纯属给自己找麻烦。import torch from torch.utils.data import Dataset class TrajectoryDataset(Dataset): def __init__(self, samples): # samples: list of (in_seq, out_seq), in_seq 形状 (10, 4), out_seq 形状 (10, 2) self.samples samples def __len__(self): return len(self.samples) def __getitem__(self, idx): in_seq, out_seq self.samples[idx] # 转成 (seq_len, feature_dim), batch 维度交给 DataLoader in_tensor torch.FloatTensor(in_seq) # (10, 4) out_tensor torch.FloatTensor(out_seq) # (10, 2) return in_tensor, out_tensor使用DataLoader时如果你的 PyTorch 版本较新默认batch_firstFalseLSTM 层会按 (seq_len, batch, feature_dim) 处理输入。如果你想用batch_firstTrue的方式组织数据记得在模型里同步设置否则维度对不上报错信息还不好看懂。4. 用 PyTorch 实现 LSTM 轨迹预测模型结构和训练循环全拆解4.1 网络结构双层 LSTM 加全连接输出层网络结构不复杂但设计理由要说清楚。我用的结构是输入层接收 [x, y, v, heading] 四个特征经过一层线性变换把维度升到 hidden_size然后进入双层 LSTM每层 hidden_size 设为 128最后接一个全连接层把隐状态映射成 2 维坐标输出。为什么用线性层升维而不是直接把 4 维特征喂给 LSTM因为 LSTM 的门控结构对输入维度有一定要求4 维直接输入会让门的计算空间太小表达能力不足先升维到 128等于给模型一个学习特征组合的投影层收敛明显更快。两层 LSTM 比一层效果好的原因在于第一层捕捉短时间内的运动趋势第二层在这基础上建模更长的时序依赖。但要注意层数不是越多越好。三层以上在轨迹预测里收益很小还会带来梯度衰减和训练变慢的问题。hidden_size 的选取和数据量直接相关——几万条样本用 128 是安全的如果只有几千条降到 64 更稳。import torch.nn as nn class TrajectoryLSTM(nn.Module): def __init__(self, input_size4, hidden_size128, num_layers2, output_size2): super().__init__() self.input_proj nn.Linear(input_size, hidden_size) self.lstm nn.LSTM( input_sizehidden_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue ) self.output_head nn.Linear(hidden_size, output_size) def forward(self, x): # x: (batch, seq_len, 4) x self.input_proj(x) # (batch, seq_len, 128) lstm_out, (h, c) self.lstm(x) # lstm_out: (batch, seq_len, 128) y_pred self.output_head(lstm_out) # (batch, seq_len, 2) return y_predforward 里直接输出整条时间序列的预测不需要只取最后一个时间步。因为我们要的是未来 10 帧的坐标而不是一个最终的汇总向量。这样设计的好处是每个时间步的损失都可以计算模型被迫在每个时刻都做出准确预测而不是把压力全部集中在最后一帧上。4.2 损失函数选型为什么用均方误差不够基础版用均方误差 MSE 就能跑通criterion nn.MSELoss()但实际使用中我发现一个问题MSE 对换道场景的预测偏向「平均化」。当同一段历史轨迹在未来有多条合理路径比如 30% 概率换道、70% 概率保持车道MSE 会让模型输出两者的加权平均——一条不存在的、骑在车道线上的轨迹。这个现象在轨迹预测领域叫「平均轨迹问题」。要解决它常见做法是改损失函数。对课程设计级别的项目我会劝你先用 MSE 跑通 baseline然后在进阶版本里换成负对数似然损失让模型输出未来每个时间步的高斯分布参数均值加方差。def gaussian_nll_loss(mu, log_var, target): # mu: (batch, seq_len, 2), log_var: (batch, seq_len, 2), target: (batch, seq_len, 2) var torch.exp(log_var) 1e-6 # 加 epsilon 防止除零 diff (target - mu) ** 2 nll 0.5 * (torch.log(var) diff / var) return nll.mean()网络结构要相应改动输出层从 2 个神经元改成 4 个分别对应 x 的均值、x 的方差对数、y 的均值、y 的方差对数。这个进阶版本的预测结果是一系列高斯分布采样后可以看到多模态的轨迹分布实验结果会明显比 MSE 版本更有说服力。但代价是训练难度上升方差参数可能训练到负数——所以要用 log_var 而不是直接输出 var再取指数保证非负。4.3 训练循环teacher forcing、学习率调度与早停训练循环的核心是三个技巧。第一个是 teacher forcingLSTM 每一步输入是否使用真实历史值。轨迹预测里我们输入的是完整的历史 10 帧而不是让模型自回归地生成所以不存在 teacher forcing 的问题。但如果你想扩展成「预测未来时把上一步预测当输入」的自回归模式就要考虑 scheduled sampling——训练前期用真实值后期逐渐换成模型自己的预测否则推理时误差会快速累积。第二个是学习率调度。我一般用 Adam 优化器初始学习率 0.001训练 30 个 epoch每 10 个 epoch 把学习率乘以 0.5。比固定学习率的效果好很多尤其是训练后期模型接近收敛时高学习率会在最优点附近震荡训练损失曲线会出现锯齿。optimizer torch.optim.Adam(model.parameters(), lr0.001) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.5) for epoch in range(30): model.train() epoch_loss 0.0 for in_seq, out_seq in train_loader: optimizer.zero_grad() y_pred model(in_seq) loss criterion(y_pred, out_seq) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() epoch_loss loss.item() scheduler.step() val_loss evaluate(model, val_loader) print(fEpoch {epoch1:02d} | Train Loss {epoch_loss/len(train_loader):.4f} | Val Loss {val_loss:.4f}) if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_lstm_trajectory.pt)第三个技巧是梯度裁剪。LSTM 在长序列上容易出现梯度爆炸梯度范数超过一定阈值时参数更新会一步跳到很远。clip_grad_norm_把梯度向量缩放到最大范数 5这个操作可以在 PyTorch 里直接调用成本几乎为零但能显著提升训练稳定性。4.4 推理流程加载权重、标准化输入、逆标准化输出训练完成后的推理和训练有本质区别训练时模型一次看 10 帧历史输出 10 帧未来推理时你手里的可能只有最近 10 帧数据。流程是从数据源里取出这 10 帧做和训练时完全一样的归一化然后喂给模型拿到 10 帧预测坐标最后做逆变换还原到真实坐标系。def infer(model, raw_track): # raw_track: numpy array, shape (10, 4), 原始坐标和速度 model.eval() with torch.no_grad(): norm_track normalize_trajectory(raw_track) x torch.FloatTensor(norm_track).unsqueeze(0) # (1, 10, 4) y_pred model(x) # (1, 10, 2) y_pred y_pred.squeeze(0).numpy() # (10, 2) # 逆标准化: 把相对坐标旋转和平移回原始坐标系 theta -raw_track[0, 3] cos_t, sin_t np.cos(theta), np.sin(theta) # 先旋转回来再平移 x_local y_pred[:, 0] * cos_t y_pred[:, 1] * sin_t y_local -y_pred[:, 0] * sin_t y_pred[:, 1] * cos_t x_global x_local raw_track[0, 0] y_global y_local raw_track[0, 1] return np.stack([x_global, y_global], axis1)逆变换这一块最容易翻车因为正向是「平移到原点再旋转到 x 轴正向」反向必须按相反顺序执行先旋转回来再平移回去。旋转矩阵的逆正好是它的转置所以代码里把 cos 和 sin 的符号调换位置就行。这里我吃过亏——一开始直接用np.linalg.inv求逆矩阵数值上是对的但后来发现手工转置更快逻辑也更清楚。5. 训练和部署中的五个高频坑现象、原因与解决5.1 坑一验证集上预测轨迹开裂出现跨车道的虚线轨迹现象训练 MSE 降得很平稳验证集 ADE 在 0.5 米左右但可视化发现预测的轨迹横跨两条车道看起来像一条既不在这条车道也不在那条车道的「幽灵轨迹」。原因这就是前面提到的平均轨迹问题。LSTM 在历史轨迹模糊比如前车遮挡导致几帧数据缺失时学到的是多种未来可能性的期望值。MSE 会让这个期望值落在多个模式之间而不是选择某一个单一模式。解决换用高斯混合损失让模型输出多个未来轨迹分布。如果项目时间紧迫一个轻量替代是添加分类头先让模型预测「保持车道」还是「换道」然后根据分类结果选择不同的回归头输出轨迹。这个方案实现简单而且能明显减少幽灵轨迹出现。5.2 坑二换道起点的帧号错位整个序列的语义全变了现象数据集里某辆车在第 500 帧开始打转向灯换道但滑窗切分出来的样本 1 和历史对齐样本 2 却晚了 3 帧模型预测的换道时机总是慢半拍。原因数据预处理时用了滚动滑窗但换道标签或者参考轨迹没有跟着滑窗同步滑动。本质上你是把两个不同语义的序列强对齐了模型学到的映射关系是「历史轨迹 A 到未来轨迹 B 的错位版本」。解决每次滑窗生成样本时把该样本的元信息起始帧号、车辆 ID、是否包含换道动作一起输出后面做验证集分析时只对包含换道动作的样本单独算指标。这样至少能判断模型是「不会预测换道」还是「训练标签错了」。我排查过多次验证集精度异常最后都是这个原因。5.3 坑三归一化反了方向预测结果完全位移现象训练跑得通损失也低但画出来的预测轨迹不在实际道路上位置偏了好几公里。原因推理阶段做逆变换时旋转矩阵的符号搞错了。正向旋转用的是theta -track[0, 3]反向却忘记取负号导致旋转方向相反。这种错误在损失函数里不可见因为模型学到的坐标是相对坐标损失是在归一化空间里计算的出了模型世界才暴露。解决写一个自测函数取一段真实轨迹的前 10 帧喂给模型把预测结果逆变换回原始坐标系再手动检查第一个预测点的位置是否在车辆的真实位置附近。如果偏差超过 5 米几乎可以肯定是逆变换的问题。这个检查每次都做成本很低。5.4 坑四LSTM 隐层层数加了模型反而不收敛现象把num_layers从 1 改成 3 后训练损失在最初 5 个 epoch 内下降很快然后停滞在某个高位验证损失反而比 1 层更差。原因小规模时间序列数据撑不起深层 LSTM 的表达能力。层数增加意味着更多参数、更大的优化难度和更容易过拟合训练集中的噪声。轨迹预测序列长度只有 10-20 帧时间依赖本身不深1 层或 2 层已经足够。解决如果非要加深网络先加 hidden_size 而不是层数观察验证损失的变化。如果验证损失没有显著下降说明模型容量已经够用继续加参数只会过拟合。我自己的默认配置就是 2 层 128 隐藏单元大部分场景下已经跑赢更复杂的结构。5.5 坑五批大小设置不当训练损失曲线剧烈震荡现象批大小设为 512训练损失曲线像锯齿一样每步下降一点然后跳回原位批大小改成 32 之后曲线变平滑了但训练速度慢了很多。原因轨迹预测的样本之间存在相关性——同一辆车的多个滑窗样本在批次里同时出现梯度方向被这种窗口间的相关性主导导致优化过程震荡。批太大时这种相关性被放大梯度噪声也随之增大。解决批大小建议设在 64 到 128 之间同时开启 DataLoader 的shuffleTrue打散同一辆车不同滑窗样本的集中出现。如果还想更进一步可以在 Dataset 里对车辆 ID 做分组保证每个批次里最多出现 4 辆车相关性就控制得更好了。6. 用 ADE 和 FDE 评估把预测轨迹画出来看评价指标上轨迹预测领域的两个硬指标必须用起来。ADEAverage Displacement Error是预测轨迹和真实轨迹在所有时间步上的平均欧氏距离衡量整体偏差FDEFinal Displacement Error只比较最终时刻的位移衡量模型在终点预测上的能力。写评估代码时不复杂但有一个细节评估要在原始坐标系上做不要在归一化空间里算——否则 ADE 会受坐标缩放的误导。def compute_ade_fde(y_pred, y_true): # y_pred, y_true: (num_samples, pred_len, 2) diff np.sqrt(np.sum((y_pred - y_true) ** 2, axis-1)) # (num_samples, pred_len) ade diff.mean(axis-1) # 每条样本的 ADE fde diff[:, -1] # 每条样本的 FDE return ade.mean(), fde.mean()数值只能告诉你「好还是不好」可视化告诉你「为什么」。我每次训练完都会做一个固定的验证步骤随机挑 20 条测试集样本把历史轨迹、真实未来轨迹、预测轨迹画在同一张图上背景画上车道线。肉眼能看出的问题包括预测轨迹是否平滑、换道时机是否合理、是否出现幽灵轨迹。这一步的时间成本不超过十分钟但对项目报告的贡献比任何量化指标都大——评审老师一眼就能看出你的模型是真的理解车辆运动还是只在拟合训练集。最后说一个我自己的习惯模型保存时同时保存归一化统计量和坐标变换参数作为单独的 JSON 配置文件而不是把它们也放到模型权重里。原因是推理时如果文件损坏只要配置还在数据预处理逻辑就能完全复现不至于所有实验推倒重来。这个习惯帮我避免过好几次灾难希望帮到你。本文还有配套的精品资源点击获取