早在 2018 年,谷歌就為 NLP 應(yīng)用程序開發(fā)了一個基于 Transformer 的強(qiáng)大的機(jī)器學(xué)習(xí)模型,該模型在不同的基準(zhǔn)數(shù)據(jù)集中優(yōu)于以前的語言模型。這個模型被稱為BERT。在這篇文章中,我們將使用來自 Hugging Face 的預(yù)訓(xùn)練 BERT 模型進(jìn)行文本分類任務(wù)。一般而言,文本分類任務(wù)中模型的主要目標(biāo)是將文本分類為預(yù)定義的標(biāo)簽或標(biāo)簽之一。
本文中,我們使用 BBC 新聞分類數(shù)據(jù)集,使用預(yù)訓(xùn)練的 BERT 模型來分類新聞文章的文本是否可以分類為體育、政治、商業(yè)、娛樂或科技類別。
什么是 BERT
BERT 是 Bidirectional Encoder Representations from Transformers 的首字母縮寫詞。

BERT 架構(gòu)由多個堆疊在一起的 Transformer 編碼器組成。每個 Transformer 編碼器都封裝了兩個子層:一個自注意力層和一個前饋層。
有兩種不同的 BERT 模型:

- BERT base 模型,由 12 層 Transformer 編碼器、12 個注意力頭、768 個隱藏大小和 110M 參數(shù)組成。
- BERT large 模型,由 24 層 Transformer 編碼器、16 個注意力頭、1024 個隱藏大小和 340M 個參數(shù)組成。
BERT 是一個強(qiáng)大的語言模型至少有兩個原因:
- 它使用從 BooksCorpus (有 8 億字)和 Wikipedia(有 25 億字)中提取的未標(biāo)記數(shù)據(jù)進(jìn)行預(yù)訓(xùn)練。
- 顧名思義,它是通過利用編碼器堆棧的雙向特性進(jìn)行預(yù)訓(xùn)練的。這意味著 BERT 不僅從左到右,而且從右到左從單詞序列中學(xué)習(xí)信息。
論文
- Transformer: Attention Is All You Need:https:///abs/1706.03762
- BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding:https:///abs/1810.04805
源碼
- Transformer Pytorch源碼:https://github.com/jadore801120/attention-is-all-you-need-pytorch
- BERT Pytorch源碼:https://github.com/hichenway/CodeShare/tree/master/bert_pytorch_source_code
- HuggingFace Transformers:https://github.com/huggingface/transformers
BERT 輸入

BERT 模型需要一系列 tokens (words) 作為輸入。在每個token序列中,BERT 期望輸入有兩個特殊標(biāo)記:
[CLS] :這是每個sequence的第一個token,代表分類token。[SEP] :這是讓BERT知道哪個token屬于哪個序列的token。這一特殊表征法主要用于下一個句子預(yù)測任務(wù)或問答任務(wù)。如果我們只有一個sequence,那么這個token將被附加到序列的末尾。
為什么選它們([CLS]/[SEP])呢,因為與文本中已有的其它詞相比,這個無明顯語義信息的符號會更“公平”地融合文本中各個詞的語義信息,從而更好的表示整句話的語義。
具體來說,self-attention 是用文本中的其它詞來增強(qiáng)目標(biāo)詞的語義表示,但是目標(biāo)詞本身的語義還是會占主要部分的,因此,經(jīng)過BERT的12層,每次詞的 Embedding 融合了所有詞的信息,可以去更好的表示自己的語義。
而[CLS]位本身沒有語義,經(jīng)過12層,得到的是attention后所有詞的加權(quán)平均,相比其他正常詞,可以更好的表征句子語義。
就像Transformer的普通編碼器一樣,BERT 將一系列單詞作為輸入,這些單詞不斷向上流動。每一層都應(yīng)用自我注意,并將其結(jié)果通過前饋網(wǎng)絡(luò)傳遞,然后將其傳遞給下一個編碼器。

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

第一步,需要將這個句子轉(zhuǎn)換為一系列tokens (words) ,這個過程稱為tokenization。

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

其實我們只需要一行代碼(即使用BertTokenizer)就可以將輸入句子轉(zhuǎn)換為 BERT 所期望的tokens 序列。
還需要注意的是,可以輸入 BERT 模型的最大tokens大小為 512。如果sequence中的tokens小于 512,我們可以使用填充來用 [PAD] 填充未使用的tokens。如果sequence中的tokens長于 512,那么需要進(jìn)行截斷。
BERT 輸出
每個位置輸出一個大小為 hidden_ size的向量(BERT Base 中為 768)。對于我們在上面看到的句子分類示例,我們只關(guān)注第一個位置的輸出(將特殊的 [CLS] token 傳遞到該位置)。

