【Pytorch with fastai】第 17 章 :基础神经网络

news2025/1/15 13:45:46

🔎大家好,我是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_sizeby 的矩阵中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循环:一个用于行索引,一个用于列索引,一个用于内部求和。acar分别代表 的列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两个具有相同形状的张量ab我们将得到一个由 和 的元素之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、meanall等约简操作 返回只有一个元素的张量,称为 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 速度执行。jb

要访问一列或一行,我们可以简单地写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 的向量?在这两种情况下,我们都可以找到一种方法来理解这种操作。

当尝试进行逐元素操作时形状兼容时,广播给出了特定的规则来编码,以及如何扩展较小形状的张量以匹配较大形状的张量。如果您希望能够编写快速执行的代码,那么掌握这些规则是必不可少的。在本节中,我们将扩展之前对广播的处理以了解这些规则。

将向量广播到矩阵

我们可以将向量广播到矩阵,如下所示:

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为矩阵的向量大小mn

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 矩阵。这是通过 unsqueezePyTorch 中的方法完成的:

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],而不是ba[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)

我们现在比第一次实施快 3,700 倍!在我们继续之前,让我们更详细地讨论广播规则。

爱因斯坦求和

在使用 PyTorch 操作@or之前torch.matmul,我们还有最后一种方法可以实现矩阵乘法:爱因斯坦求和 ( einsum). 这是以一般方式组合乘积和和的紧凑表示。我们写一个这样的等式:

ik,kj -> ij

左侧表示操作数维度,以逗号分隔。这里我们有两个张量,每个张量都有两个维度(i,k和 k,j)。右侧代表结果维度,所以这里我们有一个二维张量i,j

爱因斯坦求和记法规则如下:

  1. 如果左侧的重复索引不在右侧,则它们会隐式求和。

  2. 每个索引最多可以在左侧出现两次。

  3. 左侧的不重复索引必须出现在右侧。

所以在我们的例子中,因为k是重复的,所以我们对该索引求和。最后,这个公式代表了我们把第一个张量中的所有系数( )乘以第二个张量中i,j的系数( )的总和得到的矩阵……这就是矩阵乘积!i,kk,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 + bx我们的输入,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、 b1w2b2中。为此,我们需要一些数学知识——特别是链式法则。这是指导我们如何计算复合函数的导数的微积分规则:

                                                              (g∘f)'(x)=g'(f(x))f'(x)

我发现这个符号很难理解,所以我喜欢这样想:if y = g(u)and u=f(x), then dy/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.

因此,为了计算更新所需的所有梯度,我们需要从模型的输出开始,然后一层接一层地向后工作——这就是为什么这个步骤被称为反向传播的原因。我们可以通过让我们实现的每个函数 ( relumselin) 提供其反向步骤来自动化它:也就是说,如何从损失相对于输出的梯度推导出相对于输入的损失梯度。

在这里,我们将这些梯度填充到每个张量的属性中,有点像 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)

我们不会停留在定义它们的数学公式上,因为它们对我们的目的并不重要,但如果您对此主题感兴趣,请查看可汗学院的优秀微积分课程。

一旦我们定义了这些函数,我们就可以使用它们来编写向后传递。由于每个梯度都自动填充到正确的张量中,我们不需要将这些 _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.gb1.gw2.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]

然后我们可以将所有内容放入我们使用张量 w1b1w2和启动的模型中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

我们写的LinMse, 和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)

然后我们只需要在我们的每个子类中实现forwardbwd

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()

用于构建利用这些Functions 的更复杂模型的结构是torch.nn.Module. 这是所有模型的基本结构,您到目前为止看到的所有神经网络都继承自该类。它主要有助于注册所有可训练的参数,正如我们所见,这些参数可以在训练循环中使用。

要实施,nn.Module您只需执行以下操作:

  1. 确保在__init__初始化时首先调用超类。

  2. 使用 将模型的任何参数定义为属性nn.Parameter

  3. 定义一个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)

在第 19 章中,我们将从这样一个模型开始,看看如何从头开始构建训练循环,并将其重构为我们在前几章中使用的模型。

结论

在本章中,我们探索了深度学习的基础,从矩阵乘法开始,然后从头开始实现神经网络的前向和反向传递。然后,我们重构了我们的代码以展示 PyTorch 的底层工作原理。

