🔎大家好,我是Sonhhxg_柒,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流🔎
📝个人主页-Sonhhxg_柒的博客_CSDN博客 📃
🎁欢迎各位→点赞👍 + 收藏⭐️ + 留言📝
📣系列专栏 - 机器学习【ML】 自然语言处理【NLP】 深度学习【DL】
🖍foreword
✔说明⇢本人讲解主要包括Python、机器学习(ML)、深度学习(DL)、自然语言处理(NLP)等内容。
如果你对这个系列感兴趣的话,可以关注订阅哟👋
文章目录
从头开始构建神经网络层
神经元建模
从头开始矩阵乘法
逐元素算术
广播
用标量广播
将向量广播到矩阵
广播规则
爱因斯坦求和
向前和向后传球
定义和初始化层
梯度和向后传递
重构模型
走向 PyTorch
结论
本章开始了一段旅程,我们将深入挖掘前几章中使用的模型的内部结构。我们将涵盖许多我们以前见过的相同内容,但这一次我们将更仔细地研究实施细节,而不是更密切地关注事物如何以及为何如此的实际问题。
我们将从头开始构建所有内容,仅使用对张量的基本索引。我们将从头开始编写一个神经网络,然后手动实现反向传播,以便我们在调用loss.backward
. 我们还将看到如何使用 允许我们指定自己的前向和后向计算的自定义autograd函数来扩展 PyTorch。
从头开始构建神经网络层
让我们从刷新我们对如何在基本神经网络中使用矩阵乘法的理解开始。自从 我们正在从头开始构建所有内容,最初我们只使用纯 Python(除了索引到 PyTorch 张量),然后在我们了解如何创建它之后用 PyTorch 功能替换纯 Python。
神经元建模
神经元接收给定数量的输入并具有内部权重 对于他们每个人。它将这些加权输入相加以产生输出并添加内部偏差。在数学上,这可以写成
如果我们命名我们的输入(x1,⋯,xn), 我们的权重 (w1,⋯,wn),以及我们的偏见b. 在代码中,这转化为以下内容:
output = sum([x*w for x,w in zip(inputs,weights)]) + bias
然后将该输出输入到称为激活函数的非线性函数中,然后再发送到另一个神经元。在深度学习中,最常见的是整流线性单元,或 ReLU,正如我们所见,这是一种奇特的表达方式:
def relu(x): return x if x >= 0 else 0
然后通过在连续层中堆叠大量这些神经元来构建深度学习模型。我们创建具有一定数量神经元(称为隐藏大小)的第一层,并将所有输入链接到这些神经元中的每一个。这样的层通常称为全连接层 或密集层(密集连接),或线性层。
它要求您为input
具有给定 的每个神经元计算weight
点积:
sum([x*w for x,w in zip(input,weight)])
如果你学过一点线性代数,你可能还记得当你做矩阵乘法时会有很多这样的点积。更准确地说,如果我们的输入在x
一个大小为batch_size
by 的矩阵中n_inputs
,并且如果我们将神经元的权重分组在一个大小为n_neurons来自于
n_input
的矩阵中w
(每个神经元必须具有与其输入相同数量的权重)以及大小为b向量n_neurons
中的所有偏差,那么这个全连接层的输出是
y = x @ w.t() + b
其中@
表示矩阵乘积,w.t()
是 的转置矩阵w
。然后输出y
的大小batch_size
为 n_neurons
,并且在位置上(i,j)
我们有这个(对于那里的数学家):
或者在代码中:
y[i,j] = sum([a * b for a,b in zip(x[i,:],w[j,:])]) + b[j]
转置是必要的,因为在矩阵乘积的数学定义中m @ n
,系数(i,j)
如下:
sum([a * b for a,b in zip(m[i,:],n[:,j])])
所以我们需要的最基本的操作是矩阵乘法,因为它隐藏在神经网络的核心中。
从头开始矩阵乘法
在我们允许自己使用它的 PyTorch 版本之前,让我们编写一个函数来计算两个张量的矩阵乘积。 我们将仅使用 PyTorch 张量中的索引:
import torch
from torch import tensor
我们需要三个嵌套for
循环:一个用于行索引,一个用于列索引,一个用于内部求和。ac
和ar
分别代表 的列a
数和行数a
(遵循b
相同的约定),我们通过检查列数a
与行数相同b
来确保计算矩阵乘积是可能的:
def matmul(a,b):
ar,ac = a.shape # n_rows * n_cols
br,bc = b.shape
assert ac==br
c = torch.zeros(ar, bc)
for i in range(ar):
for j in range(bc):
for k in range(ac): c[i,j] += a[i,k] * b[k,j]
return c
为了对此进行测试,我们将假装(使用随机矩阵)我们正在处理一小批 5 个 MNIST 图像,将其展平为28*28
向量,并使用线性模型将它们转换为 10 个激活:
m1 = torch.randn(5,28*28)
m2 = torch.randn(784,10)
让我们使用 Jupyter 的“魔法”命令来计时我们的函数%time
:
%time t1=matmul(m1, m2)
CPU times: user 1.15 s, sys: 4.09 ms, total: 1.15 s Wall time: 1.15 s
看看它与 PyTorch 的内置相比如何@
?
%timeit -n 20 t2=m1@m2
14 µs ± 8.95 µs per loop (mean ± std. dev. of 7 runs, 20 loops each)
正如我们所见,在 Python 中,三个嵌套循环是一个坏主意!Python 是一种慢速语言,这不会很有效。我们在这里看到 PyTorch 比 Python 快大约 100,000 倍——这甚至在我们开始使用 GPU 之前!
这种差异从何而来?PyTorch 没有用 Python 编写其矩阵乘法,而是用 C++ 编写以使其速度更快。一般来说,每当我们对张量进行计算时,我们都需要将它们矢量化,以便我们可以利用 PyTorch 的速度优势,通常使用两种技术:逐元素算术和广播。
逐元素算术
所有基本运算符(+
, -
, *
, /
, >
, <
, ==
)都可以按元素应用。 这意味着如果我们写a+b
两个具有相同形状的张量a
,b
我们将得到一个由 和 的元素之a
和组成的张量b
:
a = tensor([10., 6, -4])
b = tensor([2., 8, 7])
a + b
tensor([12., 14., 3.])
布尔运算符将返回一个布尔数组:
a < b
tensor([False, True, True])
如果我们想知道 的每个元素a
是否小于 中的相应元素b
,或者两个张量是否相等,我们需要将这些逐元素运算与 结合起来torch.all
:
(a < b).all(), (a==b).all()
(tensor(False), tensor(False))
sum、mean
和all
等约简操作 返回只有一个元素的张量,称为 rank-0 张量。如果你想将其转换为普通的 Python 布尔值或数字,你需要调用.item
:
(a + b).mean().item()
9.666666984558105
elementwise 操作适用于任何等级的张量,只要它们具有相同的形状:
m = tensor([[1., 2, 3], [4,5,6], [7,8,9]])
m*m
tensor([[ 1., 4., 9.], [16., 25., 36.], [49., 64., 81.]])
但是,您不能对形状不同的张量执行元素运算(除非它们是可广播的,如下一节所述):
n = tensor([[1., 2, 3], [4,5,6]])
m*n
RuntimeError: The size of tensor a (3) must match the size of tensor b (2) at dimension 0
使用逐元素算法,我们可以删除三个嵌套循环之一:我们可以在对所有i
元素 求和之前将a
对应于由 PyTorch 以 C 速度执行。j
b
要访问一列或一行,我们可以简单地写a[i,:]
or b[:,j]
。手段拿走了那个空间的 :
一切。我们可以通过传递一个范围来限制这一点,只取该维度的一部分,比如 1:5
,而不仅仅是:
。在那种情况下,我们将采用第 1 列到第 4 列中的元素(第二个数字不包含在内)。
一种简化是我们始终可以省略尾随冒号,因此 a[i,:]
可以缩写为a[i]
. 考虑到所有这些,我们可以编写一个新版本的矩阵乘法:
def matmul(a,b):
ar,ac = a.shape
br,bc = b.shape
assert ac==br
c = torch.zeros(ar, bc)
for i in range(ar):
for j in range(bc): c[i,j] = (a[i] * b[:,j]).sum()
return c
%timeit -n 20 t3 = matmul(m1,m2)
1.7 ms ± 88.1 µs per loop (mean ± std. dev. of 7 runs, 20 loops each)
我们已经快了约 700 倍,只是通过删除那个内部for
循环!而这仅仅是开始 — 通过广播,我们可以删除另一个循环并获得更重要的加速。
广播
正如我们在第 4 章中讨论的那样,广播是Numpy 库引入的一个术语,它 描述了在算术运算中如何处理不同秩的张量。例如,显然没有办法将 3×3 矩阵与 4×5 矩阵相加,但是如果我们想将一个标量(可以表示为 1×1 张量)与矩阵相加怎么办?还是一个 3×4 矩阵的大小为 3 的向量?在这两种情况下,我们都可以找到一种方法来理解这种操作。
当尝试进行逐元素操作时形状兼容时,广播给出了特定的规则来编码,以及如何扩展较小形状的张量以匹配较大形状的张量。如果您希望能够编写快速执行的代码,那么掌握这些规则是必不可少的。在本节中,我们将扩展之前对广播的处理以了解这些规则。
用标量广播
使用标量进行广播是最简单的广播类型。当我们有一个 张量a
和标量,我们只需想象一个与该标量相同形状的张 a
量并执行操作:
a = tensor([10., 6, -4])
a > 0
tensor([ True, True, False])
我们如何能够进行这种比较?0
正在广播以具有与 相同的尺寸a
。请注意,这是在没有在内存中创建一个全为零的张量的情况下完成的(那样效率很低)。
如果您想通过从整个数据集(矩阵)中减去平均值(标量)并除以标准差(另一个标量)来标准化数据集,这将很有用:
m = tensor([[1., 2, 3], [4,5,6], [7,8,9]])
(m - 5) / 2.73
tensor([[-1.4652, -1.0989, -0.7326], [-0.3663, 0.0000, 0.3663], [ 0.7326, 1.0989, 1.4652]])
如果您对矩阵的每一行有不同的均值怎么办?在这种情况下,您需要将向量广播到矩阵。
将向量广播到矩阵
c = tensor([10.,20,30])
m = tensor([[1., 2, 3], [4,5,6], [7,8,9]])
m.shape,c.shape
(torch.Size([3, 3]), torch.Size([3]))
m + c
tensor([[11., 22., 33.], [14., 25., 36.], [17., 28., 39.]])
这里 的元素c
被扩展成匹配的三行,使操作成为可能。同样,PyTorch 实际上不会c
在内存中创建三个副本。这是通过expand_as
幕后方法完成的:
c.expand_as(m)
tensor([[10., 20., 30.], [10., 20., 30.], [10., 20., 30.]])
如果我们查看相应的张量,我们可以询问其storage
属性(显示用于张量的内存的实际内容)以检查是否存储了无用的数据:
t = c.expand_as(m)
t.storage()
10.0 20.0 30.0 [torch.FloatStorage of size 3]
尽管张量正式有九个元素,但只有三个标量存储在内存中。这是可能的,这要归功于在该维度上将该维度的步幅设置为 0 的巧妙技巧(这意味着当 PyTorch 通过添加步幅查找下一行时,它不会移动):
t.stride(), t.shape
((0, 1), torch.Size([3, 3]))
由于m
是 3×3 的大小,因此有两种方式进行广播。它在最后一个维度上完成的事实是来自广播规则的约定,与我们对张量排序的方式无关。相反,如果我们这样做,我们会得到相同的结果:
c + m
tensor([[11., 22., 33.], [14., 25., 36.], [17., 28., 39.]])
事实上,它只能广播大小n
为矩阵的向量大小m
为n
:
c = tensor([10.,20,30])
m = tensor([[1., 2, 3], [4,5,6]])
c+m
tensor([[11., 22., 33.], [14., 25., 36.]])
这行不通:
c = tensor([10.,20])
m = tensor([[1., 2, 3], [4,5,6]])
c+m
RuntimeError: The size of tensor a (2) must match the size of tensor b (3) at dimension 1
如果我们想在另一个维度上广播,我们必须改变向量的形状,使其成为 3×1 矩阵。这是通过 unsqueeze
PyTorch 中的方法完成的:
c = tensor([10.,20,30])
m = tensor([[1., 2, 3], [4,5,6], [7,8,9]])
c = c.unsqueeze(1)
m.shape,c.shape
(torch.Size([3, 3]), torch.Size([3, 1]))
这一次,c
是在列侧展开的:
c+m
tensor([[11., 12., 13.], [24., 25., 26.], [37., 38., 39.]])
和以前一样,只有三个标量存储在内存中:
t = c.expand_as(m)
t.storage()
10.0 20.0 30.0 [torch.FloatStorage of size 3]
展开的张量具有正确的形状,因为列维度的步幅为 0:
t.stride(), t.shape
((1, 0), torch.Size([3, 3]))
通过广播,如果我们需要添加维度,它们默认在开始时添加。之前我们直播的时候,PyTorchc.unsqueeze(0)
在幕后执行:
c = tensor([10.,20,30])
c.shape, c.unsqueeze(0).shape,c.unsqueeze(1).shape
(torch.Size([3]), torch.Size([1, 3]), torch.Size([3, 1]))
该unsqueeze
命令可以用None
索引代替:
c.shape, c[None,:].shape,c[:,None].shape
(torch.Size([3]), torch.Size([1, 3]), torch.Size([3, 1]))
您始终可以省略尾随冒号,并且...
表示所有前面的维度:
c[None].shape,c[...,None].shape
(torch.Size([1, 3]), torch.Size([3, 1]))
这样,我们就可以删除for
矩阵乘法函数中的另一个循环。现在,我们可以使用广播与整个矩阵b[:,j]
相乘 a[i]
,而不是b
与 a[i]
相乘,然后对结果求和:
def matmul(a,b):
ar,ac = a.shape
br,bc = b.shape
assert ac==br
c = torch.zeros(ar, bc)
for i in range(ar):
# c[i,j] = (a[i,:] * b[:,j]).sum() # previous
c[i] = (a[i ].unsqueeze(-1) * b).sum(dim=0)
return c
%timeit -n 20 t4 = matmul(m1,m2)
357 µs ± 7.2 µs per loop (mean ± std. dev. of 7 runs, 20 loops each)
广播规则
当对两个张量进行操作时,PyTorch 会按元素比较它们的形状。它从尾随维度开始并向后工作,在遇到空维度时加 1。二维当下列其中一项为真时 是兼容的:
-
他们是平等的。
-
其中之一是 1,在这种情况下,广播该维度以使其与另一个维度相同。
数组不需要具有相同的维数。例如,如果您有一个 256×256×3 的 RGB 值数组,并且您想要按不同的值缩放图像中的每种颜色,则可以将图像乘以具有三个值的一维数组。根据广播规则排列这些数组的尾轴的大小表明它们是兼容的:
Image (3d tensor): 256 x 256 x 3 Scale (1d tensor): (1) (1) 3 Result (3d tensor): 256 x 256 x 3
然而,尺寸为 256×256 的二维张量与我们的图像不兼容:
Image (3d tensor): 256 x 256 x 3 Scale (2d tensor): (1) 256 x 256 Error
在我们之前使用 3×3 矩阵和大小为 3 的向量的示例中,广播是在行上完成的:
Matrix (2d tensor): 3 x 3 Vector (1d tensor): (1) 3 Result (2d tensor): 3 x 3
64 x 3 x 256 x 256
作为练习,当您需要使用三个元素的向量(一个用于均值,一个用于标准差)来归一化一批尺寸图像时,尝试确定要添加的维度(以及添加到何处)。
另一种简化张量操作的有用方法是使用爱因斯坦求和约定。
爱因斯坦求和
在使用 PyTorch 操作@
or之前torch.matmul
,我们还有最后一种方法可以实现矩阵乘法:爱因斯坦求和 ( einsum
). 这是以一般方式组合乘积和和的紧凑表示。我们写一个这样的等式:
ik,kj -> ij
左侧表示操作数维度,以逗号分隔。这里我们有两个张量,每个张量都有两个维度(i,k
和 k,j
)。右侧代表结果维度,所以这里我们有一个二维张量i,j
。
爱因斯坦求和记法规则如下:
-
如果左侧的重复索引不在右侧,则它们会隐式求和。
-
每个索引最多可以在左侧出现两次。
-
左侧的不重复索引必须出现在右侧。
所以在我们的例子中,因为k
是重复的,所以我们对该索引求和。最后,这个公式代表了我们把第一个张量中的所有系数( )乘以第二个张量中i,j
的系数( )的总和得到的矩阵……这就是矩阵乘积!i,k
k,j
以下是我们如何在 PyTorch 中对此进行编码:
def matmul(a,b): return torch.einsum('ik,kj->ij', a, b)
爱因斯坦求和是一种非常实用的表达涉及索引和产品总和的操作的方法。请注意,您可以在左侧设置一个成员。例如,
torch.einsum('ij->ji', a)
返回矩阵的转置a
。您还可以拥有三个或更多成员:
torch.einsum('bi,ij,bj->b', x, y, z)
这将返回一个大小为 的向量b
,其中第k
- 个坐标是 的总和a[k,i] b[i,j] c[k,j]
。当您因为批处理而具有更多维度时,此表示法特别方便。例如,如果您有两批矩阵并且想要计算每批的矩阵乘积,您可以这样做:
torch.einsum('bik,bkj->b', x, y)
让我们回到我们的新matmul
实现einsum
,看看它的速度:
%timeit -n 20 t5 = matmul(m1,m2)
68.7 µs ± 4.06 µs per loop (mean ± std. dev. of 7 runs, 20 loops each)
如您所见,它不仅实用,而且速度非常快。 einsum
通常是在 PyTorch 中执行自定义操作的最快方式,无需深入研究 C++ 和 CUDA。(但它通常不如精心优化的 CUDA 代码快,正如您从“从头开始的矩阵乘法”中的结果所见。)
向前和向后传球
正如我们在第 4 章中看到的,为了训练模型,我们需要计算给定损失相对于其参数的所有梯度,这被称为向后传递。在前传中, 我们根据矩阵乘积计算给定输入的模型输出。在定义第一个神经网络时,我们还将深入研究正确初始化权重的问题,这对于正确开始训练至关重要。
定义和初始化层
我们将首先以双层神经网络为例。正如我们所见,一层可以表示为y = x @ w + b
,x
我们的输入,y
我们的 输出,w
层的权重(如果我们不像以前那样转置,它的大小是输入数乘以神经元数),并且 b
是偏置向量:
def lin(x, w, b): return x @ w + b
我们可以将第二层堆叠在第一层之上,但由于在数学上两个线性运算的组合是另一个线性运算,因此只有在中间放置一些非线性的东西(称为激活函数)时,这才有意义。正如 本章开头提到的,在深度学习应用中,最常用的激活函数是 ReLU,它返回 和 的x
最大值0
。
我们不会在本章中实际训练我们的模型,因此我们将使用随机张量作为我们的输入和目标。假设我们的输入是 200 个大小为 100 的向量,我们将它们分组为一批,我们的目标是 200 个随机浮点数:
x = torch.randn(200, 100)
y = torch.randn(200)
对于我们的双层模型,我们将需要两个权重矩阵和两个偏置向量。假设我们的隐藏大小为 50,输出大小为 1(在这个玩具示例中,对于我们的输入之一,相应的输出是一个浮点数)。我们随机初始化权重并将偏差设为零:
w1 = torch.randn(100,50)
b1 = torch.zeros(50)
w2 = torch.randn(50,1)
b2 = torch.zeros(1)
那么我们第一层的结果就是这样:
l1 = lin(x, w1, b1)
l1.shape
torch.Size([200, 50])
请注意,此公式适用于我们的一批输入,并返回一批隐藏状态:l1
大小为 200(我们的批量大小)乘以 50(我们的隐藏大小)的矩阵。
但是,我们的模型初始化方式存在问题。要理解它,我们需要查看以下的均值和标准差 (std) l1
:
l1.mean(), l1.std()
(tensor(0.0019), tensor(10.1058))
均值接近于零,这是可以理解的,因为我们的输入矩阵和权重矩阵的均值都接近于零。但是标准偏差,代表我们的激活与平均值的距离,从 1 到 10。这是一个非常大的问题,因为它只有一层。现代神经网络可以有数百层,所以如果每一层都将我们的激活规模乘以 10,那么到最后一层结束时,我们将无法用计算机表示数字。
事实上,如果我们在x
大小为 100×100 的随机矩阵之间进行 50 次乘法运算,我们将得到:
x = torch.randn(200, 100)
for i in range(50): x = x @ torch.randn(100,100)
x[0:5,0:5]
tensor([[nan, nan, nan, nan, nan], [nan, nan, nan, nan, nan], [nan, nan, nan, nan, nan], [nan, nan, nan, nan, nan], [nan, nan, nan, nan, nan]])
结果nans
无处不在。所以也许我们矩阵的规模太大了,我们需要更小的权重?但如果我们使用太小的权重,我们就会遇到相反的问题——我们的激活值将从 1 变为 0.1,并且在 50 层之后我们将到处都是零:
x = torch.randn(200, 100)
for i in range(50): x = x @ (torch.randn(100,100) * 0.01)
x[0:5,0:5]
tensor([[0., 0., 0., 0., 0.], [0., 0., 0., 0., 0.], [0., 0., 0., 0., 0.], [0., 0., 0., 0., 0.], [0., 0., 0., 0., 0.]])
所以我们必须精确地缩放我们的权重矩阵,以便我们的激活的标准偏差保持在 1。我们可以计算出精确的值以在数学上使用,如 Xavier Glorot 和 Yoshua Bengio 在 “了解训练深度前馈神经网络的困难”。给定层的正确比例是1/nin, 在哪里 nin表示输入的数量。
在我们的例子中,如果我们有 100 个输入,我们应该将权重矩阵缩放 0.1:
x = torch.randn(200, 100)
for i in range(50): x = x @ (torch.randn(100,100) * 0.1)
x[0:5,0:5]
tensor([[ 0.7554, 0.6167, -0.1757, -1.5662, 0.5644], [-0.1987, 0.6292, 0.3283, -1.1538, 0.5416], [ 0.6106, 0.2556, -0.0618, -0.9463, 0.4445], [ 0.4484, 0.7144, 0.1164, -0.8626, 0.4413], [ 0.3463, 0.5930, 0.3375, -0.9486, 0.5643]])
最后,一些既不是零也不是nan
! 请注意我们激活的规模有多稳定,即使在这 50 个假层之后:
x.std()
tensor(0.7042)
如果您稍微研究一下 scale 的值,您会注意到即使是 0.1 的微小变化也会使您得到非常小或非常大的数字,因此正确初始化权重非常重要。
让我们回到我们的神经网络。由于我们的输入有些混乱,我们需要重新定义它们:
x = torch.randn(200, 100)
y = torch.randn(200)
对于我们的权重,我们将使用正确的比例,这被称为Xavier 初始化(或Glorot 初始化):
from math import sqrt
w1 = torch.randn(100,50) / sqrt(100)
b1 = torch.zeros(50)
w2 = torch.randn(50,1) / sqrt(50)
b2 = torch.zeros(1)
现在,如果我们计算第一层的结果,我们可以检查均值和标准差是否在控制之下:
l1 = lin(x, w1, b1)
l1.mean(),l1.std()
(tensor(-0.0050), tensor(1.0000))
很好。现在我们需要通过一个 ReLU,所以让我们定义一个。ReLU 移除负值并将它们替换为零,这是另一种说法,它将我们的张量钳制在零:
def relu(x): return x.clamp_min(0.)
我们通过这个传递我们的激活:
l2 = relu(l1)
l2.mean(),l2.std()
(tensor(0.3961), tensor(0.5783))
我们回到原点:激活的均值已变为 0.4(这是可以理解的,因为我们删除了负值),标准差降至 0.58。所以像以前一样,在几层之后我们可能会以零结尾:
x = torch.randn(200, 100)
for i in range(50): x = relu(x @ (torch.randn(100,100) * 0.1))
x[0:5,0:5]
tensor([[0.0000e+00, 1.9689e-08, 4.2820e-08, 0.0000e+00, 0.0000e+00], [0.0000e+00, 1.6701e-08, 4.3501e-08, 0.0000e+00, 0.0000e+00], [0.0000e+00, 1.0976e-08, 3.0411e-08, 0.0000e+00, 0.0000e+00], [0.0000e+00, 1.8457e-08, 4.9469e-08, 0.0000e+00, 0.0000e+00], [0.0000e+00, 1.9949e-08, 4.1643e-08, 0.0000e+00, 0.0000e+00]])
这意味着我们的初始化不正确。为什么?在 Glorot 和 Bengio 撰写他们的文章时,神经网络中最流行的激活是双曲正切(tanh,这是他们使用的),并且该初始化不考虑我们的 ReLU。幸运的是,有人为我们做了数学计算,并计算出适合我们使用的比例。在 “深入研究 Rectifiers:超越人类水平的表现”(我们之前看过——这是介绍 ResNet 的文章), Kaiming He 等人。表明我们应该使用以下比例:2/nin, 在哪里nin是我们模型的输入数量。让我们看看这给了我们什么:
x = torch.randn(200, 100)
for i in range(50): x = relu(x @ (torch.randn(100,100) * sqrt(2/100)))
x[0:5,0:5]
tensor([[0.2871, 0.0000, 0.0000, 0.0000, 0.0026], [0.4546, 0.0000, 0.0000, 0.0000, 0.0015], [0.6178, 0.0000, 0.0000, 0.0180, 0.0079], [0.3333, 0.0000, 0.0000, 0.0545, 0.0000], [0.1940, 0.0000, 0.0000, 0.0000, 0.0096]])
这更好:这次我们的数字并没有全部归零。因此,让我们回到神经网络的定义并使用此初始化(称为Kaiming 初始化或He 初始化):
x = torch.randn(200, 100)
y = torch.randn(200)
w1 = torch.randn(100,50) * sqrt(2 / 100)
b1 = torch.zeros(50)
w2 = torch.randn(50,1) * sqrt(2 / 50)
b2 = torch.zeros(1)
让我们看看通过第一个线性层和 ReLU 后激活的规模:
l1 = lin(x, w1, b1)
l2 = relu(l1)
l2.mean(), l2.std()
(tensor(0.5661), tensor(0.8339))
好多了!现在我们的权重已正确初始化,我们可以定义整个模型:
def model(x):
l1 = lin(x, w1, b1)
l2 = relu(l1)
l3 = lin(l2, w2, b2)
return l3
这是前传。现在剩下要做的就是使用损失函数将我们的输出与我们拥有的标签(在本例中为随机数)进行比较。在这种情况下,我们将使用均方误差。(这是一个玩具问题,这是用于下一步计算梯度的最简单的损失函数。)
唯一的微妙之处在于我们的输出和目标没有完全相同的形状——通过模型后,我们得到如下输出:
out = model(x)
out.shape
torch.Size([200, 1])
为了摆脱这个尾随的 1 维,我们使用squeeze
函数:
def mse(output, targ): return (output.squeeze(-1) - targ).pow(2).mean()
现在我们准备好计算我们的损失:
loss = mse(out, y)
梯度和向后传递
我们已经看到 PyTorch 通过对 的神奇调用计算了我们需要的所有梯度loss.backward
,但让我们探索一下 场景。
现在是我们需要计算关于模型所有权重的损失梯度的部分,所以所有的浮点数都在w1
、 b1
、w2
和b2
中。为此,我们需要一些数学知识——特别是链式法则。这是指导我们如何计算复合函数的导数的微积分规则:
我发现这个符号很难理解,所以我喜欢这样想:if
y = g(u)
andu=f(x)
, thendy/dx = dy/du * du/dx
。这两个表示法表示相同的意思,因此请使用适合您的任何表示法。
我们的损失是不同函数的大组合:均方误差(即均值和 2 的幂的组合)、第二个线性层、ReLU 和第一个线性层。例如,如果我们想要损失的梯度,b2
我们的损失定义如下:
loss = mse(out,y) = mse(lin(l2, w2, b2), y)
链式法则告诉我们,我们有这个:
计算关于损失的梯度 b2,我们首先需要关于输出的损失梯度out. 如果我们想要损失的梯度是一样的w2. 然后,得到关于损失的梯度 b1或者w1,我们将需要关于损失的梯度l1,这又需要损失的梯度l2,这将需要关于损失的梯度out.
因此,为了计算更新所需的所有梯度,我们需要从模型的输出开始,然后一层接一层地向后工作——这就是为什么这个步骤被称为反向传播的原因。我们可以通过让我们实现的每个函数 ( relu
, mse
, lin
) 提供其反向步骤来自动化它:也就是说,如何从损失相对于输出的梯度推导出相对于输入的损失梯度。
在这里,我们将这些梯度填充到每个张量的属性中,有点像 PyTorch 对.grad
.
第一个是损失相对于我们模型输出(损失函数的输入)的梯度。我们撤消 squeeze
我们在 中所做的mse
,然后我们使用为我们提供导数的公式x2: 2x. 均值的导数只是 1/ n,其中n是我们输入中元素的数量:
def mse_grad(inp, targ):
# grad of loss with respect to output of previous layer
inp.g = 2. * (inp.squeeze() - targ).unsqueeze(-1) / inp.shape[0]
对于 ReLU 和线性层的梯度,我们使用损失相对于输出的梯度 (in out.g
) 并应用链式法则计算损失相对于输入的梯度 (in inp.g
)。链式法则告诉我们 inp.g = relu'(inp) * out.g
。的导数relu
为 0(当输入为负时)或 1(当输入为正时),因此我们得到以下结果:
def relu_grad(inp, out):
# grad of relu with respect to input activations
inp.g = (inp>0).float() * out.g
该方案与计算线性层中输入、权重和偏差的损失梯度相同:
def lin_grad(inp, out, w, b):
# grad of matmul with respect to input
inp.g = out.g @ w.t()
w.g = inp.t() @ out.g
b.g = out.g.sum(0)
我们不会停留在定义它们的数学公式上,因为它们对我们的目的并不重要,但如果您对此主题感兴趣,请查看可汗学院的优秀微积分课程。
SymPy 是一个用于符号计算的库,在处理微积分时非常有用。每 文档:
符号计算以符号方式处理数学对象的计算。这意味着数学对象被精确地而不是近似地表示,并且具有未评估变量的数学表达式以符号形式保留。
要进行符号计算,我们定义一个符号然后进行计算,如下所示:
from sympy import symbols,diff
sx,sy = symbols('sx sy')
diff(sx**2, sx)
2*sx
在这里,SymPy 已经sx**2
为我们取了导数!它可以对复杂的复合表达式进行求导,简化和分解方程式等等。现在真的没有太多理由让任何人手动进行微积分 - 对于计算梯度,PyTorch 为我们做了,而为了显示方程,SymPy 为我们做了!
一旦我们定义了这些函数,我们就可以使用它们来编写向后传递。由于每个梯度都自动填充到正确的张量中,我们不需要将这些 _grad
函数的结果存储在任何地方——我们只需要按照前向传递的相反顺序执行它们,以确保每个函数out.g
都存在:
def forward_and_backward(inp, targ):
# forward pass:
l1 = inp @ w1 + b1
l2 = relu(l1)
out = l2 @ w2 + b2
# we don't actually need the loss in backward!
loss = mse(out, targ)
# backward pass:
mse_grad(out, targ)
lin_grad(l2, out, w2, b2)
relu_grad(l1, l2)
lin_grad(inp, l1, w1, b1)
现在我们可以在 w1.g
、b1.g
、w2.g
和中访问模型参数的梯度b2.g
。我们已经成功定义了我们的模型——现在让我们让它更像一个 PyTorch 模块。
重构模型
我们使用的三个函数有两个关联函数:前向传递和反向传递。不用单独写它们,我们可以 创建一个类将它们包装在一起。该类还可以存储反向传递的输入和输出。这样,我们只需要调用backward
:
class Relu():
def __call__(self, inp):
self.inp = inp
self.out = inp.clamp_min(0.)
return self.out
def backward(self): self.inp.g = (self.inp>0).float() * self.out.g
__call__
是 Python 中的一个神奇名称,它将使我们的类可调用。这是我们键入时将执行的内容y = Relu()(x)
。我们可以对我们的线性层和 MSE 损失做同样的事情:
class Lin():
def __init__(self, w, b): self.w,self.b = w,b
def __call__(self, inp):
self.inp = inp
self.out = inp@self.w + self.b
return self.out
def backward(self):
self.inp.g = self.out.g @ self.w.t()
self.w.g = inp.t() @ self.out.g
self.b.g = self.out.g.sum(0)
class Mse():
def __call__(self, inp, targ):
self.inp = inp
self.targ = targ
self.out = (inp.squeeze() - targ).pow(2).mean()
return self.out
def backward(self):
x = (self.inp.squeeze()-self.targ).unsqueeze(-1)
self.inp.g = 2.*x/self.targ.shape[0]
然后我们可以将所有内容放入我们使用张量 w1
、b1
、w2
和启动的模型中b2
:
class Model():
def __init__(self, w1, b1, w2, b2):
self.layers = [Lin(w1,b1), Relu(), Lin(w2,b2)]
self.loss = Mse()
def __call__(self, x, targ):
for l in self.layers: x = l(x)
return self.loss(x, targ)
def backward(self):
self.loss.backward()
for l in reversed(self.layers): l.backward()
这种重构并将事物注册为我们模型的层的好处在于,前向和反向传递现在非常容易编写。如果我们想实例化我们的模型,我们只需要这样写:
model = Model(w1, b1, w2, b2)
然后可以按如下方式执行前向传递:
loss = model(x, y)
和这个向后传递:
model.backward()
走向 PyTorch
我们写的Lin
, Mse
, 和Relu
类有很多共同点,所以我们可以让它们都继承自同一个基类:
class LayerFunction():
def __call__(self, *args):
self.args = args
self.out = self.forward(*args)
return self.out
def forward(self): raise Exception('not implemented')
def bwd(self): raise Exception('not implemented')
def backward(self): self.bwd(self.out, *self.args)
然后我们只需要在我们的每个子类中实现forward
和bwd
:
class Relu(LayerFunction):
def forward(self, inp): return inp.clamp_min(0.)
def bwd(self, out, inp): inp.g = (inp>0).float() * out.g
class Lin(LayerFunction):
def __init__(self, w, b): self.w,self.b = w,b
def forward(self, inp): return inp@self.w + self.b
def bwd(self, out, inp):
inp.g = out.g @ self.w.t()
self.w.g = inp.t() @ out.g
self.b.g = out.g.sum(0)
class Mse(LayerFunction):
def forward (self, inp, targ): return (inp.squeeze() - targ).pow(2).mean()
def bwd(self, out, inp, targ):
inp.g = 2*(inp.squeeze()-targ).unsqueeze(-1) / targ.shape[0]
我们模型的其余部分可以和以前一样。这越来越接近 PyTorch 所做的事情。我们需要区分的每个基本功能都写成一个torch.autograd.Function
对象,该对象具有一个对象forward
和一个backward
方法。然后,PyTorch 将跟踪我们所做的任何计算,以便能够正确运行向后传递,除非我们将requires_grad
张量的属性设置为False
。
编写其中一个(几乎)与编写我们的原始类一样简单。不同之处在于我们选择保存什么以及将什么放入上下文变量中(这样我们确保我们不保存任何我们不需要的东西),并且我们在 backward
通道中返回梯度。很少需要编写自己的Function
,但是如果您需要一些奇特的东西或者想要弄乱常规函数的梯度,可以按照以下方法编写一个:
from torch.autograd import Function
class MyRelu(Function):
@staticmethod
def forward(ctx, i):
result = i.clamp_min(0.)
ctx.save_for_backward(i)
return result
@staticmethod
def backward(ctx, grad_output):
i, = ctx.saved_tensors
return grad_output * (i>0).float()
用于构建利用这些Function
s 的更复杂模型的结构是torch.nn.Module
. 这是所有模型的基本结构,您到目前为止看到的所有神经网络都继承自该类。它主要有助于注册所有可训练的参数,正如我们所见,这些参数可以在训练循环中使用。
要实施,nn.Module
您只需执行以下操作:
-
确保在
__init__
初始化时首先调用超类。 -
使用 将模型的任何参数定义为属性
nn.Parameter
。 -
定义一个
forward
返回模型输出的函数。
例如,这是从头开始的线性层:
import torch.nn as nn
class LinearLayer(nn.Module):
def __init__(self, n_in, n_out):
super().__init__()
self.weight = nn.Parameter(torch.randn(n_out, n_in) * sqrt(2/n_in))
self.bias = nn.Parameter(torch.zeros(n_out))
def forward(self, x): return x @ self.weight.t() + self.bias
如您所见,此类会自动跟踪已定义的参数:
lin = LinearLayer(10,2)
p1,p2 = lin.parameters()
p1.shape,p2.shape
(torch.Size([2, 10]), torch.Size([2]))
多亏了这个特性nn.Module
,我们才可以说 opt.step
优化器循环遍历参数并更新每个参数。
请注意,在 PyTorch 中,权重存储为n_out x n_in
矩阵,这就是我们在前向传递中进行转置的原因。
通过使用 PyTorch 的线性层(它也使用 Kaiming 初始化),我们在本章中建立的模型可以这样写:
class Model(nn.Module):
def __init__(self, n_in, nh, n_out):
super().__init__()
self.layers = nn.Sequential(
nn.Linear(n_in,nh), nn.ReLU(), nn.Linear(nh,n_out))
self.loss = mse
def forward(self, x, targ): return self.loss(self.layers(x).squeeze(), targ)
fastai 提供了它自己的Module
与 相同 的变体nn.Module
,但不需要你调用 super().__init__()
(它会自动为你调用):
class Model(Module):
def __init__(self, n_in, nh, n_out):
self.layers = nn.Sequential(
nn.Linear(n_in,nh), nn.ReLU(), nn.Linear(nh,n_out))
self.loss = mse
def forward(self, x, targ): return self.loss(self.layers(x).squeeze(), targ)
结论
在本章中,我们探索了深度学习的基础,从矩阵乘法开始,然后从头开始实现神经网络的前向和反向传递。然后,我们重构了我们的代码以展示 PyTorch 的底层工作原理。
这里有几件事要记住:
-
神经网络基本上是一堆矩阵乘法,其间存在非线性。
-
Python 很慢,因此要编写快速代码,我们必须对其进行矢量化并利用元素运算和广播等技术。
-
如果从末尾开始并向后匹配的维度(如果它们相同,或者其中之一为 1),则两个张量是可广播的。为了使张量可广播,我们可能需要添加大小为 1 的维度和
unsqueeze
索引None
。 -
正确初始化神经网络对于开始训练至关重要。当我们有 ReLU 非线性时,应该使用 Kaiming 初始化。
-
向后传递是多次应用链式法则,计算我们模型输出的梯度并返回,一次一层。
-
当子类化时
nn.Module
(如果不使用 fastai 的Module
),我们必须在我们的方法中调用超类__init__
方法__init__
,我们必须定义一个forward
接受输入并返回所需结果的函数。