跳到正文

第2章 数据操作

为了能够完成各种数据操作,我们需要某种方法来存储和操作数据。 通常,我们需要做两件重要的事:

  1. 获取数据;
  2. 将数据读入计算机后对其进行处理。

如果没有某种方法来存储数据,那么获取数据是没有意义的。

首先,我们介绍n维数组,也称为张量(tensor)。

alt textalt text

使用过Python中NumPy计算包的读者会对本部分很熟悉。 无论使用哪个深度学习框架,它的张量类(在MXNet中为ndarray, 在PyTorch和TensorFlow中为Tensor)都与Numpy的ndarray类似。

但深度学习框架又比Numpy的ndarray多一些重要功能: 首先,GPU很好地支持加速计算,而NumPy仅支持CPU计算; 其次,张量类支持自动微分。 这些功能使得张量类更适合深度学习。 如果没有特殊说明,本书中所说的张量均指的是张量类的实例。

1.1. 创建数组

创建数组需要以下:

  1. 形状:例如3×4矩阵
  2. 每个元素的数据类型:例如32位浮点数
  3. 每个元素的值,例如全是0或者随机数

1.2. 运算符

对于任意具有相同形状的张量, 常见的标准算术运算符(+-*/**)都可以被升级为按元素运算。 我们可以在同一形状的任意两个张量上调用按元素操作。 在下面的例子中,我们使用逗号来表示一个具有5个元素的元组,其中每个元素都是按元素操作的结果。

x = torch.tensor([1.0, 2, 4, 8])
y = torch.tensor([2, 2, 2, 2])
x + y, x - y, x * y, x / y, x ** y  # **运算符是求幂运算

输出:

(tensor([ 3.,  4.,  6., 10.]),
 tensor([-1.,  0.,  2.,  6.]),
 tensor([ 2.,  4.,  8., 16.]),
 tensor([0.5000, 1.0000, 2.0000, 4.0000]),
 tensor([ 1.,  4., 16., 64.]))

“按元素”方式可以应用更多的计算,包括像求幂这样的一元运算符。

torch.exp(x)

Output:

tensor([2.7183e+00, 7.3891e+00, 5.4598e+01, 2.9810e+03])

除了按元素计算外,我们还可以执行线性代数运算,包括向量点积和矩阵乘法。 我们将在 :numref:sec_linear-algebra 中解释线性代数的重点内容。

我们也可以**把多个张量连结(concatenate)**在一起, 把它们端对端地叠起来形成一个更大的张量。 我们只需要提供张量列表,并给出沿哪个轴连结。 下面的例子分别演示了当我们沿行(轴-0,形状的第一个元素) 和按列(轴-1,形状的第二个元素)连结两个矩阵时,会发生什么情况。 我们可以看到,第一个输出张量的轴-0长度(6)是两个输入张量轴-0长度的总和(3+3); 第二个输出张量的轴-1长度(8)是两个输入张量轴-1长度的总和(4+4)。

X = torch.arange(12, dtype=torch.float32).reshape((3,4))
Y = torch.tensor([[2.0, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
torch.cat((X, Y), dim=0), torch.cat((X, Y), dim=1)

Output:

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

有时,我们想通过逻辑运算符构建二元张量。 以 X == Y 为例: 对于每个位置,如果X和Y在该位置相等,则新张量中相应项的值为1。 这意味着逻辑语句 X == Y 在该位置处为真,否则该位置为0。

X == Y

Output:

tensor([[False,  True, False,  True],
        [False, False, False, False],
        [False, False, False, False]])

对张量中的所有元素进行求和,会产生一个单元素张量。

X.sum()

Output:

tensor(66.)

1.3. 广播机制

在上面的部分中,我们看到了如何在相同形状的两个张量上执行按元素操作。 在某些情况下,[即使形状不同,我们仍然可以通过调用 广播机制(broadcasting mechanism) 来执行按元素操作]。 这种机制的工作方式如下:

  1. 通过适当复制元素来扩展一个或两个数组,以便在转换之后,两个张量具有相同的形状;
  2. 对生成的数组执行按元素操作。

在大多数情况下,我们将沿着数组中长度为1的轴进行广播,如下例子:

a = torch.arange(3).reshape((3, 1))
b = torch.arange(2).reshape((1, 2))
a, b

Output:

(tensor([[0],
         [1],
         [2]]),
 tensor([[0, 1]]))

由于ab分别是3×11×2矩阵,如果让它们相加,它们的形状不匹配。 我们将两个矩阵广播为一个更大的3×2矩阵,如下所示:矩阵a将复制列, 矩阵b将复制行,然后再按元素相加。

a + b

Output:

tensor([[0, 1],
        [1, 2],
        [2, 3]])

1.4. 索引和切片

就像在任何其他Python数组中一样,张量中的元素可以通过索引访问。 与任何Python数组一样:第一个元素的索引是0,最后一个元素索引是-1; 可以指定范围以包含第一个元素和最后一个之前的元素。

如下所示,我们可以用 [-1] 选择最后一个元素,可以用 [1:3] 选择第二个和第三个元素:

X[-1], X[1:3]

output:

(tensor([ 8.,  9., 10., 11.]),
 tensor([[ 4.,  5.,  6.,  7.],
         [ 8.,  9., 10., 11.]]))

除读取外,我们还可以通过指定索引来将元素写入矩阵。

X[1, 2] = 9
X

Output:

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

如果我们想为多个元素赋值相同的值,我们只需要索引所有元素,然后为它们赋值。例如,[0:2, :]访问第1行和第2行,其中 : 代表沿轴1(列)的所有元素。 虽然我们讨论的是矩阵的索引,但这也适用于向量和超过2个维度的张量。

X[0:2, :] = 12
X

Output:

tensor([[12., 12., 12., 12.],
        [12., 12., 12., 12.],
        [ 8.,  9., 10., 11.]])

1.5. 节省内存

运行一些操作可能会导致为新结果分配内存。 例如,如果我们用 Y = X + Y ,我们将取消引用Y指向的张量,而是指向新分配的内存处的张量。

在下面的例子中,我们用Python的 id() 函数演示了这一点, 它给我们提供了内存中引用对象的确切地址。 运行 Y = Y + X 后,我们会发现 id(Y) 指向另一个位置。 这是因为Python首先计算 Y + X ,为结果分配新的内存,然后使Y指向内存中的这个新位置。

before = id(Y)      # 把Y的id存起来,存到before
Y = Y + X
id(Y) == before     # Output: False(原因:之前的内存地址和新的Y的地址不同)

这可能是不可取的,原因有两个:

  1. 首先,我们不想总是不必要地分配内存。在机器学习中,我们可能有数百兆的参数,并且在一秒内多次更新所有参数。通常情况下,我们希望原地执行这些更新;
  2. 如果我们不原地更新,其他引用仍然会指向旧的内存位置,这样我们的某些代码可能会无意中引用旧的参数。

幸运的是,执行原地操作非常简单。 我们可以使用切片表示法将操作的结果分配给先前分配的数组,例如 Y[:] = <expression> 。 为了说明这一点,我们首先创建一个新的矩阵Z,其形状与另一个Y相同, 使用 zeros_like 来分配一个全0的块。

python
Z = torch.zeros_like(Y)
print('id(Z):', id(Z))  # id(Z): 140327634811696
Z[:] = X + Y
print('id(Z):', id(Z))  # id(Z): 140327634811696

如果在后续计算中没有重复使用X, 我们也可以使用 X[:] = X + YX += Y 来减少操作的内存开销。

before = id(X)
X += Y
id(X) == before     # True

1.6. 转换为其他Python对象

将深度学习框架定义的张量 转换为NumPy张量(ndarray 很容易,反之也同样容易。 torch张量和numpy数组将共享它们的底层内存,就地操作更改一个张量也会同时更改另一个张量。

A = X.numpy()
B = torch.tensor(A)
type(A), type(B)

Output:

(numpy.ndarray, torch.Tensor)

将大小为1的张量转换为Python标量,我们可以调用 item 函数或 Python 的内置函数。

a = torch.tensor([3.5])
a, a.item(), float(a), int(a)

Output:

(tensor([3.5000]), 3.5, 3.5, 3)

2. 数据预处理

为了能用深度学习来解决现实世界的问题,我们经常从预处理原始数据开始, 而不是从那些准备好的张量格式数据开始。 在Python中常用的数据分析工具中,我们通常使用pandas软件包。 像庞大的Python生态系统中的许多其他扩展包一样, pandas 可以与张量兼容。 本节我们将简要介绍使用 pandas 预处理原始数据,并将原始数据转换为张量格式的步骤。 后面的章节将介绍更多的数据预处理技术。

2.1. 读取数据集

举一个例子,我们首先 创建一个人工数据集,并存储在CSV(逗号分隔值)文件 ../data/house_tiny.csv 中。 以其他格式存储的数据也可以通过类似的方式进行处理。 下面我们将数据集按行写入CSV文件中。

python
import os

os.makedirs(os.path.join('..', 'data'), exist_ok=True)
data_file = os.path.join('..', 'data', 'house_tiny.csv')
with open(data_file, 'w') as f:
    f.write('NumRooms,Alley,Price\n')   # 列名
    f.write('NA,Pave,127500\n')         # 每行表示一个数据样本
    f.write('2,NA,106000\n')
    f.write('4,NA,178100\n')
    f.write('NA,NA,140000\n')

csv

CSV 文件就是一种表格数据文件,全称Comma-Separated Values,意思是“用逗号分隔的值”。它本质上是一个普通文本文件,但可以表示 Excel 那种表格。

比如一个 house_tiny.csv 文件内容可能是:

csv
NumRooms,Alley,Price
NA,Pave,127500
2,NA,106000
4,NA,178100
NA,NA,140000

看起来像文本,但含义是一个表格:

NumRoomsAlleyPrice
NAPave127500
2NA106000
4NA178100
NANA140000

其中:

text
第一行 = 表头 / 列名
后面的每一行 = 一条数据
逗号 = 分隔不同列
NA = 缺失值

在深度学习里,CSV 常用来存训练数据,比如房价、用户行为、传感器数据、实验数据等。

从创建的CSV文件中加载原始数据集 ,我们导入 pandas 包并调用 read_csv 函数。该数据集有四行三列。其中每行描述了房间数量(“NumRooms”)、巷子类型(“Alley”)和房屋价格(“Price”)。

python
# 如果没有安装pandas,只需取消对以下行的注释来安装pandas
# !pip install pandas
import pandas as pd

data = pd.read_csv(data_file)
print(data)

Output:

   NumRooms Alley   Price
0       NaN  Pave  127500
1       2.0   NaN  106000
2       4.0   NaN  178100
3       NaN   NaN  140000

2.2. 处理缺失值

注意,“NaN”项代表缺失值。 为了处理缺失的数据,典型的方法包括 插值法删除法 ,其中 插值法 用一个替代值弥补缺失值,而 删除法 则直接忽略缺失值。 在这里,我们将考虑插值法。

通过位置索引 iloc ,我们将 data 分成 inputsoutputs , 其中前者为 data 的前两列,而后者为 data 的最后一列。 对于 inputs 中缺少的数值,我们用同一列的均值替换 NaN 项。

python
inputs, outputs = data.iloc[:, 0:2], data.iloc[:, 2]    # iloc = index location
inputs = inputs.fillna(inputs.mean())
print(inputs)

Output:

   NumRooms Alley
0       3.0  Pave
1       2.0   NaN
2       4.0   NaN
3       3.0   NaN

对于 inputs 中的类别值或离散值,我们将 NaN 视为一个类别。由于“巷子类型”(“Alley”)列只接受两种类型的类别值“Pave”和“NaN”, pandas 可以自动将此列转换为两列“Alley_Pave”和“Alley_nan”。 巷子类型为“Pave”的行会将“Alley_Pave”的值设置为1,“Alley_nan”的值设置为0。 缺少巷子类型的行会将“Alley_Pave”和“Alley_nan”分别设置为0和1。

python
inputs = pd.get_dummies(inputs, dummy_na=True)
print(inputs)

Output:

   NumRooms  Alley_Pave  Alley_nan
0       3.0           1          0
1       2.0           0          1
2       4.0           0          1
3       3.0           0          1

2.3. 转换为张量格式

现在 inputsoutputs 中的所有条目都是数值类型,它们可以转换为张量格式。 当数据采用张量格式后,可以通过在 :numref:sec_ndarray 中引入的那些张量函数来进一步操作。

python
import torch

X = torch.tensor(inputs.to_numpy(dtype=float))
y = torch.tensor(outputs.to_numpy(dtype=float))
X, y

Output:

(tensor([[nan, 1., 0.],
         [2., 0., 1.],
         [4., 0., 1.],
         [nan, 0., 1.]], dtype=torch.float64),      # python默认64位浮点数,深度学习常用32位
 tensor([127500., 106000., 178100., 140000.], dtype=torch.float64))

2.4. 小结

  1. pandas 软件包是Python中常用的数据分析工具中, pandas 可以与张量兼容。
  2. pandas 处理缺失的数据时,我们可根据情况选择用插值法删除法

3. 线性代数

在介绍完如何存储和操作数据后,接下来将简要地回顾一下部分基本线性代数内容。 这些内容有助于读者了解和实现本书中介绍的大多数模型。 本节将介绍线性代数中的基本数学对象、算术和运算,并用数学符号和相应的代码实现来表示它们。

3.1. 标量

如果你曾经在餐厅支付费用,那么应该已经知道一些基本的线性代数,比如在数字间相加或相乘。例如,北京的温度为 52F(华氏度,除摄氏度以外的另一种温度计量单位)。严格来说,仅包含一个数值被称为标量(scalar)。

如果要将此华氏度值转换为更常用的摄氏度,则可以计算表达式:

c=59(f32)

并将 f 赋为 52。在此等式中,每一项(5932)都是标量值。符号 cf 称为变量(variable),它们表示未知的标量值。

本书采用了数学表示法,其中标量变量由普通小写字母表示,例如 xyz。本书用 R 表示所有连续实数标量的空间,之后将严格定义空间(space)是什么。但现在只要记住,表达式:

xR

表示 x 是一个实值标量的正式形式。符号 称为“属于”,它表示“是集合中的成员”。例如:

x,y{0,1}

可以用来表明 xy 是值只能为 01 的数字。

标量由只有一个元素的张量表示。下面的代码将实例化两个标量,并执行一些熟悉的算术运算,即加法、乘法、除法和指数。

python
import torch

x = torch.tensor(3.0)
y = torch.tensor(2.0)

x + y, x * y, x / y, x**y

Output:

(tensor(5.), tensor(6.), tensor(1.5000), tensor(9.))

3.2. 向量

向量可以被视为标量值组成的列表。这些标量值被称为向量的元素(element)或分量(component)。

当向量表示数据集中的样本时,它们的值具有一定的现实意义。例如,如果我们正在训练一个模型来预测贷款违约风险,可能会将每个申请人与一个向量相关联,其分量与其收入、工作年限、过往违约次数和其他因素相对应。如果我们正在研究医院患者可能面临的心脏病发作风险,可能会用一个向量来表示每个患者,其分量为最近的生命体征、胆固醇水平、每天运动时间等。

在数学表示法中,向量通常记为粗体、小写的符号,例如 xyz

人们通过一维张量表示向量。一般来说,张量可以具有任意长度,取决于机器的内存限制。

python
x = torch.arange(4)
x

输出:

python
tensor([0, 1, 2, 3])

我们可以使用下标来引用向量的任一元素,例如可以通过 xi 来引用第 i 个元素。注意,元素 xi 是一个标量,所以我们在引用它时不会加粗。

大量文献认为列向量是向量的默认方向,在本书中也是如此。在数学中,向量 x 可以写为:

x=[x1x2xn]

其中 x1,,xn 是向量的元素。在代码中,我们通过张量的索引来访问任一元素

python
x[3]

输出:

python
tensor(3)

长度、维度和形状

向量只是一个数字数组,就像每个数组都有一个长度一样,每个向量也是如此。在数学表示法中,如果我们想说一个向量 xn 个实值标量组成,可以将其表示为:

xRn

向量的长度通常称为向量的维度(dimension)。

与普通的 Python 数组一样,我们可以通过调用 Python 的内置 len() 函数来访问张量的长度。

python
len(x)

输出:

python
4

当用张量表示一个向量(只有一个轴)时,我们也可以通过 .shape 属性访问向量的长度。形状(shape)是一个元组,列出了张量沿每个轴的长度,也就是维数。

对于只有一个轴的张量,形状只有一个元素。

python
x.shape

输出:

python
torch.Size([4])

需要注意,维度(dimension)这个词在不同上下文中往往会有不同含义,这经常会让人困惑。为了清楚起见,我们在此明确一下:向量或轴的维度用来表示向量或轴的长度,即向量或轴的元素数量。然而,张量的维度用来表示张量具有的轴数。在这个意义上,张量的某个轴的维数就是这个轴的长度。

3.3. 矩阵

正如向量将标量从零阶推广到一阶,矩阵将向量从一阶推广到二阶。矩阵通常用粗体大写字母表示,例如 XYZ。在代码中,矩阵表示为具有两个轴的张量。

数学表示法使用:

ARm×n

表示矩阵 A,其中 m 行和 n 列的实值标量组成。我们可以将任意矩阵 ARm×n 视为一个表格,其中每个元素 aij 属于第 i 行第 j 列:

A=[a11a12a1na21a22a2nam1am2amn]

对于任意 ARm×nA 的形状是 (m,n)m×n。当矩阵具有相同数量的行和列时,其形状将变为正方形;因此,它被称为方阵(square matrix)。

当调用函数来实例化张量时,我们可以通过指定两个分量 mn 来创建一个形状为 m×n 的矩阵。

python
A = torch.arange(20).reshape(5, 4)
A

Output:

python
tensor([[ 0,  1,  2,  3],
        [ 4,  5,  6,  7],
        [ 8,  9, 10, 11],
        [12, 13, 14, 15],
        [16, 17, 18, 19]])

我们可以通过行索引 i 和列索引 j 来访问矩阵中的标量元素 aij,例如 [A]ij

如果没有给出矩阵 A 的标量元素,如在前面的矩阵定义中那样,我们可以简单地使用矩阵 A 的小写字母索引下标 aij 来引用 [A]ij。为了表示起来简单,只有在必要时才会将逗号插入到单独的索引中,例如 a2,3[A]2i1,3

当我们交换矩阵的行和列时,结果称为矩阵的转置(transpose)。通常用 A 表示矩阵的转置。如果:

B=A

则对于任意 ij,都有:

bij=aji

因此,矩阵 A 的转置是一个形状为 n×m 的矩阵:

A=[a11a21am1a12a22am2a1na2namn]

现在在代码中访问矩阵的转置

python
A.T

输出:

python
tensor([[ 0,  4,  8, 12, 16],
        [ 1,  5,  9, 13, 17],
        [ 2,  6, 10, 14, 18],
        [ 3,  7, 11, 15, 19]])

作为方阵的一种特殊类型,对称矩阵(symmetric matrix)A 等于其转置:

A=A

这里定义一个对称矩阵 B

python
B = torch.tensor([[1, 2, 3], [2, 0, 4], [3, 4, 5]])
B

输出:

python
tensor([[1, 2, 3],
        [2, 0, 4],
        [3, 4, 5]])

现在我们将 B 与它的转置进行比较。

python
B == B.T

输出:

python
tensor([[True, True, True],
        [True, True, True],
        [True, True, True]])

矩阵是有用的数据结构:它们允许我们组织具有不同模式的数据。

例如,我们矩阵中的行可能对应于不同的房屋(数据样本),而列可能对应于不同的属性。因此,尽管单个向量的默认方向是列向量,但在表示表格数据集的矩阵中,将每个数据样本作为矩阵中的行向量更为常见。后面的章节将讲到这一点,这种约定将支持常见的深度学习实践。例如,沿着张量的最外轴,我们可以访问或遍历小批量的数据样本。

特殊矩阵

正交矩阵

  1. 所有行都相互正交。
  2. 所有行都具有单位长度。
  3. 可以写成:UU=I

其中:

jUijUkj=δik

这里,δik 是 Kronecker delta:

δik={1,i=k0,ik

置换矩阵

置换矩阵 P 满足:

Pij=1if and only ifj=π(i)

置换矩阵是正交矩阵。

3.4. 张量

就像向量是标量的推广,矩阵是向量的推广一样,我们可以构建具有更多轴的数据结构。张量(本小节中的“张量”指代数学对象)是描述具有任意数量轴的 n 维数组的通用方法。

例如,向量是一阶张量,矩阵是二阶张量。张量用特殊字体的大写字母表示,例如 XYZ。它们的索引机制,例如 xijk[X]1,2i1,3,与矩阵类似。

当我们开始处理图像时,张量将变得更加重要。图像以 n 维数组形式出现,其中 3 个轴对应于高度、宽度,以及一个通道(channel)轴,用于表示颜色通道,例如红色、绿色和蓝色。

现在先将高阶张量暂放一边,而是专注学习其基础知识。

python
X = torch.arange(24).reshape(2, 3, 4)
X

输出:

python
tensor([[[ 0,  1,  2,  3],
         [ 4,  5,  6,  7],
         [ 8,  9, 10, 11]],

        [[12, 13, 14, 15],
         [16, 17, 18, 19],
         [20, 21, 22, 23]]])

3.5. 张量算法的基本性质

标量、向量、矩阵和任意数量轴的张量(本小节中的“张量”指代数学对象)有一些实用的属性。例如,从按元素操作的定义中可以注意到,任何按元素的一元运算都不会改变其操作数的形状。

同样,给定具有相同形状的任意两个张量,任何按元素二元运算的结果都将是相同形状的张量。例如,将两个相同形状的矩阵相加,会在这两个矩阵上执行元素加法。

python
A = torch.arange(20, dtype=torch.float32).reshape(5, 4)
B = A.clone()       # 通过分配新内存,将 A 的一个副本分配给 B
A, A + B

输出:

python
(
tensor([[ 0.,  1.,  2.,  3.],
        [ 4.,  5.,  6.,  7.],
        [ 8.,  9., 10., 11.],
        [12., 13., 14., 15.],
        [16., 17., 18., 19.]]),

tensor([[ 0.,  2.,  4.,  6.],
        [ 8., 10., 12., 14.],
        [16., 18., 20., 22.],
        [24., 26., 28., 30.],
        [32., 34., 36., 38.]])
)

具体而言,两个矩阵的按元素乘法称为 Hadamard 积(Hadamard product),数学符号为

对于矩阵 BRm×n,其中第 i 行和第 j 列的元素是 bij。矩阵 AB 的 Hadamard 积为:

AB=[a11b11a12b12a1nb1na21b21a22b22a2nb2nam1bm1am2bm2amnbmn]

在 PyTorch 中,两个形状相同的张量使用 * 时,表示按元素相乘。

python
A * B

输出:

python
tensor([[  0.,   1.,   4.,   9.],
        [ 16.,  25.,  36.,  49.],
        [ 64.,  81., 100., 121.],
        [144., 169., 196., 225.],
        [256., 289., 324., 361.]])

将张量乘以或加上一个标量不会改变张量的形状,其中张量的每个元素都会与标量相加或相乘。

python
a = 2
X = torch.arange(24).reshape(2, 3, 4)
a + X, (a * X).shape

输出:

python
(
tensor([[[ 2,  3,  4,  5],
         [ 6,  7,  8,  9],
         [10, 11, 12, 13]],

        [[14, 15, 16, 17],
         [18, 19, 20, 21],
         [22, 23, 24, 25]]]),

torch.Size([2, 3, 4])
)

3.6. 降维

我们可以对任意张量进行的一个有用操作是计算其元素的和。数学表示法使用 符号表示求和。

为了表示长度为 d 的向量中元素的总和,可以记为:

i=1dxi

在代码中,可以调用计算求和的函数:

python
x = torch.arange(4, dtype=torch.float32)
x, x.sum()

输出:

python
(tensor([0., 1., 2., 3.]), tensor(6.))

我们可以表示任意形状张量的元素和。例如,矩阵 A 中元素的和可以记为:

i=1mj=1naij
python
A.shape, A.sum()

输出:

python
(torch.Size([5, 4]), tensor(190.))

默认情况下,调用求和函数会沿所有轴降低张量的维度,使它变为一个标量。我们还可以指定张量沿哪一个轴来通过求和降低维度

以矩阵为例,为了通过求和所有行的元素来降维(轴 0),可以在调用函数时指定 axis=0。由于输入矩阵沿 0 轴降维以生成输出向量,因此输入轴 0 的维数在输出形状中消失。

python
A_sum_axis0 = A.sum(axis=0)
A_sum_axis0, A_sum_axis0.shape

输出:

python
(tensor([40., 45., 50., 55.]), torch.Size([4]))

指定 axis=1 将通过汇总所有列的元素降维(轴 1)。因此,输入轴 1 的维数在输出形状中消失。

python
A_sum_axis1 = A.sum(axis=1)
A_sum_axis1, A_sum_axis1.shape

输出:

python
(tensor([ 6., 22., 38., 54., 70.]), torch.Size([5]))

沿着行和列对矩阵求和,等价于对矩阵的所有元素进行求和。

python
A.sum(axis=[0, 1])  # 结果和 A.sum() 相同

输出:

python
tensor(190.)

一个与求和相关的量是平均值(mean 或 average)。我们通过将总和除以元素总数来计算平均值。在代码中,可以调用函数来计算任意形状张量的平均值。

python
A.mean(), A.sum() / A.numel()

输出:

python
(tensor(9.5000), tensor(9.5000))

同样,计算平均值的函数也可以沿指定轴降低张量的维度。

python
A.mean(axis=0), A.sum(axis=0) / A.shape[0]

输出:

python
(tensor([ 8.,  9., 10., 11.]), tensor([ 8.,  9., 10., 11.]))

非降维求和

但是,有时在调用函数来计算总和或均值时,保持轴数不变会很有用。

python
sum_A = A.sum(axis=1, keepdims=True)
sum_A

输出:

python
tensor([[ 6.],
        [22.],
        [38.],
        [54.],
        [70.]])

例如,由于 sum_A 在对每行进行求和后仍保持两个轴,我们可以通过广播机制A 除以 sum_A

python
A / sum_A

输出:

python
tensor([[0.0000, 0.1667, 0.3333, 0.5000],
        [0.1818, 0.2273, 0.2727, 0.3182],
        [0.2105, 0.2368, 0.2632, 0.2895],
        [0.2222, 0.2407, 0.2593, 0.2778],
        [0.2286, 0.2429, 0.2571, 0.2714]])

累加求和:如果我们想沿某个轴计算 A 元素的累积总和,例如 axis=0(按行计算),可以调用 cumsum 函数。此函数不会沿任何轴降低输入张量的维度。

python
A.cumsum(axis=0)

输出:

python
tensor([[ 0.,  1.,  2.,  3.],
        [ 4.,  6.,  8., 10.],
        [12., 15., 18., 21.],
        [24., 28., 32., 36.],
        [40., 45., 50., 55.]])

3.7. 点积(Dot Product)

我们已经学习了按元素操作、求和及平均值。另一个最基本的操作之一是点积

给定两个向量:

x,yRd

它们的点积(dot product)xy,或 x,y,是相同位置的按元素乘积的和:

xy=i=1dxiyi

点积是相同位置的按元素乘积的和。

python
y = torch.ones(4, dtype=torch.float32)
x, y, torch.dot(x, y)

输出:

python
(tensor([0., 1., 2., 3.]), tensor([1., 1., 1., 1.]), tensor(6.))

注意,我们可以通过执行按元素乘法,然后进行求和来表示两个向量的点积:

python
torch.sum(x * y)

输出:

python
tensor(6.)

点积在很多场合都很有用。例如,给定一组由向量 xRd 表示的值,和一组由 wRd 表示的权重,x 中的值根据权重 w 的加权和,可以表示为点积:

xw

当权重为非负数且和为 1,即:

i=1dwi=1

时,点积表示加权平均(weighted average)。

将两个向量规范化得到单位长度后,点积表示它们夹角的余弦。本节后面的内容将正式介绍长度(length)的概念。

3.8. 矩阵-向量积

现在我们知道如何计算点积,可以开始理解矩阵-向量积(matrix-vector product)。

回顾前面定义的矩阵 ARm×n 和向量 xRn ,让我们将矩阵 A 用它的行向量表示为:

A=[a1a2am]

其中每个 aiRn 都是行向量,表示矩阵的第 i 行。

矩阵向量积 Ax 是一个长度为 m 的列向量,其第 i 个元素是点积 aix

Ax=[a1a2am]x=[a1xa2xamx]

我们可以把一个矩阵 ARm×n 乘法看作一个从 RnRm 向量的转换。这些转换非常有用,例如可以用方阵的乘法来表示旋转。后续章节将讲到,我们也可以使用矩阵-向量积来描述在给定前一层的值时,求解神经网络每一层所需的复杂计算。

在代码中使用张量表示矩阵-向量积,我们使用 mv 函数。当我们为矩阵 A 和向量 x 调用 torch.mv(A, x) 时,会执行矩阵-向量积。注意,A 的列维数,也就是沿轴 1 的长度,必须与 x 的维数,也就是其长度,相同。

python
A.shape, x.shape, torch.mv(A, x)

输出:

python
(torch.Size([5, 4]), torch.Size([4]), tensor([ 14.,  38.,  62.,  86., 110.]))

3.9. 矩阵-矩阵乘法

在掌握点积和矩阵-向量积的知识后,矩阵-矩阵乘法(matrix-matrix multiplication)应该很简单。

假设有两个矩阵 ARn×k,BRk×m

A=[a11a12a1ka21a22a2kan1an2ank],B=[b11b12b1mb21b22b2mbk1bk2bkm]

用行向量 aiRk 表示矩阵 A 的第 i 行,并让列向量 bjRk 作为矩阵 B 的第 j 列。

要生成矩阵乘积 C=AB ,最简单的方法是考虑 A 的行向量和 B 的列向量:

A=[a1a2an],B=[b1b2bm]

当我们简单地将每个元素 cij 计算为点积 aibj 时:

C=AB=[a1a2an][b1b2bm]=[a1b1a1b2a1bma2b1a2b2a2bmanb1anb2anbm]

我们可以将矩阵-矩阵乘法 AB 看作简单地执行 m 次矩阵-向量积,并将结果拼接在一起,形成一个 n×m 矩阵。

在下面的代码中,我们在 AB 上执行矩阵乘法。这里的 A 是一个 54 列的矩阵,B 是一个 43 列的矩阵。两者相乘后,我们得到了一个 53 列的矩阵。

python
B = torch.ones(4, 3)
torch.mm(A, B)

输出:

python
tensor([[ 6.,  6.,  6.],
        [22., 22., 22.],
        [38., 38., 38.],
        [54., 54., 54.],
        [70., 70., 70.]])

矩阵-矩阵乘法可以简单地称为矩阵乘法,不要与 Hadamard 积混淆。

3.10. 范数

线性代数中最有用的一些运算符是范数(norm)。非正式地说,向量的范数表示一个向量有多大。这里考虑的大小(size)概念不涉及维度,而是分量的大小。

在线性代数中,向量范数是将向量映射到标量的函数 f。给定任意向量 x,向量范数需要满足一些属性。

第一个性质是:如果我们按常数因子 α 缩放向量的所有元素,其范数也会按相同常数因子的绝对值缩放:

f(αx)=|α|f(x)

第二个性质是熟悉的三角不等式:

f(x+y)f(x)+f(y)

第三个性质简单地说就是范数必须是非负的:

f(x)0

这是有道理的。因为在大多数情况下,任何东西的最小大小是 0。最后一个性质要求范数最小为 0,并且当且仅当向量全由 0 组成时,范数才为 0

i,[x]i=0f(x)=0

范数听起来很像距离的度量。欧几里得距离和毕达哥拉斯定理中的非负性概念和三角不等式可能会给出一些启发。

事实上,欧几里得距离是一个 L2 范数。假设 n 维向量 x 中的元素是 x1,,xnL2 范数是向量元素平方和的平方根

x2=i=1nxi2

其中,在 L2 范数中常常省略下标 2,也就是说,x 等同于 x2

在代码中,我们可以按照如下方式计算向量的 L2 范数。

python
u = torch.tensor([3.0, -4.0])
torch.norm(u)

输出:

python
tensor(5.)

深度学习中更经常地使用 L2 范数的平方,也会经常遇到 L1 范数,它表示为向量元素的绝对值之和

x1=i=1n|xi|

L2 范数相比,L1 范数受异常值的影响较小。为了计算 L1 范数,我们将绝对值函数和按元素求和组合起来。

python
torch.abs(u).sum()

输出:

python
tensor(7.)

L2 范数和 L1 范数都是更一般的 Lp 范数的特例:

xp=(i=1n|xi|p)1/p

类似于向量的 L2 范数,矩阵 XRm×n弗罗贝尼乌斯范数(Frobenius norm) 是矩阵元素平方和的平方根:

XF=i=1mj=1nxij2

Frobenius 范数满足向量范数的所有性质,它就像是矩阵形向量的 L2 范数。调用以下函数将计算矩阵的 Frobenius 范数。

python
torch.norm(torch.ones((4, 9)))

输出:

python
tensor(6.)

范数和目标

在深度学习中,我们经常试图解决优化问题:最大化分配给观测数据的概率,最小化预测和真实观测之间的距离。用向量表示物品(如单词、产品或新闻文章),以便最小化相似项目之间的距离,最大化不同项目之间的距离。

目标,或许是深度学习算法最重要的组成部分,通常被表达为范数。

3.11. 关于线性代数的更多信息

仅用一节,我们就教会了阅读本书所需的、用以理解现代深度学习的线性代数。 线性代数还有很多,其中很多数学对于机器学习非常有用。 例如,矩阵可以分解为因子,这些分解可以显示真实世界数据集中的低维结构。 机器学习的整个子领域都侧重于使用矩阵分解及其向高阶张量的泛化,来发现数据集中的结构并解决预测问题。 当开始动手尝试并在真实数据集上应用了有效的机器学习模型,你会更倾向于学习更多数学。 因此,这一节到此结束,本书将在后面介绍更多数学知识。

3.12. 小结

  • 标量、向量、矩阵和张量是线性代数中的基本数学对象。
  • 向量泛化自标量,矩阵泛化自向量。
  • 标量、向量、矩阵和张量分别具有零、一、二和任意数量的轴。
  • 一个张量可以通过 summean 沿指定的轴降低维度。
  • 两个矩阵的按元素乘法被称为它们的 Hadamard 积。它与矩阵乘法不同。
  • 在深度学习中,我们经常使用范数,如 L1 范数、L2 范数和 Frobenius 范数。
  • 我们可以对标量、向量、矩阵和张量进行各种操作。

4. 微积分 calculus

在2500年前,古希腊人把一个多边形分成三角形,并把它们的面积相加,才找到计算多边形面积的方法。 为了求出曲线形状(比如圆)的面积,古希腊人在这样的形状上刻内接多边形。 如图所示,内接多边形的等长边越多,就越接近圆。 这个过程也被称为逼近法(method of exhaustion)。

用逼近法求圆的面积

事实上,逼近法就是积分(integral calculus)的起源。 2000多年后,微积分的另一支,微分(differential calculus)被发明出来。 在微分学最重要的应用是优化问题,即考虑如何把事情做到最好。 正如在 2.3.10.1节中讨论的那样, 这种问题在深度学习中是无处不在的。

在深度学习中,我们“训练”模型,不断更新它们,使它们在看到越来越多的数据时变得越来越好。 通常情况下,变得更好意味着最小化一个损失函数(loss function), 即一个衡量“模型有多糟糕”这个问题的分数。 最终,我们真正关心的是生成一个模型,它能够在从未见过的数据上表现良好。 但“训练”模型只能将模型与我们实际能看到的数据相拟合。 因此,我们可以将拟合模型的任务分解为两个关键问题:

  • 优化(optimization):用模型拟合观测数据的过程;
  • 泛化(generalization):数学原理和实践者的智慧,能够指导我们生成出有效性超出用于训练的数据集本身的模型。

为了帮助读者在后面的章节中更好地理解优化问题和方法, 本节提供了一个非常简短的入门教程,帮助读者快速掌握深度学习中常用的微分知识。

4.1. 导数和微分

我们首先讨论导数的计算,这是几乎所有深度学习优化算法的关键步骤。

在深度学习中,我们通常选择对于模型参数可微的损失函数。简而言之,对于每个参数,如果我们把这个参数增加或减少一个无穷小的量,就可以知道损失会以多快的速度增加或减少。

假设我们有一个函数:

f:RR

其输入和输出都是标量。如果 f 的导数存在,这个极限被定义为:

f(x)=limh0f(x+h)f(x)h

如果 f(a) 存在,则称 fa 处是可微的(differentiable)。

如果 f 在一个区间内的每个数上都是可微的,则此函数在该区间中是可微的。我们可以将导数 f(x) 解释为 f(x) 相对于 x瞬时变化率(instantaneous rate of change)。

所谓瞬时变化率,是基于 x 的变化量 h,并令 h 接近 0

为了更好地解释导数,让我们做一个实验。定义:

u=f(x)=3x24x
python
%matplotlib inline

import numpy as np
from matplotlib_inline import backend_inline
from d2l import torch as d2l

定义函数:

f(x)=3x24x
python
def f(x):
    return 3 * x ** 2 - 4 * x

通过令 x=1 并让 h 逐渐接近 0,导数定义中的差商:

f(x+h)f(x)h

其数值结果逐渐接近 2。虽然这个实验不是严格的数学证明,但可以观察到,当 x=1 时,导数:

u=f(1)=2

下面定义一个函数,用于计算导数的数值近似:

python
def numerical_lim(f, x, h):
    return (f(x + h) - f(x)) / h

h0.1 开始,每次缩小为原来的十分之一:

python
h = 0.1

for i in range(5):
    print(
        f'h={h:.5f}, '
        f'numerical limit={numerical_lim(f, 1, h):.5f}'
    )
    h *= 0.1

输出:

text
h=0.10000, numerical limit=2.30000
h=0.01000, numerical limit=2.03000
h=0.00100, numerical limit=2.00300
h=0.00010, numerical limit=2.00030
h=0.00001, numerical limit=2.00003

可以看到,随着 h 越来越接近 0,数值近似结果越来越接近 2

让我们熟悉一下导数的几个等价符号。给定:

y=f(x)

其中 xy 分别是函数 f 的自变量和因变量。以下表达式是等价的:

f(x)=y=dydx=dfdx=ddxf(x)=Df(x)=Dxf(x)

其中符号 ddxD微分运算符,表示微分操作。

我们可以使用以下规则来求常见函数的导数:

  • 常数求导:DC=0 ,其中 C 是一个常数。
  • 幂函数求导,即幂律(power rule):Dxn=nxn1 其中 n 是任意实数。
  • 指数函数求导:Dex=ex
  • 对数函数求导:Dln(x)=1x

为了对由一些常见函数组成的函数进行微分,下面的一些法则非常有用。假设函数 fg 都是可微的,C 是一个常数。

常数倍乘法则

ddx[Cf(x)]=Cddxf(x)

加法法则

ddx[f(x)+g(x)]=ddxf(x)+ddxg(x)

乘法法则

ddx[f(x)g(x)]=f(x)ddx[g(x)]+g(x)ddx[f(x)]

除法法则

ddx[f(x)g(x)]=g(x)ddx[f(x)]f(x)ddx[g(x)][g(x)]2

现在我们可以应用上述几个法则来计算:

u=f(x)=3ddxx24ddxx=6x4

x=1,我们有:u=2 在这个实验中,数值结果接近 2,这一点得到了本节前面实验的支持。当 x=1 时,此导数也是曲线 u=f(x) 切线的斜率。

为了对导数的这种解释进行可视化,我们将使用 matplotlib。这是一个 Python 中流行的绘图库。

要配置 matplotlib 生成图形的属性,我们需要定义几个函数。在下面,use_svg_display 函数指定 matplotlib 软件包输出 SVG 图表,以获得更清晰的图像。

注意,注释 #@save 是一个特殊标记,会将对应的函数、类或语句保存在 d2l 包中。因此,以后无须重新定义,就可以直接调用它们,例如:d2l.use_svg_display()

python
def use_svg_display():  #@save
    """使用 SVG 格式在 Jupyter 中显示绘图"""
    backend_inline.set_matplotlib_formats('svg')

我们定义 set_figsize 函数来设置图表大小。注意,这里可以直接使用 d2l.plt,因为导入语句 from matplotlib import pyplot as plt 已标记为保存到 d2l 包中。

python
def set_figsize(figsize=(3.5, 2.5)):  #@save
    """设置 matplotlib 的图表大小"""
    use_svg_display()
    d2l.plt.rcParams['figure.figsize'] = figsize

下面的 set_axes 函数用于设置由 matplotlib 生成图表的轴属性。

python
#@save
def set_axes(axes, xlabel, ylabel, xlim, ylim, xscale, yscale, legend):
    """设置 matplotlib 的轴"""
    axes.set_xlabel(xlabel)
    axes.set_ylabel(ylabel)
    axes.set_xscale(xscale)
    axes.set_yscale(yscale)
    axes.set_xlim(xlim)
    axes.set_ylim(ylim)

    if legend:
        axes.legend(legend)

    axes.grid()

通过这三个用于图形配置的函数,定义一个plot函数来简洁地绘制多条曲线, 因为我们需要在整个书中可视化许多曲线。

python
#@save
def plot(X, Y=None, xlabel=None, ylabel=None, legend=None, xlim=None,
         ylim=None, xscale='linear', yscale='linear',
         fmts=('-', 'm--', 'g-.', 'r:'), figsize=(3.5, 2.5), axes=None):
    """绘制数据点"""
    if legend is None:
        legend = []

    set_figsize(figsize)
    axes = axes if axes else d2l.plt.gca()

    # 如果X有一个轴,输出True
    def has_one_axis(X):
        return (hasattr(X, "ndim") and X.ndim == 1 or isinstance(X, list)
                and not hasattr(X[0], "__len__"))

    if has_one_axis(X):
        X = [X]
    if Y is None:
        X, Y = [[]] * len(X), X
    elif has_one_axis(Y):
        Y = [Y]
    if len(X) != len(Y):
        X = X * len(Y)
    axes.cla()
    for x, y, fmt in zip(X, Y, fmts):
        if len(x):
            axes.plot(x, y, fmt)
        else:
            axes.plot(y, fmt)
    set_axes(axes, xlabel, ylabel, xlim, ylim, xscale, yscale, legend)

现在我们可以绘制函数 u=f(x) 及其在 x=1 处的切线 y=2x3 ,其中系数 2 是切线的斜率。

python
x = np.arange(0, 3, 0.1)

plot(
    x,
    [f(x), 2 * x - 3],
    'x',
    'f(x)',
    legend=['f(x)', 'Tangent line (x=1)']
)

图中:

  • 蓝色曲线表示函数 f(x)=3x24x
  • 紫色虚线表示函数在 x=1 处的切线。
  • 切线斜率为:f(1)=2

因此切线方程为 yf(1)=f(1)(x1)

因为 f(1)=3(1)24(1)=1 ,所以 y+1=2(x1)

整理得到 y=2x3

4.2. 偏导数

到目前为止,我们只讨论了仅含一个变量的函数的微分。在深度学习中,函数通常依赖于许多变量。因此,我们需要将微分的思想推广到多元函数(multivariate function)上。

y=f(x1,x2,,xn) 是一个具有 n 个变量的函数。y 关于第 i 个变量 xi偏导数(partial derivative)为:

yxi=limh0f(x1,,xi1,xi+h,xi+1,,xn)f(x1,,xi,,xn)h

为了计算 yxi 我们可以将 x1,,xi1,xi+1,,xn 看作常数,并计算 y 关于 xi 的导数。

对于偏导数的表示,以下写法是等价的:

yxi=fxi=fxi=fi=Dif=Dxif

4.3. 梯度

我们可以连接一个多元函数对其所有变量的偏导数,以得到该函数的梯度(gradient)向量。

具体而言,设函数 f:RnR 的输入是一个 n 维向量:

x=[x1,x2,,xn]

并且输出是一个标量。函数 f(x) 相对于 x 的梯度,是一个包含 n 个偏导数的向量:

xf(x)=[f(x)x1,f(x)x2,,f(x)xn]

其中,xf(x) 通常在没有歧义时简写为 f(x)

假设 xn 维向量,在微分多元函数时经常使用以下规则:

  • 对于所有 ARm×n
xAx=A
  • 对于所有 ARn×m
xxA=A
  • 对于所有 ARn×n
xxAx=(A+A)x
  • 向量平方范数的梯度:
xx22=xxx=2x

同样,对于任意矩阵 X ,都有:

XXF2=2X

正如后面将看到的,梯度对于设计深度学习中的优化算法非常重要。

4.4. 链式法则

然而,上面的方法可能很难找到梯度。这是因为在深度学习中,多元函数通常是复合函数(composite function),所以很难直接应用前述规则对这些函数求导。

幸运的是,链式法则可以用于求复合函数的导数。

先考虑单变量函数。假设函数 y=f(u)u=g(x) 都是可微的。根据链式法则:

dydx=dydududx

现在考虑一个更一般的场景,即函数具有任意数量的变量。

假设可微函数 y 依赖于变量:

u1,u2,,um

其中每个可微函数 ui 又依赖于变量:

x1,x2,,xn

因此,y 最终是 x1,x2,,xn 的函数。对于任意:

i=1,2,,n

链式法则给出:

yxi=yu1u1xi+yu2u2xi++yumumxi

4.5. 小结

  • 微分和积分是微积分的两个分支,前者可以应用于深度学习中的优化问题。
  • 导数可以被解释为函数相对于其变量的瞬时变化率,它也是函数曲线的切线的斜率。
  • 梯度是一个向量,其分量是多变量函数相对于其所有变量的偏导数。
  • 链式法则可以用来微分复合函数。

5. 自动微分

正如在微积分部分中所说,求导是几乎所有深度学习优化算法的关键步骤。虽然求导的计算很简单,只需要一些基本的微积分,但对于复杂的模型,手工进行更新是一件很痛苦的事情,而且经常容易出错。

深度学习框架通过自动计算导数,即自动微分(automatic differentiation)来加快求导。

实际中,根据设计好的模型,系统会构建一个计算图(computational graph),来跟踪哪些数据通过哪些操作组合起来产生输出。自动微分使系统能够随后反向传播梯度。

这里,反向传播(backpropagate)意味着跟踪整个计算图,填充关于每个参数的偏导数。

5.1. 简单举例

作为一个演示例子,假设我们想对函数:

y=2xx

关于列向量 x 求导。

首先,我们创建变量 x,并为其分配一个初始值。

python
import torch

x = torch.arange(4.0)
x

输出:

python
tensor([0., 1., 2., 3.])

在计算 y 关于 x 的梯度之前,需要一个地方来存储梯度。

重要的是,我们不会在每次对一个参数求导时都分配新的内存,因为在模型训练过程中,通常会成千上万次地更新相同的参数。每次都分配新的内存可能很快耗尽内存。

注意,一个标量函数关于向量 x 的梯度是一个向量,并且与 x 具有相同的形状。

python
x.requires_grad_(True)
# 等价于:x = torch.arange(4.0, requires_grad=True)

x.grad

初始时:

python
None

现在计算:

python
y = 2 * torch.dot(x, x)
y

输出:

python
tensor(28., grad_fn=<MulBackward0>)

因为:

xx===========================02+12+22+3214

所以:y=2×14=28

接下来,通过调用反向传播函数,自动计算 y 关于 x 每个分量的梯度

python
y.backward()
x.grad

输出:

python
tensor([0., 4., 8., 12.])

函数 y=2xx 关于 x 的梯度为 4x

验证计算结果:

python
x.grad == 4 * x

输出:

python
tensor([True, True, True, True])

现在计算 x 的另一个函数。

python
# 在默认情况下,PyTorch 会累积梯度,我们需要清除之前的值
x.grad.zero_()

y = x.sum()
y.backward()

x.grad

输出:

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

因为:

y=ixi

所以 y 关于 x 中每个元素的偏导数都是:

yxi=1

因此梯度为:

xy=[1111]

5.2. 非标量变量的反向传播

y 不是标量时,向量 y 关于向量 x 的导数最自然的解释是一个矩阵。对于高阶和高维的 yx,求导的结果可以是一个高阶张量。

然而,虽然这些更奇特的对象确实出现在高级机器学习中,包括深度学习,但当调用向量的反向计算时,我们通常会试图计算一批训练样本中每个组成部分的损失函数的导数。

这里,我们的目的不是计算微分矩阵,而是单独计算批量中每个样本的偏导数之和

python
# 对非标量调用 backward 需要传入一个 gradient 参数,该参数指定微分函数关于 self 的梯度。
# 本例只想求偏导数的和,因此传入全 1 的梯度是合适的。

x.grad.zero_()
y = x * x

# 等价于:y.backward(torch.ones(len(x)))

y.sum().backward()
x.grad

输出:

python
tensor([0., 2., 4., 6.])

因为:

yi=xi2

所以:

yixi=2xi

当:

x=[0 1 2 3]

时:

xiyi=============================2x[0 2 4 6]

5.3. 分离计算

有时,我们希望将某些计算移动到记录的计算图之外

例如,假设 y 是作为 x 的函数计算的,而 z 则是作为 yx 的函数计算的。我们想计算 z 关于 x 的梯度,但由于某种原因,希望将 y 视为常数,只考虑 xy 计算完成后所发挥的作用。

这里可以分离 y,得到一个新变量 u。该变量与 y 具有相同的值,但丢弃了计算图中关于如何计算 y 的信息。

换句话说,梯度不会通过 u 继续向后传播到 x

因此,下面的反向传播计算的是 z=ux

关于 x 的梯度,并将 u 作为常数处理,而不是计算 z=xxx 关于 x 的梯度。

python
x.grad.zero_()

y = x * x
u = y.detach()
z = u * x

z.sum().backward()
x.grad == u

输出:

python
tensor([True, True, True, True])

因为在计算 z 时,u 被视为常数,所以:

xizi=u

由于计算图仍然记录了 y 的计算过程,因此可以随后在 y 上调用反向传播,得到y=x*x关于的x的导数,即2*x

python
x.grad.zero_()

y.sum().backward()
x.grad == 2 * x

输出:

python
tensor([True, True, True, True])

5.4. Python控制流的梯度计算

使用自动微分的一个好处是:即使构建函数的计算图需要通过 Python 控制流,例如条件、循环或任意函数调用,我们仍然可以计算最终变量的梯度

在下面的代码中,while 循环的迭代次数和 if 语句的执行结果都取决于输入 a 的值。

python
def f(a):
    b = a * 2

    while b.norm() < 1000:
        b = b * 2

    if b.sum() > 0:
        c = b
    else:
        c = 100 * b

    return c

现在计算梯度:

python
a = torch.randn(size=(), requires_grad=True)
# torch.randn(size=()) 生成一个服从标准正态分布的随机标量
# size() 表示创建的是 0 维标量张量
d = f(a)
d.backward()

我们可以分析上面定义的函数 f。需要注意的是,它关于输入 a 是分段线性的。

换言之,对于任意输入 a,都存在一个常量标量 k,使得:

f(a)=ka

其中,k 的具体值取决于输入 a 以及程序执行的控制流。

因为:

d=f(a)=ka

所以:

ddaf(a)=k=da

因此,可以用下面的代码验证自动微分计算的梯度是否正确:

python
a.grad == d / a

输出:

python
tensor(True)

5.5. 小结

深度学习框架可以自动计算导数:我们首先将梯度附加到想要对其计算偏导数的变量上,然后记录目标值的计算,执行它的反向传播函数,并访问得到的梯度。

6. 概率

简单地说,机器学习就是做出预测。

根据病人的临床病史,我们可能想预测他们在下一年心脏病发作的概率。 在飞机喷气发动机的异常检测中,我们想要评估一组发动机读数为正常运行情况的概率有多大。 在强化学习中,我们希望智能体(agent)能在一个环境中智能地行动。 这意味着我们需要考虑在每种可行的行为下获得高奖励的概率。 当我们建立推荐系统时,我们也需要考虑概率。 例如,假设我们为一家大型在线书店工作,我们可能希望估计某些用户购买特定图书的概率。 为此,我们需要使用概率学。 有完整的课程、专业、论文、职业、甚至院系,都致力于概率学的工作。 所以很自然地,我们在这部分的目标不是教授整个科目。 相反,我们希望教给读者基础的概率知识,使读者能够开始构建第一个深度学习模型, 以便读者可以开始自己探索它。

现在让我们更认真地考虑第一个例子:根据照片区分猫和狗。 这听起来可能很简单,但对于机器却可能是一个艰巨的挑战。 首先,问题的难度可能取决于图像的分辨率。

如图所示,虽然人类很容易以 160×160 像素的分辨率识别猫和狗,但它在 40×40 像素上变得具有挑战性,而在 10×10 像素下几乎是不可能的。

换句话说,我们在很远的距离(从而降低分辨率)区分猫和狗的能力可能会变为猜测。概率给了我们一种正式的途径来说明我们的确定性水平。

如果我们完全肯定图像是一只猫,我们说标签 y 是“猫”的概率,表示为:

P(y=“猫”)=1

如果我们没有证据表明 y=“猫”y=“狗”,那么我们可以说这两种可能性是相等的,即:

P(y=“猫”)=P(y=“狗”)=0.5

如果我们不十分确定图像描绘的是一只猫,我们可以将概率赋值为:

0.5<P(y=“猫”)<1

现在考虑第二个例子:给出一些天气监测数据,我们想预测明天北京下雨的概率。如果是夏天,下雨的概率是 0.5

在这两种情况下,我们都不确定结果,但这两种情况之间有一个关键区别:

  • 在第一种情况中,图像实际上是狗或猫二选一。
  • 在第二种情况下,结果实际上是一个随机的事件。

因此,概率是一种灵活的语言,用于说明我们的确定程度,并且它可以有效地应用于广泛的领域中。

6.1. 基本概率论

假设我们掷骰子,想知道看到 1 的概率有多大,而不是看到另一个数字。如果骰子是公平的,那么所有六个结果 {1,,6} 都有相同的可能发生,因此我们可以说 1 发生的概率为 16

然而现实生活中,对于我们从工厂收到的真实骰子,我们需要检查它是否有瑕疵。检查骰子的唯一方法是多次投掷并记录结果。

对于每个骰子,我们将观察到 {1,,6} 中的一个值。对于每个值,一种自然的方法是将它出现的次数除以投掷的总次数,即此事件(event)概率的估计值。

大数定律(law of large numbers)告诉我们:随着投掷次数的增加,这个估计值会越来越接近真实的潜在概率。让我们用代码试一试!

首先,我们导入必要的软件包。

python
%matplotlib inline
import torch
from torch.distributions import multinomial
from d2l import torch as d2l

在统计学中,我们把从概率分布中抽取样本的过程称为抽样(sampling)。

笼统来说,可以把分布(distribution)看作对事件的概率分配。稍后我们将给出更加正式的定义。

将概率分配给一些离散选择的分布称为多项分布(multinomial distribution)。

为了抽取一个样本,即掷骰子,我们只需传入一个概率向量。输出是另一个相同长度的向量:它在索引 i 处的值是采样结果中出现 i 的次数。

python
fair_probs = torch.ones([6]) / 6
multinomial.Multinomial(1, fair_probs).sample()

输出:

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

在估计一个骰子的公平性时,我们希望从同一分布中生成多个样本。如果使用 Python 的 for 循环来完成这个任务,速度会慢得惊人。

因此,我们使用深度学习框架的函数同时抽取多个样本,得到我们想要的任意形状的独立样本数组。

python
multinomial.Multinomial(10, fair_probs).sample()

输出:

text
tensor([5., 3., 2., 0., 0., 0.])

现在我们知道如何对骰子进行采样,我们可以模拟 1000 次投掷。然后,我们可以统计 1000 次投掷后,每个数字被投中了多少次。

具体来说,我们计算相对频率,以作为真实概率的估计。

python
# 将结果存储为 32 位浮点数以进行除法
counts = multinomial.Multinomial(1000, fair_probs).sample()
counts / 1000  # 相对频率作为估计值

输出:

text
tensor([0.1550, 0.1820, 0.1770, 0.1710, 0.1600, 0.1550])

因为我们是从一个公平的骰子中生成的数据,所以我们知道每个结果都有真实概率 16 大约为 0.167。因此,上面输出的估计值看起来并不差。

我们也可以看到这些概率如何随着时间的推移收敛到真实概率。让我们进行 500 组实验,每组抽取 10 个样本。

python
counts = multinomial.Multinomial(10, fair_probs).sample((500,))
cum_counts = counts.cumsum(dim=0)
estimates = cum_counts / cum_counts.sum(dim=1, keepdims=True)

d2l.set_figsize((6, 4.5))

for i in range(6):
    d2l.plt.plot(
        estimates[:, i].numpy(),
        label=("P(die=" + str(i + 1) + ")")
    )

d2l.plt.axhline(
    y=0.167,
    color="black",
    linestyle="dashed"
)

d2l.plt.gca().set_xlabel("Groups of experiments")
d2l.plt.gca().set_ylabel("Estimated probability")
d2l.plt.legend()

每条实线对应于骰子的6个值中的一个,并给出骰子在每组实验后出现值的估计概率。 当我们通过更多的实验获得更多的数据时,这6条实体曲线向真实概率收敛。

6.1.1. 概率论公理

在处理骰子掷出时,我们将集合

S={1,2,3,4,5,6}

称为样本空间(sample space)或结果空间(outcome space),其中每个元素都是结果(outcome)。

事件(event)是一组给定样本空间的随机结果。例如,“看到 5” {5} 和“看到奇数” {1,3,5} 都是掷出骰子的有效事件。

注意,如果一个随机实验的结果在 A 中,则事件 A 已经发生。也就是说,如果投掷出 3 点,因为

3{1,3,5}

我们可以说,“看到奇数”的事件发生了。

概率(probability)可以被认为是将集合映射到真实值的函数。在给定的样本空间 S 中,事件 A 的概率表示为 P(A) 并满足以下属性:

  • 对于任意事件 A,其概率不会是负数,即:

    P(A)0
  • 整个样本空间的概率为 1,即:

    P(S)=1
  • 对于互斥(mutually exclusive)事件,即对于所有 ij 都有:

    AiAj=

    的任意可数序列 A1,A2,,序列中任意一个事件发生的概率等于它们各自发生概率之和,即:

    P(i=1Ai)=i=1P(Ai)

以上也是概率论的公理,由科尔莫戈罗夫于 1933 年提出。有了这个公理系统,我们可以避免任何关于随机性的哲学争论;相反,我们可以用数学语言严格地推理。

例如,假设事件 A1 为整个样本空间,且当所有 i>1 时都有:

Ai=

那么我们可以证明:

P()=0

即不可能发生事件的概率是 0。

6.1.2. 随机变量

在我们掷骰子的随机实验中,我们引入了随机变量(random variable)的概念。随机变量几乎可以是任何数量,并且它可以在随机实验的一组可能性中取一个值。

考虑一个随机变量 X,其值在掷骰子的样本空间 S={1,2,3,4,5,6} 中。我们可以将事件“看到一个 5”表示为:{X=5}X=5

其概率表示为:P({X=5})P(X=5)

通过 P(X=a),我们区分了随机变量 XX 可以采取的值,例如 a

然而,这可能会导致烦琐的表示。为了简化符号,一方面,我们可以将 P(X) 表示为随机变量 X 上的分布(distribution):分布告诉我们 X 获得某一个值的概率。

另一方面,我们可以简单地用 P(a) 表示随机变量取值 a 的概率。

由于概率论中的事件是来自样本空间的一组结果,因此我们可以为随机变量指定值的可取范围。例如:P(1X3)

表示事件 {1X3} ,即 {X=1,2, or ,3} 的概率。

等价地,

P(1X3) 表示随机变量 X 从集合 {1,2,3} 中取值的概率。

请注意,离散(discrete)随机变量,例如骰子的每一面,和连续(continuous)随机变量,例如人的体重和身高,之间存在细微的区别。

现实生活中,测量两个人是否具有完全相同的身高没有太大意义。如果我们进行足够精确的测量,最终会发现这个星球上没有两个人具有完全相同的身高。

在这种情况下,询问某人的身高是否落入给定的区间中,例如是否在 1.79 米1.81 米 之间,更有意义。

在这些情况下,我们将这个看到某个数值的可能性量化为密度(density)。

高度恰好为 1.80 米 的概率为 0,但密度不是 0。在任何两个不同高度之间的区间,我们都有非零的概率。

在本节的其余部分中,我们将考虑离散空间中的概率。连续随机变量的概率可以参考深度学习数学附录中“随机变量”一节。

6.2. 处理多个随机变量

很多时候,我们会考虑多个随机变量。例如,我们可能需要对疾病和症状之间的关系进行建模。给定一个疾病和一个症状,比如“流感”和“咳嗽”,以某个概率存在或不存在于某个患者身上。我们需要估计这些概率以及概率之间的关系,以便我们可以运用我们的推断来实现更好的医疗服务。

再举一个更复杂的例子:图像包含数百万像素,因此有数百万个随机变量。在许多情况下,图像会附带一个标签(label),标识图像中的对象。我们也可以将标签视为一个随机变量。我们甚至可以将所有元数据视为随机变量,例如位置、时间、光圈、焦距、ISO、对焦距离和相机类型。所有这些都是联合发生的随机变量。当我们处理多个随机变量时,会有若干个变量是我们感兴趣的。

6.2.1. 联合概率

第一个被称为联合概率(joint probability)P(A=a,B=b)。给定任意值 ab,联合概率可以回答:A=aB=b 同时满足的概率是多少?

请注意,对于任何 ab 的取值,P(A=a,B=b)P(A=a)。这是确定的,因为要同时发生 A=aB=bA=a 就必须发生;B=b 也必须发生。

反之亦然。因此,A=aB=b 同时发生的可能性不大于 A=aB=b 单独发生的可能性。

6.2.2. 条件概率

联合概率的不等式带给我们一个有趣的比率:

0P(A=a,B=b)P(A=a)1

我们将这个比率称为条件概率(conditional probability),并用 P(B=bA=a) 表示。它表示:在 A=a 已经发生的前提下,B=b 发生的概率。

6.2.3. 贝叶斯定理

使用条件概率的定义,我们可以得到统计学中最有用的方程之一:贝叶斯定理(Bayes' theorem)。

根据乘法法则(multiplication rule),可得到 P(A,B)=P(BA)P(A)。根据对称性,也可得到 P(A,B)=P(AB)P(B)

假设 P(B)>0,求解其中一个条件变量,可以得到:

P(AB)=P(BA)P(A)P(B)

请注意,这里使用了紧凑的表示法。其中,P(A,B) 是一个联合分布(joint distribution),P(AB) 是一个条件分布(conditional distribution)。这种分布可以在给定值 A=aB=b 上进行求值。

6.2.4. 边际化

为了能进行事件概率求和,我们需要求和法则(sum rule),即 B 的概率相当于计算 A 的所有可能选择,并将所有选择的联合概率聚合在一起:

P(B)=AP(A,B)

这也称为边际化(marginalization)。边际化结果的概率或分布称为边际概率(marginal probability)或边际分布(marginal distribution)。

6.3. 期望和方差

为了概括概率分布的关键特征,我们需要一些测量方法。一个随机变量 X 的期望(expectation),或平均值(average),表示为

E[X]=xxP(X=x)

当函数 f(x) 的输入是从分布 P 中抽取的随机变量时,f(x) 的期望值为

ExP[f(x)]=xf(x)P(x)

在许多情况下,我们希望衡量随机变量 X 与其期望值的偏置。这可以通过方差来量化:

Var[X]=E[(XE[X])2]=E[X2]E[X]2

方差的平方根称为标准差(standard deviation)。随机变量函数的方差衡量的是:当从该随机变量分布中采样不同值时,函数值偏离该函数期望值的程度,即

Var[f(x)]=E[(f(x)E[f(x)])2]

6.4. 小结

  • 我们可以从概率分布中采样。
  • 我们可以使用联合分布、条件分布、贝叶斯定理、边缘化和独立性假设来分析多个随机变量。
  • 期望和方差为概率分布的关键特征提供了实用的度量形式。

评论区

可以在这里补充理解、提问,或记录这一章的讨论。