2026-10-06 · 喵窝资源铺
场景:花名册是 HR 系统导的,加班表是考勤机导的,两边的「人名」对不上——「李 娜」中间两个空格、「陈志强(已离职)」挂着备注、外文名「O ' Brien Tom」间隔号空格随心写、还有人直接填了拼音 zhaolei。VLOOKUP 一拉一片 #N/A。这篇给一个纯标准库的 Python 脚本(name_fuzzy_merge.py),用三级策略合并两张表,并把每一级为什么是这个顺序用实跑结果讲清楚。文中代码实跑验证,真实输出贴在下面。
| 级别 | 做什么 | 为什么必须在这个位置 |
|---|---|---|
| ① 归一化精确 | 去空格/全角空格、统一间隔号、剥掉「(已离职)」这类括号备注,然后查字典 | O(1) 且零误报,能解决的绝不留给模糊匹配 |
| ② 拼音/工号兜底 | 名字本身或辅助列是纯 ASCII 拼音/工号时精确对上 | 相似度对 zhaolei 这种输入完全失灵,必须走等值通道 |
| ③ difflib 相似度 | ≥0.92 自动合并,0.75–0.92 出「人工确认」组,低于 0.75 不配 | 模糊匹配必然有假阳性,用置信度分层把错误关进确认组 |
主表(花名册,10 人)× 从表(加班表,11 行,写法全打乱):
自动合并 7 / 需人工确认 0 / 未匹配 4 未匹配: 王 破 玲 未匹配: zhaolei 未匹配: 周 洁 未匹配: 郑小龙
合并输出(节选):
表内姓名,匹配到,相似度,部门,入职日期 张伟,张伟,1.0,技术部,2023-03-01 李娜,李 娜,1.0,人事部,2022-07-15 O ' Brien Tom,O'Brien Tom,1.0,销售部,2024-01-10 陈志强(已离职),陈志强,1.0,技术部,2020-05-06 刘 颖,刘颖,1.0,市场部,2023-09-01
主表加一列拼音后重跑,zhaolei 也正确落到赵磊头上(相似度 1.0):
zhaolei,赵磊,1.0,技术部,zhaolei,2019-12-01
剩下三个不配的逐个检查:「王 破 玲」——如果真有这个人是王晓玲的错别字,相似度 0.667 落在「不配」区是正确行为,交给人工一眼定;但它和「王晓彤」(0.667)分数一样,脚本如果自动并了,两个不同的人就会被并到同一行。「周 洁」——「洁」和「杰」是不同的字,0.5 不配是对的,自动化最怕的就是这种「看起来像同一个人其实不是」。「郑小龙」——和任何人都不像,0.0,干净利落。不配 ≠ 脚本失败,不配是脚本在拒绝猜测。
第一版想解决「主表存花名简称」的问题:从表全名包含主表简称就放行。结果兜底条件写得太松——「郑小龙」和「张伟」没有任何包含关系,却因为兜底分支的find() >= 0对空串恒成立(''.find in语义坑),被自动合并成了张伟,还带上了技术部的部门信息。
实测抓到后修复:包含兜底必须同时满足「双方归一化后都 ≥2 字」和「一方是另一方的完整子串」:
if len(nc) >= 2 and len(mn) >= 2 and (mn in nc or nc in mn):
inc = mrec # 只有真包含才放行
修复后「郑小龙」回到不配组。教训:兜底分支的放行条件要单独写测试,它最容易在「帮倒忙」。
「陈志强(已离职)」直接算相似度,「(已离职)」五个字符拉低了分数,第一版掉进人工确认组。
正解:括号内容在归一化阶段就剥掉(re.sub(r'[((].*?[))]', '', s)),但注意输出里要保留原文——人事看到「陈志强(已离职)」出现在加班表里本身就是要处理的信息,剥掉的只是「参与比较」的那部分。归一化只影响匹配,不改写数据。
主表里存的是「王晓玲」,从表里写「王破玲」倒还好;反过来主表存花名「玲玲」、从表写全名「王晓玲」时,只做「从表→主表」方向的包含判断就永远配不上。
正解:包含判断双向都查(mn in nc or nc in mn),但这也正是坑一松条件的来源——所以双向包含必须绑着「≥2 字」约束一起上,两个坑是同一处代码的两面。
拼音列兜底在真实 Excel 里第一次跑就炸:'NoneType' object has no attribute 'strip'——空单元格经 pandas/Excel 往返后是None,对它调.strip()直接崩。CSV 测试文件里永远遇不到这个。
正解:所有单元格先过 v is not None 再取值。这也是为什么实跑要模拟「脏输入」:CSV 测试数据太干净,是数据类脚本自测的最大盲区。
「王 破 玲」→ 王晓玲 0.667,「王晓彤」→ 王晓玲也是 0.667。如果阈值设 0.6「就多帮用户配一点」,这两个人会被合并到同一行,加班时数直接串人。
正解:阈值卡 0.75 以下宁可不配。模糊合并的底线是「错并」比「不并」糟糕得多——不并顶多人工补几行,错并是把别人的工资算到另一个人头上。
同一对输入连跑两次,输出文件 md5 一致(dddd2a97… == dddd2a97…)——脚本不维护任何状态,输出只由输入决定,可以放心放进定时任务反复跑。
python name_fuzzy_merge.py 主表.csv 从表.csv 主表姓名列 从表姓名列 主表要带的列1,列2 输出.csv
纯标准库,Python 3.8+ 直接跑。两表姓名列名可以不同(真实场景就是「花名册姓名」对「姓名」),输出保留从表原文 + 匹配结果 + 相似度 + 主表补充列,人工确认组和不配组原样给出,不静默丢弃。
#!/usr/bin/env python3
"""name_fuzzy_merge.py — 两张表人名写法不一致时模糊匹配合并
用法: python name_fuzzy_merge.py 主表.csv 从表.csv 主表姓名列 从表姓名列 主表附加列1,列2 输出.csv
策略: ①归一化精确(去空格/间隔号/剥括号备注) ②拼音/工号列兜底
③difflib 相似度: ≥0.92 自动并, 0.75-0.92 人工确认, 以下不配
"""
import csv, difflib, re, sys
def _strip_paren(s):
return re.sub(r'[((].*?[))]', '', s).strip()
def norm(s):
s = _strip_paren(s)
return ''.join(s.split()).replace('\u3000', '').replace('·', '').replace('・', '')
def is_ascii(s):
return s and all(ord(ch) < 128 for ch in s)
def load(path, name_col, extra_cols):
rows = []
with open(path, encoding='utf-8-sig') as f:
for r in csv.DictReader(f):
name = r.get(name_col, '')
if not name or not name.strip():
continue
name = name.strip()
rest = {k: r[k] for k in extra_cols if k in r}
# 拼音/工号列: ASCII 且长度够,作为等值匹配的辅助键
aux = [v for v in rest.values()
if v is not None and is_ascii(v.strip()) and len(v.strip()) >= 3]
rows.append({'name': name, 'rest': rest, 'aux': aux})
return rows
def match(master_names, cand):
n = norm(cand)
if not n:
return None, 0.0
best = max(master_names, key=lambda m: difflib.SequenceMatcher(None, norm(m), n).ratio())
score = difflib.SequenceMatcher(None, norm(best), n).ratio()
return best, score
def run(master_path, cand_path, master_col, cand_col, extra_cols, out_path,
auto=0.92, review=0.75):
master = load(master_path, master_col, extra_cols)
cands = load(cand_path, cand_col, extra_cols)
master_names = [m['name'] for m in master]
by_name = {norm(m['name']): m for m in master}
auto_rows, review_rows, nomatch = [], [], []
for c in cands:
best, score = match(master_names, c['name'])
row = {'表内姓名': c['name'], '匹配到': best, '相似度': round(score, 3), **c['rest']}
nc = norm(c['name'])
exact = by_name.get(nc)
# 拼音兜底: 从表名是拼音,或主表某列里有对应拼音/工号
if exact is None:
probes = [nc] + [norm(v).lower() for v in c['aux']]
for p in probes:
for mrec in master:
for v in mrec['aux']:
vn = norm(v).lower()
if vn and is_ascii(vn) and vn == p:
exact = mrec; break
if exact: break
if exact: break
if exact is not None:
row['匹配到'] = exact['name']; row['相似度'] = 1.0
row.update(exact['rest']); auto_rows.append(row); continue
if best is not None and score >= auto:
mrec = by_name.get(norm(best))
if mrec is not None:
row.update(mrec['rest']); auto_rows.append(row); continue
if score >= review:
review_rows.append(row)
else:
nomatch.append(row)
all_rows = auto_rows + review_rows + nomatch
with open(out_path, 'w', newline='', encoding='utf-8-sig') as f:
w = csv.DictWriter(f, fieldnames=list(all_rows[0].keys()))
w.writeheader(); w.writerows(all_rows)
return auto_rows, review_rows, nomatch
if __name__ == '__main__':
a, r, n = run(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4],
sys.argv[5].split(','), sys.argv[6])
print(f'自动合并 {len(a)} / 需人工确认 {len(r)} / 未匹配 {len(n)}')
for row in r: print('确认:', row)
for row in n: print('未匹配:', row['表内姓名'])
喵窝资源铺提供代跑脚本服务:你把两张表发过来,跑完把合并结果(含人工确认组清单)发回你,¥5 起,虚拟物品不退款(跑不通不收费)。脚本本身免费自取。
📖 相邻文章:列名不统一的多个Excel怎么合并 · 两份流水账自动对账 · CSV按人去重保留最新 · 博客目录
合并结果仅供参考,涉及薪资/考勤等敏感数据请人工复核确认组后再入库。