0. 前言

我们已经深入探讨了强化学习 (Reinforcement Learning, RL) 的理论概念,接下来,我们进入实践环节。在本节中,将介绍 Gymnasium 库的基础知识,该库为 RL 智能体提供统一 API 接口,并集成了大量 RL 环境。这套 API 最初由 OpenAI Gym 库实现,但现已停止维护。在本专栏中,我们将使用 Gymnasium——这是 OpenAI Gym 的一个分支,完全兼容原 API。统一环境 API 的价值在于:它能消除模板代码的编写需求,以通用方式实现智能体,而无需关注环境细节。本节中,还将实现随机动作智能体,并深化对 RL 基础概念的理解。

1. 智能体的构成要素

强化学习 (Reinforcement Learning, RL) 一节中,我们已经学习了 RL 中的几个基本概念:

  • 智能体:采取主动动作的实体。实践中,智能体是执行某种策略的代码模块,该策略根据观测数据决定每个时间步的动作
  • 环境:智能体外部的一切,负责提供观测值和奖励信号。环境状态会随智能体动作而改变

接下来,使用 Python 实现这两个概念,定义一个在有限步数内(无论智能体采取何种动作)随机给予奖励的环境。这种设定虽无实际用途,但能让我们聚焦环境和智能体类的特定方法。

(1) 从环境实现开始:

class Environment:
    def __init__(self):
        self.steps_left = 10

在以上代码中,我们允许环境初始化其内部状态。在本节中,状态只是一个计数器,用于限制智能体与环境交互的时间步数。

(2) get_observation() 方法的作用是向智能体返回当前环境观测值。该方法通常作为环境内部状态的某种函数实现:

    def get_observation(self) -> List[float]:
        return [0.0, 0.0, 0.0]

-> List[float]Python 3.5 引入的类型注解示例。在我们的例子中,观察向量始终是零,因为环境基本上没有内部状态。

(3) get_actions() 方法允许智能体查询可执行的动作集合:

    def get_actions(self) -> List[int]:
        return [0, 1]

通常情况下,动作集合不会随时间改变,但在不同状态下某些动作可能无法执行(例如井字棋游戏中,并非所有位置都能进行任意移动)。在本节示例中,智能体仅能执行两个动作,分别用整数 01 编码。

(4) is_done() 方法用于向智能体发送回合结束信号:

    def is_done(self) -> bool:
        return self.steps_left == 0

环境与智能体的交互过程被划分为称为"回合" (episode) 的步骤序列。回合可以是有限的(如国际象棋对局),也可以是无限的(如旅行者2号任务——这个著名的太空探测器自46年前发射以来已飞越太阳系)。为涵盖这两种情况,环境提供了检测回合何时结束的机制,当回合终止时,智能体将无法再与环境交互。

(5) action()方法是环境功能的核心组件:

    def action(self, action: int) -> float:
        if self.is_done():
            raise Exception("Game is over")
        self.steps_left -= 1
        return random.random()

该方法主要执行两个关键操作:处理智能体动作并返回对应奖励值。在本节中,奖励值随机生成且不处理具体动作。同时,我们会更新步数计数器,并在回合终止后停止交互。

(6) 相比之下,智能体的实现更为简洁,仅包含构造函数和环境单步执行方法:

class Agent:
    def __init__(self):
        self.total_reward = 0.0

(7) 在构造函数中,初始化了用于累计回合总奖励的计数器。step()函数接受环境实例作为参数:

    def step(self, env: Environment):
        current_obs = env.get_observation()
        actions = env.get_actions()
        reward = env.action(random.choice(actions))
        self.total_reward += reward

该函数使智能体能够执行以下操作:

  • 观测环境状态
  • 根据观测结果决策要执行的动作
  • 将选定动作提交至环境
  • 获取当前步骤的奖励值

(8) 在本节示例中,智能体较为简单,在决策过程中会忽略所有观测数据,仅随机选择动作。最后,实例化这两个类并运行一个完整回合:

if __name__ == "__main__":
    env = Environment()
    agent = Agent()

    while not env.is_done():
        agent.step(env)

    print("Total reward got: %.4f" % agent.total_reward)

多次运行将获得智能体收集的不同奖励值,运行结果如下所示:

Total reward got: 4.3004

以上代码简洁的展示了 RL 模型的基本重要概念。虽然实际应用中,环境可能是极其复杂的物理模型,智能体也可能是实现最新强化学习算法的大型神经网络 (Neural Network, NN),但其基本模式始终如一:在每一步,智能体会从环境中获取观测数据,经过计算后选择执行动作,最终获得相应奖励和新的观测值。

