TensorFlow 实战(八)(4)https://developer.aliyun.com/article/1522971
B.2.2 比编码器更好的是什么?一个预训练的编码器
如果你直接将原始网络用于 Pascal VOC 数据集,你可能会对其性能感到非常失望。这种行为背后可能有几个原因:
- Pascal VOC 中的数据比原始 U-Net 设计的要复杂得多。例如,与黑白图像中包含简单细胞结构不同,我们有包含现实世界复杂场景的 RGB 图像。
- 作为一个完全卷积网络,U-Net 具有很高的正则化程度(由于参数数量较少)。这个参数数量不足以以足够的准确度解决我们所面临的复杂任务。
- 作为一个从随机初始化开始的网络,它需要学会在没有来自预训练模型的预训练知识的情况下解决任务。
按照这种推理,让我们讨论一下我们将对原始 U-Net 架构进行的一些改变。我们将实现一个具有
- 预训练的编码器
- 每个解码器模块中的滤波器数量更多
我们将使用的预训练编码器是一个 ResNet-50 模型(arxiv.org/pdf/1512.03385.pdf
)。几年前,它是计算机视觉社区中引起轰动的开创性残差网络之一。我们只会简单地介绍 ResNet-50,因为我们将在 DeepLab v3 模型的部分详细讨论该模型。ResNet-50 模型由多个卷积块组成,后跟一个全局平均池化层和一个具有 softmax 激活的完全连接的最终预测层。卷积块是该模型的创新部分(在图 B.4 中用 B 表示)。原始模型有 16 个卷积块组织成 5 组。我们将仅使用前 13 个块(即前 4 组)。单个块由三个卷积层(步幅为 2 的 1 × 1 卷积层、3 × 3 卷积层和 1 × 1 卷积层)、批量归一化和残差连接组成,如图 B.4 所示。我们在第七章深入讨论了残差连接。
图 B.4 修改后的 U-Net 架构(最佳查看彩色)。此版本的 U-Net 将 ResNet-50 模型的前四个块作为编码器,并将解码器规格(例如,滤波器数量)增加到与匹配的编码器层的规格相匹配。
实现修改后的 U-Net
通过对模型及其不同组件进行深入的概念理解,是时候在 Keras 中实现它了。我们将使用 Keras 函数式 API。首先,我们定义网络的编码器部分:
inp = layers.Input(shape=(512, 512, 3)) # Defining the pretrained resnet 50 as the encoder encoder = tf.keras.applications.ResNet50 ( include_top=False, input_tensor=inp,pooling=None )
接下来,我们讨论解码器的花哨之处。解码器由多个上采样层组成,这些层具有两个重要功能:
- 将输入上采样到更大的输出
- 复制、裁剪和连接匹配的编码器输入
下面的列表中显示的函数封装了我们概述的计算。
列表 B.4 修改后的 UNet 解码器的上采样层
def upsample_conv(inp, copy_and_crop, filters): """ Up sampling layer of the U-net """ # 2x2 transpose convolution layer conv1_out = layers.Conv2DTranspose( filters, (2,2), (2,2), activation='relu' )(inp) # Size of the crop length for one side crop_side = int((copy_and_crop.shape[1]-conv1_out.shape[1])/2) # Crop if crop side is > 0 if crop_side > 0: cropped_copy = layers.Cropping2D(crop_side)(copy_and_crop) else: cropped_copy = copy_and_crop # Concat the cropped encoder output and the decoder output concat_out = layers.Concatenate(axis=-1)([conv1_out, cropped_copy]) # 3x3 convolution layer conv2_out = layers.Conv2D( filters, (3,3), activation='relu', padding='valid' )(concat_out) # 3x3 Convolution layer out = layers.Conv2D( filters, (3,3), activation='relu', padding='valid' )(conv2_out) return out
让我们分析我们编写的函数。它接受以下参数:
- 输入—层的输入
- copy_and_crop—从编码器复制过来的输入
- filters—执行转置卷积后的输出滤波器数量
首先,我们执行转置卷积,如下所示:
conv1_out = layers.Conv2DTranspose( filters=filters, kernel_size=(2,2), strides=(2,2), activation='relu' )(inp)
Conv2DTranspose 的语法与我们多次使用的 Conv2D 相同。它有一些滤波器、卷积核大小(高度和宽度)、步长(高度和宽度)、激活函数和填充(默认为 valid)。我们将根据转置卷积输出的大小和编码器的输入来计算裁剪参数。然后,根据需要使用 Keras 层 Cropping2D 进行裁剪:
crop_side = int((copy_and_crop.shape[1]-conv1_out.shape[1])/2) if crop_side > 0: cropped_copy = layers.Cropping2D(crop_side)(copy_and_crop) else: cropped_copy = copy_and_crop
在这里,我们首先计算从一侧裁剪多少,方法是从上采样输出 conv1_out 中减去编码器的大小。然后,如果大小大于零,则通过将 crop_side 作为参数传递给 Cropping2D Keras 层来计算 cropped_copy。然后将裁剪后的编码器输出和上采样的 conv1_out 连接起来以产生单个张量。这通过两个具有 ReLU 激活和有效填充的 3 × 3 卷积层产生最终输出。我们现在完全定义解码器(请参见下一个清单)。解码器由三个上采样层组成,这些层使用前一层的输出以及复制的编码器输出。
清单 B.5 修改后的 U-Net 模型的解码器
def decoder(inp, encoder): """ Define the decoder of the U-net model """ up_1 = upsample_conv(inp, encoder.get_layer("conv3_block4_out").output, ➥ 512) # 32x32 up_2 = upsample_conv(up_1, ➥ encoder.get_layer("conv2_block3_out").output, 256) # 64x64 up_3 = upsample_conv(up_2, encoder.get_layer("conv1_relu").output, 64) ➥ # 128 x 128 return up_3
跨越预定义模型的中间输出的复制不是我们以前做过的事情。因此,值得进一步调查。我们不能够诉诸于先前定义的代表编码器输出的变量,因为这是一个通过 Keras 下载的预定义模型,没有用于创建模型的实际变量的引用。
但是访问中间输出并使用它们创建新连接并不那么困难。你只需要知道要访问的层的名称即可。这可以通过查看 encoder.summary() 的输出来完成。例如,在这里(根据图 B.4),我们获得了 conv3、conv2 和 conv1 模块的最后输出。要获取 conv3_block4_out 的输出,你需要做的就是
encoder.get_layer("conv3_block4_out").output
将其传递给我们刚刚定义的上采样卷积层。能够执行这样复杂的操作证明了 Keras 函数 API 有多么灵活。最后,你可以在下一个清单中的函数 unet_pretrained_encoder() 中定义完整修改后的 U-Net 模型。
清单 B.6 完整修改后的 U-Net 模型
def unet_pretrained_encoder(): """ Define a pretrained encoder based on the Resnet50 model """ # Defining an input layer of size 384x384x3 inp = layers.Input(shape=(512, 512, 3)) # Defining the pretrained resnet 50 as the encoder encoder = tf.keras.applications.ResNet50 ( include_top=False, input_tensor=inp,pooling=None ) # Encoder output # 8x8 decoder_out = decoder(encoder.get_layer("conv4_block6_out").output, encoder) # Final output of the model (note no activation) final_out = layers.Conv2D(num_classes, (1,1))(decoder_out) # Final model model = models.Model(encoder.input, final_out) return model
这里发生的情况非常清楚。我们首先定义一个大小为 512 × 512 × 3 的输入,将其传递给编码器。我们的编码器是一个没有顶部预测层或全局池化的 ResNet-50 模型。接下来,我们定义解码器,它将 conv4_block6_out 层的输出作为输入(即 ResNet-50 模型的 conv4 块的最终输出),然后逐渐使用转置卷积操作上采样它。此外,解码器复制、裁剪和连接匹配的编码器层。我们还定义一个产生最终输出的 1 × 1 卷积层。最后,我们使用 Keras 函数 API 定义端到端模型。
附录 C:自然语言处理
C.1 环游动物园:遇见其他 Transformer 模型
在第十三章,我们讨论了一种强大的基于 Transformer 的模型,称为 BERT(双向编码器表示来自 Transformer)。但 BERT 只是一波 Transformer 模型的开始。这些模型变得越来越强大,更好,要么通过解决 BERT 的理论问题,要么重新设计模型的各个方面以实现更快更好的性能。让我们了解一些流行的模型,看看它们与 BERT 的不同之处。
C.1.1 生成式预训练(GPT)模型(2018)
故事实际上甚至早于 BERT。OpenAI 在 Radford 等人的论文“通过生成式预训练改善语言理解”中引入了一个模型称为 GPT(mng.bz/1oXV
)。它的训练方式类似于 BERT,首先在大型文本语料库上进行预训练,然后进行有区分性的任务微调。与 BERT 相比,GPT 模型是一个Transformer 解码器。它们的区别在于,GPT 模型具有从左到右(或因果)的注意力,而 BERT 在计算自注意力输出时使用双向(即从左到右和从右到左)注意力。换句话说,GPU 模型在计算给定单词的自注意力输出时只关注其左侧的单词。这与我们在第五章讨论的掩码注意力组件相同。因此,GPT 也被称为自回归模型,而 BERT 被称为自编码器。此外,与 BERT 不同,将 GPT 适应不同的任务(如序列分类、标记分类或问题回答)需要进行轻微的架构更改,这很麻烦。GPT 有三个版本(GPT-1、GPT-2 和 GPT-3);每个模型都变得更大,同时引入轻微的改进以提高性能。
注意 OpenAI,TensorFlow:github.com/openai/gpt-2
。
C.1.2 DistilBERT(2019)
跟随 BERT,DistilBERT 是由 Hugging Face 在 Sanh 等人的论文“DistilBERT, a distilled version of BERT: Smaller, faster, cheaper and lighter”(arxiv.org/pdf/1910.01108v4.pdf
)中介绍的模型。DistilBERT 的主要焦点是在保持性能相似的情况下压缩 BERT。它是使用一种称为知识蒸馏(mng.bz/qYV2
)的迁移学习技术进行训练的。其思想是有一个教师模型(即 BERT),和一个更小的模型(即 DistilBERT),试图模仿教师的输出。DistilBERT 模型相比于 BERT 更小,只有 6 层,而不是 BERT 的 12 层。DistilBERT 模型是以 BERT 的每一层的初始化为基础进行初始化的(因为 DistilBERT 恰好有 BERT 层的一半)。DistilBERT 的另一个关键区别是它只在掩码语言建模任务上进行训练,而不是在下一个句子预测任务上进行训练。
注意 Hugging Face 的 Transformers:huggingface.co/transformers/model_doc/distilbert.xhtml
。
C.1.3 RoBERT/ToBERT(2019)
RoBERT(递归 BERT)和 ToBERT(基于 BERT 的 Transformer)是由 Pappagari 等人在论文“Hierarchical Transformer Models for Long Document Classification”(arxiv.org/pdf/1910.10781.pdf
)中介绍的两个模型。这篇论文解决的主要问题是 BERT 在长文本序列(例如,电话转录)中的性能下降或无法处理。这是因为自注意力层对长度为 n 的序列具有 O(n²) 的计算复杂度。这些模型提出的解决方案是将长序列分解为长度为 k 的较小段(有重叠),并将每个段馈送到 BERT 以生成汇集输出(即 [CLS] 标记的输出)或来自任务特定分类层的后验概率。然后,堆叠 BERT 为每个段返回的输出,并将其传递给像 LSTM(RoBERT)或较小 Transformer(ToBERT)这样的递归模型。
注意 Hugging Face 的 Transformers:huggingface.co/transformers/model_doc/roberta.xhtml
。
C.1.4 BART(2019)
BART(双向和自回归 Transformer)由 Lewis 等人在“BART:去噪序列到序列预训练用于自然语言生成、翻译和理解”(arxiv.org/pdf/1910.13461.pdf
)中提出,是一个序列到序列模型。我们在第 11 和 12 章中已经讨论了序列到序列模型,BART 也借鉴了这些概念。BART 有一个编码器和一个解码器。如果你还记得第五章,Transformer 模型也有一个编码器和一个解码器,可以视为序列到序列模型。Transformer 的编码器具有双向注意力,而 Transformer 的解码器具有从左到右的注意力(即是自回归的)。
与原始 Transformer 模型不同,BART 使用了几种创新的预训练技术(文档重建)来预训练模型。特别地,BART 被训练成为一个去噪自编码器,其中提供了一个有噪声的输入,并且模型需要重建真实的输入。在这种情况下,输入是一个文档(一系列句子)。这些文件使用表 C.1 中列出的方法进行损坏。
表 C.1 文档损坏所采用的各种方法。真实文档为“I was hungry. I went to the café.”下划线字符(_)代表遮蔽标记。
方法 | 描述 | 例子 |
记号遮蔽 | 句子中的记号随机遮蔽。 | 我饿了。我去了 _ 咖啡馆。 |
记号删除 | 随机删除记号。 | 我饿了。我去了咖啡馆。 |
句子排列 | 更改句子顺序。 | 我去了咖啡馆。我饿了。 |
文档旋转 | 旋转文档,以便文档的开头和结尾发生变化。 | 咖啡馆。我饿了。我去了 |
文本补齐 | 使用单一遮蔽标记遮蔽跨度标记。一个长度为 0 的跨度将插入遮蔽标记。 | 我饿了。我 _ 去了咖啡馆。 |
使用损坏逻辑,我们生成输入到 BART 的数据,目标就是没有损坏的真实文档。初始时,已经损坏的文档被输入到编码器,然后解码器被要求递归地预测出真实的序列,同时使用先前预测出的输出作为下一个输入。这类似于第十一章使用机器翻译模型预测翻译的方法。
模型预训练后,你可以将 BART 用于 Transformer 模型通常用于的任何 NLP 任务。例如,可以按以下方式将 BART 用于序列分类任务(例如,情感分析):
- 把记号序列(例如,电影评论)输入到编码器和解码器中。
- 在给解码器输入时,在序列末尾添加一个特殊记号(例如,[CLS])。我们在使用 BERT 时将特殊记号添加到序列开头。
- 通过解码器得到特殊标记的隐藏表示结果,并将其提供给下游分类器以预测最终输出(例如,积极/消极的预测结果)。
如果要将 BART 用于序列到序列的问题(例如机器翻译),请按照以下步骤进行:
- 将源序列输入编码器。
- 向目标序列的开头和结尾分别添加起始标记(例如,[SOS])和结束标记(例如,[EOS])。
- 在训练时,使用除了最后一个标记以外的所有目标序列标记作为输入,使用除了第一个标记以外的所有标记作为目标(即,teacher forcing)来训练解码器。
- 在推理时,将起始标记提供给解码器作为第一个输入,并递归地预测下一个输出,同时将前一个输出(件)作为输入(即自回归)。
注意 Hugging Face’s Transformers: mng.bz/7ygy
。
C.1.5 XLNet (2020)
XLNet 是由杨飞等人在 2020 年初发布的论文 “XLNet: Generalized Autoregressive Pretraining for Language Understanding” 中推出的。它的主要重点是捕捉基于自编码器模型(例如 BERT)和自回归模型(例如 GPT)两种方法的优点,这很重要。
作为自编码器模型,BERT 具有的一个关键优势是,任务特定的分类头包含了由双向上下文丰富化的标记的隐藏表示。正如你所想像的那样,了解给定标记之前和之后的内容能够得到更好的下游任务效果。相反地,GPT 只关注给定单词的左侧来生成表示。因此,GPT 的标记表示是次优的,因为它们只关注(左侧)上下文的单向 注意力。
另一方面,BERT 的预训练方法涉及引入特殊标记 [MASK]。虽然这个标记出现在预训练的上下文中,但它在微调的上下文中从未出现过,造成了预训练和微调之间的差异。
BERT 中存在一个更为关键的问题。BERT 假设掩盖标记是单独构建的(即独立假设),这在语言建模中是错误的。换句话说,如果你有句子 “I love [MASK][1] [MASK][2] city”,第二个掩盖标记是独立于 [MASK][1] 的选择而生成的。这是错误的,因为要生成一个有效的城市名称,必须在生成 [MASK][2] 之前先了解 [MASK][1] 的值。然而,GPT 的自回归性质允许模型先预测 [MASK][1] 的值,然后使用其与其左侧的其他单词一起生成城市的第一个单词的值,然后生成第二个单词的值(即上下文感知)。
XLNet 将这两种语言建模方法融合为一体,因此你可以从 BERT 使用的方法中得到双向上下文,以及从 GPT 的方法中得到上下文感知。这种新方法被称为置换语言建模。其思想如下。考虑一个长度为 T 的单词序列。对于该序列,有 T!种排列方式。例如,句子“Bob 爱猫”将有 3! = 3 × 2 × 1 = 6 种排列方式:
Bob loves cats Bob cats loves loves Bob cats loves cats Bob cats Bob loves cats loves Bob
如果用于学习的语言模型的参数在所有排列中共享,我们不仅可以使用自回归方法来学习它,还可以捕获给定单词的文本两侧的信息。这是论文中探讨的主要思想。
注意 Hugging Face 的 Transformers:mng.bz/mOl2
。
C.1.6 Albert(2020)
Albert 是 BERT 模型的一种变体,其性能与 BERT 相媲美,但参数更少。Albert 做出了两项重要贡献:减少模型大小和引入一种新的自监督损失,有助于模型更好地捕捉语言。
嵌入层的因式分解
首先,Albert 对 BERT 中使用的嵌入矩阵进行了因式分解。在 BERT 中,嵌入是大小为 V × H 的度量,其中 V 是词汇表大小,H 是隐藏状态大小。换句话说,嵌入大小(即嵌入向量的长度)与最终隐藏表示大小之间存在紧密耦合。然而,嵌入(例如 BERT 中的 WordPiece 嵌入)并不是设计用来捕捉上下文的,而隐藏状态是在考虑了标记及其上下文的情况下计算的。因此,增大隐藏状态大小 H 是有意义的,因为隐藏状态比嵌入更能捕获标记的信息性表示。但是,由于存在紧密耦合,这样做会增加嵌入矩阵的大小。因此,Albert 建议对嵌入矩阵进行因式分解为两个矩阵,V × E 和 E × H,从而解耦嵌入大小和隐藏状态大小。通过这种设计,可以增加隐藏状态大小,同时保持嵌入大小较小。
跨层参数共享
跨层参数共享是 Albert 中引入的另一种减少参数空间的技术。由于 BERT 中的所有层(以及 Transformer 模型一般)从顶部到底部都具有统一的层,参数共享是微不足道的。参数共享可以通过以下三种方式之一实现:
- 在所有自注意子层之间
- 在所有完全连接的子层之间
- 在自注意和完全连接的子层之间(分开)
Albert 将跨层共享所有参数作为默认策略。通过使用这种策略,Albert 实现了 71%到 86%的参数减少,而不会显著影响模型的性能。
句序预测而非下一句预测
论文的作者最终认为,BERT 中基于下一句预测的预训练任务所增加的价值是值得怀疑的,这一点得到了多项先前研究的支持。因此,他们提出了一种新的、更具挑战性的模型,主要关注语言的一致性:句子顺序预测。在这个模型中,模型使用一个二元分类头来预测给定的一对句子是否是正确的顺序。数据可以很容易地生成,正样本是按照顺序排列的相邻句子,而负样本则是通过交换两个相邻句子生成的。作者认为这比基于下一句预测的任务更具挑战性,导致比 BERT 更具见解的模型。
备注 TFHub: (tfhub.dev/google/albert_base/3
)。Hugging Face 的 Transformers: (mng.bz/5QM1
)。
C.1.7 Reformer (2020)
Reformer 是最近加入 Transformer 家族的模型之一。Reformer 的主要思想是能够扩展到包含数万个标记的序列。Reformer 在 2020 年初由 Kitaev 等人在论文“Reformer: The Efficient Transformer”中提出(arxiv.org/pdf/2001.04451.pdf
)。
防止普通 Transformer 被用于长序列的主要限制是自注意力层的计算复杂性。它需要对每个词查看所有其他词,以生成最终的表示,这对含有 L 个标记的序列具有 O(L²) 的复杂性。Reformer 使用局部敏感哈希(LSH)将这种复杂性降低到 O(L logL)。LSH 的思想是为每个输入分配一个哈希;具有相同哈希的输入被认为是相似的,并被分配到同一个桶。这样,相似的输入就会被放在同一个桶中。为此,我们需要对自注意力子层进行若干修改。
自注意力层中的局部敏感哈希
首先,我们需要确保 Q 和 K 矩阵是相同的。这是必要的,因为其思想是计算查询和键之间的相似性。这可以通过共享 Q 和 K 的权重矩阵的权重来轻松实现。接下来,需要开发一个哈希函数,可以为给定的查询/键生成哈希,使得相似的查询/键(共享-qk)获得相似的哈希值。同时需要记住,这必须以可微的方式完成,以确保模型的端到端训练。使用了以下哈希函数:
h(x) = argmax([xR; - xR])
其中,R 是大小为[d_model, b/2]的随机矩阵,用于用户定义的 b(即,桶的数量),x 是形状为[b, L, d_model]的输入。通过使用这个散列函数,您可以得到批处理中每个输入标记在给定位置的桶 ID。要了解更多关于这个技术的信息,请参考原始论文“Practical and Optimal LSH for Angular Distance” by Andoni et al.(arxiv.org/pdf/1509.02897.pdf
)。根据桶 ID,共享的 qk 项被排序。
然后,排序后的共享的 qk 项使用固定的块大小进行分块。更大的块大小意味着更多的计算(即,会考虑更多单词来给定标记),而较小的块大小可能意味着性能不佳(即,没有足够的标记可供查看)。
最后,自注意力的计算如下。对于给定的标记,查看它所在的相同块以及前一个块,并关注这两个块中具有相同桶 ID 的单词。这将为输入中提供的所有标记产生自注意力输出。这样,模型不必为每个标记查看每个其他单词,可以专注于给定标记的子集单词或标记。这使得模型可伸缩到长达几万个标记的序列。
注意 Hugging Face’s Transformers: mng.bz/6XaD
。