作者: Matthew Carrigan 和 Merve Noyan
建立日期 13/01/2022
上次修改 13/01/2022
描述: 使用 Keras 和 Hugging Face Transformers 進行問答的實作。
問答是一個常見的 NLP 任務,有多種變體。在某些變體中,任務是多項選擇:每個問題都提供一組可能的答案,模型只需要返回選項的機率分佈。問答的一個更具挑戰性的變體(更適用於實際任務)是不提供選項時。相反,模型會得到一個輸入文件(稱為上下文)和關於該文件的問題,它必須提取文件中包含答案的文本跨度。在這種情況下,模型不是計算答案的機率分佈,而是計算文件文本中兩個機率分佈,代表包含答案的跨度的開始和結束。此變體稱為「抽取式問答」。
抽取式問答是一個非常具有挑戰性的 NLP 任務,當問題和答案都是自然語言時,從頭開始訓練此類模型所需的數據集大小非常龐大。因此,問答(像幾乎所有 NLP 任務一樣)從強大的預訓練基礎模型開始獲益良多 - 從強大的預訓練語言模型開始可以將達到給定準確性所需的數據集大小減少多個數量級,使您能夠以驚人的合理數據集達到非常強大的性能。
但是,從預訓練模型開始會增加困難 - 您從哪裡獲得模型?您如何確保您的輸入資料以與原始模型相同的方式進行預處理和標記化?您如何修改模型以新增與您感興趣的任務相符的輸出頭?
在本範例中,我們將向您展示如何從 Hugging Face 🤗Transformers 程式庫載入模型來應對此挑戰。我們還將從 🤗Datasets 程式庫載入基準問答資料集 - 這是另一個開源儲存庫,其中包含從 NLP 到視覺及其他領域的各種資料集。不過,請注意,這些程式庫並非必須彼此搭配使用。如果您想在自己的資料上訓練來自 🤗Transformers 的模型,或者您想從 🤗 Datasets 載入資料並使用它訓練您自己的完全不相關的模型,這當然是可能的(並且非常鼓勵!)
!pip install git+https://github.com/huggingface/transformers.git
!pip install datasets
!pip install huggingface-hub
我們將使用 🤗 Datasets 程式庫使用 load_dataset()
下載 SQUAD 問答資料集。
from datasets import load_dataset
datasets = load_dataset("squad")
datasets
物件本身是一個 DatasetDict
,其中包含訓練、驗證和測試集的鍵。我們可以看見訓練、驗證和測試集都有一個欄位,分別用於上下文、問題和這些問題的答案。若要存取實際元素,您需要先選擇一個分割,然後提供索引。我們可以看見答案由它們在文本中的起始位置及其完整文本指示,這是我們上面提到的上下文的子字串。讓我們看看單個訓練範例的樣子。
print(datasets["train"][0])
{'id': '5733be284776f41900661182', 'title': 'University_of_Notre_Dame', '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?', 'answers': {'text': ['Saint Bernadette Soubirous'], 'answer_start': [515]}}
在我們可以將這些文本輸入到模型之前,我們需要預處理它們。這由 🤗 Transformers Tokenizer
完成,它會(顧名思義)對輸入進行標記化(包括將標記轉換為預訓練詞彙表中對應的 ID)並將其放入模型期望的格式,以及產生模型需要的其他輸入。
若要執行所有這些操作,我們使用 AutoTokenizer.from_pretrained
方法實例化我們的標記器,這將確保
該詞彙表將被快取,因此下次我們運行單元格時不會再次下載。
from_pretrained()
方法需要一個模型名稱。如果您不確定要選擇哪個模型,請不要驚慌!可供選擇的模型列表可能會讓人眼花撩亂,但總的來說,有一個簡單的權衡:較大的模型速度較慢,並且消耗更多記憶體,但通常在微調後會產生稍微更好的最終準確性。對於此範例,我們選擇了(相對)輕量的 "distilbert"
,這是著名的 BERT 語言模型的較小、蒸餾版本。但是,如果您絕對必須為重要任務獲得最高的準確性,並且您有 GPU 記憶體(和空閒時間)來處理它,您可能更喜歡使用更大的模型,例如 "roberta-large"
。比 "roberta"
更新、更大的模型存在於 🤗 Transformers 中,但我們將找到和訓練它們的任務留給特別受虐狂或有 40GB VRAM 可以使用的讀者。
from transformers import AutoTokenizer
model_checkpoint = "distilbert-base-cased"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
Downloading: 0%| | 0.00/29.0 [00:00<?, ?B/s]
Downloading: 0%| | 0.00/411 [00:00<?, ?B/s]
Downloading: 0%| | 0.00/208k [00:00<?, ?B/s]
Downloading: 0%| | 0.00/426k [00:00<?, ?B/s]
根據您選擇的模型,您將在上面單元格返回的字典中看到不同的鍵。它們對於我們在這裡所做的事情來說並不重要(只需知道它們是我們稍後將要實例化的模型所需要的),但是如果您有興趣,可以在 本教程中了解有關它們的更多資訊。
問答預處理的一個具體問題是如何處理非常長的文件。我們通常在其他任務中截斷它們,當它們長於模型最大句子長度時,但是在這裡,刪除上下文的一部分可能會導致遺失我們正在尋找的答案。為了處理這個問題,我們將允許資料集中的一個(長)範例提供多個輸入特徵,每個輸入特徵的長度都短於模型的最大長度(或我們設定為超參數的長度)。此外,為了以防萬一答案位於我們分割長上下文的位置,我們允許我們產生的特徵之間存在一些由超參數 doc_stride
控制的重疊。
如果我們僅使用固定大小(max_length
)進行截斷,我們將會遺失資訊。我們希望避免截斷問題,而是僅截斷上下文,以確保任務仍然可解決。為了做到這一點,我們會將 truncation
設定為 "only_second"
,以便僅截斷每個配對中的第二個序列(上下文)。為了取得以最大長度為上限的特徵列表,我們需要將 return_overflowing_tokens
設定為 True,並將 doc_stride
傳遞給 stride
。為了查看原始上下文中哪些特徵包含答案,我們可以回傳 "offset_mapping"
。
max_length = 384 # The maximum length of a feature (question and context)
doc_stride = (
128 # The authorized overlap between two part of the context when splitting
)
# it is needed.
在無法回答的情況下(答案位於具有長上下文的範例所提供的另一個特徵中),我們會為開始和結束位置都設定 cls 索引。如果旗標 allow_impossible_answers
為 False
,我們也可以直接從訓練集中捨棄這些範例。由於預處理已經夠複雜了,為了簡化這部分,我們就保持簡單。
def prepare_train_features(examples):
# Tokenize our examples with truncation and padding, but keep the overflows using a
# stride. This results in one example possible giving several features when a context is long,
# each of those features having a context that overlaps a bit the context of the previous
# feature.
examples["question"] = [q.lstrip() for q in examples["question"]]
examples["context"] = [c.lstrip() for c in examples["context"]]
tokenized_examples = tokenizer(
examples["question"],
examples["context"],
truncation="only_second",
max_length=max_length,
stride=doc_stride,
return_overflowing_tokens=True,
return_offsets_mapping=True,
padding="max_length",
)
# Since one example might give us several features if it has a long context, we need a
# map from a feature to its corresponding example. This key gives us just that.
sample_mapping = tokenized_examples.pop("overflow_to_sample_mapping")
# The offset mappings will give us a map from token to character position in the original
# context. This will help us compute the start_positions and end_positions.
offset_mapping = tokenized_examples.pop("offset_mapping")
# Let's label those examples!
tokenized_examples["start_positions"] = []
tokenized_examples["end_positions"] = []
for i, offsets in enumerate(offset_mapping):
# We will label impossible answers with the index of the CLS token.
input_ids = tokenized_examples["input_ids"][i]
cls_index = input_ids.index(tokenizer.cls_token_id)
# Grab the sequence corresponding to that example (to know what is the context and what
# is the question).
sequence_ids = tokenized_examples.sequence_ids(i)
# One example can give several spans, this is the index of the example containing this
# span of text.
sample_index = sample_mapping[i]
answers = examples["answers"][sample_index]
# If no answers are given, set the cls_index as answer.
if len(answers["answer_start"]) == 0:
tokenized_examples["start_positions"].append(cls_index)
tokenized_examples["end_positions"].append(cls_index)
else:
# Start/end character index of the answer in the text.
start_char = answers["answer_start"][0]
end_char = start_char + len(answers["text"][0])
# Start token index of the current span in the text.
token_start_index = 0
while sequence_ids[token_start_index] != 1:
token_start_index += 1
# End token index of the current span in the text.
token_end_index = len(input_ids) - 1
while sequence_ids[token_end_index] != 1:
token_end_index -= 1
# Detect if the answer is out of the span (in which case this feature is labeled with the
# CLS index).
if not (
offsets[token_start_index][0] <= start_char
and offsets[token_end_index][1] >= end_char
):
tokenized_examples["start_positions"].append(cls_index)
tokenized_examples["end_positions"].append(cls_index)
else:
# Otherwise move the token_start_index and token_end_index to the two ends of the
# answer.
# Note: we could go after the last offset if the answer is the last word (edge
# case).
while (
token_start_index < len(offsets)
and offsets[token_start_index][0] <= start_char
):
token_start_index += 1
tokenized_examples["start_positions"].append(token_start_index - 1)
while offsets[token_end_index][1] >= end_char:
token_end_index -= 1
tokenized_examples["end_positions"].append(token_end_index + 1)
return tokenized_examples
為了將此函數應用於資料集中所有的句子(或句子對),我們只需使用 Dataset
物件的 map()
方法,它會將該函數應用於所有元素。
我們將使用 batched=True
來批次編碼文本。這是為了充分利用我們先前載入的快速分詞器,它將使用多執行緒同時處理批次中的文本。我們還使用 remove_columns
參數來移除應用分詞之前就存在的欄位,這可確保剩下的特徵僅是我們實際想傳遞給模型的那些。
tokenized_datasets = datasets.map(
prepare_train_features,
batched=True,
remove_columns=datasets["train"].column_names,
num_proc=3,
)
更棒的是,🤗 Datasets 函式庫會自動快取結果,以避免您下次執行筆記本時在此步驟花費時間。🤗 Datasets 函式庫通常夠聰明,可以偵測到您傳遞給 map 的函數是否已變更(因此需要不使用快取資料)。例如,如果您變更第一個儲存格中的任務並重新執行筆記本,它會正確地偵測到。🤗 Datasets 會在它使用快取檔案時警告您,您可以在呼叫 map()
時傳遞 load_from_cache_file=False
來不使用快取檔案,並強制再次應用預處理。
由於我們所有的資料都已填充或截斷為相同的長度,而且它不會太大,我們現在可以簡單地將它轉換為 numpy 陣列的字典,以便進行訓練。
雖然我們不會在這裡使用它,但 🤗 Datasets 有一個 to_tf_dataset()
輔助方法,旨在協助您處理無法輕易轉換為陣列的資料,例如當資料具有可變的序列長度,或太大而無法放入記憶體時。此方法會在底層的 🤗 Dataset 周圍包裝一個 tf.data.Dataset
,從底層資料集串流取樣並即時批次處理它們,從而最大限度地減少不必要的填充所造成的記憶體和運算浪費。如果您的使用案例需要它,請參閱 文件,以取得關於 to_tf_dataset 和資料整理器 (data collator) 的範例。如果不需要,請隨意遵循此範例並簡單地轉換為字典!
train_set = tokenized_datasets["train"].with_format("numpy")[
:
] # Load the whole dataset as a dict of numpy arrays
validation_set = tokenized_datasets["validation"].with_format("numpy")[:]
這工作量很大!但現在我們的資料已準備就緒,一切都會非常順利。首先,我們下載預訓練模型並進行微調。由於我們的任務是問答,我們使用 TFAutoModelForQuestionAnswering
類別。與分詞器一樣,from_pretrained()
方法會為我們下載並快取模型
from transformers import TFAutoModelForQuestionAnswering
model = TFAutoModelForQuestionAnswering.from_pretrained(model_checkpoint)
Downloading: 0%| | 0.00/338M [00:00<?, ?B/s]
Some layers from the model checkpoint at distilbert-base-cased were not used when initializing TFDistilBertForQuestionAnswering: ['vocab_transform', 'activation_13', 'vocab_projector', 'vocab_layer_norm']
- This IS expected if you are initializing TFDistilBertForQuestionAnswering from the checkpoint of a model trained on another task or with another architecture (e.g. initializing a BertForSequenceClassification model from a BertForPreTraining model).
- This IS NOT expected if you are initializing TFDistilBertForQuestionAnswering from the checkpoint of a model that you expect to be exactly identical (initializing a BertForSequenceClassification model from a BertForSequenceClassification model).
Some layers of TFDistilBertForQuestionAnswering were not initialized from the model checkpoint at distilbert-base-cased and are newly initialized: ['dropout_19', 'qa_outputs']
You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.
警告告訴我們,我們正在捨棄一些權重並重新初始化其他權重。別慌!這絕對是正常的。回想一下,像 BERT 和 Distilbert 這樣的模型是針對**語言建模**任務進行訓練的,但我們正在將模型載入為 TFAutoModelForQuestionAnswering
,這表示我們希望模型執行**問答**任務。此變更需要移除最終輸出層或「頭部」,並將其替換為適合新任務的新頭部。from_pretrained
方法會為我們處理所有這些,而警告只是提醒我們,已經執行了一些模型手術,並且在於某些資料上微調新初始化的層之前,模型不會產生有用的預測。
接下來,我們可以建立最佳化器並指定損失函數。您通常可以透過使用學習率衰減和分離權重衰減來獲得稍微更好的效能,但為了此範例的目的,標準 Adam
最佳化器效果很好。但是請注意,在微調預訓練轉換器模型時,您通常會想要使用較低的學習率!我們發現 1e-5 到 1e-4 範圍內的值可以獲得最佳結果,並且訓練可能會在預設的 Adam 學習率 1e-3 時完全發散。
import tensorflow as tf
from tensorflow import keras
optimizer = keras.optimizers.Adam(learning_rate=5e-5)
現在我們只需編譯並擬合模型。為了方便起見,所有 🤗 Transformers 模型都具有與其輸出頭部相符的預設損失,當然您也可以自由使用自己的損失。由於內建損失是在前向傳遞期間在內部計算的,因此當您使用它時,您可能會發現某些 Keras 指標行為不當或產生意外輸出。這是 🤗 Transformers 中非常積極開發的領域,所以希望我們很快就能找到這個問題的好解決方案!
不過,現在,讓我們使用不含任何指標的內建損失。若要取得內建損失,只需將 compile
的 loss
引數省略即可。
# Optionally uncomment the next line for float16 training
keras.mixed_precision.set_global_policy("mixed_float16")
model.compile(optimizer=optimizer)
INFO:tensorflow:Mixed precision compatibility check (mixed_float16): OK
Your GPU will likely run quickly with dtype policy mixed_float16 as it has compute capability of at least 7.0. Your GPU: Tesla V100-SXM2-16GB, compute capability 7.0
No loss specified in compile() - the model's internal loss computation will be used as the loss. Don't panic - this is a common way to train TensorFlow models in Transformers! Please ensure your labels are passed as keys in the input dict so that they are accessible to the model during the forward pass. To disable this behaviour, please pass a loss argument, or explicitly pass loss=None if you do not want your model to compute a loss.
現在我們可以訓練我們的模型。請注意,我們沒有傳遞單獨的標籤 - 標籤是輸入字典中的鍵,使其在前向傳遞期間對模型可見,以便它可以計算內建損失。
model.fit(train_set, validation_data=validation_set, epochs=1)
2773/2773 [==============================] - 1205s 431ms/step - loss: 1.5360 - val_loss: 1.1816
<keras.callbacks.History at 0x7f0b104fab90>
我們完成了!讓我們試試看,使用 keras.io 首頁的一些文字
context = """Keras is an API designed for human beings, not machines. Keras follows best
practices for reducing cognitive load: it offers consistent & simple APIs, it minimizes
the number of user actions required for common use cases, and it provides clear &
actionable error messages. It also has extensive documentation and developer guides. """
question = "What is Keras?"
inputs = tokenizer([context], [question], return_tensors="np")
outputs = model(inputs)
start_position = tf.argmax(outputs.start_logits, axis=1)
end_position = tf.argmax(outputs.end_logits, axis=1)
print(int(start_position), int(end_position[0]))
26 30
看來我們的模型認為答案是從標記 1 到 12(含)的跨度。不用猜也知道這些標記是什麼!
answer = inputs["input_ids"][0, int(start_position) : int(end_position) + 1]
print(answer)
[ 8080 111 3014 20480 1116]
現在我們可以 使用 tokenizer.decode()
方法將這些標記 ID 轉換回文字
print(tokenizer.decode(answer))
consistent & simple APIs
就是這樣!請記住,此範例旨在快速執行,而不是最先進的,而且此處訓練的模型肯定會出錯。如果您使用更大的模型來進行訓練,並花時間適當調整超參數,您會發現您可以獲得更好的損失(以及相應更準確的答案)。
最後,您可以將模型推送到 HuggingFace Hub。透過推送此模型,您將擁有
model.push_to_hub("transformers-qa", organization="keras-io")
tokenizer.push_to_hub("transformers-qa", organization="keras-io")
如果您有非基於 Transformers 的 Keras 模型,您也可以使用 push_to_hub_keras
來推送它們。您可以使用 from_pretrained_keras
輕鬆載入。
from huggingface_hub.keras_mixin import push_to_hub_keras
push_to_hub_keras(
model=model, repo_url="https://huggingface.co/your-username/your-awesome-model"
)
from_pretrained_keras("your-username/your-awesome-model") # load your model