动手学深度强化学习——OpenAI Gym API 与 Gymnasium

153 阅读10分钟

在第 1 章讨论了大量强化学习(RL)的理论概念之后,本章开始做点实践。你将学习 Gymnasium 的基础——它为 RL 智能体与大量 RL 环境提供统一 API。最初,这套 API 由 OpenAI Gym 提供,但后来已不再维护。本书改用 Gymnasium(一个实现了相同 API 的 OpenAI Gym 分支)。无论如何,拥有统一的环境 API 能省去样板代码,让你以通用方式实现智能体,而不必纠结于各个环境的细节。

你还会编写你的第一个随机行为的智能体,并进一步熟悉到目前为止涉及的 RL 基本概念。到本章末,你将理解:

  • 将智能体“插入”RL 框架所需的高层要求
  • 一个纯 Python 的随机 RL 智能体基础实现
  • OpenAI Gym API 及其实现——Gymnasium

智能体的解剖(The anatomy of the agent)

如上一章所述,RL 中有几个基本概念:

  • 智能体(agent) :主动行动的“人或物”。在实践中,智能体是一段实现**策略(policy)**的代码。给定观测,策略决定每个时间步需要采取的动作。
  • 环境(environment) :智能体之外的一切,负责提供观测奖励。环境会基于智能体的动作更新自身状态。

下面用 Python 为一个简单场景实现二者。我们定义一个环境:在有限步数内,不论智能体做什么,都返回随机奖励。这在真实世界并不实用,但有助于把注意力放在环境类与智能体类中的关键方法上。

本书中的代码片段并非完整示例。完整代码见 GitHub:
github.com/PacktPublis… ,可自行运行。

先看环境:

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

上述代码让环境初始化其内部状态。我们这里的状态只是一个计数器,用来限制智能体与环境交互的时间步数量。

get_observation() 方法应当把当前环境的观测返回给智能体。通常它是环境内部状态的某个函数:

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

如果你对 -> List[float] 好奇,这是 Python 3.5 引入的类型注解示例,详见文档:docs.python.org/3/library/t… 。在本例中,由于环境基本没有内部状态,观测向量总是零。get_actions() 方法让智能体查询可执行的动作集合:

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

通常动作集合不随时间改变,但在不同状态下某些动作可能不可行(例如井字棋的某些位置不能落子)。在我们的极简示例中,只有两个动作,用整数 01 编码。

以下方法向智能体指示回合结束

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

如第 1 章所述,环境—智能体的交互序列被划分为回合(episode) 。回合可以是有限的(如国际象棋),也可以是近乎无限的(如旅行者 2 号任务——46 年前发射、已飞出太阳系)。为覆盖两类场景,环境需要提供一种方式来检测回合是否结束、何时无法再与之交互。

action() 方法是环境功能的核心

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

它完成两件事:处理智能体的动作,并返回该动作的奖励。在本例中,奖励是随机数,且忽略传入的动作。同时我们更新步数计数,不再继续已结束的回合。

看智能体部分,更为简洁,仅含两个方法:构造函数与在环境中执行一步的函数。

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

构造函数中初始化一个计数器,用来累计本回合智能体获得的总奖励

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

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

  1. 观测环境
  2. 基于观测对将采取的动作做决策
  3. 将该动作提交给环境
  4. 获取奖励

在本例中,智能体很“笨”,决策时忽略观测,每次都随机选动作。最后是“胶水代码”,创建两类对象并运行一个回合:

if __name__ == "__main__": 
    env = Environment() 
    agent = Agent() 
    while not env.is_done(): 
        agent.step(env) 
    print("Total reward got: %.4f" % agent.total_reward)

完整代码位于仓库 Chapter02/01_agent_anatomy.py。它无外部依赖,在任意较新的 Python 版本上都应可运行。多运行几次,你会得到不同的累计奖励。示例输出(你的结果会不同):

Chapter02$ python 01_agent_anatomy.py 
Total reward got: 5.8832

