很多刚接触强化学习的同学,第一次听到“数据集”这个词的时候都会愣一下:强化学习的数据集到底长什么样?是像图像分类那样下载一堆图片,还是像NLP那样拿一批文本?我在带新人踩坑的过程中,几乎每次都要先花二十分钟把这层窗户纸捅破——强化学习里的“数据集”,至少有三种完全不同的形态,而你选哪一种,直接决定了后面网络怎么搭、训练怎么跑、坑怎么踩。
这篇文章我打算把强化学习里最常见的一批数据集和环境系统梳理一遍,从概念、下载安装到实际处理,全程按我自己的实操习惯来写。适合刚入门、准备跑基准实验,或者已经被环境安装折磨得想砸电脑的同学参考。我会尽量把“为什么这么做”也讲清楚,不光是丢给你几条pip命令。
1. 先把“数据集”这个概念捋清楚:环境、经验池和离线文件
1.1 为什么强化学习的“数据集”和传统深度学习完全不是一回事
传统深度学习中,数据集是一个静态文件集合,里面是“输入-标签”对,训练过程就是反复在同样的数据上计算梯度,数据分布从头到尾不变。强化学习不是这个玩法,智能体通过和环境交互产生数据,也就是“状态-动作-奖励-下一状态”这样的四元组,而这些数据的分布会随着策略改变而改变。
这个差异带来的直接后果是:你不能像做图像分类那样,把一份强化学习数据“打乱后分成训练集和测试集”。因为某个状态序列是否出现,取决于智能体当前的行为策略。如果你拿死数据训练,策略一旦更新,旧数据里的状态分布就已经“过时”了,这叫分布偏移。在线强化学习解决这个问题靠的是不断重新采样,所以大家口中常说的“强化学习数据集”,大多数时候其实指的是那些标准环境,以及环境交互产生的数据流。
但离线强化学习(OFFLINE RL / Batch RL)确实需要静态数据集,比如D4RL、RL Unplugged这些,它们把一批已经采集好的转移元组保存成文件,训练时不允许和环境交互。这部分和传统数据集最接近,但也有不少特殊之处,后面单独说。
1.2 按交互方式把“数据集”分成三类
根据我的经验,可以把强化学习里的数据来源粗略分成三类:
- 在线环境:最常见的一类,也就是Gymnasium、MuJoCo、Atari这些。你调用
env.reset()返回初始状态,调用env.step(action)拿到转移数据,数据是实时产生的。绝大多数入门教程和论文实验都跑在这类环境上。 - 经验回放缓冲区:算法运行过程中主动存储的一批转移数据,比如DQN里的Replay Buffer。它本质上也像个数据集,只不过由算法自己维护,每次训练从里面抽样小批量。
- 离线数据集:预先录制好的转移元组文件,数据格式固定,训练过程中完全不和真实环境交互。典型代表是D4RL的
halfcheetah-medium-v2这类任务。
搞清楚这三类,你再看网上那些教程就不会被绕晕。很多人问“为什么别人的代码里有个数据集下载地址,我却找不到”,多半是把离线数据集和在线环境搞混了。
1.3 选择数据集或环境前,先想清楚这三件事
不管是自己写环境还是选现成的基准,我建议你先回答三个问题:
第一,状态空间是离散还是连续?CartPole的状态是4维连续向量,Atari是像素图像,MuJoCo是几十维的关节角度和速度。状态类型决定了网络输入层的设计,也决定你需不需要做图像预处理。
第二,动作空间是离散还是连续?Atari是离散动作(比如上下左右、开火),MuJoCo和PyBullet都是连续动作(比如关节力矩)。离散动作可以用带softmax的动作头,连续动作一般要输出高斯分布的均值方差,或者用确定性策略网络,处理方式差得非常多。
第三,奖励信号是稠密还是稀疏?CartPole每次step都有奖励,属于稠密;Atari很多游戏只有得分变化时才给奖励,属于稀疏;有些任务干脆只有成功或失败两种结局。奖励结构直接决定你需不需要奖励塑形、课程学习、HER这类额外手段。
我见过太多次这样的情况:新手拿一个稀疏奖励的连续控制任务练手,跑了两周不收敛,然后怀疑是自己的算法写得有问题。其实环境选错了,从一开始训练难度就被拉满了。
2. 最常用的一批强化学习环境和数据集,从入门到进阶
2.1 入门首选:CartPole、MountainCar、Pendulum、Acrobot、LunarLander
这一组是Gymnasium自带的玩具级环境,状态维度低、动作空间简单、单个episode短,非常适合验证算法逻辑是否写对。
- CartPole-v1:状态是4维(小车位置、速度、杆角度、角速度),动作2个(左右推力),目标是保持杆子不倒。
- MountainCar-v0:状态2维(位置和速度),动作3个(向左、不动、向右),目标是翻过山坡。这个环境有一个经典问题:稀疏奖励下探索特别难,适合讲reward shaping。
- Pendulum-v1:状态3维,动作1维连续值,目标是摆到竖直位置,适合入门连续控制。
- Acrobot-v1:状态6维,动作3个离散值,类似双摆,适合测试值函数方法。
- LunarLander-v2:状态8维,动作4个离散值,奖励密度适中有一定挑战,是我推荐新手练DQN、PPO的第一站。
这些环境的好处是训练快、可解释性强,出问题能很快定位。比如训练曲线完全不动,你可以手工算一下最优策略的期望回报大概是多少,就知道算法有没有跑对方向。我自己的习惯是:新写一个RL算法,先在这上面跑通,再上Atari或者MuJoCo。
2.2 标准基准:Atari 2600(Arcade Learning Environment)
Atari 2600游戏是目前强化学习论文中出镜率最高的基准之一。它的特点是输入是原始的屏幕像素,动作是离散的摇杆按钮组合,数据形式非常接近“视觉决策”任务。从DQN开始,Atari就是衡量深度强化学习算法能不能处理高维像素输入、稀疏奖励、长期信度分配的标准场地。
ALE(Arcade Learning Environment)提供了统一的接口,Gymnasium里通过gymnasium.make("ALE/Breakout-v5")这样的方式访问。目前常用的游戏大概有57个,比如Breakout、Pong、SpaceInvaders、Seaquest,每个游戏在动作空间、奖励频率、状态信息量上差别很大。
Atari的坑主要在两点:一是需要手动获取ROM文件,版权原因不能自动下载;二是标准预处理流程对结果影响巨大,灰度化、缩放、跳帧、帧堆叠这些操作几乎是必须的。换句话说,Atari这个“数据集”好不好用,一半取决于你预处理做得对不对。
2.3 连续控制:MuJoCo、PyBullet和DeepMind Control Suite
MuJoCo是连续控制领域的老牌物理引擎,HalfCheetah、Hopper、Walker2d、Ant、Humanoid这些环境都是基于它的。MuJoCo的特点是接触动力学建模比较精确,计算效率高,因此是SAC、TD3、PPO这些连续控制算法论文里的常客。
需要特别说明的是MojoCo的历史包袱。老版本的mujoco-py安装过程非常痛苦,还涉及许可证问题,我早年搭环境的时候在它上面花的时间比写算法还多。2022年后MuJoCo官方开源,新版直接pip install mujoco就能装,不再需要注册号或密钥,Gymnasium也直接注册了对应的环境版本(比如HalfCheetah-v4)。如果你看到网上老教程让你去官网申请许可证,那是旧版本的流程,可以不用理会。
PyBullet是另一个思路,纯Python生态、安装极简单,模型也都内置了,比如pybullet_envs里的KUKA机器人、四足机器人等。它的物理精度比MuJoCo稍粗糙一些,但胜在开源、好上手、模型库丰富。如果你不是要复现论文的精确数值,用PyBullet做初步验证完全够。
DeepMind Control Suite(dm_control)是另一个常用的连续控制基准,特点是物理任务从简单到复杂、统一使用像素输入也能跑。它对算法能否从视觉输入学到连续控制是个很好的考验,SOTA类的视频预测、模型基方法经常拿它做评测。
2.4 更现代的基准:Procgen、MINIGRID和较新的平台
Procgen是一组程序化生成的游戏环境,每次生成的关卡布局都不一样,专门用来测泛化能力。传统Atari环境是固定关卡,智能体可能背板,Procgen避免了这个毛病。如果你研究的是策略泛化、Domain Randomization,这个平台很合适。
MINIGRID是网格世界环境,可以调整地图尺寸、障碍布局,灵活生成部分可观测、稀疏奖励的任务。它特别适合做课程学习、层次强化学习、元强化学习的小规模实验,一张普通显卡就能跑很快。
此外还有NetHack、Crafter这类相对新的环境。Crafter是把Minecraft简化成一个2D生存游戏,奖励稀疏、目标多元,适合评估探索能力和通用智能体,不过对初学者来说可能有点超纲,先放在收藏夹就行。
2.5 离线强化学习专用数据集:D4RL、RL Unplugged、NeoRL
离线数据集是真正意义上“文件即数据集”的形态,我单独把它拎出来说。
- D4RL:目前最常用的离线RL基准,包含基于MuJoCo的HalfCheetah、Hopper、Walker2d等任务,以及AntMaze、Kitchen、Adroit等更复杂的操作类任务。数据保存为HDF5文件,按数据质量分为
random、medium、medium-replay、expert等版本。它的设计初衷是让研究者可以公平比较不同离线RL算法在“固定数据、无环境交互”条件下的表现。 - RL Unplugged:由DeepMind发布的离线数据集集合,覆盖Atari、dm_control、locomotion等,数据量很大,存储格式是TFRecord。它的一个特点是数据由成熟行为策略采集,质量稳定,适合做大规模离线预训练。
- NeoRL:这个数据集相对小众一些,特点是每个任务同时提供低、中、高三种质量的数据,并且带有一个“在线评估”模式,你可以先用离线数据训练,再放到真实环境中评估。对实际落地场景的参考价值比较大,因为它更贴近“先离线学策略,再上线微调”的生产流程。
离线数据集的处理方式和在线环境差别不小,比如要处理终止状态的边界、要避免用全局统计量归一化造成数据泄露,这些我在第4节和第6节会细说。
3. 下载和安装:哪些直接pip装,哪些要单独折腾
3.1 Gymnasium统一入口,大部分入门环境一条命令搞定
现在主流RL代码库基本都迁移到了Gymnasium(Gym的继承维护版),它的安装非常简单:
pip install gymnasium安装完之后就可以创建入门级环境了:
import gymnasium as gym env = gym.make("CartPole-v1") obs, info = env.reset() for _ in range(100): action = env.action_space.sample() # 随机策略 obs, reward, terminated, truncated, info = env.step(action) if terminated or truncated: obs, info = env.reset()注意新版接口里done被拆分成了terminated和truncated,这是很多老代码迁移时报错的重灾区。terminated表示回合因目标达成或失败而结束,truncated表示因步数上限等外部原因被截断。在写训练循环时,两者往往要分开处理,比如DQN里只有terminated才表示真正的终止状态。
如果你需要Atari环境,安装完整依赖集更省心:
pip install gymnasium[atari]3.2 Atari的ROM文件:pip只装了客户端,游戏本体要单独找
这是Atari环境最经典的坑。gymnasium[atari]装的是ALE库和对接层,真正的Atari 2600游戏ROM文件因为版权原因不会自动下载。你直接gym.make("ALE/Breakout-v5")大概率会报错,提示缺少ROM文件。
解决办法是去开源社区找Atari 2600 ROM合集,把ROM文件放到指定目录,默认路径是~/.gym/roms,或者通过环境变量指定。放好后可以用下面这段代码验证:
import gymnasium as gym spec = gym.spec("ALE/Breakout-v5") print(spec) # 如果可以创建环境,说明ROM已经就位 env = gym.make("ALE/Breakout-v5", render_mode="rgb_array") print(env.observation_space, env.action_space)装完ROM之后还有个版本问题。ALE的版本和ROM版本需要匹配,如果你发现某个游戏能跑但其他游戏注册不到,先确认ROM文件是否齐全、文件名是否符合ALE的命名规则。文件名不对或者放错子目录,ALE会直接当成没有ROM处理,连报错都不太直观。
3.3 MuJoCo:新版本已经开源,别再被旧教程带偏
新版MuJoCo的安装路径非常干净:
pip install mujoco装好之后,Gymnasium的MuJoCo环境可以直接用:
import gymnasium as gym env = gym.make("HalfCheetah-v4") print(env.observation_space.shape) # (17,) print(env.action_space.shape) # (6,)新版不需要许可证、不需要手动放模型文件。但如果你是从旧教程复制来的命令,看到pip install mujoco-py,就要注意了:mujoco-py是老版本封装,安装时要编译C++源码,还需要额外配置LD_LIBRARY_PATH,我建议新项目一律不用它。
另外有个细节,Gymnasium注册的MuJoCo环境有版本后缀,比如HalfCheetah-v4、Hopper-v4,不同版本之间奖励函数细节略微不同,复现论文时一定看清对方用的是v3还是v4,别把版本号抄串了。
3.4 离线数据集D4RL如何下载和读取
D4RL的数据不是通过pip安装的,要手动去官方发布页下载对应任务的zip压缩包,解压后得到.hdf5文件。下载链接和数据规模在README里写得很清楚,比如halfcheetah_medium的数据集大小通常在几十MB到几百MB不等。
下载之后,建议把数据文件组织成这样的目录结构:
d4rl_data/ ├── halfcheetah_medium_v2.hdf5 ├── halfcheetah_expert_v2.hdf5 └── hopper_medium_v2.hdf5D4RL库本身可以pip安装,但新版D4RL推荐使用Mujoco环境,需要确保MuJoCo版本兼容。读取数据的典型方式:
import h5py import numpy as np with h5py.File("halfcheetah_medium_v2.hdf5", "r") as f: print(list(f.keys())) # ['actions', 'observations', 'rewards', 'terminals', 'timeouts'] actions = f["actions"][:] observations = f["observations"][:] rewards = f["rewards"][:] terminals = f["terminals"][:] timeouts = f["timeouts"][:]很多代码库会直接使用D4RL的Python接口:
import d4rl import gymnasium as gym env = gym.make("halfcheetah-medium-v2") dataset = d4rl.qlearning_dataset(env) # dataset是字典,包含observations、actions、next_observations、rewards、terminals这里有个小知识点:qlearning_dataset会把原始数据转换成标准的“状态-动作-奖励-下一状态-终止标志”格式,方便直接用高抛Q学习类算法训练。如果不想依赖D4RL接口,直接用h5py手动读取再组装也可以,效果完全一样。
3.5 RL Unplugged和NeoRL的下载方式
RL Unplugged的数据集主要托管在TensorFlow Datasets上,加载方式通常是这样的:
import tensorflow_datasets as tfds dataset = tfds.load("rlu_atari/run_1", split="train") for example in dataset.take(5): # 每条示例包含observation、action、reward、discount等字段 print(example.keys())如果你只是做小规模验证,建议先下载其中一个子集,因为RL Unplugged全量数据非常大,全下载会占用大量磁盘空间。
NeoRL则需要去项目主页下载对应任务的数据包,同样是按任务名区分。它会把offline_data里的文件按低中高质量分别存放,训练的时候按需加载。这种设计有一个好处:你可以直观对比同一个任务在不同数据质量下的表现差异,非常适合写分析型报告做实验对比。
4. 数据处理才是重头戏:观测、动作、奖励的预处理
4.1 统一接口:reset、step、observation_space、action_space
不管什么环境,Gymnasium风格的接口都可以抽象成几个固定操作。env.reset()返回初始观测和额外信息;env.step(action)返回四元组(obs, reward, terminated, truncated, info);env.observation_space和env.action_space分别是两个Space对象。
理解Space类型是预处理的第一步。观测空间常见的是Box(low, high, shape, dtype),比如CartPole的Box(-inf, inf, (4,), float32);Atari的观测空间则是Box(0, 255, (210, 160, 3), uint8)。动作空间常见的是Discrete(n)或者Box(low, high, shape, dtype)。代码里拿到space.shape和space.dtype,就能判断出后面该走图像处理还是向量归一化。
4.2 观测空间处理:灰度化、缩放、归一化、帧堆叠
对于像素输入的Atari环境,标准预处理流程基本是这样一个流水线:
- 取原始RGB帧,尺寸一般是(210,160,3),dtype是uint8。
- 灰度化,把通道数从3降到1,减少无用颜色信息,降低计算量。
- 缩放到84x84或者96x96,统一输入尺寸。
- 跳帧加最多像素保留,也就是把连续几帧里变化最大的像素作为当前帧,这样一个step就能感知运动趋势,同时变相缩短了序列长度。
- 帧堆叠,把最近4帧按通道维叠成一个(4,84,84)的输入,相当于给网络提供了短时运动信息。
- 归一化,一般除以255把像素值变到[0,1]区间,让CNN输入分布更稳定。
每一步都有它的道理。灰度化和缩放纯粹为了省计算量;跳帧和帧堆叠是互补的,跳帧降低时间分辨率踩着游戏的真实帧同步,帧堆叠又把历史信息还给网络,让单帧输入变成“短时动态切片”;归一化则是为了让网络初始化时的梯度尺度更合理。
注意一个细节:Atari原始帧dtype是uint8,范围[0,255]。如果你忘除以255,直接把uint8喂给网络,图像的值域太大,第一层CNN输出的激活值会偏高,很容易导致训练前期不稳定。反过来,如果你在日志里看到obs的min/max是-1和1,说明有人做了别的normalize方案,这在PyBullet图像输入类环境里也常见,并不算错,关键是训练和测试时保持一致。
对于向量状态,比如MuJoCo的关节角度、速度,推荐做法是用在线的滚动统计量做标准化(减去均值除以标准差)。但强化学习里这个标准化和监督学习有个重要区别:均值方差会随着策略变化而变化。所以实际工程里,要么用运行时刻的running mean/std(比如Stable-Baselines3的VecNormalize就是这种思路),要么干脆不归一化,让算法自己适应,有些算法的确对尺度敏感,不归一化训练会很痛苦。
4.3 动作空间处理:离散动作和连续动作各有各的坑
离散动作空间一般只需要做整数索引映射。网络输出每个动作的logits,通过argmax或者softmax采样得到action int,直接传给env.step(action)。有个小坑是很多环境允许的动作范围并不从0开始,比如Atari有些游戏允许NOOP、分别对应不同编号,你要确认env.action_space.n后再设计动作头输出维度。
连续动作空间问题更多。环境里的动作范围往往是对称区间,比如MuJoCo一般是[-1,1]或者以某个力矩范围为界。网络输出的是无约束的实数,不能直接拿来当动作,通常的做法是按照范围clip到边界:
import numpy as np low = env.action_space.low high = env.action_space.high action = model_output_np # shape = (action_dim,) action = np.clip(action, low, high)更精细的做法是使用tanh输出或高斯策略,先在网络内部把输出限制到[-1,1],再通过low + (high - low) * (action + 1) / 2的反变换映射到实际范围。SAC和TD3都常用这个trick。需要注意,动作缩放和反变换只在训练和环境中来回映射时使用,存储到回放缓冲区时最好存缩放后的原始动作还是环境实际动作,要看后续算法怎么用,最常见的做法是缓冲区里存实际传给环境的动作值。
4.4 奖励处理:clip、scale、discount各司其职
奖励处理是最容易被忽略、又最影响训练效果的一环。
奖励裁剪(reward clipping)把超出一定范围的奖励强行拉到边界上。DQN在Atari上的原始实现会把正奖励clip到1,负奖励clip到-1,也就是每个step只给-1、0、1三种反馈。这样做的好处是限制Q值估计的尺度,训练更稳定;坏处是丢失了奖励大小信息,但对很多Atari游戏来说,奖励大小本身没那么重要,方向比大小更有意义。
奖励缩放(reward scaling)是另一种思路,将奖励乘一个固定系数,让累计回报的尺度适合优化器的学习率。PPO、SAC这类算法对奖励尺度的敏感度比DQN低一些,但也不是完全免疫。实际经验是:如果训练初期观察到loss爆炸或Q值迅速发散,优先检查reward的尺度是不是太大。
折扣因子决定多远的未来回报影响当前决策。折扣因子越接近1表示越有远见,但也会让训练方差变大。MuJoCo环境一般默认使用0.99,Atari默认0.99,部分稀疏奖励任务会用到0.999。
很多新手会问:可不可以直接不改奖励就开始训练?答案是可以,但不改奖励往往需要配合调其他超参数。我的经验是,在你还没把学习率、网络宽度、采样数量这些都稳定下来之前,先把奖励处理固定成一套保守方案,比如rew scale=1或者clip到[-1,1],可以有效减少变量数量。
4.5 用Gymnasium Wrapper组合一套标准预处理流水线
Gymnasium预置了很多现成Wrapper,比如GrayScaleObservation、ResizeObservation、FrameStack、TransformReward、ClipReward、TimeLimit。你可以用gym.wrappers把它们组合起来:
import gymnasium as gym from gymnasium.wrappers import ( GrayScaleObservation, ResizeObservation, FrameStack, TransformReward, ) def make_atari_env(env_id="ALE/Breakout-v5"): env = gym.make(env_id, render_mode="rgb_array") env = GrayScaleObservation(env) env = ResizeObservation(env, (84, 84)) env = FrameStack(env, num_stack=4) env = TransformReward(env, lambda r: r / 100.0) # 示例,看情况调整 return env env = make_atari_env() obs, info = env.reset() print(obs.shape) # 如果是FrameStack,输出(4, 84, 84),或(C, H, W)格式注意Wrapper顺序很重要。先灰度再缩放,最后帧堆叠,这是最常规的顺序。如果你先FrameStack再做单帧缩放,缩放会分散到多帧上,逻辑上没错但是多了一些重复计算;如果先缩放再灰度,又浪费了计算在原始分辨率上。顺序不对不至于崩溃,但会影响效率和帧组合语义。
另外提醒一下,FrameStack返回的观测对象通常是一个LazyFrames容器,它不会真的复制数据,而是在内部保存最近N帧的引用,直到被转成numpy数组才进行拼接。这在内存上很友好,但如果你习惯用obs.shape判断数组布局然后直接改obs[0],就会踩坑,最好先np.array(obs)再操作。
5. 实操记录:从零搭建一个原始像素输入的DQN训练管线
5.1 环境初始化:用Wrapper组装训练环境
下面是一个更完整的Atari环境制造函数,我平时做DQN实验基本直接复用这套:
import gymnasium as gym from gymnasium.wrappers import AtariPreprocessing, FrameStack def make_dqn_atari_env(env_id, seed=0): env = gym.make(env_id, render_mode="rgb_array") env = AtariPreprocessing( env, noop_max=30, frame_skip=4, terminal_on_life_loss=False, grayscale_obs=True, grayscale_new_shape=(84, 84), screen_size=84, ) env = FrameStack(env, num_stack=4) env.action_space.seed(seed) return envAtariPreprocessing是Gymnasium自带的浓缩版Atari预处理,把灰度化、缩放、跳帧、NOOP随机重置都打包了。terminal_on_life_loss这个参数值得单独说明一下:设为True时,智能体每失去一条命就当成一次终止状态,这是DQN论文里用的常见技巧,它能让智能体更密集地学习到“死亡”这个负反馈;但设为True也会改变数据分布,对某些游戏有效果,对某些游戏反而有害,建议不同游戏分开实验。
FrameStack的4帧堆叠让单帧图像带上运动信息,CNN可以在空间特征之外,额外利用相邻帧的差异,这就是Atari环境能玩出Pong弹球轨迹的原因。
5.2 踩过的经典坑:观测形状不匹配、dtype不对、归一化缺失
我第一版环境组装函数踩的坑特别值得一说。
第一个坑是AtariPreprocessing的输入输出shape。原始Atari观测是(210,160,3),grayscale_new_shape=(84,84)之后输出是(84,84,1),但FrameStack之后堆叠维度默认在最前面,即最终形状是(4,84,84),不是(84,84,4)。如果你按PyTorch习惯,以为通道在最后一维,直接obs.transpose(1,2,0)之后送入CNN,维度匹配会报错。代码里要把channel维固定放在第二维(channel, height, width)或者转换成自己的习惯布局,不要凭直觉。
第二个坑是dtype。AtariPreprocessing的灰度帧是uint8,FrameStack返回的LazyFrames在转numpy后依旧是uint8。DQN网络第一层输入通常要求float,归一化时要显式转成float32再除以255:
obs = np.array(obs, dtype=np.float32) / 255.0如果漏了这个转换,网络输入是uint8,PyTorch的卷积层不会报错,但数值范围太大,训练loss就会出现诡异的波动。
第三个坑是归一化被Wrapper挡住。如果直接用Wrapper组成的env取obs,这个obs还是[0,255]的uint8,只有在网络输入前做归一化才算完整。我习惯把归一化放在网络forward函数内部,这样donnees从环境到网络的过程统一在一处处理。
5.3 完整的训练管线参考:环境向量化、采样、训练三步走
为了说明数据流怎么走通,我给你一个简化的训练循环骨架。这个骨架不涉及具体算法细节,但把数据的每一步流向标得很清楚:
import numpy as np import torch def collect_trajectory(env, policy, max_steps=1000): obs, info = env.reset() obs = np.array(obs, dtype=np.float32) trajectory = [] for step_idx in range(max_steps): action = policy.get_action(obs) # 返回int或float数组 next_obs, reward, terminated, truncated, info = env.step(action) next_obs = np.array(next_obs, dtype=np.float32) # 这里可追加归一化等其他转换 trajectory.append({ "obs": obs, "action": action, "reward": reward, "next_obs": next_obs, "terminated": terminated, "truncated": truncated, }) obs = next_obs if terminated or truncated: break return trajectory注意terminated和truncated的区别:如果是truncated(比如步数超限),下一状态的Q值不应该被置为0,因为环境并没有真正结束,智能体还有继续操作的机会;如果是terminated(比如游戏失败),下一状态的Q值应该当作0处理。很多DQN实现里用一个done = terminated or truncated就完事了,这在步数上限极短的任务里会带来偏差。
5.4 收集完数据先别急着训练,看一眼统计量
跑第一次实验的时候,我建议先用随机策略走几十个episode,把数据的统计量打印一遍。重点看三样东西:
- 观测维度和取值范围:确认obs配备归一化后范围稳定在0~1。
- 奖励统计:均值、最大、最小,判断是否存在巨大离群值。
- epside长度分布:如果episode整体极短或者极长,说明奖励结构可能和自己预期不一致。
这一步的花费很小,但能省下以后调参的大量时间。我自己吃过亏:有一段代码把奖励漏了clip,结果训练曲线完全飞掉,花了三天排查才发现是最开始数据统计这关没守住。
6. 常见问题与排查实录
6.1 训练不收敛,先查数据处理而不是网络结构
这是我在各种RL项目里反复看到的共性问题。网络结构不对、优化器选错这些确实是影响因素,但更多的基础案例是数据层面出了问题:
- 奖励范围过大,Q值发散。
- 观测没有归一化,输入尺度差异大。
- 忘记处理truncated,导致目标值计算错误。
- 离线数据集里的
timeouts没有正确区分,错误的把时间截断当成终止状态。 - 状态、动作、奖励的dtype存储混乱,训练时隐式转换产生了精度损失。
排查顺序我一般建议:先打印一个批次的obs、action、reward、next_obs的shape和统计量,然后检查target计算是否把truncated排除掉,再看loss曲线发散的形态。都没问题再去折腾网络结构。
6.2 环境安装、依赖、版本兼容经典坑
我把这几年遇到最多的安装类问题汇总成一张表:
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
gym.make报Unknown Env | 包名写错,或没装对应extra | 检查环境ID,比如CartPole-v1;安装gymnasium[atari]等对应依赖 |
| Atari环境提示缺少ROM | ROM文件没放对位置,或文件名不匹配 | 把ROM放到~/.gym/roms,检查文件名与ALE命名规则一致 |
MuJoCo报mujoco_py相关异常 | 装了旧版mujoco-py | 卸载老包,改用pip install mujoco,确认环境ID是-v3或-v4 |
done维度不匹配 | 新版Gymnasium把done拆成terminated和truncated | 按新接口解包,老代码需要适配 |
| 离线数据集读出来是h5py对象 | D4RL返回原始HDF5字段 | 手动组装或使用d4rl.qlearning_dataset(env)转换 |
| 训练时obs与网络输入维度不匹配 | 忘记看FrameStack的维度顺序 | 打印obs.shape,把维度转换成自己网络要求的格式 |
6.3 离线数据集的特殊坑:normalize、terminal、trajectory边界
离线数据集比在线环境多了几个专属坑。
第一,归一化时的数据泄露。如果你用整个离线数据集计算均值、标准差,再把这个统计量用于归一化,然后还是用同一份数据训练,那就引入了微小的“训练数据信息”,严格来说会让评估结果乐观一点。虽然在线RL没人追究,但做离线RL论文实验时,最好用一个固定的baseline统计量,或者基于行为策略采样的一小部分数据计算,而不是全量数据。
第二,timeouts和terminals的边界问题。D4RL数据里同时有terminals和timeouts两个字段,前者表示真正任务结束,后者表示只是步数到了上限。离线RL算法里,时间截断位置并不代表状态真正“终止”,下一状态的Q值不能置为0。很多初版实现直接把两者合并成done = terminals | timeouts,这会让TD算法出现系统性偏差。正确做法是只在terminals为True的地方把目标设成奖励本身,timeouts处正常走bootstrapping。
第三,数据量分布和轨迹切分。不同质量的离线数据,episode数量差异很大,expert数据可能只有几百条轨迹,random数据可能有几千条。如果直接按transition抽batch,容易让训练偏向数据量大的质量类型,做策略分析时要按episode分层或者对轨迹ID做限制。
6.4 快速诊断表:拿到一个不work的实验该查哪里
| 检查项 | 快速验证方法 |
|---|---|
| 观测是否归一化 | 打印obs.min()和obs.max(),确认在预期范围 |
| 奖励是否有离群值 | 打印reward的max和mean,看数量级 |
| terminated/truncated是否区分 | 统计两类事件的占比,确认truncated没有被直接当终止 |
| 回放数据的next_obs是否对应 | 随机抽几条transition,人工比对obs和next_obs是否合理 |
| 离线数据的质量分布 | 打印每条轨迹的平均回报,观察是否有异常轨迹 |
| 环境是否正常重置 | 连续重置两次episode,对比初始obs是否一致 |
我自己跑实验有一个习惯:把上面这张表贴在工位旁边,每次实验失败先按顺序自查一遍。很多时候你以为是自己算法创新点的问题,其实只是某个小数据处理步骤漏了。
离线RL里还有一个常用的检查点:训练完的策略在离线数据上的回报均值,能不能接近数据集本身的最优轨迹均值。如果差得远,说明策略没有充分拟合数据分布,这时候先别急着加正则化项,优先检查数据是否被正确处理了。
最后分享一个小技巧:如果你不确定某个Wrapper到底做了什么处理,可以先构造一个假环境,用随机动作跑几个step,在每个Wrapper之间打印obs的shape、dtype、值范围。这个方法看起来笨,但在调试复杂数据处理链路时比看文档有效得多。我每次给环境加新Wrapper,都会先这样做一轮“数据水管检查”,确认每段管子流出来的数据都在预期内,再跑训练循环。