PPO强化学习实战:从零训练AI通关超级马里奥完整指南
2026/9/17 1:52:31 网站建设 项目流程

先回答一个很多人问过我的问题:为什么一提到强化学习实战,大家总是拿超级马里奥开刀?因为这个游戏是一个绝佳的“最小可行问题”——画面是像素矩阵,动作空间小到一只手数得过来,但你要处理的却是强化学习里最真实的两个痛点:只能看到当前屏幕带来的部分可观测环境、以及绝大多数时刻没有任何奖励的稀疏奖励。换句话说,你不必造一个复杂的仿真环境,就能把PPO算法、GAE优势估计、奖励塑形这些概念全部串起来跑一遍。

这篇文章我会从环境搭建、预处理、PPO核心原理、奖励设计到训练调参,完整拆解一个能通关超级马里奥1-1关卡的AI项目,并附上全套可运行代码。你不需要有很深的强化学习背景,也不需要懂游戏开发,但最好对Python和PyTorch有基本了解。如果你正在学强化学习却卡在“理论都会、代码不会写”的阶段,这篇就是为你准备的。

1. 为什么拿马里奥这个关卡训练PPO——选题背后的考量

1.1 马里奥不是玩具:一个“小游戏”里藏着两个核心难题

超级马里奥第一关看起来简单,实际跑起来却远没有CartPole或者月球着陆器那么轻松。首先,它的输入是高分辨率的彩色图像,虽然一帧画面是224x256的RGB,但真正影响决策的语义信息——马里奥位置、敌人位置、管道和台阶的分布——在图像里只占很小一部分,模型必须自己“看”出来。其次,马里奥是一个连续时域决策问题,这一帧的动作会影响下一帧的状态,AI不能像处理静态图片一样一次决定一个动作,而要在每一步都基于当前画面和历史信息做判断。

这两个特征叠加在一起,正好踩中了强化学习的两个核心难点。

第一是部分可观测。马里奥的屏幕只能展示角色周围的一小块区域,后面有没有水管、前面有没有悬崖,AI是看不到的。它必须在“记忆”里建立对关卡结构的估计,而不是简单地看一眼画面就做决定。第二是稀疏奖励。在这个环境里,游戏自带的奖励信号非常稀薄:踩到敌人才给100分,吃到金币给1分,到达终点才给1000分。但在绝大多数时间步里,马里奥只是在一个平台上跑动,agent收到的奖励是0。如果你直接用原始奖励去训练,策略网络在初期完全无法从随机探索中得到有效梯度,模型会在原地瞎跳,看起来像一只没头的苍蝇。

这也是我坚持用马里奥做教学案例的原因:它足够“小”到可以在单机上训练,又足够“难”到能逼你真正理解PPO本质,而不是套一个现成库就以为学会了。

1.2 PPO凭什么能搞定这种任务

有人可能会问,DQN不是也能玩Atari游戏吗?A3C不是也能做这种连续控制任务吗?为什么要选PPO?

DQN在高维离散动作空间里当然也能跑,但它依赖经验回放和目标网络,对超参数极其敏感。训练过程中稍微改动一下学习率,或者网络结构差了一层,Q值就可能发散。我在跑DQN类算法时最头疼的就是调试不稳定:loss曲线明明在降,agent的表现却越来越差,最后排查半天发现是回放缓冲区里样本分布出了问题。

A3C通过异步多进程引入多样本,但算法层面使用的是策略梯度的高方差版本,收敛速度在不同随机种子之间差异很大。你今天跑能过第一关,明天换一个种子可能连第一个水管都跳不过去。

PPO之所以成为目前单智能体强化学习最常用的baseline,原因很朴素:它在TRPO的基础上做了一层近似,用clip操作限制每次更新的幅度,实现简单、对超参不那么敏感、而且天然适合并行采样。你不需要仔细维护目标网络和评估网络的同步节奏,也不必担心步长太大策略直接崩掉。OpenAI把PPO列为默认算法不是没有道理的——对于马里奥这种中等复杂度的游戏,PPO几乎是“第一次跑就能有正经结果”的选择。

