3、词袋模型:从原始文本到数值特征
说实话,词袋模型是我入行时学的第一个文本表示方法。那时候觉得它太简单了,不就是数单词嘛。后来在几个真实项目里摔过跟头,才明白这个「简单」背后藏着不少门道。
这一章,咱们就把词袋模型、CountVectorizer 和 TF-IDF 彻底讲透。你学完就能自己动手,把一堆乱七八糟的文本变成规整的数值矩阵。
3.1 词袋模型的核心思想
词袋(Bag of Words,简称 BoW)的思路特别直白:忽略词语的顺序和语法,只关心每个词出现了多少次。
举个例子。两句话:
- 句子A:「我喜欢猫,也喜欢狗」
- 句子B:「狗喜欢猫,但猫不喜欢我」
用词袋模型表示,我们先建一个词典:{我, 喜欢, 猫, 也, 狗, 但, 不}。然后统计每个词在句子中出现的次数:
| 句子 | 我 | 喜欢 | 猫 | 也 | 狗 | 但 | 不 |
|---|---|---|---|---|---|---|---|
| A | 1 | 2 | 1 | 1 | 1 | 0 | 0 |
| B | 1 | 1 | 2 | 0 | 1 | 1 | 1 |
你看,原始文本的「顺序信息」全丢了。句子A和B的语序完全不同,但词袋模型只看频次。这就是它叫「词袋」的原因——把所有词倒进一个袋子里摇一摇,谁管你原来怎么排列的。
核心要点:词袋模型将每个文档映射为一个固定长度的向量,向量的维度等于词典大小,每个维度的值是该词在文档中出现的次数。
3.2 CountVectorizer 实战
在 scikit-learn 里,CountVectorizer 就是词袋模型的官方实现。我习惯叫它「计数器」,因为它干的事就是数数。
先看一个最基础的用法:
from sklearn.feature_extraction.text import CountVectorizer
# 原始语料
corpus = [
"我喜欢猫 也喜欢狗",
"狗喜欢猫 但猫不喜欢我"
]
# 初始化向量化器
vectorizer = CountVectorizer()
# 拟合并转换
X = vectorizer.fit_transform(corpus)
# 查看词典
print("词典:", vectorizer.get_feature_names_out())
# 输出: ['不' '也' '我' '喜欢' '猫' '狗' '但']
# 查看向量矩阵
print("矩阵:\n", X.toarray())
# 输出: [[0 1 1 2 1 1 0]
# [1 0 1 1 2 1 1]]
嗯,这里要注意:fit_transform 做了两件事——先学词典(fit),再转成向量(transform)。在实际项目中,你只需要在训练集上 fit 一次,验证集和测试集直接用 transform 就行。
我的习惯:永远先打印 get_feature_names_out() 看一眼词典。有一次我处理中文评论,发现词典里全是单字而不是词语,才意识到忘了做分词。这个小动作帮我省了不少排查时间。
3.2.1 常用参数调优
CountVectorizer 的参数不少,但真正常用的就这几个:
| 参数 | 作用 | 推荐值 |
|---|---|---|
max_features |
只保留出现频率最高的前 N 个词 | 5000~20000 |
min_df |
过滤掉出现次数少于该值的词 | 2~5 |
max_df |
过滤掉出现频率超过该比例的词(通常是停用词) | 0.8~0.95 |
ngram_range |
是否考虑词组(如两个词组合) | (1,1) 或 (1,2) |
stop_words |
指定停用词列表 | 'english' 或自定义列表 |
举个例子,加上这些参数后:
vectorizer = CountVectorizer(
max_features=1000, # 只保留前1000个高频词
min_df=2, # 至少在2个文档中出现过
max_df=0.9, # 去掉超过90%文档都出现的词
ngram_range=(1, 2), # 考虑单个词和两个词的组合
stop_words='english' # 去掉英文停用词
)
我曾经踩过的坑:有一次做中文文本分类,我直接用了默认参数。结果词典里全是「的」「了」「是」这些高频无意义词,模型准确率惨不忍睹。后来加了自定义中文停用词表,效果立竿见影。所以,停用词处理一定要根据语言和业务场景定制。
3.3 TF-IDF:给词频加点「权重」
纯词频有个明显的问题:有些词在所有文档里都出现很多次,比如「的」「是」「在」。这些词对区分文档没什么帮助,但它们的频次却很高。
TF-IDF 就是来解决这个问题的。它的全称是 Term Frequency - Inverse Document Frequency,翻译过来就是「词频-逆文档频率」。
说白了,TF-IDF 做了一件事:如果一个词在某篇文档中出现很多次,但在其他文档中出现很少,那这个词就很重要。
3.3.1 数学原理
TF-IDF 由两部分组成:
- TF(词频):词在文档中出现的次数。公式:
TF(t, d) = count(t, d) / len(d),其中 len(d) 是文档的总词数。 - IDF(逆文档频率):衡量词的稀有程度。公式:
IDF(t) = log(N / df(t)) + 1,其中 N 是文档总数,df(t) 是包含词 t 的文档数。
最终得分:TF-IDF(t, d) = TF(t, d) × IDF(t)
你想想看,如果一个词「猫」在 100 篇文档中出现了 80 篇,那它的 IDF 就很小,接近 1。而「机器学习」只出现在 5 篇文档中,IDF 就很大。这样,稀有但重要的词就能获得更高的权重。
3.3.2 手动实现 TF-IDF
为了让你彻底搞懂,咱们手写一个简单的 TF-IDF:
import math
from collections import Counter
def compute_tf(text):
"""计算词频"""
words = text.split()
word_count = len(words)
tf_dict = Counter(words)
for word in tf_dict:
tf_dict[word] /= word_count
return tf_dict
def compute_idf(documents):
"""计算逆文档频率"""
N = len(documents)
idf_dict = {}
# 统计每个词出现在多少篇文档中
df = {}
for doc in documents:
words = set(doc.split())
for word in words:
df[word] = df.get(word, 0) + 1
# 计算 IDF
for word, count in df.items():
idf_dict[word] = math.log(N / count) + 1
return idf_dict
def compute_tfidf(tf_dict, idf_dict):
"""计算 TF-IDF"""
tfidf = {}
for word, tf_val in tf_dict.items():
tfidf[word] = tf_val * idf_dict.get(word, 0)
return tfidf
# 测试
documents = [
"我 喜欢 猫 也 喜欢 狗",
"狗 喜欢 猫 但 猫 不 喜欢 我"
]
idf = compute_idf(documents)
for i, doc in enumerate(documents):
tf = compute_tf(doc)
tfidf = compute_tfidf(tf, idf)
print(f"文档{i+1}的TF-IDF: {tfidf}")
运行结果会显示,像「喜欢」这种在两个文档中都出现的词,TF-IDF 值会被压低。而「也」「但」「不」这些只在单个文档出现的词,权重会更高。
3.3.3 使用 TfidfVectorizer
当然,实际项目中我们直接用 scikit-learn 的 TfidfVectorizer:
from sklearn.feature_extraction.text import TfidfVectorizer
corpus = [
"我 喜欢 猫 也 喜欢 狗",
"狗 喜欢 猫 但 猫 不 喜欢 我"
]
vectorizer = TfidfVectorizer()
X = vectorizer.fit_transform(corpus)
print("词典:", vectorizer.get_feature_names_out())
print("TF-IDF矩阵:\n", X.toarray())
关键区别:CountVectorizer 输出的是整数频次,TfidfVectorizer 输出的是浮点数权重。TF-IDF 能更好地突出「有区分度」的词,在文本分类、信息检索等任务中通常表现更好。
3.4 词袋模型的知识体系
下面这张图帮你理清本章的核心脉络:
3.5 避坑指南与最佳实践
做了这么多年 NLP 项目,我总结了几条关于词袋模型的实战经验:
- 词典大小要控制:默认情况下,CountVectorizer 会把所有出现过的词都纳入词典。如果语料很大,词典可能达到几十万维。我建议用
max_features=10000做个截断,既能保留主要信息,又不会让矩阵太大。 - 中文一定要分词:词袋模型的最小单位是「词」,不是「字」。中文如果不分词,词典里全是单字,语义信息基本丢失。我习惯用 jieba 分词后再喂给 CountVectorizer。
- TF-IDF 不是万能的:在短文本(比如一句话)上,TF-IDF 的效果提升有限。因为短文本里每个词出现的次数都很少,IDF 的区分度体现不出来。
- 注意稀疏性:词袋模型生成的矩阵非常稀疏(大部分元素是0)。用
.toarray()转成稠密矩阵会占用大量内存。我建议直接用稀疏矩阵格式,scikit-learn 默认就是稀疏的。
我曾经犯过的错:有一次做新闻分类,我直接用 CountVectorizer 处理了 10 万篇文档,没设 max_features。结果词典大小超过 50 万,内存直接爆了。后来加了 max_features=20000,模型效果只下降了 2%,但内存占用减少了 90%。所以,别贪心,该截断就截断。