☰
高斯朴素贝叶斯分类原理与工程避坑:先验、对数域和交叉验证
2026/11/4 3:05:02 网站建设 项目流程

简介:《基于贝叶斯定理的糖尿病检测系统的设计与实现》面向模式识别、机器学习课程学习者及需要完成分类项目实践的学生,围绕朴素贝叶斯在高斯分布假设下的糖尿病预测展开。文档系统梳理贝叶斯分类原理、Pima Indians糖尿病数据集说明、训练集与测试集拆分、均值方差和标准差计算、特征条件概率累乘、类别判定及准确率评估,并给出loadCsv、SplitDataset、separateByClass、summarizeByClass、calculateProbability等关键函数的实现思路与程序流程图。压缩包仅含1个PDF文件,大小约771KB,便于直接阅读并整理为课程报告或实验笔记。已有237人学习,适合希望理解贝叶斯分类完整流程、对照代码实现与图表分析、快速搭建同类检测系统的读者参考。

1. 高斯朴素贝叶斯:为什么“连乘”出来的那个数根本不是概率

一个基于贝叶斯定理的糖尿病检测系统,跑完整个流程最后只输出一个数字:Accuracy。可真正决定分类结果的那个中间值,既不是概率,也不落在 0 到 1 之间——它是九项指标的高斯密度连乘出来的一个极小量,量级低到 1e-25 也不稀奇。很多人第一次读到朴素贝叶斯的实现都会卡一下:教科书上的贝叶斯公式明明有分母 P(x),代码里从头到尾没算过它;公式里明明有先验 P(C),代码里也没乘。这两处“缺斤少两”不是偷懒,而是分类任务本身只关心比值大小。搞懂这件事的价值在于:一旦你知道自己在比较的是一个未归一化的分子分数,就能立刻明白这个模型为什么对特征方差、对量纲、对生理指标里那些伪装成数值的 0 格外敏感,也才能解释同一份代码跑两次、准确率为什么能差好几个百分点。

2. 贝叶斯公式落到代码里:被丢掉的分母和没乘上的先验

2.1 只比分子就够了:P(x) 为什么可以不看

贝叶斯定理在分类任务里的标准写法是 P(C|x) = P(x|C)·P(C) / P(x)。要判定一条样本属于发病组还是非发病组,就是分别算出两个类别的 P(C|x),谁大取谁。

关键在于分母 P(x):它是“这条样本出现的概率”,对发病组和非发病组是同一个数。两个分数比大小时乘同一个正数不改变大小关系,所以分母可以直接扔掉。真正参与比较的是 P(x|C)·P(C),也就是“该类别下出现这组指标的可能性”乘上“该类别本身的占比”。常见的实现思路是把这两部分统称为“类别分数”,它不满足全概率归一,但 argmax 结果与真实后验完全一致。

问题出在后半截——先验 P(C)。看这份实现里的概率累乘:

def calculateClassProbabilities(summaries, inputVector): probabilities = {} for classValue, classSummaries in summaries.items(): probabilities[classValue] = 1 # 从 1 开始,只乘似然 for i in range(len(classSummaries)): mean, stdev = classSummaries[i] x = inputVector[i] # 只累乘 P(x_i|C),没有乘 P(C) probabilities[classValue] *= calculateProbability(x, mean, stdev) return probabilities

probabilities[classValue]从 1 起乘,乘的全是各特征的类条件密度,先验从头到尾没出现。这等价于隐式假设 P(发病) = P(不发病) = 0.5。在类别接近均衡的数据上,这个假设几乎无害;可一旦用在真实筛查场景(阳性率往往只有百分之十几到三十),忽略先验会让决策边界明显偏向阳性一侧,假阳性数量上去。补回来只要一行:

# 从训练集统计先验:某类别样本数 / 总样本数 priors = {c: len(v) / float(len(trainingSet)) for c, v in separated.items()} probabilities[classValue] *= priors[classValue]

先验的影响到底有多大,可以做个粗略的量级对比。假设某条样本算出的似然比(阳性/阴性)是 8:1,先验 0.5:0.5 时判阳性;先验换成 0.2:0.8 时,后验比变成 1.6:0.8,仍然判阳性,但置信度从 8 倍掉到 2 倍。似然比只有 3:1 的时候,先验一换结论就翻转了。这类“边界样本翻转”在测试集上往往就是两三个百分点的准确率波动,看起来像随机噪声,其实是先验在起作用。

