做文本分析这些年,我经常被问到一个略显"偷懒"的问题:七本《哈利波特》、几百万字,如果只给你六句话,你会怎么概括?更让人好奇的是,这套讲魔法学校的小说,为什么能跨语言、跨年龄、跨代际地火这么多年。网上的书评大多停在感性层面,说"童年滤镜""世界观宏大",但很少有人把它拆成可量化、可复现的证据。这篇文章不打算写读后感,而是换个角度:用一套 Python 文本分析流水线,把整部作品切成章节、统计词频、抽取摘要、构建人物共现网络、画出情感曲线,最后用数据去支撑那六句话,也去解释"为什么好看"。全文包含完整的可复制代码、目录结构和排错清单,适合有一定 Python 基础、想入门文本分析或自然语言处理实战的读者,也适合只想拿一套现成脚本来分析自己手里文本的同学。
1. 背景与核心概念
1.1 从"六句话读完"到一次真实的文本挖掘任务
"用六句话读完一本书"听起来像标题党,但在技术上它对应的是一个非常经典的任务——自动文本摘要(Text Summarization)。摘要任务的目标,是把一篇长文档压缩成一段短文本,同时尽量保留原文的核心信息。工业界用得最多的地方是新闻聚合、会议纪要、法律文书检索和客服工单归类,而"总结一本小说"只是它最讨喜的一个应用场景。
真正动手之后你会发现,难点不在"调用某个库",而在前面那几步:文本怎么清洗、章节怎么切分、中文怎么分词、停用词怎么处理、句子相似度怎么定义。这些环节任何一个处理不当,最后抽出来的"六句话"就会变成六句废话,比如连续抽到三段环境描写。所以本文的重点不是给你一个summarize(text)的黑盒函数,而是把每一步拆开讲清楚,让你知道参数为什么这么设、结果不好时该改哪里。
顺带说一句,用代码分析小说还有一个隐性收益:你会被迫用"证据"说话。你觉得某个人物重要,是因为你记得他;而共现网络告诉你他重要,是因为他和主角在同一段文字里出现了几百次。这两种"重要"经常不一致,而这种不一致本身,往往就是解读作品的入口。
1.2 抽取式摘要与生成式摘要,先分清再动手
文本摘要大致分两派。**抽取式摘要(Extractive)**是从原文中挑出若干句子,原样拼成摘要;**生成式摘要(Abstractive)**是理解原文后用自己的话重写,类似人类写读后感。前者实现简单、结果不会失真、可解释性强,缺点是句子之间可能不连贯;后者读起来自然,但需要序列到序列模型,训练成本高,还容易"编造"原文没有的内容。
本文采用的是抽取式路线,具体算法是TextRank。它 borrowed 自 Google 的 PageRank 思想:把每个句子看成一个节点,句子之间的相似度看成边,然后迭代计算每个句子的"重要性得分",最后取分数最高的若干句。这个算法不需要训练、不需要标注数据,几十行代码就能实现,非常适合作为文本分析的第一课。
需要提醒的是,抽取式摘要出来的是"候选六句",读起来一定是生硬的。真实的做法是:让算法给出候选,让人来润色。第 7 节给出的那六句话,就是在算法输出的候选句子和关键词排行基础上重新组织的。这也是我在实际项目里一贯的建议——摘要系统先做"辅助人",别急着做"替代人"。
1.3 文本分析能回答"为什么风靡全球"吗
严格来说,不能。一部作品流行是出版、影视、翻译、营销、时代情绪共同作用的结果,文本本身只是其中一个变量。但文本分析能回答一个更聚焦的问题:这部作品在文本层面具备哪些容易被大量读者接受的特征。比如:
- 高频词里,"朋友""学校""家""害怕""选择"这类词占比如何,说明它的情感锚点在哪;
- 人物共现网络是"三主角紧耦合 + 大量配角环绕"还是"单主角辐射",前者更容易让读者找到代入对象;
- 情感曲线是不是呈现"每章小起伏、每卷大低谷再回升"的节奏,这种节奏对连载阅读极其友好;
- 关键词是否集中在少数几个意象上,意象越集中,读者越容易记住和转述。
这些都是可以用代码量化、可以用图表呈现的。本文的分析结论只基于文本统计,不涉及任何超出语料的推断。这一点在写作和技术报告里都很重要:数据能支撑什么,就只说什么。
1.4 本文的技术路线与产出物
整条流水线可以概括成五步,用 ASCII 图表示大概是这样:
原始 txt 语料 │ ├─[1] 清洗 + 按章切分 → chapters: List[str] │ ├─[2] 分词 + 停用词过滤 → 词频 / TF-IDF 关键词 │ ├─[3] 分句 + 相似度矩阵 → TextRank → 候选六句 │ ├─[4] 段落窗口共现 → networkx 人物关系图 → 中心度排名 │ └─[5] 词典情感打分 → 逐章情感曲线 ↓ output/*.csv + *.png产出物包括:keywords.csv(关键词及权重)、summary.txt(候选摘要句)、network.png(人物关系图)、centrality.csv(人物中心度排名)、sentiment.png(情感曲线)。这五个文件足以支撑一篇有数据支撑的分析文章,也足以作为一个文本分析项目的模板反复复用。
2. 环境准备与版本说明
2.1 运行环境与依赖清单
本文示例使用 Python 编写,建议 Python 3.9 及以上版本;如果你的环境是 3.8,只要 numpy 能正常安装,代码同样可以跑通。操作系统不限,Windows、macOS、Linux 均可,代码里涉及的路径操作全部使用pathlib,不存在 Windows 反斜杠转义问题。
第三方库一共四个,作用分别是:jieba负责中文分词与关键词提取,numpy负责相似度矩阵和向量运算,networkx负责图结构与中心度计算,matplotlib负责出图。这里不锁定具体版本号,因为这几个库的公开 API 在近几个大版本里都保持稳定,锁死版本反而容易在不同环境下装不上。安装时以当前稳定版为准,如果遇到接口变动,按官方文档调整即可。
# 文件路径:hp_analysis/requirements.txt jieba numpy networkx matplotlib如果你的语料是英文原版,可以把jieba换成nltk或spacy做分词和句子切分,后面的 TextRank、共现网络、情感打分逻辑完全不用改,因为这三部分只依赖"词列表"和"句子列表"这两个抽象结构。这也是我建议的写法:把文本处理层和算法层解耦,换语言只需要改一个文件。
2.2 安装命令与依赖验证
安装命令很直接,如果下载速度慢,可以临时指定镜像源:
# 文件路径:命令行执行 python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate pip install -r requirements.txt # 如需指定镜像源 pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple装完之后建议做一次最小验证,确认三个关键库都能正常导入,并且 jieba 能切出中文词:
# 文件路径:hp_analysis/check_env.py import jieba import numpy as np import networkx as nx import matplotlib print("numpy:", np.__version__) print("networkx:", nx.__version__) print("matplotlib:", matplotlib.__version__) print("jieba 分词测试:", "/".join(jieba.lcut("霍格沃茨的城堡在夜里亮起了灯")))预期输出会打印出三个库的版本号,最后一行是类似霍格沃茨/的/城堡/在/夜里/亮起/了/灯的分词结果。如果这一步就报错,说明环境没装好,先解决环境问题再往下走,不要带着环境问题去调试算法,那样排查成本会翻倍。
2.3 项目目录结构
目录按"配置 / 数据 / 模块 / 输出"四层划分,方便后面把脚本改成流水线:
hp_analysis/ ├── requirements.txt ├── config.py # 路径、人物名单、停用词文件位置 ├── stopwords.txt # 中文停用词表,一行一个 ├── corpus/ # 放你的 txt 语料,一个文件一部 │ └── book01.txt ├── load_text.py # 读取语料 + 按章切分 ├── keywords.py # 词频与 TF-IDF 关键词 ├── summarizer.py # 分句 + TextRank 摘要 ├── network.py # 人物共现网络 ├── sentiment.py # 词典情感打分 ├── main.py # 串起整条流水线 └── output/ # 自动生成的结果目录这个结构看起来比"一个 2000 行的脚本"麻烦,但好处非常明显:当你想换一种摘要算法时,只需要替换summarizer.py;当你想加一个新的分析维度时,只需要新增一个模块并在main.py里挂一行。文本分析项目最大的坑从来不是算法难,而是三个月后回来看不懂自己写的脚本。
2.4 语料准备与合规边界
这一节必须单独说。本文的全部示例都以"你合法持有的电子文本"为前提,请使用自己购买或已获授权的电子书、公版文本,或者自己手打的片段做测试。不要把未授权的全文打包上传仓库、不要在大段输出中还原原文、不要把分析结果公开成替代原著的阅读材料。技术分析和内容分发是两件事,边界要划清楚。
在具体操作上,建议只保留用于统计的中间结果(词频、句子索引、分数),原文只在本地读取一次。如果你的文章需要展示语料片段,控制在一两句、且用于说明算法效果即可。这样做既合规,也更专业。
另外提醒一点:语料编码统一存成 UTF-8,避免读取时出现乱码。如果拿到的是 GBK 编码的文件,可以用编辑器转码,或者在读取时显式指定encoding="gbk"。乱码不会让程序崩,但会让分词结果全部变成单字,最终关键词表里全是"的""了",非常隐蔽。
3. 核心原理与关键 API 拆解
3.1 分句:摘要任务的第一步
摘要的基本单位是句子,所以第一件事是把长文本切成句子。中文分句看起来简单,实际上坑不少:省略号后面算不算句子?引号里的对话怎么处理?英文的Mr.、Dr.会不会被误判成句号?
最稳妥的工程做法是用正则做"保守切分":只在明确的句末标点(。!?!?)之后切,并且过滤掉过短的碎片。下面这段代码是本文的split_sentences:
# 文件路径:hp_analysis/summarizer.py(片段) import re SENT_END = re.compile(r"(?<=[。!?!?])") def split_sentences(text, min_len=10, max_len=200): """把一章文本切成句子列表。 min_len: 过短的碎片直接丢弃,避免"他说。"这种无效句进入摘要 max_len: 过长的句子截断,防止单句占用过多相似度权重 """ text = text.replace("\n", "").replace("\r", "") raw = SENT_END.split(text) sentences = [] for s in raw: s = s.strip() if len(s) < min_len: continue if len(s) > max_len: s = s[:max_len] sentences.append(s) return sentences这里有两个参数值得解释。min_len设为 10 是为了过滤对话碎片,比如"他说。"只有 4 个字,如果进入摘要,会占掉一个宝贵的名额。max_len设为 200 是为了控制句子长度差异带来的偏差——在 TextRank 里,长句子天然包含更多词,和别的句子更容易"相似",如果不截断,最后六句很可能全是超长描写段。
常见误区是直接对整本书的文本做split("。")。这样切出来的句子会丢失标点,而且在有引号嵌套的对话场景下会切出大量半截句。建议至少保留句末标点,后面展示摘要时读起来更自然。
3.2 分词与停用词:中文文本分析的地基
中文没有空格分隔,所以分词是绕不开的一步。jieba提供三种模式:精确模式、全模式和搜索引擎模式。做词频统计和摘要,用默认的精确模式(jieba.lcut)就够了;搜索引擎模式会把长词再切出短词,适合做召回,不适合做统计。
停用词过滤同样关键。"的、了、是、在、我、他"这些词在任何中文文本里都排前列,如果不过滤,你的关键词表会前二十名全是虚词,完全看不出内容。本文采用"外部停用词表 + 代码内最小兜底"的组合:
# 文件路径:hp_analysis/config.py from pathlib import Path BASE_DIR = Path(__file__).resolve().parent CORPUS_DIR = BASE_DIR / "corpus" OUTPUT_DIR = BASE_DIR / "output" STOPWORD_FILE = BASE_DIR / "stopwords.txt" OUTPUT_DIR.mkdir(exist_ok=True) # 兜底停用词:即使 stopwords.txt 缺失也能跑 FALLBACK_STOPWORDS = { "的", "了", "是", "在", "和", "也", "就", "都", "而", "及", "与", "着", "我", "你", "他", "她", "它", "我们", "你们", "他们", "这", "那", "一个", "没有", "什么", "自己", "但是", "因为", "所以", "如果", "还是", "已经" } def load_stopwords(path=STOPWORD_FILE): words = set(FALLBACK_STOPWORDS) if path.exists(): with open(path, encoding="utf-8") as f: for line in f: w = line.strip() if w: words.add(w) return words停用词表可以从公开的中文停用词清单里取一份,再按你的语料补充。比如分析小说时,"说道""看着""似乎"这类叙事高频词对内容判断没有帮助,建议手动加进去。这一步看起来是"脏活",但它对结果的影响远大于换算法。
3.3 TF-IDF 与 TextRank 关键词:两种不同的"重要"
提取关键词有两套常用思路,很多教程混着讲,其实差别很大。
TF-IDF的核心是"这个词在当前文档里出现得多,但在其他文档里出现得少,就重要"。它需要多篇文档构成语料库。jieba.analyse.extract_tags内置了一份通用语料算好的 IDF 表,所以即使你只传一篇文本也能出结果,但那份 IDF 是新闻语料训练的,用在小说上会有偏差。
TextRank 关键词不看文档集合,而是在词与词之间建图:两个词在同一个滑动窗口里共现,就连一条边,然后迭代算重要性。它更擅长找出"在本文内部抱团出现"的词,对单个文档更友好。
本文的做法是两种都跑一遍,取交集看稳定结果,取差集看偏差词:
# 文件路径:hp_analysis/keywords.py from collections import Counter import jieba import jieba.analyse def top_words(chapters, stopwords, topn=50): """基于词频的朴素统计,作为基准线""" counter = Counter() for ch in chapters: for w in jieba.lcut(ch): w = w.strip() if len(w) < 2 or w in stopwords or w.isdigit(): continue counter[w] += 1 return counter.most_common(topn) def tfidf_keywords(text, topn=30): return jieba.analyse.extract_tags(text, topK=topn, withWeight=True) def textrank_keywords(text, topn=30): return jieba.analyse.textrank(text, topK=topn, withWeight=True)需要注意的是,jieba.analyse.textrank抽的是关键词,不是句子。这个词很容易让人误会,我第一次用时也以为它能直接做句子摘要,结果返回一堆词。句子级摘要需要我们自己实现,见下一节。
3.4 句子相似度与 TextRank 排序:六句话怎么选出来
TextRank 的完整流程是:句子集合 → 相似度矩阵 → 按行归一化成转移概率 → 幂迭代求稳态分布 → 按分数排序取 TopK。相似度有多种定义方式,最常用的是"基于词重叠"的版本,公式大致是:
相似度 = 两句共有词数 / (log(句1词数+1) + log(句2词数+1))分母取对数,是为了惩罚长句子的天然优势。下面给出完整实现:
# 文件路径:hp_analysis/summarizer.py import math from itertools import combinations import jieba import numpy as np def sentence_similarity(s1, s2, stopwords): w1 = {w for w in jieba.lcut(s1) if len(w) >= 2 and w not in stopwords} w2 = {w for w in jieba.lcut(s2) if len(w) >= 2 and w not in stopwords} if not w1 or not w2: return 0.0 common = len(w1 & w2) if common == 0: return 0.0 denom = math.log(len(w1) + 1) + math.log(len(w2) + 1) return common / denom if denom > 0 else 0.0 def build_similarity_matrix(sentences, stopwords): n = len(sentences) sim = np.zeros((n, n), dtype=np.float64) for i, j in combinations(range(n), 2): s = sentence_similarity(sentences[i], sentences[j], stopwords) sim[i][j] = s sim[j][i] = s # 按行归一化:每行和为 1,代表"从该句跳转到其他句"的概率 for i in range(n): row_sum = sim[i].sum() if row_sum > 0: sim[i] = sim[i] / row_sum return sim def textrank_scores(sentences, stopwords, d=0.85, max_iter=100, tol=1e-4): n = len(sentences) if n == 0: return np.array([]) sim = build_similarity_matrix(sentences, stopwords) score = np.ones(n) / n for _ in range(max_iter): new_score = (1 - d) / n + d * sim.T.dot(score) if np.abs(new_score - score).sum() < tol: score = new_score break score = new_score return score def summarize(chapters, stopwords, topk=6, max_sent_per_chapter=300): """跨章抽取:先按章分句,再统一打分,最后按原文顺序输出""" all_sents = [] for ch in chapters: sents = split_sentences(ch)[:max_sent_per_chapter] all_sents.extend(sents) # 语料过大时先按章抽样,避免 O(n^2) 矩阵撑爆内存 if len(all_sents) > 4000: step = len(all_sents) // 4000 + 1 all_sents = all_sents[::step] scores = textrank_scores(all_sents, stopwords) if len(scores) == 0: return [] top_idx = np.argsort(scores)[::-1][:topk] top_idx = sorted(top_idx.tolist()) # 还原原文顺序,读起来更连贯 return [(i, all_sents[i], float(scores[i])) for i in top_idx]三个参数要重点理解。d=0.85是阻尼系数,含义是"有 85% 的概率沿着边跳转,15% 的概率随机跳",这个默认值从 PageRank 沿用至今,一般不用改。max_iter和tol控制迭代终止,文本量小时通常十几次就收敛。max_sent_per_chapter是工程上的保护,避免某一章特别长导致整本书的句子分布失衡。
还有一个容易被忽略的点:TopK 取出来之后要按原位置重排。如果不重排,输出的六句话是乱序的,读起来完全不知所云。这个细节在多数教程里都没提,但它是"结果能不能用"的分水岭。
3.5 共现网络:把人物关系变成一张图
人物关系分析的思路很朴素:如果两个人物在同一个文本窗口里同时出现,就认为他们"有关系",共现次数越多,关系越强。用networkx建无向图,边权就是共现次数,然后算度中心度(degree centrality)和加权度,就能排出"谁最重要"。
# 文件路径:hp_analysis/network.py from itertools import combinations import networkx as nx def build_cooccurrence(chapters, characters, window=300): """按固定长度滑动窗口统计人物共现。 window 越小,越强调"同场景";越大,越强调"同章节"。 """ G = nx.Graph() for c in characters: G.add_node(c) for ch in chapters: for start in range(0, len(ch), window // 2): # 50% 重叠,减少边界截断 seg = ch[start:start + window] present = [c for c in characters if c in seg] for a, b in combinations(present, 2): if G.has_edge(a, b): G[a][b]["weight"] += 1 else: G.add_edge(a, b, weight=1) return G def centrality_table(G, topn=15): degree = nx.degree_centrality(G) weighted = {n: sum(d["weight"] for _, _, d in G.edges(n, data=True)) for n in G.nodes} rows = [(n, round(degree[n], 4), weighted[n]) for n in G.nodes] rows.sort(key=lambda x: x[2], reverse=True) return rows[:topn]window的取值直接决定图的形态。取 100 到 200 字,人物必须真的在同一段对话或场景里出现才算有边,网络会比较稀疏,反映的是"直接互动";取 500 字以上,一章里出现过的人物全都连起来,网络会变成一团,看不出结构。我的经验是从 200 开始试,画出图来看密度,太密就调小,太散就调大。
另一半是人物名单。这里必须手动维护一个CHARACTERS列表,因为通用分词器不认识"伏地魔""邓布利多"这类专有名词,很容易切成"伏地""魔"或者"邓布""利多"。解决办法有两种:把人物名加进jieba的自定义词典,或者干脆不做分词,直接用字符串in判断。本文用的是字符串匹配,因为人名足够长,误匹配概率很低,而且不受分词结果影响。
3.6 情感曲线:把"节奏"变成一条可看的线
情感分析有两种做法:基于词典的和基于模型的。基于模型(比如各种预训练情感分类器)准确率更高,但要装大依赖、跑得慢;基于词典的版本几十行就能写完,可解释性强,适合做趋势分析。
# 文件路径:hp_analysis/sentiment.py import jieba POS_WORDS = { "快乐", "高兴", "温暖", "朋友", "家", "希望", "勇敢", "笑声", "拥抱", "胜利", "喜欢", "爱", "安全", "明亮", "礼物", "庆祝", "骄傲", "信任" } NEG_WORDS = { "恐惧", "害怕", "黑暗", "死亡", "痛苦", "哭", "鲜血", "愤怒", "逃跑", "受伤", "孤独", "绝望", "冰冷", "尖叫", "危险", "失去", "恨", "谎言" } def chapter_sentiment(chapters): """返回每章的情感倾向值,0 表示偏负面,1 表示偏正面,0.5 为中性""" scores = [] for ch in chapters: words = [w for w in jieba.lcut(ch) if len(w) >= 2] pos = sum(1 for w in words if w in POS_WORDS) neg = sum(1 for w in words if w in NEG_WORDS) total = pos + neg scores.append(0.5 if total == 0 else round(pos / total, 4)) return scores这套词典是"最小可用版",实际使用时建议按你的语料扩充到几百个词。扩充方法很简单:先把正负样本各取几段,人工标一遍,再把漏掉的词补进去。要注意的是,词典法对否定句和反讽基本无解("他一点也不害怕"会被判为负面),所以结论只能看趋势,不要拿单章数值下判断。这个限制必须在报告里写清楚,否则就是过度解读。
4. 完整实战案例
4.1 步骤一:语料清洗与按章切分
先写读取和切分模块。中文小说通常用"第一章""第十二章"这类标题分章,用正则匹配即可:
# 文件路径:hp_analysis/load_text.py import re from config import CORPUS_DIR CHAPTER_PATTERN = re.compile(r"第[一二三四五六七八九十百零〇\d]+章") def split_chapters(text): matches = list(CHAPTER_PATTERN.finditer(text)) if not matches: text = text.strip() return [text] if len(text) > 200 else [] chapters = [] for idx, m in enumerate(matches): start = m.start() end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) chunk = text[start:end].strip() if len(chunk) > 200: chapters.append(chunk) return chapters def load_corpus(): chapters = [] for path in sorted(CORPUS_DIR.glob("*.txt")): text = path.read_text(encoding="utf-8", errors="ignore") chapters.extend(split_chapters(text)) print(f"[load] {path.name} -> 累计 {len(chapters)} 章") return chapters这里用finditer而不是split,是为了保留章节标题。标题里往往包含"密室""火焰杯"这类关键词,对后续词频统计有正面作用。另外errors="ignore"是为了兼容少量编码异常字符,避免整个流程因为一个乱码字节崩掉。
4.2 步骤二:词频统计与关键词提取
有了章节列表,词频和关键词就是几行调用:
# 文件路径:hp_analysis/main.py(片段) from config import OUTPUT_DIR, load_stopwords from load_text import load_corpus from keywords import top_words, tfidf_keywords, textrank_keywords chapters = load_corpus() stopwords = load_stopwords() full_text = "\n".join(chapters) words = top_words(chapters, stopwords, topn=50) with open(OUTPUT_DIR / "keywords.csv", "w", encoding="utf-8") as f: f.write("word,freq\n") for w, c in words: f.write(f"{w},{c}\n") print("TF-IDF 前 10:", tfidf_keywords(full_text, 10)) print("TextRank 前 10:", textrank_keywords(full_text, 10))跑完之后打开keywords.csv,你会看到一张按词频排序的表。如果前几名出现大量人名,说明语料主要是对话驱动;如果出现大量场景词(城堡、森林、走廊),说明描写比重高。这两种结构对应的叙事风格完全不同,是可以直接写进分析的观察。
4.3 步骤三:跑出六句话摘要
摘要部分是整个流程里最需要调参的环节。直接调用summarize(chapters, stopwords, topk=6)通常第一次结果不会太理想,常见问题是六句话全部来自同一本书或者同一类场景。解决方法是在summarize里加一个"来源去重"参数:
# 文件路径:hp_analysis/main.py(续) from summarizer import summarize picked = summarize(chapters, stopwords, topk=6) with open(OUTPUT_DIR / "summary.txt", "w", encoding="utf-8") as f: for rank, (idx, sent, score) in enumerate(picked, 1): f.write(f"[{rank}] score={score:.4f} idx={idx}\n{sent}\n\n") print(f"[{rank}] {sent}")如果希望摘要覆盖不同阶段,可以按语料前、中、后三段分别取 2 句,这样得到的六句话天然带有"开端—发展—高潮"的层次。这个做法在项目里非常实用:与其追求一个全局最优分数,不如按业务需求约束结果分布。论文摘要和小说摘要的需求就完全不同,前者要覆盖方法、数据、结论,后者要覆盖起承转合。
4.4 步骤四:人物共现网络与中心度
人物名单需要手动维护,这是无法回避的成本。下面是一份够用的初始名单:
# 文件路径:hp_analysis/config.py(续) CHARACTERS = [ "哈利", "罗恩", "赫敏", "邓布利多", "斯内普", "伏地魔", "海格", "马尔福", "麦格", "卢娜", "纳威", "金妮", "小天狼星", "多比", "韦斯莱", "卢平", "穆迪", "斯普劳特", "弗立维", "特里劳妮" ]注意名单里只放人物,像"霍格沃茨""格兰芬多""斯莱特林"这类地点和学院名要单独一个列表。如果混在一起,最后算出来的"中心度第一名"可能是霍格沃茨——它确实出现最多,但这对人物分析没有意义。
建图之后导出中心度表并画图:
# 文件路径:hp_analysis/main.py(续) import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import networkx as nx from config import CHARACTERS from network import build_cooccurrence, centrality_table G = build_cooccurrence(chapters, CHARACTERS, window=300) rows = centrality_table(G, topn=20) with open(OUTPUT_DIR / "centrality.csv", "w", encoding="utf-8") as f: f.write("character,degree,weighted_degree\n") for n, d, w in rows: f.write(f"{n},{d},{w}\n") plt.figure(figsize=(12, 9)) pos = nx.spring_layout(G, k=0.6, seed=42, weight="weight") weights = [G[u][v]["weight"] for u, v in G.edges] nx.draw_networkx_nodes(G, pos, node_size=800, node_color="#9ecae1") nx.draw_networkx_edges(G, pos, alpha=0.4, width=[min(w / 50, 4) for w in weights]) nx.draw_networkx_labels(G, pos, font_size=10, font_family="SimHei") plt.axis("off") plt.tight_layout() plt.savefig(OUTPUT_DIR / "network.png", dpi=200) print("已生成 network.png")spring_layout的seed参数一定要固定,否则每次画出来的布局都不一样,做前后对比时容易怀疑自己改错了东西。边宽用min(w/50, 4)做了一次压缩,是因为共现次数的量级差异很大,不压缩的话少数几条边会粗到糊住整张图。
4.5 步骤五:情感曲线与节奏分析
最后一步,把每章的情感值画成折线:
# 文件路径:hp_analysis/main.py(续) from sentiment import chapter_sentiment scores = chapter_sentiment(chapters) plt.figure(figsize=(14, 5)) plt.plot(range(1, len(scores) + 1), scores, linewidth=1.2, color="#3182bd") plt.axhline(0.5, color="gray", linestyle="--", linewidth=0.8) plt.xlabel("chapter index") plt.ylabel("positive ratio") plt.title("Sentiment curve by chapter") plt.tight_layout() plt.savefig(OUTPUT_DIR / "sentiment.png", dpi=200) print(f"共 {len(scores)} 章,情感均值 {sum(scores)/len(scores):.4f}")看这条曲线的方法是找"低谷"和"回升"。沿 x 轴看,如果每隔一段距离就出现一个明显的凹陷,随后又缓慢回升,说明节奏是"危机—解决—再危机"的循环结构,这种结构对读者非常友好:每次都能获得阶段性的情绪释放,同时留下新的悬念。
4.6 运行方式与输出说明
把上面的片段汇总到main.py后,一条命令跑完:
# 文件路径:命令行执行 cd hp_analysis python main.py预期会在output/目录下得到五个文件。keywords.csv是词频表,centrality.csv是人物排名,summary.txt是候选摘要句,network.png和sentiment.png是两张图。终端会打印每本书的章节数、加载进度和摘要句子,方便你实时判断进度。
需要说明的是,具体数值取决于你使用的语料版本、章节切分方式和停用词表,不同版本会导致词频有差异,所以我这里不给"某词出现 12345 次"这类硬数字。你应该以自己跑出来的结果为准,做纵向对比时也要保证三次运行用的是同一份语料和同一份停用词表,否则结论不成立。
5. 常见问题与排查思路
文本分析流程的报错往往很隐蔽——程序不崩,但结果是垃圾。下面这张表覆盖了我在实际使用中遇到的高频问题:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 关键词前 20 名全是"的""了""是" | 停用词表未加载或路径错误 | 打印len(stopwords)确认加载数量,检查stopwords.txt编码 |
| 中文图表显示成方框 | matplotlib 默认字体不含中文 | 设置font_family="SimHei"或指定已安装的中文字体路径 |
| 摘要六句话全是同一段 | 相似度阈值过低,长句互相"抱团" | 加大max_len惩罚,或按章节分段取 TopK |
MemoryError或程序卡死 | 句子数过多,n×n 矩阵爆炸 | 对句子抽样,控制总量在 4000 以内 |
| 人物网络是一团糊 | window太大,几乎人人相连 | 把 window 从 500 降到 200 甚至 100 |
| 人物名被切成碎片 | 分词器不认识专有名词 | 用字符串in匹配,或jieba.add_word加自定义词典 |
| 章节数为 0 | 分章正则与实际标题格式不符 | 打印文本前 500 字,观察标题实际写法 |
| 情感曲线几乎是一条直线 | 正负词典太小,命中率低 | 扩充词典到几百词,或改用模型方法 |
| 读取文本出现乱码 | 文件编码不是 UTF-8 | 读取时指定encoding="gbk"或用编辑器转码 |
挑三个最典型的展开说。
第一个是"结果全是虚词"。这个问题的根本原因通常是路径问题:STOPWORD_FILE指向的文件不存在,代码走了兜底逻辑,而兜底的十几个词根本不够。排查方法是打印加载到的停用词数量,正常情况下几百到上千都合理,如果只有十几个,就是表没读到。
第二个是"内存爆掉"。TextRank 的相似度矩阵是 O(n²),一万个句子就是 1 亿个浮点数,单精度也要 400MB。解决办法是控制句子总量,或者在计算时只看相邻区间内的句子对。后者在工程上叫"窗口化相似度",牺牲一点全局性换取可行性,非常常用。
第三个是"中文出图乱码"。这是 matplotlib 的经典坑,跟你的代码逻辑无关。最简单的解法是在画图前统一设置字体:
# 文件路径:hp_analysis/main.py(开头) import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "DejaVu Sans"] plt.rcParams["axes.unicode_minus"] = Falsefont.sans-serif给的是一个候选列表,matplotlib 会按顺序找第一个存在的字体,所以这么写可以同时兼容 Windows 和部分 Linux 环境。如果全都找不到,就手动指定字体文件的绝对路径。
6. 最佳实践与工程建议
6.1 语料与中间产物分层管理
做文本分析最容易踩的坑是"语料和结果混在一起"。建议严格分三层:corpus/只放原始文本,只读不改;output/只放结果文件,随时可删可重建;中间产物(分句结果、相似度矩阵)如果要缓存,单独放cache/并加入.gitignore。
这样做的好处是流程可重放。当你调整了停用词表,删掉output/重跑一遍,得到的就是一套全新结果,不会出现"一半是旧结果一半是新结果"的混乱。在真实项目里,可重放性比算法先进性更重要。
6.2 把脚本拆成可替换的流水线
main.py里不应该出现算法细节,它只负责"按顺序调用"。每个模块对外暴露一个函数,输入输出都是基本数据结构(列表、字典),这样任何一个模块都可以被单独测试和替换。比如你想把词典情感分析换成模型方案,只要新写一个sentiment_model.py,保证chapter_sentiment(chapters)这个签名一致,main.py一行都不用改。
这也是我在团队里推的一条规则:模块之间传数据,不传对象。传List[str]、Dict[str, float]这种朴素结构,比传自定义类要灵活得多,尤其在数据处理这种频繁试错的场景。
6.3 性能与内存的实际考量
整本书级别的文本分析,瓶颈几乎永远在相似度计算。几个实用的优化手段:
- 先用
max_sent_per_chapter限制每章句数,避免个别超长章节主导结果; - 对超长语料做等距抽样,而不是随机抽样,保证前后期内容都有覆盖;
- 相似度矩阵用
float32而不是float64,内存减半,精度损失可以忽略; - 如果只看关键词,完全不需要建句子矩阵,直接跑词频即可,速度快一个数量级。
另外,jieba首次加载词典会有一两秒的延迟,这是正常的。如果要批量处理很多文件,把jieba初始化放在程序入口,不要放进循环里。
6.4 结果可解释性与人工校验
自动摘要最容易犯的错是"看起来很专业,其实读不懂"。上线前必须做人工校验,建议按这个顺序检查:摘要句子单独拿出来读,是否语法完整;六句话连起来读,是否构成一个连贯的叙事;每句话是否能对应到原文的某个明确位置(我们保留了idx,就是为了这个)。
如果六句话读不通,不要急着换模型,先检查是不是抽取式方法本身的局限。这时候正确的做法是保留算法输出作为"候选池",人工重写一版。技术分析报告里,说清楚"哪部分是机器给的、哪部分是人改的",比假装全自动要专业得多。
6.5 合规与生产环境的边界
涉及文本数据时,几条底线要守住:只处理你有权使用的文本;不把原文大段输出到公开渠道;不在结果中还原可替代原著的连续内容;如果涉及用户数据,必须做脱敏并遵守最小权限原则。
如果这套流程要放进生产环境(比如给内容平台做自动摘要),还要额外考虑:摘要结果要有人工复核环节,不能直接对外发布;处理日志里不要记录原文;批量任务要在测试环境验证后再上生产,并准备好回滚方案。这些看起来和分析算法无关,但恰恰是工程落地时最容易出事的地方。
7. 数据背后的答案:六句话与它为什么好看
7.1 六句话版本
先给出结论。下面这六句话不是原文摘抄,而是在算法给出的候选句、关键词排行和人物中心度基础上重新组织的概括,每一句都对应一个可以在数据里找到支撑的结构特征:
- 一个住在楼梯下、额头带疤的男孩,在十一岁生日那天被告知自己是个巫师。
- 他走进魔法学校,交到两个真正意义上的朋友,也第一次撞上那个连名字都不愿被提起的人留下的阴影。
- 每一学年都像一次成人礼:石头、密室、囚徒、赛场、预言、洞穴、死亡圣器,代价一次比一次重。
- 长辈的死亡与一段迟到多年的记忆,把"爱与选择"这条暗线彻底翻上台面。
- 三个少年放弃学业踏上逃亡之路,靠彼此的信任和对逝者的记忆对抗恐惧。
- 最后一战里,他取胜靠的不是更强的魔法,而是不杀、承认软弱、把魔杖放回原处。
这六句话覆盖了"起点—关系—升级—转折—低谷—结局"六个节点,正好对应情感曲线的六个关键位置。这就是为什么它读起来像一条完整的线,而不是六个孤立的事实。
7.2 关键词给出的证据
跑完词频后,最值得看的不是前几名,而是第 10 到 50 名。前几名往往是人名和书名里的意象词,信息量低;中间这一段才是主题词。在我的多次运行里,稳定出现在这个区间的是三类词:关系类(朋友、家人、哥哥、同学)、情绪类(害怕、生气、高兴、担心)、选择类(决定、相信、愿意、拒绝)。
这三类词恰好对应青少年读者的三个核心关切:我在群体里的位置、我的情绪是否被理解、我能不能自己做决定。一部作品如果在这三个维度上都有高频表达,它天然就容易和这个年龄段的读者建立连接。这个结论不需要读完整本书也能验证——你只要看关键词表里这三类词是否成规模出现。
反过来,如果一部作品的关键词集中在器物、地理、术语上,那它更可能是"设定驱动"而非"人物驱动",读者记住的是世界观而不是人。这两种取向没有高下之分,但传播路径完全不同:人物驱动的作品更容易产生同人创作和情感讨论,设定驱动的作品更容易产生考据和设定集。
7.3 人物网络给出的证据
人物共现网络的结构比中心度排名更有意思。用window=200跑出来的图,通常呈现三个特征:
第一,三个主角之间形成高权重的三角形,边权远高于其他连接。这意味着他们在文本里高度绑定,几乎共享所有重要场景。对读者来说,这是"代入入口"——不管你是谁,三人组里总有一个和你相似。
第二,存在一批"高中心度但低权重"的配角。他们的度不低(和很多人有连接),但每条边的权重都不高(互动次数少)。这类人物在叙事里承担的是"信息节点"和"场景连接器"的功能,比如老师、店主、记者。他们的存在让世界显得稠密,但不抢主线。
第三,存在少数"低中心度但高权重"的人物,只和主角有强连接。这类角色通常是关键转折的触发者,出现次数不多,但每次出现都推动剧情。
这三个结构加在一起,构成了一种非常"耐读"的关系拓扑:主线清晰(三角形),世界有厚度(稠密配角),转折有力度(强连接的关键角色)。这不是靠写作技巧硬凑出来的,而是长篇幅连载自然形成的结构优势。
7.4 情感曲线给出的证据
逐章情感曲线跑出来之后,最直观的观察是波动频率。如果曲线是缓慢的长周期波动,说明作品节奏偏慢,适合沉浸式阅读;如果是高频小幅波动叠加少数深谷,说明它采用的是"章节级悬念 + 卷级危机"的双层结构。
后者对连载阅读极其友好:每一章结束时都有一个小钩子,让你愿意读下一章;每一卷结束时有一个大释放,让你觉得"这卷值了"。等连载变成整本出版、再变成电影,这种节奏依然成立,因为它的颗粒度足够细。
还有一点值得注意:曲线的最低点通常不在最后一战,而在"重要角色死亡"那几章。这说明作品的情绪重心是"失去"而不是"对抗"。对抗带来紧张,失去带来共鸣,后者的记忆留存时间要长得多。这也解释了为什么很多读者记不清战斗细节,却记得某些角色离开的那一页。
8. 总结与继续学习的方向
这套流程跑下来,你实际掌握的是四件事:怎么把杂乱的长文本变成结构化的章节列表,怎么用词频和 TF-IDF 快速摸清一份文本的主题分布,怎么用 TextRank 做可解释的抽取式摘要,怎么用图和曲线把"关系"和"节奏"变成可视化证据。这四项能力不只适用于小说,换成新闻、财报、客服记录、政策文件,流程完全一样,只需要调整停用词和关键词表。
如果继续往下走,建议按这个顺序深入:先把jieba的自定义词典用起来,解决专有名词识别问题;再学scikit-learn的TfidfVectorizer,用真正的语料库训练自己的 IDF;然后尝试句向量(如sentence-transformers)替代词重叠相似度,摘要质量会有明显提升;最后再考虑生成式摘要模型,那时候你已经有了完整的评测基线,能判断模型是不是真的比基线好。
实际项目里最需要优先关注的三个风险,我这里再强调一遍:一是语料合规,别把不该分发的文本放进仓库;二是结果可解释,任何自动摘要都要保留溯源信息(我们保留的idx就是这个作用);三是参数留痕,停用词表、window 大小、TopK 数量都要写进配置文件,不要散落在代码里,否则三个月后你无法复现自己的结论。
最后给一个可执行的小建议:先不要急着分析整部七本,拿一章、一节、甚至一段对话先跑通全流程,看到output/目录里真的出现了图和表,再去扩语料。文本分析这件事,跑通一次比读十篇教程管用得多。代码在你的机器上跑起来、结果和预期有偏差、你去调参数把它调对——这个过程本身,就是最有价值的部分。