这里有几件事要记住:

  • 神经网络基本上是一堆矩阵乘法,其间存在非线性。

  • Python 很慢,因此要编写快速代码,我们必须对其进行矢量化并利用元素运算和广播等技术。

  • 如果从末尾开始并向后匹配的维度(如果它们相同,或者其中之一为 1),则两个张量是可广播的。为了使张量可广播,我们可能需要添加大小为 1 的维度和 unsqueeze索引None

  • 正确初始化神经网络对于开始训练至关重要。当我们有 ReLU 非线性时,应该使用 Kaiming 初始化。

  • 向后传递是多次应用链式法则,计算我们模型输出的梯度并返回,一次一层。

  • 当子类化时nn.Module(如果不使用 fastai 的 Module),我们必须在我们的方法中调用超类__init__方法 __init__,我们必须定义一个forward接受输入并返回所需结果的函数。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/19239.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【LeetCode每日一题:799.香槟塔~~~模拟】

题目描述 我们把玻璃杯摆成金字塔的形状&#xff0c;其中 第一层 有 1 个玻璃杯&#xff0c; 第二层 有 2 个&#xff0c;依次类推到第 100 层&#xff0c;每个玻璃杯 (250ml) 将盛有香槟。 从顶层的第一个玻璃杯开始倾倒一些香槟&#xff0c;当顶层的杯子满了&#xff0c;任…

〖全域运营实战白宝书 - 运营角色认知篇①〗- 初识运营,明晰运营的学习路径

✌ 大家好&#xff0c;我是 哈士奇 &#xff0c;一位工作了十年的"技术圈混子"&#xff0c; 致力于为开发者赋能的UP主, 目前正在运营着 TFS_CLUB社区。 ✌ &#x1f4ac; 人生格言&#xff1a;优于别人,并不高贵,真正的高贵应该是优于过去的自己。&#x1f4ac; &am…

ES6 入门教程 14 Set 和 Map 数据结构 14.1 Set

ES6 入门教程 ECMAScript 6 入门 作者&#xff1a;阮一峰 本文仅用于学习记录&#xff0c;不存在任何商业用途&#xff0c;如侵删 文章目录ES6 入门教程14 Set 和 Map 数据结构14.1 Set14.1.1 基本用法14.1.2 Set 实例的属性和方法14.1.3 遍历操作14 Set 和 Map 数据结构 14.1…

[附源码]java毕业设计实验教学过程管理平台

项目运行 环境配置&#xff1a; Jdk1.8 Tomcat7.0 Mysql HBuilderX&#xff08;Webstorm也行&#xff09; Eclispe&#xff08;IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持&#xff09;。 项目技术&#xff1a; SSM mybatis Maven Vue 等等组成&#xff0c;B/S模式 M…

【龙芯1B】:74HC595数码管或74HC138数码管程序开发

学习目标&#xff1a;解决龙芯1B&#xff1a;74HC595或74HC138数码管显示问题 首先我们要知道数码管的原理&#xff1b;以74HC595为例&#xff0c;74HC595是具有三态输出功能&#xff08;即具有高电平、低电平和高阻抗三种输出状态&#xff09;的门电路。输出寄存器可以直接清除…

基于Springboot搭建java项目(十六)——Kafka的简介

kafka官网&#xff1a;http://kafka.apache.org/ 参考文献&#xff1a;大白话 kafka 架构原理 (qq.com) 一、kafka简介 Kafka最初由Linkedin公司开发&#xff0c;是一个分布式的、分区的、多副本的、多订阅者&#xff0c;基于zookeeper协调的分布式日志系统&#xff08;也可…

[附源码]计算机毕业设计JAVA后疫情下物业管理系统

[附源码]计算机毕业设计JAVA后疫情下物业管理系统 项目运行 环境配置&#xff1a; Jdk1.8 Tomcat7.0 Mysql HBuilderX&#xff08;Webstorm也行&#xff09; Eclispe&#xff08;IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持&#xff09;。 项目技术&#xff1a; SSM my…

数据传输功能单元——DID参数定义

诊断协议那些事儿 诊断协议那些事儿专栏系列文章&#xff0c;本文介绍数据传输服务的dataldentifier&#xff08;DID&#xff09;定义。 参考文章&#xff1a; 22服务-ReadDataByIdentifier 文章目录诊断协议那些事儿一、DID参数二、参数的定义总结一、DID参数 逻辑上&#…

MongoDB初识(一)

什么是MongoDB MongoDB 是一个以JSON为数据模型的文档数据库&#xff0c;文档来自于“JSON Document”&#xff0c;并非我们一般理解的PDF&#xff0c;WORD文档 MongoDB中的记录就是一个文档&#xff0c;它是由键值对组成的数据结构。MongoDB 文档类似于 JSON 对象。字段的值…

基于农产品(蔬菜)数据挖掘的分析与实现(Python+Spider)

