【Transformers】第 4 章 :多语言命名实体识别

发布于:2022-11-09 ⋅ 阅读:(10) ⋅ 点赞:(0) ⋅ 评论:(0)

 🔎大家好,我是Sonhhxg_柒,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流🔎

 

📝个人主页-Sonhhxg_柒的博客_CSDN博客 📃

🎁欢迎各位→点赞👍 + 收藏⭐️ + 留言📝​

📣系列专栏 - 机器学习【ML】 自然语言处理【NLP】  深度学习【DL】

 🖍foreword

✔说明⇢本人讲解主要包括Python、机器学习(ML)、深度学习(DL)、自然语言处理(NLP)等内容。

如果你对这个系列感兴趣的话,可以关注订阅哟👋

文章目录

数据集

多语言Transformers

仔细研究标记化

标记器管道

SentencePiece 分词器

用于命名实体识别的转换器

Transformers 模型类的剖析

身体和头部

为令牌分类创建自定义模型

加载自定义模型

为 NER 标记文本

绩效指标

微调 XLM-RoBERTa

错误分析

跨语言转移

零镜头转移何时有意义?

一次微调多种语言

与模型小部件交互

结论


到目前为止,我们已经应用了转换器来解决英语语料库上的 NLP 任务——但是当你的文档是用希腊语、斯瓦希里语或克林贡语编写时,你会怎么做?一种方法是在 Hugging Face Hub 中搜索合适的预训练语言模型,并根据手头的任务对其进行微调。然而,这些预训练模型往往只存在于“高资源”语言,如德语、俄语或普通话,其中有大量的网络文本可用于预训练。当您的语料库是多语言时,另一个常见的挑战会出现:在生产中维护多个单语模型对您或您的工程团队来说不会有任何乐趣。

幸运的是,有一类多语言转换器可以提供帮助。与 BERT 一样,这些模型使用掩码语言建模作为预训练目标,但它们是在超过 100 种语言的文本上联合训练的。通过对跨多种语言的庞大语料库进行预训练,这些多语言转换器实现了零样本跨语言迁移。这意味着在一种语言上微调的模型无需任何进一步的培训即可应用于其他语言!这也使得这些模型非常适合“语码转换”,即说话者在单个对话的上下文中交替使用两种或多种语言或方言。

在本章中,我们将探讨如何微调称为 XLM-RoBERTa(第 3 章介绍)1的单个转换器模型,以跨多种语言执行命名实体识别(NER)。正如我们在第 1 章中看到的,NER 是一种常见的 NLP 任务,它可以识别文本中的人、组织或位置等实体。这些实体可用于各种应用程序,例如从公司文档中获取洞察力、提高搜索引擎的质量,或简单地从语料库构建结构化数据库。

对于本章,假设我们要为瑞士的客户执行 NER,那里有四种国家语言(英语通常充当它们之间的桥梁)。让我们从为这个问题获取合适的多语言语料库开始。

笔记

零样本迁移零样本学习通常是指在一组标签上训练模型,然后在另一组标签上对其进行评估的任务。在 Transformer 的上下文中,零样本学习也可能是指像 GPT-3 这样的语言模型在下游任务上进行评估的情况,甚至没有对其进行微调。

数据集

在本章中,我们将使用称为 WikiANN 或 PAN-X 的多语言编码器跨语言传输评估 (XTREME) 基准测试的子集。2该数据集包含多种语言的维基百科文章,包括瑞士最常用的四种语言:德语 (62.9%)、法语 (22.9%)、意大利语 (8.4%) 和英语 (5.9%)。每篇文章都以“inside-outside-beginning”(IOB2)格式LOC标注(location)、PER(person)和ORG(organization)标签 。在这种格式中,前缀表示实体的开始,属于同一实体的连续标记被赋予前缀。标记表示令牌不属于任何实体。例如,以下B-I-O句子:

Jeff Dean 是加州谷歌的计算机科学家

将被标记为 IOB2 格式,如表 4-1所示。

表 4-1。使用命名实体注释的序列示例

Tokens Jeff Dean is a computer scientist at Google in California
Tags B-PER I-PER O O O O O B-ORG O B-LOC

要在 XTREME 中加载 PAN-X 子集之一,我们需要知道要传递 函数的数据集配置。load_dataset()每当您处理具有多个域的数据集时,都可以使用该get_dataset_config_names()函数找出可用的子集:

from datasets import get_dataset_config_names

xtreme_subsets = get_dataset_config_names("xtreme")
print(f"XTREME has {len(xtreme_subsets)} configurations")
XTREME has 183 configurations

哇,这么多配置!让我们通过查找以“PAN”开头的配置来缩小搜索范围:

panx_subsets = [s for s in xtreme_subsets if s.startswith("PAN")]
panx_subsets[:3]
['PAN-X.af'、'PAN-X.ar'、'PAN-X.bg']

好的,看来我们已经确定了 PAN-X 子集的语法:每个子集都有一个两个字母的后缀,似乎是 ISO 639-1 语言代码。这意味着要加载德语语料库,我们将de代码传递给以下name参数load_dataset()

from datasets import load_dataset

load_dataset("xtreme", name="PAN-X.de")

为了制作真实的瑞士语料库,我们将根据口语比例从 PAN-X 中抽取德语 ( de)、法语 ( fr)、意大利语 ( it) 和英语 ( ) 语料库。en这将造成语言不平衡,这在现实世界的数据集中很常见,由于缺乏精通该语言的领域专家,获取少数语言的标记示例可能会很昂贵。这个不平衡的数据集将模拟处理多语言应用程序时的常见情况,我们将了解如何构建适用于所有语言的模型。

为了跟踪每种语言,让我们创建一个 Python defaultdict,将语言代码存储为键,并将类型的 PAN-X 语料库存储DatasetDict为值:

from collections import defaultdict
from datasets import DatasetDict

langs = ["de", "fr", "it", "en"]
fracs = [0.629, 0.229, 0.084, 0.059]
# Return a DatasetDict if a key doesn't exist
panx_ch = defaultdict(DatasetDict)

for lang, frac in zip(langs, fracs):
    # Load monolingual corpus
    ds = load_dataset("xtreme", name=f"PAN-X.{lang}")
    # Shuffle and downsample each split according to spoken proportion
    for split in ds:
        panx_ch[lang][split] = (
            ds[split]
            .shuffle(seed=0)
            .select(range(int(frac * ds[split].num_rows))))

在这里,我们使用该shuffle()方法来确保我们不会意外地偏向数据集拆分,同时select() 允许我们根据fracsDataset.num_rows让我们通过访问属性来看看我们在训练集中每种语言有多少示例:

import pandas as pd

pd.DataFrame({lang: [panx_ch[lang]["train"].num_rows] for lang in langs},
             index=["Number of training examples"])
de fr it en
Number of training examples 12580 4580 1680 1180

根据设计,我们的德语示例比所有其他语言的总和还要多,因此我们将使用它作为起点,从零样本跨语言迁移到法语、意大利语和英语。让我们检查一下德语语料库中的一个例子 :

element = panx_ch["de"]["train"][0]
for key, value in element.items():
    print(f"{key}: {value}")
langs: ['de', 'de', 'de', 'de', 'de', 'de', 'de', 'de', 'de', 'de', 'de', 'de']
ner_tags: [0, 0, 0, 0, 5, 6, 0, 0, 5, 5, 6, 0]
tokens: ['2.000', 'Einwohnern', 'an', 'der', 'Danziger', 'Bucht', 'in', 'der',
'polnischen', 'Woiwodschaft', 'Pommern', '.']

与我们之前遇到的Dataset对象一样,我们示例的键对应于箭头表的列名,而值表示每列中的条目。特别是,我们看到该 ner_tags列对应于每个实体到类 ID 的映射。这对人眼来说有点神秘,所以让我们用熟悉LOCPER、 和ORG标签创建一个新列。为此,首先要注意的是我们的Dataset对象有一个features 属性,该属性指定与每一列关联的基础数据类型:

for key, value in panx_ch["de"]["train"].features.items():
    print(f"{key}: {value}")
tokens: Sequence(feature=Value(dtype='string', id=None), length=-1, id=None)
ner_tags: Sequence(feature=ClassLabel(num_classes=7, names=['O', 'B-PER',
'I-PER', 'B-ORG', 'I-ORG', 'B-LOC', 'I-LOC'], names_file=None, id=None),
length=-1, id=None)
langs: Sequence(feature=Value(dtype='string', id=None), length=-1, id=None)

该类Sequence指定该字段包含一个特征列表,在这种情况下,它ner_tags对应于一个 ClassLabel特征列表。让我们从训练集中挑选出这个特征如下:

tags = panx_ch["de"]["train"].features["ner_tags"].feature
print(tags)
ClassLabel(num_classes=7, names=['O', 'B-PER', 'I-PER', 'B-ORG', 'I-ORG',
'B-LOC', 'I-LOC'], names_file=None, id=None)

