保姆級教程,用 PyTorch 和 BERT 進行文本分類

文本中,小猴子和大家一起學習,如何利用 Hugging Face 的預訓練 BERT 模型對新聞文章的文本(BBC 新聞分類數據集)進行分類。

早在 2018 年,谷歌就爲 NLP 應用程序開發了一個基於 Transformer 的強大的機器學習模型,該模型在不同的基準數據集中優於以前的語言模型。這個模型被稱爲 BERT。

在這篇文章中,我們將使用來自 Hugging Face 的預訓練 BERT 模型進行文本分類任務。一般而言,文本分類任務中模型的主要目標是將文本分類爲預定義的標籤或標籤之一。

本文中,我們使用 BBC 新聞分類數據集,使用預訓練的 BERT 模型來分類新聞文章的文本是否可以分類爲_體育_、_政治_、_商業_、_娛樂_或_科技_類別。

什麼是 BERT

BERT 是 Bidirectional Encoder Representations from Transformers 的首字母縮寫詞。

BERT 架構由多個堆疊在一起的 Transformer 編碼器組成。每個 Transformer 編碼器都封裝了兩個子層:一個自注意力層和一個前饋層。

有兩種不同的 BERT 模型:

  1. BERT base 模型,由 12 層 Transformer 編碼器、12 個注意力頭、768 個隱藏大小和 110M 參數組成。

  2. BERT large 模型,由 24 層 Transformer 編碼器、16 個注意力頭、1024 個隱藏大小和 340M 個參數組成。

BERT 是一個強大的語言模型至少有兩個原因:

  1. 它使用從 BooksCorpus (有 8 億字)和 Wikipedia(有 25 億字)中提取的未標記數據進行預訓練。

  2. 顧名思義,它是通過利用編碼器堆棧的雙向特性進行預訓練的。這意味着 BERT 不僅從左到右,而且從右到左從單詞序列中學習信息。

論文

源碼

BERT 輸入

BERT 模型需要一系列 tokens (words) 作爲輸入。在每個 token 序列中,BERT 期望輸入有兩個特殊標記:

爲什麼選它們([CLS]/[SEP])呢,因爲與文本中已有的其它詞相比,這個無明顯語義信息的符號會更 “公平” 地融合文本中各個詞的語義信息,從而更好的表示整句話的語義。

具體來說,self-attention 是用文本中的其它詞來增強目標詞的語義表示,但是目標詞本身的語義還是會佔主要部分的,因此,經過 BERT 的 12 層,每次詞的 Embedding 融合了所有詞的信息,可以去更好的表示自己的語義。

[CLS]位本身沒有語義,經過 12 層,得到的是 attention 後所有詞的加權平均,相比其他正常詞,可以更好的表徵句子語義。

就像 Transformer 的普通編碼器一樣,BERT 將一系列單詞作爲輸入,這些單詞不斷向上流動。每一層都應用自我注意,並將其結果通過前饋網絡傳遞,然後將其傳遞給下一個編碼器。

舉個簡單的例子以更清楚說明,假設我們有一個包含以下短句的文本:

第一步,需要將這個句子轉換爲一系列 tokens (words) ,這個過程稱爲tokenization

雖然已經對輸入句子進行了標記,但還需要再做一步。在將其用作 BERT 模型的輸入之前,我們需要通過添加 [CLS][SEP] 標記來對 tokens 的 sequence 重新編碼。

其實我們只需要一行代碼(即使用BertTokenizer)就可以將輸入句子轉換爲 BERT 所期望的 tokens 序列。

還需要注意的是,可以輸入 BERT 模型的最大 tokens 大小爲 512。如果 sequence 中的 tokens 小於 512,我們可以使用填充來用 [PAD] 填充未使用的 tokens。如果 sequence 中的 tokens 長於 512,那麼需要進行截斷。

BERT 輸出

每個位置輸出一個大小爲 hidden_ size的向量(BERT Base 中爲 768)。對於我們在上面看到的句子分類示例,我們只關注第一個位置的輸出(將特殊的 [CLS] token 傳遞到該位置)。

該向量現在可以用作我們選擇的分類器的輸入。該論文僅使用單層神經網絡作爲分類器就取得了很好的效果。

如果有更多標籤,只需調整分類器網絡以獲得更多輸出神經元然後通過 softmax 輸出多標籤分類。