1.3 本文的实验环境与最终目标

讲原理之前先把“战场”交代清楚,免得你照着做的时候在环境依赖上卡半天。

我这次的运行环境是Ubuntu 20.04、Python 3.9、PyTorch 2.0、stable-baselines3 2.1、gym-super-mario-bros 7.4。这个组合在Windows下也大部分能用,但nes-py这个底层模拟器在Windows上偶尔会有pygame依赖和编译问题,如果你在Windows上遇到环境创建失败,优先考虑用WSL2或者直接上Google Colab。关于版本匹配我后面会详细说。

最终的训练目标是让模型跑完100万步后,在SuperMarioBros-1-1关卡中稳定通关。所谓“通关”指的是马里奥从起点走到终点旗杆并触发flag_get。我会把整个流程拆成四块:环境处理、PPO核心机制、奖励塑形、训练与评估。你跟着走一遍,就能跑出一个有真实学习曲线、有通关能力、而不是只会在左上角摇头晃脑的AI。

2. 环境与预处理:先让AI“看见”像素,再让它“按”对键

2.1 环境库与版本匹配:这里有一堆小坑

gym-super-mario-bros是基于NES模拟器nes-py封装出来的gym环境,它有多个版本号,不同版本对gym接口的依赖不同。我在跑之前先确认了三个包的版本关系:gym-super-mario-bros 7.4.0依赖nes-py 8.2.1,而nes-py对Python版本有硬性要求。Python 3.10以上在安装nes-py时经常出现ERROR: Failed building wheel for nes-py,这是因为底层的C++扩展需要重新编译。

我的建议是直接用Python 3.9环境跑,装起来最省心。安装命令就两行:

pip install gym-super-mario-bros==7.4.0 pip install stable-baselines3==2.1.0

如果你是在Colab上跑,Colab默认的Python是3.10,需要先在Notebook里切换运行时到Python 3.9的老版本镜像,或者降低nes-py版本试试,但我个人不推荐降版本,因为老版本和新的gym接口兼容性更差。

另外一个容易忽略的坑是gym的接口版本。稳定基线库SB3 2.x版本兼容gym 0.21接口,但gym-super-mario-bros官方的顶层导入用的是旧版gym.Env。如果你在同一个环境里装了gymnasium,很可能会出现AttributeError: module 'gym' has no attribute 'make'之类的报错,因为两个包会互相覆盖命名空间。解决方式很粗暴:不要在同一个虚拟环境里同时安装gym和gymnasium。如果必须共存,就用colab或者换一个干净的venv。

2.2 动作空间:为什么用SIMPLE_MOVEMENT而不是COMPLEX_MOVEMENT

马里奥环境提供了两套动作空间。SIMPLE_MOVEMENT包含7个动作,基本覆盖了游戏中最高频的操作:左、右、跳、右跳、左跳、原地跳跃等。COMPLEX_MOVEMENT有12个动作,额外加入了加速跑、下蹲、下蹲跳等操作,更接近真实玩家的操作习惯。

第一关训练我强烈建议先用SIMPLE_MOVEMENT。原因也很直白:动作空间越小,策略网络需要探索的组合就越少,收敛越快。对1-1关来说,7个动作的覆盖范围足够让马里奥完成所有跳跃操作。尤其是“右跳”这个动作组合,编码方式非常直观——向右移动和跳跃同时触发,训练初期agent只要学会这个组合就能跳过第一个台阶。

代码封装如下:

from nes_py.wrappers import JoypadSpace import gym_super_mario_bros from gym_super_mario_bros.actions import SIMPLE_MOVEMENT env = gym_super_mario_bros.make('SuperMarioBros-1-1-v0') env = JoypadSpace(env, SIMPLE_MOVEMENT)

