点击上方“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个最常见的单词和子单词。这个词汇表包含个东西:
整个单词
出现在单词前面或单独出现的子单词(“em”(如embeddings中的“em”)与“go get em”中的独立字符序列“em”分配相同的向量)
不在单词前面的子单词,在前面加上“##”来表示这种情况
单个字符
要在此模型下对单词进行记号化,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']
Next, we need to call the tokenizer to match the tokens agains their indices in the tokenizer vocabulary:
接下来,我们需要调用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 layer
with torch.no_grad():
encoded_layers, _ = model(tokens_tensor, segments_tensors)
输出
这个模型的全部隐藏状态存储在对象“encoded_layers”中,有点令人眼花缭乱。这个对象有四个维度,顺序如下:
层数(12层)
batch号(1句)
单词/令牌号(在我们的句子中有22个令牌)
隐藏单元/特征号(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维向量空间。
—END—
英文原文:https://mccormickml.com/2019/05/14/BERT-word-embeddings-tutorial/
请长按或扫描二维码关注本公众号
喜欢的话,请给我个好看吧!