RL——PPO


PPO 方法介绍

PPO 的目标

  • PPO目标定义
    $$
    \begin{aligned}
    \max_{\theta_\text{new}} \quad &\mathbb{E}_{s \sim \rho_{\pi_{\theta_\text{old}}}, a \sim \pi_{\theta_\text{old}}}\left[\frac{\pi_{\theta_\text{new}}(a|s)}{\pi_{\theta_\text{old}}(a|s)} A_{\pi_{\theta_\text{old}}}(s,a)\right] \\
    &\text{s.t. } \quad \quad \mathbb{E}_{s \sim \rho_{\pi_{\theta_\text{old}}}} \left[D_{\text{KL}}(\pi_{\theta_\text{old}}, \pi_{\theta_\text{new}})\right] \le \delta
    \end{aligned}
    $$
  • PPO目标详细推导见RL——TRPO-PPO-目标函数基础推导

PPO-Penalty

  • 又名PPO-惩罚
    $$
    \begin{aligned}
    \max_{\theta}&\ \ \mathbb{E}_{s \sim \rho_{\pi_{\theta_\text{old}}}, a \sim \pi_{\theta_\text{old}}}\left[\frac{\pi_\theta(a|s)}{\pi_{\theta_{\text{old}}}(a|s)}A_{\theta_{\text{old}}}(s,a) - \beta D_{KL}(\pi_{\theta_{\text{old}}}(\cdot|s), \pi_\theta(\cdot|s))\right]
    \end{aligned}
    $$

PPO-Clip

  • 又名PPO截断
    $$
    \begin{aligned}
    \max_\theta&\ \ \mathbb{E}_{s\sim \rho_{\theta_{\text{old}}},a\sim q(a|s)}\min\left(\color{blue}{\frac{\pi_\theta(a|s)}{q(a|s)}A_{\theta_{\text{old}}}(s,a)}, \color{red}{clip\left(\frac{\pi_\theta(a|s)}{q(a|s)}, 1-\epsilon, 1+\epsilon\right)A_{\theta_{\text{old}}}(a,s)}\right)
    \end{aligned}
    $$
  • 理论上,以上采样分布可以是任意分布 ,实际上使用Old策略效果更好,样本利用率也更高,所以常用的PPO目标一般会如下定义:
    $$
    \begin{aligned}
    \max_\theta&\ \ \mathbb{E}_{s \sim \rho_{\pi_{\theta_\text{old}}}, a \sim \pi_{\theta_\text{old}}}\min\left(\color{blue}{\frac{\pi_\theta(a|s)}{\pi_{\theta_{\text{old}}}(a|s)}A_{\theta_{\text{old}}}(s,a)}, \color{red}{clip\left(\frac{\pi_\theta(a|s)}{\pi_{\theta_{\text{old}}}(a|s)}, 1-\epsilon, 1+\epsilon\right)A_{\theta_{\text{old}}}(a,s)}\right)
    \end{aligned}
    $$
    • 以上目标是 \(\max_\theta\),实际实现时会在令损失函数等于负的目标函数
  • 令 \(r(\theta) = \frac{\pi_\theta(a|s)}{\pi_{\theta_{\text{old}}}(a|s)} \),则有:
    $$
    \begin{aligned}
    \max_\theta&\ \ \mathbb{E}_{s \sim \rho_{\pi_{\theta_\text{old}}}, a \sim \pi_{\theta_\text{old}}}\min\left(\color{blue}{r(\theta)A_{\theta_{\text{old}}}(s,a)}, \color{red}{clip\left(r(\theta), 1-\epsilon, 1+\epsilon\right)A_{\theta_{\text{old}}}(a,s)}\right)
    \end{aligned}
    $$

