☰
sklearn文本向量化实战:从词袋模型到TF-IDF的特征工程
2026/10/22 13:34:58 网站建设 项目流程

开头

先说一个很多入门 NLP 的朋友都问过的问题:同样的文本量,为什么别人训练出来的模型效果比你好一大截?大部分时候问题不在模型,而在特征工程。文本不像表格数据那样天然就是数值,模型读不懂人话,它只认数字。所以,怎么把一段话变成一组向量、一组矩阵,这一步做得够不够细,直接决定了下游分类、聚类、相似度计算的准确率。

这篇文章要聊的就是 sklearn 里最经典的两大文本向量化方案:词袋模型(Bag of Words)和 TF-IDF。我会先用白话讲清楚它们背后的数学逻辑,再带着你从头到尾跑一遍 CountVectorizer 和 TfidfVectorizer 的实战代码,最后给出我在真实项目中常用的参数组合和踩过的坑。适合正在学机器学习、自然语言处理,或者在用 sklearn 搭文本分类、文本匹配、关键词提取场景的开发者。有些基础概念会比较细,但你耐心看完,文本这块的特征工程基本上就不会再发怵了。

1. 先理解为什么文本必须做向量化

1.1 模型无法直接理解文本

机器学习的核心操作是矩阵运算,无论是线性回归、SVM 还是神经网络,输入输出本质上都是数值。而自然语言是由字符、词语、句子构成的非结构化数据,这两者之间天然存在一道鸿沟。向量化(Vectorization)就是搭在鸿沟上的桥,它的职责是把每一个样本变成固定维度的数值向量。

如果你不做这个转换,哪怕你用的是再强的分类器,也不可能直接喂给模型一句“这个商品质量不错”。所以在任何文本挖掘项目里,向量化一定是第一步,而且它的质量会传导到后续所有环节。早期我做情感分析的时候,曾天真地以为只要把模型换得足够复杂,准确率就能上去,折腾了两周效果平平。后来回头观察才发现,特征表达太粗糙,词与词之间的关系、关键信息的重要程度完全没体现出来,模型再强也救不回来。

1.2 词袋模型的基本逻辑

词袋模型的核心思想非常朴素:把一段文本看成是词语的无序集合,忽略语法和词序,只统计每个词在文档中出现了多少次。假设语料库里有这样两个句子:

  • 句子 A:我喜欢苹果,苹果很好吃
  • 句子 B:他喜欢香蕉

先构建词表,也就是把所有文档中出现过的不同词语收集起来:

["我", "喜欢", "苹果", "好吃", "他", "香蕉"]

那么句子 A 的向量就是:

[1, 1, 2, 1, 0, 0]

句子 B 的向量就是:

[0, 1, 0, 0, 1, 1]

向量中的每一位代表该词在当前文档中出现的次数。这种表示方式最大的优点在于实现简单、计算高效,而且对短文本、关键词色彩强烈的场景表现不错。但它也有很明显的缺点:只看频率,忽略语义,同义词和一词多义都处理不了;不同文档长度差异大时,长文档天然拥有更大的词频数字,会干扰比较;停用词、虚词这类高频率但低信息量的词会霸占向量中的主导地位,真正有价值的主题词反而被稀释。

1.3 TF-IDF 对词袋的修正逻辑

TF-IDF 的全称是 Term Frequency - Inverse Document Frequency,即词频-逆文档频率。它在词袋的基础上增加了全局信息。词袋只关心“这个词在你的文档里出现了几次”,而 TF-IDF 会继续追问:“这个词在整个语料库里常见吗?如果到处都是,那它对你这篇文档的区分价值就很小;如果只有少数文档才有,那它一旦出现,价值就很高。”

用公式拆解一下:

  • TF(词频):某词在文档中出现的次数 / 该文档总词数
  • IDF(逆文档频率):ln[(总文档数 + 1)/(包含该词的文档数 + 1)] + 1

TF 衡量局部重要性,IDF 衡量全局稀缺性,两者一乘,每个词就有了一个兼顾局部与全局的权重。比如“的”“了”“是”这类词,几乎每篇文档都有,IDF 值会被压低;而“神经网络”“梯度消失”这类词只在小部分技术文档中出现,IDF 值很大会被抬高。

