简介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 参数为什么这么敏感GAEGeneralized Advantage Estimation的本质是用指数加权平均来平衡偏差和方差。当 lambda 接近 0 时它退化成一步 TD 误差的叠加当 lambda 接近 1 时它接近蒙特卡洛回报。LunarLander 的奖励信号比较稀疏——你只有在落地或坠毁时才能拿到大幅度的正负奖励中间过程都是小数值的 shaping reward——所以 lambda 取偏高一点能更快传播奖励但太高又会增加方差。实际实现时不建议自己手写循环用 PyTorch 的向量化操作更优雅def compute_gae(rewards, values, dones, gamma0.99, lam0.95): rewards: shape [T, N] values: shape [T1, 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是 T1 个时刻的价值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_dim8, action_dim4, hidden_dim128): 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(logitslogits) 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, dtypetorch.float32)).squeeze().detach() next_value actor_critic.value(torch.tensor([next_state], dtypetorch.float32)).squeeze().detach().item() advantages, returns compute_gae( torch.tensor(rewards, dtypetorch.float32).unsqueeze(1), torch.cat([values, torch.tensor([next_value])]).unsqueeze(1), torch.tensor(dones, dtypetorch.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_lossentropy_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减少同一批数据上的更新次数。我遇到一次崩溃就是 lr1e-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(seedseed)。如果你用的是新版 Gymnasiumenv.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% 的计算开销但换来的是一晚上的安心。希望这些拆解能让你少踩几个我踩过的坑顺利把这个项目跑出理想分数。本文还有配套的精品资源点击获取