☰
朴素贝叶斯糖尿病预测:高斯分布、概率比与工程实践真相
2026/10/31 9:01:54 网站建设 项目流程

简介:本资源是一份面向机器学习初学者与模式识别课程学习者的实践型技术文档,聚焦于利用朴素贝叶斯分类器实现糖尿病早期风险预测。文档完整呈现了从理论原理(贝叶斯定理与高斯概率密度函数推导)、数据准备(Pima Indians Diabetes数据集,含442例9维生理指标)、特征处理(均值/方差/标准差计算、按类别汇总统计)、Python代码实现(loadCsv、SplitDataset、calculateProbability等核心函数详解)到模型评估(准确率计算与图表可视化)的全流程设计与实现逻辑。资源为单文件PDF,大小771KB,内容结构清晰,含系统流程图、关键代码截图、公式推导及结果分析,适合作为课程大作业参考、算法复现范本或贝叶斯分类入门实践材料。目前已有237人学习下载,涵盖高校研究生模式识别课程实践环节,具备较强的教学适配性与工程可复现性。

1. 朴素贝叶斯预测糖尿病:为什么用高斯分布算“概率”,却从不真正计算分母?

你训练一个糖尿病预测模型,输入是年龄、BMI、血糖等9个数值,输出是“5年内发病(1)”或“不发病(0)”。代码跑完,准确率82.3%——但你有没有想过:那个predict()函数里,probabilities[classValue] *= calculateProbability(...)连乘出来的,根本不是数学意义上的概率?它连归一化都没有做,更没算贝叶斯公式里的分母 P(x);而最终只比大小就敢叫“预测”,凭什么不会翻车?这不是玄学,而是朴素贝叶斯最被误解的底层契约:它压根不关心绝对概率值,只依赖类间概率比的单调性。这个系统能 work,不是因为高斯分布“拟合了真实数据”,而是因为当所有特征近似独立、且在两类中都呈单峰分布时,用高斯密度函数替代真实条件概率密度,其比值方向(即 argmax)大概率保持不变。本文就带你撕开这层纸——从calculateProbability()里那行exponent = math.exp(-(math.pow(x-mean,2)/(2*math.pow(stdev,2))))开始,一路下钻到浮点精度如何影响类别判决、为什么splitRatio=0.67比0.9更鲁棒、以及线性回归图里那条红色拟合线,其实正在悄悄背叛贝叶斯假设。


2. 贝叶斯公式的工程变形:从 P(C|x) 到 logP(C) + ΣlogP(xᵢ|C)

2.1 为什么跳过分母 P(x)?——分类任务的本质是排序,不是度量

贝叶斯定理的标准形式是:
$$ P(C_k \mid \mathbf{x}) = \frac{P(\mathbf{x} \mid C_k) P(C_k)}{P(\mathbf{x})} $$
其中 $\mathbf{x} = [x_1, x_2, ..., x_n]$ 是9维特征向量,$C_k \in {0,1}$ 是类别。
但predict()函数里根本没有出现 $P(\mathbf{x})$。原因很实在:对同一个输入 $\mathbf{x}$,分母 $P(\mathbf{x})$ 对所有类别 $C_k$ 都相同。因此比较 $P(C_0 \mid \mathbf{x})$ 和 $P(C_1 \mid \mathbf{x})$,等价于比较分子 $P(\mathbf{x} \mid C_0) P(C_0)$ 与 $P(\mathbf{x} \mid C_1) P(C_1)$。这就是所谓“最大后验估计(MAP)”的工程落地形态。

但问题还没完:P(x|C_k)是9维联合概率密度,现实中无法直接估计。朴素贝叶斯的“朴素”二字,就落在这里——它强行假设: $$ P(\mathbf{x} \mid C_k) = \prod_{i=1}^{n} P(x_i \mid C_k) $$ 即各特征在给定类别下条件独立。这个假设在医学数据中显然不成立(比如 BMI 和血压高度相关),但它让原本需要估计 $O(d^n)$ 参数的问题,降维到只需估计 $O(d \cdot n)$ 个一维分布——这是可计算性的生死线。

