☰
PPO强化学习实战:用PyTorch让LunarLander稳定突破250分
2026/10/11 14:23:28 网站建设 项目流程

简介:PyTorch-LunarLander是一个基于PyTorch框架的深度强化学习示例工程,面向想要学习PPO算法的Python开发者,演示如何让智能体在月球着陆器环境中通过策略梯度方法实现自主降落。整个资源压缩包仅5KB,非常轻量,包含4个Python脚本,分别用于构建Actor-Critic网络、进行多环境并行采样、执行PPO训练循环以及绘制训练奖励曲线,功能覆盖完整。项目以Gym经典环境LunarLander-v2为测试平台,代码中详细实现了经验回放缓冲区、优势函数估计、新旧策略比例裁剪与信任区域约束等核心技术点,便于读者对照公式理解PPO如何在限制策略更新幅度的同时稳定提升累积奖励。资源虽小,但结构清晰,便于二次修改,适合作为自定义强化学习实验的基础模板。目前该资源已有1613人学习下载,对于希望从代码层面快速掌握PPO并尝试复现实验效果的开发者是一份值得参考的入门资料。

1. 用 PPO 把 LunarLander 跑上 250 分:这个 PyTorch 项目到底值不值得下

先说结论:这份 pytorch-lunarlander 资源解决的是「从零手写 PPO 并在 LunarLander-v2 上稳定收敛」这件事,它不是一个封装好的黑盒库,而是一套能逐行看懂、能改、能复现的训练代码。LunarLander 这个环境很有意思——动作空间只有 4 维离散,观测是 8 维连续向量,奖励范围在 -100 到 +100 之间,看起来比 Atari 简单,但实际上它对算法稳定性极其敏感:策略网络初始化差一点、GAE 的 lambda 设得不对、或者 clip 参数调过头,都可能让你看到「损失在下降但奖励永远在 -200 徘徊」的诡异现象。

我之前拿 DQN 跑过这个环境,虽然也能勉强过 200 分,但样本效率明显不如 PPO——DQN 需要近百万步才能稳定,而 PPO 配合 GAE 通常 40 到 60 万步就能达到 250 分左右。这份资源核心就是一套完整的 PPO 实现,包含 Actor-Critic 网络、GAE 优势估计、Clipped Surrogate Objective 以及训练循环,适合两类人:一类是正在学强化学习、想找一个「能跑通且代码量适中」的参考实现;另一类是已经在用 Stable-Baselines3 但想深入理解 PPO 内部机制、准备自己改算法的工程师。如果你只是想快速出个分,那用 SB3 更省事;但如果你想搞懂每个张量在做什么,这份资源值得拆开看。

2. PPO 的核心机制:Clipped Loss 与 GAE 在代码里怎么落地

2.1 三个关键公式到 PyTorch 代码的映射

PPO 的原始论文里给出了 Clipped Surrogate Objective,但很多人看公式觉得懂了,一写代码就懵。其实核心就三个点:新旧策略的比率、clip 操作、以及 GAE 计算的优势函数。这份资源里最让我觉得舒服的地方,就是把这三个公式拆成了清晰的计算步骤。

先看比率计算。在 PyTorch 里,你需要从当前策略采样动作,同时用旧策略的 log_prob 做分母。训练时重新计算新策略的 log_prob,两者的比值就是 importance sampling 的修正项。代码里通常长这样:

# 从 replay buffer 里取出一批经验 states, actions, old_log_probs, advantages, returns = batch # 重新计算当前策略下的 log_prob log_probs = self.actor(states).log_prob(actions) ratios = torch.exp(log_probs - old_log_probs) # Clipped surrogate objective surr1 = ratios * advantages surr2 = torch.clamp(ratios, 1.0 - clip_epsilon, 1.0 + clip_epsilon) * advantages policy_loss = -torch.min(surr1, surr2).mean()