PPO-Clip 进阶讨论

  • 副标题:PPO-Clip的损失函数究竟在做什么?为什么需要使用 \(\min\) 操作?
  • 参考链接:如何理解 PPO-CLIP 目标函数中的 clip 和 min 操作?过犹不及论 - Finch的文章 - 知乎
  • PPO的目标定义如下:
    $$
    \begin{aligned}
    \max_\theta&\ \ \mathbb{E}_{s \sim \rho_{\pi_{\theta_\text{old}}}, a \sim \pi_{\theta_\text{old}}}\min\left(\color{blue}{\frac{\pi_\theta(a|s)}{\pi_{\theta_{\text{old}}}(a|s)}A_{\theta_{\text{old}}}(s,a)}, \color{red}{clip\left(\frac{\pi_\theta(a|s)}{\pi_{\theta_{\text{old}}}(a|s)}, 1-\epsilon, 1+\epsilon\right)A_{\theta_{\text{old}}}(a,s)}\right)
    \end{aligned}
    $$
    • 注意: 发生截断时, \((1+\epsilon)A_{\theta_{\text{old}}}(a,s)\) 或 \((1-\epsilon)A_{\theta_{\text{old}}}(a,s)\) 对策略参数 \(\theta\) 的梯度为 0
      • 常数 \((1+\epsilon)\) 和 \((1+\epsilon)\) 对策略参数的梯度为 0
      • \(A_{\theta_{\text{old}}}(a,s)\) 本身与旧策略有关(但本质上也只是按照旧策略与环境交互得到 Reward 而已,也不会回传梯度到旧策略),与当前策略参数 \(\theta\) 无关,对策略参数 \(\theta\) 的梯度为 0
      • \(A_{\theta_{\text{old}}}(a,s)\) 计算优势函数时用到了价值网络,这也与当前策略参数 \(\theta\) 无关,对策略参数 \(\theta\) 的梯度为 0(特别地,\(A_{\theta_{\text{old}}}(a,s)\) 是经过 stop_gradient 得到的,也不会影响价值网络的参数)
  • \(clip + \min\) 操作讨论 ,关于 \(\min\left(\color{blue}{r(\theta)A_{\theta_{\text{old}}}(s,a)}, \color{red}{clip\left(r(\theta), 1-\epsilon, 1+\epsilon\right)A_{\theta_{\text{old}}}(a,s)}\right)\),由于 \(A_{\theta_{\text{old}}}(a,s) > 0\) 是有正有负的(\(A=Q-V\) 的加权平均),对于某个样本来说:
    • 当 \(A_{\theta_{\text{old}}}(a,s) > 0\) 时,要提升目标动作概率 \(\pi_\theta(a|s)\) :
      • 若 \(r(\theta) > 1+\epsilon\),则说明相对原始策略 \(\pi_{\theta_\text{old}(a|s)}\),\(\pi_\theta(a|s)\) 已经提升够多了 ,不希望再继续提升(偏离原始策略太多容易不稳定),\(clip + \min\) 操作可以将这个样本的目标值截断为 \((1+\epsilon)A_{\theta_{\text{old}}}(a,s)\),这与策略参数 \(\theta\) 无关 ,不会有梯度回传,即该样本相当于被废弃了(不考虑求均值会用到样本数量)
      • 若 \(r(\theta) \leq 1+\epsilon\),则目标动作概率 \(\pi_\theta(a|s)\) 还小 ,可以正常更新以提升该动作的概率(问题:这里其实是无法控制目标概率更新的幅度的,更新后的真实值可能超过 \(1+\epsilon\) 这个阈值)
    • 当 \(A_{\theta_{\text{old}}}(a,s) < 0\) 时,要降低目标动作概率 \(\pi_\theta(a|s)\) :
      • 若 \(r(\theta) < 1-\epsilon\),则说明相对原始策略 \(\pi_{\theta_\text{old}(a|s)}\),\(\pi_\theta(a|s)\) 已经降低的够多了 ,不希望再继续降低(偏离原始策略太多容易不稳定),\(clip + \min\) 操作可以将这个样本的目标值截断为 \((1-\epsilon)A_{\theta_{\text{old}}}(a,s)\),这与策略参数 \(\theta\) 无关,不会有梯度回传,即该样本相当于被废弃了(不考虑求均值会用到样本数量)
      • 若 \(r(\theta) \geq 1+\epsilon\),则目标动作概率 \(\pi_\theta(a|s)\) 较大 ,可以正常更新以降低该动作的概率(问题:这里其实是无法控制目标概率更新的幅度的,更新后的真实值可能小于 \(1-\epsilon\) 这个阈值)
    • 思考:由于 \(r(\theta) > 1+\epsilon\) 的样本动作概率不许继续提升(但可以被降低),\(r(\theta) < 1-\epsilon\) 的样本动作概率不许继续降低(但可以提升),所以整体来说,所有动作的概率都倾向于维持 \(r(\theta) \in [1-\epsilon, 1+\epsilon]\) 之间(只是倾向于,不能完全保证)
    • 严格One-Step更新下,损失函数可做如下简化:此时旧策略采样的样本仅更新一次模型即丢弃(即epoch=1,且一次更新完所有参数,batch_size足够大),且立刻会将新策略的更新同步到旧策略上,保证每次更新模型前新旧策略完全一致 ,则无需使用Clip操作和min操作(因为 \(r(\theta)=1\)),PPO目标函数将可以简化为如下形式(注意,虽然此时 \(r(\theta) = \frac{\pi_\theta(a|s)}{\pi_{\theta_{\text{old}}}(a|s)}=1\),但是必须保留 \(r(\theta)\),因为梯度传导需要分子 \(\pi_\theta(a|s)\)):
      $$
      \begin{aligned}
      \max_\theta&\ \ \mathbb{E}_{s \sim \rho_{\pi_{\theta_\text{old}}}, a \sim \pi_{\theta_\text{old}}}\left(\color{blue}{\frac{\pi_\theta(a|s)}{\pi_{\theta_{\text{old}}}(a|s)}A_{\theta_{\text{old}}}(s,a)}\right)
      \end{aligned}
      $$
      • 建议epoch大于1的理由1 :One-Step更新样本效率低,如果设置的学习率过大又会引发不稳定,所以建议还是多次epoch更新,增加样本利用率
      • 建议epoch大于1的理由2 :epoch大于1的多次更新还有补救回调的作用,当策略已经更新偏离旧策略太多时,PPO损失函数保证可以有机会被拉回来
      • 补充讨论:实际上,严格One-Step更新下,PPO降级为普通PG ,更详细的讨论见附录
    • 补充:Simplified PPO-Clip Objective中有PPO简化的推导,能更加清晰的显示PPO的 \(clip + \min\) 操作
  • 一些博客(比如:Visualize the Clipped Surrogate Objective Function)有关于Clip仅限制了单边的讨论(有效性有待商榷):
    • 当 \(A_{\theta_{\text{old}}}(a,s) > 0\) 时, \(r(\theta)\) 在Clip后的生效范围在 \(r(\theta) \in [0,1+\epsilon]\),也就是 \(1-\epsilon\) 边界会失效(问题 :此时失效是正常的吧,因为此时 \(A_{\theta_{\text{old}}}(a,s) > 0\),我们要提升目标动作概率,当前的策略目标动作概率越小,我们越应该提升,这反而是PPO的设计:为了使得整体策略不偏离旧策略太远?)
    • 当 \(A_{\theta_{\text{old}}}(a,s) < 0\) 时, \(r(\theta)\) 在Clip后的生效范围在 \(r(\theta) \in [1-\epsilon,+\infty]\),也就是 \(1+\epsilon\) 边界会失效(同上问题 :此时失效是正常的吧,因为此时 \(A_{\theta_{\text{old}}}(a,s) < 0\),我们要降低目标动作概率,当前的策略目标动作概率越大,我们越应该降低,这反而是PPO的设计:为了使得整体策略不偏离旧策略太远?)
    • 改进方案(有效性有待商榷) :在on-policy的设定下,我们认为策略新旧策略的比值 \(r(\theta)\) 不会太大,一般不会出现问题,但是off-policy设定下,可能会出现问题,所以需要再加一层Clip(参考自:Visualize the Clipped Surrogate Objective Function
      $$
      \begin{aligned}
      \max_\theta&\ \ \mathbb{E}_{s \sim \rho_{\pi_{\theta_\text{old}}}, a \sim \pi_{\theta_\text{old}}}\max\left( \color{red}{\eta A_{\theta_{\text{old}}}(a,s)}, \min\left(\color{blue}{r(\theta)A_{\theta_{\text{old}}}(s,a)}, \color{red}{clip\left(r(\theta), 1-\epsilon, 1+\epsilon\right)A_{\theta_{\text{old}}}(a,s)}\right)\right)
      \end{aligned}
      $$
      • 其中 \(\color{red}{\eta}\) 是超参数(理解:一般会比较大,比如 5 或 10 等?)
      • 注:综合上面的问题,这里的结论不一定合理 ,这种做法的有效性还有待商榷,因为 Clip 不是在控制每一个epoch更新后的 \(r(\theta)\),而是根据更新前的 \(r(\theta)\) 判断是否要继续更新对应的状态动作对
      • 如果从比值过大可能是对应异常值导致来看(比如模型推理错误导致异常值),这里确实可以做一下截断
        • 否则使用保留梯度的截断更合适(至少保留部分梯度),详情见附录
      • 特别说明:理论上来说,上述的 \(\max\left( \color{red}{\eta A_{\theta_{\text{old}}}(a,s)}, \cdot\right)\) 只会在 \(A_{\theta_{\text{old}}}(a,s) < 0\) 时生效,因为 \(A_{\theta_{\text{old}}}(a,s) > 0\) 时会被更小的上界 \(1+\epsilon\) 提前 Clip 掉(相当于 \(\color{red}{\eta}\) 没有生效)

PPO 网络更新

  • Critic 网络更新(原始论文中未明确给出 Critic 网络更新的公式,实际上 Critic 网络的更新有直接使用真实折扣奖励作为目标值TD-Error 作为损失函数和使用GAE 作为目标值等版本,这里给出TD-Error 作为损失函数的形式,更多详情见后面的章节单独讨论)
    $$
    Loss_{\text{critic}} = \sum (r_t + \gamma V^{\bar{w}}(s_{t+1}) - V^{w}(s_{t})) ^ 2
    $$
    • 这里虽然使用 Target V 网络表达,但实际上PPO一般不需要使用 Target V 网络
    • 这里V值拟合的目标是策略 \(\pi_\theta\) 对应的V值 \(V^{\pi_\theta}\)
    • \(r_t = r(s_t, a_t)\vert_{a_t \sim \pi_\theta(\cdot|s_t)}\),训练用的整个轨迹链路都是从策略 \(\pi_\theta\) 采样得到的
  • Actor网络更新
    $$
    Loss_{\text{actor}} = - \mathbb{E}_{s \sim \rho_{\pi_{\theta_\text{old}}}, a \sim \pi_{\theta_\text{old}}}\min\left(\color{blue}{r(\theta)A_{\theta_{\text{old}}}(s,a)}, \color{red}{clip\left(r(\theta), 1-\epsilon, 1+\epsilon\right)A_{\theta_{\text{old}}}(a,s)}\right)
    $$
    • 其中: \(r(\theta) = \frac{\pi_\theta(a|s)}{\pi_{\theta_{\text{old}}}(a|s)} \)

PPO Critic 网络更新的其他方式

  • 损失函数形式的选择 :在PPO算法中,Critic网络(即价值函数网络)的更新通常采用均方误差(MSE)损失Huber Loss(也称为 Smooth_l1_loss),通过梯度下降来最小化价值函数的预测误差
  • PPO中Critic的核心更新公式为 MSE损失等,但目标值 \( V_{\text{target} } \) 的计算方式可能因具体实现而异(单步TD、GAE等)。实际代码中通常结合以下步骤:
    • 从经验缓冲区采样数据 \((s_t, r_t, s_{t+1})\)
    • 计算目标值 \( V_{\text{target} } \)(如单步TD或GAE)
    • 最小化 \( L_{\text{critic} } \) 更新Critic参数 \( \theta \)
  • Critic 损失函数(MSE)
    $$
    L_{\text{critic} } = \frac{1}{2} \mathbb{E}_{(s_t) \sim \text{batch} } \left[ \left( V_{\theta}(s_t) - V_{\text{target} }(s_t) \right)^2 \right]
    $$
    • \( V_\theta(s_t) \) 是当前Critic网络的输出,\( \theta \) 为网络参数
    • \( V_{\text{target} }(s_t) \) 是目标值,可以有多种实现,具体实现见下文
可选目标1:基于 TD 误差的更新(Temporal Difference Learning)
  • Critic的目标是拟合状态值函数 \( V^\pi(s) \),通过TD误差计算当前值函数的预测与目标值的差异

  • TD目标值(单步)
    $$
    V_{\text{target} }(s_t) = r_t + \gamma V_{\text{old} }(s_{t+1})
    $$

    • \( \gamma \) 是折扣因子
    • \( V_{\text{old} } \) 是旧Critic网络的输出(稳定训练),需要注意这里不是Target网络,而是PPO每个episode可能会更新多个epoch ,需要保证更新过程中Critic网络的学习目标是不变的,即每个epoch中目标值\( V_{\text{target} }(s_t) \) 始终是不变的 ,实现时,确保目标值是提前计算得到的即可,具体实现可以如下(参考自动手学强化学习实现):
      1
      2
      3
      4
      5
      6
      7
      8
      9
      # ...
      td_target = rewards + self.gamma * self.critic(next_states) * (1 - dones)
      # ...
      for _ in range(self.epochs):
      critic_loss = torch.mean(F.mse_loss(self.critic(states), td_target.detach()))
      self.critic_optimizer.zero_grad()
      critic_loss.backward()
      self.critic_optimizer.step()
      # ...
  • 此时的Critic 损失函数相当于:
    $$
    L_{\text{critic} } = \frac{1}{2} \mathbb{E}_{(s_t) \sim \text{batch} } \left[ \left( \color{red}{r_t + \gamma V_{\text{old} }(s_{t+1})} - V_{\theta}(s_t)\right)^2 \right]
    $$

可选目标2:广义优势估计(GAE)结合多步TD
  • 当使用GAE(Generalized Advantage Estimation)时,Critic的目标值会引入多步TD误差的加权平均,进一步减少方差
  • GAE 优势函数
    $$
    A_t^{\text{GAE}(\gamma, \lambda)} = \sum_{k=0}^{T-t} (\gamma \lambda)^k \delta_{t+k}
    $$
    • 其中 \( \delta_t = r_t + \gamma V_{\text{old} }(s_{t+1}) - V_{\text{old} }(s_t) \) 是单步TD误差,\( \lambda \) 是GAE超参数
  • Critic 目标值(GAE)
    $$
    V_{\text{target} }(s_t) = A_t^{\text{GAE} } + V_{\text{old} }(s_t)
    $$
  • 此时的Critic 损失函数相当于:
    $$
    L_{\text{critic} } = \frac{1}{2} \mathbb{E}_{(s_t) \sim \text{batch} } \left[ \left( \color{red}{A_t^{\text{GAE} } + V_{\text{old} }(s_t)} - V_{\theta}(s_t)\right)^2 \right]
    $$
可选目标3:折扣真实奖励
  • 对于状态 \( s_t \),其目标值 \( V_{\text{target}}(s_t) \) 是从 \( t \) 时刻开始到回合结束的累计折扣奖励:
    $$
    V_{\text{target}}(s_t) = \sum_{k=0}^{T-t} \gamma^k r_{t+k}
    $$
    • \( T \) 是回合终止时间步,
    • \( \gamma \) 是折扣因子(如0.99),
    • \( r_{t+k} \) 是 \( t+k \) 时刻的即时奖励
  • 此时的Critic损失函数相当于:
    $$
    L_{\text{critic} } = \frac{1}{2} \mathbb{E}_{(s_t) \sim \text{batch} } \left[ \left( \color{red}{\sum_{k=0}^{T-t} \gamma^k r_{t+k}} - V_{\theta}(s_t)\right)^2 \right]
    $$
其他优化:目标网络(Target Network,一般PPO不需要)
  • 为稳定训练,Critic的目标值 \( V_{\text{target} } \) 可能通过慢更新的目标网络计算(类似DQN):
    $$
    V_{\text{target} }(s_t) = r_t + \gamma V_{\bar{\theta}}(s_{t+1})
    $$
    • 其中 \( \bar{\theta} \) 是目标网络参数,通过Polyak平均更新(一种加权平均方法,即RL中常说的软更新):
      $$
      \theta^- \leftarrow \tau \theta + (1 - \tau) \bar{\theta} \quad (\tau \ll 1)
      $$

PPO 的一些实践说明

  • 常用的形式是 PPO-Clip 形式,实践中效果更好
  • 一般来说 PPO 需要使用 Target V 网络,使用 Target V 网络会导致收敛较慢
  • Critic 网络的损失函数可以归回方法中常用的 smooth_l1_loss(即huber_loss),以减少异常值带来的影响
  • PPO-Clip 中一般设置 \(\epsilon=0.2\)
  • PPO原始论文中,每次采样到的数据会作 K 次 epochs,且不同游戏使用的次数不同,在 Mujoco 中使用 \(epochs=10\),Roboschool 中使用 \(epochs=15\),Atari 中使用 \(epochs=3\)

PPO 连续动作实现离散动作的实现主要区别

模型建模
  • 策略网络 :连续动作需要使用 \(\mu_\theta,\sigma_\theta\) 表示均值和方差,连续分布下,每个动作的概率理论上都是0,但借助概率密度函数的含义,可以通过计算

  • 采样方式 :采样时需要创建分布来采样,由于不需要梯度回传,所以不需要使用重参数法

  • Critic网络 :由于离散连续场景都用V网络,仅仅评估状态下的价值即可,与动作无关,连续动作处理不需要特殊修改

    新旧策略比值计算方式不同
  • 离线动作按照推导中的实现即可

    1
    2
    3
    4
    def compute_surrogate_obj(self, states, actions, advantage, old_log_probs, actor):  # 计算策略目标
    log_probs = torch.log(actor(states).gather(1, actions))
    ratio = torch.exp(log_probs - old_log_probs)
    return torch.mean(ratio * advantage)
  • 连续动作需要使用概率密度函数来实现

    1
    2
    3
    4
    5
    6
    def compute_surrogate_obj(self, states, actions, advantage, old_log_probs, actor):
    mu, std = actor(states)
    action_dists = torch.distributions.Normal(mu, std)
    log_probs = action_dists.log_prob(actions) # 返回\log(f(actions)),f为概率密度函数
    ratio = torch.exp(log_probs - old_log_probs) # 这里可以直接用于算概率之间的比值理论是概率密度函数的含义
    return torch.mean(ratio * advantage) # 注意torch内部实现这里的梯度可以回传到actor网络上(基于参数mu,std可以运算得到log_prob,所以梯度可以回传)

PPO 的训练技巧

  • 参考:影响PPO算法性能的10个关键技巧(附PPO算法简洁Pytorch实现)
  • 亲自测试实践总结(主要以 ‘CartPole-v0’ 环境测试):
    • Advantage Normalization 能让 Critic Loss 和 Policy Loss 都更加平滑(比较稳定),但是对整体回报不一定有收益,某些情况下还出现了波动(待确定原因)
    • State Normalization 会严重拖慢训练速度,实际测试时发现一个有趣的现象:使用 State Normalization 技巧后,会导致策略先逐步收敛到最优策略再突然下降,降幅很大且不再恢复,状态和策略都陷入了崩溃状态(结论是 State Normalization 技巧要慎用)
    • Orthogonal Initialization 一般都会有正向的效果,虽然某些场景下不一定提升很大
    • Reward Scaling 会导致策略不稳定(甚至无法收敛)
    • 增加 Policy Entropy 会导致收敛不稳定,且超参数很敏感
  • 附上述实验的代码:
    >>>点击展开折叠内容...
    1
     

Advantage Normalization

  • 最早出自The Mirage of Action-Dependent Baselines in Reinforcement Learning,对 Advantage Function 进行归一化,用于提升 PG 方法的性能

  • 具体方法:减去均值除以方差

  • 理解:

    • 归一化 将 Advantage 的均值强制设为 0:
      • 这意味着在当前的 Batch 中,大约有一半的动作会被认为是“好动作”(\(A>0\),增加概率),另一半是“坏动作”(\(A<0\),减少概率)
      • 这能有效防止 Policy 总是往一个方向跑(例如 Reward 全是正数时),显著加快收敛
    • 归一化 将方差设为 1
      • 这使得 Loss 的量级不会因为 Reward 的绝对数值大小而剧烈波动,使得超参数(如 Learning Rate)更容易调节
  • 实现方案:

    • 方案一:Batch Advantage Normalization(BAN),对当前 Batch 的所有 Advantage 求均值和方差
    • 方案二:Mini-Batch Advantage Normalization(MBAN),仅对当前 Mini-Batch Advantage Normalization
  • 实践中,BAN 效果最好,MBAN 效果次之,不使用任何 AN 效果最差;

    • 理解,方案一和方案二并未限定具体采样的轨迹是多少个,但是主要思路是尽量在更多的样本上统计均值和方差,减少波动,这样效果更好些
  • 关于 MBAN,ppo-implementation-details博客中有详细实现

  • 问题:GAE 中还需要做 Advantage Normalization 吗?是否是在计算 GAE 之前做归一化?

    • 回答,可以做,是在 GAE 之后做
  • 在一个带 GAE 和 Advantage Normalization 的 PPO 的一个训练迭代中,流程通常如下:

    • 第一步:采集数据(Rollout) :Agent 与环境交互,采集一定步数的数据。假设采集了 \(N\) 个步骤(例如 2048 步)
    • 计算 GAE :利用这 \(N\) 个数据,计算出每一个时间步的 Advantage值,得到一个向量 \(\mathbf{A} = [A_1, A_2, …, A_N]\)
    • 计算统计量(非滑动) :直接计算这 \(N\) 个数据的均值和标准差:
      $$ \mu_{batch} = \frac{1}{N} \sum_{i=1}^{N} A_i $$
      $$ \sigma_{batch} = \sqrt{\frac{1}{N} \sum_{i=1}^{N} (A_i - \mu_{batch})^2} $$
    • 执行归一化
      $$ A_{norm}^{(i)} = \frac{A_i - \mu_{batch}}{\sigma_{batch} + \epsilon} $$
    • Mini-batch 训练 :将归一化后的数据打乱(Shuffle),切分成多个小批次(Mini-batch)进行 SGD 更新
为什么不用滑动平均?
  • Advantage 的定义是相对的 :Advantage \(A(s,a) = Q(s,a) - V(s)\) 衡量的是动作 \(a\) 比“平均表现”好多少
  • 分布漂移(Non-stationarity) :随着 Policy 的更新,Agent 的能力在变,Value Function 也在变
    • 上一次迭代算出的 Advantage 分布与当前迭代的分布可能完全不同
    • 如果使用历史数据的滑动平均,会引入过时的统计信息,导致对当前策略评估的偏差
  • Advantage Normalization 的核心目的是为了降低方差(Variance Reduction) 并确保 Policy Gradient 的更新幅度在不同 Batch 间保持稳定,而不是为了将数据缩放到某个固定的物理尺度
代码示例
  • 类似 开源框架 Stable Baseline3 的伪代码逻辑如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    # 采集数据并计算 GAE (在 rollout_buffer 中完成)
    rollout_buffer.compute_returns_and_advantage(...)

    # 在准备训练数据时进行归一化
    # 注意:这里是对整个 buffer 的 advantage 进行操作
    advantages = rollout_buffer.advantages
    if self.normalize_advantage:
    # 直接计算当前 buffer 的均值和标准差
    advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)

    # 之后再进行 mini-batch 循环更新
    for epoch in range(n_epochs):
    for rollout_data in rollout_buffer.get(batch_size):
    # 使用已经归一化好的 advantages 计算 loss
    pass

