Ray-学习指南-早期发布--全-

Ray 学习指南(早期发布)(全)

原文:annas-archive.org/md5/3e589da5aa6cdfe1ef0de6b67d8e8ade

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:Ray 概述

分布式系统是指你甚至都不知道存在的计算机出现故障,可能会使你自己的计算机无法使用。

莱斯利·兰波特

我们需要高效的分布式计算的原因之一是,我们正在以越来越快的速度收集越来越多种类的数据。过去十年中出现的存储系统、数据处理和分析引擎对许多公司的成功至关重要。有趣的是,大多数“大数据”技术是由(数据)工程师构建和操作的,他们负责数据收集和处理任务。其理念是为了让数据科学家可以专注于他们擅长的工作。作为数据科学从业者,您可能希望专注于训练复杂的机器学习模型、运行高效的超参数选择、构建全新和定制的模型或仿真,或者提供您的模型以展示它们。同时,您可能必须将它们扩展到计算集群。为此,您选择的分布式系统需要支持所有这些细粒度的“大计算”任务,可能还需要使用专门的硬件。理想情况下,它还应该适合您正在使用的大数据工具链,并且足够快以满足您的延迟要求。换句话说,分布式计算必须强大且灵活,以处理复杂的数据科学工作负载,而 Ray 可以帮助您实现这一点。

Python 很可能是今天数据科学中最流行的语言,对我来说,在日常工作中它是最实用的。现在它已经超过 30 年了,但仍然拥有一个不断增长和活跃的社区。丰富的 PyData 生态系统 是数据科学家工具箱的重要组成部分。你如何确保扩展你的工作负载同时仍然利用你需要的工具?这是一个棘手的问题,特别是因为社区不能被迫抛弃他们的工具箱或编程语言。这意味着数据科学的分布式计算工具必须为其现有的社区构建。

Ray 是什么?

我喜欢 Ray 的原因是它符合以上所有要求。它是为 Python 数据科学社区构建的灵活分布式计算框架。Ray 容易上手,保持简单事物的简单。它的核心 API 尽可能精简,帮助您有效地思考要编写的分布式程序。您可以在笔记本电脑上有效地并行执行 Python 程序,并且几乎无需任何更改即可在集群上运行您在本地测试过的代码。它的高级库易于配置,并且可以无缝地一起使用。其中一些,如 Ray 的强化学习库,将有一个光明的未来作为独立项目,无论是分布式还是非分布式。虽然 Ray 的核心是用 C++ 构建的,但从一开始就是以 Python 为主的框架,与许多重要的数据科学工具集成,并且可以依靠一个不断增长的生态系统。

分布式 Python 并不新鲜,Ray 也不是这个领域的第一个框架(也不会是最后一个),但它在提供内容方面确实与众不同。当你结合 Ray 的多个模块并拥有自定义的、机器学习密集型工作负载时,Ray 表现尤为出色,否则这些工作将难以实现。它通过利用你已知且想要使用的 Python 工具,轻松地运行复杂工作负载,使分布式计算灵活多变。换句话说,通过学习 Ray,你可以了解数据科学中灵活的分布式 Python

在本章中,你将初步了解 Ray 可以为你做些什么。我们将讨论组成 Ray 的三个层次,即其核心引擎、高级库和生态系统。在整个章节中,我们将展示首个代码示例,让你对 Ray 有所感觉,但我们将深入讨论 Ray 的 API 和组件留到后面的章节。你可以把本章视为整本书的概述。

什么导致了 Ray 的出现?

编写分布式系统很困难。它需要你可能没有的特定知识和经验。理想情况下,这样的系统应该不会干扰你,而是提供抽象层让你专注于工作。但实际上,“所有非平凡的抽象,在某种程度上,都会泄漏”(Spolsky),让计算机集群按照你的意愿工作无疑是困难的。许多软件系统需要远远超出单个服务器所能提供的资源。即使一个服务器足够了,现代系统也需要具备故障安全性,并提供高可用性等功能。这意味着你的应用程序可能需要在多台甚至多个数据中心上运行,以确保它们的可靠性。

即使你对机器学习(ML)或更广义的人工智能(AI)不太熟悉,你一定听说过这个领域的最新突破。仅举两个例子,像Deepmind 的 AlphaFold解决蛋白质折叠问题,或者OpenAI 的 Codex帮助软件开发人员处理繁琐的工作,近期都成为了新闻。你可能也听说过,ML 系统通常需要大量数据来进行训练。OpenAI 在他们的论文“AI and Compute”中展示了 AI 模型训练所需的计算能力呈指数增长,这些操作以 petaflops(每秒数千万亿次操作)计量,自 2012 年以来每 3.4 个月翻倍

将这与摩尔定律¹进行比较,该定律规定计算机中的晶体管数量每两年翻一番。即使你对摩尔定律持乐观态度,你也能看出在机器学习中有分布式计算的明显需求。你还应该了解到,许多机器学习任务可以自然地分解为并行运行。因此,如果可以加快速度,为什么不这样做呢?

分布式计算通常被认为很难。但是为什么会这样呢?难道不应该找到良好的抽象方法在集群上运行代码,而不必不断考虑各个单独的机器及其相互操作吗?如果我们专门关注人工智能工作负载会怎样呢?

加州大学伯克利分校的RISELab的研究人员创建了 Ray 来解决这些问题。当时存在的工具都不能满足他们的需求。他们正在寻找一种简单的方式将工作负载分发到计算集群中以加快处理速度。他们考虑的工作负载性质相当灵活,不适合现有的分析引擎。与此同时,RISELab 希望建立一个系统来处理工作分发的细节。通过合理的默认行为,研究人员应能够专注于他们的工作。理想情况下,他们应该能够使用 Python 中所有他们喜爱的工具。因此,Ray 的设计强调高性能和异构工作负载。Anyscale,Ray 背后的公司,正在构建一个托管 Ray 应用程序的托管平台,并提供托管解决方案。让我们来看看 Ray 设计用于哪些应用程序的示例。

Python 和强化学习中的灵活工作负载

我手机上的一个我最喜欢的应用可以自动分类或“标记”我们花园中的各种植物。它的工作原理很简单,只需展示相关植物的图片即可。这非常有帮助,因为我擅长的不是分辨它们。(我并不是在炫耀我的花园有多大,只是我分辨不好。)在过去几年中,我们见证了许多类似的令人印象深刻的应用程序的激增。

最终,人工智能的承诺是建立超越分类对象的智能代理。想象一下,一个人工智能应用不仅了解您的植物,还可以照顾它们。这样的应用程序必须

  • 在动态环境中运行(如季节变化)

  • 对环境变化做出反应(如剧烈风暴或害虫攻击您的植物)

  • 进行一系列操作(如浇水和施肥)

  • 完成长期目标(如优先考虑植物健康)

通过观察环境,这样的人工智能也会学习探索其可能采取的行动,并随着时间的推移提出更好的解决方案。如果你觉得这个例子太假或者离实际太远,自己也不难想出符合上述所有要求的例子。想想如何管理和优化供应链,在考虑波动需求时战略性地补充仓库存货,或者编排装配线中的加工步骤。另一个可以从人工智能期望中看到的著名例子是史蒂芬·沃兹尼亚克的著名“咖啡测试”。如果你被邀请去朋友家,你可以找到厨房,找到咖啡机和所有必要的配料,弄清楚如何冲一杯咖啡,并坐下来享用。一台机器应该能做同样的事情,尽管最后一部分可能有点难度。你能想到哪些其他例子呢?

你可以在机器学习的一个子领域——强化学习(RL)中自然地表述所有上述要求。我们在第四章中专门讨论了 RL。现在,理解它与代理通过观察环境并发出动作进行互动有关就足够了。在 RL 中,代理通过分配奖励来评估他们的环境(例如,我的植物在 1 到 10 的尺度上有多健康)。术语“强化”来自于代理有望学会寻求导致良好结果(高奖励)的行为,并回避惩罚性情况(低或负奖励)。代理与其环境的交互通常通过创建其计算模拟来建模。正如你可以从我们提供的例子中想象的那样,这些模拟很快就会变得复杂起来。

我们还没有像我描绘的那种园艺机器人。我们也不知道哪种人工智能范式会让我们达到那里。我知道的是,世界充满了复杂、动态和有趣的例子,我们需要应对这些问题。为此,我们需要帮助我们做到这一点的计算框架,而 Ray 正是为此而建立的。RISELab 创建 Ray 以在规模上构建和运行复杂的人工智能应用程序,而强化学习从一开始就是 Ray 的一个组成部分。

三层:核心、库和生态系统

现在你知道为什么 Ray 被建立以及其创作者的初衷,让我们来看看 Ray 的三个层次。

  • 一个用简明的核心 API 为 Python 提供的低级分布式计算框架。³

  • 由 Ray 的创建者构建和维护的一组高级数据科学库。

  • 一个与其他著名项目进行整合和合作的不断增长的生态系统。

这里有很多内容需要解开,我们将在本章节的剩余部分逐个探讨这些层次。您可以将 Ray 的核心引擎及其 API 想象为中心,其他所有东西都是在其基础上构建的。Ray 的数据科学库则是在其之上构建的。在实践中,大多数数据科学家将直接使用这些更高级别的库,并且不经常需要回到核心 API。对于经验丰富的从业者来说,Ray 的第三方集成数量不断增加,是另一个很好的切入点。让我们逐个看看这些层次。

分布式计算框架

Ray 的核心是一个分布式计算框架。在这里,我们只介绍基本术语,并在第二章中深入讨论 Ray 的架构。简而言之,Ray 设置并管理计算机群集,以便您可以在其上运行分布式任务。一个 Ray 群集由通过网络连接的节点组成。您将程序编写到所谓的 driver,即程序根节点上,该节点位于 head node 上。driver 可以运行 jobs,即在群集节点上运行的任务集合。具体来说,一个 job 的各个任务在 worker nodes 上的 worker 进程上运行。图 图 1-1 描述了 Ray 群集的基本结构。

Ray 群集示意图

图 1-1. Ray 簇的基本组件

有趣的是,Ray 群集也可以是一个 本地群集,即仅由您自己的计算机组成的群集。在这种情况下,只有一个节点,即头节点,它具有驱动程序进程和一些工作进程。默认的工作进程数量是您机器上可用的 CPU 数量。

有了这些知识,现在是时候动手运行您的第一个本地 Ray 群集了。在任何主流操作系统上使用 pip 安装 Ray⁴ 应该是无缝的:

pip install "ray[rllib, serve, tune]"==1.9.0

通过简单的 pip install ray 命令,您只安装了 Ray 的基本组件。由于我们希望探索一些高级功能,我们还安装了“extras” 中的 rllibservetune,稍后我们将讨论它们。根据您的系统配置,您可能不需要上述安装命令中的引号。

接下来,请启动一个 Python 会话。您可以使用 ipython 解释器,我觉得它非常适合跟随简单示例。如果您不想自己输入命令,也可以转到本章节的 jupyter notebook 并在那里运行代码。选择权在您,但无论如何,请记住使用 Python 版本 3.7 或更高版本。在您的 Python 会话中,您现在可以轻松导入并初始化 Ray,如下所示:

示例 1-1.
import ray
ray.init()

使用这两行代码,你已经在本地机器上启动了一个 Ray 集群。这个集群可以作为工作节点利用你计算机上所有可用的核心。在这种情况下,你没有向 init 函数提供任何参数。如果你想在一个“真实”的集群上运行 Ray,你需要向 init 传递更多参数。其余的代码将保持不变。

运行这段代码后,你应该会看到以下形式的输出(我们使用省略号删除了杂乱的内容):

... INFO services.py:1263 -- View the Ray dashboard at http://127.0.0.1:8265
{'node_ip_address': '192.168.1.41',
 'raylet_ip_address': '192.168.1.41',
 'redis_address': '192.168.1.41:6379',
 'object_store_address': '.../sockets/plasma_store',
 'raylet_socket_name': '.../sockets/raylet',
 'webui_url': '127.0.0.1:8265',
 'session_dir': '...',
 'metrics_export_port': 61794,
 'node_id': '...'}

这表明你的 Ray 集群已经启动并运行。从输出的第一行可以看出,Ray 自带一个预打包的仪表板。很可能你可以在 http://127.0.0.1:8265 查看它,除非你的输出显示了不同的端口。如果愿意,你可以花些时间探索这个仪表板。例如,你应该看到列出了所有 CPU 核心以及你(简单的)Ray 应用程序的总利用率。我们将在后续章节中回到仪表板。

我们在这里还没有完全准备好深入了解 Ray 集群的所有细节。稍微提前一点,你可能会看到 raylet_ip_address,它是所谓的 Raylet 的引用,负责在你的工作节点上安排任务。每个 Raylet 都有一个用于分布式对象的存储,上面的 object_store_address 暗示了这一点。任务一旦被安排,就会由工作进程执行。在 第二章 中,你将更好地理解所有这些组件以及它们如何组成一个 Ray 集群。

在继续之前,我们还应该简要提到 Ray 核心 API 非常易于访问和使用。但由于它也是一个相对底层的接口,使用它构建有趣的示例需要一些时间。第二章 中有一个广泛的第一个示例,可以帮助你开始使用 Ray 核心 API,在 第三章 中,你将看到如何构建一个更有趣的 Ray 强化学习应用程序。

现在你的 Ray 集群还没有做太多事情,但这将很快改变。在下一节快速介绍数据科学工作流程之后,你将运行你的第一个具体的 Ray 示例。

一套数据科学库

转向 Ray 的第二层,本节将介绍 Ray 自带的所有数据科学库。为此,让我们首先俯瞰一下做数据科学意味着什么。一旦理解了这个背景,理解 Ray 的高级库并看到它们如何对你有用就容易得多了。如果你对数据科学过程有很好的理解,可以直接跳到 “使用 Ray Data 进行数据处理” 部分。

机器学习与数据科学工作流程

“数据科学”(DS)这个有些难以捉摸的术语近年来发生了很大变化,你可以在网上找到许多不同用途的定义。[⁵] 对我来说,它是通过利用数据获得见解并构建真实应用的实践。这是一个非常广泛的定义,你不一定同意我的观点。我的观点是,数据科学是一个围绕构建和理解事物的实践和应用领域,这在纯粹学术背景下几乎没有意义。从这个意义上讲,将这个领域的从业者描述为“数据科学家”就像将黑客描述为“计算机科学家”一样不合适。[⁶]

既然你对 Python 很熟悉,希望你带有一定的工匠精神,我们可以从非常实用的角度来探讨 Ray 的数据科学库。在实践中进行数据科学是一个迭代的过程,大致如下:

需求工程

你需要与利益相关者交流,明确需要解决的问题,并为这个项目澄清需求。

数据收集

然后你收集、检查和审视数据。

数据处理

然后你处理数据,以便能够解决问题。

模型构建

接着,你开始使用数据构建模型(广义上),这可以是一个包含重要指标的仪表盘,一个可视化效果,或者一个机器学习模型,还有许多其他形式。

模型评估

接下来的步骤是根据第一步的要求评估你的模型。

部署

如果一切顺利(很可能不会),你将在生产环境中部署解决方案。你应该将其视为需要监控的持续过程,而不是一次性步骤。

否则,你需要回到起点重新开始。最有可能的结果是,即使在初次部署之后,你也需要在各种方面改进解决方案。

机器学习并不一定是这个过程的一部分,但你可以看到构建智能应用或获得见解如何从机器学习中受益。在你的社交媒体平台中建立人脸检测应用,无论是好是坏,可能就是一个例子。当明确包括构建机器学习模型的数据科学过程时,你可以进一步指定一些步骤:

数据处理

要训练机器学习模型,你需要数据以一种能被你的 ML 模型理解的格式。转换和选择应该被馈送到模型中的数据的过程通常称为特征工程。这一步骤可能会很混乱。如果你能依靠常用工具来完成这项工作,你将受益匪浅。

模型训练

在机器学习中,你需要在上一步处理的数据上训练算法。这包括选择适合任务的正确算法,如果你能从多种算法中选择,那将会很有帮助。

超参数调优

机器学习模型具有在模型训练步骤中调整的参数。大多数 ML 模型还有另一组参数,称为超参数,可以在训练之前修改。这些参数可以严重影响您所得到的 ML 模型的性能,需要适当地进行调整。有很好的工具可以帮助自动化这个过程。

模型服务

训练后的模型需要部署。服务一个模型意味着通过任何必要的手段使其对需要访问的人员可用。在原型中,通常使用简单的 HTTP 服务器,但也有许多专门的 ML 模型服务软件包。

这个列表并不全面。如果你从未经历过这些步骤或对术语感到困惑,不要担心,我们将在后面的章节中详细讨论。如果你想更深入地了解构建机器学习应用程序时数据科学流程的整体视图,书籍《Building Machine Learning Powered Applications》专门讨论了这个问题。

图 Figure 1-2 概述了我们刚讨论的步骤:

数据科学实验工作流

图 1-2. 使用机器学习进行数据科学实验工作流程的概述

此时,你可能想知道这些与 Ray 有什么关系。好消息是 Ray 为上述四种专门的机器学习任务各自提供了专用库,涵盖了数据处理、模型训练、超参数调整和模型服务。而 Ray 的设计方式,所有这些库都是分布式构建的。接下来我们逐一介绍每一个。

使用 Ray Data 进行数据处理

Ray 的第一个高级库被称为“Ray Data”。这个库包含一个称为Dataset的数据结构,用于加载各种格式和系统的数据的多种连接器,用于转换这些数据集的 API,用于构建数据处理管道以及与其他数据处理框架的许多集成。Dataset抽象构建在强大的Arrow 框架之上。

要使用 Ray Data,你需要安装 Python 的 Arrow,例如通过运行pip install pyarrow。我们现在将讨论一个简单的示例,从 Python 数据结构创建一个在本地 Ray 集群上分布式的Dataset。具体来说,你将从一个包含字符串name和整数data的 Python 字典创建一个包含10000个条目的数据集:

示例 1-2.
import ray

items = [{"name": str(i), "data": i} for i in range(10000)]
ds = ray.data.from_items(items)   ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
ds.show(5)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

1

使用ray.data模块中的from_items创建一个Dataset

2

打印Dataset的前 10 个条目。

show一个Dataset意味着打印它的一些值。你应该在命令行上精确地看到5个所谓的ArrowRow元素,如下所示:

ArrowRow({'name': '0', 'data': 0})
ArrowRow({'name': '1', 'data': 1})
ArrowRow({'name': '2', 'data': 2})
ArrowRow({'name': '3', 'data': 3})
ArrowRow({'name': '4', 'data': 4})

现在你有了一些分布式的行数据,但是你能用这些数据做什么呢?Dataset API 在函数式编程方面表现得非常出色,因为它非常适合数据转换。尽管 Python 3 在隐藏一些函数式编程能力时有所改进,但你可能已经熟悉诸如 mapfilter 等功能。如果还不熟悉,学起来也很容易。map 对数据集的每个元素进行转换,并行进行。filter 根据布尔过滤函数删除数据点。稍微复杂一点的 flat_map 首先类似于 map 映射值,然后还会“展平”结果。例如,如果 map 会生成一个列表的列表,flat_map 将会展平嵌套列表并给出一个单一的列表。有了这三个函数式 API 调用,让我们看看你能多轻松地转换你的数据集 ds

示例 1-3。使用常见的函数式编程例程转换 Dataset
squares = ds.map(lambda x: x["data"] ** 2)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)

evens = squares.filter(lambda x: x % 2 == 0)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
evens.count()

cubes = evens.flat_map(lambda x: [x, x**3])  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
sample = cubes.take(10)  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)
print(sample)

1

我们将 ds 的每一行映射为仅保留其 data 条目的平方值。

2

然后,我们筛选出 squares 中的偶数(共 5000 个元素)。

3

然后,我们使用 flat_map 用其各自的立方体增强剩余值。

4

take 总共 10 个值意味着离开 Ray 并返回一个可以打印这些值的 Python 列表。

Dataset 转换的缺点是每个步骤都是同步执行的。在示例 Example 1-3 中,这不是问题,但对于复杂的任务,例如混合读取文件和处理数据,您希望执行可以重叠各个任务。DatasetPipeline 正是这样做的。让我们将最后一个例子重写为一个流水线。

示例 1-4。
pipe = ds.window()  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
result = pipe\
    .map(lambda x: x["data"] ** 2)\
    .filter(lambda x: x % 2 == 0)\
    .flat_map(lambda x: [x, x**3])  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
result.show(10)

1

你可以通过在其上调用 .window()Dataset 转换为流水线。

2

流水线步骤可以链接以产生与以前相同的结果。

Ray 数据还有很多要说的,特别是它与显著数据处理系统的集成,但我们必须推迟深入讨论直到 Chapter 7。

模型训练。

接下来让我们看看 Ray 的分布式训练能力。为此,您可以访问两个库。一个专门用于强化学习,另一个则具有不同的范围,主要针对监督学习任务。

使用 Ray RLlib 进行强化学习。

让我们从 Ray RLlib 开始进行强化学习。这个库由现代 ML 框架 TensorFlow 和 PyTorch 提供支持,你可以选择使用其中之一。这两个框架在概念上似乎越来越收敛,所以你可以根据自己的喜好选择其中一个,而不会在过程中失去太多。在整本书中,为了保持一致性,我们使用 TensorFlow。现在就可以通过 pip install tensorflow 安装它。

在 RLlib 中运行示例的最简单方法之一是使用命令行工具 rllib,我们之前已经通过 pip 隐式安装过了。一旦你在 第四章 中运行更复杂的示例,你将主要依赖其 Python API,但现在我们只是想初步尝试运行 RL 实验。

我们将讨论一个相当经典的控制问题,即平衡摆动摆的问题。想象一下,你有一个像图 图 1-3 中所示的摆锤,固定在一个点上并受重力作用。你可以通过从左侧或右侧推动摆锤来操纵它。如果你施加恰到好处的力量,摆锤可能会保持竖直位置。这是我们的目标 - 我们要解决的问题是是否能教会一个强化学习算法来为我们做到这一点。

Pendulum

图 1-3. 控制一个简单的摆锤,通过向左或向右施加力来实现

具体来说,我们希望训练一个能够向左或向右推动的强化学习代理,从而通过作用于其环境(操纵摆锤)来达到“竖直位置”目标,进而获得奖励。要使用 Ray RLlib 解决这个问题,可以将以下内容保存在名为 pendulum.yml 的文件中。

示例 1-5.
# pendulum.yml
pendulumppo:
    env: Pendulum-v1  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    run: PPO  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
    checkpoint_freq: 5  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
    stop:
        episode_reward_mean: 800  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)
    config:
        lambda: 0.1  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/5.png)
        gamma: 0.95
        lr: 0.0003
        num_sgd_iter: 6

1

Pendulum-v1 环境模拟了我们刚刚描述的摆动问题。

2

我们使用一种强大的 RL 算法叫做 Proximal Policy Optimization,即 PPO。

3

每经过五次“训练迭代”我们就会对模型进行检查点保存。

4

一旦我们达到 -800 的奖励,我们将停止实验。

5

PPO 需要一些 RL 特定的配置才能解决这个问题。

这个配置文件的详细信息在这一点上并不重要,请不要被它们分散注意力。重要的部分是你要指定内置的Pendulum-v1环境以及足够的 RL 特定配置,以确保训练过程顺利进行。配置是 Ray 的一个简化版本的tuned examples之一。我们选择了这个配置,因为它不需要任何特殊的硬件,并且在几分钟内完成。如果你的计算机足够强大,你也可以尝试运行这个调整后的示例,它应该会得到更好的结果。要训练这个摆例子,你现在可以简单地运行:

rllib train -f pendulum.yml

如果你愿意,你可以检查这个 Ray 程序的输出,并查看训练过程中不同指标的演变。如果你不想自己创建这个文件,并且想要运行一个能给你更好结果的实验,你也可以这样运行:

curl https://raw.githubusercontent.com/maxpumperla/learning_ray/main/notebooks/pendulum.yml -o pendulum.yml
rllib train -f pendulum.yml

无论如何,假设训练程序已经完成,我们现在可以检查它的表现如何。要可视化训练过的摆,你需要安装另一个 Python 库,使用pip install pyglet。你需要弄清楚的另一件事是 Ray 存储了你的训练进展的地方。当你运行rllib train进行实验时,Ray 会为你创建一个唯一的实验 ID,并默认将结果存储在~/ray-results的子文件夹中。对于我们使用的训练配置,你应该看到一个结果文件夹,看起来像是~/ray_results/pendulum-ppo/PPO_Pendulum-v1_<experiment_id>。在训练过程中,中间的模型检查点会在同一个文件夹中生成。例如,在我的机器上有一个文件夹:

 ~/ray_results/pendulum-ppo/PPO_Pendulum-v1_20cbf_00000_0_2021-09-24_15-20-03/checkpoint_000029/checkpoint-29

一旦你找到实验 ID 并选择了检查点 ID(作为经验法则,ID 越大,结果越好),你可以像这样评估你的摆训练运行的训练性能:

rllib evaluate \
  ~/ray_results/pendulum-ppo/PPO_Pendulum-v1_<experiment_id>/checkpoint_0000<cp-id>/checkpoint-<cp-id> \
  --run PPO --env Pendulum-v1 --steps 2000

你应该看到一个由代理控制的摆的动画,看起来像图 1-3。由于我们选择了快速的训练过程而不是最大化性能,你应该看到代理在摆运动中有些困难。我们本可以做得更好,如果你有兴趣浏览 Ray 调整后的示例来针对Pendulum-v1环境,你会找到许多这个练习的解决方案。这个示例的重点是向你展示使用 RLlib 来训练和评估强化学习任务可以有多简单,只需两个命令行调用到rllib

使用 Ray 进行分布式训练

Ray RLlib 专注于强化学习,但如果你需要为其他类型的机器学习,比如监督学习,训练模型,你可以在这种情况下使用另一个 Ray 库进行分布式训练,称为Ray Train。目前,我们对诸如TensorFlow之类的框架尚不了解,无法为 Ray Train 给出具体和有信息的例子。在第六章讨论时,我们会讨论所有这些。但我们至少可以大致勾画一下 ML 模型的分布式训练“包装器”会是什么样子,概念上足够简单:

例 1-6。
from ray.train import Trainer

def training_function():  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    pass

trainer = Trainer(backend="tensorflow", num_workers=4)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
trainer.start()

results = trainer.run(training_function)  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
trainer.shutdown()

1

首先,定义你的 ML 模型训练函数。我们这里简单传递。

2

然后用 TensorFlow 作为后端初始化一个Trainer实例。

3

最后,在 Ray 集群上扩展你的训练函数。

如果你对分布式训练感兴趣,你可以跳到第六章。

超参数调整

命名事物很难,但 Ray 团队通过Ray Tune找到了关键点,你可以用它来调整各种参数。具体而言,它被设计用于为机器学习模型找到良好的超参数。典型的设置如下:

  • 你想运行一个非常耗费计算资源的训练函数。在 ML 中,运行需要数天甚至数周的训练过程并不罕见,但我们假设你只需几分钟。

  • 作为训练的结果,你计算一个所谓的目标函数。通常你要么希望最大化你的收益,要么最小化你的损失,以你实验的性能为准。

  • 难点在于你的训练函数可能依赖于某些参数、超参数,这些影响你的目标函数值。

  • 你可能有关于个别超参数应该是什么的直觉,但调整它们可能很困难。即使你可以将这些参数限制在合理的范围内,测试各种组合通常是不可行的。你的训练函数简直太昂贵了。

你能做些什么来有效地采样超参数,并在你的目标上获得“足够好”的结果?专注于解决这个问题的领域称为超参数优化(HPO),而 Ray Tune 拥有大量用于解决此问题的算法。让我们看一个 Ray Tune 的第一个例子,用于我们刚刚解释的情况。重点再次在 Ray 及其 API 上,而不是特定的 ML 任务(我们现在只是模拟)。

例 1-7。使用 Ray Tune 最小化昂贵训练函数的目标
from ray import tune
import math
import time

def training_function(config):  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    x, y = config["x"], config["y"]
    time.sleep(10)
    score = objective(x, y)
    tune.report(score=score)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

def objective(x, y):
    return math.sqrt((x**2 + y**2)/2)  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)

result = tune.run(  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)
    training_function,
    config={
        "x": tune.grid_search([-1, -.5, 0, .5, 1]),  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/5.png)
        "y": tune.grid_search([-1, -.5, 0, .5, 1])
    })

print(result.get_best_config(metric="score", mode="min"))

1

我们模拟一个昂贵的训练函数,它依赖于从config读取的两个超参数xy

2

在睡眠了 5 秒钟以模拟训练和计算目标后,我们将分数报告给 tune

3

目标函数计算 xy 的平方的平均值,并返回该项的平方根。这种类型的目标函数在机器学习中非常常见。

4

我们接着使用 tune.run 来初始化我们的 training_function 的超参数优化。

5

一个关键部分是为 tune 提供 xy 的参数空间以进行搜索。

Example 1-7 中的 Tune 示例为一个具有给定 objectivetraining_function 找到了最佳的参数 xy 的选择。尽管目标函数一开始可能看起来有点吓人,因为我们计算 xy 的平方和,所有的值都是非负的。这意味着在 x=0y=0 处获得最小值,这会使目标函数的值为 0

我们进行所谓的 网格搜索,遍历所有可能的参数组合。因为我们为 xy 明确传递了五个可能的值,这总共有 25 个组合会传递给训练函数。由于我们指示 training_function 睡眠 10 秒钟,顺序测试所有超参数组合将总共需要超过四分钟。由于 Ray 在并行化这个工作负载方面很聪明,在我的笔记本电脑上,整个实验只需要大约 35 秒。现在想象一下,每个训练运行都需要几个小时,我们有 20 个而不是两个超参数。这使得网格搜索变得不可行,特别是如果你对参数范围没有明智的猜测。在这种情况下,你将不得不使用 Ray Tune 中更复杂的超参数优化方法,正如 Chapter 5 中讨论的那样。

模型服务

Ray 的高级库中的最后一个我们将讨论的专注于模型服务,简称为 Ray Serve。要看它在实际中的示例,你需要一个训练好的机器学习模型来提供服务。幸运的是,现在你可以在互联网上找到许多有趣的已经为你训练好的模型。例如,Hugging Face 提供了许多可直接在 Python 中下载的模型。我们将使用的模型是一个称为 GPT-2 的语言模型,它接受文本作为输入,并生成继续或完成输入的文本。例如,你可以提示一个问题,GPT-2 将尝试完成它。

提供这样一个模型是使其易于访问的一个好方法。你可能不知道如何在你的电脑上加载和运行 TensorFlow 模型,但你现在知道如何用简单的英语提问。模型服务隐藏了解决方案的实现细节,让用户可以专注于提供输入和理解模型的输出。

要继续,请确保运行pip install transformers来安装 Hugging Face 库,该库包含我们想要使用的模型。有了这个,我们现在可以导入并启动 Ray 的serve库的实例,加载并部署一个 GPT-2 模型,并询问它生命的意义,就像这样:

示例 1-8。
from ray import serve
from transformers import pipeline
import requests

serve.start()  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)

@serve.deployment  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
def model(request):
    language_model = pipeline("text-generation", model="gpt2")  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
    query = request.query_params["query"]
    return language_model(query, max_length=100)  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)

model.deploy()  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/5.png)

query = "What's the meaning of life?"
response = requests.get(f"http://localhost:8000/model?query={query}")  ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/6.png)
print(response.text)

1

我们在本地启动serve

2

@serve.deployment装饰器将一个带有request参数的函数转换为一个serve部署。

3

在每次请求中加载model函数内的language_model是低效的,但这是展示部署的最快方法。

4

我们要求模型给出最多100个字符来继续我们的查询。

5

然后,我们正式部署模型,使其可以开始通过 HTTP 接收请求。

6

我们使用不可或缺的requests库来获取您可能有的任何问题的响应。

在[待定链接]中,您将学习如何在各种场景下正确地部署模型,但现在我鼓励您尝试这个示例并测试不同的查询。重复运行最后两行代码将会在实际操作中每次得到不同的答案。这是一颗深邃的诗意宝石,引发更多的问题,我在我的机器上查询过并稍作年龄限制的审查:

[{
    "generated_text": "What's the meaning of life?\n\n
     Is there one way or another of living?\n\n
     How does it feel to be trapped in a relationship?\n\n
     How can it be changed before it's too late?
     What did we call it in our time?\n\n
     Where do we fit within this world and what are we going to live for?\n\n
     My life as a person has been shaped by the love I've received from others."
}]

这里我们结束了对 Ray 数据科学库的风驰电掣的介绍,这是 Ray 的第二层。在我们结束本章之前,让我们简要地看一看第三层,即围绕 Ray 的不断增长的生态系统。

一个不断增长的生态系统

Ray 的高级库功能强大,本书中应该对其进行更深入的探讨。虽然它们在数据科学实验生命周期中的用处不可否认,但我也不想给人留下 Ray 从现在开始就是您唯一需要的印象。事实上,我认为最好和最成功的框架是与现有解决方案和思想良好整合的框架。更好地专注于您的核心优势,并利用其他工具弥补解决方案中缺失的部分。通常没有理由重新发明轮子。

Ray 如何整合和扩展

为了给您一个 Ray 如何与其他工具集成的示例,可以考虑 Ray Data 是其库中的一个相对较新的添加。如果要简化一下,也许有点过于简单化,Ray 可以被视为一个以计算为先的框架。相比之下,像 Apache Spark⁷或 Dask 等分布式框架可以被视为以数据为先。几乎您在 Spark 中做的任何事情都以定义分布式数据集及其转换开始。Dask 则致力于将常见数据结构如 Pandas 数据帧或 Numpy 数组引入到分布式设置中。从各自的角度来看,它们都非常强大,我们将在[Link to Come]中为您提供更详细和公平的比较与 Ray。关键在于,Ray Data 并不试图取代这些工具。相反,它与两者都很好地集成。正如您将要看到的那样,这是 Ray 的一个常见主题。

Ray 作为分布式接口

