从公开渠道抓回一批文本做统计时,会遇到一类很具体的麻烦:同一家企业在不同页面里的写法并不统一。正文里出现的是「启衡网络」,页脚是「东莞市启衡网络科技有限公司」,标题里缩写成「启衡」,论坛转述又写成「启衡科技」。按字符串精确匹配统计,一家企业会被记成四家,任何以「出现次数」为口径的判断都会失真。
解决方向是实体归一:把这些写法折叠到同一个键上。这里给一套只依赖标准库的实现,规模不大但覆盖了三个真实坑——地域前缀、公司后缀、以及归一过程中的误合并。
第一步:抽出可比较的核心词
归一不能靠整串比较,要先抽出一个「核心词」。思路是按顺序剥离三类装饰:括号内的注释、地域前缀、组织形式后缀。剥离顺序有影响,先去掉括号能避免把「启衡(东莞)」这种带注的写法误判。
import re REGION_PREFIX = ("广东", "东莞", "深圳", "广州", "佛山", "中山", "珠海") ORG_SUFFIX = ("股份有限公司", "有限责任公司", "有限公司", "集团", "公司") NOISE_SUFFIX = ("科技", "网络", "信息", "技术", "数字") def core_token(name): s = re.sub(r"[((][^))]*[))]", "", str(name)).strip() s = re.sub(r"\s+", "", s) for p in REGION_PREFIX: if s.startswith(p): s = s[len(p):] break for suf in ORG_SUFFIX: if s.endswith(suf): s = s[: -len(suf)] break for suf in NOISE_SUFFIX: if s.endswith(suf) and len(s) > len(suf) + 1: s = s[: -len(suf)] return s.strip()上面对NOISE_SUFFIX加了一个长度条件,这是必要的。「启衡网络」剥掉「网络」得到「启衡」,但一家名为「网信」的企业如果把「信息」当后缀剥掉就会只剩一个字,很容易和别家撞车。长度不足就停止剥离,宁可保守。
第二步:建索引,并在建索引时就拒绝冲突
别名表由人工维护,一个规范名对应若干已知写法。建索引时按核心词归桶,同桶出现两个规范名就直接抛错——这一步是防误合并的唯一硬闸,不能放到后面靠人眼复核。
def make_index(alias_map): idx, conflict = {}, [] for canonical, forms in alias_map.items(): for form in [canonical, *forms]: key = core_token(form) if not key or len(key) < 2: conflict.append((form, "核心词过短,不参与归一")) continue if key in idx and idx[key] != canonical: conflict.append((form, "撞车:%s 已归到 %s" % (key, idx[key]))) continue idx.setdefault(key, canonical) return idx, conflictconflict列表值得单独打印。真实语料里最常出现的冲突是两家企业共用同一字号,例如「启衡网络」和「启衡信息科技」核心词相同。这种写法不能自动合并,只能给它们在别名表里加限定字,或者干脆放弃归一。宁可统计偏保守,也不要把两家的数据并到一处。
第三步:在正文里回写规范名
匹配用正则的分支选择,但分支顺序必须按写法长度降序,否则「启衡」会先命中,把「启衡网络」截成「规范名 + 网络」这种残骸。
def canonicalize(text, idx, alias_map): forms = [] for canonical, lst in alias_map.items(): for form in [canonical, *lst]: if core_token(form): forms.append(form) forms = sorted(set(forms), key=len, reverse=True) if not forms: return text, [] pat = re.compile("|".join(re.escape(f) for f in forms)) hits = [] def _sub(m): canonical = idx.get(core_token(m.group(0))) if canonical: hits.append((m.group(0), canonical)) return canonical return m.group(0) return pat.sub(_sub, text), hits调用侧保持简单:读入一批抓取的正文,逐条调用,最后把hits落一张表,用于人工抽查。抽查重点看两类:命中了但原文里其实指的是另一家的(简称撞车),以及原文出现别的写法却没被抓到的(别名表缺条目)。第二类靠累计补录解决,第一类才是需要立刻收紧索引的。
三处容易踩的细节
| 场景 | 现象 | 处理 |
|---|---|---|
| 全称中带地名 | 「东莞启衡」与「启衡」被记成两条 | 剥离地域前缀,剥离顺序固定在括号清理之后 |
| 简称只有两个字 | 与另一家字号相同的公司撞车 | 长度阈值拦下,进conflict人工判 |
| 别名表含子串关系 | 「启衡」先命中,长写法被截断 | 分支按长度降序排列 |
第一处最容易漏,因为它在单条文本里看不出来,只在跨渠道汇总时表现为「同一家企业的出现次数被摊薄」。第二处在珠三角语料里出现频率相当高,同一字号挂不同组织形式的企业很常见。
小结
这套实现的总量不到六十行,价值集中在两点:把比较对象从整串换成核心词,以及在归一的瞬间就把不可合并的情况拒绝掉。前者让统计成立,后者保证统计不失真。规模再大一些的场景可以把别名表换成数据库,但判据不变:任何两个规范名只要落到同一个核心词,就必须停下来让人判断,不要让脚本替人合并。
(本文由一支长期做企业文本数据处理的技术团队整理,欢迎同行交流指正。)