快速分词器的特殊功能
在本节中,我们将深入探讨 🤗 Transformers 中分词器功能。到目前为止,我们只使用它们来分词输入或将 ID 解码回文本,但分词器——尤其是那些由 🤗 Tokenizers 库支持的分词器——可以做更多的事情。为了说明这些附加功能,我们将探索如何重现我们在 第 1 章 中首次遇到的 token-classification
(我们称之为 ner
)和 question-answering
管道的结果。
在接下来的讨论中,我们将经常区分“慢速”和“快速”分词器。“慢速”分词器是那些在 🤗 Transformers 库中用 Python 编写的,而快速版本是由 🤗 Tokenizers 提供的,它们是用 Rust 编写的。如果你还记得 第 5 章 中的那张表格,它报告了快速和慢速分词器对药物评论数据集进行分词所需的时间,你应该对为什么我们称它们为快速和慢速有所了解。
快速分词器 | 慢速分词器 | |
---|---|---|
batched=True | 10.8 秒 | 4 分 41 秒 |
batched=False | 59.2 秒 | 5 分 3 秒 |
⚠️ 当分词一个句子时,你不会总是看到相同分词器的慢速版本和快速版本之间的速度差异。事实上,快速版本可能实际上更慢!只有在同时并行分词大量文本时,你才能清楚地看到差异。
批处理编码
分词器的输出不是简单的 Python 字典;我们得到的是一个特殊的 BatchEncoding
对象。它是字典的子类(这就是我们之前能够毫无问题地索引该结果的原因),但它具有更多的方法,这些方法主要用于快速分词器。
除了它们的并行化能力外,快速分词器的关键功能是它们始终跟踪最终标记来自的文本的原始范围——我们称之为偏移映射。这反过来又解锁了诸如将每个单词映射到它生成的标记或将原始文本的每个字符映射到它所在的标记,反之亦然的功能。
让我们来看一个例子
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
example = "My name is Sylvain and I work at Hugging Face in Brooklyn."
encoding = tokenizer(example)
print(type(encoding))
如前所述,我们在分词器的输出中得到一个 BatchEncoding
对象
<class 'transformers.tokenization_utils_base.BatchEncoding'>
由于 AutoTokenizer
类默认选择快速分词器,因此我们可以使用此 BatchEncoding
对象提供的附加方法。我们有两种方法可以检查我们的分词器是快速分词器还是慢速分词器。我们可以检查 tokenizer
的 is_fast
属性
tokenizer.is_fast
True
或检查我们的 encoding
的相同属性
encoding.is_fast
True
让我们看看快速分词器能让我们做什么。首先,我们可以访问标记,而不必将 ID 转换回标记
encoding.tokens()
['[CLS]', 'My', 'name', 'is', 'S', '##yl', '##va', '##in', 'and', 'I', 'work', 'at', 'Hu', '##gging', 'Face', 'in',
'Brooklyn', '.', '[SEP]']
在这种情况下,索引 5 处的标记是 ##yl
,它是原始句子中单词“Sylvain”的一部分。我们还可以使用 word_ids()
方法来获取每个标记来自的单词的索引
encoding.word_ids()
[None, 0, 1, 2, 3, 3, 3, 3, 4, 5, 6, 7, 8, 8, 9, 10, 11, 12, None]
我们可以看到,分词器的特殊标记 [CLS]
和 [SEP]
被映射到 None
,然后每个标记都被映射到它起源的单词。这对于确定标记是否位于单词的开头或两个标记是否在同一个单词中特别有用。我们可以依靠 ##
前缀来做到这一点,但它只适用于类似 BERT 的分词器;只要它是快速分词器,此方法适用于任何类型的分词器。在下一章中,我们将看到如何利用此功能将我们为每个单词拥有的标签正确地应用于诸如命名实体识别 (NER) 和词性 (POS) 标注等任务中的标记。我们还可以使用它来屏蔽来自同一单词的所有标记(一种称为全词屏蔽的技术)。
什么是单词的概念很复杂。例如,“I’ll”(“I will”的缩写)算作一个词还是两个词?实际上这取决于分词器以及它应用的预分词操作。一些分词器只在空格处分割,因此它们将此视为一个词。其他分词器除了空格之外还使用标点符号,因此它们将此视为两个词。
✏️ 试试看! 从 bert-base-cased
和 roberta-base
检查点创建一个分词器,并用它们对“81s”进行分词。你观察到了什么?单词 ID 是什么?
类似地,有一个 sentence_ids()
方法,我们可以用它将标记映射到它来自的句子(尽管在这种情况下,分词器返回的 token_type_ids
可以给我们相同的信息)。
最后,我们可以通过 word_to_chars()
或 token_to_chars()
和 char_to_word()
或 char_to_token()
方法将任何单词或标记映射到原始文本中的字符,反之亦然。例如,word_ids()
方法告诉我们 ##yl
是索引 3 处的单词的一部分,但它是在句子的哪个词中?我们可以这样找出
start, end = encoding.word_to_chars(3)
example[start:end]
Sylvain
如前所述,这一切都是由于快速分词器在偏移量列表中跟踪每个标记来自的文本范围。为了说明它们的用法,接下来我们将向您展示如何手动复制 token-classification
管道的结果。
✏️ 试试看! 创建您自己的示例文本,看看您是否可以理解哪些标记与词 ID 相关联,以及如何提取单个词的字符跨度。 作为奖励,尝试使用两个句子作为输入,看看句子 ID 是否对您有意义。
Token 分类管道的内部机制
在 第 1 章 中,我们第一次尝试应用 NER - 其中任务是识别文本中哪些部分对应于人、地点或组织等实体 - 使用 🤗 Transformers pipeline()
函数。 然后,在 第 2 章 中,我们看到了管道如何将从原始文本获取预测所需的三个阶段组合在一起:标记化、将输入传递给模型和后处理。 token-classification
管道的头两个步骤与任何其他管道相同,但后处理稍微复杂一些 - 让我们看看它是如何工作的!
使用管道获取基本结果
首先,让我们获取一个 token 分类管道,以便我们可以得到一些结果来进行手动比较。 默认使用的模型是 dbmdz/bert-large-cased-finetuned-conll03-english
; 它对句子执行 NER
from transformers import pipeline
token_classifier = pipeline("token-classification")
token_classifier("My name is Sylvain and I work at Hugging Face in Brooklyn.")
[{'entity': 'I-PER', 'score': 0.9993828, 'index': 4, 'word': 'S', 'start': 11, 'end': 12},
{'entity': 'I-PER', 'score': 0.99815476, 'index': 5, 'word': '##yl', 'start': 12, 'end': 14},
{'entity': 'I-PER', 'score': 0.99590725, 'index': 6, 'word': '##va', 'start': 14, 'end': 16},
{'entity': 'I-PER', 'score': 0.9992327, 'index': 7, 'word': '##in', 'start': 16, 'end': 18},
{'entity': 'I-ORG', 'score': 0.97389334, 'index': 12, 'word': 'Hu', 'start': 33, 'end': 35},
{'entity': 'I-ORG', 'score': 0.976115, 'index': 13, 'word': '##gging', 'start': 35, 'end': 40},
{'entity': 'I-ORG', 'score': 0.98879766, 'index': 14, 'word': 'Face', 'start': 41, 'end': 45},
{'entity': 'I-LOC', 'score': 0.99321055, 'index': 16, 'word': 'Brooklyn', 'start': 49, 'end': 57}]
该模型正确地识别了由“Sylvain”生成的每个标记为一个人,由“Hugging Face”生成的每个标记为一个组织,以及“Brooklyn”标记为一个位置。 我们还可以要求管道将对应于同一实体的标记分组在一起
from transformers import pipeline
token_classifier = pipeline("token-classification", aggregation_strategy="simple")
token_classifier("My name is Sylvain and I work at Hugging Face in Brooklyn.")
[{'entity_group': 'PER', 'score': 0.9981694, 'word': 'Sylvain', 'start': 11, 'end': 18},
{'entity_group': 'ORG', 'score': 0.97960204, 'word': 'Hugging Face', 'start': 33, 'end': 45},
{'entity_group': 'LOC', 'score': 0.99321055, 'word': 'Brooklyn', 'start': 49, 'end': 57}]
选择的 aggregation_strategy
将改变为每个分组实体计算的分数。 使用 "simple"
,分数只是给定实体中每个标记分数的平均值:例如,“Sylvain”的分数是我们在上一个示例中看到的 S
、##yl
、##va
和 ##in
标记分数的平均值。 其他可用的策略包括
"first"
,其中每个实体的分数是该实体第一个标记的分数(因此对于“Sylvain”,它将是 0.993828,即S
标记的分数)"max"
,其中每个实体的分数是该实体中标记的最大分数(因此对于“Hugging Face”,它将是 0.98879766,即“Face”的分数)"average"
,其中每个实体的分数是构成该实体的词的平均分数(因此对于“Sylvain”,与"simple"
策略没有区别,但“Hugging Face”的分数将是 0.9819,即“Hugging”分数 0.975 和“Face”分数 0.98879 的平均值)
现在让我们看看如何在不使用 pipeline()
函数的情况下获得这些结果!
从输入到预测
首先,我们需要对输入进行标记化,并将其传递给模型。 这与 第 2 章 中的操作完全相同; 我们使用 AutoXxx
类实例化标记器和模型,然后将它们应用于我们的示例
from transformers import AutoTokenizer, AutoModelForTokenClassification
model_checkpoint = "dbmdz/bert-large-cased-finetuned-conll03-english"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
model = AutoModelForTokenClassification.from_pretrained(model_checkpoint)
example = "My name is Sylvain and I work at Hugging Face in Brooklyn."
inputs = tokenizer(example, return_tensors="pt")
outputs = model(**inputs)
由于我们在这里使用 AutoModelForTokenClassification
,因此我们为输入序列中的每个标记获得一组 logits
print(inputs["input_ids"].shape)
print(outputs.logits.shape)
torch.Size([1, 19])
torch.Size([1, 19, 9])
我们有一个包含 1 个序列的 19 个标记的批次,模型有 9 个不同的标签,因此模型的输出的形状为 1 x 19 x 9。 与文本分类管道一样,我们使用 softmax 函数将这些 logits 转换为概率,然后我们取 argmax 以获得预测(注意,我们可以取 logits 的 argmax,因为 softmax 不会改变顺序)
import torch
probabilities = torch.nn.functional.softmax(outputs.logits, dim=-1)[0].tolist()
predictions = outputs.logits.argmax(dim=-1)[0].tolist()
print(predictions)
[0, 0, 0, 0, 4, 4, 4, 4, 0, 0, 0, 0, 6, 6, 6, 0, 8, 0, 0]
model.config.id2label
属性包含索引到标签的映射,我们可以用它来理解预测
model.config.id2label
{0: 'O',
1: 'B-MISC',
2: 'I-MISC',
3: 'B-PER',
4: 'I-PER',
5: 'B-ORG',
6: 'I-ORG',
7: 'B-LOC',
8: 'I-LOC'}
正如我们之前所见,有 9 个标签:O
是未包含在任何命名实体中的标记的标签(代表“外部”),然后我们为每种类型的实体有两个标签(杂项、人、组织和位置)。 标签 B-XXX
表示标记位于实体 XXX
的开头,标签 I-XXX
表示标记位于实体 XXX
的内部。 例如,在当前示例中,我们预计我们的模型将把 S
标记分类为 B-PER
(人的实体开头),并将 ##yl
、##va
和 ##in
标记分类为 I-PER
(人的实体内部)。
您可能认为模型在这种情况下是错误的,因为它将 I-PER
标签分配给了所有四个标记,但这并不完全正确。 这些 B-
和 I-
标签实际上有两种格式:IOB1 和 IOB2。 IOB2 格式(下面粉红色显示)是我们介绍的格式,而在 IOB1 格式(蓝色显示)中,以 B-
开头的标签只用于分隔两种相邻的相同类型实体。 我们正在使用的模型是在使用该格式的数据集上进行微调的,这就是为什么它将 I-PER
标签分配给 S
标记的原因。
有了这个映射,我们就可以重现(几乎完全)第一个管道的结果 - 我们只需获取所有未分类为 O
的标记的分数和标签
results = []
tokens = inputs.tokens()
for idx, pred in enumerate(predictions):
label = model.config.id2label[pred]
if label != "O":
results.append(
{"entity": label, "score": probabilities[idx][pred], "word": tokens[idx]}
)
print(results)
[{'entity': 'I-PER', 'score': 0.9993828, 'index': 4, 'word': 'S'},
{'entity': 'I-PER', 'score': 0.99815476, 'index': 5, 'word': '##yl'},
{'entity': 'I-PER', 'score': 0.99590725, 'index': 6, 'word': '##va'},
{'entity': 'I-PER', 'score': 0.9992327, 'index': 7, 'word': '##in'},
{'entity': 'I-ORG', 'score': 0.97389334, 'index': 12, 'word': 'Hu'},
{'entity': 'I-ORG', 'score': 0.976115, 'index': 13, 'word': '##gging'},
{'entity': 'I-ORG', 'score': 0.98879766, 'index': 14, 'word': 'Face'},
{'entity': 'I-LOC', 'score': 0.99321055, 'index': 16, 'word': 'Brooklyn'}]
这与我们之前得到的结果非常相似,唯一的例外是:管道还提供了有关每个实体在原始句子中的 start
和 end
的信息。 这就是我们的偏移映射发挥作用的地方。 要获取偏移量,我们只需在将标记器应用于输入时设置 return_offsets_mapping=True
inputs_with_offsets = tokenizer(example, return_offsets_mapping=True)
inputs_with_offsets["offset_mapping"]
[(0, 0), (0, 2), (3, 7), (8, 10), (11, 12), (12, 14), (14, 16), (16, 18), (19, 22), (23, 24), (25, 29), (30, 32),
(33, 35), (35, 40), (41, 45), (46, 48), (49, 57), (57, 58), (0, 0)]
每个元组都是对应于每个标记的文本跨度,其中 (0, 0)
保留给特殊标记。 我们之前看到索引为 5 的标记是 ##yl
,它在这里的偏移量为 (12, 14)
。 如果我们在示例中获取相应的切片
example[12:14]
我们得到正确的文本跨度,没有 ##
yl
使用它,我们现在可以完成之前的结果
results = []
inputs_with_offsets = tokenizer(example, return_offsets_mapping=True)
tokens = inputs_with_offsets.tokens()
offsets = inputs_with_offsets["offset_mapping"]
for idx, pred in enumerate(predictions):
label = model.config.id2label[pred]
if label != "O":
start, end = offsets[idx]
results.append(
{
"entity": label,
"score": probabilities[idx][pred],
"word": tokens[idx],
"start": start,
"end": end,
}
)
print(results)
[{'entity': 'I-PER', 'score': 0.9993828, 'index': 4, 'word': 'S', 'start': 11, 'end': 12},
{'entity': 'I-PER', 'score': 0.99815476, 'index': 5, 'word': '##yl', 'start': 12, 'end': 14},
{'entity': 'I-PER', 'score': 0.99590725, 'index': 6, 'word': '##va', 'start': 14, 'end': 16},
{'entity': 'I-PER', 'score': 0.9992327, 'index': 7, 'word': '##in', 'start': 16, 'end': 18},
{'entity': 'I-ORG', 'score': 0.97389334, 'index': 12, 'word': 'Hu', 'start': 33, 'end': 35},
{'entity': 'I-ORG', 'score': 0.976115, 'index': 13, 'word': '##gging', 'start': 35, 'end': 40},
{'entity': 'I-ORG', 'score': 0.98879766, 'index': 14, 'word': 'Face', 'start': 41, 'end': 45},
{'entity': 'I-LOC', 'score': 0.99321055, 'index': 16, 'word': 'Brooklyn', 'start': 49, 'end': 57}]
这与我们从第一个管道中获得的结果相同!
分组实体
使用偏移量来确定每个实体的开始和结束键非常方便,但这项信息并不是严格必需的。 但是,当我们想要将实体分组在一起时,偏移量将为我们节省大量混乱的代码。 例如,如果我们想要将 Hu
、##gging
和 Face
标记分组在一起,我们可以制定一些特殊的规则,说前两个应该连接在一起,同时删除 ##
,而 Face
应该用空格添加,因为它不以 ##
开头 - 但这只适用于这种特定类型的标记器。 我们将不得不为 SentencePiece 或 Byte-Pair-Encoding 标记器(本章稍后讨论)编写另一组规则。
有了偏移量,所有这些自定义代码都不存在了:我们只需获取从第一个标记开始并以最后一个标记结束的原始文本中的跨度。 因此,在 Hu
、##gging
和 Face
标记的情况下,我们应该从字符 33(Hu
的开头)开始,并在字符 45(Face
的结尾)之前结束
example[33:45]
Hugging Face
要编写在分组实体时后处理预测的代码,我们将把连续标记为 I-XXX
的实体分组在一起,除了第一个实体,它可以标记为 B-XXX
或 I-XXX
(因此,当我们得到一个 O
、一个新的实体类型,或者一个告诉我们相同类型实体开始的 B-XXX
时,我们停止分组实体)
import numpy as np
results = []
inputs_with_offsets = tokenizer(example, return_offsets_mapping=True)
tokens = inputs_with_offsets.tokens()
offsets = inputs_with_offsets["offset_mapping"]
idx = 0
while idx < len(predictions):
pred = predictions[idx]
label = model.config.id2label[pred]
if label != "O":
# Remove the B- or I-
label = label[2:]
start, _ = offsets[idx]
# Grab all the tokens labeled with I-label
all_scores = []
while (
idx < len(predictions)
and model.config.id2label[predictions[idx]] == f"I-{label}"
):
all_scores.append(probabilities[idx][pred])
_, end = offsets[idx]
idx += 1
# The score is the mean of all the scores of the tokens in that grouped entity
score = np.mean(all_scores).item()
word = example[start:end]
results.append(
{
"entity_group": label,
"score": score,
"word": word,
"start": start,
"end": end,
}
)
idx += 1
print(results)
我们得到了与第二个管道相同的结果!
[{'entity_group': 'PER', 'score': 0.9981694, 'word': 'Sylvain', 'start': 11, 'end': 18},
{'entity_group': 'ORG', 'score': 0.97960204, 'word': 'Hugging Face', 'start': 33, 'end': 45},
{'entity_group': 'LOC', 'score': 0.99321055, 'word': 'Brooklyn', 'start': 49, 'end': 57}]
这些偏移量非常有用的另一个任务示例是问答。 深入研究该管道,我们将在下一节中进行,也将使我们能够了解 🤗 Transformers 库中的标记器的最后一个功能:处理我们在将输入截断到给定长度时溢出的标记。