使用 BERT 進行文本分類

本文的主題是用 BERT 對文本進行分類。在這篇文章中,我們將使用 kaggle 上的BBC 新聞分類數據集

數據集已經是 CSV 格式,它有 2126 個不同的文本,每個文本都標記在 5 個類別中的一個下:sport(體育),business(商業),politics(政治),tech(科技),entertainment(娛樂)

看一下數據集的樣子:

如上表所示,數據框只有兩列,category 將作爲標籤,text 將作爲 BERT 的輸入數據。

預模型下載和使用

BERT 預訓練模型的下載有許多方式,比如從 github 官網上下載(官網下載的是 tensorflow 版本的),還可以從源碼中找到下載鏈接,然後手動下載,最後還可以從 huggingface 中下載。
huggingface下載預訓練模型的地址:https://huggingface.co/models

在搜索框搜索到你需要的模型。

來到下載頁面:

注意,這裏常用的幾個預訓練模型,bert-base-cased、bert-base-uncased 及中文 bert-base-chinese。其中前兩個容易混淆。bert-base-cased 是區分大小寫,不需要事先 lower-case;而 bert-base-uncased 不能區分大小寫,因爲詞表只有小寫,需要事先 lower-case。

基本使用示例:

from transformers import BertModel,BertTokenizer
BERT_PATH = './bert-base-cased'
tokenizer = BertTokenizer.from_pretrained(BERT_PATH)
print(tokenizer.tokenize('I have a good time, thank you.'))
bert = BertModel.from_pretrained(BERT_PATH)
print('load bert model over')
['I', 'have', 'a', 'good', 'time',
',', 'thank', 'you', '.'] 
load bert model over

預處理數據

現在我們基本熟悉了 BERT 的基本使用,接下來爲其準備輸入數據。一般情況下,在訓練模型前,都需要對手上的數據進行預處理,以滿足模型需要。

前面已經介紹過了,模型輸入數據中,需要通過添加 [CLS][SEP] 這兩個特殊的 token,將文本轉換爲 BERT 所期望的格式。

首先,需要通過 pip 安裝 Transformers 庫:

%%capture
!pip install transformers

爲了更容易理解得到的輸出tokenization,我們以一個簡短的文本爲例。

from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-cased')
example_text = 'I will watch Memento tonight'
bert_input = tokenizer(example_text,padding='max_length', 
                       max_length = 10, 
                       truncation=True,
                       return_tensors="pt")
# ------- bert_input ------
print(bert_input['input_ids'])
print(bert_input['token_type_ids'])
print(bert_input['attention_mask'])
tensor([[  101,   146,  1209,  2824,  2508,
         26173,  3568,   102,     0,     0]])
tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]])
tensor([[1, 1, 1, 1, 1, 1, 1, 1, 0, 0]])

下面是對上面BertTokenizer參數的解釋:

從上面的變量中看到的輸出bert_input,是用於稍後的 BERT 模型。但是這些輸出是什麼意思?

  1. 第一行是 input_ids,它是每個 token 的 id 表示。實際上可以將這些輸入 id 解碼爲實際的 token,如下所示:
example_text = tokenizer.decode(bert_input.input_ids[0])
print(example_text)
'[CLS] I will watch Memento tonight
 [SEP] [PAD] [PAD]'

由上述結果所示,BertTokenizer負責輸入文本的所有必要轉換,爲 BERT 模型的輸入做好準備。它會自動添加 [CLS][SEP][PAD] token。由於我們指定最大長度爲 10,所以最後只有兩個 [PAD] token。

  1. 第二行是 token_type_ids,它是一個 binary mask,用於標識 token 屬於哪個 sequence。如果我們只有一個 sequence,那麼所有的 token 類型 id 都將爲 0。對於文本分類任務,token_type_ids是 BERT 模型的可選輸入參數。

  2. 第三行是 attention_mask,它是一個 binary mask,用於標識 token 是真實 word 還是隻是由填充得到。如果 token 包含 [CLS][SEP] 或任何真實單詞,則 mask 將爲 1。如果 token 只是 [PAD] 填充,則 mask 將爲 0。

注意到,我們使用了一個預訓練BertTokenizerbert-base-cased模型。如果數據集中的文本是英文的,這個預訓練的分詞器就可以很好地工作。