State Normalization

  • 对状态做归一化

  • State Normalization 的核心在于,与环境交互的过程中,维护一个动态的关于所有经历过的所有 State 的 Mean 和 Std, 然后对当前的获得的 State 做normalization

  • 经过 Normalization 后的 State 符合 Mean=0,Std=1 的正态分布,用这样的状态作为神经网络的输入,更有利于神经网络的训练

  • 采用滑动增量更新的方式(详细证明见附录):

    • 均值:\(\mu_{\text{new}} = \mu_{\text{old}} + \frac{1}{n}(x-\mu_{\text{old}})\)
    • 方差中间变量:\(S_{\text{new}} = S_{\text{old}} + (x-\mu_{\text{old}})\cdot(x-\mu_{\text{new}})\)
      • 注意这个值除以 \(n\) 才是方差
  • 代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    class RunningMeanStd:
    # Dynamically calculate mean and std
    def __init__(self, shape): # shape:the dimension of input data
    self.n = 0
    self.mean = np.zeros(shape)
    self.S = np.zeros(shape)
    self.std = np.sqrt(self.S)

    def update(self, x):
    x = np.array(x)
    self.n += 1
    if self.n == 1:
    self.mean = x
    self.std = x
    else:
    old_mean = self.mean.copy()
    self.mean = old_mean + (x - old_mean) / self.n
    self.S = self.S + (x - old_mean) * (x - self.mean)
    self.std = np.sqrt(self.S / self.n )

    class Normalization:
    def __init__(self, shape):
    self.running_ms = RunningMeanStd(shape=shape)

    def __call__(self, x, update=True):
    # Whether to update the mean and std,during the evaluating,update=Flase
    if update:
    self.running_ms.update(x)
    x = (x - self.running_ms.mean) / (self.running_ms.std + 1e-8)

    return x
  • 在模型实现时,状态归一化这个函数是添加到策略网络和状态网络层的输入端的,实现 Demo 如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    def forward(self, x):
    if config.use_state_norm:
    norm_x = []
    for i in range(x.size(0)): # 逐个动作归一化
    norm_x.append(torch.tensor(self.state_norm(x[i]), dtype=torch.float32))
    x = torch.stack(norm_x, dim=0)
    x = F.relu(self.fc1(x))
    x = F.relu(self.fc2(x))
    return self.fc3(x)