目 录 摘 要 I Abstract II 1绪论 1 1.1研究背景 1 1.2项目来源 1 1.3研究目的 2 1.3研究现状 2 1.4主要内容及预期目标 3 1.4.1数据挖掘技术简述及优点 3 1.4.2程序设计思路 3 1.4.3节点布置方案 4 1.4.4预期目标 5 2农产品&#xff08;蔬菜&#xff09;价格形成机制及影响因素…

少儿编程 电子学会图形化 scratch编程等级考试四级真题答案解析(选择题)2022年9月

2022年9月Scratch四级真题解析 选择题(共15题,每题2分,共30分) 1、运行下列程序,说法正确的是 A、列表中的数字全部小于11 B、列表的长度为10 C、变量i最终值为20 D、列表中有大于10的数字 答案:D 考点分析:考查积木综合使用,重点考查列表积木的使用,开始向列表…

4.1.2 类非静态成员函数返回值转换与NRV优化

类非静态成员函数返回值转换与NRV优化 下述代码即是Point3d的整体函数原型&#xff0c;对于第33行的调用&#xff0c;正常理解即会首先调用默认构造函数生成一个临时对象&#xff0c;进而调用拷贝构造函数将临时对象拷贝给p2。但是实际上任何编译器都不会采用上述的调用方法&a…

四十七、Fluent近壁面处理

0. 前言 今天这篇文章&#xff0c;我们介绍一下近壁面处理的方式来求解湍流壁面物理规律。 前文四十五、四十六及本文四十七这三篇文章可以说是非常详细而系统的把壁面函数及相关理论全都介绍了一遍&#xff0c;大家可以参考进行设置。也希望大家能多多转发&#xff0c;点赞呀&…

MySQL中的锁机制、MyISAM表锁、MyISAM表级锁争用情况、MyISAM并发插入Concurrent Inserts、MyISAM的锁调度

前言: 关于读锁、写锁、乐观锁、悲观锁、行锁、表锁的理解可以看看以前我写的: 读锁、写锁、乐观锁、悲观锁、行锁、表锁 内部锁:在MySQL服务器内部执行的锁,以管理多个会话对表内容的争用。这种类型的锁是内部的,因为它完全由MySQL服务器执行,不涉及其他程序。 表级锁:…

JAVA中使用最广泛的本地缓存?Ehcache的自信从何而来 —— 感受来自Ehcache的强大实力

大家好&#xff0c;又见面了。 作为《深入理解缓存原理与实战设计》系列专栏&#xff0c;前面几篇文章中我们详细的介绍与探讨了Guava Cache与Caffeine的实现、特性与使用方式。提到JAVA本地缓存框架&#xff0c;还有一个同样无法被忽视的强大存在 —— Ehcache&#xff01;它…

Windows同时安装两个版本JDK,并实现动态切换JAVA8或者JAVA11

一、需求 对于Java开发工程师来说&#xff0c;可能手头上同时负责不同的项目&#xff0c;但是由于历史的原因&#xff0c;Java版本可能没有做到统一升级&#xff0c;有的项目是使用JDK8版本&#xff0c;有的项目使用的是JDK11的版本&#xff0c;那这时候就需要我们电脑同时兼容…

html2canvas 行内元素边框样式生成问题解决(根据文字生成图片)

项目场景&#xff1a; 实现一个基于一段文字生成一张图片的需求&#xff0c;其中&#xff0c;有一段文字需要下划线&#xff0c;但是不是text-decoration:underline;的样式&#xff0c;因为下划线要距离字一段距离&#xff0c;接到这个方案时&#xff0c;第一时间想到的就是ht…

在x86的Docker中构建TVM的ARM环境

文章目录前言1. 加载arm-ubuntu镜像2. 安装acl库3. 编译arm运行时4. 编译在x86运行在arm4.1 在x86的环境中构建arm的编译环境4.2 测试x86-ubuntu与arm-ubuntu能否ping通4.3 调用RPC4.4 ACL的使用5. arm版的tvm编译和运行时环境5.1 构建arm版的tvm编译和运行时环境5.2 关于ubunt…

卷积版wav to image 训练实例

🍿*★,*:.☆欢迎您/$:*.★* 🍿 目录 背景 正文 总结 背景描述

Java语言知识大盘点(期末总复习)二

&#x1f339;作者:云小逸 &#x1f4dd;个人主页:云小逸的主页 &#x1f4dd;Github:云小逸的Github &#x1f91f;motto:要敢于一个人默默的面对自己&#xff0c;强大自己才是核心。不要等到什么都没有了&#xff0c;才下定决心去做。种一颗树&#xff0c;最好的时间是十年前…