ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

轨迹预测训练评估闭环:数据管线、多模态模型与ADE/FDE评估

轨迹预测训练评估闭环:数据管线、多模态模型与ADE/FDE评估

简介:这份压缩包面向具备一定深度学习基础、希望复现轨迹预测实验的开发者与学习者,聚焦基于循环神经网络的轨迹序列建模与预测任务。包内共11个文件,以7个Python脚本为核心,配合训练检查点、CSV样本数据及分片数据索引文件,整体约723KB;脚本覆盖LSTM与GRU模型实现、逻辑回归对比实验、数据读取预处理和轨迹分析等模块,可据此梳理从数据加载、模型训练到结果对比的完整流程。已有230人学习,说明该案例在轨迹预测入门与课程实践中具有一定参考价值。读者可从中获得可运行的模型代码、对比实验思路以及检查点与数据组织方式,便于在此基础上替换数据、调整网络结构或补充评价指标,适合用作课程设计、小规模实验复现与算法对照的起点。

1. 轨迹预测代码包拆开看:一套能跑的训练-评估闭环长什么样

“完整代码.zip轨迹预测”这个名字透露的信息不多,但压缩包解开后的目录结构往往很实在:数据解析、滑窗切样本、编码器-解码器模型、训练脚本、ADE/FDE 评估、可视化回放,一条链走完。做轨迹预测的人最常卡住的地方往往在坐标系、帧率和评估口径上,而不是模型本身——同一个模型换个口径,ADE 能从 0.4 跳到 1.2,最后连自己都不信自己的数。这份代码包的价值在于它把这条链固定下来了,作为软件/插件类资源,它可以直接嵌进感知回放或仿真流程里跑,不需要从零搭骨架。适合两类人:做行人、车辆轨迹预测的算法工程师,以及刚接手相关模块、需要一份能跑通的基线来对比自己方案的人。想直接上生产还差封装和推理加速,这部分放在最后一章说。

2. 环境与数据管线:把原始轨迹喂进模型前要过的四道工序

轨迹预测的翻车现场,八成发生在模型 forward 之前。原始标注是一帧一帧的散点,模型要的是定长的 (观测段, 预测段) 样本对;这中间隔着依赖、目录、滑窗切分、坐标归一化四道工序,任何一道错了,后面训练得再顺都是白跑。这一章按执行顺序拆开,每一步给可抄的代码和参数取值的依据。

2.1 依赖与目录:先让 import 通过

这类代码包通常按“配置-数据-模型-脚本”四层组织,先看一遍目录再动手装环境,比直接pip install -r requirements.txt更省时间。

# 建议先建独立环境,轨迹预测包对 numpy/torch 版本比较敏感 conda create -n trajpred python=3.9 -y conda activate trajpred # torch 与 CUDA 版本要匹配,先确认驱动 nvidia-smi # 按驱动支持的 CUDA 版本装 torch,别直接装默认的 CPU 包 pip install torch --index-url https://download.pytorch.org/whl/cu118 # 其余依赖(numpy / pandas / scipy / matplotlib / pyyaml / tqdm) pip install -r requirements.txt

装完先做一次冒烟测试,只 import 不训练:

python -c "import torch, numpy, pandas, scipy; print(torch.__version__, torch.cuda.is_available())"

逻辑说明:torch.cuda.is_available()返回 False 是最常见的“跑得动但慢十倍”的原因,很多时候是装成了 CPU 版 torch,或者驱动版本低于 torch 编译时的 CUDA 版本。参数说明:--index-url里的 cu118 要按nvidia-smi右上角的 CUDA Version 下调一档选,比如驱动只支持到 11.8,就不要装 cu121 的包,否则 import 时就报libcudart.so找不到。

目录上重点确认三样东西在不在:数据目录下有没有预处理好的npz/pkl样本缓存,模型目录下有没有__init__.py暴露的注册表(很多包用字符串名映射模型类),脚本目录下训练和评估是不是分开的两个入口。有些包把评估逻辑写死在train.py里,每轮跑测试集,这种要提前改成按验证集早停,原因在避坑章节展开。