Reward Normalization

  • 与 State Normalization 的方案,动态维护所有获得过的 Reward 的 Mean 和 Std,然后再对当前的 Reward 做 Normalization
  • 问题:是对单次 Reward 做了 Normalization 吗?如果 Reward 已经做了 Normalization,GAE 中实际上就已经使用了标准化后的 Reward 了,Advantage 是否还需要做呢?
  • 常常被替换为 Reward Scaling
  • 虽然 Advantage 不用滑动平均,但 Reward Normalization 通常使用滑动平均的
    • Reward Normalization
      • 目的是让环境反馈的 Reward 尺度统一(比如不管是股票涨跌的金额,还是游戏得分,都缩放到 1 左右)
      • 因为环境的物理属性是不变的,所以用滑动平均(Running Mean/Std)来估计环境 Reward 的全局统计特性是合理的
    • Advantage Normalization :是对“优势”的归一化,是策略更新内部的一个数值稳定技巧,只关注当前批次

Reward Scaling

  • 相关论文:PPO-Implementation matters in deep policy gradients A case study on PPO and TRPO
  • Reward Scaling 与 Reward Normalization 的区别在于,Reward Scaling 是动态计算一个 standard deviation of a rolling discounted sum of the rewards,然后只对当前的 reward 除以这个 std(不减去均值?)
  • Reward Normalization 和 Reward Scaling 二选一即可,建议使用 Reward Scaling 而不是 Reward Normalization 即可
  • 代码实现
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class RewardScaling:
    def __init__(self, shape, gamma):
    self.shape = shape # reward shape=1
    self.gamma = gamma # discount factor
    self.running_ms = RunningMeanStd(shape=self.shape)
    self.R = np.zeros(self.shape)

    def __call__(self, x):
    self.R = self.gamma * self.R + x
    self.running_ms.update(self.R)
    x = x / (self.running_ms.std + 1e-8) # Only divided std
    return x

    def reset(self): # When an episode is done,we should reset 'self.R'
    self.R = np.zeros(self.shape)

