
简介面向机器学习初学者与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$ 个自由参数。D30、m2 时是十亿量级任何数据集都填不满这张表绝大多数格子是空的。这就是维度灾难在这个模型上的具体形态——不是算不动是没数据可算。独立假设做的事情是把联合分布按维度劈开$P(x\mid c)\prod_{d}P(x_d\mid c)$。参数总量从 $m^D-1$ 降到 $K\cdot D\cdot(m-1)$同样 D30、m2、K2只要 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})$ 这个因子。这一点最容易被忽略拿词频矩阵直接喂伯努利模型等于把「没出现」的信息全部丢掉效果会莫名其妙地差。分布类型参数规模输入要求典型场景高斯 normal2KD连续、类内近似单峰传感器、测量值、图像特征多项式 mnKD非负计数词频、事件计数伯努利 bernoulliKD0/1 二值短文本是否出现、开关量2.3 连乘 500 项会老老实实下溢成 0判别函数是 $\hat c\arg\max_c P(c)\prod_d P(x_d\mid c)$。D 大时这个连乘会出事double 能表示的最小正规数在 $10^{-308}$ 附近如果每维似然是 0.1500 维连乘就是 $10^{-500}$直接变成 0。所有类别的分数都变成 0max返回第一个下标模型开始「永远预测第一类」——这个 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/(NK\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 在这里的作用不是「防止除零」而是重新分配概率质量alpha1 时每个词至少分到一份虚拟计数代价是所有词的估计都被轻微拉平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/0NaN。减掉行最大值之后最大项一定等于 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或者整行变成NaNmax之后预测结果乱跳。原因有两个来源。一是某个类别在某一维上的方差估成了 0该类在这维上取值全相同或只有一条样本log(2*pi*0)是-Inf同时(x-mu)^2/0在 xmu 时是0/0NaN。二是多项式版本里某维计数为 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也是负数乘积变正或者直接触发复数的幂运算另一种是先验没归一化(nkalpha)/(NK*alpha)里的 K 写错导致各类先验之和不等于 1。解决训练前断言输入类型连续特征走高斯或者核密度计数特征走多项式二值特征走伯努利先验算完之后加一行assert(abs(sum(model.prior)-1) 1e-12)。这类断言成本几乎为零但能挡住后面几小时的困惑。6. 取舍独立假设的代价、平滑的强度与换模型的时机把前面几章连起来看朴素贝叶斯的所有设计都能归结到一个取舍用一条必然错误的独立性假设换参数的线性增长和极低的估计方差。这个交换在小样本、高维度、特征相关性不强的场景里划算得惊人反过来样本几万条、特征强相关时它省下来的方差已经不值钱偏差就成了主导误差。维度高斯朴素贝叶斯多项式朴素贝叶斯逻辑回归树模型参数量2KDKDD1随深度增长训练复杂度O(ND)一次遍历O(ND)需迭代优化需分裂搜索小样本表现好好一般差特征相关性无法处理无法处理可处理可处理输出概率刻度偏极端需校准偏极端较准需校准增量更新天然支持天然支持需重训需重训平滑系数是第二个取舍点而且它同时干了两件事alpha 调大先验更平、条件概率更平模型更保守方差更小偏差更大alpha 调小则相反。文本任务里这个参数常常比特征工程影响还大值得单独用交叉验证扫一遍。第三个取舍是「要不要输出概率」。只要标签扔掉分母、只比大小最快最稳要拿分数做阈值、做人工复核、做多模型融合就必须做 log-sum-exp 归一化还得考虑高斯假设下概率刻度天然偏极端它总觉得数据离均值不远必要时用保序回归或者 Platt 缩放校准一遍。从那以后我每次跑朴素贝叶斯之前都会先做三件事看一眼特征相关矩阵把方差下限和先验收敛的断言写死把预测分数转成后验概率再定阈值。这三步加起来不到十分钟挡掉的坑比调参省下的时间多得多。希望帮到你。本文还有配套的精品资源点击获取