公式符号含义代码里的对应物是否被实现
P(C)类别先验无未实现,默认均匀
P(x_i|C)类条件密度calculateProbability已实现
∏P(x_i|C)联合似然probabilities[classValue]累乘已实现
P(x)证据,归一化分母无有意省略

2.2 连续特征没法数频次:高斯假设是给离散公式打的补丁

朴素贝叶斯最原始的形态是给离散特征用的:文本分类里每个词要么出现要么不出现,P(词|类别) 直接用“该类别下这个词出现的次数 / 该类别下所有词的总次数”估出来,再加个拉普拉斯平滑防止分子为零。

糖尿病指标是连续值:BMI 是 22.7,血压是 78,血清化验值能到小数点后好几位。连续值上“数频次”没有意义——每个具体数值基本只出现一次,频率估计全是 0。所以必须换一种方式估 P(x_i|C)。

常见的补丁是假设:在给定类别的条件下,每个特征服从正态分布,用该类别下这个特征的样本均值和样本标准差把这根曲线定下来,然后拿密度函数值当“似然”用。这就是高斯朴素贝叶斯。它并没有让公式更正确,只是给“连续特征的条件概率”找了一个可计算的替身。

这里有个容易混淆的点必须说清:高斯密度函数算出来的值不是概率,是密度,它可以大于 1。标准差 0.5 的时候,峰值密度是 1/(√(2π)×0.5) ≈ 0.8;标准差 0.1 的时候峰值能到 4。所以代码里probabilities[classValue]出现大于 1 的中间值完全是正常的,最终那个连乘结果也不该被当作“患病的可能性是 3.7 倍”来解读,它只有相对大小的意义。

2.3 四个函数的分工:一份手写实现的骨架

这套实现没有调用任何机器学习库,全靠四个函数把“载入—拆分—按类聚合—统计”串起来。骨架摊开看,职责切得很干净:

import csv, random, math def loadCsv(path): # 指定 utf-8,避免不同平台默认编码不一致导致读表失败 lines = csv.reader(open(path, "r", encoding="utf-8")) dataset = list(lines) # CSV 读出来全是字符串,必须整体转 float,否则后面减法直接报错 for i in range(len(dataset)): dataset[i] = [float(x) for x in dataset[i]] return dataset def splitDataset(dataset, splitRatio): trainSize = int(len(dataset) * splitRatio) trainSet = [] copy = list(dataset) # 拷贝,pop 不破坏原始数据 while len(trainSet) < trainSize: index = random.randrange(len(copy)) trainSet.append(copy.pop(index)) # 抽走不放回,剩下的自然成为测试集 return [trainSet, copy] def separateByClass(dataset): separated = {} for vector in dataset: label = vector[-1] # 约定:最后一列是类别 if label not in separated: separated[label] = [] # 第一次见到该类别,先建桶 separated[label].append(vector) return separated def summarize(dataset): # zip(*dataset) 是转置:把每一列聚成一个元组 summaries = [(mean(col), stdev(col)) for col in zip(*dataset)] del summaries[-1] # 转置后最后一列是标签,不能参与统计 return summaries

几个细节决定了这段代码能不能跑对。splitDataset用pop而不是随机取下标再判重,等价于无放回抽样,天然保证训练集和测试集不重叠,也保证训练集大小精确等于int(len(dataset) * splitRatio)。zip(*dataset)这个写法是整份实现里最巧的一处:它把“行是样本、列是特征”的列表翻成“每个特征一列”,这样mean和stdev只要写一版就能套用到所有特征上。

而del summaries[-1]是最容易被漏掉的一步。转置之后最后一列是类别标签,如果不删,标签也会被算出一个均值和标准差,然后在calculateClassProbabilities里被当成第 10 个特征参与连乘。后果不是报错,是准确率悄悄掉下去——这种不抛异常的错最难受。

统计量本身用的是样本标准差:

def mean(numbers): return sum(numbers) / float(len(numbers)) def stdev(numbers): avg = mean(numbers) # 除以 n-1:样本方差的无偏估计 variance = sum([(x - avg) ** 2 for x in numbers]) / float(len(numbers) - 1) return math.sqrt(variance)

