adv-nlp-tf2x-merge-2

65 阅读1小时+

Github DevOps 加速指南(三)

原文:annas-archive.org/md5/677f27c30764b3701bc2b6cf6de3a30e

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:多模态网络与图像描述生成,使用 ResNet 和 Transformer 网络

“一图胜千言”是一句著名的谚语。在本章中,我们将验证这句谚语,并为图像生成描述。在此过程中,我们将使用多模态网络。到目前为止,我们的输入是文本。人类可以将多种感官输入结合起来,理解周围的环境。我们可以带字幕观看视频并结合所提供的信息来理解场景。我们可以通过面部表情和唇部动作与声音一起理解语言。我们可以在图像中识别文本,并能回答有关图像的自然语言问题。换句话说,我们能够同时处理来自不同模态的信息,并将它们整合在一起理解我们周围的世界。人工智能和深度学习的未来在于构建多模态网络,因为它们能 closely 模拟人类的认知功能。

图像、语音和文本处理的最新进展为多模态网络奠定了坚实的基础。本章将引导你从自然语言处理(NLP)领域过渡到多模态学习领域,我们将使用熟悉的 Transformer 架构结合视觉和文本特征。

本章将涵盖以下主题:

  • 多模态深度学习概述

  • 视觉与语言任务

  • 图像描述任务和 MS-COCO 数据集的详细概述

  • 残差网络架构,特别是 ResNet

  • 使用预训练的 ResNet50 提取图像特征

  • 从零构建完整的 Transformer 模型

  • 提升图像描述生成性能的思路

我们的旅程从视觉理解领域的各种任务概述开始,重点介绍结合语言和图像的任务。

多模态深度学习

“模态”一词的词典定义是“某物存在、体验或表达的特定方式。”感官模态,如触觉、味觉、嗅觉、视觉和听觉,使人类能够体验周围的世界。假设你在农场采摘草莓,朋友告诉你挑选成熟且红的草莓。指令 成熟且红的草莓 会被处理并转化为视觉和触觉标准。当你看到草莓并触摸它们时,你会直觉地知道它们是否符合 成熟且红的 标准。这项任务就是多个模态协同工作来完成一个任务的例子。正如你能想象的,这些能力对机器人学至关重要。

作为前面示例的直接应用,考虑一个需要采摘成熟果实的收割机器人。1976 年 12 月,Harry McGurk 和 John MacDonald 在著名期刊《自然》上发表了一篇题为听嘴唇,看声音的研究论文(www.nature.com/articles/264746a0)。他们录制了一段年轻女性说话的视频,其中ba音节的发音被配上了ga音节的口型。当这个视频播放给成年人时,人们听到的音节是da。而当没有视频只播放音频时,正确的音节被报告了出来。这篇研究论文强调了视觉在语音识别中的作用。使用唇读信息的语音识别模型在视听语音识别AVSR)领域得到了开发。多模态深度学习模型在医疗设备和诊断、学习技术及其他人工智能AI)领域中有许多令人兴奋的应用。

让我们深入探讨视觉与语言的具体互动以及我们可以执行的各种任务。

视觉与语言任务

计算机视觉CV)和自然语言处理NLP)的结合使我们能够构建能够“看”和“说”的智能 AI 系统。CV 和 NLP 的结合为模型开发提供了有趣的任务。给定一张图像并为其生成描述是一个广为人知的任务。该任务的一个实际应用是为网页上的图像生成替代文本标签。视觉障碍读者使用屏幕阅读器来读取这些标签,从而在浏览网页时提高网页的可访问性。该领域的其他话题包括视频描述和讲故事——从一系列图像中编写故事。下图展示了图像和描述的一些示例。本章的主要关注点是图像描述:

一张包含照片、房间、大量物品的图片  描述自动生成

图 7.1:带有描述的示例图像

视觉问答VQA)是一个具有挑战性的任务,旨在回答关于图像中物体的问题。下图展示了来自 VQA 数据集的一些示例。与图像描述不同,图像描述会在描述中体现显著物体,而 VQA 是一个更为复杂的任务。回答问题可能还需要一定的推理。

请看下图右下方的面板。回答问题“这个人视力是 20/20 吗?”需要推理。VQA 的数据集可以在visualqa.org获取:

一人摆姿势拍照  描述自动生成

图 7.2:来自 VQA 数据集的示例(来源:VQA:视觉问答,Agrawal 等人)

推理引出了另一个具有挑战性但又令人着迷的任务——视觉常识推理VCR)。当我们查看一张图像时,我们可以猜测情绪、动作,并推测正在发生的事情。这个任务对人类来说相当简单,甚至可能不需要有意识地努力。VCR 任务的目标是构建能够执行此类任务的模型。这些模型还应能够解释或选择一个适当的理由,来说明已作出的逻辑推理。以下图像展示了 VCR 数据集中的一个示例。有关 VCR 数据集的更多细节,请访问 visualcommonsense.com

社交媒体帖子截图 说明自动生成

图 7.3:VCR 示例(来源:《从识别到认知:视觉常识推理》,Zellers 等人著)

到目前为止,我们已经从图像转向了文本。反过来也可以实现,并且是一个活跃的研究领域。在这个任务中,图像或视频是通过使用生成对抗网络(GANs)和其他生成架构从文本生成的。想象一下,能够根据故事的文本生成一本插画漫画书!这个特定任务目前处于研究的前沿。

该领域的一个关键概念是视觉基础。基础使得将语言中的概念与现实世界相连接。简单来说,就是将词语与图片中的物体相匹配。通过结合视觉和语言,我们可以将语言中的概念与图像中的部分进行对接。例如,将“篮球”这个词与图像中看起来像篮球的物体匹配,这就是视觉基础。也可以有更抽象的概念进行基础化。例如,一只矮小的大象和一个矮小的人具有不同的测量值。基础为我们提供了一种方式来查看模型正在学习的内容,并帮助我们引导它们朝着正确的方向前进。

现在我们已经对视觉和语言任务有了一个正确的视角,让我们深入探讨图像描述任务。

图像描述

图像描述就是用一句话描述图像的内容。描述有助于基于内容的图像检索和视觉搜索。我们已经讨论过,描述如何通过使屏幕阅读器更容易总结图像内容来提高网站的可访问性。描述可以视为图像的总结。一旦我们将问题框定为图像摘要问题,我们可以借用上一章中的 seq2seq 模型来解决这个问题。在文本摘要中,输入是长篇文章的序列,输出是总结内容的简短序列。在图像描述中,输出格式与摘要类似。然而,如何将由像素组成的图像结构化为一系列嵌入,以便输入到编码器中,这可能并不显而易见。

其次,摘要架构使用了双向长短期记忆网络BiLSTMs),其基本原理是相互之间靠得更近的单词在意义上也较为相似。BiLSTMs 通过从两侧查看输入序列并生成编码表示来利用这一特性。为图像生成适合编码器的表示需要一些思考。

一个表示图像为序列的简单解决方案是将其表示为像素列表。因此,一个 28x28 像素的图像就变成了 784 个标记的序列。当这些标记代表文本时,嵌入层学习每个标记的表示。如果这个嵌入层的维度为 64,那么每个标记将通过一个 64 维的向量来表示。这个嵌入向量是在训练过程中学习到的。继续延伸我们使用像素作为标记的类比,一种直接的解决方案是使用图像中每个像素的红/绿/蓝通道值来生成三维嵌入。然而,训练这三个维度似乎并不是一种合乎逻辑的方法。更重要的是,像素在 2D 表示中排布,而文本则是在 1D 表示中排布。这个概念在以下图像中得到了说明。单词与其旁边的单词相关。当像素以序列的形式排列时,这些像素的数据局部性被打破,因为像素的内容与其周围的所有像素相关,而不仅仅是与其左右相邻的像素相关。这个想法通过下面的图像展示出来:

图 7.4:文本与图像中的数据局部性

数据局部性和平移不变性是图像的两个关键特性。平移不变性是指一个物体可以出现在图像的不同位置。在一个全连接模型中,模型会试图学习物体的位置,这会阻止模型的泛化。卷积神经网络CNNs)的专门架构可以用来利用这些特性并从图像中提取信号。总的来说,我们使用 CNNs,特别是ResNet50架构,将图像转换为可以输入到 seq2seq 架构的张量。我们的模型将在 seq2seq 模型下结合 CNNs 和 RNNs 的优势来处理图像和文本部分。以下图示展示了我们架构的高层概述:

图 7.5:高级图像标注模型架构

虽然对 CNNs 的全面解释超出了本书的范围,但我们将简要回顾关键概念。由于我们将使用一个预训练的 CNN 模型,因此无需深入探讨 CNNs 的细节。Python 机器学习(第三版)(由 Packt 出版)是一本阅读 CNNs 的优秀资源。

在上一章的文本摘要中,我们构建了一个带有注意力机制的 seq2seq 模型。在这一章,我们将构建一个 Transformer 模型。Transformer 模型目前是自然语言处理领域的最前沿技术。Transformer 的编码器部分是双向编码器表示(Bidirectional Encoder Representations from Transformers)BERT)架构的核心。Transformer 的解码器部分是生成式预训练 TransformerGPT)系列架构的核心。Transformer 架构有一个在图像字幕生成问题中尤为重要的特定优势。在 seq2seq 架构中,我们使用了 BiLSTM,它尝试通过共现来学习关系。在 Transformer 架构中,没有递归。相反,使用位置编码和自注意力机制来建模输入之间的关系。这一变化使我们能够将处理后的图像补丁作为输入,并希望学习到图像补丁之间的关系。

实现图像字幕生成模型需要大量代码,因为我们将实现多个部分,例如使用 ResNet50 进行图像预处理,并从零开始完整实现 Transformer 架构。本章的代码量远远超过其他章节。我们将依赖代码片段来突出代码中的最重要部分,而不是像以前那样逐行详细讲解代码。

构建模型的主要步骤总结如下:

  1. 下载数据:由于数据集的庞大体积,这是一个耗时的活动。

  2. 预处理字幕:由于字幕是 JSON 格式的,它们被平坦化为 CSV 格式,以便更轻松地处理。

  3. 特征提取:我们通过 ResNet50 将图像文件传递来提取特征,并将其保存,以加速训练。

  4. Transformer 训练:一个完整的 Transformer 模型,包括位置编码、多头注意力机制、编码器和解码器,在处理后的数据上进行训练。

  5. 推理:使用训练好的模型为一些图像生成字幕!

  6. 评估性能:使用双语评估替代法Bilingual Evaluation Understudy,简称BLEU)分数来比较训练模型与真实数据。

让我们首先从数据集开始。

MS-COCO 数据集用于图像字幕生成

微软在 2014 年发布了上下文中的常见物体Common Objects in Context,简称COCO)数据集。所有版本的数据集可以在cocodataset.org上找到。COCO 数据集是一个大型数据集,广泛用于物体检测、分割和字幕生成等任务。我们的重点将放在 2014 年的训练和验证图像上,每个图像都有五个字幕。训练集大约有 83K 张图像,验证集有 41K 张图像。训练和验证图像及字幕需要从 COCO 网站下载。

大文件下载警告:训练集图像数据集大约为 13 GB,而验证集数据集超过 6 GB。图像文件的注释,包括标题,大小约为 214 MB。下载该数据集时,请小心你的网络带宽使用和潜在费用。

Google 还发布了一个新的 Conceptual Captions 数据集,地址为 ai.google.com/research/ConceptualCaptions。它包含超过 300 万张图像。拥有一个大型数据集可以让深度模型更好地训练。还有一个相应的比赛,你可以提交你的模型,看看它与其他模型的表现如何。

鉴于这些是大文件下载,你可能希望使用最适合你的下载方式。如果你的环境中有 wget,你可以使用它来下载文件,方法如下:

$ wget http://images.cocodataset.org/zips/train2014.zip
$ wget http://images.cocodataset.org/zips/val2014.zip
$ wget http://images.cocodataset.org/annotations/annotations_trainval2014.zip 

请注意,训练集和验证集的注释文件是一个压缩包。下载文件后,需要解压。每个压缩文件都会创建一个文件夹,并将内容放入其中。我们将创建一个名为 data 的文件夹,并将所有解压后的内容移动到其中:

$ mkdir data
$ mv train2014 data/
$ mv val2014 data/
$ mv annotations data/ 

所有图像都在 train2014val2014 文件夹中。数据的初步预处理代码位于 data-download-preprocess.py 文件中。训练和验证图像的标题可以在 annotations 子文件夹中的 captions_train2014.jsoncaptions_val2014.json JSON 文件中找到。这两个文件的格式相似。文件中有四个主要键——info、image、license 和 annotation。image 键包含每个图像的记录,以及关于图像的大小、URL、名称和用于引用该图像的唯一 ID。标题以图像 ID 和标题文本的元组形式存储,并带有一个用于标题的唯一 ID。我们使用 Python 的 json 模块来读取和处理这些文件:

valcaptions = json.load(open(
    './data/annotations/captions_val2014.json', 'r'))
trcaptions = json.load(open(
    './data/annotations/captions_train2014.json', 'r'))
# inspect the annotations
print(trcaptions.keys())
dict_keys(['info', 'images', 'licenses', 'annotations']) 

我们的目标是生成一个包含两列的简单文件——一列是图像文件名,另一列是该文件的标题。请注意,验证集包含的图像数量是训练集的一半。在一篇关于图像标题生成的开创性论文《深度视觉-语义对齐用于生成图像描述》中,Andrej Karpathy 和 Fei-Fei Li 提出了在保留 5,000 张验证集图像用于测试后,训练所有的训练集和验证集图像。我们将通过将图像名称和 ID 处理成字典来遵循这种方法:

prefix = "./data/"
val_prefix = prefix + 'val2014/'
train_prefix = prefix + 'train2014/'
# training images
trimages = {x['id']: x['file_name'] for x in trcaptions['images']}
# validation images
# take all images from validation except 5k - karpathy split
valset = len(valcaptions['images']) - 5000 # leave last 5k 
valimages = {x['id']: x['file_name'] for x in valcaptions['images'][:valset]}
truevalimg = {x['id']: x['file_name'] for x in valcaptions['images'][valset:]} 

由于每个图像都有五个标题,验证集不能根据标题进行拆分。否则,会出现训练集数据泄漏到验证集/测试集的情况。在前面的代码中,我们保留了最后 5K 张图像用于验证集。

现在,让我们查看训练集和验证集图像的标题,并创建一个合并的列表。我们将创建空列表来存储图像路径和标题的元组:

# we flatten to (caption, image_path) structure
data = list()
errors = list()
validation = list() 

接下来,我们将处理所有的训练标签:

for item in trcaptions['annotations']:
    if int(item['image_id']) in trimages:
        fpath = train_prefix + trimages[int(item['image_id'])]
        caption = item['caption']
        data.append((caption, fpath))
    else:
        errors.append(item) 

对于验证标签,逻辑类似,但我们需要确保不为已预留的图像添加标签:

for item in valcaptions['annotations']:
    caption = item['caption']
    if int(item['image_id']) in valimages:
        fpath = val_prefix + valimages[int(item['image_id'])]
        data.append((caption, fpath))
    elif int(item['image_id']) in truevalimg: # reserved
        fpath = val_prefix + truevalimg[int(item['image_id'])]
        validation.append((caption, fpath))
    else:
        errors.append(item) 

希望没有任何错误。如果遇到错误,可能是由于下载文件损坏或解压时出错。训练数据集会进行洗牌,以帮助训练。最后,会持久化保存两个 CSV 文件,分别包含训练数据和测试数据:

# persist for future use
with open(prefix + 'data.csv', 'w') as file:
    writer = csv.writer(file, quoting=csv.QUOTE_ALL)
    writer.writerows(data)
# persist for future use
with open(prefix + 'validation.csv', 'w') as file:
    writer = csv.writer(file, quoting=csv.QUOTE_ALL)
    writer.writerows(validation)
print("TRAINING: Total Number of Captions: {},  Total Number of Images: {}".format(
    len(data), len(trimages) + len(valimages)))
print("VALIDATION/TESTING: Total Number of Captions: {},  Total Number of Images: {}".format(
    len(validation), len(truevalimg)))
print("Errors: ", errors) 
TRAINING: Total Number of Captions: 591751,  Total Number of Images: 118287
VALIDATION/TESTING: Total Number of Captions: 25016,  Total Number of Images: 5000
Errors:  [] 

到此为止,数据下载和预处理阶段已经完成。下一步是使用 ResNet50 对所有图像进行预处理,以提取特征。在我们编写相关代码之前,我们将稍作绕行,了解一下 CNN 和 ResNet 架构。如果你已经熟悉 CNN,可以跳过这部分,直接进入代码部分。

使用 CNN 和 ResNet50 进行图像处理

在深度学习的世界里,已经开发出特定的架构来处理特定的模态。CNN 在处理图像方面取得了巨大的成功,并且是计算机视觉任务的标准架构。使用预训练模型提取图像特征的一个很好的思维模型是,将其类比为使用预训练的词向量(如 GloVe)来处理文本。在这个特定的案例中,我们使用一种叫做 ResNet50 的架构。虽然本书并不深入讲解 CNN 的所有细节,但这一节将简要概述 CNN 和 ResNet。如果你已经熟悉这些概念,可以跳到使用 ResNet50 进行图像特征提取这一部分。

CNN(卷积神经网络)

CNN(卷积神经网络)是一种旨在从以下关键特性中学习的架构,这些特性与图像识别相关:

  • 数据局部性:图像中的像素与周围像素高度相关。

  • 平移不变性:一个感兴趣的物体,例如鸟,可能出现在图像的不同位置。模型应该能够识别该物体,而不管它在图像中的位置如何。

  • 尺度不变性:感兴趣的物体可能会根据缩放显示为较小或较大的尺寸。理想情况下,模型应该能够识别图像中的物体,而不管它们的尺寸如何。

卷积层和池化层是帮助 CNN 从图像中提取特征的关键组件。

卷积操作

