在NLP领域比较重要的就是语义相似度计算,可用于非常多方面的应用,比如搜索、智能问答系统、多轮对话、基于内容的推荐系统召回模块等。能够在语义相似度任务这些领域会有巨大提升。
像搜索领域中用到的elasticsearch分布式高性能搜索工具中用到的BM25算法,是通过词频和逆文档形成的稀疏矩阵来计算相似度。这种方法没有考虑到句子之间的语义关系,只是考虑到词频带来的影响。BM25是tf-idf的改进版,考虑到词频无上限增长问题,来抑制词频过大带来的影响,还使用了文档归一化,将句子归一化到平均长度,减弱句子长度带来的扰动。但是这种算法也适用于信息召回,并不能识别到搜索的重要程度。最后肯定还是需要对于搜索内容进行一个排序。
在工业界领域中像有监督的训练数据获取困难,往往需要标注很多数据才能使相似度效果变佳。人工标注的成本也是很高,在一部分中小公司对于不太愿意来承担这一部分的成本。这时候无监督的句向量生成就比较适用了。无监督句向量生成不仅仅是在训练时候只需要cpu即可完成,而且在计算的时候只用cpu也是很快能够计算完成。
有监督算法比较依赖于标注数据,但是标注数据需要大量人工成本,实现语义相似度计算的模型也有很多,比如孪生网络、DSSM算法、Bert等。是Bert做语义相似度有几种做法,比如将要对比的句子拼接在一起输入Bert然后接一层sigmoid激活函数的全连接或者将Bert进行孪生网络的方式使用孪生Bert,然后权重共享等操作来实现。不过一般用比较多的可能是前两种。非常不推荐直接使用Bert的句向量来做余弦相似度计算,效果非常差,具体原因我后面再写篇文章来分析分析,这里就不做过多的赘述了。
无监督算法现在是有几种方法比如通过词向量直接相加平均、词向量通过tf-idf进行加权、词向量使用sif算法加权等。词向量直接相加平均没有考虑到训练样本中词频带来的影响和句子长度带来的影响。tf-idf加权却是考虑到词频带来的影响,但是效果提升不是特别大。2016年提出来SIF加权算法,是当时顶会最好的一篇无监督句向量生成的算法,使用随机游走来推算句子的生成概率。我今天要讲的也就是他的改进版USIF算法,是2018年ACL顶会最好的一篇无监督句向量生成算法。
USIF算法和SIF算法思想上差不了太多,如果没看过SIF算法的朋友可以去看一下SIF算法。都是通过随机游走来推算出句向量生成的一个概率公式,最后化简成一个加权的公式。
这是USIF论文的名字,感兴趣的可以直接去看看原论文。我接下来讲解下这篇论文。
一、加权公式推导
论文中通过随机游走对于单词w在t时刻产生的概率由对数线性模型给出一个公式
假设句向量ct在句子生成的整个过程中变化不大,将贯穿所有时间步的句向量序列{ct}替换为单个句向量cs。
则给出一个句子向量cs生成一个单词w的概率为
由于Z不能计算,a是未知的,a是一个需要调优的超参数。这种加权方案称为平滑逆频率(SIF),并对更频繁的单词给予较低的权重。使用语料库中所有{
这是SIF的一个思想,USIF也是根据这个思想进行改进。
有两个x和y两个比较少出现的词,而且x和y词语义不相似,其中x,y是非停用词(即那些具有不可忽视的重要性的词),将词向量
分解可以得到上式,带入SIF的加权函数中可以得到下式:
我们对于
将结果带入公式1可以得到公式10
为了解决单词向量长度的混淆效应,论文提出了一种随机漫步模型,其中在t时刻观察到单词w的概率,t时刻句子向量
因此,角距离也可以解释为L2归一化词向量与L2归一化话语向量在单位范围上的最短路径长度。因为角距离范围是
因为时间步句子向量变化不大,公式11带入公式2中可得公式12:
我们将某些句子的嵌入定义为生成句子的句子向量的映射估计。假设对可能的句子有一致的先验,映射估计也是对句子的最大似然估计。句子的对数似然值为:
为了使对数似然函数最大化,使用泰勒一阶展开式逼近。
公式15在单位范围可近似于得到公式16:
a是单词w偶然产生的概率,而不是由句子向量产生的概率。为了估计a,我们首先考虑一个句子向量cs在随机行走的n步中至少产生一个随机单词w的概率
Z和α都和词表的数量有关,则在单位范围上,潜在句子矢量与单词矢量之间的期望距离为
根据上面式子可以推算公式21:
二、减去句子向量投影上的主成分
在SIF算法中提到减去句子第一主成分上的投影,可以减去训练句子中组成的共性表达,提高句子的语义表达能力。这篇USIF算法在此基础上进行改进,理论上去除更多的主成分会增加表达。作者实验中发现超过5的奇异向量不能解释更多的方差。所以论文去除了前五的主成分,并且使用算出来的奇异值进行奇异向量的加权。
句子向量减去前五的主成分公式:
注意事项:
在计算语义相似度之前先要算出来训练数据中所有句子一起的截断奇异值分解模型,便于在后续语义相似度中应用。
三、USIF算法的代码实现
#*- coding:utf-8 -*- coding
import json
import numpy as np
from sklearn.decomposition import TruncatedSVD
from numba import jit
from gensim.models import KeyedVectors
import pandas as pd
import joblib
class word2prob(object):
def __init__(self,word_count_file_path):
file_path = word_count_file_path
with open(file_path, 'r',encoding='utf-8') as f:
self.prob = json.load(f)
total = sum(self.prob.values())
self.prob = {k: (self.prob[k] / total) for k in self.prob}
self.min_prob = min(self.prob.values())
self.count = total
def __getitem__(self, w):
return self.prob.get(w.lower(), self.min_prob)
def __contains__(self, w):
return w.lower() in self.prob
def __len__(self):
return len(self.prob)
def vocab(self):
return iter(self.prob.keys())
class word2vec(object):
def __init__(self,embedding_path):
self.embedding_path = embedding_path
vectors = KeyedVectors.load(embedding_path, mmap='r+') #Vectors(name=embedding_path)
self.vectors = vectors
def __getitem__(self, w):
return self.vectors[w]
def __contains__(self, w):
return w in self.vectors
class uSIF(object):
def __init__(self, vec, prob, n=11, m=5):
self.vec = vec
self.m = m
if not (isinstance(n, int) and n > 0):
raise TypeError("n should be a positive integer")
vocab_size = float(len(prob))
threshold = 1 - (1 - 1 / vocab_size) ** n
alpha = len([w for w in prob.vocab() if prob[w] > threshold]) / vocab_size
Z = 0.5 * vocab_size
self.a = (1 - alpha) / (alpha * Z)
self.weight = lambda word: (self.a / (0.5 * self.a + prob[word]))
@jit
def _to_vec(self, sentence):
tokens = sentence.split()
#v_t = np.zeros(300)
if tokens == []:
return np.zeros(300) + self.a
else:
# for i in tokens:
# v_t +=self.vec[i]*self.weight(i)/len(tokens)
# return v_t
v_t = np.array([self.vec[t] for t in tokens])
v_t = v_t * (1.0 / np.linalg.norm(v_t, axis=0))
v_t = np.array([self.weight(t) * v_t[i, :] for i, t in enumerate(tokens)])
return np.mean(v_t, axis=0)
def embed(self, sentences):
sentences_vectors = self._to_vec(sentences).reshape(1,-1)
#sentences_vectors = [self._to_vec(s) for s in sentences.split()]
if self.m == 0:
return sentences_vectors
proj = lambda a, b: a.dot(b.transpose())*b
#svd = TruncatedSVD(n_components=self.m, n_iter=7,random_state=0).fit(sentences_vectors)
svd = joblib.load(filename="E:/微信文本语料/trigram/svd_n5.pkl")
for i in range(self.m):
lambda_i = (svd.singular_values_[i] ** 2) / (svd.singular_values_ ** 2).sum()
pc = svd.components_[i]
sentences_vectors = [v_s - lambda_i*proj(v_s, pc) for v_s in sentences_vectors]
return np.array(sentences_vectors)
四、使用USIF算法给语义相似度提升
我在公司实际项目中应用了USIF算法,并且使用百度千言数据集的训练集作为评测数据。
在无监督句子表征上USIF虽然能够带来提升,但是还是需要对于词向量训练有着很高的要求,我单使用词向量加权平均可以到达MAP 0.9左右,语料处理和词向量模型和参数调节。我这里使用的是FastText无监督训练词向量,FastText的子词拼接可以一定程度上避免OOV情况产生。
具体的代码和评测数据,过一段时间会放在GitHub上,并在文章中插入链接。