机器学习:TF-IDF
计算机不认识汉字,更不懂得"黛玉葬花"的凄美。要让机器理解一篇文本在讲什么,第一步就是把文字变成数字。TF-IDF(词频-逆文档频率)就是其中最经典、最经久不衰的一种"文字数字化"方法。本文将以《红楼梦》120 回文本为语料,结合 sklearn 与 jieba,从原理推导到代码逐行解析,带你彻底搞懂 TF-IDF。
一、引言:为什么需要 TF-IDF?
在自然语言处理(NLP)任务中,无论做文本分类、关键词提取,还是搜索引擎、推荐系统,第一步都是把文本转成机器能处理的数值向量 ,这个过程叫文本向量化(Text Vectorization)。
最朴素的想法是词袋模型(Bag of Words):统计每个词在文档中出现的次数。但单纯数次数有个致命问题:
假设你要判断一篇文章是不是讲红楼梦的。文章里出现最多的词是"的"、"了"、"是"------这些词出现频率最高,但它们对判断主题毫无帮助。真正能区分主题的,是"宝玉""黛玉""大观园"这类只在红楼梦里高频、在别的书里罕见的词。
TF-IDF 正是为解决这一问题而生:给"在本文档频繁出现,但在其他文档罕见"的词更高的权重,给"到处都常见"的词更低的权重。
二、算法原理:TF-IDF 的数学本质
TF-IDF = TF × IDF,由两部分相乘得到。下面分别拆解。
2.1 TF(Term Frequency,词频)
定义:某个词在当前文档中出现的频率。
计算公式:
T F t , d = 词 t 在文档 d 中出现的次数 文档 d 的总词数 TF_{t,d} = \frac{\text{词 } t \text{ 在文档 } d \text{ 中出现的次数}}{\text{文档 } d \text{ 的总词数}} TFt,d=文档 d 的总词数词 t 在文档 d 中出现的次数
物理意义 :TF 越大,说明这个词在当前文档越重要------它反映了词对本文档的贡献度。
示例 :某文档共 100 个词,其中"宝玉"出现 5 次,则 TF(宝玉) = 5/100 = 0.05。
⚠️ TF 的局限:只看本文档不够。比如"的"在每篇文档里都出现很多次,TF 也很高,但它没有区分力。
2.2 IDF(Inverse Document Frequency,逆文档频率)
为了弥补 TF 的缺陷,引入 IDF:衡量一个词在整个语料库中的稀有程度。
计算公式:
I D F t = log N 1 + D F t IDF_t = \log\frac{N}{1 + DF_t} IDFt=log1+DFtN
N:语料库中文档总数DF_t:包含词 t 的文档数(Document Frequency)- 分母
1+DF_t是平滑项,防止某词未出现时除以 0
物理意义:
- 词在越多文档出现(DF 大)→ IDF 越小 → 权重越低(如"的""是")
- 词在越少文档出现(DF 小)→ IDF 越大 → 权重越高(如"宝玉""黛玉")
💡 通俗类比:IDF 就像"稀有度评分"。
- "的""了"这种词,每本书都有,稀有度 = 0(人人都有,不值钱)。
- "大观园"只出现在红楼梦相关文本里,稀有度高,值钱。
2.3 TF-IDF 合成
计算公式:
T F - I D F t , d = T F t , d × I D F t TF\text{-}IDF_{t,d} = TF_{t,d} \times IDF_t TF-IDFt,d=TFt,d×IDFt
物理意义:同时满足"在本文档高频"且"在全局稀有"的词,会得到最高的 TF-IDF 分数。这正是关键词提取的核心依据。
💡 记忆口诀:
- TF 看局部(本文档):出现得多才重要
- IDF 看全局(整个语料库):大家都有的不重要,少数人有的才重要
- TF-IDF = 局部热度 × 全局稀有度
2.4 计算示例
假设语料库有 3 篇文档,统计"的"和"黛玉":
| 词 | 在文档1出现次数 | 文档1总词数 | TF | 包含该词的文档数 DF | 语料库文档数 N | IDF | TF-IDF |
|---|---|---|---|---|---|---|---|
| 的 | 20 | 100 | 0.20 | 3 | 3 | log(3/(1+3))≈-0.12 | 负值(被压低) |
| 黛玉 | 5 | 100 | 0.05 | 1 | 3 | log(3/(1+1))≈0.41 | 0.0205 |
可以看到:虽然"的"的 TF 是"黛玉"的 4 倍,但 IDF 把它压到了负值;而"黛玉"因稀有获得高权重,最终 TF-IDF 远高于"的"。这就是 TF-IDF 的威力。
三、jieba 分词:中文 TF-IDF 的前置工序
对英文而言,词与词之间天然用空格分隔,直接统计即可。但中文没有空格 ,"贾宝玉初试云雨情"机器无法知道该切成"贾/宝玉/初试/云雨情"还是"贾宝/玉初/试云/雨情"。因此中文 TF-IDF 的第一步必须是分词 ,而 jieba 是 Python 最流行的中文分词库。
3.1 jieba 的分词原理
jieba 采用基于前缀词典 + 动态规划(DP)+ HMM 隐马尔可夫模型的混合策略:
- 前缀词典扫描:用词典匹配出所有可能的词组合。
- 动态规划:基于词频构建有向无环图(DAG),求最大概率路径,得到最优切分。
- HMM 新词发现:对词典中没有的词,用 HMM 模型(BMES 四状态标注)识别新词。
3.2 jieba 的三种分词模式
| 模式 | 调用方法 | 特点 | 适用场景 |
|---|---|---|---|
| 精确模式 | jieba.cut(s) |
最精确切分,不冗余 | 文本分析(TF-IDF 用这个) |
| 全模式 | jieba.cut(s, cut_all=True) |
扫出所有可能的词,速度快但冗余 | 搜索引擎分词(召回阶段) |
| 搜索引擎模式 | jieba.cut_for_search(s) |
在精确模式基础上对长词再切 | 搜索引擎(精确召回兼顾) |
示例对比(句子:"小明硕士毕业于中国科学院计算所"):
python
import jieba
s = "小明硕士毕业于中国科学院计算所"
# 精确模式:最精确切分,不冗余
print(list(jieba.cut(s)))
# ['小明', '硕士', '毕业', '于', '中国科学院', '计算所']
# 全模式:扫出所有可能的词,冗余但召回高
print(list(jieba.cut(s, cut_all=True)))
# ['小明', '硕士', '毕业', '于', '中国', '科学', '学院', '科学院', '中国科学院', '计算', '计算所']
# 搜索引擎模式:在精确模式基础上对长词再切,兼顾召回
print(list(jieba.cut_for_search(s)))
# ['小明', '硕士', '毕业', '于', '中国', '科学', '学院', '科学院', '中国科学院', '计算', '计算所']
3.3 jieba 在 TF-IDF 流程中的应用
- 精确模式分词 :TF-IDF 需要准确的词边界,用
jieba.cut()默认精确模式。 - 自定义词库 :领域专有名词(如红楼梦的"贾宝玉""大观园""潇湘馆")默认词库切不开,需
jieba.load_userdict()加载。 - 动态增词 :少量词可临时用
jieba.add_word('江淮实验室')添加。
本文案例的 jieba的使用.py 演示了动态添加词:
python
import jieba
str = '他在清华实验室研究人工智能'
jieba.add_word('清华实验室') # 动态添加专有名词(默认会切成"清华"+"实验室")
a = jieba.lcut(sentence=str) # lcut 直接返回列表
print(a)
# ['他', '在', '清华实验室', '研究', '人工智能']
💡 为什么红楼梦案例必须加载自定义词库?
默认词库会把"薛蟠"切成"薛""蟠",把"贾雨村"切成"贾""雨""村"。这样分词后"贾"会在每回都高频,最终 TF-IDF 提取出的关键词全是无意义单字。加载
红楼梦词库.txt后才能正确识别专有人名地名。
四、代码实战:从分词到关键词提取
第三章讲了 jieba 分词原理,本章把原理落地为完整代码流程:脏数据读取 → 分词去停用词 → TF-IDF 矩阵构建(含参数调优)→ 关键词提取 → 工程化数据生成 → 可交互搜索验证。涉及的核心文件如下:
| 文件 | 作用 |
|---|---|
jieba的使用.py |
jieba 分词入门 |
TF-IDF模拟案例.py |
6 行英文语料的极简 TF-IDF |
红楼梦案例-关键词提取.py |
红楼梦 120 回完整 TF-IDF 关键词提取 |
生成搜索数据.py |
工程化版本,生成前端搜索用的 data.js |
红楼梦章节搜索.html |
消费 data.js 的可交互搜索页面(案例验证) |
4.1 模块一:数据读取
红楼梦案例的数据组织 :红楼梦/分卷/ 目录下有 120 个 txt 文件,每个文件是一回,文件名格式如上卷 第三回 贾雨村夤缘复旧职 林黛玉抛父进京都.txt。
原始数据样本(第一回前几行)
手机电子书·大学生小说网 更新时间:2006-7-26 11:43:00 本章字数:8717
此开卷第一回也.作者自云:因曾历过一番梦幻之后,故将真事隐去,而借"通灵"之说,撰此《石头记》一书也.故曰"甄士隐"云云.
此回中凡用"梦"用"幻"等字,是提醒阅者眼目,亦是此书立意本旨.
列位看官:你道此书从何而来?说起根由虽近荒唐,细按则深有趣味.
观察样本可以发现三大"脏数据"特征:
- 首行是元数据 :第 1 行是
手机电子书·大学生小说网 更新时间:... 本章字数:8717,这是电子书网站的来源信息,不是小说正文。如果不剔除,"手机""电子书""小说网""更新时间"会被统计为词,污染 TF-IDF 结果。 - 正文含大量换行与全角空格 :每个段落以
\n换行,段首还有全角空格(占两个字符位)。换行符会打断分词,全角空格会被 jieba 切成无意义单字。 - 标点符号密集 :
.,:""《》等中文标点遍布全文。标点本身无语义,若不剔除会大量进入词表,稀释有效词的 TF-IDF 权重。
数据读取与初步清洗代码
python
import pandas as pd
import os
filePanths = [] # 保存文件路径
fileContents = [] # 保存文件内容
for root, dirs, files in os.walk(r".\红楼梦\分卷"):
for name in files:
filePath = os.path.join(root, name)
filePanths.append(filePath)
f = open(filePath, 'r', encoding='utf-8')
next(f) # 跳过第一行元数据
fileContent = f.read()
fileContent = fileContent.replace('\n','') # 去除换行,把全文压成一行
f.close()
fileContents.append(fileContent)
corpos = pd.DataFrame({'filePanth': filePanths, 'fileContent': fileContents})
基于样本解释每步清洗:
| 代码 | 针对的脏数据 | 不清洗的后果 |
|---|---|---|
next(f) |
跳过首行网站元数据 | "手机""小说网""更新时间"成为高频词,挤进关键词 |
replace('\n','') |
去除段间换行 | 换行符会被 jieba 当成分隔符,导致跨段词被切断 |
encoding='utf-8' |
正确解码中文 | 默认编码可能乱码,导致分词失败 |
💡 为什么用
os.walk而非os.listdir?红楼梦/分卷/下还分上卷/下卷子目录,os.walk递归遍历所有层级子目录,os.listdir只看一层。
4.2 模块二:分词 + 去停用词
红楼梦词库样本
皑皑轻趁步
爱彼之貌容兮
爱才好客
艾官
暗渡陈仓
安国公
暗洒闲抛却为谁
安身乐业
俺只念木石前盟
鳌愁坤轴陷
为什么必须加载自定义词库? 红楼梦里大量专有名词(人名如艾官、地名如安国公、诗句如暗洒闲抛却为谁)在 jieba 默认词库中不存在。若不加载,艾官会被切成艾+官,安国公会被切成安+国公,导致人物名称支离破碎,TF-IDF 提取的关键词变成无意义单字。
停用词表样本
stopword
nbsp
?
、
。
"
"
《
,
-
为什么必须去停用词? 停用词表包含:
- 标点符号 :
。、""《》,-等------纯符号无语义。 - HTML 实体 :
nbsp(从网页抓取的电子书残留)。 - 高频虚词 :
的、了、是、就、都、而等------出现频率极高但无区分度。
不过滤的后果:以第一回为例,的出现约 200 次、了约 80 次,远超主角士隐(约 30 次)。TF-IDF 排名前 10 的关键词会被这些虚词霸占,真正的人物、地名被淹没。
分词 + 去停用词代码
python
import jieba
jieba.load_userdict(r".\红楼梦\红楼梦词库.txt") # 加载红楼梦专有词库
# 加载停用词表
stopwords = pd.read_csv(r".\红楼梦\StopwordsCN.txt",
encoding="utf-8", engine="python", index_col=False)
file_to_jieba = open(r'./红楼梦/分词后汇总.txt', 'w', encoding='utf-8')
for index, row in corpos.iterrows():
juan_ci = ''
fileContent = row['fileContent']
segs = jieba.cut(fileContent) # 精确模式分词,返回生成器
for seg in segs:
# 剔除停用词和空白字符
if seg not in stopwords.stopword.values and len(seg.strip()) > 0:
juan_ci += seg + ' ' # 用空格拼接,形成"词1 词2 词3"
file_to_jieba.write(juan_ci + '\n') # 每回一行写入文件
file_to_jieba.close()
关键点解析:
- 加载词库 :
红楼梦词库.txt每行一个专有名词,让 jieba 优先识别,避免人名地名被切碎。 - 停用词过滤 :
seg not in stopwords.stopword.values把标点、虚词、HTML 残留全部剔除,降低维度、消除噪声。 - 空格拼接 :sklearn 的
TfidfVectorizer默认以空格分词,所以中文分词后必须用空格连成字符串。 - 输出文件 :
分词后汇总.txt每行对应一回的分词结果,作为 TF-IDF 的输入语料。
清洗后数据样本(分词后汇总.txt 第一行节选)
开卷 第一回 作者 自云 因曾 历过 一番 梦幻 之后 真事 隐去 通灵 撰此 石头记 一书 故曰 甄士隐 但书中 所记 何事 何人 ...
对比原始数据,可以看到:
- 首行元数据"手机电子书·大学生小说网..."已消失(被
next(f)跳过)。 - 换行和全角空格已去除(被
replace('\n','')清理)。 - 标点和虚词已剔除(被停用词表过滤)。
- 人名
甄士隐、专有词石头记、通灵被正确切分(自定义词库生效)。 - 词与词之间用空格分隔,符合 sklearn 输入要求。
这就是从"脏数据"到"TF-IDF 可用语料"的完整清洗链路。
输出格式:为什么是"每行一回、空格分词"?
分词后汇总.txt 的格式并非随意,而是为了对接 sklearn 的 TfidfVectorizer------它接受一个可迭代的字符串序列,每个字符串是一个文档:
# 极简案例 task2_1.txt 格式(每行一个英文文档):
This is the first document
This document is the second document
And this is the third one
...
# 中文案例 分词后汇总.txt 格式(每行一个分词后的文档,词用空格分隔):
士隐 封肃 英莲 甄家 葫芦 ...
黛玉 贾母 凤姐 夫人 宝玉 ...
⚠️ 中文必须先分词并用空格连接 :sklearn 默认正则分词器是
r"(?u)\b\w\w+\b",针对英文设计,对未分词的中文只能按字切分(把"贾宝玉"切成"贾""宝""玉")。所以本节用 jieba 分词 + 空格拼接,正是为了产出 TfidfVectorizer 能正确处理的输入格式。
4.3 模块三:TF-IDF 矩阵构建
python
from sklearn.feature_extraction.text import TfidfVectorizer
inFile = open(r'./红楼梦/分词后汇总.txt', 'r', encoding='utf-8')
corpus = inFile.readlines() # 每行一个文档
vectorizer = TfidfVectorizer() # 实例化 TF-IDF 转换器
tfidf = vectorizer.fit_transform(corpus) # 拟合并转换,返回稀疏矩阵
print(tfidf)
wordlist = vectorizer.get_feature_names_out() # 获取所有特征词(语料库词表)
print(wordlist)
df = pd.DataFrame(tfidf.T.todense(), index=wordlist) # 转置并转为稠密矩阵
print(df)
关键点解析:
| 代码 | 作用 |
|---|---|
TfidfVectorizer() |
创建转换器对象,内部自动计算 TF 和 IDF |
fit_transform(corpus) |
一步完成:统计词表(fit)+ 计算每文档 TF-IDF(transform) |
get_feature_names_out() |
返回语料库所有不重复词的列表(特征名) |
tfidf.T.todense() |
.T 转置(行=词、列=文档),.todense() 稀疏矩阵转稠密矩阵 |
pd.DataFrame(..., index=wordlist) |
用词作为行索引,便于按词查值 |
输出矩阵含义 (df):
行 = 语料库中所有词(如"宝玉""黛玉""大观园"......)
列 = 每一回文档(第1列=第一回,第2列=第二回......)
值 = 该词在该回的 TF-IDF 分数
例如 df.loc['宝玉', 0] = 0.0832,表示"宝玉"在第一回的 TF-IDF 值是 0.0832。
输出结果解读 :fit_transform 返回的是稀疏矩阵(CSR 格式):
python
<120x15234 sparse matrix of type '<class 'numpy.float64'>'
with 89432 stored elements in Compressed Sparse Row format>
含义:
- 120 行 = 120 回文档
- 15234 列 = 语料库共 15234 个不重复词
- 89432 个非零元素 = 大部分词只在少数回出现,矩阵高度稀疏
取某文档的 TF-IDF 向量:
python
tfidf[0].toarray() # 第一回的 TF-IDF 向量(形状 1×15234)
tfidf[0, vectorizer.vocabulary_['黛玉']] # "黛玉"在第一回的 TF-IDF 值
TfidfVectorizer 核心参数与调优
上面用的是默认参数 TfidfVectorizer()。实际项目中常需调参以优化效果:
| 参数 | 默认值 | 说明 |
|---|---|---|
norm |
'l2' |
是否对每行做 L2 归一化,使每文档向量长度为 1。可设 None 关闭 |
use_idf |
True |
是否启用 IDF 加权。设 False 则退化为纯 TF |
smooth_idf |
True |
是否对 IDF 加 1 平滑(防除零),公式变为 log(N/DF) + 1 |
sublinear_tf |
False |
是否用 1 + log(TF) 替代原始 TF,缓解高频词影响 |
max_df |
1.0 |
出现在超过该比例文档的词将被过滤(自动停用词) |
min_df |
1 |
出现次数低于该值的词将被过滤(降维) |
max_features |
None |
只保留 TF-IDF 总和最高的前 N 个词 |
stop_words |
None |
内置停用词表(如 'english') |
token_pattern |
默认正则 | 分词正则,中文需配合自定义分词器 |
ngram_range |
(1,1) |
词组范围,(1,2) 表示同时考虑 1-gram 和 2-gram |
常用调参建议:
python
vectorizer = TfidfVectorizer(
max_df=0.9, # 过滤出现在 90% 以上文档的词(自动去停用词)
min_df=2, # 过滤只出现 1 次的词(去噪降维)
max_features=10000, # 限制特征数,避免维度爆炸
sublinear_tf=True, # 用 log(TF) 缓解高频词影响
ngram_range=(1,2), # 考虑 2-gram 捕获"贾_宝玉"这类组合
)
4.4 模块四:关键词提取
python
for j in range(len(corpus)): # 遍历每一回
featurelist = df.iloc[:, j].to_list() # 取第 j 回所有词的 TF-IDF 值
resdict = {}
for i in range(len(wordlist)):
resdict[wordlist[i]] = featurelist[i] # 构建 {词: TF-IDF值} 字典
# 按 TF-IDF 值降序排序,取前 50
resdict = sorted(resdict.items(), key=lambda x: x[1], reverse=True)
print("第{}回的核心关键词:".format(j+1), resdict[0:50])
运行结果示例(第27回为实际运行的真实数据):
第1回的核心关键词: [('士隐', 0.0832), ('封肃', 0.0651), ('英莲', 0.0573), ('甄家', 0.0512), ('葫芦', 0.0489), ...]
第3回的核心关键词: [('黛玉', 0.1245), ('贾母', 0.0987), ('凤姐', 0.0856), ('夫人', 0.0723), ('宝玉', 0.0698), ...]
第27回的核心关键词: [('红玉', 0.2822), ('红玉道', 0.2407), ('奶奶', 0.1893), ...]
结果解读:
- 第1回(甄士隐梦幻识通灵)关键词是"士隐""封肃""英莲",正是本回主角------TF-IDF 准确捕捉。
- 第3回(林黛玉抛父进京都)关键词是"黛玉""贾母""凤姐",对应黛玉进府的情节------TF-IDF 准确捕捉。
- 第27回(埋香冢飞燕泣残红)top 关键词竟是"红玉"而非"葬花"!这看似反常,实则因为第27回有两条线索:黛玉葬花 + 林红玉(小红)与贾芸的手帕传情,"红玉"作为人名在该回高频且全局稀有,TF-IDF 自然最高。
💡 第27回的结果埋下一个伏笔:TF-IDF 是统计方法而非语义理解 ------它找的是"词与文档的统计相关性",不等于"情节主题"。这一特性将在 4.6 节的搜索验证中进一步揭示。TF-IDF 能自动从 120 回中提取代表性词,这就是无监督关键词提取的魅力与局限所在。
4.5 模块五:工程化版本(生成搜索数据.py)
生成搜索数据.py 在上述流程基础上做了工程化增强,核心改进:
- 章节信息解析 :用正则从文件名提取卷、回数、标题,并用
chinese_to_int()把"七十一"转成 71。 - 保留段落结构 :为前端展示,保留
paragraphs列表而非压平成单行。 - 词频统计:除 TF-IDF 外,额外统计每回词频,供前端搜索时高亮。
- 数据序列化 :把所有信息打包成
data.js供红楼梦章节搜索.html使用。
关键片段:
python
# 中文数字转阿拉伯数字(如"七十一"->71)
def chinese_to_int(s):
digit_map = {'零': 0, '〇': 0, '一': 1, ..., '九': 9}
if '十' in s:
if s == '十': return 10
if s.startswith('十'): return 10 + digit_map.get(s[1], 0)
parts = s.split('十')
return digit_map.get(parts[0], 0) * 10 + digit_map.get(parts[1], 0)
# 位值表示法
result = 0
for c in s:
if c in digit_map: result = result * 10 + digit_map[c]
return result
# 生成 data.js 供前端使用
chaptersData = []
for idx in range(len(chapterInfos)):
wordData = {}
for word, count in chapterWordFreq[idx].items():
tfidf_score = chapterTfidfMap[idx].get(word, 0)
wordData[word] = {'c': count, 't': round(tfidf_score, 6)} # c=词频, t=tfidf
chaptersData.append({
'num': info['num'], 'juan': info['juan'], 'title': info['title'],
'keywords': [[k, round(v, 6)] for k, v in chapterKeywords[idx]],
'wordData': wordData,
'content': chapterContents[idx]
})
with open(output_path, 'w', encoding='utf-8') as f:
f.write('const CHAPTERS = ')
f.write(json.dumps(chaptersData, ensure_ascii=False))
4.6 模块六:案例验证------红楼梦章节搜索页面
前面五步把 TF-IDF 从算法变成数据(data.js),但数据是否真的有效?需要一个可交互的测试界面 来验证案例可行性。红楼梦章节搜索.html 是一个纯前端页面(HTML + CSS + JavaScript),消费 data.js,把 TF-IDF 计算结果可视化为人能理解的搜索体验。
完整页面代码
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>红楼梦章节关键词检索</title>
<script src="data.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "SimSun", "KaiTi", serif;
background: linear-gradient(135deg, #f5e6d3 0%, #e8d5b7 100%);
min-height: 100vh;
color: #3a2a1a;
}
/* 头部 */
header {
background: linear-gradient(135deg, #8b3a3a 0%, #6b2c2c 100%);
color: #f5e6d3; padding: 30px 20px; text-align: center;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
header h1 { font-size: 2.2em; font-family: "KaiTi","SimSun",serif;
letter-spacing: 8px; text-shadow: 2px 2px 4px rgba(0,0,0,0.3); }
header p { margin-top: 8px; font-size: 0.95em; opacity: 0.85; letter-spacing: 2px; }
.container { max-width: 1000px; margin: 0 auto; padding: 25px 20px; }
/* 搜索区 */
.search-section {
background: rgba(255,255,255,0.7); border-radius: 12px; padding: 25px;
box-shadow: 0 3px 12px rgba(0,0,0,0.08); margin-bottom: 25px;
}
.search-box { display: flex; gap: 12px; margin-bottom: 15px; }
.search-box input {
flex: 1; padding: 14px 18px; border: 2px solid #c9a96e;
border-radius: 8px; font-size: 1.05em; font-family: inherit;
background: #fff; transition: border-color 0.3s;
}
.search-box input:focus { outline: none; border-color: #8b3a3a; }
.search-box button {
padding: 14px 28px; background: linear-gradient(135deg,#8b3a3a,#6b2c2c);
color: #f5e6d3; border: none; border-radius: 8px; font-size: 1em;
font-family: inherit; cursor: pointer; letter-spacing: 3px;
transition: opacity 0.3s, transform 0.2s;
}
.search-box button:hover { opacity: 0.9; transform: translateY(-1px); }
/* 快捷标签 */
.quick-tags { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.quick-tags span.label { font-size: 0.85em; color: #8b6f4e; margin-right: 4px; }
.quick-tags button {
padding: 5px 14px; background: rgba(139,58,58,0.1);
border: 1px solid rgba(139,58,58,0.3); border-radius: 20px;
font-size: 0.88em; font-family: inherit; color: #6b2c2c;
cursor: pointer; transition: all 0.3s;
}
.quick-tags button:hover { background: rgba(139,58,58,0.2); border-color: #8b3a3a; }
/* 工具栏 */
.toolbar { display: flex; justify-content: space-between; align-items: center;
margin-bottom: 15px; flex-wrap: wrap; gap: 10px; }
.sort-btns { display: flex; gap: 8px; }
.sort-btns button {
padding: 6px 16px; border: 1px solid #c9a96e; background: #fff;
border-radius: 6px; font-size: 0.85em; font-family: inherit;
color: #6b2c2c; cursor: pointer; transition: all 0.3s;
}
.sort-btns button.active { background: #8b3a3a; color: #f5e6d3; border-color: #8b3a3a; }
.result-count { font-size: 0.9em; color: #8b6f4e; }
/* 结果卡片 */
.results { display: flex; flex-direction: column; gap: 12px; }
.result-card {
background: rgba(255,255,255,0.8); border-radius: 10px; padding: 18px 22px;
border-left: 4px solid #8b3a3a; transition: all 0.3s; cursor: pointer;
}
.result-card:hover {
background: rgba(255,255,255,0.95);
box-shadow: 0 4px 15px rgba(139,58,58,0.15); transform: translateX(4px);
}
.card-header { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
.chapter-num { background: #8b3a3a; color: #f5e6d3; padding: 3px 12px;
border-radius: 5px; font-size: 0.9em; font-weight: bold; white-space: nowrap; }
.juan-badge { background: #c9a96e; color: #fff; padding: 3px 10px;
border-radius: 5px; font-size: 0.8em; }
.chapter-title { font-size: 1.05em; color: #3a2a1a; font-family: "KaiTi", serif; }
.card-scores { display: flex; gap: 20px; flex-wrap: wrap; font-size: 0.88em; color: #6b5544; }
.score-item { display: flex; align-items: center; gap: 6px; }
.score-item .keyword-tag { background: rgba(139,58,58,0.12); color: #8b3a3a;
padding: 2px 8px; border-radius: 4px; font-weight: bold; }
.score-item .tfidf-val { color: #c0392b; font-weight: bold; }
/* 原文摘要 */
.content-snippet {
margin-top: 12px; padding: 12px 15px; background: rgba(201,169,110,0.08);
border-left: 3px solid #c9a96e; border-radius: 4px; font-size: 0.88em;
line-height: 1.9; color: #5a4030; font-family: "KaiTi","SimSun",serif;
max-height: 120px; overflow: hidden; position: relative;
transition: max-height 0.4s ease;
}
.content-snippet.expanded { max-height: 600px; }
.content-snippet p { margin-bottom: 8px; text-indent: 2em; }
.content-snippet p:last-child { margin-bottom: 0; }
.content-snippet mark { background: #f9e79f; color: #8b3a3a;
padding: 1px 4px; border-radius: 3px; font-weight: bold; }
.snippet-toggle { display: none; margin-top: 8px; font-size: 0.82em;
color: #8b3a3a; cursor: pointer; background: none; border: none;
font-family: inherit; padding: 0; }
.snippet-toggle:hover { text-decoration: underline; }
.snippet-toggle.visible { display: inline-block; }
.relevance-bar { height: 6px; background: #e8d5b7; border-radius: 3px;
margin-top: 10px; overflow: hidden; }
.relevance-fill { height: 100%; background: linear-gradient(90deg,#c9a96e,#8b3a3a);
border-radius: 3px; transition: width 0.5s ease; }
/* 章节详情弹窗 */
.modal-overlay { display: none; position: fixed; top:0; left:0; right:0; bottom:0;
background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center;
align-items: flex-start; padding: 40px 20px; overflow-y: auto; }
.modal-overlay.show { display: flex; }
.modal { background: #faf6f0; border-radius: 12px; padding: 30px;
max-width: 700px; width: 100%; box-shadow: 0 10px 40px rgba(0,0,0,0.3); position: relative; }
.modal-close { position: absolute; top: 15px; right: 20px; font-size: 1.8em;
color: #8b6f4e; cursor: pointer; line-height: 1; }
.modal-close:hover { color: #8b3a3a; }
.modal h2 { color: #8b3a3a; font-family: "KaiTi", serif; margin-bottom: 5px; font-size: 1.4em; }
.modal .modal-subtitle { color: #8b6f4e; font-size: 0.9em; margin-bottom: 20px; }
.keyword-cloud { display: flex; flex-wrap: wrap; gap: 8px; }
.keyword-item { display: inline-flex; align-items: center; gap: 4px; padding: 5px 12px;
background: rgba(139,58,58,0.08); border: 1px solid rgba(139,58,58,0.15);
border-radius: 20px; font-size: 0.88em; cursor: pointer; transition: all 0.3s; }
.keyword-item:hover { background: rgba(139,58,58,0.18); border-color: #8b3a3a; }
.keyword-item .kw-name { color: #3a2a1a; font-weight: bold; }
.keyword-item .kw-score { color: #c0392b; font-size: 0.85em; }
.modal-tabs { display: flex; gap: 0; margin-bottom: 18px; border-bottom: 2px solid #e8d5b7; }
.modal-tab { padding: 10px 24px; font-size: 0.95em; font-family: inherit; color: #8b6f4e;
background: none; border: none; border-bottom: 3px solid transparent; cursor: pointer;
transition: all 0.3s; margin-bottom: -2px; }
.modal-tab:hover { color: #8b3a3a; }
.modal-tab.active { color: #8b3a3a; border-bottom-color: #8b3a3a; font-weight: bold; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.chapter-content { max-height: 60vh; overflow-y: auto; padding: 10px 15px;
background: rgba(255,255,255,0.5); border-radius: 8px; border: 1px solid rgba(201,169,110,0.3); }
.chapter-content p { font-size: 0.92em; line-height: 2; color: #3a2a1a;
margin-bottom: 12px; text-indent: 2em; font-family: "KaiTi","SimSun",serif; }
.content-search-hint { font-size: 0.82em; color: #8b6f4e; margin-bottom: 10px;
padding: 6px 12px; background: rgba(201,169,110,0.12); border-radius: 6px; }
.content-search-hint mark { background: #f9e79f; color: #8b3a3a;
padding: 1px 4px; border-radius: 3px; font-weight: bold; }
/* 浏览所有章节 */
.browse-section { margin-top: 25px; }
.browse-section h3 { color: #8b3a3a; font-family: "KaiTi", serif; margin-bottom: 15px;
font-size: 1.3em; border-bottom: 2px solid #c9a96e; padding-bottom: 8px; }
.chapter-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.chapter-card { background: rgba(255,255,255,0.7); border-radius: 8px; padding: 14px 16px;
border-left: 3px solid #c9a96e; cursor: pointer; transition: all 0.3s; }
.chapter-card:hover { background: rgba(255,255,255,0.95); border-left-color: #8b3a3a;
transform: translateY(-2px); box-shadow: 0 3px 10px rgba(0,0,0,0.1); }
.chapter-card .cc-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.chapter-card .cc-num { color: #8b3a3a; font-weight: bold; font-size: 0.9em; }
.chapter-card .cc-title { font-size: 0.9em; color: #5a4030; font-family: "KaiTi", serif; }
.chapter-card .cc-keywords { font-size: 0.8em; color: #8b6f4e; line-height: 1.5; }
.chapter-card .cc-keywords b { color: #6b2c2c; }
.empty-msg { text-align: center; padding: 60px 20px; color: #8b6f4e; font-size: 1.1em; }
.hint { font-size: 0.82em; color: #8b6f4e; margin-top: 10px; }
footer { text-align: center; padding: 20px; color: #8b6f4e; font-size: 0.82em; }
</style>
</head>
<body>
<header>
<h1>红楼梦章节关键词检索</h1>
<p>基于 TF-IDF 算法 · 通过关键词查找红楼梦章节</p>
</header>
<div class="container">
<!-- 搜索区 -->
<div class="search-section">
<div class="search-box">
<input type="text" id="searchInput" placeholder="输入关键词,如:黛玉、宝玉、葬花、刘姥姥......(多关键词用空格分隔)">
<button onclick="doSearch()">检索</button>
</div>
<div class="quick-tags">
<span class="label">快捷词:</span>
<button onclick="quickSearch('黛玉')">黛玉</button>
<button onclick="quickSearch('宝玉')">宝玉</button>
<button onclick="quickSearch('宝钗')">宝钗</button>
<button onclick="quickSearch('凤姐')">凤姐</button>
<button onclick="quickSearch('刘姥姥')">刘姥姥</button>
<button onclick="quickSearch('贾母')">贾母</button>
<button onclick="quickSearch('晴雯')">晴雯</button>
<button onclick="quickSearch('袭人')">袭人</button>
<button onclick="quickSearch('紫鹃')">紫鹃</button>
<button onclick="quickSearch('薛蟠')">薛蟠</button>
</div>
</div>
<!-- 工具栏:排序与统计 -->
<div class="toolbar" id="toolbar" style="display:none;">
<div class="result-count" id="resultCount"></div>
<div class="sort-btns">
<button id="sortTfidf" class="active" onclick="setSort('tfidf')">按 TF-IDF 相关度排序</button>
<button id="sortCount" onclick="setSort('count')">按词频排序</button>
</div>
</div>
<!-- 搜索结果 -->
<div class="results" id="results"></div>
<!-- 浏览所有章节 -->
<div class="browse-section">
<h3>浏览所有章节(共 <span id="totalChapters">120</span> 回)</h3>
<div class="chapter-grid" id="chapterGrid"></div>
</div>
</div>
<!-- 章节详情弹窗 -->
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
<div class="modal" id="modalContent">
<span class="modal-close" onclick="closeModalDirect()">×</span>
<div id="modalBody"></div>
</div>
</div>
<footer>
数据来源:红楼梦分卷文本 · 分词工具:jieba · 关键词算法:TF-IDF (scikit-learn)
</footer>
<script>
// ===== 全局状态 =====
let currentSort = 'tfidf'; // 排序方式:tfidf 或 count
let currentResults = []; // 当前搜索结果
// ===== 页面初始化 =====
document.getElementById('totalChapters').textContent = CHAPTERS.length;
renderAllChapters();
// 回车触发搜索
document.getElementById('searchInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') doSearch();
});
// ===== 快捷搜索(点击标签) =====
function quickSearch(keyword) {
document.getElementById('searchInput').value = keyword;
doSearch();
}
// ===== 执行搜索:遍历 120 回的 wordData,找出包含关键词的章节 =====
function doSearch() {
const input = document.getElementById('searchInput').value.trim();
if (!input) { alert('请输入要搜索的关键词'); return; }
// 支持空格/逗号/顿号分隔多关键词
const keywords = input.split(/[\s,,、]+/).filter(k => k.length > 0);
const results = [];
for (let i = 0; i < CHAPTERS.length; i++) {
const ch = CHAPTERS[i];
const matched = [];
for (const kw of keywords) {
if (ch.wordData[kw]) { // 精确匹配分词
matched.push({
keyword: kw,
count: ch.wordData[kw].c, // 词频
tfidf: ch.wordData[kw].t // TF-IDF 值
});
}
}
if (matched.length > 0) {
// 累加 TF-IDF 和词频作为综合得分
const totalTfidf = matched.reduce((s, m) => s + m.tfidf, 0);
const totalCount = matched.reduce((s, m) => s + m.count, 0);
results.push({ chapter: ch, matched, totalTfidf, totalCount });
}
}
currentResults = results;
sortAndRender();
}
// ===== 设置排序方式 =====
function setSort(mode) {
currentSort = mode;
document.getElementById('sortTfidf').classList.toggle('active', mode === 'tfidf');
document.getElementById('sortCount').classList.toggle('active', mode === 'count');
sortAndRender();
}
// ===== 排序并渲染结果卡片 =====
function sortAndRender() {
if (currentResults.length === 0) return;
// 按 TF-IDF 或词频降序排序
if (currentSort === 'tfidf') {
currentResults.sort((a, b) => b.totalTfidf - a.totalTfidf);
} else {
currentResults.sort((a, b) => b.totalCount - a.totalCount);
}
// 最大值用于相关性条比例
const maxVal = currentSort === 'tfidf'
? Math.max(...currentResults.map(r => r.totalTfidf))
: Math.max(...currentResults.map(r => r.totalCount));
document.getElementById('toolbar').style.display = 'flex';
const keywords = document.getElementById('searchInput').value.trim();
document.getElementById('resultCount').textContent =
`关键词「${keywords}」共匹配到 ${currentResults.length} 个章节`;
// 渲染每条结果卡片
const container = document.getElementById('results');
container.innerHTML = currentResults.map((r) => {
const ch = r.chapter;
const relVal = currentSort === 'tfidf' ? r.totalTfidf : r.totalCount;
const relPercent = maxVal > 0 ? (relVal / maxVal * 100).toFixed(1) : 0;
// 关键词 + TF-IDF + 词频
const scoreItems = r.matched.map(m =>
`<div class="score-item">
<span class="keyword-tag">${m.keyword}</span>
<span>TF-IDF: <span class="tfidf-val">${m.tfidf.toFixed(4)}</span></span>
<span>词频: ${m.count}</span>
</div>`
).join('');
// 提取原文摘要:定位包含搜索词的段落并高亮
const searchKws = r.matched.map(m => m.keyword);
const snippetParas = [];
for (const para of ch.content) {
let hasKw = false;
for (const kw of searchKws) {
if (para.includes(kw)) { hasKw = true; break; }
}
if (hasKw) {
let html = escapeHtml(para);
searchKws.forEach(kw => {
const re = new RegExp(escapeRegExp(kw), 'g');
html = html.replace(re, `<mark>${kw}</mark>`);
});
snippetParas.push(html);
if (snippetParas.length >= 3) break;
}
}
let snippetHtml = snippetParas.length > 0
? snippetParas.map(p => `<p>${p}</p>`).join('')
: ch.content.slice(0, 2).map(p => `<p>${escapeHtml(p)}</p>`).join('');
const snippetId = 'snippet-' + ch.num;
const toggleId = 'toggle-' + ch.num;
return `
<div class="result-card">
<div class="card-header" onclick="showChapterDetail(${ch.num})" style="cursor:pointer;">
<span class="chapter-num">第${ch.num}回</span>
<span class="juan-badge">${ch.juan}</span>
<span class="chapter-title">${ch.title}</span>
</div>
<div class="card-scores">${scoreItems}</div>
<div class="content-snippet" id="${snippetId}" onclick="event.stopPropagation()">
${snippetHtml}
</div>
<button class="snippet-toggle" id="${toggleId}" onclick="toggleSnippet('${snippetId}','${toggleId}',this)">展开全文 ▾</button>
<div class="relevance-bar" onclick="showChapterDetail(${ch.num})" style="cursor:pointer;">
<div class="relevance-fill" style="width:${relPercent}%"></div>
</div>
</div>
`;
}).join('');
setTimeout(checkSnippetToggle, 50);
}
// ===== 显示章节详情弹窗(核心关键词 + 原文) =====
function showChapterDetail(num) {
const ch = CHAPTERS.find(c => c.num === num);
if (!ch) return;
const searchKws = document.getElementById('searchInput').value.trim()
.split(/[\s,,、]+/).filter(k => k.length > 0);
// 核心 TF-IDF 关键词云
const keywordsHtml = ch.keywords.map(([word, score]) =>
`<span class="keyword-item" onclick="quickSearch('${word}')">
<span class="kw-name">${word}</span>
<span class="kw-score">${score.toFixed(4)}</span>
</span>`
).join('');
// 原文内容,高亮搜索关键词
const contentHtml = ch.content.map(para => {
let html = escapeHtml(para);
searchKws.forEach(kw => {
if (kw) {
const re = new RegExp(escapeRegExp(kw), 'g');
html = html.replace(re, `<mark>${kw}</mark>`);
}
});
return `<p>${html}</p>`;
}).join('');
const hintHtml = searchKws.length > 0 && searchKws[0]
? `<div class="content-search-hint">已在原文中高亮搜索词:<mark>${searchKws.join('</mark>、<mark>')}</mark></div>`
: '';
document.getElementById('modalBody').innerHTML = `
<h2>第${ch.num}回 ${ch.title}</h2>
<div class="modal-subtitle">${ch.juan}</div>
<div class="modal-tabs">
<button class="modal-tab active" onclick="switchTab('tab-keywords')">核心关键词</button>
<button class="modal-tab" onclick="switchTab('tab-content')">原文内容</button>
</div>
<div class="tab-content active" id="tab-keywords">
<div class="modal-subtitle">TF-IDF 核心关键词 Top ${ch.keywords.length}</div>
<div class="keyword-cloud">${keywordsHtml}</div>
<p class="hint" style="margin-top:15px;">提示:点击关键词可快速搜索包含该词的章节</p>
</div>
<div class="tab-content" id="tab-content">
${hintHtml}
<div class="chapter-content">${contentHtml}</div>
</div>
`;
document.getElementById('modalOverlay').classList.add('show');
}
// ===== 切换标签页 =====
function switchTab(tabId) {
document.querySelectorAll('.modal-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(tabId).classList.add('active');
}
// ===== 展开/收起摘要 =====
function toggleSnippet(snippetId, toggleId, btn) {
const snippet = document.getElementById(snippetId);
if (snippet.classList.contains('expanded')) {
snippet.classList.remove('expanded'); btn.textContent = '展开全文 ▾';
} else {
snippet.classList.add('expanded'); btn.textContent = '收起 ▴';
}
}
function checkSnippetToggle() {
document.querySelectorAll('.content-snippet').forEach(el => {
const btnId = el.id.replace('snippet-', 'toggle-');
const btn = document.getElementById(btnId);
if (btn && el.scrollHeight > 120) btn.classList.add('visible');
});
}
// ===== HTML 转义工具函数 =====
function escapeHtml(str) {
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ===== 关闭弹窗 =====
function closeModal(event) {
if (event.target === document.getElementById('modalOverlay')) {
document.getElementById('modalOverlay').classList.remove('show');
}
}
function closeModalDirect() {
document.getElementById('modalOverlay').classList.remove('show');
}
// ===== 渲染所有章节(浏览模式,展示每回 top5 关键词) =====
function renderAllChapters() {
const grid = document.getElementById('chapterGrid');
grid.innerHTML = CHAPTERS.map(ch => {
const topKws = ch.keywords.slice(0, 5).map(k => `<b>${k[0]}</b>`).join('、');
return `
<div class="chapter-card" onclick="showChapterDetail(${ch.num})">
<div class="cc-header">
<span class="cc-num">第${ch.num}回</span>
<span class="cc-title">${ch.title}</span>
</div>
<div class="cc-keywords">核心词:${topKws}...</div>
</div>
`;
}).join('');
}
</script>
</body>
</html>
代码逻辑要点:
| 函数 | 作用 | 对应 TF-IDF 环节 |
|---|---|---|
doSearch() |
遍历 CHAPTERS,按关键词匹配 wordData |
消费 TF-IDF 计算结果 |
setSort() |
切换按 TF-IDF / 词频排序 | 验证 TF-IDF 区分度 |
sortAndRender() |
排序并渲染结果卡片 + 原文高亮 | 可视化 TF-IDF 值 |
showChapterDetail() |
弹窗展示该回 top50 关键词 + 全文 | 验证关键词提取准确性 |
renderAllChapters() |
浏览模式展示每回 top5 关键词 | 全局概览 TF-IDF 成果 |
运行效果展示
使用方法 :把 红楼梦章节搜索.html 与 data.js 放在同一目录(HTML 通过 <script src="data.js"> 引入数据),双击 HTML 用浏览器打开即可。
搜索"葬花"------真实运行结果
在搜索框输入葬花回车,页面遍历 120 回的 wordData,找出含"葬花"的章节并按 TF-IDF 降序排列。实际运行结果如下:

如上图所示,"葬花"共匹配到 4 个章节,每条结果卡片显示章节标题、关键词标签、TF-IDF 值、词频及相关性进度条。按 TF-IDF 相关度降序排列的具体数据如下:
| 章节 | TF-IDF | 词频 | 相关性 |
|---|---|---|---|
| 第30回 宝钗借扇机带双敲 | 0.0515 | 2 | 100% |
| 第27回 埋香冢飞燕泣残红 | 0.0268 | 1 | 52% |
| 第96回 瞒消息凤姐设奇谋 | 0.0257 | 1 | 50% |
| 第76回 凸碧堂品笛感凄清 | 0.0203 | 1 | 39% |
验证结论
通过"葬花"搜索的真实运行结果,验证了整个 TF-IDF 案例的可行性,同时揭示了 TF-IDF 作为统计方法的本质特性:
- 可行性已验证:页面正确加载 120 回数据,搜索"葬花"精确匹配出 4 个含该词的章节,并按 TF-IDF 降序排列,每条结果附带 TF-IDF 值、词频、原文摘要高亮------整个数据链路(分词→TF-IDF→data.js→前端渲染)跑通,案例完全可用。
- 统计与语义的差异 :第30回 TF-IDF(0.0515)高于第27回(0.0268),这与"黛玉葬花在第27回"的文学直觉不符。原因在于:
- 第27回"葬花"仅出现 1 次(词频 1),而该回总词数多,TF 被稀释;
- 第30回"葬花"出现 2 次(词频 2),且该回总词数相对少,TF 更高。
- 这恰恰说明 TF-IDF 是基于词频统计的方法,不等于语义理解------它找的是"词与文档的统计相关性",而非"情节主题"。
- 关键词云的洞察价值 :第27回弹窗的 top1 关键词是"红玉"(0.2822)而非"葬花"。这是因为第27回有两条线索------黛玉葬花 + 林红玉(小红)与贾芸的手帕传情,"红玉"作为人名在该回高频且全局稀有,TF-IDF 自然最高。这体现了 TF-IDF 能发现人眼容易忽略的统计事实。
💡 这就是 TF-IDF 落地为产品的完整闭环 :算法(TF-IDF)→ 数据(data.js)→ 可视化(HTML 搜索页)→ 人可验证。真实运行结果既验证了案例可行性,也直观展示了 TF-IDF"统计而非语义"的特性。读者可自行搜索
刘姥姥验证其集中在第39/40回(刘姥姥进大观园),搜索晴雯验证其集中在第77回(俏丫鬟抱屈夭风流)------亲手验证每一个搜索结果,才是对案例可行性最好的实证。
五、TF-IDF 的应用价值
5.1 优势
| 优势 | 说明 |
|---|---|
| ✅ 简单高效 | 只需统计词频,无需训练,计算复杂度低 |
| ✅ 可解释性强 | 每个词的权重可直接解读,不像深度模型是黑箱 |
| ✅ 无监督 | 不需要标注数据,直接从语料统计 |
| ✅ 抗噪声 | IDF 自动压低常见词权重,过滤"的、了"等噪声 |
| ✅ 特征维度可控 | 通过 max_features 灵活控制特征数 |
| ✅ 工程成熟 | sklearn、Lucene、Elasticsearch 等均内置支持 |
5.2 局限性
| 局限 | 说明 |
|---|---|
| ❌ 丢失语序信息 | 词袋模型,"我打你"和"你打我"TF-IDF 完全相同 |
| ❌ 无法理解语义 | "汽车"和"轿车"是两个无关的词,无法捕捉同义关系 |
| ❌ 稀疏性高 | 词表大时矩阵稀疏,存储计算开销大 |
| ❌ 对短文本效果差 | 短文本词频统计噪声大,TF 不稳定 |
| ❌ 无法处理新词 | 词表固定,遇到未登录词无法表示 |
| ❌ IDF 依赖语料 | 同一词在不同语料库 IDF 不同,结果不可迁移 |
5.3 典型应用领域
① 关键词提取(本文案例)
从长文档中自动提取代表性词。本文红楼梦案例就是典型应用,输出每回 top50 关键词,可做摘要、标签生成、内容索引。
② 文本分类
把 TF-IDF 向量作为特征输入分类器(如朴素贝叶斯、SVM、逻辑回归)。垃圾邮件检测、新闻分类的传统方案几乎都是 TF-IDF + 分类器。本仓库的逻辑回归案例(信用卡欺诈、垃圾邮件)可改为 TF-IDF 特征。
③ 信息检索(搜索引擎核心)
搜索引擎的打分公式(如 Lucene 的 Practical Scoring Function)本质就是 TF-IDF 的变体:
score(query, doc) = Σ IDF(qi) × TF(qi, doc) × boost × norm
用户输入查询,对每个候选文档计算 TF-IDF 加权得分,按分排序返回。本文 红楼梦章节搜索.html 就是一个迷你搜索引擎:输入词→高亮显示出现在哪些回。
④ 文本相似度计算
把两篇文档表示为 TF-IDF 向量,用余弦相似度衡量相似程度:
python
from sklearn.metrics.pairwise import cosine_similarity
sim = cosine_similarity(tfidf[0], tfidf[1]) # 第一回和第二回的相似度
应用:查重系统、相似新闻推荐、问答系统匹配。
⑤ 推荐系统
基于内容的推荐:把用户历史阅读文章转为 TF-IDF 向量,与候选文章计算相似度,推荐相似内容。早期新闻推荐系统广泛采用此方案。
⑥ 文本聚类
用 TF-IDF 向量做 K-Means 聚类,自动发现文档主题分组。如把新闻自动归为"体育""财经""娱乐"等类。
六、总结与展望
6.1 核心要点归纳
| 要点 | 内容 |
|---|---|
| 本质 | 把文本转为数值向量的方法,让机器能处理文字 |
| 公式 | TF-IDF = 词频 × 逆文档频率 |
| TF | 衡量词对本文档的重要性(局部热度) |
| IDF | 衡量词在全语料的稀有度(全局稀有度) |
| 核心思想 | 给"局部高频+全局稀有"的词高权重 |
| 中文前置 | 必须用 jieba 分词 + 去停用词 + 空格拼接 |
| sklearn 工具 | TfidfVectorizer 一行完成向量化 |
| 输出 | 稀疏矩阵,行=文档、列=词、值=TF-IDF |
6.2 重要地位
TF-IDF 自 1972 年提出以来,历经半个世纪仍是 NLP 领域的基线方法(Baseline):
- 它是理解现代词向量技术的起点:Word2Vec、GloVe 等都是在弥补 TF-IDF 的语义缺陷。
- 它是搜索引擎的基石:Elasticsearch、Solr 等工业级搜索引擎至今仍内置 TF-IDF 变体(BM25)。
- 它是教学与工程的首选 baseline:做文本分类、检索任务,先跑 TF-IDF 基线,再考虑深度模型。
💡 工程经验:在数据量不大、追求可解释性、或作为 baseline 时,TF-IDF 往往不输甚至优于复杂模型。"不要用大炮打蚊子"是它的最佳写照。
6.3 改进方向与未来结合
TF-IDF 的直接改进:
- BM25:引入文档长度归一化和 TF 饱和函数,是 TF-IDF 的升级版,Elasticsearch 默认算法。
- TF-ICF:把 IDF 推广到类别频率,用于文本分类特征加权。
- 位置加权 TF-IDF:给标题、首段、首句的词更高 TF 权重。
与现代技术的结合前景:
| 结合方向 | 说明 |
|---|---|
| TF-IDF + Word2Vec | 用 TF-IDF 选出关键词,用 Word2Vec 扩展同义词,兼顾统计与语义 |
| TF-IDF + LDA | TF-IDF 作为 LDA 主题模型的预处理,提升主题质量 |
| TF-IDF + BERT | 混合特征:TF-IDF 提供词级统计信号,BERT 提供上下文语义信号,拼接后输入分类器 |
| TF-IDF + 深度检索 | 双阶段检索:TF-IDF 召回 → BERT 精排,兼顾效率与精度(现代搜索引擎标配) |
学习建议:理解 TF-IDF 后,下一步学习 Word2Vec(理解分布式表示)、Transformer/BERT(理解上下文表示),形成"统计方法 → 浅层语义 → 深度语义"的完整知识脉络。TF-IDF 不是过时的技术,而是理解一切现代 NLP 的地基。