卷积是一种数学运算,它在从图像中提取的区域上执行,使用的是过滤器。过滤器是一个矩阵,通常是方形的,常见的尺寸为 3x3、5x5 和 7x7。下图展示了一个 3x3 卷积矩阵应用于 5x5 图像的例子。图像区域从左到右、从上到下提取。每次步进的像素数被称为步幅。在水平和垂直方向上,步幅为 1 时,会将一个 5x5 的图像缩减为 3x3 的图像,如下所示:

一张绿色屏幕的特写图像 自动生成的描述

图 7.6:卷积操作示例

这里应用的特定滤波器是边缘检测滤波器。在 CNN 出现之前,计算机视觉(CV)在很大程度上依赖于手工制作的滤波器。Sobel 滤波器是用于边缘检测的特殊滤波器之一。convolution-example.ipynb笔记本提供了使用 Sobel 滤波器检测边缘的示例。代码非常简单。在导入模块后,图像文件被加载并转换为灰度图像:

tulip = Image.open("chap7-tulip.jpg") 
# convert to gray scale image
tulip_grey = tulip.convert('L')
tulip_ar = np.array(tulip_grey) 

接下来,我们定义并将 Sobel 滤波器应用到图像中:

# Sobel Filter
kernel_1 = np.array([[1, 0, -1],
                     [2, 0, -2],
                     [1, 0, -1]])        # Vertical edge 
kernel_2 = np.array([[1, 2, 1],
                     [0, 0, 0],
                     [-1, -2, -1]])      # Horizontal edge 
out1 = convolve2d(tulip_ar, kernel_1)    # vertical filter
out2 = convolve2d(tulip_ar, kernel_2)    # horizontal filter
# Create a composite image from the two edge detectors
out3 = np.sqrt(out1**2 + out2**2) 

原始图像及其中间版本如下图所示:

计算机屏幕截图 描述自动生成

图 7.7:使用 Sobel 滤波器进行边缘检测

构建这样的滤波器是非常繁琐的。然而,CNN(卷积神经网络)可以通过将滤波器矩阵视为可学习的参数来学习许多这样的滤波器。CNN 通常会通过数百或数千个这样的滤波器(称为通道)处理一张图像,并将它们堆叠在一起。你可以将每个滤波器视为检测某些特征,如竖直线、水平线、弧形、圆形、梯形等。然而,当多个这样的层组合在一起时,魔法就发生了。堆叠多个层导致了分层表示的学习。理解这一概念的一个简单方法是,想象早期的层学习的是简单的形状,如线条和弧形;中间层学习的是圆形和六边形等形状;顶层学习的是复杂的物体,如停车标志和方向盘。卷积操作是关键创新,它利用数据的局部性并提取出特征,从而实现平移不变性。

这种分层的结果是模型中流动的数据量增加。池化是一种帮助减少通过数据的维度并进一步突出这些特征的操作。

池化

一旦卷积操作的值被计算出来,就可以对图像中的补丁应用池化操作,以进一步集中图像中的信号。最常见的池化形式称为最大池化,并在以下图示中展示。这就像是在一个补丁中取最大值一样简单。

以下图示显示了在不重叠的 2x2 补丁上进行最大池化:

色彩丰富的背景特写 描述自动生成

图 7.8:最大池化操作

另一种池化方法是通过对值进行平均。虽然池化降低了复杂性和计算负担,但它也在一定程度上帮助了尺度不变性。然而,这样的模型有可能会出现过拟合,并且无法很好地泛化。Dropout(丢弃法)是一种有助于正则化的技术,它使得此类模型能够更好地泛化。

使用 dropout 进行正则化

你可能还记得,在之前的章节中,我们在 LSTM 和 BiLSTM 设置中使用了 dropout 设置。dropout 的核心思想如下图所示:

Logo 的特写,描述自动生成

图 7.9:丢弃法

与其将低层的每个单元连接到模型中每个更高层的单元,不如在训练期间随机丢弃一些连接。输入仅在训练期间被丢弃。由于丢弃输入会减少到达节点的总输入,相对于测试/推理时间,需要按丢弃率上调输入,以确保相对大小保持一致。训练期间丢弃一些输入迫使模型从每个输入中学习更多内容,因为它不能依赖于特定输入的存在。这有助于网络对缺失输入的鲁棒性,从而帮助模型的泛化。

这些技术的结合帮助构建了越来越深的网络。随着网络变得越来越深,出现的一个挑战是输入信号在更高层变得非常小。残差连接是一种帮助解决这个问题的技术。

残差连接与 ResNet

直觉上,增加更多层应该能提高性能。更深的网络拥有更多的模型容量,因此它应该能够比浅层网络更好地建模复杂的分布。然而,随着更深的模型的建立,精度出现了下降。由于这种下降甚至出现在训练数据上,因此可以排除过拟合作为可能的原因。随着输入通过越来越多的层,优化器在调整梯度时变得越来越困难,以至于学习在模型中受到抑制。Kaiming He 及其合作者在他们的开创性论文《深度残差学习用于图像识别》中发布了 ResNet 架构。

在理解 ResNet 之前,我们必须理解残差连接。残差连接的核心概念如下图所示。在常规的密集层中,输入首先与权重相乘。然后,添加偏置,这是一个线性操作。输出通过激活函数(如 ReLU),这为层引入了非线性。激活函数的输出是该层的最终输出。

然而,残差连接在线性计算和激活函数之间引入了求和,如下图右侧所示:

手机屏幕截图,描述自动生成

图 7.10:一个概念性的残差连接

请注意,前面的图示仅用于说明残差连接背后的核心概念。在 ResNet 中,残差连接发生在多个模块之间。下图展示了 ResNet50 的基本构建模块,也称为瓶颈设计。之所以称之为瓶颈设计,是因为 1x1 卷积块在将输入传递到 3x3 卷积之前会减少输入的维度。最后一个 1x1 块再次将输入的维度扩大,以便传递到下一层:

键盘的特写  描述自动生成

图 7.11:ResNet50 瓶颈构建模块

ResNet50 由几个这样的模块堆叠而成,共有四个组,每组包含三个到七个模块。BatchNorm(批量归一化)是 Sergey Ioffe 和 Christian Szegedy 在 2015 年发表的论文 Batch Normalization: Accelerating Deep Network Training By Reducing Internal Covariate Shift 中提出的。批量归一化旨在减少从一层输出到下一层输入的输出方差。通过减少这种方差,BatchNorm 起到了类似 L2 正则化的作用,后者通过向损失函数中添加权重大小的惩罚来实现类似的效果。BatchNorm 的主要动机是有效地通过大量层反向传播梯度更新,同时最小化这种更新导致发散的风险。在随机梯度下降中,梯度用于同时更新所有层的权重,假设一层的输出不会影响其他任何层。然而,这并不是一个完全有效的假设。对于一个 n 层网络,计算这个将需要 n 阶梯度,这在计算上是不可行的。相反,使用了批量归一化,它一次处理一个小批量,并通过限制更新来减少权重分布中的这种不必要的变化。它通过在将输出传递到下一层之前进行归一化来实现这一点。

ResNet50 的最后两层是全连接层,将最后一个模块的输出分类为一个物体类别。全面了解 ResNet 是一项艰巨的任务,但希望通过本次关于卷积神经网络(CNN)和 ResNet 的速成课程,您已经获得了足够的背景知识来理解它们的工作原理。鼓励您阅读参考文献和 《深度学习与 TensorFlow 2 和 Keras 第二版》,该书由 Packt 出版,提供了更为详细的内容。幸运的是,TensorFlow 提供了一个预训练的 ResNet50 模型,可以直接使用。在接下来的部分中,我们将使用这个预训练的 ResNet50 模型提取图像特征。

使用 ResNet50 进行图像特征提取

ResNet50 模型是在 ImageNet 数据集上训练的。该数据集包含超过 20,000 个类别的数百万张图片。大规模视觉识别挑战赛 ILSVRC 主要聚焦于 1,000 个类别,模型们在这些类别上进行图像识别竞赛。因此,执行分类的 ResNet50 顶层具有 1,000 的维度。使用预训练的 ResNet50 模型的想法是,它已经能够解析出在图像描述中可能有用的对象。

tensorflow.keras.applications 包提供了像 ResNet50 这样的预训练模型。写本文时,所有提供的预训练模型都与计算机视觉(CV)相关。加载预训练模型非常简单。此部分的所有代码都位于本章 GitHub 文件夹中的 feature-extraction.py 文件中。使用单独文件的主要原因是,它让我们能够以脚本的方式运行特征提取。

考虑到我们将处理超过 100,000 张图片,这个过程可能需要一段时间。卷积神经网络(CNN)在计算上受益于 GPU。现在让我们进入代码部分。首先,我们必须设置从上一章的 JSON 注释中创建的 CSV 文件的路径:

prefix = './data/'
save_prefix = prefix + "features/"  # for storing prefixes
annot = prefix + 'data.csv'
# load the pre-processed file
inputs = pd.read_csv(annot, header=None, names=["caption", "image"]) 

ResNet50 期望每张图像为 224x224 像素,并具有三个颜色通道。来自 COCO 数据集的输入图像具有不同的尺寸。因此,我们必须将输入文件转换为 ResNet 训练时使用的标准:

# We are going to use the last residual block of # the ResNet50 architecture
# which has dimension 7x7x2048 and store into individual file
def load_image(image_path, size=(224, 224)):
    # pre-processes images for ResNet50 in batches 
    image = tf.io.read_file(image_path)
    image = tf.io.decode_jpeg(image, channels=3)
    image = tf.image.resize(image, size)
    image = **preprocess_input(image)**  # from keras.applications.ResNet50
    return image, image_path 

高亮显示的代码展示了 ResNet50 包提供的一个特殊预处理函数。输入图像中的像素通过 decode_jpeg() 函数加载到数组中。每个像素在每个颜色通道的值介于 0 和 255 之间。preprocess_input() 函数将像素值归一化,使其均值为 0。由于每张输入图像都有五个描述,我们只应处理数据集中独特的图像:

uniq_images = sorted(inputs['image'].unique())  
print("Unique images: ", len(uniq_images))  # 118,287 images 

接下来,我们必须将数据集转换为 tf.dat.Dataset,这样可以更方便地批量处理和使用之前定义的便捷函数处理输入文件:

image_dataset = tf.data.Dataset.from_tensor_slices(uniq_images)
image_dataset = image_dataset.map(
    load_image, num_parallel_calls=tf.data.experimental.AUTOTUNE).batch(16) 

为了高效地处理和生成特征,我们必须一次处理 16 张图片。下一步是加载一个预训练的 ResNet50 模型:

rs50 = tf.keras.applications.ResNet50(
    include_top=False,
    weights="imagenet", 
    input_shape=(224, 224, 3)
)
new_input = rs50.input
hidden_layer = rs50.layers[-1].output
features_extract = tf.keras.Model(new_input, hidden_layer)
features_extract.summary() 
__________________________________________________________________
Layer (type)                    Output Shape         Param #     Connected to
==================================================================
input_1 (InputLayer)            [(None, 224, 224, 3) 0
__________________________________________________________________
<CONV BLOCK 1>
__________________________________________________________________
<CONV BLOCK 2>
__________________________________________________________________
<CONV BLOCK 3>
__________________________________________________________________
<CONV BLOCK 4>
__________________________________________________________________
<CONV BLOCK 5>
==================================================================
Total params: 23,587,712
Trainable params: 23,534,592
Non-trainable params: 53,120
__________________________________________________________________ 

上述输出已简化以节省篇幅。该模型包含超过 2300 万个可训练参数。我们不需要顶层的分类层,因为我们使用该模型进行特征提取。我们定义了一个新的模型,包括输入和输出层。在这里,我们取了最后一层的输出。我们也可以通过改变 hidden_layer 变量的定义来从 ResNet 的不同部分获取输出。实际上,这个变量可以是一个层的列表,在这种情况下,features_extract 模型的输出将是列表中每一层的输出。

接下来,必须设置一个目录来存储提取的特征:

save_prefix = prefix + "features/"
try:
    # Create this directory 
    os.mkdir(save_prefix)
except FileExistsError:
    pass # Directory already exists 

特征提取模型可以处理图像批次并预测输出。每张图像的输出为 2,048 个 7x7 像素的补丁。如果输入的是 16 张图像的批次,那么模型的输出将是一个[16, 7, 7, 2048]维度的张量。我们将每张图像的特征存储为单独的文件,同时将维度展平为[49, 2048]。每张图像现在已经被转换成一个包含 49 个像素的序列,嵌入大小为 2,048。以下代码执行此操作:

for img, path in tqdm(image_dataset):
    batch_features = features_extract(img)
    batch_features = tf.reshape(batch_features,
                                (batch_features.shape[0], -1,                                  batch_features.shape[3]))
    for feat, p in zip(batch_features, path):
        filepath = p.numpy().decode("utf-8")
        filepath = save_prefix + filepath.split('/')[-1][:-3] + "npy"
        np.save(filepath, feat.numpy())
print("Images saved as npy files") 

这可能是一个耗时的操作,具体取决于你的计算环境。在我的 Ubuntu Linux 系统和 RTX 2070 GPU 的配置下,这个过程花费了大约 23 分钟。

数据预处理的最后一步是训练子词编码器。你应该对这部分非常熟悉,因为它与我们在前几章中做的完全相同:

# Now, read the labels and create a subword tokenizer with it
# ~8K vocab size
cap_tokenizer = tfds.features.text.SubwordTextEncoder.build_from_corpus(
    inputs['caption'].map(lambda x: x.lower().strip()).tolist(),
    target_vocab_size=2**13, reserved_tokens=['<s>', '</s>'])
cap_tokenizer.save_to_file("captions") 

请注意,我们包含了两个特殊的标记来表示序列的开始和结束。你可能会回忆起在第五章中,使用 RNN 和 GPT-2 生成文本时提到过这种技术。在这里,我们采用了稍微不同的方式来实现相同的技术,目的是展示如何用不同的方式实现相同的目标。

到此为止,预处理和特征提取已经完成。接下来的步骤是定义 Transformer 模型。然后,我们就可以开始训练模型了。

Transformer 模型

Transformer 模型在第四章中有讨论,使用 BERT 进行迁移学习。它的灵感来源于 seq2seq 模型,并包含了编码器(Encoder)和解码器(Decoder)部分。由于 Transformer 模型不依赖于 RNN,输入序列需要使用位置编码进行标注,这使得模型能够学习输入之间的关系。移除循环结构显著提高了模型的速度,并减少了内存占用。Transformer 模型的这一创新使得像 BERT 和 GPT-3 这样的大型模型成为可能。Transformer 模型的编码器部分已在前述章节中展示。完整的 Transformer 模型已在第五章使用 RNN 和 GPT-2 生成文本中展示。我们将从一个修改版的完整 Transformer 模型开始。具体来说,我们将修改 Transformer 的编码器部分,创建一个视觉编码器,该编码器以图像数据作为输入,而不是文本序列。为了适应图像作为输入,编码器需要做一些其他小的修改。我们将要构建的 Transformer 模型在下图中展示。这里的主要区别在于输入序列的编码方式。在文本的情况下,我们将使用子词编码器对文本进行分词,并将其通过一个可训练的嵌入层。

随着训练的进行,标记的嵌入(embeddings)也在不断学习。在图像描述的情况下,我们将图像预处理为一个由 49 个像素组成的序列,每个像素的“嵌入”大小为 2,048。这样实际上简化了输入的填充处理。所有图像都经过预处理,使其具有相同的长度。因此,不需要填充和掩码输入:

一张手机的截图,自动生成的描述

图 7.12:带有视觉编码器的 Transformer 模型

以下代码段需要实现才能构建 Transformer 模型:

  • 输入的位置信息编码,以及输入和输出的掩码。我们的输入是固定长度的,但输出和描述是可变长度的。

  • 缩放点积注意力和多头注意力,使编码器和解码器能够专注于数据的特定方面。

  • 一个由多个重复块组成的编码器。

  • 一个使用编码器输出的解码器,通过其重复块进行操作。

Transformer 的代码来自于 TensorFlow 教程,标题为 Transformer 模型用于语言理解。我们将使用这段代码作为基础,并将其适配于图像描述的应用场景。Transformer 架构的一个优点是,如果我们能将问题转化为序列到序列的问题,就可以应用 Transformer 模型。在描述实现过程时,代码的主要要点将被突出展示。请注意,本节的代码位于 visual_transformer.py 文件中。

实现完整的 Transformer 模型确实需要一些代码。如果你已经熟悉 Transformer 模型,或者只是想了解我们的模型与标准 Transformer 模型的不同之处,请专注于下一节和 VisualEncoder 部分。其余部分可以在你有空时阅读。

位置编码和掩码

Transformer 模型不使用 RNN。这使得它们能够在一步计算中得出所有输出,从而显著提高了速度,并且能够学习长输入之间的依赖关系。然而,这也意味着模型无法了解相邻单词或标记之间的关系。为了弥补缺乏关于标记顺序的信息,使用了位置编码向量,其中包含奇数和偶数位置的值,帮助模型学习输入位置之间的关系。

嵌入帮助将含义相似的标记放置在嵌入空间中彼此靠近的位置。位置编码根据标记在句子中的位置将标记相互靠近。将两者结合使用非常强大。

在图像描述中,这对描述是非常重要的。从技术上讲,对于图像输入,我们不需要提供这些位置编码,因为 ResNet50 应该已经生成了适当的补丁。然而,位置编码仍然可以用于输入。位置编码对于偶数位置使用sin函数,对于奇数位置使用cos函数。计算某个位置编码的公式如下:

这里,w[i]定义为:

在前面的公式中,pos表示给定标记的位置,d[model]表示嵌入的维度,i是正在计算的特定维度。位置编码过程为每个标记生成与嵌入相同维度的向量。你可能会想,为什么要使用这么复杂的公式来计算这些位置编码?难道只是从一端到另一端编号标记不就行了吗?事实证明,位置编码算法必须具备一些特性。首先,数值必须能够轻松地推广到长度可变的序列。使用简单的编号方案会导致输入的序列长度超过训练数据时无法处理。此外,输出应该对每个标记的位置是唯一的。而且,不同长度的输入序列中,任何两个位置之间的距离应该保持一致。这种公式相对简单易实现。相关的代码在文件中的位置编码器部分。

首先,我们必须计算角度,如前面的w[i]公式所示,如下所示:

def get_angles(pos, i, d_model):
    angle_rates = 1 / np.power(10000, (2 * (i // 2)) / np.float32(d_model))
    return pos * angle_rates 

然后,我们必须计算位置编码的向量:

def positional_encoding(position, d_model):
    angle_rads = get_angles(np.arange(position)[:, np.newaxis],
                            np.arange(d_model)[np.newaxis, :],
                            d_model)
    # apply sin to even indices in the array; 2i
    angle_rads[:, 0::2] = np.sin(angle_rads[:, 0::2])
    # apply cos to odd indices in the array; 2i+1
    angle_rads[:, 1::2] = np.cos(angle_rads[:, 1::2])
    pos_encoding = angle_rads[np.newaxis, ...]
    return tf.cast(pos_encoding, dtype=tf.float32) 

下一步是计算输入和输出的掩码。让我们暂时关注解码器。由于我们没有使用 RNN,整个输出一次性传入解码器。然而,我们不希望解码器看到未来时间步的数据。所以,输出必须被掩蔽。就编码器而言,如果输入被填充到固定长度,则需要掩码。然而,在我们的情况下,输入总是正好是 49 的长度。所以,掩码是一个固定的全 1 向量:

def create_padding_mask(seq):
    seq = tf.cast(tf.math.equal(seq, 0), tf.float32)
    # add extra dimensions to add the padding
    # to the attention logits.
    return seq[:, tf.newaxis, tf.newaxis, :]  
    # (batch_size, 1, 1, seq_len)
# while decoding, we dont have recurrence and dont want Decoder
# to see tokens from the future
def create_look_ahead_mask(size):
    mask = 1 - tf.linalg.band_part(tf.ones((size, size)), -1, 0)
    return mask  # (seq_len, seq_len) 

第一个方法用于掩盖填充的输入。这个方法是为了完整性而包括的,但稍后你会看到我们传递给它的是一个由 1 组成的序列。所以,这个方法的作用只是重新调整掩码。第二个掩码函数用于掩蔽解码器的输入,使其只能看到自己生成的位置。

转换器的编码器和解码器层使用特定形式的注意力机制。这是架构的基本构建块,接下来将进行实现。

缩放点积和多头注意力

注意力函数的目的是将查询与一组键值对进行匹配。输出是值的加权和,权重由查询和键之间的匹配度决定。多头注意力学习多种计算缩放点积注意力的方式,并将它们结合起来。

缩放点积注意力通过将查询向量与键向量相乘来计算。这个乘积通过查询和键的维度的平方根进行缩放。注意,这个公式假设键和查询向量具有相同的维度。实际上,查询、键和值向量的维度都设置为嵌入的大小。

在位置编码中,这被称为d[model]。在计算键和值向量的缩放点积之后,应用 softmax,softmax 的结果再与值向量相乘。使用掩码来遮盖查询和键的乘积。

def scaled_dot_product_attention(q, k, v, mask):
    # (..., seq_len_q, seq_len_k)
    matmul_qk = tf.matmul(q, k, transpose_b=True)
    # scale matmul_qk
    dk = tf.cast(tf.shape(k)[-1], tf.float32)
    scaled_attention_logits = matmul_qk / tf.math.sqrt(dk)
    # add the mask to the scaled tensor.
    if mask is not None:
        scaled_attention_logits += (mask * -1e9)
    # softmax is normalized on the last axis (seq_len_k)     # so that the scores
    # add up to 1.
    attention_weights = tf.nn.softmax(
                        scaled_attention_logits,
                        axis=-1)  # (..., seq_len_q, seq_len_k)
    output = tf.matmul(attention_weights, v)  
    # (..., seq_len_q, depth_v)
    return output, attention_weights 

多头注意力将来自多个缩放点积注意力单元的输出进行拼接,并通过一个线性层传递。嵌入输入的维度会被头数除以,用于计算键和值向量的维度。多头注意力实现为自定义层。首先,我们必须创建构造函数:

class MultiHeadAttention(tf.keras.layers.Layer):
    def __init__(self, d_model, num_heads):
        super(MultiHeadAttention, self).__init__()
        self.num_heads = num_heads
        self.d_model = d_model
        **assert** **d_model % self.num_heads ==** **0**
        self.depth = d_model // self.num_heads
        self.wq = tf.keras.layers.Dense(d_model)
        self.wk = tf.keras.layers.Dense(d_model)
        self.wv = tf.keras.layers.Dense(d_model)
        self.dense = tf.keras.layers.Dense(d_model) 

请注意,突出显示的assert语句。当实例化 Transformer 模型时,选择某些参数至关重要,以确保头数能够完全除尽模型大小或嵌入维度。此层的主要计算在call()函数中:

 def call(self, v, k, q, mask):
        batch_size = tf.shape(q)[0]
        q = self.wq(q)  # (batch_size, seq_len, d_model)
        k = self.wk(k)  # (batch_size, seq_len, d_model)
        v = self.wv(v)  # (batch_size, seq_len, d_model)
        # (batch_size, num_heads, seq_len_q, depth)
        **q = self.split_heads(q, batch_size)**
        # (batch_size, num_heads, seq_len_k, depth)
        **k = self.split_heads(k, batch_size)**
        # (batch_size, num_heads, seq_len_v, depth)
        **v = self.split_heads(v, batch_size)**
        # scaled_attention.shape == (batch_size, num_heads, seq_len_q, depth)
        # attention_weights.shape == (batch_size, num_heads, seq_len_q, seq_len_k)
        scaled_attention, attention_weights = scaled_dot_product_attention(q, k, v, mask)
        # (batch_size, seq_len_q, num_heads, depth)
        scaled_attention = tf.transpose(scaled_attention, 
                                                   perm=[0, 2, 1, 3])
        concat_attention = tf.reshape(scaled_attention,
                                        (batch_size, -1,
                              self.d_model))  
        # (batch_size, seq_len_q, d_model)
        # (batch_size, seq_len_q, d_model)
        output = self.dense(concat_attention)
        return output, attention_weights 

这三行突出显示了如何将向量分割成多个头。split_heads()定义如下:

 def split_heads(self, x, batch_size):
        """
        Split the last dimension into (num_heads, depth).
        Transpose the result such that the shape is (batch_size, num_heads, seq_len, depth)
        """
        x = tf.reshape(x, (batch_size, -1, 
self.num_heads, self.depth))
        return tf.transpose(x, perm=[0, 2, 1, 3]) 

这完成了多头注意力的实现。这是 Transformer 模型的关键部分。这里有一个关于 Dense 层的小细节,Dense 层用于聚合来自多头注意力的输出。它非常简单:

def point_wise_feed_forward_network(d_model, dff):
    return tf.keras.Sequential([
        # (batch_size, seq_len, dff)
        tf.keras.layers.Dense(dff, activation='relu'),
        tf.keras.layers.Dense(d_model)
        # (batch_size, seq_len, d_model)
    ]) 

到目前为止,我们已经查看了指定 Transformer 模型的以下参数:

  • d[model]用于嵌入的大小和输入的主要流动

  • d[ff]是前馈部分中中间 Dense 层输出的大小

  • h指定了多头注意力的头数

接下来,我们将实现一个视觉编码器,该编码器已经被修改为支持图像作为输入。

VisualEncoder

Transformer 模型部分中展示的图表显示了编码器的结构。编码器通过位置编码和掩码处理输入,然后将其通过多头注意力和前馈层的堆栈进行传递。这个实现偏离了 TensorFlow 教程,因为教程中的输入是文本。在我们的案例中,我们传递的是 49x2,048 的向量,这些向量是通过将图像传入 ResNet50 生成的。主要的区别在于如何处理输入。VisualEncoder被构建为一个层,以便最终组成 Transformer 模型:

class VisualEncoder(tf.keras.layers.Layer):
    def __init__(self, num_layers, d_model, num_heads, dff,
                 maximum_position_encoding=**49**, dropout_rate=0.1,
                 use_pe=True):
        # we have 7x7 images from ResNet50, 
        # and each pixel is an input token
        # which has been embedded into 2048 dimensions by ResNet
        super(VisualEncoder, self).__init__()
        self.d_model = d_model
        self.num_layers = num_layers

        **# FC layer replaces embedding layer in traditional encoder**
        **# this FC layers takes 49x2048 image** 
        **# and projects into model dims**
        **self.fc = tf.keras.layers.Dense(d_model, activation=****'relu'****)**
        self.pos_encoding = positional_encoding(
                                         maximum_position_encoding,
                                      self.d_model)
        self.enc_layers = [EncoderLayer(d_model, num_heads, 
                                        dff, dropout_rate)
                           for _ in range(num_layers)]
        self.dropout = tf.keras.layers.Dropout(dropout_rate)

        self.use_pe = use_pe 

构造函数如下所示。引入了一个新的参数,用于指示层数。原始论文使用了 6 层,512 作为d[模型],8 个多头注意力头,以及 2,048 作为中间前馈输出的大小。请注意前面代码中标记的行。预处理过的图像的尺寸可能会有所不同,具体取决于从 ResNet50 中提取输出的层。我们将输入通过一个稠密层fc,调整为模型所需的输入大小。这使得我们可以在不改变架构的情况下,尝试使用不同的模型来预处理图像,比如 VGG19 或 Inception。还要注意,最大位置编码被硬编码为 49,因为那是 ResNet50 模型输出的维度。最后,我们添加了一个标志位,可以在视觉编码器中开启或关闭位置编码。你应该尝试训练包含或不包含位置编码的模型,看看这是否有助于或阻碍学习。

VisualEncoder由多个多头注意力和前馈块组成。我们可以利用一个方便的类EncoderLayer来定义这样一个块。根据输入参数创建这些块的堆叠。我们稍后会检查EncoderLayer的内部实现。首先,让我们看看输入如何通过VisualEncoder传递。call()函数用于生成给定输入的输出:

 def call(self, x, training, mask):
        # all inp image sequences are always 49, so mask not needed
        seq_len = tf.shape(x)[1]
        # adding embedding and position encoding.
        # input size should be batch_size, 49, 2048)
        # output dims should be (batch_size, 49, d_model)
        x = self.fc(x)
        # scaled dot product attention
        x *= tf.math.sqrt(tf.cast(self.d_model, tf.float32)) 
        if self.use_pe:
            x += self.pos_encoding[:, :seq_len, :]
        x = self.dropout(x, training=training)
        for i in range(self.num_layers):
            x = self.enc_layersi  # mask shouldnt be needed
        return x  # (batch_size, 49, d_model) 

由于之前定义的抽象,这段代码相当简单。请注意使用训练标志来开启或关闭 dropout。现在,让我们看看EncoderLayer是如何定义的。每个 Encoder 构建体由两个子块组成。第一个子块将输入传递通过多头注意力,而第二个子块则将第一个子块的输出通过 2 层前馈层:

class EncoderLayer(tf.keras.layers.Layer):
    def __init__(self, d_model, num_heads, dff, rate=0.1):
        super(EncoderLayer, self).__init__()
        self.mha = MultiHeadAttention(d_model, num_heads)
        self.ffn = point_wise_feed_forward_network(d_model, dff)
        self.layernorm1 = tf.keras.layers.LayerNormalization(
                                                        epsilon=1e-6)
        self.layernorm2 = tf.keras.layers.LayerNormalization(
                                                        epsilon=1e-6)
        self.dropout1 = tf.keras.layers.Dropout(rate)
        self.dropout2 = tf.keras.layers.Dropout(rate)
    def call(self, x, training, mask):
        # (batch_size, input_seq_len, d_model)
        attn_output, _ = self.mha(x, x, x, mask)
        attn_output = self.dropout1(attn_output, 
                                      training=training)
        # (batch_size, input_seq_len, d_model)
        **out1 = self.layernorm1(x + attn_output)** **# Residual connection**

        # (batch_size, input_seq_len, d_model)
        ffn_output = self.ffn(out1)  
        ffn_output = self.dropout2(ffn_output, training=training)
        # (batch_size, input_seq_len, d_model)
        **out2 = self.layernorm2(out1 + ffn_output)** **# Residual conx**
        return out2 

每一层首先通过多头注意力计算输出,并将其传递通过 dropout。一个残差连接将输出和输入的和通过 LayerNorm。该块的第二部分将第一层 LayerNorm 的输出通过前馈层和另一个 dropout 层。

同样,一个残差连接将输出和输入合并,并传递给前馈部分,再通过 LayerNorm。请注意,dropout 和残差连接的使用,这些都是在 Transformer 架构中为计算机视觉(CV)任务开发的。

层归一化或 LayerNorm

LayerNorm 于 2016 年在一篇同名论文中提出,作为 RNN 的 BatchNorm 替代方案。如CNNs部分所述,BatchNorm 会在整个批次中对输出进行归一化。但是在 RNN 的情况下,序列长度是可变的。因此,需要一种不同的归一化公式来处理可变长度的序列。LayerNorm 会在给定层的所有隐藏单元之间进行归一化。它不依赖于批次大小,并且对给定层中的所有单元进行相同的归一化。LayerNorm 显著加快了训练和 seq2seq 模型的收敛速度。

有了VisualEncoder,我们就准备好实现解码器,然后将这一切组合成完整的 Transformer。

解码器

解码器也由块组成,和编码器一样。然而,解码器的每个块包含三个子模块,如Transformer 模型部分中的图所示。首先是掩蔽多头注意力子模块,接着是多头注意力块,最后是前馈子模块。前馈子模块与编码器子模块相同。我们必须定义一个可以堆叠的解码器层来构建解码器。其构造器如下所示:

class DecoderLayer(tf.keras.layers.Layer):
    def __init__(self, d_model, num_heads, dff, rate=0.1):
        super(DecoderLayer, self).__init__()
        self.mha1 = MultiHeadAttention(d_model, num_heads)
        self.mha2 = MultiHeadAttention(d_model, num_heads)
        self.ffn = point_wise_feed_forward_network(d_model, dff)
        self.layernorm1 = tf.keras.layers.LayerNormalization(
                                                       epsilon=1e-6)
        self.layernorm2 = tf.keras.layers.LayerNormalization(
                                                       epsilon=1e-6)
        self.layernorm3 = tf.keras.layers.LayerNormalization(
                                                       epsilon=1e-6)
        self.dropout1 = tf.keras.layers.Dropout(rate)
        self.dropout2 = tf.keras.layers.Dropout(rate)
        self.dropout3 = tf.keras.layers.Dropout(rate) 

基于前面的变量,三个子模块应该是相当明显的。输入通过这一层,并根据call()函数中的计算转换为输出:

 def call(self, x, enc_output, training,
             look_ahead_mask, padding_mask):
        # enc_output.shape == (batch_size, input_seq_len, d_model)
        **attn1, attn_weights_block1 = self.mha1(**
            **x, x, x, look_ahead_mask)**
        # args ^ => (batch_size, target_seq_len, d_model)
        attn1 = self.dropout1(attn1, training=training)
        out1 = self.layernorm1(attn1 + x) # residual
        attn2, attn_weights_block2 = self.mha2(
            enc_output, enc_output, out1, padding_mask)  
        # args ^ =>  (batch_size, target_seq_len, d_model)
        attn2 = self.dropout2(attn2, training=training)
        # (batch_size, target_seq_len, d_model)
        out2 = self.layernorm2(attn2 + out1)
        ffn_output = self.ffn(out2)  
        ffn_output = self.dropout3(ffn_output, training=training)
        # (batch_size, target_seq_len, d_model)
        out3 = self.layernorm3(ffn_output + out2)
        return out3, attn_weights_block1, attn_weights_block2 

第一个子模块,也称为掩蔽多头注意力模块,使用输出令牌,并对当前生成的位置进行掩蔽。在我们的例子中,输出是组成标题的令牌。前瞻掩蔽将未生成的令牌进行掩蔽。

注意,这个子模块不使用编码器的输出。它试图预测下一个令牌与之前生成的令牌之间的关系。第二个子模块使用编码器的输出以及前一个子模块的输出生成输出。最后,前馈网络通过对第二个子模块的输出进行处理来生成最终输出。两个多头注意力子模块都有自己的注意力权重。

我们将解码器定义为一个由多个DecoderLayer块组成的自定义层。Transformer 的结构是对称的。编码器和解码器的块数是相同的。构造器首先被定义:

class Decoder(tf.keras.layers.Layer):
    def __init__(self, num_layers, d_model, num_heads, 
                 dff, target_vocab_size,
                 maximum_position_encoding, rate=0.1):
        super(Decoder, self).__init__()
        self.d_model = d_model
        self.num_layers = num_layers
        self.embedding = tf.keras.layers.Embedding(
                                        target_vocab_size, d_model)
        self.pos_encoding = positional_encoding(
                                maximum_position_encoding, 
                                  d_model)
        self.dec_layers = [DecoderLayer(d_model, num_heads, 
                                           dff, rate)
                           for _ in range(num_layers)]
        self.dropout = tf.keras.layers.Dropout(rate) 

解码器的输出是通过call()函数计算的:

 def call(self, x, enc_output, training,
             look_ahead_mask, padding_mask):
        seq_len = tf.shape(x)[1]
        attention_weights = {}
        x = self.embedding(x)  
        x *= tf.math.sqrt(tf.cast(self.d_model, tf.float32))
        x += self.pos_encoding[:, :seq_len, :]
        x = self.dropout(x, training=training)
        for i in range(self.num_layers):
            x, block1, block2 = self.dec_layersi
        attention_weights['decoder_layer{}_block1'.format(i + 1)]  = block1
        attention_weights['decoder_layer{}_block2'.format(i + 1)] = block2
        # x.shape == (batch_size, target_seq_len, d_model)
        return x, attention_weights 

哇,这真是一大段代码。Transformer 模型的结构如此优雅。该模型的美丽之处在于,允许我们堆叠更多的编码器和解码器层,创建更强大的模型,正如最近的 GPT-3 所展示的那样。让我们将编码器和解码器结合起来,构建一个完整的 Transformer。

Transformer

Transformer 由编码器、解码器和最终的密集层组成,用于生成跨子词词汇的输出令牌分布:

class Transformer(tf.keras.Model):
    def __init__(self, num_layers, d_model, num_heads, dff,
                 target_vocab_size, pe_input, pe_target, rate=0.1,
                 use_pe=True):
        super(Transformer, self).__init__()
        self.encoder = VisualEncoder(num_layers, d_model, 
                                     num_heads, dff,
                                     pe_input, rate, use_pe)
        self.decoder = Decoder(num_layers, d_model, num_heads, 
                       dff, target_vocab_size, pe_target, rate)
        self.final_layer = tf.keras.layers.Dense(
                                       target_vocab_size)
    def call(self, inp, tar, training, enc_padding_mask,
             look_ahead_mask, dec_padding_mask):
        # (batch_size, inp_seq_len, d_model)
        enc_output = self.encoder(inp, training, enc_padding_mask)
        # dec_output.shape == (batch_size, tar_seq_len, d_model)
        dec_output, attention_weights = self.decoder(
                                tar, enc_output, training, 
                                look_ahead_mask, dec_padding_mask)
        # (batch_size, tar_seq_len, target_vocab_size)
        final_output = self.final_layer(dec_output)
        return final_output, attention_weights 

这就是完整 Transformer 代码的快速概览。理想情况下,TensorFlow 中的 Keras 将提供一个更高层次的 API 来定义 Transformer 模型,而不需要你编写代码。如果这对你来说有点复杂,那就专注于掩码和 VisualEncoder,因为它们是与标准 Transformer 架构的唯一偏离之处。

我们现在准备开始训练模型。我们将采用与上一章相似的方法,通过设置学习率衰减和检查点进行训练。

使用 VisualEncoder 训练 Transformer 模型

训练 Transformer 模型可能需要几个小时,因为我们希望训练大约 20 个 epoch。最好将训练代码放到一个文件中,这样可以从命令行运行。请注意,即使训练只进行 4 个 epoch,模型也能显示一些结果。训练代码位于caption-training.py文件中。总体来说,在开始训练之前需要执行以下步骤。首先,加载包含标题和图像名称的 CSV 文件,并附加包含提取图像特征文件的相应路径。还需要加载 Subword Encoder。创建一个tf.data.Dataset,包含编码后的标题和图像特征,便于批处理并将它们输入到模型中进行训练。为训练创建一个损失函数、一个带有学习率计划的优化器。使用自定义训练循环来训练 Transformer 模型。让我们详细了解这些步骤。

加载训练数据

以下代码加载我们在预处理步骤中生成的 CSV 文件:

prefix = './data/'
save_prefix = prefix + "features/"  # for storing prefixes
annot = prefix + 'data.csv'
inputs = pd.read_csv(annot, header=None, 
                      names=["caption", "image"])
print("Data file loaded") 

数据中的标题使用我们之前生成并保存在磁盘上的 Subword Encoder 进行分词:

cap_tokenizer = \
          tfds.features.text.SubwordTextEncoder.load_from_file(
                                                    "captions")
print(cap_tokenizer.encode(
                  "A man riding a wave on top of a surfboard.".lower())
)
print("Tokenizer hydrated")
# Max length of captions split by spaces
lens = inputs['caption'].map(lambda x: len(x.split()))
# Max length of captions after tokenization
# tfds demonstrated in earlier chapters
# This is a quick way if data fits in memory
lens = inputs['caption'].map(
                lambda x: len(cap_tokenizer.encode(x.lower()))
)
# We will set this as the max length of captions
# which cover 99% of the captions without truncation
max_len = int(lens.quantile(0.99) + 1)  # for special tokens 

最大的标题长度是为了适应 99%的标题长度而生成的。所有的标题都会被截断或填充到这个最大长度:

start = '<s>'
end = '</s>'
inputs['tokenized'] = inputs['caption'].map(
    lambda x: start + x.lower().strip() + end)
def tokenize_pad(x):
    x = cap_tokenizer.encode(x)
    if len(x) < max_len:
        x = x + [0] * int(max_len - len(x))
    return x[:max_len]
inputs['tokens'] = inputs.tokenized.map(lambda x: tokenize_pad(x)) 

图像特征被保存在磁盘上。当训练开始时,这些特征需要从磁盘读取并与编码后的标题一起输入。然后,包含图像特征的文件名被添加到数据集中:

# now to compute a column with the new name of the saved 
# image feature file
inputs['img_features'] = inputs['image'].map(lambda x:
                                             save_prefix +
                                             x.split('/')[-1][:-3]
                                             + 'npy') 

创建一个tf.data.Dataset,并设置一个映射函数,在枚举批次时读取图像特征:

captions = inputs.tokens.tolist()
img_names = inputs.img_features.tolist()
# Load the numpy file with extracted ResNet50 feature
def load_image_feature(img_name, cap):
    img_tensor = np.load(img_name.decode('utf-8'))
    return img_tensor, cap
dataset = tf.data.Dataset.from_tensor_slices((img_train, 
                                              cap_train))
# Use map to load the numpy files in parallel
dataset = dataset.map(lambda item1, item2: tf.numpy_function(
    load_image_feature, [item1, item2], [tf.float32, tf.int32]),
    num_parallel_calls=tf.data.experimental.AUTOTUNE) 

现在数据集已经准备好,我们可以实例化 Transformer 模型了。

实例化 Transformer 模型

我们将实例化一个较小的模型,具体来说是层数、注意力头数、嵌入维度和前馈单元的数量:

# Small Model
num_layers = 4
d_model = 128
dff = d_model * 4
num_heads = 8 

为了比较,BERT 基础模型包含以下参数:

# BERT Base Model
# num_layers = 12
# d_model = 768
# dff = d_model * 4
# num_heads = 12 

这些设置在文件中可用,但被注释掉了。使用这些设置会减慢训练速度,并需要大量的 GPU 内存。还需要设置其他一些参数,并实例化 Transformer:

target_vocab_size = cap_tokenizer.vocab_size  
# already includes start/end tokens
dropout_rate = 0.1
EPOCHS = 20  # should see results in 4-10 epochs also
transformer = vt.Transformer(num_layers, d_model, num_heads, dff,
                             target_vocab_size,
                             pe_input=49,  # 7x7 pixels
                             pe_target=target_vocab_size,
                             rate=dropout_rate,
                             use_pe=False
                             ) 

这个模型包含超过 400 万个可训练参数。它比我们之前看到的模型要小:

Model: "transformer"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
visual_encoder (VisualEncode multiple                  1055360   
_________________________________________________________________
decoder (Decoder)            multiple                  2108544   
_________________________________________________________________
dense_65 (Dense)             multiple                  1058445   
=================================================================
Total params: 4,222,349
Trainable params: 4,222,349
Non-trainable params: 0
_________________________________________________________________ 

然而,由于输入维度尚未提供,因此模型摘要不可用。一旦我们通过模型运行一个训练示例,摘要将可用。

为训练模型创建一个自定义学习率调度。自定义学习率调度会随着模型准确性的提高而逐渐降低学习率,从而提高准确性。这个过程被称为学习率衰减或学习率退火,在第五章《使用 RNN 和 GPT-2 生成文本》中有详细讨论。

自定义学习率调度

这个学习率调度与Attention Is All You Need 论文中提出的完全相同:

class CustomSchedule(tf.keras.optimizers.schedules.LearningRateSchedule):
    def __init__(self, d_model, warmup_steps=4000):
        super(CustomSchedule, self).__init__()
        self.d_model = d_model
        self.d_model = tf.cast(self.d_model, tf.float32)
        self.warmup_steps = warmup_steps
    def __call__(self, step):
        arg1 = tf.math.rsqrt(step)
        arg2 = step * (self.warmup_steps ** -1.5)
        return tf.math.rsqrt(self.d_model) * \
                tf.math.minimum(arg1, arg2)
learning_rate = CustomSchedule(d_model)
optimizer = tf.keras.optimizers.Adam(learning_rate, 
                                     beta_1=0.9, beta_2=0.98,
                                     epsilon=1e-9) 

以下图表显示了学习计划:

一个人的特写 描述自动生成

图 7.13:自定义学习率调度

当训练开始时,由于损失较高,因此使用较高的学习率。随着模型不断学习,损失开始下降,这时需要使用较低的学习率。采用上述学习率调度可以显著加快训练和收敛。我们还需要一个损失函数来进行优化。

损失和指标

损失函数基于类别交叉熵。这是一个常见的损失函数,我们在前几章中也使用过。除了损失函数外,还定义了准确度指标,以跟踪模型在训练集上的表现:

loss_object = tf.keras.losses.SparseCategoricalCrossentropy(
                              from_logits=True, reduction='none')
def loss_function(real, pred):
    mask = tf.math.logical_not(tf.math.equal(real, 0))
    loss_ = loss_object(real, pred)
    mask = tf.cast(mask, dtype=loss_.dtype)
    loss_ *= mask
    return tf.reduce_sum(loss_) / tf.reduce_sum(mask)
train_loss = tf.keras.metrics.Mean(name='train_loss')
train_accuracy = tf.keras.metrics.SparseCategoricalAccuracy(
                        name='train_accuracy') 

这个公式在前几章中也有使用。我们已经差不多可以开始训练了。在进入自定义训练函数之前,我们还需要完成两个步骤。我们需要设置检查点,以便在发生故障时保存进度,同时还需要为 Encoder 和 Decoder 屏蔽输入。

检查点和屏蔽

我们需要指定一个检查点目录,以便 TensorFlow 保存进度。这里我们将使用 CheckpointManager,它会自动管理检查点并存储有限数量的检查点。一个检查点可能非常大。对于小模型,五个检查点大约占用 243 MB 的空间。更大的模型会占用更多的空间:

checkpoint_path = "./checkpoints/train-small-model-40ep"
ckpt = tf.train.Checkpoint(transformer=transformer,
                           optimizer=optimizer)
ckpt_manager = tf.train.CheckpointManager(ckpt, checkpoint_path, 
                           max_to_keep=5)
# if a checkpoint exists, restore the latest checkpoint.
if ckpt_manager.latest_checkpoint:
    ckpt.restore(ckpt_manager.latest_checkpoint)
    print ('Latest checkpoint restored!!') 

接下来,必须定义一个方法来为输入图像和字幕创建屏蔽:

def create_masks(inp, tar):
    # Encoder padding mask - This should just be 1's
    # input shape should be (batch_size, 49, 2048)
    inp_seq = tf.ones([inp.shape[0], inp.shape[1]])  
    enc_padding_mask = vt.create_padding_mask(inp_seq)
    # Used in the 2nd attention block in the Decoder.
    # This padding mask is used to mask the encoder outputs.
    dec_padding_mask = vt.create_padding_mask(inp_seq)
    # Used in the 1st attention block in the Decoder.
    # It is used to pad and mask future tokens in the input 
    # received by the decoder.
    look_ahead_mask = vt.create_look_ahead_mask(tf.shape(tar)[1])
    dec_target_padding_mask = vt.create_padding_mask(tar)
    combined_mask = tf.maximum(dec_target_padding_mask, 
                                  look_ahead_mask)
    return enc_padding_mask, combined_mask, dec_padding_mask 

输入始终是固定长度,因此输入序列被设置为全 1。只有 Decoder 使用的字幕被屏蔽。Decoder 有两种类型的屏蔽。第一种是填充屏蔽。由于字幕被设置为最大长度,以处理 99%的字幕,大约是 22 个标记,任何少于这个标记数的字幕都会在末尾添加填充。填充屏蔽有助于将字幕标记与填充标记分开。第二种是前瞻屏蔽。它防止 Decoder 看到未来的标记或尚未生成的标记。现在,我们准备好开始训练模型。

自定义训练

与摘要模型类似,训练时将使用教师强制(teacher forcing)。因此,将使用一个定制的训练函数。首先,我们必须定义一个函数来对一批数据进行训练:

@tf.function
def train_step(inp, tar):
    tar_inp = tar[:, :-1]
    tar_real = tar[:, 1:]
    enc_padding_mask, combined_mask, dec_padding_mask = create_masks(inp, tar_inp)
    with tf.GradientTape() as tape:
        predictions, _ = transformer(inp, tar_inp,
                                     True,
                                     enc_padding_mask,
                                     combined_mask,
                                     dec_padding_mask)
        loss = loss_function(tar_real, predictions)
    gradients = tape.gradient(loss, 
                                transformer.trainable_variables)
    optimizer.apply_gradients(zip(gradients, 
                                   transformer.trainable_variables))
    train_loss(loss)
    train_accuracy(tar_real, predictions) 

该方法与摘要训练代码非常相似。现在我们需要做的就是定义训练的轮数(epochs)和批次大小(batch size),然后开始训练:

# setup training parameters
BUFFER_SIZE = 1000
BATCH_SIZE = 64  # can +/- depending on GPU capacity
# Shuffle and batch
dataset = dataset.shuffle(BUFFER_SIZE).batch(BATCH_SIZE)
dataset = dataset.prefetch(buffer_size=tf.data.experimental.AUTOTUNE)
# Begin Training
for epoch in range(EPOCHS):
    start_tm = time.time()
    train_loss.reset_states()
    train_accuracy.reset_states()
    # inp -> images, tar -> caption
    for (batch, (inp, tar)) in enumerate(dataset):
        train_step(inp, tar)
        if batch % 100 == 0:
            ts = datetime.datetime.now().strftime(
                                      "%d-%b-%Y (%H:%M:%S)")
            print('[{}] Epoch {} Batch {} Loss {:.6f} Accuracy'+\ 
                   '{:.6f}'.format(ts, epoch + 1, batch,
                                   train_loss.result(),
                                   train_accuracy.result()))
    if (epoch + 1) % 2 == 0:
        ckpt_save_path = ckpt_manager.save()
        print('Saving checkpoint for epoch {} at {}'.format(
                               epoch + 1,
                               ckpt_save_path))
    print('Epoch {} Loss {:.6f} Accuracy {:.6f}'.format(epoch + 1,
                                       train_loss.result(),
                                       train_accuracy.result()))
    print('Time taken for 1 epoch: {} secs\n'.format(
                                     time.time() - start_tm)) 

训练可以从命令行启动:

(tf24nlp) $ python caption-training.py 

这次训练可能需要一些时间。一个训练周期在我启用 GPU 的机器上大约需要 11 分钟。如果与摘要模型对比,这个模型的训练速度非常快。与包含 1300 万个参数的摘要模型相比,它要小得多,训练也非常快。这个速度提升归功于缺乏递归结构。

最先进的摘要模型使用 Transformer 架构,并结合子词编码。考虑到你已经掌握了 Transformer 的所有构件,一个很好的练习来测试你的理解就是编辑 VisualEncoder 来处理文本,并将摘要模型重建为 Transformer。这样你就能体验到这些加速和精度的提升。

更长的训练时间能让模型学习得更好。然而,这个模型在仅经过 5-10 个训练周期后就能给出合理的结果。训练完成后,我们可以尝试将模型应用到一些图像上。

生成字幕

首先,你需要祝贺自己!你已经通过了一个快速实现 Transformer 的过程。我相信你一定注意到前几章中使用过的一些常见构建块。由于 Transformer 模型非常复杂,我们把它放到这一章,探讨像巴赫达努(Bahdanau)注意力机制、定制层、定制学习率计划、使用教师强制(teacher forcing)进行的定制训练以及检查点(checkpoint)等技术,这样我们就可以快速覆盖大量内容。在你尝试解决 NLP 问题时,应该把所有这些构建块视为你工具包中的重要组成部分。

不再多说,让我们尝试为一些图像生成字幕。我们将再次使用 Jupyter notebook 进行推理,以便快速尝试不同的图像。所有推理代码都在image-captioning-inference.ipynb文件中。

推理代码需要加载子词编码器,设置遮掩(masking),实例化一个 ResNet50 模型来从测试图像中提取特征,并一遍遍地生成字幕,直到序列结束或达到最大序列长度。让我们一一走过这些步骤。

一旦我们完成了适当的导入并可选地初始化了 GPU,就可以加载在数据预处理时保存的子词编码器(Subword Encoder):

cap_tokenizer = tfds.features.text.SubwordTextEncoder.load_from_file("captions") 

现在我们必须实例化 Transformer 模型。这是一个重要步骤,确保参数与检查点中的参数相同:

# Small Model
num_layers = 4
d_model = 128
dff = d_model * 4
num_heads = 8
target_vocab_size = cap_tokenizer.vocab_size  # already includes 
                                              # start/end tokens
dropout_rate = 0\. # immaterial during inference
transformer = vt.Transformer(num_layers, d_model, num_heads, dff,
                             target_vocab_size,
                             pe_input=49,  # 7x7 pixels
                             pe_target=target_vocab_size,
                             rate=dropout_rate
                             ) 

从检查点恢复模型时需要优化器,即使我们并没有训练模型。因此,我们将重用训练代码中的自定义调度器。由于该代码之前已经提供,这里省略了它。对于检查点,我使用了一个训练了 40 个周期的模型,但在编码器中没有使用位置编码:

checkpoint_path = "./checkpoints/train-small-model-nope-40ep"
ckpt = tf.train.Checkpoint(transformer=transformer,
                           optimizer=optimizer)
ckpt_manager = tf.train.CheckpointManager(ckpt, checkpoint_path, 
                                            max_to_keep=5)
# if a checkpoint exists, restore the latest checkpoint.
if ckpt_manager.latest_checkpoint:
    ckpt.restore(ckpt_manager.latest_checkpoint)
    print ('Latest checkpoint restored!!') 

最后,我们必须为生成的字幕设置遮罩功能。请注意,前瞻遮罩在推理过程中并没有真正的帮助,因为未来的标记尚未生成:

# Helper function for creating masks
def create_masks(inp, tar):
    # Encoder padding mask - This should just be 1's
    # input shape should be (batch_size, 49, 2048)
    inp_seq = tf.ones([inp.shape[0], inp.shape[1]])  
    enc_padding_mask = vt.create_padding_mask(inp_seq)
    # Used in the 2nd attention block in the Decoder.
    # This padding mask is used to mask the encoder outputs.
    dec_padding_mask = vt.create_padding_mask(inp_seq)
    # Used in the 1st attention block in the Decoder.
    # It is used to pad and mask future tokens in the input received by
    # the decoder.
    look_ahead_mask = vt.create_look_ahead_mask(tf.shape(tar)[1])
    dec_target_padding_mask = vt.create_padding_mask(tar)
    combined_mask = tf.maximum(dec_target_padding_mask, 
                                 look_ahead_mask)
    return enc_padding_mask, combined_mask, dec_padding_mask 

推理的主要代码在evaluate()函数中。此方法将 ResNet50 生成的图像特征作为输入,并用起始标记来初始化输出字幕序列。然后,它进入循环,每次生成一个标记,同时更新遮罩,直到遇到序列结束标记或达到字幕的最大长度:

def evaluate(inp_img, max_len=21):
    start_token = cap_tokenizer.encode("<s>")[0]
    end_token = cap_tokenizer.encode("</s>")[0]

    encoder_input = inp_img # batch of 1

    # start token for caption
    decoder_input = [start_token]
    output = tf.expand_dims(decoder_input, 0)
    for i in range(max_len):
        enc_padding_mask, combined_mask, dec_padding_mask = \
                create_masks(encoder_input, output)

        # predictions.shape == (batch_size, seq_len, vocab_size)
        predictions, attention_weights = transformer(
                                               encoder_input, 
                                               output,
                                               False,
                                               enc_padding_mask,
                                               combined_mask,
                                               dec_padding_mask)
        # select the last word from the seq_len dimension
        predictions = predictions[: ,-1:, :]  
        predicted_id = tf.cast(tf.argmax(predictions, axis=-1), 
                                  tf.int32)

        # return the result if predicted_id is equal to end token
        if predicted_id == end_token:
            return tf.squeeze(output, axis=0), attention_weights

        # concatenate the predicted_id to the output which is 
        # given to the decoder  as its input.
        output = tf.concat([output, predicted_id], axis=-1)
    return tf.squeeze(output, axis=0), attention_weights 

使用一个包装方法来调用评估方法并输出字幕:

def caption(image):
    end_token = cap_tokenizer.encode("</s>")[0]
    result, attention_weights = evaluate(image)

    predicted_sentence = cap_tokenizer.decode([i for i in result 
                                              if i > end_token])
    print('Predicted Caption: {}'.format(predicted_sentence)) 

现在唯一剩下的就是实例化一个 ResNet50 模型,以便从图像文件中动态提取特征:

rs50 = tf.keras.applications.ResNet50(
    include_top=False,
    weights="imagenet",  # no pooling
    input_shape=(224, 224, 3)
)
new_input = rs50.input
hidden_layer = rs50.layers[-1].output
features_extract = tf.keras.Model(new_input, hidden_layer) 

终于到了关键时刻!让我们在一张图像上尝试一下模型。我们将加载图像,对其进行 ResNet50 预处理,并从中提取特征:

# from keras
image = load_img("./beach-surf.jpg", target_size=(224, 224)) 
image = img_to_array(image)
image = np.expand_dims(image, axis=0)  # batch of one
image = preprocess_input(image)  # from resnet
eval_img = features_extract.predict(image)
caption(eval_img) 

以下是示例图像及其字幕:

一个人正在海洋中的冲浪板上骑着波浪  自动生成的描述

图 7.14:生成的字幕 - 一名男子在波浪上骑着冲浪板

这看起来是给定图像的一个精彩字幕!然而,模型的整体准确度只有 30%左右。模型还有很大的改进空间。下一节将讨论图像字幕生成的最先进技术,并提出一些你可以尝试和玩弄的更简单的思路。

请注意,你可能会看到略微不同的结果。这本书的审阅者在运行这段代码时得到的结果是一个穿黑衬衫的男人正在骑冲浪板。这是预期的结果,因为概率上的微小差异以及模型在损失曲面中停止训练的精确位置并不完全相同。我们在这里是操作于概率领域,因此可能会有一些微小的差异。你可能也在前几章的文本生成和摘要代码中经历过类似的差异。

以下图像展示了更多图像及其字幕的示例。该笔记本包含了几个不错的示例,也有一些糟糕的生成标签示例:

一张包含照片、不同、各种、群体的图片  自动生成的描述

图 7.15:图像及其生成的字幕示例

这些图像都不在训练集中。从上到下,字幕质量下降。我们的模型理解特写、蛋糕、人群、沙滩、街道和行李等等。然而,最后两个示例令人担忧。它们暗示模型中存在某种偏见。在这两张底部的图像中,模型误解了性别。

这些图像是有意选择的,展示了一位穿着商务套装的女性和打篮球的女性。在这两种情况下,模型在字幕中建议的都是男性。当模型尝试使用女子网球运动员的图像时,它猜对了性别,但在女子足球比赛的图像中却改变了性别。模型中的偏见在图像字幕等情况下是立即显现的。事实上,2019 年在发现它如何分类和标记图像中存在偏见后,ImageNet 数据库中已移除了超过 60 万张图像(bit.ly/3qk4FgN)。ResNet50 是在 ImageNet 上预训练的。然而,在其他模型中,偏见可能更难以检测。建立公平的深度学习模型和减少模型偏见是机器学习社区的活跃研究领域。

你可能已经注意到,我们跳过了在评估集和测试集上运行模型的步骤。这是为了简洁起见,也因为这些技术之前已经涵盖过了。

关于评估字幕质量的度量标准的简短说明。在前几章节中我们看到了 ROUGE 度量标准。在图像字幕中,ROUGE-L 仍然适用。您可以将字幕的心理模型视为图像的摘要,而不是文本摘要中段落的摘要。有多种表达摘要的方式,而 ROUGE-L 试图捕捉意图。还有两个常报告的度量标准:

  • BLEU:这代表双语评估助手,是机器翻译中最流行的度量标准。我们也可以把图像字幕问题视为机器翻译问题。它依赖于 n-gram 来计算预测文本与多个参考文本的重叠,并将结果合并为一个分数。

  • CIDEr:这代表基于一致性的图像描述评估,并在 2015 年的同名论文中提出。它试图处理自动评估的困难,当多个字幕可能都合理时,通过结合 TF-IDF 和 n-gram 来比较模型生成的字幕与多个人类注释者的字幕,并根据共识进行评分。

在本章结束之前,让我们花点时间讨论提升性能和最先进模型的方法。

提升性能和最先进模型

在讨论最新的模型之前,让我们先讨论一些你可以尝试的简单实验来提高性能。回想一下我们在编码器中对输入位置编码的讨论。添加或移除位置编码会有助于或妨碍性能。在上一章中,我们实现了用于生成摘要的束搜索算法。你可以调整束搜索代码,并在结果中看到束搜索的改善。另一个探索方向是 ResNet50。我们使用了一个预训练的网络,但没有进一步微调。也可以构建一个架构,其中 ResNet 是架构的一部分,而不是一个预处理步骤。图像文件被加载,并且特征从 ResNet50 中提取作为视觉编码器的一部分。ResNet50 的层可以从一开始就进行训练,或者仅在最后几次迭代中训练。这一想法在resnet-finetuning.py文件中实现,你可以尝试。另一种思路是使用与 ResNet50 不同的物体检测模型,或者使用来自不同层的输出。你可以尝试使用更复杂的 ResNet 版本,如 ResNet152,或使用来自 Facebook 的 Detectron 等其他物体检测模型。由于我们的代码非常模块化,因此使用不同的模型应该是非常简单的。

当你使用不同的模型来提取图像特征时,关键是确保张量维度能够在编码器中正确流动。解码器不应需要任何更改。根据模型的复杂性,你可以选择预处理并存储图像特征,或者实时计算它们。

回顾一下,我们直接使用了图像中的像素。这是基于最近在 CVPR 上发表的一篇名为Pixel-BERT的论文。大多数模型使用从图像中提取的区域提议,而不是直接使用像素。图像中的物体检测涉及在图像中的物体周围绘制边界。另一种执行相同任务的方法是将每个像素分类为物体或背景。这些区域提议可以以图像中的边界框形式存在。最先进的模型将边界框或区域提议作为输入。

图像描述的第二大提升来自于预训练。回想一下,BERT 和 GPT 都是在特定的预训练目标下进行预训练的。模型的区别在于,是否仅对编码器(Encoder)进行预训练,或者编码器和解码器(Decoder)都进行了预训练。一种常见的预训练目标是 BERT 的掩码语言模型(MLM)任务版本。回想一下,BERT 的输入结构为[CLS] I1 I2 … In [SEP] J1 J2 … Jk [SEP],其中输入序列中的部分标记被遮掩。这个过程被适用于图像描述,其中输入中的图像特征和描述标记被拼接在一起。描述标记被像 BERT 模型中的遮掩一样进行遮掩,预训练目标是让模型预测被遮掩的标记。预训练后,CLS 标记的输出可以用于分类,或者送入解码器生成描述。需要小心的是,不能在相同的数据集上进行预训练,例如评估时使用的数据集。设置的一个示例是使用 Visual Genome 和 Flickr30k 数据集进行预训练,并使用 COCO 进行微调。

图像描述是一个活跃的研究领域。关于多模态网络的研究才刚刚起步。现在,让我们回顾一下本章所学的内容。

总结

在深度学习的领域中,已经开发出特定的架构来处理特定的模态。卷积神经网络CNNs)在处理图像方面非常有效,是计算机视觉(CV)任务的标准架构。然而,研究领域正在向多模态网络的方向发展,这些网络能够处理多种类型的输入,如声音、图像、文本等,并且能够像人类一样进行认知。在回顾多模态网络后,我们将重点深入研究视觉和语言任务。这个领域中存在许多问题,包括图像描述、视觉问答、视觉-常识推理(VCR)和文本生成图像等。

基于我们在前几章中学习的 seq2seq 架构、自定义 TensorFlow 层和模型、自定义学习计划以及自定义训练循环,我们从零开始实现了一个 Transformer 模型。Transformer 模型是目前写作时的最先进技术。我们快速回顾了 CNN 的基本概念,以帮助理解图像相关部分。我们成功构建了一个模型,虽然它可能无法为一张图片生成千言万语,但它绝对能够生成一条人类可读的描述。该模型的表现仍需改进,我们讨论了若干可能性,以便进行改进,包括最新的技术。

很明显,当深度模型包含大量数据时,它们的表现非常出色。BERT 和 GPT 模型已经展示了在海量数据上进行预训练的价值。对于预训练或微调,获得高质量的标注数据仍然非常困难。在自然语言处理领域,我们有大量的文本数据,但标注数据却远远不够。下一章将重点介绍弱监督学习,构建能够为预训练甚至微调任务标注数据的分类模型。

第八章:使用 Snorkel 进行弱监督学习分类

像 BERT 和 GPT 这样的模型利用大量的未标注数据和无监督训练目标(例如 BERT 的掩码语言模型MLM)或 GPT 的下一个单词预测模型)来学习文本的基本结构。少量任务特定数据用于通过迁移学习微调预训练模型。这些模型通常非常庞大,拥有数亿个参数,需要庞大的数据集进行预训练,并且需要大量计算能力进行训练和预训练。需要注意的是,解决的关键问题是缺乏足够的训练数据。如果有足够的领域特定训练数据,BERT 类预训练模型的收益就不会那么大。在某些领域(如医学),任务特定数据中使用的词汇对于该领域是典型的。适量增加训练数据可以大大提高模型的质量。然而,手工标注数据是一项繁琐、资源密集且不可扩展的任务,尤其是对于深度学习所需的大量数据而言。

本章讨论了一种基于弱监督概念的替代方法。通过使用 Snorkel 库,我们在几小时内标注了数万个记录,并超越了在第三章中使用 BERT 开发的模型的准确性,命名实体识别(NER)与 BiLSTM、CRF 和维特比解码。本章内容包括:

  • 弱监督学习概述

  • 生成模型和判别模型之间差异的概述

  • 使用手工特征构建基准模型以标注数据

  • Snorkel 库基础

  • 使用 Snorkel 标签函数大规模增强训练数据

  • 使用噪声机器标注数据训练模型

理解弱监督学习的概念至关重要,因此我们先从这个概念开始。

弱监督学习

近年来,深度学习模型取得了令人难以置信的成果。深度学习架构消除了特征工程的需求,只要有足够的训练数据。然而,深度学习模型学习数据的基本结构需要大量数据。一方面,深度学习减少了手工特征制作所需的人工工作,但另一方面,它大大增加了特定任务所需的标注数据量。在大多数领域,收集大量高质量标注数据是一项昂贵且资源密集的任务。

这个问题可以通过多种方式解决。在前面的章节中,我们已经看到了使用迁移学习在大数据集上训练模型,然后再针对特定任务微调模型。图 8.1 展示了这种方法以及其他获取标签的方法:

A picture containing clock  Description automatically generated

图 8.1:获取更多标注数据的选项

手动标记数据是一种常见的方法。理想情况下,我们有足够的时间和金钱来雇佣**专业学科专家(SMEs)**手动标记每一条数据,这是不切实际的。考虑标记肿瘤检测数据集并雇佣肿瘤学家来进行标记任务。对于肿瘤学家来说,标记数据可能比治疗肿瘤患者的优先级低得多。在以前的公司,我们组织了披萨派对,我们会为人们提供午餐来标记数据。一个人可以在一个小时内标记大约 100 条记录。每月为 10 人提供午餐一年,结果是 12,000 条标记记录!这种方案对于模型的持续维护非常有用,我们会对模型无法处理或置信度较低的记录进行采样。因此,我们采用了主动学习,确定标记数据对分类器性能影响最大的记录。

另一个选择是雇佣标记人员,他们不是专家,但更为丰富且更便宜。这是亚马逊机械土耳其服务采取的方法。有大量公司提供标记服务。由于标记者不是专家,同一条记录可能会由多人标记,并且会使用类似多数表决的机制来决定记录的最终标签。通过一名标记者标记一条记录的收费可能会因为关联标签所需的步骤复杂性而从几分钱到几美元不等。这种过程的输出是一组具有高覆盖率的嘈杂标签,只要您的预算允许。我们仍需找出获取的标签质量,以确定这些标签如何在最终模型中使用。

弱监督试图以不同的方式解决问题。假如,使用启发式方法,一位**专业学科专家(SME)**可以在几秒钟内手工标记成千上万条记录?我们将使用 IMDb 电影评论数据集,并尝试预测评论的情感。我们在第四章中使用了 IMDb 数据集,BERT 迁移学习,我们探索了迁移学习。使用相同的示例展示一个替代的技术来展示迁移学习是合适的。

弱监督技术不必用作迁移学习的替代品。弱监督技术有助于创建更大的领域特定标记数据集。在没有迁移学习的情况下,一个更大的标记数据集即使来自弱监督的嘈杂标签,也会提高模型性能。然而,如果同时使用迁移学习和弱监督,模型性能的提升将更为显著。

一个简单的启发式函数示例用于将评论标记为具有积极情感的伪代码如下所示:

if movie.review has "amazing acting" in it:
then sentiment is positive 

虽然这对于我们的用例看起来是一个微不足道的例子,但你会惊讶于它的有效性。在更复杂的设置中,一位肿瘤学家可以提供一些这些启发式规则,并定义一些标注函数来标注一些记录。这些函数可能会相互冲突或重叠,类似于众包标签。获取标签的另一种方法是通过远程监督。可以使用外部知识库(如 Wikipedia)通过启发式方法为数据记录标注。在命名实体识别NER)的用例中,使用地名表来将实体匹配到已知实体列表,如第二章《使用 BiLSTM 理解自然语言中的情感》所讨论。在实体之间的关系抽取中,例如员工属于配偶是,可以从实体的 Wikipedia 页面中提取关系,进而标注数据记录。还有其他获取这些标签的方法,例如使用对生成数据的底层分布的深刻理解。

对于给定的数据集,标签可以来自多个来源。每个众包标签员都是一个来源。每个启发式函数(如上面展示的“惊人的演技”函数)也是一个来源。弱监督的核心问题是如何将这些多个来源结合起来,生成足够质量的标签供最终分类器使用。模型的关键点将在下一节描述。

本章中提到的领域特定模型被称为分类器,因为我们所取的示例是电影评论情感的二分类。然而,生成的标签可以用于多种领域特定的模型。

弱监督与标注函数的内部工作原理

少数启发式标注函数覆盖率低、准确度不完美,但仍能帮助提高判别模型准确度的想法听起来很棒。本节提供了一个高层次的概述,介绍了它是如何工作的,然后我们将在 IMDb 情感分析数据集上实践这一过程。

我们假设这是一个二分类问题进行说明,尽管该方案适用于任意数量的标签。二分类的标签集为{NEG, POS}。我们有一组未标记的数据点,X,包含m个样本。

请注意,我们无法访问这些数据点的实际标签,但我们用Y表示生成的标签。假设我们有n个标注函数LF[1]到LF[n],每个函数生成一个标签。然而,我们为弱监督添加了另一个标签——一个弃权标签。每个标注函数都有选择是否应用标签或放弃标注的能力。这是弱监督方法的一个关键方面。因此,标注函数生成的标签集扩展为{NEG, ABSTAIN, POS}。

在这种设置下,目标是训练一个生成模型,该模型建模两个方面:

  • 给定数据点的标注函数弃权的概率

  • 给定标注函数正确分配标签到数据点的概率

通过在所有数据点上应用所有标注函数,我们生成一个m × n的矩阵,表示数据点及其标签。由启发式标注函数LF[j]对数据点X[i]生成的标签可以表示为:

生成模型试图通过标注函数之间的共识与分歧来学习参数。

生成模型与判别模型

如果我们有一组数据X,以及与数据对应的标签Y,我们可以说,判别模型试图捕捉条件概率p(Y | X)。生成模型则捕捉联合概率 p(X, Y)。顾名思义,生成模型可以生成新的数据点。我们在第五章*中看到生成模型的例子,通过 RNN 和 GPT-2 生成文本,在那里我们生成了新闻头条。GANs生成对抗网络)和自编码器是著名的生成模型。判别模型则对给定数据集中的数据点进行标注。它通过在特征空间中划分一个平面,将数据点分成不同的类别。像 IMDb 情感评论预测模型这样的分类器通常是判别模型。

如可以想象,生成模型在学习数据的整个潜在结构方面面临着更具挑战性的任务。

生成模型的参数权重,w,可以通过以下方式估计:

注意,观察到的标签的对数边际似然性会因预测标签Y而因式分解。因此,该生成模型是以无监督方式工作的。一旦生成模型的参数被计算出来,我们就可以预测数据点的标签,表示为:

其中,Y[i]表示基于标注函数的标签,表示生成模型的预测标签。这些预测标签可以被传递给下游的判别模型进行分类。

这些概念在 Snorkel 库中得到了实现。Snorkel 库的作者是引入数据编程方法的关键贡献者,该方法在 2016 年神经信息处理系统会议(Neural Information Process Systems conference)上以同名论文形式发表。Snorkel 库在 2019 年由 Ratner 等人在题为Snorkel: rapid training data creation with weak supervision的论文中正式介绍。苹果和谷歌分别发布了使用 Snorkel 库的论文,分别是关于OvertonSnorkel Drybell的论文。这些论文可以提供关于使用弱监督创建训练数据的数学证明的深入讨论。

尽管底层原理可能很复杂,但在实践中,使用 Snorkel 进行数据标注并不难。让我们通过准备数据集开始吧。

使用弱监督标签来改进 IMDb 情感分析

对 IMDb 网站上的电影评论进行情感分析是分类类型自然语言处理NLP)模型的标准任务。我们在第四章中使用了这些数据来演示使用 GloVe 和 VERT 嵌入进行迁移学习。IMDb 数据集包含 25,000 个训练示例和 25,000 个测试示例。数据集还包括 50,000 条未标记的评论。在之前的尝试中,我们忽略了这些无监督的数据点。增加更多的训练数据将提高模型的准确性。然而,手动标注将是一个耗时且昂贵的过程。我们将使用 Snorkel 驱动的标注功能,看看是否能够提高测试集上的预测准确性。