从这个角度看,TF-IDF 并不是一个和词袋对立的方案,而是词袋模型的一次升级迭代。它依然不关心词序和语义,但它解决了高频无意义词干扰的问题,是工程上都愿意默认选用的基线方案。

2. sklearn 中两大核心工具的使用与拆解

2.1 CountVectorizer 快速上手

CountVectorizer 是 sklearn 对词袋模型的直接实现。它的用法非常轻量,一个类封装了分词、词表构建、向量化全流程。看一下最基础的调用方式:

from sklearn.feature_extraction.text import CountVectorizer corpus = [ "我喜欢苹果,苹果很好吃", "他喜欢香蕉", "苹果和香蕉都是水果" ] vectorizer = CountVectorizer() X = vectorizer.fit_transform(corpus) print(vectorizer.get_feature_names_out()) print(X.toarray())

输出大致是:

['他', '好吃', '喜欢', '水果', '苹果', '香蕉', '都是'] [[0 1 1 0 2 0 0] [1 0 1 0 0 1 0] [0 0 0 1 1 1 1]]

这里要注意,sklearn 默认会做英文分词,按空格和标点切分。如果是中文,默认情况下它会把整句话当成一个 token,效果并不理想。后面我会专门讲中文场景怎么办。

另外还要提醒一下,CountVectorizer 默认会去掉长度小于 2 的字符,且不会做小写转换之外的其他预处理。实际项目中,建议配合自定义 tokenizer 和停用词表一起使用。

2.2 TfidfVectorizer 快速上手

TfidfVectorizer 的使用方式和 CountVectorizer 几乎一模一样,这要感谢 sklearn 在设计上保持了统一的接口风格。它相当于在词袋输出的频率矩阵之上,自动完成 TF-IDF 权重的计算。代码层面只是换了个类名:

from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer() X = vectorizer.fit_transform(corpus) print(vectorizer.get_feature_names_out()) print(X.toarray())

输出的矩阵里,每个位置不再是整数词频,而是浮点权重。数值越大,表示这个特征在当前文档中的区分度越高。如果你拿到矩阵后想判断哪些词是每篇文档的关键词,直接按权重排序即可。

这里要特别说一个容易被忽略的点:TfidfVectorizer 的默认参数里norm='l2',也就是说,每篇文档的向量会被归一化为单位长度。这个设计是为了消除文档长度差异带来的偏差,但也意味着你看到的具体数值会受文档整体长度影响。明白了这一点,你就不会奇怪为什么同一句话在不同长度的文档里 TF-IDF 值不一样了。

2.3 两个类的参数差异与选择原则

虽然两个类长得像,但参数细节上还是有不少差异。我给出一张对比表,方便你查阅:

参数CountVectorizerTfidfVectorizer作用说明
min_df支持支持过滤掉在少于指定数量文档中出现的词
max_df支持支持过滤掉在超过指定比例文档中出现的词
stop_words支持支持停用词过滤
ngram_range支持支持是否提取词组特征
tokenizer支持支持自定义分词函数
sublinear_tf不支持支持用 1 + log(tf) 压缩词频数值
norm不支持支持归一化方式
use_idf不支持支持是否启用 IDF 加权
smooth_idf不支持支持平滑 IDF 计算,避免除零

选择原则其实并不复杂。如果你的任务是关键词快速抽取、关键词权重排序,优先用 TfidfVectorizer。如果你的任务是词共现矩阵、词频统计等探索性分析,CountVectorizer 更合适。如果只是想快速试一个文本分类的基线模型,直接用 TfidfVectorizer 就对了,省去手动调权重的麻烦。

3. 文本预处理与中文分词那些事

3.1 清洗流程决定了向量化的上限

很多新手以为向量化就是调包一行代码,预处理随便写写就行。实际上,清洗环节的粗糙会直接拉低向量化的上限。你想想,如果文本里充满了 HTML 标签、URL、特殊符号、重复标点,这些噪声会被当做一个词进入词表。词表越脏,向量维度越大,但有效信息密度反而越小。