Policy Entropy(Entropy Bonus)

  • 在 Actor 的 Loss 中增加一项策略熵,在最大化收益的同时,最大化策略熵,增加探索性(理解:同时有正则的作用)
  • 但是增加以后会新增加一个新的超参数,且模型对该参数很敏感

Learning Rate Decay

  • 学习率逐步衰减
  • 代码实现
    1
    2
    3
    4
    5
    6
    7
    def lr_decay(self, total_steps):
    lr_a_now = self.lr_a * (1 - total_steps / self.max_train_steps)
    lr_c_now = self.lr_c * (1 - total_steps / self.max_train_steps)
    for p in self.optimizer_actor.param_groups:
    p['lr'] = lr_a_now
    for p in self.optimizer_critic.param_groups:
    p['lr'] = lr_c_now

Gradient Clip

  • 梯度裁剪
  • 代码实现
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    # Update actor
    self.optimizer_actor.zero_grad()
    actor_loss.mean().backward()
    if self.use_grad_clip: # Trick 7: Gradient clip
    torch.nn.utils.clip_grad_norm_(self.actor.parameters(), 0.5)
    self.optimizer_actor.step()

    # Update critic
    self.optimizer_critic.zero_grad()
    critic_loss.backward()
    if self.use_grad_clip: # Trick 7: Gradient clip
    torch.nn.utils.clip_grad_norm_(self.critic.parameters(), 0.5)
    self.optimizer_critic.step()