我们可以使用第 2 章ClassLabel.int2str()中遇到的方法 在训练集中创建一个新列,其中包含每个标签的类名。我们将使用该map() 方法返回 一个dict,其键对应于新列名,值作为类名的 一个 list

def create_tag_names(batch):
    return {"ner_tags_str": [tags.int2str(idx) for idx in batch["ner_tags"]]}

panx_de = panx_ch["de"].map(create_tag_names)

现在我们有了人类可读格式的标签,让我们看看标记和标签如何与训练集中的第一个示例对齐:

de_example = panx_de["train"][0]
pd.DataFrame([de_example["tokens"], de_example["ner_tags_str"]],
['Tokens', 'Tags'])
0 1 2 3 4 5 6 7 8 9 10 11
Tokens 2.000 Einwohnern an der Danziger Bucht in der polnischen Woiwodschaft Pommern .
Tags O O O O B-LOC I-LOC O O B-LOC B-LOC I-LOC O

标签的存在是LOC有道理的,因为句子“2,000 Einwohnern an der Danziger Bucht in der polnischen Woiwodschaft Pommern”在英语中的意思是“波兰波美拉尼亚省格但斯克湾的 2,000 名居民”,而格但斯克湾是波罗的海的一个海湾海,而“voivodeship”对应于波兰的一个州。

作为快速检查我们在标签中没有任何异常不平衡的情况,让我们计算每个实体在每个拆分中的频率:

from collections import Counter

split2freqs = defaultdict(Counter)
for split, dataset in panx_de.items():
    for row in dataset["ner_tags_str"]:
        for tag in row:
            if tag.startswith("B"):
                tag_type = tag.split("-")[1]
                split2freqs[split][tag_type] += 1
pd.DataFrame.from_dict(split2freqs, orient="index")
ORG LOC PER
validation 2683 3172 2893
test 2573 3180 3071
train 5366 6186 5810

这看起来不错——每个拆分的PERLOCORG 频率分布大致相同,因此验证集和测试集应该可以很好地衡量我们的 NER 标注器的泛化能力。接下来,让我们看看一些流行的多语言转换器,以及它们如何适应我们的 NER 任务。

多语言Transformers

除了用于预训练的语料库由多种语言的文档组成之外,多语言转换器涉及与其单语对应物相似的架构和训练程序。这种方法的一个显着特征是,尽管没有接收到明确的信息来区分语言,但由此产生的语言表示能够很好地泛化各种 下游任务的语言。在某些情况下,这种执行跨语言迁移的能力可以产生与单语模型相媲美的结果,从而避免了对每种语言训练一个模型的需要!

为了衡量 NER 的跨语言迁移进度, CoNLL-2002和 CoNLL-2003数据集通常用作英语、荷兰语、西班牙语和德语的基准。该基准包括使用与 PAN-X 相同LOC的 PER、 和类别注释的新闻文章,但它包含不属于前三个组的杂项实体ORG的附加 标签。MISC多语言变压器模型通常以三种不同的方式进行评估:

en

微调英语训练数据,然后评估每种语言的测试集。

each

对单语测试数据进行微调和评估,以衡量每种语言的 性能。

all

微调所有训练数据,以评估每种语言测试集的所有数据。

我们将为我们的 NER 任务采用类似的评估策略,但首先我们需要选择一个模型进行评估。第一个多语言转换器之一是 mBERT,它使用与 BERT 相同的架构和预训练目标,但将来自多种语言的维基百科文章添加到预训练语料库中。从那时起,mBERT 就被 XLM-RoBERTa(或简称 XLM-R)所取代,这就是我们将在本章中考虑的模型。

正如我们在第 3 章中看到的,XLM-R 仅使用 MLM 作为 100 种语言的预训练目标,但其预训练语料库与其前辈相比具有巨大的规模:每种语言的 Wikipedia 转储和 2.5 TB的 Common Crawl 数据来自网络。这个语料库比早期模型中使用的语料库大几个数量级,并为缅甸语和斯瓦希里语等资源匮乏的语言提供了显着的信号提升,这些语言中只有少量的维基百科文章。

模型名称中的 RoBERTa 部分指的是预训练方法与单语 RoBERTa 模型相同。RoBERTa 的开发人员在 BERT 的几个方面进行了改进,特别是完全删除了下一句预测任务。3 XLM-R 还放弃了 XLM 中使用的语言嵌入,并使用 SentencePiece 直接对原始文本进行标记。4除了多语言特性外,XLM-R 和 RoBERTa 之间的显着区别在于各自词汇的大小:250,000 个标记对 55,000 个!

XLM-R 是多语言 NLU 任务的绝佳选择。在下一节中,我们将探讨它如何有效地跨多种语言进行标记。

仔细研究标记化

XLM-R 没有使用 WordPiece 分词器,而是使用了一个名为 SentencePiece 的分词器,它在所有一百种语言的原始文本上进行训练。为了了解 SentencePiece 与 WordPiece 的比较,让我们以通常的方式使用Transformers 加载 BERT 和 XLM-R 标记器:

from transformers import AutoTokenizer

bert_model_name = "bert-base-cased"
xlmr_model_name = "xlm-roberta-base"
bert_tokenizer = AutoTokenizer.from_pretrained(bert_model_name)
xlmr_tokenizer = AutoTokenizer.from_pretrained(xlmr_model_name)

通过编码一小段文本,我们还可以检索每个模型在预训练期间使用的特殊标记:

text = "Jack Sparrow loves New York!"
bert_tokens = bert_tokenizer(text).tokens()
xlmr_tokens = xlmr_tokenizer(text).tokens()
BERT [CLS] Jack Spa ##rrow loves New York ! [SEP] None
XLM-R <s> ▁Jack ▁Spar row ▁love s ▁New ▁York ! </s>

在这里,我们看到BERT 用于句子分类任务的不是[CLS]and标记,而是 XLM-R 使用and来表示序列的开始和结束。这些标记是在标记化的最后阶段添加的,我们将在接下来看到。[SEP]<s><\s>

标记器管道

到目前为止,我们已经将标记化视为将字符串转换为整数的单个操作,我们可以通过模型传递。这并不完全准确,如果我们仔细观察,我们会发现它实际上是一个完整的处理管道,通常由四个步骤组成,如图 4-1所示。

图 4-1。标记化管道中的步骤

让我们仔细看看每个处理步骤,并用例句“杰克斯派洛爱纽约!”来说明它们的效果:

正常化

此步骤对应于您应用于原始字符串以使其“更干净”的一组操作。常见操作包括去除空格和删除重音字符。Unicode 规范化是许多分词器应用的另一种常见的规范化操作,以处理经常存在不同方式来编写相同字符的事实。这会使“相同”字符串的两个版本(即具有相同的抽象字符序列)显得不同;像 NFC、NFD、NFKC 和 NFKD 等 Unicode 规范化方案用标准形式替换了编写相同字符的各种方法。规范化的另一个例子是小写。如果预计模型只接受和使用小写字符,则可以使用此技术来减少所需词汇的大小。标准化后,我们的示例字符串看起来像“杰克麻雀爱纽约!”。

预标记化

此步骤将文本拆分为较小的对象,这些对象为您的标记在训练结束时的上限提供了上限。考虑这一点的一个好方法是 pretokenizer 将您的文本拆分为“单词”,您的最终标记将成为这些单词的一部分。对于允许这样做的语言(英语、德语和许多印欧语系),字符串通常可以拆分为带有空格和标点符号的单词。例如,这一步可能会改变我们的 ["jack", "sparrow", "loves", "new", "york", "!"]. 然后这些词更容易分成子词在管道的下一步中使用字节对编码 (BPE) 或 Unigram 算法。然而,拆分成“单词”并不总是一种微不足道的确定性操作,甚至不是一个有意义的操作。例如,在中文、日文或韩文等语言中,对印欧语单词等语义单元中的符号进行分组可能是具有多个同等有效组的非确定性操作。在这种情况下,最好不要对文本进行预标记,而是使用特定于语言的库进行预标记。

分词器模型

一旦输入文本被规范化和预标记,标记器就会对单词应用子词拆分模型。这是需要在您的语料库上训练的管道部分(或者如果您使用的是预训练的标记器,则已经训练过)。该模型的作用是将词拆分为子词,以减小词汇量,并尽量减少词汇外标记的数量。存在几种子词标记化算法,包括 BPE、Unigram 和 WordPiece。例如,我们正在运行的示例可能看起来像[jack, spa, rrow, loves, new, york, !]应用了分词器模型之后的样子。请注意,此时我们不再有字符串列表,而是整数列表(输入 ID);为了保持示例说明性,我们保留了单词但删除了引号以指示转换。

后期处理

