☰
TF-IDF提取《红楼梦》回目关键词:中文古白话文本分析实战
2026/10/16 17:50:12 网站建设 项目流程

做文本分析这些年,我越来越觉得中文语料的坑比想象中多。前阵子接了个活儿,把《红楼梦》一百二十回按回目做核心关键词提取,用来快速浏览每回到底讲了啥,顺便看看能不能发现一些隐藏的叙事线索。标题里写的“基于 TF-IDF”,听起来是个老掉牙的方法,但真拿它去处理古白话小说,尤其是《红楼梦》这种人物多、场景切换快、虚实交织的文本,还是会遇到一堆预料之外的麻烦。这篇就复盘一下整个实战过程,包括我踩过的坑、调过的参数,以及最后怎么让结果看起来像人话而不是术语堆砌。

1. 项目概述:为什么选 TF-IDF 和《红楼梦》分回文本

1.1 核心需求:从三十万字的章回体里快速定位每回焦点

《红楼梦》全书一百二十回,每回少则五六千字,多则上万字,通读一遍费时费力。文本挖掘的第一步通常是“降维”,先把每回的主要内容压缩成几个关键词,让人一眼看出这回的主线是什么。比如某回如果提到“凤姐”“操办”“丧事”,大概率能猜到是协理宁国府的情节;如果出现“黛玉”“焚稿”“断痴情”,那基本是高鹗续书里的名场面。这个需求在实际场景里很有用——做文学研究的人可以用它辅助标注,做在线阅读平台的编辑可以用它生成章节摘要,普通读者用它当导读也方便。

但“提取核心关键词”这件事,难点不在算法本身,而在“什么算核心”。在《红楼梦》这种文本里,高频人名、地名、器物名会淹没真正的动作和事件,比如贾母、宝玉这两个词几乎每回都会出现,但对“这一回”的区分度极低。所以做这件事的核心思路不是单纯统计词频,而是找一个能平衡“词频”和“区分度”的指标,TF-IDF就是这么个东西。

1.2 方案选型:为什么没用 LDA 也没用 TextRank

在动手之前肯定要纠结一下方案。市面上常见的文本关键词提取无外乎三种路线:统计路线(TF-IDF、词频)、图路线(TextRank)、主题模型路线(LDA)。我当时选 TF-IDF 而不是另外两个,原因有三条。

第一,LDA 适合做“主题归纳”而不是“关键词提取”。LDA 输出的是词的概率分布,拟合的是“这篇文章在谈论什么主题”,粒度太粗。比如某回模型算出“主题1:家庭事务”,你还是不知道具体是哪个事件。而且 LDA 需要人为指定主题数 k,调起来主观性太强,不同 k 值结果天差地别,不太适合做批量自动化的章节导读。

第二,TextRank 的效果受文本长度影响很大,而且更吃图构建时的共现窗口参数。分回文本长度不一,从 3000 到 9000 字都有,固定窗口会把短章和长章的连边数量搞得不可比,导致有些回目提取出的词特别散乱。TF-IDF 对文本长度没有那么敏感,因为它本质上是在每个文档内部做词频归一化,再乘逆文档频率。

第三,TF-IDF 在 sklearn 里实现成熟、参数透明,跑批处理非常稳。配合 jieba 分词,百来行代码就能出一个基线结果,后续调优的空间也大。说白了,先用一套可解释性强的基线方法把流程打通,后面再叠加 TextRank 或者词向量做融合,心里才踏实。

最终我的技术栈定成了:Python + jieba 分词 + sklearn 的 TfidfVectorizer + pandas 做结果整理。数据源用的是网上公开的《红楼梦》全文 TXT,按回目切分好。整个流程跑完,一百二十回每个回目输出 8-10 个核心关键词,大概花了不到二十分钟,效果比预想的好。

2. TF-IDF 核心原理与中文文本预处理

2.1 一个朴素但有效的基础假设:词的重要性 = 在本文中多 + 在全书中少

