
如果你已经玩过几轮DQN一定会有一种隐隐的不适感明明可以把Q值估得很准但最终策略只是机械地取argmax动作选择完全由值函数“间接”决定。遇到连续动作空间DQN这套玩法基本就瘫痪了——你总不能对无穷多个动作挨个求最大值。更麻烦的是值函数方法天然是确定性策略可现实任务里很多时候最优策略恰恰是随机的比如扑克里的虚张声势、玩游戏时的随机试探。也就是从这个时候开始我开始认真琢磨策略梯度Policy Gradient这条完全不同的路线。它不绕弯子直接把策略本身参数化然后沿着“让好动作更可能被选”的方向去优化。这是深度强化学习系列的第13篇这篇我会把策略梯度的动机、数学推导、损失函数设计、代码实现和训练时踩过的坑一次讲透不堆公式但也不会帮你跳过关键推导。1. 为什么值函数方法到头了策略梯度出场的真实原因1.1 DQN的“间接优化”困局先回忆一下DQN在做什么。DQN的目标是拟合一个Q函数也就是“在状态s下执行动作a之后一直按最优策略走能拿到的期望回报”。训练完成后策略本身并没有被显式表达出来它只是Q函数的一个附属产物[ \pi(s) \arg\max_a Q(s,a) ]这个思路在离散动作空间里确实漂亮但也正是这个argmax埋下了隐患。我实际调DQN时体会最深的是两个问题第一只要动作空间稍微大一点比如动作数量从4涨到几百max操作的计算开销就上去了更别提让Q网络在动作维度上做泛化第二Q函数估计本身有误差而argmax会把这误差放大——某个动作的Q值被高估了一点点它就会被锤死成“最优动作”之后所有训练样本都偏向它高估越来越严重策略越来越固化。这个偏差链条用DQN的人多少都遇到过。1.2 连续动作空间是压垮骆驼的最后一根稻草如果动作不是几个离散选项而是一个连续向量呢比如控制一个机械臂每个关节的角度是0到360度之间的任意值再比如自动驾驶的方向盘转角是个连续实数。DQN的argmax在这里直接失效因为你没法枚举无穷多个候选动作。有人可能会想那我用优化器在线求解argmax或者训练一个额外的网络来做“给定状态→最优动作”的映射这就是DDPG后来做的事。但别忘了这些都是“用策略梯度思想去补值函数的漏”本质上还是在绕弯子。策略梯度的思路非常直白既然最终要的是策略本身那我干脆直接把策略定义成一个带参数的网络 (\pi_\theta(a|s))输入状态输出动作分布然后用梯度上升法去调参数让最终累积回报最大。不经过Q函数这座“中间商”自然就不存在argmax的问题。1.3 随机策略的“隐藏价值”还有一个很容易被忽视的点。DQN收敛出来的策略是确定性的可很多任务的真实最优策略是随机的。最经典的例子是石头剪刀布纯确定性策略必输不完美信息博弈里最优策略往往要求以特定概率混合多种动作。策略梯度输出的天然就是一个概率分布它有内在的探索机制——学到的策略即使在后期也保留一定随机性不会像DQN那样动不动就“铁了心”走一条路。这种特性在博弈类、对抗类环境里价值极大。2. 策略梯度的数学内核那个神奇的log技巧2.1 目标函数和它的梯度既然要优化策略先得给“好策略”一个量化指标。标准做法是定义期望回报[ J(\theta) \mathbb{E}{\tau \sim \pi\theta} \left[ \sum_{t0}^{T} \gamma^t r_t \right] ]这里 (\tau) 表示一条完整轨迹也就是 (s_0, a_0, r_0, s_1, a_1, r_1, \dots) 的这个序列。我们的目标就是找到一组参数 (\theta)让 (J(\theta)) 最大。注意是最大化这和训练分类网络时的最小化损失完全是两个方向实操中所有代码都会以“负损失”的形式出现。直接对 (J(\theta)) 求梯度是可行的但有个麻烦期望里面那条轨迹的生成概率本身就依赖参数 (\theta)而采样过程又是一个不可微的随机过程。你不能像训练普通神经网络那样直接对采样出来的动作求梯度。2.2 对数似然比从“轨迹概率”到“动作概率”这里就用到了策略梯度定理核心是一个叫“对数似然比”的技巧。我们做一次不太严谨但直觉到位的推导轨迹 (\tau) 出现的概率可以写成[ P(\tau|\theta) \rho(s_0) \prod_{t0}^{T} \pi_\theta(a_t|s_t) P(s_{t1}|s_t,a_t) ]其中 (\rho(s_0)) 是初始状态分布(P(s_{t1}|s_t,a_t)) 是环境转移概率。注意到一件关键的事这个概率分成了两部分一部分是策略 (\pi_\theta) 控制的另一部分是环境控制的我们优化不了。对目标函数求梯度[ \nabla_\theta J(\theta) \int \nabla_\theta P(\tau|\theta) R(\tau) d\tau ]接下来就是那个trick了。因为 (\nabla_\theta P(\tau|\theta) P(\tau|\theta) \nabla_\theta \log P(\tau|\theta))所以[ \nabla_\theta J(\theta) \mathbb{E}{\tau \sim \pi\theta} \left[ \nabla_\theta \log P(\tau|\theta) R(\tau) \right] ]再把 (\log P(\tau|\theta)) 展开环境转移概率那一项跟 (\theta) 无关求导直接变零最后只剩下[ \nabla_\theta J(\theta) \mathbb{E}{\tau \sim \pi\theta} \left[ \left( \sum_{t0}^{T} \nabla_\theta \log \pi_\theta(a_t|s_t) \right) \cdot R(\tau) \right] ]这就是REINFORCE算法的核心公式。它翻译成人话就是采样几条轨迹如果某条轨迹最终回报高就把这条轨迹上所有动作的概率往上调如果回报低就往下压。(R(\tau)) 相当于给“动作序列”打分(\log) 则把概率乘积变成了求和方便梯度传播。2.3 为什么是log而不是别的初学者常会问我直接用 (P(\tau|\theta)) 的梯度不行吗非要绕个log原因有两点。第一log把乘积展开为求和数学上处理起来简单得多梯度也能一路回传到每个时间步的动作概率上第二(\nabla_\theta \log \pi_\theta(a|s)) 有个统计学习上的名字叫“得分函数”score function它有一个漂亮的性质策略参数往哪个方向变化会让当前动作的对数概率增加最大。这个方向乘上回报 (R(\tau))就成了参数更新的方向。用生活里的例子打比方你是一支球队的教练策略就是阵容安排。你试了几套阵容赢了就记下“这套阵容好”下次更倾向用输了就记下“不好”下次少用。(\log) 技巧就是帮你精确算出“某套阵容里某个球员在场与否对胜率影响多大”的公式。3. 从公式到代码用PyTorch实现一个能跑的REINFORCE3.1 算法整体流程理论再漂亮跑不起来就是零。REINFORCE的流程非常朴素一共就四步用当前策略 (\pi_\theta) 在环境里采样收集一批完整轨迹。计算每条轨迹的总回报如果有折扣因子 (\gamma)就逐时间步打折。对每个时间步计算损失 (-\log \pi_\theta(a_t|s_t) \times G_t)其中 (G_t) 是从t时刻起的折扣回报。对损失做反向传播梯度上升更新参数。注意第3步那个负号。因为PyTorch的优化器默认是做梯度下降最小化loss我们要的是让高回报动作概率更大也就是最大化对数概率所以取个负号翻过来。3.2 完整可运行的CartPole示例直接上一份精简但能跑的代码环境用CartPole-v1策略网络就两层MLPimport gym import torch import torch.nn as nn import torch.optim as optim import numpy as np class PolicyNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim128): super().__init__() self.fc nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim), nn.Softmax(dim-1) ) def forward(self, state): return self.fc(state) def compute_returns(rewards, gamma0.99): 计算折扣回报 G_t returns [] G 0.0 for r in reversed(rewards): G r gamma * G returns.insert(0, G) return returns def train(): env gym.make(CartPole-v1) policy PolicyNetwork(4, 2) optimizer optim.Adam(policy.parameters(), lr1e-3) for episode in range(1000): state env.reset() log_probs [] rewards [] done False while not done: state_tensor torch.FloatTensor(state) probs policy(state_tensor) dist torch.distributions.Categorical(probs) action dist.sample() log_probs.append(dist.log_prob(action)) next_state, reward, done, _ env.step(action.item()) rewards.append(reward) state next_state # 计算折扣回报并构造损失 returns compute_returns(rewards) returns_tensor torch.FloatTensor(returns) # 关键一行损失 -log_prob * return loss [] for log_prob, G in zip(log_probs, returns_tensor): loss.append(-log_prob * G) loss torch.stack(loss).sum() optimizer.zero_grad() loss.backward() optimizer.step() if episode % 50 0: print(fEpisode {episode}, Total Reward: {sum(rewards):.0f}) env.close() if __name__ __main__: train()这段代码如果在CartPole上跑一般几百个episode就能稳定在200分满分附近。但注意CartPole是个过于简单的环境收敛得“太顺利”反而容易让人误以为REINFORCE很好调。换个难点的环境坑马上就来了。3.3 损失函数的写法细节上面代码里有一个很容易被忽视但很重要的细节损失是对一条轨迹内所有时间步的 (-\log \pi_\theta(a_t|s_t) \cdot G_t) 求和。理论上每次更新也可以只用整条轨迹的总回报乘上所有log prob的和即[ -\left( \sum_t \log \pi_\theta(a_t|s_t) \right) \cdot R(\tau) ]两种写法在期望上等价但逐时间步用 (G_t) 的好处是方差更小。因为早中期的动作只对未来一部分回报负责没必要为它还没经历过的遥远未来背锅。这在数学上对应的就是策略梯度定理的“奖励因果性”变体。做实验时如果发现收敛慢检查一下是不是把 (G_t) 错写成了全局总回报 (R(\tau))这个细节值得单独拿出来说。4. 策略梯度的高方差问题Baseline与Actor-Critic4.1 方差为什么会高到“训练崩盘”REINFORCE有一个非常恼人的特点方差极大。你想想它做了什么事——它完全靠采样的几条轨迹去估计期望梯度。如果初始策略很差所有采出来的轨迹回报都很低那网络相当于“被罚了一顿”但如果某条轨迹碰巧走了狗屎运回报稍微高一点网络又会猛地把那批动作概率抬上去。这种噪声主导的更新训练曲线看起来就像心电图一上一下完全没规律。我自己第一次在Pendulum环境上跑REINFORCE的经历是连续跑了2000个episode平均回报一直在一个很低的区间震荡无论如何调学习率都救不回来。后来才意识到不是代码写错了而是这个方法在不给任何“方差削减”手段时本质就长这样。4.2 Baseline的数学直觉和推导应对高方差的标准手段是引入一个baseline (b(s))把更新规则改成[ \nabla_\theta J(\theta) \mathbb{E} \left[ \left( \sum_t \nabla_\theta \log \pi_\theta(a_t|s_t) \right) \cdot \left( R(\tau) - b(s_t) \right) \right] ]关键是这个减号不影响期望。原因很优雅如果 (b(s_t)) 只依赖状态、不依赖动作那么[ \mathbb{E}{a_t \sim \pi\theta} \left[ \nabla_\theta \log \pi_\theta(a_t|s_t) \cdot b(s_t) \right] b(s_t) \cdot \mathbb{E}{a_t} \left[ \nabla\theta \log \pi_\theta(a_t|s_t) \right] 0 ]因为 (\sum_a \nabla_\theta \pi_\theta(a|s) \nabla_\theta \sum_a \pi_\theta(a|s) \nabla_\theta 1 0)这是得分函数的基础性质。那baseline为什么能降方差因为它把“原始回报”换成了“这个回报比平均期望好多少”。(R(\tau) - b(s_t)) 的正负号才真正决定动作是被增强还是被削弱。如果一条轨迹总回报是100但某个状态上平均水平只有10那这个状态里的动作其实获得了“额外惊喜90”应该被强烈增强反之某个状态平均就有1000你在里面只拿到100其实是差劲的但REINFORCE原始版本会把100当成正信号去增强动作这就错了。最简单的baseline是全轨迹回报的均值。实现上就是把return减去mean(return)几行代码的事但方差能肉眼可见地降一截。更进阶的做法是用一个单独网络去拟合状态值函数 (V(s))这就是Actor-Critic的雏形——一个网络负责出动作Actor一个网络负责打分Critic。# 简易baseline版本在compute_returns之后 returns torch.FloatTensor(returns) returns (returns - returns.mean()) / (returns.std() 1e-9)这里的std归一化不是数学上必须的但实操中强烈建议加它能把不同量级环境里的回报拉到接近尺度大幅提升训练稳定性。后续A2CAdvantage Actor-Critic就是把baseline从“均值”升级为“神经网络拟合的V(s)”优势函数 (A(s,a) Q(s,a) - V(s)) 和这里的 (R(\tau) - b(s)) 在思想上完全一脉相承。4.3 方差削减的另一个手段多轨迹平均还有一个小技巧是batch训练。不要每个episode都立刻更新而是攒一批轨迹算完平均梯度再更新。这样梯度估计的噪声也更小。我在实验里常用的做法是每次采样20到50条轨迹用它们的综合梯度做一次更新。这个技巧不太起眼但对稳定性提升非常显著。如果环境允许并行采样收益更大。5. 训练稳定性和调试经验那些调参文档里不会写的事5.1 收敛慢不是bug是策略梯度的“原罪”策略梯度方法收敛慢是出了名的原因在于它的梯度估计是on-policy的你用当前策略采样更新完策略后这批数据立刻作废。下一轮更新又得重新采样。这种“用一次扔一次”的数据效率和DQN的experience replay比起来简直是奢侈。所以不要指望REINFORCE能在复杂环境上快速出结果它的定位是“原理清晰、实现简单、适合验证想法”。真要高效解决实际问题还是得往PPO、SAC这类进阶算法走但那些算法的核心组件——策略网络、advantage估计、baseline思想——全都是在策略梯度这里打的地基。5.2 学习率和随机种子两个“翻车高发区”我在调REINFORCE时摔过最大的跟头一个是学习率一个是随机种子。学习率的问题在于策略梯度的loss尺度随轨迹长度和回报量级剧烈变化。CartPole里回报最多200Pendulum里回报可能是-1000量级同样的学习率在两个环境下表现天差地别。建议做法是先不开训练纯采样几条轨迹打印一下 (-\log \pi \cdot G) 的典型量级然后让学习率乘以这个量级的倒数作为起点。或者干脆把回报归一化就是上一节那个mean/std操作这样学习率选择会鲁棒很多。随机种子——很多人不屑一提但我还是要说REINFORCE这么高方差的方法换一个种子可能就从“学得动”变成“完全学不动”。同一份代码种子0可能500episode收敛种子42可能2000episode还是一条水平线。这不是你代码有bug是算法方差大。唯一的对策是多跑几个种子取中位数别拿单次实验的结果下结论。5.3 诊断工具画“动作熵”曲线而不是只盯回报回报曲线毛刺多很难判断模型是否真在学。我后来养成了一个习惯同时在训练时记录策略分布的熵。熵的大小表示策略的随机程度一开始熵应该比较高探索随着训练进行应该逐渐下降策略逐渐明确。如果熵下降太快说明策略过早确定性了容易陷入次优如果熵几乎不变说明梯度信号太弱参数根本没被推动。这个诊断手段看着简单实际帮我在好几个项目里省了几天的排查时间。具体实现也简单每次采样动作时顺便算一下dist.entropy()存下来画曲线就行。如果发现熵过早上到0可以降低学习率或增大探索噪声如果发现熵不掉检查baseline和回报归一化是不是漏了。5.4 一个容易被忽略的细节reward shaping要谨慎策略梯度对奖励函数非常敏感因为它是直接拿回报当梯度的“权重”。我见过有人为了让训练快点给中间过程加了一堆辅助奖励结果策略被带偏出现了“刷分行为”——在辅助奖励高的地方反复横跳主目标反而没推进。这是reward shaping的典型陷阱。如果你想加辅助奖励请一定保证它能被主目标覆盖或者使用基于势能的塑形potential-based shaping那样理论上不改变最优策略。这个细节在DQN里也有影响但由于策略梯度的“直接优化策略”特性跑偏起来更严重。6. 从REINFORCE走向进阶策略梯度的后续延展6.1 减方差家族的脉络如果你理解了策略梯度的核心痛点——高方差、样本效率低——后面读任何进阶算法都会顺畅很多。整个现代深度强化学习的发展有很大一条主线就是围绕“如何在保留策略梯度直接优化策略这一优点的同时把方差降下来”。REINFORCE with baseline减去了状态相关的baseline降方差的第一小步。Actor-Critic (A2C/A3C)用价值网络替代baseline同时用TD误差做advantage估计摆脱了“必须等一条完整轨迹才能更新”的限制实现单步更新。TRPO/PPO在更新参数时限制更新幅度防止策略一步迈太大直接崩坏本质上还是在解决“梯度噪声导致的更新不稳”。DDPG/TD3/SAC把策略梯度思想移植到连续控制领域用确定性策略 off-policy的方式极大提升样本效率。看所有这些算法都没离开策略梯度定理这个根只是不断在“回报估计”和“更新策略”这两个环节上做文章。6.2 一个我实践中的选型建议如果你的任务满足“动作空间离散且不大、环境可以快速采样、目标是验证一个想法”直接用REINFORCE加baseline就够了简单可靠好调试。如果动作空间连续或者环境采样很贵比如真实机器人、仿真耗时高就别在REINFORCE上死磕了直接上PPO或SAC。PPO的代码量也不算大但稳定性比REINFORCE高一个数量级。我自己在写研究原型时经常先用REINFORCE做“可行性验证”——因为一旦这个算法在简单环境上能学起来说明我的环境和奖励设计是合理的再换成PPO去调优排查范围会小很多。关于策略梯度其实还能讲很多比如带基线的完整推导、GAE广义优势估计的计算细节、策略熵正则的数学解释这些留到系列后续再展开。这次先把最核心的“策略梯度是什么、为什么有效、怎么写代码、怎么调试”讲清楚。你在跑REINFORCE时如果遇到训练曲线诡异、loss爆炸或者怎么调都不收敛欢迎带着具体现象来交流大概率是上面提到那几处细节中的一个。