spaCy携手Transformers:微调BERT、XLNet和GPT-2

3 阅读16分钟

spaCy携手Transformers:微调BERT、XLNet和GPT-2

像BERT、GPT-2和XLNet这样的大型Transformer模型已经在几乎所有NLP排行榜上树立了新的准确率标杆。现在,通过我们开发的一个新的接口库,您可以在spaCy中使用这些模型。该库将spaCy与Hugging Face出色的实现连接了起来。

在这篇文章中,我们将介绍新的封装库 spacy-transformers。它提供了对多种模型的一致且易于使用的接口,这些模型可以提取特征来驱动您的NLP流程。该库支持通过spaCy标准的 nlp.update 训练API对Transformer模型进行微调。此外,它还计算了与spaCy语言学词元分割的对齐关系,使您能够将Transformer特征关联回实际的词语,而不仅仅是词片段。基于Transformer的流程并非适用于所有用例,但它们也不仅仅是为了研究:即使您需要大规模处理文本,您的团队也有很多方法可以利用这些庞大但高度准确的模型。

更新(2019年10月)spacy-transformers 包之前名为 spacy-pytorch-transformers。自本博文发布以来,Hugging Face 发布了一个更新并更名的 transformers 包,现在同时支持PyTorch和TensorFlow 2。我们已经相应地更新了我们的库和这篇博文。

Transformer与迁移学习

自然语言处理(NLP)系统面临着一个被称为“知识获取瓶颈”的问题。深度神经网络通过构建能在任务间良好迁移的密集表示,提供了一种解决方案。在过去的几年里,研究表明,只要网络足够大以能够表示罕见用法现象的长尾分布,就可以从无标签文本中有效地获取语言知识。为了持续扩大网络规模,研究特别关注那些能高效利用当前GPU和TPU硬件,以及支持高效并行训练的模型。

这样做的结果是产生了一类新的架构,它们提供了与以往技术截然不同的权衡。Transformer使用了一种网络架构,它对词序和局部上下文的重要性预先设定的假设更少。当模型较小或数据有限时,这种(稍微更)“白板”的方法是一个劣势,但只要有足够大的模型和充分的示例,Transformer就能对语言信息达到更精妙的理解。Transformer架构让更大的模型变得更好。使用以往的技术,如果您只是让模型更大(也因此更慢),即使有足够的训练数据,准确率也会很快达到平台期。Transformer还让您能更好地利用昂贵的GPU和TPU硬件——这是另一个只有对更大模型才有意义的好处。

在生产环境中使用Transformer模型

尽管Transformer模型每个月都在刷新准确率记录,但将它们直接应用于大多数实际问题并不容易。通常,如果NLP值得做,就值得快速完成:这些技术通常只在需要处理大量文本或需要实时答案的应用中才有充分的理由。您的项目应该已经拥有一个具有人类级自然语言理解能力的过程,但代价是高运行时成本和高延迟:人工标注。Transformer模型提供了一个新的中间地带:比人工标注便宜得多、延迟也低得多,但对于大多数直接应用来说仍然太慢。在某些问题上几乎与人工标注一样准确,但存在不可预测的错误和难以推理的输出。

在某机构柏林的一次近期演讲中,Jacob Devlin 描述了该机构如何在内部使用他们的BERT架构。这些模型太大,无法在生产环境中直接提供服务,但它们可以用来监督一个较小的生产模型。根据(相当模糊的)营销文案,某云服务商可能在SageMaker中做了类似的事情。Transformer模型的另一个离线用例是质量控制——这正是某全球评级机构使用BERT的方式,因为他们的应用上下文需要严格的准确性保证。另一个潜在用例是监控,作为一种“健康检查”,来评估生产模型在近期数据上的表现。

介绍 spacy-transformers

Thomas Wolf 和 Hugging Face 的其他贡献者在一个易于使用的包 transformers 中实现了多个最近的Transformer模型。这使得编写一个封装库变得容易,让您可以在spaCy流程中使用这些模型。我们还利用了 spacy package 命令来构建提供权重、入口点和所有依赖项的pip包。这样,您可以使用与我们其他模型包相同的工作流程来下载和加载基于Transformer的模型:

