ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

词袋模型与朴素贝叶斯:短文本分类的稳健基线实现与调优

词袋模型与朴素贝叶斯:短文本分类的稳健基线实现与调优

做文本分类有一点经验的人都会发现,真正上手时最先要过的坎不是模型多深,而是怎么把一段话变成模型能算的东西。我在处理短信、评论、工单这些短文本时,最常用也最稳的一套组合就是词袋模型加朴素贝叶斯算法。这套组合不挑机器,训练快,解释起来也直白:词袋模型负责把文本切成一张词频表,朴素贝叶斯再根据这张表去算概率。这篇文章就以这个组合为主线,从原理讲到手写实现,再把容易踩的坑一并说清楚,最后给一份可以照着用的调优顺序表。

1. 先想清楚:这两个名词为什么总被放在一起讲

1.1 文本分类第一步,是把文字变成数字

很多刚接触机器学习的人会困惑一个问题:分类器吃的是矩阵,可文本是一串字符,这怎么吃?答案很简单,先把文字拆成特征,再把这些特征变成数值。

词袋模型要做的就是把“一段话”变成“一个装满词的袋子”。举个例子,有一句评论是“奶茶很好喝,奶盖有点少”,词袋模型会把句子拆成“奶茶”“很好喝”“奶盖”“有点少”这些词,然后统计每个词出现了几次。至于这些词原本的先后顺序,它完全不关心。“奶盖有点少”和“有点少奶盖”在它眼里是同一个向量。这在真实场景里经常足够用了,因为分类任务往往只需要知道“哪些词出现了、出现了几次”,而不是“这句话到底怎么组织出来的”。

所以词袋模型做的是一件非常基础的事:它把一段长度不固定的文本,映射到一个长度固定等于词表大小的向量上。向量里的每个维度对应一个词,数值就是这个词在文档中的词频。这个向量通常又高维又稀疏,因为真实文档里大部分词都只出现一次,而词表可能有几千甚至几万个词。

1.2 词袋模型把特征空间铺开之后,朴素贝叶斯才有了用武之地

有了词频向量,下一个问题是怎么根据这个向量选标签。这里就要请出朴素贝叶斯。它的核心是贝叶斯定理:给定一个文档 (d),要计算它属于类别 (c) 的后验概率 (P(c|d))。

写成公式就是:

[ P(c|d) \propto P(c) \prod_{i} P(w_i|c) ]

这个公式的意思是,文档属于某个类别的概率,等于先验概率 (P(c)) 乘以每个词在该类别下出现概率的乘积。朴素贝叶斯的“朴素”就体现在这里:它假设每个词的出现是相互独立的。虽然真实文本里词和词之间明显有关联,但这个假设可以把计算量从指数级降到线性级。

词袋模型和朴素贝叶斯的搭配之所以经典,是因为词袋模型输出的词频正好是一份“证据表”,而朴素贝叶斯做的就是把证据表里的概率逐项乘起来。词袋模型不看语序,朴素贝叶斯也不看语序,两个人在思维上天然一致。

1.3 这套组合的适用边界

不是所有文本分类都适合词袋模型加朴素贝叶斯,但它在很多场景里是性价比之王。

我自己一般会在这些场景里优先用它:文本比较短,比如短信、评论、工单标题;类别数量不多,二分类或者几个类别;数据量不算很大,可能几千到几万条;业务要求能解释,比如别人问你“为什么这条被判为垃圾短信”,你可以直接说,因为出现了“免费”“中奖”这些词,概率很高。

如果任务是长文档分类、语义相似度、情感极性判断这类对上下文要求很高的场景,词袋模型和朴素贝叶斯会显得吃力。比如“这家店不便宜”和“这家店价格很实惠”,在词袋模型看来,前者出现“不”“便宜”,后者出现“实惠”,两者的词重叠很低,很难被正确归类。这种情况就需要往更复杂的模型方向考虑了。

