基于朴素贝叶斯的幽门螺杆菌筛查:Python实现与调参实战

发布时间:2026/9/15 15:42:04
基于朴素贝叶斯的幽门螺杆菌筛查:Python实现与调参实战
上个月有个做体检系统集成测试的朋友找到我问能不能用Python快速搭一个模型根据常规体检指标判断病人是否感染幽门螺杆菌。说实话这种需求的难点不在模型而在于把数据和问题对齐。我最后用了朴素贝叶斯分类模型原因是数据量不大、特征连续、二分类语义清晰而且模型结果可以解释给非技术同事听。整轮跑下来在测试集上准确率接近80%把阈值往召回率方向调之后阳性召回率能到85%以上。对一台没有GPU的轻量服务来说这个方案相当实用。这篇文章会把完整思路、数据处理方式、Python实现和调试过程写清楚。如果你刚好在学Python机器学习想动手做一次完整的分类任务或者被问到朴素贝叶斯到底怎么落地可以直接参考这篇的内容。1. 幽门螺杆菌筛查问题为什么适合朴素贝叶斯1.1 筛查场景的特征形态幽门螺杆菌的规范诊断通常依赖碳13/碳14呼气试验、胃镜活检或粪便抗原检测这些方法准确但需要专门设备或操作流程。而在很多体检场景里我们手头只有血常规和生化数据比如白细胞计数、血红蛋白、胃蛋白酶原I/II、年龄、性别等。如果能在这些常规指标和感染状态之间找到一个可解释的映射就能在正式检查前做一轮分流模型预测风险较高的人优先做呼气试验风险较低的人可以暂缓。这种问题本质是二分类样本特征是一串连续型数值标签是0或1代表未感染和感染。数据量往往只有几百到几千条特征维度在5到10个左右放在深度学习里属于杀鸡用牛刀但放在传统机器学习里恰好是朴素贝叶斯的舒适区。1.2 贝叶斯公式在感染判断中的表达朴素贝叶斯的核心是先算后验概率给定一组特征X时病人属于感染类别y1的概率有多大。用公式写就是P(y1 | X) P(X | y1) × P(y1) / P(X)其中P(y1)是先验概率表示在历史数据中感染者的比例P(X | y1)是似然表示在已知感染的人群里看到这组特征的可能性分母P(X)对所有类别都一样比较时可以不care。朴素贝叶斯的朴素体现在它假设了在各个类别内部不同特征之间条件独立所以P(X | y)可以拆成每个特征单独概率密度的连乘。对连续型特征最常用的做法是假设每个特征在某个类别下服从正态分布也就是高斯朴素贝叶斯。模型训练时只需要统计每个类别下每个特征的均值和方差预测时把新样本带入高斯概率密度函数算出似然再乘以先验概率最后比较哪个类别的后验概率更大。1.3 为什么没有改用逻辑回归或随机森林我也考虑过逻辑回归和随机森林。逻辑回归的优点是特征系数能直接反映方向但它在小样本且特征有缺失时对异常值更敏感调参空间也更多。随机森林在小数据上容易过拟合训练出的模型解释起来不像贝叶斯那么直接。朴素贝叶斯有两个很实际的优势一是训练极快整个模型就是一堆均值和方差几乎没有可调的超参数二是预测过程可分解哪项特征拉高了感染概率、哪项特征拉低了都能回看。当然它也有短板最关键的就是条件独立假设在医学指标里并不完全成立比如胃蛋白酶原I和II本身就高度相关。这个问题我在第5章会专门展开。总体而言当目标是快速做出一个能用的筛查模型而不是刷比赛榜单时朴素贝叶斯是性价比非常高的起点。2. 数据准备从体检指标到可训练样本2.1 特征选择与标签定义做这个项目时我并没有真的拿到医院脱敏数据而是参考公开文献里幽门螺杆菌相关指标的正常范围用模拟方式构造了一份实验数据集。这里要强调如果你要复现到真实场景特征选择和标签定义必须和检验科确认不能拍脑袋。我选了这样几个特征年龄连续值单位岁性别离散值转为0/1BMI连续值白细胞计数WBC连续值单位10^9/L血红蛋白HGB连续值单位g/L胃蛋白酶原IPGI连续值单位μg/L胃蛋白酶原IIPGII连续值单位μg/L标签字段是infection1表示检测确认感染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_size0.3, random_state42, stratifyy ) 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_smoothing1e-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(axis0) # 加平滑项避免方差为0 self.vars_[c] X_c.var(axis0) 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]), axis1 ) log_probs[:, i] np.log(self.priors_[c]) log_likelihood # 将log概率转成归一化概率 max_log log_probs.max(axis1, keepdimsTrue) exp_log np.exp(log_probs - max_log) return exp_log / exp_log.sum(axis1, keepdimsTrue) def predict(self, X): proba self.predict_proba(X) return self.classes_[np.argmax(proba, axis1)]核心逻辑解释一下。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验证再到阈值调整这整条链路跑通之后我的体会是朴素贝叶斯在类似体检指标分类这种小数据、低维度、需要解释性的场景里确实是个好选择。它不会给你顶尖效果但能用极低成本把基线模型立起来。后续如果要上线再把特征工程做透或者换成逻辑回归做概率校准都是顺理成章的事。