拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

哈利波特七部曲文本分析:词频统计、情感曲线与交互可视化

哈利波特七部曲文本分析:词频统计、情感曲线与交互可视化

harrypotter03-2 这名字一眼看过去特别像哪个项目的临时备份文件夹,但它其实是我个人第三个数据分析项目(代号03)的第二次完整重构(-2)。项目内容也很直接:把《哈利波特》七部曲的英文原版文本拉下来,做词频统计、角色出场热度分析、按章节的情感曲线,最后全部塞进一个能交互的网页仪表盘里。选这个题材主要是因为英文小说文本结构稳定、词汇量够大,对NLP入门特别友好,加上我本身就是老读者,折腾起来不腻。如果你正在学文本分析或者数据可视化,又不想用那种烂大街的影评数据集练手,这个项目从数据到代码都能直接抄作业。

1. 项目代号背后的真实需求:为什么是03,又为什么是-2

1.1 前两次尝试留下的教训

03不是随手指的版本号。我在这之前做过两个文本分析练习:01是爬豆瓣短评做中文情感分析,02是统计莎士比亚十四行诗的高频词。这两个项目最大的问题不是算法多难,而是我把所有代码都堆在一份巨长的Jupyter Notebook里,数据路径全是硬编码,换个目录就要改十几处,处理中断之后想从某一步重跑只能整本跑。02尤其翻车,十四行诗语料倒是不大,但我当时对词形还原没概念,统计到的top词一半是"thou""thy""doth",看起来毫无信息量。这两个项目做完基本上就是一次性作业,没有任何复用价值。

所以03从立项开始我就定了三条红线:解析逻辑和分析逻辑彻底分开、所有中间结果落盘可复用、最终交付物必须是一个别人能打开玩的页面。正好那阵子把哈利波特英文原版又翻了一遍,七本书加起来超过一百万词,拿来当语料既够复杂又不至于撑爆笔记本,于是项目代号就这么定了。

1.2 -2重构的完整目标清单

第一次写是赶工出来的,解析、统计、画图混在一起,结果在第五本(凤凰社)上因为版本差异崩了,我干脆推翻重来。重构之后的技术栈非常朴素,没有任何需要申请GPU的东西:

环节选型理由
文本解析Python + 正则 + NLTK项目管理地道,NLTK生态成熟,不需要额外训练模型
情感分析VADER(NLTK内置)基于词典,无需标注数据,对小说文本适用性不错
数据处理Pandas合并七本书后结构化数据,后续筛选分组都靠它
可视化WordCloud + Plotly Express词云直观,Plotly自带交互
页面组装Streamlit少写三百行前端代码,组件够用,部署也简单

这套组合的另一个好处是每层都能单独替换。后来我换过词形还原策略、换过角色匹配逻辑,都不影响其他模块,这就是重构最值的地方。

1.3 为什么选英文原版而不是中译本

说句实在话,中文NLP现在也不差,但小说级长度的文本处理,英文生态依然是最好上手的:分词有现成的punkt模型,词形还原有WordNet,情感分析有VADER这种专门为英文社交文本优化的词典工具。中文译本当然读着亲切,但一来分词还要额外引入jieba,二来翻译本身会带走一部分语言特征——比如罗琳喜欢用特定词汇塑造人物口头禅,这类信号在译本里会被冲淡。所以我的选择很简单:用英文原版做分析,最后把图表标题和结论写成中文展示,这样两边好处都占到了。

2. 语料准备与文本清洗:七本书合并不像看起来那样简单

2.1 语料来源与格式转换

语料我是从正规渠道买的电子书:Kindle买过一部分,另一部分从出版商商城买完导到Calibre里。这里必须多说一句:版权问题躲不过去,书是自己的,转成文本自己分析没问题,但别把txt随便往外发。

我拿到手的基本都是epub或azw3,统一用Calibre转成纯txt做进一步处理。命令行格式大概是这样的:

ebook-convert book_1.epub book_1.txt --output-profile=default

注意Calibre的ebook-convert命令有时候不在PATH里,Mac用户通常要去/Applications/calibre.app/Contents/MacOS/目录下找,Windows用户则一般在Calibre安装目录的bin文件夹里。顺手可以写个循环把七本书一次处理完,文件名按小说顺序带序号,后面合并排序会省很多事。

