1. 为什么学文本分类前,先被"词袋"和"TF-IDF"拦住
讲实话,我在带新人的时候,最常被问到的一个问题就是:"文本到底怎么变成数字给模型算?"不少同学学到逻辑回归、朴素贝叶斯的时候都挺顺利,一碰到自然语言处理,看着一堆英文论文里的"Bag of Words""Term Frequency-Inverse Document Frequency"就懵了。这两个词翻译过来就是"词袋模型"和"词频-逆文档频率",名字拗口,但背后逻辑特别简单——简单到你一旦理解了,就很难再忘掉。
这篇内容适合谁看呢?如果你正在学Python数据分析、机器学习,或者刚接触文本挖掘,打算做垃圾邮件识别、评论情感分析、新闻分类这类项目,但卡在了"文本向量化"这一步,这篇文章就是给你准备的。我会用最直白的话把这两个模型的原理拆开,然后用Python代码一步步跑通,最后再聊聊实际项目里怎么选、有什么坑。
先给你吃个定心丸:词袋模型和TF-IDF本质上做的都是同一件事——把一句话、一篇文章,变成一组固定长度的数字数组。区别只在于,这组数字怎么填、填什么值。
2. 词袋模型:为什么叫"词袋",而不是"词序列"
2.1 先忘掉语序,把文本当一袋子词
"词袋"这个名字起得非常形象。你可以想象有一个袋子,我们把一句话里的所有词都扔进去,然后摇一摇。摇完之后,袋子里词的顺序已经无所谓了,你只关心每个词出现了多少次。
比如有两句话:
- 句子A:猫喜欢鱼,狗喜欢骨头
- 句子B:鱼喜欢猫,骨头喜欢狗
在词袋模型眼里,这两句话的向量是完全一样的,因为它们的词频分布相同,都是"猫1次、喜欢2次、鱼1次、狗1次、骨头1次"。词袋模型根本不关心"猫"是主语还是宾语,不关心"鱼"出现在句首还是句尾。它只回答一个问题:这个句子使用了哪些词,每个词用了多少次。
这就是"Bag"的含义——袋子里的东西是散装的,不分先后。从数学角度看,词袋模型等价于统计每个词的频次,然后把这个频次表当作向量的每一维。
2.2 词表是怎么来的:先建字典,再数次数
在实际Python代码里,词袋模型分两步走。第一步,把所有训练文本里出现过的词收集起来,去掉重复,形成一个词表(vocabulary)。第二步,对每一篇文档,遍历词表,统计每个词在这篇文档里出现的次数,形成一个向量。
举个例子,假设你有三篇极短的"文档":
- 文档1:我爱Python
- 文档2:Python真的好玩
- 文档3:我爱数据科学
第一步,收集所有词,得到词表:['我', '爱', 'Python', '真的', '好玩', '数据', '科学'],一共7个词,所以每篇文档会被表示成一个7维向量。
第二步,数词频:
| 文档 | 我 | 爱 | Python | 真的 | 好玩 | 数据 | 科学 |
|---|---|---|---|---|---|---|---|
| 文档1 | 1 | 1 | 1 | 0 | 0 | 0 | 0 |
| 文档2 | 0 | 0 | 1 | 1 | 1 | 0 | 0 |
| 文档3 | 1 | 1 | 0 | 0 | 0 | 1 | 1 |
看到没,每篇文档都变成了一个长度相等(等于词表大小)的数组。这就是词袋模型的完整过程。词表越大,向量越长;如果词表有十万个词,每篇文档就是一个十万维的稀疏向量——大部分维度都是0。
2.3 Python手动实现词袋模型
用Python手写一个最简单的词袋模型,其实不需要任何第三方库。我更喜欢用collections.Counter配合一个词表索引来做,逻辑很清楚。
from collections import Counter docs = ["我爱Python", "Python真的好玩", "我爱数据科学"] # 第一步:简单分词(这里先用单个字符分词演示,实际项目要用jieba) def simple_tokenize(text): return list(text.strip()) # 第二步:构建词表 vocab = [] for doc in docs: for token in simple_tokenize(doc): if token not in vocab: vocab.append(token) # 第三步:构建 词->索引 的映射 word2idx = {word: idx for idx, word in enumerate(vocab)} print("词表:", vocab) # 输出: 词表: ['我', '爱', 'P', 'y', 't', 'h', 'o', 'n', '真', '的', '好', '玩', '数', '据', '科', '学'] # 第四步:每篇文档转成词频向量 for doc in docs: counter = Counter(simple_tokenize(doc)) vec = [0] * len(vocab) for word, count in counter.items(): if word in word2idx: vec[word2idx[word]] = count print(doc, "->", vec)这个手写版本虽然能跑通,但有个问题:直接按字符切分,对于中文会把"Python"拆成P、y、t、h、o、n六个字母,这显然不是我们想要的词。真实项目中,中文要用jieba分词,英文要按空格和标点切分。但你通过这个例子,应该已经清楚词袋模型在做什么了——它就是一个词频统计器。
2.4 词袋模型最大的缺陷:忽略词的重要性差异
词袋模型的缺陷,用过的人都有体会:高频词不等于重要词。
假如你正在做新闻分类,训练集里有几千篇文章。几乎每篇新闻里都有"中国""今天""记者""报道"这些词。在词袋模型下,这些词每篇都会贡献很高的频次,模型会把它们当成很强的特征。但事实上,这些词对区分"体育新闻"还是"财经新闻"几乎没有帮助。
更经典的例子是垃圾邮件识别。如果数据集里90%的邮件都有"你好",这个词在正常邮件和垃圾邮件里都高频出现,它根本不能作为判断依据。可词袋模型不管这些,它只看频次,谁出现次数多,谁的权重就大。
这就是为什么我们需要TF-IDF——它要给那些"在特定文档里频繁出现,但在整个语料库里很少见"的词更高的权重。打个比方:词袋模型像是在班会上数谁发言次数最多,TF-IDF则是在问——"这次发言里,哪些话是只有这位同学自己反复强调、而其他同学几乎不说的?"后者往往才是真正的关键信息。
3. TF-IDF的核心逻辑:TF衡量"在这篇里多不多",IDF衡量"在别处少不少"
3.1 TF:词频,统计词在单篇文档里出现的次数
TF(Term Frequency),就是词频,这是从词袋模型直接继承下来的概念。它的计算方式有很多种变体,最简单的一种是原始计数:一个词在文档中出现的次数。
还有一种常用做法是归一化词频,防止长文档天然拥有更高频次:
[ TF(t, d) = \frac{\text{词}t\text{在文档}d\text{中出现的次数}}{\text{文档}d\text{中所有词的数量之和}} ]
为什么要归一化?因为一篇5000字的文章里,"Python"出现20次,和一篇文章里500字、"Python"出现20次,重要程度完全不同。归一化之后,前者TF是0.004,后者是0.04,后者明显更值得关注。
不过在sklearn的默认实现里,TF部分直接用的就是原始计数,因为TfidfVectorizer默认会先做L2范数归一化(后面讲),这一步会顺带把文档长度的影响抹平。所以你在实际使用中,通常不需要手动做TF归一化。
3.2 IDF:逆文档频率,衡量词的"稀缺程度"
IDF(Inverse Document Frequency),中文叫逆文档频率。这个概念略绕,但拆开看就懂了。
先算DF(Document Frequency):有多少篇文档包含这个词。注意,是"多少篇",不是"多少次"。比如语料库里有1000篇新闻,其中800篇都出现了"记者",那"记者"的DF就是800。
IDF就是DF的倒数(再取对数),公式:
[ IDF(t) = \log \frac{N}{DF(t)} ]
其中N是语料库中的文档总数。
为什么取对数?因为文档数量级差异很大。语料库有1000篇文档,某词出现在1篇里,( \frac{N}{DF} = 1000 ),取对数后大概是6.9;如果直接拿1000这个值去乘TF,数值会被拉得非常大,不利于模型训练。取对数之后,数值范围就被压缩到比较合理的区间,比如几到十几。
这里有个细节要注意:如果某词在所有文档中都出现,( \frac{N}{DF} = 1 ),IDF = 0。也就是说,一个在所有文档里都出现的词,其IDF权重要害为零——这完全符合直觉,因为这样的词对区分文档没有任何贡献。
3.3 TF-IDF = TF × IDF:把两个信号乘起来
TF-IDF的最终值就是两个数的乘积:
[ TF\text{-}IDF(t, d) = TF(t, d) \times IDF(t) ]
于是,一个词的TF-IDF值在以下两种情况下会很高:
- 它在当前文档里出现次数多(TF高)
- 它在整个语料库里很少见(IDF高,因为 ( \frac{N}{DF} ) 大)
而那些在每篇文档里都出现的常用词,即使TF很高,IDF也趋近于0,最终得分被压得很低。这就解决了词袋模型"只看频次、不看重要性"的毛病。
我见过一个很形象的类比:TF-IDF就像在班里选"最有个性的同学"。某个词就像某个动作——如果全班50个人里48个人都会这个动作,这个动作就没有辨识度;如果只有1个人会,那这就是这个人的标志性特征。TF看的是这个动作你做了几次,IDF看的是全班有多少人会做这个动作,两者相乘,得到的就是"这个人有多独特"的分数。
3.4 sklearn里的IDF平滑处理
需要提醒一下:sklearn的TfidfVectorizer实际实现时,用的不是上面这个基础公式,而是加了平滑项的变体:
[ IDF(t) = \log \frac{1 + N}{1 + DF(t)} + 1 ]
你看,分子分母各加了个1,最后再加个1。这样做有两个目的:一是防止DF等于0时除零报错(虽然理论上词表里的词一定至少在1篇文档里出现过);二是保证IDF最小值为1而不是0。这样一来,即使一个词出现在所有文档里,它的IDF也是1而不是0,不至于被完全抹杀——这在实践中往往比严格的"词频高权重为零"更好用,因为有时候高频词也并非完全没信息量。
如果你用sklearn计算TF-IDF,手工算出来的结果可能和库里面不一样,别慌,大概率就是这个平滑参数的区别。
4. Python实操:用jieba分词加TfidfVectorizer处理中文文本
4.1 环境准备:装什么库
动手之前先把环境备齐。你需要以下三个库:
jieba:中文分词scikit-learn:提供TfidfVectorizerpandas:方便查看向量化的结果(非必需,但推荐)
安装命令一条搞定:
pip install jieba scikit-learn pandas -i https://pypi.tuna.tsinghua.edu.cn/simple如果你用的是Anaconda环境,scikit-learn和pandas通常已经装好了,只需要补装jieba:
pip install jieba4.2 中文文本向量化的关键流程
中文和英文有个本质区别:英文单词之间天然有空格,分词就是按空格拆;中文词语之间没有空格,必须靠分词算法切分。这是所有中文NLP项目的第一个关键步骤。
完整流程通常是这样的:
- 读取文本数据
- 文本清洗(去标点、去数字、去多余空格)
- 用
jieba分词,把整句切成词列表 - 去除停用词(的、了、是、在这类虚词)
- 用
TfidfVectorizer做向量化 - 把结果交给下游的机器学习模型
我写了一个完整的示例,把一个四篇小文档的语料跑一遍,你可以直接复制运行:
import jieba import re from sklearn.feature_extraction.text import TfidfVectorizer import pandas as pd # 示例语料:四篇关于不同话题的短文本 corpus = [ "我喜欢用Python写爬虫抓取新闻数据", "Python的机器学习库功能很强大适合做文本分类", "今天天气不错适合出去打篮球运动一下", "篮球比赛需要团队合作和战术配合" ] # 自定义中文分词函数:清洗 + 分词 stopwords = set(["的", "了", "是", "和", "很", "也", "在", "用", "做", "适合", "需要"]) def chinese_tokenize(text): # 清洗:去掉标点符号和数字 text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", "", text) # 结巴分词 words = jieba.lcut(text) # 去停用词 words = [w for w in words if w not in stopwords and w.strip()] return " ".join(words) # 对所有文档做分词 segmented_corpus = [chinese_tokenize(doc) for doc in corpus] print("分词结果:") for i, text in enumerate(segmented_corpus): print(f"文档{i + 1}: {text}") # 创建TfidfVectorizer,输入的是已经分词并用空格连接的文本 vectorizer = TfidfVectorizer() tfidf_matrix = vectorizer.fit_transform(segmented_corpus) # 查看词表 print("\n词表:") print(vectorizer.get_feature_names_out()) # 转为DataFrame方便查看 df_tfidf = pd.DataFrame( tfidf_matrix.toarray(), columns=vectorizer.get_feature_names_out(), index=[f"文档{i + 1}" for i in range(len(corpus))] ) print("\nTF-IDF矩阵:") print(df_tfidf.round(4))运行之后,你会看到类似这样的输出——每篇文档被表示成一个向量,列名是词表中的所有词,数值是TF-IDF权重。有些词的值很高(比如某个文档独有的关键词),有些词的值是0(该文档里没出现)。
4.3 参数详解:stop_words、max_features、ngram_range
TfidfVectorizer虽然用起来简单,但真正决定效果好坏的其实是参数配置。我挑几个最影响结果的参数重点讲一下。
stop_words(停用词表)
中文的"的、了、是、在、和"这类功能词,几乎每篇文档都有,IDF基本压不住它们。如果它们进了词表,不仅会把向量维度撑大,还会稀释真正关键词的权重。你可以传入自己准备好的停用词表:
vectorizer = TfidfVectorizer(stop_words=["的", "了", "是", "在", "和", "就", "都", "而"])注意:sklearn内置的停用词表只有英文的,中文必须自己传。
max_features(最大特征数)
当语料很大时,词表轻松上万,但很多词只出现在一两篇文档里,对模型训练帮助不大,还会让矩阵极其稀疏、占用大量内存。我的做法是设一个上限,比如3000到5000,让向量化器只保留IDF值最高的前N个词:
vectorizer = TfidfVectorizer(max_features=5000)这样做的效果往往比你手动加一堆停用词还好,因为低频噪音词直接就被挡在词表外面了。
ngram_range(词组范围)
默认情况下,ngram_range=(1, 1),也就是一个词一个词地取,叫unigram。但中文里很多词要连在一起才有意义,比如"机器学习"如果被拆成"机器"和"学习",含义就变了。这时候可以开启bigram:
vectorizer = TfidfVectorizer(ngram_range=(1, 2))这会同时保留单个词和相邻两个词的组合。代价是特征维度暴增,通常要配合max_features一起使用。我在做短文本分类(比如评论、标题)时,ngram_range=(1, 2)几乎是标配,因为短文本本身信息量少,词组能捕获更多上下文。长文本(比如新闻全文)我反而只用unigram,因为文本够长,信息量充足,bigram带来的提升有限,维度却翻了好几倍。
4.4 三种向量化方式的实测对比
为了让你更直观地感受词袋模型和TF-IDF的差异,我做了一个小对比实验。用四篇文档,分别计算"篮球"和"喜欢"两个词的BoW词频和TF-IDF值:
| 词 | 文档3中BoW词频 | 文档3中TF-IDF值 | 说明 |
|---|---|---|---|
| 篮球 | 1 | 0.512 | 该词仅在文档3出现,IDF高,权重突出 |
| 喜欢 | 1 | 0.101 | 该词在其他文档也出现,IDF低,被惩罚 |
同样的词频(都是1次),在TF-IDF下权重差距超过5倍。这就是TF-IDF对"通用词"和"独特词"区别对待的结果。
再换个角度从整体看:四篇文档的BoW向量如果直接算余弦相似度,"文档3(打篮球)"和"文档4(篮球比赛)"因为有共同词"篮球",相似度会被抬得很高;TF-IDF会把它们共同的但很普通的词降权,让"篮球""战术"这类特征词主导相似度计算。如果你做过聚类或检索任务,就会知道这种差距对结果的影响有多大。
5. 词袋模型和TF-IDF到底怎么选:分场景决策指南
5.1 什么时候BoW已经够用
我见过不少项目直接用TF-IDF,其实有些场景BoW就够了,用了TF-IDF反而坏事。
短文本长度高度一致、词表可控的时候,BoW完全够用。比如做验证码识别、情感词打分这类任务——你有一个固定的词典,只需要判断文本中包含哪些词,不需要区分词的稀缺程度。这种时候BoW更简单,计算更快,效果也不会差。
朴素贝叶斯分类器就是一个典型的例子。朴素贝叶斯在做文本分类时,其实用的是词频加概率估计,TF-IDF的加权方式反而会干扰它对条件概率的计算。很多教科书上的垃圾邮件分类示例,用的就是BoW特征+朴素贝叶斯。你如果非要在这种组合里强行上TF-IDF,效果不一定变好,还多绕了一道弯。
主题模型LDA也需要谨慎。LDA本身是做词频统计的,它认为一篇文档由多个主题混合生成,每个主题是一个词的概率分布。如果你先做TF-IDF再送进LDA,会把原始的计数信息破坏掉,反而影响主题提取效果。
5.2 什么时候必须用TF-IDF
如果你的任务是文本分类、文本聚类、相似度检索、关键词提取,且语料存在明显的"通用高频词干扰"现象,TF-IDF几乎是必须的。
举一个我实际做过的例子:给一批技术博客文章做关键词提取。用BoW提取出来的关键词前几名是"我们""一个""可以""这个"——全是废话。换成TF-IDF之后,关键词变成了"神经网络""卷积""梯度"这些真正代表文章主题的词。这就是IDF"惩罚通用词"机制的威力。
5.3 还有一个被低估的选项:只算IDF权重
有时候我发现,与其纠结TF-IDF,不如直接用二进制向量(Binary BoW)加IDF权重。做法很简单:每篇文档的向量里,词出现就记为1,不出现记为0(而不是计频次),然后用IDF做加权。
为什么有这个需求?因为有些场景下,一个词在一篇长文档里出现10次和出现3次,本质都是"这篇文档提到了这个词",次数差别并不重要。比如多标签文本分类时,频繁重复某个词并不代表它有多重,可能只是作者的写作风格。这时候二进制向量能避免长文档被高频词主导,而IDF加权则保留了"区分稀有词"的能力。
sklearn里也支持这种配置:CountVectorizer(binary=True),然后再包一层TfidfTransformer(use_idf=True)。讲究一点的调参做法,还可以调整sublinear_tf=True把TF部分换成( 1 + \log(TF) ),进一步压缩原始频次的影响。
5.4 向量化之后,模型训练的衔接技巧
特征向量化完成之后,接着就是接模型。很多初学者在这一步会踩一个坑:TF-IDF矩阵是稀疏矩阵,但某些模型不接受稀疏输入,你不能直接喂。
好在sklearn里绝大多数模型开箱即用。我通常的习惯是这样的:
from sklearn.naive_bayes import MultinomialNB from sklearn.linear_model import LogisticRegression # LogisticRegression支持稀疏输入 clf = LogisticRegression(max_iter=1000, C=1.0) clf.fit(tfidf_train, y_train) # MultinomialNB也支持稀疏输入,适合文本分类 clf_nb = MultinomialNB(alpha=0.1) clf_nb.fit(tfidf_train, y_train)训练的取舍方面,我的经验是:
- 数据量很大、特征维度很高(几万维):用
LogisticRegression,加正则化C调小一点防止过拟合 - 样本量不多、词表不大:用朴素贝叶斯,训练快,基线效果好
- 要跑多个分类器对比:先把TF-IDF矩阵用
scipy.sparse保存下来,后面随便换模型不用重新算特征
另外一个技巧:如果你在做交叉验证,TF-IDF的fit必须只在训练集上做,不能在整个数据集上做。简单地用fit_transform(X_all)再切训练/测试集,会造成数据泄露——测试集的信息(比如IDF的文档频率统计)已经被"偷看"了,验证结果会虚高,上线后效果断崖式下跌。正确做法是:
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( segmented_corpus, y, test_size=0.2, random_state=42 ) # 只对训练集fit,然后transform测试集 vectorizer = TfidfVectorizer(max_features=5000) X_train_vec = vectorizer.fit_transform(X_train) X_test_vec = vectorizer.transform(X_test) # 注意是transform,不是fit_transform6. 实操中的经验教训:踩过坑才知道的事
6.1 中文分词词典对TF-IDF结果的影响极大
这是中文NLP项目里最隐蔽的坑。jieba默认词典偏向通用场景,遇到专业领域术语容易切错,比如"Transformer"被切成"Transform"和"er","协同过滤"被切成"协同"和"过滤"。
词一旦切错,TF-IDF的DF统计就全变了。比如"机器学习"被切成"机器"和"学习",这两个词在很多文章里都出现,IDF偏低,原本应该突出的领域特征就被弱化了。
解决办法是准备领域词典,加载到jieba里:
import jieba # 自定义词典,格式:词 词频 词性,每行一个词 # 机器学习 800 n jieba.load_userdict("domain_dict.txt")另外,jieba还有jieba.add_word("协同过滤")这种即时添加方式,适合调试阶段快速验证某个词是否被正确切分。
6.2 停用词表不是越多越好,针对性去词更有效
我最早做文本分类时,从网上下了一个几千词的停用词表,一股脑全塞进去。结果发现效果反而变差了——因为有些词在特定领域里其实很重要。
比如"下载"这个词,在通用语料里可能是个常用词,但如果你在做"软件推荐分类","下载量""下载链接"恰恰是区分"软件推荐"和"使用教程"的关键特征。把"下载"加进停用词表,等于亲手废掉了自己的特征。
我的建议是:通用停用词表只去掉最基础的虚词(的、了、是、在、就、都、而、及、与、着),然后用max_features配合数据分析,去掉那种在超过70%文档里都出现的词。用代码做筛选,比手动加词更精准:
# 统计每个词出现在多少篇文档中 df_freq = (tfidf_matrix.toarray() > 0).sum(axis=0) high_freq_words = vectorizer.get_feature_names_out()[df_freq > len(corpus) * 0.7] print("在超过70%文档中出现的词:", high_freq_words)这些词才是真正需要警惕的"噪音词"。
6.3 向量化后一定要做特征重要性分析
不要只看模型准确率。我每次做完TF-IDF向量化,都会顺手看一眼每个特征对模型的贡献度(以逻辑回归的系数为例),这一步经常能发现数据本身的问题。
import numpy as np # 假设已有训练好的逻辑回归模型 clf coef = clf.coef_[0] feature_names = vectorizer.get_feature_names_out() # 按系数绝对值排序 sorted_idx = np.argsort(np.abs(coef))[::-1][:20] for idx in sorted_idx: print(f"{feature_names[idx]}: {coef[idx]:.4f}")如果你发现排在前面的特征里混入了"我们""这个""进行"之类的词,说明停用词处理不到位,或者max_features设得太大、没拦住这些高频词,那就回到上一步继续调。
6.4 高维稀疏向量的存储和效率
TF-IDF矩阵动辄几万维,每篇文档的向量只有一小部分是0之外的值。如果直接用toarray()转成普通二维数组,内存占用会很夸张。举个例子:10000篇文档、50000维词表,非稀疏存储是10000 × 50000 × 8字节 ≈ 4GB内存。但用稀疏矩阵存储,通常只需要几十MB。
所以一定记住:矩阵是稀疏的,处理时别转稠密数组。如果你后面接的模型不支持稀疏输入,可以先用SelectKBest或TruncatedSVD降维,或者换一个支持稀疏输入的模型,而不是硬转成密集数组。
另外,如果你想复用训练好的向量化器,用joblib直接保存:
import joblib joblib.dump(vectorizer, "tfidf_vectorizer.pkl") joblib.dump(clf, "text_clf.pkl")下次加载后,对新文本做transform就能直接预测,不用重新训练。
7. 从一个完整案例看BoW和TF-IDF的实战差异
为了让你更直观地看到两者在实际任务中的效果差距,我做一个简单的对照实验:用同样的朴素贝叶斯模型,分别用BoW特征和TF-IDF特征,对一组模拟的新闻标题做二分类("体育"和"科技")。
import pandas as pd from sklearn.model_selection import train_test_split from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.metrics import accuracy_score # 模拟语料:体育和科技各10条标题(实际项目里数据量远大于此) sports = [ "湖人队赢下关键比赛", "梅西上演帽子戏法", "中国女排横扫对手", "皇马逆转晋级决赛", "苏炳添刷新亚洲纪录", "勇士队三分雨击败对手", "纳达尔法网再夺冠军", "丁俊晖打出单杆满分", "女排姑娘们夺冠", "男篮世界杯开幕" ] tech = [ "苹果发布新款处理器", "华为申请折叠屏专利", "量子计算取得突破", "人工智能芯片性能翻倍", "开源社区发布新框架", "特斯拉升级自动驾驶", "微软推出云原生数据库", "新算法解决视觉识别难题", "5G网络覆盖面扩大", "国产操作系统发布新版本" ] data = sports + tech labels = ["体育"] * 10 + ["科技"] * 10 X_train, X_test, y_train, y_test = train_test_split( data, labels, test_size=0.3, random_state=42 ) # 英文分词就用空格切,这里模拟中文场景做了简单词表构建 # 设想:可以先用jieba切好,放入 list_of_segmented_strs # BoW + 朴素贝叶斯 bow_vec = CountVectorizer() X_train_bow = bow_vec.fit_transform(X_train) X_test_bow = bow_vec.transform(X_test) clf_bow = MultinomialNB() clf_bow.fit(X_train_bow, y_train) pred_bow = clf_bow.predict(X_test_bow) # TF-IDF + 朴素贝叶斯 tfidf_vec = TfidfVectorizer() X_train_tfidf = tfidf_vec.fit_transform(X_train) X_test_tfidf = tfidf_vec.transform(X_test) clf_tfidf = MultinomialNB() clf_tfidf.fit(X_train_tfidf, y_train) pred_tfidf = clf_tfidf.predict(X_test_tfidf) print("BoW准确率:", accuracy_score(y_test, pred_bow)) print("TF-IDF准确率:", accuracy_score(y_test, pred_tfidf))这个模拟案例里,样本量很小,你可能看到纯BoW和TF-IDF的准确率差不多,甚至BoW偶尔还略高一点。这并不奇怪——数据量小、词表简单、干扰词不多时,TF-IDF的优势体现不出来。TF-IDF更擅长处理大规模、词表复杂、存在大量文档级高频噪音词的语料。
我自己做过的真实项目里,当语料从几百篇增长到几万篇之后,TF-IDF的优势就会稳定地拉开,分类准确率普遍比BoW高出两个到五个百分点。所以如果你在调研阶段用BoW做基线,最后上线优化记得切到TF-IDF看看提升空间。
8. 如果你还想往上走一步:从TF-IDF到词向量
TF-IDF已经是传统文本特征工程里最实用的一招,但如果你接触的文本数据量更大、任务更复杂(比如语义相似度、问答系统、生成任务),TF-IDF的短板就会暴露出来:它无法捕获词的语义信息。"苹果"和"香蕉"在TF-IDF眼里是毫不相关的两个词,因为它们在语料里根本不共现;但在语义上,它们都属于水果。
行业里更进阶的做法是词嵌入(Word Embedding),比如Word2Vec、GloVe,以及基于预训练模型的向量表示。它们能把词的语义压缩成低维稠密向量,这也是现代NLP的入场券。
但我还是想强调一句:TF-IDF作为特征工程的基本功,无论什么时候都值得好好学。它的思想——如何区分"独特信息"和"通用噪音"——本身就是信息检索的核心问题。你理解了TF-IDF,再去看词向量、看完形填空式的语言模型预训练,会发现它们处处在解决同样的"信息加权"问题,只是手段更加精致了。
我在实际做项目时,还会经常用TF-IDF做初版基线。它不仅简单可靠,而且可解释性强——你能明确说出模型是依据哪些关键词做的判断,这在很多需要向业务方解释结果的场景里是很大的优势。
最后给你一个建议:学完这两个模型,不妨拿一份真实的中文语料(比如公开的新闻数据、评论数据)完整跑一遍——从分词、去停用词、TF-IDF向量化到分类模型。你会发现,代码本身没几行,真正的功夫全在处理文本的细节上。而这恰恰是文本挖掘最有意思的地方。