这份笔记是为了复现 PPO 整理的,按照采样、优势估计、损失计算、参数更新的顺序走一遍。公式记号尽量和 Schulman 的两篇原文(PPO 2017、GAE 2015)保持一致。这篇文章主要记 PPO 论文和实现里容易对不上的地方,代码复现部分下一篇再讲。

原论文链接:

[1707.06347] Proximal Policy Optimization Algorithms

前置知识

原本的策略梯度

DQN 是学一个“动作价值函数” $Q(s,a)$,然后选 $Q$ 最大的动作。Policy Gradient 是直接学一个策略 $\pi_\theta(a \mid s)$,让网络直接输出动作概率,或者输出连续动作分布的参数。

DQN 里网络做的是:输入 state,输出每个动作的 $Q$。训练目标是让 $Q$ 满足 Bellman 方程:

所以 DQN 本质上是:

  1. 先学“每个动作有多好”。
  2. 再根据 $Q$ 值选动作。

Policy Gradient 的思路

Policy Gradient 直接让神经网络表示策略:

它表示在状态 $s$ 下选择动作 $a$ 的概率。智能体从这个概率分布里采样动作:

所以 Policy Gradient 的核心是直接调整策略参数 $\theta$,让好动作出现的概率变大。

最基础的写法里,会用 $\log \pi_\theta(a_t \mid s_t) \times G_t$ 构造 policy objective。代码里对应的直觉就是:

1
log_prob * return

如果某个动作后面的回报高,就提高这个动作的 log_prob;如果回报低,就压低这个动作的 log_prob

策略梯度表达式

论文里普通 policy gradient 的估计写成:

也就是说,策略梯度由两部分组成:

  • $\nabla\theta \log \pi\theta(a_t \mid s_t)$:负责告诉网络当前动作概率该怎么变。它是当前动作的 log probability 对参数 $\theta$ 的梯度。
  • $\hat{A}_t$:负责告诉网络这个动作到底好不好,以及好多少或者差多少。

这个公式给的是一个梯度向量 $\hat{g}$,也就是优化器真正需要的更新方向。

拆开看:

表示当前动作概率对参数 $\theta$ 的敏感方向,也就是当前概率对 $\theta$ 的梯度。

表示这个动作比预期好还是差,以及好多少或者差多少。

loss 表达式

策略梯度里真正想最大化的是 policy objective:

如果先不考虑 PPO 的 ratio 和 clip,最朴素的 policy loss 可以写成:

1
policy_loss = -(log_prob * advantage).mean()

负号是因为优化器默认做最小化,而策略目标本身是要最大化。这个符号后面 PPO 里也一样,policy objective 要最大化,代码里的 policy_loss 就要取负。

Policy Gradient / PPO 的 rollout buffer

Policy Gradient / PPO 的 rollout buffer,和 SARSA 里的 $(s,a,r,s’,a’)$ 很像,都是在记录“智能体真实和环境交互过的一段经历”。区别在于:SARSA 存一条 transition 就可以做 TD 更新;Policy Gradient 往往要存一整段轨迹,因为它要回头算这个动作后面到底带来了多少累计收益。

SARSA 存的是:

1
s_t, a_t, r_t, s_{t+1}, a_{t+1}

它用这些量学的是:

1
动作价值 Q(s,a)

Policy Gradient / PPO 存的量会更多,最少也要有:

1
s_t, a_t, r_t, done_t, log_prob_t, value_t

其中 log_prob_t 是动作的对数概率。连续动作里,它通常是某个动作在高斯分布下的对数概率密度。这个量后面算 ratio 时会用到。

Policy Gradient 直接学的是:

1
策略 π(a|s)

普通 PG 局限性

普通 Policy Gradient 最大的问题有三个。

第一,样本效率低。一批轨迹通常只更新一次,用完就扔,因为数据是由旧策略采来的,而策略一更新,数据就“过期”了。

第二,方差大。同一个动作可能因为环境随机性、初始状态不同,导致 return 差很多,所以训练不稳定。