2.2 预处理管线

转出来的txt相当脏:每本书开头有版权页、目录、出版社信息,结尾还有广告页和作者简介,最麻烦的是章节标题格式不统一——前三本大多是Chapter One这种全称,后几本有些章节目录变成了CHAPTER 1,还有几章是罗马数字。我用一条正则统一提取章节标记:

import re def find_chapters(text): # 覆盖 "Chapter One" / "CHAPTER 1" / "Chapter I" 等常见格式 pattern = re.compile(r'^\s*chapter\s+([a-z0-9ivx]+)\s*$', re.I | re.M) matches = list(pattern.finditer(text)) return matches

提取完章节边界之后,我把每本书的正文按段落拆开,删除掉以Title Page、Copyright、Table of Contents开头的段落,再把ASCII外的引号统一替换成标准引号。英文原文里有很多'(右单引号),如果不统一,后面分词会把"said"切成"said",小问题积累起来够你排查半天。

最后我把七本书的所有章节放在一个结构化表格里:

records = [] for book_id, book_text in enumerate(books, start=1): for ch_idx, (start, end) in enumerate(chapter_spans[book_id], start=1): raw = book_text[start:end] records.append({ "book": book_id, "chapter": ch_idx, "text": raw, "word_count": len(raw.split()), }) df = pd.DataFrame(records)

这一步做完了,后续所有分析都只跟这个DataFrame打交道,想重跑某本书的分析再也不用重新解析全文。

2.3 这块最常踩的坑

第一,转出来的txt经常带UTF-8 BOM。BOM会让第一行文本莫名其妙多一个\ufeff,正则匹配章节头的时候第一本书的第一章会离奇失踪。读文件的时候直接encoding='utf-8-sig'就能解决。

第二,epub转txt后内容顺序可能会有问题。Calibre的默认规则是先把正文前面所有副文本(标题页、献词、目录)都放在开头,我曾遇到"目录"里面包含整章的摘要文字,如果不删除目录段落,章节统计会凭空多出一倍。我的经验是做个黑名单关键词,遇到Contents、CHAPTER出现在目录区块时直接跳到正文起始标记,而不是单纯按行删。

第三,七本书的章节数是变化的:第一本17章,第五本38章,第七本只有36章但每章特别长。所以"第几章"只有在同一本书内才有意义,分析里凡是跨书对比,我都用"章节序号/该书总章数"的百分比作为横轴,否则七条曲线根本没法对齐。

3. 词频统计:真正的决策都在停用词和词形还原上

3.1 分词与词形还原

英文分词我用NLTK的punkt分词器,它比我之前用的str.split()靠谱太多,不会把句子结尾的标点留在词上:

import nltk from nltk.tokenize import word_tokenize nltk.download('punkt') sample = "It does not do to dwell on dreams." tokens = word_tokenize(sample) # ['It', 'does', 'not', 'do', 'to', 'dwell', 'on', 'dreams', '.']

分词之后我还加了一步词形还原(lemmatization),用的是WordNetLemmatizer。这一步很多人会忽略,但它对统计质量的影响极大:runs、running、ran会被归一到run,否则在哈利波特这种以过去时为主的小说里,said这种词的分词变体一大堆,根本看不出词汇特色。我的处理管线是分词→过滤标点→词形还原→统一小写。

3.2 停用词表选错了,结果完全不一样

NLTK自带的英文停用词表大概是120个常见词,但这个表是通用领域的,放到小说场景里有明显问题:像said、replied、asked这类对话动词不在停用词表里,可它们在小说里出现的频率高到能把其他词全部盖住。我单独往停用词集合里加了常见对话标签和小说高频填充词。

另外哈利波特这套书有个特殊情况:魔法词汇本身就是真正的"高频词",magic、wand、spell、Harry算不算停用词得看分析目标。我要做的是挖掘文本特色词而不是剧情梗概,所以把主角名字和纯魔法通用词放进了一个"背景词表",统计的时候过滤掉,只保留能反映写作场景和人物特征的实义词。

实际操作中我是这样处理的:

from nltk.stem import WordNetLemmatizer from nltk.corpus import stopwords lemmatizer = WordNetLemmatizer() custom_stopwords = set(stopwords.words('english')) custom_stopwords.update({ 'said', 'replied', 'asked', 'cried', 'shouted', 'hmm', 'oh', 'ha', 'let', 'us', 'would', 'could', 'shall', 'harry', 'ron', 'hermione', 'magic', 'wand', 'spell' }) def clean_tokens(tokens): result = [] for t in tokens: t = t.lower() if not t.isalpha(): continue t = lemmatizer.lemmatize(t) if t not in custom_stopwords and len(t) > 2: result.append(t) return result

3.3 从词频统计里看到的写作特征

跑完全部七本书之后,我印象最深的是高频词里出现了一批"地点性"词汇:dormitory、commonroom、corridor、hospital。霍格沃茨作为主要场景,这些词几乎每一章的分布都稳定。另一类非常突出的是感受类词汇:feel、look、somebody、nothing。这说明罗琳的叙事视角大量落在主角团内部的感知上,和传统第三人称全知视角的小说有明显差异。

词云我用的是wordcloud库,这里有个经验:WordCloud.generate_from_frequencies()直接吃Counter字典,比传入整段文本再让它自己分词要可控得多,因为你已经做完了词形还原和去停用词,再让它分一遍反而会把还原后的词搞乱。画图的时候我指定了colormap='darkorange',背景白色,出来的效果有点像蜂蜜公爵的糖果包装纸,观感还行。

WordCloud绘图时要注意一点:如果你在Linux服务器上运行,系统缺少中文字体,图里的标题如果用了中文会变成方框。我是在本地把图生成好直接导出PNG,页面端只负责展示图片,绕开了云端字体问题。这个思路在后来的部署环节帮了大忙。

4. 角色出场统计:一个正则表达式搞不定的活儿

4.1 角色名单与别名工程

词频统计只针对普通词汇,角色出场必须另建一套系统。我先从网上找了一份哈利波特主要角色清单(大概60人),然后人工补全别名映射。这一阶段的工作量远超预期,因为罗琳写对话时特别喜欢用简称和教名切换。

举个实际例子:罗恩的全名是Ronald Weasley,但正文里绝大多数时候只写"Ron",偶尔写"Ronald";赫敏有个经典发音梗"Herm-own-ninny"是克鲁克山那段,别人故意叫错的;伏地魔就更离谱了,他至少有四个称呼——Voldemort、Lord Voldemort、Tom Riddle、You-Know-Who。如果你只统计Voldemort,第六部里他在记忆球场景的出场会被严重低估。

我最后维护了一个别名归一化表:

角色唯一ID匹配词集合
voldemortvoldemort, lord voldemort, tom riddle, you-know-who, dark lord
sirius_blacksirius, sirius black, padfoot
hermionehermione, herm-own-ninny
ronron, ronald

匹配时统一小写,用正则的\b边界避免Ron匹配到wrong这种词。注意Sirius Black这个角色名里"Black"绝对不能单独用来匹配,不然"black cat""black robes"会把他刷成全书出场第一。这是我踩得最深的一个坑,教训就一句话:角色名撞上普通词的时候,宁可少算也别硬算,能接受因为使用全名导致的漏统计,也不要被形容词刷爆数据。

4.2 统计口径:计词次还是计场景

确定了匹配词集合,下一个要决策的问题是统计颗粒度。最简单的方式是数"角色名在全文出现多少次",但这样会有个严重问题:一个场景里某角色连续说三句话,每句话都喊他名字,计词次会把他出场虚高好几倍。我更关心的是"这个角色在多少个剧情场景里出现",于是把统计粒度切成句子——一个句子内只要命中角色任一个别名,就给这个角色加1次出现,不重复累加。

import re from collections import defaultdict def count_character_mentions(text, aliases): sentences = nltk.sent_tokenize(text) counter = defaultdict(int) for sent in sentences: lower = sent.lower() for char_id, patterns in aliases.items(): for pat in patterns: if re.search(r'\b' + re.escape(pat) + r'\b', lower): counter[char_id] += 1 break # 一个句子最多记一次 return counter

句子级统计还有个附带好处:我可以在计算情感分数时复用同一个句子列表,两个分析共享同一套切分结果,避免重复跑一遍NLTK的sent_tokenize,省了大概五分钟的运行时间。

