:文本数值化入门与工程实践)
词袋模型Bag of Words—— 文本数值化的第一步刚接触自然语言处理的朋友第一个绕不开的概念大概率就是词袋模型Bag of Words简称 BoW。不管你是要做垃圾短信识别、舆情分析还是给搜索系统写个关键词匹配模块第一件事永远是同一件——把文本变成机器能算的数值。而词袋模型就是这门功课的入门第一课。它做的事情其实非常朴素把一段文本拆成一个个词然后统计每个词出现的次数用一个向量表示整段文本。听起来简单但这是几乎所有高深NLP方法的地基。你后边学到的TF-IDF、LDA主题模型、甚至词向量模型底层逻辑都在回应词袋模型留下的一些问题。这篇文章我会从模型原理、数学表达、代码实现一路拆到工程里的常见坑争取让你看完之后不仅会用还能理解它为什么这么设计。这篇文章适合谁看适合刚入门NLP的学生、做数据分析想给文本特征建模的开发者以及任何想把非结构化文本变成结构化表格的从业者。不需要太多前置知识会一点Python就行核心概念我会从头讲透。1. 词袋模型到底在做什么先建立直觉1.1 为什么叫“词袋”先回到名字本身。“词袋”这个词翻译自Bag of Words它强调一个核心设计思想我们只关心文本里有哪些词、各自出现了多少次完全不关心词的先后顺序。打个比方。你把一篇文章里所有词一个个拆下来扔进一个口袋里摇匀口袋里有什么词、每个词有几个这就是词袋模型看到的全部信息。原本句子结构里的主谓宾、修饰关系它一概不管。“我喜欢你”和“你喜欢我”在词袋模型看来是完全一样的因为两个句子的词频统计结果完全相同。这听起来是个很明显的缺陷对吧确实它丢失了语序也就丢了句法逻辑。但有意思的是大量文本任务的基线效果用这个看起来“很笨”的方法就已经不错了。比如垃圾邮件识别、情感倾向判断很多时候核心词汇的出现频率就足够说明问题。“免费”“点击”“中奖”这些词频繁出现这封邮件是垃圾邮件的概率就很高语序几乎不影响这个判断。所以词袋模型的核心价值在于它用最少的计算代价把“文本相似度”这个感知层面的概念转化成了“向量距离”这个可以精确计算的数学概念。这个转化是后续所有文本处理的前提。1.2 文本数值化的完整链路你可能会问为什么非得数值化因为机器只会做数学运算。我们人类阅读时能自然理解“今天天气不错”这句话的含义但计算机看到的是字符编码它们不理解“天气”和“不错”之间有语义上的关联。要让计算机处理文本必须先把文本映射到某个数学空间里。整个文本数值化的链路大致是这样的原始文本获取从数据库、日志、网页等来源拿到的未处理字符串。文本清洗去掉HTML标签、特殊符号、多余空白统一大小写等。分词把连续的字符串切成一个个独立的词。英文按空格切就行中文需要专门的分词工具。构建词汇表把所有文档中出现的不重复词收集起来形成一个词表。文本向量化根据词汇表把每一篇文档映射成一个数值向量。词袋模型就是第4步和第5步的执行者。前几步的产物是“一堆词”词袋模型负责把“一堆词”变成“一个向量”。后面所有分类、聚类、检索的操作全都在这个向量上展开。这个链路里有个很关键的思维转换文档集合语料库决定词汇表词汇表决定向量的含义。同一句话放在不同语料库里得到的向量是不同的。比如单独看“苹果”这个词在没有上下文时你无法判断它是水果还是手机品牌但如果你把包含“苹果”的所有句子放进语料库模型会在统计意义上帮你抓住这个词在不同语境中的分布特征——当然这是后话了基础版词袋模型不处理一词多义先有这个印象就行。2. 手动算一遍词袋原理、数学表达与代码实现2.1 词袋模型的三个基本步骤先别急着跑代码我们用手算一遍彻底搞懂里面发生了什么事。假设我有三句短文本作为语料库文档1“我喜欢猫”文档2“我喜欢狗”文档3“猫和狗我都很喜欢”第一步分词。中文分词这里先用最简单的方式演示假设我们已经把每句话拆成了词文档1[“我”, “喜欢”, “猫”]文档2[“我”, “喜欢”, “狗”]文档3[“猫”, “和”, “狗”, “我”, “都”, “很”, “喜欢”]第二步构建词汇表。把语料库里所有出现过的不同词收集起来排序后编号编号词编号词0我4和1喜欢5都2猫6很3狗词汇表大小 V 7这就是每个文档向量的维度。这一步是整个词袋模型的核心因为一旦词汇表确定下来整个语料库的向量空间也就确定了。第三步统计词频。对每一篇文档统计词汇表中每个词在文档里出现多少次按词汇表的顺序填进一个向量里。向量的第 i 个位置对应的就是词汇表里第 i 个词的词频。文档1里我出现1次、喜欢1次、猫1次其余词0次所以向量是[1, 1, 1, 0, 0, 0, 0]。文档2[1, 1, 0, 1, 0, 0, 0]。文档3我1次、喜欢1次、猫1次、狗1次、和1次、都1次、很1次向量是[1, 1, 1, 1, 1, 1, 1]。现在三句话变成了三个7维向量。你看这个过程里完全不关心词的位置“我”在句首还是句尾“喜欢”后面跟的是“猫”还是“狗”统统不影响向量结果。这就是前面说的“词袋”的含义。有了向量之后下游任务就简单了。比如要算文档1和文档2的相似度直接算这两个向量的余弦相似度就行数值越接近1代表越相似。文档1和文档2除了中间一位其他位置都是相同的所以它们相似度很高这符合直觉——都是“我喜欢XX”句式。而文档1和文档3的相似度相对低一些因为文档3包含了更多其他词。一个分类器接在这组向量后面就能根据向量的几何位置判断文本类别了。2.2 代码实现用Python跑一个最小样例理论清楚了代码其实非常简单。这里我直接用Python手写一个最简版不做任何封装让你看清每一步。import jieba corpus [ 我喜欢猫, 我喜欢狗, 猫和狗我都很喜欢 ] # 1. 分词 tokenized_docs [list(jieba.cut(doc)) for doc in corpus] print(tokenized_docs) # 输出[[我, 喜欢, 猫], [我, 喜欢, 狗], [猫, 和, 狗, 我, 都, 很, 喜欢]] # 2. 构建词汇表 vocab {} vocab_list [] for doc in tokenized_docs: for word in doc: if word not in vocab: vocab[word] len(vocab_list) vocab_list.append(word) print(vocab) # 输出{我: 0, 喜欢: 1, 猫: 2, 狗: 3, 和: 4, 都: 5, 很: 6} # 3. 词频向量化 import numpy as np def bow_vectorize(tokens, vocab): vec np.zeros(len(vocab), dtypenp.int8) for token in tokens: if token in vocab: vec[vocab[token]] 1 return vec vectors [bow_vectorize(doc, vocab) for doc in tokenized_docs] for vec in vectors: print(vec) # 输出 # [1 1 1 0 0 0 0] # [1 1 0 1 0 0 0] # [1 1 1 1 1 1 1]整个过程不到20行代码。实际工程项目里一般不会自己造轮子直接用scikit-learn的CountVectorizer就够了底层实现加了并行化和稀疏矩阵优化效率高得多from sklearn.feature_extraction.text import CountVectorizer corpus [ 我喜欢猫, 我喜欢狗, 猫和狗我都很喜欢 ] vectorizer CountVectorizer(tokenizerlambda s: jieba.lcut(s)) X vectorizer.fit_transform(corpus) # 词汇表vectorizer.vocabulary_ print(vectorizer.vocabulary_) # 输出{我: 4, 喜欢: 3, 猫: 1, 狗: 2, 和: 0, 都: 5, 很: 6} print(X.toarray()) # 输出 # [[1 1 0 1 1 0 0] # [1 0 1 1 1 0 0] # [1 1 1 1 1 1 1]]注意CountVectorizer默认词汇表顺序是按字母排序的而不是按出现顺序所以和排在了第0位这不要紧顺序只是索引含义不影响模型使用。用CountVectorizer时有一个关键点fit_transform在训练集上调用之后对测试集只需要调用transform不要再次fit。因为词汇表必须保持一致如果测试集单独再建一个词汇表向量维度就对不上了模型也就没法用了。2.3 词袋模型输出的真实形态稀疏矩阵上面演示用的是toarray()转换后的稠密矩阵看起来直观。但当语料库变大比如你有1万篇新闻稿词汇表5万词那么矩阵规模就是 10000 × 50000 5亿个位置。即使每个位置只存一个整数也需要大量内存。好在实际情况是一篇1000字的文章可能只用到几百个不同词也就是说矩阵里绝大多数位置是0。CountVectorizer返回的实际上是一个稀疏矩阵scipy.sparse它只存储非零位置的值内存占用能降低几个数量级。这个我之前在实际项目里实测过一个100万条评论的语料库如果用稠密矩阵存直接几十个GB内存不够用但用稀疏矩阵只要几百MB。所以务必记住拿到CountVectorizer的输出后不要轻易调toarray()尤其是大数据场景。后续模型训练比如朴素贝叶斯、线性SVM都直接支持稀疏矩阵输入。3. 词袋模型的进阶优化TF-IDF与n-gram3.1 为什么纯词频不够用基础版词袋模型有个问题高频词会主导向量但不一定代表文本特征。比如做新闻分类时“今天”“我们”“时间”这类词几乎每篇文章都有它们词频高但完全没法区分文章主题。真正有用的往往是那些出现次数适中、只在特定类别里出现的“特征词”。为了压制通用高频词、凸显代表性词汇常用的改进方式就是 TF-IDFTerm Frequency - Inverse Document Frequency词频-逆文档频率。TF-IDF的直觉是一个词在当前文档里出现次数多TF高但在整个语料库的很多文档中都出现DF高那它对当前文档的区分能力就弱要给个惩罚反之如果它在当前文档中频繁出现但其他文档很少出现说明它是这篇文章的重要特征要给个奖励。数学表达式是TF-IDF(t, d) TF(t, d) × IDF(t)IDF(t) log((N 1) / (DF(t) 1)) 1其中 N 是语料库文档总数DF(t) 是包含词 t 的文档数。注意这里有个 1 的平滑项。传统公式是 log(N / DF)但直接用的话如果某个词出现在所有文档里IDF会变成 log(1) 0整个权重就归零了。加上平滑项后这类词至少有一个基础权重不会完全消失这在工程上是更稳妥的处理方式。代码实现很简单from sklearn.feature_extraction.text import TfidfVectorizer import jieba corpus [ 我喜欢猫猫很可爱, 我喜欢狗狗很忠诚, 我喜欢花花很香 ] vectorizer TfidfVectorizer(tokenizerlambda s: jieba.lcut(s)) X_tfidf vectorizer.fit_transform(corpus) # 查看核心特征词的权重 feature_names vectorizer.get_feature_names_out() for i in range(X_tfidf.shape[0]): print(f文档{i1}:) for j in range(X_tfidf.shape[1]): if X_tfidf[i, j] 0: print(f {feature_names[j]}: {X_tfidf[i, j]:.3f})像“我”“喜欢”这类在每篇文档都出现的词TF-IDF权重会被压得很低而“猫”“狗”“花”这类只在特定文档出现的词权重会明显凸起。做文本聚类或分类时TF-IDF的特征选择性明显好于纯词频。3.2 n-gram给词袋模型补上“上下文”前面说词袋模型忽略顺序那有没有办法在保留词袋思路的前提下捕捉一点局部语序信息有这就是 n-gram 扩展。n-gram 的做法是把相邻的n个词打包成一个整体当作一个“词”来统计。比如 2-grambigram“我喜欢猫”会被拆成“我喜欢”和“喜欢猫”两个组合。这样一来模型的词汇表里既有单个词unigram也有相邻词组合。在CountVectorizer里通过ngram_range参数控制vectorizer CountVectorizer(ngram_range(1, 2), tokenizerlambda s: jieba.lcut(s)) X vectorizer.fit_transform([我喜欢猫, 我喜欢狗]) print(vectorizer.vocabulary_) # 输出里会同时包含 我, 喜欢, 猫, 狗, 我喜欢, 喜欢猫, 喜欢狗 等词项加了bigram之后模型能区分“我喜欢猫”和“猫喜欢我”——前者的bigram是“我喜欢/喜欢猫”后者是“猫喜欢/喜欢我”特征完全不同。这对情感分析这类任务帮助很大比如“不好”和“不 好”拆成单个词时顺序信息丢失但bigram“不好”能作为一个整体特征参与计算。代价也很直接词汇表规模暴涨。n-gram的组合数量是近似指数级的词表从几千涨到几万甚至几十万很正常。工程上通常设置ngram_range(1, 2)或(1, 3)很少超过3因为超过3之后稀疏性太大统计意义也不够了还会拖垮后续模型训练速度。3.3 参数调优心得min_df、max_df和stop_words怎么设实际项目里很少有人直接拿默认参数跑。我自己的习惯是先拿一套小规模数据跑一遍观察词汇表的构成再做这几件事第一设置min_df和max_df。min_df指的是词至少在多少篇文档里出现过才保留默认是1也就是说只要在1篇文档里出现就进词汇表。这在语料大时会导致大量只在某一篇文档里出现的“噪声词”进入词表让维度爆炸。经验值一般设2~5。max_df指的是词在超过多少比例的文档里出现就舍弃默认1.0表示不限制。对于通用文本我常把max_df设在0.8~0.95之间去掉那些几乎每篇都出现的高频虚词。这两个参数是对词汇表规模的第一道闸门。第二停用词表要想清楚再加。英文有成熟的停用词表如nltk自带的stopwords但中文的停用词质量参差不齐。我会在项目初期尽量少用停用词而是靠max_df做高频词过滤。因为有些词比如“不”“但是”在情感分析里是有实际意义的转折信号一刀切掉会伤特征。停用词表更适合在明确知道场景的前提下手工维护一份针对性的列表。第三sublinear_tf开关值得一试。TfidfVectorizer里有个参数sublinear_tfTrue它计算时用的是1 log(TF)而不是原始TF本质是对词频做了一次压缩降低“一个词反复出现30次”和“出现3次”之间的绝对差异。这个参数在长文档场景下非常有效因为长文档的词频天然偏高不做压缩的话频率高的词会过度主导向量。4. 工程落地文本分类项目的完整流程4.1 从原始文本到模型输入前面的原理和代码都是切片式的接下来我串一个完整的实战流程——用词袋模型做一个简单的文本分类器把“体育”“科技”“娱乐”三类新闻区分开。这个流程你在很多项目里都能复用。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.pipeline import Pipeline from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report import jieba # 假设已经拿到了文本和标签 texts [...] # 新闻文本列表 labels [...] # 对应标签列表 # 定义预处理函数 def tokenize(text): return jieba.lcut(text) # 创建流水线TF-IDF向量化 - 朴素贝叶斯分类 pipeline Pipeline([ (vect, TfidfVectorizer( tokenizertokenize, ngram_range(1, 2), min_df2, max_df0.9, sublinear_tfTrue )), (clf, MultinomialNB()) ]) # 划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split( texts, labels, test_size0.2, random_state42 ) # 训练与评估 pipeline.fit(X_train, y_train) y_pred pipeline.predict(X_test) print(classification_report(y_test, y_pred))这里我选择的分类器是朴素贝叶斯。为什么因为MultinomialNB对稀疏高维特征非常友好训练极快而且它假设特征之间条件独立——这个假设在词袋模型下虽然不真实“人工智能”和“算法”明显相关但实践中它依然能取得不错的基线效果。如果你换用SVM或逻辑回归也能得到不错的结果但要先做特征缩放或选择适合稀疏矩阵的求解器。完整流程里有一个容易被忽略的细节向量化器只能先把分词后的原始文本传进去。我见过不少新手在这个环节犯迷糊以为要先手工构造词频矩阵再丢给分类器其实Pipeline已经把向量化和训练打包了fit时它会自动完成fit_transform变换predict时自动transform测试数据不用自己手动做。4.2 评估模型时除了准确率还要看什么文本分类的评估经常陷入只盯着准确率Accuracy的误区。在类别不平衡时准确率会骗人。比如100条样本里95条是体育新闻、5条是科技新闻模型全预测成体育也能拿到95%的准确率但这显然不是你想要的模型。这时候应该看precision精确率、recall召回率和F1-score。精确率关心“预测为A类的样本里有多少真的是A类”召回率关心“所有A类样本里模型抓回来了多少”。一个只把“足球”“篮球”相关文本判为体育类的模型精确率可能很高但召回率很低因为大量没有这些关键词的体育新闻被漏掉了。在词袋模型场景下我发现一个挺实用的诊断方法把模型分错和分对的样本拉出来对比看它们的特征词分布。比如有个体育类新闻被错判成了科技类把它的预测概率最高的特征词打出来看看是不是混入了大量“计算机”“芯片”这类词汇。如果确实如此大概率是训练数据里这两类的界限不够清晰或者停用词过滤把真正重要的区分词给滤掉了。通过这种方式能很快定位是特征工程问题还是数据标注问题。4.3 中文场景的分词注意点中文文本处理绕不开分词这一步。词袋模型的粒度完全取决于分词器的输出如果分词器把“机器学习”切成一个整词模型就会把它当作一个特征如果切成“机器”和“学习”两个词模型就只知道这两个单独词的出现频率丢失了“机器学习”这个整体概念。目前主流的开源分词工具有几个选择jieba简单易用但词表相对固定新词发现能力一般HanLP和LTP提供更丰富的词法分析能力BERT等预训练模型也自带分词器不过那是另一个技术路线了。我的经验是刚起步的项目直接用jieba就够了但要注意两件事。第一自定义词典对于特定领域比如医疗、法律、游戏先准备一份该领域的术语表导入词典能显著提升分词质量。第二分词一致性训练集和预测集必须用同一套分词配置不能训练用jieba预测换了个工具否则特征空间直接对不上。5. 词袋模型的局限性你要知道它的边界在哪里5.1 高维稀疏与计算压力词袋模型向量的维度等于词汇表大小。语料稍微一多几万维是家常便饭。虽然稀疏矩阵缓解了内存问题但几万维的特征依然会拖慢下游模型的训练。尤其是深度模型图神经网络等对输入维度非常敏感直接接几万维的词袋特征第一批参数就已经是一个巨大的矩阵了。处理方式有几类。一是做特征筛选上面提到的min_df、max_df就是最简单的维度控制二是用HashingVectorizer做哈希映射把词直接哈希到固定维度的向量比如2^18维彻底避免维护词汇表三是降维比如TruncatedSVDLSA可以把词袋向量压到几百维同时在一定程度上缓解稀疏性问题。5.2 语义鸿沟同义词和多义词词袋模型把每个词当成独立符号词与词之间没有任何关联。这意味着“汽车”和“轿车”虽然语义相近但在向量里它们是两个完全正交的维度模型无法“理解”这两个词之间存在相似性因为它们在特征空间里没有任何天然的相似关系。反过来“苹果”作为水果名和作为公司名在词袋模型里被看成同一个词完全无法区分语境差异。这就是常说的语义鸿沟问题。词袋模型只能捕捉“文档用了什么词”但捕捉不到“词在说什么意思”。要解决这个问题就需要词嵌入Word Embedding这类思路了比如Word2Vec通过上下文学习词的分布式表示让语义相近的词在向量空间里距离较近。不过那是词袋模型的下一站不是本文的重点。5.3 什么时候该用词袋什么时候该换方案讲了这么多局限不代表词袋模型就该被丢弃。它是典型的“简单有效”方案如果你手头是几万到几十万量级的数据任务本身对语义理解要求不高比如垃圾内容过滤、关键词匹配、文本检索词袋模型完全可以作为核心方案而且推理速度极快、可解释性强。你可以直观地告诉业务方“我们这个模型把‘发票’‘代开发票’识别为高危词所以这条消息被拦下了。”这种可解释性在金融风控、内容审核等场景里很重要。反过来如果你的任务要求理解反讽、理解长距离依赖、回答自由文本问题那词袋模型就明显不够了需要上BERT类预训练模型或者大语言模型路线。我个人的建议是任何文本项目先做一版词袋模型当baseline再考虑要不要上更复杂的方案。这不仅仅是图省事它给你提供了一把刻度尺——如果词袋模型都能跑到0.8的F1而你上一套BERT只跑到0.81那说明当前瓶颈不在特征能力上可能数据标注质量才是主要问题。这个判断会帮你节约大量时间和成本。6. 词袋模型实践中的常见错误与排查技巧6.1 训练集和测试集特征不对齐这是词袋模型项目里最隐蔽也最致命的错误。场景是这样的你拿训练集fit了CountVectorizer然后在测试集上跑的时候顺手又fit了一次或者说测试集里出现了一个训练集词汇表里没有的新词然后你手动把向量对齐到训练词汇表时漏掉了一部分。结果就是训练集向量是5000维测试集向量是4800维模型predict时报维度不匹配。更隐蔽的情况是维度没报错但实际上两个向量各说各话——比如训练集词汇表第100位是“猫”测试集第100位却是“狗”模型算出来的结果全是错的。排查方法其实很简单做完向量化之后手动打印一下训练集和测试集的特征维度确保完全一致另外CountVectorizer和TfidfVectorizer都提供了vocabulary_和get_feature_names_out()方法你可以把两个集合的特征名拉出来比对一下看是否对得上。6.2 新词和未登录词怎么处理测试集里出现训练集没见过的词非常正常。默认情况下CountVectorizer会直接忽略这些词也就是说它们对向量没有任何贡献。这在词袋场景下其实问题不大因为新词往往出现频率很低对分类决策影响微弱。但有一种情况要小心如果训练语料太小大量测试集常用词都没在训练中出现过导致测试向量极度稀疏模型的判别能力会大幅下降。这时候可以考虑补充训练语料或者用HashingVectorizer绕开词汇表限制。HashingVectorizer不需要维护词汇表任何词都可以哈希映射到特征空间的某个位置不存在“未登录词”的概念。缺点是它不支持逆向映射无法告诉你哪个特征对应哪个词这对于需要解释模型结果的应用是个硬伤。6.3 词频权重的极端情况我踩过一个具体的坑做舆情分析时有个类别是事故通报类文本里“死亡”这个词出现了几百次因为每篇通报都在重复伤亡数字。词袋向量里这个词的频率高到把其他特征全部淹没模型的预测结果几乎就变成了“看到‘死亡’就分到事故类”。这其实是数据本身分布不均衡导致的单靠词袋模型没法根治。解决思路有两个一是用TF-IDF替代纯词频因为“死亡”在很多文档里都有出现IDF会压低它的权重二是对文本做段落截断或长度限制让每篇文档的词频分布更均匀。如果你的任务里存在某些词出现频率呈幂律分布的情况这两种方法都值得优先尝试。7. 词袋模型之外下一步往哪儿走7.1 从词袋到词嵌入的演进词袋模型和词嵌入都做“把文本变成向量”这件事但底层逻辑完全不同。词袋模型是稀疏的、基于统计的词与词相互独立词嵌入是稠密的、基于学习的语义相近的词在向量空间里距离更近。从词袋到Word2Vec、GloVe再到Transformer的演进本质上就是把“统计”升级为“语义理解”。如果你已经吃透了词袋模型再往上学这些会轻松得多。因为你会发现它们解决的都是词袋模型留下的问题词袋忽略语序所以有了n-gram和LSTM词袋无法表达语义相似度所以有了Word2Vecn-gram只能捕捉局部窗口所以有了Transformer。每个新方法都是对前一个方法某个缺点的定向修正。7.2 词袋思想在现代系统中的影响有意思的是词袋模型没有被完全淘汰。在大语言模型时代你依然能看到它的影子文本检索领域经典的BM25算法本质上就是带权重的词袋打分推荐系统的召回阶段有些方案用的还是TF-IDF计算文本相似度传统反作弊规则系统里“某个词在一段时间内出现频率暴涨”这种统计特征用的还是词袋思想。我自己的体会是这个模型行业地位有点像“矩阵中的初等变换”——所有人都在学日常计算中却不一定直接用它。但你越熟悉它越能在遇到新问题时敏锐判断它属于“统计匹配型”还是“语义理解型”问题并顺手给出一个预期合理的方案。这篇文章讲到这里词袋模型从原理、代码、调参到项目落地和边界问题就都覆盖了。如果你正在做文本相关的项目不妨从今天起先搭一个词袋baseline跑通全流程把上面的参数一个个调着试试。动手改一遍比读十遍文章都管用。