如果你已经玩过几轮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_{t=0}^{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_{t=0}^{T} \pi_\theta(a_t|s_t) P(s_{t+1}|s_t,a_t) ]
其中 (\rho(s_0)) 是初始状态分布,(P(s_{t+1}|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_{t=0}^{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实现一个能跑的REINFORCE
3.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,策略网络就两层MLP:
import 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_dim=128): 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, gamma=0.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(), lr=1e-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(f'Episode {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-Critic
4.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归一化不是数学上必须的,但实操中强烈建议加,它能把不同量级环境里的回报拉到接近尺度,大幅提升训练稳定性。后续A2C(Advantage 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里回报最多200,Pendulum里回报可能是-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爆炸或者怎么调都不收敛,欢迎带着具体现象来交流,大概率是上面提到那几处细节中的一个。