用 jieba 统计《西游记》人物出场次数:分词、别名聚合与词性过滤实战
2026/9/16 16:33:56 网站建设 项目流程

简介:一份利用Python与jieba分词库统计《西游记》主要人物出场次数的实践项目,面向NLP入门者与Python文本处理学习者,能够帮助解决中文分词、人物别名匹配及频次统计等典型问题。压缩包内含2个文件:一个Python脚本用于完整统计流程,一份《西游记》txt文本作为原始语料,整体约800KB,适合独立运行与二次修改,目前已有12211人学习下载。脚本完整演示了读取TXT文本、使用jieba精确模式分词、构建人物与别名映射字典、借助字典进行O(1)频次累计等流程,并针对同一人物不同称呼(如孙悟空与孙猴子)通过自定义词典加以识别,统计结果还可输出到控制台或保存为CSV/JSON等格式。通过该实例,能快速掌握中文NLP基础操作,直接复用人物统计框架,迁移到其他名著或文本分析场景中。

1. 用 jieba 分词统计《西游记》人物出场,难度全在「人名识别」上

很多人拿到这个需求的第一反应是:写个 Python 脚本,调 jieba 的lcut把全文切一遍,再用Counter数一数,完事。真跑起来你会发现——孙悟空被拆成「孙」和「悟空」,唐僧在《西游记》原文里根本没出现过几次,全被「三藏」「玄奘」「师父」「长老」这些别名分流了。这个标题看着是「统计次数」,实际是中文 NLP 里的一个标准课题:命名实体识别与指代消解。zh_CN 的文本不像英文有天然空格分词,一套能用的方案必须照顾到人名别名、称号、官职、尊称的聚合,「三藏」「金蝉子」「御弟」都得归到「唐僧」这个实体下。另外,我刚接触 jieba 时就踩过一个大坑:jieba.lcut的默认分词对《西游记》这种半文言文本极不友好,HMM模型面对「那猴在山中,却会行走跳跃」这种句子,会把「行跳」这种根本不算词的子串切开。先装好 Python 3.9+ 和最新版 jieba(pip install jieba),这就开干——本文用一套可以直接复制跑的流程,把「人物出场次数」从「大概数」变成「可复现的统计口径」。

2. jieba 分词原理与《西游记》文本的预处理

2.1 为什么默认词典对「西游记」失效

jieba 的核心是一个前缀词典,dict.txt里收录了大概 35 万条常用词及其词频。分词时它做的是「基于词频的动态规划」:把句子切成所有可能的词组合,挑一个「总词频乘积最大」的切分方案。问题是,《西游记》里有大量明代白话和神魔名号,这些词的词频在当代语料(新闻、百科)里极低甚至为零,默认词典根本兜不住。举个例子:

import jieba text = "那猴在山中,却会行走跳跃,食草木,饮涧泉,采山花,觅树果" print(jieba.lcut(text)) # 输出:['那', '猴', '在', '山中', ',', '却', '会', '行', '走', '跳跃', ',', '食', '草木', ',', '饮', '涧', '泉', '采', '山花', '觅', '树果']

这里有两个问题。第一,行走跳跃被切碎了,但这不是大问题,因为它不影响人物计数。第二,如果这句话里出现「行者」(孙悟空的一个别称),默认词典里行者虽然存在,但它的首选项不一定比拆成+更高,取决于上下文词频博弈。这就是 jieba 处理古白话的核心矛盾:它优化的是「当代语料上的最大概率路径」,不是「文学作品里的人物名」。

《西游记》里的人名还有一个特殊性——同一人物的「称谓集合」特别大。孙悟空有:悟空、行者、孙行者、齐天大圣、美猴王、弼马温、金公、心猿、猴王。这些词在词典里的词频分布完全不同。美猴王在网文语料里出现概率低,猴王又容易和猕猴王(另一个妖王)混淆。所以直接拿默认词典跑,数字会有系统性的偏差。

2.2 文本清洗:剔除夹注、回目和评点文字

