pkuseg中文分词实战:领域自适应、自定义词典与模型微调 很多人第一次接触中文分词都是从调一个库、跑一句话、看看切出来的词开始的我也不例外。但真到业务里拿分词结果做关键词抽取、做评论标签、做检索召回的时候通用分词器给出的结果经常让人挠头——机器学习被切成机器和学习苹果手机被切成苹果和手机一个行业黑话更是被切得七零八落。我最近因为一个中文短文本标签的项目重新翻出了 pkuseg 这个由某高校语言计算团队开源的领域自适应中文分词工具从安装、跑通、换模型、加自定义词典到训练自己的领域模型完整走了一遍。这篇就把初试过程、实测数据、踩过的坑和最终的使用感受原原本本写下来适合正在做中文文本处理、纠结分词方案选型、或者想尝试领域自适应分词的朋友参考。全文不讲空话代码和参数都能直接抄。1. 为什么我会去折腾一个老分词器1.1 一次关键词抽取跑偏引出的问题项目背景不复杂一批中文短文本长度大多在 15 到 60 字之间需要从中抽取标签词。我一开始用的是业界最常用的那个基于前缀词典加动态规划的通用分词库好处是安装零成本、速度快、社区资料多。但跑完第一轮统计之后Top50 的高频词里出现了大量无意义碎片比如的了这个之外还有操作起来感觉还行这类半截短语以及被错误切开的专有名词。我做了个简单的对照人工标了 200 条短文本的理想切分结果然后拿分词器输出和人工结果算词级别的准确率。通用分词器在通用新闻语料上表现还行但在这种带点口语化、带点行业术语的短文本上词级 F1 大概只有 0.86 左右。问题集中在三类领域术语被切碎、数字和英文混排处理粗糙、短句里缺少上下文导致歧义切分。这个时候我想起了 pkuseg。它在设计上的出发点恰恰就是领域自适应——不追求一个模型打天下而是提供多个已经训练好的领域模型同时开放训练接口让你用自己的语料继续训练。这一点和大部分开箱即用、但很难改的分词器有本质区别。需要说明的是这个项目已经有一段时间没有大版本更新了社区活跃度不如当年。但停止更新不等于不能用尤其是在你需要一个可训练、可解释、依赖轻量的中文分词基线时它仍然是一个很实用的选择。我后面会专门说清楚哪些场景该用、哪些场景建议绕开。1.2 pkuseg 与通用分词器的根本差异在哪里要理解 pkuseg 的行为得先知道它在做什么。它的核心是一个基于字符级特征的序列标注模型用平均感知机averaged perceptron做训练输出每个字的 BMES 标签再把标签还原成词。这个技术路线决定了三件事第一它对未登录词词典里没有的词的泛化能力来自特征模板而不是词典匹配。特征通常包括当前字、前后各一到两个字的字符、字符的类别数字、英文、标点、以及是否在词典中命中。这意味着遇到新词时它靠字与字的组合规律来判断边界而不是死查词典。第二它天然支持增量训练。因为模型参数是可以继续更新的你可以拿一个预训练模型做初始化再用自己的标注语料做微调这就是官方所说的fine-tune模式。相比之下纯词典类的分词器想加入领域词只能靠扩充词典而词典扩充对歧义切分几乎无能为力。第三速度不如纯词典方案。序列标注模型要对每个字做一次特征打分复杂度随文本长度线性增长但常数比词典匹配大得多。这是它绕不开的代价我在实测里会给出具体数字。这里做个直观的类比通用词典分词像查字典做题见过就会没见过就靠规则硬猜pkuseg 更像一个学过语感的学生即使没见过某个词也能根据这两个字老是黏在一起判断它们该是一个词。前者快且稳定后者灵活但有代价。2. 把环境搭起来从安装到跑通第一行代码2.1 依赖与安装过程中最容易卡住的地方先说结论如果你的 Python 环境是 3.7 到 3.9 之间的纯净虚拟环境安装过程通常很顺。但如果你在 3.10 以上、或者系统里已经装了一堆科学计算包的环境里直接 pip很可能会遇到两个问题。第一个问题是编译依赖。这个包在安装时可能需要本地编译 Cython 扩展如果没有 C 编译器就会报错。我在一台干净的 Linux 机器上复现过报错信息大致是找不到Python.h或者 gcc 不可用。# 先确认基础编译环境 gcc --version python3 -c import sysconfig; print(sysconfig.get_paths()[include]) # Debian/Ubuntu 系补装 sudo apt-get install -y build-essential python3-dev第二个问题是版本漂移。原作者维护的版本对新 Python 的兼容性有限我在 3.11 环境下试过安装能过但 import 时报了一些与旧 API 相关的告警。我的处理办法是干脆用 conda 起一个 3.9 的独立环境不和主环境混。conda create -n seg python3.9 -y conda activate seg pip install pkuseg提示不要在生产镜像里直接用latest版本把版本号钉死在requirements.txt里否则哪天构建机拉到一个新版本行为变化很难排查。装完之后官方提供了一份预训练模型包通常需要单独下载并解压到包目录下或者通过参数指定模型路径。我第一次就是因为模型路径没对上程序起不来却报了一个很含糊的错误。模型目录结构大概是这样的pkuseg/ └── pretrained/ ├── spacy/ # 混合领域新闻、网络、科技等 ├── default/ # 通用领域 ├── msr/ # 简体中文分词语料风格 └── ctb/ # 树库风格判断模型是否就位最简单的办法是看目录里有没有model.pt之类的权重文件和一个dict.txt词典文件。缺任何一个加载都会出问题。2.2 首次加载模型的耗时与内存实测跑通第一行代码之前我特意量了一下加载成本因为它直接影响你在服务里能不能接受这个组件。import time import pkuseg t0 time.time() seg pkuseg.pkuseg() # 默认走混合模型 t1 time.time() print(f模型加载耗时: {t1 - t0:.2f}s) text 这个降噪耳机的主动降噪模块在通勤场景下表现不错 t2 time.time() for _ in range(1000): seg.cut(text) t3 time.time() print(f1000 次分词总耗时: {t3 - t2:.2f}s)在 4 核 8G 的机器上默认模型首次加载大约 1.5 到 2.5 秒常驻内存增加约 200 到 300MB。这个数字放在批处理任务里完全无感但如果你是做在线短文本服务、每秒几十次请求那就一定得做成进程内单例绝对不能每个请求都新建一个分词器对象——我见过有人在接口里每次调用都pkuseg.pkuseg()一次QPS 直接掉到个位数。另外还有一点很关键这个分词器实例不是线程安全的。多线程共用一个对象时我遇到过输出结果错乱的情况虽然不常见但确实发生过。稳妥的做法是每个线程持有一个实例或者干脆用多进程。import threading _local threading.local() def get_seg(): if not hasattr(_local, seg): import pkuseg _local.seg pkuseg.pkuseg() return _local.seg def cut(text): return get_seg().cut(text)注意每个线程各持一个实例意味着内存乘以线程数。8 个线程就是 2GB 起步容器内存限制一定要提前算好我就因为这一点被打爆过一次 Pod。3. 分词模式与领域模型到底该选哪一个3.1 内置模型的差异对照这个工具最吸引我的地方就是多模型可选。官方提供了几个预训练模型风格各不相同。我用同一批短文本200 条餐饮、数码、出行三类评论混合跑了一遍主要看粒度差异和术语识别情况。模型训练语料倾向分词粒度术语识别我的实测耗时千条混合模型新闻网络科技偏细中等偏上约 6.2s通用模型通用书面语适中一般约 5.8s简体分词语料风格规范标注语料偏细中等约 6.0s树库风格树库标注规范偏粗偏低约 5.9s说人话的结论如果你完全不知道自己的语料是什么领域先用混合模型它的综合表现最稳如果你处理的是规范化文本、追求更细的粒度用于检索可以试语料风格那个树库风格偏粗适合你需要短语级而不是词级切分的场景比如构建短语索引。我实测中最明显的差异出现在复合名词上。比如续航焦虑这个词混合模型能整体切出通用模型会切成续航 / 焦虑。在标签抽取任务里前者显然更有价值。3.2 词性标注与自定义词典的正确用法词性标注是顺手就能用的能力把postag打开即可import pkuseg seg pkuseg.pkuseg(postagTrue) text 这个降噪耳机的主动降噪模块在通勤场景下表现不错 print(seg.cut(text)) # [(这个, r), (降噪, n), (耳机, n), ...]有了词性抽名词做标签这件事就变得很直接保留n、nr、ns、nz、vn这几类过滤掉代词、助词、副词。我一开始没做词性过滤结果抽出来的标签里全是这个非常感觉纯粹是浪费。自定义词典是我认为这个工具最实用的功能之一但用法上有几个容易踩的点import pkuseg seg pkuseg.pkuseg(user_dictmy_dict.txt) print(seg.cut(小明在用开源分词工具做领域适配))词典文件就是每行一个词UTF-8 编码不要带表头、不要带额外列。如果要给词加权可以在词后面加空格再写一个数字。我第一次写词典时在文件末尾留了一个空行结果加载时报了一个挺难懂的解析错误删掉空行就好了。提示自定义词典是提高该词被整体切出的概率不是强制切出。当上下文特征强烈指向另一种切分时词典也可能失效。这一点后面我在排查链路里会详细展开。4. 在真实语料上的初试效果、速度与踩坑记录4.1 三份语料的对比测试为了不只凭感觉判断我准备了三份语料做对照第一份是 200 条人工标注的短文本就是前面说的那个测试集用来算词级 F1第二份是约 12 万字的行业资讯正文用来测吞吐第三份是 3 万条左右的用户评论用来测真实口语场景下的稳定性。先看准确率。加上自定义词典我人工整理了 380 个领域术语之后混合模型在短文本测试集上的词级 F1 从通用方案的 0.86 提到了 0.915 左右。提升主要来自两块领域术语整体切出的比例明显上升数字与单位、英文缩写的粘连错误明显减少。再看吞吐。12 万字正文单进程单线程用混合模型跑完大约 8 到 10 秒换算下来 1.2 到 1.5 万字/秒。如果开nthread参数多线程跑在 4 核机器上能到 3 万字/秒左右但核数再多收益就很小了因为 Python 层的调度开销上来了。seg pkuseg.pkuseg(nthread4)第三份用户评论语料上暴露的问题最多。口语化表达、错别字、表情符号残留、标点缺失这些都会让模型判断失准。比如好用的一批这种模型很难处理得好再比如连着几个感叹号没有空格切分结果会带出一堆单字符片段。我的处理方式是在分词前做一轮清洗全角转半角、去掉连续的重复标点、把表情符号统一替换成空格、把英文缩写前后的空格补齐。清洗之后同一个模型在评论语料上的可读性提升非常明显。这个清洗层看起来不起眼但它是分词效果提升性价比最高的一环比换模型管用。4.2 自定义词典不生效的完整排查链路这一段我想把排查过程完整写出来因为它很典型而且网上的答案基本都对不上。现象我在词典里加了续航焦虑降噪模块快充协议这几个词但在测试文本里续航焦虑依然被切成两段快充协议偶尔能整体切出、偶尔又不行。第一步确认词典真的被读进去了。我在user_dict指定的路径上临时改成绝对路径然后在词典里故意放一个肯定不存在的怪词看它会不会被整体切出。结果怪词被正确整体切出说明加载没问题。这一步排除了路径错误、编码错误、文件权限错误。第二步确认词典文件的格式。我用file my_dict.txt查了编码又用cat -A看行尾。发现文件是从某表格软件导出的行尾带\r\n而且部分行末尾还有制表符。修复方式是统一转成 LF 并把制表符换成空格。改完之后快充协议的稳定性好了很多说明行尾脏字符确实会干扰解析。第三步看长度。续航焦虑是四个字四个字的词在序列标注里本来就难整体切出因为模型倾向于把长词拆成语义更常见的二字组合。我做了个对照实验把续航焦虑改成续航焦虑感五字结果反而更容易整体切出改成焦虑二字则百分百整体切出。这个规律说明词典权重对短词更有效。第四步加权。词典里给词加权重写法和效果是这样的续航焦虑 5 降噪模块 3权重越高模型越倾向于把它当成一个整体单元。我实测下来权重从 1 提到 5四字词的整体切出率有明显改善但副作用是相邻的边界可能被带偏所以不建议全表统一拉高只对真正高频的核心术语加权。第五步考虑微调。如果加权之后仍然不稳定说明这个切分方式和模型训练时的语料分布冲突太大。这时候真正的解法是拿一批标注语料做微调而不是继续调词典权重。整个链路走下来花了大半天但收获很实在**词典不生效九成情况是格式和权重问题一成是模型本身的分布问题。**先排查前者因为成本低。5. 训练自己的领域模型从语料到可用的模型文件5.1 训练数据的格式与标注成本这条工具链路最有价值的部分是能自己训练。训练语料是词级别的标注格式一行一句词之间用空格分开标点单独成词这个 降噪 耳机 的 主动 降噪 模块 在 通勤 场景 下 表现 不错 快充 协议 兼容 性 一般 但 充电 速度 很快 。关于标注量我做了个粗略的资源评估。我的经验值是如果只做领域适配微调2000 到 5000 句带标注的领域文本就能看到明显收益如果要从零训练一个模型那至少得几万句而且标注质量比数量重要得多。对我这个项目来说我标了大约 3000 句短文本两个人各标一半然后交叉复核大概花了三四天。标注过程中最容易出现的分歧是复合词边界。比如无线充电底座是切无线/充电/底座还是无线/充电底座这类问题必须提前定好标注规范否则前后不一致模型学出来也是乱的。我们的做法是先标 200 句把有分歧的地方全部拉出来开会定调形成一份简短的《切分口径说明》后面统一按这个来。还有一个坑训练语料和测试语料要来自同一个分布。我第一次把新闻风格的语料拿去训练、用短评论文本测试效果反而比预训练模型还差。换成短评论文本训练之后才正常。这个道理很简单但实操中真的很容易忘。5.2 微调流程与验证方式训练接口的调用形式很简单import pkuseg # 微调在已有模型基础上继续训练 pkuseg.train( train_filetrain.txt, test_filedev.txt, output_dir./my_model, nthread4, train_iter20, fine_tuneTrue, )几个参数我实测下来的体会train_iter控制迭代轮数。轮数太少欠拟合太多会过拟合到训练集的口音上在没见过的文本上反而退化。我的数据量下从 10 轮开始试15 到 20 轮之间稳定25 轮之后 dev 上的指标开始掉头这就是典型的过拟合信号。fine_tuneTrue表示在预训练模型上做增量训练False就是从零训练。除非你的领域和通用领域差异极大比如大量专业符号、特殊格式否则建议一律从微调开始收敛快、需要的语料少、对标注噪声的容忍度也高。训练完之后会产出一个模型目录加载时把路径指过去就行seg pkuseg.pkuseg(model_name./my_model, user_dictmy_dict.txt)验证方式我用了两套。一套是自动指标拿一份单独的 dev 集算词级 P/R/F1。另一套是人工抽检随机抽 100 条人工看切分结果是否合理重点看领域术语和歧义句。自动指标高但人工抽检不理想的模型我遇到过一次原因是我标注的是细粒度但业务实际需要的是短语级指标和目标对不上。所以人工抽检这一步不能省。提示训练过程对内存消耗比推理大不少且训练时nthread开太多反而可能变慢。我建议先用 2 到 4 线程跑小规模验证确认流程通了再全量跑。6. 我的使用感受与选型建议6.1 什么时候该用它什么时候不该用用了一周多我的判断是这样的。适合用的场景一是领域术语密集的文本比如垂直行业评论、专业问答自定义词典加微调的组合效果非常好二是需要词性标注、又不想引入重量级深度学习框架的轻量项目三是批量离线处理几万字几百万字一次性跑完对首字延迟没有要求的四是需要可解释分词边界的场景因为它的特征模板相对透明出问题时能推演。不适合用的场景一是对首字延迟极度敏感的在线接口它的模型加载和单句推理成本比纯词典方案高一个量级二是需要长期获得社区支持、BUG 修复、新特性的项目这个工具已经很久没有版本迭代了遇到环境兼容问题得自己动手三是超大规模、需要分布式推理的场景它不是为这个设计的四是多语言混合文本中文之外的语言它没有优势。我最后在这个项目里的处理方式是双通道离线批量任务用 pkuseg 加微调模型保证标签质量在线接口用轻量的通用分词方案做兜底保证响应速度。两者共用同一份自定义词典行为尽量对齐。6.2 工程化落地时的几个补救手段考虑到这个工具维护状态落地时必须做几层防护这些都是我踩过之后补上的。第一层是模型文件版本化管理。预训练模型和我自己训的模型都放进对象存储带上哈希值部署时按哈希拉取避免同样的镜像在不同机器上加载到不同模型这种玄学问题。第二层是降级路径。封装一个统一的分词接口内部先试 pkuseg加载失败或者超时就自动切到备用方案并打日志。我遇到过模型文件因为容器层缓存被覆盖导致加载失败如果没有降级整个服务会直接起不来。def segment(text): try: return _primary.cut(text) except Exception as e: logger.warning(primary segmenter failed: %s, e) return _fallback.cut(text)第三层是黄金测试集。维护一份 300 条左右的人工标注样本每次升级模型或者调整词典都跑一遍算指标指标跌超过 1 个百分点就拒绝上线。这个做法听起来很重但真正救过我一次——某次我无意中改了一个词典权重整体指标只掉了 0.8 个点但某个核心品类的术语整体崩了黄金集里正好有这类样本直接被拦下来。第四层是输入清洗的前置。前面说过清洗对效果的提升性价比最高。我把清洗做成独立模块规则很土但很有效全角转半角、连续标点折叠、表情符号替换、英文数字与中文之间补空格。这四条规则加上去同一个模型的可读性和下游标签质量都有肉眼可见的提升。还有个小技巧值得单独提一句分词结果别直接进下游统计先做一遍停用词和单字过滤。单字在标签抽取里几乎是纯噪声尤其是的了个是这类。加一个长度大于等于 2的过滤标签质量立刻干净一截成本几乎为零。我在实际操作中的体会是这类老牌但思路扎实的工具价值不在于它是不是最先进的而在于它的可改造性强。你愿意花两三天标一批数据、调一次模型它就能给你一个贴合自己领域的、行为可预期的分词基线。反过来如果你只是想要装完就能用、且永远不用管那它可能不是最省心的选择。