Python 机器学习工程第二版(三)
原文:
annas-archive.org/md5/12b0185c4bf68c0fcb37173533d7088b译者:飞龙
第六章:扩展
上一章全部关于开始讨论我们如何使用不同的部署模式将我们的解决方案推向世界,以及我们可以使用的某些工具。本章将在此基础上进行讨论,讨论我们可以使用的概念和工具,以扩展我们的解决方案以应对大量数据或流量。
在您的笔记本电脑上运行一些简单的机器学习(ML)模型,在几千个数据点上是一个很好的练习,尤其是在您执行我们在任何机器学习开发项目开始时概述的发现和概念验证步骤时。然而,如果我们必须以相对较高的频率运行数百万个数据点,或者如果我们必须同时训练数千个类似规模的模型,这种方法就不合适了。这需要不同的方法、心态和工具集。
在以下页面中,我们将介绍目前使用最广泛的两个用于分布式数据计算的框架的详细信息:Apache Spark和Ray。特别是,我们将讨论这些框架在底层的一些关键点,以便在开发过程中,我们可以就如何使用它们做出一些好的决策。然后,我们将讨论如何使用这些框架在您的机器学习工作流程中,并提供一些具体的示例,这些示例专门旨在帮助您在处理大量数据时。接下来,将提供一个关于创建允许您扩展推理端点的无服务器应用的简要介绍。最后,我们将介绍如何使用 Kubernetes 扩展容器化的机器学习应用,这补充了我们在第五章,部署模式和工具中完成的工作,并在第八章,构建示例 ML 微服务中详细展开。
这将帮助您在我们之前在这本书中已经查看的一些实际示例的基础上进行构建,当时我们使用 Spark 来解决我们的机器学习问题,并增加一些更具体的理论理解和更详细的实际示例。在本章之后,您应该对如何使用一些最好的框架和技术来扩展您的机器学习解决方案以适应更大的数据集感到自信。
在本章中,我们将在以下部分中涵盖所有这些内容:
-
使用 Spark 进行扩展
-
启动无服务器基础设施
-
使用 Kubernetes 进行大规模容器化
-
使用 Ray 进行扩展
-
设计大规模系统
技术要求
与其他章节一样,您可以通过使用提供的 Conda 环境yml文件或从书库中的requirements.txt文件来设置您的 Python 开发环境,以便能够运行本章中的示例,在第六章下:
conda env create –f mlewp-chapter06.yml
本章的示例还需要安装一些非 Python 工具,以便从头到尾遵循示例;请参阅每个工具的相关文档:
-
AWS CLI v2
-
Docker
-
Postman
-
Ray
-
Apache Spark(版本 3.0.0 或更高)
使用 Spark 进行扩展
Apache Spark,或简称 Spark,起源于 2012 年加州大学伯克利分校一些杰出研究人员的工作,自那时起,它彻底改变了我们处理大数据集问题的方法。
Spark 是一个集群计算框架,这意味着它基于几个计算机以允许计算任务共享的方式相互连接的原则。这使我们能够有效地协调这些任务。每次我们讨论运行 Spark 作业时,我们总是谈论我们在其上运行的集群。
这是一组执行任务的计算机,即工作节点,以及托管组织工作负载的计算机,被称为头节点。
Spark 是用 Scala 编写的,这是一种具有强烈函数式风格的编程语言,并编译成Java 虚拟机(JVMs)。由于这是一本关于 Python 机器学习工程的书籍,我们不会过多讨论 Spark 底层的 Scala 组件,除非它们有助于我们在工作中使用它。Spark 有几个流行的 API,允许程序员用多种语言(包括 Python)与之一起开发。这导致了我们在本书中使用的 PySpark 语法。
那么,这一切是如何组合在一起的?
首先,使 Apache Spark 如此受欢迎的一个原因是它拥有大量可用的连接器、组件和 API。例如,四个主要组件与Spark Core接口:
-
Spark SQL、DataFrames和Datasets:这个组件允许你创建非常可扩展的程序,用于处理结构化数据。通过 Spark 的主要结构化 API(Python、Java、Scala 或 R)编写符合 SQL 规范的查询并创建利用底层 Spark 引擎的数据表,可以非常容易地访问 Spark 的主要功能集。
-
Spark Structured Streaming:这个组件允许工程师处理由例如 Apache Kafka 提供的流数据。设计极其简单,允许开发者像处理一个不断增长的 Spark 结构化表一样简单地处理流数据,具有与标准表相同的查询和处理功能。这为创建可扩展的流解决方案提供了低门槛。
-
GraphX:这是一个库,允许你实现图并行处理并将标准算法应用于基于图的数据(例如,如 PageRank 或三角形计数)。Databricks 的GraphFrames项目通过允许我们在 Spark 中使用基于 DataFrame 的 API 来分析图数据,使得这一功能更加易于使用。
-
Spark ML:最后但同样重要的是,我们有最适合我们作为机器学习工程师的组件:Spark 的原生机器学习库。这个库包含了我们在本书中已经看到过的许多算法和特征工程能力。能够在库中使用 DataFrame API 使得它极其易于使用,同时仍然为我们提供了创建非常强大代码的途径。
通过在 Spark 集群上使用 Spark ML 与在单个线程上运行另一个机器学习库相比,你可以为你的机器学习训练获得巨大的速度提升。我们还可以应用其他技巧到我们最喜欢的机器学习实现中,然后使用 Spark 来扩展它们;我们稍后会探讨这一点。
Spark 的架构基于驱动程序/执行器架构。驱动程序是作为 Spark 应用程序的主要入口点的程序,也是创建 SparkContext 对象的地方。SparkContext 将任务发送到执行器(它们在自己的 JVM 上运行),并以适合给定管理器和解决方案运行模式的方式与集群管理器进行通信。驱动程序的主要任务之一是将我们编写的代码转换为 有向无环图(DAG)中的逻辑步骤集合(与我们在第五章 部署模式和工具 中使用的 Apache Airflow 的概念相同),然后将该 DAG 转换为需要在可用的计算资源上执行的任务集合。
在接下来的页面中,我们将假设我们正在使用 Hadoop YARN 资源管理器运行 Spark,这是最受欢迎的选项之一,也是 AWS Elastic MapReduce(EMR)解决方案的默认选项(关于这一点稍后还会详细介绍)。在以 集群模式 运行 YARN 时,驱动程序程序在 YARN 集群上的一个容器中运行,这使得客户端可以通过驱动程序提交作业或请求,然后退出(而不是要求客户端保持与集群管理器的连接,这在所谓的 客户端模式 下可能会发生,这里我们不会讨论)。
集群管理器负责在集群上可用的资源上启动执行器。
Spark 的架构允许我们作为机器学习工程师,无论我们是在笔记本电脑上本地工作还是在拥有数千个节点的集群上工作,都可以使用相同的 API 和语法来构建解决方案。驱动程序、资源管理器和执行器之间的连接是实现这种魔法的关键。
Spark 技巧和技巧
在本小节中,我们将介绍一些简单但有效的技巧,以使用 Spark 编写高性能的解决方案。我们将重点关注数据操作和准备的关键语法,这些通常是任何机器学习管道中的第一步。让我们开始吧:
-
首先,我们将介绍编写良好的 Spark SQL 的基础知识。任何 Spark 程序的入口点是
SparkSession对象,我们需要在我们的应用程序中导入其实例。它通常使用
spark变量实例化:from pyspark.sql import SparkSession spark = SparkSession\ .builder\ .appName("Spark SQL Example")\ .config("spark.some.config.option", "some-value")\ .getOrCreate() -
然后,你可以使用
spark对象和sql方法运行 Spark SQL 命令,针对你的可用数据:spark.sql('''select * from data_table''')根据数据存在的地方,有各种方法可以在 Spark 程序内部提供所需的数据。以下示例取自我们在第三章,“从模型到模型工厂”中经过的一些代码,展示了如何从
csv文件中将数据拉入 DataFrame:data = spark.read.format("csv")\ .option("sep", ";")\ .option("inferSchema", "true")\ .option("header", "true").load( "data/bank/bank.csv") -
现在,我们可以使用以下语法创建此数据的临时视图:
data.createOrReplaceTempView('data_view') -
然后,我们可以使用之前提到的方法查询此数据,以查看记录或创建新的 DataFrames:
new_data = spark.sql('''select …''')
当编写 Spark SQL 时,一些标准做法有助于提高代码的效率:
-
尽量不要将左边的大的表格与右边的小的表格连接,因为这效率低下。通常,尽量使用于连接的数据集尽可能瘦,例如,尽可能少地使用未使用的列或行进行连接。
-
避免查询语法扫描非常大的数据集;例如,
select max(date_time_value)。
在这个情况下,尝试定义逻辑,在找到最小或最大值之前更积极地过滤数据,并且通常允许解决方案扫描更小的数据集。
在使用 Spark 时,以下是一些其他的好做法:
-
避免数据倾斜:尽可能了解你的数据将如何在执行器之间分割。如果你的数据是在日期列上分区的,如果每天的数据量相当,这可能是一个不错的选择,但如果某些天有大部分数据而其他天很少,这可能是一个坏选择。可能需要使用更合适的列(或使用
repartition命令生成的 Spark 生成的 ID)重新分区。 -
避免数据洗牌:这是指数据在不同分区之间重新分配。例如,我们可能有一个按日级别分区的数据集,然后我们要求 Spark 对所有时间的数据集的一个列求和。这将导致所有每日分区被访问,并将结果写入一个新的分区。为此,必须发生磁盘写入和网络传输,这通常会导致你的 Spark 作业的性能瓶颈。
-
避免在大数据集中执行操作:例如,当你运行
collect()命令时,你将把所有数据都带回驱动节点。如果这是一个大数据集,这可能会非常糟糕,但可能需要将计算结果转换为其他东西。请注意,toPandas()命令,它将你的 SparkDataFrame转换为 pandasDataFrame,也会收集驱动器内存中的所有数据。 -
当适用时使用 UDF:作为 Apache Spark 的 ML 工程师,你武器库中的另一个优秀工具是用户定义函数(UDF)。UDF 允许你封装更复杂和定制的逻辑,并以各种方式大规模应用。这个方面的重要之处在于,如果你编写了一个标准的 PySpark(或 Scala)UDF,那么你可以在 Spark SQL 语法内部应用这个 UDF,这允许你高效地重用你的代码,甚至简化 ML 模型的适用。缺点是这些代码有时可能不是最有效的,但如果它有助于使你的解决方案更简单、更易于维护,那么它可能是一个正确的选择。
作为具体示例,让我们构建一个 UDF,它将查看我们在第三章“从模型到模型工厂”中处理过的银行数据,创建一个名为‘month_as_int’的新列,该列将当前月份的字符串表示形式转换为整数以便后续处理。我们不会关注训练/测试分割或这可能被用于什么;相反,我们将突出如何将一些逻辑应用于 PySpark UDF。
让我们开始吧:
-
首先,我们必须读取数据。注意这里给出的相对路径与本书 GitHub 仓库中的
spark_example_udfs.py脚本一致,该脚本位于github.com/PacktPublishing/Machine-Learning-Engineering-with-Python-Second-Edition/blob/main/Chapter06/mlewp2-spark/spark_example_udfs.py:from pyspark.sql import SparkSession from pyspark import SparkContext from pyspark.sql import functions as f sc = SparkContext("local", "Ch6BasicExampleApp") # Get spark session spark = SparkSession.builder.getOrCreate() # Get the data and place it in a spark dataframe data = spark.read.format("csv").option("sep", ";").option("inferSchema", "true").option("header", "true").load( "data/bank/bank.csv")如果我们使用
data.show()命令显示当前数据,我们会看到类似以下内容:图 6.1:银行数据集中初始 DataFrame 的数据样本。
-
现在,我们可以使用
data.printSchema()命令双重检查这个 DataFrame 的模式。这确认了month目前是以字符串形式存储的,如下所示:|-- age: integer (nullable = true) |-- job: string (nullable = true) |-- marital: string (nullable = true) |-- education: string (nullable = true) |-- default: string (nullable = true) |-- balance: integer (nullable = true) |-- housing: string (nullable = true) |-- loan: string (nullable = true) |-- contact: string (nullable = true) |-- day: integer (nullable = true) |-- month: string (nullable = true) |-- duration: integer (nullable = true) |-- campaign: integer (nullable = true) |-- pdays: integer (nullable = true) |-- previous: integer (nullable = true) |-- poutcome: string (nullable = true) |-- y: string (nullable = true) -
现在,我们可以定义我们的 UDF,它将使用 Python 的
datetime库将月份的字符串表示形式转换为整数:import datetime def month_as_int(month): month_number = datetime.datetime.strptime(month, "%b").month return month_number -
如果我们想在 Spark SQL 内部应用我们的函数,那么我们必须将函数注册为 UDF。
register()函数的参数是函数的注册名称、我们刚刚编写的 Python 函数的名称以及返回类型。默认情况下,返回类型是StringType(),但我们在这里明确指定了它:from pyspark.sql.types import StringType spark.udf.register("monthAsInt", month_as_int, StringType()) -
最后,既然我们已经注册了函数,我们就可以将其应用于我们的数据。首先,我们将创建银行数据集的一个临时视图,然后运行一个 Spark SQL 查询,该查询引用我们的用户定义函数(UDF):
data.createOrReplaceTempView('bank_data_view') spark.sql(''' select *, monthAsInt(month) as month_as_int from bank_data_view ''').show()使用
show()命令运行前面的语法显示我们已经成功计算了新列。结果DataFrame的最后几列如下所示:图 6.2:通过应用我们的 UDF 成功计算了新列。
-
或者,我们可以使用以下语法创建我们的 UDF,并将结果应用于 Spark
DataFrame。如前所述,使用 UDF 有时可以让你非常简单地封装相对复杂的语法。这里的语法相当简单,但我仍然会向你展示。这给我们带来了与前面截图相同的结果:from pyspark.sql.functions import udf month_as_int_udf = udf(month_as_int, StringType()) df = spark.table("bank_data_view") df.withColumn('month_as_int', month_as_int_udf("month")).show() -
最后,PySpark 还提供了一个很好的装饰器语法来创建我们的 UDF,这意味着如果你确实在构建一些更复杂的功能,你只需将这个装饰器放在被装饰的 Python 函数中即可。下面的代码块也给出了与前面截图相同的结果:
@udf("string") def month_as_int_udf(month): month_number = datetime.datetime.strptime(month, "%b").month return month_number df.withColumn('month_as_int', month_as_int_udf("month")).show()
这显示了如何在 UDF 中应用一些简单的逻辑,但为了使用这种方法在规模上部署模型,我们必须在函数内部放置 ML 逻辑并以相同的方式应用它。如果我们想使用我们习惯于从数据科学世界使用的标准工具,如 Pandas 和Scikit-learn,这可能会变得有点棘手。幸运的是,我们还有另一个可以使用的选项,它有一些优点。我们现在就来讨论这个。
当我们在 Python 中工作时,目前考虑的 UDF 存在一个小问题,那就是在 JVM 和 Python 之间转换数据可能需要一段时间。一种解决方法是使用所谓的pandas UDFs,它底层使用 Apache Arrow 库来确保我们的 UDF 执行时数据读取快速。这给我们带来了 UDF 的灵活性,而没有任何减速。
pandas UDFs 也非常强大,因为它们与 pandas Series 和 DataFrame 对象的语法一起工作。这意味着许多习惯于使用 pandas 在本地构建模型的科学家可以轻松地将他们的代码扩展到使用 Spark。
例如,让我们回顾一下如何将一个简单的分类器应用于我们在这本书中之前使用过的 wine 数据集。请注意,该模型并未针对这些数据进行优化;我们只是展示了一个应用预训练分类器的示例:
-
首先,让我们在 wine 数据集上创建一个简单的支持向量机(SVM)分类器。我们在这里没有进行正确的训练/测试分割、特征工程或其他最佳实践,因为我们只是想向你展示如何应用任何
sklearn模型:import sklearn.svm import sklearn.datasets clf = sklearn.svm.SVC() X, y = sklearn.datasets.load_wine(return_X_y=True) clf.fit(X, y) -
然后,我们可以将特征数据带入 Spark DataFrame,以展示如何在后续阶段应用 pandas UDF:
df = spark.createDataFrame(X.tolist()) -
pandas UDFs 非常容易定义。我们只需在函数中编写我们的逻辑,然后添加
@pandas_udf装饰器,在那里我们还需要为函数提供输出类型。在最简单的情况下,我们可以将使用训练模型进行预测的(通常是串行或仅本地并行化)过程封装起来:import pandas as pd from pyspark.sql.types import IntegerType from pyspark.sql.functions import pandas_udf @pandas_udf(returnType=IntegerType()) def predict_pd_udf(*cols): X = pd.concat(cols, axis=1) return pd.Series(clf.predict(X)) -
最后,我们可以通过传递我们函数所需的适当输入来将此应用于包含数据的 Spark
DataFrame。在这种情况下,我们将传递特征列的名称,共有 13 个:col_names = ['_{}'.format(x) for x in range(1, 14)] df_pred = df.select('*', predict_pd_udf(*col_names).alias('class'))
现在,如果您查看这个结果,您将看到df_pred DataFrame 的前几行如下所示:
图 6.3:应用简单的 pandas UDF 的结果。
这样,我们就完成了对 Spark 和 pandas UDF 在 Spark 中的快速浏览,这使我们能够以明显并行的方式应用诸如数据转换或我们的机器学习模型之类的串行 Python 逻辑。
在下一节中,我们将专注于如何在云端设置 Spark-based 计算。
云端 Spark
如前所述,应该很清楚,编写和部署基于 PySpark 的机器学习解决方案可以在您的笔记本电脑上完成,但为了在工作规模上看到好处,您必须拥有适当规模的计算集群。提供此类基础设施可能是一个漫长而痛苦的过程,但正如本书中已经讨论的那样,主要公共云提供商提供了大量的基础设施选项。
对于 Spark,AWS 有一个特别好的解决方案,称为AWS Elastic MapReduce(EMR),这是一个托管的大数据平台,允许您轻松配置大数据生态系统中的几种不同类型的集群。在这本书中,我们将专注于基于 Spark 的解决方案,因此我们将专注于创建和使用带有 Spark 工具的集群。
在下一节中,我们将通过一个具体的例子来展示如何在 EMR 上启动一个 Spark 集群,然后将其部署一个简单的基于 Spark ML 的应用程序。
因此,让我们在AWS EMR上探索 Spark 在云端的应用!
AWS EMR 示例
为了理解 EMR 是如何工作的,我们将继续遵循本书的实践方法,并深入一个例子。我们将首先学习如何创建一个全新的集群,然后再讨论如何编写和部署我们的第一个 PySpark ML 解决方案到集群中。让我们开始吧:
-
首先,导航到 AWS 上的EMR页面,找到创建集群按钮。然后,您将被带到允许您输入集群配置数据的页面。第一个部分是您指定集群名称和要安装在其上的应用程序的地方。我将把这个集群命名为
mlewp2-cluster,使用写作时的最新 EMR 版本 6.11.0,并选择Spark应用程序包。 -
在这个第一部分,所有其他配置都可以保持默认设置。这如图 6.4 所示:
图 6.4:使用一些默认配置创建我们的 EMR 集群。
-
接下来是集群中使用的计算配置。您在这里也可以再次使用默认设置,但了解正在发生的事情很重要。首先,是选择使用“实例组”还是“实例舰队”,这指的是根据您提供的某些约束条件部署的计算扩展策略。实例组更简单,定义了您为每种节点类型想要运行的特定服务器,关于这一点我们稍后再详细说明,并且您可以在集群生命周期内需要更多服务器时选择“按需”或“竞价实例”。实例舰队允许采用更多复杂的获取策略,并为每种节点类型混合不同的服务器实例类型。有关更多信息,请阅读 AWS 文档,以确保您对不同的选项有清晰的了解,
docs.aws.amazon.com/emr/index.xhtml;我们将通过使用具有默认设置的实例组来继续操作。现在,让我们转到节点。EMR 集群中有不同的节点;主节点、核心节点和任务节点。主节点将运行我们的 YARN 资源管理器,并跟踪作业状态和实例组健康。核心节点运行一些守护程序和 Spark 执行器。最后,任务节点执行实际的分布式计算。现在,让我们按照为实例组选项提供的默认设置进行操作,如图 6.5 中的主节点所示。图 6.5:我们的 EMR 集群的计算配置。我们选择了更简单的“实例组”选项进行配置,并采用了服务器类型的默认设置。
-
接下来,我们将定义我们在步骤 2中提到的用于实例组和实例舰队计算选项的显式集群缩放行为。再次提醒,现在请选择默认设置,但您可以在这里尝试调整集群的大小,无论是通过增加节点数量,还是定义在负载增加时动态增加集群大小的自动缩放行为。图 6.6展示了它应该看起来是什么样子。
图 6.6:集群配置和缩放策略选择。在这里,我们选择了特定的小集群大小的默认设置,但您可以增加这些值以获得更大的集群,或者使用自动缩放选项来提供最小和最大大小限制。
-
现在,有一个网络部分,如果你已经为书中的其他示例创建了一些虚拟专用网络(VPC)和子网,这将更容易;参见第五章,部署模式和工具以及 AWS 文档以获取更多信息。只需记住,VPCs 主要是关于将你正在配置的基础设施与其他 AWS 账户中的服务以及更广泛的互联网隔离开来,因此熟悉它们及其应用绝对是件好事。
-
为了完整性,图 6.7显示了我在这个示例中使用的设置。
图 6.7:网络配置需要使用 VPC;如果没有选择,它将自动为集群创建一个子网。
-
我们只需要输入几个更多部分来定义我们的集群。下一个强制性的部分是关于集群终止策略。我总是建议在可能的情况下为基础设施设置自动拆解策略,因为这有助于管理成本。整个行业都有很多关于团队留下未使用的服务器运行并产生巨额账单的故事!图 6.8显示,我们正在使用这样的自动集群终止策略,其中集群将在 1 小时未被使用后终止。
图 6.8:定义一个类似于这样的集群终止策略被认为是最佳实践,并且可以帮助避免不必要的成本。
-
完成所需的最后一个部分是定义适当的身份和访问管理(IAM)角色,它定义了哪些账户可以访问我们正在创建的资源。如果你已经有了一些你愿意作为 EMR 服务角色重用的 IAM 角色,那么你可以这样做;然而,对于这个示例,让我们为这个集群创建一个新的服务角色。图 6.9显示,选择创建新角色的选项会预先填充 VPC、子网和安全组,其值与通过此过程已选择的值相匹配。你可以添加更多内容。图 6.10显示,我们还可以选择创建一个“实例配置文件”,这只是一个在启动时应用于 EC2 集群中所有服务器实例的服务角色的名称。
图 6.9:创建 AWS EMR 服务角色。
图 6.10:为在此 EMR 集群中使用的 EC2 服务器创建实例配置文件。实例配置文件只是分配给所有集群 EC2 实例在启动时的服务角色的名称。
-
讨论的部分都是创建您的集群所必需的章节,但也有一些可选章节,我想简要提及以指导您进一步探索。这里有指定步骤的选项,您可以在其中定义要按顺序运行的 shell 脚本、JAR 应用程序或 Spark 应用程序。这意味着您可以在基础设施部署后提交作业之前,启动集群并准备好应用程序以按您希望的顺序处理数据。还有一个关于引导操作的章节,它允许您定义在安装任何应用程序或处理 EMR 集群上的任何数据之前应运行的定制安装或配置步骤。集群日志位置、标签和一些基本软件考虑因素也适用于配置。最后要提到的重要一点是安全配置。图 6.11显示了选项。尽管我们将不指定任何 EC2 密钥对或安全配置来部署此集群,但如果您想在生产中运行此集群,了解您组织的网络安全要求和规范至关重要。请咨询您的安全或网络团队以确保一切符合预期和要求。目前,我们可以将其留空,然后继续创建集群。
图 6.11:此处显示的集群安全配置是可选的,但如果您打算在生产中运行集群,应仔细考虑。
-
现在我们已经选择了所有必需的选项,点击创建集群按钮以启动。创建成功后,您应该会看到一个类似于图 6.12所示的审查页面。就这样;现在您已经在云中创建了自己的 Spark 集群了!!
图 6.12:成功启动后显示的 EMR 集群创建审查页面。
在启动我们的 EMR 集群后,我们希望能够向其提交工作。在这里,我们将调整我们在第三章,从模型到模型工厂中生产的示例 Spark ML 管道,以分析银行数据集,并将其作为步骤提交到我们新创建的集群。我们将这样做为一个独立的单个 PySpark 脚本,作为我们应用程序的唯一步骤,但很容易在此基础上构建更复杂的应用程序:
-
首先,我们将从第三章,从模型到模型工厂中提取代码,并根据我们围绕良好实践的讨论进行一些精心的重构。我们可以更有效地模块化代码,使其包含一个提供所有建模步骤的功能(为了简洁,并非所有步骤都在此处重现)。我们还包括了一个最终步骤,将建模结果写入
parquet文件:def model_bank_data(spark, input_path, output_path): data = spark.read.format("csv")\ .option("sep", ";")\ .option("inferSchema", "true")\ .option("header", "true")\ .load(input_path) data = data.withColumn('label', f.when((f.col("y") == "yes"), 1).otherwise(0)) # ... data.write.format('parquet')\ .mode('overwrite')\ .save(output_path) -
在此基础上,我们将所有主要样板代码封装到一个名为
main的函数中,该函数可以在程序的if __name__=="__main__":入口点被调用:def main(): parser = argparse.ArgumentParser() parser.add_argument( '--input_path', help='S3 bucket path for the input data. Assume to be csv for this case.' ) parser.add_argument( '--output_path', help='S3 bucket path for the output data. Assume to be parquet for this case' ) args = parser.parse_args() # Create spark context sc = SparkContext("local", "pipelines") # Get spark session spark = SparkSession\ .builder\ .appName('MLEWP Bank Data Classifier EMR Example')\ .getOrCreate() model_bank_data( spark, input_path=args.input_path,, output_path=args.output_path ) -
我们将前面的函数放入一个名为
spark_example_emr.py的脚本中,稍后我们将将其提交到我们的 EMR 集群:import argparse from pyspark.sql import SparkSession from pyspark import SparkContext from pyspark.sql import functions as f from pyspark.mllib.evaluation import BinaryClassificationMetrics, MulticlassMetrics from pyspark.ml.feature import StandardScaler, OneHotEncoder, StringIndexer, Imputer, VectorAssembler from pyspark.ml import Pipeline, PipelineModel from pyspark.ml.classification import LogisticRegression def model_bank_data(spark, input_path, output_path): ... def main(): ... if __name__ == "__main__": main() -
现在,为了将此脚本提交到我们刚刚创建的 EMR 集群,我们需要找到集群 ID,我们可以从 AWS UI 或通过运行以下命令来获取:
aws emr list-clusters --cluster-states WAITING -
然后,我们需要将
spark_example_emr.py脚本发送到 S3,以便集群读取。我们可以创建一个名为s3://mlewp-ch6-emr-examples的 S3 存储桶来存储这个和其他工件,无论是使用 CLI 还是 AWS 控制台(参见第五章,部署模式和工具)。一旦复制完成,我们就为最后一步做好了准备。 -
现在,我们必须使用以下命令提交脚本,用我们刚刚创建的集群 ID 替换
<CLUSTER_ID>。请注意,如果你的集群由于我们设置的自动终止策略而终止,你无法重新启动它,但你可以克隆它。几分钟后,步骤应该已经完成,输出应该已经写入同一 S3 存储桶中的results.parquet文件:aws emr add-steps\ --region eu-west-1 \ --cluster-id <CLUSTER_ID> \ --steps Type=Spark,Name="Spark Application Step",ActionOnFailure=CONTINUE,\ Args=[--files,s3://mlewp-ch6-emr-examples/spark_example_emr.py,\ --input_path,s3://mlewp-ch6-emr-examples/bank.csv,\ --output_path,s3://mleip-emr-ml-simple/results.parquet]就这样——这就是我们如何在云上使用AWS EMR开始开发 PySpark ML 管道的方法!
你会发现,通过导航到适当的 S3 存储桶并确认results.parquet文件已成功创建,这个先前的过程已经成功;参见图 6.13。
图 6.13:提交 EMR 脚本后成功创建 results.parquet 文件。
在下一节中,我们将探讨使用所谓的无服务器工具来扩展我们解决方案的另一种方法。
启动无服务器基础设施
无论何时我们进行机器学习或软件工程,都必须在计算机上运行必要的任务和计算,通常伴随着适当的网络、安全和其它协议及软件,这些我们通常称之为构成我们的基础设施。我们基础设施的一个大组成部分是我们用来运行实际计算的服务器。这可能会显得有些奇怪,所以让我们先从无服务器基础设施(这怎么可能存在呢?)开始谈。本节将解释这个概念,并展示如何使用它来扩展你的机器学习解决方案。
无服务器作为一个术语有点误导,因为它并不意味着没有物理服务器在运行你的程序。然而,它确实意味着你正在运行的程序不应被视为静态托管在一台机器上,而应被视为在底层硬件之上的另一层上的短暂实例。
无服务器工具对你的机器学习解决方案的好处包括(但不限于)以下内容:
-
无服务器:不要低估通过将基础设施管理外包给云服务提供商所能节省的时间和精力。
-
简化扩展:通常,通过使用明确定义的最大实例等,很容易定义您无服务器组件的扩展行为。
-
低门槛:这些组件通常设置和运行起来非常简单,让您和您的团队成员能够专注于编写高质量的代码、逻辑和模型。
-
自然集成点:无服务器工具通常非常适合在与其他工具和组件之间进行交接。它们的易于设置意味着您可以在极短的时间内启动简单的作业,这些作业可以传递数据或触发其他服务。
-
简化服务:一些无服务器工具非常适合为您的机器学习模型提供服务层。之前提到的可扩展性和低门槛意味着您可以快速创建一个非常可扩展的服务,该服务可以根据请求或由其他事件触发提供预测。
无服务器功能中最好和最广泛使用的例子之一是 AWS Lambda,它允许我们通过简单的网页界面或通过我们常用的开发工具用各种语言编写程序,然后让它们在完全独立于任何已设置的基础设施的情况下运行。
Lambda 是一个惊人的低门槛解决方案,可以快速将一些代码部署并扩展。然而,它主要针对创建可以通过 HTTP 请求触发的简单 API。如果您旨在构建事件或请求驱动的系统,使用 Lambda 部署您的机器学习模型特别有用。
要看到这个功能在实际中的运用,让我们构建一个基本的系统,该系统接受带有 JSON 体的 HTTP 请求作为输入图像数据,并使用预构建的 Scikit-Learn 模型返回包含数据分类的类似消息。这个教程基于 AWS 的示例,请参阅aws.amazon.com/blogs/compute/deploying-machine-learning-models-with-serverless-templates/。
对于这个,我们可以通过利用作为 AWS 无服务器应用程序模型(SAM)框架的一部分已经构建和维护的模板来节省大量时间(aws.amazon.com/about-aws/whats-new/2021/06/aws-sam-launches-machine-learning-inference-templates-for-aws-lambda/)。
要在您的相关平台上安装 AWS SAM CLI,请遵循docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.xhtml中的说明。
现在,让我们执行以下步骤来设置一个模板无服务器部署,用于托管和提供用于对手写数字图像进行分类的机器学习模型:
-
首先,我们必须运行
sam init命令并选择 AWS 的Quick Start Templates选项:Which template source would you like to use? 1 - AWS Quick Start Templates 2 - Custom Template Location Choice: 1 -
然后,你将获得选择使用
AWS Quick Start应用程序模板的机会;选择选项 15,Machine Learning:Choose an AWS Quick Start application template 1 - Hello World Example 2 - Data processing 3 - Hello World Example with Powertools for AWS Lambda 4 - Multi-step workflow 5 - Scheduled task 6 - Standalone function 7 - Serverless API 8 - Infrastructure event management 9 - Lambda Response Streaming 10 - Serverless Connector Hello World Example 11 - Multi-step workflow with Connectors 12 - Full Stack 13 - Lambda EFS example 14 - DynamoDB Example 15 - Machine Learning Template: -
接下来是你要使用的 Python 运行时的选项;与本书的其他部分一致,我们将使用 Python 3.10 运行时:
Which runtime would you like to use? 1 - python3.9 2 - python3.8 3 - python3.10 Runtime: -
在撰写本文时,SAM CLI 将根据这些选择自动选择一些选项,首先是包类型,然后是依赖管理器。然后,你将被要求确认你想要使用的 ML 起始模板。对于这个示例,选择
XGBoost Machine Learning API:Based on your selections, the only Package type available is Image. We will proceed to selecting the Package type as Image. Based on your selections, the only dependency manager available is pip. We will proceed copying the template using pip. Select your starter template 1 - PyTorch Machine Learning Inference API 2 - Scikit-learn Machine Learning Inference API 3 - Tensorflow Machine Learning Inference API 4 - XGBoost Machine Learning Inference API Template: 4 -
SAM CLI 随后会友好地询问一些配置请求跟踪和监控的选项;你可以根据自己的喜好选择是或否。在这个示例中,我选择了否。
Would you like to enable X-Ray tracing on the function(s) in your application? [y/N]: N Would you like to enable monitoring using CloudWatch Application Insights? For more info, please view: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch-application-insights.xhtml [y/N]: N Project name [sam-app]: mlewp-sam-ml-api Cloning from https://github.com/aws/aws-sam-cli-app-templates (process may take a moment) -
最后,你的命令行将提供一些有关安装和下一步操作的有用信息:
----------------------- Generating application: ----------------------- Name: mlewp-sam-ml-api Base Image: amazon/python3.10-base Architectures: x86_64 Dependency Manager: pip Output Directory: . Configuration file: mlewp-sam-ml-api/samconfig.toml Next steps can be found in the README file at mlewp-sam-ml-api/README.md Commands you can use next ========================= [*] Create pipeline: cd mlewp-sam-ml-api && sam pipeline init --bootstrap [*] Validate SAM template: cd mlewp-sam-ml-api && sam validate [*] Test Function in the Cloud: cd mlewp-sam-ml-api && sam sync --stack-name {stack-name} --watch
注意,前面的步骤创建了一个基于 XGBoost 的系统模板,用于对手写数字进行分类。对于其他应用程序和项目用例,你需要根据需要调整模板的源代码。如果你想部署这个示例,请按照以下步骤操作:
-
首先,我们必须构建模板中提供的应用程序容器。首先,导航到你的项目的顶级目录,你应该能看到目录结构应该是这样的。我使用了
tree命令在命令行中提供一个干净的目录结构概览:cd mlewp-sam-ml-api ls tree ├── README.md ├── __init__.py ├── app │ ├── Dockerfile │ ├── __init__.py │ ├── app.py │ ├── model │ └── requirements.txt ├── events │ └── event.json ├── samconfig.toml └── template.yaml 3 directories, 10 files -
现在我们处于顶级目录,我们可以运行
build命令。这要求你的机器在后台运行 Docker:sam build -
在成功构建后,你应该在你的终端收到类似以下的成功消息:
Build Succeeded Built Artifacts : .aws-sam/build Built Template : .aws-sam/build/template.yaml Commands you can use next ========================= [*] Validate SAM template: sam validate [*] Invoke Function: sam local invoke [*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch [*] Deploy: sam deploy --guided -
现在,我们可以在本地测试该服务,以确保一切都能与存储库中提供的模拟数据良好地工作。这使用了一个编码基本图像的 JSON 文件,并运行服务的推理步骤。如果一切顺利,你将看到类似以下输出的服务:
sam local invoke --event events/event.json Invoking Container created from inferencefunction:python3.10-v1 Building image................. Using local image: inferencefunction:rapid-x86_64. START RequestId: de4a2fe1-be86-40b7-a59d-151aac19c1f0 Version: $LATEST END RequestId: de4a2fe1-be86-40b7-a59d-151aac19c1f0 REPORT RequestId: de4a2fe1-be86-40b7-a59d-151aac19c1f0 Init Duration: 1.30 ms Duration: 1662.13 ms Billed Duration: 1663 ms Memory Size: 5000 MB Max Memory Used: 5000 MB {"statusCode": 200, "body": "{\"predicted_label\": 3}"}% -
在实际项目中,你需要在部署到云之前,编辑解决方案的
app.py和其他所需文件。我们将使用 SAM CLI 来完成这项工作,理解如果你想要自动化这个过程,你可以使用本书中讨论的 CI/CD 流程和工具,特别是在第四章,打包部分。要部署,你可以通过运行deploy命令来使用 CLI 的引导部署向导,这将返回以下输出:sam deploy --guided Configuring SAM deploy ====================== Looking for config file [samconfig.toml] : Found Reading default arguments : Success Setting default arguments for 'sam deploy' ========================================= Stack Name [mlewp-sam-ml-api]: -
然后,我们必须为提供的每个元素配置应用程序。在大多数情况下,我选择了默认设置,但你也可以参考 AWS 文档,并根据你的项目做出最相关的选择:
Configuring SAM deploy ====================== Looking for config file [samconfig.toml] : Found Reading default arguments : Success Setting default arguments for 'sam deploy' ========================================= Stack Name [mlewp-sam-ml-api]: AWS Region [eu-west-2]: #Shows you resources changes to be deployed and require a 'Y' to initiate deploy Confirm changes before deploy [Y/n]: y #SAM needs permission to be able to create roles to connect to the resources in your template Allow SAM CLI IAM role creation [Y/n]: y #Preserves the state of previously provisioned resources when an operation fails Disable rollback [y/N]: y InferenceFunction has no authentication. Is this okay? [y/N]: y Save arguments to configuration file [Y/n]: y SAM configuration file [samconfig.toml]: SAM configuration environment [default]: -
上一个步骤将在终端生成大量数据;你可以监控这些数据以查看是否有任何错误或问题。如果部署成功,那么你应该会看到一些关于应用程序的最终元数据,如下所示:
CloudFormation outputs from deployed stack --------------------------------------------------------------------------------------------------------------------- Outputs --------------------------------------------------------------------------------------------------------------------- Key InferenceApi Description API Gateway endpoint URL for Prod stage for Inference function Value https://8qg87m9380.execute-api.eu-west-2. amazonaws.com/Prod/classify_digit/ Key InferenceFunctionIamRole Description Implicit IAM Role created for Inference function Value arn:aws:iam::508972911348:role/mlewp-sam-ml-api-InferenceFunctionRole-1UE509ZXC1274 Key InferenceFunction Description Inference Lambda Function ARN Value arn:aws:lambda:eu-west-2:508972911348:function:mlewp-sam-ml-api-InferenceFunction-ueFS1y2mu6Gz --------------------------------------------------------------------------------------------------------------------- -
为了快速测试确认云托管解决方案是否正常工作,我们可以使用 Postman 等工具来调用我们闪亮的新 ML API。只需将步骤 8输出屏幕中的
InferenceApiURL 复制为请求的目的地,选择POST作为请求类型,然后选择二进制作为主体类型。注意,如果你需要获取推理 URL,你还可以在终端中运行sam list endpoints --output json命令。然后,你可以选择一个手写数字的图像,或者任何其他图像,发送到 API。你可以在 Postman 中通过选择二进制主体选项并附加图像文件,或者复制图像的编码字符串。在图 6.14中,我使用了events/event.json文件中body键值对的编码字符串,这是我们用来本地测试函数的:图 6.14:使用 Postman 调用我们的无服务器 ML 端点。这使用了一个编码的示例图像作为请求的主体,该请求与 SAM XGBoost ML API 模板一起提供。
-
你也可以使用以下
curl命令以更程序化的方式测试这个服务——只需将图像的编码二进制字符串替换为适当的值,或者实际上编辑命令以指向数据二进制文件,如果你愿意,就可以开始了:curl --location --request POST 'https://8qg87m9380.execute-api.eu-west-2.amazonaws.com/Prod/classify_digit/' \ --header 'Content-Type: raw/json' \ --data '<ENCODED_IMAGE_STRING>'在这个步骤和步骤 9中,Lambda 函数的响应主体如下:
{ "predicted_label": 3 }就这样——我们已经在 AWS 上构建和部署了一个简单的无服务器 ML 推理服务!
在下一节中,我们将简要介绍本章中将要讨论的最终扩展解决方案,即使用 Kubernetes(K8s)和 Kubeflow 来水平扩展容器化应用程序。
使用 Kubernetes 进行大规模容器化
我们已经介绍了如何使用容器来构建和部署我们的 ML 解决方案。下一步是了解如何编排和管理多个容器以大规模部署和运行应用程序。这就是开源工具Kubernetes(K8s)发挥作用的地方。
K8s 是一个非常强大的工具,它提供了各种不同的功能,帮助我们创建和管理非常可扩展的容器化应用程序,包括但不限于以下内容:
-
负载均衡:K8s 将为你管理路由到你的容器的入站流量,以确保负载均匀分配。
-
水平扩展:K8s 提供了简单的接口,让你可以控制任何时刻拥有的容器实例数量,如果需要,可以大规模扩展。
-
自我修复:有内置的管理来替换或重新安排未通过健康检查的组件。
-
自动回滚:K8s 存储了你的系统历史,以便在出现问题时可以回滚到先前的有效版本。
所有这些功能都有助于确保你的部署解决方案是健壮的,并且能够在所有情况下按要求执行。
K8s 的设计是通过使用微服务架构,并使用控制平面与节点(服务器)交互,每个节点都托管运行应用程序组件的 pods(一个或多个容器)来确保上述功能从底层开始嵌入。
K8s 提供的关键功能是,通过创建基础解决方案的副本来根据负载扩展应用程序。如果你正在构建具有 API 端点的服务,这些端点在不同时间可能会面临需求激增,这将非常有用。了解你可以这样做的一些方法,请参阅kubernetes.io/docs/concepts/workloads/controllers/deployment/#scaling-a-deployment:
图 6.15:K8s 架构。
但关于机器学习(ML)呢?在这种情况下,我们可以看看 K8s 生态系统中的新成员:Kubeflow,我们在第五章“部署模式和工具”中学习了如何使用它。
Kubeflow 将自己定位为K8s 的 ML 工具包(www.kubeflow.org/),因此作为机器学习工程师,了解这个快速发展的解决方案是有意义的。这是一个非常激动人心的工具,也是一个活跃的开发领域。
对于 K8s 的水平扩展概念通常仍然适用,但 Kubeflow 提供了一些标准化的工具,可以将你构建的流水线转换为标准的 K8s 资源,然后可以按照之前描述的方式管理和分配资源。这有助于减少模板代码,并让我们作为机器学习工程师专注于构建我们的建模逻辑,而不是设置基础设施。我们在第五章构建示例流水线时利用了这一点。
我们将在第八章“构建示例 ML 微服务”中更详细地探讨 Kubernetes,我们将使用它来扩展我们自己的封装 ML 模型在 REST API 中。这将很好地补充本章中关于可以用于扩展的高级抽象的工作,特别是在“启动无服务器基础设施”部分。我们在这里只会简要提及 K8s 和 Kubeflow,以确保你了解这些工具以供探索。有关 K8s 和 Kubeflow 的更多详细信息,请参阅文档。我还推荐另一本 Packt 出版的书籍,名为Aly Saleh和Murat Karslioglu的《Kubernetes in Production Best Practices》。
现在,我们将继续讨论另一个非常强大的用于扩展计算密集型 Python 工作负载的工具包,它现在在机器学习工程社区中变得极为流行,并被 Uber、Amazon 等组织以及 OpenAI 用于训练其大型语言生成预训练变换器(GPT)模型,我们将在第七章深度学习、生成式 AI 和 LLMOps中详细讨论。让我们来认识Ray。
使用 Ray 进行扩展
Ray 是一个专为帮助机器学习工程师满足大规模数据和大规模可扩展机器学习系统需求而设计的 Python 原生分布式计算框架。Ray 有一个使可扩展计算对每个机器学习开发者都可用,并且以抽象出与底层基础设施的所有交互的方式来运行在任何地方的理念。Ray 的独特特性之一是它有一个分布式调度器,而不是像 Spark 那样在中央进程中运行的调度器或 DAG 创建机制。从其核心来看,Ray 从一开始就考虑了计算密集型任务,如机器学习模型训练,这与以数据密集型为目标的 Apache Spark 略有不同。因此,你可以这样简单地思考:如果你需要多次处理大量数据,那么选择 Spark;如果你需要多次处理同一份数据,那么 Ray 可能更合适。这只是一个经验法则,不应严格遵循,但希望它能给你一个有用的指导原则。例如,如果你需要在大型批量处理中转换数百万行数据,那么使用 Spark 是有意义的,但如果你想在同一数据上训练机器学习模型,包括超参数调整,那么 Ray 可能更有意义。
这两个工具可以非常有效地一起使用,Spark 在将特征集转换后,将其输入到用于机器学习训练的 Ray 工作负载中。这特别由Ray AI Runtime(AIR)负责,它提供了一系列不同的库来帮助扩展机器学习解决方案的不同部分。这些包括:
-
Ray Data: 专注于提供数据预处理和转换原语。
-
Ray Train: 促进大型模型训练。
-
Ray Tune: 帮助进行可扩展的超参数训练。
-
Ray RLib: 支持强化学习模型开发的方法。
-
Ray Batch Predictor: 用于批量推理。
-
Ray Serving: 用于实时推理。
AIR 框架提供了一个统一的 API,通过它可以与所有这些功能进行交互,并且很好地集成了你将习惯使用的以及我们在本书中利用的大量标准机器学习生态系统。
图 6.16:来自 Anyscale 的 Jules Damji 的演示中的 Ray AI 运行时,来自:microsites.databricks.com/sites/defau…
图 6.17:包括 Raylet 调度器的 Ray 架构。来自 Jules Damji 的演示:microsites.databricks.com/sites/defau…
Ray 核心 API 有一系列不同的对象,当你在使用 Ray 时可以利用这些对象来分发你的解决方案。首先是任务,这是系统要执行的工作的异步项。为了定义一个任务,你可以使用一个 Python 函数,例如:
def add(int: x, int: y) -> int:
return x+y
然后添加@remote装饰器,然后使用.remote()语法来将此任务提交到集群。这不是一个阻塞函数,所以它将只返回一个 ID,Ray 将使用该 ID 在后续的计算步骤中引用任务(www.youtube.com/live/XME90SGL6Vs?feature=share&t=832):
import ray
@remote
def add(int: x, int: y) -> int:
return x+y
add.remote()
同样,Ray API 可以将相同的概念扩展到类中;在这种情况下,这些被称为Actors:
import ray
@ray.remote
class Counter(object):
def __init__(self):
self.value = 0
def increment(self):
self.value += 1
return self.value
def get_counter(self):
return self.value
# Create an actor from this class.
counter = Counter.remote()
最后,Ray 还有一个分布式不可变对象存储。这是一种智能的方法,可以在集群的所有节点之间共享一个数据存储,而不需要移动大量数据并消耗带宽。你可以使用以下语法向对象存储写入:
import ray
numerical_array = np.arange(1,10e7)
obj_numerical_array = ray.put(numerical_array)
new_numerical_array = 0.5*ray.get(obj_numerical_array)
重要提示
在这个语境中,一个 Actor 是一个服务或具有状态的工作者,这是一个在其他分布式框架(如 Akka)中使用的概念,它运行在 JVM 上,并且有 Java 和 Scala 的绑定。
Ray 用于机器学习的入门
要开始,你可以通过运行以下命令安装带有 AI 运行时的 Ray,以及一些超参数优化包、中央仪表板和 Ray 增强的 XGBoost 实现:
pip install "ray[air, tune, dashboard]"
pip install xgboost
pip install xgboost_ray
重要提示
这里提醒一下,当你在这本书中看到pip install时,你还可以使用在第四章“打包”中概述的 Poetry。因此,在这种情况下,在运行poetry new project_name:之后,你会得到以下命令:
poetry add "ray[air, tune, dashboard]"
poetry add xgboost
poetry add pytorch
让我们从查看 Ray Train 开始,它提供了一系列Trainer对象的 API,有助于简化分布式训练。在撰写本文时,Ray 2.3.0 支持跨各种不同框架的培训师,包括:
-
深度学习:Horovod、Tensorflow 和 PyTorch。
-
基于树的:LightGBM 和 XGBoost。
-
其他:Scikit-learn、HuggingFace 和 Ray 的强化学习库 RLlib。
图 6.18:如 Ray 文档中所示(docs.ray.io/en/latest/t… Ray 训练器。
我们首先将查看一个基于树的 XGBoost 学习器示例。打开一个脚本并开始向其中添加内容;在仓库中,这个脚本被称为getting_started_with_ray.py。以下内容基于 Ray 文档中给出的一个入门示例。首先,我们可以使用 Ray 下载标准数据集之一;如果我们想的话,我们也可以使用sklearn.datasets或其他来源,就像我们在本书的其他地方所做的那样:
import ray
dataset = ray.data.read_csv("s3://anonymous@air-example-data/breast_
cancer.csv")
train_dataset, valid_dataset = dataset.train_test_split(test_size=0.3)
test_dataset = valid_dataset.drop_columns(cols=["target"])
注意,在这里我们使用ray.data.read_csv()方法,该方法返回一个PyArrow数据集。Ray API 有从其他数据格式读取的方法,例如 JSON 或 Parquet,以及从数据库如 MongoDB 或您自己的自定义数据源读取。
接下来,我们将定义一个预处理步骤,该步骤将标准化我们想要使用的特征;有关特征工程的信息,您可以查看第三章,从模型到模型工厂:
from ray.data.preprocessors import StandardScaler
preprocessor = StandardScaler(columns=["mean radius", "mean texture"])
然后是定义 XGBoost 模型的Trainer对象的有趣部分。这有几个不同的参数和输入,我们将在稍后定义:
from ray.air.config import ScalingConfig
from ray.train.xgboost import XGBoostTrainer
trainer = XGBoostTrainer(
scaling_config=ScalingConfig(...),
label_column="target",
num_boost_round=20,
params={...},
datasets={"train": train_dataset, "valid": valid_dataset},
preprocessor=preprocessor,
)
result = trainer.fit()
如果您在 Jupyter 笔记本或 Python 脚本中运行此代码,您将看到类似于图 6.19所示的输出。
图 6.19:使用 Ray 并行训练 XGBoost 模型的输出。
result对象包含大量有用的信息;它的一个属性称为metrics,您可以打印出来以揭示运行结束状态的相关细节。执行print(result.metrics),您将看到如下内容:
{'train-logloss': 0.01849572773292735,
'train-error': 0.0, 'valid-logloss': 0.089797893552767,
'valid-error': 0.04117647058823529,
'time_this_iter_s': 0.019704103469848633,
'should_checkpoint': True,
'done': True,
'timesteps_total': None,
'episodes_total': None,
'training_iteration': 21,
'trial_id': '6ecab_00000',
'experiment_id': '2df66fa1a6b14717bed8b31470d386d4',
'date': '2023-03-14_20-33-17',
'timestamp': 1678825997,
'time_total_s': 6.222438812255859,
'pid': 1713,
'hostname': 'Andrews-MacBook-Pro.local',
'node_ip': '127.0.0.1',
'config': {},
'time_since_restore': 6.222438812255859,
'timesteps_since_restore': 0,
'iterations_since_restore': 21,
'warmup_time': 0.003551006317138672, 'experiment_tag': '0'}
在XGBoostTrainer的实例化中,我们定义了一些在先前的示例中省略的重要缩放信息;如下所示:
scaling_config=ScalingConfig(
num_workers=2,
use_gpu=False,
_max_cpu_fraction_per_node=0.9,
)
num_workers参数告诉 Ray 启动多少个 actor,默认情况下每个 actor 分配一个 CPU。由于我们在这里没有使用 GPU 加速,所以将use_gpu标志设置为 false。最后,通过将_max_cpu_fraction_per_node参数设置为0.9,我们在每个 CPU 上留下了一些备用容量,这些容量可以用于其他操作。
在上一个示例中,我们还提供了一些 XGBoost 特定的参数:
params={
"objective": "binary:logistic",
"eval_metric": ["logloss", "error"],
}
如果您想为 XGBoost 训练使用 GPU 加速,您可以在params字典中添加一个键值对tree_method: gpu_hist。
图 6.20:几个实验展示了在作者的笔记本电脑(一台 8 核心的 Macbook Pro)上,改变每个 worker 可用的 worker 数量和 CPU 数量如何导致 XGBoost 训练时间不同。
现在,我们将简要讨论如何在除本地机器以外的环境中使用 Ray 扩展计算。
为 Ray 扩展计算能力
我们迄今为止看到的示例使用的是本地 Ray 集群,该集群在第一次调用 Ray API 时自动设置。这个本地集群抓取您机器上所有可用的 CPU 并使其可用于执行工作。显然,这只能让您走这么远。下一个阶段是与可以扩展到更多可用工作者的集群一起工作,以获得更多的加速。如果您想这样做,您有几个选择:
-
在云上:Ray 提供了部署到 Google Cloud Platform 和 AWS 资源的能力,Azure 部署由社区维护的解决方案处理。有关在 AWS 上部署和运行 Ray 的更多信息,您可以查看其在线文档。
-
使用 Kubernetes:我们在 第五章,部署模式和工具 中已经遇到了 Kubeflow,它用于构建支持 Kubernetes 的 ML 管道。在本章的“在规模上容器化 Kubernetes”部分中,我们也讨论了 Kubernetes。如前所述,Kubernetes 是一个容器编排工具包,旨在基于容器创建可大规模扩展的解决方案。如果您想在 Kubernetes 上使用 Ray,可以使用 KubeRay 项目,
ray-project.github.io/kuberay/。
在云或 Kubernetes 上设置 Ray 主要涉及定义集群配置及其扩展行为。一旦完成这些操作,Ray 的美妙之处在于扩展您的解决方案就像编辑我们在上一个示例中使用的 ScalingConfig 对象一样简单,并且您可以保持所有其他代码不变。例如,如果您有一个 20 节点的 CPU 集群,您可以简单地将其定义更改为以下内容,并像以前一样运行:
scaling_config=ScalingConfig(
num_workers=20,
use_gpu=False,
_max_cpu_fraction_per_node=0.9,
)
使用 Ray 扩展您的服务层
我们已经讨论了您可以使用 Ray 来使用分布式 ML 训练作业的方法,但现在让我们看看您如何使用 Ray 来帮助您扩展应用程序层。如前所述,Ray AIR 提供了一些在 Ray Serve 下的良好功能。
Ray Serve 是一个框架无关的库,它帮助您轻松地根据您的模型定义 ML 端点。就像我们与之交互的 Ray API 的其余部分一样,它被构建为提供易于互操作性和访问扩展,而无需大量的开发开销。
基于前几节提供的示例,让我们假设我们已经训练了一个模型,并将其存储在我们的适当注册表中,例如 MLflow,并且我们已经检索了这个模型并将其保存在内存中。
在 Ray Serve 中,我们通过使用 @ray.serve.deployments 装饰器来创建部署。这些部署包含我们希望用于处理传入 API 请求的逻辑,包括通过我们构建的任何机器学习模型。例如,让我们构建一个简单的包装类,它使用与上一个示例中我们使用的类似的 XGBoost 模型,根据通过请求对象传入的一些预处理特征数据来进行预测。首先,Ray 文档鼓励使用 Starlette 请求库:
from starlette.requests import Request
import ray
from ray import serve
接下来,我们可以定义一个简单的类,并使用 serve 装饰器来定义服务。我将假设从 MLflow 或任何其他模型存储位置提取的逻辑被封装在以下代码块中的实用函数 get_model 中:
@serve.deployment
class Classifier:
def __init__(self):
self.model = get_model()
async def __call__(self, http_request: Request) -> str:
request_payload = await http_request.json()
input_vector = [
request_payload["mean_radius"],
request_payload["mean_texture"]
]
classification = self.model.predict([input_vector])[0]
return {"result": classification}
然后,您可以将此部署到现有的 Ray 集群中。
这就结束了我们对 Ray 的介绍。我们现在将结束于对设计大规模系统的最终讨论,然后是对我们所学到的一切的总结。
设计大规模系统
为了在第五章,“部署模式和工具”以及本章中提出的思想的基础上进行扩展,我们现在应该考虑一些方法,这些方法可以让我们在机器学习工程项目中最大限度地发挥我们讨论过的扩展能力。
扩展的整体思想应该从提供分析或推理吞吐量的增加或可以处理的数据的最终大小增加的角度来考虑。在大多数情况下,您可以开发的分析或解决方案的类型没有真正的区别。这意味着成功应用扩展工具和技术更多地取决于选择将从中受益的正确流程,即使包括使用这些工具带来的任何开销。这就是我们现在在本节中要讨论的,以便您在做出自己的扩展决策时有一些指导原则。
如本书中多处所述,您为机器学习项目开发的管道通常需要包含以下任务的一些阶段:
-
数据摄取/预处理
-
特征工程(如果与上述不同)
-
模型训练
-
模型推理
-
应用层
并行化或分布式处理可以在许多步骤中提供帮助,但通常以不同的方式。对于摄取/预处理,如果你在一个大型的预定批量设置中操作,那么以分布式方式扩展到更大的数据集将带来巨大的好处。在这种情况下,使用 Apache Spark 是有意义的。对于特征工程,同样,主要瓶颈在于我们执行转换时一次性处理大量数据,因此 Spark 对于这一点也是有用的。我们在第三章,“从模型到模型工厂”中详细讨论的训练机器学习模型的计算密集型步骤,非常适合用于这种密集计算的框架,无论数据大小如何。这就是 Ray 在前面章节中发挥作用的地方。Ray 意味着你也可以整洁地并行化你的超参数调整,如果你也需要这样做的话。请注意,你可以在 Spark 中运行这些步骤,但 Ray 的低任务开销和其分布式状态管理意味着它特别适合分割这些计算密集型任务。另一方面,Spark 具有集中的状态和调度管理。最后,当涉及到推理和应用层,即我们产生和展示机器学习模型结果的地方,我们需要考虑特定用例的需求。例如,如果你想将你的模型作为 REST API 端点提供服务,我们在上一节中展示了 Ray 的分布式模型和 API 如何帮助非常容易地实现这一点,但在 Spark 中这样做是没有意义的。然而,如果模型结果需要以大量批次的形式生成,那么 Spark 或 Ray 可能是合适的。此外,正如在特征工程和摄取步骤中提到的,如果最终结果也需要在大批量中进行转换,例如转换成特定的数据模型,如星型模式,那么由于这个任务的数据规模要求,在 Spark 中执行这种转换可能是合理的。
让我们通过考虑一个来自行业的潜在示例来使这个问题更加具体。许多具有零售元素的机构将分析交易和客户数据,以确定客户是否可能流失。让我们探讨一些我们可以做出的决策,以设计和开发这个解决方案,特别关注使用我们在本章中介绍的工具和技术进行扩展的问题。
首先,我们有数据摄取。对于这种情况,我们将假设客户数据,包括与不同应用程序和系统的交互,在业务日结束时处理,数量达到数百万条记录。这些数据包含数值和分类值,并且需要经过处理才能输入到下游机器学习算法中。如果数据按日期分区,或者数据的一些其他特征,那么这非常自然地适用于 Spark 的使用,因为你可以将其读入 Spark DataFrame,并使用分区来并行化数据处理步骤。
接下来,我们讨论特征工程。如果在第一步中使用 Spark DataFrame,那么我们可以使用本章前面讨论的基础 PySpark 语法来应用我们的转换逻辑。例如,如果我们想应用来自 Scikit-Learn 或其他机器学习库的一些特征转换,我们可以将这些转换封装在 UDFs 中,并在所需的规模上应用。然后,我们可以使用 PySpark API 将数据导出为我们选择的数据格式。对于客户流失模型,这可能意味着对分类变量的编码和对数值变量的缩放,这与在 第三章,从模型到模型工厂 中探讨的技术一致。
转向模型的训练,我们现在正从数据密集型任务转向计算密集型任务。这意味着自然地开始使用 Ray 进行模型训练,因为你可以轻松设置并行任务来训练具有不同超参数设置的模型,并分配训练步骤。使用 Ray 进行深度学习或基于树的模型训练有特定的好处,因为这些算法易于并行化。所以,如果我们使用 Spark ML 中可用的模型之一进行分类,这可以在几行代码内完成,但如果我们使用其他东西,我们可能需要开始封装 UDFs。Ray 对库的依赖性更少,但再次,真正的好处来自于我们使用 PyTorch 或 TensorFlow 中的神经网络,或者使用 XGBoost 或 LightGBM,因为这些更自然地并行化。
最后,我们来看模型推理步骤。在批量设置中,关于这里建议的框架,谁是赢家并不那么明确。使用 UDFs 或 PySpark 核心 API,你可以轻松地使用 Apache Spark 和你的 Spark 集群创建一个相当可扩展的批量预测阶段。这主要是因为在大批量上的预测实际上只是另一种大规模数据转换,而 Spark 在这方面表现卓越。然而,如果你希望将你的模型作为一个可以跨集群扩展的端点提供服务,那么正如使用 Ray 扩展你的服务层部分所示,Ray 提供了非常易于使用的功能。Spark 没有创建这种端点的功能,并且启动 Spark 作业所需的调度和任务开销意味着,对于像这种作为请求传入的小数据包,运行 Spark 可能并不值得。
对于客户流失的例子,这可能意味着如果我们想在整个客户基础上进行流失分类,Spark 提供了一个很好的方式来处理所有这些数据,并利用像底层数据分区这样的概念。你仍然可以在 Ray 中这样做,但较低级别的 API 可能意味着这需要更多的工作。请注意,我们可以使用许多其他机制来创建这个服务层,如第五章、部署模式和工具以及本章中关于启动无服务器基础设施的部分所述。第八章、构建示例 ML 微服务也将详细说明如何使用 Kubernetes 扩展 ML 端点的部署。
最后,我将最后一个阶段称为应用层,以涵盖解决方案中输出系统与下游系统之间的任何“最后一公里”集成。在这种情况下,Spark 实际上并没有扮演什么角色,因为它实际上可以被视为一个大规模数据转换引擎。另一方面,Ray 则更侧重于通用的 Python 加速哲学,所以如果你的应用程序后端有任务可以从并行化中受益,比如数据检索、一般计算、模拟或其他一些过程,那么你仍然可以在某种程度上使用 Ray,尽管可能还有其他可用的工具。因此,在客户流失的例子中,Ray 可以用于在服务结果之前对单个客户进行分析,并在Ray Serve端点并行执行此操作。
通过这个高级示例,我们的目的是突出你在机器学习工程项目中可以做出关于如何有效扩展的选择的点。我常说,通常没有“正确答案”,但往往有很多“错误答案”。我的意思是,通常有几种构建良好解决方案的方法都是同样有效的,并且可能利用不同的工具。重要的是要避免最大的陷阱和死胡同。希望这个例子能给你一些关于如何将这种思考应用到扩展你的机器学习解决方案的启示。
重要提示
尽管我在这里提出了很多关于 Spark 与 Ray 的问题,并提到了 Kubernetes 作为更基础的扩展基础设施选项,但现在有了通过使用RayDP结合 Spark 和 Ray 的能力。这个工具包现在允许你在 Ray 集群上运行 Spark 作业,这样你就可以继续使用 Ray 作为你的基础扩展层,同时利用 Spark 的 API 和功能,这些功能是 Spark 擅长的。RayDP 于 2021 年推出,目前正在积极开发中,因此这绝对是一个值得关注的功能。更多信息,请参阅项目仓库:github.com/oap-project/raydp。
这就结束了我们对如何开始将我们讨论的一些扩展技术应用到我们的机器学习用例中的探讨。
现在我们将本章的内容以简要总结结束,总结我们在过去几页中涵盖的内容。
摘要
在本章中,我们探讨了如何将我们在过去几章中构建的机器学习解决方案进行扩展,以适应更大的数据量或更高的预测请求数量。为此,我们主要关注了Apache Spark,因为它是分布式计算中最受欢迎的通用引擎。在讨论 Apache Spark 的过程中,我们回顾了在此书中之前使用的一些编码模式和语法。通过这样做,我们更深入地理解了在 PySpark 开发中如何以及为什么进行某些操作。我们详细讨论了**UDFs(用户定义函数)**的概念,以及如何使用这些函数创建可大规模扩展的机器学习工作流程。
之后,我们探讨了如何在云上使用 Spark,特别是通过 AWS 提供的EMR服务。然后,我们查看了一些其他可以扩展我们解决方案的方法;即,通过无服务器架构和容器化的水平扩展。在前一种情况下,我们介绍了如何使用AWS Lambda构建一个用于服务机器学习模型的服务。这使用了 AWS SAM 框架提供的标准模板。我们提供了如何使用 K8s 和 Kubeflow 水平扩展机器学习管道的高级视图,以及使用这些工具的一些其他好处。随后,我们介绍了一个关于 Ray 并行计算框架的部分,展示了如何使用其相对简单的 API 在异构集群上扩展计算,以加速你的机器学习工作流程。Ray 现在是 Python 最重要的可扩展计算工具包之一,并被用于训练地球上的一些最大的模型,包括 OpenAI 的 GPT-4 模型。
在下一章中,我们将通过讨论你可以构建的最大机器学习模型来扩展这里的规模概念:深度学习模型,包括大型语言模型(LLMs)。我们将在下一章中讨论的所有内容,都只能通过考虑我们在这里介绍的技术来开发和有效利用。在第八章,构建一个示例机器学习微服务中,我们也将重新审视扩展你的机器学习解决方案的问题,我们将重点关注使用 Kubernetes 水平扩展机器学习微服务。这很好地补充了我们在这里通过展示如何扩展更多实时工作负载来扩展大型批量工作负载的工作。此外,在第九章,构建一个提取、转换、机器学习用例中,我们在这里讨论的许多扩展讨论都是先决条件;因此,我们在这里介绍的所有内容都将为你从本书的其余部分中获得最大收益奠定良好的基础。因此,带着所有这些新知识,让我们去探索已知最大模型的领域。
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:
第七章:深度学习、生成式 AI 和 LLMOps
世界正在快速变化。截至 2023 年中期,机器学习(ML)和人工智能(AI)以一种甚至几个月前看起来不可能的方式进入了公众意识。随着 2022 年底 ChatGPT 的推出,以及来自世界各地实验室和组织的全新工具的涌现,数亿人现在每天都在使用 ML 解决方案来创造、分析和开发。除此之外,创新似乎正在加速,每天都有新的记录打破模型或新工具的宣布。ChatGPT 只是使用现在所知的生成式人工智能(生成 AI 或 GenAI)的解决方案的一个例子。虽然 ChatGPT、Bing AI 和 Google Bard 是文本生成 AI 工具的例子,但在图像空间中还有 DALL-E 和 Midjourney,现在还有一系列结合这些和其他类型数据的跨模态模型。鉴于正在演变的生态系统和全球领先 AI 实验室正在开发的模型,很容易感到不知所措。但不必担心,因为这一章完全是关于回答“这对作为新兴 ML 工程师的我意味着什么?”这个问题。
在本章中,我们将采取与本书其他章节相同的策略,专注于核心概念,并构建您可以在未来的项目中使用多年的坚实基础。我们将从自 2010 年代以来一直是许多 ML 前沿发展核心的基本算法方法开始,对深度学习进行回顾。然后,我们将讨论您如何构建和托管自己的深度学习模型,然后过渡到 GenAI,在那里我们将探讨一般格局,然后深入探讨 ChatGPT 和其他强大文本模型背后的方法,大型语言模型(LLMs)。
然后,我们将顺利过渡到探索如何将机器学习工程和 MLOps 应用于 LLMs,包括讨论这带来的新挑战。这是一个如此新的领域,我们将在本章中讨论的大部分内容将反映我在写作时的观点和理解。作为一个机器学习社区,我们目前正在开始定义这些模型的最佳实践意味着什么,所以我们将在这接下来的几页中共同为这个勇敢的新世界做出贡献。我希望你们享受这次旅程!
我们将在以下章节中涵盖所有这些内容:
-
深入学习深度学习
-
使用 LLMs 进行大规模开发
-
使用 LLMOps 构建未来
深入学习深度学习
在这本书中,我们迄今为止一直使用相对“经典”的机器学习模型,这些模型依赖于各种不同的数学和统计方法来从数据中学习。这些算法在一般情况下并不是基于任何学习生物学理论,其核心动机是找到不同的方法来显式优化损失函数。读者可能已经了解的一个稍微不同的方法,我们在第三章“从模型到模型工厂”中关于学习学习的部分简要介绍过,那就是人工神经网络(ANNs)所采用的方法,它起源于 20 世纪 50 年代,并基于大脑中神经元活动的理想化模型。人工神经网络的核心概念是通过连接相对简单的计算单元,称为神经元或节点(基于生物神经元建模),我们可以构建能够有效模拟任何数学函数的系统(下方的信息框中提供了更多细节)。在这个案例中,神经元是系统的一个小组成部分,它将根据输入以及使用某些预先确定的数学公式对输入进行转换来返回输出。它们本质上是非线性的,当它们组合在一起时,可以非常快速地开始模拟相当复杂的数据。人工神经元可以被认为是按层排列的,其中一层的神经元与下一层的神经元相连。在具有不多神经元和不多层的较小神经网络层面,我们在这本书中讨论的许多关于重新训练和漂移检测的技术仍然适用,无需修改。当我们达到具有许多层和神经元的所谓深度神经网络(DNNs)时,我们必须考虑一些额外的概念,这些概念我们将在本节中介绍。
神经网络能够表示各种各样函数的能力,在所谓的万能逼近定理中有着理论基础。这些是严格的数学结果,证明了多层神经网络可以逼近数学函数类,达到任意精度的近似。这些结果并没有说明哪些具体的神经网络会做到这一点,但它们告诉我们,只要有足够的隐藏神经元或节点,我们就可以确信,只要有足够的数据,我们应当能够表示我们的目标函数。这些定理中一些最重要的结果是在 20 世纪 80 年代末通过像Hornik, K., Stinchcombe, M. and White, H. (1989) “Multilayer feedforward networks are universal approximators”, Neural Networks, 2(5), pp. 359–366和Cybenko, G. (1989) “Approximation by superpositions of a sigmoidal function”, Mathematics of Control, Signals, and Systems, 2(4), pp. 303–314这样的论文中确立的。
在过去几年中,深度神经网络(DNNs)风靡全球。从计算机视觉到自然语言处理,从 StableDiffusion 到 ChatGPT,现在有无数令人惊叹的例子表明 DNNs 正在做以前被认为是人类专属的事情。深度学习模型的深入数学细节在其他许多文献中都有涉及,例如 Goodfellow、Bengio、Courville 的经典著作《深度学习》,由麻省理工学院出版社于 2016 年出版,我们在这里无法充分展示。尽管详细的理论超出了本章的范围,但我将尝试提供一个概述,包括你需要了解的主要概念和技术,以便你能够具备良好的工作知识,并能够开始在你的机器学习工程项目中使用这些模型。
正如所述,人工神经网络基于从生物学中借鉴的思想,就像在生物大脑中一样,ANN 由许多单个神经元组成。神经元可以被视为在 ANN 中提供计算单元。神经元通过接收多个输入并将它们按照特定的配方组合起来以产生单个输出来工作,这个输出可以随后作为另一个神经元的输入或作为整体模型输出的部分。在生物环境中,神经元的输入沿着树突流动,输出则沿着轴突传导。
但输入是如何转换为输出的呢?我们需要将几个概念结合起来才能理解这个过程。
-
权重:网络中每个神经元之间的连接都分配了一个数值,这个数值可以被视为连接的“强度”。在神经网络训练过程中,权重是用于最小化损失的一组值之一。这与第三章中提供的模型训练解释相一致,即从模型到模型工厂。
-
偏差:网络中的每个神经元都给定了一个额外的参数,该参数作为激活(以下定义)的偏移量。这个数值在训练过程中也会更新,它为神经网络提供了更多的自由度来拟合数据。你可以将偏差视为改变神经元“放电”(或产生特定输出)的水平,因此作为变量值意味着神经元有更多的适应性。
-
输入:这些可以被视为在考虑权重或偏差之前馈送到神经元的原始数据点。如果神经元是根据数据提供特征,则输入是特征值;如果神经元是接收来自其他神经元的输出,那么这些就是那种情况下的值。
-
激活:ANN 中的神经元接收多个输入;激活是输入的线性组合,乘以适当的权重加上偏差项。这把多块传入数据转换成一个单一的数值,然后可以用来确定神经元的输出应该是什么。
-
激活函数:激活只是一个数字,但激活函数是我们决定这个数字对神经元意味着什么的方式。目前深度学习中非常流行的激活函数有很多,但重要的特征是,当这个函数作用于激活值时,它产生一个数字,这是神经元或节点的输出。
这些概念在图 7.1中以图表形式呈现。深度学习模型没有严格的定义,但就我们的目的而言,一旦一个人工神经网络(ANN)由三个或更多层组成,我们就可以认为它是深层的。这意味着我们必须定义这些层的一些重要特征,我们现在就来做这件事:
-
输入层:这是第一个神经元层,其输入是原始数据或从数据中创建的预处理特征。
-
隐藏层:这些是输入层和输出层之间的层,可以认为是在这里执行数据的主要非线性变换。这通常是因为有很多隐藏层和神经元!隐藏层中神经元的组织和连接是神经网络架构的关键部分。
-
输出层:输出层负责将神经网络中执行过的变换的结果转换为可以适当解释的结果。例如,如果我们使用神经网络来分类图像,我们需要最终层输出指定类别的 1 或 0,或者我们可以让它输出不同类别的概率序列。
这些概念是有用的背景知识,但我们在 Python 中如何开始使用它们呢?世界上两个最受欢迎的深度学习框架是 Tensorflow,由谷歌大脑在 2015 年发布,以及 PyTorch,由 Meta AI 在 2016 年发布。在本章中,我们将专注于使用 PyTorch 的示例,但许多概念在经过一些修改后同样适用于 TensorFlow。
图 7.1:人工神经网络(ANN)中“神经元”的示意图以及它如何接收输入数据 x 并将其转换为输出 y。
开始使用 PyTorch
首先,如果你还没有安装 PyTorch,你可以通过遵循pytorch.org/get-started/locally/上的 PyTorch 文档来安装,用于在 Macbook 上本地安装,或者使用:
pip3 install torch
在使用 PyTorch 时,有一些重要的概念和特性是值得记住的:
-
torch.Tensor:张量是可以通过多维数组表示的数学对象,并且是任何现代深度学习框架的核心组件。我们输入网络的数据应该被转换为张量,例如:inputs = torch.tensor(X_train, dtype=torch.float32) labels = torch.tensor(y_train, dtype=torch.long) -
torch.nn: 这是定义我们的神经网络模型所使用的主要模块。例如,我们可以使用它来定义一个包含三个隐藏层的基本分类神经网络,每个隐藏层都有一个修正线性单元(ReLU)激活函数。当使用这种方法在 PyTorch 中定义模型时,你还应该编写一个名为forward的方法,该方法定义了在训练过程中数据如何通过网络。以下代码展示了如何在继承自torch.nn.Module对象的类中构建一个基本神经网络。这个网络有四个线性层,每个层都有 ReLU 激活函数,以及一个简单的正向传递函数:import torch import torch.nn as nn class NeuralNetwork(nn.Module): def __init__(self): super(NeuralNetwork, self).__init__() self.sequential = nn.Sequential( nn.Linear(13, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 16), nn.ReLU(), nn.Linear(16, 3) ) def forward(self, x): x = self.sequential(x) return x -
损失函数: 在
torch.nn模块中,有一系列损失函数可用于训练网络。一个流行的选择是交叉熵损失,但在文档中还有更多可供选择:criterion = nn.CrossEntropyLoss() -
torch.optim.Optimizer: 这是 PyTorch 中所有优化器的基类。这允许实现第三章,“从模型到模型工厂”中讨论的大多数优化器。在 PyTorch 中定义优化器时,在大多数情况下,你需要传入实例化模型的参数以及特定优化器的相关参数。例如,如果我们定义一个学习率为
0.001的 Adam 优化器,这就像这样简单:import torch.optim as optim model = NeuralNetwork() optimizer = torch.optim.Adam( model.parameters(), lr=0.001 ) -
torch.autograd: 回想一下,训练一个机器学习模型实际上是一个利用线性代数、微积分和一些统计学的优化过程。PyTorch 通过使用自动微分来执行模型优化,这是一种将函数的偏导数求解问题转化为一系列易于计算的原语应用的方法,尽管如此,它仍然能够以良好的精度计算微分。这不同于有限差分法或符号微分法。你可以通过使用损失函数并调用backward方法来隐式地调用它,该方法使用 autograd 来计算每个 epoch 中权重更新的梯度;然后通过调用optimizer.step()在优化器中使用这些梯度。在训练过程中,重置任何输入张量是很重要的,因为在 PyTorch 中张量是可变的(操作会改变其数据),同样,使用optimizer.zero_grad()重置优化器中计算的任何梯度也很重要。基于此,一个包含五百个 epoch 的示例训练运行如下:for epoch in range(500): running_loss = 0.0 optimizer.zero_grad() inputs = torch.tensor(X_train, dtype=torch.float32) labels = torch.tensor(y_train, dtype=torch.long) outputs = net(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() -
torch.save和torch.load: 你可能可以从它们的名字中猜出这些方法的作用!但仍然重要的是要展示如何保存和加载你的 PyTorch 模型。在训练深度学习模型时,在训练过程中定期保存模型也很重要,因为这通常需要很长时间。这被称为“检查点”,意味着如果在训练运行中出现任何问题,你可以从上次停止的地方继续。为了保存 PyTorch 检查点,我们可以在训练循环中添加如下语法:model_path = "path/to/model/my_model.pt" torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'loss': loss, }, model_path) -
要加载模型,你需要初始化你的神经网络类和优化器对象的另一个实例,然后从
checkpoint对象中读取它们的状态:model = NeuralNetwork() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) checkpoint = torch.load(model_path) model.load_state_dict(checkpoint['model_state_dict']) optimizer.load_state_dict(checkpoint['optimizer_state_dict']) epoch = checkpoint['epoch'] loss = checkpoint['loss'] -
model.eval()和model.train(): 一旦你加载了 PyTorch 的检查点,你需要将模型设置为执行任务所需的适当模式,否则可能会出现下游问题。例如,如果你想进行测试和验证,或者你想使用你的模型对新数据进行推理,那么在使用模型之前,你需要调用model.eval()。这将冻结任何包含的批归一化或 dropout 层,因为它们在训练期间计算统计数据和执行更新,而这些更新在测试期间你不希望是活跃的。同样,model.train()确保这些层在训练运行期间可以继续按预期执行更新。应该注意的是,有一种比
model.eval()更极端的设置,你可以使用以下语法完全关闭你上下文中的任何 autograd 功能:with torch.inference_mode():这可以在推理时提供额外的性能,但应该只在确定你不需要任何梯度或张量更新跟踪或执行时使用。
-
评估:如果你想测试上面示例中我们刚刚训练的模型,你可以使用类似以下语法计算准确率,但本书中讨论的任何模型验证方法都适用!
inputs = torch.tensor(X_test, dtype=torch.float32) labels = torch.tensor(y_test, dtype=torch.long) outputs = net(inputs) _, predicted = torch.max(outputs.data, 1) correct = (predicted == labels).sum().item() total = labels.size(0) accuracy = correct / total print('Accuracy on the test set: %.2f %%' % (100 * accuracy))
有了这些,你现在可以构建、训练、保存、加载和评估你的第一个 PyTorch 模型。我们将现在讨论如何通过考虑将深度学习模型投入生产的挑战来进一步扩展。
规模化和将深度学习投入生产
现在我们将转向如何在生产系统中运行深度学习模型。为此,我们需要考虑一些特定的点,这些点将 DNN 与其他经典机器学习算法区分开来:
-
它们是数据饥渴的:与其他机器学习算法相比,DNN 通常需要相对大量的数据,这是因为它们正在执行极其复杂的多元优化,每个神经元的参数增加了自由度。这意味着为了从头开始训练 DNN,你必须提前做一些工作,确保你有足够的数据,并且数据种类适合充分训练模型。数据需求通常还意味着你需要能够将大量数据存储在内存中,因此这通常需要提前考虑。
-
训练更加复杂:这一点与上面提到的内容相关,但有所不同。我们正在解决的非常复杂的非线性优化问题意味着在训练过程中,模型往往有多种方式“迷失方向”并达到次优局部最小值。正如我们在上一节中描述的checkpointing示例,在深度学习社区中这些技术非常普遍,因为你经常需要在损失没有朝着正确的方向移动或停滞不前时停止训练,回滚,并尝试不同的方法。
-
你面临一个新的选择,即模型架构:深度神经网络(DNNs)也与经典机器学习算法有很大不同,因为你现在不仅需要担心几个超参数,还需要决定你的神经网络架构或形状。这通常是一项非同小可的练习,可能需要深入了解神经网络。即使你使用的是标准的架构,如 Transformer 架构(见图 7.2),你也应该对所有组件的功能有一个稳固的理解,以便有效地诊断和解决任何问题。正如在第三章“关于学习的知识”部分讨论的自动架构搜索等技术可以帮助加快架构设计,但坚实的知识基础仍然很重要。
-
可解释性固有的更难:过去几年中,针对深度神经网络(DNNs)的一个批评是,其结果可能非常难以解释。这是可以预料的,因为重点确实在于 DNN 将任何问题的许多具体细节抽象化成一个更抽象的方法。这在许多情况下可能没问题,但现在已导致几个高调案例,DNNs 表现出不希望的行为,如种族或性别偏见,这可能导致更难解释和补救。在高度监管的行业,如医疗保健或金融,你的组织可能负有法律义务能够证明为什么做出了特定的决定。如果你使用 DNN 来帮助做出这个决定,这通常会相当具有挑战性。
图 7.2:Transformer 架构如图所示,最初在谷歌大脑发表的论文“Attention is all you need”中提出,arxiv.org/abs/1706.03…
考虑到所有这些,我们在使用深度学习模型为我们的机器学习系统时应该考虑哪些主要事项呢?嗯,你可以做的第一件事是使用现有的预训练模型,而不是自己训练。这显然带来了一些风险,即确保模型及其提供的数据对你的应用来说是足够高质量的,所以总是要谨慎行事,并做好你的尽职调查。
然而,在许多情况下,这种方法绝对是可行的,因为我们可能正在使用一个以相当公开的方式经过测试的模型,并且它可能在我们希望使用的任务上已知表现良好。此外,我们可能有一个用例,我们愿意接受导入和使用这个预存模型的运营风险,前提是我们自己的测试。让我们假设我们现在处于这样一个例子中,我们想要构建一个基本的管道来总结一个虚构组织客户和员工之间的文本对话。我们可以使用现成的转换器模型,如图 7.2所示,来自 Hugging Face 的 transformers 库。
要开始使用,你只需要知道你想要从 Hugging Face 模型服务器下载的模型名称;在这种情况下,我们将使用 Pegasus 文本摘要模型。Hugging Face 提供了一个“pipeline" API,用于包装模型并使其易于使用:
from transformers import pipeline
summarizer = pipeline("summarization", model= "google/pegasus-xsum")
执行我们的第一个深度学习模型推理就像只是将一些输入传递给这个管道一样简单。因此,对于上面描述的虚构人机交互,我们只需传递一些示例文本,看看它返回什么。让我们这样做,以总结一个虚构的客户和聊天机器人之间的对话,其中客户正在尝试获取他们已下订单的更多信息。对话如下所示:
text = "Customer: Hi, I am looking for some help regarding my recent purchase of a bouquet of flowers. ChatBot: Sure, how can I help you today? Customer: I purchased a bouquet the other day, but it has not arrived. ChatBot: What is the order ID? Customer: 0123456\. ChatBot: Please wait while I fetch the details of your order... It doesn't seem like there was an order placed as you described; are you sure of the details you have provided?"
然后,我们将把这个对话输入到摘要器 pipeline 对象中,并打印结果:
summary = summarizer(text)
print(summary)
[{'summary_text': 'This is a live chat conversation between a customer and a ChatBot.'}]
结果显示,该模型实际上已经很好地总结了这种交互的本质,突出了在深度学习革命之前可能非常困难或甚至不可能开始做的事情现在变得多么容易。
我们刚刚看到了一个使用预训练的转换器模型来执行某些特定任务的例子,在这种情况下是文本摘要,而无需根据新数据更新模型。在下一节中,我们将探讨当你想要根据你自己的数据更新模型时应该做什么。
微调和迁移学习
在上一节中,我们展示了如果能够找到适合您任务的现有深度学习模型,开始构建解决方案是多么容易。然而,一个值得我们自问的好问题是:“如果这些模型并不完全适合我的具体问题,我能做什么?”这就是微调和迁移学习概念发挥作用的地方。微调是指我们取一个现有的深度学习模型,然后在一些新数据上继续训练该模型。这意味着我们不是从头开始,因此可以更快地达到一个优化的网络。迁移学习是指我们冻结神经网络的大部分状态,并使用新数据重新训练最后几层,以便执行一些稍微不同的任务,或者以更适合我们问题的方法执行相同的任务。在这两种情况下,这通常意味着我们可以保留原始模型中的许多强大功能,例如其特征表示,但开始为我们的特定用例进行调整。
为了使这个例子更加具体,我们现在将演示一个迁移学习在实际中的应用示例。微调可以遵循类似的过程,但并不涉及我们将要实施的神经网络调整。在这个例子中,我们将使用 Hugging Face 的datasets和evaluate包,这将展示我们如何使用基础双向编码器表示从 Transformer(BERT)模型,然后使用迁移学习来创建一个分类器,该分类器将估计在多语言亚马逊评论语料库(registry.opendata.aws/amazon-reviews-ml/)中用英语撰写的评论的星级评分。
图 7.3展示了该数据集的一个示例评分:
图 7.3:这展示了来自多语言亚马逊评论语料库的一个示例评论和星级评分。
尽管我们在以下示例中使用了 BERT 模型,但还有许多变体可以与相同的示例一起工作,例如 DistilBERT 或 AlBERT,这些是更小的模型,旨在更快地训练并保留原始 BERT 模型的大部分性能。您可以尝试所有这些,甚至可能会发现这些模型由于尺寸减小而下载速度更快!
为了开始我们的迁移学习示例:
-
首先,我们可以使用
datasets包来检索数据集。我们将使用 Hugging Face 数据集提供的“配置”和“拆分”概念,这些概念指定了数据的具体子集以及您是否想要数据的训练、测试或验证拆分。对于这个案例,我们想要英语评论,并且最初将使用数据的训练拆分。图 7.3展示了数据集的一个示例记录。数据检索的语法如下:import datasets from datasets import load_dataset def fetch_dataset(dataset_name: str="amazon_reviews_multi", configuration: str="en", split: str="train" ) -> datasets.arrow_dataset.Dataset: ''' Fetch dataset from HuggingFace datasets server. ''' dataset = load_dataset(dataset_name, configuration, split=split) return dataset -
下一步是标记化数据集。为此,我们将使用与我们将使用的 BERT 模型配对的
AutoTokenizer。在我们引入那个特定的分词器之前,让我们编写一个函数,该函数将使用所选的分词器来转换数据集。我们还将定义将数据集转换为适合在后续 PyTorch 过程中使用的形式的逻辑。我还添加了一个选项来对测试数据进行下采样:import typing from transformers import AutoTokenizer def tokenize_dataset(tokenizer: AutoTokenizer, dataset: datasets.arrow_dataset.Dataset, sample=True) -> datasets.arrow_dataset.Dataset: ''' Tokenize the HuggingFace dataset object and format for use in later Pytorch logic. ''' tokenized_dataset = dataset.map( lambda x: tokenizer(x["review_body"], padding="max_length", truncation=True), batched=True ) # Torch needs the target column to be named "labels" tokenized_dataset = tokenized_dataset.rename_column("stars", "labels") # We can format the dataset for Torch using this method. tokenized_dataset.set_format( type="torch", columns=["input_ids", "token_type_ids", "attention_mask", "labels"] ) # Let's downsample to speed things up for testing if sample==True: tokenized_dataset_small = tokenized_dataset.\ shuffle(seed=42).select(range(10)) return tokenized_dataset_small else: return tokenized_dataset -
接下来,我们需要创建 PyTorch
dataloader以将数据输入到模型中:from torch.utils.data import DataLoader def create_dataloader( tokenized_dataset: datasets.arrow_dataset.Dataset, batch_size: int = 16, shuffle: bool = True ): dataloader = DataLoader(tokenized_dataset, shuffle=shuffle, batch_size=batch_size) return dataloader -
在我们定义训练模型的逻辑之前,编写一个用于定义学习调度器和训练运行优化器的辅助函数将很有用。然后我们可以在我们的训练函数中调用它,我们将在下一步定义。在这个例子中,我们将使用 AdamW 优化器:
from torch.optim import AdamW from transformers import get_scheduler def configure_scheduler_optimizer( model: typing.Any, dataloader: typing.Any, learning_rate: float, num_training_steps: int) -> tuple[typing.Any, typing.Any]: ''' Return a learning scheduler for use in training using the AdamW optimizer ''' optimizer = AdamW(model.parameters(), lr=learning_rate) lr_scheduler = get_scheduler( name="linear", optimizer=optimizer, num_warmup_steps=0, num_training_steps=num_training_steps ) return lr_scheduler, optimizer -
现在,我们可以定义我们想要使用迁移学习训练的模型。Hugging Face 的
transformers库提供了一个非常有用的包装器,可以帮助您根据核心 BERT 模型更改神经网络的分类头。我们实例化这个模型并传入类别数,这隐式地更新了神经网络架构,以便在运行预测时为每个类别提供 logits。在运行推理时,我们将取这些 logits 中的最大值对应的类别作为推断类别。首先,让我们在函数中定义训练模型的逻辑:import torch from tqdm.auto import tqdm def transfer_learn( model: typing.Any, dataloader: typing.Any, learning_rate: float = 5e-5, num_epochs: int = 3, progress_bar: bool = True )-> typing.Any: device = torch.device("cuda") if torch.cuda.is_available() else\ torch.device("cpu") model.to(device) num_training_steps = num_epochs * len(dataloader) lr_scheduler, optimizer = configure_scheduler_optimizer( model = model, dataloader = dataloader, learning_rate = learning_rate, num_training_steps = num_training_steps ) if progress_bar: progress_bar = tqdm(range(num_training_steps)) else: pass model.train() for epoch in range(num_epochs): for batch in dataloader: batch = {k: v.to(device) for k, v in batch.items()} outputs = model(**batch) loss = outputs.loss loss.backward() optimizer.step() lr_scheduler.step() optimizer.zero_grad() if progress_bar: progress_bar.update(1) else: pass return model -
最后,我们可以调用所有这些方法来获取分词器,引入数据集,转换它,定义模型,配置学习调度器和优化器,并最终执行迁移学习以创建最终模型:
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased") tokenized_dataset = tokenize_dataset(tokenizer=tokenizer, dataset=dataset, sample=True) dataloader = create_dataloader(tokenized_dataset=tokenized_dataset) model = AutoModelForSequenceClassification.from_pretrained( "bert-base-cased", num_labels=6) # 0-5 stars transfer_learned_model = transfer_learn( model = model, dataloader=dataloader ) -
然后,我们可以使用 Hugging Face 的
evaluate包或任何我们喜欢的方法来评估模型在数据测试分割上的性能。注意,在下面的示例中,我们调用model.eval()以使模型处于评估模式,正如之前讨论的那样:import evaluate device = torch.device("cuda") if torch.cuda.is_available() else\ torch.device("cpu") metric = evaluate.load("accuracy") model.eval() eval_dataset = fetch_dataset(split="test") tokenized_eval_dataset = tokenize_dataset( tokenizer=tokenizer,dataset=eval_dataset, sample=True) eval_dataloader = create_dataloader( tokenized_dataset=tokenized_eval_dataset) for batch in eval_dataloader: batch = {k: v.to(device) for k, v in batch.items()} with torch.no_grad(): outputs = model(**batch) logits = outputs.logits predictions = torch.argmax(logits, dim=-1) metric.add_batch(predictions=predictions, references=batch["labels"]) metric.compute()这将返回一个包含计算出的指标值的字典,如下所示:
{'accuracy': 0.8}
这就是您如何使用 PyTorch 和 Hugging Face 的 transformers 库来执行迁移学习。
Hugging Face 的 transformers 库现在还提供了一个非常强大的 Trainer API,以帮助您以更抽象的方式执行微调。如果我们从之前的示例中取相同的分词器和模型,要使用 Trainer API,我们只需做以下操作:
-
当使用 Trainer API 时,您需要定义一个
TrainingArguments对象,它可以包括超参数和一些其他标志。我们只需接受所有默认值,但提供一个输出检查点的路径:from transformers import TrainingArguments training_args = TrainingArguments(output_dir="trainer_checkpoints") -
然后,我们可以使用之前示例中使用的相同
evaluate包来定义一个计算任何指定指标的功能,我们将将其传递给主trainer对象:import numpy as np import evaluate metric = evaluate.load("accuracy") def compute_metrics(eval_pred): logits, labels = eval_pred predictions = np.argmax(logits, axis=-1) return metric.compute(predictions=predictions, references=labels) -
然后您定义一个包含所有相关输入对象的
trainer对象:trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=eval_dataset, compute_metrics=compute_metrics, ) -
您可以通过调用这些指定的配置和对象来训练模型
trainer.train().
这就是您在 Hugging Face 上对现有模型进行自己训练的方法。
还值得注意的是,Trainer API 提供了一种非常不错的方式来使用Optuna这样的工具,我们在第三章,从模型到模型工厂中遇到过,以执行超参数优化。您可以通过指定 Optuna 试验搜索空间来完成此操作:
def optuna_hp_space(trial):
return {
"learning_rate": trial.suggest_float("learning_rate", 1e-6, 1e-4,
log=True)
}
然后定义一个函数,用于在超参数搜索的每个状态下初始化神经网络:
def model_init():
model = AutoModelForSequenceClassification.from_pretrained(
"bert-base-cased", num_labels=6)
return model
然后,您只需将此传递给Trainer对象:
trainer = Trainer(
model=None,
args=training_args,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
compute_metrics=compute_metrics,
tokenizer=tokenizer,
model_init=model_init,
)
最后,您可以运行超参数搜索并检索最佳运行:
best_run = trainer.hyperparameter_search(
n_trials=20,
direction="maximize",
hp_space=optuna_hp_space
)
这样,我们就完成了使用 Hugging Face 工具进行 PyTorch 深度学习模型的迁移学习和微调的示例。需要注意的是,微调和迁移学习仍然是训练过程,因此仍然可以应用于第三章中概述的模型工厂方法。例如,当我们说“训练”时,在第三章中概述的“train-run”过程中,这可能现在指的是预训练深度学习模型的微调或迁移学习。
如我们之前已经广泛讨论过的,深度学习模型可以是非常强大的工具,用于解决各种问题。近年来,许多团体和组织积极探索的一个趋势是,随着这些模型变得越来越大,可能实现什么。在下一节中,我们将开始通过探索深度学习模型变得极其大时会发生什么来回答这个问题。是时候进入大型语言模型(LLMs)的世界了。
与大型语言模型(LLMs)一起生活
在撰写本文时,GPT-4 仅在前几个月的 2023 年 3 月由 OpenAI 发布。这个模型可能是迄今为止开发的最大机器学习模型,据报道有 1000 亿个参数,尽管 OpenAI 尚未确认确切数字。从那时起,微软和谷歌已经宣布在其产品套件中使用类似的大型模型提供高级聊天功能,并发布了一系列开源软件包和工具包。所有这些解决方案都利用了迄今为止开发的一些最大的神经网络模型,即 LLMs。LLMs 是被称为基础模型的更广泛模型类别的一部分,不仅涵盖文本应用,还包括视频和音频。作者将这些模型大致分类为对于大多数组织来说太大,无法从头开始训练。这意味着组织将要么作为第三方服务消费这些模型,要么托管并微调现有模型。以安全可靠的方式解决这一集成挑战是现代机器学习工程的主要挑战之一。没有时间可以浪费,因为新的模型和功能似乎每天都在发布;所以让我们行动起来吧!
理解大型语言模型(LLMs)
基于大型语言模型(LLM)的系统的主要焦点是针对各种基于文本的输入创建类似人类的响应。LLM 基于我们已经接触过的转换器架构。这使得这些模型能够并行处理输入,与其它深度学习模型相比,在相同数据量的训练上显著减少了所需的时间。
对于任何转换器来说,LLM 的架构由一系列编码器和解码器组成,这些编码器和解码器利用了自注意力和前馈神经网络。
从高层次来看,你可以将编码器视为负责处理输入,将其转换成适当的数值表示,然后将这个表示输入到解码器中,从解码器中生成输出。转换器的魔力来自于自注意力的使用,这是一种捕捉句子中词语之间上下文关系的机制。这导致了表示这种上下文关系的注意力向量,当这些向量的多个被计算时,就称为多头注意力。编码器和解码器都使用自注意力机制来捕捉输入和输出序列的上下文依赖关系。
在 LLM 中使用的基于转换器的最流行的模型之一是 BERT 模型。BERT 是由谷歌开发的,是一个预训练模型,可以针对各种自然语言任务进行微调。
另一个流行的架构是生成预训练转换器(GPT),由 OpenAI 创建。OpenAI 在 2022 年 11 月发布的 ChatGPT 系统,显然在引起世界轰动时使用了第三代 GPT 模型。截至 2023 年 3 月写作时,这些模型已经发展到第四代,并且非常强大。尽管 GPT-4 仍然相对较新,但它已经引发了关于 AI 未来的激烈辩论,以及我们是否已经达到了人工通用智能(AGI)。作者并不认为我们已经达到,但无论如何,这是一个多么激动人心的领域啊!
使得 LLM 在每个新的商业环境或组织中重新训练变得不可行的是,它们是在庞大的数据集上训练的。2020 年发布的 GPT-3 在近 5000 亿次文本标记上进行了训练。在这个例子中,一个标记是用于 LLM 训练和推理过程中单词的小片段,大约有 4 个英文字符左右。这可是大量的文本!因此,训练这些模型的成本相应地也很高,甚至推理也可能非常昂贵。这意味着,那些唯一关注不生产这些模型的组织可能无法看到规模经济和投资这些模型所需的回报。在考虑需要专业技能、优化基础设施以及获取所有这些数据的能力之前,这种情况就已经存在了。这与几年前公共云的出现有很多相似之处,当时组织不再需要投资大量的本地基础设施或专业知识,而是开始按“使用即付费”的方式支付费用。现在,这种情况正在最复杂的机器学习模型中发生。这并不是说较小的、更专业化的模型已被排除在外。事实上,我认为这将是组织利用他们独特的数据集来驱动竞争优势和构建更好产品的一种方式。最成功的团队将是那些能够以稳健的方式将这种方法与最大模型的方法相结合的团队。
尽管规模不是唯一重要的组成部分。ChatGPT 和 GPT-4 不仅在大量数据上进行了训练,而且还使用了一种称为人类反馈强化学习(RLHF)的技术进行了微调。在这个过程中,模型会接收到一个提示,例如一个对话式问题,然后生成一系列可能的回答。这些回答随后会被展示给人类评估者,他们会对回答的质量提供反馈,通常是通过排名,这些反馈随后用于训练一个奖励模型。然后,该模型会通过近端策略优化(PPO)等技术来微调底层语言模型。所有这些细节都远远超出了本书的范围,但希望您已经对这种并非普通数据科学,任何团队都无法迅速扩展的方法有了直观的认识。既然如此,我们就必须学习如何将这些工具视为更类似于“黑盒”的东西,并将它们作为第三方解决方案来使用。我们将在下一节中介绍这一点。
通过 API 消费 LLM
如前几节所述,我们作为想要与 LLMs 和一般基础模型交互的 ML 工程师的思维方式的重大变化是,我们不能再假设我们有权访问模型工件、训练数据或测试数据。相反,我们必须将模型视为一个第三方服务,我们应该调用它以进行消费。幸运的是,有许多工具和技术可以实现这一点。
下一个示例将展示如何使用流行的LangChain包构建利用 LLMs 的管道。这个名字来源于这样一个事实:为了利用 LLMs 的力量,我们通常需要通过调用其他系统和信息来源与它们进行许多交互。LangChain 还提供了一系列在处理 NLP 和基于文本的应用时非常有用的功能。例如,有文本拆分、处理向量数据库、文档加载和检索以及会话状态持久化的工具。这使得它即使在不是专门与 LLMs 工作的项目中也是一个值得检查的包。
首先,我们通过一个基本示例来调用 OpenAI API:
-
安装
langchain和openaiPython 绑定:pip install langchain pip install openai -
我们假设用户已经设置了 OpenAI 账户并有权访问 API 密钥。你可以将其设置为环境变量或使用像 GitHub 提供的那样一个秘密管理器进行存储。我们将假设密钥可以通过环境变量访问:
import os openai_key = os.getenv('OPENAI_API_KEY') -
现在,在我们的 Python 脚本或模块中,我们可以定义我们将通过
langchain包装器访问的 OpenAI API 调用的模型。这里我们将使用gpt-3.5-turbo模型,这是 GPT-3.5 聊天模型中最先进的:from langchain.chat_models import ChatOpenAI gpt = ChatOpenAI(model_name='''gpt-3.5-turbo''') -
LangChain 随后通过提示模板促进使用 LLMs 构建管道,这些模板允许您标准化我们将如何提示和解析模型的响应:
template = '''Question: {question} Answer: ''' prompt = PromptTemplate( template=template, input_variables=['question'] ) -
然后,我们可以创建我们的第一个“链”,这是在
langchain中拉取相关步骤的机制。这个第一个链是一个简单的链,它接受一个提示模板和输入,创建一个适当的提示发送给 LLM API,然后返回一个格式适当的响应:# user question question = "Where does Andrew McMahon, author of 'Machine Learning Engineering with Python', work?" # create prompt template > LLM chain llm_chain = LLMChain( prompt=prompt, llm=gpt ) -
你可以运行这个问题并将结果打印到终端作为测试:
print(llm_chain.run(question))这返回:
As an AI language model, I do not have access to real-time information. However, Andrew McMahon is a freelance data scientist and software engineer based in Bristol, United Kingdom.
由于我是一名受雇于大型银行并驻扎在英国格拉斯哥的 ML 工程师,你可以看到即使是功能最复杂的 LLMs 也会出错。这是我们所说的“幻觉”的一个例子,其中 LLM 给出了一个错误但看似合理的答案。我们将在关于构建未来与LLMOps的章节中回到 LLMs 出错的话题。这仍然是一个通过程序化方式以标准化方式与 LLMs 交互的基本机制的例子。
LangChain 还提供了使用链中的generate方法将多个提示组合在一起的能力:
questions = [
{'question': '''Where does Andrew McMahon, author of 'Machine Learning Engineering with Python', work?'''},
{'question': 'What is MLOps?'},
{'question': 'What is ML engineering?'},
{'question': 'What's your favorite flavor of ice cream?'}
]
print(llm_chain.generate(questions))
这一系列问题的回答相当冗长,但以下是返回对象的第一部分:
generations=[[ChatGeneration(text='As an AI modeler and a data scientist, Andrew McMahon works at Cisco Meraki, a subsidiary of networking giant Cisco, in San Francisco Bay Area, USA.', generation_info=None, message=AIMessage(content='As an AI modeler and a data scientist, Andrew McMahon works at Cisco Meraki, a subsidiary of networking giant Cisco, in San Francisco Bay Area, USA.', additional_kwargs={}))], …]
再次,并不完全正确。不过你大概明白了!通过一些提示工程和更好的对话设计,这可以很容易地变得更好。我将让你自己尝试并享受其中的乐趣。
这份关于 LangChain 和 LLMs 的快速介绍只是触及了表面,但希望这能给你足够的信息,将调用这些模型的代码整合到你的机器学习工作流程中。
让我们继续讨论 LLMs 成为机器学习工程工具包重要组成部分的另一种方式,正如我们探索使用人工智能助手进行软件开发时。
使用 LLMs 进行编码
LLMs 不仅对创建和分析自然语言有用;它们还可以应用于编程语言。这就是 OpenAI Codex 系列模型的目的,这些模型在数百万个代码仓库上进行了训练,目的是在提示时能够生成看起来合理且性能良好的代码。自从 GitHub Copilot,一个编码人工智能助手推出以来,AI 助手帮助编码的概念已经进入主流。许多人认为这些解决方案在执行自己的工作时提供了巨大的生产力提升和更愉快的体验。GitHub 发布了一些自己的研究,表明在询问的 2,000 名开发者中,有 60-75%的人表示在开发软件时感到的挫败感减少,满意度提高。在 95 名开发者的一个小群体中,其中 50 名是对照组,他们使用给定规范在 JavaScript 中开发 HTTP 服务器时也显示了速度提升。我相信在我们宣布 AI 编码助手显然使我们所有人更快乐、更高效之前,应该在这个主题上做更多的工作,但 GitHub 的调查和测试结果确实表明它们是值得尝试的有用工具。这些结果发布在github.blog/2022-09-07-research-quantifying-github-copilots-impact-on-developer-productivity-and-happiness/。关于这一点,斯坦福大学的研究人员在一篇有趣的 arXiv 预印本论文中,arXiv:2211.03622 [cs.CR],似乎表明使用基于 OpenAI codex-davinci-002模型的 AI 编码助手的开发者更有可能在其代码中引入安全漏洞,并且即使存在这些问题,模型的使用者也会对自己的工作更有信心!应该注意的是,他们使用的模型在 OpenAI 现在提供的 LLM 家族中相对较旧,因此还需要更多的研究。这确实提出了一个有趣的可能性,即 AI 编码助手可能提供速度提升,但也可能引入更多的错误。时间将证明一切。随着强大开源竞争者的引入,这一领域也开始变得热门。其中一个值得指出的是 StarCoder,它是通过 Hugging Face 和 ServiceNow 的合作开发的huggingface.co/blog/starcoder。有一点是肯定的,这些助手不会消失,并且随着时间的推移只会变得更好。在本节中,我们将开始探索以各种形式与这些 AI 助手一起工作的可能性。学习与 AI 合作很可能是未来机器学习工程工作流程的一个关键部分,所以让我们开始学习吧!
首先,作为一个机器学习工程师,我什么时候会想使用 AI 编码助手呢?社区和 GitHub 的研究共识似乎表明,这些助手有助于在已建立的语言(如 Python)上开发样板代码。它们似乎并不适合当你想做一些特别创新或不同的事情时;然而,我们也会探讨这一点。
那么,你实际上是如何与 AI 合作来帮助你编写代码的呢?在撰写本文时,似乎有两种主要方法(但考虑到创新的步伐,你可能会很快通过脑机接口与 AI 合作;谁知道呢?),每种方法都有其自身的优缺点:
-
直接编辑器或 IDE 集成:在 Copilot 支持的代码编辑器和 IDE 中,包括撰写本文时我们在这本书中使用的 PyCharm 和 VS Code 环境,你可以启用 Copilot 在你输入代码时提供自动补全建议。你还可以在代码的注释中提供 LLM 模型的提示信息。这种集成方式只要开发者使用这些环境,就可能会一直存在,但我预见未来会有大量的 AI 助手服务。
-
聊天界面:如果你不使用 Copilot 而是使用其他 LLM,例如 OpenAI 的 GPT-4,那么你可能需要在一个聊天界面中工作,并在你的编码环境和聊天之间复制粘贴相关信息。这可能看起来有点笨拙,但确实更加灵活,这意味着你可以轻松地在你选择的模型之间切换,甚至组合多个模型。如果你有相关的访问权限和 API 来调用,你实际上可以构建自己的代码来将这些模型输入你的代码中,但到了那个阶段,你只是在重新开发一个像 Copilot 这样的工具!
我们将通过一个示例来展示这两种方法,并突出它们如何可能在你未来的机器学习工程项目中帮助你。
如果你导航到 GitHub Copilot 网页,你可以为个人订阅支付月费并享受免费试用。一旦你完成了这个步骤,你就可以遵循这里为你选择的代码编辑器的设置说明:docs.github.com/en/copilot/getting-started-with-github-copilot。
一旦你设置了这个环境,就像我为 VS Code 所做的那样,你就可以立即开始使用 Copilot。例如,我打开了一个新的 Python 文件并开始输入一些典型的导入语句。当我开始编写我的第一个函数时,Copilot 就提出了一个建议,来完成整个函数,如图 7.4 所示。
图 7.4:GitHub Copilot 在 VS Code 中建议的自动补全。
如上所述,这并不是向 Copilot 提供输入的唯一方式;您还可以使用注释来向模型提供更多信息。在图 7.5中,我们可以看到在首行注释中提供一些评论有助于定义我们希望在函数中包含的逻辑。
图 7.5:通过提供首行注释,您可以帮助 Copilot 为您代码建议所需的逻辑。
在使用 Copilot 时,以下是一些有助于发挥其最佳效果的事项,值得您牢记:
-
非常模块化:您能将代码做得越模块化,效果越好。我们之前已经讨论过这有利于维护和快速开发,但在这里它也有助于 Codex 模型创建更合适的自动补全建议。如果您的函数将要变得很长,很复杂,那么 Copilot 的建议可能就不会很好。
-
编写清晰的注释:这当然是一种良好的实践,但它确实有助于 Copilot 理解您需要的代码。在文件顶部编写较长的注释,描述您希望解决方案执行的操作,然后在函数之前编写较短但非常精确的注释可能会有所帮助。图 7.5中的示例显示了一个注释,它指定了我想让函数执行特征准备的方式,但如果注释只是说“标准化特征”,那么建议可能就不会那么完整。
-
编写接口和函数签名:正如图 7.5所示,如果您在代码块开始时提供函数签名和类型或类定义的第一行(如果是类的话),这有助于启动模型以完成代码块的其余部分。
希望这足以让您开始与 AI 合作构建解决方案的旅程。我认为随着这些工具变得更加普遍,将会有很多机会使用它们来加速您的工作流程。
现在我们已经知道了如何使用 LLMs 构建一些管道,并且知道了如何开始利用它们来辅助我们的开发,我们可以转向我认为这个领域最重要的一个话题。我也认为这是最具未解之谜的话题,因此它是一个非常激动人心的探索方向。这一切都与利用 LLMs 的操作影响有关,现在被称为LLMOps。
用 LLMOps 构建未来
近期对大型语言模型(LLMs)的兴趣日益增长,很多人表达了将这类模型集成到各种软件系统中的愿望。对于我们这些机器学习工程师来说,这应该立即引发我们思考,“这将对我们的操作意味着什么?”正如本书中多次讨论的那样,将操作与机器学习系统的开发相结合被称为 MLOps。然而,与 LLMs 一起工作可能会带来一些有趣的挑战,因此出现了一个新术语,LLMOps,以给 MLOps 的子领域带来一些良好的市场营销。
这真的有什么不同吗?我认为它并没有那么不同,但应该被视为 MLOps 的一个子领域,它有自己的额外挑战。我在这个领域看到的一些主要挑战包括:
-
即使是微调,也需要更大的基础设施:正如之前讨论的那样,这些模型对于典型的组织或团队来说太大,无法考虑自己训练,因此团队将不得不利用第三方模型,无论是开源的还是专有的,并对它们进行微调。微调如此规模的模型仍然会非常昂贵,因此构建非常高效的数据摄取、准备和训练管道将更加重要。
-
模型管理有所不同:当你自己训练模型时,正如我们在第三章“从模型到模型工厂”中多次展示的那样,有效的机器学习工程需要我们为模型的版本控制和存储提供实验和训练过程的历史记录的元数据定义良好的实践。在一个模型更常由外部托管的世界里,这会稍微困难一些,因为我们无法访问训练数据、核心模型工件,甚至可能连详细的模型架构都无法访问。版本控制元数据可能默认为模型的公开可用元数据,例如
gpt-4-v1.3和类似名称。这并不是很多信息,因此你可能会考虑想出方法来丰富这些元数据,可能包括你自己的示例运行和测试结果,以便了解该模型在特定场景下的行为。这也就与下一个点相关联。 -
回滚变得更加困难:如果你的模型由第三方托管,你无法控制该服务的路线图。这意味着,如果模型版本 5 存在问题,你想回滚到版本 4,你可能没有这个选项。这与我们在本书中详细讨论过的模型性能漂移是不同类型的“漂移”,但它将变得越来越重要。这意味着你应该准备自己的模型,可能功能或规模远不及这些模型,作为最后的手段,在出现问题时切换到默认选项。
-
模型性能是一个更大的挑战:正如前一点提到的,随着基础模型作为外部托管服务提供,你不再像以前那样有那么多控制权。这意味着如果你检测到你所消费的模型有任何问题,无论是漂移还是其他错误,你所能做的非常有限,你将需要考虑我们刚才讨论的默认回滚。
-
应用自己的安全措施将是关键:LLM 会幻想,它们会出错,它们可能会重复训练数据,甚至可能无意中冒犯与之互动的人。所有这些都意味着随着这些模型被更多组织采用,将会有越来越多的需求来开发为利用这些模型构建的系统应用定制安全措施的方法。例如,如果某个 LLM 被用来驱动下一代聊天机器人,你可以设想在 LLM 服务和聊天界面之间,可以有一个系统层来检查突然的情感变化和应该被隐藏的重要关键词或数据。这一层可以利用更简单的机器学习模型和多种其他技术。在其最复杂的形式下,它可能试图确保聊天机器人不会导致违反组织建立的道德或其他规范。如果你的组织将气候危机作为一个重点关注的领域,你可能希望实时筛选对话中的信息,以避免与该领域关键科学发现相悖,例如。
由于基础模型的时代才刚刚开始,很可能会出现越来越多的复杂挑战,让我们作为机器学习工程师在接下来的很长时间里都忙碌不已。对我来说,这是我们作为一个社区面临的最激动人心的挑战之一,那就是如何以仍然允许软件每天对用户安全、高效和稳健运行的方式,利用机器学习社区开发出的最复杂和最前沿的能力。你准备好接受这个挑战了吗?
让我们更详细地探讨一些这些话题,首先从 LLM 验证开始讨论。
验证 LLM
生成式 AI 模型的验证本质上与其它 ML 模型的验证不同,看起来也更复杂。主要原因在于,当你正在生成内容时,你通常会在结果中创建非常复杂的数据,这些数据以前从未存在过!如果 LLM 在请求帮助总结和分析某些文档时返回一段生成的文本,你如何判断这个答案是否“好”?如果你要求 LLM 将一些数据重新格式化为表格,你如何构建一个合适的指标来捕捉它是否正确地完成了这项任务?在生成环境中,“模型性能”和“漂移”究竟意味着什么,我该如何计算它们?其他问题可能更依赖于具体的应用场景,例如,如果你正在构建一个信息检索或检索增强生成(见Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks,arxiv.org/pdf/2005.11401.pdf)解决方案,你如何评估 LLM 生成的文本的真实性?
在如何筛选 LLM 生成的输出以避免可能对运行该模型的组织造成伤害或声誉损害的偏见或有害输出方面,也有一些重要的考虑因素。LLM 验证的世界非常复杂!
我们能做些什么呢?幸运的是,这一切并非在真空中发生,已经有几个基准工具和数据集发布,可以帮助我们在旅途中。由于事物还处于初级阶段,因此这些工具的实例并不多,但我们将讨论关键点,以便您了解这一领域,并跟上事物的发展。以下是 LLM 的一些高知名度评估框架和数据集:
-
OpenAI Evals:这是一个框架,OpenAI 允许通过众包开发针对 LLM 生成的文本补全的测试。evals 的核心概念是“补全函数协议”,这是一种标准化与 LLM 交互时返回的字符串测试的机制。该框架可在 GitHub 上找到,
github.com/openai/evals。 -
全面评估语言模型(HELM):这个由斯坦福大学发起的项目将自己定位为 LLM 性能的“活基准”。它提供了各种数据集、模型和指标,并展示了这些不同组合的性能。这是一个非常强大的资源,您可以用它来基于自己的测试场景,或者直接使用这些信息来了解使用任何特定 LLM 的潜在风险和收益。HELM 基准可在
crfm.stanford.edu/helm/latest/找到。 -
Guardrails AI:这是一个 Python 包,允许你以类似于
pydantic的方式对 LLM 的输出进行验证,这是一个非常强大的想法!你还可以用它来为 LLM 构建控制流程,以应对诸如对提示的响应不符合你设定的标准等问题;在这种情况下,你可以使用 Guardrails AI 重新提示 LLM,希望得到不同的响应。要使用 Guardrails AI,你需要指定一个可靠的 AI 标记语言(RAIL)文件,该文件以 XML 类似的文件定义了提示格式和预期的行为。Guardrails AI 可在 GitHub 上找到:shreyar.github.io/guardrails/。
每时每刻都有更多这样的框架被创建,但随着越来越多的组织希望将基于 LLM 的系统从有趣的证明概念转变为生产解决方案,熟悉核心概念和现有数据集将变得越来越重要。在本章的最后部分,我们将简要讨论我在构建 LLM 应用时围绕“提示”管理所看到的一些具体挑战。
PromptOps
当与需要文本输入的生成式 AI 工作时,我们输入的数据通常被称为“提示”(prompts),以捕捉与这些模型互动的对话起源以及输入需要响应的概念,就像人的提示一样。为了简单起见,我们将任何我们提供给 LLM 的输入数据都称为提示,无论这是通过用户界面还是通过 API 调用,也不论我们提供给 LLM 的内容性质如何。
提示通常与我们通常喂给 ML 模型的数据大不相同。它们可以是自由形式的,长度各异,并且在大多数情况下,表达了我们希望模型如何行动的意图。在其他 ML 建模问题中,我们当然可以输入非结构化文本数据,但这个意图部分是缺失的。这导致我们作为与这些模型一起工作的 ML 工程师需要考虑一些重要因素。
首先,提示的塑造很重要。术语提示工程最近在数据社区中变得流行,它指的是在设计和这些提示的内容和格式时往往需要大量的思考。当我们用这些模型设计我们的 ML 系统时,我们需要牢记这一点。我们应该问自己像“我能为我的应用程序或用例标准化提示格式吗?”“我能在用户或输入系统提供的之上提供适当的额外格式或内容,以获得更好的结果吗?”等问题。我将坚持使用这个术语来称呼提示工程。
其次,提示并非典型的机器学习输入,跟踪和管理它们是一个新的、有趣的挑战。这个挑战由于同一个提示可能对不同模型或同一模型的不同版本产生非常不同的输出而变得更加复杂。我们应该仔细考虑跟踪我们提示的谱系以及它们产生的输出。我将这个挑战称为提示管理。
最后,我们面临的一个挑战并非必然与提示相关,但如果允许系统用户输入自己的提示,例如在聊天界面中,它就变得更为相关。在这种情况下,我们需要对进入和离开模型的数据应用某种筛选和混淆规则,以确保模型不会被以某种方式“越狱”,从而规避任何安全措施。我们还想防范可能旨在从这些系统中提取训练数据,从而获取我们不希望共享的个人身份信息或其他关键信息的对抗性攻击。
当你们开始和全世界一起探索这个充满挑战的新世界 LLMOps 时,牢记这些与提示相关的挑战将非常重要。现在,我们将以一个简要总结来结束本章,总结我们已经讨论的内容。
摘要
在本章中,我们专注于深度学习。特别是,我们讨论了深度学习背后的关键理论概念,然后转向讨论如何构建和训练自己的神经网络。我们通过使用现成的模型进行推理的示例,然后通过微调和迁移学习将它们适应到特定的用例。所有展示的示例都是基于大量使用 PyTorch 深度学习框架和 Hugging Face API。
我们接着讨论了当前的热门话题:迄今为止构建的最大模型——大型语言模型(LLMs),以及它们对机器学习工程的意义。我们在展示如何使用流行的 LangChain 包和 OpenAI API 在管道中与它们交互之前,简要探讨了它们重要的设计原则和行为。我们还探讨了使用 LLMs 来提高软件开发生产力的潜力,以及这对作为机器学习工程师的你们意味着什么。
我们以对 LLMOps 这一新主题的探讨结束本章,LLMOps 是关于将本书中讨论的机器学习工程和 MLOps 原则应用于 LLMs。这涵盖了 LLMOps 的核心组件,以及一些可以用来验证你的 LLMs 的新功能、框架和数据集。我们最后提供了一些关于管理你的 LLM 提示的指导,以及如何将我们在第三章,“从模型到模型工厂”中讨论的实验跟踪概念应用到这种情况。
下一章将开始书的最后一部分,并将涵盖一个详细的端到端示例,我们将使用 Kubernetes 构建一个 ML 微服务。这将使我们能够应用我们在书中学到的许多技能。
加入我们的 Discord 社区
加入我们的 Discord 空间,与作者和其他读者进行讨论:
第八章:构建示例 ML 微服务
本章将主要介绍如何将我们在书中学到的知识结合到一个现实示例中。这将是基于在 第一章,机器学习工程简介 中介绍的场景之一,其中我们被要求为商店商品销售构建预测服务。我们将详细讨论该场景,并概述为了使解决方案成为现实必须做出的关键决策,然后展示我们如何通过本书中学到的过程、工具和技术从机器学习工程的角度解决问题的关键部分。到本章结束时,您应该对如何构建自己的 ML 微服务以解决各种商业问题有一个清晰的了解。
在本章中,我们将涵盖以下主题:
-
理解预测问题
-
设计我们的预测服务
-
选择工具
-
扩规模训练
-
使用 FastAPI 提供模型服务
-
容器化并部署到 Kubernetes
每个主题都将为我们提供一个机会,让我们回顾作为在复杂机器学习交付中工作的工程师所必须做出的不同决策。这将为我们提供在现实世界中执行此类操作时的便捷参考!
那么,让我们开始构建一个预测微服务吧!
技术要求
如果您在您的机器上安装并运行以下内容,本章中的代码示例将更容易理解:
-
Postman 或其他 API 开发工具
-
本地 Kubernetes 集群管理器,如 minikube 或 kind
-
Kubernetes CLI 工具,
kubectl
书籍 GitHub 仓库中 Chapter08 文件夹包含几个不同的技术示例的 conda 环境配置 .yml 文件,因为有几个不同的子组件。这些是:
-
mlewp-chapter08-train: 这指定了运行训练脚本的运行环境。 -
mlewp-chapter08-serve: 这指定了本地 FastAPI 网络服务的环境规范。 -
mlewp-chapter08-register: 这提供了运行 MLflow 跟踪服务器的环境规范。
在每种情况下,像往常一样创建 Conda 环境:
conda env create –f <ENVIRONMENT_NAME>.yml
本章中的 Kubernetes 示例还需要对集群和我们将部署的服务进行一些配置;这些配置在 Chapter08/forecast 文件夹下的不同 .yml 文件中给出。如果您使用 kind,可以通过运行以下简单配置来创建一个集群:
kind create cluster
或者,您可以使用存储库中提供的其中一个配置 .yaml 文件:
kind create cluster --config cluster-config-ch08.yaml
Minikube 不提供像 kind 那样读取集群配置 .yaml 选项,因此,你应该简单地运行:
minikube start
部署您的本地集群。
理解预测问题
在第一章“ML 工程简介”中,我们考虑了一个 ML 团队,该团队被分配提供零售业务中单个商店层面的商品预测。虚构的业务用户有以下要求:
-
预测结果应通过基于 Web 的仪表板进行展示和访问。
-
用户在必要时应能够请求更新预测。
-
预测应在单个商店层面进行。
-
用户在任何一次会话中都会对其自己的区域/商店感兴趣,而不会关注全球趋势。
-
在任何一次会话中请求更新预测的数量将很少。
鉴于这些要求,我们可以与业务团队合作创建以下用户故事,我们可以将这些故事放入像 Jira 这样的工具中,如第二章“机器学习开发过程”中所述。满足这些要求的一些用户故事示例如下:
-
用户故事 1:作为一名本地物流规划师,我希望在早上 09:00 登录仪表板,并能够看到未来几天商店层面的商品需求预测,以便我能够提前了解运输需求。
-
用户故事 2:作为一名本地物流规划师,我希望能够在看到预测信息过时的情况下请求更新。我希望新的预测结果能在 5 分钟内返回,以便我能够有效地做出运输需求决策。
-
用户故事 3:作为一名本地物流规划师,我希望能够筛选特定商店的预测信息,以便我能够了解哪些商店在推动需求,并在决策中使用这些信息。
这些用户故事对于整个解决方案的开发非常重要。由于我们专注于问题的 ML 工程方面,我们现在可以深入探讨这些对构建解决方案意味着什么。
例如,希望“能够看到商店层面的商品需求预测”的愿望可以很好地转化为解决方案 ML 部分的几个技术要求。这告诉我们目标变量将是特定一天所需商品的数量。这告诉我们我们的 ML 模型或模型需要能够在商店层面工作,因此我们可能需要为每个商店有一个模型,或者将商店的概念作为某种特征来考虑。
同样,用户希望“能够在看到预测信息过时时请求更新我的预测……我希望新的预测能在五分钟内检索到”的要求对训练的延迟提出了明确的要求。我们不能构建需要几天时间才能重新训练的东西,这可能意味着在整个数据上构建一个模型可能不是最佳解决方案。
最后,请求I want to be able to filter for forecasts for specific stores再次支持了这样的观点,即无论我们构建什么,都必须在数据中利用某种类型的存储标识符,但不必一定作为算法的特征。因此,我们可能需要开始考虑应用逻辑,该逻辑将接受对特定店铺的预测请求,该店铺通过此存储 ID 识别,然后仅通过某种类型的查找或检索使用此 ID 进行筛选来检索该店铺的 ML 模型和预测。
通过这个过程,我们可以看到仅仅几行需求是如何使我们开始具体化我们在实践中如何解决问题的。这些想法和其他想法可以通过我们团队在项目中进行一些头脑风暴,并像表 8.1那样的表格进行整合:
| 用户故事 | 细节 | 技术需求 |
|---|---|---|
| 1 | 作为本地物流规划师,我希望在早上 09:00 登录仪表板,并能够看到未来几天在店铺层面的项目需求预测,以便我能够提前了解运输需求。 |
-
目标变量 = 项目需求。
-
预测范围 – 1-7 天。
-
用于仪表板或其他可视化解决方案的 API 访问。
|
| 2 | 作为本地物流规划师,我希望能够在看到预测过时的情况下请求更新我的预测。我希望新的预测在 5 分钟内返回,以便我能够有效地做出关于运输需求的决策。 |
|---|
-
轻量级重新训练。
-
每个店铺的模型。
|
| 3 | 作为本地物流规划师,我希望能够筛选特定店铺的预测,以便我能够了解哪些店铺在推动需求,并在决策中使用这一点。 |
|---|
- 每个店铺的模型。
|
表 8.1:将用户故事转换为技术需求。
现在,我们将通过开始为解决方案的 ML 部分设计一个设计来加深我们对问题的理解。
设计我们的预测服务
在理解预测问题部分的要求是我们需要达到的目标定义,但它们并不是达到目标的方法。借鉴我们从第五章,部署模式和工具中关于设计和架构的理解,我们可以开始构建我们的设计。
首先,我们应该确认我们应该工作在哪种设计上。由于我们需要动态请求,遵循在第五章部署模式和工具中讨论的微服务架构是有意义的。这将使我们能够构建一个专注于从我们的模型存储中检索正确模型并执行请求推理的服务。因此,预测服务应该在仪表板和模型存储之间提供接口。
此外,由于用户可能希望在任何一次会话中与几个不同的存储组合一起工作,并且可能在这些预测之间来回切换,我们应该提供一个高效执行此操作的机制。
从场景中也可以清楚地看出,我们可以非常容易地有大量的预测请求,但模型更新的请求较少。这意味着将训练和预测分开是有意义的,我们可以遵循第三章中概述的“从模型到模型工厂”的 train-persist 过程。这意味着预测不会每次都依赖于完整的训练运行,并且检索用于预测的模型相对较快。
从要求中我们还了解到,在这种情况下,我们的训练系统不一定需要由漂移监控触发,而是由用户发出的动态请求触发。这增加了一点点复杂性,因为它意味着我们的解决方案不应该对每个请求都进行重新训练,而应该能够确定重新训练对于给定的请求是否有价值,或者模型是否已经是最新的。例如,如果有四个用户登录并查看相同的区域/商店/商品组合,并且所有用户都请求重新训练,那么很明显我们不需要四次重新训练我们的模型!相反,应该发生的情况是,训练系统记录一个请求,执行重新训练,然后安全地忽略其他请求。
如我们在这本书中多次讨论的那样,有几种方式可以提供机器学习模型。一种非常强大且灵活的方式是将模型或模型提供逻辑封装到一个独立的服务中,该服务仅限于执行机器学习推理所需的任务。这是我们将在本章中考虑的提供模式,它是经典的“微服务”架构,其中不同的功能部分被分解成它们自己的独立和分离的服务。这为软件系统增加了弹性和可扩展性,因此这是一个很好的模式,需要变得熟悉。这也特别适合机器学习系统的开发,因为这些系统必须由训练、推理和监控服务组成,如第三章中概述的“从模型到模型工厂”。本章将介绍如何使用微服务架构提供机器学习模型,使用几种不同的方法,各有优缺点。然后你将能够根据这些示例调整和构建你自己的未来项目。
我们可以将这些设计点整合到一个高级设计图中,例如,在图 8.1中:
图 8.1:预测微服务的高级设计。
下一节将专注于在开发前进行一些工具选择时,将这些高级设计考虑因素细化到更低的细节水平。
工具选择
现在我们已经有一个高级设计在心中,并且我们已经写下了一些明确的技术要求,我们可以开始选择我们将用于实现解决方案的工具集。
在这个方面最重要的考虑因素之一将是我们将使用什么框架来建模我们的数据并构建我们的预测功能。鉴于问题是一个需要快速重新训练和预测的时间序列建模问题,我们可以在继续之前考虑一些可能适合的选择的优缺点。
这个练习的结果显示在表 8.2 中:
| 工具/框架 | 优点 | 缺点 |
|---|---|---|
| Scikit-learn |
-
几乎所有数据科学家都已经理解。
-
语法非常易于使用。
-
社区支持非常丰富。
-
良好的特征工程和管道支持。
|
-
没有原生的时间序列建模能力(但流行的
sktime包确实有这些)。 -
将需要更多的特征工程来将模型应用于时间序列数据。
|
| Prophet |
|---|
-
专注于预测。
-
具有内置的超参数优化功能。
-
开箱即提供大量功能。
-
在广泛的问题上通常给出准确的结果。
-
开箱即提供置信区间。
|
-
不像 scikit-learn 那样常用(但仍然相对流行)。
-
基础方法相当复杂——可能会导致数据科学家使用黑盒。
-
本身不具有可扩展性。
|
| Spark ML |
|---|
-
本地可扩展到大量数据。
-
良好的特征工程和管道支持。
|
-
没有原生的时间序列建模能力。
-
算法选项相对有限。
-
调试可能更困难。
|
表 8.2:考虑的一些不同机器学习工具包解决此预测问题的优缺点。
根据表 8.2 中的信息,看起来Prophet库是一个不错的选择,并在预测能力、所需的时间序列能力和团队中的开发人员和科学家的经验之间提供了一个良好的平衡。
数据科学家可以使用这些信息构建一个概念验证,代码类似于第一章,机器学习工程简介中的示例 2:预测 API部分,该部分将 Prophet 应用于标准零售数据集。
这涵盖了我们将用于建模的机器学习包,但其他组件怎么办?我们需要构建一个允许前端应用程序请求后端执行操作的东西,因此考虑某种类型的 Web 应用程序框架是个好主意。我们还需要考虑当后端应用程序受到大量请求时会发生什么,因此有意识地构建它以考虑可扩展性是有意义的。另一个考虑因素是我们在这个用例中不仅要训练一个模型,而是要训练多个模型,每个零售店一个,因此我们应该尽可能并行化训练。最后一块拼图将是使用模型管理工具和需要编排层来按计划或动态触发训练和监控作业。
将所有这些内容综合起来,我们可以在使用 Prophet 库的基础上做出一些关于底层工具的设计决策。以下是一个总结列表:
-
Prophet:我们在第一章,机器学习工程简介中遇到了 Prophet 预测库。在这里,我们将深入了解该库及其工作原理,然后再开发一个训练流程来创建我们在第一章中为该零售用例看到的预测模型类型。
-
Kubernetes:如第六章,扩展中讨论的,这是一个在计算集群中编排多个容器的平台,允许你构建高度可扩展的机器学习模型服务解决方案。我们将使用它来托管主要应用程序。
-
Ray Train:我们在第六章,扩展中已经遇到了 Ray。在这里,我们将使用 Ray Train 并行训练许多不同的 Prophet 预测模型,并允许这些作业在向处理传入请求的主要网络服务发出请求时触发。
-
MLflow:我们在第三章,从模型到模型工厂中遇到了 MLflow,它将作为我们的模型注册库。
-
FastAPI:对于 Python,典型的后端网络框架通常是 Django、Flask 和 FastAPI。我们将使用 FastAPI 创建主要后端路由应用程序,该应用程序将提供预测并与其他解决方案组件交互。FastAPI 是一个设计用于简单使用和构建高性能网络应用的 Web 框架,目前被一些知名组织使用,包括 Uber、Microsoft 和 Netflix(根据 FastAPI 主页信息)。
最近关于使用 FastAPI 时可能出现的内存泄漏问题有一些讨论,尤其是对于长时间运行的服务。这意味着确保运行 FastAPI 端点的机器有足够的 RAM 非常重要。在许多情况下,这似乎不是一个关键问题,但在 FastAPI 社区中是一个活跃的讨论话题。更多关于这个话题的信息,请参阅github.com/tiangolo/fastapi/discussions/9082。其他框架,如Litestarlitestar.dev/,似乎没有相同的问题,所以你可以自由地尝试不同的网络框架来构建以下示例和你的项目中的服务层。FastAPI 仍然是一个非常有用的框架,具有许多优点,所以我们将在本章中继续使用它;只是要记住这个要点。
在本章中,我们将关注与大规模服务模型相关的系统组件,因为计划训练和重新训练方面将在第九章,构建提取、转换、机器学习用例中介绍。我们关注的组件可以被认为是我们的“服务层”,尽管我会向你展示如何使用 Ray 并行训练多个预测模型。
现在我们已经做出了一些工具选择,让我们开始构建我们的机器学习微服务吧!
规模化训练
当我们在第六章“扩展”中介绍 Ray 时,我们提到了一些用例,其中数据或处理时间需求如此之大,以至于使用一个非常可扩展的并行计算框架是有意义的。没有明确指出的是,有时这些需求来自我们实际上想要训练许多模型的事实,而不仅仅是大量数据上的一个模型或更快地训练一个模型。这正是我们将在这里做的事情。
我们在第一章“机器学习工程简介”中描述的零售预测示例使用了一个包含多个不同零售店的数据集。与其创建一个可能包含店铺编号或标识符作为特征的模型,也许更好的策略是为每个单独的店铺训练一个预测模型。这可能会提供更好的准确性,因为店铺级别的数据特征可能具有一些预测能力,不会被查看所有店铺组合的模型所平均。因此,我们将采取这种方法,这也是我们可以使用 Ray 的并行性同时训练多个预测模型的地方。
要使用Ray来完成这个任务,我们需要将我们在第一章中提到的训练代码稍作修改。首先,我们可以将用于预处理数据和训练预测模型的函数组合在一起。这样做意味着我们正在创建一个可以分发给运行在每个存储对应的数据分片上的串行进程。原始的预处理和训练模型函数如下:
import ray
import ray.data
import pandas as pd
from prophet import Prophet
def prep_store_data(
df: pd.DataFrame,
store_id: int = 4,
store_open: int = 1
) -> pd.DataFrame:
df_store = df[
(df['Store'] == store_id) &\
(df['Open'] == store_open)
].reset_index(drop=True)
df_store['Date'] = pd.to_datetime(df_store['Date'])
df_store.rename(columns= {'Date': 'ds', 'Sales': 'y'}, inplace=True)
return df_store.sort_values('ds', ascending=True)
def train_predict(
df: pd.DataFrame,
train_fraction: float,
seasonality: dict
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, int]:
# grab split data
train_index = int(train_fraction*df.shape[0])
df_train = df.copy().iloc[0:train_index]
df_test = df.copy().iloc[train_index:]
#create Prophet model
model=Prophet(
yearly_seasonality=seasonality['yearly'],
weekly_seasonality=seasonality['weekly'],
daily_seasonality=seasonality['daily'],
interval_width = 0.95
)
# train and predict
model.fit(df_train)
predicted = model.predict(df_test)
return predicted, df_train, df_test, train_index
现在我们可以将这些合并成一个单独的函数,该函数将接受一个pandas DataFrame,预处理这些数据,训练一个 Prophet 预测模型,然后返回测试集、训练数据集、测试数据集和训练集大小的预测,这里用train_index值标记。由于我们希望分发此函数的应用,我们需要使用我们在第六章“扩展”中介绍的@ray.remote装饰器。我们将num_returns=4参数传递给装饰器,让 Ray 知道这个函数将以元组的形式返回四个值。
@ray.remote(num_returns=4)
def prep_train_predict(
df: pd.DataFrame,
store_id: int,
store_open: int=1,
train_fraction: float=0.8,
seasonality: dict={'yearly': True, 'weekly': True, 'daily': False}
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, int]:
df = prep_store_data(df, store_id=store_id, store_open=store_open)
return train_predict(df, train_fraction, seasonality)
现在我们有了远程函数,我们只需要应用它。首先,我们假设数据集已经以与第一章,机器学习工程介绍中相同的方式读入到一个pandas DataFrame 中。这里的假设是数据集足够小,可以放入内存,并且不需要计算密集型的转换。这有一个优点,就是允许我们使用pandas相对智能的数据摄入逻辑,例如,可以对标题行进行各种格式化,以及在我们现在熟悉的pandas语法中使用之前,应用任何我们想要的过滤或转换逻辑。如果数据集更大或转换更密集,我们就可以使用 Ray API 中的ray.data.read_csv()方法来读取数据作为 Ray Dataset。这会将数据读入到 Arrow 数据格式中,它有自己的数据操作语法。
现在,我们已经准备好应用我们的分布式训练和测试。首先,我们可以从数据集中检索所有存储标识符,因为我们将为每一个训练一个模型。
store_ids = df['Store'].unique()
在我们做任何事情之前,我们将使用我们在第六章,扩展规模中遇到的ray.init()命令初始化 Ray 集群。这避免了在我们第一次调用远程函数时执行初始化,这意味着如果我们进行基准测试,我们可以获得实际处理的准确时间。为了提高性能,我们还可以使用ray.put()将 pandas DataFrame 存储在 Ray 对象存储中。这阻止了每次运行任务时都复制此数据集。将对象放入存储返回一个 id,然后你可以像原始对象一样将其用作函数参数。
ray.init(num_cpus=4)
df_id = ray.put(df)
现在,我们需要将我们的 Ray 任务提交到集群。每次你这样做时,都会返回一个 Ray 对象引用,这将允许我们在使用ray.get收集结果时检索该进程的数据。我在这里使用的语法可能看起来有点复杂,但我们可以一点一点地分解它。核心 Python 函数map只是将列表操作应用于zip语法的输出结果的所有元素。zip(*iterable)模式允许我们将列表推导式中的所有元素解包,这样我们就可以有一个包含预测对象引用、训练数据对象引用、测试数据对象引用以及最终的训练索引对象引用的列表。注意使用df_id来引用对象存储中的存储数据框。
pred_obj_refs, train_obj_refs, test_obj_refs, train_index_obj_refs = map(
list,
zip(*([prep_train_predict.remote(df_id, store_id) for store_id in store_ids])),
)
然后,我们需要获取这些任务的实际结果,这可以通过使用前面讨论的ray.get()来实现。
ray_results = {
'predictions': ray.get(pred_obj_refs),
'train_data': ray.get(train_obj_refs),
'test_data': ray.get(test_obj_refs),
'train_indices': ray.get(train_index_obj_refs)
}
然后,你可以使用ray_results['predictions'][<index>]等来访问每个模型的这些值。
在 Github 仓库中,文件 Chapter08/train/train_forecasters_ray.py 运行此语法并示例循环,逐个以串行方式训练 Prophet 模型以进行比较。使用 time 库进行测量,并在我的 Macbook 上运行实验,Ray 集群利用了四个 CPU,我仅用不到 40 秒就能用 Ray 训练 1,115 个 Prophet 模型,而使用串行代码则需要大约 3 分 50 秒。这几乎提高了六倍的速度,而且几乎没有进行多少优化!
我们没有涵盖将模型和元数据保存到 MLFlow 的内容,你可以使用我们在 第三章 中深入讨论的语法来完成。为了避免大量的通信开销,最好是将元数据临时存储为训练过程的结果,就像我们在存储预测的字典中做的那样,然后在最后将所有内容写入 MLFlow。这意味着你不会因为与 MLFlow 服务器的通信而减慢 Ray 进程。注意,我们还可以通过使用讨论过的 Ray Dataset API 并更改转换逻辑以使用 Arrow 语法来进一步优化这种并行处理。最后一个选择也可以是使用 Modin,之前被称为 Pandas on Ray,它允许你在利用 Ray 并行性的同时使用 pandas 语法。
现在我们开始构建我们解决方案的提供层,这样我们就可以使用这些预测模型为其他系统和用户生成结果。
使用 FastAPI 提供模型
在 Python 中,以微服务形式提供 ML 模型的最简单且可能最灵活的方法是将提供逻辑包装在一个轻量级 Web 应用程序中。Flask 多年来一直是 Python 用户中流行的选择,但现在 FastAPI Web 框架有许多优势,这意味着它应该被认真考虑作为更好的替代方案。
使 FastAPI 成为轻量级微服务优秀选择的某些特性包括:
-
数据验证:FastAPI 使用并基于 Pydantic 库,该库允许你在运行时强制执行类型提示。这允许你实现非常容易创建的数据验证步骤,使你的系统更加健壮,并有助于避免边缘情况的行为。
-
内置的异步工作流程:FastAPI 通过
async和await关键字提供开箱即用的异步任务管理,因此你可以在许多情况下相对无缝地构建所需的逻辑,而无需求助于额外的库。 -
开放规范:FastAPI 基于几个开源标准,包括 OpenAPI REST API 标准 和 JSON Schema 声明性语言,这有助于创建自动数据模型文档。这些规范有助于保持 FastAPI 的工作方式透明,并且非常易于使用。
-
自动文档生成: 上一点提到了数据模型,但 FastAPI 还使用 SwaggerUI 自动生成整个服务的文档。
-
性能: 快速是它的名字!FastAPI 使用了 异步服务器网关接口 (ASGI) 标准,而其他框架如 Flask 则使用 Web 服务器网关接口 (WSGI)。ASGI 可以在单位时间内处理更多的请求,并且效率更高,因为它可以在等待前一个任务完成之前执行任务。WSGI 接口按顺序执行指定的任务,因此处理请求需要更长的时间。
因此,上述内容是为什么使用 FastAPI 来提供本例中的预测模型可能是一个好主意的原因,但我们该如何着手去做呢?这正是我们现在要讨论的。
任何微服务都必须以某种指定的格式接收数据;这被称为“请求”。然后它将返回数据,称为“响应”。微服务的任务是摄取请求,执行请求定义或提供输入的一系列任务,创建适当的输出,然后将该输出转换为指定的请求格式。这看起来可能很基础,但回顾这一点很重要,它为我们设计系统提供了起点。很明显,在设计时,我们必须考虑以下要点:
-
请求和响应模式: 由于我们将构建一个 REST API,因此自然地,我们将指定请求和响应的数据模型,作为具有相关模式的 JSON 对象。在执行此操作时,关键是使模式尽可能简单,并且它们包含客户端(请求服务)和服务器(微服务)执行适当操作所需的所有必要信息。由于我们正在构建一个预测服务,请求对象必须提供足够的信息,以便系统提供适当的预测,上游调用服务的解决方案可以将其展示给用户或执行进一步的逻辑。响应将必须包含实际的预测数据点或指向预测位置的指针。
-
计算: 在本例中,创建响应对象(在这种情况下,是一个预测),需要计算,正如在 第一章,机器学习工程导论 中所讨论的。
设计机器学习微服务时的一个关键考虑因素是计算资源的大小以及执行它所需的适当工具。例如,如果你正在运行一个需要大型 GPU 才能进行推理的计算机视觉模型,你不能在只运行 CPU 的小型服务器上这样做,该服务器运行的是网络应用程序的后端。同样,如果推理步骤需要摄取一个 TB 的数据,这可能需要我们使用像 Spark 或 Ray 这样的并行化框架,在专用集群上运行,根据定义,它将不得不在不同的机器上运行,而不是运行服务网络应用程序的机器。如果计算需求足够小,并且从另一个位置获取数据不是太激烈,那么你可能在同一台机器上运行推理,该机器托管着网络应用程序。
-
模型管理:这是一个机器学习服务,所以当然涉及模型!这意味着,正如我们在第三章中详细讨论的,从模型到模型工厂,我们需要实施一个健壮的过程来管理适当的模型版本。这个示例的要求还意味着我们必须能够以相对动态的方式利用许多不同的模型。这需要我们仔细考虑,并使用像 MLflow 这样的模型管理工具,我们也在第三章中提到过。我们还必须考虑我们的模型更新和回滚策略;例如,我们将使用蓝/绿部署还是金丝雀部署,正如我们在第五章,部署模式和工具中讨论的那样。
-
性能监控:对于任何机器学习系统,正如我们在整本书中详细讨论的那样,监控模型的性能将至关重要,采取适当的行动来更新或回滚这些模型也同样重要。如果任何推理的真实数据不能立即返回给服务,那么这需要它自己的过程来收集真实数据和推理,然后再对它们进行所需的计算。
这些是我们构建解决方案时必须考虑的一些重要点。在本章中,我们将重点关注第 1 点和第 3 点,因为第九章将涵盖如何在批量设置中构建训练和监控系统。既然我们已经知道了一些我们想要纳入解决方案的因素,那么让我们开始动手构建吧!
响应和请求模式
如果客户端请求特定商店的预测,正如我们在需求中假设的那样,这意味着请求应该指定一些内容。首先,它应该指定商店,使用某种类型的商店标识符,该标识符将在机器学习微服务的数据模型和客户端应用程序之间保持通用。
其次,预测的时间范围应以适当的格式提供,以便应用程序可以轻松解释并提供服务。系统还应具备逻辑来创建适当的预测时间窗口,如果请求中没有提供,这是完全合理的假设,如果客户端请求“为商店 X 提供预测”,那么我们可以假设一些默认行为,提供从现在到未来的某个时间段的预测将可能对客户端应用程序有用。
满足这一点的最简单的请求 JSON 架构可能如下所示:
{
"storeId": "4",
"beginDate": "2023-03-01T00:00:00Z",
"endDate": "2023-03-07T00:00:00Z"
}
由于这是一个 JSON 对象,所有字段都是字符串类型,但它们填充了在我们 Python 应用程序中易于解释的值。Pydantic 库还将帮助我们执行数据验证,这一点我们稍后将会讨论。请注意,我们还应该允许客户端应用程序请求多个预测,因此我们应该允许这个 JSON 扩展以允许请求对象的列表:
[
{
"storeId": "2",
"beginDate": "2023-03-01T00:00:00Z",
"endDate": "2023-03-07T00:00:00Z"
},
{
"storeId": "4",
"beginDate": "2023-03-01T00:00:00Z",
"endDate": "2023-03-07T00:00:00Z"
}
]
如前所述,我们希望构建我们的应用程序逻辑,以便即使客户端只指定了store_id,系统仍然可以工作,然后我们推断适当的预测时间范围是从现在到未来的某个时间。
这意味着我们的应用程序应该在以下内容作为 API 调用的 JSON 主体提交时工作:
[
{
"storeId": "4",
}
]
为了强制执行这些请求约束,我们可以使用 Pydantic 功能,通过从 Pydantic 的BaseModel继承并创建一个数据类来定义我们刚刚做出的类型要求:
from pydantic import BaseModel
class ForecastRequest(BaseModel):
store_id: str
begin_date: str | None = None
end_date: str | None = None
如您所见,我们在这里强制执行了store_id是一个字符串,但我们允许预测的开始和结束日期可以给出为None。如果没有指定日期,我们可以根据我们的业务知识做出合理的假设,即一个有用的预测时间窗口将从请求的日期时间开始,到现在的七天。这可能是在应用程序配置中更改或提供的东西,我们在这里不会处理这个特定的方面,以便专注于更令人兴奋的内容,所以这留给读者作为有趣的练习!
在我们的案例中,预测模型将基于 Prophet 库,如前所述,这需要一个包含预测运行所需日期时间的索引。为了根据请求生成这个索引,我们可以编写一个简单的辅助函数:
import pandas as pd
def create_forecast_index(begin_date: str = None, end_date: str = None):
# Convert forecast begin date
if begin_date == None:
begin_date = datetime.datetime.now().replace(tzinfo=None)
else:
begin_date = datetime.datetime.strptime(begin_date,
'%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=None)
# Convert forecast end date
if end_date == None:
end_date = begin_date + datetime.timedelta(days=7)
else:
end_date = datetime.datetime.strptime(end_date,
'%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=None)
return pd.date_range(start = begin_date, end = end_date, freq = 'D')
这种逻辑允许我们在从模型存储层检索到输入后创建预测模型,在我们的例子中,是 MLflow。
响应对象必须以某种数据格式返回预测,并且始终必须返回足够的信息,以便客户端应用程序能够方便地将返回的对象与触发其创建的响应关联起来。满足这一点的简单模式可能如下所示:
[
{
"request": {
"store_id": "4",
"begin_date": "2023-03-01T00:00:00Z",
"end_date": "2023-03-07T00:00:00Z"
},
"forecast": [
{
"timestamp": "2023-03-01T00:00:00",
"value": 20716
},
{
"timestamp": "2023-03-02T00:00:00",
"value": 20816
},
{
"timestamp": "2023-03-03T00:00:00",
"value": 21228
},
{
"timestamp": "2023-03-04T00:00:00",
"value": 21829
},
{
"timestamp": "2023-03-05T00:00:00",
"value": 21686
},
{
"timestamp": "2023-03-06T00:00:00",
"value": 22696
},
{
"timestamp": "2023-03-07T00:00:00",
"value": 21138
}
]
}
]
我们将允许以与请求 JSON 模式相同的方式将其扩展为列表。我们将在本章的其余部分使用这些模式。现在,让我们看看我们将如何管理应用程序中的模型。
在您的微服务中管理模型
在第三章从模型到模型工厂中,我们详细讨论了您如何使用 MLflow 作为模型工件和元数据存储层在您的 ML 系统中。我们在这里也将这样做,所以假设您已经有一个运行的 MLflow Tracking 服务器,然后我们只需要定义与它交互的逻辑。如果您需要复习,请随时回顾第三章。
我们需要编写一些逻辑来完成以下操作:
-
检查在 MLflow 服务器中有可用于生产的模型。
-
检索满足我们设定的任何标准的模型版本,例如,模型不是在超过一定天数前训练的,并且它在所选范围内有验证指标。
-
如果在预测会话期间需要,可以缓存模型以供使用和重复使用。
-
如果响应对象需要,对多个模型执行上述所有操作。
对于第 1 点,我们必须在 MLflow 模型注册表中标记模型为已准备好生产,然后我们可以使用在第三章从模型到模型工厂中遇到的MlflowClient()和mlflow pyfunc功能:
import mlflow
import mlflow.pyfunc
from mlflow.client import MlflowClient
import os
tracking_uri = os.getenv(["MLFLOW_TRACKING_URI"])
mlflow.set_tracking_uri(tracking_uri)
client = MlflowClient(tracking_uri=tracking_uri)
def get_production_model(store_id:int):
model_name = f"prophet-retail-forecaster-store-{store_id}"
model =mlflow.pyfunc.load_model(
model_uri=f"models:/{model_name}/production"
)
return model
对于第 2 点,我们可以通过使用下面将要描述的 MLflow 功能来检索给定模型的指标。首先,使用模型的名称,您检索模型的元数据:
model_name = f"prophet-retail-forecaster-store-{store_id}"
latest_versions_metadata = client.get_latest_versions(
name=model_name
)
这将返回一个如下所示的数据集:
[<ModelVersion: creation_timestamp=1681378913710, current_stage='Production', description='', last_updated_timestamp=1681378913722, name='prophet-retail-forecaster-store-3', run_id='538c1cbded614598a1cb53eebe3de9f2', run_link='', source='/Users/apmcm/
dev/Machine-Learning-Engineering-with-Python-Second-Edition/Chapter07/register/artifacts/0/538c1cbded614598a1cb53eebe3de9f2/artifacts/model', status='READY', status_message='', tags={}, user_id='', version='3'>]
然后,您可以使用这些数据通过此对象检索版本,然后检索模型版本元数据:
latest_model_version_metadata = client.get_model_version(
name=model_name,
version=latest_versions_metadata.version
)
这包含看起来像这样的元数据:
<ModelVersion: creation_timestamp=1681377954142, current_stage='Production', description='', last_updated_timestamp=1681377954159, name='prophet-retail-forecaster-store-3', run_id='41f163b0a6af4b63852d9218bf07adb3', run_link='', source='/Users/apmcm/dev/Machine-Learning-Engineering-with-Python-Second-Edition/Chapter07/register/artifacts/0/41f163b0a6af4b63852d9218bf07adb3/artifacts/model', status='READY', status_message='', tags={}, user_id='', version='1'>
该模型版本的指标信息与run_id相关联,因此我们需要获取它:
latest_model_run_id = latest_model_version_metadata.run_id
run_id的值可能如下所示:
'41f163b0a6af4b63852d9218bf07adb3'
然后,您可以使用这些信息来获取特定运行的模型指标,并在其上执行任何您想要的逻辑。要检索指标值,您可以使用以下语法:
client.get_metric_history(run_id=latest_model_run_id, key='rmse')
例如,您可以使用在第二章持续模型性能测试部分中应用的逻辑,并简单地要求均方根误差低于某个指定的值,然后才允许它在预测服务中使用。
我们还可能希望允许服务在模型年龄超出容忍度时触发重新训练;这可以作为任何已实施的训练系统之上的另一层模型管理。
如果我们的训练过程由运行在 AWS MWAA 上的 Airflow DAG 编排,正如我们在第五章部署模式和工具中讨论的那样,那么以下代码可以用来调用训练管道:
import boto3
import http.client
import base64
import ast
# mwaa_env_name = 'YOUR_ENVIRONMENT_NAME'
# dag_name = 'YOUR_DAG_NAME'
def trigger_dag(mwaa_env_name: str, dag_name: str) -> str:
client = boto3.client('mwaa')
# get web token
mwaa_cli_token = client.create_cli_token(
Name=mwaa_env_name
)
conn = http.client.HTTPSConnection(
mwaa_cli_token['WebServerHostname']
)
mwaa_cli_command = 'dags trigger'
payload = mwaa_cli_command + " " + dag_name
headers = {
'Authorization': 'Bearer ' + mwaa_cli_token['CliToken'],
'Content-Type': 'text/plain'
}
conn.request("POST", "/aws_mwaa/cli", payload, headers)
res = conn.getresponse()
data = res.read()
dict_str = data.decode("UTF-8")
mydata = ast.literal_eval(dict_str)
return base64.b64decode(mydata['stdout']).decode('ascii')
下几节将概述如何将这些组件组合在一起,以便 FastAPI 服务可以在讨论如何容器化和部署应用程序之前,围绕这些逻辑的几个部分进行包装。
将所有这些整合在一起
我们已经成功定义了我们的请求和响应模式,并且我们已经编写了从我们的模型存储库中提取适当模型的相关逻辑;现在剩下的只是将这些整合在一起,并使用模型进行实际推理。这里有几个步骤,我们将现在分解。FastAPI 后端的主要文件名为 app.py,其中包含几个不同的应用程序路由。对于本章的其余部分,我将在每个相关代码片段之前展示必要的导入,但实际的文件遵循 PEP8 规范,即导入位于文件顶部。
首先,我们定义我们的日志记录器,并设置一些全局变量作为检索到的模型和服务处理程序的轻量级内存缓存:
# Logging
import logging
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
logging.basicConfig(format = log_format, level = logging.INFO)
handlers = {}
models = {}
MODEL_BASE_NAME = f"prophet-retail-forecaster-store-"
使用全局变量在应用程序路由之间传递对象只是一种好主意,如果你知道这个应用程序将以独立方式运行,并且不会因为同时接收来自多个客户端的请求而创建竞争条件。当这种情况发生时,多个进程会尝试覆盖变量。你可以将这个例子改编一下,用缓存如 Redis 或 Memcache 来替换全局变量的使用,作为练习!
我们接下来需要实例化一个 FastAPI 应用程序对象,并且可以通过使用启动生命周期事件方法来定义我们希望在启动时运行的任何逻辑:
from fastapi import FastAPI
from registry.mlflow.handler import MLFlowHandler
app = FastAPI()
@app.on_event("startup")
async def startup():
await get_service_handlers()
logging.info("Updated global service handlers")
async def get_service_handlers():
mlflow_handler = MLFlowHandler()
global handlers
handlers['mlflow'] = mlflow_handler
logging.info("Retreving mlflow handler {}".format(mlflow_handler))
return handlers
如前所述,FastAPI 非常适合支持异步工作流程,允许在等待其他任务完成时使用计算资源。服务处理程序的实例化可能是一个较慢的过程,因此在这里采用这可能是有用的。当调用使用 async 关键字的函数时,我们需要使用 await 关键字,这意味着在调用 async 函数的函数中,其余部分可以暂停,直到返回结果并释放用于其他任务的任务资源。在这里,我们只有一个处理程序需要实例化,它将处理与 MLflow 跟踪服务器的连接。
registry.mlflow.handler 模块是我编写的一个包含 MLFlowHandler 类的模块,其中包含我们将在整个应用程序中使用的各种方法。以下是该模块的内容:
import mlflow
from mlflow.client import MlflowClient
from mlflow.pyfunc import PyFuncModel
import os
class MLFlowHandler:
def __init__(self) -> None:
tracking_uri = os.getenv('MLFLOW_TRACKING_URI')
self.client = MlflowClient(tracking_uri=tracking_uri)
mlflow.set_tracking_uri(tracking_uri)
def check_mlflow_health(self) -> None:
try:
experiments = self.client.search_experiments()
return 'Service returning experiments'
except:
return 'Error calling MLFlow'
def get_production_model(self, store_id: str) -> PyFuncModel:
model_name = f"prophet-retail-forecaster-store-{store_id}"
model = mlflow.pyfunc.load_model(
model_uri=f"models:/{model_name}/production"
)
return model
如您所见,此处理程序具有检查 MLflow 跟踪服务器是否正常运行并获取生产模型的方法。您还可以添加用于查询 MLflow API 以收集我们之前提到的度量数据的方法。
现在回到主要的 app.py 文件,我编写了一个小的健康检查端点来获取服务的状态:
@app.get("/health/", status_code=200)
async def healthcheck():
global handlers
logging.info("Got handlers in healthcheck.")
return {
"serviceStatus": "OK",
"modelTrackingHealth": handlers['mlflow'].check_mlflow_health()
}
接下来是一个获取特定零售店 ID 的生产模型的方法。此函数检查模型是否已存在于 global 变量中(作为简单的缓存),如果不存在,则添加它。您可以将此方法扩展到包括关于模型年龄或您想要使用的任何其他指标的逻辑,以决定是否将模型拉入应用程序:
async def get_model(store_id: str):
global handlers
global models
model_name = MODEL_BASE_NAME + f"{store_id}"
if model_name not in models:
models[model_name] = handlers['mlflow'].\
get_production_model(store_id=store_id)
return models[model_name]
最后,我们有预测端点,客户端可以使用我们之前定义的请求对象向此应用程序发起请求,并基于我们从 MLflow 获取的 Prophet 模型获得预测。就像本书的其他部分一样,为了简洁,我省略了较长的注释:
@app.post("/forecast/", status_code=200)
async def return_forecast(forecast_request: List[ForecastRequest]):
forecasts = []
for item in forecast_request:
model = await get_model(item.store_id)
forecast_input = create_forecast_index(
begin_date=item.begin_date,
end_date=item.end_date
)
forecast_result = {}
forecast_result['request'] = item.dict()
model_prediction = model.predict(forecast_input)[['ds', 'yhat']]\
.rename(columns={'ds': 'timestamp', 'yhat': 'value'})
model_prediction['value'] = model_prediction['value'].astype(int)
forecast_result['forecast'] = model_prediction.to_dict('records')
forecasts.append(forecast_result)
return forecasts
然后,您可以在本地运行应用程序:
uvicorn app:app –-host 127.0.0.1 --port 8000
如果您想在不运行应用程序的情况下开发应用程序,可以添加 –reload 标志。如果您使用 Postman(或 curl 或您选择的任何其他工具)并使用我们之前描述的请求体查询此端点,如 图 8.2 所示,您将得到类似 图 8.3 中所示的输出。
图 8.2:Postman 应用中对 ML 微服务的请求。
图 8.3:使用 Postman 查询时 ML 微服务的响应。
就这样,我们得到了一个相对简单的机器学习微服务,当查询端点时,它将返回零售店的 Prophet 模型预测!现在,我们将继续讨论如何将此应用程序容器化并部署到 Kubernetes 集群以实现可扩展的服务。
容器化和部署到 Kubernetes
当我们在 第五章 中介绍 Docker 时,部署模式和工具,我们展示了如何使用它来封装您的代码,然后在许多不同的平台上一致地运行它。
在这里,我们将再次执行此操作,但带着这样的想法:我们不仅想在不同的基础设施上以单例模式运行应用程序,实际上我们希望允许许多不同的微服务副本同时运行,并且通过负载均衡器有效地路由请求。这意味着我们可以将可行的方法扩展到几乎任意大的规模。
我们将通过执行以下步骤来完成这项工作:
-
使用 Docker 容器化应用程序。
-
将此 Docker 容器推送到 Docker Hub,作为我们的容器存储位置(您可以使用 AWS Elastic Container Registry 或其他云服务提供商的类似解决方案来完成此步骤)。
-
创建一个 Kubernetes 集群。我们将使用 minikube 在本地执行此操作,但您也可以在云服务提供商上使用其管理的 Kubernetes 服务来完成此操作。在 AWS 上,这是 弹性 Kubernetes 服务(EKS)。
-
在可以扩展的集群上定义一个服务和负载均衡器。在这里,我们将介绍在 Kubernetes 集群上通过程序定义服务和部署特性的概念。
-
部署服务并测试其是否按预期工作。
让我们现在进入下一节,详细说明这些步骤。
容器化应用程序
如本书前面所述,如果我们想使用 Docker,我们需要在 Dockerfile 中提供如何构建容器以及安装任何必要的依赖项的说明。对于这个应用程序,我们可以使用基于可用的 FastAPI 容器镜像之一的一个,假设我们有一个名为 requirements.txt 的文件,其中包含我们所有的 Python 包依赖项:
FROM tiangolo/uvicorn-gunicorn-fastapi:latest
COPY ./requirements.txt requirements.txt
RUN pip install --no-cache-dir --upgrade -r requirements.txt
COPY ./app /app
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
然后,我们可以使用以下命令构建这个 Docker 容器,其中我已将容器命名为 custom-forecast-service:
docker build -t custom-forecast-service:latest .
一旦成功构建,我们需要将其推送到 Docker Hub。您可以在终端中登录 Docker Hub,然后通过运行以下命令将内容推送到您的账户:
docker login
docker push <DOCKER_USERNAME>/custom-forecast-service:latest
这意味着其他构建过程或解决方案可以下载并运行您的容器。
注意,在您将内容推送到 Docker Hub 之前,您可以通过执行以下类似命令来测试容器化应用程序是否可以运行,其中我包含了一个平台标志,以便在我的 MacBook Pro 上本地运行容器:
docker run -d --platform linux/amd64 -p 8000:8080 electricweegie/custom-forecast-service
现在我们已经构建并分享了容器,我们可以通过部署到 Kubernetes 来扩展它。
使用 Kubernetes 扩展
与 Kubernetes 一起工作可能对即使是经验最丰富的开发者来说也是一个陡峭的学习曲线,所以我们在这里只会触及表面,并为您提供足够的资源,让您开始自己的学习之旅。本节将指导您完成将您的 ML 微服务部署到本地运行的 Kubernetes 集群的步骤,因为部署到远程托管集群(进行一些小的修改)需要采取相同的步骤。在生产环境中无缝运行 Kubernetes 集群需要考虑网络、集群资源配置和管理、安全策略等多个方面。详细研究所有这些主题需要一本完整的书。实际上,Aly Saleh 和 Murat Karsioglu 的《Kubernetes in Production Best Practices》是一本很好的资源,可以帮助您了解许多这些细节。在本章中,我们将专注于理解您开始使用 Kubernetes 开发 ML 微服务所需的最重要步骤。
首先,让我们为 Kubernetes 开发做好准备。在这里,我将使用 minikube,因为它有一些方便的实用工具,可以设置可以通过 REST API 调用服务。在这本书的先前部分,我使用了 kind(在 Docker 中运行的 Kubernetes),您也可以在这里使用它;只需准备好做一些额外的工作并使用文档。
要在你的机器上设置 minikube,请遵循官方文档中针对您平台的安装指南,链接为 minikube.sigs.k8s.io/docs/start/。
一旦安装了 minikube,您可以使用默认配置启动您的第一个集群,命令如下:
minikube start
一旦集群启动并运行,您可以在终端中使用以下命令将 fast-api 服务部署到集群:
kubectl apply –f direct-kube-deploy.yaml
其中 direct-kube-deploy.yaml 是一个包含以下代码的清单:
apiVersion: apps/v1
kind: Deployment
metadata:
name: fast-api-deployment
spec:
replicas: 2
selector:
matchLabels:
app: fast-api
template:
metadata:
labels:
app: fast-api
spec:
containers:
- name: fast-api
image: electricweegie/custom-forecast-service:latest
resources:
limits:
memory: "128Mi"
cpu: "500m"
ports:
- containerPort: 8000
本清单定义了一个 Kubernetes Deployment,该 Deployment 创建并管理包含一个名为 fast-api 的容器的 Pod 模板的两个副本。这个容器运行的是我们之前创建并发布的 Docker 镜像,即 electricweegie/custom-forecast-service:latest。它还定义了运行在 Pod 内部容器上的资源限制,并确保容器监听端口 8000。
现在我们已经创建了一个包含应用程序的 Deployment,我们需要将此解决方案暴露给传入流量,最好是使用负载均衡器,以便高效地将传入流量路由到应用程序的不同副本。要在 minikube 中完成此操作,你必须执行以下步骤:
-
默认情况下,minikube 集群上运行的服务不提供网络或主机机访问,因此我们必须使用
tunnel命令创建一个路由来公开集群 IP 地址:minkube tunnel -
打开一个新的终端窗口。这允许隧道持续运行,然后你需要创建一个类型为
LoadBalancer的 Kubernetes 服务,该服务将访问我们已设置的deployment:kubectl expose deployment fast-api-deployment --type=LoadBalancer --port=8080 -
你可以通过运行以下命令来获取访问服务的公网 IP:
kubectl get svc这应该会给出类似以下输出的结果:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE fast-api-deployment LoadBalancer 10.96.184.178 10.96.184.178 8080:30791/TCP 59s
然后,你将能够使用负载均衡器服务的 EXTERNAL-IP 来访问 API,因此你可以导航到 Postman 或你的其他 API 开发工具,并使用 http://<EXTERNAL-IP>:8080 作为你成功构建并部署到 Kubernetes 的 FastAPI 服务的根 URL!
部署策略
如第五章部署模式和工具中所述,你可以使用几种不同的策略来部署和更新你的 ML 服务。这包括两个组件:一个是模型的部署策略,另一个是托管应用程序或为模型提供服务的管道的部署策略。这两个策略可以同时执行。
在这里,我们将讨论如何将我们刚刚部署到 Kubernetes 的应用程序更新,并使用金丝雀和蓝绿部署策略。一旦你学会了如何对基础应用程序进行此操作,可以通过在金丝雀或蓝绿部署中指定一个具有适当标签的模型版本,来添加对模型的类似更新策略。例如,我们可以使用 MLflow 中模型注册表的“staging”阶段来提供我们的“蓝”模型,然后在过渡到“绿色”时,确保我们已经使用本章和第三章从模型到模型工厂中概述的语法,将此模型移动到模型注册表的“生产”阶段。
由于金丝雀部署是在生产环境的一个较小子集中部署应用程序的新版本,我们可以创建一个新的部署清单,强制只创建和运行一个金丝雀应用的副本(在较大的集群中可能更多)。在这种情况下,这只需要你编辑之前的副本数量为“1。”
为了确保金丝雀部署可以访问相同的负载均衡器,我们必须利用 Kubernetes 中的资源标签概念。然后我们可以部署一个选择具有所需标签的资源负载均衡器。以下是一个部署此类负载均衡器的示例清单:
apiVersion: v1
kind: Service
metadata:
name: fast-api-service
spec:
selector:
app: fast-api
ports:
- protocol: TCP
port: 8000
targetPort: 8000
type: LoadBalancer
或者使用与上面相同的 minkube 语法:
kubectl expose deployment fast-api-deployment --name=fast-api-service --type=LoadBalancer --port=8000 --target-port=8000 --selector=app=fast-api
在部署此负载均衡器和金丝雀部署之后,你可以然后实现集群或模型上的日志监控,以确定金丝雀是否成功并且应该获得更多流量。在这种情况下,你只需更新部署清单以包含更多副本。
蓝绿部署将以非常相似的方式工作;在每种情况下,你只需编辑 Deployment 清单,将应用程序标记为蓝色或绿色。然而,蓝绿部署与金丝雀部署的核心区别在于流量的切换更为突然,在这里我们可以使用以下命令,该命令使用kubectl CLI 来修补服务选择器的定义,将生产流量切换到绿色部署:
kubectl patch service fast-api-service -p '{"spec":{"selector":{"app":"fast-api-green"}}}'
这就是你在 Kubernetes 中执行金丝雀和蓝/绿部署的方式,以及如何使用它来尝试不同的预测服务版本;试试看吧!
摘要
在本章中,我们通过一个示例展示了如何将本书前七章中的工具和技术结合起来,以解决一个实际业务问题。我们详细讨论了为什么对动态触发的预测算法的需求可以迅速导致需要多个小型服务无缝交互的设计。特别是,我们创建了一个包含处理事件、训练模型、存储模型和执行预测的组件的设计。然后,我们通过考虑诸如任务适用性以及可能的开发者熟悉度等因素,介绍了如何在现实场景中选择我们的工具集来构建这个设计。最后,我们仔细定义了构建解决方案所需的关键代码,以重复和稳健地解决问题。
在下一章,也就是最后一章,我们将构建一个批处理机器学习过程的示例。我们将命名这个模式为提取、转换、机器学习,并探讨任何旨在构建此类解决方案的项目应涵盖的关键点。
加入我们的社区 Discord
加入我们的社区 Discord 空间,与作者和其他读者进行讨论: