
By Chris McCormick and Nick Ryan
介紹
歷史
2018 年是 NLP 突破的一年,遷移學(xué)習(xí)、特別是 Allen AI 的 ELMO,OpenAI 的 Open-GPT,以及 Google 的 BERT,這些模型讓研究者們刷新了多項任務(wù)的基線(benchmark),并提供了容易被微調(diào)預(yù)訓(xùn)練模型(只需很少的數(shù)據(jù)量和計算量),使用它們,可產(chǎn)出當(dāng)今最高水平的結(jié)果。但是,對于剛接觸 NLP 甚至很多有經(jīng)驗的開發(fā)者來說,這些強(qiáng)大模型的理論和應(yīng)用并不是那么容易理解。
什么是 BERT
2018年底發(fā)布的BERT(Bidirectional Encoder Representations from Transformers)是我們在本教程中要用到的模型,目的是讓讀者更好地理解和指導(dǎo)讀者在 NLP 中使用遷移學(xué)習(xí)模型。BERT是一種預(yù)訓(xùn)練語言表征的方法,NLP實踐者可以免費(fèi)下載并使用這些模型。你可以用這些模型從文本數(shù)據(jù)中提取高質(zhì)量的語言特征,也可以用自己的數(shù)據(jù)對這些模型在特定的任務(wù)(分類、實體識別、問答問題等)上進(jìn)行微調(diào),以產(chǎn)生高質(zhì)量的預(yù)測結(jié)果。
本文將解釋如何修改和微調(diào) BERT,以創(chuàng)建一個強(qiáng)大的 NLP 模型。
Fine-tuning 的優(yōu)勢
在本教程中,我們將使用BERT來訓(xùn)練一個文本分類器。具體來說,我們將采取預(yù)訓(xùn)練的 BERT 模型,在末端添加一個未訓(xùn)練過的神經(jīng)元層,然后訓(xùn)練新的模型來完成我們的分類任務(wù)。為什么要這樣做,而不是訓(xùn)練一個特定的深度學(xué)習(xí)模型(CNN、BiLSTM等)?
-
更快速的開發(fā)
首先,預(yù)訓(xùn)練的 BERT 模型權(quán)重已經(jīng)編碼了很多關(guān)于我們語言的信息。因此,訓(xùn)練我們的微調(diào)模型所需的時間要少得多——就好像我們已經(jīng)對網(wǎng)絡(luò)的底層進(jìn)行了廣泛的訓(xùn)練,只需要將它們作為我們的分類任務(wù)的特征,并輕微地調(diào)整它們就好。事實上,作者建議在特定的 NLP 任務(wù)上對 BERT 進(jìn)行微調(diào)時,只需要 2-4 個 epochs 的訓(xùn)練(相比之下,從頭開始訓(xùn)練原始 BERT 或 LSTM 模型需要數(shù)百個 GPU 小時)。
-
更少的數(shù)據(jù)
此外,也許同樣重要的是,預(yù)訓(xùn)練這種方法,允許我們在一個比從頭開始建立的模型所需要的數(shù)據(jù)集小得多的數(shù)據(jù)集上進(jìn)行微調(diào)。從零開始建立的 NLP 模型的一個主要缺點(diǎn)是,我們通常需要一個龐大的數(shù)據(jù)集來訓(xùn)練我們的網(wǎng)絡(luò),以達(dá)到合理的精度,這意味著我們必須投入大量的時間和精力在數(shù)據(jù)集的創(chuàng)建上。通過對 BERT 進(jìn)行微調(diào),我們現(xiàn)在可以在更少的數(shù)據(jù)集上訓(xùn)練一個模型,使其達(dá)到良好的性能。
-
更好的結(jié)果
最后,這種簡單的微調(diào)程過程(通常在 BERT 的基礎(chǔ)上增加一個全連接層,并訓(xùn)練幾個 epochs)被證明可以在廣泛的任務(wù)中以最小的調(diào)節(jié)代價來實現(xiàn)最先進(jìn)的結(jié)果:分類、語言推理、語義相似度、問答問題等。與其實現(xiàn)定制的、有時還很難理解的網(wǎng)絡(luò)結(jié)構(gòu)來完成特定的任務(wù),不如使用 BERT 進(jìn)行簡單的微調(diào),也許是一個更好的(至少不會差)選擇。
NLP 的轉(zhuǎn)變
這種向遷移學(xué)習(xí)的轉(zhuǎn)變,與幾年前計算機(jī)視覺領(lǐng)域發(fā)生的轉(zhuǎn)變相似。為計算機(jī)視覺任務(wù)創(chuàng)建一個好的深度學(xué)習(xí)網(wǎng)絡(luò)可能需要數(shù)百萬個參數(shù),并且訓(xùn)練成本非常高。研究人員發(fā)現(xiàn),深度網(wǎng)絡(luò)的特征表示可以分層進(jìn)行學(xué)習(xí)(在最底層學(xué)習(xí)簡單的特征,如物體邊緣等,在更高的層逐漸增加復(fù)雜的特征)。與其每次從頭開始訓(xùn)練一個新的網(wǎng)絡(luò),不如將訓(xùn)練好的網(wǎng)絡(luò)的低層泛化圖像特征復(fù)制并轉(zhuǎn)移到另一個有不同任務(wù)的網(wǎng)絡(luò)中使用。很快,下載一個預(yù)訓(xùn)練過的深度網(wǎng)絡(luò),然后為新任務(wù)快速地重新訓(xùn)練它,或者在上面添加額外的層,這已成為一種常見的做法——這比從頭開始訓(xùn)練一個昂貴的網(wǎng)絡(luò)要好得多。對許多人來說,2018年推出的深度預(yù)訓(xùn)練語言模型(ELMO、BERT、ULMFIT、Open-GPT等),預(yù)示著和計算機(jī)視覺一樣,NLP 正在向遷移學(xué)習(xí)發(fā)生轉(zhuǎn)變。
讓我們開始行動吧!
1. 安裝
1.1. 使用 Colab GPU 來訓(xùn)練
Google Colab 提供免費(fèi)的 GPU 和 TPU!由于我們將訓(xùn)練一個大型的神經(jīng)網(wǎng)絡(luò),所以最好使用這些硬件加速(本文中,我們將使用一個 GPU),否則訓(xùn)練將需要很長時間。
你可以在目錄中選擇添加 GPU
Edit -> Notebook Settings -> Hardware accelerator -> (GPU)
接著運(yùn)行下面代碼來確認(rèn) GPU 被檢測到:
import torch
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
device
-----
device(type='cuda')
1.2. 安裝 Hugging Face 庫
下一步,我們來安裝 Hugging Face 的 transformers 庫,它將為我們提供一個 BERT 的 pytorch 接口(這個庫包含其他預(yù)訓(xùn)練語言模型的接口,如 OpenAI 的 GPT 和 GPT-2)。我們選擇了 pytorch 接口,因為它在高層次的API(很容易使用,但缺乏細(xì)節(jié))和 tensorflow 代碼(其中包含很多細(xì)節(jié),這往往會讓我們陷入關(guān)于 tensorflow 的學(xué)習(xí)中,而這里的目的是 BERT!)之間取得了很好的平衡。
目前來看,Hugging Face 似乎是被廣泛接受的、最強(qiáng)大的 Bert 接口。除了支持各種不同的預(yù)訓(xùn)練模型外,該庫還包含了適應(yīng)于不同任務(wù)的模型的預(yù)構(gòu)建。例如,在本教程中,我們將使用 BertForSequenceClassification 來做文本分類。
該庫還為 token classification、question answering、next sentence prediction 等不同 NLP 任務(wù)提供特定的類庫。使用這些預(yù)構(gòu)建的類,可以簡化定制 BERT 的過程。安裝 transformer:
!pip install transformers
本教程中的代碼實際上是 huggingface 樣例代碼 run_glue.py 的簡化版本。
run_glue.py 是一個有用的工具,它可以讓你選擇你想運(yùn)行的 GLUE 任務(wù),以及你想使用的預(yù)訓(xùn)練模型。它還支持使用 CPU、單個 GPU 或多個 GPU。如果你想進(jìn)一步提高速度,它甚至支持使用 16 位精度。
遺憾的是,所有這些配置讓代碼的可讀性變得很差,本教程會極大的簡化這些代碼,并增加大量的注釋,讓大家知其然,并知其所以然。
2. 加載 CoLA 數(shù)據(jù)集
我們將使用 The Corpus of Linguistic Acceptability(CoLA)數(shù)據(jù)集進(jìn)行單句分類。它是一組被標(biāo)記為語法正確或錯誤的句子。它于2018年5月首次發(fā)布,是 "GLUE Benchmark" 中的數(shù)據(jù)集之一。
2.1. 下載 & 解壓
我們使用 wget 來下載數(shù)據(jù)集,安裝 wget:
!pip install wget
下載數(shù)據(jù)集
import wget
import os
print('Downloading dataset...')
# 數(shù)據(jù)集的下載鏈接
url = 'https://nyu-mll.github.io/CoLA/cola_public_1.1.zip'
# 如本地沒有,則下載數(shù)據(jù)集
if not os.path.exists('./cola_public_1.1.zip'):
wget.download(url, './cola_public_1.1.zip')
解壓之后,你就可以在 Colab 左側(cè)的文件系統(tǒng)窗口看到這些文件:
# 如果沒解壓過,則解壓zip包
if not os.path.exists('./cola_public/'):
!unzip cola_public_1.1.zip
2.2. 解析
從解壓后的文件名就可以看出哪些文件是分詞后的,哪些是原始文件。
我們使用未分詞版本的數(shù)據(jù),因為要應(yīng)用預(yù)訓(xùn)練 BERT,必須使用模型自帶的分詞器。這是因為: (1) 模型有一個固定的詞匯表, (2) BERT 用一種特殊的方式來處理詞匯外的單詞(out-of-vocabulary)。
先使用 pandas 來解析 in_domain_train.tsv 文件,并預(yù)覽這些數(shù)據(jù):
import pandas as pd
# 加載數(shù)據(jù)集到 pandas 的 dataframe 中
df = pd.read_csv("./cola_public/raw/in_domain_train.tsv", delimiter='\t', header=None, names=['sentence_source', 'label', 'label_notes', 'sentence'])
# 打印數(shù)據(jù)集的記錄數(shù)
print('Number of training sentences: {:,}\n'.format(df.shape[0]))
# 抽樣10條數(shù)據(jù)來預(yù)覽一下
df.sample(10)
| sentence_source | label | label_notes | sentence | |
|---|---|---|---|---|
| 1406 | r-67 | 1 | NaN | A plan to negotiate an honorable end to the wa... |
| 7315 | sks13 | 0 | * | I said. |
| 8277 | ad03 | 0 | * | What Julie did of Lloyd was become fond. |
| 621 | bc01 | 1 | NaN | The ball lies completely in the box. |
| 6646 | m_02 | 1 | NaN | Very heavy, this parcel! |
| 361 | bc01 | 0 | ?? | Which problem do you wonder whether John said ... |
| 7193 | sks13 | 0 | * | Will put, this girl in the red coat will put a... |
| 4199 | ks08 | 1 | NaN | The papers removed from the safe have not been... |
| 5251 | b_82 | 1 | NaN | He continued writing poems. |
| 3617 | ks08 | 1 | NaN | It was last night that the policeman met sever... |
上表中我們主要關(guān)心 sentence 和 label 字段,label 中 0 表示“語法不可接受”,而 1 表示“語法可接受”。
下面是 5 個語法上不可接受的例子,可以看到相對于情感分析來說,這個任務(wù)要困難很多:
df.loc[df.label == 0].sample(5)[['sentence', 'label']]
| sentence | label | |
|---|---|---|
| 4867 | They investigated. | 0 |
| 200 | The more he reads, the more books I wonder to ... | 0 |
| 4593 | Any zebras can't fly. | 0 |
| 3226 | Cities destroy easily. | 0 |
| 7337 | The time elapsed the day. | 0 |
我們把 sentence 和 label 字段加載到 numpy 數(shù)組中
# 構(gòu)建 sentences 和 labels 列表
sentences = df.sentence.values
labels = df.label.values
3. 分詞 & 格式化輸入層
在本小節(jié)中,我們會將數(shù)據(jù)集轉(zhuǎn)化為可被 BERT 訓(xùn)練的格式。
3.1. BERT 分詞器
要將文本輸入到 BERT 中,必須先對它們分詞,并使用模型內(nèi)部提供的詞匯表,把這些詞轉(zhuǎn)換為詞的下標(biāo)。
先在代碼中導(dǎo)入 BERT 庫,這里使用 "uncased" 小寫版本的預(yù)訓(xùn)練模型:
from transformers import BertTokenizer
# 加載 BERT 分詞器
print('Loading BERT tokenizer...')
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased', do_lower_case=True)
我們輸入一個句子試試:
# 輸出原始句子
print(' Original: ', sentences[0])
# 將分詞后的內(nèi)容輸出
print('Tokenized: ', tokenizer.tokenize(sentences[0]))
# 將每個詞映射到詞典下標(biāo)
print('Token IDs: ', tokenizer.convert_tokens_to_ids(tokenizer.tokenize(sentences[0])))
-----
Original: Our friends won't buy this analysis, let alone the next one we propose.
Tokenized: ['our', 'friends', 'won', "'", 't', 'buy', 'this', 'analysis', ',', 'let', 'alone', 'the', 'next', 'one', 'we', 'propose', '.']
Token IDs: [2256, 2814, 2180, 1005, 1056, 4965, 2023, 4106, 1010, 2292, 2894, 1996, 2279, 2028, 2057, 16599, 1012]
在真正訓(xùn)練的時候,我們使用 tokenize.encode 這個函數(shù)來完成上面 tokenize 和 convert_tokens_to_ids 兩個步驟。
在這之前,我們先介紹下 BERT 的格式化要求。
3.2. 格式化要求
BERT 要求我們:
- 在句子的句首和句尾添加特殊的符號
- 給句子填充 or 截斷,使每個句子保持固定的長度
- 用 “attention mask” 來顯示的區(qū)分填充的 tokens 和非填充的 tokens。
特殊符號
[SEP]
在每個句子的結(jié)尾,需要添加特殊的 [SEP] 符號。
在以輸入為兩個句子的任務(wù)中(例如:句子 A 中的問題的答案是否可以在句子 B 中找到),該符號為這兩個句子的分隔符。
目前為止我還不清楚為什么要在單句中加入該符號,但既然這樣要求我們就這么做吧。
[CLS]
在分類任務(wù)中,我們需要將 [CLS] 符號插入到每個句子的開頭。
這個符號有特殊的意義,BERT 包含 12 個 Transformer 層,每層接受一組 token 的 embeddings 列表作為輸入,并產(chǎn)生相同數(shù)目的 embeddings 作為輸出(當(dāng)然,它們的值是不同的)。

