强化学习训练总崩溃?从PPO到GRPO,这篇实战指南帮你彻底搞定

0 阅读13分钟

不堆复杂公式,但把数学思想讲透。OpenAI、DeepMind都在用的PPO算法,手把手带你写出来。

前言:为什么你的智能体总是“一步摔死”?

做过强化学习的人,几乎都经历过这个场景:你兴致勃勃地写好策略梯度算法,前几十轮智能体表现越来越好,你正觉得胜利在望,突然某一轮——分数直接掉到零,从此再也爬不起来了。你调小学习率,它学得慢如蜗牛;调大学习率,它崩得更快。这到底是为什么?

答案很简单:经典策略梯度算法没有“保护机制”。它像一名蒙眼登山者,每次只根据脚下坡度决定迈步方向,却不知道前方是不是悬崖。一旦步子迈大,就会坠入谷底。更糟的是,摔下去之后,它只能采集到失败经验,用失败经验训练只会变得更差,陷入死亡螺旋。

而**PPO(近端策略优化)**就是给这位登山者加了一根“安全绳”。它通过一个巧妙的数学技巧,确保每次更新都在旧策略附近的“安全区域”内,既保证了学习速度,又防止了性能崩塌。今天,我们就从最朴素的数学直觉出发,彻底搞懂PPO,并亲手写出它的代码。


1. 先搞懂核心痛点:步子迈大为什么导致崩溃?

要明白PPO为什么有效,得先理解经典策略梯度到底“脆弱”在哪里。

1.1 策略梯度的“论功行赏”逻辑

策略梯度的训练思路很直观:用当前策略玩一局游戏,记录每一步的“状态-动作-得分”。如果某个动作最终带来了高总分,就增加它下次出现的概率;如果带来了低分,就降低它的概率。这就像给好动作“颁奖”,给坏动作“罚款”。

用数学语言简单表述一下:我们想要最大化期望总奖励。梯度告诉我们,为了让策略变好,应该沿着**“动作的对数概率乘以该动作的回报”这个方向去更新参数。这个方向本身没问题,问题在于——我们应该迈多大的步子**?

1.2 地形不同,安全步长也不同

想象一下训练过程中的“奖励地形”。有些区域很平坦(梯度很小),想有效学习,步子需要大一点;有些区域非常陡峭(梯度很大),稍大一点的步子就会冲过头,直接掉下悬崖。经典策略梯度用同一个学习率应对所有地形,显然是不合理的。

1.3 数据分布会随策略变化——这是最致命的一点

你可能会说:深度学习不也是用一个固定学习率大步更新吗?为什么没事?因为深度学习的训练数据是静态的——图片库不会因为模型参数改变而改变。但强化学习的训练数据是在线采样的——你的策略一旦变差,下一轮采到的轨迹全是失败的,用这些失败数据训练出来的策略只会更差。这就是“一脚踩空,万劫不复”的原因。


2. PPO的核心数学思想:给策略更新加一个“软约束”

既然问题出在“步长不受控”,那我们就主动限制每次更新的幅度。学术上这叫置信域方法:每次只允许新策略停留在旧策略附近的一个“信任区域”内。

2.1 一个直观的比喻:比值 r(θ)

我们先定义这样一个数值:

比值 r(θ) = 新策略选择动作 a 的概率 ÷ 旧策略选择同一动作 a 的概率

如果 r(θ) = 1,说明新旧策略对这个动作的态度完全一样。如果 r(θ) = 1.5,说明新策略更喜欢这个动作了(概率增加了50%)。如果 r(θ) = 0.6,说明新策略不那么喜欢了(概率降低了40%)。

2.2 为什么要限制比值?

如果我们完全不限制 r(θ),新策略可能会变得非常极端:某个动作的概率从0.1飙升到0.9,r(θ)=9。步子一大,策略就可能“飞”到一个完全陌生的区域,这个区域的表现可能极差。而且由于数据分布变了,后面全乱套。

所以PPO的想法是:不要让 r(θ) 离1太远。通常我们会把 r(θ) 限制在 [0.8, 1.2] 这个区间内。数学上这叫做裁剪(clipping)

2.3 优势函数 A:这个动作到底好不好?

光知道比值还不够,我们还需要知道这个动作本身是“好”还是“坏”。这就需要优势函数 A。优势函数的含义是:

A > 0:这个动作比当前策略的平均水平好,应该鼓励。
A < 0:这个动作比平均水平差,应该抑制。

优势函数通常用**GAE(广义优势估计)**来计算,它综合了即时奖励和未来奖励的估计。GAE的核心是一个递推公式,可以理解为“考虑未来几步的TD误差累积”。虽然公式看起来有点复杂,但代码实现起来就是从一个数组末尾往前累加。

