简介:面向机器学习初学者与Matlab实践者的朴素贝叶斯分类器代码包,采用非官方自编实现,围绕贝叶斯定理、特征条件独立假设以及先验概率与条件概率的计算展开,适合课程实验、课堂演示和自学复现。压缩包共3个文件,均为.m脚本,整体约961B,体量轻便,内容覆盖训练、预测与测试环节,便于读者对照理解概率分类器从参数估计到分类决策的实现链路。已有3441人学习,说明其在入门级概率模型实践中具有一定参考价值。读者可借助训练与预测代码观察数据预处理、类别频率统计、条件概率计算和分类决策的完整流程,并通过测试模块验证分类结果、比较不同数据下的表现,进而加深对朴素贝叶斯适用边界、实现细节与模型评估思路的理解。
1. 朴素贝叶斯:一个「假设注定不成立」的分类器,凭什么还能打
朴素贝叶斯在文本分类、垃圾邮件过滤、故障初筛这些场景里被反复使用。但它的核心假设——给定类别后各特征相互独立——在真实数据里几乎从不成立:一封邮件里出现「发票」和「报销」的概率显然不独立。更反直觉的是,把这条明显错误的假设硬塞进去,准确率往往只掉几个点,样本只有几百条时它甚至还能压过逻辑回归。
这里有个值得拆开的机制问题:分类器要的到底是「算得准的概率」,还是「排得对的顺序」?为什么代码里那些看起来粗糙的平滑系数、方差下限、对数变换,恰恰是它能工作的关键?下面从贝叶斯公式怎么被拆开讲起,一路拆到 Matlab 里那几个矩阵到底存了什么。
2. 后验概率的三段拆解:先验、似然与对数域里的加法规避
2.1 独立假设究竟省掉了多少个参数
决策规则只有一句话:给定特征向量 x,选出后验概率最大的类别。
$$P(c\mid x)=\frac{P(x\mid c),P(c)}{P(x)}$$
麻烦全在 $P(x\mid c)$ 这一项。D 维特征、每维取 m 个值,联合分布需要 $m^D-1$ 个自由参数。D=30、m=2 时是十亿量级,任何数据集都填不满这张表,绝大多数格子是空的。这就是维度灾难在这个模型上的具体形态——不是算不动,是没数据可算。
独立假设做的事情,是把联合分布按维度劈开:$P(x\mid c)=\prod_{d}P(x_d\mid c)$。参数总量从 $m^D-1$ 降到 $K\cdot D\cdot(m-1)$,同样 D=30、m=2、K=2,只要 60 个参数,两三百条样本就能估得勉强能用。
代价很直接:真实数据里 $P(x_1,x_2\mid c)\neq P(x_1\mid c)P(x_2\mid c)$,模型一定是有偏的。换来的是方差大幅下降——每个一维分布只用一列数据估,估得极稳。这是典型的偏差-方差权衡:拿一个必然错的假设,换一个极低的估计方差。新手常以为「假设不成立所以模型不行」,实际上小样本下低方差的价值远大于低偏差。
2.2 三种似然写法,对应三类完全不同的数据
同一个公式,把 $P(x_d\mid c)$ 换成不同分布,就是三个不同的算法。选错分布比调错参数严重得多。
高斯朴素贝叶斯假设每类每维服从正态分布,参数是均值 $\mu_{cd}$ 和方差 $\sigma^2_{cd}$,总参数量 $2KD$。适用于连续特征,比如传感器读数、像素灰度、金额。前提是这一维在类内大致单峰、对称。
多项式朴素贝叶斯把 x 当作计数向量,$P(x\mid c)\propto\prod_d \theta_{cd}^{x_d}$,参数是每类一个 D 维概率向量 $\theta_c$,总量 $KD$。文本的词频、日志里的事件计数都属于这一类。注意它建模的是「出现多少次」,词表里没出现的词根本不进连乘。
伯努利朴素贝叶斯建模的是「出现没出现」,每一维都参与计算,包括取值为 0 的维度,那一维贡献的是 $(1-\theta_{cd})$ 这个因子。这一点最容易被忽略:拿词频矩阵直接喂伯努利模型,等于把「没出现」的信息全部丢掉,效果会莫名其妙地差。
| 分布类型 | 参数规模 | 输入要求 | 典型场景 |
|---|---|---|---|
| 高斯 normal | 2KD | 连续、类内近似单峰 | 传感器、测量值、图像特征 |
| 多项式 mn | KD | 非负计数 | 词频、事件计数 |
| 伯努利 bernoulli | KD | 0/1 二值 | 短文本是否出现、开关量 |
2.3 连乘 500 项会老老实实下溢成 0
判别函数是 $\hat c=\arg\max_c P(c)\prod_d P(x_d\mid c)$。D 大时这个连乘会出事:double 能表示的最小正规数在 $10^{-308}$ 附近,如果每维似然是 0.1,500 维连乘就是 $10^{-500}$,直接变成 0。所有类别的分数都变成 0,max返回第一个下标,模型开始「永远预测第一类」——这个 bug 表现得很像标签不平衡,实际是数值下溢。
正确的做法是全程在对数域:$\log P(c)+\sum_d \log P(x_d\mid c)$。乘法变加法,500 维也只是 -1150 这样的普通数字,离下溢远得很。而且加法比乘法快,梯度性质的推导也干净。
% 对数域判别函数:一行算完一个类别的分数 % X : N×D 特征矩阵 % mu : 1×D 该类各维均值 % s2 : 1×D 该类各维方差(已加下限) % logPr : 标量,该类先验的对数 % 返回 N×1 的对数后验(未归一化) function s = logScoreGaussian(X, mu, s2, logPr) % log N(x; mu, s2) = -0.5*log(2*pi*s2) - 0.5*(x-mu)^2/s2 % 按维求和即得到联合对数似然,隐式扩展需要 R2016b+,老版本用 bsxfun quad = (X - mu).^2 ./ s2; % N×D 标准化残差平方 logLik = -0.5 * sum(log(2*pi*s2) + quad, 2); s = logLik + logPr; % 加上先验,得到未归一化对数后验 endsum(..., 2)的第二个参数是维度,按行求和,把 D 维特征压成一个样本分数,这是整个模型里唯一一次「跨特征」的运算,也是独立假设在代码层面的落点:除了这里,各维之间没有任何交互项。log(2*pi*s2)这一项只跟类别有关、跟样本无关,做纯分类时其实可以整块省掉,但保留它才能在后面还原出真实的概率刻度。
3. 训练阶段存了什么:均值矩阵、方差矩阵与计数表的真实结构
3.1 一个结构体装下全部参数
训练阶段不做任何优化、不迭代、不求导,只做分组统计。这是朴素贝叶斯训练快到几乎无感的原因:一次遍历数据就能出结果,时间复杂度是 $O(ND)$,跟迭代轮数无关。
function model = nbTrainGaussian(X, y, alpha, varSmooth) % X: N×D 特征矩阵;y: N×1 类别标签(数值或字符串/categorical) % alpha : 先验平滑系数,1 即拉普拉斯 % varSmooth : 方差下限,防止某维方差为 0 导致除零 if nargin < 3, alpha = 1; end if nargin < 4, varSmooth = 1e-9; end classes = unique(y); % 升序排列,索引即内部类别编号 K = numel(classes); [N, D] = size(X); model.classes = classes; % 保留原始标签,预测时映射回去 model.prior = zeros(K,1); model.mu = zeros(K,D); model.sigma2 = zeros(K,D); for k = 1:K idx = (y == classes(k)); % 逻辑索引,比 find 快且不产生临时下标 nk = sum(idx); model.prior(k) = (nk + alpha) / (N + K*alpha); % 平滑后的先验 Xk = X(idx, :); model.mu(k,:) = mean(Xk, 1); model.sigma2(k,:) = var(Xk, 0, 1) + varSmooth; % 0 表示按 N-1 归一化 end endvar(Xk, 0, 1)里第一个 0 是权重参数,0 表示按 N-1 归一化(无偏估计),1 表示按 N 归一化。样本量小的类别用 0 容易把方差估得偏大,但至少不会系统性偏小;如果某类只有一条样本,两种归一化都救不了,这条样本对应的方差会直接是 0,只能靠varSmooth兜住。
先验为什么要平滑?当某个类别样本极少时,nk/N可能是 0,取对数后变成-Inf,这个类别就彻底失去竞争资格。加 alpha 后每个类别至少有 $alpha/(N+K\alpha)$ 的底概率,模型保留了「万一」的可能性。而K*alpha在分母上保证所有先验加起来仍然等于 1,这一点漏掉就会让分数整体偏移。
3.2 fitcnb 返回的对象里,参数落在哪几个属性上
Matlab 自带的fitcnb返回一个ClassificationNaiveBayes对象,核心属性跟手写版一一对应:ClassNames是类别列表,Prior是先验向量,Sigma是协方差参数,DistributionParameters存各类各维的分布参数。DistributionNames决定似然形式,取值包括'normal'(高斯)、'mn'(多项式)、'kernel'(核密度)、'mvmn'(多维多项,用于离散特征)。
有两个参数最值得注意。Prior除了'empirical'(按频率)和'uniform'(等概率)之外,还能直接传一个数值向量,这是处理类别不平衡最省事的手段:把少数类的先验手动抬高,不改数据就能挪动决策边界。Sigma在高斯情形下是 K×D 的矩阵,每行是一个类别的各维方差,说明它走的就是对角协方差;如果换成一个 K×D×D 的三维数组,就是在建模特征间的相关性——那时候它已经不是朴素贝叶斯了,而是二次判别分析。
分布名选错是新手最容易踩的坑:'mn'要求输入是非负整数计数,把标准化后的连续特征喂进去,log里会出现负数,Matlab 不报错,返回复数,后面max比较复数大小按模长算,结果毫无意义且很难察觉。
3.3 多项式版本的计数表长什么样
function model = nbTrainMultinomial(X, y, alpha) % X: N×D 非负计数矩阵(如词频);alpha: 平滑系数,1 即拉普拉斯 classes = unique(y); K = numel(classes); D = size(X,2); model.classes = classes; model.logPrior = zeros(K,1); model.logTheta = zeros(K,D); % 每行是一个类别的条件概率向量 for k = 1:K idx = (y == classes(k)); cnt = sum(X(idx,:), 1) + alpha; % 逐维累加计数后平滑 model.logTheta(k,:) = log(cnt) - log(sum(cnt)); % 归一化后取对数 model.logPrior(k) = log(sum(idx) / numel(y)); end endlog(cnt) - log(sum(cnt))这个写法比log(cnt/sum(cnt))稳:先归一化再取对数,当某个计数极小时会先损失精度;先取对数再相减,等于在 log 空间做除法,精度损失小得多。这个技巧在概率建模里很常见,血泪经验往往就是这么攒下来的。
平滑系数 alpha 在这里的作用不是「防止除零」,而是重新分配概率质量:alpha=1 时每个词至少分到一份虚拟计数,代价是所有词的估计都被轻微拉平;alpha 取 0.1、0.01 时拉平程度减弱,但对没见过的词更不宽容。文本任务里 alpha 常常是效果最敏感的超参,比换分布类型影响还大。
4. 预测阶段:分母整块扔掉之后,剩下的是什么
4.1 argmax 的单调性给了扔掉分母的合法性
$P(x)$ 是所有类别共享的同一个数,跟 c 无关。$\arg\max_c \frac{a_c}{b}$ 和 $\arg\max_c a_c$ 结果完全一样,所以判别时只看分子。这一步省掉的不只是计算量:$P(x)=\sum_c P(x\mid c)P(c)$ 需要对所有类别求和,如果某个类别的似然估值偏高,分母会被带偏,反而污染排序。
但这里埋着一个容易被误用的点:扔掉分母之后,分数不再是概率,只是「未归一化的对数后验」。如果后面要拿分数做阈值决策——比如「后验超过 0.9 才自动判定为垃圾邮件」——直接拿 logScore 跟 0.9 比就完全错了。想拿到真实概率刻度,必须做一次 log-sum-exp 归一化:
% 把未归一化对数后验转成真实后验概率 % logPost: N×K 的分数矩阵 function P = logPostToProb(logPost) mx = max(logPost, [], 2); % 每行最大值,防止 exp 溢出 ex = exp(logPost - mx); % 减去最大值后 exp,安全 P = ex ./ sum(ex, 2); % 归一化到和为 1 end减去mx是关键:exp(-1150)直接下溢到 0,几行全是 0,除出来就是0/0=NaN。减掉行最大值之后,最大项一定等于 1,其余项都在 (0,1] 之间,既不下溢也不上溢。这个技巧在所有 softmax 实现里都能看到,自己写的时候容易忘。
4.2 判别函数展开后,各项分别在干什么
把高斯对数似然展开成关于 x 的表达式:
$$g_c(x)=-\frac{1}{2}\sum_d \log(2\pi\sigma_{cd}^2)-\frac{1}{2}\sum_d\frac{(x_d-\mu_{cd})^2}{\sigma_{cd}^2}+\log P(c)$$
再把平方项拆开,得到一个二次型:
$$g_c(x)=-\frac{1}{2}x^\top\Sigma_c^{-1}x+\mu_c^\top\Sigma_c^{-1}x-\frac{1}{2}\mu_c^\top\Sigma_c^{-1}\mu_c-\frac{1}{2}\log|\Sigma_c|+\log P(c)$$
在朴素假设下 $\Sigma_c$ 是对角阵,$x^\top\Sigma_c^{-1}x$ 里没有 $x_ix_j\ (i\neq j)$ 的交叉项,二次项对所有类别是同一个形状,比较时可以整块消掉,判别边界退化成线性的。这就是「朴素贝叶斯在高斯假设下等价于对角协方差的二次判别分析」这句话的来历。
如果放宽独立假设、让 $\Sigma_c$ 是全矩阵但所有类别共享同一个 $\Sigma$,二次项彻底消失,就得到线性判别分析;如果让每个类别的 $\Sigma_c$ 都不一样且都是全矩阵,就是完整的二次判别分析。三者是同一套公式在协方差约束上的三档松紧,理解了这条谱系,就明白朴素贝叶斯不是「简化版的贝叶斯」,而是协方差矩阵被强制对角化之后的特例。
4.3 打分矩阵到标签:max 的第二个返回值不能省
function [yhat, score] = nbPredictGaussian(model, X) % model: nbTrainGaussian 的输出 % 返回 yhat 预测标签,score 为 N×K 未归一化对数后验 K = numel(model.classes); N = size(X,1); score = zeros(N, K); for k = 1:K score(:,k) = logScoreGaussian(X, model.mu(k,:), ... model.sigma2(k,:), log(model.prior(k))); end [~, idx] = max(score, [], 2); % 第二个返回值才是类别列号 yhat = model.classes(idx); % 映射回原始标签 endmax(score, [], 2)的第一个返回值是最大值本身,第二个才是下标。写成[~, idx]是固定套路,但第二个参数[]不能少——省略它 Matlab 会按列求最大值,返回 1×K 而不是 N×1,后面索引直接错位。这种错误不会报错,只会让准确率莫名其妙地停在随机水平,排查起来相当费时间。
score单独返回很有必要。分类之外,很多场景需要看「第二候选跟第一候选差多少」:差得小说明样本落在边界上,可以转人工复核;差得大可以放心自动处理。这个裕度信息在只返回标签的接口里被彻底丢掉了,做工程时建议一开始就留着。
5. 排查:零概率、方差塌陷与量纲错配的常见翻车现场
这个模型代码短,出错的地方反而隐蔽——它不会报错,只会表现成「效果不行」。下面五条是我见过最多的,按出现频率排。
5.1 训练集准确率接近满分,测试集接近随机
现象:在训练集上算一遍准确率 99%,换测试集掉到 50% 出头,混淆矩阵几乎均匀分布。
原因:最常见的是特征泄漏——在划分训练测试之前就做了标准化或者特征选择,测试集的均值方差信息漏进了训练过程。第二常见的是维度远大于样本数且特征高度相关,每一维方差估得极小,似然函数在训练点上变成一个极尖的峰,稍微偏离就判成别的类。
解决:把标准化、缺失值填补、特征选择全部塞进交叉验证的折内,用分层 K 折(cvpartition的'Stratified')而不是随机划分;如果维度确实过高,先做主成分或者相关性剪枝再训练。判断标准很朴素:交叉验证的准确率标准差如果比训练/测试的差距还大,说明划分本身不稳定,多折几遍再下结论。
5.2 分数出现 -Inf 或者 NaN
现象:score矩阵里某些元素是-Inf,或者整行变成NaN,max之后预测结果乱跳。
原因有两个来源。一是某个类别在某一维上的方差估成了 0(该类在这维上取值全相同,或只有一条样本),log(2*pi*0)是-Inf,同时(x-mu)^2/0在 x=mu 时是0/0=NaN。二是多项式版本里某维计数为 0 且没做平滑,log(0)=-Inf。
解决:方差加一个绝对下限(varSmooth),同时对样本数过少的类别做方差收缩,把类内方差朝全局方差拉一把:
% 方差收缩:样本少的类别向全局方差靠拢,避免方差塌陷 % sigma2k : 1×D 该类方差;sigma2g : 1×D 全局方差;nk : 该类样本数;tau : 收缩强度 lambda = nk / (nk + tau); sigma2k = lambda * sigma2k + (1 - lambda) * sigma2g; sigma2k = max(sigma2k, 1e-9); % 双保险,绝不允许 0tau取 1 到 10 之间比较稳,它等价于「虚拟地给这个类别补 tau 条等于全局分布的样本」。这个做法在协方差估计里很常见,本质就是向先验收缩。
5.3 某个类别永远预测不出来
现象:混淆矩阵里有一整行全是 0,无论输入什么,模型都不输出这个类。
原因:类别不平衡加上经验先验。假设正负样本 1:99,先验就是 0.01 和 0.99,对数差是 -4.6,似然必须提供超过 4.6 的优势才能翻盘,而朴素贝叶斯的似然往往给不出这么大的差距。另一种情况是该类样本数小于特征维度,方差被估得极小,似然项异常尖锐,稍微偏离就崩到极小值。
解决:优先调先验而不是重采样。把Prior设成'uniform',或者按类别频率的倒数加权传一个自定义向量,能直接挪动决策边界且不改变数据分布。如果确实需要重采样,对少数类做 SMOTE 之类的插值要小心——它会人为制造出方差更小的合成样本,进一步加剧方差塌陷。
5.4 多加了一个特征,效果反而变差
现象:往特征集里加一列明显有信息量的特征,交叉验证准确率反而下降。
原因:新特征跟已有特征强相关。独立假设下,两个相关系数 0.95 的特征会被当成两份独立证据,同一个信号被数了两遍,等价于把这个特征的权重放大了一倍。这不是「加噪」,是「加权失衡」,而且失衡量随相关特征数量线性增长。
解决:训练前先看一遍特征相关矩阵,把相关系数超过 0.9 的成对特征删掉一个。这件事看起来原始,但对朴素贝叶斯特别有效,因为它的假设决定了它没有能力自己处理共线性。另一个思路是保留全部特征但换模型,让协方差矩阵去承担去相关的责任。
5.5 输出复数或者概率大于 1
现象:score里出现复数,或者手工算出来的「概率」不在 [0,1] 区间。
原因:分布类型和数据类型错配。多项式模型要求非负计数,喂了标准化后的连续特征(含负值),x_d * log(theta)里log(theta)是负数、x_d也是负数,乘积变正,或者直接触发复数的幂运算;另一种是先验没归一化,(nk+alpha)/(N+K*alpha)里的 K 写错,导致各类先验之和不等于 1。
解决:训练前断言输入类型,连续特征走高斯或者核密度,计数特征走多项式,二值特征走伯努利;先验算完之后加一行assert(abs(sum(model.prior)-1) < 1e-12)。这类断言成本几乎为零,但能挡住后面几小时的困惑。
6. 取舍:独立假设的代价、平滑的强度与换模型的时机
把前面几章连起来看,朴素贝叶斯的所有设计都能归结到一个取舍:用一条必然错误的独立性假设,换参数的线性增长和极低的估计方差。这个交换在小样本、高维度、特征相关性不强的场景里划算得惊人;反过来,样本几万条、特征强相关时,它省下来的方差已经不值钱,偏差就成了主导误差。
| 维度 | 高斯朴素贝叶斯 | 多项式朴素贝叶斯 | 逻辑回归 | 树模型 |
|---|---|---|---|---|
| 参数量 | 2KD | KD | D+1 | 随深度增长 |
| 训练复杂度 | O(ND),一次遍历 | O(ND) | 需迭代优化 | 需分裂搜索 |
| 小样本表现 | 好 | 好 | 一般 | 差 |
| 特征相关性 | 无法处理 | 无法处理 | 可处理 | 可处理 |
| 输出概率刻度 | 偏极端,需校准 | 偏极端 | 较准 | 需校准 |
| 增量更新 | 天然支持 | 天然支持 | 需重训 | 需重训 |
平滑系数是第二个取舍点,而且它同时干了两件事:alpha 调大,先验更平、条件概率更平,模型更保守,方差更小偏差更大;alpha 调小则相反。文本任务里这个参数常常比特征工程影响还大,值得单独用交叉验证扫一遍。
第三个取舍是「要不要输出概率」。只要标签,扔掉分母、只比大小最快最稳;要拿分数做阈值、做人工复核、做多模型融合,就必须做 log-sum-exp 归一化,还得考虑高斯假设下概率刻度天然偏极端(它总觉得数据离均值不远),必要时用保序回归或者 Platt 缩放校准一遍。
从那以后我每次跑朴素贝叶斯之前,都会先做三件事:看一眼特征相关矩阵,把方差下限和先验收敛的断言写死,把预测分数转成后验概率再定阈值。这三步加起来不到十分钟,挡掉的坑比调参省下的时间多得多。希望帮到你。
本文还有配套的精品资源,点击获取