分母是len(numbers) - 1而不是n。样本量小的时候这个差别不小:某类别只有 4 条样本,除以 3 和除以 4 得到的方法差 33%,标准差差 15%,直接改变高斯曲线的胖瘦,也就改变了似然值。442 条样本按类别拆开,每类两百条上下,这个修正影响有限但不该省。

3. 高斯似然的源码级拆解:指数、分母与连乘的下溢风险

3.1 calculateProbability 里的两段计算

整个模型最核心的三行代码就是概率密度函数:

def calculateProbability(x, mean, stdev): # 指数部分:-(x-μ)²/(2σ²),衡量 x 偏离均值几个标准差 exponent = math.exp(-(math.pow(x - mean, 2) / (2 * math.pow(stdev, 2)))) # 归一化系数 1/(√(2π)·σ),保证整条曲线下面积为 1 return (1 / (math.sqrt(2 * math.pi) * stdev)) * exponent

拆开看是两段完全不同的东西。exponent管形状:x 离均值越远,指数越负,密度衰减得越快,一个标准差处衰减到峰值的 0.61,两个标准差处衰减到 0.135。这段决定了“异常值有多不被接受”。前面那个1/(√(2π)·σ)管缩放:它跟 x 无关,只跟这个特征在这一类里的离散程度有关。

缩放项是理解模型行为的关键。假设 BMI 在非发病组的 σ 是 6,在发病组的 σ 是 4,同一条样本算出来两个类别的密度,缩放系数就差了 1.5 倍。这意味着:一个特征如果在某个类别里更集中,它天然会给这个类别更高的似然,哪怕 x 正好落在两个均值中间。这不是 bug,是密度函数的固有性质,但它解释了为什么量纲差异大的特征混在一起时,结果会莫名其妙地偏向某一类。

3.2 方差为什么除以 n-1,似然里为什么又除 σ

两个地方都出现了“除”,容易被当成同一件事,其实完全不同。

stdev里除n-1是统计估计问题:我们拿样本标准差去估计总体标准差,除以 n 会系统性低估,除以 n-1 才无偏。这是纠偏。

calculateProbability里除 σ 是数学定义问题:正态密度函数 f(x) = 1/(σ√(2π))·exp(-(x-μ)²/(2σ²)),那个 1/σ 是归一化因子的一部分。如果少了它,曲线下的面积就不是 1,不同 σ 的特征之间密度值没法比。有人看到这里会以为“是不是对方差开了个根号开错了”,不是,指数里的 σ² 和前面的 σ 本来就该一个平方一个不平方。

这两个 σ 的量纲也不一样。标准差和原始特征同量纲(BMI 的标准差还是 BMI 单位),方差是平方量纲。所以写代码时math.pow(stdev, 2)拿到的其实就是方差,不用再单独算一遍——summarize返回的是 (均值, 标准差),方差在密度函数里现算。

3.3 连乘的代价:从 1e-30 到对数域

一条样本有 9 个特征,每个特征的密度值大概在 0.01 到 1 之间,9 个连乘下来量级就落到 1e-18 附近。样本再“非典型”一点,某些特征密度低到 1e-4,九项一乘就是 1e-36,超过了双精度浮点的下限(约 1e-308)吗?还不到,但已经很危险。真正的问题是两个类别同时下溢成 0,这时候probability > bestProb的比较完全失效,永远返回字典里第一个类别。

这就是“永远预测同一类”这类玄学现象最常见的来源。解法是把连乘搬到对数域,乘法变加法,1e-36 变成 -82.9,浮点完全扛得住:

def calculateLogProbability(x, mean, stdev): # 高斯密度取对数:常数项 - 二次项,乘法转加法 return (-math.log(math.sqrt(2 * math.pi) * stdev) - ((x - mean) ** 2) / (2 * stdev ** 2)) def predictByLog(summaries, inputVector, priors): bestLabel, bestScore = None, float("-inf") for label, classSummaries in summaries.items(): score = math.log(priors[label]) # 先验也进对数域 for i, (m, s) in enumerate(classSummaries): score += calculateLogProbability(inputVector[i], m, s) if score > bestScore: # 加法结果比大小,等价于原似然比 bestScore, bestLabel = score, label return bestLabel