最后一層 transformer 的輸出,只有第 1 個 embedding(對應(yīng)到 [CLS] 符號)會輸入到分類器中。
“The first token of every sequence is always a special classification token (
[CLS]). The final hidden state corresponding to this token is used as the aggregate sequence representation for classification tasks.” (from the BERT paper)
你也許會想到對最后一層的 embeddings 使用一些池化策略,但沒有必要。因為 BERT 就是被訓(xùn)練成只使用 [CLS] 來做分類,它會把分類所需的一切信息編碼到 [CLS] 對應(yīng)的 768 維 embedding 向量中,相當(dāng)于它已經(jīng)為我們做好了池化工作。
句長 & 注意力掩碼(Attention Mask)
很明顯,數(shù)據(jù)集中句子長度的取值范圍很大,BERT 該如何處理這個問題呢?
BERT 有兩個限制條件:
所有句子必須被填充或截斷到固定的長度,句子最大的長度為 512 個 tokens。
填充句子要使用
[PAD]符號,它在 BERT 詞典中的下標(biāo)為 0,下圖是最大長度為 8 個 tokens 的填充說明:

“Attention Mask” 是一個只有 0 和 1 組成的數(shù)組,標(biāo)記哪些 tokens 是填充的,哪些不是的。掩碼會告訴 BERT 中的 “Self-Attention” 機(jī)制不去處理這些填充的符號。
句子的最大長度配置會影響訓(xùn)練和評估速度,例如,在 Tesla K80 上有以下測試:
MAX_LEN = 128 # 每個 epoch 要訓(xùn)練 5'28''
MAX_LEN = 64 # 每個 epoch 要訓(xùn)練 2'27''
3.3. 對數(shù)據(jù)集分詞
transformers 庫提供的 encode 函數(shù)會為我們處理大多數(shù)解析和數(shù)據(jù)預(yù)處理的工作。
在編碼文本之前,我們需要確定 MAX_LEN 這個參數(shù),下面的代碼可以計算數(shù)據(jù)集中句子的最大長度:
max_len = 0
for sent in sentences:
# 將文本分詞,并添加 `[CLS]` 和 `[SEP]` 符號
input_ids = tokenizer.encode(sent, add_special_tokens=True)
max_len = max(max_len, len(input_ids))
print('Max sentence length: ', max_len)
為了避免不會出現(xiàn)更長的句子,這里我們將 MAX_LEN 設(shè)為 64。下面我們正式開始分詞。
函數(shù) tokenizer.encode_plus 包含以下步驟:
- 將句子分詞為 tokens。
- 在兩端添加特殊符號
[CLS]和[SEP]。 - 將 tokens 映射為下標(biāo) IDs。
- 將列表填充或截斷為固定的長度。
- 創(chuàng)建 attention masks,將填充的和非填充 tokens 區(qū)分開來。
# 將數(shù)據(jù)集分完詞后存儲到列表中
input_ids = []
attention_masks = []
for sent in sentences:
encoded_dict = tokenizer.encode_plus(
sent, # 輸入文本
add_special_tokens = True, # 添加 '[CLS]' 和 '[SEP]'
max_length = 64, # 填充 & 截斷長度
pad_to_max_length = True,
return_attention_mask = True, # 返回 attn. masks.
return_tensors = 'pt', # 返回 pytorch tensors 格式的數(shù)據(jù)
)
# 將編碼后的文本加入到列表
input_ids.append(encoded_dict['input_ids'])
# 將文本的 attention mask 也加入到 attention_masks 列表
attention_masks.append(encoded_dict['attention_mask'])
# 將列表轉(zhuǎn)換為 tensor
input_ids = torch.cat(input_ids, dim=0)
attention_masks = torch.cat(attention_masks, dim=0)
labels = torch.tensor(labels)
# 輸出第 1 行文本的原始和編碼后的信息
print('Original: ', sentences[0])
print('Token IDs:', input_ids[0])
3.4. 拆分訓(xùn)練集和驗證集
將 90% 的數(shù)據(jù)集作為訓(xùn)練集,剩下的 10% 作為驗證集:
from torch.utils.data import TensorDataset, random_split
# 將輸入數(shù)據(jù)合并為 TensorDataset 對象
dataset = TensorDataset(input_ids, attention_masks, labels)
# 計算訓(xùn)練集和驗證集大小
train_size = int(0.9 * len(dataset))
val_size = len(dataset) - train_size
# 按照數(shù)據(jù)大小隨機(jī)拆分訓(xùn)練集和測試集
train_dataset, val_dataset = random_split(dataset, [train_size, val_size])
print('{:>5,} training samples'.format(train_size))
print('{:>5,} validation samples'.format(val_size))
我們使用 DataLoader 類來讀取數(shù)據(jù)集,相對于一般的 for 循環(huán)來說,這種方法在訓(xùn)練期間會比較節(jié)省內(nèi)存:
from torch.utils.data import DataLoader, RandomSampler, SequentialSampler
# 在 fine-tune 的訓(xùn)練中,BERT 作者建議小批量大小設(shè)為 16 或 32
batch_size = 32
# 為訓(xùn)練和驗證集創(chuàng)建 Dataloader,對訓(xùn)練樣本隨機(jī)洗牌
train_dataloader = DataLoader(
train_dataset, # 訓(xùn)練樣本
sampler = RandomSampler(train_dataset), # 隨機(jī)小批量
batch_size = batch_size # 以小批量進(jìn)行訓(xùn)練
)
# 驗證集不需要隨機(jī)化,這里順序讀取就好
validation_dataloader = DataLoader(
val_dataset, # 驗證樣本
sampler = SequentialSampler(val_dataset), # 順序選取小批量
batch_size = batch_size
)
4. 訓(xùn)練分類模型
現(xiàn)在模型的輸入數(shù)據(jù)已經(jīng)準(zhǔn)備好了,是時候開始微調(diào)了。
4.1. BertForSequenceClassification
在本任務(wù)中,我們首先需要將預(yù)訓(xùn)練 BERT 模型改為分類模型。接著,用我們的數(shù)據(jù)集來訓(xùn)練這個模型,以使該模型能夠端到端的、很好的適應(yīng)于我們的任務(wù)。
幸運(yùn)的是,huggingface 的 pytorch 實現(xiàn)包含一系列接口,就是為不同的 NLP 任務(wù)設(shè)計的。這些接口無一例外的構(gòu)建于 BERT 模型之上,對于不同的 NLP 任務(wù),它們有不同的結(jié)構(gòu)和不同的輸出類型。
以下是當(dāng)前提供給微調(diào)的類列表:
- BertModel
- BertForPreTraining
- BertForNextSentencePrediction
- BertForNextSentencePrediction
- BertForSequenceClassification - 我們使用這個
- BertForTokenClassification
- BertForQuestionAnswering
這些類的文檔在這里。
我們將使用 BertForSequenceClassification,它由一個普通的 BERT 模型和一個單線性分類層組成,而后者主要負(fù)責(zé)文本分類。當(dāng)我們向模型輸入數(shù)據(jù)時,整個預(yù)訓(xùn)練 BERT 模型和額外的未訓(xùn)練的分類層將會一起被訓(xùn)練。
好了, 我們現(xiàn)在加載 BERT!有幾種不同的預(yù)訓(xùn)練模型可供選擇,"bert-base-uncased" 是只有小寫字母的版本,且它是 "base" 和 "large" 中的較小版。
接口 from_pretrained 的文檔在這里,額外的參數(shù)說明在這里。
from transformers import BertForSequenceClassification, AdamW, BertConfig
# 加載 BertForSequenceClassification, 預(yù)訓(xùn)練 BERT 模型 + 頂層的線性分類層
model = BertForSequenceClassification.from_pretrained(
"bert-base-uncased", # 小寫的 12 層預(yù)訓(xùn)練模型
num_labels = 2, # 分類數(shù) --2 表示二分類
# 你可以改變這個數(shù)字,用于多分類任務(wù)
output_attentions = False, # 模型是否返回 attentions weights.
output_hidden_states = False, # 模型是否返回所有隱層狀態(tài).
)
# 在 gpu 中運(yùn)行該模型
model.cuda()
好奇心使然,我們可以根據(jù)參數(shù)名來查看所有的模型參數(shù)。
下面會打印參數(shù)名和參數(shù)的形狀:
- embedding 層
- 12 層 transformers 的第 1 層
- 輸出層
# 將所有模型參數(shù)轉(zhuǎn)換為一個列表
params = list(model.named_parameters())
print('The BERT model has {:} different named parameters.\n'.format(len(params)))
print('==== Embedding Layer ====\n')
for p in params[0:5]:
print("{:<55} {:>12}".format(p[0], str(tuple(p[1].size()))))
print('\n==== First Transformer ====\n')
for p in params[5:21]:
print("{:<55} {:>12}".format(p[0], str(tuple(p[1].size()))))
print('\n==== Output Layer ====\n')
for p in params[-4:]:
print("{:<55} {:>12}".format(p[0], str(tuple(p[1].size()))))
輸出
The BERT model has 201 different named parameters.
==== Embedding Layer ====
bert.embeddings.word_embeddings.weight (30522, 768)
bert.embeddings.position_embeddings.weight (512, 768)
bert.embeddings.token_type_embeddings.weight (2, 768)
bert.embeddings.LayerNorm.weight (768,)
bert.embeddings.LayerNorm.bias (768,)
==== First Transformer ====
bert.encoder.layer.0.attention.self.query.weight (768, 768)
bert.encoder.layer.0.attention.self.query.bias (768,)
bert.encoder.layer.0.attention.self.key.weight (768, 768)
bert.encoder.layer.0.attention.self.key.bias (768,)
bert.encoder.layer.0.attention.self.value.weight (768, 768)
bert.encoder.layer.0.attention.self.value.bias (768,)
bert.encoder.layer.0.attention.output.dense.weight (768, 768)
bert.encoder.layer.0.attention.output.dense.bias (768,)
bert.encoder.layer.0.attention.output.LayerNorm.weight (768,)
bert.encoder.layer.0.attention.output.LayerNorm.bias (768,)
bert.encoder.layer.0.intermediate.dense.weight (3072, 768)
bert.encoder.layer.0.intermediate.dense.bias (3072,)
bert.encoder.layer.0.output.dense.weight (768, 3072)
bert.encoder.layer.0.output.dense.bias (768,)
bert.encoder.layer.0.output.LayerNorm.weight (768,)
bert.encoder.layer.0.output.LayerNorm.bias (768,)
==== Output Layer ====
bert.pooler.dense.weight (768, 768)
bert.pooler.dense.bias (768,)
classifier.weight (2, 768)
classifier.bias (2,)
4.2. 優(yōu)化器 & 學(xué)習(xí)率調(diào)度器
加載了模型后,下一步我們來調(diào)節(jié)超參數(shù)。
在微調(diào)過程中,BERT 的作者建議使用以下超參 (from Appendix A.3 of the BERT paper)::
- 批量大?。?6, 32
- 學(xué)習(xí)率(Adam):5e-5, 3e-5, 2e-5
- epochs 的次數(shù):2, 3, 4
我們的選擇如下:
- Batch size: 32(在構(gòu)建 DataLoaders 時設(shè)置)
- Learning rate:2e-5
- Epochs: 4(我們將看到這個值對于本任務(wù)來說有點(diǎn)大了)
參數(shù) epsilon = 1e-8 是一個非常小的值,他可以避免實現(xiàn)過程中的分母為 0 的情況 (from here)。
你可以在 run_glue.py 中找到優(yōu)化器 AdamW 的創(chuàng)建:
# 我認(rèn)為 'W' 代表 '權(quán)重衰減修復(fù)"
optimizer = AdamW(model.parameters(),
lr = 2e-5, # args.learning_rate - default is 5e-5
eps = 1e-8 # args.adam_epsilon - default is 1e-8
)
from transformers import get_linear_schedule_with_warmup
# 訓(xùn)練 epochs。 BERT 作者建議在 2 和 4 之間,設(shè)大了容易過擬合
epochs = 4
# 總的訓(xùn)練樣本數(shù)
total_steps = len(train_dataloader) * epochs
# 創(chuàng)建學(xué)習(xí)率調(diào)度器
scheduler = get_linear_schedule_with_warmup(optimizer,
num_warmup_steps = 0,
num_training_steps = total_steps)
4.3. 訓(xùn)練循環(huán)
下面是訓(xùn)練循環(huán),有很多代碼,但基本上每次循環(huán),均包括訓(xùn)練環(huán)節(jié)和評估環(huán)節(jié)。
訓(xùn)練:
- 取出輸入樣本和標(biāo)簽數(shù)據(jù)
- 加載這些數(shù)據(jù)到 GPU 中
- 清除上次迭代的梯度計算
- pytorch 中梯度是累加的(在 RNN 中有用),本例中每次迭代前需手動清零
- 前向傳播
- 反向傳播
- 使用優(yōu)化器來更新參數(shù)
- 監(jiān)控訓(xùn)練過程
評估:
- 取出輸入樣本和標(biāo)簽數(shù)據(jù)
- 加載這些數(shù)據(jù)到 GPU 中
- 前向計算
- 計算 loss 并監(jiān)控整個評估過程
定義計算準(zhǔn)確率的函數(shù):
import numpy as np
# 根據(jù)預(yù)測結(jié)果和標(biāo)簽數(shù)據(jù)來計算準(zhǔn)確率
def flat_accuracy(preds, labels):
pred_flat = np.argmax(preds, axis=1).flatten()
labels_flat = labels.flatten()
return np.sum(pred_flat == labels_flat) / len(labels_flat)
將訓(xùn)練耗時格式化成 hh:mm:ss 的幫助函數(shù):
import time
import datetime
def format_time(elapsed):
'''
Takes a time in seconds and returns a string hh:mm:ss
'''
# 四舍五入到最近的秒
elapsed_rounded = int(round((elapsed)))
# 格式化為 hh:mm:ss
return str(datetime.timedelta(seconds=elapsed_rounded))
全部訓(xùn)練代碼:
import random
import numpy as np
# 以下訓(xùn)練代碼是基于 `run_glue.py` 腳本:
# https://github.com/huggingface/transformers/blob/5bfcd0485ece086ebcbed2d008813037968a9e58/examples/run_glue.py#L128
# 設(shè)定隨機(jī)種子值,以確保輸出是確定的
seed_val = 42
random.seed(seed_val)
np.random.seed(seed_val)
torch.manual_seed(seed_val)
torch.cuda.manual_seed_all(seed_val)
# 存儲訓(xùn)練和評估的 loss、準(zhǔn)確率、訓(xùn)練時長等統(tǒng)計指標(biāo),
training_stats = []
# 統(tǒng)計整個訓(xùn)練時長
total_t0 = time.time()
for epoch_i in range(0, epochs):
# ========================================
# Training
# ========================================
print("")
print('======== Epoch {:} / {:} ========'.format(epoch_i + 1, epochs))
print('Training...')
# 統(tǒng)計單次 epoch 的訓(xùn)練時間
t0 = time.time()
# 重置每次 epoch 的訓(xùn)練總 loss
total_train_loss = 0
# 將模型設(shè)置為訓(xùn)練模式。這里并不是調(diào)用訓(xùn)練接口的意思
# dropout、batchnorm 層在訓(xùn)練和測試模式下的表現(xiàn)是不同的 (source: https://stackoverflow.com/questions/51433378/what-does-model-train-do-in-pytorch)
model.train()
# 訓(xùn)練集小批量迭代
for step, batch in enumerate(train_dataloader):
# 每經(jīng)過40次迭代,就輸出進(jìn)度信息
if step % 40 == 0 and not step == 0:
elapsed = format_time(time.time() - t0)
print(' Batch {:>5,} of {:>5,}. Elapsed: {:}.'.format(step, len(train_dataloader), elapsed))
# 準(zhǔn)備輸入數(shù)據(jù),并將其拷貝到 gpu 中
b_input_ids = batch[0].to(device)
b_input_mask = batch[1].to(device)
b_labels = batch[2].to(device)
# 每次計算梯度前,都需要將梯度清 0,因為 pytorch 的梯度是累加的
model.zero_grad()
# 前向傳播
# 文檔參見:
# https://huggingface.co/transformers/v2.2.0/model_doc/bert.html#transformers.BertForSequenceClassification
# 該函數(shù)會根據(jù)不同的參數(shù),會返回不同的值。 本例中, 會返回 loss 和 logits -- 模型的預(yù)測結(jié)果
loss, logits = model(b_input_ids,
token_type_ids=None,
attention_mask=b_input_mask,
labels=b_labels)
# 累加 loss
total_train_loss += loss.item()
# 反向傳播
loss.backward()
# 梯度裁剪,避免出現(xiàn)梯度爆炸情況
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
# 更新參數(shù)
optimizer.step()
# 更新學(xué)習(xí)率
scheduler.step()
# 平均訓(xùn)練誤差
avg_train_loss = total_train_loss / len(train_dataloader)
# 單次 epoch 的訓(xùn)練時長
training_time = format_time(time.time() - t0)
print("")
print(" Average training loss: {0:.2f}".format(avg_train_loss))
print(" Training epcoh took: {:}".format(training_time))
# ========================================
# Validation
# ========================================
# 完成一次 epoch 訓(xùn)練后,就對該模型的性能進(jìn)行驗證
print("")
print("Running Validation...")
t0 = time.time()
# 設(shè)置模型為評估模式
model.eval()
# Tracking variables
total_eval_accuracy = 0
total_eval_loss = 0
nb_eval_steps = 0
# Evaluate data for one epoch
for batch in validation_dataloader:
# 將輸入數(shù)據(jù)加載到 gpu 中
b_input_ids = batch[0].to(device)
b_input_mask = batch[1].to(device)
b_labels = batch[2].to(device)
# 評估的時候不需要更新參數(shù)、計算梯度
with torch.no_grad():
(loss, logits) = model(b_input_ids,
token_type_ids=None,
attention_mask=b_input_mask,
labels=b_labels)
# 累加 loss
total_eval_loss += loss.item()
# 將預(yù)測結(jié)果和 labels 加載到 cpu 中計算
logits = logits.detach().cpu().numpy()
label_ids = b_labels.to('cpu').numpy()
# 計算準(zhǔn)確率
total_eval_accuracy += flat_accuracy(logits, label_ids)
# 打印本次 epoch 的準(zhǔn)確率
avg_val_accuracy = total_eval_accuracy / len(validation_dataloader)
print(" Accuracy: {0:.2f}".format(avg_val_accuracy))
# 統(tǒng)計本次 epoch 的 loss
avg_val_loss = total_eval_loss / len(validation_dataloader)
# 統(tǒng)計本次評估的時長
validation_time = format_time(time.time() - t0)
print(" Validation Loss: {0:.2f}".format(avg_val_loss))
print(" Validation took: {:}".format(validation_time))
# 記錄本次 epoch 的所有統(tǒng)計信息
training_stats.append(
{
'epoch': epoch_i + 1,
'Training Loss': avg_train_loss,
'Valid. Loss': avg_val_loss,
'Valid. Accur.': avg_val_accuracy,
'Training Time': training_time,
'Validation Time': validation_time
}
)
print("")
print("Training complete!")
print("Total training took {:} (h:mm:ss)".format(format_time(time.time()-total_t0)))
我們一起來看一下整個訓(xùn)練的概要:
import pandas as pd
# 保留 2 位小數(shù)
pd.set_option('precision', 2)
# 加載訓(xùn)練統(tǒng)計到 DataFrame 中
df_stats = pd.DataFrame(data=training_stats)
# 使用 epoch 值作為每行的索引
df_stats = df_stats.set_index('epoch')
# 展示表格數(shù)據(jù)
df_stats
| epoch | Training Loss | Valid. Loss | Valid. Accur. | Training Time | Validation Time |
|---|---|---|---|---|---|
| 1 | 0.50 | 0.45 | 0.80 | 0:00:51 | 0:00:02 |
| 2 | 0.32 | 0.46 | 0.81 | 0:00:51 | 0:00:02 |
| 3 | 0.22 | 0.49 | 0.82 | 0:00:51 | 0:00:02 |
| 4 | 0.16 | 0.55 | 0.82 | 0:00:51 | 0:00:02 |
注意到,每次 epoch,訓(xùn)練誤差都會降低,而驗證誤差卻在上升!這意味著我們的訓(xùn)練模型的時間過長了,即模型過擬合了。
在評估過程中,驗證集誤差相對于準(zhǔn)確率來說更為精細(xì),因為準(zhǔn)確率并不關(guān)心具體的輸出值,而僅僅考慮給定一個閾值,樣本會落在哪個分類上。
當(dāng)我們預(yù)測正確,但信心依然不足時,可以使用驗證誤差來評估,而準(zhǔn)確率卻做不到這一點(diǎn),對比每次 epoch 的訓(xùn)練誤差和驗證誤差:
import matplotlib.pyplot as plt
% matplotlib inline
import seaborn as sns
# 繪圖風(fēng)格設(shè)置
sns.set(style='darkgrid')
# Increase the plot size and font size.
sns.set(font_scale=1.5)
plt.rcParams["figure.figsize"] = (12,6)
# 繪制學(xué)習(xí)曲線
plt.plot(df_stats['Training Loss'], 'b-o', label="Training")
plt.plot(df_stats['Valid. Loss'], 'g-o', label="Validation")
# Label the plot.
plt.title("Training & Validation Loss")
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.legend()
plt.xticks([1, 2, 3, 4])
plt.show()

