【NLP】文本相似度的BERT度量方法

作者 | James Briggs

编译 | VK
来源 | Towards Data Science

这篇文章讨论的是关于BERT的序列相似性。

NLP的很大一部分依赖于高维空间中的相似性。通常,一个NLP解决方案需要一些文本,处理这些文本来创建一个大的向量/数组来表示该文本。

这是高维的魔法。

句子的相似性是一个最清楚的例子,说明了高维魔法是多么强大。

逻辑是这样的:

  • 把一个句子,转换成一个向量。

  • 把其他许多句子,转换成向量。

  • 找出它们之间的距离(欧几里德)或余弦相似性。

  • 我们现在就有了一个句子间语义相似性的度量!

当然,我们希望更详细地了解正在发生的事情,并用Python实现它!所以,让我们开始吧。


BERT

BERT,正如我们已经提到的,是NLP的MVP。其中很大一部分归功于BERT将单词的意思嵌入到密集向量的能力。

我们称之为密集向量,因为向量中的每个值都有一个值,并且有一个成为该值的原因-这与稀疏向量相反,例如one-hot编码向量,其中大多数值为0。

BERT擅长创建这些密集向量,每个编码器层输出一组密集向量。

对于BERT-base,这将是一个包含768维的向量,这768个值包含我们对单个token的数字表示,我们可以使用它作为上下文词嵌入。

我们可以把这些张量转换成输入序列的语义表示。然后,我们可以采用相似性度量并计算不同序列之间的相似性。

最简单和最常用的提取张量是最后的隐藏状态。

当然,这是一个相当大的张量,是512x768维,因为有512个token,我们需要一个向量来应用我们的相似性度量。

要做到这一点,我们需要把最后一个隐藏态张量转换成768维的向量。

创建向量

为了把最后一个隐藏态张量转换成向量,我们使用了平均池运算。

这512个token中的每一个都有各自的768个值。这个池操作将取所有token嵌入的平均值,并将它们压缩到一个768向量空间中,从而创建一个“句子向量”。

我们不需要考虑填充token(我们不应该包括它)。


代码

这是理论和逻辑-但我们如何在现实中应用这一点?

我们将概述两种方法-简单方法和稍微复杂一点的方法。

简单—Sentence-Transformers

对于我们来说,实现我们刚刚介绍的所有内容的最简单方法是通过Sentence-Transformers库——它将这个过程的大部分内容封装成几行代码。

首先,我们使用pip install sentence-transformers来安装sentence-transformers。这个库使用HuggingFace的Transformer,所以我们可以在这里找到 sentence-transformers模型:https://huggingface.co/sentence-transformers

我们将使用bert-base-nli-mean-tokens模型,它实现了我们到目前为止讨论的相同逻辑。

(它还使用128个输入token,而不是512个)。

让我们创建一些句子,初始化我们的模型,并对句子进行编码:

Write a few sentences to encode (sentences 0 and 2 are both similar):
sentences = [
    "Three years later, the coffin was still full of Jello.",
    "The fish dreamed of escaping the fishbowl and into the toilet where he saw his friend go.",
    "The person box was packed with jelly many dozens of months later.",
    "He found a leprechaun in his walnut shell."
]
Initialize our model:
from sentence_transformers import SentenceTransformer

model = SentenceTransformer('bert-base-nli-mean-tokens')
HBox(children=(HTML(value=''), FloatProgress(value=0.0, max=405234788.0), HTML(value='')))

Encode the sentences:
sentence_embeddings = model.encode(sentences)
sentence_embeddings.shape
(4, 768)

很好,我们现在有四个句子嵌入-每个包含768维。

现在我们要做的是取这些嵌入,找出它们之间的余弦相似性。所以对于第0句:

Three years later, the coffin was still full of Jello.

我们可以通过以下方法找到最相似的句子:

from sklearn.metrics.pairwise import cosine_similarity
让我们计算第0句的余弦相似度:
cosine_similarity(
    [sentence_embeddings[0]],
    sentence_embeddings[1:]
)
array([[0.33088642, 0.7218851 , 0.55473834]], dtype=float32)
这些相似之处可以解释为:
IndexSentenceSimilarity
1"The fish dreamed of escaping the fishbowl and into the toilet where he saw his friend go."0.3309
2"The person box was packed with jelly many dozens of months later."0.7219
3"He found a leprechaun in his walnut shell."0.5547
复杂-Transformer和PyTorch

