搞 NLP 的人,绕不开文本向量化,而词袋模型(Bag of Words,简称 BoW)和 TF-IDF 就是两条最基础的必经之路。标题里“时序和文本”这几个字可能会让一部分人误解成时间序列那套东西,其实这里说的更准确的理解是:我们处理文本时,天然会考虑词出现的先后顺序,但 BoW 和 TF-IDF 恰恰反着来,它们把顺序彻底丢掉,只保留“词在不在、出现了几次”这一层信息。我见过不少初学者在这里卡壳,一边背公式一边困惑,为什么这么简单的操作会有这么大的影响力。
这篇文章就把 BoW 和 TF-IDF 掰开揉碎,讲清楚它们解决的是什么问题、计算过程长什么样、Python 里怎么落地,以及实际项目里踩过的坑。适合刚入门 NLP、准备做文本分类或关键词提取的读者,也适合复习基础的朋友直接从代码和参数层面找答案。
1. 先看清问题:文本数据为什么不能直接喂给模型
1.1 模型的输入约束与文本的天然形态
机器学习模型,尤其是传统的线性模型和大部分深度学习模型,要求的输入都是一个形状规整的数值矩阵。你给它一个字符串,它没法像人一样直接“读懂”语义,它只认识数字。文本是一条变长的字符序列,短的可能只有几个词,长的能到几万字,这种天然形态没法直接进模型,必须先做一步转换,把文本映射成固定长度的数值向量。
拿现实生活类比一下。你让一个从来没见过汉字的外国人判断两句话是不是同一个意思,他没办法从字形上直接判断,你得先给他一个字典,把每个词翻译成他能理解的编号或者符号组合。模型也是一样,它需要一份“词表”(Vocabulary),然后按照词表把每句话编码成一个向量。这里有个很关键的设计选择:编码时到底要不要保留词序?保留词序意味着“我打你”和“你打我”要编码成完全不同的向量,不保留就是只要这两个词出现过,向量就一样,只是顺序颠倒不影响表示。
这个选择直接决定了模型能捕捉到什么信息。保留顺序的编码方式已经属于序列模型或者 n-gram 的范畴,而 BoW 和 TF-IDF 选择了完全忽略顺序的策略。看起来这损失了信息,但恰好换来了两个好处:一个是向量长度固定,每篇文档都能映射成词表长度的一维向量;另一个是计算极其简单,底层只需要做统计计数,这套逻辑在当年算力有限的年代非常讨巧,即便放到现在,它对很多任务来说依然是性价比极高的基线方法。
1.2 “词袋”这两个字到底意味着什么
把文本想象成一颗颗水果扔进一个口袋里,然后你晃一晃袋子再倒出来,最终只关心“袋子里有苹果、香蕉、橙子各多少个”,完全不关心它们进去的先后顺序,这就是“词袋”的核心隐喻。每一篇文档就是一个袋子,装满了一个个词,模型看到的是一个鼓鼓囊囊的袋子,里面只有词和词的频次,没有排列顺序。
所以真相是:BoW 和 TF-IDF 本质上都处理的是无序词集合。标题里提到的“时序”,在它们面前没有生存空间。如果你在做任务之前反复纠结这句话的顺序很重要,比如要做情感分析、判断“我不喜欢”和“喜欢我不”,那 BoW 和 TF-IDF 就不该是第一选择,你得考虑把局部词序纳入模型的 n-gram 特征,或者直接上序列模型。但如果任务本身是“判断这篇新闻属于哪个栏目”“找出文档中的关键词”,顺序影响其实没那么致命,词袋类的向量就完全够用。
这个认知特别重要,因为它能帮你在项目启动阶段做对第一道选择题:不是所有文本任务都需要时序建模,很多任务用 BoW 就足够了,盲目上复杂模型反而是浪费资源。
2. 词袋模型 BoW:从词频计数到向量空间
2.1 手工实现一个最简 BoW 流程
先把 BoW 的完整流程拆开,一共三步:构建词表、对每篇文档统计词频、把统计结果拼成向量。词表就是所有文档中出现过的去重词的集合。假设有两篇文档:
- 文档一:“今天天气不错,适合出门跑步。”
- 文档二:“跑步回来喝了杯咖啡,天气很舒服。”
中文本身没有空格分词,这里为了讲原理我先按词划分。去掉标点后,词表就是“今天、天气、不错、适合、出门、跑步、回来、喝了、杯、咖啡、很、舒服”这 12 个词。然后对每篇文档统计每个词的次数。文档一里“今天”出现 1 次,“跑步”出现 1 次,没出现过的词记 0。最终它的向量就是 [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0],文档二对应是 [0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]。
就这样,两篇变长的文本被压成了两个同样长度为 12 的数值向量。这个向量的每一个位置都对应词表里的一个词,位置上的数字就是该词在这篇文档里的出现次数,也就是词频(Term Frequency,简称 TF)。现在你可以把这两行向量丢进分类器、做相似度计算或者聚类,因为它们的形态已经符合模型输入要求。
这里注意到一个“时序”被抹掉的直观表现:把“今天天气不错”和“天气今天不错”写成两篇,BoW 得到的向量完全一致。这在某些场景下是缺陷,在某些场景下反而是鲁棒性,比如垃圾邮件检测里“中奖 恭喜”“恭喜 中奖”这两句话虽然语序不同,但表达意图非常接近,词袋模型恰好能做到一定程度的泛化。
2.2 用 Scikit-learn 实现 CountVectorizer
手工统计只能帮你建立直觉,真正干活的时候没人手写这个步骤。Scikit-learn 里的 CountVectorizer 是 BoW 的标准实现,名字里的 Count 指的就是做词频计数,“Vectorizer”表示语料到向量的转换器。用法非常简单,几行代码就能跑通。
from sklearn.feature_extraction.text import CountVectorizer docs = [ "今天天气不错 适合出门跑步", "跑步回来喝了杯咖啡 天气很舒服" ] vectorizer = CountVectorizer() bow_matrix = vectorizer.fit_transform(docs) print(vectorizer.get_feature_names_out()) print(bow_matrix.toarray())运行结果会打印出词表顺序和两行词频向量。默认情况下,CountVectorizer 采用单个词作为特征,也就是 unigram,词表里的词不排序,按内部哈希或者词典序处理。输出的是一个稀疏矩阵,用 toarray() 转成密集数组方便查看,但真实项目里千万别转成密集矩阵,因为词表常常是几万维,全转成密集矩阵内存直接爆炸。
CountVectorizer 有几个参数直接影响向量效果。token_pattern 默认匹配的是字母数字字符,中文场景下如果用默认配置可能把整句切成一个又一个连续片段,实际使用中通常需要先分词,把分词结果用空格拼起来再喂给它。ngram_range 控制的是特征粒度,默认 (1, 1) 表示只取单个词,如果设成 (1, 2) 就会额外生成相邻连着的两个词作为特征,比如“天气”“天气不错”都会进词表,这相当于给无序的 BoW 模型补了一丁点局部顺序信息。min_df 和 max_df 负责过滤低频和高频词,这两个参数能大幅影响词表大小,后面实操部分我会再展开讲。
2.3 词袋模型的硬伤:频率不代表重要性
BoW 用词频来度量一个词在文档中的分量,这在朴素的视角下说得通,但放到真实文本里立刻露馅。
中文里“的”“了”“是”“在”这类功能词几乎每篇文档都有,而且出现频率往往比实词还高。比如“这个方案的问题在于设备成本太高的部分原因在于……”,一句话里两个“在于”都占着词频指标,但对理解文章主题几乎没有帮助。BoW 给所有词一视同仁地计数,结果就是高频的虚词霸占了向量里最大的权重,真正的主题词反而被淹没。
更麻烦的是,词频高不见得就代表这篇文档特有。假设你现在做新闻分类,体育新闻里“比赛”出现次数很多,财经新闻里“市场”出现很多。可如果语料里大部分文档都出现“记者”“报道”这类词,它们的词频一样不低,却说不清新闻属于哪个类别。BoW 把这种“到处都在出现”的词和“只在特定类别中出现”的词同等对待,导致向量丢失了区分力。
这就是 TF-IDF 出来要解决的问题。它本质上是在 BoW 词频统计的基础上,给每个词额外乘上一个“权重系数”,用来拉低那些四处乱跑的高频词的得分,同时抬高那些只在少数文档里出现的词的得分。所以理解 TF-IDF 之前必须先把 BoW 的缺陷想清楚,否则你会觉得 TF-IDF 的公式很莫名其妙,为什么要对词频取对数,为什么还要算整个语料里的文档频率。
3. TF-IDF:给每个词重新称重
3.1 TF 和 IDF 各自的职责划分
TF-IDF 是 Term Frequency - Inverse Document Frequency 的缩写,翻译过来就是“词频-逆文档频率”。这个名字直接把两个核心组件写清楚了。TF 是词频,前面已经出现过,它的职责是衡量这个词在本文档里有多重要。这个指标的直觉很朴素:一篇文章反复提到“锂电池”,说明这篇文章大概率在讲锂电池相关的话题。
IDF 是逆文档频率,它的职责是衡量这个词在整个语料里的稀缺程度。如果一个词在绝大多数文档里都出现,比如“我们”“可以”,它的区分度就很低,逆文档频率就会小;反之,如果一个词只在极少数文档里出现,比如“析锂”“正极材料”,说明它具有很强的主题指向性,IDF 就会大。TF 管“这篇文章里谁更显眼”,IDF 管“整个语料里谁更稀有”,两者相乘,就得到一个既考虑词频又考虑稀缺性的综合权重。
这里有个非常精妙的地方:IDF 是在语料级别计算,而不是在单篇文档里计算。这意味着同一个词在不同文档里的 TF 可能不同,但它的 IDF 是固定的。TF-IDF 是一个把“局部重要性”和“全局区分度”结合起来的权重方案,比单纯的词频多了一个全局视角,所以它比 BoW 更能反映文档的主题差异。
3.2 TF-IDF 到底怎么计算:一步一步手动算一遍
公式层面,TF 最简单粗暴的版本就是词在文档中的出现次数,但也有不少实现会用词频除以文档总词数做归一化。IDF 的常见公式是:
IDF(t) = log( (总文档数 N) / (包含词 t 的文档数 df(t)) )
这里 log 的底数通常取自然常数 e,不过取对数底数只会让所有词的 IDF 等比例缩放,不影响相对大小。实际工程里为了防止 df(t) 等于 0 造成除零错误,会在分母上加 1,很多时候分子上也会加 1,这种平滑操作叫做平滑 IDF。Sklearn 默认走的是一种变体,公式是 log( (1 + N) / (1 + df(t)) ) + 1,这么做的目的是避免一个极高 IDF 的词被赋予过于夸张的权重,同时确保从未出现的词不会直接得到一个 0 分。
手动跑一遍计算能帮你彻底理解。假设语料有三篇文档,词表和文档频率如表所示:
| 词 | 文档1出现次数 | 文档2出现次数 | 文档3出现次数 | 包含该词的文档数 df |
|---|---|---|---|---|
| 天气 | 2 | 1 | 0 | 2 |
| 跑步 | 1 | 0 | 3 | 2 |
| 咖啡 | 0 | 2 | 0 | 1 |
| 舒服 | 1 | 0 | 0 | 1 |
全体文档数 N = 3。先算 IDF。“天气”的 IDF 是 log((1 + 3) / (1 + 2)) + 1 = log(4 / 3) + 1,约等于 0.2877 + 1 = 1.2877。“咖啡”的计算则是 log((1 + 3) / (1 + 1)) + 1 = log(2) + 1,约等于 1.693。可以看到“咖啡”因为只出现在一篇文档里,IDF 比“天气”高出一截。
再算 TF-IDF。文档1里“天气”出现 2 次,TF-IDF 得分就是 2 × 1.2877 ≈ 2.5754。文档1里“咖啡”没出现,得分直接是 0。这里“天气”虽然词频高,但它同时出现在文档1和文档2里,“稀缺性”被拉低,所以总权重没有想象中那么夸张。真正能拿到高分的,往往是那些在当前文档中反复出现、但整体语料里很少露面的词。
Sklearn 的 TfidfVectorizer 在计算之后还会做一步 L2 归一化,也就是把每个文档的向量除以它的长度,让整个向量的欧几里得范数变成 1。这一步主要是为了后续计算余弦相似度时更方便,也消除了文档长度对向量数值的影响。所以你拿到的矩阵不是一个简简单单的 TF × IDF 结果,而是归一化之后的版本,这个细节很多人不看源码都不知道。
3.3 从 CountVectorizer 到 TfidfVectorizer:只差这一步
理解了公式之后,你会发现 TfidfVectorizer 本质上就是 CountVectorizer 的升级版:先用词频统计得到稀疏矩阵,再对矩阵里的每个数值乘以全局 IDF 权重,最后做 L2 归一化。Sklearn 甚至提供了一个参数 use_idf,设为 False 之后它输出的就是纯词频加归一化,相当于一个带缩放的 BoW。
很多刚入门的同学会把 CountVectorizer 和 TfidfVectorizer 当成两个完全不同的工具,其实不用器,记清楚了会发现它们底层共享同一套“先分词、再计数”的逻辑。TfidfVectorizer 内部有一个 TfidfTransformer 转换器,它的输入正是 CountVectorizer 输出的计数矩阵。甚至可以直接这样理解:TfidfVectorizer = CountVectorizer + TfidfTransformer。
4. 实操:用 Python 快速构建一个文本向量化 Demo
4.1 完整代码与参数配置
中文场景下,你直接调 TfidfVectorizer 默认配置基本没法用,因为它内部按空格分词,对中文一整段没有空格的文本只能当做一个长字符串处理。所以中文文本向量化的标准流程是多一步分词,通常先用 jieba 把句子切成词,再用空格重新拼接,最后才交给 TfidfVectorizer。我用一个完整的小 demo 演示整个流程。
import jieba from sklearn.feature_extraction.text import TfidfVectorizer docs = [ "今天天气不错 适合出门跑步", "跑步回来喝了杯咖啡 天气很舒服", "咖啡提神醒脑 我每天都喝" ] def chinese_cut(text): return " ".join(jieba.cut(text)) cut_docs = [chinese_cut(doc) for doc in docs] vectorizer = TfidfVectorizer( ngram_range=(1, 2), min_df=1, max_df=0.8, sublinear_tf=True, use_idf=True ) tfidf_matrix = vectorizer.fit_transform(cut_docs) print(vectorizer.get_feature_names_out()) print(tfidf_matrix.toarray())这里几个参数各有讲究。ngram_range=(1,2) 会把单个词和相邻两个词的组合都当成特征,比如“天气”“天气不错”“跑步”都会进词表,这样能在保持词袋高效率的同时捕捉一些局部短语信息。min_df=1 和 max_df=0.8 的组合是过滤用的,min_df 小于 1 表示最少出现在 1 篇文档里,max_df=0.8 表示超过 80% 文档都包含的词会被剔除,这能自动干掉类似“的”“了”“是”这些可能到处出现的高频词,前提是你的语料足够有代表性。sublinear_tf=True 表示对词频改用 1 + log(tf) 的形式,目的是减弱词频线性增长带来的影响,避免某篇文档里同一个词反复出现导致它的权重无限放大。
代码跑完打印出特征词表和每篇文档的 TF-IDF 向量。你会发现“天气”和“咖啡”这些词的权重分布比较符合直觉,高频但是常见的词被压下来了。稀疏矩阵同样不要直接 toarray(),这里只是为了演示看数值。
4.2 向量化之后的常见应用方向
拿到 TF-IDF 向量之后,最常见的三个应用方向是文本分类、文档相似度和关键词提取。
文本分类的套路很标准:用 TfidfVectorizer 对训练语料做 fit_transform,拿到一个大的稀疏矩阵,把这个矩阵直接当作特征输入逻辑回归、SVM 这类分类器。TF-IDF 向量的好处是特征维度虽然高,但极其稀疏,许多线性模型的训练效率依然很高,而且不需要额外做特征筛选就能获得还不错的基线效果。我在实际项目里发现,用 TF-IDF 加逻辑回归做新闻分类,在中小规模语料上经常能到达一个很强的 baseline,甚至能超过一些没有经过精细调参的深度学习模型。
文档相似度计算一般配合余弦相似度使用。因为两个向量的长度不影响夹角,余弦相似度天然适合高维稀疏向量的比较。写几行代码就能把两两文档之间的相似度算出来:
from sklearn.metrics.pairwise import cosine_similarity sim_matrix = cosine_similarity(tfidf_matrix) print(sim_matrix)输出的是一个 3x3 的相似度矩阵,第 i 行第 j 列表示文档 i 和文档 j 的相似度。这个技术在文本去重、问题检索、文章推荐里面都是基础操作。
关键词提取的逻辑也很直观:一篇文档的向量里,每个维度对应一个词,维度的数值就是 TF-IDF 权重,按权重从大到小排序,取前 N 个词作为文档关键词。这种方式不需要额外训练模型,跑起来速度非常快,在标题生成、标签推荐这类场景里可以做快速预处理。
4.3 实操中的三个坑
第一个坑是中文没有预先分词。很多人拿着一篇中文文章直接丢进 TfidfVectorizer,结果除了标点符号之外什么都没有提取出来。分词这一步不能省,而且分词器质量直接影响效果,比如“不知道”切错成“不知”“道”,整个特征就废掉了。实际项目中还可以结合自定义词典补充领域术语。
第二个坑是词表没控制好,维度爆炸。默认参数下,几千篇文档就能产生几十万维的特征,如果再开 ngram_range=(1,3),词表轻松上百万。这个维度后续喂给模型会造成训练时间变长、内存挤爆、模型泛化变差。解决办法是从上到下控制三个参数:max_df 干掉遍布语料的停用虚词,min_df 去掉只出现一两次的噪声词,最后把 max_features 设一个上限,比如 50000 或者 100000,只保留词频最高的部分。这样做不仅降维,还能提升模型质量。
第三个坑是拿 fit_transform 的结果直接用在测试集上,而不是用训练好的 vectorizer 去 transform。正确做法是训练集上 fit_transform,测试集上只调用 transform。这样保证词表不依赖测试数据的分布,也让评估结果更接近线上表现。很多人因为手滑写错这部分,得到的评估分数虚高,上线后却表现大幅下跌。
5. 真实项目里的经验与建议
5.1 什么时候用 BoW,什么时候用 TF-IDF
如果只做简单的词频统计,或者任务本质上就是计数相关的,比如某些规则系统判断“这份文档里是否出现某个词”,BoW 够用。但绝大部分文本分类和检索任务,直接用 TF-IDF 会更稳,因为它的全局加权天然具备停用词抑制能力,不需要依赖一个额外的停用词表。
我发现一个规律:短文本场景下 BoW 和 TF-IDF 的差距没那么大。比如判断一句商品评论是好评还是差评,文本很短,词频就是 0 或 1,IDF 的作用有限,用词袋特征配上分类器已经能拿到不错的结果。长文本场景下差距就很明显,因为文档里词的分布更加极端,高频词和低频词的区分度需要 IDF 来校准。例如识别技术文档的主题,单纯词频容易把“系统”“问题”“方案”这些词推上高位,加上 IDF 才能把“微服务”“容器化”“分布式事务”这些更具体的主题词顶上来。
5.2 参数调试的优先级
参数调优的顺序,我先看 min_df 和 max_df,再看是否要开 ngram_range,最后才考虑调 max_features。原因是前两个参数直接决定了词表质量,虚词和生僻词先去掉,特征的基本盘就稳了。ngram_range 是否需要开启,取决于任务是否看中短语信息。分类新闻标题这种短文本,开 bigram 常常有帮助;分类长文章,单字词和双字词已经能表达主题,开 bigram 反而让维度翻好几倍。
max_df 的设置要参考语料本身的分布。语料里全是同一领域的文档时,max_df 可以设高一点,比如 0.9;语料来源很杂,比如要处理多个完全不同的话题,max_df 设成 0.5 可能更合适。这里没有银子弹,得跑几次看效果。
5.3 给新手的一点心得
我见过很多入门的人一头扎进深度学习的序列模型,把 LSTM、Transformer 当成标配,结果连 TF-IDF 是什么都讲不清楚。实际上,无论是学术论文的 baseline,还是工业界的快速原型,BoW 和 TF-IDF 都还占据着重要的位置。它们计算快、解释性强,而且能跟很多复杂模型混用,比如把 TF-IDF 向量和 embedding 特征拼在一起,往往能同时提升模型的覆盖率和精准度。
我自己的经验是,做任何文本项目第一步都先拿 TF-IDF 跑一个最简单的基线,把结果记下来。后面不管上多复杂的模型,拿这个基线作为参照物来判断复杂模型是否真的带来了增益。这样做的好处是不至于一上来就把周末耗在调参上,结果连简单方案能到什么水平都没摸清。
最后提一个小技巧:当你处理的数据里包含大量专有名词和领域术语时,记得先整理一份领域词典喂给分词器,再做 TF-IDF。这个动作看起来不起眼,却常常能带来比调参还要明显的效果提升。分词切得准,词袋里的词才靠谱,后面的所有事情才有意义。