这是标记化管道的最后一步,其中可以对标记列表应用一些额外的转换——例如,在标记索引的输入序列的开头或结尾添加特殊标记。例如,BERT 风格的分词器会添加分类和分隔符: [CLS, jack, spa, rrow, loves, new, york, !, SEP]. 这个序列(回想一下,这将是一个整数序列,而不是您在此处看到的标记)然后可以馈送到模型中。

回到我们对 XLM-R 和 BERT 的比较,我们现在了解到 SentencePiece 在后处理步骤中添加了<s>and<\s>而不是[CLS]and [SEP](作为惯例,我们将继续在图形插图中使用[CLS]and )。[SEP]让我们回到 SentencePiece 分词器,看看它有什么特别之处。

SentencePiece 分词器

SentencePiece 分词器基于一种称为 Unigram的子词分割类型并将每个输入文本编码为 Unicode 字符序列。最后一个功能对于多语言语料库特别有用,因为它允许 SentencePiece 不知道口音、标点符号以及许多语言(如日语)没有空格字符的事实。SentencePiece 的另一个特点是空白被分配了 Unicode 符号 U+2581 或字符,也称为较低的四分之一块字符。这使 SentencePiece 能够在不产生歧义且不依赖于特定语言的预分词器的情况下对序列进行去分词。例如,在上一节的示例中,我们可以看到 WordPiece 丢失了“York”和“!”之间没有空格的信息。相比之下,

"".join(xlmr_tokens).replace(u"\u2581", " ")
'<s> Jack Sparrow loves New York!</s>'

现在我们了解了 SentencePiece 的工作原理,让我们看看如何将简单示例编码为适合 NER 的形式。首先要做的是加载带有标记分类头的预训练模型。但不是直接从 变形金刚加载这个头,我们将自己构建它!通过深入了解 Transformers API,我们只需几个步骤即可完成。

用于命名实体识别的转换器

第 2 章中,我们看到对于文本分类,BERT 使用特殊[CLS]标记来表示整个文本序列。然后将该表示通过全连接或密集层馈送,以输出所有离散标签值的分布,如图 4-2所示。

图 4-2。微调基于编码器的变压器以进行序列分类

BERT 和其他仅编码器的转换器对 NER 采取了类似的方法,除了每个单独输入令牌的表示被馈送到相同的全 连接层以输出令牌的实体。出于这个原因,NER通常被定义为一个token分类 任务。该过程类似于 图 4-3中的图表。

图 4-3。微调基于编码器的转换器以进行命名实体识别

到目前为止,一切都很好,但是我们应该如何处理标记分​​类任务中的子词呢?例如, 图 4-3中的名字“Christa”被标记为子词“Chr”和“##ista”,那么应该为哪个(s)分配B-PER标签?