上述简洁代码很好地体现了 RL 模型的核心模式:每一步,智能体从环境获取观测,进行计算并选择动作;该动作的结果是一个奖励与一个新的观测。现实中,环境可以是极其复杂的物理模型;智能体也可能是大型神经网络实现的最新 RL 算法,但基本范式不变

你可能会问:既然范式相同,为什么还要从零编写?有没有别人已经实现好的可以直接用?当然有。在花时间讨论这些框架之前,我们先准备开发环境

硬件与软件要求

本书中的示例基于 Python 3.11 实现与测试。我假定你已熟悉该语言以及诸如虚拟环境等常识性概念,因此不会详细展开如何在隔离环境中安装依赖包。示例会使用上文提到的 Python 类型注解,以便为函数和类方法提供类型签名。

当下可用的 ML 与 RL 库很多,但在本书中我尽量精简依赖,优先选择自行实现方法,而不是盲目引入第三方库。

本书将使用的外部库均为开源软件,包括:

  • NumPy:科学计算库,用于矩阵运算与常用函数。
  • OpenCV Python 绑定:计算机视觉库,提供大量图像处理功能。
  • Gymnasium(Farama Foundation,farama.org):OpenAI Gym 的维护分支(原库:github.com/openai/gym),是一个 RL 框架,提供多种可通过统一方式交互的环境。
  • PyTorch:灵活且表达力强的深度学习(DL)库。第 3 章会有一个速成入门。
  • PyTorch Ignite:构建在 PyTorch 之上的一组高层工具,用于减少样板代码。第 3 章会简要介绍;完整文档见 pytorch-ignite.ai/
  • PTANgithub.com/Shmuma/ptan):我为支持现代深度 RL 方法与积木而创建的、对 OpenAI Gym API 的开源扩展。书中会结合源码详细介绍所用到的各类。

一些章节会使用其他库(按需):例如用 Microsoft TextWorld 玩文字类游戏,用 PyBulletMuJoCo 做机器人仿真,用 Selenium 处理基于浏览器的自动化问题等。此类专题章节会包含对应库的安装说明。

本书相当一部分内容(第 2、3、4 部分)聚焦于近几年发展的现代深度 RL 方法。这里的 “deep” 指大量使用深度学习。你可能知道,DL 方法对算力要求高。一块现代 GPU 的训练速度往往是即便最强的多核 CPU 系统的 10–100 倍。在实践中,这意味着同一段代码:在一台带 GPU 的机器上训练 1 小时 的任务,即便放在最快的 CPU 系统上,也可能需要半天到一周。这并不代表没有 GPU 就不能尝试本书的示例,只是会更耗时。为了自己动手实验代码(这是最有效的学习方式),最好能获得一台带 GPU 的机器。可行途径包括:

  • 购买一块支持 CUDAPyTorch 支持的现代 GPU。
  • 使用云主机:AWSGCP 都提供带 GPU 的实例。
  • 使用 Google Colab,其 Jupyter 笔记本提供免费的 GPU 访问

如何搭建系统环境超出本书范围,互联网上有大量手册可供参考。操作系统方面,建议使用 Linux 或 macOSWindows 也受到 PyTorch 与 Gymnasium 的支持,但本书示例未在 Windows 上完整测试

为了给出本书贯穿使用的外部依赖的精确版本,下面是 requirements.txt(注意:在 Python 3.11 上测试通过;不同版本可能需要微调,或无法工作):

gymnasium[atari]==0.29.1 
gymnasium[classic-control]==0.29.1 
gymnasium[accept-rom-license]==0.29.1 
moviepy==1.0.3 
numpy<2 
opencv-python==4.10.0.84 
torch==2.5.0 
torchvision==0.20.0 
pytorch-ignite==0.5.1 
tensorboard==2.18.0 
mypy==1.8.0 
ptan==0.8.1 
stable-baselines3==2.3.2 
torchrl==0.6.0 
ray[tune]==2.37.0 
pytest