Ray 中一个我认为极大低估的方面是,其库无缝集成了常见工具作为后端。Ray 通常创建通用接口,而不是试图创建新的标准⁸。这些接口允许您以分布式方式运行任务,这是大多数相应后端所不具备的,或者不具备相同的程度。例如,Ray RLlib 和 Train 支持来自 TensorFlow 和 PyTorch 的全部功能。Ray Tune 支持来自几乎所有著名的超参数优化工具的算法,包括 Hyperopt、Optuna、Nevergrad、Ax、SigOpt 等等。这些工具默认情况下都不是分布式的,但 Tune 在一个通用接口中统一了它们。Ray Serve 可以与 FastAPI 等框架一起使用,Ray Data 由 Arrow 支持,并与 Spark 和 Dask 等其他框架有着许多集成。总的来说,这似乎是一个可以用来扩展当前 Ray 项目或将新后端集成进来的健壮设计模式。

摘要

总结一下本章讨论的内容,图 1-4 给出了 Ray 的三个层次的概述。Ray 的核心分布式执行引擎位于框架的中心。对于实际的数据科学工作流程,您可以使用 Ray Data 进行数据处理,使用 Ray RLlib 进行强化学习,使用 Ray Train 进行分布式模型训练,使用 Ray Tune 进行超参数调优,以及使用 Ray Serve 进行模型服务。您已经看到了每个库的示例,并对它们的 API 有了初步的了解。此外,Ray 的生态系统还有许多扩展,我们稍后会更详细地讨论。也许您已经在图 1-4⁹中发现了一些您了解和喜欢的工具?

Ray 图层

图 1-4. Ray 的三个层次:其核心 API,库 RLlib、Tune、Ray Train、Ray Serve、Ray Data 以及许多第三方集成

¹ 摩尔定律长期以来都有效,但可能已经显示出放缓的迹象。不过我们不打算在这里讨论它。重要的不是我们的计算机通常变得更快,而是与我们需要的计算量之间的关系。

² 对于你们中的专家,我并不声称强化学习是答案。强化学习只是一种自然适合于讨论人工智能目标的范式。

³ 本书是关于 Python 的,因此我们将专注于 Python。但你至少应该知道 Ray 也有一个 Java API,目前相对于其 Python 版本来说,还不够成熟。

⁴ 我们目前使用的是 Ray 版本 1.9.0,因为这是本文写作时最新的版本。

⁵ 我从未喜欢过将数据科学归类为学科交叉点,比如数学、编码和商业的交集。最终,这并不能告诉你从业者们什么。告诉厨师他们坐在农业、热力学和人际关系的交集上,并不完全正确,也并不是非常有帮助。

⁶ 作为一项有趣的练习,我建议阅读保罗·格雷厄姆(Paul Graham)著名的《黑客与画家》一文,将“计算机科学”替换为“数据科学”。那么黑客 2.0 会是什么样子呢?

⁷ Spark 是由加州大学伯克利分校的 AMPLab 创建的。互联网上充斥着关于 Ray 应该被视为 Spark 替代品的博客文章。最好将它们视为具有不同优势的工具,两者都很可能会继续存在。

⁸ 在深度学习框架Keras正式成为企业旗舰的一部分之前,它起初是各种低级框架(如 Theano、CNTK 或 TensorFlow)的方便 API 规范。从这个意义上说,Ray RLlib 有可能成为 RL 的 Keras。Ray Tune 或许只是超参数优化的 Keras。更广泛采用的缺失可能是更优雅的 API。

⁹ 请注意,“Ray Train”在较早版本的 Ray 中被称为“raysgd”,并且还没有新的标志。

第二章:开始使用 Ray Core

对于一本关于分布式 Python 的书来说,有一定讽刺意味的是,Python 本身在分布式计算方面效果并不好。它的解释器实际上是单线程的,这使得在同一台机器上利用多个 CPU,甚至整个机群,使用纯 Python 变得困难。这意味着您需要额外的工具支持,幸运的是,Python 生态系统为您提供了一些选择。例如,像multiprocessing这样的库可以帮助您在单台机器上分发工作,但不能跨越机器。

在本章中,您将了解 Ray 核心如何通过启动本地集群处理分布式计算,并学习如何使用 Ray 精简而强大的 API 来并行化一些有趣的计算任务。例如,您将构建一个示例,以高效异步地在 Ray 上运行数据并行任务,这种方式方便而且不容易用其他工具复制。我们将讨论任务actors如何作为 Python 中函数和类的分布式版本工作。您还将了解 Ray 的所有基本概念及其架构的内部工作原理。换句话说,我们将让您深入了解 Ray 引擎的内部工作。

Ray 核心简介

本章的大部分内容是一个扩展的 Ray 核心示例,我们将一起构建。许多 Ray 的概念可以通过一个良好的示例来解释,这正是我们将要做的。与之前一样,您可以通过自己键入代码(强烈推荐)或者通过跟随本章的笔记本来跟随这个示例。

在第一章中,我们向您介绍了 Ray 集群的基础知识,并展示了如何通过简单地键入来启动本地集群

示例 2-1.
import ray
ray.init()

在继续之前,您需要一个运行中的 Ray 集群来运行本章的示例。本节的目标是为您快速介绍 Ray Core API,从现在起我们将简称为 Ray API。

作为 Python 程序员,Ray API 的一个伟大之处在于它非常贴近我们的生活。它使用熟悉的概念,如装饰器、函数和类,为您提供快速的学习体验。Ray API 旨在为分布式计算提供通用的编程接口。这绝非易事,但我认为 Ray 在这方面取得了成功,因为它为您提供了直观学习和使用的良好抽象。Ray 引擎在后台为您处理所有繁重的工作。这种设计理念使得 Ray 能够与现有的 Python 库和系统兼容。

使用 Ray API 的第一个示例

举个例子,考虑以下从数据库检索和处理数据的函数。我们的虚拟database是一个简单的 Python 列表,包含这本书标题的单词。我们假设从这个数据库检索一个单独的item并进一步处理它是昂贵的,通过让 Python sleep来模拟这一过程。

示例 2-2.
import time

database =   ![1
    "Learning", "Ray",
    "Flexible", "Distributed", "Python", "for", "Data", "Science"
]

def retrieve(item):
    time.sleep(item / 10.)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
    return item, database[item]

1

一个包含这本书标题的字符串数据的虚拟数据库。

2

我们模拟一个长时间运行的数据处理操作。

我们的数据库有八个项目,从database[0]的“Learning”到database[7]的“Science”。如果我们按顺序检索所有项目,那需要多长时间?对于索引为5的项目,我们等待半秒(5 / 10.),依此类推。总体而言,我们可以预期大约需要 (0+1+2+3+4+5+6+7)/10\. = 2.8 秒的运行时间。让我们看看实际上会得到什么:

示例 2-3.
def print_runtime(input_data, start_time, decimals=1):
    print(f'Runtime: {time.time() - start_time:.{decimals}f} seconds, data:')
    print(*input_data, sep="\n")

start = time.time()
data = [retrieve(item) for item in range(8)]  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
print_runtime(data, start)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

1

我们使用列表推导式来检索所有八个项目。

2

然后我们解压数据,每个项目都单独打印在一行上。

如果您运行此代码,您应该看到以下输出:

Runtime: 2.8 seconds, data:
(0, 'Learning')
(1, 'Ray')
(2, 'Flexible')
(3, 'Distributed')
(4, 'Python')
(5, 'for')
(6, 'Data')
(7, 'Science')

我们在程序输出后截断小数点后一位数字。还有一点额外的开销使得总时间接近 2.82 秒。在您的计算机上可能会略少一些,或者更多,这取决于您的计算机性能。重要的是我们的简单 Python 实现无法并行运行此函数。也许这对您来说并不奇怪,但您至少可以怀疑 Python 的列表推导在这方面更有效率。我们得到的运行时间几乎是最坏的情况,即我们在运行代码之前计算的 2.8 秒。仔细想想,看到一个基本上大部分时间都在睡眠的程序运行得这么慢甚至有点令人沮丧。最终,您可以归咎于全局解释器锁(GIL),但它已经够受罪了。

函数和远程 Ray 任务

假设这样一个任务可以从并行化中受益是合理的。如果完美分布,运行时间不应该比最长的子任务长多少,即7/10\. = 0.7秒。因此,让我们看看如何在 Ray 上扩展这个例子。为此,您可以按照以下步骤使用@ray.remote装饰器:

示例 2-4.
@ray.remote  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
def retrieve_task(item):
    return retrieve(item)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

1

只需这个装饰器,我们就可以将任何 Python 函数变成 Ray 任务。

2

其他一切保持不变。retrieve_task只是简单地传递给retrieve

这样,函数retrieve_task变成了所谓的 Ray 任务。这是一个非常方便的设计选择,因为你可以首先专注于你的 Python 代码,而不必完全改变你的思维方式或编程范式来使用 Ray。请注意,在实践中,你只需简单地给你的原始retrieve函数添加@ray.remote装饰器(毕竟,这就是装饰器的预期用途),但我们为了尽可能清晰地保持事物,不想改动先前的代码。

足够简单,那么在检索数据并测量性能的代码中,你需要改变什么?事实证明,不需要太多改动。让我们看看你会如何做:

示例 2-5。衡量你的 Ray 任务的性能。
start = time.time()
data_references = [retrieve_task.remote(item) for item in range(8)]  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
data = ray.get(data_references)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
print_runtime(data, start, 2)

1

要在本地 Ray 集群上运行retrieve_task,你使用.remote()并像以前一样传递你的数据。你会得到一个对象引用的列表。

2

要获取数据,而不仅仅是 Ray 对象引用,你使用ray.get

你有发现区别吗?你必须使用remote函数远程执行你的 Ray 任务。当任务在远程执行时,即使在本地集群上,Ray 也会异步执行。最后一个代码片段中的data_references中的列表项并不直接包含结果。实际上,如果你检查第一个项目的 Python 类型,使用type(data_references[0]),你会发现它实际上是一个ObjectRef。这些对象引用对应于你需要询问结果的futures。这就是调用ray.get(...)的用途。

我们仍然希望在这个例子²中进一步努力,但让我们在这里退后一步,总结一下我们到目前为止所做的。你从一个 Python 函数开始,并使用@ray.remote装饰它。这使得你的函数成为一个 Ray 任务。然后,你不是直接在代码中调用原始函数,而是在 Ray 任务上调用了.remote(...)。最后一步是从 Ray 集群中.get(...)结果。我认为这个过程如此直观,以至于我敢打赌,你现在甚至可以从另一个函数创建自己的 Ray 任务,而不必回顾这个例子。为什么不现在就试试呢?

回到我们的例子,通过使用 Ray 任务,我们在性能方面得到了什么?在我的机器上,运行时钟为0.71秒,稍微超过最长子任务的0.7秒。这非常好,比以前要好得多,但我们可以通过利用 Ray 的更多 API 进一步改进我们的程序。

使用 put 和 get 的对象存储

你可能注意到,在 retrieve 的定义中,我们直接从我们的 数据库 访问了项目。在本地 Ray 集群上运行这没问题,但想象一下你在一个包含多台计算机的实际集群上运行。所有这些计算机如何访问相同的数据?请记住,在 Ray 集群中,有一个带有驱动程序进程(运行 ray.init())的头节点,以及许多带有执行任务的工作程序进程的工作节点。我的笔记本电脑有总共 8 个 CPU 核心,因此 Ray 将在我的单节点本地集群上创建 8 个工作进程。我们的 数据库 目前仅在驱动程序上定义,但运行任务的工作程序需要访问它来运行 retrieve 任务。幸运的是,Ray 提供了一种简单的方法来在驱动程序和工作程序(或工作程序之间)之间共享数据。您可以简单地使用 put 将您的数据放入 Ray 的分布式对象存储中,然后在工作程序上使用 get 来检索它,如下所示。

示例 2-6。
database_object_ref = ray.put(database)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)

@ray.remote
def retrieve_task(item):
    obj_store_data = ray.get(database_object_ref)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
    time.sleep(item / 10.)
    return item, obj_store_data[item]

1

put 将你的 数据库 放入对象存储并接收到它的引用。

2

这使得您的工作程序可以在集群中的任何位置 get 到数据。

通过这种方式使用对象存储,您可以让 Ray 处理整个集群中的数据访问。我们将在讨论 Ray 的基础设施时详细讨论数据在节点之间和工作程序内部如何传递。虽然与对象存储的交互需要一些开销,但 Ray 在存储数据方面非常聪明,这在处理更大、更真实的数据集时可以提供性能收益。目前,重要的部分是在真正分布式设置中这一步骤是必不可少的。如果愿意,尝试使用这个新的 retrieve_task 函数重新运行 示例 2-5,并确认它仍然如预期般运行。

使用 Ray 的等待函数进行非阻塞调用。

请注意在示例 2-5 中如何使用ray.get(data_references)来访问结果。这个调用是阻塞的,这意味着我们的驱动程序必须等待所有结果可用。在我们的情况下这并不是什么大问题,程序现在在不到一秒内完成。但想象一下,如果每个数据项的处理需要几分钟,那该怎么办?在那种情况下,你可能希望释放驱动程序来处理其他任务,而不是闲坐着。此外,最好能够在结果可用时即时处理它们(某些结果比其他的更快完成)。还有一个需要记住的问题是,如果一个数据项无法按预期获取会发生什么情况?假设数据库连接中某处发生死锁。在这种情况下,驱动程序将会挂起并永远无法检索所有项。因此,明智的做法是使用合理的超时。在我们的场景中,在停止任务之前,我们不应等待超过最长数据检索任务的 10 倍时间。这里是如何通过使用wait在 Ray 中实现的:

示例 2-7.
start = time.time()
data_references = [retrieve_task.remote(item) for item in range(8)]
all_data = []

while len(data_references) > 0:  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    finished, data_references = ray.wait(data_references, num_returns=2, timeout=7.0)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
    data = ray.get(finished)
    print_runtime(data, start, 3)  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
    all_data.extend(data)  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)

1

不阻塞,我们循环处理未完成的data_references

2

我们使用合理的timeout异步wait等待完成的数据。在这里,data_references被覆盖,以防止无限循环。

3

当结果到达时,我们按两个数据块打印它们。

4

然后,直到完成,我们将新的data追加到all_data中。

如您所见,ray.wait返回两个参数,即已完成的数据和仍需要处理的未来数据。我们使用num_returns参数,默认为1,让wait在新的一对数据项可用时返回。在我的笔记本电脑上,这导致以下输出:

Runtime: 0.108 seconds, data:
(0, 'Learning')
(1, 'Ray')
Runtime: 0.308 seconds, data:
(2, 'Flexible')
(3, 'Distributed')
Runtime: 0.508 seconds, data:
(4, 'Python')
(5, 'for')
Runtime: 0.709 seconds, data:
(6, 'Data')
(7, 'Science')

请注意,在while循环中,与其仅仅打印结果,我们可以做许多其他事情,比如使用已检索到的数据启动其他工作节点上的全新任务。

处理任务依赖关系

到目前为止,我们的示例程序在概念上相当简单。它只包括一个步骤,即检索一堆数据。现在,想象一下,一旦加载了数据,您希望对其运行后续处理任务。更具体地说,假设我们希望使用第一个检索任务的结果查询其他相关数据(假装您正在从同一数据库中的不同表中查询数据)。以下代码设置了这样一个任务,并依次运行我们的retrieve_taskfollow_up_task

示例 2-8. 运行依赖另一个 Ray 任务的后续任务
@ray.remote
def follow_up_task(retrieve_result):  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    original_item, _ = retrieve_result
    follow_up_result = retrieve(original_item + 1)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
    return retrieve_result, follow_up_result  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)

retrieve_refs = [retrieve_task.remote(item) for item in [0, 2, 4, 6]]
follow_up_refs = [follow_up_task.remote(ref) for ref in retrieve_refs]  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)

result = [print(data) for data in ray.get(follow_up_refs)]

1

使用retrieve_task的结果,我们在其基础上计算另一个 Ray 任务。

2

利用第一个任务中的original_item,我们retrieve更多的数据。

3

然后我们返回原始数据和后续数据。

4

我们将第一个任务中的对象引用传递给第二个任务。

运行此代码将产生以下输出。

((0, 'Learning'), (1, 'Ray'))
((2, 'Flexible'), (3, 'Distributed'))
((4, 'Python'), (5, 'for'))
((6, 'Data'), (7, 'Science'))

如果您在异步编程方面没有太多经验,您可能对示例 2-8 不感兴趣。但我希望说服您,这段代码至少有些令人惊讶³,因为这段代码片段实际上是可以运行的。那么,有什么了不起的地方呢?毕竟,这段代码看起来像是普通的 Python 代码 - 一个函数定义和几个列表推导式。关键在于,follow_up_task函数体期望其输入参数retrieve_result是一个 Python tuple,我们在函数定义的第一行对其进行解包。

但通过调用[follow_up_task.remote(ref) for ref in retrieve_refs],我们根本没有向后续任务传递元组。相反,我们使用retrieve_refs传递了 Ray 的对象引用。在幕后发生的是,Ray 知道follow_up_task需要实际的值,因此在该任务内部,它将调用ray.get来解析这些 future 对象。Ray 为所有任务构建依赖图,并按照依赖关系的顺序执行它们。您不必显式告诉 Ray 何时等待先前的任务完成,它会自动推断这些信息。

仅当单个检索任务完成时,后续任务才会被调度。如果问我,这是一个令人难以置信的特性。实际上,如果我像retrieve_refs那样称呼它为retrieve_result,您甚至可能没有注意到这个重要细节。这是有意设计的。Ray 希望您专注于工作,而不是集群计算的细节。在图 Figure 2-1 中,您可以看到可视化的两个任务的依赖图。

任务依赖

图 2-1. 使用 Ray 异步并行运行两个依赖任务

如果您愿意,可以尝试重写示例 2-8,以便在将值传递给后续任务之前明确使用get。这不仅会引入更多样板代码,而且写起来和理解起来也没有那么直观。

从类到 actors

在结束这个示例之前,让我们讨论 Ray Core 的另一个重要概念。注意在我们的示例中,一切本质上都是一个函数。我们只是使用ray.remote装饰器使其中一些成为远程函数,除此之外只是使用普通的 Python。假设我们想追踪我们的database被查询的频率?当然,我们可以简单地计算我们的检索任务的结果,但是有没有更好的方法?我们想以一种“分布式”的方式进行跟踪,这样就能扩展。为此,Ray 有actors的概念。actors 允许您在集群上运行有状态的计算。它们还可以相互通信⁴。就像 Ray 任务只是被装饰的函数一样,Ray actors 是被装饰的 Python 类。让我们编写一个简单的计数器来跟踪我们的数据库调用。

示例 2-9。
@ray.remote  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
class DataTracker:
    def __init__(self):
        self._counts = 0

    def increment(self):
        self._counts += 1

    def counts(self):
        return self._counts

1

我们可以通过使用与之前相同的ray.remote装饰器使任何 Python 类成为 Ray actor。

这个DataTracker类已经是一个 actor,因为我们给它装饰了ray.remote。这个 actor 可以追踪状态,这里只是一个简单的计数器,并且它的方法是 Ray 任务,可以像我们之前使用函数一样精确地调用,即使用.remote()。让我们看看如何修改我们现有的retrieve_task以包含这个新的 actor。

示例 2-10。
@ray.remote
def retrieve_tracker_task(item, tracker):  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    obj_store_data = ray.get(database_object_ref)
    time.sleep(item / 10.)
    tracker.increment.remote()  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
    return item, obj_store_data[item]

tracker = DataTracker.remote()  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)

data_references = [retrieve_tracker_task.remote(item, tracker) for item in range(8)]  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)
data = ray.get(data_references)
print(ray.get(tracker.counts.remote()))  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/5.png)

1

我们将tracker actor 传递给这个任务。

2

tracker每次调用都会收到一个increment

3

我们通过在类上调用.remote()来实例化我们的DataTracker actor。

4

这个 actor 被传递到检索任务中。

5

然后我们可以从另一个远程调用中获取我们的trackercounts状态。

毫不奇怪,这个计算的结果实际上是8。我们不需要 actor 来计算这个,但我希望你能看出它可以多么有用,可以跨集群跟踪状态,潜在地覆盖多个任务。事实上,我们可以将我们的 actor 传递给任何依赖任务,甚至将其传递给另一个 actor 的构造函数。你可以做任何事情,没有限制,正是这种灵活性使 Ray API 如此强大。值得一提的是,分布式 Python 工具很少允许进行这种有状态的计算。这个特性非常有用,特别是在运行复杂的分布式算法时,例如使用强化学习时。这完成了我们广泛的第一个 Ray API 示例。接下来让我们看看是否可以简洁地总结 Ray API。

Ray 核心 API 概述

如果你回想一下我们在前面的例子中做过的事情,你会注意到我们只使用了总共六种 API 方法⁵。你使用 ray.init() 来启动集群,使用 @ray.remote 将函数和类转换为任务和 actor。然后我们使用 ray.put() 将数据传入 Ray 的对象存储,使用 ray.get() 从集群中检索数据。最后,我们在 actor 方法或任务上使用 .remote() 在我们的集群上运行代码,并使用 ray.wait 避免阻塞调用。

虽然 Ray API 中有六种方法看起来可能不多,但这些方法在你使用 Ray API⁶ 时很可能是你唯一关心的。让我们简要总结它们在表格中,这样你以后可以轻松地参考。

表 2-1. Ray 核心的六个主要 API 方法

API 调用 描述
ray.init() 初始化你的 Ray 集群。传入一个 address 来连接到现有的集群。
@ray.remote 将函数转换为任务,类转换为 actor。
ray.put() 将数据放入 Ray 的对象存储中。
ray.get() 从对象存储中获取数据。返回你在那里 put 的数据或由任务或 actor 计算的数据。
.remote() 在你的 Ray 集群上运行 actor 方法或任务,并用于实例化 actors。
ray.wait() 返回两个对象引用列表,一个是我们正在等待的已完成任务,另一个是未完成的任务。

现在你已经看到了 Ray API 的实际应用,让我们快速讨论一下 Ray 的设计哲学,然后再讨论其系统架构。

设计原则

Ray 是根据几个设计原则构建的,你已经对大多数原则有所了解。它的 API 设计简单且通用。其计算模型依赖于灵活性。其系统架构设计用于性能和可伸缩性。让我们更详细地看看每一个。

简单和抽象

正如你所见,Ray 的 API 不仅简单易懂,而且易于掌握。无论你只想使用笔记本电脑上的所有 CPU 核心还是利用集群中的所有机器,都无关紧要。你可能需要改变一两行代码,但你使用的 Ray 代码基本保持不变。正如任何好的分布式系统一样,Ray 在幕后管理任务分发和协调。这很棒,因为你不必为分布式计算的机制而烦恼。一个好的抽象层让你专注于工作,我认为 Ray 在这方面做得很好。

由于 Ray 的 API 如此通用且符合 Python 风格,它很容易与其他工具集成。例如,Ray actor 可以调用或被现有的分布式 Python 工作负载调用。从这个意义上说,Ray 也适用于分布工作负载的“胶水代码”,因为它足够高效和灵活,可以在不同系统和框架之间进行通信。

灵活性

对于 AI 工作负载,特别是在处理强化学习等范式时,您需要一个灵活的编程模型。射线的 API 旨在使编写灵活且可组合的代码变得容易。简而言之,如果您可以用 Python 表达您的工作负载,那么您可以用射线分发它。当然,您仍然需要确保您有足够的资源可用,并且要谨慎考虑您想要分发的内容。但射线不会限制您所能做的事情。

射线在计算异构性方面也很灵活。例如,假设您正在进行复杂的模拟。模拟通常可以分解为多个任务或步骤。其中一些步骤可能需要几小时才能运行,而其他一些步骤只需要几毫秒,但总是需要快速调度和执行。有时,模拟中的单个任务可能需要很长时间,但其他较小的任务应该能够并行运行而不会阻塞它。此外,后续任务可能依赖于上游任务的结果,因此您需要一个允许良好处理任务依赖性的动态执行框架。在本章中我们讨论的示例中,您已经看到射线的 API 是为此而构建的。

您还需要确保您在资源使用方面具有灵活性。例如,某些任务可能必须在 GPU 上运行,而其他任务最适合在几个 CPU 核心上运行。射线为您提供了这种灵活性。

速度和可伸缩性

射线的另一个设计原则是射线执行其异构任务的速度。它可以处理数百万个任务每秒。更重要的是,使用射线只会产生非常低的延迟。它被设计为在毫秒级的延迟下执行任务。

要使分布式系统快速,它还需要具有良好的可扩展性。射线在您的计算集群中有效地分发和调度任务。它也以容错的方式进行,因为在分布式系统中,问题不在于是否,而在于何时出现问题。一台机器可能会出现故障,中止任务,或者干脆起火。[⁷] 在任何情况下,射线都建立在快速从故障中恢复的基础上,这有助于其整体速度。

理解射线系统组件

您已经了解了射线 API 的使用方式,并理解了射线背后的设计哲学。现在是时候更好地了解底层系统组件了。换句话说,射线是如何工作的,以及它如何实现其功能的?

在节点上调度和执行工作

您知道射线集群由节点组成。我们将首先查看单个节点上发生的情况,然后再放大讨论整个集群的互操作性。

正如我们已经讨论过的,一个工作节点由多个工作进程或简单地说是工作组成。每个工作进程都有一个唯一的 ID、一个 IP 地址和一个端口,通过这些可以引用它们。工作进程之所以被称为工作进程,是有原因的,它们是盲目执行您给予它们的工作的组件。但是是谁告诉它们什么时候做什么的呢?一个工作进程可能已经很忙了,它可能没有适当的资源来运行一个任务(例如访问 GPU),甚至可能没有运行给定任务所需的数据。除此之外,工作进程对其执行的工作之前或之后发生的事情一无所知,没有协调。

为了解决这些问题,每个工作节点都有一个称为Raylet的组件。把 Raylets 想象成节点的智能组件,负责管理工作进程。Raylets 在作业之间共享,并由两个组件组成,即任务调度器和对象存储。

首先让我们谈谈对象存储。在本章的运行示例中,我们已经在不明确指定的情况下宽泛地使用了对象存储的概念。Ray 集群的每个节点都配备有一个对象存储,位于该节点的 Raylet 内部,并且所有存储的对象共同形成了集群的分布式对象存储。对象存储在节点之间具有共享内存,因此每个工作进程都可以轻松访问它。对象存储是在Plasma中实现的,现在它归属于 Apache Arrow 项目。功能上,对象存储负责内存管理,并最终确保工作进程可以访问它们所需的数据。

Raylet 的第二个组件是其调度器。调度器负责资源管理等多项工作。例如,如果一个任务需要访问 4 个 CPU,调度器需要确保它可以找到一个空闲的工作进程,可以授权访问这些资源。默认情况下,调度器了解并获取其节点上可用的 CPU 数、GPU 数以及内存量的信息,但如果需要,您可以注册自定义资源。如果它无法提供所需的资源,则无法调度任务的执行。

除了资源外,调度器需要处理的另一个要求是依赖解析。这意味着它需要确保每个工作节点具备执行任务所需的所有输入数据。为此,调度器将首先通过查找对象存储中的数据来解析本地依赖关系。如果所需数据在该节点的对象存储中不可用,调度器将不得不与其他节点通信(稍后我们会告诉您如何),并拉取远程依赖项。一旦调度器确保了任务的足够资源,解析了所有所需的依赖关系,并为任务找到了一个工作节点,它就可以将该任务安排到执行队列中。

任务调度是一个非常复杂的话题,即使我们只讨论单个节点。我想你可以轻松想象出一些情况,即由于资源不足,错误或天真地规划任务执行可能会“阻塞”下游任务。特别是在分布式环境中,分配这样的工作可能会很快变得非常棘手。

现在你已经了解了 Raylet,让我们简要回到工作进程,以便我们可以结束对工作节点的讨论。对于 Ray 整体性能做出贡献的一个重要概念是拥有权

拥有权(Ownership)意味着运行某个进程的过程对其负责。这样一来,整体设计就是去中心化的,因为各个任务都有唯一的所有者。具体来说,这意味着每个工作进程拥有它提交的任务,包括正确执行和结果可用性(即正确解析对象引用)。此外,通过 ray.put() 注册的任何东西都由调用者拥有。你应该理解拥有权与依赖性的对比,我们在讨论任务依赖性时已经通过示例进行了解释。

举个具体的例子,假设我们有一个程序启动了一个名为 task 的任务,它接受一个名为 val 的输入值,并在内部调用另一个任务。具体代码如下:

例子 2-11。
@ray.remote
def task_owned():
    return

@ray.remote
def task(dependency):
    res_owned = task_owned.remote()
    return

val = ray.put("value")
res = task.remote(dependency=val)

从这一点开始,我们不再提及它,但是这个例子假设你已经启动了一个带有 ray.init() 的运行中的 Ray 集群。让我们快速分析一下这个例子中的拥有权和依赖关系。我们定义了两个任务 tasktask_owned,总共有三个变量,即 valresres_owned。我们的主程序定义了 val(将 "value" 放入对象存储)和 res(程序的最终结果),并调用了 task。换句话说,根据 Ray 的拥有权定义,驱动程序拥有 taskvalres。相比之下,res 依赖于 task,但它们之间没有拥有关系。当调用 task 时,它以 val 作为依赖项。然后调用 task_owned 并赋值 res_owned,因此它同时拥有这两者。最后,task_owned 本身并不拥有任何东西,但显然 res_owned 依赖于它。

对于 Ray 的工作来说,了解拥有权是重要的,但在实际使用中并不经常遇到。我们在这个背景下提到它的原因是工作进程需要追踪它们所拥有的内容。事实上,它们有一个所谓的拥有权表来确保这一点。如果一个任务失败并需要重新计算,工作进程已经拥有完成此操作所需的所有信息。此外,工作进程还有一个用于小对象的进程内存储,其默认限制为 100KB。工作进程拥有这个存储空间,可以直接访问和存储小数据,而不必与 Raylet 对象存储产生通信开销,后者主要用于大对象。

总结关于工作节点的讨论,图 图 2-2 给出了所有涉及组件的概述。

Worker

图 2-2. 组成 Ray 工作节点的系统组件

头节点

我们在第一章已经指出,每个 Ray 集群都有一个特殊节点称为头节点。到目前为止,您知道这个节点有驱动程序进程⁸。驱动程序可以自己提交任务,但不能执行它们。您还知道头节点可以有一些工作进程,这对于能够运行由单个节点组成的本地集群是很重要的。换句话说,头节点具有工作节点拥有的一切(包括一个 Raylet),但它还有一个驱动进程。

另外,头节点配备了一个称为全局控制存储(GCS)的组件。GCS 是一个键值存储,目前在 Redis 中实现。它是一个重要的组件,负责携带关于集群的全局信息,例如系统级元数据。例如,它有一个包含每个 Raylet 心跳信号的表,以确保它们仍然可达。反过来,Raylet 发送心跳信号到 GCS 表示它们仍然存活。GCS 还在各自的表中存储 Ray actor 和大对象的位置,并知道对象之间的依赖关系。

分布式调度和执行

让我们简要讨论集群编排以及节点如何管理、计划和执行任务。在谈论工作节点时,我们已经指出,使用 Ray 分发工作负载涉及多个组件。以下是这个过程中涉及的步骤和复杂性的概述。

分布式内存:单个 Raylet 的对象存储在节点上共享它们的内存。但有时需要在节点之间传输数据,这称为分布式对象传输。这对于远程依赖解析是必要的,以便工作节点具有运行任务所需的数据。

通信

Ray 集群中大部分通信(例如对象传输)都通过gRPC协议进行。

资源管理和满足

在一个节点上,Raylet 负责为任务所有者分配资源并租赁工作进程。所有节点上的调度器形成分布式调度器。通过与 GCS 的通信,本地调度器了解其他节点的资源。

任务执行

一旦任务被提交执行,所有依赖项(本地和远程数据)都需要解析,例如通过从对象存储中检索大数据,然后执行才能开始。

如果最近几节内容在技术上显得有些复杂,那是因为确实如此。在我看来,理解你正在使用的软件的基本模式和思想非常重要,但我承认,在一开始理解 Ray 的架构细节可能会有些困难。事实上,Ray 的设计原则之一就是在可用性和架构复杂性之间做出权衡。如果你想更深入地了解 Ray 的架构,一个好的起点是阅读它们的架构白皮书

总结一下,让我们通过图 Figure 2-3 的简明架构概述来总结我们所知道的所有内容:

架构

图 2-3. Ray 架构组件概述

总结

你已经在本章看到了 Ray API 的基本操作。你知道如何将数据 put 到对象存储中,并如何 get 回来。此外,你还熟悉了使用 @ray.remote 装饰器将 Python 函数声明为 Ray 任务,并且知道如何使用 .remote() 在 Ray 集群上运行它们。同样地,你理解了如何从 Python 类声明 Ray actor,如何实例化它,并利用它进行有状态的分布式计算。

此外,你还了解了 Ray 集群的基本知识。在使用 ray.init(...) 启动它们后,你知道可以向集群提交由任务组成的作业。驻留在主节点上的驱动进程将任务分发给工作节点。每个节点上的 Raylet 将调度任务,并且工作进程将执行它们。这次快速的 Ray 核心之旅应该能让你开始编写自己的分布式程序,而在下一章中,我们将通过共同实现一个基本的机器学习应用程序来测试你的知识。

¹ 我依然不知道如何正确发音这个缩写词,但我觉得那些把 GIF 发音成“长颈鹿”的人也会把 GIL 发音成“吉他”。随便选一个,或者拼写出来,如果你不确定的话。

² 这个例子改编自迪恩·沃姆普勒的精彩报告 “什么是 Ray?”

³ 根据克拉克的第三定律,任何足够先进的技术都无法与魔法区分开来。对我来说,这个例子确实有些神奇的成分。

⁴ 演员模型是计算机科学中一个已经确立的概念,例如在 Akka 或 Erlang 中可以找到它的实现。然而,演员的历史和具体细节并不适用于我们的讨论。

⁵ 引用艾伦·凯的话来说,为了实现简单,你需要找到稍微复杂一点的构建块。在我看来,Ray API 正是为 Python 分布式计算而设计的。

⁶ 你可以查看API 参考文档,会发现其实有相当多的可用方法。在某个时候,你应该投入时间去理解init的参数,但其他所有方法如果你不是 Ray 集群的管理员,可能对你没什么兴趣。