第三,更新步子容易太大。如果对同一批数据多训练几轮,策略可能变化很大,导致原来的 log_prob 和现在的策略不匹配,性能可能直接崩。

PPO 论文第 2.1 节明确说,虽然对同一批轨迹上的普通 PG loss 做多步优化很诱人,但这样没有充分理论保证,经验上经常导致破坏性的大策略更新。

PPO 核心

这里看 PPO 论文里的两个图,也是 PPO 最主要的思想。

PPO clip objective

第一张图把单个样本的 clipped 目标画成关于 ratio $r$ 的曲线:

左右两幅分别对应 $A>0$ 和 $A<0$。红点都在 $r=1$ 处,也就是更新刚开始、新旧策略还一样时的位置。

$A>0$ 时(左图),动作好,想提高它的概率。目标随 $r$ 线性上升,但到 $r=1+\epsilon$ 之后变平。也就是说,就算把概率提得再高,目标也不再增加,没有动力把策略往这个方向推过头。注意 clip 只发生在右侧,也就是 $r$ 偏大的那一边;左侧 $r<1-\epsilon$ 仍然是斜线。

$A<0$ 时(右图),动作差,想降低它的概率。目标在 $r<1-\epsilon$ 这一段是平的,过了 $1-\epsilon$ 之后,随 $r$ 增大持续往下掉。这里 clip 发生在左侧,也就是 $r$ 偏小那一边;右侧没有下限,会一直惩罚下去。

把两幅图合起来看,规律是:clip 只在“朝有利方向更新过头”时把目标削平,去掉继续过度更新的动力;而“朝不利方向跑”的那一侧不设限,仍然保留梯度。

PPO clipped objective and KL

第二张图(PPO 论文 Figure 2)画的是 Hopper-v1 上第一次更新时,几个目标量随策略参数变化的曲线。

先说横坐标,它不是训练时间。横坐标是 linear interpolation factor,表示在参数空间里,从旧参数 $\theta{\text{old}}$(取值 0)到一次 PPO 更新后得到的新参数 $\theta{\text{new}}$(取值 1)之间做线性插值,并且向两边延伸。它是沿“这一次更新方向”切出来的一条一维剖面,用来观察如果沿这个方向走得更远会发生什么,跟训练步数和时间没关系。

四条曲线:

  • $L^{\text{CPI}} = \hat{\mathbb{E}}_t[r_t A_t]$(橙):未截断的代理目标,一路往上涨没有上界,单看它会把策略推到任意远。
  • $\hat{\mathbb{E}}_t[\text{clip}(r_t, 1-\epsilon, 1+\epsilon)A_t]$(绿):截断项,走到一定程度就饱和、压平。
  • $L^{\text{CLIP}} = \hat{\mathbb{E}}_t[\min(r_t A_t,\ \text{clip}(r_t,1-\epsilon,1+\epsilon)A_t)]$(红):取 min 之后的真正目标,先升,在 1 稍微靠右的地方到达峰值,再往后开始下降。
  • $\hat{\mathbb{E}}_t[\text{KL}_t]$(蓝):插值策略和旧策略之间的 KL 散度。

重点是红色的 $L^{\text{CLIP}}$ 有一个峰,而这个峰恰好落在 KL 约等于 0.02 的地方。也就是说,优化 $L^{\text{CLIP}}$ 自然会停在一个新旧策略差异不大的位置,而不像 $L^{\text{CPI}}$ 那样越走越高、把策略推飞。

为什么只 clip 不够,还要套一个 min

关键在于:如果目标写成纯 clip 版本:

那么一旦 $r$ 落进被截断的平台区,梯度就是零。不管这次更新是变好还是变坏,都没有信号。

举个会出问题的例子。设 $A>0$,这个动作是好的,应该提高概率。但某次更新反而把概率降下去了,$r$ 掉到 $1-\epsilon$ 以下。这时候希望有梯度把 $r$ 拉回来。可是纯 clip 在 $r<1-\epsilon$ 处取 $\text{clip}=1-\epsilon$,目标变成常数 $(1-\epsilon)A$,梯度为零,策略可以一直停在这个“变坏了”的位置而不受惩罚。

