prog-pt-dl-merge-1

84 阅读42分钟

PyTorch 深度学习编程(二)

原文:Programming Pytorch for Deep Learning

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:迁移学习和其他技巧

在查看了上一章的架构之后,您可能会想知道是否可以下载一个已经训练好的模型,然后进一步训练它。答案是肯定的!这是深度学习领域中一种非常强大的技术,称为迁移学习,即将一个任务(例如 ImageNet)训练的网络适应到另一个任务(鱼与猫)。

为什么要这样做呢?事实证明,一个在 ImageNet 上训练过的架构已经对图像有了很多了解,特别是对于是否是猫或鱼(或狗或鲸鱼)有相当多的了解。因为您不再从一个基本上空白的神经网络开始,使用迁移学习,您可能会花费更少的时间进行训练,而且您可以通过一个远远较小的训练数据集来完成。传统的深度学习方法需要大量数据才能产生良好的结果。使用迁移学习,您可以用几百张图像构建人类级别的分类器。

使用 ResNet 进行迁移学习

现在,显而易见的事情是创建一个 ResNet 模型,就像我们在第三章中所做的那样,并将其插入到我们现有的训练循环中。您可以这样做!ResNet 模型中没有什么神奇的东西;它是由您已经看到的相同构建块构建而成。然而,这是一个庞大的模型,尽管您将看到一些改进,但您需要大量数据来确保训练信号到达架构的所有部分,并显著训练它们以适应您的新分类任务。我们试图避免在这种方法中使用大量数据。

然而,这里有一点需要注意:我们不再处理一个使用随机参数初始化的架构,就像我们过去所做的那样。我们的预训练的 ResNet 模型已经编码了大量信息,用于图像识别和分类需求,那么为什么要尝试重新训练它呢?相反,我们微调网络。我们稍微改变架构,以在末尾包含一个新的网络块,替换通常执行 ImageNet 分类的标准 1,000 个类别的线性层。然后,我们冻结所有现有的 ResNet 层,当我们训练时,我们只更新我们新层中的参数,但仍然从我们冻结的层中获取激活。这样可以快速训练我们的新层,同时保留预训练层已经包含的信息。

首先,让我们创建一个预训练的 ResNet-50 模型:

from torchvision import models
transfer_model = models.ResNet50(pretrained=True)

接下来,我们需要冻结层。我们这样做的方法很简单:通过使用requires_grad()来阻止它们累积梯度。我们需要为网络中的每个参数执行此操作,但幸运的是,PyTorch 提供了一个parameters()方法,使这变得相当容易:

for name, param in transfer_model.named_parameters():
    param.requires_grad = False
提示

您可能不想冻结模型中的BatchNorm层,因为它们将被训练来逼近模型最初训练的数据集的均值和标准差,而不是您想要微调的数据集。由于BatchNorm校正您的输入,您的数据中的一些信号可能会丢失。您可以查看模型结构,并仅冻结不是BatchNorm的层,就像这样:

for name, param in transfer_model.named_parameters():
    if("bn" not in name):
        param.requires_grad = False

然后,我们需要用一个新的分类块替换最终的分类块,用于检测猫或鱼。在这个例子中,我们用几个Linear层、一个ReLUDropout来替换它,但您也可以在这里添加额外的 CNN 层。令人高兴的是,PyTorch 对 ResNet 的实现定义了最终分类器块作为一个实例变量fc,所以我们只需要用我们的新结构替换它(PyTorch 提供的其他模型使用fcclassifier,所以如果您尝试使用不同的模型类型,您可能需要检查源代码中的定义):

transfer_model.fc = nn.Sequential(nn.Linear(transfer_model.fc.in_features,500),
nn.ReLU(),
nn.Dropout(), nn.Linear(500,2))

在上面的代码中,我们利用了in_features变量,它允许我们获取传入层的激活数量(在本例中为 2,048)。你也可以使用out_features来发现传出的激活数量。当你像搭积木一样组合网络时,这些都是很方便的函数;如果一层的传入特征与前一层的传出特征不匹配,你会在运行时得到一个错误。

最后,我们回到我们的训练循环,然后像往常一样训练模型。你应该在几个 epochs 内看到一些准确度的大幅提升。

迁移学习是提高深度学习应用准确性的关键技术,但我们可以采用一堆其他技巧来提升我们模型的性能。让我们看看其中一些。

找到那个学习率

你可能还记得我在第二章中介绍了训练神经网络的学习率的概念,提到它是你可以改变的最重要的超参数之一,然后又提到了你应该使用什么值,建议使用一个相对较小的数字,让你尝试不同的值。不过...坏消息是,很多人确实是这样发现他们架构的最佳学习率的,通常使用一种称为网格搜索的技术,通过穷举搜索一部分学习率值,将结果与验证数据集进行比较。这是非常耗时的,尽管有人这样做,但许多其他人更倾向于从实践者的传统中获得经验。例如,一个已经被观察到与 Adam 优化器一起工作的学习率值是 3e-4。这被称为 Karpathy 的常数,以安德烈·卡帕西(目前是特斯拉 AI 主管)在 2016 年发推文后得名。不幸的是,更少的人读到了他的下一条推文:“我只是想确保人们明白这是一个笑话。”有趣的是,3e-4 往往是一个可以提供良好结果的值,所以这是一个带有现实意味的笑话。

一方面,你可以进行缓慢而繁琐的搜索,另一方面,通过在无数架构上工作直到对一个好的学习率有了感觉来获得的晦涩和神秘的知识——甚至可以说是手工制作的神经网络。除了这两个极端,还有更好的方法吗?

幸运的是,答案是肯定的,尽管你会对有多少人没有使用这种更好的方法感到惊讶。美国海军研究实验室的研究科学家莱斯利·史密斯撰写的一篇有些晦涩的论文包含了一种寻找适当学习率的方法。但直到杰里米·霍华德在他的 fast.ai 课程中将这种技术推广开来,深度学习社区才开始关注。这个想法非常简单:在一个 epoch 的过程中,从一个小的学习率开始,逐渐增加到一个更高的学习率,每个小批次结束时都会有一个较高的学习率。计算每个速率的损失,然后查看绘图,选择使下降最大的学习率。例如,查看图 4-1 中的图表。

学习率损失图

图 4-1。学习率与损失

在这种情况下,我们应该考虑使用大约 1e-2 的学习率(在圆圈内标记),因为这大致是梯度下降最陡峭的点。

注意

请注意,你不是在寻找曲线的底部,这可能是更直观的地方;你要找的是最快到达底部的点。

以下是 fast.ai 库在幕后执行的简化版本:

import math
def find_lr(model, loss_fn, optimizer, init_value=1e-8, final_value=10.0):
    number_in_epoch = len(train_loader) - 1
    update_step = (final_value / init_value) ** (1 / number_in_epoch)
    lr = init_value
    optimizer.param_groups[0]["lr"] = lr
    best_loss = 0.0
    batch_num = 0
    losses = []
    log_lrs = []
    for data in train_loader:
        batch_num += 1
        inputs, labels = data
        inputs, labels = inputs, labels
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = loss_fn(outputs, labels)

        # Crash out if loss explodes

        if batch_num > 1 and loss > 4 * best_loss:
            return log_lrs[10:-5], losses[10:-5]

        # Record the best loss

        if loss < best_loss or batch_num == 1:
            best_loss = loss

        # Store the values

        losses.append(loss)
        log_lrs.append(math.log10(lr))

        # Do the backward pass and optimize

        loss.backward()
        optimizer.step()

        # Update the lr for the next step and store

        lr *= update_step
        optimizer.param_groups[0]["lr"] = lr
    return log_lrs[10:-5], losses[10:-5]