pip install spacy-transformers
python -m spacy download en_trf_bertbaseuncased_lg

包中包含什么? 这些包包含配置设置、Transformer模型的二进制权重以及用于词片段(wordpiece)词元化的映射表。

spaCy 模型包 | 预训练模型 | 语言 | 作者 | --- | --- en_trf_bertbaseuncased_lg | bert-base-uncased | 英语 | 某研究机构 de_trf_bertbasecased_lg | bert-base-german-cased | 德语 | deepset en_trf_xlnetbasecased_lg | xlnet-base-cased | 英语 | CMU/Google Brain

Transformer流程有一个 trf_wordpiecer 组件,负责执行模型的词片段预处理,以及一个 trf_tok2vec 组件,它在文档上运行Transformer,并将结果保存到内置的 doc.tensor 属性和几个扩展属性中。

该模型的词元向量编码器组件设置了自定义钩子,覆盖了spaCy在 TokenSpanDoc 对象上的 .vector 属性和 .similarity 方法的默认行为。默认情况下,这些通常指向词向量表。自然地,在Transformer模型中,我们更愿意使用 doc.tensor 属性,因为它包含了一个更具信息量的上下文相关表示。

import spacy
import torch
import numpy
from numpy.testing import assert_almost_equal

is_using_gpu = spacy.prefer_gpu()
if is_using_gpu:
    torch.set_default_tensor_type("torch.cuda.FloatTensor")

nlp = spacy.load("en_trf_bertbaseuncased_lg")
doc = nlp("Here is some text to encode.")
assert doc.tensor.shape == (7, 768)  # 每个词元一行

doc._.trf_word_pieces_  # 词片段的字符串值
doc._.trf_word_pieces  # 词片段ID(注意:*不是* spaCy的哈希值!)
doc._.trf_alignment  # spaCy词元与词片段之间的对齐关系

# 原始的Transformer输出每个词片段一行。
assert len(doc._.trf_last_hidden_state) == len(doc._.trf_word_pieces)

# 为避免信息丢失,我们计算 doc.tensor 属性,使得
# 求和池化后的向量相匹配(除了数值误差)
assert_almost_equal(doc.tensor.sum(axis=0), doc._.trf_last_hidden_state.sum(axis=0), decimal=5)

span = doc[2:4]
# 从Span元素访问tensor(对句子特别有用)
assert numpy.array_equal(span.tensor, doc.tensor[2:4])

# .vector 和 .similarity 使用Transformer输出
apple1 = nlp("Apple shares rose on the news.")
apple2 = nlp("Apple sold fewer iPhones this quarter.")
apple3 = nlp("Apple pie is delicious.")
print(apple1[0].similarity(apple2[0]))  # 0.73428553
print(apple1[0].similarity(apple3[0]))  # 0.43365782

最重要的特征是Transformer的原始输出,可以通过 doc._.trf_outputs.last_hidden_state 访问。这个变量提供了一个张量,每个词片段对应一行。doc.tensor 属性为每个spaCy词元提供一行,如果您在处理词元级别的任务(如词性标注或拼写纠正)时,这非常有用。我们精心计算了模型的各种词片段词元化方案与spaCy基于语言学的词元化之间的对齐关系,并采用了一种确保不丢失任何信息的加权方案。

关于性能的说明:Transformer架构并非设计为在CPU上高效运行,因此我们建议您在训练和使用时都配备GPU。在内部,该库依赖于PyTorch和Cupy都支持的DLPack格式,它允许PyTorch和spaCy的机器学习库Thinc之间进行零拷贝互操作。这意味着封装层引入的开销可以忽略不计,即使它在不同库之间传递数组。然而,当前的封装策略确实存在一些缺点。主要的一点是,PyTorch和Cupy各自使用不同的分配缓存,导致在处理那些单独使用PyTorch可能没问题的任务时出现内存不足错误。目前也不支持多GPU。我们正在着手解决这两个问题。

