
做强化学习的人迟早会和策略梯度Policy Gradient打交道。无论是玩CartPole、训练机器人走路还是调大语言模型的RLHF核心引擎基本都是它。很多教程把策略梯度讲得云里雾里要么堆公式要么直接甩代码两头都不挨着。这篇算是我的个人笔记整理把策略梯度的来龙去脉、几种主流算法的设计逻辑、实现时的坑和调参心得都串一遍尽量做到看完能理解、能上手、能排错。适合刚入门RL的读者也适合那些已经跑过PPO但总觉得哪里没吃透的朋友。先说清楚策略梯度是什么。强化学习的任务是找一个策略policy让智能体在和环境交互的过程中拿到尽可能多的累计回报。传统值函数方法先估计Q值或V值再根据值函数推策略策略梯度则直接对策略本身做参数化沿着“让好动作概率变大、坏动作概率变小”的方向更新参数。这个思路看起来简单但涉及到目标函数怎么设计、梯度怎么估计、方差怎么控制等问题稍不留神就会训练崩掉。所以这篇文章不光是讲公式更想把公式背后的直觉、实现时的取舍、以及我踩过的那些坑一起讲清楚。1. 策略梯度到底在做什么1.1 从“先估值再决策”到“直接给动作打分”最早接触强化学习时大家一般先学Q-learning维护一张Q表或Q网络算出每个状态下每个动作的预期回报然后选Q值最大的动作。这种“间接”方法在离散动作空间表现不错但遇到连续控制比如机器人关节力矩、高维动作空间比如语言模型输出token序列时就会非常尴尬。Q-learning要找出所有动作的max连续空间里根本无法穷举策略梯度则天然适合连续动作——策略网络直接输出动作分布例如高斯分布的均值和方差采样即得动作不需要求max。所以策略梯度的第一个优点它把策略本身作为优化对象搜索空间就是参数空间而不是动作空间。第二个优点是随机策略的天然探索机制策略分布自带随机性不会像ε-greedy那样需要额外维护探索逻辑。第三个优点是当回报函数复杂甚至不可导时策略梯度可以用采样估计梯度不用对环境求导——这一点在RLHF这种场景里特别关键人类偏好反馈根本无法写成平滑函数但我们可以通过策略梯度去优化它。1.2 目标函数三种写法一个本质策略梯度的目标函数有多种等价写法常见的有三种期望回报J(θ) E_{τ~πθ}[R(τ)]其中τ表示一个完整的轨迹状态、动作、奖励序列R(τ)是累积奖励。平均奖励形式J(θ) Σ_s dπ(s) Σ_a πθ(a|s) R(s,a)dπ(s)是策略下的稳态状态分布。初始状态形式J(θ) V^{πθ}(s0)即从初始状态出发的期望回报。这三种写法本质都在衡量“当前策略跑出来的平均成绩”。优化目标就是找一组参数θ让J(θ)最大。梯度上升的直觉是如果某个动作在某个状态下带来了高于平均的回报就提高这个动作的条件概率反之就降低。梯度方向就是状态-动作对的对数概率对参数的梯度乘以回报值。1.3 策略梯度定理跳过未知的微分直接采样估计严格推导过程不细写但结果很干净∇θ J(θ) E_{τ~πθ} [ Σ_t ∇θ log πθ(a_t|s_t) * R(τ) ]这个定理的价值在于J(θ)的梯度可以表示成一个期望形式期望里面有且仅有策略本身的梯度不涉及环境动态模型转移概率P(s|s,a)的梯度。也就是说即使我们完全不知道环境怎么跳转只要能和环境交互采样就能用蒙特卡洛方法无偏估计出策略梯度。这是策略梯度能落地的理论基石。回想一下我刚学这里时的困惑为什么可以无视环境的动态直觉解释是——环境的跳转概率虽然影响回报但它不依赖θ所以对θ求导时这一项就“消失”了。真正需要调整的是我们自己可控的策略分布。把这句话记牢后面看Actor-Critic、PPO会顺很多。2. 从REINFORCE到PPO策略梯度算法的进化逻辑2.1 REINFORCE最朴素的蒙特卡洛策略梯度REINFORCE是最基础的策略梯度算法逻辑直白用当前策略采样一整条轨迹。计算整条轨迹的总回报R(τ)。对轨迹中每个时间步t用 ∇θ log πθ(a_t|s_t) 乘以整个轨迹的回报R(τ)作为梯度贡献。更新参数。但直接这么干方差大到离谱。想象一个场景一模一样的状态和动作可能因为环境随机性导致后续回报波动巨大一个本来不错的动作这次运气差拿到负回报梯度就会错误地压低它的概率。所以REINFORCE理论无偏但高方差实际中必须配合大量采样和对冲技巧才勉强能用。它的优点是简单——作为理解策略梯度的入口再合适不过。只要环境采样不太慢CartPole这种玩具问题还是能跑通的。但如果你直接拿它去训练HalfCheetah大概率看到loss曲线像心电图。2.2 Actor-Critic引入价值函数做基线为了降低方差算法设计者想到一个思路我们不直接用总回报R(τ)而是用回报减去一个“预期水平”基线b(s_t)。如果某个动作的回报高于预期就加大概率低于预期就减小概率。这个预期水平可以用价值函数 V(s_t) 来近似。改造后的梯度为∇θ log πθ(a_t|s_t) * (R_t - V(s_t))其中 (R_t - V(s_t)) 就是优势函数Advantage的估计。这里的价值网络V(s)就是Critic策略网络πθ就是Actor。Actor负责出动作Critic负责打分二者交替训练。这个结构已经成为现代策略梯度算法的标准骨架后面所有算法都在这个框架上做文章。2.3 TRPO信任区域的试金石直接用优势函数更新策略容易出现步子太大扯着蛋的问题。一次更新太猛新策略和旧策略差异过大下一步采样的数据分布就变了训练直接崩掉。TRPO的核心思路是给每次更新加一个约束限制新旧策略的KL散度在某个小范围内。这样做能保证更新后的策略不会离原来的策略太远从而让训练过程稳定。TRPO的实现复杂光是那个共轭梯度求解就够折腾。但它提出了一个关键概念trust region。理解了它的动机你会更容易接受PPO的简化方案。2.4 PPO把约束变成惩罚PPO没有严格求解KL约束而是用两种更简单的办法来近似PPO-Clip限制新旧策略的概率比 r_t(θ) πθ(a_t|s_t) / π_old(a_t|s_t) 在 [1-ε, 1ε] 之间。具体目标函数写出来就是 min(r_t * A_t, clip(r_t, 1-ε, 1ε) * A_t)。翻译成人话如果优势为正就最大程度鼓励动作但概率比不能超过1ε如果优势为负就压制动作但概率比不能低于1-ε。PPO-KL在目标函数里直接减掉 KL散度项但需要自适应调整KL惩罚系数。PPO-Clip在工程上最省心不需要算KL也不需要对约束做复杂求解成为OpenAI等团队的首选。你如果了解它的来历就知道为什么现在跑实验几乎都用PPO而不是TRPO。2.5 算法对比速查算法核心思路优点主要缺点REINFORCE轨迹总回报 × log概率梯度简单、无偏方差高、样本效率低Actor-Critic引入V值作为基线比REINFORCE方差低价值网络误差会引入偏差TRPOKL散度硬约束稳定性极强实现复杂、计算量大PPO-Clip概率比裁剪稳定且实现简单超参数ε敏感需要调选型建议做实验和工程项目默认PPO学习理解先手写REINFORCE再过渡到Actor-Critic如果你的环境允许大量采样且不追求效率TRPO也仍然可用。3. 实现策略梯度时躲不开的细节3.1 优势估计与GAE前面说用 R_t - V(s_t) 当优势但R_t具体怎么算很讲究。最朴素的做法是用蒙特卡洛回报 G_t Σ_{k0}^{T-t} γ^k r_{tk}。这个无偏但方差大。另一种做法是用TD误差 δ_t r_t γV(s_{t1}) - V(s_t)方差低但有偏差。GAEGeneralized Advantage Estimation在这两者之间做了加权平均A_t^{GAE(γ,λ)} Σ_{l0}^{∞} (γλ)^l δ_{tl}当λ0时GAE退化成一次TD误差当λ1时接近蒙特卡洛回报。实际中λ取0.95到0.99之间是一个很稳的起点。GAE这个公式背下来写PPO时几乎一定会用到。3.2 基线、归一化与奖励尺度即使有了GAE优势数值可能忽大忽小。强烈建议在每轮batch内对优势做标准化减去均值、除以标准差。这不是理论必须但实践下来能显著稳定训练。原因在于策略梯度的更新幅度受优势量级影响量级不稳定时同一个学习率在不同batch里可能发出误导性的更新。奖励尺度和折扣因子也需要认真对待。如果环境奖励都是5左右γ取0.99V值会累积到几百网络需要学很大的数值容易振荡。可以适当调小γ比如0.95对短视任务够用或者在奖励上做缩放。但这些改动要理解其影响γ越小智能体越“短视”尤其对于需要长程信用分配的任务γ太小就废了。3.3 熵正则对抗策略坍缩随着训练推进策略会逐渐收敛到确定性的最优动作。但在收敛前如果策略过早变成“几乎总是选同一个动作”探索就停止了可能永远找不到更好的策略这叫熵坍缩。常见做法是在loss里加入一项β * H(πθ(s))其中H是策略分布的熵。鼓励策略保持一定随机性。β初始值可以从0.01开始根据熵的下降速度调整。如果训练早期就看到熵骤降把β升高到0.05甚至0.1如果熵一直不降可能是β太大限制了策略收敛能力。3.4 网络设计与参数初始化策略网络和价值网络的一个实用建议用两层MLP隐层256或512激活函数用tanh或ReLU都行但连续动作输出层一定要配好初始scale。例如连续控制环境里策略输出通常是高斯分布的mean和log_stdlog_std初始值设为0或-0.5别一开始就设成绝对值很大的负数否则探索噪声太小策略很难跳出局部。Critic网络学习率一般比Actor低些或者两个网络共用学习率但梯度裁剪。我自己的习惯是Actor和Critic在同一个优化器下但把Critic的loss loss乘以0.5或0.25再算总loss。这让Critic更新稍微慢一点减少它对Actor的误导。4. 实操全程从零实现一个可用的PPO-CartPole4.1 环境搭建与整体流程我这里以CartPole-v1为例用PyTorch实现一个最小可用的PPO。虽然CartPole简单但流程完整采样 → 计算GAE → 更新策略 → 重复。你没有必要照着抄重点看结构和关键参数怎么选。import gymnasium as gym import torch import torch.nn as nn import torch.optim as optim import numpy as np class ActorCritic(nn.Module): def __init__(self, obs_dim, act_dim): super().__init__() self.common nn.Sequential( nn.Linear(obs_dim, 128), nn.Tanh(), nn.Linear(128, 128), nn.Tanh() ) self.pi nn.Linear(128, act_dim) self.v nn.Linear(128, 1) self.log_std nn.Parameter(torch.zeros(act_dim)) def policy(self, obs): x torch.tanh(self.common(obs)) probs torch.softmax(self.pi(x), dim-1) return probs def value(self, obs): x torch.tanh(self.common(obs)) return self.v(x) def dist(self, obs): # 简单起见用离散softmax其实CartPole可以更简单 probs self.policy(obs) return torch.distributions.Categorical(probs)注意这里为了照顾CartPole的离散动作用了Categorical分布。如果是连续任务需要改成MultivariateNormal代码如下def dist(self, obs): x torch.tanh(self.common(obs)) mean self.pi(x) std torch.exp(self.log_std) return torch.distributions.Normal(mean, std.to(mean.device))4.2 采样与GAE计算PPO需要先跑若干步收集数据。一个完整epoch中我们用当前策略跑出多个轨迹存下obs、action、reward、done、value。然后按时间顺序算GAE。def collect_rollout(env, model, steps2048, gamma0.99, gae_lambda0.95): obs_list [] action_list [] reward_list [] done_list [] value_list [] logprob_list [] obs, _ env.reset() for _ in range(steps): obs_t torch.FloatTensor(obs).unsqueeze(0) dist model.dist(obs_t) action dist.sample().item() logprob dist.log_prob(torch.tensor([action])).item() value model.value(obs_t).item() next_obs, reward, terminated, truncated, _ env.step(action) done terminated or truncated obs_list.append(obs) action_list.append(action) reward_list.append(reward) done_list.append(done) value_list.append(value) logprob_list.append(logprob) obs next_obs if done: obs, _ env.reset() # compute returns and advantages obs_tensor torch.FloatTensor(np.array(obs_list)) action_tensor torch.LongTensor(action_list) reward_tensor torch.FloatTensor(reward_list) done_tensor torch.FloatTensor(done_list) value_tensor torch.FloatTensor(value_list) advantages [] gae 0 for t in reversed(range(steps)): next_value 0 if (t steps - 1 or done_tensor[t]) else value_tensor[t 1] delta reward_tensor[t] gamma * next_value - value_tensor[t] gae delta gamma * gae_lambda * (1 - done_tensor[t]) * gae advantages.insert(0, gae) returns torch.FloatTensor(advantages) value_tensor advantages torch.FloatTensor(advantages) return obs_tensor, action_tensor, logprob_list, returns, advantagesGAE的循环从后往前累加注意结束时或者done时next_value要置0避免跨episode计算价值。4.3 PPO更新步骤PPO更新通常会在同一批数据上做多轮比如4或10轮每轮打乱数据并切成mini-batch。更新时重新计算当前策略的logprob然后算ratio exp(pi_logprob - old_logprob)就是新旧策略概率比。def update_ppo(model, optimizer, obs, actions, old_logprobs, returns, advantages, clip_eps0.2, epochs4, batch_size64): total_loss 0 for _ in range(epochs): inds torch.randperm(obs.shape[0]) for start in range(0, len(inds), batch_size): idx inds[start:start batch_size] batch_obs obs[idx] batch_actions actions[idx] batch_old_ll torch.FloatTensor([old_logprobs[i] for i in idx]) batch_returns returns[idx] batch_adv advantages[idx] dist model.dist(batch_obs) entropy dist.entropy().mean() pi_logprob dist.log_prob(batch_actions).sum(-1) ratio torch.exp(pi_logprob - batch_old_ll) adv batch_adv # normalize advantages adv (adv - adv.mean()) / (adv.std() 1e-8) loss_pi -torch.min(ratio * adv, torch.clamp(ratio, 1 - clip_eps, 1 clip_eps) * adv).mean() loss_entropy -0.01 * entropy value_pred model.value(batch_obs).squeeze(-1) loss_value ((value_pred - batch_returns) ** 2).mean() loss loss_pi 0.5 * loss_value loss_entropy optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.5) optimizer.step() total_loss loss.item() return total_loss / (epochs * max(1, (obs.shape[0] // batch_size)))这里有几个细节值得注意优势标准化只对当前batch的advantage做标准化目的是稳定更新幅度。熵惩罚系数取了0.01CartPole问题简单太小不会提前坍缩像连续控制任务就适当加大。梯度裁剪clip_grad_norm很关键尤其当ratio过大或value loss爆炸时能救命。value loss系数0.5让critic收敛更平滑。4.4 训练循环与效果env gym.make(CartPole-v1) model ActorCritic(env.observation_space.shape[0], env.action_space.n) optimizer optim.Adam(model.parameters(), lr3e-4) for iteration in range(500): obs, actions, old_ll, returns, adv collect_rollout(env, model, steps2048) loss update_ppo(model, optimizer, obs, actions, old_ll, returns, adv) # 每50轮或定期评估一下 if iteration % 50 0: eval_reward evaluate(model, env) print(fIter {iteration}, loss ~ {loss:.3f}, eval reward ~ {eval_reward:.1f})CartPole在PPO下通常几百个iteration就能达到500分的上限。如果epoch数太少比如1收敛会慢太多比如20容易过拟合当前batch导致下次采样性能反而下降。4到10是一个合理区间。5. 训练策略梯度最容易踩的坑5.1 优势数值量级忽大忽小现象loss曲线出现巨大尖刺参数更新后策略一瞬间变成随机策略。排查方向先看advantage的均值和标准差如果某个batch的adv数值是另一个batch的几十倍说明GAE计算有问题或者奖励尺度在不同episode差异太大。解决对adv做标准化并检查奖励是否异常。还要确认gamma设置是否合理gamma太接近1时GAE数值会非常大。5.2 策略熵急速崩塌现象训练初期熵就快速掉到接近0然后性能不再提升。原因通常是探索不足。对策增加熵系数从0.01调到0.05甚至0.1降低学习率检查log_std初始化是否过小。连续动作任务里尤其注意log_std不要初始化为负数大值否则策略一开始就“自信得过头”。5.3 value loss不下降现象Critic的value loss一直在高位Advantage约等于随机噪声。原因可能是网络容量不够或者状态观测没有归一化。很多环境的状态尺度差异很大位置是0.1量级、速度是10量级建议对obs做RunningMeanStd归一化或者至少做标准化。对CartPole这种简单任务不敏感但对HalfCheetah这种连续控制任务obs不归一化很难训练。5.4 随机种子与可复现性强化学习对随机种子的敏感度远超普通监督学习。同一个代码seed1能跑到500分seed2可能只有200分。这不是代码bug而是回报分布非平稳导致的正常现象。要写实验时务必固定seed并且跑多个seed取平均。另外确定性推理时关闭dropout、把策略均值作为动作能帮助你评估当前策略的真实能力。5.5 ratio爆炸问题PPO-Clip的一个潜在风险是ratio可能特别大。当旧策略把某动作概率压到极低新策略又认为这个动作很好时exp(pi_logprob - old_logprob)可能超过几十。如果裁剪限定在0.8到1.2好像问题不大但在batch内某些极端ratio依然会让梯度爆炸。我的经验是尽量保证采样时策略不是太确定log_std不要过小如果ratio超过10的比例多于1%就要检查是不是策略学习率太高。6. 我的个人实操体会策略梯度这套东西理论门槛不高但工程坑不少。我在跑了至少几十次PPO变体之后最大的感受是调公式参数不如调数据。多数时候训练不稳定问题不在算法本身而在奖励设计、obs归一化、batch大小和advantage估计这些“外围环节”。比如有一次我在自定义机器人环境里训练策略一直不收敛折腾了一周最后发现是奖励里某个传感器读数存在极大的离群值把advantage搞出了几个数量级的尖峰。把奖励clip之后效果立刻上升。另外一个容易被忽略的点是batch大小。PPO默认2048步往往是个最低门槛如果任务复杂比如需要在长轨迹里做信用分配1024步根本连一个完整episode都覆盖不了GAE算出来就失去意义。建议先统计一下一个episode的平均长度让每次采样至少覆盖几十个完整episode的长度。最后说说“收藏版”这三个字。策略梯度相关的知识确实适合收藏因为它是RL里最常见、使用面最广的一类算法从学术论文到工业落地几乎绕不开。但收藏之后更要做的是自己动手实现一次REINFORCE再改成Actor-Critic再改造成PPO。这个手写过程带来的理解比读十篇总结都管用。希望这篇笔记能帮你少走些弯路。