简介:基于PyTorch的PPO算法实战项目,用于训练智能体完成月球着陆器(LunarLander-v2)的降落控制,适合想掌握深度强化学习策略梯度方法的Python开发者。包内共4个Python文件,分别承担模型网络定义、并行环境交互、PPO更新逻辑与训练结果绘图,压缩包仅5KB,结构精炼,便于逐一研读。项目已有1613人学习,可见其实用价值。通过源码可学习PPO如何利用Actor-Critic网络生成动作策略、计算优势函数,并以信任区域约束限制新旧策略差异,避免更新过于激进;同时可理解经验缓冲区采样、批量优化以及策略与价值网络交替更新的实现方式。对初学者而言,这是一个轻量而完整的训练闭环示例,能将算法原理与PyTorch代码逐行对应,也为后续扩展到其他连续控制任务打下扎实基础。
1. 为什么用 PPO 啃月球着陆器:算法不难,难的是让它真的学会
LunarLander 的状态只有 8 个浮点数,动作也只有 4 个离散选项,看上去是强化学习入门最简单的环境之一。但如果你自己用 PyTorch 从零实现 PPO 去跑它,第一次大概率学不会——奖励长期徘徊在 -100 附近,policy loss 在降,智能体却始终学不会平稳着陆。这不是 PPO 的数学有问题,而是 Gymnasium 新接口、终端状态截断、GAE 归一化、熵系数这些工程细节在拖后腿。本文以 pytorch-lunarlander 为模拟项目X,把 PPO 在月球着陆器上的完整落地路径拆开讲:环境怎么建模、clip 与 GAE 在数学上到底干了什么、训练脚本怎么组织、超参数从哪组开始调,以及 5 个几乎人人会踩的坑。目标很直接:让新手照着步骤能跑出 200+ 的平均分,让熟手知道边界在哪、参数往哪个方向动。
2. LunarLander 环境拆解:连续控制任务到底在学什么
很多人把 LunarLander 当成一个「比 CartPole 难一点的玩具」,结果训练时屡屡翻车。原因是它表面上是低维状态,暗地里却是一个带非线性动力学、稀疏奖励信号和强延迟反馈的控制问题。在动手写 PPO 之前,先把环境本身读透,能省下后面大量排查时间。
2.1 状态、动作与奖励:LunarLander 在学什么
LunarLander 的观测空间是 8 维向量,按顺序分别是:水平坐标、垂直坐标、水平速度、垂直速度、机体角度、角速度,以及两个着陆腿的接触状态(0 或 1)。前 6 维是连续的物理量,后 2 维是布尔量,共同描述着陆器在二维平面内的完整运动状态。这个状态设计是有讲究的——它是马尔可夫性质的,也就是说当前帧的观测足以决定未来走势,不需要像部分可观测任务那样引入循环网络。
动作空间在基础版本里是 4 个离散选项:不点火、点火左侧推进器、点火主推进器、点火右侧推进器。每一步选择其中一种。很多人会误以为「左侧推进器」控制向左飞,实际上它控制的是机体绕自身轴的旋转,最终产生的是横向推力。这意味着智能体必须学会组合动作:先调整姿态,再控制垂直下落速度,最后在接触地面时熄火。奖励函数则把这个目标拆成了三部分:向目标着陆区靠近每帧给一点正奖励,主推进器每点火一帧扣 0.3(鼓励省燃料),成功着陆加 100 分,坠毁扣 100 分。
这里有个容易忽略的细节:LunarLander 的奖励不是纯粹的稀疏奖励。它每一帧都有信号,但大多数帧的奖励绝对值很小,主要信息藏在「成功 +100 / 坠毁 -100」这两个大事件里。这意味着价值函数的预测目标跨度很大,Critic 需要足够多的样本量才能稳定估计。观察环境自带的最优策略分数——如果能稳定跑出 250 分左右,说明智能体已经学会了节省燃料的着陆方式;200 分只是「安全着陆」的及格线。
2.2 为什么 LunarLander 是 PPO 最好的练手环境
PPO 的论文里用的是连续控制任务,但社区里几乎所有人都从 LunarLander 离散版开始上手。原因有三层。第一,状态维度低,网络结构可以压到很小,单 GPU 甚至 CPU 都能跑,调试周期短——一次完整的训练只需要几分钟到十几分钟,这决定了你能否快速迭代超参数。第二,奖励信号有梯度有延迟,正好暴露出 PPO 实现里最常见的两类 bug:价值函数拟合不到位,以及终端状态截断处理错误。CartPole 里这两种 bug 不容易暴露,因为任务太短、奖励太稠密,随便写写都能凑合学出来。
第三,环境自带一个明确的收敛判据:过去 100 局的平均奖励达到 200 以上。这个判据是环境设计者给出的官方「学会」标准,比 CartPole 的 195 更严格,因为它要求智能体不仅不失败,还得持续稳定地省燃料着陆。实际训练中你会发现,分数从 -100 爬到 0 很快,从 0 爬到 200 却要经历一个漫长的平台期,这正好逼你去看 GAE 的归一化、学习率的衰减策略这些更细的工程问题。
还有一个常被忽略的点:LunarLander 支持随机起始状态。每一局初始时,着陆器的位置、角度、速度都带有随机扰动,这保证了训练出的策略不是死记硬背某一条轨迹,而是真正学到了反馈控制律。做消融实验时,同一组超参数换一个随机种子,最终分数可能差 50 分以上,所以后续评估必须跑多个种子取中位数,这个问题我在最后一章会展开讲。
2.3 搭建最小骨架:环境接口的正确打开方式
用某主流环境库(以 gymnasium 举例)创建 LunarLander,新版接口已经和早期版本有了显著差异。早期版本里 step 返回 4 个值,新版本返回 5 个值,多出来的一个是 terminated 和 truncated 的拆分。这个改动直接影响了 PPO 的回报计算逻辑,很多人掉过的坑就在这里。
import gymnasium as gym env = gym.make("LunarLander-v2", continuous=False, render_mode=None) obs, info = env.reset() # 单步交互的完整模式 action = env.action_space.sample() obs, reward, terminated, truncated, info = env.step(action) done = terminated or truncated if done: obs, info = env.reset()代码的逻辑很简单,但要注意两个参数:continuous=False明确选用离散动作版本;render_mode=None关闭渲染以提升采样速度。训练过程中不必开窗口渲染,渲染开销会让采样速度慢一个数量级。done = terminated or truncated这个合并是 PPO 回报计算的基石——如果只判断 terminated,那么所有因为到达最大步数而截断的回合会被错误地当成「自然终止」,GAE 的 bootstrapping 值会算错,价值函数会被污染。
另一个关键点是采样循环里reset()的调用时机。一种常见误用是把reset()写在step()之前,导致多出一个无效帧;另一种是把reset()写在if done的分支外,导致回合结束后继续拿旧观测做采样。正确写法是只在回合结束时 reset,并且立刻把新的观测存入缓冲区。这个骨架代码虽然短,但它是后面所有训练逻辑的地基,建议先跑通这一段、确认 step 返回值的形状和类型,再往上加 PPO。
3. PPO 核心机制拆解:clip 截断与优势估计如何稳定训练
PPO 的数学推导在论文里写得很简洁,但落地时真正影响训练稳定性的只有两件事:clip 截断的比值项,以及 GAE 算出来的优势值。这两块的实现有一百种写法,大部分都能收敛,可一旦细节出错,表现就是「别人能跑到 250,你跑到 150 就上不去了」。
3.1 clip 到底在限制什么:策略比值的直觉理解
策略梯度方法的核心问题是:更新步长多大才合适。步长太小学得慢,步长太大策略会崩。TRPO 用 KL 散度约束来限制步长,但实现复杂;PPO 换了个思路——不显式约束 KL,而是把目标函数里的概率比值直接截断。
这个比值就是新策略在某状态下选中某动作的概率,除以旧策略选中该动作的概率。如果新策略比旧策略更倾向于这个动作,比值大于 1;反之小于 1。clip 的操作是:当比值超出 1±ε 的范围时,把对应的梯度贡献直接切掉。ε 通常取 0.2,意思是单次更新里,任何一个动作的选择概率变化不允许超过 20%,超出的部分视为「不信任的更新」,不参与梯度计算。
实现上有两种写法。简洁版直接对整体 loss 做 clip,严谨版会额外考虑当优势为负时的双侧截断。社区里流传的很多实现只做了单侧处理,这在大多数任务上也能收敛,但在 LunarLander 这种奖励信号跨度大的环境里,双侧截断的版本更稳定。判断你的 clip 是否工作正常,有一个很实用的诊断指标:每次更新时计算被 clip 的样本占比,如果这个比例长期为 0,说明学习率太小;如果长期高于 30%,说明策略更新太激进,需要调低学习率或增大 batch size。
3.2 GAE 优势估计与 λ 的连续谱
优势函数衡量的是「当前状态下这个动作比平均水准好多少」。PPO 里最常用的优势估计是 GAE,它用一个参数 λ 在方差和偏差之间做权衡。λ=0 时退化为一步时序差分,方差小但偏差大;λ=1 时退化为蒙特卡洛回报,无偏但方差极大。LunarLander 的回合长度在几百帧到上千帧不等,λ 取 0.95 是社区里验证过的均衡点。
GAE 的实现依赖价值函数的预测。具体流程是:采样出完整轨迹后,对每个时间步计算时序差分误差 δ=reward+γ·value(next)−value(current),然后从后往前递归累加,得到每个时间步的优势值。递归公式是 A(t)=δ(t)+γ·λ·A(t+1)。这个反向累加的过程必须严谨,一个常见错误是忘记乘以 γ,或者把最后一个时间步的 bootstrapping 值设成零而不是价值函数预测值。
地区归一化是另一个救命的细节。GAE 算出的优势值分布可能很大,直接喂给策略梯度会导致更新步长失控。常见做法是对一个 batch 内的优势做标准化——减去均值除以标准差——但要注意这个标准化只作用于策略 loss,价值函数的 loss 仍然用原始回报计算。很多实现把归一化后的优势存回缓冲区,结果价值函数也在间接使用归一化数据,这会造成价值估计偏差。
3.3 手写 PPO 更新:核心代码与剪枝
PPO 的更新阶段可以用一个函数概括。它做的事情是:从缓冲区里拿出已经算好的优势值和回报,对每个 epoch 打乱数据、切 batch,然后对每个 batch 计算三层 loss——策略 loss、价值 loss、熵 bonus。
def ppo_update(policy, optimizer, buffer, clip_eps=0.2, vf_coef=0.5, ent_coef=0.01): obs = buffer.observations actions = buffer.actions old_log_probs = buffer.log_probs advantages = buffer.advantages returns = buffer.returns # 归一化优势,只影响策略梯度 advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8) for _ in range(10): indices = torch.randperm(len(obs)) for start in range(0, len(obs), 64): idx = indices[start:start+64] batch_obs = obs[idx] batch_actions = actions[idx] batch_old_log_probs = old_log_probs[idx] batch_advantages = advantages[idx] batch_returns = returns[idx] log_probs, entropy = policy.evaluate(batch_obs, batch_actions) ratio = (log_probs - batch_old_log_probs).exp() # 双侧 clip:优势为正时限制上限,为负时限制下限 surr1 = ratio * batch_advantages surr2 = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps) * batch_advantages policy_loss = -torch.min(surr1, surr2).mean() value = policy.critic(batch_obs).squeeze(-1) value_loss = 0.5 * (value - batch_returns).pow(2).mean() loss = policy_loss + vf_coef * value_loss - ent_coef * entropy.mean() optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(policy.parameters(), 0.5) optimizer.step()逻辑说明:ratio 先通过对数概率相减再取指数得到,数值上比直接除更稳定。torch.clamp做的是双侧截断——优势为正时限制最大倍率,优势为负时限制最小倍率,防止策略在某个动作上过度悲观。entropy.mean()前面是减号,因为我们要鼓励探索,让熵更大。梯度裁剪的阈值 0.5 是为了防止单个 batch 的奇异样本破坏整个网络;如果你发现训练早期 loss 经常出现 NaN,优先检查这一行和周遭的参数是否设置正确。
参数说明:clip_eps=0.2是 PPO 论文的默认值,LunarLander 这种低维任务一般不需要调;vf_coef=0.5权衡策略和价值两个 loss 的比例,调大它会让价值函数更稳但策略更新变慢;ent_coef是最值得调的参数,取 0.01 能在早期防止策略坍缩,训练稳定后可以考虑逐步衰减到 0。内层循环 10 个 epoch、batch size 64,是对应 2048 步采样量的搭配,后面超参数表里会给出完整组合。
4. 从零搭建 ppo-lunarlander 训练脚本:网络结构与超参数
原理讲完了,接下来进入工程化。这一章的目标不是贴一个完整项目源码,而是把训练脚本拆成三块讲透:Actor-Critic 网络怎么搭、超参数从哪组起步、训练主循环怎么把采样和更新串起来。每块都有可复现的代码,照抄就能跑。
4.1 Actor-Critic 结构:离散动作的两种写法
LunarLander 离散版的动作空间是 4 类,Actor 只需要输出一个 4 维的 softmax 分布。这里有一个工程选择:Critic 和 Actor 是共享网络主干,还是各自独立。共享主干在样本效率上有优势,但实践中容易互相干扰;独立网络在低维状态上没有明显劣势,反而让调参更直观。我的做法是:两层共享的 MLP,之后分叉成 Actor 头(输出 softmax 概率分布)和 Critic 头(输出标量)。
import torch import torch.nn as nn import torch.distributions as dist class Policy(nn.Module): def __init__(self, state_dim=8, hidden_dim=128, action_dim=4): super().__init__() self.shared = nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, hidden_dim), nn.Tanh(), ) self.actor_head = nn.Linear(hidden_dim, action_dim) self.critic_head = nn.Linear(hidden_dim, 1) def forward(self, obs): x = self.shared(obs) logits = self.actor_head(x) value = self.critic_head(x) return logits, value.squeeze(-1) def act(self, obs): logits, _ = self.forward(obs) dist_obj = dist.Categorical(logits=logits) action = dist_obj.sample() log_prob = dist_obj.log_prob(action) return action.item(), log_prob def evaluate(self, obs, actions): logits, value = self.forward(obs) dist_obj = dist.Categorical(logits=logits) log_probs = dist_obj.log_prob(actions) entropy = dist_obj.entropy() return log_probs, entropy, value激活函数选 Tanh 而不是 ReLU,这是照着 LunarLander 这类物理连续控制任务的惯例来的。Tanh 的输出范围是 [-1,1],对状态特征的映射更柔和,不会像 ReLU 那样出现神经死亡;如果你的环境换成了更复杂的连续控制任务,隐藏层深度和宽度都要加大,但激活函数这层经验可以保留。动作采样用dist.Categorical(logits=logits)而不是手动 softmax 后采样,原因是 PyTorch 的 Categorical 类内部已经做过 log-sum-exp 的数值稳定处理,还能直接给出 log_prob 和 entropy,省去手写时容易产生的数值错误。
Critic 头输出的是标量价值预测,它监督目标是用 GAE 算出的回报。注意这里没有对价值头加输出激活函数,因为价值可以是任意实数,LunarLander 里它可能在 -300 到 +300 之间波动,套一层 Tanh 或 Sigmoid 反而会压制预测范围。
4.2 能跑通的超参数表:直接从这张表开始
超参数是 PPO 实现里最玄学的部分。同一组参数,换一个随机种子、换一个网络初始化方式,最终分数可能差 30 分。以下这组配置是我在实际实验中验证过、从零开始能稳定跑到 220+ 分的起点,适用于LunarLander-v2离散版本。
| 参数名 | 取值 | 说明 |
|---|---|---|
| 采样步数 num_steps | 2048 | 每轮采样收集的步数,决定了 GAE 计算的轨迹长度 |
| batch_size | 64 | 每次梯度更新用的样本数,从 2048 步中随机抽取 |
| 更新轮次 update_epochs | 10 | 每轮采样后对数据重复更新的次数 |
| 学习率 lr | 3e-4 | 适用 Adam 优化器,过高会导致策略震荡 |
| 折扣因子 γ | 0.99 | 几乎不变的标准值,调大可能加剧方差 |
| GAE λ | 0.95 | 偏差方差折中,λ=1 时训练会明显抖动 |
| clip ε | 0.2 | 策略最大更新幅度,不用频繁调 |
| 价值系数 vf_coef | 0.5 | 价值 loss 在总 loss 中的权重 |
| 熵系数 ent_coef | 0.01 | 防止策略坍缩,稳定后可衰减 |
| 梯度裁剪阈值 | 0.5 | 限制单次更新的最大梯度范数 |
| 总步数 total_steps | 500000 | 约 250 轮迭代,实际在 300k 步时通常已能学会 |
这个表的关键不是每个值都精确最优,而是整体搭配合理。特别提醒两点:更新轮次 10 配合 batch_size 64,相当于每轮采样后要更新 320 次参数,这对小网络是合理的;但如果你的网络加大到 3 层 256 维,建议更新轮次降到 5,否则价值函数会过拟合当前批次数据。学习率 3e-4 是 Adam 在小型 RL 任务上的舒适区,不建议一上来就用 1e-3,LunarLander 的奖励信号波动大,学习率偏高时经常出现「涨到 180 分然后一夜回到解放前」的现象。
4.3 训练主循环:采样、GAE 计算、更新
主循环是训练脚本的骨架。它做的事情可以拆成三步:采样阶段收集数据并保存每个时间步的动作概率、计算 GAE 和回报、调用更新函数优化策略。一个常见的工程问题是把这三步混在一个函数里,导致日志信息和缓冲区状态难以排查。我习惯把它们拆开,采样函数只管生成数据,更新函数只管优化。
def compute_gae(buffer, gamma=0.99, lam=0.95): obs = buffer.observations rewards = buffer.rewards masks = buffer.masks # 1 表示非终止, 0 表示回合结束 values = buffer.values advantages = torch.zeros_like(rewards) gae = 0.0 # 反向遍历计算 GAE for t in reversed(range(len(rewards))): if t == len(rewards) - 1: next_value = 0.0 # 轨迹末尾不再 bootstrap else: next_value = values[t + 1] delta = rewards[t] + gamma * next_value * masks[t] - values[t] gae = delta + gamma * lam * masks[t] * gae advantages[t] = gae returns = advantages + values return advantages, returns逻辑说明:masks数组记录每个时间步是否结束,masks[t]=0时下一状态价值不被 bootstrap。这个细节对应前面提到的 terminated/truncated 拆分——如果回合因为截断结束,truncated=True时其实还可以继续探索,但价值估计通常仍然用 0 处理,具体选择会影响训练的保守程度。GAE 的递归公式从轨迹末尾反向计算,t从后往前遍历,每次都累乘 γ 和 λ。这种写法很紧凑,但要求values和rewards的长度严格对齐,任何一步的错位都会让整条轨迹的优势值算错。
训练主循环的框架相对固定:创建环境、初始化策略和优化器、循环采样 2048 步、计算 GAE、调用更新函数、记录日志。每一步的记录都值得做厚:平均奖励、平均回合长度、策略损失、价值损失、熵值、被 clip 的样本比例。这些指标会在第 5 章的排查中派上用场。建议把日志写到本地文件而不是只打印到终端,训练结束后再用可视化工具画曲线,观察平台期和收敛趋势比盯打印输出有效得多。
5. 训练翻车实录:LunarLander 的 5 个常见坑与排查方法
这一章是血泪经验合集。我自己在这套环境上从零写 PPO 踩过的坑、以及帮某个 A 同学排查他模拟项目X 时的典型案例,几乎都集中在下面 5 个现象里。每个都按「现象 → 原因 → 解决」的顺序写,排查时可以直接对照。
5.1 现象一:loss 在降,奖励却一直死在 -100 附近
现象:训练了 2 万步,policy loss 和 value loss 都在稳步下降,但每局平均奖励始终在 -100 上下波动,智能体的行为像是原地乱撞。原因:策略过早坍缩到某个局部最优,通常是熵系数为 0 或学习率过大导致。价值函数学到了一套「反正会坠毁」的悲观预测,策略据此固化,不再探索。解决:把熵系数从 0 提到 0.01,同时检查动作分布的熵值——如果熵在几千步内从 1.3 附近迅速掉到 0.1 以下,说明坍缩已经发生,需要调大熵权重降低学习率重训。另外检查优势值归一化是否正确,很多人在这里忘了标准化,导致梯度尺度过大,把策略推向死区。
5.2 现象二:reward 涨到 200 又掉回负数
现象:训练曲线一路上涨,到了 180~210 分区间后突然崩盘,奖励回到 0 以下,之后长时间爬不回来。原因:这是「过更新」的典型症状。策略已经学得不错,但学习率太大 + 更新轮次太多,导致在某次更新中策略偏离太远,后续的数据分布发生偏移,价值函数随之失效。解决:把学习率从 3e-4 降到 1e-4,或将更新轮次从 10 降到 5。同时观察被 clip 的样本占比,如果崩盘前该比例急剧上升,说明策略更新幅度已经超出了 clip 的控制范围。
5.3 现象三:自己写的 PPO 学不动,SB3 能学
现象:用某成熟开源库(如 SB3)跑同样的环境,几万步就能拿到 200 分;自己按论文实现的代码跑了几十万步还在原地。原因:实现细节有差异,最常见的三个:一是价值函数初始化不当,最后一层偏置没有合理设置,导致初始价值预测偏离实际回报几个数量级,策略梯度被错误放大;二是 GAE 计算中忽略了终端状态的掩码,导致回合结束后的状态被错误 bootstrap;三是采样阶段保存的是旧策略的 log_prob,但更新时用的分布参数不对齐。解决:不要迷信论文公式,打印出每个中间量的形状和量级。检查你的old_log_probs是否真的来自采样时的策略、而不是更新后的策略,这是很多人没有意识到的隐藏 bug。
5.4 现象四:训练很慢,GPU 占用率上不去
现象:训练总时长长达数小时,nvidia-smi 显示 GPU 利用率只有 10% 左右。原因:LunarLander 的环境步进在 CPU 上执行,单环境串行采样是主要瓶颈。每一轮采样 2048 步,环境计算占了大半时间,神经网络前向传播反而不是瓶颈。解决:用gymnasium.vector.AsyncVectorEnv并行创建多个环境,比如 8 个子进程同时采样,再合并数据。注意向量化环境的观测返回值形状会多一个维度,缓冲区拼接时要调整。另外确认渲染确实关闭,render_mode=None能让采样速度提升 3~5 倍。
5.5 现象五:同一组权重,eval 分数忽高忽低
现象:保存的模型在测试时一局能拿 250 分,下一局直接得 -80,波动极大。原因:训练时通常用采样方式选动作,λ 分布自带随机性;评估时如果沿用采样策略,随机动作可能直接导致着陆失败。解决:评估阶段必须切换到确定性策略——离散动作取argmax(logits),连续动作取分布均值。另外评估要跑足够多的回合数,单局评估没有统计意义,至少跑 10 局取平均,标准做法是 100 局滑动平均。
6. 模型评估与进阶方向:从「跑通」到「稳定复现」的验证技巧
训练跑通只是第一步,真正的考验是评估口径是否科学、结果能否稳定复现。这一章把评估方法和进阶方向收在一起,你会发现很多「学不会」的问题其实不是算法问题,而是评估方法误导了判断。
6.1 评估口径:平均分 200 到底怎么算
LunarLander 官方的收敛标准是「过去 100 局平均奖励 ≥ 200」。这里有两个容易踩的细节。第一,是「过去 100 局」而不是「当前 100 局」——训练过程中需要持续维护一个滑动窗口,窗口里的数据会包含早期没学会时的低分样本,所以哪怕最近的成绩已经稳定在 250 分,滑动平均也可能还没到 200。解决办法是额外单独跑一轮纯评测,不更新策略、用确定性动作、连续测 100 局取平均。第二,评估用的随机种子要和训练时保持一致,否则环境初始状态的分布差异会直接反映在分数上。
科学的评估流程是:训练结束后固定一组随机种子,创建一个新的环境实例(不共享训练时的状态),在确定性模式下连续运行 100 局,记录平均分、标准差、成功率和平均燃料消耗。平均分 200+ 只说明学会了着陆,标准差能反映稳定性,成功率反映鲁棒性。如果平均分达标但标准差超过 50,说明策略对某些初始状态还很脆弱,建议继续训练或检查熵系数是否过高。
6.2 进阶方向:连续动作版本与复现性检查
当你跑通了离散版 LunarLander,下一步最自然的挑战是切换到连续动作版本——动作从 4 个离散选项变成一个 2 维连续向量,分别控制主推进器和侧向推进器。这一步会让 PPO 的 Actor 从输出 softmax 变为输出高斯分布的均值和方差,训练难度也会上一个台阶,但环境接口、GAE 逻辑、超参数表几乎可以原样复用。我的建议是先在离散版验证你的更新函数和排查工具,再切换到连续版,这样定位新问题时能排除环境建模的干扰。
复现性检查是更进阶的工程习惯。同一组超参数换一个随机种子,分数可能差 30~50 分,因此评估时必须跑 5 个不同种子取中位数和四分位距,而不是盯着某一次实验的结果。训练过程中定期保存检查点也很重要——按迭代轮次保存权重文件,而不是只在训练结束时保存一次。这样当发现后期评估掉分时,可以回滚到之前的检查点,不必重新训练。我习惯每 50 轮采样保存一次,一个训练跑下来会有五六个检查点,排查问题时相当于有了后悔药。
最后说一个某开发者的教训:他当初为了让训练更快,把熵系数直接设成 0,想着「反正策略收敛了就不需要探索」,结果策略在 3 万步内坍缩,奖励卡在 -100 附近死活出不来,排查了两天才意识到是探索不足的问题。从那以后他的习惯是:熵系数起步永远保留 0.01,除非看到训练曲线明确进入平台期,否则不轻易归零。这个习惯让他后续跑连续控制任务时少踩了很多坑。在 LunarLander 这种低维环境里,策略坍缩的代价是小,一旦养成坏习惯,换到复杂环境就会付出高昂的时间成本。希望这篇笔记能帮你从「跑通 PPO」走到「理解 PPO」,再把这份理解迁移到更复杂的控制任务上。
本文还有配套的精品资源,点击获取