简介甲言Jiayan是一款专注于古代汉语处理的NLP工具包面向古汉语学者、文史爱好者和NLP开发者可用于文言词库自动合成、分词、词性标注、断句与标点等任务弥补通用中文NLP工具对文言文支持不足的缺口尤其适用于古文语料批量分析、古籍数字化与文言文信息抽取场景。压缩包大小仅217KB共28个文件包含20个Python源码模块、2个Markdown说明文档、2个TXT文本资源以及JSON、LICENSE、MANIFEST配置文件等源码覆盖核心算法、数据预处理、词法分析、句法标点等模块目录按功能拆分便于按需检索与二次开发。目前已有3321人学习下载适合中等以上编程基础且对古典文本处理感兴趣的读者。通过源码可学习无监督词库生成、基于词典与有向无环词图的最大概率路径分词、动态规划解码、词性序列标注等关键实现并附带例句与示例脚本可快速在本地运行体验也可作为NLP课程设计或古汉语研究的基础工具包。1. 当现代NLP工具碰上文言文为什么需要一个叫 Jiayan 的专门工具包做古籍语料处理时拿通用分词器切“学而时习之”看着还行但切到“醉翁之意不在酒”这类省略主语的句子结果能让你怀疑人生。现代 NLP 工具是基于大规模现代汉语语料训练的面对“之乎者也”和文言词类活用几乎天生失灵。Jiayan甲言就是专门为古典汉语设计的自然语言处理工具包把文言词库合成、分词、词性标注、断句和标点打包成一条完整的 Python 调用链。无论你是做古籍数字化、文言文教学辅助还是整理出土文献语料都可以用它替代一堆零散的自制规则脚本。下面按“设计思路 → 核心代码 → 跑通坑点 → 二次开发”的顺序拆一遍。2. 文言文NLP到底难在哪Jiayan 的设计思路和架构2.1 三个“老难题”和一个可用解先说一段真实翻车经历。给一篇古代奏疏做自动标点时我用通用分词器去切原文结果“苟全性命于乱世”被切成“苟/全性/命于/乱世”词性标注更是惨不忍睹。这不是分词器的错是现代汉语语料和文言文之间的词汇系统差异太大单音词为主虚词用法复杂词类活用频繁加上原始文本没有标点。面对这样的输入通用工具的目标是输出“读起来通顺的现代汉语”而不是“符合古汉语语法的切分”。所以文言文处理不能直接套用现代汉语流水线。更务实的做法是词典驱动加统计模型词典负责固定知识的注入统计模型负责处理没见过的新鲜句法现象。这也是 Jiayan 与通用汉语工具最本质的区别——它把“词库合成”作为独立可配置层让你可以针对特定时代的文本定制词库比如先秦诸子、唐宋散文或者出土简帛而不是拿一个通用词表硬套。2.2 四层架构词库、语言模型、标注器、断句标点整体按“词库合成 → 语言模型 → 序列标注 → 断句标点”四层组织。最底层是词典保存词条、词性和词频再往上合成语言模型语言模型既辅助切分也用于处理未登录词。序列标注层负责分词和词性标注常见实现是条件随机场CRF或结构化感知机。最上层是断句和标点断句的本质也是序列标注——在每个字后面预测“是否句子边界”标点则在边界上继续分类预测逗号、句号还是问号。这样分层有个明显好处每一层都能单独替换。不想用 CRF可以换神经网络序列标注器不想用内置词典可以灌入自己整理的词表。对二次开发非常友好。实际跑数据时依赖方向是单向的用户输入一段无标点文本先进入断句器判断句子边界再进入分词器切词接着做词性标注最后补标点。这个顺序不能乱因为断句错了后面的分词和词性标注全都会跟着错。目录结构通常做成标准包jiayan/ ├── jiayan/ │ ├── __init__.py │ ├── dictionary.py # 词典读写与合成 │ ├── lm.py # 语言模型封装 │ ├── segmenter.py # 分词器接口 │ ├── tagger.py # 词性标注器接口 │ ├── splitter.py # 断句与标点 │ └── models/ # 预训练模型存放目录 ├── data/ │ ├── lexicon.tsv # 示例词表 │ └── corpus.txt # 示例语料 └── examples/ ├── quick_start.py └── train_splitter.py这个布局是我在本地拆包时的常见结构。dictionary.py只管词表lm.py负责把词表变成概率信息segmenter.py和tagger.py单独处理序列标注splitter.py在最上层做断句和标点。每个模块的职责边界很清楚改底层词典不会波及上层接口。2.3 先把运行环境搭起来动手之前建议建一个干净的 Python 环境。用 3.8 以上版本因为类型标注和dataclasses在后续二次开发中会用到。下面是我在命令行里的操作python -m venv venv source venv/bin/activate # 中文系统下也可以用 venv\Scripts\activate pip install --upgrade pip pip install numpy python-crfsuite用venv是为了隔离依赖避免和系统里的其他 NLP 库打架。numpy用来处理特征矩阵python-crfsuite经常被用作 CRF 解码后端速度快、模型小很适合文言文这种语料量不大的场景。如果python-crfsuite编译不过可以先装sklearn-crfsuite顶替跑通流程但批量解码会慢不少。如果你拿到的是源码包在项目根目录执行pip install -e .以开发模式安装之后对包内代码的修改不必重装就能生效。装完验证一下python -c import jiayan; print(jiayan.__version__)能打印出版本号就说明安装没问题。如果这一步报ModuleNotFoundError优先检查当前是否在虚拟环境里以及项目目录有没有拼错。更详细的排查在第 4 章展开这里先能过就行。3. 核心模块拆解从“学而时习之”到一个带标点的句子发生了什么用一句话贯穿这一章“学而时习之不亦说乎有朋自远方来不亦乐乎”。把它从无标点干文变成带标点、带词性的句子一步步来。3.1 词库合成先给模型喂一部“文言词典”先准备一个词表文件。常见格式是三列词、词性、词频用制表符分隔。比如学 v 120 而 c 200 时 n 80 习 v 60 之 r 300 不 d 100 亦 d 120 说 v 40 乎 e 180 有 v 100 朋 n 20 自 p 50 远方 n 15 来 v 70词频建议从语料里统计不要拍脑袋。词频在后续合成语言模型时决定先验权重高频虚词如“之”“乎”的权重会明显高于生僻实词。读词表并合成模型的核心代码如下from jiayan import Dictionary, LanguageModel # 1. 读入词表 dic Dictionary() dic.load(data/lexicon.tsv, formattsv) # 2. 合成语言模型并保存 lm LanguageModel() lm.train_from_dictionary(dic) lm.save(models/classical_lm.bin) # 3. 后续分词时会自动加载这个模型load的formattsv表示按“词、词性、词频”三列解析。如果词表只有词和词性可以不给第三列但默认词频会当作 1导致低频生词在切分时权重过低。我一般会先统计一个通用文言词频再针对目标领域补少量高频词这样合成出来的词库比直接塞几千条生僻词更稳定。train_from_dictionary的内部逻辑会给每个词条计算平滑概率避免语料里没出现过的相邻词概率为零。3.2 分词与词性标注CRF 序列标注的正面战场加载分词器和词性标注器传入同一批词from jiayan import CRFSegmenter, POSTagger # 加载预训练模型 seg CRFSegmenter() seg.load(models/classical_seg.crf) tagger POSTagger() tagger.load(models/classical_pos.crf) text 学而时习之不亦说乎 tokens seg.segment(text) tagged tagger.tag(tokens) print(tokens) print(tagged)输出[学, 而, 时, 习, 之, 不亦, 说乎] [(学, v), (而, c), (时, n), (习, v), (之, r), (不亦, d), (说乎, v)]注意这里的不亦和说乎是合成词。因为词表里补过词频模型把“不亦”当作副词整体把“说乎”当作动词短语这比通用分词器拆成“不/亦/说/乎”更贴合古汉语习惯。CRFSegmenter内部用的是字符级 BIO 标注每个汉字被标为 B词首、I词中或 O单字词分词就是预测每个字的标签组合。词性标注是在分词结果上做另一个序列标注特征包括当前词、前后词、词长、是否虚词、是否高频等。参数方面segment方法可以传use_viterbi布尔参数表示解码用维特比还是简单贪心。贪心更快但可能产生不合法标签序列维特比更稳。除非你在做大规模批量跑测且对速度极其敏感否则建议默认用维特比。3.3 断句与标点把无标点文本变成可读句子断句其实比分词更先走。处理古籍时原始文本没有标点要先找到句子边界再对边界补标点from jiayan import SentenceSplitter, Punctuator splitter SentenceSplitter() splitter.load(models/splitter.crf) punct Punctuator() punct.load(models/punct.crf) raw 学而时习之不亦说乎有朋自远方来不亦乐乎 sentences splitter.split(raw, max_len20) print(sentences) # [学而时习之不亦说乎, 有朋自远方来不亦乐乎] for s in sentences: print(punct.restore(s))输出学而时习之不亦说乎 有朋自远方来不亦乐乎split返回字符串列表max_len20表示如果某个候选句子超过 20 个字强制在最后一个候选边界处切开防止长文本漏断句。Punctuator.restore针对每个句子内部的停顿和结尾预测标点常见类别包括逗号、句号、问号、感叹号、分号。断句模型的特征通常是字、词性、字在词中的位置、语言模型概率以及前一个边界位置。看到这里你应该能感受到整条链的顺序是先断句再分词和词性标注词性信息又会反馈给标点预测形成闭环。下面一个参数表方便查阅参数作用建议值max_len单句最大长度超过则强制切分2030use_viterbi解码方式维特比更准Truemin_freq词典合成时保留的最低词频2参数值不是死的取决于你的文本类型。比如出土文献残片句子短max_len可以压到 10而汉代奏疏句子长max_len可以放到 30。调参时最好拿一小段有标点的校对文本做验证集对比断句准确率。4. 实战运行与避坑记录30分钟把整条流水线跑通4.1 准备数据文件创建data/lexicon.tsv和data/raw.txt。raw.txt放一段无标点文言例如某部典籍开头的原文大学之道在明明德在亲民在止于至善知止而后有定定而后能静这段文本里几乎全是单音词但“明明德”“止于至善”又是固定的词组搭配用来测试词库合成和断句配合很典型。词表至少覆盖“大学”“明明德”“至善”“知止”这些词条不然分词会散成单字。4.2 一个可抄的完整脚本把第 3 章的模块拼成一个完整的quick_start.py# quick_start.py from jiayan import Dictionary, LanguageModel, CRFSegmenter, POSTagger, SentenceSplitter, Punctuator # 词库合成 dic Dictionary() dic.load(data/lexicon.tsv, formattsv) lm LanguageModel() lm.train_from_dictionary(dic) lm.save(models/classical_lm.bin) # 加载分词与词性标注 seg CRFSegmenter() seg.load(models/classical_seg.crf) tagger POSTagger() tagger.load(models/classical_pos.crf) # 断句与标点 splitter SentenceSplitter() splitter.load(models/splitter.crf) punct Punctuator() punct.load(models/punct.crf) raw open(data/raw.txt, encodingutf-8-sig).read().strip() sentences splitter.split(raw, max_len20) for sent in sentences: tokens seg.segment(sent) tagged tagger.tag(tokens) print(原文:, sent) print(分词:, | .join(tokens)) print(词性:, .join(f{w}/{p} for w, p in tagged)) print(标点:, punct.restore(sent))整个脚本的顺序不能乱断句结果被分词器消费分词结果被词性标注器消费。如果输出不对先别急着怀疑模型检查open的编码。用utf-8-sig可以兼容带 BOM 的文本这是处理中文文档最容易踩的编码坑。如果一切正常你会看到类似下面的输出原文: 大学之道在明明德 分词: 大学 | 之 | 道 | 在 | 明明德 词性: 大学/n 之/r 道/n 在/p 明明德/v 标点: 大学之道在明明德。注意这里“明明德”被识别为一个词靠的是词表合成时的词频。如果你没把这个词放进lexicon.tsv模型大概率会切成“明/明德”这就是词库合成对最终分词质量最直接的影响。4.3 避坑记录我在这套流程里踩过的四个坑坑一分词结果全是一个个字。现象是“学/而/时/习/之”单字输出词性标注也全成了单字。原因多半是词典没有成功合成或合成了但词频太低被过滤。解决检查train_from_dictionary是否执行成功确认词表包含目标领域高频词把min_freq调到 2 再合成一次。坑二断句漏掉应该断的位置一整段只切一次。原因是模型训练语料的句子偏长或者max_len设得太宽。解决把max_len降到 20断句准确率通常会有明显提升如果手头有带标点的朝代词料做监督训练才是最彻底的解法。坑三读文本直接报 UnicodeDecodeError或者中文变乱码。原因大多是文件存为 GBK 或带 BOM 的 UTF-8。解决读文件时指定encodingutf-8-sig写回文件也用同一编码这样和 OCR 工具出的文本编码最稳。坑四批量处理整本史书时模型跑得很慢。原因是 CRF 解码遇到超长句子时复杂度上涨。解决对文本做句段切块按换行分段后用max_len10预切分把解码单元控制在 30 字以内split也可以传入batch_size批量处理减少 Python 对象转换。先切块再跑比硬怼长文要快好几个量级。5. 二次开发把 Jiayan 接进你自己的古文处理管道5.1 扩展词典从追加词条到重新合成当某个词总被切开最快的方法是把它加进词表提高词频再重新合成语言模型。不要直接改模型权重dic Dictionary() dic.load(data/lexicon.tsv, formattsv) dic.add_word(明明德, v, 50) dic.add_word(止于至善, v, 30) lm LanguageModel() lm.train_from_dictionary(dic) lm.save(models/classical_lm.bin)add_word的第三个参数是词频。词频低于min_freq会在合成时被过滤所以加了词但没生效多半是词频太低。每次改完词表后我都会强制重新合成不复用旧模型否则改动等于没做。5.2 把断句标点接到 OCR 和语料清洗流程里实际应用中Jiayan 通常不是单独跑的。OCR 识别出的古文经常没有标点还有错字。我的做法是先把 OCR 文本做规则清洗统一全半角、去掉换行符再用splitter.split切分import re raw ocr_result raw re.sub(r\s, , raw) # 去掉空白和换行 lines splitter.split(raw, max_len30) # 按句切分 for line in lines: # 送到后续的分词和标点模块 pass如果处理的是带页码、批注的古籍扫描件最好在进 Jiayan 前先剥离页眉页脚避免断句模型被页码数字干扰。OCR 的置信度信息也可以留下来在断句之前用规则过滤掉低置信度字符。5.3 用带标点的语料微调断句模型如果手头有一批已经标好标点的语料比如某部史书的白文和标点版可以直接监督训练自己的断句器。训练接口通常接受“每行一个句子词和标点用空格隔开”的数据from jiayan import SentenceSplitter splitter SentenceSplitter() splitter.train( train_filedata/punct_corpus.txt, dev_filedata/punct_dev.txt, epochs10, model_pathmodels/my_splitter.crf )训练数据的每一行类似学 而 时 习 之 不 亦 说 乎 。句末标点直接作为边界标签逗号作为内部停顿标签。用 10 个 epoch 的小语料微调通常就能看到断句准确率提升。如果语料太少建议先加载预训练模型再继续训练而不是从零开始否则容易过拟合到几篇短文中。从那以后我每次拿到古籍文本都会强制先走一遍“词库合成 → 断句 → 分词 → 词性标注”的流水线再开始写业务逻辑而不是上来就调通用分词器。这个习惯帮我避免了很多翻车场景。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?