⁷ 这听起来可能很激烈,但并非玩笑。举一个例子,在 2021 年 3 月,一家法国数据中心完全被烧毁,这件事你可以在这篇文章中了解到。如果你的整个集群被烧毁了,恐怕 Ray 也无能为力。

⁸ 实际上,它可能有多个驱动程序,但这对我们的讨论来说并不重要。

第三章:构建你的第一个分布式应用程序

现在你已经看到了 Ray API 的基础知识,让我们用它构建一个更现实的东西。通过这个相对较短的章节结束时,你将已经从零开始构建了一个强化学习(RL)问题,实现了第一个解决方案,并使用了 Ray 任务和执行者将此解决方案并行化到一个本地集群中——所有这些都不到 250 行代码。

本章旨在适用于没有任何强化学习经验的读者。我们将解决一个简单的问题,并培养必要的技能来实际应对它。由于第四章完全致力于这个主题,我们将跳过所有高级的强化学习主题和术语,只专注于手头的问题。但即使你是一个相当高级的强化学习用户,你也很可能会受益于在分布式环境中实现一个经典算法。

这是与 Ray Core 仅仅 一起工作的最后一章了。我希望你学会欣赏它的强大和灵活性,以及你可以多快地实现分布式实验,否则需要相当大的努力来扩展。

设置一个简单的迷宫问题

与之前的章节一样,我鼓励你与我一起编写本章的代码,并在我们进行时共同构建此应用程序。如果你不想这样做,你也可以简单地跟随本章的笔记本

为了给你一个概念,我们正在构建的应用程序结构如下:

  • 你实现了一个简单的二维迷宫游戏,其中一个单个玩家可以在四个主要方向上移动。

  • 你将迷宫初始化为一个5x5的网格,玩家被限制在其中。

  • 25个网格单元中的一个是“目标”,玩家称为“追寻者”必须到达的目标。

  • 你不会硬编码一个解决方案,而是会使用一个强化学习算法,让追寻者学会找到目标。

  • 这是通过反复运行迷宫的模拟来完成的,奖励追寻者找到目标并聪明地跟踪追寻者的哪些决策有效,哪些不行。

  • 由于模拟可以并行化,并且我们的 RL 算法也可以并行训练,我们利用 Ray API 来并行化整个过程。

我们还没有准备好将此应用程序部署到由多个节点组成的实际 Ray 集群上,所以现在我们将继续使用本地集群。如果你对基础设施主题感兴趣,并想学习如何设置 Ray 集群,请跳到[链接将到来的地方],要看到一个完全部署的 Ray 应用程序,你可以转到[链接将到来的地方]。

让我们从实现刚刚草绘的二维迷宫开始。我们的想法是在 Python 中实现一个简单的网格,该网格从(0, 0)开始,到(4, 4)结束,并正确定义玩家如何在网格中移动。为此,我们首先需要一个用于沿四个基本方向移动的抽象。这四个动作,即向上、向下、向左和向右,可以在 Python 中被编码为我们称之为Discrete的类。移动多个离散动作的抽象是如此有用,以至于我们将其泛化到n个方向,而不仅仅是四个。如果你担心,这并不是过早 - 我们实际上在一会儿需要一个通用的Discrete类。

例子 3-1.
import random

class Discrete:
    def __init__(self, num_actions: int):
        """ Discrete action space for num_actions.
        Discrete(4) can be used as encoding moving in one of the cardinal directions.
        """
        self.n = num_actions

    def sample(self):
        return random.randint(0, self.n - 1)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)

space = Discrete(4)
print(space.sample())  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

1

一个离散动作可以在0n-1之间均匀抽样。

2

例如,从Discrete(4)中抽样将给出0123

Discrete(4)中进行抽样,例如在这个例子中会随机返回0123。我们如何解释这些数字由我们决定,所以假设我们选择“向下”、“向左”、“向右”和“向上”的顺序。

现在我们知道如何编码在迷宫中移动了,让我们编写迷宫本身的代码,包括goal单元格和试图找到目标的seeker玩家的位置。为此,我们将实现一个名为Environment的 Python 类。它被称为这个名字,因为迷宫是玩家“生活”的环境。为了简化问题,我们将seeker始终放在(0, 0),将goal放在(4, 4)。为了使seeker移动并找到其目标,我们将用Discrete(4)初始化Environmentaction_space

我们的迷宫环境中还有一点信息需要设置,那就是seeker位置的编码。原因是我们将来要实现一个算法,用于跟踪哪些动作对应于哪些seeker位置带来了良好的结果。将seeker位置编码为Discrete(5*5),这样可以变成一个更易处理的单一数字。在强化学习术语中,把玩家可以访问的游戏信息称为observation。所以,类比我们为seeker定义动作空间一样,我们也可以为其定义一个observation_space。这里是我们刚刚讨论过的实现:

例子 3-2.
import os

class Environment:

    seeker, goal = (0, 0), (4, 4)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    info = {'seeker': seeker, 'goal': goal}

    def __init__(self,  *args, **kwargs):
        self.action_space = Discrete(4)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
        self.observation_space = Discrete(5*5)  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)

1

seeker初始化在迷宫的左上角,goal初始化在右下角。

2

我们的seeker可以向下、向左、向上和向右移动。

3

并且它可以处于总共25种状态中,每个状态代表网格上的一个位置。

请注意,我们还定义了一个info变量,它可以用于打印迷宫当前状态的信息,例如用于调试目的。要从寻求者的角度玩找到目标的实际游戏,我们必须定义几个辅助方法。显然,当寻求者找到目标时,游戏应该被认为是“完成”的。此外,我们应该奖励寻求者找到目标。当游戏结束时,我们应该能够将其重置为初始状态,以便再次游戏。最后,我们还定义了一个get_observation方法,返回编码的seeker位置。继续实现Environment类,这转化为以下四种方法。

示例 3-3.
    def reset(self):  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
        """Reset seeker and goal positions, return observations."""
        self.seeker = (0, 0)
        self.goal = (4, 4)

        return self.get_observation()

    def get_observation(self):
        """Encode the seeker position as integer"""
        return 5 * self.seeker[0] + self.seeker[1]  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

    def get_reward(self):
        """Reward finding the goal"""
        return 1 if self.seeker == self.goal else 0  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)

    def is_done(self):
        """We're done if we found the goal"""
        return self.seeker == self.goal  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)

1

要开始一个新游戏,我们必须将网格reset为其原始状态。

2

将寻求者元组转换为环境的observation_space中的值。

3

只有当寻求者到达目标时才会获得奖励。

4

如果寻求者在目标处,则游戏结束。

最后要实现的关键方法是step方法。想象一下你在玩迷宫游戏,并决定向右移动作为你的下一步。step方法将采取这个动作(即3,表示“右”)并将其应用到游戏的内部状态。为了反映发生了什么变化,step方法将返回寻求者的观察结果、其奖励、游戏是否结束以及游戏的info值。这就是step方法的工作原理:

示例 3-4.
    def step(self, action):
        """Take a step in a direction and return all available information."""
        if action == 0:  # move down
            self.seeker = (min(self.seeker[0] + 1, 4), self.seeker[1])
        elif action == 1:  # move left
            self.seeker = (self.seeker[0], max(self.seeker[1] - 1, 0))
        elif action == 2:  # move up
            self.seeker = (max(self.seeker[0] - 1, 0), self.seeker[1])
        elif action == 3:  # move right
            self.seeker = (self.seeker[0], min(self.seeker[1] + 1, 4))
        else:
            raise ValueError("Invalid action")

        return self.get_observation(), self.get_reward(), self.is_done(), self.info  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)

1

在指定方向迈出一步后,我们返回观察结果、奖励、游戏是否结束以及可能有用的任何其他信息。

我说step方法是最后一个必要的方法,但实际上我们想定义一个更有帮助的辅助方法来可视化游戏并帮助我们理解它。这个render方法将把游戏的当前状态打印到命令行。

示例 3-5.
    def render(self, *args, **kwargs):
        """Render the environment, e.g. by printing its representation."""
        os.system('cls' if os.name == 'nt' else 'clear')  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
        grid = [['| ' for _ in range(5)] + ["|\n"] for _ in range(5)]
        grid[self.goal[0]][self.goal[1]] = '|G'
        grid[self.seeker[0]][self.seeker[1]] = '|S'  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
        print(''.join([''.join(grid_row) for grid_row in grid]))  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)

1

首先,我们清除屏幕。

2

然后我们绘制网格,在上面标记目标为G,标记寻求者为S

3

然后,通过将其打印到您的屏幕上来渲染网格。

太好了,现在我们完成了定义我们的 2D 迷宫游戏的Environment类的实现。我们可以通过这个游戏进行step,知道什么时候done并再次reset它。游戏的玩家,即寻求者,还可以观察其环境,并为找到目标而获得奖励。

让我们使用这个实现来玩一个寻找目标的游戏,寻找者只需随机采取行动。这可以通过创建一个新的Environment,对其进行采样并应用行动,然后渲染环境直到游戏结束来完成:

示例 3-6。
import time

environment = Environment()

while not environment.is_done():
    random_action = environment.action_space.sample()  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    environment.step(random_action)
    time.sleep(0.1)
    environment.render()  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

1

我们可以通过应用采样的行动来测试我们的环境,直到我们完成为止。

2

为了可视化环境,我们在等待十分之一秒后渲染它(否则代码运行得太快了,无法跟上)。

如果你在你的电脑上运行这个程序,最终你会看到游戏结束了,寻找者找到了目标。如果你不幸的话,可能会花一些时间。

如果你认为这是一个非常简单的问题,并且解决它只需走 8 步,即随意向右和向下各四次,我不会和你争辩。关键是,我们想要使用机器学习来解决这个问题,这样我们以后就可以解决更困难的问题。具体来说,我们想要实现一个算法,通过反复玩游戏:观察发生了什么,决定下一步要做什么,并为你的行为获得奖励,从而自行解决如何玩游戏。

如果你愿意的话,现在是时候让游戏变得更加复杂了。只要你不改变我们为Environment类定义的接口,你可以以许多方式修改这个游戏。以下是一些建议:

  • 将其设为 10x10 网格或随机化寻找者的初始位置。

  • 让网格的外围墙变得危险。每当你试图接触它们时,你都会受到-100 的奖励,即严重惩罚。

  • 在网格中引入障碍物,寻找者无法通过。

如果你感觉非常有冒险精神,你也可以随机化目标位置。这需要额外的注意,因为目前寻找者在get_observation方法中对目标位置没有信息。也许在阅读完本章之后再回来解决这个最后的练习。

建立一个模拟

有了Environment类的实现,解决“教导”寻找者如何玩得好这个问题需要什么?它如何能够在最少的 8 步中一直找到目标?我们已经为迷宫环境配备了奖励信息,这样寻找者就可以使用这个信号来学习玩游戏。在强化学习中,你会反复玩游戏,并从你在这个过程中获得的经验中学习。游戏的玩家通常被称为代理,它在环境中采取行动,观察其状态并获得奖励。¹代理学得越好,它就越能够解释当前游戏状态(观察)并找到导致更有益的结果的行动。

无论您想使用什么样的强化学习算法(如果您了解任何算法),您都需要一种重复模拟游戏以收集经验数据的方法。因此,我们将很快实现一个简单的Simulation类。

我们需要继续进行的另一个有用的抽象是Policy,一种指定行动的方式。目前,我们可以为追逐者随机抽取行动来玩游戏。Policy允许我们为当前游戏状态获取更好的行动。实际上,我们将Policy定义为一个具有get_action方法的类,该方法接受游戏状态并返回一个动作。

请记住,在我们的游戏中,追逐者在网格上有25种可能的状态,并且可以执行4个动作。一个简单的想法是观察状态和动作的配对,并为配对分配高值,如果在这个状态下执行此动作将导致高奖励,否则分配低值。例如,从您对游戏的直觉来看,向下或向右移动总是一个好主意,而向左或向上移动则不是。然后,创建一个25x4的查找表,包含所有可能的状态-动作配对,并将其存储在我们的Policy中。然后我们只需请求我们的策略在给定状态时返回任何动作的最高值。当然,实现一个为这些状态-动作配对找到好值的算法是具有挑战性的部分。让我们首先实现这个Policy的概念,然后再考虑适合的算法。

示例 3-7.
class Policy:

    def __init__(self, env):
        """A Policy suggests actions based on the current state.
        We do this by tracking the value of each state-action pair.
        """
        self.state_action_table = [
            [0 for _ in range(env.action_space.n)]for _ in range(env.observation_space.n)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
        ]
        self.action_space = env.action_space

    def get_action(self, state, explore=True, epsilon=0.1):  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
        """Explore randomly or exploit the best value currently available."""
        if explore and random.uniform(0, 1) < epsilon:  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
            return self.action_space.sample()
        return np.argmax(self.state_action_table[state])

1

我们为每个状态-动作对定义了一个值的嵌套列表,初始值为零。

2

根据需求,我们可以explore随机行动,以避免陷入次优行为。

3

有时候我们可能希望在游戏中随机探索行动,这就是为什么我们在get_action方法中引入了一个explore参数。默认情况下,这种情况发生的概率为 10%。

我们返回查找表中值最高的动作,给定当前状态。

我在Policy定义中添加了一个小的实现细节,可能有点令人困惑。get_action方法有一个explore参数。这样做的原因是,如果您学习了一个极度不好的策略,例如总是想向左移动的策略,您将永远没有机会找到更好的解决方案。换句话说,有时您需要探索新的方法,而不是“利用”您对游戏的当前理解。如前所述,我们还没有讨论如何学习改善我们策略的state_action_table中的值。现在,只需记住策略在模拟迷宫游戏时给我们所需的行动即可。

继续之前提到的Simulation类,一个模拟应当接受一个Environment并计算给定Policy的动作,直至达到目标并结束游戏。当我们“执行”这样的完整游戏时观察到的数据,就是我们所说的经验。因此,我们的Simulation类具有一个rollout方法,用于计算一个完整游戏的经验并返回它们。这里是Simulation类的实现:

示例 3-8.
class Simulation(object):
    def __init__(self, env):
        """Simulates rollouts of an environment, given a policy to follow."""
        self.env = env

    def rollout(self, policy, render=False, explore=True, epsilon=0.1):  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
        """Returns experiences for a policy rollout."""
        experiences = []
        state = self.env.reset()  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
        done = False
        while not done:
            action = policy.get_action(state, explore, epsilon)  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
            next_state, reward, done, info = self.env.step(action)  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)
            experiences.append([state, action, reward, next_state])  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/5.png)
            state = next_state
            if render:  ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/6.png)
                time.sleep(0.05)
                self.env.render()

        return experiences

1

我们通过遵循policy的动作计算游戏的“roll-out”,可以选择渲染模拟。

2

为了确保,我们在每次rollout之前重置环境。

3

传入的policy驱动我们采取的动作。exploreepsilon参数被传递。

4

我们通过应用策略的action在环境中进行步进。

5

我们定义经验为一个(state, action, reward, next_state)四元组。

6

在每个步骤可选择渲染环境。

注意,我们在rollout中收集的每个experiences条目包含四个值:当前状态、采取的动作、接收到的奖励和下一个状态。我们即将实现的算法将利用这些经验来学习。其他算法可能使用其他的经验值,但这些是我们继续所需的。

现在我们有一个尚未学到任何东西的策略,但我们已经可以测试其接口是否工作。让我们通过初始化一个Simulation对象,调用其在一个不那么聪明的Policy上的rollout方法,并打印其state_action_table来试试看:

示例 3-9.
untrained_policy = Policy(environment)
sim = Simulation(environment)

exp = sim.rollout(untrained_policy, render=True, epsilon=1.0)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
for row in untrained_policy.state_action_table:
    print(row)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

1

我们使用一个“未经训练”的策略进行一个完整游戏的“roll-out”,并进行渲染。

2

当前所有状态-动作值均为零。

如果你觉得自上一节以来我们没有取得太多进展,我可以向你保证,接下来的部分将会有所进展。设置SimulationPolicy的准备工作是为了正确地构架问题。现在唯一剩下的就是设计一种智能的方式,根据我们收集到的经验更新Policy的内部状态,使其真正学会玩迷宫游戏。

训练强化学习模型

假设我们有一组经验数据,这些数据是从几场游戏中收集到的。更新我们Policystate_action_table中的值的聪明方法是什么?这里有一个想法。假设你处于位置(3,5),然后决定向右移动,将你移到(4,5),距离目标只有一步之遥。显然,在这种情况下,你可以向右移动并收到奖励1。这必须意味着你当前的状态结合向右行动应该有一个很高的值。换句话说,这个特定的状态-动作对的值应该很高。相反,在相同的情况下向左移动并没有带来任何进展,因此相应的状态-动作对应该有一个低值。

更一般地说,假设你处于给定的state,然后决定采取一个action,导致一个reward,然后进入next_state。请记住,这就是我们定义的一次经验。通过我们的policy.state_action_table,我们可以稍微展望一下,并查看从next_state采取行动是否能带来任何好处。也就是说,我们可以计算

next_max = np.max(policy.state_action_table[next_state])

如何将这个值的知识与当前的状态-动作值进行比较,即value = policy.state_action_table[state][action]?有许多方法可以解决这个问题,但显然我们不能完全丢弃当前的value,而过于信任next_max。毕竟,这只是我们在这里使用的单一经验。因此,作为第一个近似值,为什么我们不简单地计算旧值和期望值的加权和,并选择new_value = 0.9 * value + 0.1 * next_max?在这里,0.90.1这些值被任意地选择,唯一重要的是第一个值足够高以反映我们保留旧值的偏好,并且这两个权重的总和为1。这个公式是一个很好的起点,但问题在于我们根本没有考虑到从reward中获取的关键信息。事实上,我们应该更加信任当前的reward值而不是预测的next_max值,因此最好稍微打折后者,比如说 10%。更新状态-动作值将如下所示:

new_value = 0.9 * value + 0.1 * (reward + 0.9 * next_max)

根据你对这种推理方式的经验水平,最后几段可能需要一些时间消化。好消息是,如果你已经理解到这一点,这一章剩下的部分可能会很容易理解。从数学上讲,这是这个示例中唯一的(也是最后的)难点。如果你以前接触过强化学习,你现在可能已经注意到这是所谓的 Q 学习算法的实现方式。它被称为这样,因为状态-动作表可以描述为一个函数Q(state, action),它返回这些对的值。

我们已经接近了,现在让我们通过为一个策略和收集到的经验实现一个update_policy函数来正式化这个过程:

示例 3-10。
import numpy as np

def update_policy(policy, experiences, weight=0.1, discount_factor=0.9):
    """Updates a given policy with a list of (state, action, reward, state)
    experiences."""
    for state, action, reward, next_state in experiences:  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
        next_max = np.max(policy.state_action_table[next_state])  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
        value = policy.state_action_table[state][action]  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
        new_value = (1 - weight) * value + weight * (reward + discount_factor * next_max)  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)
        policy.state_action_table[state][action] = new_value  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/5.png)

1

我们按顺序遍历所有经验。

2

然后我们在下一个状态中选择所有可能动作中的最大值。

3

然后提取当前状态动作值。

4

新值是旧值和期望值的加权和,期望值是当前奖励和折扣next_max的总和。

5

更新后,我们设置新的state_action_table值。

现在有了这个函数,训练策略做出更好的决策就变得非常简单了。我们可以使用以下过程:

  • 初始化一个策略和一个模拟器。

  • 运行模拟多次,比如总共运行10000次。

  • 对于每场游戏,首先通过运行rollout收集经验。

  • 然后通过调用update_policy更新收集到的经验来更新策略。

就是这样!以下train_policy函数直接实现了上述过程。

示例 3-11.
def train_policy(env, num_episodes=10000, weight=0.1, discount_factor=0.9):
    """Training a policy by updating it with rollout experiences."""
    policy = Policy(env)
    sim = Simulation(env)
    for _ in range(num_episodes):
        experiences = sim.rollout(policy)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
        update_policy(policy, experiences, weight, discount_factor)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

    return policy

trained_policy = train_policy(environment)  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)

1

收集每场游戏的经验。

2

使用这些经验更新我们的策略。

3

最后,从之前的enviroment中训练并返回一个策略。

请注意,在强化学习文献中,高级说法是迷宫游戏的完整游戏称为一个情节。这就是为什么我们在train_policy函数中称之为num_episodes而不是num_games的原因。

现在我们有了一个训练过的策略,让我们看看它的表现如何。在本章中,我们之前随机运行了两次策略,只是为了了解它们在迷宫问题上的表现。但现在让我们在几个游戏上适当评估我们训练过的策略,看看它的平均表现如何。具体来说,我们将运行我们的模拟几个情节,并计算每个情节需要多少步才能达到目标。因此,让我们实现一个evaluate_policy函数,正好做到这一点:

示例 3-12.
def evaluate_policy(env, policy, num_episodes=10):
    """Evaluate a trained policy through rollouts."""
    simulation = Simulation(env)
    steps = 0

    for _ in range(num_episodes):
        experiences = simulation.rollout(policy, render=True, explore=False)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
        steps += len(experiences)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

    print(f"{steps / num_episodes} steps on average "
          f"for a total of {num_episodes} episodes.")

    return steps / num_episodes

evaluate_policy(environment, trained_policy)

1

这次我们将explore设置为False,以充分利用训练过的策略的学习。

2

experiences的长度是我们完成游戏所需的步数。

除了看到训练过的策略连续十次击败迷宫问题,正如我们希望的那样,您还应该看到以下提示:

8.0 steps on average for a total of 10 episodes.

换句话说,训练后的策略能够为迷宫游戏找到最优解。这意味着你成功地从头开始实现了你的第一个强化学习算法!

基于你目前积累的理解,你认为将 seeker 放入随机起始位置,然后运行这个评估函数仍然有效吗?为什么不继续做必要的更改?

另一个有趣的问题是问问自己,我们使用的算法中有什么假设?例如,该算法明确要求所有状态-动作对都可以被制表。如果我们有数百万个状态和数千个动作,你认为它仍然会表现良好吗?

构建一个分布式 Ray 应用程序。

让我们在这里退一步。如果你是一个强化学习专家,你会知道我们一直在做什么。如果你完全是强化学习的新手,你可能会感到有些不知所措。如果你处于中间某个位置,你可能会喜欢这个例子,但可能会想知道我们到目前为止所做的与 Ray 有什么关系。这是一个很好的问题。很快你就会看到,为了使上述强化学习实验成为一个分布式 Ray 应用程序,我们只需要编写三个简短的代码片段。这就是我们要做的:

  • 我们创建了一个 Ray 任务,可以远程初始化一个 Policy

  • 然后,我们只需几行代码将 Simulation 设为 Ray actor。

  • 然后我们将 update_policy 函数封装在一个 Ray 任务中。

  • 最后,我们定义了一个结构与原始版本完全相同的并行版本的 train_policy

让我们通过实现一个 create_policy 任务和一个名为 SimulationActor 的 Ray actor 来解决这个计划的前两个步骤:

示例 3-13。
import ray

ray.init()
environment = Environment()
env_ref = ray.put(environment)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)

@ray.remote
def create_policy():
    env = ray.get(env_ref)
    return Policy(env)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

@ray.remote
class SimulationActor(Simulation):  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
    """Ray actor for a Simulation."""
    def __init__(self):
        env = ray.get(env_ref)
        super().__init__(env)

1

初始化后,我们将我们的 environment 放入 Ray 对象存储。

2

这个远程任务返回一个新的 Policy 对象。

3

这个 Ray actor 以一种简单直接的方式封装了我们的 Simulation 类。

在你在第 第二章 中建立的 Ray Core 基础上,阅读这段代码应该不成问题。也许自己编写它可能需要一些适应时间,但在概念上,你应该掌握这个例子的要领。

接下来,让我们定义一个分布式的 update_policy_task Ray 任务,然后将所有内容(两个任务和一个 actor)封装在一个名为 train_policy_parallel 的函数中,在你的本地 Ray 集群上分发这个强化学习工作负载:

示例 3-14。
@ray.remote
def update_policy_task(policy_ref, experiences_list):
    """Remote Ray task for updating a policy with experiences in parallel."""
    [update_policy(policy_ref, ray.get(xp)) for xp in experiences_list]  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    return policy_ref

def train_policy_parallel(num_episodes=1000, num_simulations=4):
    """Parallel policy training function."""
    policy = create_policy.remote()  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
    simulations = [SimulationActor.remote() for _ in range(num_simulations)]  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)

    for _ in range(num_episodes):
        experiences = [sim.rollout.remote(policy) for sim in simulations]  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)
        policy = update_policy_task.remote(policy, experiences)  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/5.png)

    return ray.get(policy)  ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/6.png)

1

这个任务通过向 update_policy 函数传递从对象存储中检索到的策略和经验的引用,委托给原始函数。

2

为了并行训练,我们首先远程创建一个策略,返回一个我们称之为 policy 的引用。

3

我们不是创建一个模拟,而是创建四个模拟执行者。

4

现在,我们从远程模拟执行者收集经验。

5

然后我们可以远程更新我们的策略。注意experiences是经验的嵌套列表。

6

最后,我们再次从对象存储中检索训练好的策略。

这使得我们能够采取最后一步,以与之前相同的方式并行运行训练过程,然后评估结果。

示例 3-15。
parallel_policy = train_policy_parallel()
evaluate_policy(environment, parallel_policy)

这两行的结果与我们在迷宫 RL 训练的串行版本运行时相同。希望您能欣赏train_policy_paralleltrain_policy具有完全相同的高级结构。逐行比较这两者是一个很好的练习。基本上,将训练过程并行化所需的全部工作就是以适当的方式三次使用ray.remote装饰器。当然,您需要一些经验才能做到这一点。但请注意,我们几乎没有花费时间思考分布式计算,而是将更多时间花在实际的应用代码上。我们无需采用全新的编程范式,只需以最自然的方式解决问题。最终,这才是您想要的——而 Ray 非常擅长提供这种灵活性。

最后,让我们快速查看我们刚刚构建的 Ray 应用程序的任务执行图。总结一下,我们所做的是:

  • train_policy_parallel函数创建了多个SimulationActor执行者和一个create_policy策略。

  • 模拟执行者使用策略创建回合,并收集用于更新策略的经验,update_policy_task利用这些经验来更新策略。

  • 这是可行的,因为策略更新的方式设计得很好。不管经验是由一个模拟还是多个模拟收集的,都没有关系。

  • 进行回合和更新操作,直到达到我们希望训练的轮数,然后返回最终的trained_policy

图图 3-1 以一种紧凑的方式总结了此任务图:

Ray 训练

图 3-1。使用 Ray 并行训练强化学习策略。

本章的运行示例有一个有趣的旁注,即它是其创作者在初始论文中用来展示 Ray 灵活性的伪代码实现。该论文有一张类似于图 3-1 的图表,值得阅读以获取背景知识。

回顾强化学习术语。

在我们结束本章之前,让我们在更广泛的背景下讨论我们在迷宫示例中遇到的概念。这样做将为您准备好在下一章中更复杂的强化学习设置,并向您展示我们为本章的运行示例简化了哪些内容。

每个强化学习问题都从制定环境的形式开始,该环境描述了您想要进行“游戏”的动态。环境托管一个玩家或代理,通过简单的接口与其环境进行交互。代理可以请求来自环境的信息,即其在环境中的当前状态、在此状态下收到的奖励以及游戏是否完成。通过观察状态和奖励,代理可以学习基于其接收到的信息做出决策。具体而言,代理将发出一个动作,环境可以通过采取下一步来执行该动作。

代理用于在给定状态下生成动作的机制称为策略,有时我们会说代理遵循特定策略。给定一个策略,我们可以使用该策略模拟或展开几步或整个游戏。在展开过程中,我们可以收集经验,这些经验包括当前状态和奖励、下一步动作以及结果状态的信息。从开始到结束的整个步骤序列称为回合,环境可以通过将其重置到初始状态来开始新的回合。

在本章中使用的策略基于简单的状态-动作值(也称为Q 值)制表的理念,并且用于根据回合收集的经验更新策略的算法称为Q 学习。更普遍地说,您可以将我们实施的状态-动作表视为策略使用的模型。在下一章中,您将看到更复杂的模型示例,例如神经网络用于学习状态-动作值。策略可以决定利用它对环境学到的内容,选择其模型的最佳可用值,或者通过选择随机动作探索环境。

这里介绍的许多基本概念适用于任何 RL 问题,但我们做了一些简化的假设。例如,环境中可能存在多个代理(想象一下有多个寻找者竞争首先达到目标的情况),我们将在下一章中研究所谓的多代理环境和多代理 RL。此外,我们假设代理的action spacediscrete的,这意味着代理只能采取固定的一组行动。当然,您也可以有continuous的动作空间,第一章中的摆锤示例就是其中的一个例子。特别是当您有多个代理时,动作空间可能会更复杂,您可能需要动作元组,甚至根据情况进行嵌套。我们为迷宫游戏考虑的observation space也相当简单,被建模为一组离散状态。您可以很容易想象,像机器人这样与其环境互动的复杂代理可能会使用图像或视频数据作为观测值,这也需要一个更复杂的观测空间。

我们所做的另一个关键假设是环境是deterministic的,这意味着当我们的代理选择采取行动时,结果的状态总是反映出那个选择。在一般环境中,情况并非如此,环境中可能存在随机因素。例如,在迷宫游戏中,我们可以实现一个硬币翻转,每当出现反面时,代理就会被随机推向一个方向。在这种情况下,我们不能像本章中那样事先规划,因为行动不会确定地导致每次都是相同的下一个状态。为了反映这种概率行为,在一般情况下,我们必须考虑我们的 RL 实验中的state transition probabilities

最后,我想在这里谈论的最后一个简化假设是,我们一直把环境及其动态视为可以完美模拟的游戏。但事实是,有些物理系统无法忠实地模拟。在这种情况下,您可能仍然可以通过像我们在我们的Environment类中定义的接口与这个物理环境交互,但会涉及一些通信开销。实际上,我发现将 RL 问题视为游戏来推理几乎没有损害体验的感觉。

总结

总结一下,我们在纯 Python 中实现了一个简单的迷宫问题,然后使用简单的强化学习算法解决了在该迷宫中找到目标的任务。然后,我们将这个解决方案移植到一个大致只有 25 行代码的分布式 Ray 应用程序中。在这个过程中,我们无需规划如何使用 Ray,只需简单地使用 Ray API 来并行化我们的 Python 代码。这个例子展示了 Ray 如何让你专注于应用程序代码,而不必考虑与 Ray 的交互。它还展示了如何高效地实现和分布使用 RL 等高级技术的定制工作负载。

在下一章中,你将基于所学内容继续构建,并看到使用更高级别的 Ray RLlib 库直接解决迷宫问题是多么容易。

¹ 正如我们将在第四章中看到的那样,你也可以在多人游戏中运行强化学习。将迷宫环境变成所谓的多智能体环境,其中多个搜索者竞争目标,是一个有趣的练习。

第四章:使用 Ray RLlib 的强化学习

在上一章中,你从头开始构建了一个强化学习(RL)环境,一个模拟来执行一些游戏,一个 RL 算法,以及并行化训练算法的代码。知道如何做这一切是很好的,但实际上在训练 RL 算法时,你真正想做的只是第一部分,即指定你的自定义环境,“游戏”¹。然后大部分精力将会放在选择正确的算法上,设置它,为问题找到最佳参数,并且总体上专注于训练一个表现良好的算法。

Ray RLlib 是一个工业级库,用于大规模构建 RL 算法。你已经在 第一章 中看到了 RLlib 的一个示例,但在本章中我们将深入探讨。RLlib 的优点在于它是一个成熟的开发库,并提供了良好的抽象以便开发者使用。正如你将看到的,许多这些抽象你已经从上一章中了解过了。

我们首先通过概述 RLlib 的能力来开始本章。然后我们迅速回顾一下 第三章 中的迷宫游戏,并展示如何在几行代码中使用 RLlib 命令行界面(CLI)和 RLlib Python API 来处理它。你将看到 RLlib 的易用性在开始学习它的关键概念之前是多么简单。

我们还将更详细地研究一些在实践中非常有用但通常在其他 RL 库中得不到良好支持的高级 RL 主题。例如,你将学习如何为你的 RL 代理创建一个学习课程,以便它们可以先学习简单的场景,然后再转向更复杂的场景。你还将看到 RLlib 如何处理单个环境中有多个代理,并如何利用在当前应用之外收集的经验数据来提高你的代理性能。

RLlib 概述

在我们深入一些示例之前,让我们快速概述一下 RLlib 的功能。作为 Ray 生态系统的一部分,RLlib 继承了 Ray 的所有性能和可扩展性优势。特别是,RLlib 默认是分布式的,因此你可以将 RL 训练扩展到任意数量的节点。其他 RL 库可能也能够扩展实验,但通常并不简单明了。

基于 Ray 构建的另一个好处是,RLlib 与其他 Ray 库紧密集成。例如,所有 RLlib 算法都可以通过 Ray Tune 进行调优,正如我们将在 第五章 中看到的,你也可以使用 Ray Serve 无缝部署你的 RLlib 模型,正如我们将在 第八章 中讨论的那样。

极其有用的是,RLlib 在撰写本文时与两种主要的深度学习框架都兼容,即 PyTorch 和 TensorFlow。您可以将其中任何一个用作后端,并且可以轻松地在它们之间切换,通常只需更改一行代码。这是一个巨大的优势,因为公司通常被锁定在其底层深度学习框架中,无法承担切换到另一个系统并重写其代码的成本。

RLlib 还解决了许多现实世界的问题,并且是许多公司将其 RL 工作负载推向生产的成熟库。我经常向工程师推荐 RLlib,因为它的 API 往往对他们具有吸引力。其中一个原因是 RLlib API 为许多应用程序提供了正确的抽象级别,同时仍然足够灵活,可以在必要时进行扩展。

除了这些更一般的好处之外,RLlib 还具有许多 RL 特定的功能,我们将在本章中介绍。事实上,RLlib 功能如此丰富,以至于它本身都应该有一本书来介绍,所以我们只能在这里涉及一些方面。例如,RLlib 具有丰富的高级 RL 算法库可供选择。在本章中,我们将只专注于其中的一些选择,但您可以在RLlib 算法页面上跟踪不断增长的选项列表。RLlib 还有许多用于指定 RL 环境的选项,并且在训练过程中处理这些选项非常灵活,详见RLlib 环境概述

使用 RLlib 入门

