词袋与 TF-IDF:文本表示


文档摘要

词袋与 TF-IDF:文本表示 本节摘要:先计数,后思考。在定义明确的任务上,TF-IDF 到 2026 年仍然胜过嵌入。模型要数字,你手里却只有字符串。每个 NLP 流水线都要回答同一个问题:如何把变长 token 流变成分类器能消化的定长向量。这个领域给出的第一个答案是「最笨但能用」的——数词频,造向量。词袋(Bag of Words)丢掉顺序,TF-IDF 给词袋重新加权:出现在每篇文档里的词不携带信息,要压低;在单篇里高频、在全语料里罕见的词才是信号,要抬高。本节从零实现词袋与 TF-IDF,再用 scikit-learn 三行复现,最后点出那个逼你转向嵌入的失败模式。 对应原课程:Phase 5 · Lesson 02 · (原英文 )。

词袋与 TF-IDF:文本表示

本节摘要:先计数,后思考。在定义明确的任务上,TF-IDF 到 2026 年仍然胜过嵌入。模型要数字,你手里却只有字符串。每个 NLP 流水线都要回答同一个问题:如何把变长 token 流变成分类器能消化的定长向量。这个领域给出的第一个答案是「最笨但能用」的——数词频,造向量。词袋(Bag of Words)丢掉顺序,TF-IDF 给词袋重新加权:出现在每篇文档里的词不携带信息,要压低;在单篇里高频、在全语料里罕见的词才是信号,要抬高。本节从零实现词袋与 TF-IDF,再用 scikit-learn 三行复现,最后点出那个逼你转向嵌入的失败模式。

对应原课程:Phase 5 · Lesson 02 · bag-of-words-tfidf(原英文 phases/05-nlp-foundations-to-advanced/02-bag-of-words-tfidf/docs/en.md)。前置依赖:第 01 节(文本处理)、Phase 2 · 02(从零实现线性回归)。

学习目标

阅读完本节,你应当能够:

  1. 说清词袋与 TF-IDF 各自做什么、为何 TF-IDF 在 2026 年仍是窄分类任务的默认首选。
  2. 从零实现词袋矩阵、词频/文档频率、IDF、TF-IDF 加权与 L2 归一化。
  3. scikit-learnCountVectorizer / TfidfVectorizer 复现同一工作,并说清每个关键参数的作用。
  4. 识别 TF-IDF 的语义盲区与 OOV(词表外)失败,并掌握「TF-IDF 加权嵌入」这一混合折中。

一、问题与直觉

模型要数字,你手里只有字符串。

每个 NLP 流水线都要回答同一个问题:如何把一段变长的 token 流,变成一个定长的、分类器能吃的向量。这个领域最早给出的答案,是最笨却管用的那个——数词频,造向量。

这个向量扛起了远超任何嵌入模型的生产 NLP 体量:垃圾邮件过滤、主题分类、日志异常检测、搜索排序(BM25 之前)、第一波情感分析、学术 NLP 基准的第一个十年。2026 年的从业者在窄分类任务上,仍然会先伸手去拿它。它快、可解释,而且在「词的出现与否就是关键」的任务上,常常和一个 4 亿参数的嵌入模型难以区分

两种表示,各有职责:

  • **词袋(BoW)**丢掉顺序。对每篇文档,统计每个词表词出现多少次。向量长度等于词表大小,第 i 位就是第 i 个词的计数。
  • TF-IDF 给词袋重新加权。出现在每篇文档里的词不携带信息,压低它;在全语料罕见、在单篇高频的词是信号,抬高它。
TF-IDF(w, d) = TF(w, d) * IDF(w) = count(w in d) / |d| * log(N / df(w))

其中 TF 是词在文档里的频率,df 是文档频率(多少篇文档含该词),N 是文档总数。log 让无处不在的词权重有界。

💡 关键性质:两者都产出轴可解释的稀疏向量。你可以盯着训练好的分类器权重,读出哪些词把一篇文档推向某一类——768 维的 BERT 嵌入做不到这一点。

二、从零实现

第 1 步:构建词表

def build_vocab(docs): vocab = {} for doc in docs: for token in doc: if token not in vocab: vocab[token] = len(vocab) return vocab

输入是分词后的文档列表(任意词级分词器都行;本节 code/main.py 用一个简化的小写变体)。输出是 {word: index} 字典。稳定的插入顺序意味着,索引 0 是第一篇文档里出现的第一个词。惯例因库而异,scikit-learn 按字母排序。

第 2 步:词袋

