基于朴素贝叶斯的幽门螺杆菌筛查:Python实现与调参实战
2026/9/15 15:42:04 网站建设 项目流程

上个月有个做体检系统集成测试的朋友找到我,问能不能用Python快速搭一个模型,根据常规体检指标判断病人是否感染幽门螺杆菌。说实话这种需求的难点不在模型,而在于把数据和问题对齐。我最后用了朴素贝叶斯分类模型,原因是数据量不大、特征连续、二分类语义清晰,而且模型结果可以解释给非技术同事听。整轮跑下来,在测试集上准确率接近80%,把阈值往召回率方向调之后,阳性召回率能到85%以上。对一台没有GPU的轻量服务来说,这个方案相当实用。

这篇文章会把完整思路、数据处理方式、Python实现和调试过程写清楚。如果你刚好在学Python机器学习,想动手做一次完整的分类任务,或者被问到"朴素贝叶斯到底怎么落地",可以直接参考这篇的内容。

1. 幽门螺杆菌筛查问题为什么适合朴素贝叶斯

1.1 筛查场景的特征形态

幽门螺杆菌的规范诊断通常依赖碳13/碳14呼气试验、胃镜活检或粪便抗原检测,这些方法准确但需要专门设备或操作流程。而在很多体检场景里,我们手头只有血常规和生化数据,比如白细胞计数、血红蛋白、胃蛋白酶原I/II、年龄、性别等。如果能在这些常规指标和感染状态之间找到一个可解释的映射,就能在正式检查前做一轮分流:模型预测风险较高的人优先做呼气试验,风险较低的人可以暂缓。

这种问题本质是二分类:样本特征是一串连续型数值,标签是0或1,代表未感染和感染。数据量往往只有几百到几千条,特征维度在5到10个左右,放在深度学习里属于"杀鸡用牛刀",但放在传统机器学习里恰好是朴素贝叶斯的舒适区。

1.2 贝叶斯公式在感染判断中的表达

朴素贝叶斯的核心是先算后验概率:给定一组特征X时,病人属于感染类别y=1的概率有多大。用公式写就是:

P(y=1 | X) = P(X | y=1) × P(y=1) / P(X)

其中P(y=1)是先验概率,表示在历史数据中感染者的比例;P(X | y=1)是似然,表示在已知感染的人群里看到这组特征的可能性;分母P(X)对所有类别都一样,比较时可以不care。朴素贝叶斯的"朴素"体现在它假设了在各个类别内部,不同特征之间条件独立,所以P(X | y)可以拆成每个特征单独概率密度的连乘。

对连续型特征,最常用的做法是假设每个特征在某个类别下服从正态分布,也就是高斯朴素贝叶斯。模型训练时只需要统计每个类别下每个特征的均值和方差,预测时把新样本带入高斯概率密度函数算出似然,再乘以先验概率,最后比较哪个类别的后验概率更大。

1.3 为什么没有改用逻辑回归或随机森林

我也考虑过逻辑回归和随机森林。逻辑回归的优点是特征系数能直接反映方向,但它在小样本且特征有缺失时对异常值更敏感,调参空间也更多。随机森林在小数据上容易过拟合,训练出的模型解释起来不像贝叶斯那么直接。朴素贝叶斯有两个很实际的优势:一是训练极快,整个模型就是一堆均值和方差,几乎没有可调的超参数;二是预测过程可分解,哪项特征拉高了感染概率、哪项特征拉低了,都能回看。

当然它也有短板,最关键的就是条件独立假设在医学指标里并不完全成立,比如胃蛋白酶原I和II本身就高度相关。这个问题我在第5章会专门展开。总体而言,当目标是"快速做出一个能用的筛查模型"而不是"刷比赛榜单"时,朴素贝叶斯是性价比非常高的起点。

2. 数据准备:从体检指标到可训练样本

2.1 特征选择与标签定义

做这个项目时,我并没有真的拿到医院脱敏数据,而是参考公开文献里幽门螺杆菌相关指标的正常范围,用模拟方式构造了一份实验数据集。这里要强调:如果你要复现到真实场景,特征选择和标签定义必须和检验科确认,不能拍脑袋。