在 BERT 论文中,5位作者将此标签分配给第一个子词(在我们的示例中为“Chr”)并忽略了以下子词(“##ista”)。这是我们将在这里采用的约定,我们将用IGN. 我们稍后可以在后处理步骤中轻松地将第一个子词的预测标签传播到后续子词。我们也可以选择包含“##ista”子词的表示,方法是为其分配 B-LOC标签的副本,但这违反了 IOB2 格式。

幸运的是,我们在 BERT 中看到的所有架构方面都继承了 XLM-R,因为它的架构基于 RoBERTa,与 BERT 相同!接下来,我们将看到 Transformers 如何通过微小的修改来支持许多其他任务。

Transformers 模型类的剖析

Transformers 围绕每个架构和任务的专用类进行组织。与不同任务关联的模型类根据 <ModelName>For<Task>约定或AutoModelFor<Task>在使用AutoModel类时命名。

但是,这种方法有其局限性,为了激发更深入地了解Transformers API,请考虑以下场景。假设你有一个很棒的想法来解决一个你想了很久的 NLP 问题,它使用了一个 Transformer 模型。因此,你与老板开会,并通过精心制作的 PowerPoint 演示文稿,宣传如果你最终能解决问题,你可以增加部门的收入。对你丰富多彩的演讲和利润的谈论印象深刻,你的老板慷慨地同意给你一周的时间来建立一个概念验证。对结果感到满意,您立即开始工作。你启动你的 GPU 并打开一个笔记本。你执行 from transformers import BertForTaskXY(注意TaskXY是您想要解决的虚构任务),当可怕的红色充满您的屏幕时,颜色会从您的脸上消失: 。哦,不,您的用例没有 BERT 模型!如果你必须自己实现整个模型,你怎么能在一周内完成这个项目?!你甚至应该从哪里开始?ImportEr⁠ror:​ can⁠not import name BertForTaskXY

不要恐慌! Transformers 旨在使您能够轻松地为特定用例扩展现有模型。您可以从预训练模型加载权重,并且可以访问特定于任务的辅助函数。这使您可以以很少的开销为特定目标构建自定义模型。在本节中,我们将了解如何实现我们自己的自定义模型。

身体和头部

使Transformer 如此通用的主要概念是将架构拆分为主体头部(正如我们在第 1 章 中看到的那样)。我们已经看到,当我们从预训练任务切换到下游任务时,我们需要将模型的最后一层替换为适合该任务的层。最后一层称为模型头;这是特定于任务的部分。模型的其余部分称为主体;它包括与任务无关的令牌嵌入和转换器层。这种结构也反映在 Transformers 代码中:模型的主体在一个类中实现,BertModel例如GPT2Model返回最后一层的隐藏状态。任务特定的模型,例如BertForMaskedLMBertForSequenceClassification使用基本模型并在隐藏状态之上添加必要的头部,如图 4-4所示。

图 4-4。该类BertModel仅包含模型的主体,而这些BertFor<Task>类将主体与给定任务的专用头部结合在一起

正如我们接下来将看到的,这种身体和头部的分离允许我们为任何任务构建一个自定义头部,并将其安装在预训练模型的顶部。

为令牌分类创建自定义模型

让我们完成为 XLM-R 构建自定义令牌分类头的练习。由于 XLM-R 使用与 RoBERTa 相同的模型架构,我们将使用 RoBERTa 作为基本模型,但会增加特定于 XLM-R 的设置。请注意,这是一个教育练习,向您展示如何为您自己的任务构建自定义模型。对于令牌分类,XLMRobertaForTokenClassification 已经存在一个可以从 

Transformers 导入的类。如果需要,您可以跳到下一部分并简单地使用该部分。

首先,我们需要一个数据结构来表示我们的 XLM-R NER 标记器。作为第一个猜测,我们需要一个配置对象来初始化模型和一个forward()函数来生成输出。让我们继续构建用于令牌分类的 XLM-R 类:

import torch.nn as nn
from transformers import XLMRobertaConfig
from transformers.modeling_outputs import TokenClassifierOutput
from transformers.models.roberta.modeling_roberta import RobertaModel
from transformers.models.roberta.modeling_roberta import RobertaPreTrainedModel

class XLMRobertaForTokenClassification(RobertaPreTrainedModel):
    config_class = XLMRobertaConfig

    def __init__(self, config):
        super().__init__(config)
        self.num_labels = config.num_labels
        # Load model body
        self.roberta = RobertaModel(config, add_pooling_layer=False)
        # Set up token classification head
        self.dropout = nn.Dropout(config.hidden_dropout_prob)
        self.classifier = nn.Linear(config.hidden_size, config.num_labels)
        # Load and initialize weights
        self.init_weights()

    def forward(self, input_ids=None, attention_mask=None, token_type_ids=None,
                labels=None, **kwargs):
        # Use model body to get encoder representations
        outputs = self.roberta(input_ids, attention_mask=attention_mask,
                               token_type_ids=token_type_ids, **kwargs)
        # Apply classifier to encoder representation
        sequence_output = self.dropout(outputs[0])
        logits = self.classifier(sequence_output)
        # Calculate losses
        loss = None
        if labels is not None:
            loss_fct = nn.CrossEntropyLoss()
            loss = loss_fct(logits.view(-1, self.num_labels), labels.view(-1))
        # Return model output object
        return TokenClassifierOutput(loss=loss, logits=logits,
                                     hidden_states=outputs.hidden_states,
                                     attentions=outputs.attentions)

config_class可确保在我们初始化新模型时使用标准 XLM-R 设置。如果要更改默认参数,可以通过覆盖配置中的默认设置来实现。用super()方法我们调用RobertaPreTrainedModel类的初始化函数。这个抽象类处理预训练权重的初始化或加载。然后我们加载我们的模型主体,即RobertaModel,并使用我们自己的分类头扩展它,该分类头由一个 dropout 和一个标准的前馈层组成。请注意,我们设置add_​pool⁠ing_layer=False为确保返回所有隐藏状态,而不仅仅是与[CLS] 令牌关联的状态。最后,我们通过调用 init_weights()我们继承的方法来初始化所有的权重RobertaPreTrainedModel,它将加载模型主体的预训练权重并随机初始化我们的标记分类头的权重。

剩下要做的就是用一个forward()方法定义模型在前向传递中应该做什么。在前向传递期间,数据首先通过模型主体馈送。有许多输入变量,但我们现在唯一需要的是input_ids和 attention_mask。隐藏状态是模型主体输出的一部分,然后通过 dropout 和分类层进行馈送。如果我们在前向传递中也提供标签,我们可以直接计算损失。如果有注意力掩码,我们需要做更多的工作来确保我们只计算未掩码令牌的损失。最后,我们将所有输出包装在一个TokenClassifierOutput对象中,该对象允许我们访问前面章节中熟悉的命名元组中的元素。

只需实现一个简单类的两个功能,我们就可以构建自己的自定义转换器模型。而且由于我们从 a 继承 PreTrainedModel,我们可以立即访问所有有用的 Transformer 实用程序,例如 from_pretrained()!让我们看看如何将预训练的权重加载到我们的自定义模型中。

加载自定义模型

现在我们准备加载我们的令牌分类模型。除了模型名称之外,我们还需要提供一些额外的信息,包括我们将用来标记每个实体的标签以及每个标签到 ID 的映射,反之亦然。所有这些信息都可以从我们的tags变量中派生,作为一个ClassLabel对象names,我们可以使用该属性来派生映射:

index2tag = {idx: tag for idx, tag in enumerate(tags.names)}
tag2index = {tag: idx for idx, tag in enumerate(tags.names)}

我们会将这些映射和tags.num_classes 属性存储在我们在第 3 章AutoConfig中遇到的对象中。将关键字参数传递给from_pretrained()方法会覆盖默认值:

from transformers import AutoConfig

xlmr_config = AutoConfig.from_pretrained(xlmr_model_name,
                                         num_labels=tags.num_classes,
                                         id2label=index2tag, label2id=tag2index)

该类AutoConfig包含模型架构的蓝图。当我们使用 加载模型时 ,与该模型关联的配置文件会自动下载。但是,如果我们想修改类的数量或标签名称等内容,那么我们可以首先使用我们想要自定义的参数加载配置。AutoModel.from_pretrained(model_ckpt)

from_pretrained() 现在,我们可以像往常一样使用带有附加config参数的函数加载模型权重。请注意,我们没有在自定义模型类中实现加载预训练权重;我们通过继承来免费获得RobertaPreTrainedModel

import torch

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
xlmr_model = (XLMRobertaForTokenClassification
              .from_pretrained(xlmr_model_name, config=xlmr_config)
              .to(device))

作为快速检查我们是否正确初始化了标记器和模型,让我们测试我们对已知实体的小序列的预测:

input_ids = xlmr_tokenizer.encode(text, return_tensors="pt")
pd.DataFrame([xlmr_tokens, input_ids[0].numpy()], index=["Tokens", "Input IDs"])
0 1 2 3 4 5 6 7 8 9
Tokens <s> ▁Jack ▁Spar ▁love s ▁New ▁York ! </s>
Input IDs 0 21763 37456 15555 5161 7 2356 5753 38 2

正如您在此处看到的,开始<s>和结束</s>标记的 ID 分别为0 和 2 。

最后,我们需要将输入传递给模型并通过获取 argmax 来提取预测,以获得每个令牌最可能的类别:

outputs = xlmr_model(input_ids.to(device)).logits
predictions = torch.argmax(outputs, dim=-1)
print(f"Number of tokens in sequence: {len(xlmr_tokens)}")
print(f"Shape of outputs: {outputs.shape}")
Number of tokens in sequence: 10
Shape of outputs: torch.Size([1, 10, 7])

在这里,我们看到 logits 的形状 [batch_size, num_tokens, num_tags]为 ,每个标记在七个可能的 NER 标记中都有一个 logit。通过枚举序列,我们可以快速查看预训练模型预测的内容:

preds = [tags.names[p] for p in predictions[0].cpu().numpy()]
pd.DataFrame([xlmr_tokens, preds], index=["Tokens", "Tags"])
0 1 2 3 4 5 6 7 8 9
Tokens <s> ▁Jack ▁Spar row ▁love s ▁New ▁York ! </s>
Tags O I-LOC B-LOC B-LOC O I-LOC O O I-LOC B-LOC

不出所料,我们具有随机权重的令牌分类层还有很多不足之处;让我们微调一些标记数据以使其更好!在这样做之前,让我们将前面的步骤包装成一个辅助函数以供以后使用:

def tag_text(text, tags, model, tokenizer):
    # Get tokens with special characters
    tokens = tokenizer(text).tokens()
    # Encode the sequence into IDs
    input_ids = xlmr_tokenizer(text, return_tensors="pt").input_ids.to(device)
    # Get predictions as distribution over 7 possible classes
    outputs = model(input_ids)[0]
    # Take argmax to get most likely class per token
    predictions = torch.argmax(outputs, dim=2)
    # Convert to DataFrame
    preds = [tags.names[p] for p in predictions[0].cpu().numpy()]
    return pd.DataFrame([tokens, preds], index=["Tokens", "Tags"])

在我们训练模型之前,我们还需要对输入进行标记并准备标签。我们接下来会这样做。

为 NER 标记文本

现在我们已经确定分词器和模型可以对单个示例进行编码,下一步是对整个数据集进行分词,以便我们可以将其传递给 XLM-R 模型进行微调。正如我们在 第 2 章中看到的,数据集提供了一种使用操作标记对象 Dataset 的快速方法map()。为了实现这一点,回想一下,我们首先需要定义一个具有最小签名的函数:

function(examples: Dict[str, List]) -> Dict[str, List]

其中examples相当于 a 的一个切片Dataset,例如 panx_de['train'][:10]. 由于 XLM-R 分词器返回模型输入的输入 ID,我们只需要使用注意掩码和标签 ID 来扩充此信息,标签 ID 对有关与每个 NER 标签关联的令牌的信息进行编码。

按照 Transformers 文档中采用的方法,让我们首先将单词和标签收集为普通列表,看看它是如何与我们的单个德语示例一起工作的:

words, labels = de_example["tokens"], de_example["ner_tags"]

接下来,我们标记每个单词并使用is_split_into_words参数告诉标记器我们的输入序列已经被分割成单词:

tokenized_input = xlmr_tokenizer(de_example["tokens"], is_split_into_words=True)
tokens = xlmr_tokenizer.convert_ids_to_tokens(tokenized_input["input_ids"])
pd.DataFrame([tokens], index=["Tokens"])
0 1 2 3 4 5 6 ... 18 19 20 21 22 23 24
Tokens <s> ▁2.000 ▁Einwohner n ▁an ▁der ▁Dan ... schaft ▁Po mmer n . </s>

在这个例子中,我们可以看到分词器将“Einwohnern”拆分为两个子词,“Einwohner”和“n”。由于我们遵循只有“Einwohner”应该与B-LOC标签相关联的约定,我们需要一种方法来掩盖第一个子词之后的子词表示。幸运的是,tokenized_input 是一个包含一个word_ids()函数的类,可以帮助我们实现这一点:

word_ids = tokenized_input.word_ids()
pd.DataFrame([tokens, word_ids], index=["Tokens", "Word IDs"])
0 1 2 3 4 5 6 ... 18 19 20 21 22 23 24
Tokens <s> ▁2.000 ▁Einwohner n ▁an ▁der ▁Dan ... schaft ▁Po mmer n . </s>
Word IDs None 0 1 1 2 3 4 ... 9 10 10 10 11 11 None

这里我们可以看到word_ids已经将每个子词映射到序列中对应的索引words,所以第一个子词“2.000”被分配索引0,而“Einwohner”和“n”被分配索引1(因为“ Einwohnern”是 words) 中的第二个词。我们还可以看到像<s>和之类的特殊标记<\s>被映射到None. 让我们将 –100 设置为这些特殊标记和我们希望在训练期间屏蔽的子词的标签:

previous_word_idx = None
label_ids = []

for word_idx in word_ids:
    if word_idx is None or word_idx == previous_word_idx:
        label_ids.append(-100)
    elif word_idx != previous_word_idx:
        label_ids.append(labels[word_idx])
    previous_word_idx = word_idx

labels = [index2tag[l] if l != -100 else "IGN" for l in label_ids]
index = ["Tokens", "Word IDs", "Label IDs", "Labels"]

pd.DataFrame([tokens, word_ids, label_ids, labels], index=index)
0 1 2 3 4 5 ... 19 20 21 22 23 24
Tokens <s> ▁2.000 ▁Einwohner n ▁an ▁der ... ▁Po mmer n . </s>
Word IDs None 0 1 1 2 3 ... 10 10 10 11 11 None
Label IDs -100 0 0 -100 0 0 ... 6 -100 -100 0 -100 -100
Label  IGN IGN ... I-LOC IGN IGN IGN IGN

笔记

为什么我们选择 –100 作为 ID 来屏蔽子词表示?原因是在 PyTorch 中,交叉熵损失类torch.nn.CrossEntropyLoss有一个名为的属性ignore_index,其值为 –100。该索引在训练期间被忽略,因此我们可以使用它来忽略与连续子词相关的 标记。

就是这样!我们可以清楚地看到标签 ID 如何与标记对齐,因此让我们通过定义一个包含所有逻辑的函数来将其扩展到整个数据集:

def tokenize_and_align_labels(examples):
    tokenized_inputs = xlmr_tokenizer(examples["tokens"], truncation=True,
                                      is_split_into_words=True)
    labels = []
    for idx, label in enumerate(examples["ner_tags"]):
        word_ids = tokenized_inputs.word_ids(batch_index=idx)
        previous_word_idx = None
        label_ids = []
        for word_idx in word_ids:
            if word_idx is None or word_idx == previous_word_idx:
                label_ids.append(-100)
            else:
                label_ids.append(label[word_idx])
            previous_word_idx = word_idx
        labels.append(label_ids)
    tokenized_inputs["labels"] = labels
    return tokenized_inputs

我们现在拥有了对每个拆分进行编码所需的所有成分,所以让我们编写一个可以迭代的函数:

def encode_panx_dataset(corpus):
    return corpus.map(tokenize_and_align_labels, batched=True,
                      remove_columns=['langs', 'ner_tags', 'tokens'])

通过将此函数应用于对象,我们每次拆分DatasetDict都会得到一个编码 对象。Dataset让我们用它来编码我们的德语语料库:

panx_de_encoded = encode_panx_dataset(panx_ch["de"])

现在我们有了一个模型和一个数据集,我们需要定义一个性能指标。

绩效指标

评估 NER 模型类似于评估文本分类模型,通常报告精度、召回率和 1分数的结果。唯一的微妙之处是,一个实体的所有单词都需要被正确预测,才能使预测被视为正确。幸运的是,有一个名为 seqeval的漂亮库专为此类任务而设计。例如,给定一些占位符 NER 标签和模型预测,我们可以通过 seqeval 的classification_report()函数计算指标:

from seqeval.metrics import classification_report

y_true = [["O", "O", "O", "B-MISC", "I-MISC", "I-MISC", "O"],
          ["B-PER", "I-PER", "O"]]
y_pred = [["O", "O", "B-MISC", "I-MISC", "I-MISC", "I-MISC", "O"],
          ["B-PER", "I-PER", "O"]]
print(classification_report(y_true, y_pred))
              precision    recall  f1-score   support

        MISC       0.00      0.00      0.00         1
         PER       1.00      1.00      1.00         1

   micro avg       0.50      0.50      0.50         2
   macro avg       0.50      0.50      0.50         2
weighted avg       0.50      0.50      0.50         2

正如我们所看到的,seqeval期望预测和标签作为列表的列表,每个列表对应于我们验证或测试集中的单个示例。为了在训练期间整合这些指标,我们需要一个函数来获取模型的输出并将它们转换为seqeval期望的列表。下面通过确保我们忽略与后续子词关联的标签 ID 来解决问题:

import numpy as np

def align_predictions(predictions, label_ids):
    preds = np.argmax(predictions, axis=2)
    batch_size, seq_len = preds.shape
    labels_list, preds_list = [], []

    for batch_idx in range(batch_size):
        example_labels, example_preds = [], []
        for seq_idx in range(seq_len):
            # Ignore label IDs = -100
            if label_ids[batch_idx, seq_idx] != -100:
                example_labels.append(index2tag[label_ids[batch_idx][seq_idx]])
                example_preds.append(index2tag[preds[batch_idx][seq_idx]])

        labels_list.append(example_labels)
        preds_list.append(example_preds)

    return preds_list, labels_list

配备性能指标后,我们可以继续实际训练模型。

微调 XLM-RoBERTa

我们现在拥有微调模型的所有要素!我们的第一个策略是在 PAN-X 的德语子集上微调我们的基础模型,然后评估其在法语、意大利语和英语上的零样本跨语言性能。像往常一样,我们将使用 

TransformerTrainer来处理我们的训练循环,所以首先我们需要使用TrainingArguments类定义训练属性:

from transformers import TrainingArguments

num_epochs = 3
batch_size = 24
logging_steps = len(panx_de_encoded["train"]) // batch_size
model_name = f"{xlmr_model_name}-finetuned-panx-de"
training_args = TrainingArguments(
    output_dir=model_name, log_level="error", num_train_epochs=num_epochs,
    per_device_train_batch_size=batch_size,
    per_device_eval_batch_size=batch_size, evaluation_strategy="epoch",
    save_steps=1e6, weight_decay=0.01, disable_tqdm=False,
    logging_steps=logging_steps, push_to_hub=True)

在这里,我们在每个 epoch 结束时评估模型对验证集的预测,调整权重衰减,并设置 save_steps为较大的数字以禁用检查点,从而加快训练速度。

这也是确保我们登录到 Hugging Face Hub 的一个好点(如果您在终端中工作,则可以huggingface-cli login改为执行命令):

from huggingface_hub import notebook_login

notebook_login()

我们还需要告诉Trainer如何计算验证集上的指标,所以在这里我们可以使用align_predictions()我们之前定义的函数来提取seqeval所需格式的预测和标签来计算1 -score:

from seqeval.metrics import f1_score

def compute_metrics(eval_pred):
    y_pred, y_true = align_predictions(eval_pred.predictions,
                                       eval_pred.label_ids)
    return {"f1": f1_score(y_true, y_pred)}

最后一步是定义一个数据整理器,以便我们可以将每个输入序列填充到批处理中的最大序列长度。 Transformers 为令牌分类提供了一个专用的数据整理器,它将与输入一起填充标签:

from transformers import DataCollatorForTokenClassification

data_collator = DataCollatorForTokenClassification(xlmr_tokenizer)

填充标签是必要的,因为与文本分类任务不同,标签也是序列。这里的一个重要细节是标签序列填充了值 –100,正如我们所见,PyTorch 损失函数会忽略该值。

我们将在本章的课程中训练几个模型,因此我们将避免Trainer通过创建一个model_init()方法来为每个模型初始化一个新模型。此方法加载未经训练的模型并在调用开始时train()调用:

def model_init():
    return (XLMRobertaForTokenClassification
            .from_pretrained(xlmr_model_name, config=xlmr_config)
            .to(device))

我们现在可以将所有这些信息与编码数据集一起传递给Trainer

from transformers import Trainer

trainer = Trainer(model_init=model_init, args=training_args,
                  data_collator=data_collator, compute_metrics=compute_metrics,
                  train_dataset=panx_de_encoded["train"],
                  eval_dataset=panx_de_encoded["validation"],
                  tokenizer=xlmr_tokenizer)

然后按如下方式运行训练循环并将最终模型推送到 Hub:

trainer.train() trainer.push_to_hub(commit_message="Training completed!")
Epoch Training Loss Validation Loss F1
1 0.2652 0.160244 0.822974
2 0.1314 0.137195 0.852747
3 0.0806 0.138774 0.864591

这些 F1 分数对于 NER 模型来说非常好。为了确认我们的模型按预期工作,让我们在简单示例的德语翻译上对其进行测试:

text_de = "Jeff Dean ist ein Informatiker bei Google in Kalifornien"
tag_text(text_de, tags, trainer.model, xlmr_tokenizer)
0 1 2 3 4 5 ... 8 9 10 11 12 13
Tokens <s> ▁Jeff ▁De an ▁ist ▁ein ... ▁bei ▁Google ▁in ▁Kaliforni en </s>
Tags O B-PER I-PER I-PER O O ... O B-ORG O B-LOC I-LOC O

有用!但是我们永远不应该对基于单个示例的性能过于自信。相反,我们应该对模型的错误进行适当而彻底的调查。在下一节中,我们将探讨如何为 NER 任务执行此操作。

错误分析

在我们深入研究 XLM-R 的多语言方面之前,让我们花一点时间来调查一下我们模型的错误。正如我们在第 2 章中看到的,对模型进行彻底的错误分析是训练和调试转换器(以及一般的机器学习模型)时最重要的方面之一。有几种故障模式看起来模型表现良好,但在实践中它有一些严重的缺陷。训练可能失败的例子包括:

  • 我们可能不小心掩盖了太多的令牌,也掩盖了我们的一些标签,以获得真正有希望的损失下降。

  • compute_metrics()函数可能存在高估真实性能的错误。

  • 我们可能会将 NER 中的零类或O实体作为普通类包括在内,这将严重扭曲准确性和1 -score,因为它是多数类。

当模型的性能比预期的差很多时,查看错误可以产生有用的见解并揭示仅通过查看代码很难发现的错误。而且即使模型表现良好并且代码中没有错误,错误分析仍然是了解模型优缺点的有用工具。这些是我们在生产环境中部署模型时始终需要牢记的方面。

对于我们的分析,我们将再次使用我们可以使用的最强大的工具之一,即查看具有最高损失的验证示例。我们可以重用我们在第 2 章中构建的用于分析序列分类模型的大部分函数,​​但现在我们将计算样本序列中每个标记的损失。

让我们定义一个可以应用于验证集的方法:

from torch.nn.functional import cross_entropy

def forward_pass_with_label(batch):
    # Convert dict of lists to list of dicts suitable for data collator
    features = [dict(zip(batch, t)) for t in zip(*batch.values())]
    # Pad inputs and labels and put all tensors on device
    batch = data_collator(features)
    input_ids = batch["input_ids"].to(device)
    attention_mask = batch["attention_mask"].to(device)
    labels = batch["labels"].to(device)
    with torch.no_grad():
        # Pass data through model
        output = trainer.model(input_ids, attention_mask)
        # logit.size: [batch_size, sequence_length, classes]
        # Predict class with largest logit value on classes axis
        predicted_label = torch.argmax(output.logits, axis=-1).cpu().numpy()
    # Calculate loss per token after flattening batch dimension with view
    loss = cross_entropy(output.logits.view(-1, 7),
                         labels.view(-1), reduction="none")
    # Unflatten batch dimension and convert to numpy array
    loss = loss.view(len(input_ids), -1).cpu().numpy()

    return {"loss":loss, "predicted_label": predicted_label}

我们现在可以将此函数应用于整个验证集,map() 并将所有数据加载到 aDataFrame中以进行进一步分析:

valid_set = panx_de_encoded["validation"]
valid_set = valid_set.map(forward_pass_with_label, batched=True, batch_size=32)
df = valid_set.to_pandas()

标记和标签仍然使用它们的 ID 进行编码,所以让我们将标记和标签映射回字符串,以便更容易阅读结果。对于标签为 –100 的填充标记,我们分配一个特殊标签IGN,以便我们稍后过滤它们。我们还通过将它们截断为输入的长度来消除losspredicted_label字段中的所有填充:

index2tag[-100] = "IGN"
df["input_tokens"] = df["input_ids"].apply(
    lambda x: xlmr_tokenizer.convert_ids_to_tokens(x))
df["predicted_label"] = df["predicted_label"].apply(
    lambda x: [index2tag[i] for i in x])
df["labels"] = df["labels"].apply(
    lambda x: [index2tag[i] for i in x])
df['loss'] = df.apply(
    lambda x: x['loss'][:len(x['input_ids'])], axis=1)
df['predicted_label'] = df.apply(
    lambda x: x['predicted_label'][:len(x['input_ids'])], axis=1)
df.head(1)
attention_mask input_ids labels loss predicted_label input_tokens
0 [1, 1, 1, 1, 1, 1, 1] [0, 10699, 11, 15, 16104, 1388, 2] [IGN,B-ORG,IGN,I-ORG,I-ORG,I-ORG,IGN] [0.0, 0.014679872, 0.0, 0.009469474, 0.010393422, 0.01293836, 0.0] [I-ORG,B-ORG,I-ORG,I-ORG,I-ORG,I-ORG,I-ORG] [<s>, Ham, a, (, Unternehmen, ), </s>]

每列包含每个样本的标记、标签、预测标签等列表。让我们通过解包这些列表来单独查看令牌。该pan⁠das.Series.explode() 函数允许我们通过为原始行列表中的每个元素创建一行来在一行中完成此操作。由于一行中的所有列表都具有相同的长度,因此我们可以对所有列并行执行此操作。我们还删除了我们命名的填充标记IGN,因为它们的损失无论如何都是零。最后,我们将仍然是numpy.Array 对象的损失转换为标准浮点数:

df_tokens = df.apply(pd.Series.explode)
df_tokens = df_tokens.query("labels != 'IGN'")
df_tokens["loss"] = df_tokens["loss"].astype(float).round(2)
df_tokens.head(7)
attention_mask input_ids labels loss predicted_label input_tokens
1 10699 B-ORG 0.01 B-ORG ▁Ham
1 15 I-ORG 0.01 I-ORG ▁(
1 16104 I-ORG 0.01 I-ORG ▁Unternehmen
1 1388 I-ORG 0.01 I-ORG ▁)
1 56530 O 0.00 O ▁WE
1 83982 B-ORG 0.34 B-ORG ▁Luz
1 10 I-ORG 0.45 I-ORG ▁a

有了这种形状的数据,我们现在可以按输入标记对其进行分组,并将每个标记的损失与计数、均值和总和进行汇总。最后,我们按损失的总和对聚合数据进行排序, 并查看哪些令牌在验证集中累积的损失最多:

(
    df_tokens.groupby("input_tokens")[["loss"]]
    .agg(["count", "mean", "sum"])
    .droplevel(level=0, axis=1)  # Get rid of multi-level columns
    .sort_values(by="sum", ascending=False)
    .reset_index()
    .round(2)
    .head(10)
    .T
)
0 1 2 3 4 5 6 7 8 9
input_tokens ▁der ▁in ▁von ▁/ ▁und ▁( ▁) ▁'' ▁A
count 6066 1388 989 808 163 1171 246 246 2898 125
mean 0.03 0.1 0.14 0.14 0.64 0.08 0.3 0.29 0.02 0.44
sum 200.71 138.05 137.33 114.92 104.28 99.15 74.49 72.35 59.31 54.48

我们可以在此列表中观察到几种模式:

  • 空白标记的总损失最高,这并不奇怪,因为它也是列表中最常见的标记。但是,它的平均损失远低于列表中的其他代币。这意味着模型不会费力地对其进行分类。

  • “in”、“von”、“der”和“und”等词出现的频率相对较高。它们经常与命名实体一起出现,有时是它们的一部分,这解释了为什么模型可能会将它们混合在一起。

  • 单词开头的括号、斜杠和大写字母比较少见,但平均损失相对较高。我们将进一步调查它们。

我们还可以对标签 ID 进行分组并查看每个类别的损失:

(
    df_tokens.groupby("labels")[["loss"]]
    .agg(["count", "mean", "sum"])
    .droplevel(level=0, axis=1)
    .sort_values(by="mean", ascending=False)
    .reset_index()
    .round(2)
    .T
)
0 1 2 3 4 5 6
labels B-ORG I-LOC I-ORG B-LOC B-PER I-PER O
count 2683 1462 3820 3172 2893 4139 43648
mean 0.66 0.64 0.48 0.35 0.26 0.18 0.03
sum 1769.47 930.94 1850.39 1111.03 760.56 750.91 1354.46

我们看到B⁠-⁠ORG平均损失最高,这意味着确定组织的开始对我们的模型构成挑战。

我们可以通过绘制标记分类的混淆矩阵来进一步分解这一点,我们看到一个组织的开始经常与随后的I-ORG标记混淆:

from sklearn.metrics import ConfusionMatrixDisplay, confusion_matrix

def plot_confusion_matrix(y_preds, y_true, labels):
    cm = confusion_matrix(y_true, y_preds, normalize="true")
    fig, ax = plt.subplots(figsize=(6, 6))
    disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=labels)
    disp.plot(cmap="Blues", values_format=".2f", ax=ax, colorbar=False)
    plt.title("Normalized confusion matrix")
    plt.show()
