
1. 语料准备与文本读取为什么选《红楼梦》以及怎么拿到干净文本1.1 选《红楼梦》作为语料库的三个理由很多朋友问我做文本分析为什么不选新闻语料、微博评论或者知乎问答偏偏拿古典小说开刀其实这里有几个非常实际的考量。首先《红楼梦》的文本量足够大。全文大概73万字前后共120回不管是做整本书的词频统计、章节级别的叙事节奏分析还是人物关系的共现网络构建这个体量都能撑得起。拿几百字的短文练手很多算法跑起来根本没感觉词的分布规律、文本拆分粒度的影响这些核心问题都暴露不出来。其次《红楼梦》的文本质量高度稳定。人民文学出版社等权威版本的电子文本在网络上有广泛流传相比爬虫抓取的那种掺杂大量标签、广告、评论的网页文本小说正文的干净程度要高好几个量级。这就让整个分析链路可以集中在文本处理算法本身而不是被数据清洗消耗大量精力。第三《红楼梦》是一部叙事结构复杂的文学作品。人物众多、事件交错、隐喻丰富这恰好给关键词挖掘提供了足够多的考点比如人名、地名、器物名、诗词意象混在一起怎么让算法区分哪些词是真正的叙事关键词哪些只是高频的虚词这比分析一篇技术文档有意思得多。1.2 获取语料与预处理细节拿到一份TXT格式的《红楼梦》全文之后第一件事不是写分析代码而是先确认编码。这个坑我踩过一次就长记性了。网上流传的版本有不少是GBK或GB2312编码而Python 3默认读文件用的是UTF-8两者对不上直接报UnicodeDecodeError。稳妥的做法是先用chardet或者charset-normalizer库检测编码再读取文件import chardet with open(hongloumeng.txt, rb) as f: raw_data f.read() result chardet.detect(raw_data) print(result)看到编码结果之后再统一转换成UTF-8格式或者直接在读取时指定编码with open(hongloumeng.txt, r, encodinggb18030, errorsignore) as f: text f.read()我这里用gb18030而不是gbk是因为gb18030是GBK的超集兼容性更好某些生僻字在GBK下会解码失败换成gb18030基本就稳了。读取之后还有一个很容易被忽略的步骤——清理不可见字符和章节碎片。TXT版本通常会在每一回标题前后夹杂大量换行、空格、全角空格如果直接拿去分词这些干扰虽然不会显著改变词频排名但会影响后续的章节定位和句子切分。我会用一个简单的清洗函数import re def clean_text(text): # 统一换行符 text text.replace(\r\n, \n).replace(\r, \n) # 去掉全角空格统一为普通空格 text text.replace(\u3000, ) # 多个连续空白压缩为换行 text re.sub(r[ \t], , text) # 连续三个以上换行压缩为两个 text re.sub(r\n{3,}, \n\n, text) return text.strip()注意这一步不要做过度清洗。正文里的中文标点句号、逗号、问号、感叹号必须保留它们是句子拆分的核心标记人名、地名里的断行也要保留原貌别顺手把跨行的词给合并丢了。2. 文本拆分的核心逻辑从整本书到可计算的最小单元2.1 章节级拆分守住叙事边界《红楼梦》的结构天然以回为单位每回一个相对完整的叙事段落。按章节拆分的目的有两个一是让后续分析具备位置感知道某个关键词主要出现在哪一回而不是只知道全书总词频二是给叙事节奏分析提供基础比如后四十回和前八十回的词汇差异这是红学研究里一个经典话题。拆分方法本身不复杂关键在于怎么精准定位回目标题。常规TXT版的标题格式一般像第一回 甄士隐梦幻识通灵 贾雨村风尘怀闺秀但有的版本会用第 一 回或者第一回后面跟空格格式不太统一。我建议用正则做两层匹配先匹配主模式再兜底chapter_pattern re.compile(r第[零一二三四五六七八九十百]回\s*(.))遍历全文把每回标题的起始位置记录下来然后按位置切块matches list(chapter_pattern.finditer(text)) chapters [] for i, match in enumerate(matches): start match.start() end matches[i 1].start() if i 1 len(matches) else len(text) chapter_text text[start:end] chapters.append(chapter_text)这里有个小细节标题本身包含第X回我用(\s*)允许回目和标题之间有零个或多个空格这样能兼容第一回 甄士隐...和第一回甄士隐...两种写法。拆分完之后顺手打印一下每一回的字数分布做个快速体检chapter_lengths [len(c) for c in chapters] print(max(chapter_lengths), min(chapter_lengths), sum(chapter_lengths) / len(chapter_lengths))正常情况下《红楼梦》每回大概在5000到8000字之间回目字数差异不大。如果你发现某回字数突然只有几百多半是章节正则漏匹配了这时候需要手动检查那一带的原文格式。2.2 句子级拆分用正则而非盲目split句子拆分是文本分析里最容易被轻视的一步。拿英文做文本分析按空格直接split()就行因为英文的词边界天然存在。中文不一样词和词之间没有空格句子边界也不是单纯的句号问题——中文的句末标记包括句号、问号、感叹号、省略号有时候还有分号和冒号。如果只是按句号拆遇到《红楼梦》里大量的人物对话就会出问题。比如黛玉笑道你说你会过目成诵难道我就不能一目十行这里冒号和双引号引出的对话内容如果只按句号拆整个引号内的一句话被截断前后语义分家了。更麻烦的是省略号它由六个点组成正则匹配的时候得按连续字符处理。我的做法是自定义一个句子切分函数优先处理引号内的完整对话再切分常规句子def split_sentences(chapter_text): # 先把引号内容作为一个整体保护起来避免内部句号被错误切分 quoted re.findall(r[\u201c\u2014\n]\u201c[^\u201d]*\u201d, chapter_text) # 简单做法先把双引号内容临时替换成占位符 placeholders [] def repl(match): placeholders.append(match.group(0)) return \x00 text_with_placeholders re.sub(r\u201c[^\u201d]*\u201d, repl, chapter_text) # 对占位后的文本按句末标点切分 parts re.split(r(?[。]), text_with_placeholders) # 恢复引号内容 result [] for part in parts: part part.replace(\x00, placeholders.pop(0)) result.append(part) return [p.strip() for p in result if p.strip()]这个策略并不完美双引号里的嵌套引号、连续对话场景还是会切错但做文本层级的关键词统计足够了。如果你要做多模态情感分析那种精细活建议直接用LTP或者jieba自带的analyse模块底层已经处理好了分句问题。分词层面我常用jieba但必须配合自定义词典。这个下个小节展开说。2.3 词语级拆分jieba分词与自定义词典词语拆分是关键词挖掘的前置条件直接决定后续所有统计的质量。jieba是Python生态里最常用的中文分词库默认词典覆盖了大部分现代汉语词汇但古代白话文里的说法它未必认识。《红楼梦》里很多词在现代汉语里已经很少用了比如菱花茜雪茗烟雁翅步斗这些词如果不处理jieba会把它们拆成单字或者奇怪组合词频统计就没有意义了。我的处理套路分三步。第一步加载自定义词典把所有主要人物名、地名、器物名、常见双字词放进去设定词频参数import jieba custom_words [ 贾宝玉, 林黛玉, 薛宝钗, 王熙凤, 史湘云, 妙玉, 秦可卿, 贾母, 王夫人, 刘姥姥, 袭人, 晴雯, 麝月, 秋纹, 碧痕, 蘅芜苑, 潇湘馆, 怡红院, 秋爽斋, 稻香村, 通灵宝玉, 冷香丸, 护官符, 金陵十二钗 ] for word in custom_words: jieba.add_word(word)jieba.add_word()设置了词频参数默认0一般直接用就行。如果某个词在语料里出现次数很高可以把词频调大让分词器更容易把它当作整体。第二步加载停用词表。停用词是关键词挖掘里的反向词典像之乎者也的了么这个那个这类高频但无实义的词必须在统计前剔除。网上有现成的中文停用词表我用的是一份整合了哈工大和百度停用词表的版本大概两千多个词。停用词过滤代码with open(stopwords.txt, r, encodingutf-8) as f: stopwords set(line.strip() for line in f) def filter_stopwords(words): return [w for w in words if w not in stopwords and len(w) 1]第三步按回切分后逐句分词最后把词频累加from collections import Counter word_counter Counter() for chapter in chapters: sentences split_sentences(chapter) for sent in sentences: words jieba.lcut(sent) filtered filter_stopwords(words) word_counter.update(filtered)这里有一点经验之谈jieba默认的分词模式是精确模式适合做关键词统计。不要用全模式那不是给人看的是给搜索引擎召回用的会产出大量冗余词块。3. 关键词挖掘词频之外还有哪些指标值得关注3.1 词频第一印象与它的欺骗性做完分词和词频统计之后你大概会得到类似这样的排名贾宝玉、林黛玉、王熙凤、薛宝钗、老太太、太太、姑娘、宝玉、黛玉……这结果不能说错但信息量太低了。所有人物名的词频天然就高这是叙事小说决定的不需要算法也能猜出来。真正值得观察的是那些词频排名在一百名上下、却能反映叙事主题的词。比如梦泪笑诗花玉金陵大观园这些词的单次出现频率不高但它们的分布和变化能说明文本的调性。如果单纯按词频排序这类词很容易被淹没在两千个高频词里。所以关键词挖掘不能只看词频至少要有两个指标做交叉验证TF-IDF和TextRank。3.2 TF-IDF词频之外带权重TF-IDF的核心思想很简单一个词在本文档中出现得多TF高但在整个语料库的其他文档中出现得少IDF高说明这个词对本文档有区分度更像是关键词。反过来像宝玉这种在所有章节里都高频出现的词IDF值就很低重要性会被降下来。jieba的analyse模块已经封装好了TF-IDF关键词提取直接调用就行import jieba.analyse for i, chapter in enumerate(chapters[:5]): keywords jieba.analyse.extract_tags(chapter, topK10, withWeightTrue) print(f第{i1}回关键词, keywords)extract_tags返回带权重的词权重可以理解为综合TF-IDF得分。这里要注意一个参数——allowPOS也就是允许提取的词性。默认不设置分词后的所有词都会参与计算实际使用中我会限定allowPOS(ns, n, vn, v)把动词和名词保留形容词和副词一般不具备关键词价值。另一个容易踩的坑是IDF语料库的问题。jieba默认用的IDF语料是通用文本包含新闻、百科等现代文内容古代白话文的用词在默认IDF里统计不准确。严谨的做法是在分词后用当前《红楼梦》语料重新计算IDFfrom sklearn.feature_extraction.text import TfidfVectorizer corpus [ .join(jieba.lcut(ch)) for ch in chapters] vectorizer TfidfVectorizer(token_patternr(?u)\b\w\b) tfidf_matrix vectorizer.fit_transform(corpus)用TfidfVectorizer自己算好处是词表完全贴合《红楼梦》语料缺点是sklearn的CountVectorizer默认会做小写转换中文不影响但个别英文字符会被误拆。实际跑下来sklearn版本的结果比jieba默认版本更贴合正文语境。3.3 TextRank让关键词互相投票TF-IDF本质上是统计模型不考虑词与词之间的语义关系。TextRank的思路更像是一种投票机制它把文本构造成一张图词是节点词在同一个窗口内共现就建立边然后通过类似PageRank的迭代算法给每个词打分。一个词如果周围都是重要词它自己也会变得重要。jieba同样支持TextRankkeywords_tr jieba.analyse.textrank(chapter, topK10, withWeightTrue)TextRank有个参数span默认是5表示共现窗口大小即左右各5个词以内算共现。我在《红楼梦》语料上试过span调成3的时候人名和地名更容易被提起因为近窗口内的词对共现更敏感调成8的时候一些叙事情节词比如笑道来了会挤进排名。这个参数不是越大越好。我把两个算法的前20名关键词放在一起对比能明显看出差异回目TF-IDF Top5TextRank Top5第三回黛玉、贾母、宝玉、王夫人、黛玉笑道黛玉、宝玉、贾母、王夫人、来了第二十七回黛玉、宝钗、宝玉、凤姐、探春黛玉、宝钗、凤姐、众人、笑道第六十六回尤三姐、贾琏、尤二姐、湘莲、凤姐尤三姐、贾琏、尤二姐、剑、凤姐TF-IDF更偏好名词实体TextRank更容易把虚化动词来了笑道带出来。两者结合看一个看提到谁一个看做了什么信息互补。实际分析时我的习惯是各取前20做并集去重得到一份综合关键词集合。3.4 词频走势让关键词动起来光看静态排名不够关键词更值钱的信息藏在变化里。比如黛玉这个词在全书各回的词频如果画成曲线前八十回波动明显后四十回陡然降低这本身就是一个可以讨论的现象。计算单回词频并做归一化def keyword_frequency_per_chapter(keyword, chapters): freq [] for ch in chapters: words jieba.lcut(ch) count sum(1 for w in words if w keyword) # 这里不做全词频归一化因为各回字数基本接近 freq.append(count) return freq daiyu_freq keyword_frequency_per_chapter(黛玉, chapters)归一化问题很多人忽略我得提醒一句如果各回字数差异大必须用词频除以该回总字数再做比较否则长的章节天然词频高走势图会失真。4. 结果可视化词云、词频走势与人物关系初探4.1 词云实操与字体坑词云是文本分析最直观的输出也是最容易出效果、最容易出问题的环节。wordcloud库配合matplotlib基本几十行代码就能出图。from wordcloud import WordCloud import matplotlib.pyplot as plt word_freq_dict dict(word_counter) wc WordCloud( font_pathsimhei.ttf, background_colorwhite, width800, height600, max_words200, max_font_size100, random_state42 ) wc.generate_from_frequencies(word_freq_dict) plt.figure(figsize(10, 8)) plt.imshow(wc, interpolationbilinear) plt.axis(off) plt.show()最大的坑就是font_path。wordcloud默认字体不支持中文不指定中文字体直接生成画面上全是一堆方块。必须到系统中找中文字体文件Windows下是C:/Windows/Fonts/simhei.ttfmacOS下可以用/System/Library/Fonts/PingFang.ttc。如果你在服务器上跑记得先用fc-list查一下可用字体没有中文字体就下载一个装到/usr/share/fonts/下。第二个坑是掩膜mask。很多人喜欢让词云生成人物轮廓或者葫芦形状需要scipy读图片数组再传给WordCloud的mask参数。实测下来掩膜图片如果空白区域太少词云的词语会被压缩得很难看最好选大面积白色区域的纯色剪影图。4.2 章节词频走势看叙事节奏词频走势图用matplotlib画线图即可但要注意横坐标的处理。《红楼梦》120回直接用1~120作为横轴图太长文字标注挤在一起。我的做法是每十回取一个坐标点或者直接画完整曲线但只在偶数回标注xticks。plt.figure(figsize(14, 5)) x range(1, 121) plt.plot(x, daiyu_freq, label黛玉) plt.plot(x, baoyu_freq, label宝玉) plt.xticks(range(1, 121, 10)) plt.legend() plt.show()可以看到黛玉词频在后四十回断崖式下跌宝玉在整个后半段明显稀疏。这不是文本内容的绝对判断但确实值得作为线索去读文本——后四十回的叙事重心从大观园儿女日常转移到家族衰败人物的出场频次自然变化。我还会把同一人物在不同时期的词频变化做差分观察突降点。比如凤姐在第四十三回、第六十八回、第一百零五回附近都有明显波峰这些正好是她的重头戏章节。换句话说词频走势能帮你快速定位一本书的情节节点不用一章一章去翻原文。4.3 人物共现分析构建一个简单的共现网络关键词挖掘再多走一步就是共现分析。这个思路在文本分析里很常见如果两个人物在同一个句子或段落里同时出现说明他们之间存在叙事关系把所有这样的共现关系统计出来就能构建一个人物关系网络。代码思路不复杂from collections import defaultdict co_occurrence defaultdict(int) window_size 20 # 句子窗口内 for ch in chapters: sentences split_sentences(ch) for sent in sentences: words jieba.lcut(sent) characters_in_sent set() for name in character_names: if name in sent: characters_in_sent.add(name) for name1 in characters_in_sent: for name2 in characters_in_sent: if name1 ! name2: co_occurrence[(name1, name2)] 1这里有个关键点人物名可能有多重叫法宝玉和贾宝玉要统一成贾宝玉黛玉和林黛玉统一成林黛玉。建议在预处理阶段就做一个别名映射表。共现网络的可视化可以用networkx加matplotlib也能用Gephi做力导向布局。如果只想快速看一眼networkx画个带权重的图就够用了核心代码不复杂import networkx as nx G nx.Graph() for (name1, name2), weight in co_occurrence.items(): if weight 10: G.add_edge(name1, name2, weightweight) nx.draw(G, with_labelsTrue, node_size100, font_size8) plt.show()实测里贾母、王夫人、凤姐这些枢纽型人物会形成中心节点而一些边缘角色只和少数人连线。这样一张图放在文章里比一百个词汇列表直观得多。5. 我在实操中踩过的坑与最终心得5.1 编码与格式不显眼的头号杀手我在第一节就说到了编码问题但这里要再强调一次。不只是读取时要注意写入CSV、写入图表时同样要指定UTF-8编码。用pandas写CSV时如果不带encodingutf-8-sigExcel打开会乱码这个坑我浪费了两个小时。import csv with open(keywords.csv, w, encodingutf-8-sig, newline) as f: writer csv.writer(f) writer.writerow([keyword, weight]) writer.writerows(keyword_weight_list)5.2 自定义词典与停用词表的维护第2.3节提到的自定义词典不是一次性维护的。每跑一轮我都要看输出结果里有没有不该出现的碎片词比如宝玉道黛玉笑这类的组合说明分词器把动词和人物名粘在一起了。发现问题就立刻add_word或del_word词典是迭代出来的不是一开始就配好的。停用词表也建议针对文学语料补充。通用停用词表里有些词在《红楼梦》里反而是有效信息比如老太太这种称谓如果被当成泛称词去掉关键词分析就失真了。我最后保留了一组文学语料专属的非停用词清单老太太、太太、姑娘、老爷、小厮、嬷嬷、丫鬟。凡是这些词出现在停用词表里直接剔除。5.3 别迷信默认参数jieba默认的extract_tags参数好用是好用但你要清楚它在做什么。topK20是前20withWeightTrue返回权重allowPOS控制词性。这三个参数我几乎每次都调。TextRank的span到底取多少也建议自己跑一组对比。我在《红楼梦》上做了一组实验把span分别设为3、5、8输出排序前十的关键词发现span5默认值时人物名占比最高span8时叙事情节词混入较多。如果你做的是一般性的文章关键词提取默认值够用但如果你想分析叙事视角可以把span调大试试。5.4 我的个人工作流磨到最后我形成了一套固定流程。第一步读取文本检测编码清洗格式第二步按回目正则拆分章节按标点引号拆分句子第三步先跑分词维护自定义词典和停用词表至少迭代两轮第四步分别用TF-IDF和TextRank提取关键词做并集和对比第五步对目标关键词做章节级走势统计最后一步生成词云和共现网络辅助写作和汇报。这套流程换个语料也能用。比如你还想分析《三国演义》的人物关系只需要换掉自定义词典里的书名和人名停用词表微调一下就行。或者说你想做歌词文本分析把《红楼梦》的章节替换成歌曲列表把回目正则替换成歌曲标题匹配规则后面的关键词挖掘和词频走势逻辑完全一样。这也是为什么我一直建议身边的人用文学名著练手——你学到的是一整套方法而不是只会在某一个特定文本上跑通的死代码。最后分享一句话文本分析的价值不在于算出贾宝玉出现了多少次而在于这些数字能帮你看清那些只靠阅读很难察觉的结构和模式。Python只是工具真正的分析视角还是你自己的。