深度强化学习这个系列写到了第三篇前两篇我们把基础建模和DQN家族过了一遍也踩了不少坑。这篇我打算换一个思路不再从头讲某个算法的公式推导而是拿一个在生产环境里真正扛过活的算法——PPO从选型理由、环境搭建、代码实现、训练调参到常见问题排查完整走一遍实战流程。如果你已经能把DQN在简单环境里跑起来但对策略梯度方法还停留在“看过论文、没写过代码”的状态这篇应该能帮你在今天之内把PPO跑通并且理解它为什么是当前口碑最稳的策略优化算法。如果你只想抄作业跑个实验代码分段我都会给可复用的版本。1. 为什么最终选了PPO算法选型的底层逻辑1.1 从REINFORCE到Actor-Critic策略梯度演进的三个关键节点要理解PPO为什么好用得先知道它解决的是谁的问题。最早期的策略梯度算法REINFORCE思路非常朴素收集一条完整的轨迹根据整条轨迹的累计回报去调整策略参数表现好的轨迹对应的动作概率被增大表现差的被降低。但这种方式有个致命弱点梯度的方差非常大。同一个状态下随机种子不同、探索程度不同收集到的轨迹回报可能天差地别导致策略更新方向忽左忽右收敛极其不稳定。后来的Actor-CriticAC架构做了一个关键改进引入一个价值网络来估计状态的价值用“实际回报和预估价值之间的差值”——也就是优势函数——来代替整条轨迹的回报作为更新信号。这样一来更新步长变小了方差显著降低。但AC架构又暴露了另一个问题在策略更新时我们是在用旧策略采样的数据去更新新策略如果一步更新幅度过大新旧策略分布差异会急剧拉大训练直接崩掉。这就引出了PPO的核心思想在每次更新时显式地限制新旧策略的差异程度。用一个裁剪clip机制把更新幅度控制在一定范围内保证每一步都是在“安全区”内优化。1.2 PPO的clip机制让每一次更新都不越界PPO最容易被误解的地方是它的目标函数。表面上看起来只是加了一个min和clip操作但实际这是整篇论文最精妙的部分。它的目标函数长这样L min( r_t * A_t, clip(r_t, 1-ε, 1ε) * A_t )其中r_t表示新旧策略在状态s_t下采取动作a_t的概率比值A_t是优势函数估计值ε是裁剪范围通常取0.2。我用自己的话解释一下这个公式背后的直觉当优势A_t为正时说明这个动作比平均值好我们希望增大这个动作的概率。但如果新旧策略比值r_t已经超过了1ε说明这一次更新已经走得够远了再往前就会脱离旧数据的支持范围所以直接把更新量卡死。当优势为负时同理我们想降低不好动作的概率但如果比值已经小于1-ε同样卡住不再更新。这就像开车经过一个弯道你可以加速但每次打方向盘的幅度被限制在一定角度内防止整辆车直接甩出去。实际操作中这个ε0.2是经过大量实验验证的“甜点值”大多数任务不需要调整它这也是PPO工程友好的关键原因之一。1.3 为什么说PPO是工程落地的“万金油”我见过不少团队在选强化学习算法时纠结。TRPO的单调提升保证听起来很美但每个iteration都要解一个带约束的优化问题光是用共轭梯度法求近似解就够写几百行代码在复杂环境中计算开销太大。DDPG在连续控制任务上采样效率高但极其容易发散对超参数敏感到“调参三天、跑崩一分钟”。TD3稳定了一些但要处理延迟更新、目标策略平滑这些细节代码量也不小。相比之下PPO在离散动作和连续动作两种场景下都适用实现代码量少对超参数不敏感而且支持在同一个环境里让多个并行worker同时采样采数据的速度可以线性扩展。在机器人控制、游戏AI、推荐系统、自动驾驶仿真等实际项目里PPO的稳定性和易用性让它成为了工业级默认算法。我在实际工作中也是优先用PPO做baseline跑出合理分数之后再考虑其他采样效率更高的算法优化。2. 环境搭建与Agent框架设计先把基础设施夯实2.1 两个验证环境兼顾离散与连续动作空间实战不能只在一个环境里跑通就算完事那样等于只验证了一种情况。我这次选了两个环境做交叉验证CartPole-v1是离散动作空间动作只有左和右用来验证算法对动作选择的基本能力Pendulum-v1是连续动作空间动作是力矩范围在[-2, 2]用来验证PPO在高斯策略输出下的表现。两个环境一个简单一个相对复杂组合起来能从两个维度检验Agent是否正确实现了。需要注意环境库现在用的是Gymnasium不是早期的Gym。Gymnasium是Gym的维护分支接口基本一致但修复了大量老版本的问题。我建议直接用Gymnasium不要在老版本上继续折腾依赖。2.2 网络结构不同观测空间的不同处理方式在动手写Agent之前先明确网络设计。CartPole-v1的观测是4维向量位置、速度、角度、角速度直接拼成一个特征向量输入MLP即可。Pendulum-v1的观测是3维向量角度和角速度的三角函数编码同样用MLP处理。但如果你要跑像素输入的任务比如Atari游戏那就需要换成CNN结构先把画面编码成特征向量再接MLP。我在这里不做过度设计统一用隐藏层为64x64的MLP网络激活函数用Tanh。为什么不选ReLU在强化学习场景中ReLU在大学习率情况下容易导致神经元死亡Tanh的输出范围是[-1,1]训练稳定性好很多。后续如果想提升表现再把这个隐藏层容量加大到128x128或256x256效果会有提升但先跑通才是王道。2.3 版本依赖锁定省掉地狱级兼容性问题很多初学者在环境搭建上花的精力比写算法还多主要是版本冲突。我这次使用的核心版本如下Python 3.10 PyTorch 2.1.0 Gymnasium 0.29.1 numpy 1.26.0PyTorch版本尽量不低于2.0Gymnasium不低于0.28因为环境接口在后续版本有细微调整。如果你用的是conda建议专门为这个项目建一个虚拟环境避免和其他项目的依赖互相污染。等这套环境稳定了再做项目时只需要复制一份环境配置即可。3. PPO核心代码逐段拆解从采样到更新的完整闭环3.1 Rollout采样Agent如何收集训练数据PPO属于on-policy算法它的训练数据来源于当前策略的采样结果。也就是说每个iteration都会先让Agent在环境中跑若干轮收集一批状态、动作、奖励等数据然后基于这批数据做若干轮梯度更新更新完之后丢弃旧数据重新采样。这种方式的优点是策略永远和当前数据高度一致不会出现off-policy算法中“策略和数据分布不匹配”的偏差问题。代价是采样效率低所以工程上通常用多个并行环境来提速。下面的代码实现了一个最简单的单进程采样循环def collect_rollouts(env, policy, buffer, num_steps2048): obs, _ env.reset() for _ in range(num_steps): obs_tensor torch.FloatTensor(obs).unsqueeze(0) dist policy.actor(obs_tensor) action dist.sample().cpu().numpy().flatten() action_for_env action if env.action_space.shape else int(action[0]) next_obs, reward, terminated, truncated, info env.step(action_for_env) done terminated or truncated buffer.store(obs, action, reward, done) obs next_obs if done: obs, _ env.reset() return buffer注意这里terminated和truncated是分开的。terminated表示任务真正结束truncated表示因为超时或其它限制被强制截断。在计算未来回报时truncated之后的环境价值不能按0处理而应该用价值网络的估计值。这个细节很多新手会忽略但对算法稳定性影响很大后面在GAE部分会再展开。3.2 GAE优势估计一个关键且容易出错的实现PPO的更新不直接使用累计回报而是使用优势函数。优势函数衡量的是“这个动作比平均水平好多少”。GAEGeneralized Advantage Estimation是现在最主流的优势估计方法核心是用一个参数λ在“偏差大方差小”和“偏差小方差大”之间做权衡。GAE的计算公式可以写成递归形式delta_t reward_t gamma * V(s_{t1}) - V(s_t) A_t delta_t gamma * lambda * A_{t1}实际代码实现时我们用的是反向遍历的方式。因为未来的时间步先算出来当前时间步才能利用上def compute_gae(rewards, dones, values, gamma0.99, lam0.95): advantages torch.zeros_like(rewards) gae 0.0 next_value 0.0 for t in reversed(range(len(rewards))): if t len(rewards) - 1: next_non_terminal 1.0 - dones[t] delta rewards[t] gamma * values[-1] * next_non_terminal - values[t] else: next_non_terminal 1.0 - dones[t 1] delta rewards[t] gamma * values[t 1] * next_non_terminal - values[t] gae delta gamma * lam * next_non_terminal * gae advantages[t] gae returns advantages values return advantages, returns这里的values是价值网络对每个状态的估计如果在truncated状态处直接让next_value0会导致优势估计偏低经验上会让训练变慢甚至不收敛。3.3 Policy Loss与Value Lossclip到底怎么生效PPO的actor网络输出一个动作分布。离散动作空间输出的是Categorical分布连续动作空间输出的是高斯分布均值由网络输出标准差是一个可学习的参数向量。loss的计算是整个实现的灵魂直接上代码def compute_ppo_loss(old_log_probs, log_probs, advantages, values, returns): ratio torch.exp(log_probs - old_log_probs) clip_eps 0.2 surr1 ratio * advantages surr2 torch.clamp(ratio, 1.0 - clip_eps, 1.0 clip_eps) * advantages policy_loss -torch.min(surr1, surr2).mean() value_loss 0.5 * (returns - values).pow(2).mean() return policy_loss, value_losspolicy_loss前面加负号是因为我们要最大化目标函数而PyTorch的优化器默认是做梯度下降。advantages是为了让正优势的动作概率变大、负优势的动作概率变小。value_loss用的是和returns之间的均方误差returns是通过GAE计算得到的折扣累计回报估计。另外我建议对advantages做标准化处理先减均值再除以标准差。这样能避免优势绝对值过大导致更新步长太大。实操中这一步能显著提升稳定性几乎不需要额外成本。3.4 完整训练循环采样、更新、再采样有了上述模块之后主训练循环就很清晰了。每个iteration先采样一批数据然后在这个批量数据上用Adam优化器迭代多个epoch。PPO的典型设置是采样2048个step然后在上面迭代10个epoch。这个比例不是随便定的是性能和稳定性之间的平衡epoch太少则数据利用率低epoch太多则可能过拟合当前批数据导致策略退化。for iteration in range(total_iterations): buffer collect_rollouts(env, agent, buffer) old_log_probs, values agent.evaluate(buffer) advantages, returns compute_gae(buffer.rewards, buffer.dones, values) advantages (advantages - advantages.mean()) / (advantages.std() 1e-8) for _ in range(update_epochs): indices np.random.permutation(len(buffer)) for start in range(0, len(buffer), batch_size): batch indices[start:start batch_size] log_probs, values_batch agent.evaluate_buffer(buffer, batch) policy_loss, value_loss compute_ppo_loss( old_log_probs[batch], log_probs, advantages[batch], values_batch, returns[batch] ) optimizer.zero_grad() total_loss policy_loss 0.5 * value_loss total_loss.backward() torch.nn.utils.clip_grad_norm_(agent.parameters(), max_norm0.5) optimizer.step()梯度裁剪那行代码很多人会忽略但实际上非常关键。PPO在epoch较多的情况下容易出现个别梯度爆炸导致整个网络参数被冲散的情况。把梯度范数限制在0.5能让训练稳定很多这是我从工程中踩坑踩出来的经验。4. 训练调参实录从震荡到收敛的完整过程4.1 核心超参数清单与推荐配置PPO的超参数虽然相对不敏感但也不是随便填就能跑出好效果。下面是我反复验证后觉得最适合起步的一套配置参数推荐值调整说明clip_eps0.2通常不需要调整如果有发散迹象可以降到0.1gamma0.99折扣因子任务时间跨度越大越接近1lam0.95GAE平衡参数影响优势估计的偏差方差采样步数2048越多越稳定但采样时间线性增加更新epoch数10数据量小时可适当减少到5batch_size64对训练速度影响明显显存允许时可以调大actor学习率3e-4推荐使用线性衰减逐渐降到1e-5范围critic学习率1e-3价值网络可以比策略网络稍快梯度裁剪0.5稳定训练的保险丝不要省优势标准化是减均值除标准差对稳定性影响极大4.2 两个真实案例为什么我的训练不收敛我在调试过程中遇到过两个典型的失败场景。第一个是CartPole训练到第100个iteration左右分数上去了但突然又掉回个位数然后再也拉不起来。排查后发现是因为update_epochs设到了20数据被过度利用策略在第一批数据上反复更新导致新旧策略差距过大旧数据不再能有效指导新策略。把epoch调回10之后问题解决。第二个案例是Pendulum的loss一路飙升到NaN。第一反应是学习率过大但调到1e-5仍然不会改善。最后发现是value网络的输出不稳定导致GAE计算时产生异常值。解决方法是在计算returns之后用torch.clamp把极端值限制在合理范围内同时把critic的学习率从默认值调低一半NaN就消失了。4.3 稳定训练的几条铁律我总结了几条在实战中反复验证过的经验可能和学术代码中的做法有些出入但都是自己踩坑换来的第一一定要做优势标准化。不做标准化的PPO在Pendulum这种连续控制任务上很容易出现训练曲线大起大落做了标准化之后曲线的平滑度肉眼可见地提升。第二不要把update_epochs和batch_size同时调到很大。数据量是2048步的话epoch10、batch_size64已经足够。盲目加大只会让训练变慢而且容易走入过拟合的陷阱。第三奖励缩放要尽早做。有些环境的奖励天然数值很大或很小如果不做缩放价值网络的回归目标会很难学。最简单的方法是除以一个常数让奖励的绝对值范围大致落在1以内。5. 常见问题速查与跨环境性能实录5.1 六类高频问题及排查方案问题现象可能原因解决方向训练曲线完全不动学习率过小或网络初始化问题检查奖励均值是否有变化适当调大学习率训练前期上升快后期崩溃更新epoch过多或优势未标准化epoch降到10以内加入优势标准化Loss变为NaN价值网络输出爆炸或学习率过大梯度裁剪加上降低critic学习率检查回报极值连续控制动作始终不准确高斯分布标准差恒定不变检查标准差参数是否为可学习的确保在优化器参数列表中奖励一直为负且无变化奖励稀疏或探索不足考虑先做奖励塑形或增大熵系数两个环境一好一坏网络结构在不同维度输入下差异检验输入归一化是否统一观测值范围是否合理5.2 两个环境的训练结果记录实际跑完两个环境之后我自己记录的指标如下。CartPole-v1用上述PPO实现约150个iteration时达到500分的封顶分数Pendulum-v1的奖励从-1500附近起步约200个iteration之后稳定在-300到-200的区间大约相当于Pendulum环境下较为合格的水平。如果你发现自己的曲线比这个慢了一倍先别急着怀疑代码优先检查超参数设置和GAE实现细节。5.3 一个容易被忽略的调试技巧最后分享一个我的调试习惯在训练过程中定期保存当前策略并且单独跑一段evaluation轨迹把智能体的实际动作渲染成视频或gif来观察行为。reward曲线只能告诉你分数在涨但不能告诉你是如何涨上去的。比如CartPole如果分数上去了但动作是左右高频抖动的小幅摆动说明策略仍然是脆弱的遇到环境轻微变化就可能崩盘。从实际渲染中看到的行为才是判断策略质量最直观的依据。我自己的经验是跑通PPO不困难真正困难的是理清楚每个模块为什么要这么设计。如果你照着这篇把代码从头到尾敲一遍然后把每个模块对应的论文公式抄下来对照你会发现原本抽象的策略梯度概念会变得非常具体。接下来这个系列我打算写一版在连续控制环境中加大任务难度的实战比如让Agent同时追踪多个目标点并且对比一下PPO和SAC在复杂任务中的实际表现差距有兴趣的朋友可以先跑跑这两个环境。