我们也努力确保预处理细节(如边界词元)对于每个不同的模型都能被正确处理。看似微小的细节,比如“分类”(class)词元应该放在句首(如BERT)还是句尾(如XLNet),对于有效的微调会产生很大影响。如果Transformer的输入与其预训练时的输入不匹配,它将不得不更多地依赖您的小型标注训练语料,从而导致准确率降低。我们希望为Transformer模型提供一个更统一的接口,这不仅能帮助生产用户,也能帮助研究人员。对齐的词元化对于回答诸如“这两个Transformer关注相同的词语吗?”这样的问题应该特别有帮助。您还可以将spaCy的词性标注器、句法解析器和实体识别器添加到流程中,这能让您提出诸如“当存在句法歧义时,注意力模式是否会改变?”之类的问题。

迁移学习

预训练Transformer模型的主要用例是迁移学习。您加载一个在海量文本上预训练的大型通用模型,然后在针对您问题的小型带标签数据集上开始训练。spacy-transformers 包提供了自定义流程组件,使这个过程变得特别容易。我们提供了一个用于文本分类的示例组件。开发用于其他任务的类似组件应该相当直接。

trf_textcat 组件基于spaCy内置的 TextCategorizer,并支持通过 trf_tok2vec 组件使用由Transformer模型分配的特征。这让您可以使用像BERT这样的模型来预测上下文相关的词元表示,然后在其之上学习一个文本分类器作为任务特定的“头”。其API与任何其他spaCy组件相同:

示例数据

TRAIN_DATA = [
    ("text1", {"cats": {"POSITIVE": 1.0, "NEGATIVE": 0.0}})
]

关于示例的说明:此示例已简化以展示最小的工作流程。对于功能更完整的训练循环,请参阅 train_textcat.py 示例脚本,它展示了如何在IMDB数据上训练文本分类器。

训练循环

import spacy
from spacy.util import minibatch
import random
import torch

is_using_gpu = spacy.prefer_gpu()
if is_using_gpu:
    torch.set_default_tensor_type("torch.cuda.FloatTensor")

nlp = spacy.load("en_trf_bertbaseuncased_lg")
print(nlp.pipe_names) # ["sentencizer", "trf_wordpiecer", "trf_tok2vec"]
textcat = nlp.create_pipe("trf_textcat", config={"exclusive_classes": True})
for label in ("POSITIVE", "NEGATIVE"):
    textcat.add_label(label)
nlp.add_pipe(textcat)

optimizer = nlp.resume_training()
for i in range(10):
    random.shuffle(TRAIN_DATA)
    losses = {}
    for batch in minibatch(TRAIN_DATA, size=8):
        texts, cats = zip(*batch)
        nlp.update(texts, cats, sgd=optimizer, losses=losses)
    print(i, losses)
nlp.to_disk("/bert-textcat")

我们仍在测试和完善相关的工作流程,还有许多功能尚未实现。目前我们缺失的最重要功能是对所有模型输出的支持:我们目前只传递最后一个隐藏层状态。完整的隐藏状态激活和注意力矩阵应该很快就能用了。此外,我们还需要在API中公开许多选项,特别是配置更新是否反向传播到Transformer模型的能力。

我们特别期待在我们的标注工具 Prodigy 中支持这些Transformer模型。在设计Prodigy时,我们的核心假设之一是少量的监督可以发挥很大作用。有了好的工具和设计良好的标注方案,您不需要标注数百万个数据点——这意味着您不需要将标注任务视为低价值的点击工作。现代的迁移学习技术正在证实这一点。Xie等人(2019)已经证明,仅用1%的IMDB情感分析数据(仅几十个样本)训练的Transformer模型就能超越2016年之前的最高水平。尽管该领域的发展速度远超我们的预期,但这种工具辅助的工作流程正是我们将Prodigy设计为可脚本化且对开发者友好的原因。

针对您的任务微调预训练的Transformer模型