提示:separateByClass()返回的separated字典,就是为每个类别 $C_k$ 单独维护一份数据子集,后续所有mean()、stdev()都是在该子集上计算。这意味着:模型没有全局统计量,只有“类内局部统计”。这也是它对类别不平衡敏感的根源。

2.2 高斯分布不是选择,而是妥协:当连续特征遇上离散分类

calculateProbability(x, mean, stdev)实现的是高斯概率密度函数(PDF):

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

注意:这是密度函数值,不是概率!PDF 在单点取值可以 >1,且积分才为1。但代码里直接把它当“概率”连乘——这合法吗?

合法,因为:

  • 我们只关心不同类别下的 PDF 值之比;
  • 对同一 $x_i$,若 $C_0$ 和 $C_1$ 的 $\mu, \sigma$ 不同,则 $\frac{f_{C_0}(x_i)}{f_{C_1}(x_i)}$ 的符号和单调性,与真实条件密度比高度一致;
  • 更关键的是:所有连乘项都带相同的常数因子 $\frac{1}{\sqrt{2\pi}\sigma}$,它们在取 argmax 时会被约掉。

验证一下:设 $f_C(x) = \frac{1}{\sqrt{2\pi}\sigma_C} \exp\left(-\frac{(x-\mu_C)^2}{2\sigma_C^2}\right)$,则
$$ \log f_C(x) = -\log(\sqrt{2\pi}\sigma_C) - \frac{(x-\mu_C)^2}{2\sigma_C^2} $$
所以 $\log P(C_k|\mathbf{x}) \propto \log P(C_k) + \sum_i \log f_{C_k}(x_i)$,而log P(C_k)就是len(separated[C_k]) / len(dataset)的对数(代码中隐含为先验概率)。predict()虽没显式算 log,但连乘等价于加 log——只是用浮点乘法代替了 log 加法,牺牲了数值稳定性换来了简单性。

2.3 先验概率藏在哪?——separateByClass()的沉默契约

看这段代码:

def separateByClass(dataset): separated = {} for i in range(len(dataset)): vector = dataset[i] if (vector[-1] not in separated): separated[vector[-1]] = [] separated[vector[-1]].append(vector) return separated

vector[-1]是最后一列,即标签(0 或 1)。separated字典的 key 就是类别,value 是该类别所有样本列表。那么先验概率 $P(C_k)$ 怎么体现?
答案在getPredictions()调用链的上游:summarizeByClass(trainingSet)内部调用了separateByClass(trainingSet),而trainingSet来自splitDataset()——先验概率完全由训练集中各类别的样本数量比例决定。如果训练集里 0 类有 300 个、1 类只有 50 个,那么 $P(C=0)=0.857$,$P(C=1)=0.143$。这个先验会通过summaries中每个类别的样本数,隐式影响后续所有概率计算(虽然calculateClassProbabilities()没显式乘先验,但predict()中probabilities[classValue]的初始值 1,实际应理解为P(C_k)的缩放因子——因为所有calculateProbability返回值都已包含该类别的归一化常数)。

这就是为什么splitRatio=0.67(即训练集占 67%)比原文说的 “90% 训练” 更合理:留出足够测试样本,才能让separated中的类别分布更接近真实世界,避免先验偏差放大误判。


3. 高斯假设的脆弱性:当标准差趋近于零时,你的模型正在崩溃

3.1stdev()的致命缺陷:无样本方差保护

看stdev()函数实现:

def stdev(numbers): avg = mean(numbers) variance = sum([pow(x-avg,2) for x in numbers])/float(len(numbers)-1) return math.sqrt(variance)

问题来了:如果某个特征在某一类别中所有值完全相同(例如s5在C=1类中全是 120.0),则variance=0,stdev=0。代入calculateProbability():

return (1 / (math.sqrt(2*math.pi) * 0)) * exponent # ZeroDivisionError!

这绝非理论风险。Pima 数据集中,sex特征实际只有 0/1 两个值,在C=1子集中若恰好全为 0,则stdev=0。而原始代码没有任何防御机制。

3.2 浮点下溢:连乘 9 个 <1 的数,结果可能变成 0.0

calculateClassProbabilities()中:

probabilities[classValue] *= calculateProbability(x, mean, stdev)

每个calculateProbability()返回值通常在 $10^{-2} \sim 10^{-1}$ 量级(高斯 PDF 在均值处最大值为 $1/(\sqrt{2\pi}\sigma)$,若 $\sigma=10$,则峰值≈0.04)。9 个这样的数连乘,结果可能低至 $10^{-18}$。而 Python float 的最小正数约为 $2.2 \times 10^{-308}$,看似安全?错。当值小于 $10^{-324}$ 时,会下溢为0.0。一旦某个类别的概率变成 0,argmax必然失败——因为另一个类别再小也是正数。

3.3 特征尺度灾难:单位不统一如何扭曲高斯密度?

摘要明确指出:“属性之间的单位不一样”。例如:

  • age: 单位是“岁”,范围 21–81;
  • bmi: 单位是 kg/m²,范围 18–67;
  • glucose: 单位是 mg/dL,范围 0–200。

高斯 PDF 的形状由 $\sigma$ 决定。若某特征单位大(如 glucose),其 $\sigma$ 天然更大,导致 PDF 更“扁平”,密度值更小;反之单位小的特征(如sex,但它是离散的,此处暂忽略),$\sigma$ 小,PDF 更“尖锐”,密度值更大。连乘时,尺度大的特征贡献的密度值天然被压缩,尺度小的被放大——模型在无意识中给某些特征赋予了更高权重。这违背了“所有特征同等重要”的朴素假设。

避坑 / 常见问题 / 排查

  1. 现象:模型在测试集上准确率忽高忽低(如一次 85%,下次 62%),且每次运行splitDataset()后结果不同。
    原因:splitDataset()使用random.randrange()随机打乱,但未设置random.seed()。训练集组成随机波动,导致separated中各类别样本数剧变,先验概率失真。
    解决:在if __name__ == '__main__':开头添加random.seed(42),确保可复现。

  2. 现象:程序运行报ZeroDivisionError: float division by zero,定位到calculateProbability()第二行。
    原因:某类别下某特征所有值相同,stdev(numbers)返回 0.0。
    解决:修改stdev(),加入方差下限保护:

    def stdev(numbers): if len(numbers) < 2: return 1e-6 # 防止单样本或空样本 avg = mean(numbers) variance = sum([pow(x-avg,2) for x in numbers]) / float(len(numbers)-1) return max(math.sqrt(variance), 1e-6) # 强制 std >= 1e-6
  3. 现象:calculateClassProbabilities()返回的probabilities字典中,两个类别的值都是0.0。
    原因:连乘导致下溢。例如glucose=180在C=1类中均值=120、std=30,则calculateProbability(180,120,30) ≈ 0.0044;9 个类似值相乘 ≈1.5e-22,在部分 Python 环境下显示为0.0。
    解决:改用对数空间计算。将probabilities[classValue]初始化为math.log(prior_prob),每次+= math.log(calculateProbability(...)),最后用math.exp()还原(仅在必要时)。核心修改:

    def calculateClassProbabilities(summaries, inputVector): probabilities = {} for classValue, classSummaries in summaries.items(): # prior_prob = len(separated[classValue]) / len(trainingSet) probabilities[classValue] = math.log(prior_prob) # 先验对数 for i in range(len(classSummaries)): mean, stdev = classSummaries[i] x = inputVector[i] prob = calculateProbability(x, mean, stdev) probabilities[classValue] += math.log(max(prob, 1e-300)) # 防 log(0) return probabilities
  4. 现象:模型对bmi特征异常敏感,微调bmi值 1 个单位,预测结果就翻转。
    原因:bmi范围(18–67)远小于glucose(0–200),其 std 更小,高斯 PDF 更尖锐,calculateProbability()返回值更大,在连乘中主导地位过强。
    解决:必须做特征标准化!在summarize()前,对整个trainingSet每列减去均值、除以 std:

    def standardize_dataset(dataset): arr = np.array(dataset) means = np.mean(arr[:, :-1], axis=0) # 排除标签列 stds = np.std(arr[:, :-1], axis=0) stds = np.where(stds == 0, 1, stds) # 防 std=0 arr[:, :-1] = (arr[:, :-1] - means) / stds return arr.tolist()

