[转载]个人认为最好的BERT讲解博客(下)

用 BERT fine tune 下游任務¶

我們在給所有人的 NLP 入門指南碰過的假新聞分類任務將會是本文拿 BERT 來做 fine-tuning 的例子。選擇這個任務的最主要理由是因為中文數據容易理解,另外網路上針對兩個句子做分類的例子也較少。

就算你對假新聞分類沒興趣也建議繼續閱讀。因為本節談到的所有概念完全可以被套用到其他語言的文本以及不同的 NLP 任務之上。因此我希望接下來你能一邊閱讀一邊想像如何用同樣的方式把 BERT 拿來處理你自己感興趣的 NLP 任務。

[转载]个人认为最好的BERT讲解博客(下)_第1张图片
給定假新聞 title1,判斷另一新聞 title2 跟 title1 的關係(同意、反對或無關) ( 圖片來源)

fine tune BERT 來解決新的下游任務有 5 個簡單步驟:

  1. 準備原始文本數據
  2. 將原始文本轉換成 BERT 相容的輸入格式
  3. 在 BERT 之上加入新 layer 成下游任務模型
  4. 訓練該下游任務模型
  5. 對新樣本做推論

對,就是那麼直覺。而且你應該已經看出步驟 1、4 及 5 都跟訓練一般模型所需的步驟無太大差異。跟 BERT 最相關的細節事實上是步驟 2 跟 3:

  • 如何將原始數據轉換成 BERT 相容的輸入格式?
  • 如何在 BERT 之上建立 layer(s) 以符合下游任務需求?

事不宜遲,讓我們馬上以假新聞分類任務為例回答這些問題。我在之前的文章已經說明過,這個任務的輸入是兩個句子,輸出是 3 個類別機率的多類別分類任務(multi-class classification task),跟 NLP 領域裡常見的自然語言推論(Natural Language Inference)具有相同性質。

1. 準備原始文本數據¶

為了最大化再現性(reproducibility)以及幫助有興趣的讀者深入研究,我會列出所有的程式碼,你只要複製貼上就能完整重現文中所有結果並生成能提交到 Kaggle 競賽的預測檔案。你當然也可以選擇直接閱讀,不一定要下載數據。

因為 Kaggle 網站本身的限制,我無法直接提供數據載點。如果你想要跟著本文練習以 BERT fine tune 一個假新聞的分類模型,可以先前往該 Kaggle 競賽下載資料集。下載完數據你的資料夾裡應該會有兩個壓縮檔,分別代表訓練集和測試集:

import glob
glob.glob("*.csv.zip")

在这里插入图片描述

接著就是我實際處理訓練資料集的程式碼。再次申明,你只需稍微瀏覽註解並感受一下處理邏輯即可,no pressure。

因為競賽早就結束,我們不必花費時間衝高分數。比起衝高準確度,讓我們做點有趣的事情:從 32 萬筆訓練數據裡頭隨機抽樣 1 % 來讓 BERT 學怎麼分類假新聞。

我們可以看看 BERT 本身的語言理解能力對只有少量標註數據的任務有什麼幫助:

"""
前處理原始的訓練數據集。
你不需了解細節,只需要看註解了解邏輯或是輸出的數據格式即可
"""
import os
import pandas as pd

# 解壓縮從 Kaggle 競賽下載的訓練壓縮檔案
os.system("unzip train.csv.zip")

# 簡單的數據清理,去除空白標題的 examples
df_train = pd.read_csv("train.csv")
empty_title = ((df_train['title2_zh'].isnull()) \
               | (df_train['title1_zh'].isnull()) \
               | (df_train['title2_zh'] == '') \
               | (df_train['title2_zh'] == '0'))
df_train = df_train[~empty_title]

# 剔除過長的樣本以避免 BERT 無法將整個輸入序列放入記憶體不多的 GPU
MAX_LENGTH = 30
df_train = df_train[~(df_train.title1_zh.apply(lambda x : len(x)) > MAX_LENGTH)]
df_train = df_train[~(df_train.title2_zh.apply(lambda x : len(x)) > MAX_LENGTH)]

# 只用 1% 訓練數據看看 BERT 對少量標註數據有多少幫助
SAMPLE_FRAC = 0.01
df_train = df_train.sample(frac=SAMPLE_FRAC, random_state=9527)

# 去除不必要的欄位並重新命名兩標題的欄位名
df_train = df_train.reset_index()
df_train = df_train.loc[:, ['title1_zh', 'title2_zh', 'label']]
df_train.columns = ['text_a', 'text_b', 'label']

# idempotence, 將處理結果另存成 tsv 供 PyTorch 使用
df_train.to_csv("train.tsv", sep="\t", index=False)

print("訓練樣本數:", len(df_train))
df_train.head()
[转载]个人认为最好的BERT讲解博客(下)_第2张图片

事情變得更有趣了。因為我們在抽樣 1 % 的數據後還將過長的樣本去除,實際上會被拿來訓練的樣本數只有 2,657 筆,佔不到參賽時可以用的訓練數據的 1 %,是非常少量的數據。