在进入第二种方法之前,值得注意的是,它与第一种方法做了相同的事情,但有点复杂。

使用这种方法,我们需要自己创建句子嵌入。为此,我们执行平均池操作。

https://youtu.be/jVPd7lEvjtg

此外,在平均池操作之前,我们需要创建last_hidden_state,如下所示:

from transformers import AutoTokenizer, AutoModel
import torch
First we initialize our model and tokenizer:
tokenizer = AutoTokenizer.from_pretrained('sentence-transformers/bert-base-nli-mean-tokens')
model = AutoModel.from_pretrained('sentence-transformers/bert-base-nli-mean-tokens')
Then we tokenize the sentences just as before:
sentences = [
    "Three years later, the coffin was still full of Jello.",
    "The fish dreamed of escaping the fishbowl and into the toilet where he saw his friend go.",
    "The person box was packed with jelly many dozens of months later.",
    "He found a leprechaun in his walnut shell."
]

# 初始化字典来存储
tokens = {'input_ids': [], 'attention_mask': []}

for sentence in sentences:
    # 编码每个句子并添加到字典
    new_tokens = tokenizer.encode_plus(sentence, max_length=128,
                                       truncation=True, padding='max_length',
                                       return_tensors='pt')
    tokens['input_ids'].append(new_tokens['input_ids'][0])
    tokens['attention_mask'].append(new_tokens['attention_mask'][0])

# 将张量列表重新格式化为一个张量
tokens['input_ids'] = torch.stack(tokens['input_ids'])
tokens['attention_mask'] = torch.stack(tokens['attention_mask'])
We process these tokens through our model:
outputs = model(**tokens)
outputs.keys()
odict_keys(['last_hidden_state', 'pooler_output'])

The dense vector representations of our text are contained within the outputs 'last_hidden_state' tensor, which we access like so:
embeddings = outputs.last_hidden_state
embeddings
tensor([[[-0.0692,  0.6230,  0.0354,  ...,  0.8033,  1.6314,  0.3281],
         [ 0.0367,  0.6842,  0.1946,  ...,  0.0848,  1.4747, -0.3008],
         [-0.0121,  0.6543, -0.0727,  ..., -0.0326,  1.7717, -0.6812],
         ...,
         [ 0.1953,  1.1085,  0.3390,  ...,  1.2826,  1.0114, -0.0728],
         [ 0.0902,  1.0288,  0.3297,  ...,  1.2940,  0.9865, -0.1113],
         [ 0.1240,  0.9737,  0.3933,  ...,  1.1359,  0.8768, -0.1043]],

        [[-0.3212,  0.8251,  1.0554,  ..., -0.1855,  0.1517,  0.3937],
         [-0.7146,  1.0297,  1.1217,  ...,  0.0331,  0.2382, -0.1563],
         [-0.2352,  1.1353,  0.8594,  ..., -0.4310, -0.0272, -0.2968],
         ...,
         [-0.5400,  0.3236,  0.7839,  ...,  0.0022, -0.2994,  0.2659],
         [-0.5643,  0.3187,  0.9576,  ...,  0.0342, -0.3030,  0.1878],
         [-0.5172,  0.3599,  0.9336,  ...,  0.0243, -0.2232,  0.1672]],

        [[-0.7576,  0.8399, -0.3792,  ...,  0.1271,  1.2514,  0.1365],
         [-0.6591,  0.7613, -0.4662,  ...,  0.2259,  1.1289, -0.3611],
         [-0.9007,  0.6791, -0.3778,  ...,  0.1142,  0.9080, -0.1830],
         ...,
         [-0.2158,  0.5463,  0.3117,  ...,  0.1802,  0.7169, -0.0672],
         [-0.3092,  0.4833,  0.3021,  ...,  0.2289,  0.6656, -0.0932],
         [-0.2940,  0.4678,  0.3095,  ...,  0.2782,  0.5144, -0.1021]],

        [[-0.2362,  0.8551, -0.8040,  ...,  0.6122,  0.3003, -0.1492],
         [-0.0868,  0.9531, -0.6419,  ...,  0.7867,  0.2960, -0.7350],
         [-0.3016,  1.0148, -0.3380,  ...,  0.8634,  0.0463, -0.3623],
         ...,
         [-0.1090,  0.6320, -0.8433,  ...,  0.7485,  0.1025,  0.0149],
         [ 0.0072,  0.7347, -0.7689,  ...,  0.6064,  0.1287,  0.0331],
         [-0.1108,  0.7605, -0.4447,  ...,  0.6719,  0.1059, -0.0034]]],
       grad_fn=<NativeLayerNormBackward>)