加上 min 就能修正。$A>0$、$r<1-\epsilon$ 时:

所以 $\min$ 取的是没被截断的 $rA$,梯度恢复,把 $r$ 往回拉。换句话说:

  • 朝有利方向更新过头($A>0$ 且 $r>1+\epsilon$,或 $A<0$ 且 $r<1-\epsilon$):min 取截断项,目标变平,不奖励过度更新。
  • 朝不利方向跑($A>0$ 却 $r$ 变小,或 $A<0$ 却 $r$ 变大):min 取未截断项,惩罚照常,梯度把策略拉回来。

所以 min 的本质是取两者里更“悲观”(更小)的那个,让 $L^{\text{CLIP}}$ 成为真实目标的一个下界。只 clip 会在不利方向上漏掉惩罚,min 把这个漏洞补上。

蓝线 KL 单调上升,是因为横坐标越大,插值出来的策略离 $\theta_{\text{old}}$ 越远,而 KL 度量的就是两个分布之间的差距,离得越远自然越大。

KL 升高代表新策略相对旧策略改得越来越多,也就是这一步走得越来越大。PPO 想要的是“改一点点、别一步迈太远”。问题在于 $L^{\text{CPI}}$(橙)会一直奖励继续往前走,对应 KL 无限制地涨上去,这正是不加约束的策略梯度容易训崩的原因。而 $L^{\text{CLIP}}$(红)涨到峰值后掉头向下,优化它不会把 KL 推到很大,最后停在 KL 约 0.02 这种温和的程度。这就是 clip 加 min 想达到的效果:不用显式的 KL 惩罚或信赖域约束,光靠对 ratio 截断就把策略更新幅度限制住。

具体细节

on-policy 还是 off-policy

这和 DQN 不一样。DQN 是 off-policy,可以把以前的经验存在 replay buffer 里,过很久之后还拿出来训练。因为 DQN 学的是 Bellman target,数据可以不是当前策略生成的。

PPO 是 on-policy。大致过程是:用当前策略采样,短期复用这批 rollout 做多轮 minibatch 更新,然后丢掉这批数据,再用新策略重新采样。

PPO 采样用的就是当前策略 $\pi_{\text{old}}$,这批数据只在当前一次更新里短期复用。采一批 rollout,用它训练 num_epochs 遍,然后丢掉,再用新策略采下一批。所以 PPO 没有 DQN 那种长期 replay buffer。

off-policy(如 DQN)允许旧数据长期存在 replay buffer 里随机取用;on-policy 则要求数据来自当前策略附近,不能复用很久以前的策略采的数据。PPO 之所以敢对同一批数据训练多个 epoch,靠的就是 ratio 加 clip 限制住了新旧策略的差异。

网络架构

PPO 是 actor-critic 结构,需要两个输出:

  • Actor:输入状态 $st$,输出动作分布 $\pi\theta(a_t \mid s_t)$。
  • Critic:输入状态 $st$,输出状态价值 $V\theta(s_t)$。

其中 $\pi\theta(a_t \mid s_t)$ 是当前策略在 $s_t$ 下选动作 $a_t$ 的概率,$V\theta(s_t)$ 是 critic 对从 $s_t$ 出发的未来折扣回报的估计。$\theta$ 是网络参数,actor 和 critic 可以完全分开,也可以共享一部分主干。

Rollout 采样

如果只看一条环境交互里最基础的 transition:

1
s_t, a_t, r_t, s_{t+1}

这只是最小的交互记录,对 PPO 来说不够。Policy Gradient 更关心的是这个动作对后续整段表现的贡献,所以它通常先收集一批完整或截断的轨迹,然后统一算 return / advantage,再更新网络。

PPO rollout 里一条训练样本更接近这样:

1
s_t, a_t, r_t, done_t, log_prob_t, value_t

