【譯】BERT Fine-Tuning 指南(with PyTorch)

By Chris McCormick and Nick Ryan

原文鏈接:http://tinyurl.com/y74pvgyc

介紹

歷史

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等)?

  1. 更快速的開發(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 小時)。

  2. 更少的數(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á)到良好的性能。

  3. 更好的結(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)心 sentencelabel 字段,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

我們把 sentencelabel 字段加載到 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ù)來完成上面 tokenizeconvert_tokens_to_ids 兩個步驟。

在這之前,我們先介紹下 BERT 的格式化要求。

3.2. 格式化要求

BERT 要求我們:

  1. 在句子的句首和句尾添加特殊的符號
  2. 給句子填充 or 截斷,使每個句子保持固定的長度
  3. 用 “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)然,它們的值是不同的)。

CLS_token_500x606.png

最后一層 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 有兩個限制條件:

  1. 所有句子必須被填充或截斷到固定的長度,句子最大的長度為 512 個 tokens。

  2. 填充句子要使用 [PAD] 符號,它在 BERT 詞典中的下標(biāo)為 0,下圖是最大長度為 8 個 tokens 的填充說明:

padding_and_mask.png

“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 包含以下步驟:

  1. 將句子分詞為 tokens。
  2. 在兩端添加特殊符號 [CLS][SEP]。
  3. 將 tokens 映射為下標(biāo) IDs。
  4. 將列表填充或截斷為固定的長度。
  5. 創(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ù)的形狀:

  1. embedding 層
  2. 12 層 transformers 的第 1 層
  3. 輸出層
# 將所有模型參數(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()
learning_curve_w_validation_loss.png

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()
mcc_score_by_batch.png

我們將所有批量的結(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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容