
开头先说一个很多入门 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] 1TF 衡量局部重要性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 的默认参数里norml2也就是说每篇文档的向量会被归一化为单位长度。这个设计是为了消除文档长度差异带来的偏差但也意味着你看到的具体数值会受文档整体长度影响。明白了这一点你就不会奇怪为什么同一句话在不同长度的文档里 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(tokenizerjieba_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(tokenizerjieba_tokenizer)但我不建议直接用一个通用停用词表就完事。原因很简单不同领域的词频分布差异极大。通用停用词表里的词未必覆盖你领域的噪声词而通用停用词表保留下的词在你的领域里可能也是无意义的。更推荐的做法是先在全量语料上跑一次 CountVectorizer按词频降序看前 200 个词凡是明显不是特征词但频率极高的就手动加进停用词表。迭代两三轮之后你会发现词表的信噪比提升非常明显。4. 参数调优实战如何选对 ngram、min_df、max_df4.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_df3表示一个词至少在 3 篇文档中出现过否则直接丢弃。这个参数对减少噪声很有用因为那些只在一篇文档里出现的生僻词、拼写错误词、专有名词往往对泛化没有帮助。max_df0.8表示超过 80% 文档都包含的词会被丢弃效果类似于自动去停用词。实际操作里我的策略是先设置min_df2max_df0.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( tokenizertokenizer, ngram_range(1, 2), min_df2, max_df0.9, sublinear_tfTrue, norml2 ) X vectorizer.fit_transform(raw_texts) y labels X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy )这里要特别强调一句fit_transform必须只在训练集上执行测试集只能用transform。因为你一旦在全部数据上 fit 后再做切分测试集的信息已经泄露到了词表构建过程里模型评估就不公平了。正确做法是先切分数据集再对训练集做向量化然后利用同一个 vectorizer 变换测试集。我这里为了展示步骤就先展示了整体代码实际工程中要调整顺序。5.2 模型训练与评估特征矩阵出来后剩下的事就交给分类器了。这里我用随机森林做演示因为它对稀疏特征矩阵的支持不错而且不用过多调参就能得到一个合理的基线model RandomForestClassifier(n_estimators200, random_state42) 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(tokenizertokenizer, ngram_range(1, 2))), (clf, RandomForestClassifier(n_estimators200, random_state42)) ]) 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 个词。这一步让我在几分钟内就能摸清数据集的脾气是领域术语多还是口语噪声多类间差异到底有没有。很多时候看完这些关键词再决定后续用什么模型、要不要做规则补充项目推进就快得多了。希望这篇文章能帮你把文本特征工程这个地基打得扎实。