JoypadSpace这个wrapper的作用是“裁剪”动作空间——它把NES手柄里原始的那一串按钮组合,映射成我们指定的动作集合。所以这里的本质不是减少游戏本身的复杂性,而是帮agent在探索初期排除掉明显无效的按钮组合。

2.3 观测预处理:灰度化、缩放、堆帧分别解决什么问题

马里奥原始观测是一张RGB图像,形状是(224, 256, 3),如果直接扔给神经网络,第一个卷积层的输入通道就是3,参数量还勉强可以接受,但计算量会明显拖慢训练。更关键的问题是,颜色对这张地图的决策几乎没有任何帮助,马里奥不像某些游戏那样需要靠红色和蓝色区分敌我属性。所以第一步就是把三通道彩色图压成单通道灰度图。

然后是缩放。Atari系列环境的标配是84x84,这个尺寸是经过大量实验验证的性价比选择。224x256缩到84x84,空间分辨率降了一半多,但管道、台阶、敌人的轮廓依然清晰可辨,卷积计算量却大幅下降。我试过直接用原始尺寸训练,训练速度大概要慢三倍,收敛效果却没有实质提升。

最后是堆帧,这一步经常被新手忽略。单张灰度图能告诉agent“现在马里奥的位置和前方障碍物”,但没法告诉它“马里奥是在上升还是下落”。堆帧就是连续拼接最近4帧灰度图,让agent能从画面序列中感知运动趋势。这跟你打游戏时盯着屏幕看人物动态是一个道理,单张截图永远不如连续画面有信息量。

我使用的预处理链路如下:

from gym.wrappers import GrayScaleObservation, ResizeObservation, FrameStack env = gym_super_mario_bros.make('SuperMarioBros-1-1-v0') env = JoypadSpace(env, SIMPLE_MOVEMENT) env = GrayScaleObservation(env, keep_dim=False) env = ResizeObservation(env, 84) env = FrameStack(env, 4)

这里每一步都是有明确目的的。GrayScaleObservation的keep_dim=False表示把(H, W, 1)压成(H, W),否则后面ResizeObservation的输入维度对不上。ResizeObservation是一个很轻量的wrapper,内部用OpenCV的插值算法做缩放。最后的FrameStack返回的是一个LazyFrames对象,它不会真的复制四份图像,而是在需要取值时才拼接,所以内存开销很小。

经过这一串操作,环境的观测空间从原来的(224, 256, 3)变成了(84, 84, 4)。第四个维度是堆帧数,而不是通道数,这一点在自定义CNN时要格外注意。

3. PPO的核心公式与代码里的关键参数

3.1 从策略梯度到clip:PPO到底在优化什么

PPO属于策略梯度方法的一个变种。策略梯度的核心思想非常直观:如果某个动作在该状态下获得了高于平均水平的回报,那我们就提高这个动作的概率;反之就降低它。用数学语言说,策略梯度的损失函数长这样:

L^{PG}(θ) = E_t [ ∇θ log π_θ(a_t | s_t) A_t ]

这里的A_t叫优势函数,表示“动作a_t在状态s_t下比平均水平好多少”。如果A_t是正的,梯度方向会推高log概率;如果A_t是负的,梯度会压低它。听起来很完美,但问题出在更新的幅度上。在传统策略梯度里,如果你一不小心用了一个偏大的学习率,某一步就会把策略概率推向一个极端,前面几百步学到的信息全部报废。

TRPO的解决方式是给每次更新加一个硬约束:新旧策略的KL散度不能超过一个阈值。数学上最优,实现上复杂,尤其是大规模神经网络下的共轭梯度求解极其麻烦。

PPO的聪明之处在于把这个约束转化成了目标函数里的一个clip项:

L^{CLIP}(θ) = E_t [ min( r_t(θ) A_t, clip(r_t(θ), 1-ε, 1+ε) A_t ) ]

其中 r_t(θ) = π_θ(a|s) / π_old(a|s) 表示新旧策略在同一个动作上的概率比。当优势A_t为正时,理论上我们希望r_t越大越好,但clip操作会让r_t超过1+ε的那部分梯度变为0;当A_t为负时,r_t低于1-ε的部分梯度也被截断。这样每次梯度更新就不会超过一个ε的比例,既限制了步长,又不需要计算复杂的KL约束。