我选了这样几个特征:

  • 年龄:连续值,单位岁
  • 性别:离散值,转为0/1
  • BMI:连续值
  • 白细胞计数WBC:连续值,单位10^9/L
  • 血红蛋白HGB:连续值,单位g/L
  • 胃蛋白酶原I(PGI):连续值,单位μg/L
  • 胃蛋白酶原II(PGII):连续值,单位μg/L

标签字段是infection,1表示检测确认感染,0表示未感染。在我的模拟数据里,感染概率根据这些特征做逻辑函数映射,这样生成的数据内部存在一定的非线性关系,又不会像真实数据那样脏乱。

模拟数据的生成代码如下:

import numpy as np import pandas as pd rng = np.random.default_rng(42) n_samples = 2000 age = rng.normal(50, 12, n_samples) bmi = rng.normal(24, 4, n_samples) wbc = rng.normal(6.5, 1.8, n_samples) hgb = rng.normal(140, 15, n_samples) pgi = rng.normal(70, 25, n_samples) pgii = rng.normal(15, 8, n_samples) gender = rng.binomial(1, 0.5, n_samples) # 构造感染概率:此处仅用于模拟一份带结构的数据集 logit = (-0.02 * hgb + 0.05 * wbc + 0.03 * pgi - 0.10 * pgii + 0.01 * age + 0.2 * gender - 1.0) infection_prob = 1 / (1 + np.exp(-logit)) infection = rng.binomial(1, infection_prob) df = pd.DataFrame({ 'age': age, 'gender': gender, 'bmi': bmi, 'wbc': wbc, 'hgb': hgb, 'pgi': pgi, 'pgii': pgii, 'infection': infection })

生成的数据有2000条,正负样本比例大致平衡。实际项目里如果感染者只占10%,那就要额外处理类别不平衡,这一点后面会说到。

2.2 缺失值、异常值处理方式

真实体检数据必然有缺失。我常用的规则很简单:

  • 缺失率超过30%的特征直接删除,因为补出来的信息价值不大。
  • 缺失率较低的连续特征,先看分布是否接近正态;接近正态用均值填充,偏态明显用中位数填充。
  • 性别这类离散特征用众数填充。

异常值方面,体检数据里偶尔会出现录入错误,比如血红蛋白写成1400,白细胞写成65。我会用分位数截断:把超过99.5%分位数或低于0.5%分位数的值替换成边界值,避免一个异常点把某类别的方差拉得过大。不要直接删行,除非你能确认是明显错误,否则容易丢掉真实的高危样本。

2.3 划分数据集与标准化

划分数据时我用了分层抽样,确保训练集和测试集中的感染者比例一致。测试集比例设为30%,并固定random_state方便复现。

高斯朴素贝叶斯本身对数据尺度不敏感,但标准化仍然有好处。一是特征量级差异较大时,概率密度计算出来的数值极差很大,对数运算更稳定;二是代码逻辑更通用,后续换成逻辑回归或SVM也能直接用。标准化时要注意一个坑:只能用训练集的均值和标准差去transform测试集,不能拿全量数据fit后再划分,否则会造成信息泄露,评估结果会偏乐观。

from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler feature_cols = ['age', 'gender', 'bmi', 'wbc', 'hgb', 'pgi', 'pgii'] X = df[feature_cols].values y = df['infection'].values X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test)

3. Python实现:手写高斯朴素贝叶斯分类器

3.1 为什么先手写而不是直接调sklearn

很多人一上来就from sklearn.naive_bayes import GaussianNB,这没错,但为了讲清楚原理,我先自己写了一个简化版。手写一遍能让你看到三个关键点:

  • 训练阶段其实只算了每类的均值、方差、先验概率;
  • 预测阶段用的是对数概率,防止连续特征连乘后数值下溢到0;
  • 方差需要加一个平滑项,否则某个类别里特征完全相同时方差为0,概率密度计算会直接除零。

这三个点在sklearn源码里都被封装好了,不手写一遍很难理解为什么预测结果有时候看起来"过度自信"。

3.2 核心代码与逐步解释

下面这个类实现了高斯朴素贝叶斯的训练和预测。

