大规模 MLOps 工程(二)

原文:zh.annas-archive.org/md5/5ca914896ff49b8bc0c3f25ca845e22b

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:更多的探索性数据分析和数据准备

本章涵盖内容

  • 分析华盛顿特区出租车数据集的摘要统计信息

  • 评估用于机器学习的替代数据集大小

  • 使用统计量选择合适的机器学习数据集大小

  • 在 PySpark 作业中实现数据集抽样

在上一章中,您开始分析了华盛顿特区出租车费用数据集。在将数据集转换为适合分析的 Apache Parquet 格式后,您检查了数据架构,并使用 Athena 交互式查询服务来探索数据。数据探索的初步步骤揭示了许多数据质量问题,促使您建立严谨的方法来解决机器学习项目中的垃圾进、垃圾出问题。接下来,您了解了用于数据质量的 VACUUM 原则,并通过几个案例研究说明了这些原则的现实相关性。最后,您对华盛顿特区出租车数据集应用了 VACUUM 进行了“清洁”,准备了一个足够质量的数据集,以便从中进行机器学习的抽样。

本章继续使用经过 VACUUM 处理的数据集进行更深入的数据探索。在本章中,您将分析数据集的摘要统计信息(算术平均值、标准差等等),以便更明智地确定用于机器学习的训练、验证和测试数据集的大小。您将比较常见的数据集大小选择方法(例如,使用 70/15/15% 的划分)和根据数据集统计信息选择合适大小的方法。您将了解如何使用统计量,如均值标准误、Z 分数和 P 值,来帮助评估替代数据集大小,并学习如何使用 PySpark 实现基于数据的实验来选择合适的大小。

4.1 数据采样入门

本节向您介绍了一种更严谨、基于数据驱动且可重复使用的方法,用于选择适合您数据集的正确训练、验证和测试数据集分割大小。利用华盛顿特区出租车数据的示例,您将探索选择正确数据集大小所需的关键统计量,然后使用一个可以重复使用于其他数据集的数据集大小选择方法来实现一个 PySpark 作业。

我经常听到初级机器学习从业者提出的一个最常见的问题是关于训练、验证和测试数据集的数据集大小的选择。这应该不足为奇,因为在线课程、博客和机器学习教程经常使用像 70/15/15% 这样的数字,意味着项目数据集的 70% 应该分配给训练,15% 分配给验证,15% 分配给留出测试数据。一些课程主张使用 80/10/10% 的分割或 98/1/1% 的“大数据”数据集。著名的 Netflix Prize 使用了大约 97.3/1.35/1.35% 的分割来处理大约 1 亿条记录的数据集,但体积不到 1 GB,它应该被视为“大数据”吗?

4.1.1 探索清理后数据集的汇总统计

在本节中,您将将清理后的数据集元数据加载为 pandas DataFrame 并探索数据集的汇总统计(包括计数、算术平均数、标准差等)。

在第三章结束时,除了清理后的数据集之外,dctaxi_parquet_vacuum.py PySpark 作业使用 save_stats_metadata 函数保存了一些带有数据集统计描述的元数据信息,包括每列值的总行数、均值、标准差、最小值和最大值。要将此信息读入名为 df 的 pandas DataFrame 中,请执行以下代码:

!pip install fsspec s3fs        ❶

import s3fs
import pandas as pd