Orthogonal Initialization

  • 正交初始化(Orthogonal Initialization)是为了防止在训练开始时出现梯度消失、梯度爆炸等问题所提出的一种神经网络初始化方式。具体的方法分为两步:
    • 用均值为 0,标准差为1的高斯分布初始化权重矩阵
    • 对这个权重矩阵进行奇异值分解,得到两个正交矩阵,取其中之一作为该层神经网络的权重矩阵
  • 代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    # orthogonal init
    def orthogonal_init(layer, gain=1.0):
    nn.init.orthogonal_(layer.weight, gain=gain)
    nn.init.constant_(layer.bias, 0)

    class Actor_Gaussian(nn.Module):
    def __init__(self, args):
    super(Actor_Gaussian, self).__init__()
    self.max_action = args.max_action
    self.fc1 = nn.Linear(args.state_dim, args.hidden_width)
    self.fc2 = nn.Linear(args.hidden_width, args.hidden_width)
    self.mean_layer = nn.Linear(args.hidden_width, args.action_dim)
    self.log_std = nn.Parameter(torch.zeros(1, args.action_dim)) # We use 'nn.Paremeter' to train log_std automatically
    if args.use_orthogonal_init:
    print("------use_orthogonal_init------")
    orthogonal_init(self.fc1)
    orthogonal_init(self.fc2)
    orthogonal_init(self.mean_layer, gain=0.01)

    def forward(self, s):
    s = torch.tanh(self.fc1(s))
    s = torch.tanh(self.fc2(s))
    mean = self.max_action * torch.tanh(self.mean_layer(s)) # [-1,1]->[-max_action,max_action]
    return mean

    def get_dist(self, s):
    mean = self.forward(s)
    log_std = self.log_std.expand_as(mean) # To make 'log_std' have the same dimension as 'mean'
    std = torch.exp(log_std) # The reason we train the 'log_std' is to ensure std=exp(log_std)>0
    dist = Normal(mean, std) # Get the Gaussian distribution
    return dist

    class Critic(nn.Module):
    def __init__(self, args):
    super(Critic, self).__init__()
    self.fc1 = nn.Linear(args.state_dim, args.hidden_width)
    self.fc2 = nn.Linear(args.hidden_width, args.hidden_width)
    self.fc3 = nn.Linear(args.hidden_width, 1)
    if args.use_orthogonal_init:
    print("------use_orthogonal_init------")
    orthogonal_init(self.fc1)
    orthogonal_init(self.fc2)
    orthogonal_init(self.fc3)

    def forward(self, s):
    s = torch.tanh(self.fc1(s))
    s = torch.tanh(self.fc2(s))
    v_s = self.fc3(s)
    return v_s

Adam Optimizer Epsilon Parameter

  • 实践中,从官方默认值 1e-8 改成 1e-5
  • 原因?

Tanh Activation Function

  • 将 ReLU 换成 tanh 激活函数
  • 建议 PPO 算法默认使用激活函数
  • 原因?

Value Clipping

  • 核心目标:为了训练的稳定性,对价值进行裁剪

  • 实现方式1:Stable Baselines3 的 PPO 实现中有如下代码 /ppo/ppo.py#L234-L244

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    if self.clip_range_vf is None:
    # No clipping
    values_pred = values
    else:
    # Clip the difference between old and new value
    # NOTE: this depends on the reward scaling
    values_pred = rollout_data.old_values + th.clamp(
    values - rollout_data.old_values, -clip_range_vf, clip_range_vf
    )
    # Value loss using the TD(gae_lambda) target
    value_loss = F.mse_loss(rollout_data.returns, values_pred)
    • 从代码可以看出,Stable Baselines3 认为只要是当前价值网络的预估值超过旧网络预估值的情况,都是异常情况,则当前样本不置信,将当前样本的损失设置为固定值(通过Clip实现,此时该样本对价值网络参数的梯度为0,相当于丢弃样本了)
  • 实现方式2:一些地方也看到过类似表达(最早是在RLHF中看到的)

    1
    2
    3
    4
    5
    6
    def critic_loss_fn(self, values, old_values, returns, mask):
    values_clipped = torch.clamp(values, old_values - clip_range_vf, old_values + clip_range_vf)
    vf_loss1 = (values - returns) ** 2
    vf_loss2 = (values_clipped - returns) ** 2
    vf_loss = torch.mean(torch.max(vf_loss1, vf_loss2) * mask) / mask.sum()
    return vf_loss
    • 忽略上面的 mask 部分,是语言模型训来特有的
    • 这种实现的基本思路是:如果 Clip 后的价值与真实值的误差(MSE)大于当前策略,则使用 Clip 后的,此时该样本的 Loss 对价值网络参数的梯度为0,相当于丢弃样本了
    • 理解 max 操作:当策略误差已经比 Clip 后的值对应的误差还要小了,就不要使用该误差了(也就是说当前样本的误差相对上一轮已经小了一定大小了,不需要继续更新了,这种做法称为保守更新),只有 MSE 误差相对原始价值网络对应的误差变小时,才丢弃样本

附录-均值方差滑动更新公式证明

