ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Jaro-Winkler算法详解:短字符串相似度匹配的Python实现与工程实践

Jaro-Winkler算法详解:短字符串相似度匹配的Python实现与工程实践 做数据清洗和业务开发这几年我最大的感受就是很多团队一开始都用编辑距离走天下后来碰到人名、昵称、电话地址这类短字符串才发现原来相似度算法也分“场景”和“脾气”。今天想聊的这个Jaro-Winkler similarity就是我在做字符串相似度匹配时用得比较顺手的一个算法。它跟Levenshtein编辑距离不一样专门针对“短字符串、有轻微拼写偏差、前缀一致性高”的场景设计尤其适合匹配人名、地名、系统名称这类数据。本文我会从原理推导、公式拆解、Python手写实现、真实场景实测到工程优化完整过一遍看完你不仅能写出来还知道什么时候用它比用其他算法更聪明。1. 为什么短字符串相似度不能靠编辑距离硬撑先说一个我在一个客户数据清洗项目里遇到的真实场景。当时要把两个来源的客户名单做关联A库是业务员手动录入的B库是客户自己在网上填的同一个客户可能出现“张家港华达贸易有限公司”“张家港华达贸易有限公”“华达贸易”这些变体。第一版我直接用了Levenshtein编辑距离结果发现一个问题短字符串之间差一个字符编辑距离只差1但相似度会被拉得极低导致大量客户被误判成不同的人。比如“张伟”和“张玮”编辑距离是1如果按常见的相似度公式1 - distance / max_len算得分是0.5。可实际上这是一个姓名常见的同音字替换人眼一看就知道大概率是同一个人。这就是编辑距离的本质缺陷它把每一个字符替换都当成平等代价完全忽略了人类书写短字符串时的错误模式。人打字出错往往发生在后半段前半段尤其是第一个字、第一个词通常会保留比如“李佳琪”写错成“李嘉琪”前面两个字符还能对得上。后来我换成了Jaro-Winkler similarity情况立刻不一样了。这个算法原生考虑了三种信息匹配字符数、字符顺序、前缀一致性。也就是说它天然知道“开头对上了”比“结尾对上了”更有参考价值。对于上面“张伟/张玮”这种词它给出的相似度明显优于编辑距离的0.5更符合业务方的直觉判断。再一个例子是识别重复创建的工单标题。运维人员提交工单时经常随手带后缀比如“[紧急]数据库CPU飙高”“数据库CPU飙高”如果单纯靠编辑距离人工调整范围不固定得分波动很大。但Jaro-Winkler对这类前缀一致、后面随意追加的文本响应很稳健因为它计算相似度时对前缀的加分权重很高。所以从实用角度说它不是取代编辑距离而是补齐了编辑距离在“短文本前缀可靠”场景下的选择盲区。这也引出了这篇文章要拆解的核心Jaro-Winkler similarity到底是怎么算的以及手写实现时要注意哪些坑。2. Jaro-Winkler similarity的核心原理拆解很多人直接调库用这个算法但不理解公式遇到某些结果会觉得“这分数怎么这么奇怪”。所以我坚持把原理拆开讲清楚。Jaro-Winkler是两层的叠加先把两个字符串的Jaro相似度算出来然后根据前缀匹配程度在这个基础上做额外加分。分两步理解会容易很多。2.1 第一步先算Jaro相似度Jaro相似度的基本思想很直观两个字符串有多像取决于三个因素——能匹配上的字符数多不多、匹配字符跨得远不远、字符相对顺序有没有乱。先定义几个变量len(s1)字符串1的长度len(s2)字符串2的长度m两串能匹配上的字符数量t换位次数transpositions的一半匹配规则有一个关键的匹配窗口概念对于字符串s1中的每个字符它只能在s2中一个有限范围内寻找自己的对应字符。这个窗口半径是match_distance floor(max(len(s1), len(s2)) / 2) - 1为什么是“一半长度减1”这是Jaro当年的经验设计。如果两个字符隔得太远还能匹配上那说明这两个字符串的结构差异太大这种匹配反而会污染相似度判断。窗口的意义相当于说我允许你在附近找“长得一样”的字符但距离别太远。匹配具体过程比较像一个贪心算法从左往右遍历s1的每个字符在s2的匹配窗口范围内找第一个未被占用的相同字符找到了就标记两个位置都被匹配m 1。拿到m之后还要考虑字符顺序被打乱的程度。计算方法是把所有“被匹配上的字符”按原顺序各自提取出来逐个对齐比较统计不一样的位数这个位数的一半就是t。为什么除以2因为一次交换比如两个字符顺序颠倒了会导致两个位置都不一致你要除以2才是真正的交换次数。Jaro相似度公式长这样jaro (m/len(s1) m/len(s2) (m - t)/m) / 3可以看成三部分加权平均m/len(s1)s1有多少比例的字符被匹配上了m/len(s2)s2有多少比例的字符被匹配上了(m - t)/m匹配上的字符里顺序正常的有多少比例三部分各占三分之一。这样设计的巧妙之处在于如果两个字符串完全相等三项全部为1结果为1如果完全没有任何匹配m0结果为0。我用一个经典例子手动算一遍你就能彻底理解。设s1 MARTHAs2 MARHTA这是算法论文里很常见的示例。两个长度都是6所以match_distance 6 // 2 - 1 2逐个匹配M匹配MA匹配AR匹配RT在s2窗口内找到位置4的TH在s2窗口内找到位置3的HA匹配最后一个A所以m 6按原顺序提取匹配字符后s1侧得到M A R T H As2侧得到M A R H T A只有第4、5位T和H的顺序不同不一致位数是2所以t 2 / 2 1代入公式(6/6 6/6 (6-1)/6) / 3 (1 1 0.8333) / 3 0.9444这个0.9444是Jaro相似度已经比编辑距离算出来的0.67高了不少因为算法知道大部分字符都在且顺序基本正确。2.2 第二步Winkler的前缀加权Jaro-Winkler的“Winkler”部分核心就是一个基于前缀的加分项。经过大量观察Winkler发现如果两个字符串开头几个字符相同那么这两个字符串是同一个实体的概率会显著提高特别是人名。于是他把这个先验知识变成了公式jaro_winkler jaro (prefix_len * P) * (1 - jaro)其中prefix_len两串从头开始连续相同字符的个数通常最多取4P前缀权重系数通常取0.1为什么prefix_len上限是4这是实验经验。Winkler在对姓氏和名字做统计时发现超过4个字符的前缀一致对相似度提升的边际效果减弱不再适合继续线性往上加。所以实际工程里你看到算法通常会先比较前4个字符数出前面连续相同的个数。为什么P取0.1因为这是让分数落在合理区间、又不至于过度膨胀的一个经验值。如果你设成0.3两个前缀相同的字符串得分会被抬得过分很多完全不相关但恰好前缀一样的词会变成“高相似度”误报率会明显上升。继续算“MARTHA”和“MARHTA”Jaro相似度算出来是0.9444前缀匹配M、A、R三个字符连续相同到第4个字符时T和H不一样所以prefix_len 3代入公式0.9444 3 * 0.1 * (1 - 0.9444) 0.9444 0.0167 0.9611最终得分约0.9611。这个分数比Jaro的0.9444更高也基本符合人眼判断。有意思的是如果你把字符串改成“MARTHA”和“MARTA”匹配到的字符数会少一个Jaro会明显降低但前缀前三字符仍然相同Winkler加分还是会把它往上拉一点——这种“前缀可靠尾巴有损”的形态恰恰是该算法最适合处理的场景之一。3. 从公式到可运行的Python实现原理清楚了之后代码其实没有想象中复杂。我自己第一版写的时候踩了好几个坑所以这里把每一块拆开讲并给出一版完整可运行的Python实现方便你直接用或者改成别的语言。3.1 逐段实现并验证经典用例先放完整代码再逐段解释def jaro_similarity(s1: str, s2: str) - float: if s1 s2: return 1.0 len1, len2 len(s1), len(s2) if len1 0 or len2 0: return 0.0 # 匹配窗口半径 match_distance max(len1, len2) // 2 - 1 if match_distance 0: match_distance 0 s1_matches [False] * len1 s2_matches [False] * len2 matches 0 for i in range(len1): start max(0, i - match_distance) end min(i match_distance 1, len2) for j in range(start, end): if s2_matches[j]: continue if s1[i] ! s2[j]: continue s1_matches[i] True s2_matches[j] True matches 1 break if matches 0: return 0.0 # 统计换位次数 transpositions 0 j 0 for i in range(len1): if not s1_matches[i]: continue while not s2_matches[j]: j 1 if s1[i] ! s2[j]: transpositions 1 j 1 t transpositions / 2 return (matches / len1 matches / len2 (matches - t) / matches) / 3 def jaro_winkler_similarity(s1: str, s2: str, prefix_weight: float 0.1) - float: jaro jaro_similarity(s1, s2) # 计算前缀连续匹配长度最多4 prefix_len 0 max_prefix min(len(s1), len(s2), 4) for i in range(max_prefix): if s1[i] s2[i]: prefix_len 1 else: break return jaro prefix_len * prefix_weight * (1 - jaro)验证一下经典用例print(jaro_winkler_similarity(MARTHA, MARHTA)) # 输出约 0.9611 print(jaro_winkler_similarity(DIXON, DICKSONX)) # 输出约 0.8133 print(jaro_winkler_similarity(JELLYFISH, SMELLYFISH)) # 输出约 0.8963这几个输出和我在文档、论文里看到的标准结果是一致的说明实现逻辑没问题。这里有个细节需要特别说明匹配窗口用的是max(len1, len2)不是min。我之前一度以为窗口应该基于较短字符串的长度来定因为“短的那个都匹配不上长的更不可能匹配上”。但实际看Jaro定义的原始论文窗口是基于较长字符串长度的一半。两种写法在大多数情况下差别不大但在长度悬殊比较大的字符串对比如“abc”和“abcdefghi”上窗口选择会影响m的结果和最终分数。我在实际项目里对比过用max作为基准更符合算法设计者的本意也不要随意改成min。第二个细节是换位统计的j指针。这里不是简单比较两个字符串的每个位置的字符而是先取出所有匹配过的字符按顺序比较。所以代码里用了一个while not s2_matches[j]的循环跳过s2中未匹配的字符。如果不加这一步直接把s1的字符和s2对应位置比会把很多本不该算作换位的情况误判成换位导致分数偏低。3.2 实现中最容易写错的三个位置手写这个算法最容易出问题的地方我总结下来就三个代码量不大但细节很重。第一匹配窗口的边界。end一定要min(i match_distance 1, len2)因为Python的range是左闭右开不加1会导致最右边的候选字符永远匹配不上。我见过好几个开源实现都是在这里翻车的特征就是某些长字符串分数异常低。第二字符匹配要“去重”。我在第一版里没给s2_matches[j]做占用标记导致同一个s2字符被多个s1字符重复匹配m值虚高比如“aaa”和“aa”算出来相似度接近1.0显然不对。加入了s2_matches[j]判断和置位之后匹配就是一一对应的算法才名副其实。第三跳表指针j不要越界。在换位统计那块一定要保证j在range(len2)范围内而且所有s2_matches中为True的位置最终都会被遍历到。稳妥一点可以在循环开头加个边界断言防止极端输入下数组越界导致线上崩溃。4. 实测中的效果分水岭与选型判断实现跑通之后我在几个业务场景里把这个算法和Levenshtein编辑距离、余弦相似度做了对比。这里分享一下实测观察也帮你搞清楚到底什么时候选它。4.1 不同场景下它比编辑距离、余弦相似度强在哪我在一个内部系统里用小批量真实数据做了三组测试第一组是客户中文姓名含同音字、漏字第二组是工单标题含语气词、标点符号第三组是公司全称含贸易、有限、公司等常见后缀。先说姓名组。张伟 - 张玮 李佳琪 - 李嘉琪 王小明 - 王小朋用编辑距离算这三对的得分分别只有0.5、0.5、0.667而用Jaro-Winkler算得分分别是0.833、0.833、0.867。业务方看一眼就同意用Jaro-Winkler的结果。原因就在于这些错误类型全部符合Jaro-Winkler的加分预期前缀相同、错字靠后、匹配字符占比高。再说工单标题组。【紧急】数据库CPU飙高 - 数据库CPU飙高 服务器磁盘空间不足 - 服务器磁盘空间不足,请尽快处理这种情况下编辑距离会因为前缀多了“【紧急】”或后缀多了“请尽快处理”而大幅扣分但Jaro-Winkler对“前面几个字符连续相同”极其敏感所以得分都在0.85以上。做工单聚类时我直接用它做初筛召回率好看很多。4.2 那些看着像其实是陷阱的场景这里必须泼一盆冷水Jaro-Winkler并不万能我把它列为“擅长短文本、有前缀偏好”恰恰也意味着它会对另一些场景产生系统性误判。陷阱一倒序问题。比如“张伟王”和“王张伟”从语义上说可能是同一个人填反了姓名顺序但Jaro-Winkler的换位惩罚对这种大范围倒序非常敏感得分会掉到0.4以下。相比而言编辑距离虽然也会判低但至少视觉上还算“一个字符插入两个删除”的距离。所以在做中文姓名倒置匹配时建议先把字符串按词组或字做一次归一化或者配合其他算法做二次确认。陷阱二前缀相同但语义不同。算法有个天然盲点只见过前缀可靠却没法判断前缀是否足够区分。比如“上海大众”和“上海大智慧”前缀前四个字里有两个字相同算法可能给出0.85的分但业务上这完全是两个不同主体。这种情况我一般会加一个“停用词表”和“最小后缀差异校验”而不是直接信任算法分数。陷阱三非常短的字符串。长度小于4的字符串前缀上限实际上用不满比如“AB”和“AC”这种Jaro值本身就低加成分也不明显最终分和编辑距离差别不大。如果你想做验证码、订单号这类短码的相似度判断Jaro-Winkler提升有限可以考虑用其他专用方案。我用一个表格总结一下我实际观察到的对比效果方便你选型场景LevenshteinJaro-Winkler适用结论中文姓名含同音错字中规中矩偏保守表现好分数更贴近直觉优先选Jaro-Winkler工单标题前缀一致、后缀追加得分偏低得分高且稳定优先选Jaro-Winkler顺序颠倒的字段低但可解释极低都不太适合需预处理超短字符串长度2差别不大差别不大选计算简单的即可长文本整段相似度计算慢不擅长用SimHash或余弦相似度Jaro-Winkler最快、最准的时候就是面对“长度不过几十个字符、错误集中在中后段、前缀相对可靠”的字符串。你得先判断自己的数据是否符合这个形态再决定要不要用它做主匹配器。5. 大量数据下的工程优化与组合策略算法在手写Demo里跑通不难难的是在几十万甚至上百万条数据上落地。我在实际工程项目里做了几轮优化这里直接分享一些经验和踩坑记录。5.1 换掉双循环的预处理思路如果直接在两层for循环里对全量数据两两计算Jaro-WinklerO(n²)的时间复杂度很快就会让你后悔。比如10万条客户数据两两比较就是100亿次调用Python纯实现根本扛不住。我的第一步是降低需要进入全量比较的候选对数量。常用手段包括对字符串做拼音首字母缩写先只对首字母相同的数据计算完整Jaro-Winkler对中文字符串按第一个字符或前两个字符建索引因为Jaro-Winkler天然看重前缀前缀不同且相似度又高的概率极低按字符串长度分组只比较长度差不超过一定阈值的组在内部测试里加了“前缀索引”之后需要全量计算的候选对数量减少到原来的百分之几效果立竿见影。注意这一步不能省因为Jaro-Winkler算法本身的复杂度是O(L1 * L2)再叠加两两比较数据量一大必然卡死。第二步是优先用C扩展库。Python纯实现方便测试和阅读但性能瓶颈明显。工程环境里我建议直接用rapidfuzz库它底层是C实现对Jaro-Winkler做了高度优化在保持相同参数语义的前提下速度比纯Python实现快几十倍以上。如果你不是想彻底搞懂原理而是要在生产环境用直接引入成熟库是划算的。我在项目里用rapidfuzz.fuzz.token_sort_ratio配合JaroWinkler做了个组合打分器client端响应时间完全可控。5.2 阈值和二次校验的组合用法阈值怎么定也是工程里的经验活。我在一个CRM项目里的做法是Jaro-Winkler 0.92直接判为同一客户得分在0.85到0.92之间进人工审核队列得分低于0.85基本忽略。这个阈值不是拍脑袋是拿人工标注了大概5000对数据之后画出来的分布曲线。你会发现不同业务下最优阈值完全不同比如公司名匹配的阈值可以放宽到0.80因为公司名后缀变化多而身份证号、手机号这类字段一旦做模糊匹配就必须设得很严0.95以上才算同一人。我另外一个实践心得是Jaro-Winkler只适合做第一轮召回不太适合做最终裁决。原因是它只基于表面字符完全没有理解语义。比如“中国移动通信集团”和“中国移动”Jaro-Winkler只会告诉你前缀高度一致、相似度较高但它无法判断“集团”是不是修饰词也无法区分“上海浦东发展银行”和“浦东发展银行上海分行”这两个本质上相同但顺序倒置的实体。面对这种业务我会引入一个“业务规则层”先把通用后缀词归一化再对关键核心词做顺序无关的匹配。我踩过最值得写下来的坑是在中文场景中直接将英文字母大小写和空格规范化后不加处理就做Jaro-Winkler比较结果“张小华”和“张小 华”这种中间夹了空格的脏数据被误判成低相似度。解决方式很简单在进入算法前统一做一次正则清洗把空格、标点全部删除中文只留下汉字和数字。这个预处理做对了算法才能发挥出真实水平。另外如果你做的是姓名匹配可以尝试把Jaro-Winkler和拼音相似度组合起来。具体做法先判断两个字符串的Jaro-Winkler得分如果高于0.8再转成拼音串算一次如果拼音串的相似度也高再提升总评分。这样做能覆盖同音字问题但代价是多一次转换和比较要根据业务延迟要求权衡。整个项目做完之后我最大的感受是选算法不是选择最好的而是选择最不坏的。Jaro-Winkler在处理短字符串相似度时确实有它独特的前缀敏感优势但它不是银弹用错场景比不用还糟糕。如果让我给你一个最稳妥的操作路线那就是先清洗数据再按前缀和长度建立索引缩小候选集用Jaro-Winkler做第一轮召回再用业务规则做二次过滤最后保留一个人工审核兜底通道。最后分享一个小技巧如果你要在SQL里快速试一下这个算法的效果很多数据库没有现成函数建议把上述Python代码封装成一个UDF用户自定义函数先跑一个1000条的样本看分数分布再决定整体策略。我在项目里就是这样反复试了好几轮才找到最适合业务的最优阈值而不是一上来就全量跑数。
返回列表