我們也可以看到 unrelated 的樣本佔了 68 %,因此我們用 BERT 訓練出來的分類器最少最少要超過多數決的 68 % baseline 才行:

df_train.label.value_counts() / len(df_train)

[转载]个人认为最好的BERT讲解博客(下)_第3张图片

接著我也對最後要預測的測試集做些非常基本的前處理,方便之後提交符合競賽要求的格式。你也不需了解所有細節,只要知道我們最後要預測 8 萬筆樣本:

os.system("unzip test.csv.zip")
df_test = pd.read_csv("test.csv")
df_test = df_test.loc[:, ["title1_zh", "title2_zh", "id"]]
df_test.columns = ["text_a", "text_b", "Id"]
df_test.to_csv("test.tsv", sep="\t", index=False)

print("預測樣本數:", len(df_test))
df_test.head()
[转载]个人认为最好的BERT讲解博客(下)_第4张图片
ratio = len(df_test) / len(df_train)
print("測試集樣本數 / 訓練集樣本數 = {:.1f} 倍".format(ratio))

在这里插入图片描述

因為測試集的樣本數是我們迷你訓練集的 30 倍之多,後面你會看到反而是推論需要花費比較久的時間,模型本身一下就訓練完了。

2. 將原始文本轉換成 BERT 相容的輸入格式¶

處理完原始數據以後,最關鍵的就是了解如何讓 BERT 讀取這些數據以做訓練和推論。這時候我們需要了解 BERT 的輸入編碼格式。

這步驟是本文的精華所在,你將看到在其他只單純說明 BERT 概念的文章不會提及的所有實務細節。以下是原論文裡頭展示的成對句子編碼示意圖:

[转载]个人认为最好的BERT讲解博客(下)_第5张图片
加入 PyTorch 使用細節的 BERT 成對句子編碼示意圖

第二條分隔線之上的內容是論文裡展示的例子。圖中的每個 Token Embedding 都對應到前面提過的一個 wordpiece,而 Segment Embeddings 則代表不同句子的位置,是學出來的。Positional Embeddings 則跟其他 Transformer 架構中出現的位置編碼同出一轍。

實際運用 PyTorch 的 BERT 時最重要的則是在第二條分隔線之下的資訊。我們需要將原始文本轉換成 3 種 id tensors

  • tokens_tensor:代表識別每個 token 的索引值,用 tokenizer 轉換即可
  • segments_tensor:用來識別句子界限。第一句為 0,第二句則為 1。另外注意句子間的 [SEP] 為 0
  • masks_tensor:用來界定自注意力機制範圍。1 讓 BERT 關注該位置,0 則代表是 padding 不需關注

論文裡的例子並沒有說明 [PAD] token,但實務上每個 batch 裡頭的輸入序列長短不一,為了讓 GPU 平行運算我們需要將 batch 裡的每個輸入序列都補上 zero padding 以保證它們長度一致。另外 masks_tensor 以及 segments_tensor[PAD] 對應位置的值也都是 0,切記切記。

有了這些背景知識以後,要實作一個 Dataset 並將原始文本轉換成 BERT 相容的格式就變得十分容易了:

"""
實作一個可以用來讀取訓練 / 測試集的 Dataset,這是你需要徹底了解的部分。
此 Dataset 每次將 tsv 裡的一筆成對句子轉換成 BERT 相容的格式,並回傳 3 個 tensors:
- tokens_tensor:兩個句子合併後的索引序列,包含 [CLS] 與 [SEP]
- segments_tensor:可以用來識別兩個句子界限的 binary tensor
- label_tensor:將分類標籤轉換成類別索引的 tensor, 如果是測試集則回傳 None
"""
from torch.utils.data import Dataset
 
    
class FakeNewsDataset(Dataset):
    # 讀取前處理後的 tsv 檔並初始化一些參數
    def __init__(self, mode, tokenizer):
        assert mode in ["train", "test"]  # 一般訓練你會需要 dev set
        self.mode = mode
        # 大數據你會需要用 iterator=True
        self.df = pd.read_csv(mode + ".tsv", sep="\t").fillna("")
        self.len = len(self.df)
        self.label_map = {'agreed': 0, 'disagreed': 1, 'unrelated': 2}
        self.tokenizer = tokenizer  # 我們將使用 BERT tokenizer
    
    # 定義回傳一筆訓練 / 測試數據的函式
    def __getitem__(self, idx):
        if self.mode == "test":
            text_a, text_b = self.df.iloc[idx, :2].values
            label_tensor = None
        else:
            text_a, text_b, label = self.df.iloc[idx, :].values
            # 將 label 文字也轉換成索引方便轉換成 tensor
            label_id = self.label_map[label]
            label_tensor = torch.tensor(label_id)
            
        # 建立第一個句子的 BERT tokens 並加入分隔符號 [SEP]
        word_pieces = ["[CLS]"]
        tokens_a = self.tokenizer.tokenize(text_a)
        word_pieces += tokens_a + ["[SEP]"]
        len_a = len(word_pieces)
        
        # 第二個句子的 BERT tokens
        tokens_b = self.tokenizer.tokenize(text_b)
        word_pieces += tokens_b + ["[SEP]"]
        len_b = len(word_pieces) - len_a
        
        # 將整個 token 序列轉換成索引序列
        ids = self.tokenizer.convert_tokens_to_ids(word_pieces)
        tokens_tensor = torch.tensor(ids)
        
        # 將第一句包含 [SEP] 的 token 位置設為 0,其他為 1 表示第二句
        segments_tensor = torch.tensor([0] * len_a + [1] * len_b, 
                                        dtype=torch.long)
        
        return (tokens_tensor, segments_tensor, label_tensor)
    
    def __len__(self):
        return self.len
    
    
