在第 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]
通常动作集合不随时间改变,但在不同状态下某些动作可能不可行(例如井字棋的某些位置不能落子)。在我们的极简示例中,只有两个动作,用整数 0 与 1 编码。
以下方法向智能体指示回合结束:
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
该函数让智能体执行以下操作:
- 观测环境
- 基于观测对将采取的动作做决策
- 将该动作提交给环境
- 获取奖励
在本例中,智能体很“笨”,决策时忽略观测,每次都随机选动作。最后是“胶水代码”,创建两类对象并运行一个回合:
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/。
- PTAN(github.com/Shmuma/ptan):我为支持现代深度 RL 方法与积木而创建的、对 OpenAI Gym API 的开源扩展。书中会结合源码详细介绍所用到的各类。
一些章节会使用其他库(按需):例如用 Microsoft TextWorld 玩文字类游戏,用 PyBullet 和 MuJoCo 做机器人仿真,用 Selenium 处理基于浏览器的自动化问题等。此类专题章节会包含对应库的安装说明。
本书相当一部分内容(第 2、3、4 部分)聚焦于近几年发展的现代深度 RL 方法。这里的 “deep” 指大量使用深度学习。你可能知道,DL 方法对算力要求高。一块现代 GPU 的训练速度往往是即便最强的多核 CPU 系统的 10–100 倍。在实践中,这意味着同一段代码:在一台带 GPU 的机器上训练 1 小时 的任务,即便放在最快的 CPU 系统上,也可能需要半天到一周。这并不代表没有 GPU 就不能尝试本书的示例,只是会更耗时。为了自己动手实验代码(这是最有效的学习方式),最好能获得一台带 GPU 的机器。可行途径包括:
- 购买一块支持 CUDA 且 PyTorch 支持的现代 GPU。
- 使用云主机:AWS 与 GCP 都提供带 GPU 的实例。
- 使用 Google Colab,其 Jupyter 笔记本提供免费的 GPU 访问。
如何搭建系统环境超出本书范围,互联网上有大量手册可供参考。操作系统方面,建议使用 Linux 或 macOS。Windows 也受到 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 中的表示方式。
抽象基类 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 × 160 的 RGB 图像:
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) ))这种灵活性并不常用;例如在本书中,你主要会见到
Box与Discrete作为动作/观测空间,但在某些场景下Tuple也很方便。
Gym 还定义了其他 Space 子类,例如 Sequence(表示可变长度序列)、Text(字符串)和 Graph(节点及其连接的集合)。不过,上述三种(Discrete、Box、Tuple)是最常用的。
每个环境都包含两个 Space 类型的成员:action_space 与 observation_space。这使我们可以编写通用代码适配任意环境。当然,处理屏幕像素与处理离散观测是不同的(前者通常需要用卷积层等视觉方法做预处理),因此多数情况下仍需针对特定环境或环境组进行优化;但 Gym 并不妨碍我们编写通用逻辑。
环境(The environment)
环境由 Gym 中的 Env 类表示,包含以下成员:
-
action_space:Space字段,给出环境中允许的动作规格。 -
observation_space:同样为Space字段,给出环境提供的观测规格。 -
reset():将环境复位到初始状态,返回初始观测向量以及一个包含额外环境信息的字典。 -
step():让智能体执行一个动作,并返回该动作结果相关的信息:- 下一步观测
- 即时奖励
- 回合结束标志(done)
- 回合被截断标志(truncated)
- 环境额外信息的字典
该方法稍显复杂;本节稍后会细讲其细节。Env 还有一些辅助方法,如 render() (以更易读的形式获取观测),但我们不会使用。完整列表可参见 Gym 文档。这里聚焦核心的 reset() 与 step() 。
reset() 更简单
reset() 无参数,指示环境复位到初始状态,并获取首个观测。注意:创建环境实例后,需要先调用 reset() 。如第 1 章所述,智能体与环境的交互可能会有结束(类似“Game Over”)。这样的会话称为回合(episode) ,回合结束后需要重新开始。该方法返回的值就是环境的第一帧观测。
除了观测,reset() 还会返回第二个值——包含环境特定额外信息的字典。大多数标准环境在该字典中不返回任何内容,但更复杂的环境(如 TextWorld,交互式小说游戏的模拟器;本书后面会介绍)可能会返回超出标准观测之外的附加信息。
step() 是环境功能的核心
一次调用完成多项工作:
- 告诉环境下一步要执行的动作;
- 获取该动作之后的新观测;
- 获取本步的奖励;
- 获取回合是否结束的指示;
- 获取回合是否被截断(例如启用了时间上限)的标志;
- 获取环境特定的额外信息字典。
上述列表中的第 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(),直到 done 或 truncated 为 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 个环境。下图展示了其游戏画面:
图 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 维护了多个与多智能体 RL、3D 导航、机器人学、网页自动化等专项相关的仓库;此外还有大量第三方仓库。可参阅 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)组,核心是控制一台小车(平台)来平衡其上竖直铰接的一根杆(见下图)。
麻烦在于,这根杆会向左或向右倒下,你需要在每个时间步通过把平台向左或向右移动来保持平衡。
图 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_space 是 Discrete(2) ,所以动作只有 0 或 1:0 = 向左推平台,1 = 向右推。observation_space 是 Box(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_space 与 observation_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_done 和 is_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。
图 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)环境。这通过两个包装器实现:HumanRendering 与 RecordVideo。
这两个类取代了已被移除的 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(),窗口很快就会关闭。
图 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 工具之一。