要使用 RLlib,请确保已在计算机上安装了它:

pip install "ray[rllib]"==1.9.0

与本书中的每一章一样,如果您不想自己键入代码来跟进,可以查看本章的附带笔记本

每个 RL 问题都始于拥有一个有趣的环境来研究。在第一章中,我们已经看过了经典的摆动平衡问题。请回忆一下,我们没有实现这个摆动环境,它是由 RLlib 自带的。

相反,在第三章中,我们自己实现了一个简单的迷宫游戏。这个实现的问题是我们不能直接将其与 RLlib 或任何其他 RL 库一起使用。原因是在 RL 中,您必须遵循环境的普遍标准。您的环境需要实现某些接口。用于 RL 环境的最知名且最广泛使用的库是gym,这是一个来自 OpenAI 的开源 Python 项目

让我们看看gym是什么,以及如何将上一章的迷宫Environment转换为与 RLlib 兼容的gym环境。

创建一个 Gym 环境

如果你看过在 GitHub 上很好文档化且易于阅读的gym.Env环境接口 链接,你会注意到这个接口的实现具有两个必需的类变量和三个子类需要实现的方法。你不必查看源代码,但我鼓励你看一看。你可能会对你已经了解到的关于gym环境的知识感到惊讶。

简而言之,gym 环境的接口看起来像以下的伪代码:

import gym

class Env:

    action_space: gym.spaces.Space
    observation_space: gym.spaces.Space  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)

    def step(self, action):  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
        ...

    def reset(self):  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
        ...

    def render(self, mode="human"):  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)
        ...

1

gym.Env接口具有动作和观察空间。

2

Env可以运行step并返回一个包含观测、奖励、完成状态和其他信息的元组。

3

一个Env可以通过reset方法重置自身,并返回当前的观测结果。

4

我们可以为不同目的render一个Env,比如用于人类显示或作为字符串表示。

如果你仔细阅读了第三章,你会注意到这与我们在那里构建的迷宫Environment接口非常相似。事实上,gym中有一个所谓的Discrete空间在gym.spaces中实现,这意味着我们可以将我们的迷宫Environment作为gym.Env来使用。我们假设你将这段代码存储在一个名为maze_gym_env.py的文件中,并且Discrete空间和来自第三章的Environment代码要么位于文件的顶部(或者被导入到那里)。

# Original definition of `Environment` and `Discrete` go here.

import gym
from gym.spaces import Discrete  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)

class GymEnvironment(Environment, gym.Env):  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
    def __init__(self, *args, **kwargs):
        """Make our original `Environment` a gym `Env`."""
        super().__init__(*args, **kwargs)

gym_env = GymEnvironment()

1

我们使用我们自己的Discrete实现覆盖gym的实现。

2

然后,我们简单地使我们的GymEnvironment实现gym.Env。接口本质上与之前相同。

当然,我们本来可以通过直接从中继承的方式使我们的原始Environment实现gym.Env。但重点在于,在强化学习的背景下,gym.Env接口如此自然地显现出来,所以不依赖外部库实现它是个不错的练习。

值得注意的是,gym.Env接口还提供了有用的实用功能和许多有趣的示例实现。例如,我们在第一章中使用的Pendulum-v1环境就是gym的一个例子,还有许多其他环境可用于测试你的强化学习算法。

运行 RLlib CLI

现在我们已经将我们的GymEnvironment实现为gym.Env,以下是您如何在 RLlib 中使用它。您在第一章中已经看到了 RLlib CLI 的运行情况,但这次情况有些不同。在第一章中,我们仅通过名称在一个 YAML 文件中引用了Pendulum-v1环境,以及其他 RL 训练配置。这次我们想要使用我们自己的gym环境类,即我们在maze_gym_env.py中定义的GymEnvironment类。为了在 Ray RLlib 中指定这个类,您需要使用从引用它的地方的类的完整限定名称,即在我们的情况下是maze_gym_env.GymEnvironment。如果您有一个更复杂的 Python 项目,并且您的环境存储在另一个模块中,您只需相应地添加模块名。

以下的 YAML 文件指定了在GymEnvironment类上训练 RLlib 算法所需的最小配置。为了尽可能地与我们在第三章中的实验保持一致,我们使用 Q-learning 时,选择了DQN作为我们训练的算法。此外,为了确保我们可以控制训练的时间,我们设置了显式的停止条件,即通过将timesteps_total设置为10000

# maze.yml
maze_env:
    env: maze_gym_env.GymEnvironment  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    run: DQN
    checkpoint_freq: 1 ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
    stop:
        timesteps_total: 10000  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)

1

我们在这里指定了相对 Python 路径到我们的环境类。

2

我们在每次训练迭代后存储我们模型的检查点。

3

我们还可以为训练指定一个停止条件,这里是最大 10000 步。

假设您将此配置存储在一个名为maze.yml的文件中,您现在可以通过运行以下train命令来启动 RLlib 训练运行:

 rllib train -f maze.yml

这一行代码基本上处理了我们在第三章中所做的一切,但更好。它为我们运行了一个更复杂的 Q-Learning 版本(DQN),在底层处理多个工作者的扩展,并且甚至自动为我们创建算法的检查点。

从训练脚本的输出中,您应该看到 Ray 会将训练结果写入位于~/ray_results/maze_envlogdir目录。在那个文件夹中,您会找到另一个以DQN_maze_gym_env.GymEnvironment_开头的目录,其中包含这个实验的标识符(在我的情况下是0ae8d)和当前的日期和时间。在那个目录中,您应该会找到几个以checkpoint前缀开头的其他子目录。对于我电脑上的训练运行,总共有10个检查点可用,并且我们正在使用最后一个(checkpoint_000010/checkpoint-10)来评估我们训练过的 RLlib 算法。通过在我的机器上生成的文件夹和检查点,您可以使用rllib evaluate命令读取如下(根据您的机器调整检查点路径):

rllib evaluate ~/ray_results/maze_env/DQN_maze_gym_env.Environment_0ae8d_00000_
0_2022-02-08_13-52-59/checkpoint_000010/checkpoint-10\
  --run DQN\
  --env maze_gym_env.Environment\
  --steps 100

--run中使用的算法和--env中指定的环境必须与训练运行中使用的算法和环境匹配,我们评估了经过训练的算法共计 100 步。这应该会导致以下形式的输出:

Episode #1: reward: 1.0
Episode #2: reward: 1.0
Episode #3: reward: 1.0
...
Episode #13: reward: 1.0

在我们给DQN算法设置的简单迷宫环境中,RLlib 算法每次都能获得最大奖励1,这应该不会让人感到意外。

在转向 RLlib 的 Python API 之前,应该注意trainevaluateCLI 命令即使对于更复杂的环境也很方便。YAML 配置可以接受 Python API 会接受的任何参数,因此从这个意义上说,在命令行上训练您的实验没有限制²。

使用 RLlib Python API

话虽如此,您可能会大部分时间在 Python 中编写您的强化学习实验。最终,RLlib CLI 只是我们现在要看的基础 Python 库的一个包装器。

要从 Python 运行 RLlib 的 RL 工作负载,您的主入口点是Trainer类。具体而言,对于您选择的算法,您希望使用相应的Trainer。在我们的案例中,由于我们决定使用 Deep Q-Learning (DQN)进行演示,我们将使用DQNTrainer类。

训练 RLlib 模型

RLlib 为其所有Trainer实现提供了良好的默认值,这意味着您可以初始化它们而不必调整这些训练器的任何配置参数³。例如,要生成一个 DQN 训练器,您可以简单地使用DQNTrainer(env=GymEnvironment)。但值得注意的是,RLlib 训练器是高度可配置的,正如您将在以下示例中看到的。具体来说,我们向Trainer构造函数传递了一个config字典,并告诉它总共使用四个工作进程。这意味着DQNTrainer将生成四个 Ray actor,每个使用一个 CPU 内核,以并行方式训练我们的 DQN 算法。

在您使用所需的env初始化您的训练器,并传入您想要的config之后,您可以简单地调用train方法。让我们使用这种方法来对算法进行总共十次迭代的训练:

from ray.tune.logger import pretty_print
from maze_gym_env import GymEnvironment
from ray.rllib.agents.dqn import DQNTrainer

trainer = DQNTrainer(env=GymEnvironment, config={"num_workers": 4})  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)

config = trainer.get_config()  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
print(pretty_print(config))

for i in range(10):
    result = trainer.train()  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)

print(pretty_print(result))  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)

1

我们使用 RLlib 中的DQNTrainer来使用 Deep-Q-Networks (DQN)进行训练,使用 4 个并行工作者(Ray actors)。

2

每个Trainer都有一个复杂的默认配置。

3

我们随后可以简单地调用train方法来对代理进行十次迭代的训练。

4

使用pretty_print实用程序,我们可以生成训练结果的易读输出。

请注意,10 次训练迭代并没有特殊意义,但应足以使算法充分学习如何解决迷宫问题。这个例子只是向您展示,您完全可以控制训练过程。

从打印的 config 字典中,您可以验证 num_workers 参数设置为 4⁴。同样地,如果您运行此训练脚本,则 result 包含了关于 Trainer 状态和训练结果的详细信息,这些信息过于冗长,无法在此处列出。对我们目前最相关的部分来说,有关算法奖励的信息应当表明算法已学会解决迷宫问题。您应该看到以下形式的输出:

...
episode_reward_max: 1.0
episode_reward_mean: 1.0
episode_reward_min: 1.0
episodes_this_iter: 15
episodes_total: 19
...
timesteps_total: 10000
training_iteration: 10
...

特别是,这个输出显示每一集平均达到的最小奖励为 1.0,这意味着代理程序总是能够达到目标并收集到最大奖励(1.0)。

保存、加载和评估 RLlib 模型

对于这个简单示例来说,达到目标并不太难,但我们来看看评估训练过的算法是否确认代理也可以以最优的方式达到目标,即仅需最少的八步即可。

为此,我们利用了您已经从 RLlib CLI 中看到的另一种机制,即 checkpointing。创建模型检查点非常有用,以确保您在崩溃时可以恢复工作,或者简单地持续跟踪训练进度。您可以通过调用 trainer.save() 在训练过程的任何时候创建 RLlib 训练器的检查点。一旦您有了检查点,您可以轻松地通过调用 trainer.evaluate(checkpoint) 和您创建的检查点来 restore 您的 Trainer。整个过程如下所示:

checkpoint = trainer.save()  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
print(checkpoint)

evaluation = trainer.evaluate(checkpoint)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
print(pretty_print(evaluation))

restored_trainer = DQNTrainer(env=GymEnvironment)
restored_trainer.restore(checkpoint)  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)

1

您可以使用 save 来创建训练器的检查点。

2

RLlib 的训练器可以在您的检查点处进行评估。

3

您还可以从给定的检查点 restore 任何 Trainer

我应该提到,您也可以直接调用 trainer.evaluate() 而无需先创建检查点,但通常最好还是使用检查点。现在看看输出,我们可以确认,经过训练的 RLlib 算法确实收敛到了迷宫问题的良好解决方案,这是通过评估中的长度为 8 的集数表明的:

~/ray_results/DQN_GymEnvironment_2022-02-09_10-19-301o3m9r6d/checkpoint_000010/
checkpoint-10 evaluation:
  ...
  episodes_this_iter: 5
  hist_stats:
    episode_lengths:
    - 8
    - 8
    ...

计算动作

RLlib 训练器拥有比我们迄今为止看到的trainevaluatesaverestore方法更多的功能。例如,您可以直接根据环境的当前状态计算动作。在第三章中,我们通过在环境中进行步进并收集奖励来实现了回合轧制。我们可以通过以下方式轻松地为我们的GymEnvironment使用 RLlib 实现相同的功能:

env = GymEnvironment()
done = False
total_reward = 0
observations = env.reset()

while not done:
    action = trainer.compute_single_action(observations)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    observations, reward, done, info = env.step(action)
    total_reward += reward

1

要为给定的observations计算动作,请使用compute_single_action

如果您需要一次计算多个动作,而不仅仅是单个动作,您可以使用compute_actions方法,该方法以字典形式的观察结果作为输入,并产生具有相同字典键的动作字典作为输出。

action = trainer.compute_actions({"obs_1": observations, "obs_2": observations})
print(action)
# {'obs_1': 0, 'obs_2': 1}

访问策略和模型状态

请记住,每个强化学习算法都基于一个策略,该策略根据智能体对环境的当前观察选择下一个动作。每个策略又基于一个底层模型

就我们在第三章中讨论的基本 Q 学习而言,模型是一个简单的状态-动作值查找表,也称为 Q 值。该策略根据所学到的模型决定是否利用,或者通过随机动作探索环境来预测下一个动作。

在使用深度 Q 学习时,策略的基础模型是一个神经网络,它大致将观察结果映射到动作。请注意,为了选择环境中的下一个动作,我们最终对近似的 Q 值的具体值不感兴趣,而是对每个动作的概率感兴趣。所有可能动作的概率分布称为动作分布。在我们这里使用的迷宫示例中,我们可以向上、向右、向下或向左移动,因此在这种情况下,动作分布是一个包含四个概率的向量,每个概率对应一个动作。

为了具体说明,让我们看看如何在 RLlib 中访问策略和模型:

policy = trainer.get_policy()
print(policy.get_weights())

model = policy.model

policymodel都有许多有用的方法可供探索。在这个示例中,我们使用get_weights来检查策略底层模型的参数(标准惯例称为“权重”)。

为了让您相信这里实际上不只是一个模型在起作用,而实际上是我们在单独的 Ray 工作器上训练的四个模型集合,我们可以访问我们在训练中使用的所有工作器,然后像这样询问每个工作器的策略权重:

workers = trainer.workers
workers.foreach_worker(lambda remote_trainer: remote_trainer.get_policy().get_weights())

通过这种方式,您可以在每个工作器上访问Trainer实例的每个可用方法。原则上,您也可以使用这种方法设置模型参数,或以其他方式配置您的工作器。RLlib 工作器最终是 Ray actor,因此您几乎可以按照任何方式更改和操作它们。

我们尚未讨论在DQNTrainer中使用的具体深度 Q 学习实现,但是实际上使用的model比我到目前为止描述的要复杂一些。从任何从策略获得的 RLlibmodel都有一个base_model,它具有一个summary方法来描述自身:

model.base_model.summary()

如您在下面的输出中所见,此模型接收我们的observations。这些observations的形状有点奇怪地标注为[(None, 25)],但本质上这只是表示我们正确编码了预期的5*5迷宫网格值。该模型接着使用两个所谓的Dense层,并在最后预测一个单一值。

Model: "model"
____________________________________________________________________________________
Layer (type)                    Output Shape         Param #     Connected to
====================================================================================
observations (InputLayer)       [(None, 25)]         0
____________________________________________________________________________________
fc_1 (Dense)                    (None, 256)          6656        observations[0][0]
____________________________________________________________________________________
fc_out (Dense)                  (None, 256)          65792       fc_1[0][0]
____________________________________________________________________________________
value_out (Dense)               (None, 1)            257         fc_1[0][0]
====================================================================================
Total params: 72,705
Trainable params: 72,705
Non-trainable params: 0
____________________________________________________________________________________

请注意,您完全可以为您的 RLlib 实验定制此模型。例如,如果您的环境非常复杂并且具有较大的观察空间,您可能需要一个更大的模型来捕捉该复杂性。但是,这需要对底层神经网络框架(在这种情况下为 TensorFlow)有深入的了解,我们并不假设您已具备⁵。

接下来,让我们看看我们是否可以从环境中获取一些观察结果,并将它们传递给我们刚刚从我们的策略中提取出来的model。这部分内容有点技术性,因为在 RLlib 中直接访问模型有点困难。原因是通常情况下,您只会通过您的policy与一个model进行接口,它会负责预处理观察结果并将其传递给模型(以及其他操作)。

幸运的是,我们可以简单地访问策略使用的预处理器,从我们的环境中transform观察结果,然后将其传递给模型:

from ray.rllib.models.preprocessors import get_preprocessor
env = GymEnvironment()
obs_space = env.observation_space
preprocessor = get_preprocessor(obs_space)(obs_space)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)

observations = env.reset()
transformed = preprocessor.transform(observations).reshape(1, -1)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

model_output, _ = model.from_batch({"obs": transformed})  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)

1

您可以使用get_processor来访问策略使用的预处理器。

2

对于从您的env获取的任何observations,您都可以使用transform将其转换为模型所期望的格式。请注意,我们也需要重新调整这些观察结果的形状。

3

您可以使用模型的from_batch方法,在预处理后的观察字典上获取模型输出。

计算了我们的model_output之后,我们现在既可以访问 Q 值,也可以访问模型对于该输出的动作分布,如下所示:

q_values = model.get_q_value_distributions(model_output)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
print(q_values)

action_distribution = policy.dist_class(model_output, model)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
sample = action_distribution.sample()  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
print(sample)

1

get_q_value_distributions方法仅适用于DQN模型。

2

通过访问dist_class,我们可以获取策略的动作分布类。

3

可以从动作分布中进行抽样。

配置 RLlib 实验

现在您已经在示例中看到了 RLlib 的基本 Python 训练 API,让我们退一步,更深入地讨论如何配置和运行 RLlib 实验。到目前为止,您知道您的Trainer接受一个config参数,到目前为止,我们只用它来设置 Ray 工作器的数量为 4。

如果您想要更改 RLlib 训练运行的行为,方法是更改您的Trainerconfig参数。这既相对简单,因为您可以快速添加配置属性,也有点棘手,因为您必须知道config字典期望哪些关键字。一旦您掌握了可用的内容和期望的内容,找到并调整正确的配置属性就会变得更容易。

RLlib 的配置分为两部分,即特定于算法和通用配置。到目前为止,在示例中我们使用了DQN作为我们的算法,它具有某些仅适用于此选择的属性⁶。一旦您选择了算法并希望为性能进行优化,特定于算法的配置就变得更加相关,但在实践中,RLlib 为您提供了很好的默认值以便开始使用。您可以在RLlib 算法的 API 参考中查找配置参数。

算法的通用配置可以进一步分为以下类型。

资源配置

无论您是在本地使用还是在集群上使用 Ray RLlib,您都可以指定用于训练过程的资源。以下是考虑的最重要选项:

num_gpus

指定用于训练的 GPU 数量。首先检查您选择的算法是否支持 GPU 是很重要的。此值也可以是分数。例如,如果在DQN中使用四个回滚工作器(num_workers= 4),则可以设置num_gpus=0.25以将所有四个工作器放置在同一个 GPU 上,以便所有训练器都能从潜在的加速中受益。

num_cpus_per_worker

设置每个工作器使用的 CPU 数。

调试和日志配置

对于任何项目来说,调试您的应用程序都至关重要,机器学习也不例外。RLlib 允许您配置它记录信息的方式以及您如何访问它。

log_level

设置要使用的日志记录级别。这可以是DEBUGINFOWARNERROR,默认为WARN。您应该尝试不同的级别,看看在实践中哪个最适合您的需求。

callbacks

您可以指定自定义回调函数,在训练过程中的各个时点调用。我们将在第五章中更详细地讨论这个话题。

ignore_worker_failures

对于测试来说,将此属性设置为True可能会忽略工作器的失败(默认为False)。

logger_config

您可以指定一个自定义的日志配置,作为一个嵌套字典传递。

回滚工作器和评估配置

当然,你也可以指定训练和评估期间用于回放的工作者数量。

num_workers

你已经见过这个选项了。它用于指定要使用的 Ray 工作者数量。

num_envs_per_worker

指定每个工作者要评估的环境数量。这一设置允许你对环境进行“批量”评估。特别是,如果你的模型评估时间很长,将环境分组可以加速训练。

create_env_on_driver

如果你至少设置了 num_workers 为 1,那么驱动进程不需要创建环境,因为有回放工作者。如果你将此属性设置为 True,你将在驱动器上创建一个额外的环境。

explore

默认设置为 True,此属性允许你关闭探索,例如在评估算法时。

evaluation_num_workers

指定要使用的并行评估工作者数量,默认值为 0。

环境配置

env

指定你想用于训练的环境。这可以是 Ray RLlib 已知的环境的字符串,如任何 gym 环境,或者你实现的自定义环境的类名。还有一种方法是 注册 你的环境,以便你可以通过名称引用它们,但这需要使用 Ray Tune。我们将在 第五章 中学习这个功能。

observation_spaceaction_space

你可以指定环境的观察空间和动作空间。如果不指定,它们将从环境中推断出来。

env_config

你可以选择指定一个环境配置选项的字典,这些选项将传递给环境构造函数。

render_env

默认设置为 False,此属性允许你打开环境渲染,这需要你实现环境的 render 方法。

注意,我们省略了列出的每种类型的许多可用配置选项。此外,还有一类通用配置选项,用于修改强化学习训练过程的行为,比如修改要使用的底层模型。这些属性在某种意义上是最重要的,同时也需要对强化学习有最具体的了解。对于这篇关于 RLlib 的介绍,我们无法再深入讨论更多细节。但好消息是,如果你是 RL 软件的常规用户,你将很容易识别相关的训练配置选项。

使用 RLlib 环境

到目前为止,我们只向你介绍了 gym 环境,但 RLlib 支持多种环境。在快速概述所有可用选项后,我们将向你展示两个具体的高级 RLlib 环境示例。

RLlib 环境概述

所有可用的 RLlib 环境都扩展自一个通用的 BaseEnv 类。如果您想要使用多个相同的 gym.Env 环境副本,可以使用 RLlib 的 VectorEnv 包装器。矢量化环境非常有用,但也是对您已经见过的内容的直接推广。RLlib 提供的另外两种类型的环境更有趣,值得更多的关注。

第一个称为 MultiAgentEnv,允许您训练具有 多个智能体 的模型。与多个智能体一起工作可能会很棘手,因为您必须确保在环境中定义您的智能体具有适当的接口,并考虑每个智能体可能有完全不同的与其环境交互方式。更重要的是,智能体可能会相互交互,并且必须尊重彼此的行动。在更高级的设置中,甚至可能存在智能体的 层次结构,这些结构明确依赖于彼此。简而言之,运行多智能体强化学习实验很困难,我们将在下一个示例中看到 RLlib 如何处理这一问题。

我们将看看的另一种环境类型称为 ExternalEnv,它可以用于将外部模拟器连接到 RLlib。例如,想象一下我们早期简单迷宫问题是一个实际机器人导航迷宫的模拟。在这种情况下,将机器人(或其模拟,实现在不同的软件堆栈中)与 RLlib 的学习代理共同定位可能并不适合。为此,RLlib 为您提供了一个简单的客户端-服务器架构,用于通过 REST API 与外部模拟器进行通信。

在图 Figure 4-1 中,我们总结了所有可用的 RLlib 环境:

RLlib envs

Figure 4-1. 所有可用的 RLlib 环境概述

多智能体工作

在 RLlib 中定义多智能体环境的基本思想很简单。无论您在 gym 环境中定义为单个值的内容,现在您都可以定义为具有每个智能体值的字典,并且每个智能体都有其独特的键。当然,实际上细节比这更复杂。但一旦您定义了一个承载多个智能体的环境,必须定义这些智能体应该如何学习。

在单智能体环境中,有一个智能体和一个策略需要学习。在多智能体环境中,可能会有多个智能体映射到一个或多个策略。例如,如果您的环境中有一组同质智能体,则可以为所有智能体定义一个单一策略。如果它们都以相同的方式 行动,那么它们的行为可以以相同的方式学习。相反,您可能会遇到异构智能体的情况,其中每个智能体都必须学习单独的策略。在这两个极端之间,图 Figure 4-2 中显示了一系列可能性:

Mapping envs

图 4-2。在多代理强化学习问题中将代理映射到策略

我们继续使用迷宫游戏作为本章的运行示例。这样,您可以亲自检查接口在实践中的差异。因此,为了将我们刚刚概述的思想转化为代码,让我们定义一个GymEnvironment类的多代理版本。我们的MultiAgentEnv类将精确地有两个代理,我们将它们编码在一个名为agents的 Python 字典中,但原则上,这也适用于任意数量的代理。我们开始通过初始化和重置我们的新环境:

from ray.rllib.env.multi_agent_env import MultiAgentEnv
from gym.spaces import Discrete
import os

class MultiAgentMaze(MultiAgentEnv):

    agents = {1: (4, 0), 2: (0, 4)}  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    goal = (4, 4)
    info = {1: {'obs': agents[1]}, 2: {'obs': agents[2]}}  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

    def __init__(self,  *args, **kwargs):  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
        self.action_space = Discrete(4)
        self.observation_space = Discrete(5*5)

    def reset(self):
        self.agents = {1: (4, 0), 2: (0, 4)}

        return {1: self.get_observation(1), 2: self.get_observation(2)}  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)

1

现在,我们有两个搜索者在一个名为agents的字典中具有(0, 4)(4, 0)的起始位置。

2

对于info对象,我们使用代理 ID 作为键。

3

动作和观察空间与之前完全相同。

4

观察现在是每个代理的字典。

请注意,与单一代理情况相比,我们既不需要修改动作空间也不需要修改观察空间,因为我们在这里使用的是两个本质上相同的代理,它们可以使用相同的空间。在更复杂的情况下,您需要考虑这样一个事实,即对于某些代理,动作和观察可能看起来不同。

接下来,让我们将我们的辅助方法get_observationget_rewardis_done泛化为适用于多个代理的方法。我们通过将action_id传递给它们的签名,并像以前一样处理每个代理来实现这一点。

    def get_observation(self, agent_id):  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
        seeker = self.agents[agent_id]
        return 5 * seeker[0] + seeker[1]

    def get_reward(self, agent_id):
        return 1 if self.agents[agent_id] == self.goal else 0

    def is_done(self, agent_id):
        return self.agents[agent_id] == self.goal

1

根据其 ID 获取特定代理。

2

重新定义每个辅助方法,以便每个代理都可以使用。

接下来,要将step方法移植到我们的多代理设置中,您必须知道MultiAgentEnv现在期望step传递给一个动作是一个字典,其键也是代理 ID。我们通过循环遍历所有可用代理并代表它们行动来定义一个步骤⁷。

    def step(self, action):  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
        agent_ids = action.keys()

        for agent_id in agent_ids:
            seeker = self.agents[agent_id]
            if action[agent_id] == 0:  # move down
                seeker = (min(seeker[0] + 1, 4), seeker[1])
            elif action[agent_id] == 1:  # move left
                seeker = (seeker[0], max(seeker[1] - 1, 0))
            elif action[agent_id] == 2:  # move up
                seeker = (max(seeker[0] - 1, 0), seeker[1])
            elif action[agent_id] == 3:  # move right
                seeker = (seeker[0], min(seeker[1] + 1, 4))
            else:
                raise ValueError("Invalid action")
            self.agents[agent_id] = seeker  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

        observations = {i: self.get_observation(i) for i in agent_ids}  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
        rewards = {i: self.get_reward(i) for i in agent_ids}
        done = {i: self.is_done(i) for i in agent_ids}

        done["__all__"] = all(done.values())  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)

        return observations, rewards, done, self.info

1

step中的动作现在是每个代理的字典。

2

在为每个搜索者应用正确的动作之后,我们设置所有agents的正确状态。

3

observationsrewardsdones 也是以代理 ID 为键的字典。

4

此外,RLlib 需要知道所有代理何时完成。

最后一步是修改环境的渲染方式,我们通过在屏幕上打印迷宫时用其 ID 表示每个代理来实现这一点。

    def render(self, *args, **kwargs):
        os.system('cls' if os.name == 'nt' else 'clear')
        grid = [['| ' for _ in range(5)] + ["|\n"] for _ in range(5)]
        grid[self.goal[0]][self.goal[1]] = '|G'
        grid[self.agents[1][0]][self.agents[1][1]] = '|1'
        grid[self.agents[2][0]][self.agents[2][1]] = '|2'
        print(''.join([''.join(grid_row) for grid_row in grid]))

例如,可以通过以下代码随机执行一个情节,直到一个代理到达目标:

import time

env = MultiAgentMaze()

while True:
    obs, rew, done, info = env.step(
        {1: env.action_space.sample(), 2: env.action_space.sample()}
    )
    time.sleep(0.1)
    env.render()
    if any(done.values()):
        break

注意我们如何确保通过 Python 字典的方式传递两个随机样本到step方法,并检查任何代理是否已经完成了done。为了简单起见,我们使用了这个break条件,因为两个寻找者同时偶然找到目标的可能性非常小。但是当然,我们希望两个代理最终能够完成迷宫。

无论如何,配备我们的MultiAgentMaze,训练一个 RLlib 的Trainer的方法与以前完全相同。

from ray.rllib.agents.dqn import DQNTrainer

simple_trainer = DQNTrainer(env=MultiAgentMaze)
simple_trainer.train()

这涵盖了训练多智能体强化学习(MARL)问题的最简单情况。但是,如果你还记得我们之前说的,当使用多个代理时,代理和策略之间总是有一个映射关系。如果不指定这样的映射,我们的两个寻找者都会隐式地分配到相同的策略。可以通过修改我们训练器配置中的multiagent字典来改变这一点,如下所示:

示例 4-1.
trainer = DQNTrainer(env=MultiAgentMaze, config={
    "multiagent": {
        "policies": {  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
            "policy_1": (None, env.observation_space, env.action_space, {"gamma": 0.80}),
            "policy_2": (None, env.observation_space, env.action_space, {"gamma": 0.95}),
        },
        "policy_mapping_fn": lambda agent_id: f"policy_{agent_id}",  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
    },
})

print(trainer.train())

1

我们首先为我们的代理定义多个策略。

2

每个代理随后可以通过自定义的policy_mapping_fn映射到一个策略。

正如您所看到的,运行多智能体 RL 实验是 RLlib 的一等公民,并且有很多更多的内容可以讨论。支持 MARL 问题是 RLlib 最强大的功能之一。

使用策略服务器和客户端

对于本节中环境的最后一个例子,假设我们原始的GymEnvironment只能在一个不能运行 RLlib 的机器上模拟,例如因为它没有足够的可用资源。我们可以在一个PolicyClient上运行环境,该客户端可以向一个相应的服务器询问适当的下一步动作以应用于环境。反过来,服务器不知道环境的情况。它只知道如何从PolicyClient摄取输入数据,并负责运行所有 RL 相关的代码,特别是定义一个 RLlib 的config对象并训练一个Trainer

定义一个服务器

让我们首先定义这种应用程序的服务器端。我们定义一个名为PolicyServerInput的内容,在localhost9900端口上运行。这个策略输入是客户端稍后将提供的。将这个policy_input定义为我们训练器配置的input,我们可以定义另一个在服务器上运行的DQNTrainer

# policy_server.py
import ray
from ray.rllib.agents.dqn import DQNTrainer
from ray.rllib.env.policy_server_input import PolicyServerInput
import gym

ray.init()

def policy_input(context):
    return PolicyServerInput(context, "localhost", 9900)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)

config = {
    "env": None,  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
    "observation_space": gym.spaces.Discrete(5*5),
    "action_space": gym.spaces.Discrete(4),
    "input": policy_input,  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
    "num_workers": 0,
    "input_evaluation": [],
    "log_level": "INFO",
}

trainer = DQNTrainer(config=config)

1

policy_input函数返回一个在 localhost 上运行在端口 9900 上的PolicyServerInput对象。

2

我们明确将env设置为None,因为这个服务器不需要环境。

3

要使这个工作起来,我们需要将我们的 policy_input 输入到实验的 input 中。

有了这个trainer的定义 ⁸,我们现在可以像这样在服务器上开始一个训练会话:

# policy_server.py
if __name__ == "__main__":

    time_steps = 0
    for _ in range(100):
        results = trainer.train()
        checkpoint = trainer.save()  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
        if time_steps >= 10.000:  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
            break
        time_steps += results["timesteps_total"]

1

我们最多训练 100 次迭代,并在每次迭代后保存检查点。

2

如果训练超过 10,000 个时间步,我们停止训练。

在接下来的内容中,我们假设你将最后两个代码片段存储在一个名为 policy_server.py 的文件中。如果愿意,你现在可以在本地终端上运行 python policy_server.py 来启动这个策略服务器。

定义一个客户端

接下来,为了定义应用程序的相应客户端端,我们定义一个PolicyClient,它连接到刚刚启动的服务器。由于我们不能假设您家里或云端有多台计算机,与之前相反,我们将在同一台机器上启动此客户端。换句话说,客户端将连接到 http://localhost:9900,但如果您可以在不同的机器上运行服务器,可以用该机器的 IP 地址替换 localhost,只要它在网络中可用。

策略客户端具有相当简洁的接口。它们可以触发服务器开始或结束一个 episode,从中获取下一个动作,并将奖励信息记录到其中(否则不会有)。说到这里,这是如何定义这样一个客户端的方法。

# policy_client.py
import gym
from ray.rllib.env.policy_client import PolicyClient
from maze_gym_env import GymEnvironment

if __name__ == "__main__":
    env = GymEnvironment()
    client = PolicyClient("http://localhost:9900", inference_mode="remote")  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)

    obs = env.reset()
    episode_id = client.start_episode(training_enabled=True)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

    while True:
        action = client.get_action(episode_id, obs)  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)

        obs, reward, done, info = env.step(action)

        client.log_returns(episode_id, reward, info=info)  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)

        if done:
            client.end_episode(episode_id, obs)  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/5.png)
            obs = env.reset()

            exit(0)  ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/6.png)

1

我们在服务器地址上启动一个策略客户端,以 remote 推理模式运行。

2

接着,我们告诉服务器开始一个 episode。

3

对于给定的环境观察,我们可以从服务器获取下一个动作。

4

client必须向服务器记录奖励信息。

5

如果达到某个特定条件,我们可以停止客户端进程。

6

如果环境处于 done 状态,我们必须通知服务器 episode 已经完成。

假设你把这段代码存储在 policy_client.py 中,并通过运行 python policy_client.py 启动它,之后我们之前启动的服务器将仅仅从客户端获取环境信息进行学习。

高级概念

到目前为止,我们一直在处理简单的环境,这些环境足够简单,可以用 RLlib 中最基本的 RL 算法设置来解决。当然,在实践中,您并不总是那么幸运,可能需要想出其他方法来解决更难的环境问题。在本节中,我们将介绍迷宫环境的稍微更难的版本,并讨论一些帮助您解决这个环境的高级概念。

构建高级环境