我在实际项目中的清洗流程一般是这样:

  • 去除 HTML 标签
  • 将英文字母统一转为小写
  • 去除 URL、邮箱、电话号码
  • 去除连续重复的标点符号
  • 去除全角/半角空格差异
  • 按实际需要决定是否保留数字
  • 去除换行符和制表符

每一步都不难,但要养成按顺序写的习惯,否则后面调整会很痛苦。我记得有一次做电商评论分析,忘了处理“???”这种连续问号,结果词表里出现了大量类似的符号 token,后面 TF-IDF 排序时前排全是问号。后来我在清洗函数里加了一步正则替换,把连续标点折叠成一个,词表立刻干净了很多。

3.2 中文场景必须考虑分词

CountVectorizer 默认的 token_pattern 是按“字母或数字连续串”切分的,对中文来说,一句话被切成一个 token,词表里全是整句,完全没法用。所以要处理中文文本,就必须切词。

常见方法有两个库:一个是 jieba 分词,一个是 pkuseg。jieba 胜在轻量和社区资源丰富,pkuseg 在领域分词准确率上稍好一些,但模型较大、速度略慢。基础项目用 jieba 完全够用。

在 sklearn 里接入 jieba 的方式很直接:

import jieba def jieba_tokenizer(text): return [word for word in jieba.cut(text) if word.strip()] vectorizer = TfidfVectorizer(tokenizer=jieba_tokenizer)

这里有一个重要提醒:TfidfVectorizer的tokenizer参数传入的是可调用对象,它接收一个字符串,返回一个分词后的列表。我在项目里还会在分词后顺手加一步过滤:去掉长度小于 2 的词,去掉纯数字,去掉标点。这些逻辑可以写在同一个函数里,保持接口一致。

实际上分词这一步没有标准答案,同一个词在“机器学习”场景里可能切分正确,在“机器”和“学习”各自独立出现的场景里就会产生歧义。所以你需要准备一份自定义词典或者领域词表,喂给 jieba 去强化正确切分。

3.3 停用词表如何构建与更新

停用词是文本特征工程里绕不开的一环。它指的是“的、了、是、在、我、你”这类语言结构词,单独存在时没有实际意义,却会拉高维度、稀释权重。

sklearn 里可以这样传:

import jieba from sklearn.feature_extraction.text import TfidfVectorizer stop_words = ["的", "了", "是", "在", "也", "和", "就"] def jieba_tokenizer(text): words = jieba.cut(text) return [w for w in words if w.strip() and w not in stop_words] vectorizer = TfidfVectorizer(tokenizer=jieba_tokenizer)

但我不建议直接用一个通用停用词表就完事。原因很简单,不同领域的词频分布差异极大。通用停用词表里的词未必覆盖你领域的噪声词;而通用停用词表保留下的词,在你的领域里可能也是无意义的。

更推荐的做法是:先在全量语料上跑一次 CountVectorizer,按词频降序看前 200 个词,凡是明显不是特征词但频率极高的,就手动加进停用词表。迭代两三轮之后,你会发现词表的信噪比提升非常明显。

4. 参数调优实战:如何选对 ngram、min_df、max_df

4.1 ngram_range 对特征维度和语义捕捉的影响

ngram_range 控制的是特征的类型。(1, 1)表示只用单词,(1, 2)表示单词加上相邻两词组合,(2, 2)表示只用相邻两词组合。这个参数对中文尤其重要,因为中文里很多词需要组合才有语义边界。

举个例子,“苹果手机”如果只切成“苹果”“手机”,那么单独出现的“苹果”既可能是水果,也可能是品牌,模型分不清。如果加入(2, 2)的“苹果手机”,这个词就被单独成一个特征,语义更精确。

但要注意,ngram 的范围增大,特征维度是爆炸性增长的。比如原始词表只有 2000 个词,加入二元组合后,特征可能直接冲到 2 万到 5 万维。这会显著拖慢训练速度,还会带来过拟合的风险。

