
从CartPole这类自带环境切到自己的模拟器是很多强化学习学习者真正卡壳的第一道坎。前面几篇讲了策略梯度、基础算法思路这一篇我们把重点放到动手落地上让BaseLine3使用自定义环境完成训练而且状态输入是最常见的“一维状态向量”形式。先解释一下标题里的BaseLine3它其实就是Stable-Baselines3社区通常简称SB3是一个把PPO、SAC、TD3、DQN等主流算法封装好的强化学习库提供统一的API省去了重复造算法轮子的过程。本文适合两类读者一类是已经把Gym自带环境跑通、但不知道自定义环境怎么接入SB3的人另一类是手里有业务模拟器或物理仿真模型想把强化学习接进来、但又不想从零写策略更新代码的开发者。这篇会解决三个具体问题自定义环境怎么写才符合SB3的胃口状态向量怎么组织和预处理训练效果才稳定训练过程中那些高频报错和“不收敛”状况该怎么排。后面会有完整的环境代码、训练脚本和踩坑记录你可以直接照着改。1. 整体设计与思路拆解1.1 为什么必须自己做环境Gym自带的环境虽然方便但真实项目里几乎不会正好有对应的任务。举个最直接的例子你手上有一个温控系统仿真器输入是当前温度和升温速率输出是加热功率目标是把温度稳定在某个区间。这种任务在Gym里没有现成的CartPole是“推杆保持平衡”MountainCar是“冲坡到顶”跟温控完全不是一回事。所以自定义环境是绕不开的步骤。SB3的设计思路是它不关心你的环境内部是什么逻辑只要求环境对外暴露一套标准接口。你只要把状态、动作、奖励、终止条件按照约定告诉SB3它就能用PPO、SAC这类算法在上面跑训练。这和“插件化”的思路很像算法是通用的发动机环境是你的车身接口对齐了就能组装起来。我见过不少开发者的误区总觉得环境要写得很复杂、很完整才能开始训练。其实恰恰相反第一次做自定义环境时环境逻辑越简单越好先把链路跑通再逐步加复杂度。否则环境出问题你根本分不清是算法没调好还是环境本身有bug。1.2 状态向量在训练闭环里的位置强化学习训练的闭环是“状态-动作-奖励-新状态”。状态向量就是这个闭环里喂给策略网络的输入也叫观测observation。SB3里的“输入状态向量”指的就是把环境内部的关键信息拼成一个数值数组比如[位置x位置y速度vx速度vy]这样的四维向量。这个向量决定了两件大事。第一它决定了神经网络能“看到”什么第二它决定了observation_space的定义进而影响网络的输入维度。如果状态向量漏掉了关键信息策略网络再强大也没用。比如一个二维移动任务你只给位置不给速度模型每次决策都像“摸着石头过河”很难学到平滑的控制策略。反过来你把不需要的噪音塞进状态向量模型又要花时间去识别哪些特征有用训练效率也会下降。从类型上讲Gym的观测空间有三类最常见Box连续向量、Discrete离散值、Dict字典可混合多种子空间。标题里说的“输入状态向量”基本就是Box类型的连续向量这也是实际项目里最普遍的一种形态物理仿真返回的数据大部分都是浮点数数组。1.3 算法选型先定框架再写环境环境还没写最好先想清楚用哪个算法。SB3并不是一个算法通吃所有场景选错算法后面会走很多弯路。我做了一个简单对照表方便你按自己的任务类型快速对齐。任务特点推荐算法说明连续动作高维状态SAC、TD3样本效率高适合真实仿真和机器人控制连续动作算力有限PPO稳定、好调参适合快速验证环境是否正确离散动作状态为向量DQN、PPODQN需要配合经验回放PPO实现更直接任务奖励稀疏SAC HER或自定义奖励塑形稀疏奖励下需要额外手段建议先做奖励塑形我个人在“第一次验证自定义环境”时最常用的方案是PPO。原因很简单PPO对超参数不敏感训练曲线相对平滑出问题时更容易定位到环境逻辑上。如果第一次跑就用SAC虽然样本效率高但SAC对reward scale和学习率的敏感度更高环境有bug时会和算法调参的问题搅在一起排查体验很差。等环境逻辑稳定了再换SAC去提升样本效率也不迟。2. 自定义环境实现把接口写到标准状态2.1 Gym环境接口的核心约定SB3能消费的环境本质上是遵循Gym接口的环境。新版Gym0.26以上和gymnasium库的接口基本一致环境类需要继承gym.Env并且实现reset和step两个核心方法。reset负责初始化一轮回合返回初始状态观测。在新版接口下返回的是两个值观测和info字典。step负责执行一步动作返回四个值新观测、奖励、终止标志、截断标志外加info字典。这里的重点是新版返回的是5个值老版本只有4个SB3当前版本默认兼容新版接口。如果你沿用老代码只返回4个值会直接报“too many values to unpack”一类的错。每个环境还需要在__init__里定义observation_space和action_space。这两个空间的作用有两点一是SB3用它们来自动构建神经网络输入输出层二是训练过程中SB3会用env.action_space.sample()来自动采样动作同时也会校验你step返回的形状是否匹配。空间定义错了训练时会出现各种奇怪的广播错误或维度错误。2.2 状态向量生成从业务数据到numpy数组自定义环境里最关键的一段代码就是把业务数据拼成状态向量。这里有三个硬性规范必须是numpy数组、必须是float32、形状必须和observation_space完全一致。为什么要float32因为PyTorch默认的浮点类型就是float32SB3底层使用PyTorch如果你的状态是float64输入网络前会发生类型转换虽然很多情况下不报错但会增加隐式的性能开销和潜在精度问题。直接用np.array(..., dtypenp.float32)最省心。形状一致性是另一个容易踩的坑。如果你的observation_space定义为shape(4,)但reset里返回的是[[1,2,3,4]]这种形状为(1,4)的数组SB3在收集训练数据时会报维度错误。我自己习惯在写环境时用一个内部方法专门负责“组装观测”比如这样import numpy as np from gym import spaces import gym class PointGoalEnv(gym.Env): def __init__(self): super().__init__() self.observation_space spaces.Box( low-np.inf, highnp.inf, shape(4,), dtypenp.float32 ) self.action_space spaces.Box( low-1.0, high1.0, shape(2,), dtypenp.float32 ) self.target np.array([2.0, 2.0], dtypenp.float32) self.max_steps 200 self.step_count 0 def _get_obs(self): return self.state.copy() def reset(self, *, seedNone, optionsNone): super().reset(seedseed) self.state np.array([-2.0, -2.0, 0.0, 0.0], dtypenp.float32) self.step_count 0 return self._get_obs(), {} def step(self, action): action np.clip(action, -1.0, 1.0) x, y, vx, vy self.state ax, ay action vx_new vx ax * 0.02 vy_new vy ay * 0.02 x_new x vx_new * 0.02 y_new y vy_new * 0.02 self.state np.array([x_new, y_new, vx_new, vy_new], dtypenp.float32) self.step_count 1 distance np.linalg.norm(self.state[:2] - self.target) reward -distance - 0.01 * np.dot(action, action) terminated bool(distance 0.1) truncated self.step_count self.max_steps return self.state.copy(), float(reward), terminated, truncated, {}上面这个例子是一个很典型的“点到点运动控制”任务状态向量是“位置x、位置y、速度vx、速度vy”动作是“x方向加速度、y方向加速度”。环境的目标是让机器人从起点移动到目标点奖励是“负的距离”加上“小的控制惩罚”。注意step里返回的是拷贝后的state而不是直接返回内部self.state。这个细节很有用SB3会在训练过程中缓存观测数据如果直接把内部数组暴露出去后续环境状态更新时之前缓存的数据可能被一并修改掉导致训练数据混乱。2.3 奖励设计与返回值规范奖励函数是自定义环境里的“灵魂”它直接决定模型学习的走向。上面的例子里我用了连续奖励负距离加控制惩罚。这种设计的思路是让智能体每一时刻都知道自己做得怎么样而不是非得等到回合结束才知道成败。连续奖励在密集反馈任务里收敛速度远快于稀疏奖励。奖励尺度也不能乱来。如果reward动辄是几百上千PPO的损失很容易爆炸如果reward小到1e-5级别模型又感知不到差异。经验上把单步奖励控制在[-1, 1]附近训练会稳定很多。上面的例子里目标点在2左右起步距离最大约5.6单步奖励大概在[-5.6, 0]左右这个量级可以接受不需要额外缩放。关于采样频率和步长也有讲究。上面代码把0.02这个时间步长写死了它表示每一步仿真推进的时间。如果步长太大运动轨迹会显得“跳变”模型很难学到平滑控制如果步长太小同样回合长度下智能体只能探索很短的一段轨迹训练耗时变长。先把时间步长固定成常数是新手最容易做到也最容易忽略的事。还有一个高频犯错点terminated和truncated的区别。terminated表示回合真正完结比如“到达目标”或“翻车”truncated表示回合因超出最大步数而中止。两者对SB3的意义不一样terminated会被算法识别为“任务完成”而truncated在PPO等算法中通常按超时截断处理不会让模型误以为是成功结束。如果你把超时也当terminated返回模型会学到一个错误信号把“因为没来得及完成被强制结束”理解成“这么做是对的”。2.4 环境实例化make_vec_env的使用环境类写好后不能直接丢给SB3模型。SB3的训练接口通常需要向量环境也就是把多个环境实例组合在一起同时交互。最常用的工具是make_vec_env。from stable_baselines3.common.env_util import make_vec_env env make_vec_env(lambda: PointGoalEnv(), n_envs4)这里传入的是一个返回环境实例的callable而不是环境类本身。原因很简单SB3需要为每个子环境单独创建实例如果直接传类名内部会尝试用类实例化多数情况下也能跑但遇到需要传参的环境就会失败。用lambda包一层是兼容性最好的方式。n_envs指并行环境数量。设为4意味着同时跑4个独立回合采集数据训练速度大约能提升3到4倍。但也不是越大越好环境数量太多时PPO采集一个batch等待最慢进程的时间会变长显存和CPU占用也会上升。先在n_envs4跑通再调大。3. 状态向量的预处理训练稳定的隐藏变量3.1 归一化为什么不做就会“飘”状态向量的不同维度往往代表不同物理量取值范围天差地别。比如位置在[-2, 2]速度在[-0.1, 0.1]角度在[-π, π]。如果不做归一化神经网络输入层的数值尺度差异会导致梯度更新被大数值维度主导训练过程极其不稳定。这就好比你在上一门课平时成绩满分100附加分满分1万总评几乎只看附加分其他维度全被带偏了。SB3里处理这个问题的标准工具是VecNormalize。它会在训练时动态估计状态向量的均值和方差然后做标准化。用法很简单from stable_baselines3.common.vec_env import VecNormalize env make_vec_env(lambda: PointGoalEnv(), n_envs4) env VecNormalize(env, norm_obsTrue, norm_rewardFalse, clip_obs10.0)注意我把norm_reward设成了False。为什么不归一化奖励因为这个例子里奖励已经是负距离惩罚尺度本身稳定在个位数。而VecNormalize对奖励的归一化是用“最近窗口”的均值和标准差换算的在训练初期奖励变化剧烈时反而容易引入噪声。大多数连续控制任务里先把奖励手动缩放到合理区间比依赖norm_reward更可控。训练完成后保存模型时必须同时保存VecNormalize的统计参数否则部署时直接用裸环境输入分布对不上策略效果会大幅下降。VecNormalize有一个save方法加载测试时要通过VecNormalize.load恢复。3.2 高维度不等于高质量很多人在设计状态向量时潜意识里觉得“多加几个维度信息更多模型应该学得更好”。这个想法在强化学习里往往不成立。状态维度越高策略网络需要拟合的函数越复杂需要的样本量也随之增加。在真实仿真场景里一个回合动辄几秒甚至几分钟样本采集成本并不低维度膨胀带来的收益常常抵不过成本。更好的做法是先问几个问题这个状态量是不是控制决策必需的这个状态量能不能被其他已有状态推导出来这个状态量在真实部署时是不是可得如果一个状态在训练时能拿到、部署时拿不到那它就不应该作为输入。我见过一个失败案例某开发者把仿真器内部的目标轨迹预测值塞进了状态向量模型学到了很强的依赖结果部署时发现外部系统根本不给这个预测值策略立刻失效。如果确实觉得信息不够优先考虑增加“速度”“加速度”这类导数信息它们能明显改善控制平滑度而且物理含义清晰。盲目加一堆传感器读数不如加两个精心推导的派生量。3.3 混合类型状态连续向量加离散标志真实系统的状态往往不全是连续量比如“是否接触地面”“当前档位”这类离散标志。SB3的MlpPolicy天然吃向量输入没法直接混入一个离散枚举值。常见的解决办法是把离散值转换成one-hot编码再拼到连续向量后面。举个例子假设原来状态是4维连续向量再加一个“模式标志”取值只在0、1、2之间三选一。那就先把标志转成3维one-hot再把7维向量作为最终观测。此时observation_space仍然用Box定义只是维度从4变成7。也可以直接用Dict空间把连续部分和离散部分分开但SB3的MlpPolicy对Dict的支持在部分版本里不够顺手我建议新手先用拼接方式简单直接后面再迁移到Dict。4. 实操过程从零训练一个自定义环境4.1 训练脚本全流程环境写好后训练脚本本身非常短。以PPO为例核心流程如下from stable_baselines3 import PPO from stable_baselines3.common.vec_env import VecNormalize vec_env make_vec_env(lambda: PointGoalEnv(), n_envs4) vec_env VecNormalize(vec_env, norm_obsTrue, norm_rewardFalse, clip_obs10.0) model PPO( MlpPolicy, vec_env, learning_rate3e-4, n_steps2048, batch_size64, gamma0.99, gae_lambda0.95, verbose1, ) model.learn(total_timesteps200_000) model.save(point_goal_ppo) vec_env.save(vec_normalize_stats.pkl)这段脚本里total_timesteps表示总共采集的训练步数等于“采集步数×更新次数”。200000步对于这个简单的二维移动任务来说足够了实际训练时间通常在几分钟以内。n_steps2048是PPO每次收集数据的步数也就是每次更新前每个环境需要跑多少步。n_envs4时一次收集就有4×20488192条样本。batch_size64代表每次梯度更新的样本数。gamma0.99表示折扣因子越接近1模型越关注长远收益。这些参数的组合不是随便选的。n_steps决定了每次更新的数据量太小会导致更新频繁、方差大太大又会让收集时间变长。对一般连续控制任务2048到4096是比较常见的区间。gamma0.99适合回合步数在几百以内的任务如果任务需要几百步以上才能看到回报可以考虑调到0.995或0.999。4.2 训练过程观察与判断训练中verbose1会在终端打印rolling rollout的回报曲线。真正开始调参前先学会看这个输出。核心看两列一是ep_rew_mean也就是平均回合奖励二是time和fps用于判断训练速度是否正常。对于PointGoalEnv这个任务训练初期ep_rew_mean可能在-5左右因为智能体刚开始是乱走的距离目标远。随着训练推进奖励应该逐步向0靠近。如果跑了5万步奖励纹丝不动甚至下降那问题大概率不是超参数而是环境本身。最常见的环境问题是动作对状态的影响太小智能体怎么动都差不多奖励信号过于平坦模型学不到“区分好坏”。我的调试习惯是先不改算法超参数把n_envs设成1动作对状态的影响放大几倍观察最初始的策略能不能“蒙对”几次。如果单步推演都看不出状态明显变化那环境设计有问题。环境让状态动起来训练才有意义。4.3 评估与策略复用训练完成后不要只凭最后几次打印的回报判断好坏。用SB3自带工具做一次离线评估更可靠from stable_baselines3.common.evaluation import evaluate_policy eval_env make_vec_env(lambda: PointGoalEnv(), n_envs1) eval_env VecNormalize.load(vec_normalize_stats.pkl, eval_env) eval_env.training False eval_env.norm_reward False mean_reward, std_reward evaluate_policy( model, eval_env, n_eval_episodes20, deterministicTrue ) print(fmean_reward{mean_reward:.2f} ± {std_reward:.2f})deterministicTrue表示评估时使用确定性策略也就是每次取动作分布的均值而不是按概率采样。这样评估结果波动更小更能反映模型的真实水平。注意评估之前必须设置eval_env.training False否则VecNormalize还在更新统计值评估用的归一化尺度和训练不一致结果会失真。如果评估结果达标可以再做一个可视化验证写一个循环调用model.predict把每个step的轨迹打印出来人工确认智能体确实从起点走到了目标点附近。这一步别省很多模型在平均奖励指标上看着不错实际轨迹却是原地绕圈。人工看一眼轨迹比任何指标都实在。5. 常见问题与排查技巧实录5.1 高频报错速查表自定义环境接入SB3的报错80%集中在接口、形状和类型这三类。下面这个表是我实际使用中总结的基本覆盖了新手会遇到的大部分情况。报错关键词常见原因解决方法could not broadcast input array状态形状与observation_space定义不一致检查reset和step返回的数组shapeExpected float32, got float64状态或动作是float64numpy数组显式加dtypenp.float32too many values to unpackstep返回的元组数量不对新版接口必须返回5个值assert observation_spaceaction_space与输出不匹配核对网络输入层和空间定义检查离散/连续类型NaN in observation环境计算出现除零或溢出在环境里加一行np.isnan检查VecNormalize: norm_obsFalse训练和评估不一致评估时也要恢复VecNormalize参数NaN问题值得单独说。物理仿真里最常见的情况是把np.linalg.norm作用在零向量上或者把距离作为分母、除数为0。NaN一旦进入训练模型会一崩到底而且很难回滚。我建议在环境早期开发阶段reset和step里都加一段临时检查比如assert np.isfinite(self.state).all()跑通后再删掉。5.2 训练不收敛时的排查顺序训练不收敛是第二大高频问题。很多人第一反应是调学习率实际上大多数情况不是学习率的锅。我的排查顺序是先确认环境逻辑再确认状态向量有效性然后看奖励尺度最后才动算法超参数。确认环境逻辑最直接的方法是“随机策略小样本测试”把模型替换成RandomPolicy跑几百步看看奖励分布。如果随机策略下的奖励已经不错说明任务太简单不需要强化学习如果随机策略下奖励一直保持不变说明状态或动作对奖励的影响太弱模型无从学起。这种情况常见于奖励函数设计缺陷比如奖励只跟绝对位置有关但动作对位置的影响经过多步才能体现单步反馈太微弱。还有一种隐蔽的问题回合的初始状态过于随机。PointGoalEnv里每次都从[-2, -2]出发是固定的。如果初始状态在每个回合都随机分布那么模型每一回合看到的状态分布都不同学习方差会大很多。保险做法是先固定初始状态把整个训练流程跑通然后再引入随机初始化逐步增加难度。5.3 状态向量设计上的经验谈状态向量设计得好不好训练期间不明显部署时才暴露。一个常见问题是把“上帝视角信息”加进去比如目标位置在未来几步的预测值。模型学得很好一部署就废因为部署环境根本没有未来信息。设计状态向量时要反复问自己这个量在部署阶段能不能实时拿到如果不能就不应该出现在训练输入里。另一个问题是归一化参数的保存和加载时机。很多人在训练后只保存了模型权重漏了VecNormalize的统计文件部署时策略输出明显不对还以为是模型没训练好。记住使用VecNormalize时模型和归一化参数必须成对保存、成对加载缺一不可。更细一点VecNormalize.load加载后部署推理阶段要设置norm_rewardFalse因为线上评估只关心原始奖励不需要再对reward做标准化。5.4 关于稳定复现的小技巧强化学习训练有随机性同样的代码跑两次最终策略也会有差异。为了对比算法或调参时的公平性建议在写训练脚本时固定种子import numpy as np np.random.seed(42) import torch torch.manual_seed(42)不光是numpy和torch环境的reset里也依赖self.np_random这是在super().reset(seedseed)时自动初始化的。如果环境里自己用了np.random而没有走Gym的随机数生成器固定种子可能不完全生效。所以环境内部尽量只使用self.np_random来生成随机量。6. 这个内容后续还可以怎么扩展状态向量从一维连续向量开始是最稳妥的但跑通之后你会发现这套流程可以很自然地扩展到更复杂的场景。最常见的扩展方向有四个一是把状态升级成Dict空间连续量、离散量、图像信息混合输入二是换成SAC或TD3算法同样的环境几乎不用改代码只看样本效率和动作表现是否有提升三是引入固定候选动作集把连续动作离散化后用PPO跑离散策略对比两种建模方式的差距四是把环境逻辑替换成更真实的仿真模拟器比如把上面的0.02步长改成真实物理引擎的动力学方程。我个人在实际操作中的体会是自定义环境接入SB3这件事第一次做会卡在接口和对齐上但一旦把一个最小环境跑通后面换任何任务都只是改__init__里的状态向量和step里的动力学模型。不要把第一版环境想得太复杂先用最简单的运动控制和一条奖励曲线把流程走通是最快的学习路径。最后再提醒一句训练和部署时的观测归一化必须保持一致这个细节我至少见过四五个人踩过代价往往是一整轮训练白跑。