LLM 課程文件
問答
並獲得增強的文件體驗
開始使用
問答
是時候瞭解問答了!這項任務有多種形式,但我們將在本節中重點介紹的是抽取式問答。這包括提出關於文件的問題,並將答案識別為文件本身的文字片段。
我們將在 SQuAD 資料集上微調 BERT 模型,該資料集由眾包工作者在一組維基百科文章上提出的問題組成。這將為我們提供一個能夠計算如下預測的模型
這實際上展示了使用本節中顯示的程式碼訓練並上傳到 Hub 的模型。您可以在這裡找到它並再次檢查預測。
💡 像 BERT 這樣的僅編碼器模型善於提取“誰發明了 Transformer 架構?”這樣的事實性問題的答案,但在回答“為什麼天空是藍色的?”這樣的開放式問題時表現不佳。在這些更具挑戰性的情況下,通常使用像 T5 和 BART 這樣的編碼器-解碼器模型來合成資訊,這與文字摘要非常相似。如果您對這種生成式問答感興趣,我們建議您檢視我們基於 ELI5 資料集的演示。
準備資料
用於抽取式問答的學術基準資料集是 SQuAD,所以我們在這裡使用它。還有一個更難的 SQuAD v2 基準,其中包括沒有答案的問題。只要您自己的資料集包含上下文列、問題列和答案列,您就可以調整下面的步驟。
SQuAD 資料集
像往常一樣,藉助 load_dataset()
,我們只需一步即可下載和快取資料集
from datasets import load_dataset
raw_datasets = load_dataset("squad")
然後我們可以檢視此物件以瞭解有關 SQuAD 資料集的更多資訊
raw_datasets
DatasetDict({
train: Dataset({
features: ['id', 'title', 'context', 'question', 'answers'],
num_rows: 87599
})
validation: Dataset({
features: ['id', 'title', 'context', 'question', 'answers'],
num_rows: 10570
})
})
看起來我們擁有 context
、question
和 answers
欄位所需的一切,所以讓我們打印出訓練集第一個元素的這些欄位
print("Context: ", raw_datasets["train"][0]["context"])
print("Question: ", raw_datasets["train"][0]["question"])
print("Answer: ", raw_datasets["train"][0]["answers"])
Context: 'Architecturally, the school has a Catholic character. Atop the Main Building\'s gold dome is a golden statue of the Virgin Mary. Immediately in front of the Main Building and facing it, is a copper statue of Christ with arms upraised with the legend "Venite Ad Me Omnes". Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basilica is the Grotto, a Marian place of prayer and reflection. It is a replica of the grotto at Lourdes, France where the Virgin Mary reputedly appeared to Saint Bernadette Soubirous in 1858. At the end of the main drive (and in a direct line that connects through 3 statues and the Gold Dome), is a simple, modern stone statue of Mary.'
Question: 'To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France?'
Answer: {'text': ['Saint Bernadette Soubirous'], 'answer_start': [515]}
context
和 question
欄位使用起來非常簡單。answers
欄位有點複雜,因為它包含一個包含兩個欄位的字典,這兩個欄位都是列表。這是評估期間 squad
指標所期望的格式;如果您使用自己的資料,則無需擔心將答案設定為相同的格式。text
欄位相當明顯,answer_start
欄位包含每個答案在上下文中的起始字元索引。
在訓練期間,只有一個可能的答案。我們可以透過使用 Dataset.filter()
方法來再次檢查這一點
raw_datasets["train"].filter(lambda x: len(x["answers"]["text"]) != 1)
Dataset({
features: ['id', 'title', 'context', 'question', 'answers'],
num_rows: 0
})
然而,對於評估,每個樣本有幾個可能的答案,它們可能相同或不同
print(raw_datasets["validation"][0]["answers"])
print(raw_datasets["validation"][2]["answers"])
{'text': ['Denver Broncos', 'Denver Broncos', 'Denver Broncos'], 'answer_start': [177, 177, 177]}
{'text': ['Santa Clara, California', "Levi's Stadium", "Levi's Stadium in the San Francisco Bay Area at Santa Clara, California."], 'answer_start': [403, 355, 355]}
我們不會深入研究評估指令碼,因為它將由 🤗 Datasets 指標為我們封裝所有內容,但簡而言之,一些問題有幾個可能的答案,此指令碼將比較預測的答案與所有可接受的答案並取最佳分數。例如,如果我們檢視索引 2 的樣本
print(raw_datasets["validation"][2]["context"])
print(raw_datasets["validation"][2]["question"])
'Super Bowl 50 was an American football game to determine the champion of the National Football League (NFL) for the 2015 season. The American Football Conference (AFC) champion Denver Broncos defeated the National Football Conference (NFC) champion Carolina Panthers 24–10 to earn their third Super Bowl title. The game was played on February 7, 2016, at Levi\'s Stadium in the San Francisco Bay Area at Santa Clara, California. As this was the 50th Super Bowl, the league emphasized the "golden anniversary" with various gold-themed initiatives, as well as temporarily suspending the tradition of naming each Super Bowl game with Roman numerals (under which the game would have been known as "Super Bowl L"), so that the logo could prominently feature the Arabic numerals 50.'
'Where did Super Bowl 50 take place?'
我們可以看到答案確實可以是之前看到的三個可能性之一。
處理訓練資料
讓我們從預處理訓練資料開始。最難的部分將是為問題的答案生成標籤,這將是上下文中與答案對應的詞元的起始和結束位置。
但讓我們不要超前。首先,我們需要使用分詞器將輸入中的文字轉換為模型可以理解的 ID
from transformers import AutoTokenizer
model_checkpoint = "bert-base-cased"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
如前所述,我們將微調 BERT 模型,但只要實現了快速分詞器,您可以使用任何其他模型型別。您可以在此大表中檢視所有附帶快速版本的架構,並檢查您正在使用的 tokenizer
物件是否確實由 🤗 Tokenizers 提供支援,您可以檢視其 is_fast
屬性
tokenizer.is_fast
True
我們可以將問題和上下文一起傳遞給我們的分詞器,它將正確插入特殊詞元以形成如下句子
[CLS] question [SEP] context [SEP]
讓我們再次檢查
context = raw_datasets["train"][0]["context"]
question = raw_datasets["train"][0]["question"]
inputs = tokenizer(question, context)
tokenizer.decode(inputs["input_ids"])
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] Architecturally, '
'the school has a Catholic character. Atop the Main Building\'s gold dome is a golden statue of the Virgin '
'Mary. Immediately in front of the Main Building and facing it, is a copper statue of Christ with arms '
'upraised with the legend " Venite Ad Me Omnes ". Next to the Main Building is the Basilica of the Sacred '
'Heart. Immediately behind the basilica is the Grotto, a Marian place of prayer and reflection. It is a '
'replica of the grotto at Lourdes, France where the Virgin Mary reputedly appeared to Saint Bernadette '
'Soubirous in 1858. At the end of the main drive ( and in a direct line that connects through 3 statues '
'and the Gold Dome ), is a simple, modern stone statue of Mary. [SEP]'
標籤將是答案起始和結束詞元的索引,模型將負責預測輸入中每個詞元的一個起始和結束邏輯,理論標籤如下
在這種情況下,上下文不會太長,但資料集中有些示例的上下文非常長,會超出我們設定的最大長度(本例中為 384)。正如我們在第六章探索 question-answering
管道的內部機制時所見,我們將透過從資料集的一個樣本中建立多個訓練特徵,並在它們之間設定滑動視窗來處理長上下文。
要了解如何使用當前示例進行此操作,我們可以將長度限制為 100,並使用 50 個詞元的滑動視窗。作為提醒,我們使用
max_length
設定最大長度(此處為 100)truncation="only_second"
在問題及其上下文過長時截斷上下文(位於第二個位置)stride
設定兩個連續塊之間重疊的詞元數量(此處為 50)return_overflowing_tokens=True
讓分詞器知道我們需要溢位的詞元
inputs = tokenizer(
question,
context,
max_length=100,
truncation="only_second",
stride=50,
return_overflowing_tokens=True,
)
for ids in inputs["input_ids"]:
print(tokenizer.decode(ids))
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] Architecturally, the school has a Catholic character. Atop the Main Building\'s gold dome is a golden statue of the Virgin Mary. Immediately in front of the Main Building and facing it, is a copper statue of Christ with arms upraised with the legend " Venite Ad Me Omnes ". Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basi [SEP]'
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] the Main Building and facing it, is a copper statue of Christ with arms upraised with the legend " Venite Ad Me Omnes ". Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basilica is the Grotto, a Marian place of prayer and reflection. It is a replica of the grotto at Lourdes, France where the Virgin [SEP]'
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basilica is the Grotto, a Marian place of prayer and reflection. It is a replica of the grotto at Lourdes, France where the Virgin Mary reputedly appeared to Saint Bernadette Soubirous in 1858. At the end of the main drive ( and in a direct line that connects through 3 [SEP]'
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP]. It is a replica of the grotto at Lourdes, France where the Virgin Mary reputedly appeared to Saint Bernadette Soubirous in 1858. At the end of the main drive ( and in a direct line that connects through 3 statues and the Gold Dome ), is a simple, modern stone statue of Mary. [SEP]'
如我們所見,我們的示例被分割成四個輸入,每個輸入都包含問題和上下文的一部分。請注意,問題的答案(“Bernadette Soubirous”)僅出現在第三個和最後一個輸入中,因此以這種方式處理長上下文將建立一些訓練示例,其中不包含答案。對於這些示例,標籤將是 start_position = end_position = 0
(因此我們預測 [CLS]
詞元)。在不幸的情況下,如果答案已被截斷,以至於我們只剩下答案的開始(或結束)部分,我們也會設定這些標籤。對於答案完全包含在上下文中的示例,標籤將是答案開始的詞元索引和答案結束的詞元索引。
資料集為我們提供了答案在上下文中的起始字元,透過加上答案的長度,我們可以找到答案在上下文中的結束字元。為了將這些對映到詞元索引,我們需要使用我們在第六章中學習的偏移對映。我們可以透過傳入 return_offsets_mapping=True
讓分詞器返回這些偏移對映。
inputs = tokenizer(
question,
context,
max_length=100,
truncation="only_second",
stride=50,
return_overflowing_tokens=True,
return_offsets_mapping=True,
)
inputs.keys()
dict_keys(['input_ids', 'token_type_ids', 'attention_mask', 'offset_mapping', 'overflow_to_sample_mapping'])
如我們所見,我們得到了常規的輸入 ID、詞元型別 ID 和注意力掩碼,以及我們需要的偏移對映和額外的鍵 overflow_to_sample_mapping
。當我們將多個文字同時分詞時(我們應該這樣做以利用分詞器由 Rust 提供支援的優勢),相應的值將對我們有用。由於一個樣本可以生成多個特徵,因此它將每個特徵對映到其原始示例。因為這裡我們只分詞了一個示例,所以我們得到一個 0
的列表
inputs["overflow_to_sample_mapping"]
[0, 0, 0, 0]
但如果我們分詞更多的例子,這將變得更有用
inputs = tokenizer(
raw_datasets["train"][2:6]["question"],
raw_datasets["train"][2:6]["context"],
max_length=100,
truncation="only_second",
stride=50,
return_overflowing_tokens=True,
return_offsets_mapping=True,
)
print(f"The 4 examples gave {len(inputs['input_ids'])} features.")
print(f"Here is where each comes from: {inputs['overflow_to_sample_mapping']}.")
'The 4 examples gave 19 features.'
'Here is where each comes from: [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3].'
如我們所見,前三個示例(在訓練集中的索引 2、3 和 4)各生成了四個特徵,最後一個示例(在訓練集中的索引 5)生成了 7 個特徵。
這些資訊將有助於我們將獲得的每個特徵對映到其對應的標籤。如前所述,這些標籤是:
- 如果答案不在上下文的相應跨度中,則為
(0, 0)
- 如果答案在上下文的相應跨度中,則為
(start_position, end_position)
,其中start_position
是答案開始的詞元(在輸入 ID 中)的索引,end_position
是答案結束的詞元(在輸入 ID 中)的索引。
為了確定是哪種情況,以及(如果相關)詞元的位置,我們首先找到在輸入 ID 中開始和結束上下文的索引。我們可以使用詞元型別 ID 來完成此操作,但由於並非所有模型都必然存在這些 ID(例如 DistilBERT 不需要它們),因此我們將改為使用分詞器返回的 BatchEncoding
的 sequence_ids()
方法。
一旦我們有了這些詞元索引,我們就檢視相應的偏移量,它們是表示原始上下文中的字元跨度的兩個整數元組。因此,我們可以檢測此特徵中上下文的塊是否在答案之後開始或在答案開始之前結束(在這種情況下,標籤為 (0, 0)
)。如果不是這種情況,我們迴圈以找到答案的第一個和最後一個詞元。
answers = raw_datasets["train"][2:6]["answers"]
start_positions = []
end_positions = []
for i, offset in enumerate(inputs["offset_mapping"]):
sample_idx = inputs["overflow_to_sample_mapping"][i]
answer = answers[sample_idx]
start_char = answer["answer_start"][0]
end_char = answer["answer_start"][0] + len(answer["text"][0])
sequence_ids = inputs.sequence_ids(i)
# Find the start and end of the context
idx = 0
while sequence_ids[idx] != 1:
idx += 1
context_start = idx
while sequence_ids[idx] == 1:
idx += 1
context_end = idx - 1
# If the answer is not fully inside the context, label is (0, 0)
if offset[context_start][0] > start_char or offset[context_end][1] < end_char:
start_positions.append(0)
end_positions.append(0)
else:
# Otherwise it's the start and end token positions
idx = context_start
while idx <= context_end and offset[idx][0] <= start_char:
idx += 1
start_positions.append(idx - 1)
idx = context_end
while idx >= context_start and offset[idx][1] >= end_char:
idx -= 1
end_positions.append(idx + 1)
start_positions, end_positions
([83, 51, 19, 0, 0, 64, 27, 0, 34, 0, 0, 0, 67, 34, 0, 0, 0, 0, 0],
[85, 53, 21, 0, 0, 70, 33, 0, 40, 0, 0, 0, 68, 35, 0, 0, 0, 0, 0])
讓我們看看一些結果,以驗證我們的方法是否正確。對於第一個特徵,我們發現標籤為 (83, 85)
,所以讓我們將理論答案與從 83 到 85(包括)的解碼詞元跨度進行比較
idx = 0
sample_idx = inputs["overflow_to_sample_mapping"][idx]
answer = answers[sample_idx]["text"][0]
start = start_positions[idx]
end = end_positions[idx]
labeled_answer = tokenizer.decode(inputs["input_ids"][idx][start : end + 1])
print(f"Theoretical answer: {answer}, labels give: {labeled_answer}")
'Theoretical answer: the Main Building, labels give: the Main Building'
所以匹配成功!現在讓我們檢查索引 4,我們將標籤設定為 (0, 0)
,這意味著答案不在該特徵的上下文塊中。
idx = 4
sample_idx = inputs["overflow_to_sample_mapping"][idx]
answer = answers[sample_idx]["text"][0]
decoded_example = tokenizer.decode(inputs["input_ids"][idx])
print(f"Theoretical answer: {answer}, decoded example: {decoded_example}")
'Theoretical answer: a Marian place of prayer and reflection, decoded example: [CLS] What is the Grotto at Notre Dame? [SEP] Architecturally, the school has a Catholic character. Atop the Main Building\'s gold dome is a golden statue of the Virgin Mary. Immediately in front of the Main Building and facing it, is a copper statue of Christ with arms upraised with the legend " Venite Ad Me Omnes ". Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basilica is the Grot [SEP]'
確實,我們沒有在上下文中看到答案。
✏️ 輪到你了! 使用 XLNet 架構時,填充應用於左側,問題和上下文互換。將我們剛剛看到的所有程式碼適應 XLNet 架構(並新增 padding=True
)。請注意,應用填充後,[CLS]
詞元可能不在位置 0。
現在我們已經逐步瞭解瞭如何預處理我們的訓練資料,我們可以將其組合成一個函式,並將其應用於整個訓練資料集。我們將每個特徵填充到我們設定的最大長度,因為大多數上下文都會很長(並且相應的樣本將被分割成多個特徵),因此在此處應用動態填充並沒有真正的好處。
max_length = 384
stride = 128
def preprocess_training_examples(examples):
questions = [q.strip() for q in examples["question"]]
inputs = tokenizer(
questions,
examples["context"],
max_length=max_length,
truncation="only_second",
stride=stride,
return_overflowing_tokens=True,
return_offsets_mapping=True,
padding="max_length",
)
offset_mapping = inputs.pop("offset_mapping")
sample_map = inputs.pop("overflow_to_sample_mapping")
answers = examples["answers"]
start_positions = []
end_positions = []
for i, offset in enumerate(offset_mapping):
sample_idx = sample_map[i]
answer = answers[sample_idx]
start_char = answer["answer_start"][0]
end_char = answer["answer_start"][0] + len(answer["text"][0])
sequence_ids = inputs.sequence_ids(i)
# Find the start and end of the context
idx = 0
while sequence_ids[idx] != 1:
idx += 1
context_start = idx
while sequence_ids[idx] == 1:
idx += 1
context_end = idx - 1
# If the answer is not fully inside the context, label is (0, 0)
if offset[context_start][0] > start_char or offset[context_end][1] < end_char:
start_positions.append(0)
end_positions.append(0)
else:
# Otherwise it's the start and end token positions
idx = context_start
while idx <= context_end and offset[idx][0] <= start_char:
idx += 1
start_positions.append(idx - 1)
idx = context_end
while idx >= context_start and offset[idx][1] >= end_char:
idx -= 1
end_positions.append(idx + 1)
inputs["start_positions"] = start_positions
inputs["end_positions"] = end_positions
return inputs
請注意,我們定義了兩個常量來確定所使用的最大長度以及滑動視窗的長度,並且我們在分詞之前進行了一些清理:SQuAD 資料集中的一些問題在開頭和結尾處有多餘的空格,這些空格沒有任何作用(如果您使用像 RoBERTa 這樣的模型,在分詞時會佔用空間),所以我們刪除了這些多餘的空格。
要將此函式應用於整個訓練集,我們使用帶有 batched=True
標誌的 Dataset.map()
方法。在這裡是必需的,因為我們正在更改資料集的長度(因為一個示例可以生成多個訓練特徵)。
train_dataset = raw_datasets["train"].map(
preprocess_training_examples,
batched=True,
remove_columns=raw_datasets["train"].column_names,
)
len(raw_datasets["train"]), len(train_dataset)
(87599, 88729)
如我們所見,預處理增加了大約 1,000 個特徵。我們的訓練集現在已準備好使用 — 讓我們深入研究驗證集的預處理!
處理驗證資料
預處理驗證資料將稍微容易一些,因為我們不需要生成標籤(除非我們想計算驗證損失,但這個數字並不能真正幫助我們瞭解模型的優劣)。真正的樂趣在於將模型的預測解釋為原始上下文的跨度。為此,我們只需要儲存偏移對映和某種方式來將每個建立的特徵與它來自的原始示例匹配。由於原始資料集中有一個 ID 列,我們將使用該 ID。
這裡我們唯一要新增的是對偏移對映進行一點點清理。它們將包含問題和上下文的偏移量,但一旦進入後處理階段,我們就無法知道輸入 ID 的哪一部分對應於上下文,哪一部分是問題(我們使用的 sequence_ids()
方法僅適用於分詞器的輸出)。因此,我們將把對應於問題的偏移量設定為 None
。
def preprocess_validation_examples(examples):
questions = [q.strip() for q in examples["question"]]
inputs = tokenizer(
questions,
examples["context"],
max_length=max_length,
truncation="only_second",
stride=stride,
return_overflowing_tokens=True,
return_offsets_mapping=True,
padding="max_length",
)
sample_map = inputs.pop("overflow_to_sample_mapping")
example_ids = []
for i in range(len(inputs["input_ids"])):
sample_idx = sample_map[i]
example_ids.append(examples["id"][sample_idx])
sequence_ids = inputs.sequence_ids(i)
offset = inputs["offset_mapping"][i]
inputs["offset_mapping"][i] = [
o if sequence_ids[k] == 1 else None for k, o in enumerate(offset)
]
inputs["example_id"] = example_ids
return inputs
我們可以像以前一樣將此函式應用於整個驗證資料集
validation_dataset = raw_datasets["validation"].map(
preprocess_validation_examples,
batched=True,
remove_columns=raw_datasets["validation"].column_names,
)
len(raw_datasets["validation"]), len(validation_dataset)
(10570, 10822)
在這種情況下,我們只增加了幾百個樣本,所以驗證資料集中的上下文似乎短了一些。
現在我們已經預處理了所有資料,我們可以開始訓練了。
使用 Trainer API 微調模型
此示例的訓練程式碼將與上一節中的程式碼非常相似 — 最難的部分將是編寫 compute_metrics()
函式。由於我們將所有樣本都填充到我們設定的最大長度,因此無需定義資料收集器,因此此指標計算是我們唯一需要擔心的事情。困難的部分是將模型的預測後處理為原始示例中的文字跨度;一旦我們完成了這一點,🤗 Datasets 庫中的指標將為我們完成大部分工作。
後處理
模型將輸出答案在輸入 ID 中的起始和結束位置的 logits,正如我們在探索問答
管道時所看到的。後處理步驟將與我們之前所做的類似,所以這裡快速回顧一下我們採取的行動
- 我們掩蓋了上下文之外的詞元所對應的起始和結束 logits。
- 然後,我們使用 softmax 將起始和結束 logits 轉換為機率。
- 我們透過取對應兩個機率的乘積,為每個
(start_token, end_token)
對分配一個分數。 - 我們尋找得分最高的有效答案對(例如,
start_token
小於end_token
)。
這裡我們將稍微改變這個過程,因為我們不需要計算實際分數(只需預測答案)。這意味著我們可以跳過 softmax 步驟。為了更快,我們也不會對所有可能的(start_token, end_token)
對進行評分,而只對與最高n_best
logits 對應的對進行評分(其中n_best=20
)。由於我們將跳過 softmax,這些分數將是 logit 分數,並且將透過取起始和結束 logits 的總和獲得(而不是乘積,因為規則).
為了演示所有這些,我們需要一些預測。由於我們還沒有訓練模型,我們將使用 QA 管道的預設模型,在驗證集的一小部分上生成一些預測。我們可以使用與之前相同的處理函式;因為它依賴於全域性常量 tokenizer
,所以我們只需將該物件更改為我們希望臨時使用的模型的分詞器。
small_eval_set = raw_datasets["validation"].select(range(100))
trained_checkpoint = "distilbert-base-cased-distilled-squad"
tokenizer = AutoTokenizer.from_pretrained(trained_checkpoint)
eval_set = small_eval_set.map(
preprocess_validation_examples,
batched=True,
remove_columns=raw_datasets["validation"].column_names,
)
現在預處理已完成,我們將分詞器改回我們最初選擇的那個
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
然後我們刪除 eval_set
中模型不需要的列,用這個小的驗證集構建一個批次,並將其透過模型。如果 GPU 可用,我們使用它來加快速度。
import torch
from transformers import AutoModelForQuestionAnswering
eval_set_for_model = eval_set.remove_columns(["example_id", "offset_mapping"])
eval_set_for_model.set_format("torch")
device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
batch = {k: eval_set_for_model[k].to(device) for k in eval_set_for_model.column_names}
trained_model = AutoModelForQuestionAnswering.from_pretrained(trained_checkpoint).to(
device
)
with torch.no_grad():
outputs = trained_model(**batch)
由於 Trainer
將以 NumPy 陣列的形式提供預測,因此我們獲取起始和結束 logits 並將其轉換為該格式
start_logits = outputs.start_logits.cpu().numpy() end_logits = outputs.end_logits.cpu().numpy()
現在,我們需要為 small_eval_set
中的每個示例找到預測的答案。一個示例可能在 eval_set
中被分割成多個特徵,因此第一步是將 small_eval_set
中的每個示例對映到 eval_set
中對應的特徵。
import collections
example_to_features = collections.defaultdict(list)
for idx, feature in enumerate(eval_set):
example_to_features[feature["example_id"]].append(idx)
有了這些,我們就可以真正開始工作了,迴圈遍歷所有示例,對於每個示例,再迴圈遍歷所有相關特徵。正如我們之前所說,我們將檢視 n_best
起始 logits 和結束 logits 的 logit 分數,排除以下情況的位置:
- 答案不在上下文中
- 答案長度為負數
- 答案過長(我們將可能性限制為
max_answer_length=30
)
一旦我們為一個示例獲得了所有評分的可能答案,我們只需選擇 logit 分數最佳的那個
import numpy as np
n_best = 20
max_answer_length = 30
predicted_answers = []
for example in small_eval_set:
example_id = example["id"]
context = example["context"]
answers = []
for feature_index in example_to_features[example_id]:
start_logit = start_logits[feature_index]
end_logit = end_logits[feature_index]
offsets = eval_set["offset_mapping"][feature_index]
start_indexes = np.argsort(start_logit)[-1 : -n_best - 1 : -1].tolist()
end_indexes = np.argsort(end_logit)[-1 : -n_best - 1 : -1].tolist()
for start_index in start_indexes:
for end_index in end_indexes:
# Skip answers that are not fully in the context
if offsets[start_index] is None or offsets[end_index] is None:
continue
# Skip answers with a length that is either < 0 or > max_answer_length.
if (
end_index < start_index
or end_index - start_index + 1 > max_answer_length
):
continue
answers.append(
{
"text": context[offsets[start_index][0] : offsets[end_index][1]],
"logit_score": start_logit[start_index] + end_logit[end_index],
}
)
best_answer = max(answers, key=lambda x: x["logit_score"])
predicted_answers.append({"id": example_id, "prediction_text": best_answer["text"]})
預測答案的最終格式將是我們將使用的度量標準所期望的格式。像往常一樣,我們可以藉助 🤗 Evaluate 庫載入它
import evaluate
metric = evaluate.load("squad")
該指標期望預測答案採用我們上面看到的格式(一個字典列表,其中包含一個用於示例 ID 的鍵和一個用於預測文字的鍵),並期望理論答案採用以下格式(一個字典列表,其中包含一個用於示例 ID 的鍵和一個用於可能答案的鍵)
theoretical_answers = [
{"id": ex["id"], "answers": ex["answers"]} for ex in small_eval_set
]
現在我們可以透過檢視兩個列表的第一個元素來檢查是否得到合理的結果
print(predicted_answers[0])
print(theoretical_answers[0])
{'id': '56be4db0acb8001400a502ec', 'prediction_text': 'Denver Broncos'}
{'id': '56be4db0acb8001400a502ec', 'answers': {'text': ['Denver Broncos', 'Denver Broncos', 'Denver Broncos'], 'answer_start': [177, 177, 177]}}
還不錯!現在讓我們看看度量給出的分數
metric.compute(predictions=predicted_answers, references=theoretical_answers)
{'exact_match': 83.0, 'f1': 88.25}
同樣,考慮到 DistilBERT 在 SQuAD 上微調後,根據其論文,在整個資料集上獲得了 79.1 和 86.9 的分數,這相當不錯。
現在,我們將所有這些操作放入一個 compute_metrics()
函式中,並在 Trainer
中使用它。通常,compute_metrics()
函式只接收一個包含 logits 和標籤的元組 eval_preds
。在這裡,我們需要更多資訊,因為我們必須在特徵資料集中查詢偏移量,並在示例資料集中查詢原始上下文,因此我們將無法使用此函式在訓練期間獲取常規評估結果。我們只會在訓練結束時使用它來檢查結果。
compute_metrics()
函式將與之前相同的步驟分組;我們只是添加了一個小檢查,以防我們沒有得到任何有效答案(在這種情況下,我們預測一個空字串)。
from tqdm.auto import tqdm
def compute_metrics(start_logits, end_logits, features, examples):
example_to_features = collections.defaultdict(list)
for idx, feature in enumerate(features):
example_to_features[feature["example_id"]].append(idx)
predicted_answers = []
for example in tqdm(examples):
example_id = example["id"]
context = example["context"]
answers = []
# Loop through all features associated with that example
for feature_index in example_to_features[example_id]:
start_logit = start_logits[feature_index]
end_logit = end_logits[feature_index]
offsets = features[feature_index]["offset_mapping"]
start_indexes = np.argsort(start_logit)[-1 : -n_best - 1 : -1].tolist()
end_indexes = np.argsort(end_logit)[-1 : -n_best - 1 : -1].tolist()
for start_index in start_indexes:
for end_index in end_indexes:
# Skip answers that are not fully in the context
if offsets[start_index] is None or offsets[end_index] is None:
continue
# Skip answers with a length that is either < 0 or > max_answer_length
if (
end_index < start_index
or end_index - start_index + 1 > max_answer_length
):
continue
answer = {
"text": context[offsets[start_index][0] : offsets[end_index][1]],
"logit_score": start_logit[start_index] + end_logit[end_index],
}
answers.append(answer)
# Select the answer with the best score
if len(answers) > 0:
best_answer = max(answers, key=lambda x: x["logit_score"])
predicted_answers.append(
{"id": example_id, "prediction_text": best_answer["text"]}
)
else:
predicted_answers.append({"id": example_id, "prediction_text": ""})
theoretical_answers = [{"id": ex["id"], "answers": ex["answers"]} for ex in examples]
return metric.compute(predictions=predicted_answers, references=theoretical_answers)
我們可以檢查它是否適用於我們的預測
compute_metrics(start_logits, end_logits, eval_set, small_eval_set)
{'exact_match': 83.0, 'f1': 88.25}
看起來不錯!現在讓我們用它來微調我們的模型。
微調模型
我們現在準備訓練我們的模型。首先,像以前一樣,使用 AutoModelForQuestionAnswering
類建立它
model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)
像往常一樣,我們收到一個警告,一些權重未被使用(來自預訓練頭的權重),另一些權重是隨機初始化的(用於問答頭的權重)。您現在應該已經習慣了,但這表示該模型尚未準備好使用,需要進行微調 — 幸運的是我們即將進行這項工作!
為了將我們的模型推送到 Hub,我們需要登入 Hugging Face。如果您在筆記本中執行此程式碼,您可以使用以下實用函式登入,它會顯示一個您可以輸入登入憑據的小部件
from huggingface_hub import notebook_login
notebook_login()
如果您不在筆記本中工作,只需在終端中輸入以下行
huggingface-cli login
完成此操作後,我們可以定義我們的 TrainingArguments
。正如我們在定義計算指標的函式時所說,由於 compute_metrics()
函式的簽名,我們將無法進行常規評估迴圈。我們可以編寫自己的 Trainer
子類來完成此操作(您可以在問答示例指令碼中找到這種方法),但這對於本節來說有點太長了。相反,我們將只在本訓練結束時評估模型,並在下面的“自定義訓練迴圈”中向您展示如何進行常規評估。
這正是 Trainer
API 侷限性凸顯,而 🤗 Accelerate 庫大放異彩之處:為特定用例定製類可能很痛苦,但調整完全暴露的訓練迴圈則很容易。
讓我們看看我們的 TrainingArguments
from transformers import TrainingArguments
args = TrainingArguments(
"bert-finetuned-squad",
evaluation_strategy="no",
save_strategy="epoch",
learning_rate=2e-5,
num_train_epochs=3,
weight_decay=0.01,
fp16=True,
push_to_hub=True,
)
我們之前已經見過大部分這些:我們設定了一些超引數(如學習率、訓練時期數和一些權重衰減),並表明我們希望在每個時期結束時儲存模型,跳過評估,並將結果上傳到模型中心。我們還啟用了混合精度訓練,設定 fp16=True
,因為它可以在最近的 GPU 上很好地加速訓練。
預設情況下,使用的倉庫將在您的名稱空間中,並以您設定的輸出目錄命名,因此在我們的例子中將是 "sgugger/bert-finetuned-squad"
。我們可以透過傳遞 hub_model_id
來覆蓋此設定;例如,要將模型推送到 huggingface_course
組織,我們使用了 hub_model_id="huggingface_course/bert-finetuned-squad"
(這是我們在本節開頭連結到的模型)。
💡 如果您正在使用的輸出目錄存在,它必須是您要推送的倉庫的本地克隆(因此,如果在定義 Trainer
時遇到錯誤,請設定一個新名稱)。
最後,我們將所有內容傳遞給 Trainer
類並啟動訓練
from transformers import Trainer
trainer = Trainer(
model=model,
args=args,
train_dataset=train_dataset,
eval_dataset=validation_dataset,
tokenizer=tokenizer,
)
trainer.train()
請注意,在訓練過程中,每次模型儲存時(這裡是每個 epoch),它都會在後臺上傳到 Hub。這樣,如有必要,您就可以在另一臺機器上恢復訓練。整個訓練需要一段時間(在 Titan RTX 上一個多小時),因此您可以在訓練進行時喝杯咖啡或重讀一些您覺得更具挑戰性的課程部分。另請注意,一旦第一個 epoch 完成,您將看到一些權重上傳到 Hub,並且您可以開始在其頁面上使用您的模型。
訓練完成後,我們終於可以評估模型了(並祈禱我們沒有白白花費所有計算時間)。Trainer
的 predict()
方法將返回一個元組,其中第一個元素是模型的預測(此處是起始和結束 logits 的一對)。我們將此傳送到我們的 compute_metrics()
函式。
predictions, _, _ = trainer.predict(validation_dataset)
start_logits, end_logits = predictions
compute_metrics(start_logits, end_logits, validation_dataset, raw_datasets["validation"])
{'exact_match': 81.18259224219489, 'f1': 88.67381321905516}
太棒了!作為比較,BERT 文章中報告的該模型基準分數分別為 80.8 和 88.5,因此我們完全符合預期。
最後,我們使用 push_to_hub()
方法確保上傳模型的最新版本
trainer.push_to_hub(commit_message="Training complete")
這將返回剛剛提交的 URL,如果您想檢查它的話
'https://huggingface.co/sgugger/bert-finetuned-squad/commit/9dcee1fbc25946a6ed4bb32efb1bd71d5fa90b68'
Trainer
還會起草一份包含所有評估結果的模型卡並上傳。
在此階段,您可以使用模型中心上的推理小部件來測試模型並與您的朋友、家人和最喜歡的寵物分享。您已成功在問答任務上微調了一個模型 — 恭喜!
✏️ 輪到你了! 嘗試另一種模型架構,看看它在這個任務上的表現是否更好!
如果您想更深入地瞭解訓練迴圈,我們現在將向您展示如何使用 🤗 Accelerate 來完成同樣的事情。
自定義訓練迴圈
現在讓我們看看完整的訓練迴圈,以便您可以輕鬆自定義所需的部件。它將與第三章中的訓練迴圈非常相似,但評估迴圈除外。我們將能夠定期評估模型,因為我們不再受 Trainer
類的限制。
為訓練準備一切
首先,我們需要從資料集構建 DataLoader
。我們將這些資料集的格式設定為 "torch"
,並刪除驗證集中模型未使用的列。然後,我們可以使用 Transformers 提供的 default_data_collator
作為 collate_fn
,並打亂訓練集,但不打亂驗證集。
from torch.utils.data import DataLoader
from transformers import default_data_collator
train_dataset.set_format("torch")
validation_set = validation_dataset.remove_columns(["example_id", "offset_mapping"])
validation_set.set_format("torch")
train_dataloader = DataLoader(
train_dataset,
shuffle=True,
collate_fn=default_data_collator,
batch_size=8,
)
eval_dataloader = DataLoader(
validation_set, collate_fn=default_data_collator, batch_size=8
)
接下來我們重新例項化我們的模型,以確保我們不是在之前的基礎上繼續微調,而是從 BERT 預訓練模型重新開始
model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)
然後我們需要一個最佳化器。像往常一樣,我們使用經典的 AdamW
,它類似於 Adam,但修復了權重衰減的應用方式
from torch.optim import AdamW
optimizer = AdamW(model.parameters(), lr=2e-5)
一旦我們擁有了所有這些物件,我們就可以將它們傳送到 accelerator.prepare()
方法。請記住,如果您想在 Colab notebook 中在 TPU 上進行訓練,則需要將所有這些程式碼移動到訓練函式中,並且不應執行任何例項化 Accelerator
的單元格。我們可以透過將 fp16=True
傳遞給 Accelerator
來強制進行混合精度訓練(或者,如果您將程式碼作為指令碼執行,只需確保適當地填寫 🤗 Accelerate config
)。
from accelerate import Accelerator
accelerator = Accelerator(fp16=True)
model, optimizer, train_dataloader, eval_dataloader = accelerator.prepare(
model, optimizer, train_dataloader, eval_dataloader
)
正如您應該從上一節中瞭解到的,我們只能在 train_dataloader
經過 accelerator.prepare()
方法後才使用其長度來計算訓練步驟的數量。我們使用與上一節相同的線性排程
from transformers import get_scheduler
num_train_epochs = 3
num_update_steps_per_epoch = len(train_dataloader)
num_training_steps = num_train_epochs * num_update_steps_per_epoch
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=num_training_steps,
)
為了將我們的模型推送到 Hub,我們需要在一個工作資料夾中建立一個 Repository
物件。首先登入到 Hugging Face Hub,如果您還沒有登入的話。我們將從我們想要給模型設定的模型 ID 中確定倉庫名稱(可以隨意用您自己的選擇替換 repo_name
;它只需要包含您的使用者名稱,這正是 get_full_repo_name()
函式的作用)。
from huggingface_hub import Repository, get_full_repo_name
model_name = "bert-finetuned-squad-accelerate"
repo_name = get_full_repo_name(model_name)
repo_name
'sgugger/bert-finetuned-squad-accelerate'
然後我們可以將該倉庫克隆到本地資料夾中。如果它已經存在,則此本地資料夾應該是我們正在使用的倉庫的克隆
output_dir = "bert-finetuned-squad-accelerate"
repo = Repository(output_dir, clone_from=repo_name)
現在我們可以透過呼叫 repo.push_to_hub()
方法上傳我們儲存在 output_dir
中的任何內容。這將幫助我們在每個 epoch 結束時上傳中間模型。
訓練迴圈
我們現在準備編寫完整的訓練迴圈。在定義一個進度條以跟蹤訓練進度後,該迴圈分為三個部分
- 訓練本身,這是經典的
train_dataloader
迭代、透過模型的正向傳播,然後是反向傳播和最佳化器步驟。 - 評估,我們在此階段收集
start_logits
和end_logits
的所有值,然後將它們轉換為 NumPy 陣列。一旦評估迴圈完成,我們將所有結果連線起來。請注意,我們需要截斷,因為Accelerator
可能在末尾添加了一些樣本,以確保每個程序中的示例數量相同。 - 儲存和上傳,我們首先儲存模型和分詞器,然後呼叫
repo.push_to_hub()
。和之前一樣,我們使用引數blocking=False
告訴 🤗 Hub 庫在非同步程序中推送。這樣,訓練正常進行,而這個(耗時較長)指令在後臺執行。
以下是訓練迴圈的完整程式碼
from tqdm.auto import tqdm
import torch
progress_bar = tqdm(range(num_training_steps))
for epoch in range(num_train_epochs):
# Training
model.train()
for step, batch in enumerate(train_dataloader):
outputs = model(**batch)
loss = outputs.loss
accelerator.backward(loss)
optimizer.step()
lr_scheduler.step()
optimizer.zero_grad()
progress_bar.update(1)
# Evaluation
model.eval()
start_logits = []
end_logits = []
accelerator.print("Evaluation!")
for batch in tqdm(eval_dataloader):
with torch.no_grad():
outputs = model(**batch)
start_logits.append(accelerator.gather(outputs.start_logits).cpu().numpy())
end_logits.append(accelerator.gather(outputs.end_logits).cpu().numpy())
start_logits = np.concatenate(start_logits)
end_logits = np.concatenate(end_logits)
start_logits = start_logits[: len(validation_dataset)]
end_logits = end_logits[: len(validation_dataset)]
metrics = compute_metrics(
start_logits, end_logits, validation_dataset, raw_datasets["validation"]
)
print(f"epoch {epoch}:", metrics)
# Save and upload
accelerator.wait_for_everyone()
unwrapped_model = accelerator.unwrap_model(model)
unwrapped_model.save_pretrained(output_dir, save_function=accelerator.save)
if accelerator.is_main_process:
tokenizer.save_pretrained(output_dir)
repo.push_to_hub(
commit_message=f"Training in progress epoch {epoch}", blocking=False
)
如果這是您第一次看到使用 🤗 Accelerate 儲存模型,讓我們花點時間檢查一下相關的三行程式碼
accelerator.wait_for_everyone() unwrapped_model = accelerator.unwrap_model(model) unwrapped_model.save_pretrained(output_dir, save_function=accelerator.save)
第一行是不言自明的:它告訴所有程序在繼續之前等待所有程序都達到該階段。這是為了確保在儲存之前,每個程序中的模型都相同。然後我們獲取 unwrapped_model
,這是我們定義的原始模型。accelerator.prepare()
方法會更改模型以在分散式訓練中工作,因此它將不再具有 save_pretrained()
方法;accelerator.unwrap_model()
方法會撤消該步驟。最後,我們呼叫 save_pretrained()
但告訴該方法使用 accelerator.save()
而不是 torch.save()
。
完成此操作後,您應該會得到一個模型,其結果與使用 Trainer
訓練的模型非常相似。您可以在 huggingface-course/bert-finetuned-squad-accelerate 處檢視我們使用此程式碼訓練的模型。如果您想測試訓練迴圈的任何調整,可以直接透過編輯上面顯示的程式碼來實現!
使用微調模型
我們已經向您展示瞭如何使用模型中心上的推理小部件來使用我們微調的模型。要在本地的 pipeline
中使用它,您只需指定模型識別符號
from transformers import pipeline
# Replace this with your own checkpoint
model_checkpoint = "huggingface-course/bert-finetuned-squad"
question_answerer = pipeline("question-answering", model=model_checkpoint)
context = """
🤗 Transformers is backed by the three most popular deep learning libraries — Jax, PyTorch and TensorFlow — with a seamless integration
between them. It's straightforward to train your models with one before loading them for inference with the other.
"""
question = "Which deep learning libraries back 🤗 Transformers?"
question_answerer(question=question, context=context)
{'score': 0.9979003071784973,
'start': 78,
'end': 105,
'answer': 'Jax, PyTorch and TensorFlow'}
太棒了!我們的模型與此管道的預設模型一樣好用!
< > 在 GitHub 上更新