让我们让我们的迷宫GymEnvironment变得更具挑战性。首先,我们将其大小从5x5增加到11x11网格。然后我们在迷宫中引入障碍物,代理可以穿过,但会受到惩罚,即负奖励-1。这样,我们的搜索代理将不得不学会避开障碍物,同时找到目标。另外,我们随机化代理的起始位置。所有这些都使得 RL 问题更难解决。让我们首先看看这个新AdvancedEnv的初始化:

from gym.spaces import Discrete
import random
import os

class AdvancedEnv(GymEnvironment):

    def __init__(self, seeker=None, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.maze_len = 11
        self.action_space = Discrete(4)
        self.observation_space = Discrete(self.maze_len * self.maze_len)

        if seeker:  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
            assert 0 <= seeker[0] < self.maze_len and 0 <= seeker[1] < self.maze_len
            self.seeker = seeker
        else:
            self.reset()

        self.goal = (self.maze_len-1, self.maze_len-1)
        self.info = {'seeker': self.seeker, 'goal': self.goal}

        self.punish_states =   ![2
            (i, j) for i in range(self.maze_len) for j in range(self.maze_len)
            if i % 2 == 1 and j % 2 == 0
        ]

1

现在我们可以在初始化时设置seeker的位置。

2

我们将punish_states引入作为代理的障碍物。

接下来,在重置环境时,我们希望确保将代理的位置重置为随机状态。我们还将达到目标的正奖励增加到5,以抵消通过障碍物时的负奖励(在 RL 训练器探测到障碍位置之前,这种情况会经常发生)。像这样平衡奖励在校准 RL 实验中是至关重要的任务。

    def reset(self):
        """Reset seeker position randomly, return observations."""
        self.seeker = (random.randint(0, self.maze_len - 1), random.randint(0, self.maze_len - 1))
        return self.get_observation()

    def get_observation(self):
        """Encode the seeker position as integer"""
        return self.maze_len * self.seeker[0] + self.seeker[1]

    def get_reward(self):
        """Reward finding the goal and punish forbidden states"""
        reward = -1 if self.seeker in self.punish_states else 0
        reward += 5 if self.seeker == self.goal else 0
        return reward

    def render(self, *args, **kwargs):
        """Render the environment, e.g. by printing its representation."""
        os.system('cls' if os.name == 'nt' else 'clear')
        grid = [['| ' for _ in range(self.maze_len)] + ["|\n"] for _ in range(self.maze_len)]
        for punish in self.punish_states:
            grid[punish[0]][punish[1]] = '|X'
        grid[self.goal[0]][self.goal[1]] = '|G'
        grid[self.seeker[0]][self.seeker[1]] = '|S'
        print(''.join([''.join(grid_row) for grid_row in grid]))

还有许多其他方法可以使这个环境变得更难,比如将其扩大许多倍,对代理在某个方向上的每一步引入负奖励,或者惩罚代理试图走出网格。到现在为止,您应该已经足够了解问题设置,可以自定义迷宫了。

尽管您可能已成功训练了这个环境,但这是一个介绍一些高级概念的好机会,您可以应用到其他 RL 问题中。

应用课程学习

RLlib 最有趣的特性之一是为 Trainer 提供一个课程来学习。这意味着,我们不是让训练器从任意的环境设置中学习,而是挑选出容易学习的状态,然后慢慢地引入更困难的状态。通过这种方式构建学习课程是使你的实验更快收敛于解决方案的好方法。唯一需要应用课程学习的是一个关于哪些起始状态比其他状态更容易的观点。对于许多环境来说,这实际上可能是一个挑战,但对于我们的高级迷宫来说,很容易想出一个简单的课程。即,追逐者距离目标的距离可以用作难度的度量。我们将使用的距离度量是追逐者坐标与目标之间的绝对距离之和,以定义一个difficulty

要在 RLlib 中运行课程学习,我们定义一个 CurriculumEnv,它扩展了我们的 AdvancedEnv 和来自 RLLib 的所谓 TaskSettableEnvTaskSettableEnv 的接口非常简单,你只需定义如何获取当前难度(get_task)以及如何设置所需难度(set_task)。下面是这个 CurriculumEnv 的完整定义:

from ray.rllib.env.apis.task_settable_env import TaskSettableEnv

class CurriculumEnv(AdvancedEnv, TaskSettableEnv):

    def __init__(self, *args, **kwargs):
        AdvancedEnv.__init__(self)

    def difficulty(self):
        return abs(self.seeker[0] - self.goal[0]) + abs(self.seeker[1] - self.goal[1])

    def get_task(self):
        return self.difficulty()

    def set_task(self, task_difficulty):
        while not self.difficulty() <= task_difficulty:
            self.reset()

要在课程学习中使用这个环境,我们需要定义一个课程函数,告诉训练器何时以及如何设置任务难度。我们在这里有很多选择,但我们使用的是一个简单的调度,每 1000 个训练时间步骤增加一次难度:

def curriculum_fn(train_results, task_settable_env, env_ctx):
    time_steps = train_results.get("timesteps_total")
    difficulty = time_steps // 1000
    print(f"Current difficulty: {difficulty}")
    return difficulty

要测试这个课程函数,我们需要将它添加到我们的 RLlib 训练器 config 中,即通过将 env_task_fn 属性设置为我们的 curriculum_fn。请注意,在对 DQNTrainer 进行15次迭代训练之前,我们还在配置中设置了一个 output 文件夹。这将把我们的训练运行的经验数据存储到指定的临时文件夹中。

config = {
    "env": CurriculumEnv,
    "env_task_fn": curriculum_fn,
    "output": "/tmp/env-out",
}

from ray.rllib.agents.dqn import DQNTrainer

trainer = DQNTrainer(env=CurriculumEnv, config=config)

for i in range(15):
    trainer.train()

运行这个训练器,你应该看到任务难度随着时间的推移而增加,从而为训练器提供了容易开始的例子,以便它从中学习,并在进展中转移到更困难的任务。

课程学习是一个很棒的技术,RLlib 允许你通过我们刚刚讨论过的课程 API 轻松地将其纳入你的实验中。

使用离线数据

在我们之前的课程学习示例中,我们将训练数据存储到了一个临时文件夹中。有趣的是,你已经从第三章知道,在 Q 学习中,你可以先收集经验数据,然后决定在后续的训练步骤中何时使用它。数据收集与训练的分离打开了许多可能性。例如,也许你有一个良好的启发式算法,可以以一种不完美但合理的方式解决你的问题。或者你有人与环境互动的记录,演示了如何通过示例解决问题。

为了以后的训练收集经验数据的主题通常被称为离线数据。之所以称为离线数据,是因为它不是直接由与环境在线交互的策略生成的。不依赖于自己策略输出进行训练的算法称为离策略算法,Q 学习和 DQN 就是其中的一个例子。不具备这种属性的算法相应地称为在策略算法。换句话说,离策略算法可用于训练离线数据⁹。

要使用我们之前存储在/tmp/env-out文件夹中的数据,我们可以创建一个新的训练配置,将该文件夹作为input。请注意,在以下配置中,我们将exploration设置为False,因为我们只想利用以前收集的数据进行训练 - 该算法不会根据自己的策略进行探索。

input_config = {
    "input": "/tmp/env-out",
    "input_evaluation": [],
    "explore": False
}

使用这个input_config进行训练的方式与以前完全相同,我们通过训练一个代理进行10次迭代并评估它来证明这一点:

imitation_trainer = DQNTrainer(env=AdvancedEnv, config=input_config)
for i in range(10):
    imitation_trainer.train()

imitation_trainer.evaluate()

请注意,我们称之为imitation_trainer的训练器。这是因为这种训练过程旨在模仿我们之前收集的数据中反映的行为。因此,在 RL 中这种通过示范学习的类型通常被称为模仿学习行为克隆

其他高级主题

在结束本章之前,让我们看看 RLlib 还提供了一些其他高级主题。您已经看到 RLlib 有多么灵活,从使用各种不同环境到配置您的实验、在课程上进行训练或运行模仿学习。为了让您了解还有什么可能性,您还可以通过 RLlib 做以下几件事情:

  • 您可以完全定制在幕后使用的模型和策略。如果您之前有过深度学习的经验,您就知道在 RL 中拥有一个良好的模型架构是多么重要。在 RL 中,这通常不像在监督学习中那样关键,但仍然是成功运行高级实验的重要部分。

  • 您可以通过提供自定义预处理器来更改观察数据的预处理方式。对于我们简单的迷宫示例,没有什么需要预处理的,但在处理图像或视频数据时,预处理通常是一个关键步骤。

  • 在我们的AdvancedEnv中,我们介绍了要避免的状态。我们的代理需要学会这样做,但是 RLlib 有一个功能可以通过所谓的参数化动作空间自动避免它们。粗略地说,您可以在每个时间点上从动作空间中“屏蔽掉”所有不需要的动作。

  • 在某些情况下,具有可变观察空间可能也是必要的,RLlib 也完全支持这一点。

  • 我们只是简要触及了离线数据的主题。Rllib 具有完整的 Python API,用于读写经验数据,可在各种情况下使用。

  • 最后,我要再次强调,我们这里仅仅使用了DQNTrainer来简化操作,但 RLlib 拥有令人印象深刻的训练算法范围。举一个例子,MARWIL 算法是一种复杂的混合算法,您可以使用它从离线数据中进行模仿学习,同时还可以混合“在线”生成的常规训练数据。

摘要

总结一下,您在本章中看到了 RLlib 的一些有趣特性。我们涵盖了训练多智能体环境,处理由另一个智能体生成的离线数据,设置客户端-服务器架构以将模拟与 RL 训练分离,并使用课程学习来指定越来越困难的任务。

我们还为您快速概述了 RLlib 的主要概念,以及如何使用其 CLI 和 Python API。特别是,我们展示了如何根据您的需求配置 RLlib 训练器和环境。由于我们只覆盖了 RLlib 的一小部分可能性,我们鼓励您阅读其文档并探索其 API

在下一章中,您将学习如何使用 Ray Tune 调整 RLlib 模型和策略的超参数。

¹ 我们仅仅使用了一个简单的游戏来说明强化学习的过程。强化学习有许多有趣的工业应用,并非仅限于游戏。

² 我们需要提及的是,RLlib CLI 在内部使用 Ray Tune,用于诸如模型的检查点等许多其他功能。您将在第五章中更多了解这种集成。

³ 当然,配置你的模型是强化学习实验的关键部分。我们将在下一节详细讨论 RLlib 训练器的配置。

⁴ 如果将num_workers设置为0,只会创建本地节点上的本地工作进程,并且所有训练都在此进行。这在调试时非常有用,因为不会生成额外的 Ray actor 进程。

⁵ 如果您想了解更多关于定制化您的 RLlib 模型的信息,请查看 Ray 文档中关于自定义模型的指南

⁶ 对于专家来说,我们的 DQNs 是通过"dueling": True"double_q": True的默认参数实现的双 Q 学习模型。

⁷ 注意,这可能会导致诸如决定哪个代理程序首先行动之类的问题。在我们的简单迷宫问题中,行动顺序不重要,但在更复杂的场景中,这成为正确建模 RL 问题的关键部分。

⁸ 由于技术原因,我们必须在这里指定观察和行动空间,这在项目的未来迭代中可能不再需要,因为它泄漏了环境信息。同时请注意,我们需要将input_evaluation设置为空列表才能使此服务器正常工作。

⁹ 请注意,RLlib 还具有诸如PPO之类的广泛的在线策略算法。

第五章:使用 Ray Tune 进行超参数优化

在上一章中,我们看到了如何构建和运行各种强化学习实验。运行这些实验可能会很昂贵,无论是在计算资源方面还是运行时间方面。当您转向更具挑战性的任务时,这种情况只会加剧,因为不太可能仅仅从头开始选择一个算法并运行它以获得良好的结果。换句话说,某个时候,您需要调整算法的超参数以获得最佳结果。正如我们将在本章中看到的那样,调整机器学习模型是困难的,但 Ray Tune 是帮助您解决这一任务的绝佳选择。

Ray Tune 是一款非常强大的超参数优化工具。它不仅默认以分布式方式工作,就像 Ray 构建的任何其他库一样,而且它还是当前功能最丰富的超参数优化(HPO)库之一。更为重要的是,Tune 还与一些最杰出的 HPO 库集成,如 HyperOpt、Optuna 等等。这是非常了不起的,因为它使 Tune 成为分布式 HPO 实验的理想选择,几乎无论您来自哪个其他库,或者您是从头开始。

在本章中,我们将首先深入探讨为什么 HPO 很难做到以及如何使用 Ray 自己去简单地实现它。然后我们会教给您 Ray Tune 的核心概念以及如何使用它来调整我们在前一章中构建的 RLlib 模型。最后,我们还将研究如何使用 Tune 来进行监督学习任务,使用像 PyTorch 和 TensorFlow 这样的框架。在此过程中,我们将展示 Tune 如何与其他 HPO 库集成,并向您介绍其更多高级特性。

调整超参数

让我们简要回顾一下超参数优化的基础知识。如果您对这个主题很熟悉,可以跳过本节,但由于我们还讨论了分布式 HPO 的方面,您可能仍然会从中受益。这一章节的笔记本可以在本书的 GitHub 仓库中找到。

如果您还记得我们在第三章介绍的第一个强化学习实验,我们定义了一个非常基础的 Q 学习算法,其内部的状态-动作值根据明确的更新规则进行更新。初始化后,我们从未直接接触这些模型参数,它们是由算法学习的。相比之下,在设置算法时,我们明确选择了在训练之前的 weightdiscount_factor 参数。我当时没有告诉您我们选择如何设置这些参数,我们只是接受它们足以解决手头问题的事实。同样,在第四章中,我们通过设置 num_workers=4 来初始化一个使用四个 rollout worker 的 RLlib 算法的 config。这些参数称为超参数,找到它们的好选择对于成功的实验至关重要。超参数优化领域致力于高效地找到这样的良好选择。

使用 Ray 构建一个随机搜索示例

超参数如我们的 Q 学习算法中的 weightdiscount_factor连续参数,因此我们不可能测试它们的所有组合。更重要的是,这些参数选择可能彼此不独立。如果我们希望它们被选中,我们还需要为每个参数指定一个值范围(在这种情况下,两个超参数都需要在 0 到 1 之间选择)。那么,我们如何确定好甚至是最优的超参数呢?

让我们看一个快速示例,实现了一个天真但有效的超参数调整方法。这个示例还将允许我们介绍稍后将使用的一些术语。核心思想是我们可以尝试随机抽样超参数,为每个样本运行算法,然后根据我们得到的结果选择最佳运行。但为了体现本书的主题,我们不仅想在顺序循环中运行它,我们希望使用 Ray 并行计算我们的运行。

为了保持简单,我们将再次回顾我们在第三章中介绍的简单 Q 学习算法。如果您不记得主训练函数的签名,我们将其定义为 train_policy(env, num_episodes=10000, weight=0.1, discount_factor=0.9)。这意味着我们可以通过向 train_policy 函数传入不同的值来调整算法的 weightdiscount_factor 参数,并查看算法的性能。为此,让我们为我们的超参数定义一个所谓的搜索空间。对于所讨论的两个参数,我们只需在 0 到 1 之间均匀采样值,共 10 个选择。以下是其样子:

示例 5-1.
import random
search_space = []
for i in range(10):
    random_choice = {
        'weight': random.uniform(0, 1),
        'discount_factor': random.uniform(0, 1)
    }
    search_space.append(random_choice)

接下来,我们定义一个目标函数,或者简称目标。目标函数的作用是评估给定超参数集在我们感兴趣的任务中的性能。在我们的情况下,我们想要训练我们的强化学习算法并评估训练好的策略。回想一下,在第三章中,我们还定义了一个evaluate_policy函数,目的正是如此。evaluate_policy函数被定义为返回代理在底层迷宫环境中达到目标所需的平均步数。换句话说,我们想要找到一组能够最小化我们目标函数结果的超参数集。为了并行化目标函数,我们将使用ray.remote装饰器来将我们的objective变成一个 Ray 任务。

示例 5-2.
import ray

@ray.remote
def objective(config):  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    environment = Environment()
    policy = train_policy(  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
        environment, weight=config["weight"], discount_factor=config["discount_factor"]
    )
    score = evaluate_policy(environment, policy)  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
    return [score, config]  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)

1

我们将一个超参数样本字典传递给我们的目标函数。

2

然后我们使用选定的超参数训练我们的 RL 策略。

3

之后,我们可以评估策略,以获取我们希望最小化的分数。

4

我们返回得分和一起选择的超参数以便后续分析。

最后,我们可以使用 Ray 并行运行目标函数,通过迭代搜索空间并收集结果:

示例 5-3.
result_objects = [objective.remote(choice) for choice in search_space]
results = ray.get(result_objects)

results.sort(key=lambda x: x[0])
print(results[-1])

此超参数运行的实际结果并不是非常有趣,因为问题很容易解决(大多数运行将返回 8 步的最优解,无论选择哪些超参数)。但是,如果我还没有向您展示 Ray 的能力,更有趣的是使用 Ray 并行化目标函数有多么容易。事实上,我想鼓励您重新编写上述示例,只需循环遍历搜索空间并为每个样本调用目标函数,以确认这样的串行循环有多么缓慢。

从概念上讲,我们运行上述示例的三个步骤代表了超参数调整工作的一般过程。首先,您定义一个搜索空间,然后定义一个目标函数,最后运行分析以找到最佳超参数。在 HPO 中,通常将每个超参数样本对目标函数的评估称为试验,所有试验形成您分析的基础。关于如何从搜索空间中抽样参数(在我们的案例中是随机抽样),这完全取决于搜索算法的决定。实际上,找到好的超参数说起来容易做起来难,因此让我们更仔细地看看为什么这个问题如此棘手。

为什么 HPO 难?

如果您从上述示例中稍微放大视角,就会发现在使超参数调整过程顺利运行中有许多复杂因素。以下是最重要的几个要点的快速概述:

  • 您的搜索空间可以由大量超参数组成。这些参数可能具有不同的数据类型和范围。一些参数可能是相关的,甚至依赖于其他参数。从复杂的、高维空间中抽样良好的候选者是一项困难的任务。

  • 随机选择参数可能效果出乎意料地好,但并不总是最佳选择。一般来说,您需要测试更复杂的搜索算法来找到最佳参数。

  • 特别是,即使像我们刚刚所做的那样并行化您的超参数搜索,单次运行目标函数可能需要很长时间才能完成。这意味着您不能负担得起总共运行太多次搜索。例如,训练神经网络可能需要几个小时才能完成,因此您的超参数搜索最好要有效。

  • 在分发搜索时,您需要确保有足够的计算资源可用于有效地运行对目标函数的搜索。例如,您可能需要一个 GPU 来快速计算您的目标函数,因此您所有的搜索运行都需要访问一个 GPU。为每个试验分配必要的资源对加速搜索至关重要。

  • 您希望拥有便捷的工具来进行您的 HPO 实验,如提前停止糟糕的运行,保存中间结果,从先前的试验重新启动,或暂停和恢复运行等。

作为一种成熟的、分布式的 HPO 框架,Ray Tune 处理所有这些话题,并为您提供一个简单的界面来运行超参数调整实验。在我们研究 Tune 如何工作之前,让我们重写上面的例子以使用 Tune。

对 Tune 的介绍

要初尝 Tune 的滋味,将我们对随机搜索的天真 Ray Core 实现移植到 Tune 是直截了当的,并且遵循与之前相同的三个步骤。首先,我们定义一个搜索空间,但这次使用tune.uniform,而不是random库:

示例 5-4。
from ray import tune

search_space = {
    "weight": tune.uniform(0, 1),
    "discount_factor": tune.uniform(0, 1),
}

接下来,我们可以定义一个目标函数,几乎与以前的相同。我们设计得就是这样。唯一的区别是,这次我们将分数返回为一个字典,并且我们不需要一个ray.remote装饰器,因为 Tune 会在内部为我们分配此目标函数。

示例 5-5。
def tune_objective(config):
    environment = Environment()
    policy = train_policy(
        environment, weight=config["weight"], discount_factor=config["discount_factor"]
    )
    score = evaluate_policy(environment, policy)

    return {"score": score}

有了这个tune_objective函数的定义,我们可以将其传递到tune.run调用中,以及我们定义的搜索空间。默认情况下,Tune 将为您运行随机搜索,但您也可以指定其他搜索算法,很快您将看到。调用tune.run为您的目标生成随机搜索试验,并返回包含有关超参数搜索信息的analysis对象。我们可以通过调用get_best_config并指定metricmode参数(我们希望最小化我们的分数)来获得找到的最佳超参数:

示例 5-6。
analysis = tune.run(tune_objective, config=search_space)
print(analysis.get_best_config(metric="score", mode="min"))

这个快速示例涵盖了 Tune 的基础知识,但还有很多要解开的。tune.run函数非常强大,有许多参数供您配置运行。为了理解这些不同的配置选项,我们首先需要向您介绍 Tune 的关键概念。

Tune 是如何工作的?

要有效地使用 Tune,您必须了解六个关键概念,其中四个在上一个示例中已经使用过。下面是 Ray Tune 组件的非正式概述及其思考方式:

  • 搜索空间: 这些空间确定要选择的参数。搜索空间定义了每个参数值的范围以及如何对其进行采样。它们定义为字典,并使用 Tune 的采样函数指定有效的超参数值。您已经看到了tune.uniform,但还有很多其他选择可供选择

  • 可训练对象: Trainable是 Tune 对您想要“调整”的目标的正式表示。Tune 还有基于类的 API,但在本书中我们只使用基于函数的 API。对于我们来说,Trainable是一个带有单个参数(搜索空间)的函数,它向 Tune 报告得分。最简单的方法是通过返回包含您感兴趣得分的字典来实现。

  • 试验: 通过触发tune.run(...),Tune 将确保设置试验并安排它们在集群上执行。每个试验包含关于目标单次运行的所有必要信息,给定一组超参数。

  • 分析: 调用run方法完成后返回一个ExperimentAnalysis对象,其中包含所有试验的结果。您可以使用此对象深入了解试验结果。

  • 搜索算法: Tune 支持多种搜索算法,它们是调整超参数的核心。到目前为止,您只隐式地遇到了 Tune 的默认搜索算法,它从搜索空间中随机选择超参数。

  • 调度器: Tune 实验的最后一个关键组件是调度器。调度器计划并执行搜索算法选择的试验。默认情况下,Tune 按先进先出(FIFO)的方式调度搜索算法选择的试验。实际上,您可以将调度器视为加快实验速度的一种方法,例如通过提前停止不成功的试验。

图 图 5-1 概述了 Tune 的主要组件及其在一个图表中的关系:

Tune

图 5-1. Ray Tune 的核心组件。

注意,Tune 内部运行在 Ray 集群的驱动进程上,该进程会生成多个工作进程(使用 Ray actors),这些进程执行您的 HPO 实验的各个单独试验。在驱动程序上定义的可训练对象必须发送到工作进程,并且需要将试验结果通知给运行tune.run(...)的驱动程序。

搜索空间、可训练模型、试验和分析不需要额外解释太多,我们将在本章的其余部分看到每个组件的更多示例。但是搜索算法,或简称为搜索器,以及调度器需要更详细的阐述。

搜索算法

Tune 提供的所有高级搜索算法以及其集成的许多第三方 HPO 库都属于贝叶斯优化的范畴。不幸的是,深入讨论特定贝叶斯搜索算法的细节远远超出了本书的范围。基本思想是,根据先前试验的结果更新您对值得探索的超参数范围的信念。使用这一原则的技术能够做出更为明智的决策,因此通常比独立随机抽样参数(例如随机抽样)更为高效。

除了我们已经看到的基本随机搜索以外,还有从预定义的选择“网格”中选择超参数的网格搜索,Tune 还提供了各种贝叶斯优化搜索器。例如,Tune 集成了流行的 HyperOpt 和 Optuna 库,您可以通过这两个库使用流行的 TPE(树形结构 Parzen 估计器)搜索器与 Tune 一起使用。不仅如此,Tune 还集成了 Ax、BlendSearch、FLAML、Dragonfly、Scikit-Optimize、BayesianOptimization、HpBandSter、Nevergrad、ZOOpt、SigOpt 和 HEBO 等工具。如果您需要在集群上使用这些工具运行 HPO 实验,或者想要轻松地在它们之间切换,Tune 是您的首选。

为了更具体地说明问题,让我们重新编写我们之前的基本随机搜索 Tune 示例,使用bayesian-optimization库。为此,请确保首先在您的 Python 环境中安装这个库,例如使用pip install bayesian-optimization

示例 5-7.
from ray.tune.suggest.bayesopt import BayesOptSearch

algo = BayesOptSearch(random_search_steps=4)

tune.run(
    tune_objective,
    config=search_space,
    metric="score",
    mode="min",
    search_alg=algo,
    stop={"training_iteration": 10},
)

请注意,我们在贝叶斯优化的开始时进行四个随机步骤来“热启动”,并且我们明确地stop在 10 次训练迭代后停止试验运行。

请注意,由于我们不仅仅是随机选择参数来使用BayesOptSearch,我们在 Tune 运行中使用的search_alg需要知道要优化的metric以及是最小化还是优化这个指标。正如我们之前所讨论的,我们希望达到一个"min" "score"

调度器

接下来让我们讨论如何在 Tune 中使用试验调度器来使您的运行更加高效。我们还将在这一节中介绍一种稍微不同的方法,用于在目标函数中向 Tune 报告您的指标。

因此,假设我们不像本章中所有示例中那样直接计算分数,而是在循环中计算中间分数。这在监督式机器学习场景中经常发生,当为模型进行多次迭代训练时(我们将在本章后面看到具体应用)。通过选择良好的超参数,这个中间分数可能会在它计算的循环之前停滞。换句话说,如果我们不再看到足够的增量变化,为什么不早点停止试验呢?这正是 Tune 的调度程序构建的情况之一。

这里是这样一个目标函数的快速示例。这是一个玩具例子,但它将帮助我们更好地讨论我们希望 Tune 找到的最优超参数,远比我们讨论一个黑盒场景要好。

示例 5-8。
def objective(config):
    for step in range(30):  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
        score = config["weight"] * (step ** 0.5) + config["bias"]
        tune.report(score=score)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

search_space = {"weight": tune.uniform(0, 1), "bias": tune.uniform(0, 1)}

1

通常情况下,您可能希望计算中间分数,例如在“训练循环”中。

2

您可以使用tune.report来告诉 Tune 这些中间分数的情况。

我们希望在这里最小化的分数是一个正数的平方根乘以一个weight,再加上一个bias项。显然,这两个超参数都需要尽可能小,以最小化任何正xscore。考虑到平方根函数的“平坦化”,我们可能不必计算所有30次循环通过以找到足够好的值来调整我们的两个超参数。如果你想象每个score计算花费一个小时,尽早停止可能会极大地提升您的实验运行速度。

让我们通过使用流行的 Hyperband 算法作为我们的试验调度程序来说明这个想法。该调度程序需要传递一个指标和模式(再次,我们要min-imize 我们的score)。我们还确保运行 10 个样本,以避免过早停止:

示例 5-9。
from ray.tune.schedulers import HyperBandScheduler

scheduler = HyperBandScheduler(metric="score", mode="min")

analysis = tune.run(
    objective,
    config=search_space,
    scheduler=scheduler,
    num_samples=10,
)

print(analysis.get_best_config(metric="score", mode="min"))

请注意,在这种情况下,我们没有指定搜索算法,这意味着 Hyperband 将在通过随机搜索选择的参数上运行。我们也可以结合这个调度程序与另一个搜索算法。这将使我们能够选择更好的试验超参数并及早停止不良试验。然而,请注意,并非每个调度程序都可以与搜索算法结合使用。建议您查看Tune 的调度程序兼容矩阵获取更多信息。

总结一下,除了 Hyperband 外,Tune 还包括分布式实现的早停算法,如中位数停止规则、ASHA、基于人口的训练(PBT)和基于人口的强盗(PB2)。

配置和运行 Tune

在探讨更具体的使用 Ray Tune 的机器学习示例之前,让我们深入研究一些有用的主题,这些主题将帮助您更好地利用您的 Tune 实验,例如正确利用资源、停止和恢复试验、向您的 Tune 运行添加回调,或定义自定义和条件搜索空间。

指定资源

默认情况下,每个 Tune 试验将在一个 CPU 上运行,并利用尽可能多的 CPU 用于并发试验。例如,如果您在具有 8 个 CPU 的笔记本电脑上运行 Tune,则本章中迄今计算的任何实验都将生成 8 个并发试验,并为每个试验分配一个 CPU。可以使用 Tune 运行的 resources_per_trial 参数来控制这种行为。

有趣的是,这不仅限于 CPU,您还可以确定每个试验使用的 GPU 数量。此外,Tune 还允许您使用 分数资源,即您可以在试验之间共享资源。所以,假设您的机器有 12 个 CPU 和两个 GPU,并且您请求您的 objective 使用以下资源:

from ray import tune

tune.run(objective, num_samples=10, resources_per_trial={"cpu": 2, "gpu": 0.5})

这意味着 Tune 可以在您的机器上调度和执行最多四个并发试验,因为这样可以最大化 GPU 的利用率(同时您仍然可以有 4 个空闲的 CPU 用于其他任务)。如果您愿意,还可以通过将字节的数量传递给 resources_per_trial 来指定试验使用的 "memory" 量。还请注意,如果您需要显式地 限制 并发试验的数量,可以通过将 max_concurrent_trials 参数传递给您的 tune.run(...) 来实现。在上述示例中,假设您希望始终保留一个 GPU 供其他任务使用,您可以通过设置 max_concurrent_trials = 2 来限制并发试验的数量为两个。

请注意,我们刚刚为单台机器上的资源举例的所有内容,自然地扩展到任何 Ray 集群及其可用资源。在任何情况下,Ray 都会尝试安排下一个试验,但会等待并确保有足够的资源可用才执行它们。

回调和指标

如果你花了一些时间调查我们在本章中启动的 Tune 运行的输出,你会注意到每个试验默认都有很多信息,比如试验 ID、执行日期等等。有趣的是,Tune 不仅允许你自定义要报告的指标,还可以通过提供 回调 来钩入 tune.run。让我们计算一个快速而代表性的示例,同时做这两件事。

稍微修改之前的示例,假设我们想在每次试验返回结果时记录特定消息。为此,您只需要在来自 ray.tune 包的 Callback 对象上实现 on_trial_result 方法。下面是一个报告 score 的目标函数的示例:

示例 5-10.
from ray import tune
from ray.tune import Callback
from ray.tune.logger import pretty_print

class PrintResultCallback(Callback):
    def on_trial_result(self, iteration, trials, trial, result, **info):
        print(f"Trial {trial} in iteration {iteration}, got result: {result['score']}")

def objective(config):
    for step in range(30):
        score = config["weight"] * (step ** 0.5) + config["bias"]
        tune.report(score=score, step=step, more_metrics={})

请注意,除了分数之外,我们还向 Tune 报告 stepmore_metrics。实际上,您可以在此处公开任何其他想要跟踪的指标,Tune 将其添加到其试验指标中。以下是如何使用我们的自定义回调运行 Tune 实验,并打印刚刚定义的自定义指标:

示例 5-11.
search_space = {"weight": tune.uniform(0, 1), "bias": tune.uniform(0, 1)}

analysis = tune.run(
    objective,
    config=search_space,
    mode="min",
    metric="score",
    callbacks=[PrintResultCallback()])

best = analysis.best_trial
print(pretty_print(best.last_result))

运行此代码将产生以下输出(除了您将在任何其他 Tune 运行中看到的输出之外)。请注意,我们需要在这里明确指定 modemetric,以便 Tune 知道我们通过 best_result 意味着什么。首先,您应该看到我们回调函数的输出,同时试验正在运行:

...
Trial objective_85955_00000 in iteration 57, got result: 1.5379782083952644
Trial objective_85955_00000 in iteration 58, got result: 1.5539087627537493
Trial objective_85955_00000 in iteration 59, got result: 1.569535794562848
Trial objective_85955_00000 in iteration 60, got result: 1.5848760187255326
Trial objective_85955_00000 in iteration 61, got result: 1.5999446700996236
...

然后,在程序的最后,我们打印最佳可用试验的指标,其中包括我们定义的三个自定义指标。以下输出省略了一些默认指标,以使其更易读。我们建议您自己运行这样的示例,特别是熟悉阅读 Tune 试验输出(由于其并发性质可能会有些压倒性)。

Result logdir: /Users/maxpumperla/ray_results/objective_2022-05-23_15-52-01
...
done: true
experiment_id: ea5d89c2018f483183a005a1b5d47302
experiment_tag: 0_bias=0.73356,weight=0.16088
hostname: mac
iterations_since_restore: 30
more_metrics: {}
score: 1.5999446700996236
step: 29
trial_id: '85955_00000'
...

请注意,我们使用 on_trial_result 作为实现自定义 Tune Callback 的方法示例,但您还有许多其他有用的选项,它们都相对容易理解。在此列出它们并不是很有帮助,但我发现一些回调方法特别有用,如 on_trial_starton_trial_erroron_experiment_endon_checkpoint。后者暗示了我们接下来将讨论的 Tune 运行的一个重要方面。

检查点、停止和恢复

您启动的 Tune 试验越多,它们在单独运行时的时间越长,特别是在分布式设置中,越需要一种机制来保护您免受故障的影响,停止运行或从以前的结果中恢复。Tune 通过定期为您创建 checkpoints 来实现这一点。Tune 动态调整检查点的频率,以确保至少有 95% 的时间用于运行试验,并且不会将过多资源用于存储检查点。

在我们刚刚计算的示例中,使用的检查点目录或 logdir/Users/maxpumperla/ray_results/objective_2022-05-23_15-52-01。如果您在您的机器上运行此示例,默认情况下其结构将是 ~/ray_results/<your-objective>_<date>_<time>。如果您知道您的实验的 name,您可以像这样轻松 resume 它:

示例 5-12.
analysis = tune.run(
    objective,
    name="/Users/maxpumperla/ray_results/objective_2022-05-23_15-52-01",
    resume=True,
    config=search_space)

