资讯详情

PyTorch LSTM实现高速车辆轨迹预测:NGSIM数据预处理与模型实战

📅 2026/10/27 20:17:44 | 华诺云谱 👁 阅读
PyTorch LSTM实现高速车辆轨迹预测:NGSIM数据预处理与模型实战
简介面向自动驾驶与智能交通领域研究者提供一套基于PyTorch和LSTM的高速公路车辆轨迹预测完整项目包使用NGSIM的US101和I-80真实数据集解决车辆轨迹序列建模与短期预测问题。压缩包共16个文件包含9个Python脚本、5个结果示意图和2个Markdown说明文档整体大小311KB。Python脚本覆盖了轨迹数据滤波、不必要的特征移除、横纵向速度加速度特征添加、滑动窗口法提取8秒轨迹序列、多数据集合并、MTF-LSTM与MTF-LSTM-SP模型训练和测试等完整环节png图片直观展示每一步处理效果md文档提供中英文项目说明。已有698人学习/下载。用户可获得从原始NGSIM数据清洗、特征工程到模型训练与评估的全套源码以及已训练好的模型文件便于直接复现和在此基础上开展轨迹预测研究。1. LSTM 高速轨迹预测值不值得下这份资源能帮你省掉哪几步高速公路上的车辆轨迹预测核心问题就一句话未来 3 到 5 秒这辆车会出现在哪里。用 PyTorch 搭一个 LSTM 来做这件事是我见过性价比最高的路径——网络结构不复杂训练成本低效果在公开数据集上又足够说明问题。这份资源把数据清洗、滑窗切片、模型定义、训练评估四步都做成了能直接跑通的代码还带了切好的数据集。适合做自动驾驶决策、车路协同、交通流仿真的工程师也适合拿轨迹预测当毕设题目的人。我自己拆完这个包的第一感受是模型本身不复杂复杂的是数据切分和评估口径。如果你正卡在“面对 NGSIM 原始 CSV 不知道从哪下手”或者“训练出来预测线不动”的阶段这份资源能让你跳过最磨人的前置部分直接把精力放在改模型和调效果上。2. 把 NGSIM 原始 CSV 变成训练序列滑窗、归一化与数据划分2.1 为什么基线选 NGSIM 而不是自采数据车辆轨迹预测绕不开 NGSIM它采集的是美国 US-101 和 I-80 两条高速公路的真实车辆轨迹采样频率 0.1 秒一帧包含位置、速度、加速度、车道编号这些关键字段。自采数据不是不行但毫米波雷达加视觉融合的整套标定、同步、遮挡处理流程周期是以月计的。做算法验证的起步阶段用公开数据集把基线跑通再回头决定要不要采自己的数据这是最稳的路线。资源包里带的数据集就是按本文这套流程切好的轨迹片段加载后直接是三维数组不需要再从 CSV 开始折腾。我先把 NGSIM 最常用的字段列出来后面写预处理代码时你会反复跟它们打交道。字段含义单位/说明Vehicle_ID车辆编号同一文件内存在分段重用Frame_ID帧号0.1 秒一帧逐帧加 1Local_X道路横向位置英尺ftLocal_Y道路纵向位置英尺ftv_Vel瞬时速度英尺/秒v_Acc瞬时加速度英尺/秒²Lane_ID车道编号从中央分隔带向外编号提示NGSIM 完整数据集需要去官方申请资源包里这份子集已经做好了清洗和切片直接能用于训练。2.2 滑窗切分与向量化从 DataFrame 到 numpy 数组模型的输入是固定长度的历史轨迹输出是固定长度的未来轨迹。NGSIM 里每条车的轨迹长度从几十帧到几千帧不等所以要做滑窗切分。我一般用history202 秒历史和horizon101 秒未来作为默认配置在绝大多数场景下2 秒历史足够 LSTM 捕获换道和跟停的意图1 秒未来是轨迹预测最基础的评估口径。import pandas as pd import numpy as np COLUMNS [Vehicle_ID, Frame_ID, Local_X, Local_Y, v_Vel, v_Acc, Lane_ID] def load_ngsim(path): # NGSIM 原始 CSV 字段很多只取轨迹预测用得到的列 return pd.read_csv(path, usecolsCOLUMNS) def split_by_vehicle(df, min_len30): 按车辆分组过滤掉太短的轨迹片段 groups [] for veh_id, g in df.groupby(Vehicle_ID): g g.sort_values(Frame_ID).reset_index(dropTrue) if len(g) min_len: groups.append(g) return groups def make_sequences(groups, history20, horizon10, step5): 滑窗切分生成 (历史坐标, 未来坐标) 样本对 xs, ys [], [] for g in groups: coords g[[Local_X, Local_Y]].values.astype(np.float32) # 窗口起点从 0 开始步长为 step for i in range(0, len(coords) - history - horizon, step): x coords[i : i history] # 过去 2 秒 y coords[i history : i history horizon] # 未来 1 秒 xs.append(x) ys.append(y) return np.array(xs), np.array(ys)这里step5是相邻样本的起点间隔单位是帧。step 越小样本重叠越多数据量越大step 越大样本越独立但总数会变少。NGSIM 切片出来通常是十万到几十万样本量级step 取 5 是在数据量和训练速度之间的一个妥协值。要注意的是y存的是未来 10 帧的绝对坐标不是坐标差值。有的实现里会输出“下一帧相对当前帧的偏移”训练时 loss 更容易降但推理时要逐帧累加才能还原绝对位置误差会滚雪球。直接回归绝对坐标训练稍微难一点但评估和可视化都干净。2.3 数据划分与归一化两个影响指标真实性的决策点数据划分这里有一个关键决策必须按车辆的连续轨迹段划分不能按行随机划分。如果随机打乱所有滑窗样本同一条车的轨迹片段会同时出现在训练集和验证集评估出的 ADE/FDE 会虚高——你实际测的是“背题”效果不是泛化效果。我一般按 64/16/20 的比例把车辆连续段分成训练、验证、测试三份。from sklearn.preprocessing import StandardScaler def split_by_track(groups, train_ratio0.64, val_ratio0.16): 按车载连续段划分保证同一条轨迹不出现在两个集合里 rng np.random.default_rng(42) indices rng.permutation(len(groups)) n_train int(len(groups) * train_ratio) n_val int(len(groups) * val_ratio) train_idx indices[:n_train] val_idx indices[n_train:n_train n_val] test_idx indices[n_train n_val:] return [groups[i] for i in train_idx], [groups[i] for i in val_idx], [groups[i] for i in test_idx] train_groups, val_groups, test_groups split_by_track(groups) x_train, y_train make_sequences(train_groups) x_val, y_val make_sequences(val_groups) x_test, y_test make_sequences(test_groups) # 归一化参数只在训练集上拟合 scaler StandardScaler() train_flat x_train.reshape(-1, 2) scaler.fit(train_flat) x_train_norm scaler.transform(train_flat).reshape(x_train.shape) # 验证集和测试集必须用同一个 scaler禁止重新 fit x_val_norm scaler.transform(x_val.reshape(-1, 2)).reshape(x_val.shape)归一化这里最常见的错误是对全局数据做fit或者在验证集上重新算均值和方差。前者会把测试集的统计信息泄露进训练过程后者会让训练和推理的输入分布不一致。正确做法只有一种——在训练集上fit一次训练集、验证集、测试集全部用这同一组参数transform。推理的时候保存这个 scaler预测完再做逆变换还原成物理坐标。另一个容易踩的地方是归一化只做了输入特征输出的真值坐标也要做同样的归一化。这么做是为了让 loss 的数值处于合理量级否则 x、y 坐标是几千英尺量级MSE loss 会大到梯度爆炸。把y_train也用同一个 scaler 处理或单独 fit 一个只针对输出的 scaler效果都一样关键是训练和评估必须用同一套变换参数。3. 两层 LSTM 模型实战PyTorch 代码与序列建模细节3.1 任务定义与模型选型为什么是 LSTM把问题形式化输入是过去 20 帧的二维坐标[B, 20, 2]输出是未来 10 帧的二维坐标[B, 10, 2]。这是典型的多步时间序列预测LSTM 建模的是序列的条件概率——给定历史的隐状态去推理未来一段时间的空间位置分布。模型选型上LSTM 在这个任务里处于一个很舒服的位置RNN 在长序列上梯度消失严重20 帧虽然不算长但直接用 RNN 会明显感受到收敛慢Transformer 需要更长的序列和更大的数据量才能体现优势轨迹预测这种输入长度只有 20 的小样本场景很容易过拟合。GRU 参数比 LSTM 少训练更快但在这个任务上两层的 LSTM 和 GRU 效果差异很小。我选择 LSTM 的另一个原因是它作为baseline 的论文和实现最多后续要做对比实验参照系现成。import torch import torch.nn as nn class TrajLSTM(nn.Module): def __init__(self, input_dim2, hidden_dim128, num_layers2, horizon10): super().__init__() self.horizon horizon self.lstm nn.LSTM( input_dim, hidden_dim, num_layers, batch_firstTrue, dropout0.2 if num_layers 1 else 0.0 ) self.head nn.Sequential( nn.Linear(hidden_dim, 64), nn.ReLU(), nn.Linear(64, horizon * 2) # 10 帧 * (x, y) ) def forward(self, x): # x: [B, T, 2] out, _ self.lstm(x) # out: [B, T, hidden_dim] last_hidden out[:, -1, :] # 取最后一帧的隐状态 pred self.head(last_hidden) # [B, horizon*2] return pred.view(-1, self.horizon, 2)3.2 模型代码两层 LSTM 全连接输出这段代码就是整个包的核心模型文件。batch_firstTrue让输入形状保持[B, T, 2]PyTorch 的 LSTM 默认输入是[T, B, 2]这个参数写错的话 tensor shape 会一直和你较劲属于最常见的低级但磨人的错误。dropout0.2只在层数大于 1 时生效这是 PyTorch 的限制——单层 LSTM 传 dropout 参数不会报错但也不会起作用别指望它。head部分是全连接层输入取的是最后一帧的隐状态而不是整个序列的均值。为什么用最后一帧因为 LSTM 的隐状态已经编码了整个历史序列的信息最后一帧在时间上离未来最近信息密度最高。head 输出的维度是horizon * 2然后view(-1, horizon, 2)还原成[B, 10, 2]的形状这样计算 MSE loss 时不需要手动 reshape。请注意输出层不需要任何激活函数这里如果手滑加一个 Sigmoid预测坐标会被强行压到 0 到 1 之间模型直接废掉。这个问题后面避坑章节会细说。PyTorch 版本 1.13 和 2.x 都兼容这份代码不需要装 nightly 版。CPU 也能训练只是慢一些有 NVIDIA 显卡的话CUDA 环境下torch.cuda.is_available()返回 True 就能自动走 GPU。3.3 训练与推理的序列逻辑直接多步 vs 滚动递归轨迹预测的输出方式有两种流派。第一种是本文模型采用的直接多步预测direct multi-horizon一次前向输出整个未来 10 帧。优点是训练简单loss 直接对 10 帧的预测和真值计算误差不累积缺点是 horizon 固定想预测 3 秒就得重新训练一个模型。第二种是递归多步recursive每步只预测下一帧把预测结果塞回输入窗口再次预测循环 30 次就能得到 3 秒的轨迹。优点是灵活缺点是误差逐帧累积越往后越偏。这个包里的 LSTM 用的是直接多步结构因为论文里 ADE/FDE 的评估口径一般是固定 1 秒或 3 秒直接多步的实现和评估都最干净。工程上需要更长预测时在模型外面套一层滚动逻辑就能延长预测长度这个我在最后一章展开讲。训练阶段不存在 teacher forcing 的问题——因为模型是一次性输出整个未来窗口不是逐帧递归所以没有“喂真实值还是喂预测值”的选择。这一点和逐帧预测的 LSTM 写法不一样写代码时要注意别搞混。推理阶段模型吃的仍然是纯历史真实观测这点和训练一致。4. 训练与评估损失函数、收敛判断和 ADE/FDE 指标口径4.1 损失函数与评价指标ADE/FDE 怎么算训练损失用 MSE均方误差这是坐标回归任务最稳妥的选择。MSE 对较大误差惩罚更重能让模型优先学好转弯和换道这些位移较大的动作。L1 loss 在轨迹预测里也能用但梯度恒为 ±1收敛到后期容易在最优值附近震荡不如 MSE 平滑。评估指标是轨迹预测领域约定俗成的两个量ADEAverage Displacement Error平均位移误差和 FDEFinal Displacement Error最终位移误差。ADE 是所有预测帧和真值帧的欧氏距离的均值衡量整体贴合度FDE 只取最后一帧的误差衡量终点位置的准确度。后者在自动驾驶决策里很关键因为终点决定了下一步的跟车或变道判断。def ade_fde(pred_tensor, target_tensor): 输入形状均为 [B, horizon, 2] 返回 (ADE, FDE)单位与输入坐标一致 dist torch.norm(pred_tensor - target_tensor, dim-1) # [B, horizon] ade dist.mean(dim-1).mean().item() fde dist[:, -1].mean().item() return ade, fde注意这个函数输出的单位取决于你传入的坐标单位。如果用归一化坐标算结果毫无物理意义所以评估前必须把预测和真值都做scaler.inverse_transform还原成英尺或米再算 ADE/FDE。我习惯统一换算成米NGSIM 的原始坐标单位是英尺乘 0.3048 转成米再评估。这样做是为了和主流论文的指标口径对齐否则你报 DE 值 2.5 出来别人一比就知道单位对不上。4.2 训练主循环与超参数从 lr 到梯度裁剪训练代码没有太多玄学但有几个参数是固定的处理。batch size 用 256学习率 1e-3优化器 Adam再加一个 StepLR 学习率衰减。梯度裁剪是 LSTM 类模型必须写的不然序列反传时梯度范数偶尔会飙升loss 曲线会突然出现一个尖峰然后训练崩溃。from torch.utils.data import TensorDataset, DataLoader import torch.nn.functional as F dataset TensorDataset( torch.tensor(x_train_norm, dtypetorch.float32), torch.tensor(y_train_norm, dtypetorch.float32) ) loader DataLoader(dataset, batch_size256, shuffleTrue) model TrajLSTM() optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size20, gamma0.5) for epoch in range(60): model.train() total_loss 0.0 for xb, yb in loader: optimizer.zero_grad() pred model(xb) loss F.mse_loss(pred, yb) loss.backward() # LSTM 反传梯度范数容易爆炸必须裁剪 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() * xb.size(0) scheduler.step() # 每个 epoch 结束后在验证集上算一次 ADE/FDE model.eval() with torch.no_grad(): pred_val model(torch.tensor(x_val_norm, dtypetorch.float32)) ade, fde ade_fde(pred_val, torch.tensor(y_val_norm, dtypetorch.float32)) print(fepoch {epoch:02d} | loss {total_loss/len(loader.dataset):.4f} | ADE {ade:.3f} | FDE {fde:.3f})clip_grad_norm_的max_norm5.0是经验值意思是把整个模型的梯度范数限制在 5 以内。太大起不到保护作用太小会拖慢收敛。如果发现 loss 在某个 epoch 突然变成 NaN先检查是不是裁剪没写再检查学习率是否过大。StepLR每 20 个 epoch 把学习率减半配合 60 轮的总训练时长收敛节奏比较合理。显存方面输入序列长度只有 20batch 256 在 6 GB 显存的显卡上毫无压力。如果显卡更老batch size 降到 64学习率同步降到 3e-4一样能收敛只是训练步数变多。4.3 收敛判断与调参loss 曲线怎么看判断训练是否健康我习惯看三样东西训练 loss、验证集 ADE、两者的差距。健康曲线是前 10 个 epoch 训练 loss 快速下降20 到 30 个 epoch 进入平台期验证 ADE 同步下降并趋于稳定。如果训练 loss 一直降但验证 ADE 反弹就是过拟合这时候在 LSTM 层加 dropout 或缩小 hidden_dim。如果训练 loss 在某个值上长时间不动先调学习率再用step_size更小的调度器比如每 10 轮减半。数据量层面几十万样本对这个规模的模型来说比較充足通常 60 轮训练在 30 轮附近已经收敛。你也可以用 early stopping验证 ADE 连续 5 个 epoch 不下降就保存当前模型并停止。保存模型时记得把 scaler 一起存下来评估时要用它做逆变换。这里有一个我踩过的坑只保存了模型参数换了机器跑评估时忘了加载 scaler预测结果全在归一化空间里画出来的轨迹是一条被压扁的线。5. 避坑排查五个让预测结果翻车的真实案例5.1 预测轨迹钉在原地保持直线或几乎不动现象训练 loss 正常下降但可视化看到预测轨迹是一条很短的直线甚至原地不动而真值明明在正常前进。原因最常见是输出层误加了 Sigmoid 激活函数坐标被压缩到 0-1 区间且无法表达较大位移。另一个常见原因是预测结果忘了做scaler.inverse_transform直接在归一化空间里画图看起来自然就是一团贴着零点的短线。解决回归任务的输出层不加任何激活函数最后一层 Linear 裸奔可视化前对预测值和真值同时做逆变换。我一般会在测试脚本里加一句断言检查预测坐标的标准差是否和训练集原始坐标标准差在同一个数量级不对劲直接报错。5.2 推理滚动预测时越推越飘轨迹偏离车道现象单步预测直接用真实历史窗口预测未来 10 帧效果不错但把预测结果拼回输入窗口继续推推到第 3 个窗口就开始大幅偏离道路。原因递归多步的固有误差累积同时你的滚动方式可能把模型自己的预测值全塞进了输入窗口导致输入分布被污染——模型从来没有在训练时见过“输入里包含不精确预测值”的情况。解决滚动时每个新窗口只保留最近的真实观测帧或者每隔一个预测窗口就用真实值校正一次。如果必须全预测值滚动那输入窗口长度要大于预测输出长度让新的输入窗口里仍然以真实帧为主。误差累积是物理规律不存在彻底消除的办法控制滚动长度才是务实方案。5.3 按 Vehicle_ID 分组后轨迹出现位置跳变现象明明是一辆车轨迹却在中途突然从道路中间跳到路边速度曲线也同时断裂。原因NGSIM 的 Vehicle_ID 不是全局唯一的同一份 CSV 文件里存在分段重用的情况。直接按 Vehicle_ID 做 groupby会把两辆不同车的轨迹拼接成一条。解决分组时同时检查 Frame_ID 的连续性如果相邻两行的 Frame_ID 差值大于 1正常应该逐帧加 1就在那里断开。我处理 NGSIM 时的习惯组逻辑是Vehicle_ID 相同且 Frame_ID 连续才算同一条轨迹段。5.4 评估指标好到离谱ADE 零点几米可视化却对不上现象验证集 ADE 只有 0.3 米效果比很多论文都好但画出来的预测轨迹和真值明显不重合。原因单位换算出了问题。NGSIM 坐标是英尺直接把英尺数值当米算误差数字自然好看——1 英尺只有 0.3048 米算成“米”所有指标都得除以 3 以上。还有一个隐蔽原因是数据划分泄漏同一条车的滑窗样本同时出现在训练和验证集。解决指标计算前统一单位换算我默认都转成米。另外回到第 2 章确认划分逻辑是按车辆连续段划分而不是按样本行随机划分。这两件事都检查过指标和可视化才对得上。5.5 训练结果不稳定每次跑 ADE 差很多现象同样的数据和代码换台机器或换次随机种子验证 ADE 从 0.8 米变到 1.5 米方差很大。原因滑窗样本重叠度高batch 顺序的随机性对梯度方向影响被放大另外如果验证集样本太少单批评估的波动本身就很大。解决全局固定随机种子数据处理和模型初始化都走一遍确定性设置验证集按车辆段划分要保证足够的样本数量至少几千条连续轨迹评估时跑三次取平均。下面的代码我每次训练前都会执行一次。import random def set_seed(seed42): torch.manual_seed(seed) np.random.seed(seed) random.seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)6. 进阶验证滚动预测、可视化自检与后续扩展思路6.1 滚动预测从 1 秒延伸到 5 秒直接多步模型只能输出固定的未来 10 帧但工程上往往需要看 3 到 5 秒。做法是在模型外部套滚动窗口把模型预测出的前几帧坐标拼到输入序列尾部丢掉输入头部对应数量的帧再喂给模型继续预测。下面的函数可以任意控制总预测帧数。def rollout_predict(model, init_seq, total_steps50, window20, horizon10): 滚动预测每次用最近 window 帧预测 horizon 帧逐步推进 init_seq: 归一化后的真实历史序列形状 [window, 2] model.eval() seq init_seq.copy() preds [] with torch.no_grad(): while len(preds) total_steps: x torch.tensor(seq[-window:], dtypetorch.float32).unsqueeze(0) pred model(x).squeeze(0).numpy() # [horizon, 2] preds.extend(pred.tolist()) # 把预测拼回序列只保留最近 window 帧避免序列无限增长 seq np.vstack([seq, pred])[-window:] return np.array(preds[:total_steps])这个函数里seq np.vstack([seq, pred])[-window:]是核心逻辑每次预测 10 帧输入窗口是 20 帧所以新窗口里包含了 10 帧真实或早期预测的旧值、10 帧新预测值。滚动越深窗口里属于模型自生成的内容越多误差累积越明显。我实测下来这个配置推到 5 秒左右 ADE 会膨胀到单步预测的 2 到 3 倍这是正常的工程上要么限制滚动长度要么每 1 秒用传感器真实位置校正一次。6.2 可视化自检与模型扩展方向每次训练完我都会把一条真实测试轨迹的预测结果画成图历史观测涂灰、预测轨迹用红色实线、真值用绿色虚线。一张图就能看出模型有没有学到车道的约束——如果预测线笔直冲出道路边界说明模型只拟合了时序惯性没有学到道路拓扑。走完这套基线的下一步我建议从两个方向扩展。第一个是把 Lane_ID 和周围车辆的相对位置作为额外特征拼进输入LSTM 输入维度从 2 变成 5 或 7模型能学到换道时的交互行为第二个是把 Lane_ID 离散化做意图分类和轨迹回归输出拼在一起做多任务学习这是目前轨迹预测论文的主流框架。资源包里代码的模块划分是数据处理、模型、训练、评估四块改特征维度只需要动数据预处理和输入维度参数。我自己现在训完模型一定会强制跑一遍滚动预测加可视化自检确认预测线不会在换道场景里“原地等待”也不会在跟停场景里提前冲出去。这个习惯源于第一次把归一化参数用在全量数据上导致验证指标虚高而不自知——从那以后数据划分和归一化代码被死死固定成独立脚本训练代码只读取预处理后的.npy文件。这套流程多花十分钟但能省掉后面几天返工的代价值得形成肌肉记忆。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