均值更新推导

  • 均值更新推导详情:
    $$
    \begin{align}
    \mu_n &= \frac{1}{n}\sum_{i=1}^n x_i \\
    &= \frac{1}{n}(\sum_{i=1}^{n-1} x_i + x_n) \\
    &= \frac{1}{n}(\sum_{i=1}^{n-1} x_i + x_n) \\
    &= \frac{1}{n}((n-1)\cdot\frac{1}{n-1}\sum_{i=1}^{n-1} x_i + x_n) \\
    &= \frac{1}{n}((n-1)\mu_{n-1} + x_n) \\
    &= \frac{1}{n}(n\mu_{n-1} + x_n - \mu_{n-1}) \\
    &= \mu_{n-1} + \frac{1}{n}(x_n - \mu_{n-1})
    \end{align}
    $$

方差更新推导

  • 将方差中间变量 \(S_n\) 展开(这里 \(S_n\) 除以n才是方差)有:
    $$
    \begin{align}
    S_n &= \sum_{i=1}^{n} (x_i - \mu_n)^2 \\
    &= \sum_{i=1}^{n-1} (x_i - \mu_n)^2 + (x_n - \mu_n)^2 \\
    &= \sum_{i=1}^{n-1} (x_i - \mu_{n-1} + \mu_{n-1} - \mu_n)^2 + (x_n - \mu_n)^2 \\
    &= \sum_{i=1}^{n-1} (x_i - \mu_{n-1})^2 + 2(\mu_{n-1} - \mu_{n})\sum_{i=1}^{n-1}(x_i - \mu_{n-1}) + (n-1)(\mu_{n-1} -\mu_n)^2 + (x_n - \mu_n)^2 \\
    &= S_{n-1} + 2(\mu_{n-1} - \mu_{n})(\sum_{i=1}^{n-1} x_i - (n-1)\mu_{n-1}) + (n-1)(\mu_{n-1} -\mu_n)^2 + (x_n - \mu_n)^2 \\
    &= S_{n-1} + 2(\mu_{n-1} - \mu_{n})((n-1)\mu_{n-1} - (n-1)\mu_{n-1}) + (n-1)(\mu_{n-1} -\mu_n)^2 + (x_n - \mu_n)^2 \\
    &= S_{n-1} + 2(\mu_{n-1} - \mu_{n})\cdot 0 + (n-1)(\mu_{n-1} -\mu_n)^2 + (x_n - \mu_n)^2 \\
    &= S_{n-1} + (n-1)(\mu_{n-1} -\mu_n)^2 + (x_n - \mu_n)^2 \\
    &= S_{n-1} + (n-1)(\mu_{n-1} -\mu_n)(\mu_{n-1} -\mu_n) + (x_n - \mu_n)^2 \\
    &= S_{n-1} + ((n-1)\mu_{n-1} -(n-1)\mu_n)(\mu_{n-1} -\mu_n) + (x_n - \mu_n)^2 \\
    &= S_{n-1} + (n\mu_{n} - x_n -(n-1)\mu_n)(\mu_{n-1} -\mu_n) + (x_n - \mu_n)^2 \\
    &= S_{n-1} + (\mu_{n} - x_n)(\mu_{n-1} -\mu_n) + (x_n - \mu_n)^2 \\
    &= S_{n-1} + (x_n - \mu_{n})(\mu_n - \mu_{n-1}) + (x_n - \mu_n)^2 \\
    &= S_{n-1} + (x_n - \mu_{n})(\mu_n - \mu_{n-1} + x_n - \mu_n)\\
    &= S_{n-1} + (x_n - \mu_{n})(x_n - \mu_{n-1})\\
    \end{align}
    $$
  • 最终有: \(S_n = S_{n+1} + (x_n - \mu_{n})(x_n - \mu_{n-1})\)

DPPO(Distributed PPO)

  • DPPO是PPO的分布式版本,引入了分布式计算的概念,允许多个计算节点(或智能体)并行地与环境交互,收集数据,并将这些数据用于更新全局模型
  • 分布式架构不仅加快了数据收集的速度,还提高了算法处理大规模并行任务的能力,使得学习过程更加高效

附录:一次采样仅更新一次的 PPO 讨论

  • 问题 :一次采样仅更新一次的 PPO 下,此时是 on-policy 的场景,为什么损失函数看起来和普通的 PG 不相等?(注:可以回顾一下,普通 PG 的更新公式是严格按照 on-policy 更新推导的,二者理应相等)
  • 回答 :其实此时两者的更新公式是一致的
  • 补充说明 :一次采样仅更新一次的 PPO 场景下,此时旧策略采样的样本仅更新一次模型即丢弃(即 epoch=1,且一次更新完所有参数,batch_size 足够大),且立刻会将新策略的更新同步到旧策略上,保证每次更新模型前新旧策略完全一致

普通策略梯度更新公式

  • 普通策略梯度的更新为:
    $$\theta \leftarrow \theta + \alpha \nabla_\theta \log \pi_\theta(a_t|s_t) G_t^n$$
  • 以上梯度更新对应的损失函数为:
    $$ Loss(\theta) = - \log \pi_\theta(a_t|s_t) G_t^n $$
  • 进一步求导有:
    $$\nabla_\theta Loss(\theta) = - \frac{\nabla_\theta \pi_\theta(a_t|s_t)}{\pi_\theta(a_t|s_t)} G_t^n$$
  • 注意,以上更新使用的 \(s_t,a_t,G_t^n\) 等均来源于当前策略,由于此时有 \(\pi_\theta = \pi_{\theta_\text{old}}\),所以上面的更新公式也可以写成
    $$\nabla_\theta Loss(\theta) = - \color{blue}{\frac{\nabla_\theta \pi_\theta(a_t|s_t)}{\pi_{\theta_\text{old}}(a_t|s_t)}} \color{red}{G_t^n}$$

PPO 简化后的更新公式

  • 一次采样仅更新一次的PPO,其简化后的损失函数为:
    $$ Loss(\theta) = - \mathbb{E}_{s \sim \rho_{\pi_{\theta_\text{old}}}, a \sim \pi_{\theta_\text{old}}}\left(\frac{\pi_\theta(a|s)}{\pi_{\theta_{\text{old}}}(a|s)}A_{\theta_{\text{old}}}(s,a)\right)$$
  • 将期望形式转换为采样后有:
    $$ Loss(\theta) = - \frac{\pi_\theta(a|s)}{\pi_{\theta_{\text{old}}}(a|s)}A_{\theta_{\text{old}}}(s,a)$$
  • 进一步求导有:
    $$ \nabla_\theta Loss(\theta) = - \color{blue}{\frac{\nabla_\theta \pi_\theta(a|s)}{\pi_{\theta_{\text{old}}}(a|s)}}\color{red}{A_{\theta_{\text{old}}}(s,a)}$$
    • 注:此时有 \(\pi_\theta = \pi_{\theta_\text{old}}\)

