☰
Actor-Critic与PPO实战:从策略梯度推导到调参避坑指南
2026/9/30 7:51:01 网站建设 项目流程

简介:这份资料围绕强化学习中 Actor-Critic 框架与 PPO 算法展开,面向已具备神经网络与梯度下降基础、希望深入理解策略梯度与近端策略优化的学习者,帮助打通从策略梯度推导到 PPO 实现的完整链路。资源为单个 PDF 文件,压缩包约 599KB,内容以公式推导与框架图讲解为主,适合作为强化学习入门到进阶的推导笔记。资料从 Actor 网络选择动作、Critic 网络评估状态价值入手,逐步引出 td_error、优势函数与重要性采样,并完整推导 PPO 的损失函数与梯度更新过程,同时讨论 baseline 与合适信用分配两个关键技巧。目前已有 6054 人学习,适合需要系统梳理 PPO 原理、对照公式理解算法细节的读者参考。

1. Actor-Critic 与 PPO:从策略梯度到工程可用的推导路线

如果你写过 REINFORCE,大概率经历过这种崩溃:同一个任务,换个随机种子,回报曲线能从「稳步上升」变成「原地躺平」。策略梯度方差大是公开的秘密,而 Actor-Critic 就是冲着这个痛点来的——用一个 Critic 估计价值,替代蒙特卡洛回报,把方差压下去。但引入 Critic 之后,训练又变得不稳定:策略更新太猛,Critic 跟不上,整个系统开始震荡。PPO(Proximal Policy Optimization)正是为了解决「更新幅度」这个玄学问题而生的,它用重要性采样比率加裁剪,把每次策略更新限制在一个可信区间内。这套组合现在是深度强化学习里最常用的基线之一,从机械臂控制到交通信号灯调度都能看到它的影子。这篇笔记不打算复述教科书,而是把 Actor-Critic 的结构、PPO 的目标函数推导、以及落地时真正会卡住你的参数和坑,一条线讲清楚。适合已经了解 MDP 基本概念、想动手实现或调参的从业者。

2. Actor-Critic 到底在算什么:策略梯度、价值估计与两者如何咬合

2.1 从策略梯度到 Actor-Critic 的动机

策略梯度的核心公式是:

∇J(θ) = E[∇log π_θ(a|s) · G_t]

其中 G_t 是蒙特卡洛回报,从当前时刻一直累加到回合结束。问题在于,G_t 的方差随回合长度线性增长。一个 200 步的回合,G_t 里包含了 200 个随机动作和随机奖励的叠加,梯度估计的噪声大到需要海量样本才能平均掉。

Actor-Critic 的思路很直接:用一个函数 V_φ(s) 来估计「从状态 s 出发的期望回报」,然后用它替代 G_t。这样梯度变成:

∇J(θ) = E[∇log π_θ(a|s) · (R_t + γV_φ(s_{t+1}) - V_φ(s_t))]

括号里那一项就是优势函数 A(s,a) 的估计,通常叫 TD 误差。Critic 的任务是最小化 V_φ(s_t) 和 R_t + γV_φ(s_{t+1}) 之间的差距,也就是做时序差分学习。

这里有个关键点:Actor 和 Critic 共享同一个环境交互产生的数据,但优化目标不同。Actor 要最大化期望回报,Critic 要最小化价值估计误差。两者交替更新,互相依赖——Critic 估得准,Actor 的梯度方向才靠谱;Actor 策略变好,Critic 的目标值也跟着变。这种耦合关系是 Actor-Critic 不稳定的根源,也是 PPO 要解决的问题。

2.2 Actor 和 Critic 的网络结构怎么搭

常见做法是共享底层特征提取层,然后分两个头:一个输出动作分布参数(Actor),一个输出状态价值(Critic)。以连续控制为例,Actor 输出高斯分布的均值和标准差,Critic 输出一个标量。

import torch import torch.nn as nn import torch.nn.functional as F class ActorCritic(nn.Module): def __init__(self, state_dim, action_dim, hidden=256): super().__init__() # 共享特征层 self.shared = nn.Sequential( nn.Linear(state_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), ) # Actor 头:输出动作均值 self.actor_mean = nn.Linear(hidden, action_dim) # Actor 头:输出动作标准差(用 log_std 保证正数) self.actor_log_std = nn.Parameter(torch.zeros(action_dim)) # Critic 头:输出状态价值 self.critic = nn.Linear(hidden, 1) def forward(self, state): feat = self.shared(state) mean = self.actor_mean(feat) # log_std 独立于状态,也可以做成状态相关 std = self.actor_log_std.exp().expand_as(mean) value = self.critic(feat) return mean, std, value def get_action(self, state): mean, std, value = self.forward(state) dist = torch.distributions.Normal(mean, std) action = dist.sample() log_prob = dist.log_prob(action).sum(dim=-1) return action, log_prob, value