书中所有示例使用并测试于 PyTorch 2.5.0。安装方法参见 pytorch.org(通常根据操作系统执行 conda install pytorch torchvision -c pytorch,或直接 pip install torch)。

接下来,我们将深入 OpenAI Gym API 的细节——它为我们提供了海量环境,从最简单到最具挑战性的都有。

OpenAI Gym API 与 Gymnasium

由 OpenAI(<www.openai.com>)开发的 Python 库 Gym 的首个版本发布于 2017 年。自那以后,大量环境被基于这一最初的 API 开发或迁移到其上,该 API 逐渐成为强化学习(RL)的事实标准。

2021 年,最初的 OpenAI Gym 团队将开发迁移到 Gymnasium(github.com/Farama-Foundation/Gymnasium)——这是原 Gym 库的一个分支。Gymnasium 提供与 Gym 相同的 API,旨在成为一个“即插即用的替代品”(你可以 import gymnasium as gym,大多数情况下原代码即可运行)。

本书示例使用 Gymnasium,但为行文简洁,正文多处仍以“Gym”指代;仅在少数确有区别之处,才明确写作“Gymnasium”。

Gym 的主要目标是:以统一接口提供丰富的 RL 实验环境集合。因此,库的中心类正是环境类,名为 Env。该类的实例通过若干方法与字段暴露其能力与必要信息。高层来看,每个环境都提供以下信息与功能:

  • 可执行动作集合:环境中允许执行的动作集。Gym 同时支持离散动作连续动作,以及它们的组合
  • 观测(observation)的形状与取值边界:环境在每个时间步给予智能体的观测数据的规格。
  • step 方法:执行一个动作,返回当前观测奖励以及一个指示回合是否结束的标志。
  • reset 方法:将环境复位到初始状态,并返回首个观测

下面分别介绍环境的这些组成部分。

动作空间(Action Space)

如前所述,智能体可执行的动作可以是离散的连续的,或两者的组合

  • 离散动作:是一组固定的互斥操作,例如网格中的方向 左/右/上/下;又如按钮只有“按下/松开”两种状态。离散动作空间的要点是:同一时刻仅能从有限集合中选择一个动作
  • 连续动作:带有实值的参数,例如方向盘转角油门踏板深度。连续动作的描述包含其取值边界:方向盘可从 −720° 到 720°,油门通常是 0 到 1

当然,动作并不局限于单一维度:环境可能需要同时执行多个动作,比如同时按下多个按钮,或一边打方向一边踩刹车/油门。为支持这类情形,Gym 定义了容器类,可将若干动作空间嵌套组合为一个统一的动作描述。

观测空间(Observation Space)

如第 1 章所述,观测是环境在每个时间步(除了奖励之外)提供给智能体的信息片段。观测可以很简单(一串数字),也可以很复杂(来自多路摄像头的彩色图像等多维张量)。观测也可以是离散的,与动作空间类似。一个离散观测空间的例子是灯泡:只有 开/关 两种状态,可用布尔值表示。

由此可见,动作与观测在结构上具有相似性,Gym 的类层次也据此进行抽象与表示。接下来通常会给出一个类图来说明二者在 Gym 中的表示方式。

image.png

抽象基类 Space 中与我们相关的有 1 个属性3 个方法

  • shape:空间的形状(维度),与 NumPy 数组的形状一致。
  • sample() :从该空间随机采样一个元素。
  • contains(x) :检查参数 x 是否属于该空间的取值域
  • seed() :为该空间及其所有子空间初始化随机数生成器。若希望多次运行得到可复现的环境行为,这很有用。

