☰
策略梯度强化学习:从REINFORCE到PPO的实践指南
2026/10/1 16:27:21 网站建设 项目流程

做强化学习时间一长,你就会发现一个问题:同样是梯度下降,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_{t+1}|s_t,a_t)

这里 d(s_0) 是初始状态分布,p(s_{t+1}|s_t,a_t) 是环境转移概率,注意它和策略参数 θ 无关。目标函数是期望折扣回报:

J(θ) = E_{τ~p_θ(τ)} [ Σ_{t=0}^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_θ(τ) = Σ_{t=0}^T ∇_θ log π_θ(a_t|s_t)

于是得到策略梯度定理的最简形式:

∇_θ J(θ) = E_τ [ Σ_t ∇_θ log π_θ(a_t|s_t) R(τ) ]

这个形式已经很能说明问题了:它的意思是,一条轨迹如果回报越高,我们就沿着让这条轨迹概率增大的方向更新 θ;如果回报越低,就抑制这条轨迹的概率。但这里还有一个实际问题,每条轨迹的总回报 R(τ) 包含了过去的奖励,这会导致不必要的方差。直觉上,在时间 t 做出的决策不应该为 t 之前的奖励负责。所以通常会把累积回报改写成“从 t 时刻开始的折扣回报”,也叫 return-to-go:

G_t = Σ_{t'=t}^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) 作为优势。前者偏差小但方差可能还是偏高,后者方差低但偏差也低。后者更常见,因为稳定性更好。

A2C(Advantage 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 = Σ_{t'=t}^{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_steps=1000): 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, gamma=0.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(), lr=3e-3) for epoch in range(1000): states, actions, rewards = collect_trajectory(env, policy) returns = compute_returns(rewards, gamma=0.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-Critic

REINFORCE 跑通之后,再升级到 Actor-Critic 只需要加一个 Critic 网络和一个优势计算过程。Critic 的输出是一个标量状态值 V(s),网络结构可以和 Actor 共享前几层,也可以完全独立。实践里对于环境复杂度不高的任务,独立小网络更容易稳定,省去共享层带来的调参烦恼。

优势计算用 GAE 实现,伪代码如下:

def compute_gae(rewards, values, gamma=0.99, lam=0.95, done=1): 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 里有 NaN;return 标准化时 std 为 0;log_std 经过 exp 后出现无穷大。逐个检查即可。注意torch.nan_to_num可以兜底,但不要依赖它,还是要在数据入口做检查。

5.2 优势估计和奖励尺度

策略梯度对奖励的绝对尺度非常敏感。我见过一个任务,奖励动辄上千,REINFORCE 直接刚开始就 NaN;另一个任务奖励在 1/100 量级,策略几乎不动。原因是梯度的大小与回报量级成正比,不归一化就相当于在旧策略上做了一次步长完全不可控的更新。

处理方式有两个层面。底层的是 return 归一化或 advantage 归一化,这在 REINFORCE 代码里已经做了;上层的是如果任务本身有 reward shaping,尽量把奖励保持在 0~1 这个量级,或者用 log 压缩。我个人习惯在拿到一个新环境后,先跑几轮随机策略,统计 reward 的均值方差,再决定是否需要对奖励做缩放。

5.3 更实用的调参顺序与速查表

刚开始调参最忌讳一次动多个参数。我的实际调试顺序是这样的:先固定 lr=3e-4 或 1e-3(Adam),然后把 GAE 的 λ 设为 0.95,γ 保持 0.99;先看能不能在简单子任务上有一点进步;不行再调熵系数、batch size、更新轮数;最后才动网络结构和 lr 调度。下面是遇到不同问题时的参考表:

现象可能原因建议手段
训练曲线上蹿下跳方差过大,优势估计不准引入 GAE,λ 调小;增大 batch size;return 归一化
熵降为 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、梯度范数都记录下来,训练出问题了才能一眼定位是哪一环出了差错。这些日志在事后复盘调参时,比任何工具都好用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询