# 初始化一個專門讀取訓練樣本的 Dataset,使用中文 BERT 斷詞
trainset = FakeNewsDataset("train", tokenizer=tokenizer)

這段程式碼不難,我也很想硬掰些台詞撐撐場面,但該說的重點都寫成註解給你看了。如果你想要把自己手上的文本轉換成 BERT 看得懂的東西,那徹底理解這個 Dataset 的實作邏輯就非常重要了。

現在讓我們看看第一個訓練樣本轉換前後的格式差異:

# 選擇第一個樣本
sample_idx = 0

# 將原始文本拿出做比較
text_a, text_b, label = trainset.df.iloc[sample_idx].values

# 利用剛剛建立的 Dataset 取出轉換後的 id tensors
tokens_tensor, segments_tensor, label_tensor = trainset[sample_idx]

# 將 tokens_tensor 還原成文本
tokens = tokenizer.convert_ids_to_tokens(tokens_tensor.tolist())
combined_text = "".join(tokens)

# 渲染前後差異,毫無反應就是個 print。可以直接看輸出結果
print(f"""[原始文本]
句子 1:{text_a}
句子 2:{text_b}
分類  :{label}

--------------------

[Dataset 回傳的 tensors]
tokens_tensor  :{tokens_tensor}

segments_tensor:{segments_tensor}

label_tensor   :{label_tensor}

--------------------

[還原 tokens_tensors]
{combined_text}
""")
[原始文本]
句子 1:苏有朋要结婚了,但网友觉得他还是和林心如比较合适
句子 2:好闺蜜结婚给不婚族的秦岚扔花球,倒霉的秦岚掉水里笑哭苏有朋!
分類  unrelated

--------------------

[Dataset 回傳的 tensors]
tokens_tensor
tensor([ 101, 5722, 3300, 3301, 6206, 5310, 2042, 749, 8024, 852, 5381, 1351,
6230, 2533, 800, 6820, 3221, 1469, 3360, 2552, 1963, 3683, 6772, 1394,
6844, 102, 1962, 7318, 6057, 5310, 2042, 5314, 679, 2042, 3184, 4638,
4912, 2269, 2803, 5709, 4413, 8024, 948, 7450, 4638, 4912, 2269, 2957,
3717, 7027, 5010, 1526, 5722, 3300, 3301, 8013, 102])

segments_tensortensor([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1])

label_tensor 2

--------------------

[還原 tokens_tensors]
[CLS]苏有朋要结婚了,但网友觉得他还是和林心如比较合适[SEP]好闺蜜结婚给不婚族的秦岚扔花球,倒霉的秦岚掉水里笑哭苏有朋![SEP]

好啦,我很雞婆地幫你把處理前後的差異都列了出來,你現在應該了解我們定義的 trainset 回傳的 tensors 跟原始文本之間的關係了吧!如果你之後想要一行行解析上面我定義的這個 Dataset,強烈建議安裝在 Github 上已經得到超過 1 萬星的 PySnooper:

<div class="cell border-box-sizing text_cell rendered"><div class="inner_cell">
<div class="text_cell_render border-box-sizing rendered_html">
<p>好啦,我很雞婆地幫你把處理前後的差異都列了出來,你現在應該了解我們定義的 <code>trainset</code> 回傳的 tensors 跟原始文本之間的關係了吧!如果你之後想要一行行解析上面我定義的這個 <code>Dataset</code>,強烈建議安裝在 Github 上已經得到超過 1 萬星的 <a href="https://github.com/cool-RR/PySnooper">PySnooper</a></p>
</div>
</div>
</div>

加上 @pysnooper.snoop()、重新定義 FakeNewsDataset、初始化一個新的 trainset 並將第一個樣本取出即可看到這樣的 logging 訊息:

[转载]个人认为最好的BERT讲解博客(下)_第6张图片
使用 PySnooper 讓你輕鬆了解怎麼將原始文本變得「 BERT 相容」

有了 Dataset 以後,我們還需要一個 DataLoader 來回傳成一個個的 mini-batch。畢竟我們不可能一次把整個數據集塞入 GPU,對吧?

痾 ... 你剛剛應該沒有打算這麼做吧?

除了上面的 FakeNewsDataset 實作以外,以下的程式碼是你在想將 BERT 應用到自己的 NLP 任務時會需要徹底搞懂的部分:

"""
實作可以一次回傳一個 mini-batch 的 DataLoader
這個 DataLoader 吃我們上面定義的 `FakeNewsDataset`,
回傳訓練 BERT 時會需要的 4 個 tensors:
- tokens_tensors  : (batch_size, max_seq_len_in_batch)
- segments_tensors: (batch_size, max_seq_len_in_batch)
- masks_tensors   : (batch_size, max_seq_len_in_batch)
- label_ids       : (batch_size)
"""

