BERT中的詞向量指南

2019-11-19     AI公園

作者:Chris McCormick

編譯:ronghuaiyang

導讀

在本文中,我將深入研究谷歌的BERT生成的word embeddings,並向你展示如何通過BERT生成自己的word embeddings。

在本文中,我將深入研究谷歌的BERT生成的word embeddings,並向你展示如何通過BERT生成自己的word embeddings。

介紹

歷史

2018年是NLP的突破之年。遷移學習,特別是像ELMO,Open-GPT,BERT之類的模型,允許研究人員針對特定的任務小小的微調一下(使用少量的數據和少量的計算),就可以得到一個很好的結果。不幸的是,對於許多剛開始學習NLP的人,甚至對於一些有經驗的實踐者,這些強大模型的理論和實際應用仍然沒有得到很好的理解。

BERT是什麼?

BERT(Bidirectional Encoder Representations from Transformers)於2018年末發布,是我們將在本教程中使用的模型,為讀者更好地理解和指導在NLP中使用遷移學習模型提供了實用的指導。BERT是一種預訓練語言表示的方法,用於創建NLP從業人員可以免費下載和使用的模型。你可以使用這些模型從文本數據中提取高質量的語言特徵,也可以使用你自己的數據對這些模型進行微調,以完成特定的任務(分類、實體識別、問題回答等),從而生成最先進的預測。

為什麼要使用BERT的嵌入?

在本教程中,我們將使用BERT從文本數據中提取特徵,即單詞和句子的嵌入向量。我們可以用這些詞和句子的嵌入向量做什麼?首先,這些嵌入對於關鍵字/搜索擴展、語義搜索和信息檢索非常有用。例如,如果你希望將客戶的問題或搜索與已經回答的問題或文檔化的搜索相匹配,這些表示將幫助準確的檢索匹配客戶意圖和上下文含義的結果,即使沒有關鍵字或短語重疊。

其次,或許更重要的是,這些向量被用作下游模型的高質量特徵輸入。NLP模型(如LSTMs或CNNs)需要以數字向量的形式輸入,這通常意味著需要將詞彙表和部分語音等特徵轉換為數字表示。在過去,單詞被表示為惟一索引值(one-hot編碼),或者更有用的是作為神經單詞嵌入,其中詞彙與固定長度的特徵嵌入進行匹配,這些特徵嵌入是由Word2Vec或Fasttext等模型產生的。與Word2Vec之類的模型相比,BERT提供了一個優勢,因為儘管Word2Vec下的每個單詞都有一個固定的表示,而與單詞出現的上下文無關,BERT生成的單詞表示是由單詞周圍的單詞動態通知的。例如,給定兩句話:

「The man was accused of robbing a bank.」 「The man went fishing by the bank of the river.」

Word2Vec將在兩個句子中為單詞「bank」生成相同的單詞嵌入,而在BERT中為「bank」生成不同的單詞嵌入。除了捕獲一詞多義之類的明顯差異外,上下文相關的單詞embeddings還捕獲其他形式的信息,這些信息可以產生更精確的特徵表示,從而提高模型性能。

從教育的角度看,仔細查看BERT的詞嵌入的是一個深入學習BERT及其遷移學習模型的很好的方法,我們設置了一些實用知識和上下文,以便在後面的內容中更好地理解模型的內部細節。

安裝和導入

使用Hugging Face的github倉庫來安裝pytorch接口。(這個庫包含其他預訓練語言模型的接口,比如OpenAI的GPT和GPT-2)我們之所以選擇pytorch接口,是因為它在高級api(易於使用,但不能深入了解工作原理)和tensorflow代碼(其中包含了很多細節,但通常會讓我們忽略關於tensorflow的內容,此處的目的是BERT!)之間取得了很好的平衡。

!pip install pytorch-pretrained-bert

現在我們導入pytorch、預訓練的BERT模型和BERT tokenizer。我們將在後面的教程中詳細解釋BERT模型,但這是谷歌發布的預訓練模型,它在Wikipedia和Book Corpus上運行了很多很多小時,Book Corpus是一個包含+10,000本不同類型書籍的數據集。這個模型(稍加修改)在一系列任務中擊敗了NLP基準測試。谷歌發布了一些BERT模型的變體,但是我們在這裡使用的是兩個可用尺寸(「base」和「large」)中較小的一個。

