基于 PPO 的 LunarLander 着陆智能体(第 4 周实践)
仓库地址:https://huggingface.co/lin267/ppo-LunarLander-cleanrl
一、实验目的
这次实验对应第 4 周课件第 2~14 页的案例:训练一个智能体,让它控制登月舱在月球表面两个旗杆之间安全着陆。完成一次合格的着陆大约能得到 200 分以上,随机乱撞大约在 -150 分左右。
课件给出的参考实现是 cleanrl 风格的 PPO 代码。第一周我已经用 Stable-Baselines3 在同样的环境上跑通过一次,所以这次的重点不在于"把流程跑起来",而在于自己把 PPO 写一遍:Actor-Critic 网络、rollout 采样、GAE 优势估计、PPO-Clip 损失,以及训练日志、模型评估和视频录制这些外围部分,都自己实现,这样才能看清课件里那些公式在代码里到底对应哪几行。
二、实验环境
| 项目 | 内容 |
|---|---|
| 操作系统 | Windows |
| 运行环境 | conda 虚拟环境 RL,Python 3.10.22 |
| 强化学习库 | gymnasium 1.3.0 + Box2D 2.3.10 |
| 深度学习框架 | PyTorch 2.14.1(CPU 版) |
| 其他依赖 | numpy 2.2.6、pillow、imageio、imageio-ffmpeg、huggingface_hub |
| 硬件 | 本机 CPU 训练,没有用 GPU |
这里有一个环境上的情况需要说明:gymnasium 1.3 中 LunarLander-v2 已经被标记为废弃,只能使用 LunarLander-v3。两者的观测空间(8 维连续向量)、动作空间(4 个离散动作)和奖励设计与 v2 相同,v3 主要是修了一些渲染和版本管理上的问题,所以训练效果可以和课件里 v2 的结果直接对比。
不采用 GPU 是因为网络规模很小:Actor 和 Critic 各是一个 8-64-64 的 MLP,两个网络加起来约 1 万个参数。训练时的实际瓶颈是 Box2D 物理引擎的单线程步进,不是矩阵运算,用 16 个并行环境已经能把 CPU 吃满,换成 GPU 反而会多出传输开销。
三、实验内容
3.1 自己实现的 PPO
代码在 src/ppo.py 里,主要部分和课件的对应关系如下:
- 网络结构:观测(8 维)先经过两层 64 维的 Tanh 隐层,然后分成两个头,Actor 头输出 4 个动作的 logits,Critic 头输出一个状态价值。策略用 Categorical 分布采样,对应课件第 12 页的
get_action_and_value()。 - 并行采样:同时开 16 个环境,每个环境每轮连续走 256 步,一次采样得到 4096 条经验。环境用自己写的一个
VecEnv管理,包括回合结束后的自动重置和回报统计。 - 优势估计:用 GAE,从最后一步往前递推,计算 TD 残差和指数加权平均,回报等于优势加价值估计,对应课件第 13 页。
- 损失函数:策略损失用 PPO-Clip(裁剪系数 0.2),价值损失也做裁剪,熵奖励系数 0.01,优势在 mini-batch 内做标准化,梯度按范数 0.5 裁剪,学习率随训练线性衰减到 0,对应课件第 14 页。
- 训练监控:每次更新把回报、策略损失、价值损失、熵、近似 KL、裁剪比例、每秒步数写进 CSV;每 15 次更新用 argmax 策略单独跑 16 局做评估,并按评估分数保存最好的模型;训练到 10%、25%…100% 进度时各录一局视频,用来观察策略是怎么一步步变好的。
3.2 第一次实验:照课件命令跑 5 万步
先按课件第 11 页给的参数跑一遍,步数取默认的 5 万,其余参数也保持默认(4 个环境、每环境 128 步、gamma 0.99、GAE lambda 0.95、4 个 mini-batch、更新 4 轮)。
python ppo.py --total-timesteps 50000 --num-envs 4 --num-steps 128 --out-dir runs --run-name baseline_50k
结果:训练只用了 19.4 秒,最后 100 个回合的训练回报是 -143.8;用 10 局 argmax 评估,平均 -690.3(标准差 265.9),着陆率 0%。也就是说,模型完全没有学会着陆。
分析下来有三个原因:
- 更新次数太少。5 万步除以(4 环境 × 128 步),一共只有 97 次参数更新。PPO 是 on-policy 算法,每次更新只能用上一轮采到的数据,97 次更新根本不足以让一个随机初始化的网络形成有效的策略。
- 折扣因子太小。
gamma=0.99时,300 到 900 步之后的着陆奖励会被折扣到 0.99^500 ≈ 0.007,几乎传不回前面的状态。而登月恰恰是"前期动作的后果要到几百步之后才兑现"的任务,这一点影响很大。 - 评估口径会误导。训练时用的是采样策略,探索噪声掩盖了很多问题;部署和评估用的是 argmax,早期网络输出的概率几乎是均匀的,argmax 会固定选同一个动作(比如一直开主引擎),所以评估分数反而比随机策略(约 -200)还低。这也是为什么 5 万步的训练回报看起来只是"略微差",评估却是 -690。
3.3 第二次实验:调整参数后跑 250 万步
针对上面的原因改了以下几处,其余超参数保持不变:
| 参数 | 原值 | 新值 | 修改原因 |
|---|---|---|---|
--gamma |
0.99 | 0.999 | 让几百步之后的着陆奖励还能传回来(0.999^500 ≈ 0.61) |
--gae-lambda |
0.95 | 0.98 | 长时程任务下降低优势估计的偏差,与新的 gamma 配合 |
--num-envs / --num-steps |
4 / 128 | 16 / 256 | 单次采样从 512 条增加到 4096 条,梯度噪声更小 |
--num-minibatches |
4 | 8 | 每个 mini-batch 约 512 条,配合更大的批量 |
--update-epochs |
4 | 10 | 每次采样多利用几轮,提高样本效率 |
--total-timesteps |
50000 | 2500000 | 保证有 610 次更新,留出足够的收敛时间 |
python ppo.py --total-timesteps 2500000 --num-envs 16 --num-steps 256 --gamma 0.999 --gae-lambda 0.98 --num-minibatches 8 --update-epochs 10 --eval-interval 15 --eval-episodes 16 --capture-video --seed 31 --out-dir runs --run-name main_2.5m
训练一共用了 8.3 分钟(497 秒),610 次更新,平均每秒约 5000 个环境步。
训练过程中每隔 15 次更新做的评估(argmax 策略,每次 16 局)大致是这么变化的:
| 训练步数 | 评估平均分 | 着陆率 | 阶段 |
|---|---|---|---|
| 6.1 万 | -1983.3 | 0% | 策略还是均匀分布,argmax 退化,这个分数没有参考价值 |
| 18.4 万 | 17.3 | 0% | 开始学会俯冲和反推,但姿态控制不住 |
| 43.0 万 | 194.7 | 44% | 接近 200 分,能落但经常翻倒 |
| 98.3 万 | 204.1 | 88% | 越过 200 分,稳定性明显提高 |
| 135 万 | 202.1 | 69% | 有波动,没有一直保持 |
| 221 万 | 268.3 | 100% | 训练中最好的一次,这个检查点保存为 model_best.pth |
| 250 万 | 257.5 | 100% | 训练结束 |
从损失曲线也能看出过程是正常的:策略熵从 1.386(4 个动作的均匀分布)降到 0.63 左右,说明策略逐渐确定化;价值损失从 3500 多降到 150 上下;策略损失在优势标准化之后一直在 ±0.01 内小幅波动;近似 KL 全程低于 0.02,说明 PPO 的裁剪一直在起作用,没有出现策略被更新坏掉的情况。
四、实验结果
4.1 最终模型的评估
用 model_best.pth(训练到 221 万步时的检查点)做 100 局 argmax 评估,这是官方判定"解决"的标准口径:
| 指标 | 数值 |
|---|---|
| 平均回报 | 258.66 |
| 标准差 | 22.25 |
| 中位数 | 260.42 |
| 最低 / 最高 | 116.49 / 298.98 |
| 25% / 75% 分位 | 249.07 / 269.71 |
| 单局回报 ≥ 200 的局数 | 98 / 100 |
| 着陆率 | 98% |
作为对照,用随机策略跑同样的 100 局,平均 -204.08(标准差 118.89),最低 -602.79,最高 16.75,着陆率 0%。两者的差距很清楚。
官方对这个环境的判定标准是"100 局平均分 ≥ 200",258.66 分已经达到。不过也要如实说明:100 局里有 2 局没到 200 分(最低 116.49),说明模型并不是每一局都稳定着陆,偶尔会出现姿态没控住的情况。
另外提一个容易混淆的地方:训练日志里最后一行的"训练回报"是 244.1,它用的是带探索的采样策略,和评估用的 argmax 策略不是一回事。评估应该以 argmax 的分数为准,因为部署时就是这么用的。
4.2 学习曲线与训练过程
assets/learning_curve.png 是学习曲线,灰线是每轮更新的训练回报(波动很大,是正常的探索噪声),蓝线是它的滑动平均,绿线是每隔 15 次更新的评估分数,红色虚线是 200 分的达标线。可以看到大约 40 万步之后策略开始成形,100 万步左右跨过 200 分,200 万步之后稳定在 250 分上下并越过达标线。
assets/losses.png 是四个损失项随训练的变化,assets/eval_progress.png 是评估均值加标准差,assets/eval_histogram.png 是最终 100 局评估的回报分布——大部分局集中在 240 到 290 分之间,左侧拖出两条没落好的,和上面说的情况一致。
4.3 视频
videos/ 里有两类视频:
replay_ep1.mp4、replay_ep2.mp4、replay_ep3.mp4:最终模型的三个演示回合,回报分别是 266.5、256.3、249.9,都能看到登月舱从上方下降、调整姿态、最后落在两个旗杆之间的平地上。learning_*.mp4:训练过程中按进度录的 7 个回合,能直观看到策略的变化——早期(24.9 万步)是 -83.6 分,基本是乱撞;到 62 万步能得 218.6 分;最后几次都在 210 到 270 分之间。
五、结论
- 课件默认的 5 万步配置在这个任务上学不出来。按并行环境数和采样步数换算,5 万步只有 97 次参数更新,对 on-policy 的 PPO 来说远远不够。
gamma对这个任务影响很大。着陆奖励出现在几百步之后,0.99 的折扣会把奖励几乎完全衰减掉,改成 0.999 之后才有足够的学习信号;但只改 gamma 是不够的,批大小、GAE lambda 和更新轮数要一起调,否则训练方差会变得很大。- 评估方式和训练指标不能混用。训练回报反映的是带探索的策略,评估要用 argmax 并跑足够多的局(至少 30 局,按官方标准要 100 局),否则很容易被少数几局的结果误导。
- 不要因为训练早期的 argmax 评估分数很低就判断训练失败。那时策略还没确定化,argmax 退化是正常现象,从 6.1 万步的 -1983 分到 43 万步的 194.7 分,中间变化很大。
- 这个任务不需要 GPU。网络只有约 1 万参数,瓶颈在 Box2D 物理引擎的步进,CPU 上开 16 个并行环境,8 分钟就能从零训练到 258 分。
六、复现方法
环境已经配置好(conda 环境名 RL),在命令行里执行:
conda activate RL
cd lunarlander-ppo
python check_env.py
check_env.py 会检查 gymnasium、Box2D、torch 等依赖,并创建一次环境跑 100 步随机动作,确认物理引擎可用。
训练和评估:
:: 环境自检
python check_env.py
:: 课件原始配置(5 万步,用于复现失败情况)
python ppo.py --total-timesteps 50000 --num-envs 4 --num-steps 128 --out-dir runs --run-name baseline_50k
:: 正式训练(约 8~10 分钟)
python ppo.py --total-timesteps 2500000 --num-envs 16 --num-steps 256 --gamma 0.999 --gae-lambda 0.98 --num-minibatches 8 --update-epochs 10 --eval-interval 15 --eval-episodes 16 --capture-video --seed 31 --out-dir runs --run-name main_2.5m
:: 100 局评估,并生成演示视频、关键帧和 GIF
python evaluate.py --model runs/main_2.5m/model_best.pth --episodes 100 --video-episodes 3 --out artifacts
:: 画曲线
python plot_curves.py --run runs/main_2.5m --baseline runs/baseline_50k --out artifacts
文件说明:
| 路径 | 内容 |
|---|---|
src/ppo.py |
训练主程序,从零实现的 PPO |
src/evaluate.py |
评估脚本,输出指标、视频、关键帧、GIF |
src/plot_curves.py |
绘图脚本,生成学习曲线和评估图表 |
model.pt |
最终模型权重(含 obs/动作维度和训练配置,可直接 torch.load) |
results/eval_results.json |
100 局逐局回报与统计结果,含随机策略对照 |
results/summary.json |
训练汇总(超参数、耗时、最终评估) |
logs/train_log.csv |
每次参数更新的训练日志 |
logs/eval_log.csv |
每次评估的日志 |
logs/config.json |
本次训练的全部超参数 |
assets/ |
学习曲线、损失曲线、评估分布、基线对比、关键帧、GIF |
videos/ |
演示视频和学习过程视频 |