ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Colibri 文本模式挖掘:n-gram、skipgram 与覆盖率实战

Colibri 文本模式挖掘:n-gram、skipgram 与覆盖率实战 搜 colibri 这个词的人多半想要的东西和我当初不一样。我最早是想找一只蜂鸟的照片结果一路翻到了一个同名的文本工具——它不做鸟做的是一件很实在的事从一大堆原始文本里把 n-gram、skipgram 这类短语级模式一条条挖出来并且告诉你每条模式出现了多少次、覆盖了多少句子。如果你手上有几千篇工单、几万条评论、一整年的文档日志光靠词频表你只能看到退货和流程各自出现了几千次却看不到退货流程这四个字才是真正能拿来做事的那个单位。Colibri 这类工具存在的意义就在这里它把短语变成了可计量、可排序、可对比的实体。我这篇只讲我实际在用的那条线命令行工具链加上 Python 侧的后处理把这套东西接进日常的数据清洗和检索准备流程。不涉及任何需要特殊网络环境的东西全部是本地跑、离线算。适合已经会写点 Python、手上有一批文本、又不想上重型 NLP 框架的人——尤其是做搜索、做内容运营、做语料整理的朋友。1. Colibri 解决的到底是什么问题词频表为什么不够用1.1 单词频次在真实语料上的三个死角先说清楚痛点不然工具选型永远是拍脑袋。我用词频表做过很长一段时间的语料分析最后卡在三个地方。第一个死角是多词表达被拆散。中文里申请、提交、审核都是高频词但你真正关心的是提交申请这个动作单元。词频表会把它们拆成三个独立计数你看到的是三个都很高的数字却拼不回业务含义。英文更明显空格分词之后 machine 和 learning 各自排在前列但你要的是 machine learning 这一整个术语。第二个死角是不连续搭配完全抓不到。真实文本里大量存在提交……申请等待……审核这种中间插了修饰成分的结构。这类东西用固定长度的连续窗口去统计要么被切碎要么长度不够覆盖。第三个死角是高频不等于有意义。在客服工单里请问、您好、麻烦这类礼貌用语往往排在最前面但它们对业务零价值。单看频次你没有任何依据把它们筛掉只能靠人工维护一份停用词表而停用词表是永远维护不完的。我自己统计过一批 3 万条左右的工单语料纯词频前 50 里差不多有三分之一是这类噪声。这就是为什么必须往上一层走去看模式而不是词。1.2 n-gram、skipgram、flexgram三层模式对应三种需求Colibri 里最核心的概念就是三个层次我按自己的理解重新讲一遍比看文档直观。n-gram是最基础的一层就是连续的 n 个 token。n3 的时候退货 流程 说明算一条三元模式。这一层解决固定短语的问题实现简单、结果稳定是绝大多数场景的第一选择。skipgram允许中间跳掉若干 token但顺序固定。它可以表达成提交 _ 申请这样带空位的形状空位里可以填 0 到若干个 token。这一层解决中间插了修饰语的问题比如提交 一份 退货 申请和提交 退货 申请都能被同一条 skipgram 命中。flexgram是更上层的一层它不是从原文直接切出来的而是从一批共现的模式里归纳出来的灵活结构。顺序和间隔都可以不固定代价是抽象程度高、噪声也更大需要靠后续打分排序来挑。模式类型是否连续顺序是否固定适合解决的问题噪声水平n-gram连续固定固定术语、固定话术、专有名词低skipgram可跳空固定中间插修饰语的搭配、可变长度的表达中flexgram可变不固定词序灵活的同义表达、句法模板高这张表我建议你贴在显示器边上。很多人一上来就冲 flexgram觉得抽象层次高就更智能结果拿到一堆看不出规律的东西最后放弃整个工具。正确顺序是先把 n-gram 跑明白确认语料和参数没问题再逐层往上加。1.3 类编码性能分水岭藏在看不见的地方Colibri 有一个在文档里不太显眼、但实际上决定了它能不能处理大语料的设计类编码。简单说它在预处理阶段给语料里每一个不同的 token 分配一个整数编号然后整个语料不再以字符串形式保存而是以一串整数保存到二进制文件里。后续所有训练和查询都在整数空间上做。你可以这样理解本来每句话里都是退货流程这样的字现在全部换成了门牌号比如 10428、39571。计算机比较两个门牌号比比较两个字符串快得多占的内存也小得多。这个改造带来的收益不是百分之几在几千万 token 的规模上通常是数量级的差别。代价是编码表必须和语料严格绑定。你为语料 A 建了一份编码表就不能拿它去解语料 B 的数据否则每个门牌号都指向错误的内容。这一点我在第四节会专门讲一次翻车经历因为这是新手最容易踩、又最难自己看出来的坑。注意编码这一步不是可选的优化项它是整个流程的一部分。跳过它你会发现工具要么报错要么速度慢到无法接受。2. 第一次跑通清洗、编码、训练、查询四步走2.1 语料切分策略决定了后面一半的结论在敲任何命令之前有一件事必须先想清楚你的最小语料单元是什么。是句子还是段落还是整篇文档这个选择直接影响后面覆盖率这个指标的含义。覆盖率算的是有多少个语料单元里出现了这条模式单元如果是一整篇几千字的文档那几乎所有常见模式覆盖率都是 100%指标就废了。单元如果是句子覆盖率才有区分度。我自己踩过的经验值是这样的工单、评论、聊天记录按句子切标点符号作为切点一行一句。这类文本短、话题集中句子粒度最合适。文档、报告、说明书先按段落切如果段落普遍超过 200 字再按句子二次切。段落粒度的覆盖率适合判断这条模式是不是全文性话题。日志、代码注释保持原有的行结构不要合并。行本身就是天然的语义单元。清洗方面我只做三件必要的事统一转成 UTF-8、把连续空白压成一个空格、去掉长度为零的行。至于标点保留还是去掉我的做法是保留但清洗——把全角半角统一、把连续的重复标点压成一个。因为标点本身是有信息的它可以帮你判断模式是不是跨句边界的删掉就看不出来了。提示清洗阶段一定要把语料存成一行一个单元并且确认文件末尾有换行符。很多工具读最后一行时对末尾换行很敏感缺一个换行符可能导致最后一条记录被吞掉而你不会收到任何报错。2.2 两条安装路线源码编译和 Python 包这里必须坦白讲一句这个工具链的安装在不同平台上体验差异很大我建议你两条路线都了解一下再选。路线一C 源码编译。从官方仓库拉代码走常见的 autotools 流程配置、编译、安装。好处是命令行工具齐全、性能是原生的、大语料上跑得动。代价是编译过程对系统依赖有要求遇到编译错误需要一点耐心Windows 上尤其折腾。路线二Python 包。直接在 pip 里搜名字安装。好处是几分钟就能跑起来适合先验证思路、处理中等规模语料。这里提醒一句同名或近名的包可能不止一个一个偏纯 Python 实现、一个偏 C 绑定装之前一定看清包的描述和依赖别装完了发现接口和你照着写的代码对不上。我的实际组合是先用 Python 包在小样本上把流程和参数摸熟语料规模上去了再切到 C 命令行工具做正式训练。这个组合的好处是学习成本低同时不会在真正的生产语料上被性能卡住。维度C 命令行工具链Python 包上手速度慢需要解决编译依赖快pip 直接装处理规模大语料友好中等语料够用脚本集成靠子进程调用稍绕原生 Python最顺调试体验出错信息偏底层报错直接、好定位我的用法正式训练、批量跑参数试验、结果后处理2.3 三步命令把语料变成可查询的模式模型流程本身只有三个动作但每一步都有值得说的细节。下面是我常用的命令行骨架参数名在不同版本之间可能有出入跑之前先用--help确认一次这个习惯能省你半小时。# 第一步编码。把纯文本语料转成带整数编号的二进制语料 # 同时会生成一份编码表文件后续所有步骤都要用到它 colibri-classencode corpus.txt # 第二步训练。从编码后的语料里抽模式输出模式模型文件 # -n 指定最长窗口大小-o 指定输出文件 colibri-ngrams -f corpus.colibri.dat -n 5 -o ngram.colibri.patternmodel # 第三步查询。按关键词把相关模式捞出来看先做人工抽查 colibri-query -f ngram.colibri.patternmodel -q 退货每一行的意图我解释一下因为很多人是照着敲能跑但不知道为什么。第一步的意图是把字符串转成可计算的整数同时把这份编码表单独存一份。编码表是后面所有步骤的钥匙务必和语料一起归档。第二步的意图是枚举候选模式并计数。-n 5意味着最长看五个 token 的窗口从 1 一直枚举到 5。这个数字怎么定第三节会展开算一遍。第三步的意图是抽样验证。不要一上来就导出全量结果先拿几个你确定会出现的关键词去查看看出来的模式是不是人话。如果查出来一堆看不懂的碎片说明分词或者清洗出了问题后面所有工作都是白做。2.4 用 Python 把结果接进现有流程命令行输出适合人看但要接进搜索、接进内容审核、接进 BI还是得落到 Python 里。我的做法是训练在命令行做筛选和导出在 Python 做。# 思路示意加载训练好的模式模型按覆盖率与频次做二次筛选导出成表 # 具体类名与方法名请以你所装版本的文档为准 from colibricore import PatternModel model PatternModel() model.load(ngram.colibri.patternmodel) rows [] for pattern in model: freq model.getfrequency(pattern) coverage model.getcoverage(pattern) # 双阈值过滤既要够频繁也要够分散 if freq 20 and coverage 8: rows.append((str(pattern), freq, coverage)) rows.sort(keylambda r: (-r[2], -r[1])) for text, freq, coverage in rows[:200]: print(text, freq, coverage)这段代码里最关键的不是 API 怎么调而是那个if判断——同时用频次和覆盖率两个阈值过滤。只用频次你会捞出一堆在某一段模板文字里反复出现的短语只用覆盖率你会捞出一堆到处都是但毫无信息量的通用搭配。两个一起用效果立刻不一样。这个策略我在下一节会从原理上解释为什么。3. 参数怎么定n、阈值、覆盖率、熵3.1 n 取到几一笔可以自己算的账-n这个参数我见过太多人随手写个 10然后抱怨跑不动。我们来算一笔账。假设你的语料有 100 万个最小语料单元平均每个单元 20 个 token总 token 数就是 2000 万。当 n 取到 5 的时候单个单元内长度 5 的滑窗有 16 个全局就是 1600 万个窗口。这还只是 n5 这一层从 1 到 5 全部加起来窗口总量差不多是这个数的两倍多。窗口数量不等于模式数量大量窗口是重复的会合并计数但唯一模式的数量随 n 增长是非常快的。每条模式至少要存字节内容、频次、覆盖率这几项粗估每条几十字节。哪怕去重之后只剩几百万条内存占用也在几百 MB 到 GB 级别而且训练时间会明显拉长。我的实际取值建议中文语料n 取 4 到 5。中文双字词居多三到五字基本覆盖了主要术语形态。英文语料n 取 5 到 7。英文术语更长几个单词组合起来才有意义。先小后大永远先用-n 3在语料的一个子集上跑一遍确认结果合理再放大 n 和语料规模。还有一个成本更低的做法先用 n3 跑出结果看看里面还有多少明显被截断的模式。如果你发现大量机器学习 算法这种被切在中间的片段反复出现说明 n 还不够往上加。反过来如果 n5 的结果里绝大多数模式的频次都只有 1说明 n 太大了大部分窗口都是长尾噪声。3.2 频次阈值和覆盖率阈值本质上是两种不同的过滤这两个指标经常被混为一谈但它们的含义完全不同。频次是这条模式在整个语料里出现了多少次。它衡量的是总热度。覆盖率是有多少个语料单元里至少出现过这条模式一次。它衡量的是分散程度。差别在哪里举个例子。假设某条模式出现了 500 次但它全部集中在一份模板文件里那份文件把同一句话复制了 500 遍。这时候它的频次是 500覆盖率只有 1。反过来另一条模式出现 100 次分散在 80 个不同的工单里频次 100覆盖率 80。从这条模式是不是真实的语言现象这个角度看后者的可信度远高于前者。频次高、覆盖率低几乎可以断定是模板、复制粘贴或者系统生成的重复内容。这个判断规则我是从一个做日志分析的朋友那里学来的后来在所有语料项目里都用上了非常好用。所以过滤策略应该是先设一个中等偏低的频次下限比如 20再设一个覆盖率下限比如 8 或 10两个同时满足才留下。这两个数字怎么定我的方法是从小到大试三组看留下来的模式里人话比例有多高。人工看 50 条就够了不需要看全部。3.3 用熵和信息增益把高频废话挤出去频次和覆盖率能过滤掉模板但过滤不掉高频废话——那些分布均匀、到处都是、但毫无信息量的搭配比如中文里的这个问题、的情况下。它们在频次和覆盖率两个维度上都很漂亮。这时候需要换个思路看这条模式的上下文分布是不是足够专一。一条有信息量的模式它出现的语境通常比较集中。比如退货流程大概率出现在讨论售后的上下文里它后面接的词、它前面出现的词都相对固定。而这个问题可以出现在任何话题里它的上下文极其发散。在信息论里衡量分布发散程度的东西就是熵。上下文分布越均匀熵越高越集中熵越低。所以一个很实用的打分方式是对每条模式统计它左右邻居的分布算一个熵值把熵值特别高的排在后面。这类打分在工具里通常有对应的排序选项名字可能叫信息增益、相对熵或者类似的叫法具体命令用--help找一下。还有一种更好用的用法比较两个模型之间的差异。比如你手上有自家文案和竞品文案两份语料分别训练出两个模式模型然后找出在竞品语料里异常高频、在自家语料里几乎不出现的模式。这类模式往往就是对方在反复强调、而你没提的卖点或者痛点。这个用法我强烈推荐比单纯看自己语料的高频模式有价值得多。3.4 一份可以直接抄的参数起点下面这张表是我自己在不同语料上试出来的起点值不是最优值但能让你少走几轮弯路。从这里开始调比从零开始试快得多。场景最小单元n 起点频次下限覆盖率下限备注中文客服工单句子4208先跑 n3 看切分是否合理中文文档/报告段落4103段落粒度长覆盖率阈值要调低英文技术文档句子6155术语长n 要给够短评/弹幕类句子33015单元极短n 给大没意义跨语料对比与主语料一致与主语料一致105两侧参数必须完全一致注意最后一行跨语料对比的参数一致性是很多人忽略的关键点。两边参数不一样模型之间就没有可比性算出来的差异全是参数造成的假象。做对比之前先把两侧的 n、清洗规则、切分粒度对齐再去算差异。4. 我踩过的坑以及完整的排查链路4.1 编码表和语料对不上一次完整的排查记录这是我印象最深的一次翻车。当时我把语料更新了一版补了几千条新工单但忘了重新做编码直接拿旧模型的编码表去处理新语料。结果是命令全部正常执行没有任何报错输出的模式表看起来也像那么回事——但仔细一看里面全是些莫名其妙的字符串有些甚至不是完整的词。我当时的第一反应是分词坏了于是去检查分词脚本花了快一个小时分词完全正常。第二个怀疑是编码问题检查了文件编码也是正常的 UTF-8。第三个怀疑才落到编码表上——因为这两份文件的时间戳差了三天。排查顺序是这样的你也可以照着走看时间戳。语料文件、编码表文件、模型文件三个的时间戳是不是同一个批次产生的。这一步只要十秒钟但能解决相当一部分莫名其妙的问题。我后来把它变成了固定动作每次开工先ls -l看一眼。验证单点。随便挑一个你确定高频的词用它去查询模型。如果连一个必然出现的词都查不到问题就在数据链路的前端不在训练参数。新建一个小样本做端到端验证。从新语料里抽 100 行从零开始完整跑一遍编码、训练、查询。如果小样本正常说明是旧文件的问题不是流程的问题。定位到具体文件后重建。删除编码表用新语料重新编码然后重新训练。这套顺序背后的逻辑是从成本最低、最可能的原因开始排除。时间戳十秒验证单点一分钟重建小样本五分钟重跑全量可能是半小时。很多人习惯反过来一上来就重跑全量浪费大量时间。4.2 中文分词不做这一步后面全是垃圾Colibri 按空白切分 token。这对英文天然友好对中文就是灾难——一整句话中间没有空格它会把整句当成一个 token。后果是什么你训练出来的n-gram本质上是n 个整句的连续组合。除非你的语料里有大量完全相同的句子否则这些模式的频次全是 1覆盖率全是 1整张表毫无价值。表现出的症状很有迷惑性命令跑得飞快输出文件很小打开一看全是长句误以为语料太少了。解决办法很直接在清洗阶段先做中文分词然后用空格把词连起来写回文件。import jieba with open(raw.txt, encodingutf-8) as fin, \ open(corpus_tokenized.txt, w, encodingutf-8) as fout: for line in fin: line line.strip() if not line: continue # 分词后用空格连接让下游按空白切分就能拿到正确的 token fout.write( .join(jieba.cut(line)) \n)这里有两个细节值得说。第一标点要不要单独成 token取决于你的目标。想抓跨标点的搭配就保留标点作为独立 token想抓纯词汇搭配就在分词后把标点剔除。我的习惯是保留因为它顺便能告诉你这条模式会不会跨句边界。第二分词字典要固定下来并一起归档。自定义词典一变同样的语料分出来的 token 序列就不一样前后两次训练的模型没法对比。我在项目里会把这个词典文件和语料放在同一个目录命名上带日期。4.3 内存爆掉的三个典型时刻用这个工具的人早晚会遇到内存问题。我遇到的都集中在三个时刻。第一个时刻训练时的 n 开得太大。症状是训练过程越来越慢最后被系统杀掉。解决办法就是回退到 3.1 节的估算把 n 降一到两档或者先把语料切成几个部分分别训练。第二个时刻加载模型时把每条模式都构造了对象。这是使用方式的问题。如果你在循环里对每条模式都创建一个完整的模式对象、再逐个算属性内存开销会比先算好需要的字段、再把字段存进列表大得多。我在 2.4 节那段代码里直接往列表里塞字符串和整数就是为了避开这个问题。只保留你真正需要的字段这是一个很便宜但很有效的习惯。第三个时刻一次展开太多弹性模式。弹性模式是从多个模式归纳出来的展开的时候可能触发大量组合。这时候别一次全展开先按频次取前几千条展开看效果再决定要不要全量。4.4 skipgram 出来的东西太碎怎么收skipgram 好用但它有个天然倾向空位越多覆盖的描述越宽命中越泛信息量越低。如果放任空位无限长提交 _ _ _ _ 申请这种模式几乎能命中所有东西。我的收法有三个按优先级排手段具体做法效果代价限制空位长度把允许跳过的 token 数压到 1 到 2立刻变干净会漏掉少量长距搭配加覆盖率二次过滤用 3.2 节的双阈值再筛一遍命中更实需多跑一遍筛选加词性/位置约束空位处只允许特定类型的词精准度最高实现成本高绝大多数情况下前两个手段就够了。第三个手段我只有在做非常精细的术语抽取时才会用因为要额外维护一套词性标注的依赖收益和投入不太成正比。4.5 一个通用排查顺序表把上面这些经验压缩成一张表遇到问题按顺序往下走比凭感觉乱试快得多。症状优先排查常见原因处理输出的模式不是完整的词分词环节未做中文分词或词典变动分词后空格连接词典归档命令正常但结果全是长句分词环节中文没分词整句当 token同上结果全是看不懂的碎片编码一致性编码表与语料批次日不一致重建编码表并重训训练被系统杀掉资源n 过大或语料过大降 n、切分语料高频模式全是这个问题类打分策略只做了频次过滤加覆盖率与上下文熵排序两种语料对比差异巨大参数一致性两侧参数或清洗规则不一致对齐参数后重跑5. 从模式表到能交付的东西几个真实落地的用法5.1 领域术语表的冷启动这件事解决的是从零开始做垂直领域词典的问题。传统做法是派人手工整理一周下来也就几百条还容易漏。我的做法是把领域语料跑一遍 n-gram用双阈值过滤然后按覆盖率从高到低取前 500 条人工过一遍。这一轮不过滤语义只判断是不是一个完整的术语。通常留下六成左右剩下的四成是各种搭配和口语表达放进第二个池子备用。整个过程从语料准备到出表一个人半天以内能完成第一轮后续按周迭代补充。关键的心得是第一轮不要追求完美追求覆盖。因为模式挖掘的价值在于它能一次给你几千条候选你人工筛的是哪些不要这比凭空想要哪些效率高一个量级。我第一个人工过一轮 500 条用了不到 40 分钟比从文档里一条条抄快太多。5.2 检索侧查询扩展和同义归并有了模式表之后检索侧能立刻受益的有两件事。第一件是查询扩展。用户搜退货你从模式表里找出所有包含退货且覆盖率合格的模式就能知道这个领域里退货通常和哪些词绑在一起出现。把高频搭配加进召回的扩展词里长尾查询的召回率通常会有可见的提升。第二件是同义归并。在不同渠道的文案里申请退款和退款申请可能都在高频出现。这类词序差异在 n-gram 层面看得一清二楚。你可以据此建一张变体映射表把检索时的多个变体归并到同一个意图上。这件事用人工想特别容易漏因为词序颠倒这种差异人的阅读习惯会自动忽略掉但机器不会。5.3 内容体检把模板句和套话揪出来这是我用得最顺手的一个场景逻辑非常简单频次高、覆盖率极低的模式 模板句。我们做内容巡检的时候原来靠人工抽查几十篇效率低还容易漏。后来改成先用这个规则把候选模板句捞出来再人工确认。捞出来的东西通常分两类一类是系统自动生成的重复文案一类是编辑复制粘贴留下来的痕迹。两类都有价值前者是质量问题后者是流程问题。反过来覆盖率很高、但熵值也很高的模式 套话。它们出现在什么地方都不违和但也不提供任何信息。这类东西是写作质量改进的重点目标。我给内容团队做过一次这样的清单把排名前 200 的套话列出来做成慎用词表效果比空泛地说要写具体一点好得多因为清单是具体可查的。5.4 跨语料对比看语言在往哪边漂最后一件事也是我觉得最有长期价值的一件把不同时间段的语料分别训练成模型然后比差异。比如拿今年上半年和去年下半年的工单语料各训一个模型找出新增的、消失的、强度变化最大的模式。新增的往往是新产品或者新问题带来的新说法消失的是已经解决的老问题。这个对比出来的清单比看统计数据里的数字涨跌有意思得多因为它告诉你用户在用什么样的词描述他们的处境。做这件事只有一个硬性要求两边的参数和清洗规则必须完全一致包括分词词典。词典一变同样的文本分出来的 token 就不一样模式就不可比了。我在项目里会把这个对比任务写成一个固定脚本每次只需要换两个语料路径其他全部固定避免手工操作引入不一致。最后分享一个我自己一直在用的小技巧每次跑完一轮把过滤后的前 200 条模式导成一个文本文件文件名带上日期存到一个专门的历史目录里。不用做任何分析就是存着。半年之后回头看这一排文件你能非常直观地看到这个语料库的语言在怎么变化哪些说法冒出来了哪些消失了。这个低成本留档的习惯比任何一次性的分析都更有价值因为它积累的是时间维度上的观察而这是单次跑模型永远拿不到的东西。
返回列表