import numpy as np class GaussianNaiveBayes: def __init__(self, var_smoothing=1e-6): self.var_smoothing = var_smoothing def fit(self, X, y): self.classes_ = np.unique(y) self.means_ = {} self.vars_ = {} self.priors_ = {} for c in self.classes_: X_c = X[y == c] self.priors_[c] = len(X_c) / len(y) self.means_[c] = X_c.mean(axis=0) # 加平滑项,避免方差为0 self.vars_[c] = X_c.var(axis=0) + self.var_smoothing return self def _log_gaussian_pdf(self, x, mean, var): return -0.5 * np.log(2 * np.pi * var) - (x - mean) ** 2 / (2 * var) def predict_proba(self, X): log_probs = np.zeros((X.shape[0], len(self.classes_))) for i, c in enumerate(self.classes_): log_likelihood = np.sum( self._log_gaussian_pdf(X, self.means_[c], self.vars_[c]), axis=1 ) log_probs[:, i] = np.log(self.priors_[c]) + log_likelihood # 将log概率转成归一化概率 max_log = log_probs.max(axis=1, keepdims=True) exp_log = np.exp(log_probs - max_log) return exp_log / exp_log.sum(axis=1, keepdims=True) def predict(self, X): proba = self.predict_proba(X) return self.classes_[np.argmax(proba, axis=1)]

核心逻辑解释一下。fit方法里,我对每个类别分别统计均值和方差,所以模型大小只取决于特征维度,和训练样本数无关。predict_proba里没有直接算高斯概率密度的原始值,而是算了log后的值。原因是假设有7个特征,每个特征的似然在0.01左右,连乘后是10的负14次方,多个样本累积下来浮点数精度很容易出问题;取对数后连乘变成连加,数值稳定得多。

拟合这份模拟数据并用测试集评估:

model = GaussianNaiveBayes() model.fit(X_train, y_train) y_pred = model.predict(X_test) print('手写模型准确率:', np.mean(y_pred == y_test))

我跑出来的结果准确率大概在0.79到0.80之间。由于模拟数据的随机种子固定,你复现出来的数字应该和我一致。

3.3 用sklearn验证手写结果

手写模型的意义在于理解原理,实际项目里我还是建议直接用sklearn的GaussianNB,因为它在边界情况和数值稳定性上考虑得更完整。

from sklearn.naive_bayes import GaussianNB from sklearn.metrics import accuracy_score, precision_score, recall_score, roc_auc_score model_sk = GaussianNB() model_sk.fit(X_train, y_train) y_pred_sk = model_sk.predict(X_test) print('accuracy:', accuracy_score(y_test, y_pred_sk)) print('precision:', precision_score(y_test, y_pred_sk)) print('recall:', recall_score(y_test, y_pred_sk)) print('roc_auc:', roc_auc_score(y_test, model_sk.predict_proba(X_test)[:, 1]))

在我的数据上,sklearn模型和手写模型的输出完全一致。这也验证了手写实现没有犯低级错误。如果你在真实数据上发现两个版本有差异,优先检查是不是数据预处理环节不一致。

4. 模型评估与阈值调整:筛查场景不能只看准确率

4.1 混淆矩阵四项指标怎么看

二分类模型的评估不能只看准确率,尤其在医疗筛查场景。准确率是预测正确的样本占总样本的比例,但如果数据中90%的人没感染,模型全部预测未感染也能有90%准确率,这显然没有意义。

更关键的是混淆矩阵里的四个数字:

指标含义在幽门螺杆菌筛查中的意义
TP实际感染,预测感染正确找出的感染者
FN实际感染,预测未感染漏诊,危害最大
FP实际未感染,预测感染造成不必要的进一步检查
TN实际未感染,预测未感染正确排除未感染的人

由这四个数字推出了几个常用指标:

  • 准确率 = (TP + TN) / (TP + TN + FP + FN)
  • 精确率 = TP / (TP + FP),预测感染的病人里有多大比例真的是感染者
  • 召回率 = TP / (TP + FN),真实感染者里有多大比例被发现了
  • F1 = 2 × 精确率 × 召回率 / (精确率 + 召回率)

在筛查场景中,我更看重召回率,因为漏掉一个感染者可能导致病情延误。代价是召回率提升后,精确率通常下降,也就是说会有更多假阳性。

4.2 ROC曲线与最佳阈值选择

GaussianNB.predict默认以0.5作为分类阈值,但0.5并不总是最优。我们可以用预测概率和真实标签画出ROC曲线,再根据实际需求挑阈值。