这段代码有三个容易出错的地方。第一,old_log_probs必须在采样时保存下来,而不是训练时重新用旧策略计算——除非你保存了完整的旧策略权重,否则你根本没有旧策略;第二,advantages是 GAE 算出来的优势估计,不是简单的return - value;第三,clip_epsilon通常取 0.2 是一个经验值,有些环境 0.1 更稳,LunarLander 上用 0.2 是安全的。

GAE 部分则是另一个容易翻车的地方。这里我拆开讲。

2.2 GAE 的代码实现:lambda 参数为什么这么敏感

GAE(Generalized Advantage Estimation)的本质是用指数加权平均来平衡偏差和方差。当 lambda 接近 0 时,它退化成一步 TD 误差的叠加;当 lambda 接近 1 时,它接近蒙特卡洛回报。LunarLander 的奖励信号比较稀疏——你只有在落地或坠毁时才能拿到大幅度的正负奖励,中间过程都是小数值的 shaping reward——所以 lambda 取偏高一点能更快传播奖励,但太高又会增加方差。

实际实现时不建议自己手写循环,用 PyTorch 的向量化操作更优雅:

def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95): """ rewards: shape [T, N] values: shape [T+1, N],values[-1] 是 bootstrap 的 next_value dones: shape [T, N],terminal 状态标记 """ T = rewards.shape[0] advantages = torch.zeros_like(rewards) gae = 0.0 next_value = values[-1] for t in reversed(range(T)): # 如果 t 步是终止状态,delta 不需要加 bootstrap 项 delta = rewards[t] + gamma * next_value * (1 - dones[t]) - values[t] gae = delta + gamma * lam * (1 - dones[t]) * gae advantages[t] = gae next_value = values[t] returns = advantages + values[:-1] return advantages, returns

注意看dones[t]的作用——它同时控制 bootstrap 项和 GAE 递推项。很多人写这段时容易漏掉对dones的乘法,导致终止状态的价值被错误地 bootstrap,训练出来的 critic 就会高估终端附近的价值。我检查这个 bug 花过整整一下午,现象是训练几千步后 policy loss 突然爆炸,因为优势值在 terminal state 附近被算错了。

还有一点,这个实现里values是 T+1 个时刻的价值,values[:-1]对齐 rewards 的时间步,values[-1]是最后一个 transition 的下一状态价值。如果你用的是普通的compute_returns函数,这两个张量的长度对齐一定要小心。

2.3 Actor-Critic 网络结构:LunarLander 不需要复杂网络

这份资源里的 Actor 和 Critic 共享一个 backbone,但输出层分开。LunarLander 的状态空间只有 8 维,用两层 MLP 加 tanh 激活就足够了,不需要 CNN 也不需要注意力机制。一个我踩过的坑是:把 hidden size 从 128 提到 512 并不能明显提升性能,反而让训练变慢,还更容易过拟合到近期样本上。

class ActorCritic(nn.Module): def __init__(self, state_dim=8, action_dim=4, hidden_dim=128): super().__init__() self.features = nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, hidden_dim), nn.Tanh(), ) self.policy = nn.Linear(hidden_dim, action_dim) self.value = nn.Linear(hidden_dim, 1) def forward(self, x): features = self.features(x) logits = self.policy(features) dist = Categorical(logits=logits) value = self.value(features) return dist, value

这里的Categorical直接对 logits 构造分布,采样和 log_prob 计算都由它代劳。你不需要手动做 softmax。唯一要注意的是Categorical和torch.distributions.Categorical一样,传入的 logits 不会被缓存,每次调用log_prob都会重新计算一次 softmax,性能上没问题,但如果你在同一个分布对象上多次调用log_prob,要注意传入的 action 必须与采样时维度一致,否则静默广播会给你错误结果。

Critic 的输出是一个标量,但在 batch 训练时它的 shape 是[batch_size, 1]而不是[batch_size]。如果你直接用return - value计算 advantage,你会得到 shape 为[batch_size, batch_size]的矩阵——这个坑我见过不少人踩,Python 的广播机制不会报错,只会给你一堆看起来合理的数字,但 loss 曲线会逐渐变得奇怪。

3. 完整训练流程:从 rollout 采集到策略更新的每一步