逻辑说明:共享层让 Actor 和 Critic 复用特征,减少参数量,但也会带来梯度干扰——Critic 的损失可能破坏 Actor 需要的特征。如果训练不稳定,可以试试不共享,各自独立网络。actor_log_std用可学习参数而不是网络输出,是连续控制里的常见做法,好处是标准差不会随状态剧烈波动,坏处是失去了状态相关的探索能力。参数方面,hidden 取 256 是常见起点,任务简单可以降到 64,复杂任务可以加到 512。激活函数用 Tanh 比 ReLU 在连续控制里更稳,因为输出范围有界。

2.3 优势函数估计:GAE 怎么用

直接用单步 TD 误差估计优势,偏差大但方差小;用蒙特卡洛回报,方差大但偏差小。GAE(Generalized Advantage Estimation)用参数 λ 在两者之间插值:

A_t^GAE = Σ (γλ)^l · δ_{t+l}

其中 δ_t = R_t + γV(s_{t+1}) - V(s_t)。λ=0 退化成单步 TD,λ=1 退化成蒙特卡洛。实践中 λ 取 0.95 左右,γ 取 0.99。

def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95): """ rewards: list of float, 长度 T values: list of float, 长度 T+1(最后一个是 bootstrap 值) dones: list of bool, 长度 T """ T = len(rewards) advantages = [0.0] * T last_gae = 0.0 for t in reversed(range(T)): # 如果回合结束,next_value 为 0 next_value = 0.0 if dones[t] else values[t + 1] delta = rewards[t] + gamma * next_value - values[t] last_gae = delta + gamma * lam * (0.0 if dones[t] else last_gae) advantages[t] = last_gae returns = [advantages[t] + values[t] for t in range(T)] return advantages, returns

逻辑说明:从后往前递推,last_gae累积多步 TD 误差。dones[t]为 True 时,next_value 置零,且 GAE 累积断掉,因为回合已经结束。返回的returns是 Critic 的回归目标,等于优势加基线值。参数方面,γ 控制折扣视野,0.99 对应约 100 步的有效视野;λ 控制偏差方差权衡,0.95 是常用默认值。如果任务回合很短(比如 50 步以内),λ 可以调到 0.9 甚至更低,减少方差。

3. PPO 的目标函数推导:从 TRPO 到裁剪替代目标

3.1 重要性采样与信赖域

策略梯度是 on-policy 的:用当前策略 π_θ 采的数据,只能更新当前策略。但采样很贵,我们希望一批数据能多次更新。重要性采样解决了这个问题:

E_{a~π_θ}[f(a)] = E_{a~π_old}[π_θ(a|s) / π_old(a|s) · f(a)]

令 r_t(θ) = π_θ(a_t|s_t) / π_old(a_t|s_t),则替代目标可以写成:

L(θ) = E[r_t(θ) · A_t]

但 r_t(θ) 偏离 1 太远时,重要性采样的方差会爆炸。TRPO 的做法是加一个 KL 约束:

max L(θ) s.t. E[KL(π_old || π_θ)] ≤ δ

TRPO 用共轭梯度加线搜索求解,实现复杂,计算量大。PPO 用更简单的方式近似这个约束。

3.2 裁剪替代目标:PPO-Clip

PPO 的核心目标函数:

L^CLIP(θ) = E[min(r_t(θ) · A_t, clip(r_t(θ), 1-ε, 1+ε) · A_t)]

当 A_t > 0(动作比平均好),我们希望增大 r_t,但 r_t 超过 1+ε 后梯度截断,不再鼓励。当 A_t < 0(动作比平均差),我们希望减小 r_t,但 r_t 低于 1-ε 后梯度截断。ε 通常取 0.1 或 0.2。

def ppo_loss(log_probs, old_log_probs, advantages, values, returns, clip_eps=0.2, vf_coef=0.5, ent_coef=0.01): """ log_probs: 当前策略下动作的 log 概率, shape (B,) old_log_probs: 采样时策略的 log 概率, shape (B,) advantages: GAE 优势, shape (B,) values: Critic 输出, shape (B,) returns: Critic 回归目标, shape (B,) """ # 重要性采样比率 ratio = torch.exp(log_probs - old_log_probs) # 未裁剪目标 surr1 = ratio * advantages # 裁剪目标 surr2 = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps) * advantages # Actor 损失:取负号因为要最大化 actor_loss = -torch.min(surr1, surr2).mean() # Critic 损失:均方误差 critic_loss = F.mse_loss(values, returns) # 总损失 total_loss = actor_loss + vf_coef * critic_loss return total_loss, actor_loss, critic_loss

