原文:
zh.annas-archive.org/md5/0bc67f8f61131022ce5bcb512033ea38
译者:飞龙
第五章:顺序标注和语言建模
本章涵盖
-
使用顺序标注解决词性标注(POS)和命名实体识别(NER)
-
使 RNNs 更强大——多层和双向循环神经网络(RNNs)
-
使用语言模型捕捉语言的统计特性
-
使用语言模型评估和生成自然语言文本
在本章中,我们将讨论顺序标注——一个重要的自然语言处理框架,系统会为每个单词打上相应的标签。许多自然语言处理应用,如词性标注和命名实体识别,可以被构建为顺序标注任务。在本章的后半部分,我将介绍语言模型的概念,这是自然语言处理中最基本但也最令人兴奋的主题之一。我将谈论它们为何重要以及如何使用它们来评估甚至生成一些自然语言文本。
5.1 介绍顺序标注
在上一章中,我们讨论了句子分类,任务是为给定的句子分配一些标签。垃圾邮件过滤、情感分析和语言检测是句子分类的一些具体例子。尽管许多现实世界的自然语言处理问题可以被规约为句子分类任务,但这种方法也可能相当有限,因为根据定义,该模型只允许我们为整个句子分配一个单一的标签。但如果你想要更细粒度的东西呢?例如,如果你想要对单个单词做一些操作,而不仅仅是句子呢?你遇到的最典型的场景是当你想要从句子中提取一些东西时,这并不能很容易地通过句子分类来解决。这就是顺序标注发挥作用的地方。
5.1.1 顺序标注是什么?
顺序标注 是一种自然语言处理任务,给定一个序列,比如一个句子,NLP 系统会为输入序列的每个元素(比如单词)分配一个标签。这与句子分类形成对比,句子分类仅为输入句子分配一个标签。图 5.1 展示了这种对比。
图 5.1 句子分类与顺序标注
但为什么这是个好主意呢?我们什么时候需要每个词都有一个标签?顺序标注非常方便的一个典型场景是当你想要分析一个句子并为每个词生成一些语言学信息。例如,词性标注(POS)就是一个很好的例子,如我在第一章中提到的,它为输入句子中的每个单词生成一个词性标签,比如名词、动词和介词,非常适合顺序标注。请参见图 5.2 进行说明。
图 5.2 使用顺序标注进行词性标注(POS)
词性标注是最基础、最重要的自然语言处理任务之一。许多英语单词(以及许多其他语言的单词)都是有歧义的,意味着它们有多种可能的解释。例如,单词“book”可以用来描述由页面组成的物理或电子对象(“我读了一本书”),也可以用来描述预订某物的行为(“我需要预订一次航班”)。下游的自然语言处理任务,比如解析和分类,在知道每个“book”的出现实际上意味着什么以便处理输入句子时受益匪浅。如果你要构建一个语音合成系统,你必须知道某些单词的词性才能正确地发音——名词“lead”(一种金属)与“bed”押韵,而动词“lead”(指导,引导)与“bead”押韵。词性标注是解决这种歧义的重要第一步。
另一个场景是当你想要从一个句子中提取一些信息片段时。例如,如果你想要提取名词短语和动词短语等子序列(短语),这也是一个序列标记任务。你如何使用标记来实现提取?这个想法是使用标记来标记所需信息片段的开始和结束(或开始和继续,取决于你如何表示它)。命名实体识别(NER)就是一个例子,它是从一个句子中识别真实世界实体的任务,比如专有名词和数字表达式(在图 5.3 中说明)。
图 5.3 使用序列标记的命名实体识别(NER)
注意,所有不属于任何命名实体的单词都被标记为 O(代表“外部”)。目前,你可以忽略图 5.3 中一些神秘的标签,比如 B-GPE 和 I-MONEY。在第 5.4 节中,我会更多地讨论如何将命名实体识别问题制定为一个序列标记问题。
5.1.2 使用 RNNs 编码序列
在句子分类中,我们使用递归神经网络(RNNs)将可变长度的输入转换为固定长度的向量。这个固定长度的向量通过一个线性层转换为一组“分数”,捕获了关于输入句子的信息,这对于推导句子标签是必要的。作为提醒,这个 RNN 的作用可以用以下伪代码和图 5.4 中显示的图表来表示:
def rnn_vec(words):
state = init_state()
for word in words:
state = update(state, word)
return state
图 5.4 句子分类的递归神经网络(RNN)
什么样的神经网络可以用于序列标记?我们似乎需要句子中每个输入单词的一些信息,而不仅仅是在末尾。如果您仔细查看 rnn_vec()的伪代码,您会注意到我们已经有了输入中每个单词的信息,这些信息由状态捕获。该函数恰好只返回状态的最终值,但我们没有理由不能存储状态的中间值并将它们作为列表返回,就像以下函数一样:
def rnn_seq(words):
state = init_state()
states = []
for word in words:
state = update(state, word)
states.append(state)
return states
如果你将此函数应用于图 5.2 中显示的“time flies”示例并展开它——也就是说,不使用循环写出它——它将如下所示:
state = init_state()
states = []
state = update(state, v("time"))
states.append(state)
state = update(state, v("flies"))
states.append(state)
state = update(state, v("like"))
states.append(state)
state = update(state, v("an"))
states.append(state)
state = update(state, v("arrow"))
states.append(state)
state = update(state, v("."))
states.append(state)
这里的 v()是一个函数,它返回给定单词的嵌入。这可以通过图 5.5 中所示的方式进行可视化。请注意,对于每个输入单词 word,网络都会产生捕获有关该单词的一些信息的相应状态。状态列表 states 的长度与 words 的长度相同。状态的最终值,即 states[-1],与先前的 rnn_vec()的返回值相同。
图 5.5 用于序列标记的递归神经网络(RNN)
如果将这个循环神经网络视为一个黑匣子,它接受一系列东西(例如,词嵌入)并将其转换为编码有关输入中各个单词信息的向量序列,因此该架构在 AllenNLP 中被称为Seq2Seq(代表“序列到序列”)编码器。
最后一步是将这个 RNN 的每个状态应用于一个线性层,以得到对每个标签的可能性的一组分数。如果这是一个词性标注器,我们
对于标签 NOUN,需要一个分数,对于 VERB,需要另一个分数,以此类推,适用于每个单词。此转换如图 5.6 所示。请注意,相同的线性层(具有相同的参数集)应用于每个状态。
图 5.6 将线性层应用于 RNN
总结一下,我们可以使用几乎与我们用于句子分类相同的结构进行序列标记,唯一的区别是前者为每个单词生成一个隐藏状态,而不仅仅是每个句子。要生成用于确定标签的分数,必须将线性层应用于每个隐藏状态。
5.1.3 在 AllenNLP 中实现 Seq2Seq 编码器
AllenNLP 实现了一个称为 Seq2SeqEncoder 的抽象类,用于抽象化所有接受向量序列并返回另一个修改后向量序列的 Seq2Seq 编码器。理论上,您可以继承该类并实现自己的 Seq2Seq 编码器。然而,在实践中,您很可能会使用 PyTorch/AllenNLP 提供的现成实现之一,例如 LSTM 和 GRU。请记住,当我们为情感分析器构建编码器时,我们使用了 PyTorch 的内置 torch.nn.LSTM,并将其包装为 PytorchSeq2VecWrapper,如下所示,这使其与 AllenNLP 的抽象兼容:
encoder = PytorchSeq2VecWrapper(
torch.nn.LSTM(EMBEDDING_DIM, HIDDEN_DIM, batch_first=True))
AllenNLP 还实现了 PytorchSeq2SeqWrapper,它使用 PyTorch 的内置 RNN 实现之一,并使其符合 AllenNLP 的 Seq2SeqEncoder,因此你需要做的很少,只需要像这样初始化一个 Seq2Seq 编码器:
encoder = PytorchSeq2SeqWrapper(
torch.nn.LSTM(EMBEDDING_DIM, HIDDEN_DIM, batch_first=True))
就是这样!还有一些需要注意的地方,但是你会惊奇地发现,为了使其用于顺序标记,你需要进行的更改很少,这得益于 AllenNLP 的强大抽象——大部分时间你只需要关心各个组件如何相互作用,而不需要关心这些组件工作的实现方式。
5.2 构建一个词性标注器
在本节中,我们将构建我们的第一个顺序标记应用程序—一个词性(POS)标注器。您可以在 Google Colab 笔记本上查看此部分的全部代码(realworldnlpbook.com/ch5.html#pos-nb
)。我们假设您已经导入了所有必要的依赖项,如下所示:
from itertools import chain
from typing import Dict
import numpy as np
import torch
import torch.optim as optim
from allennlp.data.data_loaders import MultiProcessDataLoader
from allennlp.data.samplers import BucketBatchSampler
from allennlp.data.vocabulary import Vocabulary
from allennlp.models import Model
from allennlp.modules.seq2seq_encoders import Seq2SeqEncoder, PytorchSeq2SeqWrapper
from allennlp.modules.text_field_embedders import TextFieldEmbedder, BasicTextFieldEmbedder
from allennlp.modules.token_embedders import Embedding
from allennlp.nn.util import get_text_field_mask, sequence_cross_entropy_with_logits
from allennlp.training.metrics import CategoricalAccuracy
from allennlp.training import GradientDescentTrainer
from allennlp_models.structured_prediction.dataset_readers.universal_dependencies import UniversalDependenciesDatasetReader
from realworldnlp.predictors import UniversalPOSPredictor
5.2.1 读取数据集
如我们在第一章中所看到的那样,词性(POS)是一组共享相似语法属性的词汇类别。词性标注是将句子中的每个单词与相应的词性标记进行标记化的过程。用于 POS 标注的训练集遵循一组预定义的语言 POS 标签集。
要训练一个词性标注器,我们需要一个数据集,其中每个句子中的每个单词都标有相应的词性标记。在这个实验中,我们将使用英语 Universal Dependencies (UD)数据集。Universal Dependencies 是一个由一群研究者开发的语言无关的依存语法框架。UD 还定义了一个标签集,称为通用词性标记集 (realworldnlpbook.com/ch1.html#universal-pos
)。UD 和 Universal POS 标记集的使用在 NLP 社区中非常流行,尤其是在诸如词性标注和解析等语言无关任务和模型中。
我们将使用 UD 的一个子语料库,名为*“A Gold Standard Universal Dependencies Corpus for English”,该语料库建立在英语 Web Treebank (EWT)之上(realworldnlpbook.com/ch5.html#ewt
),并可在创作共用许可下使用。如需要,您可以从数据集页面(realworldnlpbook.com/ch5.html#ewt-data
)下载整个数据集。
Universal Dependencies 数据集以一种称为CoNLL-U 格式(universaldependencies.org/docs/format.html
)的格式分发。AllenNLP 模型包已经实现了一个名为 UniversalDependenciesDatasetReader 的数据集读取器,它以这种格式读取数据集,并返回包含词形、词性标签和依赖关系等信息的实例集合,因此你只需初始化并使用它,如下所示:
reader = UniversalDependenciesDatasetReader()
train_path = ('https:/./s3.amazonaws.com/realworldnlpbook/data/'
'ud-treebanks-v2.3/UD_English-EWT/en_ewt-ud-train.conllu')
dev_path = ('https:/./s3.amazonaws.com/realworldnlpbook/'
'data/ud-treebanks-v2.3/UD_English-EWT/en_ewt-ud-dev.conllu')
还有,不要忘记初始化数据加载器和一个词汇表实例,如下所示:
sampler = BucketBatchSampler(batch_size=32, sorting_keys=["words"])
train_data_loader = MultiProcessDataLoader(
reader, train_path, batch_sampler=sampler)
dev_data_loader = MultiProcessDataLoader(
reader, dev_path, batch_sampler=sampler)
vocab = Vocabulary.from_instances(chain(train_data_loader.iter_instances(),
dev_data_loader.iter_instances()))
train_data_loader.index_with(vocab)
dev_data_loader.index_with(vocab)
5.2.2 定义模型和损失
构建词性标注器的下一步是定义模型。在前面的部分中,我们已经看到你可以使用 AllenNLP 内置的 PytorchSeq2VecWrapper 很少修改就初始化一个 Seq2Seq 编码器。让我们按照以下方式定义其他组件(词嵌入和 LSTM)以及模型所需的一些变量:
EMBEDDING_SIZE = 128
HIDDEN_SIZE = 128
token_embedding = Embedding(num_embeddings=vocab.get_vocab_size('tokens'),
embedding_dim=EMBEDDING_SIZE)
word_embeddings = BasicTextFieldEmbedder({"tokens": token_embedding})
lstm = PytorchSeq2SeqWrapper(
torch.nn.LSTM(EMBEDDING_SIZE, HIDDEN_SIZE, batch_first=True))
现在我们准备定义词性标注器模型的主体,如下所示。
清单 5.1 词性标注器模型
class LstmTagger(Model):
def __init__(self,
embedder: TextFieldEmbedder,
encoder: Seq2SeqEncoder,
vocab: Vocabulary) -> None:
super().__init__(vocab)
self.embedder = embedder
self.encoder = encoder
self.linear = torch.nn.Linear(
in_features=encoder.get_output_dim(),
out_features=vocab.get_vocab_size('pos'))
self.accuracy = CategoricalAccuracy() ❶
def forward(self,
words: Dict[str, torch.Tensor],
pos_tags: torch.Tensor = None,
**args) -> Dict[str, torch.Tensor]: ❷
mask = get_text_field_mask(words)
embeddings = self.embedder(words)
encoder_out = self.encoder(embeddings, mask)
tag_logits = self.linear(encoder_out)
output = {"tag_logits": tag_logits}
if pos_tags is not None:
self.accuracy(tag_logits, pos_tags, mask)
output["loss"] = sequence_cross_entropy_with_logits(
tag_logits, pos_tags, mask) ❸
return output
def get_metrics(self, reset: bool = False) -> Dict[str, float]:
return {"accuracy": self.accuracy.get_metric(reset)}
❶ 我们使用准确度来评估词性标注器。
❷ 我们需要**args 来捕获 AllenNLP 自动解构的不必要的实例字段。
❸ 使用序列交叉熵损失训练 Seq2Seq 编码器。
注意,清单 5.1 中显示的代码与我们用于构建情感分析器的 LstmClassifier 代码(清单 4.1)非常相似。实际上,除了一些命名差异之外,只存在一个基本差异——损失函数的类型。
回想一下,我们在句子分类任务中使用了一种称为交叉熵的损失函数,它基本上衡量了两个分布之间的距离。如果模型产生了真实标签的高概率,损失将很低。否则,它将很高。但是这假设每个句子只有一个标签。当每个词只有一个标签时,我们如何衡量预测与真实标签的差距?
答案是:仍然使用交叉熵,但是将其平均化到输入序列中的所有元素上。对于词性标注,你计算每个词的交叉熵,就像它是一个单独的分类任务一样,将其求和到输入句子中的所有词上,并除以句子的长度。这将给你一个反映你的模型平均预测输入句子的词性标签的好坏程度的数字。查看图 5.7 进行说明。
图 5.7 计算序列的损失
关于评估指标,POS 标注器通常使用准确率进行评估,我们将在这里使用。POS 标注的平均人类表现约为 97%,而最先进的 POS 标注器略高于此(realworldnlp book.com/ch5.html#pos-sota
)。然而,需要注意准确率并非没有问题——假设存在一个相对罕见的 POS 标签(例如 SCONJ,表示从属连接),它仅占总标记数的 2%,而一个 POS 标注器每次出现都会搞砸它。如果标注器将其余标记都正确识别,则仍可达到 98% 的准确率。
5.2.3 构建训练流水线
现在我们准备好开始构建训练流水线了。与之前的任务一样,AllenNLP 中的训练流水线看起来非常相似。请查看下一个清单以查看训练代码。
清单 5.2 POS 标注器的训练流水线
model = LstmTagger(word_embeddings, encoder, vocab)
optimizer = optim.Adam(model.parameters())
trainer = GradientDescentTrainer(
model=model,
optimizer=optimizer,
data_loader=train_data_loader,
validation_data_loader=dev_data_loader,
patience=10,
num_epochs=10,
cuda_device=-1)
trainer.train()
当运行此代码时,AllenNLP 会在两个阶段之间交替进行:1)使用训练集训练模型,2)使用验证集评估每个时代,同时监控两个集合上的损失和准确率。经过几个时代后,验证集的准确率会在约 88% 左右稳定。训练结束后,您可以运行下面显示的模型以查看一个未见过的实例:
predictor = UniversalPOSPredictor(model, reader)
tokens = ['The', 'dog', 'ate', 'the', 'apple', '.']
logits = predictor.predict(tokens)['tag_logits']
tag_ids = np.argmax(logits, axis=-1)
print([vocab.get_token_from_index(tag_id, 'pos') for tag_id in tag_ids])
此代码使用了 UniversalPOSPredictor,这是我为这个特定的 POS 标注器编写的一个预测器。虽然其细节并不重要,但如果您感兴趣,可以查看它的代码(realworldnlpbook.com/ch5#upos-predictor
)。如果成功,这将显示一个 POS 标签列表:[‘DET’, ‘NOUN’, ‘VERB’, ‘DET’, ‘NOUN’, ‘PUNCT’],这确实是输入句子的正确 POS 标签序列。
5.3 多层和双向 RNN
正如我们迄今所见,RNN 是构建 NLP 应用程序的强大工具。在本节中,我将讨论它们的结构变体——多层和双向 RNN,这些是构建高度准确的 NLP 应用程序的更强大组件。
5.3.1 多层 RNN
如果将 RNN 视为黑盒子,则它是一个将一系列向量(单词嵌入)转换为另一系列向量(隐藏状态)的神经网络结构。输入和输出序列的长度相同,通常是输入标记的数量。这意味着您可以通过将 RNN 堆叠在一起多次重复这个“编码”过程。一个 RNN 的输出(隐藏状态)成为上面的另一个 RNN 的输入,这个 RNN 刚好位于前一个 RNN 的上面。较大神经网络的子结构(例如单个 RNN)称为层,因为您可以像层一样将它们堆叠在一起。两层 RNN 的结构如图 5.8 所示。
图 5.8 两层 RNN
为什么这是一个好主意呢?如果你将 RNN 的一层看作是一个接受具体输入(例如,单词嵌入)并提取一些抽象概念(例如,POS 标签的得分)的机器,你可以期望,通过重复这个过程,RNN 能够随着层数的增加提取越来越抽象的概念。尽管没有完全经过理论证明,但许多真实世界的 NLP 应用都使用了多层 RNNs。例如,谷歌的神经机器翻译(NMT)系统使用了一个包括八层编码器和解码器的堆叠 RNN(realworldnlpbook.com/ch5.html#nmt-paper
)。
要在你的 NLP 应用中使用多层 RNNs,你需要做的只是改变编码器的初始化方式。具体来说,你只需要使用 num_layers 参数指定层数,就像下一个代码片段中所示的那样,而 AllenNLP 会确保训练管道的其余部分按原样工作:
encoder = PytorchSeq2SeqWrapper(
torch.nn.LSTM(
EMBEDDING_SIZE, HIDDEN_SIZE, num_layers=2, batch_first=True))
如果你更改了这一行并重新运行 POS 标记器训练管道,你会注意到在验证集上的准确率几乎没有变化,或者略低于前一个单层 RNN 模型。这并不奇怪——进行 POS 标记所需的信息大多是表面的,比如被标记的单词的身份和相邻单词。很少情况下需要深入理解输入句子。另一方面,向 RNN 添加层并非没有额外的成本。它会减慢训练和推断的速度,并增加参数的数量,从而使其容易过拟合。对于这个小实验来说,向 RNN 添加层似乎弊大于利。当你改变网络的结构时,一定要记得验证其对验证集的影响。
5.3.2 双向 RNNs
到目前为止,我们一直将单词逐个输入 RNN——从句子的开头到结尾。这意味着当 RNN 处理一个单词时,它只能利用到目前为止遇到的信息,也就是单词的左侧上下文。当然,你可以从单词的左侧上下文中获得很多信息。例如,如果一个单词前面是情态动词(例如,“can”),那么下一个单词是动词的信号就很强烈。然而,右侧上下文也包含了很多信息。例如,如果你知道下一个单词是限定词(例如,“a”),那么左侧的“book”是一个动词,而不是名词的信号就很强烈。
双向 RNN(或简称双向 RNN)通过组合两个方向相反的 RNN 来解决这个问题。前向 RNN 是我们在本书中一直使用的正向 RNN,它从左到右扫描输入句子,并使用输入词和在其左侧所有信息来更新状态。而反向 RNN 则按相反的方向扫描输入句子。它使用输入词和在其右侧所有信息来更新状态。这相当于翻转输入句子的顺序并将其馈送给前向 RNN。双向 RNN 产生的最终隐藏状态是来自前向和后向 RNN 的隐藏状态的连接。详见图 5.9。
图 5.9 双向 RNN
让我们用具体的例子来说明。假设输入句子是“time flies like an arrow”,你想知道这个句子中间的单词“like”的词性标注。前向 RNN 处理“time”和“flies”,到达“like”时,它的内部状态(图 5.9 中的 A)编码了关于“time flies like”所有的信息。同样地,反向 RNN 处理“arrow”和“an”,到达“like”时,它的内部状态(图 5.9 中的 B)编码了关于“like an arrow”的所有信息。双向 RNN 的“like”的内部状态是这两个状态(A + B)的连接。您只需将两个向量连接在一起——不需要进行数学运算。因此,“like”的内部状态编码了整个句子的信息。这比只知道句子的一半要好得多!
实现双向 RNN 同样容易——您只需要在初始化 RNN 时添加 bidirectional=True 标志,如下所示:
encoder = PytorchSeq2SeqWrapper(
torch.nn.LSTM(
EMBEDDING_SIZE, HIDDEN_SIZE, bidirectional=True, batch_first=True))
如果你使用这个变化来训练 POS 标注器,验证集的准确率将从 ~88% 跳升到 91%。这意味着将词的两侧信息结合起来对于 POS 标注是有效的。
请注意,您可以通过堆叠双向 RNN 来结合本节介绍的两种技术。双向 RNN 的一层输出(由前向和后向层连接)成为另一层双向 RNN 的输入(见图 5.10)。您可以在初始化 PyTorch/AllenNLP 中的 RNN 时指定 num_layers 和 bidirectional 两个标志来实现此目的。
图 5.10 两层双向 RNN
5.4 命名实体识别
序列标注可以应用于许多信息提取任务,不仅仅是词性标注。在本节中,我将介绍命名实体识别(NER)的任务,并演示如何使用序列标注构建一个 NER 标注器。此部分的代码可以通过 Google Colab 平台查看和执行(realworldnlpbook.com/ch5#ner-nb
)。
5.4.1 什么是命名实体识别?
正如前面提到的,命名实体是对现实世界实体的提及,如专有名词。通常由 NER 系统覆盖的常见命名实体包括以下内容:
-
个人姓名(PER):艾伦·图灵、Lady Gaga、埃隆·马斯克
-
组织(ORG):谷歌、联合国、巨人
-
位置(LOC):雷尼尔山、巴厘岛、尼罗河
-
地理政治实体(GPE):英国、旧金山、东南亚
然而,不同的 NER 系统处理不同的命名实体集合。在 NLP 中,命名实体的概念有点过载,意味着任何对应用程序用户感兴趣的提及。例如,在医学领域,你可能想提取药品和化学化合物的名称提及。在金融领域,公司、产品和股票符号可能是感兴趣的。在许多领域,数字和时间表达式也被视为命名实体。
识别命名实体本身就很重要,因为命名实体(谁、什么、在哪里、什么时候等)通常是大多数人感兴趣的。但是 NER 也是许多其他自然语言处理应用的重要第一步。一个这样的任务是关系抽取:从给定的文档中提取所有命名实体之间的关系。例如,给定一个新闻稿件,你可能想提取出其中描述的事件,比如哪家公司以什么价格收购了哪家其他公司。这通常假设所有各方都已通过 NER 识别。与 NER 密切相关的另一个任务是实体链接,其中命名实体的提及与某些知识库(如维基百科)相关联。当维基百科被用作知识库时,实体链接也称为维基化。
但是你可能会想,仅仅提取命名实体有什么难的?如果它们只是专有名词,你可以简单地编制一个字典,比如所有的名人(或所有的国家,或你感兴趣的任何东西),然后使用它吗?这个想法是,每当系统遇到一个名词,它就会通过这个字典,并标记出现在其中的提及。这样的字典称为地名词典,许多 NER 系统确实使用它们作为一个组件。
然而,仅仅依靠这样的字典有一个主要问题——歧义性。前面我们看到一个单词类型可能有多个词性(例如,“book”既是名词又是动词),命名实体也不例外。例如,“Georgia”可以是一个国家的名字,也可以是美国的一个州,跨越美国的城镇和社区(乔治亚州,印第安纳州;乔治亚州,内布拉斯加州),一部电影,几首歌曲,船只和一个人名。像“book”这样的简单单词也可能是命名实体,包括:Book(路易斯安那州的一个社区),Book/Books(一个姓氏),The Books(一个美国乐队)等。如果它们是模糊的,简单地将提及与字典进行匹配将告诉你它们的身份。
幸运的是,句子通常提供了可以用于消歧提及的线索。 例如,如果句子中读到“我住在乔治亚州”,通常是“乔治亚州”是地点名称,而不是电影或人名的强烈信号。 NER 系统使用关于提及本身的信号(例如,它们是否在预定义字典中)以及关于它们上下文的信号(它们是否由某些词先导或跟随)的组合来确定它们的标记。
5.4.2 标记跨度
与词性标注不同,指向命名实体的提及可以跨越多个词,例如,“美国”和“世界贸易组织”。 在 NLP 中,跨度 只是一个或多个连续词的范围。 我们如何使用相同的序列标记框架来建模跨度?
NLP 中的一个常见做法是使用某种形式的编码将跨度转换为每个词的标记。 NER 中最常用的编码方案称为IOB2 标记。 它通过位置标记和类别标记的组合来表示跨度。 以下是三种类型的位置标记:
-
B(Beginning):分配给跨度的第一个(或唯一的)标记
-
我(Inside):分配给跨度的所有标记的第一个标记之外的所有标记
-
O(Outside):分配给任何跨度之外的所有单词
现在,让我们看一下之前看到的 NER 示例,并显示在图 5.11 中。 标记“Apple”是 ORG(表示“组织”)的第一个(也是唯一的)标记,并分配了一个 B-ORG 标记。 类似地,“UK”是 GPE(表示“地缘政治实体”)的第一个和唯一的标记,并分配了 B-GPE。 对于“$1”和“billion”,表示货币表达式(MONEY)的第一个和第二个标记,分别分配了 B-MONEY 和 I-MONEY。 所有其他标记都被赋予 O。
图 5.11 命名实体识别(NER)使用序列标记
解决 NER 的其余管道与解决词性标注非常相似:两者都涉及为每个词分配适当的标记,并且可以通过 RNN 解决。 在接下来的部分中,我们将使用神经网络构建一个简单的 NER 系统。
5.4.3 实现命名实体识别器
要构建一个 NER 系统,我们使用由 Abhinav Walia 准备的命名实体识别注释语料库,该语料库已在 Kaggle 上发布(realworldnlpbook.com/ch5.html#ner-data
)。 在接下来的内容中,我假设您已经下载并展开了数据集,并将其放置在 data/entity-annotated-corpus 下。 或者,您可以使用我上传到 S3 的数据集的副本(realworldnlpbook.com/ch5.html#ner-data-s3
),这就是以下代码所做的事情。 我为这个数据集编写了一个数据集读取器(realworldnlpbook.com/ch5.html#ner-reader
),所以您只需导入(或复制粘贴)它并使用它:
reader = NERDatasetReader('https:/./s3.amazonaws.com/realworldnlpbook/'
'data/entity-annotated-corpus/ner_dataset.csv')
由于数据集没有分为训练集、验证集和测试集,数据集读取器将为您分离为训练集和验证集。您所需要做的就是在初始化数据加载器时指定您想要的分割方式,如下所示:
sampler = BucketBatchSampler(batch_size=16, sorting_keys=["tokens"])
train_data_loader = MultiProcessDataLoader(
reader, 'train', batch_sampler=sampler)
dev_data_loader = MultiProcessDataLoader(
reader, 'dev', batch_sampler=sampler)
基于 RNN 的顺序标注模型和训练流程的其余部分与以前的示例(词性标注器)几乎相同。唯一的区别在于我们如何评估我们的 NER 模型。因为典型的 NER 数据集中大多数标签只是“O”,使用标签准确度很容易误导 —— 一个将所有东西标记为“O”的愚蠢系统可以获得非常高的准确度。相反,NER 通常被评估为一项信息提取任务,其目标是从文本中提取命名实体,而不仅仅是标记它们。我们希望基于检索到的命名实体的“干净程度”(有多少是实际实体)和“完整程度”(系统能够检索到多少实际实体)来评估 NER 系统。这些听起来熟悉吗?是的,这些就是我们在第 4.3 节中讨论过的召回率和精确度的定义。由于命名实体通常有多种类型,因此这些指标(精确度、召回率和 F1 度量)是按实体类型计算的。
注意,如果在计算这些指标时忽略实体类型,它被称为总体平均。例如,总体平均的精确度是所有类型的真阳性总数除以检索到的命名实体总数,而不管类型如何。另一方面,如果按实体类型计算这些指标,然后对它们进行平均,它被称为宏平均。例如,如果 PER 和 GPE 的精确度分别为 80%和 90%,则它的宏平均为 85%。接下来,AllenNLP 所计算的是总体平均。
AllenNLP 实现了 SpanBasedF1Measure,它计算每个类型的指标(精确度、召回率和 F1 度量),以及平均值。你可以在你的模型的 init()方法中定义这个指标,如下所示:
self.f1 = SpanBasedF1Measure(vocab, tag_namespace='labels')
并使用它在训练和验证过程中获得指标,如下所示:
def get_metrics(self, reset: bool = False) -> Dict[str, float]:
f1_metrics = self.f1.get_metric(reset)
return {'accuracy': self.accuracy.get_metric(reset),
'prec': f1_metrics['precision-overall'],
'rec': f1_metrics['recall-overall'],
'f1': f1_metrics['f1-measure-overall']}
如果你运行这个训练流程,你会得到大约 0.97 的准确度,而精确度、召回率和 F1 度量将都在 0.83 左右。你还可以使用predict()
方法来获得未见过的句子的命名实体标签,如下所示:
tokens = ['Apple', 'is', 'looking', 'to', 'buy', 'UK', 'startup',
'for', '$1', 'billion', '.']
labels = predict(tokens, model)
print(' '.join('{}/{}'.format(token, label)
for token, label in zip(tokens, labels)))
它将生成如下结果:
Apple/B-org is/O looking/O to/O buy/O UK/O startup/O for/O $1/O billion/O ./O
这并不完美 —— NER 标注器正确获取了第一个命名实体(“Apple”),但错过了另外两个(“UK”和“10 亿美元”)。如果你查看训练数据,你会发现提及“UK”的情况从未出现过,而且没有标注货币值。系统难以标记它从未见过的实体是毫不奇怪的。在自然语言处理(以及机器学习一般)中,测试实例的特征需要与训练数据匹配,才能使模型完全有效。
5.5 语言建模
在这一部分,我将稍微转换一下方向,介绍语言模型,这是自然语言处理中最重要的概念之一。我们将讨论它们是什么,它们为什么重要,以及如何使用我们迄今介绍的神经网络组件来训练它们。
5.5.1 什么是语言模型?
想象一下,你被要求预测接下来的单词是什么,给出一个部分句子:“My trip to the beach was ruined by bad ___。”接下来可能是什么词?许多事情都可能毁了一次海滩之行,但最有可能的是天气不好。也许是海滩上的没礼貌的人,或者可能是这个人在旅行前吃的不好的食物,但大多数人会同意在这个部分句子之后跟着“weather”是一个可能性很高的词。在这种情况下,很少有其他名词(people,food,dogs)和其他词性的词(be,the,run,green)与“weather”一样合适。
刚才你所做的是为一个英文句子分配一些信念(或概率)。你刚刚比较了几个替代方案,并判断它们作为英文句子的可能性有多大。大多数人都会同意,“My trip to the beach was ruined by bad weather”的概率远远高于“My trip to the beach was ruined by bad dogs”。
形式上,语言模型是一种给出文本片段概率的统计模型。一个英文语言模型会为看起来像英文的句子分配较高的概率。例如,一个英文语言模型会给“My trip to the beach was ruined by bad weather”比给“My trip to the beach was ruined by bad dogs”或甚至“by weather was trip my bad beach the ruined to.”更高的概率。句子的语法越好,越有“意义”,概率就越高。
5.5.2 语言模型有什么用处?
你可能会想知道这样一个统计模型有什么用。虽然在回答填空题时预测下一个单词可能会派上用场,但语言模型在自然语言处理中扮演着什么特殊的角色呢?
答案是,对于生成自然语言的任何系统都是必不可少的。例如,机器翻译系统,它可以根据另一种语言中的句子生成一个语言中的句子,将受益于高质量的语言模型。为什么?假设我们想将一个西班牙语句子“Está lloviendo fuerte”翻译成英语(“It is raining hard”)。最后一个词“fuerte”有几个英语对应词——strong、sharp、loud、heavy等等。你如何确定哪个英语对应词在这种情况下是最合适的?解决这个问题的方法有很多种,但最简单的之一是使用英语语言模型并重新排列几个不同的翻译候选项。假设你已经翻译到“It is raining”,你只需要在西班牙语-英语词典中查找所有可能的对应词,并生成“It is raining strong”、“It is raining sharp”、“It is raining loud”、“It is raining hard”。然后,你只需要询问语言模型,这些候选项中哪一个具有最高的概率。
注意 实际上,神经机器翻译模型可以被视为在目标语言中生成句子的语言模型的一种变体,这种语言模型受其输入(源语言中的句子)的影响。这样的语言模型被称为条件语言模型,与我们在此讨论的无条件语言模型相对。我们将在第六章讨论机器翻译模型。
语音识别中也会出现类似的情况,这是另一个根据口语音频输入生成文本的任务。例如,如果有人说“你是对的”,语音识别系统如何知道实际上是“你是对的”?因为“you’re”和“your”可能发音相同,同样,“right”和“write”,甚至“Wright”和“rite”也可能发音相同,系统的输出可能是“You’re write”,“You’re Wright”,“You’re rite”,“Your right”,“Your write”,“Your Wright”等等。再次,解决这种歧义的最简单方法是使用语言模型。英语语言模型会正确重新排序这些候选项,并确定“you’re right”是最可能的转录。
实际上,人类一直在做这种类型的消歧义,虽然是无意识的。当你在一个大型聚会上和别人交谈时,你接收到的实际音频信号通常非常嘈杂。大多数人仍然可以毫无问题地相互理解,因为人们的语言模型帮助他们“纠正”你听到的内容并填补任何缺失部分。如果你尝试用不太熟练的第二语言进行交流,你会注意到这一点——在嘈杂的环境中,你会更难以理解对方,因为你的语言模型不如你的第一语言好。
训练 RNN 语言模型
此时,你可能想知道预测下一个单词与为句子分配概率之间的联系是什么。这两者实际上是等价的。我不打算解释背后的理论,因为这需要你理解一些数学(尤其是概率论),我将在不涉及数学细节的情况下尝试一个直观的例子。
想象一下,你想要估计明天的天气有雨和地面潮湿的几率。让我们简化一下,假设只有两种天气,晴天和雨天。地面只有两种结果:干燥或潮湿。这相当于估计一个序列的概率:[rain, wet]。
进一步假设某一天下雨的可能性是 50-50。下雨后,地面潮湿的概率是 90%。那么,雨和地面潮湿的概率是多少?简单地是 50%乘以 90%,即 45%,或者 0.45。如果我们知道一个事件在另一个事件之后发生的概率,那么你可以简单地将两个概率相乘得到序列的总概率。这在概率论中被称为链规则。
类似地,如果你能正确估计一部分句子后一个词出现的概率,你可以简单地将其与部分句子的概率相乘。从第一个词开始,你可以一直做下去,直到句子的结尾。例如,如果你想计算“去海滩的旅行是……”的概率,你可以将以下内容相乘:
-
句子开头出现“The”的概率
-
“The”之后出现“trip”的概率
-
“旅行”之后出现“去”的概率
-
“去”之后出现“the”的概率
-
依此类推
这意味着要建立一个语言模型,你需要一个能够预测下一个词的概率(或者更准确地说,是概率分布)的模型,考虑到上下文。你可能已经注意到这听起来有点熟悉。确实,在这里所做的事情与本章中我们一直在谈论的顺序标记模型非常相似。例如,一个词性标注模型预测可能的词性标签在给定上下文的情况下的概率分布。一个命名实体识别(NER)模型为可能的命名实体标签做同样的事情。不同之处在于,语言模型是为可能的下一个词做出预测,考虑到模型到目前为止遇到了什么。希望现在开始有些明白为什么我在本章中谈论语言模型了!
总之,要构建一个语言模型,你需要微调一个基于 RNN 的序列标注模型,使其稍微调整,以便它给出下一个单词的估计值,而不是 POS 或 NER 标签。在第三章中,我谈到了 Skip-gram 模型,它根据目标单词预测上下文中的单词。注意这里的相似之处——这两种模型都预测可能单词的概率。Skip-gram 模型的输入只是一个单词,而语言模型的输入是部分序列。你可以使用类似的机制,通过使用线性层将一个向量转换为另一个向量,然后使用 softmax 将其转换为概率分布,正如我们在第三章中讨论的那样。体系结构如图 5.12 所示。
图 5.12 基于 RNN 的语言模型架构
基于 RNN 的语言模型的训练方式与其他序列标注模型类似。我们使用的损失函数是序列交叉熵损失,它度量了预测单词与实际单词之间的“偏差”程度。交叉熵损失是每个单词计算的,并且在句子中的所有单词上进行平均。
5.6 使用 RNN 生成文本
我们看到语言模型为自然语言句子给出了概率。但更有趣的部分是,你可以使用语言模型从头开始生成自然语言句子!在本章的最后一节,我们将构建一个语言模型。你可以使用训练好的模型来评估和生成英语句子。你可以在 Google Colab 笔记本上找到此子节的整个脚本(realworldnlpbook.com/ch5.html#lm-nb
)。
5.6.1 将字符馈送到 RNN
在本节的前半部分,我们将构建一个英语语言模型,并使用通用英语语料库对其进行训练。在我们开始之前,我们注意到,本章中构建的 RNN 语言模型是基于 字符 而不是基于单词或令牌的。到目前为止,我们所见过的所有 RNN 模型都是基于单词的,这意味着 RNN 的输入始终是单词序列。另一方面,在本节中,我们将使用的 RNN 接受字符序列作为输入。
理论上,RNNs 可以处理任何序列,无论是令牌、字符还是完全不同的东西(例如,语音识别的波形),只要它们可以转换为向量。在构建语言模型时,我们通常将字符作为输入,甚至包括空白和标点,将它们视为长度为 1 的单词。模型的其余部分完全相同——首先将单个字符嵌入(转换为向量),然后将其馈送到 RNN 中,然后训练 RNN,以便它能最好地预测可能出现的下一个字符的分布。
在决定是否应将单词或字符馈送到 RNN 时需要考虑一些因素。使用字符肯定会使 RNN 的效率降低,这意味着它需要更多的计算才能“理解”相同的概念。例如,基于单词的 RNN 可以在一个时间步接收到单词“dog”并更新其内部状态,而基于字符的 RNN 必须等到接收到三个元素 d、o 和 g,以及可能的“_”(空格符)才能做到。基于字符的 RNN 需要“学会”这个由这三个字符组成的序列表示了某个特殊意义(“dog”这个概念)。
另一方面,通过向 RNN 馈送字符,您可以避开许多处理标记的问题。其中一个问题与处理词汇表外(OOV)的单词有关。当训练基于单词的 RNN 时,通常会固定整个词汇表的集合,通常通过枚举在训练集中出现的所有单词来实现。但是,每当在测试集中遇到一个 OOV 单词时,它就不知道如何处理它。通常情况下,它会给所有 OOV 单词分配一个特殊的标记 并以相同的方式处理它们,这并不理想。相反,基于字符的 RNN 仍然可以处理单个字符,因此它可能能够通过观察训练集中的“dog”所学到的规则,推断出“doggy”是什么意思,即使它从未见过确切的单词“doggy”。
5.6.2 使用语言模型评估文本
让我们开始构建一个基于字符的语言模型。第一步是读取一个纯文本数据集文件并生成用于训练模型的实例。我将展示如何在不使用数据集读取器的情况下构建实例以进行演示。假设您有一个 Python 字符串对象 text,您想将其转换为用于训练语言模型的实例。首先,您需要使用 CharacterTokenizer 将其分段为字符,如下所示:
from allennlp.data.tokenizers import CharacterTokenizer
tokenizer = CharacterTokenizer()
tokens = tokenizer.tokenize(text)
需要注意的是,这里的 tokens 是一个 Token 对象的列表。每个 Token 对象包含一个字符,而不是一个单词。然后,按照下面显示的方式在列表的开头和末尾插入 和 符号:
from allennlp.common.util import START_SYMBOL, END_SYMBOL
tokens.insert(0, Token(START_SYMBOL))
tokens.append(Token(END_SYMBOL))
在 NLP 中,在每个句子的开头和结尾插入这些特殊符号是一种常见做法。使用这些符号,模型可以区分句子中一个标记在中间出现与一个标记在开头或结尾出现的情况。例如,句点很可能出现在句子的末尾(“. ”)而不是开头(“ .”),语言模型可以给出两个非常不同的概率,而不使用这些符号是不可能做到的。
最后,您可以通过指定单独的文本字段来构建一个实例。请注意,语言模型的“输出”与输入完全相同,只是偏移了一个标记,如下所示:
from allennlp.data.fields import TextField
from allennlp.data.instance import Instance
input_field = TextField(tokens[:-1], token_indexers)
output_field = TextField(tokens[1:], token_indexers)
instance = Instance({'input_tokens': input_field,
'output_tokens': output_field})
这里的 token_indexers 指定了如何将各个标记映射到 ID。我们仍然使用迄今为止使用的 SingleIdTokenIndexer,如下所示:
from allennlp.data.token_indexers import TokenIndexer
token_indexers = {'tokens': SingleIdTokenIndexer()}
图 5.13 显示了从该过程创建的实例。
图 5.13 用于训练语言模型的实例
训练流程的其余部分以及模型与本章前面提到的顺序标记模型非常相似。有关更多详细信息,请参见 Colab 笔记本。如下面的代码片段所示,在模型完全训练后,你可以从新的文本中构建实例、将它们转化为实例,并计算损失,该损失基本上衡量了模型在预测下一个字符方面的成功程度:
predict('The trip to the beach was ruined by bad weather.', model)
{'loss': 1.3882852}
predict('The trip to the beach was ruined by bad dogs.', model)
{'loss': 1.5099115}
predict('by weather was trip my bad beach the ruined to.', model)
{'loss': 1.8084583}
这里的损失是预测字符与期望字符之间的交叉熵损失。出现较多“不符合预期”的字符,损失值就会越高,因此你可以使用这些值来衡量输入作为英文文本的自然程度。正如预期的那样,自然句子(如第一个句子)得分低于非自然句子(如最后一个句子)。
注意,如果你计算交叉熵的 2 的幂,那么这个值就被称为困惑度。对于给定的固定自然语言文本,困惑度会降低,因为语言模型在预测下一个字符方面表现更好,所以它通常用于评估文献中的语言模型的质量。
5.6.3 使用语言模型生成文本
(完全训练好的) 语言模型最有趣的方面在于,它们可以根据给定的一些上下文来预测可能出现的下一个字符。具体而言,它们可以给出可能的下一个字符的概率分布,然后根据该分布选择确定下一个字符。例如,如果模型生成了“t”和“h”,并且 LM 是基于通用英文文本训练的,它可能会对字母“e”分配较高的概率,生成常见的英文单词,包括 the、they、them 等。如果你从 标记开始这个过程,并一直进行下去直到达到句子的结尾(即生成 ),你就可以从头开始生成一句英文句子。顺便说一句,这也是为什么像 和 这样的标记很有用——你需要将某些内容输入 RNN 以开始生成,并且你还需要知道句子何时结束。
让我们在下面的类似 Python 代码的伪代码中看一下这个过程:
def generate():
state = init_state()
token = <START>
tokens = [<START>]
while token != <END>:
state = update(state, token)
probs = softmax(linear(state))
token = sample(probs)
tokens.append(token)
return tokens
这个循环看起来与更新 RNNs 的循环非常相似,但有一个关键区别:在这里,我们不接收任何输入,而是生成字符并将它们作为输入。换句话说,RNN 的操作对象是 RNN 自己迄今为止生成的字符序列。这种在其自身生成的过去序列上操作的模型称为 自回归模型。有关此过程的示例,请参见图 5.14。
图 5.14 使用 RNN 生成文本
在上一个代码段中,init_state()和 update()函数是初始化和更新 RNN 隐藏状态的函数,正如我们之前所见。 在生成文本时,我们假设模型及其参数已经训练好了大量的自然语言文本。softmax()函数是在给定向量上运行 Softmax 的函数,而 linear()是扩展/缩小向量大小的线性层。sample()函数根据给定的概率分布返回一个字符。例如,如果分布是“a”:0.6,“b”:0.3,“c”:0.1,则会在 60%的时间内选择“a”,30%的时间选择“b”,10%的时间选择“c”。这确保生成的字符串每次都不同,同时每个字符串看起来都像是英语句子。
注意,您可以使用 PyTorch 的 torch.multinomial()从概率分布中进行抽样。
如果使用 Tatoeba 中的英语句子进行训练,并按照这个算法生成句子,系统将会创建类似于以下举出的例子:
You can say that you don't know it, and why decided of yourself.
Pike of your value is to talk of hubies.
The meeting despoit from a police?
That's a problem, but us?
The sky as going to send nire into better.
We'll be look of the best ever studented.
There's you seen anything every's redusention day.
How a fail is to go there.
It sad not distaples with money.
What you see him go as famous to eat!
这不是个坏开端!如果你看看这些句子,有很多词语和短语看起来是合理的英语句子(“You can say that”、“That’s a problem”、“to go there”、“see him go”等)。即使系统生成了奇怪的单词(“despoit”、“studented”、“redusention”、“distaples”),它们看起来几乎像真正的英语单词,因为它们基本上遵循英语的形态和音韵规则。这意味着语言模型成功地学习了英语的基本语言要素,如字母排列(拼写)、词形变化(形态学)以及基本句子结构(语法)。
然而,如果你将句子作为一个整体来看,很少有句子是有意义的(例如,你看到他去当名人吃饭)。这意味着我们训练的语言模型在建模句子的语义一致性方面存在缺陷。这可能是因为我们的模型不够强大(我们的 LSTM-RNN 需要将句子的所有内容压缩成一个 256 维的向量),或者训练数据集太小(只有 10,000 个句子),或者两者兼而有之。但是你可以轻易想象,如果我们不断增加模型的容量以及训练集的大小,该模型在生成逼真的自然语言文本方面将变得非常出色。2019 年 2 月,OpenAI 宣布开发了一个基于 Transformer 模型的巨型语言模型(我们将在第八章介绍),该模型在 40GB 的互联网文本上进行了训练。该模型显示,它可以在给定提示的情况下生成逼真的文本,展现了几乎完美的语法和长期的主题一致性。事实上,该模型非常出色,以至于 OpenAI 决定不发布他们训练的大型模型,因为他们担心技术可能被用于恶意目的。但是重要的是要记住,无论输出看起来多么智能,他们的模型都是基于我们在本章中的示例玩具模型的相同原理——只是尝试预测下一个字符!
总结
-
序列标记模型会给输入中的每个词都打上一个标签,这可以通过递归神经网络(Recurrent Neural Networks, RNNs)来实现。
-
词性标注(Part-of-speech tagging)和命名实体识别(Named Entity Recognition, NER)是序列标记任务的两个实例。
-
多层 RNN 将多个 RNN 层堆叠在一起,而双向 RNN 结合了前向和后向 RNN 来编码整个句子。
-
语言模型为自然语言文本分配概率,这是通过预测下一个词来实现的。
-
你可以使用一个经过训练的语言模型来评估一个自然语言句子的“自然程度”,甚至是从零开始生成看起来逼真的文本。
第二部分:高级模型
过去几年,自然语言处理领域取得了迅猛的进步。具体来说,Transformer 和预训练语言模型(如 BERT)的出现彻底改变了该领域的格局以及从业者构建自然语言处理应用的方式。本书的这部分内容将帮助你跟上这些最新进展。
第六章介绍了序列到序列模型,这是一类重要的模型,它将使你能够构建更复杂的应用,比如机器翻译系统和聊天机器人。第七章讨论了另一种流行的神经网络架构,卷积神经网络(CNNs)。
第八章和第九章可以说是本书最重要和最令人兴奋的章节。它们分别涵盖了 Transformer 和迁移学习方法(如 BERT)。我们将演示如何利用这些技术构建高质量的机器翻译和拼写检查器等高级自然语言处理应用。
当你完成阅读这一部分时,你会自信地感觉到,通过你目前所学,你现在能够解决各种各样的自然语言处理任务。
第六章:序列到序列模型
本章包括
-
使用 Fairseq 构建机器翻译系统
-
使用 Seq2Seq 模型将一句话转换成另一句话
-
使用束搜索解码器生成更好的输出
-
评估机器翻译系统的质量
-
使用 Seq2Seq 模型构建对话系统(聊天机器人)
在本章中,我们将讨论序列到序列(Seq2Seq)模型,这些模型是一些最重要的复杂自然语言处理模型,被用于广泛的应用场景,包括机器翻译。Seq2Seq 模型及其变种已经在许多实际应用中作为基本构建块使用,包括谷歌翻译和语音识别。我们将使用一个强大的框架来构建一个简单的神经机器翻译系统,以了解这些模型的工作原理以及如何使用贪婪和束搜索算法生成输出。在本章的结尾,我们将构建一个聊天机器人——一个可以与之对话的自然语言处理应用。我们还将讨论简单 Seq2Seq 模型的挑战和局限性。
6.1 介绍序列到序列模型
在前一章中,我们讨论了两种强大的自然语言处理模型,即序列标记和语言模型。回顾一下,序列标记模型接收一些单元的序列(例如,单词)并为每个单元分配一个标签(例如,词性标注)。而语言模型接收一些单元的序列(例如,单词),并估计给定序列在模型训练的领域中出现的概率。你还可以使用语言模型从零开始生成看起来真实的文本。请参阅图 6.1 以了解这两种模型的概况。
图 6.1 序列标记和语言模型
虽然这两种模型对于许多自然语言处理任务都非常有用,但对于某些任务,你可能希望兼顾这两者——让你的模型接收一些输入(例如,一句句子)并产生另一个东西(例如,另一句句子)作为响应。例如,如果你希望将用一种语言写的文本翻译成另一种语言,你需要让模型接收一个句子并产生另一个句子。你能用序列标记模型实现吗?不能,因为它们只能产生与输入句子中标记数量相同数量的输出标签。这显然对于翻译来说太过有限——一种语言中的表达(比如法语中的“Enchanté”)在另一种语言中可以有任意多或少的单词(比如英语中的“Nice to meet you”)。你能用语言模型实现吗?还是不能。虽然你可以使用语言模型生成看起来真实的文本,但你几乎无法控制它们生成的文本。事实上,语言模型不接受任何输入。
但是如果你仔细看图 6.1,你可能会注意到一些东西。左侧模型(序列标记模型)以句子作为输入,并生成某种形式的表示,而右侧模型则生成一个看起来像自然语言文本的长度可变的句子。我们已经有了构建我们想要的东西所需的组件,即一个接受句子并将其转换为另一个句子的模型。唯一缺失的部分是一种连接这两者的方法,以便我们可以控制语言模型生成什么。
实际上,当左侧模型完成处理输入句子时,循环神经网络已经生成了其抽象表示,该表示被编码在循环神经网络的隐藏状态中。如果你能简单地将这两者连接起来,使得句子表示从左到右传递,并且语言模型可以根据这个表示生成另一个句子,那么似乎你可以实现最初想要做的事情!
序列到序列模型,简称Seq2Seq模型,是基于这一见解构建的。Seq2Seq 模型由两个子组件组成,即编码器和解码器。见图 6.2 进行说明。编码器接受一系列单位(例如,一个句子)并将其转换为某种内部表示。另一方面,解码器从内部表示生成一系列单位(例如,一个句子)。总的来说,Seq2Seq 模型接受一个序列并生成另一个序列。与语言模型一样,生成过程在解码器产生一个特殊标记时停止,这使得 Seq2Seq 模型可以生成比输入序列更长或更短的输出。
图 6.2 序列到序列模型
有许多 Seq2Seq 模型的变体存在,这取决于你用于编码器的架构,你用于解码器的架构以及两者之间信息流动的方式。本章涵盖了最基本类型的 Seq2Seq 模型——简单地通过句子表示连接两个循环神经网络。我们将在第八章中讨论更高级的变体。
机器翻译是 Seq2Seq 模型的第一个,也是迄今为止最流行的应用。然而,Seq2Seq 架构是一个通用模型,适用于许多自然语言处理任务。在其中一项任务中,摘要生成,一个自然语言处理系统接受长文本(例如新闻文章)并生成其摘要(例如新闻标题)。Seq2Seq 模型可以用来将较长的文本“翻译”成较短的文本。另一个任务是对话系统,或者聊天机器人。如果你将用户的话语视为输入,系统的回应视为输出,对话系统的工作就是将前者“翻译”成后者。在本章后面,我们将讨论一个案例研究,在这个案例中,我们实际上使用了 Seq2Seq 模型构建了一个聊天机器人。另一个(有些令人惊讶的)应用是解析——如果你将输入文本视为一种语言,将其语法表示视为另一种语言,你可以使用 Seq2Seq 模型解析自然语言文本。
6.2 机器翻译 101
我们在第 1.2.1 节简要提及了机器翻译。简而言之,机器翻译(MT)系统是将给定文本从一种语言翻译成另一种语言的自然语言处理系统。输入文本所用语言称为源语言,而输出文本所用语言称为目标语言。源语言和目标语言的组合称为语言对。
首先,让我们看一些例子,看看是什么样子,以及为什么将外语翻译成英语(或者任何其他你理解的语言)是困难的。在第一个例子中,让我们将一个西班牙句子翻译成英文,即,“Maria no daba una bofetada a la bruja verde.” 翻译成英文对应的是,“Mary did not slap the green witch.” 在说明翻译过程时的一个常见做法是绘制两个句子之间具有相同意思的单词或短语如何映射的图。两个实例之间的语言单位的对应称为对齐。图 6.3 显示了西班牙语和英语句子之间的对齐。
图 6.3 西班牙语和英语之间的翻译和词对齐
一些单词(例如,“Maria” 和 “Mary”,“bruja” 和 “witch”,以及 “verde” 和 “green”)完全一一对应。然而,一些表达(例如,“daba una bofetada” 和 “slap”)在某种程度上有很大不同,以至于你只能在西班牙语和英语之间对齐短语。最后,即使单词之间有一对一的对应关系,单词的排列方式,或者词序,在两种语言之间可能也会有所不同。例如,形容词在西班牙语中在名词之后添加(“la bruja verde”),而在英语中,它们在名词之前(“the green witch”)。在语法和词汇方面,西班牙语和英语在某种程度上是相似的,尤其是与中文和英语相比,尽管这个单一的例子显示了在两种语言之间进行翻译可能是一项具有挑战性的任务。
汉语和英语之间的情况开始变得更加复杂。图 6.4 展示了一句汉语句子(“布什与沙龙举行了会谈。”)和其英文翻译(“Bush held a talk with Shalon.”)之间的对齐。尽管汉语使用了自己的表意文字,但我们在这里使用了罗马化的句子以示简便。
图 6.4 汉语和英语之间的翻译和词对齐
现在你可以在图中看到更多交叉的箭头。与英语不同,汉语介词短语(比如“和沙龙一起”)通常从左边附着在动词上。此外,汉语不明确标记时态,机器翻译系统(以及人工翻译)需要“猜测”英文翻译中应该使用的正确时态。最后,汉译英的机器翻译系统还需要推断每个名词的正确数量(单数或复数),因为汉语名词没有根据数量明确标记(例如,“会谈”只是表示“谈话”,没有明确提及数量)。这是一个很好的例子,说明了翻译的难度取决于语言对。在语言学上不同的语言之间开发机器翻译系统(如中文和英文)通常比在语言学上类似的语言之间(如西班牙语和葡萄牙语)更具挑战性。
图 6.5 日语和英语之间的翻译和词对齐
让我们再看一个例子——从日语翻译成英语,在图 6.5 中有说明。图中所有的箭头都是交叉的,表示这两个句子的词序几乎完全相反。除了日语介词短语(例如“to music”)和关系从句从左边附着,跟汉语一样,宾语(例如例句中的“listening”在“我喜爱听”中)出现在动词之前。换句话说,日语是一种 SOV(主语-宾语-动词)的语言,而到目前为止我们提到的其他语言(英语、西班牙语和汉语)都是 SVO(主语-动词-宾语)的语言。结构上的差异是直接、逐字翻译效果不佳的原因之一。
注 这种语言的词序分类系统(如 SOV 和 SVO)常常用于语言类型学。世界上绝大多数语言都是 SOV(最常见)或 SVO(稍少一些),尽管少数语言遵循其他词序系统,例如阿拉伯语和爱尔兰语使用的 VSO(动词-主语-宾语)。很少一部分语言(不到所有语言的 3%)使用其他类型(VOS、OVS 和 OSV)。
除了前面图示的结构差异之外,许多其他因素也会使机器翻译成为一项困难的任务。其中之一是词汇差异。例如,如果你将日语单词“音楽”翻译成英语“music”,几乎没有歧义。“音楽”几乎总是“music”。然而,如果你将英语单词“brother”翻译成中文,你会面临歧义,因为中文对“哥哥”和“弟弟”使用不同的词语。在更极端的情况下,如果你将“cousin”翻译成中文,你会有八种不同的选择,因为在中国家庭制度中,你需要根据你的表兄弟是母亲的还是父亲的,是女性还是男性,比你大还是小,使用不同的词语。
另一个使机器翻译具有挑战性的因素是省略。你可以在图 6.5 中看到,日语中没有“我”的单词。在诸如中文、日语、西班牙语等许多其他语言中,当主语代词在上下文和/或动词形式中是明确的时候,你可以省略主语代词。这被称为zero pronoun,当从一个省略代词的语言翻译成一个省略频率较低的语言时(例如英语),它可能会成为一个问题。
在乔治敦-IBM 实验期间开发的最早的机器翻译系统之一是在冷战期间将俄语句子翻译成英语的。但它所做的不过是不比用双语词典查找每个单词并用其翻译替换它有多不同。上面展示的三个例子应该足以让你相信,简单地逐词替换太过于限制了。后来的系统包含了更大的词典和语法规则,但这些规则是由语言学家手动编写的,并不足以捕捉语言的复杂性(再次记住第一章中可怜的软件工程师)。
在神经机器翻译(NMT)出现之前,在学术界和工业界主导的机器翻译的主要范式称为统计机器翻译(SMT)。其背后的理念很简单:通过数据学习如何翻译,而不是通过手工制定规则。具体而言,SMT 系统学习如何从包含源语言文本和其在目标语言中的翻译的数据集中进行翻译。这些数据集称为平行语料库(或平行文本或双文本)。通过查看两种语言中成对句子的集合,算法寻找一种语言中的单词应如何翻译为另一种语言的模式。由此产生的统计模型称为翻译模型。同时,通过查看一系列目标句子,算法可以学习目标语言中有效句子的外观。听起来耳熟吗?这正是语言模型的全部内容(请参阅前一章)。最终的 SMT 模型结合了这两个模型,并生成一种对输入的合理翻译,并且在目标语言中是一句有效、流畅的句子。
大约在 2015 年,强大的神经机器翻译(NMT)模型的出现颠覆了 SMT 的主导地位。SMT 和 NMT 有两个关键区别。首先,根据定义,NMT 基于神经网络,而神经网络以其准确建模语言的能力而闻名。因此,由 NMT 生成的目标句子往往比由 SMT 生成的句子更流畅和自然。其次,NMT 模型是端到端训练的,正如我在第一章中简要提到的那样。这意味着 NMT 模型由一个单一的神经网络组成,该网络接受输入并直接产生输出,而不是您需要独立训练的子模型和子模块的拼接。因此,与 SMT 模型相比,NMT 模型更容易训练,代码规模更小。
MT 已经在许多不同的行业和我们生活的方方面面得到了应用。将外语文本翻译成您理解的语言以快速抓住其含义的过程称为摘要。如果在摘要后认为文本足够重要,则可能会将其发送到正式的手动翻译中。专业翻译人员也使用 MT 进行工作。通常,源文本首先使用 MT 系统翻译为目标语言,然后由人类翻译人员编辑生成的文本。这种编辑称为后编辑。使用自动化系统(称为计算机辅助翻译或 CAT)可以加速翻译过程并降低成本。
6.3 构建你的第一个翻译器
在本节中,我们将构建一个可工作的 MT 系统。我们不会编写任何 Python 代码来实现,而是会充分利用现有的 MT 框架。许多开源框架使构建 MT 系统变得更加容易,包括 Moses(www.statmt.org/moses/
)用于 SMT 和 OpenNMT(opennmt.net/
)用于 NMT。在本节中,我们将使用 Fairseq(github.com/pytorch/fairseq
),这是 Facebook 开发的一个 NMT 工具包,如今在 NLP 从业者中变得越来越流行。以下几个方面使 Fairseq 成为快速开发 NMT 系统的不错选择:1)它是一个现代化的框架,提供了许多预定义的最先进的 NMT 模型,您可以立即使用;2)它非常可扩展,意味着您可以通过遵循它们的 API 快速实现自己的模型;3)它非常快速,默认支持多 GPU 和分布式训练。由于其强大的模型,您可以在几小时内构建一个质量不错的 NMT 系统。
在开始之前,请在项目目录的根目录中运行pip install fairseq
来安装 Fairseq。此外,请在您的 shell 中运行以下命令来下载并展开数据集(如果您使用的是 Ubuntu,则可能需要安装 unzip,可以通过运行sudo apt-get install unzip
来安装):²
$ mkdir -p data/mt
$ wget https://realworldnlpbook.s3.amazonaws.com/data/mt/tatoeba.eng_spa.zip
$ unzip tatoeba.eng_spa.zip -d data/mt
我们将使用 Tatoeba 项目中的西班牙语和英语平行句子来训练一个西班牙语到英语的 MT 系统,这是我们在第四章中已经使用过的。该语料库包含大约 20 万个英语句子及其西班牙语翻译。我已经提前格式化了数据集,这样您就可以在不必担心获取数据、标记文本等方面的情况下使用它。数据集已经分为训练、验证和测试子集。
6.3.1 准备数据集
如前所述,MT 系统(包括 SMT 和 NMT)是机器学习模型,因此是根据数据训练的。MT 系统的开发过程看起来与任何其他现代 NLP 系统相似,如图 6.6 所示。首先,对平行语料库的训练部分进行预处理,并用于训练一组 NMT 模型候选者。接下来,使用验证部分来选择所有候选模型中表现最佳的模型。这个过程称为模型选择(请参阅第二章进行复习)。最后,最佳模型将在数据集的测试部分上进行测试,以获得反映模型优劣的评估指标。
图 6.6 构建 NMT 系统的流水线
MT 开发的第一步是对数据集进行预处理。但在进行预处理之前,你需要将数据集转换为易于使用的格式,通常是自然语言处理中的纯文本格式。实践中,用于训练 MT 系统的原始数据以多种不同格式出现,例如,纯文本文件(如果你很幸运的话)、专有软件的 XML 格式、PDF 文件和数据库记录。你的第一项任务是对原始文件进行格式化,使源句子和它们的目标翻译按句子对齐。结果文件通常是一个 TSV 文件,每行都是一个以制表符分隔的句子对,如下所示:
Let's try something. Permíteme intentarlo.
Muiriel is 20 now. Ahora, Muiriel tiene 20 años.
I just don't know what to say. No sé qué decir.
You are in my way. Estás en mi camino.
Sometimes he can be a strange guy. A veces él puede ser un chico raro.
...
在翻译对齐后,平行语料被输入到预处理管道中处理。具体的操作因应用程序和语言而异,但以下步骤最为常见:
-
过滤
-
清理
-
分词
在过滤步骤中,将从数据集中移除任何不适合用于训练 MT 系统的句子对。一个句子对是否太长、是否有用等因素影响很大,例如,任何其中一个文本长度过长(例如超过 1000 个单词)的句子对都无用,因为大多数 MT 模型不能建模这样长的句子。此外,任何其中一个句子过长但另一个句子过短的句子对都可能是由于数据处理或对齐错误而引起的噪音。例如,如果一个西班牙语句子有 10 个单词,其英语翻译的长度应该在 5 到 15 个单词之间。最后,如果平行语料库包含除源语言和目标语言之外的任何语言,应该移除这样的句子对。这种情况比你想象的要多得多——许多文档由于引用、解释或代码切换(在一个句子中混合多种语言)而成为多语言文档。语言检测(见第四章)可以帮助检测到这些异常情况。
过滤后,数据集中的句子可以进一步清理。该过程可能包括删除 HTML 标签和任何特殊字符,以及对字符(例如,繁体中文和简体中文)和拼写(例如,美式英语和英式英语)进行归一化。
如果目标语言使用类似于拉丁(a,b,c,…)或西里尔(а,б,в,…)字母表的脚本,区分大小写,您可能需要规范化大小写。通过这样做,您的 MT 系统将“NLP”与“nlp”和“Nlp”分组在一起。通常,这是一件好事,因为通过具有三个不同表示的单一概念,MT 模型必须从数据中学习它们实际上是单一概念。规范化大小写也会减少不同单词的数量,从而使训练和预测更快。但是,这也将“US”和“Us”以及“us”分组在一起,这可能不是一种理想的行为,具体取决于您处理的数据类型和领域。在实践中,这些决策,包括是否规范化大小写,都是通过观察它们对验证数据性能的影响来谨慎做出的。
机器翻译和 NLP 的数据清理
请注意,这里提到的清理技术并不特定于 MT。任何 NLP 应用和任务都可以从经过精心设计的过滤和清理操作的流程中受益。然而,对于 MT 来说,清理训练数据尤为重要,因为翻译的一致性对于构建强大的 MT 模型至关重要。如果您的训练数据在某些情况下使用“NLP”,而在其他情况下使用“nlp”,则模型将难以找到正确翻译该单词的方法,而人类很容易理解这两个单词代表一个概念。
此时,数据集仍然是一堆字符字符串。大多数 MT 系统操作单词,因此您需要对输入进行标记化(第 3.3 节)以识别单词。根据语言,您可能需要运行不同的流程(例如,对于中文和日文,需要进行词段切分)。
您之前下载和展开的 Tatoeba 数据集已经通过了所有这些预处理流程。现在,您已经准备好将数据集交给 Fairseq 了。第一步是告诉 Fairseq 将输入文件转换为二进制格式,以便训练脚本可以轻松读取它们,如下所示:
$ fairseq-preprocess \
--source-lang es \
--target-lang en \
--trainpref data/mt/tatoeba.eng_spa.train.tok \
--validpref data/mt/tatoeba.eng_spa.valid.tok \
--testpref data/mt/tatoeba.eng_spa.test.tok \
--destdir data/mt-bin \
--thresholdsrc 3 \
--thresholdtgt 3
当成功时,您应该在终端上看到一条“Wrote preprocessed data to data/mt-bin”的消息。您还应该在 data/mt-bin 目录下找到以下一组文件:
dict.en.txt dict.es.txt test.es-en.en.bin test.es-en.en.idx test.es-en.es.bin test.es-en.es.idx train.es-en.en.bin train.es-en.en.idx train.es-en.es.bin train.es-en.es.idx valid.es-en.en.bin valid.es-en.en.idx valid.es-en.es.bin valid.es-en.es.idx
此预处理步骤的关键功能之一是构建词汇表(在 Fairseq 中称为dictionary),它是从词汇项(通常为单词)到它们的 ID 的映射。注意目录中的两个字典文件 dict.en.txt 和 dict.es.txt。MT 涉及两种语言,因此系统需要维护两个映射,每个语言一个。
6.3.2 训练模型
现在,训练数据已转换为二进制格式,您可以准备好训练 MT 模型了。按下面所示使用包含二进制文件的目录以及几个超参数来调用 fairseq-train 命令:
$ fairseq-train \
data/mt-bin \
--arch lstm \
--share-decoder-input-output-embed \
--optimizer adam \
--lr 1.0e-3 \
--max-tokens 4096 \
--save-dir data/mt-ckpt
您不必担心理解大多数参数的含义(至少暂时不用)。此时,您只需要知道使用指定目录中存储的数据(data/mt-bin)使用 LSTM 架构(-arch lstm)和一堆其他超参数来训练模型,并将结果保存在 data/mt-ckpt(checkpoint 的缩写)中即可。
运行此命令时,终端会交替显示两种进度条——一个用于训练,另一个用于验证,如下所示:
| epoch 001: 16%|???▏ | 61/389 [00:13<01:23, 3.91it/s, loss=8.347, ppl=325.58, wps=17473, ups=4, wpb=3740.967, bsz=417.180, num_updates=61, lr=0.001, gnorm=2.099, clip=0.000, oom=0.000, wall=17, train_wall=12]
| epoch 001 | valid on 'valid' subset | loss 4.208 | ppl 18.48 | num_updates 389
验证结果对应的行内容很容易区分——它们会说“验证”子集。每个时期,训练过程会轮流进行两个阶段:训练和验证。机器学习中使用的一个概念——一个时期,意味着对整个训练数据的一次遍历。在训练阶段,使用训练数据计算损失,然后以使新的参数集降低损失的方式调整模型参数。在验证阶段,模型参数被固定,使用一个单独的数据集(验证集)来衡量模型在该数据集上的表现。
我在第一章中提到过,验证集用于模型选择,这是从单个培训集中选择最佳的机器学习模型的过程。在这里,通过交替进行训练和验证阶段,我们使用验证集来检查所有中间模型(即第一个时期后的模型,第二个时期后的模型,等等)的性能。换言之,我们使用验证阶段来监视培训的进展情况。
为什么这是个好方法?我们通过在每个时期之后插入验证阶段获得了许多好处,但最重要的好处是避免过度拟合——验证数据之所以重要正是因为这个原因。为了进一步说明这一点,让我们看看在我们的西班牙语到英语机器翻译模型的训练过程中,训练集和验证集的损失如何随着时间变化,如图 6.7 所示。
随着训练的进行,训练损失变得越来越小,并逐渐趋近于零,因为这正是我们告诉优化器要做的:使损失尽可能地降低。检查训练损失是否在一个个时期后稳步下降是一个很好的“健全性检查”,可以验证您的模型和培训流水线是否按预期工作。
另一方面,如果您看一下验证损失,它在前几个时期内会下降,但在一定点之后逐渐回升,形成一个 U 形曲线——这是过度拟合的一个典型迹象。经过几个时期的培训后,您的模型在训练集上表现得非常好,开始失去其对验证集的泛化性。
图 6.7 训练和验证损失
让我们用机器翻译中的一个具体例子来说明当模型过度拟合时实际发生了什么。例如,如果您的训练数据包含了英文句子“It is raining hard”及其西班牙语翻译“Esta lloviendo fuerte”,而其他句子中没有包含“hard”一词,那么过拟合的模型可能会认为“fuerte”是“hard”的唯一可能翻译。一个正确拟合的模型可能会留下一些余地,让其他西班牙语单词出现作为“hard”的翻译,但一个过拟合的机器翻译系统总是会将“hard”翻译为“fuerte”,这是根据训练集“正确”的做法,但显然不是您想要构建健壮的机器翻译系统的理想选择。例如,“She is trying hard”中“hard”的最佳翻译方式并不是“fuerte”。
如果您看到验证损失开始上升,那么继续保持训练过程是没有意义的,因为很有可能您的模型已经在某种程度上过度拟合了数据。在这种情况下的一种常见做法,称为提前停止,是终止训练。具体来说,如果您的验证损失在一定数量的轮次内没有改善,您就停止训练,并使用验证损失最低时的模型。等待训练终止的轮次数称为耐心。在实践中,最关心的指标(例如 BLEU;请参阅第 6.5.2 节)用于提前停止,而不是验证损失。
好了,现在关于训练和验证就说到这里。图 6.7 中的图表表明验证损失在第 8 轮左右最低,所以你可以在大约 10 轮后停止(通过按 Ctrl + C),否则该命令会一直运行下去。Fairseq 将自动将最佳模型参数(根据验证损失)保存到 checkpoint_best.pt 文件中。
警告 如果您只使用 CPU 进行训练,可能需要很长时间。第十一章解释了如何使用 GPU 加速训练。
6.3.3 运行翻译器
模型训练完成后,您可以调用 fairseq-interactive 命令以交互方式在任何输入上运行您的机器翻译模型。您可以通过指定二进制文件位置和模型参数文件来运行该命令,如下所示:
$ fairseq-interactive \
data/mt-bin \
--path data/mt-ckpt/checkpoint_best.pt \
--beam 5 \
--source-lang es \
--target-lang en
看到提示“Type the input sentence and press return”后,尝试逐一输入(或复制粘贴)以下西班牙语句子:
¡ Buenos días !
¡ Hola !
¿ Dónde está el baño ?
¿ Hay habitaciones libres ?
¿ Acepta tarjeta de crédito ?
La cuenta , por favor .
请注意这些句子中的标点和空白——Fairseq 假定输入已经进行了分词。您的结果可能会略有不同,这取决于许多因素(深度学习模型的训练通常涉及一些随机性),但您会得到类似以下的结果(我加粗了以示强调):
¡ Buenos días !
S-0 ¡ Buenos días !
H-0 -0.20546913146972656 Good morning !
P-0 -0.3342 -0.3968 -0.0901 -0.0007
¡ Hola !
S-1 ¡ Hola !
H-1 -0.12050756067037582 Hi !
P-1 -0.3437 -0.0119 -0.0059
¿ Dónde está el baño ?
S-2 ¿ Dónde está el baño ?
H-2 -0.24064254760742188 Where 's the restroom ?
P-2 -0.0036 -0.4080 -0.0012 -1.0285 -0.0024 -0.0002
¿ Hay habitaciones libres ?
S-3 ¿ Hay habitaciones libres ?
H-3 -0.25766071677207947 Is there free rooms ?
P-3 -0.8187 -0.0018 -0.5702 -0.1484 -0.0064 -0.0004
¿ Acepta tarjeta de crédito ?
S-4 ¿ Acepta tarjeta de crédito ?
H-4 -0.10596384853124619 Do you accept credit card ?
P-4 -0.1347 -0.0297 -0.3110 -0.1826 -0.0675 -0.0161 -0.0001
La cuenta , por favor .
S-5 La cuenta , por favor .
H-5 -0.4411449432373047 Check , please .
P-5 -1.9730 -0.1928 -0.0071 -0.0328 -0.0001
这里大部分的输出句子都几乎完美,除了第四句(我会翻译成“有免费的房间吗?”)。即使考虑到这些句子都是任何一本旅行西班牙短语书中都可以找到的简单例子,但对于一个在一个小时内构建的系统来说,这并不是一个坏的开始!
6.4 Seq2Seq 模型的工作原理
在本节中,我们将深入探讨构成 Seq2Seq 模型的各个组件,包括编码器和解码器。我们还将涵盖用于解码目标句子的算法——贪婪解码和波束搜索解码。
6.4.1 编码器
正如我们在本章开始看到的,Seq2Seq 模型的编码器与我们在第五章中讨论的顺序标记模型并没有太大的不同。它的主要工作是接受输入序列(通常是一个句子)并将其转换为固定长度的向量表示。你可以使用像图 6.8 中所示的 LSTM-RNN。
图 6.8 Seq2Seq 模型的编码器
与顺序标记模型不同,我们只需要 RNN 的最终隐藏状态,然后将其传递给解码器生成目标句子。你也可以使用多层 RNN 作为编码器,这种情况下句子表示是每一层输出的串联,如图 6.9 所示。
图 6.9 使用多层 RNN 作为编码器
同样地,你可以使用双向(甚至是双向多层)RNN 作为编码器。最终的句子表示是正向层和反向层输出的串联,如图 6.10 所示。
图 6.10 使用双向 RNN 作为编码器
注意 这是一个小细节,但要记得 LSTM 单元产生两种类型的输出:单元状态和隐藏状态(请参阅 4.2.2 节)。在使用 LSTM 编码序列时,我们通常只使用最终隐藏状态,而丢弃单元状态。把单元状态看作是类似于临时循环变量,用于计算最终结果(隐藏状态)。请参见图 6.11 进行说明。
图 6.11 使用 LSTM 单元的编码器
6.4.2 解码器
同样,Seq2Seq 模型的解码器与我们在第五章中介绍的语言模型类似。实际上,它们除了一个关键的区别外完全相同——解码器从编码器那里获取输入。我们在第五章中介绍的语言模型称为无条件语言模型,因为它们在没有任何输入或前提条件的情况下生成语言。另一方面,根据某些输入(条件)生成语言的语言模型称为条件语言模型。Seq2Seq 解码器是一种条件语言模型,其中条件是编码器生成的句子表示。请参见图 6.12,了解 Seq2Seq 解码器的工作原理的示例。
图 6.12 Seq2Seq 模型的解码器
就像语言模型一样,Seq2Seq 解码器从左到右生成文本。与编码器一样,您可以使用 RNN 来实现这一点。解码器也可以是多层 RNN。然而,解码器不能是双向的——你不能从两边生成一个句子。正如第五章中提到的那样,对过去生成的序列进行操作的模型被称为自回归模型。
非自回归模型
如果你认为简单地从左到右生成文本太过受限制,那么你有道理。人类也不总是线性地写语言——我们经常在之后修订、添加和删除单词和短语。此外,线性地生成文本并不是很高效。句子的后半部分需要等待直到它的前半部分完成,这使得并行化生成过程非常困难。截至本文撰写时,研究人员正在大力开发非自回归的机器翻译模型,这些模型不会以线性方式生成目标句子(例如,请参阅 Salesforce Research 的这篇论文:arxiv.org/abs/1711.02281
)。然而,它们在翻译质量上还没有超过自回归模型,大多数研究和生产的机器翻译系统仍然采用自回归模型。
解码器在训练阶段和预测阶段的行为略有不同。让我们先看看它是如何训练的。在训练阶段,我们确切地知道源句应该被翻译成目标句。换句话说,我们确切地知道解码器应该逐词生成什么。因此,解码器的训练方式与顺序标记模型的训练方式相似(参见第五章)。
首先,解码器被喂入由编码器产生的句子表示和一个特殊标记,该标记表示句子的开始。 第一个 RNN 单元处理这两个输入并产生第一个隐藏状态。 隐藏状态向量被馈送到一个线性层,该层收缩或扩展此向量以匹配词汇表的大小。 然后得到的向量通过 softmax,将其转换为概率分布。 此分布规定了词汇表中每个单词在接下来出现的可能性。
然后,这就是训练发生的地方。 如果输入是“Maria no daba una bofetada a la bruja verde”,那么我们希望解码器生成其英文等效句子:“Mary did not slap the green witch.” 这意味着我们希望最大化第一个 RNN 单元生成“Mary”的概率,给定输入句子。 这是本书中我们在很多地方见过的一个多类别分类问题——词嵌入(第三章),句子分类(第四章)和序列标记(第五章)。 您使用交叉熵损失来衡量期望结果与网络实际输出之间的差距有多远。 如果“Mary”的概率很大,那么好——网络会产生较小的损失。 另一方面,如果“Mary”的概率很小,则网络会产生较大的损失,这会鼓励优化算法大幅更改参数(魔法常量)。
然后,我们移动到下一个单元。 下一个单元接收由第一个单元计算的隐藏状态和单词“Mary”,不管第一个单元生成了什么。 与使用语言模型生成文本时喂入先前单元生成的标记不同,我们约束解码器的输入,以防止其“偏离”。 第二个单元基于这两个输入产生隐藏状态,然后用于计算第二个单词的概率分布。 我们通过将分布与期望输出“did”进行比较来计算交叉熵损失,并继续移动到下一个单元。 我们一直这样做,直到达到最终标记,即。 句子的总损失是句子中所有单词产生的所有损失的平均值,如图 6.13 所示。
图 6.13 训练 Seq2Seq 解码器
最后,以这种方式计算的损失用于调整解码器的模型参数,以便下一次它能生成期望的输出。 请注意,在此过程中也会调整编码器的参数,因为损失通过句子表示一直传播回编码器。 如果编码器产生的句子表示不好,那么解码器无论如何努力,都无法生成高质量的目标句子。
6.4.3 贪婪解码
现在让我们看看解码器在预测阶段的行为,其中给定了一个源句子给网络,但我们不知道正确的翻译应该是什么。在这个阶段,解码器的行为很像我们在第五章讨论过的语言模型。它被提供了由编码器产生的句子表示,以及一个特殊的标记,表示句子的开头。第一个循环神经网络单元处理这两个输入并产生第一个隐藏状态,然后将其馈送到线性层和 softmax 层,以产生目标词汇的概率分布。关键部分来了——与训练阶段不同,你不知道接下来应该出现的正确单词,所以你有多个选项。你可以选择任何一个具有相当高概率的随机单词(比如“dog”),但最好的选择可能是选择概率最高的单词(如果是“Mary”那就太幸运了)。机器翻译系统生成刚刚选择的单词,然后将其馈送到下一个循环神经网络单元。这个过程重复进行,直到遇到特殊标记。图 6.14 说明了这个过程。
图 6.14 使用 Seq2Seq 解码器进行预测
好的,我们都准备好了吗?我们可以继续评估我们的机器翻译系统了吗,因为它正在尽其所能产生最佳的翻译?不要那么快——在这种方式解码目标句子时可能会出现许多问题。
首先,机器翻译解码的目标是最大化整个目标句子的概率,而不仅仅是单个单词。这正是你训练网络要做的事情——为正确的句子产生最大的概率。然而,前面描述的每一步选择单词的方式是为了最大化该单词的概率。换句话说,这种解码过程只保证了局部最大概率。这种短视、局部最优的算法在计算机科学中被称为贪婪,我刚刚解释的解码算法被称为贪婪解码。然而,仅仅因为你在每一步都在最大化单词的概率并不意味着你在最大化整个句子的概率。一般来说,贪婪算法不能保证产生全局最优解,而使用贪婪解码可能会让你陷入次优翻译的困境。这并不是很直观,所以让我用一个简单的例子来说明这一点。
当你在每个时间步选择单词时,你有多个单词可以选择。你选择其中一个然后移动到下一个循环神经网络单元,它会产生另一组可能选择的单词,这取决于你之前选择的单词。这可以用一个树状结构来表示,就像图 6.15 所示的那样。该图显示了你在一个时间步选择的单词(例如“did”)如何分支到下一个时间步可以选择的一组可能单词(“you”和“not”)。
图 6.15 解码决策树
每个单词到单词的转换都加上了一个分数,该分数对应于选择该转换的概率有多大。你的目标是在从时间步 1 到 4 遍历一条路径时最大化得分的总和。在数学上,概率是 0 到 1 之间的实数,并且你应该将(而不是相加)每个概率相乘以获得总数,但我在这里简化了问题。例如,如果你从“Mary”到“did”,然后到“you”和“do”,你刚生成了一个句子“Mary did you do”,总分是 1 + 5 + 1 = 7。
在之前看到的贪婪解码器生成时间步 2 的“did”后,它将面临两个选择:用 5 分数生成“you”或用 3 分数生成“not”。因为它只是选择得分最高的那个,它会选择“you”并继续前进。然后在时间步 3 之后,它将面临另一个分支——用 1 分数生成“do”或用 2 分数生成“know”。同样,它将选择最大的分数,这样你就会得到“Mary did you know”的翻译,其分数为 1+ 5 + 1 = 8。
这并不是一个坏结果。至少,它不像第一条路径一样糟糕,它的总得分为 7。通过在每个分支上选择最大分数,你确保你的最终结果至少是像样的。然而,如果你在时间步 3 选择了“not”呢?乍一看,这似乎不是个好主意,因为你得到的分数只有 3,比你走另一条路径的 5 小。但在下一个时间步,通过生成“slap”,你得到了 5 分的分数。回顾起来,这是正确的决定——总体而言,你得到了 1 + 3 + 5 = 9 分,这比沿着另一个“you”路径得到的分数要高。通过牺牲短期回报,你能够在长期获得更大的回报。但是由于贪婪解码器的近视性质,它永远不会选择这条路径——它无法回溯并改变其心意,一旦选择了一条而不是另一条路径。
如果看一下图 6.15 中的玩具示例,选择哪个方向以最大化总分数似乎很容易,但在现实中,你不能“预见”未来——如果你处于时间步 t,你无法预测在时间步 t + 1 及以后会发生什么,直到你实际选择一个单词并将其馈送到 RNN 中为止。但是,最大化单个概率的路径不一定是最优解。你无法尝试每个可能的路径并查看你得到的分数,因为词汇表通常包含成千上万个独特的单词,这意味着可能的路径数呈指数增长。
令人沮丧的事实是,您无法合理地期望在短时间内找到最大化整个句子概率的最优解路径。但是你可以避免陷入困境(或至少减少陷入困境的可能性),这就是梁搜索解码器的作用。
6.4.4 梁搜索解码
让我们想象如果你处于同样的情境下应该怎么做。假设你是一名大学大二的学生,在本学年结束前,你需要决定选择哪个专业。你的目标是在你一生中最大化收入(或幸福或其他你关心的东西),但你不知道哪个专业对于这一目标来说是最好的。你不能尝试每个可能的专业并观察几年后的结果——专业太多,你也无法回到过去。并且仅仅因为有些专业在短期内看起来很有吸引力(例如选择经济学专业可能会带来在大型投资银行的好实习机会),并不意味着这条道路在长期来看是最好的(请看 2008 年发生了什么)。
在这种情况下,你可以做的一件事是通过同时选择多个专业(双专业或辅修)而不是 100%致力于特定的专业,来进行投机。几年后,如果情况与你想象的不同,仍然可以更改主意并追求另一种选择,如果你贪婪地选择专业(即只考虑短期前景),则这是不可能的。
梁搜索解码的主要思想类似于这个——不是只选择一条路径,而是同时追求多条路径(称为假设)。这样,你就为“黑马”留下了一些空间,也就是那些在最初得分不高但可能后来表现出色的假设。让我们使用图 6.16 中的示例,这是图 6.15 的略微修改版本。
梁搜索解码的关键思想是使用梁(图 6.16 底部),可以将其看作是一种缓冲区,可以同时保留多个假设。梁的大小,即它可以保持的假设数,称为梁宽度。让我们使用大小为 2 的梁并看看会发生什么。最初,你的第一个假设只包含一个词“Mary”,得分为 0。当你转向下一个单词时,你选择的单词被附加到假设中,并且得分增加了你刚刚走过的路径的得分。例如,当你转到“did”时,它会生成一个新的假设,包含“Mary did”和得分 1。
图 6.16 梁搜索解码
如果在任何特定时间步有多个词可供选择,假设可能会产生多个子假设。在时间步 2,你有三个不同的选择——“你”,“不”和“n’t”——这会生成三个新的子假设:[Mary did you] (6),[Mary did not] (4) 和 [Mary did n’t] (3)。这就是束搜索解码的关键部分:因为束中只有有限的空间,任何不够好的假设在按分数排序后都会从束中掉下来。因为在这个例子中束只能容纳两个假设,除了前两个以外的任何东西都会被挤出束外,这就留下了[Mary did you] (6)和[Mary did not] (4)。
在时间步 3,每个剩余的假设可以产生多达两个子假设。第一个([Mary did you] (6))将生成[Mary did you know] (8)和[Mary did you do] (7),而第二个([Mary did not] (4))会变成[Mary did not slap] (9)。这三个假设按分数排序,最好的两个将作为束搜索解码的结果返回。
恭喜——现在你的算法能够找到最大化总分数的路径。通过同时考虑多个假设,束搜索解码可以增加你找到更好解决方案的机会。然而,它永远不是完美的——注意,一个同样好的路径[Mary did n’t do],得分为 9,在时间步 3 就从束中掉出来了。要“拯救”它,你需要增加束宽度到 3 或更大。一般来说,束宽度越大,翻译结果的期望质量就越高。然而,这是有一个折衷的:因为计算机需要考虑多个假设,随着束宽度的增加,它会线性变慢。
在 Fairseq 中,你可以使用 —beam 选项来改变束大小。在第 6.3.3 节的示例中,我使用了 —beam 5 来使用束宽度为 5。你已经在不知不觉中使用了束搜索。如果你使用相同的命令调用 —beam 1,这意味着你使用的是贪婪解码而不是束搜索,你可能会得到略有不同的结果。当我尝试这样做时,我得到的结果几乎相同,除了最后一个:“counts, please”,这不是“La cuenta, por favor.” 的一个很好的翻译。这意味着使用束搜索确实有助于提高翻译质量!
6.5 评估翻译系统
在这一节中,我想简要谈谈评估机器翻译系统的话题。准确评估机器翻译系统是一个重要的话题,无论在理论上还是在实践中。
6.5.1 人工评估
评估机器翻译系统输出的最简单、最准确的方法是使用人工评估。毕竟,语言是为人类翻译的。被人类认为好的翻译应该是好的。
正如之前提到的,我们对好的翻译有一些考虑因素。对于这两个方面最重要且常用的概念是充分性(也称为忠实度)和流畅性(也与可理解性密切相关)。充分性是源句子中的信息在翻译中反映的程度。如果通过阅读它的翻译,你可以重构源句子表达的大量信息,那么该翻译具有很高的充分性。流畅性则是翻译在目标语言中的自然程度。例如,如果你正在翻译为英语,“Mary did not slap the green witch”是一种流畅的翻译,而“Mary no had a hit with witch, green”则不是,尽管这两种翻译几乎是同样充分的。请注意,这两个方面在某种程度上是独立的-你可以想象一种流畅但不充分的翻译(例如,“Mary saw a witch in the forest”是一种完全流畅但不充分的翻译),反之亦然,就像之前的例子一样。能够产生既充分又流畅输出的 MT 系统是有价值的。
MT 系统通常通过将其翻译呈现给人类注释器并要求他们对其每个方面进行 5 或 7 点的评价来进行评估。流畅性更容易判断,因为它只需要目标句子的单语种听众,而充分性需要源语言和目标语言的双语种人员。
6.5.2 自动评估
尽管人工评估给出了 MT 系统质量的最准确评估,但并不总是可行的。在大多数情况下,你可能无法负担雇用人类评估员以在你需要时评估 MT 系统的输出。如果你处理的是不常见的语言对,你可能根本找不到双语的说话者来评估其充分性。
但更重要的是,在开发 MT 系统时需要不断地评估和监测其质量。例如,如果你使用 Seq2Seq 模型去训练一个 NMT 系统,你需要每次调整一个超参数就重新评估其性能。否则,你就不知道你的更改对它的最终性能是否有好或坏的影响。更糟糕的是,如果你要做像“early stopping”(见第 6.3.2 节)这样的事情来决定何时停止训练过程,你需要在每个周期之后评估它的性能。你不可能雇用人来在每个周期评估你的中间模型-这将是开发 MT 系统的一个可怕的缓慢的方法。这也是浪费时间,因为最初模型的输出在很大程度上是垃圾,不值得人类评估。中间模型的输出之间存在大量的相关性,人类评估员将花费大量的时间评估非常相似甚至相同的句子。
因此,如果我们能够使用某种自动方式来评估翻译质量,将是可取的。这种工作方式类似于我们之前看到的其他 NLP 任务的一些自动度量,例如分类的准确度、精确度、召回率和 F1 度量。其思想是提前为每个输入实例创建期望的输出,并将系统的输出与之进行比较。通常,这是通过为每个源句准备一组人为创建的翻译(称为参考)并计算参考和系统输出之间的某种相似度来完成的。一旦你创建了参考并定义了指标,你就可以根据需要自动评估翻译质量多少次。
计算参考和系统输出之间相似度的最简单方法之一是使用单词错误率(WER)。WER 反映系统相对于参考的错误数量,通过插入、删除和替换的相对数量来衡量。该概念类似于编辑距离,不同之处在于 WER 是针对单词而不是字符计数的。例如,当参考句子是“玛丽没有打绿色的女巫”,系统翻译为“玛丽打了绿色的邪恶女巫”时,你需要进行三次“编辑”才能将后者与前者匹配——插入“没有”,用“打”替换“击中”,删除“邪恶”。如果你将三除以参考长度(= 7),就是你的 WER(= 3/7,或 0.43)。WER 越低,翻译质量越好。
尽管 WER 简单易用,但在评估机器翻译系统时如今并不常用。一个原因与多个参考有关。对于单个源句可能有多个同样有效的翻译,但是当存在多个参考时如何应用 WER 并不清楚。对于机器翻译中自动评估最常用的稍微复杂一点的指标是 BLEU(双语评估学习)。BLEU 通过使用修改后的精确度来解决多个参考的问题。接下来我将用一个简单的例子来说明这一点。
在以下表格中,我们评估一个候选人(系统的输出)“the the the the the the the”(顺便说一句,这是一个糟糕的翻译)与两个参考:“猫在地毯上”和“地毯上有只猫”。BLEU 的基本思想是计算候选中所有唯一单词的精度。因为候选中只有一个唯一单词“the”,如果计算其精度,它将自动成为候选的分数,即为 1,或 100%。但这似乎有些不对。
候选 | the | the | the | the | the | the | the |
---|---|---|---|---|---|---|---|
参考 1 | 猫 | 在 | 地毯 | 上 | |||
参考 2 | 那里 | 有 | 一只 | 猫 | 在 | 地毯 | 上 |
因为参考译文中只有两个“the”,系统生成的虚假“the”不应该计入精度。换句话说,我们应该将它们视为误报。我们可以通过将精度的分母限制为参考译文中该词的最大出现次数来做到这一点。因为在这种情况下(在参考译文 1 中)是 2,其修改后的精度将为 2/7,约为 29%。在实践中,BLEU 不仅使用唯一的词(即一元词),还使用候选译文和参考译文中长度不超过 4 的所有唯一单词序列(n 元词)。
然而,我们可以通过另一种方式操纵这个指标——因为它基于精度而不是召回率,一个机器翻译系统可以通过产生很少的系统确信的词语来轻松获得高分。在前面的例子中,你只需简单地产生“cat”(甚至更简单地,“the”),BLEU 分数将达到 100%,这显然不是一个好的翻译。BLEU 通过引入简洁惩罚来解决这个问题,如果候选翻译比参考译文短,就会打折扣。
精确自动评估指标的开发是一个活跃的研究领域。许多新的指标被提出并用于解决 BLEU 的缺点。我们在这一部分只是浅尝辄止。虽然新的指标显示与人类评估的相关性更高,并声称更好,但 BLEU 仍然是目前最广泛使用的指标,主要是因为其简单性和悠久的传统。
6.6 案例研究:构建聊天机器人
在本节中,我将讨论 Seq2Seq 模型的另一个应用——聊天机器人,这是一种 NLP 应用,你可以与之进行对话。我们将使用 Seq2Seq 模型构建一个非常简单但功能齐全的聊天机器人,并讨论构建智能代理的技术和挑战。
6.6.1 引入对话系统
我在第 1.2.1 节简要介绍了对话系统。简而言之,主要有两种类型的对话系统:面向任务和聊天机器人。尽管面向任务的对话系统用于实现一些特定目标,例如在餐厅预订和获取一些信息,但聊天机器人用于与人类进行对话。由于商业对话人工智能系统如亚马逊 Alexa、苹果 Siri 和谷歌助手的成功和大量普及,对话技术目前是自然语言处理从业者的热门话题。
您可能不知道如何开始构建可以进行会话交流的自然语言处理应用程序。我们该如何构建一个“智能”的东西来“思考”,以便它能为人类输入生成有意义的响应?这似乎有些遥远和困难。但是,如果您退后一步,看看我们与其他人的典型对话,有多少实际上是“智能”的呢?如果您像我们大多数人一样,那么您正在进行的会话中有很大一部分都是自动驾驶的:“你好吗?”“我没事,谢谢”“祝你有个愉快的一天”“你也是!”等等。您可能还有一组“模板”回复,对于许多日常问题,例如“你在干什么?”和“你来自哪里?”这些问题可以通过查看输入来回答。甚至更复杂的问题,如“X 中你最喜欢的餐厅是什么?”(其中 X 是您城市的一个地区的名称)和“你最近看过任何 Y 类型的电影吗?”(其中 Y 是一种类型),都可以通过“模式匹配”并从记忆中检索相关信息来回答。
如果您将对话视为一组“回合”,其中响应是通过模式匹配来生成的,那么这看起来与典型的自然语言处理问题非常相似。特别是,如果您认为对话是一个问题,其中 NLP 系统只是将您的问题“翻译”为其响应,那么这正是我们可以使用本章迄今涵盖的 Seq2Seq 模型的地方。我们可以将之前(人类的)话语视为外国语句子,并将聊天机器人“翻译”成另一种语言。尽管在这种情况下,这两种语言都是英语,但是在 NLP 中,通常将输入和输出视为两种不同的语言,并将 Seq2Seq 模型应用于它们,包括摘要(将更长的文本缩短)和语法纠错(将有错误的文本纠正为无错误的文本)。
6.6.2 准备数据集
在本案例中,我们将使用自我对话语料库(github.com/jfainberg/self_dialogue_corpus
),其中包含 24,165 个对话。这个数据集的特殊之处在于,这些对话并不是两个人之间的实际对话,而是由一人扮演双方所写的虚构对话。虽然还有其他几个基于文本的聊天机器人的对话数据集(例如 OpenSubtitles 数据集,opus.nlpl.eu/OpenSubtitles-v2018.php
),但这些数据集通常存在噪声并且常常包含粗言秽语。相比之下,通过收集编造的对话,自我对话语料库在仅有一人的情况下提高了一半的质量(因为你只需要一个人而不是两个人!)。
与之前相同,我对语料进行了分词和转换,使其可被 Fairseq 解读。您可以按以下方式获取转换后的数据集:
$ mkdir -p data/chatbot
$ wget https://realworldnlpbook.s3.amazonaws.com/data/chatbot/selfdialog.zip
$ unzip selfdialog.zip -d data/chatbot
您可以使用以下 paste 命令的组合(以水平方式拼接文件)和 head 命令来查看训练部分的开头。请注意,我们使用 fr(表示“外语”,而不是“法语”)来表示我们正在从中翻译的“语言”:
$ paste data/chatbot/selfdialog.train.tok.fr data/chatbot/selfdialog.train.tok.en | head
...
Have you played in a band ? What type of band ?
What type of band ? A rock and roll band .
A rock and roll band . Sure , I played in one for years .
Sure , I played in one for years . No kidding ?
No kidding ? I played in rock love love .
I played in rock love love . You played local ?
You played local ? Yes
Yes Would you play again ?
Would you play again ? Why ?
...
如您所见,每一行都包含一个话语(在左侧)和对其的回应(在右侧)。请注意,此数据集与我们在第 6.3.1 节中使用的西班牙语-英语平行语料库具有相同的结构。下一步是运行 fairseq-preprocess 命令将其转换为二进制格式,如下所示:
$ fairseq-preprocess \
--source-lang fr \
--target-lang en \
--trainpref data/chatbot/selfdialog.train.tok \
--validpref data/chatbot/selfdialog.valid.tok \
--destdir data/chatbot-bin \
--thresholdsrc 3 \
--thresholdtgt 3
再次,这与我们为西班牙语翻译器示例运行的内容类似。只需注意您指定的源语言—我们在这里使用的是 fr 而不是 es。
6.6.3 训练和运行聊天机器人
现在,聊天机器人的训练数据准备好了,让我们从这些数据中训练一个 Seq2Seq 模型。您可以像下面这样调用 fairseq-train 命令,几乎使用与上次相同的参数:
$ fairseq-train \
data/chatbot-bin \
--arch lstm \
--share-decoder-input-output-embed \
--optimizer adam \
--lr 1.0e-3 \
--max-tokens 4096 \
--save-dir data/chatbot-ckpt
与先前一样,请注意验证损失随每个时代的变化。当我尝试时,验证损失在约五个时代内减小,然后开始缓慢上升。在观察到验证损失趋于稳定后,随时按 Ctrl + C 停止训练命令。Fairseq 将自动将最佳模型(按验证损失测量)保存为 checkpoint_best.pt。
最后,您可以通过调用 fairseq-interactive 命令来运行聊天机器人模型,如下所示:
$ fairseq-interactive \
data/chatbot-bin \
--path data/chatbot-ckpt/checkpoint_best.pt \
--beam 5 \
--source-lang fr \
--target-lang en
与前面的示例一样,您可以输入源句并与聊天机器人对话,让它将它们“翻译”为另一种语言!以下是我与我训练的模型进行的一部分对话(为了清晰起见,我添加了粗体)。再次强调,您的结果可能不同:
Hi !
S-0 Hi !
H-0 -1.011711835861206 Hey .
P-0 -0.6860 -2.1352 -0.2140
How are you ?
S-1 How are you ?
H-1 -0.6552960276603699 Doing good , you ?
P-1 -0.6472 -0.8651 -1.0960 -0.9257 -0.3337 -0.0640
Good .
S-2 Good .
H-2 -0.8540937900543213 Do you have a favorite team ?
P-2 -2.3027 -0.0550 -1.7088 -0.4403 -0.1763 -2.0262 -0.1150 -0.0085
Yeah I like the Seahawks .
S-3 Yeah I like the Seahawks .
H-3 -0.6036218404769897 Do you have a favorite team ?
P-3 -2.7614 -0.0525 -1.0905 -0.3178 -0.3213 -0.1049 -0.1701 -0.0106
How about you ?
S-4 How about you ?
H-4 -1.2329542636871338 I 'm a big fan of them .
P-4 -0.7648 -1.8435 -1.6808 -1.2868 -0.2102 -0.1362 -3.5273 -1.0031 -0.6438
What 's your favorite movie ?
S-5 What 's your favorite movie ?
H-5 -0.8238655924797058 Oh , that 's a tough one . I 'd have to say Ghost Busters .
P-5 -3.2622 -0.3213 -1.4815 -0.1012 -0.3417 -0.3069 -0.2432 -0.6954 -1.0858 -2.3246 -0.4296 -0.0039 -0.0905 -2.6429 -0.1676 -0.3853 -0.1221
Oh yeah , I like that movie too .
S-6 Oh yeah , I like that movie too .
H-6 -1.0659444332122803 Yeah , that 's a good one .
P-6 -2.0782 -0.1724 -2.5089 -0.9560 -1.5034 -0.9040 -0.4471 -0.5435 -0.4801
在这个例子中,对话看起来很自然。因为自我对话语料库是通过限制可能的对话主题集来构建的,所以如果您保持在这些主题(电影、体育、音乐等)上,对话更有可能顺利进行。
然而,一旦您开始谈论不熟悉的话题,聊天机器人就会对其答案失去信心,如下所示:
What 's your name ?
S-0 What 's your name ?
H-0 -0.9807574152946472 I do n't know , but I do n't think I 've heard of them .
P-0 -1.4680 -2.2938 -0.0635 -1.0242 -1.2039 -0.5201 -0.3381 -2.2612 -0.1665 -1.6033 -0.6112 -1.5515 -0.8739 -0.8453 -1.0143 -0.4702 -0.3637
What do you do ?
S-1 What do you do ?
H-1 -1.170507788658142 I do n't know .
P-1 -0.9807 -2.1846 -0.3276 -0.9733 -1.3650 -1.1919
Are you a student ?
S-2 Are you a student ?
H-2 -0.9505285024642944 I 'm not sure .
P-2 -1.5676 -1.5270 -0.6944 -0.2493 -0.8445 -0.8204
这是一个众所周知的现象—一个简单的基于 Seq2Seq 的聊天机器人很快就会退化到生成“我不知道”和“我不确定”等模板回答,每当被问及它不熟悉的事物时。这与我们训练这个聊天机器人的方式有关。因为我们训练模型是为了使其在训练数据中最小化损失,它能采取的最佳策略是生成适用于尽可能多的输入句子的东西。非常通用的短语,比如“我不知道”,可以成为许多问题的答案,所以这是一个安全的策略,可以减少损失!
6.6.4 后续步骤
虽然我们的聊天机器人可以为许多输入产生逼真的响应,但它离完美还有很远的路要走。它处理不好的一个问题是专有名词。当你询问需要具体答案的问题时,就会看到这一点,比如:
What 's your favorite show ?
S-0 What 's your favorite show ?
H-0 -0.9829921722412109 I would have to say <unk> .
P-0 -0.8807 -2.2181 -0.4752 -0.0093 -0.0673 -2.9091 -0.9338 -0.3705
这里,是未知词的通用特殊符号。聊天机器人正在尝试回答某些问题,但该问题在训练数据中出现的频率太低,以至于不能被视为独立的词。这是一般简单 NMT 系统中出现的问题。因为模型需要将有关单词的所有信息压缩成 200 多维的数字向量,所以许多细节和类似单词之间的区别都被牺牲了。想像一下将关于你所在城市所有餐厅的信息压缩成一个 200 维的向量!
此外,我们训练的聊天机器人没有任何 “记忆” 或任何上下文概念。你可以通过问一系列相关问题来测试这一点,如下所示:
Do you like Mexican food ?
S-0 Do you like Mexican food ?
H-0 -0.805641770362854 Yes I do .
P-0 -1.0476 -1.1101 -0.6642 -0.6651 -0.5411
Why do you like it ?
S-1 Why do you like it ?
H-1 -1.2453081607818604 I think it 's a great movie .
P-1 -0.7999 -2.1023 -0.7766 -0.7130 -1.4816 -2.2745 -1.5750 -1.0524 -0.4324
在第二个问题中,聊天机器人在理解上下文方面有困难,并产生了完全无关的响应。要正确回答这样的问题,模型需要理解代词 “it” 指的是前面的名词,即本例中的 “Mexican food”。NLP 系统在现实世界中解决哪些提及指向哪些实体的任务被称为共指消解。系统还需要维护某种类型的记忆,以跟踪到目前为止在对话中讨论了哪些内容。
最后,在本章中讨论的简单 Seq2Seq 模型在处理长句子方面并不擅长。如果您回顾一下图 6.2,就会理解这一点 - 模型使用 RNN 读取输入语句,并使用固定长度的句子表示向量表示有关句子的所有内容,然后从该向量生成目标语句。无论输入是“Hi!”还是“The quick brown fox jumped over the lazy dog.”,句子表示都会成为瓶颈,特别是对于更长的输入。因此,在 2015 年左右,直到发明了一种称为 注意力 的机制来解决这个问题之前,神经 MT 模型无法击败传统的基于短语的统计 MT 模型。我们将在第八章详细讨论注意力。
概括
-
Seq2Seq 模型使用编码器和解码器将一个序列转换为另一个序列。
-
你可以使用 fairseq 框架在一小时内构建工作中的翻译系统。
-
Seq2Seq 模型使用解码算法生成目标序列。贪心解码每一步都最大化概率,而束搜索则尝试同时考虑多个假设来寻找更好的解决方案。
-
用于自动评估翻译系统的一个指标叫做 BLEU。
-
通过使用 Seq2Seq 模型和对话数据集,可以构建一个简单的聊天机器人。
^(1.) 详细信息请参阅 Oriol Vinyals 等人的“Grammar as a Foreign Language”(2015 年;arxiv.org/abs/1412.7449
)。
^(2.) 请注意每行开头的 $ 是由 shell 渲染的,您无需输入它。
第七章:卷积神经网络
本章内容包括
-
通过检测模式解决文本分类问题。
-
使用卷积层来检测模式并生成分数。
-
使用池化层来聚合由卷积产生的分数。
-
通过组合卷积和池化来构建卷积神经网络(CNN)。
-
使用 AllenNLP 构建基于 CNN 的文本分类器。
在之前的章节中,我们介绍了线性层和 RNN,这是 NLP 中常用的两种主要神经网络体系结构。在本章中,我们介绍了另一种重要的神经网络类别,称为卷积神经网络(CNN)。CNN 具有与 RNN 不同的特征,使它们适用于检测语言模式至关重要的 NLP 任务,例如文本分类。
7.1 介绍卷积神经网络(CNN)
本节介绍了另一种类型的神经网络体系结构,称为卷积神经网络(CNN),它以与 RNN 不同的方式运行。CNN 特别擅长于模式匹配任务,在 NLP 社区中越来越受欢迎。
7.1.1 循环神经网络及其缺点。
在第四章中,我们讨论了句子分类,这是一个自然语言处理任务,接收一些文本作为输入并为其生成标签。我们还讨论了如何使用循环神经网络(RNN)来完成该任务。作为复习,RNN 是一种具有“循环”的神经网络,它从开头开始逐个元素地处理输入序列直到结束。在每一步更新的内部循环变量称为隐藏状态。当 RNN 完成处理整个序列时,最终时间步长处的隐藏状态表示输入序列的压缩内容,可用于包括句子分类在内的 NLP 任务。或者,您可以在每一步之后取出隐藏状态并将其用于为单词分配标签(例如 PoS 和命名实体标签)。在循环中反复应用的结构称为单元。具有简单乘法和非线性的 RNN 称为香草或埃尔曼 RNN。另一方面,基于 LSTM 和 GRU 的 RNN 使用更复杂的单元,这些单元使用存储器和门。
RNN 在现代 NLP 中是一种强大的工具,具有广泛的应用范围;但是,它们并非没有缺点。首先,RNN 速度较慢-无论如何都需要逐个元素地扫描输入序列。它们的计算复杂度与输入序列的长度成正比。其次,由于它们的顺序性质,RNN 难以并行化。想象一个多层 RNN,其中多个 RNN 层堆叠在一起(如图 7.1 所示)。在朴素实现中,每一层都需要等到所有下面的层完成对输入的处理。
图 7.1 多层 RNN
其次,对于某些任务,RNN 结构过于复杂和低效。例如,在第四章中我们讲解了检测符合语法的句子的任务。在最简单的形式下,任务是在一个两个词组成的句子中识别主谓一致性的正确与否。如果句子包含诸如“I am”和“you are”之类的短语,那么它是符合语法的。如果包含“I are”或“you am”,那么就不符合语法。在第四章,我们构建了一个简单的带非线性的 LSTM-RNN 来识别有四个单词词汇量的两个词组成句子的语法正确性。但是,如果你需要对一个词汇量非常大的任意长度的句子进行分类,这个过程就开始变得非常复杂了。你的 LSTM 需要从大量的噪声(与一致性无关的其他单词和短语)中识别出信号(主谓一致性),同时学习使用更新操作来处理输入的每一个元素。
但是如果你仔细考虑,无论句子有多长或者词汇量有多大,你的网络的任务应该还是相当简单——如果句子包含有效的短语(如“I am”和“you are”),那么它符合语法。否则,不符合语法。实际上,这个任务与我们在第一章中看到的“如果-那么”情感分析器非常相似。很明显,LSTM RNN 的结构对于这个任务来说过于复杂,简单的文字和短语模式匹配就足够了。
7.1.2 句子分类的模式匹配
如果你看一下文本分类的一般情况,很多任务可以通过“模式匹配”来有效解决。以垃圾邮件过滤为例:如果你想要检测垃圾邮件,只需要查找像“v1agra”和“商机”这样的词语和短语,甚至不需要读完整封邮件;这些模式出现在什么地方并不重要。如果你想要从电影评论中检测情感,检测到像“amazing”和“awful”这样的积极和消极词语就足够了。换句话说,学习和检测这种本地语言模式,而不考虑它们的位置,对于文本分类任务是一种有效而高效的策略,也可能对其他自然语言处理任务有效。
在第三章,我们学习了 n 元语法的概念,即一个或多个词的连续序列。它们经常被用作自然语言处理中更正式定义的语言单位(如短语和从句)的代理。如果有一种工具能够遍历大量的文本噪声并检测作为信号的 n 元语法,那将非常适合文本分类。
7.1.3 卷积神经网络(CNNs)
卷积神经网络(CNN)正是做到这一点的。CNN 是一种神经网络类型,它涉及一种称为卷积的数学运算,简单来说,它检测有用于当前任务的局部模式。CNN 通常由一个或多个卷积层和一个或多个池化层组成,卷积层进行卷积操作,池化层负责聚合卷积结果。请参见图 7.2。分别在第 7.2 节和第 7.3 节中详细介绍卷积层和池化层。
图 7.2 卷积神经网络
CNN 受到人脑视觉系统的启发,在计算机视觉任务(如图像分类和目标检测)中被广泛使用。近年来,CNN 的使用在自然语言处理领域越来越流行,特别是在文本分类、序列标注和机器翻译等任务中。
7.2 卷积层
在本节中,我们将讨论卷积层,这是 CNN 架构的核心部分。术语卷积听起来可能有点可怕,但本质上它只是一种模式匹配。我们将使用图示和直观的例子来说明它的工作原理。
7.2.1 使用滤波器进行模式匹配
卷积层是 CNN 中最重要的组件。如前所述,卷积层将一种称为卷积的数学运算应用于输入向量,并产生输出。但是什么是卷积?理解卷积的严格定义需要了解线性代数,因此我们将使用类比和具体示例来理解它。想象一下,你手里拿着一个带有复杂图案的矩形玻璃块(就像你在教堂里看到的彩色玻璃),在观察它的同时将其滑动到输入序列上。如果输入模式与玻璃块的模式匹配,更多的光线透过玻璃进去,你会得到更大的输出值。如果输入模式看起来不像玻璃块的模式或者相反,你会得到更小的输出值。换句话说,你正在使用带有彩色玻璃块的道具在输入序列中寻找特定的模式。
这个类比比较模糊,所以让我们回顾一下我们在第四章中使用的语法检测的例子,并看看如何将卷积层应用到这个任务上。回顾一下,我们的神经网络接收一个包含两个词的句子作为输入,并需要区分出语法正确的序列和语法错误的序列。词汇表中只有四个词–“I”,“you”,“am”和“are”,它们由单词嵌入表示。类似地,输入句子只有四种可能性–“I am”,“I are”,“you am”和“you are”。你希望网络对前两种情况产生 1,对其他情况产生 0。请参见图 7.3 进行说明。
图 7.3 识别英文语法正确的句子
现在,让我们将词嵌入表示为模式。我们用黑色圆表示值-1,白色圆表示 1。然后,您可以将每个单词向量表示为两个圆的一对(请参见图 7.3 左侧的表)。同样,您可以将每个两个词的句子表示为两个向量的小“片段”,或者四个圆(请参见图 7.3 右侧的表)。我们的任务开始看起来更像是一个模式识别任务,网络需要学习对应于语法句子的黑白模式。
然后,让我们想象一个相同大小的“滤波器”(两个圆×两个圆),它充当我们之前讨论过的彩色玻璃。该滤波器的每个圆也是黑色或白色,对应值-1 和 1。您将通过这个滤波器查看一个模式,并确定是否这是您要找的模式。您可以通过将滤波器放在模式上并计算两者之间的颜色匹配数量来执行此操作。对于四个位置中的每一个,如果颜色匹配(黑色-黑色或白色-白色),则得分+1,如果不匹配(黑色-白色或白色-黑色),则得分-1。您的最终得分是四个分数的总和,从-4(无匹配)到+4(四次匹配)。请参见图 7.4 中的一些示例。
图 7.4 卷积滤波器示例
您得到的分数取决于模式和滤波器,但如图所示,当滤波器与模式相似时,分数变大,当两者不相似时,分数变小。当两者完全匹配时,您获得最大分数(4),当两者完全相反时,您获得最小分数(-4)。该滤波器充当输入的模式检测器。虽然这是一个非常简化的例子,但基本上显示了卷积层在做什么。在卷积神经网络中,这种滤波器称为核。
在更一般的设置中,您有一个任意长度的输入句子,并且从左到右将一个核滑过句子。请参见图 7.5 以了解此过程的示意图。该核反复应用于连续的两个词,以生成一系列分数。因为我们在这里使用的核覆盖了两个词,所以它被称为具有大小为 2 的核。而且,因为输入嵌入中有两个维度(称为通道),所以核的输入通道数量为 2。
图 7.5 在输入句子上滑动核
注意 嵌入维度被称为通道的原因是因为 CNN 最常应用于计算机视觉任务,其中输入通常是不同通道的 2-D 图像,这些通道对应于不同颜色的强度(如红色、绿色和蓝色)。在计算机视觉中,核是二维的,并在输入的 2-D 图像上移动,这也被称为 2-D 卷积。然而,在自然语言处理中,核通常是一维的(1-D 卷积),并且只有一个尺寸。
7.2.2 整流线性单元(ReLU)
作为下一步,让我们考虑如何使用核来获得期望的输出(图 7.3 中的 Desired 列)。如果我们使用图 7.4 中第二列所示的滤波器会怎样?从现在开始,我们将这个核称为核 1。这个核完全匹配第一个模式并给它一个高分,同时给其他模式给出零或负分数。图 7.6 显示了将核 1 应用于每个模式时的分数(称为分数 1)。
图 7.6 对模式应用核 1
现在让我们忘记分数的大小,专注于它们的符号(正数和负数)。前三个模式的符号在分数 1 和所需之间匹配,但对于最后一个模式则不是。要正确评分,即给出正分数,您需要使用另一个与最后一个模式完全匹配的滤波器。我们称这个核为核 2。图 7.7 显示了应用核 2 到每个模式时的分数(称为分数 2)。
核 2 可以为最后三个模式给出与所需符号匹配的正确分数,但不能为第一个模式。但是,如果仔细观察图 7.6 和 7.7,看起来如果有一种方法可以在核给出负分数时忽略输出,然后组合来自多个核的分数,那么就可以更接近所需的分数。
图 7.7 对模式应用核 2
让我们考虑一个函数,它将任何负输入夹紧为零,同时保持任何正值不变。在 Python 中,这个函数可以写成如下:
def f(x):
if x >= 0:
return x
else:
return 0
或者更简单
def f(x):
return max(0, x)
您可以通过将此函数应用于分数 1 和分数 2 来忽略负值,如图 7.8 和 7.9 所示。
图 7.8 对分数 1 应用 ReLU
图 7.9 对分数 2 应用 ReLU
这个函数,被称为修正线性单元,或称为 ReLU(发音为“rel-you”),是深度学习中最简单但最常用的激活函数之一。 它通常与卷积层一起使用,虽然它非常简单(它只是将负值夹紧为零),但它仍然是一个激活函数,它使神经网络能够学习复杂的非线性函数(参见第四章,了解为什么非线性激活函数很重要)。 它还具有有利的数学属性,使得优化网络变得更容易,尽管理论细节超出了本书的范围。
7.2.3 合并分数
如果您查看图 7.8 和图 7.9,所谓的“固定”分数—显示在 f(Score 1) 和 f(Score 2) 列中—至少部分地捕捉到了期望的分数。 您所需做的就是将它们结合在一起(通过求和)并调整范围(通过除以 4)。 图 7.10 展示了这个结果。
图 7.10 结合两个内核的结果
在合并之后,分数与期望的结果完全匹配。 到目前为止,我们所做的一切都是设计与我们想要检测的模式相匹配的内核,然后简单地组合分数。 比较一下我们在第 4.1.3 节中处理的 RNN 示例,那里我们需要使用一些复杂的数值计算来推导参数。 希望这个例子足以向您展示 CNN 对于文本分类可以有多简单而强大!
我们在本节中处理的示例仅用于介绍 CNN 的基本概念,因此我们偷了很多懒。 首先,在实践中,模式和内核不仅仅是黑白的,而是包含实值数字。 应用内核到模式后的分数不是通过计算颜色匹配次数得到的,而是通过一种称为内积的数学运算得到的,它捕捉了两者之间的相似性。 第二,内核产生的分数不是通过某种任意的操作(就像我们在本节中所做的那样)组合在一起的,而通常是通过线性层(见 3.4.3 节)组合在一起的,该线性层可以学习针对输入的线性变换以产生输出。 最后,内核和最终线性层中的权重(魔法常数 w 和 b)都是 CNN 的可训练参数,这意味着它们的值会被调整,以使 CNN 能够产生期望的分数。
7.3 池化层
在前一节中,我们假设输入只是两个词——主语和动词的组合,尽管在实践中,CNN 的输入可以是任意长度的。您的 CNN 不仅需要检测模式,还需要在输入中可能存在的大量噪声中找到它们。正如我们在第 7.2 节中看到的,您将一个核从左到右滑过句子,并且核会重复应用于两个连续的单词以产生一系列分数。剩下的问题是如何处理这些产生的分数。具体来说,我们应该在图 7.11 中的“?”位置使用什么操作来获得所需的分数?这个操作需要具有一些属性——它必须是可以应用于任意数量的分数的东西,因为句子可能非常长。它还需要以一种对输入句子中目标模式(“我是”的单词嵌入)的位置不可知的方式聚合分数。您能想出答案吗?
图 7.11 聚合分数以获得所需分数
汇总分数的最简单方法是取它们的最大值。因为图 7.11 中的最大分数为 4,它将成为该层的输出。这种汇总操作称为池化,而执行汇总的神经网络子结构称为池化层。您还可以执行其他类型的数学运算来进行聚合,例如取平均值,尽管最常用的是取最大值(称为最大池化)。
汇总分数将被馈送到一个线性层,可选地与其他核的分数结合,并用作预测分数。整个过程如图 7.12 所示。现在我们有一个完全功能的 CNN!
与我们迄今看到的其他神经网络一样,线性层的输出被馈送到 softmax 以产生标签上的概率分布。然后将这些预测值与真实标签进行比较,以产生损失并用于优化网络。
在我们结束之前,对 CNN 还有几句话:请注意,图 7.12 中的 CNN 无论搜索模式(“我是”)在输入句子中的位置如何,都会产生相同的预测值。这是由于卷积核的局部性以及我们刚刚添加的最大池化层的属性。通常情况下,即使输入句子通过移动几个单词而被修改,CNN 也会产生相同的预测。从技术上讲,CNN 被称为变换不变,这是 CNN 的一个重要属性。如果您使用图像识别示例,则该属性可能更直观。猫的图像仍然是猫的图像,无论猫在图像中的位置如何。同样,一个语法正确的英文句子(例如,“我是学生”)仍然是语法正确的,即使句子通过在开头添加几个单词(例如,“那是对的”)而被转换为“那是对的,我是学生”。
图 7.12 带有多个卷积核的完整 CNN
因为 CNN 中的卷积核不相互依赖(与 RNN 不同,后续单元需要等待所有前面的单元完成输入处理),所以 CNN 计算效率高。GPU 可以并行处理这些卷积核,不用等待其他卷积核的输出。由于这个特性,CNN 通常比大小相似的 RNN 更快。
7.4 案例研究:文本分类
现在,我们已经了解了 CNN 的基础知识,在本节中,我们将使用 CNN 构建一个 NLP 应用并看看它在实践中的工作原理。正如之前提到的,CNN 在 NLP 中最受欢迎和直接的应用之一就是文本分类。CNN 擅长检测文本中的模式(如突出的单词和短语),这也是准确文本分类的关键。
7.4.1 复习:文本分类
我们已经在第二章和第四章中介绍了文本分类,但是为了回顾一下,文本分类是指一个 NLP 系统给定一段文本分配一个标签的任务。如果文本是一个电子邮件,标签是邮件是否为垃圾邮件,那就是垃圾邮件过滤。如果文本是一个文档(比如新闻文章),标签是它的主题(如政治、商业、技术或体育),那就叫做文档分类。根据输入和输出的不同,还存在许多其他变种的文本分类。但是在本节中,我们将再次处理情感分析,它的输入是一些表达作者主观意见的文本(如电影和产品评论),输出是意见的标签(如正面或负面,甚至星级评价),也被称为极性。
在第二章和第四章中,我们构建了一个 NLP 系统,使用 Stanford Sentiment Treebank 检测给定电影评论的情感极性,这是一个包含电影评论及其极性(非常正面,正面,中立,负面,非常负面)的数据集。在本节中,我们将构建同样的文本分类器,但是使用 CNN 而不是 RNN。好消息是,我们可以重用第二章编写的大部分代码,在这一部分只需要修改几行代码将 RNN 替换为 CNN。这在很大程度上归功于 AllenNLP 强大而设计良好的抽象,它可以让您通过公共接口与许多具有不同架构的模块一起工作。让我们下面看看它的运行。
7.4.2 使用 CnnEncoder
记住,在第 4.4 节中,我们定义了文本分类的 LstmClassifier 如下:
class LstmClassifier(Model):
def __init__(self,
embedder: TextFieldEmbedder,
encoder: Seq2VecEncoder,
vocab: Vocabulary,
positive_label: str = '4') -> None:
...
我们没有对这个定义进行深入的思考,但是从这个构造函数中,我们可以看到这个模型是建立在两个子组件之上的:一个名为embedder
的TextFieldEmbedder
和一个名为encoder
的Seq2VecEncoder
,除此之外还有词汇表和正标签的字符串,这些对我们的讨论不相关。我们在第三章详细讨论了词嵌入,尽管我们只是简要涉及了编码器。这个Seq2VecEncoder
到底是什么意思呢?
在 AllenNLP 中,Seq2VecEncoder
是一类神经网络架构,它接受一系列向量(或一般张量)并返回一个单个向量。RNN 是其中的一个例子,它接受由多个向量组成的可变长度输入,并在最后一个单元格中将其转换为单个向量。我们使用以下代码基于 LSTM-RNN 创建了一个Seq2VecEncoder
的实例:
encoder = PytorchSeq2VecWrapper(
torch.nn.LSTM(EMBEDDING_DIM, HIDDEN_DIM, batch_first=True))
但只要你的组件具有相同的输入和输出规范,你就可以使用任何神经网络架构作为Seq2VecEncoder
。在编程语言中,Seq2VecEncoder
类似于 Java(以及许多其他语言中的)中的接口——接口定义了你的类是什么样子的,它做什么,但它们不关心你的类是如何做到的。实际上,你的模型可以简单地对所有输入向量求和以产生输出,而不需要任何复杂的变换,比如非线性变换。事实上,这就是BagOfEmbeddingsEncoder
—AllenNLP 中实现的Seq2VecEncoder
之一的做法。
接下来,我们使用 CNN 将一系列向量“压缩”为单个向量。在 AllenNLP 中,基于 CNN 的Seq2VecEncoder
实现为CnnEncoder
,可以如下实例化:
encoder = CnnEncoder(
embedding_dim=EMBEDDING_DIM,
num_filters=8,
ngram_filter_sizes=(2, 3, 4, 5))
在这个例子中,embedding_dim
指定了输入嵌入的维度。第二个参数num_filters
告诉我们将使用多少个过滤器(或内核,如第 7.2.1 节所解释的)。最后一个参数ngram_filter_sizes
指定了 n-gram 大小的列表,即这些内核的大小。在这里,我们使用 2、3、4 和 5 的 n-gram 大小,这意味着有 8 个用于 bigram 的内核,8 个用于 trigram,以此类推,直到 5-gram。总而言之,这个 CNN 可以学习 32 个不同的内核来检测模式。CnnEncoder
通过一个最大池化层运行这些内核的结果,并得出一个总结输入的单个向量。
训练流水线的其余部分几乎与我们在第二章中看到的 LSTM 版本相同。整个代码都可以在 Google Colab 上找到。www.realworldnlpbook.com/ch7.html#cnn-nb
。但有一个注意事项:由于一些 n-gram 过滤器具有宽形状(例如,4-gram 和 5-gram),您需要确保每个文本字段至少具有该长度,即使原始文本很短(例如,只有一个或两个单词)。您需要了解 AllenNLP 中的批处理和填充工作原理(我们将在第十章中介绍)才能充分理解如何处理这一问题,但简而言之,您需要在初始化标记索引器时指定 token_min_padding_length 参数,如下所示:
token_indexer = SingleIdTokenIndexer(token_min_padding_length=5)
reader = StanfordSentimentTreeBankDatasetReader(
token_indexers={'tokens': token_indexer})
7.4.3 训练和运行分类器
当您运行脚本时,您会在训练结束时看到类似以下日志输出:
{'best_epoch': 1,
'best_validation_accuracy': 0.40236148955495005,
'best_validation_f1_measure': 0.37362638115882874,
'best_validation_loss': 1.346440097263881,
'best_validation_precision': 0.4722222089767456,
'best_validation_recall': 0.30909091234207153,
'epoch': 10,
'peak_cpu_memory_MB': 601.656,
'training_accuracy': 0.993562734082397,
'training_cpu_memory_MB': 601.656,
'training_duration': '0:01:10.138277',
'training_epochs': 10,
'training_f1_measure': 0.994552493095398,
'training_loss': 0.03471498479299275,
'training_precision': 0.9968798756599426,
'training_recall': 0.9922360181808472,
'training_start_epoch': 0,
'validation_accuracy': 0.35149863760217986,
'validation_f1_measure': 0.376996785402298,
'validation_loss': 3.045241366113935,
'validation_precision': 0.3986486494541168,
'validation_recall': 0.35757574439048767}
这意味着训练精度达到了约 99%,而验证精度则达到了约 40%。同样,这是过拟合的典型症状,即您的模型非常强大,可以很好地拟合训练数据,但不能很好地泛化到验证和测试数据集。我们的 CNN 具有许多能够记住训练数据中显著模式的过滤器,但这些模式未必有助于预测验证实例的标签。在本章中,我们不太担心过拟合。有关避免过拟合的常见技术,请参见第十章。
如果您想对新实例进行预测,可以使用与第二章相同的预测器。AllenNLP 中的预测器是您训练好的模型的一个轻量级包装器,负责将输入和输出格式化为 JSON 格式并将实例提供给模型。您可以使用以下代码段使用您训练好的 CNN 模型进行预测:
predictor = SentenceClassifierPredictor(model, dataset_reader=reader)
logits = predictor.predict('This is the best movie ever!')['logits']
label_id = np.argmax(logits)
print(model.vocab.get_token_from_index(label_id, 'labels'))
摘要
-
CNN 使用称为内核的过滤器和称为卷积的操作来检测输入中的局部语言模式。
-
卷积层使用的激活函数称为 ReLU,它将负值截断为零。
-
CNN 然后使用池化层来聚合卷积层的结果。
-
CNN 预测是转换不变的,意味着即使对输入进行线性修改后也保持不变。
-
您可以通过修改文本分类器的几行代码将基于 CNN 的编码器用作 AllenNLP 中的 Seq2VecEncoder。
第八章:注意力与 Transformer
本章内容包括
-
使用注意力机制生成输入摘要,提高 Seq2Seq 模型的质量
-
用自注意力替换 RNN 风格的循环,一种使输入摘要自身的机制
-
用 Transformer 模型改进机器翻译系统
-
使用 Transformer 模型和公开可用数据集构建高质量的拼写检查器
到目前为止,本书的重点一直是循环神经网络(RNNs),它是一种强大的模型,可应用于各种 NLP 任务,如情感分析、命名实体识别和机器翻译。在本章中,我们将介绍一个更加强大的模型——Transformer¹——一种基于自注意力概念的全新编码器-解码器神经网络架构。自 2017 年问世以来,它毫无疑问是最重要的 NLP 模型。它不仅是一个强大的模型(例如,用于机器翻译和各种 Seq2Seq 任务),而且还被用作许多现代 NLP 预训练模型的底层架构,包括 GPT-2(第 8.4.3 节)和 BERT(第 9.2 节)。自 2017 年以来的现代 NLP 发展最好的总结可以概括为“Transformer 时代”。
在本章中,我们首先介绍了注意力机制,这是机器翻译中取得突破的一种机制,然后介绍了自注意力,这是 Transformer 模型的基础概念。我们将构建两个 NLP 应用程序——西班牙语到英语的机器翻译器和一个高质量的拼写检查器,并学习如何将 Transformer 模型应用于您的日常应用程序。正如我们将在后面看到的那样,Transformer 模型可以在某些任务(如翻译和生成)中以接近人类水平的性能显著提高 NLP 系统的质量,而且几乎可以超越 RNNs。
8.1 什么是注意力?
在第六章中,我们介绍了 Seq2Seq 模型——一种使用编码器和解码器将一个序列转换为另一个的 NLP 模型。Seq2Seq 是一种多功能且强大的范式,尽管“简单” Seq2Seq 模型也不是没有局限性。在本节中,我们讨论了 Seq2Seq 模型的瓶颈,并激发了使用注意力机制的动机。
8.1.1 简单 Seq2Seq 模型的局限性
让我们先回顾一下 Seq2Seq 模型的工作原理。Seq2Seq 模型由编码器和解码器组成。解码器接收源语言中的一系列标记,并将其通过 RNN 运行,最终产生一个固定长度的向量。这个固定长度的向量是输入句子的表示。解码器,另一个 RNN,接收这个向量,并逐标记地产生目标语言中的一个序列。图 8.1 说明了如何使用简单的 Seq2Seq 模型将西班牙语句子翻译成英语。
图 8.1 简单 Seq2Seq 模型中的瓶颈
这个 Seq2Seq 架构非常简单而强大,但众所周知,它的基本版本(如图 8.1 所示)在翻译句子方面不如其他传统的机器翻译算法(如基于短语的统计机器翻译模型)。如果你仔细观察它的结构,可能就能猜出其中的原因——它的编码器试图将源句子中的所有信息“压缩”到句子表示中,这是一个固定长度的向量(例如,256 个浮点数),而解码器则试图仅从该向量中恢复整个目标句子。无论源句子有多长(或多短),向量的大小都是固定的。中间向量是一个巨大的瓶颈。如果你考虑一下人类实际上如何在语言之间进行翻译,这听起来相当困难且有些不寻常。专业的翻译人员不会一口气读完源句子然后把它的翻译写下来。他们会根据需要多次参考源句子,以翻译目标句子中的相关部分。
将所有信息压缩成一个向量可能(并且确实)对短句子有效,正如我们稍后在 8.2.2 节中将看到的那样,但随着句子变得越来越长,这种方法变得越来越困难。研究表明,基本 Seq2Seq 模型的翻译质量随着句子变得越来越长而变差。²
8.1.2 注意力机制
解码器如果能够在生成目标标记时参考编码器的某个特定部分,将会容易得多。这类似于人类翻译员(解码器)根据需要参考源句子(编码器)。
这可以通过使用 注意力 来实现,注意力是神经网络中的一种机制,它专注于输入的特定部分并计算其上下文相关的摘要。这就像拥有某种包含输入所有信息的键值存储,然后用查询(当前上下文)查找它一样。存储的值不仅仅是单个向量,而通常是一个向量列表,每个标记关联一个相应的键。这有效地增加了解码器在进行预测时可以参考的“内存”大小。
在讨论注意力机制如何在 Seq2Seq 模型中工作之前,让我们先看一下它以一般形式的运行情况。图 8.2 描绘了一个具有以下特征的通用注意力机制:
-
注意力机制的输入是值及其相关的键。输入值可以采用许多不同的形式,但在自然语言处理中,它们几乎总是向量列表。对于 Seq2Seq 模型,这里的键和值是编码器的隐藏状态,它们代表了输入句子的标记编码。
-
每个与值关联的键都使用注意力函数 f 与查询进行比较。通过将 f 应用于查询和每个键之一,您会得到一组分数,每个键值对一个,然后将其归一化以获得一组注意力权重。特定的函数 f 取决于体系结构(稍后会详细介绍)。对于 Seq2Seq 模型,这会给出一个输入令牌的分布。输入令牌越相关,其权重越大。
-
输入值由第 2 步中获得的相应权重加权,并相加以计算最终摘要向量。对于 Seq2Seq 模型,此摘要向量附加到解码器隐藏状态以辅助翻译过程。
图 8.2 使用注意力机制对输入进行总结
由于第 3 步,注意力机制的输出始终是输入向量的加权和,但它们如何加权是由注意力权重确定的,而注意力权重又是从键和查询计算得出的。换句话说,注意力机制计算的是 上下文(查询)相关的输入摘要。神经网络的下游组件(例如基于 RNN 的 Seq2Seq 模型的解码器,或者 Transformer 模型的上层)使用此摘要进一步处理输入。
在接下来的几节中,我们将学习 NLP 中两种最常用的注意力机制类型 —— 编码器-解码器注意力(也称为 交叉注意力;在基于 RNN 的 Seq2Seq 模型和 Transformer 中都使用)和自注意力(在 Transformer 中使用)。
8.2 带有注意力的序列到序列
在本节中,我们将学习如何将注意力机制应用于 RNN 基础的 Seq2Seq 模型,注意力机制是首次发明的。我们将研究它如何与具体示例一起工作,然后我们将使用 fairseq 实验带有和不带有注意力机制的 Seq2Seq 模型,以观察它对翻译质量的影响。
8.2.1 编码器-解码器注意力
正如我们之前所看到的,注意力是在特定上下文下创建输入摘要的机制。我们使用了一个键值存储和一个查询作为它如何工作的类比。让我们看看注意力机制如何在基于 RNN 的 Seq2Seq 模型中使用,使用随后的具体示例。
图 8.3 在基于 RNN 的 Seq2Seq 模型中添加注意力机制(浅色阴影框)
图 8.3 展示了一个带有注意力机制的 Seq2Seq 模型。一开始看起来很复杂,但实际上它只是一个基于 RNN 的 Seq2Seq 模型,在编码器顶部左上角的浅色阴影框中添加了一些额外的“东西”。如果你忽略里面的内容,将其视为黑匣子,它所做的就是简单地接受一个查询并从输入中返回某种摘要。它计算这个摘要的方式只是我们在 8.1.2 节中介绍的通用注意力形式的一个变体。它的执行步骤如下:
-
注意力机制的输入是编码器计算的隐藏状态列表。这些隐藏状态既用作键也用作值(即,键和值是相同的)。某个令牌(例如,“no”令牌)的编码器隐藏状态反映了关于该令牌及其之前所有令牌的信息(如果 RNN 是单向的),或整个句子(如果 RNN 是双向的)。
-
假设你已经解码到“Mary did.”。此时解码器的隐藏状态被用作查询,与每个键使用函数 f 进行比较。这会产生一个注意力分数列表,每个键值对应一个分数。这些分数确定了解码器在尝试生成跟在“Mary did.”后面的单词时应该关注输入的哪个部分。
-
这些分数被转换为概率分布(一组正值,总和为 1),用于确定哪些向量应该得到最多的关注。这个注意力机制的返回值是所有值的加权和,加权值为注意力分数经过 softmax 归一化后的值。
你可能想知道注意力函数 f 是什么样的。f 的几个变体是可能的,这取决于它如何计算键和查询之间的注意力分数,但这些细节在这里并不重要。值得注意的一点是,在提出注意力机制的原始论文中,作者使用了一个“迷你”神经网络来计算键和查询之间的注意力分数。
这个基于“迷你”网络的注意力函数不是你只需事后将其插入到 RNN 模型中就能让其正常工作的东西。它是作为整个网络的一部分进行优化的—也就是说,当整个网络通过最小化损失函数进行优化时,注意力机制也会变得更好,因为这样做也有助于解码器生成更好的翻译并降低损失函数。换句话说,整个网络,包括注意力机制,都是端到端训练的。这通常意味着,随着网络的优化,注意力机制开始学习只关注输入的相关部分,这通常是目标标记与源标记对齐的地方。换句话说,注意力计算了源标记和目标标记之间某种“软”单词对齐。
8.2.2 使用注意力构建 Seq2Seq 机器翻译
在第 6.3 节中,我们使用由 Facebook 开发的 NMT 工具包 fairseq 构建了我们的第一个机器翻译(MT)系统。 使用来自 Tatoeba 的平行数据集,我们构建了一个基于 LSTM 的 Seq2Seq 模型,将西班牙语句子翻译成英语。
在本节中,我们将尝试使用 Seq2Seq 机器翻译系统,并看看注意力如何影响翻译质量。 我们假设您已经按照我们构建 MT 系统的步骤操作,通过下载数据集并运行 fairseq-preprocess 和 fairseq-train 命令(第 6.3 节)。 之后,您运行了 fairseq-interactive 命令以将西班牙语句子交互式地翻译成英语。 您可能已经注意到,从这个仅花了您 30 分钟构建的 MT 系统得到的翻译实际上相当不错。 实际上,我们使用的模型架构(—arch lstm)默认内置了注意力机制。 请注意,当您运行以下 fairseq-train 命令时
fairseq-train \
data/mt-bin \
--arch lstm \
--share-decoder-input-output-embed \
--optimizer adam \
--lr 1.0e-3 \
--max-tokens 4096 \
--save-dir data/mt-ckpt
您应该已经在终端中看到了您的模型的输出,如下所示:
...
LSTMModel(
(encoder): LSTMEncoder(
(embed_tokens): Embedding(16832, 512, padding_idx=1)
(lstm): LSTM(512, 512)
)
(decoder): LSTMDecoder(
(embed_tokens): Embedding(11416, 512, padding_idx=1)
(layers): ModuleList(
(0): LSTMCell(1024, 512)
)
(attention): AttentionLayer(
(input_proj): Linear(in_features=512, out_features=512, bias=False)
(output_proj): Linear(in_features=1024, out_features=512, bias=False)
)
)
)
...
这告诉您,您的模型有一个编码器和一个解码器,但解码器还有一个称为注意力的组件(类型为 AttentionLayer),如代码片段中的粗体所示。 这正是我们在第 8.2.1 节中讨论过的“小型网络”。
现在让我们训练相同的模型,但是不使用注意力机制。您可以在 fairseq-train 中添加—decoder-attention 0 来禁用注意力机制,同时保持其他所有内容不变,如下所示:
$ fairseq-train \
data/mt-bin \
--arch lstm \
--decoder-attention 0 \
--share-decoder-input-output-embed \
--optimizer adam \
--lr 1.0e-3 \
--max-tokens 4096 \
--save-dir data/mt-ckpt-no-attn
当您运行此命令时,您将看到类似的输出,接下来显示了模型的架构,但没有注意力机制:
LSTMModel(
(encoder): LSTMEncoder(
(embed_tokens): Embedding(16832, 512, padding_idx=1)
(lstm): LSTM(512, 512)
)
(decoder): LSTMDecoder(
(embed_tokens): Embedding(11416, 512, padding_idx=1)
(layers): ModuleList(
(0): LSTMCell(1024, 512)
)
)
)
正如我们在第 6.3.2 节中看到的,训练过程在训练和验证之间交替进行。 在训练阶段,神经网络的参数通过优化器进行优化。 在验证阶段,这些参数被固定,并且模型在称为验证集的数据集的一个保留部分上运行。 除了确保训练损失下降外,您还应该在训练过程中查看验证损失,因为它更好地表示了模型在训练数据之外的泛化能力。
在这个实验中,您应该观察到由注意力模型实现的最低验证损失约为 1.727,而无注意力模型的最低验证损失约为 2.243。 较低的损失值意味着模型更好地适应了数据集,因此这表明注意力有助于改善翻译。 让我们看看这是否属实。 正如我们在第 6.3.2 节中所做的,您可以通过运行以下 fairseq-interactive 命令来交互地生成翻译:
$ fairseq-interactive \
data/mt-bin \
--path data/mt-ckpt/checkpoint_best.pt \
--beam 5 \
--source-lang es \
--target-lang en
在表 8.1 中,我们比较了带有和不带有注意力的模型生成的翻译。基于注意力的模型得到的翻译与我们在第 6.3.3 节中看到的一样。请注意,基于没有注意力的模型得到的翻译比具有注意力的模型要糟糕得多。如果您看一下“¿Hay habitaciones libres?”和“Maria no daba una bofetada a la bruja verde”的翻译,您会看到其中的陌生令牌“”(表示“未知”)。这里发生了什么?
表 8.1 模型带有和不带有注意力的翻译比较
西班牙语(输入) | 带有注意力 | 没有注意力 |
---|---|---|
¡Buenos días! | 早上好! | Good morning! |
¡Hola! | 你好! | Hi! |
¿Dónde está el baño? | 厕所在哪里? | Where’s the toilet? |
¿Hay habitaciones libres? | 有空房间吗? | Are there rooms? |
¿Acepta tarjeta de crédito? | 你们接受信用卡吗? | Do you accept credit card? |
La cuenta, por favor. | 请结账。 | Check, please. |
Maria no daba una bofetada a la bruja verde. | Maria 没有打绿色女巫。 | Mary wasn’t a of the pants. |
这些是分配给词汇表之外(OOV)词汇的特殊令牌。我们在第 3.6.1 节中提及了 OOV 词汇(当我们介绍用于 FastText 的子词概念时)。大多数自然语言处理应用都在一个固定的词汇表中操作,每当它们遇到或尝试生成超出预定义集合的词汇时,这些词汇都会被替换为一个特殊的令牌 <unk>
。这类似于当方法不知道如何处理输入时返回的特殊值(例如 Python 中的 None)。因为这些句子包含某些词汇(我怀疑它们是“libres”和“bofetada”),没有注意力的 Seq2Seq 模型,其内存是有限的,不知道该如何处理它们,简单地回归到最安全的操作,即生成一个通用的、捕获所有的符号 <unk>
。另一方面,您可以看到注意力防止系统生成这些符号,并有助于提高生成的翻译的整体质量。
8.3 Transformer 和自注意力
在这一节中,我们将学习 Transformer 模型的工作原理,具体来说,是它如何利用一种称为自注意力的新机制生成高质量的翻译。自注意力机制使用每个令牌作为上下文,为每个令牌创建了整个输入的摘要。
8.3.1 自注意力
正如我们之前看到的,注意力是一种创建输入的上下文相关摘要的机制。对于基于 RNN 的 Seq2Seq 模型,输入是编码器隐藏状态,而上下文是解码器隐藏状态。Transformer 的核心思想,自注意力,也创建了输入的摘要,除了一个关键的区别——创建摘要的上下文也是输入本身。请参见图 8.4,了解自注意力机制的简化示例。
图 8.4 自注意力将输入转化为摘要。
为什么这很好?为什么它有效?正如我们在第四章中讨论的那样,RNN 也可以通过循环遍历输入标记并更新内部变量(隐藏状态)来创建输入的摘要。这是有效的-我们之前看到当 RNN 与注意力结合时可以生成良好的翻译,但是它们有一个关键问题:因为 RNN 按顺序处理输入,随着句子变得越来越长,处理标记之间的远程依赖关系变得越来越困难。
让我们看一个具体的例子。如果输入句子是“The Law will never be perfect, but its application should be just”,了解代词“its”指的是什么(“The Law”)对于理解句子的含义以及任何后续任务(如准确翻译句子)都很重要。然而,如果您使用 RNN 来编码这个句子,要学习这个代词的共指关系,RNN 需要先学习在隐藏状态中记住名词“The Law”,然后等待循环遇到目标代词(“its”),同时学会忽略之间的所有无关内容。对于神经网络来说,这听起来像是一种复杂的技巧。
但事情不应该那么复杂。像“its”这样的单数所有格代词通常指的是它们前面最近的单数名词,而与它们之间的词无关,因此简单的规则“用最近出现的名词替换它”就足够了。换句话说,在这种情况下,“随机访问”比“顺序访问”更适合。自注意力更擅长学习这种远程依赖关系,稍后我们将会看到。
让我们通过一个例子来了解自注意力是如何工作的。假设我们要将西班牙语翻译成英语,并且想要编码输入句子中的前几个单词“Maria no daba”。我们还将关注一个特定的标记“no”,以及如何从整个输入计算其嵌入。第一步是将目标标记与输入中的所有标记进行比较。自注意力通过使用投影 W[Q]将目标转换为查询,使用投影 W[K]将所有标记转换为键,并使用函数 f 计算注意力权重来完成这一步骤。由 f 计算得到的注意力权重通过 softmax 函数进行归一化和转换为概率分布。图 8.5 说明了这些步骤,注意力权重如何计算。与我们在 8.2.1 节中涵盖的编码器-解码器注意力机制一样,注意力权重决定了我们从输入标记中获得的值如何“混合”。对于像“its”这样的词,我们希望相关词的权重会更高,比如之前的例子中的“Law”。
图 8.5 从键和查询计算注意力权重
在下一步中,将每个输入令牌对应的向量通过投影 W[V] 转换为值向量。每个投影值都由相应的注意权重加权,并加总以生成摘要向量。请参见图 8.6 进行说明。
图 8.6 计算所有值的加权和
如果这是“常规”的编码器-解码器注意机制,那就是这样了。在解码期间,每个令牌只需要一个摘要向量。然而,编码器-解码器注意力和自注意力之间的一个关键区别是后者会针对输入中的每个令牌重复此过程。如图 8.7 所示,这会为输入产生一组新的嵌入,每个令牌一个。
图 8.7 为整个输入序列生成摘要(细节被省略)
自注意力产生的每个摘要都考虑了输入序列中的所有令牌,但权重不同。因此,对于诸如“its”之类的词,它可以融入一些来自相关词语的信息,例如“法律”,无论这两个词有多远。使用类比,自注意力通过对输入进行随机访问产生摘要。这与 RNN 形成对比,后者只允许对输入进行顺序访问,并且这也是 Transformer 之所以是编码和解码自然语言文本的强大模型之一的关键原因之一。
我们需要解释自注意力的最后一个细节才能完全理解它。现在,前面介绍的自注意机制只能使用输入序列的一个方面来生成摘要。例如,如果您希望自注意力学习每个代词指代哪个单词,它可以做到这一点——但您也可能希望根据其他一些语言学方面“混合”其他单词的信息。例如,您可能希望参考代词修改的其他单词(在这种情况下是“应用”)。解决方案是为每个令牌计算多组密钥、值和查询,并计算多组注意权重以“混合”关注不同输入方面的值。最终的嵌入是以这种方式生成的摘要的组合。这种机制被称为多头自注意力(图 8.8)。
图 8.8 多头自注意力生成具有多个密钥、值和查询的摘要。
如果你想要完全理解 Transformer 层的工作原理,你需要学习一些额外的细节,但本节已经涵盖了最重要的概念。如果你对更多细节感兴趣,请查看*《图解 Transformer》(jalammar.github.io/illustrated-transformer/
),这是一个写得很好的指南,用易于理解的插图解释了 Transformer 模型。此外,如果你有兴趣用 Python 从零开始实现 Transformer 模型,请查看《注释版 Transformer》*(nlp.seas.harvard.edu/2018/04/03/attention.html
)。
8.3.2 Transformer
Transformer 模型不仅仅使用单步自注意力来编码或解码自然语言文本。它重复应用自注意力到输入中,逐渐转换它们。与多层 RNN 一样,Transformer 还将一系列转换操作分组到一个层中,并重复应用它。图 8.9 显示了 Transformer 编码器的一个层。
每个层内都有很多操作,我们的目标不是解释每一个细节——你只需要理解多头自注意力是其核心,后跟通过前馈神经网络的转换(图 8.9 中的“FF”)。引入了残差连接和归一化层,以使模型更容易训练,尽管这些操作的细节超出了本书的范围。Transformer 模型反复应用这个层,将输入从文字的形式(原始词嵌入)转换为更抽象的东西(句子的“含义”)。在原始的 Transformer 论文中,Vaswani 等人用了六层进行机器翻译,尽管如今更大的模型通常使用 10-20 层。
图 8.9 一个具有自注意力和前馈层的 Transformer 编码器层
到这一步,你可能已经注意到自注意力操作完全独立于位置。换句话说,即使我们颠倒“Maria”和“daba”之间的单词顺序,自注意力的嵌入结果也完全相同,因为该操作只关注单词本身和来自其他单词的聚合嵌入,而不考虑它们的位置。这显然非常限制——自然语言句子的意义很大程度上取决于单词的顺序。那么,Transformer 如何编码单词顺序呢?
Transformer 模型通过生成一些人工嵌入来解决这个问题,这些嵌入在位置之间不同,并在将它们馈送到层之前添加到词嵌入中。这些嵌入被称为位置编码,如图 8.10 所示,可以由某些数学函数(如正弦曲线)生成,或者在训练过程中根据位置学习。这样,Transformer 可以区分第一个位置的“Maria”和第三个位置的“Maria”,因为它们具有不同的位置编码。
图 8.10 将位置编码添加到输入中以表示词序
图 8.11 显示了 Transformer 解码器。虽然很多事情正在进行,但一定要注意两个重要的事情。首先,你会注意到一个额外的机制,称为交叉注意力,插入在自注意力和前馈网络之间。这个交叉注意力机制类似于我们在第 8.2 节介绍的编码器-解码器注意力机制。它的工作方式与自注意力完全相同,唯一的区别是注意力的值来自编码器,而不是解码器,总结了从编码器提取的信息。
图 8.11 Transformer 解码器层,具有自注意力和交叉注意力
最后,Transformer 模型以与我们之前在第 6.4 节学习的基于 RNN 的 Seq2Seq 模型完全相同的方式生成目标句子。解码器由特殊标记初始化,并生成可能的下一个标记的概率分布。从这里,你可以选择具有最大概率的标记(贪婪解码,如第 6.4.3 节所示),或者在寻找最大化总分数的路径时保留一些具有最高概率的标记(波束搜索,如第 6.4.4 节所示)。事实上,如果你把 Transformer 解码器看作一个黑匣子,它生成目标序列的方式与 RNN 完全相同,你可以使用相同的一组解码算法。换句话说,第 6.4 节介绍的解码算法是一种通用的算法,不受底层解码器架构的影响。
8.3.3 实验
现在我们知道了 Transformer 模型的工作原理,让我们用它构建一个机器翻译系统。好消息是,序列到序列工具包 Fairseq 已经支持基于 Transformer 的模型(以及其他强大的模型),可以在训练模型时通过--arch transformer
选项指定。假设你已经预处理了我们用于构建西班牙语到英语机器翻译的数据集,你只需要调整给予fairseq-train
的参数,如下所示:
fairseq-train \
data/mt-bin \
--arch transformer \
--share-decoder-input-output-embed \
--optimizer adam --adam-betas '(0.9, 0.98)' --clip-norm 0.0 \
--lr 5e-4 --lr-scheduler inverse_sqrt --warmup-updates 4000 \
--dropout 0.3 --weight-decay 0.0 \
--criterion label_smoothed_cross_entropy --label-smoothing 0.1 \
--max-tokens 4096 \
--save-dir data/mt-ckpt-transformer
注意,这甚至可能在你的笔记本电脑上都无法运行。你真的需要 GPU 来训练 Transformer 模型。还要注意,即使有 GPU,训练也可能需要几个小时。更多关于使用 GPU 的信息请参见第 11.5 节。
这里出现了一些神秘的参数,但您不需要担心。当您运行此命令时,您可以看到模型结构。整个模型转储相当长,因此我们在清单 8.1 中省略了一些中间层。仔细观察,您会发现层次结构与我们之前显示的图形相对应。
清单 8.1 Fairseq 的 Transformer 模型转储
TransformerModel(
(encoder): TransformerEncoder(
(embed_tokens): Embedding(16832, 512, padding_idx=1)
(embed_positions): SinusoidalPositionalEmbedding()
(layers): ModuleList(
(0): TransformerEncoderLayer(
(self_attn): MultiheadAttention( ❶
(out_proj): Linear(in_features=512, out_features=512, bias=True)
)
(self_attn_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_
affine=True)
(fc1): Linear(in_features=512, out_features=2048, bias=True) ❷
(fc2): Linear(in_features=2048, out_features=512, bias=True)
(final_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
)
...
(5): TransformerEncoderLayer(
(self_attn): MultiheadAttention(
(out_proj): Linear(in_features=512, out_features=512, bias=True)
)
(self_attn_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
(fc1): Linear(in_features=512, out_features=2048, bias=True)
(fc2): Linear(in_features=2048, out_features=512, bias=True)
(final_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
)
)
)
(decoder): TransformerDecoder(
(embed_tokens): Embedding(11416, 512, padding_idx=1)
(embed_positions): SinusoidalPositionalEmbedding()
(layers): ModuleList(
(0): TransformerDecoderLayer(
(self_attn): MultiheadAttention( ❸
(out_proj): Linear(in_features=512, out_features=512, bias=True)
)
(self_attn_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
(encoder_attn): MultiheadAttention( ❹
(out_proj): Linear(in_features=512, out_features=512, bias=True)
)
(encoder_attn_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_
affine=True)
(fc1): Linear(in_features=512, out_features=2048, bias=True) ❺
(fc2): Linear(in_features=2048, out_features=512, bias=True)
(final_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
)
...
(5): TransformerDecoderLayer(
(self_attn): MultiheadAttention(
(out_proj): Linear(in_features=512, out_features=512, bias=True)
)
(self_attn_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
(encoder_attn): MultiheadAttention(
(out_proj): Linear(in_features=512, out_features=512, bias=True)
)
(encoder_attn_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_
affine=True)
(fc1): Linear(in_features=512, out_features=2048, bias=True)
(fc2): Linear(in_features=2048, out_features=512, bias=True)
(final_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
)
)
)
)
❶ 编码器的自注意力
❷ 编码器的前馈网络
❸ 解码器的自注意力
❹ 解码器的编码器-解码器
❺ 解码器的前馈网络
当我运行时,验证损失在大约第 30 个时期后收敛,此时您可以停止训练。我将同一组西班牙语句子翻译成英文的结果如下:
¡ Buenos días !
S-0 ¡ Buenos días !
H-0 -0.0753164291381836 Good morning !
P-0 -0.0532 -0.0063 -0.1782 -0.0635
¡ Hola !
S-1 ¡ Hola !
H-1 -0.17134985327720642 Hi !
P-1 -0.2101 -0.2405 -0.0635
¿ Dónde está el baño ?
S-2 ¿ Dónde está el baño ?
H-2 -0.2670585513114929 Where 's the toilet ?
P-2 -0.0163 -0.4116 -0.0853 -0.9763 -0.0530 -0.0598
¿ Hay habitaciones libres ?
S-3 ¿ Hay habitaciones libres ?
H-3 -0.26301929354667664 Are there any rooms available ?
P-3 -0.1617 -0.0503 -0.2078 -1.2516 -0.0567 -0.0532 -0.0598
¿ Acepta tarjeta de crédito ?
S-4 ¿ Acepta tarjeta de crédito ?
H-4 -0.06886537373065948 Do you accept credit card ?
P-4 -0.0140 -0.0560 -0.0107 -0.0224 -0.2592 -0.0606 -0.0594
La cuenta , por favor .
S-5 La cuenta , por favor .
H-5 -0.08584468066692352 The bill , please .
P-5 -0.2542 -0.0057 -0.1013 -0.0335 -0.0617 -0.0587
Maria no daba una bofetada a la bruja verde .
S-6 Maria no daba una bofetada a la bruja verde .
H-6 -0.3688890039920807 Mary didn 't slapped the green witch .
P-6 -0.2005 -0.5588 -0.0487 -2.0105 -0.2672 -0.0139 -0.0099 -0.1503 -0.0602
大多数英文翻译几乎完美。令人惊讶的是,模型几乎完美地翻译了最困难的句子(“Maria no daba . . .”)。这可能足以说服我们,Transformer 是一个强大的翻译模型。在它的出现之后,这个模型成为了研究和商业机器翻译的事实标准。
8.4 基于 Transformer 的语言模型
在第 5.5 节中,我们介绍了语言模型,这是一种给文本赋予概率的统计模型。通过将文本分解为令牌序列,语言模型可以估计给定文本的“概率”。在第 5.6 节中,我们演示了通过利用这一特性,语言模型也可以用于“凭空”生成新的文本!
Transformer 是一个强大的模型,在 Seq2Seq 任务(如机器翻译)中取得了令人印象深刻的结果,尽管它的架构也可以用于语言建模和生成。在本节中,我们将学习如何使用 Transformer 进行语言建模和生成真实文本。
8.4.1 Transformer 作为语言模型
在第 5.6 节中,我们建立了基于字符 LSTM-RNN 的语言生成模型。简而言之,给定一个前缀(到目前为止生成的部分句子),模型使用基于 LSTM 的 RNN(一个带有循环的神经网络)来生成可能的下一个令牌的概率分布,如图 8.12 所示。
图 8.12 使用 RNN 生成文本
我们早些时候指出,通过将 Transformer 解码器视为黑匣子,您可以使用与我们之前介绍的 RNN 相同的一组解码算法(贪婪、束搜索等)。对于语言生成也是如此——通过将神经网络视为在给定前缀的情况下产生某种分数的黑匣子,您可以使用相同的逻辑生成文本,而不管底层模型如何。图 8.13 显示了类似 Transformer 的架构如何用于语言生成。除了一些细微差别(如缺乏交叉注意力)之外,结构几乎与 Transformer 解码器相同。
图 8.13 使用 Transformer 进行语言生成
以下片段显示了使用 Transformer 模型生成文本的类似 Python 的伪代码。在这里,model() 是主要的函数,模型计算发生在这里——它接受标记,将它们转换为嵌入,添加位置编码,并将它们传递到所有的 Transformer 层,将最终的隐藏状态返回给调用者。调用者然后将它们通过线性层传递,将它们转换为 logits,然后通过 softmax 转换为概率分布:
def generate():
token = <START>
tokens = [<START>]
while token != <END>:
hidden = model(tokens)
probs = softmax(linear(hidden))
token = sample(probs)
tokens.append(token)
return tokens
实际上,Seq2Seq 模型的解码和语言模型的语言生成是非常相似的任务,输出序列是逐标记生成的,将自身反馈给网络,就像前面的代码片段所示。唯一的区别在于,前者有某种形式的输入(源句子),而后者没有(模型自我反馈)。这两个任务也分别称为无条件生成和有条件生成。图 8.14 描绘了这三个组件(网络、任务和解码)以及它们如何结合起来解决特定问题。
图 8.14 语言生成和 Seq2Seq 任务的三个组件
在本节的其余部分,我们将尝试使用一些基于 Transformer 的语言模型,并使用它们生成自然语言文本。我们将使用由 Hugging Face 开发的 transformers 库(huggingface.co/transformers/
),这个库在过去几年已经成为了 NLP 研究人员和工程师使用 Transformer 模型的标准库。它提供了一些最先进的模型实现,包括 GPT-2(本节)和 BERT(下一章),以及预训练模型参数,您可以立即加载和使用。它还提供了一个简单、一致的接口,通过这个接口您可以与强大的 NLP 模型进行交互。
8.4.2 Transformer-XL
在许多情况下,您希望加载并使用由第三方提供的预训练模型(通常是模型的开发者),而不是从头开始训练它们。最近的 Transformer 模型相当复杂(通常具有数亿个参数),并且使用大量的数据集进行训练(数十吉字节的文本)。这将需要只有大型机构和科技巨头才能承受得起的 GPU 资源。甚至有些模型在训练时需要数天的时间,即使有十几个 GPU!好消息是,这些庞大的 Transformer 模型的实现和预训练模型参数通常由它们的创建者公开提供,以便任何人都可以将它们集成到他们的 NLP 应用程序中。
在这一部分中,我们首先将介绍 Transformer-XL,这是由 Google Brain 的研究人员开发的 Transformer 的一个变种。由于原始的 Transformer 模型中没有固有的“循环”,不像 RNNs,所以原始的 Transformer 不擅长处理超长的上下文。在用 Transformer 训练语言模型时,你首先将长文本分割成较短的块,比如 512 个单词,并将它们分别馈送到模型中。这意味着模型无法捕获超过 512 个单词的依赖关系。Transformer-XL⁴通过对原始 Transformer 模型进行一些改进来解决这个问题(“XL”表示额外长)。尽管这些改变的细节超出了本书的范围,在简单地说,该模型重复使用前一个段落的隐藏状态,有效地创建了一个在不同文本段之间传递信息的循环。它还改进了我们之前提到的位置编码方案,使得模型更容易处理更长的文本。
您只需在命令行中运行 pip install transformers 即可安装 transformers 库。您将与主要抽象进行交互的是分词器和模型。分词器将原始字符串拆分为一系列标记,而模型定义了架构并实现了主要逻辑。模型和预训练权重通常取决于特定的标记化方案,因此您需要确保您使用的分词器与模型兼容。
初始化一个分词器和一个模型,并使用一些指定的预训练权重的最简单方法是使用 AutoTokenizer 和 AutoModelWithLMHead 类,并调用它们的 from_pretrained()方法如下所示:
import torch
from transformers import AutoModelWithLMHead, AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained('transfo-xl-wt103')
model = AutoModelWithLMHead.from_pretrained('transfo-xl-wt103')
from_pre-trained()函数的参数是模型/预训练权重的名称。这是一个在名为 wt103(WikiText103)的数据集上训练的 Transformer-XL 模型。
您可能想知道 AutoModelWithLMHead 中的“LMHead”部分是什么意思。LM(语言模型)头是添加到神经网络中的特定层,它将其隐藏状态转换为一组分数,这些分数确定要生成的下一个标记。然后,这些分数(也称为 logits)被馈送到 softmax 层以获得可能的下一个标记的概率分布(图 8.15)。我们希望一个带有 LM 头的模型,因为我们有兴趣通过将 Transformer 作为语言模型来生成文本。但是,根据任务的不同,您可能还想要一个没有 LM 头的 Transformer 模型,并且只想使用其隐藏状态。这将是我们在下一章中要做的事情。
图 8.15 使用 Transformer 的语言模型头
下一步是初始化前缀,用于让语言模型生成故事的其余部分。可以使用 tokenizer.encode() 方法将字符串转换为标记 ID 列表,然后将其转换为张量。我们还将初始化变量 past,用于缓存内部状态并加速推理过程,如下所示:
generated = tokenizer.encode("On our way to the beach")
context = torch.tensor([generated])
past = None
现在,您已准备好生成文本的其余部分了。请注意,下面的代码与我们之前显示的伪代码相似。思路很简单:从模型获取输出,使用输出随机采样一个标记,并将其输入模型。反复这个过程。
for i in range(100):
output = model(context, mems=past)
token = sample_token(output.prediction_scores)
generated.append(token.item())
context = token.view(1, -1)
past = output.mems
需要进行一些清理工作,以使张量的形状与模型兼容,我们暂时可以忽略此步骤。此处的 sample_token() 方法将模型的输出转换为概率分布,并从中随机采样一个标记。我没有显示该方法的完整代码,但您可以查看 Google Colab 笔记本(realworldnlpbook.com/ch8.html#xformer-nb
)了解更多细节。此外,虽然我们在此处从零编写了生成算法,但如果您需要更全面的生成方式(如波束搜索),请查看该库开发者的官方示例脚本:mng.bz/wQ6q
。
在生成完成后,您可以通过调用 tokenizer.decode() 将标记 ID 转换为原始字符串,如下所示:
print(tokenizer.decode(generated))
运行后我得到了以下“故事”:
On our way to the beach, she finds, she finds the men who are in the group to be " in the group ". This has led to the perception that the " group " in the group is " a group of people in the group with whom we share a deep friendship, and which is a common cause to the contrary. " <eos> <eos> = = Background = = <eos> <eos> The origins of the concept of " group " were in early colonial years with the English Civil War. The term was coined by English abolitionist John
这不是一个坏的开始。我喜欢这个故事试图通过坚持“群体”概念来保持一致性的方式。然而,由于该模型仅训练于维基百科文本,其生成的结果并不真实,看起来有点过于正式。
8.4.3 GPT-2
GPT-2(代表生成预训练)是由 OpenAI 开发的迄今为止最著名的语言模型。你可能听说过关于一种语言模型生成如此真实无缝的自然语言文本,以至于你无法分辨其与人类写作的文本。从技术上讲,GPT-2 只是一个庞大的 Transformer 模型,就像我们之前介绍的那个一样。主要区别在于其规模(最大模型有 48 层!)以及该模型是通过从网络上收集到的大量自然语言文本进行训练的。OpenAI 团队公开发布了实现和预训练权重,因此我们可以轻松尝试这个模型。
初始化标记器和 GPT-2 模型,方法与 Transformer-XL 相同,如下所示:
tokenizer = AutoTokenizer.from_pretrained('gpt2-large')
model = AutoModelWithLMHead.from_pretrained('gpt2-large')
然后使用以下代码片段生成文本:
generated = tokenizer.encode("On our way to the beach")
context = torch.tensor([generated])
past = None
for i in range(100):
output = model(context, past_key_values=past)
token = sample_token(output.logits)
generated.append(token.item())
context = token.unsqueeze(0)
past = output.past_key_values
print(tokenizer.decode(generated))
你可能已经注意到这段代码与 Transformer-XL 的代码几乎没有变化。在许多情况下,当切换不同的模型时,您不需要进行任何修改。这就是为什么 transformers 库如此强大的原因 - 您可以尝试并集成各种最先进的基于 Transformer 的模型到您的应用程序中,只需使用一个简单且一致的界面。正如我们将在下一章中看到的那样,这个库还集成到 AllenNLP 中,这使得使用最先进的模型构建强大的自然语言处理应用程序变得容易。
当我尝试这个代码时,GPT-2 生成了以下精美的段落:
On our way to the beach, there was a small island that we visited for the first time. The island was called 'A' and it is a place that was used by the French military during the Napoleonic wars and it is located in the south-central area of the island.
A is an island of only a few hundred meters wide and has no other features to distinguish its nature. On the island there were numerous small beaches on which we could walk. The beach of 'A' was located in the...
注意它和自然的阅读感。此外,GPT-2 模型擅长保持一致性-您可以看到“A”这个岛的名字在整个段落中始终使用。就我所知,世界上没有一个真正名为 A 的岛屿,这意味着这是模型简单地编造的。这是一个伟大的成就,模型记住了它刚刚创造的名字,并成功围绕它写了一个故事!
下面是 GPT-2 根据提示生成的另一段话:'Real World Natural Language Processing’是这本书的名字:
'Real World Natural Language Processing' is the name of the book. It has all the tools you need to write and program natural language processing programs on your computer. It is an ideal introductory resource for anyone wanting to learn more about natural language processing. You can buy it as a paperback (US$12), as a PDF (US$15) or as an e-book (US$9.99).
The author's blog has more information and reviews.
The free 'Real World Natural Language Processing' ebook has all the necessary tools to get started with natural language processing. It includes a number of exercises to help you get your feet wet with writing and programming your own natural language processing programs, and it includes a few example programs. The book's author, Michael Karp has also written an online course about Natural Language Processing.
'Real World Natural Language Processing: Practical Applications' is a free e-book that explains how to use natural language processing to solve problems of everyday life (such as writing an email, creating and
到 2019 年 2 月,当 GPT-2 发布时,我几乎刚开始写这本书,所以我怀疑 GPT-2 对此一无所知。对于一个没有关于这本书的任何先验知识的语言模型来说,这是一项惊人的工作,尽管我必须指出它价格和作者的错误。
8.4.4 XLM
最后,作为一个有趣的例子,我们将尝试多语言语言生成。XLM(跨语言语言模型)是由 Facebook AI Research 的研究人员提出的基于 Transformer 的跨语言语言模型,可以生成和编码多种语言的文本。通过学习如何编码多语言文本,模型可以用于不同语言之间的迁移学习。我们将在第九章介绍迁移学习。
您可以通过以下方式初始化分词器和模型,并使用预训练权重进行初始化:
tokenizer = AutoTokenizer.from_pretrained('xlm-clm-enfr-1024')
model = AutoModelWithLMHead.from_pretrained('xlm-clm-enfr-1024')
在这里,我们加载一个使用英语和法语(enfr)进行训练的 XLM 模型(xlm),使用因果语言模型(CLM)目标(clm)进行训练。CLM 只是以更高级的方式描述我们在本章中所做的内容-根据前缀预测下一个标记。XLM 通常用于对多语言文本进行编码,用于一些下游任务,如文本分类和机器翻译,但我们只是将其用作生成文本的语言模型。有关使用 XLM 生成多语言文本的代码片段,请参见清单 8.2。您可以再次重用大部分之前的代码片段,尽管您还需要指定您正在使用的语言(请注意 lang = 0 行)。此外,在这里,我们通过仅提供 BOS 标记(其索引为零)从头开始生成文本。
清单 8.2 生成多语言文本与 XLM
generated = [0] # start with just <s>
context = torch.tensor([generated])
lang = 0 # English
for i in range(100):
langs = torch.zeros_like(context).fill_(lang)
output = model(context, langs=langs)
token = sample_token(output)
generated.append(token.item())
context = torch.tensor([generated])
print(tokenizer.decode(generated))
运行这个代码后,我得到了以下结果:
<s>and its ability to make decisions on the basis of its own. " </s>The government has taken no decisions on that matter, " Mr Hockey said. </s>A lot of the information is very sensitive. </s>The new research and information on the Australian economy, which is what we're going to get from people, and the information that we are going to be looking at, we're going to be able to provide and we 'll take it forward. </s>I'm not trying to make sure we're not
然后,让我们将语言更改为 1(表示法语),并再次运行相同的代码片段,这将给出下一段文本:
<s></s>En revanche, les prix des maisons individuelles n' ont guère augmenté ( - 0,1 % ). </s>En mars dernier, le taux de la taxe foncière, en légère augmentation à la hausse par rapport à février 2008\. </s>" Je n' ai jamais eu une augmentation " précise ". </s>" Je me suis toujours dit que ce n' était pas parce que c' était une blague. </s>En effet, j' étais un gars de la rue " </s>Les jeunes sont des gens qui avaient beaucoup d' humour... "
尽管这种生成质量不如我们之前实验的 GPT-2 那么好,但是看到一种单一模型可以同时生成英语和法语的文本非常令人耳目一新。如今,构建基于 Transformer 的多语言 NLP 模型以解决多种语言的 NLP 问题和任务越来越普遍。这也得益于 Transformer 对语言复杂性建模的强大能力。
8.5 案例研究:拼写检查器
在本章的最后一节中,我们将使用 Transformer 构建一个实用的 NLP 应用——拼写检查器。在现代世界中,拼写检查器无处不在。你的 Web 浏览器可能装备有一个拼写检查器,它会在拼写错误的单词下划线提示你。许多字处理器和编辑器也默认运行拼写检查器。一些应用程序(包括 Google Docs 和 Microsoft Word)甚至指出简单的语法错误。你是否想知道它们是如何工作的?我们将学习如何将其作为 NLP 问题进行规划、准备数据集、训练和改进模型。
8.5.1 拼写纠正作为机器翻译
拼写检查器接收这样一个文本:“tisimptant too spll chck ths dcment”,检测任何拼写和语法错误,并修复所有错误:“It’s important to spell-check this document.” 如何使用自然语言处理技术解决这个任务?这些系统如何实现?
最简单的方法是将输入文本分词为单词,并检查每个单词是否在字典中。如果不在,你可以查找距离最近的有效单词并替换它。可以使用一些度量(如编辑距离)来计算距离。重复这个过程,直到没有需要更正的单词。这种逐个单词修正的算法被许多拼写检查器广泛使用,因为它很简单。
然而,这种类型的拼写检查器有几个问题。首先,就像示例中的第一个单词“tisimptant”一样,您如何知道句子的哪一部分实际上是一个单词?我副本中默认的微软 Word 拼写检查器指出它是“disputant”的拼写错误,尽管对于任何英语使用者来说,它实际上是两个(或更多)单词的拼写错误是显而易见的。用户还可能拼写标点符号(包括空格),这使得一切都变得复杂。其次,仅仅因为某个单词在词典中存在,并不意味着它就没有错误。例如,示例中的第二个单词“too”是“to”的拼写错误,但两者都是任何英语词典中都有的有效单词。您如何判断前者在这种情况下是错误的呢?第三,所有这些决定都是在没有上下文的情况下做出的。我尝试过的一个拼写检查器在这个例子中显示“thus”是替换“ths”的候选词之一。然而,从这个上下文(名词之前)来看,“this”是一个更合适的候选词是显而易见的,尽管“this”和“thus”都与“ths”相隔一个编辑距离,这意味着根据编辑距离来看,它们都是同样有效的选项。
通过添加一些启发式规则,您可以解决其中一些问题。例如,“too”更有可能是动词之前“to”的拼写错误,“this”更有可能出现在名词之前而不是“thus”。但这种方法显然不具备可扩展性。还记得第 1.1.2 节中可怜的初级开发者吗?语言广阔而充满异常。您不能仅仅通过编写这些简单单词的规则来处理语言的全部复杂性。即使您能够为这些简单单词编写规则,您又如何知道“tisimptant”实际上是两个单词呢?您会尝试在每个可能的位置拆分这个单词,看拆分后的单词是否与现有单词相似吗?如果输入的是一种没有空格的语言,比如中文和日语,会怎么样呢?
此时,您可能意识到这种“拆分和修复”的方法行不通。一般来说,在设计自然语言处理应用程序时,您应该从以下三个方面考虑:
-
任务—正在解决什么任务?是分类、序列标注还是序列到序列问题?
-
模型—您将使用什么模型?是前馈网络、循环神经网络还是 Transformer?
-
数据集—您从哪里获取数据集来训练和验证您的模型?
根据我的经验,如今绝大多数自然语言处理应用程序都可以通过结合这些方面来解决。拼写检查器呢?因为它们以一段文本作为输入,并生成修复后的字符串,如果我们将其作为一个 Seq2Seq 任务使用 Transformer 模型来解决将会最直接。换句话说,我们将建立一个机器翻译系统,将带有拼写/语法错误的嘈杂输入转换为干净、无误的输出,如图 8.16 所示。您可以将这两个方面看作是两种不同的“语言”(或英语的“方言”)。
图 8.16 将拼写检查器训练为将“嘈杂”的句子翻译成“干净”的句子的 MT 系统
此时,您可能会想知道我们从哪里获取数据集。这通常是解决现实世界自然语言处理问题中最重要(也是最困难)的部分。幸运的是,我们可以使用公共数据集来完成这项任务。让我们深入研究并开始构建一个拼写检查器。
8.5.2 训练拼写检查器
我们将使用 GitHub Typo Corpus(github.com/mhagiwara/github-typo-corpus
)作为训练拼写检查器的数据集。这个数据集是由我和我的合作者创建的,其中包含数十万个从 GitHub 自动收集的“打字错误”编辑。这是迄今为止最大的拼写错误及其校正数据集,这使得它成为训练拼写检查器的理想选择。
在准备数据集和训练模型之前,我们需要做出一个决定,那就是选择模型操作的原子语言单位。许多自然语言处理模型使用令牌作为最小单位(即,RNN/Transformer 被馈送一个令牌序列),但越来越多的自然语言处理模型使用单词或句子片段作为基本单位(第 10.4 节)。对于拼写校正,我们应该使用什么作为最小的单位?与许多其他自然语言处理模型一样,起初使用单词作为输入听起来像是一个很好的“默认”选择。然而,正如我们之前所看到的,令牌的概念并不适用于拼写校正——用户可能会弄乱标点符号,如果您正在处理令牌,这会使一切过于复杂。更重要的是,因为自然语言处理模型需要操作一个固定的词汇表,所以拼写校正器的词汇表需要包含训练期间遇到的每个单词的每个拼写错误。这将使得训练和维护这样一个自然语言处理模型变得不必要昂贵。
由于这些原因,我们将使用字符作为拼写检查器的基本单位,就像在第 5.6 节中一样。使用字符有几个优点——它可以保持词汇表的大小相当小(通常对于具有小字母表集的语言,如英语,不到一百个)。即使是充满打字错误的嘈杂数据集,您也不必担心膨胀您的词汇表,因为打字错误只是字符的不同排列。您还可以将标点符号(甚至空白符)视为词汇表中的字符之一。这使得预处理步骤非常简单,因为您不需要任何语言工具包(如标记器)来执行此操作。
注意:使用字符并非没有缺点。其中一个主要问题是使用它们会增加序列的长度,因为你需要将所有内容分解为字符。这使得模型变得庞大且训练速度变慢。
首先,让我们为训练拼写检查器准备数据集。构建拼写检查器所需的所有必要数据和代码都包含在此代码库中:github.com/mhagiwara/xfspell
。经过分词和拆分的数据集位于 data/gtc 目录下(如 train.tok.fr、train.tok.en、dev.tok.fr、dev.tok.en)。后缀 en 和 fr 是机器翻译中常用的约定,其中“fr”表示“外语”,“en”表示英语,因为许多机器翻译研究项目最初是由希望将某种外语翻译为英语的人发起的。这里,我们将“fr”和“en”仅仅解释为“拼写纠错前的嘈杂文本”和“拼写纠错后的纠正文本”。
图 8.17 显示了根据 GitHub Typo Corpus 创建的拼写纠错数据集的摘录。请注意,文本被分割成单个字符,甚至包括空格(由“_”替换)。所有不在通用字母表(大写字母、小写字母、数字和一些常见标点符号)内的字符都被替换为“#”。您可以看到数据集包含各种纠正,包括简单的拼写错误(pubilc->public 在第 670 行,HYML->HTML 在第 672 行),更复杂的错误(mxnet 一词替换成 mxnet is not 在第 681 行,22th->22nd 在第 682 行),甚至不带任何更正的行(第 676 行)。这看起来是训练拼写检查器的一个好资源。
图 8.17 拼写纠错的训练数据
训练拼写检查器(或任何其他 Seq2Seq 模型)的第一步是对数据集进行预处理。因为数据集已经分割和格式化,你只需要运行 fairseq-preprocess 将数据集转换为二进制格式,操作如下:
fairseq-preprocess --source-lang fr --target-lang en \
--trainpref data/gtc/train.tok \
--validpref data/gtc/dev.tok \
--destdir bin/gtc
然后,您可以使用以下代码立即开始训练模型。
列表 8.3 训练拼写检查器
fairseq-train \
bin/gtc \
--fp16 \
--arch transformer \
--encoder-layers 6 --decoder-layers 6 \
--encoder-embed-dim 1024 --decoder-embed-dim 1024 \
--encoder-ffn-embed-dim 4096 --decoder-ffn-embed-dim 4096 \
--encoder-attention-heads 16 --decoder-attention-heads 16 \
--share-decoder-input-output-embed \
--optimizer adam --adam-betas '(0.9, 0.997)' --adam-eps 1e-09 --clip-norm 25.0 \
--lr 1e-4 --lr-scheduler inverse_sqrt --warmup-updates 16000 \
--dropout 0.1 --attention-dropout 0.1 --activation-dropout 0.1 \
--weight-decay 0.00025 \
--criterion label_smoothed_cross_entropy --label-smoothing 0.2 \
--max-tokens 4096 \
--save-dir models/gtc01 \
--max-epoch 40
您不需要担心这里的大多数超参数——这组参数对我来说效果还不错,尽管可能还有其他参数组合效果更好。但是,您可能想注意一些与模型大小相关的参数,即:
-
层数(-[encoder|decoder]-layers)
-
自注意力的嵌入维度(-[encoder|decoder]-embed-dim)
-
前馈层的嵌入维度(-[encoder/decoder]-ffn-embed-dim)
-
注意力头数(-[encoder|decoder]-attention-heads)
这些参数决定了模型的容量。一般来说,这些参数越大,模型的容量就越大,尽管作为结果,模型也需要更多的数据、时间和 GPU 资源来进行训练。另一个重要的参数是—max-token,它指定加载到单个批次中的标记数。如果在 GPU 上遇到内存不足错误,请尝试调整此参数。
训练完成后,您可以运行以下命令使用训练好的模型进行预测:
echo "tisimptant too spll chck ths dcment." \
| python src/tokenize.py \
| fairseq-interactive bin/gtc \
--path models/gtc01/checkpoint_best.pt \
--source-lang fr --target-lang en --beam 10 \
| python src/format_fairseq_output.py
因为 fairseq-interactive 界面也可以从标准输入接收源文本,所以我们直接使用 echo 命令提供文本。Python 脚本 src/format_fairseq_output.py,顾名思义,格式化来自 fairseq-interactive 的输出,并显示预测的目标文本。当我运行这个脚本时,我得到了以下结果:
tisimplement too spll chck ths dcment.
这相当令人失望。拼写检查器学会了如何将“imptant”修正为“implement”,尽管它未能纠正任何其他单词。我怀疑有几个原因。使用的训练数据,GitHub Typo Corpus,严重偏向于软件相关的语言和纠正,这可能导致了错误的更正(imptant -> implement)。此外,训练数据可能对于 Transformer 来说太小了。我们如何改进模型,使其能够更准确地纠正拼写错误呢?
8.5.3 改进拼写检查器
正如我们之前讨论的,拼写检查器不如预期工作的一个主要原因可能是因为模型在训练过程中没有暴露给更多种类、更大数量的拼写错误。但据我所知,没有这样的大型数据集公开可用于训练一个通用领域的拼写检查器。我们如何获取更多的数据来训练一个更好的拼写检查器呢?
这就是我们需要有创造性的地方。一个想法是从干净的文本中人工生成带有噪音的文本。如果你想一想,这是非常困难的(尤其对于一个机器学习模型)来纠正拼写错误,但很容易“破坏”干净的文本,以模拟人们如何打字错误,即使对于计算机也是如此。例如,我们可以从一些干净的文本(例如,几乎无限的从网页抓取的文本)中随机替换一些字母。如果你将以这种方式创建的人工生成的带噪音的文本与原始的干净文本配对,这将有效地创建一个新的、更大的数据集,你可以在其上训练一个更好的拼写检查器!
我们需要解决的剩下的问题是如何“破坏”干净的文本以生成看起来像人类所做的真实拼写错误。你可以编写一个 Python 脚本,例如,随机替换、删除和/或交换字母,虽然不能保证以这种方式生成的拼写错误与人类所做的拼写错误相似,也不能保证生成的人工数据集能为 Transformer 模型提供有用的见解。我们如何建模这样一个事实,例如,人们更有可能在“too”的地方输入“to”,而不是“two”呢?
这又开始听起来熟悉了。我们可以使用数据来模拟打字错误!但是如何做呢?这就是我们需要再次发挥创造力的地方——如果你“翻转”我们用来训练拼写检查器的原始数据集的方向,你可以观察到人们是如何打字错误的。如果你把干净的文本视为源语言,把嘈杂的文本视为目标语言,并为该方向训练一个 Seq2Seq 模型,那么你实际上是在训练一个“拼写损坏器”—一个将看起来很真实的拼写错误插入干净文本的 Seq2Seq 模型。请参见图 8.18 进行说明。
图 8.18 使用回译生成人工噪声数据
在机器学习文献中,使用原始训练数据的“反向”来从目标语言中的真实语料库中人工生成大量源语言数据的技术被称为 回译。这是一种提高机器翻译系统质量的流行技术。正如我们接下来将展示的,它也可以有效地提高拼写检查器的质量。
通过交换源语言和目标语言,您可以轻松训练一个拼写损坏器。您可以在运行 fairseq-preprocess 时将“en”(干净文本)作为源语言提供,将“fr”(嘈杂文本)作为目标语言,如下所示:
fairseq-preprocess --source-lang en --target-lang fr \
--trainpref data/gtc/train.tok \
--validpref data/gtc/dev.tok \
--destdir bin/gtc-en2fr
我们不再详细介绍训练过程——你可以使用几乎相同的 fairseq-train 命令启动训练。只是不要忘记为 —save-dir 指定一个不同的目录。在训练结束后,您可以检查拼写损坏器是否确实能按预期损坏输入文本:
$ echo 'The quick brown fox jumps over the lazy dog.' | python src/tokenize.py \
| fairseq-interactive \
bin/gtc-en2fr \
--path models/gtc-en2fr/checkpoint_best.pt \
--source-lang en --target-lang fr \
--beam 1 --sampling --sampling-topk 10 \
| python src/format_fairseq_output.py
The quink brown fox jumps ove-rthe lazy dog.
注意我之前添加的额外选项,以粗体显示。这意味着 fairseq-interactive 命令使用采样(从概率最大的前 10 个标记中采样)而不是束搜索。当损坏干净文本时,通常最好使用采样而不是束搜索。简而言之,采样根据 softmax 层后的概率分布随机选择下一个标记,而束搜索则试图找到最大化输出序列分数的“最佳路径”。虽然束搜索在翻译某些文本时可以找到更好的解决方案,但在通过回译增加数据时,我们希望得到更嘈杂、更多样化的输出。过去的研究⁶也表明,采样(而不是束搜索)对通过回译增加数据效果更好。
从这里开始,一切皆有可能。你可以收集尽可能多的干净文本,使用刚刚训练的损坏程序生成嘈杂文本,并增加训练数据的大小。并不能保证人工错误看起来像人类所做的真实错误一样,但这并不重要,因为 1)源(嘈杂)侧仅用于编码,2)目标(干净)侧数据始终是由人类编写的“真实”数据,从中 Transformer 可以学习如何生成真实文本。你收集的文本数据越多,模型对无错误的真实文本的信心就越大。
我不会详细介绍我为增加数据量所采取的每一步,但这里是我所做的事情以及你也可以做的事情的总结。从公开可用的数据集(如 Tatoeba 和维基百科的转储)中收集尽可能多的干净和多样化的文本数据是一个方法。我最喜欢的方法是使用 OpenWebTextCorpus(skylion007.github.io/OpenWebTextCorpus/
),这是一个开源项目,用于复制最初用于 GPT-2 训练的数据集。它由从 Reddit 的所有外部链接爬取的大量(40 GB)高质量网页文本组成。因为整个数据集的预处理和运行损坏程序可能需要几天甚至几周的时间,你可以取一个子集(比如说,1/1000),然后将其添加到数据集中。我取了数据集的 1/100 子集,对其进行了预处理,并运行了损坏程序,以获得嘈杂干净的平行数据集。这 1/100 子集单独就添加了五百多万对(相比之下,原始训练集仅包含约 240k 对)。你可以下载预训练权重并尝试存储库中的拼写检查器,而不是从头开始训练。
训练花了几天时间,甚至在多个 GPU 上,但当完成时,结果非常令人鼓舞。它不仅可以准确地修复拼写错误,如下所示
$ echo "tisimptant too spll chck ths dcment." \
| python src/tokenize.py \
| fairseq-interactive \
bin/gtc-bt512-owt1k-upper \
--path models/bt05/checkpoint_best.pt \
--source-lang fr --target-lang en --beam 10 \
| python src/format_fairseq_output.py
It's important to spell check this document.
而且拼写检查器似乎也在某种程度上理解英语的语法,如下所示:
$ echo "The book wer about NLP." |
| python src/tokenize.py \
| fairseq-interactive \
...
The book was about NLP.
$ echo "The books wer about NLP." |
| python src/tokenize.py \
| fairseq-interactive \
...
The books were about NLP.
这个例子本身可能不能证明模型真正理解语法(即根据主语的数量使用正确的动词)。它可能只是学习了一些连续单词之间的关联,这可以通过任何统计 NLP 模型(如 n-gram 语言模型)实现。然而,即使在你让句子更加复杂之后,拼写检查器也显示出了惊人的弹性,如下一个代码片段所示:
$ echo "The book Tom and Jerry put on the yellow desk yesterday wer about NLP." |
| python src/tokenize.py \
| fairseq-interactive \
...
The book Tom and Jerry put on the yellow desk yesterday was about NLP.
$ echo "The books Tom and Jerry put on the yellow desk yesterday wer about NLP." |
| python src/tokenize.py \
| fairseq-interactive \
...
The books Tom and Jerry put on the yellow desk yesterday were about NLP.
从这些例子中,可以清楚地看出模型学会了如何忽略不相关的名词短语(例如“Tom and Jerry”和“yellow desk”),并专注于决定动词形式(“was”与“were”)的名词(“book(s)”)。我们更有信心它理解了基本的句子结构。我们所做的一切只是收集了大量干净的文本,并在其上训练了 Transformer 模型,结合了原始的训练数据和损坏器。希望通过这些实验,你能感受到 Transformer 模型的强大之处!
摘要
-
注意力机制是神经网络中的一种机制,它专注于输入的特定部分,并计算其上下文相关的摘要。它类似于“软”版本的键-值存储。
-
可以将编码器-解码器注意力机制添加到 Seq2Seq 模型中,以提高其翻译质量。
-
自注意力是一种注意力机制,通过总结自身来产生输入的摘要。
-
Transformer 模型反复应用自注意力机制,逐渐转换输入。
-
可以使用 Transformer 和一种称为回译的技术来构建高质量的拼写检查器。
^(1.)Vaswani 等人,“注意力机制就是一切”,(2017)。arxiv.org/abs/1706.03762
。
^(2.)Bahdanau 等人,“通过共同学习对齐和翻译进行神经机器翻译”,(2014)。arxiv.org/abs/1409.0473
。
^(3.)Bahdanau 等人,“通过共同学习对齐和翻译进行神经机器翻译”,(2014)。arxiv.org/abs/1409.0473
。
^(4.)Dai 等人,“Transformer-XL:超越固定长度上下文的注意力语言模型”,(2019)。arxiv.org/abs/1901.02860
。
^(5.)Lample 和 Conneau,“跨语言语言模型预训练”,(2019)。arxiv.org/abs/1901 .07291
。
^(6.)Edunov 等人,“大规模理解回译”,(2018)。arxiv.org/abs/1808.09381
。