3.1 整体训练框架:环境包装、buffer 结构与主循环

这份资源的训练流程不是一上来就进入 PPO 更新,而是严格按照「收集经验 → 计算优势 → 多轮更新 → 清空 buffer」的节奏推进。这是我建议你不要改动的结构,因为 PPO 是 on-policy 算法,使用旧经验进行多轮更新是允许的,但前提是这些经验来自同一次策略分布,一旦你中途改了策略参数,buffer 里的经验就「过期」了。

for iteration in range(total_iterations): # 阶段 1:收集 rollout states, actions, rewards, dones, next_states, old_log_probs = [], [], [], [], [], [] state = env.reset() episode_reward = 0 episode_count = 0 # 收集固定数量的 transition(而不是固定 episode 数) while len(states) < buffer_size: dist, value = actor_critic(state) action = dist.sample() log_prob = dist.log_prob(action).item() next_state, reward, done, _ = env.step(action.item()) states.append(state) actions.append(action.item()) rewards.append(reward) dones.append(done) old_log_probs.append(log_prob) episode_reward += reward state = next_state if done: state = env.reset() episode_count += 1 # 阶段 2:计算 GAE values = actor_critic.value(torch.tensor(states, dtype=torch.float32)).squeeze().detach() next_value = actor_critic.value(torch.tensor([next_state], dtype=torch.float32)).squeeze().detach().item() advantages, returns = compute_gae( torch.tensor(rewards, dtype=torch.float32).unsqueeze(1), torch.cat([values, torch.tensor([next_value])]).unsqueeze(1), torch.tensor(dones, dtype=torch.float32).unsqueeze(1) ) # 阶段 3:多轮 PPO 更新 for _ in range(update_epochs): indices = torch.randperm(buffer_size) for start in range(0, buffer_size, batch_size): idx = indices[start:start + batch_size] dist, value = actor_critic(states[idx]) log_probs = dist.log_prob(actions[idx]) ratios = torch.exp(log_probs - old_log_probs[idx]) # ... 计算 policy loss、value loss、entropy bonus 并更新

这里最关键的一点是update_epochs与buffer_size的配合。PPO 的作者默认推荐 3 到 4 个 epoch,但如果你设成 10,你会看到 policy loss 在第一个 epoch 后就开始来回震荡——这不是算法问题,是你在同一批数据上过度优化了。LunarLander 环境下,我测试下来 3 个 epoch、batch size 256 是最稳的组合。buffer size 的话,我一般取 2048,也就是大约 10 到 15 个 episode 的长度,这个量级下优势估计的方差控制得比较好。

3.2 损失函数组合:policy loss、value loss 与 entropy bonus 的配比

PPO 的最终损失是三部分的加权和,很多初学者只写了 policy loss,结果训练前期探索不足导致策略一直在一个次优区域转。正确做法是加入 value loss 和 entropy bonus:

# 价值损失,用 clipped value loss 可以进一步稳定训练 value_pred_clipped = old_value + (value - old_value).clamp(-clip_epsilon, clip_epsilon) value_loss = torch.max((value - returns) ** 2, (value_pred_clipped - returns) ** 2).mean() # 熵奖励,鼓励探索 entropy = dist.entropy().mean() policy_loss = -torch.min(surr1, surr2).mean() - entropy_coef * entropy total_loss = policy_loss + value_coef * value_loss

entropy_coef在 LunarLander 上我一般取 0.01,取大了策略会永远在随机游走,取小了前期容易陷入某个角落出不来回。value_coef默认 0.5 即可。如果你发现 reward 曲线早期有平台期,先检查 entropy 是不是掉得太快——如果熵在 5000 步内就从 1.3 掉到 0.1,说明策略太早确定,需要调高 entropy_coef 或降低学习率。

有一个细节值得记住:old_value必须在更新之前保存,否则多轮更新后value已经变了,你的 clipped value loss 对比的是错的值。这里的实现可以是old_value = value.detach()在进入 epoch 循环之前保存一份。

3.3 超参数记忆表:LunarLander 环境下的推荐配置