2.2 滑窗切分:obs_len 与 pred_len 的取值依据

轨迹预测的标准设定是“看几帧、猜几帧”。行人数据集常见 2.5 Hz 标注(每帧 0.4 秒),观测 8 帧合 3.2 秒,预测 12 帧合 4.8 秒;车载场景数据帧率更高,10 Hz 或 20 Hz,对应的帧数要按秒数换算,不能照抄 8/12。这一步的核心是把每个 agent 的连续轨迹切成固定长度片段,断帧处必须切断。

import numpy as np import pandas as pd from collections import defaultdict OBS_LEN, PRED_LEN, STRIDE = 8, 12, 1 # 3.2s 观测 / 4.8s 预测,步长 1 帧 MIN_LEN = OBS_LEN + PRED_LEN # 一条样本至少需要的连续帧数 def load_tracks(csv_path): """读取一份场景标注,按 agent 聚合成轨迹列表""" df = pd.read_csv(csv_path) # 约定列: frame, agent_id, x, y tracks = defaultdict(list) for r in df.itertuples(index=False): tracks[r.agent_id].append((int(r.frame), float(r.x), float(r.y))) for k in tracks: tracks[k].sort(key=lambda t: t[0]) return tracks def build_samples(tracks, obs_len=OBS_LEN, pred_len=PRED_LEN, stride=STRIDE): samples = [] win = obs_len + pred_len for aid, seq in tracks.items(): seg = [] prev = None # 末尾补一个哨兵,保证最后一段也能被收尾 for item in seq + [(None, None, None)]: if prev is not None and item[0] != prev + 1: arr = np.asarray(seg, dtype=np.float32) # [L, 3] -> frame,x,y for s in range(0, len(arr) - win + 1, stride): samples.append(( aid, arr[s:s + obs_len, 1:], # 历史 [obs_len, 2] arr[s + obs_len:s + win, 1:], # 未来 [pred_len, 2] )) seg = [] if item[0] is not None: seg.append(item) prev = item[0] return samples

逻辑说明:seq + [(None, None, None)]是收尾技巧,让最后一段轨迹在循环结束时也能进入切分分支,不用在循环外再抄一遍切分代码。断帧判断item[0] != prev + 1必须保留——标注里丢帧、遮挡补帧很常见,跨过空洞切出来的样本等于让模型学“瞬移”,训练 loss 会异常但不会报错,属于典型的黑匣子问题。

参数说明:STRIDE=1时相邻样本高度重叠,样本量大约是非重叠切法的win倍,适合数据量小的行人数据集;车辆数据样本多,STRIDE可设成pred_len减少冗余。obs_len不要低于 4,否则速度方向估不准;pred_len超过 20 时误差会快速发散,多模态的价值才真正体现出来。

2.3 坐标归一化:别让量纲吃掉你的 ADE

模型直接吃世界坐标(UTM 或墨卡托)是灾难:不同场景的绝对坐标能差出几十万米,第一层线性层的权重会被拉到极端值。标准做法是“平移 + 可选旋转 + 可选缩放”,平移以最后一帧观测位置为原点,这一步任何场景都必须做。

def normalize(obs_xy, pred_xy=None, mode="rotate", scale=1.0): """obs_xy: [T_obs, 2] pred_xy: [T_pred, 2] 或 None""" origin = obs_xy[-1].copy() # 以最后观测帧为原点 obs = (obs_xy - origin) / scale pred = None if pred_xy is None else (pred_xy - origin) / scale if mode == "rotate": d = obs[-1] - obs[-2] # 末两帧位移定朝向 if np.linalg.norm(d) > 1e-3: # 静止目标不旋转,避免除零 theta = np.arctan2(d[1], d[0]) c, s = np.cos(-theta), np.sin(-theta) R = np.array([[c, -s], [s, c]], dtype=np.float32) obs = obs @ R.T if pred is not None: pred = pred @ R.T return obs, pred

