第二章
在第一章讨论了大量的**强化学习(RL)**理论概念之后,我们是时候开始着手实践。本章中,你将学习Gymnasium的基础知识,Gymnasium是一个用于为RL智能体和大量RL环境提供统一接口的库。一开始,这种统一接口的库是由Open AI Gym库来实现的,可是现在不再维护了。在这本书里,我们将使用Gymnasium库—Open AI Gym的一个分支,也是实现了同样的接口。不管怎么说,为环境提供统一的接口可以省去编写重复冗余代码的麻烦,且让你以通用的方式实现智能体,而无需关注具体环境细节。
你还将编写首个随机行为的智能体,并进一步熟悉我们目前介绍的强化学习(RL)基本概念。到本章结束时,你将掌握以下内容:
- 智能体接入强化学习(RL)框架所需实现的高级要求
- 一个由简单的,纯python代码实现的随机RL智能体
- OpenAI Gym接口及其实现——Gymnasium 库
1.剖析智能体
正如你在上一章所学的,RL有几个基本概念:
- 智能体(Agent):指代物或人,扮演活跃的角色。实际上智能体是一段代码,用于实现某种策略(Policy)。简而言之,该策略根据当前观测结果,决定每个时间步需要采取的动作。
- 环境(Environment):除智能体以外的一切事物,负责提供观察结果和奖励。环境的内部状态会因智能体的动作而发生改变。
让我们通过一个简单场景,探讨如何在 Python 中实现这两者。我们将定义一个环境:无论智能体采取何种动作,该环境都会在有限的步数内给予随机奖励。这种场景在现实世界中虽不实用,但能让我们专注于环境类和智能体类的具体实现方法。
📝警告或重要的注释:
请注意,在本书中显示的代码片段并不是完整的示例。你可以在GitHub页面上找到完整的例子:并运行起来。
让我门从环境开始:
class Environment:
def __init__(self):
self.steps_left = 10
在上面的代码中,我们让环境初始化了它的内部状态。在我们的案例中,这个状态只是个计数器,该计数器用于限制智能体与环境所交互的时间步步数。
get_observation()
函数用于让智能体接收当前环境的观察(observation)。该函数通常用于实现环境的内部状态。
def get_observation(self) -> List[float]:
return [0.0, 0.0, 0.0]
如果你对List[float]
的含义感到好奇,它是Python类型注解的一个例子,类型注解是在Python 3.5中引入的。你可以在Python官方文档](https://docs.python.org/3/library/typing.html)中找到更多信息。在我们的例子中,观察向量始终为零,因为环境基本上没有内部状态。
get_actions()
函数是让智能体查询它可以执行的动作集合:
def get_actions(self) -> List[int]:
return [0, 1]
通常情况下,动作集合不会随时间改变,但在不同状态下某些动作可能无法执行(例如,在九宫格游戏的某些位置并非所有移动都是可行的)。在我们这个简化的示例中,智能体只能执行两个动作,分别用整数0和1进行编码。
以下函数会向智能体发出回合结束的信号:
def is_done(self) -> bool:
return self.steps_left == 0
如你在第1章所见,环境与智能体之间连续的交互活动被划分为一连串的时间步,被称为回合。回合可以是有限的(如国际象棋游戏),也可以是无限的(如旅行者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
这个函数让智能体执行以下操作:
- 观察环境。
- 基于观察数据对 要执行的动作 作出决策。
- 把 决策出来的动作 提交给给环境。
- 从环境获取当前时间步的奖励。
在我们的示例代码中,在整个决策过程中关于智能体要执行哪个动作,智能体的表现是呆板的,它忽略了已接收的观察数据。相反地,每一个动作是随机选取。最后的代码部分是胶水代码(glue code),负责创建这两个类并运行一个回合:
if __name__ == "__main__":
env = Environment()
agent = Agent()
while not env.is_done():
agent.step(env)
print("Total reward got: %.4f" % agent.total_reward)
你可以在本书的GitHub仓库中找到完整的代码,具体为 Chapter02/01_agent_anatomy.py
文件。这个代码没有外部依赖,只要Python版本相对新一点都能运行起来。通过多次运行,你会发现智能体收集到的奖励总值是不一样的。以下是我在机器上得到的输出:
Chapter02$ python 01_agent_anatomy.py
Total reward got: 5.8832
前面代码的简洁性很好地阐释了强化学习(RL)模型的重要基础概念。环境可以是一个极其复杂的物理模型,而智能体(agent)也可以是一个实现了最新强化学习算法的大型神经网络(NN),但基本模式始终保持一致——在每一个时间步中,智能体会从环境中获取观察数据,进行决策计算,并选择要执行的动作。执行完该动作的结果将产生奖励(reward)和一个新的观察值。
你可能会问:既然模式相同,为什么需要从头实现?如果已经有人将其封装成库,那么直接调用不是更好吗?确实存在这样的框架,但在我们深入讨论之前,让我们先配置好你的开发环境。
2.硬软件需求
本书示例基于Python 3.11版本实现并测试通过。假设读者已熟悉Python语言及虚拟环境等基本概念,因此不再赘述软件包安装与隔离环境配置的具体方法。所有代码将使用前文提及的Python类型注解(type annotations),这将帮助我们为函数和类方法提供明确的类型签名(type signatures)。
当前机器学习(ML)与强化学习(RL)领域存在大量现成库,但本书力求最小化外部依赖,优先通过自行实现方法而非直接调用第三方库来完成功能开发。
本书涉及的外部开源库包括:
- NumPy:是一个用于科学计算和矩阵运算及常用函数的库。
- OpenCV Python bindings:是一个计算机视觉库,提供了许多图像处理功能。
- Gymnasium(来自Farama基金会):是OpenAI Gym库的一个维护分支,是一个拥有各种环境并以统一的方式与不同的环境进行通信的强化学习框架。
- PyTorch:是一个灵活且富有表现力的深度学习库。第3章将提供关于它的简短速成课程。
- PyTorch Ignite:是一组基于PyTorch的高级工具集合,用于减少重复性代码。第3章将会简要介绍它。可访问完整文档链接。
- PTAN(网址):这是本书作者创建的一个OpenAI Gym接口的开源扩展,用于支持现代深度强化学习方法及其构建块。所有使用到的类都将与源代码一起详细描述。
本书部分章节将使用特定功能库:例如使用微软TextWorld库处理文字冒险游戏、PyBullet库和MuJoCo库实现机器人仿真、Selenium库解决基于浏览器的自动化任务等。这些专题章节将包含对应库的安装指南。
本书核心内容(第2、3以及4模块)聚焦于近年来发展的现代深度强化学习方法。此处的"深度"指深度学习的广泛应用。需注意的是,深度学习方法对计算资源需求较高:一块现代GPU的计算速度可能比最快的多CPU系统快10至100倍。这意味着,在GPU上训练1小时的代码,在纯CPU系统上可能需半天至一周。虽无GPU仍可运行本书示例,但亲自实验代码(最高效的学习方式)时建议配备GPU资源。获取GPU的途径包括:
- 购买支持CUDA且与PyTorch兼容的现代GPU。
- 使用云服务实例:Amazon Web Services及Google Cloud Platform均提供GPU实例。
- Google Colab:其Jupyter Notebook服务提供免费GPU支持。
系统配置指南不在本书讨论范围内,但互联网上有丰富教程资源。操作系统方面建议使用Linux或macOS。虽然Windows支持PyTorch和Gymnasium,但本书示例未在Windows系统上完整测试。
为明确代码依赖版本,以下提供requirements.txt
文件(基于Python 3.11测试,其他版本可能需要调整依赖项,否则将无法运行):
[text]
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
Chapter 2 33
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编写并测试通过,你也可以通过访问https://pytorch.org网站并按照其上的说明进行安装(通常,根据你的操作系统,只需执行conda install pytorch torchvision -c pytorch或pip install torch命令即可)。
现在,让我们深入了解OpenAI Gym接口的详细信息,它为我们提供了从简单到复杂的各种环境。
3.OpenAI Gym API与Gymnasium库
由OpenAI(www.openai.com)开发的Python库Gym,其首个版本于2017年发布。自那时起,许多环境都是根据这套原始API来开发或者采纳,该API已成为强化学习(RL)领域的事实标准。
在2021年,开发OpenAI Gym的团队将开发工作转移到了Gymnasium,这是原始Gym库的分支。Gymnasium提供了相同的API,旨在成为Gym的 “ 即插即用替代品(drop-in replacement)”(你可以在代码里写import gymnasium as gym
,你的代码很可能仍能正常工作)。
📝警告或重要的注释:
本书示例代码使用的是Gymnasium库,但在文中,为了简洁起见,我将使用“Gym”来称呼。在极少数情况下,如果产生歧义,我会使用“Gymnasium”。
Gym的主要目标是提供一个丰富的环境集合,用于通过统一接口进行强化学习实验。因此,该库中的核心类是名为Env的环境类,这一点并不奇怪。Env类的实例暴露了几个方法和字段,以提供有关其功能的必要信息。从高层次来看,每个环境都提供了以下信息和功能:
- 允许在环境中执行的动作集合:Gym支持离散和连续两种类型动作,以及两者的组合。
- 观察数据的形状和边界,这是环境向智能体提供的。
- 名为step的方法:用于执行动作,返回当前观察、奖励以及一个表示回合是否结束的标志。
- 名为reset的方法:将环境重置为其初始状态,并获得第一个观察数据。
接下来,我们将详细讨论环境的这些组件。
3.1 动作空间
如前所述,智能体可以执行的动作可分为离散动作、连续动作或二者的组合。
离散动作(Discrete actions) 是智能体可执行的固定动作集合,例如,网格环境中的移动方向(左/右/上/下)。另一个例子就是按下按钮的两个状态(按下/释放)。这两种动作之间具有互斥性,互斥性就是离散动作空间的主要特征,每次只能从有限集合中选择一个动作。
连续动作(continuous action) 是带有连续数值参数的动作,例如, 方向盘,它能够转动至一个具体的角度(-720度 到 720度之间),或者是油门踏板,可以以不同力度按下踏板(通常为0到1之间)。连续动作的描述包含动作值的边界范围。
当然,我们并不局限于单一的动作;环境可以采取多种动作,例如同时按下多个按钮,或转动方向盘并踩下两个踏板(刹车和油门)。为了支持这些情况,Gym定义了一个特殊的容器类,允许将多个动作空间嵌套为统一的动作中。
3.2 观察空间
如第1章所述,观察是环境在每个时间步提供给智能体除奖励外的信息。观察数据可以简单到是一组数字,也可以复杂到来自多个摄像头的若干个包含彩色图像的多维张量。观察数据甚至可以是离散的,这与动作空间类似。离散观察空间的一个例子是灯泡状态,它可以处于两种状态——开或关,以布尔值形式呈现。
由此可见,动作和观察具有相似性,这也体现在Gym库里类的设计中。让我们来看类结构图:
基础的抽象类Space包含一个属性和三个核心方法:
- shape:该属性表示空间的形状,与NumPy数组的shape一致。
- sample():这个函数返回从空间中随机采样的结果。
- contains(x):检查参数x是否属于该空间的范围。
- seed():该函数初始化一个随机数生成器,该随机数生成器用于空间及其所有子空间。如果你希望在多次运行中获得可复现的环境行为,那么这函数将非常有用。
这些方法均为抽象方法,在各个Space子类中重新实现:
- Discrete类表示从0到n-1编号的互斥项集合。通过可选构造参数 start 调整起始索引。n 值是指Discrete对象包含集合元素的个数。例如,Discrete(n=4)可用于表示四个移动方向的动作空间[左、右、上、下]。
- Box类表示取值区间为[low, high]的n维有理数张量。例如:前面有个环境提到的油门踏板取值范围
[
0.0
,
1.0
]
[0.0,1.0]
[0.0,1.0],可以用 Box(low=0.0, high=1.0, shape=(1,), dtype=np.float32) 来表示,这里的shape参数为包含一个元素的元组,其中里面元素为数值1,这表示将会给我们一维张量,里面只有1个数值。dtype 参数定义了space的值类型,在例子中,我们定义其为Numpy 32位float类型。
另一个Box的例子是Atari屏幕观察(我们稍后会介绍许多Atari环境),这是一个大小为210×160的RGB(红、绿、蓝)图像:Box(low=0, high=255, shape=(210, 160, 3), dtype=np.uint8) 。在这种情况下,shape参数是一个包含三个元素的元组:第一个维度是图像的高度,第二个是宽度,第三个为3(分别对应红色、绿色和蓝色的三个颜色平面)。因此,每个观察数据都是一个三维张量,总大小为100,800个字节。 - Space的最后一个子类是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类在某些情况下还是很有用的。
❗️ 疑似缺陷报告 ❗️
本人去Gymnasium代码里查证了下,图2.1中的Space子类里面没有一个名为spaces
子类,spaces
反而是一个模块,包含Space各种子类定义的py文件,其中包含Tuple类,Tuple类的传入参数恰好有一个名为spaces
的可迭代Space类型元素的容器,如下图所示,故图2.1里的Spaces
和其下参数Tuple
的两者关系应该对调过来才是正确表述。
Gym还定义了其他子类如Sequence(表示可变长序列)、Text(字符串)和Graph(其空间是节点集合和节点之间的连接关系),但上述三个(Box,Discrete以及Tuple)最为常用。
每个环境都包含action_space
和observation_space
两个Space类型的成员,这使得我们可以创建通用代码,使其可以适用于任何环境。当然,处理屏幕像素与处理离散观察数据的方法是不同的(前者使用卷积层或其他计算机视觉工具的方法对图像进行预处理);所以,大多数情况下,这意味着针对特别环境或成组的环境是需要优化代码,但Gym并不限制我们创建通用的代码。
3.3 环境
Gym中是由Env类来表示环境,该类有以下成员:
- action_space(动作空间):这是Space类的字段,规定了环境中允许执行的动作。
- observation_space(观测空间):同样是Space类字段,但规定了环境所提供的观察数据。
- reset()(重置):将环境重置到初始状态,返回初始观察向量和包含环境额外信息的字典信息。
- step()(步进):让智能体执行动作并返回动作结果信息(该方法较为复杂,我们将在本节后续详细分析):
- 下一观察值
- 即时奖励
- 回合结束标志
- 回合截断标志
- 包含环境额外信息的字典
Env类还包含其他工具函数,如render()用于以人类可读形式呈现观察数据,但是本文不计划使用它们,你有兴趣的话也可以在Gym的文档里面找到全部工具函数的清单。因为本文重点聚焦Env核心函数:reset()和step()。
由于reset()函数比较简单,我们将从它开始。其无参数调用即可重置环境至初始状态并获取初始观察。注意创建环境后必须调用reset()。如第1章所述,智能体与环境的交互会有结束的时候(如出现"游戏结束"的画面)。这样的交互情节称为回合(episode),回合结束后智能体需要重新开始。reset()函数返回值是环境的首个观察。
除了返回观察外,reset()还返回包含环境特定额外的字典信息。大部分标准环境通常返回的是空字典,但复杂环境(如TextWorld环境,一种互动小说游戏类型的模拟器,我们稍后会在书中讨论)可能返回其他信息,这样的信息通常与标准观察数据不兼容。
step()函数是环境的核心部分,单次调用实现了多步操作,如下所示:
- 告知环境下一步要执行的动作
- 在提交动作后从环境获取新观察
- 获取智能体在当前时间步所得到的奖励
- 获取回合结束标记
- 获取回合截断标记(如时间限制触发时)
- 获取以字典类型表示的环境特定信息。
前面清单中的第一项(提到的动作action)作为唯一参数传递给step()函数,其余项由该函数返回。更具体地说,这是一个包含五个元素的元组(此元组是指Python的元组tuple,而不是我们在前一节讨论Space子类的Tuple类型),这五个元素是观察observation , 奖励reward , 结束标记done , 截断标记truncated , 以及 其他信息info .
这五个元素的类型以及含义:
- observation(观察):NumPy向量/矩阵形式的观察数据。
- reward(奖励):浮点型奖励值。
- done(终止标志):布尔值,True表示回合结束,需调用reset()。
- truncated(截断标志):布尔值,True表示回合被截断。对于大多数环境而言,截断意思为时间限制(用于限制回合长度),但在某些环境中可能有不同的含义。此标志与done标志分开,因为在某些场景中,区分“智能体的交互活动到达了回合尽头”和“智能体的交互活动达到了环境时间限制”这两种情况时候, truncated 标志能派得上用场。若truncated若为True,如同done标志那样调用reset()。
- info(其他信息):info可以是关于环境任何特定的内容,包含环境的额外信息。在RL实践中通常忽略此值。
到此,你可能已经了解了智能体代码中环境的用法——我们循环调用step()函数执行某个动作,直到done或truncate标志变为真。然后调用reset()函数重新开始。唯一缺少的部分还没讲——如何首先创建Env对象。
3.4 创建一个环境
每个环境均拥有唯一的环境名称,命名格式为EnvironmentName-vN,其中N为版本号,用于区分同一环境的不同版本(例如修复漏洞或重大更新时版本迭代)。创建一个环境实例,Gymnasium包提供了make(name) 函数,其唯一参数为字符串形式的环境名称。
在撰写本文时,Gymnasium 0.29.1版本(与[atari]扩展版本一起安装)共包含1,003个不同名称的环境。当然,所有环境都不是唯一的,因为该环境清单中包含了同一环境的所有历史版本。此外,同一环境可能存在参数设置及观察空间的不同变体。例如,Atari游戏Breakout拥有以下环境名称:
- Breakout-v0、Breakout-v4:乃原始版本,弹球的初始位置和方向是随机的。
- 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种环境变体。若你未曾见过此游戏,以下是其游戏画面的截图:
即使在去除这些同款的重复环境之后,Gymnasium仍能够提供令人印象深刻的环境清单:198个独一无二的环境,这些环境可以分为以下几组:
- 经典控制问题:是一些玩具任务,用于最优控制理论和强化学习(RL)论文中的基准或演示。这些任务通常很简单,具有低维度的观察空间和动作空间,但在快速验证实现过的算法时很有用。可以将它们视为 “强化学习的MNIST”(MNIST是Yann LeCun提供的手写数字识别数据集,可以在http://yann.lecun.com/exdb/mnist/上找到)。
- Atari 2600:这些是来自20世纪70年代经典游戏平台的游戏。共有63个独一无二的游戏。
- 算法类:聚焦于执行小型计算任务的问题,如复制观察序列或进行数字加法。
- Box2D:这些环境使用Box2D物理模拟器来学习行走或汽车控制。
- MuJoCo:这是另一个用于多个连续控制问题的物理模拟器。
- 参数调优:使用强化学习来优化神经网络参数。
- 玩具文本:简单文本环境版的网格世界。
当然,兼容Gym API的强化学习环境还有更多。例如,Farama基金会维护了几个与特殊强化学习主题相关的代码仓库,如多智能体强化学习、3D导航、机器人技术和Web自动化。此外,还有许多第三方代码仓库。想了解这方面的情况,你可以查看Gymnasium文档。
现在理论已经讲够了!现在让我们来使用Python交互式会话来使用Gym中的某个环境。
3.5 在Ptyhon会话里使用CartPole环境
让我们运用所学知识,探索Gym提供的最简单强化学习环境之一。
$ python
>>> import gymnasium as gym
>>> e = gym.make("CartPole-v1")
这里,我们已经导入了gymnasium包,并创建了一个名为CartPole的环境。这个环境属于经典控制问题分组,其要领是控制一个底部附有木棒的平台(参见下图)。
其难点在于,这个木棒容易向右或向左倒下,你需要通过每一步向左或向右移动平台来保持木棒的平衡。
这个环境的观察值是四个浮点数,包含了木棒质心的x坐标、木棒质心的速度、木棒与平台的夹角以及角速度信息。当然,运用一些数学和物理知识,将这些数字转化为需要平衡木棒时的动作并不复杂,但我们面对的问题不一样——如何在不知道观察数字确切含义的情况下,仅通过获得奖励来学习如何平衡这个系统?在这个环境中每个时间步都会给出奖励,奖励值为1。回合会持续进行,直到木棒倒下,因此,为了获得更多累积奖励,我们需要以某种方式平衡平台以避免木棒倒下。
这个问题可能看起来很困难,但在接下来的两章中,我们将编写一个算法,在几分钟内就轻松解决CartPole问题,而无需了解观察数字的含义。我们只需要通过试错和使用一点强化学习的魔力来做到这一点。
但是现在,让我们继续我们的Python会话。
>>> obs, info = e.reset()
>>> obs
array([ 0.02100407, 0.02762252, -0.01519943, -0.0103739 ], dtype=float32)
>>> info
{}
在会话里我们重置了环境,并获得了第一个观察值(我们总是需要重置刚创建的环境)。正如我所说,观观察值是四个数字,所以这里没有什么惊喜。现在,让我们来检查环境的动作空间和观察空间:
>>> 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类型,所以我们的动作只能是0或1,其中0表示向左推动平台,1表示向右推动。observation_space是Box(4,)类型,这意味着它是一个含有四个数字的向量。observation_space字段中显示的第一个列表是参数的下界,第二个是上界。
如果你对该环境的观察空间好奇,你可以在Gymnasium代码仓库的cartpole.py文件中查看环境的源代码,地址。
CartPole类的文档字符串(即"""符号起止范围内)提供了所有详细信息,包括了观察的语义:
• 小车位置(Cart position):值在-4.8…4.8范围内
• 小车速度(Cart velocity):值在
−
∞
-\infty
−∞ …
∞
\infty
∞范围内
• 木棒角度(Pole angle):值在-0.418 … 0.418弧度范围内
• 木棒角速度(Pole angular velocity):值在
−
∞
-\infty
−∞ …
∞
\infty
∞ 范围内
Python使用float32的最大值和最小值来表示无穷大,这就是为什么边界向量中的一些条目具有 1 0 38 10^{38} 1038的量级值。所有这些内部细节都很有趣,但使用强化学习方法解决环境时,绝对不需要了解它们。让我们继续,并向环境发送一个动作:
>>> e.step(0)
(array([-0.01254663, -0.22985364, -0.01435183, 0.24902613], dtype=float32), 1.0, False,False, {})
这里,我们通过执行动作0向左推动了我们的平台,并得到了一个包含五个元素的元组:
• 一个新的观察值,它是一个包含四个数字的新向量。
• 奖励为1.0。
• done标志的值为False,这意味着回合还没有结束,意味着我们处于或多或少平衡住了木棒。
• truncated标志的值为False,意味着回合没有被截断。
• 关于环境的额外信息,这是一个空字典
接下来,我们将在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)
这个方法从底层空间中返回了一个随机样本。在我们Discrete动作空间的情况下,意味着一个随机的0或1数字,而对于观察空间,意味着一个随机的四个数字的向量。观察空间的随机样本没什么用,但动作空间的随机样本可以在我们不确定如何执行动作时来使用。
其实这个功能特别方便,如果你没感觉,只是因为你目前还不了解任何强化学习方法,不过我们仍然想在Gym环境玩耍下。现在,你既然已经知道足够多的知识来为CartPole实现你的第一个随机行为智能体,让我们开始吧。
3.6 随机策略的倒立摆智能体
尽管目前的环境比第2节中的第一个示例复杂得多,但智能体的代码却简短得多。这正是可重用性、抽象层和第三方库的威力所在!
以下是代码(你可以在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()
此处,我们创建了环境并初始化了时间步计数器和奖励累加器。最后一行重置环境以获取第一个观察值(由于我们的智能体是随机策略,此处不会使用该观测):
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)、奖励、is_done标志以及is_trunc标志。如果回合结束,则停止循环并显示已执行的时间步数和累计奖励。运行此示例时,你会看到类似这样的输出(由于智能体的随机性,结果不会完全相同):
Chapter02$ python 02_cartpole_random.py
Episode done in 12 steps, total reward 12.00
平均来说,随机策略下的智能体在木棒倒下且回合结束前会执行12到15个时间步。Gym中的大多数环境都有一个"奖励边界",即智能体需要连续100个回合达到的平均奖励值才能被视为"解决"该环境。对于CartPole,这个边界值是195,意味着智能体平均需要保持木棒直立195个时间步或更长时间。从这个标准来看,我们的随机智能体表现不佳。但请别失望——我们才刚刚开始,很快你就能解决CartPole以及许多更有趣、更具挑战性的环境。
4.Gym接口的额外功能
目前我们讨论的内容已覆盖了Gym核心接口的三分之二,以及编写智能体所需的基本函数。其余API虽然可以不用,但它们能简化代码并提升开发效率。让我们简要介绍接口的其他功能。
4.1 包装器(Wrappers)
比较常见的情况是,当你想以通用方式来扩展环境功能时,那么包装器非常实用。例如,当环境给你一些观察数据时,但你希望将这些数据在某个缓冲区中累积起来,并向智能体提供𝑁个最近的观察结果,这种场景是动态计算机游戏中比较常见,因为单帧画面不足以获得关于游戏状态的完整信息。另一个例子是,当你想要裁剪或预处理图片的像素,以适配智能体去读取,又或者如果你想以某种方式对奖励分数进行归一化。有许多这样相似结构的情况——你想“包装”现有的环境并添加一些额外的逻辑来执行某些操作。Gym为此提供了Wrapper类框架。
其类结构如图2.4所示:
要处理更具体的要求,例如,某个Wrapper类只想处理来自环境的观察结果,或者只处理动作,那么有以下Wrapper的子类,可以做到过滤特定信息的一部分。它们如下所示:
- ObservationWrapper:通过重写父类的observation(obs)函数处理观测数据,obs参数是来自被包装环境的观察结果,此函数应返回观察结果以提供给智能体。
- RewardWrapper:这个包装器暴露了reward(rew)函数来修改奖励值,这些奖励值是用于提供给智能体。例如将奖励值调整到所需的范围内,根据之前的某些动作添加折扣,或者类似的操作。
- ActionWrapper:需要重写action(a)函数,该方法可以调整智能体向 被包装环境 传入的动作。
为了让示例更具实践性,假设我们希望在智能体生成的动作流中进行干预,以10%的概率将当前动作替换为随机动作。这个技巧咋一看没什么用,但正是这个简单技巧解决了第一章提到的探索/利用困境,是最实用且高效的解决方案之一。通过插入随机动作,我们让智能体持续探索环境,偶尔偏离其策略的常规路径。使用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
和包装器抽象层,我们实现了通用代码,可适配Gym中的任意环境。我们还添加了控制台信息输出(仅用于演示包装器的工作状态),实际生产代码中应移除此类调试信息。
现在我们将创建标准的CartPole环境,并将该环境实例传入包装器的构造函数中:
if __name__ == "__main__":
env = RandomActionWrapper(gym.make("CartPole-v1"))
从这个代码之后,我们可以将包装过的环境作为标准Env实例来使用,替代了原始的CartPole环境。由于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}")
通过运行代码,你应该看到包装器确实正在运行着:
Chapter02$ python 03_random_action_wrapper.py
Random action 0
Random action 0
Reward got: 9.00
现在我们应该继续学习如何在程序执行过程中渲染环境。
4.2 渲染环境
另一个需要注意的功能是环境渲染。这通过两个包装器实现: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()方法,窗口会迅速关闭。
另一个实用包装器是RecordVideo(视频录制),它通过捕获环境中的像素数据生成智能体行为的视频文件。其用法与HumanRendering类似,但需额外指定存储视频文件的目录参数。若目录不存在,将自动创建:
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
此包装器尤其适用于在无图形界面的远程机器上运行智能体的时候。
4.3 更多的包装器
Gymnasium 提供了许多其他包装器,我们将在后续章节中使用它们。它可以对 Atari 游戏图像进行标准化预处理、奖励归一化、观察帧堆叠、环境向量化、时间限制等更多操作。
可用包装器的完整清单可在文档链接和源代码中找到。
5.总结
你已经开始学习强化学习的实践方面!在本章中,我们实践了 Gymnasium库,它有大量的环境可供玩耍。我们也研究了其基本 API,并创建了一个随机行为的智能体。
你还学习了如何以模块化方式扩展现有环境的功能,并熟悉了如何使用包装器呈现智能体的活动。这将在后续章节中大量使用。
在下一章中,我们将使用 PyTorch 快速回顾深度学习,PyTorch 是最广泛使用的深度学习工具包之一。
按图索骥
模块一:强化学习初探
- 第1章:什么是强化学习
- 第2章:OpenAI Gym接口和 Gymnasium库
- 第3章:使用Pytorch进行深度学习
- 第4章:交叉熵方法
模块二:基于价值的方法
- 第5章:表格学习与贝尔曼方程
- 第6章:深度Q网络
- 第7章:更高级的强化学习库
- 第8章:DQN扩展
- 第9章:加速强化学习的方法
- 第10章:使用强化学习进行股票交易
模块三:基于策略的方法
- 第11章:策略梯度
- 第12章:Actor-Critic方法
- 第13章:TextWorld环境
- 第14章:网页导航
模块四:高级强化学习
- 第15章:连续动作空间
- 第16章:信任域方法
- 第17章:强化学习里的黑盒优化
- 第18章:高级探索
- 第19章:带有人类反馈的强化学习
- 第20章:AlphaGo Zero 和 MuZero
- 第21章:离散优化中的强化学习
- 第22章:多智能体强化学习