def bag_of_words(docs, vocab): matrix = [[0] * len(vocab) for _ in docs] for i, doc in enumerate(docs): for token in doc: if token in vocab: matrix[i][vocab[token]] += 1 return matrix
>>> docs = [["cat", "sat", "on", "mat"], ["cat", "cat", "ran"]] >>> vocab = build_vocab(docs) >>> bag_of_words(docs, vocab) [[1, 1, 1, 1, 0], [2, 0, 0, 0, 1]]

行是文档,列是词表索引。[i][j] 表示「词 j 在文档 i 中出现几次」。文档 1 的 cat 是 2,因为它确实出现两次;文档 0 的 ran 是 0,因为它没出现。

第 3 步:词频与文档频率

import math def term_frequency(doc_bow, doc_length): return [c / doc_length if doc_length else 0 for c in doc_bow] def document_frequency(bow_matrix): df = [0] * len(bow_matrix[0]) for row in bow_matrix: for j, count in enumerate(row): if count > 0: df[j] += 1 return df def inverse_document_frequency(df, n_docs): return [math.log((n_docs + 1) / (d + 1)) + 1 for d in df]

两个平滑技巧值得一说。(n+1)/(d+1) 避免 log(x/0);末尾的 +1 保证一个出现在每篇文档里的词仍有 IDF 值 1(而非 0),与 scikit-learn 默认一致。别的实现用原始 log(N/df),两者都行,平滑版更友好。

第 4 步:TF-IDF

def tfidf(bow_matrix): n_docs = len(bow_matrix) df = document_frequency(bow_matrix) idf = inverse_document_frequency(df, n_docs) out = [] for row in bow_matrix: length = sum(row) tf = term_frequency(row, length) out.append([tf_j * idf_j for tf_j, idf_j in zip(tf, idf)]) return out
>>> docs = [ ... ["the", "cat", "sat"], ... ["the", "dog", "sat"], ... ["the", "cat", "ran"], ... ] >>> vocab = build_vocab(docs) >>> bow = bag_of_words(docs, vocab) >>> tfidf(bow)

三篇文档、五个词表词(thecatsatdogran)。the 三篇都有,IDF 低;dog 只在一篇出现,IDF 高。向量稀疏(多数项很小),区分性词凸显出来。

第 5 步:L2 归一化

def l2_normalize(matrix): out = [] for row in matrix: norm = math.sqrt(sum(x * x for x in row)) out.append([x / norm if norm else 0 for x in row]) return out

不归一化的话,长文档向量更大,在相似度里一家独大。L2 归一化把每篇文档放到单位超球面上,行之间的余弦相似度就成了一个点积。

三、框架对比

scikit-learn 自带生产级实现。

from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer docs = ["the cat sat on the mat", "the dog sat on the mat", "the cat ran"] bow_vectorizer = CountVectorizer() bow = bow_vectorizer.fit_transform(docs) print(bow_vectorizer.get_feature_names_out()) print(bow.toarray()) tfidf_vectorizer = TfidfVectorizer() tfidf = tfidf_vectorizer.fit_transform(docs) print(tfidf.toarray().round(3))

CountVectorizer 一次调用搞定分词、词表、词袋;TfidfVectorizer 加上 IDF 加权与 L2 归一化。两者都返回稀疏矩阵。10 万篇文档时,稠密版本根本装不进内存——在分类器要稠密之前,保持稀疏

那些「牵一发动全身」的旋钮:

参数 作用
ngram_range=(1, 2) 纳入二元语法,通常提升分类
min_df=2 丢掉出现在少于 2 篇文档里的词,噪声数据上修剪词表
max_df=0.95 丢掉出现在超过 95% 文档里的词,近似去停用词,无需硬编码列表
stop_words="english" scikit-learn 内置停用词表,视任务而定——情感分析不该去掉否定词
sublinear_tf=True 1 + log(tf) 替代原始 tf,某词在一篇里反复出现时有帮助

TF-IDF 在 2026 年仍然胜出的场景

  • 垃圾检测、主题标注、日志异常标记:词的出现就是关键,语义微妙无关紧要。
  • 低数据 regime(几百条标注样本):TF-IDF 加逻辑回归没有预训练成本。
  • 任何延迟敏感的系统:TF-IDF 加线性模型微秒级响应;Transformer 嵌入一篇文档要 10~100ms。
  • 必须解释预测的系统:翻分类器系数,正权重最大的词就是理由。

TF-IDF 失败的场景

语义盲区失败。看这两篇文档:

  • "The movie was not good at all."
  • "The movie was excellent."