这里发生的情况是,我们遍历批次,几乎像往常一样训练;我们通过模型传递我们的输入,然后从该批次获取损失。我们记录到目前为止的best_loss是多少,并将新的损失与其进行比较。如果我们的新损失是best_loss的四倍以上,我们就会退出函数,返回到目前为止的内容(因为损失可能趋向无穷大)。否则,我们会继续附加当前学习率的损失和日志,并在循环结束时更新学习率到最大速率的下一步。然后可以使用matplotlibplt函数显示绘图:

logs,losses = find_lr()
plt.plot(logs,losses)
found_lr = 1e-2

请注意,我们返回lr日志和损失的切片。我们这样做只是因为训练的最初部分和最后几部分(特别是如果学习率变得非常快地变大)往往不会告诉我们太多信息。

fast.ai 库中的实现还包括加权平滑,因此您在绘图中会得到平滑的线条,而此代码段会产生尖锐的输出。最后,请记住,因为这个函数实际上确实训练了模型并干扰了优化器的学习率设置,所以您应该在调用find_lr()之前保存和重新加载您的模型,以恢复到调用该函数之前的状态,并重新初始化您选择的优化器,您现在可以这样做,传入您从图表中确定的学习率!

这为我们提供了一个良好的学习率值,但我们可以通过差异学习率做得更好。

差异学习率

到目前为止,我们对整个模型应用了一个学习率。从头开始训练模型时,这可能是有道理的,但是在迁移学习时,如果我们尝试一些不同的东西,通常可以获得更好的准确性:以不同速率训练不同组层。在本章的前面,我们冻结了模型中的所有预训练层,并只训练了我们的新分类器,但是我们可能想要微调我们正在使用的 ResNet 模型的一些层。也许给我们的分类器之前的层添加一些训练会使我们的模型更准确一点。但是由于这些前面的层已经在 ImageNet 数据集上进行了训练,也许与我们的新层相比,它们只需要一点点训练?PyTorch 提供了一种简单的方法来实现这一点。让我们修改 ResNet-50 模型的优化器:

optimizer = optimizer.Adam([
{ 'params': transfer_model.layer4.parameters(), 'lr': found_lr /3},
{ 'params': transfer_model.layer3.parameters(), 'lr': found_lr /9},
], lr=found_lr)

这将把layer4(就在我们的分类器之前)的学习率设置为找到的学习率的三分之一,layer3的学习率的九分之一。这种组合在我的工作中经验上表现得非常好,但显然您可以随意尝试。不过还有一件事。正如您可能还记得本章开头所说的,我们冻结了所有这些预训练层。给它们一个不同的学习率是很好的,但是目前,模型训练不会触及它们,因为它们不会累积梯度。让我们改变这一点:

unfreeze_layers = [transfer_model.layer3, transfer_model.layer4]
for layer in unfreeze_layers:
    for param in layer.parameters():
        param.requires_grad = True

现在这些层的参数再次接受梯度,当您微调模型时将应用差异学习率。请注意,您可以随意冻结和解冻模型的部分,并对每个层进行进一步的微调,如果您愿意的话!

现在我们已经看过学习率了,让我们来研究训练模型的另一个方面:我们输入的数据。

数据增强

数据科学中令人恐惧的短语之一是,“哦,不,我的模型在数据上过拟合了!”正如我在第二章中提到的,过拟合发生在模型决定反映训练集中呈现的数据而不是产生一个泛化解决方案时。你经常会听到人们谈论特定模型记住了数据集,意味着模型学习了答案,然后在生产数据上表现不佳。

传统的防范方法是积累大量数据。通过观察更多数据,模型对它试图解决的问题有一个更一般的概念。如果你把这种情况看作是一个压缩问题,那么如果你阻止模型简单地能够存储所有答案(通过用大量数据压倒性地超出其存储容量),它被迫压缩输入,因此产生一个不能简单地在自身内部存储答案的解决方案。这是可以的,而且效果很好,但是假设我们只有一千张图片,我们正在进行迁移学习。我们能做什么呢?