对数变换不改变 argmax:log 是单调递增函数,谁大谁小关系不变。所以这一步纯粹是数值稳定性改造,模型本身一点没变。顺带一提,math.exp在calculateProbability里传入很小的负数时返回 0.0 而不是报错,这属于静默失败,比抛异常更难排查——你只会看到准确率不对劲,看不到任何报错信息。

3.4 0.67 这个比例是怎么来的

文档里写的切分是 90% 训练、10% 测试,主函数里落地的却是splitRatio = 0.67:

if __name__ == '__main__': splitRatio = 0.67 dataset = loadCsv("data/diabetes.csv") trainingSet, testSet = splitDataset(dataset, splitRatio) summaries = summarizeByClass(trainingSet) predictions = getPredictions(summaries, testSet) print("Accuracy: ", getAccuracy(testSet, predictions))

442 条样本按 0.67 拆,训练集约 296 条,测试集约 146 条。按 0.9 拆则是训练 397 条、测试 45 条。这两种配置在评估上的可信度差得远:测试集只有 45 条时,错一条就是 2.2 个百分点,测出来的准确率基本是噪声。0.67 这个比例更接近常见的经验值,测试集规模勉强够看,但也不算充裕。

比例本身没有绝对正确答案,取决于样本量。经验上测试集至少要有几百条,评估结果才有稳定意义;样本只有几百条的时候,单次留出法(hold-out)本身就是权宜之计,正确做法是交叉验证,这属于评估方法层面的取舍,下一章展开。

另外要注意:splitDataset用的是random.randrange而没有设种子。同一份数据、同一份代码,两次运行会拿到完全不同的训练集,准确率自然会飘。这不是模型不稳定,是评估流程没做可复现控制。

4. 准确率与那张 5×2 的图:单次抽样能证明什么

4.1 getAccuracy 给的是一个点,不是一段区间

准确率的实现很直白:逐条比对预测标签和真实标签,对了加一,最后除以测试集总数乘 100。

def getAccuracy(testSet, predictions): correct = 0 for x in range(len(testSet)): if testSet[x][-1] == predictions[x]: # 标签完全相等才算对 correct += 1 return (correct / float(len(testSet))) * 100.0

这个函数没问题,问题在于它只被调用了一次。单次随机拆分得到的是一个点估计,它背后有一个方差相当大的分布。想看清这个分布有多宽,把评估流程重复几十遍就够了:

def repeatEvaluate(dataset, ratio, rounds=30): scores = [] for _ in range(rounds): train, test = splitDataset(dataset, ratio) # 每轮重新随机拆分 summaries = summarizeByClass(train) preds = getPredictions(summaries, test) scores.append(getAccuracy(test, preds)) avg = sum(scores) / len(scores) return avg, min(scores), max(scores)

三十轮跑下来,均值、最小值、最大值一起看,你立刻能判断出“74%”这个数字到底代表模型水平 74%,还是只代表这一次运气还行。我一般会这么理解:报告单次准确率而不给波动范围,等于只报了一次抛硬币的结果,却不说抛了多少次。

真正该做的是 k 折交叉验证:把数据均分成 k 份,每次拿一份当测试、其余当训练,跑 k 次取平均。它把每一条样本都当过一次测试样本,方差比单次留出法小得多,代价是要训练 k 次。样本量只有四百多的时候,这个代价可以忽略。

评估方式训练次数测试集规模结果稳定性适用场景
单次留出 0.671~146差快速验证流程通不通
单次留出 0.91~45很差不建议
重复留出 30 轮30~146中看波动范围
10 折交叉验证10~44好小样本正式评估

4.2 线性回归图在回答哪个问题

可视化部分是另一套逻辑:它不训练分类器,而是逐个特征对连续目标值做一元线性回归,画出散点和拟合直线,标题里挂上 R²。

import numpy as np import matplotlib.pyplot as plt from sklearn.linear_model import LinearRegression plt.figure(figsize=(12, 25)) for i, col in enumerate(df.columns): # Series 是一维,sklearn 要求二维输入,reshape(-1, 1) 把列变成 n×1 x = df.loc[:, col].values.reshape(-1, 1) model = LinearRegression().fit(x, target) axes = plt.subplot(5, 2, i + 1) axes.scatter(x, target, s=6) grid = np.linspace(x.min(), x.max(), 100).reshape(-1, 1) axes.plot(grid, model.predict(grid), c="red") # 拟合直线 axes.set_title(f"{col}: R2={model.score(x, target):.3f}") plt.show()

