ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

手写高斯朴素贝叶斯做糖尿病预测:从原理到二次开发

手写高斯朴素贝叶斯做糖尿病预测:从原理到二次开发

简介:这份PDF面向模式识别与机器学习入门学习者,围绕朴素贝叶斯分类器在医疗预测场景中的落地展开,帮助读者理解如何用统计学原理完成糖尿病风险判别。内容涵盖贝叶斯定理分类原理、Pima Indians糖尿病数据集说明、特征均值方差与标准差计算、高斯概率密度估计、按类别汇总特征、概率连乘与最大后验判别,以及准确率评估和图表可视化,并附有loadCsv、SplitDataset、separateByClass、summarizeByClass、calculateProbability等关键函数的实现讲解,可作为课程大作业或项目实践参考。资源包共1个PDF文件,大小约771KB,篇幅紧凑便于通读。已有236人学习,适合希望把分类算法从公式推导推进到代码实现与结果分析的读者对照研读。

1. 从一份课程大作业说起:朴素贝叶斯做糖尿病预测到底能跑出什么

前阵子帮一个做模式识别课程的朋友看代码,他扔过来一个压缩包,说“跑是能跑,但准确率忽高忽低,图表也画得乱七八糟”。打开一看,是一个用纯 Python 手写朴素贝叶斯、对皮马印第安人糖尿病数据集做二分类的小系统,外加一份用 sklearn 线性回归做特征可视化的脚本。没有框架、没有模型持久化、没有 Web 界面,就是两个.py文件加一个 CSV,但恰恰是这种“裸写算法”的项目,最适合拿来把贝叶斯分类的每一步拆开看清楚。它适合两类人:一是想搞明白高斯朴素贝叶斯内部到底怎么算的初学者,二是需要一个干净基线做二次开发、往上叠特征工程或换模型的开发者。下面按“能干什么 → 怎么跑 → 怎么改”的顺序,把这个项目从头拆一遍。

2. 项目定位与技术选型:为什么用纯手写而不是直接调 sklearn

2.1 这个系统实际在解决什么问题

把问题说白一点:给一条病人的生理指标记录(怀孕次数、血糖、血压、皮褶厚度、胰岛素、BMI、糖尿病家族史函数、年龄这 8 个特征,外加一个 0/1 的类别标签),判断这个人五年内会不会发病。这是一个标准的二分类任务,特征全是数值型,量纲不统一,样本量不大。项目选朴素贝叶斯,核心假设是“在给定类别的条件下,各特征相互独立”,于是联合概率可以拆成各特征条件概率的连乘,计算量从指数级降到线性级。对这么小的数据集来说,这个假设虽然不严谨(BMI 和皮褶厚度显然相关),但工程上够用,而且训练阶段只需要存每个类别下每个特征的均值和标准差,预测时套高斯公式即可,没有迭代、没有梯度、没有调参,属于“一次遍历就能出结果”的模型。

项目正文里把流程写得很清楚:载入 CSV → 按比例拆训练/测试 → 按类别分组 → 算每类每特征的均值与标准差 → 用高斯概率密度算条件概率 → 连乘得类别概率 → 取最大者 → 对比真实标签算准确率。这条链路没有任何黑盒,每一行都能对应到公式,这正是它作为教学与二次开发基线的价值。

2.2 技术栈与运行环境

技术栈非常轻:Python 3 + 标准库csv、random、math,可视化部分用numpy、pandas、matplotlib,线性回归对比实验用scikit-learn。开发环境是 PyCharm Community Edition,硬件是 i7-8550U + 8GB 内存的普通笔记本,也就是说这套东西在任何一台能装 Python 的机器上都能跑,没有 GPU、没有数据库、没有服务端依赖。

组件用途是否必需
csv / random / math数据载入、随机拆分、高斯计算必需
numpy / pandas线性回归脚本的数据组织可视化脚本必需
matplotlib散点图与拟合直线可视化脚本必需
scikit-learnLinearRegression 与 load_diabetes可视化脚本必需

这里有个容易忽略的点:主分类脚本其实完全不依赖第三方库,只用标准库就能跑通。这意味着你可以把它拷到一台没装任何包的机器上验证算法逻辑,排除了“到底是算法错了还是库版本不对”的干扰,调试时这一点很省心。

2.3 目录与文件组织

项目本身没有复杂的工程结构,按功能拆成两个入口文件加一份数据:

project/ ├── naive_bayes_diabetes.py # 主分类脚本:载入、拆分、训练、预测、评估 ├── linear_regression_plot.py # 可视化脚本:逐特征线性回归 + 散点拟合图 └── diabetes.data.csv # 皮马印第安人糖尿病数据集(数值型,逗号分隔)

主脚本里所有函数都是模块级函数,没有类封装,if __name__ == '__main__':里串起完整流程。这种写法在工程上不算优雅,但对“读代码学算法”这个目标来说反而友好——从上往下读就是执行顺序。二次开发时我一般会先把这些函数收进一个GaussianNB类,再把数据路径、拆分比例、随机种子抽成配置,这一步后面第 5 章会具体讲。

3. 核心算法拆解:从 CSV 到准确率的六个函数

3.1 数据载入与类型转换

第一步是把 CSV 读成二维浮点列表。原始代码里路径写死成了绝对路径,还带中文目录,这是典型的“换台机器就跑不了”的写法,先看原版:

import csv def loadCsv(): # 原版:绝对路径 + 中文目录,换机器必挂 lines = csv.reader(open(r"D:\课程\大作业\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

逻辑说明:csv.reader逐行返回字符串列表,list()一次性物化成二维列表,然后逐元素float()转换。参数上唯一需要改的就是路径,我一般改成相对路径./diabetes.data.csv,或者用pathlib.Path(__file__).parent / "diabetes.data.csv",这样脚本挪到哪都能找到数据。另外原版在转换前有一行print打印整个数据集,调试时有用,正式跑一定要注释掉,否则几千行输出会把终端刷爆,也会拖慢启动。

3.2 随机拆分训练集与测试集

拆分用的是“从副本里随机弹出”的策略,而不是先打乱再切片:

import random 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%,这两个值都出现过,实际以__main__里的赋值为准。copy.pop(index)保证同一条样本不会同时进训练和测试,这是正确的。但这里有个隐患:没有设随机种子,每次运行拆分结果不同,准确率自然会在几个百分点内浮动,这就是我朋友说的“忽高忽低”。解决办法是在文件头加random.seed(42),或者在函数签名里加seed参数,保证实验可复现。参数建议:小数据集上 0.67~0.8 的训练占比比较稳,0.9 会让测试集只剩几十条,准确率方差更大。

3.3 按类别分组与统计量汇总

朴素贝叶斯训练阶段要的就是“每个类别下每个特征的均值和标准差”。先按标签分组:

def separateByClass(dataset): separated = {} for i in range(len(dataset)): vector = dataset[i] label = vector[-1] # 最后一列是类别 if label not in separated: separated[label] = [] separated[label].append(vector) return separated

vector[-1]取最后一列作为类别,这是整个项目里唯一约定“标签在末尾”的地方,改数据结构时务必同步。接着算均值和标准差:

import math def mean(numbers): return sum(numbers) / float(len(numbers)) def stdev(numbers): avg = mean(numbers) variance = sum([pow(x - avg, 2) for x in numbers]) / float(len(numbers) - 1) return math.sqrt(variance) def summarize(dataset): summaries = [(mean(attr), stdev(attr)) for attr in zip(*dataset)] del summaries[-1] # 去掉标签列对应的统计量 return summaries

zip(*dataset)是这里的关键技巧:把二维列表转置,得到“每个特征一列”,然后对每列求均值与标准差。del summaries[-1]删掉的是标签列,因为标签本身不该参与高斯计算。注意stdev用的是样本标准差(除以 n-1),样本量小时比总体标准差略大,属于合理选择。summarizeByClass只是把分组和汇总串起来,返回{类别: [(均值, 标准差), ...]}的字典,这个字典就是整个模型的“全部参数”,训练到此结束。

3.4 高斯概率密度与类别概率连乘

预测阶段对每个特征套高斯公式:

def calculateProbability(x, mean, stdev): exponent = math.exp(-(math.pow(x - mean, 2) / (2 * math.pow(stdev, 2)))) return (1 / (math.sqrt(2 * math.pi) * stdev)) * exponent

对应公式f(x) = 1/(σ√(2π)) · exp(-(x-μ)²/(2σ²))。参数说明:x是待预测样本在该特征上的取值,mean、stdev来自训练阶段该类别该特征的统计量。这里有个必须防的坑:如果某个特征在某个类别下所有样本取值相同,stdev会等于 0,除零直接抛异常。稳妥做法是在stdev返回前加一个下限,比如return max(math.sqrt(variance), 1e-9),或者对这类特征直接跳过。

