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的完整流程画出来了。从采样到更新,每一步都清楚:

REINFORCE算法核心流程 环境 Environment 策略网络 πθ(a|s) 采样动作 a ~ πθ 存储 (s, a, r, logπ) 计算 Gt = ∑γkrt+k 状态 s 梯度更新 θ ← θ + α∇J 动作 a 关键点 • 整条轨迹采样完成后才更新 • 回报 Gt 是蒙特卡洛估计 • 梯度 = logπ · G(无偏但有高方差)

3.8 实践中的避坑指南

代码写完了,图也画了。但真正跑起来,你可能会遇到一堆问题。我踩过的坑,列出来给你参考:

  • 回报爆炸:如果任务没有终止条件(continuing task),Gt会一直累加。解决办法:设一个最大步数,或者用折扣因子让远期的回报衰减。
  • 梯度消失/爆炸:log概率的值域是(-∞, 0],乘上Gt后可能很大。我习惯在更新前对梯度做裁剪(gradient clipping)。
  • 探索不足:策略一旦收敛到某个动作,其他动作的概率会趋近于0。可以加熵正则项,鼓励探索。
  • 训练不稳定:同一个任务,跑两次结果差很多。这是蒙特卡洛采样的方差导致的。多跑几个seed取平均,或者增大batch size。

我曾经踩过的一个大坑:在连续控制任务里,我直接用REINFORCE输出动作的均值,方差固定。结果策略很快坍缩到一条直线——因为方差太小,探索没了。后来改成让网络同时输出均值和方差,并且用softplus保证方差为正,问题才解决。

嗯,REINFORCE的内容就这些。它简单、直观,是理解策略梯度方法的基石。虽然现在工业界很少直接用REINFORCE(方差太大),但它的思想贯穿了几乎所有现代策略梯度算法。把这个搞透,后面学PPO、SAC会轻松很多。