該向量現(xiàn)在可以用作我們選擇的分類器的輸入。該論文僅使用單層神經(jīng)網(wǎng)絡(luò)作為分類器就取得了很好的效果。

如果有更多標(biāo)簽,只需調(diào)整分類器網(wǎng)絡(luò)以獲得更多輸出神經(jīng)元然后通過softmax輸出多標(biāo)簽分類。
使用 BERT 進(jìn)行文本分類

本文的主題是用 BERT 對文本進(jìn)行分類。在這篇文章中,我們將使用kaggle上的BBC 新聞分類數(shù)據(jù)集。
數(shù)據(jù)集已經(jīng)是 CSV 格式,它有 2126 個不同的文本,每個文本都標(biāo)記在 5 個類別中的一個下:sport(體育),business(商業(yè)),politics(政治),tech(科技),entertainment(娛樂)。
看一下數(shù)據(jù)集的樣子:

如上表所示,數(shù)據(jù)框只有兩列,category 將作為標(biāo)簽,text 將作為 BERT 的輸入數(shù)據(jù)。
預(yù)模型下載和使用
BERT 預(yù)訓(xùn)練模型的下載有許多方式,比如從github官網(wǎng)上下載(官網(wǎng)下載的是tensorflow版本的),還可以從源碼中找到下載鏈接,然后手動下載,最后還可以從huggingface中下載。
從huggingface下載預(yù)訓(xùn)練模型的地址:https:///models
在搜索框搜索到你需要的模型。

來到下載頁面:

注意,這里常用的幾個預(yù)訓(xùn)練模型,bert-base-cased、bert-base-uncased及中文bert-base-chinese。其中前兩個容易混淆。bert-base-cased是區(qū)分大小寫,不需要事先lower-case;而bert-base-uncased不能區(qū)分大小寫,因為詞表只有小寫,需要事先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
預(yù)處理數(shù)據(jù)
現(xiàn)在我們基本熟悉了 BERT 的基本使用,接下來為其準(zhǔn)備輸入數(shù)據(jù)。一般情況下,在訓(xùn)練模型前,都需要對手上的數(shù)據(jù)進(jìn)行預(yù)處理,以滿足模型需要。
前面已經(jīng)介紹過了,模型輸入數(shù)據(jù)中,需要通過添加 [CLS] 和 [SEP] 這兩個特殊的token,將文本轉(zhuǎn)換為 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參數(shù)的解釋:
padding:將每個sequence填充到指定的最大長度。max_length: 每個sequence的最大長度。本示例中我們使用 10,但對于本文實際數(shù)據(jù)集,我們將使用 512,這是 BERT 允許的sequence 的最大長度。truncation:如果為True,則每個序列中超過最大長度的標(biāo)記將被截斷。return_tensors:將返回的張量類型。由于我們使用的是 Pytorch,所以我們使用pt;如果你使用 Tensorflow,那么你需要使用tf。
從上面的變量中看到的輸出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]'
由上述結(jié)果所示,BertTokenizer負(fù)責(zé)輸入文本的所有必要轉(zhuǎn)換,為 BERT 模型的輸入做好準(zhǔn)備。它會自動添加 [CLS]、[SEP] 和 [PAD] token。由于我們指定最大長度為 10,所以最后只有兩個 [PAD] token。
2. 第二行是 token_type_ids,它是一個 binary mask,用于標(biāo)識 token 屬于哪個 sequence。如果我們只有一個 sequence,那么所有的 token 類型 id 都將為 0。對于文本分類任務(wù),token_type_ids是 BERT 模型的可選輸入?yún)?shù)。
3. 第三行是 attention_mask,它是一個 binary mask,用于標(biāo)識 token 是真實 word 還是只是由填充得到。如果 token 包含 [CLS]、[SEP] 或任何真實單詞,則 mask 將為 1。如果 token 只是 [PAD] 填充,則 mask 將為 0。
注意到,我們使用了一個預(yù)訓(xùn)練BertTokenizer的bert-base-cased模型。如果數(shù)據(jù)集中的文本是英文的,這個預(yù)訓(xùn)練的分詞器就可以很好地工作。
如果有來自不同語言的數(shù)據(jù)集,可能需要使用bert-base-multilingual-cased。具體來說,如果你的數(shù)據(jù)集是德語、荷蘭語、中文、日語或芬蘭語,則可能需要使用專門針對這些語言進(jìn)行預(yù)訓(xùn)練的分詞器??梢栽诖颂幉榭聪鄳?yīng)的預(yù)訓(xùn)練標(biāo)記器的名稱[1]。特別地,如果數(shù)據(jù)集中的文本是中文的,需要使用bert-base-chinese 模型,以及其相應(yīng)的BertTokenizer等。
數(shù)據(jù)集類
現(xiàn)在我們知道從BertTokenizer中獲得什么樣的輸出,接下來為新聞數(shù)據(jù)集構(gòu)建一個Dataset類,該類將作為一個類來將新聞數(shù)據(jù)轉(zhuǎn)換成模型需要的數(shù)據(jù)格式。
上下滑動查看更多源碼
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
在上面實現(xiàn)的代碼中,我們定義了一個名為 labels的變量,它是一個字典,將DataFrame中的 category 映射到 labels的 id 表示。注意,上面的__init__函數(shù)中,還調(diào)用了BertTokenizer將輸入文本轉(zhuǎn)換為 BERT 期望的向量格式。
定義Dataset類后,將數(shù)據(jù)框拆分為訓(xùn)練集、驗證集和測試集,比例為 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
構(gòu)建模型
至此,我們已經(jīng)成功構(gòu)建了一個 Dataset 類來生成模型輸入數(shù)據(jù)?,F(xiàn)在使用具有 12 層 Transformer 編碼器的預(yù)訓(xùn)練 BERT 基礎(chǔ)模型構(gòu)建實際模型。
如果數(shù)據(jù)集中的文本是中文的,需要使用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 模型輸出了兩個變量:
- 在上面的代碼中命名的第一個變量
_包含sequence中所有 token 的 Embedding 向量層。 - 命名的第二個變量
pooled_output包含 [CLS] token 的 Embedding 向量。對于文本分類任務(wù),使用這個 Embedding 作為分類器的輸入就足夠了。
然后將pooled_output變量傳遞到具有 ReLU 激活函數(shù)的線性層。在線性層中輸出一個維度大小為 5 的向量,每個向量對應(yīng)于標(biāo)簽類別(運(yùn)動、商業(yè)、政治、 娛樂和科技)。
訓(xùn)練模型
接下來是訓(xùn)練模型。使用標(biāo)準(zhǔn)的 PyTorch 訓(xùn)練循環(huán)來訓(xùn)練模型。
from torch.optim import Adam
from tqdm import tqdm
def train(model, train_data, val_data, learning_rate, epochs):
# 通過Dataset類獲取訓(xùn)練和驗證集
train, val = Dataset(train_data), Dataset(val_data)
# DataLoader根據(jù)batch_size獲取數(shù)據(jù),訓(xùn)練時選擇打亂樣本
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')
# 定義損失函數(shù)和優(yōu)化器
criterion = nn.CrossEntropyLoss()
optimizer = Adam(model.parameters(), lr=learning_rate)
if use_cuda:
model = model.cuda()
criterion = criterion.cuda()
# 開始進(jìn)入訓(xùn)練循環(huán)
for epoch_num in range(epochs):
# 定義兩個變量,用于存儲訓(xùn)練集的準(zhǔn)確率和損失
total_acc_train = 0
total_loss_train = 0
# 進(jìn)度條函數(shù)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()
# ------ 驗證模型 -----------
# 定義兩個變量,用于存儲驗證集的準(zhǔn)確率和損失
total_acc_val = 0
total_loss_val = 0
# 不需要計算梯度
with torch.no_grad():
# 循環(huán)獲取數(shù)據(jù)集,并用訓(xùn)練好的模型進(jìn)行驗證
for val_input, val_label in val_dataloader:
# 如果有GPU,則使用GPU,接下來的操作同訓(xùn)練
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}''')
我們對模型進(jìn)行了 5 個 epoch 的訓(xùn)練,我們使用 Adam 作為優(yōu)化器,而學(xué)習(xí)率設(shè)置為1e-6。因為本案例中是處理多類分類問題,則使用分類交叉熵作為我們的損失函數(shù)。
建議使用 GPU 來訓(xùn)練模型,因為 BERT 基礎(chǔ)模型包含 1.1 億個參數(shù)。
EPOCHS = 5
model = BertClassifier()
LR = 1e-6
train(model, df_train, df_val, LR, EPOCHS)

顯然,由于訓(xùn)練過程的隨機(jī)性,每次可能不會得到與上面截圖類似的損失和準(zhǔn)確率值。如果在 5 個 epoch 之后沒有得到好的結(jié)果,可以嘗試將 epoch 增加到 10 個,或者調(diào)整學(xué)習(xí)率。
在測試數(shù)據(jù)上評估模型
現(xiàn)在我們已經(jīng)訓(xùn)練了模型,我們可以使用測試數(shù)據(jù)來評估模型在未見數(shù)據(jù)上的性能。下面是評估模型在測試集上的性能的函數(shù)。
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)
運(yùn)行上面的代碼后,我從測試數(shù)據(jù)中得到了 0.994 的準(zhǔn)確率。由于訓(xùn)練過程中的隨機(jī)性,將獲得的準(zhǔn)確度可能會與我的結(jié)果略有不同。
討論兩個問題
這里有個問題:使用BERT預(yù)訓(xùn)練模型為什么最多只能輸入512個詞,最多只能兩個句子合成一句?
這是Google BERT預(yù)訓(xùn)練模型初始設(shè)置的原因,前者對應(yīng)Position Embeddings,后者對應(yīng)Segment Embeddings
在BERT中,Token,Position,Segment Embeddings 都是通過學(xué)習(xí)來得到的,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 預(yù)訓(xùn)練模型時,輸入最多512個詞(還要除掉[CLS]和[SEP]),最多兩個句子合成一句。這之外的詞和句子會沒有對應(yīng)的 Embedding 。
當(dāng)然,如果有足夠的硬件資源自己重新訓(xùn)練 BERT,可以更改 BERT config,設(shè)置更大 max_position_embeddings 和 type_vocab_size 值去滿足自己的需求。
此外還有人問 BERT的三個Embedding直接相加會對語義有影響嗎?

這是一個非常有意思的問題,蘇劍林老師也給出了回答,真的很妙?。?/p>
Embedding 的數(shù)學(xué)本質(zhì),就是以 one hot 為輸入的單層全連接。也就是說,世界上本沒什么 Embedding,有的只是one hot。
在這里想用一個例子再嘗試解釋一下:
假設(shè) token Embedding 矩陣維度是 [4,768];position Embedding 矩陣維度是 [3,768];segment Embedding 矩陣維度是 [2,768]。
對于一個字,假設(shè)它的 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 組成的特征空間,空間維度是 432=24 ,那在新的特征空間,這個字的one-hot就是[1,0,0,0,0...] (23個0)。
此時,Embedding 矩陣維度就是 [24,768],最后得到的 word Embedding 依然是和上面的等效,但是三個小 Embedding 矩陣的大小會遠(yuǎn)小于新特征空間對應(yīng)的 Embedding 矩陣大小。
當(dāng)然,在相同初始化方法前提下,兩種方式得到的 word Embedding 可能方差會有差別,但是,BERT還有Layer Norm,會把 Embedding 結(jié)果統(tǒng)一到相同的分布。
BERT的三個Embedding相加,本質(zhì)可以看作一個特征的融合,強(qiáng)大如 BERT 應(yīng)該可以學(xué)到融合后特征的語義信息的
這就是 BERT 期望的所有輸入。
然后,BERT 模型將在每個token中輸出一個大小為 768 的 Embedding 向量。我們可以將這些向量用作不同類型 NLP 任務(wù)的輸入,無論是文本分類、本文生成、命名實體識別 (NER) 還是問答。
對于文本分類任務(wù),我們將注意力集中在特殊 [CLS] token 的 embedding 向量輸出上。這意味著我們將使用具有 [CLS] token 的大小為 768 的 embedding 向量作為分類器的輸入,然后它將輸出一個大小為分類任務(wù)中類別個數(shù)的向量。
寫在最后
現(xiàn)在我們?nèi)鐚W(xué)會了何利用 Hugging Face 的預(yù)訓(xùn)練 BERT 模型進(jìn)行文本分類任務(wù)的步驟。我希望在你開始使用 BERT是,這篇文章能幫到你。我們不僅可以使用來自 BERT 的embedding向量來執(zhí)行句子或文本分類任務(wù),還可以執(zhí)行更高級的 NLP 應(yīng)用,例如問答、文本生成或命名實體識別 (NER)任務(wù)。
參考資料
[1]預(yù)訓(xùn)練標(biāo)記器的名稱: https:///transformers/pretrained_models.html