原文:
zh.annas-archive.org/md5/5ca914896ff49b8bc0c3f25ca845e22b
译者:飞龙
第七章:无服务器机器学习的扩展性。
本章节涵盖以下几个方面
-
使用 IterableDataset 与 AWS 和其他云平台
-
理解 PyTorch 编程中的 GPU
-
使用 GPU 核对梯度下降进行扩展
-
利用线性回归对 DC 出租车数据集进行基准测试。
在第 5 和 6 章节中,您已经学会了如何在小规模上使用 PyTorch,实例化由几百个数据值组成的张量并仅在几个参数中训练机器学习模型。第六章中使用的规模意味着,要训练机器学习模型,您可以对整个模型参数集、参数梯度和整个训练数据集进行梯度下降,假定它们可以轻松适应单个节点的内存,并因此随时可供梯度下降算法使用。
本章节介绍扩大机器学习系统所需的概念。建立在您对梯度下降的现有知识(有关刷新,请参见附录 A)的基础上,学习如何对数据集批次执行梯度下降。接下来,使用批次来帮助您扩展到不适合单个节点的机器学习平台内存的数据集。您还将学习如何在单个节点上进行扩展,换句话说,利用多个处理器(如 CPU 和 GPU)。本章节的概念还在第八章节中重用,以解释扩展出机器学习系统,换句话说,确保您的机器学习系统可以利用由多个互连处理节点组成的分布式计算集群的计算能力。
7.1 如果我的机器学习模型只需要单个节点,该怎么办?
本章节教授如何确定 PyTorch 机器学习系统的扩展能力与您的机器学习项目是否有关。
如果(a)您使用的数据集可以适应您机器学习平台的单个节点内存,(b)即使启动系统后您的数据集仍可适应内存,(c)您认为 CPU 或 GPU 上的机器学习代码的性能已足够,则可以放弃本章和下一章中描述的扩展技巧。
注意作为一般经验法则,如果您的机器学习模型和数据集保证适合内存,请将其保留在内存中。
针对内存数据集的机器学习算法在计算效率和机器学习效果方面提供了最佳性能。这意味着当使用内存数据上的机器学习时,你的机器学习模型训练和推断将需要更少的时间,并且你将能够更快地达到最佳的损失和度量性能。此外,对于适合内存的数据集,你可以使用单节点的机器学习框架(如 scikit-learn)在进入生产之前开发和测试各种机器学习模型。如果你的计划是避免实现用于扩展机器学习的代码,你可以考虑跳转到第九章继续进行特征选择和工程。
在你匆忙前进之前,你应该记住,“The Bitter Lesson”是由杰出计算机科学家和人工智能研究员 Rich Sutton 在 2019 年发表的一篇有影响力的论文(www.incompleteideas.net/IncIdeas/BitterLesson.html
),该论文认为利用大规模计算能力的机器学习系统一直以来效果最佳,且差距较大。Sutton 的论文描述了在围棋游戏、语音识别和计算机视觉等人工智能研究的不同领域取得突破性成果的例子。如果你正在构建托管在云平台上的机器学习系统,你的系统可以利用的信息技术能力规模受到财务预算而不是技术限制的限制。因此,在你的机器学习系统设计中,你需要对你的系统预计在云中使用的信息技术资源的规模做出决策。如果你正在构建需要在市场上表现优越或在学术领域提供最新成果的机器学习系统,那么你应该从“苦涩的教训”中汲取教训,并为扩展和缩放设计系统。
如果你打算认真对待“苦涩的教训”,并扩展你的机器学习系统,你需要付出一些努力,深入了解数据集批处理、使用 GPU 和使用 PyTorch 进行分布式处理。尽管有许多流行和有效的机器学习算法,包括梯度提升决策树,但梯度下降和深度学习的优势在于它们能够利用由 Rich Sutton 描述的规模的计算、存储和网络的优势。
7.2 使用 IterableDataset 和 ObjectStorageDataset
本节介绍了 IterableDataset 类的应用,它可以帮助你支持 out-of-memory 和流式数据集的梯度下降。你还将了解到 ObjectStorageDataset,它可以帮助你使用位于 AWS 对象存储或其他主要云提供商的类似服务中的数据集。
在 out-of-memory 数据集中,数据集中的一个示例可能存储在磁盘上或者你机器学习平台的任意节点的内存中。map-style 数据集(在 getitem 方法中使用的基于索引的内存假设)不适用于这种 out-of-memory 模型。此外,map-style 数据集(如第六章所述)假设数据集必须使用 len 方法,因此对于概念上无界的流式数据集不适用。
新的 torch.utils.data.IterableDataset,在 PyTorch 1.2 版本中引入,消除了定义 getitem 和 len 方法的要求,而只需定义一个 iter 方法,可以与 Python 内置的迭代器 API 一起使用。
7.1 IterableDataset 子类声明草图
import torch.utils.data.IterableDataset
class MyIterableDataset(Dataset):
def __init__(self, ...):
...
def __iter__(self):
...
例如,要从数据集中检索单个批次的示例并将其分配给批次变量,可以使用 Python 的 next 和 iter 函数:
batch = next(iter(MyIterableDataset(...)))
虽然批量中的示例数量未作为 IterableDataset 类的一部分指定,但 IterableDataset 类的各个实现和实例负责管理批量大小。例如,在本书的其余部分中使用的 ObjectStorageDataset 类中,批量大小被指定为类的 init 方法的参数之一。
就像 TensorDataset(在第六章中描述)为基于张量的、内存中的数据集提供了对 map-style 接口的支持一样,ObjectStorageDataset 为基于张量的、out-of-memory 数据集提供了对 iterable-style 接口的支持。当你安装 PyTorch 时,默认情况下不会提供 ObjectStorageDataset,因此你需要从 Kaen 框架中单独安装它
pip install kaen[osds]
安装后,可以使用以下命令在运行时导入该类
from kaen.torch import ObjectStorageDataset as osds
ObjectStorageDataset 类为以 CSV 格式存储的数据集提供了标准的 PyTorch 接口,无论它们是位于公共云对象存储中还是位于本地文件系统中。对于类的 iter 方法的每次调用,结果是来自基于 CSV 的数据集的数值的 PyTorch 张量。在第四章的 dctaxi_dev _test.py PySpark 作业创建的 DC 出租车开发数据集的情况下,这意味着 ObjectStorageDataset 返回的张量必须分离为执行梯度下降迭代所需的标签(y)和特征(X)。例如,这可以使用 Python 切片表示法来完成 ❷。
7.2 划分批量张量
batch = next(iter(osds(...)))
def batchToXy(batch):
batch = batch.squeeze_() ❶
return batch[:, 1:], batch[:, 0] ❷
X_batch, y_batch = batchToXy(batch)
❶ 消除张量的前导(批量)维度。
❷ 将批次切片为第一个(y_batch)和剩余列(X_batch)。
第一列中的所有行都分配给标签张量 y_batch,剩余列的所有行都分配给特征张量 X_batch。
要实例化 ObjectStorageDataset,您必须指定一个 URL 样式路径(类似于 Unix 通配符字符串),该路径指向您的 CSV 格式数据集的位置。例如,如果已经为包含开发数据集的 S3 存储桶配置了 BUCKET_ID 和 AWS_DEFAULT_REGION 环境变量,您可以使用以下命令实例化该类
import os
BUCKET_ID = os.environ['BUCKET_ID']
AWS_DEFAULT_REGION = os.environ['AWS_DEFAULT_REGION']
BATCH_SIZE = 1_048_576 # = 2 ** 20
train_ds = \
osds(f"s3://dc-taxi-{BUCKET_ID}-{AWS_DEFAULT_REGION}/csv/dev/part*.csv",
partitions_glob = f"s3://dc-taxi-{BUCKET_ID}-
➥ {AWS_DEFAULT_REGION}/csv/dev/.meta/shards/*.csv",
storage_options = {'anon': False},
batch_size = BATCH_SIZE)
其中 train_ds 被分配了 ObjectStorageDataset 的一个实例。由于 ObjectStorageDataset 支持通配符字符 (),用于创建 train_ds 实例的 Python f-string 指定数据集应包括 dc-taxi- B U C K E T I D − {BUCKET_ID}- BUCKETID−{AWS_DEFAULT_REGION} S3 存储桶中匹配 /csv/dev/part.csv glob 的所有对象。
ObjectStorageDataset 的 partitions_glob 参数指向有关匹配 /csv/dev/part*.csv glob 的 CSV 部分文件的元数据文件。请回想 dctaxi_dev_test.py PySpark 作业将 Spark 分区(也称为部分文件)元数据保存到开发和测试数据集的 .meta/shards 子文件夹中。对于数据集的开发部分,您可以通过将其加载到内存中作为 pandas DataFrame 来预览此元数据,
import pandas as pd
partitions_df = pd.read_csv(f"s3://dc-taxi-{BUCKET_ID}-
➥ {AWS_DEFAULT_REGION}/csv/dev/.meta/shards/*.csv")
print(partitions_df[:5])
这应该会产生类似以下的输出:
id count
77 164315
10 165314
31 165168
1 165436
65 164777
其中 id 列表示 .meta/shards 子文件夹中的一个部分文件的 ID,count 列表示部分文件中行数(记录数)的计数。
ObjectStorageDataset 被设计为在最短可能的时间内实例化,以启动梯度下降的迭代。实际上,这意味着 ObjectStorageDataset 仅缓存内存和磁盘上需要从数据集返回第一批示例的数据集对象,如图 7.1 所示。
图 7.1 ObjectStorageDataset 使用对象存储的多级缓存访问
在图 7.1 的示例中,ObjectStorageDataset 使用一个虚构的 src S3 存储桶实例化,该存储桶包含以完整 URL 样式路径 s3://src/data/part*.csv 作为 CSV 格式对象(图 7.1 的右侧)。数据集的分区(即名称匹配 part*.csv 的 CSV 格式对象)位于 src 存储桶的 data 文件夹下。在图 7.1 中,part*.csv 对象显示为 S3 存储桶中的带编号的正方形。为了说明,假定 S3 存储桶中的每个 part*.csv 对象都包含 1,000 个示例,这些示例以 CSV 格式的每行一行表示。
在使用 batch_size 为 2,048 实例化 ObjectStorageDataset 后(计算节点 Python 运行时的左侧),ObjectStorageDataset 的实现会触发从 S3 存储桶到计算节点的文件系统缓存的三个数据集分区的网络传输。由于 S3 中的每个对象有 1,000 行,需要将 3 个对象(总共 3,000 行)从 S3 传输到计算节点的文件系统,以使 ObjectStorageDataset 实例生成一个 2,048 行的批次。在图中,文件系统缓存的位置显示为 /tmp 目录;但是,Linux 操作系统特定的位置可能会因操作系统默认值而异。文件系统缓存是为了在多次重复训练机器学习模型的过程中最小化网络上的总数据传输。
注意,分区的大小(行数)完全独立于用于实例化 ObjectStorageDataset 的 batch_size,这意味着 batch_size 可以变化,而分区的大小保持不变。在本书中使用的 DC 出租车项目中,分区的数量和大小在保存清理后的数据集到对象存储中的 PySpark 作业中指定。一般来说,数据集分区的数量和大小取决于机器学习项目的具体情况,尽管如果您使用的是常见的 100 Mbps 网络接口,最好选择 100—200 MB 范围内的分区大小以实现有效的网络传输。
注意:除非 URL 样式路径以 file:// 协议处理程序开头,或者数据集源自节点的文件系统,否则数据集分区将被复制到文件系统缓存中。
当单个批次的训练示例适合内存时,在分区被缓存到文件系统后,缓存了分区 1、2 和第 3 个分区的前 48 行(在图 7.1 中以虚线显示)的内存中,这些构成了一个大小为 2,048 的碎片,作为 PyTorch 张量被缓存在内存中。ObjectStorageDataset 的每次对 iter 方法的调用都会清空内存缓存,触发网络传输将下一个碎片所需的额外数据集分区从存储桶传输到文件系统缓存目录,并将下一个 2,048 个示例加载到内存中作为 PyTorch 张量。
本节描述的 ObjectStorageDataset 的所有功能都适用于驻留在主要云提供商的无服务器对象存储服务中的数据集。¹尽管本书中的示例侧重于使用 AWS 和 S3,但您可以通过修改类的 URL 样式 glob 命名参数中指定的协议,轻松地将 ObjectStorageDataset 类的实例重新定向到不同的云提供商(或本地文件系统):
-
例如使用 gcs:// 来表示 Google Cloud Storage,
-
osds(f"gcs://dc-taxi-${BUCKET_ID}-${AWS_DEFAULT_REGION}/test/part*.csv")
-
例如使用 abfs:// 来表示 Azure Blob Storage 或 Azure Datalake Gen2,
-
osds(f"abfs://dc-taxi-${BUCKET_ID}-${AWS_DEFAULT_REGION}/test/part*.csv")
-
对于位于本地文件系统上的文件,使用 file://,例如使用
-
osds("file://home/user/test/part*.csv")
使用内存中的数据集进行梯度下降
在本节中,你将扩展第六章中解释的基本线性回归示例,以计算 DC 出租车训练和测试数据集的弱基准性能指标。
到目前为止,在本章中,你已经学会了使用基于批次的 PyTorch 梯度下降,使用 Dataset 和 DataLoader 类的实例以及只有一个模型参数的基本线性回归模型。由于你准备用于训练的 DC 出租车数据集包含八个特征(接送地点的纬度和经度坐标,以及行程的年份,月份,星期几和小时),要执行线性回归,你需要将机器学习模型扩展到至少八个参数,每个特征一个。
注意,到目前为止,你看到的线性回归示例都没有使用偏置参数。这是有意为之的,以简化示例代码:由于先前的线性回归示例依赖于具有零均值的数据集,因此不需要偏置参数。然而,DC 出租车数据集的列在下一个示例中不具有零均值。因此,你将添加一个额外的张量标量来表示线性回归的偏置参数。
以前,你使用 torch.randn 方法通过从正态分布中采样来初始化模型参数,但由于你正在过渡到更复杂的模型,你可以利用 PyTorch 提供的更好的模型参数初始化方案。
Kaiming 初始化是由何等人在 2015 年的一篇名为《深入研究整流器:在 ImageNet 分类中超越人类水平的性能》的重要研究论文中流行化的。Kaiming 初始化通过考虑需要初始化的模型参数的数量来设置初始模型参数值。要使用 Kaiming 初始化,你只需将对 torch.empty 的调用包装在 torch.nn.init.kaiming_uniform_ 方法中。
列表 7.3 使用 Kaiming 初始化进行模型参数初始化
pt.set_default_dtype(pt.float64) ❶
FEATURE_COUNT = 8
w = pt.nn.init.kaiming_uniform_(pt.empty(FEATURE_COUNT,
1, requires_grad=True)) ❷
b = pt.nn.init.kaiming_uniform_(pt.empty(1,
1, requires_grad = True)) ❸
使用 torch.float64 作为新创建张量的 dtype。
这里,模型参数(也称为线性回归中的系数)被分配给变量 w,模型偏置(截距)被分配给 b。
注意:正如第五章中更详细地解释的那样,kaiming_uniform_ 方法是 PyTorch 中的一个例子,它是一个就地方法,方法名后面有一个下划线。由于本章中对 Kaiming 初始化的示例使用了就地方法,因此由 empty 方法返回的张量值将被初始化替换。
在第 7.2 节中,你已经看到默认情况下,ObjectStorageDataset 将 float64 作为批量张量的 dtype 返回。如第五章所述,PyTorch 要求张量在执行诸如矩阵乘法之类的操作之前具有相同的 dtype。列表 7.3 中使用的 set_default_dtype 方法❶确保 w 和 b 张量使用 float64 数据类型创建,以匹配 ObjectStorageDataset 返回的 dtype。
要利用修改后的模型参数,你需要改变梯度下降迭代的前进步骤的细节。此时,由于 DC 出租车数据集的特征张量 X 的形状为 torch.Size([DATASET_SIZE, FEATURE_COUNT]),模型参数张量 w 的形状为 torch.Size([FEATURE_COUNT, 1]),它们的乘积必须具有 torch.Size([DATASET_SIZE, 1])的形状。然而,如列表 7.2 中所述,从 ObjectStorageDataset 切片创建的 y_batch 张量的形状为 torch.Size([DATASET_SIZE])。因此,在计算损失指标期间,在 y_batch 和 y_est 张量进行减法计算之前,你应该使用 PyTorch 的 squeeze 方法更新 y_est 张量,以确保它们的形状都是 torch.Size([DATASET_SIZE]):
def forward(X):
y_est = X @ w + b
return y_est.squeeze_()
经过这些变化,DC 出租车数据集的基线线性回归实现已经准备就绪。
列表 7.4 使用线性回归的弱基线
import os
import time
import torch as pt
from torch.utils.data import TensorDataset, DataLoader
from kaen.torch import ObjectStorageDataset as osds
pt.manual_seed(0);
pt.set_default_dtype(pt.float64)
BUCKET_ID = os.environ['BUCKET_ID']
AWS_DEFAULT_REGION = os.environ['AWS_DEFAULT_REGION']
BATCH_SIZE = 2 ** 20 #evaluates to 1_048_576
train_ds = osds(f"s3://dc-taxi-{BUCKET_ID}-
➥ {AWS_DEFAULT_REGION}/csv/dev/part*.csv",
storage_options = {'anon': False},
batch_size = BATCH_SIZE)
train_dl = DataLoader(train_ds, batch_size=None)
FEATURE_COUNT = 8
w = pt.nn.init.kaiming_uniform_(pt.empty(FEATURE_COUNT,
1, requires_grad=True))
b = pt.nn.init.kaiming_uniform_(pt.empty(1,
1, requires_grad = True))
def batchToXy(batch):
batch = batch.squeeze_()
return batch[:, 1:], batch[:, 0]
def forward(X):
y_est = X @ w + b
return y_est.squeeze_()
LEARNING_RATE = 0.03
optimizer = pt.optim.SGD([w, b], lr = LEARNING_RATE)
GRADIENT_NORM = None ❶
ITERATION_COUNT = 5
for iter_idx, batch in zip(range(ITERATION_COUNT), train_dl):
start_ts = time.perf_counter()
X, y = batchToXy(batch)
y_est = forward(X)
mse = pt.nn.functional.mse_loss(y_est, y)
mse.backward()
pt.nn.utils.clip_grad_norm_([w, b], ❷
GRADIENT_NORM) if GRADIENT_NORM else None ❸
optimizer.step()
optimizer.zero_grad()
sec_iter = time.perf_counter() - start_ts
print(f"Iteration: {iter_idx:03d}, Seconds/Iteration: {sec_iter:.3f}
➥ MSE: {mse.data.item():.2f}")
❶ 假设 GRADIENT_NORM 未初始化,将其设置为 None。
❷ 如果梯度高于 GRADIENT_NORM,则剪裁梯度;否则不进行任何操作。
列表 7.4 中的 ITERATION_COUNT 故意设置为 5,因为一旦执行列表中的代码,你将会看到类似以下的输出:
WARNING:root:defaulting to batch_size of 1048576
WARNING:root:stats_glob is not specified at initialization, defaulting to
stats_glob=s3://dc-taxi-c6e91f06095c3d7c61bcc0af33d68382-us-west-2/csv/dev/.meta/stats/part-00000-e4fcf448-1123-4bf4-b2bc-9768d30c6dd6-c000.csv
Iteration: 000, Seconds/Iteration: 0.020 MSE: 1590566.22
Iteration: 001, Seconds/Iteration: 0.024 MSE: 95402822161212448.00
Iteration: 002, Seconds/Iteration: 0.021 MSE:
➥ 5722549747136962931644694528.00
Iteration: 003, Seconds/Iteration: 0.023 MSE:
➥ 343256645163430856187799115795093520384.00
Iteration: 004, Seconds/Iteration: 0.021 MSE:
➥ 20589650711877918152593680659301796448689601904640.00
请注意,与第六章中的线性回归示例不同,此输出中的损失函数未收敛。如果在看本书之前从未见过梯度下降产生这种行为,那么恭喜你!你刚刚观察到了你的第一个梯度爆炸!
如果你对这个结果感到意外,回想一下第六章中使用的合成 X 和 y 数据张量:它们的值的平均值为零,相对较小。相比之下,DC 出租车数据集中的数据由原始的位置和出租车费用值组成,具有较大的值和非零均值。你将在本书的后续部分学习有关如何正确准备数据集并防止梯度爆炸(和消失)的技术。现在,你应该知道可以使用内置的 PyTorch torch.nn.utils.clip_grad_norm_ 方法轻松解决梯度爆炸的问题。
列表 7.4 中的前两个注释 ❶ 和 ❸ 展示了如何在梯度下降迭代中包含梯度裁剪。当您在执行列表中的代码时观察到梯度爆炸时,GRADIENT_NORM 被设置为 None ❶,这将关闭梯度裁剪。要启用梯度裁剪,GRADIENT_NORM 的值应设置为正的小数值。该值被用作模型张量中最大梯度值的上限。换句话说,梯度裁剪相当于在传递给 clip_grad_norm 方法的张量的每个梯度值上运行 Python 的 min(gradient, GRADIENT_NORM) 函数。因此,在 backward 步骤(设置可能爆炸的梯度值)之后但优化器步骤(使用梯度值来更新模型参数)之前,使用 clip_grad_norm 方法非常重要。
为了获取训练数据集上均方误差的基准度量,将 GRADIENT_NORM 修改为 0.5,将 ITERATION_COUNT 修改为 50。GRADIENT_NORM 和 ITERATION_COUNT 的值成反比:将梯度裁剪到较小的值意味着梯度下降需要更多的迭代次数来调整模型参数的值。虽然了解梯度裁剪对于排查机器学习模型问题是有用的,但更好的方法是在首次出现梯度爆炸的风险最小化。
假设您在列表 7.4 中使用了默认的种子设置为 0,并使用 GRADIENT_NORM=0.5 和 ITERATION_COUNT=50 重新执行了代码,那么训练应该会返回梯度下降的最后 10 次迭代的以下结果:
Iteration: 040, Seconds/Iteration: 0.027 MSE: 2450.01
Iteration: 041, Seconds/Iteration: 0.026 MSE: 416.45
Iteration: 042, Seconds/Iteration: 0.026 MSE: 218.96
Iteration: 043, Seconds/Iteration: 0.026 MSE: 416.74
Iteration: 044, Seconds/Iteration: 0.026 MSE: 214.22
Iteration: 045, Seconds/Iteration: 0.027 MSE: 407.82
Iteration: 046, Seconds/Iteration: 0.029 MSE: 216.30
Iteration: 047, Seconds/Iteration: 0.026 MSE: 415.99
Iteration: 048, Seconds/Iteration: 0.026 MSE: 223.59
Iteration: 049, Seconds/Iteration: 0.026 MSE: 421.73
在最后 10 次迭代中,均方误差损失函数的值没有改善,并且 200-400 的范围明显是一个较弱的基准线。然而,启用了梯度裁剪后,梯度不再爆炸。
7.4 使用 GPU 加速 PyTorch 张量操作
本节介绍了 PyTorch 张量 API 提供的图形处理单元 (GPU) 支持,以及 GPU 如何帮助提高机器学习算法的性能,实现更高吞吐量的计算。了解 PyTorch 中的 GPU 支持将为下一节做准备,在下一节中,您将修改基准线性回归实现来使用 GPU 而不是 CPU 来处理 DC 出租车数据集。
Alex Krizhevsky 在 2012 年 ImageNet 竞赛中的获胜作品是帮助重新燃起对深度学习兴趣的最具有代表性的成功故事之一。 尽管卷积神经网络(Krizhevsky 使用的机器学习模型)自 1990 年代以来就广为人知,但它之所以在排行榜中获得最高排名,很大程度上要归功于“非常高效的 GPU 实现。” 从 2012 年以来,GPU 和其他专用处理器已被用于高效训练最先进的机器学习模型,特别是对于包含大量非结构化数据集的领域,例如包含图像,视频,音频或大量自然语言文档的领域。
PyTorch 张量可以在不更改机器学习代码实现的情况下利用 GPU 的更高吞吐量计算。 但是,如果您计划在机器学习项目中使用 GPU,则应清楚了解可以使用 CPU 获得的张量性能,并且还要了解使用 PyTorch 张量的 GPU 的入门障碍。
PyTorch 依赖于基于 Compute Unified Device Architecture (CUDA) 的 API 与 GPU 进行交互。 CUDA 是由 nVidia(一个主要 GPU 制造商)于 2007 年引入的,此后成为为像 PyTorch 这样的应用程序和框架提供 GPU API 的软件库的事实上的标准。 CUDA 使 PyTorch 能够在 GPU 上对张量操作进行并行处理,而不管 GPU 是由 nVidia,Intel 还是其他支持 CUDA 标准的处理器制造商构建的。
PyTorch 可以使用 CPU 执行有限程度的并行处理,因为现代处理器通常具有 2 至 16 个核心。 要查找 PyTorch 可用的 CPU 核心数量,可以执行以下 Python 代码:
import os
os.cpu_count()
在我的情况下,它返回 4。 在处理器术语中,每个 CPU 核心都可以充当独立的算术逻辑单元 (ALU),执行 PyTorch 张量操作所需的底层算术计算。 然而,如图 7.2 所示,在标准 CPU 上的 ALU 数量与 GPU 上的 ALU 数量相比相形见绌。
图 7.2 虽然具有更大的缓存,但 CPU(左)的并行处理吞吐量受 ALU 核心数的限制。 GPU(右)中较小的缓存和控制单元在更多的 ALU 核心之间共享,并具有比 CPU 更高的总并行处理吞吐量。
PyTorch CUDA APIs 可以为您提供有关 GPU 设备上的 ALU 数量的信息。 在使用 GPU 之前,习惯上需要在 PyTorch 中初始化设备变量。
列表 7.5 检查 GPU 和 CUDA 设备驱动程序是否可用
device = pt.device("cuda" if pt.cuda.is_available() else "cpu")
如果您的计算机上有一个 CUDA 设备可用,设备变量的值为 “cuda”。要找出您可用的 ALU 数量,您需要首先使用 get_device_capability 方法找出您的 CUDA 计算能力配置文件:
[pt.cuda.get_device_properties(i) for i in range(pt.cuda.device_count())]
在我的情况下报告
[_CudaDeviceProperties(name='Tesla P100-PCIE-16GB',
major=6, minor=0, total_memory=16280MB, multi_processor_count=56)]
get_device_capability 返回的值不是实际的 ALU 计数,而是通用的设备配置。要找出配置文件的实际 ALU 数量,您需要查阅相应的 nVidia CUDA 网站条目:docs.nvidia.com/cuda/cuda-c-programming-guide/index.html
。例如,在 6,0 配置文件的情况下,特定 URL 是 docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#compute-capability-6-x
,其中列出了 64 个 ALU,与图 7.2 右侧的数字相匹配。
在第 7.3 节中,您了解了如何使用 set_default_dtype 方法指定 PyTorch 代码中创建的张量的默认 dtype。对于每种受支持的 PyTorch dtype(例如,torch.float64),PyTorch 库都提供了两种替代实现:一种是基于 CPU 的,另一种是基于 GPU 的张量。⁶ PyTorch 默认使用基于 CPU 的张量,除非您使用 set_default_tensor_type 方法将 CUDA-based 实现指定为默认值。例如,
pt.set_default_dtype(pt.float64)
tensor = pt.empty(1)
print(tensor.dtype, tensor.device)
输出
torch.float64 cpu
张量实例的设备属性报告 PyTorch 默认为基于 CPU 的实现。但是,您可以配置 PyTorch 默认为基于 GPU 的实现(清单 7.6 ❶)。
清单 7.6 使用 GPU 张量作为默认张量类型
pt.set_default_tensor_type(pt.cuda.FloatTensor) ❶
pt.set_default_dtype(pt.float64)
tensor = pt.empty(1)
print(tensor.dtype, tensor.device)
❶ 使用 torch.cuda.FloatTensor 作为默认张量类型。
此代码生成
torch.float64 cuda:0
显示张量默认到第一个 CUDA GPU 设备,如 cuda 前缀和基于 0 的索引后缀所示。
注意 在本章中,您将学习如何扩展 PyTorch 代码以使用每个计算节点的单个 GPU。第八章和以后的章节解释如何扩展到多个 GPU 设备和网络中的多个计算节点。
set_default_tensor_type 方法是全局设置,因此可能会意外影响整个 PyTorch 代码库。即使您将 set_default_tensor_type 指定为使用 FloatTensor 张量的基于 GPU 的实现,您代码中创建的所有张量也会转换为使用 GPU。例如,
pt.set_default_tensor_type(pt.cuda.FloatTensor)
pt.set_default_dtype(pt.float64)
tensor = pt.empty(1, dtype=int)
print(tensor.dtype, tensor.device)
打印出
torch.int64 cuda:0
显示配置为 int 的张量实例也默认为基于 GPU 的实现。
虽然注意全局 set_default_tensor_type 设置很重要,但在使用 GPU 进行张量操作时更好的做法是直接在所需设备上创建张量。假设你按照清单 7.5 中的说明初始化设备变量,你可以通过设置设备命名参数在特定设备上创建张量:
device = pt.device("cuda" if pt.cuda.is_available() else "cpu")
tensor = pt.empty(1, dtype=int, device=device)
print(tensor.dtype, tensor.device)
这导致输出
torch.int64 cuda:0
如果 PyTorch 可用 CUDA 设备(即 cuda.is_available 为 True)。 当 CUDA 设备不可用或未配置时,⁷ 相同的代码输出
torch.int64 cpu
在您的 PyTorch 代码处理来自外部库(如 NumPy)的张量的情况下,将现有张量移动到不同设备可能很有用,方法是使用 to 方法。 例如,如果您的设备变量初始化为 cuda,则可以使用以下方法在 GPU 上创建包含 100 个随机元素的数组张量
a = pt.randn(100).to(device)
请注意,张量操作使用的所有张量必须驻留在同一设备上,以使操作成功。 这意味着
b = pt.randn(100).to(device)
a + b
正确返回张量 a 和 b 的和,而
c = pt.randn(100)
a + c
失败,出现 RuntimeError: 预期设备 cuda:0,但得到设备 cpu。
尽管与 CPU 相比,GPU 为机器学习提供了显著的性能改进,但将张量的内容从主计算机内存移动到 GPU 内存中涉及延迟开销。 要量化 GPU 的性能优势,您可以从以下基准函数开始:
import timeit
MAX_SIZE = 28
def benchmark_cpu_gpu(n, sizes):
for device in ["cpu", "cuda"]:
for size in sizes:
a = pt.randn(size).to(device)
b = pt.randn(size).to(device)
yield timeit.timeit(lambda: a + b, number = n)
sizes = [2 ** i for i in range(MAX_SIZE)]
measurements = list(benchmark_cpu_gpu(1, sizes))
cpu = measurements[:MAX_SIZE]
gpu = measurements[MAX_SIZE:]
ratios = [cpu[i] / gpu[i] for i in range(len(cpu))]
其中,在执行基准方法之后,ratio 变量包含 GPU 与 CPU 的性能比(越高越好)。 例如,在我的测量中,GPU 对 CPU 的加速超过 1000 倍(图 7.3),特别是对于较大的张量大小。
图 7.3 对于更大的张量大小(横轴),GPU 可比 CPU(纵轴)快高达 150 倍。
但是,对于较小的张量大小,即包含 4,096 个浮点值或更少的张量(图 7.4),CPU 的性能要么与 GPU 相当,要么更快。
图 7.4 对于少于 4K 个值的张量(横轴),将数据传输到 GPU 内存的开销可能导致 GPU 性能等于 CPU 性能的 50%。
7.5 扩展以使用 GPU 核心
在本节中,您将修改列表 7.4 中的基准线性回归实现,以利用计算节点的多个 GPU 核心。 正如您从第 7.4 节中学到的那样,要使您的机器学习代码适应 GPU 的优势,您需要确保通过调用 torch.cuda.is_available() 方法正确配置 PyTorch 的 CUDA 设备和设备驱动程序(列表 7.7 ❶),其中可用设备被分配给设备变量。
列表 7.7 使用线性回归的弱基准
iimport os
import torch as pt
from torch.utils.data import DataLoader
from kaen.torch import ObjectStorageDataset as osds
pt.manual_seed(0);
pt.set_default_dtype(pt.float64)
device = pt.device("cuda" \
if pt.cuda.is_available() else "cpu") ❶
BATCH_SIZE = 1_048_576 # = 2 ** 20
train_ds = osds(f"s3://dc-taxi-{os.environ['BUCKET_ID']}-
➥ {os.environ['AWS_DEFAULT_REGION']}/csv/dev/part*.csv",
storage_options = {'anon': False},
batch_size = BATCH_SIZE)
train_dl = DataLoader(train_ds,
pin_memory = True) ❷
FEATURE_COUNT = 8
w = \ ❷'
pt.nn.init.kaiming_uniform_(pt.empty(FEATURE_COUNT, 1,
requires_grad=True, device=device))❸
b = \
pt.nn.init.kaiming_uniform_(pt.empty(1, 1,
requires_grad = True, device=device))❹
def batchToXy(batch):
batch = batch.squeeze_().to(device) ❽
return batch[:, 1:], batch[:, 0]
def forward(X):
y_pred = X @ w + b
return y_pred.squeeze_()
def loss(y_est, y):
mse_loss = pt.mean((y_est - y) ** 2)
return mse_loss
LEARNING_RATE = 0.03
optimizer = pt.optim.SGD([w, b], lr = LEARNING_RATE)
GRADIENT_NORM = 0.5
ITERATION_COUNT = 50
for iter_idx, batch in zip(range(ITERATION_COUNT), train_dl): ❺
start_ts = time.perf_counter()
X, y = batchToXy(batch)
y_est = forward(X)
mse = loss(y_est, y)
mse.backward()
pt.nn.utils.clip_grad_norm_([w, b],
GRADIENT_NORM) if GRADIENT_NORM else None
optimizer.step()
optimizer.zero_grad()
sec_iter = time.perf_counter() - start_ts
print(f"Iteration: {iter_idx:03d}, Seconds/Iteration: {sec_iter:.3f}
➥ MSE: {mse.data.item():.2f}")
❶ 当设备可用时,使用 GPU。
❷ 自定义 DataLoader 以将数据固定在内存中以加速传输。
❷ 初始化模型参数…
❸ 当可用时,将模型偏差设置为使用 GPU 设备,否则使用 CPU。
❽ 当 GPU 设备可用时,将批数据传输到 GPU 设备,否则不执行操作。
剩余的更改在清单 7.7❸—❾中突出显示。请注意,DataLoader 的实例化已更改以利用 pin_memory 参数❸。此参数通过“固定住”操作系统的虚拟内存页面,以防止页面从物理内存交换到存储器,反之亦然,从而帮助加速大张量从 CPU 内存到 GPU 的传输。其余的更改❹—❾只是为了指定正确的设备与 PyTorch 张量一起使用:如果 GPU 可用于 PyTorch 运行时,则为 cuda,否则为 cpu。
运行代码清单 7.7 应该可以通过 MSE 损失来展示弱基线:
Iteration: 040, Seconds/Iteration: 0.009 MSE: 865.98
Iteration: 041, Seconds/Iteration: 0.009 MSE: 36.48
Iteration: 042, Seconds/Iteration: 0.009 MSE: 857.78
Iteration: 043, Seconds/Iteration: 0.009 MSE: 39.33
Iteration: 044, Seconds/Iteration: 0.009 MSE: 868.70
Iteration: 045, Seconds/Iteration: 0.009 MSE: 37.57
Iteration: 046, Seconds/Iteration: 0.009 MSE: 870.87
Iteration: 047, Seconds/Iteration: 0.009 MSE: 36.42
Iteration: 048, Seconds/Iteration: 0.009 MSE: 852.75
Iteration: 049, Seconds/Iteration: 0.009 MSE: 36.37
总结
-
在较小的机器学习问题中使用内存中的方法更快且更有效,而使用内存不足的技术可以扩展到更大的数据集和更大的信息技术资源池:计算、存储、网络。
-
使用数据集批处理结合梯度下降,使得您的 PyTorch 代码能够利用单个节点中的计算资源并扩展到利用计算集群中的多个节点。
-
PyTorch 的 IterableDataset 简化了在 PyTorch 代码中对于内存不足和流式数据集的批处理的使用,而 ObjectStorageDataset 实用程序类提供了已经准备好的用于内存不足数据集的实现。
-
PyTorch 对于 GPU 的支持是由 CUDA 设备驱动程序实现的,它使得 PyTorch 开发人员可以轻松地扩展现有的 PyTorch 代码,以利用 GPU 的更高吞吐量和更多的并行计算能力。
-
在 PyTorch 中实现基本的线性回归可以为 DC 出租车数据集上预期的训练均方误差提供一个弱基线。
^(1.)ObjectStorageDataset 支持的存储选项的完整列表可在此处找到: filesystem-spec.readthedocs.io/en/latest/
。
^(2.)论文摘要以及 PDF 版本的链接可以从 arXiv 获取:arxiv.org/ abs/1502.01852
。
^(3.)比赛网站以及比赛结果的链接可在此处找到:mng.bz/Koqj
。
^(4.)如 Alex Krizhevsky 的论文所述:www.cs.toronto.edu/~hinton/absps/imagenet.pdf
。
^(5.)谷歌开发了一种用于加速张量操作的张量处理单元(TPU)。
^(6.)关于 PyTorch 数据类型(dtypes)以及相应的 CPU 和 GPU 张量实现的详细文档,请参考mng.bz/9an7
。
^(7.)设备参数对于所有 PyTorch 张量的创建操作都可用,并在此处记录: mng.bz/jj8r
。
第八章:使用分布式训练进行扩展
本章内容包括:
-
理解分布式数据并行梯度下降
-
在梯度下降中使用梯度累积以处理内存不足的数据集
-
对比参数服务器和基于环结构的分布式梯度下降方法
-
理解基于环结构的梯度下降的 reduce-scatter 和 all-gather 阶段
-
使用 Python 实现基于环结构的分布式梯度下降的单节点版本
在第七章中,您了解了如何将机器学习实现扩展到单个计算节点上,以充分利用可用的计算资源。例如,您可以看到如何利用 GPU 设备中更强大的处理器。然而,当您在生产中启动一个机器学习系统时,训练示例的数量增长速度和训练数据集的规模可能会超过即使是最强大的服务器和工作站的计算能力。尽管借助现代公共云基础设施的升级,例如通过升级到更强大的处理器,增加内存或 GPU 设备,可以获得很大的扩展性,但您应该有一个更好的长远计划。
分布式数据并行(DDP)训练是一种依靠扩展而不是升级的机器学习模型训练方法。随着训练数据集的增大,通过将模型训练涉及的计算工作负载划分并在网络计算服务器(节点)集群上进行,可以进行扩展。这里的“节点”是连接成集群的网络上的虚拟或物理服务器。与采用更高性能(也往往更昂贵)的计算节点来执行机器学习模型训练(即扩展方法)不同,通过扩展,您可以将一组较弱甚至是普通的计算节点组成一个网络,并通过在节点之间分布和并行执行工作的方式,可能更早地完成训练。事实上,将训练数据集扩展到更大规模意味着向集群中添加更多的节点。
DDP 模型训练不仅仅是通过向集群中添加节点来进行扩展。“数据并行”方面的 DDP 描述了在集群中,每个节点仅使用训练数据集的独立且互斥的划分(也称为“分片”)来计算梯度。通常,每个分片中的训练示例数量都被选定为确保每个节点的内存可以容纳该分片。虽然在 DDP 方法中,集群中的每个训练节点在梯度下降的每次迭代中都使用数据集的不同分片,但在迭代的范围内,所有节点必须使用相同的模型副本进行训练以计算模型参数梯度。因此,在节点根据训练数据集(或一批训练示例)计算梯度后,节点必须同步到更新后的模型参数版本。
在本章中,您将了解分布式梯度下降的替代方法以及 DDP 梯度下降实现如何帮助您有效地跨任意数量的节点扩展训练,同时使用具有有限计算、内存、存储和带宽资源的实用节点。
8.1 如果训练数据集不适合内存怎么办?
本节及其子节提供了逐步介绍梯度累积以及梯度累积在梯度下降中的作用,以支持超出内存训练数据集的功能。
8.1.1 说明梯度累积
本节演示了使用 PyTorch 的 autograd 库进行梯度累积。虽然本节中的示例是基于使用 autograd 与一个简单函数的情况,但后面的部分将梯度累积应用于更现实的示例。
当使用梯度下降与反向模式累积自动微分时,在执行梯度下降的优化步骤后,有必要清除张量的梯度值。在 PyTorch 中,可以通过将张量的梯度设置为 None 或使用 torch.optim.Optimizer 的辅助方法 zero_grad 来实现此操作。除非将梯度清零(清除),否则由损失函数产生的张量的 backward 方法的调用可能会导致模型的张量中梯度值的累积。以下列表显示了这种行为。
列表 8.1 说明梯度累积对于反向调用的重复调用的插图
import torch as pt
x = pt.tensor(3., requires_grad=True) ❶
y = x ** 2
for _ in range(5):
y.backward(retain_graph=True) ❷
print(x.grad)
❶ 使用 requires_grad=True 来启用对 y 相对于 x 的微分。
❷ 设置 retain_graph=True 来防止 PyTorch 释放内存。
这会输出
tensor(6.)
tensor(12.)
tensor(18.)
tensor(24.)
tensor(30.)
根据对 的 y = x² 的五次重复调用,输出为 3 时为 6。由于累积的结果,x.grad 的输出在 for 循环的 5 次迭代中跳过 6。尽管梯度累积可能看起来像是自动微分的一个不方便的副作用,但在将梯度下降扩展到超出内存数据集和分布式集群时,它可以发挥有用的作用。
8.1.2 准备一个示例模型和数据集
本节描述了如何准备一个示例模型和一个训练数据集,以说明梯度累积在扩展到超出内存数据集时的作用。在下一节中,您将学习如何在梯度下降中使用模型和数据集。
假设您正在处理一个包含 1,000 个结构化记录的训练数据集,并且执行您的梯度下降算法的计算节点只能一次容纳 250 个示例。当然,现代计算环境可以扩展到更大的数据集;然而,选择这些数字将证明对实例有用。让我们首先看一个适合内存的虚构数据集的梯度累积,然后再直接进入现实世界的超出内存数据集的复杂性。
列表 8.2 准备一个样本多元线性回归数据集
pt.manual_seed(42) ❶
FEATURES = 4 ❷
TRAINING_DATASET_SIZE = 1000 ❸
X_train = pt.distributions.multivariate_normal.
➥ MultivariateNormal( ❹
pt.arange(FEATURES, dtype=pt.float32), ❺
pt.eye(FEATURES)).sample((TRAINING_DATASET_SIZE,)) ❻
y_train = X_train @ (pt.arange(FEATURES,
dtype=pt.float32) + 1) ❼
❶ 设置伪随机数种子以实现可重现性。
❷ 创建用于多元线性回归问题的数据集。
❸ 在训练示例数据集中使用 1,000 条记录(行)。
❹ 使用 multivariate_normal 生成合成训练数据集。
❺ 使用不同的均值来作为独立变量。
❻ 指定独立变量应该不相关。
❼ 将 X_train 中的特征与系数相乘。
此列表创建了一个训练数据集,其中有四个特征(自变量)和一个因变量(标签),基于每个特征的四个系数 1、2、3、4。例如,假设在生成 X_train 值时使用了种子值 42,则 y_train[0] 的值是从 X_train[0,:] 计算的:
print(X_train[0, :] @ pt.tensor([1, 2, 3, 4], dtype = pt.float32))
应输出
tensor(19.1816)
您还可以通过打印来确认训练数据集张量 X_train 和 y_train 的预期形状
print(X_train.shape, y_train.shape)
应基于 TRAINING_DATASET_SIZE 和 FEATURES 的值输出如下:
(torch.Size([1000, 4]), torch.Size([1000]))
有了训练数据集张量的准备,您可以准备一个线性回归模型和支持方法,使用梯度下降来训练模型。模型 w 是用从标准正态分布中抽取的随机值初始化的。此外,由于模型参数张量 w 被创建为 requires_grad=True,因此张量的初始梯度值设置为 None。
列表 8.3 定义模型 w 和梯度下降的实用方法
pt.manual_seed(42)
w = pt.randn(FEATURES, requires_grad = True) ❶
def forward(w, X): ❷
return X @ w
def mse(y_est, y):
err = y_est - y ❸
return (err ** 2).mean() ❹
❶ 创建多元线性回归问题的模型。
❷ 基于模型 w 实现梯度下降的前向步骤。
❸ 计算目标(y)的误差(残差)。
❹ 返回均方误差的值。
尽管您可以使用更复杂的技术来初始化 w,但在这种情况下,多元线性回归问题足够简单,不需要增加复杂性。
8.1.3 理解使用超出内存的数据片段的梯度下降
在本节中,使用第 8.1.2 节准备的模型和数据集,使用梯度下降利用 autodiff 的梯度累积特性来扩展到超出内存的数据集。
通过依赖梯度累积,梯度下降可以使用图 8.1 中所示的方法基于整个训练数据集(即梯度下降的一个时期)来计算梯度。注意不要将图 8.1 中显示的分片与 mini-batch 梯度下降中使用的批次混淆;区别在下面的段落中进行了澄清。
图 8.1 梯度累积重新使用分片内存以实现对超出内存的数据集的扩展。
图 8.1 的左侧显示了使用 [0:250][0] 表示训练数据集中的前 250 个示例(记录)的第一个分片,[0:250][1] 表示第二个分片,即记录从 250 到 500,依此类推。在这里,使用 Python 切片表示法(例如,[0:250])来指定训练数据集中的哪些 1,000 个示例包含在一个分片中。
请注意,在图 8.1 中,每个分片都使用相同的模型 w 进行处理(在梯度下降的前向和后向步骤中),或者更准确地说,使用相同的 w 模型参数值。虽然图 8.1 中梯度积累的四个顺序步骤中模型参数值是相同的,但由于每个分片包含训练示例的不同集合,因此为每个分片计算的梯度也是不同的,并且是特定于分片的。在图中,使用下标表示分片及其相应的梯度之间的关系,以便分片 [0:250][0] 产生梯度g[0],依此类推。
一旦每个分片的训练样本计算出梯度(见清单 8.4),则不会使用分片梯度来更新模型参数。相反,梯度被保留在模型张量中累积。因此,在第二个训练示例分片通过前向方法处理,然后后向方法计算相应的梯度g[1]之后,模型张量 w.grad 包含梯度g[0]+g[1]的总和(累积)。
请注意,使用分片进行计算与小批量梯度下降中的批量计算不同,其中来自每个批次的梯度用于更新模型参数,然后清除。将批次与分片区分开很有用,因为两者都可以与梯度下降一起使用;例如,分片可以是批次的分区,在数据批次不适合节点内存的情况下。分片还可以由多个批次组成,以便通过处理存储在节点内存中的多个批次来加速梯度下降。虽然可以将分片与小批量梯度下降一起使用,但本节重点介绍使用分片与普通梯度下降的更基本示例,其中根据整个训练示例集计算梯度。
仅在处理完整个训练数据集后,一次处理一个分片,图 8.1 中的算法才执行基于累积梯度g[0]+g[1]+g[2]+g[3]的梯度下降的优化步骤。
清单 8.4 使用 IN_MEMORY_SHARD_SIZE 示例的梯度下降
EPOCHS = 500
LEARNING_RATE = 0.01
IN_MEMORY_SHARD_SIZE = 250
for epoch in range(EPOCHS):
for i in range(0, \
TRAINING_DATASET_SIZE // IN_MEMORY_SHARD_SIZE): ❶
start_idx = i * IN_MEMORY_SHARD_SIZE
end_idx = start_idx + IN_MEMORY_SHARD_SIZE
y_shard = y_train[start_idx : end_idx]
X_shard = X_train[start_idx : end_idx] ❷
y_est = forward(w, X_shard) ❸
loss = \ ❹
(IN_MEMORY_SHARD_SIZE / TRAINING_DATASET_SIZE) * mse(y_est, y_shard)
loss.backward() ❺
#notice that the following is
#in scope of the outer for loop
w.data -= LEARNING_RATE * w.grad ❻
w.grad = None ❼
❶ 每个周期执行 TRAINING_DATASET_SIZE // IN_MEMORY_SHARD_SIZE 次迭代。
❷ 将训练示例分配给 y_shard 和 X_shard。
❸ 执行梯度下降的前向步骤。
❹ 计算调整后的分片大小训练损失。
❺ 执行反向传播和梯度累积
❻ 执行梯度下降优化步骤。
❼ 清除模型张量的梯度。
代码执行后,打印语句
print(w)
应该输出
tensor([1.0000, 2.0000, 3.0000, 4.0000], requires_grad=True)
证实梯度下降正确地恢复了列表 8.2 中使用的系数[1.0000,2.0000,3.0000,4.0000],以创建由 y_train 和 X_train 组成的训练数据集。
在列表 8.4 中计算损失时使用的分数(IN_MEMORY_SHARD_SIZE / TRAINING_DATASET_SIZE)微妙但重要。回想一下,该列表旨在计算整个训练示例或更准确地说是 TRAINING_DATASET_SIZE 示例的梯度。mse 方法的默认实现,计算模型估计值 y_est 的均方误差,假定在计算期间有 IN_MEMORY_SHARD_SIZE 个示例。换句话说,在列表中内部 for 循环的每次迭代中,通过计算 mse 来计算,或者在 PyTorch 中等效地使用
(1 / IN_MEMORY_SHARD_SIZE) * ((y_est - y_shard) ** 2).sum()
返回每个 IN_MEMORY_DATASET_SIZE 示例的均方误差。列表 8.4 中在计算损失时使用的(IN_MEMORY_SHARD_SIZE / TRAINING_DATASET_SIZE)分数将均方误差重新缩放为 TRAINING_DATASET_SIZE 示例。
通过这个以方程表示的乘法,注意到重新缩放相当于 IN_MEMORY_DATASET_SIZE,这在的分子和分母中取消了。
当内部 for 循环完成时,w.grad 包含训练示例梯度的总和,因此代码 w.data -= LEARNING_RATE * w.grad 计算了整个 epoch 的片段的优化步骤。换句话说,在列表 8.4 中的梯度下降实现中,梯度优化步骤是针对每个训练示例的 epoch 执行一次。这证实了列表 8.4 中的实现不是小批量梯度下降。
虽然图 8.1 中的方法使得可以在使用任意片段大小的内存外数据集上进行扩展,但它遭受着一个显著的算法复杂性问题:内部 for 循环是顺序的,这会将梯度下降实现的大零性能从O(EPOCHS)变为O(EPOCHS * SHARDS)。
将列表 8.4 中的内部 for 循环分布到一组并行工作节点上,可以将实现恢复到原始O(EPOCHS)最坏情况的性能。但是如何高效地实现呢?
8.2 参数服务器方法的梯度累积
本节介绍了基于参数服务器的分布式梯度下降的实现,并解释了梯度累积在实现中的作用。本节澄清了参数服务器方法的局限性,并激发了更高效的基于环的实现。
像 TensorFlow 1.x 这样的传统机器学习框架普及了基于参数服务器的方法,以在集群的多个节点之间分布梯度下降。 图 8.2 中描绘的参数服务器方法易于理解和实现。
图 8.2 梯度下降在工作节点和参数服务器之间进行分布以支持扩展
在图中,每个工作节点(使用虚线表示)根据训练数据集的单个分片执行梯度下降的前向和后向步骤(例如清单 8.4 中的内部循环中的步骤),以计算损失函数的分片特定梯度。请注意,在图 8.2 中,梯度具有与用于计算梯度的分片的下标对应的下标,就像图 8.1 中一样。
一旦工作节点计算出其梯度,它就将梯度发送到参数服务器(或参数服务器集群)进行处理。参数服务器(图 8.2 的右侧)等待累积来自工作节点的梯度,并使用累积的梯度执行梯度下降的优化步骤,计算下一次梯度下降的模型参数。然后,基于新计算的模型参数(在图 8.2 中表示为 w’),将下一个版本的模型发送到工作节点,取代以前的模型参数(在图 8.2 中表示为 w),确保每个节点使用相同和更新的模型的下一个梯度下降迭代。
图 8.2 中的分布式梯度下降的参数服务器实现是一种分布式数据并行(在本章的介绍中定义)方法。在分布式数据并行方法中,训练数据集被划分为独立且互不重复的子集,以便训练数据集分片和工作节点之间存在一对一的关系。接下来,每个工作节点使用一个分片和一个相同的模型参数副本计算梯度。
与替代的分布式数据并行方法(在本章的其余部分中讲解)不同,分布式梯度下降的参数服务器实现存在重大的可伸缩性问题:工作节点和参数服务器之间的网络连通性是通信瓶颈。具体而言,在实现的两个通信阶段中都存在通信带宽受限的问题:在从工作节点到参数服务器的梯度的多到一(或多到少)通信期间,以及在从参数服务器(多个参数服务器)到工作节点的更新模型参数的一到多(或少到多)通信期间。
8.3 引入逻辑环形梯度下降
本节介绍了在逻辑环网络中通信的节点的基本概念。本节不是为了提供实际节点并使其通过网络通信,而是使用在单节点环境中运行的简单 Python 程序来解释网络概念。一旦你对概念有了牢固的掌握,你将把它们应用到更复杂的、分布式的、多节点环境中。
与依赖于集中式参数服务器集群(第 8.2 节中所示方法)相反,基于逻辑环的分布式数据并行算法(例如 Horovod;github.com/horovod/horovod
)避免了一对多和多对一通信的瓶颈,并且仅依赖于在环中与两个逻辑邻居通信的节点:前趋节点和后继节点。
图 8.3(左侧)的图示显示了四个节点,每个节点使用虚线表示,并表示为节点n[0]到n[3],这些节点组织在一个逻辑环中。请注意,在公共云环境中的当代虚拟网络中,节点不必物理连接到彼此形成环:标准以太网网络足够。但是,在图中显示的逻辑环网络中,每个节点都受到限制,仅与其前趋节点和后继节点通信。正如您将在第 8.4 节中了解到的那样,这有助于限制分布式梯度下降每次迭代所需的网络带宽。
图 8.3 逻辑网络环(左)使用示例值解释
对于具有标识符n[i]的节点,后继节点的标识符被定义为n[(i+1) %] NODES,其中 NODES 是逻辑环中节点的总数。模运算确保通信模式形成一个环,其中具有最高标识符(始终为n[NODES-1])的节点与标识符为 0 的节点进行通信,反之亦然。在环形网络中,如本章所述,每个节点只向后继节点发送数据。
使用类似的逻辑,基于环形网络的前趋节点的标识符被定义为n[(i-1) %] NODES,以便节点 0 可以与节点 1 和具有最高标识符值(NODES - 1)的节点进行通信。本章使用的环网络中的每个节点只从前趋节点接收数据。
就像第 8.2 节中解释的基于参数服务器的方法一样,图 8.3 中的节点处理训练数据集的独立碎片,以便g0 表示由节点n[0]计算的具有索引 0 的碎片的梯度值。继续使用第 8.2 节的示例,如果[0:250]0 是四个碎片中的第一个碎片,那么g0 表示由节点n[0]计算的第一个碎片的梯度值,使用模型参数值 w。因此,就像基于参数服务器的方法一样,基于环的方法也是数据并行分布式的。
在基于环的分布式数据并行实现中,不存在专用的参数服务器。相反,在集群中的每个节点完成梯度下降迭代的前向和后向步骤后,节点在逻辑环网络中通信,以便所有碎片的梯度在环中的每个节点上累积。
需要在节点之间通信什么样的信息,以确保模型参数值和累积梯度值完全同步和相同?在逻辑环中,由于每个节点只能向后继节点发送数据,因此节点只能从前驱节点的一系列梯度发送/接收操作中接收累积梯度。例如,为了让节点n[0]从节点n[1]到n[3](图 8.4 的最右侧)累积梯度,需要三次迭代的发送/接收操作。这三次迭代从图 8.4 的左侧到右侧按顺序显示。正如您将在本章中观察到的那样,在由 NODES 个节点组成的多节点集群中,需要(NODES - 1)次发送/接收通信迭代。
图 8.4 在一个由四个节点组成的环中将梯度(求和)减少到节点 0,这是一个分布梯度的全局规约算法,用于在节点之间分发梯度。
列表 8.5 中的源代码提供了图 8.4 描述的逻辑的 Python 伪代码实现。在实现中,使用了 NODES 变量,该变量是使用训练数据集中训练示例的数量(常量 TRAINING_DATASET_SIZE 的值)与多节点集群中一个节点的内存中适合的训练示例的数量(IN_MEMORY_SHARD_SIZE 的值)之间的关系定义的。使用地板除法运算符//以确保 NODES 常量的值被设置为整数值,因为它稍后将用作 Python 范围操作的参数。
列表 8.5 Python 伪代码,以说明梯度减少到节点 0
NODES = \
TRAINING_DATASET_SIZE // IN_MEMORY_SHARD_SIZE ❶
GRADIENTS = [5., 3., 2., 1.] ❷
node_to_gradients = \
dict(zip(range(NODES), GRADIENTS)) ❸
for iter in range(NODES - 1): ❹
node = (iter + 1) % NODES ❺
grad = node_to_gradients[node] ❻
next_node = (node + 1) % NODES ❼
# simulate "sending" of the gradient value
# over the network to the next node in the ring
node_to_gradients[next_node] += grad ❽
❶ 计算训练数据集所需的节点数量。
❷ 为演示分配任意的 GRADIENT 值,每个节点一个。
❸ 创建一个字典来跟踪节点计算的梯度。
❹ 执行 NODES - 1 次通信迭代。
❺ 从节点 iter+1 开始,以便在 NODES-1 后…
❻……迭代,节点 0 累积梯度。
❼下一个节点的标识符结束了环。
❽在节点对梯度进行累积。
一旦代码执行完毕,打印 node_to_gradients 字典的值。
print(node_to_gradients)
输出结果:
{0: 11.0, 1: 3.0, 2: 5.0, 3: 6.0}
其中键 0 对应于预期梯度,计算的 n[0],值为 11,基于累积梯度 5+3+2+1。此外,请注意,由于图 8.4 不包括对n[0]以外的任何节点的梯度累积,因此 n[1]到n[3]的梯度保持不变。即将介绍的部分将解释如何确保在环中的所有节点上累积相同的梯度。
在三(节点-1)次迭代的第一次(在图 8.4 中以基于零的索引显示为迭代 0)中,节点n[1]发送并且节点n[2]接收节点n[1]在开始迭代 0 之前计算的梯度值 g1。由于在环中的通信目的是为了到达累积梯度,一旦接收 g1 梯度值,n[2]节点可以直接累积(加到)梯度值,以确保重用内存来存储累积梯度值:g1+g2。例如,如果每个节点上的每个梯度张量都是 400 MB,那么在环中的节点之间传输 400 MB 的数据,并且每个节点消耗 400 MB 的内存来存储累积梯度值。到迭代 0 结束时,节点n[2]累积了添加(即使用求和操作减少的)梯度。
因此,在第二次迭代(在图 8.4 中标记为迭代 1)期间,累积的梯度值从节点n[2]发送到节点n[3],导致在第二次迭代结束时在节点 n[3]上累积的梯度值g1+g2+g3。
在这个示例中的最后一次迭代(在图 8.4 中标记为迭代 2)完成了对节点n[0]上的梯度的累积,将在这次迭代中计算的节点n[0]上的梯度* g0 加到从n* [3]收到的累积梯度上。由此得到的梯度,包括g0+g1+g2+g3,足以让n[0]计算出下一个优化步骤的模型参数值,这个步骤是由集群中的每个节点执行的梯度下降过程。
虽然图 8.4 和列表 8.5 中示例的三次迭代实现了梯度的累积(reduce 步骤)到单个节点,但要使分布式数据并行梯度下降工作,环中的每个节点必须访问整个累积梯度:g0 + g1 + g2 + g3。除非每个节点都可以访问累积梯度,否则节点无法执行使用累积梯度更改模型参数值的梯度下降步骤。即将介绍的各节将基于列表 8.5 中的 reduce 步骤来解释整个分布式梯度下降算法的 reduce-all 阶段。
8.4 理解基于环形的分布式梯度下降
虽然第 8.3 节描述的天真的基于环的 reduce 操作可以消除对参数服务器的需求,并确保梯度值在环形多节点集群中的各个计算节点上被减少(累积),但它存在一些缺点。随着训练数据集的增长(这是可以预料的),集群中的节点数量必须增长以跟上。这也意味着集群需要的总带宽必须随着节点数量的增加而增长,因为每个节点在每次迭代期间都必须将整个梯度发送给环中的下一个节点。在本节中,您将了解基于环形分布式数据并行算法(例如,著名的 Horovod 算法)如何在规模化情况下帮助有效利用带宽,其中训练节点的数量和训练示例的数量都增加。
Horovod 算法可以支持训练数据集的增长(以及集群中节点的数量),同时保持带宽需求恒定或甚至降低带宽要求。为了支持这一点,Horovod 依赖于两个分离且独立的环形通信阶段:(1)reduce-scatter 和(2)all-gather。在两个阶段中,Horovod 不是在节点之间发送/接收整个梯度数据,而是只通信梯度的一个单一段落,其中默认情况下段落的大小是梯度大小乘以 ,其中NODES是环集群中的工作节点数。因此,增加工作节点的数量以与训练数据集大小成比例地减少节点间通信的带宽需求。
那么梯度的 段 是什么?你可以把每个段视为梯度的逻辑分区,如图 8.5 所示。在图中,节点 n[0] 计算的梯度 g[0],基于训练数据集分片 [0:250][0](其中 [0:250] 是 Python 切片表示法),依次被分成 NODES 段,以便默认情况下,每个段都存在大致相等数量的梯度值。继续之前的例子,梯度占据了 400 MB 的数据量(例如模型参数的 32 位浮点梯度值的每 100,000,000 个字节为 4 字节),每个段是 100 MB 的互斥逻辑分区的相同模型张量。请注意,在这种情况下,由于分片是由节点 n[0] 计算的,因此每个四个段中的 i 都使用 s[i](n[0]) 进行注释。
图 8.5 Horovod 用于节点间通信的梯度段
还要注意,虽然在图 8.5 的框架的水平轴上不能累积(相加)段,但是可以沿垂直轴累积段。此外,图 8.5 段框架下方显示的段 s[i] 对应于每个节点计算的相应段的累积。例如,s[0] 等于 s[0](n[0]) + s[1](n[1]) + s[2](n[2]) + s[3](n[3])。因此,图 8.5 框架下方显示的 s[0]s[1]s[2]s[3] 段等同于将累积梯度 g[0] + g[1] + g[2] + g[3] 逻辑分割为段所需以执行梯度下降的优化步骤。
就像在列表 8.5 中介绍的基于环形减少步骤一样,本章的其余部分使用 Python 伪代码来解释 Horovod 算法。请回忆,对于一个分布式数据并行算法(如 Horovod)要正确工作,环形集群中的每个节点必须初始化具有模型参数的相同副本。在列表 8.6 中,Python 张量列表 W 被用来表示相同的模型。请注意,W 中的每个张量都是使用来自 w_src 的值初始化的,w_src 是从标准正态分布中抽样的伪随机值张量。
列表 8.6 W 存储模型张量的相同副本
pt.manual_seed(42)
w_src = pt.randn((4,))
W = [pt.tensor(w_src.detach().numpy(),
requires_grad=True) for _ in range(NODES)]
为了重复使用列表 8.4 中的训练数据集张量 X_train 和 y_train,Horovod 算法的以下解释创建了一个 PyTorch DataLoader,它将训练数据集分成每个 IN_MEMORY_SHARD_SIZE 记录的分片。不要被列表 8.7 中 DataLoader 的 batch_size 参数所迷惑;虽然该参数用于分割源 TensorDataset,但是单个分片不会作为批量用于更新模型的参数。
列表 8.7 使用 PyTorch DataLoader 进行分片的梯度下降步骤
from torch.utils.data import TensorDataset, DataLoader
train_dl = DataLoader(TensorDataset(y_train, X_train), \
batch_size = IN_MEMORY_SHARD_SIZE,
shuffle = False)
for node, (y_shard, X_shard) in zip(range(NODES), train_dl):
y_est = forward(W[node], X_shard)
loss = \
(IN_MEMORY_SHARD_SIZE / TRAINING_DATASET_SIZE) * mse(y_shard, y_est)
loss.backward()
代码执行完毕后,
[W[node].grad for node in range(NODES)]
应该输出
[tensor([ -0.1776, -10.4762, -19.9037, -31.2003]),
tensor([ 0.0823, -10.3284, -20.6617, -30.2549]),
tensor([ -0.1322, -10.9773, -20.4698, -30.2835]),
tensor([ 0.1597, -10.4902, -19.8841, -29.5041])]
代表环形集群中每个节点的模型梯度的张量。
请注意,在使用列表 8.7 中 for 循环中的代码对每个节点执行梯度下降的前向和后向步骤之后,Horovod 算法必须执行两个基于环形网络的阶段,以便将累积梯度通信到环中的每个节点。第一个阶段称为 reduce-scatter,在第 8.5 节中进行了解释,第二个阶段称为 all-gather,在第 8.6 节中进行了解释。
8.5 阶段 1:Reduce-scatter
本节介绍了 Horovod 的 reduce-scatter 阶段,假设环形集群中的每个节点都使用模型参数的相同副本进行初始化。本节继续使用列表 8.7 中的示例,其中模型参数的相同副本存储在 W[node] 中,并且每个节点完成了梯度下降的前向和后向步骤,导致的梯度值保存在 W[node].grad 中。通过本节的结束,您将了解 Horovod 的 reduce-scatter 阶段如何确保环中的每个节点最终获得累积梯度 g[0] + g[1] + g[2] + g[3] 的不同段。
Horovod 的第一个阶段称为 reduce-scatter,在每个节点都完成基于数据集的节点特定分片的梯度计算后开始。如前一节所述,每个节点逻辑上将计算的梯度分成 NODES 个段。此阶段的第一次迭代(共三次迭代)显示在图 8.6 中,其中图的顶部显示,在阶段开始时,每个节点 n[i] 存储着特定于分片的段,s0 到 s[NODES-1](n[i])。
图 8.6 第一次 reduce-scatter 阶段的迭代启动了跨节点的梯度段传输。
由于每次迭代 reduce-scatter 仅将一个段的数据发送到后续节点,因此在第一次迭代(如图 8.6 底部箭头所示)中,节点 n[i] 将段 s[(i - 1)] % NODES(n[i]) 转发给后续节点。在第一次迭代结束时(图 8.6 底部),每个节点 n[i] 累积了一个段 s(i - t - 1) % NODES % NODES]) + s(i - t - 1) % NODES,其中 t=1 代表第一次迭代。
在后续的迭代中,每个节点将上一次迭代中累积的段发送给后继节点。例如,在第二次迭代(如图 8.7 所示)中,节点n[1]发送段s[3](n[0] + n[1]),节点n[2]发送段s[0](n[1] + n[2]),一般来说,对于第 t 次迭代,节点n[i]发送累积的段s[(i - t)] % NODES(n[i])。由于在具有四个节点的示例中,只需要三次迭代来减少散布段,因此图 8.7 的底部显示,到第二次迭代结束时,每个节点上只缺少每个段的一部分:由s(i + 1) % NODES 指定的段。
图 8.7 第二次减少散布迭代传播累积梯度。
这个缺失的部分在示例的第三个和最后一个迭代中填补,即在迭代结束时(图 8.8 的底部),每个节点n[i]都累积了整个段s[i]。例如,注意在图 8.8 中,n[0]以s[0]结束了本阶段的最后一次迭代,节点n[1]以s[1]结束,依此类推。
图 8.8 第三次减少散布迭代完成四节点环的梯度累积。
列表 8.8 减少散布阶段的 Python 伪代码
for iter in range(NODES - 1):
for node in range(NODES):
seg = (node - iter - 1) % NODES ❶
grad = W[node].grad[seg] ❷
next_node = (node + 1) % NODES
W[next_node].grad[seg] += grad ❸
❶ 第一个段被累积到第一个节点。
❷ 检索与节点和段 seg 对应的梯度值。
❸ 在环中的下一个节点上累积梯度段的值。
在列表 8.8 中的代码执行完毕后,可以使用以下方式输出结果梯度
print([f"{W[node].grad}" for node in range(NODES)])
应该打印出
['tensor([ -0.0679, -31.9437, -39.7879, -31.2003])',
'tensor([ 0.0823, -42.2722, -60.4496, -61.4552])',
'tensor([-4.9943e-02, -1.0977e+01, -8.0919e+01, -9.1739e+01])',
'tensor([ 1.0978e-01, -2.1468e+01, -1.9884e+01, -1.2124e+02])'].
请注意,如预期的那样,梯度值分散在节点之间,以便n[0]存储累积梯度的段s[0],n[1]存储段s[1],依此类推。一般来说,减少散布后的梯度累积段可以使用以下方式打印出来
print([f"{W[node].grad[node]}" for node in range(NODES)]),
它在每个节点上输出累积段的值:
['-0.06785149872303009', '-42.27215576171875', '-80.91938018798828', '-121.24281311035156']
图 8.9 的插图总结了当减少散布环由四个节点组成时,列表 8.8 中的代码的情况。
图 8.9a 减少散布的迭代
图 8.9b 减少散布的迭代
8.6 阶段 2:全聚合
本节介绍 Horovod 算法的第二个和最后一个阶段:全聚合。在本节中,您可以观察到来自减少散布阶段的累积梯度的散布段如何被收集或发送到环中,以便在阶段结束时,每个节点都存储整个累积梯度g[0] + g[1] + g[2] + g[3]。这意味着在本阶段结束时,逻辑环中的每个节点都可以执行梯度下降的优化步骤,并计算出模型参数的下一个迭代,以进行进一步的训练。
鉴于减少-分散阶段执行了有选择性地累积(减少)梯度段值的细微步骤,全收集的实现,即第二个和最后一个阶段,更容易理解。使用与减少-全部算法介绍的方法,此阶段仅涉及从一个节点发送累积的段到下一个节点。与 Horovod 算法的减少-分散阶段一样,全收集阶段在集群的逻辑环网络中进行节点到节点通信,共需要 NODES - 1 次迭代。
图 8.10 中四个节点的三次迭代分别表示为图的左上、右上和右下象限。图的左下角显示了节点在完成 Horovod 算法的所有步骤后集群中的最终状态。请注意,每个节点上的梯度段(表示为 s[0] 到 s[3])存储了从训练数据集的相应分片计算出的整个累积梯度(表示为 g[0] + g[1] + g[2] + g[3])。
图 8.10a 全收集的迭代
图 8.10b 全收集的迭代
图的左上象限指出,在第一个迭代的开始,示例中四个节点的状态是 n[0] 存储了累积梯度的段 s[0],等等。在该阶段的第一次迭代(左上象限)中,每个节点仅向环中的后继节点发送它存储的累积段,覆盖并替换后继节点中存储的任何先前的段值。
列表 8.9 全收集阶段的 Python 伪代码
for iter in range(NODES - 1):
for node in range(NODES):
seg = (node - iter) % NODES ❶
grad = W[node].grad[seg]
next_node = (node + 1) % NODES
W[next_node].grad[seg] = grad ❷
❶ 从第一次迭代的第一个节点开始。
❷ 在环中的下一个节点上存储段的梯度值。
在第二次迭代开始时(图 8.10 的右上象限),每个节点恰好存储两个完整的累积梯度段。在这次迭代和剩余的迭代中,每个节点将在上一次迭代中接收的段(例如,在第二次迭代中,n[0] 接收了 s[3])发送到环中的后继节点。最后一次迭代(右下象限)完成了将梯度的剩余段传输到集群中的节点。在此阶段结束时(左下象限),环集群中的每个节点上都可以获得累积梯度 g[0] + g[1] + g[2] + g[3]。
此时,在每个节点上打印模型的梯度,
print([f"{W[node].grad}" for node in range(NODES)]),
为环中的每个节点输出了四个相同的梯度值:
['tensor([-6.7851e-02, -4.2272e+01, -8.0919e+01, -1.2124e+02])',
'tensor([-6.7851e-02, -4.2272e+01, -8.0919e+01, -1.2124e+02])',
'tensor([-6.7851e-02, -4.2272e+01, -8.0919e+01, -1.2124e+02])',
'tensor([-6.7851e-02, -4.2272e+01, -8.0919e+01, -1.2124e+02])']
列表 8.10 Horovod 基于环的分布式梯度下降算法
import torch as pt
from torch.utils.data import TensorDataset, DataLoader
IN_MEMORY_SHARD_SIZE = 250
TRAINING_DATASET_SIZE = 1000
NODES = TRAINING_DATASET_SIZE // IN_MEMORY_SHARD_SIZE
FEATURES = 4
pt.manual_seed(42)
w_src = pt.randn((FEATURES,))
W = [pt.tensor(w_src.detach().numpy(),
requires_grad=True) for _ in range(NODES)]
def forward(w, X):
return X @ w
def mse(y_est, y):
err = y_est - y
return (err ** 2).mean()
X_train = pt.distributions.multivariate_normal.MultivariateNormal(
pt.arange(FEATURES, dtype=pt.float32),
pt.eye(FEATURES)).sample((TRAINING_DATASET_SIZE,))
y_train = X_train @ (pt.arange(FEATURES, dtype=pt.float32) + 1)
train_dl = DataLoader(TensorDataset(y_train, X_train), \
batch_size = IN_MEMORY_SHARD_SIZE,
shuffle = False)
EPOCHS = 1000
LEARNING_RATE = 0.01
for epoch in range(EPOCHS):
#compute per shard gradients on each node
for node, (y_shard, X_shard) in zip(range(NODES), train_dl):
y_est = forward(W[node], X_shard)
loss = \
(IN_MEMORY_SHARD_SIZE / TRAINING_DATASET_SIZE) * mse(y_shard, y_est)
loss.backward()
#horovod phase 1: reduce-scatter
for iter in range(NODES - 1):
for node in range(NODES):
seg = (node - iter - 1) % NODES
grad = W[node].grad[seg]
next_node = (node + 1) % NODES
W[next_node].grad[seg] += grad
#horovod phase 2: all-gather
for iter in range(NODES - 1):
for node in range(NODES):
seg = (node - iter) % NODES
grad = W[node].grad[seg]
next_node = (node + 1) % NODES
W[next_node].grad[seg] = grad
#perform a step of gradient descent
for node in range(NODES):
W[node].data -= LEARNING_RATE * W[node].grad
W[node].grad = None
print([f"{W[node].data}" for node in range(NODES)])
这应该输出恢复的多变量线性回归系数:
['tensor([1.0000, 2.0000, 3.0000, 4.0000])',
'tensor([1.0000, 2.0000, 3.0000, 4.0000])',
'tensor([1.0000, 2.0000, 3.0000, 4.0000])',
'tensor([1.0000, 2.0000, 3.0000, 4.0000])']
概要
-
分布式数据并行训练是一种分布式梯度下降的方法,其中规模扩展的集群中的每个节点都使用训练模型的相同副本,但是使用训练数据集的专用分片。
-
反向模式累积自动微分的梯度累积特性使得梯度下降可以缩小到有限内存节点,或者扩展到超出内存的数据集。
-
基于遗留参数服务器的分布式数据并行梯度下降方法需要昂贵的广播式网络操作,并且在带宽限制下不易扩展。
-
Horovod 是一个可伸缩且带宽高效的算法,用于基于两个阶段的基于环形网络操作的分布式数据并行梯度下降:reduce-scatter 和 all-gather。
^(1.)自动微分的这个特性在第五章中有详细介绍。
^(2.)例如,许多深度学习模型都是使用 Kaiming 初始化进行初始化的:mng.bz/5K47
。
第三部分:无服务器机器学习流水线
一个机器学习系统不仅仅是一个模型和一个数据集。在这一部分,你将逐步学习整个机器学习流水线的工程步骤,从自动化特征工程到超参数优化和实验管理的步骤。
-
在第九章中,你将探索围绕特征选择和特征工程的用例,通过案例研究学习,了解可以为 DC 出租车数据集创建的特征类型。
-
在第十章中,你将采用一个名为 PyTorch Lightning 的 PyTorch 框架,以减少在你的实现中的样板工程代码的数量。此外,你将确保你可以训练、验证和测试基于 PyTorch Lightning 的机器学习模型。
-
在第十一章中,你将把你的机器学习模型与 Optuna 超参数框架集成,基于 Optuna 建议的超参数值训练替代模型,并根据它们的损失和指标表现对模型进行排名。
-
在第十二章中,你将把你的机器学习模型实现打包成一个 Docker 容器,以便通过整个机器学习流水线的各个阶段运行容器,从开发数据集开始,一直到准备好进行生产部署的训练模型。
第九章:特征选择
本章涵盖
-
了解特征选择和特征工程原则
-
将特征选择原则应用于案例研究
-
基于案例分析磨练特征选择技能
到目前为止,您一直在使用 DC 出租车数据集的原始(原始)数据值作为机器学习模型的特征。 特征 是机器学习模型在训练和推断阶段期间使用的输入值或一组值(请参阅附录 A)。 特征工程 是选择、设计和实施使用原始数据值的合成(虚构)特征的过程,可以显着提高模型的机器学习性能。 特征工程的一些例子是对原始数据值进行简单的、公式化的转换,例如将任意数字值重新调整到-1 到 1 的范围。 特征选择(也称为特征设计)是特征工程的初始阶段,是工作的更有创意的部分,涉及指定捕获有关数据集的人类知识或直觉的特征,例如选择衡量出租车行程数据集中每次乘车的上车和下车位置之间距离的特征。
无差别地向项目数据集中添加大量特征可能是一个代价高昂的错误(参见“维度诅咒”问题)。 特征“过度设计”可能导致过拟合以及机器学习模型性能的整体下降。 这引出了一个问题:有哪些指导原则可以帮助选择正确的特征,以避免可怕的特征过度设计? 本章将使用案例研究来介绍这些原则,并说明如何在实践中应用它们。
本章涵盖了三个案例研究,涉及金融、广告和移动游戏行业。 对于每个案例研究,您将得到一个行业的机器学习项目描述,以及您预期为该项目训练的机器学习模型的高级规范。 然后,您将得到该项目的候选特征描述。 最后,每个案例研究的讨论部分描述了如何应用五个指导原则,以帮助您决定是否应选择候选特征。
特征选择的指导原则
本节介绍了五个指导原则,以帮助你为你的机器学习项目选择正确的特征。虽然我没有看到这些原则被作为行业标准编纂,但它们总结了我在选择数据科学、机器学习和深度学习项目特征方面超过十年的经验。本节的其余部分将更详细地解释这些指导原则,以便在第 9.2 节中,你可以将它们应用于案例研究和候选特征的具体示例。根据这些原则,一个候选特征应该是:
-
与标签相关
-
在推断时间之前记录
-
得到丰富的例子支持
-
表达为具有有意义刻度的数字
-
基于项目的专业见解
9.1.1 与标签相关
本节教你在评估特征(或潜在特征)与标签之间关系时应考虑哪些因素,以便你可以为你的特征工程工作选择和优先考虑特征。在你决定在机器学习模型训练实验中包含一个潜在特征之前,确保你能够表达一个理由(一种证明),解释为什么这个特征与标签值相关联。表达理由可以帮助你说服自己(在理想情况下,一个公正的观察者),这个特征实际上与你尝试使用机器学习解决的问题相关。当然,有些理由比其他理由更好:“为什么不呢?”并不是一个强有力的特征理由。一般来说,弱理由会导致潜在特征的数量过多,并且与标签的相关性强弱不一。
请注意,对于一个特征有一个理由也很重要,因为候选特征与标签之间的关系可能会根据你的问题而改变。在实践中,改变你用数据集回答的问题可以改变候选特征与标签之间是否存在关系。例如,当估算跨越 DC 的出租车费用时,接送地点之间的距离特征值与出租车费用估算相关。然而,如果你决定不再估算出租车费用,而是决定使用距离特征来估算在 DC 特定时间的特定位置的出租车接送数量,那么这个特征就失去了与标签的相关性。虽然似乎显而易见,一个为不同项目选择的特征在更改标签时可能变得无用,但在实践中,训练数据集和特征存储库被重新使用于机器学习项目,导致意外重复使用无关和潜在有害(如本章的其余部分所示)的特征。
当本节中使用的“相关”一词有不同的含义时,其意义也不尽相同,例如,“统计相关”就是其中一种相关性。正如你所知,统计相关性意味着因果关系可能对特征和标记之间的关系起到作用,也可能不起作用。
考虑一个众所周知的例子:根据标准的皮尔逊统计相关度量,儿童的阅读能力与他们的鞋尺码相关。换句话说,根据经典统计学的说法,如果你想要估算一个孩子的鞋码,你可以使用孩子最近一次阅读测试的分数。当然,在鞋尺码和阅读能力变量之间不存在因果关系,因相关性存在于年龄这一潜在(混淆)变量之间。
鞋尺码估算的例子如图 9.1 所示。注意,孩子年龄与鞋尺码之间的因果关系,以及孩子年龄和阅读成绩之间的因果关系也转化为这些变量对之间的统计相关性。因此,虽然候选特征与标记之间的统计相关性可以作为特征的基线理由,但更强的证明可以基于特征与标记之间的因果关系。
图 9.1 特征可以基于孩子的阅读测试成绩或年龄变量,但基于年龄的特征与鞋尺码之间有更强的相关性,因为年龄与鞋尺码之间存在反事实因果关系。
是否可以区分相关性和因果关系?这取决于你对因果关系的定义。大多数现代机器学习和数据科学从业者和学者在业界和学术界都使用一种称为“反事实因果”的因果关系。¹ 你可以通过回答一个假设性问题来决定候选原因和效应变量之间是否存在反事实因果关系:在其他所有条件相同的情况下,如果一个全能的演员只改变原因,那么结果是否必然改变?注意,这种因果关系在阅读测试成绩和鞋尺码之间的关系中不存在:如果有人干预并改变某个年龄段儿童的平均测试成绩,他们的鞋尺码不会改变。相比之下,用一群年龄更小的孩子代替一群年龄更大的孩子,会导致平均阅读测试分数(假设测试没有经过年龄调整)增加,平均鞋尺码也变大。
当阐述候选特征和标记之间的相关关系时,有助于确定并优先考虑与标记存在反事实因果关系的特征,而不是表明与标记之间存在相关性或不明确关系的特征。
9.1.2 推理之前的记录
本节教你如何避免特征选择中的一个常见陷阱:使用在训练时已知但在部署到生产环境后难以获取或难以获取的特征。
您正在考虑的特征值必须在推理时可用,一旦您的机器学习模型处理超出用于创建模型的训练和测试数据集的数据时。这是特征选择的一个重要但经常被忽视的方面,特别是因为训练数据集是回顾性的,通常不包含有关数据值可用顺序的信息。例如,描述公司销售交易历史的训练数据集通常不会捕捉到在关闭销售交易的金额之前,客户的姓名和联系信息是公司可用的这一事实。
使这个问题微妙的是,回顾性地查看过去事件的数据,正如在训练数据集中记录的,事件的顺序可能并不是立即显而易见的,您可能会在推理时无意中使用未来的信息。例如,假设您正在尝试估计新生儿的体重,其中一个候选特征是怀孕的持续时间。
注意:早产儿的体重低于平均水平,而在怀孕 42 周后出生的婴儿平均体重较重,因此,以周数表示的怀孕持续时间似乎是一个有用的选择特征。
在训练数据集中找到怀孕期(以周为单位)的持续时间不足为奇,因为这是一个历史性的衡量标准,在出生时记录是有意义的。但是,如果您正在创建一个模型,以估计怀孕期中途的常规医生探访时新生儿的预期体重,那么出生时的怀孕周数就是未知的。
对新生儿体重估计的示例只是所谓数据泄漏问题的一个实例。数据泄漏的其他示例包括在训练、验证和测试数据集之间重复观察。通常,当关于标签值的信息无意中包含或“泄漏”到训练数据集中时,就会发生数据泄漏。数据泄漏的症状包括在训练数据集上膨胀的机器学习模型性能,甚至在部署到生产环境后完全失败。例如,当使用来自著名的泰坦尼克号数据集的特征时,就会出现这个问题,如图 9.2 所示。
在泰坦尼克号数据集的特征子集中,年龄和性别在乘客的生存结果之前是已知的。根据乘客的生存或死亡情况记录了船和尸体特征的值。因此,船和尸体特征会“泄漏”有关生存标签的数据。
推理过去事件的预测是违反直觉的;然而,泰坦尼克号数据集恰恰邀请您这样做。您可以训练一个机器学习模型来预测泰坦尼克号在 1912 年 4 月横渡大西洋时的乘客的二元生存结果(是否幸存)。虽然整个数据集包含 13 个特征和一个标签,但图 9.2 仅显示了与此示例相关的 4 个特征以及标签(生存)。数据集中的生存标签使用值 1 表示乘客幸存,而值 0 表示乘客遇难。年龄特征是数值型的,表示乘客登船时的年龄,而性别特征则限定为男性和女性值,分别使用 1 和 0 表示。船和尸体特征都是分类的,并编码为字符串。船特征存储乘客被救的船的标识符(例如“11”)。尸体特征存储类似“328”的值,指定了遇难乘客的标识符。
为了避免使用“预测”一词带来的违反直觉的含义,在考虑候选特征训练机器学习模型时,将每位乘客的生存结果推断出来是有帮助的。从时间顺序的角度来看,推断基于年龄和性别特征的生存是合理的,因为这两个特征在每位乘客的生存结果之前都存在。图 9.2 使用从年龄和性别特征指向生存标签的箭头来展示这一点。因此,机器学习模型可以训练使用年龄和性别特征,因为这两个特征应该在推断时可用。
相比之下,一旦泰坦尼克号上的乘客幸存或遇难,该乘客就会在救生船上幸存或者以尸体标签结束。图 9.2 使用从生存标签指向尸体和船特征的箭头来展示这一点。尸体和船特征值都不应该用于训练机器学习模型推断泰坦尼克号乘客的生存结果。当然,我们不希望发生类似的事件,因此这样的模型永远不会用于推断除泰坦尼克号乘客之外的任何乘客的生存结果。这个例子的目标是介绍数据集中特征的重要性以及特征之间的时间顺序关系。
在其他情况下,由于法律或伦理原因,在推断时可能无法获得特征值。例如,对于一个总部位于欧盟(EU)的公司,记录求职者的出生日期是完全合法的。如果你在欧盟使用这种类型的人力资源信息来估计求职者接受工作邀约的可能性,求职者的年龄是一个合理的特征选择。然而,一旦你试图在美国应用同样的特征,获取求职者的出生日期可能违法,因为在美国,基于年龄做招聘决定是非法的。此外,泄露有关出生日期的特征,如高中或大学毕业年份,也可能引起企业的法律担忧。因此,如果你试图将在欧盟建立的机器学习系统调整到美国,你应该重新评估培训数据集中的特征是否被你的组织允许使用。正如你可以想象的那样,人力资源数据集可能充满了信息(例如种族或健康记录),但作为机器学习模型的特征则是非法或不道德的。
9.1.3 丰富的示例支持
本节探讨了在选择具有太多缺失值或具有太少不同值的特征时可能出现的问题。
简单地通过添加 NaN 或 NULL 值的列将一个特征引入到训练数据集中是非常简单的。虽然这样的特征显然是无用的,但这个例子提醒我们,在增加训练数据集中的特征数量时,更多并不意味着更好。另一个极端情况涉及添加组合起来作为标签的唯一标识符的特征。作为特征工程的最坏情况示例,考虑一个分类问题的训练数据集,其中分类标签有 2^N 个不同的值。由于 N 个二进制特征可以编码 2^N 种可能性,每个标签值可能都可以通过 N 个特征列的二进制值来唯一标识。
当然,在实践中很少会发生特征过度工程的极端情况,但它们可以作为方便的参考点,用于比较你自己的特征工程工作。例如,如果你正在选择一个特征,你是否期望在培训数据集中有显著比例(大于 5%)的特征值不是 NaN?如果你选择了多个特征,这些特征值在一起是否会导致在培训数据集中的标签值具有唯一标识符,而在测试数据集中没有?
要回答这些问题,你需要确保在考虑候选特征时,也要保持对特征缺失值的统计以及每个标签值的特征值集合的交叉乘积计数。
9.1.4 用具有有意义的比例表达的数字
本节将教授一个方便的经验法则,用于检查是否将一个特征正确地表达为可用于机器学习算法的数字。
正如你在第一章中了解的那样,本书关注的是从结构化数据集进行有监督机器学习。对于特征工程来说,这意味着如果你计划使用用于训练机器学习模型的原始数据包含视频、图像、音频或自然语言文本等非结构化内容,则在开始进行本书中描述的特征工程步骤之前,必须将相应的非结构化数据值转换为数字表示。这些转换的具体技术,例如自然语言文本的单词嵌入或用于摄影数据的图像分类,超出了本书的范围。
对于特征工程来说,拥有一个由数值组成的项目数据集是前提条件,因为有监督的机器学习模型是对数字(根据附录 A 的定义,可以是连续的或分类的)特征值进行算术操作以估计标签值的序列。但是,即使你的原始数据值是数字(即使用数字表达的),这也不意味着用于训练机器学习模型的相应特征具有有意义的数值比例和大小。如果你不相信,考虑一下字母 A 和 B 在 ASCII 编码标准中分别表示为数字 65 和 66。编码的总和 131(65+66)对应于字符 â(带有抑扬符的 a 字母),这不是一个有意义的结果。
如果你能够熟练应用附录 A 中图表 A.9 中的正式定义到这个例子中,你应该会认识到 ASCII 编码了一个描述 ASCII 字符有限词典的分类变量。一般而言,你可以通过对这些值进行基本的算术操作来检查数值是否可以被视为连续变量,以确认是否能够获得有意义的结果。
9.1.5 基于项目的专业见解
本节描述了特征设计、选择和工程最重要的指导原则——它可以对你的机器学习模型的性能产生最积极的影响。
在与具有各自行业深度领域专业知识的主题专家(SMEs)一起开展机器学习项目时,我发现鼓励 SMEs 提出有用特征的最有效方式是问他们:“您会与团队分享哪些信息以帮助他们更好地估计标签值?” 这种谈话方式激励 SMEs 将注意力从机器学习系统设计的复杂性转移到以自然、以人为本的术语思考问题。接下来,我会问:“如何从项目数据集中提取这些信息?”
总之,对这些问题的回答提供了从项目数据集中生成候选特征的途径。例如,假设您正在解决的问题是对世界各地城市拍摄的汽车照片进行分类,以估计照片是否包含该城市的出租车。虽然可以进行复杂的工程演练,提取徽章号码、车牌号码或其他独特的出租车标识符,并将此信息与城市出租车的市政数据集相结合,但有一个更简单的前进路径。了解该城市的人类 SMEs 可以立即通过颜色辨认出出租车,例如在纽约市为黄色或在伦敦为黑色。这个经验法则是 SME 可以轻松与从未去过该城市的人分享,并帮助他们在街上的众多汽车中识别出一辆出租车。
成功的特征选择不仅仅是算法数据处理;它是一种基于对世界的人类常识和对问题领域的洞察的创造性过程,用于机器学习项目。请记住,本节中的案例研究方法可以介绍您了解特征选择的构思和设计基础。但是,这并不能取代将机器学习系统投入生产的真实世界经验。
9.2 特征选择案例研究
本节介绍了在金融、广告和移动游戏行业跨越三个不同案例研究中应用监督机器学习的应用。在每个案例研究中,您将了解一个特定行业的机器学习项目,以便了解如何将特征选择原则应用于其他项目。
-
金融:信用卡欺诈分类。您正在与一家金融行业公司合作,该公司向客户发行信用卡,并监视客户信用卡交易以寻找欺诈迹象。您监督的机器学习分类模型的目的是估计给定交易是否欺诈或非欺诈。为了使特征工程练习简单化,请假设您正在使用一个平衡的数据集(在生产中并非如此)来处理欺诈与非欺诈示例,因此您的分类器的准确度是一个有意义的机器学习模型性能指标。
-
广告:在线横幅广告点击估计。你正在与一家广告行业公司合作,为其客户管理在线广告横幅,并在观看者点击横幅时收费。由于该公司有一款为客户设计横幅广告的工具(例如横幅广告设计师示例,请查看
github.com/osipov/banner-designer
),它拥有横幅广告设计和广告活动期间广告收到的点击数的数据集。你监督的机器学习回归模型的目的是根据横幅设计估计广告应该获得的总点击数。模型的特征应该根据广告内容和设计设置进行选择。 -
移动游戏:流失预测。你正在与一家快速增长的移动游戏初创公司合作,帮助他们通过对他们的畅销射击游戏 Clash Legends 进行升级来提高客户满意度。你监督的机器学习回归模型的目的是估计下一周有望从其移动设备上卸载游戏(即流失)的总客户数(游戏玩家)。
9.3 使用指导原则进行特征选择
在这一部分中,针对第 9.2 节的每个案例研究,都提出了几个建议的特征,并讨论了是否应该选择该应用程序。请注意,对于每个案例研究,使用领域知识和一些常识有助于决定是否选择该特征。
9.3.1 与标签相关
这一部分教授了评估候选特征所需的概念,即它们与标签的关系有多强,以便您可以优先选择更有效的特征。
案例研究: 信用卡欺诈分类
特征: 在交易中使用信用卡购买同一供应商的次数
讨论: 一笔欺诈性信用卡交易可能涉及未经授权的购买。例如,欺诈者可能使用被盗的信用卡从欺诈者拥有的虚假在线商店购买商品,或者从没有视频监控或保留不良记录的实体供应商购买商品。相反,如果卡在交易中多次用于从供应商购买且没有欺诈报告的情况,则关联性可能较低。对于这个特征,关联应该是明显的,并且应该选择该特征用于模型。
特征: 在交易期间信用卡插入信用卡阅读器的毫秒数
**讨论:**这是一个技术性的信息示例,机器学习从业者通常可以获得这些信息,但却不能转化为模型的重要特征。注意,使用信用卡进行实体交易时,有时卡片会很快取出,而有时卡片会停留在读卡器中较长时间。只要交易成功完成,就没有这个特征与某个交易是否欺诈之间的关联。尽管你可能会认为,一个欺诈者很可能窃取了一批卡,并在进行欺诈交易时迅速轮换使用它们,但请记住,模型的目的并不是对一批交易进行分类,而是必须对任何单一的任意交易进行分类。大多数交易都是非欺诈性的,并涉及各种各样的信用卡读卡器和许多用户,他们可能会将卡片在读卡器中停留任意长的时间。在这种情况下,并没有明显的关联选择这个特征。
**特征:**交易中供应商的业务类别
**讨论:**众所周知,在美国,盗取的信用卡通常会在加油站进行小额购买,以确认卡片的正常使用。尽管在特征选择时可能不知道其他关联性,但机器学习的应用可能会揭示供应商特定业务类别与欺诈交易之间的关联。
**特征:**信用卡的过期日期
**讨论:**如果这个候选特征仅仅捕捉了卡的过期月份,而卡片的过期月份大致上有相同的几率,那么就没有理由相信这个特征和欺诈之间存在关联。此外,仅仅捕捉过期月份和年份并不意味着伪造交易发生在一个过期的卡上:当卡到期时,是不会发生交易的,所以不需要将其归类为欺诈或非欺诈。换句话说,没有理由相信欺诈者能够以某种方式盯上在十二月过期的卡而不是一月过期的卡。然而,在美国,从邮箱里盗取信用卡存在潜在的欺诈交易的可能性。这意味着你可以选择一个更复杂的特征,来捕捉交易日期和卡的到期日之间的差异,以便在发生欺诈交易靠近卡发放日期的时候进行检测,例如卡到期前两到五年。
案例研究:在线横幅广告点击预估
**特征:**横幅广告中商品的价格
讨论: 这是可以从广告中选择的最明显的特征之一。商品的价格很可能是决定广告查看者是否对广告感兴趣并点击横幅的最重要因素。鉴于这种关联的强度,这应该是你回归模型的优先特征。
特征: 横幅广告中文本的字体类别
讨论: 在这种情况下,该特征基于广告中使用的字体的命名类别,例如 Times New Roman 或 Comic Sans。要描述这个候选特征与标签之间的关联,请记住设计元素(如字体)会引发观众的情绪反应。由于某些字体可能更具吸引力,因此捕捉广告中使用的字体类型的分类特征可以模拟字体引发的参与感与广告点击总数之间的微妙关联。当然,这个特征应该与广告的其他设计元素一起使用,包括内容。例如,选择 Comic Sans 来广告小丑服装可能会比将该字体用于财富管理广告更容易产生更多点击。
特征: 横幅广告上显示的剩余库存商品数量
讨论: 你一定曾在某个在线零售网站购物时看到过类似“仅剩三件”的消息,位于你想购买的商品旁边。那么,选择一个指示横幅广告上显示的剩余商品数量的特征是否合适呢?由于你一定在真实的横幅广告上看到过这样的消息,你可能会倾向于选择这个特征,但是这个特征与标签之间的关联是什么呢?在这种情况下,关联与广告查看者在看到广告商品的有限库存后可能产生的更强烈的紧迫感有关。紧迫感可能会转化为更高的点击率,这正是你试图捕捉的。
特征: 库存系统报告的广告商品数量
讨论: 尽管你可能会认为商品的低库存水平是商品受欢迎程度的代理,表明这是一种备受追捧的商品,但你应该从许多不同横幅广告跨多个广告系列的角度思考这个特征。例如,玩具汽车的库存量与实际汽车的库存量以及纸张的库存量不同。此外,广告查看者对于广告商品的实际库存数量一无所知,因此点击广告的决定与实际库存商品数量之间没有关联。
案例研究:流失预测
特征: 客户的邮政编码
讨论: 玩家的地理位置与他们在下一个季度流失的潜力可能有各种各样的联系。一些原因是技术性的:也许游戏服务器基础设施使用的网络基础设施会使美国东南部的邮政编码产生更高的延迟(因此增加了不好的体验),而与加州湾区的客户相比。其他原因可能来自人口统计学:与游戏的“甜点”相比,一些邮编有较老或较年轻的人口。
特征: 每月花费在服务上的美元金额
讨论: 由于移动游戏通常包括每月循环订阅价格以及各种选项(例如:花钱购买强化道具,玩家角色装饰和其他选项来在其他玩家中脱颖而出),因此玩家在游戏中花费的金额是其投入程度的代表,也是其投资程度的衡量标准。因此,花费的金额与下一个季度流失的可能性之间存在复杂的关系。例如,不在游戏上花钱可能意味着玩家失去了兴趣,更有可能卸载游戏。另一方面,如果玩家过度投入,花费过多的巨额款项(例如,处于前 0.1%的百分位数),他们也很可能因游戏过度消磨时间而精疲力尽,从而卸载他们的设备上的游戏。
特征: 距下一次游戏公司纳税日期的天数
讨论: 美国的公司必须向美国税务局提交季度和年度纳税申报表。令人惊讶的是,许多公司注意到了纳税日期和客户流失的变化之间的相关性。尽管公司内部分析师可能会注意到并报告此相关性,但您应该对此协会持怀疑态度:玩家在卸载日期时是否甚至知道这些日期?如果存在相关性,更有可能是在月底,当很多人回顾他们的月度支出并决定削减不必要的物品时。由于美国税务局的纳税申报日期与许多人重新评估他们的支出的时间重合,因此这可能表现为美国税务局时间表和客户流失之间的虚假相关性。
特征: 订阅游戏的周数
讨论: 游戏的许多卸载发生在玩家第一次安装游戏后不久,然后决定放弃它。
9.3.2 推理时间之前记录
本节介绍了数据泄漏的概念,它可能会微妙地破坏您的机器学习模型的性能,并说明了有效的特征选择如何帮助您避免这些问题。
案例研究:信用卡欺诈分类
特征: 在该店之前使用过该卡
讨论: 此信息应在推理时可用,因为通过实体卡阅读器进行的实体卡交易将被分类不同,并为每笔交易记录适当的信息。
特征: 交易中售出的商店商品是新进货的商品
讨论: 如果你进行过信用卡购买,你会知道,在交易级别,购买的详细信息对于发卡公司是不可用的。例如,如果你在杂货店购买并购买了一种新口味的可口可乐,信用卡公司无法将此信息与商店的其他购买区分开来;所有商品只是被合并成一笔费用,所以这对你的机器学习模型来说不是一个好的特征。
特征: 交易中销售的商品类别
讨论: 这可能在推理时可用,也可能不可用。例如,如果交易是汽油购买,那么类别是明显的。在其他情况下,当购买是在诸如 Target 或 Walmart 之类的大型零售商处进行时,存在成千上万种不同的商品类别,这些信息在推理时是不可用的。
特征: 使用信用卡在实体(而不是在线)位置消费
讨论: 此信息应在推理时可用,因为通过实体卡阅读器进行的实体卡交易将被分类不同,并为每笔交易记录适当的信息。
案例研究:在线横幅广告点击量估算
特征: 使用折扣码购买的商品总数
讨论: 尽管此信息应在运行活动的公司的数据仓库中在活动结束后可用,但此信息在推理时不可用。
特征: 在过去 30 天内使用折扣码购买的商品数量
讨论: 如果运营广告的公司维护有关折扣码使用次数的交易数据,那么可以维护一个滑动窗口,记录过去 30 天的数据,并为任何给定的交易计算这个值。此外,通过预测横幅广告上折扣码的点击次数,可以使用这个每日值更好地估计总横幅广告点击次数。
特征: 查看了有关该商品的横幅广告的顾客数量
讨论: 对于在线横幅广告,信息应该来自数据仓库或数据分析源。
特征: 制造商广告库存中的商品数量
讨论: 故意选择这个候选特征,引发一个发人深省的关于潜在特征和广告参与度的讨论。请记住,训练数据集是基于过去广告活动的数据和实际的横幅广告点击次数。虽然您可能拥有关于广告活动中使用的项目的历史库存数据(在广告被查看时),但是否应该将此数据用于机器学习模型的特征?尽管您可能会认为低库存水平表示一个受欢迎或需求量大的项目,但您应该从许多不同横幅广告的广告活动的角度考虑该特征。您无法建立合理的关联来使用此特征。此外,在运行时(执行推断或产生估计时)获得此特征的值可能是一个技术挑战,这可能超过了通过尝试此关联获得的价值。
案例研究:流失预测
特征: 游戏中总共花费的分钟数
讨论: 请注意,在描述此特征时使用词语 总 可能会导致混淆。对于卸载游戏的人来说,总数是指安装游戏的整个时间段内花费的分钟数。相比之下,对于没有卸载游戏的人来说,总数描述的是他们的数据被记录在训练数据集中的时点之前的分钟数。一旦清楚了这个双重解释的可能性,也就清楚了不应使用此特征,因为在推断时不可能确定从未取消订阅或卸载游戏的玩家的游戏总分钟数。
特征: 过去 28 天内玩游戏的总分钟数
讨论: 通过微小的变化,限制对过去 28 天内游戏时间的测量,可以使用前一特征的关键思想。无论玩家是否在下个月流失,都可以测量他们过去 28 天的游戏活动,并将其用作训练和推断的特征。
特征: 客户提供的卸载原因
讨论: 当玩家卸载游戏时,他们可以指定反馈,说明他们为什么决定卸载。显然,这些信息仅适用于已卸载游戏的玩家,并且只有在他们卸载游戏后才可用。因此,在推断时不可用,不应用于训练机器学习模型。
特征: 流失前三个月客户满意度得分
讨论: 如果客户满意度是随机收集的,涵盖了已卸载游戏的玩家以及继续玩游戏的玩家,那么这是一个有用的特征,可以用于推断。
9.3.3 丰富的例子支持
本节提供了候选特征的示例,这些特征可能或可能不足以训练机器学习模型,以指导您在实际示例中使用此原则。
案例研究:信用卡欺诈分类
特征: 持卡人地址和商家地址之间的距离
讨论: 拥有有关持卡人地址和交易发生地点之间距离的信息在欺诈分类时可能很有用。但是,要使用此特征,从业者需要评估在实践中是否有此特征的示例。除非客户在交易时已积极地进行了地理编码以测量距离,否则不应期望有足够的此特征示例。
特征: 持卡人地址和商家地址的邮政编码是否相同
讨论: 请注意,与试图使用地理编码估算持卡人和商家位置之间距离的特征不同,可以通过检查商家和持卡人邮政编码是否匹配的特征来支持客户的历史交易数据。由于每笔金融交易都应具有此特征,因此应该有大量关于此特征的示例。
特征: 在商家处使用过卡片
讨论: 金融公司会为给定卡片维护一份交易历史记录,因此可以检查以前的交易,以确定该卡片是否在特定商家处使用过。缺少与商家的交易表明该卡片未在商家处使用过,因此对于数据集中的每笔交易,可以为此特征分配真值或假值。
特征: 购买物品的类别
讨论: 尽管对于一些商家(如加油站),可能可以唯一地识别交易中购买的物品的类别,但许多公司维护专有的物品库存代码。一般而言,并不保证交易包含有关所购买物品的信息。您不应期望对于一般用途的欺诈与非欺诈分类器而言,有足够数量的购买物品类别示例。但是,您可能可以为特定子类商家(如加油站)创建更专业的分类器。
案例研究:在线横幅广告点击量估算
特征: 广告活动开始的年份的日期
讨论: 只要管理横幅广告活动的公司维护活动的开始和结束日期,此日期应该对于每个训练示例都是可用的。
特征: 查看横幅广告的人已经购买了广告中的物品
-
讨论: 大多数情况下,横幅广告中显示的商品购买次数与横幅广告浏览次数相比少于 0.1%。你不应该期望有大量例子表明广告观看者是否购买了该商品。
-
特征: 广告中提供的折扣百分比
-
讨论: 广告中提供的大多数折扣百分比都是基于一小部分众所周知的值,如 10%、20%、25%、50%、75%或 90%。你不应该期望在横幅广告上看到折扣 27.54%。只要跨营销活动提供的不同折扣金额的数量是训练数据集的一个小部分,你应该有足够的示例来支持这个特征。
案例研究:流失预测
-
特征: 玩家账单地址位置的纬度和经度
-
讨论: 由于客户账单地址位置的纬度和经度对于每个客户的账单位置是唯一的,使用这些值来估计玩家是否要卸载游戏是错误的。除非谨慎使用,特定的坐标值可能会产生过度拟合训练数据集的模型。不应使用账单地址位置的原始纬度和经度值来预测客户流失。
-
特征: 玩家以前卸载游戏的次数
-
讨论: 如果玩家过去卸载了游戏,他们很可能会再次卸载。请注意,游戏卸载记录的缺失可能意味着玩家从未卸载过游戏,只要游戏平台将卸载事件准确报告给游戏公司即可。因此,应该能够为每个训练示例选择具有值的此功能。
-
特征: 玩家打算在下个月游戏上花费的美元金额
-
讨论: 尽管知道一个给定玩家在下个月的预算中有多少钱用于支付手机游戏可以极大地帮助估计他们是否会流失,但公司很有可能对任何或任何重要部分的玩家都没有这些信息。
9.3.4 具有意义大小的数值
这一部分比较了备选特征表示法,以帮助你选择连续和分类特征之间的区别。
案例研究:信用卡欺诈分类
-
特征: 交易中供应商的邮政编码
-
讨论: 尽管交易中的邮政编码是一个整数,但应明确它不是连续值,不能直接在机器学习模型中使用。如果你还不相信,你可以应用算术值的经验法则:将纽约市的邮政编码 10001 加到华盛顿特区的邮政编码 20002,得到 30003 的邮政编码,这是乔治亚州 Norcross 的邮政编码-显然是一个无意义的结果。邮政编码值应被编码并被视为一个分类变量。
特征: 交易中信用卡被用于从同一供应商购买的次数
讨论: 虽然可以将此计数视为连续变量并获得有意义的结果,但请注意,知道卡片在供应商处使用的实际次数并不特别有意义。
特征: 信用卡的到期日期
讨论: 信用卡到期的月份和年份作为连续变量并不实用。你可以将这些信息重新编码为距离到期的天数,但是这些信息的原始数值并没有实际意义。
案例研究:在线横幅广告点击量估计
特征: 折扣的百分比值,例如 10%、25% 或 50% off
讨论: 表面上看,这似乎是一个简单的数值特征值。但是,负值呢?可能获得负 100%的折扣吗?这应该被分类为重新列出特征值的分类特征。
特征: 横幅广告的尺寸
讨论: 表面上看,像 400 像素乘以 400 像素或 100 像素乘以 400 像素这样的数量似乎是传统的数值特征。然而,由于模型试图从这些值中外推和内插,你可能会发现自己处理意外的结果。
特征: 横幅广告所使用的字体
讨论: 添加或乘以字体值会产生无意义的结果。这不是一个连续而是一个分类特征。
特征: 横幅广告字体的颜色
讨论: 虽然可以将颜色表示为数字的组合,例如使用红-绿-蓝值,但在这种情况下,特征是关于预测广告点击的,因此更适合使用分类表示,因为颜色在整个广告中是统一的,落入人类可读的类别,例如蓝色、黑色或绿色。
特征: 通过优惠券折扣的项目类别标识符
讨论: 类别编号,如 1 代表乳制品,3 代表罐头食品等,作为连续值并不具有实际意义,应该重新编码为分类变量。
案例研究:流失预测
特征: 客户的平均游戏时间与用户群体的平均游戏时间之比
讨论: 这个特征应该被编码为连续值。它有一个有意义的零值(即当客户的平均值与整个用户群的平均值相同时),并且有一个有意义的负值到正值的范围。
特征: 订阅游戏的周数
讨论: 这个特征应该被编码为连续值,因为它可以被细分为更精细的部分。
特征: 客户使用的移动操作系统(例如 iOS、Android 或其他)
讨论: 由于移动游戏应用程序支持的操作系统是有限的,所以这个特征应该被编码为分类变量。
9.3.5 带来专家见解解决问题
本节将通过金融、广告和游戏领域的专家见解的示例,帮助您磨练这些特性,以实现更有效的机器学习。
如本章所述,虽然在技术上选择各种特性是可行的,但并不是每个潜在的特性都对机器学习系统的成功有所贡献,有些特性可能对系统产生更多伤害而不是益处。成功的特性是那些专家知识和常识增强原始数据集并简化机器学习算法任务的特性,使特性与标签值之间的关系更直接。
案例研究:信用卡欺诈分类
特性: 过去一个月与嫌疑供应商的交易
讨论: 根据专家的说法,许多欺诈交易是由于供应商受到威胁,员工窃取信用卡信息后使用被窃信息实施欺诈行为。人工专家通常使用基于图形的分析来识别涉嫌教唆欺诈的供应商,如图 9.3 所示。图 9.3 显示,三张用于报告欺诈交易的信用卡都在同一嫌疑供应商处使用,以粗体显示在左侧。
图 9.3 合法的信用卡交易与嫌疑供应商(左侧以粗体显示)导致被报告为欺诈的交易(右侧)。
基于专家见解,您可以为每个供应商使用一个数字特性,表示过去一个月报告欺诈的信用卡总数。
案例研究:在线横幅广告点击量估计
特性: 广告主题与 Twitter 热门话题的相关度评分
讨论: 正如你所预期的,在线横幅广告的点击量不仅取决于广告本身的内容,还取决于其与市场的相关性。例如,关于手电筒的广告会在飓风登陆前夜产生更多的点击量。同样,在温网决赛周末,关于私人网球课程的广告会产生更多的点击量。基于这一观察结果,您可以设计一个数字特性,捕捉在线横幅广告中与广告活动期间 Twitter 上热门话题的主题之间的相似性。
案例研究:流失预测
特性: 社交网络上玩游戏的连接人数。
讨论: 视频游戏设计人员会告诉您,同行压力是玩家是否继续玩游戏的最强预测因素之一。这就是为什么很多手机游戏尝试连接到您的 Facebook 和其他社交媒体帐户的原因:如果游戏开发商知道您的“朋友”在玩游戏,他们就会知道您也有可能玩。
9.4 选择 DC 出租车数据集的特征
在这一部分,您将学习有关 DC 出租车票价数据集的专家见解,以及如何将这些见解应用于选择一组候选特征,以供您的机器学习模型使用。
从第四章中可以回顾到,DC 出租车数据集的特征集相当稀疏:推断时仅可用于原始数据包括行程开始的日期和时间戳以及出租车行程的乘车和下车位置的纬度和经度坐标。您可以在出租车票价估算问题中带来什么见解以选择正确的特征?
对于 DC 出租车数据集,一个关键的专家见解涉及用于指定行程位置坐标的 GPS。GPS 系统可以精确到距离实际位置 1 米(略超过 3 英尺)。然而,从出租车票价估算的角度来看,GPS 精度过高:正如第二章所述,出租车行程的业务规则使用 1/8 英里的粒度进行定价。这相当于大约 200 米(约 660 英尺)的精度。定价精度比 GPS 坐标估计的精度粗略多达两个数量级。因此,该数据集的特征可以包括对出租车乘车和下车坐标的更粗略的表示。
DC 出租车数据集中的出租车乘车地点的边界是使用纬度和经度坐标的最小值和最大值指定的。这些最小值和最大值如何用于特征?图 9.4 中的插图解释了关于乘车和下车坐标的粗略表示的概念。
图 9.4 中的原始最小和最大纬度和经度坐标(左)可用于基于坐标的分箱生成数值特征(右)。
为了清晰起见,DC 出租车数据集的实际纬度和经度坐标的最小值和最大值被替换为一组更方便的数字。尽管这些虚构的坐标数字与华盛顿特区非常接近,但它们被选中是为了更容易地解释。因此,图 9.4 中的左侧和右侧都假设 DC 出租车数据集中所有行程的坐标范围从纬度(南北)38.30 到 38.90,经度(东西)-76.48 到-76.12。
除了使用原始纬度和经度值来训练机器学习模型之外,用于乘车和下车坐标的粗粒度表示的特征可以使用分箱(也称为离散化或量化)GPS 坐标值的概念,如图 9.4 右侧所示,假设纬度和经度坐标均被“分箱”为三个箱子,分别对应于三个相等大小的区间:
-
(38.30, 38.50),(38.50, 38.70),以及 (38.70, 38.90),对应纬度
-
(–76.48, –76.36) (–76.36, –76.24),以及 (–76.24, –76.12),对应经度
图 9.4 右侧的图表使用整数 0、1 和 2 索引了三个箱子中的每一个。因此,九个粗粒度位置中的每一个可以使用一对整数表示。例如,从一个上车位置(38.31,-76.47)到一个下车位置(38.61,-76.14)的出租车行程可以分别用位置(0,0)和(2,1)表示。请记住,选择九个位置是任意的。由于不清楚位置边界应该有多粗,因此最终应该根据相应特征如何帮助模型预测出租车票价来衡量坐标的粗细选择。
在接下来的章节中,你将学习如何应用特征工程技术来实现本节描述的粗粒度上车和下车位置特征。请注意,尽管基于专家对粗粒度位置表示的见解的特征不能保证改进的出租车票价估计,但这些特征可以在机器学习模型开发的迭代过程中使用,并且可以根据其对机器学习模型性能指标的影响进行评估。
概要
-
有效的特征工程可以决定机器学习系统是平庸还是成功。
-
尽管特征选择更多的是一门艺术而不是一门科学,但机器学习实践者可以通过练习指导原则到现实世界的例子,学习识别机器学习系统所需的正确特征的技能。
-
本章的案例研究帮助机器学习实践者学习如何将特征选择原则一贯地应用于来自不同行业的案例研究,包括金融、广告和移动游戏。
-
成功的特征工程通过精心选择和设计的特征,将常识知识和专家见解融入到机器学习问题中的原始训练数据中。
^(1.)反事实因果关系与因果关系的潜在结果定义密切相关。Judea Pearl 等人的书籍,《统计因果推断:入门》(Wiley,2016),是一个很好的资源,对因果关系有更正式的处理。
^(2.)泰坦尼克号数据集,以及文档,可从www.openml.org/d/40945
获取。
第十章:采用 PyTorch Lightning
本章涵盖了
-
使用 PyTorch Lightning 减少样板代码
-
为 DC 出租车模型添加训练、验证和测试支持
-
使用 pandas 分析 DC 出租车模型的训练和验证
到目前为止,您已经编写了与训练和测试您的机器学习模型相关的实现。然而,您编写的大部分代码与您的机器学习模型架构无关,可以适用于广泛范围的不同模型。基于这一观察结果,本章介绍了 PyTorch Lightning,这是一个可以帮助您减少机器学习系统中样板工程代码量的框架,并因此帮助您专注于发展您的模型设计和实现的框架。
了解 PyTorch Lightning
本节介绍了 PyTorch Lightning 框架,用于您的 PyTorch DC 出租车车费估算模型,并教您如何启用 PyTorch Lightning 训练、验证和测试特性的步骤。
目前为止,您已经为您的机器学习模型实现了大部分的 Python 和 PyTorch 样板代码。这意味着你的实现中只有少部分是模型特定的,比如
-
将特征值打包为张量
-
配置神经网络层
-
计算损失的张量
-
模型指标报告
剩下的大部分代码,比如迭代培训批次、验证批次和培训纪元的代码,主要是样板代码,这意味着它可以在模型特定的代码发生各种变化时不加修改地重复使用。
随着您的机器学习系统变得更加复杂,系统实现中的样板代码也会变得更加复杂。例如,成熟的机器学习系统需要周期性保存(检查点)模型的权重值到存储介质,以便实现可复制性。拥有模型检查点还可以使机器学习训练流程从预训练模型中恢复。其他例子包括与超参数优化服务、指标跟踪和控制机器学习流程的其他实验管理工具集成的代码。这不应该令人惊讶:回想一下第一章中提到的,一个生产级机器学习系统的组件中有超过 90% 是辅助于核心机器学习代码的。
PyTorch Lightning 框架 (www.pytorchlightning.ai
) 的目标是通过帮助开发者专注于开发核心机器学习代码而不被样板代码分散注意力来提高 PyTorch 开发者的生产力。就 DC 出租车模型而言,采用 PyTorch Lightning 是直接的。在开始之前,您需要确保已经在您的 shell 环境中运行以下内容安装了 PyTorch Lightning 的 pip 包:
pip install pytorch_lightning
PyTorch Lightning 是一个全面的框架,具有可观的特性集,用于机器学习模型开发。本书不旨在取代现有的 PyTorch Lightning 教程或文档;相反,接下来的章节专注于您可以为 DC 出租车模型采用的框架特性。
10.1.1 将 PyTorch 模型训练转换为 PyTorch Lightning
本节教你关于 PyTorch Lightning 的 init、training_step 和 configure_optimizers 方法,然后演示如何为 DC 出租车模型实现这些方法以及如何使用小型的示例训练数据集训练基于 PyTorch Lightning 的 DC 出租车模型。
假设 PyTorch Lightning 包在您的环境中正确安装,您可以实现一个最小的、可训练的 DC 出租车模型。
图 10.1:带有支持的基本 PyTorch Lightning DC 出租车模型
import torch as pt
import pytorch_lightning as pl ❶
pt.set_default_dtype(pt.float64) ❷
class DcTaxiModel(pl.LightningModule): ❸
def __init__(self, **kwargs): ❹
super().__init__() ❺
self.save_hyperparameters() ❻
pt.manual_seed(int(self.hparams.seed)) ❼
self.layers = pt.nn.Linear(int(self.hparams.num_features), 1) ❽
def batchToXy(batch): ❾
batch = batch.squeeze_()
X, y = batch[:, 1:], batch[:, 0]
return X, y
def forward(X): ❿
y_est = self.model(X)
return y_est.squeeze_()
def training_step(self, batch, batch_idx):
X, y = self.batchToXy(batch)
y_est = self.forward(X)
loss = pt.nn.functional.mse_loss(y_est, y)
for k,v in {
"train_mse": loss.item(),
"train_rmse": loss.sqrt().item(),
}.items():
self.log(k, v, on_step=True, on_epoch=True, prog_bar=True, logger=True)⓫
return loss ⓬
def configure_optimizers(self): ⓭
optimizers = {'Adam': pt.optim.AdamW,
'SGD': pt.optim.SGD}
optimizer = optimizers[self.hparams.optimizer]
return optimizer(self.layers.parameters(), ⓮
lr = float(self.hparams.lr))
model = DcTaxiModel(**{ ⓯
"seed": "1686523060",
"num_features": "8",
"optimizer": "Adam",
"lr": "0.03",
"max_batches": "100",
"batch_size": "64",
})
❶ 导入 PyTorch Lightning 库,并将其别名为 pl。
❷ 使用 torch.float64 作为模型参数的 dtype。
❸ PyTorch Lightning 模型必须扩展自 LightningModule。
❹ 使用 **kwargs 传递超参数给模型。
❺ LightningModule 子类必须首先调用父类的 init。
❻ 将 **kwargs 中的超参数保存到 self.hparams。
❼ 根据超参数设置设置伪随机数生成器。
❽ 为此示例使用简单的线性回归模型。
❾ 重用第七章的函数进行 batchToXy 的 . . .
❿ . . . 以及前向实现。
⓫ 使用 PyTorch Lightning 内置的日志记录来记录 MSE 和 RMSE 测量值。
⓬ training_step 方法必须返回损失张量。
⓭ LightningModule 子类必须有一个 configure_optimizers 方法。
⓮ 返回由超参数指定的配置好的优化器实例。
⓯ 实例化一个 PyTorch Lightning 版本的 DcTaxiModel,命名为 model。
注意,DcTaxiModel 类继承自基础的 pl.LightningModule 类,在 init 方法中通过 super().init() 方法调用需要单独初始化。类的其余 init 方法在此处被简化以进行说明,并突出显示以下关键概念:将模型的超参数存储在 self.hparams 中以及在 self.layers 实例中实例化模型参数。
training_step 方法是 PyTorch Lightning 实现的工作核心,执行模型层的前向步骤,计算损失并返回损失值。请注意,它依赖于 batchToXy 方法(在第七章介绍),该方法负责将一批训练样例转换为适合模型训练的格式。
转换就是使用 squeeze_ 方法消除任何形状为 1 的维度。例如,形状为 [1, 128, 5, 1] 的张量在 squeeze_ 应用后被重新调整为 [128,5]。在 squeeze_ 中使用下划线进行了小幅度的性能优化。回想一下第五章所讲的,squeeze_ 中的下划线表示 PyTorch 方法将就地执行操作,对张量进行突变,而不是返回新的张量实例。
DcTaxiModel 的实现假定张量中的第一列是标签,其余列是特征。因此,在 batchToXy 代码的结尾部分,只需将标签简单地别名为 y,将特征别名为 X 并返回结果。
在 training_step 方法中调用 self.log 报告模型计算出的训练 MSE 和 RMSE 值。正如第五章所解释的那样,在 PyTorch 张量 API 中,标量张量的 item 方法返回常规的 Python 值而不是张量。因此,使用 self.log 记录的值是 Python 数值而不是 PyTorch 张量。PyTorch Lightning 的 self.log 方法是一个可扩展的日志框架的通用 API,稍后在本章中有更详细的介绍。
示例中的 configure_optimizers 方法使用优化器字典,以便使模型能够根据优化器超参数的值在不同的优化算法(Adam 和 SGD)之间切换。尽管这种模型训练的实现尚未使用超参数优化,但在 configure_optimizers 中展示的基于字典查找的方法确保了当后续开发启用超参数优化时,模型代码无需改变。
在 PyTorch Lightning 中,使用一个 Trainer 实例来训练模型。
代码清单 10.2:使用 PyTorch Lightining Trainer 训练子类
from pytorch_lightning.loggers import CSVLogger
csvLog = \
CSVLogger(save_dir = "logs", ❶
name = "dctaxi",
version = f"seed_{model.hparams.seed}") ❷
trainer = \
pl.Trainer(gpus = pt.cuda.device_count() \ ❸
if pt.cuda.is_available() else 0,
max_epochs = 1, ❹
limit_train_batches = \ ❺
int( model.hparams.max_batches ) \
if 'max_batches' in model.hparams else 1,
log_every_n_steps = 1, ❻
logger = [csvLog]) ❼
❶ 使用 CSVLogger 可以用 pandas 进行分析。
❷ seed 超参数被用于唯一标识模型日志。
❸ 在有多个 GPU 可用时使用多个 GPU 进行训练。
❹ 在此将其设置为 1,因为训练持续时间由 max_batches 控制。
❺ 使用 max_batches 来设置训练迭代的次数。
❻ 确保每次调用 self.log 的记录都被保存在日志中。
❼ 基于 csvLog 设置将发送到 self.log 的值保存下来。
超参数值可以应用于机器学习流水线,而不仅仅是模型:例如,max_batches 超参数控制模型训练的持续时间。正如你将在本章的其余部分中看到的,超参数值可以在机器学习流水线的各个阶段使用。代码示例中的 max_epochs 设置旨在确保训练流水线可以支持 Iterable 和 Map PyTorch 数据集。回想一下第七章,IterableDataset 实例具有可变数量的训练数据集示例;因此,对这一类别的训练受到限制,限制训练批次的数量。这个数字是使用 Trainer 的 limit_train_batches 参数指定的。
在列表 10.2 中,progress_bar_refresh_rate 和 weight_summary 设置是 Trainer 的合理默认设置,以最小化训练过程中报告的日志信息量。如果你喜欢对训练模型参数进行报告,你可以将 weights_summary 更改为 “full”,以报告所有权重,或者更改为 “top”,以仅报告模型中顶部(连接到模型主干的权重)的层的权重。同样,progress_bar_refresh_rate 可以更改为表示多久重新绘制一次显示训练完成进度的进度条的整数值(以训练步骤的数量为单位)。
要提供训练样本给模型,你可以使用第七章介绍的 ObjectStorageDataset。在执行下一个示例中的代码片段之前,请确保已安装 Kaen 框架使用
pip install kaen[osds]
接下来,要仅执行模型的训练,你可以在 pl.Trainer 的实例上调用 fit 方法,传入一个包含训练样本的 PyTorch DataLoader:
from torch.utils.data import DataLoader
from kaen.torch import ObjectStorageDataset as osds
train_ds = osds('https://raw.githubusercontent.com/osipov/
➥ smlbook/master/train.csv',
batch_size = int(model.hparams.batch_size) )
train_dl = DataLoader(train_ds,
pin_memory = True)
trainer.fit(model,
train_dataloaders = train_dl)
trainer.fit(model, train_dl)
在例子中,使用来自 mng.bz/nr9a
的 DC 出租车数据集的样本简单说明了如何使用 PyTorch Lightning。在下一章中,你将看到如何通过简单地更改传递给 osds 的 URL 字符串来扩展到更大的数据集。
由于在训练过程中损失和指标值被记录到一个 CSV 文件中,一旦训练结束,你可以将这些值加载到一个 pandas DataFrame 中,并使用以下方式绘制结果,
import pandas as pd
metrics_df = pd.read_csv(f'logs/dctaxi/seed_{model.hparams.seed}/
➥ metrics.csv')
ax = metrics_df.plot('step', 'train_rmse_step')
这应该输出一个类似于图 10.1 的图形。
图 10.1 在一个小样本上,微不足道的线性回归模型按预期收敛。
根据图 10.1,来自列表 10.1 的简单线性回归模型收敛到一致的损失值。要检查收敛的最后 25 步的损失值的详细信息,你可以再次利用 pandas DataFrame API,
ax = metrics_df.iloc[-25:].plot('step', 'train_rmse_step')
ax.plot(metrics_df.iloc[-25:]['step'],
pt.full([25], metrics_df[-25:]['train_rmse_step'].mean())),
这绘制了图 10.2。
图 10.2 训练的最后 25 步收敛到大约 4.0 的 RMSE。
你可以确认在训练的最后 25 步左右,模型以大约 4.0 的平均 RMSE 收敛。即使对于微不足道的线性回归模型,这也不应该令人惊讶,因为本示例使用了一个小训练样本。
此时,引入一个 build 函数很有用,可以调用它来实例化、训练,以及稍后验证和测试模型。为了方便起见,以下是此版本模型的完整实现,其中包含了训练步骤的封装。
列表 10.3 基本的 PyTorch Lightning DC 出租车模型
import torch as pt
import pytorch_lightning as pl
from torch.utils.data import DataLoader
from kaen.torch import ObjectStorageDataset as osds
pt.set_default_dtype(pt.float64)
class DcTaxiModel(pl.LightningModule):
def __init__(self, **kwargs):
super().__init__()
self.save_hyperparameters()
pt.manual_seed(int(self.hparams.seed))
self.layers = pt.nn.Linear(int(self.hparams.num_features), 1)
def batchToXy(self, batch):
batch = batch.squeeze_()
X, y = batch[:, 1:], batch[:, 0]
return X, y
def forward(self, X):
y_est = self.layers(X)
return y_est.squeeze_()
def training_step(self, batch, batch_idx):
X, y = self.batchToXy(batch) #unpack batch into features and label
y_est = self.forward(X)
loss = pt.nn.functional.mse_loss(y_est, y)
for k,v in {
"train_mse": loss.item(),
"train_rmse": loss.sqrt().item(),
}.items():
self.log(k, v, on_step=True,
on_epoch=True, prog_bar=True, logger=True)
return loss
def configure_optimizers(self):
optimizers = {'Adam': pt.optim.AdamW,
'SGD': pt.optim.SGD}
optimizer = optimizers[self.hparams.optimizer]
return optimizer(self.layers.parameters(),
lr = float(self.hparams.lr))
def build(model):
csvLog = CSVLogger(save_dir = "logs",
name = "dctaxi",
version = f"seed_{model.hparams.seed}"
)
trainer = pl.Trainer(gpus = pt.cuda.device_count() \
if pt.cuda.is_available() else 0,
max_epochs = 1,
limit_train_batches = int( model.hparams.max_batches ) \
if 'max_batches' in model.hparams else 1,
progress_bar_refresh_rate = 20,
weights_summary = None,
log_every_n_steps = 1,
logger = csvLog)
train_ds = osds('https://raw.githubusercontent.com/osipov/smlbook/
➥ master/train.csv',
batch_size = int(model.hparams.batch_size) )
train_dl = DataLoader(train_ds,
pin_memory = True)
trainer.fit(model,
train_dataloaders = train_dl)
return model, trainer
model = build(DcTaxiModel(**{
"seed": "1686523060",
"num_features": "8",
"optimizer": "Adam",
"lr": "0.03",
"max_batches": "100",
"batch_size": "100",
}))
10.1.2 启用已训练模型的测试和报告
本节描述了 PyTorch Lightning 模型的 test_step 方法以及如何使用该方法测试和报告已训练模型的指标。
一旦模型训练完成,Trainer 实例也可以用于报告模型在测试数据集上的损失和度量。但是,为了支持 PyTorch Lightning 中的测试,必须将 LightningModule 子类扩展为实现 test_step 方法。以下代码片段描述了 DcTaxiModel 的相应实现:
def test_step(self, batch, batch_idx):
X, y = self.batchToXy(batch)
with pt.no_grad(): ❶
loss = pt.nn.functional.mse_loss(self.forward(X), y)
for k,v in {
"test_mse": loss.item(), ❷
"test_rmse": loss.sqrt().item(), ❸
}.items():
self.log(k, v, on_step=True, on_epoch=True,
prog_bar=True, logger=True)
❶ 在测试期间忽略梯度图以获得更好的性能。
❷ 使用 test_mse 而不是 train_mse . . .
❸ . . . 并在记录测试测量时使用 test_rmse 而不是 train_rmse。
PyTorch Lightning 的 test_step 不需要任何返回值;相反,代码应报告使用训练模型计算的指标。回顾第六章中的自动微分讨论,保持梯度的反向图会带来额外的性能开销。由于在模型测试(或验证)期间不需要模型梯度,因此在 pt.no_grad() 的上下文中调用 forward 和 mse_loss 方法,该上下文禁用了用于损失梯度计算的跟踪。
除了与重命名的记录损失和度量测量相关的轻微更改(例如,test_rmse 对比 train_rmse),test_step 记录的实现与 training_step 函数完全相同。
为了向 Trainer 实例引入配置更改以进行测试并创建测试数据的 DataLoader,需要修改 build 函数 ❶—❹:
def build(model, train_glob, test_glob): ❶
csvLog = CSVLogger(save_dir = "logs",
name = "dctaxi",
version = f"seed_{model.hparams.seed}")
trainer = pl.Trainer(gpus = pt.cuda.device_count() \
if pt.cuda.is_available() else 0,
max_epochs = 1,
limit_train_batches = int( model.hparams.max_batches ) \
if 'max_batches' in model.hparams else 1,
limit_test_batches = 1, ❷
log_every_n_steps = 1,
logger = csvLog)
train_ds = osds(train_glob, ❸
batch_size = int(model.hparams.batch_size) )
train_dl = DataLoader(train_ds, ❹
pin_memory = True)
trainer.fit(model,
train_dataloaders = train_dl)
test_ds = osds(test_glob,
batch_size = int(model.hparams.batch_size) )
test_dl = DataLoader(test_ds,
pin_memory = True)
trainer.test(model, ❺
test_dataloaders=test_dl)
return model, trainer
❶ 通过 URL 通配符实例化 DataLoader 以用于训练和测试数据。
❷ 仅使用测试数据集一次来报告损失和度量。
❸ 使用 test_glob 实例化 train_ds . . .
❹ . . . 并创建 train_dl 实例。
❺ 使用 Trainer.test 方法测试并报告模型性能。
在使用更新后的模型和构建实现进行训练和测试之后
model = build(DcTaxiModel(**{
"seed": "1686523060",
"num_features": "8",
"optimizer": "Adam",
"lr": "0.03",
"max_batches": "100",
"batch_size": "100",}),
train_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥ master/train.csv',
test_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥ master/train.csv')
你应该获得类似以下的测试结果:
-----------------------------------------------------------------------------
DATALOADER:0 TEST RESULTS
{'test_mse': 9.402312278747559,
'test_mse_epoch': 9.402312278747559,
'test_rmse': 3.066318988800049,
'test_rmse_epoch': 3.066318988800049}
-----------------------------------------------------------------------------
10.1.3 启用模型训练期间的验证
本节说明了如何在 LightningModule 子类中使用 validation_step 方法来启用对 PyTorch 模型的验证支持。
当您修改实现以支持训练期间的重复验证步骤时,使用 PyTorch Lightning 的优势变得更加明显。例如,要将模型验证添加到 DcTaxiModel 实现中,只需引入 validation_step 方法:
def validation_step(self, batch, batch_idx):
X, y = self.batchToXy(batch)
with pt.no_grad():
loss = pt.nn.functional.mse_loss(self.forward(X), y)
for k,v in {
"val_mse": loss.item(),
"val_rmse": loss.sqrt().item(),
}.items():
self.log(k, v, on_step=True, on_epoch=True, prog_bar=True, logger=True)
return loss
下面的代码描述了配置训练器实例以在固定大小的数据集上执行验证(而不是 k 折交叉验证)所需的剩余更改:
trainer = pl.Trainer(gpus = pt.cuda.device_count() \
if pt.cuda.is_available() else 0,
max_epochs = 1,
limit_train_batches = int( model.hparams.max_batches ) \
if 'max_batches' in model.hparams else 1,
limit_val_batches = 1, ❶
num_sanity_val_steps = 1, ❷
val_check_interval = min(20, ❸
int( model.hparams.max_batches ) ),
limit_test_batches = 1,
log_every_n_steps = 1,
logger = csvLog,
progress_bar_refresh_rate = 20,
weights_summary = None,)
❶ 仅验证 1 批验证 DataLoader 数据。
❷ 在训练之前进行验证以确保验证数据集可用。
❸ 在每 20 次梯度下降的训练迭代(步骤)之后进行验证。
limit_val_batches 的作用类似于 limit_train_batches,指定用于验证的验证数据集中的批次数量。Trainer 中的 num_sanity_val_steps 参数控制了 PyTorch Lightning 的一个特性,该特性使用验证数据集来确保模型以及验证 DataLoader 被正确实例化并准备好进行训练。在本示例中,将 num_sanity_val_steps 的值设置为 1 执行单个验证步骤并报告相应的指标。val_check_interval 参数指定,在每 20 次训练迭代之后,PyTorch Lightning 应使用 limit_val_batches 参数指定的批次数进行验证。使用 val_check_interval 的 min 函数确保如果 max_batches 的超参数设置为小于 20,则在训练结束时执行验证。
列表 10.4 PyTorch Lightning DC 出租车线性回归模型
import torch as pt
import pytorch_lightning as pl
from torch.utils.data import DataLoader
from kaen.torch import ObjectStorageDataset as osds
pt.set_default_dtype(pt.float64)
class DcTaxiModel(pl.LightningModule):
def __init__(self, **kwargs):
super().__init__()
self.save_hyperparameters()
pt.manual_seed(int(self.hparams.seed))
self.layers = pt.nn.Linear(int(self.hparams.num_features), 1)
def batchToXy(self, batch):
batch = batch.squeeze_()
X, y = batch[:, 1:], batch[:, 0]
return X, y
def forward(self, X):
y_est = self.layers(X)
return y_est.squeeze_()
def training_step(self, batch, batch_idx):
X, y = self.batchToXy(batch)
y_est = self.forward(X)
loss = pt.nn.functional.mse_loss(y_est, y)
for k,v in {
"train_mse": loss.item(),
"train_rmse": loss.sqrt().item(),
}.items():
self.log(k, v, on_step=True, on_epoch=True,
prog_bar=True, logger=True)
return loss
def validation_step(self, batch, batch_idx):
X, y = self.batchToXy(batch)
with pt.no_grad():
loss = pt.nn.functional.mse_loss(self.forward(X), y)
for k,v in {
"val_mse": loss.item(),
"val_rmse": loss.sqrt().item(),
}.items():
self.log(k, v, on_step=True, on_epoch=True,
prog_bar=True, logger=True)
return loss
def test_step(self, batch, batch_idx):
X, y = self.batchToXy(batch)
with pt.no_grad():
loss = pt.nn.functional.mse_loss(self.forward(X), y)
for k,v in {
"test_mse": loss.item(),
"test_rmse": loss.sqrt().item(),
}.items():
self.log(k, v, on_step=True, on_epoch=True,
prog_bar=True, logger=True)
def configure_optimizers(self):
optimizers = {'Adam': pt.optim.AdamW,
'SGD': pt.optim.SGD}
optimizer = optimizers[self.hparams.optimizer]
return optimizer(self.layers.parameters(),
lr = float(self.hparams.lr))
def build(model, train_glob, val_glob, test_glob):
csvLog = CSVLogger(save_dir = "logs",
name = "dctaxi",
version = f"seed_{model.hparams.seed}")
trainer = pl.Trainer(gpus = pt.cuda.device_count() \
if pt.cuda.is_available() else 0,
max_epochs = 1,
limit_train_batches = int( model.hparams.max_batches ) \
if 'max_batches' in model.hparams else 1,
limit_val_batches = 1,
num_sanity_val_steps = 1,
val_check_interval = min(20, int( model.hparams.max_batches ) ),
limit_test_batches = 1,
log_every_n_steps = 1,
logger = csvLog,
progress_bar_refresh_rate = 20,
weights_summary = None,)
train_dl = \
DataLoader(osds(train_glob,
batch_size = int(model.hparams.batch_size) ),
pin_memory = True)
val_dl = \
DataLoader(osds(val_glob,
batch_size = int(model.hparams.batch_size) ),
pin_memory = True)
trainer.fit(model,
train_dataloaders = train_dl,
val_dataloaders = val_dl)
test_dl = \
DataLoader(osds(test_glob,
batch_size = int(model.hparams.batch_size) ),
pin_memory = True)
trainer.test(model,
dataloaders=test_dl)
return model, trainer
您可以通过运行来训练、验证和测试整个模型
model, trainer = build(DcTaxiModel(**{
"seed": "1686523060",
"num_features": "8",
"optimizer": "Adam",
"lr": "0.03",
"max_batches": "100",
"batch_size": "100",}),
train_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥ master/train.csv',
val_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥ master/valid.csv',
test_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥ master/train.csv').
如果将来自 logs/dctaxi/version_1686523060 文件夹的生成日志加载为 pandas DataFrame,并使用以下代码绘制结果
import pandas as pd
metrics_df = \
pd.read_csv(f'logs/dctaxi/seed_{model.hparams.seed}/metrics.csv')
ax = (metrics_df[['step', 'train_rmse_step']][20:]
.dropna()
.plot('step', 'train_rmse_step'))
ax = (metrics_df[['step', 'val_rmse_step']][20:]
.fillna(method='ffill')['val_rmse_step']
.plot(ax = ax))
您应该观察到类似于图 10.3 的图形。由于 val_check_interval 参数设置为 20,因此数据帧中 val_rmse_step 列的大多数值都缺失。fillna(method=‘ffill’) 调用向前填充缺失值,例如,根据步骤 80 的验证 RMSE 设置步骤 81、82 等的缺失值。
图 10.3 尽管测试性能合理,但验证 RMSE 信号表明过拟合。
如图 10.3 所示,对验证数据集的平淡性能表明模型可能在训练数据集上过拟合。在将代码投入生产之前,应重构模型实现以使其更具一般性,并且不那么依赖于训练数据集的记忆。这意味着为了前进,您需要具有更全面的实验和超参数优化支持的模型开发方法。
总结
-
采用 PyTorch Lightning 框架可以帮助您重构机器学习实现,减少附带的样板代码比例,并专注于模型特定开发。
-
在基于 PyTorch Lightning 的机器学习模型实现中,您可以逐步添加对模型训练、验证和测试的支持,以及可插拔的特性,例如用于分析的日志框架。
-
PyTorch Lightning 中的 CSVLogger 将模型训练、验证和测试结果保存到 CSV 文件中,您可以使用 pandas 进行分析。