2. 核心细节:词袋向量怎么构造,条件概率怎么算

2.1 数据清洗与分词:决定上游质量

很多人一上来就直接写代码统计词频,结果效果稀烂,问题往往出在清洗和分词这步。

文本清洗至少要处理这几类内容:URL、数字、特殊符号和大小写。URL 和纯数字对分类任务来说通常是噪声,我习惯把它们替换成特殊占位符,而不是直接删掉。原因是“订单号12345”和“验证码8888”本身没有明确语义,但它们的出现方式有时能代表某一类文本,比如垃圾短信里经常带“点击链接”和“输入验证码”,所以把 URL 统一替换成__URL__、把数字统一替换成__NUM__,既能保留这种信号,又不会让词表里塞满一长串独一无二的数字。

清洗代码一般长这样:

import re def clean_text(text): text = re.sub(r'http\S+', ' __URL__ ', text) text = re.sub(r'\d+', ' __NUM__ ', text) text = re.sub(r'[^\w\s]', ' ', text) text = text.lower() return text

清洗完就是分词。英文按空格和标点切开就行,中文要麻烦得多,必须用分词工具先把连续汉字切开。分词质量对词袋模型的影响很大,因为一个词被切错,后面所有统计都跟着错。比如“研究生命起源”如果被切成“研究生”“命”“起源”,意思就完全变了。

分词之后通常还要去停用词。“的”“了”“吗”“a”“the”这类词出现频率极高,但对分类几乎没贡献,甚至会把真正有区分度的词淹没在频次里。我一般会准备一个停用词表,预处理时把这些词过滤掉,能明显降低特征纬度,还能提升一点效果。

2.2 词表构建与向量化

清洗分词之后,就可以构建词表了。词表就是训练集所有文档中出现过的去重词集合。这个集合里的每个词对应向量里的一个维度。

构建词表的代码可以这样写:

from collections import Counter def build_vocab(tokenized_docs, min_df=1): df = Counter() for tokens in tokenized_docs: for token in set(tokens): df[token] += 1 return {word: idx for idx, (word, count) in enumerate(df.items()) if count >= min_df}

注意这里统计的是文档频率,也就是这个词出现在几篇文档里,而不是出现多少次。min_df=1表示只要在至少一篇文档里出现就保留。实际项目里我经常设min_df=2或更高,因为只出现一次的词常常是拼写错误、人名、数字残留,这些词不仅没有泛化能力,还容易让朴素贝叶斯记住噪声。

有了词表之后,每篇文档就可以变成一个长度为词表大小的向量:遍历文档里的每个词,找到它在词表中的位置,把对应维度的计数加一。这样一个文档集合就变成了一个二维稀疏矩阵,行是文档,列是词。

2.3 朴素贝叶斯不是一种算法,而是三种常用变体

很多人以为朴素贝叶斯只有一种,其实按特征分布假设,它至少分成三种常用版本,选错版本会直接影响效果。

表格对比一下:

变体特征输入适用场景概率计算特点
MultinomialNB词频、计数文本分类主力,处理离散计数按词频占比估计概率
BernoulliNB0/1,是否出现短文本、关键词是否出现更有意义按文档是否包含词来估计概率
GaussianNB连续值特征数值特征,不常用于原始词频假设特征服从高斯分布

文本分类里我默认用MultinomialNB,因为它能利用重复出现的信息。比如一篇文档连续三次出现“免费”,这通常比只出现一次更强烈地暗示它是广告,多项式模型会把这种强度记录在词频里,而伯努利模型只看“出现没出现”,会丢掉这层信息。

2.4 拉普拉斯平滑和对数概率:两个保命操作

训练朴素贝叶斯时,最重要的数学操作就是平滑。如果不做平滑,某个词在训练集的某个类别里没有出现过,它的条件概率直接就是 0,而概率连乘里只要有一个 0,整个后验概率就变成 0,类别直接被判死刑。