预处理 IMDb 数据集

之前,我们使用了tensorflow_datasets包来下载和管理数据集。然而,为了实现标签功能的编写,我们需要对数据进行更低级别的访问。因此,第一步是从网络上下载数据集。

本章的代码分为两个文件。snorkel-labeling.ipynb文件包含用于下载数据和使用 Snorkel 生成标签的代码。第二个文件imdb-with-snorkel-labels.ipynb包含训练有无额外标注数据的模型的代码。如果运行代码,最好先运行snorkel-labeling.ipynb文件中的所有代码,以确保生成所有标注数据文件。

数据集包含在一个压缩档案中,可以通过如下方式下载并解压,如snorkel-labeling.ipynb所示:

(tf24nlp) $ wget https://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz
(tf24nlp) $ tar xvzf aclImdb_v1.tar.gz 

这将解压档案到aclImdb目录中。训练数据和无监督数据位于train/子目录中,测试数据位于test/子目录中。还有一些额外的文件,但它们可以忽略。下面的图 8.2展示了目录结构:

图 8.2:IMDb 数据的目录结构

评论以单独的文本文件形式存储在叶目录中。每个文件的命名格式为<review_id>_<rating>.txt。评论标识符从 0 到 24999 按顺序编号,用于训练和测试示例。对于无监督数据,最高评论编号为 49999\。

