☰
策略梯度从推导到实战:对数导数技巧、方差削减与手写实现
2026/10/27 2:50:18 网站建设 项目流程

策略梯度这玩意儿,我头一回认真推它是在做一个连续控制的小项目时被逼的。当时用现成的库跑得好好的,结果换了个动作空间,采样效率直接崩了,日志里全是方差大到没法看的梯度范数。那一刻我才意识到,光会调包不行,得把策略梯度这条线从头到尾捋一遍——它到底在优化什么、梯度是怎么从期望里“漏”出来的、为什么同样是策略梯度,有的实现稳如老狗,有的跑两步就发散。这篇就把我推导和踩坑的过程完整摊开,从目标函数一路推到实操里那些不能踩的细节,适合已经懂点强化学习基础、但一看到“对数导数技巧”就犯迷糊的朋友,也适合想自己手写一版策略梯度、不想被框架黑盒糊弄过去的人。

1. 先把目标函数摆正:策略梯度到底在优化什么

1.1 从“回报”到“期望回报”的那一步转换

很多人推策略梯度卡壳,不是卡在数学,而是卡在没想清楚优化对象是谁。我们手里有一个策略网络,参数记作 θ,它输出的是动作分布 π(a|s; θ)。注意这里的关键:策略不是一个确定函数,而是一个分布。你给它一个状态,它给你的是“在这个状态下各个动作被选中的概率”。这个设定决定了后面所有的推导逻辑。

那我们要优化的东西是什么?是期望回报,记作 J(θ)。一条轨迹 τ 是从策略里采样出来的,轨迹的回报记作 R(τ),那么 J(θ) = E_{τ~π_θ}[R(τ)]。这里有个特别容易被忽略的点:期望是对轨迹分布求的,而轨迹分布又依赖 θ。也就是说,你改一点 θ,采样出来的轨迹分布就变了,回报的期望也跟着变。这跟普通监督学习里“数据固定、只改模型”完全不是一回事。

我见过不少人一上来就想对 R(τ) 求导,然后发现 R(τ) 里根本没有 θ,导数为零,直接懵掉。问题就出在这——θ 不在回报里,θ 在采样分布里。所以策略梯度的本质,不是“让回报函数对参数可导”,而是“让期望这个算子对参数可导”。这个视角一换,后面的对数导数技巧就顺理成章了。

1.2 为什么不能直接对期望求导:一个生活化的类比

你可以把这件事想成“调整一个抽奖机的内部配重”。抽奖机每次吐出的奖品价值是固定的(对应 R(τ)),但吐哪个奖品由内部配重决定(对应 π_θ)。你想提高平均收益,能动的只有配重,奖品价值本身你改不了。所以你要算的是“配重微调后,平均收益怎么变”,而不是“奖品价值怎么变”。

数学上,J(θ) = ∫ R(τ) p_θ(τ) dτ,其中 p_θ(τ) 是轨迹分布。对 θ 求导,R(τ) 是常数可以不动,但 p_θ(τ) 要动,于是得到 ∇J = ∫ R(τ) ∇p_θ(τ) dτ。到这一步其实已经能算了,但 ∇p_θ(τ) 这个东西没法直接采样估计,因为它不是一个概率密度,积分不为 1。这就是为什么需要下一步的“对数导数技巧”把它变回一个期望的形式。

提示:推导时一定要盯住“谁是变量、谁在期望里面”。θ 只通过 p_θ(τ) 影响结果,这个认知是整条推导链的地基,地基歪了后面全歪。

1.3 轨迹分布怎么拆:马尔可夫性在这里登场

要把 p_θ(τ) 写开,得用轨迹的定义。一条轨迹 τ = (s_0, a_0, s_1, a_1, ..., s_T, a_T),它的概率可以拆成:

p_θ(τ) = ρ(s_0) · ∏_{t=0}^{T} π_θ(a_t|s_t) · P(s_{t+1}|s_t, a_t)