既然模式固定,为何还要从零开始编写?是否存在现成的库可以直接调用?虽然确实存在这样的框架,但在讨论具体工具前,我们先配置开发环境。本专栏代码将使用前文提到的 Python 类型注解功能,这能为函数和类方法提供类型签名,但不使用类型注解功能并不影响代码的运行。

2. 机器学习库

当前机器学习库生态丰富,但本专栏尽量精简依赖项,优先采用自主实现而非直接引入第三方库。我们将使用以下开源工具:

  • NumPy:科学计算库,提供矩阵运算和常用函数
  • OpenCV:计算机视觉库,含丰富图像处理功能
  • GymnasiumOpenAI Gym 的维护分支,提供标准化接口的各类RL环境
  • PyTorch:灵活高效的深度学习框架
  • PyTorch Ignite:构建于 PyTorch 之上的高级工具集,用于减少模板代码

其他专用库将用于特定内容:例如 Microsoft TextWorld (文字游戏)、PyBulletMuJoCo (机器人仿真)、Selenium (浏览器自动化)等。相关内容会包含对应库的安装指南。
本专栏聚焦于前沿深度强化学习方法,“深度”意味着深度学习 (Deep Learning, DL) 方法的应用。以下是本专栏使用的依赖库清单:

  • gymnasium[atari]
  • gymnasium[classic-control]
  • gymnasium[accept-rom-license]
  • numpy
  • opencv-python
  • torch
  • torchvision
  • pytorch-ignite
  • tensorboard

接下来,我们深入解析 Gymnasium 的细节,该框架提供了从简单到复杂的丰富环境库。

3. OpenAI Gym API 和 Gymnasium

OpenAI 开发的 PythonGym2017 年发布首个版本,其 API 已成为强化学习领域事实标准,大量环境被开发或适配至此框架。2021 年,原开发团队将项目迁移至 Gymnasium,这是原 Gym 库的分支版本。Gymnasium 完全兼容原 API,可实现"无缝替换"(只需使用 import gymnasium as gym,绝大多数代码无需修改即可运行)。
Gym 的核心目标是通过统一接口为 RL 实验提供丰富环境集合。因此,库中的核心类是环境 (Env),该类的实例通过以下方法和字段提供标准化功能:

  • 动作空间:支持离散/连续动作及其组合。
  • 观测空间:明确观测值的维度结构与数值范围。
  • step 方法,执行动作后返回当前观察、奖励以及指示回合是否结束的标志。
  • reset 方法,将环境恢复初始状态并返回首个观测值

接下来,我们将详细讨论环境的这些核心组件。

3.1 动作空间

智能体可执行的动作可分为离散型、连续型或混合型:

  • 离散动作是一组智能体可以执行的固定动作,例如网格环境中的移动方向(左/右/上/下),或是按钮的按压/释放。这类动作空间的核心特征是互斥性,在该空间中,任何时刻只能执行一个来自有限动作集合的动作

  • 连续动作是一个关联在指定范围内数值的动作。例如,方向盘可以以特定角度转动,或加速踏板可以以不同的力度踩下。连续动作的描述包括该动作可能具有的值的边界。例如,方向盘的范围可能是 -720 度到 720 度,而加速踏板的范围通常是从 01

实际场景中,环境往往可以接受多个动作,例如同时按下多个按钮,或同时转动方向盘并踩下刹车/加速。为此,Gymnasium 专门设计了容器类,支持将多个动作空间嵌套为统一动作。

3.2 观测空间

观测值是环境除奖励外,在每个时间步提供给智能体的信息。观测可以是简单数值集合,也可以是多维张量(如多摄像头彩色图像)。类似于动作空间,观测也可以是离散的。离散观测空间的一个例子是灯泡,它有两种状态——开或关,通常以布尔值的形式提供。
观测空间与动作空间在 Gymnasium 中采用相似的设计范式,具体类结构如下图所示:

观测空间

基本的抽象空间类 (Space) 包括一个属性和三个相关方法:

  • shape:此属性包含空间的形状,与 NumPy 数组相同
  • sample():此方法返回空间中的随机样本
  • contains(x):检查参数 x 是否属于该空间的定义域
  • seed():该方法允许我们为空间及其所有子空间初始化随机数生成器。这在需要多次运行中获得可重现的环境行为时非常有用