评分是一个介于 0 到 9 之间的数字,仅在测试和训练数据中有意义。这个数字反映了对某个评论给出的实际评分。pos/子目录中的所有评论情感为正面,neg/子目录中的评论情感为负面。评分为 0 到 4 被视为负面,而评分为 5 到 9(包括 9)则视为正面。在这个特定的示例中,我们不使用实际评分,只考虑整体情感。

我们将数据加载到pandas DataFrame 中,方便处理。定义了一个便捷函数,将子目录中的评论加载到 DataFrame 中:

def load_reviews(path, columns=["filename", 'review']):
    assert len(columns) == 2
    l = list()
    for filename in glob.glob(path):
        # print(filename)
        with open(filename, 'r') as f:
            review = f.read()
            l.append((filename, review))
    return pd.DataFrame(l, columns=columns) 

上述方法将数据加载到两列中——一列为文件名,另一列为文件的文本。使用这种方法,加载了无监督数据集:

unsup_df = load_reviews("./aclImdb/train/unsup/*.txt")
unsup_df.describe() 
filename 

|

review 

|

---------

|

count 

|

50000 

|

50000 

|

|

unique 

|

50000 

|

49507 

|

|

top 

|

./aclImdb/train/unsup/24211_0.txt 

|

Am not from America, I usually watch this show... 