同样地,您可以通过定义停止条件并将其明确传递给 tune.runstop 您的试验。最简单的选项是通过提供带有停止条件的字典来实现,我们之前已经看到了这个选项。以下是如何在达到 training_iteration 计数为 10 时停止运行我们的 objective 分析,这是所有 Tune 运行的内置指标之一:

示例 5-13.
tune.run(
    objective,
    config=search_space,
    stop={"training_iteration": 10})

这种指定停止条件的方式之一的缺点是,它假定所涉及的度量是增加的。例如,我们计算的 score 起始较高,这是我们希望最小化的。为了为我们的 score 制定一个灵活的停止条件,最好的方法是提供一个如下的停止函数。

示例 5-14。
def stopper(trial_id, result):
    return result["score"] < 2

tune.run(
    objective,
    config=search_space,
    stop=stopper)

在需要更多上下文或显式状态的停止条件的情况下,您还可以定义一个自定义的 Stopper 类,将其传递给 Tune 运行的 stop 参数,但我们不会在这里涵盖这种情况。

自定义和条件搜索空间

我们在这里要涵盖的最后一个更高级的主题是复杂的搜索空间。到目前为止,我们只看到彼此独立的超参数,但实际上,有时某些超参数是相互依赖的。此外,虽然 Tune 的内置搜索空间提供了很多选择,但有时您可能希望从更奇特的分布或您自己的模块中对参数进行抽样。

这里是您可以在 Tune 中处理这两种情况的方法。继续使用我们简单的 objective 示例,假设您不想使用 Tune 的 tune.uniform,而想要使用 numpy 包中的 random.uniform 采样器来为您的 weight 参数。然后,您的 bias 参数应该是 weight 乘以一个标准正态变量。使用 tune.sample_from,您可以处理这种情况(或更复杂和嵌套的情况),如下所示:

示例 5-15。
from ray import tune
import numpy as np

search_space = {
    "weight": tune.sample_from(lambda context: np.random.uniform(low=0.0, high=1.0)),
    "bias": tune.sample_from(lambda context: context.config.alpha * np.random.normal())
}

tune.run(objective, config=search_space)

在 Ray Tune 中有许多有趣的功能可以探索,但是让我们在这里转换一下视角,看看如何使用 Tune 进行一些机器学习应用。

使用 Tune 进行机器学习

正如我们所看到的,Tune 是多才多艺的,允许您为任何您给定的目标调整超参数。特别是,您可以将其与您感兴趣的任何机器学习框架一起使用。在本节中,我们将给出两个示例来说明这一点。

首先,我们将使用 Tune 来优化 RLlib 强化学习实验的参数,然后我们将通过 Tune 使用 Optuna 来调整 Keras 模型。

使用 RLlib 和 Tune

RLlib 和 Tune 已经设计成可以很好地配合使用,因此您可以轻松地为现有的 RLlib 代码设置一个 HPO 实验。事实上,RLlib 的 Trainers 可以作为 tune.run 的第一个参数传入,作为 Trainable。您可以选择实际的 Trainer 类,如 DQNTrainer,或其字符串表示形式,如 "DQN"。作为 Tune metric,您可以传递由您的 RLlib 实验跟踪的任何指标,例如 "episode_reward_mean"。而 tune.runconfig 参数就是您的 RLlib Trainer 配置,但您可以利用 Tune 的搜索空间 API 的全部功能来对超参数进行抽样,例如学习率或训练批次大小¹。这里是我们刚刚描述的完整示例,运行一个在 CartPole-v0 gym 环境上进行调优的 RLlib 实验:

示例 5-16。
from ray import tune

analysis = tune.run(
    "DQN",
    metric="episode_reward_mean",
    mode="max",
    config={
        "env": "CartPole-v0",
        "lr": tune.uniform(1e-5, 1e-4),
        "train_batch_size": tune.choice([10000, 20000, 40000]),
    },
)

调整 Keras 模型

结束本章时,让我们看一个稍微更复杂的示例。正如我们之前提到的,这本书不是主要介绍机器学习,而是介绍 Ray 及其库的入门。这意味着我们既不能向您介绍 ML 的基础知识,也不能花太多时间详细介绍 ML 框架。因此,在本节中,我们假设您熟悉 Keras 及其 API,并对监督学习有一些基本了解。如果您没有这些先决条件,您仍然应该能够跟上,并专注于 Ray Tune 特定部分。您可以将以下示例视为将 Tune 应用于机器学习工作负载的更现实的场景。

从鸟瞰角度来看,我们将加载一个常见的数据集,为 ML 任务准备它,通过创建一个深度学习模型定义一个 Tune 目标,该模型使用 Keras 报告精度指标,并使用 Tune 的 HyperOpt 集成来定义调优我们 Keras 模型的一组超参数的搜索算法。工作流程保持不变-我们定义一个目标,一个搜索空间,然后使用tune.run与我们想要的配置。

要定义一个用于训练的数据集,让我们编写一个简单的load_data实用函数,加载 Keras 附带的著名 MNIST 数据。MNIST 由 28 乘以 28 像素的手写数字图像组成。我们将像素值归一化为 0 到 1 之间,并将这十个数字的标签作为categorical variables。这里是如何只使用 Keras 内置功能来做到这一点(在运行之前确保pip install tensorflow):

示例 5-17。
from tensorflow.keras.datasets import mnist
from tensorflow.keras.utils import to_categorical

def load_data():
    (x_train, y_train), (x_test, y_test) = mnist.load_data()
    num_classes = 10
    x_train, x_test = x_train / 255.0, x_test / 255.0
    y_train = to_categorical(y_train, num_classes)
    y_test = to_categorical(y_test, num_classes)
    return (x_train, y_train), (x_test, y_test)

接下来,我们定义一个调谐目标函数或可训练函数,通过加载我们刚刚定义的数据,设置一个顺序的 Keras 模型,其中的超参数来自我们传入config中的选择,然后编译和拟合模型。为了定义我们的深度学习模型,我们首先将 MNIST 输入图像展平为向量,然后添加两个全连接层(在 Keras 中称为Dense),并在其中添加一个Dropout层。我们要调节的超参数包括第一个Dense层的激活函数、Dropout率以及第一层的“隐藏”输出单元数。我们可以以同样的方式调节这个模型的任何其他超参数,这个选择只是一个例子。

我们可以像在本章的其他示例中手动报告感兴趣的度量方式一样(例如通过在我们的objective中返回字典或使用tune.report(...))。但由于 Tune 配备了合适的 Keras 集成,我们可以使用所谓的TuneReportCallback作为自定义 Keras 回调,将其传递到我们模型的fit方法中。这是我们的 Keras objective函数的样子:

示例 5-18。
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Flatten, Dense, Dropout
from ray.tune.integration.keras import TuneReportCallback

def objective(config):
    (x_train, y_train), (x_test, y_test) = load_data()
    model = Sequential()
    model.add(Flatten(input_shape=(28, 28)))
    model.add(Dense(config["hidden"], activation=config["activation"]))
    model.add(Dropout(config["rate"]))
    model.add(Dense(10, activation="softmax"))

    model.compile(loss="categorical_crossentropy", metrics=["accuracy"])
    model.fit(x_train, y_train, batch_size=128, epochs=10,
              validation_data=(x_test, y_test),
              callbacks=[TuneReportCallback({"mean_accuracy": "accuracy"})])

接下来,让我们使用自定义搜索算法来调整这个目标。具体来说,我们使用HyperOptSearch算法,通过 Tune 可以访问 HyperOpt 的 TPE 算法。要使用这种集成,请确保在您的机器上安装 HyperOpt(例如使用pip install hyperopt==0.2.5)。HyperOptSearch允许我们定义一组有前途的初始超参数选择进行研究。这是完全可选的,但有时您可能有良好的猜测作为起点。在我们的案例中,我们选择了一个"rate"为 0.2 的 dropout,128 个"hidden"单元,并且最初使用了整流线性单元(ReLU)的"activation"函数。除此之外,我们可以像之前使用tune实用工具一样定义一个搜索空间。最后,通过将所有内容传递到tune.run调用中,我们可以得到一个analysis对象来确定找到的最佳超参数。

示例 5-19。
from ray import tune
from ray.tune.suggest.hyperopt import HyperOptSearch

initial_params = [{"rate": 0.2, "hidden": 128, "activation": "relu"}]
algo = HyperOptSearch(points_to_evaluate=initial_params)

search_space = {
    "rate": tune.uniform(0.1, 0.5),
    "hidden": tune.randint(32, 512),
    "activation": tune.choice(["relu", "tanh"])
}

analysis = tune.run(
    objective,
    name="keras_hyperopt_exp",
    search_alg=algo,
    metric="mean_accuracy",
    mode="max",
    stop={"mean_accuracy": 0.99},
    num_samples=10,
    config=search_space,
)
print("Best hyperparameters found were: ", analysis.best_config)

注意,我们在这里充分利用了 HyperOpt 的全部功能,而无需学习其任何具体内容。我们使用 Tune 作为另一个 HPO 工具的分布式前端,同时利用其与 Keras 的原生集成。

虽然我们选择了 Keras 和 HyperOpt 的组合作为使用 Tune 与先进 ML 框架和第三方 HPO 库的示例,但正如前文所述,今天的流行使用中我们几乎可以选择任何其他机器学习库和 HPO 库。如果你对深入了解 Tune 提供的众多其他集成感兴趣,请查看Ray Tune 文档示例

摘要

Tune 可以说是你今天可以选择的最多才多艺的 HPO 工具之一。它功能非常丰富,提供许多搜索算法、高级调度器、复杂的搜索空间、自定义停止器等许多其他功能,在本章中我们无法全部覆盖。此外,它与大多数知名 HPO 工具(如 Optuna 或 HyperOpt)无缝集成,这使得无论是从这些工具迁移,还是通过 Tune 简单利用它们的功能,都变得非常容易。由于 Tune 作为 Ray 生态系统的一部分默认是分布式的,它比许多竞争对手更具优势。您可以将 Ray Tune 视为一个灵活的、分布式的 HPO 框架,扩展了可能只能在单台机器上运行的其他框架。从这个角度来看,如果您需要扩展您的 HPO 实验,那么采用 Tune 几乎没有任何不合适的理由。

¹ 如果你想知道为什么tune.run中的config参数没有称为search_space,历史原因在于与 RLlib config对象的互操作性。

第六章:分布式训练与 Ray Train

理查德·利奥

在之前的章节中,您已经学习了如何使用 Ray 构建和扩展强化学习应用程序,以及如何为这些应用程序优化超参数。正如我们在第一章中所指出的,Ray 还配备了 Ray Train 库,提供了广泛的机器学习训练集成,并允许它们无缝扩展。

我们将从提供关于为什么可能需要扩展机器学习训练的背景开始。然后我们将介绍您使用 Ray Train 所需了解的一些关键概念。最后,我们将介绍 Ray Train 提供的一些更高级的功能。

一如既往,你可以使用本章的笔记本进行跟随。

分布式模型训练的基础

机器学习通常需要大量的重型计算。根据你正在训练的模型类型,无论是梯度提升树还是神经网络,你可能会面临几个训练机器学习模型的常见问题,这促使你调查分布式训练解决方案:

  1. 完成训练所需的时间太长。

  2. 数据的大小太大,无法放入一台机器中。

  3. 模型本身太大,无法适应单台机器。

对于第一种情况,通过提高数据处理的吞吐量可以加速训练。一些机器学习算法,比如神经网络,可以并行计算部分内容以加快训练¹。

在第二种情况下,你选择的算法可能要求将数据集中的所有可用数据放入内存,但给定的单节点内存可能不足。在这种情况下,你需要将数据分布到多个节点上,并以分布式的方式进行训练。另一方面,有时你的算法可能不需要数据分布,但如果你一开始就使用分布式数据库系统,你仍然希望使用能够利用分布式数据的训练框架。

在第三种情况下,当您的模型无法适应单个机器时,您可能需要将模型分割为多个部分,分布在多台机器上。将模型分割到多台机器上的方法称为模型并行。要遇到这个问题,你首先需要一个足够大以至于无法放入单个机器的模型。通常情况下,像 Google 或 Facebook 这样的大公司倾向于需要模型并行,并依赖内部解决方案来处理分布式训练。

相比之下,前两个问题通常在机器学习从业者旅程的早期阶段就会出现。我们刚刚概述的这些问题的解决方案属于数据并行训练的范畴。你不是将模型分割到多台机器上,而是依赖分布式数据来加速训练。

特别是对于第一个问题,如果您能加速训练过程,希望能够在准确性减少或没有减少的情况下尽可能做到成本效益,为什么不去做呢?如果您有分布式数据,无论是由于算法的必要性还是数据存储方式的原因,您都需要一个训练解决方案来处理它。正如您将看到的,Ray Train 是专为高效的数据并行训练而构建的。

Ray Train 简介

Ray Train 是一个用于 Ray 上的分布式训练库。它提供了关键工具,用于训练工作流的不同部分,从特征处理到可伸缩训练,再到与 ML 跟踪工具的集成和模型的导出机制。

在典型的 ML 训练流水线中,您将使用 Ray Train 的以下关键组件:

预处理器

Ray Train 提供了几个常见的预处理对象和工具,以将数据集对象处理成可消耗的特征供训练器使用。

训练器

Ray Train 拥有几个训练器类,使得分布式训练成为可能。训练器是围绕第三方训练框架(如 XGBoost)提供的包装类,与核心 Ray actors(用于分布)集成,还有 Tune 和数据集的整合。

模型

每个训练器可以生成一个模型。该模型可以用于服务。

让我们看看如何通过计算第一个 Ray Train 示例来将这些概念付诸实践。

创建一个端到端的 Ray Train 示例

在下面的示例中,我们演示了如何使用 Ray Train 加载、处理和训练机器学习模型的能力。

对于此示例,我们将使用 scikit-learn 的 datasets 包中的 load_breast_cancer 函数来使用一个简单的数据集²。我们首先将数据加载到 Pandas DataFrame 中,然后将其转换为所谓的 Ray Dataset。第七章完全专注于 Ray Data 库,我们在此仅用它来说明 Ray Train 的 API。

示例 6-1。
from ray.data import from_pandas
import sklearn.datasets

data_raw = sklearn.datasets.load_breast_cancer(as_frame=True)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)

dataset_df = data_raw["data"]
predict_ds = from_pandas(dataset_df)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)

dataset_df["target"] = data_raw["target"]
dataset = from_pandas(dataset_df)

1

将乳腺癌数据加载到 Pandas DataFrame 中。

2

从 DataFrame 创建一个 Ray Dataset。

接下来,让我们指定一个预处理函数。在这种情况下,我们将使用三个关键的预处理器:ScalerRepartitionerChain 对象,将前两者链在一起。

示例 6-2。
preprocessor = Chain(  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    Scaler(["worst radius", "worst area"]),  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
    Repartitioner(num_partitions=2)  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
)

1

创建一个预处理的 Chain

2

缩放两个特定的数据列。

3

将数据分区为两个分区。

我们进行分布式训练的入口是训练器对象。针对不同的框架有特定的训练器,并且每个训练器都配置了一些特定于框架的参数。

举个例子,让我们看看XGBoostTrainer类,它实现了XGBoost的分布式训练。

示例 6-3。
trainer = XGBoostTrainer(
    scaling_config={  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
        "num_actors": 2,
        "gpus_per_actor": 0,
        "cpus_per_actor": 2,
    },
    label="target",  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
    params={  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/3.png)
        "tree_method": "approx",
        "objective": "binary:logistic",
        "eval_metric": ["logloss", "error"],
    },
)

result = trainer.fit(dataset=dataset, preprocessor=preprocessor)  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/4.png)

print(result)

1

指定缩放配置。

2

设置标签列。

3

指定 XGBoost 特定参数。

4

通过调用fit来训练模型。

Ray Train 中的预处理器

预处理器是处理数据预处理的核心类。每个预处理器具有以下 API:

transform 用于处理并应用数据集的处理转换。
fit 用于计算和存储有关预处理器数据集的聚合状态。返回 self 以便进行链式调用。
fit_transform 用于执行需要聚合状态的转换的语法糖。可能在特定预处理器的实现级别进行优化。
transform_batch 用于对批处理进行相同的预测转换。

目前,Ray Train 提供以下编码器

FunctionTransformer 自定义转换器
Pipeline 顺序预处理
StandardScaler 标准化
MinMaxScaler 标准化
OrdinalEncoder 编码分类特征
OneHotEncoder 编码分类特征
SimpleImputer 缺失值填充
LabelEncoder 标签编码

您经常希望确保在训练时间和服务时间可以使用相同的数据预处理操作。

预处理器的使用

您可以通过将它们传递给训练器来使用这些预处理器。Ray Train 将负责以分布式方式应用预处理器到数据集中。

示例 6-4。
result = trainer.fit(dataset=dataset, preprocessor=preprocessor)

预处理器的序列化

现在,一些预处理操作符,如独热编码器,在训练时很容易运行并传递到服务。然而,像标准化这样的其他操作符有点棘手,因为您不希望在服务时间执行大数据处理(例如查找特定列的平均值)。

Ray Train 的一个很好的特点是它们是可序列化的。这使得您可以通过序列化这些运算符来轻松实现从训练到服务的一致性。

示例 6-5。
pickle.dumps(prep)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)

1

我们可以序列化和保存预处理器。

Ray Train 中的训练器

训练器是特定框架的类,以分布式方式运行模型训练。所有训练器共享一个公共接口:

fit(self) 使用给定数据集来拟合这个训练器。
get_checkpoints(self) 返回最近模型检查点列表。
as_trainable(self) 获取此对象的 Tune 可训练类包装器。

Ray Train 支持各种不同的训练器,涵盖多种框架,包括 XGBoost、LightGBM、Pytorch、HuggingFace、Tensorflow、Horovod、Scikit-learn、RLlib 等。

接下来,我们将深入了解两类特定的训练器:梯度提升树框架训练器和深度学习框架训练器。

梯度提升树的分布式训练

Ray Train 提供了 LightGBM 和 XGBoost 的训练器。

XGBoost 是一个经过优化的分布式梯度提升库,旨在高效、灵活和可移植。它在梯度提升框架下实现了机器学习算法。XGBoost 提供了一种并行树提升(也称为 GBDT、GBM),以快速和准确的方式解决许多数据科学问题。

LightGBM 是基于树的学习算法的梯度提升框架。与 XGBoost 相比,它是一个相对较新的框架,但在学术界和生产环境中迅速流行起来。

通过利用 Ray Train 的 XGBoost 或 LightGBM 训练器,您可以在多台机器上使用大型数据集训练 XGBoost Booster。

深度学习的分布式训练

Ray Train 提供了深度学习训练器,例如支持 Tensorflow、Horovod 和 Pytorch 等框架。

与梯度提升树训练器不同,这些深度学习框架通常会给用户更多控制权。例如,Pytorch 提供了一组原语,用户可以用来构建他们的训练循环。

因此,深度学习训练器 API 允许用户传入一个训练函数,并提供回调函数,用于报告指标和检查点。让我们看一个例子,Pytorch 训练脚本。

下面,我们构建一个标准的训练函数:

示例 6-6。
import torch
import torch.nn as nn

num_samples = 20
input_size = 10
layer_size = 15
output_size = 5

class NeuralNetwork(nn.Module):
    def __init__(self):
        super(NeuralNetwork, self).__init__()
        self.layer1 = nn.Linear(input_size, layer_size)
        self.relu = nn.ReLU()
        self.layer2 = nn.Linear(layer_size, output_size)

    def forward(self, input_data):
        return self.layer2(self.relu(self.layer1(input_data)))

input = torch.randn(num_samples, input_size)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
labels = torch.randn(num_samples, output_size)

def train_func():  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/2.png)
    num_epochs = 3
    model = NeuralNetwork()
    loss_fn = nn.MSELoss()
    optimizer = optim.SGD(model.parameters(), lr=0.1)
    for epoch in range(num_epochs):
        output = model(input)
        loss = loss_fn(output, labels)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

1

在此示例中,我们使用随机生成的数据集。

2

定义一个训练函数。

我们构建了一个 Pytorch 训练脚本,其中创建了一个小型神经网络,并使用均方误差(MSELoss)目标来优化模型。这里模型的输入是随机噪声,但您可以想象它是从一个 Torch 数据集中生成的。

现在,Ray Train 将为您处理两个关键事项。

  1. 建立一个协调进程间通信的后端。

  2. 多个并行进程的实例化。

所以,简而言之,您只需要对您的代码做一行更改:

示例 6-7。
import ray.train.torch

def train_func_distributed():
    num_epochs = 3
    model = NeuralNetwork()
    model = train.torch.prepare_model(model)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
    loss_fn = nn.MSELoss()
    optimizer = optim.SGD(model.parameters(), lr=0.1)
    for epoch in range(num_epochs):
        output = model(input)
        loss = loss_fn(output, labels)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

1

为分布式训练准备模型。

然后,您可以将其接入 Ray Train:

示例 6-8。
from ray.train import Trainer

trainer = Trainer(backend="torch", num_workers=4, use_gpu=False)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ray/img/1.png)
trainer.start()
results = trainer.run(train_func_distributed)
trainer.shutdown()

1

创建一个训练器。对于 GPU 训练,将 use_gpu 设置为 True。

这段代码可以在单台机器或分布式集群上运行。

使用 Ray Train 训练器扩展训练规模

Ray Train 的总体理念是,用户不需要考虑如何并行化他们的代码。

使用 Ray Train Trainers,您可以指定一个scaling_config,允许您扩展您的训练而无需编写分布式逻辑。scaling_config允许您声明性地指定 Trainer 使用的计算资源

特别是,您可以通过提供工作者数量和每个工作者应使用的设备类型来指定 Trainer 应该使用的并行度:

示例 6-9。
scaling_config = {"num_workers": 10, "use_gpu": True}

trainer = ray.train.integrations.XGBoostTrainer(
    scaling_config=scaling_config,
    # ...
)

注意,缩放配置参数取决于 Trainer 类型。

这种规范的好处在于,您不需要考虑底层硬件。特别是,您可以指定使用数百个工作者,Ray Train 将自动利用 Ray 集群中的所有节点:

示例 6-10。
# Connect to a large Ray cluster
ray.init(address="auto")

scaling_config = {"num_workers": 200, "use_gpu": True}

trainer = ray.train.integrations.XGBoostTrainer(
    scaling_config=scaling_config,
    # ...
)

将数据连接到分布式训练

Ray Train 提供了训练大数据集的实用工具。

与同样的理念相同,用户不需要考虑如何并行化他们的代码,您只需简单地将大数据集“连接”到 Ray Train,而不必考虑如何摄入和将数据提供给不同的并行工作者。

在这里,我们从随机数据创建了一个数据集。但是,您可以使用其他数据 API 来读取大量数据(使用read_parquet,从Parquet 格式读取数据)。

示例 6-11。
from typing import Dict
import torch
import torch.nn as nn

import ray
import ray.train as train
from ray.train import Trainer

def get_datasets(a=5, b=10, size=1000, split=0.8):

    def get_dataset(a, b, size):
        items = [i / size for i in range(size)]
        dataset = ray.data.from_items([{"x": x, "y": a * x + b} for x in items])
        return dataset

    dataset = get_dataset(a, b, size)
    split_index = int(dataset.count() * split)
    train_dataset, validation_dataset = dataset.random_shuffle().split_at_indices(
        [split_index]
    )
    train_dataset_pipeline = train_dataset.repeat().random_shuffle_each_window()
    validation_dataset_pipeline = validation_dataset.repeat()
    datasets = {
        "train": train_dataset_pipeline,
        "validation": validation_dataset_pipeline,
    }
    return datasets

然后可以指定一个训练函数来访问这些数据点。注意,这里使用了特定的get_dataset_shard函数。在幕后,Ray Train 会自动对提供的数据集进行分片,以便各个工作节点可以同时训练数据的不同子集。这样可以避免在同一个 epoch 内对重复数据进行训练。get_dataset_shard函数将从数据源传递数据的子集给每个单独的并行训练工作者。

接下来,我们对每个 shard 调用iter_epochsto_torchiter_epochs将生成一个迭代器。此迭代器将生成 Dataset 对象,每个对象将拥有整个 epoch 的 1 个 shard(命名为train_dataset_iteratorvalidation_dataset_iterator)。

to_torch将把 Dataset 对象转换为 Pytorch 迭代器。还有一个等价的to_tf函数,将其转换为 Tensorflow Data 迭代器。

当 epoch 结束时,Pytorch 迭代器会引发StopIteration,然后train_dataset_iterator将再次查询新的 shard 和新的 epoch。

示例 6-12。
def train_func(config):
    batch_size = config["batch_size"]
    # hidden_size = config["hidden_size"]
    # lr = config.get("lr", 1e-2)
    epochs = config.get("epochs", 3)

    train_dataset_pipeline_shard = train.get_dataset_shard("train")
    validation_dataset_pipeline_shard = train.get_dataset_shard("validation")
    train_dataset_iterator = train_dataset_pipeline_shard.iter_epochs()
    validation_dataset_iterator = validation_dataset_pipeline_shard.iter_epochs()

    for _ in range(epochs):
        train_dataset = next(train_dataset_iterator)
        validation_dataset = next(validation_dataset_iterator)
        train_torch_dataset = train_dataset.to_torch(
            label_column="y",
            feature_columns=["x"],
            label_column_dtype=torch.float,
            feature_column_dtypes=torch.float,
            batch_size=batch_size,
        )
        validation_torch_dataset = validation_dataset.to_torch(
            label_column="y",
            feature_columns=["x"],
            label_column_dtype=torch.float,
            feature_column_dtypes=torch.float,
            batch_size=batch_size,
        )
        # ... training

    return results

您可以通过以下方式使用 Trainer 将所有内容整合起来:

示例 6-13。
num_workers = 4
use_gpu = False

datasets = get_datasets()
trainer = Trainer("torch", num_workers=num_workers, use_gpu=use_gpu)
config = {"lr": 1e-2, "hidden_size": 1, "batch_size": 4, "epochs": 3}
trainer.start()
results = trainer.run(
   train_func,
   config,
   dataset=datasets,
   callbacks=[JsonLoggerCallback(), TBXLoggerCallback()],
)
trainer.shutdown()
print(results)

Ray Train 特性

检查点

Ray Train 将生成模型检查点以检查训练的中间状态。这些模型检查点提供了经过训练的模型和适配的预处理器,可用于下游应用,如服务和推断。

示例 6-14。
result = trainer.fit()
model: ray.train.Model = result.checkpoint.load_model()

模型检查点的目标是抽象出模型和预处理器的实际物理表示。因此,您应该能够从云存储位置生成检查点,并将其转换为内存表示或磁盘表示,反之亦然。

示例 6-15.
chkpt = Checkpoint.from_directory(dir)
chkpt.to_bytes() -> bytes

回调函数

您可能希望将您的训练代码与您喜爱的实验管理框架插入。Ray Train 提供了一个接口来获取中间结果和回调函数来处理或记录您的中间结果(传递给 train.report(...) 的值)。

Ray Train 包含了流行跟踪框架的内置回调,或者您可以通过 TrainingCallback 接口实现自己的回调函数。可用的回调函数包括:

  • Json 日志记录

  • Tensorboard 日志记录

  • MLflow 日志记录

  • Torch Profiler

示例 6-16.
# Run the training function, logging all the intermediate results
# to MLflow and Tensorboard.

result = trainer.run(
    train_func,
    callbacks=[
        MLflowLoggerCallback(experiment_name="train_experiment"),
        TBXLoggerCallback(),
    ],
)

与 Ray Tune 集成

Ray Train 与 Ray Tune 集成,使您可以仅用几行代码进行超参数优化。Tune 将根据每个超参数配置创建一个试验。在每个试验中,将初始化一个新的 Trainer 并使用其生成的配置运行训练函数。

示例 6-17.
from ray import tune

fail_after_finished = True
prep_v1 = preprocessor
prep_v2 = preprocessor

param_space = {
    "scaling_config": {
        "num_actors": tune.grid_search([2, 4]),
        "cpus_per_actor": 2,
        "gpus_per_actor": 0,
    },
    "preprocessor": tune.grid_search([prep_v1, prep_v2]),
    # "datasets": {
    #     "train_dataset": tune.grid_search([dataset_v1, dataset_v2]),
    # },
    "params": {
        "objective": "binary:logistic",
        "tree_method": "approx",
        "eval_metric": ["logloss", "error"],
        "eta": tune.loguniform(1e-4, 1e-1),
        "subsample": tune.uniform(0.5, 1.0),
        "max_depth": tune.randint(1, 9),
    },
}
if fail_after_finished > 0:
    callbacks = [StopperCallback(fail_after_finished=fail_after_finished)]
else:
    callbacks = None
tuner = tune.Tuner(
    XGBoostTrainer(
        run_config={"max_actor_restarts": 1},
        scaling_config=None,
        resume_from_checkpoint=None,
        label="target",
    ),
    run_config={},
    param_space=param_space,
    name="tuner_resume",
    callbacks=callbacks,
)
results = tuner.fit(datasets={"train_dataset": dataset})
print(results.results)

与其他分布式超参数调整解决方案相比,Ray Tune 和 Ray Train 具有一些独特的特性:

  • 能够将数据集和预处理器指定为参数

  • 容错性

  • 能够在训练时调整工作人数。

导出模型

在使用 Ray Train 训练模型后,您可能希望将其导出到 Ray Serve 或模型注册表。

为此,您可以使用 load_model API 获取模型:

示例 6-18.
result = trainer.fit(dataset=dataset, preprocessor=preprocessor)
print(result)

this_checkpoint = result.checkpoint
this_model = this_checkpoint.load_model()
predicted = this_model.predict(predict_ds)
print(predicted.to_pandas())

一些注意事项

特别是,请记住,标准神经网络训练通过在数据集的不同数据批次(通常称为小批量梯度下降)之间进行迭代。

为了加快速度,您可以并行计算每个小批量更新的梯度。这意味着批次应该在多台机器上分割。

如果保持批处理大小不变,则随着工作人数增加,系统利用率和效率会降低。

为了补偿,实践者通常增加每批数据的数量。

因此,通过数据的单次遍历时间(一个 epoch)应该理想地减少,因为总批次数量减少。

¹ 这特别适用于神经网络中的梯度计算。

² Ray 能处理比那更大的数据集。在 第七章 中,我们将更详细地看一下 Ray 数据库,了解如何处理大数据集。

第七章:使用 Ray 进行数据处理

Edward Oakes

在上一章中,您学习了如何使用 Ray 训练来扩展机器学习训练。当然,将机器学习应用于实践的关键组件是数据。在本章中,我们将探讨 Ray 上的核心数据处理能力集:Ray 数据。

虽然并不意味着取代 Apache Spark 或 Apache Hadoop 等更通用的数据处理系统,但 Ray 数据提供了基本的数据处理功能和一种标准方法,用于在 Ray 应用程序的不同部分加载、转换和传递数据。这使得 Ray 上的库生态系统能够以一种框架不可知的方式说同一种语言,用户可以混合和匹配功能,以满足他们的需求。

Ray 数据生态系统的核心组件是名副其实的“数据集”,它提供了在 Ray 集群中加载、转换和传递数据的核心抽象。数据集是使不同库能够在 Ray 之上相互操作的“粘合剂”。在本章中,您将看到这一点的实际操作,因为我们将展示如何使用 Dask on Ray 进行数据帧处理,并使用 Ray 训练和 Ray 调整将结果转换为数据集,以及如何使用数据集有效地进行大规模分布式深度学习训练。

数据集的主要优势包括:

灵活性

数据集支持各种数据格式,在 Ray 上与 Dask on Ray 等库集成无缝,可以在 Ray 任务和角色之间传递而不复制数据。

机器学习工作负载性能

数据集提供重要功能,如加速器支持、流水线处理和全局随机洗牌,加速机器学习训练和推理工作负载。

本章旨在使您熟悉在 Ray 上进行数据处理的核心概念,并帮助您了解如何完成常见模式以及为什么选择使用不同的部分来完成任务。本章假定您对诸如映射、过滤、分组和分区等数据处理概念有基本了解,但不是一般数据科学的教程,也不是对这些操作的内部实现进行深入挖掘。具有有限数据处理/数据科学背景的读者不应该在跟进时遇到问题。

我们将从对核心构建模块 Ray 数据集进行基本介绍开始。这将涵盖架构、API 基础知识以及示例,展示如何使用数据集来简化构建复杂的数据密集型应用程序。然后,我们将简要介绍 Ray 上的外部库集成,重点放在 Dask on Ray 上。最后,我们将通过在单个 Python 脚本中构建可扩展的端到端机器学习管道来将所有内容汇总。

本章的笔记本也可在线获得

Ray 数据集

数据集概述

Datasets 的主要目标是支持在 Ray 上进行数据处理的可扩展、灵活的抽象。Datasets 旨在成为 Ray 库完整生态系统中读取、写入和传输数据的标准方式。Datasets 最强大的用途之一是作为机器学习工作负载的数据摄入和预处理层,使您能够通过 Ray Train 和 Ray Tune 高效扩展训练。这将在本章的最后一节中详细探讨。

如果你之前使用过其他分布式数据处理 API,比如 Apache Spark 的 RDDs,那么你会觉得 Datasets API 非常熟悉。该 API 的核心依赖于函数式编程,并提供标准功能,如读取/写入多种不同的数据源,执行诸如映射、过滤和排序等基本转换,以及一些简单的聚合操作,比如分组。

在底层,Datasets 实现了分布式Apache Arrow。Apache Arrow 是一个统一的列式数据格式,用于数据处理库和应用程序,因此与之集成意味着 Datasets 能够与诸如 NumPy 和 pandas 等最受欢迎的处理库直接进行交互。

一个 Dataset 包含一系列 Ray 对象引用,每个引用指向数据的“块”。这些块可以是 Arrow 表格,也可以是 Python 列表(对于不支持 Arrow 格式的数据),存储在 Ray 的共享内存对象存储中,数据的计算(如映射或过滤操作)发生在 Ray 任务(有时是 actor)中。

由于 Datasets 依赖于 Ray 的任务和共享内存对象存储的核心原语,它继承了 Ray 的关键优势:可扩展到数百个节点、由于在同一节点上的进程之间共享内存而具有高效的内存使用率,以及对象溢出和恢复以优雅处理故障。此外,由于 Datasets 只是对象引用列表,它们还可以在任务和 actor 之间高效传递,而无需复制数据,这对于使数据密集型应用程序和库具有可扩展性至关重要。

