简介这份PDF资料聚焦强化学习中Actor-Critic框架与PPO算法的原理推导面向已具备神经网络与梯度下降基础、希望深入理解策略梯度方法的强化学习学习者与算法工程师。内容从Actor网络选择动作、Critic网络评估状态价值的分工讲起逐步推导td_error、优势函数与重要性采样并完整给出PPO的损失函数与梯度更新公式帮助读者打通从策略梯度到PPO的推导链路。资源为单个PDF文件压缩包约599KB篇幅紧凑、公式推导完整适合作为强化学习入门到进阶的公式速查与推导笔记。目前已有6054人学习下载读者可借此掌握Actor-Critic的交互机制、baseline与credit分配两个关键技巧以及on-policy到off-policy的转换思路为复现PPO算法打下理论基础。1. Actor-Critic 与 PPO为什么大多数人的策略梯度跑不出效果如果你写过 REINFORCE大概率经历过这种崩溃训练曲线像心电图方差大到怀疑人生同一个随机种子跑两次结果能差出一个数量级。这不是你的实现有问题而是纯策略梯度方法的先天缺陷——用整条轨迹的回报去估计梯度方差随回合长度线性增长。Actor-Critic 就是冲着这个痛点来的用一个 Critic 网络估计状态价值把「整条轨迹的回报」换成「单步 TD 误差」方差立刻降下来。而 PPOProximal Policy Optimization在 Actor-Critic 基础上又加了一道保险——限制新旧策略的 KL 散度防止一步更新把策略带偏到无法恢复的区域。这套组合现在是深度强化学习里最稳的基线之一从机械臂控制到交通信号灯调度只要动作空间连续、环境有随机性PPO 基本都是第一个该试的方案。这篇笔记不打算复述教科书而是把 Actor-Critic 的结构、PPO 的目标函数推导、以及从零写一个能跑通的 PyTorch 实现串起来中间穿插我踩过的坑和参数设置经验。适合已经了解 MDP 基本概念、写过至少一个 RL 环境交互循环、但被策略梯度方差折磨过的从业者。2. Actor-Critic 的网络结构两个头怎么分、梯度怎么流2.1 为什么需要两个网络以及它们各自在优化什么Actor-Critic 的核心思想是把策略和值函数拆成两个模块。Actor 负责输出动作分布连续动作通常是高斯分布的均值和标准差离散动作是 softmax 概率Critic 负责估计当前状态的价值 V(s) 或状态-动作价值 Q(s,a)。训练时 Critic 用 TD 误差更新自己Actor 用 Critic 给出的优势估计来更新策略。这里有一个容易混淆的点Critic 的损失函数是均方 TD 误差Actor 的损失函数是策略梯度。两者共享输入但不共享损失。常见做法是共享底层特征提取层比如几层 MLP 或 CNN然后在最后一层分叉出两个头。共享层的好处是特征复用坏处是两个任务的梯度可能互相干扰。我的经验是状态维度低于 50 时共享不共享差别不大状态是图像或高维向量时共享底层能明显加速收敛但需要把 Critic 的学习率设得比 Actor 稍大一点让它先跟上。import torch import torch.nn as nn import torch.nn.functional as F class ActorCritic(nn.Module): def __init__(self, state_dim, action_dim, hidden256): 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) self.actor_log_std nn.Parameter(torch.zeros(action_dim)) # 可学习的 log 标准差 # Critic 头输出状态价值 self.critic nn.Linear(hidden, 1) def forward(self, state): feat self.shared(state) mean self.actor_mean(feat) std self.actor_log_std.exp().expand_as(mean) value self.critic(feat) return mean, std, value这段代码里actor_log_std是一个独立可学习参数不是网络输出。这样做的好处是训练稳定——如果让网络直接输出标准差初期容易输出接近零的值导致分布坍缩。用 log 标准差再取 exp能保证标准差恒正且梯度不会爆炸。Tanh激活在 RL 里比 ReLU 更常用因为状态归一化后值域在 [-1,1] 附近Tanh 的饱和区反而能起到隐式正则作用。2.2 优势函数GAE 是怎么把偏差和方差捏在一起的Actor 更新时需要知道「这个动作比平均水平好多少」也就是优势 A(s,a)。最朴素的做法是用回报减去基线但回报的方差大。GAEGeneralized Advantage Estimation用 TD 残差的指数加权和来估计优势引入一个参数 λ 控制偏差-方差权衡。具体计算方式先算每一步的 TD 残差 δ_t r_t γV(s_{t1}) - V(s_t)然后从后往前累加A_t δ_t γλδ_{t1} (γλ)²δ_{t2} …。λ0 时退化成单步 TD 误差偏差大方差小λ1 时接近蒙特卡洛回报方差大偏差小。实践中 λ0.95 是默认值γ0.99 适用于大多数回合长度在几百步以内的任务。def compute_gae(rewards, values, dones, gamma0.99, lam0.95): rewards: (T,) 每一步的奖励 values: (T1,) 包含最后一步的 bootstrap 值 dones: (T,) 是否终止 返回: advantages (T,), returns (T,) T len(rewards) advantages torch.zeros(T) last_gae 0.0 for t in reversed(range(T)): # 非终止步才加 bootstrap终止步下一状态价值为 0 next_value values[t 1] * (1 - dones[t]) delta rewards[t] gamma * next_value - values[t] last_gae delta gamma * lam * (1 - dones[t]) * last_gae advantages[t] last_gae returns advantages values[:-1] return advantages, returns注意dones的处理如果这一步是终止步下一状态的价值不应该被 bootstrap否则会把下一回合的信息泄漏进来。这个细节在连续控制任务里尤其重要因为很多环境比如 MuJoCo的终止条件是摔倒或超时超时终止和真正终止的 bootstrap 处理方式不同。超时终止时理论上应该 bootstrap但大多数实现为了简单统一按终止处理会带来轻微偏差通常可以接受。3. PPO 的目标函数推导从策略梯度到裁剪替代目标3.1 策略梯度的原始形式和重要性采样策略梯度的基本形式是 ∇J(θ) E[∇log π_θ(a|s) · A(s,a)]。这个估计量要求用当前策略 π_θ 采样。但 PPO 想做的是用旧策略 π_θ_old 采一批数据然后对这批数据做多次梯度更新。这就引入了分布不匹配的问题——旧策略采的动作分布和新策略不一样。重要性采样是标准解法E_{π_θ}[f] E_{π_θ_old}[π_θ/π_θ_old · f]。把这个套进策略梯度得到替代目标 L(θ) E[π_θ(a|s)/π_θ_old(a|s) · A]。理论上这个目标对 θ 的梯度等于原始策略梯度但前提是重要性权重不能偏离 1 太远否则方差会爆炸。PPO 的做法不是直接约束重要性权重而是约束目标函数本身。具体来说定义概率比 r_t(θ) π_θ(a_t|s_t)/π_θ_old(a_t|s_t)PPO 的裁剪目标是L_clip(θ) E[min(r_t(θ)A_t, clip(r_t(θ), 1-ε, 1ε)A_t)]这个式子的含义当优势为正时如果 r_t 超过 1ε梯度被截断不再鼓励继续增大该动作概率当优势为负时如果 r_t 低于 1-ε梯度同样被截断。ε 通常取 0.1 或 0.2控制每次更新策略能偏离旧策略多远。3.2 裁剪目标为什么能替代 KL 约束TRPO 的做法是显式约束新旧策略的 KL 散度不超过 δ用共轭梯度加线搜索求解计算量大且实现复杂。PPO 的裁剪目标可以看作 KL 约束的一阶近似——当 r_t 偏离 1 时目标函数变成线性的梯度不再推动策略进一步偏离。这相当于一个软约束虽然不保证严格满足 KL 上限但实现简单且效果足够好。实际训练中我一般会监控两个指标clip fraction被裁剪的样本比例和近似 KL。clip fraction 持续高于 0.3 说明 ε 太小或学习率太大策略每次更新步子迈得太猛近似 KL 超过 0.02 时通常需要提前停止当前 batch 的更新。这两个指标比看总损失有用得多因为 PPO 的总损失包含 Critic 损失和熵正则项数值本身不直接反映策略更新质量。def ppo_loss(new_log_probs, old_log_probs, advantages, values, returns, entropy, clip_eps0.2, vf_coef0.5, ent_coef0.01): new_log_probs: 当前策略下动作的 log 概率 (B,) old_log_probs: 采样时策略的 log 概率 (B,) advantages: GAE 优势 (B,)通常做标准化 values: Critic 当前输出 (B,) returns: GAE 计算的回报 (B,) entropy: 策略熵 (B,) ratio torch.exp(new_log_probs - old_log_probs) surr1 ratio * advantages surr2 torch.clamp(ratio, 1 - clip_eps, 1 clip_eps) * advantages actor_loss -torch.min(surr1, surr2).mean() critic_loss F.mse_loss(values, returns) entropy_loss -entropy.mean() total_loss actor_loss vf_coef * critic_loss ent_coef * entropy_loss return total_loss, actor_loss, critic_lossadvantages在传入前需要做标准化减均值除标准差这一步对训练稳定性影响很大。不做标准化时如果某个 batch 的优势全是正的策略会同时提升所有动作的概率导致熵急剧下降。标准化后优势有正有负策略知道该提升哪些、压制哪些。vf_coef控制 Critic 损失权重0.5 是常用值ent_coef是熵正则系数0.01 适用于大多数连续控制任务离散动作任务可以设到 0.02 到 0.05 鼓励探索。4. 从零实现 PPO 训练循环数据收集、更新、日志4.1 采样阶段怎么存、存什么、什么时候停PPO 是 on-policy 算法每轮更新必须用当前策略重新采样。典型流程是用当前策略跑 N 个环境步把 (state, action, log_prob, reward, done, value) 存进缓冲区然后对这批数据做 K 轮 epoch 的小批量更新更新完后清空缓冲区重新采样。采样步数 N 的设置很关键。N 太小每批数据不够梯度估计方差大N 太大旧策略数据和新策略差距拉大重要性权重偏离严重。我的经验是 N 取 2048 到 8192 之间具体看环境单步耗时。如果环境仿真一步要 10msN4096 意味着每轮采样要 40 秒训练效率太低这时候应该用并行环境。如果环境很快比如 CartPoleN 可以设到 8192 甚至更大。class RolloutBuffer: def __init__(self): self.states [] self.actions [] self.log_probs [] self.rewards [] self.dones [] self.values [] def store(self, state, action, log_prob, reward, done, value): self.states.append(state) self.actions.append(action) self.log_probs.append(log_prob) self.rewards.append(reward) self.dones.append(done) self.values.append(value) def get_batches(self, batch_size): 将缓冲区数据打乱后按 batch_size 切分 T len(self.states) indices torch.randperm(T) for start in range(0, T, batch_size): idx indices[start:start batch_size] yield ( torch.stack([self.states[i] for i in idx]), torch.stack([self.actions[i] for i in idx]), torch.stack([self.log_probs[i] for i in idx]), torch.stack([self.rewards[i] for i in idx]), torch.stack([self.dones[i] for i in idx]), torch.stack([self.values[i] for i in idx]), )缓冲区里存的是张量而不是 numpy 数组这样后续计算不用反复转换。get_batches每次调用返回一个生成器打乱顺序后按 batch_size 切分。注意values存的是采样时 Critic 的输出计算 GAE 时还需要最后一步的 bootstrap 值这个值在采样结束后单独用 Critic 算一次。4.2 更新阶段epoch 数、batch size、学习率怎么配PPO 的更新阶段有几个关键超参数每批数据的更新 epoch 数通常 10、小批量大小通常 64 或 128、学习率通常 3e-4。这三个参数互相影响不能独立调。epoch 数决定同一批数据被重复利用多少次。epoch 越大样本效率越高但策略偏离旧策略越远裁剪机制会频繁触发。我的做法是先设 epoch10观察 clip fraction如果超过 0.25 就降到 5 或 8。batch size 影响梯度估计的噪声太小比如 16会导致更新方向抖动太大比如 512会降低随机性带来的正则效果。64 到 256 之间是比较安全的范围。学习率方面PPO 对学习率比监督学习敏感得多。3e-4 是 Adam 优化器的常用起点但如果发现训练初期策略熵下降太快比如 100 轮内熵从 1.0 掉到 0.1说明学习率太大应该降到 1e-4 或 5e-5。另外可以加一个学习率线性衰减从 3e-4 降到 1e-5后期更新步子变小有助于收敛到更优的策略。def train_step(model, optimizer, buffer, clip_eps0.2, epochs10, batch_size64): # 先计算 GAE with torch.no_grad(): last_value model(buffer.states[-1].unsqueeze(0))[2] values torch.stack(buffer.values [last_value.squeeze()]) rewards torch.stack(buffer.rewards) dones torch.stack(buffer.dones).float() advantages, returns compute_gae(rewards, values, dones) advantages (advantages - advantages.mean()) / (advantages.std() 1e-8) # 多轮 epoch 更新 for _ in range(epochs): for states, actions, old_log_probs, _, _, _ in buffer.get_batches(batch_size): mean, std, values_pred model(states) dist torch.distributions.Normal(mean, std) new_log_probs dist.log_prob(actions).sum(dim-1) entropy dist.entropy().sum(dim-1) # 找到对应索引的优势和回报 # 这里简化处理实际实现需要维护索引映射 idx ... # 根据 batch 中的状态找到对应的 advantage 和 return loss, a_loss, c_loss ppo_loss( new_log_probs, old_log_probs, advantages[idx], values_pred.squeeze(), returns[idx], entropy, clip_epsclip_eps ) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm0.5) optimizer.step()梯度裁剪clip_grad_norm_的 max_norm 设 0.5 是 PPO 的常见做法比监督学习里的 1.0 更严格。原因是策略梯度的方差本身就大偶尔会出现梯度爆炸0.5 能在不破坏正常更新的前提下兜住极端情况。如果发现训练过程中梯度范数经常被裁剪可以打印 grad_norm 观察说明学习率太大或者优势估计有问题。5. 避坑与排查PPO 训练中五个高频翻车现场5.1 现象策略熵在 50 轮内从 1.0 掉到 0.05动作几乎确定原因通常是熵正则系数太小或学习率太大。熵正则项ent_coef * entropy在损失里是负的因为我们要最大化熵而优化器最小化损失如果 ent_coef 只有 0.001熵的梯度信号被 Actor 损失淹没策略会迅速收敛到局部最优。另一个可能是优势标准化后所有值都偏大导致策略更新步子过猛。解决办法先把 ent_coef 提到 0.02 到 0.05观察熵下降速度。如果仍然过快把学习率降到 1e-4。还有一个隐蔽原因是动作分布的标准差初始化太小actor_log_std初始化为 -1 而不是 0这样初始标准差约 0.37探索范围更合理。5.2 现象Critic 损失不降反升价值估计越来越大这是典型的 bootstrap 发散。检查compute_gae里终止步的处理如果 doneTrue 时仍然加了gamma * next_value价值会被无限传播到未来导致估计值爆炸。另一个可能是奖励尺度太大比如环境返回的奖励在 1000 量级Critic 需要拟合很大的值梯度不稳定。解决办法确认 done 处理正确后对奖励做缩放把奖励除以一个常数比如 running estimate 的标准差让奖励大致在 [-10, 10] 范围内。Critic 最后一层可以加一个tanh或不做激活但初始化权重为小值比如正交初始化 gain1.0。5.3 现象clip fraction 持续高于 0.4策略更新被大量截断clip fraction 高说明新旧策略概率比经常超出 [0.8, 1.2] 范围。原因可能是 epoch 太多同一批数据反复更新、学习率太大、或者旧策略的 log_prob 计算有误。检查 log_prob 是否在采样时用torch.no_grad()计算如果采样时也带了梯度旧策略的概率会随参数更新而变化重要性采样就失效了。解决办法把 epoch 降到 5学习率降到 1e-4确认采样阶段所有计算都在torch.no_grad()下进行。如果用的是连续动作检查 log_prob 是否对所有动作维度求和漏掉维度会导致概率比计算错误。5.4 现象训练前期奖励上升中期突然崩溃到随机水平这种「先升后崩」在 PPO 里很常见通常是 Critic 过拟合了早期数据价值估计偏差变大导致优势符号错误Actor 被带偏。另一个可能是环境有随机性某次采样到极端状态梯度爆炸把策略推到了坏区域。解决办法在 Critic 损失里加权重衰减weight_decay1e-4或者对 Critic 用单独的学习率比 Actor 小一个数量级。更直接的办法是保存训练过程中最好的模型而不是用最后一个 epoch 的模型。PPO 不像监督学习那样单调收敛后期性能回退是正常现象。5.5 现象同一份代码换个随机种子结果完全不同RL 对随机种子极度敏感这是算法特性不是 bug。但如果差异大到「一个收敛一个不收敛」说明超参数在稳定性边界上。常见原因是初始化范围太大、学习率太高、或者环境本身有高方差。解决办法固定所有随机种子torch、numpy、环境跑 3 到 5 个种子取均值和置信区间。如果某个种子完全失败检查初始化Actor 最后一层权重用正交初始化 gain0.01Critic 最后一层 gain1.0共享层用默认的 Xavier 初始化。这些初始化技巧能把种子间方差降低一个数量级。6. 进阶技巧用 KL 早停和自适应学习率把 PPO 调稳PPO 的裁剪机制是硬约束但实际训练中更优雅的做法是监控近似 KL 并动态调整。具体来说每轮更新后计算新旧策略的平均 KL如果 KL 1.5 * target_kl通常 target_kl0.01就提前终止当前 batch 的剩余 epoch如果 KL 0.5 * target_kl说明更新太保守可以适当增大学习率。这种自适应策略在 TRL 和 Stable-Baselines3 里都有实现效果比固定学习率稳定得多。def adaptive_kl_update(model, optimizer, buffer, target_kl0.01, lr3e-4): 带 KL 早停的 PPO 更新 with torch.no_grad(): old_mean, old_std, _ model(buffer.states) old_dist torch.distributions.Normal(old_mean, old_std) old_log_probs old_dist.log_prob(buffer.actions).sum(dim-1) for epoch in range(10): # 计算当前策略的 log_prob mean, std, _ model(buffer.states) dist torch.distributions.Normal(mean, std) new_log_probs dist.log_prob(buffer.actions).sum(dim-1) # 近似 KLE[old_log_prob - new_log_prob] approx_kl (old_log_probs - new_log_probs).mean().item() if approx_kl 1.5 * target_kl: print(fKL 早停于 epoch {epoch}, KL{approx_kl:.4f}) break # 正常 PPO 更新... # 如果 KL 太小可以在这里增大学习率 if approx_kl 0.5 * target_kl: for param_group in optimizer.param_groups: param_group[lr] min(param_group[lr] * 1.1, 1e-3)这段代码里近似 KL 用的是old_log_probs - new_log_probs的均值这是 KL 的一阶近似比精确计算 KL 散度快得多且足够用。注意old_log_probs是在更新前用torch.no_grad()算的不能和采样时的 log_prob 混用——采样时的 log_prob 是逐步计算的而这里是对整个 buffer 一次性计算数值上可能有微小差异但用于 KL 监控足够。另一个实用技巧是价值函数裁剪。PPO 原论文里对 Critic 也做了裁剪把价值更新限制在旧值附近防止 Critic 单步变化太大。具体做法是value_clipped old_values clamp(values - old_values, -clip_eps, clip_eps)然后取max((values - returns)^2, (value_clipped - returns)^2)作为 Critic 损失。这个技巧在奖励尺度大的任务里能明显提升稳定性但会增加一点计算量。最后说一个我自己的习惯每次跑新环境先用小学习率1e-4和少 epoch5跑 100 轮看奖励曲线是否单调上升。如果上升但慢再逐步加大学习率和 epoch如果震荡先检查 GAE 的 λ 和 γ 是否匹配任务的时间尺度。γ0.99 对应约 100 步的有效视野如果任务回合长度是 1000 步γ 应该设到 0.995 或 0.999。这个匹配关系比调网络结构重要得多。希望帮到你。本文还有配套的精品资源点击获取