2.4 PPO的裁剪损失:min 操作的妙用

PPO最终的目标函数(我们想要最大化的东西)可以通俗地描述为:

目标 = 期望[ min( r(θ) × A, 裁剪后的r(θ) × A ) ]

这个 min 操作是PPO的灵魂。我们用几个具体数字来理解它(假设裁剪区间[0.8, 1.2]):

  • 情况A

    :r(θ)=1.1,A=+5(好动作,新策略也更倾向它)。r×A=5.5,裁剪后的r=1.1(没超限),min取5.5。正常鼓励。

  • 情况B

    :r(θ)=1.5,A=+5(好动作,但新策略已经太激进)。r×A=7.5,裁剪后的r=1.2,裁剪后×A=6.0,min取6.0。虽然其实可以更大,但PPO故意只给6.0,压制了过激的更新。

  • 情况C

    :r(θ)=0.5,A=-5(差动作,新策略已经不太选了)。r×A=-2.5,裁剪后的r=0.8,裁剪后×A=-4.0,min取-4.0(因为-4.0比-2.5更小)。注意这里取的是更小的值(更负),相当于加大了惩罚,鼓励新策略进一步远离这个差动作——但又不至于让r(θ)变得比0.8还小。

  • 情况D

    :r(θ)=1.5,A=-5(差动作,但新策略反而更喜欢它了)。r×A=-7.5,裁剪后的r=1.2,裁剪后×A=-6.0,min取-7.5(更小)。这里直接保留了原始的负值,允许新策略大幅降低这个差动作的概率。

总结成一句话:当比值离开[0.8,1.2]且优势方向相同时,裁剪会阻止过激更新;当比值离开但优势方向相反时,裁剪反而会放大惩罚或奖励,让策略更快纠正错误。

这就是PPO背后的全部数学直觉。没有复杂的二阶导数,没有黑塞矩阵,只有一个聪明的min操作和一个裁剪阈值。


3. 辅助知识:理解优势计算和重要性采样

在写代码之前,还有两个概念需要简单提一下,因为它们在PPO的实现中会用到。

3.1 优势函数GAE:如何评价一个动作?

GAE的核心思想是:一个动作的优势 = 即时奖励 + 未来奖励的估计 - 当前状态的价值估计。用通俗的话说:这个动作带来的“额外好处”是多少。GAE有一个参数 λ(通常0.95),它控制着我们要看多远的未来。λ=0时只看一步,λ=1时看整个轨迹。

GAE的递推公式可以这样记忆:从轨迹末尾向前,每一步的GAE = TD误差 + γ·λ·下一步的GAE。这个递推实现起来非常简单,就是几行循环代码。

3.2 重要性采样:为什么可以用旧数据训练新策略?

PPO的一个优点是:同一批数据可以重复使用多次。这依赖于重要性采样技术。简单来说,如果我们想用旧策略采集的数据来估计新策略的期望值,只需要给每个数据乘上一个权重:新策略概率 / 旧策略概率。这个权重的期望就是1,但如果两个策略差异太大,权重的方差会爆炸,导致估计不准。这正是为什么我们要限制比值接近1——为了控制方差,使重要性采样稳定。


4. 实战:从零实现PPO(CartPole环境)

理论讲清楚了,下面我们用PyTorch实现一个完整的PPO智能体。环境选择经典的CartPole-v0:滑块上立着一根杆子,通过向左或向右推滑块,让杆子保持直立。坚持200步不倒即为胜利。

4.1 定义策略网络和价值网络

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
from torch.distributions import Categorical
import gym
 
# ---------- 策略网络:输入状态,输出动作概率 ----------
class PolicyNet(nn.Module):
    def __init__(self, state_dim=4, action_dim=2, hidden=128):
        super().__init__()
        self.fc1 = nn.Linear(state_dim, hidden)
        self.fc2 = nn.Linear(hidden, action_dim)
 
    def forward(self, x):
        x = F.relu(self.fc1(x))
        logits = self.fc2(x)          # 未归一化的logits
        return F.softmax(logits, dim=-1)  # 转换为概率分布
 
# ---------- 价值网络:输入状态,输出状态价值 ----------
class ValueNet(nn.Module):
    def __init__(self, state_dim=4, hidden=128):
        super().__init__()
        self.fc1 = nn.Linear(state_dim, hidden)
        self.fc2 = nn.Linear(hidden, 1)
 
    def forward(self, x):
        x = F.relu(self.fc1(x))
        return self.fc2(x)            # 输出一个标量价值