我可以用一个爬山类比来解释。你手里有一张旧地图(旧策略)标注了一条上山的路线,PPO允许你沿这条路线走一小步,但最多只能走“ε”这么远。走完之后你必须重新评估自己在哪里、地图要不要更新,而不是凭着一张旧地图一路狂奔。这样即使地图有误差,也不会一下把你带进沟里。

3.2 GAE优势估计:为什么单一奖励本身不够用

强化学习里agent每一步获得的真实奖励R_t,并不能直接作为优劣判断标准。原因很简单:即使这个动作本身导致当前reward较低,如果它能引导后续获得巨大收益,那它依然是好动作。所以我们需要一个更“长视”的指标。

优势函数的定义是 A(s, a) = Q(s, a) - V(s),就是说“在状态s下,做了a之后平均能获得的额外回报,减去平均水平”。Q(s,a)很难直接学,老方法是用一步TD误差 δ_t = r_t + γV(s_{t+1}) - V(s_t) 来近似。但一步TD的缺点是偏差大的时候会丢掉远期信息。

GAE(Generalized Advantage Estimation)把多步TD做了一个指数加权平均:

A_t^{GAE} = Σ_{l=0}^{∞} (γλ)^l δ_{t+l}

这里的λ是一个权衡参数。λ=0时GAE退化成一步TD,方差小但偏差大——它只相信眼前这一步的奖励;λ=1时退化成蒙特卡洛,偏差小但方差大——它要看完整条轨迹的总回报。PPO在游戏任务里一般取λ=0.95,既保留了一定远期信息,又不至于让方差爆炸。

这也是为什么你在代码里会看到gae_lambda=0.95这个参数。它和γ的区别是:γ是奖励折扣因子,解决的是“未来的奖励值不值得信”的问题;λ解决的是“多少步的差分信息应该被纳入优势估计”的问题。两者协同,共同决定agent看待决策影响的时间尺度。

3.3 超参数是如何定出来的:从默认值开始微调

很多新手一上来就疯狂调超参,结果越调越差。我的经验是先跑一组SB3官方默认值,确认环境没问题、梯度有在更新,再针对具体问题微调。PPO关键参数就那么几个,我直接列个表说明:

参数我使用的值作用调大/调小的影响
learning_rate2.5e-4控制每次梯度更新的步长过大训练震荡;过小收敛极慢
n_steps512每次收集多少步样本后更新一次减小提高更新频率,增大提高样本利用率
batch_size256每次梯度更新用的样本数过小梯度噪声大,过大更新次数少
n_epochs10同一批样本重复训练轮数增大拟合更充分,但也更容易过拟合到当前buffer
gamma0.99奖励折扣因子越小越短视,越大会导致长期估计不稳
gae_lambda0.95GAE衰减因子靠近1更接近蒙特卡洛,靠近0更短视
ent_coef0.01熵奖励系数调大鼓励探索,过大会导致动作过于随机
clip_range0.1PPO的ε太大策略更新激进,太小更新保守

这些参数不是孤立的。比如你把n_steps调小,相当于更频繁地更新策略,这时候learning_rate必须相应调小,否则就会更新过头。我建议你先把表格里的值固定住,跑通一遍流程之后,再每次只动一个参数观察效果。一次动两个以上,出了问题根本不知道是谁引起的。

4. 手写奖励塑形:从稀疏奖励到有效进步

4.1 奖励塑形原则:教AI“前进”,但别教它“作弊”

马里奥环境的原始奖励有三个来源:吃到金币加1分、踩到敌人加100分、到达旗杆加1000分。但初期agent完全找不到这些目标,绝大多数时间步的reward是0,梯度信号形同虚设。奖励塑形就是在这个稀疏信号上叠加“过程奖励”,给AI一个更密集的学习梯度。

