简介:面向深度强化学习与模仿学习方向的学习者与研究人员,这份代码包围绕基于PPO的模仿学习算法实现展开,覆盖AIRL、BC、DRIL、FAIRL、GAIL、GMMIL、PUGAIL、RED等主流方法,并提供仅状态模仿、吸收状态指示与R1梯度正则化等可选配置,便于在同一实验框架下比较生成式对抗模仿、行为克隆与逆强化学习路线的差异。包内共27个文件,以17张PNG训练与测试收益曲线图、6个Python实现脚本为主,辅以2份Markdown说明文档、1个专家轨迹权重文件和1个gitignore配置,压缩包约999KB,体量轻便却保留了环境、模型、训练与评估等核心模块。已有1068人学习,说明其在模仿学习入门与复现实验中具有一定参考价值。读者可据此搭建可运行的实验流程,借助现成的专家轨迹与各算法收益曲线,快速观察训练稳定性、样本效率与策略表现,并在此基础上调整正则化系数、状态输入方式或算法配置,形成自己的对比实验与调参思路。
1. 模仿学习的反直觉起点:训练损失最低的那一版策略,上线后往往最先翻车
把专家轨迹当成监督数据,训一个「观测 → 动作」的回归网络,训练集动作 MSE 压到 0.01,验证集同样漂亮,一放进闭环环境跑,二十步内偏出轨迹,五十步直接撞墙。这不是调参没调好,问题出在假设被悄悄换掉了:监督学习假设样本独立同分布,而策略的输出会决定它下一步看到什么状态。imitation-learning 的全部难点,几乎都从这个裂缝里长出来。搞懂它,你才知道什么时候该老老实实做行为克隆,什么时候必须把专家重新拉回来标注。
2. 行为克隆的数学骨架:从 MDP 到最大似然,优化目标到底在逼近什么
2.1 把控制问题折叠成条件分布
先把环境写成标准的马尔可夫决策过程:状态空间 S、动作空间 A、转移核 P(s'|s,a)、初始分布 ρ0、折扣 γ。专家是一条策略 π_E,数据集是它跑出来的若干条轨迹,每条轨迹是按时间顺序的 (s_0, a_0, s_1, a_1, …)。行为克隆做的事只有一件:在数据集的经验分布上做最大似然,
max_θ E_{(s,a) ~ D} [ log π_θ(a | s) ]看起来和监督分类一模一样,差别藏在 D 的定义里。D 中的状态并不是从某个固定分布独立采样的,而是从专家策略诱导的状态访问分布 d^{π_E}(s) 里来的。也就是说,你拟合的是「专家会走到的那些状态上,专家会做什么」。状态 s 一旦偏离 d^{π_E} 的支撑集,π_θ 在那里没有任何训练信号,输出完全由网络的外推行为决定。深度网络在训练分布外的表现,从来没有保证。
这就解释了一个很常见的困惑:离线指标明明在降,闭环表现却在崩。指标衡量的是 π_θ 与 π_E 在 d^{π_E} 上的 KL 散度,而真正决定成败的是 π_θ 在它自己诱导出来的分布 d^{π_θ} 上的表现。训练时用的是前者,部署时面对的是后者,两个分布从一开始就不是同一个。
从参数化角度,动作头决定了策略能表达什么。离散动作就是 softmax,连续动作的高斯头则要额外处理一下:网络输出均值 μ(s),再配一个可学习或固定的标准差 σ,最后用 tanh 把动作压到 [-1, 1] 再按动作维度做缩放。这段缩放必须和环境真实动作范围严格对齐,我在实际项目里见过因为少乘一个 max_torque 导致策略输出永远只有真实力矩的十分之一,闭环看起来「能动但没劲」,排查了两天才定位到归一化。
2.2 复合误差为什么是平方级而不是线性
这是行为克隆最核心的一条理论结论,值得完整走一遍推理。
假设在专家的状态分布上,学到的策略每一步犯错的概率不超过 ε,即 P_{s~d^{π_E}}[π_θ(s) ≠ π_E(s)] ≤ ε。在第一步,不出错的概率是 1-ε。一旦出错,状态被推到一个训练分布外的区域,那里最坏情况下每一步都错(概率上界为 1)。
朴素累加会得到 T·ε 这个量级,但真实情况更糟:一旦在时刻 t 偏离,剩下的 T-t 步全都在错误分布上执行,误差不再是 ε 而是接近 1。所以期望总错误步数的上界是
Σ_{t=0}^{T-1} [ ε + (1-ε) · (T - t - 1) · 1 ] ≈ O(ε · T²)这个平方项就是「复合误差」(compounding error)。它的直觉是:错误不只是累加,还会改变后续所有决策的输入分布。T=100 时,ε=0.01 的单步错误率可能被放大到几十步级别的失败。
有一个极简的一维实验能把这个放大过程看得非常清楚:
import numpy as np k_expert = 1.0 # 专家策略:a = 1.0 * s k_bc = 0.98 # 学到的策略:在训练分布内只差 2% dt, s = 0.1, 1.0 for t in range(20): a = k_bc * s # 策略输出 s = s + dt * a # 环境:s_{t+1} = s_t + dt * a print(round(s, 3)) # 单步 2% 的偏差,闭环 20 步后放大到 6.5 倍左右这里的k_bc模拟的是「在训练分布内只差一点点」的回归误差,dt是环境积分步长。误差按 (1 + dt·k)^t 的速率指数放大,和时间步数是乘法关系而不是加法关系。真实系统里这个放大因子可能来自动力学的不稳定模态、反馈延迟、接触力突变,形式不同,但机制一致:策略的小偏差把状态推出训练分布,出分布之后策略没有任何正确性保证,偏差进一步扩大。
顺带说一句,这个上界只在最坏情况下紧。实践中如果专家轨迹本身覆盖得很密(比如带噪声的演示、多个人类操作者、随机初始状态),ε 会因覆盖变好而降低,平方项的实际系数也小得多。这就引出后面所有方法共同的设计思路:要么压低 ε,要么改变训练分布的支撑集,让它覆盖策略自己会走到的状态。
2.3 动作头的选择决定了策略能不能表达「犹豫」
连续动作的 BC 最常踩的坑是损失函数与数据多峰性不匹配。假设某个路口,专家有时左转有时右转,条件分布 π_E(a|s) 是双峰的。用单高斯头加 L2 损失去拟合,最优解是两峰的均值,也就是「直着开过去」。网络训练得很收敛,loss 很低,行为完全错误。
常见的实现思路有这么几种,各有取舍:
| 动作头 | 能表达多峰 | 训练稳定性 | 典型代价 |
|---|---|---|---|
| 单高斯 + L2 | 否 | 高 | 多峰场景直接坍缩到均值 |
| 混合密度网络(GMM) | 是 | 中 | 需要手工设分量数,易出现分量塌缩 |
| 分位数回归 | 部分 | 高 | 输出维度随分位数数量线性增长 |
| 动作分块(chunking) | 是(隐式) | 高 | 推理延迟换时间一致性 |
| 扩散/流匹配头 | 是 | 中 | 采样步数多,单步推理慢 |
从代码层面看,高斯头的最小实现大致是这样:
import torch, torch.nn as nn class GaussianPolicy(nn.Module): def __init__(self, obs_dim, act_dim, hidden=256): super().__init__() self.trunk = nn.Sequential( nn.Linear(obs_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), ) self.mean = nn.Linear(hidden, act_dim) # log_std 用可学习参数,初值 -2 表示初始标准差约 0.135 self.log_std = nn.Parameter(torch.full((act_dim,), -2.0)) def forward(self, obs): h = self.trunk(obs) mean = torch.tanh(self.mean(h)) # 动作约束在 [-1, 1] std = self.log_std.exp().expand_as(mean) return torch.distributions.Normal(mean, std) def bc_loss(policy, obs, act): dist = policy(obs) # 用 log_prob 的负均值,等价于高斯下的加权 MSE return -dist.log_prob(act).mean()log_std做成可学习参数而不是固定常数,是因为不同动作维度的噪声尺度往往差一个量级:转向维度可能很敏感,油门维度相对宽松。固定一个全局 σ 会让敏感维度学得过度平滑、宽松维度学得过度抖动。expand_as保证 std 的形状和 mean 对齐,避免广播错误——这个错误在 batch size 为 1 时不会报,跑到正式训练才炸,属于典型的血泪经验。
再往深一层,动作的时序相关性也是 BC 的隐性维度。逐帧独立预测动作,在开环评测里看不出问题,闭环里会出现高频抖动,因为相邻两帧的预测没有互相约束。动作分块(一次预测未来 k 步)和时序模型(因果卷积、Transformer)本质上都是在给策略注入「时间一致性」这个先验,代价是推理延迟和实现复杂度。
3. DAgger 与交互式纠偏:怎么让专家去标注「模型自己犯的错」
3.1 数据集聚合的迭代结构
复合误差的根源是训练分布和测试分布不一致。既然不一致,最直接的办法就是把测试分布拉进训练集。DAgger(Dataset Aggregation)的思路可以一句话概括:让当前策略自己去跑,跑到它不知道该怎么做的地方,请专家在那个状态上给出正确动作,把这个 (s, a*) 加进数据集,重训。
形式化一点:第 i 轮,用 β_i = β·π_E + (1-β)·π_i 的混合策略采样轨迹,β 通常取 0.1~0.3 或者随轮次衰减。混合策略的作用是保证探索不会离专家太远,同时又能踩到 π_i 自己会犯错的那些状态。采到的每个状态 s 都请专家标注 a* = π_E(s),得到新数据集 D_i = {(s, π_E(s))},然后 D ← D ∪ D_i,重新训练 π_{i+1}。
理论上的收益很干脆:如果每一轮用 no-regret 在线学习算法(Follow-the-Leader、在线梯度下降都算),那么平均策略的累积 regret 是 O(Tε),而不是 BC 的 O(T²ε)。少掉的那个 T 因子,就是从「只在专家分布上评估」换成「在策略自己会访问的分布上评估」换来的。
这里有个容易被忽略的细节:专家标注的是状态上的动作,不是重跑整条轨迹。也就是说 DAgger 需要的专家接口是「给定状态返回动作」,而不是「给定初始状态返回一条完整轨迹」。这个区别决定了工程上能不能落地——人类操作员在仿真里可以随时接管并给单步动作,但要让他从头重演一条完全相同的轨迹几乎不可能。
3.2 一份可运行的聚合循环
def dagger(env, expert, policy, rounds=10, steps_per_round=2000, beta=0.2): dataset = [] # [(obs, act), ...] for i in range(rounds): obs = env.reset() for _ in range(steps_per_round): # β 混合:以 beta 概率听专家的,否则听自己的 if np.random.rand() < beta: act = expert(obs) else: act = policy.act(obs) # 关键:无论动作由谁给出,标签永远是专家在“当前状态”下的动作 dataset.append((obs, expert(obs))) obs, _, done, _ = env.step(act) if done: obs = env.reset() # 每轮全量重训,也可以改成增量微调 policy.fit(dataset, epochs=20) return policybeta的取值直接决定了探索范围:取 0 就是纯 DAgger(策略完全自主跑,可能跑飞),取 1 就退化成纯行为克隆。实践里常用衰减策略,前面几轮 β 大一点保证数据质量,后面逐步降到 0.1 左右,让策略有机会暴露自己的错误模式。
dataset.append((obs, expert(obs)))这一行是整个算法的心脏。它把「执行动作的人」和「提供标签的人」解耦了:动作可以来自任何策略,标签永远来自专家。这正是 DAgger 能把策略诱导分布拉进训练集的原因。
policy.fit每轮全量重训是最稳的做法,代价是训练成本随轮数线性增长。数据量大时可以改成对旧数据降采样、对新数据加权,或者只保留最近几轮的数据——这个取舍在 Ross 的原始论文里也有讨论,效果差别通常不大。
3.3 专家查询成本与安全边界
DAgger 真正的门槛从来不是算法复杂度,而是专家查询的可得性。人类专家标一条轨迹上的两百个状态,和标两百条独立样本,心理负担完全不同。常见的工程折衷有这么几种:
- 合成专家替代:如果有规划器、规则引擎、物理模型,直接拿它们当 expert 接口,查询成本接近零,DAgger 就能放心多跑几十轮。
- 稀疏查询:只在策略不确定性高(集成模型方差大、预测熵高)的状态上查询,把标注预算花在刀刃上。
- 安全屏蔽:策略输出动作前先过一层安全约束(关节限位、碰撞检测、速度上限),超限时回退到保守动作。这层屏蔽不改变算法,但能防止探索阶段把设备撞坏。
- 离线 DAgger:没有在线交互条件时,可以先训一个 BC 策略,在仿真里跑它,记录失败状态,再离线请人标注这批状态。省掉了在线接管的实时性要求,代价是反馈延迟一轮。
还有一个经常被忽视的点:DAgger 假设专家在任意状态上都能给出正确动作。人类专家面对一个从未见过的极端状态,给出的动作未必比策略好。当专家标签本身噪声很大时,DAgger 可能把噪声放大——每一轮都在学「专家在我出错的地方匆忙给出的判断」。遇到这种情况,前面提到的安全屏蔽比继续加轮次更有效。
4. 逆强化学习与 GAIL:不学动作,去学「为什么选这个动作」
4.1 奖励函数的病态性与最大熵消歧
行为克隆学的是 π(a|s),逆强化学习(IRL)学的是 R(s,a)。动机很实在:动作是低层信号,奖励是高层意图。同一个意图可以对应很多种动作实现方式,如果能把奖励还原出来,再在新环境里用强化学习求解,泛化能力可能超过直接从动作模仿。
但 IRL 本身是一个病态问题。给定专家策略 π_E,满足「π_E 是 R 下的最优策略」的奖励函数有无数个。最简单的反例:R 和 R + 常数、R 和 αR(α>0)诱导的最优策略完全一样。更麻烦的是,很多结构完全不同的奖励函数会诱导出同一条专家轨迹。
最大熵 IRL 的消歧方式是:在所有能解释专家行为的奖励里,选让专家策略熵最大的那一个。直观理解是「不要假设专家比必要的更确定」。数学上这等价于让专家轨迹的似然与一个以 reward 为能量的玻尔兹曼分布匹配:
P(τ) ∝ exp( Σ_t R(s_t, a_t) )这套推导很漂亮,代价是计算量:每个候选奖励都要解一次内层强化学习,外层再更新奖励参数,典型的双层优化。在状态动作空间稍大的任务上,这个成本基本不可接受,这也解释了为什么纯 IRL 方法在 deep-learning 场景里不如对抗式方法流行。
4.2 GAIL 的对抗目标怎么被写成一个策略梯度问题
GAIL(Generative Adversarial Imitation Learning)绕开了「显式还原奖励」这一步。它借了 GAN 的结构:一个判别器 D(s,a) 负责区分「专家数据」和「策略采样的数据」,策略 π 的目标是骗过判别器。目标函数是
min_π max_D E_{π_E}[ log D(s,a) ] + E_π[ log(1 - D(s,a)) ] - λ H(π)H(π)是策略熵,λ是熵系数。判别器的输出被直接当成奖励信号喂给策略优化器。实际实现里常用一个等价变体:
import torch, torch.nn as nn, torch.nn.functional as F def disc_loss(D, s_exp, a_exp, s_pi, a_pi): # 专家样本标记为 1,策略样本标记为 0 logits_exp = D(torch.cat([s_exp, a_exp], dim=-1)) logits_pi = D(torch.cat([s_pi, a_pi], dim=-1)) loss = F.binary_cross_entropy_with_logits(logits_exp, torch.ones_like(logits_exp)) loss += F.binary_cross_entropy_with_logits(logits_pi, torch.zeros_like(logits_pi)) return loss def policy_reward(D, s, a): # 用 -log(1 - D) 作为奖励,数值上比 log D 更稳,且恒为正 d = torch.sigmoid(D(torch.cat([s, a], dim=-1))) return -torch.log(1.0 - d + 1e-8)disc_loss是标准的二分类交叉熵,policy_reward把判别器输出转成密集奖励。选-log(1-D)而不是log D,原因有两个:一是这个形式在 D 接近 0(策略样本被识破)时给出大奖励,梯度方向更符合「模仿」的直觉;二是它恒为正,避免了奖励符号在训练早期频繁翻转导致的价值函数估计不稳定。
还有一个实现细节:判别器的输入是单步的 (s, a) 还是状态动作序列,效果差别不小。单步判别器训练快但判别能力弱,策略容易通过「只模仿局部动作、不管长期后果」骗过去;序列判别器(用因果卷积或 RNN 编码一段 (s,a))能捕捉长期模式,代价是训练更慢、更容易过拟合专家数据的长度分布。
4.3 判别器、奖励与非平稳优化
GAIL 的不稳定来源和 GAN 同源,但多了一个维度:这里优化的不是一个静态分布,而是一个会改变环境的策略。
第一类问题是判别器过强。D 太早收敛到接近完美,-log(1-D)在所有策略样本上都接近常数,奖励信号消失,策略梯度变成噪声。常见对策是降低判别器学习率、限制判别器更新次数(比如每更新策略 1 次,判别器更新 3 次)、给判别器加梯度惩罚。
第二类问题是奖励漂移。D 每更新一次,同一个 (s,a) 的奖励就变一次,价值函数在追一个移动的目标。这在 deep-reinforcement-learning 里是典型的非平稳优化,表现出来就是训练曲线剧烈震荡、价值损失降不下去。实践中会加奖励归一化(running mean/std)、调小 critic 学习率、或者在判别器更新之间冻结奖励一段时间。
第三类问题是模式坍缩。策略找到某个能骗过判别器的局部行为后就不再探索,熵迅速下降。熵正则项 λH(π) 就是为此存在的,λ 太小会坍缩,太大会让策略一直随机抖动。这个系数没有通用值,只能按任务调,我的经验是从 0.01 起步,看熵曲线是否平滑下降。
三类问题的共同根源是「判别器和策略在同一个优化循环里互相追逐」。从设计取舍角度看,GAIL 用优化难度换掉了 IRL 的双层求解成本,用样本效率换掉了 BC 的分布偏移问题,但代价是引入了对抗训练的全部不稳定性。它适合的场景很明确:有大量专家数据、环境可以低成本交互、奖励函数难以手工设计,并且团队有能力调试对抗训练。
5. 排查手册:模仿学习翻车的五类现象与对应动作
5.1 数据侧:覆盖不足、动作抖动与样本失衡
现象一:离线 loss 很低,闭环里稍微偏离轨迹就彻底失效,输出饱和到动作边界。原因:训练数据只覆盖了专家走过的窄状态带,策略一旦被推出去,网络在分布外做外推,输出被 tanh 压到 ±1。这不是过拟合,是根本没有训练信号。 解决:先做状态覆盖诊断,把数据集的状态画到低维投影上(PCA/UMAP),再看闭环失败时策略访问的状态落在哪里。覆盖缺口明显的话,加随机初始状态、加专家演示的扰动版本、或者直接上 DAgger 把失败状态补进训练集。同时给策略加一个不确定性估计(集成多个 BC 模型看方差),方差超阈值时切到保守策略。
现象二:策略在仿真里动作平滑,真机上高频抖动,电机发热。原因:逐帧独立预测动作,相邻帧之间没有约束;人类演示本身带有生理抖动,BC 把这个抖动当成信号学了下来。 解决:三种手段可以叠加。动作分块,一次预测未来 k 步并只执行前几步;时序模型,用因果卷积或 Transformer 聚合历史观测;输出侧低通滤波或加动作变化率的正则项。第三种最简单,但会引入相位延迟,闭环控制里要小心。
现象三:直行样本占九成,转弯永远学不会。原因:按时间均匀采样,信息密度极不均匀。直行阶段每个样本几乎一样,转弯阶段几百毫秒内的样本才是关键。梯度被大量冗余样本主导。 解决:按动作变化幅度加权采样,或者对稀有动作段做过采样。也可以用分层采样,保证每个轨迹片段被采到的概率相近。权重不要设得太激进,否则会把演示噪声也放大。
5.2 优化与评估侧:口径错位与多峰坍缩
现象四:验证集 MSE 一路下降,闭环成功率原地踏步甚至倒退。原因:评估口径错位,加上数据泄漏。开环 MSE 衡量的是「在专家状态上动作像不像」,闭环成功率衡量的是「能不能完成任务」,两者在复合误差存在时不单调相关。更隐蔽的是切分错误:把同一条轨迹的前半段放训练集、后半段放验证集,相邻帧高度相关,验证集本质上是训练集的复制。 解决:按轨迹切分,训练集和验证集不能共享任何一条轨迹,最好连初始状态分布都分开。评估指标换成闭环 rollout 成功率加平均回合回报,开环 MSE 只作为辅助监控。下面这段切分代码是最小正确版本:
# 正确:以轨迹为单位切分,杜绝相邻帧泄漏 trajs = load_trajectories("demos/") # list[list[(obs, act)]] idx = np.random.permutation(len(trajs)) split = int(0.8 * len(trajs)) train = [step for t in trajs[idx[:split]] for step in t] val = [step for t in trajs[idx[split:]] for step in t]现象五:某个分岔路口,策略输出一个「两个方向都不是」的中间动作。原因:条件动作分布是多峰的,用单高斯加 L2 损失拟合,最优解就是各峰均值。 解决:换混合密度头、分位数头或扩散头;或者把连续动作离散化成若干个 bin 做分类,再从 bin 内回归精细值。分类化的好处是天然表达多峰,坏处是 bin 边界处的分辨率损失,需要配合残差回归补偿。
这五类现象有个共同点:它们都不会在训练 loss 上留下明显痕迹。所以排查顺序应该反过来,先跑闭环、先看失败模式,再回头查数据分布和损失函数,而不是盯着 loss 曲线找问题。
6. 设计取舍:什么时候停在 BC,什么时候必须付出交互成本
四种方法的取舍可以压成一张表,看的时候注意「需要什么」这一列,它基本决定了项目能不能启动:
| 方法 | 需要什么 | 样本效率 | 闭环风险 | 实现复杂度 |
|---|---|---|---|---|
| 行为克隆 | 离线专家轨迹 | 高 | 复合误差 O(εT²) | 低 |
| DAgger | 可查询的专家接口 + 在线交互 | 中 | O(εT) | 中 |
| GAIL | 专家轨迹 + 环境交互 + 对抗训练调试能力 | 低 | 训练不稳定 | 高 |
| 最大熵 IRL | 专家轨迹 + 内层 RL 求解器 | 很低 | 双层优化成本 | 很高 |
选择逻辑其实不复杂。如果任务的动作分布近似单峰、专家数据覆盖足够密、失败代价可控,行为克隆就够用,没必要为了理论上的优雅去背对抗训练的调试包袱。如果数据覆盖窄、闭环失败模式明显是「越走越偏」,而专家接口又拿得到(规划器、仿真接管、离线标注都算),DAgger 的性价比最高——它改动最小,收益最直接。只有当奖励函数确实难以手工设计、且环境交互成本低到可以承受几十万步采样时,GAIL 才值得上。
还有一个经常被忽略的中间地带:先 BC 出一个基线,用它跑闭环收集失败状态,离线请专家标注这批状态,再混进原数据集重训。这是 DAgger 的离线变体,不需要实时接管,也不需要环境交互预算,在工业场景里往往是最容易推动落地的方案。
有个判断我踩过坑之后才形成:不要用开环指标做方法选型。曾经有个项目,BC 的验证 MSE 比 DAgger 低一个数量级,团队据此选了 BC,上线后闭环成功率只有三成;换成 DAgger 后开环指标反而变差了,闭环成功率到八成五。开环指标衡量的是「像不像专家」,闭环指标衡量的是「能不能活下来」,这两件事在复合误差存在时没有必然联系。
从那以后我每次启动模仿学习项目,都会强制先跑一遍闭环基线:哪怕策略很烂,也要先拿到「随机策略 / BC / 专家」三档的闭环回报,把评估口径钉死,再谈模型改进。这个习惯救过我很多次,也希望帮到你。
本文还有配套的精品资源,点击获取