4.2 实现PPO智能体

智能体需要完成:选择动作、采集轨迹、计算GAE、执行PPO更新。

class PPOAgent:
    def __init__(self, lr=3e-4, gamma=0.99, clip_eps=0.2, gae_lambda=0.95, 
                 update_epochs=10, device='cpu'):
        self.gamma = gamma
        self.clip_eps = clip_eps
        self.gae_lambda = gae_lambda
        self.update_epochs = update_epochs
        self.device = device
 
        self.policy = PolicyNet().to(device)
        self.value = ValueNet().to(device)
        self.pi_optim = torch.optim.Adam(self.policy.parameters(), lr=lr)
        self.v_optim = torch.optim.Adam(self.value.parameters(), lr=lr)
 
    def get_action(self, state):
        """输入状态,输出动作及其对数概率"""
        state = torch.FloatTensor(state).unsqueeze(0).to(self.device)
        probs = self.policy(state).squeeze(0)
        m = Categorical(probs)
        action = m.sample().item()
        log_prob = m.log_prob(torch.tensor(action).to(self.device))
        return action, log_prob.item()
 
    def collect_trajectory(self, env, max_steps=200):
        """采集一条完整轨迹,记录状态、动作、奖励、对数概率等"""
        state = env.reset()
        states, actions, log_probs, rewards, dones = [], [], [], [], []
 
        for _ in range(max_steps):
            action, lp = self.get_action(state)
            next_state, reward, done, _ = env.step(action)
 
            states.append(state)
            actions.append(action)
            log_probs.append(lp)
            rewards.append(reward)
            dones.append(done)
 
            state = next_state
            if done:
                break
 
        # 注意:我们需要next_states来计算GAE,这里简单处理
        next_states = states[1:] + [next_state]  # 最后一步的next_state是终止状态
        return states, actions, log_probs, rewards, dones, next_states
 
    def compute_gae(self, rewards, dones, values, next_values):
        """计算GAE优势函数和TD目标"""
        advantages = []
        gae = 0.0
        # 从后向前递推
        for t in reversed(range(len(rewards))):
            delta = rewards[t] + self.gamma * next_values[t] * (1 - dones[t]) - values[t]
            gae = delta + self.gamma * self.gae_lambda * (1 - dones[t]) * gae
            advantages.insert(0, gae)
        advantages = np.array(advantages)
        # 优势归一化(稳定训练)
        advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
        targets = advantages + np.array(values)
        return torch.FloatTensor(advantages).to(self.device), torch.FloatTensor(targets).to(self.device)
 
    def update(self, trajectory):
        """使用一条轨迹更新策略和价值网络"""
        states, actions, old_log_probs, rewards, dones, next_states = trajectory
        # 将数据转为tensor
        states_t = torch.FloatTensor(states).to(self.device)
        next_states_t = torch.FloatTensor(next_states).to(self.device)
        actions_t = torch.LongTensor(actions).to(self.device).view(-1, 1)
        old_log_probs_t = torch.FloatTensor(old_log_probs).to(self.device).view(-1, 1)
 
        # 计算当前价值网络的估计
        with torch.no_grad():
            values = self.value(states_t).cpu().numpy().squeeze()
            next_values = self.value(next_states_t).cpu().numpy().squeeze()
        advantages, targets = self.compute_gae(rewards, dones, values, next_values)
 
        # 重复使用同一批数据更新多次
        for _ in range(self.update_epochs):
            # 新策略下的对数概率
            probs = self.policy(states_t)
            new_log_probs = torch.log(probs.gather(1, actions_t) + 1e-8)
 
            # 计算比值 r(θ)
            ratio = torch.exp(new_log_probs - old_log_probs_t)
 
            # 裁剪后的比值
            clipped_ratio = torch.clamp(ratio, 1 - self.clip_eps, 1 + self.clip_eps)
 
            # PPO策略损失 (注意我们的目标是最大化,所以取负)
            surr1 = ratio * advantages
            surr2 = clipped_ratio * advantages
            pi_loss = -torch.min(surr1, surr2).mean()
 
            # 价值损失 (MSE)
            v_pred = self.value(states_t).squeeze()
            v_loss = F.mse_loss(v_pred, targets)
 
            # 总损失
            loss = pi_loss + 0.5 * v_loss
 
            # 更新
            self.pi_optim.zero_grad()
            self.v_optim.zero_grad()
            loss.backward()
            self.pi_optim.step()
            self.v_optim.step()

4.3 训练循环与效果

