ARTICLE DETAIL

资讯详情

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

Colibri 大规模 n-gram 统计与 skipgram 模式建模实践

Colibri 大规模 n-gram 统计与 skipgram 模式建模实践 1. 为什么要拿 colibri 做 n-gram 统计而不是自己写个计数器1.1 先说说colibri这个名字Colibri 在西班牙语和法语里是蜂鸟的意思。蜂鸟这个意象挺贴切的体重几克翅膀每秒扇几十次能悬停、能倒飞干的事情不多但那一件事干得极其精准。我最早接触 colibri 这个开源项目的时候第一反应就是这名字取得实在——它就是一个轻量、专注、把 n-gram 统计这件事做到极致的工具。这里要先做个说明免得方向跑偏。colibri 在开源世界里对应的是一个做**模式模型pattern model**的 C 工具集核心能力是从大规模语料里统计 n-gram、skipgram、flexgram 这类语言模式并提供一套可复现的二进制存储格式和 Python 绑定。它不负责分词、不负责词性标注、不负责训练词向量它只干数模式、存模式、查模式这一件事。但恰恰是这一件事很多做文本分析的人一开始都想自己写脚本搞定然后在对上亿 token 的真实语料时才意识到自己写的那个脚本根本撑不住。我写这篇东西的目标很明确把 colibri 从听说过的奇怪名字变成你手边能跑起来、能落到项目里的工具。不管你是做舆情分析、日志挖掘、语料库语言学还是单纯想统计一段文本里高频搭配这套思路都用得上。1.2 自己撸计数脚本通常死在哪一步我第一次做大规模 n-gram 统计的时候用的就是最朴素的路子读文本、分词、用一个dict做计数器key 是.join(words[i:in])。小语料上跑得飞快几百万 token 也就几十秒。然后语料换成一个几千万句的规模机器就开始不对劲了。问题出在四个地方我按踩到的顺序列一下内存。Python 的字符串对象本身开销极大一个 5-gram 的 key 在字典里可能占几百字节。10 亿 token 的语料光是 2-gram 到 5-gram 的 key 集合就能吃掉上百 GB 内存。这不是优化一下能解决的是数据结构选错了。序列化。你辛苦算出来的计数要落盘给下一步用。用 pickle 或者 json 存文件动辄几十 GB读回来又要解析一遍每一次实验迭代都在等 IO。组合爆炸。skipgram允许中间跳词的模式的数量不是线性增长的。5-gram 允许 1 个跳词模式数量直接翻好几倍。用字典硬存内存曲线是指数级的。重复劳动。想换个阈值重新看结果就得从头再数一遍。因为计数和查询耦合在一起了没有一个中间态可以复用。colibri 的设计正好是对着这四个痛点去的。1.3 colibri 的核心取舍class encoding pattern modelcolibri 最关键的一个设计是把文本先编码成整数序列再在整数序列上做建模。这一步叫 class encoding。它的逻辑很直白文本里的每一个 token词、标点、子词单元看你怎么切分配一个整数 ID整个语料就变成了一串整数。整数在内存里是定长的比较、哈希、排序都比字符串快一个数量级落到磁盘上也是紧凑的二进制可以直接mmap映射进来读不用反序列化。这就把读语料这个环节的成本压到了接近硬盘顺序读的极限。第二个设计是把模式当成一等公民。colibri 里有一个Pattern概念它不只是一串 token还可以带洞——也就是 gap。一个带洞的模式可以表示成the __ mat这种形式中间的洞代表任意 token。这样 unigram、n-gram、skipgram、flexgram 全都能用同一个数据结构表达查询接口也统一了。第三个设计是索引和计数分离。你可以先生成一个未经索引的模型只负责计数把它写成一个单独的二进制文件之后需要查询的时候再把它加载成一个可索引的模型。同一份计数结果可以被无数次查询复用不用重算。提示这个先编码、再建模、最后索引查询的三段式是整个工具链的骨架后面所有的实操和踩坑都围绕它展开。理解了这个骨架你就能预判大部分问题的位置。1.4 什么任务不该用它工具再好也有边界。我遇到过好几次有人拿 colibri 去干明显不合适的事情最后得出这工具不好用的结论其实是选错了场景。下面这几种情况我一般不会用 colibri场景为什么不用 colibri语料只有几万到几十万 tokencollections.Counter几行就搞定编码建模的流程反而是负担需要词性、依存、句法结构colibri 只处理 token 序列不带语言学标注要训练词向量或神经网络它不做梯度、不做嵌入和这个方向没有交集需要频繁做模糊匹配、正则查询它的查询是基于精确模式和通配洞的不是正则引擎数据是流式、一次性的它依赖预编码语料适合反复分析的场景反过来如果你的语料是百万句以上、需要反复试不同阈值和长度、需要统计带洞的搭配、或者需要把统计结果做成可以随取随用的离线资源colibri 就很合适。2. 编译与安装我为什么最后放弃了 pip 一把梭2.1 两条安装路线的实际差异colibri 的 Python 用户通常会看到两条路一条是从包管理器装一条是从源码编译。两条我都试过结论是第一次上手可以用包安装快速验证但只要你要在生产环境或者长期项目里用还是得走源码编译。原因不复杂。colibri 的核心是 C 写的Python 绑定只是一层壳。通过包管理器装的时候如果你的环境里没有 C 编译工具链、没有 Python 的头文件安装过程会直接失败而失败信息往往藏在几百行日志里新手很容易卡在这里。其次是版本问题包里的版本可能落后于仓库主干而 colibri 的 pattern model 文件格式在不同版本间不一定完全兼容这就埋了个雷——你在 A 机器上生成的模型文件拿到 B 机器上可能读不出来。从源码编译的好处是版本自己控制、编译参数自己控制、绑定和库一定是配套的。代价是要花十几分钟折腾工具链。2.2 编译前置条件和参数选择的理由colibri 的依赖其实相当克制。主体部分通常只需要一个支持C11的编译器和make。Python 绑定另外需要 Python 的开发头文件很多发行版里是带-dev或-devel后缀的那个包和setuptools。我在几台机器上装下来的经验是先把这三件事确认掉能省掉 80% 的报错g --version能输出版本且不低于 4.8最好 7 以上。python3-config --includes能正常输出包含路径。如果这条命令报找不到就是缺开发头文件。磁盘剩余空间要够。编译过程本身不大但后面处理语料会生成多个中间文件一个中等规模语料生成的编码文件和模型文件加起来上 GB 是常事留个几十 GB 比较稳妥。编译本身一般就是经典的configure加make那一套有些版本直接make就行。这里有个参数值得留意如果你的任务只是做统计和查询不需要额外的功能模块可以在编译时关掉它们能明显缩短编译时间也能减少链接阶段的意外。注意编译时如果报链接错误八成是某个可选的压缩库缺失。colibri 会把这类依赖做成可选项缺了也能编但生成的模型文件体积会大一些。我个人的建议是装上因为模型文件通常会被反复读写省下来的 IO 时间远超装一个库的成本。2.3 装完之后一定要做的三项验证装完别急着跑真实任务先做三个最小验证确认工具链是通的# 1. 命令行工具是否在 PATH 里 colibri-classencode --help colibri-patternmodeller --help # 2. Python 绑定能否导入 python3 -c import colibricore; print(ok) # 3. 用一个三行的小文件走一遍完整流程 printf the cat sat\nthe cat ran\nthe dog sat\n tiny.txt colibri-classencode tiny.txt第三条命令跑完你目录下应该会出现一个后缀类似.cls的编码表文件和一个.dat的编码语料文件。如果这两个文件出现了说明最脆弱的编码环节是通的。这一步我强烈建议不要跳过。真实语料动辄跑几十分钟如果工具链本身有问题你会在几十分钟后才发现非常浪费时间。2.4 Python 环境里的一个隐藏坑如果你用虚拟环境venv或者conda要特别注意编译时的 Python 和运行时的 Python 必须是同一个。我遇到过一次很典型的情况用系统 Python 编译了绑定然后在虚拟环境里import colibricore结果报了一个看起来完全无关的动态链接错误。排查了半天才反应过来虚拟环境用的是另一个 Python 版本。解决办法就是先激活虚拟环境再编译绑定。另外如果你的项目是通过容器部署的编译出来的库要打进镜像里记得把运行时需要的那几个.so文件一起带上别只在构建阶段装了。3. 把术语翻译成人话class、pattern、unigram、skipgram 到底是什么3.1 class encoding给每个词发一个索书号想象一个图书馆书有几百万本。如果每本书都用完整的书名来排序和查找效率极低。所以图书馆给每本书一个索书号索书号很短、定长、可以直接比较大小上架下架都按索书号来。class encoding 就是这个索书号系统。colibri 会先扫描一遍语料统计每个 token 出现的次数然后给每个 token 分配一个整数 ID把词到 ID的对应关系存成一个编码表文件习惯上后缀是.cls本质是一个纯文本文件可以直接用编辑器打开看。之后语料本身被转换成整数序列存成二进制文件。这里有三个细节值得说清楚编码表是可以复用的。同一批语料加工出来的编码表可以反复用来编码新的文本。这在增量处理场景下非常有用老语料不用重新编码。编码表本身很小。它只存词到 ID的映射不存语料内容所以可以跟着代码一起做版本管理。遇到编码表里没有的词需要一个兜底策略。通常是映射到一个专门的未知ID。这一点后面讲踩坑时会重点说因为它是最容易导致结果对不上的原因。3.2 unigram、n-gram最朴素的那一层unigram 就是单个 token 的统计n-gram 就是连续的 n 个 token。这部分没什么玄的任何做过文本统计的人都熟。colibri 在这里的价值不在于能算而在于能在大规模上算、并且算完能存下来反复查。对于连续性 n-gram它本质上是在编码后的整数序列上做滑动窗口计数因为整数比较快、排序快整体吞吐量比字符串方案高得多。3.3 skipgram允许在中间挖洞skipgram 是 colibri 真正有意思的地方。举个具体的例子。语料里有这么几个句子the black cat sat on the mat the white cat sat on the mat the small cat sat on the mat如果你只统计 5-gramthe ___ cat sat on 这一类的模式永远统计不到因为它们连在一起的时候各自不同。但实际上the X cat sat on 是一个有意义的模式X 是什么词并不重要。skipgram 允许你在模式里插入通配位置把这个共同结构抽出来。colibri 内部把它表示成一个带 gap 的 pattern。代价是组合数量会膨胀。一个长度为 n 的模式允许出现 k 个跳词位置候选数量是组合级的。所以实际使用中必须配合阈值剪枝只有出现次数超过某个阈值的模式才会被保留下来。这个阈值的设定是整个流程里最需要经验的参数之一后面我会给一组我常用的起点。3.4 flexgram连洞的数量都不固定flexgram 是在 skipgram 基础上再抽象一层不预先指定有几个洞、洞在哪里而是让工具自己去发现哪些位置可以灵活。理解它的方式是把它当成更宽松的模糊匹配。它的表达能力最强但组合数量也最恐怖因此对阈值的敏感度最高。下面这张表是我自己在选模式类型时的判断依据模式类型表达能力强弱组合数量增长典型用途unigram最弱线性词频统计、词表构建连续 n-gram中等线性n 固定时固定搭配、术语抽取skipgram较强组合级带变项的句式模板flexgram最强组合级的组合级探索性分析谨慎使用提示新手最容易犯的错是一上来就开 flexgram。我的建议是永远从连续 n-gram 开始把结果看明白了再逐步放开 skipgramflexgram 只在明确需要的时候用。4. 一条完整链路从原始文本到可查询的模式模型4.1 编码之前语料本身要怎么处理colibri 处理的是 token 序列所以怎么切 token这件事完全由你决定它不管。这个自由度是双刃剑好处是你对结果有完全的控制坏处是很多人在这里偷懒最后得到一个没法解释的模型。我自己的习惯是这样几条统一字符编码。全部转成 UTF-8不要混。混编码的语料在编码阶段就会出问题而且错误信息很难定位。明确分句边界。绝大多数模式统计是有意义的句子内统计跨句的 n-gram 没有语言学意义。所以我一般让一行一句把第 4 步交给工具的时候边界是干净的。colibri 在读取语料时通常是按行处理的训练时如果用--sentences之类的开关含义就是把每一行当成独立单元。标点要保留还是去掉提前决定。保留的好处是你好。和你好能被区分开有些句式规律确实依赖标点去掉的好处是模式数量明显减少。我的默认选择是标点作为独立 token 保留但在后续查询时通过过滤把它排除。中文必须先分词。colibri 不做中文分词它收到什么 token 就统计什么。如果你直接把中文字符按字切开那统计出来的是字级别的 n-gram也是有用的比如做新词发现的候选但和词级别的分析是两回事要清楚自己在做哪一种。大小写处理要一致。要么全小写要么保留。全小写会丢掉一部分信息比如专有名词但能显著减少模式数量。4.2 建索引从命令行到 Python 两种方式命令行方式适合批量、脚本化的流水线。典型的三步是这样# 第一步扫描语料生成编码表和编码后的语料 colibri-classencode corpus.txt # 第二步在编码语料上训练模式模型 # 参数含义最小出现次数、最大模式长度、是否统计跳词模式 colibri-patternmodeller \ -i corpus.colibri.dat \ -o corpus.colibri.patternmodel \ --mintokens 5 \ --maxlength 8 \ --skipgram # 第三步导出频率列表或者做查询 colibri-freqlist -i corpus.colibri.patternmodel -o freqlist.txtPython 方式更适合做交互式探索和跟其他数据管道对接。骨架大概长这样import colibricore # 1. 建立编码器扫描语料 classencoder colibricore.ClassEncoder() classencoder.build(corpus.txt) classencoder.save(corpus.colibri.cls) # 保存编码表务必保存 classencoder.encodefile(corpus.txt, corpus.colibri.dat) # 2. 训练一个只做计数的模型 model colibricore.UnindexedPatternModel() corpus colibricore.Corpus(corpus.colibri.dat, colibricore.ClassDecoder(corpus.colibri.cls)) # 设置模型参数最小出现次数、最大长度、是否要跳词 model.options colibricore.PatternModelOptions( mintokens5, maxlength8, doskipgramsTrue, ) model.train(corpus) model.write(corpus.colibri.patternmodel) # 3. 需要查询时再加载成索引模型 indexed colibricore.IndexedPatternModel( corpus.colibri.patternmodel, colibricore.ClassDecoder(corpus.colibri.cls), )注意不同版本里的类名和方法名可能有细微差别比如build和process、train和build。我建议第一件事是dir(colibricore)看一眼当前版本到底有哪些类别照着教程硬写。4.3 把模型当只读数据库来用模型生成之后用法就很简单了基本就是把Pattern当成字典的 key。# 用编码器把一个字符串转成 pattern pattern classencoder.buildpattern(the cat sat) # 查询它的出现次数 count indexed[pattern] print(count) # 遍历模型按条件筛选 for p in indexed: if p.ngramsize() 4 and indexed[p] 100: print(p.tokens(), indexed[p])实际项目里我常用的几类操作按长度过滤只看 2-gram 到 4-gram太长或太短的单独处理。按频率过滤低频模式往往是噪音高频模式往往是虚词组合中间那一档才最有信息量。按是否含洞过滤把 skipgram 和连续 n-gram 分开看因为它们的解读方式完全不同。做覆盖率检查看前 N 个高频模式覆盖了语料里多少 token这个指标能帮你判断阈值是不是设得太松或太紧。4.4 一个能立刻验证的小实验想快速建立直觉可以拿一个几百行的文本做这样一个实验先用很高的阈值比如 20跑一遍看看留下来的都是什么然后把阈值降到 3 再跑一遍对比一下多出来的模式。你会很直观地感受到阈值对结果的影响这比看任何文档都有效。我自己第一次做这个对比的时候看到的结果是高阈值下留下来的几乎全是the of and这种组合低阈值下才开始出现真正有意义的搭配。这个观察直接改变了我的参数选择习惯——不要用固定阈值要用出现次数排名前百分之多少这样的相对阈值这样不同规模的语料之间才有可比性。5. 踩坑实录四个让我重跑整晚的回合5.1 第一回合阈值设低了内存直接见底这是最典型的一次。语料大概几千万句我心想阈值设小一点才能发现稀有模式于是把最小出现次数设成了 2。跑起来之后机器内存曲线一路往上最后 OOM 被杀掉。排查过程其实很简单但当时没想明白。我用一个小样本先跑了一遍把生成的模型按长度分组统计数量结果是这样的2-gram 的数量是合理的3-gram 数量涨了一个量级4-gram 又涨一个量级到了 5-gram 数量开始失控。也就是说在低阈值下长模式的增长是超线性的因为长模式本身数量就多低阈值又几乎不剪枝。我最终的解决办法是三条一起用阈值不要一刀切短模式放宽、长模式收紧。常见做法是给一个基础阈值然后随着长度增加逐步提高门槛。限制最大长度。绝大多数有意义的搭配在 5 以内超过 8 的模式往往只是句子的碎片。分阶段生成模型。先生成长度 2 到 4 的看看效果再决定要不要往上加。另外还有一个内存相关的经验colibri 生成的模型文件是二进制并且支持内存映射的这意味着查询阶段几乎不占内存内存压力主要集中在训练阶段。所以如果你的场景是生成一次、查询很多次那训练时多用点内存是完全值得的。5.2 第二回合查询结果对不上问题出在符号表这次更隐蔽。我在 A 机器上生成了模型文件和编码表在 B 机器上做查询同一个 pattern 查出来的次数和 A 机器上不一样。一开始我怀疑是并发或者缓存的问题查了半天没头绪。后来才定位到真正的原因我把编码表换成了新版本。具体是这样我在处理新语料的时候重新生成了一份编码表因为新语料里有新词然后把旧的模型文件和新的编码表配在一起用了。看起来好像没问题因为两份文件都在但问题是旧的模型文件里的整数 ID 是按旧编码表编的新的编码表可能把不同的 ID 分配给了不同的词。整数序列本身没变但解读它的字典变了结果自然全错。这件事给我留下的教训很深我把结论固化成了三条操作规范三个文件必须成套。编码表、编码语料、模型文件这三者的关系是字典-正文-索引缺一不可混用必错。文件命名带上版本或时间戳。我现在的习惯是corpus_20240612.cls这样命名一眼能看出是哪一批。跨机器传输时整包传。不要只传模型文件三个文件一起打包。文件类型作用能不能单独换编码表.cls词到整数 ID 的映射绝对不能必须和模型配套编码语料.dat整数序列形式的语料可以和模型分开存放但重建模型时必须一致模型文件.patternmodel模式与计数的索引依赖编码表不能单独迁移提示如果你确实需要更新编码表正确做法是拿新编码表重新编码语料、重新训练模型而不是把新旧拼在一起。5.3 第三回合skipgram 的参数不是越大越好第三个坑跟 skipgram 有关。我当时想统计一种X 和 Y的句式模式本来以为开启跳词就能自动找到。结果跑出来的结果里塞满了大量无意义的模式比如的 __ 了这种。原因是我把允许的洞的数量放得太宽了。跳词模式的数量增长非常快一旦洞的数量放开几乎所有高频虚词的组合都会涌现出来真正的结构模式被淹没在噪音里。我的调整思路是先限制最多允许一个洞。一个洞已经能覆盖大部分固定框架 可变填充的模式。给洞的位置加约束。有些实现允许你指定洞不能出现在模式的边缘这个约束很有用因为边缘有洞的模式往往没有语言学意义。对跳词模式单独设阈值通常要比连续 n-gram 的阈值高因为它们本身更稀疏、更容易出现过拟合式的偶然匹配。还有一个反直觉的点跳词模式的计数天然会比连续模式高因为同一个出现位置可能被多个不同洞位置的模式匹配到。所以你不能拿跳词模式的计数和连续 n-gram 的计数直接比较大小它们不在一个尺度上。5.4 第四回合把模型文件放到网络盘读取慢得离谱这次是环境问题。团队里为了方便共享把模型文件统一放在网络存储上。结果查询速度变得非常慢比放在本地盘慢了两个数量级。原因在于colibri 的模型文件是设计成内存映射方式的也就是说它依赖操作系统的页缓存和随机读取。网络文件系统在这个场景下表现很差因为每次页缺失都可能触发一次网络往返。对本地盘来说这是微秒级的操作对网络盘来说可能变成毫秒级累积起来就是灾难。解决办法很朴素把模型文件复制到本地临时目录再用。如果你的模型文件有几个 GB复制一次可能要几分钟但相比之后成百上千次查询的提速这笔账非常划算。顺带说一个相关的经验训练阶段和查询阶段最好不要放在同一台机器上抢资源。训练吃 CPU 和内存查询吃磁盘缓存混在一起容易互相拖累。6. 接进现有工具链几个我常用的组合打法6.1 跟 Python 数据管道对接的方式colibri 最容易被人忽略的优势是它有 Python 绑定。这意味着你可以把它当成一个高性能统计中间件塞进现有的 Python 流程里而不是必须走命令行。我常用的模式是这样的上游用 pandas 或者普通 Python 脚本做清洗把结果写成一行一句的文本中间调用 colibri 做编码和建模下游把频率列表读回 pandas做进一步的特征工程或者可视化。这里有一个细节值得强调不要把逐条查询写在 Python 的循环里去问模型这个词出现了几次。更好的做法是一次性把模型导成频率列表然后在 Python 侧用字典或者 DataFrame 做查询。原因还是那个老问题跨语言调用的开销。每一次从 Python 进到 C 层都有固定成本几百万次调用累积起来非常可观。导出之后在 Python 侧的操作就自由多了import pandas as pd # 假设 freqlist.txt 每行是 计数 制表符 模式 df pd.read_csv(freqlist.txt, sep\t, headerNone, names[count, pattern]) # 只看长度在 2 到 4 之间、且频率在中高位段的模式 df[length] df[pattern].str.split().str.len() candidates df[(df[length].between(2, 4)) (df[count] 50)] print(candidates.sort_values(count, ascendingFalse).head(50))6.2 模型文件的持久化与版本管理模型文件通常有几个 GB不适合直接进 Git。我的做法是把它当成构建产物来管理代码和配置进版本库模型文件进对象存储或者制品库用版本号或者内容哈希作为文件名。在仓库里维护一份清单记录每个模型文件是用哪份语料、哪组参数、哪个版本的 colibri 生成的。关键参数写进配置文件不要留在命令行历史里。这份清单看着麻烦但当你需要复现半年前的实验结果时价值就体现出来了。我吃过不止一次参数忘了的亏最后不得不重跑一遍浪费时间不说还不一定能得到完全一样的结果。6.3 什么时候该老实回去写脚本最后说个反向的建议。colibri 不是万能的有几个信号出现的时候我会果断放弃它回到 Python 脚本数据量不大。几百万 token 以内Counter加itertools的组合足够快几分钟就跑完了没必要引入一整套编码建模流程。需要频繁改变切分逻辑。如果要不断调整分词方式colibri 的编码一次、多用几次优势就消失了因为每次都要重新编码。这种情况下直接用 Python 迭代更快。需要丰富的模式运算。比如你要做模式之间的相似度计算、聚类、图分析colibri 提供的是计数和查询后面的分析还是得回到 Python 生态里做。我的实际经验是把 colibri 定位成语料规模上到一定程度之后用来降低统计成本的工具而不是所有文本分析任务的默认起点。这个定位想清楚了选型和排错的思路都会清晰很多。我自己现在的做法是任何项目先用 Python 写个几十行的最小版本跑一遍如果这一步就满足需求收工如果发现跑不动或者反复重算太浪费时间再切换到 colibri这时候你已经对数据和参数有了直觉切换成本最低。最后分享一个小习惯每次生成模型之后我会先用极小的样本比如一百行文本走一遍完整流程确认三个文件都生成正确、能查出一个已知的模式再放到全量语料上跑。这一步大概花两分钟但帮我省掉过好几个小时的无效等待。
返回列表