我们可以使用的一种方法是数据增强。如果我们有一张图像,我们可以对该图像做一些事情,应该可以防止过拟合,并使模型更加通用。考虑图 4-2(#normal-cat-in-box)和图 4-3(#flipped-cat-in-box)中的 Helvetica 猫的图像。

盒子里的猫

图 4-2. 我们的原始图像

翻转的盒子里的猫

图 4-3. 翻转的 Helvetica

显然对我们来说,它们是相同的图像。第二个只是第一个的镜像副本。张量表示将会不同,因为 RGB 值将在 3D 图像中的不同位置。但它仍然是一只猫,所以训练在这张图像上的模型希望能够学会识别左侧或右侧帧上的猫形状,而不仅仅是将整个图像与关联起来。在 PyTorch 中做到这一点很简单。你可能还记得这段代码片段来自第二章:

transforms = transforms.Compose([
        transforms.Resize(64),
        transforms.ToTensor(),
        transforms.Normalize(mean=[0.485, 0.456, 0.406],
                     std=[0.229, 0.224, 0.225] )
        ])

这形成了一个转换管道,所有图像在进入模型进行训练时都会经过。但是torchivision.transforms库包含许多其他可以用于增强训练数据的转换函数。让我们看一下一些更有用的转换,并查看 Helvetica 在一些不太明显的转换中会发生什么。

Torchvision 转换

torchvision包含了一个大量的潜在转换集合,可以用于数据增强,以及构建新转换的两种方式。在本节中,我们将看一下提供的最有用的转换,以及一些你可以在自己的应用中使用的自定义转换。

torchvision.transforms.ColorJitter(brightness=0, contrast=0, saturation=0, hue=0)

ColorJitter会随机改变图像的亮度、对比度、饱和度和色调。对于亮度、对比度和饱和度,你可以提供一个浮点数或一个浮点数元组,所有非负数在 0 到 1 的范围内,随机性将在 0 和提供的浮点数之间,或者它将使用元组生成在提供的一对浮点数之间的随机性。对于色调,需要一个在-0.5 到 0.5 之间的浮点数或浮点数元组,它将在[-hue,hue]或[min, max]之间生成随机色调调整。参见图 4-4 作为示例。

ColorJitter 应用于所有参数为 0.5

图 4-4. ColorJitter 应用于所有参数为 0.5

如果你想翻转你的图像,这两个转换会随机地在水平或垂直轴上反射图像:

torchvision.transforms.RandomHorizontalFlip(p=0.5)
torchvision.transforms.RandomVerticalFlip(p=0.5)

要么提供一个从 0 到 1 的概率来发生反射,要么接受默认的 50%反射几率。在图 4-5 中展示了一个垂直翻转的猫。

RandomVerticalFlip

图 4-5. 垂直翻转

RandomGrayscale是一种类似的转换类型,不同之处在于它会随机将图像变为灰度,取决于参数p(默认为 10%):

torchvision.transforms.RandomGrayscale(p=0.1)

RandomCropRandomResizeCrop,正如你所期望的那样,在图像上执行随机裁剪,size可以是一个整数,表示高度和宽度,或包含不同高度和宽度的元组。图 4-6 展示了RandomCrop的示例。

torchvision.transforms.RandomCrop(size, padding=None,
pad_if_needed=False, fill=0, padding_mode='constant')
torchvision.transforms.RandomResizedCrop(size, scale=(0.08, 1.0),
ratio=(0.75, 1.3333333333333333), interpolation=2)

现在在这里需要小心一点,因为如果您的裁剪区域太小,就有可能剪掉图像的重要部分,使模型训练错误的内容。例如,如果图像中有一只猫在桌子上玩耍,而裁剪掉了猫,只留下部分桌子被分类为,那就不太好。虽然RandomResizeCrop会调整裁剪大小以填充给定大小,但RandomCrop可能会取得靠近边缘并进入图像之外黑暗区域的裁剪。

注意

RandomResizeCrop使用双线性插值,但您也可以通过更改interpolation参数选择最近邻或双三次插值。有关更多详细信息,请参阅PIL 滤镜页面

正如您在第三章中看到的,我们可以添加填充以保持图像所需的大小。默认情况下,这是constant填充,它用fill中给定的值填充图像之外的空白像素。然而,我建议您改用reflect填充,因为经验上它似乎比只是填充空白常数空间要好一点。

尺寸为 100 的 RandomCrop

图 4-6。尺寸为 100 的 RandomCrop

如果您想要随机旋转图像,RandomRotation将在[-degrees, degrees]之间变化,如果degrees是一个单个浮点数或整数,或者在元组中是(min,max)

torchvision.transforms.RandomRotation(degrees, resample=False,expand=False, center=None)

如果expand设置为True,此函数将扩展输出图像,以便包含整个旋转;默认情况下,它设置为在输入尺寸内裁剪。您可以指定 PIL 重采样滤镜,并可选择提供一个(x,y)元组作为旋转中心;否则,变换将围绕图像中心旋转。图 4-7 是一个RandomRotation变换,其中degrees设置为 45。

旋转角度为 45 度的 RandomRotation

图 4-7。旋转角度为 45 度的 RandomRotation

Pad是一个通用的填充变换,它在图像的边界上添加填充(额外的高度和宽度):

torchvision.transforms.Pad(padding, fill=0, padding_mode=constant)

padding中的单个值将在所有方向上应用填充。两元组padding将在长度为(左/右,上/下)的方向上产生填充,四元组将在(左,上,右,下)的方向上产生填充。默认情况下,填充设置为constant模式,它将fill的值复制到填充槽中。其他选择是edge,它将图像边缘的最后值填充到填充长度;reflect,它将图像的值(除边缘外)反射到边界;以及symmetric,它是reflection,但包括图像边缘的最后值。图 4-8 展示了padding设置为 25 和padding_mode设置为reflect。看看盒子如何在边缘重复。

填充为 25 和填充模式为 reflect 的 Pad

图 4-8。使用填充为 25 和填充模式为 reflect 的填充

RandomAffine允许您指定图像的随机仿射变换(缩放、旋转、平移和/或剪切,或任何组合)。图 4-9 展示了仿射变换的一个示例。

torchvision.transforms.RandomAffine(degrees, translate=None, scale=None,
shear=None, resample=False, fillcolor=0)

旋转角度为 10 度和剪切为 50 的 RandomAffine

图 4-9。旋转角度为 10 度,剪切为 50 的 RandomAffine

degrees参数可以是单个浮点数或整数,也可以是一个元组。以单个形式,它会产生在(–*degrees**degrees*)之间的随机旋转。使用元组时,它会产生在(*min**max*)之间的随机旋转。必须明确设置degrees以防止旋转发生——没有默认设置。translate是一个包含两个乘数(*horizontal_multipler**vertical_multiplier*)的元组。在变换时,水平偏移dx在范围内取样,即(–*image_width × horizontal_multiplier < dx < img_width × horizontal_width*),垂直偏移也以相同的方式相对于图像高度和垂直乘数进行取样。

缩放由另一个元组(*min**max*)处理,从中随机抽取一个均匀缩放因子。剪切可以是单个浮点数/整数或一个元组,并以与degrees参数相同的方式随机取样。最后,resample允许您可选地提供一个 PIL 重采样滤波器,fillcolor是一个可选的整数,指定最终图像中位于最终变换之外的区域的填充颜色。

至于在数据增强流水线中应该使用哪些转换,我强烈建议开始使用各种随机翻转、颜色抖动、旋转和裁剪。

torchvision中还提供其他转换;查看文档以获取更多详细信息。但当然,您可能会发现自己想要创建一个特定于您的数据领域的转换,而这并不是默认包含的,因此 PyTorch 提供了各种定义自定义转换的方式,接下来您将看到。

颜色空间和 Lambda 转换

即使提到这似乎有点奇怪,但到目前为止,我们所有的图像工作都是在相当标准的 24 位 RGB 颜色空间中进行的,其中每个像素都有一个 8 位的红色、绿色和蓝色值来指示该像素的颜色。然而,其他颜色空间也是可用的!

HSV 是一种受欢迎的替代方案,它具有三个 8 位值,分别用于色调饱和度。一些人认为这种系统比传统的 RGB 颜色空间更准确地模拟了人类视觉。但为什么这很重要呢?在 RGB 中的一座山在 HSV 中也是一座山,对吧?

最近在着色方面的深度学习工作中有一些证据表明,其他颜色空间可能比 RGB 产生稍微更高的准确性。一座山可能是一座山,但在每个空间的表示中形成的张量将是不同的,一个空间可能比另一个更好地捕捉到您的数据的某些特征。

与集成结合使用时,您可以轻松地创建一系列模型,将 RGB、HSV、YUV 和 LAB 颜色空间的训练结果结合起来,从而从您的预测流水线中挤出更多的准确性百分点。

一个小问题是 PyTorch 没有提供可以执行此操作的转换。但它提供了一些工具,我们可以使用这些工具将标准 RGB 图像随机转换为 HSV(或其他颜色空间)。首先,如果我们查看 PIL 文档,我们会发现可以使用Image.convert()将 PIL 图像从一种颜色空间转换为另一种。我们可以编写一个自定义的transform类来执行这种转换,但 PyTorch 添加了一个transforms.Lambda类,以便我们可以轻松地包装任何函数并使其可用于转换流水线。这是我们的自定义函数:

def _random_colour_space(x):
    output = x.convert("HSV")
    return output

然后将其包装在transforms.Lambda类中,并可以在任何标准转换流水线中使用,就像我们以前看到的那样:

colour_transform = transforms.Lambda(lambda x: _random_colour_space(x))

如果我们想将每张图像都转换为 HSV,那倒没什么问题,但实际上我们并不想这样。我们希望它在每个批次中随机更改图像,因此很可能图像在不同的时期以不同的颜色空间呈现。我们可以更新我们的原始函数以生成一个随机数,并使用该随机数生成更改图像的随机概率,但相反,我们更懒惰,使用RandomApply

random_colour_transform = torchvision.transforms.RandomApply([colour_transform])

默认情况下,RandomApply会用值0.5填充参数p,所以转换被应用的概率是 50/50。尝试添加更多的颜色空间和应用转换的概率,看看它对我们的猫和鱼问题有什么影响。

让我们看看另一个稍微复杂一些的自定义转换。

自定义转换类

有时一个简单的 lambda 不够;也许我们有一些初始化或状态要跟踪,例如。在这些情况下,我们可以创建一个自定义转换,它可以操作 PIL 图像数据或张量。这样的类必须实现两个方法:__call__,转换管道在转换过程中将调用该方法;和__repr__,它应该返回一个字符串表示转换,以及可能对诊断目的有用的任何状态。

在下面的代码中,我们实现了一个转换类,它向张量添加随机高斯噪声。当类被初始化时,我们传入所需噪声的均值和标准分布,在__call__方法中,我们从这个分布中采样并将其添加到传入的张量中:

class Noise():
    """Adds gaussian noise to a tensor.

 >>> transforms.Compose([
 >>>     transforms.ToTensor(),
 >>>     Noise(0.1, 0.05)),
 >>> ])

 """
    def __init__(self, mean, stddev):
        self.mean = mean
        self.stddev = stddev

    def __call__(self, tensor):
        noise = torch.zeros_like(tensor).normal_(self.mean, self.stddev)
        return tensor.add_(noise)

    def __repr__(self):
        repr = f"{self.__class__.__name__  }(mean={self.mean},
               stddev={self.stddev})"
        return repr