Peters等人(2019)对两种迁移学习方法进行了详细研究:微调 (🔥)特征提取 (❄️)。他们发现两种方法各有优势,❄️ 具有实际优势,并且根据任务和数据集的不同,有时在准确率上也能超越 🔥。当前的文本分类模型使用 🔥,并遵循Devlin等人(2018)的方法,使用分类(class)词元的向量来表示句子,并将该向量传递到softmax层以执行分类。对于多文档句子,我们对softmax输出进行均值池化。

流程组件可以通过递增 doc._.trf_d_last_hidden_state 属性将梯度传回给Transformer,该属性是一个numpy/cupy数组,保存了相对于Transformer最后一个隐藏层状态的梯度。要为新的任务实现自定义组件,您应该创建一个 spacy.pipeline.Pipe 的新子类,定义模型、predictset_annotationsupdate 方法。在 update 方法中,您的组件将接收到一批包含Transformer特征的 Doc 对象,以及一批包含标准标注的 GoldParse 对象。使用这些输入,您应该更新您的模型,并增加 doc._.trf_last_hidden_state 变量上最后一个隐藏层状态的梯度。您的子类可以以任何方式处理其模型,但如果您使用PyTorch,最简单的方法是使用Thinc的PyTorch封装类,这将使您不必实现 to_bytes/from_bytesto_disk/from_disk 序列化方法。我们期望将来发布一个包含推荐工作流程的完整教程。

词片段(Wordpieces)和输出与语言学词元的对齐

Transformer模型通常在经过“词片段”(wordpiece)算法预处理的文本上进行训练,这限制了模型需要考虑的不同词元类型的数量。词片段对于训练神经网络很方便,但它产生的分割与任何语言学上的“词”概念都不匹配。大多数罕见词会映射到多个词片段词元,有时对齐关系会是多对多的。下面是一个示例,展示了使用来自IMDB数据集的文本片段,不同预训练模型产生的词片段词元:

原始词片段

  • bert-base-uncased:['[CLS]', 'laced', 'with', 'dreams', '-', 'dripping', 'in', 'reality', ',', 'the', 'american', 'dream', 'reign', '##ites', 'after', '9', '.', '11', 'with', 'a', 'true', 'story', 'about', 'the', 'devil', 'ray', "'", 's', 'mid', '-', 'life', 'rookie', ',', 'jimmy', 'morris', '.', '[SEP]']
  • gpt2:['<|endoftext|>', 'L', 'aced', 'Ġwith', 'Ġdreams', 'Ġ-', 'Ġdripping', 'Ġin', 'Ġreality', ',', 'Ġthe', 'ĠAmerican', 'ĠDream', 'Ġreign', 'ites', 'Ġafter', 'Ġ9', '.', '11', 'Ġwith', 'Ġa', 'Ġtrue', 'Ġstory', 'Ġabout', 'Ġthe', 'ĠDevil', 'ĠRay', "'s", 'Ġmid', '-', 'life', 'Ġrookie', ',', 'ĠJimmy', 'ĠMorris', '.', '<|endoftext|>']
  • xlnet-base-cased:['', '▁Lac', 'ed', '▁with', '▁dreams', '▁', '-', '▁dripping', '▁in', '▁reality', ',', '▁the', '▁American', '▁Dream', '▁reign', 'ites', '▁after', '▁9', '.', '11', '▁with', '▁a', '▁true', '▁story', '▁about', '▁the', '▁Devil', '▁Ray', "'", 's', '▁mid', '-', 'life', '▁rookie', ',', '▁Jimmy', '▁Morris', '.', '']

