简介:这份PDF文档面向模式识别、机器学习入门学习者与课程设计者,围绕朴素贝叶斯分类器在医疗场景的落地展开,完整呈现糖尿病检测系统的设计思路与实现路径。资源包仅含1个PDF文件,体积约771KB,内容涵盖贝叶斯定理分类原理、Pima Indians数据集说明、特征提取与高斯概率计算、分类预测流程、准确率评估与可视化分析,并附有loadCsv、SplitDataset、summarizeByClass、calculateProbability等核心函数的代码实现与注释。读者可借此理解条件概率累乘、均值方差标准差估计、高斯概率密度函数应用等关键环节,掌握从数据加载、训练测试拆分到预测评估的完整链路,同时获得硬件软件环境配置参考与图表展示思路。目前已有235人学习,适合希望以具体项目吃透朴素贝叶斯算法、完成课程大作业或夯实统计学习基础的读者参考。
1. 从一份课程设计拆出来的朴素贝叶斯糖尿病检测:它到底能跑出什么结果
很多人第一次接触朴素贝叶斯,都是在模式识别或者机器学习课的课程设计里。这份《基于贝叶斯定理的糖尿病检测系统的设计与实现》就是典型的课程设计产物——用 Python 从零手写高斯朴素贝叶斯,不依赖 sklearn 的GaussianNB,把加载数据、拆分训练测试集、按类别汇总均值方差、高斯概率密度计算、连乘求类别概率、预测与准确率评估整条链路全部自己实现一遍,最后再用线性回归和散点图做特征与发病结果的关联分析。它解决的不是“工业级糖尿病诊断”问题,而是“让你真正搞懂朴素贝叶斯每一步在算什么”的问题。适合正在做模式识别大作业的学生、想从零手推一遍贝叶斯分类器的开发者,以及需要一份可复现基线代码来对照 sklearn 结果的从业者。数据集用的是皮马印第安人糖尿病数据,442 条记录、9 项数值型生理指标,所有属性单位不统一,这也是后面要讲标准化坑的根源。
2. 高斯朴素贝叶斯的数学链路:从条件概率到连乘决策
2.1 为什么选高斯而不是多项式或伯努利
朴素贝叶斯有三个常见变体:多项式、伯努利、高斯。选哪个不是拍脑袋,而是看特征的数据类型。多项式适合词频这类离散计数,伯努利适合二值特征,而皮马印第安数据集里的年龄、BMI、血清化验值全是连续数值,单位还各不相同。对连续值做概率估计,常见做法是假设它服从某个分布,最省事也最常用的就是正态分布——只需要均值和标准差两个参数就能描述整条曲线。这就是高斯朴素贝叶斯的选型理由:不假设特征之间独立(虽然“朴素”这个词本身就意味着独立假设),而是假设每个特征在给定类别下的条件分布是正态的。
具体到这份代码,summarizeByClass()先按类别把数据分开,再对每个类别下的每个属性调用mean()和stdev(),用zip(*dataset)把列转置后逐列计算。这里有个容易忽略的细节:summarize()里最后有一句del summaries[-1],目的是把类别标签那一列从统计结果里删掉,因为标签不是特征,不参与概率计算。如果你自己写的时候忘了删,后面calculateClassProbabilities()遍历classSummaries时就会多算一个属性,虽然不报错,但概率会偏。
2.2 高斯概率密度函数的代码实现与参数含义
核心公式就是标准正态分布的概率密度函数:
F(x) = 1/(σ√(2π)) · exp(-(x-μ)²/(2σ²))
代码里拆成两步算,先算指数部分再算前面的系数:
import math def calculateProbability(x, mean, stdev): # 指数部分:exp(-(x-μ)²/(2σ²)) exponent = math.exp(-(math.pow(x - mean, 2) / (2 * math.pow(stdev, 2)))) # 系数部分:1/(σ√(2π)),再乘指数部分 return (1 / (math.sqrt(2 * math.pi) * stdev)) * exponent参数说明:x是待预测样本在某个特征上的取值,mean和stdev是该特征在某个类别下的均值和标准差,来自训练集的汇总结果。math.pow(stdev, 2)就是方差 σ²。这里有个血泪经验:如果某个特征在某个类别下的标准差为 0(比如某个属性所有值都一样),stdev做分母会直接抛ZeroDivisionError。皮马数据集里不太会出现这种情况,但换成自己的数据就要加一个极小值兜底,常见做法是stdev = max(stdev, 1e-6)。
2.3 连乘求类别概率与最大值决策
有了单特征的条件概率,接下来要算整个样本属于某个类别的概率。朴素贝叶斯的“朴素”就体现在这里:假设各特征相互独立,所以联合概率等于各特征条件概率的连乘。
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] # 逐个特征概率连乘 probabilities[classValue] *= calculateProbability(x, mean, stdev) return probabilities def predict(summaries, inputVector): probabilities = calculateClassProbabilities(summaries, inputVector) bestLabel, bestProb = None, -1 for classValue, probability in probabilities.items(): if bestLabel is None or probability > bestProb: bestProb = probability bestLabel = classValue return bestLabel逻辑说明:probabilities字典的 key 是类别(0 表示五年后不发病,1 表示发病),value 是该样本属于这个类别的联合概率。predict()遍历字典找最大值对应的类别。这里有个理论上的关键点——贝叶斯公式的分母 P(x) 对所有类别都是一样的,所以比较分子就够了,不需要真的去除以分母。这也是为什么代码里从头到尾没有出现 P(x) 的计算。
提示:连乘容易下溢。特征多的时候,几十个小于 1 的概率相乘会趋近于 0,float 精度扛不住。常见做法是取对数把连乘变连加,即 log P(x|c) + log P(c),比较对数概率大小。这份课程设计特征只有 9 个,暂时不会触发下溢,但换成高维数据就必须改。
2.4 数据加载与训练测试拆分的实操步骤
数据加载用标准库csv就够了,不需要 pandas。关键是把字符串转成 float,否则后面算均值方差会报类型错误。
import csv import random def loadCsv(): # 注意:实际使用时把路径换成你自己的数据集路径 lines = csv.reader(open("diabetes.data.csv", "r", encoding="utf-8")) dataset = list(lines) 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) while len(trainSet) < trainSize: index = random.randrange(len(copy)) trainSet.append(copy.pop(index)) return [trainSet, copy]参数说明:splitRatio控制训练集比例,原文主程序里写的是 0.67,但正文描述里提到 90% 训练 10% 测试,这两个值不一致。我一般会固定用 0.8 或者做多次随机拆分取平均,单次拆分的结果波动很大,尤其是数据量只有几百条的时候。random.randrange(len(copy))配合copy.pop(index)实现无放回随机抽样,保证训练集和测试集不重叠。
主程序串起来就是:
if __name__ == '__main__': splitRatio = 0.8 dataset = loadCsv() trainingSet, testSet = splitDataset(dataset, splitRatio) summaries = summarizeByClass(trainingSet) predictions = getPredictions(summaries, testSet) accuracy = getAccuracy(testSet, predictions) print("Accuracy: ", accuracy)getAccuracy()的逻辑很直白:遍历测试集,预测类别和真实类别一致就correct += 1,最后correct / len(testSet) * 100就是准确率。这个指标在类别不平衡时会有误导性,皮马数据集里阳性样本比例不算极端,所以还能用,但换成极端不平衡的数据就要看召回率和 F1。
3. 线性回归与特征可视化:哪些指标真的和发病相关
3.1 用 sklearn 加载数据集并构建 DataFrame
课程设计后半部分换了一条路线,不再手写贝叶斯,而是用 sklearn 的load_diabetes加载数据,再用线性回归逐个特征拟合,看每个特征和 target 的关系。
import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.datasets import load_diabetes data_diabetes = load_diabetes() data = data_diabetes['data'] target = data_diabetes['target'] feature_names = data_diabetes['feature_names'] df = pd.DataFrame(data, columns=feature_names) df.info()load_diabetes返回的是一个字典,三个 key:data是特征矩阵,target是回归目标值,feature_names是特征名列表。转成 DataFrame 之后df.info()可以看到每个特征都是 float64,没有缺失值。注意这里的数据和前面 CSV 加载的皮马数据集不是同一份——load_diabetes是 sklearn 内置的另一个糖尿病数据集,442 个样本、10 个特征,target 是连续值而不是 0/1 类别。原文把两者混在一起讲,实际上前半段分类用的是 CSV 数据,后半段回归用的是 sklearn 内置数据,读者自己复现时要注意区分。
3.2 逐特征线性回归与散点图绘制
核心思路是:对每个特征单独建一个线性回归模型,看它单独能解释多少 target 的变化,用score()返回的 R² 衡量。
from sklearn.linear_model import LinearRegression from sklearn.model_selection import train_test_split plt.figure(figsize=(12, 25)) for i, col in enumerate(df.columns): # 取单列特征,reshape 成二维,因为 sklearn 要求二维输入 train_X = df.loc[:, col].values.reshape(-1, 1) train_Y = target linear_model = LinearRegression() linear_model.fit(train_X, train_Y) score = linear_model.score(train_X, train_Y) axes = plt.subplot(5, 2, i + 1) plt.scatter(train_X, train_Y, s=5) # 画拟合直线 y = kx + b k = linear_model.coef_ b = linear_model.intercept_ x = np.linspace(train_X.min(), train_X.max(), 100) y = k * x + b plt.plot(x, y, c='red') axes.set_title(col + ':' + str(round(score, 3))) plt.tight_layout() plt.show()参数说明:reshape(-1, 1)是把一维 Series 转成 n 行 1 列的二维数组,sklearn 的fit()不接受一维输入。linear_model.coef_是回归系数 k,linear_model.intercept_是截距 b。score()返回的是 R²,越接近 1 说明这个特征单独对 target 的解释力越强。从结果看,BMI 对应的 R² 明显高于其他特征,年龄有一点点关系,性别几乎无关——这和原文的分析结论一致。
注意:这里的 R² 是单特征对 target 的拟合优度,不是分类准确率,不能直接拿来和前面贝叶斯的准确率比较。两者衡量的是不同任务。
3.3 特征与发病关系的解读边界
从散点图和拟合直线能看出几件事:BMI 和 target 呈明显正相关,说明体重指数越高,病情发展指标越差;年龄有弱正相关;性别那一列几乎是一条水平线,说明在这个数据集里性别对 target 没有区分力。但这里有个容易翻车的地方——load_diabetes的 target 是连续值,代表“一年后病情发展情况”,不是“是否患病”的二分类标签。所以严格来说,这部分做的是回归分析,不是分类。原文把它和前面的贝叶斯分类放在一起讲,逻辑上有点混,读者自己写论文或报告时最好把两条线分开表述:分类用皮马 CSV 数据,回归用 sklearn 内置数据,各自说明数据来源和任务类型。
4. 避坑与排查:这份代码最容易翻车的五个地方
4.1 准确率每次跑都不一样
现象:同一份代码,每次运行输出的 Accuracy 都在变,有时候差好几个百分点。
原因:splitDataset()用的是random.randrange()做随机拆分,没有固定随机种子,每次训练集和测试集的划分都不同。数据量只有几百条时,单次拆分的方差很大。
解决:在拆分前加random.seed(42)固定随机序列,或者做 10 次随机拆分取平均准确率。更稳妥的做法是用 sklearn 的train_test_split配合stratify参数做分层抽样,保证训练集和测试集里正负样本比例一致。
4.2 标准差为零导致除零错误
现象:程序跑到calculateProbability()时抛ZeroDivisionError,提示 division by zero。
原因:某个特征在某个类别下的所有取值完全相同,stdev()算出来是 0,高斯公式里 σ 做分母就炸了。
解决:在stdev()返回前加一个下限保护,比如return max(math.sqrt(variance), 1e-6)。或者换数据前先检查每个类别下每个特征的方差,方差为 0 的特征直接剔除。
4.3 文件路径写死导致换机器就跑不了
现象:在别人电脑上运行,报FileNotFoundError,提示找不到D:\研究生课程\...\diabetes.data.csv。
原因:loadCsv()里用的是绝对路径,而且路径里带中文和空格,换一台机器路径就不存在了。
解决:把数据文件和代码放同一目录,用相对路径open("diabetes.data.csv", "r", encoding="utf-8")。如果一定要用绝对路径,用os.path.join()拼接,避免手写反斜杠转义问题。
4.4 连乘下溢导致概率全为零
现象:特征数量增加后,probabilities[classValue]打印出来全是 0.0,predict()返回的类别随机。
原因:多个小于 1 的概率连乘,结果趋近于浮点数下限,被截断为 0。
解决:改用对数概率,把probabilities[classValue] *= p改成probabilities[classValue] += math.log(p),最后比较对数概率大小。注意math.log(0)会报错,所以calculateProbability()返回前也要加下限保护。
4.5 把回归 R² 当成分类准确率
现象:报告里写“模型准确率达到 0.5 以上”,但贝叶斯分类的准确率和线性回归的 R² 被混在一起说。
原因:前半段分类任务用getAccuracy()算的是百分比准确率,后半段回归任务用score()算的是 R²,两个指标量纲和含义完全不同。
解决:报告里分开写。分类部分写“在皮马数据集上,高斯朴素贝叶斯的分类准确率为 XX%”;回归部分写“BMI 特征对 target 的单变量线性回归 R² 为 XX”。不要跨任务比较指标。
5. 从手写版到 sklearn 对照验证:一个我常用的自检习惯
手写朴素贝叶斯最大的价值不是拿去生产,而是拿来对照 sklearn 的GaussianNB,验证自己每一步算得对不对。我一般会这么做:先用自己写的loadCsv()和splitDataset()拿到训练集和测试集,然后分别用手写版和 sklearn 版跑一遍,比较两者的准确率差异。如果差超过 2 个百分点,说明手写版某一步有问题,通常是均值方差算错或者概率连乘时多算/少算了特征。
from sklearn.naive_bayes import GaussianNB from sklearn.metrics import accuracy_score import numpy as np # 假设 trainingSet 和 testSet 已经通过前面的 splitDataset 拿到 train_X = np.array([row[:-1] for row in trainingSet]) train_y = np.array([row[-1] for row in trainingSet]) test_X = np.array([row[:-1] for row in testSet]) test_y = np.array([row[-1] for row in testSet]) # sklearn 高斯朴素贝叶斯 gnb = GaussianNB() gnb.fit(train_X, train_y) sk_pred = gnb.predict(test_X) sk_acc = accuracy_score(test_y, sk_pred) print("sklearn Accuracy:", sk_acc * 100) # 手写版 summaries = summarizeByClass(trainingSet) my_pred = getPredictions(summaries, testSet) my_acc = getAccuracy(testSet, my_pred) print("手写版 Accuracy:", my_acc)这段对照代码的关键在于:两边必须用完全相同的训练集和测试集,否则比较没有意义。GaussianNB默认也是用均值和方差做高斯概率估计,和手写版理论一致,所以准确率应该非常接近。如果手写版明显偏低,优先检查summarize()里有没有正确del summaries[-1],以及calculateClassProbabilities()里inputVector的索引有没有和classSummaries对齐。
还有一个我踩过的坑:sklearn 的GaussianNB默认会使用先验概率priors=None,也就是从训练数据里统计类别频率作为先验。手写版代码里没有显式乘先验概率 P(c),只算了似然 P(x|c)。如果训练集里正负样本比例不均衡,手写版和 sklearn 版的结果会有偏差。要完全对齐,手写版需要在calculateClassProbabilities()里把probabilities[classValue]初始化为该类别的先验概率,而不是 1。
从那以后我每次手写分类器,都会强制走一遍“手写版 vs sklearn 版”的对照流程,准确率对不上就先查先验、再查方差计算、最后查特征索引。这个习惯帮我省了很多翻论文找公式的时间。希望帮到你。
本文还有配套的精品资源,点击获取