如果我们将这个添加到管道中,我们可以看到__repr__方法被调用的结果:

transforms.Compose([Noise(0.1, 0.05))])
>> Compose(
    Noise(mean=0.1,sttdev=0.05)
)

因为转换没有任何限制,只是继承自基本的 Python 对象类,你可以做任何事情。想在运行时完全用来自 Google 图像搜索的东西替换图像?通过完全不同的神经网络运行图像并将结果传递到管道中?应用一系列图像转换,将图像变成其以前的疯狂反射阴影?所有这些都是可能的,尽管不完全推荐。尽管看到 Photoshop 的Twirl变换效果会使准确性变得更糟还是更好会很有趣!为什么不试试呢?

除了转换,还有一些其他方法可以尽可能地从模型中挤出更多性能。让我们看更多例子。

从小开始,变得更大!

这里有一个看起来奇怪但确实能获得真实结果的提示:从小开始,变得更大。我的意思是,如果你在 256×256 图像上训练,创建几个更多的数据集,其中图像已经缩放到 64×64 和 128×128。使用 64×64 数据集创建你的模型,像平常一样微调,然后使用完全相同的模型在 128×128 数据集上训练。不是从头开始,而是使用已经训练过的参数。一旦看起来你已经从 128×128 数据中挤出了最大的价值,转移到目标 256×256 数据。你可能会发现准确性提高了一个或两个百分点。

虽然我们不知道为什么这样做有效,但工作理论是通过在较低分辨率训练,模型学习图像的整体结构,并随着传入图像的扩展来完善这些知识。但这只是一个理论。然而,这并不能阻止它成为一个很好的小技巧,当你需要从模型中挤出每一点性能时。

如果你不想在存储中留下数据集的多个副本,你可以使用torchvision转换来使用Resize函数实时进行操作:

resize = transforms.Compose([ transforms.Resize(64),
 …_other augmentation transforms_…
 transforms.ToTensor(),
 transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])

你要付出的代价是你最终会花更多时间在训练上,因为 PyTorch 必须每次应用调整大小。如果你事先调整了所有图像,你可能会得到更快的训练运行,但这会填满你的硬盘。但这种权衡难道不是一直存在的吗?

从小开始然后变得更大的概念也适用于架构。使用像 ResNet-18 或 ResNet-34 这样的 ResNet 架构来测试转换方法并了解训练的工作方式,比起一开始就使用 ResNet-101 或 ResNet-152 模型,提供了一个更紧密的反馈循环。从小开始,逐步建立,你可以在预测时通过将它们添加到集成模型中来潜在地重复使用较小的模型运行。

集成

有什么比一个模型做出预测更好?那么,多个模型怎么样?集成是一种在传统机器学习方法中相当常见的技术,在深度学习中也非常有效。其思想是从一系列模型中获得预测,并将这些预测组合起来产生最终答案。由于不同模型在不同领域具有不同的优势,希望所有预测的组合将产生比单个模型更准确的结果。

有很多集成方法,我们不会在这里详细介绍所有方法。相反,这里有一种简单的集成方法,可以在我的经验中再增加 1%的准确率;简单地平均预测结果:

# Assuming you have a list of models in models, and input is your input tensor

predictions = [m[i].fit(input) for i in models]
avg_prediction = torch.stack(b).mean(0).argmax()

stack方法将张量数组连接在一起,因此,如果我们在猫/鱼问题上工作,并且在我们的集成中有四个模型,我们将得到一个由四个 1×2 张量构成的 4×2 张量。mean执行您所期望的操作,取平均值,尽管我们必须传入维度 0 以确保它在第一维上取平均值,而不仅仅是将所有张量元素相加并产生标量输出。最后,argmax选择具有最高元素的张量索引,就像您以前看到的那样。

很容易想象更复杂的方法。也许可以为每个单独模型的预测结果添加权重,并且如果模型回答正确或错误,则调整这些权重。您应该使用哪些模型?我发现 ResNets(例如 34、50、101)的组合效果非常好,没有什么能阻止您定期保存模型并在集成中使用模型的不同快照!

结论

当我们结束第四章时,我们将离开图像,转向文本。希望您不仅了解卷积神经网络在图像上的工作原理,还掌握了一系列技巧,包括迁移学习、学习率查找、数据增强和集成,这些技巧可以应用于您特定的应用领域。

进一步阅读

如果您对图像领域的更多信息感兴趣,请查看 Jeremy Howard,Rachel Thomas 和 Sylvain Gugger 的fast.ai课程。正如我所提到的,本章的学习率查找器是他们使用的简化版本,但该课程详细介绍了本章中许多技术。建立在 PyTorch 上的 fast.ai 库使您可以轻松将它们应用于您的图像(和文本!)领域。

¹ 请参阅 Leslie Smith(2015)的“用于训练神经网络的循环学习率”

第五章:文本分类

我们暂时离开图像,转而关注另一个领域,深度学习在传统技术上已经被证明是一个重大进步的地方:自然语言处理(NLP)。一个很好的例子是谷歌翻译。最初,处理翻译的代码有 500,000 行代码。基于 TensorFlow 的新系统大约有 500 行代码,性能比旧方法更好。

最近的突破还发生在将迁移学习(你在第四章中学到的)引入到 NLP 问题中。新的架构,如 Transformer 架构,已经导致了像 OpenAI 的 GPT-2 这样的网络的创建,其更大的变体产生的文本几乎具有人类般的质量(事实上,OpenAI 没有发布这个模型的权重,因为担心它被恶意使用)。

本章提供了循环神经网络和嵌入的快速介绍。然后我们探讨了torchtext库以及如何使用基于 LSTM 的模型进行文本处理。

循环神经网络

如果我们回顾一下迄今为止我们如何使用基于 CNN 的架构,我们可以看到它们总是在一个完整的时间快照上工作。但考虑这两个句子片段:

The cat sat on the mat.

She got up and impatiently climbed on the chair, meowing for food.

假设你将这两个句子一个接一个地馈送到 CNN 中,并问,“猫在哪里?”你会遇到问题,因为网络没有“记忆”的概念。当处理具有时间域的数据时(例如文本、语音、视频和时间序列数据)这是非常重要的。通过循环神经网络(RNNs)通过隐藏状态给神经网络提供了记忆来解决这个问题。

RNN 是什么样子的?我最喜欢的解释是,“想象一个神经网络和一个for循环相交。” 图 5-1 显示了一个经典 RNN 结构的图表。

经典 RNN 图

图 5-1。一个 RNN

我们在时间步t添加输入,得到ht隐藏输出状态,并且输出也被馈送回 RNN 用于下一个时间步。我们可以展开这个网络,深入了解正在发生的事情,如图 5-2 所示。

!展开的 RNN 图

图 5-2。一个展开的 RNN

我们这里有一组全连接层(具有共享参数)、一系列输入和输出。输入数据被馈送到网络中,然后预测输出作为序列中的下一个项目。在展开的视图中,我们可以看到 RNN 可以被看作是一系列全连接层的管道,连续的输入被馈送到序列中的下一层(在层之间插入了通常的非线性,如ReLU)。当我们完成了预测的序列后,我们必须通过 RNN 将错误反向传播回去。因为这涉及到通过网络的步骤向后走,这个过程被称为通过时间的反向传播。错误是在整个序列上计算的,然后网络像图 5-2 中展开一样,为每个时间步计算梯度并组合以更新网络的共享参数。你可以想象它是在单独的网络上进行反向传播,然后将所有梯度相加。

这就是 RNN 的理论。但这种简单的结构存在问题,我们需要讨论如何通过更新的架构来克服这些问题。

长短期记忆网络

