MENU

BERT Word Embeddings Tutorial

August 21, 2020 • Read: 7178 • Deep Learning阅读设置

本文译自BERT Word Emebddings Tutorial,我将其中部分内容进行了精简。转载请注明出处

1. Loading Pre-Trained BERT

通过Hugging Face安装BERT的PyTorch接口,该库还包含其它预训练语言模型的接口,如OpenAI的GPT和GPT-2

如果您在Google Colab上运行此代码,每次重新连接时都必须安装此库

!pip install transformers

BERT是由Google发布的预训练模型,该模型使用Wikipedia和Book Corpus数据进行训练(Book Corpus是一个包含不同类型的10000+本书的数据集)。Google发布了一系列BERT的变体,但我们在这里使用的是两种可用尺寸("base" 和 "large")中较小的一种,并且我们设置忽略单词大小写

transformers提供了许多应用于不同任务的BERT模型。在这里,我们使用最基本的BertModel,这个接口的输出不针对任何特定任务,因此用它提取embeddings是个不错的选择

现在让我们导入PyTorch,预训练BERT 模型以及BERT tokenizer

import torch
from transformers import BertTokenizer, BertModel

# 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')

2. Input Formatting

由于BERT是一个预训练模型,需要输入特定格式的数据,因此我们需要:

  1. A special token, [SEP], to mark the end of a sentence, or the separation between two sentences
  2. A special token, [CLS], at the beginning of our text. This token is used for classification tasks, but BERT expects it no matter what your application is.
  3. Tokens that conform with the fixed vocabulary used in BERT
  4. The Token IDs for the tokens, from BERT’s tokenizer
  5. Mask IDs to indicate which elements in the sequence are tokens and which are padding elements
  6. Segment IDs used to distinguish different sentences
  7. Positional Embeddings used to show token position within the sequence

幸运的是,使用tokenizer.encode_plus这个函数可以帮我们处理好一切。但是,由于这只是使用BERT的介绍,因此我们将主要以手动方式执行这些步骤

有关tokenizer.encode_plus这个函数的使用示例,可以这篇文章

2.1 Special Tokens

BERT可以将一个或两个句子作为输入。如果是两个句子,则使用[SEP]将它们分隔,并且[CLS]标记总是出现在文本的开头;如果是一个句子,也始终需要两个标记,此时[SEP]表示句子的结束。举个例子

2个句子的输入:

[CLS] The man went to the store. [SEP] He bought a gallon of milk.

1个句子的输入:

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

2.2 Tokenization

BERT提供了tokenize方法,下面我们看看它是如何处理句子的

text = "Here is the sentence I want embeddings for."
marked_text = "[CLS] " + text + " [SEP]"

# Tokenize our sentence with the BERT tokenizer.
tokenized_text = tokenizer.tokenize(marked_text)

# Print out the tokens.
print (tokenized_text)
# ['[CLS]', 'here', 'is', 'the', 'sentence', 'i', 'want', 'em', '##bed', '##ding', '##s', 'for', '.', '[SEP]']

注意"embeddings"这个词是如何表示的:['em', '##bed', '##ding', '##s']

原始单词已被拆分为较小的子词和字符。这些子词中前面两个##哈希符号表示该子词或字符是较大字的一部分。因此,例如'##bed'和'bed'这两个token不相同;第一个用于子词"bed"出现在较大词中时,第二个是独立的token

为什么会这样?因为BERT的tokenizer是使用WordPiece模型创建的。这个模型贪婪地创建了一个固定大小的词汇表,其中包含了最适合我们语言的固定数量的字符、子词和单词。由于我们BERT模型的tokenizer限制词汇量为30000,因此WordPiece模型生成的词汇表包含所有英文字符以及该模型所训练英语预料库中找到的约30000个最常见的单词和子词。该词汇表包含四类东西:

  1. 整个词
  2. 出现在单词开头或单独出现的子词("embddings"中的"em"与"go get em"中的"em"向量相同)
  3. 不在单词开头的子词,前面会添加上"##"
  4. 单个字符