5. 在測試集上測試性能
下面我們加載測試集,并使用 Matthew相關(guān)系數(shù)來評估模型性能,因為這是一種在 NLP 社區(qū)中被廣泛使用的衡量 CoLA 任務(wù)性能的方法。使用這種測量方法,+1 為最高分,-1 為最低分。于是,我們就可以在特定任務(wù)上,橫向和最好的模型進(jìn)行性能對比了。
5.1. 數(shù)據(jù)準(zhǔn)備
對測試集的處理,和處理訓(xùn)練數(shù)據(jù)集的步驟是一致的,如下
import pandas as pd
# 加載數(shù)據(jù)集
df = pd.read_csv("./cola_public/raw/out_of_domain_dev.tsv", delimiter='\t', header=None, names=['sentence_source', 'label', 'label_notes', 'sentence'])
# 打印數(shù)據(jù)集大小
print('Number of test sentences: {:,}\n'.format(df.shape[0]))
# 將數(shù)據(jù)集轉(zhuǎn)換為列表
sentences = df.sentence.values
labels = df.label.values
# 分詞、填充或截斷
input_ids = []
attention_masks = []
for sent in sentences:
encoded_dict = tokenizer.encode_plus(
sent,
add_special_tokens = True,
max_length = 64,
pad_to_max_length = True,
return_attention_mask = True,
return_tensors = 'pt',
)
input_ids.append(encoded_dict['input_ids'])
attention_masks.append(encoded_dict['attention_mask'])
input_ids = torch.cat(input_ids, dim=0)
attention_masks = torch.cat(attention_masks, dim=0)
labels = torch.tensor(labels)
batch_size = 32
# 準(zhǔn)備好數(shù)據(jù)集
prediction_data = TensorDataset(input_ids, attention_masks, labels)
prediction_sampler = SequentialSampler(prediction_data)
prediction_dataloader = DataLoader(prediction_data, sampler=prediction_sampler, batch_size=batch_size)
5.2. 評估測試集性能
準(zhǔn)備好測試集數(shù)據(jù)后,就可以用之前微調(diào)的模型來對測試集進(jìn)行預(yù)測了
# 預(yù)測測試集
print('Predicting labels for {:,} test sentences...'.format(len(input_ids)))
# 依然是評估模式
model.eval()
# Tracking variables
predictions , true_labels = [], []
# 預(yù)測
for batch in prediction_dataloader:
# 將數(shù)據(jù)加載到 gpu 中
batch = tuple(t.to(device) for t in batch)
b_input_ids, b_input_mask, b_labels = batch
# 不需要計算梯度
with torch.no_grad():
# 前向傳播,獲取預(yù)測結(jié)果
outputs = model(b_input_ids, token_type_ids=None,
attention_mask=b_input_mask)
logits = outputs[0]
# 將結(jié)果加載到 cpu 中
logits = logits.detach().cpu().numpy()
label_ids = b_labels.to('cpu').numpy()
# 存儲預(yù)測結(jié)果和 labels
predictions.append(logits)
true_labels.append(label_ids)
print(' DONE.')
使用 Mathews 相關(guān)性系數(shù)(MCC)來評估測試集性能,原因在于類別的分布是不均勻的:
print('Positive samples: %d of %d (%.2f%%)' % (df.label.sum(), len(df.label), (df.label.sum() / len(df.label) * 100.0)))
-----
Positive samples: 354 of 516 (68.60%)
最終評測結(jié)果會基于全量的測試數(shù)據(jù),不過我們可以統(tǒng)計每個小批量各自的分?jǐn)?shù),以查看批量之間的變化。
from sklearn.metrics import matthews_corrcoef
matthews_set = []
# 計算每個 batch 的 MCC
print('Calculating Matthews Corr. Coef. for each batch...')
# For each input batch...
for i in range(len(true_labels)):
pred_labels_i = np.argmax(predictions[i], axis=1).flatten()
# 計算該 batch 的 MCC
matthews = matthews_corrcoef(true_labels[i], pred_labels_i)
matthews_set.append(matthews)
# 創(chuàng)建柱狀圖來顯示每個 batch 的 MCC 分?jǐn)?shù)
ax = sns.barplot(x=list(range(len(matthews_set))), y=matthews_set, ci=None)
plt.title('MCC Score per Batch')
plt.ylabel('MCC Score (-1 to +1)')
plt.xlabel('Batch #')
plt.show()

