🔎大家好,我是Sonhhxg_柒,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流🔎
📝个人主页-Sonhhxg_柒的博客_CSDN博客 📃
🎁欢迎各位→点赞👍 + 收藏⭐️ + 留言📝
📣系列专栏 - 机器学习【ML】 自然语言处理【NLP】 深度学习【DL】
🖍foreword
✔说明⇢本人讲解主要包括Python、机器学习(ML)、深度学习(DL)、自然语言处理(NLP)等内容。
如果你对这个系列感兴趣的话,可以关注订阅哟👋
文章目录
在第 4 章中,我们学习了如何创建一个识别图像的神经网络。我们在区分 3 和 7 时能够达到 98% 以上的准确率——但我们也看到 fastai 的内置类能够接近 100%。让我们开始尝试缩小差距。
在本章中,我们将从深入了解卷积是什么并从头开始构建 CNN 开始。然后,我们将研究一系列提高训练稳定性的技术,并了解库通常为我们应用的所有调整,以获得出色的结果。
机器学习从业者可以使用的最强大的工具之一是特征工程。特征是数据的转换,旨在使其更容易建模。 例如,我们在第 9 章add_datepart
中用于表格数据集预处理的函数向 Bulldozers 数据集添加了日期特征。我们可以从图像中创建什么样的特征?
特征工程
创建输入数据的新转换,以便更容易建模。
在图像的上下文中,特征是视觉上与众不同的属性。例如,数字 7 的特征是靠近数字顶部的水平边缘,以及其下方从右上角到左下角的对角线边缘。另一方面,数字 3 的特点是在数字的左上角和右下角有一个方向的对角线边缘,左下角和右上角有相反的对角线,中间、顶部和底部有水平边缘,等等。那么,如果我们可以提取每幅图像中边缘出现位置的信息,然后使用该信息而不是原始像素作为我们的特征呢?
事实证明,寻找图像中的边缘是计算机视觉中一项非常常见的任务,而且非常简单。为此,我们使用了一种叫做卷积的东西。卷积不需要更多 而不是乘法和加法——这两种运算负责我们将在本书的每个深度学习模型中看到的绝大部分工作!
卷积在图像上应用内核。内核是一个小矩阵,例如右上角的 3×3 矩阵 图 13-1。
图 13-1。将内核应用到一个位置
左边的 7×7 网格是我们要应用内核的图像。卷积运算将内核的每个元素乘以图像的 3×3 块的每个元素。然后将这些乘法的结果加在一起。图 13-1中的图表显示了将核应用于图像中单个位置的示例,即单元格 18 周围的 3×3 块。
让我们用代码来做到这一点。首先,我们像这样创建一个 3×3 的小矩阵:
- top_edge = tensor([[-1,-1,-1],
- [ 0, 0, 0],
- [ 1, 1, 1]]).float()
我们将把它称为我们的内核(因为计算机视觉研究人员就是这样称呼它们的)。当然,我们需要一张图片:
path = untar_data(URLs.MNIST_SAMPLE)
im3 = Image.open(path/'train'/'3'/'12.png')
show_image(im3);
现在我们将获取图像顶部的 3×3 像素正方形,并将这些值中的每一个乘以内核中的每一项。然后我们将它们相加,如下所示:
- im3_t = tensor(im3)
- im3_t[0:3,0:3] * top_edge
tensor([[-0., -0., -0.], [0., 0., 0.], [0., 0., 0.]])
(im3_t[0:3,0:3] * top_edge).sum()
tensor(0.)
到目前为止还不是很有趣——左上角的所有像素都是白色的。但让我们选择几个更有趣的地方:
- df = pd.DataFrame(im3_t[:10,:20])
- df.style.set_properties(**{'font-size':'6pt'}).background_gradient('Greys')
在单元格 5,8 处有一个顶部边缘。让我们在那里重复我们的计算:
(im3_t[4:7,6:9] * top_edge).sum()
tensor(762.)
在单元格 8,18 处有一个右边缘。这给了我们什么?
(im3_t[7:10,17:20] * top_edge).sum()
tensor(-29.)
如您所见,这个小计算返回一个高数字,其中 3×3 像素的正方形代表顶部边缘(即,正方形顶部有低值,正下方有高值)。那是因为-1
我们内核中的值在那种情况下几乎没有影响,但是这些1
值有很多。
让我们稍微看一下数学。过滤器将在我们的图像中采用任何大小为 3×3 的窗口,如果我们这样命名像素值
它会返回a1+a2+a3-a7-a8-a9. 如果我们在图像的一部分a1, a2, 和 a3加起来等于a7, a8, 和a9,那么这些项将相互抵消,我们将得到 0。但是,如果一个1大于 a7,a2大于a8, 和 a3大于a9,结果我们会得到一个更大的数字。所以这个过滤器检测水平边缘——更准确地说,是我们从图像顶部的明亮部分到底部较暗部分的边缘。
将我们的过滤器更改为1
在顶部有–1
s 行,在底部有 s 行将检测到从暗到亮的水平边缘。将1
s 和–1
s 放在列而不是行中将为我们提供检测垂直边缘的过滤器。每组权重都会产生不同类型的结果。
让我们创建一个函数来为一个位置执行此操作,并检查它是否与我们之前的结果相匹配:
- def apply_kernel(row, col, kernel):
- return (im3_t[row-1:row+2,col-1:col+2] * kernel).sum()
apply_kernel(5,7,top_edge)
tensor(762.)
但请注意,我们不能将它应用到角落(例如,位置 0,0),因为那里没有完整的 3×3 正方形。
我们可以apply_kernel()
跨越坐标网格进行映射。也就是说,我们将采用 3×3 内核并将其应用于图像的每个 3×3 部分。例如,图 13-2显示了一个 3×3 内核的位置 可以应用于 5×5 图像的第一行。
图 13-2。跨网格应用内核
[[(i,j) for j in range(1,5)] for i in range(1,5)]
[[(1, 1), (1, 2), (1, 3), (1, 4)],
[(2, 1), (2, 2), (2, 3), (2, 4)],
[(3, 1), (3, 2), (3, 3), (3, 4)],
[(4, 1), (4, 2), (4, 3), (4, 4)]]
嵌套列表理解
嵌套列表推导在 Python 中被大量使用,所以如果您以前没有见过它们,请花几分钟时间确保您了解这里发生的事情,并尝试编写您自己的嵌套列表推导。
这是将我们的内核应用于坐标网格的结果:
- rng = range(1,27)
- top_edge3 = tensor([[apply_kernel(i,j,top_edge) for j in rng] for i in rng])
-
- show_image(top_edge3);
看起来不错!我们的顶部边缘是黑色的,底部边缘是白色的(因为它们与顶部边缘相反)。现在我们的图像也包含负数,matplotlib
已经自动改变了我们的颜色,白色是图像中最小的数字,黑色是最大的数字,零显示为灰色。
我们可以对左边缘尝试同样的事情:
- left_edge = tensor([[-1,1,0],
- [-1,1,0],
- [-1,1,0]]).float()
-
- left_edge3 = tensor([[apply_kernel(i,j,left_edge) for j in rng] for i in rng])
-
- show_image(left_edge3);
正如我们之前提到的,卷积是在网格上应用此类内核的操作。Vincent Dumoulin 和 Francesco Visin 的论文“A Guide to Convolution Arithmetic for Deep Learning”有很多很棒的图表 显示如何应用图像内核。图 13-3是论文中的一个示例,显示(在底部)应用了深蓝色 3×3 内核的浅蓝色 4×4 图像,在顶部创建了一个 2×2 绿色输出激活图。
图 13-3。将 3×3 内核应用于 4×4 图像的结果(由 Vincent Dumoulin 和 Francesco Visin 提供)
查看结果的形状。如果原始图像的高度为 h
,宽度为w
,我们可以找到多少个 3×3 的窗口?从例子中可以看出,有h-2
by w-2
windows,所以我们得到的结果图片的高度为h-2
,宽度为w-2
。
卷积是如此重要和广泛使用的操作以至于 PyTorch 它是内置的。它被称为F.conv2d
(回想一下,根据 PyTorch 的建议,它F
是从 中导入torch.nn.functional
的 fastai)。PyTorch 文档告诉我们它包含以下参数:
input
形状的输入张量(minibatch, in_channels, iH, iW)
weight
形状过滤器(out_channels, in_channels, kH, kW)
这iH,iW
是图像的高度和宽度(即28,28
), kH,kW
是我们内核的高度和宽度(3,3
)。但显然 PyTorch 期望这两个参数都是 4 阶张量,而目前我们只有 2 阶张量(即矩阵或具有两个轴的数组)。
这些额外轴的原因是 PyTorch 有一些小技巧。第一个技巧是 PyTorch 可以同时对多个图像应用卷积。这意味着我们可以一次对一批中的每个项目调用它!
第二个技巧是 PyTorch 可以同时应用多个内核。因此,让我们也创建对角边缘内核,然后将所有四个边缘内核堆叠到一个张量中:
- diag1_edge = tensor([[ 0,-1, 1],
- [-1, 1, 0],
- [ 1, 0, 0]]).float()
- diag2_edge = tensor([[ 1,-1, 0],
- [ 0, 1,-1],
- [ 0, 0, 1]]).float()
-
- edge_kernels = torch.stack([left_edge, top_edge, diag1_edge, diag2_edge])
- edge_kernels.shape
torch.Size([4, 3, 3])
为了测试这一点,我们需要DataLoader
一个样本小批量。让我们使用数据块 API:
- mnist = DataBlock((ImageBlock(cls=PILImageBW), CategoryBlock),
- get_items=get_image_files,
- splitter=GrandparentSplitter(),
- get_y=parent_label)
-
- dls = mnist.dataloaders(path)
- xb,yb = first(dls.valid)
- xb.shape
torch.Size([64, 1, 28, 28])
默认情况下,fastai 在使用数据块时将数据放在 GPU 上。在我们的示例中,让我们将其移至 CPU:
xb,yb = to_cpu(xb),to_cpu(yb)
一批包含 64 张图像,每张图像 1 个通道,像素为 28×28。 F.conv2d
也可以处理多通道(彩色)图像。通道是图像中的单一基本颜色——对于常规的全彩色图像,有红色、绿色和蓝色三个通道。PyTorch 将图像表示为 rank-3 张量,具有以下维度:
[channels, rows, columns]
我们将在本章后面看到如何处理多个通道。传递给F.conv2d
需要为 4 阶张量的内核:
[ features_out , channels_in , rows , columns ]
edge_kernels
目前缺少其中一个:我们需要告诉 PyTorch 内核中输入通道的数量是一个,我们可以通过在第一个位置插入一个大小为 1 的轴(这被称为单位轴)来做到这一点,其中PyTorch 文档显示in_channels
是预期的。要将单位轴插入张量,我们使用以下unsqueeze
方法:
edge_kernels.shape,edge_kernels.unsqueeze(1).shape
(torch.Size([4, 3, 3]), torch.Size([4, 1, 3, 3]))
现在这是 的正确形状edge_kernels
。让我们把这一切传递给conv2d
:
edge_kernels = edge_kernels.unsqueeze(1)
- batch_features = F.conv2d(xb, edge_kernels)
- batch_features.shape
torch.Size([64, 4, 26, 26])
输出形状显示我们在小批量中有 64 张图像、4 个内核和 26×26 的边缘图(我们从 28×28 图像开始,但如前所述,每侧丢失一个像素)。我们可以看到我们得到了与手动执行此操作时相同的结果:
show_image(batch_features[0,0]);
PyTorch 拥有的最重要的技巧是它可以使用 GPU 并行完成所有这些工作——跨多个通道将多个内核应用于多个图像。并行执行大量工作对于让 GPU 高效工作至关重要;如果我们一次执行这些操作中的每一个,我们的运行速度通常会慢数百倍(如果我们使用上一节中的手动卷积循环,我们会慢数百万倍!)。因此,要成为一名强大的深度学习实践者,一项需要练习的技能就是让你的 GPU 一次有大量的工作要做。
最好不要在每个轴上丢失这两个像素。我们这样做的方法是添加padding,这只是在图像外部添加的附加像素。最常见的是,添加零像素。
通过适当的填充,我们可以确保输出的激活图与原始图像大小相同,这可以使事情变得很多 当我们构建我们的架构时更简单。图 13-4显示了添加填充如何允许我们在图像角中应用内核。
图 13-4。带填充的卷积
使用 5×5 输入、4×4 内核和 2 个像素填充,我们最终得到一个 6×6 激活图,如图 13-5 所示。
图 13-5。具有 5×5 输入和 2 像素填充的 4×4 内核(由 Vincent Dumoulin 和 Francesco Visin 提供)
ks
如果我们添加一个大小为 by ks
(ks
奇数)的内核,则每边保持相同形状所需的填充是ks//2
. 偶数的ks
将需要在顶部/底部和左侧/右侧填充不同数量的填充,但实际上我们几乎从不使用偶数过滤器大小。
到目前为止,当我们将内核应用于网格时,我们一次将它移动一个像素。但是我们可以跳得更远;例如,我们可以在每个内核应用程序之后移动两个像素, 如图 13-6 所示。这被称为stride-2 卷积。实践中最常见的内核大小是 3×3,并且 最常见的填充是 1。正如您将看到的,stride-2 卷积对于减小输出的大小很有用,stride-1 卷积对于添加层而不改变 输出大小。
图 13-6。具有 5×5 输入、stride-2 卷积和 1 像素填充的 3×3 内核(由 Vincent Dumoulin 和 Francesco Visin 提供)
在大小为 的图像中h
,w
使用 1 的填充和 2 的步幅将为我们提供大小为 的(h+1)//2
结果(w+1)//2
。每个维度的通用公式是
(n + 2*pad - ks) // stride + 1
哪里pad
是填充,ks
是我们内核的大小,stride
是步幅。
现在让我们看一下如何计算卷积结果的像素值。
为了解释卷积背后的数学原理,fast.ai 学生 Matt Kleinsmith 提出了一个非常聪明的想法: 不同观点的 CNN。事实上,它是如此聪明,如此有用,我们也将在这里展示它!
这是我们的 3×3 像素图像,每个像素都标有一个字母:
这是我们的内核,每个权重都标有希腊字母:
由于过滤器适合图像四次,因此我们有四个结果:
图 13-7显示了我们如何将内核应用于图像的每个部分以产生每个结果。
图 13-7。应用内核
方程式视图如图 13-8 所示。
图 13-8。方程式
请注意,偏差项b对于图像的每个部分都是相同的。您可以将偏差视为过滤器的一部分,就像权重 (α, β, γ, δ) 是过滤器的一部分一样。
这里有一个有趣的见解——卷积可以表示为一种特殊的矩阵乘法,如图 13-9 所示。重量 矩阵就像传统神经网络中的矩阵一样。然而,这个权重矩阵有两个特殊的性质:
灰色显示的零点是不可训练的。这意味着它们将在整个优化过程中保持为零。
一些权重是相等的,虽然它们是可训练的(即可变的),但它们必须保持相等。这些称为共享权重。
零对应于过滤器无法触及的像素。权重矩阵的每一行对应于过滤器的一个应用。
图 13-9。卷积作为矩阵乘法
没有理由相信某些特定的边缘滤波器是图像识别最有用的内核。此外,我们已经看到在后面的层中,卷积核变成了来自较低层的特征的复杂转换,但我们不知道如何手动构建这些。
相反,最好学习内核的值。我们已经知道如何做到这一点 — SGD!实际上,模型将学习对分类有用的特征。当我们使用卷积代替(或除了)常规线性 层,我们创建了一个卷积神经网络(CNN)。
让我们回到 第 4 章中的基本神经网络。它是这样定义的:
- simple_net = nn.Sequential(
- nn.Linear(28*28,30),
- nn.ReLU(),
- nn.Linear(30,1)
- )
我们可以查看模型的定义:
simple_net
Sequential( (0): Linear(in_features=784, out_features=30, bias=True) (1): ReLU() (2): Linear(in_features=30, out_features=1, bias=True) )
我们现在想要创建一个与此线性模型类似的架构,但使用卷积层而不是线性模型。nn.Conv2d
是 . 的模块等价物F.conv2d
。它比 F.conv2d
创建架构时更方便,因为它会在我们实例化它时自动为我们创建权重矩阵。
这是一个可能的架构:
- broken_cnn = sequential(
- nn.Conv2d(1,30, kernel_size=3, padding=1),
- nn.ReLU(),
- nn.Conv2d(30,1, kernel_size=3, padding=1)
- )
这里要注意的一件事是我们不需要指定28*28
输入大小。这是因为线性层需要在权重矩阵中为每个像素分配一个权重,因此它需要知道有多少个像素,但会自动对每个像素应用卷积。正如我们在上一节中看到的,权重仅取决于输入和输出通道的数量以及内核大小。
想想输出的形状是什么;那么让我们试试看:
broken_cnn(xb).shape
torch.Size([64, 1, 28, 28])
这不是我们可以用来进行分类的东西,因为我们需要每个图像的单个输出激活,而不是 28×28 的激活图。解决这个问题的一种方法是使用足够的 stride-2 卷积,使得最后一层的大小为 1。经过一个 stride-2 卷积后,大小将为 14×14;两次之后,它将是 7×7;然后是 4×4、2×2,最后是尺寸 1。
让我们现在试试看。首先,我们将定义一个函数,其中包含我们将在每个卷积中使用的基本参数:
- def conv(ni, nf, ks=3, act=True):
- res = nn.Conv2d(ni, nf, stride=2, kernel_size=ks, padding=ks//2)
- if act: res = nn.Sequential(res, nn.ReLU())
- return res
当我们使用 stride-2 卷积时,我们经常同时增加特征的数量。这是因为我们将激活图中的激活数量减少了 4 倍;我们不想一次减少太多层的容量。
频道和功能
下面是我们如何构建一个简单的 CNN:
- simple_cnn = sequential(
- conv(1 ,4), #14x14
- conv(4 ,8), #7x7
- conv(8 ,16), #4x4
- conv(16,32), #2x2
- conv(32,2, act=False), #1x1
- Flatten(),
- )
在每次卷积之后添加像此处这样的注释,以显示每层之后激活图的大小。这些注释假定输入大小为 28×28。
现在网络输出两个激活,它们映射到我们标签中的两个可能级别:
simple_cnn(xb).shape
torch.Size([64, 2])
我们现在可以创建我们的Learner
:
learn = Learner(dls, simple_cnn, loss_func=F.cross_entropy, metrics=accuracy)
要准确查看模型中发生了什么,我们可以使用summary
:
learn.summary()
Sequential (Input shape: ['64 x 1 x 28 x 28']) ================================================================ Layer (type) Output Shape Param # Trainable ================================================================ Conv2d 64 x 4 x 14 x 14 40 True ________________________________________________________________ ReLU 64 x 4 x 14 x 14 0 False ________________________________________________________________ Conv2d 64 x 8 x 7 x 7 296 True ________________________________________________________________ ReLU 64 x 8 x 7 x 7 0 False ________________________________________________________________ Conv2d 64 x 16 x 4 x 4 1,168 True ________________________________________________________________ ReLU 64 x 16 x 4 x 4 0 False ________________________________________________________________ Conv2d 64 x 32 x 2 x 2 4,640 True ________________________________________________________________ ReLU 64 x 32 x 2 x 2 0 False ________________________________________________________________ Conv2d 64 x 2 x 1 x 1 578 True ________________________________________________________________ Flatten 64 x 2 0 False ________________________________________________________________ Total params: 6,722 Total trainable params: 6,722 Total non-trainable params: 0 Optimizer used:Loss function: Callbacks: - TrainEvalCallback - Recorder - ProgressCallback
请注意,最后Conv2d
一层的输出是64x2x1x1
。我们需要删除那些多余的1x1
轴;就是Flatten
这样。它与 PyTorch 的squeeze
方法基本相同,但作为一个模块。
让我们看看这是否训练!由于这是一个比我们之前从头开始构建的网络更深的网络,我们将使用更低的学习率和更多的时期:
learn.fit_one_cycle(2, 0.01)
epoch | train_loss | vaild_loss | accuracy | time |
---|---|---|---|---|
0 | 0.072684 | 0.045110 | 0.990186 | 00:05 |
1 | 0.022580 | 0.030775 | 0.990186 | 00:05 |
成功!它越来越接近我们的resnet18
结果,虽然还不完全是,而且它需要更多的时间,我们需要使用较低的学习率。我们还有一些技巧需要学习,但我们越来越接近能够从头开始创建现代 CNN。
我们可以从摘要中看到我们有一个 size 的输入64x1x28x28
。轴是batch,channel,height,width
. 这通常表示为 NCHW
(其中N
指批量大小)。另一方面,TensorFlow 使用NHWC
轴顺序。这是第一层:
- m = learn.model[0]
- m
Sequential( (0): Conv2d(1, 4, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1)) (1): ReLU() )
所以我们有 1 个输入通道、4 个输出通道和一个 3×3 内核。让我们检查第一个卷积的权重:
m[0].weight.shape
torch.Size([4, 1, 3, 3])
摘要显示我们有40个参数,4*1*3*3
是36个。其他四个参数是什么?让我们看看偏差包含什么:
m[0].bias.shape
torch.Size([4])
我们现在可以使用这些信息来澄清我们在上一节中的陈述:“当我们使用 stride-2 卷积时,我们通常会增加特征的数量,因为我们正在减少 激活图中的激活数乘以 4;我们不想一次减少太多层的容量。”
每个通道都有一个偏差。(有时 当通道不是输入通道时,它们被称为特征或过滤器64x4x14x14
。)输出形状是,因此这将成为下一层的输入形状。根据 ,下一层 summary
有 296 个参数。为了简单起见,让我们忽略批处理轴。因此,对于每个14*14=196
位置,我们乘以 296-8=288
权重(为简单起见忽略偏差),所以这是196*288=56_448
这一层的乘法。下一层将有7*7*(1168-16)=56_448
乘法。
这里发生的事情是,我们的 stride-2 卷积将网格大小减半, 从14x14
到7x7
,我们将过滤器的数量从 8 个增加到 16 个,导致计算量没有整体变化。如果我们在每个 stride-2 层中保留相同的通道数,那么随着网络越来越深,网络中进行的计算量会越来越少。但我们知道更深层必须计算语义丰富的特征(例如眼睛或毛发),因此我们不认为减少计算量会有意义。
另一种思考方式是基于感受野。
感受Fields是参与层计算的图像区域。在本书的网站上,您会找到一个名为conv-example.xlsx的 Excel 电子表格显示了使用 MNIST 数字计算两个 stride-2 卷积层。每一层都有一个内核。图 13-10显示了如果我们单击conv2 部分中的一个单元格(显示第二个卷积层的输出)并单击跟踪先例,我们会看到什么。
图 13-10。Conv2层的直接先例
在这里,带有绿色边框的单元格是我们单击的单元格,蓝色突出显示的单元格是它的先例——用于计算它的值的单元格。这些单元格是来自输入层(左侧)的相应 3×3 单元格区域,以及来自过滤器(右侧)的单元格。现在让我们再次单击跟踪先例,以查看使用哪些单元格来计算这些输入。图 13-11显示了发生的情况。
图 13-11。Conv2层的二级先例
在此示例中,我们只有两个卷积层,每个卷积层的步幅为 2,因此现在可以追溯到输入图像。我们可以看到输入层中 7×7 的单元格区域用于计算 Conv2 层中的单个绿色单元格。这个 7×7 的区域是 Conv2 中绿色激活输入的感受野。我们还可以看到现在需要第二个过滤器内核,因为我们有两层。
正如你从这个例子中看到的,我们在网络中越深(具体来说,我们在一层之前有越多的 stride-2 convs),该层中激活的感受野就越大。大的感受野意味着大量的输入图像用于计算该层中的每个激活。我们现在知道,在网络的更深层,我们具有语义丰富的特征,对应于更大的感受野。因此,我们希望我们的每个特征都需要更多的权重来处理这种日益增加的复杂性。这是我们在上一节中提到的同一件事的另一种说法:当我们在网络中引入 stride-2 conv 时,我们还应该增加通道数。
在编写这一章时,我们有很多问题需要回答,以便能够尽可能地向您解释 CNN。信不信由你,我们在 Twitter 上找到了大部分答案。在我们继续讨论彩色图像之前,我们现在将短暂休息一下与您讨论这个问题。
至少可以说,我们不是一般社交网络的大用户。但我们写这本书的目标是帮助你成为最好的深度学习 从业者你可以,而且我们不能不提 Twitter 在我们自己的深度学习之旅中的重要性。
你看,推特还有另一部分,远离唐纳德特朗普和卡戴珊家族,深度学习研究人员和从业者每天都在这里讨论。在我们撰写本节时,Jeremy 想要仔细检查我们所说的关于 stride-2 卷积的内容是否准确,因此他在 Twitter 上问道:
几分钟后,弹出了这个答案:
Christian Szegedy 是 Inception的第一作者,2014 年 ImageNet 获胜者,以及现代神经网络中使用的许多关键见解的来源。两个小时后,出现了:
你认得那个名字吗?您在第 2 章中看到过,当时我们正在谈论为今天的深度学习奠定基础的图灵奖获得者!
Jeremy 还在 Twitter 上请求帮助检查我们在第 7 章中对标签平滑的描述是否准确,并再次直接从 Christian Szegedy 那里得到了回应(标签平滑最初在 Inception 论文中介绍):
当今深度学习领域的许多顶尖人物都是 Twitter 的常客,并且非常乐于与更广泛的社区互动。开始的一个好方法是查看 Jeremy 最近的 Twitter 点赞列表,或 Sylvain 的点赞列表。这样,您就可以看到我们认为有有趣和有用的事情要说的 Twitter 用户列表。
Twitter 是我们了解最新有趣论文、软件发布和其他深度学习新闻的主要方式。为了与深度学习社区建立联系,我们建议同时参与fast.ai 论坛和 Twitter。
也就是说,让我们回到本章的主题。到目前为止,我们只向您展示了黑白图片示例,每个像素一个值。实际上,大多数彩色图像的每个像素都有三个值来定义它们的颜色。接下来我们将研究如何处理彩色图像。
- im = image2tensor(Image.open(image_bear()))
- im.shape
torch.Size([3, 1000, 846])
show_image(im);
第一个轴包含红色、绿色和蓝色通道(此处用相应的颜色图突出显示):
- _,axs = subplots(1,3)
- for bear,ax,color in zip(im,axs,('Reds','Greens','Blues')):
- show_image(255-bear, ax=ax, cmap=color)
我们看到了图像的一个通道上的一个滤波器的卷积运算是什么(我们的例子是在一个正方形上完成的)。卷积层将获取具有一定数量通道的图像(常规 RGB 彩色图像的第一层为三个)并输出具有不同数量通道的图像。与表示线性层中神经元数量的隐藏大小一样,我们可以决定拥有任意数量的过滤器,并且每个过滤器都能够专门化(一些用于检测水平边缘,另一些用于检测垂直边缘,等等)第四)给出类似于我们在第 2 章中学习的示例的内容。
在一个滑动窗口中,我们有一定数量的通道,我们需要尽可能多的过滤器(我们不会对所有通道使用相同的内核)。所以我们的内核大小不是 3×3,而是 ch_in
(对于输入的通道)是 3×3。在每个通道上,我们将窗口的元素乘以相应过滤器的元素,然后对结果求和(如前所述)并对所有过滤器求和。在图 13-12给出的示例中,我们的卷积层在该窗口上的结果是红色 + 绿色 + 蓝色。
图 13-12。对 RGB 图像进行卷积
因此,为了将卷积应用于彩色图片,我们需要一个大小与第一个轴匹配的核张量。在每个位置,内核和图像补丁的相应部分相乘在一起。
然后将这些全部加在一起,为每个输出要素的每个网格位置生成一个数字,如图 13-13所示。
图 13-13。添加 RGB 滤镜
然后我们有这样的ch_out
过滤器,所以最后,我们的卷积层的结果将是一批图像,这些图像具有ch_out
通道以及前面概述的公式给出的高度和宽度。这给了我们在一个四维的大张量中表示ch_out
的大小张量。ch_in x ks x ks
在 PyTorch 中,这些权重的维度顺序是ch_out x ch_in x ks x ks
.
此外,我们可能希望每个过滤器都有一个偏差。在前面的例子中,我们的卷积层的最终结果是 是R+是G+是乙+b在这种情况下。与在线性层中一样,有多少个内核就有多少个偏差,所以偏差是一个大小为 的向量ch_out
。
设置 CNN 以使用彩色图像进行训练时不需要特殊机制。只要确保你的第一层有三个输入。
有很多处理彩色图像的方法。例如,您可以将它们更改为黑色和白色,从 RGB 更改为 HSV(色调、饱和度和值)颜色空间,等等。一般来说,实验表明,只要您不在转换中丢失信息,更改颜色编码不会对您的模型结果产生任何影响。因此,转换为黑白不是一个好主意,因为它完全删除了颜色信息(这可能很关键;例如,宠物品种可能具有独特的颜色);但转换为 HSV 通常不会有任何区别。
现在你知道Zeiler 和 Fergus 论文中“神经网络学习的内容”第 1 章中的那些图片是什么意思了!提醒一下,这是他们对一些第 1 层权重的图片:
这是为每个输出特征获取卷积核的三个切片,并将它们显示为图像。我们可以看到,即使神经网络的创建者从未明确创建内核来寻找边,例如,神经网络使用 SGD 自动发现了这些特征。
由于我们非常擅长从 7 中识别 3,让我们继续进行更难的事情——识别所有 10 位数字。这意味着我们需要使用MNIST
而不是 MNIST_SAMPLE
:
path = untar_data(URLs.MNIST)
path.ls()
(#2) [Path('testing'),Path('training')]
数据位于名为training和testing的两个文件夹中,因此我们必须说明 GrandparentSplitter
这一点(默认为train
和 valid
)。我们在get_dls
函数中执行此操作,我们定义该函数以便稍后更改批量大小:
- def get_dls(bs=64):
- return DataBlock(
- blocks=(ImageBlock(cls=PILImageBW), CategoryBlock),
- get_items=get_image_files,
- splitter=GrandparentSplitter('training','testing'),
- get_y=parent_label,
- batch_tfms=Normalize()
- ).dataloaders(path, bs=bs)
-
- dls = get_dls()
dls.show_batch(max_n=9, figsize=(4,4))
现在我们已经准备好数据,我们可以在其上训练一个简单的模型。
- def conv(ni, nf, ks=3, act=True):
- res = nn.Conv2d(ni, nf, stride=2, kernel_size=ks, padding=ks//2)
- if act: res = nn.Sequential(res, nn.ReLU())
- return res
让我们从一个基本的 CNN 开始作为基线。我们将使用与之前相同的一个,但有一个调整:我们将使用更多的激活。由于我们有更多的数字需要区分,我们可能需要学习更多的过滤器。
正如我们所讨论的,我们通常希望每次我们有一个 stride-2 层时将过滤器的数量加倍。增加整个网络中过滤器数量的一种方法是将第一层中的激活数量加倍——然后之后的每一层最终也将是之前版本的两倍。
但这会产生一个微妙的问题。考虑应用于每个像素的内核。默认情况下,我们使用 3×3 像素的内核。因此,在每个位置总共有 3 × 3 = 9 个像素应用内核。以前,我们的第一层有四个输出过滤器。因此,从每个位置的九个像素计算出四个值。想想如果我们将这个输出加倍到八个过滤器会发生什么。然后当我们应用内核时,我们将使用九个像素来计算八个数字。这意味着它并没有真正学到太多东西:输出大小几乎与输入大小相同。神经网络只有在被迫这样做时才会创建有用的特征——也就是说,如果操作的输出数量明显小于输入数量。
为了解决这个问题,我们可以在第一层使用更大的内核。如果我们使用 5×5 像素的内核,则每个内核应用程序将使用 25 个像素。从中创建八个过滤器意味着神经网络必须找到一些有用的特征:
- def simple_cnn():
- return sequential(
- conv(1 ,8, ks=5), #14x14
- conv(8 ,16), #7x7
- conv(16,32), #4x4
- conv(32,64), #2x2
- conv(64,10, act=False), #1x1
- Flatten(),
- )
正如您稍后会看到的,我们可以在模型训练时查看模型内部,以尝试找到让它们训练得更好的方法。为此,我们使用 ActivationStats
回调,它记录每个可训练层的激活的均值、标准差和直方图(正如我们所见,回调用于向训练循环添加行为;我们将探讨它们的工作原理在 第 16 章中):
from fastai.callback.hook import *
我们想要快速训练,这意味着以高学习率进行训练。让我们看看我们在 0.06 时的表现:
- def fit(epochs=1):
- learn = Learner(dls, simple_cnn(), loss_func=F.cross_entropy,
- metrics=accuracy, cbs=ActivationStats(with_hist=True))
- learn.fit(epochs, 0.06)
- return learn
learn = fit()
epoch | train_loss | vaild_loss | accuracy | time |
---|---|---|---|---|
0 | 2.307071 | 2.305865 | 0.113500 | 00:16 |
这根本没有训练好!让我们找出原因。
传递给回调的一个方便的特性Learner
是它们自动可用,与回调类同名, 除了在snake_case
。所以,我们的ActivationStats
回调可以通过访问activation_stats
。我相信您还记得learn.recorder
……您能猜出它是如何实现的吗?没错,就是回调叫Recorder
!
ActivationStats
包括一些方便的实用程序,用于在训练期间绘制激活图。绘制均值和plot_layer_stats(idx)
层数激活的标准偏差idx
,以及接近零的激活百分比。这是第一层的情节:
learn.activation_stats.plot_layer_stats(0)
通常,我们的模型在训练期间应该具有一致的或至少平滑的层激活均值和标准差。接近零的激活尤其有问题,因为这意味着我们在模型中进行的计算根本不做任何事情(因为乘以零得到零)。当你在一层中有一些零时,它们通常会转移到下一层……然后会产生更多的零。这是我们网络的倒数第二层:
learn.activation_stats.plot_layer_stats(-2)
使训练更稳定的一种方法是增加批量大小。较大的批次具有更准确的梯度,因为它们是根据更多数据计算的。但不利的一面是,更大的批次大小意味着每个时期更少的批次,这意味着更少 您的模型更新权重的机会。让我们看看批量大小为 512 是否有帮助:
dls = get_dls(512)
learn = fit()
epoch | train_loss | vaild_loss | accuracy | time |
---|---|---|---|---|
0 | 2.309385 | 2.302744 | 0.113500 | 00:08 |
让我们看看倒数第二层是什么样的:
learn.activation_stats.plot_layer_stats(-2)
同样,我们的大部分激活都接近于零。让我们看看我们还能做些什么来提高训练的稳定性。
我们的初始权重不太适合我们要解决的任务。因此,以高学习率开始训练是危险的:正如我们所见,我们很可能使训练立即发散。我们大概 也不想以高学习率结束训练,这样我们就不会跳过最低限度。但是我们希望在剩余的训练期间以高学习率进行训练,因为那样我们可以更快地进行训练。因此,我们应该在训练过程中改变学习率,从低到高,然后再变回低。
Leslie Smith(是的,就是发明学习率查找器的那个人!)在他的文章中提出了这个想法 “超收敛:使用大学习率非常快速地训练神经网络”。他设计了一个学习率时间表,分为两个阶段:一个是学习率从最小值增长到最大值(预热),另一个是下降到最小值(退火)。史密斯 将这种方法组合称为1cycle training。
1cycle 训练允许我们使用比其他类型的训练高得多的最大学习率,这有两个好处:
通过以更高的学习率进行训练,我们训练得更快——史密斯称这种现象为超收敛。
通过以更高的学习率进行训练,我们可以减少过度拟合,因为我们跳过了尖锐的局部最小值,最终得到了更平滑(因此更具泛化性)的损失部分。
第二点很有趣,也很微妙。它基于这样的观察:一个泛化能力很好的模型是一个如果你少量改变输入,它的损失不会改变太多的模型。如果一个模型以较大的学习率训练了很长一段时间,并且在这样做时可以找到一个很好的损失,那么它一定已经找到了一个泛化能力也很好的区域,因为它在批次之间跳来跳去很多(基本上高学习率的定义)。问题是,正如我们已经讨论过的那样,仅仅跳到高学习率更有可能导致不同的损失,而不是看到你的损失有所改善。所以我们不会直接跳到高学习率。相反,我们从低学习率开始,我们的损失不会发散,
然后,一旦我们为参数找到了一个很好的平滑区域,我们就想找到该区域的最佳部分,这意味着我们必须再次降低学习率。这就是为什么 1cycle 训练有渐进的学习率预热和渐进的学习率冷却。许多研究人员发现,在实践中,这种方法会导致更准确的模型和更快的训练。这就是为什么fine_tune
在 fastai 中默认使用这种方法的原因。
在第 16 章中,我们将了解 SGD 中的动量。简而言之,动量是一种技术,优化器不仅可以在梯度的方向上迈出一步,而且还可以在先前步骤的方向上继续前进。莱斯利·史密斯介绍了 “神经网络超参数的规范方法:第 1 部分”中的循环动量。这表明动量的变化方向与学习率相反:当我们处于高学习率时,我们使用较少的动量,而在退火阶段我们再次使用更多。
我们可以通过调用在 fastai 中使用 1cycle 训练fit_one_cycle
:
- def fit(epochs=1, lr=0.06):
- learn = Learner(dls, simple_cnn(), loss_func=F.cross_entropy,
- metrics=accuracy, cbs=ActivationStats(with_hist=True))
- learn.fit_one_cycle(epochs, lr)
- return learn
learn = fit()
epoch | train_loss | vaild_loss | accuracy | time |
---|---|---|---|---|
0 | 0.210838 | 0.084827 | 0.974300 | 00:08 |
我们终于取得了一些进展!它现在给了我们一个合理的准确性。
plot_sched
我们可以通过调用来查看整个训练过程中的学习率和动量learn.recorder
。learn.recorder
(作为名字 建议)记录训练过程中发生的一切,包括损失、指标和超参数,如学习率和动量:
learn.recorder.plot_sched()
Smith 最初的 1cycle 论文使用了线性预热和线性退火。如您所见,我们通过将 fastai 中的方法与另一种流行的方法相结合来对其进行了调整:余弦退火。 fit_one_cycle
提供以下您可以调整的参数:
lr_max
将使用的最高学习率(这也可以是每个层组的学习率列表,或 slice
包含第一个和最后一个层组学习率的 Python 对象)
div
除以多少lr_max
得到起始学习率
div_final
除以多少 lr_max
得到最终学习率
pct_start
用于预热的批次百分比
moms
元组,其中是初始动量,是最小动量,是最终动量(mom1,mom2,mom3)
mom1
mom2
mom3
让我们再次看一下图层统计信息:
learn.activation_stats.plot_layer_stats(-2)
接近零权重的百分比越来越好,尽管它仍然很高。color_dim
我们可以通过使用向它传递一个图层索引来了解更多关于我们训练中发生的事情:
learn.activation_stats.color_dim(-2)
color_dim
由 fast.ai 与学生 Stefano Giomo 共同开发。Giomo 将这个想法称为色彩维度,他提供了一个 深入解释方法背后的历史和细节。基本思想是创建层激活的直方图,我们希望它遵循正态分布等平滑模式(图 13-14)。
图 13-14。彩色维度的直方图(由 Stefano Giomo 提供)
为了创建color_dim
,我们采用左侧显示的直方图并将其转换为底部显示的彩色表示。然后我们将它翻转过来,如右图所示。我们发现如果取直方图值的对数,分布会更清晰。然后,Giomo 描述:
每个层的最终图是通过沿水平轴堆叠每个批次的激活直方图来绘制的。因此,可视化中的每个垂直切片代表单个批次的激活直方图。颜色强度对应直方图的高度;换句话说,每个直方图 bin 中的激活数。
图 13-15显示了这一切是如何组合在一起的。
图 13-15。多彩维度的总结(由 Stefano Giomo 提供)
这说明了当f服从正态分布时,为什么 log( f ) 比f更丰富多彩,因为取对数会改变二次方的高斯曲线,二次曲线并不那么窄。
因此,考虑到这一点,让我们再看一下倒数第二层的结果:
learn.activation_stats.color_dim(-2)
这显示了“不良训练”的经典画面。我们从几乎所有激活都为零开始——这就是我们在最左边看到的,所有的都是深蓝色。底部的亮黄色代表接近零的激活。然后,在前几批中,我们看到非零激活的数量呈指数增长。但它走得太远而崩溃了!我们看到深蓝色回归,底部再次变成亮黄色。看起来培训几乎是从头开始的。然后我们看到激活再次增加并再次崩溃。重复几次后,我们最终会看到整个范围内的激活分布。
如果训练能从一开始就顺利进行,那就更好了。指数增长然后崩溃的循环往往会导致大量接近零的激活,从而导致训练缓慢和最终结果不佳。解决这个问题的一种方法是使用批量归一化。
为了解决上一节中训练缓慢和最终结果不佳的问题,我们需要修复初始的大百分比 接近零激活,然后尝试在整个训练过程中保持良好的激活分布。
Sergey Ioffe 和 Christian Szegedy 在 2015 年的论文“Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift”。在摘要中,他们只描述了我们已经看到的问题:
训练深度神经网络很复杂,因为每一层输入的分布在训练过程中都会发生变化,因为前一层的参数会发生变化。这通过要求较低的学习率和仔细的参数初始化来减慢训练速度……我们将这种现象称为内部协变量偏移,并通过规范化层输入来解决这个问题。
他们的解决方案,他们说如下:
使规范化成为模型架构的一部分,并对每个训练小批量执行规范化。Batch Normalization 允许我们使用更高的学习率并且对初始化不那么小心。
这篇论文一发表就引起了极大的轰动,因为它包含了图 13-16中的图表,它清楚地表明批归一化可以训练出比当前最先进的模型( Inception 架构)更准确的模型大约快 5 倍。
图 13-16。批量归一化的影响(由 Sergey Ioffe 和 Christian Szegedy 提供)
批量归一化(通常称为batchnorm)的工作原理是取一层激活的均值和标准差的平均值,并使用它们对激活进行归一化。然而,这可能会导致问题,因为网络可能希望某些激活非常高,以便做出准确的预测。因此他们还添加了两个可学习的参数(意味着它们将在 SGD 步骤中更新),通常称为gamma
和beta
。在对激活进行归一化以获得一些新的激活向量y
之后,batchnorm 层返回gamma*y + beta
。
这就是为什么我们的激活可以有任何均值或方差,独立于前一层结果的均值和标准差。这些统计数据是单独学习的,使我们的模型更容易训练。训练和验证期间的行为是不同的:在训练期间,我们使用批次的均值和标准差来规范化数据,而在验证期间,我们使用训练期间计算的统计数据的运行平均值。
让我们添加一个 batchnorm 层到conv
:
- def conv(ni, nf, ks=3, act=True):
- layers = [nn.Conv2d(ni, nf, stride=2, kernel_size=ks, padding=ks//2)]
- layers.append(nn.BatchNorm2d(nf))
- if act: layers.append(nn.ReLU())
- return nn.Sequential(*layers)
并适合我们的模型:
learn = fit()
epoch | train_loss | vaild_loss | accuracy | time |
---|---|---|---|---|
0 | 0.130036 | 0.055021 | 0.986400 | 00:10 |
这是一个很好的结果!让我们来看看 color_dim
:
learn.activation_stats.color_dim(-4)
这正是我们希望看到的:激活的顺利发展,没有“崩溃”。Batchnorm 在这里真的兑现了它的承诺!事实上,batchnorm 非常成功,以至于我们在几乎所有现代神经网络中都能看到它(或非常相似的东西)。
关于包含批量归一化层的模型的一个有趣观察是,它们往往比不包含它们的模型具有更好的泛化能力。虽然我们还没有看到对这里发生的事情进行严格的分析,但大多数研究人员认为,原因是批量归一化为训练过程增加了一些额外的随机性。每个 mini-batch 的均值和标准差与其他 mini-batch 有所不同。因此,每次都会用不同的值对激活进行归一化。为了让模型做出准确的预测,它必须学会对这些变化变得稳健。通常,在训练过程中添加额外的随机化通常会有所帮助。
既然一切进展顺利,让我们再训练几个 epoch,看看进展如何。事实上,让我们提高 学习率,因为 batchnorm 论文的摘要声称我们应该能够“以更高的学习率进行训练”:
learn = fit(5, lr=0.1)
epoch | train_loss | vaild_loss | accuracy | time |
---|---|---|---|---|
0 | 0.191731 | 0.121738 | 0.960900 | 00:11 |
1 | 0.083739 | 0.055808 | 0.981800 | 00:10 |
2 | 0.053161 | 0.044485 | 0.987100 | 00:10 |
3 | 0.034433 | 0.030233 | 0.990200 | 00:10 |
4 | 0.017646 | 0.025407 | 0.991200 | 00:10 |
learn = fit(5, lr=0.1)
epoch | train_loss | vaild_loss | accuracy | time |
---|---|---|---|---|
0 | 0.183244 | 0.084025 | 0.975800 | 00:13 |
1 | 0.080774 | 0.067060 | 0.978800 | 00:12 |
2 | 0.050215 | 0.062595 | 0.981300 | 00:12 |
3 | 0.030020 | 0.030315 | 0.990700 | 00:12 |
4 | 0.015131 | 0.025148 | 0.992100 | 00:12 |
我们已经看到卷积只是一种矩阵乘法,对权重矩阵有两个约束:一些元素始终为零,而一些元素是并列的(强制始终具有相同的值)。在第 1 章中,我们看到了 1986 年出版的并行分布式处理一书中的八个要求;其中之一是“单元之间的连接模式”。这正是这些约束的作用:它们强制执行某种连接模式。
这些约束允许我们在模型中使用更少的参数,而不会牺牲表示复杂视觉特征的能力。这意味着我们可以更快地训练更深层次的模型,同时减少过度拟合。尽管通用逼近定理表明应该可以 在一个隐藏层中表示完全连接的网络中的任何东西,但我们现在已经看到,在实践中,我们可以通过考虑网络架构来训练更好的模型。
卷积是迄今为止我们在神经网络中看到的最常见的连接模式(连同常规线性层,我们称之为 完全连接),但很可能还会发现更多。
我们还看到了如何解释网络中层的激活以查看训练是否进行顺利,以及 batchnorm 如何帮助规范 训练并使其更流畅。在下一章中,我们将使用这两个层来构建计算机视觉中最流行的架构:残差网络。