4、文本表示方法:独热编码、词袋模型、TF-IDF原理与Scikit-learn实现
文本表示,说白了就是把人类语言翻译成机器能懂的数字。
我刚开始做NLP时,总觉得这一步很简单。不就是把文字转成数字吗?后来踩了不少坑才明白——文本表示的质量,直接决定了模型的天花板。你模型再花哨,输入的特征是垃圾,输出也必然是垃圾。
这一章,咱们就聊聊三种最经典的文本表示方法。它们虽然基础,但直到今天,很多工业级系统还在用。
4.1 独热编码:最朴素的表示法
独热编码(One-hot Encoding)的思路非常直白:
- 先建一个词典,里面包含所有出现的词
- 每个词对应一个向量,向量的长度等于词典大小
- 这个词的位置是1,其他位置全是0
举个例子。假设我们有三个词:["我", "爱", "你"]。
词典大小是3。那么:
- "我" → [1, 0, 0]
- "爱" → [0, 1, 0]
- "你" → [0, 0, 1]
嗯,就是这么简单。
核心特点:
- 每个向量只有一个1,其余全是0
- 向量之间相互正交(点积为0)
- 无法表达词与词之间的语义关系
我在项目中遇到过一个问题:用独热编码做情感分析,词典有5万个词。结果每个样本的向量是5万维,稀疏得可怕。模型训练慢不说,效果还差。
我曾经踩过的坑:独热编码无法处理未登录词。如果测试集里出现了训练集没见过的词,直接报错。所以一定要做好词典的兜底策略,比如加一个<UNK>标记。
4.2 词袋模型:考虑词频
独热编码只告诉你「这个词出现了没」,但没告诉你「出现了几次」。
词袋模型(Bag of Words, BoW)解决了这个问题。它统计每个词在文档中出现的次数。
比如两句话:
- 句子A:"我 爱 你"
- 句子B:"我 爱 你 你 你"
词典是["我", "爱", "你"]。那么:
- 句子A → [1, 1, 1]
- 句子B → [1, 1, 3]
你看,句子B中"你"出现了3次,所以第三维是3。
我个人的习惯:在做BoW之前,一定要做分词。中文不像英文天然有空格分隔。分词质量直接影响BoW的效果。我一般用jieba分词,配合自定义词典。
Scikit-learn实现BoW非常方便:
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())
# 输出:
# [[1 0 0 1 1 0]
# [0 1 0 1 0 1]
# [0 0 1 0 1 0]]
你想想看,这里有个问题:高频词(比如"的"、"了")会占据很大的权重,但它们其实没什么信息量。怎么办?
TF-IDF就是来解决这个问题的。
4.3 TF-IDF:给词加权
TF-IDF的全称是Term Frequency - Inverse Document Frequency。
说白了就是:一个词在本文档中出现越频繁,在其他文档中出现越少,它就越重要。
公式其实很简单:
| 组成部分 | 公式 | 含义 |
|---|---|---|
| TF(词频) | TF(t,d) = 词t在文档d中出现的次数 / 文档d的总词数 | 这个词在当前文档中重不重要 |
| IDF(逆文档频率) | IDF(t) = log(总文档数 / 包含词t的文档数 + 1) | 这个词在整个语料中稀不稀有 |
| TF-IDF | TF-IDF(t,d) = TF(t,d) × IDF(t) | 综合权重 |
为什么会这样设计?
我举个例子你就明白了。假设你在做新闻分类,语料里有1000篇文章。
- "的"这个词,几乎每篇文章都有。IDF = log(1000/1000) = 0。权重为0,完美过滤。
- "人工智能"这个词,只有10篇文章有。IDF = log(1000/10) ≈ 4.6。权重很高。
这样一来,真正有区分度的词就被凸显出来了。
避坑指南:我曾经在做一个法律文书分类项目时,直接用TF-IDF,结果效果很差。后来发现是因为文档长度差异太大——有的文书几百字,有的几万字。长文档的TF天然就高。解决方案是用sublinear_tf=True,对TF做对数平滑。
Scikit-learn实现TF-IDF:
from sklearn.feature_extraction.text import TfidfVectorizer
# 同样的语料
corpus = [
"我 爱 自然语言处理",
"我 喜欢 深度学习",
"自然语言处理 很 有趣"
]
# 创建TF-IDF模型
tfidf = TfidfVectorizer()
X_tfidf = tfidf.fit_transform(corpus)
# 查看TF-IDF矩阵
print("TF-IDF矩阵:\n", X_tfidf.toarray())
# 输出(数值已做近似):
# [[0.58 0. 0. 0.58 0.58 0. ]
# [0. 0.58 0. 0.58 0. 0.58]
# [0. 0. 0.58 0. 0.58 0. ]]
# 查看每个词的IDF值
print("IDF值:", dict(zip(tfidf.get_feature_names_out(), tfidf.idf_)))
4.4 三种方法的对比
我把它们放在一起对比一下,你心里就有数了:
| 方法 | 维度 | 是否考虑词频 | 是否考虑稀有度 | 适用场景 |
|---|---|---|---|---|
| 独热编码 | 词典大小 | 否 | 否 | 类别特征、小规模词典 |
| 词袋模型 | 词典大小 | 是 | 否 | 短文本、主题分布均匀 |
| TF-IDF | 词典大小 | 是 | 是 | 长文本、需要过滤停用词 |
我的建议:除非你有特殊理由,否则直接上TF-IDF。它几乎在所有场景下都优于BoW。独热编码现在用得少了,但在处理类别型特征(比如用户ID、商品ID)时还是很有用的。
4.5 实战中的几个要点
最后,分享几个我在实战中总结的经验:
- 一定要做预处理:分词、去停用词、小写化(英文)、词干提取(英文)。这一步做不好,后面全是白费。
- 控制词典大小:我一般设置
max_features=5000或10000。太大会引入噪声,太小会丢失信息。 - 用n-gram扩展特征:比如
ngram_range=(1,2),可以捕捉"不 好"这样的短语,比单独的"不"和"好"更有意义。 - 注意数据泄露:一定要先切分训练集和测试集,再在训练集上fit,在测试集上transform。千万别在整个数据集上fit_transform,否则测试集的信息会泄露到训练集中。
嗯,这三种方法虽然基础,但它们是理解更高级文本表示(Word2Vec、BERT等)的基石。下一章,咱们就聊聊那些「带语义」的表示方法。