最常见的做法是给x_pos位移加正向奖励——马里奥向右走了就加分,原地不动就没有奖励。这个思路本身没错,但有个巨大的坑:如果只按位移给奖励,AI会发现在屏幕边缘不停撞击边缘也能得到位移奖励?不,其实不会,x_pos代表的是游戏世界坐标,撞到边缘不会增长。

但另一个作弊我倒是亲眼见过:AI学会了在一个台阶前面反复跳跃,因为跳跃动作本身会让x_pos微微前移,它卡在某个障碍前不停跳,每次都能获得一点位移奖励,但永远过不了障碍。这就是典型的“奖励黑客”行为——它学会的不是通关,而是最大化奖励函数,不管这个动作是否真的通向终点。

所以奖励塑形要遵守三条铁律:一,过程奖励必须指向任务的真实目标;二,必须有惩罚机制抑制无意义刷分;三,最终奖励(到达终点)的权重必须足够大,让agent知道什么才是最终目的。

4.2 CustomRewardWrapper完整实现与解释

我写的奖励包装类如下:

import gym from gym import Wrapper class CustomRewardWrapper(Wrapper): def __init__(self, env): super().__init__(env) self._last_x = 0 self._last_time = 0 self._current_x = 0 self._current_time = 0 def reset(self, **kwargs): obs = self.env.reset(**kwargs) self._last_x = 0 self._last_time = 0 self._current_x = 0 self._current_time = 0 return obs def step(self, action): obs, reward, done, info = self.env.step(action) self._current_x = info.get('x_pos', 0) self._current_time = info.get('time', 0) # 位移奖励:鼓励角色向右前进 dx = self._current_x - self._last_x reward += dx / 40.0 # 时间惩罚:防止原地逗留或恶意磨时间 if self._current_time < self._last_time: dt = self._last_time - self._current_time reward -= 0.05 * dt # 死亡惩罚:避免不断送命换位移奖励 if done and not info.get('flag_get', False): reward -= 15.0 # 通关奖励:给足正向反馈 if info.get('flag_get', False): reward += 1000.0 self._last_x = self._current_x self._last_time = self._current_time return obs, reward, done, info

逐行解释一下设计意图。dx / 40.0的意思是每前进40个像素给1分。马里奥正常跑步速度大概每秒200像素以上,所以dx奖励不会太稀疏也不会太密集。时间惩罚0.05乘上时间差,是为了让AI不要在某个障碍物前反复横跳刷位移奖励,因为每耗一秒钟都在扣分。死亡惩罚-15分也很关键——如果死亡代价太低,agent可能会为了吃一个金币故意送命重置位置。

这个wrapper必须包在JoypadSpace之后、图像预处理之前,因为它读取的是info字典里的原始游戏状态,而不是处理后的画面。reset里必须重置所有内部变量,否则第一个episode的位移计算会带入上一局的数值,奖励直接乱掉。

4.3 奖励调试:如何判断塑形是否成功

奖励塑形写得好不好,光看训练曲线不够,建议同时盯着两个指标:平均reward和平均episode长度。如果你的奖励设计方向是对的,前期reward会稳步上升,同时episode长度会先涨后降——先涨是因为agent学会了移动和跳跃,能活得更久;后降是因为它学会了高效冲到终点,不需要绕路。

我还习惯在训练的时候额外打印info里的x_pos和flag_get。具体做法是在回调里读取环境info,虽然SB3默认不把info传给回调,但你可以在env.method或自定义callback中从局部变量里取。一个更野但也更有效的方法是:每隔1000步用当前模型跑一个episode,记录最终x_pos和是否冲过旗杆,然后把这两项连同训练loss一起打到日志里。x_pos比reward可靠得多,因为reward是被塑形过的,可能虚高。

如果发现x_pos卡在一个特定值不动,比如一直卡在第一个台阶附近,说明agent根本没有学到跳跃。这时候不要急着调PPO超参,先检查动作空间里有没有“右跳”这个组合,或者把位移奖励系数调大一点,让“前进”这件事本身更有吸引力。

