只统计词频,朴素贝叶斯怎样判断垃圾邮件?从先验到对数似然
从小样本文本分类出发,手算类别先验、词频似然与拉普拉斯平滑,追踪对数空间推理,并实现可检查的多项式朴素贝叶斯。
上一篇的 RBF 支持向量机(Support Vector Machine,SVM)直接寻找分类边界:哪些样本靠近边界、边界怎样弯曲,是训练的中心。但在垃圾邮件过滤中,我们经常面对数万维词表、许多零计数和很少的标注样本。先学习一条复杂边界未必是最经济的起点。
另一条路线是先问:垃圾邮件通常会产生哪些词?正常邮件又会产生哪些词?收到新邮件后,再反过来判断哪一类更可能生成它。这就是生成式分类(Generative Classification)的思路。
本文只讲透三个紧密环节:类别先验与词频似然怎样组成后验分数、条件独立假设怎样把高维联合概率拆开,以及平滑与对数空间怎样让计算真正可用。
01 为什么从“边界”转向“数据怎样生成”?#
设训练集有 封邮件,词表包含 个词。把第 封邮件写成词频向量:
- :第 封邮件中词 出现的次数;
- :全部训练邮件的稀疏计数矩阵;
- :每封邮件的类别;
- :类别数,二分类垃圾邮件任务中通常为 2。
逻辑回归与 SVM 学习 或直接学习决策分数,属于判别式方法(Discriminative Method)。朴素贝叶斯(Naive Bayes,NB)则估计:
前者是类别先验(Class Prior),后者是类条件似然(Class-Conditional Likelihood)。再用贝叶斯定理(Bayes’ Theorem)反转方向:
预测同一封邮件时,分母 对所有候选类别相同,所以分类只需比较分子:
这一步很重要:训练不是在背诵“优惠”必然等于垃圾邮件,而是在分别统计“类别本来多常见”和“该类别中各词多常见”。
02 “朴素”究竟假设了什么?#
若直接估计 个词的联合分布 ,可能的词频组合多得无法覆盖。朴素贝叶斯作出条件独立(Conditional Independence)假设:给定类别后,每个特征的出现不再依赖其他特征。
对一般特征,它把联合似然近似为:
文本中的“免费”和“领取”显然不是现实独立的,因此这不是对语言的忠实描述。它的价值在于把一个几乎无法估计的高维联合分布,变成每类一组可由计数估计的参数。
03 多项式模型怎样把一封邮件变成似然?#
多项式朴素贝叶斯(Multinomial Naive Bayes)把一类邮件中的每个词看成从词表分类分布中反复抽取。对类别 ,参数向量为:
表示类别 中一次词元抽取为词 的概率。若新邮件词频为 ,总词数 ,其多项式似然为:
组合系数只由当前邮件 决定,比较类别时可以消去。因此联合分数与下式成正比:
- :类别 的先验;
- :新邮件中词 的次数;
- :类别 下词 的概率。
注意指数 的含义:词出现两次,就把相应证据乘两次;没出现的词满足 ,不贡献这一项。
04 从训练计数估计先验和词概率#
类别先验可用样本比例估计:
记 为训练集中类别 的所有邮件里,词 的总出现次数; 为该类总词数。带加性平滑(Additive Smoothing)的词概率是:
- :平滑强度;
- :拉普拉斯平滑(Laplace Smoothing);
- :Lidstone 平滑(Lidstone Smoothing)。
若不平滑,某词从未在某类训练邮件中出现时 。新邮件只要含这个词,整类似然的乘积就立刻变为 0;一个有限训练集中的“没见过”被错误解释成“绝不可能”。平滑给每个词增加伪计数,避免这类一票否决。
05 用三个词手算一封新邮件#
词表固定为 [优惠, 会议, 项目],训练集有两封垃圾邮件、两封正常邮件。汇总词频如下:
| 类别 | 邮件数 | 优惠 | 会议 | 项目 | 总词数 |
|---|---|---|---|---|---|
| 垃圾 | 2 | 3 | 0 | 1 | 4 |
| 正常 | 2 | 0 | 2 | 2 | 4 |
两类先验相同:
取 ,平滑后:
新邮件“优惠 会议”的计数向量为 。忽略两类共有的组合系数,联合分数为:
因为 ,模型预测垃圾邮件。若把两个分数归一化:
这是一个刻意做小的例子:同一封邮件同时包含两类各自偏爱的词,因此结论并不强。它也展示了先验怎样参与竞争——若正常邮件在真实流量中远多于垃圾邮件,较大的 可能翻转结果。
交互手算:若“优惠”出现两次会怎样?
此时 。垃圾类与正常类的未归一化分数分别为 与 。两者比值从 扩大到 ,重复出现的词会重复贡献证据。
06 为什么工程实现必须进入对数空间?#
真实词表可能有十万维,一封邮件会连乘许多小于 1 的概率。浮点数很快下溢为 0,类别之间便无法比较。取对数把乘法改成加法:
对数是单调函数,不改变最大值所在类别。完整数据流如下:
训练阶段
文本 [N] ─► 分词/词表 ─► 稀疏计数 X [N,V] + 标签 y [N]
│
┌──────────────┴──────────────┐
▼ ▼
类别计数 [K] 类别-词计数 [K,V]
│ │ + alpha
▼ ▼
log prior [K] log theta [K,V]
推理阶段
新文本 [Q] ─► 同一词表 ─► Xq [Q,V]
│ Xq @ log_theta.T + log_prior
▼
joint log score [Q,K]
│ argmax / log-sum-exp
▼
类别 [Q] / 后验概率 [Q,K]text词表也是模型状态。推理时若重新拟合词表,同一列可能从“优惠”变成“项目”,维度即使相同,语义也已经错位。
07 不调用 fit,先写出训练本体#
下面的 NumPy 实现把计数、平滑和矩阵推理全部暴露出来:
import numpy as np
X = np.array([
[2, 0, 1],
[1, 0, 0],
[0, 1, 1],
[0, 1, 1],
], dtype=np.float64) # [N=4, V=3]
y = np.array([1, 1, 0, 0]) # [N=4]
classes = np.unique(y) # [K=2],升序为 [0, 1]
alpha = 1.0
class_count = np.array([
np.sum(y == c) for c in classes
], dtype=np.float64) # [K]
feature_count = np.stack([
X[y == c].sum(axis=0) for c in classes
]) # [K, V]
class_log_prior = np.log(class_count / len(y))
smoothed_count = feature_count + alpha
feature_log_prob = np.log(
smoothed_count / smoothed_count.sum(axis=1, keepdims=True)
) # [K, V]
X_query = np.array([[1, 1, 0]], dtype=np.float64) # [Q=1, V=3]
joint_log_score = (
X_query @ feature_log_prob.T + class_log_prior
) # [Q, K]
prediction = classes[np.argmax(joint_log_score, axis=1)] # [Q]
# 稳定归一化为后验概率
shifted = joint_log_score - joint_log_score.max(axis=1, keepdims=True)
posterior = np.exp(shifted)
posterior /= posterior.sum(axis=1, keepdims=True) # [Q, K]
assert np.all(X >= 0) and np.all(X_query >= 0)
assert feature_count.shape == feature_log_prob.shape == (2, 3)
assert np.allclose(np.exp(feature_log_prob).sum(axis=1), 1.0)
assert np.allclose(posterior.sum(axis=1), 1.0)python训练的本质不是迭代梯度下降,而是一次分组计数与归一化;推理则是稀疏矩阵乘法。这解释了它为什么在超高维文本上训练和预测都很快。
08 用 scikit-learn 1.9 建立可复现流程#
截至本文写作时,scikit-learn 1.9 的 CountVectorizer 直接产生稀疏词频矩阵,MultinomialNB 接受稠密或稀疏的 [N,V] 非负特征。把两者放进 Pipeline,能保证验证与线上推理复用训练词表:
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.model_selection import StratifiedKFold, cross_validate
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import make_pipeline
train_texts = [
'优惠 优惠 项目',
'优惠',
'会议 项目',
'会议 项目',
]
y_train = np.array(['垃圾', '垃圾', '正常', '正常'])
pipeline = make_pipeline(
CountVectorizer(token_pattern=r'(?u)\b\w+\b'),
MultinomialNB(alpha=1.0, force_alpha=True, fit_prior=True),
)
pipeline.fit(train_texts, y_train)
query = ['优惠 会议']
prediction = pipeline.predict(query) # [Q=1]
log_probability = pipeline.predict_log_proba(query) # [1, K]
probability = pipeline.predict_proba(query) # [1, K]
vectorizer = pipeline.named_steps['countvectorizer']
model = pipeline.named_steps['multinomialnb']
vocabulary = vectorizer.get_feature_names_out() # [V]
print('classes:', model.classes_)
print('vocabulary:', vocabulary)
print('class counts:', model.class_count_) # [K]
print('word counts:', model.feature_count_) # [K, V]
print('log word probabilities:', model.feature_log_prob_) # [K, V]
positive_column = np.flatnonzero(model.classes_ == '垃圾').item()
spam_probability = probability[:, positive_column]python重要接口语义:
alpha可为标量或[V]数组;alpha=1.0是拉普拉斯平滑;force_alpha=True会保留调用者给出的极小alpha,也意味着过小值可能造成数值问题;fit_prior=True从训练类别频率学习先验,class_prior可显式注入已知部署先验;feature_count_形状为[K,V],feature_log_prob_与其同形;predict_proba的列顺序永远由classes_决定,不能凭业务名称猜列号;partial_fit可按批增量累计计数,但第一次调用必须传入所有可能的classes。
中文文本的示例用空格表示已经分好词。真实项目不能默认把 CountVectorizer 当中文分词器:应把经过验证的 tokenizer 放进 Pipeline,并把分词词典、规范化规则与模型一起版本化。
09 训练、验证和上线时要检查什么?#
平滑强度 、是否使用二元出现特征、n-gram 范围与最低词频都属于超参数,必须在训练折内学习词表并在验证折评价。不要先对全数据执行 fit_transform 再交叉验证,否则验证文本已经参与词表筛选。
可以用分层交叉验证同时记录概率质量与分类质量:
cv = StratifiedKFold(n_splits=2, shuffle=True, random_state=42)
scores = cross_validate(
pipeline,
train_texts,
y_train,
cv=cv,
scoring=['neg_log_loss', 'balanced_accuracy'],
return_train_score=True,
)python四条最短调试路径:
- 确认矩阵非负。 中心化或某些降维会产生负数,不符合多项式计数模型;
- 抽查列语义。 打印词表与一行非零位置,确认分词、大小写和停用词处理没有错位;
- 检查未登录词比例。 线上大量词不在训练词表时,它们会被忽略,输入可能接近全零;
- 检查分数而非只看标签。 查看
predict_joint_log_proba或predict_log_proba,定位是先验压倒似然,还是某些重复词贡献过大。
10 常见错误与失败场景#
- 训练和推理词表不一致。 必须保存整个 Pipeline,而不是只保存分类器;
- 把类别字段直接整数化后交给
MultinomialNB。 它适合计数或非负强度,不等于一般类别特征;一般类别特征应评估CategoricalNB; - 把
alpha=0当成“更无偏”。 零计数会制造零概率,且force_alpha=True不会替你兜底; - 类别失衡时只看准确率。 同时看混淆矩阵、每类召回率、PR 曲线与部署先验;
- 让重复模板词支配结果。 邮件签名、页脚和转发历史可能重复计算无关证据,应清洗或限制特征;
- 训练分布与线上分布变化。 垃圾邮件用词快速漂移,需要监控未登录词、类别比例和错误样本;
- 把相关性当成因果。 “优惠”提高垃圾分数,只说明训练关联,不说明该词导致邮件有害。
11 与相近方法怎样区分?#
| 方法 | 输入假设 | 训练方式 | 主要边界 |
|---|---|---|---|
| 多项式 NB | 非负词频或强度 | 每类累计词计数 | 重复出现会重复贡献证据 |
| 伯努利 NB | 每个词出现/未出现 | 每类累计二元事件 | 关心是否出现,不关心次数 |
| 补集 NB | 用其他类别估计权重 | 计数闭式估计 | 常用于类别失衡文本 |
| 高斯 NB | 每类每维独立高斯 | 估计均值和方差 | 适合连续特征,不建模相关性 |
| 逻辑回归 | 对数几率线性 | 优化条件似然 | 不生成词,常需更多标注数据 |
多项式 NB 与逻辑回归在词袋特征上都能形成线性决策分数,但参数来源不同:前者由每类词频概率之比得到,后者直接优化分类条件似然。数据很少、维度很高时 NB 往往启动快;数据增多且特征相关明显时,正则化逻辑回归常更稳。
12 今天真正需要记住什么?#
- 朴素贝叶斯先估计类别先验和类条件似然,再用贝叶斯定理比较后验分数;
- 条件独立假设把高维联合概率拆成每个特征的证据,但可能造成过度自信;
- 多项式 NB 用类别内词频估计 ,拉普拉斯平滑防止未见词把整类概率归零;
- 推理必须在对数空间累加:
X @ feature_log_prob_.T + class_log_prior_; - 词表、分词与分类器是同一个模型状态,必须一起验证和部署。
13 思考题与小练习#
练习 1:先验怎样翻转结论?
手算例中新邮件的类条件似然比为 。若部署先验变为 ,联合分数比变成 ,预测会翻转为正常邮件。
练习 2:比较多项式与伯努利输入
将 x=[3,0,1] 二值化为 [1,0,1]。解释为什么多项式模型会把第一个词的证据计算三次,而伯努利模型只计算一次;再思考哪种更适合短标题,哪种更适合长文档。
练习 3:制造一个词表错位 bug
分别对训练文本和查询文本调用两个 CountVectorizer().fit_transform,然后打印两份 get_feature_names_out()。即使矩阵列数相同,列语义也可能不同;说明为什么只保存 MultinomialNB 不足以上线。
相关工作#
- Maron: Automatic Indexing—An Experimental Inquiry ↗:用概率方法进行文档自动索引的早期经典工作。
- McCallum & Nigam: A Comparison of Event Models for Naive Bayes Text Classification ↗:系统比较文本朴素贝叶斯的多项式与伯努利事件模型。
- Ng & Jordan: On Discriminative vs. Generative Classifiers ↗:比较朴素贝叶斯与逻辑回归学习行为的代表性研究。
- Rennie et al.: Tackling the Poor Assumptions of Naive Bayes Text Classifiers ↗:提出补集朴素贝叶斯等改进以缓解文本中的失衡与偏差。
- scikit-learn: MultinomialNB ↗:当前参数、属性、稀疏输入和增量训练接口说明。
14 下一篇预告#
朴素贝叶斯为了可估计性,假设给定类别后各特征独立。若身高和体重、多个传感器读数明显相关,我们能否保留生成式分类的概率框架,同时显式建模特征协方差?下一篇将从共享高斯协方差推导线性判别分析,并解释它为什么仍然得到一条直线。