具体来说,tokenzier首先检查整个单词是否在词汇表中,如果不在,它会尝试将单词分解为词汇表中最大可能的子词,如果子词也没有,它就会将整个单词分解为单个字符。所以我们至少可以将一个单词分解为单子字符的集合。基于此,不在词汇表中的单词不会分配给"UNK"这种万能的标记,而是分解为子词和字符标记

因此,即使"embeddings"这个词不在词汇表中,我们也不会将这个词视为未知词汇,而是将其分为子词tokens ['em', '##bed', '##ding', '##s'],这将保留单词的一些上下文含义。我们甚至可以平均这些子词的嵌入向量以生成原始单词的近似向量。有关WordPeice的更多信息,请参考原论文

下面是我们词汇表中的一些示例

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']

将文本分解为标记后,我们必须将句子转换为词汇索引列表。从这开始,我们将使用下面的例句,其中两个句子都包含"bank"这个词,且它们的含义不同

# Define a new example sentence with multiple meanings of the word "bank"
text = "After stealing money from the bank vault, the bank robber was seen " \
       "fishing on the Mississippi river bank."

# Add the special tokens.
marked_text = "[CLS] " + text + " [SEP]"

# Split the sentence into tokens.
tokenized_text = tokenizer.tokenize(marked_text)

# Map the token strings to their vocabulary indeces.
indexed_tokens = tokenizer.convert_tokens_to_ids(tokenized_text)

# Display the words with their indeces.
for tup in zip(tokenized_text, indexed_tokens):
    print('{:<12} {:>6,}'.format(tup[0], tup[1]))
[CLS]           101
after         2,044
stealing     11,065
money         2,769
from          2,013
the           1,996
bank          2,924
vault        11,632
,             1,010
the           1,996
bank          2,924
robber       27,307
was           2,001
seen          2,464
fishing       5,645
on            2,006
the           1,996
mississippi   5,900
river         2,314
bank          2,924
.             1,012
[SEP]           102

2.3 Segment ID

BERT希望用0和1区分两个句子。也就是说,对于tokenized_text中的每个token,我们必须指明它属于哪个句子。如果是单句,只需要输入一系列1;如果是两个句子,请将第一个句子中的每个单词(包括[SEP])指定为0,第二个句子指定为1

# Mark each of the 22 tokens as belonging to sentence "1".
segments_ids = [1] * len(tokenized_text)

3. Extracting Embeddings

3.1 Running BERT on our text

接下来,我们需要将数据转换为PyTorch tensor类型

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

调用from_pretrained函数将从互联网上获取模型。当我们加载bert-base-uncased时,我们会在logging记录中看到模型的定义。该模型是一个具有12层的深度神经网络,解释每层的功能不在本文的范围内,您可以查看我博客之前的内容来学习相关信息

model.eval()会使得我们的模型处于测试模式,而不是训练模式。在测试模式下,模型将会关闭dropout regularization

# Load pre-trained model (weights)
model = BertModel.from_pretrained('bert-base-uncased',
                                  output_hidden_states = True, # Whether the model returns all hidden-states.
                                  )

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

接下来,让我们把示例文本传入模型,并获取网络的隐藏状态

torch.no_grad()告诉PyTorch在前向传播的过程中不构造计算图(因为我们不会在这里反向传播),这有助于减少内存消耗并加快运行速度

# Run the text through BERT, and collect all of the hidden states produced
# from all 12 layers. 
with torch.no_grad():

    outputs = model(tokens_tensor, segments_tensors)

    # Evaluating the model will return a different number of objects based on 
    # how it's  configured in the `from_pretrained` call earlier. In this case, 
    # becase we set `output_hidden_states = True`, the third item will be the 
    # hidden states from all layers. See the documentation for more details:
    # https://huggingface.co/transformers/model_doc/bert.html#bertmodel
    hidden_states = outputs[2]