embeddings.shape
torch.Size([4, 128, 768])

在生成密集向量嵌入之后,我们需要执行平均池操作来创建单个向量编码(句子嵌入)。

为了实现这个平均池操作,我们需要将嵌入张量中的每个值乘以其各自的掩码值,这样我们就可以忽略非实数token。

To perform this operation, we first resize our attention_mask tensor:
attention_mask = tokens['attention_mask']
attention_mask.shape
torch.Size([4, 128])
mask = attention_mask.unsqueeze(-1).expand(embeddings.size()).float()
mask.shape
torch.Size([4, 128, 768])
mask
tensor([[[1., 1., 1.,  ..., 1., 1., 1.],
         [1., 1., 1.,  ..., 1., 1., 1.],
         [1., 1., 1.,  ..., 1., 1., 1.],
         ...,
         [0., 0., 0.,  ..., 0., 0., 0.],
         [0., 0., 0.,  ..., 0., 0., 0.],
         [0., 0., 0.,  ..., 0., 0., 0.]],

        [[1., 1., 1.,  ..., 1., 1., 1.],
         [1., 1., 1.,  ..., 1., 1., 1.],
         [1., 1., 1.,  ..., 1., 1., 1.],
         ...,
         [0., 0., 0.,  ..., 0., 0., 0.],
         [0., 0., 0.,  ..., 0., 0., 0.],
         [0., 0., 0.,  ..., 0., 0., 0.]],

        [[1., 1., 1.,  ..., 1., 1., 1.],
         [1., 1., 1.,  ..., 1., 1., 1.],
         [1., 1., 1.,  ..., 1., 1., 1.],
         ...,
         [0., 0., 0.,  ..., 0., 0., 0.],
         [0., 0., 0.,  ..., 0., 0., 0.],
         [0., 0., 0.,  ..., 0., 0., 0.]],

        [[1., 1., 1.,  ..., 1., 1., 1.],
         [1., 1., 1.,  ..., 1., 1., 1.],
         [1., 1., 1.,  ..., 1., 1., 1.],
         ...,
         [0., 0., 0.,  ..., 0., 0., 0.],
         [0., 0., 0.,  ..., 0., 0., 0.],
         [0., 0., 0.,  ..., 0., 0., 0.]]])

上面的每个向量表示一个单独token的掩码——现在每个token都有一个大小为768的向量,表示它的attention_mask状态。然后将两个张量相乘:
masked_embeddings = embeddings * mask
masked_embeddings.shape
torch.Size([4, 128, 768])
masked_embeddings
tensor([[[-0.0692,  0.6230,  0.0354,  ...,  0.8033,  1.6314,  0.3281],
         [ 0.0367,  0.6842,  0.1946,  ...,  0.0848,  1.4747, -0.3008],
         [-0.0121,  0.6543, -0.0727,  ..., -0.0326,  1.7717, -0.6812],
         ...,
         [ 0.0000,  0.0000,  0.0000,  ...,  0.0000,  0.0000, -0.0000],
         [ 0.0000,  0.0000,  0.0000,  ...,  0.0000,  0.0000, -0.0000],
         [ 0.0000,  0.0000,  0.0000,  ...,  0.0000,  0.0000, -0.0000]],

        [[-0.3212,  0.8251,  1.0554,  ..., -0.1855,  0.1517,  0.3937],
         [-0.7146,  1.0297,  1.1217,  ...,  0.0331,  0.2382, -0.1563],
         [-0.2352,  1.1353,  0.8594,  ..., -0.4310, -0.0272, -0.2968],
         ...,
         [-0.0000,  0.0000,  0.0000,  ...,  0.0000, -0.0000,  0.0000],
         [-0.0000,  0.0000,  0.0000,  ...,  0.0000, -0.0000,  0.0000],
         [-0.0000,  0.0000,  0.0000,  ...,  0.0000, -0.0000,  0.0000]],

        [[-0.7576,  0.8399, -0.3792,  ...,  0.1271,  1.2514,  0.1365],
         [-0.6591,  0.7613, -0.4662,  ...,  0.2259,  1.1289, -0.3611],
         [-0.9007,  0.6791, -0.3778,  ...,  0.1142,  0.9080, -0.1830],
         ...,
         [-0.0000,  0.0000,  0.0000,  ...,  0.0000,  0.0000, -0.0000],
         [-0.0000,  0.0000,  0.0000,  ...,  0.0000,  0.0000, -0.0000],
         [-0.0000,  0.0000,  0.0000,  ...,  0.0000,  0.0000, -0.0000]],

        [[-0.2362,  0.8551, -0.8040,  ...,  0.6122,  0.3003, -0.1492],
         [-0.0868,  0.9531, -0.6419,  ...,  0.7867,  0.2960, -0.7350],
         [-0.3016,  1.0148, -0.3380,  ...,  0.8634,  0.0463, -0.3623],
         ...,
         [-0.0000,  0.0000, -0.0000,  ...,  0.0000,  0.0000,  0.0000],
         [ 0.0000,  0.0000, -0.0000,  ...,  0.0000,  0.0000,  0.0000],
         [-0.0000,  0.0000, -0.0000,  ...,  0.0000,  0.0000, -0.0000]]],
       grad_fn=<MulBackward0>)

