两张表人名对不上?Python 模糊匹配合并实测:先精确,再拼音,最后才相似度

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 不配模糊匹配必然有假阳性,用置信度分层把错误关进确认组

实跑:11 行混合写法 → 8 自动合并 + 3 明确不配

主表(花名册,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 导出的空单元格读进来是 None 不是 ""

拼音列兜底在真实 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按人去重保留最新 · 博客目录

合并结果仅供参考,涉及薪资/考勤等敏感数据请人工复核确认组后再入库。