我們將所有批量的結(jié)果合并,來計算最終的 MCC 分:
# 合并所有 batch 的預(yù)測結(jié)果
flat_predictions = np.concatenate(predictions, axis=0)
# 取每個樣本的最大值作為預(yù)測值
flat_predictions = np.argmax(flat_predictions, axis=1).flatten()
# 合并所有的 labels
flat_true_labels = np.concatenate(true_labels, axis=0)
# 計算 MCC
mcc = matthews_corrcoef(flat_true_labels, flat_predictions)
print('Total MCC: %.3f' % mcc)
-----
Total MCC: 0.498
Cool!只用了半個小時,在沒有調(diào)整任何超參數(shù)的情況下(調(diào)整學(xué)習(xí)率、epochs、批量大小、ADAM 屬性等),我們卻得到了一個還不賴的分?jǐn)?shù)。
庫文檔期望的準(zhǔn)確率 benchmark 在此查看。你也可以在這里查看官方的排行榜。
總結(jié)
本教程主要描述了在預(yù)訓(xùn)練 BERT 模型的基礎(chǔ)上,你可以使用較少數(shù)據(jù)和訓(xùn)練時間,快速且高效的創(chuàng)建一個高質(zhì)量的 NLP 模型。
附錄
A.1. 存儲 & 加載微調(diào)的模型
下面的代碼(源自 run_glue.py)將模型和分詞器寫到磁盤上
import os
# 模型存儲到的路徑
output_dir = './model_save/'
# 目錄不存在則創(chuàng)建
if not os.path.exists(output_dir):
os.makedirs(output_dir)
print("Saving model to %s" % output_dir)
# 使用 `save_pretrained()` 來保存已訓(xùn)練的模型,模型配置和分詞器
# 它們后續(xù)可以通過 `from_pretrained()` 加載
model_to_save = model.module if hasattr(model, 'module') else model # 考慮到分布式/并行(distributed/parallel)訓(xùn)練
model_to_save.save_pretrained(output_dir)
tokenizer.save_pretrained(output_dir)
# Good practice: save your training arguments together with the trained model
# torch.save(args, os.path.join(output_dir, 'training_args.bin'))
將 Colab Notebook 中的模型存儲到 Google Drive 上
# 掛載 Google Drive
from google.colab import drive
drive.mount('/content/drive')
# 拷貝模型文件到 Google Drive
!cp -r ./model_save/ "./drive/Shared drives/AI/BERT Fine-Tuning/"
下面的代碼將從磁盤上加載模型
# 加載微調(diào)后的模型的詞匯表
model = model_class.from_pretrained(output_dir)
tokenizer = tokenizer_class.from_pretrained(output_dir)
# 將模型 copy 到 GPU/CPU 中運(yùn)行
model.to(device)
A.2. 權(quán)重衰減
huggingface 的例子中包含以下代碼來設(shè)置權(quán)重衰減(weight decay),但默認(rèn)的衰減率為 "0",所以我把這部分代碼移到了附錄中。
這個代碼段本質(zhì)上告訴優(yōu)化器不在 bias 參數(shù)上運(yùn)用權(quán)重衰減,權(quán)重衰減實際上是一種在計算梯度后的正則化。
# 代碼來源于:
# https://github.com/huggingface/transformers/blob/5bfcd0485ece086ebcbed2d008813037968a9e58/examples/run_glue.py#L102
# 不在包含以下字符串的參數(shù)名對應(yīng)的參數(shù)上運(yùn)用權(quán)重衰減
# (Here, the BERT doesn't have `gamma` or `beta` parameters, only `bias` terms)
no_decay = ['bias', 'LayerNorm.weight']
# 將`weight`參數(shù)和`bias`參數(shù)分開
# - 對于`weight`參數(shù), 'weight_decay_rate'設(shè)為 0.01
# - 對于`bias`參數(shù), 'weight_decay_rate'設(shè)為 0.0
optimizer_grouped_parameters = [
# Filter for all parameters which *don't* include 'bias', 'gamma', 'beta'.
{'params': [p for n, p in param_optimizer if not any(nd in n for nd in no_decay)],
'weight_decay_rate': 0.1},
# Filter for parameters which *do* include those.
{'params': [p for n, p in param_optimizer if any(nd in n for nd in no_decay)],
'weight_decay_rate': 0.0}
]
# 注意 - `optimizer_grouped_parameters` 僅包含參數(shù)值,不包含參數(shù)名
譯者注:經(jīng)驗證,以上代碼均可在 Google Colab 上運(yùn)行,鏈接如下:https://colab.research.google.com/drive/1sfAypJA0r8DEaDmTGWD8FCrvpQZ33TVl?usp=sharing