|

|

freq 

|

1 

|

5 

|

训练和测试数据集使用略有不同的方法:

def load_labelled_data(path, neg='/neg/', 
                       pos='/pos/', shuffle=True):
    neg_df = load_reviews(path + neg + "*.txt")
    pos_df = load_reviews(path + pos + "*.txt")
    neg_df['sentiment'] = 0
    pos_df['sentiment'] = 1
    df = pd.concat([neg_df, pos_df], axis=0)
    if shuffle:
        df = df.sample(frac=1, random_state=42)
    return df 

该方法返回三列——文件名、评论文本和情感标签。如果情感为负,则情感标签为 0;如果情感为正,则情感标签为 1,这由评论所在的目录决定。

现在可以像这样加载训练数据集:

train_df = load_labelled_data("./aclImdb/train/")
train_df.head() 
filename 

|

review 

|

sentiment 

|

------------

|

6868 

|

./aclImdb/train//neg/6326_4.txt 

|

If you're in the mood for some dopey light ent... 

|

0 

|

|

11516 

|

./aclImdb/train//pos/11177_8.txt 

|

*****Spoilers herein*****<br /><br />What real... 

|

1 

|

|

9668 

|

./aclImdb/train//neg/2172_2.txt 

|

Bottom of the barrel, unimaginative, and pract... 

|

0 

|

|

1140 

|

./aclImdb/train//pos/2065_7.txt 

|

Fearful Symmetry is a pleasant episode with a ... 

|

1 

|

|

1518 

|

./aclImdb/train//pos/7147_10.txt 

|

I found the storyline in this movie to be very... 

|

1 

|

虽然我们没有使用原始分数进行情感分析,但这是一个很好的练习,你可以尝试预测分数而不是情感。为了帮助处理原始文件中的分数,可以使用以下代码,该代码从文件名中提取分数:

def fn_to_score(f):
    scr = f.split("/")[-1]  # get file name
    scr = scr.split(".")[0] # remove extension
    scr = int (scr.split("_")[-1]) #the score
    return scr
train_df['score'] = train_df.filename.apply(fn_to_score) 

这会向 DataFrame 添加一个新的分数列,可以作为起始点使用。

测试数据可以通过传递不同的起始数据目录,使用相同的便捷函数加载。

test_df = load_labelled_data("./aclImdb/test/") 

一旦评论加载完成,下一步是创建一个分词器。

学习子词分词器

可以使用tensorflow_datasets包学习子词分词器。请注意,我们希望在学习此分词器时传递所有训练数据和无监督评论。

text = unsup_df.review.to_list() + train_df.review.to_list() 

这一步创建了一个包含 75,000 项的列表。如果检查评论文本,可以发现评论中包含一些 HTML 标签,因为这些评论是从 IMDb 网站抓取的。我们使用 Beautiful Soup 包来清理这些标签。

txt = [ BeautifulSoup(x).text for x in text ] 

然后,我们学习了包含 8,266 个条目的词汇表。

encoder = tfds.features.text.SubwordTextEncoder.\
                build_from_corpus(txt, target_vocab_size=2**13)
encoder.save_to_file("imdb") 

该编码器已保存到磁盘。学习词汇表可能是一个耗时的任务,并且只需要做一次。将其保存到磁盘可以在随后的代码运行中节省时间。

提供了一个预训练的子词编码器。它可以在与本章对应的 GitHub 文件夹中找到,并命名为imdb.subwords,如果你想跳过这些步骤,可以直接使用它。

在我们使用 Snorkel 标注的数据构建模型之前,先定义一个基准模型,以便在添加弱监督标签前后,比较模型的性能。

一个 BiLSTM 基准模型

为了理解额外标注数据对模型性能的影响,我们需要一个比较的基准。因此,我们设定一个之前见过的 BiLSTM 模型作为基准。有几个数据处理步骤,比如分词、向量化和填充/截断数据的长度。由于这些代码在第 3 和第四章中已经出现过,因此为了完整性,在此处进行了重复,并附上简明的描述。

Snorkel 在训练数据大小是原始数据的 10 倍到 50 倍时效果最佳。IMDb 提供了 50,000 条未标注的示例。如果所有这些都被标注,那么训练数据将是原始数据的 3 倍,这不足以展示 Snorkel 的价值。因此,我们通过将训练数据限制为仅 2,000 条记录,模拟了大约 18 倍的比例。其余的训练记录被视为未标注数据,Snorkel 用来提供噪声标签。为了防止标签泄露,我们将训练数据进行拆分,并存储为两个独立的数据框。拆分的代码可以在 snorkel-labeling.ipynb 笔记本中找到。用于生成拆分的代码片段如下所示:

from sklearn.model_selection import train_test_split
# Randomly split training into 2k / 23k sets
train_2k, train_23k = train_test_split(train_df, test_size=23000, 
                                      random_state=42, 
                                      stratify=train_df.sentiment)
train_2k.to_pickle("train_2k.df") 

使用分层拆分方法以确保正负标签的样本数量相等。一个包含 2,000 条记录的数据框被保存,并用于训练基准模型。请注意,这可能看起来像是一个人为构造的示例,但请记住,文本数据的关键特点是数据量庞大;然而,标签则稀缺。通常,标注数据的主要障碍是所需的标注工作量。在我们了解如何标注大量数据之前,先完成基准模型的训练以作比较。

分词和向量化数据

我们对训练集中的所有评论进行分词,并将其截断/填充为最多 150 个词元。评论通过 Beautiful Soup 处理,去除任何 HTML 标记。所有与此部分相关的代码都可以在 imdb-with-snorkel-labels.ipynb 文件中的 Training Data Vectorization 部分找到。这里仅为简便起见展示了部分代码:

# we need a sample of 2000 reviews for training
num_recs = 2000
train_small = pd.read_pickle("train_2k.df")
# we dont need the snorkel column
train_small = train_small.drop(columns=['snorkel'])
# remove markup
cleaned_reviews = train_small.review.apply(lambda x: BeautifulSoup(x).text)
# convert pandas DF in to tf.Dataset
train = tf.data.Dataset.from_tensor_slices(
                             (cleaned_reviews.values,
                             train_small.sentiment.values)) 

分词和向量化通过辅助函数进行,并应用于整个数据集:

# transformation functions to be used with the dataset
from tensorflow.keras. pre-processing import sequence
def encode_pad_transform(sample):
    encoded = imdb_encoder.encode(sample.numpy())
    pad = sequence.pad_sequences([encoded], padding='post', maxlen=150)
    return np.array(pad[0], dtype=np.int64)  
def encode_tf_fn(sample, label):
    encoded = tf.py_function(encode_pad_transform, 
                                       inp=[sample], 
                                       Tout=(tf.int64))
    encoded.set_shape([None])
    label.set_shape([])
    return encoded, label
encoded_train = train.map(encode_tf_fn,
                 num_parallel_calls=tf.data.experimental.AUTOTUNE) 

测试数据也以类似的方式进行处理:

# remove markup
cleaned_reviews = test_df.review.apply(
lambda x: BeautifulSoup(x).text)
# convert pandas DF in to tf.Dataset
test = tf.data.Dataset.from_tensor_slices((cleaned_reviews.values, 
                                        test_df.sentiment.values))
encoded_test = test.map(encode_tf_fn,
                 num_parallel_calls=tf.data.experimental.AUTOTUNE) 

一旦数据准备好,下一步就是搭建模型。

使用 BiLSTM 模型进行训练

创建和训练基准模型的代码位于笔记本的 Baseline Model 部分。创建了一个适中的模型,重点展示了无监督标注带来的提升,而非模型复杂性。此外,较小的模型训练速度更快,且能进行更多迭代:

# Length of the vocabulary 
vocab_size = imdb_encoder.vocab_size 
# Number of RNN units
rnn_units = 64
# Embedding size
embedding_dim = 64
#batch size
BATCH_SIZE=100 

该模型使用一个小的 64 维嵌入和 RNN 单元。创建模型的函数如下:

from tensorflow.keras.layers import Embedding, LSTM, \
                                    Bidirectional, Dense,\
                                    Dropout

dropout=0.5
def build_model_bilstm(vocab_size, embedding_dim, rnn_units, batch_size, dropout=0.):
    model = tf.keras.Sequential([
        Embedding(vocab_size, embedding_dim, mask_zero=True,
                  batch_input_shape=[batch_size, None]),
        Bidirectional(LSTM(rnn_units, return_sequences=True)),
        Bidirectional(tf.keras.layers.LSTM(rnn_units)),
        Dense(rnn_units, activation='relu'),
        Dropout(dropout),
        Dense(1, activation='sigmoid')
      ])
    return model 

添加适量的 dropout,以使模型具有更好的泛化能力。该模型大约有 70 万个参数。

bilstm = build_model_bilstm(
  vocab_size = vocab_size,
  embedding_dim=embedding_dim,
  rnn_units=rnn_units,
  batch_size=BATCH_SIZE)