这里 ρ(s_0) 是初始状态分布,P 是环境的状态转移概率。关键观察:环境转移 P 和初始分布 ρ 都不含 θ,只有策略项 π_θ 含 θ。这个观察直接决定了后面求导时哪些项会消失。我第一次推的时候没注意这点,把 P 也一起求导了,结果多出一堆根本没法算的项,白折腾半天。

所以对 log p_θ(τ) 求 θ 的梯度时,ρ 和 P 的项全部归零,只剩下 ∑_t ∇log π_θ(a_t|s_t)。这一步是整个推导里最“爽”的地方——环境 dynamics 未知也没关系,因为它压根不参与梯度。这也是策略梯度能做成**无模型(model-free)**方法的根本原因。你不需要知道环境怎么转移,只需要能采样、能算策略的 log 概率梯度就行。

2. 对数导数技巧:把“不可采样”变成“可采样”

2.1 那个恒等式的来龙去脉

对数导数技巧的核心就一个恒等式:∇p = p · ∇log p。它成立是因为 ∇log p = ∇p / p,两边乘 p 就回去了。看着简单,但它是把“对概率密度求导”转成“对 log 概率求导再乘回概率”的桥梁,而这个桥梁的意义在于:乘回 p 之后,整个式子重新变成了一个期望。

具体走一遍:∇J = ∫ R(τ) ∇p_θ(τ) dτ = ∫ R(τ) p_θ(τ) ∇log p_θ(τ) dτ = E_{τ~π_θ}[R(τ) ∇log p_θ(τ)]。到这一步,右边就是一个可以拿采样去估的期望了——你采样 N 条轨迹,每条算一下 R(τ)∇log p_θ(τ),平均一下就是梯度的估计。这就是策略梯度最原始的形态,业内常叫它 REINFORCE 的雏形。

我特别想强调这个转换的“动机”:不是因为 ∇log p 好算(虽然它确实好算),而是因为只有变成期望形式,我们才能用蒙特卡洛采样去估。强化学习里绝大多数梯度估计的推导,本质都是在做“把不可采样量改写成可采样期望”这件事。理解了这个动机,你再看后面那些变体(带 baseline 的、带优势函数的),就不会觉得是天上掉下来的公式,而是“为了降方差做的等价改写”。

2.2 从轨迹级到单步级:把 R(τ) 拆开

上面那个式子里 R(τ) 是整条轨迹的回报,∇log p_θ(τ) 是整条轨迹的 log 概率梯度。实际实现时我们不会真的去算整条轨迹的联合概率,而是利用 1.3 的拆分结果:

∇log p_θ(τ) = ∑_{t=0}^{T} ∇log π_θ(a_t|s_t)