一篇是差评,一篇是好评。它们的 TF-IDF 重叠恰恰是 {the, movie, was}。词袋分类器只能死记 notgood 附近会翻转标签——数据够多能学会,但永远不如一个懂语法的模型来得自然。

另一个失败:推理时的词表外词(OOV)。在 IMDb 影评上训练的词袋模型,对 Zoomer-approved 这种训练时没见过的 token 完全束手无策。子词嵌入(第 04 节)能处理,TF-IDF 不能。

混合:TF-IDF 加权嵌入

2026 年中等数据量分类的务实默认:把 TF-IDF 权重当作对词嵌入的注意力。

def tfidf_weighted_embedding(doc, tfidf_scores, embedding_table, dim): vec = [0.0] * dim total_weight = 0.0 for token in doc: if token not in embedding_table or token not in tfidf_scores: continue weight = tfidf_scores[token] emb = embedding_table[token] for i in range(dim): vec[i] += weight * emb[i] total_weight += weight if total_weight == 0: return vec return [v / total_weight for v in vec]

你既拿到嵌入的语义能力,又拿到 TF-IDF 对罕见词的强调。分类器在池化后的向量上训练。在标注样本约 5 万以下的情感、主题、意图分类上,这比任一单独使用都强。

四、可复用产物

保存为 outputs/prompt-vectorization-picker.md:

--- name: vectorization-picker description: Given a text-classification task, recommend BoW, TF-IDF, embeddings, or a hybrid. phase: 5 lesson: 02 --- You recommend a text-vectorization strategy. Given a task description, output: 1. Representation (BoW, TF-IDF, transformer embeddings, or a hybrid). Explain why in one sentence. 2. Specific vectorizer configuration. Name the library. Quote the arguments (`ngram_range`, `min_df`, `max_df`, `sublinear_tf`, `stop_words`). 3. One failure mode to test before shipping. Refuse to recommend embeddings when the user has under 500 labeled examples unless they show evidence of semantic failure in a TF-IDF baseline. Refuse to remove stopwords for sentiment analysis (negations carry signal). Flag class imbalance as needing more than a vectorizer change. Example input: "Classifying 30k customer support tickets into 12 categories. Most tickets are 2-3 sentences. English only. Need explainability for audit logs." Example output: - Representation: TF-IDF. 30k examples is not small; explainability requirement rules out dense embeddings. - Config: `TfidfVectorizer(ngram_range=(1, 2), min_df=3, max_df=0.95, sublinear_tf=True, stop_words=None)`. Keep stopwords because category keywords sometimes are stopwords ("not working" vs "working"). - Failure to test: verify `min_df=3` does not drop rare category keywords. Run `get_feature_names_out` filtered by class and eyeball.

五、练习

  1. 基础:在 L2 归一化的 TF-IDF 输出上实现 cosine_similarity(doc_vec_a, doc_vec_b)。验证相同文档得 1.0、词表完全不相交的文档得 0.0。
  2. 进阶:给 bag_of_wordsn-gram 支持,参数 n 产出 n 元语法计数。测试 n=2["the", "cat", "sat"] 产出 ["the cat", "cat sat"] 的二元计数。
  3. 挑战:用 GloVe 100 维向量(下载一次,缓存)实现上面的 TF-IDF 加权嵌入混合方案。在 20 Newsgroups 数据集上对比纯 TF-IDF、纯均值池化嵌入,报告各自在何处胜出。

本节要点回顾

  1. 变长 token 流要变定长向量,词袋是最笨但能用的答案:数词频,丢顺序。
  2. TF-IDF 给词袋重新加权:通用词压低(the),罕见高频词抬高(dog),log 让权重有界。
  3. 稀疏且轴可解释:能读分类器系数知「为何这么判」,稠密嵌入做不到。
  4. 两个平滑技巧:(n+1)/(d+1) 防除零,末尾 +1 与 scikit-learn 默认一致。
  5. L2 归一化把文档放上单位超球面,余弦相似度退化为点积。
  6. scikit-learn 三行搞定,关键旋钮:ngram_rangemin_dfmax_dfsublinear_tfstop_words
  7. 2026 年 TF-IDF 仍胜出的场景:垃圾/主题/日志、低数据、延迟敏感、要可解释。
  8. 两大失败:语义盲区(not good vs excellent)、推理时 OOV 词。
  9. 混合折中:TF-IDF 权重当注意力叠加在词嵌入上,5 万样本以下的分类常更强。
  10. 在分类器要稠密之前,保持稀疏:10 万文档的稠密矩阵装不进内存。

下一节,我们将让词袋里的每个词自己学会一个向量——进入「Word2Vec 词嵌入」,看词如何第一次带上语义。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U