cqr&ctr:文本匹配的破城长矛

简介

综合转载于:

1. 背景

搜索也好,检索式对话也好,文本是一个很难绕开的话题,虽然语义是一个重要因素,用语义相似度直接梭,但是用户的感知可不是如此,很多用户的感知更多是文本层面的相似要高于语义相似,或者说,遇到语义相似和文本相似的时候会更优先接受文本相似,毕竟文本使用户能直接看到的,当然语义相似度虽好,但是对于没有什么标注数据的情况,也是束手无策吧。

所以,即使语义相似度如火如荼地发展着,文本层面的匹配依旧是项目实践中不可避免的关注点。

2. cqr&ctr概念

cqr和ctr的概念还是比较清晰明确的。

给定query $Q=q_1, q_2, \ldots, q_m$ 和title $T=t_1, t_2, \ldots, t_n$ ,现在计算cqr和ctr。

讲完了,就是这么简单,其实就是看两者交集占query的占比和占title的占比,就是对应的cqr和ctr。

当然,由于这种计算会把所有词的重要性考虑进去,例如“怎么做作业”分别和“怎样做作业”、“怎么做手机”,两个的相似度就一样了,此时就要考虑到给每个词加点权重,这样能更好地描述,这就是一个优化的实用版本,加权

给定query $Q=q_1, q_2, \ldots, q_m$ ,有对应的权重 $W_q=\omega_{q 1}, \omega_{q 2}, \ldots, \omega_{q m}$ 和title $T=t_1, t_2, \ldots, y_n$ ,以及对应权重 $W_t=\omega_{t 1}, \omega_{t 2}, \ldots, \omega_{t n}$ ,现在计算cqr和ctr。

想到可能会有人问到权重怎么来,这里我就要把我的历史文章放出来了,之前是专门讲过词权重的问题的:NLP.TM[20] | 词权重问题

这个应该就是我自己平时用的版本了,而且屡试不爽。

而如果是要分析两个句子综合、无偏的相似度,只要相乘就好了。

3. 细品

可以看到,这个东西很简单,就是一个基于统计计算的工具,但是我依然想仔细讨论一下这个东西。

首先,有关相似度,其实我们很容易想到这个计算方法:

就是比较著名的jaccard相似度,当然还有一个更加出名的方法,那就是BM25(更为常见,此处就不赘述了)。但是我并没有选择,为什么呢,其实核心就是1个点:

query和title的长度信息。

jaccard距离虽然能比较综合、无偏向性地计算两者的相似度,但问题是,当query和title长度计算差距很大的时候,计算准确性就会受到影响,而分成两个指标,则能够充分表现两者的相似性,当然具体用哪种其实还是要看具体场景的,有的时候这种无偏向性对效果优化还是有用的,但是有的时候其实会影响最终效果。

来看个例子,query是“我昨天新买的手机,今天怎么就不能开机了”,title是“手机不能开机”,这里可以,ctr无疑就是1,当然cqr就比较低了,但是我们可以用ctr作为后续的排序特征或者过滤条件。

4. 优缺点

感觉有些东西想说但是没说出来,直接总结一下这个方案的优缺点吧,以便大家进行方案选择吧,这个优点,是相对于常见的语义相似度模型而言的。

首先说优点:

  • 能够体现文本层面的相似度,在一些领域下体验比较好。
  • 性能比语义相似度模型好很,所以是一个简单轻快的模型。
  • 无监督,词权重的话用语料就可以训练了。
  • 效果稳定可追踪。

当然,还是有缺点的。

  • 文本层面的匹配无法体现语义,同义词、说法之类的无法体现。
  • 对切词敏感,类似“充不进去电”和“充电”就完全匹配不上。

这类型的方法,非常适合前期在时间不足时做的baseline,毕竟前期开荒时间上很紧张,各个功能和基础工作需要花很多时间,且数据资源不够,别说训练集了,测试集都很难,此时模型很难做起来。先上cqrctr计算把baseline做好,然后进行深度学习实验,用加权的方式进行融合,然后加入模型中作为特征(在一些场景,尽量还是不要扔掉字面的特征的),整个流程十分顺滑,冷启动速度也比较快。

5. 应用

有这些有缺点,其实我们就可以考虑这个相似度该怎么用了:

  • 用于过滤一些肯定不对的答案。
  • 无标注数据下,这个指标可以作为排序的指标,对启动项目挺重要的。
  • 作为排序特征,保证结果在文本层面还是比较接近的。

当然,在一个比较完整的搜索或者是检索式对话的系统里,其实这种文本相似度类的特征还是非常有收益的,结合语义相似度还是会有一些比较稳定的收益。

6. 代码

终于到了代码环节,我这里直接上代码了,正式代码其实也没几行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import jieba
import numpy as np

class TokenDistance():
def __init__(self, idf_path):
idf_dict = {}
tmp_idx_list = []
with open(idf_path, encoding="utf8") as f:
for line in f:
ll = line.strip().split(" ")
idf_dict[ll[0]] = float(ll[1])
tmp_idx_list.append(float(ll[1]))
self._idf_dict = idf_dict
self._median_idf = np.median(tmp_idx_list)

def predict_jaccard(self, q1, q2):
# jaccard距离,根据idf加权
if len(q1) < 1 or len(q2) < 1:
return 0

q1 = set(list(jieba.cut(q1)))
q2 = set(list(jieba.cut(q2)))
print(q1.intersection(q2))
print(q1.union(q2))

numerator = sum([self._idf_dict.get(word, self._median_idf) for word in q1.intersection(q2)])
denominator = sum([self._idf_dict.get(word, self._median_idf) for word in q1.union(q2)])
return numerator / denominator

def predict_left(self, q1, q2):
# 单向相似度,分母为q1,根据idf加权
if len(q1) < 1 or len(q2) < 1:
return 0

q1 = set(list(jieba.cut(q1)))
q2 = set(list(jieba.cut(q2)))

numerator = sum([self._idf_dict.get(word, self._median_idf) for word in q1.intersection(q2)])
denominator = sum([self._idf_dict.get(word, self._median_idf) for word in q1])
return numerator / denominator

def predict_cqrctr(self, q1, q2):
# cqr*ctr
if len(q1) < 1 or len(q2) < 1:
return 0

cqr = self.predict_left(q1, q2)
ctr = self.predict_left(q2, q1)

return cqr * ctr

if __name__ == "__main__":
import sys
q1 = sys.argv[1]
q2 = sys.argv[2]

token_distance = TokenDistance("./data/idf.txt")
print(q1, q2)
print(token_distance.predict_jaccard(q1, q2))
print(token_distance.predict_left(q1, q2))
print(token_distance.predict_cqrctr(q1, q2))

说明:

  • 此处的加权,用的jieba的idf.txt,直接加载成dict就能查了。
  • 对于未登录词,词权重词典里没有的,一般用整个词典的中位数来计算。
  • 这里附上jaccard距离,和ctr、cqr不同的是,他的分母用的是q1和q2的并集,而不只是q1或者q2本身。
  • 因为cqr和ctr本质上只是分母的选择不同,所以我写成一个函数,要把谁做分母,就把谁放q1的位置就行。
  • cqrctr的计算,其实就是把两者相乘,这个是比较简单的。
一分一毛,也是心意。