以上方法均为抽象方法,会在各个 Space 子类中重新实现

  • Discrete:表示一组互斥的条目,编号为 0 到 n-1。如有需要,可通过可选构造参数 start 重新定义起始索引。n 是该 Discrete 对象描述的条目数量。例如,Discrete(n=4) 可表示四个移动方向的动作空间 [左、右、上、下]

  • Box:表示区间 [low, high] 上的 n 维实数张量。例如,油门踏板的单一取值范围为 0.0 到 1.0,可编码为
    Box(low=0.0, high=1.0, shape=(1,), dtype=np.float32)。这里 shape 设为长度为 1 的元组 (1,),表示一维单元素张量dtype 指定空间的数值类型,这里为 NumPy 32 位浮点
    另一个 Box 的示例是 Atari 的屏幕观测(本书后面会大量使用 Atari 环境):尺寸 210 × 160RGB 图像:
    Box(low=0, high=255, shape=(210, 160, 3), dtype=np.uint8)。此处 shape 的三个维度依次为3(R/G/B 三个颜色通道) 。总计每次观测是一个三维张量,包含 100,800 个字节。

  • Tuple:允许将多个 Space 实例组合在一起,从而创建任意复杂的动作/观测空间。例如,需要为汽车定义动作空间:它在每个时间步可调整方向盘角度刹车踏板位置油门踏板位置——这三项可用一个 Box 的三个浮点值表示。除此之外还有离散控制,如转向灯(关/右/左)或喇叭(开/关)。可用如下代码将这些合并为一个动作空间:

    Tuple(spaces=(
        Box(low=-1.0, high=1.0, shape=(3,), dtype=np.float32),
        Discrete(n=3),
        Discrete(n=2)
    ))
    

    这种灵活性并不常用;例如在本书中,你主要会见到 BoxDiscrete 作为动作/观测空间,但在某些场景下 Tuple 也很方便。

Gym 还定义了其他 Space 子类,例如 Sequence(表示可变长度序列)、Text(字符串)和 Graph(节点及其连接的集合)。不过,上述三种(DiscreteBoxTuple)是最常用的。

每个环境都包含两个 Space 类型的成员:action_spaceobservation_space。这使我们可以编写通用代码适配任意环境。当然,处理屏幕像素与处理离散观测是不同的(前者通常需要用卷积层等视觉方法做预处理),因此多数情况下仍需针对特定环境或环境组进行优化;但 Gym 并不妨碍我们编写通用逻辑。

环境(The environment)

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

  • action_spaceSpace 字段,给出环境中允许的动作规格。

  • observation_space:同样为 Space 字段,给出环境提供的观测规格。

  • reset() :将环境复位到初始状态,返回初始观测向量以及一个包含额外环境信息的字典。

  • step() :让智能体执行一个动作,并返回该动作结果相关的信息:

    • 下一步观测
    • 即时奖励
    • 回合结束标志(done)
    • 回合被截断标志(truncated)
    • 环境额外信息的字典

该方法稍显复杂;本节稍后会细讲其细节。Env 还有一些辅助方法,如 render() (以更易读的形式获取观测),但我们不会使用。完整列表可参见 Gym 文档。这里聚焦核心的 reset()step()

reset() 更简单

reset() 无参数,指示环境复位到初始状态,并获取首个观测。注意:创建环境实例后,需要先调用 reset() 。如第 1 章所述,智能体与环境的交互可能会有结束(类似“Game Over”)。这样的会话称为回合(episode) ,回合结束后需要重新开始。该方法返回的值就是环境的第一帧观测

除了观测,reset() 还会返回第二个值——包含环境特定额外信息的字典。大多数标准环境在该字典中不返回任何内容,但更复杂的环境(如 TextWorld,交互式小说游戏的模拟器;本书后面会介绍)可能会返回超出标准观测之外的附加信息。

step() 是环境功能的核心

一次调用完成多项工作:

  1. 告诉环境下一步要执行的动作
  2. 获取该动作之后的新观测
  3. 获取本步的奖励
  4. 获取回合是否结束的指示;
  5. 获取回合是否被截断(例如启用了时间上限)的标志;
  6. 获取环境特定的额外信息字典。