4. 数据集真相:Pima Indians Diabetes 不是“442 个病人”,而是 768 行带缺失值的脏数据

4.1 文件名陷阱:diabetes.data.csvvsload_diabetes()

项目正文反复提到 “442 个皮马印第安人”,并两次引用from sklearn.datasets import load_diabetes。这是严重混淆!

  • 真正的 Pima Indians Diabetes 数据集:由美国国立糖尿病、消化和肾脏疾病研究所(NIDDK)收集,共768 行,8 个特征 + 1 个标签(Outcome),不是 442 行。442 是 sklearn 自带的load_diabetes()回归数据集(预测病情进展程度,非二分类),其特征是 10 个生理指标(age,sex,bmi,bp,s1-s6),目标变量target是定量数值。
  • 项目中loadCsv()读取的"diabetes.data.csv",实为经典 Pima 数据集(768 行),但文件路径写成"D:\研究生课程\...",且代码中print('dataset')后注释掉输出——作者很可能从未检查过实际数据维度。

验证方法:下载标准 Pima 数据集(UCI ML Repository),用 pandas 读取:

import pandas as pd df = pd.read_csv("pima-indians-diabetes.data", header=None) print(df.shape) # 输出 (768, 9) print(df.isnull().sum()) # 会发现 glucose, bloodpressure 等列有 0 值,实为缺失值标记

4.2 “0 值即缺失”:医学数据的潜规则

Pima 数据集中,glucose=0、bloodpressure=0、skinthickness=0、insulin=0、bmi=0均不可能是真实生理值(空腹血糖 <70 mg/dL 才算低血糖,0 是仪器故障或未测)。这些 0 是 UCI 数据集约定的缺失值标记。但原文代码loadCsv()直接float(x)转换,把 0 当作有效值参与mean()、stdev()计算——这会让均值被严重拉低,标准差被虚假放大。

例如,若某类中glucose有 50 个真实值(均值 120)+ 10 个0,则计算出的均值变为 $ (50×120 + 10×0)/60 = 100 $,std 也因 0 的引入而增大。calculateProbability(120, 100, std)的值,与真实分布相比已面目全非。

4.3 特征工程黑洞:sex是离散特征,却被当连续变量处理

Pima 数据集的sex列并不存在!标准 Pima 特征是:Pregnancies,Glucose,BloodPressure,SkinThickness,Insulin,BMI,DiabetesPedigreeFunction,Age,Outcome。原文描述中“性别”纯属虚构。而load_diabetes()中的sex是编码后的数值(-1 或 +1),也不是原始性别。模型在用根本不存在的特征做预测。

更致命的是:sex若存在,应是离散类别(0/1),但calculateProbability()强行用高斯分布拟合——对二值变量,高斯 PDF 在x=0和x=1处的值毫无生物学意义,纯粹是数值游戏。

避坑 / 常见问题 / 排查

  1. 现象:mean()计算出glucose均值为 100 左右,但医学常识知道 Pima 群体糖尿病高发,空腹血糖均值应 >120。
    原因:数据中大量glucose=0被当作有效值。
    解决:加载后立即清洗:

    def loadCsv(): df = pd.read_csv("pima-indians-diabetes.data", header=None) # 将 0 值替换为 NaN,再用同类均值填充 cols_with_zeros = [1,2,3,4,5] # Glucose, BP, Skin, Insulin, BMI 列索引 for col in cols_with_zeros: df[col] = df[col].replace(0, np.nan) # 按 Outcome 分组填充 df = df.groupby(8).apply(lambda x: x.fillna(x.mean())).reset_index(drop=True) return df.values.tolist()
  2. 现象:summarizeByClass()计算出某类SkinThickness的stdev=50,但该特征正常范围是 10–50 mm,std 不可能达 50。
    原因:SkinThickness=0缺失值未清洗,拉大了方差。
    解决:同上,清洗后再summarizeByClass()。

  3. 现象:load_diabetes()加载的数据shape=(442, 10),但predict()报IndexError: list index out of range。
    原因:load_diabetes().data是 442×10 矩阵,load_diabetes().target是 442 维向量,但原文代码试图用vector[-1]取标签——而load_diabetes()没有内置标签列,target是独立数组。
    解决:彻底弃用load_diabetes(),专注处理真实的 Pima CSV。若需用 sklearn,应:

    from sklearn.datasets import fetch_openml pima = fetch_openml('diabetes', version=1, as_frame=True, parser='auto') # 但注意:此数据集是二分类,需确认 frame 结构