代回去,∇J = E[ ∑_t R(τ) ∇log π_θ(a_t|s_t) ]。这时候有个非常重要的化简:在 t 时刻的动作 a_t 只影响 t 之后的回报,t 之前的回报跟 a_t 无关(因为那些回报在 a_t 被选之前就已经确定了)。所以 R(τ) 可以换成从 t 时刻开始的回报,记作 G_t = ∑_{t'=t}^{T} γ^{t'-t} r_{t'}。这个替换不是近似,是严格等价的,因为前面那些项的期望在求梯度时为零(可以严格证明,直觉上就是“过去的事跟现在的动作没关系”)。

于是得到最常用的形式:∇J = E[ ∑_t G_t ∇log π_θ(a_t|s_t) ]。这个形式的好处是每一项只依赖单步的 log 概率梯度和从该步往后的回报,实现起来非常直接。我手写第一版的时候就是照这个式子来的,一个 for 循环遍历时间步,累加 G_t 乘 ∇log π,跑通那一刻确实有点小激动。

2.3 一个必须讲清楚的细节:γ 折扣放在哪

折扣因子 γ 的位置经常被搞混。G_t 里带 γ 是对的,但要注意它是在“回报”里做折扣,不是在“梯度”里做折扣。有些人会把 γ^t 乘到 ∇log π 前面,那是另一种等价写法(把折扣吸收进梯度权重),两种写法在数学上一致,但实现时容易和 G_t 里的折扣重复计算。我的建议是:统一在 G_t 里算折扣,梯度项前面不要再乘 γ 的幂,这样最不容易出错。实测下来,重复折扣是新手最常见的 bug 之一,表现为学习曲线明显偏慢但又不完全崩,很难一眼看出来。

3. 方差之痛:为什么原始策略梯度几乎不能直接用

3.1 方差从哪来:三个叠加的噪声源

原始 REINFORCE 的梯度估计是无偏的,但方差大得离谱。我拆过一下,噪声至少来自三处。第一是轨迹采样本身的随机性,同一个策略跑两次,回报可能差很多;第二是回报的量级,如果回报是几十上百,乘到 ∇log π 上,梯度范数就很大;第三是动作采样的随机性,log 概率梯度本身也带噪声。三者叠加,导致梯度估计的方差可能比均值还大,更新方向基本靠运气。

这就解释了一个现象:为什么原始策略梯度在小环境(比如倒立摆)上能跑,一到稍微复杂点的任务就学不动。不是算法错了,是信噪比太低。我当时的日志里,梯度范数在 0.1 到 50 之间乱跳,参数更新像喝醉了走路。

3.2 基线(baseline):减掉一个不改变期望的量

降方差最经典的手段是引入 baseline b(s)。核心恒等式是:E_{a~π}[b(s) ∇log π_θ(a|s)] = b(s) ∇∑_a π_θ(a|s) = b(s) ∇1 = 0。也就是说,任何只依赖状态、不依赖动作的量,乘上 ∇log π 后的期望都是零。所以你从回报里减掉它,期望不变(还是无偏),但方差可能大幅下降。

那 b(s) 选什么?最常用的是状态价值函数 V(s),因为它近似了“在这个状态下平均能拿多少回报”。减掉它之后,剩下的量 G_t - V(s_t) 就是“实际回报比预期好多少”,这个量的波动明显更小。直觉上:原来你是拿“绝对收益”去加权梯度,现在拿“超出预期的部分”去加权,好事就加强、坏事就削弱,方向更明确。

实现上,V(s) 通常用一个单独的网络去拟合,和策略网络一起训练。这里有个实操坑:baseline 网络不能更新太猛,否则它自己抖动会反过来污染策略梯度。我一般给 baseline 的学习率设得比策略网络小一点,或者用 target 网络的思路做软更新,稳很多。

3.3 优势函数与 GAE:把“超出预期”算得更精细

G_t - V(s_t) 其实就是优势函数 A(s_t, a_t) 的一个蒙特卡洛估计。但它还是用了整条轨迹的回报,方差依然不小。更进一步的做法是用时序差分(TD)来估优势,牺牲一点偏差换大幅降方差。GAE(广义优势估计)就是在这条谱系上做插值,用一个参数 λ 控制“偏蒙特卡洛”还是“偏 TD”。

GAE 的递推式是:δ_t = r_t + γV(s_{t+1}) - V(s_t),然后 A_t = δ_t + γλ δ_{t+1} + (γλ)^2 δ_{t+2} + ...。λ=0 时退化成单步 TD 优势(偏差大方差小),λ=1 时退化成蒙特卡洛优势(无偏但方差大)。实践中 λ 常取 0.95 左右,是个挺稳的折中。我第一次用 GAE 时把 λ 设成 1,结果和原始 REINFORCE 差不多抖,后来调到 0.95 才体会到它的好处。这个参数没有普适最优,得看任务回报的稀疏程度,回报越稀疏越该往 1 靠。

方案偏差方差适用场景
原始回报 G_t无偏很大极简环境、教学演示
G_t - V(s)无偏中等通用,需额外价值网络
GAE(λ=0.95)小偏差小连续控制、多数实际任务
单步 TD 优势偏差较大最小回报密集、episode 很长

4. 手写一版策略梯度:从公式到能跑的代码

4.1 整体结构:三个网络还是一张表

先说清楚要准备什么。策略网络输出动作分布参数(离散动作就是各类 logits,连续动作就是高斯分布的均值和方差),价值网络输出 V(s) 用于 baseline,再加上一个采样和回报计算的循环。如果是简单环境,策略和价值可以用共享躯干加两个头的结构,省参数也省算力;如果任务复杂,分开建更灵活。我一般先用分开的版本调通,再考虑共享。

下面给一个离散动作的最小实现骨架,用 PyTorch 风格写,重点是把推导里的每一项对应到代码的哪一行,这样你调试时知道该看哪。

import torch import torch.nn as nn class PolicyNet(nn.Module): def __init__(self, state_dim, action_dim, hidden=64): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), nn.Linear(hidden, action_dim) ) def forward(self, s): return self.net(s) # 输出 logits class ValueNet(nn.Module): def __init__(self, state_dim, hidden=64): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), nn.Linear(hidden, 1) ) def forward(self, s): return self.net(s).squeeze(-1)