参数推荐值说明
gamma0.99折扣因子,LunarLander 任务最长 1000 步,0.99 足够
lam0.95GAE 参数,偏高有利于奖励传播
clip_epsilon0.2PPO 裁剪范围,0.1 更保守但收敛稍慢
learning_rate3e-4Adam 优化器首选,1e-3 容易发散
update_epochs3每批数据更新次数,超过 5 会过拟合
batch_size256小 batch 噪声大,大 batch 收敛慢
entropy_coef0.01熵正则系数
value_coef0.5价值损失权重
buffer_size2048每轮收集的 transition 数
hidden_dim128网络宽度,足够

这份表不是拍脑袋写的,是我在同一个环境下反复跑出来的可复现组合。严格来说,如果你完全照抄这份表的参数组合,配合正确的实现,LunarLander-v2 的平均奖励在 30 万步左右会突破 200 分,40 到 60 万步之间达到 250 分以上。这和 SB3 的 PPO 基线性能是接近的,但你的代码是逐行手写的,理解深度完全不一样。

4. 避坑锦囊:PPO 训练 LunarLander 的五个血泪经验

4.1 奖励长期为负且不增长:优势计算的维度匹配错误

现象:reward 曲线稳定在 -150 到 -100 之间,policy loss 也在下降,但就是不见好。

原因:这个是最隐蔽的坑。当returns的 shape 是[batch_size, 1]而value的 shape 是[batch_size]时,value_loss = (value - returns) ** 2计算出来的梯度方向是对的,但数值大小会被广播机制放大或缩小。更常见的是 advantage 计算时values[:-1]与rewards长度不匹配,PyTorch 静默广播导致最后一个 transition 被错误对齐。

解决:统一所有张量到[batch_size, 1]。在进入训练循环前断言value.shape == returns.shape,如果不相等,用.unsqueeze(-1)或.squeeze()调整。我用一个 debug 函数在每次更新前检查:

def assert_shape_consistent(tensor_dict): for key, tensor in tensor_dict.items(): assert tensor.dim() == 2, f"{key} must be 2D, got {tensor.shape}" assert tensor.shape[1] == 1, f"{key} must have singleton last dim, got {tensor.shape}"

4.2 训练到一半策略崩溃:学习率过大导致策略跳变

现象:前 10 万步表现很好,reward 在稳步上升,突然某个 iteration 开始,reward 骤降到 -300,且短期无法恢复。

原因:这是 on-policy 算法的经典问题——策略更新步长过大,一步跨出了「安全区域」。PPO 的 clip 机制能在一定程度上防止这种情况,但不能完全消除。尤其是在 buffer 边缘的 transition 上,如果其优势值极高(比如一次罕见的好降落),策略会被拖向一个激进的方向。

解决:学习率从 3e-4 降到 1e-4,同时确保梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)已经加上。还有个有效手段是降低update_epochs到 2,减少同一批数据上的更新次数。我遇到一次崩溃就是 lr=1e-3 且没加梯度裁剪,加了之后同样配置能稳定跑完。

4.3 奖励能到 200 但很快掉回负值:GAE 的 lambda 设得太高

现象:训练曲线在 20 万步附近爬到 200 分,但之后开始大幅回落,训练不稳定。

原因:lambda 设为 0.99 时,GAE 接近蒙特卡洛,方差太大。LunarLander 的 shaping reward 在小尺度上有不少噪声,过高的 lambda 会把短期波动放大成虚假的优势信号。训练初期这种「虚假优势」有助于探索,但后期它会干扰策略收敛。

解决:把 lambda 从 0.99 改为 0.95,这是一个非常实用的调参经验。在别的环境里我习惯用 0.98,但在 LunarLander 上 0.95 更稳,保险起见你可以直接用 0.92 到 0.95 之间做一次小 grid search。

4.4 entropy 过早归零:策略固化后无法跳出局部最优

现象:训练到中期,柯西奖励停留在 150 分左右,entropy 已经小于 0.05,策略几乎确定但效果差。