Datasets 基础

本节将基本概述 Ray Datasets,涵盖如何开始读取、写入和转换数据集。这并不意味着是全面参考,而是为了介绍基本概念,以便我们在后面的部分中构建一些有趣的示例,展示 Ray Datasets 的强大之处。有关支持的最新信息和确切语法,请参阅Datasets 文档

若要按照本节示例操作,请确保在本地安装了 Ray Data:

pip install "ray[data]"==1.9.0

创建数据集

首先,让我们创建一个简单的数据集并对其执行一些基本操作:

示例 7-1.
import ray

# Create a dataset containing integers in the range [0, 10000).
ds = ray.data.range(10000)

# Basic operations: show the size of the dataset, get a few samples, print the schema.
print(ds.count())  # -> 10000
print(ds.take(5))  # -> [0, 1, 2, 3, 4]
print(ds.schema())  # -> <class 'int'>

我们创建了一个包含从 0 到 10000 的数字的数据集,然后打印了一些关于它的基本信息:记录总数,一些样本以及架构(稍后我们将详细讨论这一点)。

从存储中读取和写入

当然,对于真实的工作负载,您通常会希望从持久存储中读取数据并写入结果。写入和读取数据集非常简单;例如,要将数据集写入 CSV 文件,然后将其加载回内存,我们只需使用内置的 write_csvread_csv 实用程序即可:

示例 7-2.
# Save the dataset to a local file and load it back.
ray.data.range(10000).write_csv("local_dir")
ds = ray.data.read_csv("local_dir")
print(ds.count())

数据集支持多种常见的序列化格式,如 CSV、JSON 和 Parquet,并且可以从/写入到本地磁盘以及像 HDFS 或 AWS S3 这样的远程存储。

在上面的例子中,我们仅提供了一个本地文件路径("local_dir"),因此数据集被写入到本地机器上的一个目录中。如果我们想要改为写入和读取 S3,我们需要提供类似 "s3://my_bucket/" 的路径,数据集将自动处理有效地读写远程存储,并通过多任务并行化请求以提高吞吐量。

请注意,数据集还支持自定义数据源,您可以使用它们来写入到任何不受开箱即用支持的外部数据存储系统。

内置转换

现在我们已经了解了如何创建和检查数据集的基本 API,让我们来看一些可以在其上执行的内置操作。下面的代码示例展示了数据集支持的三个基本操作:- 首先,我们union两个数据集。结果是一个包含两者所有记录的新数据集。- 然后,我们通过提供自定义过滤函数,filter数据集中的元素仅包括偶数整数。- 最后,我们对数据集进行sort排序。

示例 7-3.
# Basic transformations: join two datasets, filter, and sort.
ds1 = ray.data.range(10000)
ds2 = ray.data.range(10000)
ds3 = ds1.union(ds2)
print(ds3.count())  # -> 20000

# Filter the combined dataset to only the even elements.
ds3 = ds3.filter(lambda x: x % 2 == 0)
print(ds3.count())  # -> 10000
print(ds3.take(5))  # -> [0, 2, 4, 6, 8]

# Sort the filtered dataset.
ds3 = ds3.sort()
print(ds3.take(5))  # -> [0, 0, 2, 2, 4]

除了上述操作外,数据集还支持您可能期望的常见聚合,例如 groupbysummin 等。您还可以传递用户定义的函数进行自定义聚合。

块和重新分区

使用数据集时要牢记的一件重要事情是的概念,在本节中之前已经讨论过。块是构成数据集的基本数据块,操作是逐块应用到底层数据的。如果数据集中的块数过多,每个块将很小,并且每次操作都会有很多开销。如果块数太少,则操作无法高效并行化。

如果我们从上面的例子中查看一下,我们可以看到我们创建的初始数据集默认每个有 200 个块,当我们将它们组合时,结果数据集有 400 个块。在这种情况下,我们可能希望重新分区数据集,将其恢复到最初的 200 个块:

示例 7-4.
ds1 = ray.data.range(10000)
print(ds1.num_blocks())  # -> 200
ds2 = ray.data.range(10000)
print(ds2.num_blocks())  # -> 200
ds3 = ds1.union(ds2)
print(ds3.num_blocks())  # -> 400

print(ds3.repartition(200).num_blocks())  # -> 200

块还控制了在将 Dataset 写入存储时创建的文件数(因此,如果希望所有数据合并到单个输出文件中,应在写入之前调用.repartition(1))。

模式和数据格式

到目前为止,我们一直在处理仅由整数组成的简单 Dataset。然而,对于更复杂的数据处理,我们经常希望有一个模式,这样可以更轻松地理解数据并强制每列的类型。

鉴于数据集旨在成为 Ray 上应用程序和库的交互点,它们被设计为对特定数据类型不可知,并提供灵活性以在许多流行的数据格式之间读取、写入和转换。通过支持 Arrow 的列格式,Dataset 能够在 Python 字典、DataFrames 和序列化 parquet 文件等不同类型的结构化数据之间转换。

创建带有模式的 Dataset 的最简单方法是从 Python 字典列表创建它:

示例 7-5。
ds = ray.data.from_items([{"id": "abc", "value": 1}, {"id": "def", "value": 2}])
print(ds.schema())  # -> id: string, value: int64

在这种情况下,模式是从我们传递的字典键中推断出来的。我们还可以将数据类型与流行库(如 pandas)的数据类型进行转换:

示例 7-6。
pandas_df = ds.to_pandas()  # pandas_df will inherit the schema from our Dataset.

在这里,我们从 Dataset 转换为 pandas DataFrame,但反向操作也适用:如果您从 DataFrame 创建 Dataset,它将自动继承 DataFrame 的模式。

在 Dataset 上计算

在上面的部分中,我们介绍了 Ray Dataset 提供的一些功能,如过滤、排序和合并。然而,Dataset 最强大的部分之一是它们允许您利用 Ray 的灵活计算模型,并高效地处理大量数据。

执行 Dataset 上的自定义转换的主要方法是使用.map()。这允许您传递一个自定义函数,该函数将应用于 Dataset 的记录。一个基本的例子可能是将 Dataset 的记录平方:

示例 7-7。
ds = ray.data.range(10000).map(lambda x: x ** 2)
ds.take(5)  # -> [0, 1, 4, 9, 16]

在这个例子中,我们传递了一个简单的 lambda 函数,我们操作的数据是整数,但我们可以在这里传递任何函数,并在支持 Arrow 格式的结构化数据上操作。

我们还可以选择使用.map_batches()来映射数据批次,而不是单个记录。有些类型的计算在进行向量化时效率更高,这意味着它们使用一种更高效的算法或实现,可以同时操作一组项目。

重新访问我们简单的平方 Dataset 值的例子,我们可以重写它以批处理执行,并使用优化的numpy.square实现,而不是朴素的 Python 实现:

示例 7-8。
import numpy as np

ds = ray.data.range(10000).map_batches(lambda batch: np.square(batch).tolist())
ds.take(5)  # -> [0, 1, 4, 9, 16]

在进行深度学习训练或推理时,矢量化计算在 GPU 上尤其有用。然而,通常在 GPU 上执行计算也具有显著的固定成本,因为需要将模型权重或其他数据加载到 GPU 内存中。为此,Datasets 支持使用 Ray actors 对数据进行映射。Ray actors 寿命长且可以保存状态,与无状态的 Ray tasks 相反,因此我们可以通过在 actor 的构造函数中运行它们(例如将模型加载到 GPU 上)来缓存昂贵的操作成本。

要使用 Datasets 执行批处理推理,我们需要传递一个类而不是一个函数,指定此计算应使用 actors 运行,并使用 .map_batches(),以便我们可以执行矢量化推理。如果我们希望这在 GPU 上运行,还会传递num_gpus=1,这指定了运行 map 函数的 actor 每个都需要一个 GPU。Datasets 将自动为一组 actor 进行自动缩放,以执行映射操作。

示例 7-9.
def load_model():
    # Return a dummy model just for this example.
    # In reality, this would likely load some model weights onto a GPU.
    class DummyModel:
        def __call__(self, batch):
            return batch

    return DummyModel()

class MLModel:
    def __init__(self):
        # load_model() will only run once per actor that's started.
        self._model = load_model()

    def __call__(self, batch):
        return self._model(batch)

ds.map_batches(MLModel, compute="actors")

数据集管道

默认情况下,Dataset 操作是阻塞的,这意味着它们从开始到结束同步运行,一次只有一个操作发生。然而,对于某些工作负载,这种模式可能非常低效。例如,考虑以下一组 Dataset 转换,这些转换可能用于对机器学习模型进行批处理推理:

示例 7-10.
ds = ray.data.read_parquet("s3://my_bucket/input_data")\
        .map(cpu_intensive_preprocessing)\
        .map_batches(gpu_intensive_inference, compute="actors", num_gpus=1)\
        .repartition(10)\
        .write_parquet("s3://my_bucket/output_predictions")

此流水线共有五个阶段,每个阶段都强调系统的不同部分: - 从远程存储中读取需要集群入口带宽,并可能受到存储系统吞吐量的限制。 - 对输入进行预处理需要 CPU 资源。 - 在模型上进行矢量化推理需要 GPU 资源。 - 重新分区需要集群内部的网络带宽。 - 写入远程存储需要集群出口带宽,并可能再次受到存储吞吐量的限制。

低效的数据集计算

图 7-1. 一个天真的 Dataset 计算,导致阶段之间的空闲资源

在这种情况下,相对于顺序执行阶段并允许它们重叠,很可能效率会更高。这意味着一旦从存储中读取了一些数据,它就被馈送到预处理阶段,然后到推理阶段,依此类推。

优化的 DatasetPipeline

图 7-2. 一个优化的 DatasetPipeline,实现了阶段之间的重叠计算并减少了空闲资源

这种流水线将改善端到端工作负载的整体资源使用情况,提高吞吐量,从而减少运行计算所需的成本(更少的空闲资源更好!)。

使用 ds.window() 可以将数据集转换为 DatasetPipelines,从而实现我们在这种场景中希望的管道化行为。窗口指定在通过管道中的一个阶段之前将通过多少个块传递到下一个阶段。可以使用 blocks_per_window 参数进行调整,默认为 10。

让我们重写上面低效伪代码,改用 DatasetPipeline:

示例 7-11.
ds = ray.data.read_parquet("s3://my_bucket/input_data")\
        .window(blocks_per_window=5)\
        .map(cpu_intensive_preprocessing)\
        .map_batches(gpu_intensive_inference, compute="actors", num_gpus=1)\
        .repartition(10)\
        .write_parquet("s3://my_bucket/output_predictions")

唯一的修改是在 read_parquet 之后和预处理阶段之前添加了 .window() 调用。现在 Dataset 已经转换为 DatasetPipeline,并且其阶段将以 5 块窗口并行进行,减少空闲资源并提高效率。

使用 ds.repeat() 可以创建 DatasetPipelines,以有限次数或无限次数重复管道中的阶段。在下一节中我们将进一步探讨这一点,我们将在训练工作负载中使用它。当然,除了推理之外,管道化对于训练的性能同样有益。

示例:从头开始的并行 SGD

数据集的一个关键优点是它们可以在任务和参与者之间传递。在本节中,我们将探讨如何利用这一点来编写复杂分布式工作负载的高效实现,如分布式超参数调整和机器学习训练。

如 第五章 中讨论的,机器学习训练中的一个常见模式是探索一系列“超参数”,以找到产生最佳模型的超参数。我们可能希望在广泛的超参数范围内运行,而这样做可能非常昂贵。数据集使我们能够在单个 Python 脚本中轻松地跨多个并行训练运行中共享相同的内存中数据:我们可以加载和预处理数据一次,然后将其引用传递给许多下游参与者,这些参与者可以从共享内存中读取数据。

此外,有时在处理非常大的数据集时,将完整的训练数据加载到单个进程或单台机器的内存中是不可行的。在分布式训练中,通常将数据分片到许多不同的工作节点上进行并行训练,并使用参数服务器同步或异步地合并结果。有一些重要的考虑因素可能会使这变得棘手:1. 许多分布式训练算法采用同步方法,要求工作节点在每个训练 epoch 后同步其权重。这意味着需要一些协调来维护工作节点在操作的数据批次之间的一致性。2. 在每个 epoch 中,每个工作节点获取数据的随机样本是重要的。全局随机洗牌已被证明比本地洗牌或不洗牌效果更好。

让我们通过一个示例来演示如何使用 Ray Datasets 实现这种类型的模式。在示例中,我们将使用不同的超参数并行地训练多个 SDG 分类器的副本。虽然这不完全相同,但它是一个类似的模式,专注于 Ray Datasets 在机器学习训练工作负载中的灵活性和强大性。

我们将在生成的二分类数据集上训练一个 scikit-learn SGDClassifier,我们将调整的超参数是正则化项(alpha 值)。实际的机器学习任务和模型的详细信息对于本示例并不重要,你可以用任何示例替换模型和数据。这里的重点是我们如何使用 Datasets 来编排数据加载和计算过程。

首先,让我们定义我们的TrainingWorker,它将在数据上训练分类器的一个副本:

示例 7-12。
from sklearn import datasets
from sklearn.linear_model import SGDClassifier
from sklearn.model_selection import train_test_split

@ray.remote
class TrainingWorker:
    def __init__(self, alpha: float):
        self._model = SGDClassifier(alpha=alpha)

    def train(self, train_shard: ray.data.Dataset):
        for i, epoch in enumerate(train_shard.iter_epochs()):
            X, Y = zip(*list(epoch.iter_rows()))
            self._model.partial_fit(X, Y, classes=[0, 1])

        return self._model

    def test(self, X_test: np.ndarray, Y_test: np.ndarray):
        return self._model.score(X_test, Y_test)

关于TrainingWorker有几点重要的事项需要注意:- 它只是一个简单的SGDClassifier包装器,并使用给定的 alpha 值实例化它。- 主要的训练功能在train方法中进行。每个 epoch,它都会在可用的数据上对分类器进行训练。- 我们还有一个test方法,可以用来针对测试集运行训练好的模型。

现在,让我们用不同的超参数(alpha 值)实例化几个TrainingWorker

示例 7-13。
ALPHA_VALS = [0.00008, 0.00009, 0.0001, 0.00011, 0.00012]

print(f"Starting {len(ALPHA_VALS)} training workers.")
workers = [TrainingWorker.remote(alpha) for alpha in ALPHA_VALS]

接下来,我们生成训练和验证数据,并将训练数据转换为 Dataset。在这里,我们使用.repeat()来创建一个 DatasetPipeline。这定义了我们训练将运行的 epoch 数量。在每个 epoch 中,后续操作将应用于 Dataset,并且工作者将能够迭代处理结果数据。我们还随机打乱数据并将其分片以传递给训练工作者,每个工作者获得一个相等的块。

示例 7-14。
# Generate training & validation data for a classification problem.
X_train, X_test, Y_train, Y_test = train_test_split(*datasets.make_classification())

# Create a dataset pipeline out of the training data. The data will be randomly
# shuffled and split across the workers for 10 iterations.
train_ds = ray.data.from_items(list(zip(X_train, Y_train)))
shards = train_ds.repeat(10)\
                 .random_shuffle_each_window()\
                 .split(len(workers), locality_hints=workers)

要在工作者上运行训练,我们调用它们的train方法,并将 DatasetPipeline 的一个 shard 传递给每个工作者。然后,我们阻塞等待所有工作者的训练完成。总结这个阶段发生的事情如下:- 每个 epoch,每个工作者都会获得随机的数据 shard。- 工作者在分配给它的数据片段上训练其本地模型。- 一旦工作者完成了当前 shard 的训练,它会阻塞直到其他工作者也完成。- 上述过程在剩余的 epochs 中重复(在本例中总共为 10 个 epochs)。

示例 7-15。
# Wait for training to complete on all of the workers.
ray.get([worker.train.remote(shard) for worker, shard in zip(workers, shards)])

最后,我们可以在一些测试数据上测试每个工作者训练出的模型,以确定哪个 alpha 值产生了最准确的模型。

示例 7-16。
# Get validation results from each worker.
print(ray.get([worker.test.remote(X_test, Y_test) for worker in workers]))

虽然这可能不是一个真实的机器学习任务,实际上你可能应该选择 Ray Tune 或 Ray Train,但这个例子传达了 Ray 数据集的强大之处,特别是对于机器学习工作负载。仅用几十行 Python 代码,我们就能实现一个复杂的分布式超参数调整和训练工作流程,可以轻松扩展到数十台或数百台机器,并且不受任何框架或特定机器学习任务的限制。

外部库集成

概述

虽然 Ray 数据集支持一些常见的数据处理功能,但正如上文所述,它并不是完整数据处理系统的替代品。相反,它更侧重于执行“最后一英里”处理,如基本数据加载、清理和特征化,然后进行机器学习训练或推断。

用于最后一英里数据处理的 Ray

图 7-3. 使用 Ray 进行机器学习的典型工作流程:使用外部系统进行主要数据处理和 ETL,使用 Ray 数据集进行最后一英里预处理

然而,还有许多其他功能更完备的 DataFrame 和关系型数据处理系统与 Ray 集成:- Dask on Ray - RayDP(Spark on Ray) - Modin(Pandas on Ray) - Mars on Ray

这些都是独立的数据处理库,你可能在 Ray 的环境之外也很熟悉它们。每个工具都与 Ray 核心集成,可以实现比内置数据集更具表现力的数据处理,同时利用 Ray 的部署工具、可扩展的调度和共享内存对象存储交换数据。

Ray 与生态系统集成

图 7-4. Ray 数据生态系统集成的好处,实现在 Ray 上更具表现力的数据处理。这些库与 Ray 数据集集成,以提供给下游库,如 Ray Train。

为了本书的目的,我们将稍微深入探讨一下 Dask on Ray,让你对这些集成有所了解。如果你对特定集成的详细信息感兴趣,请参阅最新的 Ray 文档 获取最新信息。

Dask on Ray

要跟着本节的示例进行操作,请安装 Ray 和 Dask:

pip install ray["data"]==1.9.0 dask

Dask) 是一个专门针对将分析和科学计算工作负载扩展到集群的并行计算 Python 库。Dask 最受欢迎的功能之一是 Dask DataFrames,它提供了 pandas DataFrame API 的一个子集,在处理单节点内存不可行的情况下可以扩展到一组机器上。DataFrames 通过创建一个任务图来工作,该图提交给调度器执行。执行 Dask DataFrames 操作的最典型方式是使用 Dask 分布式调度器,但也有一个可插拔的 API,允许其他调度器执行这些任务图。

Ray 自带一个 Dask 调度器后端,允许 Dask DataFrame 任务图作为 Ray 任务执行,并因此利用 Ray 调度器和共享内存对象存储。这完全不需要修改核心 DataFrames 代码;相反,为了使用 Ray 运行,您只需首先连接到一个运行中的 Ray 集群(或在本地运行 Ray),然后启用 Ray 调度器后端:

示例 7-17.
import ray
from ray.util.dask import enable_dask_on_ray

ray.init()  # Start or connect to Ray.
enable_dask_on_ray()  # Enable the Ray scheduler backend for Dask.

现在我们可以运行常规的 Dask DataFrames 代码,并让它跨 Ray 集群进行扩展。例如,我们可能想使用标准的 DataFrame 操作进行一些时间序列分析,如过滤、分组和计算标准偏差(示例摘自 Dask 文档)。

示例 7-18.
import dask

df = dask.datasets.timeseries()
df = df[df.y > 0].groupby("name").x.std()
df.compute()  # Trigger the task graph to be evaluated.

如果您习惯于 pandas 或其他 DataFrame 库,您可能会想知道为什么我们需要调用 df.compute()。这是因为 Dask 默认是延迟执行的,只会在需要时计算结果,从而优化将在整个集群上执行的任务图。

这个功能中最强大的一个方面是它与 Ray 数据集非常好地集成在一起。我们可以使用内置工具将 Ray 数据集转换为 Dask DataFrame,反之亦然:

示例 7-19.
import ray
ds = ray.data.range(10000)

# Convert the Dataset to a Dask DataFrame.
df = ds.to_dask()
print(df.std().compute())  # -> 2886.89568

# Convert the Dask DataFrame back to a Dataset.
ds = ray.data.from_dask(df)
print(ds.std())  # -> 2886.89568

这个简单的例子可能看起来不太令人印象深刻,因为我们能够使用 Dask DataFrames 或 Ray 数据集计算标准偏差。然而,正如您将在下一节看到的那样,当我们构建一个端到端的 ML pipeline 时,这使得工作流程非常强大。例如,我们可以利用 DataFrame 的全部表现力来进行特征化和预处理,然后直接将数据传递到诸如分布式训练或推断等下游操作,同时保持所有数据在内存中。这突显了 DataFrames 如何在 Ray 上实现广泛的用例,并且像 Dask on Ray 这样的集成使生态系统变得更加强大。

构建一个 ML Pipeline

虽然我们能够在前一节从头开始构建一个简单的分布式训练应用程序,但还有许多边缘情况、性能优化机会和我们想要解决的可用性功能,以构建一个真实的应用程序。正如您在前几章关于 Ray RLlib、Ray Tune 和 Ray Train 中学到的,Ray 拥有一系列库,使我们能够构建可投入生产的 ML 应用程序。在本节中,我们将探讨如何使用数据集作为“粘合层”来端到端构建 ML 管道。

背景

要成功将机器学习模型投入生产,首先需要使用标准的 ETL 流程收集和编目数据。然而,这还不是故事的结束:为了训练模型,我们还经常需要对数据进行特征化,然后再将其馈送到我们的训练过程中,而我们如何将数据馈送到训练中会对成本和性能产生很大影响。训练完模型后,我们还想要在许多不同的数据集上运行推断,毕竟这正是训练模型的全部意义!这个端到端的过程在下图中概述。

尽管这可能看起来只是一系列步骤,但在实践中,机器学习的数据处理工作流程是一个迭代的实验过程,以定义正确的特征集并在其上训练高性能模型。高效加载、转换和将数据输入训练和推断也对性能至关重要,这直接转化为计算密集型模型的成本。通常情况下,实现这些 ML 流水线意味着将多个不同的系统连接在一起,并在阶段之间将中间结果实现到远程存储。这有两个主要缺点:1. 首先,它需要为单个工作流程编排许多不同的系统和程序。这对于任何 ML 从业者来说可能是很难处理的,因此许多人会借助工作流程编排系统,如Apache Airflow。虽然 Airflow 有一些很好的好处,但引入它也意味着引入了许多复杂性(特别是在开发中)。2. 其次,我们在多个不同的系统上运行我们的 ML 工作流程意味着我们需要在每个阶段之间读取和写入存储。这会因数据传输和序列化而产生重大的开销和成本。

相比之下,使用 Ray,我们能够将完整的机器学习流水线构建为一个单独的应用程序,可以作为单个 Python 脚本运行。内置和第三方库的生态系统使得可以混合匹配适合特定用例的正确功能,并构建可扩展的、可投入生产的管道。Ray 数据集充当粘合层,使我们能够在避免昂贵的序列化成本和保持中间数据在共享内存中的情况下,高效地加载、预处理和计算数据。

端到端示例:在纽约出租车乘车中预测大费用

本节将通过一个实际的端到端示例演示如何使用 Ray 构建深度学习流水线。我们将建立一个二元分类模型,以预测出租车行程是否会有较大的小费(超过车费的 20%),使用公共的纽约市出租车和豪华车委员会(TLC)出行记录数据。我们的工作流程将与典型的机器学习从业者非常相似:- 首先,我们将加载数据,进行基本预处理,并计算我们模型中将使用的特征。- 然后,我们将定义一个神经网络,并使用分布式数据并行训练它。- 最后,我们将把训练好的神经网络应用于新的数据批次。

本例将使用 Ray 上的 Dask 并训练一个 PyTorch 神经网络,但请注意,这里没有具体限定于这两个库,Ray Dataset 和 Ray Train 可以与许多流行的机器学习工具一起使用。要在本节中按照示例代码操作,请安装 Ray、PyTorch 和 Dask:

pip install ray["data"]==1.9.0 torch dask

在下面的示例中,我们将从本地磁盘加载数据,以便在您的机器上轻松运行示例。您可以使用 AWS CLI 从AWS 开放数据注册表下载数据到您的本地机器上:

pip install awscli==1.22.1
aws s3 cp --no-sign-request "s3://nyc-tlc/trip data/" ./nyc_tlc_data/

如果您想直接从云存储加载数据,请将示例中的本地路径替换为相应的 S3 URL。

使用 Ray 上的 Dask 进行加载、预处理和特征化

训练我们的模型的第一步是加载和预处理数据。为此,我们将使用 Ray 上的 Dask,正如上文所讨论的,它为我们提供了一个方便的 DataFrame API,并且可以跨集群扩展预处理并有效地传递到我们的训练和推断操作中。

下面是我们的预处理和特征化代码:

示例 7-20。
import ray
from ray.util.dask import enable_dask_on_ray

import dask.dataframe as dd

LABEL_COLUMN = "is_big_tip"

enable_dask_on_ray()

def load_dataset(path: str, *, include_label=True):
    # Load the data and drop unused columns.
    df = dd.read_csv(path, assume_missing=True,
                     usecols=["tpep_pickup_datetime", "tpep_dropoff_datetime",
                              "passenger_count", "trip_distance", "fare_amount",
                              "tip_amount"])

    # Basic cleaning, drop nulls and outliers.
    df = df.dropna()
    df = df[(df["passenger_count"] <= 4) &
            (df["trip_distance"] < 100) &
            (df["fare_amount"] < 1000)]

    # Convert datetime strings to datetime objects.
    df["tpep_pickup_datetime"] = dd.to_datetime(df["tpep_pickup_datetime"])
    df["tpep_dropoff_datetime"] = dd.to_datetime(df["tpep_dropoff_datetime"])

    # Add three new features: trip duration, hour the trip started, and day of the week.
    df["trip_duration"] = (df["tpep_dropoff_datetime"] -
                           df["tpep_pickup_datetime"]).dt.seconds
    df = df[df["trip_duration"] < 4 * 60 * 60] # 4 hours.
    df["hour"] = df["tpep_pickup_datetime"].dt.hour
    df["day_of_week"] = df["tpep_pickup_datetime"].dt.weekday

    if include_label:
        # Calculate label column: if tip was more or less than 20% of the fare.
        df[LABEL_COLUMN] = df["tip_amount"] > 0.2 * df["fare_amount"]

    # Drop unused columns.
    df = df.drop(
        columns=["tpep_pickup_datetime", "tpep_dropoff_datetime", "tip_amount"]
    )

    return ray.data.from_dask(df)

这涉及到基本的数据加载和清洗(删除空值和异常值),以及将一些列转换为可以用作机器学习模型特征的格式。例如,我们将提供为字符串的上车和下车日期时间转换为三个数值特征:trip_durationhourday_of_week。这在 Dask 内置支持的Python datetime utilites的帮助下变得非常简单。如果这些数据将用于训练,我们还需要计算标签列(小费是否超过车费的 20%)。

最后,一旦我们计算出预处理后的 Dask DataFrame,我们将其转换为 Ray Dataset,以便稍后传递到我们的训练和推断过程中。

定义一个 PyTorch 模型

现在我们已经清理和准备好数据,我们需要定义一个模型架构,我们将用于模型。实际上,这可能是一个迭代过程,并涉及研究类似问题的最新技术。为了我们的示例,我们将保持简单,并使用基本的 PyTorch 神经网络。神经网络有三个线性变换,从我们的特征向量维度开始,然后使用 Sigmoid 激活函数输出一个值在 0 到 1 之间。此输出值将四舍五入以产生是否会有较大小费的二进制预测。

示例 7-21。
import torch
import torch.nn as nn
import torch.nn.functional as F

NUM_FEATURES = 6

class FarePredictor(nn.Module):
    def __init__(self):
        super().__init__()

        self.fc1 = nn.Linear(NUM_FEATURES, 256)
        self.fc2 = nn.Linear(256, 16)
        self.fc3 = nn.Linear(16, 1)

        self.bn1 = nn.BatchNorm1d(256)
        self.bn2 = nn.BatchNorm1d(16)

    def forward(self, *x):
        x = torch.cat(x, dim=1)
        x = F.relu(self.fc1(x))
        x = self.bn1(x)
        x = F.relu(self.fc2(x))
        x = self.bn2(x)
        x = F.sigmoid(self.fc3(x))

        return x

使用 Ray Train 进行分布式训练

现在我们已经定义了神经网络架构,我们需要一种有效地在我们的数据上训练它的方式。这个数据集非常庞大(总共数百 GB),因此我们最好的选择可能是执行分布式数据并行训练。我们将使用 Ray Train,在第六章中学习到的内容中定义可扩展的训练过程,这将在底层使用PyTorch DataParallel

首先,我们需要定义逻辑,以便在每个工作节点的每个时期训练数据批次。这将接收完整数据集的本地片段,通过本地模型副本运行,并执行反向传播。

示例 7-22。
import ray.train as train

def train_epoch(iterable_dataset, model, loss_fn, optimizer, device):
    model.train()
    for X, y in iterable_dataset:
        X = X.to(device)
        y = y.to(device)

        # Compute prediction error.
        pred = torch.round(model(X.float()))
        loss = loss_fn(pred, y)

        # Backpropagation.
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

接下来,我们还需要为每个工作节点定义验证其当前模型副本的逻辑。这将通过模型运行本地数据批次,将预测与实际标签值进行比较,并使用提供的损失函数计算随后的损失。

示例 7-23。
def validate_epoch(iterable_dataset, model, loss_fn, device):
    num_batches = 0
    model.eval()
    loss = 0
    with torch.no_grad():
        for X, y in iterable_dataset:
            X = X.to(device)
            y = y.to(device)
            num_batches += 1
            pred = torch.round(model(X.float()))
            loss += loss_fn(pred, y).item()
    loss /= num_batches
    result = {"loss": loss}
    return result

最后,我们定义核心训练逻辑。这将接收各种配置选项(例如批量大小和其他模型超参数),实例化模型、损失函数和优化器,然后运行核心训练循环。在每个时期,每个工作节点将获取其训练和验证数据集的片段,将其转换为本地的PyTorch 数据集,并运行上述定义的验证和训练代码。每个时期结束后,工作节点将使用 Ray Train 工具报告结果并保存当前模型权重以供以后使用。

示例 7-24。
def train_func(config):
    batch_size = config.get("batch_size", 32)
    lr = config.get("lr", 1e-2)
    epochs = config.get("epochs", 3)

    train_dataset_pipeline_shard = train.get_dataset_shard("train")
    validation_dataset_pipeline_shard = train.get_dataset_shard("validation")

    model = train.torch.prepare_model(FarePredictor())

    loss_fn = nn.SmoothL1Loss()
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)

    train_dataset_iterator = train_dataset_pipeline_shard.iter_epochs()
    validation_dataset_iterator = \
        validation_dataset_pipeline_shard.iter_epochs()

    for epoch in range(epochs):
        train_dataset = next(train_dataset_iterator)
        validation_dataset = next(validation_dataset_iterator)

        train_torch_dataset = train_dataset.to_torch(
            label_column=LABEL_COLUMN,
            batch_size=batch_size,
        )
        validation_torch_dataset = validation_dataset.to_torch(
            label_column=LABEL_COLUMN,
            batch_size=batch_size)

        device = train.torch.get_device()

        train_epoch(train_torch_dataset, model, loss_fn, optimizer, device)
        result = validate_epoch(validation_torch_dataset, model, loss_fn,
                                device)
        train.report(**result)
        train.save_checkpoint(epoch=epoch, model_weights=model.module.state_dict())

现在已经定义了完整的训练过程,我们需要加载训练和验证数据以供训练工作节点使用。在这里,我们调用之前定义的load_dataset函数,该函数将进行预处理和特征化,然后将数据集分割为训练和验证数据集¹。最后,我们希望将这两个数据集都转换为数据集流水线,以提高效率,并确保在每个时期将训练数据集在所有工作节点之间全局洗牌。

示例 7-25。
def get_training_datasets(*, test_pct=0.8):
    ds = load_dataset("nyc_tlc_data/yellow_tripdata_2020-01.csv")
    ds, _ = ds.split_at_indices([int(0.01 * ds.count())])
    train_ds, test_ds = ds.split_at_indices([int(test_pct * ds.count())])
    train_ds_pipeline = train_ds.repeat().random_shuffle_each_window()
    test_ds_pipeline = test_ds.repeat()
    return {"train": train_ds_pipeline, "validation": test_ds_pipeline}

一切准备就绪,现在是运行我们的分布式训练过程的时候了!剩下的就是创建一个 Trainer,传入我们的 Datasets,并让训练在配置的工作节点上扩展和运行。训练完成后,我们使用检查点 API 获取最新的模型权重。

示例 7-26。
trainer = train.Trainer("torch", num_workers=4)
config = {"lr": 1e-2, "epochs": 3, "batch_size": 64}
trainer.start()
trainer.run(train_func, config, dataset=get_training_datasets())
model_weights = trainer.latest_checkpoint.get("model_weights")
trainer.shutdown()

使用 Ray 数据集进行分布式批量推理

一旦我们训练了一个模型并获得了最佳准确性,下一步就是实际应用它。有时这意味着提供低延迟服务,我们将在第八章中探讨这一点,但通常任务是在数据批次到达时跨批次应用模型。

让我们使用上述训练过程中的训练模型权重,并将其应用于新的数据批次(在本例中,只是同一公共数据集的另一块)。为此,首先需要以与训练相同的方式加载、预处理和特征化数据。然后我们将加载我们的模型,并在整个数据集上进行map操作。如上节所述,Datasets 允许我们通过 Ray Actors 高效地执行此操作,甚至只需通过更改一个参数即可使用 GPU。我们只需将训练好的模型权重封装在一个类中,该类将加载并配置一个推理模型,然后调用map_batches并传入推理模型类:

示例 7-27。
class InferenceWrapper:
    def __init__(self):
        self._model = FarePredictor()
        self._model.load_state_dict(model_weights)
        self._model.eval()

    def __call__(self, df):
        tensor = torch.as_tensor(df.to_numpy(), dtype=torch.float32)
        with torch.no_grad():
            predictions = torch.round(self._model(tensor))
        df[LABEL_COLUMN] = predictions.numpy()
        return df