解决办法是在每个词的频次上加上一个平滑项 (\alpha)。计算公式变成:

[ P(w_i|c) = \frac{count(w_i, c) + \alpha}{\sum_w count(w, c) + \alpha \cdot V} ]

其中 (V) 是词表大小。当 (\alpha=1) 时,这个平滑叫作拉普拉斯平滑。加平滑本质上是给没见过的词留一点“同情分”,防止概率被一刀切到零。

另外一个保命操作是对数化。文档里词数很多时,一堆小于 1 的概率相乘,数值会越来越小,最后可能在 float 精度下直接下溢成 0。所以训练时我把概率全部取对数,预测时改成加法:

[ \log P(c|d) = \log P(c) + \sum_i \log P(w_i|c) ]

对数概率不改变最终排序,只会让计算更稳定。这也是为什么很多库训练完,直接暴露出来的是feature_log_prob_,而不是原始概率。

3. 从零实现一个可运行的小项目

3.1 准备一份小数据

为了把流程串起来,我用一个虚构的垃圾短信分类任务来做演示。数据集只有六条样本,类别是spam和normal,足够跑通流程。

raw_docs = [ ("winner you are selected free gift call now", "spam"), ("congrats claim your prize before midnight", "spam"), ("limited time offer cheap insurance renewal", "spam"), ("meeting moved to 3pm tomorrow", "normal"), ("please review the attached report before noon", "normal"), ("can you send me the updated schedule", "normal"), ]

这个数据集规模非常小,真实项目里至少也要几百条,但用来理解代码逻辑足够了。重点是看数据从原始文本变成词频矩阵,再变成概率模型的完整链路。

3.2 实现词袋和朴素贝叶斯

先把分词和词表构建写在一起:

import re from collections import Counter import numpy as np def tokenize(text): text = text.lower() return re.findall(r"[a-z0-9']+", text) tokenized_docs = [tokenize(text) for text, _ in raw_docs] labels = [label for _, label in raw_docs] vocab = build_vocab(tokenized_docs, min_df=1)

然后手写一个多项式朴素贝叶斯类:

class MultinomialNB: def __init__(self, alpha=1.0): self.alpha = alpha def fit(self, docs, y, vocab): self.classes_ = np.unique(y) self.vocab_ = vocab V = len(vocab) self.class_count_ = np.array([np.sum(y == c) for c in self.classes_]) self.class_log_prior_ = np.log(self.class_count_ / len(y)) # 用 alpha 初始化频次矩阵,实现拉普拉斯平滑 self.feature_count_ = np.full((len(self.classes_), V), self.alpha) for tokens, label in zip(docs, y): row = np.where(self.classes_ == label)[0][0] for token in tokens: if token in vocab: self.feature_count_[row, vocab[token]] += 1 total = self.feature_count_.sum(axis=1, keepdims=True) self.feature_log_prob_ = np.log(self.feature_count_ / total) def score(self, tokens): scores = [] for cls_idx, cls in enumerate(self.classes_): score = self.class_log_prior_[cls_idx] for token in tokens: if token in self.vocab_: score += self.feature_log_prob_[cls_idx, self.vocab_[token]] scores.append(score) return np.array(scores), self.classes_ def predict(self, tokens): scores, classes = self.score(tokens) return classes[np.argmax(scores)]

这段代码的关键点在于feature_count_初始值就是alpha,所以每个类别、每个特征的初始计数都从平滑项开始。后面训练样本每出现一个词,对应位置就加一。最后按行归一化,再取对数,得到每个词在每个类别下的对数条件概率。

调用方式很简单:

model = MultinomialNB(alpha=1.0) model.fit(tokenized_docs, labels, vocab) test_tokens = tokenize("congrats you won a free gift") scores, classes = model.score(test_tokens) pred = model.predict(test_tokens) print(scores, classes, pred)