上述列表中的第 1 项(动作)作为 step()唯一参数传入,其余内容作为返回值给出。更精确地说,返回的是一个 Python 元组(注意不是上一节所说的 Tuple 空间类),包含 5 个元素:
(observation, reward, done, truncated, info),其含义与类型如下:

  • observation:NumPy 向量或矩阵形式的观测数据。
  • reward:浮点数形式的奖励。
  • done:布尔值;为 True 表示回合结束。若为 True,需要在环境上调用 reset(),因为无法继续执行动作。
  • truncated:布尔值;为 True 表示回合被截断。多数环境中这由 TimeLimit 触发(用于限制回合长度),但在某些环境中含义可能不同。将其与 done 分离,是为了区分“智能体到达回合自然终点”与“智能体触及环境时间上限”。若 truncated 为 True,同样需要调用 reset()
  • info:环境特定的额外信息,类型不定。一般性的 RL 方法通常忽略该值。

至此,你大概已经明白在智能体代码中如何使用环境:在循环里,不断以某个动作调用 step(),直到 donetruncated 为 True;然后调用 reset() 重新开始。还差最后一环——如何创建 Env 对象——我们接下来就来介绍。

创建环境(Creating an environment)

每个环境都有一个唯一名称,形式为 EnvironmentName-vN,其中 N 用于区分同一环境的不同版本(例如修复了某些缺陷或进行了重大更改时)。要创建环境,gymnasium 提供了函数 make(name) ,其唯一参数是字符串形式的环境名称。

在撰写本文时,Gymnasium 0.29.1(安装了 [atari] 扩展)包含 1,003 个不同名称的环境。当然,这些并非全部独立环境,因为列表中包含了各环境的所有版本。此外,同一环境在设置观测空间上也可能存在不同变体。以 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 个环境。下图展示了其游戏画面:

image.png

图 2.2:Breakout 的游戏画面

