背景

Sentence-BERT是对句子进行向量表示的一项经典工作,论文延伸出来的sentence-transformers 项目,在GitHub上已经收获了8.1k个star,今天重读下论文。

Introduction

句子的向量表示,也就是sentence embedding,是利用神经网络对句子进行编码,得到的固定长度向量,我们希望这个向量包含了句子的”语义信息“:

句子向量表示

句子向量可以应用于NLP领域的方方面面,我们暂时将目光聚焦到文本语义相似度检测 (semantic textual similarity, STS )任务上:给定两个句子,判断两个句子在语义层面的相似程度,相似程度可以是连续值([0, 1])也可以是离散值 (0-5)。

前BERT时代有不少出彩的工作,咱们就先略去不表了,直接看BERT是怎么做的,本身BERT模型的输入就包含两个序列,所以天然适合处理STS任务,将两个句子拼接:

[CLS] sentence 1 [SEP] sentence 2 [SEP]

直接作为BERT的输入,然后取最后一层的[CLS]向量或者所有token向量的mean/max啥的,再接一个简单的MLP即可。剩下的就是找个数据集进行fine-tune吧。

我们将这种方式称为"Cross-Encoder",因为两个句子的token可以交互,有利于学习到句子对之间的相似性。

如果你的的任务也像STS这样,句子对(sentence pair)的关系已经固定了,只需要判断句子对的关系(比如相似程度),那么Cross-Encoder非常适合你,但是,如果你的任务是从\( N \)个句子中找出最相似的两个句子,或者找出和句子\( q \)最相似的句子,那么Cross-Encoder就面临一个计算量的问题。

\( N \) 个句子两两组合,有多少种情况? \( \frac{N\cdot (N-1)}{2} \)

  • 如果\( N = 10\),结果是45
  • 如果\( N = 100\),结果是4950
  • 如果\( N = 1000\),结果是49995000
  • ……

实际的业务场景中,几十万上百万的句子都算少的,好家伙,这计算量着实有点难顶啊。

can you 顶得住?

还是让我们回到sentence embedding这个更泛化的问题上来,如果现在有一个NBERT模型,能够得到高质量的句子向量表示,那么面对STS任务,我们就可以先将"sentence 1"作为BERT输入,得到向量"vector 1",再将"sentence 2"作为BERT输入,得到向量"vector 2",然后计算两个向量之间的相似度。

现在的计算量是多少呢,\( N \)个句子只需要\( N \)次前向计算,当然如果是找出最相似的两个句子,还需要\( \frac{N\cdot (N-1)}{2} \) 次向量计算,但是向量计算(比如点积)的成本可以BERT前向计算少太多了。

我们将这种方式称为"Bi-Encoder",

Bi-Encoder vs Cross-Encoder

但是如果直接拿BERT来做,Bi-Encoder的效果要比Cross-Encoder差,甚至可能差很多,再甚至可能比average GloVe embedding都差。毕竟没有经过fine-tune嘛,那么如何fine-tune?下面就开始介绍Sentence-BERT。

Sentence-BERT (SBERT)

如何对BERT fine-tune,才能得到高质量的句子向量?我们要思考的就是,如何将这个问题转化为可学习的机器学习/NLP任务,这种问题转化/任务定义的能力真正体现出了算法工程师的功力。而任务定义又可以体现在损失函数的设计上。

想一下,现在有一个句子"sentence 1",经过SBERT编码后得到向量"vector 1",怎样评判"vector 1"的好坏呢?这里的“好”指的是能表示句子的语义。

最简单的,如果有一个标签就好了,就像文本分类任务那样,但是仔细一想,标签往往是一个数字,数字又怎么表示句子的语义呢?看起来不太可能,先pass掉。

可能有聪明的同学想到了,这个标签可以不是句子语义,而是文本类别啊,我们就用文本分类数据集来fine-tune,用"vector 1"来预测类别,这样感觉也能得到质量不错的句子向量,这个思路听起来也合理,不过有点问题,同一个类别下的句子向量怎么区分呢?

比如两句话: “我永远支持AC米兰"和"梅西的盘带让人眼花缭乱”,都可以归属为"足球"类别,但是二者的意思可大不相同,向量可能在分类中很好用,但是未必能体现出句子的语义,因为损失函数中缺少了让模型去学习区分同一类别下的不同句子

对比学习

对于上面碰到的问题,一种方法是用对比学习(Contrastive Learning),假设有三个句子\( s_{1}, s_{2}, s_{3} \),对应的向量分别是\(v_{1}, v_{2}, v_{3} \),句子类别分别是\(c_{1}, c_{2}, c_{3} \) ,我们不仅希望能通过\(v\)学习到\(c\),还希望\(v_{1}\)和\(v_{2}\)的距离小于\(v_{1}\) 和\(v_{3}\) ,也就是\(Dis(v_{1}, v_{2}) \leq Dis(v_{1}, v_{3}) \),我们可以用triplet loss来表示,

\[max(||v_{1} - v_{2}|| - ||v_{1} - v_{3}|| + \epsilon, 0)\]

实际情况中,让模型去区分\(v_{1}\) 、\(v_{2}\) 和 \(v_{3}\) 可比学习 \(v –> c\) 的映射难多了,所以只需要使用triplet loss就能得到效果不错的句子向量了。

原来,Sentence-BERT就是通过对比学习来fine-tune啊。

对比学习的思想很简单,难点就是如何找到合适的三元组(\(s_{a}, s_{p}, s_{n}\) )来训练模型,其中\(s_{p}\) 和 \(s_{a}\) 属于同一个"类别",被称为正样本, \(s_{n}\) 和 \(s_{a}\) 不是同一个"类别",被称为负样本,这里的类别是广义上的类别,可以是文本分类的类别,甚至也可以是每个句子单独对应一个类别(instance classification),典型的工作是恺明大神的MoCo 系列。

其他策略

如果是针对STS任务,已经有了句子对相似程度的标注数据,其实没必要用对比学习,在拿到两个句子的向量 \(u\) 和 \(v\) 之后,如果句子对的标签是离散数值,作者设计了如下的网络结构:

\[o = softmax(W_{t}(u, v, |u-v|))\]

然后就是用交叉熵损失函数来解决这个分类任务。

如果句子对的标签是连续数值,直接对 \(u\) 和 \(v\) 进行点积操作,使用MSE。

再提一下,如何得到句子向量 \(u\) ,SBERT并没有做创新,老方法:

  1. [CLS]对应的embedding
  2. 所有token embedding的mean
  3. 所有token embedding各个维度的max value

总结

  • 为了得到高质量的句子向量,SBERT在不同任务上使用了不同的fine-tune策略,在句子对类型的任务上可以直接使用作者设计的简单有效的子网络结构,如果数据集较小,你甚至可以直接用Cross-Encoder :) 但更具推广意义的是,引入了对比学习方法。

  • 标题中的siamese network(孪生网络),文章里我没有提到,无非是多进行几次forward再计算梯度而已,感谢现在的深度学习框架吧,让自动微分如此简单。

引用

[1] Sentence-BERT论文,https://arxiv.org/abs/1908.10084

[2] sentence-transformers项目,GitHub - UKPLab/sentence-transformers: Multilingual Sentence & Image Embeddings with BERT

[3] Cross-Encoders,Cross-Encoders - Sentence-Transformers documentation

[4] Bi-Encoders, Bi-Encoders - Sentence-Transformers documentation

[5] Dimensionality Reduction by Learning an Invariant Mapping, http://yann.lecun.com/exdb/publis/pdf/hadsell-chopra-lecun-06.pdf

[6] MoCo