连乘得到类别概率:

def calculateClassProbabilities(summaries, inputVector): probabilities = {} for classValue, classSummaries in summaries.items(): probabilities[classValue] = 1 for i in range(len(classSummaries)): mean, stdev = classSummaries[i] x = inputVector[i] probabilities[classValue] *= calculateProbability(x, mean, stdev) return probabilities

注意这里算的是“似然连乘”,没有乘先验概率,也没有除以证据因子。因为分母对所有类别相同,比较大小不影响结果;但如果两个类别样本数严重不均衡,忽略先验会让预测偏向多数类。二次开发时如果想更严谨,可以在循环前乘上P(classValue) = 该类样本数 / 总样本数,改动只有一行。

3.5 取最大概率与准确率评估

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 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

predict遍历字典找最大值,getAccuracy逐条比对真实标签与预测标签,返回百分比。主流程把这几步串起来:

if __name__ == '__main__': random.seed(42) # 建议加:固定拆分,结果可复现 splitRatio = 0.67 dataset = loadCsv() trainingSet, testSet = splitDataset(dataset, splitRatio) summaries = summarizeByClass(trainingSet) predictions = getPredictions(summaries, testSet) accuracy = getAccuracy(testSet, predictions) print("Accuracy: ", accuracy)

跑通后终端会打印一个准确率数字,通常在 70%~76% 区间波动,加种子后固定。这个数字不算高,但对朴素贝叶斯在这个数据集上的表现来说是正常水平,别指望调到 95%,那是过拟合或者数据泄漏的信号。

3.6 线性回归可视化脚本在做什么

第二个脚本和分类模型是两套东西,它用sklearn.datasets.load_diabetes载入另一份糖尿病数据(442 条、10 个特征、一个连续的目标值),然后对每个特征单独做一元线性回归,画散点加拟合直线,标题上带 R² 分数:

import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import load_diabetes from sklearn.linear_model import LinearRegression data_diabetes = load_diabetes() data = data_diabetes['data'] target = data_diabetes['target'] feature_names = data_diabetes['feature_names'] plt.figure(figsize=(12, 25)) for i, col in enumerate(feature_names): train_X = data[:, i].reshape(-1, 1) # 一维转二维,模型要求 model = LinearRegression() model.fit(train_X, target) score = model.score(train_X, target) # R² axes = plt.subplot(5, 2, i + 1) plt.scatter(train_X, target, s=8) k, b = model.coef_[0], model.intercept_ x = np.linspace(train_X.min(), train_X.max(), 100) plt.plot(x, k * x + b, c='red') axes.set_title(f"{col}: {score:.3f}") plt.tight_layout() plt.show()

逻辑说明:reshape(-1, 1)是因为LinearRegression要求二维输入;model.score返回的是 R²,越接近 1 说明单特征线性关系越强。从图上能直观看到 BMI 那一格的散点有明显斜率,而性别那一格几乎是一条水平线,这正好对应正文里“发病概率和性别无关、和 BMI 关系很大”的结论。参数上figsize和subplot(5, 2, ...)要匹配特征数量,特征多于 10 个时得改成6 x 2之类,否则图会挤在一起。

4. 跑起来与避坑:环境、路径、随机性三处最容易翻车

4.1 从零跑通的最小步骤

先把数据文件放到脚本同级目录,然后按顺序执行:

# 1. 建虚拟环境(可选但推荐) python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 2. 装可视化脚本需要的包(主脚本只用标准库) pip install numpy pandas matplotlib scikit-learn # 3. 跑分类主脚本 python naive_bayes_diabetes.py # 4. 跑可视化脚本 python linear_regression_plot.py

第 3 步终端输出Accuracy: xx.x即成功;第 4 步会弹出一个 5 行 2 列的子图窗口。如果只想验证算法,第 2 步可以跳过,主脚本照样能跑,这一点在排查“是不是库的问题”时很有用。

4.2 避坑与常见问题排查

现象一:FileNotFoundError,提示找不到 CSV。原因几乎都是原版写死的绝对路径在别的机器上不存在,或者中文目录在部分终端下编码异常。解决:改成相对路径,并用pathlib定位脚本所在目录,同时确认 CSV 文件名大小写一致。

