首页 / 资讯中心 / 文章详情

策略梯度方法全解析:从原理推导到Actor-Critic实战

策略梯度方法全解析:从原理推导到Actor-Critic实战 ★ FEATURED ARTICLE
策略梯度Policy Gradient在强化学习中的分量怎么强调都不过分。从最朴素的REINFORCE到后来的PPO、TRPO再到各种大规模分布式训练框架几乎所有主流算法的最底层逻辑都锚定在这套思路上面。作为强化学习系列的第9篇这篇就把策略梯度方法彻底讲透为什么值函数方法不够用、策略梯度的目标函数和推导过程到底长什么样、REINFORCE算法是如何一步步落地的以及在实际跑实验时哪些坑是我踩过之后才彻底想明白的。这篇文章适合已经了解Q-learning、DQN这类值函数方法、但还没系统接触过策略梯度的人也适合那些看过公式但始终没搞懂“为什么要这么做”的读者。我会把公式、直觉和工程实现三条线串在一起讲尽量保证你读完之后不但能看懂推导还能独立写一个能跑的Actor-Critic雏形。1. 为什么值函数方法会撞到天花板1.1 值函数方法的底层逻辑与局限先回顾一下DQN这类方法在做什么。核心思路是估计Q值——也就是“在某个状态下执行某个动作之后累计折扣回报的期望”。一旦这个Q函数被估计出来策略就直接从Q函数里提取哪个动作的Q值大策略就选哪个。这种思路在离散动作空间里非常顺手因为动作数量有限可以逐一把所有动作的Q值算一遍再取最大值。但问题也随之而来。第一连续动作空间没法穷举。假设机器人关节有7个自由度每个自由度都输出连续力矩那动作空间本身就是高维连续域你没法把所有动作枚举出来做argmax。第二策略是贪婪提取的它天然是一个确定性策略虽然可以通过epsilon-greedy加一些探索但这种探索方式在复杂任务里往往效率不高。第三值函数估计存在系统性偏差积累的问题尤其当环境奖励稀疏、状态空间很大的时候光靠TD误差逐步逼近Q值收敛会非常慢。这就是策略梯度方法出现的根本原因我们不去绕道估计值函数再提取策略而是直接用一个参数化的策略网络来表示“状态到动作分布”的映射然后沿着让期望回报上升的方向去更新这个网络的参数。思路换了一条路很多之前卡住的问题就打开了。1.2 直接参数化策略是什么感觉参数化策略意味着什么呢假设你现在有一个策略网络输入是状态s输出的不是Q值而是动作的概率分布π_θ(a|s)。在离散动作场景里输出层通常是Softmax在连续动作场景里输出通常是高斯分布的均值和方差。你在这个分布上采样得到这次实际执行的动作。这里有一个很关键的心态切换策略不再是一个“从Q值推导出来的副产品”而是模型本身。你优化的目标也不再是“让Q值预测得更准”而是“让期望回报最大化”。打个比方值函数方法像是一个先学会看地图、再根据地图找路的导航仪策略梯度则是请了一个老司机不看地图也能开通过一路收到的“奖励信号”不断调整自己的开车方式。这个类比虽然粗略但能帮你抓住核心差异前者是在学习估计后者是在直接学习决策者本身。这种切换带来的直接好处之一就是策略可以是随机的。随机策略天然带有探索性在部分可观测环境、博弈对抗场景里随机性的价值甚至比贪婪更重要。连续控制问题也顺势化解了不需要对动作空间做任何离散化网络输出什么分布的参数你就从那个分布里采样一个连续动作出来。1.3 策略梯度方法能解决什么问题在实际应用层面策略梯度方法擅长处理三类任务。一类是连续控制比如机械臂关节力矩控制、自动驾驶的转向和油门联合控制、游戏里角色的连续操作。一类是随机策略本身就有意义的场景比如不完全信息博弈对手无法预判你的下一步。还有一类是策略本身结构比较复杂、难以从值函数反推的任务比如多智能体协同、层次化策略。当然策略梯度不是什么银弹。它最大的缺点后面会反复提到——方差大、收敛慢而且超参数敏感。所以现代实践里几乎没有人在用纯REINFORCE跑大型任务大家都在REINFORCE的地基上加各种技巧基线、Critic、优势函数、裁剪目标、熵正则化。但不管怎么加核心思想还是策略梯度定理这条线。把地基打稳后面所有的改进型算法你都能快速看懂。2. 策略梯度定理与REINFORCE的完整推导2.1 目标函数拿什么衡量“策略好不好”在推导之前先明确我们到底要优化什么东西。强化学习的目标是最大化期望累计折扣回报。如果策略由参数θ决定那目标函数可以写成J(θ) E_τ_∼_π_θ [ R(τ) ]其中τ表示一条完整轨迹也就是s_0, a_0, r_1, s_1, a_1, r_2, ...这个序列。R(τ)是轨迹的累计折扣回报R(τ) Σ γ^t · r_t注意这里的期望是在“状态转移概率”和“策略给出的动作概率”双重随机性下计算的。也就是说轨迹本身是随机变量策略参数θ影响的是轨迹分布的形状。我们希望找到一组θ使得从这个分布里采样出来的轨迹平均来看回报最大。问题在于这个目标函数带期望而期望里面又被环境动态和策略共同作用我们通常拿不到环境的显式模型连梯度都没法直接算。策略梯度定理的核心贡献恰恰就是虽然对θ求导没法直接穿透整个轨迹分布但梯度可以改写成一种期望形式这个期望里只包含策略的对数概率和回报而这两样东西我们都有办法采样估计。2.2 对数似然技巧梯度计算的破局点下面走一遍推导中最关键的一步叫做“似然比技巧”likelihood ratio trick也叫score function trick。轨迹τ的概率可以写成P_θ(τ) P(s_0) · ∏ π_θ(a_t|s_t) · P(s_{t1}|s_t,a_t)对θ求梯度的时候环境动态P(s_{t1}|s_t,a_t)里头没有θ所以求导只会落到策略项头上。但问题是我们要求的是log J(θ)还是J(θ)的梯度直接用“梯度的期望等于期望的梯度”这个交换还不够因为要对P_θ(τ)这个分布本身求导。妙处在于∇_θ J(θ) ∇_θ ∫ R(τ)P_θ(τ)dτ ∫ R(τ)∇_θ P_θ(τ)dτ然后利用∇_θ P_θ(τ) P_θ(τ) · ∇_θ log P_θ(τ)把上面的积分改写成∇_θ J(θ) E_τ_∼_π_θ [ R(τ) · ∇_θ log P_θ(τ) ]再把log P_θ(τ)展开环境项全部不含θ求导后直接消失剩下∇_θ log P_θ(τ) Σ_t ∇_θ log π_θ(a_t|s_t)这一步就是策略梯度定理的核心结果之一。直观理解是如果一条轨迹的回报高我们就往增大这条轨迹上每个动作出现概率的方向更新如果一条轨迹回报低就反向压低这些动作的概率。整条轨迹的表现变成了每个动作的“评分卡”而评分卡乘以对数概率梯度就是最终的参数更新方向。这个技巧之所以有效是因为它把“对随机分布求梯度”转换成了“在样本上求对数概率梯度再乘回报”从而绕开了环境动态模型。工程上完全实现了你只要跑几轮环境收集轨迹算出每条轨迹的累计回报再对策略网络里这些动作的log概率自动求导最后乘上回报作为权重就完成了一次策略梯度更新。2.3 REINFORCE算法最朴素的落地版本REINFORCE就是把前面这个结果直接拿来实现的算法。流程非常简单核心就四步收集轨迹、算回报、算梯度、更新参数。更细一层的写法是这样的。对一条轨迹里的每一个时间步t可以用从t时刻开始的累计折扣回报G_t Σ_{k0}^{∞} γ^k r_{tk}作为动作a_t的评分。REINFORCE的更新规则是θ ← θ α · G_t · ∇_θ log π_θ(a_t|s_t)注意这里用的是G_t而不是整条轨迹的总回报R(τ)区别在于一个动作的好坏应该由它之后能拿到的回报来衡量之前已经发生的奖励和它无关。这在理论上对应的是Q函数的蒙特卡洛估计也就是说G_t是Q(s_t,a_t)的一个无偏样本。下面给一份简化版的PyTorch风格代码骨架帮你把流程串起来import torch import torch.nn as nn import torch.optim as optim class PolicyNet(nn.Module): def __init__(self, state_dim, action_dim, hidden64): super().__init__() self.fc nn.Sequential( nn.Linear(state_dim, hidden), nn.ReLU(), nn.Linear(hidden, action_dim) ) def forward(self, s): logits self.fc(s) return torch.softmax(logits, dim-1) def train_reinforce(env, policy, optimizer, num_episodes2000, gamma0.99): for episode in range(num_episodes): states, actions, rewards [], [], [] s, _ env.reset() done False while not done: probs policy(torch.FloatTensor(s)) dist torch.distributions.Categorical(probs) a dist.sample().item() s_next, r, done, _ env.step(a) states.append(s) actions.append(a) rewards.append(r) s s_next # 从后往前计算折扣回报 returns [] G 0 for r in reversed(rewards): G r gamma * G returns.insert(0, G) returns torch.FloatTensor(returns) # 计算策略梯度损失 probs policy(torch.FloatTensor(states)) dist torch.distributions.Categorical(probs) log_probs dist.log_prob(torch.LongTensor(actions)) loss - (log_probs * returns).mean() optimizer.zero_grad() loss.backward() optimizer.step()这一段代码在CartPole这种简单环境里其实已经能学到不错的策略但它的问题也会很快暴露方差太大学得慢而且对奖励的量级和分布极其敏感。接下来一节就专门分析这个方差问题以及主流的改进手法。3. 高方差问题与基线方法3.1 为什么REINFORCE的方差会大到离谱REINFORCE最让人头疼的地方就是训练曲线抖动剧烈有时候明明已经学到不错的地方了一个批次差样本又给拉回去。根源在于我们是用“单条轨迹样本”来估计Q值而轨迹本身受环境随机性和策略随机性双重影响。想象一下同样的状态下同一个动作运气好时后续路径拿了一堆高分运气差时立刻结束拿负分两次采样出来的G_t可能天差地别。这意味着梯度估计的方差非常大。更新方向一定程度上是“噪声主导”的上一秒朝着正确方向下一秒可能就乱飞。损失项- (log_prob * returns)本身不是一个定义良好的监督损失它只是把梯度方向用蒙特卡洛样本包装成了一个可以被反向传播的表达式其绝对值大小并不代表真实的损失变化所以训练曲线难看是正常的。另外一个容易被忽略的问题如果所有回报都为正即使某些动作其实很糟糕它们被采到的概率依然会被抬升只不过抬升幅度低于好动作而已。这种“全员鼓励”的问题会导致策略收敛迟缓甚至朝某个次优方向偏。后面讲基线时你会看到这只是奖励平移带来的影响之一。3.2 基线的作用减去一个不依赖于动作的量既然问题是方差大一个自然的想法是给评分减掉一个“平均分”。在REINFORCE的更新式子里把G_t换成G_t - b(s_t)其中b(s_t)只依赖状态s_t、不依赖动作a_t。关键是这个b从数学上不会改变梯度的期望因为E_a [ ∇_θ log π_θ(a|s) · b(s) ] b(s) · ∇_θ Σ_a π_θ(a|s) b(s) · ∇_θ 1 0所以期望不变但方差通常会显著下降。直观理解就是原来一个动作好不好看绝对回报现在看它“比该状态下的平均表现好多少”。这样那些轨迹即便全局回报都为正相对差的动作也只会拿到偏负的评分不会被盲目鼓励。基线怎么选最简单的是用整个批次的平均回报作为常数基线效果已经比零基线好很多。稍微进一步可以训练一个价值网络V(s)来预测期望回报用它作为状态相关的基线。这就引出了Actor-Critic结构。3.3 优势函数从基线到真正的Actor-Critic当我们用V(s)当基线时评分就变成了A(s_t, a_t) G_t - V(s_t)这个量叫优势函数它衡量的是“在当前状态下选择这个动作比平均情况好出多少”。它是策略梯度理论中非常核心的对象。完全用蒙特卡洛回报算优势方差依然偏大于是实践中更常用的是TD误差形式的优势估计A_t r_t γ·V(s_{t1}) - V(s_t)也就是说用一步的即时奖励加上下一状态的价值估计减去当前状态的基准。这个量作为优势的估计偏差通常略增但方差大幅下降。在现代实现里大家通常用GAEGeneralized Advantage Estimation在两者之间做插值GAE本质上是对多步优势估计做一个指数加权平均兼顾偏差和方差。当策略网络和值函数网络分开训练时整个架构就成了标准的Actor-CriticActor负责输出动作分布Critic负责输出状态价值用于计算优势。Actor用优势加权的方式来更新策略参数Critic则用TD误差做回归。后面实战部分就围绕这个结构展开。4. Actor-Critic实战从零写一个能跑的雏形4.1 网络结构与数据流的设计Actor-Critic里面有两条网络线。Actor的输入是状态输出动作分布参数Critic的输入也是状态输出一个标量V(s)。在简单任务里可以让两者共享底层的特征提取层只在输出头分成两个分支这样状态特征可以复用但要注意两个任务的梯度可能会互相干扰复杂场景下更稳妥的做法是各自独立网络或者共享前几层后分流。数据流大概是这样的环境给出状态s_tActor采样出动作a_t推进环境一步拿到r_t和s_{t1}Critic分别预测V(s_t)和V(s_{t1})然后算出TD误差δ_t r_t γV(s_{t1}) - V(s_t)。这个δ_t既作为Critic的回归目标又作为Actor的优势估计。这比REINFORCE更像在线学习每一轮交互都可以立即更新不必等一整个回合结束。不过完全单步更新容易导致样本相关性高、更新噪声大。实际中常用的是n步回报或者GAE折中并且把一批交互数据攒下来做批量更新。下面我给出一版用GAE做优势估计的Actor-Critic实现骨架这是很多现代算法的公共底座。4.2 关键代码实现GAE版Actor-Criticimport torch import torch.nn as nn import numpy as np class ActorCritic(nn.Module): def __init__(self, state_dim, action_dim, hidden64): super().__init__() self.actor nn.Sequential( nn.Linear(state_dim, hidden), nn.ReLU(), nn.Linear(hidden, action_dim) ) self.critic nn.Sequential( nn.Linear(state_dim, hidden), nn.ReLU(), nn.Linear(hidden, 1) ) def act(self, s): logits self.actor(s) dist torch.distributions.Categorical(logitslogits) return dist def value(self, s): return self.critic(s) def compute_gae(rewards, values, gamma0.99, lam0.95): values: 每个状态的Critic预测值长度比rewards多1包含s_{T1} advantages np.zeros_like(rewards) gae 0 for t in reversed(range(len(rewards))): delta rewards[t] gamma * values[t1] - values[t] gae delta gamma * lam * gae advantages[t] gae returns advantages values[:-1] return advantages, returns def update(model, states, actions, returns, advantages): states torch.FloatTensor(states) actions torch.LongTensor(actions) returns torch.FloatTensor(returns) advantages torch.FloatTensor(advantages) # 归一化优势稳定训练 advantages (advantages - advantages.mean()) / (advantages.std() 1e-8) dist model.act(states) log_probs dist.log_prob(actions) entropy dist.entropy().mean() actor_loss - (log_probs * advantages).mean() critic_loss nn.MSELoss()(model.value(states), returns) total_loss actor_loss 0.5 * critic_loss - 0.01 * entropy optimizer.zero_grad() total_loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm0.5) optimizer.step()注意几个细节。GAE需要values比rewards多一位也就是最后一个状态的价值需要单独预测那条轨迹的末尾状态通常是terminal的话对应的value应为0。优势归一化这步非常关键很多新手训练不稳定去掉这步可能直接导致发散。熵正则项不是可选的尤其在早期它能保持一定的探索性防止策略还没学到东西就过早坍缩成一个确定性分布。梯度裁剪是保命用的后面排查章节会展开讲。4.3 训练循环与批次组织的实操细节训练循环里一个容易犯的错是更新频率和环境交互频率的平衡。别每走一步就更新一次那样数据高度相关梯度估计方差大。常见做法是收集一个batch的经验——比如跑满2000步或者500个transition——然后统一算优势、统一更新若干轮。这个“收集一整批再更新”的节奏就是on-policy算法的典型模式。批大小对训练影响很大。批次太小优势估计噪声大学习不稳批次太大样本利用率低训练速度慢。我建议先从2048起步简单任务可以降到512甚至256。另外GAE的λ参数决定了偏差和方差的权重λ接近0类似于TD(0)偏差大方差小λ接近1类似于蒙特卡洛偏差小方差大。实际中0.95是个好起点。还有一个助理级细节环境的reward scale对训练影响极大。如果奖励数值在千级你直接拿原始奖励去算GAE网络可能直接梯度爆炸。最常见的手段是把奖励缩放或者对优势做归一化。后者我们已经在代码里做了这也是为什么即便奖励量级不同这套代码还能稳健工作的原因。5. 训练策略梯度模型的调参与工程心得5.1 输入归一化再简单也要做策略梯度对输入状态的尺度异常敏感。假如状态向量里一个维度在0到1之间、另一个维度在几千到几万之间那策略网络的初始梯度会被大尺度的维度主导小尺度的特征可能根本学不到。这导致训练初期的策略严重偏向某些维度后续要花很长时间才能纠正回来。所以无论任务多简单养成对观测做标准化的习惯。用运行中的均值和方差做归一化或者简单地在采样前手动算一遍归一化又或者用环境已有的归一化包装器。很多看似“莫名其妙不收敛”的问题检查之后发现就是输入没归一化。这一条对连续控制类任务几乎百分之百适用。另外奖励归一化也是一个方向。严格说折扣奖励的绝对大小会影响价值网络的初始预测难度但优势归一化已经把这层影响抵消掉大半所以不必非得把奖励缩到某个固定区间。只要不是奖励值本身到了1e6这种离谱量级优势归一化通常足够。5.2 熵正则化与探索的平衡熵正则化是策略梯度训练里最容易被忽视又最关键的技巧之一。策略一旦收敛到某个确定性分布梯度就会趋于零探索也停止了整个训练就冻结在一个可能的次优解上。解决方式就是在损失函数里加一项负的熵鼓励策略保持一定的随机性。熵系数的大小很讲究。太大了策略一直乱试学不到精细动作太小了探索不足收敛早期就坍缩。我的经验是从0.01起步如果观察到策略太早变确定就往上调到0.05甚至0.1如果是连续控制任务系数往往可以更大一些因为动作空间的维度高天然需要更强的探索噪声。训练过程中也可以把系数逐步衰减初期多探索、后期精细收敛。有个细节π_θ(a|s)是Softmax输出的理论上每个状态下所有动作的概率和为1所以Softmax策略自带一定的“内在随机性”。但环境的状态不同策略的置信度也不同仅仅靠softmax温度调节还不够熵正则化才是直接控制策略分布“宽度”的开关。5.3 学习率、优化器与网络宽度策略梯度的学习率比监督学习难调很多因为目标函数本身不是“光滑下降”的过程。我习惯用Adam作为默认优化器初始学习率在3e-4到1e-3之间但这只是一个起点。更重要的一招是学习率衰减或自适应调整比如当最近若干轮的平均回报变化很小时降低学习率。网络宽度方面连续控制任务用两层256的MLP通常效果不错游戏类任务可以降到128。过宽的网络在小规模任务里反而容易过拟合轨迹上的噪声产生病态梯度。激活函数默认ReLU问题不大但如果发现梯度不稳定可以换tanh试试收敛速度会慢一些但平滑性更好。关键是你得给训练过程做一个“监控仪表盘”记录每个batch的Actor损失、Critic损失、策略熵、优势均值、样本回报均值。这些指标能告诉你模型是跑到哪里出了问题还是压根没动。我先说个经验规律如果损失曲线一下子猛降而后持续不动通常是策略坍缩了如果熵值归零那就是彻彻底底的探索停止。没有这些监控你很难定位问题在哪一层。5.4 并行环境与样本收集效率单环境顺序采样效率低下一个batch的数据要跑很久。常见的做法是多开几个环境并行采样让每个环境各自跑自己的轨迹然后把样本池汇总统一更新。这样不仅快还能用多样化的轨迹数据降低方差相当于“经验去相关”。拿Python来说可以用多个进程或者借助现成库的向量化环境接口。并行的另一个隐藏好处是缓解了on-policy算法样本利用率低的痛点。策略更新后旧样本就要丢弃每个环境都跑出自己的多样性轨迹等于是让更新时见到的样本覆盖更多状态空间策略的泛化程度会好很多。并行数不用贪多8个环境的提升已经很明显我一般16个封顶。再多的话样本管理成本增加收益边际递减而且如果某个环境因为偶发原因卡死整个训练都要跟着带崩。6. 常见问题排查与排错实录6.1 训练曲线一直在震荡不收敛这是策略梯度最经典的问题。先看几个指标熵是否已经降到接近0策略熵很低但是回报还在震荡说明已经坍缩到一个次优区域在反复试探如果熵还很高则说明策略仍然在乱转。前者对应探索不足解决办法是调高熵系数、降低学习率后者对应学习率过高导致梯度更新幅度太大策略分布被来回拉扯。我遇到过一个非常隐蔽的情况环境本身返回的奖励是浮点型归一化优势后出现NaN整个batch的梯度就全乱了。排查方法是打印每个batch的优势均值、标准差、最大值发现NaN后回溯到奖励数组定位到某个环境的某个时间步生成了一个极大或极小的奖励值。这种偶发异常值在并行环境里非常常见务必给优势计算加一层数值保护。6.2 策略过早坍缩做了一大堆无用功策略过早坍缩的表现是训练刚开始回报还很差熵值已经降到很低之后无论怎么更新策略基本不再变化。这通常是因为熵系数太小策略在初始阶段就被几个“碰巧得分高”的样本带偏了。解决思路刚开始训练时把熵系数设置得高一些等到平均回报有明显上升趋势再慢慢衰减。另一个辅助手段是限制策略更新的步子。从REINFORCE到PPO演进的核心动机之一就是防止单次更新幅度过大导致不稳定的坍缩。如果暂时不想上PPO那套复杂逻辑可以先尝试最简单的一条计算新旧策略的KL散度超过阈值就减小学习率或跳过这次更新。这能极大降低坍缩风险。6.3 梯度爆炸与消失策略梯度的公式里log_prob对参数求导后通常是一个有界的量但乘上优势值之后如果优势值很大梯度就会非常大。尤其是刚把Critic初始化不当时TD误差可能在一开始就非常大带动整个网络参数剧烈抖动。这就是我前面强调梯度裁剪的原因。nn.utils.clip_grad_norm_设个0.5基本能防止最坏情况。如果裁剪之后仍然不稳就得怀疑优化器本身的问题了。把学习率降低一个数量级测试一下如果指数下降能稳住说明长期使用的是过高的学习率如果降低学习率之后梯度小得更新不动那要考虑是不是优势归一化出了问题检查是不是std除以了0。6.4 回报始终没有变化问题可能根本不在算法有一次我调一个机械臂任务训练了很久回报曲线纹丝不动。看了半天网络、参数、奖励最后发现环境的动作范围是[-1,1]而我的策略网络输出层没有加tanh导致采样的动作超出范围被环境clip到一个角落。这时候策略的梯度已经在摸索有效动作区间但一步就被clip了根本学不到。加上输出层激活函数之后立刻好了。这个案例说明的策略梯度实现里你必须确保动作分布的有效支撑集和环境的动作空间一致。高斯策略通常输出mean和stdmean直接作为动作时要确保它被限制在环境允许的范围里或者在采样后做clip但clip操作会让梯度传播失效更稳妥的做法是让mean经过tanh再缩放。还有一个自我提醒用同一份策略和数据去调试多个环节容易陷入“哪里都可能有问题”的困境。我的习惯是一层一层拆先固定网络结构检查单个batch的梯度是否合理再逐步放开训练循环最后才调熵系数和学习率。每次只改一个变量才能知道到底是哪个改动起了作用。结尾做策略梯度实验这几年我个人最大的感受是数学公式看起来很短但从公式到稳定的训练代码之间隔着一整条“工程鸿沟”。策略梯度定理本身并不复杂复杂的是方差控制、探索保持、超参数配合这些公式里不会写的东西。如果你刚接触这部分内容我建议先别急着上PPO或TRPO这些高级算法老老实实把REINFORCE跑通再去实现一个带GAE的Actor-Critic把优势和基线的作用亲手验证一遍。这个过程走完你再看任何策略梯度变种论文的“改进动机”部分都会有豁然开朗的感觉。最后再分享一个小技巧每次改完超参数保留一份带时间戳的训练日志和配置记录。别问我是怎么知道这有多重要的改过的参数多了你一定会回来感谢这句话。
阅读完成 · 觉得有帮助?
咨询建站