策略梯度方法:REINFORCE算法原理、从策略采样到梯度更新、代码实战
好,咱们进入策略梯度家族的第一个成员——REINFORCE。说实话,这个算法在理论推导上非常优雅,但我在实际项目中第一次跑通它时,差点被方差问题搞到怀疑人生。别急,咱们一步步来。
为什么需要策略梯度?
之前咱们聊的值函数方法,比如Q-learning,本质上是“先评估动作好坏,再选最好的”。但你想过没有,如果动作空间是连续的,或者策略本身是随机性的,那这套思路就有点吃力了。
策略梯度方法换了个思路——直接优化策略本身。说白了,就是让智能体在“好”的动作上增加概率,在“坏”的动作上减少概率。不需要维护一个值函数,也不需要argmax操作。
核心思想:策略梯度方法直接对策略参数θ求梯度,目标是最大化期望累积回报 J(θ) = E[∑γᵗ rₜ]。
REINFORCE算法推导
REINFORCE是策略梯度里最基础的一个,也叫“蒙特卡洛策略梯度”。它的推导其实就一行核心公式:
∇J(θ) = E[ ∑ ∇log π(aₜ|sₜ, θ) · Gₜ ]
这里Gₜ是从t时刻开始的折扣回报。为什么会有log?我简单解释一下——因为我们要对策略的期望回报求导,而期望里面有个概率分布,取log是为了把乘法变成加法,方便采样估计。
我在做机器人力矩控制时,第一次看到这个公式觉得挺简单,但真正写代码时才发现:Gₜ的方差大得离谱。同一个任务跑十次,梯度方向可能都不一样。嗯,这个坑后面会讲怎么填。
算法流程
REINFORCE的执行步骤其实很直观:
- 采样:用当前策略π(θ)与环境交互,收集一条完整的轨迹 (s₁, a₁, r₁, s₂, a₂, r₂, ..., s_T)
- 计算回报:对每个时间步t,计算折扣回报 Gₜ = ∑_{k=t}^{T} γ^{k-t} rₖ
- 计算梯度:对每个时间步,计算 ∇log π(aₜ|sₜ, θ) · Gₜ,然后求和取平均
- 更新参数:θ ← θ + α · ∇J(θ)
个人经验:我建议在计算Gₜ时,先收集完整episode再统一算。在线更新虽然看起来更“实时”,但实际效果往往不如离线批量更新稳定。
从策略采样到梯度更新——代码实战
咱们直接上代码。这里我用PyTorch实现一个简单的REINFORCE,环境用CartPole,策略网络用两层MLP。
import torch
import torch.nn as nn
import torch.optim as optim
import gym
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, x):
return self.fc(x)
def compute_returns(rewards, gamma=0.99):
returns = []
G = 0
for r in reversed(rewards):
G = r + gamma * G
returns.insert(0, G)
return torch.tensor(returns, dtype=torch.float32)
def reinforce(env_name='CartPole-v1', num_episodes=1000, lr=1e-3, gamma=0.99):
env = gym.make(env_name)
state_dim = env.observation_space.shape[0]
action_dim = env.action_space.n
policy = PolicyNetwork(state_dim, action_dim)
optimizer = optim.Adam(policy.parameters(), lr=lr)
for episode in range(num_episodes):
states, actions, rewards = [], [], []
state, _ = env.reset()
done = False
# 采样一条完整轨迹
while not done:
state_tensor = torch.FloatTensor(state).unsqueeze(0)
probs = policy(state_tensor)
dist = torch.distributions.Categorical(probs)
action = dist.sample().item()
next_state, reward, terminated, truncated, _ = env.step(action)
done = terminated or truncated
states.append(state)
actions.append(action)
rewards.append(reward)
state = next_state
# 计算回报和梯度
returns = compute_returns(rewards, gamma)
returns = (returns - returns.mean()) / (returns.std() + 1e-8) # 归一化
loss = 0
for t in range(len(states)):
state_t = torch.FloatTensor(states[t]).unsqueeze(0)
probs = policy(state_t)
dist = torch.distributions.Categorical(probs)
log_prob = dist.log_prob(torch.tensor(actions[t]))
loss += -log_prob * returns[t]
optimizer.zero_grad()
loss.backward()
optimizer.step()
if episode % 100 == 0:
print(f'Episode {episode}, Total Reward: {sum(rewards):.2f}')
env.close()
return policy
# 运行
# policy = reinforce()
我曾经踩过的坑:回报归一化那一步千万别省。我第一次跑的时候没做归一化,梯度直接爆炸,loss变成NaN。后来加了 (returns - mean) / std,训练才稳定下来。
REINFORCE的优缺点
| 优点 | 缺点 |
|---|---|
| 推导简单,实现容易 | 方差大,收敛慢 |
| 适用于离散和连续动作空间 | 只能处理episodic任务 |
| 策略是随机性的,天然探索 | 样本利用率低 |
| 不需要值函数近似 | 对奖励尺度敏感 |
核心逻辑流程图
下面这张图展示了REINFORCE从采样到更新的完整流程,我建议你对照代码一起看:
关于基线(Baseline)的一点补充
REINFORCE最大的问题就是方差大。为什么?因为Gₜ本身波动很大,导致梯度估计不稳定。一个经典的改进是引入基线b(sₜ):
∇J(θ) = E[ ∑ ∇log π(aₜ|sₜ) · (Gₜ - b(sₜ)) ]
基线可以是任何与动作无关的函数,最常用的就是状态值函数V(sₜ)。减去基线后,梯度的期望不变,但方差会显著降低。我在做机械臂力矩控制时,加了基线之后训练曲线直接从“心电图”变成了“平滑下降”。
小技巧:如果你不想额外维护一个值网络,直接用Gₜ的滑动平均作为基线也能凑合。虽然不精确,但比没有强。
什么时候该用REINFORCE?
说实话,在2024年的今天,纯REINFORCE用得不多。但它作为策略梯度家族的“Hello World”,价值在于让你理解最核心的思想——用采样回报来调整策略参数。后面的Actor-Critic、PPO、SAC,本质上都是在解决REINFORCE的方差问题。
我个人建议:如果你刚接触策略梯度,先把这个算法跑通,观察一下方差带来的影响。等你亲眼看到loss曲线像过山车一样上下翻飞时,你才会真正理解为什么后来要引入那么多“花里胡哨”的改进。