def train_ppo(env, agent, episodes=500):
    rewards_history = []
    for ep in range(episodes):
        traj = agent.collect_trajectory(env)
        agent.update(traj)
        total_reward = sum(traj[3])
        rewards_history.append(total_reward)
        if (ep+1) % 50 == 0:
            print(f"Episode {ep+1}, total reward: {total_reward}")
    return rewards_history
 
if __name__ == "__main__":
    env = gym.make('CartPole-v0')
    agent = PPOAgent(device='cuda' if torch.cuda.is_available() else 'cpu')
    train_ppo(env, agent)

运行这段代码,你会发现训练曲线平滑上升,最终稳定在200分左右。即使偶尔有波动,也不会出现突然归零的崩溃——这就是PPO的“安全绳”在起作用。


5. 进阶变种:GRPO(组相对策略优化)

PPO需要同时训练策略网络和价值网络。在一些任务中(比如大语言模型的RLHF),训练价值网络本身代价很高。有没有办法不用价值网络呢?**GRPO(组相对策略优化)**给出了一个巧妙的答案。

5.1 GRPO的核心思想:用组内排名代替价值估计

GRPO不再为每个状态单独计算价值,而是:每次用旧策略采样一组轨迹(比如5条),计算每条轨迹的总奖励,然后在这一组内做标准化,得到每条轨迹的组内相对优势。轨迹内的每个动作共享这个优势。

简单说:你的绝对得分不重要,重要的是你在这一组里的排名。如果排前面,说明你的动作整体比组内其他轨迹好,就鼓励;排后面,就抑制。

5.2 GRPO的优势计算(无价值网络)

设一组有 G 条轨迹,第 i 条轨迹的总奖励为 R_i(可以是原始奖励,也可以归一化到[0,1])。则:

组内均值 μ = (R_1+...+R_G)/G
组内标准差 σ = std(R)
第 i 条轨迹的优势 A_i = (R_i - μ) / σ

然后,轨迹内所有时间步的优势都等于 A_i。后面的PPO裁剪公式完全一样,只是没有了价值网络的那部分损失。

5.3 GRPO的代码实现片段

class GRPOAgent:
    def __init__(self, lr=3e-4, clip_eps=0.2, group_size=5, update_epochs=20):
        self.policy = PolicyNet()
        self.optim = torch.optim.Adam(self.policy.parameters(), lr=lr)
        self.clip_eps = clip_eps
        self.group_size = group_size
        self.update_epochs = update_epochs
 
    def collect_trajectory(self, env):
        # 与PPO类似,但返回总奖励(归一化或原始)
        ...
 
    def calc_group_advantages(self, trajectories):
        rewards = [traj['total_reward'] for traj in trajectories]
        mean_r = np.mean(rewards)
        std_r = np.std(rewards) + 1e-8
        advantages = [(r - mean_r) / std_r for r in rewards]
        return advantages
 
    def update(self, trajectories):
        advantages = self.calc_group_advantages(trajectories)
        for _ in range(self.update_epochs):
            for traj, adv in zip(trajectories, advantages):
                # 计算ratio和裁剪损失,与PPO相同
                # 没有价值损失
                ...

GRPO显著简化了实现,尤其适合奖励稀疏或需要大量采样的场景。但它对组大小敏感,如果组太小,优势估计的方差会很大。


6. 总结与实践建议

6.1 PPO为什么能成为主流?

归根结底,PPO的成功在于它用**一个极其简单的数学技巧(clip+min)**解决了强化学习中最棘手的“步长控制”问题。相比更早的TRPO(需要计算二阶导数,工程复杂),PPO只依赖一阶优化,代码友好,性能却几乎不输。相比原始策略梯度,它又大大提升了稳定性和样本效率。这些特点使它成为工业界和学术界的首选。

6.2 写PPO代码时容易踩的坑

  • 优势归一化是必须的,否则梯度可能爆炸。

  • 裁剪阈值一般取0.2,太大失去保护,太小限制学习。

  • 同一批数据重复使用次数建议4~10次,太多会过拟合。

  • 价值损失的权重一般设为0.5,可以平衡两个网络的更新速度。

  • 别忘了在计算对数概率时加一个极小的数(1e-8)防止log(0)。

6.3 最后的思考

PPO并不是万能的。它仍然需要仔细调参,在某些高维问题上也可能陷入局部最优。但它代表了一种重要的工程哲学:在复杂的优化问题中,一个简单且稳定的约束往往比复杂的精确解更实用。下次当你训练智能体频频崩溃时,不妨想想PPO的思路——给它画一个安全区,让它放心地探索,而不是一步踏空,万劫不复。

希望这篇文章能帮你真正理解PPO,并能顺利跑通你的第一个PPO智能体。祝你训练永不崩溃!