实际上,RNN 在梯度消失问题上特别容易受到影响,我们在第二章中讨论过,或者更糟糕的情况是梯度爆炸,其中你的错误趋向于无穷大。这两种情况都不好,因此 RNN 无法解决许多它们被认为适合的问题。这一切在 1997 年 Sepp Hochreiter 和 Jürgen Schmidhuber 引入长短期记忆(LSTM)变体的时候发生了改变。

图 5-3 展示了一个 LSTM 层。我知道,这里有很多东西,但并不太复杂。真诚地说。

LSTM 图示

图 5-3. 一个 LSTM

好的,我承认,这确实令人生畏。关键是要考虑三个门(输入、输出和遗忘)。在标准的 RNN 中,我们会永远“记住”一切。但这并不是我们大脑的工作方式(可悲!),LSTM 的遗忘门允许我们建模这样一个想法,即随着我们在输入链中继续,链的开始变得不那么重要。LSTM 忘记多少是在训练过程中学习的,因此,如果对网络来说最好是非常健忘的,遗忘门参数就会这样做。

单元最终成为网络层的“记忆”,而输入、输出和遗忘门将决定数据如何在层中流动。数据可能只是通过,它可能“写入”到单元中,这些数据可能(或可能不会!)通过输出门被传递到下一层,并受到输出门的影响。

这些部分的组合足以解决梯度消失问题,并且还具有图灵完备性,因此理论上,你可以使用其中之一进行计算机上的任何计算。

当然,事情并没有止步于此。自 LSTM 以来,RNN 领域发生了几项发展,我们将在接下来的部分中涵盖一些主要内容。

门控循环单元

自 1997 年以来,已经创建了许多基本 LSTM 网络的变体,其中大多数你可能不需要了解,除非你感兴趣。然而,2014 年出现的一个变体,门控循环单元(GRU),值得了解,因为在某些领域它变得非常流行。图 5-4 展示了一个 GRU 架构的组成。

GRU 图示

图 5-4. 一个 GRU

主要的要点是 GRU 已经将遗忘门与输出门合并。这意味着它比 LSTM 具有更少的参数,因此倾向于更快地训练并在运行时使用更少的资源。出于这些原因,以及它们基本上是 LSTM 的替代品,它们变得非常流行。然而,严格来说,它们比 LSTM 弱一些,因为合并了遗忘和输出门,所以一般我建议在网络中尝试使用 GRU 或 LSTM,并看看哪个表现更好。或者只是接受 LSTM 在训练时可能会慢一些,但最终可能是最佳选择。你不必追随最新的潮流——真诚地说!

双向 LSTM

LSTM 的另一个常见变体是双向 LSTM 或简称biLSTM。正如你目前所看到的,传统的 LSTM(以及 RNN 总体)在训练过程中可以查看过去并做出决策。不幸的是,有时候你也需要看到未来。这在翻译和手写识别等应用中尤为重要,因为当前状态之后发生的事情可能与之前状态一样重要,以确定输出。

双向 LSTM 以最简单的方式解决了这个问题:它本质上是两个堆叠的 LSTM,其中输入在一个 LSTM 中向前发送,而在第二个 LSTM 中向后发送。图 5-5 展示了双向 LSTM 如何双向跨越其输入以产生输出。

双向 LSTM 图示

图 5-5. 一个双向 LSTM

PyTorch 通过在创建LSTM()单元时传入bidirectional=True参数来轻松创建双向 LSTM,您将在本章后面看到。

这完成了我们对基于 RNN 的架构的介绍。在第九章中,当我们研究基于 Transformer 的 BERT 和 GPT-2 模型时,我们将回到架构问题。

嵌入

我们几乎可以开始编写一些代码了!但在此之前,您可能会想到一个小细节:我们如何在网络中表示单词?毕竟,我们正在将数字张量输入到网络中,并获得张量输出。对于图像,将它们转换为表示红/绿/蓝分量值的张量似乎是一件很明显的事情,因为它们已经自然地被认为是数组,因为它们带有高度和宽度。但是单词?句子?这将如何运作?

最简单的方法仍然是许多自然语言处理方法中常见的方法之一,称为one-hot 编码。这很简单!让我们从本章开头的第一句话开始看:

The cat sat on the mat.

如果我们考虑这是我们世界的整个词汇表,我们有一个张量[the, cat, sat, on, mat]。one-hot 编码简单地意味着我们创建一个与词汇表大小相同的向量,并为其中的每个单词分配一个向量,其中一个参数设置为 1,其余设置为 0:

the — [1 0 0 0 0]
cat — [0 1 0 0 0]
sat — [0 0 1 0 0]
on  — [0 0 0 1 0]
mat — [0 0 0 0 1]

我们现在已经将单词转换为向量,可以将它们输入到我们的网络中。此外,我们可以向我们的词汇表中添加额外的符号,比如UNK(未知,用于不在词汇表中的单词)和START/STOP来表示句子的开头和结尾。

当我们在示例词汇表中添加另一个单词时,one-hot 编码会显示出一些限制:kitty。根据我们的编码方案,kitty将由[0 0 0 0 0 1]表示(其他向量都用零填充)。首先,您可以看到,如果我们要建模一个现实的单词集,我们的向量将非常长,几乎没有信息。其次,也许更重要的是,我们知道小猫之间存在非常强烈的关系(还有该死,但幸运的是在我们的词汇表中被跳过了!),这是无法用 one-hot 编码表示的;这两个单词是完全不同的东西。

最近变得更受欢迎的一种方法是用嵌入矩阵替换 one-hot 编码(当然,one-hot 编码本身就是一个嵌入矩阵,只是不包含有关单词之间关系的任何信息)。这个想法是将向量空间的维度压缩到更易管理的尺寸,并利用空间本身的优势。

例如,如果我们在 2D 空间中有一个嵌入,也许cat可以用张量[0.56, 0.45]表示,kitten可以用[0.56, 0.445]表示,而mat可以用[0.2, -0.1]表示。我们在向量空间中将相似的单词聚集在一起,并可以使用欧几里得或余弦距离函数进行距离检查,以确定单词之间的接近程度。那么我们如何确定单词在向量空间中的位置呢?嵌入层与您迄今在构建神经网络中看到的任何其他层没有区别;我们随机初始化向量空间,希望训练过程更新参数,使相似的单词或概念相互靠近。

嵌入向量的一个著名例子是word2vec,它是由 Google 在 2013 年发布的。这是使用浅层神经网络训练的一组词嵌入,它揭示了向量空间转换似乎捕捉到了有关支撑单词的概念的一些内容。在其常被引用的发现中,如果您提取KingManWoman的向量,然后从King中减去Man的向量并加上Woman的向量,您将得到一个代表Queen的向量表示。自从word2vec以来,其他预训练的嵌入也已经可用,如ELMoGloVefasttext

至于在 PyTorch 中使用嵌入,非常简单:

embed = nn.Embedding(vocab_size, dimension_size)

这将包含一个vocab_size x dimension_size的张量,随机初始化。我更喜欢认为它只是一个巨大的数组或查找表。您词汇表中的每个单词索引到一个大小为dimension_size的向量条目,所以如果我们回到我们的猫及其在垫子上的史诗般的冒险,我们会得到这样的东西:

cat_mat_embed = nn.Embedding(5, 2)
cat_tensor = Tensor([1])
cat_mat_embed.forward(cat_tensor)

> tensor([[ 1.7793, -0.3127]], grad_fn=<EmbeddingBackward>)

我们创建了我们的嵌入,一个包含cat在我们词汇表中位置的张量,并通过层的forward()方法传递它。这给了我们我们的随机嵌入。结果还指出,我们有一个梯度函数,我们可以在将其与损失函数结合后用于更新参数。

我们现在已经学习了所有的理论,可以开始构建一些东西了!

torchtext