5. 完整训练代码与训练过程解读

5.1 完整可运行代码:从环境到模型全链路

下面就是可以直接跑的完整训练脚本。我把它整合在一起,方便你对照着理解每一部分的作用。

import gym import torch as th import torch.nn as nn import numpy as np from nes_py.wrappers import JoypadSpace import gym_super_mario_bros from gym_super_mario_bros.actions import SIMPLE_MOVEMENT from gym.wrappers import GrayScaleObservation, ResizeObservation, FrameStack from stable_baselines3 import PPO from stable_baselines3.common.vec_env import SubprocVecEnv, VecFrameStack, VecTransposeImage from stable_baselines3.common.callbacks import EvalCallback from stable_baselines3.common.torch_layers import BaseFeaturesExtractor from stable_baselines3.common.evaluation import evaluate_policy # ---------- 1. 自定义奖励 ---------- class CustomRewardWrapper(gym.Wrapper): def __init__(self, env): super().__init__(env) self._last_x = 0 self._last_time = 0 self._current_x = 0 self._current_time = 0 def reset(self, **kwargs): obs = self.env.reset(**kwargs) self._last_x = 0 self._last_time = 0 self._current_x = 0 self._current_time = 0 return obs def step(self, action): obs, reward, done, info = self.env.step(action) self._current_x = info.get('x_pos', 0) self._current_time = info.get('time', 0) dx = self._current_x - self._last_x reward += dx / 40.0 if self._current_time < self._last_time: dt = self._last_time - self._current_time reward -= 0.05 * dt if done and not info.get('flag_get', False): reward -= 15.0 if info.get('flag_get', False): reward += 1000.0 self._last_x = self._current_x self._last_time = self._current_time return obs, reward, done, info # ---------- 2. 环境创建 ---------- def make_env(): env = gym_super_mario_bros.make('SuperMarioBros-1-1-v0') env = JoypadSpace(env, SIMPLE_MOVEMENT) env = CustomRewardWrapper(env) env = GrayScaleObservation(env, keep_dim=False) env = ResizeObservation(env, 84) return env def create_vec_env(n_envs=8): envs = [make_env for _ in range(n_envs)] env = SubprocVecEnv(envs) env = VecFrameStack(env, 4, channels_order='last') env = VecTransposeImage(env) return env # ---------- 3. 自定义CNN特征提取器 ---------- class MarioCNN(BaseFeaturesExtractor): def __init__(self, observation_space, features_dim=512): super().__init__(observation_space, features_dim) n_input_channels = observation_space.shape[0] self.cnn = nn.Sequential( nn.Conv2d(n_input_channels, 32, kernel_size=3, stride=1, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(128, 256, kernel_size=3, stride=1, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Flatten(), ) with th.no_grad(): sample = th.as_tensor(observation_space.sample()).float() sample = sample.permute(2, 0, 1).unsqueeze(0) n_flatten = self.cnn(sample).shape[1] self.linear = nn.Sequential( nn.Linear(n_flatten, features_dim), nn.ReLU() ) def forward(self, observations): return self.linear(self.cnn(observations)) # ---------- 4. PPO模型初始化与训练 ---------- if __name__ == '__main__': env = create_vec_env(n_envs=8) policy_kwargs = dict( features_extractor_class=MarioCNN, features_extractor_kwargs=dict(features_dim=512) ) model = PPO( "CnnPolicy", env, policy_kwargs=policy_kwargs, learning_rate=2.5e-4, n_steps=512, batch_size=256, n_epochs=10, gamma=0.99, gae_lambda=0.95, clip_range=0.1, ent_coef=0.01, verbose=1, ) eval_env = make_env() eval_callback = EvalCallback( eval_env, best_model_save_path="./logs/best_model", log_path="./logs/results", eval_freq=2000, n_eval_episodes=5, deterministic=True, ) model.learn(total_timesteps=1_000_000, callback=eval_callback) model.save("mario_ppo_final") env.close()

