AGI 之 【Hugging Face】 的【多语言命名实体识别】的 [NER词元化 ] / [ 性能度量 ] / [微调XML-RoBERTa] / [ 错误分析 ] / [跨语言迁移 ]的简单整理
目录
AGI 之 【Hugging Face】 的【多语言命名实体识别】的 [NER词元化 ] / [ 性能度量 ] / [微调XML-RoBERTa] / [ 错误分析 ] / [跨语言迁移 ]的简单整理
一、简单介绍
AGI,即通用人工智能(Artificial General Intelligence),是一种具备人类智能水平的人工智能系统。它不仅能够执行特定的任务,而且能够理解、学习和应用知识于广泛的问题解决中,具有较高的自主性和适应性。AGI的能力包括但不限于自我学习、自我改进、自我调整,并能在没有人为干预的情况下解决各种复杂问题。
- AGI能做的事情非常广泛:
跨领域任务执行:AGI能够处理多领域的任务,不受限于特定应用场景。
自主学习与适应:AGI能够从经验中学习,并适应新环境和新情境。
创造性思考:AGI能够进行创新思维,提出新的解决方案。
社会交互:AGI能够与人类进行复杂的社会交互,理解情感和社会信号。
- 关于AGI的未来发展前景,它被认为是人工智能研究的最终目标之一,具有巨大的变革潜力:
技术创新:随着机器学习、神经网络等技术的进步,AGI的实现可能会越来越接近。
跨学科整合:实现AGI需要整合计算机科学、神经科学、心理学等多个学科的知识。
伦理和社会考量:AGI的发展需要考虑隐私、安全和就业等伦理和社会问题。
增强学习和自适应能力:未来的AGI系统可能利用先进的算法,从环境中学习并优化行为。
多模态交互:AGI将具备多种感知和交互方式,与人类和其他系统交互。
Hugging Face作为当前全球最受欢迎的开源机器学习社区和平台之一,在AGI时代扮演着重要角色。它提供了丰富的预训练模型和数据集资源,推动了机器学习领域的发展。Hugging Face的特点在于易用性和开放性,通过其Transformers库,为用户提供了方便的模型处理文本的方式。随着AI技术的发展,Hugging Face社区将继续发挥重要作用,推动AI技术的发展和应用,尤其是在多模态AI技术发展方面,Hugging Face社区将扩展其模型和数据集的多样性,包括图像、音频和视频等多模态数据。
- 在AGI时代,Hugging Face可能会通过以下方式发挥作用:
模型共享:作为模型共享的平台,Hugging Face将继续促进先进的AGI模型的共享和协作。
开源生态:Hugging Face的开源生态将有助于加速AGI技术的发展和创新。
工具和服务:提供丰富的工具和服务,支持开发者和研究者在AGI领域的研究和应用。
伦理和社会责任:Hugging Face注重AI伦理,将推动负责任的AGI模型开发和应用,确保技术进步同时符合伦理标准。
AGI作为未来人工智能的高级形态,具有广泛的应用前景,而Hugging Face作为开源社区,将在推动AGI的发展和应用中扮演关键角色。
(注意:以下代码运行,可能需要科学上网)
二、多语言命名实体识别
Hugging Face 多语言命名实体识别(NER)是利用 Hugging Face 的预训练模型和工具,对文本中的实体进行识别和分类的一项自然语言处理任务。NER 任务的目标是从文本中自动提取出命名实体,并将这些实体分类为预定义的类别(如人名、地名、组织名等)。
- 什么是命名实体识别(NER)?
命名实体识别是一种信息提取技术,旨在识别并分类文本中的特定名称实体。这些实体通常包括以下类别:
Person (人名): 例如 "Albert Einstein"
Organization (组织名): 例如 "Google"
Location (地名): 例如 "New York"
Date (日期): 例如 "2024-07-04"
Miscellaneous (其他): 例如 "COVID-19"NER 在各种应用中具有广泛的用途,包括文本分析、信息检索、问答系统和语义搜索等。
- Hugging Face 的多语言 NER 模型
Hugging Face 提供了许多预训练模型,这些模型能够处理不同语言的 NER 任务。多语言 NER 模型的特点是它们能够处理多种语言的文本,并在多个语言环境下表现良好。这得益于使用了如 BERT、XLM-RoBERTa 等预训练的多语言 Transformer 模型。
- 主要特性:
预训练模型: 利用大规模语料库进行预训练,能捕捉丰富的语言信息。
多语言支持: 这些模型能处理多种语言的文本,适用于跨语言的应用场景。
高精度: 在多种 NER 任务中表现出色,提供高精度的实体识别和分类。
三、NER的词元化
现在我们已经确定了词元分析器和模型可以对单个样本进行编码,下一步是对整个数据集进行词元化,以便我们可以将其传给XLM-R模型进行微调。正如我们在第2章所看到的那样,Hugging Face Datasets库提供了一种使用map()操作快速对Dataset对象进行词元化的方法。
按照Hugging Face Transformers库文档(https://oreil.ly/lGPgh)所采用的方法,我们首先将德语例句的词元和NER标记提取为普通列表:
# 从示例数据中提取单词和标签
words, labels = de_example["tokens"], de_example["ner_tags"]
接下来,我们对每个单词进行词元化,并使用is_split_into_words参数告诉词元分析器我们的输入序列已经被分成了单词。
# 使用 XLM-RoBERTa 分词器对示例数据中的单词进行分词,并获取分词后的 token IDs
tokenized_input = xlmr_tokenizer(de_example["tokens"], is_split_into_words=True)
# 将 token IDs 转换回对应的 token 字符串
tokens = xlmr_tokenizer.convert_ids_to_tokens(tokenized_input["input_ids"])
# 将分词后的 token 转换成 DataFrame 以便查看
pd.DataFrame([tokens], index=["Tokens"])
运行结果:
在这个例子中,我们可以看到词元分析器将“Einwohnern”分成了两个子词,即“__Einwohner”和“n”。因为我们遵循的约定是仅将“__Einwohner”与B-LOC标注相关联,所以我们需要一种方式在第一个子词之后掩码子词表示。幸运的是,tokenized_input类包含了一个word_ids()函数,可以帮助我们实现这一点。
# 获取每个 token 对应的单词索引(word_id)
word_ids = tokenized_input.word_ids()
# 将分词后的 tokens 及其对应的 word_ids 转换成 DataFrame 以便查看
pd.DataFrame([tokens, word_ids], index=["Tokens", "Word IDs"])
运行结果:
在这里,我们可以看到word ids将每个子词映射到相应的words序列索引,因此第一个子词“__2.000”被分配索引0,而“__Einwohner”和“n”被分配索引1(因为“Einwohnern”是words中的第二个单词)。我们还可以看到像<s>和</s>这样的特殊词元被映射为None。我们可以将-100设置为这些特殊词元及训练过程中要掩码的子词的标注:
# 初始化变量
previous_word_idx = None
label_ids = []
# 为每个 token 分配对应的标签索引
for word_idx in word_ids:
if word_idx is None or word_idx == previous_word_idx:
# 对于 None 值或重复的单词索引,使用 -100 作为忽略标签
label_ids.append(-100)
else:
# 对于新的单词索引,使用对应的标签索引
label_ids.append(labels[word_idx])
previous_word_idx = word_idx
# 将标签索引转换为标签名,如果是 -100 则标记为 "IGN"
labels = [index2tag[l] if l != -100 else "IGN" for l in label_ids]
# 创建 DataFrame 以显示 tokens、word_ids、label_ids 和 labels
index = ["Tokens", "Word IDs", "Label IDs", "Labels"]
pd.DataFrame([tokens, word_ids, label_ids, labels], index=index)
运行结果:
为什么我们选择把子词表示中的ID掩码为-100呢?原因在于在PyTorch中,交叉熵损失类torch.nn.CrossEntropyLoss有一个称为ignore_index的属性,其值为-100。在训练过程中,这个索引值会被忽略,因此我们可以通过掩码为-100来忽略与连续子词相关的词元。
这就是全部工作了!我们可以清楚地看到标注ID如何与词元对齐,然后我们将所有逻辑封装进一个函数里面,并将其应用于整个数据集:
def tokenize_and_align_labels(examples):
# 使用 XLM-R tokenizer 对输入的 tokens 进行编码
tokenized_inputs = xlmr_tokenizer(examples["tokens"], truncation=True,
is_split_into_words=True)
labels = []
# 对每个样本的 ner_tags 进行处理
for idx, label in enumerate(examples["ner_tags"]):
# 获取每个 token 对应的单词索引
word_ids = tokenized_inputs.word_ids(batch_index=idx)
previous_word_idx = None
label_ids = []
# 为每个 token 分配标签索引
for word_idx in word_ids:
if word_idx is None or word_idx == previous_word_idx:
# 对于 None 值或重复的单词索引,使用 -100 作为忽略标签
label_ids.append(-100)
else:
# 对于新的单词索引,使用对应的标签索引
label_ids.append(label[word_idx])
previous_word_idx = word_idx
labels.append(label_ids)
# 将标签添加到 tokenized_inputs 中
tokenized_inputs["labels"] = labels
return tokenized_inputs
我们现在已经具备了编码每个数据集分割所需的所有元素,因此我们编写一个可以迭代的函数:
def encode_panx_dataset(corpus):
# 将数据集中的每个样本映射到 tokenize_and_align_labels 函数
return corpus.map(tokenize_and_align_labels, batched=True,
remove_columns=['langs', 'ner_tags', 'tokens'])
将这个函数应用到DatasetDict对象上,我们得到了每个数据集分割编码后的Dataset对象。我们使用这个函数来编码我们的德语语料库:
# 对德语的 PAN-X 数据集进行编码
panx_de_encoded = encode_panx_dataset(panx_ch["de"])
运行结果:
现在我们已经有了一个模型和数据集,我们还需要定义一个性能度量。
四、性能度量
评估命名实体识别模型与评估文本分类模型类似,通常会报告查准率、召回率和F1分数的结果。唯一的复杂之处在于,为了使预测被计算为正确,实体的所有单词都需要被正确预测。幸运的是,有一个名为seqeval(https://oreil.ly/xbKOp)的实用库专门为这类任务设计。例如,给定一些占位符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))
(注意:如果没有安装seqeval,使用命令进行安装 pip install seqeval,(!pip install seqeval(在 Jupyter notebook)))
运行结果:
正如我们所看到的,seqeval期望将预测值和标注作为列表的列表传入,其中每个列表对应于验证集或测试集中的单个样本。为了在训练过程中集成这些度量指标,我们需要一个能够接收模型输出并将其转换为seqeval所期望的列表的函数。下面的函数可以通过确保忽略与后续子词相关联的标注ID来实现此目的:
import numpy as np
def align_predictions(predictions, label_ids):
# 取出每个token预测的最大值作为最终的预测标签
preds = np.argmax(predictions, axis=2)
# 获取batch_size和序列长度
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):
# 忽略标签ID为-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数据集的德语子集上对我们的基础模型进行微调,然后评估它在法语、意大利语和英语上的零样本跨语言性能。像往常一样,我们将使用Hugging Face Transformers库的Trainer来处理训练循环,因此首先我们需要使用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", # 设置日志级别(例如:"error" 减少冗余信息)
num_train_epochs=num_epochs, # 总训练轮数
per_device_train_batch_size=batch_size, # 每个GPU/TPU核心的训练批大小
per_device_eval_batch_size=batch_size, # 每个GPU/TPU核心的评估批大小
evaluation_strategy="epoch", # 每个轮次结束后进行评估
save_steps=1e6, # 每更新1,000,000次保存一次模型检查点
weight_decay=0.01, # 权重衰减强度
disable_tqdm=False, # 训练过程中启用tqdm进度条
logging_steps=logging_steps, # 每 `logging_steps` 步记录一次指标
push_to_hub=True # 将模型检查点推送到Hub(例如Hugging Face模型Hub)
)
这里我们在每轮结束时评估模型对验证集的预测,调整权重衰减,并将save_steps设置为一个较大的数字以禁用checkpoint来加快训练速度。
首先我们需要登录Hugging Face Hub(如果你使用命令行终端,则可以执行命令huggingface-cli login)。
from huggingface_hub import notebook_login
# 从Hugging Face Hub登录笔记本环境
notebook_login()
运行结果:
输入自己的有效的 Token
我们还需要告诉Trainer如何在验证集上计算指标,因此在这里我们可以使用之前定义的align_predictions()函数来提取预测和标注,以seqeval所需的格式来计算F1分数。
from seqeval.metrics import f1_score
# 定义计算评估指标的函数
def compute_metrics(eval_pred):
# 对齐预测和真实标签
y_pred, y_true = align_predictions(eval_pred.predictions,
eval_pred.label_ids)
# 计算F1分数作为评估指标
return {"f1": f1_score(y_true, y_pred)}
最后一步是定义一个数据整理器,这样我们就可以将每个输入序列填充到批处理中的最大序列长度。Hugging Face Transformers库为词元分类提供了一个专用的数据整理器,它将连同输入一起填充标注:
from transformers import DataCollatorForTokenClassification
# 导入数据集拼接器并使用指定的tokenizer
data_collator = DataCollatorForTokenClassification(xlmr_tokenizer)
填充标注是必要的,因为与文本分类任务不同,标注也是序列。这里的一个重要细节是,标注序列用值-100进行填充,如前所述,这个值在PyTorch损失函数中会被忽略。
这里我们会训练多个模型,因此我们要通过创建一个model_init()方法来避免为每个Trainer初始化一个新模型。这个方法加载一个未训练的模型,并在train()调用开始时被调用:
def model_init():
# 从预训练模型和配置初始化XLM-RoBERTa模型,并将其移动到指定的设备上
return (XLMRobertaForTokenClassification
.from_pretrained(xlmr_model_name, config=xlmr_config)
.to(device))
我们现在可以将所有这些信息和编码好的数据集一起传给Trainer:
from transformers import Trainer
# 使用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()
# 将训练完成的模型推送到Hub
trainer.push_to_hub(commit_message="Training completed!") # 网络报错的话,可以注释掉这一句
运行结果:
Epoch | Training Loss | Validation Loss | F1 |
1 | 0.2601 | 0.157541 | 0.828096 |
2 | 0.1268 | 0.141769 | 0.844818 |
3 | 0.0813 | 0.134185 | 0.86492 |
这些F1分数对于命名实体识别模型来说相当不错。为了确认我们的模型按预期工作,我们在示例的德语译文上测试它。
text_de = "Jeff Dean ist ein Informatiker bei Google in Kalifornien"
# 调用tag_text函数,对德语文本进行命名实体识别
tag_text(text_de, tags, trainer.model, xlmr_tokenizer)
运行结果:
它表现不错!但我们不应该根据单个示例的表现过于自信。相反,我们应该对模型的错误进行适当和彻底的调查。
六、错误分析
在我们深入探讨XLM-R的多语言方面之前,我们花一分钟来调查我们模型的错误。正如我们在第2章所看到的那样,对模型进行彻底的错误分析是训练和调试Transformer(以及机器学习模型)最重要的方面之一。有几种故障模式会让你觉得模型表现得很好,而实际上它存在一些严重的缺陷。训练可能失败的示例包括:
●我们可能不小心掩码了太多的词元,也掩码了一些标注,以获得极具潜力的损失下降。
●compute_metrics()函数可能存在bug,导致高估真实表现。
●我们可以将零类或0实体作为正常类包括在N E R中,但这会严重偏移准确率和F1分数的结果,因为它是在数量上占绝大多数的类。
当模型表现远远低于预期时,查看错误可以提供有用的见解,并揭示只查看代码难以发现的错误。即使模型表现良好且代码中没有错误,在模型部署到生产环境后,错误分析仍然是了解模型优势和劣势的有用工具。在模型部署到生产环境后,我们需要始终牢记这些方面。
为了进行分析,我们将再次使用手头上最强大的工具之一,即查看拥有最高损失的验证样本。我们可以重复使用之前中构建的用于分析序列分类模型的代码,但现在我们需要计算样本序列中每个词元的损失。
我们定义一个可以应用于验证集的方法:
from torch.nn.functional import cross_entropy
def forward_pass_with_label(batch):
# 将字典的列表转换为适合数据拼接器的数据结构(列表的字典)
features = [dict(zip(batch, t)) for t in zip(*batch.values())]
# 对输入和标签进行填充,并将所有张量放置在设备上
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():
# 将数据传递给模型
output = trainer.model(input_ids, attention_mask)
# logits的尺寸:[batch_size, sequence_length, classes]
# 在类别轴上选择logits值最大的类别作为预测标签
predicted_label = torch.argmax(output.logits, axis=-1).cpu().numpy()
# 计算每个token的损失,先将batch维度展平,再使用view
loss = cross_entropy(output.logits.view(-1, 7),
labels.view(-1), reduction="none")
# 恢复batch维度并转换为numpy数组
loss = loss.view(len(input_ids), -1).cpu().numpy()
return {"loss": loss, "predicted_label": predicted_label}
现在我们可以使用map()函数将此函数应用于整个验证集,并将所有数据加载到DataFrame中进行进一步分析:
# 获取验证集
valid_set = panx_de_encoded["validation"]
# 对验证集进行批处理,并应用前向传递函数,计算损失和预测标签
valid_set = valid_set.map(forward_pass_with_label, batched=True, batch_size=32)
# 将验证集转换为Pandas DataFrame格式
df = valid_set.to_pandas()
运行结果:
仍然使用ID编码词元和标注,因此我们将词元和标注映射回字符串,以便更容易地阅读结果。对于标注为-100的填充词元,我们分配一个特殊的标注IGN,这样我们可以稍后过滤它们。我们还通过将它们截断到输入长度来消除loss和predicted_label字段中的所有填充。
# 将索引 -100 映射为标签 "IGN"
index2tag[-100] = "IGN"
# 将输入的 token ID 转换为实际的 token
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
)
# 显示DataFrame的前1行
df.head(1)
运行结果:
input_ids | attention_mask | labels | loss | predicted_label | input_tokens | |
0 | [0, 10699, 11, 15, 16104, 1388, 2] | [1, 1, 1, 1, 1, 1, 1] | [IGN, B-ORG, IGN, I-ORG, I-ORG, I-ORG, IGN] | [0.0, 0.010949999, 0.0, 0.013208039, 0.0079988... | [I-ORG, B-ORG, I-ORG, I-ORG, I-ORG, I-ORG, I-ORG] | [<s>, ▁Ham, a, ▁(, ▁Unternehmen, ▁), </s>] |
每一列包含每个样本的词元、标注、预测标注等列表。我们可以通过展开这些列表来逐个查看词元。pandas.Series.explode()函数允许我们在一行中创建原始行列表中每个元素的行。由于一行中的所有列表都具有相同的长度,因此我们可以对所有列并行执行此操作。我们还删除了名为IGN的填充词元,因为它们的损失为零。最后,我们将仍然是numpy.Array对象的损失转换为标准float。
# 将DataFrame中的列表列展开成单独的行
df_tokens = df.apply(pd.Series.explode)
# 筛选掉标签为'IGN'的行
df_tokens = df_tokens.query("labels != 'IGN'")
# 将损失值转换为浮点型并四舍五入到小数点后两位
df_tokens["loss"] = df_tokens["loss"].astype(float).round(2)
# 显示DataFrame的前7行
df_tokens.head(7)
运行结果:
input_ids | attention_mask | labels | loss | predicted_label | input_tokens | |
0 | 10699 | 1 | B-ORG | 0.01 | B-ORG | ▁Ham |
0 | 15 | 1 | I-ORG | 0.01 | I-ORG | ▁( |
0 | 16104 | 1 | I-ORG | 0.01 | I-ORG | ▁Unternehmen |
0 | 1388 | 1 | I-ORG | 0.01 | I-ORG | ▁) |
1 | 56530 | 1 | O | 0 | O | ▁WE |
1 | 83982 | 1 | B-ORG | 1.39 | B-LOC | ▁Luz |
1 | 10 | 1 | I-ORG | 1.26 | I-LOC | ▁a |
有了这种形状的数据,我们现在可以按输入词元分组,并使用计数、平均值和总和来聚合每个词元的损失。最后,我们按损失的总和对聚合数据进行排序,看看哪些词元在验证集中累积了最多的损失。
# 对input_tokens列进行分组,并对loss列进行聚合计算
# 计算每个token的出现次数(count)、平均损失(mean)和总损失(sum)
result = (
df_tokens.groupby("input_tokens")[["loss"]]
.agg(["count", "mean", "sum"]) # 聚合函数
.droplevel(level=0, axis=1) # 去掉多级索引
.sort_values(by="sum", ascending=False) # 按总损失降序排序
.reset_index() # 重置索引
.round(2) # 四舍五入到小数点后两位
.head(10) # 取前10行
.T # 转置
)
# 显示结果
result
运行结果:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |
input_tokens | ▁ | ▁der | ▁in | ▁von | ▁und | ▁/ | ▁'' | ▁( | ▁) | ▁A |
count | 6066 | 1388 | 989 | 808 | 1171 | 163 | 2898 | 246 | 246 | 125 |
mean | 0.04 | 0.09 | 0.13 | 0.15 | 0.07 | 0.51 | 0.03 | 0.31 | 0.28 | 0.47 |
sum | 216.9 | 127.3 | 124.9 | 121.3 | 87.33 | 82.43 | 78.53 | 76 | 70.07 | 58.9 |
我们可以观察到这个列表中的几种模式。
●“空格”这个词元具有最高的总损失,这并不奇怪,因为它也是列表中最常见的词元。然而,它的平均损失要比列表中的其他词元低得多。这意味着模型对其进行分类并没有太多的困难。
●像“in”“von”“der”和“und”这样的词在文本中出现相对频繁。它们经常与命名实体一起出现,有时也是命名实体的一部分,这解释了为什么模型可能会混淆它们。
●括号、斜杠和单词开头的大写字母都比较少见,但平均损失较高。我们将进一步调查它们。
我们还可以将标注ID分组,并查看每个类的损失:
# 对labels列进行分组,并对loss列进行聚合计算
# 计算每个标签的出现次数(count)、平均损失(mean)和总损失(sum)
result = (
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 # 转置
)
# 显示结果
result
运行结果:
0 | 1 | 2 | 3 | 4 | 5 | 6 | |
labels | I-LOC | B-ORG | I-ORG | B-LOC | B-PER | I-PER | O |
count | 1462 | 2683 | 3820 | 3172 | 2893 | 4139 | 43648 |
mean | 0.64 | 0.6 | 0.47 | 0.35 | 0.26 | 0.18 | 0.03 |
sum | 941.9 | 1606 | 1782 | 1099 | 753.1 | 739.5 | 1335.85 |
我们发现B-ORG的平均损失最高,这意味着确定一个组织名称的开始对我们的模型提出了挑战。
我们可以进一步分解,通过绘制词元分类的混淆矩阵来解决这个问题,我们可以发现组织名称的开始经常会被误认为是后续的I-ORG词元:
from sklearn.metrics import ConfusionMatrixDisplay, confusion_matrix
import matplotlib.pyplot as plt
def plot_confusion_matrix(y_preds, y_true, labels, savedImageName):
# 计算归一化的混淆矩阵
cm = confusion_matrix(y_true, y_preds, normalize="true")
# 创建一个6x6大小的图像和轴
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.savefig(savedImageName, bbox_inches='tight')
# 显示图像
plt.show()
# 使用plot_confusion_matrix函数绘制混淆矩阵
plot_confusion_matrix(
df_tokens["labels"], # 实际标签
df_tokens["predicted_label"], # 预测标签
tags.names, # 标签名称
"images/plot_confusion_matrix.png"
)
运行结果:
从图中可以看出,我们的模型往往最容易混淆B-ORG和I-ORG实体。另外,它在分类其他实体方面表现相当不错,这可以通过混淆矩阵近乎对角线的特征看出。
现在我们已经检查了词元级别上的错误,我们继续查看那些损失较高的序列。为了进行计算,我们将重新访问我们的“未爆炸”DataFrame,并通过对每个词元的损失进行求和来计算出总损失。为此,我们首先编写一个函数,以帮助我们显示带有标注和损失的单词序列:
def get_samples(df):
# 遍历DataFrame中的每一行
for _, row in df.iterrows():
labels, preds, tokens, losses = [], [], [], []
# 遍历每个attention_mask的索引和值
for i, mask in enumerate(row["attention_mask"]):
# 忽略第一个和最后一个token
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]) # 添加输入token
losses.append(f"{row['loss'][i]:.2f}") # 添加损失并格式化为小数点后两位
# 创建一个临时DataFrame来存储这些值
df_tmp = pd.DataFrame({"tokens": tokens, "labels": labels,
"preds": preds, "losses": losses}).T
# 生成该临时DataFrame
yield df_tmp
# 计算每一行的总损失
df["total_loss"] = df["loss"].apply(sum)
# 按总损失降序排序,并选取前三行
df_tmp = df.sort_values(by="total_loss", ascending=False).head(3)
# 使用get_samples函数生成样本,并显示每个样本
for sample in get_samples(df_tmp):
display(sample)
运行结果:
显然,这些样本的标注存在问题。例如,联合国和中非共和国都被标注为人!与此同时,在第一个例子中,“8. Juli”被标注为组织。事实证明,PAN-X数据集的标注是通过自动化过程生成的。这样的标注常常被称为“银标准”(与人工生成标注的“金标准”相对),在某些情况下,自动化方法无法产生明智的标注也就不足为奇了。实际上,这种失败模式不仅限于自动化方法,即使人类仔细标注数据,在标注者注意力衰减或者他们对句子的理解出现误解时也会出现错误。
我们之前还注意到,括号和斜杠的损失相对较高。我们看一些带有左括号的序列的例子:
# 选择包含特定字符的行(这里选择包含 "▁(" 的行),并选取前两行
df_tmp = df.loc[df["input_tokens"].apply(lambda x: u"\u2581(" in x)].head(2)
# 使用get_samples函数生成样本,并显示每个样本
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 | 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 | 0 | 0.02 | 0.02 | 0 | 0.02 | 0.00 |
通常,我们不会将括号及其内容作为命名实体的一部分,但是自动提取标注文档的方式似乎是这种方式。在其他样本中,括号包含地理规范。虽然这也是一个位置,但我们可能希望在批注中将其与原始位置分离。该数据集包括不同语言的维基百科文章,文章标题通常包含括号中的某种说明。例如,在第一个样本中,括号中的文本表示Hama是一个“Unternehmen”,即英语中的“公司”。当我们推出模型时,这些是需要知道的重要细节,因为它们可能会对模型的下游性能产生影响,整个pipeline是模型的一部分。
通过相对简单的分析,我们已经发现了模型和数据集中的一些弱点。在实际应用中,我们会对这一步进行迭代,清理数据集、重新训练模型、分析新的错误,直到对性能满意为止。
我们分析了单一语言上的错误,但我们也对跨语言性能感兴趣。
七、跨语言迁移
现在我们已经对德语进行了XLM-R的微调,我们可以通过Trainer的predict()方法评估它迁移到其他语言上的能力。由于我们计划评估多种语言,因此我们创建一个简单的函数来实现这一功能:
def get_f1_score(trainer, dataset):
# 使用Trainer预测数据集并获取F1分数
return trainer.predict(dataset).metrics["test_f1"]
我们可以使用这个函数来检验测试集的性能,并用dict记录我们的分数。
from collections import defaultdict
# 创建一个默认字典来存储F1分数
f1_scores = defaultdict(dict)
# 计算并存储[de]模型在[de]数据集上的F1分数
f1_scores["de"]["de"] = get_f1_score(trainer, panx_de_encoded["test"])
# 打印显示F1分数
print(f"F1-score of [de] model on [de] dataset: {f1_scores['de']['de']:.3f}")
运行结果:
F1-score of [de] model on [de] dataset: 0.867
这个结果在NER任务中是很不错的。我们的指标在85%左右,我们可以看到模型似乎最难处理的是机构(ORG)实体,这可能是因为这些在训练数据中最不常见,并且许多组织名称在XLM-R的词表中是罕见的。那其他语言呢?下面看看我们在德语上微调的模型在法语上的表现如何:
# 定义一个法语文本
text_fr = "Jeff Dean est informaticien chez Google en Californie"
# 使用 tag_text 函数对文本进行标记
# 参数包括:待标注文本 text_fr,标记集合 tags,模型 trainer.model,和分词器 xlmr_tokenizer
tag_text(text_fr, tags, trainer.model, xlmr_tokenizer)
运行结果:
不错!虽然两种语言中的名称和组织是相同的,但是该模型成功地将“Kalifornien”的法语翻译成正确标注。接下来,我们编写一个简单的函数,对数据集进行编码并生成分类报告,以量化我们的德语模型在整个法语测试集上的表现:
运行结果:
尽管我们在微平均指标中看到了约15个百分点的下降,但请记住我们的模型没有看到任何一个标注的法语样本!通常情况下,性能下降的大小与语言之间的距离有关。尽管德语和法语被归为印欧语系语言,但它们实际上属于不同的语系(分别是日耳曼语系和罗曼语系)。
下一步,我们来评估在意大利语上的表现。由于意大利语也是罗曼语系语言,我们预计得到的结果与我们在法语上得到的类似。
# 计算德语模型在意大利语数据集上的性能
f1_scores["de"]["it"] = evaluate_lang_performance("it", trainer)
# 打印德语模型在意大利语数据集上的 F1 分数
print(f"F1-score of [de] model on [it] dataset: {f1_scores['de']['it']:.3f}")
运行结果:
事实上,我们的期望得到了F1分数的支持。最后,我们来考察一下属于日耳曼语系的英语的性能:
# 计算德语模型在英语数据集上的性能
f1_scores["de"]["en"] = evaluate_lang_performance("en", trainer)
# 打印德语模型在英语数据集上的 F1 分数
print(f"F1-score of [de] model on [en] dataset: {f1_scores['de']['en']:.3f}")
运行结果:
令人惊讶的是,我们的模型在英语方面性能最差,尽管我们可能直觉地认为德语与英语更相似而不是法语。在对德语进行微调并执行零样本迁移到法语和英语后,我们接下来看看何时直接在目标语言上进行微调才有意义。
1、零样本迁移何时有意义
到目前为止,我们已经看到,对德语语料库进行XLM-R微调可以获得约85%的F1分数,而且在没有进行任何额外的训练的情况下,该模型可以在我们语料库中的其他语言上实现适度的性能。问题是,这些结果有多好,它们与在单语语料库上微调的XLM-R模型相比如何?
在本篇文章中,我们将通过在规模不断增加的训练集上对XLM-R进行微调,来探讨法语语料库的这个问题。通过这种方式追踪性能,我们可以确定在哪个点上进行零样本跨语言迁移更优,实际上这一点在指导是否收集更多标注数据方面是有用的。
为简单起见,我们将保留在德语语料库上进行微调时得到的超参数,除了我们将调整Training Arguments的logging_steps参数以考虑不断变化的训练集大小。我们可以将所有这些封装在一个简单的函数中,该函数接收与单语语料库相对应的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 = 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()
# 如果设置了推送到 Hub,将训练后的模型推送到 Hub
if training_args.push_to_hub:
trainer.push_to_hub(commit_message="Training completed!")
# 计算在测试集上的 F1 分数
f1_score = get_f1_score(trainer, test_ds)
# 返回包含样本数和测试集上 F1 分数的 DataFrame
return pd.DataFrame.from_dict({"num_samples": [len(train_ds)], "f1_score": [f1_score]})
与我们对德语语料库进行的微调一样,我们也需要将法语语料库编码为输入ID、注意力掩码和标注ID:
# 对 PAN-X 法语数据集进行编码处理
panx_fr_encoded = encode_panx_dataset(panx_ch["fr"])
运行结果:
接下来我们通过在一个小的训练集上运行我们的函数来检查它是否正常工作,该训练集只包含250个样本。
# 设置训练参数中的 push_to_hub 为 False,表示不将训练后的模型推送到 Hub
training_args.push_to_hub = False
# 对编码后的 PAN-X 法语数据集进行训练,选择 250 个样本进行训练
metrics_df = train_on_subset(panx_fr_encoded, 250)
# 打印训练过程中的度量指标 DataFrame
metrics_df
运行结果:
我们可以看到,仅用250个样本,在法语上微调的表现明显低于从德语中进行零样本迁移。现在,我们将训练集大小增加到500、1000、2000和4000个样本,以了解性能是如何提高的:
# 对不同数量的样本进行迭代训练,并将度量指标添加到 metrics_df 中
for num_samples in [500, 1000, 2000, 4000]:
metrics_df = metrics_df.append(
train_on_subset(panx_fr_encoded, num_samples), ignore_index=True)
运行结果:
我们可以将在法语样本上微调的结果与从德语样本上进行零样本跨语言迁移的结果进行比较,通过作图来观察随着训练集大小的不断增加,测试集上F1分数的变化趋势。
# 将收集到的度量指标数据平展为二维结构
flattened_metrics_data = [item.to_dict(orient='records')[0] if isinstance(item, pd.DataFrame) else item for item in metrics_data]
# 将平展后的数据转换为 DataFrame
metrics_df = pd.DataFrame(flattened_metrics_data)
# 创建一个新的图形和坐标系
fig, ax = plt.subplots()
# 添加零样本法语模型的零假设 F1 分数作为参考线
ax.axhline(f1_scores["de"]["fr"], ls="--", color="r")
# 将 metrics_df 的 num_samples 列设为索引,绘制在同一坐标系中
metrics_df.set_index("num_samples")["f1_score"].plot(ax=ax)
# 添加图例,标记零假设和微调后的模型在法语数据集上的性能比较
plt.legend(["Zero-shot from de", "Fine-tuned on fr"], loc="lower right")
# 设置 y 轴范围为 0 到 1
plt.ylim((0, 1))
# 设置 x 轴和 y 轴标签
plt.xlabel("Number of Training Samples")
plt.ylabel("F1 Score")
# 保存图表为文件,并设置自适应大小
plt.savefig("images/Number_of_Training_Samples.png", bbox_inches='tight')
# 显示图形
plt.show()
运行结果:
从图中我们可以看出,零样本迁移学习一直保持竞争力,直到约750个训练样本之后,此时在法语上的微调达到了类似于我们在德语上微调时得到的性能水平。尽管如此,这个结果也不容忽视!根据我们的经验,即使只让领域专家标注几百个文档都可能是昂贵的,尤其是对于NER这种需要细粒度标注且耗时的任务。
我们可以尝试用最后一种技术来评估多语言学习:同时在多种语言上进行微调!我们看看如何做到这一点。
2、一次性对多种语言进行微调
到目前为止,我们已经看到从德语到法语或意大利语的零样本跨语言迁移会导致约15个百分点的性能下降。缓解这种情况的一种方法是同时在多种语言上进行微调。为了了解我们可以获得什么类型的收益,我们首先使用Hugging Face Datasets库中的concatenate datasets()函数将德语和法语语料库合并在一起:
from datasets import concatenate_datasets
def concatenate_splits(corpora):
# 初始化一个空的 DatasetDict 对象,用于存储合并后的数据集
multi_corpus = DatasetDict()
# 对每个数据集的每个拆分进行循环
for split in corpora[0].keys():
# 合并所有数据集的指定拆分,然后进行随机打乱
multi_corpus[split] = concatenate_datasets(
[corpus[split] for corpus in corpora]).shuffle(seed=42)
# 返回合并和随机打乱后的 DatasetDict 对象
return multi_corpus
# 使用 concatenate_splits 函数合并德语和法语的 PAN-X 编码数据集
panx_de_fr_encoded = concatenate_splits([panx_de_encoded, panx_fr_encoded])
我们将再次使用与前面章节中相同的超参数进行训练,因此我们只需在训练器中更新日志记录步骤、模型和数据集即可。
# 设置日志步数为合并后的训练集长度除以批量大小
training_args.logging_steps = len(panx_de_fr_encoded["train"]) // batch_size
# 设置推送到 Hub 为 True,表示训练完成后将模型推送到 Hub
training_args.push_to_hub = True
# 设置输出目录,用于保存微调后的模型
training_args.output_dir = "xlm-roberta-base-finetuned-panx-de-fr"
# 使用 Trainer 进行模型训练
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()
# 如果设置了推送到 Hub,将训练后的模型推送到 Hub,提交消息为 "Training completed!"
trainer.push_to_hub(commit_message="Training completed!")
运行结果:
我们来看看该模型在每种语言的测试集上的表现:
# 对于每种语言,评估德语-法语模型的性能
for lang in langs:
# 评估德语-法语模型在指定语言数据集上的 F1 分数
f1 = evaluate_lang_performance(lang, trainer)
# 打印德语-法语模型在指定语言数据集上的 F1 分数,保留三位小数
print(f"F1-score of [de-fr] model on [{lang}] dataset: {f1:.3f}")
运行结果:
它在法语数据集上的表现比之前好得多,与德语测试集上的表现相匹配。有趣的是,它在意大利语和英语数据集上的表现也提高了约10个百分点!因此,即使在另一种语言的训练数据中添加数据也可以提高模型对未知语言的表现。
我们通过对比单独对每种语言进行微调和对所有语料库进行多语言学习来完成我们的分析。由于我们已经在德语语料库上进行了微调,因此我们可以使用train_on_subset()函数在剩余的语言上进行微调,num_samples等于训练集中的样例数。
# 初始化语料库列表,仅包含德语编码数据集
corpora = [panx_de_encoded]
# 遍历所有语言,排除德语(假设 langs[0] 是德语)
for lang in langs[1:]:
# 设置输出目录,用于保存每种语言的微调模型
training_args.output_dir = f"xlm-roberta-base-finetuned-panx-{lang}"
# 对单一语言数据集进行编码
ds_encoded = encode_panx_dataset(panx_ch[lang])
# 在单一语言语料库上进行微调
metrics = train_on_subset(ds_encoded, ds_encoded["train"].num_rows)
# 在 F1 分数字典中收集单一语言数据集上的 F1 分数
f1_scores[lang][lang] = metrics["f1_score"][0]
# 将单一语言数据集添加到需要合并的语料库列表中
corpora.append(ds_encoded)
运行结果:
现在我们已经在每种语言的语料库上进行了微调,下一步是将所有拆分的语料库连接在一起,创建一个包含所有四种语言的多语言语料库。与先前的德语和法语分析一样,我们可以使用concatenate_splits()函数,在我们上一步生成的语料库列表上执行此步骤:
# 使用 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 = 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()
# 将训练后的模型推送到 Hub,提交消息为 "Training completed!"
trainer.push_to_hub(commit_message="Training completed!")
运行结果:
最后一步是从训练器生成对每种语言的测试集的预测结果。这将为我们展示多语言学习的效果。我们将在f1_scores字典中收集F1分数,然后创建一个DataFrame以总结我们多语言实验的主要结果:
# 对于每种语言,评估在所有语言上微调的模型的性能
for idx, lang in enumerate(langs):
# 使用 get_f1_score 函数评估微调后的模型在指定语言测试集上的 F1 分数
f1_scores["all"][lang] = get_f1_score(trainer, corpora[idx]["test"])
# 准备 F1 分数数据,用于创建 DataFrame
scores_data = {
"de": f1_scores["de"], # 在德语数据集上微调的 F1 分数
"each": {lang: f1_scores[lang][lang] for lang in langs}, # 在每种语言数据集上微调的 F1 分数
"all": f1_scores["all"] # 在所有语言数据集上微调的 F1 分数
}
# 将 F1 分数数据转换为 DataFrame,并转置
f1_scores_df = pd.DataFrame(scores_data).T.round(4)
# 重命名 DataFrame 的轴
f1_scores_df.rename_axis(index="Fine-tune on", columns="Evaluated on", inplace=True)
# 显示最终的 F1 分数 DataFrame
f1_scores_df
运行结果:
从这些结果中,我们可以得出一些普遍的结论:
- 多语言学习可以带来显著的性能提升,特别是如果跨语言迁移的低资源语言属于相似的语系。在我们的实验中,我们可以看到德语、法语和意大利语在all类别中实现了类似的性能,这表明这些语言彼此之间比与英语更相似。
- 作为一种通用策略,集中注意力进行语言家族内的跨语言迁移是一个很好的想法,特别是当处理像日语这样的不同文本时。
八、用模型小部件进行交互
在本章中,我们将许多经过微调的模型推送到了Hub。虽然我们可以使用pipeline()函数在本地机器上与它们进行交互,但Hub提供了适用于这种工作流的小部件。例如,图4-5展示了我们的transformersbook/xlm-roberta-base-finetuned-panx-all checkpoint,你可以看到它已经很好地识别了德语文本中的所有实体。