一个做分类需求的朋友跟我聊起过数据就几千条特征全是连续数值业务方只问这个东西到底该归哪一类。他自己用逻辑回归试了一圈效果一般又觉得上树模型有点杀鸡用牛刀。我问他试没试过朴素贝叶斯他想了一会儿说感觉那个东西不是解决垃圾邮件或者文本问题的吗其实不是。朴素贝叶斯家族里有个针对连续特征的分支叫高斯朴素贝叶斯计算量小、速度快、解释起来也直白在这种中小规模表格数据上往往能给出一个非常稳的基线结果。今天就把它的原理和Python实现一条线讲透手写核心代码给你看再用现成库实现一遍最后聊几个实际训练时容易踩的坑。1. 朴素贝叶斯到底在算什么1.1 一个具体场景用花瓣特征猜花的品种先说一个最直白的场景你手里有一批花的数据每个样本记录了花萼长度、花萼宽度、花瓣长度、花瓣宽度这4个连续数值标签是三类不同的花。现在的任务是来一朵新花知道了它的4个数值猜它属于哪一类。这个问题放到概率视角下就是要求一个条件概率给定特征x的时候样本属于类别k的概率有多大记作P(类别k | 特征x)。我们想要的是在所有类别里挑出那个概率最大的类别。这里有个很自然的工具就是贝叶斯定理。它把反向的条件概率用正向的概率表达出来P(Yk|Xx) P(Xx|Yk) * P(Yk) / P(Xx)这个式子里的P(Yk)叫先验概率意思是在看到任何特征之前某个类别本身出现的比例比如三类花各占三分之一那先验就是0.33。P(Xx|Yk)叫似然意思是假设已知这朵花属于类别k那么它呈现出这个特征组合的概率是多少。P(Xx)是证据也就是这个特征组合在所有类别下出现的总体概率。在实际做分类时我们并不需要精确计算P(Xx)因为对不同类别来说分母是一样的。比较P(Yk|Xx)的大小等价于比较P(Xx|Yk) * P(Yk)的大小。这个忽略分母的小技巧让贝叶斯分类器实现起来非常清爽。1.2 贝叶斯公式的直觉拆解很多人觉得贝叶斯定理绕其实换个生活化的说法就通了。我举个看病的类比不完全严谨但方便理解某种病在人群中的发病率是1%这就是先验。如果一个人确实有病某项检测呈阳性的概率是90%这是似然。现在一个人检测呈阳性他真正有病的概率不是90%还要除以总人群中检测阳性的比例因为健康人也有假阳性。这个除法就是为了修正检测阳性本身的基础频率。先验、似然、证据三者的关系就体现在这里。回到花分类的问题上。我们手里有带标签的训练集可以从数据里直接估计出来先验P(Yk)数一数标签为k的样本占总样本的比例。似然P(Xx|Yk)这个是最难直接估计的因为特征组合的可能情况太多。如果特征都是离散的比如颜色是红的、形状是圆的那可以用频率去数。但现在特征是连续的实数理论上花瓣长度可以是2.1、2.15、2.152……无限种可能用频率方式没法数。于是高斯朴素贝叶斯的做法就出来了既然无法直接统计连续特征的精确概率干脆假设每一个类别下、每一个特征都服从一个高斯分布正态分布然后用训练数据去估计这个高斯分布的两个参数均值和方差。有了均值和方差就能算出任意一个具体数值在该分布下出现的概率密度。2. 高斯朴素贝叶斯的原理与关键假设2.1 连续特征为什么套高斯分布高斯分布的概率密度函数长这样f(x) 1 / (sqrt(2 * pi * sigma^2)) * exp(-(x - mu)^2 / (2 * sigma^2))其中mu是均值sigma^2是方差。这个公式的含义是给定一组均值和方差就能知道某个数值x落在不同区间内的概率密度。比如一类花的花瓣长度均值是3.5、方差是0.4那么花瓣长度等于3.5附近的密度最大离3.5越远密度越小。选择高斯分布不是因为它是唯一选择而是因为它在很多实际问题里够用、也够简单。自然界和工程数据里大量连续量本身就接近正态比如尺寸测量、生物特征、传感器读数。就算数据稍微有点偏态高斯分布也能抓住均值位置和波动范围对分类来说往往就够用了。这里有个非常关键的细节要说清楚概率密度函数计算出来的是密度值不是概率值。密度值可以大于1也可以很小。但我们在分类时只是拿不同类别的密度值乘以后边的先验做相对比较所以密度的绝对值是多大不重要重要的是在同一个特征维度上不同类别的密度值相对大小。2.2 训练阶段到底要学哪些参数确定了用高斯分布来拟合每个类别下每个特征那训练阶段要干的事情就非常明确了遍历每一个类别在属于该类别的样本上计算每个特征的均值和方差。具体来说假设有C个类别每个样本有D个特征那训练结束后会得到一张参数表先验数组长度为C记录了每个类别的样本占比。均值矩阵形状为C * D记录了每个类别在每个特征维度上的均值。方差矩阵形状为C * D记录了每个类别在每个特征维度上的方差。这些参数就是模型的全部知识了。保存模型本质上就是保存这三个东西。新生成了一个样本要做的事情是逐个类别地计算这个样本特征在属于该类别的条件下有多大的密度把每个特征的密度乘起来再乘上该类别的先验最后比较不同类别的结果大小。这里就体现出朴素贝叶斯的一个优点训练快预测也快。训练阶段只需要做均值和方差的统计不需要像梯度下降类模型那样反复迭代预测阶段的每个矩阵运算都可以用向量化完成。在特征维度上千、样本量几十万的问题上高斯朴素贝叶斯也能在毫秒级完成预测这是很多复杂模型比不了的。2.3 朴素假设明明不成立为什么还能用朴素这个词指的是一个很强的假设在给定类别k的条件下各个特征之间是相互独立的。也就是说花瓣长度和花瓣宽度在知道类别之后互相之间没有关联。这个假设在大多数真实数据里都不成立——花瓣长的花花瓣往往也宽这种正相关是客观存在的。但有趣的是即使独立性假设不成立朴素贝叶斯在很多任务里的分类效果依然不错。原因在于分类任务关心的是后验概率的相对大小或者说最大值出自哪个类别而不是后验概率的绝对数值。这个特性在机器学习里有个专门的说法即使概率估计有偏只要各类别之间的排序没有被破坏argmax的结果还是对的。特征之间的相关性会被重复计入但如果每个类别的特征相关性结构差不多这种重复计数对所有类别一视同仁最后比大小的时候误差就互相抵消了。当然如果特征之间存在极度强烈的相关性比如一个特征是另一个特征的精确线性变换那重复计数就可能过头导致某个类别的分数被不合理地放大。这时候普通朴素贝叶斯会出问题可以考虑用TAN贝叶斯或者树增强结构的变体也可以在预处理阶段做去相关处理比如PCA之后再用高斯朴素贝叶斯。我在后面第4部分会详细讲这个坑。3. Python完整实现从手写核心到sklearn一页收工3.1 手写一个可运行的SimpleGaussianNB先说结论手写高斯朴素贝叶斯的核心逻辑非常短只需要fit和predict两个方法。fit负责算参数predict负责把新样本往高斯密度公式里带。import numpy as np class SimpleGaussianNB: def __init__(self): self.classes None self.priors {} self.means {} self.vars {} def fit(self, X, y): self.classes np.unique(y) n_samples X.shape[0] for c in self.classes: X_c X[y c] self.priors[c] len(X_c) / n_samples self.means[c] np.mean(X_c, axis0) self.vars[c] np.var(X_c, axis0) 1e-6 return self def _pdf(self, x, mean, var): eps 1e-6 coeff 1.0 / np.sqrt(2 * np.pi * var eps) exp_part np.exp(-((x - mean) ** 2) / (2 * var eps)) return coeff * exp_part def predict(self, X): y_pred [] for sample in X: scores {} for c in self.classes: # 用log计算避免小概率连乘导致下溢 log_score np.log(self.priors[c]) log_score np.sum(np.log(self._pdf(sample, self.means[c], self.vars[c]))) scores[c] log_score y_pred.append(max(scores, keyscores.get)) return np.array(y_pred)这里有几个细节我在写的时候是刻意考虑的新手很容易忽略方差加了1e-6的平滑项避免某个类别里某个特征的方差为0导致除以零或者exp计算时出现无穷大。这个在真实数据里很常见比如某个类别所有样本的某个特征值完全一样。预测时全程用log计算分数而不是直接用概率连乘。原因很简单特征一多每个概率密度如果是0.1量级连乘50个就变成1e-50浮点数会下溢为0完全没法比较。取对数之后连乘变成连加数值范围就温和多了。_pdf函数里也加了eps是为了防止方差取到极端小值时指数部分除以一个接近0的数爆发成很大的数。3.2 在数据集上跑一遍完整流程写完了类找个公开数据集验证一下。这里用经典的鸢尾花数据它正好是4个连续特征、3个类别跟高斯朴素贝叶斯的适配度很高。from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score, classification_report data load_iris() X data.data y data.target X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) model SimpleGaussianNB() model.fit(X_train, y_train) y_pred model.predict(X_test) print(Accuracy:, accuracy_score(y_test, y_pred)) print(classification_report(y_test, y_pred))跑完以后准确率一般在0.95左右三类花的分类报告也都比较漂亮。这个结果能在训练集只有105条样本的情况下拿到说实话已经相当能打了。作为对比如果用逻辑回归在这个数据上跑通常也能拿到类似的分数但逻辑回归需要迭代优化、标准化特征实现成本明显更高。数据集本身特征数量少、类别可分性强所以结果好看。真正业务数据如果也有相似的特性——特征不多、每个类别下特征近似正态、类别间分布有差异——那这个手写模型迁移过去效果大概率也不会差。3.3 sklearn实现生产环境直接用轮子手写一遍是为了搞清楚原理但实际项目里直接用scikit-learn里的GaussianNB就够了。它的实现做了大量优化底层是向量化运算速度比自己写for循环快很多而且在处理方差为0、概率下溢这些问题上都有成熟的内部处理。from sklearn.naive_bayes import GaussianNB from sklearn.model_selection import cross_val_score sk_model GaussianNB() sk_model.fit(X_train, y_train) print(sklearn Accuracy:, sk_model.score(X_test, y_test)) # 交叉验证更靠谱 scores cross_val_score(GaussianNB(), X, y, cv5) print(CV Accuracy:, scores.mean(), -, scores.std())需要注意的一点是GaussianNB默认方差的平滑方式和我手写的略有不同。它默认使用类内样本数加某个系数来估计方差并且预测时也是用对数后验。这些内部细节不用太操心但你要清楚sklearn的GaussianNB没有可调的复杂超参数它唯一的自由度就是通过var_smoothing控制方差平滑程度。默认值是1e-9如果遇到概率输出特别极端的情况可以适当调大这个值试一下。我还习惯用交叉验证来替代单次划分因为单次划分容易受随机种子影响。在鸢尾花这种小数据集上尤其明显不同随机种子跑出来的准确率能差个四五个百分点。交叉验证能给出更稳定的估计也方便在不同模型之间横向比较。4. 训练和排查中的真实坑位4.1 概率下溢连续乘小数的致命问题这个坑在理论上已经讲过但在实际项目里还是会反复出现。尤其当样本的特征维度上千每个特征的密度值都在0.01到0.5这个区间一千个连乘下来数量级直接掉到1e-300以下Python的float64已经扛不住结果直接归零。一旦某个类别的连乘结果变成0再取对数就是负无穷比较大小就直接失灵了。这也是为什么手写代码里一开始就用对数分数。如果你在调自己写的模型时发现预测结果里有一堆nan或者全是同一个类别先检查是不是没用对数化。顺带提一个相关的问题log里如果传入了0会报错所以预测时使用的密度值必须严格大于0。即使某个样本在这个类别下极度不可能出现密度的理论值也是正的但现实中浮点数可能计算出来是0。解决方案就在方差上加一个足够小的平滑项。我在SimpleGaussianNB里用的1e-6就是干这个用的别看它小关键时刻能救命。4.2 方差为0与数值稳定性方差为0的情况比很多人想的要常见。特征本身是个常数、或者某一个类别下样本数极少且特征值恰好都一样都会导致方差为0。如果这个时候不做处理sqrt(2 * pi * 0)是01 / 0就出问题了。除了在方差上加平滑项还有一个思路是全局性地设置方差下限。比如在sklearn里var_smoothing就是干这个的。它会把所有特征估计出来的方差统一加上一个数值保证全局稳定。如果你自己写代码也可以类似地在fit结束之后把所有方差np.maximum(vars, min_var)强行截断到某个下限。另一个经验是如果某个特征的方差特别小说明这个特征在类别内几乎不起区分作用甚至可能是个噪音维度。这种情况下去掉它有时候反而能把模型稳住。数据清洗阶段的这一步价值不亚于在算法层面调参数。4.3 特征相关性、非高斯分布和非校准概率这三个问题虽然发生频率不一样但处理起来各有讲究。特征强相关性是朴素贝叶斯最被诟病的地方。我之前处理过一个任务两个传感器在物理上本来就是测同一个量输出几乎线性相关。高斯朴素贝叶斯把它们当独立特征处理等于是把同一个信息拿了两遍某个类别在预测时分数被明显抬高结果其他类别的正确率被压下去了。后来我用主成分分析把这两个维度合成一个模型效果立刻恢复正常。遇到特征明显冗余的时候先做去相关再喂给朴素贝叶斯是个性价比很高的组合。非高斯分布数据的问题就更隐蔽了。很多业务数据其实是偏态的比如点击量、消费金额常见的是长尾分布。直接拿原始值套高斯分布均值会被尾巴拖着走方差也会偏大。我的经验是先做变换对数变换或者Box-Cox变换把数据往正态方向拉一把再进模型。还有一类场景需要注意特征如果本身是计数型整数最好先想清楚离散分布是否更合适。非校准概率是很多人没注意到的问题。高斯朴素贝叶斯输出的概率本质上不是严格校准的条件概率而是基于分布假设的密度比较结果。它用来排序、用来argmax选类别没问题但如果你打算把它输出的0.8当作80%置信度给业务方做风控决策那就要小心了。带上概率需求时我会在GaussianNB外面套一层sigmoid校准或者CalibratedClassifierCV把输出重新校准一遍再用。4.4 模型评估别只用准确率只用准确率评估分类模型是个新手容易踩的坑。假如三类花的比例是95比2.5比2.5那你全预测成第一类也能有95%的准确率但这个模型毫无价值。这种情况在真实业务里太常见了——某些类别天然稀少比如故障样本、异常订单占比可能只有百分之一。对这种类别不平衡的数据正确的评估方式应该是看混淆矩阵算每个类别的精确率、召回率和F1值。如果业务更关心少数类能不能被找出来那就重点盯召回率。如果想要一个综合性的分数来做模型对比可以看宏平均F1或者加权F1。还有一点是关于预测概率的评估。如果下游流程依赖排序或阈值可以用log_loss来看整个概率输出的质量。它既能反映排序是否正确也能反映概率值是否足够自信。相比之下准确率只是一个选对了没有的0/1统计信息量有限。最后说几句经验之谈跑了这些年数据我的体会是朴素贝叶斯不是那种能包打天下的模型但在三类场景下我会毫不犹豫地选它。第一是数据量不大、特征不多需要一个快速基线的时候它能在几分钟内给出一个不丢人的结果第二是特征维度很高但独立性尚可的文本类问题它依然是性价比之王第三是线上推理延迟要求非常严格的风控和推荐场景它的预测速度是很多复杂模型给不了的。手写一遍代码再换成轮子整个过程用不了两小时但对模型原理的理解深度完全不一样。建议拿到数据后先别急着上大模型花半小时跑一个高斯朴素贝叶斯你会对数据的可分离性有个非常直观的认知。
阅读完成 · 觉得有帮助?