逻辑说明:平移把所有样本拉回同一量级,缩放把米制距离压到 0~1 附近,旋转把朝向对齐到 +x 轴。三件事的作用不同,别混着做:只平移时模型要自己学朝向不变性,数据量小的时候学不动;加了旋转后车辆场景的收敛速度通常明显提升,因为直行、左转、右转在归一化后形态区分更干净。

参数说明:scale建议用训练集所有样本的历史位移范数的中位数,不要用最大值(会被个别跳点带偏),也不要用验证集统计量——这是数据泄漏的常见入口。行人场景scale大概在 2~5 米量级,车辆场景在 10~40 米量级,值明显偏离这个范围说明数据单位可能不是米。

3. 模型与训练:编码器解码器怎么读,超参怎么改

模型部分不用逐行啃,先确认三件事:历史怎么编码、agent 之间怎么交互、未来怎么输出多条。这三点定了,剩下的都是可以调的超参和训练技巧。这一章按“读结构 → 改超参 → 选路线”的顺序走,最后给三种多模态方案的取舍。

3.1 结构速读:历史编码、交互建模、多模态解码头

以最常见的编码器-解码器实现为例,数据在里面的形状变化是固定的,画成表比看代码快。

阶段张量名形状说明
输入hist[B, T_obs, 2]归一化后的历史位移
历史编码h[B, H]LSTM 取末状态或时序注意力池化
交互建模h_ctx[B, H]图注意力/池化,聚合邻近 agent
解码输出pred[B, K, T_pred, 2]K 条候选未来
概率输出prob[B, K]部分实现给,部分不给

B是 batch 内的 agent 数而不是场景数,这点在改 batch size 时容易搞错。K一般取 6、12、20,越大 minADE 越好看但推理越慢,也越容易过拟合。

import torch import torch.nn as nn class TrajPredictor(nn.Module): def __init__(self, hidden=64, k=6, t_pred=12): super().__init__() self.k, self.t_pred = k, t_pred self.enc = nn.LSTM(2, hidden, batch_first=True) # 历史编码 self.dec = nn.GRUCell(hidden + 2, hidden) # 逐步解码 self.head = nn.Linear(hidden, k * 2) # 一次出 K 条首步 def forward(self, hist, neigh=None): # hist: [B, T_obs, 2];neigh 可选,[B, N, H] 邻近 agent 编码 _, (h, _) = self.enc(hist) # h: [1, B, H] h = h[-1] if neigh is not None: # 简单池化做交互 h = h + neigh.mean(dim=1) outs, pos = [], torch.zeros(hist.size(0), 2, device=hist.device) for _ in range(self.t_pred): h = self.dec(torch.cat([pos, h], dim=-1), h) # 自回归 step = self.head(h).view(-1, self.k, 2) # [B, K, 2] pos = step[:, 0] # 训练走 teacher forcing outs.append(step) return torch.stack(outs, dim=2) # [B, K, T_pred, 2]

逻辑说明:上面是简化版骨架,实际包里的解码器可能是 CVAE 的隐变量采样、GAN 的生成器或锚点回归头,但张量形状一致。pos = step[:, 0]是训练期的近似 teacher forcing,推理时改成按概率取 top-1 或者直接输出 K 条互不依赖(不走自回归)。自回归解码有个隐性坑:训练和推理的输入分布不一致,长预测时误差会累积,最后一章的验证方法会专门检查这一点。

参数说明:hidden64~128 是性价比区间,再大对 minADE 提升有限;k=6对应 minADE/minFDE 的常用口径,改 K 之后所有对比结果都不可比,要重跑基线。交互模块如果包里有use_social之类的开关,行人密集场景务必打开,稀疏的高速公路场景关掉能省不少算力。

3.2 训练循环的四个关键参数

训练入口一般是 argparse 风格,四个参数决定成败:学习率、batch 口径、KL/多样性损失权重、早停指标。