其中 log_prob_t 是旧策略下动作 $at$ 的对数概率,value_t 是旧 critic 给出的 $V{\text{old}}(st)$。`s{t+1}有些实现不会单独存成nextobs,因为同一个环境下一步的obs[t + 1]就是它;rollout 最后还会额外 forward 一次得到last_value = V(s_T)`,用来给截断位置 bootstrap。这里要分清楚:环境交互的 transition 可以写成 $(s_t,a_t,r_t,s{t+1})$,但 PPO 训练用的 buffer 条目还必须带上旧策略的 log_prob、旧 critic 的 valuedone

多环境并行跑的时候是什么样,这个很重要。比如 rsl_rl 这类库,在机器人训练时通常都是多环境并行。

假设有:

1
2
num_envs = 4
num_steps = 5

意思是 4 个环境同时独立运行,每个环境跑 5 步。这个时候 rollout buffer 不是一条长链,而是一个二维结构:时间维度 $T$ 乘环境维度 $N$。

buffer[t, env] 表示第 env 个环境在第 t 个采样步的数据,具体结构大概是:

1
2
3
4
5
6
                 env0          env1          env2          env3
t = 0 transition transition transition transition
t = 1 transition transition transition transition
t = 2 transition transition transition transition
t = 3 transition transition transition transition
t = 4 transition transition transition transition

这里一共有 num_steps * num_envs = 5 * 4 = 20 条 transition。每个格子里不是只存一个 reward,而是存这一时刻训练后面需要用到的一组数据:

1
2
3
4
5
6
obs[t, env]
actions[t, env]
rewards[t, env]
dones[t, env]
old_log_probs[t, env]
values[t, env]

如果设并行环境数 $N$、每个环境采样步数 $T$,那么一轮更新前会收集 $N \times T$ 条 transition。例如 $N=4096$、$T=24$,一共是 98304 条样本。

单个环境、单个时间步的交互是这样一段过程:当前观测 $st$ 送进 actor,得到旧策略分布 $\pi{\text{old}}(\cdot \mid st)$;从中采样动作 $a_t$;同时记下这个动作在旧策略下的对数概率 $\log \pi{\text{old}}(at \mid s_t)$;critic 给出 $V{\text{old}}(st)$;环境执行动作,返回 $s{t+1}, r_t, \text{done}_t$。把这些一起写进 buffer:

1
2
3
4
5
6
7
8
buffer.add({
"obs": s_t,
"action": a_t,
"reward": r_t,
"done": done_t,
"old_log_prob": log_pi_old_a_t,
"value": V_old_s_t,
})

实际采样是 $N$ 个环境同时跑,所以 buffer 天然带时间和环境两个维度:

1
2
3
4
5
6
obs.shape           = [num_steps, num_envs, obs_dim]
actions.shape = [num_steps, num_envs, action_dim]
rewards.shape = [num_steps, num_envs]
dones.shape = [num_steps, num_envs]
old_log_probs.shape = [num_steps, num_envs]
values.shape = [num_steps, num_envs]

带入上面的例子,如果 num_envs = 4num_steps = 5

1
2
3
4
5
6
obs.shape           = [5, 4, obs_dim]
actions.shape = [5, 4, action_dim]
rewards.shape = [5, 4]
dones.shape = [5, 4]
old_log_probs.shape = [5, 4]
values.shape = [5, 4]

每个环境是独立的一条时间序列,reset 的时刻彼此不同,所以后面算优势时不能把环境维度搅在一起。

采样完成后,先计算 advantage 和 return。每一步都有自己的 advantage,这一步要按照时间顺序从后往前算,具体方法放在后面。注意在算 GAE 之前不能打乱 shuffle,也不能先展平。训练阶段才会把数据展平、打乱,然后分成 minibatch。

区分一下四个量

这四个量很容易混,先各自说清楚。

即时奖励 $r_t$ 是环境这一步直接给的,只看当前一步。在机器狗这类任务里,它往往是多个奖励项的加权和,比如速度跟踪、姿态稳定、能耗惩罚、关节速度惩罚、终止惩罚、存活奖励等。

回报 $G_t$ 是从当前时刻往后的折扣累计收益:

$G_t$ 是往未来无限步看的量,实际代码里不可能真的计算无限步。PPO 代码里的 returns 往往也不是完整的 Monte Carlo $G_t$。

因为 PPO 通常不会等完整 episode 结束,而是只采样固定长度 $T$。从 $s_t$ 到 $s_T$ 这段真实 reward 能看到,后面没采到的部分用 critic 补上:

这叫截断后的 bootstrapped return。它不是完整 episode 的真实 $G_t$,而是:

1
2
已看到的真实 reward
+ 最后状态 value 的估计

论文式 (10) 里的 advantage 可以理解成:

价值 $V(s_t)$ 是 critic 对 $G_t$ 的预测,是网络估计出来的,不是环境给的。

优势 $A_t$ 最直观的定义是实际回报比 critic 预期高出多少:

$A_t > 0$ 说明这个动作之后的结果比预期好,应该提高它的概率;$A_t < 0$ 则相反。

从 $A_t = G_t - V(s_t)$ 推到 TD error

把 $Gt = r_t + \gamma G{t+1}$ 代进优势定义:

在右边加减一个 $\gamma V(s_{t+1})$:

后一个括号正好是 $A_{t+1}$,前一个括号定义为 TD error:

于是得到递推:

一路展开就是:

GAE

上面的展开方差很大。比较直观的理解是:如果用很长的未来收益来评价当前动作,那么后面的随机事件都会被算进 $A_t$ 里。于是当前动作可能本来差不多,但算出来的 advantage 一会儿很大,一会儿很小,甚至正负变来变去。

比如机器狗在某个状态 $s_t$ 做了一个动作 $a_t$,这个动作本身可能没问题。但是后面几十步里可能发生很多随机因素,比如接触状态变化、外部扰动、姿态偏移、某个环境提前 done。这些都会影响后面的 return。

GAE 在每一项 TD error 前面再乘一个 $\lambda$,用来在偏差和方差之间做权衡(Schulman et al. 2015):

其中 $\gamma$ 是折扣因子,常用 0.99;$\lambda$ 是 GAE 系数,常用 0.95;$\gamma\lambda$ 是后续 TD error 的衰减速度。

两个极端能帮助理解 $\lambda$ 的作用。$\lambda=0$ 时,$\hat{A}_t=\delta_t$,只看一步 TD,方差小但偏差大,严重依赖 critic。$\lambda=1$ 时,退回到接近 $\hat{A}_t=G_t-V(s_t)$ 的形式,偏差小但方差大。$0<\lambda<1$ 是折中。

GAE 不是固定的 n-step,而是从当前步一直累加 TD error,直到 rollout 片段结束或者 episode done。比如 $T=24$,第 0 步最多能用 $\delta0 \ldots \delta{23}$,第 23 步只能用 $\delta_{23}$;中间遇到 done 就在那里截断。

GAE 的代码递推

采样后已有 rewardsdonesvalues,形状都是 [num_steps, num_envs]。另外还需要 rollout 结束后那一个状态的价值 last_value = V(s_T),形状是 [num_envs]

GAE 从后往前算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
advantages = zeros([num_steps, num_envs])
last_gae = zeros([num_envs])

for t in reversed(range(num_steps)):
if t == num_steps - 1:
next_value = last_value
next_non_terminal = 1 - last_done
else:
next_value = values[t + 1]
next_non_terminal = 1 - dones[t]

delta = rewards[t] + gamma * next_value * next_non_terminal - values[t]

last_gae = delta + gamma * gae_lambda * next_non_terminal * last_gae

advantages[t] = last_gae

对应的公式,考虑 done 之后是:

done 的作用是切断序列。某一步 done=True 时,next_non_terminal = 0,于是 $\delta_t = r_t - V(s_t)$,并且 $\hat{A}_t = \delta_t$。这样不会把 reset 之后新 episode 的回报错误地接到当前 episode 后面。

returns(critic 的训练目标)

PPO 里 critic 的训练目标通常叫 returns,代码里是:

1
returns = advantages + values

这里的 values 是 rollout 阶段存下来的 $V_{\text{old}}(s_t)$,所以完整写法是:

它直接来自 $A_t = G_t - V(s_t)$ 的移项 $G_t = A_t + V(s_t)$。注意这是从当前状态出发的累计收益目标,也叫 value target 或 $\lambda$-return,不是一步 reward。

三者放一起对比:

  • $r_t$ 是即时奖励。
  • $\text{returns}_t$ 是未来累计收益的训练目标,到时候要用它算 MSE 来更新 critic。
  • $\hat{A}_t$ 是当前动作比预期好多少。

这里注意,算 TD error、GAE 和 returns 时,用的一直是 rollout 阶段 old critic 存下来的 value。

为什么 value loss 不会退化成 $\hat{A}^2$

训练时存在两个 value,要分清楚。old_values 是 rollout 时旧 critic 的估计,已经在 buffer 里,用来算 GAE 和 returns;new_values 是更新时当前 critic 重新 forward 出来的,用来算 value loss。

returns 必须用 old values 构造:

value loss 用 new values 算:

1
2
new_values = critic(mb_obs)
value_loss = mean((new_values - returns) ** 2)

如果错误地写成:

1
returns = advantages + new_values

那么:

里面再也没有 critic 要拟合的信息,critic 学不到东西。正确的理解是:returns 是固定标签,new values 是当前预测,value loss 推着预测去靠近标签。

展平

GAE 和 returns 算完后,所有量还是 [num_steps, num_envs, ...]。训练前把时间和环境两个维度合并成一个 batch 维:

1
2
3
4
5
6
7
8
batch_size = num_steps * num_envs

b_obs = obs.reshape(batch_size, obs_dim)
b_actions = actions.reshape(batch_size, action_dim)
b_old_log_probs = old_log_probs.reshape(batch_size)
b_advantages = advantages.reshape(batch_size)
b_returns = returns.reshape(batch_size)
b_old_values = values.reshape(batch_size)

例如 $24 \times 4096 = 98304$,展平后 b_advantagesb_returns 都是 [98304]

要强调的是:GAE 阶段绝对不能提前展平。优势要沿着每个环境自己的时间序列从后往前算,必须先保持 [num_steps, num_envs],算完之后才展平成 [num_steps * num_envs]

minibatch 更新

batch_size = 98304minibatch_size = 16384num_epochs = 5。每个 epoch 有 $98304 / 16384 = 6$ 个 minibatch,num_epochs = 5 表示同一批 rollout 数据反复训练 5 遍,所以一共 $5 \times 6 = 30$ 次 optimizer.step()

每一遍 epoch 都重新打乱这 98304 条样本,再切成 6 个 minibatch,逐个更新。这批数据用完就丢掉,再用更新后的策略采下一批。

算 GAE 时它们是一条有先后关系的轨迹;算完之后进入训练,就被当成一堆可独立采样的样本。

当 batch 数量除不尽时,最后一个 minibatch 会比别的小。比如单环境 200 步、minibatch_size = 64,最后一块只剩 8 条。因为 loss 用的是 .mean(),样本少也不影响量纲,只是这一步梯度估计噪声大一点,对训练基本无影响。想更干净可以丢掉最后不满的那块(drop_last),或把 batch 大小选成能整除的数。

单个 minibatch 内部计算:网络更新步骤

取出一个 minibatch:

1
2
3
4
5
6
mb_obs
mb_actions
mb_old_log_probs
mb_advantages
mb_returns
mb_old_values

第一步,用当前 actor 重新算 log prob。注意 mb_actions 是 rollout 时旧策略采出来的动作,训练时不重新采样,而是拿当前新策略去评估同一个动作:

1
2
dist = Normal(mean, std)
log_prob = dist.log_prob(action).sum(dim=-1)

这里注意:log_prob 是在算当前采样动作 action 在这个高斯动作分布下的对数概率密度。rollout 时 mb_actions 是从分布里采样出来的,但更新阶段不重新采样,也不对“采样”这个操作求导。更新时 mb_actions 被当成固定常量,梯度是通过 log_prob 对分布参数 $\mu$、$\sigma$ 的依赖传回去的。

高斯对数概率是:

这里 $a$ 是常量,$\mu$、$\sigma$ 是网络输出,所以求导是对 $\mu$、$\sigma$ 求,再传回 actor。这就是策略梯度的 score function(likelihood ratio)思路:不对随机采样求导,而是调整“产生这个动作的概率密度”。优势为正就抬高它的 log_prob,优势为负就压低。正因为不需要对采样反传,rollout 阶段才可以放心地 no_graddetach

另一条路线是 SAC / VAE 用的重参数化技巧:

它把随机性挪到与参数无关的 $\epsilon$ 上,让梯度直接穿过 $a$。PPO 不走这条路。

第二步,算 ratio。rollout 存的是 $\log \pi{\text{old}}(a_t \mid s_t)$,现在算出 $\log \pi\theta(a_t \mid s_t)$,所以:

$r_t > 1$ 表示新策略更倾向这个动作,$r_t < 1$ 表示更不倾向。

第三步,算 clipped policy 目标。原始 policy gradient 是 $r_t(\theta)\hat{A}_t$,PPO 对它做截断:

$\epsilon$ 常用 0.2。代码里 optimizer 默认最小化,所以取负号:

1
2
3
surr1       = ratio * mb_advantages
surr2 = clip(ratio, 1 - eps, 1 + eps) * mb_advantages
policy_loss = -mean(min(surr1, surr2))

正负优势下 clip 的效果不同。$\hat{A}_t > 0$ 时应该提高动作概率,但 ratio 的有效增益被压在 $1+\epsilon$,$\epsilon=0.2$ 时就是 1.2。$\hat{A}_t < 0$ 时应该降低概率,但有效下降被压在 $1-\epsilon$,也就是 0.8。本质就是不让新策略一步偏离旧策略太远。

第四步,当前 critic 重新 forward 得到:

1
new_values = critic(mb_obs)

这是 critic 对这批 obs 的最新 $V(s)$ 预测。

第五步,算 value loss,标签是 mb_returns

1
value_loss = mean((new_values - mb_returns) ** 2)

第六步,算 entropy bonus:

1
entropy = new_dist.entropy()   # 多维连续动作要 .sum(dim=-1),再 .mean()

entropy 鼓励策略保持随机性,防止过早收敛到确定动作,维持探索。高斯熵有闭式解,只跟 $\sigma$ 有关、与 $\mu$ 无关:

entropy() 内部直接套这个公式,不需要采样。$\sigma$ 越大,熵越大,策略越随机;$\sigma$ 越小,策略越确定。$\sigma$ 是网络 fc_std 根据状态算出来的,所以 entropy 经由 $\sigma$ 到 fc_std 这一路带梯度,-c2 * entropy 反传时能更新到 actor 的参数。

std 有两种常见写法:本实现是 state-dependent std,$\sigma$ 随状态由网络输出,更灵活;很多经典 PPO 复现用 state-independent std,把 log_std 设成与状态无关的全局 nn.Parameter,所有状态共享,只有 $\mu$ 随状态变,更简单稳定。两种都可行,但要保证网络输出 shape、log_prob.sum(dim=-1) 和 entropy 的处理是一致的。

第七步,组合总损失。PPO 论文最大化的目标是:

代码里改成最小化,符号相应翻转:

1
2
3
4
5
6
loss = policy_loss + c1 * value_loss - c2 * entropy.mean()

optimizer.zero_grad()
loss.backward()
clip_grad_norm_(network.parameters(), max_grad_norm)
optimizer.step()

符号可以这样记:优化器只会最小化 loss,所以想最大化的项加负号,想最小化的项加正号。policy 目标 $L^{\text{CLIP}}$ 要最大化,所以 policy_loss 取负;value 误差要最小化,所以是正号;entropy 要保持,也就是希望它大一点,所以取负。

其中 policy_loss 那个负号是对整个 $\min$ 取负,漏了会让策略朝反方向更新,reward 越训越低,是排查不收敛时第一个该看的地方。$c_1=0.5$、$c_2=0.01$ 不是推导出来的,是论文和主流实现的经验默认值,连续控制任务可以先按这个用,再看训练曲线调。