策略梯度入门实战:从零推导REINFORCE算法
1. 为什么需要策略梯度方法
在强化学习领域,我们最熟悉的可能是基于值函数的方法,比如Q-learning和DQN。这些方法通过估计每个状态-动作对的期望回报来选择最优动作。但我在实际项目中发现,这类方法存在几个明显的局限性:
首先,基于值的方法在处理连续动作空间时非常吃力。想象你要控制一个机械臂,每个关节的角度都是连续值。如果用Q-learning,你需要离散化这些角度,但精细的离散化会导致维度灾难,粗略的离散化又会损失控制精度。我曾在机器人控制项目里为此头疼不已。
其次,基于值的方法通常只能输出确定性策略。但在某些场景下,随机策略反而更优。比如在石头剪刀布游戏中,纯确定性策略很容易被对手预测。策略梯度方法直接参数化策略本身,可以自然地输出动作概率分布。
最让我印象深刻的是部分可观测环境下的表现。曾经用DQN训练一个游戏AI,发现它经常在两个视觉相似但实际不同的场景下做出相同错误决策。后来改用策略梯度方法,因为其直接学习策略映射,反而避开了这个坑。
2. 策略梯度的数学直觉
理解策略梯度,关键在于把握其核心思想:通过调整策略参数,使得高回报的动作更可能被选择。这听起来简单,但如何用数学表达呢?
假设我们有个参数化的策略π(a|s;θ),目标是最大化期望回报J(θ)。这里有个巧妙的思路:与其直接求J(θ)对θ的梯度,不如找到一种采样估计方法。就像蒙特卡洛积分,通过采样来近似期望值。
我在白板上推导时发现,策略梯度定理给出了一个优雅的表达式: ∇J(θ) ∝ E[G_t ∇lnπ(A_t|S_t;θ)] 这个式子告诉我们,可以通过采样轨迹,计算每个时间步的回报G_t与对数策略梯度的乘积,来估计真实梯度。
举个生活中的例子:假设你在教小狗做动作。当它偶然做出你想要的动作时(高G_t),你就加强这个动作对应的指令(增大θ)。经过多次尝试,小狗就学会了哪些指令对应着哪些受欢迎的动作。
3. REINFORCE算法详解
REINFORCE是最基础的策略梯度算法,它的核心流程非常直接:
- 用当前策略π_θ采样完整轨迹
- 计算每个时间步的回报G_t
- 更新参数:θ ← θ + αG_t∇lnπ(A_t|S_t;θ)
我在实现时发现几个关键点需要注意:
- G_t是t时刻后的累计折扣回报,需要从后往前计算
- 对数概率的梯度计算可以用自动微分工具自动处理
- 学习率α需要仔细调整,太大容易不稳定
这里有个容易踩的坑:直接实现时,初始阶段策略很差,采样到的G_t可能都是负值,导致训练困难。我的解决方案是引入基线(baseline),比如减去这批轨迹的平均回报,显著提高了稳定性。
4. 从理论到代码的实现细节
让我们用PyTorch实现一个完整的REINFORCE算法。先定义策略网络:
import torch import torch.nn as nn import torch.optim as optim class PolicyNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden_size=128): super().__init__() self.fc1 = nn.Linear(state_dim, hidden_size) self.fc2 = nn.Linear(hidden_size, action_dim) def forward(self, state): x = torch.relu(self.fc1(state)) return torch.softmax(self.fc2(x), dim=-1)接下来是REINFORCE的核心训练逻辑:
def train(env, policy, episodes=1000, gamma=0.99, lr=0.01): optimizer = optim.Adam(policy.parameters(), lr=lr) for ep in range(episodes): state = env.reset() rewards = [] log_probs = [] # 采样轨迹 while True: state = torch.FloatTensor(state) probs = policy(state) action = torch.multinomial(probs, 1).item() next_state, reward, done, _ = env.step(action) log_prob = torch.log(probs[action]) log_probs.append(log_prob) rewards.append(reward) state = next_state if done: break # 计算回报 returns = [] G = 0 for r in reversed(rewards): G = r + gamma * G returns.insert(0, G) # 归一化回报 returns = torch.tensor(returns) returns = (returns - returns.mean()) / (returns.std() + 1e-9) # 计算损失 policy_loss = [] for log_prob, G in zip(log_probs, returns): policy_loss.append(-log_prob * G) # 参数更新 optimizer.zero_grad() loss = torch.stack(policy_loss).sum() loss.backward() optimizer.step()这段代码有几个实用技巧:
- 回报归一化(减均值除标准差)能显著提高稳定性
- 使用负对数概率乘以回报作为损失,因为优化器默认是最小化
- 自动微分自动计算∇lnπ,避免手动推导
5. 实战中的调参经验
在实际项目中,我发现REINFORCE对超参数相当敏感。经过多次实验,总结出以下经验:
学习率选择:
- 开始可以尝试1e-3到1e-2
- 如果回报波动剧烈,适当降低
- 可以结合学习率调度器动态调整
折扣因子γ:
- 接近1的值考虑长期回报
- 通常在0.9到0.99之间
- 对于回合制任务可以设为1
批量大小:
- 完全在线更新(每步更新)方差太大
- 建议积累多个episode后再更新
- 批量大小一般32-256效果较好
一个实用的技巧是熵正则化,在损失函数中加入策略熵的负值:
entropy = -torch.sum(probs * torch.log(probs)) loss = loss - 0.01 * entropy # 系数通常较小这能鼓励探索,防止策略过早收敛到次优解。
6. 算法变体与改进
基础的REINFORCE虽然简单,但存在高方差问题。以下是几种常见改进:
带基线的REINFORCE: 减去状态相关的基线b(s),通常用价值函数V(s)估计:
advantage = G_t - V(s_t)我在实现时发现,即使简单的移动平均基线也能显著提升性能。
Actor-Critic架构: 用TD误差代替蒙特卡洛回报:
delta = r + gamma * V(s_next) - V(s)这样可以在每一步更新,不再需要等待回合结束。
自然策略梯度: 考虑参数空间的曲率信息,使用Fisher信息矩阵进行预处理:
# 需要计算二阶导数 loss = -log_prob * advantage + 0.5 * (delta * log_prob).pow(2).mean()在实际项目中,我通常从带基线的REINFORCE开始,等基础版本稳定后再尝试更复杂的变体。
7. 典型问题与调试技巧
新手实现REINFORCE时常遇到以下问题:
回报不增长:
- 检查梯度更新是否真的发生(打印参数变化)
- 尝试更大的网络容量
- 增加探索(如提高熵正则系数)
回报波动剧烈:
- 减小学习率
- 增大批量大小
- 添加更稳定的基线
策略过早收敛:
- 检查熵值是否降得太快
- 添加明确的探索激励
- 尝试不同的策略初始化
一个实用的调试技巧是可视化:
- 绘制回报曲线和移动平均
- 监控策略熵的变化
- 对离散动作,记录动作选择分布
记得保存不同超参数配置的训练日志,这对分析问题非常有用。