策略网络最后一层不加 softmax,因为 PyTorch 的交叉熵类损失内部会做 log_softmax,数值更稳。这一点很多人第一次写会踩坑:自己先 softmax 再取 log,遇到概率接近 0 时直接 log(0) 变 -inf,训练瞬间 NaN。

4.2 采样与回报计算:别在这里埋雷

采样循环里要做三件事:按策略分布选动作、记录 log 概率、记录奖励。这里有个细节:log 概率要在采样时就算好并 detach 存下来,不要等到更新时再重新前向一遍去算,因为那时候参数可能已经变了(虽然单步更新里通常没变,但养成习惯更安全)。回报计算用倒序累加,注意折扣的幂次。

def collect_episode(env, policy, gamma=0.99): log_probs, rewards, states = [], [], [] s = env.reset() done = False while not done: s_t = torch.as_tensor(s, dtype=torch.float32) logits = policy(s_t) dist = torch.distributions.Categorical(logits=logits) a = dist.sample() log_probs.append(dist.log_prob(a)) states.append(s_t) s, r, done, _ = env.step(a.item()) rewards.append(r) # 倒序算回报 returns = [] G = 0.0 for r in reversed(rewards): G = r + gamma * G returns.append(G) returns.reverse() return log_probs, returns, states

倒序累加这段看着简单,但顺序搞反是高频 bug。我见过有人正序累加,结果 G_t 变成了“从开始到现在的累计”,梯度方向完全错,学出来的策略比随机还差。判断方法很简单:打印第一条轨迹的 returns,它应该是单调不增的(因为越靠后的步骤剩余回报越少,除非奖励有负值),如果单调递增那肯定写反了。

4.3 损失组装:符号和 detach 是两大雷区

策略梯度的损失是-E[ A_t · log π(a_t|s_t) ],注意负号,因为我们做的是梯度上升,而优化器默认做下降。优势 A_t 要 detach,因为它只是权重,不应该反过来对价值网络求梯度(价值网络有自己的损失)。这两点写错,表现是训练完全不收敛或者价值网络被带偏。

def update(policy, value_net, opt_p, opt_v, log_probs, returns, states, gamma=0.99): returns_t = torch.tensor(returns, dtype=torch.float32) states_t = torch.stack(states) values = value_net(states_t) advantages = (returns_t - values).detach() # 关键:detach # 策略损失 policy_loss = -(torch.stack(log_probs) * advantages).mean() # 价值损失 value_loss = nn.functional.mse_loss(values, returns_t) opt_p.zero_grad(); policy_loss.backward(); opt_p.step() opt_v.zero_grad(); value_loss.backward(); opt_v.step()

这里 advantages 用 returns 减 values,是蒙特卡洛版本的 baseline。如果想上 GAE,就把 returns 换成 GAE 算出来的优势,同时价值网络的目标改成 TD 目标。我建议先把这个最简版跑通、看到学习曲线上升,再往上加 GAE,不然出了问题你分不清是 GAE 写错还是基础版就有 bug。