5. 线性回归图的幻觉:为什么用LinearRegression画s1vstarget,却在背叛贝叶斯?

5.1 图表目的错位:探索性分析 ≠ 模型解释

项目正文 1.6 节末尾,用LinearRegression对每个特征单独建模,并画出散点图+拟合直线。例如:

train_X = df.loc[:, 'bmi'].values.reshape(-1, 1) linear_model.fit(train_X, train_Y) plt.scatter(train_X, train_Y) plt.plot(x, y, c='red') # y = k*x + b

这里train_Y是load_diabetes().target(定量病情进展值),而贝叶斯模型预测的是Outcome(二分类)。两套数据、两个任务、两种目标,却被混为一谈。用回归拟合线解释分类模型,如同用温度计读数解释血压计原理。

5.2 斜率k的误导性:s1的score=0.02意味着什么?

代码中score = linear_model.score(train_X, train_Y)计算的是 $R^2$ 决定系数。若s1(tc, total cholesterol)的score=0.02,说明该单一特征只能解释目标变量 2% 的方差——这恰恰证明s1对糖尿病进展预测能力极弱。但图表中那条红色直线,视觉上却给人“强相关”错觉。线性回归图在此场景下唯一价值,是暴露特征与目标间的非线性关系:若散点图明显呈 U 型或 S 型,说明高斯朴素贝叶斯的线性判别边界(由高斯密度比导出)必然失效。

5.3 真正该画的图:后验概率热力图

一个诚实的贝叶斯模型可视化,不该是bmivstarget散点图,而应是:

  • 固定其他 8 个特征为均值,画bmi(横轴)vsage(纵轴)的二维网格;
  • 对每个(bmi, age)点,计算P(C=1|bmi,age,...);
  • 用颜色深浅表示概率值,叠加等高线标出决策边界(P(C=1)=0.5)。

这种图才能回答:“当 BMI=35、年龄=50 时,模型有多确信会发病?”——而原文的线性回归图,只回答了“BMI 每增加 1 单位,病情进展平均增加多少点”,与分类任务无关。

避坑 / 常见问题 / 排查

  1. 现象:LinearRegression().score()对age返回0.05,对bmi返回0.12,于是结论“BMI 影响最大”。
    原因:$R^2$ 在单变量回归中,等于相关系数平方。bmi与target相关性略高,但target是回归目标,与二分类Outcome无直接映射。
    解决:放弃load_diabetes(),改用 Pima 的Outcome列做逻辑回归(LogisticRegression),再看coef_:

    from sklearn.linear_model import LogisticRegression lr = LogisticRegression() lr.fit(X_train, y_train) # y_train 是 0/1 print(lr.coef_[0]) # 各特征对 log-odds 的贡献
  2. 现象:plt.scatter(train_X, train_Y)中,train_Y是load_diabetes().target(连续值),但图标题写“糖尿病发病人数”。
    原因:概念偷换。target是病情进展量化值(如 151 表示严重),不是“是否发病”。
    解决:若坚持用load_diabetes(),应明确说明这是回归任务;若做分类,必须用 Pima 的Outcome。

  3. 现象:for i, col in enumerate(df.columns):循环中,df.columns包含'age','sex', ...,但 Pima 数据集无'sex'列,导致KeyError。
    原因:df = pd.DataFrame(data, columns=feature_names)中feature_names来自load_diabetes(),与 Pima 列名不匹配。
    解决:删除load_diabetes()相关代码,统一使用 Pima 的列名:

    pima_columns = ['Pregnancies','Glucose','BloodPressure','SkinThickness', 'Insulin','BMI','DiabetesPedigree','Age','Outcome'] df = pd.DataFrame(dataset, columns=pima_columns)

