ARTICLE DETAIL

资讯详情

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

Jaro-Winkler相似度算法:原理、Python实现与调参实战

Jaro-Winkler相似度算法:原理、Python实现与调参实战 做搜索、做数据清洗、做用户输入纠错的同学迟早都会遇到同一个问题两个字符串“看起来很像”但程序里用一比较结果是 False。比如同一个人名一个写MARTHA一个写MARHTA同一个地方一个写Beijing一个写Beijng。指望绝对相等去匹配基本颗粒无收。字符串相似度算法就是用来量化“像不像”的而Jaro–Winkler similarity是我在短文本、姓名、地名、单词纠错这类场景里用得最多的一个算法没有之一。这篇内容我会把它掰开揉碎讲清楚先解释为什么需要这个算法再逐步拆解 Jaro 距离和 Winkler 前缀加权的计算原理然后给出一份可以直接抄走的 Python 手写实现最后聊聊我在实际项目里怎么调参、怎么避坑。适合正在做实体归一化、搜索联想、模糊匹配的开发者也适合想把相似度算法选型搞明白的人。1. 为什么需要 Jaro–Winkler similarity1.1 从一个真实场景说起先想一个业务你手头有一套 CRM 客户名单总共两万条是从不同渠道合并过来的。同一个客户一条记录叫John Smith另一条叫Jon Smith一条写北京市朝阳区另一条写北京朝阳区。如果只用等值匹配这两条数据就会被当成两个客户脏数据就这么产生了。这种场景下你需要的是“模糊匹配”不是要求两个字符串完全一样而是计算一个相似度分数超过阈值就认为是同一条记录。Jaro–Winkler similarity就是专门干这个的。它最初被用在人口普查、姓名匹配这类场景里后来被广泛用到搜索、推荐、数据清洗、生物信息学比对中。它最大的特点是对常见的人类拼写错误非常友好尤其是“字符位置交换”和“前缀相同”这两种情况。举个例子感受一下。MARTHA和MARHTA这两个词肉眼一看就知道是同一个词的笔误中间的TH写反了。用编辑距离Levenshtein distance来算它俩的距离是 2要两次替换才能变一样但用 Jaro 相似度算得分约 0.944属于高度相似。为什么因为 Jaro 算法对“换位”的惩罚非常轻它专门容忍这种拼写错误。1.2 和其他相似度算法放在一起看很多刚接触这个领域的人的第一个问题是相似度算法这么多Levenshtein、LCS、Jaccard、余弦相似度为什么非要用 Jaro–Winkler我的回答是不同算法解决不同问题选型错了效果会差很多。我整理了一张对比表你可以直接参考算法核心思想擅长场景明显短板Levenshtein 编辑距离最少增删改次数文本纠错、基因序列对换位敏感MARTHA和MARHTA距离偏大Jaccard / 余弦相似度集合或向量重合度长文本、推荐系统对短语顺序、字符级差异不敏感LCS 最长公共子序列公共子序列长度文本比对、代码 diff不关心局部差异权重Jaro similarity匹配窗口 换位惩罚短字符串、人名地名对插入删除较宽容长文本效果一般Jaro–WinklerJaro 前缀加权姓名去重、搜索联想前缀加权可能让短词分数虚高Jaro–Winkler 并不是要取代编辑距离它更像是“专门给人类输入的短文本”设计的选手。人输错单词时通常是两种情况一是中间某几个字母拼错二是相邻字母写反了。Jaro–Winkler 对这两种情况都做了针对性设计这是它在这个领域好用的根本原因。2. 算法原理拆解窗口、匹配与换位2.1 Jaro similarity 的计算过程Jaro 相似度的核心公式是jaro (m / |s1| m / |s2| (m - t) / m) / 3其中|s1|和|s2|是两个字符串的长度m是“匹配字符数”t是“换位次数”的一半。三个分式的直觉分别对应s1 中有多少比例参与了匹配、s2 中有多少比例参与了匹配、匹配的字符里顺序有多一致。三个分数做平均得到 0 到 1 之间的相似度。这里最容易被忽略的是“匹配字符数”的定义。Jaro 算法不是简单地统计两个字符串里相同字符的个数而是有一个匹配窗口限制match_dist floor(max(|s1|, |s2|) / 2) - 1也就是说s1 里的某个字符只有在 s2 的对应位置前后match_dist的范围内找到相同字符才算匹配。为什么设置窗口因为如果不限制JELLYFISH里的J可能在SMELLYFISH的末尾找不到匹配但很多字符会相隔很远还强行匹配导致相似度虚高。窗口的作用是保证“距离太远的字符不算匹配”这更符合人对“相似”的直觉。用MARTHA和MARHTA手算一遍|s1| 6|s2| 6match_dist floor(6/2) - 1 2逐个字符匹配后m 6所有字符都在窗口内找到对应再看匹配顺序s1 的匹配序列是M A R T H As2 的匹配序列是M A R H T A其中T和H顺序不一致不一致字符数是 2所以t 2 / 2 1代入公式jaro (6/6 6/6 (6-1)/6) / 3 (1 1 0.8333) / 3 ≈ 0.9444这个值很高符合直觉。注意t为什么要除以 2因为一次交换涉及两个字符它们在匹配序列里会贡献两次位置不一致所以实际“交换次数”是不一致字符数的一半。2.2 前辍加权Winkler 改进的精髓Jaro 相似度本身已经够用了但 Winkler 在 1990 年前后做了一个改进让它在“人名匹配”场景下更准。他的观察是两个字符串如果开头几个字符完全一致那么它们很可能是同一个词的不同拼写变体。因为人在拼写错误时往往错误发生在词的中后段开头那么拼错的概率要低得多。于是就有了 Jaro–Winkler 公式jw jaro l * p * (1 - jaro)其中l是两个字符串从头开始连续相同的字符数最多取 4p是缩放系数默认 0.1。还是用MARTHA和MARHTA来算前面我们算出jaro ≈ 0.9444两个字符串的前 3 个字符M A R完全相同所以l 3那么jw 0.9444 3 * 0.1 * (1 - 0.9444) 0.9444 0.0167 ≈ 0.9611分数从 0.9444 提到了 0.9611。别小看这 0.02 的差异在阈值设在 0.95 的业务里这决定了这两条记录能不能合并。l * p * (1 - jaro)这个式子的含义是在 Jaro 相似度的基础上按前缀长度做加成加成的上限是(1 - jaro)的一部分。这样保证无论前缀多长、p 多大最终分数都不会超过 1。2.3 参数 p 和前缀长度上限怎么理解Winkler 原文里建议p 0.1前缀上限l取 4。这两个数值不是随便定的。Winkler 在实验中发现前缀超过 4 个字符之后对区分度的提升基本可以忽略反而可能把不相关的长词推高。p如果太大比如超过 0.25算法会变得“过度自信”只要开头一样后面完全不一样的两个词也能拿到很高的分数。我自己的经验是如果你在做人名、地名的归一化p 0.1、l 4可以直接用这是经过大量实践验证的默认值。但如果你在做短代码、 SKU、订单号的模糊匹配后缀可能比前缀更重要这时候就要把p调小甚至设为 0或者改用纯 Jaro 相似度。2.4 边界情况与取值范围任何算法都有边界。Jaro–Winkler 的取值范围是 0 到 11 表示完全一致0 表示完全不相关。有几个边界情况你需要提前考虑两个字符串都为空串在多数实现里会直接返回 1.0因为空串和空串在业务上可以视为相等一个为空串、另一个非空返回 0.0两个字符串长度差距极大比如一个 4 个字符、另一个 40 个字符由于公式里的m/|s1|和m/|s2|差距很大最终分数往往不会高这符合直觉match_dist可能为 0当最长的字符串长度为 2 时floor(2/2) - 1 0表示只有在相同位置找到相同字符才算匹配这种情况是合理的实现时注意不要让它变成负数。这些边界最常见的 bug 是“除零”。m为 0 时公式里的(m - t) / m会崩溃所以代码里matches 0时应该直接返回 0。3. 完整实现与测试手写一个 Python 版本3.1 核心代码算法原理说清楚了代码就水到渠成。下面是一份我常用的 Python 实现不依赖任何第三方库可以直接复制运行def jaro_similarity(s1: str, s2: str) - float: # 完全相等时直接返回 1.0避免后续无意义的计算 if s1 s2: return 1.0 len1, len2 len(s1), len(s2) if len1 0 or len2 0: return 0.0 # 匹配窗口半径 match_dist max(len1, len2) // 2 - 1 if match_dist 0: match_dist 0 s1_matches [False] * len1 s2_matches [False] * len2 matches 0 # 第一轮按窗口找匹配字符 for i in range(len1): start max(0, i - match_dist) end min(i match_dist 1, len2) for j in range(start, end): if not s2_matches[j] and s1[i] s2[j]: s1_matches[i] True s2_matches[j] True matches 1 break if matches 0: return 0.0 # 第二轮统计换位次数 transpositions 0 k 0 for i in range(len1): if s1_matches[i]: while not s2_matches[k]: k 1 if s1[i] ! s2[k]: transpositions 1 k 1 t transpositions // 2 return (matches / len1 matches / len2 (matches - t) / matches) / 3.0 def jaro_winkler_similarity( s1: str, s2: str, prefix_weight: float 0.1, max_prefix_len: int 4, ) - float: jaro_score jaro_similarity(s1, s2) # 计算公共前缀长度最多 max_prefix_len prefix_len 0 for i in range(min(len(s1), len(s2), max_prefix_len)): if s1[i] s2[i]: prefix_len 1 else: break return jaro_score prefix_len * prefix_weight * (1.0 - jaro_score)注意jaro_winkler_similarity的两个可选参数暴露出来了。在实际项目里这两个参数经常会需要调写在函数签名上比写死在实现里要灵活得多。3.2 代码逐段说明第一轮循环里start和end是窗口边界范围是[i - match_dist, i match_dist]也就是当前字符在另一个字符串中允许匹配的位置范围。not s2_matches[j]保证了一个字符最多被匹配一次防止重复计数。找到匹配就break避免一个 s1 字符吃掉多个 s2 字符。第二轮循环是换位统计。这里用了一个经典技巧先遍历 s1 中所有匹配过的字符再通过k指针按顺序找到 s2 中下一个匹配过的字符。如果两个匹配字符在各自字符串中的位置顺序不一致就计一次 transposition。最后transpositions // 2得到t。这里有个很容易写错的点第一轮里你用的是s1[i] s2[j]来匹配第二轮里却要按“匹配位置”的顺序去比较字符。匹配字符相同不代表顺序一致MARTHA和MARHTA就是典型。所以第二轮一定不能省。3.3 用经典用例验证写完之后一定要用经典用例验证不要只跑一两个自己编的例子。我每次实现字符串算法都会用下面这几个公开数据来测试字符串1字符串2Jaro 期望值Jaro–Winkler 期望值MARTHAMARHTA0.94440.9611DIXONDICKSONX0.76670.8133JELLYFISHSMELLYFISH0.89630.8963DIXON和DICKSONX这个例子特别有意思s1 里的X在 s2 里虽然存在但它的位置超出了匹配窗口所以根本不参与匹配。最终的m 4只有D I O N四个字符匹配上这也解释了为什么 Jaro 分数只有 0.7667而不是更高。3.4 不想手写直接用现成库如果你的项目允许引入第三方依赖我建议直接使用成熟的实现而不是自己维护一套。Python 生态里我常用两个库# pip install jellyfish import jellyfish print(jellyfish.jaro_winkler(MARTHA, MARHTA)) # 0.9611 print(jellyfish.jaro_similarity(MARTHA, MARHTA)) # 0.9444# pip install rapidfuzz from rapidfuzz.distance import JaroWinkler score JaroWinkler.similarity(MARTHA, MARHTA) print(score) # 0.9611jellyfish的优点是实现干净、接口简单rapidfuzz的优点是 C 扩展实现性能比纯 Python 快几十倍适合大数据量场景。如果只是做算法学习用手写版本就足够了如果上了生产环境优先rapidfuzz。有一点要特别提醒fuzzywuzzy这个库虽然名气很大但它底层用的是 Levenshtein 编辑距离不是 Jaro–Winkler。很多初学者以为用fuzzywuzzy就算用上了 Jaro–Winkler这是错的。选型之前先把库的文档看清楚。3.5 时间和空间复杂度Jaro–Winkler 看起来有两层循环但实际上窗口是有限的所以对于短文本来说时间开销非常小近似线性。最坏情况下如果两个字符串都很长复杂度是 O(n * m)但实际场景里 Jaro–Winkler 基本只用在 50 个字符以内的短文本上性能完全没问题。空间上只需要两个布尔数组记录匹配位置空间复杂度 O(n m)。手写版本最多额外用几个变量非常轻量。4. 实际场景怎么用阈值设置与调参经验4.1 姓名与实体去重场景做姓名去重是我用得最多的场景。姓名这种数据长度通常在 2 到 20 个字符之间拼写错误大多是插入、删除、换位Jaro–Winkler 天然匹配这个特征。我在实际项目里的做法是先做人名空格处理和大小写归一化然后分别比较“姓”和“名”再按业务权重加权。比如中文名转成拼音后姓的权重可以设 0.6名的权重设 0.4英文姓名则相反因为英文名常见拼写错误集中在 first name 上。阈值怎么定并没有万能答案但我可以给你一个起点业务场景建议阈值起点说明同一人姓名合并0.88低于这个值误报率会明显上升地址归一化0.85地址变体太多阈值适当调低搜索联想推荐0.70联想不需要太精确召回优先订单号 / SKU 匹配0.95这类数据错一个字符都可能是另一个商品但这只是起点。我强烈建议你抽 500 到 1000 条真实样本人工标注一遍然后在样本上画出阈值和准确率/召回率的关系曲线再定最终阈值。盲目套阈值的后果不是漏了一堆该合并的数据就是把一堆无关数据合并在一起。4.2 搜索提示与拼写纠错场景搜索引擎的“你可能想搜的是不是”功能很多实现里就用到了 Jaro–Winkler。它的逻辑是用户输入一个词候选词典里找到所有相似度超过 0.7 的词按相似度排序取前三名推荐给用户。这个场景有个细节用户输入往往带空格比如san fran这种前缀搜索。我建议先做分词对最后一个词做 Jaro–Winkler 匹配前面的词用精确匹配或者短语匹配这样既保证速度也保证准确度。直接拿完整字符串去算 Jaro–Winkler效果反而不理想因为空格会干扰前缀计算。4.3 文本归一化与字符集问题Jaro–Winkler 是“字符级”算法它不会替你处理大小写、全半角、Unicode 等价字符。所以在送入算法之前一定要先做归一化。我在生产环境里的标准动作是转小写lower()做 Unicode NFKC 归一化把全角字母、数字变成半角过滤无意义字符比如姓名里的点、短横线中文字段可先转拼音或拆成词再比较。NFKC 这一步特别容易被忽略。全角数字和半角123在码位上是完全不同的字符不归一化Jaro–Winkler 算出来可能只有 0.6 甚至更低。另外中文场景下直接对中文字符串做 Jaro–Winkler 意义不大因为中文不存在“拼写错误”的概念更常见的是同义词、简称、繁简体差异。我一般会把中文转成拼音或者分词后再应用到姓氏、地名这类场景。4.4 阈值和参数调整的通用套路调参这件事我总结了一个三步走套路第一步先固定p 0.1、max_prefix_len 4跑一遍你的真实数据看分数分布。第二步根据误报和漏报情况决定调阈值还是调参数。如果误报多不相似的数据分数过高优先降低p或者提高阈值如果漏报多且大多是前缀相同但中段不匹配可以适当提高p。第三步用小样本集反复验证直到在验证集上的 F1 不再提升为止。有一个我踩过的坑我曾在某个项目里为了追求召回把p调到了 0.2当时看结果似乎很好上线后才发现大量完全不同的用户名被关联到了一起。后来复盘发现问题就出在用户名的前缀很多都是同一个词根比如admin_001、admin_002前缀相同导致 Jaro–Winkler 分数虚高。参数不能只看单个案例要在分布层面验证。5. 常见问题与踩坑实录5.1 短字符串分数虚高Jaro–Winkler 对两个字符的短字符串特别“大方”。比如ab和ac手动算一下Jaro 得分约 0.667前缀长度 1Winkler 加成后约 0.7。看起来是“比较相似”但在很多业务里ab和ac可能是完全不同的两个代码。这就是短字符串的先天问题总共就两三个字符一个字符相同就能带来很高比例的分值。我的解决办法是对短于 4 个字符的字符串直接用编辑距离加阈值或者把 Jaro–Winkler 阈值提高到 0.95 以上。也要关注最小长度限制业务上如果允许短字符串干脆不做模糊匹配。5.2 窗口限制导致漏匹配还是回到DIXON和DICKSONX的例子X明明在两个字符串里都存在但因为超出匹配窗口匹配不上。对于短字符串窗口限制是合理的但如果你匹配的是 50 个字符以上的长文本窗口会漏掉大量真实匹配导致分数偏低。如果业务上必须处理长文本我建议一是把match_dist适当调大比如floor(max(len1, len2)) // 2不要减 1二是干脆改用编辑距离或者压缩后的 Token-based 相似度。Jaro–Winkler 的设计目标就不是长文本硬用在长篇大论上会适得其反。5.3 字符集和大小写导致的误判我处理过一次很典型的线上问题一批用户名里有John另一批里有johnJaro–Winkler 算出来分数很高但合并后才发现有些大小写不同的用户名根本不是同一个人。问题出在归一化环节而不是算法本身。更隐蔽的是 Unicode 组合字符。字符é可能有两种码位表示一种是单独的U00E9另一种是e加上组合重音U0301。不做 NFKC 归一化算法会把它们当成完全不同的字符。统一入口的归一化逻辑才能保证算法算出来的分数可靠。5.4 全量两两比较的性能灾难很多人第一次用 Jaro–Winkler 做数据清洗会写一个双重循环把所有记录两两比较一遍。1 万条记录看起来不多但两两比较是 5000 万对。用纯 Python 实现跑完几个小时就过去了。Jaro–Winkler 本身不是性能瓶颈瓶颈是全量笛卡尔积。而且这里还有一个坑Jaro–Winkler 不满足三角不等式不是严格意义上的距离度量所以不能直接套用 BK-tree 这类依赖度量的索引结构。我的做法是分两步第一步用n-gram倒排索引或者编辑距离 BK-tree 做候选召回把每个记录可能相似的候选控制在几十个以内第二步再对候选集用 Jaro–Winkler 精算。这样总计算量能下降几个数量级。5.5 与其他算法组合的实用策略没有哪个算法是银弹实际项目中我通常是组合使用。比如姓名匹配先算 Jaro–Winkler再算一个编辑距离两个分数按一定权重融合。为什么因为 Jaro–Winkler 对换位宽容但可能对“完全不同的字符被强行匹配”也宽容编辑距离对增删比较敏感但可能拼错一个字符惩罚过重。两者互补。组合策略我常用的有三种加权平均final 0.7 * jw 0.3 * (1 - normalized_levenshtein)先用小样本来标定权重硬规则叠加先过 Jaro–Winkler 阈值再过编辑距离阈值双重校验减少误报多路召回用不同算法召回一系列候选最后用 Jaro–Winkler 排序适合搜索场景。5.6 踩坑速查表最后整理一张速查表方便你排查问题现象可能原因解决方案短字符串相似度虚高前缀权重放大效应最短长度限制或提高阈值明显很像的词分数偏低匹配窗口太小调大match_dist或换编辑距离大小写不同的词被合并未做归一化统一lower()全角半角字符匹配不上Unicode 码位差异做 NFKC 归一化长文本匹配失效窗口限制 前缀假设不适用换 Token-based 算法两两比较全量跑太慢笛卡尔积先召回再精算部分无关词分数很高p值过大调回 0.1 或更小最后再说一点个人体会做算法选型时很多人喜欢追求复杂的模型但在我处理过的数据问题里真正管用的往往是这种“小而专”的算法。Jaro–Winkler 的定位非常清晰它不试图解决所有字符串相似度问题它只解决“人类输入的短文本”这一件事所以在这个领域它比很多通用算法都准。我实际用过之后最大的体会是不要盲目改参数先跑小样本看分布再决定调阈值还是调权重也不要只盯着一个算法组合使用才能覆盖更多真实场景。希望你读完这篇能少踩一些我踩过的坑。
返回列表