# 典型训练命令,参数含义见下表 python train.py \ --data ./data/processed/pedestrian \ --obs-len 8 --pred-len 12 \ --batch-size 64 --lr 1e-3 --weight-decay 1e-4 \ --epochs 100 --patience 15 \ --seed 42 --gpus 0 --out ./exp/base_k6
参数建议值改动后的影响
--batch-size32~128(按 agent 数)太小 BN/LN 统计不稳,太大显存抖动
--lr1e-3(Adam)大于 3e-3 容易发散,loss 曲线呈锯齿
--weight-decay1e-4~1e-5数据量小的时候压过拟合,太大会欠拟合
--patience10~20 轮看验证集 minADE,不要看训练 loss

关键在损失函数里对变长 agent 做掩码,否则 padding 的位置会稀释梯度:

def masked_loss(pred, gt, mask, kl=0.0, beta=1.0): # pred [B,K,T,2] gt [B,T,2] mask [B,T] 0/1 err = ((pred - gt[:, None]) ** 2).sum(-1) # [B,K,T] err = (err * mask[:, None]).sum(-1) / mask.sum(-1, keepdim=True).clamp(min=1) reg = err.min(dim=1).values.mean() # 只优化最近的一条(winner-takes-all) return reg + beta * kl # kl 来自 CVAE 分支,非 CVAE 置 0

逻辑说明:winner-takes-all是多模态训练的常见做法,每步只让最接近真值的那条预测回传梯度,其余不受惩罚,避免 K 条输出塌缩成一条。mask必须参与除法归一化,否则一个 batch 里 agent 数多的样本主导梯度。

参数说明:beta是 KL 退火系数,前 10~20 轮从 0 线性升到目标值,直接给满权重会导致后验塌缩、K 条预测几乎重合。判断方法很简单:把 K 条预测画出来,如果肉眼几乎重合,先怀疑beta太大或者多样性损失权重缺失。

3.3 三种多模态路线的取舍

代码包里一般实现了一种,偶尔给了开关。三条路线各有边界:

路线输出形式优点边界
CVAE采样隐变量 z 生成 K 条分布建模完整,能算 NLL后验塌缩,需要退火调参
GAN生成器 + 判别器样本多样、视觉合理训练不稳,指标波动大
锚点回归预设 K 个终点/轨迹模板训练最稳、推理最快多样性受锚点集限制

选型依据是下游怎么用:只要 minADE 就选锚点回归,落地最省心;要做概率风险评估、算轨迹似然,CVAE 更合适;做数据增广、生成式仿真,才轮到 GAN。混着比没意义,因为三者的 ADE 口径本身就不完全可比。

4. 评估与可视化:ADE/FDE 怎么算才不自欺欺人

训练能跑通只是及格线,指标能不能信是另一回事。ADE/FDE 的定义简单到只有一行公式,但“取哪条预测”“按样本还是按场景平均”“失败样本算不算”这三个口径没写清楚,报出来的数就没有意义。

4.1 指标定义落到代码

import numpy as np def ade_fde(pred, gt): """pred: [N, K, T, 2] gt: [N, T, 2] 返回 [N,K] 两组误差""" d = np.linalg.norm(pred - gt[:, None, :, :], axis=-1) # [N,K,T] return d.mean(axis=-1), d[:, :, -1] # ADE_k, FDE_k def report(ade_k, fde_k, k_report=6): res = {} res["ADE"] = float(ade_k[:, 0].mean()) # 单模态:固定取第 0 条 res["FDE"] = float(fde_k[:, 0].mean()) res["minADE"] = float(ade_k[:, :k_report].min(axis=1).mean()) res["minFDE"] = float(fde_k[:, :k_report].min(axis=1).mean()) return res

逻辑说明:ADE_k是第 k 条预测在所有时间步上的平均位移误差,FDE_k只看最后一个时间步。minADE是先对每个样本在 K 条里取最小,再对所有样本求平均;顺序反过来(先对 K 求平均再取最小)算出来的是另一个数,很多复现对不上就差在这一步。

参数说明:k_report要和模型输出的 K 一致,K=6 的 minADE 不能拿去和 K=20 的比。另外几个容易漏的点:误差单位要确认是米还是像素,做完归一化的预测必须反归一化再算指标;样本数 N 里如果包含了观测段不足的补齐样本,要在统计前过滤掉。

