从零搭建深度强化学习环境:Gym自定义环境与Spinning Up实战
1. 从零搭建深度强化学习环境为什么选择Gym生态搞深度强化学习的人绕不开一个现实问题算法还没开始写环境先把你折腾得够呛。我见过太多人兴致勃勃地打开一篇经典论文准备复现里面的PPO或者DQN结果卡在环境安装这一步就是大半天。不是版本冲突就是依赖缺失再不然就是渲染窗口弹不出来。这种体验说实话挺打击人的。今天要聊的这套组合——Gym、Baselines、Spinning Up算是深度强化学习入门和研究的“老三样”了。Gym提供标准化的环境接口Baselines是OpenAI放出来的经典算法实现集合Spinning Up则是教学导向的算法库代码可读性极强。三个东西配合起来用基本上覆盖了从跑通demo到理解算法内部机制的全过程。这篇文章适合谁看如果你刚接触强化学习想在自己机器上把环境跑起来顺便手撸一个自定义Gym环境来加深理解那接下来的内容应该能帮你省下不少查文档和试错的时间。如果你已经有一定基础但一直用别人封装好的环境想搞清楚Gym的接口设计逻辑自己写一个环境来对接算法那也可以看看我在实现过程中的一些取舍和踩坑记录。整篇内容会从环境安装讲起然后拆解Gym的核心接口接着手把手实现一个自定义环境最后把Baselines和Spinning Up接进来跑通训练。过程中涉及到的版本选择、依赖管理、接口设计细节我都会给出具体的理由和操作步骤。2. 环境安装版本选择与依赖管理2.1 为什么版本选择比你想的重要深度强化学习环境安装最大的坑就是版本兼容性。Gym、Baselines、Spinning Up这三个库的维护节奏不一样对底层依赖的要求也有差异。如果你无脑pip install最新版大概率会遇到numpy版本冲突、gym接口不兼容、tensorflow和pytorch打架之类的问题。我的建议是先确定你要用哪个深度学习框架。Baselines早期版本基于TensorFlow 1.xSpinning Up同时支持TensorFlow和PyTorchGym本身不依赖深度学习框架。如果你不想碰TensorFlow 1.x那些老旧的API那Spinning Up的PyTorch版本会更友好。具体到版本我实测下来比较稳的组合是这样的组件推荐版本说明Python3.8 - 3.9太新的版本部分库还没适配Gym0.21.0接口稳定文档齐全NumPy1.21.x避免2.x的破坏性变更PyTorch1.10Spinning Up的PyTorch后端TensorFlow1.15可选只在需要跑Baselines时安装注意Gym从0.26版本开始对API做了较大调整比如reset()的返回值从obs变成了(obs, info)step()的返回值从4个变成5个。如果你参考的教程比较老用新版本Gym会直接报错。所以要么统一用老版本要么统一用新接口别混着来。2.2 用Conda创建隔离环境我强烈建议用Conda来管理环境不要直接在系统Python里装。原因很简单强化学习的依赖又多又杂不同项目之间很容易冲突。Conda的环境隔离能帮你省去很多麻烦。conda create -n rl_env python3.8 conda activate rl_env创建好环境之后先装PyTorch。根据你的显卡情况选择CUDA版本如果没有GPU就用CPU版本# CPU版本 pip install torch1.10.0 torchvision0.11.0 # CUDA 11.3版本 pip install torch1.10.0cu113 torchvision0.11.0cu113 -f https://download.pytorch.org/whl/torch_stable.html然后装Gym和基础科学计算库pip install gym0.21.0 pip install numpy1.21.6 pip install matplotlib pip install pandas这里有个细节Gym的某些环境需要额外的依赖比如Atari环境需要ale-pyMuJoCo环境需要mujoco-py。如果你只是跑经典控制环境CartPole、MountainCar这些上面这些就够了。如果要跑MuJoCo安装会复杂不少建议单独查对应版本的安装指南。2.3 Spinning Up的安装Spinning Up的安装有两种方式直接pip安装或者从源码安装。我推荐从源码安装因为这样你可以直接看它的算法实现代码改起来也方便。git clone https://github.com/openai/spinningup.git cd spinningup pip install -e .安装完成后可以跑一个测试命令验证python -m spinup.run ppo --env CartPole-v1 --exp_name test_ppo --epochs 5如果能看到训练日志输出说明安装成功了。Spinning Up的代码结构很清晰spinup/algos/pytorch/下面就是各个算法的实现每个算法一个文件读起来很舒服。2.4 Baselines的安装可选Baselines的安装相对麻烦一些因为它依赖TensorFlow 1.x。如果你不需要跑Baselines里的算法可以跳过这一节。如果确实需要建议单独创建一个环境conda create -n baselines_env python3.7 conda activate baselines_env pip install tensorflow1.15 git clone https://github.com/openai/baselines.git cd baselines pip install -e .实操心得Baselines的代码比较老有些地方用了已经废弃的API。如果你在安装过程中遇到gym版本冲突可以尝试把gym降到0.15.4左右。另外Baselines对cloudpickle的版本也有要求太新的版本会报错。3. Gym核心接口拆解环境到底是怎么运作的3.1 Gym的抽象设计逻辑Gym的核心思想很简单把强化学习环境抽象成一套统一的接口让算法和环境解耦。你写算法的时候不需要关心环境内部怎么实现只需要调用reset()和step()两个方法就行。这种设计的好处是同一个算法可以无缝切换到不同环境上测试。Gym的环境都继承自gym.Env基类需要实现以下几个关键部分observation_space观测空间定义观测值的形状和范围action_space动作空间定义可选动作的集合reset()重置环境返回初始观测step(action)执行动作返回(observation, reward, done, info)render()渲染环境可选close()清理资源可选这套接口看起来简单但设计上有很多值得琢磨的地方。比如为什么step()要返回done而不是让环境自己判断终止因为算法需要知道episode什么时候结束以便进行bootstrap或者重置。再比如info字典的作用它是用来传递一些辅助信息的比如调试信息、额外指标等算法可以选择忽略它。3.2 观测空间与动作空间的类型Gym提供了多种空间类型最常用的是Box和Discreteimport gym from gym import spaces import numpy as np # Discrete离散动作空间比如CartPole只有两个动作左推、右推 discrete_space spaces.Discrete(2) print(discrete_space.n) # 2 print(discrete_space.sample()) # 随机采样一个动作 # Box连续空间比如观测是一个4维向量每个维度的范围是[-inf, inf] box_space spaces.Box(low-np.inf, highnp.inf, shape(4,), dtypenp.float32) print(box_space.shape) # (4,) print(box_space.sample()) # 随机采样一个观测选择哪种空间类型取决于你的问题性质。离散动作适合决策数量有限的情况比如游戏里的上下左右连续动作适合控制类问题比如机器人的关节角度、无人车的油门刹车。注意Box的dtype建议用np.float32而不是np.float64因为神经网络通常用float32用float64会导致类型不匹配的警告甚至错误。3.3 一个最小可运行的环境示例在动手写自定义环境之前先用Gym自带的CartPole跑一个随机策略的demo感受一下接口的调用方式import gym env gym.make(CartPole-v1) obs env.reset() total_reward 0 done False while not done: action env.action_space.sample() # 随机选一个动作 obs, reward, done, info env.step(action) total_reward reward env.render() # 弹出渲染窗口 print(fTotal reward: {total_reward}) env.close()这段代码虽然简单但包含了强化学习环境交互的基本循环重置、选动作、执行动作、累积奖励、判断终止。所有的强化学习算法本质上都是在这个循环里做文章——把随机选动作换成策略网络选动作然后根据奖励信号更新网络参数。4. 手撸一个自定义Gym环境4.1 需求定义我们要做一个什么样的环境为了把Gym的接口讲透我设计了一个简单的自定义环境一维寻宝游戏。规则是这样的一条长度为10的线段位置用0到9的整数表示智能体从位置0出发目标是到达位置9宝藏位置动作空间是离散的0表示向左移动1表示向右移动到达位置9获得奖励10episode结束每走一步获得奖励-0.1鼓励智能体尽快到达如果走出边界位置小于0或大于9位置保持不变奖励-0.5这个环境足够简单但包含了强化学习环境的核心要素状态空间、动作空间、状态转移、奖励函数、终止条件。用它来理解Gym的接口设计再合适不过。4.2 完整代码实现与逐段解析import gym from gym import spaces import numpy as np class TreasureHuntEnv(gym.Env): 一维寻宝环境 def __init__(self): super(TreasureHuntEnv, self).__init__() # 动作空间0向左1向右 self.action_space spaces.Discrete(2) # 观测空间位置是一个0到9的整数用Box表示 self.observation_space spaces.Box( low0, high9, shape(1,), dtypenp.float32 ) # 宝藏位置 self.goal_position 9 # 当前位置 self.position 0 # 最大步数防止无限循环 self.max_steps 100 self.current_step 0 def reset(self): 重置环境到初始状态 self.position 0 self.current_step 0 return np.array([self.position], dtypenp.float32) def step(self, action): 执行动作返回(obs, reward, done, info) self.current_step 1 # 根据动作更新位置 if action 0: # 向左 new_position self.position - 1 else: # 向右 new_position self.position 1 # 边界检查 if new_position 0 or new_position 9: # 撞墙位置不变惩罚 reward -0.5 else: self.position new_position if self.position self.goal_position: reward 10.0 else: reward -0.1 # 判断是否终止 done False if self.position self.goal_position: done True if self.current_step self.max_steps: done True # 观测 obs np.array([self.position], dtypenp.float32) # 辅助信息 info { step: self.current_step, position: self.position } return obs, reward, done, info def render(self, modehuman): 用文本方式渲染环境 line [-] * 10 line[self.position] A line[self.goal_position] G print(.join(line)) def close(self): pass逐段来看这个实现。__init__里定义了两个空间动作空间用Discrete(2)因为只有左右两个选择观测空间用Box虽然位置是整数但用Box可以方便地对接神经网络——网络输出的是浮点数用Box不需要额外转换。reset()方法把位置重置为0步数计数器归零返回初始观测。注意返回的观测必须是np.float32类型的数组形状要和observation_space定义的一致。step()方法是核心。它接收动作计算新位置然后根据规则给出奖励。这里有几个设计决策值得说明第一撞墙的惩罚设为-0.5比普通步数的-0.1要大这样智能体能学会避免撞墙。如果撞墙惩罚太小智能体可能会反复撞墙来“刷步数”反正每步只扣0.1。第二到达目标的奖励是10这个值要足够大让智能体愿意尽快到达。如果奖励只有1而每步扣0.1那智能体可能会绕路来避免撞墙反而不愿意直接到达目标。第三max_steps设为100防止智能体在环境里无限徘徊。这个值要根据环境大小来定10个位置的环境理论上最多走9步就能到达设100已经非常宽松了。render()方法用文本方式显示当前位置A表示智能体G表示宝藏。这种渲染方式虽然简陋但调试的时候非常直观。4.3 测试自定义环境写完之后用随机策略跑一下看看环境是否正常工作env TreasureHuntEnv() obs env.reset() total_reward 0 done False while not done: action env.action_space.sample() obs, reward, done, info env.step(action) total_reward reward env.render() print(fAction: {action}, Reward: {reward}, Position: {obs[0]}) print(fTotal reward: {total_reward})跑几次之后你会发现随机策略的表现很不稳定有时候很快到达有时候来回晃悠。这正是强化学习要解决的问题通过训练让策略学会最优路径。实操心得自定义环境最容易出错的地方是观测的形状和类型。observation_space定义的是shape(1,)那reset()和step()返回的观测也必须是shape(1,)的数组。如果返回标量或者形状不对后面的算法对接会直接报错。我建议在step()里加一句断言assert self.observation_space.contains(obs)这样能在开发阶段就发现问题。4.4 用Gym的Wrapper增强环境功能Gym提供了Wrapper机制可以在不修改环境代码的情况下增加功能。比如我们想限制最大步数可以用TimeLimitfrom gym.wrappers import TimeLimit env TreasureHuntEnv() env TimeLimit(env, max_episode_steps50)再比如想把奖励归一化可以自己写一个Wrapperclass RewardScaleWrapper(gym.RewardWrapper): def __init__(self, env, scale0.1): super().__init__(env) self.scale scale def reward(self, reward): return reward * self.scaleWrapper的设计模式是装饰器模式一层套一层每层只负责一个功能。这种设计让环境的功能组合非常灵活你可以根据需要自由搭配。5. 对接Baselines与Spinning Up跑训练5.1 用Spinning Up的PPO训练自定义环境Spinning Up的算法实现是独立的不直接依赖Gym的环境注册机制。你需要把环境传给算法的train函数。不过Spinning Up默认从环境名称创建环境要让它用自定义环境需要先注册from gym.envs.registration import register register( idTreasureHunt-v0, entry_point__main__:TreasureHuntEnv, max_episode_steps100, )注册之后就可以用环境名称来创建了import gym env gym.make(TreasureHunt-v0)然后跑PPO训练python -m spinup.run ppo --env TreasureHunt-v0 --exp_name treasure_ppo --epochs 50训练完成后Spinning Up会把模型保存在data/目录下。你可以用spinup.run的--load参数加载模型测试训练效果。5.2 训练过程中的关键参数Spinning Up的PPO有几个关键参数需要根据环境调整参数默认值调整建议steps_per_epoch4000小环境可以降到1000epochs50简单环境50轮足够gamma0.99短episode可以降到0.95pi_lr3e-4策略网络学习率vf_lr1e-3价值网络学习率对于我们的寻宝环境episode很短最多9步到达gamma设0.95就够了因为不需要考虑太远的未来。steps_per_epoch可以设小一点因为环境交互很快。5.3 训练效果验证与可视化训练完成后加载模型跑几轮测试import torch from spinup.utils.test_policy import load_policy_and_env _, get_action load_policy_and_env(data/treasure_ppo/treasure_ppo_s50) env gym.make(TreasureHunt-v0) obs env.reset() done False total_reward 0 while not done: action get_action(obs) obs, reward, done, info env.step(action) total_reward reward env.render() print(fTest total reward: {total_reward})如果训练正常你应该能看到智能体直接从位置0一路向右走到位置9总奖励接近10 - 9*0.1 9.1。如果智能体还在来回晃悠说明训练不够或者参数需要调整。常见问题训练不收敛怎么办先检查奖励函数设计是否合理。如果到达目标的奖励不够大或者每步惩罚太大智能体可能会选择“摆烂”——原地不动或者来回走因为到达目标的收益还不如省下的步数惩罚。我一般会先跑随机策略看看平均奖励是多少然后确保最优策略的奖励明显高于随机策略。6. 常见问题与排查技巧实录6.1 环境安装类问题问题一pip install gym之后import报错这种情况多半是依赖冲突。先检查numpy版本Gym 0.21需要numpy 1.21左右。如果numpy是2.x版本降级pip install numpy1.21.6问题二渲染窗口弹不出来在服务器上跑的时候没有图形界面render()会报错。解决办法是用modergb_array返回图像数组而不是弹窗img env.render(modergb_array)或者直接注释掉render()调用训练的时候不需要渲染。问题三Spinning Up安装后命令找不到检查是否在正确的conda环境里以及pip install -e .是否执行成功。可以试试python -c import spinup; print(spinup.__file__)如果报错说明安装有问题重新执行安装命令。6.2 环境实现类问题问题四step()返回的观测形状不对这是最常见的问题。observation_space定义的是什么形状reset()和step()就必须返回什么形状。用np.array([value], dtypenp.float32)确保形状是(1,)而不是标量。问题五训练时loss变成nan多半是奖励值太大或者太小。检查奖励范围如果奖励在几百上千的量级考虑归一化到[-1, 1]或者[0, 1]之间。另外检查观测值是否做了归一化如果观测值范围很大神经网络容易发散。问题六智能体学不会最优策略先确认环境本身是否可解。用随机策略跑100次看看有没有成功到达目标的案例。如果随机策略从来没能到达目标那可能是环境设计有问题比如动作空间定义反了或者奖励函数有bug。6.3 训练调参类问题问题七训练不稳定奖励忽高忽低这是强化学习的常态尤其是策略梯度方法。可以尝试减小学习率或者增大steps_per_epoch让每次更新有更多数据。另外PPO的clip_ratio参数也可以调小一点从0.2降到0.1让更新更保守。问题八训练速度太慢如果是CPU训练考虑换GPU。如果是GPU训练但利用率低检查环境交互是否成了瓶颈。Gym的环境交互是单线程的如果环境step()计算量大可以考虑用向量化环境并行采样。问题类型典型表现排查方向安装问题import报错、命令找不到检查版本、环境隔离接口问题形状不匹配、类型错误检查space定义和返回值训练问题loss nan、不收敛检查奖励范围、学习率性能问题训练慢、GPU利用率低检查环境交互瓶颈7. 一些实操中的个人体会自定义环境最难的地方不是写代码而是设计奖励函数。我一开始把到达目标的奖励设成1每步惩罚-0.01结果智能体学会了在原地转圈——因为转圈不扣分到达目标也只加1它觉得不划算。后来把目标奖励提到10每步惩罚提到-0.1智能体才学会直奔目标。另一个体会是环境的观测设计要尽量简洁。我试过把位置、步数、距离目标的距离都放进观测里结果训练反而变慢了。因为观测维度越高网络需要学习的映射就越复杂。对于简单环境只放最核心的状态信息就够了。Spinning Up的代码质量确实高每个算法文件都是独立的读起来很顺畅。如果你想深入理解PPO或者SAC的实现细节直接读它的源码比看论文快得多。Baselines的代码相对老一些但算法种类更全可以作为补充参考。最后说一个调试技巧在step()里加日志输出记录每一步的动作、奖励、位置。训练不收敛的时候把这些日志打出来看看往往能发现一些反直觉的行为。比如智能体可能在某个位置反复横跳说明那个位置的奖励设计有问题。这种问题光看训练曲线是看不出来的必须深入到每一步的交互细节里。