我的经验是:短文本、评论、标题这类场景,(1, 2)是性价比很高的选择;长文本或文章,优先(1, 1),如果效果不满意再尝试(1, 2)并用min_df把低频组合砍掉。对于数据量很小的项目,慎用高 ngram,很容易把训练集里偶然出现的组合当成黄金特征,结果在真实样本上全部失效。

4.2 min_df 和 max_df 的选取策略

min_df用于过滤罕见词,max_df用于过滤过于常见的词。它们的取值可以是绝对整数,也可以是 0 到 1 之间的小数,表示文档比例。

比如min_df=3表示一个词至少在 3 篇文档中出现过,否则直接丢弃。这个参数对减少噪声很有用,因为那些只在一篇文档里出现的生僻词、拼写错误词、专有名词,往往对泛化没有帮助。max_df=0.8表示超过 80% 文档都包含的词会被丢弃,效果类似于自动去停用词。

实际操作里我的策略是:先设置min_df=2,max_df=0.9,跑一个基线。查看特征数量,如果特征数量太大(比如超过 5 万),再逐步提高min_df到 3 或 5。如果特征数量还可以,但模型效果不行,再考虑降低max_df。

有一个原则值得记住:这两个参数宁可多砍一点,也不要放任噪声进入。特征维度低一些,模型更容易学出稳定规律。

4.3 sublinear_tf 与 norm 的实战效果

sublinear_tf是 TfidfVectorizer 独有的参数,当它设为 True 时,词频项会被替换成1 + log(tf)。它的作用是压缩原始词频的尺度差距。你想想,文档里有的词出现 3 次,有的词出现 100 次,如果直接线性比较,100 次的那个词权重会是 3 次的 30 多倍;取对数之后差距变成个位数量级,避免了极端高频词主导相似度计算。

norm参数我在前面提到过,默认是l2,即每篇文档的向量归一化为单位长度。这个设置让欧式距离和余弦相似度的计算结果更稳定。如果你做文本相似度匹配,强烈建议保持 l2 归一化。

一个常见疑问是:sublinear_tf 要不要开?我的建议是,如果文档长度差异较大或者高频词效应明显,打开会有正向收益。如果文档长度都差不多,开不开差别不大。你可以两个参数组合各跑一次交叉验证,对比 F1 值再定。sklearn 管道写好后,这种对比成本很低,值得做。

5. 完整案例:用 TfidfVectorizer 构建文本分类模型

5.1 从原始语料到特征矩阵

我带你看一个完整的例子,场景是新闻标题分类。假设我们有原始文本和一个标签列,第一步要做的是把文本清洗函数和向量化参数组合好。

import re import jieba import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report def clean_text(text): text = re.sub(r'<.*?>', '', text) text = re.sub(r'[a-zA-Z0-9]+', '', text) text = re.sub(r'\s+', '', text) return text def tokenizer(text): text = clean_text(text) words = jieba.cut(text) return [w for w in words if w.strip() and len(w) > 1] vectorizer = TfidfVectorizer( tokenizer=tokenizer, ngram_range=(1, 2), min_df=2, max_df=0.9, sublinear_tf=True, norm='l2' ) X = vectorizer.fit_transform(raw_texts) y = labels X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y )

这里要特别强调一句,fit_transform必须只在训练集上执行,测试集只能用transform。因为你一旦在全部数据上 fit 后再做切分,测试集的信息已经泄露到了词表构建过程里,模型评估就不公平了。正确做法是先切分数据集,再对训练集做向量化,然后利用同一个 vectorizer 变换测试集。我这里为了展示步骤,就先展示了整体代码,实际工程中要调整顺序。

5.2 模型训练与评估

特征矩阵出来后,剩下的事就交给分类器了。这里我用随机森林做演示,因为它对稀疏特征矩阵的支持不错,而且不用过多调参就能得到一个合理的基线:

model = RandomForestClassifier(n_estimators=200, random_state=42) model.fit(X_train, y_train) y_pred = model.predict(X_test) print(classification_report(y_test, y_pred))

