LLM 课程文档

快速分词器的特殊能力

Hugging Face's logo
加入 Hugging Face 社区

并获得增强的文档体验

开始使用

快速分词器的特殊能力

Ask a Question Open In Colab Open In Studio Lab

在本节中,我们将仔细研究 🤗 Transformers 中分词器的功能。到目前为止,我们仅使用它们来对输入进行分词或将 ID 解码回文本,但是分词器(尤其是由 🤗 Tokenizers 库支持的分词器)可以做更多的事情。为了说明这些附加功能,我们将探索如何重现我们在 第 1 章 中首次遇到的 token-classification(我们称之为 ner)和 question-answering 流程的结果。

在以下讨论中,我们经常区分“慢速”和“快速”分词器。慢速分词器是在 🤗 Transformers 库内部用 Python 编写的,而快速版本是由 🤗 Tokenizers 提供的,后者是用 Rust 编写的。如果您还记得 第 5 章 中的表格,该表格报告了快速和慢速分词器对 Drug Review Dataset 进行分词所需的时间,那么您应该了解我们为什么称它们为快速和慢速

快速分词器 慢速分词器
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-casedroberta-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-classification 流程内部

第 1 章 中,我们首次体验了应用 NER——任务是识别文本的哪些部分对应于诸如人、地点或组织之类的实体——使用 🤗 Transformers pipeline() 函数。然后,在 第 2 章 中,我们看到了一个流程如何将从原始文本中获得预测所需的三个阶段组合在一起:分词化、将输入传递给模型和后处理。token-classification 流程中的前两个步骤与任何其他流程中的步骤相同,但后处理稍微复杂一些——让我们看看如何!

使用流程获得基本结果

首先,让我们获取一个标记分类流程,以便我们可以手动比较一些结果。默认使用的模型是 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- 标签,实际上有两种格式:IOB1IOB2。IOB2 格式(粉色),是我们介绍的格式,而在 IOB1 格式(蓝色)中,以 B- 开头的标签仅用于分隔两个相邻的相同类型的实体。我们正在使用的模型是在使用该格式的数据集上进行微调的,这就是为什么它将标签 I-PER 分配给 S 标记的原因。

IOB1 vs IOB2 format

有了这张地图,我们就准备好重现(几乎完全)第一个流程的结果——我们可以只获取未分类为 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'}]

这与我们之前的非常相似,但有一个例外:流程还为我们提供了有关原始句子中每个实体的 startend 的信息。这就是我们的偏移映射将发挥作用的地方。要获得偏移量,我们只需在将分词器应用于输入时设置 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##ggingFace 分组在一起,我们可以制定特殊规则,说明前两个应该在删除 ## 的同时附加,并且 Face 应该添加一个空格,因为它不是以 ## 开头——但这仅适用于这种特定类型的分词器。我们必须为 SentencePiece 或 Byte-Pair-Encoding 分词器(在本章稍后讨论)编写另一组规则。

有了偏移量,所有自定义代码都消失了:我们只需获取原始文本中以第一个标记开头并以最后一个标记结尾的跨度即可。因此,对于标记 Hu##ggingFace,我们应该从字符 33(Hu 的开头)开始,到字符 45(Face 的结尾)之前结束

example[33:45]
Hugging Face

为了编写后处理预测同时对实体进行分组的代码,我们将对连续且标记为 I-XXX 的实体进行分组,但第一个实体除外,第一个实体可以标记为 B-XXXI-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 库中分词器的最后一个功能:当我们截断输入到给定长度时,处理溢出的标记。

< > 在 GitHub 上更新