这里会输出spam的得分高于normal,预测结果就是spam。

3.3 代码里最容易忽略的三个点

第一,训练集和测试集必须用同一个词表。不能在训练集上建一份词表,又在测试集上建一份词表,那样两个特征空间对不上,模型等于白训练。正确做法是先fit训练集得到词表,再transform所有文档,包括测试集。

第二,平滑参数的位置不能写错。有人会把平滑项加在class_count_上,也有人会在训练过程中不小心把alpha重复加进total。对多项式朴素贝叶斯来说,平滑只作用于词频矩阵,归一化分母要同步加上alpha * V,否则概率之和不为 1。

第三,预测时的未知词怎么处理。我的实现里直接忽略词典外的 token,这样做等价于不给这个证据投票。更讲究的做法是留一个UNK占位符,在训练时把所有未知词都映射进来,但实际差异通常不大。

3.4 用 sklearn 对照验证

手写代码的意义是理解原理,生产环境我还是会用现成库。用 sklearn 实现同样流程只需要两段代码:

from sklearn.feature_extraction.text import CountVectorizer from sklearn.naive_bayes import MultinomialNB texts = [text for text, _ in raw_docs] y = labels vec = CountVectorizer(tokenizer=tokenize, lowercase=False) X = vec.fit_transform(texts) clf = MultinomialNB(alpha=1.0) clf.fit(X, y) test_vec = vec.transform(["congrats you won a free gift"]) print(clf.predict_proba(test_vec), clf.predict(test_vec))

CountVectorizer背后做的就是我上面写的那套流程,包括分词、建表、转换稀疏矩阵。MultinomialNB背后做的也是同样的频次统计和平滑计算。拿手写代码和库代码跑同一份数据,结果应该一致。这样对照着理解,既能知道原理,又不至于在项目里重复造轮子。

4. 实际项目中常踩的坑和排查思路

4.1 概率为零与全部偏向多数类

如果某条测试样本的预测结果总是不变,先检查是不是类别不均衡导致先验概率压过了证据。比如正常短信有 900 条,垃圾短信只有 100 条,那么P(spam)=0.1,如果测试样本里的词没啥辨识度,先验就会把结果拉向normal。

排查方法很简单:打印class_log_prior_,再看样本里有多少个有效词。如果样本确实只有一个词,而且这个词在两类中的条件概率都接近,那结果偏向多数类完全正常。如果业务上更关心少数类,可以用调整class_prior的方式先验:

clf = MultinomialNB(class_prior=[0.3, 0.7])

这是项目里常用的“人工改赔率”做法,但要注意这会让模型输出不再代表真实的经验概率,需要重新用验证集评估。

4.2 独立性假设失效,模型却不一定崩

最常听到的质疑是“文本里词和词明明不独立,为什么朴素贝叶斯还能用?”答案是:它算出的概率不准确,但分类边界不一定错。只要各类别分数之间的相对大小没有被过度扭曲,最终标签依然是可用的。

真正让独立性假设变得棘手的是否定结构。“not good”这种短语在词袋模型里会被拆成not和good两个独立特征,模型可能看到good就加分,结果把负向情感判成正向。缓解方法有两种:一是加入ngram_range=(1, 2),让模型把“not good”作为一个特征;二是针对业务词表做人工规则,比如把“不”“没”和后面紧邻的词先合并。第二种方法工程量大,但某些场景下非常有效。

4.3 文档长度差异带来的打分偏差

这个坑我踩过好几次。朴素贝叶斯的打分是对数概率累加,每个词的对数概率都是负数,所以词越多,累加得到的分数会越极端。如果一个模型同时要处理短标题和长正文,长正文天然更容易被分到某一类,即使它的内容主题并不明确。

我的做法是,在比较各类别得分之前,把总分除以试一下sqrt(len(tokens))或者直接除以len(tokens)。这是工程上的近似归一化,不是教科书标准做法,但在我做过的短文本项目里确实能减少长度偏置。如果模型输出的是predict_log_proba,同样可以按需求做类似缩放,然后自己选最大值。

