机器学习:TF-IDF

机器学习: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 隐马尔可夫模型的混合策略:

  1. 前缀词典扫描:用词典匹配出所有可能的词组合。
  2. 动态规划:基于词频构建有向无环图(DAG),求最大概率路径,得到最优切分。
  3. 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. 首行是元数据 :第 1 行是手机电子书·大学生小说网 更新时间:... 本章字数:8717,这是电子书网站的来源信息,不是小说正文。如果不剔除,"手机""电子书""小说网""更新时间"会被统计为词,污染 TF-IDF 结果。
  2. 正文含大量换行与全角空格 :每个段落以\n换行,段首还有全角空格  (占两个字符位)。换行符会打断分词,全角空格会被 jieba 切成无意义单字。
  3. 标点符号密集.,:""《》 等中文标点遍布全文。标点本身无语义,若不剔除会大量进入词表,稀释有效词的 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()

关键点解析

  1. 加载词库红楼梦词库.txt 每行一个专有名词,让 jieba 优先识别,避免人名地名被切碎。
  2. 停用词过滤seg not in stopwords.stopword.values 把标点、虚词、HTML 残留全部剔除,降低维度、消除噪声。
  3. 空格拼接 :sklearn 的 TfidfVectorizer 默认以空格分词,所以中文分词后必须用空格连成字符串。
  4. 输出文件分词后汇总.txt 每行对应一回的分词结果,作为 TF-IDF 的输入语料。
清洗后数据样本(分词后汇总.txt 第一行节选)
复制代码
开卷 第一回 作者 自云 因曾 历过 一番 梦幻 之后 真事 隐去 通灵 撰此 石头记 一书 故曰 甄士隐 但书中 所记 何事 何人 ...

对比原始数据,可以看到:

  • 首行元数据"手机电子书·大学生小说网..."已消失(被 next(f) 跳过)。
  • 换行和全角空格已去除(被 replace('\n','') 清理)。
  • 标点和虚词已剔除(被停用词表过滤)。
  • 人名甄士隐、专有词石头记通灵被正确切分(自定义词库生效)。
  • 词与词之间用空格分隔,符合 sklearn 输入要求。

这就是从"脏数据"到"TF-IDF 可用语料"的完整清洗链路。

输出格式:为什么是"每行一回、空格分词"?

分词后汇总.txt 的格式并非随意,而是为了对接 sklearnTfidfVectorizer------它接受一个可迭代的字符串序列,每个字符串是一个文档:

复制代码
# 极简案例 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 在上述流程基础上做了工程化增强,核心改进:

  1. 章节信息解析 :用正则从文件名提取卷、回数、标题,并用 chinese_to_int() 把"七十一"转成 71。
  2. 保留段落结构 :为前端展示,保留 paragraphs 列表而非压平成单行。
  3. 词频统计:除 TF-IDF 外,额外统计每回词频,供前端搜索时高亮。
  4. 数据序列化 :把所有信息打包成 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()">&times;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    }
    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 成果
运行效果展示

使用方法 :把 红楼梦章节搜索.htmldata.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 作为统计方法的本质特性

  1. 可行性已验证:页面正确加载 120 回数据,搜索"葬花"精确匹配出 4 个含该词的章节,并按 TF-IDF 降序排列,每条结果附带 TF-IDF 值、词频、原文摘要高亮------整个数据链路(分词→TF-IDF→data.js→前端渲染)跑通,案例完全可用。
  2. 统计与语义的差异 :第30回 TF-IDF(0.0515)高于第27回(0.0268),这与"黛玉葬花在第27回"的文学直觉不符。原因在于:
    • 第27回"葬花"仅出现 1 次(词频 1),而该回总词数多,TF 被稀释;
    • 第30回"葬花"出现 2 次(词频 2),且该回总词数相对少,TF 更高。
    • 这恰恰说明 TF-IDF 是基于词频统计的方法,不等于语义理解------它找的是"词与文档的统计相关性",而非"情节主题"。
  3. 关键词云的洞察价值 :第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 的直接改进

  1. BM25:引入文档长度归一化和 TF 饱和函数,是 TF-IDF 的升级版,Elasticsearch 默认算法。
  2. TF-ICF:把 IDF 推广到类别频率,用于文本分类特征加权。
  3. 位置加权 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 的地基。

相关推荐
iori97king1 小时前
织信开发日志 18:从 informat-skills 看织信如何把平台能力交给 AI Agent
人工智能·低代码·织信
daxiang_ipo1 小时前
AI应用,正式步入质变跃迁期
人工智能·搜索引擎·百度
前沿在线1 小时前
2026世界机器人大会主论坛大咖观点(一)
人工智能·ai·大模型
智搜广告1 小时前
AI回答优化公司智搜广告让品牌成为推荐首选
大数据·人工智能·python·elasticsearch·geo
Three_ST1 小时前
沐神-动手学习深度学习-习题 4.3. 多层感知机的简洁实现
人工智能·pytorch·python·深度学习·机器学习
必须会一定会1 小时前
Agent Handoff M5 发布验收:`CHANGES.md`、`npm pack`、`release:check` 与干净环境安装验证
前端·人工智能·npm·node.js·ai编程
Allen_LVyingbo1 小时前
面向电子病历的批量语义分析自动化工具:从设计到实战(上)
运维·人工智能·机器学习·语言模型·自然语言处理·自动化·健康医疗
明志数科1 小时前
从实验室到工厂产线:具身智能训练数据的环境差异、分布偏移与工业级采集方案
人工智能·深度学习·计算机视觉
武子康1 小时前
我让 Qwen3.6-27B 真改了一次 Git 仓库:工具调用怎样形成 Agent 闭环
人工智能·后端·agent