ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

策略梯度强化学习:从REINFORCE到PPO的实践指南

2026/10/1 16:27:29 拓冰建站 浏览量
策略梯度强化学习:从REINFORCE到PPO的实践指南 做强化学习时间一长你就会发现一个问题同样是梯度下降Q-learning 学的是“这个状态下哪个动作值钱”而策略梯度学的是“这个状态下应该怎么做”。一个在估计价值一个在直接找策略。我第一次做连续控制时用 Q-learning 类算法在 CartPole 上玩得挺开心换到连续动作环境立刻哑火后来老老实实把策略梯度啃了一遍才发现之前没想明白的那些问题其实都藏在“为什么这样更新”里。这篇是我对策略梯度一段时间的实践和阅读总结覆盖思路、公式、算法演进、代码实现和踩坑经验适合刚入门强化学习的同学也适合那些跑模型总是不收敛想找原因的人。1. 策略梯度到底解决了什么问题1.1 基于值函数的方法是怎么失效的强化学习早期大家最熟悉的套路是值函数方法先学一个 Q(s,a)然后策略直接通过贪心获得也就是在某个状态下选 Q 值最大的动作。这个思路在离散动作空间里非常自然状态和动作都有限查表或者用一个网络做回归都行。但一旦动作空间变成连续的比如机械臂关节角度是 6 维实数能够选的动作数量是无限的你根本不可能枚举所有动作去取 argmax。这是 DQN 这类方法最尴尬的地方。即使强行把连续动作离散化比如把每个关节角度切成 100 段6 维动作空间就是 100 的 6 次方爆炸得离谱。而且离散化之后动作之间本来存在的平滑关系也被破坏了明明两个相邻角度效果差不多离散后却可能变成完全不同的两个选项。值函数方法还容易出现“过估计”也就是 Q 值越学越乐观策略也跟着变激进在真实环境里表现为一次失误后整个训练断崖下坠。1.2 策略梯度的核心思路直接对策略做梯度优化策略梯度的想法非常直白你干脆不要去估计 Q 值直接把策略本身参数化然后用梯度上升最大化期望回报。所谓策略 π_θ(a|s)就是一个以 θ 为参数的分布离散动作下是 softmax 分类分布连续动作下通常是一个高斯分布网络输出均值 μ 和方差 σ²动作从分布里采样得到。这样做的好处是动作选择天然就是平滑的。网络输出的均值稍微变化一点采样的动作也只会小幅度变化这符合连续控制的直觉。更重要的是策略梯度方法天然是随机策略它输出的是一个概率分布不是“唯一最优动作”所以探索行为被内建在策略里。哪怕是在完全确定性的环境里随机策略的更新过程也更稳健因为它不会因为一次异常样本就把策略推向极端。所以在实际操作中策略梯度特别适合三类场景连续动作控制、部分可观测环境、需要随机策略的任务。这其实是策略梯度“火起来”的根本原因——它把“学价值”和“选动作”这两个问题合并成了一个“优化策略”的问题省掉了 argmax 这个不好处理的步骤。2. 策略梯度的数学原理与推导2.1 符号约定与目标函数要写策略梯度的公式先约定符号。用 s_t 表示 t 时刻状态a_t 表示动作r_t r(s_t,a_t) 表示单步奖励γ 是折扣因子。一条完整的轨迹 τ 就是 (s_0,a_0,r_0,s_1,a_1,...,s_T)它的概率可以写成p_θ(τ) d(s_0) ∏ π_θ(a_t|s_t) p(s_{t1}|s_t,a_t)这里 d(s_0) 是初始状态分布p(s_{t1}|s_t,a_t) 是环境转移概率注意它和策略参数 θ 无关。目标函数是期望折扣回报J(θ) E_{τ~p_θ(τ)} [ Σ_{t0}^T γ^t r_t ]核心目标是对 θ 求梯度然后做梯度上升。但这里有一个微妙的问题期望的分布本身依赖 θ不能像监督学习那样直接把梯度放进期望内部。这时候用到一个关键技巧叫 log 导数技巧也叫似然比技巧。log 导数技巧的本质很简单对任意函数 f 对分布参数求导可以写成 E[f(x)∇log p(x)] 的形式。先铺垫一下公式∇_θ p_θ(τ) p_θ(τ) ∇_θ log p_θ(τ)。这个等式只是微积分里的链式法则因为 d/dx log f(x) f(x)/f(x)所以 f(x) f(x) * (log f(x))。就是这样简单没有更多神秘的东西。2.2 策略梯度定理从期望到采样把梯度写到期望里去∇_θ J(θ) ∫ ∇_θ p_θ(τ) R(τ) dτ ∫ p_θ(τ) ∇_θ log p_θ(τ) R(τ) dτ E_{τ~p_θ(τ)} [ ∇_θ log p_θ(τ) R(τ) ]其中 R(τ) Σ γ^t r_t。再展开 log p_θ(τ)由于环境转移概率都与 θ 无关只有策略项保留下来∇_θ log p_θ(τ) Σ_{t0}^T ∇_θ log π_θ(a_t|s_t)于是得到策略梯度定理的最简形式∇_θ J(θ) E_τ [ Σ_t ∇_θ log π_θ(a_t|s_t) R(τ) ]这个形式已经很能说明问题了它的意思是一条轨迹如果回报越高我们就沿着让这条轨迹概率增大的方向更新 θ如果回报越低就抑制这条轨迹的概率。但这里还有一个实际问题每条轨迹的总回报 R(τ) 包含了过去的奖励这会导致不必要的方差。直觉上在时间 t 做出的决策不应该为 t 之前的奖励负责。所以通常会把累积回报改写成“从 t 时刻开始的折扣回报”也叫 return-to-goG_t Σ_{tt}^T γ^{t-t} r_{t}这样替换之后梯度期望不变但方差会显著降低因为去掉了历史奖励带来的噪声。2.3 为什么会有高方差怎么压很多初学者对策略梯度的第一印象就是“怎么这么不稳”。核心原因在于这是一个蒙特卡洛估计你只是采样了一批轨迹用这批样本的平均来近似期望。采样数量少、轨迹长度长、环境随机性大都会让估计的方差直线上升。一种有效的降方差手段是引入基线 baseline b(s)。在梯度公式中减去一项与当前动作无关的基线期望不变但方差能降不少。原理是E[∇log π_θ(a|s) b(s)] Σ_a π_θ(a|s) ∇_θ log π_θ(a|s) b(s)而 ∇_θ Σ_a π_θ(a|s) ∇_θ 1 0所以这一项为零。这意味着你可以在梯度里任意减去一个只依赖状态、不依赖动作的函数期望不变。最常见的基线选择是状态价值函数 V(s)。减去 V(s) 之后括号里的部分变成优势函数 A(s,a) Q(s,a) - V(s)它直观含义是“这个动作比当前状态平均水平好多少”。如果优势为正就提高这个动作的概率优势为负就降低概率。这就是 Actor-Critic 方法的雏形用一个 Critic 来估计 V(s)作为基线用来优化 Actor 的策略。在实际代码中如果你什么都不做策略梯度的差方差会表现为损失曲线剧烈抖动甚至出现一次大回报就把策略参数撞飞的情况。所以做归一化、引入基线、使用 GAE 这些手段本质上都是在把“蒙特卡洛噪声”压下去让梯度方向更可信。3. 从 REINFORCE 到 PPO策略梯度家族进化史3.1 REINFORCE最朴素的蒙特卡洛版本REINFORCE 是最早也最直接的一种策略梯度算法。它的更新规则就是上面推导出来的形式采集完整轨迹计算从每个时间步开始的折扣回报 G_t然后用梯度 ∇log π_θ(a_t|s_t) * G_t 去更新参数。我最早实现 REINFORCE 时错了一个关键细节把 G_t 计算成了整条轨迹的总回报而不是从当前时间步开始。结果训练非常慢后来才意识到一个动作只能影响它之后的回报把它之前已经拿到的奖励也算到它头上只会把属性混进去。改成 return-to-go 之后收敛速度立刻上了一个台阶。REINFORCE 的最大优点是逻辑简单几行代码就能跑通最大缺点是方差很大。它只用一条或几条完整轨迹做估计而且完全没有利用环境模型所以只有在线下环境、轨迹比较短、奖励不稀疏的情况下才比较实用。CartPole、小规模网格世界这种任务用它做教学特别合适真上了连续控制在复杂环境里跑基本都会被波动折磨到怀疑人生。3.2 引入基线和 Actor-Critic把方差压下去既然 REINFORCE 方差大很自然的想法就是前面说的引入一个基线 V(s) 来降低方差。问题是 V(s) 也是未知的于是我们一边优化策略一边用另一个网络去学 V(s)。这个“另一个网络”就是 Critic而策略网络则是 Actor这就是 Actor-Critic 架构。实现上有两种主流做法。一种是先用 Critic 估计 V(s)然后直接用当前奖励加上折扣后的 V(s) 近似 Q(s,a)这样的优势估计是单步 TD 误差δ r γV(s) - V(s)。另一种是仍然用蒙特卡洛方式算 return-to-go然后再减掉 V(s) 作为优势。前者偏差小但方差可能还是偏高后者方差低但偏差也低。后者更常见因为稳定性更好。A2CAdvantage Actor-Critic就是典型代表它用并行的多个环境收集数据同步更新。A3C 则是异步版本在历史上性能更好但因为调试困难和复现性差现在大家更偏爱同步的 A2C。本质上 A2C 就是“策略梯度 基线 多环境并行采样”的组合它比 REINFORCE 稳定得多也是很多现代算法的基础组件。3.3 GAE 与 PPO把信任域带进策略更新A2C 让策略梯度从“能跑”变成了“堪用”但还有一个隐患更新步长不好选。步长太大策略一次更新太猛直接导致性能坍缩步长太小学得太慢。为此出现了 TRPO它通过约束新旧策略的 KL 散度来保证每次更新都在一个信任域内理论很漂亮但计算复杂度高实际实现要用共轭梯度解约束优化不是所有人都愿意折腾。PPO 是 TRPO 的简化版本它的做法非常工程化在目标函数里直接加入截断项。设 ratio π_θ(a|s) / π_θ_old(a|s)表示新策略下该动作的概率与旧策略下的比值PPO 的目标函数是L E [ min(ratio · A, clip(ratio, 1-ε, 1ε) · A) ]这里的 ε 通常取 0.2。当优势 A 0 时我们希望增大这个动作的概率但 ratio 最多增长到 1ε当 A 0 时我们希望降低概率但 ratio 最小只能减到 1-ε。这样就保证了策略更新的幅度不会太大。说白了PPO 就是“用一行代码做 KL 约束”它把 TRPO 的复杂优化简化成了一个 min 和 clip效果却非常好这使它成了目前强化学习领域的默认选择。另一个重要的配套组件是 GAE即广义优势估计。它用参数 λ 在“单步 TD”和“蒙特卡洛 full return”之间做插值给了一个平滑的优势估计A_t Σ_{tt}^{T} (γλ)^{t-t} δ_{t}。λ 越大越接近蒙特卡洛偏差低但方差大λ 越小越接近单步 TD方差低但偏差可能高。实操里 λ 通常在 0.95~0.99 之间这个参数对训练稳定性影响非常大值得仔细调。这里要特别提醒一个 PPO 实现里的常见坑advantage 必须用旧策略采样时的状态价值来计算而不是用更新后的策略的当前价值。很多人写代码时把 advantage 和 ratio 混在一起更新导致梯度被污染。正确的做法是先用旧策略跑一批数据算出 advantage存下来然后进行多个 epoch 的参数更新但 ratio 分母始终用旧的 log_prob不能重新计算。这个坑我在正式项目里踩过一次结果训练曲线直接飞掉。4. 落地实现一个最小可跑的策略梯度项目4.1 环境选择与网络设计纸上谈兵再多不如把一个最小实现跑通。先说环境选择我建议从 CartPole 这类经典环境入手因为你追求的是快速验证算法逻辑环境简单、奖励密集、轨迹短方便调试。等代码稳定了再换到连续控制环境比如 Pendulum 或 HalfCheetah。网络设计上一个两层全连接网络通常就够用。离散动作空间用 softmax 输出概率连续动作空间则输出均值和对数标准差。注意标准差一般用 log_std 来参数化而不是直接输出 std因为对数空间天然保证 σ 0而且优化更平滑。log_std 的初始值对训练影响很大经验上初始化为 0 或 -0.5 比较合适。如果初始化太大探索噪声过猛策略一开始就像喝醉酒乱撞如果初始化太小探索不足很快会收敛到一个平庸的局部解。4.2 REINFORCE 的最小实现下面给出一段非常精简的 REINFORCE 核心逻辑用 PyTorch 表达import torch import torch.nn as nn import torch.optim as optim from torch.distributions import Normal class Policy(nn.Module): def __init__(self, obs_dim, act_dim): super().__init__() self.fc nn.Sequential( nn.Linear(obs_dim, 64), nn.Tanh(), nn.Linear(64, 64), nn.Tanh(), nn.Linear(64, act_dim) ) self.log_std nn.Parameter(torch.zeros(act_dim)) def forward(self, x): mean self.fc(x) std self.log_std.exp().clamp(1e-6, 1) return mean, std def collect_trajectory(env, policy, max_steps1000): states, actions, rewards [], [], [] state, _ env.reset() for _ in range(max_steps): state_t torch.FloatTensor(state).unsqueeze(0) mean, std policy(state_t) dist Normal(mean, std) action dist.sample().squeeze(0).numpy() next_state, reward, terminated, truncated, _ env.step(action) states.append(state_t); actions.append(action); rewards.append(reward) state next_state if terminated or truncated: break return states, actions, rewards def compute_returns(rewards, gamma0.99): g 0 returns [] for r in reversed(rewards): g r gamma * g returns.insert(0, g) returns torch.FloatTensor(returns) returns (returns - returns.mean()) / (returns.std() 1e-8) return returns训练循环里最关键的一步是反向传播之前把轨迹里的 log_prob 和 return 对应起来optimizer optim.Adam(policy.parameters(), lr3e-3) for epoch in range(1000): states, actions, rewards collect_trajectory(env, policy) returns compute_returns(rewards, gamma0.99) log_probs [] for s, a in zip(states, actions): mean, std policy(s) dist Normal(mean, std) log_prob dist.log_prob(torch.FloatTensor(a)).sum() log_probs.append(log_prob) loss - (torch.stack(log_probs) * returns).sum() optimizer.zero_grad() loss.backward() optimizer.step()注意这里做了两个小操作都非常重要。一是 return 做了标准化这个操作能让 loss 的尺度稳定防止一次大回报就把策略梯度带偏。二是 loss 取了负号因为 PyTorch 默认做梯度下降而我们想要的是最大化期望回报所以把目标函数取负转成最小化问题。这两点几乎是我见过所有新手实现里最容易出错的地方。4.3 升级到 Actor-CriticREINFORCE 跑通之后再升级到 Actor-Critic 只需要加一个 Critic 网络和一个优势计算过程。Critic 的输出是一个标量状态值 V(s)网络结构可以和 Actor 共享前几层也可以完全独立。实践里对于环境复杂度不高的任务独立小网络更容易稳定省去共享层带来的调参烦恼。优势计算用 GAE 实现伪代码如下def compute_gae(rewards, values, gamma0.99, lam0.95, done1): advantage 0 advantages [] next_value 0 for r, v in zip(reversed(rewards), reversed(values)): delta r gamma * next_value - v advantage delta gamma * lam * advantage advantages.insert(0, advantage) next_value v # 注意最后需要处理 done 边界 return torch.FloatTensor(advantages)每次更新时Actor 的 loss 是-(log_prob * advantage).mean()Critic 的 loss 是mse_loss(v_pred, return)然后两个 loss 相加作为总 loss。我个人习惯把 critic loss 的系数设成 0.5并且加一个detach()把 advantage 当作固定目标来优化 Actor不要让价值梯度回流到 Actor 里去。如果你发现 Actor 和 Critic 损失来回扯皮大概率就是没有正确 detach。4.4 几个容易忽略的实现细节数据收集阶段一定要用旧的 action sample 对应的 log_prob不能更新策略后重新算一遍再用于 ratio。连续动作环境下policy 输出的 std 在较早阶段如果坍缩到极小值训练直接进入“假收敛”事后再怎么调 lr 都爬不出来。建议在每个 episode 后打印 log_std 的值观察它是否快速下降。奖励归一化不只是 return 归一化还有 observation 归一化。如果状态数值差异过大比如有的维度是 0.1 量级有的是 1000 量级网络会很难学。梯度裁剪在策略梯度里是默认保险丝nn.utils.clip_grad_norm_设置 max_norm 为 0.5 或 1.0。不要等到模型跑飞了才想起加裁剪先加上再说。5. 常见问题和调参避坑实录5.1 训练不收敛先看熵再看梯度训练策略梯度的第一步不是看 reward curve 涨没涨而是打印策略的熵。熵可以直观反映探索状态如果熵骤降到接近 0说明策略已经变成了近似确定性策略后续梯度基本不起作用训练基本停滞。这个时候你需要增加熵正则系数 β或者调大 action 的初始标准差。另一个常见问题是梯度数值异常表现为 loss 出现 NaN。原因一般有三类reward 里有 NaNreturn 标准化时 std 为 0log_std 经过 exp 后出现无穷大。逐个检查即可。注意torch.nan_to_num可以兜底但不要依赖它还是要在数据入口做检查。5.2 优势估计和奖励尺度策略梯度对奖励的绝对尺度非常敏感。我见过一个任务奖励动辄上千REINFORCE 直接刚开始就 NaN另一个任务奖励在 1/100 量级策略几乎不动。原因是梯度的大小与回报量级成正比不归一化就相当于在旧策略上做了一次步长完全不可控的更新。处理方式有两个层面。底层的是 return 归一化或 advantage 归一化这在 REINFORCE 代码里已经做了上层的是如果任务本身有 reward shaping尽量把奖励保持在 0~1 这个量级或者用 log 压缩。我个人习惯在拿到一个新环境后先跑几轮随机策略统计 reward 的均值方差再决定是否需要对奖励做缩放。5.3 更实用的调参顺序与速查表刚开始调参最忌讳一次动多个参数。我的实际调试顺序是这样的先固定 lr3e-4 或 1e-3Adam然后把 GAE 的 λ 设为 0.95γ 保持 0.99先看能不能在简单子任务上有一点进步不行再调熵系数、batch size、更新轮数最后才动网络结构和 lr 调度。下面是遇到不同问题时的参考表现象可能原因建议手段训练曲线上蹿下跳方差过大优势估计不准引入 GAEλ 调小增大 batch sizereturn 归一化熵降为 0策略固定探索不足局部最优增大熵系数 β调大 log_std 初始值Loss 为 NaN数据含 NaN 或 return std 为 0检查 reward、归一化时加 epsilon梯度裁剪训练很慢但不崩奖励稀疏信号太弱检查 reward shaping调大 γ 到 0.995延长轨迹长度更新后性能骤降PPO 中 ratio 过大确认 clip 系数 ε 是否太小检查 advantage 是否用旧策略算5.4 实际项目中才发现的坑再分享几个只有真正跑了项目才会意识到的问题。第一是 seed 问题强化学习对种子极其敏感同一个参数换一个种子可能一个收敛一个发散。所以实验对比时一定要固定多种子不要只跑一个。我在一个连续控制任务里就遇到过这种情况换种子后训练曲线差异大得像换了任务。第二是 replay buffer 在 on-policy 算法里的陷阱。策略梯度本质是 on-policy 的采集数据的分布和当前更新后的策略一旦不匹配梯度就有偏差。如果你发现自己的 PPO 训练曲线不断抖可能是因为 target 更新频率和 data collector 不同步。第三是并行环境的重要性。A2C 和 PPO 官方实现都会用 8~16 个并行环境采样这不仅仅是提速还能有效平滑环境随机性带来的梯度波动。我自己有个观察单环境的 PPO 几乎不能稳定跑通连续控制任务而换成 8 个并行环境后同样的超参数瞬间就稳定了。第四是网络设计中的激活函数选择。策略网络用 Tanh 在强化学习中比 ReLU 更常见因为 ReLU 输出会在负数部分被截断影响策略的连续性。我的经验是Actor 和 Critic 网络中间层都用 Tanh输出层如果是均值可以用 Tanh 配合缩放如果是分类概率就用 Softmax。6. 从策略梯度向外延伸的一点经验跑完一个最小策略梯度项目之后我最大的感受是这个领域表面上是方法论设计实际拼的是工程直觉。很多论文里的公式写得干干净净但落到实现里你很快会发现 normalization、seed、并行环境、熵正则这件事对结果的影响比算法本身还大。那些看起来“差不多”的改动在实验里可能差出一大截。如果继续深入下一步值得琢磨的是多智能体策略梯度、离线强化学习里对行为策略的处理以及在真实机器人上做 sim-to-real 时需要加哪些约束。我自己实际做下来发现把这些工程细节记牢比多背几个公式有用得多。最后再说一个很零碎的小技巧跑实验前先写好 checkpoint 和日志打印逻辑把每个 episode 策略的熵、reward、梯度范数都记录下来训练出问题了才能一眼定位是哪一环出了差错。这些日志在事后复盘调参时比任何工具都好用。