最近在整理中文文本挖掘的入门案例时手边一直放着一个名为threekingdoms.txt的文件——《三国演义》的中文纯文本。很多人都拿它当过练手语料最经典的需求就是“人物出场统计”把三国人物按出现次数排个序看看谁才是全书真正的C位。我最初做这个项目纯粹是想验证一个阅读时积累的直觉诸葛亮好像无处不在关羽、曹操的戏份也极重但到底谁排在前面这些印象能不能用数字说话于是就有了这篇文章从下载一份可用的threekingdoms.txt开始到设计人物名单、处理分词、统计出场次数再把结果做成排行榜和图表一套完整流程给你讲清楚。无论你是刚接触中文NLP的学生还是想给经典作品加一点数据视角的读者这套思路都可以直接参考。整个项目的门槛不高核心知识点却很密集文本清洗、分词、别名映射、统计口径设计、可视化。这些技能在之后做词频分析、舆情监控、文本相似度计算时都能复用。下面我从项目设计开始一步步带你把这件事做扎实。1. 项目概述与整体思路1.1 这个项目到底在做什么表面上看这个项目就两件事下载threekingdoms.txt统计人物出场次数。但实际操作起来中间藏着不少决策点。比如“出场”怎么定义是提到一次算一次还是在一个回目中出现就算一次同一人物有十几个称呼诸葛亮可以叫“孔明”“卧龙”关羽可以叫“云长”“关公”“美髯公”这些人名要不要合并如果“曹操”被分词器切进“曹操作战”里这个误匹配怎么避免这些问题决定了统计结果是否可信。所以我在做的时候把项目拆成了四个阶段语料准备、人物名单构建、统计逻辑实现、结果可视化。每个阶段之间是强依赖关系名单没建好统计就一定不准语料没清洗干净后面所有数字都是空中楼阁。从技术角度看这个项目适合选Python来做生态成熟jieba、matplotlib、pyahocorasick这些库正好覆盖分词、绘图和高效匹配的需求。不需要搭什么大框架脚本化处理就够用。《三国演义》全书大约90万字对Python来说属于中等规模文本普通笔记本跑起来也就几秒钟的事几乎不需要优化。真正需要动脑的是统计口径的设计和人物别名的整理。1.2 为什么选《三国演义》做文本统计《三国演义》是我认为最适合做中文人物出场统计的古典文本没有之一。首先是语料易得它是公版作品各种纯文本版本在公共版权库、开源代码仓库里都能找到不会涉及版权问题。其次是人物体系庞大且称呼复杂全书有名有姓的人物超过一千个光是一个关羽就有“云长”“关公”“美髯公”“汉寿亭侯”“关君侯”等十几种叫法这种称呼多样性恰好能检验文本处理方案是否健壮。更关键的是统计结果能产生“反直觉”的讨论空间。我做完第一批统计后最大的意外是刘备的出场次数居然明显低于曹操和关羽而诸葛亮以压倒性优势排在榜首。这和我们平时“刘关张是主角”的印象形成了一种有趣的张力也印证了“诸葛亮才是全书叙事核心”的说法。这种从数据中验证或推翻直觉的体验非常适合用来引导初学者建立对文本挖掘的兴趣做完之后你会有种“原来我读的每一页都藏着一份数据”的感觉。2. 数据准备先拿到一份能用的 threekingdoms.txt2.1 文本来源与版本差异很多人以为下载threekingdoms.txt很简单搜到就保存结果打开发现全是乱码或者文本里夹杂着出版说明、注释、回目评语导致统计结果失真。我踩过这个坑之后现在挑选语料会比较执着要求就三条纯正文、简体、无注释。来源方面一般优先选公共版权文本库和开源社区的语料合集但要留意整理者是否标注了底本。常见底本有两种毛宗岗本和嘉靖本毛本是清代以来流传最广的版本也是大部分读者的阅读记忆所在我建议优先用毛本这样统计结果和你对故事的印象更容易对上。有些下载站的历史版本还是繁体或者带BOM头的UTF-8处理起来会麻烦一些所以拿到文件后第一件事就是检查编码和前几百个字。我个人的检查方法是先用记事本打开看一眼开头再在Python里读取字符串长度正常《三国演义》正文去掉标点后应该在85万到100万字之间。如果文件只有十几万字那很可能是节选本如果超过150万字大概率混入了注释或校记后面出场的统计会被严重污染。这一步看着不起眼却能省去后面大量的返工时间。2.2 清洗与回目切分《三国演义》是章回体小说一百二十回的回目天然是叙事单元。做人物出场统计时我通常会先把文本按回目切分这样既能统计全书总出现次数也能统计“人物在多少回里出现过”。后面的叙事结构分析也需要回目粒度。清洗的第一步是去除所有非正文内容。我遇到过好几个版本在文首有“出版说明”、文末有“校勘记”回目之间偶尔还夹着后人加的评语。这些内容如果不过滤评语里出现的人名会全部算进正文数据里。常规的做法是把第一回正文开始之前的内容全部丢弃把最后一回结束之后的内容全部截断再删掉回目标题行或者至少和正文区分开。第二步是切分回目。回目标题的格式很规整用正则表达式就能处理import re def split_chapters(text): pattern re.compile(r第([一二三四五六七八九十百零])回) matches list(pattern.finditer(text)) chapters [] for i, m in enumerate(matches): start m.start() end matches[i 1].start() if i 1 len(matches) else len(text) chapters.append(text[start:end]) return chapters这段代码把每个回目标题作为起点切出对应的正文段落。切完之后最好验证一下数量正常情况下应该得到120个片段。我经常在切片阶段发现文件缺回或者有重复回目早发现比统计完再排查要高效得多。3. 人物出场统计的核心实现3.1 人物名单与别名映射做人物出场统计最核心、也最耗时的工作是整理人物名单和别名映射表。为什么不能直接拿“刘备”“曹操”两个词去文本里数因为中国古典小说里人物的称呼体系太复杂了有字、有号、有官职、有封号作者为了文采还会在同一段里换着叫。如果只统计姓名字段关羽的数据会少得可怜因为绝大多数关羽出场是以“云长”或“关公”出现的。我的做法是建立一个字典结构键是人物主名值是别名列表。比如characters { 刘备: [刘备, 玄德, 刘玄德, 刘豫州, 先主, 昭烈帝], 关羽: [关羽, 云长, 关云长, 关公, 美髯公, 汉寿亭侯, 关君侯], 张飞: [张飞, 翼德, 张翼德], 曹操: [曹操, 孟德, 曹孟德, 阿瞒, 曹公, 魏王, 魏武帝], 诸葛亮: [诸葛亮, 孔明, 诸葛孔明, 卧龙, 武乡侯, 相父], 孙权: [孙权, 仲谋, 孙仲谋, 吴侯, 碧眼儿], 司马懿: [司马懿, 仲达, 司马仲达], 吕布: [吕布, 奉先, 吕奉先, 飞将], }这里有一个必须注意的原则只收录指向性明确的称呼。像“丞相”“主公”这种词在书里既可以指诸葛亮也可能指曹操、孙权甚至泛指君主收录进来会把统计搞乱。我一开始图省事把“丞相”归给诸葛亮结果统计出的诸葛亮出场次数暴涨了好几成因为曹操挟天子以令诸侯的时期同样频繁出现“丞相”这个称谓。后来我改成保守策略宁可漏掉一些模糊计数也不引入大规模噪声。另外别名之间可能有包含关系。比如“关云长”包含“云长”“汉寿亭侯”里有个“亭侯”字样。用朴素字符串匹配时要留意如果先匹配“云长”又匹配“关云长”同一个位置可能被重复统计。解决办法很简单统一用主名聚合并使用自动机匹配后面我会讲具体实现。3.2 统计口径次数与回目在写统计代码之前必须先想清楚“出场次数”的准确含义。我在实践里定义了两种口径都有价值只是回答的问题不同。第一种叫“提及次数”指的是人物别名在全文里出现的总次数。这种口径最直接适合做词云和频率对比。比如“诸葛亮”的别名每出现一次就累加一次哪怕同一回里他被提了五十次也一样进入总数。第二种叫“覆盖回目数”指的是这个人物至少出现过一次的回目数量。这种口径用来度量“人物活跃范围”。比如董卓在第3回就死了但他的名字在后面多回还会被回顾覆盖回目数可能高于他的实际存活回数周瑜死得早但全书回目里提到他的却不少。覆盖回目数能排除同一回内反复刷屏造成的虚高。建议项目里同时输出两种口径。我的经验是最终排行榜用“提及次数”更有冲击力但要分析人物的叙事地位和存续期必须参考“覆盖回目数”。两种口径都做成本并不高只需要在按回目循环时做一次二元判断。3.3 代码实现从分词到计数统计环节有两种主流方案基于分词和基于匹配。基于分词是用jieba把全文切成词再统计词频基于匹配是直接用人物名单去原文里找。两种我都用过最终选择以匹配为核心分词作为辅助校验。匹配方案的代码非常直接from collections import Counter def count_mentions_by_regex(text, characters): stats Counter() for name, aliases in characters.items(): for alias in aliases: count len(re.findall(alias, text)) if count 0: stats[name] count return stats这个写法简单但有个潜在的性能问题如果人物名单扩充到几百人、别名上千个全文就需要被扫描上千遍90万字乘以1000次扫描时间会明显拉长。更专业的做法是引入Aho-Corasick自动机把所有人的名字拼成一个模式集合一次扫描同时匹配所有别名。import ahocorasick def build_automaton(characters): automaton ahocorasick.Automaton() for name, aliases in characters.items(): for alias in aliases: automaton.add_word(alias, (alias, name)) automaton.make_automaton() return automaton def count_mentions_ac(text, automaton): stats Counter() for _, (alias, name) in automaton.iter(text): stats[name] 1 return stats使用iter时要注意它会返回文本中每个匹配到的模式如果别名之间存在字母重叠同一段文字可能出现多个匹配结果这本就是我们要的效果。但反过来也要小心同一个别名在同一位置只计一次pyahocorasick的iter默认按模式为单位返回所有命中这个行为是符合需求的。如果想用jieba走一遍辅助检验可以先把所有别名加进自定义词典import jieba jieba.setLogLevel(60) for name, aliases in characters.items(): for alias in aliases: jieba.add_word(alias, freq1000000)然后分词并统计词频再按别名归属关系汇总到主名。你会发现加词典前后的结果差异很大。不加词典时“孔明灯”可能被切成“孔/明灯”“云长”可能被切成“云/长”统计结果直接崩坏。加词典后这些专名会作为整体被切出来准确率明显提升。4. 统计结果的解读与可视化4.1 Top20人物排行榜因为我用的是自己整理的别名表统计结果和版本选择强相关所以下面这张榜单更多是给你一个“预期形状”未必和你的数据完全一致但顺序大体可以参考。排名人物提及次数参考区间覆盖回目数1诸葛亮2000以上约90回2关羽1400-1700约80回3曹操1200-1500约90回4刘备1000-1300约100回5张飞700-900约80回6赵云500-700约70回7孙权500-700约90回8吕布300-500约30回9司马懿400-600约60回10周瑜300-500约35回这份榜单的有趣之处在于我们通常说“刘关张”或者“曹刘孙”是主角三角但数字告诉我们诸葛亮才是全书当之无愧的核心角色。诸葛亮从第27回出场一直活跃到第105回去世之后仍被反复追忆覆盖回目长、提及频率隐形加成极大。关羽虽然身死较早但“关羽”这个名字在后半部分同样频繁被回溯排名远高于他的实际戏份占比。4.2 从数据看《三国演义》的叙事重心我统计完第一批数据后又专门做了“出现次数-回目”的散点图发现可以很直观地把人物分成三类全程活跃型、阶段爆发型、高密度短命型。全程活跃型包括诸葛亮、曹操、刘备、孙权、司马懿他们的覆盖回目数都超过80回分布均匀说明叙事主线持续聚焦在他们身上。阶段爆发型比如吕布和周瑜吕布的覆盖回目只有30回左右但每回内的提及次数不低说明他在出场期间戏份密度极高属于“短而爆”的典型。高密度短命型最典型的代表是孙坚、董卓、袁绍这些人在前十几回戏份集中之后便沦为背景板。这种分析放在任何经典小说上都成立。你可以把覆盖回目数理解为人物的“生命周期”把提及次数理解为“曝光强度”两者结合就容易看出作者的叙事偏好。做这个项目时我本来只想做个词频小工具结果顺手就把《三国演义》的叙事结构给分析了一遍这也是文本挖掘有意思的地方。4.3 可视化实现统计完数据图表是必须的。我建议直接用matplotlib做一个横向条形图Top20人物一目了然。横向条形图比纵向更适合人名因为人名通常是汉字横向排列不拥挤。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False def plot_top20(stats, top_n20): top_items sorted(stats.items(), keylambda x: x[1], reverseTrue)[:top_n] names [item[0] for item in top_items][::-1] counts [item[1] for item in top_items][::-1] plt.figure(figsize(8, 10)) plt.barh(names, counts) plt.xlabel(出场次数) plt.title(《三国演义》人物出场次数 Top20) plt.tight_layout() plt.show()如果是在 Jupyter 里跑第一行字体设置尤为重要否则图表里的中文会全部变成方块。除了静态图也可以试试pyecharts生成交互式HTML鼠标悬停能看到具体次数分享给别人看时体验更好。可视化只是最后一公里核心环节永远是前面的名单和统计逻辑。5. 实操中的坑与排查技巧5.1 分词错误与同名干扰做中文文本处理最防不胜防的就是分词错误和同名干扰。我在统计时遇到过一个非常经典的Bugjieba把“曹操作战”切成了“曹操/作战/”具体切法不一定但“曹操”和“操作”黏连在一起时会识别混乱。解决办法就是上面说的把所有人物别名以高权重加入词典或者干脆用自动机匹配绕过分词环节。同名干扰更隐蔽。“于禁”是个武将名字但“禁止”这个词里也带着“禁”“陈宫”是个人名但“后宫”里也有“宫”。单纯按字符串匹配时这些非人物用法会被误计。我在实践中的处理方法是加“前后文约束”比如匹配“于禁”时手动剔除“禁止”所在的窗口匹配“陈宫”时剔除“后宫”的上下文。这种写死规则的做法有点笨但对固定文本非常有效。还容易踩的坑是繁体字和异体字。如果你下载的版本是繁体一定要先转成简体再统计否则“關羽”和“关羽”会被当成两个不同的词。转换工具我常用opencc-python准确率高对古典文本支持也好。5.2 编码与文本版本坑文本编码是最低级却最致命的坑。threekingdoms.txt在传播过程中UTF-8、UTF-8 with BOM、GBK、GB2312都有人用。读取时如果编码猜错轻则乱码重则直接UnicodeDecodeError。我的建议是优先用utf-8读取报错就依次回退到gb18030前者兼容繁体字集出错概率最低。更稳妥的办法是写一个小工具去探测编码比如用chardet库读取文件前几万字节做判断。另外如果文件开头有\ufeff这种BOM字符在open时指定encodingutf-8-sig可以自动去除这个小细节能省很多事。版本差异同样影响数据。同样是毛宗岗本不同整理者的排版习惯甚至个别用字都不一样直接导致统计结果有几万个字符的偏差。我的建议是锁定一个底本后不要中途更换所有分析结论都以这个版本为准。跨版本对比可以做但一定要在方法论说明里写清楚否则别人复现不出来就会觉得你的数字不可靠。5.3 性能优化思路虽然90万字的文本对现代计算机压力不大但如果你把统计对象从《三国演义》扩展到“全集小说处理”性能问题就绕不过去。我测试过最粗暴的方案也就是用Counter配合jieba.lcut对全文分词耗时大约在5到10秒主要时间花在分词器的初始化上。如果再把所有人物别名做成re.findall循环人物少时无所谓人物多时会明显变慢。优化手段主要有三个方向。第一把文本换成回目数组后只对涉及该人物的回目做精确匹配缩小扫描范围。第二使用 Aho-Corasick 自动机构建一次模式集合全程只扫描一遍正文这个方案在处理上千个别名时性能优势巨大。第三如果你的数据量能达到千万级可以考虑引入多进程按回目或章节分片并行统计最后汇总结果。不过对目前这个项目来说前两步优化已经完全够用。你还要注意内存问题。jieba.lcut会把全文切成一个庞大的列表90万字大约占用几十MB内存普通开发环境没有问题。但如果一次性读取多本小说全文内存会成倍增长这时推荐改用生成器或分片读取避免把整个文本都装进列表。5.4 常见问题速查表问题现象原因解决办法读取文件乱码编码猜错或文件带BOM用chardet探测或改用utf-8-sig/gb18030读取统计结果里诸葛亮次数高得离谱“丞相”等泛称被错误归入别名重新审查别名表删除指向不明确的称谓关羽次数莫名偏高“关公”与“关”字重叠匹配使用自动机匹配按最长词优先处理所有人物次数都偏少文本是繁体版繁简汉字未统一先opencc转简体再执行统计可视化图表中文显示为方块系统缺少中文字体配置设置plt.rcParams[font.sans-serif] [SimHei]120回切分后只有119段文件末尾缺最后一回或回目格式不统一检查文本末尾必要时手动补齐回目标题我还想特别提醒一个细节如果你计划把统计脚本分享给别人一定要把人物别名表单独抽成一个配置文件或数据文件。别名表是整个项目里最主观、最需要迭代维护的部分和统计代码耦合在一起将来调整起来非常痛苦。我一开始把名单写死在主脚本里后来为了增加一个“诸葛亮早期称呼”的统计差点把整个代码结构拆了重来。把这个教训分享出来希望你能提前规避。这个项目做完之后我已经习惯性地把同一套流程迁移到《水浒传》《红楼梦》上。只需替换文本语料、重建人物别名表、调整少量规则就能横向对比不同古典小说的人物聚焦程度。有一次我把《西游记》做个简易版统计发现孙悟空和唐僧的提及次数相差并不悬殊这和《三国演义》里诸葛亮独一档的分布形成了很有意思的对照。文本挖掘就是这样每换一本经典作品都可能得到一种全新的叙事观察视角。个人经验告诉我这类项目的价值并不在于数字本身而在于你会因此把一个熟悉的话题拆成“可计算的问题”然后被迫去理解人物称呼规律、叙事节奏甚至版本源流。等你做完一遍再回头看小说看数据的方式都会不一样。如果你正准备拿threekingdoms.txt练手不用纠结一开始就把名单做得多完美先跑通流程再根据漏统计的人物慢慢补充一步一步迭代这个逐步逼近的过程本身就是最有价值的部分。
阅读完成 · 觉得有帮助?