3.2 Understanding the Output

hidden_states包含的信息有点复杂,该变量有四个维度,分别是:

  1. The Layer number(13 layers)
  2. The batch number(1 sentence)
  3. The word / token number(22 tokens in our sentence)
  4. The hidden unit / feature number(768 features)

ちょっと待って,13层?前面不是说BERT只有12层吗?因为最前面的一层是Word Embedding层,剩下的是12个Encoder Layer

第二个维度(batch size)是一次向模型提交多个句子时使用的;不过,在这里我们只有一个句子

print ("Number of layers:", len(hidden_states), "  (initial embeddings + 12 BERT layers)")

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

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

token_i = 0
print ("Number of hidden units:", len(hidden_states[layer_i][batch_i][token_i]))
Number of layers: 13   (initial embeddings + 12 BERT layers)
Number of batches: 1
Number of tokens: 22
Number of hidden units: 768

通过快速浏览指定token和网络层的数值范围,您会发现其中大部分值介于[-2, 2],少数在-12附近

# For the 5th token in our sentence, select its feature values from layer 5.
token_i = 5
layer_i = 5
vec = hidden_states[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进行分组

当前的维度:[layers, batchs, tokens, features]

期望的维度:[tokens, layers, features]

幸运的是,PyTorch的permute函数可以轻松的重新排列维度。但是目前hidden_states第一个维度是list,所以我们要先结合各层,使其成为一个tensor

# Concatenate the tensors for all layers. We use `stack` here to
# create a new dimension in the tensor.
token_embeddings = torch.stack(hidden_states, dim=0)

token_embeddings.size()
# torch.Size([13, 1, 22, 768])

接着我们消掉"batch"维度,因为我们不需要它

# Remove dimension 1, the "batches".
token_embeddings = token_embeddings.squeeze(dim=1)

token_embeddings.size()
# torch.Size([13, 22, 768])

最后,我们使用permute函数来交换维度

# Swap dimensions 0 and 1.
token_embeddings = token_embeddings.permute(1,0,2)

token_embeddings.size()
# torch.Size([22, 13, 768])

3.3 Creating word and sentence vectors from hidden states

我们希望为每个词获取单独的向量,或者为整个句子获取单独的向量。但是对于输入的每个词,我们有13个向量,每个向量的长度为768。为了获得单个向量,我们需要将一些层的向量组合起来。但是,哪个层或组合哪些层比较好?

Word Vectors

我们用两种方式创建词向量。第一种方式是拼接最后四层,则每个单词的向量长度为4*768=3072

# Stores the token vectors, with shape [22 x 3,072]
token_vecs_cat = []

# `token_embeddings` is a [22 x 12 x 768] tensor.

# For each token in the sentence...
for token in token_embeddings:
    
    # `token` is a [12 x 768] tensor

    # Concatenate the vectors (that is, append them together) from 
    # the last four layers.
    # Each layer vector is 768 values, so `cat_vec` is length 3072.
    cat_vec = torch.cat((token[-1], token[-2], token[-3], token[-4]), dim=0)
    
    # Use `cat_vec` to represent `token`.
    token_vecs_cat.append(cat_vec)

print ('Shape is: %d x %d' % (len(token_vecs_cat), len(token_vecs_cat[0])))
# Shape is: 22 x 3072

第二种方式是将最后四层相加

# Stores the token vectors, with shape [22 x 768]
token_vecs_sum = []

# `token_embeddings` is a [22 x 12 x 768] tensor.

# For each token in the sentence...
for token in token_embeddings:

    # `token` is a [12 x 768] tensor

    # Sum the vectors from the last four layers.
    sum_vec = torch.sum(token[-4:], dim=0)
    
    # Use `sum_vec` to represent `token`.
    token_vecs_sum.append(sum_vec)

print ('Shape is: %d x %d' % (len(token_vecs_sum), len(token_vecs_sum[0])))
# Shape is: 22 x 768

Sentence Vectors

有很多种策略可以获得一个句子的单个向量表示,其中一种简单的方法是将倒数第2层所有token的向量求平均

# `hidden_states` has shape [13 x 1 x 22 x 768]

# `token_vecs` is a tensor with shape [22 x 768]
token_vecs = hidden_states[-2][0]

# Calculate the average of all 22 token vectors.
sentence_embedding = torch.mean(token_vecs, dim=0)

print("Our final sentence embedding vector of shape:", sentence_embedding.size())
# Our final sentence embedding vector of shape: torch.Size([768])

3.4 Confirming contextually dependent vectors

为了确认这些向量的值是上下文相关的,我们可以检查一下例句中"bank"这个词的向量

“After stealing money from the bank vault, the bank robber was seen fishing on the Mississippi river bank.”

for i, token_str in enumerate(tokenized_text):
    print(i, token_str)
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 5 vector values for each instance of "bank".')
print('')
print("bank vault   ", str(token_vecs_sum[6][:5]))
print("bank robber  ", str(token_vecs_sum[10][:5]))
print("river bank   ", str(token_vecs_sum[19][:5]))
First 5 vector values for each instance of "bank".

bank vault    tensor([ 3.3596, -2.9805, -1.5421,  0.7065,  ...])
bank robber   tensor([ 2.7359, -2.5577, -1.3094,  0.6797,  ...])
river bank    tensor([ 1.5266, -0.8895, -0.5152, -0.9298,  ...])

很明显值不同,但是通过计算向量之间的余弦相似度可以更精确的进行比较

from scipy.spatial.distance import cosine

# Calculate the cosine similarity between the word bank
# in "bank robber" vs "bank vault" (same meaning).
same_bank = 1 - cosine(token_vecs_sum[10], token_vecs_sum[6])

# Calculate the cosine similarity between the word bank 
# in "bank robber" vs "river bank" (different meanings).
diff_bank = 1 - cosine(token_vecs_sum[10], token_vecs_sum[19])

print('Vector similarity for  *similar*  meanings:  %.2f' % same_bank) # 0.94
print('Vector similarity for *different* meanings:  %.2f' % diff_bank) # 0.69

3.5 Pooling Strategy & Layer Choice

BERT Authors

BERT作者通过将不同的向量组合作为输入特征提供给NER任务,并观察所得的F1分数

虽然最后四层拼接在此特定任务上产生了最佳结果,但许多其他方法效果也不差,通常建议针对特定应用测试不同版本,结果可能会有所不同

Han Xiao's BERT-as-service

肖涵在Github上创建了一个名为bert-as-service的开源项目,该项目旨在使用BERT为您的文本创建单词嵌入。他尝试了各种方法来组合这些嵌入,并在项目的FAQ页面上分享了一些结论和基本原理

肖涵的观点认为:

  1. 第一层是嵌入层,由于它没有上下文信息,因此同一个词在不同语境下的向量是相同的
  2. 随着进入网络的更深层次,单词嵌入从每一层中获得了越来越多的上下文信息
  3. 但是,当您接近最后一层时,词嵌入将开始获取BERT特定预训练任务的信息(MLM和NSP)
  4. 倒数第二层的词嵌入比较合理
Archives Tip
QR Code for this page
Tipping QR Code
Leave a Comment

5 Comments
  1. Cassie Cassie

    请问如果一段话有超过两个句子,如何得到这段话每个词的词向量呢?

    1. mathor mathor

      @Cassie不论有几个句号,一个字符串都会被视为一个句子。如果想得到多个句子的句向量,应该将这些句子分别用引号包起来分别作为一个字符串进行输入

    2. Cassie Cassie

      @mathor好的,谢谢博主,我只需要词向量就可以,不用需要句向量

  2. Cassie Cassie

    为什么Bert模型输入的时候只需要tokens_tensor、 segments_tensors啊,不需要position吗

    1. mathor mathor

      @Cassie需要