ds = load_dataset("nyc_tlc_data/yellow_tripdata_2021-01.csv", include_label=False)
ds.map_batches(InferenceWrapper, compute="actors").write_csv("output")

¹ 代码仅加载数据的一个子集进行测试,若要进行规模化测试,请在调用load_dataset时使用所有数据分区,并在训练模型时增加num_workers

第八章:使用 Ray Serve 进行在线推断

Edward Oakes

在线推断

在之前的章节中,您已经学习了如何使用 Ray 处理数据、训练机器学习(ML)模型,并在批量推断设置中应用它们。然而,许多最令人兴奋的机器学习用例涉及“在线推断”:即使用 ML 模型增强用户直接或间接交互的 API 端点。在线推断在延迟至关重要的情况下非常重要:您不能简单地将模型应用于后台数据并提供结果。有许多现实世界的用例示例,其中在线推断可以提供很大的价值:

推荐系统:为产品(例如在线购物)或内容(例如社交媒体)提供推荐是机器学习的基础用例。虽然可以以离线方式执行此操作,但推荐系统通常受益于实时反应用户偏好。这要求使用最近的行为作为关键特征进行在线推断。

聊天机器人:在线服务通常具有实时聊天窗口,以便从键盘舒适地为客户提供支持。传统上,这些聊天窗口由客户支持人员负责,但最近的趋势是通过使用全天候在线的 ML 动力聊天机器人来减少劳动成本并改善解决时间。这些聊天机器人需要多种机器学习技术的复杂混合,并且必须能够实时响应客户输入。

估计到达时间:乘车共享、导航和食品配送服务都依赖于能够提供准确的到达时间估计(例如,为您的司机、您自己或您的晚餐)。提供准确的估算非常困难,因为它需要考虑交通模式、天气和事故等真实世界因素。估计通常在一次行程中多次刷新。

这些只是一些例子,应用机器学习以在线方式在传统上非常困难的应用领域中提供大量价值。应用领域的应用清单仍在继续扩展:一些新兴领域如自动驾驶汽车、机器人技术、视频处理管道也正在被机器学习重新定义。所有这些应用都共享一个关键特征:延迟至关重要。在在线服务的情况下,低延迟对提供良好的用户体验至关重要,对于涉及到实际世界的应用,如机器人技术或自动驾驶汽车,更高的延迟可能会在安全性或正确性方面产生更强的影响。

本章将介绍 Ray Serve,这是一个基于 Ray 的本地库,可以在 Ray 之上构建在线推理应用程序。首先,我们将讨论 Ray Serve 所解决的在线推理挑战。然后,我们将介绍 Ray Serve 的架构并介绍其核心功能集。最后,我们将使用 Ray Serve 构建一个由多个自然语言处理模型组成的端到端在线推理 API。

在线推理的挑战

在前一节中,我们讨论了在线推理的主要目标是与低延迟交互的机器学习模型。然而,这长期以来一直是 API 后端和 Web 服务器的关键要求,因此一个自然的问题是:在为机器学习模型提供服务方面有何不同?

1. 机器学习模型具有计算密集性

许多在线推理的挑战都源于一个关键特征:机器学习模型非常计算密集。与传统的 Web 服务不同,传统的 Web 服务主要处理 I/O 密集型数据库查询或其他 API 调用,大多数机器学习模型归结为执行许多线性代数计算,无论是提供推荐、估计到达时间还是检测图像中的对象。对于最近的“深度学习”趋势尤其如此,单个模型执行的权重和计算数量随时间增长而增加。深度学习模型通常也可以从使用专用硬件(如 GPU 或 TPU)中获得显著好处,这些硬件具有为机器学习计算优化的专用指令,并且可以跨多个输入并行进行矢量化计算。

许多在线推理应用程序通常需要 24/7 运行。与机器学习模型计算密集性结合在一起时,运行在线推理服务可能非常昂贵,需要始终分配大量的 CPU 和 GPU。在线推理的主要挑战在于以最小化端到端延迟和降低成本的方式提供模型服务。在线推理系统提供了一些关键特性以满足这些要求:- 支持诸如 GPU 和 TPU 之类的专用硬件。- 能够根据请求负载调整模型使用的资源的能力。- 支持请求批处理以利用矢量化计算。

2. 机器学习模型在孤立状态下并不实用

当讨论机器学习时,通常在学术或研究环境中,重点放在个体、孤立的任务上,比如对象识别或分类。然而,在真实世界的应用中,问题通常不是那么明确和定义明确的。相反,解决问题端到端需要结合多个机器学习模型和业务逻辑。例如,考虑一个产品推荐的使用案例。虽然我们可以应用多种已知的机器学习技术来解决推荐的核心问题,但在边缘处也存在许多同样重要的挑战,其中许多挑战对每个使用案例可能是特定的:

实施在线推理 API 需要能够将所有这些部分整合到一个统一的服务中。因此,有能力将多个模型与自定义业务逻辑组合在一起非常重要。这些部分实际上不能完全孤立地看待: "胶水" 逻辑通常需要随着模型本身的演变而演变。

介绍 Ray Serve

Ray Serve 是建立在 Ray 之上的可扩展计算层,用于为机器学习模型提供服务。Serve 是框架无关的,这意味着它不依赖于特定的机器学习库,而是将模型视为普通的 Python 代码。此外,它允许您灵活地将普通的 Python 业务逻辑与机器学习模型结合在一起。这使得完全端到端地构建在线推理服务成为可能:Serve 应用程序可以验证用户输入,查询数据库,在多个机器学习模型之间可扩展地执行推理,并在处理单个推理请求的过程中组合、过滤和验证输出。事实上,结合多个机器学习模型的结果是 Ray Serve 的关键优势之一,正如我们在后面章节中探讨的常见多模型服务模式所示。

Serve 虽然在性质上灵活,但为计算密集型的机器学习模型提供了专门构建的功能,可以实现动态扩展和资源分配,以确保可以有效地处理跨多个 CPU 和/或 GPU 的请求负载。在这里,Serve 从 Ray 构建中继承了许多好处:它可扩展到数百台机器,提供灵活的调度策略,并使用 Ray 的核心 API 在进程间提供低开销的通信。

本节将逐步介绍 Ray Serve 的核心功能,重点是它如何帮助解决上述在线推理的挑战。要跟随本节中的代码示例,您需要在本地安装以下 Python 包:

pip install ray["serve"]==1.12.0 transformers requests

运行示例假设你在当前工作目录中已经保存了名为app.py的文件。

架构概述

Ray Serve 建立在 Ray 之上,因此它继承了许多优点,比如可扩展性、低开销的通信、适合并行的 API,以及通过对象存储利用共享内存的能力。Ray Serve 中的核心原语是部署,你可以把它看作是一组受管理的 Ray actor,可以一起使用并处理通过负载平衡分布在它们之间的请求。部署中的每个 actor 称为 Ray Serve 中的副本。通常,一个部署将一对一地映射到一个机器学习模型,但部署可以包含任意的 Python 代码,因此它们也可以包含业务逻辑。

Ray Serve 使部署可以通过 HTTP 公开,并定义输入解析和输出逻辑。但 Ray Serve 最重要的功能之一是,部署也可以直接使用本机 Python API 相互调用,这将转换为副本之间的直接 actor 调用。这使得模型和业务逻辑的灵活高性能组合成为可能;您将在本节后面看到这一点的实际应用。

在幕后,组成 Ray Serve 应用程序的部署由一个集中的控制器actor 管理。这是一个由 Ray 管理的独立 actor,将在失败时重新启动。控制器负责创建和更新副本 actor,向系统中的其他 actor 广播更新,并执行健康检查和故障恢复。如果由于任何原因副本或整个 Ray 节点崩溃,控制器将检测到失败,并确保 actor 被恢复并可以继续提供服务。

定义基本的 HTTP 端点

本节将通过定义一个简单的 HTTP 端点来介绍 Ray Serve。我们将部署的模型是一个情感分类器:给定一个文本输入,它将预测输出是否具有积极或消极的情感。我们将使用Hugging Face transformers库中的预训练情感分类器,该库提供了一个简单的 Python API,用于预训练模型,它将隐藏模型的细节,使我们可以专注于服务逻辑。要使用 Ray Serve 部署此模型,我们需要定义一个 Python 类,并使用@serve.deployment装饰器将其转换为 Serve 的部署。该装饰器允许我们传递多个有用的选项来配置部署;我们将在本章后面探讨其中的一些选项。

示例 8-1。
from ray import serve

from starlette.requests import Request
from transformers import pipeline

@serve.deployment
class SentimentAnalysis:
    def __init__(self):
        self._classifier = pipeline("sentiment-analysis")

    def __call__(self, request: Request) -> str:
        input_text = request.query_params["input_text"]
        return self._classifier(input_text)[0]["label"]

这里有几个重要的要点需要注意。首先,在类的构造函数中实例化我们的模型。这个模型可能非常庞大,因此下载和加载到内存中可能会很慢(可能需要 10 秒或更长时间)。在 Ray Serve 中,构造函数中的代码将仅在每个副本在启动时运行一次,并且任何属性都可以被缓存以供将来使用。其次,我们在 __call__ 方法中定义处理请求的逻辑。这个方法以 Starlette 的 HTTP 请求作为输入,并可以返回任何可 JSON 序列化的输出。在本例中,我们将从模型的输出中返回一个字符串:"POSITIVE" 或 "NEGATIVE"。

一旦一个部署被定义,我们使用 .bind() API 来实例化它的一个副本。这是我们可以传递给构造函数的可选参数来配置部署(例如远程路径以下载模型权重)。请注意,这实际上并没有运行部署,而只是将其与其参数打包在一起(当我们将多个模型组合在一起时,这将变得更加重要)。

示例 8-2.
basic_deployment = SentimentAnalysis.bind()

我们可以使用 serve.run Python API 或相应的 serve run CLI 命令来运行绑定的部署。假设你将以上代码保存在一个名为 app.py 的文件中,你可以用以下命令在本地运行它:

serve run app:basic_deployment

这将实例化我们部署的单个副本,并将其托管在本地 HTTP 服务器后面。要测试它,我们可以使用 Python 的 requests 包:

示例 8-3.
import requests
print(requests.get("http://localhost:8000/", params={"input_text": "Hello friend!"}).json())

在样本输入文本 "Hello friend!" 上测试情感分类器,它正确地将文本分类为正面!

这个示例实际上是 Ray Serve 的 "hello world":我们在一个基本的 HTTP 端点后部署了一个单一模型。然而,请注意,我们不得不手动解析输入的 HTTP 请求并将其馈送到我们的模型中。对于这个基本示例来说,这只是一行代码,但现实世界的应用程序通常需要更复杂的输入模式,并且手动编写 HTTP 逻辑可能会很繁琐且容易出错。为了能够编写更具表现力的 HTTP API,Serve 与 FastAPI Python 框架集成。

Serve 部署可以包装一个 FastAPI 应用程序,利用其表达性的 API 来解析输入并配置 HTTP 行为。在下面的示例中,我们依赖于 FastAPI 来处理 input_text 查询参数,从而允许我们删除样板解析代码。

示例 8-4.
from fastapi import FastAPI

app = FastAPI()

@serve.deployment
@serve.ingress(app)
class SentimentAnalysis:
    def __init__(self):
        self._classifier = pipeline("sentiment-analysis")

    @app.get("/")
    def classify(self, input_text: str) -> str:
        return self._classifier(input_text)[0]["label"]

fastapi_deployment = SentimentAnalysis.bind()

修改后的部署应该在上述示例中表现完全相同(试着使用 serve run 运行它!),但会优雅地处理无效输入。对于这个简单的示例来说,这些可能看起来像是小小的好处,但对于更复杂的 API 来说,这可能产生天壤之别。我们在这里不会深入探讨 FastAPI 的细节,但如果想了解其功能和语法的更多细节,请查看他们出色的文档

扩展和资源分配

如上所述,机器学习模型通常需要大量计算资源。因此,重要的是能够为您的 ML 应用程序分配正确数量的资源,以处理请求负载并最大程度地减少成本。Ray Serve 允许您通过调整部署的资源以两种方式来调整:通过调整部署的副本数和调整分配给每个副本的资源。默认情况下,部署由一个使用单个 CPU 的副本组成,但这些参数可以在@serve.deployment装饰器中(或使用相应的deployment.options API)进行调整。

让我们修改上面的SentimentClassifier示例,以扩展到多个副本,并调整资源分配,使每个副本使用 2 个 CPU 而不是 1 个(在实践中,您将希望分析和了解您的模型以正确设置此参数)。我们还将添加一个打印语句来记录处理每个请求的进程 ID,以显示请求现在跨两个副本进行负载平衡。

示例 8-5.
app = FastAPI()

@serve.deployment(num_replicas=2, ray_actor_options={"num_cpus": 2})
@serve.ingress(app)
class SentimentAnalysis:
    def __init__(self):
        self._classifier = pipeline("sentiment-analysis")

    @app.get("/")
    def classify(self, input_text: str) -> str:
        import os
        print("from process:", os.getpid())
        return self._classifier(input_text)[0]["label"]

scaled_deployment = SentimentAnalysis.bind()

运行我们分类器的新版本时,使用serve run app:scaled_deployment,并像上面一样使用requests进行查询,您会看到现在有两个模型副本处理请求!我们可以通过简单调整num_replicas来轻松地扩展到数十个或数百个副本:Ray 可以在单个集群中扩展到数百台机器和数千个进程。

在这个例子中,我们扩展到了一个静态副本数量,每个副本使用两个完整的 CPU,但 Serve 还支持更具表现力的资源分配策略: - 启用部署使用 GPU 只需设置num_gpus而不是num_cpus。Serve 支持与 Ray 核心相同的资源类型,因此部署也可以使用 TPU 或其他自定义资源。 - 资源可以是分数的,允许副本被高效地装箱。例如,如果单个副本不饱和一个完整的 GPU,则可以为其分配num_gpus=0.5,并与另一个模型进行复用。 - 对于请求负载变化的应用程序,可以配置部署根据当前正在进行的请求数量动态调整副本的数量。

有关资源分配选项的更多详细信息,请参阅最新的 Ray Serve 文档。

请求批处理

许多机器学习模型可以被有效地向量化,这意味着可以更有效地并行运行多个计算,而不是顺序运行它们。当在 GPU 上运行模型时,这尤其有益,因为 GPU 专为高效地并行执行许多计算而构建。在在线推断的背景下,这为优化提供了一条路径:并行服务多个请求(可能来自不同的来源)可以显着提高系统的吞吐量(从而节省成本)。

利用请求批处理的两种高级策略:客户端批处理和服务器端批处理。在客户端批处理中,服务器接受单个请求中的多个输入,并且客户端包含逻辑以批量发送而不是逐个发送。这在单个客户端频繁发送多个推理请求的情况下非常有用。相比之下,服务器端批处理使服务器能够批处理多个请求,而无需客户端进行任何修改。这也可用于跨多个客户端批处理请求,这使得即使在每个客户端每次发送相对较少请求的情况下,也能实现高效批处理。

Ray Serve 提供了一个内置的实用程序用于服务器端批处理,即@serve.batch装饰器,只需进行少量代码更改即可。此批处理支持使用 Python 的asyncio能力将多个请求排队到单个函数调用中。该函数应接受一个输入列表并返回相应的输出列表。

再次回顾之前的情感分类器,并将其修改为执行服务器端批处理。底层的 Hugging Face pipeline 支持矢量化推理,我们只需传递一个输入文本列表,它将返回相应的输出列表。我们将调用分类器的部分拆分为一个新方法classify_batched,它将接受一个输入文本列表作为输入,在它们之间执行推理,并以格式化列表返回输出。classify_batched将使用@serve.batch装饰器自动执行批处理。可以使用max_batch_sizebatch_timeout_wait_s参数配置行为,这里我们将最大批处理大小设置为 10,并等待最多 100 毫秒。

示例 8-6。
from typing import List

app = FastAPI()

@serve.deployment
@serve.ingress(app)
class SentimentAnalysis:
    def __init__(self):
        self._classifier = pipeline("sentiment-analysis")

    @serve.batch(max_batch_size=10, batch_wait_timeout_s=0.1)
    async def classify_batched(self, batched_inputs: List[str]) -> List[str]:
        print("Got batch size:", len(batched_inputs))
        results = self._classifier(batched_inputs)
        return [result["label"] for result in results]

    @app.get("/")
    async def classify(self, input_text: str) -> str:
        return await self.classify_batched(input_text)

batched_deployment = SentimentAnalysis.bind()

请注意,现在classifyclassify_batched方法都使用了 Python 的asyncawait语法,这意味着在同一个进程中可以并发运行许多这些调用。为了测试这种行为,我们将使用serve.run Python API 使用本地 Python 句柄发送请求到我们的部署中。

from app import batched_deployment

# Get a handle to the deployment so we can send requests in parallel.
handle = serve.run(batched_deployment)
ray.get([handle.classify.remote("sample text") for _ in range(10)])

通过serve.run返回的句柄可用于并行发送多个请求:在这里,我们并行发送 10 个请求并等待它们全部返回。如果没有批处理,每个请求将按顺序处理,但因为我们启用了批处理,我们应该看到所有请求一次处理(在classify_batched方法中打印的批处理大小证明了这一点)。在 CPU 上运行时,这可能比顺序运行稍快,但在 GPU 上运行相同处理程序时,批处理版本将显著加快速度。

多模型推理图表。

到目前为止,我们一直在部署和查询一个 Serve 部署,包装一个 ML 模型。如前所述,单独使用机器学习模型通常是不够有用的:许多应用程序需要将多个模型组合在一起,并将业务逻辑与机器学习交织在一起。Ray Serve 的真正强大之处在于它能够将多个模型与常规 Python 逻辑组合成一个单一的应用程序。通过实例化许多不同的部署,并在它们之间传递引用,这是可能的。这些部署可以使用我们到目前为止讨论过的所有功能:它们可以独立缩放,执行请求批处理,并使用灵活的资源分配。

本节提供了常见的多模型服务模式的示例,但实际上不包含任何 ML 模型,以便专注于 Serve 提供的核心功能。稍后在本章中,我们将探讨一个端到端的多模型推理图,其中包含 ML 模型。

核心功能:绑定多个部署

Ray Serve 中的所有类型的多模型推理图都围绕着将一个部署的引用传递给另一个的能力。为了做到这一点,我们使用 .bind() API 的另一个特性:一个绑定的部署可以传递给另一个 .bind() 调用,并且这将在运行时解析为对部署的“句柄”。这使得部署可以独立部署和实例化,然后在运行时互相调用。以下是一个多部署 Serve 应用程序的最基本示例。

示例 8-7。
@serve.deployment
class DownstreamModel:
    def __call__(self, inp: str):
        return "Hi from downstream model!"

@serve.deployment
class Driver:
    def __init__(self, downstream):
        self._d = downstream

    async def __call__(self, *args) -> str:
        return await self._d.remote()

downstream = DownstreamModel.bind()
driver = Driver.bind(downstream)

在这个例子中,下游模型被传递到“驱动器”部署中。然后在运行时,驱动器部署调用下游模型。驱动器可以接收任意数量的传入模型,并且下游模型甚至可以接收其自己的其他下游模型。

模式 1: 管道化

机器学习应用程序中的第一个常见多模型模式是“管道化”:依次调用多个模型,其中一个模型的输入取决于前一个模型的输出。例如,图像处理通常由多个转换阶段的管道组成,如裁剪、分割、物体识别或光学字符识别(OCR)。每个模型可能具有不同的属性,其中一些是可以在 CPU 上运行的轻量级转换,而其他一些是在 GPU 上运行的重型深度学习模型。

这样的管道可以很容易地使用 Serve 的 API 表达。管道的每个阶段被定义为独立的部署,并且每个部署被传递到一个顶层的“管道驱动器”中。在下面的例子中,我们将两个部署传递到一个顶层驱动器中,驱动器按顺序调用它们。请注意,可能会有许多请求同时发送到驱动器,因此可以有效地饱和管道的所有阶段。

示例 8-8。
@serve.deployment
class DownstreamModel:
    def __init__(self, my_val: str):
        self._my_val = my_val

    def __call__(self, inp: str):
        return inp + "|" + self._my_val

@serve.deployment
class PipelineDriver:
    def __init__(self, model1, model2):
        self._m1 = model1
        self._m2 = model2

    async def __call__(self, *args) -> str:
        intermediate = self._m1.remote("input")
        final = self._m2.remote(intermediate)
        return await final

m1 = DownstreamModel.bind("val1")
m2 = DownstreamModel.bind("val2")
pipeline_driver = PipelineDriver.bind(m1, m2)

要测试此示例,可以再次使用serve run API。向管道发送测试请求将"'input|val1|val2'"作为输出返回:每个下游“模型”都添加了自己的值来构建最终结果。在实践中,每个部署可能会封装自己的 ML 模型,单个请求可能会在集群中的多个物理节点之间流动。

模式 2:广播

除了顺序链接模型之外,同时对多个模型进行推断通常也很有用。这可以是为了执行“集成学习”,即将多个独立模型的结果合并为一个结果,或者在不同输入上不同模型表现更好的情况下。通常需要将模型的结果以某种方式结合成最终结果:简单地连接在一起或者可能从中选择一个单一结果。

这与流水线示例非常相似:许多下游模型传递到顶级“驱动程序”。在这种情况下,重要的是我们并行调用模型:在调用下一个模型之前等待每个模型的结果将显著增加系统的总体延迟。

示例 8-9.
@serve.deployment
class DownstreamModel:
    def __init__(self, my_val: str):
        self._my_val = my_val

    def __call__(self):
        return self._my_val

@serve.deployment
class BroadcastDriver:
    def __init__(self, model1, model2):
        self._m1 = model1
        self._m2 = model2

    async def __call__(self, *args) -> str:
        output1, output2 = self._m1.remote(), self._m2.remote()
        return [await output1, await output2]

m1 = DownstreamModel.bind("val1")
m2 = DownstreamModel.bind("val2")
broadcast_driver = BroadcastDriver.bind(m1, m2)

测试此端点在再次运行serve run后返回'["val1", "val2"]',这是并行调用两个模型的组合输出。

模式 3:条件逻辑

最后,尽管许多机器学习应用程序大致符合上述模式之一,但静态控制流往往会非常限制。例如,构建一个从用户上传的图像中提取车牌号码的服务。在这种情况下,我们可能需要构建如上讨论的图像处理流水线,但我们也不只是盲目地将任何图像输入到流水线中。如果用户上传的是除了汽车或质量低下的图像,我们可能希望进行短路,避免调用重量级和昂贵的流水线,并提供有用的错误消息。类似地,在产品推荐用例中,我们可能希望根据用户输入或中间模型的结果选择下游模型。每个示例都需要在我们的 ML 模型旁嵌入自定义逻辑。

我们可以轻松地使用 Serve 的多模型 API 来实现这一点,因为我们的计算图是作为普通 Python 逻辑而不是作为静态定义的图形来定义的。例如,在下面的示例中,我们使用一个简单的随机数生成器(RNG)来决定调用哪个下游模型。在实际示例中,RNG 可以替换为业务逻辑、数据库查询或中间模型的结果。

示例 8-10.
@serve.deployment
class DownstreamModel:
    def __init__(self, my_val: str):
        self._my_val = my_val

    def __call__(self):
        return self._my_val

@serve.deployment
class ConditionalDriver:
    def __init__(self, model1, model2):
        self._m1 = model1
        self._m2 = model2

    async def __call__(self, *args) -> str:
        import random
        if random.random() > 0.5:
            return await self._m1.remote()
        else:
            return await self._m2.remote()

m1 = DownstreamModel.bind("val1")
m2 = DownstreamModel.bind("val2")
conditional_driver = ConditionalDriver.bind(m1, m2)

对此端点的每次调用都以 50/50 的概率返回"val1""val2"

在 Kubernetes 上部署

TODO:Ray Serve 的 Kubernetes 部署故事目前正在重新制定,完成后将更新本节内容。

端到端示例:构建基于 NLP 的 API。

在这一部分,我们将使用 Ray Serve 构建一个端到端的自然语言处理(NLP)流水线,用于在线推断。我们的目标是提供一个维基百科摘要端点,利用多个 NLP 模型和一些自定义逻辑来提供给定搜索项最相关维基百科页面的简明摘要。

此任务将汇集上述讨论的许多概念和特性:

我们的在线推断流水线将被结构化如下:

这个流水线将通过 HTTP 公开,并以结构化格式返回结果。通过本节结束时,我们将在本地运行端到端流水线,并准备在集群上进行扩展。让我们开始吧!

步骤 0:安装依赖项。

在我们深入代码之前,您需要在本地安装以下 Python 包,以便跟随操作。

pip install ray["serve"]==1.12.0 transformers requests wikipedia

另外,在本节中,我们假设所有代码示例都在名为app.py的文件中本地可用,以便我们可以从相同目录使用serve run运行部署。

步骤 1:获取内容与预处理。

第一步是根据用户提供的搜索项从维基百科获取最相关的页面。为此,我们将利用 PyPI 上的wikipedia包来进行繁重的工作。我们首先搜索该术语,然后选择顶部结果并返回其页面内容。如果找不到结果,我们将返回None,这种边缘情况将在我们定义 API 时处理。

示例 8-11。
from typing import Optional

import wikipedia

def fetch_wikipedia_page(search_term: str) -> Optional[str]:
    results = wikipedia.search(search_term)
    # If no results, return to caller.
    if len(results) == 0:
        return None

    # Get the page for the top result.
    return wikipedia.page(results[0]).content

步骤 2:NLP 模型。

接下来,我们需要定义将在 API 中负责重型工作的 ML 模型。与介绍部分一样,我们将使用Hugging Facetransformers库,因为它提供了预训练的最先进 ML 模型的便捷 API,这样我们可以专注于服务逻辑。

我们将使用的第一个模型是情感分类器,与我们在上面示例中使用的相同。此模型的部署将利用 Serve 的批处理 API 进行向量化计算。

示例 8-12.
from transformers import pipeline

@serve.deployment
class SentimentAnalysis:
    def __init__(self):
        self._classifier = pipeline("sentiment-analysis")

    @serve.batch(max_batch_size=10, batch_wait_timeout_s=0.1)
    async def is_positive_batched(self, inputs: List[str]) -> List[bool]:
        results = self._classifier(inputs, truncation=True)
        return [result["label"] == "POSITIVE" for result in results]

    async def __call__(self, input_text: str) -> bool:
        return await self.is_positive_batched(input_text)

我们还将使用文本摘要模型为所选文章提供简洁的摘要。此模型接受一个可选的“max_length”参数来限制摘要的长度。因为我们知道这是模型中计算成本最高的,所以我们设置 num_replicas=2 — 这样,如果我们同时有多个请求进来,它可以跟上其他模型的吞吐量。实际上,我们可能需要更多的副本来跟上输入负载,但这只能通过分析和监控来确定。

示例 8-13.
@serve.deployment(num_replicas=2)
class Summarizer:
    def __init__(self, max_length: Optional[int] = None):
        self._summarizer = pipeline("summarization")
        self._max_length = max_length

    def __call__(self, input_text: str) -> str:
        result = self._summarizer(
            input_text, max_length=self._max_length, truncation=True)
        return result[0]["summary_text"]

我们管道中的最终模型将是一个命名实体识别模型:它将尝试从文本中提取命名实体。每个结果都有一个置信度分数,因此我们可以设置一个阈值,只接受超过某个阈值的结果。我们还可能希望限制返回的实体总数。该部署的请求处理程序调用模型,然后使用一些基本的业务逻辑来执行所提供的置信度阈值和实体数量的限制。

示例 8-14.
@serve.deployment
class EntityRecognition:
    def __init__(self, threshold: float = 0.90, max_entities: int = 10):
        self._entity_recognition = pipeline("ner")
        self._threshold = threshold
        self._max_entities = max_entities

    def __call__(self, input_text: str) -> List[str]:
        final_results = []
        for result in self._entity_recognition(input_text):
            if result["score"] > self._threshold:
                final_results.append(result["word"])
            if len(final_results) == self._max_entities:
                break

        return final_results

第三步:HTTP 处理和驱动逻辑

随着输入预处理和 ML 模型的定义,我们准备定义 HTTP API 和驱动逻辑。首先,我们使用 Pydantic 定义了从 API 返回的响应模式的架构。响应包括请求是否成功以及状态消息,另外还有我们的摘要和命名实体。这将允许我们在错误条件下返回有用的响应,例如当未找到结果或情感分析结果为负面时。

示例 8-15.
from pydantic import BaseModel

class Response(BaseModel):
    success: bool
    message: str = ""
    summary: str = ""
    named_entities: List[str] = []

接下来,我们需要定义实际的控制流逻辑,这些逻辑将在驱动程序部署中运行。驱动程序本身不会执行任何实际的重型工作,而是调用我们的三个下游模型部署并解释它们的结果。它还将承载 FastAPI 应用程序定义,解析输入并根据管道的结果返回正确的Response模型。

示例 8-16.
from fastapi import FastAPI

app = FastAPI()

@serve.deployment
@serve.ingress(app)
class NLPPipelineDriver:
    def __init__(self, sentiment_analysis, summarizer, entity_recognition):
        self._sentiment_analysis = sentiment_analysis
        self._summarizer = summarizer
        self._entity_recognition = entity_recognition

    @app.get("/", response_model=Response)
    async def summarize_article(self, search_term: str) -> Response:
        # Fetch the top page content for the search term if found.
        page_content = fetch_wikipedia_page(search_term)
        if page_content is None:
            return Response(success=False, message="No pages found.")

        # Conditionally continue based on the sentiment analysis.
        is_positive = await self._sentiment_analysis.remote(page_content)
        if not is_positive:
            return Response(success=False, message="Only positivitiy allowed!")

        # Query the summarizer and named entity recognition models in parallel.
        summary_result = self._summarizer.remote(page_content)
        entities_result = self._entity_recognition.remote(page_content)
        return Response(
            success=True,
            summary=await summary_result,
            named_entities=await entities_result
        )

在主处理程序的主体中,我们首先使用我们的 fetch_wikipedia_page 逻辑获取页面内容(如果未找到结果,则返回错误)。然后,我们调用情感分析模型。如果返回负面结果,我们提前终止并返回错误,以避免调用其他昂贵的 ML 模型。最后,我们并行广播文章内容到摘要和命名实体识别模型。两个模型的结果被拼接到最终响应中,我们返回成功。请记住,我们可能有许多对此处理程序的调用同时运行:对下游模型的调用不会阻塞驱动程序,并且它可以协调对重型模型的多个副本的调用。

第四步:将所有内容整合在一起

到这一步,所有核心逻辑都已经定义好了。现在剩下的就是将部署的图形绑定在一起并运行它!

示例 8-17.
sentiment_analysis = SentimentAnalysis.bind()
summarizer = Summarizer.bind()
entity_recognition = EntityRecognition.bind(threshold=0.95, max_entities=5)
nlp_pipeline_driver = NLPPipelineDriver.bind(
    sentiment_analysis, summarizer, entity_recognition)
# end::final_driver[]

首先,我们需要为每个部署实例化,使用任何相关的输入参数。例如,在这里,我们为实体识别模型传递了阈值和限制。最重要的部分是我们将三个模型的引用传递给驱动程序,以便它可以协调计算。现在我们已经定义了完整的自然语言处理流水线,我们可以使用 serve run 来运行它:

serve run ch_08_model_serving:nlp_pipeline_driver

这将在本地部署每个四个部署,并使驱动程序在 http://localhost:8000/ 上可用。我们可以使用 requests 查询流水线,看看它如何工作。首先,让我们尝试查询 Ray Serve 上的一个条目。

示例 8-18.
print(requests.get("http://localhost:8000/", params={"search_term": "rayserve"}).text)
'{"success":false,"message":"No pages found.","summary":"","named_entities":[]}'

不幸的是,这个页面还不存在!验证业务逻辑的第一块代码生效并返回了“未找到页面”的消息。让我们尝试寻找一些更常见的内容:

示例 8-19.
print(requests.get("http://localhost:8000/", params={"search_term": "war"}).text)
'{"success":false,"message":"Only positivitiy allowed!","summary":"","named_entities":[]}'

也许我们只是对了解历史感兴趣,但是这篇文章对我们的情感分类器来说有点太消极了。这次让我们试试更中立一点的东西——科学怎么样?

示例 8-20.
print(requests.get("http://localhost:8000/", params={"search_term": "physicist"}).text)
'{"success":true,"message":"","summary":" Physics is the natural science that studies matter, its fundamental constituents, its motion and behavior through space and time, and the related entities of energy and force . During the Scientific Revolution in the 17th century these natural sciences emerged as unique research endeavors in their own right . Physics intersects with many interdisciplinary areas of research, such as biophysics and quantum chemistry .","named_entities":["Scientific","Revolution","Ancient","Greek","Egyptians"]}'

这个示例成功地通过了完整的流水线:API 返回了文章的简明摘要和相关命名实体的列表。

总结一下,在这一部分,我们能够使用 Ray Serve 构建一个在线自然语言处理 API。这个推理图包含了多个机器学习模型以及自定义业务逻辑和动态控制流。每个模型可以独立扩展并拥有自己的资源分配,我们可以利用服务器端批处理来进行向量化计算。现在我们已经能够在本地测试 API,下一步将是部署到生产环境。Ray Serve 通过使用 Ray 集群启动器,可以轻松部署到 Kubernetes 或其他云提供商提供的服务,并且通过调整部署的资源分配,可以轻松扩展以处理多个用户。

摘要

关于作者

马克斯·普姆佩拉 是一位数据科学教授和软件工程师,位于德国汉堡。他是一位活跃的开源贡献者,维护了几个 Python 包,撰写了机器学习书籍,并在国际会议上发表演讲。作为 Pathmind 公司产品研究负责人,他正在使用 Ray 开发规模化的工业应用强化学习解决方案。Pathmind 与 AnyScale 团队密切合作,并且是 Ray 的 RLlib、Tune 和 Serve 库的高级用户。马克斯曾是 Skymind 公司的 DL4J 核心开发者,帮助扩展和发展 Keras 生态系统,并担任 Hyperopt 维护者。

爱德华·奥克斯

理查德·李奥

posted @ 2024-06-17 19:07  绝不原创的飞龙  阅读(36)  评论(0编辑  收藏  举报