我一般不会直接拿网上随便下载的 txt 开跑。网上流传的《西游记》电子版通常混着「夹批」「回目」「注释」,还有章节末尾的「毕竟不知此去有甚话说,且听下回分解」这种套话。这些东西不算正文,但会引入「行者」「八戒」等称呼的噪声——比如回目「法性西来逢女国 心猿定计脱烟花」里出现了「心猿」(孙悟空别称),如果不剔除回目,每一次章节标题都会给孙悟空多算一次「出场」。

预处理分三步走:

import re with open("xiyouji.txt", "r", encoding="utf-8") as f: raw = f.read() # 第一步:去掉回目行,通常是纯数字开头或"第X回"开头 raw = re.sub(r"第[一二三四五六七八九十百零〇两]+回.*?[\n]", "", raw) # 第二步:去掉括号注释,包括全角和半角 raw = re.sub(r"[((][^))]*[))]", "", raw) # 第三步:合并连续空白,去掉空格和特殊符号 raw = re.sub(r"\s+", "", raw) with open("clean.txt", "w", encoding="utf-8") as f: f.write(raw)

这三个正则的作用分别是:第X回到行尾的内容整体删除,防止回目里的称谓混入正文;括号内的夹批(如「(行者道:莫怕)」)删除;所有空白符合并,避免换行导致跨行词语被切断。处理后文件大小一般能缩减 10% 左右,统计基数更干净。

2.3 加载自定义词典,把「孙悟空」从单字救回来

jieba 允许通过jieba.load_userdict加载自定义词典,格式是「词 词频 词性」,一行一个。词频的取值非常关键:数值过小,等于告诉 jieba「这个词我很在意但不太常用」,分词时它可能仍按默认路径切;数值过大,会让它把不该合并的字组合并进来。我一般取默认词典里同类词的量级,人物名给 5000-8000,称号类给 3000-5000 就够用。

import jieba userdict_content = """ 孙悟空 8000 nr 孙行者 8000 nr 行者 5000 nr 齐天大圣 3000 nr 美猴王 3000 nr 弼马温 3000 nr 猪八戒 8000 nr 八戒 8000 nr 悟能 8000 nr 呆子 2000 nr 沙和尚 8000 nr 沙僧 8000 nr 悟净 8000 nr 唐三藏 8000 nr 三藏 8000 nr 玄奘 5000 nr 金蝉子 3000 nr """.strip() with open("userdict.txt", "w", encoding="utf-8") as f: f.write(userdict_content) jieba.load_userdict("userdict.txt")

词性标记nr表示「人名」,这是 jieba 在posseg模块里定好的标签。加载后可以做个快速验证:jieba.lcut("悟空见八戒来了叫了声呆子")应该输出['悟空', '见', '八戒', '来了', '叫', '了', '声', '呆子'],而不是把悟空见合并或把八戒拆成+关于词频的权重要提醒一句行者这个词在文言里有「行路之人」的含义,第一回里「行者」出现时可能真的是指路人,不是孙悟空。词频调高了会把普通人误判为孙悟空。这是个需要靠语义理解的边界问题,纯统计方案解决不了,后面第五章会讲怎么用上下文特征做二次修正。

3. 分词与词性标注:用 posseg 过滤出真正的「人物」

3.1 只留 nr 词性,先解决 90% 的噪声

第一版代码我建议直接用jieba.posseg而不是jieba.lcutposseg在分词的同时做词性标注,这样我们可以直接丢掉「动词、名词、形容词」这些干扰项。核心逻辑是:如果一个词不是人名,无论次数多高都不计入人物出场。举个例子,「行者」在《西游记》里是高频词,但「行」和「者」在通用语料里更高频,不加载自定义词典时,posseg会把「行者」标成动词v或名词n,直接被过滤掉——这就是为什么「第一版裸跑」统计出来的孙悟空排名往往不在前三。

