基于Stable-Baseline3与Gym的多进程强化学习马里奥训练实战 简介基于Stable-Baseline3框架和Gym环境构建的超级马里奥强化学习项目面向希望掌握强化学习落地流程的人工智能开发者和研究者。项目使用多进程并行训练以加速样本采集对游戏画面进行跳帧、灰度化和降维预处理以PPO策略网络为核心完成参数调优与系统化评估成功实现智能体自主闯关。压缩包内文件共十七个大小约二十七MB覆盖六个Python源码文件、两个模型包、两个运行缓存、一个评估数据、两个说明文档和附赠材料既有训练与测试脚本也有训练日志、最优模型权重和说明文档便于读者按图索骥复现实验、分析奖励曲线或进行二次开发。目前已有一百零八人学习浏览资源整体规模适中结构清晰适合具备一定深度学习和Python基础的中高级开发者作为游戏智能体入门或进阶参考。1. 用 Stable-Baseline3 与 Gym 搭多进程马里奥训练先分清谁在采样谁在训把标题「基于 Stable-Baseline3 框架与 Gym 环境的多进程强化学习超级马里奥训练」摊开真正要解决的工程问题只有五个环境怎么接进来、跳帧灰度化降维怎么做、多进程怎么开、PPO 参数怎么调、模型怎么评估。Stable-Baseline3SB3负责 PPO 算法实现和向量化环境管理Gym 只提供标准环境协议超级马里奥通过gym_super_mario_bros暴露成普通 RL 环境。新手最容易混的点是「多进程」的边界SB3 的SubprocVecEnv会把 N 个环境分到 N 个子进程每个进程独立跑仿真、独立做预处理主进程只收观测、奖励和 done 信号。多进程不是可选项它直接决定 PPO 一个 batch 内状态是否多样也决定训练是卡在 CPU 仿真还是 GPU 推理上。下文按「环境处理 → 多进程架构 → PPO 调参 → 评估机制 → 检查点与 Crash 排查」这条链路展开所有代码都按可以复制改用的程度来写。2. 环境预处理链路跳帧、灰度化、降维的 Gym 包装器实现2.1 为什么标题把跳帧灰度化降维绑在一起观测空间决定训练能不能收敛马里奥的原始观测是 240×256 的 RGB 图像直接交给 CNN 策略网络参数量和计算量都会明显偏大训练步数少了根本学不动。游戏对颜色没有强依赖纹理信息管道、砖块、金币在灰度图里完整保留RGB 三个通道压成单通道后输入规模直接除以三再经过缩放从 240×256×3 降到 84×84×1单次策略前向的总数据量少了约 94%。这就是标题里「降维」的两个层次通道降维和空间降维。跳帧解决的则是另一类问题。马里奥一秒钟跑 60 帧相邻帧之间差异极小如果每一帧都做策略推理模型要处理大量近似重复的输入训练耗时翻好几倍而且策略容易被快速变化的背景带偏。常见做法是每 4 帧只做一次动作决策其余帧复用上一次动作同时把跳过的中间帧里像素最大值合入当前观测这样马里奥快速移动时小体积的敌人和金币不会在低帧率下「消失」。注意跳帧不是简单丢弃帧动作必须对跳过的每一帧重复执行否则游戏物理行为会和单帧模式完全不同。典型现象是马里奥跳不起来或速度感明显异常。2.2 三个可照抄的包装器灰度化、缩放、以及动作与奖励的约束先给出一版可直接粘贴的包装器代码。它把环境从 240×256×3 的 RGB 观测压到 84×84 灰度图同时把回报做一次线性缩放避免 PPO 的价值网络在量级很大的分数差上偏置过大。import gym import cv2 import numpy as np from gym.spaces import Box class GrayScaleObservation(gym.ObservationWrapper): def __init__(self, env): super().__init__(env) h, w self.observation_space.shape[:2] self.observation_space Box( low0, high255, shape(h, w), dtypenp.uint8 ) def observation(self, obs): return cv2.cvtColor(obs, cv2.COLOR_RGB2GRAY) class ResizeObservation(gym.ObservationWrapper): def __init__(self, env, height84, width84): super().__init__(env) self.h, self.w height, width self.observation_space Box( low0, high255, shape(self.h, self.w), dtypenp.uint8 ) def observation(self, obs): return cv2.resize(obs, (self.w, self.h), interpolationcv2.INTER_AREA)ObservationWrapper是 Gym 里的标准接口要求子类只覆写observation方法环境内部执行step时返回的观测会被自动重映射。这里GrayScaleObservation把通道维直接删掉observation_space也从(240, 256, 3)改成(240, 256)SB3 会在创建策略网络时读取这份声明如果只改了数据没改observation_space会直接报 shape mismatch。缩放用cv2.INTER_AREA比INTER_LINEAR更适合游戏画面因为像素级图片里的小物件乌龟、炮弹用双线性插值容易融进背景区域插值保留边缘更干净。2.3 动作空间选择与奖励缩放稳定训练的实际边界条件马里奥的环境包一般提供两套动作空间完整组合动作Discrete(256)按手柄按钮位拆解和预置的简化动作表。标题里的项目多半用的是简化表比如只保留「向右走、向右跑、向右跳、向右跑跳、向左走、下蹲」这类 M 键位组合共 7 个动作。理由很直接256 个动作的表里绝大多数组合对过关无用RL 初期探索会在这些无效动作上浪费大量步数。from stable_baselines3.common.vec_env import VecFrameStack def make_mario_env(): from gym_super_mario_bros import SuperMarioBrosEnv from gym_super_mario_bros.actions import SIMPLE_MOVEMENT env SuperMarioBrosEnv( render_modecpu, # 训练时关掉画面渲染 apply_actionsNone ) env GrayScaleObservation(env) env ResizeObservation(env, height84, width84) env gym.wrappers.FrameStack(env, num_stack4) env gym.wrappers.TransformReward(env, lambda r: r / 15.0) return env这里gym.wrappers.FrameStack把连续 4 帧堆叠成一个(4, 84, 84)的张量策略网络看到的不是单张静态图而是一小段运动轨迹CNN 才能通过帧间差分判断马里奥的移动方向和速度。TransformReward把分数差缩放到原来的约 1/15避免奖励绝对值从几十到几千波动过大。要注意VecFrameStack与gym.wrappers.FrameStack的区别在下一章讲多进程时展开单环境用后者就够了。验证环境形状是否正确直接在命令行跑python -c from envs import make_mario_env e make_mario_env() print(e.observation_space, e.action_space) obs e.reset() print(obs.shape, obs.dtype) 期望输出是Box(4, 84, 84, 0~255)与Discrete(7)obs.shape是(4, 84, 84)。如果看到(1, 4, 84, 84)说明FrameStack外又多包了一层不需要的维度通常是没有区分单环境 wrapper 和 vec wrapper 导致的。3. 多进程训练架构与 PPO 策略网络参数调优3.1 四种向量化方案对比多进程不只是开个线程池SB3 里make_vec_env是统一入口vec_env_cls参数决定环境以什么方式并行。四种常见方式的差异要先弄清楚否则踩到的坑会非常隐蔽。向量化类运行模型适用场景需要注意的地方DummyVecEnv单进程串行跑 N 个环境调试、小步数验证无进程通信但采样慢加速比接近 1SubprocVecEnv每个环境一个子进程标准多进程训练Windows 下必须包if __name__ __main__DummyVecEnv包单环境单环境重复 reset检查 env 逻辑不能反映真实训练效果SubprocVecEnvVecFrameStack子进程内做帧堆叠观测空间大、需要时序信息要求每个子进程返回的观测已经带时间维SubprocVecEnv的工作机制是主进程给每个子进程发动作子进程跑一步仿真把新的观测、奖励、done 信号回传。这里的通信延迟是固定开销所以单步仿真特别快的环境比如马里奥像素版不一定能获得理想加速。如果发现 8 个进程的 CPU 占用率没有跑满先检查环境是否处于render_modehuman——渲染开了子进程大部分时间都在等画面输出训练速度会被拖到一比一。VecFrameStack和单环境版gym.wrappers.FrameStack的关键区别在于 reset 的广播行为。多进程场景下某个子进程的 episode 结束了SB3 会单独 reset 那个进程的环境而其他进程继续跑如果帧堆叠只在单环境 wrapper 里做reset 后堆叠缓存会被清空但 SB3 不知道这件事策略网络会看到一半旧帧一半新帧的观测。正确做法是子进程内部环境不做FrameStack在SubprocVecEnv外面统一包VecFrameStack。全流程结构from stable_baselines3.common.vec_env import SubprocVecEnv, VecFrameStack def make_mario_vec_env(num_envs8, stack4): env_fns [make_mario_env for _ in range(num_envs)] vec_env SubprocVecEnv(env_fns, start_methodfork) vec_env VecFrameStack(vec_env, n_stackstack) return vec_envstart_methodfork是 Linux 下的默认方式Windows 没有 forkSB3 会自动退回spawn。用spawn时每个子进程会重新导入主模块所以训练脚本必须放在if __name__ __main__:保护块内否则子进程会重复执行训练入口直接死循环。3.2 PPO 参数调优学习率、batch_size、gamma 与 GAE 的取舍标题里直接写了「PPO 策略网络参数调优」这部分要按马里奥任务的特征重设而不是沿用 SB3 默认值。默认参数是为 MuJoCo 连续控制调过的对像素级离散动作游戏不算最优。几个关键参数的推荐起点from stable_baselines3 import PPO from stable_baselines3.common.callbacks import CheckpointCallback, EvalCallback vec_env make_mario_vec_env(num_envs8, stack4) model PPO( policyCnnPolicy, envvec_env, learning_rate2.5e-4, n_steps2048, batch_size512, n_epochs10, gamma0.99, gae_lambda0.95, clip_range0.2, ent_coef0.01, vf_coef0.5, max_grad_norm0.5, seed42, verbose1, )逐参数说明取值理由learning_rate2.5e-4SB3 默认值对离散动作任务偏高容易让策略策略分布震荡。从 3e-4 开始如果训练进度的奖励曲线反复横跳可以降到 1e-4。n_steps2048配 8 个环境一轮 rollout 是 16384 条 transitionbatch_size512则一次梯度更新用 512 条正好满足batch_size整除n_steps * num_envs的约束。SB3 内部按整批处理不整除会多出一部分被丢弃造成样本浪费。gamma0.99对马里奥是合理选择。关卡从头到尾约 3200 帧0.99 的折扣可以让远期奖励在 300 帧内仍然有可观权重想强调「吃到金币」这类即时奖励调到 0.95 会让策略更短视。gae_lambda0.95控制优势估计的偏差方差折中。lambda调高到 0.98价值网络会更关注长时间尺度的回报适合「走过一整段危险地形」这类稀疏信号但收敛会变慢。ent_coef0.01熵系数对抗策略过早锁死。马里奥初期最大的问题是模型发现向右跑能加一点分就再也不跳了ent_coef加到 0.02 能在前 200 万步里维持有效的探索后期再逐渐调低。clip_range0.2PPO 的目标函数裁剪范围。马里奥的奖励噪声比连续控制任务大裁剪越小越能防止单批数据把策略推太远建议保持 0.2 不动不要为了加速改成 0.3。3.3 缩小 CNN 特征维度降维的第二个战场图像缩放是降维的前半段后半段在策略网络内部。SB3 自带 CnnPolicy 的特征提取器把 4×84×84 的观测压成 512 维向量但对马里奥这种小观测空间512 维明显过大不仅参数多还会让后续全连接层难以在有限步数内收敛。自定义特征提取器时把features_dim改到 128import torch.nn as nn from stable_baselines3.common.torch_layers import BaseFeaturesExtractor class MarioCNN(BaseFeaturesExtractor): def __init__(self, observation_space, features_dim128): super().__init__(observation_space, features_dim) self.cnn nn.Sequential( nn.Conv2d(4, 32, kernel_size8, stride4), nn.ReLU(), nn.Conv2d(32, 64, kernel_size4, stride2), nn.ReLU(), nn.Conv2d(64, 64, kernel_size3, stride1), nn.ReLU(), nn.Flatten(), ) with torch.no_grad(): sample torch.zeros( 1, *observation_space.shape, dtypetorch.float32 ) n_flatten self.cnn(sample).shape[1] self.linear nn.Linear(n_flatten, features_dim) def forward(self, observations): return self.linear(self.cnn(observations.float() / 255.0))BaseFeaturesExtractor是 SB3 留给外部替换特征提取器的接口。注意forward里做了float() / 255.0把像素值归一化到 0~1这一行容易被漏掉漏掉后 CNN 的输入范围是 0~255BatchNorm 类结构会变得不稳定。卷积核和步长是根据 84×84 输入试过的配置三层卷积之后空间尺寸依次减半到 5×5 左右扁平化后接一个线性层降维到 128而不是用全局池化直接压掉空间信息保留了特征位置对动作决策的影响。构造 PPO 时要通过policy_kwargs把自定义网络传进去并且把策略与价值网络的全连接层单独指定policy_kwargs dict( features_extractor_classMarioCNN, features_extractor_kwargsdict(features_dim128), net_archdict(pi[128, 64], vf[256, 128]), ) model PPO(CnnPolicy, vec_env, policy_kwargspolicy_kwargs)net_arch里pi是策略网络vf是价值网络这里故意让价值网络比策略网络宽一些。原因是价值函数需要拟合的是有噪声的回报分布容量不足会导致优势估计偏差策略网络输出的是 7 个动作的概率128→64 已经足够表达该有的决策形态。3.4 训练主循环保存时机与评估回调的挂载训练循环本身只有一行model.learn()但保存和评估必须提前挂到Callback里。经验是每 50 万步存一个全量检查点同时挂EvalCallback做边训边验证checkpoint CheckpointCallback( save_freq500_000, save_path./models/mario/, name_prefixmario_ppo, ) model.learn(total_timesteps10_000_000, callbackcheckpoint) model.save(./models/mario_ppo_final)训练中途如果想改参数继续跑务必先del vec_env重新创建环境否则旧的子进程持有的环境句柄和新的观测空间不匹配会在第一次 reset 时抛出Observation space mismatch。这类错误在多进程训练里最隐蔽报错信息完全看不出是环境实例的问题。4. 模型评估机制从 episode reward 到关卡进度验证4.1 评估指标选型reward 很容易骗人x 坐标和通关标志更可信标题里的「模型评估机制」如果只是打印一段平均奖励基本等于没评。马里奥环境里基于分数的 reward 和「闯关进度」并不线性相关站在原地反复顶金币也能涨分但马里奥一寸没向前。所以评估要把指标拆成三组指标类型具体指标数据来源说明RL 常规指标episode reward、episode length训练日志用来横向对比不同超参数游戏进度指标单个 episode 内 x 坐标最大值、是否到达终点info[x_pos]、info[flag_get]真正反映「自主闯关」的程度稳定性指标连续多帧 x 坐标无变化次数自定义计数器识别模型卡死在某个角落的坏习惯info字典的键名在不同版本里不太一样有的版本是x_pos有的版本是pos子进程返回时还会多一层嵌套。正式写评估代码前先随便跑一个 step 把info打印出来确认这是排查时间最少的步骤。4.2 用 EvalCallback 做滚动评估评估环境要与训练环境严格分离SB3 内置的EvalCallback可以直接用但默认的eval_episodes5对马里奥来说样本太少。马里奥从开局到第一次死亡可能只有几百帧短 episode 的方差极大一个模型前一秒看起来很会玩下一秒可能出门就被踩。推荐把评估步数放大同时固定随机种子把评估期间的动作采样关掉from stable_baselines3.common.vec_env import DummyVecEnv eval_env VecFrameStack( DummyVecEnv([make_mario_env]), n_stack4, ) eval_cb EvalCallback( eval_env, best_model_save_path./models/best/, log_path./logs/eval/, eval_freq100_000, n_eval_episodes10, deterministicTrue, renderFalse, ) model.learn(total_timesteps5_000_000, callback[checkpoint, eval_cb])这段代码里评估环境用的是DummyVecEnv而不是SubprocVecEnv原因是评估不需要并行采样单进程逐个跑 10 个 episode 足够而且省掉了子进程通信的变量。deterministicTrue表示评估时用策略分布的最大概率动作而不是随机采样这样比出来的奖励波动更小不同时间点模型的分数高低才有可比性。4.3 自定义评估循环逐关验证并记录到文件如果要验证「能否自主闯关」只靠EvalCallback的平均分不够要单独写评估函数把每个 episode 的最大 x 坐标、是否撞到旗杆、卡死帧数都记下来import json, time def evaluate_mario(model, env, episodes10, max_steps6000): result [] for i in range(episodes): obs env.reset() done False total_reward, max_x, stuck_steps 0.0, 0, 0 prev_x 0.0 step 0 while not done and step max_steps: action, _ model.predict(obs, deterministicTrue) obs, reward, done, info env.step(action) total_reward float(reward) x_pos info[0].get(x_pos, info[0].get(pos, 0)) if x_pos is None: x_pos 0 max_x max(max_x, float(x_pos)) if abs(float(x_pos) - prev_x) 0.3: stuck_steps 1 prev_x float(x_pos) step 1 result.append({ episode: i, reward: round(total_reward, 2), max_x: round(max_x, 1), stuck_steps: stuck_steps, flag_get: bool(info[0].get(flag_get, False)), }) return result # 评估并保存结果 model PPO.load(models/best/best_model.zip) env VecFrameStack(DummyVecEnv([make_mario_env]), n_stack4) evaluations evaluate_mario(model, env, episodes10) with open(feval_{time.strftime(%Y%m%d_%H%M%S)}.json, w) as f: json.dump(evaluations, f, ensure_asciiFalse, indent2)info[0]的[0]是 vectorized env 的统一接口即使只有单环境SB3 也会把 info 包成列表不取下标的写法在model.predict时正常但env.step的返回值就取错了。卡死判定这里用连续帧 x 坐标差小于 0.3 作为条件是因为马里奥在跑动过程中像素级坐标会抖动阈值设太严会把正常走路的帧误判成卡死。flag_get是环境包里表示「马里奥触碰终点旗杆」的关键字段一次评估里如果flag_get出现 True说明智能体具备了完整闯过当前关卡的雏形。评估结果建议直接落盘成 JSON带时间戳训练过程中每轮评估都可以覆盖上一轮。后续迭代超参数时对比的不是best_model.zip的分数而是这些带时间戳的文件里max_x和flag_get的变化轨迹。5. 从检查点恢复、多进程 Crash 排查与评估结果使用5.1 中断训练后接着跑PPO.load的正确姿势训练到一半因为服务器重启或环境崩溃中断是常态。SB3 的检查点可以直接 load 后继续learn但环境必须重新创建vec_env make_mario_vec_env(num_envs8, stack4) model PPO.load( ./models/mario/mario_ppo_2500000_steps.zip, envvec_env, learning_rate1e-4, # 续跑时通常调低学习率 clip_range0.15, # 缩短单次更新的步子 ) model.learn(total_timesteps2_000_000)PPO.load时传入新的learning_rate会覆盖检查点里保存的旧值。续跑和从头训练的学习率策略不同已经训练了两三百万步的模型策略分布基本成形学习率调低到 1e-4clip_range同步收紧到 0.15防止几批高噪声样本把策略推离已经找到的较优区域。5.2 多进程崩溃排查顺序先环境后进程再梯度现象特征最可能原因排查手段训练启动后卡住无输出Windows 下没用if __name__ __main__把整个训练脚本包进保护块SubprocVecEnv报 BrokenPipe某个子进程内环境初始化失败先用DummyVecEnv跑 1000 步验证reset报 shape mismatchFrameStack包在子进程内改成VecFrameStack包外层个别进程 CPU 占用明显偏低render_mode没关检查环境工厂函数里的渲染参数训练 loss 一直不降多进程 rollout 与 batch_size 不等分计算n_steps * num_envs % batch_size是否为零排查时记住一个原则多进程环境的问题几乎都能用单进程环境先复现一遍。出问题时先把SubprocVecEnv换成DummyVecEnv跑到同样步数如果复现不了问题基本出在进程通信如果复现了问题在环境包装器或 PPO 参数本身。5.3 评估结果回填超参把每次实验变成可对比的记录第五节收在一个实际技巧上评估 JSON 文件不要只存评估数据把训练配置一并写进去这样每次实验都能追溯到对应超参。做法是在训练开始时就把 PPO 参数序列化config { model: PPO, policy: CnnPolicy, learning_rate: 2.5e-4, n_steps: 2048, batch_size: 512, n_epochs: 10, gamma: 0.99, gae_lambda: 0.95, ent_coef: 0.01, num_envs: 8, stack: 4, env: gym_super_mario_bros, } with open(fconfig_{time.strftime(%Y%m%d_%H%M%S)}.json, w) as f: json.dump(config, f, indent2)然后把config与评估结果合并存储。后续做参数对比时直接按max_x均值排序再打开 JSON 看是哪个超参起了作用比翻训练日志高效得多。整个训练工程到这里就闭环了环境预处理、多进程采样、PPO 调参、模型评估、检查点续跑每一条都能在崩溃时靠保存下来的中间产物快速定位。本文还有配套的精品资源点击获取