plot_confusion_matrix(df_tokens["labels"], df_tokens["predicted_label"],
                      tags.names)

从图中可以看出,我们的模型最容易混淆B-ORGI-ORG实体。否则,它非常擅长对剩余实体进行分类,这可以从混淆矩阵的近对角线性质中清楚地看出。

现在我们已经检查了令牌级别的错误,让我们继续看看具有高损失的序列。对于这个计算,我们将重新审视我们的“未爆炸” DataFrame,并通过对每个令牌的损失求和来计算总损失。为此,让我们首先编写一个函数来帮助我们显示带有标签和损失的标记序列:

def get_samples(df):
    for _, row in df.iterrows():
        labels, preds, tokens, losses = [], [], [], []
        for i, mask in enumerate(row["attention_mask"]):
            if i not in {0, len(row["attention_mask"])}:
                labels.append(row["labels"][i])
                preds.append(row["predicted_label"][i])
                tokens.append(row["input_tokens"][i])
                losses.append(f"{row['loss'][i]:.2f}")
        df_tmp = pd.DataFrame({"tokens": tokens, "labels": labels,
                               "preds": preds, "losses": losses}).T
        yield df_tmp

df["total_loss"] = df["loss"].apply(sum)
df_tmp = df.sort_values(by="total_loss", ascending=False).head(3)

