先说个我自己的经历。有一次给某电商售后工单做自动打标样本不多特征又是客服处理时长、订单金额这类连续数值。一开始我图省事直接上了逻辑回归结果调了半天特征缩放效果也就那样。后来我把思路换回最基础的模型——高斯朴素贝叶斯分类五十行numpy就把训练和预测跑通了效果还比逻辑回归略好。那之后就养成一个习惯新手也好、老手也罢拿到一份连续特征分类数据先花五分钟跑一个高斯朴素贝叶斯基线比什么都管用。这篇文章把高斯朴素贝叶斯的原理、数学准备、Python实现、以及和scikit-learn的对比都拆开讲最后还会聊一聊我在真实项目里踩过的坑。适合刚学机器学习、看到“贝叶斯”三个字就头疼的同学也适合想彻底弄懂模型内部逻辑、以后遇到问题能自己写算法的工程师。1. 高斯朴素贝叶斯到底在算什么从贝叶斯定理到生成式模型1.1 贝叶斯定理的直觉把“结果”反推成“原因”很多教程一上来就抛公式把贝叶斯定理讲得跟天书一样。其实它回答的问题特别朴素已知我观察到了某些特征那么样本属于某个类别的概率有多大公式长这样P(Y|X) P(X|Y) * P(Y) / P(X)拆开看就四块P(Y|X)是后验概率也就是我们想要的“看到特征X之后属于类别Y的概率”P(X|Y)是似然先假设样本属于Y再看出现这种特征组合的概率P(Y)是先验指在没有任何证据时类别Y本身占的比例P(X)是证据因子可以理解成所有类别下出现X的总概率。我平时喜欢的类比是体检某个检测指标呈阳性不代表一定生病因为健康人里也可能出现阳性病人里也可能出现阴性。贝叶斯定理做的就是把“检测阳性”当作证据结合“人群中患病率”这个先验反推出真正患病的概率。分类任务里的“疾病”就是类别“检测指标”就是特征向量。这里有个关键点做分类的时候P(X)对每个类别都是一样的。也就是说分母不影响不同类别之间的概率大小比较。所以我们只需要比较P(X|Y) * P(Y)就能决定预测结果。这也是整个朴素贝叶斯能实现得极其轻量的原因。1.2 “朴素”到底朴素在哪特征独立的强假设“朴素贝叶斯”里的“朴素”指的不是算法简单而是它做了一个非常强的假设特征之间条件独立。意思是说给定类别Y之后x1出现不会影响x2的出现x2也不会影响x3。数学上可以写成P(x1, x2, ..., xn | Y) P(x1|Y) * P(x2|Y) * ... * P(xn|Y)这个假设的好处是革命性的。如果特征不独立要建模完整的联合分布参数数量是特征维数的指数级一旦做了独立假设每个特征只需要单独建模参数数量直接降到线性。样本少、维数高的时候复杂模型很容易过拟合而朴素贝叶斯正因为“单纯”在数据量有限的情况下反而稳得住。现实中特征之间很难完全独立。程序员穿搭判断就是个很好的例子格子衬衫、双肩包、黑框眼镜这三件事实际上并不独立——穿格子衬衫的程序员大概率也背双肩包。但朴素贝叶斯不管你这些它把每一条证据单独拿出来打分最后乘起来排序。多数时候这种简化不会影响“谁排在前面”反而因为忽略了噪声关联让排序更鲁棒。1.3 连续特征为什么选高斯分布前面说的都是离散特征的情况。当特征是连续值比如“订单金额”是25.6元还是102.3元我们不能简单地统计概率需要一个概率密度函数来刻画它在每个区间的密集程度。高斯分布是个默认选择原因有三第一很多自然测量值本身就接近高斯分布。身高、血压、响应时间、图像的像素噪声这类数据的分布通常在均值附近最密集向两侧对称衰减。用高斯分布去拟合大部分情况下不会离谱。第二高斯分布只有两个参数均值和方差。均值描述数据集中在哪方差描述数据散得多开。估计这两个参数只需要遍历一次数据做累加训练开销极小。第三中心极限定理从理论上给了它合法性很多独立随机因素叠加的结果会趋向正态分布。工程上大量特征确实是多个微观因素累积出来的。需要提醒的是高斯分布给出来的数值是概率密度不是概率。密度值是可以大于1的比如方差很小时峰值会很高。好在分类时我们只关心不同类别之间谁大谁小密度的绝对值没有意义所以没问题。2. 手写实现前的数学准备对数似然、先验估计与方差平滑2.1 极大似然估计均值、方差、先验都是算出来的高斯朴素贝叶斯的训练过程本质上是为每个类别估计一组参数。假设总共有C个类别每个类别c下有Nc个训练样本每个样本有d个特征那么在训练阶段要算三组数先验概率P(c) Nc / N就是类别c在总样本N中的占比每个类别下每个特征的均值μ_ci (1/Nc) * Σ x_i即在类别c内第i个特征的平均值每个类别下每个特征的方差σ²_ci (1/Nc) * Σ (x_i - μ_ci)²即类别c内第i个特征的离散程度。这里有个统计学上的小细节我们用的是总体方差公式分母是Nc不是样本无偏估计用的Nc-1。很多人在别处习惯了无偏方差到了这里会纠结。其实不用纠结朴素贝叶斯关心的是后验概率的相对大小用总体方差在分类效果上更匹配极大似然的框架scikit-learn内部也是这么做的。2.2 下溢问题浮点数连乘的风险有了均值和方差预测的时候就要计算每个类别下所有特征的概率密度。如果按公式直着乘会出问题P(x1|Y) * P(x2|Y) * ... * P(xd|Y)每个概率密度通常在0到1之间当特征维度d达到几十甚至几百时连乘的结果会小到超出浮点数能表示的精度范围。两个类别本来只是相差一点几倍连乘之后可能一个变成1e-200另一个直接变成0.0比较就失真了。解决办法是取对数。把乘法转换成加法log P(Y|X) ∝ log P(Y) Σ log P(xi|Y)这个变换有几个额外好处对数函数是单调递增的所以原来谁大谁小取完对数依然谁大谁小加法运算又快又稳定而且我们只需要比大小连归一化分母都可以省掉。这就是为什么很多机器学习的底层代码里面到处都是log、logsumexp这类操作。2.3 方差为零的风险与平滑训练数据往往不完美。最典型的场景是某个类别里只有一个样本或者某个特征在这个类别内所有取值完全一样。这时候方差算出来是0。方差为0意味着高斯分布变成了一根无限细的针。代入概率密度公式分母变成0对数似然直接变成正的无穷大。一个类别的某一个特征值为0就能让这个类别在所有候选里胜出其他特征的证据全部失效。这在业务上是灾难性的。所以实现高斯朴素贝叶斯时几乎都会在方差上加一个极小的正数叫方差平滑。scikit-learn里这个参数是var_smoothing默认取1e-9。加这么小的数对正常特征几乎没有影响但能避免分母为0。实际使用中它还可以调大后面我会详细说什么时候该调。3. 从零实现高斯朴素贝叶斯训练、预测与完整代码拆解3.1 类结构设计两个方法搞定一个模型写这个模型不需要任何框架numpy就够了。我习惯把整个模型封装成一个类对外只暴露两个方法fit用来训练predict用来预测。额外的predict_proba方法用来输出概率方便分析置信度。类的核心状态就三样类别列表、每个类别的均值字典、每个类别的方差字典再加上一个先验字典。训练过程可以总结成先按类别把样本分组然后在每个分组里用向量化运算一次算出所有特征的均值和方差。代码里要注意一个细节X[y c]这种布尔索引在numpy里是向量化操作比for循环遍历样本高效得多。类别数量一般不会太多所以外层循环类别是没问题的内层的特征运算交给numpy的广播机制。3.2 预测阶段怎么把数学式子变成向量化代码预测时我们需要对每个样本、每个类别计算“对数先验 所有特征的对数似然之和”。高斯对数概率密度的公式可以写成-0.5 * log(2πσ²) - (x - μ)² / (2σ²)这句话展开来解释就是三部分第一部分是个只依赖方差的常数项方差越大这个惩罚越小第二部分是标准化后的欧氏距离距离均值越远的点该项负得越多。特征之间独立所以所有特征的对数似然直接相加。实现时要注意广播机制X的形状是(n_samples, d)mean的形状是(d,)两者相减后得到(n_samples, d)的差值矩阵平方、除以方差、按行求和一行代码就完成了所有样本、所有特征的对数似然计算。3.3 完整代码与合成数据的正确性验证下面是一个可直接运行的完整实现。我刻意控制代码行数每个方法只做一件事import numpy as np class GaussianNB: def __init__(self, var_smoothing1e-9): self.var_smoothing var_smoothing self.classes_ None self.means_ {} self.variances_ {} self.priors_ {} def fit(self, X, y): X np.asarray(X) y np.asarray(y) self.classes_ np.unique(y) n_samples X.shape[0] for c in self.classes_: X_c X[y c] self.means_[c] X_c.mean(axis0) self.variances_[c] X_c.var(axis0) self.var_smoothing self.priors_[c] X_c.shape[0] / n_samples return self def _log_likelihood(self, X, mean, var): return -0.5 * np.log(2 * np.pi * var) - (X - mean) ** 2 / (2 * var) def _joint_log_likelihood(self, X): X np.asarray(X) log_parts [] for c in self.classes_: log_likelihood_sum self._log_likelihood( X, self.means_[c], self.variances_[c] ).sum(axis1) log_parts.append(np.log(self.priors_[c]) log_likelihood_sum) return np.array(log_parts).T def predict_log_proba(self, X): return self._joint_log_likelihood(X) def predict_proba(self, X): log_proba self._joint_log_likelihood(X) log_proba - log_proba.max(axis1, keepdimsTrue) proba np.exp(log_proba) return proba / proba.sum(axis1, keepdimsTrue) def predict(self, X): log_proba self._joint_log_likelihood(X) return self.classes_[np.argmax(log_proba, axis1)]用合成数据验证一下逻辑是否正确。我构造两类二维高斯数据一类均值在原点一类均值在(2, 2)如果模型能把这两簇数据基本分开就说明训练和预测链路是通的np.random.seed(0) X np.vstack([ np.random.normal(loc0.0, scale1.0, size(200, 2)), np.random.normal(loc2.5, scale1.2, size(200, 2)), ]) y np.hstack([np.zeros(200), np.ones(200)]).astype(int) nb GaussianNB() nb.fit(X, y) pred nb.predict(X) print(训练集准确率:, (pred y).mean())我实际跑下来准确率在0.95左右。你可以看到整个模型训练过程只是算了几组均值和方差预测过程只是做了加减乘除和log没有迭代、没有梯度、没有学习率。这正是朴素贝叶斯最吸引人的地方简单、透明、稳定。4. 与scikit-learn对比验证数值差异、决策边界与概率解读4.1 对比配置同一份数据、同一个超参数自己写的模型不跟成熟库对比是不放心的。我用scikit-learn内置的鸢尾花数据集做对比实验鸢尾花有150个样本、4个连续特征、3个类别正是高斯朴素贝叶斯的主场。对比代码很简单from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.naive_bayes import GaussianNB as SkGNB data load_iris() X_train, X_test, y_train, y_test train_test_split( data.data, data.target, test_size0.2, random_state42) m1 GaussianNB(var_smoothing1e-9).fit(X_train, y_train) m2 SkGNB(var_smoothing1e-9).fit(X_train, y_train) acc1 (m1.predict(X_test) y_test).mean() acc2 (m2.predict(X_test) y_test).mean() same (m1.predict(X_test) m2.predict(X_test)).all() print(手写版本准确率:, acc1) print(sklearn版本准确率:, acc2) print(预测结果完全一致:, same)我跑下来的结果是两边准确率完全相同测试集上预测结果一致率是100%。这里的前提是var_smoothing设置成一样如果你用默认值那两边默认值相同结果也会一样。4.2 数值差异手写和官方实现差在哪既然逻辑一致为什么还要看数值差异因为sklearn并不是简单照着公式一行行抄的。官方实现里有一个小技巧它对每个特征提前算好方差相关的常数项并且在预测阶段用预先分配的numpy数组避免重复创建对象。当你打印两边的predict_proba时可能发现小数点后第15位左右有极细微的差别这是浮点数运算顺序导致的不影响分类结果。真正值得关注的是二者的预测概率是否符合直觉。朴素贝叶斯有一个众所周知的特点当特征强相关时它给出的概率会过度自信经常出现0.9999这种过于极端的值。这个现象不是你代码写错了而是模型假设和现实不符的结果。后面第5章我会专门讲。4.3 决策边界与概率输出为什么边界是曲线如果我们只取前两个特征把高斯朴素贝叶斯的决策边界画出来看会发现它不像线性模型那样是一条直线而是一条曲线。原因在于每个类别都有自己的方差。边界上两个类别的对数后验相等展开公式里面有(x-μ)²/σ²这一项方差不同意味着二次项的系数不同所以边界是二次曲线。反过来如果所有类别的方差恰好相同二次项系数抵消边界就会退化成直线这其实就是线性判别分析LDA的思路。换句话说高斯朴素贝叶斯本质上假设每个类别的数据服从一个轴对齐的高斯分布每个类别有自己的中心和宽度。预测时就是在问新样本离哪个高斯中心更近并且综合考虑该类别的先验占比。这个几何直觉比干看公式有用得多很多时候我能直接通过散点图预判GNB会不会分类失败靠的就是这个。5. 实战中的坑方差退化、特征相关与分布假设失效5.1 类别内样本太少导致方差为0模型越简单对数据的“不干净”越敏感。高斯朴素贝叶斯最典型的坑就是类别内方差为0。真实业务里这种例子太多了。比如某设备故障分类项目“正常”类有上万条样本而“某种罕见故障”只有3条。碰巧这3条样本在某一个特征上的取值完全相同比如温度传感器读数都是78.5。那么这一个特征在这个类别下方差就是0加上var_smoothing之后虽然不会无穷大但它的对数似然会变得极大把其他所有类别的证据都淹没掉。我在项目里见过不止一次模型输出一个“铁板钉钉”的错误预测查到最后就是某个一维特征在稀有类别里取值恒定的问题。处理办法有几个把var_smoothing调大比如1e-6甚至1e-4具体数值用交叉验证搜索在拟合时做一个全局保护对每个特征的方差取max(局部方差, 全局最大方差 * 1e-3)避免单类方差过小更根本的是检查稀有类别里哪些特征没有区分度考虑直接删掉。5.2 特征高度相关时的“双重计数”问题前面讲了朴素贝叶斯的独立假设。一旦特征之间高度相关这个假设的代价就暴露出来了重复的信息被当成独立证据连续乘了好多次。举个典型例子。假设有个特征叫“订单是否超时”另一个特征叫“订单投诉是否成立”。这两个特征在真实业务里往往强相关但GNB不care它会把两条证据分别乘起来。于是支持类别的证据被重复放大后验概率变得极度过分——准确率还没崩但预测概率已经失真。这个现象在模型评估里有个隐蔽的危害如果你用预测概率做阈值筛选比如只保留概率大于0.9的样本进入人工复审你会发现自己挑出来的样本里掺了大量噪声。处理思路是先做特征去冗余。计算特征之间的相关性矩阵对相关系数绝对值超过0.9的特征只保留一个或者用PCA把相关特征先投影成不相关的维度。如果这些都不想做至少要知道GNB的概率输出不能当“真实概率”来用只能用来排序。5.3 偏态分布与异常值对均值方差的破坏高斯分布的先决条件是数据对称、单峰。但真实业务的连续特征比如收入、响应延迟、点击量几乎都是长尾右偏的。长尾数据一旦直接用高斯拟合均值会被极少数大值拉偏方差会被拉得很大。结果就是高斯曲线又宽又扁峰值位置偏离大多数样本的密集区分类边界跟着整体漂移。更麻烦的是异常值。一个样本的某个特征写错了从10变成10000这一个点的贡献就能让均值和方差面目全非。我自己踩过这个坑之后现在拿到数据会先做一件事按类别拆分对每个特征画直方图肉眼扫一遍分布形态。凡是不对称的长尾特征先做log1p变换或者Box-Cox变换再进模型。效果往往立竿见影。如果变换之后分布还是不像正态那我就不会强行用高斯朴素贝叶斯宁可换成逻辑回归或决策树也别让分布假设把整条流水线带偏。6. 什么时候该用它高斯朴素贝叶斯的定位与选型6.1 它擅长和不擅长的业务场景用了这么多年模型我越来越觉得选模型不是找“最强大的”而是找“当前数据条件下最合适的”。高斯朴素贝叶斯适合这样几类场景中小规模表格数据特征几十到几百样本几百到几万训练时间要求苛刻用它做第一版基线非常划算类别先验差异明显、类内特征分布大致对称的场景它的后验排序往往很可用需要在线学习的流式场景因为均值、方差、计数都可以增量累加来了新数据单独更新即可要跟业务方解释模型逻辑的场景GNB的参数就是“每类每个特征的均值和方差”一句话就能讲明白。它不适合的场景同样明确特征数量上万且高度稀疏时更适合多项式朴素贝叶斯特征强耦合、依赖非线性交互关系时它打不过集成模型需要精确概率校准的场景它的输出太极端。6.2 与其他常见模型的横向对比做一个简单对比方便你建立直觉。模型核心假设训练成本概率输出适用场景高斯朴素贝叶斯特征条件独立 高斯分布极低单遍扫描偏极端需谨慎基线、小样本、流式更新逻辑回归线性决策边界低需迭代优化校准较好中高维表格、需要概率线性SVM最大间隔线性边界低到中无可直接解释概率高维但需特征缩放决策树分段常数边界低到中频次估计表格数据、非线性交互逻辑回归比GNB多了一个优势它会为每个特征学一个权重特征之间的冗余被优化过程自动压制。但它需要迭代训练对特征缩放敏感小样本下不如GNB稳。SVM是一把双刃剑核技巧能拟合非线性但解释性差训练随着样本量上升呈超线性增长。决策树不用分布假设但容易过拟合需要剪枝或集成。如果GNB在你那块数据上连80%准确率都做不到我会先检查特征本身是不是有问题而不是急着换SVM。模型提升的空间通常没有特征工程提升的空间大。6.3 工程化使用建议与增量学习的扩展最后分享几个工程层面的经验都是直接能落地的。第一var_smoothing值得专门调。默认值1e-9在很多教科书式数据上够用但真实数据方差分布跨度很大。我一般会用GridSearchCV在np.logspace(-9, -3, 7)这个范围里搜索虽然GNB本身很快搜索也不费时间。第二评估时别只看一次holdout的准确率。GNB的决策边界变化平缓单次随机划分可能让结果忽高忽低。用五折或十折交叉验证看每折的准确率标准差波动大就说明数据本身不稳定。第三特征标准化对GNB不是必须的。高斯分布对尺度不敏感缩放前后均值和方差会成比例变化模型结果等价。但如果特征量纲差异悬殊比如一个特征范围0到1、另一个是0到100000建议至少做一下缩放因为概率密度的常数项在极端方差下会引入数值压力。第四增量更新可以直接加一段代码。拟合阶段保存所有类别的样本计数、特征求和、特征平方和新样本到来时count_new count_old batch_count mean_new (mean_old * count_old batch_sum) / count_new var_new (sum_squares_old batch_square_sum) / count_new - mean_new ** 2这样就不需要重训整个模型。在流量高、模型需要频繁更新的场景里这是个很实用的能力。我个人的习惯是把GNB当成每个项目的第一块敲门砖。先用它把数据质量验一遍看特征里有没有垃圾、有没有需要变换的偏态分布再决定下一步上什么模型。这个习惯帮我挡掉了不少盲目调参的弯路。如果你手头正有一份连续特征分类数据不妨现在就用上面的代码跑一版你会发现在模型选型之前先把数据长什么样子搞清楚比什么技巧都值钱。
阅读完成 · 觉得有帮助?