就像torchvision一样,PyTorch 提供了一个官方库torchtext,用于处理文本处理管道。然而,torchtext并没有像torchvision那样经过充分测试,也没有像torchvision那样受到很多关注,这意味着它不太容易使用或文档不够完善。但它仍然是一个强大的库,可以处理构建基于文本的数据集的许多琐碎工作,所以我们将在本章的其余部分中使用它。

安装torchtext相当简单。您可以使用标准的pip

pip install torchtext

或者特定的conda渠道:

conda install -c derickl torchtext

您还需要安装spaCy(一个 NLP 库)和 pandas,如果您的系统上没有它们的话(再次使用pipconda)。我们使用spaCy来处理torchtext管道中的文本,使用 pandas 来探索和清理我们的数据。

获取我们的数据:推文!

在这一部分,我们构建一个情感分析模型,所以让我们获取一个数据集。torchtext通过torchtext.datasets模块提供了一堆内置数据集,但我们将从头开始工作,以便了解构建自定义数据集并将其馈送到我们创建的模型的感觉。我们使用Sentiment140 数据集。这是基于 Twitter 上的推文,每个推文被排名为 0 表示负面,2 表示中性,4 表示积极。

下载 zip 存档并解压。我们使用文件training.1600000.processed.noemoticon.csv。让我们使用 pandas 查看文件:

import pandas as pd
tweetsDF = pd.read_csv("training.1600000.processed.noemoticon.csv",
                        header=None)

此时您可能会遇到这样的错误:

UnicodeDecodeError: 'utf-8' codec can't decode bytes in
position 80-81: invalid continuation byte

恭喜你,现在你是一个真正的数据科学家,你可以处理数据清洗了!从错误消息中可以看出,pandas 使用的默认基于 C 的 CSV 解析器不喜欢文件中的一些 Unicode,所以我们需要切换到基于 Python 的解析器:

tweetsDF = pd.read_csv("training.1600000.processed.noemoticon.csv",
engine="python", header=None)

让我们通过显示前五行来查看数据的结构:

>>> tweetDF.head(5)
0  0  1467810672  ...  NO_QUERY   scotthamilton  is upset that ...
1  0  1467810917  ...  NO_QUERY        mattycus  @Kenichan I dived many times ...
2  0  1467811184  ...  NO_QUERY         ElleCTF    my whole body feels itchy
3  0  1467811193  ...  NO_QUERY          Karoli  @nationwideclass no, it's ...
4  0  1467811372  ...  NO_QUERY        joy_wolf  @Kwesidei not the whole crew

令人恼火的是,这个 CSV 中没有标题字段(再次欢迎来到数据科学家的世界!),但通过查看网站并运用我们的直觉,我们可以看到我们感兴趣的是最后一列(推文文本)和第一列(我们的标签)。然而,标签不是很好,所以让我们做一些特征工程来解决这个问题。让我们看看我们的训练集中有哪些计数:

>>> tweetsDF[0].value_counts()
4    800000
0    800000
Name: 0, dtype: int64

有趣的是,在训练数据集中没有中性值。这意味着我们可以将问题制定为 0 和 1 之间的二元选择,并从中得出我们的预测,但目前我们坚持原始计划,即未来可能会有中性推文。为了将类别编码为从 0 开始的数字,我们首先从标签列创建一个category类型的列:

tweetsDF["sentiment_cat"] = tweetsDF[0].astype('category')

然后我们将这些类别编码为另一列中的数字信息:

tweetsDF["sentiment"] = tweetsDF["sentiment_cat"].cat.codes

然后我们将修改后的 CSV 保存回磁盘:

tweetsDF.to_csv("train-processed.csv", header=None, index=None)

我建议您保存另一个 CSV 文件,其中包含 160 万条推文的小样本,供您进行测试:

tweetsDF.sample(10000).to_csv("train-processed-sample.csv", header=None,
    index=None)

现在我们需要告诉torchtext我们认为对于创建数据集而言重要的内容。

定义字段

torchtext采用了一种直接的方法来生成数据集:您告诉它您想要什么,它将为您处理原始 CSV(或 JSON)数据。您首先通过定义字段来实现这一点。Field类有相当多的参数可以分配给它,尽管您可能不会同时使用所有这些参数,但表 5-1 提供了一个方便的指南,说明您可以使用Field做什么。

表 5-1. 字段参数类型

参数描述默认值
sequential字段是否表示序列数据(即文本)。如果设置为False,则不会应用标记化。True
use_vocab是否包含Vocab对象。如果设置为False,字段应包含数字数据。True
init_token将添加到此字段开头以指示数据开始的令牌。None
eos_token附加到每个序列末尾的句子结束令牌。None
fix_length如果设置为整数,所有条目将填充到此长度。如果为None,序列长度将是灵活的。None
dtype张量批次的类型。torch.long
lower将序列转换为小写。False
tokenize将执行序列标记化的函数。如果设置为spacy,将使用 spaCy 分词器。string.split
pad_token将用作填充的令牌。<pad>
unk_token用于表示Vocab dict中不存在的单词的令牌。<unk>
pad_first在序列开始处填充。False
truncate_first在序列开头截断(如果需要)。False

正如我们所指出的,我们只对标签和推文文本感兴趣。我们通过使用Field数据类型来定义这些内容:

from torchtext import data

LABEL = data.LabelField()
TWEET = data.Field(tokenize='spacy', lower=true)

我们将LABEL定义为LabelField,它是Field的子类,将sequential设置为False(因为它是我们的数字类别)。TWEET是一个标准的Field对象,我们决定使用 spaCy 分词器并将所有文本转换为小写,但在其他方面,我们使用前面表中列出的默认值。如果在运行此示例时,构建词汇表的步骤花费了很长时间,请尝试删除tokenize参数并重新运行。这将使用默认值,即简单地按空格分割,这将大大加快标记化步骤,尽管创建的词汇表不如 spaCy 创建的那么好。

定义了这些字段后,我们现在需要生成一个列表,将它们映射到 CSV 中的行列表:

 fields = [('score',None), ('id',None),('date',None),('query',None),
      ('name',None),
      ('tweet', TWEET),('category',None),('label',LABEL)]

有了我们声明的字段,我们现在使用TabularDataset将该定义应用于 CSV:

twitterDataset = torchtext.data.TabularDataset(
        path="training-processed.csv",
        format="CSV",
        fields=fields,
        skip_header=False)

这可能需要一些时间,特别是使用 spaCy 解析器。最后,我们可以使用split()方法将其拆分为训练、测试和验证集:

(train, test, valid) = twitterDataset.split(split_ratio=[0.8,0.1,0.1])

(len(train),len(test),len(valid))
> (1280000, 160000, 160000)

以下是从数据集中提取的示例:

>vars(train.examples[7])

{'label': '6681',
 'tweet': ['woah',
  ',',
  'hell',
  'in',
  'chapel',
  'thrill',
  'is',
  'closed',
  '.',
  'no',
  'more',
  'sweaty',
  'basement',
  'dance',
  'parties',
  '?',
  '?']}

在一个令人惊讶的巧合中,随机选择的推文提到了我经常访问的教堂山俱乐部的关闭。看看您在数据中的浏览中是否发现了任何奇怪的事情!

构建词汇表

传统上,在这一点上,我们将构建数据集中每个单词的独热编码——这是一个相当乏味的过程。幸运的是,torchtext会为我们做这个工作,并且还允许传入一个max_size参数来限制词汇表中最常见的单词。通常这样做是为了防止构建一个巨大的、占用内存的模型。毕竟,我们不希望我们的 GPU 被压倒。让我们将词汇表限制在训练集中最多 20,000 个单词:

vocab_size = 20000
TWEET.build_vocab(train, max_size = vocab_size)

然后我们可以查询vocab类实例对象,以发现关于我们数据集的一些信息。首先,我们问传统的“我们的词汇量有多大?”:

len(TWEET.vocab)
> 20002

等等,等等,什么? 是的,我们指定了 20,000,但默认情况下,torchtext会添加两个特殊的标记,<unk>表示未知单词(例如,那些被我们指定的 20,000 max_size截断的单词),以及<pad>,一个填充标记,将用于将所有文本填充到大致相同的大小,以帮助在 GPU 上进行有效的批处理(请记住,GPU 的速度来自于对常规批次的操作)。当您声明一个字段时,您还可以指定eos_tokeninit_token符号,但它们不是默认包含的。

现在让我们来看看词汇表中最常见的单词:

>TWEET.vocab.freqs.most_common(10)
[('!', 44802),
 ('.', 40088),
 ('I', 33133),
 (' ', 29484),
 ('to', 28024),
 ('the', 24389),
 (',', 23951),
('a', 18366),
 ('i', 17189),
('and', 14252)]

基本上符合您的预期,因为我们的 spaCy 分词器没有去除停用词。(因为它只有 140 个字符,如果我们去除了停用词,我们的模型将丢失太多信息。)

我们几乎已经完成了我们的数据集。我们只需要创建一个数据加载器来输入到我们的训练循环中。torchtext提供了BucketIterator方法,它将生成一个称为Batch的东西,几乎与我们在图像上使用的数据加载器相同,但又有所不同。(很快您将看到,我们必须更新我们的训练循环来处理Batch接口的一些奇怪之处。)

train_iterator, valid_iterator, test_iterator = data.BucketIterator.splits(
(train, valid, test),
batch_size = 32,
device = device)

将所有内容放在一起,这是构建我们数据集的完整代码:

from torchtext import data

device = "cuda"
LABEL = data.LabelField()
TWEET = data.Field(tokenize='spacy', lower=true)

fields = [('score',None), ('id',None),('date',None),('query',None),
      ('name',None),
      ('tweet', TWEET),('category',None),('label',LABEL)]

twitterDataset = torchtext.data.TabularDataset(
        path="training-processed.csv",
        format="CSV",
        fields=fields,
        skip_header=False)

(train, test, valid) = twitterDataset.split(split_ratio=[0.8,0.1,0.1])

vocab_size = 20002
TWEET.build_vocab(train, max_size = vocab_size)

train_iterator, valid_iterator, test_iterator = data.BucketIterator.splits(
(train, valid, test),
batch_size = 32,
device = device)

有了我们的数据处理完成,我们可以继续定义我们的模型。

创建我们的模型

我们在 PyTorch 中使用了我们在本章前半部分讨论过的EmbeddingLSTM模块来构建一个简单的推文分类模型:

import torch.nn as nn

class OurFirstLSTM(nn.Module):
    def __init__(self, hidden_size, embedding_dim, vocab_size):
        super(OurFirstLSTM, self).__init__()

        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.encoder = nn.LSTM(input_size=embedding_dim,
                hidden_size=hidden_size, num_layers=1)
        self.predictor = nn.Linear(hidden_size, 2)

    def forward(self, seq):
        output, (hidden,_) = self.encoder(self.embedding(seq))
        preds = self.predictor(hidden.squeeze(0))
        return preds

model = OurFirstLSTM(100,300, 20002)
model.to(device)

在这个模型中,我们所做的就是创建三个层。首先,我们的推文中的单词被推送到一个Embedding层中,我们已经将其建立为一个 300 维的向量嵌入。然后将其输入到一个具有 100 个隐藏特征的LSTM中(同样,我们正在从 300 维的输入中进行压缩,就像我们在处理图像时所做的那样)。最后,LSTM 的输出(处理传入推文后的最终隐藏状态)被推送到一个标准的全连接层中,有三个输出对应于我们的三个可能的类别(负面、积极或中性)。接下来我们转向训练循环!

更新训练循环

由于一些torchtext的怪癖,我们需要编写一个稍微修改过的训练循环。首先,我们创建一个优化器(通常我们使用 Adam)和一个损失函数。因为对于每个推文,我们有三个潜在的类别,所以我们使用CrossEntropyLoss()作为我们的损失函数。然而,事实证明数据集中只有两个类别;如果我们假设只有两个类别,我们实际上可以改变模型的输出,使其产生一个介于 0 和 1 之间的单个数字,然后使用二元交叉熵(BCE)损失(我们可以将将输出压缩在 0 和 1 之间的 sigmoid 层和 BCE 层组合成一个单一的 PyTorch 损失函数,BCEWithLogitsLoss())。我提到这一点是因为如果您正在编写一个必须始终处于一种状态或另一种状态的分类器,那么它比我们即将使用的标准交叉熵损失更合适。

optimizer = optim.Adam(model.parameters(), lr=2e-2)
criterion = nn.CrossEntropyLoss()