from torch.utils.data import DataLoader
from torch.nn.utils.rnn import pad_sequence

# 這個函式的輸入 `samples` 是一個 list,裡頭的每個 element 都是
# 剛剛定義的 `FakeNewsDataset` 回傳的一個樣本,每個樣本都包含 3 tensors:
# - tokens_tensor
# - segments_tensor
# - label_tensor
# 它會對前兩個 tensors 作 zero padding,並產生前面說明過的 masks_tensors
def create_mini_batch(samples):
    tokens_tensors = [s[0] for s in samples]
    segments_tensors = [s[1] for s in samples]
    
    # 測試集有 labels
    if samples[0][2] is not None:
        label_ids = torch.stack([s[2] for s in samples])
    else:
        label_ids = None
    
    # zero pad 到同一序列長度
    tokens_tensors = pad_sequence(tokens_tensors, 
                                  batch_first=True)
    segments_tensors = pad_sequence(segments_tensors, 
                                    batch_first=True)
    
    # attention masks,將 tokens_tensors 裡頭不為 zero padding
    # 的位置設為 1 讓 BERT 只關注這些位置的 tokens
    masks_tensors = torch.zeros(tokens_tensors.shape, 
                                dtype=torch.long)
    masks_tensors = masks_tensors.masked_fill(
        tokens_tensors != 0, 1)
    
    return tokens_tensors, segments_tensors, masks_tensors, label_ids


# 初始化一個每次回傳 64 個訓練樣本的 DataLoader
# 利用 `collate_fn` 將 list of samples 合併成一個 mini-batch 是關鍵
BATCH_SIZE = 64
trainloader = DataLoader(trainset, batch_size=BATCH_SIZE, 
                         collate_fn=create_mini_batch)

加上註解,我相信這應該是你在整個網路上能看到最平易近人的實作了。這段程式碼是你要實際將 mini-batch 丟入 BERT 做訓練以及預測的關鍵,務必搞清楚每一行在做些什麼。

有了可以回傳 mini-batch 的 DataLoader 後,讓我們馬上拿出一個 batch 看看:

data = next(iter(trainloader))

tokens_tensors, segments_tensors, \
    masks_tensors, label_ids = data

print(f"""
tokens_tensors.shape   = {tokens_tensors.shape} 
{tokens_tensors}
------------------------
segments_tensors.shape = {segments_tensors.shape}
{segments_tensors}
------------------------
masks_tensors.shape    = {masks_tensors.shape}
{masks_tensors}
------------------------
label_ids.shape        = {label_ids.shape}
{label_ids}
""")

[转载]个人认为最好的BERT讲解博客(下)_第7张图片

建立 BERT 用的 mini-batch 時最需要注意的就是 zero padding 的存在了。你可以發現除了 lable_ids 以外,其他 3 個 tensors 的每個樣本的最後大都為 0,這是因為每個樣本的 tokens 序列基本上長度都會不同,需要補 padding。

[转载]个人认为最好的BERT讲解博客(下)_第8张图片

到此為止我們已經成功地將原始文本轉換成 BERT 相容的輸入格式了。這節是本篇文章最重要,也最需要花點時間咀嚼的內容。在有這些 tensors 的前提下,要在 BERT 之上訓練我們自己的下游任務完全是一塊蛋糕。

3. 在 BERT 之上加入新 layer 成下游任務模型¶

我從李宏毅教授講解 BERT 的投影片中擷取出原論文提到的 4 種 fine-tuning BERT 情境,並整合了一些有用資訊:

[转载]个人认为最好的BERT讲解博客(下)_第9张图片
在 4 種 NLP 任務上 fine-tuning BERT 的例子 ( 圖片來源)
                    

資訊量不少,但我假設你在前面教授的 BERT 影片或是其他地方已經看過類似的圖。

首先,我們前面一直提到的 fine-tuning BERT 指的是在預訓練完的 BERT 之上加入新的線性分類器(Linear Classifier),並利用下游任務的目標函式從頭訓練分類器並微調 BERT 的參數。這樣做的目的是讓整個模型(BERT + Linear Classifier)能一起最大化當前下游任務的目標。

圖中紅色小字則是該任務類型常被拿來比較的資料集,比方說 MNLI 及 SQuAD v1.1。

不過現在對我們來說最重要的是圖中的藍色字體。多虧了 HuggingFace 團隊,要用 PyTorch fine-tuing BERT 是件非常容易的事情。每個藍色字體都對應到一個可以處理下游任務的模型,而這邊說的模型指的是已訓練的 BERT + Linear Classifier

按圖索驥,因為假新聞分類是一個成對句子分類任務,自然就對應到上圖的左下角。FINETUNE_TASK 則為 bertForSequenceClassification:

# 載入一個可以做中文多分類任務的模型,n_class = 3
from transformers import BertForSequenceClassification

PRETRAINED_MODEL_NAME = "bert-base-chinese"
NUM_LABELS = 3