如果有來自不同語言的數據集,可能需要使用bert-base-multilingual-cased。具體來說,如果你的數據集是德語、荷蘭語、中文、日語或芬蘭語,則可能需要使用專門針對這些語言進行預訓練的分詞器。可以在此處查看相應的預訓練標記器的名稱 [1]。特別地,如果數據集中的文本是中文的,需要使用bert-base-chinese 模型,以及其相應的BertTokenizer等。

數據集類

現在我們知道從BertTokenizer中獲得什麼樣的輸出,接下來爲新聞數據集構建一個Dataset類,該類將作爲一個類來將新聞數據轉換成模型需要的數據格式。

上下滑動查看更多源碼
import torch
import numpy as np
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-cased')
labels = {'business':0,
          'entertainment':1,
          'sport':2,
          'tech':3,
          'politics':4
          }

class Dataset(torch.utils.data.Dataset):
    def __init__(self, df):
        self.labels = [labels[label] for label in df['category']]
        self.texts = [tokenizer(text, 
                                padding='max_length', 
                                max_length = 512, 
                                truncation=True,
                                return_tensors="pt") 
                      for text in df['text']]

    def classes(self):
        return self.labels

    def __len__(self):
        return len(self.labels)

    def get_batch_labels(self, idx):
        # Fetch a batch of labels
        return np.array(self.labels[idx])

    def get_batch_texts(self, idx):
        # Fetch a batch of inputs
        return self.texts[idx]

    def __getitem__(self, idx):
        batch_texts = self.get_batch_texts(idx)
        batch_y = self.get_batch_labels(idx)
        return batch_texts, batch_y

在上面實現的代碼中,我們定義了一個名爲 labels的變量,它是一個字典,將 DataFrame 中的 category 映射到 labels的 id 表示。注意,上面的__init__函數中,還調用了BertTokenizer將輸入文本轉換爲 BERT 期望的向量格式。

定義 Dataset 類後,將數據框拆分爲訓練集、驗證集和測試集,比例爲 80:10:10

np.random.seed(112)
df_train, df_val, df_test = np.split(df.sample(frac=1, random_state=42), 
                                     [int(.8*len(df)), int(.9*len(df))])

print(len(df_train),len(df_val), len(df_test))
1780 222 223

構建模型

至此,我們已經成功構建了一個 Dataset 類來生成模型輸入數據。現在使用具有 12 層 Transformer 編碼器的預訓練 BERT 基礎模型構建實際模型。

如果數據集中的文本是中文的,需要使用bert-base-chinese 模型。

from torch import nn
from transformers import BertModel

class BertClassifier(nn.Module):
    def __init__(self, dropout=0.5):
        super(BertClassifier, self).__init__()
        self.bert = BertModel.from_pretrained('bert-base-cased')
        self.dropout = nn.Dropout(dropout)
        self.linear = nn.Linear(768, 5)
        self.relu = nn.ReLU()

    def forward(self, input_id, mask):
        _, pooled_output = self.bert(input_ids= input_id, attention_mask=mask,return_dict=False)
        dropout_output = self.dropout(pooled_output)
        linear_output = self.linear(dropout_output)
        final_layer = self.relu(linear_output)
        return final_layer

從上面的代碼可以看出,BERT 模型輸出了兩個變量:

然後將pooled_output變量傳遞到具有 ReLU 激活函數的線性層。在線性層中輸出一個維度大小爲 5 的向量,每個向量對應於標籤類別(運動、商業、政治、 娛樂和科技)。

訓練模型

接下來是訓練模型。使用標準的 PyTorch 訓練循環來訓練模型。

上下滑動查看更多源碼
from torch.optim import Adam
from tqdm import tqdm