def train(epochs, model, optimizer, criterion, train_iterator, valid_iterator):
    for epoch in range(1, epochs + 1):

        training_loss = 0.0
        valid_loss = 0.0
        model.train()
        for batch_idx, batch in enumerate(train_iterator):
            opt.zero_grad()
            predict = model(batch.tweet)
            loss = criterion(predict,batch.label)
            loss.backward()
            optimizer.step()
            training_loss += loss.data.item() * batch.tweet.size(0)
        training_loss /= len(train_iterator)

        model.eval()
        for batch_idx,batch in enumerate(valid_iterator):
            predict = model(batch.tweet)
            loss = criterion(predict,batch.label)
            valid_loss += loss.data.item() * x.size(0)

        valid_loss /= len(valid_iterator)
        print('Epoch: {}, Training Loss: {:.2f},
        Validation Loss: {:.2f}'.format(epoch, training_loss, valid_loss))

在这个新的训练循环中需要注意的主要事项是,我们必须引用batch.tweetbatch.label来获取我们感兴趣的特定字段;它们并不像在torchvision中那样从枚举器中很好地脱落出来。

一旦我们使用这个函数训练了我们的模型,我们就可以用它来对一些推文进行简单的情感分析。

分类推文

torchtext的另一个麻烦是它有点难以预测事物。你可以模拟内部发生的处理流程,并在该流程的输出上进行所需的预测,如这个小函数所示:

def classify_tweet(tweet):
    categories = {0: "Negative", 1:"Positive"}
    processed = TWEET.process([TWEET.preprocess(tweet)])
    return categories[model(processed).argmax().item()]

我们必须调用preprocess(),它执行基于 spaCy 的标记化。之后,我们可以调用process()将标记基于我们已构建的词汇表转换为张量。我们唯一需要注意的是torchtext期望一批字符串,因此在将其传递给处理函数之前,我们必须将其转换为列表的列表。然后我们将其馈送到模型中。这将产生一个如下所示的张量:

tensor([[ 0.7828, -0.0024]]

具有最高值的张量元素对应于模型选择的类别,因此我们使用argmax()来获取该索引,然后使用item()将零维张量转换为 Python 整数,然后将其索引到我们的categories字典中。

训练完我们的模型后,让我们看看如何执行你在第 2–4 章中学到的其他技巧和技术。

数据增强

你可能会想知道如何增强文本数据。毕竟,你不能像处理图像那样水平翻转文本!但你可以使用一些文本技术,为模型提供更多训练信息。首先,你可以用同义词替换句子中的单词,如下所示:

The cat sat on the mat

可以变成

The cat sat on the rug

除了猫坚持认为地毯比垫子更柔软之外,句子的含义并没有改变。但是matrug将映射到词汇表中的不同索引,因此模型将学习到这两个句子映射到相同标签,并希望这两个单词之间存在联系,因为句子中的其他内容都是相同的。

2019 年初,论文“EDA:用于提高文本分类任务性能的简单数据增强技术”提出了另外三种增强策略:随机插入、随机交换和随机删除。让我们看看每种方法。³

随机插入

随机插入技术查看一个句子,然后随机插入现有非停用词的同义词n次。假设你有一种获取单词同义词和消除停用词(常见单词如anditthe等)的方法,通过get_synonyms()get_stopwords()在这个函数中显示,但没有实现,这个实现如下:

def random_insertion(sentence,n):
    words = remove_stopwords(sentence)
    for _ in range(n):
        new_synonym = get_synonyms(random.choice(words))
        sentence.insert(randrange(len(sentence)+1), new_synonym)
    return sentence

在实践中,替换cat的示例可能如下所示:

The cat sat on the mat
The cat mat sat on feline the mat

随机删除

顾名思义,随机删除从句子中删除单词。给定概率参数p,它将遍历句子,并根据该随机概率决定是否删除单词:

def random_deletion(words, p=0.5):
    if len(words) == 1:
        return words
    remaining = list(filter(lambda x: random.uniform(0,1) > p,words))
    if len(remaining) == 0:
        return [random.choice(words)]
    else
        return remaining

该实现处理边缘情况——如果只有一个单词,该技术将返回它;如果我们最终删除了句子中的所有单词,该技术将从原始集合中随机抽取一个单词。

随机交换

随机交换增强接受一个句子,然后在其中* n *次交换单词,每次迭代都在先前交换的句子上进行。这里是一个实现:

def random_swap(sentence, n=5):
    length = range(len(sentence))
    for _ in range(n):
        idx1, idx2 = random.sample(length, 2)
        sentence[idx1], sentence[idx2] = sentence[idx2], sentence[idx1]
    return sentence

我们根据句子的长度随机抽取两个随机数,然后一直交换直到达到n

EDA 论文中的技术在使用少量标记示例(大约 500 个)时平均提高了约 3%的准确性。如果你的数据集中有 5000 个以上的示例,该论文建议这种改进可能会降至 0.8%或更低,因为模型从更多可用数据量中获得更好的泛化能力,而不是从 EDA 提供的改进中获得。

回译

另一种流行的增强数据集的方法是回译。这涉及将一个句子从我们的目标语言翻译成一个或多个其他语言,然后将它们全部翻译回原始语言。我们可以使用 Python 库googletrans来实现这个目的。在写作时,你可以使用pip安装它,因为它似乎不在conda中:

pip install googletrans

然后,我们可以将我们的句子从英语翻译成法语,然后再翻译回英语:

import googletrans
import googletrans.Translator

translator = Translator()

sentences = ['The cat sat on the mat']

translation_fr = translator.translate(sentences, dest='fr')
fr_text = [t.text for t in translations_fr]
translation_en = translator.translate(fr_text, dest='en')
en_text = [t.text for t in translation_en]
print(en_text)

>> ['The cat sat on the carpet']

这样我们就得到了一个从英语到法语再到英语的增强句子,但让我们再进一步,随机选择一种语言:

import random

available_langs = list(googletrans.LANGUAGES.keys())
tr_lang = random.choice(available_langs)
print(f"Translating to {googletrans.LANGUAGES[tr_lang]}")

translations = translator.translate(sentences, dest=tr_lang)
t_text = [t.text for t in translations]
print(t_text)

translations_en_random = translator.translate(t_text, src=tr_lang, dest='en')
en_text = [t.text for t in translations_en_random]
print(en_text)

在这种情况下,我们使用random.choice来选择一个随机语言,将句子翻译成该语言,然后再翻译回来。我们还将语言传递给src参数,以帮助谷歌翻译的语言检测。试一试,看看它有多像电话这个老游戏。

你需要了解一些限制。首先,一次只能翻译最多 15,000 个字符,尽管如果你只是翻译句子的话,这应该不会是太大的问题。其次,如果你要在一个大型数据集上使用这个方法,你应该在云实例上进行数据增强,而不是在家里的电脑上,因为如果谷歌封禁了你的 IP,你将无法正常使用谷歌翻译!确保你一次发送几批数据而不是整个数据集。这也应该允许你在谷歌翻译后端出现错误时重新启动翻译批次。

增强和 torchtext

到目前为止,你可能已经注意到我所说的关于增强的一切都没有涉及torchtext。遗憾的是,这是有原因的。与torchvisiontorchaudio不同,torchtext并没有提供转换管道,这有点让人恼火。它确实提供了一种执行预处理和后处理的方式,但这只在标记(单词)级别上操作,这对于同义词替换可能足够了,但对于像回译这样的操作并没有提供足够的控制。如果你尝试在增强中利用这些管道,你应该在预处理管道中而不是后处理管道中进行,因为在后处理管道中你只会看到由整数组成的张量,你需要通过词汇规则将其映射到单词。

出于这些原因,我建议不要浪费时间试图把torchtext搞得一团糟来进行数据增强。相反,使用诸如回译之类的技术在 PyTorch 之外进行增强,生成新数据并将其输入模型,就像它是真实数据一样。

增强已经讨论完毕,但在结束本章之前,我们应该解决一个悬而未决的问题。

迁移学习?

也许你会想知道为什么我们还没有谈论迁移学习。毕竟,这是一个关键技术,可以帮助我们创建准确的基于图像的模型,那么为什么我们不能在这里做呢?事实证明,在 LSTM 网络上实现迁移学习有点困难。但并非不可能。我们将在第九章中回到这个主题,你将看到如何在基于 LSTM 和 Transformer 的网络上实现迁移学习。

结论

在这一章中,我们涵盖了一个文本处理流程,涵盖了编码和嵌入,一个简单的基于 LSTM 的神经网络用于执行分类,以及一些针对基于文本数据的数据增强策略。到目前为止,您有很多可以尝试的内容。在标记化阶段,我选择将每条推文都转换为小写。这是自然语言处理中的一种流行方法,但它确实丢弃了推文中的潜在信息。想想看:“为什么这不起作用?”对我们来说甚至更暗示了负面情绪,而不是“为什么这不起作用?”但是在它进入模型之前,我们已经丢弃了这两条推文之间的差异。因此,一定要尝试在标记化文本中保留大小写敏感性。尝试从输入文本中删除停用词,看看是否有助于提高准确性。传统的自然语言处理方法非常强调删除停用词,但我经常发现当保留输入中的停用词时,深度学习技术可以表现得更好(这是我们在本章中所做的)。这是因为它们为模型提供了更多的上下文信息,而将句子简化为仅包含重要单词的情况可能会丢失文本中的细微差别。

您可能还想改变嵌入向量的大小。更大的向量意味着嵌入可以捕捉更多关于其建模的单词的信息,但会使用更多内存。尝试从 100 到 1,000 维的嵌入,并查看它如何影响训练时间和准确性。

最后,您也可以尝试使用 LSTM。我们使用了一种简单的方法,但您可以增加num_layers以创建堆叠的 LSTM,增加或减少层中隐藏特征的数量,或设置bidirectional=true以创建双向 LSTM。将整个 LSTM 替换为 GRU 层也是一个有趣的尝试;它训练速度更快吗?准确性更高吗?尝试实验并看看您会发现什么!

与此同时,我们将从文本转向音频领域,使用torchaudio

进一步阅读

¹ 请注意,使用 CNN 也可以做到这些事情;在过去几年中,已经进行了大量深入研究,以将基于 CNN 的网络应用于时间域。我们不会在这里涵盖它们,但“时间卷积网络:动作分割的统一方法” 作者:Colin Lea 等(2016 年)提供了更多信息。还有 seq2seq!

² 参见“在向量空间中高效估计单词表示” 作者:Tomas Mikolov 等(2013 年)

³ 参见“EDA:用于提升文本分类任务性能的简单数据增强技术” 作者:Jason W. Wei 和 Kai Zou(2019 年)