原因:entropy_coef 设置太小(比如 0.001),或者初始策略因为某种原因快速坍缩到一个确定性策略。LunarLander 的着陆过程有明确的「失败模式」——策略一旦学会一种不算太差的降落方式,就会一直重复,因为它没有动力去尝试别的路径。

解决:提高 entropy_coef 到 0.02,并考虑在训练前 10 万步使用更高的探索率。一个更系统化的方案是给 entropy bonus 加一个衰减调度器,让它从 0.03 线性衰减到 0.005,在探索和利用之间做动态平衡。

4.5 复现不了论文分数:环境版本与 seed 未固定

现象:同样的代码,你跑 5 次,每次结果差异极大——一次 260 分,一次 120 分。

原因:LunarLander-v2 的初始状态是随机生成的,如果你的代码里没有固定np.random.seed、torch.manual_seed和env.seed,策略的初始分布差异会被放大。这不是代码 bug,是强化学习的固有方差。

解决:训练脚本最前面固定三段:

def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) env = gym.make("LunarLander-v2") env.seed(seed) return env

注意env.seed在 Gym 0.26 之后的版本里是被弃用的,新接口是env.reset(seed=seed)。如果你用的是新版 Gymnasium,env.seed(seed)会直接报错,这个兼容问题也值得关注。

5. 从 200 分到 300 分:稳定收敛的三个进阶验证技巧

5.1 用「滑动平均奖励」而非「单回合奖励」判断训练状态

训练曲线的单回合奖励噪声极大,一个回合可能因为一个偶然的引擎点火时机拿到 280 分,下一回合又因为同一个错误掉到 100 分。如果你只看原始曲线,你几乎不可能判断策略是否真的在变好。我习惯维护一个长度为 100 的滑动窗口,计算窗口内的平均奖励并记录到日志里。当滑动平均连续 5000 步没有上升时,再考虑调超参数——这个判断逻辑比每个 iteration 对比一次「最新分数」可靠得多。

5.2 验证「确定性策略」下的部署表现

训练时用的策略是随机采样的,这带来了探索噪声。实际部署时你往往希望动作是确定的——直接取dist.probs.argmax(dim=-1)而不是dist.sample()。一个很有价值的验证方法是:每 5 个 iteration 跑一次确定性策略评估,记录 30 个回合的平均奖励。这个分数比训练时的随机策略分数更能反映策略的真实水平。我在 LunarLander 上的经验是,一个训练良好的 PPO 策略,确定性评估分数比随机采样分数高 20 到 40 分——如果两者差距过大,说明策略仍过度依赖探索,部署时会出现「行为差异」。

5.3 检查「优势值分布」是否出现异常尖峰

我每 1000 步会打印一次advantages的均值、标准差和最大值。正常的训练过程中,优势值应近似服从零均值的分布,标准差在 0.5 到 1.5 之间。如果你发现某个时刻的max_advantage > 5,大概率是出现了异常样本(比如一个极端幸运的回合被采到了),此时应该考虑增大 buffer_size 来稀释极端值的影响,而不是直接调小学习率——这是两个完全不同的修理方向。

5.4 保存 checkpoint 并定期回滚

训练到后期,策略可能会在某个 iteration 突然崩溃。如果只在训练结束后保存一次模型,崩溃会让整个训练白费。我建议每个 iteration 都保存一份 checkpoint 文件,命名带iteration编号,然后在日志里记录每个 checkpoint 的确定性评估分数。一旦发现当前策略的评估分数低于历史最优的 80%,直接加载最优 checkpoint 继续训练,同时把学习率减半——这相当于给训练加了后悔药机制。

从那以后,我每次在 LunarLander 上跑 PPO 都会强制自己走完这套「滑动平均 + 确定性评估 + 优势值分布检查」的流程,不会再只看一张 loss 曲线就判断训练有没有成功。这些验证手段大约增加 10% 的计算开销,但换来的是一晚上的安心。希望这些拆解能让你少踩几个我踩过的坑,顺利把这个项目跑出理想分数。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询