for sample in get_samples(df_tmp):
    display(sample)
0 1 2 3 4 ... 13 14 15 16 17
tokens ▁'' 8 . ▁Juli ▁'' ... n ischen ▁Gar de </s>
labels B-ORG IGN IGN I-ORG I-ORG ... IGN IGN I-ORG IGN IGN
preds O O O O O ... I-ORG I-ORG I-ORG I-ORG O
losses 7.89 0.00 0.00 6.88 8.05 ... 0.00 0.00 0.01 0.00 0.00
0 1 2 3 4 ... 14 15 16 17 18
tokens ▁' ▁'' ▁Τ Κ ▁'' ... k ▁'' ▁' ala </s>
labels O O O IGN O ... IGN I-LOC I-LOC IGN IGN
preds O O B-ORG O O ... O O O O O
losses 0.00 0.00 3.59 0.00 0.00 ... 0.00 7.66 7.78 0.00 0.00
0 1 2 3 4 ... 10 11 12 13 14
tokens ▁United ▁Nations ▁Multi dimensional ▁Integra ... ▁the ▁Central ▁African ▁Republic </s>
labels B-PER I-PER I-PER IGN I-PER ... I-PER I-PER I-PER I-PER IGN
preds B-ORG I-ORG I-ORG I-ORG I-ORG ... I-ORG I-ORG I-ORG I-ORG I-ORG
losses 6.46 5.59 5.51 0.00 5.11 ... 4.77 5.32 5.10 4.87 0.00
 