bilstm.summary() 
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embedding_4 (Embedding)      (100, None, 64)           529024    
_________________________________________________________________
bidirectional_8 (Bidirection (100, None, 128)          66048     
_________________________________________________________________
bidirectional_9 (Bidirection (100, 128)                98816     
_________________________________________________________________
dense_6 (Dense)              (100, 64)                 8256      
_________________________________________________________________
dropout_6 (Dropout)          (100, 64)                 0         
_________________________________________________________________
dense_7 (Dense)              (100, 1)                  65        
=================================================================
Total params: 702,209
Trainable params: 702,209
Non-trainable params: 0
_________________________________________________________________ 

该模型使用二元交叉熵损失函数和 ADAM 优化器进行编译,并跟踪准确率、精确度和召回率指标。该模型训练了 15 个周期,可以看出模型已达到饱和:

bilstm.compile(loss='binary_crossentropy', 
             optimizer='adam', 
             metrics=['accuracy', 'Precision', 'Recall'])
encoded_train_batched = encoded_train.shuffle(num_recs, seed=42).\
                                    batch(BATCH_SIZE)
bilstm.fit(encoded_train_batched, epochs=15) 
Train for 15 steps
Epoch 1/15
20/20 [==============================] - 16s 793ms/step - loss: 0.6943 - accuracy: 0.4795 - Precision: 0.4833 - Recall: 0.5940
…
Epoch 15/15
20/20 [==============================] - 4s 206ms/step - loss: 0.0044 - accuracy: 0.9995 - Precision: 0.9990 - Recall: 1.0000 

如我们所见,即使在使用 dropout 正则化后,模型仍然对小规模的训练集发生了过拟合。

Batch-and-Shuffle 或 Shuffle-and-Batch

请注意上面代码片段中的第二行代码,它对数据进行了打乱和批处理。数据首先被打乱,然后再进行批处理。在每个周期之间对数据进行打乱是一种正则化手段,有助于模型更好地学习。在 TensorFlow 中,打乱数据顺序是一个关键点。如果数据在打乱之前就被批处理,那么只有批次的顺序会在喂入模型时发生变化。然而,每个批次的组成在不同的周期中将保持不变。通过在批处理之前进行打乱,我们确保每个批次在每个周期中都不同。我们鼓励您在有和没有打乱数据的情况下进行训练。虽然打乱会稍微增加训练时间,但它会在测试集上带来更好的性能。

让我们看看这个模型在测试数据上的表现:

bilstm.evaluate(encoded_test.batch(BATCH_SIZE)) 
250/250 [==============================] - 33s 134ms/step - loss: 2.1440 - accuracy: 0.7591 - precision: 0.7455 - recall: 0.7866 

模型的准确率为 75.9%。模型的精度高于召回率。现在我们有了基准线,我们可以查看弱监督标注是否能帮助提升模型性能。这将是下一部分的重点。

使用 Snorkel 进行弱监督标注

IMDb 数据集包含 50,000 条未标记的评论。这是训练集大小的两倍,训练集有 25,000 条已标记的评论。如前一部分所述,我们从训练数据中预留了 23,000 条记录,除了用于弱监督标注的无监督集。Snorkel 中的标注是通过标注函数来完成的。每个标注函数可以返回可能的标签之一,或者选择不进行标注。由于这是一个二分类问题,因此定义了相应的常量。还展示了一个示例标注函数。此部分的所有代码可以在名为 snorkel-labeling.ipynb 的笔记本中找到:

POSITIVE = 1
NEGATIVE = 0
ABSTAIN = -1
from snorkel.labeling.lf import labeling_function
@labeling_function()
def time_waste(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "time waste"
    ex2 = "waste of time"
    if ex1 in x.review.lower() or ex2 in x.review.lower():
        return NEGATIVE
    return ABSTAIN 

标注函数由 Snorkel 提供的 labeling_function() 注解。请注意,需要安装 Snorkel 库。详细的安装说明可以在 GitHub 上本章的子目录中找到。简而言之,Snorkel 可以通过以下方式安装:

(tf24nlp) $ pip install snorkel==0.9.5 

您看到的任何警告都可以安全忽略,因为该库使用了不同版本的组件,如 TensorBoard。为了更加确定,您可以为 Snorkel 及其依赖项创建一个单独的 conda/虚拟环境。

本章的内容得以实现,离不开 Snorkel.ai 团队的支持。Snorkel.ai 的 Frederic Sala 和 Alexander Ratner 在提供指导和用于超参数调优的脚本方面发挥了重要作用,从而最大限度地发挥了 Snorkel 的效能。

回到标签函数,以上的函数期望来自 DataFrame 的一行数据。它期望该行数据包含“review”这一文本列。该函数尝试检查评论是否表示电影或节目是浪费时间。如果是,它返回负面标签;否则,它不会对该行数据进行标记。请注意,我们正在尝试使用这些标签函数在短时间内标记数千行数据。实现这一目标的最佳方法是打印一些随机的正面和负面评论样本,并使用文本中的某些词汇作为标签函数。这里的核心思想是创建多个函数,它们能对子集行数据有较好的准确性。让我们检查训练集中一些负面评论,看看可以创建哪些标签函数:

neg = train_df[train_df.sentiment==0].sample(n=5, random_state=42)
for x in neg.review.tolist():
    print(x) 

其中一条评论开头是“一个非常俗气且乏味的公路电影”,这为标签函数提供了一个想法:

@labeling_function()
def cheesy_dull(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "cheesy"
    ex2 = "dull"
    if ex1 in x.review.lower() or ex2 in x.review.lower():
        return NEGATIVE
    return ABSTAIN 

负面评论中出现了许多不同的词汇。这是负面标签函数的一个子集,完整列表请参见笔记本:

@labeling_function()
def garbage(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "garbage"
    if ex1 in x.review.lower():
        return NEGATIVE
    return ABSTAIN
@labeling_function()
def terrible(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "terrible"
    if ex1 in x.review.lower():
        return NEGATIVE
    return ABSTAIN
@labeling_function()
def unsatisfied(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "unsatisf"  # unsatisfactory, unsatisfied
    if ex1 in x.review.lower():
        return NEGATIVE
    return ABSTAIN 

所有负面标签函数都添加到一个列表中:

neg_lfs = [atrocious, terrible, piece_of, woefully_miscast, 
           bad_acting, cheesy_dull, disappoint, crap, garbage, 
           unsatisfied, ridiculous] 

检查负面评论的样本可以给我们很多想法。通常,领域专家的一点小小努力就能产生多个易于实现的标签函数。如果你曾经看过电影,那么对于这个数据集来说,你就是专家。检查正面评论的样本会导致更多的标签函数。以下是识别评论中正面情感的标签函数示例:

import re
@labeling_function()
def classic(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "a classic"
    if ex1 in x.review.lower():
        return POSITIVE
    return ABSTAIN
@labeling_function()
def great_direction(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "(great|awesome|amazing|fantastic|excellent) direction"
    if re.search(ex1, x.review.lower()):
        return POSITIVE
    return ABSTAIN
@labeling_function()
def great_story(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "(great|awesome|amazing|fantastic|excellent|dramatic) (script|story)"
    if re.search(ex1, x.review.lower()):
        return POSITIVE
    return ABSTAIN 

所有正面标签函数可以在笔记本中看到。与负面函数类似,定义了正面标签函数的列表:

pos_lfs = [classic, must_watch, oscar, love, great_entertainment,
           very_entertaining, amazing, brilliant, fantastic, 
           awesome, great_acting, great_direction, great_story,
           favourite]
# set of labeling functions
lfs = neg_lfs + pos_lfs 

标签的开发是一个迭代过程。不要被这里显示的标签函数数量吓到。你可以看到,它们大部分都非常简单。为了帮助你理解工作量,我花费了总共 3 小时来创建和测试标签函数:

请注意,笔记本中包含了大量简单的标签函数,这里仅展示了其中的一个子集。请参考实际代码获取所有标签函数。

该过程包括查看一些样本并创建标签函数,然后在数据的子集上评估结果。查看标签函数与标记示例不一致的示例,对于使函数更精确或添加补偿函数非常有用。那么,让我们看看如何评估这些函数,以便进行迭代。

对标签函数进行迭代

一旦定义了一组标注函数,它们就可以应用于 pandas DataFrame,并且可以训练一个模型来计算在计算标签时分配给各个标注函数的权重。Snorkel 提供了帮助执行这些任务的函数。首先,让我们应用这些标注函数来计算一个矩阵。这个矩阵的列数等于每一行数据的标注函数数量:

# let's take a sample of 100 records from training set
lf_train = train_df.sample(n=1000, random_state=42)
from snorkel.labeling.model import LabelModel
from snorkel.labeling import PandasLFApplier
# Apply the LFs to the unlabeled training data
applier = PandasLFApplier(lfs)
L_train = applier.apply(lf_train) 

在上述代码中,从训练数据中提取了 1000 行数据样本。然后,将之前创建的所有标注函数列表传递给 Snorkel,并应用于这个训练数据的样本。如果我们创建了 25 个标注函数,那么 L_train 的形状将会是 (1000, 25)。每一列代表一个标注函数的输出。现在可以在这个标签矩阵上训练一个生成模型:

# Train the label model and compute the training labels
label_model = LabelModel(cardinality=2, verbose=True)
label_model.fit(L_train, n_epochs=500, log_freq=50, seed=123)
lf_train["snorkel"] = label_model.predict(L=L_train, 
                                      **tie_break_policy=****"abstain"**) 

创建一个 LabelModel 实例,参数指定实际模型中有多少个标签。然后训练这个模型,并为数据子集预测标签。这些预测的标签将作为 DataFrame 的新列添加进去。请注意传递给 predict() 方法的 tie_break_policy 参数。如果模型在标注函数的输出中有冲突,并且这些冲突在模型中得分相同,该参数指定如何解决冲突。在这里,我们指示模型在发生冲突时放弃标记记录。另一个可能的设置是 "random",在这种情况下,模型将随机分配一个被绑定标注函数的输出。这两个选项在手头问题的背景下的主要区别是精确度。通过要求模型在标记时放弃,我们可以获得更高的精确度结果,但标记的记录会更少。随机选择一个被绑定函数的输出会导致更广泛的覆盖率,但质量可能较低。可以通过分别使用这两个选项的输出来训练同一个模型来测试这一假设。鼓励您尝试这些选项并查看结果。

由于选择了放弃策略,可能并没有对所有的 1000 行进行标注:

pred_lfs = lf_train[lf_train.snorkel > -1]
pred_lfs.describe() 
sentiment 

|

score 

|

snorkel 

|

------------

|

count 

|

598.000000 

|

598.000000 

|

598.000000 

|

在 1000 条记录中,只有 458 条被标记。让我们检查其中有多少条标记错误的。

pred_mistake = pred_lfs[pred_lfs.sentiment != pred_lfs.snorkel]
pred_mistake.describe() 
sentiment 

|

score 

|

snorkel 

|

------------

|

count 

|

164.000000 

|

164.000000 

|

164.000000 

|

使用 Snorkel 和我们的标注函数标注了 598 条记录,其中 434 条标签正确,164 条记录被错误标注。标签模型的准确率约为 72.6%。为了获得更多标注函数的灵感,你应该检查一些标签模型产生错误结果的行,并更新或添加标注函数。如前所述,约花了 3 个小时迭代并创建了 25 个标注函数。为了从 Snorkel 中获得更多效果,我们需要增加训练数据量。目标是开发一种方法,快速获得大量标签,而不需要太多人工干预。在这个特定案例中,可以使用的一种技术是训练一个简单的朴素贝叶斯模型,获取与正面或负面标签高度相关的词汇。这是下一节的重点。朴素贝叶斯NB)是许多基础 NLP 书籍中涵盖的一种基本技术。

用于寻找关键词的朴素贝叶斯模型

在这个数据集上构建 NB 模型需要不到一个小时,并且有潜力显著提高标注函数的质量和覆盖面。NB 模型的核心代码可以在spam-inspired-technique-naive-bayes.ipynb笔记本中找到。请注意,这些探索与主要的标注代码无关,如果需要,可以跳过这一部分,因为这一部分的学习成果会应用于构建更好的标注函数,这些函数在snorkel-labeling.ipynb笔记本中有详细介绍。

基于 NB 的探索的主要流程是加载评论、去除停用词、选择前 2000 个词来构建一个简单的向量化方案,并训练一个 NB 模型。由于数据加载与前面章节讲解的相同,因此本节省略了细节。

本节使用了 NLTK 和wordcloud Python 包。由于我们在第一章《NLP 基础》中已经使用过 NLTK,它应该已经安装。wordcloud可以通过以下命令安装:

(tf24nlp) $ pip install wordcloud==1.8

词云有助于整体理解正面和负面评论的文本。请注意,前 2000 个词的向量化方案需要计数器。定义了一个方便的函数,它清理 HTML 文本,并删除停用词,将其余部分进行分词,代码如下:

en_stopw = set(stopwords.words("english"))
def get_words(review, words, stopw=en_stopw):
    review = BeautifulSoup(review).text        # remove HTML tags
    review = re.sub('[^A-Za-z]', ' ', review)  # remove non letters
    review = review.lower()
    tok_rev = wt(review)
    rev_word = [word for word in tok_rev if word not in stopw]
    words += rev_word 

然后,正面评价被分离出来,并生成一个词云以便于可视化:

pos_rev = train_df[train_df.sentiment == 1]
pos_words = []
pos_rev.review.apply(get_words, args=(pos_words,))
from wordcloud import WordCloud
import matplotlib.pyplot as plt
pos_words_sen = " ".join(pos_words)
pos_wc = WordCloud(width = 600,height = 512).generate(pos_words_sen)
plt.figure(figsize = (12, 8), facecolor = 'k')
plt.imshow(pos_wc)
plt.axis('off')
plt.tight_layout(pad = 0)
plt.show() 

上述代码的输出如图 8.3所示:

A close up of a sign  Description automatically generated

图 8.3:正面评价词云

不足为奇的是,moviefilm 是最大的词。然而,在这里可以看到许多其他建议的关键词。类似地,也可以生成负面评价的词云,如图 8.4所示:

A close up of a sign  Description automatically generated

图 8.4:负面评价词云

这些可视化结果很有趣;然而,训练模型后会得到更清晰的画面。只需要前 2000 个单词就可以训练模型:

from collections import Counter
pos = Counter(pos_words)
neg = Counter(neg_words)
# let's try to build a naive bayes model for sentiment classification
tot_words = pos + neg
tot_words.most_common(10) 
[('movie', 44031),
 ('film', 40147),
 ('one', 26788),
 ('like', 20274),
 ('good', 15140),
 ('time', 12724),
 ('even', 12646),
 ('would', 12436),
 ('story', 11983),
 ('really', 11736)] 

合并计数器显示了所有评论中最常出现的前 10 个单词。这些被提取到一个列表中:

top2k = [x for (x, y) in tot_words.most_common(2000)] 

每个评论的向量化相当简单——每个 2000 个单词中的一个都会成为给定评论的一个列。如果该列所表示的单词出现在评论中,则该列的值标记为 1,否则为 0。所以,每个评论由一个由 0 和 1 组成的序列表示,表示该评论包含了哪些前 2000 个单词。下面的代码展示了这一转换:

def featurize(review, topk=top2k, stopw=en_stopw):
    review = BeautifulSoup(review).text        # remove HTML tags
    review = re.sub('[^A-Za-z]', ' ', review)  # remove nonletters
    review = review.lower()
    tok_rev = wt(review)
    rev_word = [word for word in tok_rev if word not in stopw]
    features = {}
    for word in top2k:
        features['contains({})'.format(word)] = (word in rev_word)
    return features
train = [(featurize(rev), senti) for (rev, senti) in 
                        zip(train_df.review, train_df.sentiment)] 

训练模型相当简单。请注意,这里使用的是伯努利朴素贝叶斯模型,因为每个单词都是根据它在评论中是否存在来表示的。或者,也可以使用单词在评论中的频率。如果在上述评论向量化过程中使用单词的频率,那么应该使用朴素贝叶斯的多项式形式。

NLTK 还提供了一种检查最具信息性特征的方法:

classifier = nltk.NaiveBayesClassifier.train(train)
# 0: negative sentiment, 1: positive sentiment
classifier.show_most_informative_features(20) 
Most Informative Features
       contains(unfunny) = True      0 : 1      =     14.1 : 1.0
         contains(waste) = True      0 : 1      =     12.7 : 1.0
     contains(pointless) = True      0 : 1      =     10.4 : 1.0
     contains(redeeming) = True      0 : 1      =     10.1 : 1.0
     contains(laughable) = True      0 : 1      =      9.3 : 1.0
         contains(worst) = True      0 : 1      =      9.0 : 1.0
         contains(awful) = True      0 : 1      =      8.4 : 1.0
        contains(poorly) = True      0 : 1      =      8.2 : 1.0
   contains(wonderfully) = True      1 : 0      =      7.6 : 1.0
         contains(sucks) = True      0 : 1      =      7.0 : 1.0
          contains(lame) = True      0 : 1      =      6.9 : 1.0
      contains(pathetic) = True      0 : 1      =      6.4 : 1.0
    contains(delightful) = True      1 : 0      =      6.0 : 1.0
        contains(wasted) = True      0 : 1      =      6.0 : 1.0
          contains(crap) = True      0 : 1      =      5.9 : 1.0
   contains(beautifully) = True      1 : 0      =      5.8 : 1.0
      contains(dreadful) = True      0 : 1      =      5.7 : 1.0
          contains(mess) = True      0 : 1      =      5.6 : 1.0
      contains(horrible) = True      0 : 1      =      5.5 : 1.0
        contains(superb) = True      1 : 0      =      5.4 : 1.0
       contains(garbage) = True      0 : 1      =      5.3 : 1.0
         contains(badly) = True      0 : 1      =      5.3 : 1.0
        contains(wooden) = True      0 : 1      =      5.2 : 1.0
      contains(touching) = True      1 : 0      =      5.1 : 1.0
      contains(terrible) = True      0 : 1      =      5.1 : 1.0 

整个过程是为了找出哪些单词对预测负面和正面评论最有用。上面的表格显示了这些单词及其可能性比率。以输出的第一行中的单词unfunny为例,模型表示含有unfunny的评论比含有它的正面评论要负面 14.1 倍。标签函数是通过这些关键词更新的。

在分析snorkel-labeling.ipynb中标签函数分配的标签后,可以看到负面评论比正面评论标注得更多。因此,标签函数为正面标签使用的单词列表比负面标签使用的单词列表要大。请注意,数据集不平衡会影响整体训练准确性,特别是召回率。以下代码片段展示了使用上述通过朴素贝叶斯发现的关键词来增强标签函数:

# Some positive high prob words - arbitrary cutoff of 4.5x
'''
   contains(wonderfully) = True       1 : 0      =      7.6 : 1.0
    contains(delightful) = True       1 : 0      =      6.0 : 1.0
   contains(beautifully) = True       1 : 0      =      5.8 : 1.0
        contains(superb) = True       1 : 0      =      5.4 : 1.0
      contains(touching) = True       1 : 0      =      5.1 : 1.0
   contains(brilliantly) = True       1 : 0      =      4.7 : 1.0
    contains(friendship) = True       1 : 0      =      4.6 : 1.0
        contains(finest) = True       1 : 0      =      4.5 : 1.0
      contains(terrific) = True       1 : 0      =      4.5 : 1.0
           contains(gem) = True       1 : 0      =      4.5 : 1.0
   contains(magnificent) = True       1 : 0      =      4.5 : 1.0
'''
wonderfully_kw = make_keyword_lf(keywords=["wonderfully"], 
label=POSITIVE)
delightful_kw = make_keyword_lf(keywords=["delightful"], 
label=POSITIVE)
superb_kw = make_keyword_lf(keywords=["superb"], label=POSITIVE)
pos_words = ["beautifully", "touching", "brilliantly", 
"friendship", "finest", "terrific", "magnificent"]
pos_nb_kw = make_keyword_lf(keywords=pos_words, label=POSITIVE)
@labeling_function()
def superlatives(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = ["best", "super", "great","awesome","amaz", "fantastic", 
           "excellent", "favorite"]
    pos_words = ["beautifully", "touching", "brilliantly", 
                 "friendship", "finest", "terrific", "magnificent", 
                 "wonderfully", "delightful"]
    ex1 += pos_words
    rv = x.review.lower()
    counts = [rv.count(x) for x in ex1]
    if sum(counts) >= 3:
        return POSITIVE
    return ABSTAIN 

由于基于关键字的标签函数非常常见,Snorkel 提供了一种简单的方法来定义这样的函数。以下代码片段使用两种编程方式将单词列表转换为标签函数集合:

# Utilities for defining keywords based functions
def keyword_lookup(x, keywords, label):
    if any(word in x.review.lower() for word in keywords):
        return label
    return ABSTAIN
def make_keyword_lf(keywords, label):
    return LabelingFunction(
        name=f"keyword_{keywords[0]}",
        f=keyword_lookup,
        resources=dict(keywords=keywords, label=label),
    ) 

第一个函数进行简单匹配并返回特定标签,或者选择不标注。请查看snorkel-labeling.ipynb文件,了解迭代开发的完整标签函数列表。总的来说,我花了大约 12-14 小时进行标签函数的开发和研究。

在我们尝试使用这些数据训练模型之前,让我们先评估一下该模型在整个训练数据集上的准确性。

评估训练集上的弱监督标签

我们应用标签函数并在整个训练数据集上训练一个模型,只是为了评估该模型的质量:

L_train_full = applier.apply(train_df)
label_model = LabelModel(cardinality=2, verbose=True)
label_model.fit(L_train_full, n_epochs=500, log_freq=50, seed=123)
metrics = label_model.score(L=L_train_full, Y=train_df.sentiment, 
                            tie_break_policy="abstain",
                            metrics=["accuracy", "coverage", 
                                     "precision", 
                                     "recall", "f1"])
print("All Metrics: ", metrics) 
Label Model Accuracy:     78.5%
All Metrics:  {**'accuracy': 0.7854110013835218, 'coverage': 0.83844**, 'precision': 0.8564883605745418, 'recall': 0.6744344773790951, 'f1': 0.7546367008509709} 

我们的标签函数集覆盖了 25,000 条训练记录中的 83.4%,其中 85.6%是正确标签。Snorkel 提供了分析每个标签函数表现的能力:

from snorkel.labeling import LFAnalysis
LFAnalysis(L=L_train_full, lfs=lfs).lf_summary() 
 j Polarity  Coverage  Overlaps  Conflicts
atrocious             0      [0]   0.00816   0.00768    0.00328
terrible              1      [0]   0.05356   0.05356    0.02696
piece_of              2      [0]   0.00084   0.00080    0.00048
woefully_miscast      3      [0]   0.00848   0.00764    0.00504
**bad_acting            4      [0]   0.08748   0.08348    0.04304**
cheesy_dull           5      [0]   0.05136   0.04932    0.02760
bad                  11      [0]   0.03624   0.03624    0.01744
keyword_waste        12      [0]   0.07336   0.06848    0.03232
keyword_pointless    13      [0]   0.01956   0.01836    0.00972
keyword_redeeming    14      [0]   0.01264   0.01192    0.00556
keyword_laughable    15      [0]   0.41036   0.37368    0.20884
negatives            16      [0]   0.35300   0.34720    0.17396
classic              17      [1]   0.01684   0.01476    0.00856
must_watch           18      [1]   0.00176   0.00140    0.00060
oscar                19      [1]   0.00064   0.00060    0.00016
love                 20      [1]   0.08660   0.07536    0.04568
great_entertainment  21      [1]   0.00488   0.00488    0.00292
very_entertaining    22      [1]   0.00544   0.00460    0.00244
**amazing              23      [1]   0.05028   0.04516    0.02340**
great                31      [1]   0.27728   0.23568    0.13800
keyword_wonderfully  32      [1]   0.01248   0.01248    0.00564
keyword_delightful   33      [1]   0.01188   0.01100    0.00500
keyword_superb       34      [1]   0.02948   0.02636    0.01220
keyword_beautifully  35      [1]   0.08284   0.07428    0.03528
superlatives         36      [1]   0.14656   0.14464    0.07064
keyword_remarkable   37      [1]   0.32052   0.26004    0.14748 

请注意,这里展示的是输出的简短版本。完整的输出可以在笔记本中查看。对于每个标签函数,表格展示了其产生的标签和函数的覆盖范围——即,它为多少条记录提供标签,多少条记录与其他函数产生相同标签重叠,以及多少条记录与其他函数产生不同标签冲突。正标签和负标签函数已被突出显示。bad_acting()函数覆盖了 8.7%的记录,但大约 8.3%的时间与其他函数重叠。然而,它与产生正标签的函数发生冲突的时间大约是 4.3%。amazing()函数覆盖了大约 5%的数据集,发生冲突的时间大约为 2.3%。这些数据可以用来进一步微调特定的函数,并检查我们如何划分数据。图 8.5展示了正标签、负标签和弃权标签之间的平衡:

一张社交媒体帖子的截图 说明自动生成

图 8.5:Snorkel 生成的标签分布

Snorkel 提供了几种超参数调优的选项,以进一步提高标签的质量。我们通过网格搜索参数来找到最佳训练参数,同时排除那些在最终输出中增加噪声的标签函数。

超参数调优通过选择不同的学习率、L2 正则化、训练轮数和使用的优化器来进行。最后,通过设定阈值来决定哪些标签函数应该保留用于实际的标签任务:

# Grid Search
from itertools import product
lrs = [1e-1, 1e-2, 1e-3]
l2s = [0, 1e-1, 1e-2]
n_epochs = [100, 200, 500]
optimizer = ["sgd", "adam"]
thresh = [0.8, 0.9]
lma_best = 0
params_best = []
for params in product(lrs, l2s, n_epochs, optimizer, thresh):
    # do the initial pass to access the accuracies
    label_model.fit(L_train_full, n_epochs=params[2], log_freq=50, 
                    seed=123, optimizer=params[3], lr=params[0], 
                    l2=params[1])

    # accuracies
    weights = label_model.get_weights()

    # LFs above our threshold 
    vals = weights > params[4]

    # the LM requires at least 3 LFs to train
    if sum(vals) >= 3:
        L_filtered = L_train_full[:, vals]
        label_model.fit(L_filtered, n_epochs=params[2], 
                        log_freq=50, seed=123, 
                        optimizer=params[3], lr=params[0], 
                        l2=params[1])
        label_model_acc = label_model.score(L=L_filtered, 
                          Y=train_df.sentiment, 
                          tie_break_policy="abstain")["accuracy"]
        if label_model_acc > lma_best:
            lma_best = label_model_acc
            params_best = params

print("best = ", lma_best, " params ", params_best) 

Snorkel 可能会打印出一个警告,提示指标仅在非弃权标签上进行计算。这是设计使然,因为我们关注的是高置信度的标签。如果标签函数之间存在冲突,我们的模型会选择放弃为其提供标签。打印出的最佳参数是:

best =  0.8399649430324277  params  (0.001, 0.1, 200, 'adam', 0.9) 

通过这个调优,模型的准确率从 78.5%提高到了 84%!

使用这些参数,我们标注了来自训练集的 23k 条记录和来自无监督集的 50k 条记录。对于第一部分,我们标注了所有 25k 条训练记录,并将其分成两组。这个特定的分割部分在上面的基准模型部分中提到过:

train_df["snorkel"] = label_model.predict(L=L_filtered, 
                               tie_break_policy="abstain")
from sklearn.model_selection import train_test_split
# Randomly split training into 2k / 23k sets
train_2k, train_23k = train_test_split(train_df, test_size=23000, 
                                       random_state=42, 
                                       stratify=train_df.sentiment)
train_23k.snorkel.hist()
train_23k.sentiment.hist() 

代码的最后两行检查标签的状态,并与实际标签进行对比,生成图 8.6中所示的图表:

一张包含屏幕、建筑、绘画和食物的图片 说明自动生成

图 8.6:训练集中标签与使用 Snorkel 生成的标签的对比

当 Snorkel 模型放弃标签时,它会为标签分配-1。我们看到模型能够标注更多的负面评论,而不是正面标签。我们过滤掉 Snorkel 放弃标注的行并保存记录:

lbl_train = train_23k[train_23k.snorkel > -1]
lbl_train = lbl_train.drop(columns=["sentiment"])
p_sup = lbl_train.rename(columns={"snorkel": "sentiment"})
p_sup.to_pickle("snorkel_train_labeled.df") 

然而,我们面临的关键问题是,如果我们用这些噪声标签(准确率为 84%)增强训练数据,这会使我们的模型表现更好还是更差?请注意,基准模型的准确率大约为 74%。

为了回答这个问题,我们对无监督数据集进行标注,然后训练与基准相同的模型架构。

为未标记数据生成无监督标签

正如我们在上一部分看到的,我们对训练数据集进行了标注,运行模型在数据集的未标注评论上也非常简单:

# Now apply this to all the unsupervised reviews
# Apply the LFs to the unlabeled training data
applier = PandasLFApplier(lfs)
# now let's apply on the unsupervised dataset
L_train_unsup = applier.apply(unsup_df)
label_model = LabelModel(cardinality=2, verbose=True)
label_model.fit(L_train_unsup[:, vals], n_epochs=params_best[2], 
                optimizer=params_best[3], 
                lr=params_best[0], l2=params_best[1], 
                log_freq=100, seed=42)
unsup_df["snorkel"] = label_model.predict(L=L_train_unsup[:, vals], 
                                   tie_break_policy="abstain")
# rename snorkel to sentiment & concat to the training dataset
pred_unsup_lfs = unsup_df[unsup_df.snorkel > -1]
p2 = pred_unsup_lfs.rename(columns={"snorkel": "sentiment"})
print(p2.info())
p2.to_pickle("snorkel-unsup-nbs.df") 

现在标签模型已经训练完成,预测结果被添加到无监督数据集的额外列中。模型对 50,000 条记录中的 29,583 条进行了标注。这几乎等于训练数据集的大小。假设无监督数据集的错误率与训练集上的错误率相似,我们就向训练集中添加了约 24,850 条正确标签的记录和约 4,733 条错误标签的记录。然而,这个数据集的平衡性非常倾斜,因为正面标签的覆盖仍然很差。大约有 9,000 个正面标签,覆盖超过 20,000 个负面标签。笔记本中的增加正面标签覆盖率部分通过添加更多关键词函数来进一步提高正面标签的覆盖率。

这导致数据集稍微更平衡,如下图所示:

A screen shot of a social media post  Description automatically generated

图 8.7:对无监督数据集应用进一步改进的标注函数,改善了正面标签的覆盖

该数据集已保存到磁盘,以便在训练过程中使用:

p3 = pred_unsup_lfs2.rename(columns={"snorkel2": "sentiment"})
print(p3.info())
p3.to_pickle("snorkel-unsup-nbs-v2.df") 

已标记的数据集保存到磁盘,并在训练代码中重新加载,以便更好的模块化和易于阅读。在生产管道中,可能不会持久化中间输出,而是直接将其馈入训练步骤。另一个小考虑因素是分离虚拟/conda 环境来运行 Snorkel。为弱监督标注创建一个单独的脚本也可以使用不同的 Python 环境。

我们将重点重新放回到imdb-with-snorkel-labels.ipynb笔记本,该笔记本包含用于训练的模型。该部分的代码从使用 Snorkel 标注数据部分开始。新标注的记录需要从磁盘加载、清理、向量化并填充,才能进行训练。我们提取标注的记录并去除 HTML 标记,如下所示:

# labelled version of training data split
p1 = pd.read_pickle("snorkel_train_labeled.df")
p2 = pd.read_pickle("snorkel-unsup-nbs-v2.df")
p2 = p2.drop(columns=['snorkel']) # so that everything aligns
# now concatenate the three DFs
p2 = pd.concat([train_small, p1, p2]) # training plus snorkel labelled data
print("showing hist of additional data")
# now balance the labels
pos = p2[p2.sentiment == 1]
neg = p2[p2.sentiment == 0]
recs = min(pos.shape[0], neg.shape[0])
pos = pos.sample(n=recs, random_state=42)
neg = neg.sample(n=recs, random_state=42)
p3 = pd.concat((pos,neg))
p3.sentiment.hist() 

原始训练数据集在正负标签上是平衡的。然而,使用 Snorkel 标记的数据存在不平衡问题。我们平衡数据集,并忽略那些多余的负标签记录。请注意,基准模型使用的 2,000 条训练记录也需要添加进去,总共得到 33,914 条训练记录。如前所述,当数据量是原始数据集的 10 倍到 50 倍时,这个方法非常有效。在这里,如果将 2,000 条训练记录也算在内,我们达到了接近 17 倍的比例,或者是 18 倍。

A picture containing screen, orange, drawing  Description automatically generated

图 8.8:使用 Snorkel 和弱监督后的记录分布

如上图Figure 8.8所示,蓝色的记录被丢弃以平衡数据集。接下来,数据需要使用子词词汇进行清洗和向量化:

# remove markup
cleaned_unsup_reviews = p3.review.apply(
                             lambda x: BeautifulSoup(x).text)
snorkel_reviews = pd.concat((cleaned_reviews, cleaned_unsup_reviews))
snorkel_labels = pd.concat((train_small.sentiment, p3.sentiment)) 

最后,我们将 pandas DataFrame 转换为 TensorFlow 数据集,并进行向量化和填充:

# convert pandas DF in to tf.Dataset
snorkel_train = tf.data.Dataset.from_tensor_slices((
                                   snorkel_reviews.values,
                                   snorkel_labels.values))
encoded_snorkel_train = snorkel_train.map(encode_tf_fn,
                 num_parallel_calls=tf.data.experimental.AUTOTUNE) 

我们准备好尝试训练我们的 BiLSTM 模型,看看它是否能在这个任务上提高性能。

在 Snorkel 的弱监督数据上训练 BiLSTM

为了确保我们在比较相同的事物,我们使用与基准模型相同的 BiLSTM。我们实例化一个模型,嵌入维度为 64,RNN 单元数为 64,批量大小为 100。该模型使用二元交叉熵损失和 Adam 优化器。模型训练过程中,会跟踪准确率、精确度和召回率。一个重要的步骤是每个周期都打乱数据集,以帮助模型保持最小的误差。

这是一个重要的概念。深度模型基于假设损失是一个凸面,梯度沿着这个凸面下降到底部。实际上,这个面有很多局部最小值或鞍点。如果模型在一个小批次中陷入局部最小值,由于跨越多个周期时,模型不断接收到相同的数据点,它将很难从局部最小值中跳出来。通过打乱数据,改变数据集及其顺序,模型可以更好地学习,从而更快地跳出这些局部最小值。此部分的代码位于imdb-with-snorkel-labels.ipynb文件中:

shuffle_size = snorkel_reviews.shape[0] // BATCH_SIZE * BATCH_SIZE
encoded_snorkel_batched = encoded_snorkel_train.shuffle( 
                                  buffer_size=shuffle_size,
                                  seed=42).batch(BATCH_SIZE,
                                  drop_remainder=True) 

请注意,我们缓存了所有将成为批次一部分的记录,以便实现完美的缓冲。这会带来训练速度略微变慢和内存使用量增加的代价。同时,由于我们的批量大小是 100,数据集有 35,914 条记录,我们丢弃了剩余的记录。我们训练模型 20 个周期,略多于基准模型。基准模型在 15 个周期时就发生了过拟合,因此再训练也没有意义。这个模型有更多的数据可以训练,因此需要更多的周期来学习:

bilstm2.fit(encoded_snorkel_batched, epochs=20) 
Train for 359 steps
Epoch 1/20
359/359 [==============================] - 92s 257ms/step - loss: 0.4399 - accuracy: 0.7860 - Precision: 0.7900 - Recall: 0.7793
…
Epoch 20/20
359/359 [==============================] - 82s 227ms/step - loss: 0.0339 - accuracy: 0.9886 - Precision: 0.9879 - Recall: 0.9893 

该模型达到了 98.9%的准确率。精确度和召回率的数值非常接近。在测试数据上评估基线模型时,得到了 76.23%的准确率,这明显证明它对训练数据发生了过拟合。当评估使用弱监督标签训练的模型时,得到以下结果:

bilstm2.evaluate(encoded_test.batch(BATCH_SIZE)) 
250/250 [==============================] - 35s 139ms/step - loss: 1.9134 - accuracy: 0.7658 - precision: 0.7812 - recall: 0.7386 

这个在弱监督噪声标签上训练的模型达到了 76.6%的准确率,比基线模型高出 0.7%。还要注意,精确度从 74.5%提高到了 78.1%,但召回率有所下降。在这个玩具设置中,我们保持了许多变量不变,例如模型类型、dropout 比例等。在实际环境中,我们可以通过优化模型架构和调整超参数进一步提高准确率。还有其他选项可以尝试。记住,我们指示 Snorkel 在不确定时避免标注。

通过将其改为多数投票或其他策略,训练数据的量可以进一步增加。你也可以尝试在不平衡的数据集上训练,看看其影响。这里的重点是展示弱监督在大规模增加训练数据量方面的价值,而不是构建最佳模型。然而,你应该能够将这些教训应用到你的项目中。

花些时间思考结果的原因是非常重要的。在这个故事中隐藏着一些重要的深度学习教训。首先,在模型复杂度足够的情况下,更多的标注数据总是好的。数据量和模型容量之间是有相关性的。高容量的模型可以处理数据中的更复杂关系,同时也需要更大的数据集来学习这些复杂性。然而,如果模型保持不变且容量足够,标注数据的数量就会产生巨大的影响,正如这里所证明的那样。通过增加标注数据量,我们能提高的效果是有限的。在 Chen Sun 等人于 ICCV 2017 发布的论文《重新审视深度学习时代数据的非理性有效性》中,作者研究了数据在计算机视觉领域中的作用。他们报告说,随着训练数据量的增加,模型的性能呈对数增长。他们报告的第二个结果是,通过预训练学习表示会显著帮助下游任务。本章中的技术可以应用于生成更多的数据用于微调步骤,这将大大提升微调模型的性能。

第二个教训是关于机器学习基础的——训练数据集的打乱对模型性能有不成比例的影响。在本书中,我们并不是总这样做,以便管理训练时间。对于训练生产模型,重要的是关注一些基础,如在每个 epoch 之前打乱数据集。

让我们回顾一下在这一章中学到的内容。

总结

很明显,深度模型在拥有大量数据时表现非常好。BERT 和 GPT 模型展示了在大量数据上进行预训练的价值。对于预训练或微调,获得高质量的标注数据仍然非常困难。我们结合弱监督学习和生成模型的概念,以低成本标注数据。通过相对较少的努力,我们能够将训练数据量扩展 18 倍。即使额外的训练数据存在噪声,BiLSTM 模型仍然能够有效学习,并比基线模型提高了 0.6%。

表示学习或预训练会导致迁移学习,并使微调模型在下游任务中表现良好。然而,在许多领域,如医学,标注数据的数量可能较少或获取成本较高。运用本章所学的技术,训练数据的数量可以通过少量的努力迅速扩展。构建一个超越当前最先进水平的模型有助于回顾一些深度学习的基本经验教训,比如更大的数据如何显著提升性能,以及更大的模型并不总是更好的。

现在,我们将重点转向对话式人工智能。构建对话式 AI 系统是一项非常具有挑战性的任务,涉及多个层面。到目前为止,本书所涵盖的内容可以帮助构建聊天机器人系统的各个部分。下一章将介绍对话式 AI 或聊天机器人系统的关键组成部分,并概述构建它们的有效方法。