从td_error到clip:Actor-Critic与PPO推导及避坑指南
发布时间:2026/9/30 7:51:10来源:尧图网络
简介这份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_{t1}|s_t, a_t) 不可控也不可导能求导的只有 p_θ(a_t|s_t)。所以实际更新时只对动作概率求梯度R(τ) 作为权重乘上去。这里有两个坑原文的 Tip 1 和 Tip 2 讲得很清楚。第一个坑reward 可能恒为正。如果所有奖励都是正的那所有被采样到的动作概率都会上升没被采样到的动作概率相对下降这显然不合理。解决办法是减一个 baseline b E[R(τ)]让权重有正有负。第二个坑所有动作共用一个权重。一条 trajectory 里 a1 贡献了 5a3 贡献了 -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(), lr1e-3) opt_c torch.optim.Adam(critic.parameters(), lr1e-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-policyimportance 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 框架图有八步我按经验池的节奏重新梳理一遍方便对照代码步骤操作关键张量1Actor-New 采样动作存 [s, a, r, s_] 入池经验池满才继续2最后一个 s_ 输入 Critic 得 v_算 R[t] r[t] γ*v_集合 R3所有 s 输入 Critic 得 v算 adv Q - v优势集合4c_loss MSE(adv)更新 Critic均方误差5s 分别输入 Actor-New 和 Actor-Old得 prob1、prob2ratio prob2/prob16a_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 永远等于 1clip 就失效了。# 参数同步必须深拷贝 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 没有 detachCritic 的梯度污染了 Actor或者 adv 恒为正Actor 对所有动作一视同仁地提升概率。解决检查 a_loss 里 td_error 和 adv 是否 detach。adv 一定要做标准化让它有正有负。如果奖励本身恒正减 baseline 这步不能省。4.3 现象ratio 始终等于 1clip 形同虚设原因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 (γλ)δ_{t1} ... (γλ)^{T-t1}δ_{T-1} 其中 δ_t r_t γV(s_{t1}) - V(s_t)λ 是 GAE 的衰减系数取 0 时退化成单步 td_error偏差大方差小取 1 时变成蒙特卡洛估计方差大偏差小。实操里 λ 一般取 0.95配合 γ0.99这是被大量实验验证过的甜点区。实现时从后往前递推def compute_gae(rewards, values, next_value, gamma0.99, lam0.95): gae 0 adv [] values values [next_value] for t in reversed(range(len(rewards))): delta rewards[t] gamma * values[t1] - 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(fratio_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-4Actor 学的是策略用 1e-4 甚至更低。两者绑一起调的时候经常出现 Critic 收敛了 Actor 还在乱走的情况。分开之后先让 Critic 把价值估准再让 Actor 慢慢跟上训练稳定性会好很多。从那以后我每次搭 PPO 都强制走一遍先跑 100 步看 ratio 和 clip_frac 是否正常再放开训练adv 标准化和 detach 检查写成断言放在代码里不通过直接报错。这套流程帮我省了无数次通宵调参。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网