4.3 数据揭示的"隐藏主角"

角色统计跑完,有几条曲线非常有意思。纳威·隆巴顿在第七部(死亡圣器)的出场次数突然暴增,之前他一直是个存在感不强的配角,但在霍格沃茨大战里成了关键人物,这个数据点完美对应了剧情转折。西弗勒斯·斯内普在第六部(混血王子)达到个人峰值,当然是因为这一部整个主线围绕他去刺杀邓布利多展开,他既是课程教授又是混血王子,戏份多到压过大部分常规角色。罗恩的出场在第七部反而明显下滑,中期有一段他和哈利闹翻独自离队,数据呈现的效果比读小说更直观。

我把每个角色按七本书的出场次数做成热力图,横轴是七本书,纵轴是角色,颜色深浅代表场景占比。这张图拿出来之后基本不需要解释,读者一眼能看到谁是主角、谁是后期发力型角色。顺带一提,这套统计里唯一排名稳定到无聊的是哈利本人,七本书每本都是出场第一,这倒也是意料之中。

5. 情感曲线:原来第七部不是最压抑的

5.1 为什么选VADER而不是深度学习模型

情感分析这一层我纠结过一阵子。当时一个大模型方案是微调BERT,但项目目标是快、稳、可解释,我实在找不到理由为了分析一部小说去标注几千条训练数据。VADER的英文全称是Valence Aware Dictionary and sEntiment Reasoner,它基于一个手工标注的情感词典,不需要训练,对短句和对话文本的效果尤其好,而这正好是小说文本的形态——大量的人物对话和短句描写。

VADER对反讽和上下文反转的识别基本等于零,比如那句经典台词"Does it hurt?"(德拉科挖苦哈利时问的)本身在词典打分上是中性的,但放在上下文里其实是恶意满满。不过我要的是整章的情感趋势而不是逐句情感标注,单句误差在大样本里会被平滑掉,章节级别的均值曲线还是可信的。

5.2 按章节聚合的完整管线

每章的情感分数我按三步计算:先把章节切成句子,再用VADER给每条句子打compound分数(范围-1到1),最后对整章句子分数求均值。为什么不直接用PassageText直接打分?因为VADER对长文本的分词粒度不够稳定,一个4000词的章节如果整体传入,lexicon的权重会被长度稀释到几乎归零,按句切分再取均值要均衡得多。

from nltk.sentiment import SentimentIntensityAnalyzer nltk.download('vader_lexicon') sia = SentimentIntensityAnalyzer() def chapter_sentiment(text): sentences = nltk.sent_tokenize(text) scores = [sia.polarity_scores(s)['compound'] for s in sentences] return { "mean_compound": sum(scores) / len(scores), "sentence_count": len(scores) }

把七本书全部跑完之后,按书内章节序号百分比对齐,用Plotly画面积图。面积图的填充色我用蓝橙渐变表示正负,鼠标悬浮能看到具体章节名和情感分数,这是纯静态图给不了的信息。

5.3 七条曲线讲出的故事

我一直以为第七部(死亡圣器)会是全书情绪最低点,但数据显示并不是。真正稳定走低的是第五部(凤凰社):乌姆里奇登场的章节段落几乎全是负分,中间有一段"韦斯莱把戏坊"短暂拉起曲线,但很快又被审判、禁闭、预言球的压抑段落压下去,整本书像一条连绵的下坡路。

第六部最戏剧化:前半截情感值都在0附近来回波动,洛哈特和魁地奇的段落还能偶尔翻正,到邓布利多被斯内普击杀那一章,曲线直接掉到全书最低,这个大坑比第七部任何一章都深。第七部则是低开高走再回落:开篇三兄弟故事那段其实相对平静,真正低落是从逃离女贞路开始,到寻找魂器那段一路在负值附近震荡,最后霍格沃茨大战前又回落,但晚间决战并没有比中途更黑暗,因为小说整体叙事已经进入"战斗节奏",削弱了情感描写的密度。

这组发现给了我一个直观的认知:情感分析和主题叙事是两套逻辑。读者主观觉得"最黑暗的一本书"和在词法层面"情感词最负面的一本书"不一定重合,这种偏差本身就是值得写进结论里的分析价值。如果你的目标是得出"第七部最压抑"的直观结论,VADER给不了,它给的是文本层的事实,剩下的是读者自己解释。