这段代码看似很长,实际拆解下来只有四层。第一层是环境封装,包含奖励塑形和图像预处理;第二层是并行环境处理器,用于加速采样;第三层是CNN特征提取器,负责把84x84的画面压缩成512维向量;第四层才是PPO配置和训练循环。

5.2 训练主循环与Callback设计:为什么要这样组织

SB3的model.learn内部已经封装好了完整的PPO采样、优势估计、策略更新流程,你不需要手写梯度更新。但这不意味着你不需要懂流程——什么时候采样、什么时候更新,直接决定了两个参数n_steps和batch_size的关系。

n_steps=512的意思是每个并行子环境每轮收集512步,8个环境合起来就是4096步的样本。这4096步会被打包成经验buffer,然后PPO在这个buffer上做batch_size=256的mini-batch梯度更新,一共做n_epochs=10轮。这样算下来每轮训练实际有10 * (4096/256) = 160次梯度更新,这个比例对1-1这种规模的关卡是够用的。

EvalCallback的作用是在训练过程中定期用确定性策略去跑一遍评估环境,记录平均reward和episode长度。它不会参与训练,只是用来“考试”。我把eval_freq设成2000,也就是每训练2000步就评估一次,虽然评估会消耗少量时间,但能实时看到模型有没有退步。best_model_save_path会自动保存表现最好的那一版模型,这个比最后保存的模型更适合测试,因为训练后期模型可能过拟合或者震荡。

5.3 我的实测训练曲线与预计耗时

我用的是一张RTX 3080,运行上面这个配置,8个并行环境跑满,GPU利用率大概在60%到80%之间。训练100万步大约需要2.5小时。如果你只有CPU,用2个并行环境也能跑,但时间会膨胀到十几个小时,而且效果大概率不如GPU,因为PPO的卷积网络在CPU上采样和更新都慢。

训练开始后大致会经历三个阶段。第一阶段是前5万步,reward忽高忽低,episode长度在100步左右徘徊,AI的行为基本是乱跳,偶尔能跳过第一个台阶。第二阶段是5万到30万步,reward开始平稳上升,episode长度缓慢增加,AI学会了连续跳跃和躲避部分敌人。第三阶段是30万到100万步,模型开始出现“目标感”,它不再原地徘徊,而是朝右推进,最终在某个评估点第一次触发flag_get。

如果你是第一次跑,看到第一阶段那种“学了跟没学一样”的表现不要慌,这是正常的。只要平均reward在朝上走,哪怕走得很慢,都说明梯度信号是通的,给它时间就好。

6. 训练之后:模型保存、加载与效果验证

6.1 用evaluate_policy检查“真水平”

训练结束之后,很多人直接拿保存的模型跑一次游戏,看到通关就高兴,看到失败就觉得训练失败了。但一次游戏结果受随机性影响太大,正确做法是用evaluate_policy跑多个episode求统计指标。

from stable_baselines3.common.evaluation import evaluate_policy model = PPO.load("mario_ppo_final", env=make_env()) mean_reward, std_reward = evaluate_policy( model, make_env(), n_eval_episodes=20, deterministic=True, ) print(f"Mean reward: {mean_reward:.2f} ± {std_reward:.2f}")

这里注意两个细节。第一,deterministic=True表示评估时用策略网络输出的概率最大值对应的动作,而不是从动作分布里采样。这样排除了随机性,反映的是模型本身的水平。第二,n_eval_episodes至少设成20,因为马里奥关卡里有敌人随机移动,偶尔会因为运气不好踩到敌人暴毙,多跑几局才能看到真实水平。

除了平均reward,我还会额外写一个小脚本来统计通关率:跑20局,计算flag_get=True的局数占比。在1-1关卡上,一个训练充分的模型通关率应该在60%以上,如果通关率不到20%,说明模型只是学到了“跑酷”,还没有学会应对水管和敌人的组合场景。