先从原理说起。TF-IDF 由两部分组成,TF 是词频,IDF 是逆文档频率。前者衡量词在当前文档中出现的频繁程度,后者衡量词在整个语料库中出现文档数量的倒数,取对数。公式是:

TF-IDF(t, d) = TF(t, d) × IDF(t)

其中 IDF(t) = log(N / (1 + DF(t))),N 是总文档数,DF(t) 是包含词 t 的文档数。

这里的关键思想用大白话讲就是:一个词如果在某一回里反复出现,但在其他回目里很少出现,那它大概率是这一回的特征词。反过来,像“说道”“只见”“贾母”这种每回都有的词,DF 值很大,IDF 接近 0,权重被压得很低,自然排不上号。

但《红楼梦》的文风造成了一个现象:很多高频动词在全书里分布均匀,比如“笑道”“不敢”“起身”,这些词在 TF 那一步得分不低,但 IDF 会把它拉下来。真正能体现“分回焦点”的往往是具体名词——人名、物品、事件词。比如“猜谜”“螃蟹”“诗社”这类,在某几回里高度集中,IDF 会给它们很高的权重。

2.2 中文分词难点:古白话、人名歧义和词表构建

中文和英文最大的不同是分词。英文天然按空格切词,中文需要先分词,分词结果直接决定了关键词提取的上限。jieba 默认词典是面向现代汉语的,处理《红楼梦》这种古白话会有几个明显问题。

其一,古白话专名词在默认词典里缺失,比如“栊翠庵”“妙玉”“蘅芜苑”这类地名、人名,jieba 可能会切成“栊翠”“庵”“妙玉”,甚至“蘅芜”“苑”这种碎片,导致词频统计失真。

其二,同一个人名可能有多种称呼。贾宝玉在文里可以是“宝玉”“宝二爷”“怡红公子”,林黛玉可以是“黛玉”“林妹妹”“颦儿”。如果不去归一化,这些称呼会被当成不同词,各自分散 TF 值,导致真正的核心人物反而不突出。

其三,现代汉语词汇和古白话词汇混在一起。比如“丫鬟”在文里更多写成“丫头”,“吃饭”更多写成“用饭”,如果不做针对性的词表调整,提取结果会偏向现代词,不够贴合文本风格。

针对这些问题,我做了三件预处理:

  • 先加载 jieba 自带词典,再自定义追加一批《红楼梦》专有名词,包括主要人物、字、号、居所、器物名。
  • 在分词结果上做同义词归一化,把“宝二爷”“怡红公子”“宝玉”统一映射成“宝玉”,把“林妹妹”“颦儿”“黛玉”统一映射成“黛玉”。这里要注意,不是所有词都能粗暴合并,比如“凤姐”和“王熙凤”在不同章节语境里的实体指向是同一个,可以合并;但“宝玉”和“通灵宝玉”指向完全不同,合并了会乱套,所以映射表要人工盯一遍。
  • 自定义停用词表。除了常见的“的”“了”“之”,还要加入大量文言虚词和叙事套话,比如“话说”“且说”“却说”“只见”“不知”等。这套停用词表是我手动从结果里一遍遍挑出来的,后面会细说。

2.3 没有绝对通用的停用词表,只有不断迭代的停用词表

这里插一句我在项目里的真实感受。网上有很多公开的“中文停用词表”,但直接用往往效果很一般。因为停用词是高度语料相关的,对新闻语料有效的表,放到古白话小说里就差劲了。

我的做法是:先用基础停用词表跑一遍基线结果,然后把每回关键词里那些明显没信息量的词挑出来,加入停用词表,再重跑。这个迭代我做了四轮。第一轮发现高频干扰词有“一个”“如何”“只见”;第二轮发现“因”“便”“方”“且”等文言连词混进了关键词;第三轮发现一些像“老太太”“太太”“姑娘”的称谓词虽然区分度低但偶尔进 Top;第四轮又清了一批“话说”“不在话下”之类的过门套话。

迭代大概能让结果质量提升一个档次。解决方法不复杂,就是从输出反推词表,但得有点耐心,一遍全跑完再一起加,不要看到一个改一次,那样效率太低。

