0. 引言
故事起源于工作的一个实际问题,要分析两个文本序列间的相似性,然后就想着干脆把一些常见的字符串相似性内容一并整理一下好了。
于是就大概写了一下这篇文章,大致涵盖了我所知的全部字符串相似度比较的方法,大致包括:
- 汉明距离
- 最长公共子串
- 编辑距离
- jaccard距离
- bleu & rouge & ……
- ……
下面,我们来一个个考察一些这些内容。
1. 汉明距离
汉明距离(Hamming Distance)算是计算文本相似度的最简单的方式,他考察的是等长的字符串之间的距离,其具体定义就是两字符串之间不相同字符的个数。
我们可以快速地给出hamming距离的计算函数如下:
def hamming_distance(s1, s2):
return len([1 for c1, c2 in zip(s1, s2) if c1 == c2])
显然,hamming距离的算法复杂度就只有 O ( N ) O(N) O(N)而已。
2. 最长公共子序列
最长公共子序列(longest common subsequence)也是常用的一种用于评估两段文本间相似度的方法。故名思意,他就是求取两个字符串之间最长的共有子序列长度。
因此,显而易见的,较之汉明距离,他不受句长限制,允许两字符串不同长度,但是它受到顺序的影响,当两个句子意思大致相同但是有两个子串位置相反时,就会导致问题,比如不但...而且...
这样的内容。
最长公共子串的求取算法同样是一个经典的动态规划算法问题,它的递推公式可以很轻松的表达为:
d p ( i , j ) = { m a x ( d p ( i + 1 , j ) , d p ( i , j + 1 ) ) if s1[i] != s2[j] d p ( i + 1 , j + 1 ) + 1 if s1[i] == s2[j] dp(i, j)= \begin{cases} max(dp(i+1, j), dp(i, j+1)) & \text{if s1[i] != s2[j]} \\ dp(i+1, j+1) + 1 & \text{if s1[i] == s2[j]} \end{cases} dp(i,j)={max(dp(i+1,j),dp(i,j+1))dp(i+1,j+1)+1if s1[i] != s2[j]if s1[i] == s2[j]
如此,我们就可以给出最长公共子序列长度计算的python代码实现脚本:
def lcs(s1, s2):
l1 = len(s1)
l2 = len(s2)
dp = [[0 for _ in range(l2+1)] for _ in range(l1+1)]
for i in range(l1-1, -1, -1):
for j in range(l2-1, -1, -1):
if s1[i] == s2[j]:
dp[i][j] = dp[i+1][j+1] + 1
else:
dp[i][j] = max(dp[i][j+1], dp[i+1][j])
return dp[0][0]
可以看到:lcs算法的算法复杂度为 O ( N 2 ) O(N^2) O(N2)。
3. 编辑距离
最长公共子串虽然一定程度上可以衡量两个句子的相似性,但是他有一个缺点就是只关注了两者公共的部分,而并没有考虑两者不相同的部分,这就导致字符不同的部分无法在其中得到体现,比如aaa
和aba
以及abacccccc
两个字符串的lcs都是2,但是aba
较之abacccccc
显然更接近于原字符串aaa
。
而编辑距离(edit distance)则对这一点进行了优化,他的定义是:
- 将字符串(s1)通过下述三种变换方式转换为另一个字符串(s2)所需要的最少操作次数:
- 插入
- 删除
- 替换
他的算法实现和最长公共子串的算法实现有一定的雷同,都是使用动态规划算法进行计算,他的递推公式可以表达为:
d p ( i , j ) = m i n ( 1 + d p ( i − 1 , j ) , 1 + d p ( i , j − 1 ) , d p ( i − 1 , j − 1 ) + ( 1 − δ ( s 1 [ i ] = = s 2 [ j ] ) ) ) dp(i, j) = min(1 + dp(i-1, j), 1+dp(i, j-1), dp(i-1, j-1) + (1-\delta(s_1[i] == s_2[j]))) dp(i,j)=min(1+dp(i−1,j),1+dp(i,j−1),dp(i−1,j−1)+(1−δ(s1[i]==s2[j])))
其中,第一项表示增加一个字符,第二项表示减少一个字符,第三项表示替换一个字符。
给出相应的python脚本实现如下:
def edit_distance(s1, s2):
n = len(s1)
m = len(s2)
dp = [[0 for _ in range(m+1)] for _ in range(n+1)]
for i in range(n, -1, -1):
for j in range(m, -1, -1):
# print(dp)
if i == n:
dp[i][j] = m-j
elif j == m:
dp[i][j] = n-i
else:
d1 = 1 + dp[i+1][j]
d2 = 1 + dp[i][j+1]
d3 = dp[i+1][j+1] if s1[i] == s2[j] else 1 + dp[i+1][j+1]
dp[i][j] = min(d1, d2, d3)
return dp[0][0]
显然,编辑距离的算法复杂度也同样是 O ( N 2 ) O(N^2) O(N2)量级的。
4. jaccard距离
在大多数情况下,编辑距离事实上足够用于比较字符串之间的相似度了,但是,编辑距离还是存在一定的缺陷的,一个典型的例子就是它依赖于顺序,这就导致一些语义相同但是顺序不同的文本就会遭到误判,针对这样的数据,jaccard距离相对而言会是一个更好的判断方法,他是顺序无关的,只考虑两个字符串之间的token重合率。
给出jaccard距离的定义如下:
j
a
c
c
a
r
d
(
s
1
,
s
2
)
=
s
1
∩
s
2
s
1
∪
s
2
jaccard(s_1, s_2) = \frac{s_1 \cap s_2}{s_1 \cup s_2}
jaccard(s1,s2)=s1∪s2s1∩s2
我们给出字符层级下的jaccard距离计算脚本如下:
def jaccard(s1, s2):
return len(set(s1) & set(s2)) / len(set(s1) | set(s2))
显然,jaccard距离的算法复杂度就只有 O ( N ) O(N) O(N)而已。
不过,完全不考虑顺序关系会是一个双刃剑,尤其对于文本内容时,因为他的顺序是有意义的。
5. bleu & rouge & ……
当然,比较两个字符串之间的相似度也可以使用bleu以及rouge等指标,虽然会有点怪异就是了,因为bleu以及rouge指标的计算是不满足交换律的, s 1 s_1 s1与 s 2 s_2 s2不对易,因此不算是单纯的字符串相似度的比较。
但是,如果明确就是问 s 1 s_1 s1与 s 2 s_2 s2之间的距离的话,那么bleu、rouge等指标也可以用于评估两个字符串之间的距离。
有关bleu、rouge等指标的计算具体可以参考我之前的博客:NLP笔记:生成问题常用metrics整理,这里就不多做展开了。
6. 总结
综上,我们可以整理出字符串相似度比较的一些常用方法如下:
method | 定义 | 算法复杂度 | 特点 |
---|---|---|---|
hamming distance | 两等长字符串中不同字符的个数 | O ( N ) O(N) O(N) | 简单,但是表达能力有限 |
lcs | 两字符串的最长公共子序列 | O ( N 2 ) O(N^2) O(N2) | 对顺序敏感,且无法表达不相同字符的区别程度 |
edit distance | 将s1变换为s2所需要的最小编辑数目 | O ( N 2 ) O(N^2) O(N2) | 相对最为常用的一种字符串相似度衡量方法,同样对顺序敏感 |
jaccard | s 1 ∩ s 2 / s 1 ∪ s 2 s_1 \cap s_2 / s_1 \cup s_2 s1∩s2/s1∪s2 | O ( N ) O(N) O(N) | 不考虑顺序信息,只考虑字符重复比例 |