上周帮运营同事整理一份客户名册时,我发现同一个公司被录入了三种互不相同的写法:“北京智联天下科技有限公司”“智联天下(北京)科技有限公司”以及一句简短的“智联天下”。Excel自带的“删除重复项”一个都没抓到,因为它们并不完全相同。当时我就意识到,Excel数据清洗里最磨人的问题不是“完全重复”,而是“看起来重复、又不完全重复”。这次借着一个办公自动化的实战需求,我把这套思路完整沉淀下来:怎么用字符串相似度算法,把Excel中这种“隐形重复数据”成批找出来。文章既讲算法原理,也给可直接运行的Python脚本,适合经常和Excel表打交道、想用自动化代替手工肉眼查重的朋友参考。
1. 一版“看起来重复”的客户表,Excel自带查重为什么抓不到
1.1 三种最常见的“伪不同”数据
在真实表格里,两条记录明明是同一个实体,字符串层面却完全不同,通常逃不出下面几类情况:
- 格式差异:多余空格、全角半角混用、标点符号位置不同。比如“智联天下科技”和“智联天下 科技”,肉眼能看出来,
COUNTIF却算它们不重复。 - 文字差异:错别字、简繁体混用、同音字替换。比如“张叁”和“张三”,“王静”和“王婧”,字符层面不是一回事。
- 结构差异:简称与全称、公司名带不带地区/括号/行业描述。比如“智联天下”和“智联天下(北京)科技有限公司”,长度差出一大截。
这三种情况Excel原生功能都很难处理,因为它们本质上属于“模糊匹配”范畴,而不是“精确匹配”。
1.2 Excel内置去重功能的三个盲区
我经常看到有人拿“删除重复项”按钮硬扛这类数据,不是说按钮没用,而是它的定位是处理完全一致的记录。在遇到上述三种情况时,它会暴露三个明显盲区:
第一,它只能做逐字段全等判断。两行数据只要任一字符不同,就会被判定为两条新记录。
第二,高级筛选和COUNTIF虽然支持通配符,但通配符能力有限。*和?只能处理位置固定的缺字或加字,处理不了“顺序不同但意思相同”的字符串。
第三,它没有“相似度”概念。Excel不知道“北京分公司”和“北京分部”有多像,只知道“不相等”。要做模糊查重,必须跳出Excel函数,引入算法层面“量化相似”的思维。
1.3 为什么相似度算法比“等号判断”更接近人的判断
人对“两条数据是否重复”的判断,靠的是语义而不是字符。看到“智联天下”和“智联天下(北京)科技有限公司”,你会自动忽略修饰成分,提取核心词“智联天下”,然后觉得它们指代同一家。
字符串相似度算法做的事情,其实就是把这种“主观感受”翻译成可计算的数字:给两个字符串打分,越像分越高。当分数超过某个阈值,就判定为疑似重复。到这里,思路就清晰了:用相似度得分替代“等号”,把肉眼比对替换成批量计算。
2. 三种常用的字符串相似度算法:各自解决哪一类重复
字符串相似度算法不止一种,《索引》里最常用的是以下三种,它们的视角各不相同,适配的重复形态也不同。
2.1 编辑距离:错别字与短文本场景的首选
编辑距离(Levenshtein Distance)衡量的是“把一个字符串变成另一个字符串,最少需要多少次插入、删除、替换操作”。比如“张三”变成“张叁”,需要1次替换,编辑距离就是1。距离越小,文本越像。
实际使用时,通常会把它转成一个0到1之间的相似度分数:
similarity = 1 - (编辑距离 / max(len_a, len_b))“北京分公司”和“北京分部”,编辑距离是1(替换“公”为“部”),max_len是5,相似度就是0.8。可以看出,编辑距离对错别字、短文本特别友好。但它也有一点局限:对词语顺序不敏感的问题处理不太好。“智联天下科技”和“科技智联天下”需要多次移动操作(实际是多次删除+插入),编辑距离会给出很低的分,尽管语义是一样的。
2.2 n-gram与杰卡德系数:应对顺序变化和插入冗余词
n-gram的基本思路是把字符串切成连续的片段集合。以bigram(两个字符的连续片段)为例,“智联天下”的bigram集合是:{智联, 联天, 天下}。再算两个集合的交集大小除以并集大小,就是杰卡德相似系数。
这种视角天然地容忍文字顺序变化和中间插入冗余词。“智联天下科技”的bigram是{智联, 联天, 天下, 科技},“科技智联天下”的bigram是{科技, 技智, 智联, 联天, 天下}。交集是{智联, 联天, 天下},并集有6个元素,相似度0.5。换成编辑距离,这两个字符串的相似度很可能不到0.3。
所以,当数据里出现大量“词语重组”和“中间夹带无关内容”时,n-gram + 杰卡德的组合明显更稳。
2.3 TF-IDF与余弦向量化:长文本与语义层面的进阶选择
如果处理的是长文本,比如商品描述、公告正文、地址详情,字符级别的算法会显得吃力。这时可以先对文本分词,再用TF-IDF把词转成向量,最后算余弦相似度。两个文本在向量空间里越接近,说明用词越重合。
但在Excel查重场景里,大部分字段是短字符串(公司名、人名、物料名、地址),分词和向量化的收益有限,还容易引入额外误差。所以我在短文本场景下很少用它,更多把它当作一个边界说明——算法管得越来越宽,但对应的实现成本和调参难度也在上升。
| 算法 | 核心思路 | 优势 | 短板 | 最适场景 |
|---|---|---|---|---|
| 编辑距离 | 最小编辑次数 | 对错别字敏感,直觉清晰 | 不擅长词语乱序 | 人名、短编号 |
| n-gram + 杰卡德 | 字符片段集合重合度 | 容忍乱序与冗余词 | 短文本噪音较大 | 公司名、品名 |
| TF-IDF + 余弦 | 词向量空间距离 | 能发现语义近似 | 短文本上提效有限 | 长描述、正文 |
3. 用Python把算法和Excel串起来:完整可跑的查重脚本
3.1 准备环境与读取Excel
实际项目中,我用的组合是pandas + openpyxl + rapidfuzz。rapidfuzz是专门做模糊匹配的库,兼容difflib和python-Levenshtein的常见写法,但底层是C++实现,速度更快,安装也更省心。
读取Excel时有一个细节能帮你避开大量后续问题:读进来的所有列强制转成字符串。
import pandas as pd df = pd.read_excel("客户表.xlsx", sheet_name="Sheet1", dtype=str) print(df.head())如果不加dtype=str,编号列和手机号很容易被读成数值型,前导零丢失、长编码变成科学计数法。这类问题在查重阶段会变成“假差异”,干扰算法判断。
3.2 字符串归一化:查重前必须做的一次“大扫除”
很多人拿到数据直接跑相似度,效果往往很差。原因在于原始数据里混着大量格式噪音,这些噪音会拉低分数。正确的做法是先做归一化,把“明知道是格式差异”的部分提前抹平。
import re import unicodedata def normalize_text(text): if pd.isna(text): return "" # 统一全角半角 text = unicodedata.normalize("NFKC", str(text)) # 转小写(对英文、编码有用) text = text.lower() # 去除所有空格和常见标点 text = re.sub(r"[\s,。!?、;:()()【】\[\]\-—_/\\,.!?;:]+", "", text) return text.strip()这段代码做了三件事:全角转半角、英文转小写、去掉空格和常见标点。做完之后,“智联天下(北京)科技有限公司”和“智联天下-北京-科技有限公司”会在同一个基准上参与比较,而不是被标点和括号干扰。
要注意,去括号这一步要结合业务判断。如果括号里的内容代表不同分公司或不同用途(比如“张三(临时)”和“张三(正式)”),去括号可能会把本来不该合并的数据合并掉。我会建议先把括号内容单独拆列保存,再做归一化,这样后面需要时还能拿回来。
3.3 核心代码:逐对计算相似度并分组
归一化之后,就可以计算相似度了。这里我建议同时拿两个指标做聚合,不要迷信单一算法:
from rapidfuzz import fuzz def combined_similarity(text_a, text_b): if not text_a or not text_b: return 0 # ratio 基于编辑距离,token_set_ratio 对词序不敏感 return max( fuzz.ratio(text_a, text_b), fuzz.token_set_ratio(text_a, text_b) )fuzz.ratio拿编辑距离做底,适合抓错别字;fuzz.token_set_ratio会先分词、去重、排序再比较,适合抓“词语乱序”和“多词少词”的情况。取两者的最大值,相当于让两种算法互相补位。
然后是批量比较的部分。我通常不直接删除数据,而是把疑似重复的对子全部筛出来,输出成一张待审清单,交给业务方确认后再清洗。这样做有两个好处:避免算法误判造成不可逆的数据损失;方便追溯清洗规则,做完之后还能向团队解释“为什么这两行被合并了”。
def find_duplicate_pairs(df, text_col="name", threshold=85): texts = df[text_col].fillna("").tolist() n = len(texts) pairs = [] for i in range(n): for j in range(i + 1, n): a, b = texts[i], texts[j] # 长度差超过一半的,大概率不是同一实体,跳过可以大幅提速 if abs(len(a) - len(b)) > max(len(a), len(b)) * 0.5: continue score = combined_similarity(a, b) if score >= threshold: pairs.append({ "row_a": i, "row_b": j, "score": score, "text_a": a, "text_b": b, }) return pd.DataFrame(pairs) result = find_duplicate_pairs(df, text_col="客户名称", threshold=85) result.to_excel("疑似重复清单.xlsx", index=False)这里面有一个小的优化点:长度差超过百分之五十的直接跳过。“智联天下科技”和“智联天下(北京)科技有限公司”虽然长度差很大,但只要一个是另一个的近似子串,还是会被保留下来;而“abc”和“某大型集团股份有限公司”这种长度差离谱的组合,根本不用计算编辑距离,省时间。
3.4 为什么用“分组编号”而不是直接标记重复
上面输出的是两两配对表,但实际使用中会碰到更复杂的情况:A和B相似,B和C相似,A和C只是勉强相似。如果只输出两两关系,整理起来依然是多对多的乱麻。
更实用的做法是引入“并查集”,把互相连通的疑似重复记录合并成同一个组,然后输出一个带组编号的清单:
class UnionFind: def __init__(self, n): self.parent = list(range(n)) def find(self, x): while self.parent[x] != x: self.parent[x] = self.parent[self.parent[x]] x = self.parent[x] return x def union(self, x, y): rx, ry = self.find(x), self.find(y) if rx != ry: self.parent[ry] = rx uf = UnionFind(len(df)) for _, peak in result.iterrows(): uf.union(int(peak["row_a"]), int(peak["row_b"])) df["group_id"] = df.index.to_series().apply(lambda idx: uf.find(idx)) df.to_excel("分组结果.xlsx", index=False)这样,每一行都会有一个group_id,同一个组里的人互相之间就是“疑似重复家族”。后续人工复核时,只看组内记录即可,不用再翻两两配对表。
4. 阈值不是拍脑袋定的:用数据分布校准相似度门槛
4.1 从分数分布里找拐点
threshold=85这个值是我常用的起点,但它不是一个“万能真理”。不同表的书写习惯差异太大,正确做法是先让算法算一遍分数,看分数分布,再决定阈值。
scores = [] for i in range(min(200, n)): for j in range(i + 1, min(200, n)): scores.append(combined_similarity(texts[i], texts[j])) pd.Series(scores).describe(percentiles=[.5, .75, .9, .95, .99])这个分布的规律通常会呈现两极分化:大部分记录对的分数在0到40之间,极小部分在95到100之间,中间地带(60到85)比较稀疏。如果分布真是这样,选85作为阈值就很稳;但如果中间地带很密集,就说明数据书写风格差异很大,85可能会漏掉一批,需要调低。
我习惯的做法是输出分数区间和对应的对数,人工抽查每个区间的10对记录,判断它们是否真的重复:
| 分数区间 | 对数 | 人工抽查结论 |
|---|---|---|
| 90-100 | 132 | 基本全是重复 |
| 80-89 | 45 | 大概七成是重复 |
| 70-79 | 23 | 不到三成是重复 |
| 60以下 | 1987 | 基本不相关 |
如果忘了抽查直接删,很容易把大量“仅是长得像但不是同一个”的记录合并掉。比如“北京金隅集团”和“北京金隅股份有限公司”可能是同一家,但“北京金隅大厦”却是一个建筑物,分数接近也不该合并。
4.2 先严后松的两轮策略
面对一张完全没清洗过的表,我不建议一上来就追求“沙尽水清”。正确节奏是:
- 第一轮用高阈值(比如90)跑一遍,把最明显的重复先拎出来。这些数据人工审核成本最低。
- 审核确认后,删除或合并这批确定重复项。
- 第二轮再把阈值降到80再跑一遍,这时候选集小了很多,人工核对的压力也就小了。
这个策略的核心是控制信用成本。算法输出的候选名单,最终还是要靠人点头的,先输出100%确定的,再慢慢放权给低分数区间,整体风险更可控。
4.3 阈值参数也是业务规则
我后来发现,阈值本质上是一个业务规则,而不是技术参数。比如银行客户姓名里出现同音不同字,几乎可以断定是误录,阈值可以放宽;但产品批次号里“A-1002”和“A-102”完全不相同,哪怕相似度再高也不能合并。所以在跑算法之前,一定要先和业务方确认:“这条字段里,多少相似度才允许判定为重复?”否则技术养出来的模型,业务是不敢接的。
5. 数据量上来之后:性能优化与工程化改造
5.1 先做精确碰撞,再谈模糊匹配
Excel里常见的几千条数据,用双循环还能扛得住;一旦到几万、几十万行,O(n²)的双循环就完全不可行了。以5万行为例,双循环要比较12.5亿对,这不现实。
第一步优化,是先用归一化后的字符串做一轮精确匹配。由于很多“看起来很像”的记录在归一化后会变成完全相同的字符串,那它们根本不需要走模糊计算,直接归到一个组就行。
df["key"] = df["客户名称"].apply(normalize_text) df["group_id_exact"] = df["key"].groupby(df["key"]).ngroup()之后对非精确重复的行,再按组进行模糊匹配,候选集瞬间缩小。
5.2 用n-gram索引剪枝,把O(n²)变成近似线性
模糊匹配这步,还能用“n-gram倒排索引”进一步做剪枝。思路很简单:只有共享至少一个n-gram的字符串,才可能存在高相似度。
以bigram为例,“智联天下”包含{智联, 联天, 天下},它只需要和同样包含这三个片段中任意一个的字符串计算编辑距离,完全不共享片段的记录可以直接排除。
from collections import defaultdict def build_ngram_index(texts, n=2): index = defaultdict(list) for idx, text in enumerate(texts): if len(text) < n: continue ngrams = set() for i in range(len(text) - n + 1): ngrams.add(text[i:i+n]) for g in ngrams: index[g].append(idx) return index实际跑下来,剪枝之后真正需要计算相似度的对子通常只剩原来的百分之几,几万行数据也能在几分钟内跑完。
5.3 数据量再大,就要换工具了
几十万行以上,Python双循环加索引也扛不住,这时候我一般会切换成两个方向:
一是用SQLite加spellfix1扩展,把字符串放进数据库里模糊匹配,可以借助B-tree索引和并行查询,处理百万级数据也相对从容。
二是直接上RapidFuzz的多进程模式,或者把字符串向量化成n-gram集合后,用HNSW之类的向量索引做最近邻搜索。这已经是模糊匹配的工程化玩法了,普通办公场景一般用不到,但遇到需要全量跑相似度的场景时,值得作为备选方案。
6. 这套方案在真实办公场景中的延伸与边界
6.1 除了客户表,还能拿来干嘛
同样的思路,我在考勤表、库存表、发票台账里都用过。典型例子:
- 物料名称清洗:“304不锈钢螺丝M4*10”和“304不锈钢螺丝M4X10”,编辑距离很低,但n-gram算法能识别出它们的高度重合,合并后会显著减少库存表里的重复物料。
- 地址查重:“北京市朝阳区建国路88号”和“北京朝阳建国路88号”,在归一化并去掉“市”“区”后缀后,相似度能达到90以上,对做地理位置归类很有帮助。
- 发票抬头去重:企业开票信息表里经常混着“某某公司”和“某某有限责任公司”,用这套脚本跑一遍,开票系统就能少弹出一堆重复抬头。
关键是抽象的“字符串相似度”能力是通用的,换一张表只需要换列名,算法逻辑不用动。
6.2 算法管不了的重复:语义重复与规范不统一
相似度算法有一个明显边界:它处理不了表面不同、语义相同的文本。“中国移动通信集团北京有限公司”和“北京移动”,从字符角度看几乎没有任何重合,但人在阅读时一眼就知道是同一家。这类重复只能依赖业务词典、正则规则和知识库映射,字符串算法在这个场景下能做的贡献很有限。
所以,我的建议是把这套方案定位成“帮你把99%的简单重复拎出来”的自动化工具,剩下1%的语义级重复,单独交给基于词典或人工规则的系统去处理。两者配合,而不是指望一个算法搞定所有场景。
6.3 一次最值得投入的自动化改造
在各式各样的Excel数据清洗需求中,字符串相似度查重是我个人觉得性价比极高的一次自动化改造。原因是它的逻辑清晰、可解释性强,同时能直接替代大量机械式的人工比对。比起写复杂Excel公式和宏,用Python脚本处理后还能自动输出审核清单和分组编号,全过程可追溯。
如果你手头正压着一批看着“乱七八糟”的Excel数据,先从一个小样本跑一遍分数分布开始,可能就有惊喜。