做数据清洗的时候,我经常要面对一个很现实的问题:同一家公司的客户姓名在两个系统里写法不一样,比如“张丽华”和“张丽桦”,又比如英文名“JONATHON”被录成了“JONATHAN”,到底该不该判成同一个人?这时候Jaro-Winkler similarity就派上用场了。这个字符串相似度比较算法在处理短字符串、人名、拼写错误这类场景时非常稳,它天然对前缀一致的字符串更友好,所以特别适合做姓名去重、实体匹配、拼写纠错这类活儿。
这篇文章我想把这套算法彻底讲透,从公式原理、计算过程、代码实现到工程落地中的坑,一次说清楚。无论你是要做数据清洗,还是正在选型相似度算法,或者纯粹想搞明白Jaro–Winkler和编辑距离(Levenshtein distance)到底差在哪,这篇文章都值得你花十分钟看完。
1. 内容整体设计与思路拆解
1.1 为什么需要Jaro–Winkler这个字符串相似度算法
先聊一个问题:做字符串相似度比较,工具库里明明有Levenshtein编辑距离、Dice系数、Cosine相似度,为什么还要单独研究Jaro-Winkler similarity?
拿编辑距离举例,它计算的是把一个字符串变成另一个字符串最少需要多少次插入、删除、替换。这很直观,但有个毛病:它对前缀差异非常敏感。举个例子,“abcdef”和“abcxyz”,编辑距离是3,看起来好像和“abcdef”与“xbcdef”的编辑距离差不多,但实际上人在判断时,会觉得“abcdef”和“abcxyz”更像,因为前三个字符完全一样,而“abcdef”和“xbcdef”只有第一个字符不同,后面全一样。编辑距离把这两种情况一视同仁,这就导致在某些场景下排序会不符合直觉。
Jaro–Winkler算法就是专门针对这个场景优化的。它在Jaro相似度的基础上,给公共前缀加了一个权重,前缀相同的字符越多,相似度被抬得越高。这种设计非常贴合真实场景:一个名字大部分情况是开头被正确记录,越靠后的字符越容易录入出错。比如“McDonald”和“MacDonald”,一看就知道是同一个,因为前缀“M”和“Mac”的相似度摆在那里。
另一个原因是Jaro–Winkler的计算效率很高。相比编辑距离需要维护一个二维DP表(时间复杂度O(n*m)),Jaro–Winkler可以通过一次遍历加两次字符匹配完成计算,时间复杂度稳定在O(n+m),内存占用也少。在数据量大、实时性要求高的场景下,这个优势非常明显。
1.2 算法家族的选型对比
网上聊字符串相似度算法的文章很多,但很少有文章把选型思路讲透。这里我把自己在项目中常用的几个算法做个对比,方便你判断什么时候该用Jaro–Winkler:
| 算法 | 核心思想 | 时间复杂度 | 擅长场景 | 短板 |
|---|---|---|---|---|
| Levenshtein编辑距离 | 最小编辑次数 | O(n*m) | 拼写纠错、DNA序列 | 对前缀不敏感,短字符串区分度差 |
| Jaro–Winkler | 字符匹配+前缀加权 | O(n+m) | 人名、地名、实体匹配 | 对顺序打乱较敏感,重排字符会扣分 |
| Dice系数 | 字符bigram交集 | O(n+m) | 文本相似度泛化 | 短字符串容易误判 |
| Cosine相似度 | 向量空间夹角 | O(n+m) | 长文本、语义近似 | 对字符级错拼不敏感 |
这个对比表在真正的工程选型中非常实用。如果你只是判断一个单词是否拼错,编辑距离就够;如果你在做用户姓名匹配、地址标准化、脏数据合并,Jaro–Winkler明显更合适;如果你在做长文本的近似重复检测,Dice或Cosine会更合适。
我在实际项目里通常的组合方案是:先用Jaro–Winkler粗筛一遍候选集,再用编辑距离或人工规则做最终判定。这样既保证了速度,又提高了准确率。后面在写实践细节的时候我会再展开。
2. 核心原理拆解:Jaro相似度与Winkler前缀加权
2.1 Jaro相似度的基础计算逻辑
Jaro–Winkler的基础是Jaro相似度,两步走。
第一步:确定匹配字符与匹配窗口。
给定两个字符串s1和s2,首先确定一个匹配窗口(match window)。这个窗口不是随便定义的,它由两个字符串的长度决定:
match_window = max(len(s1), len(s2)) / 2 - 1注意这里的除法向下取整,窗口大小至少为0。匹配窗口的含义是:s1中的某个字符,如果在s2中存在,且位置差不超过这个窗口,那么才认为它有机会成为匹配字符。这个设计是为了避免把相隔太远的字符强行配对。比如s1 = "abcde",s2 = "axbycze",虽然a在s1中位置0,在s2中位置0,可以匹配;但e在s1中位置4,在s2中位置6,位置差为2,如果窗口小于2,e就不会计入匹配。这么做是为了防止一个字符串中的字符和另一个字符串中位置完全错乱的字符形成虚假匹配。
第二步:计算匹配字符个数和转置次数。
匹配字符个数m很好理解,就是在匹配窗口内能够对应上的字符总数。转置次数t就稍微绕一点。把s1中匹配到的字符按顺序取出来组成序列A,把s2中匹配到的字符也按顺序取出来组成序列B,然后比较A和B中位置不一致的字符对数目,除以2,就是转置次数。
转置次数的大白话解释:两个字符串里都有这些字符,但它们出现的顺序不完全一致,位置错位的对数就是转置的代价。
得到m和t之后,Jaro相似度的公式如下:
jaro = (m/len(s1) + m/len(s2) + (m-t)/m) / 3这个公式由三部分组成:s1中匹配字符比例、s2中匹配字符比例、匹配字符中未发生转置的比例。三个部分取平均,所以Jaro相似度的取值在0到1之间,1表示完全相同,0表示完全不同。
2.2 Winkler前缀加权的条件与公式
Winkler的改进在于利用了一个观察:两个字符串如果前缀一致,它们属于同一实体的概率会增加。于是他在Jaro相似度上套了一个前缀提升因子。
设公共前缀长度为l,且l不超过4个字符(超过4个后提升效果不再增加),则:
jaro_winkler = jaro + l * p * (1 - jaro)其中p是前缀权重缩放因子,标准取值为0.1。p的取值范围通常是0到0.25之间,超过0.25会导致相似度过度膨胀,可能把完全不相关的字符串判成近似。把p=0.1和l的上限4代进去,可以算出最大提升量是0.4 * (1 - jaro),这意味着即使完全相同前缀也只给到额外0.4的加权空间,不会突破1。
还有一个容易被忽略的细节:Winkler并不是对所有情况都做前缀加权,而是有一个阈值判断。常见做法是只在Jaro相似度大于某个阈值(比如0.7)时才启动加权。如果两个字符串本身的Jaro相似度很低,说明它们差异太大,即使前缀相同也不应该被强行拉近。这个阈值在很多实现里可以根据业务调整,比如在姓名匹配场景中,我会把阈值调到0.7或0.75,既保留了对正确匹配的宽容度,又不会让错误匹配钻空子。
2.3 一个逐步计算的经典例子
空谈公式不好懂,我们来跑一遍。以经典的“MARTHA”和“MARHTA”为例。
s1 = MARTHA,长度6;s2 = MARHTA,长度6。
匹配窗口 = max(6,6)/2 - 1 = 3 - 1 = 2。窗口是2,意味着字符位置差不能超过2。
逐个字符检查:
- M: s1位置0,s2位置0,差0,匹配。
- A: s1位置1,s2位置1,差1,匹配。
- R: s1位置2,s2位置2,差2,匹配。
- T: s1位置3,s2中T在位置4,差1,匹配。
- H: s1位置4,s2中H在位置3,差1,匹配。
- A: s1位置5,s2位置5,差0,匹配。
m = 6,且按照匹配顺序提取序列,s1匹配序列是MARTHA,s2匹配序列是MARHTA。这个例子中,第3和第4个字符T和H的顺序发生了交换:s1里T在H前,s2里H在T前。所以顺序不一致的对数是2,转置次数t = 2 / 2 = 1。
代入公式:
jaro = (6/6 + 6/6 + (6-1)/6) / 3 = (1 + 1 + 0.8333) / 3 = 0.9444然后看前缀:M、A都是相同字符,第三个字符s1是R,s2也是R,相同。第四个字符s1是T,s2是H,不同。公共前缀长度l = 3。由于jaro(0.9444)大于0.7,启用Winkler:
jaro_winkler = 0.9444 + 3 * 0.1 * (1 - 0.9444) = 0.9444 + 0.0167 = 0.9611最终相似度0.9611,非常接近1。完全符合直觉,MARTHA和MARHTA就是同一个人名的拼写变体。
再举一个不那么匹配的例子:s1 = "DIXON",s2 = "DICKSONX"。这个例子很多人第一次算会蒙。s1长度5,s2长度8。窗口 = max(5,8)/2 - 1 = 4 - 1 = 3。s1中D、I、O、N都可以在s2中找到对应字符且位置差不超过3,X在s2中多出来。m = 4。提取匹配序列时,s1的匹配序列是DION,s2的匹配序列是DICKSONX中按顺序取匹配字符,D、I、C、K、S、O、N,筛出匹配字符是DION,但注意O和N在s1位置是2和4,在s2位置是5和7,顺序一致,转置次数t = 0。代入公式jaro = (4/5 + 4/8 + (4-0)/4)/3 = (0.8 + 0.5 + 1)/3 = 0.7667。Winkler前缀l = 2(D、I相同),jaro > 0.7,加权后 jaro_winkler = 0.7667 + 20.1(1-0.7667) = 0.8133。这个结果可以让业务方接受这是一个候选项。
2.4 边界条件与特殊字符串处理
Jaro-Winkler虽然好用,但边界条件一定要搞清楚,否则线上会出各种隐蔽bug。
空字符串:任何一个字符串为空,相似度直接判为0。两个都为空,严格来说可以认为是1,但工程上我更建议返回1并单独处理,因为业务上两个空名字不应当被合并成同一个人。
匹配窗口小于0:当某个字符串长度为1时,窗口 = max(1, n)/2 - 1,如果n很小,窗口可能为0甚至负数。比如s1="A",s2="B",窗口=0,那只有位置差为0的字符才可能匹配。这种情况下m可能等于0,带入公式会出现除零错误。常规做法是当m=0时直接返回0,不进入转置计算。
单字符字符串对比:s1="A",s2="A",窗口=0,m=1,jaro=(1/1 + 1/1 + (1-0)/1)/3 = 1。s1="A",s2="B",m=0,返回0。s1="A",s2="AB",窗口=0,s2中A在位置0和s1的A位置差0,匹配,m=1,jaro=(1/1 + 1/2 + 1/1)/3 = 0.8333。看起来还算合理。
大写与空格:Jaro-Winkler本身区分大小写且不处理空格,所以在中文业务场景里,一定要先做预处理:统一转成小写或大写、去除多余空格、全半角转换。这个问题我在后面实操部分会重点讲。
字符串长度差异极大:比如s1="A",s2是一个100字符的字符串,即使前缀完全一致,Jaro相似度也会被s2的长度拉低。因为公式里m/len(s2)很小。这在业务上通常是合理的:短字符串和长字符串很难是同一个实体。但如果业务需要匹配“缩写”和“全称”,比如“IBM”和“International Business Machines”,Jaro-Winkler几乎帮不上忙,得考虑专门做缩写字典。
3. 实操过程与代码实现
3.1 从零实现Jaro-Winkler相似度算法的Python版本
算法原理搞清楚了,代码实现其实没有那么难。我建议每个想深入用这个算法的朋友,至少手写一遍,不要一上来就调库。手写一遍你才能真正理解匹配窗口、转置次数的含义,后面遇到诡异结果时也能更快定位问题。
下面这个Python实现是我在项目中验证过的版本,稍微做了优化,逻辑清晰,可以直接抄作业:
def jaro_similarity(s1: str, s2: str) -> float: if not s1 or not s2: return 0.0 if s1 == s2: return 1.0 len1, len2 = len(s1), len(s2) match_window = max(len1, len2) // 2 - 1 match_window = max(match_window, 0) s1_match = [False] * len1 s2_match = [False] * len2 matches = 0 for i in range(len1): start = max(0, i - match_window) end = min(i + match_window + 1, len2) for j in range(start, end): if s2_match[j]: continue if s1[i] != s2[j]: continue s1_match[i] = True s2_match[j] = True matches += 1 break if matches == 0: return 0.0 # 收集匹配字符序列,计算转置次数 seq1 = [s1[i] for i in range(len1) if s1_match[i]] seq2 = [s2[j] for j in range(len2) if s2_match[j]] transpositions = 0 for k in range(len(seq1)): if seq1[k] != seq2[k]: transpositions += 1 t = transpositions // 2 return (matches / len1 + matches / len2 + (matches - t) / matches) / 3.0 def jaro_winkler_similarity(s1: str, s2: str, p: float = 0.1, max_prefix: int = 4, prefix_weight_threshold: float = 0.7) -> float: js = jaro_similarity(s1, s2) if js < prefix_weight_threshold: return js prefix_len = 0 for i in range(min(len(s1), len(s2), max_prefix)): if s1[i] == s2[i]: prefix_len += 1 else: break return js + prefix_len * p * (1 - js)这段代码有两个细节值得注意。第一,匹配窗口我用了max(len1, len2) // 2 - 1,这是主流的定义方式。也有一些实现会加上abs(len1 - len2)之类的调整项,但我实测下来,标准定义更稳定,也更容易和别人的结果对齐。第二,转置次数是顺序不一致字符对数的一半,所以最后要// 2。有的文章会把transpositions直接当作t,这是错误的,会导致相似度被人为压低。
用上面两个例子验证一下:
print(jaro_winkler_similarity("MARTHA", "MARHTA")) # 0.9611111111111111 print(jaro_winkler_similarity("DWAYNE", "DUANE")) # 0.8400000000000001 print(jaro_winkler_similarity("DIXON", "DICKSONX")) # 0.8133333333333334结果和标准参考值一致。
3.2 工程中直接使用的标准库方案
如果不想重复造轮子,业界常用的几个库可以直接拿来用。我这里比较推荐下面三个:
jellyfish:老牌的字符串相似度库,性能不错,接口干净。jellyfish.jaro_winkler_similarity(s1, s2)一行搞定。但它内部实现里p值固定为0.1,max_prefix固定为4,阈值判断也是写死的。好在这些参数是数学上最常用的标准配置,大部分场景下足够了。
textdistance:这个库更像一个算法集合,里面实现了30多种距离和相似度算法,包括Jaro-Winkler、Levenshtein、Damerau-Levenshtein等。它的优势是方便做横向对比,调试时特别好用。如果业务方问“你俩这个相似度是不是太低”,我通常会直接用textdistance跑一排版对比,把Jaro-Winkler、Dice、编辑距离的结果拉出来,看看是不是只有这个算法表现特别异常。
fuzzywuzzy / RapidFuzz:fuzzywuzzy基于Levenshtein做了很多封装,本身不提供Jaro-Winkler。但RapidFuzz里同时提供了Jaro和Jaro-Winkler,而且RapidFuzz的C++实现非常快,处理上百万条字符串时优势明显。如果你在做一个调用量很大的服务,我建议直接用RapidFuzz。
具体用法如下:
# pip install jellyfish import jellyfish print(jellyfish.jaro_winkler_similarity("MARTHA", "MARHTA")) # 0.9611111111111111 # pip install textdistance import textdistance print(textdistance.jaro_winkler.similarity("MARTHA", "MARHTA")) # 0.9611111111111111 # pip install rapidfuzz from rapidfuzz.distance import JaroWinkler print(JaroWinkler.similarity("MARTHA", "MARHTA")) # 0.9611111111111111从工程角度,我更推荐RapidFuzz,因为它不只是提供Jaro-Winkler,还提供了一整套模糊匹配工具,包括process.extractOne这种批量查询接口,能直接从一个字符串列表中找出最相似的前K个候选,配合C++底层的速度,在线上服务里非常能打。
3.3 Java实现版本与SpringBoot集成思路
搜“字符串相似度算法”的人里很多是Java后端,这里我补一个Java实现。网上有很多人问怎么在业务系统里集成这种算法,其实思路都差不多。
public class JaroWinkler { public static double similarity(String s1, String s2) { if (s1 == null || s2 == null) { throw new IllegalArgumentException("Strings must not be null"); } if (s1.equals(s2)) { return 1.0; } int len1 = s1.length(); int len2 = s2.length(); if (len1 == 0 || len2 == 0) { return 0.0; } int matchWindow = Math.max(len1, len2) / 2 - 1; matchWindow = Math.max(matchWindow, 0); boolean[] s1Matched = new boolean[len1]; boolean[] s2Matched = new boolean[len2]; int matches = 0; for (int i = 0; i < len1; i++) { int start = Math.max(0, i - matchWindow); int end = Math.min(i + matchWindow + 1, len2); for (int j = start; j < end; j++) { if (s2Matched[j]) { continue; } if (s1.charAt(i) != s2.charAt(j)) { continue; } s1Matched[i] = true; s2Matched[j] = true; matches++; break; } } if (matches == 0) { return 0.0; } StringBuilder sb1 = new StringBuilder(); StringBuilder sb2 = new StringBuilder(); for (int i = 0; i < len1; i++) { if (s1Matched[i]) { sb1.append(s1.charAt(i)); } } for (int i = 0; i < len2; i++) { if (s2Matched[i]) { sb2.append(s2.charAt(i)); } } String seq1 = sb1.toString(); String seq2 = sb2.toString(); int transpositions = 0; for (int i = 0; i < seq1.length(); i++) { if (seq1.charAt(i) != seq2.charAt(i)) { transpositions++; } } double t = transpositions / 2.0; double jaro = (matches / (double) len1 + matches / (double) len2 + (matches - t) / matches) / 3.0; // 计算Jaro-Winkler if (jaro < 0.7) { return jaro; } int prefix = 0; for (int i = 0; i < Math.min(Math.min(len1, len2), 4); i++) { if (s1.charAt(i) == s2.charAt(i)) { prefix++; } else { break; } } return jaro + prefix * 0.1 * (1 - jaro); } }在SpringBoot里集成这个算法,我一般把它做成一个工具类,然后在Service层做一个批量去重接口。比如客户表里有10万条记录,前端上传一批新客户名单,后端先做数据清洗、统一格式,然后对每一条新记录调用JaroWinkler.similarity遍历已有客户,找出相似度高于0.9的候选,再结合其他业务规则(比如手机号、身份证)做最终合并。这个方案我们实测在100万规模内响应时间都在几十毫秒内,性能上没有压力。
3.4 参数计算结果对比与调参经验
很多朋友第一次用Jaro-Winkler时会有一个困惑:代码跑出来了,分数也能算,但不知道阈值该设多少。这个事儿真没有标准答案,但如果给一个经验范围,我推荐按场景来:
| 场景 | 推荐阈值 | 理由 |
|---|---|---|
| 精确去重(同一人/同一地址) | 0.90以上 | 容忍极小的拼写差异,误报率最低 |
| 中等容错(同义词、别称) | 0.80~0.90 | 能覆盖大多数姓名的常见错误 |
| 宽匹配(候选集预筛选) | 0.70~0.80 | 宁可多召回,后续再用规则过滤 |
| 下限(Jaro阈值) | 0.7 | 低于这个值,Winkler wouldn't even apply,强行使用加权会误判 |
有一个很常见的坑是:业务方觉得自己数据质量差,于是把阈值调到0.6甚至更低,结果相似度全在0.6-0.7之间,无法区分。这个问题的根源不在于阈值低了,而在于没有理解Jaro-Winkler只适合短字符串容错匹配。如果数据里除姓名外还混了地址、公司名这些长文本,相似度会被预期拉低,此时应该把长文本单独用Dice或Cosine算法来计算,而不是强行用低阈值。
我踩过一次坑:早期做商品名去重时,直接用Jaro-Winkler比对一堆几十个字符的句子,结果各种奇怪误报。后来我把商品名先拆成核心词(品牌+型号+规格),核心词之间用Jaro-Winkler,剩余部分用Dice系数,准确率一下子提升了很多。算法没有银弹,组合使用才是王道。
4. 常见问题与实战避坑
4.1 中文场景下的Jaro-Winkler表现与处理方式
Jaro-Winkler最初是为英文字符串设计的,对中文的支持属于“能用但不够聪明”。原因很好理解:中文没有空格分词,字符单位是汉字,一个汉字在匹配窗口内找对应字符时,和英文的字母逻辑一样,但中文姓名通常只有2-4个字,信息量小,单字错位对相似度的影响会被放大。
比如“王小明”和“王晓明”,长度都是3,窗口=0(max(3,3)/2-1 = 0),只有位置完全匹配的字符才能算匹配。王匹配,小匹配,明和晓错位,不匹配。m=2,jaro=(2/3 + 2/3 + (2-0)/2)/3 = 0.7778。公共前缀l=2(“王小”相同),jaro > 0.7,加权后=0.7778 + 20.1(1-0.7778)=0.8223。这个值能提示我们这是潜在匹配,但不够高。换成人名如果变成“张丽华”和“张丽桦”,最后那个字不同,m=2,l=2,结果也差不多0.82左右。这就是我说中文短文本信息量不足的原因。
实战建议:
- 做中文姓名匹配时,先对姓氏单独判断(中文姓氏比较有限,可以搞一个常见姓氏表),姓氏匹配+名字Jaro得分,组合出一个综合分数。
- 将全名字符串按长度补齐后计算,我试过在名字前后填充分隔符,效果不稳定,不如上面那种拆姓氏方式可靠。
- 对于中文地址、公司名这种长文本,不建议直接用Jaro-Winkler,先做分词,提取行政区划、道路、门牌等关键片段,再逐段比较。
4.2 数据预处理里最容易被忽略的三个细节
Jaro-Winkler对输入数据非常敏感,这里三个预处理细节能救你很多次:
大小写统一。Jaro-Winkler严格区分大小写。你的数据源如果既有“john”又有“John”,直接比较结果会偏低。统一走.lower()或.upper(),一般建议全小写。
全半角与特殊符号。中文系统里经常混着全角字符和半角字符。比如“john”(全角)和“john”(半角),肉眼一样,算法眼里完全不同。必须用.translate()或者正则做全角转半角。另外带连字符的名字,比如“Jean-Pierre”和“Jean Pierre”,建议把连字符、多余空格、句点统一替换成空字符串或者同一个分隔符。
去除业务噪声。客户从不同渠道录入时,可能会带“先生” “女士” “(个人)”这种后缀,或者“某某有限公司”这种公司类型的噪声词。在做姓名或公司名匹配前,建议先维护一个通用词表,把这些词去掉。这一步比后面调任何参数都管用,我用一句话概括:预处理做得好,相似度算法省心一半。
4.3 性能优化:百万级数据下怎么跑
如果你要把Jaro-Winkler用在全量数据清洗上,最怕的就是两个大列表两两对比,那就是O(n*m)的复杂度。100万条对100万条,算到天荒地老。我实际用的优化方案有三种:
方案一:长度桶过滤。如果两个字符串长度差超过一定比例(比如30%),它们的Jaro相似度理论上是有上限的。可以直接用长度差做粗筛,只在长度接近的候选对里计算Jaro-Winkler。实现起来简单,效果却很显著。
方案二:前缀索引 + 候选集。因为Jaro-Winkler对前缀加权很大,所以可以拿前2-3个字符建倒排索引。只对前缀相同或相近的记录计算相似度。注意这里不是全文扫描,而是先缩小候选集。如果有拼音或者别的索引字段,也可以用。
方案三:用RapidFuzz的process.extractOne。这个接口内置了阈值过滤和批处理能力,底层是C++,性能比我手写的Python循环快几十倍。在单机百万级数据里,基本是秒级出结果。如果你还嫌慢,可以考虑多进程并行切分数据,或者上Redis之类缓存住预处理的字符串向量。
4.4 相似度算法的实际应用场景扩展
写完基础实现之后,你会发现Jaro-Winkler能玩的花样挺多。这里列几个我实际做过或见过的应用,供你参考:
拼写纠错。搜索引擎或输入法里的“你的意思是不是XXX”提示,候选词就是通过Jaro-Winkler从词库里捞出来的。词库几万个词,每次输入都全量匹配不现实,但配合前缀索引效果非常不错。
数据库记录合并(Record Linkage)。两个CRM系统合并客户数据时,把姓名、电话、地址三个字段分别算相似度,再加权得到一个综合匹配分。Jaro-Winkler在姓名这个字段上往往比编辑距离表现好,主要就是因为它更贴近人对名字相似度的主观感受。
日志聚类。服务日志里大量报错文本只有细微差异,比如时间戳、IP、用户ID。用Jaro-Winkler可以把重复日志聚成一类,方便定位高频故障。这里需要注意把动态字段先做正则替换再比较。
地理信息匹配。地名、路名这种短字符串,Jaro-Winkler在容错上表现不赖。比如用户输入“北京中关村大街”和“北京中官村大街”,Jaro-Winkler能识别为同一地点候选。
5. 写在最后的实操心得
我大概从四年前开始真正大规模使用Jaro-Winkler similarity,中间踩过的坑比看过的文档还多。一个很大的体会是:这算法不是用来替代其他相似度算法的,而是用来给它们在短字符串、前缀敏感的场景里做互补的。在它之前,我默认用编辑距离,表面上很严谨,实际上在处理人名、地名时经常被业务方挑战“这俩明明差不多啊,怎么分数这么低”。换成Jaro-Winkler之后,这种抱怨少了很多。
还有一点想提醒:算法参数别乱调。网上很多文章喜欢教你把p调到0.2、把max_prefix调到10,好像分值高了就更好用。实际上一旦提高前缀权重,很容易把“AB”和“ABCXYZ”这种前缀相同但实际没什么关系的字符串拉出高分。我在项目里一般保持p=0.1、max_prefix=4、阈值0.7这个标准配置,只有在特定场景下才微调阈值,从不改p和max_prefix,因为数学上这两个参数被大量实验验证过,稳定性更高。
如果你正在做数据清洗或者实体匹配,最后建议你做一个简单的对照实验:拿人工标注好的1000对数据,分别用Jaro-Winkler、编辑距离和Dice系数跑一遍,画出ROC曲线或者人工挑几十个典型案例看效果。很多时候,算法选型的答案不在论文里,在你自己的数据里。用Jaro-Winkler跑一遍,再结合业务规则,这套组合拳基本能覆盖八成以上的字符串匹配需求。