import torch
from pytorch_pretrained_bert import BertTokenizer, BertModel, BertForMaskedLM

# OPTIONAL: if you want to have more information on what's happening, activate the logger as follows
import logging
#logging.basicConfig(level=logging.INFO)

import matplotlib.pyplot as plt
% matplotlib inline

# Load pre-trained model tokenizer (vocabulary)
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
100%|██████████| 231508/231508 [00:00<00:00, 2386266.84B/s]

輸入格式

因為BERT是一個預訓練的模型,它期望以特定的格式輸入數據,所以我們需要:

  • 句子的開始([CLS])和分隔/結尾([SEP])的特別標記
  • 符合BERT中使用的固定詞彙表的標記
  • BERT『s tokenizer中的token id
  • 掩碼id,以指示序列中的哪些元素是令牌,哪些是填充元素
  • 段id用於區分不同的句子
  • 用於顯示令牌在序列中的位置嵌入

幸運的是,這個接口為我們處理了這些輸入規範中的一些,因此我們只需要手動創建其中的一些(我們將在另一個教程中重新討論其他輸入)。

特殊的標記

BERT可以接受一到兩句話作為輸入,並希望每句話的開頭和結尾都有特殊的標記:

2個句子的輸入:

[CLS] the man went to the store [SEP] he bought a gallon of milk [SEP]

1個句子的輸入:

[CLS] the man went to the store [SEP]

text = "Here is the sentence I want embeddings for."
text = "After stealing money from the bank vault, the bank robber was seen fishing on the Mississippi river bank."
marked_text = "[CLS] " + text + " [SEP]"

print (marked_text)
[CLS] After stealing money from the bank vault, the bank robber was seen fishing on the Mississippi river bank. [SEP]

我們導入了一個BERT-specific tokenizer,讓我們看看輸出:

Token初始化

tokenized_text = tokenizer.tokenize(marked_text)
print (tokenized_text)
['[CLS]', 'after', 'stealing', 'money', 'from', 'the', 'bank', 'vault', ',', 'the', 'bank', 'robber', 'was', 'seen', 'fishing', 'on', 'the', 'mississippi', 'river', 'bank', '.', '[SEP]']

注意「embeddings」一詞是如何表示的:

[『em』, 『##bed』, 『##ding』, 『##s』]

原來的單詞被分成更小的子單詞和字符。這些子單詞前面的兩個#號只是我們的tokenizer用來表示這個子單詞或字符是一個更大單詞的一部分,並在其前面加上另一個子單詞的方法。因此,例如,' ##bed ' token與' bed 'token是分開的,當一個較大的單詞中出現子單詞bed時,使用第一種方法,當一個獨立的token 「thing you sleep on」出現時,使用第二種方法。

為什麼會這樣?這是因為BERT tokenizer 是用WordPiece模型創建的。這個模型使用貪心法創建了一個固定大小的詞彙表,其中包含單個字符、子單詞和最適合我們的語言數據的單詞。由於我們的BERT tokenizer模型的詞彙量限制大小為30,000,因此,用WordPiece模型生成一個包含所有英語字符的詞彙表,再加上該模型所訓練的英語語料庫中發現的~30,000個最常見的單詞和子單詞。這個詞彙表包含個東西:

  1. 整個單詞
  2. 出現在單詞前面或單獨出現的子單詞(「em」(如embeddings中的「em」)與「go get em」中的獨立字符序列「em」分配相同的向量)
  3. 不在單詞前面的子單詞,在前面加上「##」來表示這種情況
  4. 單個字符

要在此模型下對單詞進行記號化,tokenizer首先檢查整個單詞是否在詞彙表中。如果沒有,則嘗試將單詞分解為詞彙表中包含的儘可能大的子單詞,最後將單詞分解為單個字符。注意,由於這個原因,我們總是可以將一個單詞表示為至少是它的單個字符的集合。

因此,不是將詞彙表中的單詞分配給諸如「OOV」或「UNK」之類的全集令牌,而是將詞彙表中沒有的單詞分解為子單詞和字符令牌,然後我們可以為它們生成嵌入。

因此,我們沒有將「embeddings」和詞彙表之外的每個單詞分配給一個重載的未知詞彙表標記,而是將其拆分為子單詞標記[' em '、' ##bed '、' ##ding '、' ##s '],這些標記將保留原單詞的一些上下文含義。我們甚至可以平均這些子單詞的嵌入向量來為原始單詞生成一個近似的向量。

下面是詞彙表中包含的一些令牌示例。以兩個#號開頭的標記是子單詞或單個字符。

list(tokenizer.vocab.keys())[5000:5020]
['knight',
'lap',
'survey',
'ma',
'##ow',
'noise',
'billy',
'##ium',
'shooting',
'guide',
'bedroom',
'priest',
'resistance',
'motor',
'homes',
'sounded',
'giant',
'##mer',
'150',
'scenes']

接下來,我們需要調用tokenizer來匹配tokens在tokenizer詞彙表中的索引:

indexed_tokens = tokenizer.convert_tokens_to_ids(tokenized_text)

for tup in zip(tokenized_text, indexed_tokens):
print (tup)
('[CLS]', 101)
('after', 2044)
('stealing', 11065)
('money', 2769)
('from', 2013)
('the', 1996)
('bank', 2924)
('vault', 11632)
(',', 1010)
('the', 1996)
('bank', 2924)
('robber', 27307)
('was', 2001)
('seen', 2464)
('fishing', 5645)
('on', 2006)
('the', 1996)
('mississippi', 5900)
('river', 2314)
('bank', 2924)
('.', 1012)
('[SEP]', 102)

Segment ID

BERT接受了句子對的訓練,並期望使用1和0來區分這兩個句子。也就是說,對於「tokenized_text」中的每個標記,我們必須指定它屬於哪個句子:句子0(一系列0)或句子1(一系列1)。對於我們的目的,單句輸入只需要一系列的1,所以我們將為輸入語句中的每個標記創建一個1向量。

如果你想處理兩個句子,請將第一個句子中的每個單詞加上「[SEP]」token賦值為0,第二個句子中的所有token賦值為1。

segments_ids = [1] * len(tokenized_text)
print (segments_ids)
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

運行一下我們的例子

接下來,我們需要將數據轉換為torch張量並調用BERT模型。BERT PyTorch接口要求數據使用torch張量而不是Python列表,所以我們在這裡轉換列表——這不會改變形狀或數據。

eval()將我們的模型置於評估模式,而不是訓練模式。在這種情況下,評估模式關閉了訓練中使用的dropout正則化。

調用 from_pretrained 將從網上獲取模型。當我們加載 bert-base-uncased時,我們會在日誌中看到列印的模型定義。該模型是一個12層的深度神經網絡!

# Convert inputs to PyTorch tensors
tokens_tensor = torch.tensor([indexed_tokens])
segments_tensors = torch.tensor([segments_ids])

# Load pre-trained model (weights)
model = BertModel.from_pretrained('bert-base-uncased')

# Put the model in "evaluation" mode, meaning feed-forward operation.
model.eval()

接下來,讓我們獲取網絡的隱藏狀態。

torch.no_grad禁用梯度計算,節省內存,並加快計算速度(我們不需要梯度或反向傳播,因為我們只是運行向前傳播)。

# Predict hidden states features for each layerwith torch.no_grad(): encoded_layers, _ = model(tokens_tensor, segments_tensors)

輸出

這個模型的全部隱藏狀態存儲在對象「encoded_layers」中,有點令人眼花繚亂。這個對象有四個維度,順序如下:

  1. 層數(12層)
  2. batch號(1句)
  3. 單詞/令牌號(在我們的句子中有22個令牌)
  4. 隱藏單元/特徵號(768個特徵)

這是202,752個值來唯一表示我們的一句話!

第二維度,是批處理大小,用於同時向模型提交多個句子,這裡,我們只有一個句子。

print ("Number of layers:", len(encoded_layers))
layer_i = 0

print ("Number of batches:", len(encoded_layers[layer_i]))
batch_i = 0

print ("Number of tokens:", len(encoded_layers[layer_i][batch_i]))
token_i = 0

print ("Number of hidden units:", len(encoded_layers[layer_i][batch_i][token_i]))
Number of layers: 12
Number of batches: 1
Number of tokens: 22
Number of hidden units: 768

讓我們快速查看一下給定層和token的值範圍。

你將發現,所有層和token的範圍都非常相似,大多數值位於[- 2,2]之間,少量值位於-10左右。

# For the 5th token in our sentence, select its feature values from layer 5.
token_i = 5
layer_i = 5
vec = encoded_layers[layer_i][batch_i][token_i]

# Plot the values as a histogram to show their distribution.
plt.figure(figsize=(10,10))
plt.hist(vec, bins=200)
plt.show()

按層對值進行分組對於模型是有意義的,但是出於我們的目的,我們希望按token對值進行分組。

下面的代碼只是重新構造這些值,這樣我們就有了它們的形式:

 [# tokens, # layers, # features]
# Convert the hidden state embeddings into single token vectors

# Holds the list of 12 layer embeddings for each token
# Will have the shape: [# tokens, # layers, # features]
token_embeddings = []

# For each token in the sentence...
for token_i in range(len(tokenized_text)):

# Holds 12 layers of hidden states for each token
hidden_layers = []

# For each of the 12 layers...
for layer_i in range(len(encoded_layers)):

# Lookup the vector for `token_i` in `layer_i`
vec = encoded_layers[layer_i][batch_i][token_i]

hidden_layers.append(vec)

token_embeddings.append(hidden_layers)

# Sanity check the dimensions:
print ("Number of tokens in sequence:", len(token_embeddings))
print ("Number of layers per token:", len(token_embeddings[0]))
Number of tokens in sequence: 22
Number of layers per token: 12

從隱藏狀態中構建詞向量和句向量

現在,我們怎麼處理這些隱藏狀態?我們想要得到每個token的單獨向量,或者可能是整個句子的單個向量表示,但是對於輸入的每個token,我們有12個長度為768的單獨向量。

為了得到單獨的向量,我們需要組合一些層向量……但是哪個層或層的組合提供了最好的表示?BERT的作者通過將不同的向量組合作為輸入特徵輸入到一個用於命名實體識別任務的BiLSTM中,並觀察得到的F1分數來測試這一點。

雖然最後四層的連接在這個特定的任務上產生了最好的結果,但是許多其他方法緊隨其後,並且通常建議為你的特定應用程式測試不同的版本:結果可能會有所不同。

注意到BERT的不同層編碼非常不同的信息,可以部分地證明這一點,因此適當的池化策略將根據應用的不同而改變,因為不同的層化編碼不同的信息。Hanxiao對這個話題的討論是相關的,他們的實驗是在新聞數據集上訓練不同層次的PCA可視化,並觀察不同池策略下四類分離的差異。

結果是,正確的池化策略(平均值、最大值、連接等等)和使用的層(最後四層、全部、最後一層等等)依賴於應用。對池化策略的討論既適用於整個語句嵌入,也適用於類似於elmo的單個token嵌入。

詞向量

為了給你一些例子,讓我們用最後四層的連接和求和來創建單詞向量:

concatenated_last_4_layers = [torch.cat((layer[-1], layer[-2], layer[-3], layer[-4]), 0) for layer in token_embeddings] # [number_of_tokens, 3072]

summed_last_4_layers = [torch.sum(torch.stack(layer)[-4:], 0) for layer in token_embeddings] # [number_of_tokens, 768]

句向量

要為整個句子獲得一個向量,我們有多個依賴於應用的策略,但是一個簡單的方法是對每個token的倒數第二個隱藏層求平均,生成一個768長度的向量。

sentence_embedding = torch.mean(encoded_layers[11], 1)
print ("Our final sentence embedding vector of shape:"), sentence_embedding[0].shape[0]

Our final sentence embedding vector of shape:
(None, 768)

確定上下文相關的向量

為了確認這些向量的值實際上是上下文相關的,讓我們看一下下面這句話的輸出(如果你想試試這個,你必須從頂部運行這個例子,用下面的句子替換我們原來的句子):

print (text)
After stealing money from the bank vault, the bank robber was seen fishing on the Mississippi river bank.

for i,x in enumerate(tokenized_text):
print (i,x)
0 [CLS]
1 after
2 stealing
3 money
4 from
5 the
6 bank
7 vault
8 ,
9 the
10 bank
11 robber
12 was
13 seen
14 fishing
15 on
16 the
17 mississippi
18 river
19 bank
20 .
21 [SEP]

print ("First fifteen values of 'bank' as in 'bank robber':")
summed_last_4_layers[10][:15]
First fifteen values of 'bank' as in 'bank robber':
tensor([ 1.1868, -1.5298, -1.3770, 1.0648, 3.1446, 1.4003, -4.2407, 1.3946,
-0.1170, -1.8777, 0.1091, -0.3862, 0.6744, 2.1924, -4.5306])

print ("First fifteen values of 'bank' as in 'bank vault':")
summed_last_4_layers[6][:15]
First fifteen values of 'bank' as in 'bank vault':
tensor([ 2.1319, -2.1413, -1.6260, 0.8638, 3.3173, 0.1796, -4.4853, 3.1215,
-0.9740, -3.1780, 0.1046, -1.5481, 0.4758, 1.1703, -4.4859])

print ("First fifteen values of 'bank' as in 'river bank':")
summed_last_4_layers[19][:15]
First fifteen values of 'bank' as in 'river bank':
tensor([ 1.1295, -1.4725, -0.7296, -0.0901, 2.4970, 0.5330, 0.9742, 5.1834,
-1.0692, -1.5941, 1.9261, 0.7119, -0.9809, 1.2127, -2.9812])

我們可以看到,這些都是不同的向量,它們應該是不同的,雖然單詞「bank」是相同的,但在我們的每個句子中,它都有不同的含義,有時意義非常不同。

在這個句子中,我們有三種不同的「bank」用法,其中兩種幾乎是相同的。讓我們檢查餘弦相似度,看看是不是這樣:

from sklearn.metrics.pairwise import cosine_similarity

# Compare "bank" as in "bank robber" to "bank" as in "river bank"
different_bank = cosine_similarity(summed_last_4_layers[10].reshape(1,-1), summed_last_4_layers[19].reshape(1,-1))[0][0]

# Compare "bank" as in "bank robber" to "bank" as in "bank vault"
same_bank = cosine_similarity(summed_last_4_layers[10].reshape(1,-1), summed_last_4_layers[6].reshape(1,-1))[0][0]
print ("Similarity of 'bank' as in 'bank robber' to 'bank' as in 'bank vault':", same_bank)
Similarity of 'bank' as in 'bank robber' to 'bank' as in 'bank vault': 0.94567525
print ("Similarity of 'bank' as in 'bank robber' to 'bank' as in 'river bank':", different_bank)
Similarity of 'bank' as in 'bank robber' to 'bank' as in 'river bank': 0.6797334

其他:特殊的tokens,OOV單詞,相似度度量

特殊tokens

需要注意的是,雖然「[CLS]」用作分類任務的「聚合表示」,但對於高質量的句子嵌入向量來說,這不是最佳選擇。根據BERT作者Jacob Devlin:

我不確定這些向量是什麼,因為BERT不能生成有意義的句子向量。這似乎是在對單詞tokens進行平均池化,以獲得一個句子向量,但我們從未建議這將生成有意義的句子表示。」

(但是,如果對模型進行微調,[CLS] token確實變得有意義,其中該token的最後一個隱藏層用作序列分類的「句子向量」。)

詞彙表之外的單詞

對於由多個句子和字符級嵌入組成的詞彙表之外的單詞,還有一個進一步的問題,即如何最好地恢復這種嵌入。平均嵌入是最直接的解決方案(在類似的嵌入模型中依賴於子單詞詞彙表(如fasttext)),但是子單詞嵌入的總和和簡單地使用最後一個token嵌入(記住向量是上下文敏感的)是可接受的替代策略。

相似度度量

值得注意的是,單詞級相似度比較不適用於BERT embeddings,因為這些嵌入是上下文相關的,這意味著單詞vector會根據它出現在的句子而變化。這就允許了像一詞多義這樣的奇妙的東西,例如,你的表示編碼了river 「bank」,而不是金融機構「bank」,但卻使得直接的詞與詞之間的相似性比較變得不那麼有價值。但是,對於句子嵌入相似性比較仍然是有效的,這樣就可以對一個句子查詢其他句子的數據集,從而找到最相似的句子。根據使用的相似度度量,得到的相似度值將比相似度輸出的相對排序提供的信息更少,因為許多相似度度量對向量空間(例如,等權重維度)做了假設,而這些假設不適用於768維向量空間。

英文原文:https://mccormickml.com/2019/05/14/BERT-word-embeddings-tutorial/

請長按或掃描二維碼關注本公眾號

文章來源: https://twgreatdaily.com/zh/ntcCh24BMH2_cNUg5UYn.html