这些方法均为抽象方法,并在每个 Space 子类中重新实现:

  • Discrete 类表示一组互斥的项,编号从 0n-1。如果需要,可以通过可选的构造函数参数 start 重新定义起始索引。值 n 表示该 Discrete 对象描述的项的数量。例如,Discrete(n=4) 可用于表示四个移动方向的动作空间(左、右、上或下)
  • Box 类表示一个有理数的 n 维张量,其取值范围为 [low, high]。例如,一个取值范围在 0.01.0 之间的油门踏板值可以用 Box(low=0.0, high=1.0, shape=(1,), dtype=np.float32) 表示。其中,shape 参数是一个长度为 1 的元组,本节中值为 1,表示一个包含单个值的一维张量。dtype 参数指定空间的值类型,此处指定为 NumPy 32 位浮点数。另一个 Box 的例子是 Atari 屏幕观测,一个大小为 210×160RGB (红、绿、蓝)图像可以用 Box(low=0, high=255, shape=(210, 160, 3), dtype=np.uint8) 表示。在这种情况下,shape 参数是一个包含三个元素的元组:第一个维度是图像的高度,第二个是宽度,第三个是通道维度为 3,分别对应红、绿、蓝三个颜色通道,因此,总的来说,每个观测结果是一个三维张量,占用 100800 字节
  • Tuple 类也是 Space 的一个子类,它允许我们将多个 Space 类的实例组合在一起。可以用于创建任何复杂度的动作和观测空间。例如,假设为一辆车创建一个动作空间规范。汽车在每个时间戳可以改变多个控制参数,包括方向盘角度、刹车踏板位置和油门踏板位置。这三个控制参数可以通过一个 Box 实例中的三个浮点值来指定。除了这些基本控制参数外,汽车还有额外的离散控制参数,比如转向灯(关闭、右转或左转)或喇叭(开启或关闭)。为了将所有这些内容组合成一个动作空间规范类,可以使用以下代码:
    Tuple(spaces=( 
        Box(low=-1.0, high=1.0, shape=(3,), dtype=np.float32), 
        Discrete(n=3), 
        Discrete(n=2) 
    ))
    
    这种灵活性很少使用,只会用到 BoxDiscrete 类型的动作空间和观察空间,但 Tuple 类在某些情况下可能会派上用场。

Gymnasium 中还定义了其他 Space 的子类,例如 Sequence (表示可变长度的序列)、Text (字符串)和 Graph (由一组节点及其连接构成的空间)。但 BoxDiscreteTuple 是最常用的。

每个环境都有两个 Space 类型的成员:action_spaceobservation_space。这使得我们可以编写适用于任何环境的通用代码。当然,处理屏幕像素与处理离散观测值是不同的(在前者的情况下,我们可能需要使用卷积或计算机视觉中的其他方法对图像进行预处理)。因此,大多数时候,这意味着我们需要针对特定环境或环境组优化代码,但 Gymnasium 并不妨碍我们编写通用代码。

3.3 环境

Gymnasium 中,环境由 Env 类表示,该类包含以下成员:

  • action_spaceSpace 类的字段,提供环境中允许执行的动作
  • observation_space:同样属于 Space 类的字段,规定了环境提供的观测数据
  • reset():将环境重置为初始状态,返回初始观测向量及一个包含额外环境信息的字典
  • step():该方法让智能体执行动作,并返回动作执行后的相关信息,包括:
    • 下一个观测值
    • 即时奖励
    • 回合结束标志
    • 回合截断标志
    • 包含额外环境信息的字典

Env 类还包含其他实用方法,例如 render() (以人类可读的形式呈现观测数据)。我们可以在 Gymnasium 的文档中找到完整列表,但我们将重点关注核心方法:reset()step()
由于 reset() 方法较为简单,我们先介绍它。reset() 方法无需参数,其作用是让环境重置至初始状态并获取初始观测值。需要注意的是,在创建环境后必须调用 reset() 方法。智能体与环境的交互可能有终点(如“游戏结束”画面),这类交互过程称为回合 (episode)。回合结束后,智能体需重新开始。该方法的返回值是环境的第一个观测值。除了观测值,reset() 还会返回第二个值——一个包含环境特定信息的字典。大多数标准环境不会在该字典中返回内容,但更复杂的环境(如后续会介绍的文本游戏模拟器 TextWorld)可能返回无法纳入标准观测的额外信息。
step() 方法是环境功能的核心。它在一次调用中完成以下操作:

  • 告知环境下一步要执行的动作
  • 获取执行动作后的新观测值
  • 获取智能体通过此步骤获得的即时奖励
  • 获取回合结束标志
  • 获取回合截断标志(例如启用时间限制时)
  • 获取包含额外环境信息的字典