def train(model, train_data, val_data, learning_rate, epochs):
  # 通過Dataset類獲取訓練和驗證集
    train, val = Dataset(train_data), Dataset(val_data)
    # DataLoader根據batch_size獲取數據,訓練時選擇打亂樣本
    train_dataloader = torch.utils.data.DataLoader(train, batch_size=2, shuffle=True)
    val_dataloader = torch.utils.data.DataLoader(val, batch_size=2)
  # 判斷是否使用GPU
    use_cuda = torch.cuda.is_available()
    device = torch.device("cuda" if use_cuda else "cpu")
    # 定義損失函數和優化器
    criterion = nn.CrossEntropyLoss()
    optimizer = Adam(model.parameters()lr=learning_rate)

    if use_cuda:
            model = model.cuda()
            criterion = criterion.cuda()
    # 開始進入訓練循環
    for epoch_num in range(epochs):
      # 定義兩個變量,用於存儲訓練集的準確率和損失
            total_acc_train = 0
            total_loss_train = 0
      # 進度條函數tqdm
            for train_input, train_label in tqdm(train_dataloader):

                train_label = train_label.to(device)
                mask = train_input['attention_mask'].to(device)
                input_id = train_input['input_ids'].squeeze(1).to(device)
        # 通過模型得到輸出
                output = model(input_id, mask)
                # 計算損失
                batch_loss = criterion(output, train_label)
                total_loss_train += batch_loss.item()
                # 計算精度
                acc = (output.argmax(dim=1) == train_label).sum().item()
                total_acc_train += acc
        # 模型更新
                model.zero_grad()
                batch_loss.backward()
                optimizer.step()
            # ------ 驗證模型 -----------
            # 定義兩個變量,用於存儲驗證集的準確率和損失
            total_acc_val = 0
            total_loss_val = 0
      # 不需要計算梯度
            with torch.no_grad():
                # 循環獲取數據集,並用訓練好的模型進行驗證
                for val_input, val_label in val_dataloader:
          # 如果有GPU,則使用GPU,接下來的操作同訓練
                    val_label = val_label.to(device)
                    mask = val_input['attention_mask'].to(device)
                    input_id = val_input['input_ids'].squeeze(1).to(device)
  
                    output = model(input_id, mask)

                    batch_loss = criterion(output, val_label)
                    total_loss_val += batch_loss.item()
                    
                    acc = (output.argmax(dim=1) == val_label).sum().item()
                    total_acc_val += acc
            
            print(
                f'''Epochs: {epoch_num + 1} 
              | Train Loss: {total_loss_train / len(train_data): .3f} 
              | Train Accuracy: {total_acc_train / len(train_data): .3f} 
              | Val Loss: {total_loss_val / len(val_data): .3f} 
              | Val Accuracy: {total_acc_val / len(val_data): .3f}''')

我們對模型進行了 5 個 epoch 的訓練,我們使用 Adam 作爲優化器,而學習率設置爲 1e-6。因爲本案例中是處理多類分類問題,則使用分類交叉熵作爲我們的損失函數。

建議使用 GPU 來訓練模型,因爲 BERT 基礎模型包含 1.1 億個參數。

EPOCHS = 5
model = BertClassifier()
LR = 1e-6
train(model, df_train, df_val, LR, EPOCHS)

顯然,由於訓練過程的隨機性,每次可能不會得到與上面截圖類似的損失和準確率值。如果在 5 個 epoch 之後沒有得到好的結果,可以嘗試將 epoch 增加到 10 個,或者調整學習率。

在測試數據上評估模型

現在我們已經訓練了模型,我們可以使用測試數據來評估模型在未見數據上的性能。下面是評估模型在測試集上的性能的函數。

def evaluate(model, test_data):

    test = Dataset(test_data)
    test_dataloader = torch.utils.data.DataLoader(test, batch_size=2)
    use_cuda = torch.cuda.is_available()
    device = torch.device("cuda" if use_cuda else "cpu")
    if use_cuda:
        model = model.cuda()

    total_acc_test = 0
    with torch.no_grad():
        for test_input, test_label in test_dataloader:
              test_label = test_label.to(device)
              mask = test_input['attention_mask'].to(device)
              input_id = test_input['input_ids'].squeeze(1).to(device)
              output = model(input_id, mask)
              acc = (output.argmax(dim=1) == test_label).sum().item()
              total_acc_test += acc   
    print(f'Test Accuracy: {total_acc_test / len(test_data): .3f}')
    
evaluate(model, df_test)

運行上面的代碼後,我從測試數據中得到了 0.994 的準確率。由於訓練過程中的隨機性,將獲得的準確度可能會與我的結果略有不同。

討論兩個問題

這裏有個問題:使用 BERT 預訓練模型爲什麼最多隻能輸入 512 個詞,最多隻能兩個句子合成一句?