然后我们沿着轴1将剩余的嵌入项求和:
summed = torch.sum(masked_embeddings, 1)
summed.shape
torch.Size([4, 768])

然后将张量的每个位置上的值相加:
summed_mask = torch.clamp(mask.sum(1), min=1e-9)
summed_mask.shape
torch.Size([4, 768])
summed_mask
tensor([[15., 15., 15.,  ..., 15., 15., 15.],
        [22., 22., 22.,  ..., 22., 22., 22.],
        [15., 15., 15.,  ..., 15., 15., 15.],
        [14., 14., 14.,  ..., 14., 14., 14.]])

最后,我们计算平均值:
mean_pooled = summed / summed_mask
mean_pooled
tensor([[ 0.0745,  0.8637,  0.1795,  ...,  0.7734,  1.7247, -0.1803],
        [-0.3715,  0.9729,  1.0840,  ..., -0.2552, -0.2759,  0.0358],
        [-0.5030,  0.7950, -0.1240,  ...,  0.1441,  0.9704, -0.1791],
        [-0.2131,  1.0175, -0.8833,  ...,  0.7371,  0.1947, -0.3011]],
       grad_fn=<DivBackward0>)

一旦我们有了密集向量,我们就可以计算每个向量之间的余弦相似性——这和我们以前使用的逻辑是一样的:

from sklearn.metrics.pairwise import cosine_similarity
让我们计算第0句的余弦相似度:
# 将PyTorch张量转换为numpy数组
mean_pooled = mean_pooled.detach().numpy()

# 计算
cosine_similarity(
    [mean_pooled[0]],
    mean_pooled[1:]
)
array([[0.33088905, 0.7219259 , 0.55483633]], dtype=float32)

These similarities translate to:
IndexSentenceSimilarity
1"The fish dreamed of escaping the fishbowl and into the toilet where he saw his friend go."0.3309
2"The person box was packed with jelly many dozens of months later."0.7219
3"He found a leprechaun in his walnut shell."0.5548

我们返回了几乎相同的结果-唯一的区别是索引3的余弦相似性从0.5547移到了0.5548,这是一个微小的差异。


以上就是介绍如何使用BERT测量句子的语义相似性的全部内容—使用sentence-transformers ,PyTorch和transformers两种方法实现。

两种方法的完整笔记本:https://github.com/jamescalam/transformers/blob/main/course/similarity/04_sentence_transformers.ipynb和https://github.com/jamescalam/transformers/blob/main/course/similarity/03_calculating_similarity.ipynb。

感谢阅读!

参考引用

N. Reimers, I. Gurevych, Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks (2019), Proceedings of the 2019 Conference on Empirical Methods in NLP


往期精彩回顾



适合初学者入门人工智能的路线及资料下载机器学习及深度学习笔记等资料打印机器学习在线手册深度学习笔记专辑《统计学习方法》的代码复现专辑
AI基础下载机器学习的数学基础专辑黄海广老师《机器学习课程》课件合集
本站qq群851320808,加入微信群请扫码:

  • 3
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值