一、先从“训练不稳定”这件事聊起

我见过很多朋友在跑强化学习的时候,脑子里是这么想的:“网络搭好了,损失函数写对了,梯度也回传了,怎么就学不出来呢?”更让人头疼的是,一开始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这些方法,本质上都是在“降低噪声”和“引入偏差”之间找平衡。参数调整上,没有万能公式,但你可以按照我前面提到的排查顺序,配合完整的示例代码,一点点去试。记住,强化学习是一个“经验科学”,多实验、多观察、多记录,比单纯搜博客有用得多。希望这篇文章能帮你少走一些弯路,让你的模型稳定地学到好策略。