3. 实操过程:数据清洗、分词、TF-IDF 计算与结果整理

3.1 数据准备:从原始文本到一百二十个独立文档

这一步看起来平平无奇,其实最容易出鬼。我在网上找的《红楼梦》全文 TXT 版本,有各种残次品:有带校注者注释的,有不带注释的,有章节标题格式不统一的,有的还混有脂评。

我的清洗流程是这样的:

  • 把原文里的空格、全角空白、换行控制符统一清掉,只保留中文字符和少数必要标点。
  • 用正则表达式按“第X回”切分文本。注意有些版本写作“第一回”,有些写作“第一回 ”后面带空格,还有些写作“卷 第X回 ”,我统一用re.split(r'第[一二三四五六七八九十百零]+回', text)但不直接 split,而是先找到所有回目标题位置,再按位置切片。
  • 每回的内容里可能残留回目标题和副题(比如“贾雨村风尘怀闺秀”),我把它们剔除了,不然回目标题里的词必然在关键词结果里刷屏。

最后一共得到 120 个文档,存成每回一个 txt,再读入 pandas DataFrame,列名叫 “chapter” 和 “content”。这里我顺手统计了一下每回字数,最长的一回有 9640 字,最短的 4260 字,字数差距接近两倍。这个统计对后面设置 TF-IDF 参数有帮助,也让我意识到长度差异确实存在,后面考虑要不要用归一化策略时心里有底。

3.2 分词和文本向量化:代码实现与参数取舍

分词和向量化我用的是 jieba 加 sklearn 的 TfidfVectorizer。核心代码不长,贴一段关键实现:

import jieba import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer # 假设 df 已经包含 chapter 和 content 两列 def cut_words(text): return " ".join(jieba.lcut(text)) df["cut_content"] = df["content"].apply(cut_words) vectorizer = TfidfVectorizer( max_features=3000, ngram_range=(1, 2), stop_words=stop_words_list, sublinear_tf=True ) tfidf_matrix = vectorizer.fit_transform(df["cut_content"]) feature_names = vectorizer.get_feature_names_out()

几个参数我当时斟酌了很久,逐个说一下。

max_features=3000的意思是只考虑全部文档中 DF 值最大的 3000 个词。这能砍掉大量只出现一两次的冷门词和分词碎片。实际上对一百二十回的小说来说,去掉虚词和超低频词之后,特征词总数大概八千左右,3000 是个比较合适的截断点。太小会漏掉重要事件词,太大会让向量变得稀疏,增加噪声。

ngram_range=(1, 2)允许词和相邻词的组合作为特征。为什么要开 bigram?因为《红楼梦》里很多关键事件是固定搭配,比如“葬花”“焚稿”“螃蟹宴”,单独一个“花”或“稿”信息量太小,bigram 能把这些组合词抓出来。实测开 bigram 之后,比如 第二十七回“滴翠亭杨妃戏彩蝶 埋香冢飞燕泣残红”,核心词里出现了“葬花”这个组合,效果立刻比只看单个词好。

sublinear_tf=True这个参数很多人忽略。它把 TF 做了1 + log(TF)的对数放缩,防止某些回里同一个词反复出现导致 TF 值爆炸。比如某回里“酒”字出现 30 次,不代表它比出现 15 次的词重要两倍,对数放缩让这种差距变缓,更符合直觉。实测下来开了这个参数后,高频词不霸榜了,低频但关键的事件词能浮上来。

最后是 IDF 的计算方式。sklearn 默认的 smooth_idf=True,分子分母都加平滑项,避免除零。这个默认值我保留没动,因为文档数固定 120,平滑与否对排序影响很小。

3.3 结果输出:哪些词能成为真正的“回目之眼”

计算完成后,从 tfidf_matrix 里取每一行的 Top 词,输出成表格。我把每回取前 12 个,然后人工抽样检查了十几个回目的效果。

先看几个示例结果:

回数前 8 个核心关键词
第二十七回葬花、黛玉、滴翠亭、宝钗、彩蝶、红玉、凤姐、帕子
第四十回刘姥姥、贾母、行令、茄鲞、鸳鸯、笑话、杯子、酒令
第六十六回尤三姐、柳湘莲、剑、鸳鸯剑、退婚、尤二姐、贾琏、定礼
第九十八回黛玉、宝钗、宝玉、病、泪、断痴情、紫鹃、魂归、出嫁、狠心

第二十七回的结果尤其漂亮,“滴翠亭”和“葬花”分列两头,正好对应这一回的两条主线:扑蝶和葬花。第四十回“茄鲞”“行令”直接锁定宴饮场面和那句著名的“老刘老刘,食量大如牛”。第六十六回“尤三姐”“鸳鸯剑”“退婚”精准命中刚烈女子自刎的名场面。第九十八回,也就是“苦绛珠魂归离恨天”那一回,“魂归”“泪”“断痴情”排得很靠前,这说明只要预处理到位,TF-IDF 完全有能力抓住叙事的情绪高潮。

但也有失败的回目。最典型的是第一回“甄士隐梦幻识通灵 贾雨村风尘怀闺秀”,提取出的关键词里全是“通灵”“顽石”“甄士隐”“贾雨村”“太虚幻境”这些超高频概念,和“怀闺秀”相关的“娇杏”反而被淹没了。原因也简单,第一回是全书的“楔子”,信息密度极高,概念词一个接一个,TF-IDF 在这种超长密集文本上会把每个词都分配权重,反而难以聚焦。这属于方法本身的局限,我用后处理做了修正,后面细说。

3.4 同义合并和实体归一化的实现细节

刚才提了一嘴同义词归一化,这里展开说说。我的做法是在分词之后、向量化之前,对 cut_content 的 token 序列做一次映射替换。

alias_map = { "宝二爷": "宝玉", "怡红公子": "宝玉", "绛洞花主": "宝玉", "林妹妹": "黛玉", "颦儿": "黛玉", "潇湘妃子": "黛玉", "琏二爷": "贾琏", "凤辣子": "王熙凤", "老祖宗": "贾母" } def normalize_tokens(tokens): return [alias_map.get(tok, tok) for tok in tokens]

这个映射表要非常谨慎。比如“潇湘妃子”是黛玉的号,合并是合理的;“潇湘馆”是住所,不能合并。再有“袭人”和“花袭人”可以合并,但“花气袭人知昼暖”里的“袭人”是动词,不能合并。虽然这句话在文中出现次数不多,但映射表的构建原则就是“宁可遗漏,不可错并”,错了会引入新的混乱。

另外,一些人名在不同回里以姓氏+称谓出现,比如“贾母”有时叫“史太君”,有时叫“老太太”。“老太太”在贾府语境里基本专指贾母,但偶尔也指王夫人或邢夫人,这种有歧义的我就不合并,只保留“贾母”和“史太君”的同义映射。

做完归一化再跑 TF-IDF,最直观的变化是“宝玉”“黛玉”“王熙凤”这些关键人物的排名普遍上升了,因为它们的各个别名 TF 值合并在一起。但同时也要防一手:归一化后某些词的整体 DF 变大,IDF 变小,如果某个词本来是某回的别名集中出现的,比如只有某回用了“潇湘妃子”这个号,合并成“黛玉”之后反而可能被稀释掉。所以后来我做了一个变通:保留原始别名作为独立特征,但也在结果表里标注其对应的人名,这样既不破坏 IDF 的区分度,又方便人眼理解。

4. 结果解读技巧与常见问题排查实录

4.1 从关键词序列反推叙事结构:三个解读维度

提取出关键词之后,不是结束,而是开始。单看一个回目里的关键词列表,容易迷失在词海里。我总结了三个实用的解读维度。

第一是“回内对比”。把每回关键词按权重从高到低排,观察前三个词和后几个词的差异。如果前三个都是人物名,说明这一回偏向“人物驱动”,主线可能围绕关系冲突展开;如果前三个里有大量器物名或地点名,那这一回更可能偏向“场景驱动”。比如第十七回“大观园试才题对额”,关键词里“园”“匾”“对额”“贾政”“题词”占了半席,一下就能判断这是空间与建筑描写为主的回目。