逻辑说明:ratio是当前策略和旧策略的概率比。torch.min(surr1, surr2)实现了裁剪逻辑——当 ratio 在 [1-ε, 1+ε] 内,两者相同;当 ratio 超出范围,取较小的那个,梯度被截断。vf_coef控制 Critic 损失的权重,通常 0.5。ent_coef是熵奖励系数,鼓励探索,连续控制里可以设小一点甚至为 0。参数方面,clip_eps是最关键的参数,0.2 是默认值,任务越简单可以越小(0.1),越复杂可以越大(0.3),但超过 0.3 基本就失去约束意义了。

3.3 训练循环:数据收集与多轮更新

PPO 是 on-policy 算法,每批数据收集完后,用这批数据做多轮(epoch)更新,然后丢弃重新采样。典型流程:

def train_ppo(env, model, optimizer, total_steps=1_000_000, steps_per_batch=2048, epochs=10, batch_size=64): obs = env.reset() step_count = 0 while step_count < total_steps: # 1. 收集一批数据 obs_buf, act_buf, logp_buf, rew_buf, val_buf, done_buf = [], [], [], [], [], [] for _ in range(steps_per_batch): obs_tensor = torch.FloatTensor(obs).unsqueeze(0) with torch.no_grad(): action, log_prob, value = model.get_action(obs_tensor) next_obs, reward, done, _ = env.step(action.numpy()[0]) obs_buf.append(obs) act_buf.append(action.numpy()[0]) logp_buf.append(log_prob.item()) rew_buf.append(reward) val_buf.append(value.item()) done_buf.append(done) obs = next_obs if not done else env.reset() step_count += 1 # 2. 计算 GAE with torch.no_grad(): _, _, last_val = model.get_action( torch.FloatTensor(obs).unsqueeze(0)) val_buf.append(last_val.item()) advantages, returns = compute_gae(rew_buf, val_buf, done_buf) # 3. 转成 tensor obs_t = torch.FloatTensor(obs_buf) act_t = torch.FloatTensor(act_buf) old_logp_t = torch.FloatTensor(logp_buf) adv_t = torch.FloatTensor(advantages) ret_t = torch.FloatTensor(returns) # 优势归一化 adv_t = (adv_t - adv_t.mean()) / (adv_t.std() + 1e-8) # 4. 多轮更新 dataset_size = steps_per_batch for _ in range(epochs): indices = torch.randperm(dataset_size) for start in range(0, dataset_size, batch_size): idx = indices[start:start + batch_size] mean, std, values = model(obs_t[idx]) dist = torch.distributions.Normal(mean, std) log_probs = dist.log_prob(act_t[idx]).sum(dim=-1) loss, a_loss, c_loss = ppo_loss( log_probs, old_logp_t[idx], adv_t[idx], values.squeeze(-1), ret_t[idx]) optimizer.zero_grad() loss.backward() # 梯度裁剪 nn.utils.clip_grad_norm_(model.parameters(), 0.5) optimizer.step()

逻辑说明:steps_per_batch是每次采样多少步,2048 是常见值,任务简单可以降到 512,复杂任务可以加到 4096 或 8192。epochs是同一批数据重复训练多少轮,10 是默认值,但要注意——epochs 越多,策略偏离旧策略越远,裁剪机制的压力越大。如果发现训练后期 ratio 经常被裁剪,说明 epochs 太多了。batch_size是 SGD 的 mini-batch 大小,64 是常见起点。优势归一化是标准操作,能显著稳定训练。梯度裁剪 0.5 是经验值,防止偶尔的梯度爆炸。

4. 落地时真正卡住你的参数与踩坑记录

4.1 避坑:PPO 训练中五个高频翻车现场

现象一:回报曲线先升后崩,中间没有任何征兆。原因:学习率太大,策略更新过猛,Critic 跟不上,优势估计变成噪声,Actor 被带偏。解决:把 Actor 和 Critic 的学习率分开设,Actor 用 3e-4,Critic 用 1e-3,Critic 学得快一点能提供更准的优势估计。如果还崩,降到 1e-4。

现象二:训练几百个 episode 后,动作标准差趋近于零,策略完全确定。原因:熵奖励系数太小或为零,策略过早收敛到局部最优。解决:连续控制任务里ent_coef设 0.001 到 0.01,离散任务可以设 0.01 到 0.05。但也不能太大,否则策略一直随机探索,回报上不去。