清洗后的词片段(移除了特殊符号)

  • bert-base-uncased:['laced', 'with', 'dreams', '-', 'dripping', 'in', 'reality', ',', 'the', 'american', 'dream', 'reign', 'ites', 'after', '9', '.', '11', 'with', 'a', 'true', 'story', 'about', 'the', 'devil', 'ray', "'", 's', 'mid', '-', 'life', 'rookie', ',', 'jimmy', 'morris', '.']
  • gpt2:['L', 'aced', 'with', 'dreams', '-', 'dripping', 'in', 'reality', ',', 'the', 'American', 'Dream', 'reign', 'ites', 'after', '9', '.', '11', 'with', 'a', 'true', 'story', 'about', 'the', 'Devil', 'Ray', "'s", 'mid', '-', 'life', 'rookie', ',', 'Jimmy', 'Morris', '.']
  • xlnet-base-cased:['Lac', 'ed', 'with', 'dreams', '', '-', 'dripping', 'in', 'reality', ',', 'the', 'American', 'Dream', 'reign', 'ites', 'after', '9', '.', '11', 'with', 'a', 'true', 'story', 'about', 'the', 'Devil', 'Ray', "'", 's', 'mid', '-', 'life', 'rookie', ',', 'Jimmy', 'Morris', '.']

spaCy 词元:['Laced', 'with', 'dreams', '-', 'dripping', 'in', 'reality', ',', 'the', 'American', 'Dream', 'reignites', 'after', '9.11', 'with', 'a', 'true', 'story', 'about', 'the', 'Devil', 'Ray', "'s", 'mid', '-', 'life', 'rookie', ',', 'Jimmy', 'Morris', '.']

如您所见,词片段定义的分割与语言学的“词”概念并不接近。许多分割相当令人惊讶,例如GPT-2分词器决定将“Laced”分成两个词元:“L”和“aced”。词片段词元化器的优先目标是限制词汇表大小,因为词汇表大小是当前神经语言模型面临的关键挑战之一(Yang等人,2017)。虽然它无疑已被证明是一种有效的模型训练技术,但语言学词元提供了更好的可解释性和互操作性。考虑到不同词片段词元化之间的差异,这一点尤其正确:每个模型都需要自己的分割,而下一个模型无疑会需要再次不同的分割。

我们已尽可能精确地计算了对齐的 doc.tensor 表示,优先考虑避免信息丢失。为了实现这一点,张量的每一行(对应于一个spaCy词元)被设置为该词元所对齐的 last_hidden_state 张量各行的加权和,其中权重与该行对齐的其他spaCy词元的数量成正比。为了包含边界词元(通常很重要——参见Clark等人,2019)的信息,我们假设它们也与句子中的所有词元“对齐”。这种加权方案的实现可以在 TransformersTok2Vec.set_annotations 方法中找到。

批处理、填充与按句子处理

Transformer模型在序列长度方面具有立方级的运行时间和内存复杂度。这意味着为了达到合理的效率,较长的文本需要被分割成句子。spacy-transformers 在内部处理这个问题,并且要求流程中有一个句子边界检测组件。我们推荐使用spaCy内置的 sentencizer 组件。在内部,Transformer模型将对句子进行预测,然后重建生成的张量特征以产生文档级别的标注。为了进一步提高效率并减少内存需求,我们还在内部执行基于长度的子批处理。

子批处理按序列长度对批处理的句子进行重新分组,以最小化所需的填充量。每个批次3000个词的默认值在Tesla V100上运行得相当好。许多预训练的Transformer模型都有一个最大序列长度。如果一个句子超过了这个最大值,它将被截断,受影响的末尾词元将接收到零向量。

结论

训练大型Transformer模型目前需要大量的计算资源。即使计算资源原本闲置,仅能源消耗就相当可观。Strubell(2019)计算出,预训练BERT基础模型产生的碳排放大约相当于一次跨大西洋飞行。正如Sebastian Ruder在spaCy IRL会议上的主题演讲中强调的那样,因此,尽可能广泛地重用这些模型分发的预训练权重,而不是重新计算,这一点非常重要。

然而,有效地重用预训练权重可能是一项精细的操作。模型通常在经过统一预处理的文本上进行训练,这可能使它们对训练和运行时之间哪怕很小的差异也很敏感。这些预处理问题的影响可能难以预测,也难以推理。Hugging Face的 transformers 库通过使使用预训练模型和分词器变得容易,并提供了相当一致的接口,已经在很大程度上解决了这个问题。然而,要达到最佳性能,仍然需要处理许多预处理细节。我们希望我们的封装库在这方面会被证明是有用的。