這是 Google BERT 預訓練模型初始設置的原因,前者對應 Position Embeddings,後者對應 Segment Embeddings

在 BERT 中,Token,Position,Segment Embeddings 都是通過學習來得到的,pytorch 代碼中它們是這樣的

self.word_embeddings = Embedding(config.vocab_size, config.hidden_size)
self.position_embeddings = Embedding(config.max_position_embeddings, config.hidden_size)
self.token_type_embeddings = Embedding(config.type_vocab_size, config.hidden_size)

而在 BERT config 中

"max_position_embeddings"512
"type_vocab_size"2

因此,在直接使用 Google 的 BERT 預訓練模型時,輸入最多 512 個詞(還要除掉 [CLS] 和[SEP]),最多兩個句子合成一句。這之外的詞和句子會沒有對應的 Embedding 。

當然,如果有足夠的硬件資源自己重新訓練 BERT,可以更改 BERT config,設置更大 max_position_embeddingstype_vocab_size 值去滿足自己的需求。

此外還有人問 BERT 的三個 Embedding 直接相加會對語義有影響嗎?

這是一個非常有意思的問題,蘇劍林老師也給出了回答,真的很妙啊:

Embedding 的數學本質,就是以 one hot 爲輸入的單層全連接。也就是說,世界上本沒什麼 Embedding,有的只是 one hot。

在這裏想用一個例子再嘗試解釋一下:

假設 token Embedding 矩陣維度是 [4,768];position Embedding 矩陣維度是 [3,768];segment Embedding 矩陣維度是 [2,768]。

對於一個字,假設它的 token one-hot 是 [1,0,0,0];它的 position one-hot 是 [1,0,0];它的 segment one-hot 是 [1,0]。

那這個字最後的 word Embedding,就是上面三種 Embedding 的加和。

如此得到的 word Embedding,和 concat 後的特徵:[1,0,0,0,1,0,0,1,0],再過維度爲 [4+3+2,768] = [9, 768] 的全連接層,得到的向量其實就是一樣的。

再換一個角度理解:

直接將三個 one-hot 特徵 concat 起來得到的 [1,0,0,0,1,0,0,1,0] 不再是 one-hot 了,但可以把它映射到三個 one-hot 組成的特徵空間,空間維度是 4_3_2=24 ,那在新的特徵空間,這個字的 one-hot 就是 [1,0,0,0,0...] (23 個 0)。

此時,Embedding 矩陣維度就是 [24,768],最後得到的 word Embedding 依然是和上面的等效,但是三個小 Embedding 矩陣的大小會遠小於新特徵空間對應的 Embedding 矩陣大小。

當然,在相同初始化方法前提下,兩種方式得到的 word Embedding 可能方差會有差別,但是,BERT 還有 Layer Norm,會把 Embedding 結果統一到相同的分佈。

BERT 的三個 Embedding 相加,本質可以看作一個特徵的融合,強大如 BERT 應該可以學到融合後特徵的語義信息的

這就是 BERT 期望的所有輸入。

然後,BERT 模型將在每個 token 中輸出一個大小爲 768 的 Embedding 向量。我們可以將這些向量用作不同類型 NLP 任務的輸入,無論是文本分類、本文生成、命名實體識別 (NER) 還是問答。

對於文本分類任務,我們將注意力集中在特殊 [CLS] token 的 embedding 向量輸出上。這意味着我們將使用具有 [CLS] token 的大小爲 768 的 embedding 向量作爲分類器的輸入,然後它將輸出一個大小爲分類任務中類別個數的向量。

寫在最後

現在我們如學會了何利用 Hugging Face 的預訓練 BERT 模型進行文本分類任務的步驟。我希望在你開始使用 BERT 是,這篇文章能幫到你。我們不僅可以使用來自 BERT 的 embedding 向量來執行句子或文本分類任務,還可以執行更高級的 NLP 應用,例如問答、文本生成或命名實體識別 (NER) 任務。

參考資料

[1]

預訓練標記器的名稱: https://huggingface.co/transformers/pretrained_models.html

[2]

參考: https://zhuanlan.zhihu.com/p/132554155

[3]

參考: https://jalammar.github.io/illustrated-bert/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/lInH4a0a5ifXpusz7Pxc3g