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 实战中的几个要点

最后,分享几个我在实战中总结的经验:

  1. 一定要做预处理:分词、去停用词、小写化(英文)、词干提取(英文)。这一步做不好,后面全是白费。
  2. 控制词典大小:我一般设置max_features=500010000。太大会引入噪声,太小会丢失信息。
  3. 用n-gram扩展特征:比如ngram_range=(1,2),可以捕捉"不 好"这样的短语,比单独的"不"和"好"更有意义。
  4. 注意数据泄露:一定要先切分训练集和测试集,再在训练集上fit,在测试集上transform。千万别在整个数据集上fit_transform,否则测试集的信息会泄露到训练集中。

嗯,这三种方法虽然基础,但它们是理解更高级文本表示(Word2Vec、BERT等)的基石。下一章,咱们就聊聊那些「带语义」的表示方法。