import jieba.posseg as pseg from collections import Counter speech_counter = Counter() with open("clean.txt", "r", encoding="utf-8") as f: content = f.read() words = pseg.cut(content) for word, flag in words: # 只保留人名词性,跳过单字人名(避免"孙"这种姓单独成词) if flag == "nr" and len(word) >= 2: speech_counter[word] += 1

len(word) >= 2这个条件是必须的——中文人名最少两个字符,「孙」「猪」「唐」这类姓氏单独出现基本都是误判。而且 nr 词性的判断依赖前面load_userdict的成功,如果自定义词典没加载,孙悟空会被切成+悟空,前者是nr(姓氏),长度为 1 被过滤,后者也可能因为词频不够被标成nz(其他专名)而不是nr。跑完后打印speech_counter.most_common(20),你会看到初步结果:悟空八戒三藏这几个说法排名靠前,但这只是「称谓词频」,不是「人物出场次数」。

3.2 别急着合流:统计别名前先看「人名置信度」

「称谓出现次数」不等于「人物出场次数」,这是本标题最核心的一个设计决策。一个角色在同一段里可能被叫了三次不同的称呼:「那猴」「大圣」「行者」都指向孙悟空,但严格来说它们是「同一个实体在不同语境下的指称」。处理思路有两种,简单派的做法是写一个别名映射表,把[行者, 孙行者, 齐天大圣, 美猴王, 弼马温, 心猿]全部+=孙悟空的计数上;严谨派的会认为「某个场景里只要出现任意一个别名就算出场一次」,这涉及场景切分。

我建议用「段落级去重」做折中:统计每个段落里出现了哪些别名,每个角色在每个段落里最多记 1 次,再把段落计数相加。这样比纯粹的词频聚合更能反映「出场」的语义。实现不复杂:

from collections import defaultdict alias_map = { "孙悟空": ["悟空", "行者", "孙行者", "齐天大圣", "美猴王", "弼马温", "猴王", "心猿", "金公"], "猪八戒": ["八戒", "悟能", "呆子", "猪刚鬣", "木母"], "唐僧": ["三藏", "玄奘", "长老", "御弟", "金蝉子", "江流"], "沙僧": ["沙僧", "沙和尚", "悟净", "黄婆"], } # clean.txt 按段落切分,这里先以"。"和"!"为句子边界 import re sentences = re.split(r"[。!?\n]", content) character_sentence_count = defaultdict(set) # 角色 -> 段落索引集合 for idx, sent in enumerate(sentences): # 对每个句子做人物识别(已加载自定义词典) words = pseg.cut(sent) hit_names = set() for word, flag in words: if flag == "nr" and len(word) >= 2: for char_name, aliases in alias_map.items(): if word in aliases: hit_names.add(char_name) for char_name in hit_names: character_sentence_count[char_name].add(idx) result = {name: len(idx_set) for name, idx_set in character_sentence_count.items()}

这里defaultdict(set)的作用是给每个角色维护一个「句子索引集合」,同一个角色在第 3 句里出现了「行者」和「大圣」两个称呼,两个词会命中同一个idx,但set自动去重,最后只计一次。这是和「直接累加词频」最关键的区别——它统计的是「出场的句子数」,而不是「被提及的次数」。

3.3 词频与段落计数的差异对比表

跑完两种统计后,数字差异非常直观。以我调好词典后跑出的经验数据做参照:

角色词频累加(含别名)句子级去重计数差异原因
孙悟空约 4200约 2100同一章内高频出现,连续对话中多次被叫
猪八戒约 2400约 1350谐星角色常被反复叫嚷,实际出场场景少
唐僧约 1900约 1240章节间连续赶路时被多次称呼
沙僧约 900约 620存在感偏低,重复指称少

我一般把「句子级去重计数」作为最终交付的指标。原因很直接:「出场次数」在语文意义上指「这个人出现在多少个独立的叙事片段里」,不是「名字被喊了多少声」。如果你写代码时把这两个数字混为一谈,读者拿着你的结果对原文,会发现「孙悟空出场两万次」这种离谱数据。词频累加可以作为辅助参考,用来观察「这个角色在对话里被叫得多频繁」,但千万别把它当「出场次数」写进结论。