reshape(-1, 1)是必须的一步:pandas 取一列拿到的是形状 (n,) 的一维数组,fit会把它当成 n 个样本 1 个特征还是 1 个样本 n 个特征?sklearn 的规则是二维输入必须是 (样本数, 特征数),一维会直接报错。-1让 numpy 自己推这一维,1 是特征数。

model.score返回的是 R²,含义是“目标值的方差里有多大比例能被这个特征线性解释”。R² = 0.34 表示这个特征单独能解释 34% 的波动。注意它跟分类任务的准确率不是一回事,两者不能互相换算,也不能拿 R² 高的特征去论证分类器一定判得准。

从图上看,性别那一栏的散点基本是两团竖线,拟合直线几乎水平,R² 接近 0;BMI 那一栏散点有明显斜率,R² 在各项里最高;年龄有一点点正相关;几项血清指标有的正相关有的负相关。这个结论跟“BMI 与糖代谢异常关系密切”的常识是一致的。

4.3 相关性看得见,可分性看不见

图看着趋势挺明显,可分类准确率就是上不去,卡在七成多。这两件事不矛盾,因为线性回归回答的是“这个特征能不能预测目标值的大小”,分类器回答的是“这组特征能不能把两类分开”。一个特征跟目标值高度线性相关,不代表两类的分布在它上面不重叠。

更根本的问题在朴素贝叶斯那个“朴素”上:它假设各特征在给定类别下条件独立,于是把联合似然写成 ∏P(x_i|C)。这个假设在糖尿病数据上明显不成立——BMI、血压、几项血清指标之间高度相关,它们本质上在反映同一件事。当这些特征同时偏离均值时,模型会把“同一份证据”重复计入九次,后验被推到极端值(接近 0 或 1)。

后果是模型过度自信:预测对了看起来很笃定,预测错了也看起来很笃定,中间地带几乎没有。而真正的贝叶斯推断应该在这种证据高度冗余的场景下保持谨慎。这也是为什么同样是线性模型,逻辑回归在这类数据上往往更稳——它不假设特征独立,而是给每个特征一个权重,把相关性通过权重的相互抵消吸收掉。

所以那张 5×2 的图,价值不在于证明“BMI 最重要”,而在于给你一个判断依据:如果某个特征单独看几乎没区分度(性别),那它在模型里贡献的很可能只是噪声;如果几个特征都指向同一个方向,就要警惕独立性假设带来的证据重复计数。

5. 避坑与排查:这套流程跑起来之后最容易翻车的五处

5.1 每次运行的准确率都不一样

现象:同一份数据、同一份代码,连续跑五次,输出 71.2、76.8、69.4、74.0、78.1,没有任何规律。

原因:splitDataset用random.randrange抽样本,没设随机种子。每次进程启动,随机数生成器的初始状态都不同,切出来的训练集和测试集完全不一样。样本量只有四百多,测试集一百多条,换一批测试样本,准确率自然飘。

解决:在程序入口固定种子random.seed(42),让结果可复现;同时别把单次结果当结论,用 4.1 里的重复评估或交叉验证给出均值与波动范围。固定种子是为了调试方便,报告结果时该给的是分布而不是某个幸运值。

5.2 某个特征标准差为 0,程序直接崩

现象:ZeroDivisionError: float division by zero,或者math.exp之后得到nan,准确率变成nan。

原因:某个类别下某个特征的所有取值完全相同,stdev算出来是 0。随后math.pow(stdev, 2)进分母,1 / (math.sqrt(2*math.pi) * stdev)直接除零。这在小样本或某个特征被离散化之后特别容易发生——比如性别只有 0/1,某类别里全是 0。

解决:加一个极小的平滑项,常见做法是stdev = max(stdev, 1e-9),或者直接给方差加一个var_smoothing常数。这不是掩盖问题,是在“该特征在此类别下没有变化”这个退化场景下给密度函数一个有限的替代值。顺带检查一下separateByClass有没有把某个类别拆成空桶。