4.2 可视化回放:把预测叠回场景

数字看不出问题的,画一张图往往三秒定位。关键是反归一化要一致,坐标系要还原。

import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt def draw_case(ax, obs, pred_k, gt, title=""): # obs/gt: [T,2] pred_k: [K,T,2],均已反归一化到同一坐标系 ax.plot(obs[:, 0], obs[:, 1], "-o", c="tab:blue", ms=3, label="history") ax.plot(gt[:, 0], gt[:, 1], "-o", c="tab:green", ms=3, label="ground truth") for i, p in enumerate(pred_k): ax.plot(p[:, 0], p[:, 1], "--", c="tab:red", lw=1.0, alpha=1.0 if i == 0 else 0.35) ax.set_aspect("equal") # 必须等比例,否则角度全错 ax.invert_yaxis() # 图像坐标系时打开;世界坐标系关掉 ax.set_title(title) ax.legend(fontsize=7)

逻辑说明:set_aspect("equal")是硬性要求,横纵比例不同时看起来“预测偏左”其实可能是坐标拉伸的错觉。invert_yaxis()只在标注来自图像像素坐标(y 向下)时需要,混用会让整张图上下颠倒,这种图看一眼就该发现,但经常被当成“模型学反了”去改网络。

参数说明:画多条预测时把非首条调成半透明,避免 K 条叠成一团;一张图里叠 4~6 个样本就够,太多看不出细节。批量出图时建议按误差分位数采样(最差 10%、中位、最好 10%),只看最好的几个样本是最容易骗自己的做法。

4.3 换数据集时的口径对齐

同一个模型换数据集,ADE 差一倍很常见,先排查口径而不是改模型。

对齐项检查方法不对齐的后果
帧率打印相邻帧时间差8 帧在 10 Hz 数据上只有 0.8 秒
单位统计位移中位数像素当米用,ADE 放大几十倍
坐标系抽一条轨迹画图y 轴翻转,方向完全相反
划分方式看是场景级还是帧级帧级划分导致信息泄漏,指标虚高
是否含失败样本看评估脚本有没有 try/except 跳过难样本被静默丢掉,指标虚低

其中帧率最隐蔽:观测 8 帧听起来是同一个超参,实际对应的历史时长完全不同,模型看到的“运动上下文”差了一倍多。换数据集时先把obs_len按秒数重算一遍,10 Hz 数据想看 3.2 秒历史应该是 32 帧,不是 8 帧。

5. 避坑:跑不通、指标虚高、loss 不降的现场记录

下面五条都是这类代码包实际跑起来最常撞的,每条按现象、原因、解决写,对照着查能省掉大半天。

5.1 数据与坐标系

现象一:训练 loss 正常下降,验证 ADE 却是 15 米以上。原因基本是坐标单位或坐标轴错了——标注是像素坐标没转米,或者图像坐标系 y 向下但预处理按 y 向上算。解决:抽一条轨迹,打印历史位移的范数中位数,行人应该在每秒 1~2 米量级,超出数量级就是单位问题;再画一张图确认朝向,上下颠倒就是 y 轴没翻。

现象二:指标好得离谱,minADE 0.05 以下。原因是数据划分按帧随机切,同一条轨迹的相邻窗口同时进了训练集和测试集,模型等于背答案。解决:划分必须按场景或按时间段做,同一 agent 的轨迹只能出现在一个 split 里。检查方法很简单,看训练脚本里的 split 逻辑是不是对samples列表直接random.shuffle——是的话基本可以确定泄漏了。

5.2 训练与显存

现象三:显存占用随 batch 剧烈波动,偶尔 OOM。原因是 batch 按场景组,一个场景里 agent 数从 3 到 80 不等,padding 后张量尺寸跳变。解决:改成按 agent 数固定 batch(把样本摊平后按固定条数取),或者按场景内 agent 数排序后分桶;实在不行用梯度累积,把大 batch 拆成几个小步。

