ARTICLE DETAIL

资讯详情

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

从gym到Gymnasium迁移指南:API差异与训练循环改造实践

从gym到Gymnasium迁移指南:API差异与训练循环改造实践 从gym升级到Gymnasium这事我一开始是拒绝的。毕竟gym用了好几年代码里到处都是import gym说换就换想想就头大。但后来被逼着迁移了一遍才发现这步棋早晚得走而且走完之后的收益远比想象中大。如果你还在用gym跑强化学习实验或者刚入坑就搜到一堆基于旧gym的教程那这篇文章就是给你写的。我会把从gym到Gymnasium的来龙去脉、API差异、迁移步骤、踩坑记录一次讲清楚代码直接复制就能用。1. 为什么必须从gym搬到Gymnasium1.1 gym停止维护的来龙去脉OpenAI Gym从2016年发布以来几乎成了强化学习环境的事实标准。不管是DQN、PPO还是SAC教程里第一行代码大概率是import gym然后gym.make(CartPole-v1)。这个生态太深入人心了以至于很多人根本不知道它已经停止维护。事情的转折点发生在2022年。OpenAI把Gym的维护权移交给了Farama Foundation一个专门维护开源强化学习生态的非营利组织。Farama接手后做了个重要决定不再继续维护旧的gym代码库而是推出了全新的Gymnasium作为Gym的正式继承者。旧gym库停留在0.26.x版本之后不再有新特性、不再修bug、也不再兼容新的Python版本和依赖库。现在你如果去PyPI上看gym这个包的最后更新时间已经是2022年的事了。对于还在用旧库跑新实验的人来说这个问题会越来越大新环境跑不了、老代码在Python 3.12上直接报错、某些依赖冲突到怀疑人生。1.2 Gymnasium到底改了什么Gymnasium不是简单的改名它在API层面做了相当多的修正。最核心的一点是把done信号拆成了terminated和truncated这是整个迁移里最需要理解的变化。旧gym里step()返回observation, reward, done, info这4个值。done为True时表示这个回合结束了但到底是因为“智能体完成任务”还是“达到了最大步数被强制截断”完全没法区分。这在很多算法里是个隐患尤其对于需要对回合结束原因做不同处理的算法比如在不该给负反馈的地方给了负反馈。Gymnasium把done拆成了terminated智能体真正达到了终止状态比如走迷宫走到了终点、倒立摆真的立住了。truncated回合因为外部原因被截断比如达到了最大步数、环境超时。这个拆分的意义不只是语义更准确它直接影响训练效果。很多算法在计算GAE或TD目标时对于terminated和truncated要采用不同的处理方式旧gym里很多人用done一刀切其实是在用错误的方式学习不值得学习的经验。1.3 不迁移会造成什么后果我见过不少同学代码能跑就一直不换结果拖到后面出了三个问题第一是新的论文和代码库全都基于Gymnasium了。你要复现一篇2023年之后的强化学习论文大概率会看到import gymnasium as gym。你自己环境是旧gym代码直接不兼容还得手动改半天。第二是新的环境生态只给Gymnasium提供接口尤其是Farama维护的mujoco_env、atari等环境系列旧gym逐渐跑不通。第三是Python版本兼容性旧gym在Python 3.10以上就有各种奇怪报错到了3.12更是重灾区。换句话说你不迁移也可以但你会发现自己离整个社区越来越远。迁移这件事早做比晚做好。2. 迁移前的基本功搞懂API差异2.1 版本号与包装差异先说最简单的部分怎么安装。旧gym的安装命令是pip install gymGymnasium的安装命令是pip install gymnasium如果你需要用到Atari、Mujoco这类额外环境还需要装对应的额外包pip install gymnasium[atari] pip install gymnasium[mujoco] pip install gymnasium[classic-control]这里有个坑旧gym的可选依赖名一般写在方括号里比如gym[atari]Gymnasium沿用了一样的风格但包名变了。如果你在同一个环境里同时装了gym和gymnasium代码里import gym还是旧gymimport gymnasium是新库不会直接冲突但底层环境ID可能重叠最好别这么干会出现各种奇怪的环境注册问题。还有个常见的别名技巧。迁移代码时为了最小化改动很多人会在文件头部用别名导入import gymnasium as gym这样后面所有gym.make的代码都不用改。这个技巧非常实用我自己的老项目大多是这么过渡的改一处就全通了。2.2 step()返回值从4元组变成了5元组这是迁移过程中最容易出bug的地方。旧gym的step()返回4个值obs, reward, done, info env.step(action)Gymnasium返回5个值obs, reward, terminated, truncated, info env.step(action)所有训练循环都必须改成5元组接收否则会直接ValueError: not enough values to unpack。如果你不想把所有循环体都改一遍可以做个兼容层但更推荐直接改训练循环。因为terminated和truncated的区分本身就应该影响你的算法逻辑而不是简单合并成一个done。一个常见的处理方式是这样的obs, reward, terminated, truncated, info env.step(action) done terminated or truncated如果算法不需要区分就用done如果需要区分比如PPO计算GAE时就分开用。在旧gym里done只有True/False。在新Gymnasium里terminated和truncated都是布尔值。但要小心某些环境比如Atari返回的不是Python原生bool而是numpy.bool_直接做逻辑运算没问题但如果做if done is True这种判断就会踩坑要用if bool(done)。2.3 seed机制与reset()重构旧gym里设置随机种子是env.seed(42) obs env.reset()Gymnasium彻底移除了env.seed()方法种子在reset()时传参数obs, info env.reset(seed42)而且reset()返回值变成了两个observation和info。这个改动在训练循环里影响很大。旧代码obs env.reset()新代码obs, info env.reset(seed42)info是一个字典包含这个回合初始状态的一些额外信息。对于大多数标准环境它就是空字典但对于很多自定义环境你会在info里放初始状态、初始约束等数据。有一点要特别提醒旧gym的env.seed(seed)会自动为action_space、observation_space的内部随机数生成器设置种子而Gymnasium推荐的做法是给每个环境实例单独传seed。这意味着如果你并行跑多个环境做异步收集不要在reset里都传同一个seed否则所有并行环境初始状态完全一样会严重影响样本多样性。2.4 render_mode与渲染接口变化渲染这块旧gym是这么玩的env gym.make(CartPole-v1) env.render()但Gymnasium把渲染方式改成了声明式的在make时通过render_mode指定渲染模式而且render()不再接受参数。env gym.make(CartPole-v1, render_modehuman) obs, info env.reset() env.render()支持的render_mode包括human弹出窗口显示适合调试时肉眼看。rgb_array返回RGB数组适合训练时做帧缓存、保存视频。ansi返回文本描述适合终端环境。rgb_array_list记录所有帧并返回列表适合回放。一个常见需求是把训练过程录制成视频。旧gym里很多人用Monitor包装器Gymnasium里已经不再推荐使用Monitor而是统一用RecordVideo包装器import gymnasium as gym from gymnasium.wrappers import RecordVideo env gym.make(CartPole-v1, render_modergb_array) env RecordVideo(env, video_folder./video, episode_triggerlambda episode_id: episode_id % 10 0)这段代码的意思是每10个回合保存一次视频。注意RecordVideo要求环境的render_mode为rgb_array否则没有图像数据可以录制。2.5 包装器API的同步调整Gymnasium的包装器基类也变了。旧gym里包装器通过self.env直接访问底层环境。Gymnasium更推荐使用self.env.unwrapped获取原始环境。同时包装器库从gym.Wrapper变成了gymnasium.Wrapper但用法基本一致。在旧gym里常见的包装器用法env gym.make(CartPole-v1) env gym.wrappers.FrameStack(env, 4) env gym.wrappers.GrayScaleObservation(env)Gymnasium里对应的写法env gymnasium.make(CartPole-v1) env gymnasium.wrappers.FrameStack(env, 4) env gymnasium.wrappers.GrayScaleObservation(env)如果你之前是import gym后直接用gym.wrappers.xxx迁移时只需要改成gymnasium.wrappers.xxx或者用前面说的别名技巧。这一点看起来简单但实战中经常因为包装器内访问env.action_space或env.observation_space的方式不同而出错。Gymnasium的每个包装器在内部会做一些空间信息的自动推断建议包装器最好在外层创建环境之后立刻套上不要隔着好几层再套否则空间缓存可能不匹配。3. 实战迁移一步一步改代码3.1 依赖替换与import改造假设你手里有个老项目依赖文件是requirements.txt里面写着gym0.26.2。第一步就是把这一行改成gymnasium0.28如果你的代码里用了特定环境比如gym[box2d]那要改成gymnasium[box2d]。安装完新库之后清掉旧的安装缓存pip uninstall gym -y pip install gymnasium然后全局搜索代码里的import gym把import gym全部替换成import gymnasium as gym。这里有个小技巧不要用IDE的全局替换一把梭因为有些文件可能既是环境文件又是算法文件直接全部替换会漏掉一些字符串判断比如if gym in str(env.spec)这类反射代码。稳妥的做法是先全局搜一遍看清楚每个出现的位置再手动改。如果你的代码里同时用了from gym import spaces要一并改成from gymnasium import spacesspaces里面最常用的Discrete、Box、MultiDiscrete、Tuple、Dict基本都能直接用API没变但实现细节有细微差别具体后面说。3.2 环境创建与注册表迁移旧gym里环境ID是CartPole-v1、MountainCar-v0这类字符串。Gymnasium沿用了大部分ID但部分环境的ID有调整。比如Atari环境旧gym里的ID通常是PongNoFrameskip-v4这种。Gymnasium里也差不多但命名空间从gym.envs.atari变成了gymnasium.envs.atari。如果你使用gym.make(PongNoFrameskip-v4)还是能跑通但底层注册表已经换了。如果你在旧gym里自己注册过自定义环境注册表的转移很关键。旧代码from gym.envs.registration import register register( idMyEnv-v0, entry_pointmy_env:MyEnv, )新代码from gymnasium.envs.registration import register register( idMyEnv-v0, entry_pointmy_env:MyEnv, )注意旧gym的注册表函数在gym.envs.registrationGymnasium里是在gymnasium.envs.registration。另外register()函数增加了一些新参数比如order_enforce、disable_env_checker等但老参数基本兼容。一个我实际遇到的坑如果在同一个进程里同时导入过旧gym和Gymnasium再注册相同ID的环境会抛出Cannot re-register id的报错。这是因为两个库各自维护了一套注册表但你可能会在代码里被某个依赖库间接导入了旧gym。解决办法是找到那个导入旧gym的库或者干脆在环境里只装Gymnasium。3.3 训练循环改造实例DQN从旧到新我们拿一个经典的DQN训练循环举例先看旧gym的写法import gym import random import numpy as np from collections import deque env gym.make(CartPole-v1) env.seed(42) obs env.reset() # 一些超参数 gamma 0.99 epsilon 1.0 epsilon_min 0.01 epsilon_decay 0.995 memory deque(maxlen2000) for episode in range(500): obs env.reset() total_reward 0 done False while not done: if random.random() epsilon: action env.action_space.sample() else: # 假设q_network是训练好的网络 q_values q_network.predict(obs) action np.argmax(q_values) next_obs, reward, done, info env.step(action) memory.append((obs, action, reward, next_obs, done)) obs next_obs total_reward reward if epsilon epsilon_min: epsilon * epsilon_decay这段代码放到Gymnasium环境里至少会出三个问题env.seed(42)直接报错方法不存在。obs env.reset()返回值数量不对需要接收两个值。next_obs, reward, done, info env.step(action)解包失败因为step返回5个值。改成Gymnasium对应的写法import gymnasium as gym import random import numpy as np from collections import deque env gym.make(CartPole-v1) obs, _ env.reset(seed42) gamma 0.99 epsilon 1.0 epsilon_min 0.01 epsilon_decay 0.995 memory deque(maxlen2000) for episode in range(500): obs, _ env.reset(seed42 episode) # 每个回合不同种子 total_reward 0 terminated, truncated False, False while not (terminated or truncated): if random.random() epsilon: action env.action_space.sample() else: q_values q_network.predict(obs) action np.argmax(q_values) next_obs, reward, terminated, truncated, info env.step(action) done terminated or truncated memory.append((obs, action, reward, next_obs, done)) obs next_obs total_reward reward if epsilon epsilon_min: epsilon * epsilon_decay核心变化点就三处删掉env.seed改用reset(seed...)reset接收两个返回值step接收5个返回值并用done terminated or truncated兼容旧逻辑。3.4 自定义环境与包装器适配如果你只是用官方环境迁移成本不高但很多人的项目里都有自定义环境。自定义环境的编写规范在Gymnasium里更严格了。旧gym自定义环境模板import gym from gym import spaces class MyEnv(gym.Env): def __init__(self): super().__init__() self.action_space spaces.Discrete(4) self.observation_space spaces.Box(low0, high255, shape(84, 84, 3), dtypefloat) def reset(self): return self.obs def step(self, action): return self.obs, reward, done, infoGymnasium要求reset返回两个值step返回5个值import gymnasium as gym from gymnasium import spaces class MyEnv(gym.Env): def __init__(self): super().__init__() self.action_space spaces.Discrete(4) self.observation_space spaces.Box(low0, high255, shape(84, 84, 3), dtypefloat) def reset(self, seedNone, optionsNone): super().reset(seedseed) # 初始化状态 self.obs ... info {} return self.obs, info def step(self, action): # 执行动作更新状态 terminated ... # 是否真正达到终止状态 truncated ... # 是否因为外部原因截断 reward ... info {} return self.obs, reward, terminated, truncated, info这里有个重要的规范reset必须调用super().reset(seedseed)这样环境内部的随机数生成器self.np_random才会正确初始化。如果你不调用self.np_random为None调用self.np_random.uniform()会直接报错。另外Gymnasium提供了Env基类的np_random属性你可以在环境中用self.np_random生成随机数而不是直接用np.random。这样能确保每个环境实例的随机性独立可复现。包装器的适配相对轻量。如果你要自定义包装器要注意Gymnasium的包装器基类扩展了时间步计数功能import gymnasium as gym class MyWrapper(gym.Wrapper): def __init__(self, env): super().__init__(env) def step(self, action): obs, reward, terminated, truncated, info self.env.step(action) # 自定义逻辑 return obs, reward, terminated, truncated, info3.5 处理terminated和truncated对算法的影响这一步最容易产生隐藏bug很多人只是改了API接口算法逻辑没跟着变结果训练结果变得很奇怪。拿PPO举例在计算GAE的时候对终止和截断的处理应该是不同的terminatedTrue代表回合真正结束下一个状态没有实际意义价值估计应该为0。truncatedTrue代表只是被截断了但环境其实还能继续价值函数应该继续估计下一个状态的价值。这个差异对TD误差影响很大。如果你统一用done terminated or truncated然后设定下一个状态的value为0那么所有被截断的回合都少算了一截价值回报策略梯度会失真。所以在改完API之后我强烈建议你把算法里关于done的逻辑重新梳理一遍。比如杠铃推举机器人上的回合终止条件和最大步数限制这两件事在物理上完全不同。3.6 使用工具辅助大规模迁移如果你的项目文件很多光靠手工改容易漏。我自己的做法是写一个简单脚本批量辅助替换但替换之前先全局搜索确认。给大家参考一下我在迁移老仓库时用的脚本思路# 1. 全局搜索含 import gym 的文件 grep -rl import gym --include*.py . # 2. 全局搜索含 env.seed 的调用 grep -rl \.seed( --include*.py . # 3. 全局搜索含 env.reset() 然后赋值给单变量的情况 grep -rn obs env.reset() --include*.py . # 4. 全局搜索含 done 关键字的训练循环 grep -rn done --include*.py .搜索完之后重点检查训练循环文件和环境文件先手动改这两个其他辅助文件最后统一改。不要用正则全局替换obs env.reset()因为可能存在obs, _ env.reset()或obs env.reset(seed0)等不同变体机械替换容易出错。4. 常见问题与排查技巧实录4.1 高频报错速查表迁移过程中我先后遇到过不少报错整理成了一张速查表遇到类似问题可以按图索骥报错信息原因解决方案AttributeError: module gym has no attribute make环境中同时存在旧gym和gymnasium或包引用冲突检查pip list卸载旧gym确保import gymnasium as gymValueError: too many values to unpack (expected 4)step()返回值从4个变成了5个修改为obs, reward, terminated, truncated, info env.step(action)TypeError: reset() got an unexpected keyword argument seed环境是老版gym接口升级环境或改用env.reset(seed42)AttributeError: CartPoleEnv object has no attribute seed调用了env.seed()改成env.reset(seed42)Cannot re-register id: CartPole-v1重复注册环境或旧gym和gymnasium注册表冲突重启kernel确保进程内只导入一个库No registered environment with id: xxx-v0环境ID拼写错误或注册代码没执行检查环境ID确认register()执行过ModuleNotFoundError: No module named gymnasium.envs.box2d缺少可选依赖安装pip install gymnasium[box2d]ValueError: Buffer dtype mismatch, expected float32 but got float64Box空间dtype不匹配检查环境的observation_space与step()返回的obs类型是否一致RuntimeError: Cannot call env.render() before env.reset()渲染顺序错误先调用env.reset()再调用env.render()TypeError: numpy.bool_ object is not callabledone类型判断错误用if done:而不是if done is True:4.2 类型与空间的坑强化学习环境升级后最隐蔽的问题往往不是接口报错而是类型不匹配。旧gym的Box空间默认dtype是float32但很多人在自定义环境里返回了float64的numpy数组训练时框架会报错。Gymnasium对类型检查更严格如果你在自定义环境的observation_space里声明了Box(low0, high1, shape(4,), dtypenp.float32)但reset()返回的obs是np.float64那么Gymnasium会直接抛异常。另外一个常见问题是action_space.sample()的类型。旧gym里Discrete(4).sample()返回的是numpy.int64Gymnasium里也是。但有些深度学习框架比如某些版本的PyTorch在接收numpy整数作为gather索引时会出问题。最稳妥的做法是action int(env.action_space.sample())空间定义上还有一个细节spaces.Box的形状检查。旧gym只检查shape是否相同Gymnasium还会检查shape的语义是否兼容。比如你的observation_space是(84, 84, 3)但reset()返回了(3, 84, 84)旧gym可能不会及时发现Gymnasium会明确报错。这也算是个好事能帮你早点发现环境实现里的bug。4.3 性能与兼容性对比我实际测过从gym迁移到Gymnasium后训练性能的影响。结论是几乎没影响。对于CartPole这种轻量环境环境交互本身耗时极短主要耗时都在神经网络前向和反向传播上。Gymnasium在环境接口上的改动不会引入额外开销step()多返回一个布尔值也几乎可以忽略。不过有一个地方需要注意旧gym的Atari环境依赖Arcade Learning Environment的版本较老而Gymnasium的Atari集成了更现代的ALE接口两者在随机帧跳过、动作重复等方面的默认参数有差别。如果你复现论文里的结果要注意版本差异带来的微妙影响。简单来说Gymnasium在性能上不会拖后腿但在兼容性和可维护性上明显优于旧gym。特别是Farama一直在给Gymnasium修bug、加新环境长期来看收益很明显。4.4 迁移状态检查清单为了确保迁移彻底我给自己列过一个检查清单也分享给大家pip list里没有gym只有gymnasium。所有代码里没有import gym可以保留import gymnasium as gym的别名写法。所有env.reset()调用都接收两个返回值。所有env.step()调用都接收5个返回值。没有调用env.seed()全部改成env.reset(seed...)。训练循环里的done逻辑重新梳理过能区分terminated和truncated。自定义环境的reset里调用了super().reset(seedseed)。自定义环境的step返回的是5个值。视频录制如果用了Monitor换成RecordVideo。检查requirements.txt或pyproject.toml里的依赖名改成了gymnasium。重跑一遍旧实验对比关键指标平均回报、训练曲线是否一致。如果你能全部打勾那你的项目就算真正完成迁移了而不是只改了import的皮。5. 学习资源与实践建议5.1 升级后还能看哪些资料很多人问Gymnasium出来之后旧教程还能不能看我的建议是算法原理部分完全能看不影响但环境API部分需要对照新文档来看。我推荐这几个学习路径官方文档Gymnasium的文档做得还不错环境调用、空间定义、包装器用法都有详细说明。GitHub上基于Gymnasium的开源项目比如最新的CleanRL、Stable-Baselines3新版本都已经全面支持Gymnasium直接读源码比看教程更高效。动手跑一遍把一个之前写过的旧项目完整迁移一遍遇到问题查文档比看十篇教程都管用。5.2 从算法角度理解环境接口的进化最后聊点偏思考的东西。为什么Farama要费这么大劲把done拆成terminated和truncated表面上看就是加了个返回值但背后反映的是强化学习社区对“回合结束”这件事更精细的理解。当算法面对terminated和truncated时应该有不同的处理方式。比如Q-learning在更新时对terminated状态认为Q值直接等于reward对truncated状态则认为还要加上未来收益的折现。这个区别在之前被很多人忽略了等于模型学习的时候总是学到错误的长期价值。Gymnasium把这个问题推到了台前逼着每个使用者在写训练循环时明确处理这两类结束这对整个生态的实验规范性是好事。我在实际使用中发现迁移到Gymnasium之后代码里关于回合结束的判断反而更清晰了。以前看老代码里的done我得猜它到底是环境自己终止的还是我设置了最大步数。现在一眼就能看出来是哪种情况调试省了很多时间。如果你手头还有老项目早搬早省心。
返回列表