4.4 中文场景的额外麻烦

中文文本没有空格,第一步分词就会决定整个链路的表现。分词工具切错词,后面所有特征都是歪的。中文里还有大量新词、网络用语、错别字,比如“yyds”“绝绝子”这种词,如果不进词表,就会被切成一堆单字,完全失去语义。

我在中文项目里通常会在清洗阶段把数字和网址统一替换成占位符,在分词之后保留至少两个字符长度的词,再考虑是否合并相邻否定词。很多时候效果提升不是靠换模型,而是靠把脏数据洗得更干净。

4.5 评估指标与阈值调整

文本分类最容易被误导的指标是准确率。垃圾短信场景里如果 90% 是正常、10% 是垃圾,模型把所有样本都预测成正常,准确率就有 90%,但这个模型毫无用处。所以要去看混淆矩阵,重点算精确率、召回率和 F1。

朴素贝叶斯给出的概率分数有一个额外好处:可以调阈值。比如业务更担心漏掉垃圾短信,那就不一定选分数最大的类别,而是把垃圾短信的阈值调低一点:

spam_log_prob = clf.predict_log_proba(X_test)[:, spam_idx] pred = spam_log_prob > threshold

在验证集上跑不同阈值,画出精确率和召回率的变化曲线,再根据业务偏好选阈值。这一步能救回很多被准确率指标骗过的模型。

5. 经验向的调优清单与下一步扩展

5.1 我的推荐调参顺序

真正常用的参数其实没几个。我自己的习惯是先按顺序调这四样:

第一,文本预处理。检查清洗规则够不够干净,停用词表是不是有明显漏网词,分词效果能不能覆盖业务专有词。第二,词表过滤。把min_df从 1 调到 2 或 3,观察验证集 F1 的变化。第三,平滑参数alpha。用交叉验证搜几组值,比如 0.01、0.1、1、5、10。第四,是否加 n-gram。短文本任务里ngram_range=(1, 2)常常有点小提升,但要注意维度变大会让训练变慢。

这四步做完,模型基本就能到一个不错的水平。多数项目并不需要立刻上深度学习。

5.2 从词袋升级到 TF-IDF 值得吗

词袋模型统计的是词频,但有些词在几乎所有文档里都出现,比如“我们”“问题”“这个”。这些词的词频很高,对分类却没什么帮助。TF-IDF 的思路是给这些高频词降权,给只在少数文档中出现的词升权。

对朴素贝叶斯来说,升级到 TF-IDF 不一定总是正向的。因为多项式朴素贝叶斯的概率假设基于整数计数,换成 TF-IDF 的非整数值后,原来的概率解释会被稀释。我把 TF-IDF 用在词袋模型和线性分类器上更多,朴素贝叶斯则优先靠清洗、停用词和alpha调参。如果调完仍然觉得高频干扰词影响大,再试试 TF-IDF 版本对比一下,也不是不行。

5.3 什么时候该换掉这套组合

词袋模型加朴素贝叶斯是一条非常稳的基线,大部分场景都能在五分钟内跑出结果。但如果数据量到了百万级,文本长度差异很大,或者业务明显依赖语义和词序,我就会考虑换更复杂的选择。比如:

  • 数据量中等,任务依赖词序,可以上n-gram + 线性模型;
  • 数据量大且有标注,可以尝试 TextCNN 或浅层循环网络;
  • 任务强调语义理解,比如同义句匹配、意图识别,优先考虑预训练语言模型。

但即使决定用复杂模型,我也会先把词袋和朴素贝叶斯跑一遍,因为这份基线的错误样本往往能告诉我很多信息,比如哪些类别难分、哪些词被规则错判了。模型再复杂,也不如先搞清楚数据里到底发生了什么问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进