其中第一个项(动作)作为 step() 的唯一参数传入,其余项由该方法返回。具体而言,返回的是一个包含五个元素的 Python 元组:(observation, reward, done, truncated, info),其结构与含义如下:

  • observationNumPy 向量或矩阵形式的观测数据
  • reward:浮点数类型的奖励值
  • done:布尔标志,若为 True 表示回合结束。如果该值为 True,我们必须在环境中调用 reset(),因为不再执行任何动作
  • truncated:布尔标志,若为 True 表示回合被截断。多数情况下由时间限制 (TimeLimit) 触发,但在某些环境中可能有其他含义。该标志与 done 分离,因为某些场景需区分“智能体完成回合”和“环境达到时间上限”。若 truncatedTrue,同样需调用 reset()
  • info:包含环境特定信息的字典,在一般的强化学习方法中通常做法是忽略这个值

我们已经了解了在智能体代码中使用环境的方式:在循环中调用 step() 执行动作,直到 donetruncated 变为 True,然后调用 reset() 重新开始。接下来,介绍如何创建 Env 对象。

3.4 创建环境

每个环境都有一个唯一的名称,格式为 EnvironmentName-vN,其中 N 用来区分同一环境不同版本的编号。为了创建一个环境,gymnasium 包提供了 make(name) 函数,唯一的参数是字符串形式的环境名称。
Gymnasium 包含了大量具有不同名称的环境。当然,并非所有环境都是独特的,因为 Gymnasium 包含了同一环境的所有版本。此外,同一环境可能因设置和观测空间的不同而有多种变体。例如,经典的 Atari 游戏 Breakout 有以下环境名称:

  • Breakout-v0, Breakout-v4:原始的 Breakout 游戏,球的位置和方向是随机的
  • BreakoutDeterministic-v0, BreakoutDeterministic-v4:球的初始位置和速度向量固定
  • BreakoutNoFrameskip-v0, BreakoutNoFrameskip-v4:智能体接收每一帧画面(默认情况下,每个动作会持续多帧)
  • Breakout-ram-v0, Breakout-ram-v4:观测数据为完整的 Atari 仿真内存 (128 字节),而非屏幕像素
  • Breakout-ramDeterministic-v0, Breakout-ramDeterministic-v4:固定初始状态的内存观测版本
  • Breakout-ramNoFrameskip-v0, Breakout-ramNoFrameskip-v4:智能体接收每一帧画面的内存观测版本

仅这一款游戏就有 12 种环境变体,下图是该游戏画面示例:

Breakout

即使移除重复的环境,Gymnasium 仍然提供了多个独特环境,这些环境可分为以下几大类:

  • 经典控制问题:这些任务常用于最优控制理论和强化学习的基准测试或演示。它们通常结构简单,具有低维度的观测和动作空间,但非常适合快速验证算法实现,可以视为强化学习领域的 MNIST
  • Atari 2600:包含 631970 年代经典游戏平台上的游戏
  • 算法问题:专注于执行简单计算任务的环境,如序列复制或数字相加等
  • Box2D:使用 Box2D 物理模拟器来学习行走或车辆控制的环境
  • MuJoCo:用于解决多种连续控制问题的物理模拟器
  • 参数调优:应用强化学习来优化神经网络参数的环境
  • 简单文本:简单的网格世界文本环境

实际上,支持 Gymnasium API 的强化学习环境总数远不止于此。例如,Farama 基金会就维护着多个专注于特定强化学习主题的代码库,涵盖多智能体强化学习、3D 导航、机器人技术和网络自动化等领域,此外还有大量第三方环境库。
接下来,我们学习如何在 Python 环境中实际操作 Gymnasium 环境。

4. The CartPole 示例

接下来,应用我们所学的知识,探索 Gymnasium 提供的最简单的 RL 环境之一:CartPole

import gymnasium as gym
env = gym.make("CartPole-v1")

导入 gymnasium 包,并创建一个名为 CartPole 的环境。该环境属于经典控制类问题,其核心在于控制底部连接着杆子的移动小车(如下图所示)。难点在于这根杆子容易向左右倾斜,需要通过每一步移动平台来保持平衡。

CartPole-v1

该环境的观测值由四个浮点数组成:杆子质心的 x 坐标、移动速度、杆子与小车的夹角以及杆子的角速度。虽然运用数学和物理知识可以轻松将这些数值转换为平衡杆子的动作,但我们的问题是:如何在不知道观测值具体含义、仅通过获取奖励的情况下,学会平衡这个系统。该环境在每个时间步提供的固定奖励为 1,当杆子倒下时,回合结束,因此要获得更高累计奖励,就必须通过小车移动来防止杆子倾倒。
重置环境并获取初始观测值(新建环境必须首先执行重置操作),如前所述,观测值由四个数字构成:

obs, info = env.reset()
print(obs) # [ 0.02318072  0.04177851 -0.01873218 -0.04824536]
print(info) #{}

接下来,检查环境的动作空间和观测空间:

print(env.action_space) # Discrete(2)
print(env.observation_space) # Box([-4.8 -inf -0.41887903 -inf], [4.8 inf 0.41887903 inf], (4,), float32)

动作空间 (action_space) 属于 Discrete 类型,因此可选动作仅为 010 表示向左推动平台,1 表示向右推动。观测空间则是 Box(4,) 类型,即由四个数值构成的向量。observation_space 字段显示的首个列表是各参数下限,第二个列表为上限。
CartPole 类的文档字符串详细说明了观测值语义:

  • 小车位置:取值范围 -4.84.8
  • 小车速度:取值范围 − ∞ -\infty ∞ \infty
  • 杆子角度:弧度制,取值 -0.4180.418
  • 杆子角速度:取值范围 − ∞ -\infty ∞ \infty

Python 使用 float32 类型的最大最小值表示无穷,因此边界向量中某些项会出现 10 38 10^{38} 1038 量级的数值。我们可以通过以上步骤了解环境的内部细节,但运用强化学习方法解决该环境时完全无需关注。接下来,我们向环境发送动作:

e.step(0) # (array([ 0.02651944, -0.1978833 , -0.00918043,  0.3002543 ], dtype=float32), 1.0, False, False, {})

我们通过执行动作 0 将小车向左推动,获得了包含五个元素的元组:

  • 新观测值(由四个数字组成的新向量)
  • 奖励值为 1.0
  • 值为 Falsedone 标志(表示当前回合尚未结束,杆子基本保持平衡)
  • 值为 Falsetruncated 标志(表示回合未被截断)
  • 有关环境的额外信息,一个空字典

接下来,我们将使用 Space 类的 sample() 方法操作动作空间 (action_space) 和观测空间 (observation_space)。这该方法会从基础空间中返回随机样本:对于离散动作空间意味着返回 01 的随机数,对于观测空间则意味着返回四个数字的随机向量。虽然观测空间的随机样本实用价值不大,但动作空间的随机样本在我们不确定如何执行动作时非常有用。这个功能特别方便,在我们尚未掌握任何强化学习方法时,仍可以操作 Gymnasium 环境。

print(env.action_space.sample()) # 0
print(env.action_space.sample()) # 1
print(env.observation_space.sample()) # [-0.1278352  -1.3741418   0.00285893 -1.3275274 ]
print(env.observation_space.sample()) # [ 2.2755      0.35549003 -0.14950608  1.528284  ]

接下来,我们利用所学知识来实现一个具有随机行为的 CartPole 智能体。

5. 随机 CartPole 智能体

接下来,实现随机 CartPole 智能体:

import gymnasium as gym

if __name__ == "__main__":
    env = gym.make("CartPole-v1")
    total_reward = 0.0
    total_steps = 0
    obs, _ = env.reset()

在此,我们创建了环境并初始化了步数计数器和奖励累加器。最后一行代码重置了环境以获取首个观测值(由于我们的智能体是随机决策的,实际上不会使用这个初始观测值)。
在循环中,我们在采样随机动作后,要求环境执行该动作并返回下一个观测值 (obs)、奖励值 (reward)、终止标志 (is_done) 和截断标志 (is_trunc)。如果回合结束,我们就停止循环并显示累计步数和总奖励值:

    while True:
        action = env.action_space.sample()
        obs, reward, is_done, is_trunc, _ = env.step(action)
        total_reward += reward
        total_steps += 1
        if is_done:
            break

    print("Episode done in %d steps, total reward %.2f" % (total_steps, total_reward))

运行以上代码,可以看到输出如下所示:

Episode done in 16 steps, total reward 16.00

平均而言,我们的随机智能体在杆子倒下回合结束前只能维持 1215 步。Gymnasium 中的大多数环境都设有"奖励阈值"——即智能体需要连续 100 个回合达到的平均奖励值才能算作"解决"该环境。对于 CartPole 来说,这个阈值是 195,意味着智能体平均需要保持杆子平衡 195 个时间步以上。从这个角度来看,我们的随机智能体的表现并不理想。但在后续学习中,我们能够轻松解决 CartPole 以及更多更有趣、更具挑战性的环境。

小结

本节中,我们开始学习强化学习 (Reinforcement Learning, RL) 的实际应用。在本节中,我们通过 Gymnasium 进行了实验,体验了它提供的丰富环境,研究其基础 API 并创建了随机行为智能体。

系列链接

PyTorch强化学习实战(1)——强化学习(Reinforcement Learning,RL)详解

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