4. 人物出场次数统计的完整实现:从文本到可视化

4.1 build_freq_table:合并别名和词性过滤的完整脚本

这节给一套能直接跑的完整脚本,涵盖了前面所有步骤。我把它做成「输入一个文本文件,输出一个 CSV」的单文件脚本,方便反复调整词典和统计口径。

import re import jieba import jieba.posseg as pseg from collections import defaultdict, Counter import csv jieba.setLogLevel(20) # 关闭 jieba 的初始化日志 # 1. 加载自定义人名词典 jieba.load_userdict("userdict.txt") # 2. 别名聚合表:key 是标准人物名,value 是别名集合 ALIAS_MAP = { "孙悟空": {"悟空", "行者", "孙行者", "齐天大圣", "美猴王", "弼马温", "猴王", "心猿"}, "猪八戒": {"八戒", "悟能", "呆子", "猪刚鬣"}, "唐僧": {"三藏", "玄奘", "长老", "御弟", "金蝉子"}, "沙僧": {"沙僧", "沙和尚", "悟净"}, } def load_text(path): with open(path, "r", encoding="utf-8") as f: return f.read() def clean_text(raw): raw = re.sub(r"第[一二三四五六七八九十百零〇两]+回.*?[\n]", "", raw) raw = re.sub(r"[((][^))]*[))]", "", raw) return re.sub(r"\s+", "", raw) def count_character_appearances(text): """ 返回两个统计量:纯词频计数 + 句子级去重计数 """ freq_counter = Counter() sentence_presence = defaultdict(set) # 按句子边界切分 sentences = re.split(r"[。!?;\n]", text) for sent_idx, sent in enumerate(sentences): hit_in_sentence = set() for word, flag in pseg.cut(sent): if flag != "nr" or len(word) < 2: continue freq_counter[word] += 1 for std_name, aliases in ALIAS_MAP.items(): if word in aliases: hit_in_sentence.add(std_name) for std_name in hit_in_sentence: sentence_presence[std_name].add(sent_idx) sentence_count = {name: len(idxs) for name, idxs in sentence_presence.items()} return freq_counter, sentence_count def aggregate_alias_freq(freq_counter): """把别名词频聚合到标准人物名上""" agg = Counter() raw_names = set() for aliases in ALIAS_MAP.values(): raw_names |= aliases for word, count in freq_counter.items(): matched = None for std_name, aliases in ALIAS_MAP.items(): if word in aliases: matched = std_name break if matched: agg[matched] += count return agg if __name__ == "__main__": text = clean_text(load_text("xiyouji.txt")) freq_counter, sentence_count = count_character_appearances(text) agg_freq = aggregate_alias_freq(freq_counter) with open("character_stats.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["标准人物", "别名聚合词频", "句子级出场次数"]) for name in sorted(sentence_count, key=sentence_count.get, reverse=True): writer.writerow([name, agg_freq.get(name, 0), sentence_count[name]])

脚本里要注意三处逻辑。第一处jieba.setLogLevel(20)是为了关掉Building prefix dict...的加载提示,纯脚本运行时保持输出整洁;第二处clean_text里的回目过滤用了正则锚点.*?[\n],非贪婪匹配到行尾,避免误删正文里出现「第一百回」这种叙述;第三处pseg.cut对每句话单独跑而不是全文一次性跑,这是为了能拿到sent_idx做句子级去重。

4.2 调参对照表:词频、词性与场景的排列组合

统计结果对参数极度敏感,下面这张表是我实测后总结的参数影响,照着调可以少走很多弯路:

操作影响范围典型后果
load_userdict自定义词频设为 100低频词易被忽略悟空、妖精等词仍被拆开
自定义词频设为 50000高频词过度合并会把「三藏真经」「八戒禅杖」整体切成一个词
词性过滤加上nz误纳入地名/器物名「水帘洞」「花果山」会被计入次数
len(word) < 2过滤去掉姓氏独立被统计「孙」「猪」「唐」各占一行,场面混乱
句子切分只用长对话段落跨句一个段落里同一角色多次出现没去重

我的建议是目前这套参数体系:人物词频 5000-8000,称号 3000-4000,保留nrnz都可以(取决于你是否想统计「花果山」这种地名出场),句子边界保留。!?;四种。如果你把nz也加入过滤条件,统计结果会爆炸,因为全书到处都是「花果山」「水帘洞」「金箍棒」。这些专名在 jieba 里通常标nz,务必在代码里确认flag == "nr"才计入人物。

4.3 用 pyecharts 画一张「西游人物出场排行榜」横向柱状图

有了 CSV,可视化我用 pyecharts,纯 Python 生态不用切工具。它生成的 HTML 可以交互,鼠标悬浮能看到具体数字,方便给非技术的读者看。

import csv from pyecharts.charts import Bar from pyecharts import options as opts names, counts = [], [] with open("character_stats.csv", "r", encoding="utf-8-sig") as f: reader = csv.DictReader(f) for row in reader: names.append(row["标准人物"]) counts.append(int(row["句子级出场次数"])) bar = ( Bar(init_opts=opts.InitOpts(width="900px", height="600px", title="西游记主要人物出场次数 Top20")) .add_xaxis(names[:20]) .add_yaxis("句子级出场次数", counts[:20]) .set_global_opts( xaxis_opts=opts.AxisOpts(axislabel_opts=opts.LabelOpts(rotate=30)), yaxis_opts=opts.AxisOpts(name="出场次数"), ) ) bar.render("xiyouji_rank.html")

这段代码的关键在counts[:20]——CSV 已经按次数降序排好了,直接取前 20 画图。rotate=30让人名横向排列不至于重叠,中文标签在 pyecharts 里不需要额外设置字体。生成后用浏览器打开 HTML 即可。需要说明的是,阈值设定会影响排行榜的尾部:如果你把「句子级出场次数」少于 20 的角色全部过滤掉,前 20 名的尾部会缩到只剩 12-15 人。统计口径写清楚比「结果惊人」更重要。

5. 高频干扰项:把「长老」「那怪」从人物库里踢出去

5.1 歧义词的统计口径:词性过滤解决不了的,用词频阈值解决

即使做了nr词性过滤,统计结果里依然会出现一批「伪人物」。最有代表性的是这几个:

词汇被标为 nr 的场景实际指代
长老唐僧被尊称为「长老」时有时是唐僧,有时是寺院里的其他僧人
那怪妖精出场时不是具体人名
菩萨观音出场时但其他菩萨也出场
大王妖怪称王时几乎每个妖怪都被这样叫
师父悟空等人叫唐僧时但唐僧也叫菩提祖师「师父」

长老师父是最大的干扰源。《西游记》里唐僧被叫「师父」的次数极多,但书中的「师父」也指菩提祖师,所以如果把这个词归到唐僧名下,等于默认了所有「师父」都指向他,这在文本层面站不住。我的做法是分两步走,第一步在ALIAS_MAP里不收录师父长老,让它们作为nr词性被统计出来但不聚合到任何人;第二步用词频阈值——如果你看到长老的计数极高(通常超过唐僧的 60%),就要警惕。

5.2 用add_word动态修正「假人名」的词权

对于跨语境的人称词,我倾向于在自定义词典里给它们的词频设得很低,低到不足以让 jieba 优先把人名识别出来。

import jieba # 这些词如果不在 userdict 里,jieba 会按通用词典处理 # 直接 add_word 覆盖词频,让它们倾向于被切成 '长' + '老' 而不是一个专名 jieba.add_word("长老", freq=2000, tag="n") jieba.add_word("师父", freq=2000, tag="n") jieba.add_word("那怪", freq=1000, tag="r")