整个流程跑通之后,建议你观察几件事。第一,特征总数有多少?如果几万维还多,但模型效果一般,优先回来调 min_df 和 ngram_range。第二,分类报告中哪些类别的精确率和召回率明显偏低?接着去看这些类别的代表性文档向量,往往是语料本身分布不均,或者这类文本里真正有区分度的词太稀少。第三,把最重要的特征词拿出来打印一遍,看看是否与业务认知一致。如果你看到的关键词明显不对,大概率是清洗或分词环节出了问题。

5.3 使用管道提升工程效率

实际项目里,多次交叉验证、网格搜索是家常便饭。为了让流程更规范、避免信息泄露,强烈建议把向量化和分类器封装进 Pipeline。这样每次搜索参数时,sklearn 能正确地在每一折内部重新拟合向量化器,不会把验证集信息带进去。

from sklearn.pipeline import Pipeline pipeline = Pipeline([ ('tfidf', TfidfVectorizer(tokenizer=tokenizer, ngram_range=(1, 2))), ('clf', RandomForestClassifier(n_estimators=200, random_state=42)) ]) pipeline.fit(X_train_raw, y_train)

有了 Pipeline 之后,你可以在param_grid里同时搜索tfidf__min_df、tfidf__max_df、clf__max_depth等参数。调参范围一下子打开了,而且代码结构异常清晰。

6. 高频踩坑与排查技巧

6.1 分词与向量化参数理解不到位

我见过太多人把TfidfVectorizer的tokenizer写成一个返回字符串而不是列表的函数,结果程序报错,然后开始怀疑是 sklearn 版本问题。实际上,它需要返回可迭代的 token 列表,这个点看一遍文档就能避免。

另一个常见问题是,TfidfVectorizer保留了和CountVectorizer一样的token_pattern默认值。当你想自定义分词时,如果你既不是用tokenizer又不是用analyzer,而是只修改token_pattern,那你实际上修改的是正则规则,对中文依然无效。很多中文项目搞了半天特征维度为 0,都是这个原因。正确做法是在自定义tokenizer的同时,把token_pattern设为空字符串,避免冲突。

6.2 内存爆炸与稀疏矩阵处理

当语料量很大,比如十万篇文档,词表几万个词,特征矩阵会非常庞大。好在 sklearn 默认返回的是稀疏矩阵,内存占用可控。但如果你习惯性地在中间步骤调用了toarray(),就不是一回事了。把百万级文档的稀疏矩阵转为稠密矩阵,分分钟内存爆掉。

所以我的建议是,全程保持稀疏矩阵类型 $\text{scipy.sparse}$ 不转稠密。只有在做局部调试、或打印少量样本内容时,才用切片加toarray()查看。另外要注意,有一些算法库不接受稀疏矩阵输入,如果你需要切换到这类模型,可以先用SelectKBest等特征选择方法降维,再考虑转稠密。

6.3 多语言文本与字符编码问题

如果语料里有英文、中文混排,或者有特殊引号、繁体字,特征工程的处理会很麻烦。一个稳妥做法是在读取数据时统一转成 UTF-8,文本内部做全面规范化。如果英文和中文同时存在,建议定义两个分词器按语言分别处理,或者用正则先分出中英文再各自切分,不然中文里夹着英文会导致“苹果iPhone”这种混合 token 出现,后续很难处理。

我在生产环境里还碰到过文本里带全角空格的问题,肉眼看不见,但切词时会被当成字符的一部分,导致同样含义的词被分成两个完全不同的特征。排查这类问题的办法很简单:随机抽几条语料,打印它们的 repr() 结果,看看有没有特殊字符。这一步能省掉你很多无谓的调参时间。

6.4 参数记忆混淆自查表

这里整理一张自查表,如果你的项目文本效果不好,可以按表格逐项排查:

检查项正确的预期常见错误
tokenizer 返回值list of str返回了字符串
中文文本切分能切出词整句成为一个 token
停用词过滤含“的、了、是”等没有去停用词
测试集向量化方式只 transform 不 fit对测试集又 fit 了一遍
稀疏矩阵全程 sparse调用 toarray 后内存爆了
ngram_range根据场景选择上来就 (1,3),维度爆炸
sublinear_tf文档长短差异大时开启不理解参数含义就乱开

