尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PPO算法深度解析:从策略梯度推导到PyTorch实现与调参指南

PPO算法深度解析:从策略梯度推导到PyTorch实现与调参指南 1. PPO到底是什么一个老调重弹但值得彻底搞懂的问题PPOProximal Policy Optimization近端策略优化这几年几乎成了强化学习领域的默认选项。不管你是跑游戏智能体、做机器人控制还是折腾大模型的对齐微调十有八九会撞上这个算法。我见过太多人直接把 Stable-Baselines3 里的 PPO 拿出来调参跑通了就觉得自己会了一旦环境复杂一点、训练不稳定就完全不知道从哪里下手。这篇文章不打算带你过一遍论文公式就完事而是从策略梯度的根本问题出发一步步推出来 PPO 为什么要这么设计再把它的核心更新规则拆开揉碎最后给出连续动作空间下的 PyTorch 实现。你可以把它当成一篇完整的个人笔记推公式的部分我都尽量用大白话解释了一遍代码也是能直接跑起来的那种。先说清楚这篇文章适合谁。如果你已经知道策略梯度大概在做什么但理解 PPO 总差一口气或者你用过 PPO 但没自己写过想弄清楚内部机制这篇文章就是给你准备的。如果你完全没接触过强化学习建议先去补一下基本概念比如策略、价值函数、奖励信号、回合和步的概念不然上来啃 PPO 会有点痛苦。2. 从策略梯度到 PPO一步步逼出“近端”两个字2.1 策略梯度的直觉理解强化学习的目标很简单学一个策略把期望回报最大化。如果用数学写出来我们要最大化目标函数[ J(\theta) \mathbb{E}{\tau \sim \pi{\theta}} \left[ \sum_{t0}^{T} \gamma^t r_t \right] ]这里的 (\tau) 是一条轨迹也就是状态、动作、奖励的序列。问题在于这个目标对参数 (\theta) 求梯度很麻烦因为期望里的轨迹分布本身也依赖 (\theta)你没法直接把期望当成常数来求导。策略梯度定理解决的就是这个麻烦。它可以写成这样[ \nabla_{\theta} J(\theta) \mathbb{E}{\tau \sim \pi{\theta}} \left[ \sum_{t0}^{T} \nabla_{\theta} \log \pi_{\theta}(a_t | s_t) \cdot A_t \right] ]其中 (A_t) 是优势函数表示在状态 (s_t) 下采取动作 (a_t) 相对于平均水平的优势。这个公式的推导过程不复杂核心思路就是对数导数技巧[ \nabla_{\theta} \pi_{\theta}(a|s) \pi_{\theta}(a|s) \cdot \nabla_{\theta} \log \pi_{\theta}(a|s) ]这样做的好处是我们不需要对整个轨迹分布求导只需要对策略的概率输出求梯度然后乘上对应的优势值。2.2 普通策略梯度为什么容易翻车理论很漂亮但直接实现起来有个致命问题步长不好选。如果你更新步长太大策略一下子就飞了收集到的下一批数据质量急剧下降训练彻底崩溃。如果步长太小学习速度又慢得让人抓狂。TRPOTrust Region Policy Optimization尝试解决这个问题它用 KL 散度限制了新旧策略的距离每次更新都保证策略变化不会太大。TRPO 效果好但计算复杂需要解一个带约束的优化问题每次更新都要算 Fisher 信息矩阵实现和调参成本都很高。PPO 的思路则更讨巧它把 TRPO 的硬约束变成了软惩罚或者说它用一阶优化的方式去近似 TRPO 的效果计算简单得多效果却不输。2.3 PPO 的裁剪目标是怎么来的PPO 的核心是那个裁剪的替代目标常见的形式是[ L^{CLIP}(\theta) \mathbb{E}_t \left[ \min \left( r_t(\theta) \hat{A}_t, ; \text{clip}(r_t(\theta), 1-\epsilon, 1\epsilon) \hat{A}_t \right) \right] ]这里的 (r_t(\theta)) 是重要性采样比[ r_t(\theta) \frac{\pi_{\theta}(a_t | s_t)}{\pi_{\theta_{old}}(a_t | s_t)} ]这个比例表示新策略和旧策略在同一个状态下选择同一个动作的概率比。如果新策略和旧策略完全一样比例就是 1。偏差越大说明策略变化越大。为什么要用 min 加 clip关键在于当优势 (A_t) 为正时我们希望提高这个动作的概率也就是让 (r_t(\theta)) 变大但不能超过 (1\epsilon)当优势 (A_t) 为负时我们希望降低这个动作的概率也就是让 (r_t(\theta)) 变小但不能低于 (1-\epsilon)。用一张表来对照会更清楚情况优势值裁剪前目标裁剪后目标网络更新方向好动作被低估正提升概率限制最大倍率温和上调坏动作被高估负降低概率限制最小倍率温和下调策略几乎没变正或负正常更新不影响正常更新你可能会问为什么不直接用 KL 散度呢KL 散度的问题在于它是一个全局约束实际计算时很难确定多大算大、多小算小而且每次更新都要额外计算。PPO 的 clip 是逐样本的局部约束实现起来就是一个 max 和 min 的操作梯度回传也非常干净。2.4 从 Dual-Clip PPO 聊聊裁剪的坑如果你在真实项目里用过 PPO可能会遇到一种情况训练初期优势值估计很不准或者奖励信号有较大的离群值导致 clip 完全失效。原因在于标准 PPO 只对目标函数的上界做了裁剪但当下界也被突破时方差会迅速增大。Dual-Clip PPO 就是为了解决这个问题。它在原有基础上增加了一个下界裁剪[ L^{Dual}(\theta) \mathbb{E}_t \left[ \min \left( L^{CLIP}(\theta), ; c \cdot \hat{A}_t \right) \right] ]当优势值特别大且策略概率比也特别大时标准 PPO 可能仍然会让目标涨得很高Dual-Clip 则干脆把这种极端情况一刀切掉用常数 (c) 乘以优势值作为上限相当于设置了一个绝对保险丝。我在实际使用时发现Dual-Clip 对于那些奖励信号噪声大、优势估计不稳定的任务效果很明显尤其是早期训练阶段训练曲线会平滑很多。但它也不是万能的如果 (c) 设置得太小策略更新会变得过于保守学习速度明显下降。2.5 广义优势估计GAEPPO 的数据底座PPO 的更新离不开优势函数 (A_t)但优势函数不是直接能拿到的我们需要用价值网络去估计。GAE 是 PPO 最常见的优势估计方法它用 lambda 参数平衡偏差和方差[ A_t^{GAE} \sum_{l0}^{\infty} (\gamma \lambda)^l \delta_{tl} ]其中[ \delta_t r_t \gamma V(s_{t1}) - V(s_t) ]GAE 的直觉是TD 误差的累积。如果 (\lambda0)就是一步 TD 误差偏差大方差小如果 (\lambda1)就是蒙特卡洛回报偏差小方差大。PPO 常用的 (\lambda) 值在 0.95 到 0.99 之间视任务而定。我见过不少人忽略 GAE 的重要性直接拿折扣回报减去价值估计就当作优势结果训练极其不稳定。GAE 不是锦上添花它是 PPO 能稳定工作的核心基础设施之一。3. PPO 的两大核心网络Actor 与 Critic 的设计3.1 Actor-Critic 结构怎么理解PPO 使用的是 Actor-Critic 架构这个设计其实不难理解你把它想象成运动员和教练的关系。Actor 是运动员负责做动作它输出的是策略分布Critic 是教练负责评价它输出的是状态价值 (V(s))。两者协同工作Actor 根据 Critic 给出的优势信号调整自己的动作策略Critic 则不断学习更准确的评价标准。这里有个容易混淆的地方Critic 并不是直接告诉 Actor 哪个动作好而是给出状态的价值基准。Actor 拿到的信号是优势函数 (A(s,a) Q(s,a) - V(s))也就是这个动作相对于平均水平好多少。如果用教练类比教练不是在说“你刚刚那个动作得 8 分”而是在说“你在这种局面下表现比历史平均水平高了多少”。3.2 连续动作空间的输出设计PPO 要处理连续动作空间Actor 就不能输出离散概率了。常见做法是让 Actor 输出一个高斯分布的均值和方差然后从分布中采样动作。也就是说网络输出 (\mu(s)) 和 (\sigma(s))动作 (a \sim \mathcal{N}(\mu, \sigma^2))。实现时要注意几点第一方差参数的学习方式有两种一种是直接作为网络输出的一部分另一种是用一个独立的 log_std 参数。我推荐后者因为这样可以单独控制方差的学习率训练更稳。第二动作空间的边界问题。如果环境要求动作在某个范围内比如机器人关节角度在 -1 到 1 之间你需要对采样出的动作做 clip 或 tanh 压缩。但这会改变动作分布计算 log 概率时要加修正项这是最容易出错的地方。第三log 概率的计算。高斯分布下[ \log \pi(a|s) -\frac{1}{2} \left( \frac{(a-\mu)^2}{\sigma^2} \log(2\pi\sigma^2) \right) ]如果你的动作经过了 tanh 压缩需要把 log 概率修正为[ \log \pi(a|s) \log \mathcal{N}(u|s) - \sum_i \log(1 - \tanh^2(u_i)) ]其中 (u) 是压缩前的原始采样值。我在实际项目中遇到过这种细节问题很多初学者直接忽略修正项导致重要性采样比计算错误训练效果大打折扣。3.3 网络结构与参数初始化对于一般的连续控制任务两层 256 或 128 的全连接网络就够用了。不需要一开始就堆大网络除非任务特别复杂。激活函数我习惯用 ReLU 或 tanh输出层不激活保持原始数值范围。Critic 的网络结构可以和 Actor 共享底层特征也可以完全独立。共享结构节省参数但容易出现梯度冲突独立结构更稳定训练开销稍大。以我的经验对于中等复杂度的任务独立网络更省心避免了很多调参的麻烦。初始化方面标准差设为较小值会有帮助比如让 Actor 输出的方差初始值在 0.5 到 1 左右这样前期探索不会太激进也不会太保守。4. 核心更新流程的代码实现与逐段解读4.1 从零搭建 PPO 的完整代码结构直接上代码基于 PyTorch 实现一个完整的 PPO支持连续动作空间。先定义网络结构import torch import torch.nn as nn import torch.optim as optim import numpy as np class Actor(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim256, log_std_init-0.5): super().__init__() self.net nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim) ) self.log_std nn.Parameter(torch.ones(action_dim) * log_std_init) def forward(self, s): mu self.net(s) std torch.exp(self.log_std).expand_as(mu) return mu, std def sample(self, s): mu, std self.forward(s) dist torch.distributions.Normal(mu, std) action dist.sample() log_prob dist.log_prob(action).sum(dim-1) return action, log_prob def evaluate(self, s, action): mu, std self.forward(s) dist torch.distributions.Normal(mu, std) log_prob dist.log_prob(action).sum(dim-1) entropy dist.entropy().sum(dim-1) return log_prob, entropy这个 Actor 输出均值和方差使用独立的 log_std好处是方差不会随网络层输出剧烈波动训练更稳定。Critic 更简单class Critic(nn.Module): def __init__(self, state_dim, hidden_dim256): super().__init__() self.net nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 1) ) def forward(self, s): return self.net(s).squeeze(-1)这里要注意Critic 输出的是标量所以最后要 squeeze 掉多余的维度不然和优势函数做运算时容易出现 broadcasting 错误。4.2 经验缓冲区不只是存数据那么简单PPO 是 on-policy 算法这意味着它只能用当前策略收集的数据来更新模型。每次策略更新完之后这批数据就要扔掉重新收集。缓冲区需要记录状态、动作、奖励、是否终止、动作的 log 概率、状态价值。一个标准的缓冲区实现class RolloutBuffer: def __init__(self): self.states [] self.actions [] self.rewards [] self.dones [] self.log_probs [] self.values [] def push(self, s, a, r, d, log_p, v): self.states.append(s) self.actions.append(a) self.rewards.append(r) self.dones.append(d) self.log_probs.append(log_p) self.values.append(v) def clear(self): self.states.clear() self.actions.clear() self.rewards.clear() self.dones.clear() self.log_probs.clear() self.values.clear() def get(self): return (torch.tensor(np.array(self.states), dtypetorch.float32), torch.tensor(np.array(self.actions), dtypetorch.float32), torch.tensor(np.array(self.rewards), dtypetorch.float32), torch.tensor(np.array(self.dones), dtypetorch.float32), torch.tensor(np.array(self.log_probs), dtypetorch.float32), torch.tensor(np.array(self.values), dtypetorch.float32))有几个容易忽略的细节值得说一下。第一dones 必须记录原始环境的终止信号如果中途有人为的截断比如某个 episode 因为超时被强制结束处理方式要区分开否则 GAE 计算会错位。第二log_probs 和 values 是行为策略收集数据时计算的后面算重要性采样比时要用到不能重新算。4.3 训练循环采样、更新、再采样PPO 的训练循环有两种风格。一种是每个 step 都更新效率低另一种是收集一整批数据一个 rollout再统一更新PPO 论文里也推荐这种方式。下面这段是核心训练代码def train_ppo(env, actor, critic, actor_optim, critic_optim, cfg): buffer RolloutBuffer() state, _ env.reset() episode_reward 0 all_rewards [] for step in range(cfg.total_steps): state_t torch.FloatTensor(state).unsqueeze(0) with torch.no_grad(): action, log_prob actor.sample(state_t) value critic(state_t) action_np action.squeeze(0).numpy() next_state, reward, terminated, truncated, _ env.step(action_np) done terminated or truncated buffer.push(state, action_np, reward, done, log_prob.item(), value.item()) episode_reward reward state next_state if done: state, _ env.reset() all_rewards.append(episode_reward) episode_reward 0 if (step 1) % cfg.batch_size 0: update_ppo(actor, critic, actor_optim, critic_optim, buffer, cfg) buffer.clear() return all_rewards注意到这里我用torch.no_grad()来做采样时的前向传播更新时才开梯度这点很重要可以减少不必要的显存占用。4.4 GAE 与更新计算的完整实现采样结束后要计算 GAE再更新网络。GAE 的计算逻辑是逆序回溯因为当前步的优势依赖于下一步的优势def compute_gae(next_value, rewards, dones, values, gamma, lam): advantages [] gae 0 next_val next_value for t in reversed(range(len(rewards))): delta rewards[t] gamma * next_val * (1 - dones[t]) - values[t] gae delta gamma * lam * (1 - dones[t]) * gae advantages.insert(0, gae) next_val values[t] return torch.tensor(advantages, dtypetorch.float32)这里有一个关键点next_val的传递。当 episode 结束时dones[t] 1我们把后续的累计全部清零避免把跨 episode 的奖励错误地混在一起。如果出现环境截断但并非真正终止你还需要额外处理这里为了清晰先不展开。PPO 的更新部分是整个算法的高潮也是最容易出 bug 的地方def update_ppo(actor, critic, actor_optim, critic_optim, buffer, cfg): states, actions, rewards, dones, old_log_probs, values buffer.get() with torch.no_grad(): next_value critic(states[-1].unsqueeze(0)).item() advantages compute_gae(next_value, rewards.tolist(), dones.tolist(), values.tolist(), cfg.gamma, cfg.lam) returns advantages values advantages (advantages - advantages.mean()) / (advantages.std() 1e-8) for _ in range(cfg.epochs): log_probs, entropy actor.evaluate(states, actions) ratio torch.exp(log_probs - old_log_probs) surr1 ratio * advantages surr2 torch.clamp(ratio, 1 - cfg.clip_epsilon, 1 cfg.clip_epsilon) * advantages actor_loss -torch.min(surr1, surr2).mean() - cfg.ent_coef * entropy.mean() critic_loss nn.MSELoss()(critic(states), returns) actor_optim.zero_grad() actor_loss.backward() nn.utils.clip_grad_norm_(actor.parameters(), 0.5) actor_optim.step() critic_optim.zero_grad() critic_loss.backward() nn.utils.clip_grad_norm_(critic.parameters(), 0.5) critic_optim.step()这里面值得说一下为什么要把advantages做标准化。PPO 论文和很多实现里都建议这么做因为它能防止优势值过大或过小时梯度爆炸或消失。returns advantages values这行很多人不理解其实 GA 计算出的 advantage 加上原始价值估计就等于目标的回报估计因为[ \hat{A}_t \hat{R}_t - V(s_t) \Rightarrow \hat{R}_t \hat{A}_t V(s_t) ]所以 critic 的拟合目标就是returns。4.5 损失函数中熵奖励的作用你可能注意到 actor loss 里有一项- entropy.mean()这是熵正则化项。它的作用是鼓励策略保持一定的随机性避免过早陷入确定性策略而停止探索。系数ent_coef通常设置在 0.001 到 0.01 之间太大会导致策略过于随机无法收敛太小则可能过早收敛到局部最优。我一般在大规模训练时会把熵系数调小在早期训练时调大一点。还有一个小技巧如果训练后期熵值下降太快说明策略过早确定了可以适当调大熵系数重新跑一轮。5. 连续动作空间的 PPO 关键细节比离散版本难在哪5.1 动作边界处理不修正 log 概率就是白训前面提到过如果环境要求动作范围限制在 [-1, 1]你采样完再 clip 到范围内会导致 log 概率与实际策略分布不匹配。更规范的方案是用 tanh 压缩原始高斯采样值并把压缩过程中的概率密度变化修正回来。具体做法如下def sample_tanh(self, s): mu, std self.forward(s) dist torch.distributions.Normal(mu, std) raw_action dist.rsample() action torch.tanh(raw_action) log_prob dist.log_prob(raw_action) - torch.log(1 - action.pow(2) 1e-8) log_prob log_prob.sum(dim-1) return action, log_prob这里的修正项来自变量替换定理如果 (a \tanh(u))那么[ \log p(a) \log p(u) - \log(1 - \tanh^2(u)) ]加1e-8是为了防止action接近 ±1 时对数无穷大。这个细节我踩过坑如果忘了修正算法前期表现可能看起来正常但训练到中期会出现莫名其妙的性能崩塌。5.2 探索与利用的平衡连续动作空间下探索策略直接体现在 Actor 输出的噪声大小上也就是 log_std 的值。训练初期log_std 初始化为 -0.5 或 0 左右动作扰动较大策略探索空间广随着训练推进如果任务收敛log_std 通常会自然下降动作变得更确定。但这里有个陷阱如果任务需要持续探索比如非平稳环境log_std 如果降得太低模型就丧失了适应能力。我的做法是在训练过程中给 log_std 设置一个下界或者用熵奖励把它拉回来。还有一种常见做法是把 log_std 设为一个可学习参数但加一个正则项让它不要太大也不要太小。这个思路在很多成功案例里都被验证过值得尝试。5.3 多进程采样与样本利用效率PPO 的 on-policy 特性注定了它的样本效率不高。一个缓解办法是多开几个环境并行采样把收集到的数据拼在一起再更新。这时候要注意的是不同环境可能跑在不同步数GAE 计算时如果跨环境交错就会出现问题。最简单的方案是每个环境各维护一个 RolloutBuffer收集完毕后把数据拼接起来。我的习惯是并行 4 到 8 个环境每个环境收集 2048 步共 8192 到 16384 步作为一次更新的数据量。小批量更新时 shuffle 数据要小心不要破坏时间序列结构但 PPO 不像 RNN 那样对时间顺序敏感所以可以安全 shuffle。6. PPO 实现中的常见问题与排查思路6.1 训练崩溃或性能波动大的原因我见过的最常见的崩溃原因有两个一是优势估计出了问题二是 log_std 学飞了。优势估计出问题的表现是 loss 震荡剧烈但奖励曲线还在正常上升。你可以检查一下 GAE 的输入看 rewards 和 dones 是否配对正确尤其是多环境并行时的顺序问题。一个快速检查办法是单独跑一局打印每一步的 value 和 advantage看看是否在一个合理的量级。log_std 学飞的表现是训练到一半所有动作变成边界值策略几乎没有随机性。这个问题的原因是 log_std 在梯度更新下不断下降。解决办法是给 log_std 加一个约束比如在优化器更新后手动 clamp 一下with torch.no_grad(): actor.log_std.clamp_(min-2.0, max0.5)6.2 学习率与更新次数的配置经验PPO 对学习率其实不是特别敏感但不代表可以乱设。Actor 和 Critic 建议分开设置学习率Critic 可以用稍大一点。常见的做法是 Actor 学习率 3e-4Critic 学习率 1e-3。当然这只是起点实际任务要调。更新次数epochs也是一个关键参数。如果设得太大即使有 clip 保护也可能出现过拟合当前 batch 的现象导致策略在下次采样时表现变差。我一般设置在 3 到 10 之间复杂的任务用小一点简单的任务可以大一点。这里有个经验性的表格供参考参数推荐范围说明学习率1e-4 ~ 1e-3Actor 偏小Critic 偏大clip_epsilon0.1 ~ 0.3默认 0.2gamma0.95 ~ 0.999稀疏奖励用接近 1 的值lam0.95 ~ 0.99GAE 权衡参数epochs3 ~ 10每批数据内部更新次数batch_size2048 ~ 8192一次更新用的经验数据量6.3 模型不收敛时检查什么不收敛很难受但排查路径是固定的。第一检查动作是否在有效范围内如果连续动作控制的机器人初始动作就是非法值环境可能立刻返回终止。第二检查奖励是否被正确缩放奖励过大会导致梯度爆炸过小则学习缓慢。第三确认价值网络是否在正确学习画一条价值估计与折扣回报的对比曲线如果偏差一直很大说明 critic 没学会。还有一个容易忽略的问题环境的 reset 函数返回的下一个状态与观测空间是否匹配如果维度和网络输入不一致训练会立刻挂掉。这种低级错误在跑代码时经常出现用 torchinfo 打印一下网络输入输出尺寸是最快的方法。6.4 PPO 常见坑位速查表现象可能原因排查思路训练开始后 loss 为 NaN梯度爆炸检查奖励范围、学习率、clip_grad_norm熵值骤降为 0策略过早确定检查 log_std 约束调大熵系数优势值巨大GAE 参数错误检查 gammma、lam 与 dones 是否匹配动作总是在边界tanh 修正缺失检查 log 概率修正项Critic loss 不降价值估计目标混乱检查 returns 计算是否用对 value7. Dual-Clip PPO 的扩展实现对抗不稳定训练的一剂猛药标准的 PPO 已经足够稳健但如果你在真实环境中跑过尤其是那些奖励稀疏、噪声较大的任务你会发现标准 PPO 还是会有一次大更新后策略崩掉的情况。Dual-Clip PPO 就是为了解决这个问题。它的核心改动在 loss 计算上加了一个绝对下界def dual_clip_ppo_loss(log_probs, old_log_probs, advantages, clip_epsilon, c3.0): ratio torch.exp(log_probs - old_log_probs) surr1 ratio * advantages surr2 torch.clamp(ratio, 1 - clip_epsilon, 1 clip_epsilon) * advantages pi_clip torch.min(surr1, surr2) dual_clip torch.min(pi_clip, c * advantages) return -dual_clip.mean()当优势值大且 ratio 大于 1 时如果再加大 ratio目标最多只能到 (c \cdot A)而不是标准 PPO 中可能达到的更高值。这相当于给策略更新上了双保险。但 Dual-Clip 的代价是可能会降低样本效率因为很多本来可以让策略大步前进的机会被限制了。我一般只在噪声大的任务里启用它像机器人控制这类奖励平滑的问题用标准 PPO 就够了。一个折中的办法是动态调整 (c) 的值训练早期优势估计不稳定时用小一点的 (c)训练后期逐渐增大让它接近标准 PPO 的行为。8. 实操总结我踩过的坑和验证过的心得8.1 初始化对训练的影响比想象中大很多人不太注意初始化但我实测下来发现Actor 输出层的初始化对训练初期影响巨大。如果你输出层的权重初始化为高斯分布的默认值动作均值可能一开始就跑到边界附近策略会觉得所有动作回报都很差更新会非常慢。一个有效做法是将输出层权重初始化为较小的值def init_weights(m): if isinstance(m, nn.Linear): nn.init.orthogonal_(m.weight, gain0.01) nn.init.zeros_(m.bias)用正交初始化并设置较小的 gain可以让策略输出的动作均值接近 0对应 normal 分布的均值附近这样初始探索比较均匀。8.2 奖励归一化最容易上手的效果提升手段奖励的绝对大小会显著影响 value network 的学习。如果奖励的范围在几百到几千critic 要拟合的目标会很大梯度容易爆炸。最简单粗暴但很有效的办法是使用 RunningNormalizer 对奖励或观测做归一化。在实现时有一个注意事项归一化器只能使用在线的统计值不能用整个训练过程的全局统计。我通常是在收集数据时更新统计量然后用当前的统计量去归一化奖励。8.3 保存与恢复训练不要每次都从头跑PPO 训练周期动辄几十万步中途停电或者服务器重启是常有的事。所以从第一天开始就要保存 checkpoint我一般每 1 万步保存一次包含 actor、critic、optimizer、log_std 的完整状态。恢复训练时要注意optimizer 的状态也要恢复否则学习率衰减和动量信息会丢失继续训练反而可能不稳定。8.4 一个不得不说的观点不要迷信超参很多人跑 PPO 跑不出效果第一反应是调参但很多时候问题出在环境设计或数据流上。如果你发现 PPO 怎么调都不收敛先停下来检查环境是否有隐性 bug、奖励是否合理、状态是否充分。超参只能在环境没问题时发挥作用。拿我一个具体案例来说之前做一个机械臂抓取任务PPO 训练一直不收敛奖励曲线完全不动。查了半天发现是 reward 里有一个小的分支逻辑在部分状态下会返回 NaN。当我把 NaN 修掉训练立刻就正常了。这类问题比超参更值得警惕。8.5 PPO 的后续扩展方向文章最后分享一个思路如果你已经把标准 PPO 搞明白了可以往三个方向延展一是多智能体 PPO比如 MAPPO核心逻辑不变但需要处理智能体间的信息共享二是在 PPO 中引入循环神经网络用于处理部分可观测环境三是把 PPO 的更新机制迁移到离策略框架这涉及到更复杂的样本复用策略。这些方向都有一个共同点吃透 PPO 的数学动机和实现细节之后你才能理解改动为什么合理、哪些地方会出问题。希望这篇从推导到落地、再把坑和心得都盘出来的文章能让你少走一次弯路。
返回列表