3. 策略梯度方法入门:REINFORCE算法原理与实现
策略梯度方法,说白了就是直接对策略本身动手。你想想看,值函数方法绕了个弯——先估Q值,再根据Q值选动作。那为什么不直接优化策略参数呢?REINFORCE就是这类方法里最基础、最直觉的一个。
我个人习惯把REINFORCE当作策略梯度入门的“Hello World”。它简单,但核心思想一点不少。搞懂了它,后面那些PPO、TRPO理解起来就顺多了。
3.1 从值函数到策略梯度的思维转变
之前我们做Q-learning,本质上是“先评估,再改进”。策略梯度换了个思路:直接让好动作出现的概率变大,坏动作的概率变小。
为什么会这样?因为很多场景下,值函数并不好估。动作空间连续怎么办?状态空间太大怎么办?策略梯度方法天然适合这些问题。
核心思想:参数化策略 πθ(a|s),用梯度上升最大化期望累积奖励。
目标函数:J(θ) = Eτ~πθ[R(τ)]
梯度:∇J(θ) = Eτ[∑∇log πθ(at|st) · Gt]
这个公式看着有点吓人,其实意思很简单:用整条轨迹的回报Gt来加权当前动作的对数概率梯度。回报高,这个动作的梯度方向就往上推;回报低,就往下拉。
3.2 REINFORCE算法的完整推导
咱们一步步来。先明确几个东西:
- 轨迹 τ = (s0, a0, r1, s1, a1, r2, ..., sT)
- 累积回报 Gt = ∑k=0T-t γk rt+k+1
- 策略 πθ(a|s) 是神经网络,输出动作概率分布
目标是对 J(θ) 求梯度。经过一番推导(这里不展开所有数学细节),得到:
∇J(θ) = E[ ∑ (∇log π(a|s) · G) ]
这个期望怎么算?蒙特卡洛采样。跑N条轨迹,取平均。这就是REINFORCE的全部秘密。
我的经验:刚开始学的时候,我总纠结“为什么是log而不是别的”。后来想通了——log把乘法变成了加法,求导方便,而且 ∇log π = ∇π/π,天然做了“除以概率”的归一化。高概率动作的梯度不会被过分放大。
3.3 算法伪代码与实现细节
直接上伪代码,这个流程我调过不下百次:
初始化策略网络参数 θ
设置学习率 α, 折扣因子 γ
for episode in range(max_episodes):
采样一条轨迹: s0,a0,r1,s1,a1,r2,...,sT
# 计算每个时间步的回报
G = 0
for t from T-1 down to 0:
G = r_{t+1} + γ * G
存储 G_t = G
# 更新策略
for t from 0 to T-1:
计算 ∇log π(a_t|s_t) · G_t
累加梯度
θ = θ + α * 平均梯度
注意一个关键点:回报Gt是从t时刻到结束的累积奖励。这意味着后面的动作会影响前面的梯度更新——这其实不太合理,但REINFORCE就是这么干的。
3.4 方差问题与基线技巧
REINFORCE有个大毛病:方差太高。为什么?
你想想看,每次采样的轨迹都不一样。同样的动作,这次回报高,下次回报低。梯度方向忽左忽右,训练起来像喝醉了酒。
解决办法?加基线(baseline)。
带基线的REINFORCE梯度:
∇J(θ) = E[∑∇log π(a|s) · (G - b(s))]
其中 b(s) 是状态s的基线,通常用值函数 V(s) 近似。
加基线不会改变梯度的期望(可以证明),但能大幅降低方差。我做过对比实验:不加基线,2000个episode还不收敛;加了基线,500个episode就稳了。
避坑指南:我曾经在基线网络的学习率上栽过跟头。基线网络(V网络)的学习率要比策略网络小一个数量级,否则基线学得太快,把梯度信号都“吸走”了,策略反而学不动。
3.5 完整代码实现(PyTorch)
下面是我常用的REINFORCE实现模板。代码不长,但该有的都有:
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=128):
super().__init__()
self.fc = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, x):
logits = self.fc(x)
return torch.softmax(logits, dim=-1)
class REINFORCE:
def __init__(self, state_dim, action_dim, lr=1e-3, gamma=0.99):
self.policy = PolicyNetwork(state_dim, action_dim)
self.optimizer = optim.Adam(self.policy.parameters(), lr=lr)
self.gamma = gamma
self.log_probs = []
self.rewards = []
def select_action(self, state):
state = torch.FloatTensor(state).unsqueeze(0)
probs = self.policy(state)
dist = torch.distributions.Categorical(probs)
action = dist.sample()
self.log_probs.append(dist.log_prob(action))
return action.item()
def store_reward(self, reward):
self.rewards.append(reward)
def update(self):
# 计算折扣回报
G = 0
returns = []
for r in reversed(self.rewards):
G = r + self.gamma * G
returns.insert(0, G)
returns = torch.tensor(returns)
# 标准化回报(降低方差的小技巧)
returns = (returns - returns.mean()) / (returns.std() + 1e-8)
# 计算损失并更新
loss = []
for log_prob, G in zip(self.log_probs, returns):
loss.append(-log_prob * G) # 负号因为我们要梯度上升
self.optimizer.zero_grad()
loss = torch.cat(loss).sum()
loss.backward()
self.optimizer.step()
# 清空缓存
self.log_probs.clear()
self.rewards.clear()
注意这里:我把回报做了标准化。这不是REINFORCE理论要求的,但实践中几乎必加。标准化后的回报均值为0、方差为1,梯度更新更稳定。我试过不标准化,训练曲线抖得像心电图。
3.6 算法对比:REINFORCE vs 其他方法
| 特性 | REINFORCE | Q-Learning | Actor-Critic |
|---|---|---|---|
| 策略表示 | 显式(概率分布) | 隐式(通过Q值) | 显式 |
| 适用动作空间 | 离散/连续 | 离散为主 | 离散/连续 |
| 方差 | 高 | 低 | 中等 |
| 偏差 | 无偏 | 有偏(max操作) | 有偏(bootstrapping) |
| 样本效率 | 低 | 高 | 中等 |
| 收敛性 | 可能收敛到局部最优 | 可能发散 | 较稳定 |
这张表我建议你存一下。选算法的时候,先看动作空间,再看你对样本效率的要求。如果数据便宜、动作连续,REINFORCE是个不错的起点。
3.7 知识体系图:REINFORCE的核心逻辑
下面这张图把REINFORCE的完整流程画出来了。从采样到更新,每一步都清楚:
3.8 实践中的避坑指南
代码写完了,图也画了。但真正跑起来,你可能会遇到一堆问题。我踩过的坑,列出来给你参考:
- 回报爆炸:如果任务没有终止条件(continuing task),Gt会一直累加。解决办法:设一个最大步数,或者用折扣因子让远期的回报衰减。
- 梯度消失/爆炸:log概率的值域是(-∞, 0],乘上Gt后可能很大。我习惯在更新前对梯度做裁剪(gradient clipping)。
- 探索不足:策略一旦收敛到某个动作,其他动作的概率会趋近于0。可以加熵正则项,鼓励探索。
- 训练不稳定:同一个任务,跑两次结果差很多。这是蒙特卡洛采样的方差导致的。多跑几个seed取平均,或者增大batch size。
我曾经踩过的一个大坑:在连续控制任务里,我直接用REINFORCE输出动作的均值,方差固定。结果策略很快坍缩到一条直线——因为方差太小,探索没了。后来改成让网络同时输出均值和方差,并且用softplus保证方差为正,问题才解决。
嗯,REINFORCE的内容就这些。它简单、直观,是理解策略梯度方法的基石。虽然现在工业界很少直接用REINFORCE(方差太大),但它的思想贯穿了几乎所有现代策略梯度算法。把这个搞透,后面学PPO、SAC会轻松很多。