5.3 路径写死和编码问题

现象:换一台机器就报FileNotFoundError,或者读进来的第一行带着奇怪的字符,float()转换抛ValueError。

原因:数据路径写成了绝对路径,换了目录结构就失效;open()不指定编码,在不同操作系统默认编码下读同一个文件结果不同,中文路径或带 BOM 的表头尤其容易出问题。另外 CSV 首行如果是列名,float('age')一定报错。

解决:路径改成相对路径加os.path.join,或者从命令行参数传入;open显式加encoding="utf-8";如果首行是表头,next(reader)先跳过去。这几处都是血泪经验——报错信息指向的行号往往跟真实原因隔着好几层。

5.4 生理指标里的 0 不是 0

现象:模型对某些样本给出的概率极端偏向一边,但这条样本的各项指标看起来都“正常”。

原因:这类医学数据集里,缺失值常常被填成 0。血压 0、BMI 0、胰岛素 0,在数值上合法,在生理上不可能。这些 0 会被当成真实观测值送进高斯密度函数,算出一个极小的似然,把整个类别的分数拉垮。

解决:训练前先统计每个特征里 0 的占比,占比异常的(比如 BMI 为 0 的比例超过 1%)按缺失值处理,用该类别的均值或中位数填充,或者直接删掉这一列。这一步不做,后面所有调参都是在跟脏数据较劲。

5.5 拿两套不同的数据下结论

现象:可视化图上 BMI 的趋势非常明显,R² 也不低,但分类器准确率始终上不去,两边结论对不上。

原因:分类流程和可视化流程用的不是同一份数据。前者读的是本地 CSV,标签是 0/1 两类;后者通过数据加载接口拿的是另一套公开数据,目标值是连续数值。两份数据的样本量、特征含义、标签定义都不一样。把图上的相关性结论直接搬到分类器的准确率上解释,逻辑链是断的。

解决:先确认两处数据源的字段和标签定义是否一致,不一致就分开表述,各说各的。另一个同类问题是拿训练集当测试集评估——getPredictions(summaries, trainingSet)跑出来的准确率会明显虚高,因为模型已经见过这些样本。评估必须用没参与训练的那部分数据。

6. 设计取舍:手写两百行和一行的 GaussianNB,差在哪

6.1 可控性换来的东西

从工程角度看,手写这套流程在性能上毫无优势:纯 Python 双重循环,四百条样本跑起来感觉不出来,四万条就要等。成熟库的实现是向量化的,还内置了方差平滑、先验估计、对数域计算这些防御性处理。但手写版把每个环节都摊开了,你能明确知道先验没乘、标准差用了 n-1、连乘没有对数化——这些在调库时全靠读源码才能确认。

维度手写流程成熟库的 GaussianNB
概率计算自己写密度函数、自己连乘内部对数化,防下溢
方差为 0直接除零崩掉加平滑项兜住
类别先验没乘,隐式均匀按训练集频率估计
可解释性每行都能对上公式需读源码确认细节
性能纯 Python 循环向量化
教学价值高,每一步可干预低,黑匣子

这张表不是说手写更好,而是说两者的适用场景不同。要验证一个想法、要定位“为什么准确率是这个数”,手写版能让你在任意一步打日志;要上生产、要处理十万级样本,调库是唯一理性选择。真正有价值的做法是先用几十行手写版把机制跑通,再用库版本对齐结果——如果两者准确率差得离谱,那多半是手写版漏了某个细节(先验、平滑、对数化),这个差异本身就是最好的调试线索。

6.2 继续往下走的两条路

一条是修模型:把独立性假设放松。要么做特征选择,把高度相关的几列砍到只剩一个代表,减少证据重复计数;要么换模型,逻辑回归、树模型都不要求条件独立。另一条是修评估:把单次留出换成 k 折交叉验证,把准确率换成召回率和精确率——筛查场景里漏诊和误诊的代价完全不对称,只看准确率会掩盖真正要优化的方向。

从那以后我每次写完一个分类流程,都会先把三样东西打进日志再跑评估:拆分比例和随机种子、每个类别的先验、每个特征在每个类别下的均值和标准差。这三样一打印,八成的异常结果当场就能定位,不用再靠反复运行碰运气。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询