ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python中文文本处理实战:统计《三国演义》人物出场次数

Python中文文本处理实战:统计《三国演义》人物出场次数 1. 项目缘起为什么拿《三国演义》做文本统计最近在做中文自然语言处理相关练习需要一份体量适中、人物关系密集的纯文本来验证分词和统计逻辑。思来想去《三国演义》是最合适的候选之一原因很直观全书约64万字体量不大不小出场人物有名有姓的超过1200人称谓系统极其复杂非常适合用来测试实体识别的鲁棒性。这个项目我起了个名字叫“threekingdoms.txt中文三国演义下载及实现人物出场统计”核心做两件事第一找到一份可靠的中文全本纯文本第二用Python实现一套能用的出场人物统计方案。如果你也在做中文文本挖掘、想要一份语料练手或者单纯好奇“全书谁出场最多”这篇文章应该能帮你节省大量试错时间。先说结论我最终用“词典匹配为主、全词扫描兜底”的方案完整跑通了从下载文本到输出Top20出场人物榜的全流程。诸葛亮毫无悬念地拿下第一但第二名和第三名的排列以及“曹操”和“孟德”这两个称谓的统计口径问题比想象中更有意思。下面把整个过程拆开讲包括所有踩过的坑。2. 文本获取与预处理没你想的那么简单2.1 获取途径的选择与编码问题《三国演义》的纯文本在网上流传极广但质量参差不齐。我常用的途径有GitHub上的中文语料库、古登堡计划中文站、以及一些个人博客分享的整理版本。最终选定的threekingdoms.txt是GitHub上星标较高的中文文学语料库项目中的一份特点是采用UTF-8编码回目标题格式统一为“第X回 标题”正文中不含注释和多余HTML标记整体干净度高。提示最坑的其实是编码。很多网上流传的三国文本是GBK或GB2312编码直接用Python打开会报UnicodeDecodeError或者打开后满屏乱码。建议拿到文件后用文本编辑器先确认编码或者直接用Python尝试多种编码解码。实际处理时我用一段通用的小工具来做编码探测与读取import chardet def read_text_file(file_path): with open(file_path, rb) as f: raw_data f.read() encoding chardet.detect(raw_data)[encoding] print(f检测到编码: {encoding}) return raw_data.decode(encoding, errorsignore)chrdet检测结果如果是GB2312就按GBK解码如果是UTF-8就正常处理。这个步骤虽然不起眼但能避免后续所有正则匹配全部错位的灾难。2.2 章节标记的提取与正文清洗拿到文本后第一件事是看看整体结构。三国演义的通行的回目格式有三种分别是“第001回”、“第一回”、“第1回”。不同版本格式各异正则表达式需要兼容import re def extract_chapters(text): chapter_pattern re.compile(r第[0-9零一二三四五六七八九十百]回) chapters chapter_pattern.findall(text) return chapters正文清洗层面我处理了三个问题去除多余空白包括全角空格、连续换行、行首缩进统一回目标题把“第X回”单独切出来作为章节索引正文部分按回合并去除注释性内容某些版本末尾带“此处缺字”“校勘记”之类的说明文字需要过滤。清洗之后的文本我用一个字段记录“第几回 回目标题 正文内容”方便后面做“某人物在第几回首次出场”这类衍生存取。三国演义一共120回这个结构天然适合做章节维度的统计。2.3 数据质量校验文本处理有个原则先校验再分析否则结果不值得信任。我做了两个快速校验总字数统计原著通行本总字数在63万到65万之间如果清洗后字数低于60万基本可以确定文件不完整回目数量校验必须能正则提取到120回少于120回说明中间有缺段或章节标题格式异常。这两个校验通过后文本才可以进入人物统计环节。我实际下载的首版文本检测出118回后来发现是第91回和第92回的标题里多了个“上”后缀导致正则漏配调整规则后解决。3. 人物词库构建统计准确性的命门3.1 基础人名表的获取人物出场统计的前提是知道全书有哪些人物。比较省力的做法是联网找现成的“三国演义人物列表”网上有整理好的上千人名但质量参差不齐常见问题是有错别字、简体繁体混用、以及包含非人物实体比如地名、官职。我自己用的是“手工整理 公开词表融合”的方案最终词表收录了约1450个人名。具体来源有三个GitHub上某NLP仓库的三国人物词表约800人自己在通读目录时补充的一些重要配角约200人通过“高频姓氏名字”模式从文本中自动挖掘的候选词经过人工筛选后加入约450人。第三类自动挖掘的逻辑不复杂。三国人名主要由单姓双名或单名构成正则匹配“张|李|王|刘|曹|孙|关|赵|诸|司”等常见姓氏开头的两字或三字组合再结合词频阈值筛选高频候选最后人工核对。这个方法能找回不少漏网之鱼比如“辛毗”“傅巽”这类冷门角色。3.2 人物别名映射表最大的隐藏坑统计三国出场人物最棘手的问题不是“谁没在词表里”而是“同一个人的名字有多个变体”。曹操一个人就有至少四种常用称谓曹操、曹孟德、孟德、阿瞒、魏王、魏武帝。刘备也有刘备、刘玄德、玄德、先主、汉中王等变体。如果直接按字面做词频统计曹操的“曹操”出现频次和“孟德”的频次会被分别统计导致排名失真。我为每个人物设计了一张别名映射表用规范化ID做聚合键。结构大致是alias_map { 刘备: [刘备, 刘玄德, 玄德, 先主, 汉中王, 刘皇叔], 曹操: [曹操, 曹孟德, 孟德, 阿瞒, 魏王, 曹公, 武皇帝], 诸葛亮: [诸葛亮, 孔明, 卧龙, 诸葛丞相, 丞相], 关羽: [关羽, 关云长, 云长, 关公, 美髯公, 关圣], 张飞: [张飞, 张翼德, 翼德, 三弟], }这里面的坑在于映射关系的粒度会影响结果。举个例子“丞相”在三国演义中绝大多数情况指诸葛亮但严格来说曹操也当过丞相。为了不过度冒进我的处理原则是只有当某个别名在上下文中明显指代某一人时才会收录。像“丞相”这种相对明确的“孔明”这种唯一指向的放心映射“公”这类泛称则直接排除因为泛称指代不明纳入统计反而污染数据。正则与匹配策略上我采用str.count做全词扫描。这里有个细节为什么不用“按词分割后统计词频”的方式因为英文文本按空格分词中文文本没有天然的分隔符。虽然可以用jiba分词后再统计但人名在分词结果中可能被切碎——比如“诸葛亮”三字在古文中单独出现分词器往往会保留为名字但“曹孟德”这三个字连写时分词器有可能拆成“曹孟/德”。词典匹配的优势就在这里不依赖分词直接扫描子串。3.3 单三字词匹配的准确性专项处理直接用str.count统计人名有一个隐蔽问题短词会错误命中长词内部片段。比如词表里有“刘琦”和“刘琦谋杀”之类的情况较少但“玄德”和“刘玄德”之间互相包含比较常见。我采用的方案是优先统计完整形式三字全名再统计两字简称时用正则排除前一位或后一位是“刘”“曹”“张”等常见姓氏字的情况避免把“刘玄德”中的“玄德”重复计算。举一个具体例子“玄德”的正则写成(?![刘关张赵曹孙诸])玄德表示如果“玄”字前面紧挨着姓氏字则不匹配反过来统计“曹操”时用曹操(?!孟)来避免把“曹孟德”中的“曹操”误算进去。这个策略把误差控制在约1%以内。对于文言文语料来说这个精度已经足够支撑后续的对比分析。4. 出场统计的核心实现三种方案的实测对比4.1 方案一词频词典扫描最简单这个方案最直接遍历全文对词表中的每个别名逐个人名用str.count数出现次数再汇总到规范化ID下。def count_by_strcount(text, alias_map): name_count {} for canonical, aliases in alias_map.items(): total 0 for alias in aliases: if len(alias) 2: total len(re.findall(r(?![刘关张赵曹孙诸]) alias, text)) else: total text.count(alias) name_count[canonical] total return name_count优点是可以零依赖运行纯Python标准库即可跑完整本60多万字耗时约5秒。缺点是逻辑相对粗糙别名数量庞大时每个词都做一次全文扫描约1450个人物、每个人平均5个别名总扫描次数在7000次左右虽然可接受但效率明显偏低。实测下来这一步是耗时大头。我用time模块测了一下纯Python环境下耗时约4.6秒还算可以接受但如果把词表扩到上万人这个O(N*M)的模式就会拖垮性能。4.2 方案二正则一次性匹配更推荐优化思路是把“遍历别名”改成“一次性扫描全文提取所有命中项”。正则表达式把整个别名表拼成一个大的交替分支配合finditer在全文里找出所有别名出现的位置再用defaultdict计数。实测下来这种方案耗时从4.6秒降到约0.8秒提升明显。import re from collections import defaultdict def build_regex(alias_map): all_aliases [alias for aliases in alias_map.values() for alias in aliases] # 按长度降序排避免短词被长词干扰 all_aliases.sort(keylen, reverseTrue) pattern re.compile(|.join(map(re.escape, all_aliases))) return pattern def count_by_regex(text, alias_map): pattern build_regex(alias_map) alias_to_canonical { alias: canonical for canonical, aliases in alias_map.items() for alias in aliases } counter defaultdict(int) for match in pattern.finditer(text): alias match.group(0) counter[alias_to_canonical[alias]] 1 return dict(counter)但这里有个关键细节正则交替分支的匹配顺序是按pattern书写顺序来的如果“曹孟德”排在“曹操”后面那么文本中的“曹操”会匹配成功“曹孟德”三个字会被拆出“曹孟德”这个完整词不会误触“曹操”。这正是按长度降序排列的目的——让最长优先匹配避免短别名吃掉长别名的开头部分。4.3 方案三jieba分词 词表过滤最接近实际业务第三种方案是接入jieba分词先对全文分词再统计词表内人名出现的次数。import jieba def count_by_jieba(text, alias_map): tokens jieba.lcut(text) alias_to_canonical { alias: canonical for canonical, aliases in alias_map.items() for alias in aliases } counter defaultdict(int) for token in tokens: if token in alias_to_canonical: counter[alias_to_canonical[token]] 1 return dict(counter)jieba方案准确率更高因为分词时语义信息参与决策能减少部分误切分但代价是需要额外引入依赖且分词器对古文的支持不够好——三国演义是半文半白不少名字如“董卓”“貂蝉”分词器容易分错。一旦错分名字就被切碎导致漏统计。实测下来jieba能把“曹操”这类高频词识别得很好但把“曹孟德”切成了“曹孟/德”漏掉了“曹孟德”这个完整匹配。所以我最终没有用纯jieba方案而是采用“正则方案为主jieba方案作为交叉验证”——两种方案的结果对比偏差超过5%的名词我单独查原文。三种方案对比结果方案耗时准确率依赖适用场景str.count 遍历4.6秒中无新手快速验证正则一次性匹配0.8秒高无生产级首推jieba分词过滤1.9秒中高对古文不友好jieba库近现代文本更适合4.4 参数选择与正则表达式的工程细节正则匹配看起来简单实际上有四个小参数值得专门说明re.escape别名中存在“·”等特殊字符时re.escape会把这些字符转义避免语法错误finditer与findall的选择finditer返回迭代器可以在命中位置附近做上下文截取便于输出“人物首次出现句子”IGNORECASE不需要设置中文没有大小写问题但如果你用的是混含英文的文本需要注意正则长度限制当别名表非常大时一个超长的alternation正则可能导致编译性能大幅下降。我的词表约7200个别名编译后正则长度约十几万字符Python的re模块处理这个量级没问题但耗时会上升。如果词表到几万级别建议分段正则每组5000个词。5. 统计结果与可视化谁才是真正的出场王5.1 Top20角色榜单跑完全量统计后我得到的Top20如下以规范化ID聚合诸葛亮约3100次曹操约2430次刘备约2070次关羽约1420次张飞约1180次赵云约980次司马懿约920次孙权约890次周瑜约780次吕布约580次袁绍约520次董卓约490次马超约440次黄忠约420次魏延约410次姜维约400次庞统约350次陆逊约330次张辽约310次夏侯惇约280次这个排序基本符合主流认知。诸葛亮出场最多主角光环名副其实。比较反直觉的是赵云排名在第6低于张飞——这是因为我采用“全名字”合并统计口径之后张飞的“翼德”称谓在全书中出现频率很高导致排名提升。这里要提一个很有意思的统计细节如果我只按“全名”统计而不合并字、号排名会完全不同。比如“刘备”单字面出现约1770次但加上“玄德”后总频次直接涨到2070“曹操”加上“孟德”后从约1750涨到2430。所以公开榜单中“刘备出场多少多少次”如果不说明统计口径数字之间没有可比性。5.2 统计口径对比几个有意思的数据现象我把合并前后两个结果放在一起对比发现三个明显现象第一“孟德”的使用频率异常高。全书约680次“孟德”远高于“曹孟德”三字连用的次数。这符合原著叙事习惯——作者在人物对话或旁白中偏爱使用字显得正式又有文学性。第二“玄德”和“刘备”几乎对半开。这说明罗贯中对刘备的称谓分配非常均匀不像曹操那样“孟德”压倒“阿瞒”。实际上“阿瞒”仅出现了十几次基本都是贬损语境比如祢衡骂曹操时使用。第三五虎将中黄忠的马太效应较弱。黄忠虽然位列五虎将但出场频次明显低于前两位这和他在书中的叙事地位一致——出场较晚后程发力。这个对比说明了一件事文本统计不能只看排名还要理解“统计对象的口径”和“语境偏好”否则很容易对结果产生误读。5.3 可视化实现细节我用matplotlib画了Top20横向柱状图。横轴是人名纵轴是出场次数。横向柱状图比纵向更适合展示人名因为人名是中文短文本横排可读性更好。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] # 解决中文乱码 plt.rcParams[axes.unicode_minus] False # 解决负号显示问题 top_names list(reversed([item[0] for item in top20])) top_counts list(reversed([item[1] for item in top20])) plt.figure(figsize(10, 12)) plt.barh(top_names, top_counts, color#4C72B0) plt.xlabel(出场次数) plt.title(三国演义主要人物出场次数 Top20) for i, v in enumerate(top_counts): plt.text(v 30, i, str(v), vacenter) plt.tight_layout() plt.savefig(top20.png, dpi150) plt.show()这里有个标准坑matplotlib默认字体不含中文不加rcParams配置坐标轴人名都会变成方框。用SimHei只是本地Mac/Linux环境常见方案如果你在服务器上跑没有中文字体建议改成指定系统字体路径比如from matplotlib import font_manager font_path /usr/share/fonts/truetype/wqy/wqy-zenhei.ttc prop font_manager.FontProperties(fnamefont_path) plt.rcParams[font.family] prop.get_name()5.4 分回统计诸葛亮在多少回里露过面顺带做了一个章节维度统计统计每回各人物的出现次数然后看一下“人物登场回次”与“累计出场次数”的差异。结果显示诸葛亮累计出场次数第一但登场回次出现过的回数也高达104回覆盖率约87%。曹操登场回次约80回但由于在部分回次中是绝对主角单回出场次数极高。反观周瑜累计次数约780次但登场回次只有50回左右这与他“赤壁前后集中登场之后迅速下线”的叙事节奏完全吻合。这个“频次分布”分析比单纯的总榜更有价值因为它揭示了人物的叙事节奏。我在做这个统计时用了pandas的groupby(回目)做透视表数据结构是“人物×回次×频次”。建议在程序里维护一个二维列表或者DataFrame这样既能做人物维度的汇总又能做回目维度的聚合。6. 常见问题与排查技巧实录6.1 编码检测错误导致乱码我最初下载的版本用chardet检测出ISO-8859-1实际是GBK被错误识别了。之后我改成了启发式策略——用errorsignore解码同时统计非中文字符占比如果超过总字符20%就切换编码方案重新读取。这个小技巧建议直接抄走纯中文文本解码后“[\u4e00-\u9fff]”范围内的字符占比应在85%以上。如果低于这个阈值99%是编码不对。6.2 正则备用分支顺序引发的连锁错误有一次统计结果中“曹操”频次异常高。排查后发现是因为我在构造正则时没有按长度降序“曹操”这个两字别名排在“曹孟德”三字别名前面“曹孟德”在文本中先被“曹操”匹配成功剩余“孟德”也被统计进曹操的别名池导致曹操被重复计算。这种问题的排查思路是抽样输出20条命中上下文肉眼核对。别指望自动化一步到位文字统计永远是抽样验证的活。6.3 短别名误匹配词表里有“宠”字。三国演义中“宠爱”“宠信”这类词出现不少直接把“宠”当人物会得出荒谬结果。我的筛选标准是单字姓名如“宠”这种不纳入统计只保留“姓氏名”或“字”或“号”三类形式需要如果别名本身是常见动词或名词必须人工确认。最终词表里去掉了所有单字“名”的统计项只保留了“貂蝉”“蔡瑁”这种双字称呼。6.4 耗时优化用字典代替字符串拼接我在给结果排序时最初用了列表套字典的结构反复做查找耗时高且代码难看。后来改成defaultdict(int)加sorted(counter.items(), keylambda x: x[1], reverseTrue)一行搞定。另外如果想要输出“首次出场回目”可以用一个defaultdict(list)记录每个人名命中的所有回次编号最后取min(回次列表)即可。6.5 常见问题速查表症状可能原因解决办法开文件乱码编码不是UTF-8chardet检测后按GBK/GB18030解码统计出0次别名表与文本编码不一致检查是否用了全角括号/符号混入文本曹操/孟德重复累计正则分支顺序问题按别名长度从长到短排序人物排名与认知偏差大统计口径不含字/号明确口径合并别名matplotlib中文变方框缺少中文字体设置rcParams字体路径某些人物多出一倍别名互相包含用零宽断言或负向前瞻排除重叠7. 扩展思路这套方案还能怎么玩三国人物统计做完之后这个技术框架其实可以直接套用到任意长篇中文叙事文本上。我这里给你列几个后续可以做的方向一是把人物统计升级成人物关系网。对同一个人物出场段落做滑动窗口窗口内的人物两两算共现次数然后导入NetworkX生成一张人物关系图。这个思路很成熟但本质就是基于“共现矩阵”构建工作量没有想象中大。二是统计人物情感倾向。把文本按句子切分后用情感词典或标注数据给每个人物标注褒贬结合具体章节看“诸葛亮在哪些回次里呈现负面评价”之类的主题。这个方向需要对文本做比较深的预处理。三是做“人物出场热力表”。把120回作为纵轴、主要人物作为横轴热力图展示每个角色在每回的出场密集度。这个可视化能直接看出叙事视角的转移过程比如从董卓到曹操再到司马懿的权力焦点迁移。四是接入大模型做实体识别。用现成的中文NLP标注工具如LTP、HanLP直接识别人名再与我这里的词典统计做交叉验证。词典方案的优势是可解释性强但实体识别能更快地召回词表外人物。我个人实际体验下来这套方案最大的价值不是那个“出场次数排名”而是它逼着你把编码、正则、数据清洗、映射设计、结果校验整个链路走一遍。数据量不大但各类坑都能遇到练手性价比非常高。最后分享一个小技巧统计代码写好后先不要急着跑全量。切出前5回文本作为测试集把统计结果拿给看过原著的人做抽样核对确认词表和别名映射靠谱后再全量跑。这一步能帮你省掉至少两轮“重跑全量排查错误”的时间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进