即便剔除这类“重复项”(版本/变体),Gymnasium 仍提供了198 个令人印象深刻的独立环境,可分为若干类别:

  • 经典控制(Classic control) :最优控制与 RL 论文中常用的玩具任务/基准。通常观测与动作维度较低,便于快速验证算法——可把它们视作 RL 领域的 “MNIST”(MNIST 是 Yann LeCun 提供的手写数字数据集,见 yann.lecun.com/exdb/mnist/…
  • Atari 2600:上世纪 70 年代的经典平台游戏,共 63 款独立游戏。
  • 算法类(Algorithmic) :执行小型计算任务,如复制观测序列、数值相加等。
  • Box2D:基于 Box2D 物理引擎的环境,用于学习行走或车辆控制。
  • MuJoCo:另一款物理引擎,常用于各类连续控制问题。
  • 参数调优(Parameter tuning) :使用 RL 优化神经网络参数。
  • 玩具文本(Toy text) :简易的网格世界文字环境。

当然,支持 Gym API 的 RL 环境总数远不止这些。比如,Farama Foundation 维护了多个与多智能体 RL3D 导航机器人学网页自动化等专项相关的仓库;此外还有大量第三方仓库。可参阅 Gymnasium 文档的页面:
gymnasium.farama.org/environment…

理论够多了!接下来我们看一个 Python 会话,演示如何与 Gym 的某个环境交互。

CartPole 交互会话(The CartPole session)

让我们把已学知识用起来,探索 Gym 提供的最简单 RL 环境之一。

$ python
>>> import gymnasium as gym
>>> e = gym.make("CartPole-v1")

这里我们导入了 gymnasium 包,并创建了名为 CartPole 的环境。它属于经典控制(classic control)组,核心是控制一台小车(平台)来平衡其上竖直铰接的一根杆(见下图)。

麻烦在于,这根杆会向左或向右倒下,你需要在每个时间步通过把平台向左或向右移动来保持平衡。

image.png 图 2.3:CartPole 环境

该环境的观测由 4 个浮点数构成,包含:小车位置 xx、小车速度、杆相对平台的角度、杆的角速度。当然,若运用一些数理物理知识,把这些数值映射为平衡所需的动作并不难,但我们的课题不同——只凭奖励、而不理解观测数值的确切含义,如何学会平衡?在本环境中,每个时间步的奖励为 1。回合在杆倒下时结束。要获得更多累计奖励,就要想办法让杆尽量不倒

这看起来不易,但再过两章,我们就会写出一个算法,几分钟内轻松解决 CartPole,且对这些观测数值“代表什么”一无所知——只靠试错与一点 RL 魔法

现在继续我们的会话:

>>> obs, info = e.reset()
>>> obs
array([ 0.02100407,  0.02762252, -0.01519943, -0.0103739 ], dtype=float32)
>>> info
{}

我们复位环境并拿到了首帧观测(新创建环境总要先 reset())。观测确实是 4 个数,不意外。接着看看该环境的动作空间观测空间

>>> e.action_space
Discrete(2)
>>> e.observation_space
Box([-4.8000002e+00 -3.4028235e+38 -4.1887903e-01 -3.4028235e+38],
    [4.8000002e+00  3.4028235e+38  4.1887903e-01  3.4028235e+38], (4,), float32)

action_spaceDiscrete(2) ,所以动作只有 0 或 10 = 向左推平台,1 = 向右推observation_spaceBox(4,) ,即 4 维向量;显示的两行分别是该向量各分量的下界上界

如果好奇源码,可查看 Gymnasium 仓库的 cartpole.py
github.com/Farama-Foun…
其中的文档字符串给出了观测语义的细节:

  • Cart position(小车位置) :范围 −4.8…4.8-4.8 \dots 4.8
  • Cart velocity(小车速度) :范围 −∞…∞-\infty \dots \infty
  • Pole angle(杆角度,弧度) :范围 −0.418…0.418-0.418 \dots 0.418
  • Pole angular velocity(杆角速度) :范围 −∞…∞-\infty \dots \infty

Python 使用 float32 的最大/最小值来表示“无穷”,因此边界向量中会出现 103810^{38} 量级的数。了解这些内部细节很有趣,但用 RL 解环境并不必需。继续,给环境发一个动作:

>>> e.step(0)
(array([-0.01254663, -0.22985364, -0.01435183,  0.24902613], dtype=float32),
 1.0, False, False, {})

我们执行了动作 0(向左推) ,得到一个五元组

  • 新的观测(4 个数的向量)
  • 奖励 1.0
  • done 标志为 False:回合未结束,说明暂时还能平衡
  • truncated 标志为 False:回合未被截断
  • info:环境的额外信息,这里是空字典

接下来在 action_spaceobservation_space 上调用 Space.sample()

>>> e.action_space.sample()
0
>>> e.action_space.sample()
1
>>> e.observation_space.sample()
array([-4.05354548e+00, -1.13992760e+38, -1.21235274e-01,  2.89040989e+38],
      dtype=float32)
>>> e.observation_space.sample()
array([-3.6149189e-01, -1.0301251e+38, -2.6193827e-01, -2.6395525e+36],
      dtype=float32)

该方法从空间中随机采样:离散动作空间返回 0/1 之一;观测空间返回 4 维随机向量。随机观测样本并不太有用,但动作空间的随机采样在“不知道该怎么做”时可以拿来先玩玩 Gym 环境。现在你已经掌握了实现 CartPole 随机智能体所需的知识,咱们就动手吧。

随机版 CartPole 智能体(The random CartPole agent)

虽然这个环境比 2.1 节的第一个示例复杂得多,但智能体的代码却更短。这就是可复用性、抽象第三方库的威力!

代码如下(见 Chapter02/02_cartpole_random.py):

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

这里我们创建了环境,并初始化了步数计数器累计奖励。最后一行通过 reset() 获取首帧观测(不过我们不会用到它,因为我们的智能体是随机的):

    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))

在上述循环中,我们先随机采样一个动作,然后让环境执行该动作,并返回下一步观测 obs奖励 reward、以及 is_doneis_trunc 标志。若回合结束,就退出循环并打印步数累计奖励。运行该示例,你会看到类似(由于随机性不会一模一样)的输出:

Chapter02$ python 02_cartpole_random.py 
Episode done in 12 steps, total reward 12.00

