Colibri语料模式挖掘:多词表达抽取与n-gram实战
做多词表达抽取那几年我踩的第一个大坑不是算法而是太相信自己手写的规则。当时手上有几十万条行业问答文本任务是找领域里的固定搭配和术语。我一开始的做法很朴素停用词表加正则再配一份人工维护的白名单。规则从三十条涨到三百条召回率确实上去了但误报也跟着涨最要命的是每换一个业务线规则表就得重写一遍维护成本比模型本身还高。后来我把思路换了一下与其告诉机器什么是一个词不如让机器告诉我哪些连续出现的字符串有统计上的黏性。这条路走到最后落点就是一个叫Colibri的语料模式挖掘工具包。它做的事情可以用一句话概括把一份纯文本语料变成一张带频次的模式表——模式可以是连续的 n-gram可以是中间带空隙的 skipgram也可以是允许形态变化的 flexgram同时它用词类编码把庞大的词表压成紧凑的整数索引让几十 GB 的语料也能在单机上跑完。这篇文章写给三类人正在做术语抽取、关键词发现、固定搭配挖掘的同学需要做语料对比、模板套话检测的同学以及想给下游任务分类、检索、质量评估造一批可解释特征的同学。我会把流水线拆开讲清楚每一步为什么这么做也会把阈值怎么定、内存被谁吃掉、哪些坑会让人白跑一晚上这些实操细节全部摊开。基础不用很深能跑命令行、会一点 Python 就够。1. colibri 的定位它处理的不是分词而是哪些词串值得被当成一个单位先说清楚边界不然后面全是误解。Colibri 不是分词器也不是句法分析器。它不判断北京大学应该切成一个词还是两个词它只回答一个统计问题在给定语料里某个词串可以带间隔、可以带变形出现了多少次值不值得被当成一个整体来看待。这个定位决定了它的输入是已经切好的词序列输出是模式 频次 覆盖信息。1.1 从词表思维切换到模式表思维大多数人的第一反应是建词表抽词频看 top 1000人工筛。问题在于真正有信息量的表达往往不是单个词而是两到六个词的组合比如这个方案落地的风险点这种结构。单看词频方案落地风险都很普通但它们的组合在特定语料里可能异常集中。模式表的价值就在这它把组合当成一等公民来统计。你会看到类似这样的候选数字是频次P 1832 方案 落地 风险 P 977 落地 风险 点 P 604 方案 落地 风险 点拿到这种表之后判断方式就从这个词重不重要变成这个组合的频次和分布是否异常。前者靠直觉后者靠数据这是本质区别。1.2 词类编码一个被低估的索引压缩手段Colibri 有个前置步骤叫 class encoding我一般叫它词类编码。它的做法是给每个词分配一个整数编号并把所有编号写进一份映射文件通常叫 class 文件同时把原始文本转成一份二进制语料文件。之后所有的模式挖掘都在整数序列上做而不是在字符串上做。为什么要绕这一圈三个原因比较快整数比较远快于字符串比较模式计数本质上就是大量比较和哈希操作。比较省一个 token 从平均六七个字节压到两三个字节几十 GB 的语料能省下可观的内存和磁盘。能做映射编码层可以顺手处理一些归一化需求——比如把所有纯数字 token 归到一个统一类别把超出词表的长尾词归到另一个类别。这样既控制了词表规模又不会因为某个数字变了就让模式对不上。注意编码映射一旦确定最好固定下来。同一份映射复用在不同批次的语料上你才能横向比较这批文本里某模式变多了还是变少了。每次重新编码编号全变历史结果就没法对齐了。1.3 n-gram、skipgram、flexgram三种模式各自适合什么场景这是 Colibri 最核心的三个概念也是最容易被混用的地方。我用一张表把它们摆清楚。模式类型结构特点典型用途主要风险n-gram严格连续中间不能有间隔固定搭配、术语、套话模板检测长 n 的候选数量爆炸skipgram允许中间跳过若干 token存在插入成分的框架表达如不仅……而且频次天然虚高容易把无关词拼在一起flexgram允许在词类或形态层面有变化形态丰富的语言、变体表达归并抽象过度可解释性下降举个具体例子。风险 控制 措施是 n-gram要求三个词紧挨着风险 …… 措施是 skipgram中间允许插入若干词能覆盖风险防控的各项具体措施这种写法而 flexgram 则进一步允许风险/风控这类变体被归到同一个模式上。三者是递进关系不是替代关系。提示不要一上来三个全开。我通常的节奏是先用 n-gram 把语料的骨架摸清楚确认编码和处理都正常再逐步开 skipgram最后才考虑 flexgram。一次性全开会让你面对一个巨大且难以解释的输出排查问题时无从下手。2. 一条能跑通的流水线从原始文本到可查询的模式库这一节是干货主体。我会按真实顺序把每一步讲清楚包括每步的目的、常见命令形态和参数选择的理由。2.1 语料清洗只做不改变可统计性的处理清洗阶段最容易做过头。我的原则是只做那些不会让统计结果失真的处理。可以做统一全角半角、统一引号类型去掉明显是噪声的页眉页脚、导航文字、广告模板前提是你确认它是模板可以用后面的模式检测反过来验证把连续的空白压成一个如果语料里有 HTML 或 Markdown 残留把标记剥掉但保留正文词序。不该做不要在这个阶段做词形还原或去停用词。去停用词会直接破坏模式结构因为的了这些词恰恰是判断一个短语是否完整的重要信号词形还原最好放到编码的映射层去处理而不是在原文里改动。不要做句子级别的乱序或去重。去重会让频次失真而频次正是整个方法的基础。清洗完的产出应该是一份每行一句话、词之间用空格分隔的纯文本。这一步看起来平淡但它决定了后面所有结论的可信度。我在一个项目里因为清洗时顺手删掉了所有括号内容结果一批法规条款引用模式全部消失排查了半天才发现是清洗阶段埋的坑。2.2 编码阶段把文本转成整数序列这一步的输入是切好词的纯文本输出通常是一份二进制语料文件和一份词类映射文件。命令形态大致是这样colibri-classencode corpus.txt执行之后通常会得到corpus.colibri.dat和corpus.colibri.cls这类文件。前者是后续所有挖掘工具的输入后者是映射表。不同版本的默认命名和参数可能略有差异跑之前先看一眼--help输出确认输入输出路径和是否要指定最小频次。这里有两个我踩过的细节最小频次参数会影响映射表大小。如果设得太高长尾词全被归到未知类别模式里就会出现大量无法解释的占位符候选质量反而下降。我的经验是把阈值设在能覆盖语料 95% 以上 token 的位置具体数值看你的词表分布曲线。映射文件尽量保存好并纳入版本管理。它是你后续复用、对齐、复现实验结果的关键。丢了这个文件之前的二进制语料基本就是一堆无法解读的数字。2.3 n-gram 抽取与阈值用覆盖率拐点来定而不是靠猜编码完成后就可以抽 n-gram 了。命令形态大致是colibri-ngrams -n 3 -f -e corpus.colibri.dat ngrams3.txt参数的含义因版本而异我这里只讲思路-n控制最大 n-f之类的开关决定是否输出频次-e之类的开关决定输出格式。真正需要动脑的是频次阈值。大多数人定阈值的方式是看着差不多就行比如取 5 或者 10。我的做法是看覆盖曲线先从阈值 2 开始跑一遍得到模式清单统计频次 ≥ t 的模式覆盖了语料中多少比例的 token把 t 从 2 逐步提到 20画一张覆盖率和模式数量的对照表找到覆盖率开始明显趋缓、模式数量仍在快速下降的那个区间。用一张真实的感受性数据说明这是我一个中型语料上的粗略观察量级可参考但数值别照搬频次阈值 t模式数量相对值语料覆盖率2100%92%541%85%1022%78%2011%69%504%55%看这张表的正确方式不是选覆盖率最高的而是找那个用较少模式换到较高覆盖率的点。t10 到 t20 之间模式数量砍半但覆盖率只掉 9 个点这个区间通常就是候选池的甜点区。低于 t5 的部分绝大多数是随机共现人工筛起来极其痛苦。注意三级以上的 n-gram 数量会急剧膨胀。如果语料是千万级以上 token建议先用-n 2摸清底数再往上加否则输出文件可能大到浏览器都打不开。2.4 skipgram 与 flexgram开了就要承担解释成本skipgram 的抽取通常通过模式建模工具完成形态大致是colibri-patternmodeller -i corpus.colibri.dat -f model.pattern -t 20 --skipgrams这里-t是频次阈值--skipgrams之类的开关决定是否纳入带间隔的模式。skipgram 的频次天然比同长度的 n-gram 高因为它匹配的字符串范围更大。所以阈值要单独定不能沿用 n-gram 的那套。我的做法是给 skipgram 单独设一个更高的阈值大概是同长度 n-gram 阈值的 3 到 5 倍然后人工抽查前十到二十个结果看它们是不是语义上说得通。如果抽出来的全是因为……所以我们……你们这种功能性框架说明你的语料偏对话或偏议论这时 skipgram 的价值更多在于句法模板分析而不是术语发现。flexgram 我一般只在两种情况下开一是目标语言形态变化丰富同一个表达有多种写法二是需要把变体归并做统计对比。它的代价是可解释性下降——一个 flexgram 背后可能对应好几种实际写法做人工审核时得逐个回查。3. 内存、速度与一致性决定这套流程能不能上生产的三个变量工具本身不难用难的是让它在真实规模下跑完、跑对、跑得可复现。这一节讲三个最容易翻车的地方。3.1 频次阈值其实也是一个规模控制旋钮很多人把阈值当成质量过滤器其实它首先是个规模控制器。n-gram 抽取的内存和中间结果规模与有多少模式通过了阈值直接相关。阈值每降低一档进入统计结构的模式数量可能翻好几倍内存峰值也跟着翻。一个粗略的估算思路假设语料的 token 总数是N你想抽的最大 n 是k那么理论上限是N * k个候选位置。但真正进入常驻内存的只是出现次数达到阈值的那部分而高频模式数量通常只占总候选的一个很小的比例。经验上如果N是千万量级、阈值设在 10 左右普通配置的开发机跑k3基本没什么压力k5且阈值放到 2 的时候内存就可能变成瓶颈。3.2 内存到底被谁吃掉了排查内存问题的时候别只盯着工具本身占了多少。我见过的主要来源有这么几类占用来源表现缓解方式编码后的语料常驻内存语料越大越明显分片处理最后合并统计结果高频模式计数结构阈值低时急剧膨胀提高阈值先用大阈值摸底候选模式字符串还原输出阶段才发生输出时分批写盘不要一次性持有映射表加载词表越大越明显长尾归一化控制词表上限最容易被忽略的是候选模式字符串还原。统计阶段用的是整数索引很省但输出阶段要把每个编号翻译回词如果一次性把所有模式都还原再排序内存峰值可能比统计阶段还高。我现在的习惯是分片输出写完一批就落盘绝不攒着。3.3 为什么两次跑出来的结果不一样这是我在带新人的时候被问得最多的问题参数都一样为什么第二次跑出来多了几十个模式绝大多数情况不是工具的问题而是输入的一致性被破坏了。可能的原因清洗脚本在两次运行之间被改过虽然只改了一行但影响了某些 token 的切分编码映射重新生成过编号变了导致某些模式合并或分裂语料本身是流式的两次拉到的数据范围不同并行处理时结果合并顺序不同浮点或排序上的细微差异被放大。治本办法就一条把清洗脚本、编码映射、参数配置、语料版本号全部固定下来跑之前先对一遍。我现在会强制在产出目录里放一份run_config记录包含语料哈希和时间戳。后面一旦出现对不上的情况先比这个文件比分析结果快得多。4. 四个我实际用过的落地场景挖出来的模式表本身没有价值价值在于后面拿它做什么。下面这四个场景是我自己反复用过、也确实产出了结果的。4.1 领域术语与固定搭配的候选池最直接的用法。把阈值调到甜点区导出一份模式清单按频次降序排列然后人工审核。审核的技巧是优先看那些高频词组合出来的低频整体单个词都常见但组合起来只在某个语境里集中出现这类往往是真术语。我一般会把候选分成三档直接可用、需要确认、明显噪声。第一档直接进术语库第二档拿去跟业务方对第三档用来反推清洗或阈值哪里还有问题。这个流程跑顺之后一个新业务线的术语候选池基本半天能出来。4.2 语料差异与套话模板检测把两份语料的模式表做差集你会发现一些非常有意思的现象。两份看起来主题相近的语料差异最大的往往不是专业词汇而是套话结构。比如某一批文本里下面我将从三个方面这类模板反复出现另一批几乎没有那这两批文本的来源或写作流程大概率不同。这个用法我在数据质量检查里用得最多如果一份人工撰写的语料出现了大量整齐划一的模板模式那它很可能是批量生成的值得进一步核实。判断标准不是单个模式而是模板模式的集中度——某个模式在总 token 里的占比异常高且在不同文档间的分布极其均匀。4.3 给下游任务造可解释特征模式表可以直接当特征用而且比词袋特征更可解释。做法是选一批高频模式对每篇文档统计这些模式的出现次数形成特征向量。相比词袋它的优势是特征本身就是可读的短语出问题时能直接定位。下面是一段我常用的解析和统计代码思路是先读模式文件、过滤出符合条件的模式再逐篇统计# 假设模式文件每行是 P 频次 词1 词2 ... 的形式 # 不同版本输出格式不同先探测字段结构再解析 patterns [] with open(model.pattern, encodingutf-8) as f: for line in f: parts line.strip().split() if not parts: continue # 只保留我们自己关心的类型标记和足够长的模式 if parts[0] ! P or len(parts) 4: continue try: freq int(parts[1]) except ValueError: continue # 字段结构和预期不符跳过 tokens parts[2:] if freq 20 and len(tokens) 2: patterns.append((freq, tuple(tokens))) # 按频次降序取前 500 个作为特征候选 patterns.sort(reverseTrue) feature_names [ .join(t) for _, t in patterns[:500]] print(len(feature_names), feature_names[:10])# 对每篇文档统计特征命中次数 def featurize(doc_tokens, index): counts [0] * len(index) for i in range(len(doc_tokens)): for j, pat in enumerate(index): k len(pat) if i k len(doc_tokens) and tuple(doc_tokens[i:i k]) pat: counts[j] 1 return counts # index 是上面筛出来的模式元组列表 index [t for _, t in patterns[:500]] doc 这个 方案 落地 风险 点 需要 重点 关注.split() print(featurize(doc, index)[:5])这段代码写得比较直白效率不高适合验证阶段。真上生产的话把模式集合按首词建索引只从命中的位置往后比对速度能提升一个量级。这个优化是我在一个千万级文档的项目里被逼出来的朴素写法跑一天都跑不完。4.4 和正则、统计方法的分工边界我不是说正则没用。恰恰相反明确分工之后两者配合得很好。任务类型更适合的方法理由已知格式的抽取编号、日期、金额正则规则明确且模式挖掘无法保证格式全覆盖未知搭配的发现模式挖掘正则写不出来你还不知道的东西固定的敏感词过滤词表匹配模式挖掘对低频词的召回不稳定语料整体风格刻画模式挖掘需要全局统计视角局部规则做不到我的常规组合是先用模式挖掘产出一份候选再用少量正则去补那些格式确定但低频的部分。反过来做——先用正则去发现未知搭配——实践下来效率很低。5. 踩坑记录五个让我重跑流程的细节这一节的每一条都是我真实付过代价的不是纸面上推演出来的。5.1 编码文件与二进制语料不匹配导致静默错位有一次我把两批语料分别编码然后想省时间用第一批的映射文件去解读第二批的二进制语料。程序不报错跑出来的模式数量也很正常但内容全是乱码式的奇怪组合——因为编号对应的词完全对不上。这个坑最阴险的地方在于它不会崩。纠正方式很简单编码文件和二进制语料永远成对管理命名里带上语料标识和版本号。我现在会在文件名里强制带日期和语料代号宁可文件名长一点。5.2 标点与大小写处理不一致导致同一模式被拆成好几份英文语料尤其明显。如果第一批文档里逗号后面带空格第二批不带那么comma-separated模式和comma separated模式会被统计成两个东西频次双双低于阈值结果一起被过滤掉。解决办法是在清洗阶段做一次强制的规范化所有标点前后统一处理、大小写统一策略要么全小写要么保留但把大小写差异归并到映射层。关键是整个项目只允许一套规则不允许不同人写的清洗脚本混用。5.3 阈值定得太低高频垃圾淹没候选池我第一次跑这个流程的时候把阈值设成了 2想尽量多捞点。结果输出文件里有大量由停用词组成的模式频次还挺高比如各种的 了 是 的式组合。人工筛了两小时产出接近于零。后来我学聪明了低阈值跑出来的结果不是拿来选候选的而是拿来观察分布的。真正进候选池的一定是经过阈值筛选的版本。5.4 把 skipgram 抽出来的东西当 n-gram 用skipgram 的频次虚高这一点我知道的时候已经吃亏了。当时我把 skipgram 结果和 n-gram 结果放进同一张表排序结果排在前面的几乎全是 skipgram因为它们匹配范围大、计数天然高。如果直接把这张表交给业务方看会得到一个完全失真的重要短语列表。现在的做法是两类结果分开存、分开排、分开解读需要合并的时候先各自归一化再比。5.5 模式文件膨胀与解析踩坑模式文件可能非常大。我遇到过一次输出文件几十 GB用普通编辑器直接卡死。应对方式# 按频次阈值过滤的同时限制输出规模先看头部 head -n 200 model.pattern # 统计行数不要太粗暴大文件用 wc 也要等一会儿 wc -l model.pattern # 需要抽样时优先用流式处理别一次性读进内存 awk NR % 1000 0 model.pattern | head -n 50另外解析之前一定要先看几行真实内容确认字段结构。不同版本的输出格式可能有差异硬编码解析逻辑会在升级版本时悄悄出错。6. 几句实操之后的个人体会用这套东西做了几年语料工作最大的感受是阈值没有标准答案只有在你自己的数据上试出来的答案。网上那些阈值取 10的经验值换个语言、换个领域、换个切分粒度就完全不成立。花两个小时在阈值上和覆盖率上做实验比抄一个参数省事得多。另一个体会是关于工具边界的。Colibri 这类工具的强项是发现弱项是判断。它能在一晚上给你几千条候选但它不知道哪条对业务有意义。所以我在流程里一定留一道人工审核而且审核的人最好懂业务而不是只懂工具。工具把候选池从从零开始想变成从一千条里挑这一步的收益就已经很大了。最后一个小技巧把你的语料按来源或时间切成两半各自跑一遍模式挖掘然后只看两半都出现的高频模式。这一步过滤掉的噪声比我试过的任何参数调优都管用因为稳定的模式才可能是真模式偶然的共现在两半数据里同时出现的概率很低。这个做法唯一的代价是要多跑一遍但省下的人工审核时间远远超过那点算力。