简介:这份PDF资料围绕强化学习中Actor-Critic框架与PPO算法展开,面向已具备神经网络与梯度下降基础、希望深入理解策略梯度与近端策略优化的学习者。内容从Actor网络选动作、Critic网络评估状态价值入手,推导td_error、优势函数与重要性采样,并完整给出PPO的损失函数与梯度更新步骤,帮助读者打通从策略梯度到PPO的推导链路。资源包共1个PDF文件,约599KB,篇幅紧凑,适合作为算法推导笔记反复查阅。目前已有6054人学习下载,说明其在强化学习入门与面试复习中具有一定参考价值。读者可从中获得Actor-Critic框架图解析、总奖励期望与梯度推导、baseline与合适信用分配两个关键技巧,以及on-policy转off-policy的完整思路,便于对照代码实现与论文阅读,夯实PPO算法的数学基础。
1. 从 td_error 到 clip:一份能跑通的 Actor-Critic 与 PPO 推导笔记
如果你写过 DQN,大概经历过这种别扭:Q 网络输出每个动作的价值,选最大那个,可动作空间一连续就彻底抓瞎。Actor-Critic 就是来解决这件事的——Actor 直接输出动作概率,Critic 只负责打分,两者互相拉扯着往前走。这份资源把 Actor-Critic 的框架图和 PPO 的完整推导串成了一条线,从 td_error 怎么算、baseline 为什么要加,一路推到 importance sampling 和 PPO2 的 clip 目标函数,最后落到一份带经验池、双 Actor 网络、UPDATE_STEPS 循环的算法框架。它适合已经懂点强化学习基础、但被 PPO 那堆公式卡住的从业者,也适合想照着框架图自己搭一版的算法工程师。我拆下来最大的感受是:推导部分没有跳步,框架图的每一步都能对应到代码里的一个张量操作,这点比很多只贴公式的笔记实在。
2. Actor-Critic 框架:td_error 怎么同时喂给两个网络
2.1 两个网络各干什么,为什么不能只留一个
先把角色分清楚。Actor 是一个参数为 θ 的神经网络,输入状态 s,输出每个动作的概率 action_prob,采样出动作 a 丢进环境 Env,拿回奖励 r 和下一个状态 s_。Critic 是另一个网络,输入 s 和 s_,分别输出价值 v 和 v_。注意这里 Critic 被调用了两次,一次算当前状态的价值,一次算下一状态的价值,两者配合 r 才能算出时间差分误差:
td_error = r + γ * v_ - v这个 td_error 就是整个框架的枢纽。Critic 拿它的平方当损失,c_loss = td_error²,反向传播更新自己的参数,本质是在学一个更准的价值估计。Actor 则拿 td_error 当权重,a_loss = -log(action_prob) * td_error,意思是:如果这次动作带来的结果比预期好(td_error 为正),就提高这个动作的概率;比预期差,就压低。两个网络共用同一个误差信号,但优化目标完全不同,这是 Actor-Critic 最容易被忽略的设计点。
为什么不能只留 Actor?因为纯策略梯度需要一整条 trajectory 的总奖励 R(τ) 才能更新,方差大得离谱,训练起来跟抽奖一样。Critic 的作用就是把 R(τ) 换成一个逐步估计的 td_error,相当于给梯度降了方差。反过来只留 Critic 也不行,那就是 DQN 的路子,连续动作空间没法处理。所以两个网络是互补的,缺一个都不成立。
2.2 从策略梯度推到 td_error 权重
原始策略梯度的目标是最大化期望总奖励 R_θ = E[R(τ)],对它求梯度会得到:
∇R_θ = E[R(τ) * ∇log p_θ(τ)]展开 p_θ(τ) 后,环境那部分 p(s_{t+1}|s_t, a_t) 不可控也不可导,能求导的只有 p_θ(a_t|s_t)。所以实际更新时只对动作概率求梯度,R(τ) 作为权重乘上去。这里有两个坑,原文的 Tip 1 和 Tip 2 讲得很清楚。
第一个坑:reward 可能恒为正。如果所有奖励都是正的,那所有被采样到的动作概率都会上升,没被采样到的动作概率相对下降,这显然不合理。解决办法是减一个 baseline b = E[R(τ)],让权重有正有负。
第二个坑:所有动作共用一个权重。一条 trajectory 里 a1 贡献了 +5,a3 贡献了 -2,总奖励 +3,但三个动作拿到的权重都是 +3,这不公平。正确做法是每个动作只用它之后累积的奖励,再乘折扣因子 γ:
weight_t = Σ γ^(t'-t) * r_t' (t' 从 t 到 T)把 baseline 和这个累积折扣奖励合起来,就得到了优势函数 A_θ(s_t, a_t),表示在 s_t 选 a_t 比平均情况好多少。这个值交给 Critic 估计,正好接回上一节的 td_error。所以 td_error 不是拍脑袋来的,它是优势函数的一个单步估计版本。
2.3 一份最小可跑的 Actor-Critic 更新逻辑
下面这段是框架图 (1)~(5) 的直接翻译,用 PyTorch 写,动作空间按离散处理,连续空间把 Categorical 换成 Normal 即可:
import torch import torch.nn as nn import torch.nn.functional as F class Actor(nn.Module): def __init__(self, state_dim, action_dim): super().__init__() self.fc = nn.Sequential(nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, action_dim)) def forward(self, s): return F.softmax(self.fc(s), dim=-1) # 输出动作概率 class Critic(nn.Module): def __init__(self, state_dim): super().__init__() self.fc = nn.Sequential(nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, 1)) def forward(self, s): return self.fc(s) # 输出状态价值 v actor, critic = Actor(4, 2), Critic(4) opt_a = torch.optim.Adam(actor.parameters(), lr=1e-3) opt_c = torch.optim.Adam(critic.parameters(), lr=1e-3) gamma = 0.99 s = env.reset() for step in range(1000): prob = actor(torch.FloatTensor(s)) dist = torch.distributions.Categorical(prob) a = dist.sample() # 采样动作 s_, r, done = env.step(a.item()) v = critic(torch.FloatTensor(s)) v_ = critic(torch.FloatTensor(s_)).detach() # 目标值不回传梯度 td_error = r + gamma * v_ - v # 时间差分误差 c_loss = td_error.pow(2).mean() # Critic 损失 a_loss = -torch.log(prob[a]) * td_error.detach() # Actor 损失 opt_c.zero_grad(); c_loss.backward(); opt_c.step() opt_a.zero_grad(); a_loss.backward(); opt_a.step() s = s_ if not done else env.reset()几个参数要盯住。gamma 控制折扣,0.99 是常见起点,任务越长越接近 1。v_ 后面必须 detach,否则 Critic 的梯度会顺着 td_error 流进 Actor 的更新里,两个网络互相污染,训练直接崩。a_loss 里的 td_error 也要 detach,道理一样,Actor 只借用这个数值当权重,不参与 Critic 的优化。学习率上 Critic 通常可以比 Actor 稍大一点,因为它学的是回归任务,收敛要求没那么精细。
提示:这段代码是 on-policy 的,每更新一次就得重新采样。想复用数据,就得往下看 importance sampling 和 PPO。
3. 从 on-policy 到 off-policy:importance sampling 与 PPO 的动机
3.1 为什么 PPO 非要绕 importance sampling 这一圈
上面那段代码有个致命限制:数据用完就扔。因为梯度期望是在当前策略 π_θ 下采样的,θ 一更新,旧数据对应的分布就变了,再用就是错的。这就是 on-policy,样本效率极低,环境交互一次只能更新一次网络。
PPO 的目标是让 π_θ' 去和环境互动收集数据,然后拿这批数据反复训练 π_θ 很多次。但数据来自 π_θ',期望却要在 π_θ 下算,分布对不上。importance sampling 就是干这个的:
E_{x~p}[f(x)] = E_{x~q}[f(x) * p(x)/q(x)]把 p(x)/q(x) 这个比值乘上去,就能用 q 分布采的样本估计 p 分布下的期望。套到策略梯度上,梯度变成:
∇R_θ = E_{(s,a)~π_θ'} [ p_θ(a|s)/p_θ'(a|s) * A_θ'(s,a) * ∇log p_θ(a|s) ]状态那部分的比值 p_θ(s)/p_θ'(s) 因为环境动态一样,通常近似约掉,只留动作概率的比值。这个比值就是后面 PPO2 里的 ratio。
但 importance sampling 有个硬伤:两个分布差得越远,方差越大。原文专门推了方差公式,结论是 sample 次数少或者 p、q 差距大时,方差会爆炸。所以不能无限次复用旧数据,得有个机制把 π_θ 和 π_θ' 的距离卡住。这就是 PPO 和 PPO2 出场的原因。
3.2 PPO 的 KL 惩罚与 PPO2 的 clip 到底差在哪
PPO 的第一版思路很直接,在目标函数里加一个 KL 散度惩罚项:
J_PPO(θ) = J_θk(θ) - β * KL(θ, θk)KL 衡量的是两个策略在动作分布上的距离,不是网络参数的距离,这点很关键。参数差一点可能行为差不多,参数差不多也可能行为差很远,所以必须看 action 层面的差异。β 是自适应调整的:如果 KL 超过 KL_max 就增大 β 把约束收紧,低于 KL_min 就减小 β 放松约束。
PPO2 换了个更省事的做法,直接把 ratio 裁掉:
J_PPO2(θ) = Σ min{ ratio * A, clip(ratio, 1-ε, 1+ε) * A }ratio = p_θ(a|s) / p_θ'(a|s)。当 ratio 超出 [1-ε, 1+ε] 区间时,clip 把它拉回边界,再取 min 保证目标函数是保守估计。ε 一般取 0.1 或 0.2。相比 KL 惩罚,clip 不需要额外算 KL,也不用调 β,实现简单且效果稳定,所以现在说 PPO 基本默认指 PPO2。
3.3 把框架图的八步翻译成可执行流程
原文的 PPO 框架图有八步,我按经验池的节奏重新梳理一遍,方便对照代码:
| 步骤 | 操作 | 关键张量 |
|---|---|---|
| 1 | Actor-New 采样动作,存 [s, a, r, s_] 入池 | 经验池满才继续 |
| 2 | 最后一个 s_ 输入 Critic 得 v_,算 R[t] = r[t] + γ*v_ | 集合 R |
| 3 | 所有 s 输入 Critic 得 v,算 adv = Q - v | 优势集合 |
| 4 | c_loss = MSE(adv),更新 Critic | 均方误差 |
| 5 | s 分别输入 Actor-New 和 Actor-Old,得 prob1、prob2 | ratio = prob2/prob1 |
| 6 | a_loss = Σ min{ratio*adv, clip(ratio,1-ε,1+ε)*adv},更新 Actor-New | 最大化目标 |
| 7 | 循环 5~6 共 UPDATE_STEPS 次,把 Actor-New 参数赋给 Actor-Old | 同步 |
| 8 | 回到步骤 1 | 外层循环 |
这里有两个容易写错的地方。第一,步骤 2 里 R[t] 用的是经验池最后一个 s_ 的价值,不是每一步都重新算,这是 GAE 截断版本的简化处理。第二,步骤 7 的参数同步必须是硬拷贝,不是共享引用,否则 Actor-Old 会跟着 Actor-New 一起变,ratio 永远等于 1,clip 就失效了。
# 参数同步,必须深拷贝 actor_old.load_state_dict(actor_new.state_dict()) # 优势函数计算,adv 需要标准化 adv = (adv - adv.mean()) / (adv.std() + 1e-8) # PPO2 目标函数 ratio = torch.exp(log_prob_new - log_prob_old) surr1 = ratio * adv surr2 = torch.clamp(ratio, 1 - eps, 1 + eps) * adv a_loss = -torch.min(surr1, surr2).mean() # 取负号做最小化adv 标准化这步原文没写,但实操里几乎必加。优势值量纲不稳定,不标准化的话 ratio 和 adv 相乘后梯度忽大忽小,训练曲线跟心电图一样。log_prob 相减再 exp 比直接除概率数值更稳,避免极小概率相除溢出。
4. 避坑与排查:PPO 训练不收敛的五个血泪现场
4.1 现象:奖励曲线前期涨后期崩,KL 突然飙升
原因:UPDATE_STEPS 设太大,同一批数据反复训练,π_θ 和 π_θ' 越走越远,importance sampling 的方差爆炸,clip 也拦不住。这是最经典的翻车方式。
解决:把 UPDATE_STEPS 从 10 降到 3~5,或者监控 KL,一旦超过 0.02 就提前中断这批数据的训练。clip 的 ε 也可以从 0.2 降到 0.1 收紧约束。
4.2 现象:Critic 损失下降但 Actor 完全不学,动作概率趋于均匀
原因:td_error 或 adv 没有 detach,Critic 的梯度污染了 Actor;或者 adv 恒为正,Actor 对所有动作一视同仁地提升概率。
解决:检查 a_loss 里 td_error 和 adv 是否 detach。adv 一定要做标准化,让它有正有负。如果奖励本身恒正,减 baseline 这步不能省。
4.3 现象:ratio 始终等于 1,clip 形同虚设
原因:Actor-Old 和 Actor-New 共享了参数,或者同步时机写错,在更新前就同步了。
解决:确认 Actor-Old 是独立网络,参数同步只在 UPDATE_STEPS 循环结束后执行一次。可以在训练时打印 ratio 的均值和最大值,正常应该在 1 附近波动,偶尔触及 1±ε。
4.4 现象:经验池装满后训练极慢,显存爆掉
原因:经验池容量设太大,或者每个 step 都存了完整的 s 和 s_ 两份状态,状态维度高时显存吃不消。
解决:经验池容量按任务定,一般几千到几万步。s_ 可以只存最后一个,中间步骤的 s_ 就是下一步的 s,不用重复存。状态是图像时考虑存 uint8 而不是 float32。
4.5 现象:不同随机种子结果差异巨大,有时能收敛有时完全不动
原因:PPO 对初始化敏感,Actor 最后一层如果初始化方差过大,初始策略太随机,采样效率低;Critic 初始价值偏差大,adv 噪声大。
解决:Actor 输出层用正交初始化,增益设小一点(0.01)。Critic 输出层也用正交初始化。固定随机种子做对比实验,确认是算法问题还是种子问题。
注意:这五条里第 1 条和第 3 条最常出现,建议先把这两个排查完再看别的。
5. 进阶技巧:GAE 截断与 ratio 监控的实操习惯
原文提到 OpenAI 的 PPO 论文用了截断版本的 GAE 来权衡偏差和方差,公式是:
Â_t = δ_t + (γλ)δ_{t+1} + ... + (γλ)^{T-t+1}δ_{T-1} 其中 δ_t = r_t + γV(s_{t+1}) - V(s_t)λ 是 GAE 的衰减系数,取 0 时退化成单步 td_error,偏差大方差小;取 1 时变成蒙特卡洛估计,方差大偏差小。实操里 λ 一般取 0.95,配合 γ=0.99,这是被大量实验验证过的甜点区。实现时从后往前递推:
def compute_gae(rewards, values, next_value, gamma=0.99, lam=0.95): gae = 0 adv = [] values = values + [next_value] for t in reversed(range(len(rewards))): delta = rewards[t] + gamma * values[t+1] - values[t] gae = delta + gamma * lam * gae adv.insert(0, gae) return adv这段递推的关键是 gae 变量在循环里累积,每一步都把后面所有 δ 按 (γλ) 的幂次加权进来。values 列表末尾要补一个 next_value,就是经验池最后一个 s_ 的价值。返回的 adv 顺序要和经验池对齐,insert(0, ...) 保证从早到晚。
除了 GAE,我强烈建议加一个 ratio 监控。具体做法是在 UPDATE_STEPS 循环里记录每步的 ratio 均值和被 clip 的比例:
clip_frac = (torch.abs(ratio - 1) > eps).float().mean() print(f"ratio_mean={ratio.mean():.3f} clip_frac={clip_frac:.3f}")clip_frac 健康值在 0.1~0.3 之间。如果长期接近 0,说明约束太松,数据复用没起到作用;如果长期高于 0.5,说明新旧策略差太多,得降 UPDATE_STEPS 或学习率。这个指标比看奖励曲线更早发现问题,奖励还没崩的时候 clip_frac 往往已经异常了。
还有一个习惯:把 Actor 和 Critic 的学习率分开调。Critic 学的是价值回归,可以用 3e-4;Actor 学的是策略,用 1e-4 甚至更低。两者绑一起调的时候,经常出现 Critic 收敛了 Actor 还在乱走的情况。分开之后,先让 Critic 把价值估准,再让 Actor 慢慢跟上,训练稳定性会好很多。
从那以后我每次搭 PPO 都强制走一遍:先跑 100 步看 ratio 和 clip_frac 是否正常,再放开训练;adv 标准化和 detach 检查写成断言放在代码里,不通过直接报错。这套流程帮我省了无数次通宵调参。希望帮到你。
本文还有配套的精品资源,点击获取