6. 交互页面的搭建与落地:Streamlit是我最不后悔的选型

6.1 页面结构与核心代码

项目收尾要面对"别人怎么玩"的问题。我对比过三种方案:Flask+ECharts要写大量前端模板,Dash的路由体系有点重,Streamlit是纯Python写交互组件的方式,十分钟就能出一个原型。最终选了Streamlit,核心就一句话:我用写后端的思路写页面,它把前端部分包办了。

页面结构我设计成三块:左侧侧边栏选书,主区域切三个标签页(词云、角色热度、情感曲线)。数据加载做了缓存装饰器,因为七本书合并后的DataFrame和预计算好的情感分数表加起来有几十MB,每次点按钮重算会卡到用户崩溃——加上@st.cache_data之后,只有第一次启动会慢一点,之后所有交互都是秒回:

import streamlit as st st.set_page_config(page_title="harrypotter03-2", layout="wide") @st.cache_data def load_data(): df = pd.read_parquet("hp_corpus.parquet") return df @st.cache_data def load_sentiment(): return pd.read_parquet("hp_sentiment.parquet")

核心页面的切换逻辑大概是这样:侧边栏用selectbox让用户选书或选"全部七本",词云tab直接展示之前导出的PNG图片,角色热力图用Plotly的imshow画成表格形式,情感曲线tab在plotly_chart里支持拖拽缩放。Streamlit的tabs组件省了太多事情,三个分析模块之间完全隔离,各自的代码块改动互不影响。

整个预计算我做了两次落盘:一次是hp_corpus.parquet存七本书的章节级统计数据,一次是角色匹配和情感分析的最终结果。这样页面启动完全不碰原文,也就意味着部署环境里不需要带上NLTK模型,体积和加载速度都友好很多。

6.2 性能优化和部署的几个问题

第一次跑Streamlit的时候,页面启动花了将近二十秒,原因是我在加载环节直接读了txt原文然后现场做词形还原。后来我意识到这种场景根本不需要"全流程重跑",把解析和分析的结果直接序列化成parquet,页面只负责读取和渲染,启动时间压缩到两秒以内。这和当初"解析逻辑和分析逻辑分离"的目标一脉相承,属于一开始就设计对了,后来果然少走了回头路。

部署我选的是Streamlit Community Cloud,连上Git仓库就能自动部署,免费额度对个人项目足够用。真正遇到的坎是数据文件的大小:GitHub仓库对单文件有限制,我最初把parquet文件直接推上去,结果仓库体积超标、部署失败。解决方案是改用数据文件托管策略,把生成好的parquet放在Release附件里,页面加载时用requests下载到缓存目录。这招虽然绕了一圈,但部署之后页面表现和我本地完全一致。

6.3 这个页面最终变成了什么

页面做完之后给我自己的使用体验其实超过预期。最惊喜的是没有任何代码基础的朋友也能自己玩,他们不需要理解VADER是什么,只需要在侧边栏选一本书,鼠标在情感曲线上划过去就能看到"这是第六部小天狼星之死那段,情绪分暴跌到-0.44"。这种把抽象分数还原成具体情节的感受,是Pandas控制台里看CSV完全给不了的。

当然这个页面没做到尽善尽美,比如词云tab目前只展示全局词频,不能按单本书切换;角色热力图也只覆盖了top20角色,冷门角色的出场情况还没纳入。这些问题都很明确,但作为03-2交付物,它已经达到了最开始定义的三条红线:能复现、能复用、能交互。

最后再分享一个做这类型项目的心得:给项目起一个有辨识度的代号,看似只是命名习惯,实际上影响很大。harrypotter03-2这个名字让我在做完每一个阶段之后都能快速想起"这个文件属于哪个版本、解决的是哪一层的需求",比final_final_v2.ipynb有意义得多。后续我大概率会在这个项目上加一层人物共现网络——把同一段落中同时出现的角色连成一张动态关系图,看七本书里人物关系网的演变。这个扩展在现在的数据层结构上不需要改太多代码,因为预处理和分析层已经可以是独立重跑的。

返回列表