6.2 让AI肉眼可见地跑起来:录制AI通关视频

训练曲线再好看,都没有亲眼看到AI自己跳过水管、踩扁敌人来得直观。录制视频有两种方式,一种是用gym自带的Monitor wrapper保存成mp4,一种是用SB3的evaluate_policy结合render手动截图生成视频。

最省事的是gym的Monitor:

from gym.wrappers import Monitor import gym env = gym_super_mario_bros.make('SuperMarioBros-1-1-v0') env = JoypadSpace(env, SIMPLE_MOVEMENT) env = CustomRewardWrapper(env) env = GrayScaleObservation(env, keep_dim=False) env = ResizeObservation(env, 84) env = Monitor(env, "./video/", force=True, video_callable=lambda episode_id: True) model = PPO.load("mario_ppo_final") obs = env.reset() done = False total_reward = 0 while not done: action, _ = model.predict(obs, deterministic=True) obs, reward, done, info = env.step(action) total_reward += reward print(f"Episode total reward: {total_reward}") env.close()

Monitor会在./video/目录下生成带时间戳的mp4文件。注意它记录的画面是经过灰度化和缩放后的预处理图像,不是原始彩色画面,所以视频看起来是黑白的84x84小窗口。如果你想要原始彩色画面,就把Monitor包在make_env最早的位置——也就是游戏原始env上——但这会同时记录所有wrapper的输出,视频体积会大很多。

6.3 常见问题排查表:训练和评估中我踩过的坑

最后整理一份问题排查表,这些都是我实际遇到过、并且花了不少时间才定位到原因的问题:

现象可能原因解决方案
训练一两万步后reward完全不变奖励塑形代码没生效,reward始终是原始奖励检查wrapper是否正确包在图像预处理之前,打印一下step返回值确认
loss一直在降但episode长度不增策略坍缩到重复动作,可能ent_coef太小调大ent_coef到0.02或0.05,增加探索
AI卡在台阶前反复跳跃位移奖励系数太小,跳跃刷分比前进更划算调大dx奖励系数,同时提高时间惩罚
训练一段时间后崩溃报错NES模拟器在子进程环境下不稳定减少并行环境数量,或者改用DummyVecEnv
通关率低但reward很高奖励塑形目标和通关目标偏离增加通关奖励的权重,比如加到2000分
在Windows下SubprocVecEnv报错Windows缺少fork机制把训练代码放进if __name__ == '__main__',或使用DummyVecEnv

这里特别强调一下最后一个问题。Windows下SubprocVecEnv要求训练逻辑必须在if __name__ == '__main__'保护块里,否则多进程会无限递归创建子进程。我在代码里已经加了这个保护,但如果你是在Jupyter Notebook里跑,Notebook本身没有__main__保护,SubprocVecEnv会直接报错。解决方案是改用DummyVecEnv,虽然采样速度慢一些,但在Notebook里能稳定运行。

另外,NES模拟器本身有一个老毛病:在子进程环境中偶发sigsegv崩溃。如果你的训练进程跑着跑着突然消失,没有报错也没有日志,大概率是nes-py模拟器崩了。这种情况我建议把并行环境数从8降到4,或者给每个子进程加一点随机延迟,能显著降低崩溃概率。

最后再分享一个小经验。PPO这个算法本身很“皮实”,绝大多数训练失败不是算法坏了,而是环境预处理和奖励设计环节埋了雷。我第一次跑的时候,因为忘了在奖励里加时间惩罚,AI学会了在一个台阶前面反复跳跃刷位移奖励,x_pos纹丝不动但reward疯狂上涨,训练日志看起来一片大红,实际上模型完全没学会过关。所以当你怀疑PPO时,先检查你的奖励函数——它学到的往往不是你想教它的任务,而是你奖励函数里真正编码的那个任务。能把1-1关跑通关只是个开始,真正的大头是后面那些需要组合跳跃、顶砖块、连续踩敌人的复合关卡,这些留给感兴趣的人继续折腾吧。

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

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

立即咨询