
简介这是一份面向Python文本分析与数据可视化学习者的实战项目包围绕三国人物关系与词频展开适合想掌握中文分词、词频统计和词云图生成的中级学习者练手。压缩包共16个文件约6.73MB包含py脚本、txt语料与停用词表、ttf字体、png与jpg可视化图、doc设计报告及xml配置等覆盖从数据读取到成图的全流程。项目以《三国演义》文本为素材演示了用open()读取TXT、jieba分词、去除停用词与标点、Counter词频统计再到wordcloud生成词云图的完整链路并附人物社交关系网络图与词频结果直观呈现刘备、关羽、张飞及曹操、孙权等人物间的互动格局。已有959人学习下载读者可借此理解文本预处理、统计分析与可视化各环节的衔接方式并参考设计报告与脚本自行替换语料复现实验。1. 三国人物关系词频分析词云图从一份文本到一张能讲故事的图拿到「三国人物关系词频分析词云图.zip」这个标题很多人第一反应是不就是把《三国演义》全文丢进 jieba 分词再调个 wordcloud 库出张图吗真动手才发现翻车点全在细节里——「曹操」和「孟德」被拆成两个人「关公」和「关羽」各算各的词云里飘着一堆「曰」「之」「军」这种高频虚词人物关系完全看不出来。词频分析和词云图这两个词看着简单落到三国这种人物众多、称谓复杂的文本上考的是分词、别名归并和权重设计。这篇笔记面向想用中文文本做人物关系可视化的从业者从语料准备、分词清洗、别名映射、词频统计到词云图生成把每一步的参数和坑讲清楚新手能照着跑通熟手能直接拿去改自己的语料。2. 语料准备与分词三国文本为什么不能直接丢进 jieba2.1 语料来源与编码处理做三国人物词频第一步是拿到干净的全文。常见做法是从公开的古籍文本库获取《三国演义》120 回全文保存为 UTF-8 编码的 txt。这里有个血泪经验网上流传的版本编码五花八门GB2312、GBK、UTF-8 混在一起直接读会报UnicodeDecodeError。我一般先用chardet探一下编码再统一转成 UTF-8。import chardet # 探测原始文件编码避免直接读取报错 with open(sanguo_raw.txt, rb) as f: raw f.read() result chardet.detect(raw) print(result) # {encoding: GB2312, confidence: 0.99, ...} # 按探测结果读取并统一转为 UTF-8 text raw.decode(result[encoding], errorsignore) with open(sanguo_utf8.txt, w, encodingutf-8) as f: f.write(text)逻辑说明chardet.detect返回置信度最高的编码errorsignore跳过无法解码的字符避免个别乱码导致整个流程中断。参数上如果置信度低于 0.8建议手动指定编码再试不要盲信探测结果。语料清洗还要去掉回目编号、页眉页脚、注释行。常见做法是用正则把「第X回」这类行单独抽出来做章节标记正文只保留叙事段落。这一步不做后面词频里会混进大量「第一回」「话说」之类的噪声。2.2 jieba 分词与自定义词典jieba 默认词典对三国人名支持很差「诸葛亮」可能被切成「诸葛」「亮」「司马懿」切成「司马」「懿」。解决办法是加载自定义词典把三国人物名和别名全部加进去。import jieba # 加载自定义词典每行格式为 词语 词频 词性 jieba.load_userdict(sanguo_names.txt) with open(sanguo_utf8.txt, encodingutf-8) as f: text f.read() # 精确模式分词适合词频统计 words jieba.lcut(text, cut_allFalse) # 过滤单字和标点 stopwords set([的, 了, 曰, 之, 军, 人, 马, 一, 不]) words [w for w in words if len(w) 1 and w not in stopwords] print(words[:20])sanguo_names.txt的内容示例诸葛亮 100 n 孔明 100 n 卧龙 50 n 司马懿 100 n 仲达 80 n 曹操 100 n 孟德 80 n 曹孟德 60 n逻辑说明load_userdict必须在lcut之前调用否则不生效。词典里词频给高一点比如 100保证 jieba 优先按整词切分。cut_allFalse是精确模式适合词频统计全模式会产出大量冗余组合反而干扰统计。参数说明停用词表要针对三国文本定制「曰」「之」「军」这些在文言叙事里高频但无人物关系意义必须过滤。单字过滤用len(w) 1因为三国人物名基本都是两字以上单字多半是虚词或残片。2.3 别名归并与人物实体对齐分词只是第一步真正决定词云质量的是别名归并。三国里同一个人有本名、字、号、封号、尊称比如关羽可以叫「关羽」「关公」「关云长」「云长」「关某」。如果不归并词频会被稀释词云里每个人物都显得不重要。常见做法是建一张映射表把别名统一映射到标准名alias_map { 关公: 关羽, 关云长: 关羽, 云长: 关羽, 孔明: 诸葛亮, 卧龙: 诸葛亮, 诸葛孔明: 诸葛亮, 孟德: 曹操, 曹孟德: 曹操, 阿瞒: 曹操, 仲达: 司马懿, 司马仲达: 司马懿, 玄德: 刘备, 刘玄德: 刘备, 翼德: 张飞, 张翼德: 张飞, 子龙: 赵云, 赵子龙: 赵云, } words [alias_map.get(w, w) for w in words]逻辑说明dict.get的第二个参数是默认值别名命中就替换没命中保持原词。映射表要覆盖主要人物次要人物可以后续补充。归并后再统计词频人物权重才准确。注意别名映射要避免误伤。比如「云长」不会和别的词冲突但「子龙」如果出现在非人名语境极少需要人工检查一遍。我一般会先跑一遍统计把 Top 100 词打印出来肉眼过一遍确认没有明显误归并再继续。3. 词频统计与权重设计让词云图真正反映人物关系3.1 词频统计与共现矩阵归并完别名后用collections.Counter统计词频同时构建人物共现矩阵为后续关系分析打基础。from collections import Counter, defaultdict # 基础词频统计 word_freq Counter(words) top20 word_freq.most_common(20) print(top20) # 共现矩阵以段落为窗口统计人物同段出现次数 paragraphs text.split(\n) co_occur defaultdict(int) for para in paragraphs: para_words set(alias_map.get(w, w) for w in jieba.lcut(para) if len(w) 1) persons [w for w in para_words if w in alias_map.values()] for i in range(len(persons)): for j in range(i 1, len(persons)): pair tuple(sorted([persons[i], persons[j]])) co_occur[pair] 1 # 输出共现次数最高的 10 对人物 top_pairs sorted(co_occur.items(), keylambda x: -x[1])[:10] for pair, cnt in top_pairs: print(pair, cnt)逻辑说明共现窗口用段落而不是整句因为三国叙事里一段往往围绕一个事件同段出现的人物关系更紧密。set去重避免同一段里同一个人重复计数。tuple(sorted(...))保证 (A,B) 和 (B,A) 算同一对。参数说明窗口大小可以调段落是最自然的切分。如果段落太长比如某些版本一段几千字可以改成按句号切分。共现次数只是原始信号后续可以归一化或做 PMI 计算但词云图本身用词频就够了。3.2 词云权重词频、TF-IDF 还是共现强度词云图的核心参数是每个词的权重。常见有三种选法权重方案计算方式适用场景三国文本效果原始词频Counter 计数快速出图主角突出但虚词干扰大TF-IDF词频×逆文档频率多文档对比单文档不适用IDF 无意义共现强度与其他人物共现总次数关系分析能反映人物社交活跃度我一般用「词频 共现强度」加权weight freq * 0.6 co_count * 0.4。这样既保留出场次数又让关系网中心的人物更突出。比如诸葛亮出场多共现人物也多权重自然高而某些出场少但只跟主角互动的人物也能获得合理权重。# 计算共现强度 co_strength defaultdict(int) for (a, b), cnt in co_occur.items(): co_strength[a] cnt co_strength[b] cnt # 加权 final_weight {} for person in set(alias_map.values()): freq word_freq.get(person, 0) co co_strength.get(person, 0) final_weight[person] freq * 0.6 co * 0.4 # 按权重排序 sorted_weight sorted(final_weight.items(), keylambda x: -x[1]) print(sorted_weight[:15])逻辑说明co_strength把每对共现次数累加到每个人物上反映该人物与其他人物互动的总强度。加权系数 0.6/0.4 是经验值可以根据语料调整——如果语料里人物出场极不均匀可以调高共现权重。注意加权前要确保词频和共现强度量级接近否则一个会完全压制另一个。如果词频是几百共现是几十直接加权没问题如果差距过大先做归一化。3.3 词云图生成wordcloud 库参数详解Python 里最常用的是wordcloud库配合matplotlib出图。关键参数有font_path中文字体路径、width/height画布尺寸、max_words最多显示词数、background_color背景色、mask形状蒙版。from wordcloud import WordCloud import matplotlib.pyplot as plt # 中文字体路径Windows 常用 simhei.ttfLinux 用 Noto Sans CJK font_path simhei.ttf wc WordCloud( font_pathfont_path, width1200, height800, max_words100, background_colorwhite, colormapviridis, prefer_horizontal0.9, random_state42, ) wc.generate_from_frequencies(final_weight) plt.figure(figsize(12, 8)) plt.imshow(wc, interpolationbilinear) plt.axis(off) plt.savefig(sanguo_wordcloud.png, dpi200, bbox_inchestight) plt.show()逻辑说明generate_from_frequencies直接接收{词: 权重}字典比generate(text)更可控因为权重已经算好了。prefer_horizontal0.9让 90% 的词横排可读性更好。random_state固定随机种子保证每次出图布局一致方便对比调参。参数说明font_path必须指向支持中文的字体文件否则中文会显示成方块。max_words100控制词数太多会拥挤太少信息量不够。colormap用viridis或plasma对比度好避免用jet这种彩虹色视觉上容易乱。如果要做成特定形状比如中国地图轮廓需要准备一张黑白蒙版图传给mask参数。蒙版白色区域不填充黑色区域填充词。这个技巧在人物关系图上用得少但做地域分布图时很实用。4. 用 ECharts 做交互式词云图前端展示的另一种选择4.1 ECharts 词云图插件引入与数据格式Python 出的是静态图如果要放到网页上做交互鼠标悬停显示词频、点击跳转ECharts 的echarts-wordcloud插件更合适。最新网络热词里「echarts 词云图 示例」搜的人很多这里给一个能直接跑的最小示例。!DOCTYPE html html head meta charsetutf-8 script srchttps://cdn.jsdelivr.net/npm/echarts5/dist/echarts.min.js/script script srchttps://cdn.jsdelivr.net/npm/echarts-wordcloud2/dist/echarts-wordcloud.min.js/script /head body div idmain stylewidth: 100%; height: 600px;/div script var chart echarts.init(document.getElementById(main)); var data [ {name: 诸葛亮, value: 320}, {name: 曹操, value: 280}, {name: 刘备, value: 260}, {name: 关羽, value: 240}, {name: 张飞, value: 200}, {name: 赵云, value: 180}, {name: 司马懿, value: 160}, {name: 周瑜, value: 140}, {name: 孙权, value: 120}, {name: 吕布, value: 100} ]; var option { series: [{ type: wordCloud, shape: circle, sizeRange: [14, 80], rotationRange: [-45, 45], rotationStep: 45, gridSize: 8, textStyle: { fontFamily: sans-serif, fontWeight: bold, color: function () { return rgb( [ Math.round(Math.random() * 160), Math.round(Math.random() * 160), Math.round(Math.random() * 160) ].join(,) ); } }, data: data }] }; chart.setOption(option); /script /body /html逻辑说明echarts-wordcloud是独立插件必须在echarts之后引入。data数组里每个对象包含name和valuevalue决定字号大小。shape支持circle、cardioid、diamond、triangle-forward等也可以传自定义函数。参数说明sizeRange控制最小和最大字号[14, 80]适合 600px 高度的画布。rotationRange设[-45, 45]让词有轻微倾斜比全横排生动但不要超过 90 度否则阅读困难。gridSize是词间距太小会重叠太大会稀疏8 是常用值。4.2 从 Python 词频到 ECharts 数据的转换Python 算好的final_weight字典要转成 ECharts 需要的 JSON 数组。常见做法是导出为 JSON 文件前端用fetch加载。import json # 取 Top 100 并转为 ECharts 格式 top100 sorted(final_weight.items(), keylambda x: -x[1])[:100] echarts_data [{name: k, value: v} for k, v in top100] with open(sanguo_wordcloud_data.json, w, encodingutf-8) as f: json.dump(echarts_data, f, ensure_asciiFalse, indent2)逻辑说明ensure_asciiFalse保证中文正常写入不转成\uXXXX。indent2方便人工检查。前端加载后直接赋给data即可。注意ECharts 词云图对中文支持依赖浏览器字体一般没问题。但如果value差距过大比如最大 1000最小 1小词会几乎看不见可以先做对数变换或线性映射到[10, 100]区间。4.3 交互增强点击人物显示共现关系ECharts 词云图支持点击事件可以绑定click事件点击某个人物时在侧边栏显示他的共现人物列表。这个功能对「人物关系」主题特别有用。chart.on(click, function (params) { var person params.name; // 从预加载的共现数据里查 var relations coData[person] || []; var html h3 person 的共现人物/h3ul; relations.forEach(function (r) { html li r.name r.count 次/li; }); html /ul; document.getElementById(detail).innerHTML html; });逻辑说明coData是预先从 Python 导出的共现字典结构为{人物: [{name, count}, ...]}。点击词云里的词右侧显示该人物的关系列表形成「词云 关系详情」的联动。参数说明共现数据导出时按次数降序排列只保留 Top 10 关系避免列表过长。如果要做更复杂的关系图可以结合 ECharts 的graph类型把共现矩阵直接渲染成力导向图。5. 避坑与排查三国词云图最容易翻车的 5 个地方5.1 现象词云里全是「曰」「之」「军」人物看不见原因停用词表没做好文言虚词和通用名词没过滤。jieba 默认词典不区分词性高频虚词会挤占词云空间。解决建一个三国专用停用词表除了常见虚词还要加「军」「马」「人」「兵」「将」这类在叙事里高频但无人物区分度的词。我一般会先跑一遍词频把 Top 200 打印出来人工挑出需要过滤的词迭代两三轮就干净了。5.2 现象同一个人出现两次比如「关羽」和「关公」各占一块原因别名归并没做或者映射表不全。分词阶段「关公」和「关羽」是两个独立词统计时各算各的。解决建别名映射表覆盖本名、字、号、尊称。映射表要放在分词之后、统计之前。检查方法是统计完打印 Top 50看有没有明显同人不同名的情况。常见遗漏「先主」→「刘备」「后主」→「刘禅」「丞相」→「诸葛亮」但「丞相」也可能指别人需要结合上下文简单做法是直接映射到最高频的那个。5.3 现象词云图中文显示成方块原因font_path没设或指向了不支持中文的字体。Windows 默认字体路径不对Linux 服务器上可能根本没装中文字体。解决Windows 用C:/Windows/Fonts/simhei.ttfLinux 用/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc。如果找不到先fc-list :langzh查一下系统里有哪些中文字体。Docker 环境里要手动apt install fonts-noto-cjk。5.4 现象ECharts 词云图不显示控制台报wordCloud is not a function原因echarts-wordcloud插件没引入或者引入顺序错了。插件必须在echarts之后加载。解决检查两个script标签的顺序确保echarts.min.js在前echarts-wordcloud.min.js在后。如果用的是 npm 打包需要import echarts-wordcloud并在echarts.use里注册。5.5 现象词云布局每次都不一样没法对比调参原因wordcloud库默认随机布局random_state没设。ECharts 词云也有随机性但相对稳定。解决Python 端设random_state42保证每次出图一致。ECharts 端可以设layoutAnimation: false关闭动画减少视觉抖动。如果要做多版本对比固定随机种子是必须的否则调参全靠玄学。6. 进阶技巧用共现矩阵反推人物阵营与关系强度词云图做完只是起点真正有意思的是从共现矩阵里挖人物关系。我一般会做两步先算 PMI点互信息找强关联人物对再用社区发现算法看阵营划分。PMI 的计算方式是log(P(a,b) / (P(a)*P(b)))值越高说明两人共同出现的概率远超随机关系越紧密。比如「刘备」和「诸葛亮」的 PMI 会很高而「刘备」和「董卓」的 PMI 接近零甚至为负。import math total_pairs sum(co_occur.values()) person_total defaultdict(int) for (a, b), cnt in co_occur.items(): person_total[a] cnt person_total[b] cnt def pmi(a, b): pair_cnt co_occur.get(tuple(sorted([a, b])), 0) if pair_cnt 0: return -999 p_ab pair_cnt / total_pairs p_a person_total[a] / total_pairs p_b person_total[b] / total_pairs return math.log(p_ab / (p_a * p_b)) # 找 PMI 最高的 10 对 pairs list(co_occur.keys()) pmi_scores [(pair, pmi(*pair)) for pair in pairs if co_occur[pair] 5] pmi_scores.sort(keylambda x: -x[1]) for pair, score in pmi_scores[:10]: print(pair, round(score, 3))逻辑说明co_occur[pair] 5过滤掉低频噪声对避免偶然共现拉高 PMI。total_pairs是所有共现对的总次数用来算概率。PMI 为负说明两人共现次数低于随机预期关系疏远。参数说明阈值 5 是经验值语料大可以调高语料小调低。PMI 对低频对敏感所以必须过滤。如果想更稳健可以用 NPMI归一化 PMI把值映射到[-1, 1]。社区发现可以用networkx的greedy_modularity_communities把共现矩阵转成图自动分出阵营。三国里典型的阵营划分是魏、蜀、吴但算法不看历史标签纯靠共现结构有时候会把「司马懿」和「诸葛亮」分到同一社区因为对手戏多这反而能发现一些反直觉的关系。我自己的习惯是词云图用来快速看全局共现矩阵和 PMI 用来验证具体关系。每次调完参数先看词云有没有明显噪声再看 PMI Top 10 是否符合直觉如果出现「张飞」和「曹操」这种高 PMI 对就要回去检查是不是某段文本里两人被错误地放进了同一段。这套流程跑顺了换任何中文叙事文本都能复用无非是换词典和停用词表。希望帮到你。本文还有配套的精品资源点击获取