原文:
www.bookstack.cn/read/th-fastai-book
译者:飞龙
第五章:图像分类
原文:
www.bookstack.cn/read/th-fastai-book/0661b9d7375f45ab.md
译者:飞龙
现在您了解了深度学习是什么、它的用途以及如何创建和部署模型,现在是时候深入了!在理想的世界中,深度学习从业者不必了解每个细节是如何在底层工作的。但事实上,我们还没有生活在理想的世界中。事实是,要使您的模型真正起作用并可靠地工作,您必须正确处理很多细节,并检查很多细节。这个过程需要能够在训练神经网络时查看内部情况,找到可能的问题,并知道如何解决它们。
因此,从本书开始,我们将深入研究深度学习的机制。计算机视觉模型的架构是什么,自然语言处理模型的架构是什么,表格模型的架构是什么等等?如何创建一个与您特定领域需求匹配的架构?如何从训练过程中获得最佳结果?如何加快速度?随着数据集的变化,您必须做出哪些改变?
我们将从重复第一章中查看的相同基本应用程序开始,但我们将做两件事:
-
让它们变得更好。
-
将它们应用于更多类型的数据。
为了做这两件事,我们将不得不学习深度学习难题的所有部分。这包括不同类型的层、正则化方法、优化器、如何将层组合成架构、标记技术等等。但我们不会一次性把所有这些东西都扔给你;我们将根据需要逐步引入它们,以解决与我们正在处理的项目相关的实际问题。
从狗和猫到宠物品种
在我们的第一个模型中,我们学会了如何区分狗和猫。就在几年前,这被认为是一个非常具有挑战性的任务——但今天,这太容易了!我们将无法向您展示训练模型时的细微差别,因为我们在不担心任何细节的情况下获得了几乎完美的结果。但事实证明,同一数据集还允许我们解决一个更具挑战性的问题:找出每张图像中显示的宠物品种是什么。
在第一章中,我们将应用程序呈现为已解决的问题。但这不是实际情况下的工作方式。我们从一个我们一无所知的数据集开始。然后我们必须弄清楚它是如何组合的,如何从中提取我们需要的数据,以及这些数据是什么样子的。在本书的其余部分,我们将向您展示如何在实践中解决这些问题,包括理解我们正在处理的数据以及在进行建模时测试的所有必要中间步骤。
我们已经下载了宠物数据集,并且可以使用与第一章相同的代码获取到该数据集的路径:
from fastai2.vision.all import *
path = untar_data(URLs.PETS)
现在,如果我们要理解如何从每个图像中提取每只宠物的品种,我们需要了解数据是如何布局的。数据布局的细节是深度学习难题的重要组成部分。数据通常以以下两种方式之一提供:
-
表示数据项的个别文件,例如文本文档或图像,可能组织成文件夹或具有表示有关这些项信息的文件名
-
数据表(例如,以 CSV 格式)中的数据,其中每行是一个项目,可能包括文件名,提供表中数据与其他格式(如文本文档和图像)中数据之间的连接
有一些例外情况——特别是在基因组学等领域,可能存在二进制数据库格式或甚至网络流——但总体而言,您将处理的绝大多数数据集将使用这两种格式的某种组合。
要查看数据集中的内容,我们可以使用ls
方法:
path.ls()
(#3) [Path('annotations'),Path('images'),Path('models')]
我们可以看到这个数据集为我们提供了images和annotations目录。数据集的网站告诉我们annotations目录包含有关宠物所在位置而不是它们是什么的信息。在本章中,我们将进行分类,而不是定位,也就是说我们关心的是宠物是什么,而不是它们在哪里。因此,我们暂时会忽略annotations目录。那么,让我们来看看images目录里面的内容:
(path/"images").ls()
(#7394) [Path('images/great_pyrenees_173.jpg'),Path('images/wheaten_terrier_46.j
> pg'),Path('images/Ragdoll_262.jpg'),Path('images/german_shorthaired_3.jpg'),P
> ath('images/american_bulldog_196.jpg'),Path('images/boxer_188.jpg'),Path('ima
> ges/staffordshire_bull_terrier_173.jpg'),Path('images/basset_hound_71.jpg'),P
> ath('images/staffordshire_bull_terrier_37.jpg'),Path('images/yorkshire_terrie
> r_18.jpg')...]
在 fastai 中,大多数返回集合的函数和方法使用一个名为L
的类。这个类可以被认为是普通 Python list
类型的增强版本,具有用于常见操作的附加便利。例如,当我们在笔记本中显示这个类的对象时,它会以这里显示的格式显示。首先显示的是集合中的项目数,前面带有#
。在前面的输出中,你还会看到列表后面有省略号。这意味着只显示了前几个项目,这是件好事,因为我们不希望屏幕上出现超过 7000 个文件名!
通过检查这些文件名,我们可以看到它们似乎是如何结构化的。每个文件名包含宠物品种,然后是一个下划线(_
),一个数字,最后是文件扩展名。我们需要创建一段代码,从单个Path
中提取品种。Jupyter 笔记本使这变得容易,因为我们可以逐渐构建出可用的东西,然后用于整个数据集。在这一点上,我们必须小心不要做太多假设。例如,如果你仔细观察,你可能会注意到一些宠物品种包含多个单词,因此我们不能简单地在找到的第一个_
字符处中断。为了让我们能够测试我们的代码,让我们挑选出一个这样的文件名:
fname = (path/"images").ls()[0]
从这样的字符串中提取信息的最强大和灵活的方法是使用regular expression,也称为regex。正则表达式是一种特殊的字符串,用正则表达式语言编写,它指定了一个一般规则,用于决定另一个字符串是否通过测试(即“匹配”正则表达式),并且可能用于从另一个字符串中提取特定部分。在这种情况下,我们需要一个正则表达式从文件名中提取宠物品种。
我们没有空间在这里为您提供完整的正则表达式教程,但有许多优秀的在线教程,我们知道你们中的许多人已经熟悉这个神奇的工具。如果你不熟悉,那完全没问题——这是一个让你纠正的绝佳机会!我们发现正则表达式是我们编程工具包中最有用的工具之一,我们的许多学生告诉我们,这是他们最兴奋学习的事情之一。所以赶紧去谷歌搜索“正则表达式教程”吧,然后在你看得很开心之后回到这里。书籍网站也提供了我们喜欢的教程列表。
亚历克西斯说
正则表达式不仅非常方便,而且还有有趣的起源。它们之所以被称为“regular”,是因为它们最初是“regular”语言的示例,这是乔姆斯基层次结构中最低的一级。这是语言学家诺姆·乔姆斯基开发的一种语法分类,他还写了《句法结构》,这是一项寻找人类语言基础形式语法的开创性工作。这是计算的魅力之一:你每天使用的工具可能实际上来自太空船。
当你编写正则表达式时,最好的方法是首先针对一个示例尝试。让我们使用findall
方法来对fname
对象的文件名尝试一个正则表达式:
re.findall(r'(.+)_\d+.jpg$', fname.name)
['great_pyrenees']
这个正则表达式提取出所有字符,直到最后一个下划线字符,只要后续字符是数字,然后是 JPEG 文件扩展名。
现在我们确认了正则表达式对示例的有效性,让我们用它来标记整个数据集。fastai 提供了许多类来帮助标记。对于使用正则表达式进行标记,我们可以使用RegexLabeller
类。在这个例子中,我们使用了数据块 API,我们在第二章中看到过(实际上,我们几乎总是使用数据块 API——它比我们在第一章中看到的简单工厂方法更灵活):
pets = DataBlock(blocks = (ImageBlock, CategoryBlock),
get_items=get_image_files,
splitter=RandomSplitter(seed=42),
get_y=using_attr(RegexLabeller(r'(.+)_\d+.jpg$'), 'name'),
item_tfms=Resize(460),
batch_tfms=aug_transforms(size=224, min_scale=0.75))
dls = pets.dataloaders(path/"images")
这个DataBlock
调用中一个重要的部分是我们以前没有见过的这两行:
item_tfms=Resize(460),
batch_tfms=aug_transforms(size=224, min_scale=0.75)
这些行实现了一个我们称之为预调整的 fastai 数据增强策略。预调整是一种特殊的图像增强方法,旨在最大限度地减少数据破坏,同时保持良好的性能。
预调整
我们需要我们的图像具有相同的尺寸,这样它们可以整合成张量传递给 GPU。我们还希望最小化我们执行的不同增强计算的数量。性能要求表明,我们应该尽可能将我们的增强变换组合成更少的变换(以减少计算数量和损失操作的数量),并将图像转换为统一尺寸(以便在 GPU 上更有效地处理)。
挑战在于,如果在调整大小到增强尺寸之后执行各种常见的数据增强变换,可能会引入虚假的空白区域,降低数据质量,或两者兼而有之。例如,将图像旋转 45 度会在新边界的角落区域填充空白,这不会教会模型任何东西。许多旋转和缩放操作将需要插值来创建像素。这些插值像素是从原始图像数据派生的,但质量较低。
为了解决这些挑战,预调整采用了图 5-1 中显示的两种策略:
-
将图像调整为相对“大”的尺寸,即明显大于目标训练尺寸。
-
将所有常见的增强操作(包括调整大小到最终目标大小)组合成一个,并在 GPU 上一次性执行组合操作,而不是单独执行操作并多次插值。
第一步是调整大小,创建足够大的图像,使其内部区域有多余的边距,以允许进一步的增强变换而不会产生空白区域。这个转换通过调整大小为一个正方形,使用一个大的裁剪尺寸来实现。在训练集上,裁剪区域是随机选择的,裁剪的大小被选择为覆盖图像宽度或高度中较小的那个。在第二步中,GPU 用于所有数据增强,并且所有潜在破坏性操作都一起完成,最后进行单次插值。
图 5-1。训练集上的预调整
这张图片展示了两个步骤:
-
裁剪全宽或全高:这在
item_tfms
中,因此它应用于每个单独的图像,然后再复制到 GPU。它用于确保所有图像具有相同的尺寸。在训练集上,裁剪区域是随机选择的。在验证集上,总是选择图像的中心正方形。 -
随机裁剪和增强:这在
batch_tfms
中,因此它一次在 GPU 上应用于整个批次,这意味着速度快。在验证集上,只有调整大小到模型所需的最终大小。在训练集上,首先进行随机裁剪和任何其他增强。
要在 fastai 中实现此过程,您可以使用Resize
作为具有大尺寸的项目转换,以及RandomResizedCrop
作为具有较小尺寸的批处理转换。如果在aug_transforms
函数中包含min_scale
参数,RandomResizedCrop
将为您添加,就像在上一节中的DataBlock
调用中所做的那样。或者,您可以在初始Resize
中使用pad
或squish
而不是crop
(默认值)。
图 5-2 显示了一个图像经过缩放、插值、旋转,然后再次插值(这是所有其他深度学习库使用的方法),显示在右侧,以及一个图像经过缩放和旋转作为一个操作,然后插值一次(fastai 方法),显示在左侧。
图 5-2。fastai 数据增强策略(左)与传统方法(右)的比较
您可以看到右侧的图像定义不够清晰,在左下角有反射填充伪影;此外,左上角的草完全消失了。我们发现,在实践中,使用预调整显著提高了模型的准确性,通常也会加快速度。
fastai 库还提供了简单的方法来检查您的数据在训练模型之前的外观,这是一个非常重要的步骤。我们将在下一步中看到这些。
检查和调试 DataBlock
我们永远不能假设我们的代码完美运行。编写DataBlock
就像编写蓝图一样。如果您的代码中有语法错误,您将收到错误消息,但是您无法保证您的模板会按照您的意图在数据源上运行。因此,在训练模型之前,您应该始终检查您的数据。
您可以使用show_batch
方法来执行此操作:
dls.show_batch(nrows=1, ncols=3)
查看每个图像,并检查每个图像是否具有正确的宠物品种标签。通常,数据科学家使用的数据可能不如领域专家熟悉:例如,我实际上不知道这些宠物品种中的许多是什么。由于我不是宠物品种的专家,我会在这一点上使用谷歌图像搜索一些这些品种,并确保图像看起来与我在输出中看到的相似。
如果在构建DataBlock
时出现错误,您可能在此步骤之前不会看到它。为了调试这个问题,我们鼓励您使用summary
方法。它将尝试从您提供的源创建一个批次,并提供大量细节。此外,如果失败,您将准确地看到错误发生的位置,并且库将尝试为您提供一些帮助。例如,一个常见的错误是忘记使用Resize
转换,因此最终得到不同大小的图片并且无法将它们整理成批次。在这种情况下,摘要将如下所示(请注意,自撰写时可能已更改确切文本,但它将给您一个概念):
pets1 = DataBlock(blocks = (ImageBlock, CategoryBlock),
get_items=get_image_files,
splitter=RandomSplitter(seed=42),
get_y=using_attr(RegexLabeller(r'(.+)_\d+.jpg$'), 'name'))
pets1.summary(path/"images")
Setting-up type transforms pipelines
Collecting items from /home/sgugger/.fastai/data/oxford-iiit-pet/images
Found 7390 items
2 datasets of sizes 5912,1478
Setting up Pipeline: PILBase.create
Setting up Pipeline: partial -> Categorize
Building one sample
Pipeline: PILBase.create
starting from
/home/sgugger/.fastai/data/oxford-iiit-pet/images/american_bulldog_83.jpg
applying PILBase.create gives
PILImage mode=RGB size=375x500
Pipeline: partial -> Categorize
starting from
/home/sgugger/.fastai/data/oxford-iiit-pet/images/american_bulldog_83.jpg
applying partial gives
american_bulldog
applying Categorize gives
TensorCategory(12)
Final sample: (PILImage mode=RGB size=375x500, TensorCategory(12))
Setting up after_item: Pipeline: ToTensor
Setting up before_batch: Pipeline:
Setting up after_batch: Pipeline: IntToFloatTensor
Building one batch
Applying item_tfms to the first sample:
Pipeline: ToTensor
starting from
(PILImage mode=RGB size=375x500, TensorCategory(12))
applying ToTensor gives
(TensorImage of size 3x500x375, TensorCategory(12))
Adding the next 3 samples
No before_batch transform to apply
Collating items in a batch
Error! It's not possible to collate your items in a batch
Could not collate the 0-th members of your tuples because got the following
shapes:
torch.Size([3, 500, 375]),torch.Size([3, 375, 500]),torch.Size([3, 333, 500]),
torch.Size([3, 375, 500])
您可以看到我们如何收集数据并拆分数据,如何从文件名转换为样本(元组(图像,类别)),然后应用了哪些项目转换以及如何在批处理中无法整理这些样本(因为形状不同)。
一旦您认为数据看起来正确,我们通常建议下一步应该使用它来训练一个简单的模型。我们经常看到人们将实际模型的训练推迟得太久。结果,他们不知道他们的基准结果是什么样的。也许您的问题不需要大量花哨的领域特定工程。或者数据似乎根本无法训练模型。这些都是您希望尽快了解的事情。
对于这个初始测试,我们将使用与第一章中使用的相同简单模型:
learn = cnn_learner(dls, resnet34, metrics=error_rate)
learn.fine_tune(2)
epoch | train_loss | valid_loss | error_rate | time |
---|---|---|---|---|
0 | 1.491732 | 0.337355 | 0.108254 | 00:18 |
epoch | train_loss | valid_loss | error_rate | time |
--- | --- | --- | --- | --- |
0 | 0.503154 | 0.293404 | 0.096076 | 00:23 |
1 | 0.314759 | 0.225316 | 0.066306 | 00:23 |
正如我们之前简要讨论过的,当我们拟合模型时显示的表格展示了每个训练周期后的结果。记住,一个周期是对数据中所有图像的完整遍历。显示的列是训练集中项目的平均损失、验证集上的损失,以及我们请求的任何指标——在这种情况下是错误率。
请记住损失是我们决定用来优化模型参数的任何函数。但是我们实际上并没有告诉 fastai 我们想要使用什么损失函数。那么它在做什么呢?fastai 通常会根据您使用的数据和模型类型尝试选择适当的损失函数。在这种情况下,我们有图像数据和分类结果,所以 fastai 会默认使用交叉熵损失。
交叉熵损失
交叉熵损失是一个类似于我们在上一章中使用的损失函数,但是(正如我们将看到的)有两个好处:
-
即使我们的因变量有两个以上的类别,它也能正常工作。
-
这将导致更快速、更可靠的训练。
要理解交叉熵损失如何处理具有两个以上类别的因变量,我们首先必须了解损失函数看到的实际数据和激活是什么样子的。
查看激活和标签
让我们看看我们模型的激活。要从我们的DataLoaders
中获取一批真实数据,我们可以使用one_batch
方法:
x,y = dls.one_batch()
正如您所见,这返回了因变量和自变量,作为一个小批量。让我们看看我们的因变量中包含什么:
y
TensorCategory([11, 0, 0, 5, 20, 4, 22, 31, 23, 10, 20, 2, 3, 27, 18, 23,
> 33, 5, 24, 7, 6, 12, 9, 11, 35, 14, 10, 15, 3, 3, 21, 5, 19, 14, 12,
> 15, 27, 1, 17, 10, 7, 6, 15, 23, 36, 1, 35, 6,
4, 29, 24, 32, 2, 14, 26, 25, 21, 0, 29, 31, 18, 7, 7, 17],
> device='cuda:5')
我们的批量大小是 64,因此在这个张量中有 64 行。每行是一个介于 0 和 36 之间的整数,代表我们 37 种可能的宠物品种。我们可以通过使用Learner.get_preds
来查看预测(我们神经网络最后一层的激活)。这个函数默认返回预测和目标,但由于我们已经有了目标,我们可以通过将其赋值给特殊变量_
来有效地忽略它们:
preds,_ = learn.get_preds(dl=[(x,y)])
preds[0]
tensor([7.9069e-04, 6.2350e-05, 3.7607e-05, 2.9260e-06, 1.3032e-05, 2.5760e-05,
> 6.2341e-08, 3.6400e-07, 4.1311e-06, 1.3310e-04, 2.3090e-03, 9.9281e-01,
> 4.6494e-05, 6.4266e-07, 1.9780e-06, 5.7005e-07,
3.3448e-06, 3.5691e-03, 3.4385e-06, 1.1578e-05, 1.5916e-06, 8.5567e-08,
> 5.0773e-08, 2.2978e-06, 1.4150e-06, 3.5459e-07, 1.4599e-04, 5.6198e-08,
> 3.4108e-07, 2.0813e-06, 8.0568e-07, 4.3381e-07,
1.0069e-05, 9.1020e-07, 4.8714e-06, 1.2734e-06, 2.4735e-06])
实际预测是 37 个介于 0 和 1 之间的概率,总和为 1:
len(preds[0]),preds[0].sum()
(37, tensor(1.0000))
为了将我们模型的激活转换为这样的预测,我们使用了一个叫做softmax的激活函数。
Softmax
在我们的分类模型中,我们在最后一层使用 softmax 激活函数,以确保激活值都在 0 到 1 之间,并且它们总和为 1。
Softmax 类似于我们之前看到的 sigmoid 函数。作为提醒,sigmoid 看起来像这样:
plot_function(torch.sigmoid, min=-4,max=4)
我们可以将这个函数应用于神经网络的一个激活列,并得到一个介于 0 和 1 之间的数字列,因此对于我们的最后一层来说,这是一个非常有用的激活函数。
现在想象一下,如果我们希望目标中有更多类别(比如我们的 37 种宠物品种)。这意味着我们需要比单个列更多的激活:我们需要一个激活每个类别。例如,我们可以创建一个预测 3 和 7 的神经网络,返回两个激活,每个类别一个——这将是创建更一般方法的一个很好的第一步。让我们只是使用一些标准差为 2 的随机数(因此我们将randn
乘以 2)作为示例,假设我们有六个图像和两个可能的类别(其中第一列代表 3,第二列代表 7):
acts = torch.randn((6,2))*2
acts
tensor([[ 0.6734, 0.2576],
[ 0.4689, 0.4607],
[-2.2457, -0.3727],
[ 4.4164, -1.2760],
[ 0.9233, 0.5347],
[ 1.0698, 1.6187]])
我们不能直接对这个进行 sigmoid 运算,因为我们得不到行相加为 1 的结果(我们希望 3 的概率加上 7 的概率等于 1):
acts.sigmoid()
tensor([[0.6623, 0.5641],
[0.6151, 0.6132],
[0.0957, 0.4079],
[0.9881, 0.2182],
[0.7157, 0.6306],
[0.7446, 0.8346]])
在第四章中,我们的神经网络为每个图像创建了一个单一激活,然后通过sigmoid
函数传递。这个单一激活代表了模型对输入是 3 的置信度。二进制问题是分类问题的一种特殊情况,因为目标可以被视为单个布尔值,就像我们在mnist_loss
中所做的那样。但是二进制问题也可以在任意数量的类别的分类器的更一般上下文中考虑:在这种情况下,我们碰巧有两个类别。正如我们在熊分类器中看到的,我们的神经网络将为每个类别返回一个激活。
那么在二进制情况下,这些激活实际上表示什么?一对激活仅仅表示输入是 3 还是 7 的相对置信度。总体值,无论它们是高还是低,都不重要,重要的是哪个更高,以及高多少。
我们期望,由于这只是表示相同问题的另一种方式,我们应该能够直接在我们的神经网络的两个激活版本上使用sigmoid
。事实上我们可以!我们只需取神经网络激活之间的差异,因为这反映了我们对输入是 3 还是 7 更有把握的程度,然后取其 sigmoid:
(acts[:,0]-acts[:,1]).sigmoid()
tensor([0.6025, 0.5021, 0.1332, 0.9966, 0.5959, 0.3661])
第二列(它是 7 的概率)将是该值从 1 中减去的值。现在,我们需要一种适用于多于两列的方法。事实证明,这个名为softmax
的函数正是这样的:
def softmax(x): return exp(x) / exp(x).sum(dim=1, keepdim=True)
术语:指数函数(exp)
定义为e**x
,其中e
是一个特殊的数字,约等于 2.718。它是自然对数函数的倒数。请注意,exp
始终为正,并且增长非常迅速!
让我们检查softmax
是否为第一列返回与sigmoid
相同的值,以及这些值从 1 中减去的值为第二列:
sm_acts = torch.softmax(acts, dim=1)
sm_acts
tensor([[0.6025, 0.3975],
[0.5021, 0.4979],
[0.1332, 0.8668],
[0.9966, 0.0034],
[0.5959, 0.4041],
[0.3661, 0.6339]])
softmax
是sigmoid
的多类别等价物——每当我们有超过两个类别且类别的概率必须加起来为 1 时,我们必须使用它,即使只有两个类别,我们通常也会使用它,只是为了使事情更加一致。我们可以创建其他具有所有激活在 0 和 1 之间且总和为 1 的属性的函数;然而,没有其他函数与我们已经看到是平滑且对称的 sigmoid 函数具有相同的关系。此外,我们很快将看到 softmax 函数与我们将在下一节中看到的损失函数密切配合。
如果我们有三个输出激活,就像在我们的熊分类器中一样,为单个熊图像计算 softmax 看起来会像图 5-3 那样。
图 5-3. 熊分类器上 softmax 的示例
实际上,这个函数是做什么的呢?取指数确保我们所有的数字都是正数,然后除以总和确保我们将得到一堆加起来等于 1 的数字。指数还有一个很好的特性:如果我们激活中的某个数字略大于其他数字,指数将放大这个差异(因为它呈指数增长),这意味着在 softmax 中,该数字将更接近 1。
直观地,softmax 函数真的想要在其他类别中选择一个类别,因此在我们知道每张图片都有一个明确标签时,训练分类器时是理想的选择。(请注意,在推断过程中可能不太理想,因为有时您可能希望模型告诉您它在训练过程中看到的类别中没有识别出任何一个,并且不选择一个类别,因为它的激活分数略高。在这种情况下,最好使用多个二进制输出列来训练模型,每个列使用 sigmoid 激活。)
Softmax 是交叉熵损失的第一部分,第二部分是对数似然。
对数似然
在上一章中为我们的 MNIST 示例计算损失时,我们使用了这个:
def mnist_loss(inputs, targets):
inputs = inputs.sigmoid()
return torch.where(targets==1, 1-inputs, inputs).mean()
就像我们从 sigmoid 到 softmax 的转变一样,我们需要扩展损失函数,使其能够处理不仅仅是二元分类,还需要能够对任意数量的类别进行分类(在本例中,我们有 37 个类别)。我们的激活,在 softmax 之后,介于 0 和 1 之间,并且对于预测批次中的每一行,总和为 1。我们的目标是介于 0 和 36 之间的整数。
在二元情况下,我们使用torch.where
在inputs
和1-inputs
之间进行选择。当我们将二元分类作为具有两个类别的一般分类问题处理时,它变得更容易,因为(正如我们在前一节中看到的)现在有两列包含等同于inputs
和1-inputs
的内容。因此,我们只需要从适当的列中进行选择。让我们尝试在 PyTorch 中实现这一点。对于我们合成的 3 和 7 的示例,假设这些是我们的标签:
targ = tensor([0,1,0,1,1,0])
这些是 softmax 激活:
sm_acts
tensor([[0.6025, 0.3975],
[0.5021, 0.4979],
[0.1332, 0.8668],
[0.9966, 0.0034],
[0.5959, 0.4041],
[0.3661, 0.6339]])
然后对于每个targ
项,我们可以使用它来使用张量索引选择sm_acts
的适当列,如下所示:
idx = range(6)
sm_acts[idx, targ]
tensor([0.6025, 0.4979, 0.1332, 0.0034, 0.4041, 0.3661])
为了准确了解这里发生了什么,让我们将所有列放在一起放在一个表中。这里,前两列是我们的激活,然后是目标,行索引,最后是前面代码中显示的结果:
3 | 7 | targ | idx | loss |
---|---|---|---|---|
0.602469 | 0.397531 | 0 | 0 | 0.602469 |
0.502065 | 0.497935 | 1 | 1 | 0.497935 |
0.133188 | 0.866811 | 0 | 2 | 0.133188 |
0.99664 | 0.00336017 | 1 | 3 | 0.00336017 |
0.595949 | 0.404051 | 1 | 4 | 0.404051 |
0.366118 | 0.633882 | 0 | 5 | 0.366118 |
从这个表中可以看出,最后一列可以通过将targ
和idx
列作为索引,指向包含3
和7
列的两列矩阵来计算。这就是sm_acts[idx, targ]
的作用。
这里真正有趣的是,这种方法同样适用于超过两列的情况。想象一下,如果我们为每个数字(0 到 9)添加一个激活列,然后targ
包含从 0 到 9 的数字。只要激活列总和为 1(如果我们使用 softmax,它们将是这样),我们将有一个损失函数,显示我们预测每个数字的准确程度。
我们只从包含正确标签的列中选择损失。我们不需要考虑其他列,因为根据 softmax 的定义,它们加起来等于 1 减去与正确标签对应的激活。因此,使正确标签的激活尽可能高必须意味着我们也在降低其余列的激活。
PyTorch 提供了一个与sm_acts[range(n), targ]
完全相同的函数(除了它取负数,因为之后应用对数时,我们将得到负数),称为nll_loss
(NLL代表负对数似然):
-sm_acts[idx, targ]
tensor([-0.6025, -0.4979, -0.1332, -0.0034, -0.4041, -0.3661])
F.nll_loss(sm_acts, targ, reduction='none')
tensor([-0.6025, -0.4979, -0.1332, -0.0034, -0.4041, -0.3661])
尽管它的名字是这样的,但这个 PyTorch 函数并不取对数。我们将在下一节看到原因,但首先,让我们看看为什么取对数会有用。
取对数
在前一节中我们看到的函数作为损失函数效果很好,但我们可以让它更好一些。问题在于我们使用的是概率,概率不能小于 0 或大于 1。这意味着我们的模型不会在乎它是预测 0.99 还是 0.999。确实,这些数字非常接近,但从另一个角度来看,0.999 比 0.99 自信程度高 10 倍。因此,我们希望将我们的数字从 0 到 1 转换为从负无穷到无穷。有一个数学函数可以做到这一点:对数(可用torch.log
)。它对小于 0 的数字没有定义,并且如下所示:
plot_function(torch.log, min=0,max=4)
“对数”这个词让你想起了什么吗?对数函数有这个恒等式:
y = b**a
a = log(y,b)
在这种情况下,我们假设log(y,b)
返回log y 以 b 为底。然而,PyTorch 并没有这样定义log
:Python 中的log
使用特殊数字e
(2.718…)作为底。
也许对数是您在过去 20 年中没有考虑过的东西。但对于深度学习中的许多事情来说,对数是一个非常关键的数学概念,所以现在是一个很好的时机来刷新您的记忆。关于对数的关键事情是这样的关系:
log(a*b) = log(a)+log(b)
当我们以这种格式看到它时,它看起来有点无聊;但想想这实际上意味着什么。这意味着当基础信号呈指数或乘法增长时,对数会线性增加。例如,在地震严重程度的里氏震级和噪音级别的分贝尺中使用。它也经常用于金融图表中,我们希望更清楚地显示复合增长率。计算机科学家喜欢使用对数,因为这意味着可以用加法代替修改,这样可以避免产生计算机难以处理的难以处理的规模。
Sylvain 说
不仅是计算机科学家喜欢对数!在计算机出现之前,工程师和科学家使用一种称为滑尺的特殊尺子,通过添加对数来进行乘法运算。对数在物理学中被广泛用于乘法非常大或非常小的数字,以及许多其他领域。
对我们的概率取正对数或负对数的平均值(取决于是否是正确或不正确的类)给出了负对数似然损失。在 PyTorch 中,nll_loss
假设您已经对 softmax 取了对数,因此不会为您执行对数运算。
令人困惑的名称,注意
nll_loss
中的“nll”代表“负对数似然”,但实际上它根本不进行对数运算!它假设您已经已经进行了对数运算。PyTorch 有一个名为log_softmax
的函数,以快速准确的方式结合了log
和softmax
。nll_loss
设计用于在log_softmax
之后使用。
当我们首先进行 softmax,然后对其进行对数似然,这种组合被称为交叉熵损失。在 PyTorch 中,这可以通过nn.CrossEntropyLoss
来实现(实际上执行log_softmax
然后nll_loss
):
loss_func = nn.CrossEntropyLoss()
正如您所看到的,这是一个类。实例化它会给您一个像函数一样行为的对象:
loss_func(acts, targ)
tensor(1.8045)
所有 PyTorch 损失函数都以两种形式提供,刚刚显示的类形式以及在F
命名空间中提供的普通函数形式:
F.cross_entropy(acts, targ)
tensor(1.8045)
两者都可以正常工作,并且可以在任何情况下使用。我们注意到大多数人倾向于使用类版本,并且在 PyTorch 的官方文档和示例中更常见,因此我们也会倾向于使用它。
默认情况下,PyTorch 损失函数取所有项目的损失的平均值。您可以使用reduction='none'
来禁用这一点:
nn.CrossEntropyLoss(reduction='none')(acts, targ)
tensor([0.5067, 0.6973, 2.0160, 5.6958, 0.9062, 1.0048])
Sylvain 说
当我们考虑交叉熵损失的梯度时,一个有趣的特性就出现了。cross_entropy(a,b)
的梯度是softmax(a)-b
。由于softmax(a)
是模型的最终激活,这意味着梯度与预测和目标之间的差异成比例。这与回归中的均方误差相同(假设没有像y_range
添加的最终激活函数),因为(a-b)**2
的梯度是2*(a-b)
。由于梯度是线性的,我们不会看到梯度的突然跳跃或指数增加,这应该导致模型的平滑训练。
我们现在已经看到了隐藏在我们损失函数背后的所有部分。但是,虽然这可以对我们的模型表现如何(好或坏)进行评估,但它对于帮助我们知道它是否好并没有任何帮助。现在让我们看看一些解释我们模型预测的方法。
模型解释
直接解释损失函数非常困难,因为它们被设计为计算机可以区分和优化的东西,而不是人类可以理解的东西。这就是为什么我们有指标。这些指标不用于优化过程,而只是帮助我们这些可怜的人类理解发生了什么。在这种情况下,我们的准确率已经看起来相当不错!那么我们在哪里犯了错误呢?
我们在第一章中看到,我们可以使用混淆矩阵来查看模型表现好和表现不佳的地方:
interp = ClassificationInterpretation.from_learner(learn)
interp.plot_confusion_matrix(figsize=(12,12), dpi=60)
哦,亲爱的——在这种情况下,混淆矩阵很难阅读。我们有 37 种宠物品种,这意味着在这个巨大矩阵中有 37×37 个条目!相反,我们可以使用most_confused
方法,它只显示混淆矩阵中预测错误最多的单元格(这里至少有 5 个或更多):
interp.most_confused(min_val=5)
[('american_pit_bull_terrier', 'staffordshire_bull_terrier', 10),
('Ragdoll', 'Birman', 6)]
由于我们不是宠物品种专家,很难知道这些类别错误是否反映了识别品种时的实际困难。因此,我们再次求助于谷歌。一点点搜索告诉我们,这里显示的最常见的类别错误是即使是专家育种者有时也会对其存在分歧的品种差异。因此,这让我们有些安慰,我们正在走在正确的道路上。
我们似乎有一个良好的基线。现在我们可以做些什么来使它变得更好呢?
改进我们的模型
我们现在将探讨一系列技术,以改进我们模型的训练并使其更好。在此过程中,我们将更详细地解释迁移学习以及如何尽可能最好地微调我们的预训练模型,而不破坏预训练权重。
在训练模型时,我们需要设置的第一件事是学习率。我们在上一章中看到,它需要恰到好处才能尽可能高效地训练,那么我们如何选择一个好的学习率呢?fastai 提供了一个工具来帮助。
学习率查找器
在训练模型时,我们可以做的最重要的事情之一是确保我们有正确的学习率。如果我们的学习率太低,训练模型可能需要很多个 epoch。这不仅浪费时间,还意味着我们可能会出现过拟合的问题,因为每次完整地遍历数据时,我们都给了模型记住数据的机会。
那么我们就把学习率调得很高,对吗?当然,让我们试试看会发生什么:
learn = cnn_learner(dls, resnet34, metrics=error_rate)
learn.fine_tune(1, base_lr=0.1)
epoch | train_loss | valid_loss | error_rate | time |
---|---|---|---|---|
0 | 8.946717 | 47.954632 | 0.893775 | 00:20 |
epoch | train_loss | valid_loss | error_rate | time |
--- | --- | --- | --- | --- |
0 | 7.231843 | 4.119265 | 0.954668 | 00:24 |
这看起来不太好。发生了什么呢。优化器朝着正确的方向迈出了一步,但它迈得太远,完全超过了最小损失。多次重复这样的过程会使其越来越远,而不是越来越接近!
我们该如何找到完美的学习率——既不太高也不太低?在 2015 年,研究员 Leslie Smith 提出了一个绝妙的想法,称为学习率查找器。他的想法是从一个非常非常小的学习率开始,一个我们永远不会认为它太大而无法处理的学习率。我们用这个学习率进行一个 mini-batch,找到之后的损失,然后按一定百分比增加学习率(例如每次加倍)。然后我们再做另一个 mini-batch,跟踪损失,并再次加倍学习率。我们一直这样做,直到损失变得更糟,而不是更好。这是我们知道我们走得太远的时候。然后我们选择一个比这个点稍低的学习率。我们建议选择以下任一:
-
比最小损失达到的地方少一个数量级(即最小值除以 10)
-
最后一次损失明显减少的点
学习率查找器计算曲线上的这些点来帮助您。这两个规则通常给出大致相同的值。在第一章中,我们没有指定学习率,而是使用了 fastai 库的默认值(即 1e-3):
learn = cnn_learner(dls, resnet34, metrics=error_rate)
lr_min,lr_steep = learn.lr_find()
print(f"Minimum/10: {lr_min:.2e}, steepest point: {lr_steep:.2e}")
Minimum/10: 8.32e-03, steepest point: 6.31e-03
我们可以看到在 1e-6 到 1e-3 的范围内,没有什么特别的事情发生,模型不会训练。然后损失开始减少,直到达到最小值,然后再次增加。我们不希望学习率大于 1e-1,因为这会导致训练发散(您可以自行尝试),但 1e-1 已经太高了:在这个阶段,我们已经离开了损失稳定下降的阶段。
在这个学习率图中,看起来学习率约为 3e-3 可能是合适的,所以让我们选择这个:
learn = cnn_learner(dls, resnet34, metrics=error_rate)
learn.fine_tune(2, base_lr=3e-3)
epoch | train_loss | valid_loss | error_rate | time |
---|---|---|---|---|
0 | 1.071820 | 0.427476 | 0.133965 | 00:19 |
epoch | train_loss | valid_loss | error_rate | time |
--- | --- | --- | --- | --- |
0 | 0.738273 | 0.541828 | 0.150880 | 00:24 |
1 | 0.401544 | 0.266623 | 0.081867 | 00:24 |
对数刻度
学习率查找器图表采用对数刻度,这就是为什么在 1e-3 和 1e-2 之间的中间点在 3e-3 和 4e-3 之间。这是因为我们主要关心学习率的数量级。
有趣的是,学习率查找器是在 2015 年才被发现的,而神经网络自上世纪 50 年代以来一直在发展。在那段时间里,找到一个好的学习率可能是从业者面临的最重要和最具挑战性的问题。解决方案不需要任何高级数学、巨大的计算资源、庞大的数据集或其他任何使其对任何好奇的研究人员不可及的东西。此外,Smith 并不是某个独家的硅谷实验室的一部分,而是作为一名海军研究员工作。所有这些都是为了说:在深度学习中的突破性工作绝对不需要访问大量资源、精英团队或先进的数学思想。还有很多工作需要做,只需要一点常识、创造力和坚韧不拔。
现在我们有了一个好的学习率来训练我们的模型,让我们看看如何微调预训练模型的权重。
解冻和迁移学习
我们在第一章中简要讨论了迁移学习的工作原理。我们看到基本思想是,一个预训练模型,可能在数百万数据点(如 ImageNet)上训练,被为另一个任务进行微调。但这到底意味着什么?
我们现在知道,卷积神经网络由许多线性层组成,每对之间有一个非线性激活函数,然后是一个或多个最终的线性层,最后是一个诸如 softmax 之类的激活函数。最终的线性层使用一个具有足够列数的矩阵,使得输出大小与我们模型中的类数相同(假设我们正在进行分类)。
当我们在迁移学习设置中进行微调时,这个最终的线性层对我们来说可能没有任何用处,因为它专门设计用于对原始预训练数据集中的类别进行分类。因此,在进行迁移学习时,我们会将其移除、丢弃,并用一个新的线性层替换,该线性层具有我们所需任务的正确输出数量(在这种情况下,将有 37 个激活)。
这个新添加的线性层将完全随机的权重。因此,在微调之前,我们的模型具有完全随机的输出。但这并不意味着它是一个完全随机的模型!最后一个层之前的所有层都经过精心训练,以便在一般的图像分类任务中表现良好。正如我们在Zeiler 和 Fergus 论文中看到的那样,在第一章中(参见图 1-10 到 1-13),前几层编码了一般概念,比如找到梯度和边缘,后面的层编码了对我们仍然有用的概念,比如找到眼球和毛发。
我们希望以这样的方式训练模型,使其能够记住预训练模型中的所有这些通常有用的想法,用它们来解决我们的特定任务(分类宠物品种),并仅根据我们特定任务的具体要求进行调整。
在微调时,我们的挑战是用能够正确实现我们所需任务(分类宠物品种)的权重替换我们添加的线性层中的随机权重,而不破坏精心预训练的权重和其他层。一个简单的技巧可以实现这一点:告诉优化器仅更新那些随机添加的最终层中的权重。根本不要改变神经网络的其他部分的权重。这被称为冻结那些预训练的层。
当我们从预训练网络创建模型时,fastai 会自动为我们冻结所有预训练层。当我们调用fine_tune
方法时,fastai 会做两件事:
-
训练随机添加的层一个周期,同时冻结所有其他层
-
解冻所有层,并根据请求的周期数进行训练
尽管这是一个合理的默认方法,但对于您的特定数据集,您可能通过稍微不同的方式做事情来获得更好的结果。fine_tune
方法有一些参数可以用来改变其行为,但如果您想获得自定义行为,直接调用底层方法可能更容易。请记住,您可以使用以下语法查看该方法的源代码:
learn.fine_tune??
所以让我们尝试手动操作。首先,我们将使用fit_one_cycle
训练随机添加的层三个周期。正如在第一章中提到的,fit_one_cycle
是在不使用fine_tune
的情况下训练模型的建议方法。我们将在本书后面看到原因;简而言之,fit_one_cycle
的作用是以低学习率开始训练,逐渐增加学习率进行第一部分的训练,然后在最后一部分的训练中逐渐降低学习率:
learn = cnn_learner(dls, resnet34, metrics=error_rate)
learn.fit_one_cycle(3, 3e-3)
epoch | train_loss | valid_loss | error_rate | time |
---|---|---|---|---|
0 | 1.188042 | 0.355024 | 0.102842 | 00:20 |
1 | 0.534234 | 0.302453 | 0.094723 | 00:20 |
2 | 0.325031 | 0.222268 | 0.074425 | 00:20 |
然后我们将解冻模型:
learn.unfreeze()
并再次运行lr_find
,因为有更多层要训练,而且已经训练了三个周期的权重,意味着我们之前找到的学习率不再合适:
learn.lr_find()
(1.0964782268274575e-05, 1.5848931980144698e-06)
请注意,图表与随机权重时有所不同:我们没有那种表明模型正在训练的陡峭下降。这是因为我们的模型已经训练过了。在这里,我们有一个相对平坦的区域,然后是一个急剧增加的区域,我们应该选择在那个急剧增加之前的一个点,例如 1e-5。具有最大梯度的点不是我们在这里寻找的,应该被忽略。
让我们以适当的学习率进行训练:
learn.fit_one_cycle(6, lr_max=1e-5)
epoch | train_loss | valid_loss | error_rate | time |
---|---|---|---|---|
0 | 0.263579 | 0.217419 | 0.069012 | 00:24 |
1 | 0.253060 | 0.210346 | 0.062923 | 00:24 |
2 | 0.224340 | 0.207357 | 0.060217 | 00:24 |
3 | 0.200195 | 0.207244 | 0.061570 | 00:24 |
4 | 0.194269 | 0.200149 | 0.059540 | 00:25 |
5 | 0.173164 | 0.202301 | 0.059540 | 00:25 |
这稍微改进了我们的模型,但我们还可以做更多。预训练模型的最深层可能不需要像最后一层那样高的学习率,因此我们可能应该为这些层使用不同的学习率——这被称为使用区分性学习率。
区分性学习率
即使我们解冻后,我们仍然非常关心那些预训练权重的质量。我们不会期望那些预训练参数的最佳学习率与随机添加参数的学习率一样高,即使在我们为随机添加参数调整了几个轮数之后。请记住,预训练权重已经在数百个轮数中,在数百万张图像上进行了训练。
此外,您还记得我们在第一章中看到的图像吗?显示每个层学习的内容?第一层学习非常简单的基础知识,如边缘和梯度检测器;这些对于几乎任何任务都可能非常有用。后面的层学习更复杂的概念,如“眼睛”和“日落”,这些对您的任务可能完全没有用(也许您正在对汽车型号进行分类)。因此,让后面的层比前面的层更快地微调是有道理的。
因此,fastai 的默认方法是使用区分性学习率。这种技术最初是在我们将在第十章中介绍的 NLP 迁移学习的 ULMFiT 方法中开发的。就像深度学习中的许多好主意一样,这个方法非常简单:对神经网络的早期层使用较低的学习率,对后期层(尤其是随机添加的层)使用较高的学习率。这个想法基于Jason Yosinski 等人在 2014 年展示的见解,即在迁移学习中,神经网络的不同层应该以不同的速度训练,如图 5-4 所示。
图 5-4。不同层和训练方法对迁移学习的影响(由 Jason Yosinski 等人提供)
fastai 允许您在任何需要学习率的地方传递 Python slice
对象。传递的第一个值将是神经网络最早层的学习率,第二个值将是最后一层的学习率。中间的层将在该范围内等距地乘法地具有学习率。让我们使用这种方法复制先前的训练,但这次我们只将我们网络的最低层的学习率设置为 1e-6;其他层将增加到 1e-4。让我们训练一段时间,看看会发生什么:
learn = cnn_learner(dls, resnet34, metrics=error_rate)
learn.fit_one_cycle(3, 3e-3)
learn.unfreeze()
learn.fit_one_cycle(12, lr_max=slice(1e-6,1e-4))
轮数 | 训练损失 | 验证损失 | 错误率 | 时间 |
---|---|---|---|---|
0 | 1.145300 | 0.345568 | 0.119756 | 00:20 |
1 | 0.533986 | 0.251944 | 0.077131 | 00:20 |
2 | 0.317696 | 0.208371 | 0.069012 | 00:20 |
轮数 | 训练损失 | 验证损失 | 错误率 | 时间 |
--- | --- | --- | --- | --- |
0 | 0.257977 | 0.205400 | 0.067659 | 00:25 |
1 | 0.246763 | 0.205107 | 0.066306 | 00:25 |
2 | 0.240595 | 0.193848 | 0.062246 | 00:25 |
3 | 0.209988 | 0.198061 | 0.062923 | 00:25 |
4 | 0.194756 | 0.193130 | 0.064276 | 00:25 |
5 | 0.169985 | 0.187885 | 0.056157 | 00:25 |
6 | 0.153205 | 0.186145 | 0.058863 | 00:25 |
7 | 0.141480 | 0.185316 | 0.053451 | 00:25 |
8 | 0.128564 | 0.180999 | 0.051421 | 00:25 |
9 | 0.126941 | 0.186288 | 0.054127 | 00:25 |
10 | 0.130064 | 0.181764 | 0.054127 | 00:25 |
11 | 0.124281 | 0.181855 | 0.054127 | 00:25 |
现在微调效果很好!
fastai 可以展示训练和验证损失的图表:
learn.recorder.plot_loss()
正如你所看到的,训练损失一直在变得越来越好。但请注意,最终验证损失的改善会减缓,有时甚至会变得更糟!这是模型开始过拟合的时候。特别是,模型开始对其预测变得过于自信。但这并不意味着它一定变得不准确。看一下每个 epoch 的训练结果表,你会经常看到准确率持续提高,即使验证损失变得更糟。最终,重要的是你的准确率,或者更一般地说是你选择的指标,而不是损失。损失只是我们给计算机的函数,帮助我们优化。
在训练模型时,你还需要做出的另一个决定是训练多长时间。我们将在下面考虑这个问题。
选择 epochs 的数量
通常情况下,你会发现在选择训练多少个 epochs 时,你受到的限制更多是时间,而不是泛化和准确性。因此,你训练的第一步应该是简单地选择一个你愿意等待的时间内可以完成的 epochs 数量。然后查看训练和验证损失图,特别是你的指标。如果你看到它们甚至在最后几个 epochs 中仍在变得更好,那么你就知道你没有训练得太久。
另一方面,你可能会发现你选择的指标在训练结束时确实变得更糟。记住,我们不仅仅是在寻找验证损失变得更糟,而是实际的指标。你的验证损失在训练过程中会先变得更糟,因为模型变得过于自信,只有后来才会因为错误地记忆数据而变得更糟。在实践中,我们只关心后一种情况。记住,我们的损失函数是我们用来让优化器有东西可以区分和优化的,实际上我们关心的不是这个。
在 1cycle 训练出现之前,通常会在每个 epoch 结束时保存模型,然后从所有保存的模型中选择准确率最高的模型。这被称为早停。然而,这不太可能给出最好的答案,因为那些中间的 epochs 出现在学习率还没有机会达到小值的情况下,这时它才能真正找到最佳结果。因此,如果你发现你过拟合了,你应该重新从头开始训练模型,并根据之前找到最佳结果的地方选择一个总的 epochs 数量。
如果你有时间训练更多的 epochs,你可能会选择用这段时间来训练更多的参数,也就是使用更深的架构。
更深的架构
一般来说,具有更多参数的模型可以更准确地对数据进行建模。(对于这个泛化有很多很多的例外情况,这取决于你使用的架构的具体情况,但现在这是一个合理的经验法则。)对于我们将在本书中看到的大多数架构,你可以通过简单地添加更多层来创建更大的版本。然而,由于我们想使用预训练模型,我们需要确保选择已经为我们预训练的层数。
这就是为什么在实践中,架构往往只有少数几种变体。例如,在本章中使用的 ResNet 架构有 18、34、50、101 和 152 层的变体,都是在 ImageNet 上预训练的。一个更大的(更多层和参数;有时被描述为模型的容量)ResNet 版本总是能够给我们更好的训练损失,但它可能更容易过拟合,因为它有更多参数可以过拟合。
总的来说,一个更大的模型能够更好地捕捉数据的真实基本关系,以及捕捉和记忆你个别图像的具体细节。
然而,使用更深的模型将需要更多的 GPU 内存,因此你可能需要降低批量大小以避免内存不足错误。当你尝试将太多内容装入 GPU 时,就会发生这种情况,看起来像这样:
Cuda runtime error: out of memory
当发生这种情况时,你可能需要重新启动你的笔记本。解决方法是使用较小的批量大小,这意味着在任何给定时间通过你的模型传递较小的图像组。你可以通过使用bs=
创建你想要的批量大小来调用。
更深层次架构的另一个缺点是训练时间要长得多。一个可以大大加快速度的技术是混合精度训练。这指的是在训练过程中尽可能使用不那么精确的数字(半精度浮点数,也称为 fp16)。截至 2020 年初,几乎所有当前的 NVIDIA GPU 都支持一种特殊功能,称为张量核心,可以将神经网络训练速度提高 2-3 倍。它们还需要更少的 GPU 内存。要在 fastai 中启用此功能,只需在创建Learner
后添加to_fp16()
(你还需要导入模块)。
你实际上无法提前知道适合你特定问题的最佳架构——你需要尝试一些训练。所以现在让我们尝试使用混合精度的 ResNet-50:
from fastai2.callback.fp16 import *
learn = cnn_learner(dls, resnet50, metrics=error_rate).to_fp16()
learn.fine_tune(6, freeze_epochs=3)
epoch | train_loss | valid_loss | error_rate | time |
---|---|---|---|---|
0 | 1.427505 | 0.310554 | 0.098782 | 00:21 |
1 | 0.606785 | 0.302325 | 0.094723 | 00:22 |
2 | 0.409267 | 0.294803 | 0.091340 | 00:21 |
epoch | train_loss | valid_loss | error_rate | time |
--- | --- | --- | --- | --- |
0 | 0.261121 | 0.274507 | 0.083897 | 00:26 |
1 | 0.296653 | 0.318649 | 0.084574 | 00:26 |
2 | 0.242356 | 0.253677 | 0.069012 | 00:26 |
3 | 0.150684 | 0.251438 | 0.065629 | 00:26 |
4 | 0.094997 | 0.239772 | 0.064276 | 00:26 |
5 | 0.061144 | 0.228082 | 0.054804 | 00:26 |
你会看到我们又回到使用fine_tune
,因为它非常方便!我们可以传递freeze_epochs
告诉 fastai 在冻结时训练多少个周期。它将自动为大多数数据集更改学习率。
在这种情况下,我们没有从更深的模型中看到明显的优势。这是值得记住的——对于你的特定情况,更大的模型不一定是更好的模型!确保在扩大规模之前尝试小模型。
结论
在本章中,你学到了一些重要的实用技巧,既可以为建模准备图像数据(预调整大小,数据块摘要),也可以为拟合模型(学习率查找器,解冻,区分性学习率,设置周期数,使用更深的架构)。使用这些工具将帮助你更快地构建更准确的图像模型。
我们还讨论了交叉熵损失。这本书的这部分值得花费大量时间。在实践中,你可能不太可能需要自己从头开始实现交叉熵损失,但你需要理解该函数的输入和输出,因为它(或它的变体,正如我们将在下一章中看到的)几乎在每个分类模型中使用。因此,当你想要调试一个模型,或将一个模型投入生产,或提高一个模型的准确性时,你需要能够查看其激活和损失,并理解发生了什么以及为什么。如果你不理解你的损失函数,你就无法正确地做到这一点。
如果交叉熵损失函数还没有“点亮”你的灯泡,不要担心——你会理解的!首先,回到前一章,确保你真正理解了mnist_loss
。然后逐渐地通过本章的笔记本单元格,逐步了解交叉熵损失的每个部分。确保你理解每个计算在做什么以及为什么。尝试自己创建一些小张量,并将它们传递给函数,看看它们返回什么。
记住:在实现交叉熵损失时所做的选择并不是唯一可能的选择。就像我们在回归中可以在均方误差和平均绝对差(L1)之间进行选择一样,这里也可以改变细节。如果您对可能有效的其他函数有其他想法,请随时在本章的笔记本中尝试!(但要注意:您可能会发现模型训练速度较慢,准确性较低。这是因为交叉熵损失的梯度与激活和目标之间的差异成比例,因此 SGD 始终会为权重提供一个很好的缩放步长。)
问卷调查
-
为什么我们首先在 CPU 上调整大小到较大尺寸,然后在 GPU 上调整到较小尺寸?
-
如果您不熟悉正则表达式,请查找正则表达式教程和一些问题集,并完成它们。查看书籍网站以获取建议。
-
对于大多数深度学习数据集,数据通常以哪两种方式提供?
-
查阅
L
的文档,并尝试使用它添加的一些新方法。 -
查阅 Python
pathlib
模块的文档,并尝试使用Path
类的几种方法。 -
给出两个图像转换可能降低数据质量的示例。
-
fastai 提供了哪种方法来查看
DataLoaders
中的数据? -
fastai 提供了哪种方法来帮助您调试
DataBlock
? -
在彻底清理数据之前,是否应该暂停训练模型?
-
在 PyTorch 中,交叉熵损失是由哪两个部分组合而成的?
-
softmax 确保的激活函数的两个属性是什么?为什么这很重要?
-
何时可能希望激活函数不具有这两个属性?
-
自己计算图 5-3 中的
exp
和softmax
列(即在电子表格、计算器或笔记本中)。 -
为什么我们不能使用
torch.where
为标签可能有多于两个类别的数据集创建损失函数? -
log(-2)的值是多少?为什么?
-
选择学习率时有哪两个好的经验法则来自学习率查找器?
-
fine_tune
方法执行了哪两个步骤? -
在 Jupyter Notebook 中,如何获取方法或函数的源代码?
-
什么是区分性学习率?
-
当将 Python
slice
对象作为学习率传递给 fastai 时,它是如何解释的? -
为什么在使用 1cycle 训练时,提前停止是一个不好的选择?
-
resnet50
和resnet101
之间有什么区别? -
to_fp16
是做什么的?
进一步研究
-
找到 Leslie Smith 撰写的介绍学习率查找器的论文,并阅读。
-
看看是否可以提高本章分类器的准确性。您能达到的最佳准确性是多少?查看论坛和书籍网站,看看其他学生在这个数据集上取得了什么成就以及他们是如何做到的。
第六章:其他计算机视觉问题
原文:
www.bookstack.cn/read/th-fastai-book/f1ed7978537dabc2.md
译者:飞龙
在上一章中,你学习了一些在实践中训练模型的重要技术。选择学习率和周期数等考虑因素对于获得良好结果非常重要。
在本章中,我们将看到另外两种计算机视觉问题:多标签分类和回归。第一种情况发生在你想要预测每个图像的多个标签(有时甚至没有标签),第二种情况发生在你的标签是一个或多个数字——数量而不是类别。
在这个过程中,我们将更深入地研究深度学习模型中的输出激活、目标和损失函数。
多标签分类
多标签分类指的是识别图像中可能不只包含一种对象类别的问题。可能有多种对象,或者在你寻找的类别中根本没有对象。
例如,这对我们的熊分类器来说是一个很好的方法。我们在第二章中推出的熊分类器的一个问题是,如果用户上传了任何不是熊的东西,模型仍然会说它是灰熊、黑熊或泰迪熊之一——它无法预测“根本不是熊”。事实上,在我们完成本章后,你可以回到你的图像分类器应用程序,尝试使用多标签技术重新训练它,然后通过传入一个不属于你识别类别的图像来测试它。
实际上,我们并没有看到很多人为这个目的训练多标签分类器的例子——但我们经常看到用户和开发人员抱怨这个问题。看起来这个简单的解决方案并不被广泛理解或赞赏!因为在实践中,很可能有一些图像没有匹配项或有多个匹配项,所以我们应该预期在实践中,多标签分类器比单标签分类器更具普适性。
首先让我们看看多标签数据集是什么样的;然后我们将解释如何准备好供我们的模型使用。你会发现模型的架构与前一章并没有改变;只有损失函数改变了。让我们从数据开始。
数据
对于我们的示例,我们将使用 PASCAL 数据集,该数据集中的每个图像可以有多种分类对象。
我们首先按照通常的方式下载和提取数据集:
from fastai.vision.all import *
path = untar_data(URLs.PASCAL_2007)
这个数据集与我们之前看到的不同,它不是按文件名或文件夹结构化的,而是附带一个 CSV 文件,告诉我们每个图像要使用的标签。我们可以通过将其读入 Pandas DataFrame 来检查 CSV 文件:
df = pd.read_csv(path/'train.csv')
df.head()
文件名 | 标签 | 是否有效 | |
---|---|---|---|
0 | 000005.jpg | 椅子 | True |
1 | 000007.jpg | 汽车 | True |
2 | 000009.jpg | 马 人 | True |
3 | 000012.jpg | 汽车 | False |
4 | 000016.jpg | 自行车 | True |
正如你所看到的,每个图像中的类别列表显示为一个以空格分隔的字符串。
既然我们已经看到了数据的样子,让我们准备好进行模型训练。
构建数据块
我们如何将DataFrame
对象转换为DataLoaders
对象?我们通常建议在可能的情况下使用数据块 API 来创建DataLoaders
对象,因为它提供了灵活性和简单性的良好组合。在这里,我们将展示使用数据块 API 构建DataLoaders
对象的实践步骤,以这个数据集为例。
正如我们所看到的,PyTorch 和 fastai 有两个主要类用于表示和访问训练集或验证集:
数据集
返回单个项目的独立变量和依赖变量的元组的集合
数据加载器
提供一系列小批量的迭代器,其中每个小批量是一批独立变量和一批因变量的组合
除此之外,fastai 还提供了两个类来将您的训练和验证集合在一起:
Datasets
包含一个训练Dataset
和一个验证Dataset
的迭代器
DataLoaders
包含一个训练DataLoader
和一个验证DataLoader
的对象
由于DataLoader
是建立在Dataset
之上并为其添加附加功能(将多个项目整合成一个小批量),通常最容易的方法是首先创建和测试Datasets
,然后再查看DataLoaders
。
当我们创建DataBlock
时,我们逐步逐步构建,并使用笔记本检查我们的数据。这是一个很好的方式,可以确保您在编码时保持动力,并留意任何问题。易于调试,因为您知道如果出现问题,它就在您刚刚输入的代码行中!
让我们从没有参数创建的数据块开始,这是最简单的情况:
dblock = DataBlock()
我们可以从中创建一个Datasets
对象。唯一需要的是一个源——在这种情况下是我们的 DataFrame:
dsets = dblock.datasets(df)
这包含一个train
和一个valid
数据集,我们可以对其进行索引:
dsets.train[0]
(fname 008663.jpg
labels car person
is_valid False
Name: 4346, dtype: object,
fname 008663.jpg
labels car person
is_valid False
Name: 4346, dtype: object)
正如您所看到的,这只是简单地两次返回 DataFrame 的一行。这是因为默认情况下,数据块假定我们有两个东西:输入和目标。我们需要从 DataFrame 中获取适当的字段,可以通过传递get_x
和get_y
函数来实现:
dblock = DataBlock(get_x = lambda r: r['fname'], get_y = lambda r: r['labels'])
dsets = dblock.datasets(df)
dsets.train[0]
('005620.jpg', 'aeroplane')
正如您所看到的,我们并没有以通常的方式定义函数,而是使用了 Python 的lambda
关键字。这只是定义并引用函数的一种快捷方式。以下更冗长的方法是相同的:
def get_x(r): return r['fname']
def get_y(r): return r['labels']
dblock = DataBlock(get_x = get_x, get_y = get_y)
dsets = dblock.datasets(df)
dsets.train[0]
('002549.jpg', 'tvmonitor')
Lambda 函数非常适合快速迭代,但不兼容序列化,因此我们建议您在训练后要导出您的Learner
时使用更冗长的方法(如果您只是在尝试实验,lambda 是可以的)。
我们可以看到独立变量需要转换为完整路径,以便我们可以将其作为图像打开,而因变量需要根据空格字符(这是 Python 的split
函数的默认值)进行拆分,以便它变成一个列表:
def get_x(r): return path/'train'/r['fname']
def get_y(r): return r['labels'].split(' ')
dblock = DataBlock(get_x = get_x, get_y = get_y)
dsets = dblock.datasets(df)
dsets.train[0]
(Path('/home/sgugger/.fastai/data/pascal_2007/train/008663.jpg'),
['car', 'person'])
要实际打开图像并将其转换为张量,我们需要使用一组转换;块类型将为我们提供这些。我们可以使用先前使用过的相同块类型,只有一个例外:ImageBlock
将再次正常工作,因为我们有一个指向有效图像的路径,但CategoryBlock
不会起作用。问题在于该块返回一个单个整数,但我们需要为每个项目有多个标签。为了解决这个问题,我们使用MultiCategoryBlock
。这种类型的块期望接收一个字符串列表,就像我们在这种情况下所做的那样,所以让我们来测试一下:
dblock = DataBlock(blocks=(ImageBlock, MultiCategoryBlock),
get_x = get_x, get_y = get_y)
dsets = dblock.datasets(df)
dsets.train[0]
(PILImage mode=RGB size=500x375,
TensorMultiCategory([0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 0., 0.,
> 0., 0., 0., 0., 0., 0.]))
正如您所看到的,我们的类别列表的编码方式与常规的CategoryBlock
不同。在那种情况下,我们有一个整数表示哪个类别存在,基于它在我们的词汇表中的位置。然而,在这种情况下,我们有一系列 0,其中任何位置上有一个 1 表示该类别存在。例如,如果第二和第四位置上有一个 1,那意味着词汇项二和四在这个图像中存在。这被称为独热编码。我们不能简单地使用类别索引列表的原因是每个列表的长度都不同,而 PyTorch 需要张量,其中所有内容必须是相同长度。
行话:独热编码
使用一个 0 向量,其中每个位置都表示数据中表示的位置,以编码一个整数列表。
让我们来看看这个例子中类别代表什么(我们使用方便的torch.where
函数,告诉我们条件为真或假的所有索引):
idxs = torch.where(dsets.train[0][1]==1.)[0]
dsets.train.vocab[idxs]
(#1) ['dog']
使用 NumPy 数组、PyTorch 张量和 fastai 的L
类,我们可以直接使用列表或向量进行索引,这使得很多代码(比如这个例子)更清晰、更简洁。
到目前为止,我们忽略了列is_valid
,这意味着DataBlock
一直在使用默认的随机拆分。要明确选择我们验证集的元素,我们需要编写一个函数并将其传递给splitter
(或使用 fastai 的预定义函数或类之一)。它将获取项目(这里是我们整个 DataFrame)并必须返回两个(或更多)整数列表:
def splitter(df):
train = df.index[~df['is_valid']].tolist()
valid = df.index[df['is_valid']].tolist()
return train,valid
dblock = DataBlock(blocks=(ImageBlock, MultiCategoryBlock),
splitter=splitter,
get_x=get_x,
get_y=get_y)
dsets = dblock.datasets(df)
dsets.train[0]
(PILImage mode=RGB size=500x333,
TensorMultiCategory([0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0.,
> 0., 0., 0., 0., 0., 0.]))
正如我们讨论过的,DataLoader
将Dataset
中的项目整理成一个小批量。这是一个张量的元组,其中每个张量简单地堆叠了Dataset
项目中该位置的项目。
现在我们已经确认了单个项目看起来没问题,还有一步,我们需要确保我们可以创建我们的DataLoaders
,即确保每个项目的大小相同。为了做到这一点,我们可以使用RandomResizedCrop
:
dblock = DataBlock(blocks=(ImageBlock, MultiCategoryBlock),
splitter=splitter,
get_x=get_x,
get_y=get_y,
item_tfms = RandomResizedCrop(128, min_scale=0.35))
dls = dblock.dataloaders(df)
现在我们可以显示我们数据的一个样本:
dls.show_batch(nrows=1, ncols=3)
请记住,如果在从DataBlock
创建DataLoaders
时出现任何问题,或者如果您想查看DataBlock
的确切情况,您可以使用我们在上一章中介绍的summary
方法。
我们的数据现在已经准备好用于训练模型。正如我们将看到的,当我们创建我们的Learner
时,没有任何变化,但在幕后,fastai 库将为我们选择一个新的损失函数:二元交叉熵。
二元交叉熵
现在我们将创建我们的Learner
。我们在第四章中看到,Learner
对象包含四个主要内容:模型、DataLoaders
对象、优化器和要使用的损失函数。我们已经有了我们的DataLoaders
,我们可以利用 fastai 的resnet
模型(稍后我们将学习如何从头开始创建),并且我们知道如何创建一个SGD
优化器。因此,让我们专注于确保我们有一个合适的损失函数。为此,让我们使用cnn_learner
创建一个Learner
,这样我们就可以查看它的激活:
learn = cnn_learner(dls, resnet18)
我们还看到,Learner
中的模型通常是从nn.Module
继承的类的对象,并且我们可以使用括号调用它,它将返回模型的激活。你应该将独立变量作为一个小批量传递给它。我们可以尝试从我们的DataLoader
中获取一个小批量,然后将其传递给模型:
x,y = dls.train.one_batch()
activs = learn.model(x)
activs.shape
torch.Size([64, 20])
想想为什么activs
有这种形状——我们的批量大小为 64,我们需要计算 20 个类别中的每一个的概率。这是其中一个激活的样子:
activs[0]
tensor([ 2.0258, -1.3543, 1.4640, 1.7754, -1.2820, -5.8053, 3.6130, 0.7193,
> -4.3683, -2.5001, -2.8373, -1.8037, 2.0122, 0.6189, 1.9729, 0.8999,
> -2.6769, -0.3829, 1.2212, 1.6073],
device='cuda:0', grad_fn=<SelectBackward>)
获取模型激活
知道如何手动获取一个小批量并将其传递到模型中,并查看激活和损失,对于调试模型非常重要。这对学习也非常有帮助,这样你就可以清楚地看到发生了什么。
它们还没有缩放到 0 到 1 之间,但我们学会了如何在第四章中使用sigmoid
函数来做到这一点。我们还看到了如何基于此计算损失——这是我们在第四章中的损失函数,加上了在前一章中讨论的log
:
def binary_cross_entropy(inputs, targets):
inputs = inputs.sigmoid()
return -torch.where(targets==1, inputs, 1-inputs).log().mean()
请注意,由于我们有一个独热编码的因变量,我们不能直接使用nll_loss
或softmax
(因此我们不能使用cross_entropy
):
-
正如我们所看到的,
softmax
要求所有预测总和为 1,并且倾向于使一个激活远远大于其他激活(因为使用了exp
);然而,我们可能有多个我们确信出现在图像中的对象,因此限制激活的最大总和为 1 并不是一个好主意。出于同样的原因,如果我们认为任何类别都不出现在图像中,我们可能希望总和小于1。 -
正如我们所看到的,
nll_loss
返回的是一个激活值:与项目的单个标签对应的单个激活值。当我们有多个标签时,这是没有意义的。
另一方面,binary_cross_entropy
函数,即mnist_loss
加上log
,正是我们所需要的,这要归功于 PyTorch 的逐元素操作的魔力。每个激活将与每个列的每个目标进行比较,因此我们不必做任何事情使此函数适用于多个列。
Jeremy Says
我真的很喜欢使用像 PyTorch 这样的库,具有广播和逐元素操作,因为我经常发现我可以编写的代码同样适用于单个项目或一批项目,而无需更改。binary_cross_entropy
就是一个很好的例子。通过使用这些操作,我们不必自己编写循环,可以依赖 PyTorch 根据我们正在处理的张量的秩适当地执行我们需要的循环。
PyTorch 已经为我们提供了这个函数。实际上,它提供了许多版本,名称相当令人困惑!
F.binary_cross_entropy
及其模块等效nn.BCELoss
计算一个独热编码目标的交叉熵,但不包括初始的sigmoid
。通常,对于独热编码目标,您将希望使用F.binary_cross_entropy_with_logits
(或nn.BCEWithLogitsLoss
),它们在一个函数中同时执行 sigmoid 和二元交叉熵,就像前面的例子一样。
对于单标签数据集(如 MNIST 或 Pet 数据集),其中目标被编码为单个整数,相应的是F.nll_loss
或nn.NLLLoss
(没有初始 softmax 的版本),以及F.cross_entropy
或nn.CrossEntropyLoss
(具有初始 softmax 的版本)。
由于我们有一个独热编码的目标,我们将使用BCEWithLogitsLoss
:
loss_func = nn.BCEWithLogitsLoss()
loss = loss_func(activs, y)
loss
tensor(1.0082, device='cuda:5', grad_fn=<BinaryCrossEntropyWithLogitsBackward>)
我们不需要告诉 fastai 使用这个损失函数(尽管如果我们想要的话可以这样做),因为它将自动为我们选择。fastai 知道DataLoaders
具有多个类别标签,因此默认情况下将使用nn.BCEWithLogitsLoss
。
与前一章相比的一个变化是我们使用的指标:因为这是一个多标签问题,我们不能使用准确度函数。为什么呢?嗯,准确度是这样比较我们的输出和我们的目标的:
def accuracy(inp, targ, axis=-1):
"Compute accuracy with `targ` when `pred` is bs * n_classes"
pred = inp.argmax(dim=axis)
return (pred == targ).float().mean()
预测的类是具有最高激活的类(这就是argmax
的作用)。这里不起作用,因为我们可能在单个图像上有多个预测。在对我们的激活应用 sigmoid(使它们在 0 和 1 之间)之后,我们需要通过选择阈值来决定哪些是 0,哪些是 1。高于阈值的每个值将被视为 1,低于阈值的每个值将被视为 0:
def accuracy_multi(inp, targ, thresh=0.5, sigmoid=True):
"Compute accuracy when `inp` and `targ` are the same size."
if sigmoid: inp = inp.sigmoid()
return ((inp>thresh)==targ.bool()).float().mean()
如果我们直接将accuracy_multi
作为指标传递,它将使用threshold
的默认值,即 0.5。我们可能希望调整该默认值并创建一个具有不同默认值的新版本的accuracy_multi
。为了帮助解决这个问题,Python 中有一个名为partial
的函数。它允许我们绑定一个带有一些参数或关键字参数的函数,从而创建该函数的新版本,每当调用它时,总是包含这些参数。例如,这里是一个接受两个参数的简单函数:
def say_hello(name, say_what="Hello"): return f"{say_what} {name}."
say_hello('Jeremy'),say_hello('Jeremy', 'Ahoy!')
('Hello Jeremy.', 'Ahoy! Jeremy.')
我们可以通过使用partial
切换到该函数的法语版本:
f = partial(say_hello, say_what="Bonjour")
f("Jeremy"),f("Sylvain")
('Bonjour Jeremy.', 'Bonjour Sylvain.')
现在我们可以训练我们的模型。让我们尝试将准确度阈值设置为 0.2 作为我们的指标:
learn = cnn_learner(dls, resnet50, metrics=partial(accuracy_multi, thresh=0.2))
learn.fine_tune(3, base_lr=3e-3, freeze_epochs=4)
epoch | train_loss | valid_loss | accuracy_multi | time |
---|---|---|---|---|
0 | 0.903610 | 0.659728 | 0.263068 | 00:07 |
1 | 0.724266 | 0.346332 | 0.525458 | 00:07 |
2 | 0.415597 | 0.125662 | 0.937590 | 00:07 |
3 | 0.254987 | 0.116880 | 0.945418 | 00:07 |
epoch | train_loss | valid_loss | accuracy_multi | time |
--- | --- | --- | --- | --- |
0 | 0.123872 | 0.132634 | 0.940179 | 00:08 |
1 | 0.112387 | 0.113758 | 0.949343 | 00:08 |
2 | 0.092151 | 0.104368 | 0.951195 | 00:08 |
选择阈值很重要。如果选择的阈值太低,通常会选择错误标记的对象。我们可以通过改变我们的度量标准然后调用validate
来看到这一点,它会返回验证损失和度量标准:
learn.metrics = partial(accuracy_multi, thresh=0.1)
learn.validate()
(#2) [0.10436797887086868,0.93057781457901]
如果选择的阈值太高,将只选择模型非常有信心的对象:
learn.metrics = partial(accuracy_multi, thresh=0.99)
learn.validate()
(#2) [0.10436797887086868,0.9416930675506592]
我们可以通过尝试几个级别并查看哪个效果最好来找到最佳阈值。如果我们只抓取一次预测,这将快得多:
preds,targs = learn.get_preds()
然后我们可以直接调用度量标准。请注意,默认情况下,get_preds
会为我们应用输出激活函数(在本例中为 sigmoid),因此我们需要告诉accuracy_multi
不要应用它:
accuracy_multi(preds, targs, thresh=0.9, sigmoid=False)
TensorMultiCategory(0.9554)
现在我们可以使用这种方法找到最佳阈值水平:
xs = torch.linspace(0.05,0.95,29)
accs = [accuracy_multi(preds, targs, thresh=i, sigmoid=False) for i in xs]
plt.plot(xs,accs);
在这种情况下,我们使用验证集来选择一个超参数(阈值),这就是验证集的目的。有时学生们表达了他们的担忧,即我们可能会对验证集过拟合,因为我们正在尝试很多值来找出哪个是最好的。然而,正如你在图中看到的,改变阈值在这种情况下会产生一个平滑的曲线,因此我们显然没有选择不合适的异常值。这是一个很好的例子,说明你必须小心理论(不要尝试很多超参数值,否则可能会过拟合验证集)与实践(如果关系是平滑的,这样做是可以的)之间的区别。
这结束了本章专门讨论多标签分类的部分。接下来,我们将看一下回归问题。
回归
很容易将深度学习模型视为被分类到领域中,如计算机视觉、NLP等等。事实上,这就是 fastai 对其应用程序进行分类的方式——主要是因为大多数人习惯于这样思考事物。
但实际上,这隐藏了一个更有趣和更深入的视角。一个模型由其独立和依赖变量以及其损失函数定义。这意味着实际上有比简单的基于领域的分割更广泛的模型数组。也许我们有一个独立变量是图像,一个依赖变量是文本(例如,从图像生成标题);或者我们有一个独立变量是文本,一个依赖变量是图像(例如,从标题生成图像——这实际上是深度学习可以做到的!);或者我们有图像、文本和表格数据作为独立变量,我们试图预测产品购买……可能性真的是无穷无尽的。
要能够超越固定应用程序,为新问题制定自己的新颖解决方案,真正理解数据块 API(也许还有我们将在本书后面看到的中间层 API)是有帮助的。举个例子,让我们考虑图像回归的问题。这指的是从一个独立变量是图像,依赖变量是一个或多个浮点数的数据集中学习。通常我们看到人们将图像回归视为一个完全独立的应用程序——但正如你在这里看到的,我们可以将其视为数据块 API 上的另一个 CNN。
我们将直接跳到图像回归的一个有点棘手的变体,因为我们知道你已经准备好了!我们将做一个关键点模型。关键点指的是图像中表示的特定位置——在这种情况下,我们将使用人物的图像,并且我们将寻找每个图像中人脸的中心。这意味着我们实际上将为每个图像预测两个值:人脸中心的行和列。
数据组装
我们将在这一部分使用Biwi Kinect Head Pose 数据集。我们将像往常一样开始下载数据集:
path = untar_data(URLs.BIWI_HEAD_POSE)
让我们看看我们有什么!
path.ls()
(#50) [Path('13.obj'),Path('07.obj'),Path('06.obj'),Path('13'),Path('10'),Path('
> 02'),Path('11'),Path('01'),Path('20.obj'),Path('17')...]
有 24 个从 01 到 24 编号的目录(它们对应不同的被摄人物),以及每个目录对应的.obj文件(我们这里不需要)。让我们看看其中一个目录的内容:
(path/'01').ls()
(#1000) [Path('01/frame_00281_pose.txt'),Path('01/frame_00078_pose.txt'),Path('0
> 1/frame_00349_rgb.jpg'),Path('01/frame_00304_pose.txt'),Path('01/frame_00207_
> pose.txt'),Path('01/frame_00116_rgb.jpg'),Path('01/frame_00084_rgb.jpg'),Path
> ('01/frame_00070_rgb.jpg'),Path('01/frame_00125_pose.txt'),Path('01/frame_003
> 24_rgb.jpg')...]
在子目录中,我们有不同的帧。每个帧都带有一个图像(_rgb.jpg)和一个姿势文件(_pose.txt)。我们可以使用get_image_files
轻松递归获取所有图像文件,然后编写一个函数,将图像文件名转换为其关联的姿势文件:
img_files = get_image_files(path)
def img2pose(x): return Path(f'{str(x)[:-7]}pose.txt')
img2pose(img_files[0])
Path('13/frame_00349_pose.txt')
让我们来看看我们的第一张图片:
im = PILImage.create(img_files[0])
im.shape
(480, 640)
im.to_thumb(160)
Biwi 数据集网站用于解释与每个图像关联的姿势文本文件的格式,显示头部中心的位置。这些细节对我们来说并不重要,所以我们只会展示我们用来提取头部中心点的函数:
cal = np.genfromtxt(path/'01'/'rgb.cal', skip_footer=6)
def get_ctr(f):
ctr = np.genfromtxt(img2pose(f), skip_header=3)
c1 = ctr[0] * cal[0][0]/ctr[2] + cal[0][2]
c2 = ctr[1] * cal[1][1]/ctr[2] + cal[1][2]
return tensor([c1,c2])
这个函数将坐标作为两个项目的张量返回:
get_ctr(img_files[0])
tensor([384.6370, 259.4787])
我们可以将此函数传递给DataBlock
作为get_y
,因为它负责为每个项目标记。我们将将图像调整为其输入大小的一半,以加快训练速度。
一个重要的要点是我们不应该只使用随机分割器。在这个数据集中,同一个人出现在多个图像中,但我们希望确保我们的模型可以泛化到它尚未见过的人。数据集中的每个文件夹包含一个人的图像。因此,我们可以创建一个分割器函数,仅为一个人返回True
,从而使验证集仅包含该人的图像。
与以前的数据块示例的唯一区别是第二个块是PointBlock
。这是必要的,以便 fastai 知道标签代表坐标;这样,它就知道在进行数据增强时,应该对这些坐标执行与图像相同的增强:
biwi = DataBlock(
blocks=(ImageBlock, PointBlock),
get_items=get_image_files,
get_y=get_ctr,
splitter=FuncSplitter(lambda o: o.parent.name=='13'),
batch_tfms=[*aug_transforms(size=(240,320)),
Normalize.from_stats(*imagenet_stats)]
)
点和数据增强
我们不知道其他库(除了 fastai)会自动且正确地将数据增强应用于坐标。因此,如果您使用另一个库,可能需要禁用这些问题的数据增强。
在进行任何建模之前,我们应该查看我们的数据以确认它看起来没问题:
dls = biwi.dataloaders(path)
dls.show_batch(max_n=9, figsize=(8,6))
看起来不错!除了通过视觉查看批次外,还可以查看底层张量(尤其是作为学生;这将有助于澄清您对模型实际看到的内容的理解):
xb,yb = dls.one_batch()
xb.shape,yb.shape
(torch.Size([64, 3, 240, 320]), torch.Size([64, 1, 2]))
确保您了解为什么这些是我们小批量的形状。
这是依赖变量的一个示例行:
yb[0]
tensor([[0.0111, 0.1810]], device='cuda:5')
正如您所看到的,我们不必使用单独的图像回归应用程序;我们所要做的就是标记数据并告诉 fastai 独立变量和因变量代表什么类型的数据。
创建我们的Learner
也是一样的。我们将使用与之前相同的函数,只有一个新参数,然后我们就可以准备训练我们的模型了。
训练模型
像往常一样,我们可以使用cnn_learner
来创建我们的Learner
。还记得在第一章中我们如何使用y_range
告诉 fastai 我们目标的范围吗?我们将在这里做同样的事情(fastai 和 PyTorch 中的坐标始终在-1 和+1 之间重新缩放):
learn = cnn_learner(dls, resnet18, y_range=(-1,1))
y_range
在 fastai 中使用sigmoid_range
实现,其定义如下:
def sigmoid_range(x, lo, hi): return torch.sigmoid(x) * (hi-lo) + lo
如果定义了y_range
,则将其设置为模型的最终层。花点时间思考一下这个函数的作用,以及为什么它强制模型在范围(lo,hi)
内输出激活。
这是它的样子:
plot_function(partial(sigmoid_range,lo=-1,hi=1), min=-4, max=4)
我们没有指定损失函数,这意味着我们得到了 fastai 选择的默认值。让我们看看它为我们选择了什么:
dls.loss_func
FlattenedLoss of MSELoss()
这是有道理的,因为当坐标被用作因变量时,大多数情况下我们可能会尽可能地预测接近某个值;这基本上就是 MSELoss
(均方误差损失)所做的。如果你想使用不同的损失函数,你可以通过使用 loss_func
参数将其传递给 cnn_learner
。
还要注意,我们没有指定任何指标。这是因为均方误差已经是这个任务的一个有用指标(尽管在我们取平方根之后可能更易解释)。
我们可以使用学习率查找器选择一个好的学习率:
learn.lr_find()
我们将尝试一个学习率为 2e-2:
lr = 2e-2
learn.fit_one_cycle(5, lr)
epoch | train_loss | valid_loss | time |
---|---|---|---|
0 | 0.045840 | 0.012957 | 00:36 |
1 | 0.006369 | 0.001853 | 00:36 |
2 | 0.003000 | 0.000496 | 00:37 |
3 | 0.001963 | 0.000360 | 00:37 |
4 | 0.001584 | 0.000116 | 00:36 |
通常情况下,当我们运行这个时,我们得到的损失大约是 0.0001,这对应于这个平均坐标预测误差:
math.sqrt(0.0001)
0.01
这听起来非常准确!但是重要的是要用 Learner.show_results
查看我们的结果。左侧是实际(真实)坐标,右侧是我们模型的预测:
learn.show_results(ds_idx=1, max_n=3, figsize=(6,8))
令人惊讶的是,仅仅几分钟的计算,我们就创建了一个如此准确的关键点模型,而且没有任何特定领域的应用。这就是在灵活的 API 上构建并使用迁移学习的力量!特别引人注目的是,我们能够如此有效地使用迁移学习,即使在完全不同的任务之间;我们的预训练模型是用来进行图像分类的,而我们对图像回归进行了微调。
结论
在乍一看完全不同的问题(单标签分类、多标签分类和回归)中,我们最终使用相同的模型,只是输出的数量不同。唯一改变的是损失函数,这就是为什么重要的是要仔细检查你是否为你的问题使用了正确的损失函数。
fastai 将自动尝试从您构建的数据中选择正确的损失函数,但如果您使用纯 PyTorch 构建您的 DataLoader
,请确保您认真考虑您选择的损失函数,并记住您很可能想要以下内容:
-
nn.CrossEntropyLoss
用于单标签分类 -
nn.BCEWithLogitsLoss
用于多标签分类 -
nn.MSELoss
用于回归
问卷
-
多标签分类如何提高熊分类器的可用性?
-
在多标签分类问题中,我们如何对因变量进行编码?
-
如何访问 DataFrame 的行和列,就像它是一个矩阵一样?
-
如何从 DataFrame 中按名称获取列?
-
Dataset
和DataLoader
之间有什么区别? -
Datasets
对象通常包含什么? -
DataLoaders
对象通常包含什么? -
lambda
在 Python 中是做什么的? -
如何使用数据块 API 自定义独立变量和因变量的创建方法?
-
当使用一个独热编码的目标时,为什么 softmax 不是一个合适的输出激活函数?
-
当使用一个独热编码的目标时,为什么
nll_loss
不是一个合适的损失函数? -
nn.BCELoss
和nn.BCEWithLogitsLoss
之间有什么区别? -
为什么在多标签问题中不能使用常规准确率?
-
何时可以在验证集上调整超参数?
-
y_range
在 fastai 中是如何实现的?(看看你是否可以自己实现并在不偷看的情况下测试!) -
回归问题是什么?对于这样的问题应该使用什么损失函数?
-
为了确保 fastai 库将相同的数据增强应用于您的输入图像和目标点坐标,您需要做什么?
进一步研究
-
阅读关于 Pandas DataFrames 的教程,并尝试一些看起来有趣的方法。查看书籍网站上推荐的教程。
-
使用多标签分类重新训练熊分类器。看看你是否可以使其有效地处理不包含任何熊的图像,包括在 Web 应用程序中显示该信息。尝试一张包含两种熊的图像。检查在单标签数据集上使用多标签分类是否会影响准确性。
第七章:训练一个最先进的模型
原文:
www.bookstack.cn/read/th-fastai-book/798d5ac22392691a.md
译者:飞龙
本章介绍了更高级的技术,用于训练图像分类模型并获得最先进的结果。如果您想了解更多关于深度学习的其他应用,并稍后回来,您可以跳过它——后续章节不会假设您已掌握这些材料。
我们将看一下什么是归一化,一种强大的数据增强技术叫做 Mixup,渐进式调整大小方法,以及测试时间增强。为了展示所有这些,我们将从头开始训练一个模型(不使用迁移学习),使用一个名为 Imagenette 的 ImageNet 子集。它包含了原始 ImageNet 数据集中 10 个非常不同的类别的子集,使得在我们想要进行实验时训练更快。
这将比我们之前的数据集更难做得好,因为我们使用全尺寸、全彩色的图像,这些图像是不同大小、不同方向、不同光照等对象的照片。因此,在本章中,我们将介绍一些重要的技术,以便充分利用您的数据集,特别是当您从头开始训练,或者使用迁移学习在一个与预训练模型使用的非常不同类型的数据集上训练模型时。
Imagenette
当 fast.ai 刚开始时,人们主要使用三个主要数据集来构建和测试计算机视觉模型:
ImageNet
1.3 百万张各种尺寸的图像,大约 500 像素宽,分为 1,000 个类别,需要几天时间来训练
MNIST
50,000 个 28×28 像素的灰度手写数字
CIFAR10
60,000 个 32×32 像素的彩色图像,分为 10 类
问题在于较小的数据集无法有效地泛化到大型 ImageNet 数据集。在 ImageNet 上表现良好的方法通常必须在 ImageNet 上开发和训练。这导致许多人认为,只有拥有巨大计算资源的研究人员才能有效地为发展图像分类算法做出贡献。
我们认为这似乎是不太可能成立的。我们从未见过一项研究表明 ImageNet 恰好是正确的大小,其他数据集无法提供有用的见解。因此,我们希望创建一个新的数据集,研究人员可以快速、廉价地测试他们的算法,但也能提供可能在完整的 ImageNet 数据集上起作用的见解。
大约三个小时后,我们创建了 Imagenette。我们从完整的 ImageNet 中选择了 10 个看起来非常不同的类别。正如我们所希望的那样,我们能够快速、廉价地创建一个能够识别这些类别的分类器。然后我们尝试了一些算法调整,看它们如何影响 Imagenette。我们发现一些效果不错的,并在 ImageNet 上进行了测试,我们很高兴地发现我们的调整在 ImageNet 上也效果很好!
这里有一个重要的信息:您得到的数据集不一定是您想要的数据集。特别是不太可能是您想要进行开发和原型设计的数据集。您应该力求迭代速度不超过几分钟——也就是说,当您想尝试一个新想法时,您应该能够在几分钟内训练一个模型并查看其效果。如果做一个实验花费的时间更长,考虑如何减少数据集的规模,或简化模型,以提高实验速度。您做的实验越多,效果就越好!
让我们从这个数据集开始:
from fastai.vision.all import *
path = untar_data(URLs.IMAGENETTE)
首先,我们将使用在第五章中介绍的预调整技巧将我们的数据集放入DataLoaders
对象中:
dblock = DataBlock(blocks=(ImageBlock(), CategoryBlock()),
get_items=get_image_files,
get_y=parent_label,
item_tfms=Resize(460),
batch_tfms=aug_transforms(size=224, min_scale=0.75))
dls = dblock.dataloaders(path, bs=64)
然后我们将进行一个作为基线的训练运行:
model = xresnet50()
learn = Learner(dls, model, loss_func=CrossEntropyLossFlat(), metrics=accuracy)
learn.fit_one_cycle(5, 3e-3)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 1.583403 | 2.064317 | 0.401792 | 01:03 |
1 | 1.208877 | 1.260106 | 0.601568 | 01:02 |
2 | 0.925265 | 1.036154 | 0.664302 | 01:03 |
3 | 0.730190 | 0.700906 | 0.777819 | 01:03 |
4 | 0.585707 | 0.541810 | 0.825243 | 01:03 |
这是一个很好的基准,因为我们没有使用预训练模型,但我们可以做得更好。当使用从头开始训练的模型,或者对与预训练使用的数据集非常不同的数据集进行微调时,一些额外的技术就变得非常重要。在本章的其余部分,我们将考虑一些您需要熟悉的关键方法。第一个方法是归一化您的数据。
归一化
在训练模型时,如果您的输入数据是归一化的,那将会有所帮助——也就是说,具有平均值为 0 和标准差为 1。但大多数图像和计算机视觉库使用像素值在 0 到 255 之间,或者在 0 到 1 之间;在任何一种情况下,您的数据都不会具有平均值为 0 和标准差为 1。
让我们获取一批数据并查看这些值,通过对除了通道轴之外的所有轴进行平均,通道轴是轴 1:
x,y = dls.one_batch()
x.mean(dim=[0,2,3]),x.std(dim=[0,2,3])
(TensorImage([0.4842, 0.4711, 0.4511], device='cuda:5'),
TensorImage([0.2873, 0.2893, 0.3110], device='cuda:5'))
正如我们预期的那样,平均值和标准差与期望值不太接近。幸运的是,在 fastai 中对数据进行归一化很容易,只需添加Normalize
转换。这会一次作用于整个小批量数据,因此您可以将其添加到数据块的batch_tfms
部分。您需要传递给此转换您想要使用的平均值和标准差;fastai 已经定义了标准的 ImageNet 平均值和标准差。(如果您没有向Normalize
转换传递任何统计数据,fastai 将自动从您的数据的一个批次中计算出它们。)
让我们添加这个转换(使用imagenet_stats
,因为 Imagenette 是 ImageNet 的一个子集),现在看一下一个批次:
def get_dls(bs, size):
dblock = DataBlock(blocks=(ImageBlock, CategoryBlock),
get_items=get_image_files,
get_y=parent_label,
item_tfms=Resize(460),
batch_tfms=[*aug_transforms(size=size, min_scale=0.75),
Normalize.from_stats(*imagenet_stats)])
return dblock.dataloaders(path, bs=bs)
dls = get_dls(64, 224)
x,y = dls.one_batch()
x.mean(dim=[0,2,3]),x.std(dim=[0,2,3])
(TensorImage([-0.0787, 0.0525, 0.2136], device='cuda:5'),
TensorImage([1.2330, 1.2112, 1.3031], device='cuda:5'))
让我们来看看这对训练我们的模型有什么影响:
model = xresnet50()
learn = Learner(dls, model, loss_func=CrossEntropyLossFlat(), metrics=accuracy)
learn.fit_one_cycle(5, 3e-3)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 1.632865 | 2.250024 | 0.391337 | 01:02 |
1 | 1.294041 | 1.579932 | 0.517177 | 01:02 |
2 | 0.960535 | 1.069164 | 0.657207 | 01:04 |
3 | 0.730220 | 0.767433 | 0.771845 | 01:05 |
4 | 0.577889 | 0.550673 | 0.824496 | 01:06 |
尽管在这里只有一点帮助,但在使用预训练模型时,归一化变得尤为重要。预训练模型只知道如何处理之前见过的数据类型。如果训练数据的平均像素值为 0,但您的数据的像素最小可能值为 0,那么模型将看到与预期完全不同的东西!
这意味着当您分发模型时,您需要同时分发用于归一化的统计数据,因为任何使用它进行推断或迁移学习的人都需要使用相同的统计数据。同样,如果您使用别人训练过的模型,请确保您了解他们使用的归一化统计数据,并进行匹配。
在之前的章节中,我们不必处理归一化,因为通过cnn_learner
使用预训练模型时,fastai 库会自动添加适当的Normalize
转换;模型已经使用Normalize
中的某些统计数据进行了预训练(通常来自 ImageNet 数据集),因此库可以为您填充这些数据。请注意,这仅适用于预训练模型,这就是为什么在从头开始训练时需要在这里手动添加这些信息的原因。
到目前为止,我们所有的训练都是在尺寸为 224 的情况下进行的。我们本可以在那之前从较小的尺寸开始训练。这被称为渐进调整。
渐进调整
当 fast.ai 及其学生团队在 2018 年赢得 DAWNBench 比赛时,其中最重要的创新之一是非常简单的事情:使用小图像开始训练,然后使用大图像结束训练。在大部分时期使用小图像进行训练有助于训练完成得更快。使用大图像完成训练使最终准确率更高。我们称这种方法为渐进式调整大小。
术语:渐进式调整大小
在训练过程中逐渐使用越来越大的图像。
正如我们所看到的,卷积神经网络学习的特征类型与图像的大小无关——早期层发现边缘和梯度等内容,而后期层可能发现鼻子和日落等内容。因此,当我们在训练中途更改图像大小时,并不意味着我们必须为我们的模型找到完全不同的参数。
但显然小图像和大图像之间存在一些差异,因此我们不应该期望我们的模型继续完全不变地工作得很好。这让你想起了什么吗?当我们开发这个想法时,它让我们想起了迁移学习!我们试图让我们的模型学会做一些与以前学会的有点不同的事情。因此,在调整图像大小后,我们应该能够使用fine_tune
方法。
渐进式调整大小还有一个额外的好处:它是另一种数据增强形式。因此,您应该期望看到使用渐进式调整大小训练的模型具有更好的泛化能力。
要实现渐进式调整大小,最方便的方法是首先创建一个get_dls
函数,该函数接受图像大小和批量大小,就像我们在前一节中所做的那样,并返回您的DataLoaders
。
现在,您可以使用小尺寸创建您的DataLoaders
,并以通常的方式使用fit_one_cycle
,训练的时期比您可能以其他方式做的要少:
dls = get_dls(128, 128)
learn = Learner(dls, xresnet50(), loss_func=CrossEntropyLossFlat(),
metrics=accuracy)
learn.fit_one_cycle(4, 3e-3)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 1.902943 | 2.447006 | 0.401419 | 00:30 |
1 | 1.315203 | 1.572992 | 0.525765 | 00:30 |
2 | 1.001199 | 0.767886 | 0.759149 | 00:30 |
3 | 0.765864 | 0.665562 | 0.797984 | 00:30 |
然后,您可以在Learner
内部替换DataLoaders
,并进行微调:
learn.dls = get_dls(64, 224)
learn.fine_tune(5, 1e-3)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 0.985213 | 1.654063 | 0.565721 | 01:06 |
epoch | train_loss | valid_loss | accuracy | time |
--- | --- | --- | --- | --- |
0 | 0.706869 | 0.689622 | 0.784541 | 01:07 |
1 | 0.739217 | 0.928541 | 0.712472 | 01:07 |
2 | 0.629462 | 0.788906 | 0.764003 | 01:07 |
3 | 0.491912 | 0.502622 | 0.836445 | 01:06 |
4 | 0.414880 | 0.431332 | 0.863331 | 01:06 |
正如您所看到的,我们的性能要好得多,而在每个时期的小图像上的初始训练速度要快得多。
您可以根据需要重复增加大小并训练更多时期的过程,为您希望的图像大小——但当然,如果使用大于磁盘上图像大小的图像大小,您将不会获得任何好处。
请注意,对于迁移学习,渐进式调整大小实际上可能会损害性能。如果您的预训练模型与您的迁移学习任务和数据集非常相似,并且是在类似大小的图像上训练的,那么权重不需要进行太多更改。在这种情况下,使用较小的图像进行训练可能会损坏预训练权重。
另一方面,如果迁移学习任务将使用与预训练任务中使用的图像大小、形状或风格不同的图像,渐进式调整大小可能会有所帮助。像往常一样,“它会有帮助吗?”的答案是“试试看!”
我们还可以尝试将数据增强应用于验证集。到目前为止,我们只在训练集上应用了数据增强;验证集始终获得相同的图像。但也许我们可以尝试为验证集的几个增强版本进行预测并取平均值。我们将在下一步考虑这种方法。
测试时间增强
我们一直在使用随机裁剪作为一种获取一些有用数据增强的方法,这导致更好的泛化,并且需要更少的训练数据。当我们使用随机裁剪时,fastai 将自动为验证集使用中心裁剪——也就是说,它将选择图像中心的最大正方形区域,而不会超出图像的边缘。
这通常会带来问题。例如,在多标签数据集中,有时图像边缘会有小物体;这些物体可能会被中心裁剪完全裁剪掉。即使对于像我们的宠物品种分类示例这样的问题,也有可能关键特征,例如鼻子的颜色,可能会被裁剪掉。
解决这个问题的一个方法是完全避免随机裁剪。相反,我们可以简单地压缩或拉伸矩形图像以适应正方形空间。但是这样我们会错过一个非常有用的数据增强,并且还会使图像识别对我们的模型更加困难,因为它必须学会识别被压缩和拉伸的图像,而不仅仅是正确比例的图像。
另一个解决方案是在验证时不进行中心裁剪,而是从原始矩形图像中选择若干区域进行裁剪,将每个区域通过我们的模型,然后取预测的最大值或平均值。事实上,我们不仅可以对不同裁剪进行此操作,还可以对所有测试时间增强参数的不同值进行操作。这被称为测试时间增强(TTA)。
术语:测试时间增强(TTA)
在推断或验证期间,使用数据增强创建每个图像的多个版本,然后取每个增强版本的预测的平均值或最大值。
根据数据集的不同,测试时间增强可以显著提高准确性。它不会改变训练所需的时间,但会增加验证或推断所需的时间,数量取决于请求的测试时间增强图像数量。默认情况下,fastai 将使用未增强的中心裁剪图像加上四个随机增强的图像。
您可以将任何DataLoader
传递给 fastai 的tta
方法;默认情况下,它将使用您的验证集:
preds,targs = learn.tta()
accuracy(preds, targs).item()
0.8737863898277283
正如我们所看到的,使用 TTA 可以显著提高性能,而无需额外的训练。但是,它会使推断变慢——如果你对 TTA 平均了五张图像,推断将变慢五倍。
我们已经看到了一些数据增强如何帮助训练更好的模型。现在让我们专注于一种名为混合的新数据增强技术。
混合
混合(Mixup)是在 2017 年张宏毅等人的论文《混合:超越经验风险最小化》中引入的一种强大的数据增强技术,可以提供极高的准确性,特别是当你没有太多数据,也没有经过预训练的模型,该模型是在与你的数据集相似的数据上训练的。该论文解释道:“虽然数据增强始终会导致改进的泛化,但该过程取决于数据集,并因此需要专业知识的使用。”例如,将图像翻转作为数据增强的一部分是很常见的,但是你应该只水平翻转还是同时垂直翻转呢?答案是取决于你的数据集。此外,如果(例如)翻转对你来说提供的数据增强不够,你不能“多翻转”。有助于拥有数据增强技术,可以“调高”或“调低”变化的程度,以找到最适合你的方法。
对于每个图像,Mixup 的工作方式如下:
-
随机从数据集中选择另一个图像。
-
随机选择一个权重。
-
使用步骤 2 中的权重对所选图像和您的图像进行加权平均;这将是您的自变量。
-
将这个图像的标签与您的图像的标签进行加权平均(使用相同的权重);这将是您的因变量。
在伪代码中,我们这样做(其中t
是我们加权平均值的权重):
image2,target2 = dataset[randint(0,len(dataset)]
t = random_float(0.5,1.0)
new_image = t * image1 + (1-t) * image2
new_target = t * target1 + (1-t) * target2
为了使其正常工作,我们的目标需要进行独热编码。该论文使用图 7-1 中的方程式描述了这一点(其中与我们伪代码中的t
相同)。
图 7-1。Mixup 论文摘录
图 7-2 展示了在 Mixup 中进行图像线性组合的样子。
图 7-2。混合教堂和加油站
第三个图像是通过将第一个图像的 0.3 倍和第二个图像的 0.7 倍相加而构建的。在这个例子中,模型应该预测“教堂”还是“加油站”?正确答案是 30%的教堂和 70%的加油站,因为如果我们采用独热编码目标的线性组合,那就是我们将得到的结果。例如,假设我们有 10 个类别,“教堂”由索引 2 表示,“加油站”由索引 7 表示。独热编码表示如下:
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0] and [0, 0, 0, 0, 0, 0, 0, 1, 0, 0]
这是我们的最终目标:
[0, 0, 0.3, 0, 0, 0, 0, 0.7, 0, 0]
fastai 通过向我们的Learner
添加一个callback来完成所有这些操作。Callback
是 fastai 中用于在训练循环中注入自定义行为的内容(如学习率调度或混合精度训练)。您将在第十六章中学习有关回调的所有内容,包括如何制作自己的回调。目前,您只需要知道使用cbs
参数将回调传递给Learner
。
这是我们如何使用 Mixup 训练模型的方式:
model = xresnet50()
learn = Learner(dls, model, loss_func=CrossEntropyLossFlat(),
metrics=accuracy, cbs=Mixup)
learn.fit_one_cycle(5, 3e-3)
当我们用这种方式“混合”的数据训练模型时会发生什么?显然,训练会更加困难,因为很难看清每个图像中的内容。模型必须为每个图像预测两个标签,而不仅仅是一个,并且还必须弄清楚每个标签的权重。然而,过拟合似乎不太可能成为问题,因为我们不会在每个时代中显示相同的图像,而是显示两个图像的随机组合。
与我们看到的其他增强方法相比,Mixup 需要更多的时代来训练以获得更好的准确性。您可以尝试使用fastai repo中的examples/train_imagenette.py脚本来训练 Imagenette,使用 Mixup 和不使用 Mixup。在撰写本文时,Imagenette repo中的排行榜显示,Mixup 用于训练超过 80 个时代的所有领先结果,而对于更少的时代,不使用 Mixup。这与我们使用 Mixup 的经验一致。
Mixup 如此令人兴奋的原因之一是它可以应用于除照片之外的数据类型。事实上,有些人甚至已经展示了通过在模型内部的激活上使用 Mixup 而获得良好结果,而不仅仅是在输入上使用 Mixup——这使得 Mixup 也可以用于 NLP 和其他数据类型。
Mixup 为我们处理的另一个微妙问题是,我们之前看到的模型实际上永远无法完美。问题在于我们的标签是 1 和 0,但 softmax 和 sigmoid 的输出永远无法等于 1 或 0。这意味着训练我们的模型会使我们的激活值越来越接近这些值,这样我们做的时代越多,我们的激活值就会变得越极端。
使用 Mixup,我们不再有这个问题,因为我们的标签只有在我们碰巧与同一类别的另一幅图像“混合”时才会完全是 1 或 0。其余时间,我们的标签将是一个线性组合,比如我们在之前的教堂和加油站示例中得到的 0.7 和 0.3。
然而,这种方法的一个问题是 Mixup“意外地”使标签大于 0 或小于 1。也就是说,我们并没有明确告诉我们的模型我们想以这种方式改变标签。因此,如果我们想要使标签更接近或远离 0 和 1,我们必须改变 Mixup 的数量,这也会改变数据增强的数量,这可能不是我们想要的。然而,有一种更直接处理的方法,那就是使用标签平滑。
标签平滑
在损失的理论表达中,在分类问题中,我们的目标是独热编码的(在实践中,我们倾向于避免这样做以节省内存,但我们计算的损失与使用独热编码时相同)。这意味着模型被训练为对所有类别返回 0,只有一个类别返回 1。即使是 0.999 也不是“足够好”;模型将获得梯度并学会以更高的信心预测激活。这会鼓励过拟合,并在推理时给出一个不会给出有意义概率的模型:即使不太确定,它总是会为预测的类别说 1,只是因为它是这样训练的。
如果您的数据不完全标记,这可能会变得非常有害。在我们在第二章中研究的熊分类器中,我们看到一些图像被错误标记,或包含两种不同种类的熊。一般来说,您的数据永远不会是完美的。即使标签是人工制作的,也可能出现错误,或者在难以标记的图像上存在不同意见。
相反,我们可以用一个比 1 稍微小一点的数字替换所有的 1,用一个比 0 稍微大一点的数字替换所有的 0,然后进行训练。这就是标签平滑。通过鼓励模型变得不那么自信,标签平滑将使您的训练更加健壮,即使存在错误标记的数据。结果将是一个在推理时更好泛化的模型。
这就是标签平滑在实践中的工作方式:我们从独热编码的标签开始,然后用(这是希腊字母epsilon,在介绍标签平滑的论文和 fastai 代码中使用)替换所有的 0,其中是类别数,是一个参数(通常为 0.1,这意味着我们对标签有 10%的不确定性)。由于我们希望标签总和为 1,我们还用替换 1。这样,我们不会鼓励模型过于自信地预测。在我们的 Imagenette 示例中有 10 个类别,目标变成了这样(这里是对应于索引 3 的目标):
[0.01, 0.01, 0.01, 0.91, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01]
在实践中,我们不想对标签进行独热编码,幸运的是我们也不需要(独热编码只是用来解释标签平滑和可视化的)。
在实践中使用这个方法,我们只需要在调用Learner
时改变损失函数:
model = xresnet50()
learn = Learner(dls, model, loss_func=LabelSmoothingCrossEntropy(),
metrics=accuracy)
learn.fit_one_cycle(5, 3e-3)
与 Mixup 一样,您通常在训练更多时期后才会看到标签平滑带来的显著改进。自己尝试一下:在标签平滑显示改进之前,您需要训练多少个时期?
结论
您现在已经看到了训练计算机视觉中最先进模型所需的一切,无论是从头开始还是使用迁移学习。现在您只需要在自己的问题上进行实验!看看使用 Mixup 和/或标签平滑进行更长时间的训练是否可以避免过拟合并给出更好的结果。尝试渐进式调整大小和测试时间增强。
最重要的是,记住,如果您的数据集很大,那么在整个数据集上进行原型设计是没有意义的。找到一个代表整体的小子集,就像我们在 Imagenette 上所做的那样,并在其上进行实验。
在接下来的三章中,我们将看到 fastai 直接支持的其他应用程序:协同过滤、表格建模和处理文本。在本书的下一部分中,我们将回到计算机视觉,深入研究卷积神经网络在第十三章中。
问卷
-
ImageNet 和 Imagenette 之间有什么区别?在什么情况下最好在其中一个上进行实验而不是另一个?
-
什么是归一化?
-
为什么在使用预训练模型时我们不需要关心归一化?
-
什么是渐进式调整大小?
-
在自己的项目中实现渐进式调整大小。有帮助吗?
-
什么是测试时间增强?如何在 fastai 中使用它?
-
在推理中使用 TTA 比常规推理更慢还是更快?为什么?
-
什么是 Mixup?如何在 fastai 中使用它?
-
为什么 Mixup 可以防止模型过于自信?
-
为什么使用 Mixup 进行五个时期的训练最终比不使用 Mixup 训练更糟糕?
-
标签平滑背后的理念是什么?
-
您的数据中有哪些问题可以通过标签平滑来解决?
-
在使用五个类别的标签平滑时,与索引 1 相关联的目标是什么?
-
当您想在新数据集上快速进行原型实验时,应该采取的第一步是什么?
进一步研究
-
使用 fastai 文档构建一个函数,将图像裁剪为每个角落的正方形;然后实现一种 TTA 方法,该方法对中心裁剪和这四个裁剪的预测进行平均。有帮助吗?比 fastai 的 TTA 方法更好吗?
-
在 arXiv 上找到 Mixup 论文并阅读。选择一两篇介绍 Mixup 变体的较新文章并阅读它们;然后尝试在您的问题上实现它们。
-
找到使用 Mixup 训练 Imagenette 的脚本,并将其用作在自己项目上进行长时间训练的示例。执行它并查看是否有帮助。
-
阅读侧边栏["标签平滑,论文"](#label_smoothing);然后查看原始论文的相关部分,看看您是否能够理解。不要害怕寻求帮助!