十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PPO强化学习实战:从零训练AI通关超级马里奥完整指南

PPO强化学习实战:从零训练AI通关超级马里奥完整指南 先回答一个很多人问过我的问题为什么一提到强化学习实战大家总是拿超级马里奥开刀因为这个游戏是一个绝佳的“最小可行问题”——画面是像素矩阵动作空间小到一只手数得过来但你要处理的却是强化学习里最真实的两个痛点只能看到当前屏幕带来的部分可观测环境、以及绝大多数时刻没有任何奖励的稀疏奖励。换句话说你不必造一个复杂的仿真环境就能把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不是也能做这种连续控制任务吗为什么要选PPODQN在高维离散动作空间里当然也能跑但它依赖经验回放和目标网络对超参数极其敏感。训练过程中稍微改动一下学习率或者网络结构差了一层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-bros7.4.0 pip install stable-baselines32.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_dimFalse) env ResizeObservation(env, 84) env FrameStack(env, 4)这里每一步都是有明确目的的。GrayScaleObservation的keep_dimFalse表示把(H, W, 1)压成(H, W)否则后面ResizeObservation的输入维度对不上。ResizeObservation是一个很轻量的wrapper内部用OpenCV的插值算法做缩放。最后的FrameStack返回的是一个LazyFrames对象它不会真的复制四份图像而是在需要取值时才拼接所以内存开销很小。经过这一串操作环境的观测空间从原来的(224, 256, 3)变成了(84, 84, 4)。第四个维度是堆帧数而不是通道数这一点在自定义CNN时要格外注意。3. PPO的核心公式与代码里的关键参数3.1 从策略梯度到clipPPO到底在优化什么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_{t1}) - V(s_t) 来近似。但一步TD的缺点是偏差大的时候会丢掉远期信息。GAEGeneralized Advantage Estimation把多步TD做了一个指数加权平均A_t^{GAE} Σ_{l0}^{∞} (γλ)^l δ_{tl}这里的λ是一个权衡参数。λ0时GAE退化成一步TD方差小但偏差大——它只相信眼前这一步的奖励λ1时退化成蒙特卡洛偏差小但方差大——它要看完整条轨迹的总回报。PPO在游戏任务里一般取λ0.95既保留了一定远期信息又不至于让方差爆炸。这也是为什么你在代码里会看到gae_lambda0.95这个参数。它和γ的区别是γ是奖励折扣因子解决的是“未来的奖励值不值得信”的问题λ解决的是“多少步的差分信息应该被纳入优势估计”的问题。两者协同共同决定agent看待决策影响的时间尺度。3.3 超参数是如何定出来的从默认值开始微调很多新手一上来就疯狂调超参结果越调越差。我的经验是先跑一组SB3官方默认值确认环境没问题、梯度有在更新再针对具体问题微调。PPO关键参数就那么几个我直接列个表说明参数我使用的值作用调大/调小的影响learning_rate2.5e-4控制每次梯度更新的步长过大训练震荡过小收敛极慢n_steps512每次收集多少步样本后更新一次减小提高更新频率增大提高样本利用率batch_size256每次梯度更新用的样本数过小梯度噪声大过大更新次数少n_epochs10同一批样本重复训练轮数增大拟合更充分但也更容易过拟合到当前buffergamma0.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_dimFalse) env ResizeObservation(env, 84) return env def create_vec_env(n_envs8): envs [make_env for _ in range(n_envs)] env SubprocVecEnv(envs) env VecFrameStack(env, 4, channels_orderlast) env VecTransposeImage(env) return env # ---------- 3. 自定义CNN特征提取器 ---------- class MarioCNN(BaseFeaturesExtractor): def __init__(self, observation_space, features_dim512): super().__init__(observation_space, features_dim) n_input_channels observation_space.shape[0] self.cnn nn.Sequential( nn.Conv2d(n_input_channels, 32, kernel_size3, stride1, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, stride1, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size3, stride1, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(128, 256, kernel_size3, stride1, padding1), 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_envs8) policy_kwargs dict( features_extractor_classMarioCNN, features_extractor_kwargsdict(features_dim512) ) model PPO( CnnPolicy, env, policy_kwargspolicy_kwargs, learning_rate2.5e-4, n_steps512, batch_size256, n_epochs10, gamma0.99, gae_lambda0.95, clip_range0.1, ent_coef0.01, verbose1, ) eval_env make_env() eval_callback EvalCallback( eval_env, best_model_save_path./logs/best_model, log_path./logs/results, eval_freq2000, n_eval_episodes5, deterministicTrue, ) model.learn(total_timesteps1_000_000, callbackeval_callback) model.save(mario_ppo_final) env.close()这段代码看似很长实际拆解下来只有四层。第一层是环境封装包含奖励塑形和图像预处理第二层是并行环境处理器用于加速采样第三层是CNN特征提取器负责把84x84的画面压缩成512维向量第四层才是PPO配置和训练循环。5.2 训练主循环与Callback设计为什么要这样组织SB3的model.learn内部已经封装好了完整的PPO采样、优势估计、策略更新流程你不需要手写梯度更新。但这不意味着你不需要懂流程——什么时候采样、什么时候更新直接决定了两个参数n_steps和batch_size的关系。n_steps512的意思是每个并行子环境每轮收集512步8个环境合起来就是4096步的样本。这4096步会被打包成经验buffer然后PPO在这个buffer上做batch_size256的mini-batch梯度更新一共做n_epochs10轮。这样算下来每轮训练实际有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, envmake_env()) mean_reward, std_reward evaluate_policy( model, make_env(), n_eval_episodes20, deterministicTrue, ) print(fMean reward: {mean_reward:.2f} ± {std_reward:.2f})这里注意两个细节。第一deterministicTrue表示评估时用策略网络输出的概率最大值对应的动作而不是从动作分布里采样。这样排除了随机性反映的是模型本身的水平。第二n_eval_episodes至少设成20因为马里奥关卡里有敌人随机移动偶尔会因为运气不好踩到敌人暴毙多跑几局才能看到真实水平。除了平均reward我还会额外写一个小脚本来统计通关率跑20局计算flag_getTrue的局数占比。在1-1关卡上一个训练充分的模型通关率应该在60%以上如果通关率不到20%说明模型只是学到了“跑酷”还没有学会应对水管和敌人的组合场景。6.2 让AI肉眼可见地跑起来录制AI通关视频训练曲线再好看都没有亲眼看到AI自己跳过水管、踩扁敌人来得直观。录制视频有两种方式一种是用gym自带的Monitor wrapper保存成mp4一种是用SB3的evaluate_policy结合render手动截图生成视频。最省事的是gym的Monitorfrom 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_dimFalse) env ResizeObservation(env, 84) env Monitor(env, ./video/, forceTrue, video_callablelambda episode_id: True) model PPO.load(mario_ppo_final) obs env.reset() done False total_reward 0 while not done: action, _ model.predict(obs, deterministicTrue) obs, reward, done, info env.step(action) total_reward reward print(fEpisode 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关跑通关只是个开始真正的大头是后面那些需要组合跳跃、顶砖块、连续踩敌人的复合关卡这些留给感兴趣的人继续折腾吧。
返回列表