4.4 一次真实的调参记录

拿一个连续动作的小车平衡任务练手,第一版用原始回报、无 baseline,学习率 1e-3,跑了 200 个 episode 回报还在 20 上下晃(满分 200)。日志显示梯度范数波动极大。加上价值网络做 baseline 后,同样学习率,80 个 episode 左右回报开始稳定爬升。再把优势换成 GAE(λ=0.95),收敛到 180 以上只用了 50 个 episode 左右。这个对比很直观地说明:推导里的每一个“等价改写”,落到实操上都是实打实的效率提升,不是数学游戏。

5. 那些推导不会告诉你、但实操一定会遇到的坑

5.1 梯度爆炸与裁剪:什么时候该裁、裁多少

策略梯度虽然不像 RNN 那样容易梯度爆炸,但在回报量级大、log 概率又很小的时候,乘积依然可能很大。我的经验是:先监控梯度范数,再决定要不要裁。如果范数长期在个位数,不用裁;如果经常飙到几十上百,就加torch.nn.utils.clip_grad_norm_(params, max_norm),max_norm 一般取 0.5 到 1.0。裁太狠(比如 0.1)会让学习变慢,因为有效步长被压得太小。

还有一个隐蔽的坑:log 概率为极小负值时(动作概率接近 0),∇log π 的模会很大,乘上优势后梯度爆炸。这种情况在策略早期探索不足时特别常见。解决办法不是硬裁,而是检查策略输出的 logits 是不是数值范围太宽,必要时对 logits 做缩放或者加一点熵正则鼓励探索,从源头降低“极端低概率动作被采到”的概率。

5.2 熵正则:不是为了好玩,是为了别过早收敛

策略梯度有个天然倾向:一旦某个动作稍微好一点,它的概率就被推高,其他动作概率被压低,探索迅速减少,最后卡在局部最优。熵正则就是在损失里加一项-β · H(π)(β 为正),鼓励策略保持一定的随机性。β 一般从 0.01 起步,任务越需要探索,β 可以适当大一点。

但 β 不是越大越好。我试过 β=0.1,结果策略一直很随机,回报上不去,因为“保持随机”的收益盖过了“利用好动作”的收益。判断方法:看训练后期策略的熵,如果一直居高不下,说明 β 太大;如果很早就掉到接近 0,说明 β 太小或者没起作用。理想状态是熵缓慢下降,最后稳定在一个较低但不为零的水平。

5.3 常见问题速查表

现象可能原因排查与解决
回报完全不涨,梯度范数很小学习率太小 / 优势全接近 0调大学习率;检查 baseline 是否拟合过好导致优势被抹平
回报先涨后崩,出现 NaNlog 概率数值不稳 / 梯度爆炸用 log_softmax 替代手动 log;加梯度裁剪;检查回报量级
学习曲线剧烈震荡方差太大 / batch 太小加 baseline、上 GAE;增大每轮采样轨迹数
策略过早确定,探索消失缺熵正则 / 优势估计偏差大加熵正则;检查 GAE 的 λ 是否过大
价值网络 loss 不降学习率不匹配 / 回报尺度问题单独调价值网络学习率;对回报做归一化

5.4 回报归一化:一个便宜又好用的技巧

回报的量级直接影响梯度量级。如果回报在几百的量级,梯度自然大;归一化到均值 0、方差 1 附近,梯度尺度就稳定了。做法很简单:对一个 batch 内的 returns 做标准化,(returns - returns.mean()) / (returns.std() + 1e-8)。这个操作不改变优势的相对大小(因为减常数、除常数对策略梯度期望无影响,前提是减的是与动作无关的量),但能显著稳定训练。我几乎在所有策略梯度实现里都会加这一步,成本几乎为零,收益很明显。