model = BertForSequenceClassification.from_pretrained(
    PRETRAINED_MODEL_NAME, num_labels=NUM_LABELS)

clear_output()

# high-level 顯示此模型裡的 modules
print("""
name            module
----------------------""")
for name, module in model.named_children():
    if name == "bert":
        for n, _ in module.named_children():
            print(f"{name}:{n}")
    else:
        print("{:15} {}".format(name, module))

[转载]个人认为最好的BERT讲解博客(下)_第10张图片

沒錯,一行程式碼就初始化了一個可以用 BERT 做文本多分類的模型 model。我也列出了 model 裡頭最 high level 的模組,資料流則從上到下,通過:

  • BERT 處理各種 embeddings 的模組
  • 在神經機器翻譯就已經看過的 Transformer Encoder
  • 一個 pool [CLS] token 在所有層的 repr. 的 BertPooler
  • Dropout 層
  • 回傳 3 個類別 logits 的線性分類器 classifier

classifer 就只是將從 BERT 那邊拿到的 [CLS] token 的 repr. 做一個線性轉換而已,非常簡單。我也將我們實際使用的分類模型 BertForSequenceClassification 實作簡化一下供你參考:

class BertForSequenceClassification(BertPreTrainedModel):
    def __init__(self, config, num_labels=2, ...):
        super(BertForSequenceClassification, self).__init__(config)
        self.num_labels = num_labels
        self.bert = BertModel(config, ...)  # 載入預訓練 BERT
        self.dropout = nn.Dropout(config.hidden_dropout_prob)
        # 簡單 linear 層
        self.classifier = nn.Linear(config.hidden_size, num_labels)
          ...

    def forward(self, input_ids, token_type_ids=None, attention_mask=None, labels=None, ...):
        # BERT 輸入就是 tokens, segments, masks
        outputs = self.bert(input_ids, token_type_ids, attention_mask, ...)
        ...
        pooled_output = self.dropout(pooled_output)
        # 線性分類器將 dropout 後的 BERT repr. 轉成類別 logits
        logits = self.classifier(pooled_output)

        # 輸入有 labels 的話直接計算 Cross Entropy 回傳,方便!
        if labels is not None:
            loss_fct = CrossEntropyLoss()
            loss = loss_fct(logits.view(-1, self.num_labels), labels.view(-1))
            return loss
        # 有要求回傳注意矩陣的話回傳
        elif self.output_attentions:
            return all_attentions, logits
        # 回傳各類別的 logits
        return logits

這樣應該清楚多了吧!我們的分類模型 model 也就只是在 BERT 之上加入 dropout 以及簡單的 linear classifier,最後輸出用來預測類別的 logits。 這就是兩階段遷移學習強大的地方:你不用再自己依照不同 NLP 任務從零設計非常複雜的模型,只需要站在巨人肩膀上,然後再做一點點事情就好了。

你也可以看到整個分類模型 model 預設的隱狀態維度為 768。如果你想要更改 BERT 的超參數,可以透過給一個 config dict 來設定。以下則是分類模型 model 預設的參數設定:

![在这里插入图片描述](https://img-blog.csdnimg.cn/20191213091914733.png) ![在这里插入图片描述](https://img-blog.csdnimg.cn/20191213091934773.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlbGU3MTc3,size_16,color_FFFFFF,t_70)

Dropout、LayerNorm、全連接層數以及 mutli-head attentions 的 num_attention_heads 等超參數我們也都已經在之前的 Transformer 文章看過了,這邊就不再贅述。

目前 PyTorch Hub 上有 8 種模型以及一個 tokenizer 可供使用,依照用途可以分為:

  • 基本款:
    • bertModel
    • bertTokenizer
  • 預訓練階段
    • bertForMaskedLM
    • bertForNextSentencePrediction
    • bertForPreTraining
  • Fine-tuning 階段
    • bertForSequenceClassification
    • bertForTokenClassification
    • bertForQuestionAnswering
    • bertForMultipleChoice

粗體是本文用到的模型。如果你想要完全 DIY 自己的模型,可以載入純 bertModel 並參考上面看到的 BertForSequenceClassification 的實作。當然建議盡量不要重造輪子。如果只是想要了解其背後實作邏輯,可以參考 pytorch-transformers。

有了 model 以及我們在前一節建立的 trainloader,讓我們寫一個簡單函式測試現在 model 在訓練集上的分類準確率:

"""
定義一個可以針對特定 DataLoader 取得模型預測結果以及分類準確度的函式
之後也可以用來生成上傳到 Kaggle 競賽的預測結果

2019/11/22 更新:在將 `tokens`、`segments_tensors` 等 tensors
丟入模型時,強力建議指定每個 tensor 對應的參數名稱,以避免 HuggingFace
更新 repo 程式碼並改變參數順序時影響到我們的結果。
"""

def get_predictions(model, dataloader, compute_acc=False):
    predictions = None
    correct = 0
    total = 0
      
    with torch.no_grad():
        # 遍巡整個資料集
        for data in dataloader:
            # 將所有 tensors 移到 GPU 上
            if next(model.parameters()).is_cuda:
                data = [t.to("cuda:0") for t in data if t is not None]
            
            
            # 別忘記前 3 個 tensors 分別為 tokens, segments 以及 masks
            # 且強烈建議在將這些 tensors 丟入 `model` 時指定對應的參數名稱
            tokens_tensors, segments_tensors, masks_tensors = data[:3]
            outputs = model(input_ids=tokens_tensors, 
                            token_type_ids=segments_tensors, 
                            attention_mask=masks_tensors)
            
            logits = outputs[0]
            _, pred = torch.max(logits.data, 1)
            
            # 用來計算訓練集的分類準確率
            if compute_acc:
                labels = data[3]
                total += labels.size(0)
                correct += (pred == labels).sum().item()
                
            # 將當前 batch 記錄下來
            if predictions is None:
                predictions = pred
            else:
                predictions = torch.cat((predictions, pred))
    
    if compute_acc:
        acc = correct / total
        return predictions, acc
    return predictions
    
# 讓模型跑在 GPU 上並取得訓練集的分類準確率
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
print("device:", device)
model = model.to(device)
_, acc = get_predictions(model, trainloader, compute_acc=True)
print("classification acc:", acc)

在这里插入图片描述

毫不意外,模型裡新加的線性分類器才剛剛被初始化,整個分類模型的表現低於 68 % 的 baseline 是非常正常的。因為模型是隨機初始化的,你的執行結果可能跟我有點差距,但應該不會超過 68 %。

另外我們也可以算算整個分類模型以及裡頭的簡單分類器有多少參數:

<div class="cell border-box-sizing text_cell rendered"><div class="inner_cell">
<div class="text_cell_render border-box-sizing rendered_html">
<p>毫不意外,模型裡新加的線性分類器才剛剛被初始化,整個分類模型的表現低於 68 %  的 baseline 是非常正常的。因為模型是隨機初始化的,你的執行結果可能跟我有點差距,但應該不會超過 68 %</p>
<p>另外我們也可以算算整個分類模型以及裡頭的簡單分類器有多少參數:</p>
</div>
</div>
</div>

在这里插入图片描述

新增的 classifier 的參數量在 BERT 面前可說是滄海一粟。而因為分類模型大多數的參數都是從已訓練的 BERT 來的,實際上我們需要從頭訓練的參數量非常之少,這也是遷移學習的好處。

當然,一次 forward 所需的時間也不少就是了。

4. 訓練該下游任務模型¶

接下來沒有什麼新玩意了,除了需要記得我們前面定義的 batch 數據格式以外,訓練分類模型 model 就跟一般你使用 PyTorch 訓練模型做的事情相同。

為了避免失焦,訓練程式碼我只保留核心部分:

%%time

# 訓練模式
model.train()

# 使用 Adam Optim 更新整個分類模型的參數
optimizer = torch.optim.Adam(model.parameters(), lr=1e-5)


EPOCHS = 6  # 幸運數字
for epoch in range(EPOCHS):
    
    running_loss = 0.0
    for data in trainloader:
        
        tokens_tensors, segments_tensors, \
        masks_tensors, labels = [t.to(device) for t in data]

        # 將參數梯度歸零
        optimizer.zero_grad()
        
        # forward pass
        outputs = model(input_ids=tokens_tensors, 
                        token_type_ids=segments_tensors, 
                        attention_mask=masks_tensors, 
                        labels=labels)

        loss = outputs[0]
        # backward
        loss.backward()
        optimizer.step()


        # 紀錄當前 batch loss
        running_loss += loss.item()
        
    # 計算分類準確率
    _, acc = get_predictions(model, trainloader, compute_acc=True)

    print('[epoch %d] loss: %.3f, acc: %.3f' %
          (epoch + 1, running_loss, acc))

[转载]个人认为最好的BERT讲解博客(下)_第11张图片

哇嗚!我們成功地 Fine-tune BERT 了!

儘管擁有 1 億參數的分類模型十分巨大,多虧了小訓練集的助攻(?),幾個 epochs 的訓練過程大概在幾分鐘內就結束了。從準確率看得出我們的分類模型在非常小量的訓練集的表現已經十分不錯,接著讓我們看看這個模型在真實世界,也就是 Kaggle 競賽上的測試集能得到怎麼樣的成績。

5. 對新樣本做推論¶

5. 對新樣本做推論¶

%%time
# 建立測試集。這邊我們可以用跟訓練時不同的 batch_size,看你 GPU 多大
testset = FakeNewsDataset("test", tokenizer=tokenizer)
testloader = DataLoader(testset, batch_size=256, 
                        collate_fn=create_mini_batch)

# 用分類模型預測測試集
predictions = get_predictions(model, testloader)

# 用來將預測的 label id 轉回 label 文字
index_map = {v: k for k, v in testset.label_map.items()}

# 生成 Kaggle 繳交檔案
df = pd.DataFrame({"Category": predictions.tolist()})
df['Category'] = df.Category.apply(lambda x: index_map[x])
df_pred = pd.concat([testset.df.loc[:, ["Id"]], 
                          df.loc[:, 'Category']], axis=1)
df_pred.to_csv('bert_1_prec_training_samples.csv', index=False)
df_pred.head()

在这里插入图片描述
[转载]个人认为最好的BERT讲解博客(下)_第12张图片

我們前面就說過測試集是訓練集的 30 倍,因此光是做推論就得花不少時間。廢話不多說,讓我將生成的預測結果上傳到 Kaggle 網站,看看會得到怎麼樣的結果:

[转载]个人认为最好的BERT讲解博客(下)_第13张图片
在不到 1 % 的數據 Fine-tuing BERT 可以達到 80 % 測試準確率

測試集是訓練集的 30 倍大,overfitting 完全是可預期的。不過跟我們一開始多數決的 68 % baseline 相比,以 BERT fine tune 的分類模型在測試集達到 80 %,整整上升了 12 %。雖然這篇文章的重點一直都不在最大化這個假新聞分類任務的準確率,還是別忘了我們只用了不到原來競賽 1 % 的數據以及不到 5 分鐘的時間就達到這樣的結果。

讓我們忘了準確率,看看 BERT 本身在 fine tuning 之前與之後的差異。以下程式碼列出模型成功預測 disagreed 類別的一些例子:

predictions = get_predictions(model, trainloader)
df = pd.DataFrame({"predicted": predictions.tolist()})
df['predicted'] = df.predicted.apply(lambda x: index_map[x])
df1 = pd.concat([trainset.df, df.loc[:, 'predicted']], axis=1)
disagreed_tp = ((df1.label == 'disagreed') & \
                (df1.label == df1.predicted) & \
                (df1.text_a.apply(lambda x: True if len(x) < 10 else False)))
df1[disagreed_tp].head()
[转载]个人认为最好的BERT讲解博客(下)_第14张图片

其實用肉眼看看這些例子,以你對自然語言的理解應該能猜出要能正確判斷 text_b 是反對 text_a,首先要先關注「謠」、「假」等代表反對意義的詞彙,接著再看看兩個句子間有沒有含義相反的詞彙。

讓我們從中隨意選取一個例子,看看 fine tuned 後的 BERT 能不能關注到該關注的位置。再次出動 BertViz 來視覺化 BERT 的注意權重:

# 觀察訓練過後的 model 在處理假新聞分類任務時關注的位置
# 去掉 `state_dict` 即可觀看原始 BERT 結果
bert_version = 'bert-base-chinese'
bertviz_model = BertModel.from_pretrained(bert_version, 
                                          state_dict=model.bert.state_dict())

sentence_a = "烟王褚时健去世"
sentence_b = "辟谣:一代烟王褚时健安好!"

call_html()
show(bertviz_model, model_type, bertviz_tokenizer, sentence_a, sentence_b)
# 這段程式碼會顯示下圖中右邊的結果
[转载]个人认为最好的BERT讲解博客(下)_第15张图片

我們說過在 BERT 裡頭,第一個 [CLS] 的 repr. 代表著整個輸入序列的 repr.。

左邊是一般預訓練完的 BERT。如果你還記得 BERT 的其中一個預訓練任務 NSP 的話,就會了解這時的 [CLS] 所包含的資訊大多是要用來預測第二句本來是否接在第一句後面。以第 8 層 Encoder block 而言,你會發現大多數的 heads 在更新 [CLS] 時只關注兩句間的 [SEP]

有趣的是在看過一些假新聞分類數據以後(右圖),這層的一些 heads 在更新 [CLS] 的 repr. 時會開始關注跟下游任務目標相關的特定詞彙:

  • 闢謠
  • 去世
  • 安好

在 fine tune 一陣子之後, 這層 Encoder block 學會關注兩句之間「衝突」的位置,並將這些資訊更新到 [CLS] 裡頭。有了這些資訊,之後的 Linear Classifier 可以將其轉換成更好的分類預測。考慮到我們只給 BERT 看不到 1 % 的數據,這樣的結果不差。如果有時間 fine tune 整個訓練集,我們能得到更好的成果。

好啦,到此為止你應該已經能直觀地理解 BERT 並開始 fine tuning 自己的下游任務了。如果你要做的是如 SQuAD 問答等常見的任務,甚至可以用 pytorch-transformers 準備好的 Python 腳本一鍵完成訓練與推論:

# 腳本模式的好處是可以透過改變參數快速進行各種實驗。
# 壞處是黑盒子效應,不過對閱讀完本文的你應該不是個問題。
# 選擇適合自己的方式 fine-tuning BERT 吧!
export SQUAD_DIR=/path/to/SQUAD

python run_squad.py \
  --bert_model bert-base-uncased \
  --do_train \
  --do_predict \
  --do_lower_case \
  --train_file $SQUAD_DIR/train-v1.1.json \
  --predict_file $SQUAD_DIR/dev-v1.1.json \
  --train_batch_size 12 \
  --learning_rate 3e-5 \
  --num_train_epochs 2.0 \
  --max_seq_length 384 \
  --doc_stride 128 \
  --output_dir /tmp/debug_squad/

用腳本的好處是你不需要知道所有實作細節,只要調整自己感興趣的參數就好。我在用 CartoonGAN 與 TensorFlow 2 生成新海誠動畫一文也採用同樣方式,提供讀者一鍵生成卡通圖片的 Python 腳本。

當然,你也可以先試著一步步執行本文列出的程式碼,複習並鞏固學到的東西。最後,讓我們做點簡單的總結。

結語¶

一路過來,你現在應該已經能夠:

  • 直觀理解 BERT 內部自注意力機制的物理意義
  • 向其他人清楚解釋何謂 BERT 以及其運作的原理
  • 了解 contextual word repr. 及兩階段遷移學習
  • 將文本數據轉換成 BERT 相容的輸入格式
  • 依據下游任務 fine tuning BERT 並進行推論

恭喜!你現在已經具備能夠進一步探索最新 NLP 研究與應用的能力了。

[转载]个人认为最好的BERT讲解博客(下)_第16张图片
UniLM 用 3 種語言模型作為預訓練目標,可以 fine tune 自然語言生成任務,是值得期待的研究 ( 圖片來源)

我還有不少東西想跟你分享,但因為時間有限,在這邊就簡單地條列出來:

  • BERT 的 Encoder 架構很適合做自然語言理解 NLU 任務,但如文章摘要等自然語言生成 NLG 的任務就不太 okay。BertSum 則是一篇利用 BERT 做萃取式摘要並在 CNN/Dailymail 取得 SOTA 的研究,適合想要在 BERT 之上開發自己模型的人參考作法
  • UniLM 透過「玩弄」注意力遮罩使得其可以在預訓練階段同時訓練 3 種語言模型,讓 fine tune NLG 任務不再是夢想。如果你了解之前 Transformer 文章裡說明的遮罩概念,幾秒鐘就能直觀理解上面的 UniLM 架構
  • 最近新的 NLP 王者非 XLNet 莫屬。其表現打敗 BERT 自然不需多言,但訓練該模型所需的花費令人不禁思考這樣的大公司遊戲是否就是我們要的未來
  • 有些人認為 BERT 不夠通用,因為 Fine-tuning 時還要依照不同下游任務加入新的 Linear Classifier。有些人提倡使用 Multitask Learning 想辦法弄出更通用的模型,而 decaNLP 是一個知名例子。
  • PyTorch 的 BERT 雖然使用上十分直覺,如果沒有強大的 GPU 還是很難在實務上使用。你可以嘗試特徵擷取或是 freeze BERT。另外如果你是以個人身份進行研究,但又希望能最小化成本並加快模型訓練效率,我會推薦花點時間學會在 Colab 上使用 TensorFlow Hub 及 TPU 訓練模型

其他的碎念留待下次吧。

當時在撰寫進入 NLP 世界的最佳橋樑一文時我希望能用點微薄之力搭起一座小橋,幫助更多人平順地進入 NLP 世界。作為該篇文章的延伸,這次我希望已經在 NLP 世界闖蕩的你能夠進一步掌握突破城牆的巨人之力,前往更遠的地方。

我還有不少東西想跟你分享,但因為時間有限,在這邊就簡單地條列出來:

  • BERT 的 Encoder 架構很適合做自然語言理解 NLU 任務,但如文章摘要等自然語言生成 NLG 的任務就不太 okay。BertSum 則是一篇利用 BERT 做萃取式摘要並在 CNN/Dailymail 取得 SOTA 的研究,適合想要在 BERT 之上開發自己模型的人參考作法
  • UniLM 透過「玩弄」注意力遮罩使得其可以在預訓練階段同時訓練 3 種語言模型,讓 fine tune NLG 任務不再是夢想。如果你了解之前 Transformer 文章裡說明的遮罩概念,幾秒鐘就能直觀理解上面的 UniLM 架構
  • 最近新的 NLP 王者非 XLNet 莫屬。其表現打敗 BERT 自然不需多言,但訓練該模型所需的花費令人不禁思考這樣的大公司遊戲是否就是我們要的未來
  • 有些人認為 BERT 不夠通用,因為 Fine-tuning 時還要依照不同下游任務加入新的 Linear Classifier。有些人提倡使用 Multitask Learning 想辦法弄出更通用的模型,而 decaNLP 是一個知名例子。
  • PyTorch 的 BERT 雖然使用上十分直覺,如果沒有強大的 GPU 還是很難在實務上使用。你可以嘗試特徵擷取或是 freeze BERT。另外如果你是以個人身份進行研究,但又希望能最小化成本並加快模型訓練效率,我會推薦花點時間學會在 Colab 上使用 TensorFlow Hub 及 TPU 訓練模型

其他的碎念留待下次吧。

當時在撰寫進入 NLP 世界的最佳橋樑一文時我希望能用點微薄之力搭起一座小橋,幫助更多人平順地進入 NLP 世界。作為該篇文章的延伸,這次我希望已經在 NLP 世界闖蕩的你能夠進一步掌握突破城牆的巨人之力,前往更遠的地方。

啊,我想這篇文章就是讓你變成智慧巨人的脊髓液了!我們牆外見。


至此,全文转载完毕,十分感谢LeeMeng大佬的这篇博客,让我受益匪浅。
这是他个人博客的链接:LeeMeng
如果大家对此文有什么想讨论的,也可以通过邮箱与我讨论:[email protected]

你可能感兴趣的:(NLP)