很明显,这些样本的标签有问题;比如联合国和中非共和国都被贴上了一个人的标签!同时,“8. Juli”在第一个例子中被标记为一个组织。事实证明,PAN-X 数据集的注释是通过自动化过程生成的。此类注释通常被称为“银标准”(与人工生成注释的“黄金标准”相反),并且在某些情况下自动化方法无法生成合理的标签也就不足为奇了。事实上,这种故障模式并不是自动方法所独有的。即使人类仔细注释数据,当注释者的注意力减弱或他们只是误解了句子时,也会发生错误。

我们之前注意到的另一件事是括号和斜线的损失相对较高。让我们看几个带左括号的序列示例:

df_tmp = df.loc[df["input_tokens"].apply(lambda x: u"\u2581(" in x)].head(2)
for sample in get_samples(df_tmp):
    display(sample)

  
0 1 2 3 4 5
tokens ▁Ham a ▁( ▁Unternehmen ▁) </s>
labels B-ORG IGN I-ORG I-ORG I-ORG IGN
preds B-ORG I-ORG I-ORG I-ORG I-ORG I-ORG
losses 0.01 0.00 0.01 0.01 0.01 0.00
0 1 2 3 4 5 6 7
tokens ▁Kesk kül a ▁( ▁Mart na ▁) </s>
labels B-LOC IGN IGN I-LOC I-LOC IGN I-LOC IGN
preds B-LOC I-LOC I-LOC I-LOC I-LOC I-LOC I-LOC I-LOC
losses 0.02 0.00 0.00 0.01 0.01 0.00 0.01 0.00

一般来说,我们不会将括号及其内容作为命名实体的一部分包含在内,但这似乎是自动提取注释文档的方式。在其他示例中,括号包含地理规范。虽然这确实也是一个位置,但我们可能希望将其与注释中的原始位置断开。该数据集由不同语言的维基百科文章组成,文章标题通常在括号中包含某种解释。例如,在第一个示例中,括号中的文本表明 Hama 是一家“Unternehmen”或英文公司。这些是我们在推出模型时需要了解的重要细节,因为它们可能会对模型所属的整个管道的下游性能产生影响。

通过相对简单的分析,我们发现了我们的模型和数据集中的一些弱点。在实际用例中,我们将迭代这一步,清理数据集,重新训练模型,并分析新的错误,直到我们对性能感到满意。

在这里,我们分析了单一语言的错误,但我们也对跨语言的性能感兴趣。在下一节中,我们将进行一些实验,以了解 XLM-R 中的跨语言迁移效果如何。

跨语言转移

现在我们已经在德语上对 XLM-R 进行了微调,我们可以predict()通过 Trainer. 由于我们计划评估多种语言,让我们创建一个简单的函数来为我们执行此操作:

def get_f1_score(trainer, dataset):
    return trainer.predict(dataset).metrics["test_f1"]

我们可以使用此函数来检查测试集的性能并跟踪我们在 a 中的分数dict

f1_scores = defaultdict(dict)
f1_scores["de"]["de"] = get_f1_score(trainer, panx_de_encoded["test"])
print(f"F1-score of [de] model on [de] dataset: {f1_scores['de']['de']:.3f}")
F1-score of [de] model on [de] dataset: 0.868

这些对于 NER 任务来说是相当不错的结果。我们的指标在 85% 的范围内,我们可以看到模型似乎在ORG实体上最挣扎,可能是因为这些在训练数据中最不常见,而且许多组织名称在 XLM-R 的词汇表中很少见。其他语言呢?为了热身,让我们看看我们的模型是如何在法国票价上对德国票价进行微调的:

text_fr = "Jeff Dean est informaticien chez Google en Californie"
tag_text(text_fr, tags, trainer.model, xlmr_tokenizer)
0 1 2 3 4 5 6 7 8 9 10 11 12 13
Tokens <s> ▁Jeff ▁De an ▁est ▁informatic ien ▁chez ▁Google ▁en ▁Cali for nie </s>
Tags O B-PER I-PER I-PER O O O O B-ORG O B-LOC I-LOC I-LOC O

不错!尽管两种语言的名称和组织相同,但该模型确实成功地正确标记了“Kalifornien”的法语翻译。接下来,让我们通过编写一个对数据集进行编码并生成分类报告的简单函数来量化我们的德国模型在整个法国测试集上的表现:

def evaluate_lang_performance(lang, trainer):
    panx_ds = encode_panx_dataset(panx_ch[lang])
    return get_f1_score(trainer, panx_ds["test"])
f1_scores["de"]["fr"] = evaluate_lang_performance("fr", trainer)
print(f"F1-score of [de] model on [fr] dataset: {f1_scores['de']['fr']:.3f}")
F1-score of [de] model on [fr] dataset: 0.714

尽管我们看到微平均指标下降了大约 15 个点,但请记住,我们的模型没有看到一个带标签的法语示例!一般来说,性能下降的大小与语言之间的“距离”有关。尽管德语和法语被归为印欧语系,但它们在技术上属于不同的语系:分别是日耳曼语和罗曼语。

接下来,让我们来评估一下意大利语的表现。由于意大利语也是一种罗曼语,我们希望得到与法语相似的结果:

f1_scores["de"]["it"] = evaluate_lang_performance("it", trainer)
print(f"F1-score of [de] model on [it] dataset: {f1_scores['de']['it']:.3f}")
F1-score of [de] model on [it] dataset: 0.692

事实上,我们的期望得到了1分数的证实。最后,让我们来看看属于日耳曼语系的英语的表现:

f1_scores["de"]["en"] = evaluate_lang_performance("en", trainer)
print(f"F1-score of [de] model on [en] dataset: {f1_scores['de']['en']:.3f}")
F1-score of [de] model on [en] dataset: 0.589

令人惊讶的是,我们的模型在英语上的表现最差,尽管我们可能直觉地认为德语与英语比法语更相似。在对德语进行了微调并执行了到法语和英语的零样本迁移之后,接下来让我们看看什么时候直接在目标语言上进行微调是有意义的。

零镜头转移何时有意义?

到目前为止,我们已经看到在德语语料库上微调 XLM-R 产生了大约 85%的1分数,并且在没有任何额外训练的情况下,该模型能够在我们语料库中的其他语言上实现适度的性能。问题是,这些结果有多好,它们与在单语语料库上微调的 XLM-R 模型相比如何?

在本节中,我们将通过在不断增加的训练集上微调 XLM-R 来探讨法语语料库的这个问题。通过以这种方式跟踪性能,我们可以确定零样本跨语言迁移在哪一点上更胜一筹,这在实践中可用于指导是否收集更多标记数据的决策。

为简单起见,我们将在德国语料库上保持与微调运行相同的超参数,除了我们将调整 的logging_steps参数Training​Ar⁠guments以考虑不断变化的训练集大小。我们可以将这一切包装在一个简单的函数中,该函数获取一个DatasetDict对应于单语语料库的对象,将其下采样num_samples,并在该样本上微调 XLM-R 以返回最佳时期的指标:

def train_on_subset(dataset, num_samples):
    train_ds = dataset["train"].shuffle(seed=42).select(range(num_samples))
    valid_ds = dataset["validation"]
    test_ds = dataset["test"]
    training_args.logging_steps = len(train_ds) // batch_size

    trainer = Trainer(model_init=model_init, args=training_args,
        data_collator=data_collator, compute_metrics=compute_metrics,
        train_dataset=train_ds, eval_dataset=valid_ds, tokenizer=xlmr_tokenizer)
    trainer.train()
    if training_args.push_to_hub:
        trainer.push_to_hub(commit_message="Training completed!")

    f1_score = get_f1_score(trainer, test_ds)
    return pd.DataFrame.from_dict(
        {"num_samples": [len(train_ds)], "f1_score": [f1_score]})