add_word是动态修改词典的接口,freq=2000唐僧的 8000 低很多,分词时动态规划会倾向于不把长老当独立高频词来切,具体表现是「那长老」可能被切成+长老,词性变为n而不是nr这是利用 jieba 的分词决策机制做「负向约束」—— 不加这一行,统计里会凭空多出几百次「长老出场」。另一个方法是正则剔除,在posseg遍历时强制跳过:

BLACKLIST_WORDS = {"长老", "师父", "那怪", "大王", "菩萨"} for word, flag in pseg.cut(sent): if word in BLACKLIST_WORDS: continue

5.3 校对样本抽查:抽三回验证统计是否靠谱

「结果对不对」不能靠感觉,要抽原文验证。我一般的做法是从统计结果里随机抽 2 回章节,人工核对每一句里角色的实际出场情况。比如第十一回「还受生唐王遵善果 度孤魂萧瑀正空门」,这章唐僧刚出场,如果统计显示唐僧的句子级次数超过 5,那大概率把「唐王」(李世民)也归到唐僧名下了。这个校对步骤能发现两类问题:一是ALIAS_MAP里两个角色共用了一个称呼(最常见的是「大王」在多处指不同妖王),二是自定义词典词频设置过高导致跨词合并(把「唐长老」整体切成一个词,被nr标成人名后无法映射到标准人物)。我的经验是,校对时重点看「动词附近的称呼」,比如「却说那怪」——这词的那怪如果被标成nr且不在黑名单里,沙僧可能被多统计几十次,因为沙僧的别称是「那怪」出现频率最高的主语场景。

6. 进阶:用 jieba 的cut_for_search模式捕捉「跨词检测」的遗漏人名

前面所有逻辑都假设人名必须被完整切出来。但《西游记》里经常出现「孙大圣」「猪八戒」这种姓+称号的组合,如果你的ALIAS_MAP里没有「孙大圣」这个完整词,它会被切成+大圣长度为 1 被过滤,大圣不在聚合表里——结果就是这个人在场了但完全没被统计cut_for_search模式专门解决这种组合场景,它会在全词结果之外再给出组合词的所有可能子词,相当于「二次补刀」。

import jieba text = "却说孙大圣引八戒进洞来看时,那妖精早已去得远了。" # 全词模式输出:['却说', '孙', '大圣', '引', '八戒', '进洞', '来看', '时', ',', '那', '妖精', '早已', '去得', '远', '了'] print(jieba.lcut(text)) # 搜索引擎模式输出:['却', '说', '孙', '大圣', '大', '圣', '引', '八戒', '进洞', '来看', '来', '看', '时', '那', '妖精', '早已', '去', '得', '远', '了'] print(jieba.lcut_for_search(text))

搜索模式会把大圣单独拆出来,因为你自定义词典里大圣如果设了 3000 词频且带nr标记,它就能在上面的统计流程里被ALIAS_MAP命中。修改方式很简单:在count_character_appearances里把分词器从pseg.cut换成基于jieba.lcut_for_search后的词集合再跑一遍词性标注,或者直接把大圣孙大圣猪八戒全名补进ALIAS_MAP但要注意:搜索模式已经不属于标准词性标注流程,它会产出大量无意义的单字子词,必须在统计时过滤掉len(word) < 2的结果。

实际调优时,我还有个技巧是把「跨段合并」改成「跨行合并」:前面clean_text里把\s+压缩成空串,即「大圣」跨行拆成「大\n圣」的问题已经不存在,所以搜索模式在这里的优势主要在于处理「姓+号」和「号+官职」的组合。验证方法很简单:把ALIAS_MAP的 key 打印出来再对照jieba.lcut_for_search结果,确保孙大圣这种组合词能通过别名表命中。最终统计结果建议输出到 CSV 时附带一个version` 字段,因为你会发现——调词频、加别名、换分词模式,每次跑出来的数字都不一样。记录参数版本,和「哪一回改了词典导致唐僧少了 50 次」才有得追查。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询