並獲得增強文件體驗
開始使用
問答
現在讓我們看看問答!這項任務有很多變體,但我們將在本節中重點介紹的是稱為**抽取式**問答。這涉及到對文件提出問題,並將答案識別為文件本身中的**文字片段**。
我們將使用 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]'然後,標籤將是答案開始和結束的標記的索引,模型的任務是預測輸入中每個標記的一個開始和結束logit,理論標籤如下所示。
在本例中,上下文並不太長,但資料集中的某些示例具有非常長的上下文,這將超過我們設定的最大長度(在本例中為 384)。正如我們在第 6 章中探索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]標記)。在不幸的情況下,如果答案已被截斷,因此我們只有答案的開頭(或結尾),我們也將設定這些標籤。對於答案完全在上下文中的示例,標籤將是答案開始的標記索引和答案結束的標記索引。
資料集為我們提供了答案在上下文中的起始字元,透過新增答案的長度,我們可以找到上下文中的結束字元。為了將其對映到標記索引,我們需要使用我們在第 6 章中學習的偏移對映。我們可以透過傳遞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 不需要它們),因此我們將改為使用tokenizer返回的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 這樣的模型,在分詞時會佔用空間),因此我們刪除了這些額外的空格。
為了將此函式應用於整個訓練集,我們使用Dataset.map()方法以及batched=True標誌。這裡有必要,因為我們正在更改資料集的長度(因為一個示例可以提供多個訓練特徵)。
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)如我們所見,預處理增加了大約 1000 個特徵。我們的訓練集現在已準備好使用——讓我們深入瞭解驗證集的預處理!
處理驗證資料
驗證資料的預處理會稍微簡單一些,因為我們不需要生成標籤(除非我們想計算驗證損失,但該數字並不能真正幫助我們瞭解模型的優劣)。真正的樂趣在於將模型的預測解釋為原始上下文的跨度。為此,我們只需要儲存偏移對映和某種方法來將每個建立的特徵與其源自的原始示例匹配。由於原始資料集中有一個 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,正如我們在探索question-answering管道期間所看到的。後處理步驟將類似於我們在那裡執行的操作,因此以下是我們採取的操作的快速提醒。
- 我們遮蔽了上下文之外的token對應的開始和結束logits。
- 然後,我們使用softmax將開始和結束logits轉換為機率。
- 我們透過將對應兩個機率的乘積作為每個
(start_token, end_token)對的得分。 - 我們尋找得分最高的對,該對產生有效的答案(例如,
start_token小於end_token)。
在這裡,我們將稍微更改此過程,因為我們不需要計算實際得分(只需預測答案)。這意味著我們可以跳過softmax步驟。為了更快地執行,我們也不會對所有可能的(start_token, end_token)對進行評分,而只對對應於最高n_best logits的對進行評分(其中n_best=20)。由於我們將跳過softmax,因此這些分數將是logit分數,並且將透過將開始和結束logit相加得到(而不是乘積,因為規則).
為了演示所有這些,我們需要某種預測。由於我們尚未訓練我們的模型,我們將使用QA管道中的預設模型在驗證集的一小部分上生成一些預測。我們可以使用之前相同的處理函式;因為它依賴於全域性常量tokenizer,我們只需要將該物件暫時更改為我們想要使用的模型的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改回最初選擇的那個。
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}同樣,考慮到根據其論文在SQuAD上微調的DistilBERT在整個資料集上獲得了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()請注意,在訓練過程中,每次儲存模型(這裡,每個輪)時,它都會在後臺上傳到Hub。這樣,如果需要,您就可以在另一臺機器上恢復訓練。整個訓練需要一段時間(在Titan RTX上大約需要一個多小時),因此您可以在此過程中喝杯咖啡或重新閱讀課程中您覺得更有挑戰性的部分。還要注意,一旦第一個輪結束,您將看到一些權重上傳到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 執行相同操作。
自定義訓練迴圈
現在讓我們看看完整的訓練迴圈,以便您可以輕鬆自定義所需的部件。它看起來很像第 3 章中的訓練迴圈,除了評估迴圈之外。由於我們不再受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筆記本中使用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的經典迭代,然後是模型的前向傳遞,然後是反向傳遞和最佳化器步驟。 - 評估,在其中我們在將它們轉換為NumPy陣列之前收集
start_logits和end_logits的所有值。評估迴圈完成後,我們將所有結果連線起來。請注意,我們需要進行截斷,因為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'}太好了!我們的模型與該管道預設模型一樣好用!