1. 先把问题定准:轨迹预测为什么不能当成普通回归题
在人群里穿行的服务机器人、路口等待左转的自动驾驶车辆、商场里的客流分析系统,背后都绕不开同一个动作:盯着一个人过去几秒的走位,去猜他接下来几秒会往哪儿走。这个任务在学术里叫行人轨迹预测,输入通常是连续若干帧的二维坐标,输出是未来一段时间的坐标序列。我第一次接触这个方向时觉得它像个曲线拟合问题,拿 LSTM 吃历史轨迹吐未来轨迹,损失函数用 L2,训完看 loss 曲线挺漂亮,一放到真实场景里就现原形——预测出来的轨迹笔直地穿过前面那堵墙,或者直接踩在别人身上。后来才明白,问题不在这条网络不够深,而在这道题本身被问错了。
1.1 一条被平均出来的“鬼轨迹”
假设某个行人在你面前,左边有个推婴儿车的人,右边是一排花坛,他其实只有两条路可走:向左绕或者向右绕。如果训练数据里这两种走法各占一半,你用 L2 损失去训一个回归模型,数学上最优的输出是什么?是这两条轨迹的条件均值——从正中间直接撞过去。这个结论不是调参能解决的,它是 L2 损失在大样本下的必然结果:最小化平方误差等价于拟合条件期望,而多模态分布的条件期望往往落在概率密度极低的区域,甚至落在物理上根本不可行的位置。
这类“鬼轨迹”在实际部署时会带来相当糟糕的后果。机器人按这条轨迹去做避障规划,等于给了它一个错误的前提;自动驾驶的决策模块拿到一条穿过障碍物的预测,要么误判行人意图,要么触发不必要的急刹。所以后来我把这个问题的性质重新归类了一下:它不是点估计问题,是分布建模问题。模型要回答的不是“他会走哪条路”,而是“他可能走哪几条路,各自概率多大”。
1.2 Social LSTM 留下的半道题
在我读过的早期工作里,Social LSTM 是绕不过去的一篇。它提出了一个很朴素但有效的观察:行人的走位受周围人影响,你得把邻居的信息喂进模型。具体做法是给每个行人配一个 LSTM 维护隐藏状态,在每一帧做一次社交池化,把邻域内其他人的隐藏状态按空间位置聚合起来,拼进当前行人的输入。
这个设计解决了“看不见别人”的问题,但没解决多模态的问题。它的输出仍然是一条确定的轨迹,损失还是 L2,所以均值化的毛病原封不动地保留了下来。我当年复现时专门做过可视化对比,模型在人群稀疏的地方表现还行,一进密集场景就倾向于输出那种“谁都不挡”的中庸路线,看起来很安全,实际上哪条道都走不通。
Social GAN 这篇的切入点正是在这里:它把 Social LSTM 的社交池化模块整体搬了过来,但把整个训练范式从“回归一条轨迹”换成了“生成轨迹分布”。换句话说,它继承了前者的空间建模能力,换掉了后者的目标函数。
1.3 GAN 给这道题换了个问法
生成对抗网络的思路是训练两个网络互相博弈:生成器负责从随机噪声里造出样本,判别器负责分辨样本是真是假。最后生成器学到的不是某个确定的输出,而是训练数据的分布本身。这个特性正好对上轨迹预测的需求——你给生成器喂不同的噪声,它就能吐出不同的合理未来,噪声维度里天然编码了“左绕还是右绕”这种离散选择。
当然,把 GAN 搬到轨迹上不是改个损失函数就完事。轨迹是时序数据,长度固定但结构强,判别器怎么设计、生成器怎么接社交信息、怎么防止模式坍塌,每一步都有具体的坑。我下面按模块拆开讲,重点放在那些论文里一句话带过、但复现时特别容易卡住的地方。
2. 模型骨架拆解:生成器、判别器、社交池化
整个模型的输入输出先定清楚:观测 8 帧、每帧 2 维坐标,采样间隔 0.4 秒(对应 2.5 帧每秒),也就是看过去 3.2 秒;预测 12 帧、未来 4.8 秒。这个切分在行人预测领域基本是标准配置,选它是因为 3.2 秒足够看出一个人的行进意图(是直行还是准备转弯),4.8 秒又不会长到让预测失去意义。
2.1 生成器:把噪声塞进 LSTM 解码器
生成器是标准的编码器-解码器结构,但有两个细节值得说。第一,编码器逐帧吃观测轨迹,每帧输入是 2 维坐标加上一个池化后的社交向量,LSTM 跑完 8 帧后拿到最终隐藏状态。第二,解码器在每一帧的输入端,除了上一帧预测出的坐标,还要再拼上那个随机噪声向量 z。很多人第一次实现时会想当然地把噪声只在最开始的隐藏状态里注入一次,实测下来多样性的效果会明显打折,因为后续每一帧的 LSTM 更新会慢慢把初始噪声“洗掉”。论文的做法是每帧都拼,噪声维度不大,8 到 16 维就够用,太大了训练反而不稳。
解码器的输出层是一个线性映射,把隐藏状态压回 2 维坐标。这里有个实操细节:坐标绝对值的尺度在不同场景下差异很大,有的场景横跨几十米,有的只有几米,所以训练前一般要做归一化,具体做法后面讲数据预处理时再说。
2.2 社交池化到底池了什么
社交池化是整个模型里最容易被讲得含糊的部分,我尽量说透。它的作用是把“我周围有谁、他们在往哪走”这个信息压缩成一个定长向量。具体流程是以目标行人为中心,在平面上划一个网格,通常是4×4 的格子,每格对应的物理边长取 0.4 米左右(这个尺寸要跟数据集的场景尺度匹配,太大就糊成一团,太小则大部分格子是空的)。
每个邻居行人根据它相对目标的位置落进某个格子,然后在该格子里对它们的 LSTM 隐藏状态做逐维最大值池化。为什么用 max 而不是 mean?我的理解是 max 更能保留“最值得注意的那个邻居”的信号,比如正前方有个走得很快的人,这个信息不该被旁边走得慢的人稀释掉。mean 池化会把所有邻居压成一个平均状态,遇到稀疏场景时容易引入噪声。
这个设计还有个隐含好处:它对邻居数量不敏感。无论周围 3 个人还是 30 个人,池化后的向量维度都是固定的,网络输入尺寸稳定。对比之下,如果用全连接把所有邻居拼起来,输入维度会随人数变化,训练根本没法做。
2.3 判别器只做逐人判别,这个取舍值得说
判别器是一条 LSTM,输入是某个行人的完整轨迹(观测段加预测段),跑完后接一个全连接层输出真假概率。注意,论文里的判别器是逐人判别的,它没有做社交池化——这一点在后续工作里被不少人质疑过,因为一条轨迹是否合理,很大程度上取决于它跟周围人的相对关系。一个人直着走本身没问题,但如果正前方贴着另一个人还直着走,那就不合理了。
为什么原作者没加?我猜有两个原因。一是加了社交池化后判别器计算量会显著上升,训练本来就慢;二是当时的实验显示逐人判别已经能把生成质量拉到可接受水平。但如果你要做落地部署,我建议在判别器里补上社交信息,哪怕只是简单地把邻居的轨迹拼进输入,这个改动在密集场景下的收益通常是肉眼可见的。
2.4 对抗损失的写法与训练稳定性
损失函数有两块:对抗损失和 L2 损失。对抗损失用的是标准的生成对抗框架,但生成器那侧一般用非饱和形式,也就是把生成器的目标写成让判别器把假样本判成真的概率尽量大,而不是让判别器判错概率尽量小。这两种写法数学上等价,梯度性质差很多,非饱和形式在训练初期梯度更稳,不容易一上来就饱和。
L2 损失只在生成器上用,而且必须配合后面的 Variety Loss,这块单独开一节讲。判别器本身不加 L2,否则它就变成了一个重建网络,失去对抗的意义。
训练时两个网络交替更新,一般是一步判别器一步生成器。判别器太强会导致生成器梯度消失,太弱又起不到约束作用,这个平衡在轨迹数据上比图像上难拿捏,因为维度低、数据量小(五个场景加起来也就几百条轨迹)。我踩过的坑是判别器训得太快,两三个 epoch 之后生成器就只会输出一条固定轨迹,多样性完全塌掉。
3. Variety Loss:这篇论文最容易被忽略的设计
如果只让我从一个模型里挑一个设计来讲,我会挑Variety Loss。论文给它的篇幅不大,但它是整个方法能不能跑出多样性、能不能在指标上超过前作的关键。很多人复现 Social GAN 时只看了生成器和判别器的结构,把损失照抄一遍就完事,结果训出来一片坍塌,问题就出在对这个损失的理解不到位。
3.1 模式坍塌在轨迹预测里的具体表现
模式坍塌是所有 GAN 的通病:生成器发现输出某一种样本就能骗过判别器,于是它干脆只输出这一种,把其他模式全丢掉。在图像上表现为生成的脸都长得一样,在轨迹上表现为不管喂什么噪声,预测出来的路线基本重合。
轨迹预测里这个问题格外容易发生。因为坐标维度只有 2,信息量小,判别器很容易就能判断出“这条轨迹像不像真的”,而生成器找到一条能骗过判别器的路线后,几乎没有动力去探索别的。加上 L2 损失存在,生成器会更倾向往均值附近靠——这又是回到均值化的老路。
3.2 公式背后的直觉
Variety Loss 的做法是:每次前向时从噪声里采样 K 条候选轨迹,只对最接近真值的那一条计算 L2 损失。写成公式就是取 K 条候选与真实轨迹的 L2 距离中的最小值。K 的取值论文用的是 20。
这个设计的直觉是:只要生成器在 K 条候选里有一条猜中了真值附近,就不惩罚它,其他 K-1 条随意。这样生成器就有动力把概率质量分散开,去覆盖多种可能的走向,而不是所有样本都挤在均值上。我第一次读到这里时觉得有点像“作弊”——只要撒得够宽总有一条能中。但从分布建模的角度看,这恰恰是合理的:真实行人的未来本来就存在多种可能,模型输出一个覆盖这些可能的集合,比输出一个平均值更接近真实分布。
对比一下不加 Variety Loss 的情况:直接用 K 条候选的平均误差去训,生成器会倾向让所有候选都靠近均值,多样性立刻消失。所以这个 min 操作不是可有可无的优化,它是整个多模态能力的来源。
3.3 训练目标和评测口径对齐的代价
Variety Loss 还有一个巧妙之处:它跟评测指标是一致的。行人轨迹预测领域常用ADE(平均位移误差)和FDE(终点位移误差),标准的评测方式是生成 20 条候选,取误差最小的那条作为最终结果,也就是 best-of-20。Variety Loss 用 K=20 取最小值,正好跟评测对齐。
但这个对齐是有代价的,我在实际使用里感受很深。best-of-20 这个口径会系统性地高估模型的实用价值。想象一个模型,它 19 条候选都是离谱的,只有 1 条刚好压中,指标上它会显得跟一个每条候选都合理的好模型差不多。落地的场景里没人会给你 20 次机会去挑——机器人只能选一条去执行。所以看论文表格时我通常会把 best-of-20 的数字往下打个折,或者在复现时额外看一个“best-of-1”或“中位数误差”作为参考。论文自己没怎么强调这一点,但做过部署的人心里都有数。
4. 从零跑通:数据、代码、训练配置
这一节给我认为真正能抄作业的部分。我假设你手上有 PyTorch 环境,想在一个公开行人轨迹数据集上复现这套模型。数据集方面,业界常用的是五个经典场景(两个街道/校园场景、一个酒店大堂场景、两个广场场景),每条轨迹按 8 帧观测加 12 帧预测切分,场景之间做留一交叉验证,也就是用四个场景训练、第五个测试,轮换五次。
4.1 数据预处理与坐标归一化
原始数据是像素坐标或者世界坐标,尺度不一。我一般做两步处理:第一步,把每条轨迹整体平移到以观测段最后一帧为原点,这样网络学到的是相对位移而不是绝对位置,泛化性好很多;第二步,按整条轨迹的位移幅度做缩放,或者用场景级的均值方差归一化。
import numpy as np def normalize_traj(traj, obs_len=8): # traj: [T, 2] origin = traj[obs_len - 1] traj = traj - origin scale = np.linalg.norm(traj[:obs_len], axis=1).max() scale = max(scale, 1e-3) return traj / scale这里要提醒一句,缩放因子只统计观测段,不能用整条轨迹。如果用了未来帧去算 scale,等于把标签信息泄露进了输入,测试时你会得到好得不真实的指标,这个坑我早期踩过,浪费了一周排查。
4.2 网络代码的关键片段
生成器的核心就是编码器 LSTM、社交池化和解码器 LSTM。社交池化用 PyTorch 实现时,我习惯用离散化坐标加 scatter 操作,效率比循环高。
import torch import torch.nn as nn class SocialPooling(nn.Module): def __init__(self, hidden_dim=16, grid=4, cell=0.4): super().__init__() self.grid, self.cell, self.hidden_dim = grid, cell, hidden_dim self.register_buffer('zero', torch.zeros(1, hidden_dim)) def forward(self, hidden, pos): # hidden: [N, H], pos: [N, 2] out = [] for i in range(hidden.size(0)): rel = pos - pos[i] idx = torch.floor(rel / self.cell) + self.grid // 2 valid = ((idx >= 0) & (idx < self.grid)).all(dim=1) pooled = self.zero.new_zeros(self.grid * self.grid, self.hidden_dim) for gx in range(self.grid): for gy in range(self.grid): m = valid & (idx[:, 0] == gx) & (idx[:, 1] == gy) if m.any(): pooled[gx * self.grid + gy] = hidden[m].max(dim=0).values out.append(pooled.flatten()) return torch.stack(out)上面这段是教学写法,实际跑的时候要改成向量化实现,否则 N 一大人就卡死。判别器相对简单,一条 LSTM 加一个线性层就够,隐藏维度给到 64 左右,比生成器大一些,判别能力才够。
4.3 训练循环与超参设置
我把常用的配置列成表,方便对照:
| 参数 | 取值 | 说明 |
|---|---|---|
| 观测帧 / 预测帧 | 8 / 12 | 采样间隔 0.4 秒 |
| 生成器 LSTM 隐藏维度 | 16 | 加大到 32 收益不明显 |
| 判别器 LSTM 隐藏维度 | 64 | 判别器要略强于生成器 |
| 噪声维度 | 8 ~ 16 | 太大训练不稳 |
| 社交网格 | 4×4,格边长 0.4 米 | 需按场景尺度调整 |
| 采样数 K | 20 | 跟评测口径对齐 |
| 批大小 | 64 | 数据少时降到 16 |
| 学习率 | 1e-3 | Adam,前期可加线性预热 |
| 训练轮数 | 150 ~ 250 | 五个场景数据量差别大 |
损失权重方面,对抗损失和 L2 损失的系数一般是 1:1。但如果你发现多样性不够,可以把 L2 系数往下调到 0.5 甚至更低,让模型更多依赖对抗信号去探索;反过来,如果轨迹形状太离谱,L2 系数就往上调。这个系数本质上是在“覆盖多种可能”和“每条都别太离谱”之间做权衡,没有普适最优值,得看你的下游任务更在意哪一头。
训练循环的结构大致是这样:先固定生成器、更新判别器,判别器目标是真样本判 1、假样本判 0;再固定判别器、更新生成器,生成器目标是假样本被判成 1,同时加上 Variety Loss。两个优化器分开,别图省事用一个。
4.4 评测脚本与结果解读
评测时对每个测试样本生成 20 条候选,分别算 ADE 和 FDE,取最小值。ADE 是 12 帧位移误差的平均,FDE 只算最后一帧。写脚本时容易犯的一个错是忘了在评测前把坐标逆变换回原始尺度,导致指标跟论文对不上——归一化是双向的,这一步不能漏。
至于结果量级,这套模型在五个场景上的整体表现大致是ADE 在 0.6 米上下、FDE 在 1.2 米上下(best-of-20 口径),比 Social LSTM 这类单模态方法有明显提升,尤其在密集场景里差距更大。但如果你把候选数降到 1 条来测,数字会难看得多,这个落差恰恰说明了 best-of-20 口径的乐观程度。
5. 踩坑记录与排查清单
复现和调参过程中遇到的问题是博文最有价值的部分,我把印象深的几类整理出来,都是文档里不会写的。
5.1 训练不收敛的几种典型症状
症状一:生成器输出全部塌缩到一条直线。这是判别器太强的典型表现。判别器几个 epoch 就收敛到接近完美,生成器梯度几乎为零。解法是把判别器的学习率调低到生成器的三分之一,或者给判别器的更新加个概率,比如每两步才更新一次。
症状二:损失震荡,训练后期突然发散。多半是学习率太大或者没有梯度裁剪。轨迹数据维度低,梯度偶尔会爆掉,加个clip_grad_norm_(params, 1.0)基本能压住。
症状三:ADE 在训练集上降得很快,测试集上不降。数据量太小导致的过拟合。五个场景加起来样本有限,模型参数量又不大,很容易记住训练集。加 dropout、缩小隐藏维度、或者用场景间留一交叉验证来评估,都是常见手段。
5.2 指标虚高与真实效果的差距
前面提过 best-of-20 的问题,这里再补一个我实际遇到的:指标好不代表轨迹物理合理。模型可能生成一条平均误差很小、但中途速度突变或者出现折返的轨迹。评测指标只看位置,不看速度连续性。所以我在复现时会额外算一个速度平滑度指标,比如相邻帧速度差的方差,如果这个值明显高于真实数据,就说明生成的轨迹在运动学上不合理,哪怕 ADE 好看也不能用。
还有一个更隐蔽的问题:社交池化在稀疏场景里其实起负作用。周围没人时,池化输出全是零向量,等于给生成器喂了一段无用信息。如果你的部署场景以空旷环境为主,可以加个门控机制,让模型自己学要不要用社交信息。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查与处理 |
|---|---|---|
| 所有预测轨迹重合 | 模式坍塌,判别器过强 | 提高 K 值、降低 L2 系数、判别器降学习率 |
| 预测穿过障碍物 | 损失不含场景信息 | 补地图/障碍物输入,或加后处理约束 |
| 训练 loss 为 NaN | 梯度爆炸 | 梯度裁剪、降学习率、检查归一化 |
| 指标比论文差很多 | 坐标未逆变换 | 核对评测前是否还原到原始尺度 |
| 稀疏场景效果差 | 社交池化输出空 | 加门控或对空池化做特殊处理 |
| 密集场景轨迹交叠 | 判别器不建模社交 | 判别器输入拼接邻居轨迹 |
| 测试集波动大 | 数据量小、初始化敏感 | 多种子跑取均值,报告方差 |
6. 工程落地前的几点判断
读论文和用论文是两件事。这篇论文的方法在学术界被引很多,但真要落到产品里,有几个判断得提前做。
6.1 这个模型能直接用吗
我的看法是:作为基线可以用,作为最终方案不够。它的优势是结构简单、训练成本低、多模态能力有,适合拿来快速验证“多模态预测”这个方向在你的场景里有没有价值。但它的短板也很明显:判别器不建模社交关系、只预测位置不预测朝向和速度、对场景结构(墙体、通道)完全无感知。真实部署里,一条不撞人但穿墙的轨迹依然是废的。
还有一个现实问题是推理延迟。生成 20 条候选意味着 20 次前向,批量做的话还好,单条实时推理时这个开销不能忽略。如果下游规划模块只能吃一条轨迹,你还得设计一个挑选策略,比如按轨迹与历史速度的一致性来选,或者按与周围行人的最小距离来选。
6.2 我看到的几个可改方向
第一个方向是给判别器补社交建模,前面说过,这是最直接、收益最确定的一处改动。第二个方向是把噪声的结构做出来,现在是独立同分布的高斯噪声,实际上“向左绕还是向右绕”这种选择是有语义的,可以用离散隐变量或者混合密度来建模,让多样性更有解释性。第三个方向是引入场景先验,把可行走区域的栅格图拼进输入,这样模型能学会避开墙体。
我在一个模拟环境里试过前两个改动,社交判别器带来的提升在密集场景里比较明显,而结构化噪声在训练稳定性上帮助更大,收敛需要的 epoch 数少了一截。这两个都是小改动,成本不高,值得动手。
最后分享一个我自己的习惯:复现这类生成式轨迹模型时,别只盯着 ADE/FDE 看,一定要把生成的轨迹画出来,一帧一帧地看。指标能骗人,图骗不了人。我当时就是因为盯着曲线看指标,忽略了一个明显的问题——模型在转弯场景下生成的轨迹全都偏向外侧,直到把图打出来才发现,那批图至今还留在我本地的一个文件夹里。