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", encoding="gb18030", errors="ignore") 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", encoding="utf-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, topK=10, withWeight=True) print(f"第{i+1}回关键词:", keywords)extract_tags返回带权重的词,权重可以理解为综合TF-IDF得分。这里要注意一个参数——allowPOS,也就是允许提取的词性。默认不设置,分词后的所有词都会参与计算;实际使用中我会限定allowPOS=('ns', 'n', 'vn', 'v'),把动词和名词保留,形容词和副词一般不具备关键词价值。
另一个容易踩的坑是IDF语料库的问题。jieba默认用的IDF语料是通用文本,包含新闻、百科等现代文内容,古代白话文的用词在默认IDF里统计不准确。严谨的做法是在分词后,用当前《红楼梦》语料重新计算IDF:
from sklearn.feature_extraction.text import TfidfVectorizer corpus = [" ".join(jieba.lcut(ch)) for ch in chapters] vectorizer = TfidfVectorizer(token_pattern=r"(?u)\b\w+\b") tfidf_matrix = vectorizer.fit_transform(corpus)用TfidfVectorizer自己算,好处是词表完全贴合《红楼梦》语料,缺点是sklearn的CountVectorizer默认会做小写转换,中文不影响但个别英文字符会被误拆。实际跑下来,sklearn版本的结果比jieba默认版本更贴合正文语境。
3.3 TextRank:让关键词互相投票
TF-IDF本质上是统计模型,不考虑词与词之间的语义关系。TextRank的思路更像是一种"投票机制":它把文本构造成一张图,词是节点,词在同一个窗口内共现就建立边,然后通过类似PageRank的迭代算法给每个词打分。一个词如果周围都是重要词,它自己也会变得重要。
jieba同样支持TextRank:
keywords_tr = jieba.analyse.textrank(chapter, topK=10, withWeight=True)TextRank有个参数span,默认是5,表示共现窗口大小,即左右各5个词以内算共现。我在《红楼梦》语料上试过,span调成3的时候,人名和地名更容易被提起,因为近窗口内的词对共现更敏感;调成8的时候,一些叙事情节词(比如"笑道""来了")会挤进排名。
这个参数不是越大越好。我把两个算法的前20名关键词放在一起对比,能明显看出差异:
| 回目 | TF-IDF Top5 | TextRank 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_path="simhei.ttf", background_color="white", width=800, height=600, max_words=200, max_font_size=100, random_state=42 ) wc.generate_from_frequencies(word_freq_dict) plt.figure(figsize=(10, 8)) plt.imshow(wc, interpolation="bilinear") plt.axis("off") plt.show()最大的坑就是font_path。wordcloud默认字体不支持中文,不指定中文字体直接生成,画面上全是一堆方块。必须到系统中找中文字体文件,Windows下是C:/Windows/Fonts/simhei.ttf,macOS下可以用``/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, weight=weight) nx.draw(G, with_labels=True, node_size=100, font_size=8) plt.show()实测里,贾母、王夫人、凤姐这些"枢纽型"人物会形成中心节点,而一些边缘角色只和少数人连线。这样一张图放在文章里,比一百个词汇列表直观得多。
5. 我在实操中踩过的坑与最终心得
5.1 编码与格式:不显眼的头号杀手
我在第一节就说到了编码问题,但这里要再强调一次。不只是读取时要注意,写入CSV、写入图表时同样要指定UTF-8编码。用pandas写CSV时如果不带encoding="utf-8-sig",Excel打开会乱码,这个坑我浪费了两个小时。
import csv with open("keywords.csv", "w", encoding="utf-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参数好用是好用,但你要清楚它在做什么。topK=20是前20,withWeight=True返回权重,allowPOS控制词性。这三个参数我几乎每次都调。
TextRank的span到底取多少,也建议自己跑一组对比。我在《红楼梦》上做了一组实验,把span分别设为3、5、8,输出排序前十的关键词,发现span=5(默认值)时人物名占比最高,span=8时叙事情节词混入较多。如果你做的是一般性的文章关键词提取,默认值够用;但如果你想分析叙事视角,可以把span调大试试。
5.4 我的个人工作流
磨到最后,我形成了一套固定流程。第一步,读取文本,检测编码,清洗格式;第二步,按回目正则拆分章节,按标点引号拆分句子;第三步,先跑分词,维护自定义词典和停用词表,至少迭代两轮;第四步,分别用TF-IDF和TextRank提取关键词,做并集和对比;第五步,对目标关键词做章节级走势统计;最后一步,生成词云和共现网络,辅助写作和汇报。
这套流程换个语料也能用。比如你还想分析《三国演义》的人物关系,只需要换掉自定义词典里的书名和人名,停用词表微调一下就行。或者说你想做歌词文本分析,把《红楼梦》的章节替换成歌曲列表,把回目正则替换成"歌曲标题"匹配规则,后面的关键词挖掘和词频走势逻辑完全一样。这也是为什么我一直建议身边的人用文学名著练手——你学到的是一整套方法,而不是只会在某一个特定文本上跑通的死代码。
最后分享一句话:文本分析的价值不在于算出"贾宝玉出现了多少次",而在于这些数字能帮你看清那些只靠阅读很难察觉的结构和模式。Python只是工具,真正的分析视角还是你自己的。