做多词表达抽取那几年,我踩的第一个大坑不是算法,而是"太相信自己手写的规则"。当时手上有几十万条行业问答文本,任务是找领域里的固定搭配和术语。我一开始的做法很朴素:停用词表加正则,再配一份人工维护的白名单。规则从三十条涨到三百条,召回率确实上去了,但误报也跟着涨,最要命的是每换一个业务线,规则表就得重写一遍,维护成本比模型本身还高。
后来我把思路换了一下:与其告诉机器"什么是一个词",不如让机器告诉我"哪些连续出现的字符串有统计上的黏性"。这条路走到最后,落点就是一个叫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 | 模式数量(相对值) | 语料覆盖率 |
|---|---|---|
| 2 | 100% | 92% |
| 5 | 41% | 85% |
| 10 | 22% | 78% |
| 20 | 11% | 69% |
| 50 | 4% | 55% |
看这张表的正确方式不是"选覆盖率最高的",而是找那个"用较少模式换到较高覆盖率"的点。t=10 到 t=20 之间,模式数量砍半但覆盖率只掉 9 个点,这个区间通常就是候选池的甜点区。低于 t=5 的部分,绝大多数是随机共现,人工筛起来极其痛苦。
注意:三级以上的 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 左右,普通配置的开发机跑k=3基本没什么压力;k=5且阈值放到 2 的时候,内存就可能变成瓶颈。
3.2 内存到底被谁吃掉了
排查内存问题的时候,别只盯着"工具本身占了多少"。我见过的主要来源有这么几类:
| 占用来源 | 表现 | 缓解方式 |
|---|---|---|
| 编码后的语料常驻内存 | 语料越大越明显 | 分片处理,最后合并统计结果 |
| 高频模式计数结构 | 阈值低时急剧膨胀 | 提高阈值,先用大阈值摸底 |
| 候选模式字符串还原 | 输出阶段才发生 | 输出时分批写盘,不要一次性持有 |
| 映射表加载 | 词表越大越明显 | 长尾归一化,控制词表上限 |
最容易被忽略的是"候选模式字符串还原"。统计阶段用的是整数索引,很省;但输出阶段要把每个编号翻译回词,如果一次性把所有模式都还原再排序,内存峰值可能比统计阶段还高。我现在的习惯是分片输出,写完一批就落盘,绝不攒着。
3.3 为什么两次跑出来的结果不一样
这是我在带新人的时候被问得最多的问题:"参数都一样,为什么第二次跑出来多了几十个模式?"
绝大多数情况不是工具的问题,而是输入的一致性被破坏了。可能的原因:
- 清洗脚本在两次运行之间被改过,虽然只改了一行,但影响了某些 token 的切分;
- 编码映射重新生成过,编号变了,导致某些模式合并或分裂;
- 语料本身是流式的,两次拉到的数据范围不同;
- 并行处理时结果合并顺序不同,浮点或排序上的细微差异被放大。
治本办法就一条:把清洗脚本、编码映射、参数配置、语料版本号全部固定下来,跑之前先对一遍。我现在会强制在产出目录里放一份run_config记录,包含语料哈希和时间戳。后面一旦出现对不上的情况,先比这个文件,比分析结果快得多。
4. 四个我实际用过的落地场景
挖出来的模式表本身没有价值,价值在于后面拿它做什么。下面这四个场景是我自己反复用过、也确实产出了结果的。
4.1 领域术语与固定搭配的候选池
最直接的用法。把阈值调到甜点区,导出一份模式清单,按频次降序排列,然后人工审核。审核的技巧是优先看那些高频词组合出来的低频整体:单个词都常见,但组合起来只在某个语境里集中出现,这类往往是真术语。
我一般会把候选分成三档:直接可用、需要确认、明显噪声。第一档直接进术语库;第二档拿去跟业务方对;第三档用来反推清洗或阈值哪里还有问题。这个流程跑顺之后,一个新业务线的术语候选池基本半天能出来。
4.2 语料差异与套话模板检测
把两份语料的模式表做差集,你会发现一些非常有意思的现象。两份看起来主题相近的语料,差异最大的往往不是专业词汇,而是套话结构。比如某一批文本里"下面我将从三个方面"这类模板反复出现,另一批几乎没有,那这两批文本的来源或写作流程大概率不同。
这个用法我在数据质量检查里用得最多:如果一份"人工撰写"的语料出现了大量整齐划一的模板模式,那它很可能是批量生成的,值得进一步核实。判断标准不是单个模式,而是模板模式的"集中度"——某个模式在总 token 里的占比异常高,且在不同文档间的分布极其均匀。
4.3 给下游任务造可解释特征
模式表可以直接当特征用,而且比词袋特征更可解释。做法是:选一批高频模式,对每篇文档统计这些模式的出现次数,形成特征向量。相比词袋,它的优势是特征本身就是可读的短语,出问题时能直接定位。
下面是一段我常用的解析和统计代码,思路是先读模式文件、过滤出符合条件的模式,再逐篇统计:
# 假设模式文件每行是 "P <频次> <词1> <词2> ..." 的形式 # 不同版本输出格式不同,先探测字段结构再解析 patterns = [] with open("model.pattern", encoding="utf-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(reverse=True) 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 这类工具的强项是"发现",弱项是"判断"。它能在一晚上给你几千条候选,但它不知道哪条对业务有意义。所以我在流程里一定留一道人工审核,而且审核的人最好懂业务,而不是只懂工具。工具把候选池从"从零开始想"变成"从一千条里挑",这一步的收益就已经很大了。
最后一个小技巧:把你的语料按来源或时间切成两半,各自跑一遍模式挖掘,然后只看两半都出现的高频模式。这一步过滤掉的噪声比我试过的任何参数调优都管用,因为稳定的模式才可能是真模式,偶然的共现在两半数据里同时出现的概率很低。这个做法唯一的代价是要多跑一遍,但省下的人工审核时间远远超过那点算力。