from sklearn.metrics import roc_curve prob_pos = model_sk.predict_proba(X_test)[:, 1] fpr, tpr, thresholds = roc_curve(y_test, prob_pos) # 约登指数选择最优阈值 youden = tpr - fpr best_idx = np.argmax(youden) best_threshold = thresholds[best_idx] print('best threshold:', best_threshold)

约登指数是让真正例率和假正例率差值最大的点,适合在成本和收益没有明显偏向时使用。我这份数据上的最佳阈值大约在0.44左右,比默认的0.5略低。如果用0.44作为阈值,召回率会提高几个点,精确率只降一点点。

想让召回率更高,可以直接把阈值降到0.3。我试过这样做,召回率能到90%以上,但假阳性数量明显增加。这类权衡没有标准答案,要看业务方更愿意承担哪种风险。

4.3 筛查场景的误判代价和业务口径

在体检初筛里,我的建议是把模型输出分成三档,而不是只输出感染或不感染:

  • 高风险:概率大于0.6,建议尽快做呼气试验;
  • 中风险:概率在0.3到0.6之间,建议结合临床症状,可安排进一步检查;
  • 低风险:概率小于0.3,常规随访即可。

这样做的好处是避免了"机器说了算"的尴尬,模型只负责给医生一个参考区间。最后确定阈值时,最好和临床医生一起评估本地人群的患病率和检查资源,而不是自己盯着AUC数字拍板。

5. 实际运行中的坑与调参心得

5.1 特征相关性:朴素贝叶斯最被诟病的点

我在第1章提过,朴素贝叶斯假设特征条件独立。但医学指标里这个假设经常被打破,比如胃蛋白酶原I和II在功能上密切相关,白细胞和中性粒细胞比例也高度关联。当两个强相关特征同时进入模型时,模型会把重复信息当成新增证据,导致概率输出过于自信,也就是预测概率往0或1两端靠。

我在实验中发现,把相关系数超过0.8的两个特征同时保留时,AUC基本不变,但预测概率的校准度变差。解决办法不复杂:先算特征相关矩阵,把相关性高的特征组里保留业务上更合理的那个。比如PGI和PGII,如果临床更常用PGI单独判断胃黏膜状态,就只保留PGI。

5.2 正负样本不平衡时的处理

真实的幽门螺杆菌体检数据里,感染者比例可能不到20%。这种情况下,模型会学到更强的先验概率偏向负类,预测结果里阴性居多,召回率会变得很不好看。

处理方式有三种,我按推荐程度排一下:

  • 调整分类阈值,比如从0.5降到0.3,这是最省事且最直接的方法;
  • 在GaussianNB里传入priors参数,比如priors=[0.7, 0.3],告诉模型正类没那么罕见;
  • 对训练集做SMOTE上采样,让正负样本在训练时更平衡。

我自己的建议是先调阈值,因为先验概率在sklearn里其实是从训练数据估计的,强行改priors会改变概率校准,有时候反而会让输出难以解释。上采样能提升召回率,但要在交叉验证里做,否则也会引入数据泄露。

5.3 概率输出不等于真实风险

最后说一个容易被忽略的问题:朴素贝叶斯输出的概率是"当前模型下的置信度",不是"病人得病的真实概率"。模型基于训练集的分布估计,如果训练集里中老年人的比例偏高,模型对年轻人的预测就会偏向中老年人的特征分布。所以当模型用于新人群时,要定期监控真实阳性率和测试集阳性率是否一致,必要时重新训练。

另外,代码里对缺失值用均值填充这一步,如果缺失比例较高,会人为压缩特征方差,导致概率密度估计的峰值偏高。一个更好的做法是给缺失值单独编码一个类别,或者用模型自带缺失处理的方法。这个细节在真实数据上线后影响很大,但很多教程不会写。

从手写算法到sklearn验证,再到阈值调整,这整条链路跑通之后,我的体会是:朴素贝叶斯在类似体检指标分类这种小数据、低维度、需要解释性的场景里,确实是个好选择。它不会给你顶尖效果,但能用极低成本把基线模型立起来。后续如果要上线,再把特征工程做透,或者换成逻辑回归做概率校准,都是顺理成章的事。

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

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

立即咨询