LLM 課程文件

快速分詞器的超能力

Hugging Face's logo
加入 Hugging Face 社群

並獲得增強的文件體驗

開始使用

快速分詞器的超能力

Ask a Question Open In Colab Open In Studio Lab

在本節中,我們將仔細研究 🤗 Transformers 中分詞器的功能。到目前為止,我們只使用它們對輸入進行分詞或將 ID 解碼迴文本,但是分詞器——尤其是那些由 🤗 Tokenizers 庫支援的分詞器——可以做更多的事情。為了說明這些附加功能,我們將探索如何重現我們在第一章中首次遇到的 token-classification(我們稱之為 ner)和 question-answering 流水線的結果。

在下面的討論中,我們將經常區分“慢速”分詞器和“快速”分詞器。慢速分詞器是指在 🤗 Transformers 庫內部用 Python 編寫的分詞器,而快速版本是由 🤗 Tokenizers 提供的,它們是用 Rust 編寫的。如果你還記得第五章中報告快速和慢速分詞器對藥物評論資料集進行分詞所需時間的表格,你就應該明白為什麼我們稱它們為快速和慢速分詞器。

快速分詞器 慢速分詞器
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 物件提供的額外方法。我們有兩種方法來檢查我們的分詞器是快速的還是慢速的。我們可以檢查 tokenizeris_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 對你來說是否有意義。

分詞分類流水線內部

第一章中,我們首次嘗試使用 🤗 Transformers 的 pipeline() 函式應用 NER — 任務是識別文字中與人、地點或組織等實體相對應的部分。然後,在第二章中,我們看到了流水線如何將獲取原始文字預測所需的三個階段組合在一起:分詞、透過模型傳遞輸入和後處理。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() 函式的情況下獲得這些結果!

從輸入到預測

首先,我們需要對輸入進行分詞並將其傳遞給模型。這與第二章中的操作完全相同;我們使用 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 是不屬於任何命名實體的標記的標籤(它代表“outside”),然後我們為每種實體型別(雜項、人物、組織和地點)提供兩個標籤。標籤 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 標記。

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 或位元組對編碼分詞器(本章後面討論)編寫另一套規則。

有了偏移量,所有這些自定義程式碼都消失了:我們只需獲取原始文字中從第一個標記開始到最後一個標記結束的跨度。因此,在標記 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 上更新

© . This site is unofficial and not affiliated with Hugging Face, Inc.