这张表基本覆盖了新手 90% 的坑。做文本特征工程时,如果效果不好,不要第一个就怀疑模型,先回头检查特征矩阵的质量。特征对了,模型才能谈得上有意义。

7. 扩展与进阶:特征工程还能再往前走一步

7.1 从 TF-IDF 到 Word2Vec 的路径

TF-IDF 的局限在于它依然是一个基于词频的静态表达,词与词之间的语义距离没有被建模。在它之上,Word2Vec 这类词嵌入模型通过大量无监督语料学习出每个词的向量表示,可以把“苹果”和“香蕉”拉近,把“苹果”和“诉讼”拉远。

但我不建议你直接抛弃 TF-IDF 去用 Word2Vec。理由有三点。其一,Word2Vec 需要海量语料训练才有效,小数据集上容易学出噪声。其二,Word2Vec 得到的词向量需要额外策略聚合成文档向量,常见做法是加权平均,此时 TF-IDF 恰恰是很好的权重来源。其三,TF-IDF 具备可解释性,Word2Vec 不具备。从工程角度说,很多人会把 TF-IDF 和 Word2Vec 的向量拼接起来,兼顾稀疏特征和稠密语义特征,效果往往比单独使用任何一个都好。

7.2 主题特征与领域词表补充

文本特征工程并不只有向量化一条路,它还可以做得更有领域深度。比如你可以用 LDA 主题模型给每篇文档额外标注主题分布向量,把“属于哪个主题”作为特征拼接到 TF-IDF 矩阵后面。再比如你所在的领域有一些成熟的专业词典、情感词典,可以人工构建规则特征,比如“积极词出现次数”“否定词出现次数”“领域术语标记”等。

这些特征看起来是手工劳动,但它们往往比模型自己学出来的特征更稳定、更让人放心。我做一个商品评论情感分析时,就是在 TF-IDF 矩阵之后加了几列人工规则特征,包括评论长度、感叹号数量、辱骂词数量、好评词数量、差评词数量。别看这些特征粗陋,它们对分类结果的提升甚至超过了调一天的模型参数。

7.3 上线前必须验证的三个维度

如果你只是跑一次实验,前面讲的完全够用。但如果是做一个要上线的服务,我建议你再做三件事。第一,保存 vectorizer 本身,用 joblib 或 pickle 序列化,否则线上推理时你无法对单条文本生成同样的特征。第二,把训练集上记录下来的特征名称和特征矩阵统计信息保存一份,用于线下核对线上结果。第三,对线上新文本做同样的清洗流程,保持和训练时完全一致。

有时候线上和离线效果差异大,排来排去发现是文本进来了但清洗流程里某一步没走到,比如线上文本里带 \u3000 全角空格,清洗函数处理过了,但线上服务那边用的是旧的清洗函数。这种问题很隐蔽,只能靠统一封装一个文本处理模块、并做好版本管理来规避。用 Pipeline 就能把清洗、分词、向量化统一封装,部署时直接加载同一个 Pipeline 对象,两侧一致性有保证。

8. 个人实操体会与建议

文本特征工程这类工作,表面上是调包,实际上是对数据理解深度的考验。我做了几年文本相关的项目,最大的感受是:不要把所有希望寄托在再复杂的模型上,而是先把特征表达做到干净、稳定、有解释力。词袋和 TF-IDF 是老技术了,但它们的可行性与可解释性到现在依然是很多业务的基线标配,掌握了它们,再往上走词向量、语言模型,思路会顺很多。

最后再分享一个小技巧。每当我拿到一个新的文本数据集,我第一件事不是写模型,而是用 TfidfVectorizer 把所有文档向量化,然后打印每个类别下 TF-IDF 权重最高的 30 个词。这一步让我在几分钟内就能摸清数据集的脾气:是领域术语多,还是口语噪声多,类间差异到底有没有。很多时候,看完这些关键词,再决定后续用什么模型、要不要做规则补充,项目推进就快得多了。希望这篇文章能帮你把文本特征工程这个地基打得扎实。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询