6. 设计取舍的代价:当“朴素”成为枷锁,我们还能信贝叶斯吗?

6.1 为什么不用更强大的模型?——资源约束下的理性投降

项目硬件环境是 i7-8550U + 8GB RAM,软件是 PyCharm CE。在这种配置下,训练一个 XGBoost 或小型神经网络,不仅耗时(交叉验证、超参搜索),而且需要额外依赖(xgboost,tensorflow)。而朴素贝叶斯:

  • 零参数:无需调n_estimators,learning_rate,hidden_layer_sizes;
  • 零迭代:summarizeByClass()一趟遍历即可完成所有统计量计算;
  • 零内存压力:只存均值、标准差、先验计数,内存占用恒定 O(n_features × n_classes)。

这是一种清醒的工程权衡:在数据量小(768 行)、特征少(8 维)、部署环境受限(单机 CPU)的场景下,用可解释性换一点准确率,是临床辅助系统的合理选择。82.3% 的准确率虽不如现代模型的 90%+,但它每一步计算都透明——医生能指着calculateProbability(glucose=190, mu=120, std=30)说:“看,这个高血糖值让发病概率飙升”,而无法向梯度提升树解释“为什么第 17 棵树的第 3 个分裂点选在这里”。

6.2 朴素假设的补丁:如何在不改算法的前提下缓解“不独立”?

P(x_i,x_j|C) ≠ P(x_i|C)P(x_j|C)是朴素贝叶斯的阿喀琉斯之踵。Pima 数据中,Glucose和Insulin高度相关(胰岛素抵抗者血糖必高)。强行独立假设,会让模型低估联合出现高血糖+高胰岛素的风险。

一个轻量级补丁是特征组合(Feature Crossing):人工构造新特征,如Glucose_Insulin_Ratio = Glucose / (Insulin + 1)(+1 防 0)。这个新特征捕捉了二者关系,且仍可用高斯分布建模。代码只需在loadCsv()后插入:

# 假设 dataset 是 list of lists,每行 9 个值 for row in dataset: glucose = row[1] insulin = row[4] ratio = glucose / (insulin + 1) row.append(ratio) # 新增第 10 列

然后在summarizeByClass()中自动包含它。这比换模型成本低得多,且不破坏原有流程。

6.3 准确率之外的生死线:校准(Calibration)才是临床可信度的基石

getAccuracy()只告诉你“猜对了多少次”,但临床决策需要知道:“当模型说‘发病概率 85%’时,真实发病率真的是 85% 吗?” 这就是概率校准。一个未校准的贝叶斯模型,可能对所有预测都输出P=0.9或P=0.1,准确率 82% 却毫无指导意义。

验证方法:将测试集预测概率分 10 组(0–0.1, 0.1–0.2, ..., 0.9–1.0),计算每组内真实阳性率(fraction of positives)。理想情况是散点落在 y=x 线上。Pima 数据上,朴素贝叶斯常出现“过度自信”(预测 0.9 的组,真实阳性率仅 0.7)。

校准方案(无需重训):

  • Platt Scaling:用测试集预测概率训练一个 logistic 回归,映射到校准后概率;
  • Isotonic Regression:非参数方法,强制概率-真实率单调。

sklearn 提供现成工具:

from sklearn.calibration import CalibratedClassifierCV from sklearn.naive_bayes import GaussianNB gnb = GaussianNB() calibrated_gnb = CalibratedClassifierCV(gnb, method='isotonic') calibrated_gnb.fit(X_train, y_train) prob_true = calibrated_gnb.predict_proba(X_test)[:, 1]

这才是把“82.3% 准确率”转化为“医生敢信的概率值”的关键一步。

从那以后我每次实现朴素贝叶斯,都强制走三步:

  1. 用pandas加载后立刻df.describe()查 0 值和缺失;
  2. 在calculateProbability()里加math.log()和max(prob, 1e-300)防下溢;
  3. 训练完立刻用CalibratedClassifierCV包一层,再画 reliability diagram。
    不是为了炫技,而是因为当输出是“患者未来五年发病概率”,每一个小数点后的数字,都该经得起推敲。希望帮到你。

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

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

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

立即咨询