现象三:GAE 计算时忘记处理 done 标志,回合结束后还在 bootstrap。原因:dones[t]为 True 时,next_value 应该置零,GAE 累积也要断掉。如果忘了,优势估计会包含跨回合的虚假信息。解决:检查compute_gae里对dones的处理,确保回合边界正确截断。

现象四:obs 归一化没做,不同维度的状态量纲差异巨大。原因:机械臂任务里,关节角度是 [-π, π],关节速度可能是 [-10, 10],末端位置可能是 [-1, 1]。不归一化的话,网络会偏向大量纲的维度。解决:用 running mean/std 做在线归一化,或者手动除以每个维度的范围。注意归一化统计量要保存,测试时用同样的统计量。

现象五:clip_eps 设得太大,PPO 退化成普通策略梯度。原因:clip_eps=0.5 时,ratio 在 [0.5, 1.5] 内都不裁剪,约束基本失效。解决:从 0.2 开始调,任务越简单可以越小。如果发现 ratio 的均值偏离 1 很远(比如 1.3),说明裁剪没起作用,策略已经跑偏了。

4.2 参数怎么设:一张表说清楚

参数常用值调整方向影响
clip_eps0.2简单任务降到 0.1,复杂任务升到 0.3越小越保守,越大越激进
gamma0.99短回合降到 0.95,长回合保持 0.99折扣因子,控制视野
lambda0.95短回合降到 0.9,长回合保持 0.95GAE 偏差方差权衡
lr_actor3e-4不稳定时降到 1e-4Actor 学习率
lr_critic1e-3不稳定时降到 3e-4Critic 学习率
steps_per_batch2048简单任务 512,复杂任务 4096每批采样步数
epochs10裁剪频繁时降到 5同一批数据训练轮数
batch_size64显存够可以加到 128mini-batch 大小
vf_coef0.5Critic 损失大时升到 1.0Critic 损失权重
ent_coef0.01策略过早确定时升到 0.05熵奖励系数
max_grad_norm0.5梯度爆炸时降到 0.3梯度裁剪阈值

这张表是起点,不是终点。每个任务的最优参数都不一样,但偏离这张表太远通常意味着别的地方出了问题。

4.3 验证 PPO 实现是否正确:三个检查点

第一个检查点:用极简任务(比如 CartPole)跑一遍,回报应该在 100 个 episode 内达到 200 以上。如果跑不上去,先检查 GAE 计算和 loss 符号。常见错误是 actor_loss 忘了取负号,导致策略往反方向优化。

第二个检查点:打印 ratio 的均值和最大值。训练稳定时,ratio 均值应该在 1.0 附近,最大值不超过 1.5。如果均值偏离 1 很远,说明新旧策略差异太大,需要减小 epochs 或学习率。

第三个检查点:监控 Critic 的 explained variance。它衡量 Critic 对回报的解释程度,计算方式是 1 - Var(returns - values) / Var(returns)。接近 1 说明 Critic 估得准,接近 0 说明 Critic 没学到东西。如果 explained variance 长期低于 0.5,优先调 Critic 的学习率或网络容量。

5. 从推导到落地:一个可复现的调参习惯

推导看懂了,代码跑通了,真正的挑战才刚开始。我自己的习惯是:每次新任务,先用默认参数跑三组不同随机种子,看回报曲线的均值和方差。如果三组曲线差异巨大,说明算法对种子敏感,优先检查初始化和数据归一化。如果三组都跑不上去,先别调 PPO 参数,用随机策略跑一遍,确认环境本身能产生有效奖励信号。很多「PPO 不收敛」的问题,最后发现是环境奖励设计有问题,跟算法无关。

另一个习惯是:把 clip_eps 和 epochs 当成一对参数来调。epochs 越大,同一批数据被反复训练的次数越多,策略偏离旧策略越远,clip_eps 就需要越小来约束。我一般先固定 epochs=10,调 clip_eps 到 ratio 均值稳定在 1.0 附近;然后固定 clip_eps,调 epochs 到 explained variance 不再下降。这两个参数调好之后,再动学习率和网络结构。

最后说一个反直觉的经验:PPO 在连续控制任务里,动作标准差的初始化比学习率更重要。actor_log_std初始化为 0(std=1)在大多数任务里都太大,动作噪声会淹没奖励信号。我一般初始化为 -1(std≈0.37),让策略一开始就相对确定,再通过熵奖励慢慢探索。这个改动在机械臂任务里经常能把收敛速度提升一倍。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询