第二是“回间对比”。把相邻几回的关键词放一起看,能看出叙事节奏的推进。比如第六十三回到第六十五回,关键词从“宝玉”“夜宴”“群芳”逐步过渡到“尤二姐”“尤三姐”“贾琏”“偷娶”“退婚”,这说明叙事线从大观园日常转移到宁国府风波,节奏由轻松转入紧张。这种视角对文学研究很有用,可以用它来给全书做“叙事段”分割。

第三是“人物关联度”。我不止算关键词,还顺手统计了在每一回里“关键词与人物名是否共现”。具体做法很简单,就是在 TF-IDF 矩阵里挑出人物名所在的列,和事件词所在的列做相关性排序。比如第四十回里“刘姥姥”和“笑话”共现的权重很高,可以推断这一回的笑点主要由刘姥姥制造。这个方法有点糙,但对于快速验证叙事直觉已经够用。

4.2 常见坑位盘点:五个几乎必踩的问题

整个项目从零到一跑了七八遍,踩过的坑我按频次排了序,列成表格,供后来人直接避雷。

问题现象原因解决方案
回目标题刷屏“通灵”“怀秀”跑进前 5切分时没去掉回目标题行先识别回目标题并剔除,再切正文
同义词分散权重“宝二爷”“怡红公子”各自低频人称、号太多做别名映射,但要防歧义
文言虚词残留“便”“因”“方”进入Top词基础停用词表不够古白话特化四轮迭代扩充停用词表
bigram 组合太僵“了的”“之处”这类无效组合默认bigram不够或参数开太大结合自定义词典再开bigram
地名与人物混淆“蘅芜苑”被切成“蘅芜”“苑”jieba 默认词典缺古白话词自定义追加专名,维护词典

这当中最难的其实是第一个:回目标题刷屏。我最初用正则直接按“第X回”切分,没处理标题行,结果切出来的第一回正文开头还是“甄士隐梦幻识通灵”,这七个字直接霸占当回 TF 值前列。后来我把切分逻辑改成先定位所有回目标题的起止位置,然后把标题从正文里替换为“”,再分词,问题才消失。

还有一个小坑是编码问题。很多公开 TXT 是 GBK 编码,用 UTF-8 读会直接乱码。读取时我统一encoding="gb18030"做兼容,把错误处理设成errors="ignore",然后再转存为 UTF-8 的干净版本。这个坑虽然小,但能让人白忙活一小时。

4.3 超长回目和信息密集回目的后处理方法

刚才提到第一回效果不好,这也是 TF-IDF 方法的典型痛点:当一篇文档里的各个信息点都重要时,TF-IDF 不知道该怎么“侧重”,只能平均分配权重。我试过几种补救方法,效果不一,这里分享一条我觉得可行的路线。

先做“片段滑动窗口”:把每个回目的文本按 200 字一个窗口切成段,分别在窗口级做 TF-IDF,再把窗口排名结果做聚类聚合。这样能缓解长回目内部的语义漂移。不过 120 回的文档量不大,窗口滑动后文档数膨胀到上万,我当时为了跑得快,只对个别超长回目做了这个处理,没有全量启用。

再就是加一个后处理规则:从候选词里剔除“与全书所有回目共现频率超过 70%”的词。虽然这些词的 TF 可能很高,但它们不可能是某回的特征词。比如“宝玉”在 120 回里出现次数为 113 回,这个 DF 太高了,即使它是某回的关键人物名,也不具备区分度。但这种词又不能一概剔除,因为有些回目里“宝玉”确实是事件核心,删了就丢了主角。所以我的做法是把这些高频词降低权重而不是直接删:设置一个惩罚系数,DF > 100 的词权重乘以 0.6,DF 在 80-100 之间的乘以 0.8。实测惩罚后,人物名与事件词的平衡感更好,例如第一回里名场面相关的“识通灵”“怀闺秀”这类事件词排名上升了。