平均而言,我们的随机智能体在12 到 15 步后杆就会倒下、回合结束。Gym 中的大多数环境都设有“奖励门槛(reward boundary) ”:即连续 100 个回合平均奖励需达到该数值才算“解出”环境。对 CartPole 而言,这个门槛是 195——也就是说,智能体平均必须能让杆保持平衡 ≥ 195 个时间步。从这个角度看,我们的随机智能体表现确实很差。

不过别灰心——这只是开始。很快你就会解出 CartPole,以及更多更有趣、更具挑战的环境。

Gym 额外 API 功能(Extra Gym API functionality)

到目前为止,我们已经覆盖了 Gym 核心 API 的三分之二,以及开始编写智能体所需的基本函数。剩余部分即便不用也能跑起来,但会让你的开发更轻松、代码更整洁。下面简要介绍这些内容。

包装器(Wrappers)

很多时候,你会想以一种通用方式扩展环境功能。例如:环境给了你观测,但你想把它们累积到一个缓冲区里,仅把最近 N 帧提供给智能体——这在动态类电子游戏里很常见,单帧不足以反映完整状态。再如,你想裁剪/预处理图像像素以便于智能体消化,或按某种方式归一化奖励。这类需求的共同点是:你想在现有环境外面**“包一层” ,加上一点额外逻辑。Gym 为此提供了便利的框架—— Wrapper** 类。

类结构见图 2.4。

image.png

图 2.4:Gym 中 Wrapper 类的层次结构

Wrapper 继承自 Env。其构造函数只接收一个参数——要被“包裹”的 Env 实例。若要添加扩展功能,你需要重写想要扩展的方法(如 step()reset())。唯一要求是:仍需在合适时机调用父类的原始方法。为便于访问被包裹的环境,Wrapper 提供两个属性:env(当前直接包裹的环境,它本身也可能是另一个 wrapper),以及 unwrapped(去掉所有包装后、真正的底层 Env)。

为满足更定向的需求(仅处理观测、或仅处理动作等),Wrapper 还有若干子类,便于只过滤特定信息:

  • ObservationWrapper:重写父类的 observation(obs) 方法。参数 obs 是被包裹环境给出的观测;该方法应返回将要提供给智能体的观测。
  • RewardWrapper:重写 reward(rew),可修改给智能体的奖励值,例如缩放到所需范围,或基于先前动作加折扣等。
  • ActionWrapper:重写 action(a),可在智能体把动作传给被包裹环境之前对其进行调整。

更具体一点,设想你想干预智能体发出的动作流:以 10% 的概率把当前动作替换为随机动作。这看似“捣乱”,但这是解决第 1 章提到的探索/利用难题最实用、最强力的技巧之一。通过注入随机动作,我们让智能体探索环境,偶尔偏离其既有策略的“熟路”。用 ActionWrapper 很容易实现(完整示例见 Chapter02/03_random_action_wrapper.py):

import gymnasium as gym 
import random 
 
class RandomActionWrapper(gym.ActionWrapper): 
    def __init__(self, env: gym.Env, epsilon: float = 0.1): 
        super(RandomActionWrapper, self).__init__(env) 
        self.epsilon = epsilon

这里我们在构造函数中调用了父类 __init__,并保存了 epsilon(随机动作的概率)。

下面是需要从父类重写、用于篡改智能体动作的方法:

    def action(self, action: gym.core.WrapperActType) -> gym.core.WrapperActType: 
        if random.random() < self.epsilon: 
            action = self.env.action_space.sample() 
            print(f"Random action {action}") 
            return action 
        return action

每次掷“骰子”,以 epsilon 的概率,我们从动作空间随机采样一个动作,替换智能体传来的动作并返回。注意,借助 action_space 与 wrapper 的抽象,我们写出了与任何 Gym 环境通用的代码。控制台的打印只是为演示 wrapper 生效;生产代码中当然可以省略。

现在应用我们的包装器。创建一个普通的 CartPole 环境,并把它传给 Wrapper 构造函数:

if __name__ == "__main__": 
    env = RandomActionWrapper(gym.make("CartPole-v1"))

从此以后,我们像使用原始 CartPole 一样,把这个 wrapper 当作一个普通的 Env 使用。由于 Wrapper 继承 Env 并暴露相同接口,我们可以任意深度地嵌套多个包装器。这是一种强大、优雅且通用的方案。

下面这段代码与“随机智能体”几乎一样,只是这回我们每次都发同一个动作 0,也就是一个“很笨”的智能体:

    obs = env.reset() 
    total_reward = 0.0 
 
    while True: 
        obs, reward, done, _, _ = env.step(0) 
        total_reward += reward 
        if done: 
            break 
 
    print(f"Reward got: {total_reward:.2f}")

运行后你应能看到 wrapper 的确在工作:

Chapter02$ python 03_random_action_wrapper.py 
Random action 0 
Random action 0 
Reward got: 9.00

接下来,我们来看如何在执行过程中**渲染(render)**环境。

渲染环境(Rendering the environment)

你还需要了解的另一项能力是渲染(rendering)环境。这通过两个包装器实现:HumanRenderingRecordVideo

这两个类取代了已被移除的 OpenAI Gym 中的旧 Monitor 包装器。Monitor 能把智能体的表现记录到文件里,并可选地录制智能体运行的视频。

Gymnasium 中,你可以用两个类来查看环境内部发生了什么。其一是 HumanRendering,它会打开一个独立的图形窗口,交互式显示来自环境的画面。要让环境(此处以 CartPole 为例)能够渲染,创建时必须传入 render_mode="rgb_array" 参数。这个参数告诉环境,其 render() 方法需要返回像素数组,而 HumanRendering 包装器就会调用该方法。

因此,使用 HumanRendering 包装器时,需要改动随机智能体的代码(完整代码见 Chapter02/04_cartpole_random_monitor.py):

if __name__ == "__main__": 
    env = gym.make("CartPole-v1", render_mode="rgb_array") 
    env = gym.wrappers.HumanRendering(env)

运行代码后,会出现一个显示环境画面的窗口。由于我们的智能体无法长时间平衡(最多 10–30 步),一旦调用 env.close(),窗口很快就会关闭。

image.png

图 2.5:由 HumanRendering 渲染的 CartPole 环境

另一个可能有用的包装器是 RecordVideo,它捕获环境像素并生成智能体运行的视频文件。用法与人类渲染器类似,但需要额外提供保存视频文件的目录。若目录不存在,会自动创建:

if __name__ == "__main__": 
    env = gym.make("CartPole-v1", render_mode="rgb_array") 
    env = gym.wrappers.RecordVideo(env, video_folder="video")

启动后,会报告生成的视频文件名:

Chapter02$ python 04_cartpole_random_monitor.py 
Moviepy - Building video Chapter02/video/rl-video-episode-0.mp4. 
Moviepy - Writing video Chapter02/video/rl-video-episode-0.mp4 

Moviepy - Done ! 
Moviepy - video ready Chapter02/video/rl-video-episode-0.mp4 
Episode done in 30 steps, total reward 30.00

当你在无图形界面的远程机器上运行智能体时,这个包装器尤其有用。

更多包装器

Gymnasium 还提供了大量其他包装器,我们会在后续章节用到:例如对 Atari 图像的标准化预处理奖励归一化观测帧堆叠环境向量化时间上限等。

完整列表见文档:gymnasium.farama.org/api/wrapper…,以及源码。

小结

你已经开始学习 RL 的实践面!本章中,我们体验了 Gymnasium,浏览了它海量可玩的环境;学习了其基础 API,并实现了一个随机行为的智能体。

你也学会了如何以模块化方式扩展现有环境的功能,并通过包装器渲染智能体的行为。这些能力将在接下来的章节中被大量使用。

下一章,我们将用 PyTorch 做一个深度学习速成回顾——它是当前最常用的 DL 工具之一。