注意:归一化要在算优势之前做,而且 std 加一个极小值防除零。另外如果是多环境并行采样,归一化要跨环境一起做,不能每个环境单独归一化,否则尺度不一致。

6. 从推导到工程:几个值得提前想清楚的设计取舍

6.1 同策略的代价与它的合理性

策略梯度是**同策略(on-policy)**方法:用来估梯度的数据必须来自当前策略。这意味着每更新一次,旧数据就作废了,样本效率天然偏低。很多人第一反应是“能不能把旧数据存起来重复用”,答案是理论上不行,因为期望是对当前策略求的,用旧策略的数据估出来的梯度是有偏的。

但这个“不行”也有缓解空间。实践中常见的做法是:一次采样一批数据,然后在这批数据上做多轮小批量更新(PPO 就是这么干的),用重要性采样比率做修正,把“稍微旧一点”的数据也用起来。不过这就偏离了纯策略梯度,进入了近似策略优化的范畴。我的建议是:先把纯同策略版本跑明白,理解样本效率的瓶颈在哪,再去碰重要性采样那些东西,否则很容易在修正比率的 clip 上栽跟头。

6.2 离散与连续动作空间的实现差异

离散动作空间下,策略输出 logits,用 Categorical 分布采样,log 概率直接可得。连续动作空间下,策略通常输出高斯分布的均值 μ(s) 和标准差 σ(s),动作从 N(μ, σ²) 采样。这里有两个实操要点:σ 要用 softplus 或者 exp 保证为正,而且最好设一个下界,防止 σ 塌缩到 0 导致 log 概率爆炸;μ 的输出范围要和动作空间匹配,必要时用 tanh 缩放。

连续空间里还有个容易忽略的点:多维动作的 log 概率是各维之和,如果各维独立,直接 sum 就行;如果用了相关高斯(带协方差矩阵),那 log 概率的计算和梯度都会复杂不少,一般小任务用对角协方差就够了,别一上来就上满协方差矩阵,参数多、还容易数值不稳。

6.3 监控什么:三个必须盯住的指标

训练策略梯度,光看回报曲线不够,回报有延迟,出问题时往往已经晚了。我一般同时盯三个量:策略熵(看探索是否健康)、梯度范数(看更新是否稳定)、价值网络 loss(看 baseline 是否靠谱)。这三个量任何一个异常,都能提前预警。比如熵突然掉到接近 0 而回报没涨,基本就是过早收敛;价值 loss 突然飙升,说明回报分布变了,baseline 跟不上,这时候策略梯度也会被带偏。

把这些指标打到日志里,配合回报曲线一起看,排查效率比只看回报高一个数量级。我吃过只看回报的亏,一个任务跑了半天回报不涨,回头翻日志才发现熵早就塌了,白跑。

7. 我个人在实际操作中的几点体会

推导这东西,第一遍看懂和真正会用之间隔着一条河。我最大的体会是:每写一行代码,都要能对应回推导里的某一项。比如 advantages 为什么要 detach,因为它在推导里是“权重”不是“被优化的量”;比如 log 概率为什么在采样时存,因为梯度里的 ∇log π 是对当前策略求的。这种“代码—公式”的对应关系建立起来之后,调试就不再是瞎试,而是有方向地定位。

另一个体会是关于“等价改写”的价值。策略梯度这条线上,从原始回报到 baseline 到 GAE,每一步都是数学上等价的改写,但实操效果天差地别。这提醒我:无偏不等于好用,方差才是工程里的头号敌人。以后看到任何“理论上等价”的变体,我都会先问一句“它降方差了吗、代价是什么”,这个习惯帮我省了很多试错时间。

最后分享一个小技巧:如果你推完公式还是觉得虚,就找一个二维的小环境,把策略参数手动设成几个值,采样一批轨迹,手算一下梯度估计,再和代码算出来的对比。数值对上了,理解就真的到位了。我当初就是这么把对数导数技巧彻底搞明白的,比看十遍推导都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询