原文:
annas-archive.org/md5/c9f2178832e6a58ea7603b6cecb80b22
译者:飞龙
第五章:高级计算机视觉应用
在第四章中,我们介绍了用于计算机视觉的卷积网络(CNN)以及一些最受欢迎和表现最好的 CNN 模型。在本章中,我们将继续探讨类似的内容,但会深入到更高级的层次。到目前为止,我们的操作方式一直是提供简单的分类示例,以支持你对神经网络(NN)的理论知识。在计算机视觉任务的宇宙中,分类是相对直接的,因为它为图像分配一个单一的标签。这也使得手动创建大型标签化训练数据集成为可能。在本章中,我们将介绍迁移学习(TL),一种技术,它将使我们能够将预训练的神经网络的知识迁移到一个新的、无关的任务中。我们还将看到,迁移学习如何使得解决两个有趣的计算机视觉任务成为可能——目标检测和语义分割。我们可以说,这些任务相对于分类更为复杂,因为模型需要对图像有更全面的理解。它不仅要能够检测出不同的物体,还要知道它们在图像中的位置。同时,这些任务的复杂性也为更具创意的解决方案提供了空间。
最后,我们将介绍一种新型算法,称为生成模型,它将帮助我们生成新的图像。
本章将涵盖以下主题:
-
迁移 学习(TL)
-
目标检测
-
语义分割
-
使用扩散模型生成图像
技术要求
我们将在本章中使用 Python、PyTorch、Keras 和 Ultralytics YOLOv8(github.com/ultralytics/ultralytics
)实现示例。如果你没有配置好这些工具的环境,不用担心——示例可以在 Google Colab 上的 Jupyter notebook 中找到。你可以在本书的 GitHub 仓库中找到代码示例:github.com/PacktPublishing/Python-Deep-Learning-Third-Edition/tree/main/Chapter05
。
迁移学习(TL)
到目前为止,我们已经在玩具数据集上训练了小型模型,训练时间不超过一个小时。但如果我们想要处理大规模数据集,比如 ImageNet,我们将需要一个更大的网络,且训练时间会更长。更重要的是,大规模数据集并不总是能满足我们感兴趣任务的需求。请记住,除了获取图像之外,它们还需要被标注,而这可能是既昂贵又费时的。那么,当工程师想用有限资源解决实际的机器学习问题时,应该怎么办呢?这时,迁移学习(TL)就派上用场了。
迁移学习(TL)是将一个已经训练好的机器学习(ML)模型应用于一个新的但相关的问题的过程。例如,我们可以将一个在 ImageNet 上训练过的网络重新用于分类杂货店物品。或者,我们可以使用一个驾驶模拟游戏来训练神经网络(NN)驾驶一辆模拟汽车,然后用这个网络来驾驶真实的汽车(但请不要在家尝试!)。迁移学习是一个适用于所有机器学习算法的通用概念——我们将在第八章中也使用迁移学习。但在本章中,我们将讨论卷积神经网络(CNN)中的迁移学习。它是如何工作的,下面解释。
我们从一个现有的预训练网络开始。最常见的场景是使用一个在 ImageNet 上预训练的网络,但它也可以是任何数据集。PyTorch、TensorFlow(TF)和 Keras 都提供了流行的 ImageNet 预训练神经网络架构,我们可以使用。或者,我们也可以选择一个数据集来训练自己的网络。
在第四章中,我们提到过 CNN 最后的 全连接层(FC)如何作为网络语言(训练过程中学到的抽象特征表示)与我们的语言(每个样本的类别)之间的转换器。你可以将迁移学习看作是对另一种语言的翻译。我们从网络的特征开始,这些特征是最后一个卷积层或池化层的输出。然后,我们将它们翻译成新任务的不同类别。我们可以通过去除现有预训练网络的最后几层,并用一组新的层替换它们,这些新层代表了新问题的类别。以下是迁移学习场景的示意图:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_1.jpg
图 5.1 – 一个迁移学习(TL)场景,其中我们替换了一个
预训练网络,并将其重新用于新的问题
然而,我们不能机械地进行这种操作并期望新网络能够正常工作,因为我们仍然需要用与新任务相关的数据来训练新层。我们有两种方式可以做到这一点:
-
使用网络的原始部分作为特征提取器,只训练新的层:首先,我们将新的数据批次输入网络,进行前向和反向传播,查看网络的输出和误差梯度。这部分的工作方式就像常规训练一样。但在权重更新阶段,我们会锁定原始网络的权重,只更新新层的权重。这是当我们对新问题的数据有限时推荐的做法。通过锁定大部分网络权重,我们可以防止在新数据上过拟合。
-
微调整个网络:我们训练整个网络,而不仅仅是最后添加的层。可以更新所有网络权重,但我们也可以锁定一些第一层的权重。这里的想法是,初始层用于检测一般特征——与特定任务无关——因此重复使用它们是合理的。另一方面,较深的层可能会检测任务特定的特征,因此更新它们会更好。当我们拥有更多训练数据并且不需要担心过拟合时,可以使用这种方法。
在继续之前,我们需要指出,迁移学习不仅限于分类到分类的问题。正如我们在本章后面看到的,我们可以使用预训练的卷积神经网络(CNN)作为目标检测和语义分割任务的主干神经网络。现在,让我们看看如何在实践中实现迁移学习。
使用 PyTorch 进行迁移学习
在本节中,我们将应用一个先进的 ImageNet 预训练网络到 CIFAR-10 图像上。我们将实现两种类型的迁移学习。最好在 GPU 上运行这个示例:
-
要定义训练数据集,我们需要考虑几个因素:
-
使用大小为 50 的 mini-batch。
-
CIFAR-10 图像的大小为 32×32,而 ImageNet 网络期望输入为 224×224。由于我们使用的是基于 ImageNet 的网络,我们将使用
transforms.``Resize
将 32×32 的 CIFAR 图像上采样到 224×224。 -
使用 ImageNet 的均值和标准差来标准化 CIFAR-10 数据,因为网络期望的是这种格式。
-
添加轻微的数据增强(翻转)。
我们可以通过以下代码完成所有这些操作:
import torch from torch.utils.data import DataLoader from torchvision import datasets from torchvision import transforms batch_size = 50 # training data train_data_transform = transforms.Compose([ transforms.Resize(224), transforms.RandomHorizontalFlip(), transforms.RandomVerticalFlip(), transforms.ToTensor(), transforms.Normalize( [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) train_set = datasets.CIFAR10( root='data', train=True, download=True, transform=train_data_transform) train_loader = DataLoader( dataset=train_set, batch_size=batch_size, shuffle=True, num_workers=2)
-
-
按照相同的步骤使用验证数据(除了数据增强之外):
val_data_transform = transforms.Compose([ transforms.Resize(224), transforms.ToTensor(), transforms.Normalize( [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) val_set = datasets.CIFAR10( root='data', train=False, download=True, transform=val_data_transform) val_order = DataLoader( dataset=val_set, batch_size=batch_size, shuffle=False, num_workers=2)
-
选择一个设备——最好是 GPU,如果没有可退回到 CPU:
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
-
为了训练和验证模型,我们将使用
train_model(model, loss_function, optimizer, data_loader)
和test_model(model, loss_function, data_loader)
函数。我们在第三章中首先实现了这些函数,因此这里不再重复实现(完整的源代码示例可以在 GitHub 上找到)。 -
定义第一个迁移学习场景,其中我们将预训练的网络用作特征提取器:
-
我们将使用一个流行的网络,
epochs
,并在每个 epoch 后评估网络的准确度。 -
使用
plot_accuracy
准确度函数,它在matplotlib
图表上绘制验证准确度。我们不会在这里包含完整的实现,但可以在 GitHub 上找到。
以下是
tl_feature_extractor
函数,它实现了所有这些:import torch.nn as nn import torch.optim as optim from torchvision.models import MobileNet_V3_Small_Weights, mobilenet_v3_small def tl_feature_extractor(epochs=5): # load the pre-trained model model = mobilenet_v3_small( weights=MobileNet_V3_Small_Weights.IMAGENET1K_V1) # exclude existing parameters from backward pass # for performance for param in model.parameters(): param.requires_grad = False # newly constructed layers have requires_grad=True by default num_features = model.classifier[0].in_features model.classifier = nn.Linear(num_features, 10) # transfer to GPU (if available) model = model.to(device) loss_function = nn.CrossEntropyLoss() # only parameters of the final layer are being optimized optimizer = optim.Adam(model.classifier.parameters()) # train test_acc = list() # collect accuracy for plotting for epoch in range(epochs): print('Epoch {}/{}'.format(epoch + 1, epochs)) train_model(model, loss_function, optimizer, train_loader) _, acc = test_model(model, loss_function, val_order) test_acc.append(acc.cpu()) plot_accuracy(test_acc)
-
-
使用
tl_fine_tuning
函数实现微调方法。此函数与tl_feature_extractor
类似,但现在我们将训练整个网络:def tl_fine_tuning(epochs=5): # load the pre-trained model model = mobilenet_v3_small( weights=MobileNet_V3_Small_Weights.IMAGENET1K_V1) # replace the last layer num_features = model.classifier[0].in_features model.classifier = nn.Linear(num_features, 10) # transfer the model to the GPU model = model.to(device) # loss function loss_function = nn.CrossEntropyLoss() # We'll optimize all parameters optimizer = optim.Adam(model.parameters()) # train test_acc = list() # collect accuracy for plotting for epoch in range(epochs): print('Epoch {}/{}'.format(epoch + 1, epochs)) train_model(model, loss_function, optimizer, train_loader) _, acc = test_model(model, loss_function, val_order) test_acc.append(acc.cpu()) plot_accuracy(test_acc)
-
我们可以通过两种方式运行整个过程:
-
调用
tl_fine_tuning(epochs=5)
来使用微调方法训练五个 epoch。 -
调用
tl_feature_extractor(epochs=5)
来使用特征提取方法训练网络五个 epoch。
-
使用网络作为特征提取器时,我们的准确率大约为 81%,而通过微调后,准确率可以达到 89%。但如果我们在更多的训练周期中进行微调,网络将开始出现过拟合。接下来,我们来看一个相同的例子,但使用的是 Keras。
使用 Keras 进行迁移学习
在本节中,我们将再次实现这两种迁移学习(TL)场景,但这次使用的是 Keras 和 TF。通过这种方式,我们可以比较这两个库。我们仍然使用MobileNetV3Small
架构。除了 Keras 外,本示例还需要 TF Datasets 包(www.tensorflow.org/datasets
),它是一个包含各种流行机器学习数据集的集合。让我们开始:
注意
本示例部分基于github.com/tensorflow/docs/blob/master/site/en/tutorials/images/transfer_learning.ipynb
。
-
定义小批量和输入图像大小(图像大小由网络架构决定):
IMG_SIZE = 224 BATCH_SIZE = 50
-
使用 TF 数据集的帮助加载 CIFAR-10 数据集。
repeat()
方法允许我们在多个周期中重复使用数据集:import tensorflow as tf import tensorflow_datasets as tfds data, metadata = tfds.load('cifar10', with_info=True, as_supervised=True) raw_train, raw_test = data['train'].repeat(), data['test'].repeat()
-
定义
train_format_sample
和test_format_sample
函数,这些函数会将初始图像转换为适合 CNN 输入的格式。这些函数扮演了与我们在使用 PyTorch 实现迁移学习一节中定义的transforms.Compose
对象相同的角色。输入转换如下:-
图像会被调整为 224×224 的大小,这是网络预期的输入尺寸
-
每张图片都会通过转换其值来进行标准化,使其处于(-1;1)区间内
-
标签被转换为独热编码
-
训练图像会随机地水平和垂直翻转
让我们看看实际的实现:
def train_format_sample(image, label): """Transform data for training""" image = tf.cast(image, tf.float32) image = tf.image.resize(image, (IMG_SIZE, IMG_SIZE)) image = tf.image.random_flip_left_right(image) image = tf.image.random_flip_up_down(image) label = tf.one_hot(label, metadata.features['label'].num_classes) return image, label def test_format_sample(image, label): """Transform data for testing""" image = tf.cast(image, tf.float32) image = tf.image.resize(image, (IMG_SIZE, IMG_SIZE)) label = tf.one_hot(label, metadata.features['label'].num_classes) return image, label
-
-
接下来是一些模板代码,将这些转换器分配到训练/测试数据集,并将其拆分成小批量:
# assign transformers to raw data train_data = raw_train.map(train_format_sample) test_data = raw_test.map(test_format_sample) # extract batches from the training set train_batches = train_data.shuffle(1000).batch(BATCH_SIZE) test_batches = test_data.batch(BATCH_SIZE)
-
定义特征提取模型:
-
由于 Keras 是 TF 的核心部分,因此使用 Keras 来定义预训练网络和模型
-
加载
MobileNetV3Small
预训练网络,排除最后的全连接层 -
调用
base_model.trainable = False
,这会冻结所有网络权重,防止它们被训练 -
添加一个
GlobalAveragePooling2D
操作,然后在网络的末端添加一个新的、可训练的全连接层
以下代码实现了这一点:
def build_fe_model(): """"Create feature extraction model from the pre-trained model ResNet50V2""" # create the pre-trained part of the network, excluding FC layers base_model = tf.keras.applications.MobileNetV3Small( input_shape=(IMG_SIZE, IMG_SIZE, 3), include_top=False, classes=10, weights='imagenet', include_preprocessing=True) # exclude all model layers from training base_model.trainable = False # create new model as a combination of the pre-trained net # and one fully connected layer at the top return tf.keras.Sequential([ base_model, tf.keras.layers.GlobalAveragePooling2D(), tf.keras.layers.Dense( metadata.features['label'].num_classes, activation='softmax') ])
-
-
定义微调模型。它与特征提取的唯一区别是,我们只冻结一些底层的预训练网络层(而不是全部层)。以下是实现代码:
def build_ft_model(): """"Create fine tuning model from the pre-trained model MobileNetV3Small""" # create the pre-trained part of the network, excluding FC layers base_model = tf.keras.applications.MobileNetV3Small( input_shape=(IMG_SIZE, IMG_SIZE, 3), include_top=False, weights='imagenet', include_preprocessing=True ) # Fine tune from this layer onwards fine_tune_at = 100 # Freeze all the layers before the `fine_tune_at` layer for layer in base_model.layers[:fine_tune_at]: layer.trainable = False # create new model as a combination of the pre-trained net # and one fully connected layer at the top return tf.keras.Sequential([ base_model, tf.keras.layers.GlobalAveragePooling2D(), tf.keras.layers.Dense( metadata.features['label'].num_classes, activation='softmax') ])
-
实现
train_model
函数,它用于训练和评估由build_fe_model
或build_ft_model
函数创建的模型。plot_accuracy
函数在此未实现,但可在 GitHub 上找到:def train_model(model, epochs=5): """Train the model. This function is shared for both FE and FT modes""" # configure the model for training model.compile( optimizer=tf.keras.optimizers.Adam( learning_rate=0.0001), loss='categorical_crossentropy', metrics=['accuracy']) # train the model history = model.fit( train_batches, epochs=epochs, steps_per_epoch=metadata.splits['train'].num_examples / BATCH_SIZE, validation_data=test_batches, validation_steps=metadata.splits['test'].num_examples / BATCH_SIZE, workers=4) # plot accuracy plot_accuracy(history.history['val_accuracy'])
-
我们可以使用以下代码运行特征提取或微调迁移学习:
-
train_model(build_ft_model())
-
train_model(build_fe_model())
-
使用网络作为特征提取器时,我们可以获得约 82%的准确率,而通过微调后,准确率可达到 89%。这些结果与 PyTorch 示例类似。
接下来,让我们关注物体检测——这是一个我们可以通过 TL 来解决的任务。
物体检测
物体检测是指在图像或视频中找到某一类别的物体实例,例如人、车、树木等。与分类不同,物体检测不仅可以检测多个物体,还可以识别它们在图像中的位置。
物体检测器会返回一份包含每个物体以下信息的检测对象列表:
-
物体的类别(例如:人、车、树木等)。
-
一个概率值(或物体性得分),范围在[0, 1]之间,表示检测器对该位置存在物体的信心。这类似于常规二分类器的输出。
-
图像中物体所在矩形区域的坐标。这个矩形被称为边界框。
我们可以在下图中看到物体检测算法的典型输出。物体类型和物体性得分位于每个边界框的上方:
https://en.wikipedia.org/wiki/File:2011_FIA_GT1_Silverstone_2.jpg](https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_2.jpg)
图 5.2 – 物体检测器的输出。来源:en.wikipedia.org/wiki/File:2011_FIA_GT1_Silverstone_2.jpg
接下来,我们将概述解决物体检测任务的不同方法。
物体检测方法
在这一部分中,我们将概述三种方法:
-
经典滑动窗口:在这里,我们将使用常规分类网络(分类器)。这种方法可以与任何类型的分类算法一起使用,但它相对较慢且容易出错。
-
构建图像金字塔:这是将同一图像的不同尺度组合在一起(见下图)。例如,每个缩放后的图像可以比前一个小两倍。通过这种方式,我们能够检测到原始图像中不同尺寸的物体。
-
在整个图像上滑动分类器:我们将图像的每个位置作为输入传递给分类器,结果将确定该位置的物体类型。位置的边界框就是我们用作输入的图像区域。
-
每个物体的多个重叠边界框:我们将使用一些启发式方法将它们合并为一个单一的预测。
-
这里有一张展示滑动窗口方法的图示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_3.jpg
图 5.3 – 滑动窗口加图像金字塔物体检测
-
两阶段检测方法:这些方法非常准确,但相对较慢。顾名思义,它涉及两个步骤:
-
一种特殊类型的 CNN,称为区域提议网络 (RPN),扫描图像并提出多个可能的边界框,或兴趣区域 (RoI),用于检测物体可能的位置。然而,该网络并不检测物体的类型,仅仅是判断该区域是否包含物体。
-
将 RoI 送到第二阶段进行物体分类,从而确定每个边界框中的实际物体。
-
-
一阶段(或单次)检测方法:在这种方法中,单个 CNN 同时输出物体类型和边界框。这些方法通常比两阶段方法速度更快,但准确度较低。
在接下来的部分,我们将介绍YOLO——一种精确高效的一阶段检测算法。
使用 YOLO 进行物体检测
YOLO 是最受欢迎的一阶段检测算法之一。其名称来源于流行的格言“你只活一次(You Only Live Once)”,这反映了算法的一阶段特性。自其首次发布以来,YOLO 已经历了多个版本,不同的作者参与其中。为了便于理解,我们将在此列出所有版本:
-
你只看一次:统一的实时物体检测 (
arxiv.org/abs/1506.02640
),作者:Joseph Redmon、Santosh Divvala、Ross Girshick 和 Ali Farhadi。 -
YOLO9000: 更好、更快、更强 (
arxiv.org/abs/1612.08242
),作者:Joseph Redmon 和 Ali Farhadi。 -
YOLOv3: 增量式改进 (
arxiv.org/abs/1804.02767
,github.com/pjreddie/darknet
),作者:Joseph Redmon 和 Ali Farhadi。 -
YOLOv4: 物体检测的最佳速度与精度 (
arxiv.org/abs/2004.10934
,github.com/AlexeyAB/darknet
),作者:Alexey Bochkovskiy、Chien-Yao Wang 和 Hong-Yuan Mark Liao。 -
YOLOv5 和 YOLOv8 (
github.com/ultralytics/yolov5
,github.com/ultralytics/ultralytics
),由 Ultralitics 提供 (ultralytics.com/
)。V5 和 v8 没有正式论文。 -
YOLOv6 v3.0: 全面重新加载 (
arxiv.org/abs/2301.05586
,github.com/meituan/YOLOv6
),作者:Chuyi Li、Lulu Li、Yifei Geng、Hongliang Jiang、Meng Cheng、Bo Zhang、Zaidan Ke、Xiaoming Xu 和 Xiangxiang Chu。 -
YOLOv7: 可训练的“免费赠品”集合创造了实时物体检测的新技术前沿 (
arxiv.org/abs/2207.02696
, Mark Lgithub.com/WongKinYiu/yolov7
),作者:Chien-Yao Wang、Alexey Bochkovskiy 和 Hong-Yuan Mark Liao。
注意
v3 是最后一个由算法的原作者发布的版本。v4 是 v3 的一个分支,由 v1-v3 的主要作者 Joseph Redmon(twitter.com/pjreddie/status/1253891078182199296
)支持发布。另一方面,v5 是一个独立的实现,灵感来自于 YOLO。这引发了关于 v5 名称的争议。你可以查看一些讨论,访问github.com/AlexeyAB/darknet/issues/5920
,其中 v4 的作者 Alexey Bochkovskiy 也进行了发帖。v5 的作者也在这里解决了争议:blog.roboflow.com/yolov4-versus-yolov5/
。不管这些讨论如何,v5 和 v8 已经证明其有效性,并且它们在各自的领域中是受欢迎的检测算法。
我们将讨论所有版本共享的 YOLO 特性,并指出其中的一些差异。
我们从 YOLO 架构开始:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_4.jpg
图 5.4 – YOLO 架构
它包含以下组件:
-
主干网络:这是一个 CNN 模型,负责从输入图像中提取特征。这些特征随后会传递给下一个组件进行目标检测。通常,主干网络是一个在 ImageNet 上预训练的 CNN,类似于我们在第四章中讨论的高级模型。
主干网络是迁移学习(TL)的一个例子——我们将一个用于分类的 CNN 拿来重新用于目标检测。不同版本的 YOLO 使用不同的主干网络。例如,v3 使用一个名为 DarkNet-53 的特殊全卷积 CNN,它有 53 层。随后的 YOLO 版本在此架构上进行了一些改进,而其他版本则使用了自己独特的主干网络。
-
颈部:这是模型的中间部分,连接主干网络和头部。它在将组合结果发送到下一个组件(头部)之前,将主干特征图在不同阶段的输出进行串联。这是标准方法的替代方案,标准方法是仅发送最后一个主干卷积的输出以进行进一步处理。为了理解颈部的必要性,让我们回顾一下我们的目标是围绕检测到的物体边缘创建一个精确的边界框。物体本身的大小可能相对图像来说很大或很小。然而,主干的深层接收域较大,因为它汇聚了所有前面层的接收域。因此,深层检测到的特征包含了输入图像的大部分。这与我们的精细物体检测目标相悖,无论物体的大小如何。为了解决这个问题,颈部在不同主干阶段结合特征图,从而使得不同尺度的物体都能被检测到。然而,每个主干阶段的特征图维度不同,不能直接组合。颈部应用不同的技术,例如上采样或下采样,以平衡这些维度,使它们能够串联。
-
头部:这是模型的最终组件,输出检测到的物体。每个检测到的物体通过其边界框坐标和类别来表示。
通过这些步骤,我们已经获得了 YOLO 架构的概览。但这并没有回答一些不太方便(但又令人好奇)的问题,例如模型如何在同一图像上检测多个物体,或者当两个或更多物体重叠且其中一个仅部分可见时会发生什么。为了找到这些问题的答案,我们引入下面的示意图,其中包含两个重叠的物体:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_5.jpg
图 5.5 – 一个物体检测 YOLO 示例,包含两个重叠的物体及其边界框
这是 YOLO 实现物体检测的步骤:
-
将输入图像分割成S×S个单元格(前面的示意图使用了一个 3×3 的网格):
-
一个单元格的中心代表一个区域的中心,该区域可能包含一个物体。
-
模型可以检测跨越多个单元格的物体,也可以检测完全位于单元格内的物体。每个物体都与一个单元格相关联,即使它跨越了多个单元格。在这种情况下,我们将物体与其边界框中心所在的单元格关联。例如,图中的两个物体跨越了多个单元格,但它们都分配给中央单元格,因为它们的中心位于其中。
-
一个单元格可以包含多个物体(1 对 n关系)或完全没有物体。我们只关注包含物体的单元格。
-
-
该模型为每个网格单元输出多个可能的检测物体。每个检测物体由以下值数组表示:https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/320.png。我们来讨论它们:
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/321.png 描述了物体的边界框。https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/322.png 是边界框中心相对于整张图像的坐标。它们被归一化到[0, 1]的范围内。例如,如果图像大小为 100×100,且边界框中心位于[40, 70]的位置,那么
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/323.png. https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/324.png 表示相对于整个图像的归一化边界框高度和宽度。如果边界框的尺寸是 80×50,那么 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/325.png 适用于相同的 100×100 图像。在实际操作中,YOLO 实现通常包括帮助方法,允许我们获取边界框的绝对坐标。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/326.png 是一个物体性分数,表示模型对单元格中是否存在物体的置信度(范围为 [0, 1])。如果 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/327.png 趋近于 1,则表示模型确信单元格中存在物体,反之亦然。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/328.png 是检测到的物体类别的独热编码。例如,如果我们有自行车、花、人物和鱼类,并且当前物体是人物,则它的编码将是 [0, 0, 1, 0]。
-
-
到目前为止,我们已经展示了模型可以检测同一图像上的多个物体。接下来,让我们聚焦于一个更复杂的情况——同一单元格中有多个物体。YOLO 在这个问题上提供了一个优雅的解决方案——锚框(也称为先验框)。为了理解这个概念,我们从下图开始,图中展示了网格单元(方形,实线)和两个锚框——垂直和水平(虚线):
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_6.jpg
图 5.6 – 一个网格单元(方形,实线)与两个锚框(虚线)
对于每个单元格,我们会有多个候选锚框,具有不同的尺度和纵横比。如果同一单元格内有多个物体,我们将每个物体与一个单独的锚框关联。如果某个锚框没有关联物体,它的物体性得分将为零 (https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/329.png)。我们可以检测到每个单元格内的锚框数量相等的物体。例如,我们的 3×3 网格,每个单元格有两个锚框,可以检测到总共 332 = 18 个物体。因为我们有固定数量的单元格 (S×S) 和每个单元格固定数量的锚框,网络输出的大小不会随着检测到的物体数量而变化。相反,我们会输出所有可能锚框的结果,但我们只会考虑那些物体性得分为https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/330.png的锚框。
- YOLO 算法在训练和推理过程中都使用交并比(IoU)技术来提高性能:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_7.jpg
图 5.7 – 交并比(IoU)
IoU 是检测到的对象的边界框与真实标签(或其他对象)的边界框交集的面积与并集的面积之比。
在训练过程中,我们可以计算锚框与真实框之间的 IoU。然后,我们可以将每个真实对象分配给其与之重叠度最高的锚框,从而生成标注的训练数据。此外,我们还可以计算检测到的边界框与真实框(标签框)之间的 IoU。IoU 值越高,表示真实值与预测值的重叠越好。这可以帮助我们评估检测器。
在推理过程中,模型的输出包括每个单元格的所有可能的锚框,无论其中是否存在对象。许多框会重叠并预测相同的对象。我们可以通过 IoU 和非最大抑制(NMS)来过滤这些重叠的对象。它是如何工作的:
-
丢弃所有对象性分数低于https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/331.png的边界框。
-
选择剩余框中具有最高对象性分数的框,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/326.png。
-
丢弃所有与我们在上一步中选择的框的 IoU >= 0.5 的框。
现在我们(希望)已经熟悉 YOLO,接下来让我们学习如何在实际中使用它。
使用 Ultralytics YOLOv8
在本节中,我们将演示如何使用由 Ultralytics 开发的 YOLOv8 算法。对于此示例,您需要安装ultralytics
Python 包。让我们开始:
-
导入 YOLO 模块。我们将加载一个预训练的 YOLOv8 模型:
from ultralytics import YOLO model = YOLO("yolov8n.pt")
-
使用
model
在 Wikipedia 图片上检测对象:results = model.predict('https://raw.githubusercontent.com/ivan-vasilev/Python-Deep-Learning-3rd-Edition/main/Chapter05/wikipedia-2011_FIA_GT1_Silverstone_2.jpg')
results
是一个列表,包含一个ultralytics.yolo.engine.results.Results
类的实例。该实例包含检测到的对象列表:它们的边界框、类别和对象性分数。 -
我们可以通过
results[0].plot()
方法来显示结果,它会将检测到的对象叠加在输入图像上。这个操作的结果就是我们在目标检测简介部分开始时展示的第一张图像:from PIL import Image Image.fromarray(results[0].plot()).show()
这就是我们对 YOLO 系列单次检测模型的介绍。接下来,我们将重点讲解一个流行的两次检测算法的示例。
使用 Faster R-CNN 进行目标检测
在本节中,我们将讨论Faster R-CNN(Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks,arxiv.org/abs/1506.01497
)的两阶段目标检测算法。它是早期两阶段检测器的演变,Fast R-CNN(Fast R-CNN,arxiv.org/abs/1504.08083
)和R-CNN(Rich feature hierarchies for accurate object detection and semantic segmentation,arxiv.org/abs/1311.2524
)。
Faster R-CNN 模型的一般结构在下图中概述:
https://arxiv.org/abs/1506.01497](https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_8.jpg)
图 5.8 – Faster R-CNN 的结构。来源:arxiv.org/abs/1506.01497
在解释算法时,我们要记住这个图。像 YOLO 一样,Faster R-CNN 首先使用一个在 ImageNet 上训练的主干分类网络,它作为模型不同模块的基础。最初,论文的作者尝试了经典的主干架构,如VGG-16(Very Deep Convolutional Networks for Large-Scale Image Recognition,arxiv.org/abs/1409.1556
)和ZFNet(Visualizing and Understanding Convolutional Networks,arxiv.org/abs/1311.2901
)。如今,该模型已提供更多现代化的主干,如 ResNet 和 MobileNet。
与 YOLO 不同,Faster R-CNN 没有颈部模块,只使用最后一个主干卷积层的特征图作为输入,供算法的下一个组件使用。更具体地说,主干网络作为模型其他两个组件(因此是两阶段)的支撑——区域提议网络(RPN)和检测网络。我们先来讨论一下 RPN。
区域提议网络
在第一阶段,RPN 将图像(任意大小)作为输入,并输出一组矩形 RoI,表示可能存在物体的位置。RoI 相当于 YOLO 中的边界框。RPN 本身是通过采用主干模型的前p个卷积层(参见前面的图示)来创建的。一旦输入图像传播到最后一个共享的卷积层,算法就会取该层的特征图,并在特征图的每个位置滑动另一个小型网络。这个小型网络输出是否在任意一个k个锚框中存在物体(锚框的概念与 YOLO 中的相同),以及其潜在的边界框坐标。下图左侧的图像展示了 RPN 在最后一个卷积层的特征图上滑动的一个位置:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_9.jpg
图 5.9 – 单一位置的 RPN 提议。来源:https://arxiv.org/abs/1506.01497
小型网络将跨所有输入特征图,在相同位置的n×n区域作为输入。
(n = 3,根据论文)。例如,如果最终卷积层有 512 个特征图,那么在某个位置上,小型网络的输入大小为 51233 = 4608。512 个 3×3 的特征图被展平为一个 4608 维的向量。这个向量作为输入传递给一个全连接层,该层将其映射到一个较低维度(通常为 512)的向量。这个向量本身作为输入传递给接下来的两个并行的全连接层:
-
一个具有2k单元的分类层,这些单元组织成k个 2 单元的二元 softmax 输出。像 YOLO 一样,每个 softmax 的输出表示在每个k个锚框中是否存在物体的目标性分数(在[0, 1]范围内)。在训练过程中,物体是根据 IoU 公式分配给锚框的,和 YOLO 中的方式一样。
-
一个回归层,具有4k单元,组织成k个 4 单元的 RoI 数组。像 YOLO 一样,第一个数组元素表示 RoI 中心的坐标,范围为[0:1],相对于整个图像。其他两个元素表示区域的高度和宽度,相对于整个图像(再次类似 YOLO)。
论文的作者实验了三种尺度和三种长宽比,结果在每个位置上得到了九种可能的锚框。最终特征图的典型H×W大小大约是 2,400,这样就得到了 2,400*9 = 21,600 个锚框。
RPN 作为跨通道卷积
理论上,我们将小型网络滑动到最后一层卷积的特征图上。然而,小型网络的权重在所有位置之间共享。因为这个原因,滑动可以被实现为跨通道卷积。因此,网络可以在一次图像传递中为所有锚框生成输出。这是对 Fast R-CNN 的改进,后者需要为每个锚框单独进行网络传递。
RPN 通过反向传播和随机梯度下降进行训练(真是令人惊讶!)。共享卷积层的权重使用预训练的骨干网络权重进行初始化,其余的权重则是随机初始化的。每个小批量的样本都从一张图片中提取。每个小批量包含相同数量的正样本(物体)和负样本(背景)锚框。有两种正标签的锚框:与真实框 IoU 重叠最高的锚框,和与任何真实框的 IoU 重叠超过 0.7 的锚框。如果锚框的 IoU 比率低于 0.3,则该框被分配为负标签。既不是正标签也不是负标签的锚框不会参与训练。
由于 RPN 具有两个输出层(分类和回归),因此训练使用以下复合代价函数,其中包含分类 (https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/333.png) 和回归 (https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/334.png) 部分:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/335.png
让我们讨论它的组成部分:
-
i:小批量中锚点的索引。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/336.png:分类输出,表示锚点 i 是物体还是背景的预测物体性得分。https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/337.png 是相同目标的实际数据(0 或 1)。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/338.png:回归输出向量,大小为 4,表示 RoI 参数。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/339.png:相同目标的目标向量。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/333.png:分类层的交叉熵损失。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/341.png:归一化项,等于小批量大小。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/342.png:回归损失,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/343.png,其中 R 是平均绝对误差(
en.wikipedia.org/wiki/Mean_absolute_error
)。 -
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/344.png:一个归一化项,等于锚点位置的总数(大约 2400)。
-
λ:这有助于将分类和回归组件结合到代价函数中。由于https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/345.png 和 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/346.png,λ 被设置为 10,以保持两者损失的平衡。
现在我们已经讨论了 RPN,让我们集中注意力于检测网络。
检测网络
让我们回到在基于 Faster R-CNN 的目标检测部分开头展示的图示。回想一下,在第一阶段,RPN 已经生成了 RoI 坐标及其目标性分数。检测网络是一个常规分类器,用于确定当前 RoI 中物体的类别。RPN 和检测网络共享它们的第一卷积层,这些层借用了背骨网络。此外,检测网络还整合了来自 RPN 的提议区域以及最后共享层的特征图。
但是,我们如何将背骨特征图和提议的区域以统一的输入格式结合起来呢?我们可以通过RoI 池化来实现,这也是检测网络第二部分的第一层:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_10.jpg
图 5.10 – 一个 2×2 RoI 池化示例,使用 10×7 的特征图和一个 5×5 的 RoI(粗体矩形)
为了理解 RoI 池化的工作原理,假设我们有一个 10×7 的特征图和一个 RoI。如同在区域提议网络部分中所学,RoI 是由其中心坐标、宽度和高度定义的。RoI 池化首先将这些参数转换为特征图上的实际坐标。在这个示例中,区域大小是 h×w = 5×5。RoI 池化进一步通过其输出的高度和宽度 H 和 W 来定义。在这个例子中,H×W = 2×2,但在实际中,这些值可能更大,比如 7×7。该操作将 h×w 的 RoI 分割成大小不同的子区域网格(图中通过不同的背景颜色显示)。完成此操作后,每个子区域通过获取该区域的最大值来下采样为单个输出单元。换句话说,RoI 池化可以将任意大小的输入转换为固定大小的输出窗口。这样,转换后的数据可以以一致的格式通过网络传播。
正如我们在基于 Faster R-CNN 的目标检测部分中提到的,RPN 和检测网络共享它们的初始层。然而,它们一开始是作为独立的网络存在的。训练在两者之间交替进行,采用四步过程:
-
训练 RPN,它使用背骨的 ImageNet 权重进行初始化。
-
训练检测网络,使用来自步骤 1 中刚训练好的 RPN 的提议。训练也从 ImageNet 背骨网络的权重开始。此时,两个网络并不共享权重。
-
使用检测网络共享的层来初始化 RPN 的权重。然后,再次训练 RPN,但冻结共享层,只微调特定于 RPN 的层。现在,两个网络共享它们的权重。
-
通过冻结共享层并仅微调特定于检测网络的层来训练检测网络。
现在我们已经介绍了 Faster R-CNN,接下来让我们讨论如何使用预训练的 PyTorch 模型来实际应用它。
使用 PyTorch 进行 Faster R-CNN
在本节中,我们将使用一个带有 ResNet50 骨干网的预训练 PyTorch Faster R-CNN 模型进行物体检测。PyTorch 原生支持 Faster R-CNN,这使得我们很容易使用它。本示例已使用 PyTorch 实现。此外,它还使用了torchvision
和opencv-python
包。我们只会包括代码的相关部分,但你可以在本书的 GitHub 仓库中找到完整版本。让我们开始:
-
使用最新的权重加载预训练模型。确保使用
DEFAULT
选项:from torchvision.models.detection import \ FasterRCNN_ResNet50_FPN_V2_Weights, \ fasterrcnn_resnet50_fpn_v2 model = fasterrcnn_resnet50_fpn_v2( weights=FasterRCNN_ResNet50_FPN_V2_Weights.DEFAULT)
-
我们将使用模型进行推断而不是训练,因此我们将启用
eval()
模式:model.eval()
-
使用
opencv-python
读取位于image_file_path
的 RGB 图像。如果图像文件在本地不存在,我们会省略从本书仓库下载图像的代码:import cv2 img = cv2.imread(image_file_path)
这里,
img
是一个三维的numpy
整数数组。 -
实现单步图像预处理管道。它将
img
numpy
数组转换为torch.Tensor
,该 Tensor 将作为模型的输入:import torchvision.transforms as transforms transform = transforms.ToTensor()
-
运行检测模型:
nn_input = transform(img) detected_objects = model([nn_input])
这里,
detected_objects
是一个包含三个项目的字典:-
boxes
:一个边界框的列表,由它们的左上角和右下角像素坐标表示 -
labels
:每个检测到的物体的标签列表 -
scores
:每个检测到的物体的物体性得分列表
-
-
使用初始的
img
数组和detected_objects
作为draw_bboxes
函数的参数,该函数会在原始输入图像上叠加边界框和它们的标签(draw_bboxes
的实现可以在完整示例中找到):draw_bboxes(img, detected_objects)
-
使用
opencv-python
显示结果:cv2.imshow("Object detection", img) cv2.waitKey()
输出图像如下所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_11.jpg
图 5.11 – 使用 Faster R-CNN 进行物体检测
我们现在已经熟悉了两种最流行的物体检测算法。在下一部分,我们将专注于下一个主要的计算机视觉任务,称为图像分割。
介绍图像分割
图像分割是将类标签(如人、自行车或动物)分配给图像中每个像素的过程。你可以将其视为像素级别的分类——而不是将整个图像分类为一个标签,我们会分别对每个像素进行分类。图像分割操作的输出被称为分割掩码。它是一个与原始输入图像具有相同维度的 Tensor,但每个像素不是用颜色表示,而是用它所属于的物体类别来表示。图像分割有两种类型:
-
语义分割:这种方法为每个像素分配一个类别,但不会区分物体实例。例如,下面图中的中间图像展示了一种语义分割掩码,其中每辆车的像素值是相同的。语义分割可以告诉我们某个像素属于某个物体,但不能区分不同的物体。
-
实例分割:这种方法为每个像素分配一个类别,并区分不同的物体实例。例如,下面图中的右侧展示了一种实例分割掩码,每辆车都被分割为独立的物体。
以下图示展示了语义分割和实例分割的例子:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_12.jpg
图 5.12 – 左:输入图像;中:语义分割掩码;右:实例分割掩码。来源:http://sceneparsing.csail.mit.edu/
为了训练分割算法,我们需要一种特殊类型的真实数据,其中每张图像的标签是图像的分割版本。
分割图像最简单的方法是使用我们在物体检测方法部分中描述的常见滑动窗口技术——即,我们使用一个常规分类器,并以步幅 1 在任一方向上滑动它。当我们得到某个位置的预测时,我们将取位于输入区域中心的像素,并将其分配给预测的类别。可以预见,这种方法非常慢,因为图像中像素的数量非常庞大(即便是 1,024×1,024 的图像,也有超过 100 万个像素)。幸运的是,还有更快速和更准确的算法,我们将在接下来的部分中讨论。
使用 U-Net 的语义分割
我们将讨论的第一个分割方法被称为U-Net(U-Net:用于生物医学图像分割的卷积网络,arxiv.org/abs/1505.04597
)。这个名称来源于网络架构的可视化:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_13.jpg
图 5.13 – U-Net 架构。来源:https://arxiv.org/abs/1505.04597
U-Net 是一种全卷积网络(FCN),之所以如此命名,是因为它仅包含卷积层,并且在输出端不使用任何全连接层。FCN 将整个图像作为输入,并在一次传递中输出其分割图。为了更好地理解这个架构,我们首先来澄清一下图示符号:
-
水平深蓝色箭头表示 3×3 的跨通道卷积,并应用 ReLU 激活函数。模型末端的单一浅蓝色箭头代表 1×1 的瓶颈卷积,用于减少通道数。
-
所有特征图都用蓝色框表示。特征图的数量显示在框的顶部,特征图的大小显示在框的左下角。
-
水平灰色箭头表示复制和裁剪操作(稍后会详细介绍)。
-
红色竖直箭头表示 2×2 最大池化操作。
-
竖直绿色箭头表示 2×2 上卷积(或转置卷积;参见第四章)。
我们可以将 U-Net 模型分为两个虚拟组件(实际上,这只是一个单一的网络):
-
编码器:网络的第一部分(U的左侧)类似于常规 CNN,但末尾没有全连接层。其作用是学习输入图像的高度抽象表示(这没有什么新意)。输入图像本身可以是任意大小,只要每次最大池化操作的输入特征图具有偶数(而非奇数)维度。否则,输出的分割掩模将被扭曲。默认情况下,输入大小为 572×572。接下来,它像常规 CNN 一样,交替进行卷积和最大池化层。编码器由四个相同的模块组成,每个模块包含两个连续的有效(未填充)卷积。
具有步幅 1 的 3×3 跨通道卷积,可选的批量归一化、ReLU 激活,以及 2×2 最大池化层。每个下采样步骤都会使特征图数量翻倍。最终的编码器卷积结束时会得到 1,024 个 28×28 的特征图。
-
解码器:网络的第二部分(U的右侧)与编码器对称。解码器接收最内层的 28×28 编码器特征图,并同时进行上采样,将其转换为 388×388 的分割图。它包含四个相同的上采样模块:
-
上采样使用 2×2 转置交叉通道卷积,步幅为 2。
-
每个上采样步骤的输出与相应编码器步骤的裁剪高分辨率特征图(灰色横向箭头)进行拼接。裁剪是必要的,因为每次未填充的编码器和解码器卷积都会丢失边缘像素。
-
每个转置卷积后跟随两个常规卷积,以平滑扩展后的表示。
-
上采样步骤会将特征图数量减半。最终输出使用 1×1 瓶颈卷积将 64 个分量的特征图张量映射到所需的类别数量(浅蓝色箭头)。论文的作者展示了医学图像中细胞的二分类分割。
-
网络的输出是对每个像素的分割掩模进行 softmax 处理——也就是说,输出中包含与像素数相等的独立 softmax 操作。某一像素的 softmax 输出决定了该像素的类别。U-Net 像常规分类网络一样进行训练。然而,损失函数是所有像素的 softmax 输出的交叉熵损失的组合。
我们可以看到,由于网络采用了无填充卷积,输出的分割图比输入图像小(388 对比 572)。然而,输出图并不是输入图像的缩放版本。相反,它与输入图具有一对一的比例,但仅覆盖输入图块的中心部分。这在下图中得到了说明:
https://arxiv.org/abs/1505.04597](https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_14.jpg)
图 5.14 – 用于分割大图像的重叠平铺策略。来源:arxiv.org/abs/1505.04597
无填充卷积是必要的,这样网络在分割图的边缘不会产生噪声伪影。这使得使用所谓的重叠平铺策略对任意大小的图像进行分割成为可能。输入图像被分割成重叠的输入图块,如前图左侧所示。右侧图像中小的亮区的分割图需要左侧图像中的大亮区(一个图块)作为输入。
下一个输入图块与前一个图块重叠,以使它们的分割图覆盖图像的相邻区域。为了预测图像边缘区域的像素,缺失的上下文通过镜像输入图像来推断。
我们不会实现 U-Net 的代码示例,但你可以查看 github.com/mateuszbuda/brain-segmentation-pytorch
来了解 U-Net 在大脑 MRI 图像分割中的应用。
使用 Mask R-CNN 进行实例分割
Mask R-CNN (arxiv.org/abs/1703.06870
) 是 Faster R-CNN 在实例分割方面的扩展。Faster R-CNN 为每个候选目标提供两个输出:边界框参数和类别标签。除了这些,Mask R-CNN 增加了第三个输出——一个 FCN,为每个 RoI 生成二进制分割掩码。下图展示了 Mask R-CNN 的结构:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_15.jpg
图 5.15 – Mask R-CNN 结构
分割路径和检测路径都使用 RPN 的 RoI 预测,但除此之外,它们是独立的,且是并行的。分割路径生成 I m×m 分割掩码,每个 RoI 对应一个。由于检测路径负责处理目标的分类,因此分割掩码是二进制的,并且与目标类别无关。分割后的像素会自动被分配到检测路径所预测的类别中。这与其他算法(如 U-Net)不同,后者将分割与分类结合,在每个像素上应用个别的 softmax。在训练或推理时,仅考虑与分类路径中预测目标相关的掩码,其余的会被丢弃。
Mask R-CNN 用更精确的 RoI align 层替换了 RoI 最大池化操作。RPN 输出锚框的中心及其高度和宽度,作为四个浮点数。然后,RoI 池化层将其转换为整数特征图单元格坐标(量化)。此外,将 RoI 划分为 H×W 网格(与 RoI 池化区域大小相同)也涉及量化。从使用 Faster R-CNN 进行目标检测章节中的 RoI 示例可以看出,这些网格的大小不同(3×3、3×2、2×3、2×2)。这两个量化级别可能会导致 RoI 与提取的特征之间的不对齐。下图展示了 RoI align 如何解决这个问题:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_16.jpg
图 5.16 – RoI align 示例。来源:https://arxiv.org/abs/1703.06870
虚线表示特征图的单元格。中间实线区域是覆盖在特征图上的 2×2 RoI。注意,它与单元格并不完全匹配,而是根据 RPN 预测的位置来定位的,没有进行量化。同样,RoI 的单元格(黑点)也不与特定的特征图单元格对齐。RoI align 操作通过双线性插值计算 RoI 单元格的值,涉及其相邻单元格。这样,RoI align 比 RoI pooling 更精确。
在训练中,如果一个 RoI 与地面真值框的 IoU 大于或等于 0.5,则为其分配正标签,否则为负标签。掩膜目标是 RoI 与其关联的地面真值掩膜的交集。只有正 RoI 会参与分割路径的训练。
使用 PyTorch 的 Mask R-CNN
在本节中,我们将使用一个预训练的 PyTorch Mask R-CNN 模型,搭载 ResNet50 主干网络进行实例分割。与 Faster R-CNN 类似,PyTorch 原生支持 Mask R-CNN。程序结构和要求与使用 PyTorch 的 Faster R-CNN章节中的相同。我们将只包含相关的代码部分,完整版本可以在本书的 GitHub 仓库中找到。让我们开始:
-
加载预训练模型并使用最新的权重,你可以通过选择
DEFAULT
选项来确保这一点:from torchvision.models.detection import \ maskrcnn_resnet50_fpn_v2, \ MaskRCNN_ResNet50_FPN_V2_Weights model = maskrcnn_resnet50_fpn_v2( weights=MaskRCNN_ResNet50_FPN_V2_Weights.DEFAULT)
-
我们将使用模型进行推理而不是训练,因此我们将启用
eval()
模式:model.eval()
-
使用
opencv-python
读取位于image_file_path
的 RGB 图像。如果本地没有图像,我们将省略从本书的仓库下载图像的代码:import cv2 img = cv2.imread(image_file_path)
这里,
img
是一个三维的numpy
整型数组。 -
实现单步图像预处理管道。它将
img
numpy
数组转换为torch.Tensor
,作为模型的输入:import torchvision.transforms as transforms transform = transforms.ToTensor()
-
运行检测模型:
nn_input = transform(image) segmented_objects = model([nn_input])
这里,
segmented_objects
是一个包含四个项的字典:boxes
、labels
、scores
和masks
。前三项与 Faster R-CNN 中的相同。masks
是一个形状为[number_of_detected_objects, 1, image_height, image_width]
的张量。对于每个检测到的物体,我们有一个覆盖整个图像的二进制分割掩码。每个这样的掩码在所有像素中都是零,除了物体被检测到的像素,其值为 1。 -
使用初始的
img
数组和segmented_objects
作为draw_segmentation_masks
函数的参数。它将检测到的物体的边界框、分割掩码和标签叠加到原始输入图像上(draw_segmentation_masks
的实现可以在完整示例中找到):draw_segmentation_masks(image, segmented_objects)
-
使用
opencv
显示结果:cv2.imshow("Object detection", img) cv2.waitKey()
输出图像如下所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_17.jpg
图 5.17 – 使用 Mask R-CNN 进行实例分割
我们现在已经讨论了目标检测和语义分割。在下一节中,我们将讨论如何使用 CNN 生成新的图像,而不仅仅是处理现有的图像。
使用扩散模型生成图像
到目前为止,我们使用神经网络作为判别模型。这仅仅意味着,在给定输入数据的情况下,判别模型将映射它到某个标签(换句话说,就是分类)。一个典型的例子是将 MNIST 图像分类到十个数字类别之一,其中神经网络将输入数据特征(像素强度)映射到数字标签。我们也可以用另一种方式说:判别模型给出的是y(类)给定x(输入)的概率。在 MNIST 的例子中,就是在给定图像的像素强度时,识别数字的概率。在下一节中,我们将介绍神经网络作为生成模型的应用。
介绍生成模型
生成模型学习数据的分布。从某种程度上来说,它是我们刚刚描述的判别模型的对立面。它预测给定类y时输入样本的概率 – https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/347.png。
例如,一个生成模型可以根据文本描述生成图像。通常,y 是张量,而不是标量。这个张量存在于所谓的潜在空间(或潜在特征空间)中,我们将其称为原始数据的潜在表示(或潜在空间表示),而原始数据本身存在于其自己的特征空间中。我们可以将潜在表示视为原始特征空间的压缩(或简化)版本。数字到类别的例子是这种范式的极端示例——毕竟,我们是在将整个图像压缩成一个数字。为了使潜在表示有效,它必须捕捉原始数据最重要的隐藏特征,并去除噪声。
由于其相对简单性,我们可以合理地期望我们对潜在空间的结构和属性有所了解。这与特征空间不同,后者复杂到超出我们的理解。因此,如果我们知道从潜在空间到特征空间的反向映射,我们就可以基于不同的潜在表示生成不同的特征空间表示(即图像)。更重要的是,我们可以通过有意识地修改初始潜在表示来影响输出图像的属性。
为了说明这一点,假设我们成功地创建了一个反向映射,将具有n=3元素的潜在向量与完整的车辆图像关联起来。每个向量元素表示一个车辆属性,例如长度、高度和宽度(如下面的图示所示):
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_18.jpg
图 5.18 – 特征空间-潜在空间与潜在空间-特征空间映射示例
假设平均车辆长度为四米。我们可以将这个属性表示为一个正态(高斯)分布(en.wikipedia.org/wiki/Normal_distribution
),均值为 4,从而使潜在空间变得连续(同样适用于其他属性)。然后,我们可以选择从每个属性的分布范围内采样新的值。它们将形成一个新的潜在向量(在这种情况下是一个潜在变量),我们可以将其作为种子来生成新的图像。例如,我们可以生成更长和更低的车辆。
(如前所示)。
注意
本书的第二版增加了关于基于神经网络的生成模型的整章内容,其中我们讨论了两个特别的架构:变分自编码器(VAE,Auto-Encoding Variational Bayes,arxiv.org/abs/1312.6114
)和生成对抗网络(GAN,arxiv.org/abs/1406.2661
)。当时,这些是用于图像生成的最先进的生成模型。从那时起,它们被一种新型算法——扩散模型所超越。为了与时俱进,本版中我们将省略 VAE 和 GAN,重点介绍扩散模型。
去噪扩散概率模型
扩散模型是一类特殊的生成模型,首次在 2015 年提出(深度无监督学习与非平衡热力学,arxiv.org/abs/1503.03585
)。在本节中,我们将重点介绍去噪扩散概率模型(DDPM,arxiv.org/abs/2006.11239
),它们构成了许多令人印象深刻的生成工具的基础,比如稳定扩散(github.com/CompVis/stable-diffusion
)。
DDPM 遵循与我们已讨论过的生成模型类似的模式:它从一个潜在变量开始,并使用它生成完整的图像。DDPM 的训练算法分为两个部分:
-
正向扩散:从初始图像开始,然后通过一系列小步骤逐渐向其中添加随机的高斯噪声(
en.wikipedia.org/wiki/Gaussian_noise
),直到最终(潜在)表示变成纯噪声。 -
反向扩散:这是正向过程的反向过程。它从纯噪声开始,并逐渐尝试恢复原始图像。
下图展示了正向(顶部)和反向(底部)扩散过程:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_19.jpg
图 5.19 – 正向(底部)和反向(顶部)扩散过程。来源:https://arxiv.org/abs/2006.11239
让我们详细讨论一下:
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/348.png:来自原始特征空间的初始图像,表示为张量。
-
T:正向和反向过程中的步骤数。最初,作者使用了T=1000。最近,提出了T=4000(改进的去噪扩散概率模型)。每个正向或反向步骤都会添加或去除少量噪声。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/349.png:前向扩散的最终结果,表示纯噪声。我们可以把https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/349.png看作https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/351.png的一个特殊潜在表示。这两个张量具有相同的维度,与我们在引入生成 模型部分讨论的例子不同。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/352.png(注意小写的t):在一个中间步骤中的噪声增强张量,t。它的维度与https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/348.png和https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/349.png相同。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/355.png:这是前向扩散过程在一个中间步骤t的概率密度函数(PDF)。PDF 听起来很复杂,但其实并不是。它的意思就是我们给已经有噪声的张量添加少量的高斯噪声,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/356.png,生成一个新的、更有噪声的张量,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/357.png(https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/357.png是依赖于https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/359.png)。前向扩散过程不涉及机器学习或神经网络,也没有可学习的参数。我们只是加了噪声,仅此而已。然而,它表示的是从原始特征空间到潜在表示空间的映射。
请注意,我们需要知道 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/360.png 来生成 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/361.png,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/362.png,以此类推——也就是说,我们需要所有的张量 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/364.png 来生成 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/365.png。幸运的是,作者提出了一种优化方法,使我们能够仅使用初始张量 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/348.png 来推导出任何 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/352.png 的值。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/368.png(1)
这里,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/369.png 是一个系数,它会根据预定的时间表发生变化,但通常随着 t 的增加而增大。ϵ 是与https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/352.png同样大小的高斯随机噪声张量。平方根确保新的https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/357.png仍然遵循高斯分布。我们可以看到,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/357.png是https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/373.png和ϵ的混合,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/374.png决定了两者之间的平衡。如果https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/375.png,那么https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/376.png将占据更多权重。当https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/377.png时,噪声ϵ将占据主导地位。由于这种优化,我们并没有进行真正的多步前向扩散过程。相反,我们在一步操作中生成所需的噪声表示,位于步骤 t 的https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/352.png。
- https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/379.png:这是反向扩散过程在中间步骤 t-1 时的 PDF。这是https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/380.png的对立函数。它是从潜在空间映射到原始特征空间的过程——也就是说,我们从纯噪声张量https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/381.png开始,逐渐去除噪声,直到达到原始图像,整个过程需要 T 步。与在前向阶段中仅将噪声添加到图像相比,反向扩散要复杂得多。这也是将去噪过程分成多个步骤,并在初期引入少量噪声的主要原因。我们的最佳机会是训练一个神经网络(NN),希望它能学习到潜在空间与原始特征空间之间实际映射的合理近似。因此,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/382.png 是一个神经网络,其中 θ 索引表示其权重。作者们提出了一种 U-Net 类型的网络。它以噪声张量https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/365.png为输入,并输出它对原始图像中加入的噪声(即仅噪声,而非图像本身)的近似值,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/384.png。输入和输出张量具有相同的维度。DDPM 比原始的 U-Net 稍晚发布,因此它们的神经网络架构在这期间引入了一些改进。这些改进包括残差块、组归一化(一种批量归一化的替代方法)、以及 注意力机制 (Attention Is All You Need,
arxiv.org/abs/1706.03762
),arxiv.org/abs/1803.08494
) 和 注意力机制 (Attention Is All You Need,arxiv.org/abs/1706.03762
)。
接下来,我们聚焦于 DDPM 训练,以下图中的左侧所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_05_20.jpg
图 5.20 – DDPM 训练(左);DDPM 采样(右)。来源:https://arxiv.org/abs/2006.11239
一个完整的训练过程包括以下步骤(第 1 行):
-
从训练集中的随机样本(图像)开始,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/348.png(第 2 行)。
-
在区间[1:T]内采样随机噪声步长,t(第 3 行)。
-
从高斯分布中采样随机噪声张量,ϵ(第 4 行)。在神经网络本身中,步长 t 被通过 正弦位置嵌入方式嵌入到ϵ的值中。如果你不理解位置嵌入的概念,不必担心。我们将在第七章中详细讨论它,因为它最早是在该上下文中引入的。现在我们需要知道的是,步长 t 被隐式编码在ϵ的元素中,方式使得模型能够利用这些信息。步长调整的噪声在前图中以https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/386.png表示。
-
基于初始图像,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/387.png,生成一个损坏的图像张量,该图像以初始图像和根据采样的噪声步长,t,以及随机噪声,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/389.png为条件。为此,我们将使用前面在本节中介绍的公式(1)。感谢它,这一步构成了整个正向扩散阶段(第 5 行)。
-
执行一次梯度下降步骤和权重更新。训练过程中使用均方误差(MSE)。它衡量采样噪声ϵ(第 4 行)与模型预测的噪声之间的差异,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/390.png(第 5 行)。损失方程看起来 deceptively 简单。论文的作者做了长链的变换和假设,才得出这个简单的结果。这是论文的主要贡献之一。
一旦模型经过训练,我们就可以使用它基于随机初始张量,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/349.png,来采样新的图像。我们可以通过以下过程实现这一点(前面的图示,右侧):
-
从高斯分布中采样初始随机潜变量张量,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/349.png(第 1 行)。
-
重复接下来的步骤T次(第 2 行):
-
从高斯分布中采样随机噪声张量,z(第 3 行)。我们为所有反向步骤执行此操作,除了最后一步。
-
使用训练好的 U-Net 模型预测噪声,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/384.png,在步骤t时。将此噪声从当前样本中减去,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/357.png,得到新的、较少噪声的,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/395.png(第 4 行)。调度系数,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/396.png,
该公式也参与了此过程,正如在前向阶段一样。该公式还保留了原始分布的均值和方差。
-
-
最终的去噪步骤生成了图像。
这部分是我们对 DDPM 的介绍,暂时到此为止。然而,我们将在第九章中再次回顾它们,但会在稳定扩散的背景下进行讨论。
总结
在本章中,我们讨论了一些高级计算机视觉任务。我们从 TL 开始,这是一种通过预训练模型帮助启动实验的技术。我们还介绍了对象检测和语义分割模型,这些模型受益于 TL。最后,我们重点介绍了生成模型,特别是 DDPM。
在下一章中,我们将介绍语言建模与递归网络。
第三部分:
自然语言处理与变换器
本部分将以自然语言处理的介绍开始,为我们关于递归网络和变换器的讨论提供背景。变换器将是本节的主要焦点,因为它们代表了近年来深度学习领域的重大进展之一。它们是大型语言模型(LLM)的基础,例如 ChatGPT。我们将讨论它们的架构以及它们的核心元素——注意力机制。接着,我们将讨论 LLM 的特性。最后,我们将重点介绍一些高级 LLM 应用,如文本和图像生成,并学习如何构建以 LLM 为核心的应用。
本部分包括以下章节:
-
第六章,自然语言处理与递归神经网络
-
第七章,注意力机制与变换器
-
第八章,深入探索大型语言模型
-
第九章,大型语言模型的高级应用
第六章:自然语言处理和循环神经网络
本章将介绍两个不同但互补的主题——自然语言处理(NLP)和循环神经网络(RNNs)。NLP 教会计算机处理和分析自然语言文本,以执行诸如机器翻译、情感分析和文本生成等任务。与计算机视觉中的图像不同,自然文本代表了一种不同类型的数据,其中元素的顺序(或序列)非常重要。幸运的是,RNNs 非常适合处理顺序数据,如文本或时间序列。通过在这些序列上定义递归关系(因此得名),它们帮助我们处理可变长度的序列。这使得 NLP 和 RNNs 成为天然的盟友。事实上,RNNs 可以应用于任何问题,因为已经证明它们是图灵完备的——从理论上讲,它们可以模拟任何常规计算机无法计算的程序。
然而,这并不全是好消息,我们需要从一个免责声明开始。尽管 RNNs 具有很好的理论特性,但我们现在知道它们在实际应用中有一定的局限性。这些局限性大多已经被一种更新的神经网络(NN)架构——transformer克服,我们将在第七章中讨论它。从理论上讲,transformer 相比 RNNs 有更多的限制。但有时候,实践证明它表现得更好。尽管如此,我相信本章对你仍然是有益的。一方面,RNNs 具有优雅的架构,仍然代表着神经网络中的重要一类;另一方面,本章和接下来的三章所呈现的知识进展,将与这些主题在实际研究中的进展紧密相符。因此,你将在接下来的几章中也能应用这里学到的概念。本章还将帮助你充分理解新模型的优势。
本章将涵盖以下主题:
-
自然语言处理
-
介绍 RNNs
技术要求
我们将在本章中使用 Python、PyTorch 和 TorchText 包(github.com/pytorch/text
)来实现示例。如果你没有配置这些工具的环境,不必担心——该示例可以在 Google Colab 上的 Jupyter Notebook 中运行。你可以在本书的 GitHub 仓库中找到代码示例:github.com/PacktPublishing/Python-Deep-Learning-Third-Edition/tree/main/Chapter06
。
自然语言处理
NLP 是机器学习的一个子领域,使计算机能够解释、操作和理解人类语言。这个定义听起来有点枯燥,因此,为了提供一些清晰度,让我们从一个非详尽的任务列表开始,看看都有哪些任务属于 NLP 的范畴:
-
文本分类:这会为整个输入文本分配一个标签。例如,情感分析可以判断一篇产品评论是积极的还是消极的。
-
标记分类:这为每个输入文本的标记分配一个标签。标记是文本的构建块(或单位)。单词可以是标记。一个流行的标记分类任务是命名实体识别,它为每个标记分配一个预定义类别列表,如地点、公司或人物。词性(POS)标注为每个单词分配一个特定的词性,如名词、动词或形容词。
-
文本生成:这是利用输入文本生成具有任意长度的新文本。文本生成任务包括机器翻译、问答和文本摘要(在保留原文精髓的同时创建简短版本)。
解决自然语言处理(NLP)问题并非易事。为了理解其原因,我们先回顾一下计算机视觉(第四章),其中输入的图像以像素强度的二维张量表示,具有以下特点:
-
图像由像素构成,并且没有其他显式定义的结构
-
像素基于彼此的接近度,形成了隐式的更大物体的层次结构
-
只有一种类型的像素,其仅由标量强度来定义
由于其同质化的结构,我们可以将(几乎)原始的图像输入到卷积神经网络(CNN)中,让它以相对较少的数据预处理做出处理。
现在,让我们回到文本数据,它具有以下特点:
-
有不同类型的字符,具有不同的语义意义,如字母、数字和标点符号。此外,我们还可能遇到以前未见过的符号。
-
自然文本有着显式的层次结构,包括字符、单词、句子和段落。我们还有引号、标题和层次结构的标题。
-
文本的某些部分可能与序列中较远的部分有关,而不是它们的直接上下文。例如,一篇虚构故事可能会先介绍一个人名,但随后只用他或她来提及。这些指代可能被长篇文本序列分隔开,但我们仍然需要能够找到这种关系。
自然文本的复杂性要求在实际神经网络模型发挥作用之前,进行几步预处理。第一步是归一化,包括去除多余的空白字符和将所有字母转换为小写。接下来的步骤并不像前面那样简单,因此我们将专门用接下来的两节来讨论这些步骤。
分词
一种直观的处理自然语言处理任务的方法是将语料库拆分为单词,这些单词将代表我们模型的基本输入单元。然而,使用单词作为输入并不是固定不变的,我们还可以使用其他元素,比如单个字符、短语,甚至整个句子。这些单元的通用术语是标记。标记指代文本语料库的方式,就像像素指代图像一样。将语料库拆分成标记的过程被称为标记化(真是意外!)。实体
(例如,执行这种标记化的算法)称为标记器。
注意
我们将在本节中讨论的标记器是通用的,意味着它们可以与不同的自然语言处理机器学习算法配合使用。因此,本节中的预处理算法通常用于变换器模型,我们将在第七章中介绍这些模型。
接下来,让我们讨论几种标记器的类型:
-
基于词:每个单词代表一个独特的标记。这是最直观的标记化方式,但也有严重的缺点。例如,单词don’t和do not将被表示为不同的标记,但它们的含义是相同的。另一个例子是单词car和cars,或ready和readily,它们会被表示为不同的标记,而一个单一的标记会更合适。由于自然语言如此多样,像这样的特殊情况非常多。问题不仅仅在于语义相似的单词会有不同的标记,还在于由此产生的大量唯一标记。这会导致模型计算效率低下。它还会产生许多出现次数较少的标记,这对模型的学习来说是一个挑战。最后,我们可能会遇到在新文本语料库中无法识别的单词。
-
基于字符:文本中的每个字符(字母、数字、标点符号等)都是一个独特的标记。通过这种方式,我们可以减少标记数量,因为字符的总数是有限的并且是有限的。由于我们事先知道所有的字符,因此不会遇到未知的符号。
然而,与基于词的模型相比,这种标记化方法不太直观,因为由字符组成的上下文比基于词的上下文意义较小。虽然唯一标记的数量相对较少,但语料库中的标记总数将非常庞大(等于字符总数)。
-
子词标记化:这是一个两步过程,首先将语料库分割成单词。分割文本最明显的方式是通过空格。此外,我们还可以通过空格和标点符号来分割文本。在自然语言处理术语中,这一步骤被称为预标记化。
(前缀意味着接下来会进行标记化)。然后,它保留常用词,并将稀有词拆解为更频繁的有意义子词。例如,我们可以将单词tokenization分解为核心词token和后缀ization,每个部分都有自己的标记。然后,当我们遇到carbonization这个词时,我们可以将其分解为carbon和ization。这样,我们会得到两个ization的实例,而不是一个tokenization和一个carbonization。子词标记化还使得可以将未知词分解为已知标记。
特殊服务标记。
为了使标记化的概念起作用,它引入了一些服务性标记。以下是一些服务性标记:
-
UNK:替换语料库中的未知标记(可以理解为稀有词汇,如字母数字标识符)。
-
EOS:句子(或序列)结束标记。
-
BOS:句子(或序列)开始标记。
-
SEP:用来分隔两个语义上不同的文本序列,例如问题和答案。
-
PAD:这是一个填充标记,它会附加到现有序列中,以便它可以达到某个预定义长度并适应固定长度的小批次。
例如,我们可以将句子I bought a product called FD543C标记化为BOS I bought a product called UNK EOS PAD PAD,以适应长度为 10 的固定输入。
子词标记化是最流行的标记化方式,因为它结合了基于字符(较小的词汇量)和基于词语(有意义的上下文)标记化的最佳特性。在接下来的几个部分中,我们将讨论一些最流行的子词标记器。
字节对编码和 WordPiece。
字节对编码(BPE,使用子词单元进行稀有词的神经机器翻译,arxiv.org/abs/1508.07909
)是一种流行的子词标记化算法。与其他此类标记器一样,它从预标记化开始,将语料库拆分为单词。
以这个数据集为起点,BPE 的工作方式如下:
-
从初始的基础(或种子)词汇开始,该词汇由文本语料库中所有单词的单个字符组成。因此,每个单词都是一系列单字符标记。
-
重复以下步骤,直到标记词汇的大小达到某个最大阈值:
-
找出最常一起出现的一对标记(最初这些是单个字符),并将它们合并成一个新的复合标记。
-
使用新的复合标记扩展现有的标记词汇。
-
使用新的标记结构更新标记化的文本语料库。
-
为了理解 BPE,让我们假设我们的语料库包含以下(虚构的)单词:{dab: 5, adab: 4, aab: 7, bub: 9, bun: 2}
。每个单词后面的数字表示该单词在文本中出现的次数。以下是相同的语料库,但已经按符号(即字符)拆分:{(d, a, b): 5, (a, d, a, b): 4, (a, a, b): 7, (b, u, b): 9, (b, u, c): 2}
。基于此,我们可以构建我们的初始符号词汇表,每个符号的出现次数为:{b: 36, a: 27, u: 11, d: 9, c: 2}
。以下列表展示了前四次合并操作:
-
最常见的符号对是
(a, b)
,其出现次数为freq((a, b)) = 5 + 4 + 7 = 16
次。因此,我们将它们合并,语料库变为{(d,
): 5, (a, d,
): 4, (a,
): 7, (b, u, b): 9, (b, u, c): 2}
。新的符号词汇表是{b: 20,
: 16, a: 11, u: 11, d: 9,
c: 2}
。 -
新的最常见的符号对是
(b, u)
,其freq((b, u)) = 9 + 2 = 11
次出现。接着,我们将它们合并为一个新的符号:{(d, ab): 5, (a, d, ab): 4, (a, ab): 7, (``, b): 9, (``, c): 2}
。更新后的符号词汇表是{ab: 16, a: 11,
: 11, b: 9, d: 9,
c: 2}
。 -
下一个符号对是
(d, ab)
,其出现次数为freq((d, ab)) = 5 + 4 = 9
次。合并后,符号化的语料库变为{(``): 5, (a,
): 4, (a, ab): 7, (bu, b): 9, (bu, c): 2}
。新的符号词汇表是{a: 11, bu: 11, b: 9,
: 9, ab: 7,
c: 2}
。 -
新的符号对是
(bu, b)
,其出现次数为 9 次。将它们合并后,语料库变为{(dab): 5, (a, dab): 4, (a, ab): 7, (``): 9, (bu, c): 2}
,而符号词汇表变为
{a: 11,
: 9,
: 9, ab: 7, bu: 2,
c: 2}
。
BPE 会存储所有符号合并规则及其顺序,而不仅仅是最终的符号词汇表。在模型推理过程中,它会按照相同的顺序将规则应用于新的未知文本,以对其进行符号化。
词尾符号
原始 BPE 实现会在每个单词的末尾添加一个特殊的词尾符号<w/>
,例如,单词aab
变为aab<w/>
。其他实现可以将该特殊符号放在单词的开头,而不是末尾。这使得算法能够区分,例如,单词ca<w/>
中的符号ab
,与a``<w/>
中的相同符号。因此,算法可以从符号化后的语料库恢复出原始语料库(去符号化),否则是无法做到的。本节中,为了简洁起见,我们省略了词尾符号。
让我们回顾一下,我们的基础词汇表包括文本语料库中的所有字符。如果这些是 Unicode 字符(这是通常的情况),我们最终可能会得到一个最多包含 150,000 个词汇的词汇表。而且这还只是我们开始词汇合并过程之前的情况。解决这个问题的一个技巧是借助 字节级 BPE。每个 Unicode 字符可以使用多个(最多 4 个)字节进行编码。字节级 BPE 最初将语料库拆分为字节序列,而不是完整的 Unicode 字符。如果一个字符使用 n 个字节编码,分词器将把它当作 n 个单字节词汇进行处理。通过这种方式,基础词汇表的大小将始终为 256(字节中可以存储的最大唯一值)。此外,字节级 BPE 保证我们不会遇到未知的词汇。
WordPiece (arxiv.org/abs/1609.08144
) 是另一种子词分词算法。它与 BPE 相似,但有一个主要区别。像 BPE 一样,它从单个字符的基础词汇表开始,然后将它们合并成新的复合词汇。然而,它根据一个得分来定义合并顺序,得分通过以下公式计算(与使用频繁共现的 BPE 不同):
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/397.png
通过这种方式,算法优先合并那些在语料库中出现频率较低的词对。让我们将这种方法与 BPE 进行比较,BPE 仅根据新词汇的潜在增益来合并词汇。相比之下,WordPiece 在增益(公式中的分子)和现有词汇的潜在损失(分母)之间进行平衡。这是有道理的,因为新词汇将取代旧的词对,而不是与它们并存。
内部词汇
WordPiece 为单词中的所有标记添加一个特殊的 ## 前缀,除了第一个。例如,它会将单词 aab 标记为 [a, ##a, ##b]
。标记合并会去掉标记之间的 ##。因此,当我们合并 ##a 和 ##b 时,aab 会变成 [``a, ##ab]
。
与 BPE 不同,WordPiece 只存储最终的标记词汇。当它对新词进行标记时,它会在词汇中找到最长的匹配子词,并在此处分割单词。例如,假设我们想用标记词汇 [a, ##b, ##c, ##d, ab, ##cd, ##bcd]
来分割单词 abcd。根据新规则,WordPiece 会首先选择最长的子词 bcd,然后将 abcd 标记为 [``a, ##bcd]
。
BPE 和 WordPiece 都是贪心算法——它们总是根据频率标准,确定性地合并标记。然而,使用不同的标记对相同的文本序列进行编码是可能的。这可能作为潜在 NLP 算法的正则化方法。接下来,我们将介绍一种利用这一点的标记化技术。
Unigram
与 BPE 和 WordPiece 不同,Unigram(子词正则化:通过多个子词候选改进神经网络翻译模型,arxiv.org/abs/1804.10959
)算法从一个大词汇表开始,并逐步尝试将其缩减。初始词汇表是所有独特字符和语料库中最常见子串的并集。找到最常见子串的一种方法是使用 BPE。该算法假设每个标记,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/113.png,是独立发生的(因此得名 Unigram)。基于这一假设,一个标记,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/399.png,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/400.png,的概率就是它出现的次数除以语料库其他部分的总大小。然后,长度为 M 的标记序列,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/401.png,的概率如下:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/402.png
这里,V 是完整的标记词汇表。
假设我们有相同的令牌序列,X,并且有多个令牌分割候选项,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/403.png,
对于该序列。最可能的分割候选项,x*,对于X如下:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/404.png
让我们通过一个例子来澄清这一点。我们假设我们的语料库包含一些(假想的)单词,{dab: 5, aab: 7, bun: 4}
,其中数字表示该单词在文本中的出现次数。我们的初始令牌词汇是所有唯一字符和所有可能子字符串的并集(数字表示频率):{a: 19, b: 16, ab: 12, aa: 7, da: 5, d: 5, bu: 4, un: 4}
。所有令牌频率的总和为 19 + 16 + 12 + 7 + 5 + 5 + 4 + 4 = 72。然后,每个令牌的独立概率为 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/405.png – 例如,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/406.png,
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/407.png,等等。
我们扩展的词汇表使我们能够以多种方式对每个序列(为了简化起见,我们将重点放在单词上)进行分词。例如,我们可以将dab表示为{d, a, b}
、{da, b}
或{d, ab}
。在这里,每个候选项的概率为 P({d, a, b}) = P(d) * P(a) * P(b) = 0.07 * 0.264 * 0.222 = 0.0041;https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/408.png;https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/409.png。
概率最高的候选项是*x** = {da, b}
。
基于此,以下是单元字(token)分词法的逐步实现过程:
-
从初始的大型基础词汇表V开始。
-
重复以下步骤,直到|V|的大小达到某个最小阈值:
-
使用维特比算法(
en.wikipedia.org/wiki/Viterbi_algorithm
),找到语料库中所有单词的l最佳分词候选项x**。使用此算法是必要的,因为这是一项计算密集型任务。选择l个候选项,而不是一个,使得可以在相同文本上采样不同的词元序列。你可以将这看作是对输入数据的一种数据增强技术,它为 NLP 算法提供了额外的正则化。一旦我们以这种方式得到了一个分词后的语料库,就可以利用期望最大化算法(en.wikipedia.org/wiki/Expectation%E2%80%93maximization_algorithm
)估计当前词汇表V*中所有词元的概率https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/410.png。 -
对于每个标记,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/113.png,计算一个特殊的损失函数,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/412.png,它确定如果我们从标记词汇中移除https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/115.png,语料库的概率如何减少。
-
按照它们的https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/414.png排序,并只保留前n%的标记(例如,n = 80)。始终保留个别字符,以避免未知标记。
-
这就结束了我们对分词的介绍。这些技术中的一些是在 Transformer 架构出现时发展起来的,我们将在接下来的章节中充分利用它们。但现在,让我们集中讨论 NLP 管道中的另一项基础技术。
引入词嵌入
现在我们已经学会了如何对文本语料库进行分词,我们可以继续 NLP 数据处理管道中的下一步。为了简便起见,我们假设我们已将语料库分割成单词,而不是子词或字符(在本节中,单词和标记是可以互换的)。
将序列中的词作为输入传递给 NLP 算法的一种方法是使用独热编码。我们的输入向量的大小将与词汇中标记的数量相同,每个标记将具有唯一的独热编码表示。然而,这种方法有一些缺点,如下所示:
-
稀疏输入:独热编码表示大多数值为零,只有一个非零值。如果我们的 NLP 算法是神经网络(而且确实如此),这种类型的输入每个词只会激活其权重的一小部分。因此,我们需要一个大规模的训练集,以包含每个词汇中足够数量的训练样本。
-
计算强度:词汇的庞大规模将导致输入张量很大,这需要更大的神经网络和更多的计算资源。
-
不切实际:每次我们向词汇表中添加一个新单词时,词汇表的大小会增加。然而,独热编码的输入大小也会增加。因此,我们必须改变神经网络的结构以适应新的大小,并且需要进行额外的训练。
-
缺乏上下文:像dog和wolf这样的单词在语义上是相似的,但独热编码表示无法传达这种相似性。
在本节中,我们将通过低维分布式表示法来解决这些问题,这种表示被称为词嵌入(神经概率语言模型,www.jmlr.org/papers/volume3/bengio03a/bengio03a.pdf
)。分布式表示是通过学习一个嵌入函数来创建的,该函数将独热编码的单词转化为低维的词嵌入空间,具体如下:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_1.jpg
图 6.1 – 词汇 -> 独热编码 -> 词嵌入向量
从词汇表中,大小为V的单词被转化为大小为V的独热编码向量。然后,嵌入函数将这个V维空间转化为一个固定大小的分布式表示(向量),D(这里,D=4)。这个向量作为输入传递给 NLP 算法。我们可以看到,固定且较小的向量大小解决了我们刚才提到的稀疏性、计算强度和不切实际的问题。接下来,我们将看到它是如何解决上下文问题的。
嵌入函数学习关于单词的语义信息。它将词汇表中的每个单词映射到一个连续值向量表示——即词嵌入。每个单词在这个嵌入空间中对应一个点,不同的维度对应这些单词的语法或语义属性。嵌入空间的概念类似于潜在空间表示,我们在第五章中首次讨论了这一点,涉及到扩散模型。
目标是确保在嵌入空间中彼此接近的词语具有相似的含义。这里所说的接近是指它们的嵌入向量的点积(相似度)值较高。通过这种方式,某些词语在语义上相似的信息可以被机器学习算法利用。例如,它可能会学到fox和cat在语义上是相关的,并且the quick brown fox和the quick brown cat都是有效的短语。然后,一个词语序列可以被一组嵌入向量所替代,这些向量捕捉了这些词语的特征。我们可以将这个序列作为各种自然语言处理(NLP)任务的基础。例如,试图对文章情感进行分类的分类器,可能会基于之前学到的词嵌入进行训练,而不是使用独热编码向量。通过这种方式,词语的语义信息可以轻松地为情感分类器所用。
独热表示与嵌入向量之间的映射
假设我们已经计算出了每个词元的嵌入向量。一种实现一热编码表示与实际嵌入向量之间映射的方法是借助一个V×D形状的矩阵,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/415.png。我们可以把矩阵的行看作查找表,其中每一行代表一个词的嵌入向量。这个过程之所以可行,是因为输入的词是经过一热编码的,这个向量中除了对应词的索引位置是 1 外,其它位置全为 0。因此,输入的词,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/416.png,将仅激活其对应的唯一行(向量)权重,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/417.png,位于https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/418.png中。因此,对于每一个输入样本(词),只有该词的嵌入向量会参与计算。我们还可以把https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/415.png看作一个全连接(FC)神经网络层的权重矩阵。通过这种方式,我们可以将嵌入(明白了吗?)作为神经网络的第一层 —— 即,神经网络将一热编码的词元作为输入,嵌入层将其转换为一个向量。然后,神经网络的其余部分使用嵌入向量而不是一热编码表示。这是所有深度学习库中常见的标准实现。
词嵌入的概念最早是在 20 多年前提出的,但至今仍是自然语言处理领域的核心范式之一。大型语言模型(LLMs),例如 ChatGPT,使用的是改进版的词嵌入,我们将在第七章中讨论。
现在我们已经熟悉了嵌入向量,我们将继续进行获取和计算嵌入向量的算法。
Word2Vec
很多研究都致力于创建更好的词嵌入模型,特别是通过省略对单词序列的概率函数学习来实现。其中一种最流行的方法是Word2Vec (papers.nips.cc/paper/5021-distributed-representations-of-words-and-phrases-and-their-compositionality.pdf
, https://arxiv.org/abs/1301.3781, 和 https://arxiv.org/abs/1310.4546)。它基于目标词的上下文(周围单词)创建嵌入向量。更具体地说,上下文是目标词前后的n个单词。下图展示了上下文窗口在文本中滑动,围绕不同的目标词:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_2.jpg
图 6.2 – 一个 Word2Vec 滑动上下文窗口,n=2。相同类型的上下文窗口适用于 CBOW 和 skip-gram
Word2Vec 有两种版本:连续词袋模型 (CBOW) 和 skip-gram。我们将从 CBOW 开始,然后继续讨论 skip-gram。
CBOW
CBOW 根据上下文(周围单词)预测最可能的词。例如,给定序列 the quick _____ fox jumps,模型将预测 brown。它对上下文窗口内的所有单词赋予相等的权重,并且不考虑它们的顺序(因此名字中有“bag”)。我们可以借助以下简单的神经网络进行训练,该网络包含输入层、隐藏层和输出层:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_3.jpg
图 6.3 – 一个 CBOW 模型神经网络
下面是模型的工作方式:
-
输入是一个独热编码的单词表示(其长度等于词汇表大小,V)。
-
嵌入向量由输入到隐藏矩阵表示,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/420.png。
-
所有上下文单词的嵌入向量被平均以产生隐藏网络层的输出(没有激活函数)。
-
隐藏层激活值作为输入传递给Softmax输出层,大小为V(与隐藏到输出的权重矩阵,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/421.png),用于预测最可能出现在输入词汇上下文(邻近)中的词汇。具有最高激活值的索引表示最相关的单词,采用独热编码表示。
我们将使用梯度下降和反向传播训练神经网络。训练集包含的是(上下文和标签)一对一的独热编码单词对,这些单词在文本中彼此接近。例如,如果文本的一部分是 [the, quick, brown, fox, jumps]
且 n=2,训练元组将包括 ([quick, brown], the)
,([the, brown, fox], quick)
,([the, quick, fox jumps], brown)
等等。由于我们只关心词嵌入,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/422.png,我们将在训练完成后丢弃输出神经网络的权重,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/423.png。
CBOW 会告诉我们在给定上下文中最可能出现的单词。这对于稀有词可能是一个问题。例如,给定上下文 今天的天气真是 _____, 模型会预测单词 beautiful 而不是 fabulous(嘿,这只是个例子)。CBOW 的训练速度是 skip-gram 的几倍,而且对于常见单词的准确度稍好。
Skip-gram
Skip-gram 模型可以预测给定输入单词的上下文(与 CBOW 相反)。例如,单词 brown 会预测单词 The quick fox jumps。与 CBOW 不同,输入是单一的独热编码单词向量。但如何在输出中表示上下文单词呢?Skip-gram 不试图同时预测整个上下文(所有周围单词),而是将上下文转化为多个训练对,例如 (fox, the)
,(fox, quick)
,(fox, brown)
和 (fox, jumps)
。再次强调,我们可以用一个简单的单层神经网络训练该模型:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_4.jpg
图 6.4 – 一个 Skip-gram 模型神经网络
与 CBOW 一样,输出是一个 softmax,表示独热编码的最可能上下文单词。输入到隐藏层的权重,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/424.png,表示词嵌入查找表,隐藏到输出的权重,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/425.png,仅在训练过程中相关。隐藏层没有激活函数(即,它使用线性激活)。
我们将使用反向传播训练模型(这里没有惊讶的地方)。给定一系列单词,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/426.pnghttps://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/427.png,skip-gram 模型的目标是最大化平均对数概率,其中n是窗口大小:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/428.png
该模型定义了概率,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/429.png,如以下 softmax 公式所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/430.png
在这个例子中,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/431.png 和 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/432.png 是输入和输出单词,而v和v’ 是输入和输出权重矩阵中的相应词向量,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/420.png 和 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/434.png,分别表示(我们保留了论文中的原始符号)。由于神经网络没有隐藏激活函数,其对于一对输入/输出单词的输出值仅仅是输入词向量https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/435.png 和输出词向量 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/436.png(因此需要进行转置操作)。
Word2Vec 论文的作者指出,词表示无法表示那些不是由单个词组成的习语。例如,New York Times 是一家报纸,而不仅仅是 New、York 和 Times 各自含义的自然组合。为了解决这个问题,模型可以扩展到包括完整的短语。然而,这会显著增加词汇表的大小。而且,正如我们从前面的公式中看到的,softmax 的分母需要计算所有词汇的输出向量。此外,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/434.png 矩阵的每个权重都在每一步训练时被更新,这也减慢了训练速度。
为了解决这个问题,我们可以用所谓的 (fox, brown)
替代 softmax 操作,并添加 k 个额外的负样本对(例如,(fox, puzzle)
),其中 k 通常在 [5,20] 的范围内。我们不再预测最符合输入词的单词(softmax),而是直接预测当前的词对是否为真实的。实际上,我们将多项分类问题(从多个类别中选择一个)转化为二元逻辑回归(或二分类)问题。通过学习正负词对的区别,分类器最终会以与多项分类相同的方式学习词向量。在 Word2Vec 中,负样本词是从一个特殊分布中抽取的,该分布比频繁的词更常抽取不常见的词。
与稀有词相比,一些最常出现的词携带的信息量较少。此类词的例子包括定冠词和不定冠词 a、an 和 the。与 the 和 city 相比,模型从观察 London 和 city 的搭配中获益更多,因为几乎所有的词都与 the 经常同时出现。反之亦然——在大量例子上训练后,频繁词的向量表示不会发生显著变化。为了应对稀有词和频繁词之间的不平衡,论文的作者提出了一种子采样方法,其中训练集中的每个词,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/114.png,会以某个概率被丢弃,这个概率通过启发式公式计算得出。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/439.png 是单词 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/15.png 的频率,t是一个阈值(通常约为 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/441.png):
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/442.png
它会积极地对频率大于t的单词进行子采样,同时保持频率的排名。
我们可以说,一般而言,跳字模型(skip-gram)在稀有词上的表现比 CBOW 更好,但训练时间更长。
现在我们已经了解了嵌入向量,让我们学习如何可视化它们。
可视化嵌入向量
一个成功的词嵌入函数将语义相似的单词映射到嵌入空间中具有高点积相似度的向量。为了说明这一点,我们将实现以下步骤:
-
在
text8
数据集上训练 Word2Vec 跳字模型,该数据集包含来自维基百科的前 1 亿字节的纯文本(mattmahoney.net/dc/textdata.html
)。每个嵌入向量是 100 维的,这是该类型模型的默认值。 -
选择一个种子词列表。在此案例中,词语包括mother、car、tree、science、building、elephant和green。
-
计算每个种子词的 Word2Vec 嵌入向量与词汇表中所有其他单词嵌入向量之间的点积相似度。然后,为每个种子词选择一组前k(在我们的例子中,k=5)个最相似的单词(基于它们的点积相似度)。
-
在二维图中可视化种子嵌入与其相似词汇聚类嵌入之间的相似性。由于嵌入是 100 维的,我们将使用 t-SNE(
en.wikipedia.org/wiki/T-distributed_stochastic_neighbor_embedding
)降维算法。它将每个高维嵌入向量映射到二维或三维点,方法是将相似的对象建模为邻近点,而将不相似的对象建模为距离较远的点,且这种建模方式具有较高的概率。我们可以在下面的散点图中看到结果:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_5.jpg
图 6.5 – t-SNE 可视化种子词及其最相似词汇的聚类
该图证明了所获得的词向量包含了与单词相关的信息。
Word2Vec(和类似的模型)创建静态(或上下文无关)嵌入。每个单词都有一个单一的嵌入向量,基于该单词在文本语料库中的所有出现(即所有上下文)。这带来了一些局限性。例如,bank在不同的上下文中有不同的含义,如river bank(河岸)、savings bank(储蓄银行)和bank holiday(银行假日)。尽管如此,它还是通过单一的嵌入进行表示。此外,静态嵌入没有考虑上下文中的单词顺序。例如,表达式I like apples, but I don’t like oranges(我喜欢苹果,但我不喜欢橙子)和I like oranges, but I don’t like apples(我喜欢橙子,但我不喜欢苹果)具有相反的含义,但 Word2Vec 将它们视为相同的句子。我们可以通过所谓的动态(上下文相关)嵌入来解决这些问题,后者将在第七章中讨论。
到目前为止,我们一直专注于单个单词(或标记)。接下来,我们将扩展我们的研究范围,探索文本序列。
语言模型
基于词的语言模型(LM)定义了一个词汇序列的概率分布。对于本节内容,我们假设这些词元是单词。给定一个长度为m(例如,一个句子)的单词序列,语言模型会为该序列分配一个概率,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/443.png,表示这个完整的单词序列可能存在。这些概率的一种应用是生成模型,用于创建新文本——基于词的语言模型可以计算出下一个单词的可能性,前提是已知前面的单词序列。一旦我们得到了这个新单词,就可以将它添加到现有序列中,接着预测下一个新单词,依此类推。通过这种方式,我们可以生成任意长度的新文本序列。例如,给定序列the quick brown,语言模型可能会预测fox作为下一个最可能的单词。然后,序列变成the quick brown fox,我们再次让语言模型基于更新后的序列预测新的最可能单词。输出依赖于先前值以及其随机性(即带有一定随机性)的输出(新值)的模型,被称为自回归模型。
接下来,我们将重点关注词序列的属性,而不是模型本身。
注意
即使是最先进的 LLM,例如 ChatGPT,也是自回归模型——它们每次只预测下一个单词。
理解 N-gram 模型
推断长序列的概率,例如https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/444.png,通常是不可行的。为了理解原因,我们可以注意到,利用联合概率链式法则可以计算出https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/445.png。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/446.png
给定前面的单词,后面的单词的概率尤其难以从数据中估计。这就是为什么这种联合概率通常通过独立性假设来近似,假设第 i 个单词仅依赖于前面 n-1 个单词。我们只会对 n 个连续单词的组合进行联合概率建模,这些组合称为 n-grams。例如,在短语 the quick brown fox 中,我们有以下 n-grams:
-
1-gram (unigram):the、quick、brown 和 fox(这就是 unigram 分词法的来源)
-
2-gram (bigram):the quick、quick brown 和 brown fox
-
3-gram (trigram):the quick brown 和 quick brown fox
-
4-gram:the quick brown fox
注意
n-gram 术语可以指其他长度为 n 的序列类型,例如 n 个字符。
联合分布的推断通过 n-gram 模型来逼近,该模型将联合分布分割成多个独立部分。如果我们有大量的文本语料库,可以找到所有 n-gram,直到某个 n(通常为 2 到 4),并统计每个 n-gram 在该语料库中的出现次数。通过这些计数,我们可以估算每个 n-gram 最后一个单词的概率,前提是给定前 n-1 个单词:
-
Unigram: https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/447.png
-
Bigram: https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/448.png
-
n-gram: https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/449.png
假设独立性,即第 i 个单词只依赖于前 n-1 个单词,现在可以用来逼近联合分布。
例如,我们可以通过以下公式近似单元语法的联合分布:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/450.png
对于三元组,我们可以通过以下公式近似联合分布:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/451.png
我们可以看到,基于词汇表的大小,n-gram 的数量随着n的增加呈指数增长。例如,如果一个小型词汇表包含 100 个词,那么可能的 5-gram 数量将是 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/452.png 种不同的 5-gram。相比之下,莎士比亚的全部作品包含大约 30,000 个不同的单词,这说明使用大n的n-gram 是不可行的。不仅存在存储所有概率的问题,而且我们还需要一个非常大的文本语料库,才能为更大的n值创建合理的n-gram 概率估计。
高维诅咒
当可能的输入变量(单词)数量增加时,这些输入值的不同组合数量会呈指数增长。这个问题被称为维度灾难。当学习算法需要每个相关值组合至少一个例子时,就会出现这种情况,这正是n-gram 建模中所面临的问题。我们的n越大,就能越好地逼近原始分布,但我们需要更多的数据才能对n-gram 概率进行良好的估计。
但不用担心,因为n-gram 语言模型给了我们一些重要线索,帮助我们继续前进。它的理论公式是可靠的,但维度灾难使其不可行。此外,n-gram 模型强调了单词上下文的重要性,就像 Word2Vec 一样。在接下来的几节中,我们将学习如何借助神经网络模拟n-gram 模型的概率分布。
介绍 RNN
RNN 是一种可以处理具有可变长度的顺序数据的神经网络。此类数据的例子包括文本序列或某股票在不同时间点的价格。通过使用顺序一词,我们意味着序列元素彼此相关,且它们的顺序很重要。例如,如果我们把一本书中的所有单词随机打乱,文本将失去意义,尽管我们仍然能够知道每个单独的单词。
RNN 得名于它对序列应用相同函数的递归方式。我们可以将 RNN 定义为递归关系:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/453.png
在这里,f 是一个可微分的函数,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/454.png 是称为内部 RNN 状态的值向量(在步骤 t 处),而 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/352.png 是步骤 t 处的网络输入。与常规的神经网络不同,常规神经网络的状态只依赖于当前的输入(和 RNN 权重),而在这里,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/454.png 是当前输入以及先前状态 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/457.png 的函数。你可以把 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/458.png 看作是 RNN 对所有先前输入的总结。递归关系定义了状态如何在序列中一步一步地演变,通过对先前状态的反馈循环,如下图所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_6.jpg
图 6.6 – 展开的 RNN
左侧展示了 RNN 递归关系的可视化示意图,右侧展示了 RNN 状态在序列 t-1、t、t+1 上的递归展开。
RNN 有三组参数(或权重),这些参数在所有步骤之间共享:
-
U:将输入,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/352.png,转换为状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/454.png
-
W:将前一个状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/457.png,转化为当前状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/454.png
-
V:将新计算出的内部状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/454.png,映射到输出,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/464.png
U、V 和 W 对各自的输入应用线性变换。最基本的这种变换就是我们熟知并喜爱的全连接(FC)操作(因此,U、V 和 W 是权重矩阵)。我们现在可以定义内部状态和 RNN 输出如下:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/465.png
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/466.png
这里,f 是非线性激活函数(如 tanh、sigmoid 或 ReLU)。
例如,在一个基于单词的语言模型(LM)中,输入 x 将是一个词嵌入向量的序列 (https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/467.png)。
状态 s 将是一个状态向量的序列 (https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/468.png)。最后,输出 y 将是下一个单词序列的概率向量 (https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/469.png)。
请注意,在一个递归神经网络(RNN)中,每个状态依赖于通过递归关系的所有先前计算。这个重要的含义是,RNN 能够随着时间记忆,因为状态 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/454.png 包含基于之前步骤的信息。从理论上讲,RNN 可以记住信息很长一段时间,但实际上它们只能回溯几步。我们将在 消失和爆炸 梯度 部分详细讨论这个问题。
我们描述的 RNN 在某种程度上等同于一个单层常规神经网络(NN),但带有额外的递归关系。和常规神经网络一样,我们可以堆叠多个 RNN 来形成 堆叠 RNN。在时间 t 时,RNN 单元在第 l 层的单元状态 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/471.png 将接收来自第 l-1 层的 RNN 单元的输出 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/472.png 以及该层相同层级 l 的先前单元状态 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/473.png。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/474.png
在下图中,我们可以看到展开的堆叠 RNN:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_7.jpg
图 6.7 – 堆叠 RNN
因为 RNN 不仅限于处理固定大小的输入,它们扩展了我们可以通过神经网络(NNs)计算的可能性。根据输入和输出大小之间的关系,我们可以识别几种类型的任务:
-
一对一:非顺序处理,例如前馈神经网络(NNs)和卷积神经网络(CNNs)。前馈神经网络和将 RNN 应用于单个时间步之间没有太大区别。一对一处理的一个例子是图像分类。
-
一对多:基于单一输入生成一个序列——例如,从图像生成标题(展示与讲解:神经图像标题生成器,
arxiv.org/abs/1411.4555
)。 -
多对一:根据一个序列输出一个结果——例如,文本的情感分类。
-
多对多间接:一个序列被编码成一个状态向量,之后该状态向量被解码成一个新的序列——例如,语言翻译(使用 RNN 编码器-解码器学习短语表示用于统计机器翻译,
arxiv.org/abs/1406.1078
和 序列到序列学习与神经网络,papers.nips.cc/paper/5346-sequence-to-sequence-learning-with-neural-networks.pdf
)。 -
多对多直接:为每个输入步骤输出一个结果——例如,语音识别中的帧音素标注。
注意
多对多模型通常被称为 序列到序列 (seq2seq) 模型。
以下是前述输入输出组合的图示表示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_8.jpg
图 6.8 – RNN 输入输出组合,灵感来源于 karpathy.github.io/2015/05/21/rnn-effectiveness/
既然我们已经介绍了 RNN,现在让我们通过实现一个简单的 RNN 示例来加深对其的理解。
RNN 实现与训练
在上一节中,我们简要讨论了 RNN 是什么以及它们可以解决哪些问题。接下来,让我们深入了解 RNN 的细节,并通过一个非常简单的玩具示例进行训练:计算序列中的 1 的数量。
我们将教一个基本的 RNN 如何计算输入中 1 的数量,并在序列结束时输出结果。这是一个多对一关系的例子,正如我们在上一节中定义的那样。
我们将使用 Python(不使用深度学习库)和 numpy 实现这个例子。以下是输入和输出的示例:
In: (0, 0, 0, 0, 1, 0, 1, 0, 1, 0)
Out: 3
我们将使用的 RNN 如下图所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_9.jpg
图 6.9 – 用于计算输入中 1 的基本 RNN
注意
由于 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/475.png,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/476.png,U,W 和 y 都是标量值(x 仍然是一个向量),所以在 RNN 的实现和训练部分及其子部分中,我们不会使用矩阵表示法(粗体大写字母)。我们将使用斜体表示法。在代码部分,我们将它们表示为变量。然而,值得注意的是,这些公式的通用版本使用的是矩阵和向量参数。
RNN 只会有两个参数:一个输入权重 U 和一个递归权重 W。输出权重 V 设置为 1,这样我们只需将最后的状态作为输出 y。
首先,让我们添加一些代码,以便我们的例子可以执行。我们将导入 numpy 并定义我们的训练集——输入 x 和标签 y。x 是二维的,因为第一维代表了小批量中的样本。y 是一个单一的数值(它仍然有一个批量维度)。为了简单起见,我们将使用一个只有单一样本的小批量:
import numpy as np
# The first dimension represents the mini-batch
x = np.array([[0, 0, 0, 0, 1, 0, 1, 0, 1, 0]])
y = np.array([3])
由该 RNN 定义的递归关系是 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/477.png。请注意,这是一个线性模型,因为我们没有在这个公式中应用非线性函数。我们可以通过以下方式实现递归关系:
def step(s_t, x_t, U, W):
return x_t * U + s_t * W
状态 s_t
和权重 W
和 U
是单一的标量值。x_t
表示输入序列中的单个元素(在我们的例子中,是 0 或 1)。
注意
解决这个任务的一个方法是直接获取输入序列中元素的和。如果我们设置 U=1
,那么每当输入被接收时,我们将得到它的完整值。如果我们设置 W=1
,那么我们所累积的值将不会衰减。因此,对于这个例子,我们将得到期望的输出:3。然而,让我们用这个简单的例子来解释 RNN 的训练和实现。接下来的部分将会很有趣,我们将看到这些内容。
我们可以将 RNN 视为一种特殊类型的常规神经网络,通过时间展开它,进行一定数量的时间步(如前面的图所示)。这个常规神经网络的隐藏层数量等于输入序列元素的大小。换句话说,一个隐藏层代表时间中的一步。唯一的区别是每一层有多个输入:前一个状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/478.png,和当前输入,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/479.png。参数U和W在所有隐藏层之间共享。
前向传播沿序列展开 RNN,并为每个步骤构建状态堆栈。在下面的代码块中,我们可以看到前向传播的实现,它返回每个递归步骤和批次中每个样本的激活值s:
def forward(x, U, W):
# Number of samples in the mini-batch
number_of_samples = len(x)
# Length of each sample
sequence_length = len(x[0])
# Initialize the state activation for each sample along the sequence
s = np.zeros((number_of_samples, sequence_length + 1))
# Update the states over the sequence
for t in range(0, sequence_length):
s[:, t + 1] = step(s[:, t], x[:, t], U, W) # step function
return s
现在我们有了 RNN 的前向传播,我们来看一下如何训练展开的 RNN。
时间反向传播
时间反向传播(BPTT)是我们用来训练 RNN 的典型算法(时间反向传播:它的作用与如何实现,axon.cs.byu.edu/~martinez/classes/678/Papers/Werbos_BPTT.pdf
)。顾名思义,它是我们在第二章中讨论的反向传播算法的一个改进版。
假设我们将使用均方误差(MSE)损失函数。现在我们也有了前向传播步骤的实现,我们可以定义梯度如何向后传播。由于展开的 RNN 等同于一个常规的前馈神经网络,我们可以使用在第二章中介绍的反向传播链式法则。
因为权重W和U在各层之间是共享的,所以我们将在每个递归步骤中累积误差导数,最后用累积的值来更新权重。
首先,我们需要获取输出的梯度,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/480.png,关于损失函数 J,∂J/∂s。一旦我们获得了它,我们将通过在前向步骤中构建的活动堆栈向后传播。这个反向传播过程从堆栈中弹出活动,在每个时间步积累它们的误差导数。通过 RNN 传播这个梯度的递归关系可以写成如下(链式法则):
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/481.png
权重 U 和 W 的梯度将如下方式积累:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/482.png
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/483.png
掌握了这些知识后,让我们实现反向传播:
-
将
U
和W
的梯度分别累积到gU
和gW
中:def backward(x, s, y, W): sequence_length = len(x[0]) # The network output is just the last activation of sequence s_t = s[:, -1] # Compute the gradient of the output w.r.t. MSE cost function at final state gS = 2 * (s_t - y) # Set the gradient accumulations to 0 gU, gW = 0, 0 # Accumulate gradients backwards for k in range(sequence_length, 0, -1): # Compute the parameter gradients and accumulate the results. gU += np.sum(gS * x[:, k - 1]) gW += np.sum(gS * s[:, k - 1]) # Compute the gradient at the output of the previous layer gS = gS * W return gU, gW
-
使用梯度下降法优化我们的 RNN。通过反向传播函数计算梯度(使用 MSE),并用这些梯度来更新权重值:
def train(x, y, epochs, learning_rate=0.0005): # Set initial parameters weights = (-2, 0) # (U, W) # Accumulate the losses and their respective weights losses, gradients_u, gradients_w = list(), list(), list() # Perform iterative gradient descent for i in range(epochs): # Perform forward and backward pass to get the gradients s = forward(x, weights[0], weights[1]) # Compute the loss loss = (y[0] - s[-1, -1]) ** 2 # Store the loss and weights values for later display losses.append(loss) gradients = backward(x, s, y, weights[1]) gradients_u.append(gradients[0]) gradients_w.append(gradients[1]) # Update each parameter `p` by p = p - (gradient * learning_rate). # `gp` is the gradient of parameter `p` weights = tuple((p - gp * learning_rate) for p, gp in zip(weights, gradients)) return np.array(losses), np.array(gradients_u), np.array(gradients_w)
-
运行 150 个训练周期:
losses, gradients_u, gradients_w = train(x, y, epochs=150)
-
最后,显示每个权重在训练过程中的损失函数和梯度。我们将通过
plot_training
函数来实现,虽然这个函数在此处未实现,但可以在 GitHub 上的完整示例中找到。plot_training
会生成如下图表:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_10.jpg
图 6.10 – RNN 损失 – 实线 – 损失值;虚线 – 训练过程中权重的梯度
现在我们已经学习了时间反向传播,让我们来讨论熟悉的梯度消失和梯度爆炸问题是如何影响它的。
梯度消失和梯度爆炸
前面的示例有一个问题。为了说明这个问题,让我们用更长的序列运行训练过程:
x = np.array([[0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0]])
y = np.array([12])
losses, gradients_u, gradients_w = train(x, y, epochs=150)
plot_training(losses, gradients_u, gradients_w)
输出结果如下:
RuntimeWarning: overflow encountered in multiply
return x * U + s * W
RuntimeWarning: invalid value encountered in multiply
gU += np.sum(gS * x[:, k - 1])
RuntimeWarning: invalid value encountered in multiply
gW += np.sum(gS * s[:, k - 1])
出现这些警告的原因是最终的参数 U
和 W
会通过 plot_training
函数生成以下结果:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_11.jpg
图 6.11 – 在梯度爆炸情景下的参数和损失函数
在初期的训练阶段,梯度会缓慢增加,类似于它们在较短序列中增加的方式。然而,当达到第 23 个 epoch 时(虽然确切的 epoch 并不重要),梯度变得非常大,以至于超出了浮点变量的表示范围,变成了 NaN(如图中的跳跃所示)。这个问题被称为梯度爆炸。我们可以在常规的前馈神经网络中遇到梯度爆炸问题,但在 RNN 中尤为显著。为了理解原因,回顾一下我们在时间反向传播部分定义的两个连续序列步骤的递归梯度传播链规则:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/481.png
根据序列的长度,展开的 RNN 相比常规的神经网络可以更深。同时,RNN 的权重W在所有步骤中是共享的。因此,我们可以将这个公式推广,用来计算序列中两个非连续步骤之间的梯度。由于W是共享的,方程形成了一个几何级数:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/485.png
在我们简单的线性 RNN 中,如果*|W|>1*(梯度爆炸),梯度会呈指数增长,其中W是一个标量权重——例如,50 个时间步长下,当W=1.5时,结果是 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/486.png。如果*|W|<1*(梯度消失),梯度会呈指数衰减,例如,10 个时间步长下,当W=0.6时,结果是 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/487.png。如果权重参数W是矩阵而不是标量,那么这种梯度爆炸或梯度消失现象与W的最大特征值ρ(也称为谱半径)有关。当ρ<1时,梯度消失;当ρ>1时,梯度爆炸。
我们在第3 章中首先提到的梯度消失问题,在 RNN 中还有另一个更微妙的影响:梯度随着步数的增加呈指数衰减,直到在较早的状态下变得极其小。实际上,它们被来自较晚时间步长的更大梯度所掩盖,导致 RNN 无法保留这些早期状态的历史。这个问题更难以察觉,因为训练仍然会继续进行,且神经网络仍会生成有效的输出(与梯度爆炸不同)。只是它无法学习长期依赖。
通过这些内容,我们已经熟悉了 RNN 的一些问题。这些知识对我们接下来的讨论非常有帮助,因为在下一节中,我们将讨论如何借助一种特殊的 RNN 单元来解决这些问题。
长短时记忆
Hochreiter 和 Schmidhuber 广泛研究了梯度消失和梯度爆炸的问题,并提出了一种解决方案,称为长短时记忆网络(LSTM – www.bioinf.jku.at/publications/older/2604.pdf
和 Learning to Forget: Continual Prediction with LSTM, https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.55.5709&rep=rep1&type=pdf)。LSTM 由于特别设计的记忆单元,可以处理长期依赖问题。它们表现得如此出色,以至于目前大多数 RNN 在解决各种问题时的成功都归功于 LSTM 的使用。在本节中,我们将探索这个记忆单元是如何工作的,以及它如何解决梯度消失的问题。
以下是一个 LSTM 单元的示意图:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_12.jpg
Figure 6.12 – LSTM 细胞(顶部);展开的 LSTM 细胞(底部)。灵感来源于 colah.github.io/posts/2015-08-Understanding-LSTMs/
LSTM 的关键思想是细胞状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/488.png(除了隐藏的 RNN 状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/489.png),在没有外部干扰的情况下,信息只能显式写入或移除以保持状态恒定。细胞状态只能通过特定的门来修改,这些门是信息传递的一种方式。典型的 LSTM 由三个门组成:遗忘门,输入门和输出门。细胞状态、输入和输出都是向量,以便 LSTM 可以在每个时间步长保持不同信息块的组合。
LSTM 符号表示
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/490.png,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/491.png,以及https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/492.png是 LSTM 在时刻t的输入、细胞记忆状态和输出(或隐藏状态)向量。https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/493.png是候选细胞状态向量(稍后会详细介绍)。输入https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/494.png和前一时刻的细胞输出https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/495.png通过一组全连接(FC)权重W和U分别与每个门和候选细胞向量相连接。https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/496.png,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/497.png,以及https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/498.png是 LSTM 细胞的遗忘门、输入门和输出门(这些门也使用向量表示)。
这些门由全连接(FC)层、sigmoid 激活函数和逐元素相乘(表示为 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/499.png)组成。由于 sigmoid 函数的输出仅限于 0 到 1 之间,乘法操作只能减少通过门的值。我们按顺序讨论它们:
- 遗忘门,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/500.png:它决定我们是否要擦除现有单元状态的部分内容。它根据前一单元输出的加权向量和当前输入来做出决定,前一单元的输出是 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/501.png,当前输入是 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/352.png:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/503.png
从前述公式可以看出,遗忘门对先前状态向量的每个元素应用逐元素 sigmoid 激活,<mml:math xmlns:mml=“http://www.w3.org/1998/Math/MathML” xmlns:m=“http://schemas.openxmlformats.org/officeDocument/2006/math”>mml:msubmml:mrow<mml:mi mathvariant=“bold”>c</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi>mml:mo-</mml:mo>mml:mn1</mml:mn></mml:mrow></mml:msub></mml:math>:https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/505.png(注意圆点符号)。由于操作是逐元素的,因此该向量的值被压缩到[0,1]范围内。输出为 0 意味着特定的https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/506.png单元块被完全清除,而输出为 1 则允许该单元块中的信息通过。通过这种方式,LSTM 能够清除其单元状态向量中的无关信息。
- 输入门,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/507.png:它通过多步骤过程决定将哪些新信息添加到记忆单元中。第一步是决定是否添加任何信息。与遗忘门类似,它的决策是基于https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/508.png 和 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/352.png:它通过 sigmoid 函数为候选状态向量的每个单元输出 0 或 1。0 的输出意味着没有信息被添加到该单元的记忆中。因此,LSTM 可以在其单元状态向量中存储特定的片段信息:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/510.png
在输入门序列的下一步中,我们计算新的候选细胞状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/511.png。它基于先前的输出,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/512.png,以及当前的输入,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/352.png,并通过一个 tanh 函数进行转换:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/514.png
然后,我们将https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/515.png与输入门的 Sigmoid 输出通过逐元素乘法结合起来:https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/516.png。
总结来说,遗忘门和输入门分别决定了要从先前的和候选单元状态中遗忘和包含哪些信息。最终版本的新单元状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/517.png,只是这两个组成部分的逐元素和:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/518.png
- 输出门,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/519.png:它决定了单元格的总输出是什么。它将https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/520.png 和 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/352.png 作为输入。它为每个单元格记忆块输出一个 (0, 1) 范围内的值(通过 sigmoid 函数)。和之前一样,0 表示该块不输出任何信息,1 表示该块可以作为单元格的输出传递。因此,LSTM 可以从其单元格状态向量中输出特定的信息块:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/522.png
最后,LSTM 单元的输出通过 tanh 函数进行传递:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/523.png
因为这些公式都是可推导的,我们可以将 LSTM 单元串联起来,就像我们将简单的 RNN 状态串联在一起,并通过时间反向传播来训练网络一样。
那么 LSTM 是如何防止梯度消失的呢?我们从前向传播阶段开始。注意,如果遗忘门为 1,且输入门为
0: https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/524.png。只有遗忘门可以完全清除单元的记忆。因此,记忆可以在长时间内保持不变。此外,注意输入是一个 tanh 激活函数,它已被加入到当前单元的记忆中。这意味着单元的记忆不会爆炸,并且相当稳定。
让我们通过一个例子来演示 LSTM 单元如何展开。为了简化起见,我们假设它具有一维(单标量值)输入、状态和输出向量。由于值是标量,我们将在此示例的其余部分中不使用向量符号:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_13.jpg
图 6.13 – 随时间展开 LSTM
过程如下:
-
首先,我们有一个值为 3 的候选状态。输入门设置为 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/525.png,而忘记门设置为 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/526.png。这意味着先前的状态 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/527.png 被抹去,并被新的状态 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/528.png 替换。
-
对于接下来的两个时间步骤,忘记门设置为 1,而输入门设置为 0。这样,在这些步骤中,所有信息都被保留,没有新信息被添加,因为输入门被设置为 0:https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/529.png。
-
最后,输出门设置为 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/530.png,3 被输出并保持不变。我们已成功展示了如何在多个步骤中存储内部状态。
接下来,让我们聚焦于反向阶段。细胞状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/531.png,可以通过遗忘门的帮助,减轻消失/爆炸梯度的问题,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/532.png。像常规的 RNN 一样,我们可以利用链式法则计算偏导数,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/533.png,对于两个连续的步骤。根据公式 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/534.png
不展开细节,其偏导数如下:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/535.png
我们还可以将其推广到非连续的步骤:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/536.png
如果遗忘门的值接近 1,则梯度信息几乎不变地通过网络状态反向传播。这是因为 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/532.png 使用 sigmoid 激活函数,信息流仍然受到 sigmoid 激活特有的消失梯度的影响。但是,与普通 RNN 中的梯度不同,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/532.png 在每个时间步的值是不同的。因此,这不是几何级数,消失梯度效应较不明显。
接下来,我们将介绍一种新的轻量级 RNN 单元,它仍然保留 LSTM 的特性。
门控循环单元
门控循环单元(GRU)是一种循环模块,首次在 2014 年提出(使用 RNN 编码器-解码器进行统计机器翻译的学习短语表示,arxiv.org/abs/1406.1078
和 门控递归神经网络在序列建模中的实证评估,https://arxiv.org/abs/1412.3555),作为对 LSTM 的改进。GRU 单元通常具有与 LSTM 相似或更好的性能,但它的参数和操作更少:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_14.jpg
图 6.14 – 一个 GRU 单元示意图
类似于经典的 RNN,GRU 单元有一个单一的隐藏状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/539.png。你可以将其看作是 LSTM 的隐藏状态和细胞状态的结合。GRU 单元有两个门:
- 更新门,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/540.png:结合输入和遗忘 LSTM 门。根据网络输入 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/352.png 和先前的隐藏状态 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/542.png,决定丢弃哪些信息并确定新信息的包含方式。通过结合这两个门,我们可以确保细胞只会在有新信息需要包含时才丢弃信息:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/543.png
- 重置门,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/544.png:使用先前的隐藏状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/545.png,和网络输入,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/357.png,来决定保留多少先前的状态:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/547.png
接下来,我们得到候选状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/548.png:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/549.png
最后,GRU 输出,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/550.png,在时刻 t 是前一个输出,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/551.png,以及候选输出,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/548.png:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/553.png
由于更新门允许我们同时忘记和存储数据,因此它直接应用于之前的输出,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/551.png,并应用于候选输出,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/548.png。
我们将在本章的最后部分回到之前的免责声明——RNN 的实际限制。我们可以通过 LSTM 或 GRU 单元来解决其中的一个限制——消失梯度和梯度爆炸问题。但还有两个问题:
-
RNN 的内部状态在每个序列元素之后都会更新——每个新元素都需要先处理所有前面的元素。因此,RNN 的序列处理无法并行化,也无法利用 GPU 的并行计算能力。
-
所有前序序列元素的信息都被总结在一个单一的隐藏状态单元中。RNN 没有直接访问历史序列元素的能力,而是必须依赖单元状态来处理。实际上,这意味着即使是 LSTM 或 GRU,RNN 也只能有效处理最大长度约为 100 个元素的序列。
正如我们将在下一章看到的,transformer 架构成功地解决了这两种限制。但现在,让我们先看看如何在实践中使用 LSTM。
实现文本分类
在这一部分,我们将使用 LSTM 实现一个情感分析示例,数据集是 Large Movie Review Dataset (IMDb,ai.stanford.edu/~amaas/data/sentiment/
),该数据集包含 25,000 条训练评论和 25,000 条测试评论,每条评论都有一个二进制标签,表示其是正面还是负面。这个问题是一个 多对一 的关系类型,正如我们在 *循环神经网络 (*RNNs) 部分定义的那样。
情感分析模型显示在下图中:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_06_15.jpg
图 6.15 – 使用词嵌入和 LSTM 的情感分析
让我们描述一下模型组件(这些适用于任何文本分类算法):
-
序列中的每个单词都被它的嵌入向量替代。这些嵌入可以通过 word2vec 生成。
-
词嵌入作为输入传递给 LSTM 单元。
-
单元格输出, https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/556.png,作为输入传递给具有两个输出单元和 softmax 的 FC 层。softmax 输出表示评论是正面(1)还是负面(0)的概率。
-
网络可以通过 Word2Vec 生成。
-
序列的最后一个元素的输出被作为整个序列的结果。
为了实现这个例子,我们将使用 PyTorch 和 TorchText 包。它包含数据处理工具和流行的自然语言数据集。我们只会包括代码中的有趣部分,但完整示例可以在本书的 GitHub 仓库中找到。有了这些,我们开始吧:
-
定义设备(默认情况下,这是 GPU,并有 CPU 后备):
import torch device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
-
启动训练和测试数据集管道。首先,定义
basic_english
分词器,它通过空格分割文本(即词汇分词):from torchtext.data.utils import get_tokenizer tokenizer = get_tokenizer('basic_english')
-
接下来,使用
tokenizer
来构建 tokenvocabulary
:from torchtext.datasets import IMDB from torchtext.vocab import build_vocab_from_iterator def yield_tokens(data_iter): for _, text in data_iter: yield tokenizer(text) vocabulary = build_vocab_from_iterator( yield_tokens(IMDB(split='train')), specials=["<unk>"]) vocabulary.set_default_index(vocabulary["<unk>"])
在这里,
IMDB(split='train')
提供了一个迭代器,用于访问训练集中的所有电影评论(每条评论都表示为一个字符串)。yield_tokens(IMDB(split='train'))
生成器遍历所有样本并将它们分割成单词。结果作为输入传递给build_vocab_from_iterator
,该函数遍历已 token 化的样本并构建 tokenvocabulary
。请注意,词汇表仅包括训练样本。因此,任何出现在测试集(但不在训练集中的)中的 token,都将被替换为默认的未知<unk>
token。 -
接下来,定义
collate_batch
函数,该函数接收一个包含不同长度的 token 化样本的batch
,并将它们拼接成一个长的 token 序列:def collate_batch(batch): labels, samples, offsets = [], [], [0] for (_label, _sample) in batch: labels.append(int(_label) - 1) processed_text = torch.tensor( vocabulary(tokenizer(_sample)), dtype=torch.int64) samples.append(processed_text) offsets.append(processed_text.size(0)) labels = torch.tensor( labels, dtype=torch.int64) offsets = torch.tensor( offsets[:-1]).cumsum(dim=0) samples = torch.cat(samples) return labels, samples, offsets
在这里,
samples
列表聚合了batch
所有分词后的_sample
实例。最终,它们会被拼接成一个单一的列表。offsets
列表包含每个拼接样本的起始偏移量。这些信息使得可以将长的samples
序列逆向拆分回单独的项目。该函数的目的是创建一个压缩的batch
表示。这是必需的,因为每个样本的长度不同。另一种做法是将所有样本填充到与最长样本相同的长度,以便它们能够适配批量张量。幸运的是,PyTorch 提供了offsets
优化来避免这一点。一旦我们将压缩批次传入 RNN,它会自动将其逆转回单独的样本。 -
然后,我们定义了 LSTM 模型:
class LSTMModel(torch.nn.Module): def __init__(self, vocab_size, embedding_size, hidden_size, num_classes): super().__init__() # Embedding field self.embedding = torch.nn.EmbeddingBag( num_embeddings=vocab_size, embedding_dim=embedding_size) # LSTM cell self.rnn = torch.nn.LSTM( input_size=embedding_size, hidden_size=hidden_size) # Fully connected output self.fc = torch.nn.Linear( hidden_size, num_classes) def forward(self, text_sequence, offsets): # Extract embedding vectors embeddings = self.embedding( text_sequence, offsets) h_t, c_t = self.rnn(embeddings) return self.fc(h_t)
该模型实现了我们在本节开始时介绍的方案。顾名思义,
embedding
属性(一个EmbeddingBag
实例)将 token(在我们这里是单词)索引映射到其嵌入向量。我们可以看到构造函数接受词汇表大小(num_embeddings
)和嵌入向量的维度(embedding_dim
)。理论上,我们可以用预先计算好的 Word2Vec 嵌入向量初始化EmbeddingBag
。但在我们的例子中,我们将使用随机初始化,并让模型在训练过程中学习这些向量。embedding
还处理了压缩批量表示(因此在forward
方法中有offsets
参数)。嵌入的输出作为输入传入rnn
LSTM 单元,进而将输出传递给fc
层。 -
定义
train_model(model, cost_function, optimizer, data_loader)
和test_model(model, cost_function, data_loader)
函数。这些函数几乎与我们在第三章中首次定义的相同,因此我们不会在这里再次列出它们。不过,它们已经适配了压缩批量表示和额外的offsets
参数。 -
继续实验。实例化 LSTM 模型、交叉熵损失函数和 Adam 优化器:
model = LSTMModel( vocab_size=len(vocabulary), embedding_size=64, hidden_size=64, num_classes=2) cost_fn = torch.nn.CrossEntropyLoss() optim = torch.optim.Adam(model.parameters())
-
定义
train_dataloader
、test_dataloader
及其各自的数据集(使用 64 的小批量大小):from torchtext.data.functional import to_map_style_dataset train_iter, test_iter = IMDB() train_dataset = to_map_style_dataset(train_iter) test_dataset = to_map_style_dataset(test_iter) from torch.utils.data import DataLoader train_dataloader = DataLoader( train_dataset, batch_size=64, shuffle=True, collate_fn=collate_batch) test_dataloader = DataLoader( test_dataset, batch_size=64, shuffle=True, collate_fn=collate_batch)
-
训练 5 个周期:
for epoch in range(5): print(f'Epoch: {epoch + 1}') train_model(model, cost_fn, optim, train_dataloader) test_model(model, cost_fn, test_dataloader)
模型在测试集上的准确率达到了 87%。
这结束了我们关于 LSTM 文本分类的简单实用示例。巧合的是,这也标志着本章的结束。
总结
本章介绍了两个互补的主题——NLP 和 RNN。我们讨论了分词技术以及最流行的分词算法——BPE、WordPiece 和 Unigram。接着,我们介绍了词嵌入向量的概念以及生成它们的 Word2Vec 算法。我们还讨论了 n -gram 语言模型,这为我们平滑地过渡到 RNN 的话题。在那里,我们实现了一个基本的 RNN 示例,并介绍了两种最先进的 RNN 架构——LSTM 和 GRU。最后,我们实现了一个情感分析模型。
在下一章,我们将通过引入注意力机制和变压器来超级增强我们的 NLP 潜力。
第七章:注意力机制与 Transformer
在第六章中,我们概述了一个典型的自然语言处理(NLP)流程,并介绍了递归神经网络(RNNs)作为 NLP 任务的候选架构。但我们也概述了它们的缺点——它们本质上是顺序的(即不可并行化),并且由于其内部序列表示的局限性,无法处理更长的序列。在本章中,我们将介绍注意力机制,它使神经网络(NN)可以直接访问整个输入序列。我们将简要讨论 RNN 中的注意力机制,因为它最初是作为 RNN 的扩展引入的。然而,本章的主角将是Transformer——一种完全依赖于注意力的最新神经网络架构。Transformer 在过去 10 年中成为最重要的神经网络创新之一。它们是所有近期大型语言模型(LLMs)的核心,例如 ChatGPT(chat.openai.com/
),甚至是图像生成模型,如 Stable Diffusion(stability.ai/stable-diffusion
)。这是我们专注于 NLP 的章节中的第二章,也是三章中专门讨论 Transformer 的第一章。
本章将涵盖以下主题:
-
介绍序列到序列(seq2seq)模型
-
理解注意力机制
-
使用注意力机制构建 Transformer
技术要求
我们将使用 Python、PyTorch 和 Hugging Face Transformers 库(github.com/huggingface/transformers
)来实现本章的示例。如果你还没有配置这些工具的环境,不用担心——该示例作为 Jupyter 笔记本在 Google Colab 上提供。你可以在本书的 GitHub 仓库中找到代码示例:github.com/PacktPublishing/Python-Deep-Learning-Third-Edition/tree/main/Chapter07
。
介绍 seq2seq 模型
在 第六章 中,我们概述了几种类型的递归模型,取决于输入/输出的组合。其中一种是间接的多对多任务,或 seq2seq,其中一个输入序列被转换成另一个不同的输出序列,输出序列的长度不一定与输入序列相同。seq2seq 任务的一种类型是机器翻译。输入序列是一个语言中的句子的单词,而输出序列是同一句子翻译成另一种语言的单词。例如,我们可以将英语序列 tourist attraction 翻译成德语 Touristenattraktion。输出不仅长度不同,而且输入和输出序列的元素之间没有直接对应关系。一个输出元素对应于两个输入元素的组合。
另一种间接的多对多任务是会话聊天机器人,例如 ChatGPT,其中初始输入序列是用户的第一个查询。之后,整个对话(包括用户的查询和机器人的回复)都作为新生成的机器人的回复的输入序列。
在这一部分,我们将重点介绍编码器-解码器的 seq2seq 模型(使用神经网络的序列到序列学习,arxiv.org/abs/1409.3215
; 使用 RNN 编码器-解码器进行统计机器翻译的短语表示学习,arxiv.org/abs/1406.1078
),该模型首次于 2014 年提出。它们使用 RNN 的方式特别适用于解决间接的多对多任务,例如这些任务。以下是 seq2seq 模型的示意图,其中输入序列 [A, B, C, <EOS>]
被解码为输出序列 [W, X, Y, Z, <EOS>]
:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_1.jpg
图 7.1 – 一个 seq2seq 模型(灵感来源于 arxiv.org/abs/1409.3215
)
该模型由两部分组成:
-
<EOS>
—序列结束—标记已到达。假设输入是使用词级别标记化的文本序列。那么,我们将在每一步使用词嵌入向量作为编码器输入,<EOS>
标记表示句子的结束。编码器的输出会被丢弃,在 seq2seq 模型中没有作用,因为我们只关心隐藏的编码器状态。 -
<GO>
输入信号。编码器也是一个 RNN(LSTM 或 GRU)。编码器和解码器之间的联系是编码器最新的内部状态向量, https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/550.png(也称为<EOS>
,成为最可能的符号,解码完成)。
自回归模型的示例
假设我们想将英语句子*How are you today?*翻译成西班牙语。我们将其标记为[how, are, you, today, ?, <EOS>]
。一个自回归模型将从初始序列[<GO>]
开始。然后,它会生成翻译的第一个词,并将其附加到现有的输入序列中:[<GO>, ¿]
。新的序列将作为解码器的输入,以便生成下一个元素并再次扩展序列:[<GO>, ¿, cómo]
。我们将重复相同的步骤,直到解码器预测出<EOS>
标记:[<GO>, ¿, cómo, estás, hoy, ?, <EOS>]
。
该模型的训练是有监督的,因为它需要知道输入序列及其对应的目标输出序列(例如,多个语言中的相同文本)。我们将输入序列送入编码器,生成思维向量,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/550.png,并利用它启动解码器的输出序列生成。训练解码器使用一种叫做[W, X, Y]
的过程,但当前解码器生成的输出序列是[W, X, Z]
。通过教师强制法,在步骤t+1时,解码器的输入将是Y而不是Z。换句话说,解码器学习在给定目标值[...,t]
的情况下生成目标值[t+1,...]
。我们可以这样理解:解码器的输入是目标序列,而其输出(目标值)是同一序列,但向右移动了一个位置。
总结来说,seq2seq 模型通过将输入序列编码为固定长度的状态向量v,然后使用这个向量作为基础来生成输出序列,从而解决了输入/输出序列长度变化的问题。我们可以通过以下方式形式化这一过程:它尝试最大化以下概率:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/561.png
这等价于以下表达式:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/562.png
让我们更详细地看一下这个公式的各个元素:
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/563.png:条件概率,其中https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/564.png 是长度为T的输入序列,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/565.png 是长度为*T’*的输出序列。
-
v:输入序列的固定长度编码(思维向量)。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/566.png: 给定先前的词 y 以及思想向量 v,输出词 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/567.png 的概率。
原始的 seq2seq 论文介绍了一些技巧,用于增强模型的训练和性能。例如,编码器和解码器是两个独立的 LSTM。在机器翻译的情况下,这使得可以使用相同的编码器为不同语言训练不同的解码器。
另一个改进是输入序列以反向方式输入到解码器。例如,[A,B,C]
-> [W,X,Y,Z]
将变成 [C,B,A]
-> [W,X,Y,Z]
。没有明确的解释说明为什么这样有效,但作者分享了他们的直觉:由于这是一个逐步模型,如果序列按正常顺序排列,源句子中的每个源词将远离其在输出句子中的对应词。如果我们反转输入序列,输入/输出词之间的平均距离不会改变,但第一个输入词会非常接近第一个输出词。这有助于模型在输入和输出序列之间建立更好的通信。然而,这一改进也展示了 RNN(即使是 LSTM 或 GRU)隐藏状态的不足——较新的序列元素会抑制较老元素的可用信息。在下一节中,我们将介绍一种优雅的方式来彻底解决这个问题。
理解注意力机制
在这一节中,我们将按引入的顺序讨论注意力机制的几个迭代版本。
Bahdanau 注意力
第一次注意力迭代(Neural Machine Translation by Jointly Learning to Align and Translate, arxiv.org/abs/1409.0473
),被称为巴赫达瑙注意力,扩展了 seq2seq 模型,使解码器能够与所有编码器隐藏状态进行交互,而不仅仅是最后一个状态。它是对现有 seq2seq 模型的补充,而不是一个独立的实体。下图展示了巴赫达瑙注意力的工作原理:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_2.png
图 7.2 – 注意力机制
别担心——它看起来比实际更复杂。我们将从上到下解析这个图:注意力机制通过在编码器和解码器之间插入一个额外的上下文向量,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/568.png,来实现。隐藏的解码器状态 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/569.png 在时间 t 上,现在不仅是隐藏状态和解码器输出的函数,还包含上下文向量 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/570.png:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/571.png
每个解码步骤都有一个独特的上下文向量,而一个解码步骤的上下文向量只是所有编码器隐藏状态的加权和。通过这种方式,编码器可以在每个输出步骤 t 中访问所有输入序列状态,这消除了像常规 seq2seq 模型那样必须将源序列的所有信息编码为一个固定长度的思维向量的必要性:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/572.png
让我们更详细地讨论这个公式:
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/570.png:解码器输出步骤t(总共有*T’*个输出步骤)的上下文向量
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/574.png:编码器步骤i(总共有T个输入步骤)的隐藏状态向量
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/575.png:与当前解码器步骤t中https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/574.png相关的标量权重
请注意,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/577.png 对于编码器和解码器步骤都是唯一的——也就是说,输入序列的状态将根据当前的输出步骤具有不同的权重。例如,如果输入和输出序列的长度为 10,那么权重将由一个 10×10 的矩阵表示,共有 100 个权重。这意味着注意力机制将根据输出序列的当前状态,将解码器的注意力集中在输入序列的不同部分。如果https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/575.png 很大,那么解码器将非常关注在步骤 t 时的https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/574.png。
那么,我们如何计算权重https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/575.png 呢?首先,我们需要提到,对于解码器的每个步骤 t,所有https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/581.png 权重的总和为 1。我们可以通过在注意力机制之上执行 softmax 操作来实现这一点:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/582.png
这里,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/583.png 是一个对齐分数,表示输入序列中位置 i 附近的元素与位置 t 的输出匹配(或对齐)的程度。这个分数(由权重 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/584.png) 基于前一个解码器状态 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/585.png(我们使用 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/585.png,因为我们还没有计算 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/587.png),以及编码器状态 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/574.png:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/589.png
这里,a(而非 alpha)是一个可微分函数,通过反向传播与系统的其他部分一起训练。不同的函数满足这些要求,但论文的作者选择了所谓的加性注意力,它通过向量加法将https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/457.png和https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/591.png结合起来。它有两种变体:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/592.png
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/593.png
在第一个公式中,W 是一个权重矩阵,应用于连接向量 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/457.png 和 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/574.png,而 v 是一个权重向量。第二个公式类似,但这次我们有单独的 全连接 (FC) 层(权重矩阵 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/596.png 和 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/597.png),然后我们对 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/457.png 和 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/574.png 求和。在这两种情况下,对齐模型可以表示为一个简单的 前馈网络 (FFN),带有一个隐藏层。
现在我们知道 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/570.png 和 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/575.png 的公式,让我们用前者替换后者:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/602.png
作为总结,下面是一步一步总结的注意力算法:
-
将输入序列输入编码器,并计算一组隐藏状态,H = {h 1, h 2…h T}。
-
计算对齐分数,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/603.png,该对齐分数使用来自前一步解码器状态的值https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/457.png。如果t=1,我们将使用最后一个编码器状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/605.png,作为初始隐藏状态。
-
计算权重https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/606.png。
-
计算上下文向量 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/607.png。
-
计算隐藏状态,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/608.png,基于连接的向量 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/609.png 和 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/570.png 以及先前的解码器输出 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/611.png。此时,我们可以计算最终输出 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/612.png。如果我们需要对下一个单词进行分类,将使用 softmax 输出,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/613.pnghttps://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/614.png,其中 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/615.png 是一个权重矩阵。
-
重复 步骤 2 到 步骤 5,直到序列结束。
接下来,我们将讨论 Bahdanau 注意力的一个稍微改进的版本。
Luong 注意力
Luong 注意力 (Effective Approaches to Attention-based Neural Machine Translation, arxiv.org/abs/1508.04025
) 相比 Bahdanau 注意力做出了若干改进。最显著的变化是对齐分数依赖于解码器的隐藏状态 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/454.png,而不像 Bahdanau 注意力中的 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/617.png。为了更好地理解这一点,我们来比较这两种算法:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_3.jpg
图 7.3 – 左:Bahdanau 注意力;右:Luong 注意力
我们将逐步执行 Luong 注意力的过程:
-
将输入序列传入编码器,并计算编码器的隐藏状态集 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/618.pnghttps://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/619.png。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/620.png: 计算解码器的隐藏状态,基于上一个解码器的隐藏状态!<mml:math xmlns:mml=“http://www.w3.org/1998/Math/MathML” xmlns:m=“http://schemas.openxmlformats.org/officeDocument/2006/math”>mml:msubmml:mrow<mml:mi mathvariant=“bold”>s</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi>mml:mo-</mml:mo>mml:mn1</mml:mn></mml:mrow></mml:msub></mml:math> 和上一个解码器的输出!<mml:math xmlns:mml=“http://www.w3.org/1998/Math/MathML” xmlns:m=“http://schemas.openxmlformats.org/officeDocument/2006/math”>mml:msubmml:mrowmml:miy</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi>mml:mo-</mml:mo>mml:mn1</mml:mn></mml:mrow></mml:msub></mml:math>(但不是上下文向量)。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/623.png: 计算对齐分数,使用当前步骤的解码器状态!<mml:math xmlns:mml=“http://www.w3.org/1998/Math/MathML” xmlns:m=“http://schemas.openxmlformats.org/officeDocument/2006/math”>mml:msubmml:mrow<mml:mi mathvariant=“bold”>s</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi></mml:mrow></mml:msub></mml:math>。除了加性注意力之外,Luong 注意力论文还提出了两种乘性注意力:
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/625.png: 点积没有任何参数。在这种情况下,向量s和h(表示为列矩阵和行矩阵)需要具有相同的大小。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/626.png: 这里,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/627.png 是注意力层的可训练权重矩阵。
将向量相乘作为对齐评分的度量有一个直观的解释——正如我们在第二章中提到的,点积作为向量之间相似性的度量。因此,如果向量相似(即对齐),那么乘积的结果将是一个较大的值,注意力将集中在当前的t,i关系上。
-
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/606.png: 计算权重。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/607.png: 计算上下文向量。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/630.png: 根据连接的向量 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/570.png 和 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/454.png 计算中间向量。此时,我们可以计算最终输出 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/612.png。在分类的情况下,我们将使用 softmax,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/634.png,其中 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/615.png 是一个权重矩阵。
-
重复步骤 2到步骤 6直到序列结束。
接下来,我们将使用巴赫达努(Bahdanau)和 Luong 注意力作为通用注意力机制的垫脚石。
通用注意力
尽管我们在使用 RNN 的 seq2seq 背景下讨论了注意力机制,但它本身就是一种通用的深度学习(DL)技术。为了理解它,我们从以下图示开始:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_4.jpg
图 7.4 – 通用注意力
它从一个查询q开始,执行对一组键值对k和https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/636.png的查询。每个键https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/637.png有一个对应的值https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/638.png。查询、键和值都是向量。因此,我们可以将键值存储表示为两个矩阵K和V。如果有多个查询https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/639.png,我们也可以将它们表示为一个矩阵Q。因此,这些通常被简写为Q、K和V。
通用注意力与巴赫达努/Luong 注意力的区别
与一般的注意力机制不同,Bahdanau 和 Luong 注意力的键 K 和值 V 是相同的——也就是说,这些注意力模型更像是 Q/V,而不是 Q/K/V。分开键和值为一般的注意力机制提供了更多的灵活性——键专注于匹配输入查询,而值则携带实际的信息。我们可以把 Bahdanau 向量 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/640.png(或 Luong 注意力中的 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/641.png)视为查询,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/644.png。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/645.png: 使用查询向量 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/646.png 和每个关键向量 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/647.png 计算对齐分数。正如我们在巴达瑙注意力部分提到的那样,点积充当相似度度量,并且在这种情况下使用它是有意义的。
-
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/648.png:借助 softmax 计算每个值向量相对于查询的最终权重。
-
最终的注意力向量是所有值向量的加权和(即元素级别的求和),https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/649.png:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/650.png
为了更好地理解注意力机制,我们将使用以下图表中显示的数值示例:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_5.jpg
图 7.5 – 使用四维查询在包含四个向量的键值存储中执行的注意力示例
让我们一步一步地跟踪它:
-
执行一个四维查询向量,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/651.png,对一个包含四个四维向量的键值存储进行查询。
-
计算对齐得分。例如,第一个得分是 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/652.pnghttps://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/653.png。其余的得分显示在图 7**.5中。我们故意选择了查询 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/654.png,它与第二个键向量 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/655.png相对相似。这样,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/656.png具有最大的对齐得分,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/657.png,
它应该对最终结果产生最大的影响。
-
计算权重,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/658.png,借助 softmax 函数——例如,α q 1,k 2 = exp(2.4)/(exp(0.36) + exp(2.4) + exp(0.36) + exp(0.36)) = 0.756。关键向量,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/659.png,由于其较大的对齐分数,具有最大的权重。softmax 函数夸大了输入之间的差异,因此,最终的权重!<mml:math xmlns:mml=“http://www.w3.org/1998/Math/MathML” xmlns:m=“http://schemas.openxmlformats.org/officeDocument/2006/math”>mml:msubmml:mrow<mml:mi mathvariant=“bold”>k</mml:mi></mml:mrow>mml:mrowmml:mn2</mml:mn></mml:mrow></mml:msub></mml:math>甚至比输入对齐分数的比例还要高。
-
计算最终结果,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/661.png,这是值向量的加权元素级求和,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/662.png。例如,我们可以计算结果的第一个元素为 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/663.png。我们可以看到结果的值最接近值向量,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/664.png,这再次反映了键向量,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/659.png,和输入查询,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/666.png之间的大量对齐。
我希望这个例子能帮助你理解注意力机制,因为这是过去 10 年深度学习领域的一个重要创新。接下来,我们将讨论一个更先进的注意力版本。
Transformer 注意力
在本节中,我们将讨论注意力机制,正如它在 Transformer 神经网络架构中出现的那样(Attention Is All You Need,arxiv.org/abs/1706.03762
)。别担心——你现在不需要了解 Transformer,因为Transformer 注意力(TA)是整个模型的一个独立且自给自足的构建模块。它在下图中展示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_6.jpg
图 7.6 – 缩放点积(乘法)TA(灵感来源于 arxiv.org/abs/1706.03762
)
TA 使用点积(乘法)相似度,并遵循我们在通用注意力部分介绍的通用注意力过程(正如我们之前提到的,它并不限于 RNN 模型)。我们可以用以下公式来定义它:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/667.png
在实际应用中,我们会同时计算一组查询的 TA 函数,这些查询被打包在一个矩阵Q中(键 K、值 V 和结果也是矩阵)。让我们更详细地讨论公式中的各个步骤:
- 将查询Q与数据库(键K)进行矩阵乘法匹配,以生成对齐分数,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/668.png。矩阵乘法等价于在每一对查询和键向量之间应用点积相似度。假设我们希望将m个不同的查询与一个n个值的数据库进行匹配,且查询-键向量的长度为https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/669.png。然后,我们有查询矩阵,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/670.png,每行包含一个https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/671.png-维的查询向量,共m行。类似地,我们有键矩阵,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/672.png,每行包含一个https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/673.png-维的键向量,共n行(其转置为https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/674.png)。然后,输出矩阵将为https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/675.png,其中每一行包含一个查询与数据库中所有键的对齐分数:
![QK⊤=q11⋯q1dk⋮⋱⋮qm1⋯qmdk‘’’
换句话说,我们可以通过单个矩阵乘法操作,在单个矩阵乘法中匹配多个查询与多个数据库键。例如,在翻译的上下文中,我们可以计算目标句子中所有单词与源句子中所有单词的对齐分数。
-
使用https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/677.png 缩放对齐分数,其中https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/678.png 与矩阵 K 中键向量的大小相同,也等于 Q 中查询向量的大小(类似地,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/679.png 是值向量 V 的大小)。文中作者怀疑,在https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/680.png 的大值情况下,点积的幅度增大,推动 softmax 函数进入极小梯度区域。这反过来导致梯度消失问题,因此需要对结果进行缩放。
-
对矩阵的行应用 softmax 操作来计算注意力分数(稍后我们会讨论掩码操作):
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/681.png
- 通过将注意力得分与值V相乘,计算最终的注意力向量:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/682.png
完整的 TA 使用一组注意力块,称为多头注意力(MHA),如以下图所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_7.jpg
图 7.7 – MHA(灵感来自 arxiv.org/abs/1706.03762
)
不同于单一的注意力函数,其中包含https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/683.png-维度的键,我们将键、查询和数值线性投影h次,以生成h个不同的https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/684.png-维,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/684.png-维和https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/686.png-维的这些数值投影。然后,我们对新创建的向量应用独立的并行注意力块(或头部),每个头部生成一个https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/687.png-维度的输出。接着,我们将这些头部输出连接起来,并进行线性投影,生成最终的注意力结果。
注意
线性投影是指应用全连接(FC)层。也就是说,最初我们借助单独的 FC 操作将Q/K/V矩阵分支处理。最终,我们使用一个 FC 层来合并并压缩连接后的头部输出。在这种情况下,我们遵循原论文中使用的术语。
MHA 允许每个头部关注序列的不同元素。同时,模型将各个头部的输出合并为一个统一的表示。更精确地说,我们可以通过以下公式来定义这一过程:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/688.png
这里是 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/689.png。
让我们更详细地了解一下,从头部开始:
-
每个头接收初始Q、K和V矩阵的线性投影版本。这些投影是通过可学习的权重矩阵计算的!<mml:math xmlns:mml=“http://www.w3.org/1998/Math/MathML” xmlns:m=“http://schemas.openxmlformats.org/officeDocument/2006/math”>mml:msubsupmml:mrow<mml:mi mathvariant=“bold”>W</mml:mi></mml:mrow>mml:mrowmml:mii</mml:mi></mml:mrow>mml:mrowmml:miQ</mml:mi></mml:mrow></mml:msubsup></mml:math>、https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/691.png和https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/692.png,分别计算出来(再次强调,投影是全连接层)。注意,我们为每个组件(Q、K、V)以及每个头,i,拥有一组单独的权重。为了满足从https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/693.png到https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/680.png和https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/695.png的转换,这些矩阵的维度是https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/696.png,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/697.png和https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/698.png。
-
一旦Q、K和V被转换,我们就可以使用本节开头描述的常规注意力块来计算每个头的注意力。
-
最终的注意力结果是线性投影(带有权重矩阵 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/699.png,可学习的权重)作用于拼接的头输出 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/700.png。
到目前为止,我们假设注意力机制适用于不同的输入和输出序列。例如,在翻译中,每个翻译句子的单词会 关注 源句子的单词。然而,注意力还有另一个有效的应用场景。变换器也依赖于自注意力(或内部注意力),其中查询 Q 来自于与键 K 和值 V 相同的数据集。换句话说,在自注意力中,源和目标是相同的序列(在我们的例子中,就是同一句话)。自注意力的好处并不是立刻显而易见的,因为没有直接的任务来应用它。从直观的角度来看,它使我们能够看到同一序列中单词之间的关系。为了理解为什么这很重要,让我们回想一下 word2vec 模型(第六章),在这个模型中,我们利用一个词的上下文(即它周围的词)来学习该词的嵌入向量。word2vec 的一个局限性是其嵌入是静态的(或与上下文无关的)——我们为该词在整个训练语料库中的所有上下文使用相同的嵌入向量。例如,无论我们将 new 使用在 new shoes 还是 New York 中,它的嵌入向量都是相同的。自注意力使我们能够通过创建该词的动态嵌入(或上下文相关)来解决这个问题。我们暂时不深入细节(我们将在构建带注意力机制的变换器一节中讲解),但是动态嵌入的工作原理如下:我们将当前词输入注意力模块,同时也输入其当前的即时上下文(即周围词)。该词是查询 q,而上下文是 K/V 键值存储。通过这种方式,自注意力机制使模型能够产生一个动态的嵌入向量,这个向量是针对该词当前上下文的独特表示。这个向量作为各种下游任务的输入。它的目的类似于静态的 word2vec 嵌入,但它更具表现力,并使得能够以更高的准确度解决更复杂的任务。
我们可以通过以下图示来说明自注意力是如何工作的,图中展示了词 economy 的多头自注意力(不同的颜色代表不同的注意力头):
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_8.jpg
图 7.8 – 词“economy”的多头自注意力(由 https://github.com/jessevig/bertviz 生成)
我们可以看到,economy 与词 market 的关联最强,这很有道理,因为这两个词组成了一个具有独特含义的短语。然而,我们也可以看到,不同的注意力头会关注输入序列中的不同、更远的部分。
作为本节的总结,让我们概括一下注意力机制相较于 RNN 处理序列的优势:
-
直接访问序列元素:RNN 将输入元素的信息编码为一个单一的隐藏层(思维向量)。理论上,它代表了目前为止所有序列元素的浓缩版本。实际上,它的表示能力有限——它只能在最大长度约为 100 个标记的序列中保留有意义的信息,在此之后最新的标记开始覆盖旧的标记信息。
相比之下,注意力机制提供了对所有输入序列元素的直接访问。一方面,这对最大序列长度施加了严格的限制;另一方面,它使得在本书编写时,基于 Transformer 的 LLM 能够处理超过 32,000 个标记的序列。
-
输入序列的并行处理:RNN 按照元素到达的顺序逐一处理输入序列元素。因此,我们无法对 RNN 进行并行化。与此相比,注意力机制完全由矩阵乘法操作构成,显著并行。这使得能够在大规模训练数据集上训练具有数十亿可训练参数的 LLM。
但是这些优势伴随着一个劣势——当 RNN 保留序列元素的顺序时,注意力机制由于其直接访问的特性,则没有保留顺序。然而,我们将在Transformer 编码器部分介绍一种解决该限制的变通方法。
这就结束了我们对 TA 的理论介绍。接下来,让我们实现它。
实现 TA
在本节中,我们将实现 MHA,遵循 Transformer 注意力 部分的定义。本节中的代码是更大 Transformer 实现的一部分,我们将在本章中讨论这些内容。我们不会提供完整的源代码,但你可以在本书的 GitHub 仓库中找到它。
注意
这个示例基于 github.com/harvardnlp/annotated-transformer
。还需要注意的是,PyTorch 有原生的 Transformer 模块(文档可在 pytorch.org/docs/stable/generated/torch.nn.Transformer.html
查阅)。尽管如此,在本节中,我们将从头实现 TA,以便更好地理解它。
我们将从常规缩放点积注意力的实现开始。提醒一下,它实现的公式是 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/701.png,其中 query
,key
和 value
:
def attention(query, key, value, mask=None, dropout=None):
d_k = query.size(-1)
# 1) and 2) Compute the alignment scores with scaling
scores = (query @ key.transpose(-2, -1)) \
/ math.sqrt(d_k)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
# 3) Compute the attention scores (softmax)
p_attn = scores.softmax(dim=-1)
if dropout is not None:
p_attn = dropout(p_attn)
# 4) Apply the attention scores over the values
return p_attn @ value, p_attn
attention
函数包括 dropout
,因为它是完整的 transformer 实现的一部分。我们暂时将 mask
参数及其用途留到后面再讨论。还需要注意一个新细节——@
操作符的使用(query @ key.transpose(-2, -1)
和 p_attn @ value
),自 Python 3.5 起,它已被保留用于矩阵乘法。
接下来,让我们继续实现 MHA。提醒一下,实现遵循以下公式:https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/702.png。这里,head i = Attention(Q W i Q,
K W i K,V W i V。
我们将其实现为torch.nn.Module
的子类,名为MultiHeadedAttention
。我们将从构造函数开始:
class MultiHeadedAttention(torch.nn.Module):
def __init__(self, h, d_model, dropout=0.1):
"""
:param h: number of heads
:param d_model: query/key/value vector length
"""
super(MultiHeadedAttention, self).__init__()
assert d_model % h == 0
# We assume d_v always equals d_k
self.d_k = d_model // h
self.h = h
# Create 4 fully connected layers
# 3 for the query/key/value projections
# 1 to concatenate the outputs of all heads
self.fc_layers = clones(
torch.nn.Linear(d_model, d_model), 4)
self.attn = None
self.dropout = torch.nn.Dropout(p=dropout)
请注意,我们使用clones
函数(在 GitHub 上实现)来创建四个相同的全连接self.fc_layers
实例。我们将使用其中三个作为self.attn
属性。
接下来,让我们实现MultiHeadedAttention.forward
方法。请记住,声明应该缩进,因为它是MultiHeadedAttention
类的一个属性:
def forward(self, query, key, value, mask=None):
if mask is not None:
# Same mask applied to all h heads.
mask = mask.unsqueeze(1)
batch_samples = query.size(0)
# 1) Do all the linear projections in batch from d_model => h x d_k
projections = [
l(x).view(batch_samples, -1, self.h, self.d_k)
.transpose(1, 2)
for l, x in zip(self.fc_layers, (query, key, value))
]
query, key, value = projections
# 2) Apply attention on all the projected vectors in batch.
x, self.attn = attention(
query, key, value,
mask=mask,
dropout=self.dropout)
# 3) "Concat" using a view and apply a final linear.
x = x.transpose(1, 2).contiguous() \
.view(batch_samples, -1, self.h * self.d_k)
return self.fc_layers-1
我们遍历query
/key
/value
张量及其参考投影self.fc_layers
,并使用以下代码片段生成query
/key
/value
的投影:
l(x).view(batch_samples, -1, self.h, self.d_k).transpose(1, 2)
然后,我们使用我们最初定义的注意力函数,对投影进行常规注意力操作。接下来,我们将多个头的输出进行拼接,最后将它们传递到最后的全连接层(self.fc_layers[-1]
)并返回结果。
现在我们已经讨论了 TA,让我们继续深入讲解变换器模型本身。
构建具有注意力机制的变换器
我们在本章的大部分内容中都在强调注意力机制的优势。现在是时候揭示完整的变换器架构了,它与 RNN 不同,完全依赖于注意力机制(Attention Is All You Need,arxiv.org/abs/1706.03762
)。下图展示了两种最流行的变换器类型,后归一化和前归一化(或后归一化和前归一化):
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_9.jpg
图 7.9 – 左:原始(后归一化,后 ln)变换器;右:前归一化(前 ln)变换器(灵感来源于 https://arxiv.org/abs/1706.03762)
它看起来很复杂,但别担心——其实比想象的更简单。在本节中,我们将以 seq2seq 任务为背景讨论变换器,该任务我们在引入 seq2seq 模型一节中已经定义。也就是说,它将接受一组标记序列作为输入,并输出另一组不同的标记序列。与 seq2seq 模型一样,它有两个组成部分——编码器和解码器。我们将从编码器开始(即前面图中两部分的左侧)。
变换器编码器
编码器以一组独热编码的标记序列作为输入。最常用的标记化算法有字节对编码(BPE)、WordPiece 和 Unigram(第六章)。这些标记会被转换成https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/707.png-维度的嵌入向量。这一转换方式如我们在第六章中所描述。我们有一个查找表(矩阵)——独热编码标记的索引指示矩阵的行,该行代表嵌入向量。嵌入向量进一步与https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/708.png相乘。它们被随机初始化,并与整个模型一起训练(这与使用 word2vec 等算法初始化不同)。
下一步是将位置位置信息添加到现有的嵌入向量中。这是必要的,因为注意力机制无法保留序列元素的顺序。此步骤通过一种方式修改嵌入向量,隐式地将位置信息编码到其中。
原始的 Transformer 实现使用静态位置编码,通过与词嵌入大小相同的特殊位置编码向量来表示。我们将这些向量使用逐元素加法的方式加到序列中的所有嵌入向量上,具体取决于它们的位置。静态编码对于序列的每个位置都是唯一的,但对于序列元素而言是常量。由于这个原因,我们可以仅预计算位置编码一次,然后在后续使用。
编码位置信息的另一种方式是使用相对位置表示
(相对位置表示的自注意力, arxiv.org/abs/1803.02155
)。在这里,位置信息在注意力块的键值矩阵 K/V 中动态编码。输入序列的每个元素相对于其余元素的位置都是不同的。因此,相对位置编码对于每个标记动态计算。此编码作为注意力公式的附加部分应用于 K 和 V 矩阵。
编码器的其余部分由 N=6 个相同的块组成,这些块有两种类型:post-ln 和 pre-ln。这两种类型的块共享以下子层:
-
一个多头自注意力机制,就像我们在Transformer 注意力部分描述的那样。由于自注意力机制作用于整个输入序列,编码器在设计上是双向的。也就是说,当前标记的上下文包括序列中当前标记之前和之后的标记。这与常规的 RNN 相对立,后者只能访问当前标记之前的标记。在一个编码器块中,每个位置都可以关注前一个编码器块中的所有位置。
我们将每个标记的嵌入作为查询 q 输入到多头自注意力中(我们可以将整个输入序列作为一个输入矩阵 Q 一次性传递)。同时,其上下文的嵌入作为键值存储 K/V。多头自注意力操作的输出向量作为模型其余部分的输入。
MHA 和激活函数
MHA 为每个输入标记产生 h 个注意力向量,分别来自每个 h 个注意力头。然后,这些向量通过一个全连接(FC)层进行线性投影并进行结合。整个注意力块没有显式的激活函数。但让我们回想一下,注意力块的结束有一个非线性函数——softmax。键值向量的点积是一个额外的部分。
非线性。从严格意义上讲,注意力块不需要额外的激活函数。
- 一个简单的全连接 FFN,由以下公式定义:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/709.png
网络应用于每个序列元素 x,并且是独立进行的。它使用相同的参数集 (https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/710.png,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/711.png,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/712.png,和 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/713.png),但不同的编码器块使用不同的参数。原始 transformer 使用 整流线性单元(ReLU)激活函数。然而,近年来的模型使用其变种之一,如 sigmoid 线性单元(SiLU)。FFN 的作用是以更适合下一个块输入的方式处理 MHA 输出。
pre-ln 和 post-ln 块之间的区别在于归一化层的位置。每个 post-ln 子层(包括 MHA 和 FFN)都有一个残差连接,并以该连接和自身输出的和进行归一化和丢弃。post-ln transformer 中的归一化层分别位于注意力机制和 FFN 之后。因此,每个 post-ln 子层的输出如下:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/714.png
相比之下,pre-ln 块(图 7.9的右侧部分)在两个编码器归一化层中,分别位于注意力机制和 FFN 之前。因此,每个 pre-ln 子层的输出是这样的:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/715.png
两种变体的差异在训练过程中表现得尤为明显。无需深入细节,恰如其分地命名的论文理解训练变压器的难度(arxiv.org/abs/2004.08249
)表明,后-ln 变压器对残差连接的强烈依赖放大了由于参数变化(例如自适应学习率)引起的波动,从而使训练不稳定。因为这个原因,后-ln 训练从一个较低学习率的预热阶段开始,然后逐渐增加学习率。这与通常的学习率调度相反,通常从较大的学习率开始,并随着训练的进行逐渐减小。而 pre-ln 块则没有这个问题,也不需要预热阶段。然而,它们可能会遭遇表示崩溃的问题,即深层块(靠近神经网络末端的块)中的隐藏表示会变得相似,从而对模型容量贡献较小。在实践中,两种类型的块都会被使用。
到目前为止,编码器部分进展顺利。接下来,让我们在构建编码器的过程中,继续完善我们的注意力实现。
实现编码器
在这一部分,我们将实现后-ln 编码器,它由几个不同的子模块组成。让我们从主要类Encoder
开始:
class Encoder(torch.nn.Module):
def __init__(self, block: EncoderBlock, N: int):
super(Encoder, self).__init__()
self.blocks = clones(block, N)
self.norm = torch.nn.LayerNorm(block.size)
def forward(self, x, mask):
"""Iterate over all blocks and normalize"""
for layer in self.blocks:
x = layer(x, mask)
return self.norm(x)
它堆叠了N
个EncoderBlock
实例(self.blocks
),后面跟着一个LayerNorm
归一化层(self.norm
)。每个实例都作为下一个实例的输入,如forward
方法的定义所示。除了常规的输入x
外,forward
方法还接受一个mask
参数作为输入。但它只与解码器部分相关,因此我们在这里不会关注它。
接下来,让我们看看EncoderBlock
类的实现:
class EncoderBlock(torch.nn.Module):
def __init__(self,
size: int,
self_attn: MultiHeadedAttention,
ffn: PositionwiseFFN,
dropout=0.1):
super(EncoderBlock, self).__init__()
self.self_attn = self_attn
self.ffn = ffn
# Create 2 sub-layer connections
# 1 for the self-attention
# 1 for the FFN
self.sublayers = clones(SublayerConnection(size, dropout), 2)
self.size = size
def forward(self, x, mask):
x = self.sublayers0)
return self.sublayers1
每个编码器块由多头自注意力(self.self_attn
)和 FFN(self.ffn
)子层(self.sublayers
)组成。每个子层都被其残差连接(SublayerConnection
类)包裹,并通过熟悉的 clone
函数实例化:
class SublayerConnection(torch.nn.Module):
def __init__(self, size, dropout):
super(SublayerConnection, self).__init__()
self.norm = torch.nn.LayerNorm(size)
self.dropout = torch.nn.Dropout(dropout)
def forward(self, x, sublayer):
return x + self.dropout(sublayer(self.norm(x)))
SublayerConnection.forward
方法接受数据张量 x
和 sublayer
作为输入,sublayer
是 MultiHeadedAttention
或 PositionwiseFFN
的实例(它与来自 Transformer encoder 部分的子层定义https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/716.png相匹配)。
我们尚未定义的唯一组件是 PositionwiseFFN
,它实现了公式 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/717.png。我们将使用 SiLU 激活函数。现在,让我们补充这一缺失的部分:
class PositionwiseFFN(torch.nn.Module):
def __init__(self, d_model: int, d_ff: int, dropout=0.1):
super(PositionwiseFFN, self).__init__()
self.w_1 = torch.nn.Linear(d_model, d_ff)
self.w_2 = torch.nn.Linear(d_ff, d_model)
self.dropout = torch.nn.Dropout(dropout)
def forward(self, x):
return self.w_2(
self.dropout(
torch.nn.functional.silu(
self.w_1(x)
)))
这标志着我们对编码器部分的实现完成。接下来,让我们将注意力集中在解码器上。
Transformer 解码器
解码器基于编码器输出和自身先前生成的标记序列的组合生成输出序列(我们可以在图 7.9的构建带注意力的变换器部分的两侧看到解码器)。在 seq2seq 任务的背景下,完整的编码器-解码器变换器是一个自回归模型。首先,我们将初始序列——例如,待翻译的句子或待回答的问题——输入给编码器。如果序列足够短,可以适应查询矩阵的最大大小,这可以在一次传递中完成。一旦编码器处理完所有序列元素,解码器将使用编码器的输出开始逐个标记地生成输出序列。它将每个生成的标记附加到初始输入序列中。我们将新的扩展序列再次输入给编码器。编码器的新输出将启动解码器的下一步标记生成,如此循环。实际上,目标标记序列与输入标记序列相同,且位移了一位(类似于 seq2seq 解码器)。
解码器使用与编码器相同的嵌入向量和位置编码。它继续通过堆叠N=6个相同的解码器块。每个块由三个子层组成,每个子层都使用残差连接、丢弃法和归一化。与编码器一样,这些块有后-ln 和前-ln 两种形式。子层如下:
- 一种带掩码的多头自注意力机制。编码器的自注意力是双向的——它可以关注序列中的所有元素,无论这些元素是在当前元素之前还是之后。然而,解码器只有部分生成的目标序列。因此,解码器是单向的——自注意力只能关注前面的序列元素。在推理过程中,我们别无选择,只能以顺序的方式运行转换器,从而让它一个接一个地生成输出序列中的每个标记。然而,在训练过程中,我们可以同时输入整个目标序列,因为它是提前已知的。为了避免非法的前向注意力,我们可以通过在注意力的软最大输入中将所有非法值设为−∞来屏蔽非法连接。我们可以在图 7.6中的Transformer 注意力部分看到掩码组件,掩码操作的结果如下:
![<mml:math xmlns:mml=“http://www.w3.org/1998/Math/MathML” xmlns:m=“http://schemas.openxmlformats.org/officeDocument/2006/math” display=“block”><mml:mi mathvariant=“normal”>m</mml:mi><mml:mi mathvariant=“normal”>a</mml:mi><mml:mi mathvariant=“normal”>s</mml:mi><mml:mi mathvariant=“normal”>k</mml:mi><mml:mfenced separators=“|”>mml:mrow<mml:mi mathvariant=“bold”>Q</mml:mi>mml:msupmml:mrow<mml:mi mathvariant=“bold”>K</mml:mi></mml:mrow>mml:mrow<mml:mi mathvariant=“normal”>⊤</mml:mi></mml:mrow></mml:msup></mml:mrow></mml:mfenced>mml:mo=</mml:mo><mml:mi mathvariant=“normal”>m</mml:mi><mml:mi mathvariant=“normal”>a</mml:mi><mml:mi mathvariant=“normal”>s</mml:mi><mml:mi mathvariant=“normal”>k</mml:mi><mml:mfenced separators=“|”>mml:mrow<mml:mfenced open=“[” close=“]” separators=“|”>mml:mrowmml:mtablemml:mtrmml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn11</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:mo⋯</mml:mo></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn1</mml:mn><mml:mi mathvariant=“bold-italic”>n</mml:mi></mml:mrow></mml:msub></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:mo⋮</mml:mo></mml:mtd>mml:mtdmml:mo⋱</mml:mo></mml:mtd>mml:mtdmml:mo⋮</mml:mo></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrow<mml:mi mathvariant=“bold-italic”>m</mml:mi>mml:mn1</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:mo⋯</mml:mo></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrow<mml:mi mathvariant=“bold-italic”>m</mml:mi><mml:mi mathvariant=“bold-italic”>n</mml:mi></mml:mrow></mml:msub></mml:mtd></mml:mtr></mml:mtable></mml:mrow></mml:mfenced></mml:mrow></mml:mfenced>mml:mo=</mml:mo><mml:mfenced open=“[” close=“]” separators=“|”>mml:mrowmml:mtablemml:mtrmml:mtdmml:mtablemml:mtrmml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn11</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:mo-</mml:mo>mml:mi∞</mml:mi></mml:mtd>mml:mtdmml:mo-</mml:mo>mml:mi∞</mml:mi></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn21</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn22</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:mo-</mml:mo>mml:mi∞</mml:mi></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn31</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn32</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mn33</mml:mn></mml:mrow></mml:msub></mml:mtd></mml:mtr></mml:mtable></mml:mtd>mml:mtdmml:mo⋯</mml:mo></mml:mtd>mml:mtdmml:mtablemml:mtrmml:mtdmml:mo-</mml:mo>mml:mi∞</mml:mi></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:mo-</mml:mo>mml:mi∞</mml:mi></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:mo-</mml:mo>mml:mi∞</mml:mi></mml:mtd></mml:mtr></mml:mtable></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:mo⋮</mml:mo></mml:mtd>mml:mtdmml:mo⋱</mml:mo></mml:mtd>mml:mtdmml:mo⋮</mml:mo></mml:mtd></mml:mtr>mml:mtrmml:mtdmml:mtablemml:mtrmml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrow<mml:mi mathvariant=“bold-italic”>m</mml:mi>mml:mn1</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mim</mml:mi>mml:mn2</mml:mn></mml:mrow></mml:msub></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrowmml:mim</mml:mi>mml:mn3</mml:mn></mml:mrow></mml:msub></mml:mtd></mml:mtr></mml:mtable></mml:mtd>mml:mtdmml:mo⋯</mml:mo></mml:mtd>mml:mtdmml:msubmml:mrowmml:mie</mml:mi></mml:mrow>mml:mrow<mml:mi mathvariant=“bold-italic”>m</mml:mi><mml:mi mathvariant=“bold-italic”>n
-
常规注意力机制(而不是自注意力机制),查询来自前一个解码器层,键和值来自编码器输出。这使得解码器中的每个位置都能关注原始输入序列中的所有位置。这模拟了我们在引入 seq2seq 模型部分中讨论的典型编码器-解码器注意力机制。
-
FFN,与编码器中的类似。
解码器以一个全连接层结束,接着是 softmax 操作,它生成句子中最可能的下一个单词。
我们可以使用在引入 seq2seq 模型部分中定义的教师强迫过程来训练完整的编码器-解码器模型。
接下来,我们来实现解码器。
实现解码器
在这一部分,我们将以与编码器类似的模式实现解码器。我们将从主模块Decoder
的实现开始:
class Decoder(torch.nn.Module):
def __init__(self, block: DecoderBlock, N: int, vocab_size: int):
super(Decoder, self).__init__()
self.blocks = clones(block, N)
self.norm = torch.nn.LayerNorm(block.size)
self.projection = torch.nn.Linear(block.size, vocab_size)
def forward(self, x, encoder_states, source_mask, target_mask):
for layer in self.blocks:
x = layer(x, encoder_states, source_mask, target_mask)
x = self.norm(x)
return torch.nn.functional.log_softmax(self.projection(x), dim=-1)
它由N
个DecoderBlock
实例(self.blocks
)组成。正如我们在forward
方法中看到的,每个DecoderBlock
实例的输出作为下一个实例的输入。这些后面跟着self.norm
归一化(一个LayerNorm
实例)。解码器以一个全连接层(self.projection
)结束,接着是 softmax 操作,以生成最可能的下一个单词。需要注意的是,Decoder.forward
方法有一个额外的参数encoder_states
,该参数会传递给DecoderBlock
实例。encoder_states
表示编码器的输出,是编码器和解码器之间的联系。此外,source_mask
参数提供了解码器自注意力的掩码。
接下来,我们来实现DecoderBlock
类:
class DecoderBlock(torch.nn.Module):
def __init__(self,
size: int,
self_attn: MultiHeadedAttention,
encoder_attn: MultiHeadedAttention,
ffn: PositionwiseFFN,
dropout=0.1):
super(DecoderBlock, self).__init__()
self.size = size
self.self_attn = self_attn
self.encoder_attn = encoder_attn
self.ffn = ffn
self.sublayers = clones(SublayerConnection(size,
dropout), 3)
def forward(self, x, encoder_states, source_mask, target_mask):
x = self.sublayers0)
x = self.sublayers1)
return self.sublayers2
该实现遵循EncoderBlock
的模式,但针对解码器进行了调整:除了自注意力(self_attn
),我们还增加了编码器注意力(encoder_attn
)。因此,我们实例化了三个sublayers
实例(即熟悉的SublayerConnection
类的实例):分别用于自注意力、编码器注意力和 FFN。
我们可以看到在DecoderBlock.forward
方法中结合了多种注意力机制。encoder_attn
以前一个解码器块的输出(x
)作为查询,并结合编码器输出(encoder_states
)的键值对。通过这种方式,常规注意力机制在编码器和解码器之间建立了联系。另一方面,self_attn
使用x
作为查询、键和值。
这就完成了解码器的实现。接下来,我们将在下一部分中构建完整的 transformer 模型。
将所有内容结合起来
现在我们已经实现了编码器和解码器。接下来让我们将它们组合在一起,构建完整的EncoderDecoder
类:
class EncoderDecoder(torch.nn.Module):
def __init__(self,
encoder: Encoder,
decoder: Decoder,
source_embeddings: torch.nn.Sequential,
target_embeddings: torch.nn.Sequential):
super(EncoderDecoder, self).__init__()
self.encoder = encoder
self.decoder = decoder
self.source_embeddings = source_embeddings
self.target_embeddings = target_embeddings
def forward(self, source, target, source_mask, target_mask):
encoder_output = self.encoder(
x=self.source_embeddings(source),
mask=source_mask)
return self.decoder(
x=self.target_embeddings(target),
encoder_states=encoder_output,
source_mask=source_mask,
target_mask=target_mask)
它将 encoder
、decoder
、source_embeddings
和 target_embeddings
结合在一起。forward
方法接受源序列并将其输入到 encoder
中。然后,decoder
从前一步的输出中获取输入(x=self.target_embeddings(target)
),以及编码器的状态(encoder_states=encoder_output
)和源目标掩码。使用这些输入,它生成预测的下一个词(或标记),这是 forward
方法的返回值。
接下来,我们将实现 build_model
函数,该函数实例化我们实现的所有类,生成一个单一的 Transformer 实例:
def build_model(source_vocabulary: int,
target_vocabulary: int,
N=6, d_model=512, d_ff=2048, h=8, dropout=0.1):
c = copy.deepcopy
attn = MultiHeadedAttention(h, d_model)
ff = PositionwiseFFN(d_model, d_ff, dropout)
position = PositionalEncoding(d_model, dropout)
model = EncoderDecoder(
encoder=Encoder(
EncoderBlock(d_model, c(attn), c(ff), dropout), N),
decoder=Decoder(
DecoderBlock(d_model, c(attn), c(attn),
c(ff), dropout), N, target_vocabulary),
source_embeddings=torch.nn.Sequential(
Embeddings(d_model, source_vocabulary), c(position)),
target_embeddings=torch.nn.Sequential(
Embeddings(d_model, target_vocabulary), c(position)))
# Initialize parameters with random weights
for p in model.parameters():
if p.dim() > 1:
torch.nn.init.xavier_uniform_(p)
return model
除了熟悉的 MultiHeadedAttention
和 PositionwiseFFN
,我们还创建了一个 position
变量(PositionalEncoding
类的实例)。这个类实现了我们在 Transformer 编码器 部分描述的静态位置编码(我们不会在这里包含完整的实现)。
现在,让我们关注 EncoderDecoder
的实例化:我们已经熟悉了 encoder
和 decoder
,所以这部分没有什么意外。但嵌入层则更为有趣。以下代码实例化了源嵌入(但这对目标嵌入同样有效):
source_embeddings=torch.nn.Sequential(Embeddings(d_model, source_vocabulary), c(position))
我们可以看到,它们是两个组件的顺序列表:
-
Embeddings
类的一个实例,它只是torch.nn.Embedding
的组合,进一步乘以 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/719.png(我们这里不包括类定义) -
位置编码
c(position)
,它将静态位置数据添加到嵌入向量中
一旦我们以这种方式预处理了输入数据,它就可以作为编码器-解码器核心部分的输入。
在下一部分中,我们将讨论 Transformer 架构的主要变体。
仅解码器和仅编码器模型
到目前为止,我们已经讨论了 Transformer 架构的完整编码器-解码器变体。但在实际应用中,我们主要会使用它的两种变体:
-
仅编码器:这些模型只使用完整 Transformer 的编码器部分。仅编码器模型是双向的,遵循编码器自注意力的特性。
-
仅解码器:这些模型只使用 Transformer 的解码器部分。仅解码器模型是单向的,遵循解码器的掩蔽自注意力特性。
我知道这些枯燥的定义听起来有些模糊,但别担心——在接下来的两部分中,我们将讨论每种类型的一个例子来澄清。
来自 Transformer 的双向编码器表示
来自变压器的双向编码器表示 (BERT; 详见 arxiv.org/abs/1810.04805
),顾名思义,是一种仅编码器(因此是双向的)模型,用于学习表示。这些表示作为解决各种下游任务的基础(纯 BERT 模型不解决任何特定问题)。以下图表显示了通用的前层归一化和后层归一化编码器模型,带有 softmax 输出(这也适用于 BERT):
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_10.jpg
图 7.10 – 左:后层归一化编码器模型;右:前层归一化编码器模型
BERT 模型规格
BERT 有两种变体https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/720.png 和 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/721.png。 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/722.png 包含 12 个编码器块,每个块有 12 个注意力头,768 维注意力向量(https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/723.png 参数),总共 110M 参数。 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/724.png 包含 24 个编码器块,每个块有 16 个注意力头,1,024 维注意力向量,总共 340M 参数。这些模型使用 WordPiece 分词,并有一个 30,000 个词汇量。
让我们从 BERT 表示其输入数据的方式开始,这是其架构的重要部分。我们可以在以下图表中看到输入数据表示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_11.jpg
图 7.11 – BERT 输入嵌入作为标记嵌入、分段嵌入和位置嵌入的总和(来源:https://arxiv.org/abs/1810.04805)
由于 BERT 是仅编码器模型,它有两种特殊的输入数据表示模式,使其能够处理各种下游任务:
-
单一序列(例如,在分类任务中,如情感分析,或SA)
-
一对序列(例如,机器翻译或问答(QA)问题)
每个序列的第一个标记总是一个特殊的分类标记[CLS]
。与此标记对应的编码器输出作为分类任务的聚合序列表示。例如,如果我们想对序列进行 SA 分析,[CLS]
输入标记对应的输出将表示模型的情感(正面/负面)输出(当输入数据是单一序列时,此示例相关)。这是必要的,因为[CLS]
标记充当查询,而输入序列的其他所有元素充当键/值存储。通过这种方式,序列中的所有标记都参与加权注意力向量,它作为输入进入模型的其余部分。选择除了[CLS]
以外的其他标记将把该标记排除在注意力公式之外,这会导致对该标记的不公平偏见,并导致不完整的序列。
如果输入数据是一个序列对,我们将它们合并成一个单一的序列,用特殊的[SEP]
标记分隔。除此之外,我们为每个标记添加了额外学习到的分段嵌入,表示它属于序列 A 还是序列 B。因此,输入嵌入是标记嵌入、分段嵌入和位置嵌入的总和。在这里,标记嵌入和位置嵌入的作用与常规的 Transformer 相同。
现在我们已经熟悉了输入数据的表示形式,让我们继续进行训练。
BERT 训练
BERT 训练是一个两步过程(这对其他基于 Transformer 的模型同样适用):
-
预训练:在不同的预训练任务上使用无标签数据训练模型。
-
微调:一种迁移学习(TL)的形式,我们通过预训练的参数初始化模型,并在特定下游任务的有标签数据集上进行微调。
我们可以在下面的图中看到左侧是预训练,右侧是微调:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_12.jpg
图 7.12 – 左:预训练;右:微调(来源:https://arxiv.org/abs/1810.04805)
这里,Tok N表示一热编码输入标记,E表示标记嵌入,T表示模型输出向量。最顶部的标签表示我们可以在每种训练模式下使用模型进行的不同任务。
本文的作者通过两个无监督的训练任务对模型进行了预训练:掩码语言模型(MLM)和下一句预测(NSP)。
我们从 MLM(掩码语言模型)开始,其中模型输入一个序列,目标是预测该序列中缺失的单词。MLM 本质上类似于[MASK]
标记(80%的情况)、一个随机单词(10%的情况),或者保持单词不变(10%的情况)。这是必要的,因为下游任务的词汇表中没有[MASK]
标记。另一方面,预训练模型可能会期望它,这可能导致不可预测的行为。
接下来,让我们继续讨论 NSP(下一句预测)。作者认为,许多重要的下游任务,如问答或自然语言推理(NLI),基于理解两个句子之间的关系,而这种关系并未被语言建模直接捕捉。
NLI
NLI(自然语言推理)决定一个句子(表示假设)在给定另一个句子(称为前提)的情况下,是否为真(蕴含)、假(矛盾)或未确定(中立)。例如,给定前提我在跑步,我们有以下假设:我在睡觉是假的;我在听音乐是未确定的;我在训练是真的。
BERT 的作者提出了一种简单优雅的无监督解决方案来预训练模型,理解句子之间的关系(如图 7.12左侧所示)。我们将模型训练为二分类任务,每个输入样本以[CLS]
标记开始,由两个序列(为了简便,我们称之为句子A和B)组成,这两个序列通过[SEP]
标记分隔。我们将从训练语料库中提取句子A和B。在 50%的训练样本中,B是紧跟A的实际下一个句子(标记为is_next
)。在另外 50%的样本中,B是语料库中的随机句子(标记为not_next
)。如我们所提到,模型将在与输入对应的[CLS]
上输出is_next
/not_next
标签。
接下来,让我们专注于微调任务,它是在预训练任务之后进行的(如图 7.12右侧所示)。这两个步骤非常相似,但不同的是,我们不再创建一个掩码序列,而是将任务特定的未修改输入和输出直接输入 BERT 模型,并以端到端的方式微调所有参数。因此,我们在微调阶段使用的模型与我们将在实际生产环境中使用的模型相同。
让我们继续探讨一些可以通过 BERT 解决的下游任务。
BERT 下游任务
以下图表展示了如何通过 BERT 解决几种不同类型的任务:
https://arxiv.org/abs/1810.04805)](https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_13.jpg)
图 7.13 – BERT 在不同任务中的应用(来源:arxiv.org/abs/1810.04805
)
让我们来讨论一下这两个任务:
-
左上方的场景展示了如何使用 BERT 进行句子对分类任务,如 NLI。简而言之,我们将两个连接的句子输入模型,并只查看
[CLS]
令牌的输出分类,进而得出模型结果。例如,在 NLI 任务中,目标是预测第二个句子与第一个句子的关系,是蕴含、矛盾还是中立。 -
右上方的场景展示了如何使用 BERT 进行单句分类任务,如 SA。这与句子对分类非常相似。在这两种情况下,我们都将编码器扩展为一个全连接层(FC 层)和一个二进制 Softmax,并有N个可能的类别(N是每个任务的类别数)。
-
左下方的场景展示了如何在 QA 数据集上使用 BERT。假设序列A是一个问题,序列B是来自Wikipedia的段落,包含答案,目标是预测答案在该段落中的文本跨度(起始和结束)。模型输出序列B中每个令牌作为答案起始或结束的概率。
-
右下方的场景展示了如何使用 BERT 进行命名实体识别(NER),其中每个输入令牌都被分类为某种类型的实体。
这就是我们对 BERT 模型部分的介绍。接下来,让我们聚焦于仅解码器模型。
生成预训练变换器
在本节中,我们将讨论一个仅解码器模型,称为生成预训练变换器(GPT;参见通过生成预训练提升语言理解,cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf
)。这是 OpenAI 发布的一系列 GPT 模型中的第一个,后续推出了如今著名的 GPT-3 和 GPT-4。
GPT 模型的规模
GPT 有 12 个解码器层,每个层有 12 个注意力头,768 维度的注意力向量。FFN 的维度为 3,072。该模型共有 1.17 亿个参数(权重)。GPT 使用 BPE 分词,并且具有 40,000 个令牌词汇表。
我们可以在下面的图中看到 GPT 仅解码器架构:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_14.jpg
图 7.14 – 左:后-ln 解码器模型;右:前-ln 解码器模型;预训练和微调训练步骤的不同输出
注
我们在原始 GPT 论文的背景下讨论仅解码器架构,但它适用于广泛的仅解码器模型类。
它源自我们在Transformer 解码器部分讨论的解码器。该模型将令牌嵌入作为输入,并添加静态位置编码。接着是一个N个解码器块的堆叠。每个块有两个子层:
-
掩蔽的多头自注意力:我们重点强调“掩蔽”部分。它决定了仅解码器模型的主要特性——单向性和自回归性。这与双向的编码器模型是对立的。
-
FFN:这个子层的作用与编码器-解码器模型中的作用相同。
子层包含相关的残差链接、归一化和丢弃层。解码器有预-ln 和后-ln 两种形式。
模型以一个全连接层(FC)结束,后面跟着一个 softmax 操作,可以根据具体任务进行调整。
该解码器与完整编码器-解码器转换器中的解码器的主要区别在于缺少一个注意力子层,该子层在完整模型中连接编码器和解码器部分。由于当前架构没有编码器部分,因此该子层已不再使用。这使得解码器与编码器非常相似,除了掩蔽的自注意力。由此,编码器模型和解码器模型之间的主要区别在于它们分别是双向和单向的。
与 BERT 类似,GPT 的训练也是一个两步过程,包括无监督的预训练和有监督的微调。我们先从预训练开始,它类似于我们在介绍 seq2seq 模型部分描述的 seq2seq 训练算法(解码器部分)。提醒一下,我们训练原始的 seq2seq 模型,将输入的标记序列转换为另一个不同的输出标记序列。这类任务的例子包括机器翻译和问答。原始的 seq2seq 训练是有监督的,因为匹配输入和输出序列相当于标签化。一旦完整的输入序列被送入 seq2seq 编码器,解码器就开始一次生成一个标记的输出序列。实际上,seq2seq 解码器学会了预测序列中的下一个单词(与 BERT 预测完整序列中任何被遮蔽的单词不同)。在这里,我们有一个类似的算法,但输出序列与输入序列相同。从语言建模的角度来看,预训练学习的是近似下一个标记的条件概率,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/725.png,给定输入标记序列https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/726.png和模型参数θ:https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/727.png。
让我们通过一个例子来说明预训练。我们假设输入序列为[[START],
<https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/728.png>``,
<https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/729.png>``, ...,
<https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/730.png>``]
,我们将训练对标记为{input: label}
。我们的训练对为{[[START]]:
<https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/728.png>``}
,{[[START],
<https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/728.png>``]:
<https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/729.png>``}
,以及{[[START],
<https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/728.png>``,...,
<https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/730.png>``]:
<https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/736.png>``}
。我们可以从下图中看到相同的场景:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_15.jpg
图 7.15 – GPT 预训练以预测相同输入/输出序列的下一个词
接下来,让我们讨论监督式微调步骤,这与 BERT 的微调类似。以下图示说明了在 GPT 中,序列分类和 NLI 任务是如何工作的:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/py-dl-3e/img/B19627_07_16.jpg
图 7.16 – GPT 微调;上:文本分类;下:自然语言推理(NLI)
在这两种情况下,我们都有特殊的[START]
和[EXTRACT]
标记。[EXTRACT]
标记在这里起着与 BERT 中的[CLS]
相同的作用——我们将该标记的输出作为分类结果。但是在这里,它位于序列的末尾,而不是开头。再次说明,这样做的原因是解码器是单向的,并且只能在序列末尾完全访问输入序列。NLI 任务将前提和蕴含通过一个特殊的[
DELIM]`标记连接起来。
这就是我们对 GPT 的介绍——解码器-only 模型的典型示例。至此,我们已经介绍了三种主要的变换器架构——编码器-解码器、仅编码器和仅解码器。这也是本章的一个良好总结。
总结
本章的重点是注意力机制和变换器。我们从 seq2seq 模型开始,并讨论了 Bahdanau 和 Luong 注意力机制。在此基础上,我们逐步引入了 TA 机制,然后讨论了完整的编码器-解码器变换器架构。最后,我们专注于仅编码器和仅解码器的变换器变种。
在下一章,我们将专注于 LLM,并探讨 Hugging Face 的变换器库。