正如我们对德语语料库进行微调一样,我们还需要将法语语料库编码为输入 ID、注意掩码和标签 ID:

panx_fr_encoded = encode_panx_dataset(panx_ch["fr"])

接下来让我们通过在包含 250 个示例的小型训练集上运行它来检查我们的函数是否有效:

training_args.push_to_hub = False
metrics_df = train_on_subset(panx_fr_encoded, 250)
metrics_df
num_samples f1_score
0 250 0.137329

我们可以看到,只有 250 个示例,对法语的微调大大低于从德语的零样本迁移。现在让我们将训练集大小增加到 500、1,000、2,000 和 4,000 个示例,以了解性能如何提高:

for num_samples in [500, 1000, 2000, 4000]:
    metrics_df = metrics_df.append(
        train_on_subset(panx_fr_encoded, num_samples), ignore_index=True)

我们可以通过将测试集上的F 1分数作为增加训练集大小的函数来比较法语样本的微调与德语的零样本跨语言迁移的比较 :

fig, ax = plt.subplots()
ax.axhline(f1_scores["de"]["fr"], ls="--", color="r")
metrics_df.set_index("num_samples").plot(ax=ax)
plt.legend(["Zero-shot from de", "Fine-tuned on fr"], loc="lower right")
plt.ylim((0, 1))
plt.xlabel("Number of Training Samples")
plt.ylabel("F1 Score")
plt.show()

从图中我们可以看到,在大约 750 个训练示例之前,零样本迁移仍然具有竞争力,之后对法语的微调达到了与我们在对德语进行微调时获得的性能相似的水平。然而,这个结果不容小觑!根据我们的经验,让领域专家标记甚至数百个文档的成本可能很高,特别是对于 NER,其标记过程是细粒度且耗时的。

我们可以尝试评估多语言学习的最后一种技术:一次对多种语言进行微调!让我们看看如何做到这一点。

一次微调多种语言

到目前为止,我们已经看到从德语到法语或意大利语的零样本跨语言迁移导致性能下降了大约 15 个点。缓解这种情况的一种方法是同时对多种语言进行微调。为了看看我们可以获得什么类型的收益,让我们首先使用 Datasets 中的concatenate_datasets()函数 将德语和法语语料库连接在一起:

from datasets import concatenate_datasets

def concatenate_splits(corpora):
    multi_corpus = DatasetDict()
    for split in corpora[0].keys():
        multi_corpus[split] = concatenate_datasets(
            [corpus[split] for corpus in corpora]).shuffle(seed=42)
    return multi_corpus
panx_de_fr_encoded = concatenate_splits([panx_de_encoded, panx_fr_encoded])

对于训练,我们将再次使用前面部分中的相同超参数,因此我们可以简单地更新训练器中的日志记录步骤、模型和数据集:

training_args.logging_steps = len(panx_de_fr_encoded["train"]) // batch_size
training_args.push_to_hub = True
training_args.output_dir = "xlm-roberta-base-finetuned-panx-de-fr"

trainer = Trainer(model_init=model_init, args=training_args,
    data_collator=data_collator, compute_metrics=compute_metrics,
    tokenizer=xlmr_tokenizer, train_dataset=panx_de_fr_encoded["train"],
    eval_dataset=panx_de_fr_encoded["validation"])

trainer.train()
trainer.push_to_hub(commit_message="Training completed!")

让我们看看模型在每种语言的测试集上的表现:

for lang in langs:
    f1 = evaluate_lang_performance(lang, trainer)
    print(f"F1-score of [de-fr] model on [{lang}] dataset: {f1:.3f}")
F1-score of [de-fr] model on [de] dataset: 0.866
F1-score of [de-fr] model on [fr] dataset: 0.868
F1-score of [de-fr] model on [it] dataset: 0.815
F1-score of [de-fr] model on [en] dataset: 0.677

它在法国分赛上的表现比以前好得多,与德国测试集上的表现相当。有趣的是,它在意大利语和英语部分的表现也提高了大约 10 分!因此,即使添加另一种语言的训练数据也可以提高模型在未见过的语言上的性能。

让我们通过将每种语言的微调性能分别与所有语料库上的多语言学习进行比较来完善我们的分析。由于我们已经对德语语料库进行了微调,我们可以使用我们的train_on_subset() 函数对剩余的语言进行微调,与num_samples训练集中的示例数量相等:

corpora = [panx_de_encoded]

# Exclude German from iteration
for lang in langs[1:]:
    training_args.output_dir = f"xlm-roberta-base-finetuned-panx-{lang}"
    # Fine-tune on monolingual corpus
    ds_encoded = encode_panx_dataset(panx_ch[lang])
    metrics = train_on_subset(ds_encoded, ds_encoded["train"].num_rows)
    # Collect F1-scores in common dict
    f1_scores[lang][lang] = metrics["f1_score"][0]
    # Add monolingual corpus to list of corpora to concatenate
    corpora.append(ds_encoded)

现在我们已经对每种语言的语料库进行了微调,下一步是将所有拆分连接在一起以创建所有四种语言的多语言语料库。和之前的德语和法语分析一样,我们可以concatenate_splits() 在上一步生成的语料库列表中使用该函数为我们做这一步:

corpora_encoded = concatenate_splits(corpora)

现在我们有了多语种语料库,我们用训练器运行熟悉的步骤:

training_args.logging_steps = len(corpora_encoded["train"]) // batch_size
training_args.output_dir = "xlm-roberta-base-finetuned-panx-all"

trainer = Trainer(model_init=model_init, args=training_args,
    data_collator=data_collator, compute_metrics=compute_metrics,
    tokenizer=xlmr_tokenizer, train_dataset=corpora_encoded["train"],
    eval_dataset=corpora_encoded["validation"])

trainer.train()
trainer.push_to_hub(commit_message="Training completed!")

最后一步是生成训练器对每种语言测试集的预测。这将使我们深入了解多语言学习的实际效果。我们将在字典中收集1分数f1_scores,然后创建一个DataFrame总结我们多语言实验的主要结果的分数:

for idx, lang in enumerate(langs):
    f1_scores["all"][lang] = get_f1_score(trainer, corpora[idx]["test"])
scores_data = {"de": f1_scores["de"],
               "each": {lang: f1_scores[lang][lang] for lang in langs},
               "all": f1_scores["all"]}
f1_scores_df = pd.DataFrame(scores_data).T.round(4)
f1_scores_df.rename_axis(index="Fine-tune on", columns="Evaluated on",
                         inplace=True)
f1_scores_df
Evaluated on de fr it en
Fine-tune on
de 0.8677 0.7141 0.6923 0.5890
each 0.8677 0.8505 0.8192 0.7068
all 0.8682 0.8647 0.8575 0.7870

从这些结果中,我们可以得出一些一般性结论:

  • 多语言学习可以显着提高性能,特别是如果用于跨语言迁移的低资源语言属于相似的语言家族。在我们的实验中,我们可以看到德语、法语和意大利语在该all 类别中的表现相似,这表明这些语言彼此之间的相似性高于英语。

  • 作为一般策略,将注意力集中在语言家族的跨语言迁移上是一个好主意,尤其是在处理像日语这样的不同文字时。

与模型小部件交互

在本章中,我们将很多经过微调的模型推送到了 Hub。尽管我们可以pipeline()在本地机器上使用该功能与它们进行交互,但 Hub 提供了非常适合这种工作流程的小部件。图 4-5显示了我们的 检查点的一个示例 transformersbook/xlm-roberta-base-finetuned-panx-all,正如您所见,它在识别德语文本的所有实体方面做得很好。

图 4-5。Hugging Face Hub 上的小部件示例

结论

在本章中,我们看到了如何使用在 100 种语言上预训练的单个转换器来处理多语言语料库上的 NLP 任务:XLM-R。虽然我们能够证明,当只有少量标记示例可用于微调时,从德语到法语的跨语言迁移是有竞争力的,但如果目标语言与目标语言显着不同,通常不会出现这种良好的性能。基本模型在预训练期间使用的 100 种语言上进行了微调,或者不是其中一种。最近的提议如 MAD-X 正是针对这些低资源场景而设计的,而且由于 MAD-X 构建在 Transformer 之上,您可以轻松地调整本章中的代码以使用它!6

到目前为止,我们已经研究了两个任务:序列分类和标记分类。这些都属于自然语言理解领域,其中文本被合成为预测。在下一章中,我们将首先了解文本生成,其中模型的输入和输出都是文本。