总体对比

  • 总结来看,针对一次采样一次更新的 PPO,简化后的更新公式与普通 PG 完全相同,更新使用的样本 \((s,a)\) 也都是从当前策略采样的(即 on-policy 场景),两者的唯一区别是梯度权重选择:
    • 简化 PPO:基于当前策略采样得到的 GAE 估计 \(A_{\theta_{\text{old}}}(s,a)\)
    • 普通 PG:基于当前策略采样的蒙特卡罗收益 \(G_t^n\),除了 REINFORCE 外,其他的方法实际也常用Q函数或者优势函数 A 等估计值来替代,故简化后的 PPO 实际上可以看做是梯度权重是 GAE 的普通 PG 算法

附录:Dual-Clip PPO

Dual-Clip PPO 核心方法总结

设计背景:传统 PPO 的局限性

  • 在 MOBA 1v1 游戏的大规模分布式训练中,传统 PPO 面临两大核心问题:
    • 策略偏差过大 :训练数据来自多源历史策略(如不同训练阶段的 AI 自对弈轨迹),这些轨迹与当前训练的目标策略 \(\pi_{\theta}\) 差异显著,导致概率比 \(r_t(\theta) = \frac{\pi_{\theta}(a_t|s_t)}{\pi_{\theta_{old} }(a_t|s_t)}\) 可能异常巨大
    • 方差失控 :当优势函数估计值 \(\hat{A}_t < 0\)(即动作 \(a_t\) 为“劣势动作”)时,巨大的 \(r_t(\theta)\) 会使 \(r_t(\theta) \cdot \hat{A}_t\) 产生无界负向偏差,导致策略更新方向混乱,训练难以收敛

核心改进逻辑:双重裁剪机制

  • 传统 PPO :仅通过单一裁剪(Clip)限制概率比 \(r_t(\theta)\) 的范围(\(1-\epsilon \leq r_t(\theta) \leq 1+\epsilon\)),以避免策略更新幅度过大;
  • Dual-Clip PPO :在传统 PPO 的基础上,针对 \(\hat{A}_t < 0\) 的场景增加第二重裁剪 ,通过下界限制 \(r_t(\theta) \cdot \hat{A}_t\) 的负向偏差,具体逻辑如下:
    • 第一重裁剪(继承传统 PPO) :对概率比 \(r_t(\theta)\) 进行范围约束,确保策略更新“贴近”历史策略,避免突变:
      \(clip(r_t(\theta), 1-\epsilon, 1+\epsilon)\),其中 \(\epsilon\) 为超参数(实验中设为 0.2)
    • 第二重裁剪(新增) :当 \(\hat{A}_t < 0\) 时,对 \(r_t(\theta) \cdot \hat{A}_t\) 增加下界 \(c \cdot \hat{A}_t\)(\(c > 1\) 为超参数,实验中设为 3),限制其负向偏差的最大值
  • 这一改进的核心逻辑是:即使 \(r_t(\theta)\) 异常大,“劣势动作”的损失也不会无限制减小,避免策略被极端样本误导
    • 但这也限制了超过 reference 组过高的动作概率朝下更新,使用软性的梯度缩放可能会更好(即降低这个比例,同时保持梯度继续更新)

数学表达:目标函数定义

  • Dual-Clip PPO 的核心是优化以下目标函数,通过“先 min 后 max”的双重裁剪实现稳定更新:
  • 当 \(\hat{A}_t < 0\) 时,目标函数为:
    $$
    \hat{\mathbb{E} }_t\left[ \max\left( \min\left( r_t(\theta) \cdot \hat{A}_t,\ clip(r_t(\theta), 1-\epsilon, 1+\epsilon) \cdot \hat{A}_t \right),\ c \cdot \hat{A}_t \right) \right]
    $$
    • \(\hat{\mathbb{E} }_t\):对批量样本的经验期望;
    • \(r_t(\theta)\):当前策略与历史策略的概率比;
    • \(\hat{A}_t\):优势函数估计值(衡量动作 \(a_t\) 相对平均水平的优劣);
    • \(\epsilon = 0.2\)、\(c = 3\):实验验证的最优超参数,平衡探索与收敛

关键优势:适配 MOBA 训练需求

  • 注:Dual-Clip PPO 针对 MOBA 1v1 游戏的训练特性,解决了传统算法的核心痛点,论文中提到的以下具体优势主要针对这个场景
  • 保证收敛稳定性 :通过第二重裁剪限制负向偏差,即使在多源离线数据(如百万级 CPU 生成的自对弈轨迹)场景下,也能避免策略更新“失控”,实验中 AI 训练 80 小时后 Elo 评分趋于稳定(达到职业选手水平)
  • 适配大规模分布式训练 :支持“离线数据生成-在线模型训练”解耦的系统架构(如 60 万 CPU 生成样本、1064 块 GPU 训练),无需依赖在线实时采样,大幅提升训练效率(单 GPU 每秒处理 8 万样本)
  • 兼容多标签动作 decoupling :MOBA 游戏的动作需拆解为“按钮类型(如技能/移动)+ 目标单位(如敌方英雄/小兵)”等独立标签,Dual-Clip PPO 可与“控制依赖解耦”策略结合,对每个动作标签独立优化,同时保证整体策略收敛

实际效果展示

  • 在《王者荣耀》1v1 模式的实验中,Dual-Clip PPO 是 AI 击败职业选手的关键组件之一:
  • 与传统 PPO 相比,Dual-Clip PPO 使训练收敛时间缩短约 20% ,且 AI 在与职业选手的 BO5 对战中胜率达 100%(如法师英雄貂蝉 3:0 击败联赛顶尖法师选手);
  • 结合“动作掩码(Action Mask)”“目标注意力(Target Attention)”等策略后,AI 在 2100 场公开对战中胜率达 99.81%,验证了其在复杂控制场景下的有效性

思考

  • 适用于 off-policy 场景,且需要较为严重的 off-policy 才需要
  • 改成保留梯度的截断方式理论会更合适,既可以避免异常点,又可以保证已经过高的动作概率得以被惩罚
    • 注:截止到 2025 年,快手发布的 OneRec 也提出了一种方法 Early Clipped GRPO, 其中用到的方法就是本文提到的保留梯度的截断方式