df = pd.read_csv(f"s3://dc-taxi-{os.environ['BUCKET_ID']}-{os.environ['AWS_DEFAULT_REGION']}/parquet/
➥ vacuum/.meta/stats/*")print(df.info())

❶ 安装 pandas 读取 S3 所需的 Python 包。

❷ 将元数据读入 pandas DataFrame。

该代码在您的环境中安装了 s3fs 库,以使用 pandas read_csv API 访问来自 S3 的数据。代码的其余部分列出了 S3 存储桶的 parquet/vacuum/.meta/stats/* 子文件夹中的对象,并从该文件夹中的 CSV 文件读取内容到 pandas DataFrame 中。

数据帧的 info 方法的输出报告存储的数据的模式以及数据消耗的内存量。

清单 4.1 dctaxi_parquet_vacuum.py 元数据的 df.info() 输出

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 5 entries, 0 to 4
Data columns (total 10 columns):
 #   Column                              Non-Null Count  Dtype
---  ------                              --------------  -----
 0   summary                             5 non-null      object
 1   fareamount_double                   5 non-null      float64
 2   origin_block_latitude_double        5 non-null      float64
 3   origin_block_longitude_double       5 non-null      float64
 4   destination_block_latitude_double   5 non-null      float64
 5   destination_block_longitude_double  5 non-null      float64
 6   year_integer                        5 non-null      float64
 7   month_integer                       5 non-null      float64
 8   dow_integer                         5 non-null      float64
 9   hour_integer                        5 non-null      float64
dtypes: float64(9), object(1)
memory usage: 528.0+ bytes
None

请注意,清单 4.1 中的模式与第二章中的 SQL 查询使用的模式保持一致,只有一些小的变化:数据帧使用 float64 而不是 DOUBLE,并且使用 object 代替 STRING。此外,DC 出租车数据集中没有 summary 列。summary 列是通过第三章的 dctaxi_parquet_vacuum.py PySpark 作业的 describe 方法创建的,并用于存储每行在元数据表中的统计函数的名称,如平均值和计数。

要开始,您可以使用 summary 列索引数据帧并查看结果

summary_df = df.set_index('summary')
summary_df

这将产生

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

让我们将数据集的大小(即每列的值的数量)保存到一个单独的变量 ds_size 中,稍后将使用它:

ds_size = summary_df.loc['count'].astype(int).max()
print(ds_size)

执行后,这将打印 14262196。

用于获取数据集大小的代码依赖于 max 方法,在数据集的所有列中找到最大值。对于经过清理的 DC 出租车数据集,所有列都返回相同的计数,因为它们都不包含 NULL、None 或 NaN 值。尽管对于 DC 出租车数据集来说 max 是不必要的,但继续使用该函数来正确计算存储数据所需的最大行数是一个好的做法。

由于接下来的章节将重点涉及从数据中采样,所以创建两个单独的序列来收集数据集的均值(mu)

mu = summary_df.loc['mean']
print(mu)

这将输出

fareamount_double                        9.74
origin_block_latitude_double            38.90
origin_block_longitude_double          -77.03
destination_block_latitude_double       38.91
destination_block_longitude_double     -77.03
year_integer                         2,016.62
month_integer                            6.57
dow_integer                              3.99
hour_integer                            14.00
Name: mean, dtype: float64

和标准差(sigma)统计数据

sigma = summary_df.loc['stddev']
print(sigma)

打印如下所示:

fareamount_double                     4.539085
origin_block_latitude_double          0.014978
origin_block_longitude_double         0.019229
destination_block_latitude_double     0.017263
destination_block_longitude_double    0.022372
year_integer                          1.280343
month_integer                         3.454275
dow_integer                           2.005323
hour_integer                          6.145545
Name: stddev, dtype: float64

4.1.2 为测试数据集选择合适的样本大小

在本节中,您将探索使用机器学习“经验法则”选择数据集大小的有效性,并决定 DC 出租车数据集的合适大小。尽管本节以 DC 出租车数据集为例,但您将学习一种在使用实际数据集时选择正确大小的方法。

现在您已经了解了清理数据集中数值列的平均值,您准备好回答将数据集中多少记录分配给机器学习模型训练,以及有多少记录保留给测试和验证数据集的问题了。在准备训练、验证和测试数据集时,许多机器学习从业者依赖于经验法则或启发式方法来确定各个数据集的大小。有些人主张使用 80/10/10%的训练、验证和测试划分,而其他人则声称当数据集很大时,划分应为 98/1/1%,而不指定“大”是什么意思。

在处理分配给训练、验证和测试数据集的记录数量时,回顾它们的基本原理是有价值的。选择训练数据集和测试数据集的合适百分比之间存在困难的原因是它们本质上是相互对立的。一方面,用于机器学习模型训练的数据集的百分比应尽可能大。另一方面,用于测试的数据集的百分比应足够大,以便训练后的机器学习模型在测试数据集上的性能是对该模型在未知样本中的预期表现的有意义的估计。

测试和验证数据集

本书中描述的测试数据集将不会用于检查模型是否过拟合。虽然一些机器学习文献使用测试数据集来确保模型的泛化,但本书将使用一个单独的验证数据集来实现此目的。本书使用的方法如下图所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

本章中将项目的清理数据集分为开发集和测试集。接下来的章节将涵盖将开发数据集进一步分为训练集和验证数据集。

您可以使用统计学的一些基本结果来帮助您选择大小。思路是确保测试数据集足够大,以便在统计上与整个数据集相似。

首先,考虑用于测试数据的数据集的上限和下限。对于上限,在训练时使用 70%,您可以分配 15%进行验证和测试。在下限方面,您可以考虑将仅 1%用于测试和验证。为更好地说明下限的概念,让我们考虑分配 0.5%的数据进行测试的更极端情况。

您可以使用以下内容获取各种百分比(分数)的记录数:

fractions = [.3, .15, .1, .01, .005]
print([ds_size * fraction for fraction in fractions])

它的返回值为

[4278658.8, 2139329.4, 1426219.6, 142621.96, 71310.98]

在处理样本大小时,将其转化为 2 的幂次方会很有帮助。这是有帮助的原因有几个。当从样本计算统计学(例如,样本均值的标准误差)时,您会发现需要指数级地改变数据集的大小才能实现统计量的线性变化。此外,在统计公式中,取样本大小的平方根很常见,而从 2 的幂次方开始会简化计算。

要找出数据集分数的二次幂估计值,可以使用以下代码:

from math import log, floor
ranges = [floor(log(ds_size * fraction, 2)) for fraction in fractions]
print(ranges)

请注意,该代码以 30%到 0.5%的近似数据集分数的实际记录数的基 2 对数为基础。由于对数值可以是非整数值,因此 floor 函数返回以 2 的幂次方存储近似数据集分数的数据集大小。

因此,代码的输出为

[22, 21, 20, 17, 16]

对应于从 2²² = 4,194,304 到 2¹⁶ = 65,536 的范围。

尽管此范围内的数据集可以轻松适应现代笔记本电脑的内存,但让我们尝试进行实验,以确定可以对数据集进行抽样并仍然用于报告机器学习模型准确性能指标的最小数据集。实验的有价值之处不在于发现,而在于说明寻找正确样本大小的过程。该过程有价值,因为即使在更大的数据集中也可以重复。

在这个实验中,让我们继续使用上部范围作为最大样本大小,2²² = 4,194,304,但从范围较小的 2¹⁵ = 32,768 开始:

sample_size_upper, sample_size_lower = max(ranges) + 1, min(ranges) - 1
print(sample_size_upper, sample_size_lower)

代码返回的最大和最小值如下:

(23, 15)

给定范围,您可以通过运行以下内容来计算其近似数据集分数的程度:

sizes = [2 ** i for i in range(sample_size_lower, sample_size_upper)]
original_sizes = sizes
fracs = [ size / ds_size for size in sizes]
print(*[(idx, sample_size_lower + idx, frac, size) \
  for idx, (frac, size) in enumerate(zip(fracs, sizes))], sep='\n')

它的结果为

(0, 15, 0.0022975423980991427, 32768)
(1, 16, 0.004595084796198285, 65536)
(2, 17, 0.00919016959239657, 131072)
(3, 18, 0.01838033918479314, 262144)
(4, 19, 0.03676067836958628, 524288)
(5, 20, 0.07352135673917257, 1048576)
(6, 21, 0.14704271347834513, 2097152)
(7, 22, 0.29408542695669027, 4194304)

它显示 2¹⁵的测试数据集大小仅覆盖约 0.23%的数据集,而测试数据大小为 2²²则覆盖约 29.4%。

4.1.3 探索替代样本大小的统计信息

本节描述了如何使用均值的标准误差统计量以及收益递减(边际)来生成候选大小(以记录数表示)的测试数据集。在下面的清单中,清单 4.2 中的 sem_over_range 函数计算了一个 pandas DataFrame,该 DataFrame 指定了数据集中每一列和每个样本大小从 sample_size_lower 到 sample_size_upper 的标准误差(SEM)。在本例中,范围对应于从 32,768 到 4,194,304 的值。

对每个候选样本大小的每列进行 SEM(标准误差)。

import numpy as np
def sem_over_range(lower, upper, mu, sigma):    ❶
  sizes_series = pd.Series([2 ** i \            ❷
    for i in range(lower, upper + 1)])
  est_sem_df = \                                ❸
    pd.DataFrame( np.outer( (1 / np.sqrt(sizes_series)), sigma.values ),
                        columns = sigma.index,
                        index = sizes_series.values)
  return est_sem_df

sem_df = sem_over_range(sample_size_lower, sample_size_upper, mu, sigma)
sem_df

❶ sem_over_range 函数使用样本范围以及数据集的 mu 和 sigma。

❷ 将样本范围转换为 pandas Series。

❸ 通过计算每个样本大小和列σ的平均值标准误差来创建一个 pandas DataFrame。

清单 4.2 中的 sem_over_range 函数计算了一个 pandas DataFrame,该 DataFrame 指定了数据集中每一列和每个样本大小从 sample_size_lower 到 sample_size_upper 的标准误差(SEM)。在本例中,范围对应于从 32,768 到 4,194,304 的值。

请记住,对于数据集中的任何一列,给定其总体标准差(σ)和列中的记录数(观测值)(n),SEM 定义为外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

由于清单 4.2 中的 sem_df DataFrame 返回的原始 SEM 值不易解释,因此绘制图形以说明随着样本大小增长 SEM 的总体变化趋势是有价值的。您可以使用 matplotlib 库显示此趋势,绘制 sem_df 数据框中各列的平均 SEM 值,如下所示

import matplotlib.pyplot as plt
%matplotlib inline

plt.figure(figsize = (12, 9))
plt.plot(sem_df.index, sem_df.mean(axis = 1))
plt.xticks(sem_df.index,
           labels = list(map(lambda i: f"2^{i}",
                              np.log2(sem_df.index.values).astype(int))),
           rotation = 90);

这导致图 4.1。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.1 样本大小呈指数增长是昂贵的:更大的样本需要指数级的内存、磁盘空间和计算量,同时在标准误差减小方面产生的改进较少。

图 4.1 中的绘图使用二的幂次方作为水平轴上的注释,描述数据框中的样本大小。请注意,该图捕获了样本大小增加时的收益递减趋势。尽管样本大小呈指数增长,但平均 SEM 的斜率(给定样本大小的 SEM 的瞬时变化率)随着每倍增长而趋于平缓。

由于将尽可能多的数据分配给训练数据集是有价值的,您可以利用收益递减启发式方法发现测试数据集的下限大小。思路是找到一个样本大小,以便如果它更大,那么 SEM 的改善将产生收益递减。

要确定样本大小加倍的边际收益点(也称为 边际 ),您可以从每次样本大小增加时 SEM 的总减少开始。这是使用以下代码片段中的 sem_df.cumsum() 计算的。然后,为了获得每个样本大小的单个聚合度量,mean(axis = 1) 计算数据集中列之间 SEM 总减少的平均值:

agg_change = sem_df.cumsum().mean(axis = 1)
agg_change

生成

32768     0.01
65536     0.02
131072    0.02
262144    0.03
524288    0.03
1048576   0.03
2097152   0.03
4194304   0.03
8388608   0.04
dtype: float64

agg_change pandas 系列的值在图 4.2 中被绘制出来。请注意,箭头突出显示的样本大小对应于 220 的样本大小,也是由于增加样本大小而导致 SEM 减少开始产生边际收益的点。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.2 边际样本大小对应于边际收益点之前的最大样本大小。

此时,边际可以使用以下边际函数在 Python 中计算:

import numpy as np

def marginal(x):
  coor = np.vstack([x.index.values,
            x.values]).transpose()return pd.Series(index = x.index,         ❷
    data = np.cross(coor[-1] - coor[0], coor[-1] - coor) \
             / np.linalg.norm(coor[-1] - coor[0])).idxmin()

SAMPLE_SIZE = marginal(agg_change).astype(int)
SAMPLE_SIZE, SAMPLE_SIZE / ds_size

❶ 创建一个 NumPy 数组,其中数据点在 x 轴上的样本大小,SEM 值在 y 轴上。

❷ 计算数据点到连接最大和最小样本大小数据点的虚拟线的距离。

在这里,通过查看样本大小的数据点与 SEM 累积减少之间的关系,绘制连接最小和最大样本大小的虚线(图 4.2 中的虚线)并识别与虚拟线右角最远距离的数据点来计算边际。

当应用于 DC 出租车数据集时,边际函数计算如下内容:

(1048576, 0.07352135673917257)

在这里,通过边际收益启发法选择的边际测试样本大小对应于 1,048,576 条记录,或者大约是数据集的 7%。

如果可能的话,使用任意 1,048,576 条记录的样本作为测试数据集将有助于最大化可用于机器学习模型训练的数据量。然而,SEM 测量旨在确定样本大小的 下限 ,并不表示这种大小的任意数据集都适合用作测试数据集。

您可以使用 1,048,576 条记录的 p 值来建立对样本的置信度,从而回答统计假设检验的基本问题:样本来自总体的确信度是多少?

4.1.4 使用 PySpark 作业对测试集进行抽样

在本节中,您将通过使用 PySpark 作业随机采样 1,048,576 条记录(在上一节中确定的大小)来创建测试数据集进行实验。一旦采样了测试集,剩余的记录将持久保存到一个单独的 DC 出租车开发数据集中。开发和测试数据集还被分析以计算 p 值以及其他摘要统计信息。

由于整个 PySpark 作业的实现大约有 90 行代码,在本节中,作业被介绍为一系列代码片段。作业的前文部分,在列表 4.3 中显示的,类似于第 2 和第三章中的 PySpark 作业。与早期章节一样,作业的这一部分导入相关库并解析作业参数。

在 dctaxi_dev_test.py 中的第 4.3 节代码中读取的 PySpark DataFrame。

import sys
from awsglue.transforms import *
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from awsglue.context import GlueContext
from awsglue.job import Job

args = getResolvedOptions(sys.argv, ['JOB_NAME',
                                     'BUCKET_SRC_PATH',
                                     'BUCKET_DST_PATH',
                                     'SAMPLE_SIZE',
                                     'SAMPLE_COUNT',
                                     'SEED'
                                     ])

sc = SparkContext()
glueContext = GlueContext(sc)
logger = glueContext.get_logger()
spark = glueContext.spark_session

job = Job(glueContext)
job.init(args['JOB_NAME'], args)

BUCKET_SRC_PATH = args['BUCKET_SRC_PATH']
df = ( spark.read.format("parquet")
        .load( f"{BUCKET_SRC_PATH}" ))

❶ 根据 BUCKET_SRC_PATH 参数构建一个 pandas DataFrame df。

与从清理后的 DC 出租车数据集中抽样有关的实现始于列表 4.4,其中计算整个数据集大小的样本分数,并将其保存到变量 sample_frac 中。为了在 PySpark 中计算清理后数据集的摘要统计信息,实现依赖于 Kaen 库的 PySpark 实用函数 spark_df_to_stats_pandas_df,该函数从名为 df 的 PySpark DataFrame 实例返回 pandas DataFrame。然后,pandas summary_df 提供了对清理后数据集中每列的平均值(mu)和标准差(sigma)的标准 pandas DataFrame API 访问。

在 dctaxi_dev_test.py 中的第 4.4 节代码中读取的 PySpark DataFrame。

SAMPLE_SIZE = float( args['SAMPLE_SIZE'] )   
dataset_size = float( df.count() )
sample_frac = SAMPLE_SIZE / dataset_size            ❶

from kaen.spark import spark_df_to_stats_pandas_df, \
                      pandas_df_to_spark_df, \
                      spark_df_to_shards_df         ❷

summary_df = spark_df_to_stats_pandas_df(df)        ❸
mu = summary_df.loc['mean']                         ❹
sigma = summary_df.loc['stddev']

❶ 根据 Spark 的 randomSplit 方法所需的样本大小,以分数的形式表示。

❷ 从 kaen 包中导入 Spark 和 pandas 实用工具。

❸ 创建包含 Spark DataFrame 统计信息的 pandas DataFrame。

❹ 将数据集的平均值保存为 mu。

❺ 将数据集的标准差保存为 sigma。

汇总统计信息以及 sample_frac 值在列表 4.5 中用于执行随机抽样。PySpark 的 randomSplit 方法将经过清理的 DC 出租车数据集分割为 test_df,其中包含最多 SAMPLE_SIZE 行,并且总计来自 df 数据帧的 sample_frac 的整个数据集。

在 dctaxi_dev_test.py 中的第 4.5 节代码中读取的 PySpark DataFrame。

SEED = int(args['SEED'])                                ❶
SAMPLE_COUNT = int(args['SAMPLE_COUNT'])                ❷
BUCKET_DST_PATH = args['BUCKET_DST_PATH']

for idx in range(SAMPLE_COUNT):
  dev_df, test_df = ( df                                ❸
                      .cache()
                      .randomSplit([1.0 - sample_frac,
                                      sample_frac],     ❹
                                    seed = SEED) )

  test_df = test_df.limit( int(SAMPLE_SIZE) )           ❺

  test_stats_df = \                                     ❻
    spark_df_to_stats_pandas_df(test_df, summary_df,
                                  pvalues = True, zscores = True)

  pvalues_series = test_stats_df.loc['pvalues']
  if pvalues_series.min() < 0.05:
    SEED = SEED + idx                                   ❼
  else:
    break

❶ 使用 SEED 初始化伪随机数生成器。

❷ 通过使用最多 SAMPLE_COUNT 个样本,解决了选择不佳(p 值 < 0.05)的 SEED 值的问题。

❸ 将测试数据集抽样到 Spark 的 test_df DataFrame 中,其余抽样到 dev_df。

❹ 使用 df 中记录的 sample_frac 分数作为测试数据集。

❺ 确保 test_df 最多仅包含 SAMPLE_SIZE 条记录。

❻ 创建一个包含 test_df 摘要统计信息的 pandas test_stats_df DataFrame。

❼ 在出现不良样本(p 值 < 0.05)的情况下再次抽样,最多抽样 SAMPLE_COUNT 次。

列表 4.6 中显示的作业实现部分负责将开发(dev_df)和测试(test_df)数据集保存到 S3。对于每个数据集,Spark 将记录保存为 CSV 格式,带有标头信息,保存到 BUCKET_DST_PATH 中。此外,对于开发和测试,该实现还将其他元数据(稍后在本节中显示)保存到 BUCKET_DST_PATH 的子文件夹中:.meta/stats 和 .meta/shards。

stats 子文件夹存储一个包含摘要统计信息的 CSV 文件,包括计数、均值、p 值等。 shards 子文件夹被存储以便在训练期间处理数据集,并存储关于用于将数据集保存在 S3 中的 CSV 部分文件数和每个部分文件中的记录数的元数据。

列表 4.6 dctaxi_dev_test.py 中的 PySpark DataFrame 读取代码

for df, desc in [(dev_df, "dev"), (test_df, "test")]:
    ( df
    .write
    .option('header', 'true')
    .mode('overwrite')
    .csv(f"{BUCKET_DST_PATH}/{desc}") )

    stats_pandas_df = \
    spark_df_to_stats_pandas_df(df,
                                summary_df,
                                pvalues = True,
                                zscores = True)
    ( pandas_df_to_spark_df(spark,  stats_pandas_df)
    .coalesce(1)
    .write
    .option('header', 'true')
    .mode('overwrite')
    .csv(f"{BUCKET_DST_PATH}/{desc}/.meta/stats") )

    ( spark_df_to_shards_df(spark, df)
    .coalesce(1)
    .write
    .option('header', True)
    .mode('overwrite')
    .csv(f"{BUCKET_DST_PATH}/{desc}/.meta/shards") )

job.commit()

为了方便起见,下面展示了 PySpark 作业的完整实现,它应该被保存在一个名为 dctaxi_dev_test.py 的文件中。

列表 4.7 PySpark dctaxi_dev_test.py 作业以抽样开发和测试数据集

import sys
from awsglue.transforms import *
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from awsglue.context import GlueContext
from awsglue.job import Job

args = getResolvedOptions(sys.argv, ['JOB_NAME',
                                     'BUCKET_SRC_PATH',
                                     'BUCKET_DST_PATH',
                                     'SAMPLE_SIZE',
                                     'SAMPLE_COUNT',
                                     'SEED'
                                     ])

sc = SparkContext()
glueContext = GlueContext(sc)
logger = glueContext.get_logger()
spark = glueContext.spark_session

job = Job(glueContext)
job.init(args['JOB_NAME'], args)

BUCKET_SRC_PATH = args['BUCKET_SRC_PATH']
df = ( spark.read.format("parquet")
        .load( f"{BUCKET_SRC_PATH}" ))

SAMPLE_SIZE = float( args['SAMPLE_SIZE'] )
dataset_size = float( df.count() )
sample_frac = SAMPLE_SIZE / dataset_size

from kaen.spark import spark_df_to_stats_pandas_df, \
                      pandas_df_to_spark_df, \
                      spark_df_to_shards_df

summary_df = spark_df_to_stats_pandas_df(df)
mu = summary_df.loc['mean']
sigma = summary_df.loc['stddev']

SEED = int(args['SEED'])
SAMPLE_COUNT = int(args['SAMPLE_COUNT'])
BUCKET_DST_PATH = args['BUCKET_DST_PATH']

for idx in range(SAMPLE_COUNT):
  dev_df, test_df = ( df
                      .cache()
                      .randomSplit( [1.0 - sample_frac, sample_frac],
                                    seed = SEED) )
  test_df = test_df.limit( int(SAMPLE_SIZE) )

  test_stats_df = \
    spark_df_to_stats_pandas_df(test_df, summary_df,
                                  pvalues = True, zscores = True)

  pvalues_series = test_stats_df.loc['pvalues']
  if pvalues_series.min() < 0.05:
    SEED = SEED + idx
  else:
    break

for df, desc in [(dev_df, "dev"), (test_df, "test")]:
    ( df
    .write
    .option('header', 'true')
    .mode('overwrite')
    .csv(f"{BUCKET_DST_PATH}/{desc}") )

    stats_pandas_df = \
    spark_df_to_stats_pandas_df(df,
                                summary_df,
                                pvalues = True,
                                zscores = True)

    ( pandas_df_to_spark_df(spark,  stats_pandas_df)
    .coalesce(1)
    .write
    .option('header', 'true')
    .mode('overwrite')
    .csv(f"{BUCKET_DST_PATH}/{desc}/.meta/stats") )

    ( spark_df_to_shards_df(spark, df)
    .coalesce(1)
    .write
    .option('header', True)
    .mode('overwrite')
    .csv(f"{BUCKET_DST_PATH}/{desc}/.meta/shards") )

job.commit()

在 dctaxi_dev_test.py 文件中执行 PySpark 作业之前,你需要配置几个环境变量。应使用相应 Python 变量的值设置 SAMPLE_SIZE 和 SAMPLE_COUNT 操作系统环境变量:

os.environ['SAMPLE_SIZE'] = str(SAMPLE_SIZE)
os.environ['SAMPLE_COUNT'] = str(1)

与上一章节类似,PySpark 作业使用 utils.sh 脚本中的便捷函数执行。首先,在你的 bash shell 中使用以下命令下载该脚本到你的本地环境:

wget -q --no-cache https://raw.githubusercontent.com/
➥ osipov/smlbook/master/utils.sh

一旦 utils.sh 脚本被下载,你可以使用它来启动和监视 dctaxi_dev_test.py 文件中实现的 PySpark 作业。在你的 shell 环境中运行以下命令来启动该作业:

source utils.sh

PYSPARK_SRC_NAME=dctaxi_dev_test.py \
PYSPARK_JOB_NAME=dc-taxi-dev-test-job \
ADDITIONAL_PYTHON_MODULES="kaen[spark]" \
BUCKET_SRC_PATH=s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/parquet/vacuum \
BUCKET_DST_PATH=s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/csv \
SAMPLE_SIZE=$SAMPLE_SIZE \
SAMPLE_COUNT=$SAMPLE_COUNT \
SEED=30 \
run_job

注意,该作业将要读取第三章保存在 parquet/vacuum 子文件夹中的 Parquet 文件,并将开发和测试数据集保存在你的 S3 存储桶的 csv/dev 和 csv/test 子文件夹下。该作业在 AWS Glue 上应该需要大约八分钟才能完成。假设它成功完成,它应该会产生以下类似的输出:

Attempting to run a job using:
  PYSPARK_SRC_NAME=dctaxi_dev_test.py
  PYSPARK_JOB_NAME=dc-taxi-dev-test-job
  AWS_DEFAULT_REGION=us-west-2
  BUCKET_ID=c6e91f06095c3d7c61bcc0af33d68382
  BUCKET_SRC_PATH=s3://dc-taxi-c6e91f06095c3d7c61bcc0af33d68382-
➥   us-west-2/parquet/vacuum
  BUCKET_DST_PATH=s3://dc-taxi-c6e91f06095c3d7c61bcc0af33d68382-
➥   us-west-2/csv
  SAMPLE_SIZE=1048576
  SAMPLE_COUNT=1
  BINS=
  SEED=30
upload: ./dctaxi_dev_test.py to s3://dc-taxi-
➥   c6e91f06095c3d7c61bcc0af33d68382-us-west-2/glue/dctaxi_dev_test.py
2021-08-15 17:19:37       2456 dctaxi_dev_test.py
{
    "JobName": "dc-taxi-dev-test-job"
}
{
    "Name": "dc-taxi-dev-test-job"
}
{
    "JobRunId": [CA
    "jr_05e395544e86b1534c824fa1559ac395683f3e7db35d1bb5d591590d237954f2"
}
Waiting for the job to finish......................................SUCCEEDED

由于 PySpark 作业保留了关于数据集的元数据,你可以使用 pandas 预览元数据的内容。为了预览测试集的统计摘要,请执行以下 Python 代码:

pd.options.display.float_format = '{:,.2f}'.format

test_stats_df = pd.read_csv(f"s3://dc-taxi-{os.environ['BUCKET_ID']}-{os.environ['AWS_DEFAULT_REGION']}/csv/test/.meta/stats/*.csv")

test_stats_df = test_stats_df.set_index('summary')
test_stats_df

假设 PySpark 作业执行正确,对于测试数据集的 test_stats_df 的打印输出应该类似于以下内容:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

关于开发数据集的 CSV 部分文件(shards)的元数据应该已保存到你的 S3 存储桶的 csv/dev/.meta/shards 子文件夹中。如果你使用以下代码预览此元数据中的 pandas DataFrame

import pandas as pd
dev_shards_df = pd.read_csv(f"s3://dc-taxi-{os.environ['BUCKET_ID']}-{os.environ['AWS_DEFAULT_REGION']}/csv/dev/.meta/shards/*")

dev_shards_df.sort_values(by = 'id')

输出应该包含一个三列表,其中 id 列存储来自 S3 中 csv/dev 子文件夹的 CSV 部分文件的 ID,而 count 列中的相应条目指定了部分文件中的行数。数据框的内容应该类似于以下内容:

idcount
390165669
31165436
562165754
533165530
634165365

7275164569
5976164729
277164315
1178164397
2279164406

摘要

  • 使用固定百分比的启发式方法来选择保留的测试数据集的大小可能会浪费宝贵的机器学习模型训练数据。

  • 测量数据集大小增加导致递减的结果,有助于选择测试和验证数据集大小的下限。

  • 确保测试数据集具有足够的 z 分数和 p 值,可以防止选择机器学习时数据集大小过小。

  • 无服务器 PySpark 作业可用于评估替代测试数据集,并报告它们的统计摘要。

第二部分:用于无服务器机器学习的 PyTorch

在开始使用 PyTorch 之前,我花了几年时间使用 TensorFlow 的 1 和 2 版本。自从我转向 PyTorch 以来,我作为一个机器学习从业者变得更加高效,并且我发现学习和使用 PyTorch 的经历令人愉快。我想与本书的读者分享这种经历。在这个过程中,我旨在帮助你掌握 PyTorch 的核心元素,指导你了解框架中可用的抽象级别,并准备好从单独使用 PyTorch 转变为使用在 PyTorch 中实现并集成到更广泛的机器学习流水线中的机器学习模型。

  • 在第五章,我涵盖了 PyTorch 的基础知识,介绍了核心张量应用程序接口(API),并帮助你掌握使用该 API 的流畅度,以降低后续章节的学习曲线。

  • 在第六章,你将专注于学习 PyTorch 的深度学习方面,包括自动微分的支持、替代梯度下降算法和支持的实用工具。

  • 在第七章,你将通过学习图形处理单元(GPU)的特性以及如何利用 GPU 加速你的机器学习代码,来扩展你的 PyTorch 程序。

  • 在第八章,你将学习有关分布式 PyTorch 训练的数据并行方法,并深入探讨传统的基于参数服务器的方法与基于环形的分布式训练(例如 Horovod)之间的区别。

第五章:PyTorch 介绍:张量基础

本章涵盖

  • 介绍 PyTorch 和 PyTorch 张量

  • 使用 PyTorch 张量创建方法

  • 理解张量操作和广播

  • 探索在 CPU 上的 PyTorch 张量性能

在上一章中,你从 DC 出租车数据集的清理版本开始,并应用了数据驱动的抽样过程,以确定要分配给一个保留的测试数据子集的数据集的正确部分。你还分析了抽样实验的结果,然后启动了一个 PySpark 作业来生成三个不同的数据子集:训练、验证和测试。

这一章将暂时偏离 DC 出租车数据集,为你准备好使用 PyTorch 编写可扩展的机器学习代码。别担心;第七章会回到 DC 出租车数据集,以基准测试基线 PyTorch 机器学习模型。在本章中,你将专注于学习 PyTorch,这是深度学习和许多其他类型的机器学习算法的顶级框架之一。我曾在需要在机器学习平台上进行分布式训练的机器学习项目中使用过 TensorFlow 2.0、Keras 和 PyTorch,并发现 PyTorch 是最好的选择。PyTorch 可从特斯拉的关键生产机器学习用例¹扩展到 OpenAI 的最新研究²。

由于你需要在开始将它们应用于 DC 出租车数据集的机器学习之前对核心 PyTorch 概念有实际的理解,所以本章重点是为你提供对核心 PyTorch 数据结构:张量的深入知识。大多数软件工程师和机器学习实践者在数学、编程或数据结构课程中都没有使用张量,所以如果这是新的,你不应感到惊讶。

在第 5.1 节中,我介绍了 PyTorch 张量的全面定义。暂时记住,如果你曾经在编程语言中使用数组的数组(即,包含其他数组的数组)实现过矩阵,那么你已经在理解张量的路上走得很远了。作为一个工作定义,你可以将张量视为一种通用数据结构,可以存储和操作变量、数组、矩阵及其组合。在本书中,你遇到的最复杂的张量实际上是矩阵的数组,或者如果你更喜欢更递归的描述,是数组的数组的数组。

5.1 开始使用张量

本节在机器学习用例的背景下定义了张量,解释了张量的属性,包括张量的维度和形状,并最终向你介绍了使用 PyTorch 创建张量的基础知识,而不是使用本地 Python 数据类型。通过本节的结论,你应该准备好研究 PyTorch 张量相对于本地 Python 数据类型在机器学习用例中的优势了。

张量一词在数学、物理或计算机科学中使用时有微妙不同的定义。虽然从数学中了解张量的几何解释或从物理学中了解张量的应力力学解释可以丰富您对张量抽象方面的理解,但本书使用一个更狭窄的定义,更符合将张量应用于机器学习的从业者。本书中,该术语描述了一种数据结构(即数据容器),用于基本数据类型,如整数、浮点数和布尔值。

由于张量与数组密切相关,因此值得花一点时间回顾数组或 Python 列表的关键属性。数组只是数据值的有序集合。在大多数编程语言中,数组索引可以取值于一组有限的整数,基于数组中元素数量减一的范围。³例如,在 Python 中,这个范围是

range(len(a_list))

其中 a_list 是 Python 列表实例的名称。因此,不同的数组具有不同的有效索引值。相反,所有由基本数据类型组成的数组,无论长度如何,其张量维度都等于一。

维度在这里定义为访问数据结构中值所需的索引总数(而不是索引的值)。这个定义很方便,因为它帮助使用一个数字描述不同的数据结构。例如,矩阵的维度为二,因为需要两个索引,行索引和列索引,才能在矩阵中定位数据值。例如,使用 Python,一个简单的矩阵实现可以使用列表的列表:

mtx = [[3 * j + i for i in range(3)] for j in range(3)]
print(mtx[1][2])

其中 mtx 的求值结果为

[[0, 1, 2],
 [3, 4, 5],
 [6, 7, 8]]

和 mtx[1][2]的值打印为 5。由于矩阵的维度为二,因此必须指定两个索引值——行索引为 1,列索引为 2——才能访问矩阵中 5 的值。

维度还指定了实现数据结构所需的数组嵌套的度量。例如,维度为 2 的 mtx 需要一个数组的数组,而维度为 3 的矩阵数组需要一个数组的数组的数组。如果考虑一个维度为 0 的数据结构,换句话说,需要零个索引来访问值的数据结构,很快就会意识到这个数据结构只是一个常规变量。对于维度(也称为张量秩)为 0、1、2 和 3 的张量的可视化,请参阅图 5.1。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.1 张量秩(维度)等于访问张量中数据值所需的索引数量。与较低秩的张量不同,机器学习中没有公认的命名。

张量是一种能够存储任意数量维度数组的数据结构,或者更简洁地说,张量是一个 n 维数组。根据此定义,一个平面 Python 列表或任何扁平化的数组都是一维张量,有时也被描述为秩 1 张量。Python 变量是零维张量,通常被描述为标量,或者更少见地被描述为秩 0 张量。一个二维张量通常被称为矩阵。对于更高维的例子,请考虑用于表示灰度图像的矩阵,其中像素值为 0 为黑色,255 为白色,中间的数字为逐渐增加亮度的灰色颜色。然后,三维张量是一种有序灰度图像集合的便捷数据结构,因此三个指数中的第一个指定图像,其余两个指定图像中像素的行和列位置。三维张量也适用于彩色图像(但不适用于彩色图像集合),因此第一个指数指定颜色为红色、绿色、蓝色或不透明度(alpha)通道,而其余指数指定相应图像中的像素位置。继续这个例子,四维张量可以用于顺序的彩色图像集合。

有了这些基础知识,您就可以准备在 PyTorch 中创建您的第一个张量了。

列表 5.1 使用 PyTorch 实现的秩 0 张量

import torch as pt        ❶
alpha = pt.tensor(42)     ❷
alpha

❶ 导入 PyTorch 库并将其别名为 pt。

❷ 创建一个值为 42 的秩 0 张量(标量)。

一旦执行了这段代码,它会输出

tensor(42)

在导入 PyTorch 库并将其别名为 pt ❶ 之后,代码的下一行 ❷ 简单地创建一个标量(秩 0 张量)并将其赋值给一个名为 alpha 的变量。在 64 位 Python 运行时执行时,从列表 5.1 中的值 42 被表示为 64 位整数,alpha 张量将使用 PyTorch 的 torch.LongTensor 类进行实例化。

对于任何 PyTorch 张量,您可以使用 type 方法来发现用于实例化张量的特定张量类:

alpha.type()

这会输出

torch.LongTensor

torch.LongTensor,以及其他用于各种基本 Python 数据类型的张量类,都是 torch.Tensor 类的子类。⁴ torch.Tensor 的子类包括对不同处理器架构(设备)的支持;例如,torch.LongTensor 是具有 CPU 特定张量实现的类,而 torch.cuda.LongTensor 是具有 GPU 特定张量实现的类。有关 PyTorch 对 GPU 的支持,将在第七章中详细描述。

在您的机器学习代码中,您应主要依赖于张量的 dtype 属性,而不是 type 方法,因为 dtype 以与设备无关的方式返回张量的类型,确保您的代码可以轻松地在不同设备之间移植。对于 alpha 的 dtype,

alpha.dtype

输出数据类型的与设备无关的描述⁵

torch.int64

要访问张量存储的值,您可以使用 item 方法

alpha.item()

在这种情况下显示 42。

要确认 alpha 张量是一个标量,您可以访问张量的 shape 属性,

alpha.shape

打印出 torch.Size([])。

PyTorch 库使用 torch.Size 类来指定张量的大小(也称为形状)的详细信息。在这里,大小由一个空的、长度为零的列表组成,因为 alpha 标量的秩为 0。一般来说,torch.Size 列表的长度等于张量的维度。例如,

len(alpha.shape)

输出 0。张量的形状指定了沿张量维度存储的元素数量。例如,从 Python 列表创建的一个一维 PyTorch 张量的前五个斐波那契数,

arr = pt.tensor([1, 1, 2, 3, 5])
arr.shape

产生了 torch.Size([5]),这证实了 arr 张量的第一个和唯一维度中有五个元素。

如果您从 Python 列表的列表中创建一个 PyTorch 矩阵(秩为 2 的张量),

mtx = pt.tensor([ [  2,   4,  16,  32,  64],
                  [  3,   9,  27,  81, 243]] )

然后 mtx.shape 返回 torch.Size([2, 5]) 的大小,因为 mtx 矩阵有两行和五列。

标准的 Python 索引和 item 方法继续按预期工作:要检索 mtx 张量左上角的值,您使用 mtx[0][0].item(),它返回 2。

在 PyTorch 中处理秩为 2 或更高的张量时,您需要了解一个重要的默认限制:尾部维度中的元素数量;换句话说,第二个及更高的维度必须保持一致,例如,如果您尝试创建一个第二个(列)维度有四个元素的矩阵,而其他列有五个元素。

用支持变量的 PyTorch 张量的 5.2 列表

pt.tensor([  [  2,   4,  16,  32,  64],
             [  3,   9,  27,  81, 243],
             [  4,  16,  64, 256]        ])

PyTorch 报告了一个错误:

ValueError: expected sequence of length 5 at dim 1 (got 4)

由于 PyTorch 使用零为基础的索引来表示维度,因此第二个维度的索引为 1,如 ValueError 所报告的那样。尽管默认的 PyTorch 张量实现不支持“不规则”张量,但 NestedTensor 包旨在为这类张量提供支持。⁶

5.2 开始使用 PyTorch 张量创建操作

之前,您已经看到您可以从一个值(例如,一个 Python 整数)创建一个 PyTorch 标量张量,并从一组值(例如,从一个 Python 列表)创建一个数组张量;但是,还有其他工厂方法可以帮助您创建张量。在本节中,您将练习使用 PyTorch API 中的工厂方法创建 PyTorch 张量。当创建用于机器学习代码中常见的数学操作的张量时,以及当张量基于非数据集值时,这些方法非常有用。

当使用工厂方法实例化张量时,除非 PyTorch 可以从方法的参数中推断出所需张量的形状(如本节稍后解释的那样),否则将显式指定所需张量的形状。例如,要使用 zeros 工厂方法创建一个两行三列的零矩阵,请使用

pt.zeros( [2, 3] )

产生

tensor([[0., 0., 0.],
        [0., 0., 0.]])

给定张量的一个实例,您可以通过使用张量的 shape 属性来确认张量具有所需的形状,

pt.zeros( [2, 3] ).shape

返回一个 torch.Size 实例,表示形状,本例中与您传递给 zeros 方法的内容匹配:

torch.Size([2, 3])

PyTorch 张量工厂方法允许您通过将一个或多个整数传递给方法来指定张量形状。例如,要创建一个包含 10 个 1 的数组,您可以使用 ones 方法,

pt.ones(10)

返回长度为 10 的数组,

tensor([1., 1., 1., 1., 1., 1., 1., 1., 1., 1.])

而 pt.ones(2, 10)返回一个 2 行 10 列的矩阵:

tensor([[1., 1., 1., 1., 1., 1., 1., 1., 1., 1.],
        [1., 1., 1., 1., 1., 1., 1., 1., 1., 1.]])

当使用工厂方法时,您可以为张量中的值指定数据类型。虽然 ones 等方法默认返回浮点数张量,但您可以使用 dtype 属性覆盖默认数据类型。例如,要创建一个整数 1 的数组,您可以调用

pt.ones(10, dtype=pt.int64)

返回

tensor([1, 1, 1, 1, 1, 1, 1, 1, 1, 1])

其他 PyTorch 支持的数据类型包括 16 位和 32 位整数、16 位、32 位和 64 位浮点数、字节(无符号 8 位整数)和布尔值。⁷

5.3 创建 PyTorch 伪随机和间隔值张量

本节向您介绍了用于创建填充有从常用概率分布中抽样的数据值的张量的 PyTorch API,包括标准正态、正态(高斯)和均匀分布。本节还描述了如何创建由间隔(等间距)值组成的张量。学习本节中描述的 API 将帮助您生成用于测试和故障排除机器学习算法的合成数据集。

深度学习和许多机器学习算法依赖于生成伪随机数的能力。在使用 PyTorch 随机抽样工厂方法之前,您需要调用 manual_seed 方法来设置用于抽样伪随机数的种子值。如果您使用与本书中使用的相同的种子值调用 manual_seed,您将能够重现本节中描述的结果。否则,您的结果看起来会不同。以下代码片段假定您使用的种子值为 42:

pt.manual_seed(42)

设置种子后,如果您使用的是 PyTorch v1.9.0,您应该期望获得与以下示例中相同的伪随机数。randn 方法从标准正态分布中抽样,因此您可以期望这些值的均值为 0,标准差为 1。要创建一个 3×3 的张量以抽样值,调用

pt.randn(3,3)

输出

tensor([[ 0.3367,  0.1288,  0.2345],
        [ 0.2303, -1.1229, -0.1863],
        [ 2.2082, -0.6380,  0.4617]])

要从均值和标准差不同于 1 和 0 的正态分布中抽样值,您可以使用 normal 方法,例如,指定均值为 100,标准差为 10,以及 3 行 3 列的秩 2 张量:

pt.normal(100, 10, [3, 3])

导致

tensor([[102.6735, 105.3490, 108.0936],
        [111.1029,  83.1020,  90.1104],
        [109.5797, 113.2214, 108.1719]])

对于从均匀分布中抽样的伪随机值的张量,您可以使用 randint 方法,例如,从 0(包括)到 10(不包括)均匀抽样,并返回一个 3×3 矩阵:

pt.randint(0, 10, [3, 3])

产生

tensor([[9, 6, 2],
        [0, 6, 2],
        [7, 9, 7]])

randint 和 normal 方法是本书中最常用的方法。PyTorch 提供了一个全面的伪随机张量生成器库,⁸但本书不涵盖所有内容。

如 5.5 节更详细地解释的那样,在创建 Python 整数值列表时会涉及显著的内存开销。相反,您可以使用 arange 方法在指定范围内创建具有间隔(等间距)值的 PyTorch 张量。PyTorch arange 的行为类似于 Python 中的 range 运算符,因此

pt.arange(10)

返回

tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

正如您在使用 Python 时所期望的那样,在 PyTorch 中调用 arange 可以带有附加参数用于范围的起始、结束和步长(有时称为步进),因此要创建一个从 1 到 11 的奇数张量,可以使用

pt.arange(1, 13, 2)

输出

tensor([ 1,  3,  5,  7,  9, 11])

就像 Python range 一样,生成的序列不包括结束序列参数值(第二个参数),而步长被指定为该方法的第三个参数。

而不是必须计算 arange 方法的步长值,使用 linspace 方法并指定结果张量中应存在的元素数量可能更加方便。例如,要创建一个包含值在从 0 开始到 10 结束并包括值 10 的 5 个元素的张量,可以使用 linspace 方法,

pt.linspace(0, 10, 5)

导致

tensor([ 0.0000,  2.5000,  5.0000,  7.5000, 10.0000])

作为实现的一部分,linspace 方法计算适当的步长大小,以便生成的张量中的所有元素之间距离相等。此外,默认情况下,linspace 创建浮点值的张量。

现在您已经熟悉了创建张量的函数,可以继续执行常见张量操作,例如加法、乘法、指数运算等。

5.4 PyTorch 张量操作和广播

本节向您介绍了 PyTorch 张量执行常见数学运算的功能,并澄清了对不同形状的张量应用操作的规则。完成本节后,您将能够在机器学习代码中将 PyTorch 张量作为数学表达式的一部分使用。

由于 PyTorch 重载了标准 Python 数学运算符,包括 +、-、*、/ 和 **,因此使用张量操作非常容易。例如,

pt.arange(10) + 1

等同于调用 pt.arange(10).add(1),两者都输出

tensor([ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10])

当添加 PyTorch 张量和兼容的基本 Python 数据类型(浮点数、整数或布尔值)时,PyTorch 会自动将后者转换为 PyTorch 标量张量(这称为类型强制转换)。因此,这些操作

pt.arange(10) + 1

pt.arange(10) + pt.tensor(1)

是等价的。

PyTorch API 的默认实现对张量执行不可变操作。因此,在开发 PyTorch 机器学习代码时,您必须记住加法操作以及其他由 PyTorch 重载的标准 Python 数学运算符都会返回一个新的张量实例。您可以轻松通过运行以下命令进行确认

a = pt.arange(10)
id(a), id(a + 1)

PyTorch 还提供了一组可就地(in-place)操作的操作符,这些操作符可更改张量的值。这意味着 PyTorch 将直接在张量设备的内存中替换张量的值,而不是为张量分配新的 PyObject 实例。例如,使用 add_ 方法

a = pt.arange(10)
id(a), id(a.add_(1))

返回一个带有两个相同对象标识符的元组。

注意 在 PyTorch API 设计中,所有的就地(in place)更改张量的操作都使用 _ 后缀,例如 mul_ 表示就地乘法,pow_ 表示就地幂运算,abs_ 表示就地绝对值函数等等。⁹

在处理机器学习代码时,您肯定会发现自己不得不在非标量张量上执行常见的数学运算。例如,如果给定两个张量 a 和 b,则 PyTorch 找到 a + b 的值是什么意思?

列出 5.3 张量按元素加和,因为它们具有相同的形状

a = pt.arange(10)
b = pt.ones(10)

正如您所期望的那样,因为 a 的值是

tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

b 的值是

tensor([1., 1., 1., 1., 1., 1., 1., 1., 1., 1.])

他们的和等于

tensor([ 1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10.])

因此,a + b 相当于将张量 a 的每个元素递增 1。这个操作可以被描述为张量 a 和 b 的逐元素相加,因为对于张量 a 的每个元素,PyTorch 找到仅一个对应的张量 b 的元素索引值,并将它们相加产生输出。

如果尝试添加

a = pt.ones([2, 5])
b = pt.ones(5)

在 tensor a 的逻辑中,元素按位相加没有立即意义,这意味着应将张量 a 的哪些元素按 1 递增?应将张量 a 的第一行,第二行还是两行都增加 1?

要理解此示例中加法的原理以及在操作中张量的形状不同时的其他情况,您需要熟悉broadcasting(¹⁰),PyTorch 在幕后执行它来生成 a + b 的下面的结果:

tensor([[2., 2., 2., 2., 2.],
        [2., 2., 2., 2., 2.]])

当操作中使用的张量的形状不相同时,PyTorch 尝试执行张量广播。在两个大小不同的张量上执行操作时,一些维度可能会被重用或广播以完成操作。如果给定两个张量 a 和 b,并且要在两个张量之间执行广播运算,可以通过调用 can_broadcast 来检查能否执行此操作。

列出 5.4,当 can_broadcast 返回 true 时,可以 broadcast

def can_broadcast(a, b):
  return all( [x == y or x == 1 or y == 1 \
    for x, y in zip( a.shape[::-1], b.shape[::-1] ) ])

这个广播规则取决于张量的尾部维度,或者以相反顺序对齐的张量的维度。即使是将标量添加到张量的简单示例也涉及广播:当 a = pt.ones(5) 且 b = pt.tensor(42) 时,它们的形状分别为 torch.Size([5]) 和 torch.Size([])。因此,标量必须像图 5.2 中所示一样广播五次到张量 a。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.2 将标量 b 广播到秩为 1 的张量 a

广播不需要在内存中复制或复制张量数据;相反,从操作中使用的张量的值直接计算产生广播结果的张量的内容。有效地使用和理解广播可以帮助您减少张量所需的内存量,并提高张量操作的性能。

为了用更复杂的示例说明广播,其中 a = pt.ones([2, 5]) 且 b = pt.ones(5),请注意广播重复使用张量 b 的值(图 5.3 的右侧),以便在生成 a + b 张量时对齐结果的尾部维度,同时保留来自张量 a 的前导维度。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.3 将秩为 1 的张量 b 广播到秩为 2 的张量 a

根据您目前所见的广播示例,您可能会错误地认为广播只发生在一个方向上:从操作中的一个张量到另一个张量。这是错误的。请注意,根据列表 5.4 中的规则,参与操作的两个张量都可以相互广播数据。例如,在图 5.4 中,张量 a 被广播到张量 b 三次(基于第一维),然后张量 b 的内容沿着相反的方向(沿第二维)广播,以产生尺寸为 torch.Size([3, 2, 5]) 的结果张量 a+b。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.4 广播是双向的,其中 b 的第一维广播到 a,a 的第二维广播到 b。

5.5 PyTorch 张量 vs. 原生 Python 列表

在本节中,您将深入了解原生 Python 数据结构与 PyTorch 张量在内存使用方面的比较,并了解为什么 PyTorch 张量可以帮助您更有效地利用内存来处理机器学习用例。

大多数现代笔记本电脑使用的中央处理单元(CPU)的运行频率为 2 到 3 GHz。为了保持计算简单,让我们忽略一些现代处理器执行流水线功能的高级指令,并将 2 GHz 处理器频率解释为处理器大约需要半个纳秒(ns)来执行一条单个指令,例如执行加法运算并存储结果的指令。虽然处理器可以在少于 1 ns 的时间内执行一条指令,但处理器必须等待超过 100 倍的时间,从 50 到 100 ns 不等,才能从主计算机内存(动态随机访问存储器)中获取一段数据。当然,处理器使用的一些数据存储在缓存中,可以在单个数字纳秒内访问,但低延迟缓存的大小有限,通常以单个数字 MiB 为单位进行度量。[¹¹]

假设你正在编写一个计算机程序,需要对数据张量执行一些计算,比如处理一个包含 4,096 个整数的数组,并将每个整数加 1。为了使这样的程序获得高性能,可以使用较低级别的编程语言,如 C 或 C++,在计算机内存中为输入数据数组分配一个单一的块。例如,在 C 编程语言中,一个包含 4,096 个整数值的数组,每个整数值为 64 位,可以存储为某个连续内存范围内的 64 位值序列,例如从地址 0x8000f 到地址 0x9000f。[¹²]

假设所有 4,096 个整数值都在连续的内存范围内,那么这些值可以作为一个单一块从主存储器传输到处理器缓存中,有助于减少对值的加法计算的总延迟。如图 5.5 的左侧所示,C 整数仅占用足够的内存以存储整数值,以便可以将一系列 C 整数存储为可寻址的内存位置序列。请注意,数字 4,096 是有意选择的:因为 4,096 * 8(每个 64 位整数的字节数)= 32,768 字节是 2020 年 x86 处理器的常见 L1 缓存大小。这意味着每次需要刷新缓存并用另外 4,096 个整数重新填充缓存时,都会产生大约 100 ns 的延迟惩罚,这些整数需要从计算机内存中获取。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.5 C 整数的值(左侧)直接存储为可寻址的内存位置。Python 整数(右侧)存储为对通用 PyObject_HEAD 结构的地址引用,该结构指定了数据类型(整数)和数据值。

这种高性能方法不适用于本地 Python 整数或列表。在 Python 中,所有数据类型都以 Python 对象(PyObjects)的形式存储在计算机内存中。这意味着对于任何数据值,Python 分配内存来存储值以及称为 PyObject_HEAD 的元数据描述符(图 5.5 右侧),该描述符跟踪数据类型(例如,数据位描述整数还是浮点数)和支持元数据,包括一个引用计数器,用于跟踪数据值是否正在使用。对于浮点数和其他原始数据值,PyObject 元数据的开销可能会使存储数据值所需的内存量增加两倍以上。

从性能角度来看,情况更糟糕的是,Python 列表(例如,如图 5.6 左侧所示的 list PyObject)通过引用存储值到它们的 PyObject 内存地址(图 5.6 右侧)并且很少将所有值存储在连续的内存块中。由于 Python 列表存储的每个 PyObject 可能分散在计算机内存中的许多位置,所以在最坏的情况下,每个 Python 列表中的值可能会有 100 纳秒的潜在延迟惩罚,因为需要为每个值刷新并重新填充缓存。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.6 中的整数在 Python 列表(PyListObject)中通过引用(内存地址)作为 PyListObjects 中的项目进行访问,需要额外的内存访问以查找每个整数的 PyObject。根据内存碎片化程度,单个整数的 PyObjects 可以分散在内存中,导致频繁的缓存未命中。

PyTorch 张量(以及其他库,如 NumPy)使用基于低级别 C 代码实现高性能数据结构,以克服较高级别的 Python 本地数据结构的低效率。具体而言,PyTorch 使用基于 C 的 ATen 张量库¹⁴,确保 PyTorch 张量使用友好的缓存、连续的内存块(在 ATen 中称为 blobs)存储底层数据,并提供了从 Python 到 C++ 的绑定,以支持通过 PyTorch Python API 访问数据。

为了说明性能差异,请看下面的代码片段,使用 Python 的 timeit 库来测量处理长度从 2 到大约 268 百万(2²⁸)的整数值列表的性能,并将列表中的每个值递增 1,

import timeit
sizes = [2 ** i for i in range(1, 28)]

pylist = [ timeit.timeit(lambda: [i + 1 for i in list(range(size))],
                          number = 10) for size in sizes ]

使用类似的方法来测量递增张量数组中值所需的时间:

pytorch = [ timeit.timeit(lambda: pt.tensor(list(range(size))) + 1,
                          number = 10) for size in sizes ]

如图 5.7 所示,可以通过将大小作为 x 轴、比率作为 y 轴的图形比较 PyTorch 张量与本地 Python 列表的性能,其中比率定义为:

ratio = [pylist[i] / pytorch[i] for i in range(len(pylist))]

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.7 Python 到 PyTorch 性能比的比例显示了增量操作基准测试的一致更快的 PyTorch 性能,从具有 10,000 个元素及更高数量的列表开始。

摘要

  • PyTorch 是一个面向深度学习的框架,支持基于高性能张量的机器学习算法。

  • PyTorch 张量将标量、数组(列表)、矩阵和更高维数组泛化为单个高性能数据结构。

  • 使用多个张量的操作,包括张量加法和乘法,依赖于广播以对齐张量形状。

  • PyTorch 中基于 C/C++ 的张量比 Python 本地数据结构更节省内存,并且能够实现更高的计算性能。

由特斯拉 AI 主管 Andrej Karpathy 提出的 PyTorch 机器学习用例:www.youtube.com/watch?v=oBklltKXtDE

OpenAI 因创建基于 PyTorch 的最先进自然语言处理 GPT 模型而闻名:openai.com/blog/openai-pytorch/

当然,在 Python 中可以使用切片符号,但这与本说明无关。

有关 PyTorch 中所有子类的完整列表,请参阅 torch.Tensor 文档: pytorch.org/docs/stable/tensors.html#torch-tensor

PyTorch 支持的 dtype 值的全面列表可在mng.bz/YwyB上找到。

NestedTensor 类作为 PyTorch 包在这里提供:github.com/pytorch/nestedtensor

关于 PyTorch 张量数据类型的详细信息,请参阅pytorch.org/docs/stable/ tensors.html

有关 PyTorch 随机抽样工厂方法的详细文档,请访问mng.bz/GOqv

关于原地张量操作的详细参考,请访问pytorch.org/docs/stable/tensors.html 并搜索“in-place”。

广播是各种计算库和软件包(如 NumPy、Octave 等)中常用的技术。有关 PyTorch 广播的更多信息,请访问pytorch.org/docs/stable/ notes/broadcasting.html

了解每位计算机程序员都应该知道的延迟数字的全面解析,请访问gist.github.com/jboner/2841832

当然,现代计算机程序的实际内存地址不太可能具有像 0x8000f 或 0x9000f 的值;这些值仅用于说明目的。

^(13.)Python 使用此引用计数器来确定数据值是否不再使用,以便可以安全地释放用于数据的内存,并释放给其他数据使用。

^(14.)有关 ATen 文档,请访问pytorch.org/cppdocs/#aten

第六章:PyTorch 核心:Autograd、优化器和实用工具

本章涵盖内容如下

  • 理解自动微分

  • 使用 PyTorch 张量进行自动微分

  • 开始使用 PyTorch SGD 和 Adam 优化器

  • 使用 PyTorch 实现带有梯度下降的线性回归

  • 使用数据集批次进行梯度下降

  • PyTorch 数据集和 DataLoader 工具类用于批量处理

在第五章中,您学习了张量(tensor),这是 PyTorch 的核心数据结构,用于表示 n 维数组。该章节展示了 PyTorch 张量相对于原生 Python 数据结构的数组的显著性能优势,并介绍了创建张量以及在一个或多个张量上执行常见操作的 PyTorch API。

本章将介绍 PyTorch 张量的另一个关键特性:支持使用 自动微分(autodiff)进行梯度计算。自动微分被描述为自 1970 年以来科学计算中的一项重大进展,它出人意料地简单,由赫尔辛基大学的硕士研究生 Seppo Linnainmaa 发明。本章的第一部分通过展示如何使用基本的 Python 实现标量张量的核心算法来向您介绍自动微分的基础知识。

本章的其余部分将解释如何使用 PyTorch 张量 API 的自动微分功能来计算机器学习模型的梯度,以一个基于一个小的合成数据集的线性回归问题应用梯度下降的简单示例。在这个过程中,您将学习 PyTorch 自动微分的 API,并学会如何使用它们来实现机器学习中使用梯度下降的标准步骤序列。本章最后展示了使用各种梯度下降优化器的 torch.optim 包,并向您展示如何在您的机器学习代码中利用这些优化器。

6.1 理解自动微分的基础知识

本节介绍了自动微分的概念,并通过使用纯粹的 Python 编程语言构造,在没有使用 PyTorch 的情况下,通过一个简单的例子来教授其基础知识。在这个过程中,您将深入理解 PyTorch 自动微分功能,并开发出使您能够在项目中解决 PyTorch 自动微分问题的知识。在本节中,您将看到自动微分虽然出奇地简单,但它是一个支持复杂应用微积分链式法则的算法。在后续章节中,您将应用所学知识,并使用 PyTorch 张量的自动微分功能。

PyTorch 张量的自动微分功能是该框架成为深度学习和许多依赖于梯度下降以及相关优化技术的机器学习算法流行的核心原因之一。虽然可以将自动微分视为一个黑盒子来使用,而不完全理解它的工作方式,但如果您希望开发用于在生产场景中排除自动微分问题的技巧,了解这个关键的 PyTorch 功能至少是有价值的。

PyTorch 实现了一种名为反向模式积累自动微分的自动微分方法,这是一种高效的方法,用于计算常用于机器学习的损失函数(在附录 A 中定义)的梯度,包括均方误差和交叉熵。更准确地说,PyTorch 自动微分具有 O(n)的计算复杂度,其中 n 是函数中操作(如加法或乘法操作)的总数,只要函数的输入变量多于输出变量。

如果您已经熟悉反向模式积累自动微分,可以跳转到第 6.2 节,其中解释如何使用 PyTorch 自动微分 API 进行机器学习。否则,本节将帮助您更深入地了解 PyTorch 自动微分 API 设计及其用途。

如果您刚开始学习自动微分,需要知道它与其他流行的微分技术(如数值微分或符号微分)是不同的。数值微分通常在本科计算机科学课程中教授,基于对![006-01_EQ01]的近似。与数值微分不同,自动微分在数值上是稳定的,这意味着它在不同函数值的极端值时提供准确的梯度值,并且对实数的浮点数近似所引入的小误差的累积是有决策力的。

与符号微分不同,自动微分不尝试派生一个差分函数的符号表达式。因此,自动微分通常需要更少的计算和内存。然而,符号微分推导了一个可应用于任意输入值的差异函数,不像自动微分,它一次为函数的特定输入变量的值进行差异。

理解自动微分的一个好方法是自己实现一个玩具示例。在本节中,您将为一个微不足道的张量实现自动微分,一个纯量,添加支持计算使用加法和乘法的函数的梯度,然后探索如何使用您的实现来区分常见函数。

要开始,定义一个标量 Python 类,存储标量的值(val)和其梯度(grad):²

class Scalar:
  def __init__(self, val):
    self.val = val
    self.grad = 0

为了更好地跟踪标量实例的内容并支持更好的实例值输出打印,让我们也添加一个 repr 方法,返回实例的字符串表示形式:

def __repr__(self):
  return f"Value: {self.val}, Gradient: {self.grad}"

有了这个实现,您可以实例化一个标量类的对象,例如使用 Scalar(3.14)。

列表 6.1 grad 属性用于存储标量张量的梯度

class Scalar:
  def __init__(self, val):
    self.val = val
    self.grad = 0.0
  def __repr__(self):
    return f"Value: {self.val}, Gradient: {self.grad}"

print(Scalar(3.14))

一旦执行,这个操作应该返回输出

Value: 3.14, Gradient: 0

这与 repr 方法返回的字符串相对应。

接下来,让我们通过重写相应的 Python 方法来实现对标量实例的加法和乘法。在反向模式自动微分中,这被称为前向传播 过程,它仅仅计算标量运算的值:

def __add__(self, other):
    out = Scalar(self.val + other.val)
    return out

  def __mul__(self, other):
    out = Scalar(self.val * other.val)
    return out

此时,您可以对标量实例执行基本的算术运算,

class Scalar:
  def __init__(self, val):
    self.val = val
    self.grad = 0

  def __repr__(self):
    return f"Value: {self.val}, Gradient: {self.grad}"

  def __add__(self, other):
    out = Scalar(self.val + other.val)
    return out

  def __mul__(self, other):
    out = Scalar(self.val * other.val)
    return out

Scalar(3) + Scalar(4) * Scalar(5)

正确计算为

Value: 23, Gradient: 0

并证实该实现遵守算术优先规则。

在这一点上,整个实现只需大约十二行代码,应该很容易理解。您已经完成了一半以上的工作,因为这个实现正确计算了自动微分的前向传播。

为了支持计算和累积梯度的反向传播,您需要对实现做一些小的更改。首先,标量类需要用默认设置为一个空操作的反向函数进行初始化❶。

列表 6.2 反向传播支持的后向方法占位符

class Scalar:
  def __init__(self, val):
    self.val = val
    self.grad = 0
    self.backward = lambda: Nonedef __repr__(self):
    return f"Value: {self.val}, Gradient: {self.grad}"
  def __add__(self, other):
    out = Scalar(self.val + other.val)
    return out
  def __mul__(self, other):
    out = Scalar(self.val * other.val)
    return out

❶ 使用 lambda: None 作为默认实现。

令人惊讶的是,这个实现足以开始计算琐碎线性函数的梯度。例如,要找出线性函数y = x在 x = 2.0 处的梯度外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,您可以从评估开始

x = Scalar(2.0)
y = x

它将 x 变量初始化为一个 Scalar(2.0),并声明函数y = x。此外,由于这是一个非常简单的案例,计算 y 的前向传播只是一个空操作,什么也不做。

接下来,在使用反向函数之前,您需要执行两个先决步骤:首先,将变量的梯度清零(我将很快解释为什么),其次,指定输出 y 的梯度。由于 x 是函数中的一个单独变量,清零其梯度就相当于运行

x.grad = 0.0

如果您觉得设置 x.grad = 0.0 这个步骤是不必要的,因为 grad 已经在 init 方法中设置为零,那么请记住,这个例子是针对一个琐碎函数的,当您稍后将实现扩展到更复杂的函数时,设置梯度为零的必要性会变得更加明显。

第二步是指定输出 y 的梯度值,即关于自身的外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。幸运的是,如果您曾经将一个数字除以自身,那么这个值就很容易找出:y.grad 就是 1.0。

因此,要在这个简单线性函数上执行反向累积自动微分,你只需要执行以下操作:

x = Scalar(2.0)
y = x

x.grad = 0.0
y.grad = 1.0
y.backward()

然后使用

print(x.grad)

计算出 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的值,其结果正确显示为 1.0。

如果你一直关注 y = x 的定义,你完全有权利提出反对,认为这整个计算过程只是将梯度从 y.grad = 1.0 语句中取出并打印出来。如果这是你的思维线路,那么你是绝对正确的。就像前面介绍 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的例子一样,当计算 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的梯度时,对于函数 y = x,y 相对于 x 的变化与 x 相对于 y 的变化的比率就是 1.0。然而,这个简单的例子展示了一系列自动微分操作的重要顺序,即使对于复杂函数也是如此:

  • 指定变量的值

  • 指定变量的输出(前向传播)

  • 确保变量的梯度设置为零

  • 调用 backward() 来计算梯度(后向传播)

如果你对微分的推理感到舒适,那么你就可以进一步计算更复杂函数的梯度了。使用自动微分,梯度的计算发生在实现数学运算的函数内部。让我们从比较容易的加法开始:

def __add__(self, other):
  out = Scalar(self.val + other.val)

  def backward():    
    self.grad += out.grad   
    other.grad += out.grad    
    self.backward()              
    other.backward()        
  out.backward = backward   

  return out

注意,梯度的直接计算、累积和递归计算发生在分配给加法操作所产生的 Scalar 对象的 backward 函数的主体中。

要理解 self.grad += out.grad 和类似的 other.val += out.grad 指令背后的逻辑,你可以应用微积分的基本规则或者进行一些有关变化的直观推理。微积分中的相关事实告诉我们,对于一个函数 y = x + c,其中 c 是某个常数,那么 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 = 1.0。这与之前计算 y = x 的梯度的例子几乎完全相同:尽管给 x 添加了一个常数,但是 y 相对于 x 的变化与 x 相对于 y 的变化的比率仍然是 1.0。对于代码来说,这意味着 self.grad 对 out.grad 贡献的变化量与 out.grad 的值是一样的。

那么对于代码计算没有常数的函数的梯度的情况呢,换句话说y = x + z,其中 x 和 z 都是变量?在实现方面,当计算 self.grad 时,为什么 out.grad 应被视为常数呢?答案归结于梯度或关于一个变量的偏导数的定义。找到 self.grad 的梯度相当于回答问题“假设所有变量,除了 self.grad,都保持不变,那么 y 对 self.grad 的变化率是多少?”因此,在计算梯度 self.grad 时,其他变量可以视为常量值。当计算 other.grad 的梯度时,这种推理也适用,唯一的区别是 self.grad 被视为常量。

还要注意,在 add 方法的梯度计算的一部分中,both self.grad 和 other.grad 都使用+=运算符累积梯度。理解 autodiff 中的这部分是理解为什么在运行 backward 方法之前需要将梯度清零至关重要。简单地说,如果你多次调用 backward 方法,则梯度中的值将继续累积,导致不良结果。

最后但并非最不重要的是,调用 backward 方法递归触发的 self.backward()和 other.backward()代码行确保 autodiff 的实现也处理了函数组合,例如 f(g(x))。请回忆,在基本情况下,backward 方法只是一个无操作的 lambda:None 函数,它确保递归调用始终终止。

要尝试带有后向传递支持的 add 实现,让我们通过将 y 重新定义为 x 值的和来查看更复杂的示例:

x = Scalar(2.0)
y = x + x

从微积分中,你可能会记得y = x + x = 2 * x的导数只是 2。

使用你的 Scalar 实现确认一下。同样,你需要确保 x 的梯度被清零,初始化 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 = 1,然后执行后向函数:

x.grad = 0.0
y.grad = 1.0
y.backward()

此时,如果你打印出来

x.grad

它返回正确的值

2.0

要更好地理解为什么外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传评估为 2.0,回想一下在 add 方法中实现的向后函数的定义。由于 y 定义为 y = x + x,self.grad 和 other.grad 都引用 backward 方法中 x 变量的同一实例。因此,对 x 的更改相当于对 y 或梯度的更改两倍,因此梯度外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传为 2。

接下来,让我们扩展 Scalar 类的实现,以支持在乘法 Scalars 时计算梯度。在 mul 函数中,实现只需要六行额外代码:

def __mul__(self, other):
  out = Scalar(self.val * other.val)

  def backward():           
    self.grad += out.grad * other.val   
    other.grad += out.grad * self.val
    self.backward()               
    other.backward()         
  out.backward = backward    

  return out

乘法的梯度推导逻辑比加法更复杂,因此值得更详细地审查。与加法一样,假设你试图根据 self.grad 来推导梯度,这意味着在计算时,other.val 可以被视为常数 c。当计算 y = c * x 关于 x 的梯度时,梯度就是 c,这意味着对于 x 的每一个变化,y 都会变化 c。当计算 self.grad 的梯度时,c 就是 other.val 的值。类似地,你可以翻转对 other.grad 的梯度计算,并将 self 标量视为常数。这意味着 other.grad 是 self.val 与 out.grad 的乘积。

有了这个改变,标量类的整个实现只需以下 23 行代码:

class Scalar:
  def __init__(self, val):
    self.val = val
    self.grad = 0
    self.backward = lambda: None
  def __repr__(self):
    return f"Value: {self.val}, Gradient: {self.grad}"
  def __add__(self, other):
    out = Scalar(self.val + other.val)
    def backward():
      self.grad += out.grad
      other.grad += out.grad
      self.backward(), other.backward()
    out.backward = backward
    return out
  def __mul__(self, other):
    out = Scalar(self.val * other.val)
    def backward():
      self.grad += out.grad * other.val
      other.grad += out.grad * self.val
      self.backward(), other.backward()
    out.backward = backward
    return out

为了更加确信实现正确,你可以尝试运行以下测试案例:

x = Scalar(3.0)
y = x * x

重复早期步骤将梯度归零,并将输出梯度值指定为 1.0:

x.grad = 0.0
y.grad = 1.0
y.backward()

使用微积分规则,可以很容易地通过解析方法找出预期结果:给定 y = x²,则 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 = 2 * x。因此对于 x = 3.0,你的标量实现应返回梯度值为 6.0。

你可以通过打印输出来确认

x.grad

返回结果为

6.0.

标量实现也可扩展到更复杂的函数。以y = x³ + 4**x* + 1 为例,其中梯度 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 = 3 * x² + 4,所以 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传x = 3 时等于 31,你可以通过指定如下代码来使用标量类实现这个函数 y:

x = Scalar(3.0)
y = (x * x * x) + (Scalar(4.0) * x) + Scalar(1.0)

然后运行

x.grad = 0.0
y.grad = 1.0
y.backward()

x.grad

确认实现正确返回值为 31.0。

对于标量(Scalar)的自动微分实现与 PyTorch 张量提供的功能相比较简单。然而,它可以让你更深入地了解 PyTorch 在计算梯度时提供的功能,并阐明为什么以及何时需要使用看似神奇的zero_gradbackward函数。

使用 PyTorch 自动微分进行线性回归

本节在介绍了第 6.1 节中自动微分算法的基础上,引入了 PyTorch 中的自动微分支持。作为一个激励性的例子,本节将带领你通过使用 PyTorch 自动微分和基本的梯度下降法解决单变量线性回归问题的过程。在这个过程中,你将学习如何使用 PyTorch 自动微分 API,实践可微模型的前向和反向传播,并为在接下来的章节深入应用 PyTorch 做好准备。

为了说明 PyTorch 中的自动微分特性,让我们结合梯度下降算法解决经典的线性回归问题。为了建立问题,让我们生成一些样本数据,

X = pt.linspace(-5, 5, 100)

所以变量 X 包含 100 个值,均匀分布在范围从-5 到 5。在这个例子中,假设 y = 2x + ε,其中 ε 是从标准正态分布中抽样的随机数(ε ~ N (0,1)),以便 y 可以用 PyTorch 实现为:

y = 2.0 * X + pt.randn(len(X))

向 y 函数添加 randn 噪声的目的是说明算法正确估计线的斜率的能力,换句话说,仅使用训练数据张量 y、X 即可恢复值为 2.0。

此时,如果您将 X 的值沿水平轴和 y 的值沿垂直轴绘制成图表,您应该期望看到一个类似于图 6.1 的图。当然,如果您使用了不同的随机种子,您的具体值可能会有所不同。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.1 用于解释 PyTorch 张量 API 基础知识的示例回归问题

下一步,为了为梯度下降算法建立可微分模型,您需要指定模型参数以及参数值的初始猜测。对于这种简化的线性回归情况,没有偏差,您需要指定的唯一模型参数是线的斜率。为了初始化参数,您可以使用 randn 函数从标准正态分布中抽样:

w = pt.randn(1, requires_grad = True)

到目前为止,在本章中您还没有看到 requires_grad 参数在此处被 randn 张量工厂方法用于实例化 w 值。在第 6.1 节中,我介绍了自动微分算法的内部工作原理,您看到计算梯度需要为张量中的每个数据值额外的内存和计算开销。例如,对于每个 Scalar 实例,自动微分需要一个额外的 grad 属性以及一个递归反向函数的定义。

对于机器学习模型,支持自动微分可能会使张量所需的内存量增加一倍以上。因此,当使用工厂方法实例化张量时,PyTorch 默认禁用张量自动微分,需要您使用 requires_grad 参数显式启用支持。但是,如果一个张量是从启用 requires_grad 的张量派生的,那么派生张量(称为非叶张量)的 requires_grad 将自动设置为 True。

一旦模型参数 w 初始化完成,算法的前向传递就准备好了。在这种情况下,前向传递是简单地使用 w 参数猜测(预测)y 值。前向传递是作为一个返回预测的均方误差值的 forward 方法实现的:

def forward(X):
  y_pred = X * w
  mse_loss = pt.mean((y_pred - y) ** 2)
  return mse_loss

仔细查看前向方法的实现,去计算 PyTorch 在计算均方误差公式过程中实例化的所有张量的数量。第一个张量 y_pred 包含基于给定 w 值的 y 的预测值。第二个张量是 y_pred - y,包含了预测值的个体误差,而第三个张量包含了平方误差(y_pred - y) ** 2。最后,使用 mean 函数计算第四个张量,返回一个标量,其值为预测值的均方误差。

前向方法中实例化的四个张量均无需手动指定 requires_grad = True,因为 PyTorch 会自动推断:为了支持对 w 张量的梯度计算,还需要启用来自 w 的非叶张量的 requires_grad。一般来说,对于任意的 PyTorch 张量,你可以检查其 requires_grad 属性的值,以确定它是否可以用于梯度计算。例如,在前向方法的代码块中,y_pred.requires_grad 返回 True。

在本章中,你还没有使用过 PyTorch 张量聚合函数,比如 mean。在前向方法中,mean 函数简单地计算张量值的算术平均值(即平方误差的平均值),并将聚合结果返回为标量。在接下来的章节中,你将学习更多关于 mean 和其他 PyTorch 张量聚合函数的内容。

有了前向传播代码,就可以完成使用 PyTorch 自动微分来实现梯度下降的工作。回想一下,代码中的 y 和 X 的值基于方程y = 2x + ε。以下代码执行 25 次梯度下降迭代来估算 2.0 的值,该值用作方程中的斜率。

列表 6.3 使用梯度下降的 PyTorch 自动微分进行线性回归

LEARNING_RATE = 0.03

for _ in range(25):
  mse_loss = forward(X)
  w.grad = None                ❶
  mse_loss.backward()
  w.data -= LEARNING_RATE * w.grad

print("MSE ", mse_loss.data, " W ", w.data)

❶ 清空 w 的梯度(置零)。

你应该期望代码打印出接近以下数字的结果:

MSE  tensor(0.7207)  W  tensor([1.9876])

在梯度下降的实现中,将学习率任意设为 0.03,并将梯度下降迭代次数设置为 25。在接下来的章节中,你将学习更多关于超参数调整和更严格的方法来选择学习率、梯度下降迭代次数以及其他机器学习超参数的值。

正如你从第 6.1 节已经了解的那样,在使用自动微分时,重要的是在使用 backward 函数累积更新的梯度值之前将梯度归零。请注意,在 PyTorch 张量的情况下,grad 属性是通过将其设置为 None ❶ 而不是 0 的值来归零的。一旦 mse_loss 张量由 forward 方法返回,就会通过调用 backward 函数来更新梯度。梯度下降步骤等同于使用学习率和更新后梯度的负乘积来更新 w 参数数据 w.data -= LEARNING_RATE * w.grad。

请注意,由于 y 值的噪声,不应期望梯度下降或线性回归的解析解能恢复用于生成数据的精确值 2.0。为了确认这一点,你可以使用 PyTorch 张量 API 根据公式 (X^T X)(-1)*XTy* 计算解析的最小二乘解,

pt.pow(X.T @ X, -1) * (X.T @ y)

应该返回大约 tensor(1.9876) 的值。

6.3 迁移到 PyTorch 优化器进行梯度下降

本节介绍了 PyTorch 的 torch.optim 包和 Optimizer 类,包括 Adam 和 SGD(随机梯度下降),你可以在基于 PyTorch 的机器学习模型中重新使用它们,以改进模型参数的训练方式。

除了 torch.autograd 的自动微分框架外,PyTorch 还包括了 torch.optim 包,其中包含了一系列优化器,这些优化器是根据损失函数的梯度实现的替代优化启发式算法,用于更新机器学习模型参数。优化器算法的实现细节超出了本书的范围,但你应该知道,PyTorch 开发团队一直在努力维护 PyTorch torch.optim 包文档中与相应算法实现相关的研究论文的链接。³

优化器类被设计成易于交换,确保你可以在不必更改机器学习模型训练代码的情况下尝试替代优化器。回想一下,在列表 6.3 中,你实现了一个简单的线性回归版本,使用了自己简单的规则来根据梯度和学习率更新模型参数值:

w.data -= LEARNING_RATE * w.grad

不要自己硬编码这个规则,你可以通过重新实现以下代码,来重用 torch.optim.SGD 优化器中的等价更新规则。

列表 6.4 使用 torch.optim 优化器进行线性回归

import torch as pt
pt.manual_seed(0)

X = pt.linspace(-5, 5, 100)
y = 2 * X + pt.randn(len(X))

w = pt.randn(1, requires_grad = True)

def forward(X):
  y_pred = X * w
  return y_pred

def loss(y_pred, y):
  mse_loss = pt.mean((y_pred - y) ** 2)
  return mse_loss

LEARNING_RATE = 0.03
optimizer = pt.optim.SGD([w], lr = LEARNING_RATE)   ❶

EPOCHS = 25for _ in range(EPOCHS):
  y_pred = forward(X)
  mse = loss(y_pred, y)
  mse.backward()

  optimizer.step()                                  ❸
  optimizer.zero_grad()print(w.item())

❶ 使用模型参数的可迭代对象 [w] 来实例化 SGD 优化器。

❷ 假设进行 25 个周期的梯度下降。

❸ 使用 backward 计算的梯度执行梯度更新步骤。

❹ 将模型参数的梯度归零,以备下一次迭代。

这应该输出模型对线性斜率 w 的估计值大约为 2.0812765834924307。使用 PyTorch 优化器所需的更改已经标注了❶、❸和❹。请注意,当实例化优化器❶时,您正在提供一个 Python 可迭代对象(在本例中是 Python 列表),用于模型参数。梯度下降计算出梯度后(即 backward()方法返回后),对优化器的 step()方法❸的调用基于梯度更新模型参数。优化器的 zero_grad()方法❹的调用清除(清空)梯度,以准备下一次 for 循环迭代中 backward()方法的调用。

如果您有训练机器学习模型的先验经验,可能已经遇到过 Adam 优化器。使用 PyTorch 优化器库,将 SGD 优化器❶替换为 Adam 很容易:

optimizer = pt.optim.Adam([w], lr = LEARNING_RATE)

通常,要使用 torch.optim 包中的任何 PyTorch 优化器,您需要首先使用构造函数实例化一个,

torch.optim.Optimizer(params, defaults)

其中 params 是模型参数的可迭代对象,defaults 是特定于优化器的命名参数的默认值。⁴

SGD 和 Adam 优化器都可以使用除模型参数和学习率之外的其他配置设置来实例化。然而,这些设置将在第十一章中的超参数调整中详细介绍。在那之前,示例将使用 SGD 优化器,因为它更容易理解和解释。

当你逐渐进入使用梯度下降进行更复杂的训练场景时,清晰而全面的术语是很有用的。从列表 6.4 中可以看到,通过梯度下降训练机器学习模型包括多个迭代,每个迭代包括以下操作:

  • 前向传递,其中使用特征值和模型参数返回预测输出,例如来自列表 6.4 的 y_pred = forward(X)。

  • 损失计算,其中使用预测输出和实际输出来确定损失函数的值,例如来自列表 6.4 的 mse = loss(y_pred, y)。

  • 反向传递,其中反向模式自动微分算法基于损失函数的计算计算模型参数的梯度,例如来自列表 6.4 的 mse.backward()。

  • 参数权重更新,其中使用从反向传递中计算得到的梯度值更新模型参数,如果您使用的是 torch.optim 包中的优化器,应该是 optimizer.step()。

  • 清除梯度,其中将模型参数 PyTorch 张量中的梯度值设置为 None,以防止自动微分在多次梯度下降迭代中累积梯度值;如果您使用的是 torch.optim 包中的优化器,则应使用 optimizer.zero_grad()进行此操作。

在行业中,术语迭代梯度下降的步骤通常互换使用。令人困惑的是,单词“步骤”有时也用来描述作为梯度下降的一部分执行的具体操作,例如向后步骤向前步骤。由于 PyTorch 使用步骤来特指根据损失函数的梯度来更新模型参数的操作,本书将坚持使用 PyTorch 的术语。请记住,一些 PyTorch 的框架,如 PyTorch Lightning,使用步骤来表示迭代

在过渡到使用批次进行梯度下降的模型训练之前,还有必要明确术语周期的定义。在机器学习中,一个周期描述了使用每个数据集示例恰好一次来训练(更新)机器学习模型参数所需的一个或多个梯度下降迭代。例如,列表 6.4❷指定了应该进行 25 个周期的梯度下降。使用词“周期”也对应于迭代,简单地原因是对于梯度下降的每次迭代,都会使用数据集中的所有示例来计算梯度和更新模型的权重。然而,正如您将在接下来关于批次的部分中了解到的,执行一个周期的训练可能需要多次梯度下降迭代。

6.4 开始使用数据集批次进行梯度下降

本节将向您介绍数据集批次(batches),以便您可以为使用 PyTorch 进行梯度下降做准备。梯度下降中数据集批次的概念看起来很简单。批次只是从数据集中随机抽样的一组非空示例(或在数学术语中,一个多重集)。尽管如此,使用批次的梯度下降是微妙而复杂的:数学家们甚至专门研究了这个领域,称为随机优化。训练数据集批次不仅仅是训练数据集的一个样本:在梯度下降的单个迭代中,训练数据集批次中的所有数据示例都用于更新模型的梯度。

虽然您不需要拥有数学优化的博士学位来使用 PyTorch 中的批量梯度下降,但具备与批次和梯度下降相关的精确术语能够更好地理解该主题的复杂性。

批处理大小 是一个正整数(大于零),指定了批处理中的示例数。许多机器学习研究论文和在线课程使用 mini-batch 梯度下降 这个短语来描述批处理大小大于一的梯度下降。然而,在 PyTorch 中,SGD(torch.optim.SGD)优化器以及 torch.optim 包中的其他优化器可以使用从 1 到整个数据集示例数的任何批处理大小。要记住这一点,因为在机器学习文献中,随机梯度下降 这个短语通常用来描述批处理大小恰好为一的梯度下降。

批处理大小的选择与您机器学习计算节点中的内存有关,也与梯度下降算法的机器学习性能有关。这意味着批处理大小的上限应考虑您机器学习平台节点的可用内存量。关于批处理大小的确切值的选择在第十一章的超参数调整中有更详细的介绍,但首先您应该知道您的机器学习平台节点内存中可以容纳的最大批处理大小。

对 PyTorch 中批处理的应用存在很多混淆,根源在于没有意识到批处理应该被视为数据集大小的一部分,即数据集中的示例数。将批处理解释为分数就是简单的 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,因此可以将批处理大小的选择归类为生成完整或不完整批次,其中 完整批次 的批处理大小是数据集大小的整数因子,或者更准确地说

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

对于某个表示数据集中批次数的正整数 batch_count。

6.5 使用 PyTorch Dataset 和 DataLoader 的数据集批处理

这一部分教你如何开始使用 PyTorch 中的数据集批处理,并使用 PyTorch 实用程序类来帮助你管理和加载数据集作为批处理。

PyTorch 框架提供了一组数据实用程序类,组织在 torch.utils.data 包中,并实现了支持使用梯度下降的数据批处理。该包中的类,包括 DataLoader、Dataset、IterableDataset 和 TensorDataset,旨在共同简化可扩展的机器学习模型训练过程的开发,包括数据集不适合单个节点内存的情况,以及数据集由分布式计算集群中的多个节点使用的情况。虽然这些类提供了可扩展的实现,但这并不意味着它们仅对大型数据集或大型计算集群有用。正如您将在本节中看到的,这些类可以很好地工作(除了可忽略的开销)与小型、内存中的数据集。

Dataset 是一个高度可重用的类,并且可以支持基于映射样式和可迭代样式的数据集子类的各种机器学习用例。映射样式数据集是 PyTorch 框架中的原始数据集类,最适合在内存中,可以通过索引进行寻址的数据集。例如,如果您要通过将 PyTorch 的数据集子类化为 MapStyleDataset 来实现自己的映射样式数据集,那么您必须实现所需的 getitemlen 方法。

列表 6.5 设计为子类的 PyTorch 映射样式数据集

import torch.utils.data.Dataset

class MapStyleDataset(Dataset):
    def __getitem__(self, index):...
    def __len__(self):...

❶ 映射样式接口方法,用于从数据集中检索特定项。. . .

❷ . . . 并返回整个数据集中项目的总数。

请注意,作为接口的一部分,映射样式数据集做出了两个假设:预计可以通过索引值❶访问数据集中的每个示例(项),并且随时可以了解和获取数据集的大小❷。

在大多数情况下,如果您使用的是内存数据集,您可以避免实现自己的映射样式数据集子类,而是重用 TensorDataset 类。TensorDataset 也是 torch.utils.data 包的一部分,并通过包装张量或 NumPy n 维数组来实现所需的数据集方法。例如,要为张量 X 和 y 中的示例数据值创建映射样式的训练数据集,可以直接将数据张量传递给 TensorDataset❶。

列表 6.6 简化批处理 PyTorch 张量的 TensorDataset

import torch as pt
from torch.utils.data import TensorDataset
pt.manual_seed(0)

X = pt.linspace(-5, 5, 100)
y = 2 * X + pt.randn(len(X))

train_ds = TensorDataset(y, X)

这使您可以使用 Python [0]语法从数据集中获取索引为 0 的示例,使用 getitem 方法,

print(train_ds[0])

输出为

(tensor(-11.1258), tensor(-5.))

并使用 train_ds 数据集上的 len 方法验证数据集的长度为 100,

assert
 len(train_ds) == 100

其中断言中的布尔表达式的结果为 True。

在使用数据集的实例时,实现梯度下降迭代的 PyTorch 代码不应直接访问数据集,而是使用 DataLoader 的实例作为与数据交互的接口。您将在使用 PyTorch 和 GPU 的即将到来的部分中了解更多有关使用 DataLoader 的理由。

DataLoader 本质上是对数据集的包装器,因此通过包装前面描述的 train_ds 实例,可以创建 train_dl 使用

from torch.utils.data import DataLoader
train_dl = DataLoader(train_ds)

注意,默认情况下,当 DataLoader 与映射样式的数据集一起使用时,DataLoader 实例返回的每个批次的大小为 1,这意味着以下表达式的结果为 True:

len(next(iter(train_dl))) == 1

可以通过在实例化 DataLoader 时指定 batch_size 命名参数来轻松修改此默认行为,以便该表达式

train_dl = DataLoader(train_ds, batch_size = 25)
len(next(iter(train_dl)))

评估结果为 25。

批量大小的两个值,1 和 25,都会生成完整的批次。尽管所有完整的批次的批量大小相同,但不完整的批次包含的示例数少于批量大小。具体来说,根据批量大小和数据集,不完整的批次可以包含至少

dataset_size mod batch_size

示例,或者在 Python 中,dataset_size % batch_size。

例如,当使用 batch_size 为 33 时,在 for 循环的第四次迭代过程中,以下代码生成一个批次大小为 1 的不完整批次:

train_dl = DataLoader(train_ds, batch_size = 33)
for idx, (y_batch, X_batch) in enumerate(train_dl):
  print(idx, len(X_batch))

这将打印以下内容:

0 33
1 33
2 33
3 1

处理不完整的批次没有普遍接受的技术。虽然有可能尝试防止不完整批次的问题,但在实践中可能没有太多价值:因为批次是在处理足够大卷的数据时使用的,以致于数据集太大而无法放入内存中,因此如果不完整的批次对机器学习模型的整体性能没有负面和可衡量的影响,那么可以选择忽略或删除它们。例如,DataLoader 类提供了一个 drop_last 选项,这样您就可以忽略较小的、不完整的批次:

train_dl = DataLoader(train_ds, batch_size = 33, drop_last=True)
for idx, (y_batch, X_batch) in enumerate(train_dl):
  print(idx, len(X_batch))

这将输出以下内容:

0 33
1 33
2 33

尽管在指定批大小时应谨慎使用不完整批次的 drop_last 选项,特别是在使用占数据集的大比例的批次大小时。例如,假设批次大小不慎设置为外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。由于此批次大小的选择产生两个批次,当使用 drop_last=True 选项时,两个中的单个不完整批次,批次大小为外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,被丢弃,导致将近一半的数据集浪费!

可以通过训练多个周期并将数据集与自身连接起来,使用 batch_size 窗口作为滚动窗口在数据集上滑动,从而防止不完整的批次。采用这种技术时,训练周期的数量应该基于批次大小和数据集大小的最小公倍数(lcm):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

为了说明这种方法,假设仅为例子而工作的批次大小为 12,数据集大小为 33,则

import math

def lcm(a, b):
    return a * b / math.gcd(a, b)

lcm(12, 33) / 33

输出 4.0,这表示训练数据集需要与自身连接四次,以实现所需的四个训练周期,以避免不完整的批次。

从数据集中选择批次是如何进行的?由于批次旨在统计代表数据集,批次中的示例应尽可能相互独立。这意味着确保批次中的出租车票价示例是从整个数据集中随机采样(无替换)的。在实践中,实现对数据集进行随机洗牌最直接的方法是使用 PySpark DataFrame API 的 shuffle()方法。

由于批次需要统计代表数据集,您可能会倾向于重新使用基于第四章中发现的测试数据集大小的批次大小。尽管测试数据集大小指标作为批次大小的 下限,但重新使用其值并不是正确的决定,因为测试数据集大小是尽可能小而又统计代表开发数据集的。第十一章详细描述了如何使用在本章介绍的下限和上限值来选择正确的批量大小的原则性超参数调整方法。

6.6 用于批量梯度下降的 Dataset 和 DataLoader 类

本节说明了如何使用最小化示例应用 Dataset 和 DataLoader 类来教授这些概念,这些概念也适用于使用数据集批次的更复杂和更现实的机器学习问题。要使用 Dataset 和 DataLoader 执行使用批量的梯度下降的线性回归,您需要修改第 6.3 节的解决方案。

列表 6.7 使用批量数据集的基本线性回归

import torch as pt
from torch.utils.data import TensorDataset, DataLoader
pt.manual_seed(0)

X = pt.linspace(-5, 5, 100)
y = 2 * X + pt.randn(len(X))

train_ds = TensorDataset(y, X)                  ❶
train_dl = DataLoader(train_ds, batch_size=1)   ❷

w = pt.empty(1, requires_grad = True)

def forward(X):
  y_pred =  X * w
  return y_pred

def loss(y_pred, y):
  mse_loss = pt.mean((y_pred - y) ** 2)
  return mse_loss

LEARNING_RATE = 0.003
optimizer = pt.optim.SGD([w], lr = LEARNING_RATE)

EPOCHS = 25
for _ in range(EPOCHS):
  for y_batch, X_batch in train_dl:             ❸
    y_pred = forward(X_batch)                   ❹
    mse = loss(y_pred, y_batch)                 ❺
    mse.backward()

    optimizer.step()
    optimizer.zero_grad()

print(w.item())

❶ 使用 TensorDataset 提供 y 和 X 的数据集接口。

❷ 使用批量大小为 1(默认)为数据集创建一个 DataLoader。

❸ 在迭代 DataLoader 时解压数据批次中的每个元组。

❹ 对特征批次执行前向步骤以生成预测。

❺ 使用批次标签和预测计算损失。

这应该输出估计的 w 大约为 2.0812765834924307。一旦原始张量 y 和 X 被打包成映射样式的 TensorDataset 数据集 ❶,则产生的 train_ds 实例进一步包装使用 DataLoader 以生成 train_dl。

要使用梯度下降的批次,对于每个周期,代码使用在 train_dl 中由 for 循环返回的单个批次执行 100 次梯度下降迭代 ❸。由于原始数据集包含 100 个示例,并且 DataLoader 的默认批量大小等于 1。由于批量大小为 1 会产生完整的批次(回想一下批次的定义为数据集的一部分),

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

或者,如果您使用批次大小为 25 的批次

train_dl = DataLoader(train_ds, batch_size=25),

那么

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

列表 6.7 中的更改 ❹ 和 ❺ 很简单:代码不再使用原始数据集,而是使用 train_dl 实例返回的批次。

如果您将批量大小修改为生成不完整批次的值,例如通过指定批量大小为 51,

train_dl = DataLoader(train_ds, batch_size=51)

内部 for 循环的第二次迭代将生成具有 49 个示例的批次,因为 DataLoader 默认允许不完整的批次。在这种特定情况下,这不是一个问题,因为具有 torch.Size([]) 形状的模型参数可以与形状为 torch.Size([49]) 的不完整批次进行广播。然而,通常情况下,您必须注意将模型参数的形状与批次的形状对齐。在第七章中,您将从对齐模型参数与 DC 出租车数据集批次形状的示例中学习。

总结

  • 自动微分是简化复杂链式规则应用的基本算法。

  • 可以使用 python 原生数据结构来演示如何为张量实现自动微分的基础知识。

  • PyTorch 张量提供了全面的支持,用于对张量梯度进行自动微分。

  • torch.optim 中的优化器是一系列用于使用梯度下降优化机器学习模型中参数的算法。

  • PyTorch 张量的自动微分和优化器 API 在使用 PyTorch 进行机器学习时是核心内容。

  • Dataset 和 DataLoader 接口类简化了在 PyTorch 代码中使用批处理的过程。

  • TensorDataset 提供了一个可以直接使用的内存中数据集的实现。

^(1.)自动微分和反向传播的故事在这里有详细描述:people.idsia.ch/~juergen/who-invented-backpropagation.html

^(2.)在有关自动微分的复杂数学论文中,标量类被称为双重数,grad 被称为伴随数。

^(3.)例如,SGD 的文档可以从mng.bz/zEpB获取,并包含指向相关研究论文和 SGD 实现详细信息的链接。

^(4.)基础的 Optimizer 类和它的构造函数在这里有文档说明:pytorch.org/docs/stable/optim.html#torch.optim.Optimizer

^(5.)更精确地说,通过使用无替换的随机抽样,或者等效地对数据集中的示例顺序进行随机洗牌。

^(6.)根据批处理大小和数据集大小的最小公倍数增加纪元数,可以产生足够的训练纪元,以避免不完整的批次。也可以训练任意 lcm(batch_size, data set_size) 的倍数,以避免不完整批次。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值