4.4 如何选 Top N 关键词:不是越多越好

Top N 的数量直接关系到可读性。我最初输出 Top 20,结果每回的关键词表格长得像天书,第 15 个词开始出现一堆没有叙事意义的低权重词,比如“众人”“一面”“一面又”,看着枯燥也无助于导读。

后来我做了个实验,把 N 从 5 调到 20,人工对比十个回目的结果,发现 8-12 个是一个甜蜜区间。少于 8 个,摘要粒度太粗,比如某回只给了人物名,事件动词没浮出来;多于 12 个,噪声明显上升。最终我统一输出每回前 10 个词,再在权重超过平均值 1.2 倍的关键词里额外选 2 个时报词汇,这个策略基本稳定。

这里也要提醒,N 的选取不能完全拍脑袋,最好基于人工抽样看几回再定。不同语料的甜蜜区间不一样,比如分析新闻标题,可能 5 个就够;分析学术摘要,15 个也合理。关键是每回结果的“可读性一致”。

5. 后续扩展:从单纯关键词到章节“迷你摘要”

5.1 关键词串联成句:给阅读器用的浅层摘要

TF-IDF 提取的关键词是碎片,如果想做成读者可读的迷你摘要,还有一个后处理步骤。我的想法是,既然每回有 10 个核心关键词,那就用这些词去原文里找片段:把包含 3 个及以上关键词的句子抽出来,按出现顺序拼接,就能得到一段原始文本构成的迷你摘要。

实操时,我先按句号、问号、感叹号把每回切成句子,然后筛出含核心关键词数量 >= 3 的句子,按在原文里的顺序排好。这个操作在第二十七回的效果让我很惊讶——拼接出来的句子几乎就是“黛玉葬花”和“宝钗扑蝶”的原始描写片段,完全可以当导读用。

sentences = re.split(r"[。!?]", chapter_text) key_sentence = [] for sent in sentences: hit = sum(1 for word in keyword_list if word in sent) if hit >= 3: key_sentence.append(sent) summary = "。".join(key_sentence[:5])

这一招有个附带的好处:它既保留了原文文风,又能保证摘要内容不是模型瞎编的。对文学类文本来说,保持原汁原味非常重要。后来我用这个方法给一百二十回都生成了迷你摘要,人工抽查了十回,基本都能覆盖该回主要情节。除了极个别回目因为原文描写分散、一句话里难凑三个关键词,摘要会拼出一些跳跃感,但整体可用性已经远超纯关键词列表。

5.2 多方法融合:TF-IDF 作为基线的意义

最后说一点关于方法选型的思考。TF-IDF 这个算法 1972 年就提出来了,快六十年了,今天看来很简单,甚至有人觉得过时。拉一个词向量模型出来可能显得更“先进”,但我做这个项目的体会是:在文本分析里,“可解释的简单方法”往往比“不可解释的复杂模型”更值得先做。因为 TF-IDF 的每一步都能落到具体词和具体权重上,出了奇怪结果可以直接反查,对于古代文本这种噪声极高的语料尤其重要。

我现在把整个流程沉淀成了一个复现性较强的代码库,输入是任意长文本集合,输出是每个文档的 Top 词表和句子级迷你摘要。后续要做的话,可以在 TF-IDF 结果之上叠加预训练语言模型做关键词消歧,比如把“葬花”和“黛玉”之间的语义关联进一步强化。不过那是更靠后的优化方向,基线阶段我仍然推荐老老实实用 TF-IDF 把数据和预处理打磨干净,毕竟后面所有花哨模型的效果都建立在这一步的地基上。

按我个人的经验,跑这类项目最大的收获反而不在算法本身,而在对“文本差异”的敏感度。《红楼梦》每一回的语言风格、叙事密度、词场分布差异巨大,而这些差异在 TF-IDF 的权重里体现得很分明。看多了结果表,你对“什么词才是区分性的”会形成直觉,这直觉在做任何 NLP 项目时都好用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询