现象四:loss 前期降得快,20 轮后开始震荡不收敛。常见原因是学习率偏大叠加 KL 权重一步给满,CVAE 分支直接把后验压塌。解决:学习率降到 5e-4 试一轮,KL 权重按轮次线性退火,同时把早停指标改成验证集 minADE 而不是总 loss——总 loss 里含正则项,和最终指标不同向是常事。

5.3 评估与复现

现象五:和已知基线对比,minADE 差了 0.3,但训练配置看着一模一样。差异通常出在三个地方:K 的取值不同(6 和 20 差很多)、评估时是否过滤了预测段不完整的样本、随机种子的影响。解决:先把 K 和样本过滤规则对齐再比;然后固定种子跑三次,看指标的标准差,如果三次之间就能差 0.1,那 0.3 的差距里有一半是噪声。这类实验我一般至少跑三个种子取均值,单次结果只用来判断“有没有跑飞”,不用来下结论。

6. 从 demo 到插件:把预测器封装成可复用接口

代码包跑通之后,下一步是让它在别的流程里被调用。直接 import 训练脚本是行不通的——训练脚本里有 argparse、有全局配置、有数据集路径,一 import 就执行。可行的做法是抽一个无状态推理类出来,加载权重、吃历史轨迹、吐 K 条未来。

6.1 无状态接口与推理提速

import torch class TrajInfer: def __init__(self, ckpt_path, device="cuda", k=6): self.device = torch.device(device) self.k = k ckpt = torch.load(ckpt_path, map_location="cpu") self.scale = ckpt.get("scale", 1.0) # 训练时的归一化尺度,必须一起存 self.model = build_model(ckpt["cfg"]) # 复用包里的构建函数 self.model.load_state_dict(ckpt["state_dict"]) self.model.to(self.device).eval() @torch.no_grad() def predict(self, hist_xy): """hist_xy: [N, T_obs, 2] 原始坐标,返回 [N, K, T_pred, 2] 原始坐标""" origin = hist_xy[:, -1:, :].copy() # 逐样本平移原点 x = torch.from_numpy((hist_xy - origin) / self.scale).float().to(self.device) pred = self.model(x) # [N,K,T,2] 归一化坐标 pred = pred.cpu().numpy() * self.scale + origin return pred[:, :self.k]

逻辑说明:origin用[:, -1:, :]保留 batch 维度做广播,别写成hist_xy[:, -1, :],后者形状是[N,2],和[N,T,2]相减会广播错。归一化尺度scale必须跟权重一起存进 ckpt,推理时用错尺度不会报错,只会让预测整体缩小或放大,属于最难查的那类问题。

参数说明:k可以在推理时截断,小于训练时的 K 不影响正确性;device建议默认给cuda但留cpu兜底,很多回放流程跑在没有 GPU 的机器上。

提速手段预期收益代价
torch.no_grad()+eval()显存降 30%~50%,速度小幅提升无
batch 内合并多个 agent吞吐提升明显需要处理变长 padding
torch.jit.trace导出去掉 Python 开销控制流需固定,自回归解码要小心
半精度autocast再快一截数值敏感场景要验证误差

6.2 上线前的三条验证

封装完别急着接进去,三道检查走一遍。第一道是一致性:同一批样本,用训练脚本里的评估入口和用新接口各跑一遍,minADE 必须完全一致,差 1e-3 以上就说明反归一化或者 batch 维处理有问题。第二道是退化输入:历史轨迹里有重复点(目标静止)、有单帧跳点、长度不足T_obs时接口不能抛异常,该返回兜底结果就返回,回放流程里缺帧是常态。第三道是耗时:单次调用在目标硬件上跑 1000 次取 P99,别只看均值,轨迹预测常被放进实时循环,尾延迟才是卡住整个流程的东西。

我第一次把这类模型接进回放流程时,图省事直接 import 了训练脚本,结果配置解析把上层服务的参数吃掉了,排查了两个晚上才定位到 argparse 冲突。从那以后,我每次封装都强制先跑一致性对拍,接口里不留任何读配置文件的逻辑,参数一律构造时显式传入。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进