一、先从“训练不稳定”这件事聊起
我见过很多朋友在跑强化学习的时候,脑子里是这么想的:“网络搭好了,损失函数写对了,梯度也回传了,怎么就学不出来呢?”更让人头疼的是,一开始loss降得挺漂亮,训练着训练着突然就开始“抽风”——一会儿表现神勇,一会儿全盘崩溃,甚至直接变成“复读机”,永远输出同一个动作。这时候大家第一反应就是调学习率、改网络结构、换奖励函数,折腾半天没啥用。
其实呢,这种不稳定十有八九是策略梯度方法的老毛病——方差太大。策略梯度不像普通监督学习那样有个固定的标签,它是用采样出来的“奖励”去估计梯度的。样本一少,噪声就大,噪声一大,训练就跟坐过山车一样。我们今天就从“方差缩减”和“基线设计”这两个最容易出效果的角度,把这个问题拆开揉碎,好好说清楚。哪怕你只是个刚接触强化学习的新手,也能跟着思路走一遍,回头自己动手排查。
二、方差到底是从哪儿冒出来的
2.1 用一个比方理解方差
想象你在一个黑箱子里摸奖,箱子里有红球和蓝球,比例是7:3。你闭着眼睛摸十次,有可能摸出6红4蓝,也有可能摸出8红2蓝。你估计出来的红球比例就不是稳定的7成,而是忽高忽低。策略梯度就是这样,每一轮我们采样一批动作去“试探”环境,然后用这些动作获得的奖励来告诉网络“哪些动作更值得做”。采样次数少,估计就飘;奖励波动大,估计就更飘。训练不稳定,本质上是梯度方向本身在不停乱跳,你在这个方向上调参,自然就学不稳。
2.2 策略梯度的标准形式
我们用PyTorch来写一个最简单的REINFORCE算法。技术栈是Python + PyTorch。
import torch
import torch.nn as nn
import torch.optim as optim
import gym
# 一个简单的策略网络,输出动作概率
class PolicyNet(nn.Module):
def __init__(self, state_dim, action_dim, hidden=64):
super(PolicyNet, self).__init__()
self.fc1 = nn.Linear(state_dim, hidden)
self.fc2 = nn.Linear(hidden, action_dim)
def forward(self, state):
x = torch.relu(self.fc1(state))
logits = self.fc2(x)
# 用softmax转成动作概率分布
probs = torch.softmax(logits, dim=-1)
return probs
# 从概率分布中采样一个动作
def sample_action(probs):
dist = torch.distributions.Categorical(probs)
action = dist.sample()
return action.item(), dist.log_prob(action)
上面这个代码看起来没啥问题,但实际训练时你会发现,如果你直接用整条轨迹的累积奖励去做梯度,方差会非常大。为什么?因为一条轨迹里可能有几十步,每一步的奖励差异可能很大,有的步是+1,有的是-10,算出来的梯度就会忽正忽负,网络根本不知道该往哪个方向走。
三、基线设计:给奖励“降中心”
3.1 什么是基线
基线是一个和动作无关的数,它可以是一个常数,也可以是一个和状态相关的函数。我们用“奖励 - 基线”来代替原始奖励。为什么要减基线?因为策略梯度的期望不依赖基线,但方差会变小。就好比你想估计一个城市夏天的日均气温,你直接看每天的绝对温度,波动很大;但如果你知道往年平均气温是35度,你只看“每天比35度高多少”,数据就会稳定很多。
3.2 最简单的基线:常数基线
我们可以用整个训练过程中所有奖励的平均值作为基线。代码如下:
# 在训练循环里维护一个reward_sum和count
reward_sum = 0.0
count = 0
# 假设每条轨迹结束,我们拿到了总的回报total_reward
# baseline = reward_sum / count # 这个就是常数基线
# 然后每个时间步的advantage = reward_to_go - baseline
但常数基线不够精细,因为不同的状态下,奖励的“正常水平”不一样。比如在游戏里,靠近出口的状态和远离出口的状态,即使动作都一样,未来的奖励预期也不同。所以更常用的基线是状态价值函数 V(s)。
3.3 用状态价值函数做基线
我们再加一个价值网络来预测每个状态的价值,然后用“折扣累计奖励 - 当前状态价值”作为优势估计。这就是Actor-Critic的基本思路。我们先写一个简单的Critic网络:
class ValueNet(nn.Module):
def __init__(self, state_dim, hidden=64):
super(ValueNet, self).__init__()
self.fc1 = nn.Linear(state_dim, hidden)
self.fc2 = nn.Linear(hidden, 1)
def forward(self, state):
x = torch.relu(self.fc1(state))
value = self.fc2(x)
return value # 输出一个标量,表示状态价值
有了ValueNet,我们就可以计算优势:
# 在轨迹结束后,倒推每个时间步的回报G_t
# G_t = r_t + gamma * r_{t+1} + gamma^2 * r_{t+2} + ...
# advantage_t = G_t - V(s_t)
这样训练起来会比REINFORCE稳定很多。但注意,如果ValueNet本身误差很大,优势估计也很差,反而会引入新的偏差。所以基线也不是随便加的,要控制好价值网络的学习速率,别让它和策略网络抢梯度。
四、方差缩减的进阶手段:GAE
4.1 为什么TD误差比完整回报强
完整回报G_t包含了很多未来的奖励,未来奖励的方差会逐层累积。如果只用一步的TD误差(r + gamma * V(s') - V(s)),方差小,但偏差大。怎么权衡呢?GAE(Generalized Advantage Estimation)给了一个漂亮的组合方式:用参数lambda来调节多步TD的权重。lambda越接近1,就是完整回报,方差大;lambda越接近0,就是一步TD,偏差大。在实际使用中,lambda取0.95左右效果通常不错。
4.2 GAE的计算代码
我们写一个函数来计算GAE:
def compute_gae(rewards, values, gamma=0.99, lam=0.95):
"""
rewards: [t0, t1, ..., t_{T-1}]
values: [V(s0), V(s1), ..., V(s_T)] 注意多一个末尾状态价值
"""
gae = 0.0
advantages = []
# 从后往前递归计算
for t in reversed(range(len(rewards))):
# 当前状态的V,下一个状态的V
V_t = values[t]
V_next = values[t + 1]
# 时序差分误差
delta = rewards[t] + gamma * V_next - V_t
# 累加GAE
gae = delta + gamma * lam * gae
advantages.insert(0, gae) # 插入到列表头部,保持时间顺序
return advantages
这段代码里,我们特意把values多传了一个末尾状态,这样可以覆盖最后一个时间步的下一步价值(通常是0或者折现后的价值)。用GAE替换原来的G_t之后,你会发现训练曲线平稳很多。
五、常见原因辨析:明明加了基线,为什么还是不稳
5.1 学习率不匹配
很多情况下,策略网络和价值网络对学习率的敏感度不一样。策略网络更新太猛,动作概率会突然改变,导致训练抖动;价值网络学习率太高,基线跟着奖励乱跳,优势估计也不稳定。我的建议是:价值网络的学习率可以比策略网络大一点,因为价值网络收敛快,但也不能太大,否则会震荡。一般来说,策略网络用3e-4,价值网络用1e-3,然后根据实际效果微调。
5.2 奖励没有归一化
奖励的尺度对训练影响巨大。比如某个环境奖励范围是0~1,另一个环境奖励范围是-1000~0,同一个学习率根本没法通用。所以在训练开始前或者每个batch里,对奖励做一次标准化(减去均值,除以标准差),能显著提升稳定性。但注意,如果奖励本身就很小,标准化可能会放大噪声,所以有时也会用clip限制一下。
5.3 采样批次太小
策略梯度是on-policy的,每轮采样的数据用完就扔。如果一层的轨迹样本太少,梯度估计的方差就大。你可以试试增加每个batch的轨迹数量,比如从1条增加到8条或16条。样本多了,方向会更准确,训练自然更稳。当然代价是训练变慢。
5.4 时间步长截断问题
有些环境是无限长的,我们不能无限等它结束。通常我们会设定一个最大步数,比如1000步。如果到达了最大步数还没结束,这时的“最终状态”实际上是一个截断状态,它的价值不能直接当0算,否则会低估未来回报,造成偏差。正确做法是把截断状态的value设置为当前价值网络的预测V(s_T),而不是0。这种情况下,GAE的计算也要相应调整。
5.5 动作概率“饱和”
当策略网络经过若干轮更新后,可能会对某些动作产生极度偏好的概率,比如99%选择动作1。这时候采样的多样性和探索能力大大下降,一旦环境反馈有噪声,模型就会把错误信号无限放大,导致崩溃。一个简单的对策是给策略网络的输出logits加一个熵正则项,鼓励探索。我们可以在损失函数里加上“熵的负项”,让网络保持一定的随机性。
# 在策略损失里加熵正则
entropy_bonus = 0.01 * dist.entropy().mean()
loss_policy = -advantages.mean() * log_probs.mean() - entropy_bonus
六、参数调整策略与执行细节
6.1 从里到外的排查顺序
如果你训练不稳定,先别乱调参。按照下面的顺序一步步查:
- 第一步:确认奖励尺度。打印几条轨迹的奖励分布,看看是不是过大或过小。
- 第二步:检查基线网络是否收敛。画出价值网络的预测值和实际折扣回报的曲线,如果偏差很大,先单独多训练几轮价值网络。
- 第三步:调整GAE的lambda参数。若训练震荡厉害,把lambda从0.95降到0.9试试;若学习太慢,把lambda调高到0.99。
- 第四步:检查梯度范数。如果梯度范数突然爆炸,加一个梯度裁剪,比如max_norm=0.5。
6.2 一个完整的可运行示例
下面我放一个完整的PPO简化版本,技术栈是Python + PyTorch。这个示例融合了GAE、价值网络和熵正则,虽然不完全等同于PPO,但涵盖了我们今天讨论的核心技巧。环境用gym的CartPole-v1。
import torch
import torch.nn as nn
import torch.optim as optim
import gym
import numpy as np
# ---------- 策略网络和价值网络合在一个类里 ----------
class ActorCritic(nn.Module):
"""
同时输出动作概率和状态价值
"""
def __init__(self, state_dim, action_dim, hidden=64):
super(ActorCritic, self).__init__()
self.common = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
)
self.actor = nn.Linear(hidden, action_dim)
self.critic = nn.Linear(hidden, 1)
def forward(self, state):
x = self.common(state)
logits = self.actor(x)
value = self.critic(x)
return logits, value
def get_dist_and_value(self, state):
logits, value = self.forward(state)
dist = torch.distributions.Categorical(logits=logits)
return dist, value
# ---------- GAE优势估计 ----------
def compute_gae(rewards, values, gamma=0.99, lam=0.95):
"""
values 长度 = len(rewards) + 1,最后一个是V(s_T)或0
"""
gae = 0.0
advantages = []
for t in reversed(range(len(rewards))):
delta = rewards[t] + gamma * values[t + 1] - values[t]
gae = delta + gamma * lam * gae
advantages.insert(0, gae)
return advantages
def train_one_epoch(env, model, optimizer, batch_size=2048, gamma=0.99, lam=0.95, clip_eps=0.2, entropy_coef=0.01, max_norm=0.5):
"""
一个训练周期:采集batch_size步数据,然后更新一次
"""
states = []
actions = []
rewards = []
old_log_probs = []
values = []
done = False
state, _ = env.reset()
state = torch.FloatTensor(state).unsqueeze(0)
# 采集数据
while len(states) < batch_size:
dist, value = model.get_dist_and_value(state)
action = dist.sample()
log_prob = dist.log_prob(action)
next_state, reward, terminated, truncated, _ = env.step(action.item())
done = terminated or truncated
states.append(state)
actions.append(action)
rewards.append(reward)
old_log_probs.append(log_prob.detach())
values.append(value.detach())
# 注意:我们需要一个额外的value给下一个状态
if done:
# 结束状态的价值设为0
next_value = torch.tensor([[0.0]])
# 但如果是截断(truncated),可以用模型预测的value,这里简化处理
if truncated:
with torch.no_grad():
_, next_value = model.get_dist_and_value(torch.FloatTensor(next_state).unsqueeze(0))
# 把最后一个value存进去
values.append(next_value)
break
else:
# 不是结束状态,用模型预测下一步价值
with torch.no_grad():
_, next_value = model.get_dist_and_value(torch.FloatTensor(next_state).unsqueeze(0))
values.append(next_value)
state = torch.FloatTensor(next_state).unsqueeze(0)
else:
# 如果没结束但已经达到了batch_size,需要额外追加一个value
with torch.no_grad():
_, next_value = model.get_dist_and_value(torch.FloatTensor(next_state).unsqueeze(0))
values.append(next_value)
# 计算GAE
advantages = compute_gae(rewards, values[:-1], gamma, lam) # values[:-1]对应每个真实的state
advantages = torch.FloatTensor(advantages).unsqueeze(1)
# 把收集到的数据转成张量(这里我们简化为同一batch,不做mini-batch)
states_t = torch.cat(states)
actions_t = torch.cat(actions)
old_log_probs_t = torch.cat(old_log_probs).detach()
# 重新计算当前模型的动作概率和价值
dist_now, value_now = model.get_dist_and_value(states_t)
log_probs_now = dist_now.log_prob(actions_t)
entropy = dist_now.entropy().mean()
# 概率比
ratio = torch.exp(log_probs_now - old_log_probs_t)
# 策略损失:用clip防止更新过大
surr1 = ratio * advantages
surr2 = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps) * advantages
policy_loss = -torch.min(surr1, surr2).mean()
# 价值损失:MSE
value_loss = nn.functional.mse_loss(value_now, torch.FloatTensor(values[:-1]).unsqueeze(1))
# 熵正则(鼓励探索)
entropy_loss = -entropy_coef * entropy
total_loss = policy_loss + 0.5 * value_loss + entropy_loss
# 梯度更新
optimizer.zero_grad()
total_loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)
optimizer.step()
return total_loss.item(), policy_loss.item(), value_loss.item(), entropy.item()
# ---------- 主训练循环 ----------
def main():
env = gym.make("CartPole-v1")
state_dim = env.observation_space.shape[0]
action_dim = env.action_space.n
model = ActorCritic(state_dim, action_dim, hidden=128)
optimizer = optim.Adam(model.parameters(), lr=3e-4)
for epoch in range(300):
total_loss, p_loss, v_loss, ent = train_one_epoch(env, model, optimizer)
if epoch % 10 == 0:
print(f"Epoch {epoch}: total={total_loss:.3f}, policy={p_loss:.3f}, value={v_loss:.3f}, entropy={ent:.3f}")
# 最后测试一下
state, _ = env.reset()
total_reward = 0
for _ in range(200):
dist, _ = model.get_dist_and_value(torch.FloatTensor(state).unsqueeze(0))
action = dist.sample().item()
state, reward, terminated, truncated, _ = env.step(action)
total_reward += reward
if terminated or truncated:
break
print(f"Test reward: {total_reward}")
if __name__ == "__main__":
main()
上面这个例子虽然叫PPO简化版,其实用的是“reload”的数据采集方式,在一个循环内更新一次。实际操作中,为了更稳定,通常会把数据采集和更新分开多做几次迭代。但这里已经足够演示“基线+GAE+熵正则+梯度裁剪”这些关键手段了。
七、应用场景、优缺点以及注意事项
7.1 这些技巧适合哪些场景
如果你做的是连续控制、机器人仿真、游戏AI这类需要从交互中学习的环境,策略梯度是标配。当你的任务比较困难,比如稀疏奖励、长程决策,方差会尤其大,这时候方差缩减和基线设计几乎是必须的。但如果是像分类、回归这样的监督学习任务,根本不需要这些技巧,别混为一谈。
7.2 优点与缺点
优点很明显:训练更稳定,收敛速度更快,调参次数减少,对环境的适应能力更强。缺点也有:引入价值网络会带来额外的偏差,如果价值网络学不好,反而拖累策略;GAE的lambda需要额外调,多了一个超参数;代码复杂度比原始REINFORCE高,调试难度也上去了。本质上,我们是在用“增加的偏差和计算量”来换取“方差的降低”,这个交易值不值,取决于你的任务方差异不严重。
7.3 注意事项
- 不要在训练初期就使用过大的熵系数,否则策略会永远保持随机,学不到东西,一般从0.01开始。
- 基线网络不要和策略网络共用太深的层,因为两个任务的梯度方向可能冲突。
- 一定要监控reward的滑动平均,如果连续多个epoch没提升,考虑降低学习率或增大batch。
- 当你发现训练突然崩了,先看看是不是价值网络输出出现了NaN,如果是,很可能是数值精度问题,可以在输入状态上加一个小噪声。
- 每次更新后,可以画一下动作的分布,如果某个动作概率超过0.95,就立刻降低学习率或提高熵系数。
八、文章总结
策略梯度训练不稳定的核心原因是方差,但不是唯一的元凶。实际训练中,基线设计和高阶的方差缩减是解决问题的关键路径。我们需要先诊断出是“奖励尺度问题”还是“基线不收敛”还是“探索不充分”,然后针对性地调整。常数基线、状态价值基线、GAE这些方法,本质上都是在“降低噪声”和“引入偏差”之间找平衡。参数调整上,没有万能公式,但你可以按照我前面提到的排查顺序,配合完整的示例代码,一点点去试。记住,强化学习是一个“经验科学”,多实验、多观察、多记录,比单纯搜博客有用得多。希望这篇文章能帮你少走一些弯路,让你的模型稳定地学到好策略。
评论
围绕“策略梯度方法训练不稳定时从方差缩减与基线设计入手进行深度排查,常见原因辨析与参数调整策略及执行细节”参与讨论