现象二:每次运行准确率都不一样,差好几个点。原因是splitDataset用了random.randrange但没设种子,训练/测试划分每次不同。解决:在__main__开头加random.seed(42),或者把种子作为参数传进拆分函数,做对比实验时尤其要固定。

现象三:ZeroDivisionError或概率全为 0。前者是某特征在某类别下标准差为 0,后者是连乘太多小概率导致浮点下溢。解决:标准差加下限1e-9;连乘改成对数求和,即sum(math.log(p))再比较,数值稳定性会好很多。

现象四:可视化脚本报reshape相关错误。原因是把一维数组直接喂给了LinearRegression。解决:确认data[:, i].reshape(-1, 1)这一步没被删掉,sklearn 的模型接口统一要求二维特征矩阵。

现象五:图表中文标题显示成方块。matplotlib 默认字体不含中文。解决:要么标题全用英文特征名(推荐,feature_names本来就是英文),要么显式设置plt.rcParams['font.sans-serif'] = ['SimHei']并处理负号显示。

提示:改完路径和种子后,先把主脚本单独跑三遍,确认准确率稳定再动可视化脚本,两个脚本分开验证能省掉一半排查时间。

5. 二次开发:把脚本收成类、补上先验与对数概率

5.1 从函数式脚本到可复用类

原版所有函数平铺在模块里,状态靠参数传递,想同时持有两个模型就得复制变量。我一般第一步先收成一个类,把训练产物summaries和类别先验挂在实例上:

import math, random class GaussianNB: def __init__(self, eps=1e-9): self.eps = eps self.summaries = {} self.priors = {} def fit(self, dataset): separated = {} for row in dataset: separated.setdefault(row[-1], []).append(row) total = len(dataset) for label, rows in separated.items(): self.priors[label] = len(rows) / total # 补上先验 cols = list(zip(*rows)) stats = [] for col in cols[:-1]: # 跳过标签列 mu = sum(col) / len(col) var = sum((x - mu) ** 2 for x in col) / (len(col) - 1) stats.append((mu, max(math.sqrt(var), self.eps))) self.summaries[label] = stats return self def _log_prob(self, x, mu, sigma): return -math.log(sigma * math.sqrt(2 * math.pi)) - ((x - mu) ** 2) / (2 * sigma ** 2) def predict_one(self, row): best_label, best_score = None, -float("inf") for label, stats in self.summaries.items(): score = math.log(self.priors[label]) # 先验取对数 for i, (mu, sigma) in enumerate(stats): score += self._log_prob(row[i], mu, sigma) # 对数似然累加 if score > best_score: best_score, best_label = score, label return best_label

改动点有三个:先验概率进模型、连乘换对数累加、标准差加下限。对数化之后不再有下溢风险,先验让类别不均衡时预测更合理。验证方法和原版一致,用同一份拆分数据对比准确率,正常情况下差异在 1~2 个百分点内,如果差很多,先检查priors是不是算错了。

5.2 值得往上叠的几个方向

第一是交叉验证替代单次拆分,把splitDataset换成 k 折,准确率报均值加标准差,比单次数字可信得多。第二是特征标准化,虽然高斯贝叶斯对量纲不敏感(每个特征独立算均值和方差),但做特征筛选时标准化后的系数更好比较。第三是把可视化脚本里的单特征 R² 做成特征重要性排序,用来决定哪些特征值得保留。第四是加一个简单的命令行入口,用argparse接收数据路径和拆分比例,避免每次改代码。

改造点改动位置预期收益
固定随机种子__main__或拆分函数结果可复现
对数概率calculateClassProbabilities避免下溢
类别先验训练阶段不均衡数据更稳
k 折交叉验证主流程评估更可信

5.3 一个我踩过的验证习惯

这个项目最容易被忽略的不是算法,而是“改了参数之后到底有没有变好”这件事本身没法判断——因为每次拆分不同、没有基线、没有固定种子。我最早做类似的小模型实验时,改完先验、改完对数化,跑出来准确率从 73% 变成 71%,一度以为改坏了,回头一查是随机拆分换了测试集。从那以后我每次动模型参数前,都强制先把数据拆分结果落盘成固定的训练/测试文件,所有对比都在这两份文件上跑,改完再换种子复验一遍。这个习惯看着笨,但能省掉大量“玄学调参”的时间。希望这个拆解能帮你把这个小系统跑顺,也能顺着这几个改造点把它变成自己实验的基线。

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

返回列表