胎儿健康分类:机器学习全流程与多模型对比实践
简介面向机器学习初学者与数据挖掘爱好者的胎儿健康分类实战资源包以孕妇胎儿健康数据为基础演示从数据探索、特征工程到模型训练与评估的完整流程。压缩包共21个文件包含19个Python源代码、1个数据文件fetal_health.csv和1个说明文档整体仅84KB代码均可直接运行。代码手工整理覆盖随机森林、SVC、XGBoost、LightGBM、CatBoost、Stacking集成、Transformer等多种机器学习与深度学习方法并涉及SMOTE均衡采样、PCA降维、网格搜索调参、模型对比等常用技巧有助于理解不同算法在该分类任务上的表现差异。此外还提供基于PyTorch的神经网络实现以及EDA探索性数据分析脚本帮助读者掌握建模前的数据洞察方法。目前已有71人学习下载适合希望系统提升实战能力的读者参考练习。1. 胎儿健康分类一份数据、19 个 Python 脚本能讲完的机器学习全流程产科医生盯着胎心监护曲线看得最多的其实是几个统计数字变异度、减速次数、宫缩频率。这些指标组合在一起决定了胎儿是正常、可疑还是病理性状态。把这张监护表转成结构化数据就成了 2126 行、21 列的 fetal_health.csv标签是 1、2、3 三类。机器学习的任务很简单用这些特征预测胎儿健康状态。数据量不大但类不平衡、特征相关性强、模型选择空间大恰好把分类任务里最常见的坑都踩了一遍。这套资源里 19 个 Python 源代码覆盖了 EDA、特征工程、随机森林、SVC、XGBoost、LightGBM、CatBoost、Stacking甚至还有 Transformer 的表格数据尝试。适合正在学分类问题完整流程的人也适合想快速跑一组基线模型对比的从业者。接下来我会按一个可复现的顺序拆开讲。2. fetal_health.csv 数据横截面21 个特征、类不平衡与特征工程2.1 读数据与特征分布pandas 与 seaborn 的第一印象拿到数据集先不急着建模。fetal_health.csv 每一行是一条胎心监护记录21 列中前 20 列是特征最后一列 fetal_health 是标签。先用 pandas 读进来看形状、缺失值和标签分布这是所有后续判断的基础。import pandas as pd import seaborn as sns import matplotlib.pyplot as plt df pd.read_csv(fetal_health.csv) print(df.shape) # (2126, 21)20 特征 1 标签 print(df.isnull().sum().sum()) # 缺失值数量理想情况为 0 print(df[fetal_health].value_counts()) sns.countplot(xfetal_health, datadf) plt.title(Label Distribution) plt.show()value_counts()的输出基本是 1 类约 1655 条、2 类约 295 条、3 类约 176 条比例约 78%14%8%。这个分布决定了后面所有策略直接用 accuracy 评估会被多数类带偏必须看重少数类的召回率和宏平均 F1。countplot只是把分布画出来真正影响建模的是从这一刻起就要记住「分层采样」和「过采样」这两个关键词。特征本身也值得扫一遍accelerations和uterine_contractions是整体计数light_decels、severe_decels、prolongued_decels按减速严重程度拆分还有一组直方图统计量如histogram_width、histogram_min、histogram_max、histogram_mode。这些特征之间的相关性会很高尤其是直方图那组建模前需要处理。2.2 相关性筛选与降维PCA 与 SelectPercentile 的参数差异对这种结构化的医疗数据特征工程不是堆特征而是控制冗余。先算相关性矩阵找出互相纠缠的特征对再决定是删掉、做 PCA 还是用单变量筛选。import seaborn as sns corr df.iloc[:, :-1].corr() plt.figure(figsize(14, 10)) sns.heatmap(corr, cmapRdBu_r, center0, annotFalse) plt.title(Feature Correlation Matrix) plt.show()annotFalse是为了不让 400 个数字填满图只看颜色深浅。观察到的典型情况是histogram_mean、histogram_median、histogram_mode之间相关系数常超过 0.85mean_value_of_long_term_variability和histogram_width也有明显共线。对这种冗余PCA 和 SelectPercentile 是两条路适合不同目标。方法参数输出适用场景SelectPercentile(f_classif, percentile80)percentile控制保留特征比例特征掩码/新特征矩阵保留原始可解释性快速降维PCA(n_components0.95)n_components传浮点数表示保留方差比例线性组合后的主成分供后续模型使用不需要解释特征含义PolynomialFeatures(degree2)degree控制最高次数包含交叉项的高维特征线性模型需要非线性拟合能力时三种方法在资源里的脚本中都有出现参数也基本是这几个常用值。我的建议是如果后面要跑 SVC 或 KNN用 StandardScaler 加 PCA如果要跑随机森林或 XGBoost用 SelectPercentile 保留原始特征语义更好。2.3 PCA 在流水线里的位置先缩放再降维PCA对量纲极其敏感histogram_variance的数值范围跟accelerations不在一个量级直接做主成分分析结果会被大数值特征支配。所以一个规范的数据预处理流程是先StandardScaler标准化再做 PCA。from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA scaler StandardScaler() X_scaled scaler.fit_transform(df.iloc[:, :-1]) pca PCA(n_components0.95) X_pca pca.fit_transform(X_scaled) print(pca.n_components_) # 看保留下来的主成分个数 print(pca.explained_variance_ratio_.cumsum()[-1]) # 累计方差占比n_components0.95表示保留 95% 的方差具体保留多少个主成分由pca.n_components_给出。对这个数据集通常是 1215 个说明原始 20 维特征里确实有约三分之一的冗余信息。当特征数量从 20 压到 14 左右SVC 和 KNN 的训练时间会明显下降而随机森林这类树模型对线性变换后的特征并不敏感所以 PCA 通常只放在需要距离度量的模型链路中。3. 树模型与 SVC 基线RandomForest 调参、GridSearchCV 与评估矩阵3.1 train_test_split 的分层策略与 StandardScaler 边界切分数据集时必须用stratifyy理由很简单如果不分层随机切分后测试集里病理性胎儿的比例可能只剩 5%导致评估结果波动很大。分层切分保证训练集和测试集的类别比例跟原始数据一致这样模型评估才可靠。from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X df.drop(columns[fetal_health]) y df[fetal_health] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test) # 只 transform不重新 fittest_size0.2意味着测试集约 425 条训练集约 1701 条。random_state42固定随机种子保证多次运行结果可复现。scaler只在训练集上fit测试集直接transform这是防止数据泄漏的最基本规则。注意StandardScaler边界随机森林、XGBoost、LightGBM 这类树模型不关心特征的量纲缩放不缩放对分裂点没有影响但 SVC、KNN、LogisticRegression 这类基于距离或梯度的模型必须先缩放。最稳妥的做法是把缩放器放进 Pipeline让每个模型各取所需。3.2 GridSearchCV 搜索随机森林参数n_estimators、max_depth、min_samples_leaf随机森林在胎儿健康分类上表现不差但默认参数通常不是最优。资源里 15-Projekt Classification Random Forest Fetal Health.py 应该就是干这个的。手动调参太慢用GridSearchCV在参数网格上搜索最直接。from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import GridSearchCV param_grid { n_estimators: [100, 200, 300], max_depth: [None, 10, 20], min_samples_leaf: [1, 3, 5] } rf RandomForestClassifier(random_state42, n_jobs-1) grid GridSearchCV( rf, param_grid, scoringf1_macro, cv5, n_jobs-1 ) grid.fit(X_train, y_train) print(grid.best_params_) print(grid.best_score_)scoringf1_macro是关键选择。默认 accuracy 在这个不平衡数据集上会给出虚高的分数比如全部预测为正常类也能有 78% 的准确率。宏平均 F1 对三个类别一视同仁病理性胎儿这一类占比小但临床意义重大必须用宏平均去约束模型。cv5表示五折交叉验证把训练集再切成 5 份每份轮流当验证集最终分数更稳定。3.3 混淆矩阵、classification_report 与 roc_auc_score 的读法调参完成后要在测试集上看完整评估准确率、精确率、召回率、F1、混淆矩阵、ROC AUC 一个都不能少。from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score best_rf grid.best_estimator_ y_pred best_rf.predict(X_test) print(classification_report(y_test, y_pred, digits3)) print(confusion_matrix(y_test, y_pred)) print(roc_auc_score(y_test, best_rf.predict_proba(X_test), multi_classovr))classification_report输出每一类的 precision、recall、f1-score以及 macro avg 和 weighted avg。confusion_matrix是 3×3 矩阵行是真值、列是预测值对角线越突出越好。最需要盯的是第三行第三列也就是病理性这一类的召回率——如果低于 0.7说明有相当一部分高危胎儿被漏掉了。AUC 的multi_classovr是 One-vs-Rest 策略把三分类拆成三个二分类问题分别算 AUC 再平均。AUC 对不平衡数据比准确率更稳健测试集上随机森林通常能到 0.9 以上。对于精确率、召回率、F1 在三个类别上的含义可以用一张表概括类别临床含义评估重点1 正常绝大多数样本所在类别precision 高即可误报成本低2 可疑需要观察的区间recall 中等即可避免过度医疗3 病理性需要干预的高危类别recall 必须高漏报代价最大4. 不平衡与集成SMOTE 过采样、多模型对比与 Stacking 融合4.1 SMOTE 合成少数类样本imblearn 的 fit_resample 流程类不平衡在这里是核心矛盾。病理性样本只有约 8%随机森林、SVC 这类模型会在训练时天然偏向多数类。SMOTE 的思路不是丢弃多数类样本而是在少数类样本的特征空间里做插值生成新的合成样本。from imblearn.over_sampling import SMOTE from collections import Counter smote SMOTE(random_state42, k_neighbors5) X_train_res, y_train_res smote.fit_resample(X_train, y_train) print(Counter(y_train_res))fit_resample之后三个类别的样本数会变得接近训练集规模从 1701 条变成大约 3300 条。k_neighbors5是默认值表示每个少数类样本从它的 5 个近邻里随机选样本做插值如果 k 太大合成的样本可能跨越到多数类的区域变成噪声。这里有一个必须强调的纪律SMOTE 只能用在训练集上。测试集必须保持真实的类别分布否则评估结果没有说服力。如果你用了train_test_split之后再fit_resample顺序就是对的如果在切分之前对整个数据集做 SMOTE那就是数据泄漏。4.2 19 个脚本里的模型谱系从 KNN、朴素贝叶斯到 XGBoost、LightGBM、CatBoost资源里 20-Famous models comparison.py 这类脚本就是在同一份数据上循环跑多个模型输出对比表。由于 2126 条数据量不大几乎所有主流分类器都能在几秒内训练完正好可以做一轮全面的模型横向对比。from sklearn.ensemble import ( RandomForestClassifier, ExtraTreesClassifier, GradientBoostingClassifier, AdaBoostClassifier ) from sklearn.svm import SVC from sklearn.neighbors import KNeighborsClassifier from sklearn.linear_model import LogisticRegression from sklearn.naive_bayes import GaussianNB from sklearn.tree import DecisionTreeClassifier from xgboost import XGBClassifier from lightgbm import LGBMClassifier from catboost import CatBoostClassifier from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.model_selection import cross_val_score models { KNN: KNeighborsClassifier(n_neighbors5), NaiveBayes: GaussianNB(), LogisticRegression: LogisticRegression(max_iter1000), DecisionTree: DecisionTreeClassifier(random_state42), RandomForest: RandomForestClassifier(random_state42), ExtraTrees: ExtraTreesClassifier(random_state42), GradientBoosting: GradientBoostingClassifier(random_state42), AdaBoost: AdaBoostClassifier(random_state42), SVC: SVC(kernelrbf, probabilityTrue), XGBoost: XGBClassifier(eval_metricmlogloss), LightGBM: LGBMClassifier(verbose-1), CatBoost: CatBoostClassifier(verbose0) } for name, model in models.items(): pipe Pipeline([ (scaler, StandardScaler()), (model, model) ]) scores cross_val_score(pipe, X_train, y_train, cv5, scoringf1_macro) print(f{name}: {scores.mean():.4f} (/- {scores.std():.4f}))因为不同模型对特征量纲的敏感度不同统一用StandardScaler套在 Pipeline 里保证 KNN、SVC 这类模型不吃亏树模型也不受影响。cross_val_score直接做五折交叉验证避免单次划分带来的随机波动。f1_macro依然是不平衡数据下的首选评估指标。各模型在这个数据集上的适用性可以提前有个预期模型类型代表在这个数据上的特点需要注意的问题距离类KNN, SVC需要特征缩放对小数据集敏感参数C、gamma需要调优朴素贝叶斯GaussianNB训练极快特征强相关时假设不成立树模型决策树、随机森林、ExtraTrees不需要缩放能处理非线性需要调树深和采样策略BoostingXGBoost、LightGBM、CatBoost通常效果最好过拟合风险随轮数增加线性模型LogisticRegression可解释性强需要多项式特征才能拟合非线性4.3 StackingClassifier 让多个模型互相纠错Stacking是把多个基模型的预测结果作为新特征再交给一个元模型做最终判断。和单纯的投票不同Stacking 能学到「哪种模型在哪种样本上更可靠」——比如 SVC 擅长的样本区域和树模型擅长的区域不同元模型可以自动分配权重。from sklearn.ensemble import StackingClassifier from sklearn.linear_model import LogisticRegression estimators [ (rf, RandomForestClassifier(random_state42)), (xgb, XGBClassifier(eval_metricmlogloss)), (lgbm, LGBMClassifier(verbose-1)), (svc, SVC(kernelrbf, probabilityTrue, random_state42)) ] stack StackingClassifier( estimatorsestimators, final_estimatorLogisticRegression(max_iter1000), cv5, # 基模型使用 5 折交叉验证生成预测 stack_methodpredict_proba # 用概率而不是硬标签作为新特征 ) stack.fit(X_train, y_train) y_pred_stack stack.predict(X_test)stack_methodpredict_proba会让每个基模型输出 3 个类别的概率值12 个基模型就有 12 个特征信息量比硬标签丰富得多。final_estimator选 LogisticRegression 而不是树模型因为元特征维度低、线性可分性较好逻辑回归能给出稳定的权重且抗过拟合。Stacking 在测试集上的 macro-F1 通常比最好的单一模型再高 13 个百分点。代价是训练时间翻倍但对于 3300 条数据完全可以接受。如果资源里 13-Fetal Health Classification Stacking Models.py 已经做了类似的事你只需要对照cv和stack_method这两个关键参数数值不同会直接带来效果差异。5. 序列视角的尝试Fetal Health Transformer 与 PyTorch Dataset5.1 为什么把表格行当序列从 FHT 脚本看结构7-Fetal Health Transformer.py 和 3-Fetal Health Transformer FHT.py 这两个脚本的做法是把一行 20 个特征当成一个长度为 20 的序列喂给 TransformerEncoder。理论上自注意力机制能发现特征之间的远程依赖——比如histogram_variance和prolongued_decels的交互关系这是树模型基于单特征分裂所不容易捕捉的。这个想法在更大的数据集上可能有价值但在这个数据集上要小心。2126 条样本对于 Transformer 来说太小了训练集只有约 1701 条一个带 23 层 encoder、嵌入维度 32 的模型动辄就有数万参数很容易陷入过拟合。把它当作一支「侦察兵」而不是「主力部队」心态会更稳。5.2 自定义 Dataset、DataLoader 与训练循环PyTorch 脚本的基本骨架是重写torch.utils.data.Dataset把 pandas DataFrame 转成张量再交给 DataLoader 迭代。import torch import torch.nn as nn import torch.nn.functional as F from torch.utils.data import Dataset, DataLoader from sklearn.preprocessing import LabelEncoder class FetalHealthDataset(Dataset): def __init__(self, X, y): self.X torch.tensor(X, dtypetorch.float32) self.y torch.tensor(y, dtypetorch.long) def __len__(self): return len(self.y) def __getitem__(self, idx): return self.X[idx], self.y[idx] label_encoder LabelEncoder() y_train_enc label_encoder.fit_transform(y_train) # 1/2/3 - 0/1/2 train_ds FetalHealthDataset(X_train_scaled, y_train_enc) train_loader DataLoader(train_ds, batch_size32, shuffleTrue)LabelEncoder把标签从 1、2、3 映射成 0、1、2对接nn.CrossEntropyLoss的类别索引要求。batch_size32对这个数据量合适太大则每个 batch 里病理性样本太少太小则训练不稳定。shuffleTrue只对训练集打开测试集必须保持False。TransformerEncoder 部分可以写得很简洁class FetalTransformer(nn.Module): def __init__(self, d_feat20, d_model64, nhead4, num_layers2, num_classes3): super().__init__() self.input_proj nn.Linear(d_feat, d_model) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, batch_firstTrue ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.classifier nn.Sequential( nn.Linear(d_model, 32), nn.ReLU(), nn.Linear(32, num_classes) ) def forward(self, x): x x.unsqueeze(1) # (batch, seq_len, d_feat) x self.input_proj(x) # (batch, seq_len, d_model) x self.encoder(x) x x.mean(dim1) # 序列维度取平均 return self.classifier(x)input_proj把 20 维特征投影到 64 维嵌入空间nhead4表示 4 头注意力num_layers2是两层 TransformerEncoder。x.mean(dim1)是对序列维度取平均池化把变长序列压成固定向量再交给全连接分类器。这里没有位置编码因为表格特征没有天然的顺序信息加了反而可能引入虚假的邻近关系。训练循环比较标准交叉熵损失、Adam 优化器、每轮结束测一下测试集准确率并记录最佳模型。常见问题是训练 loss 下降但验证 loss 在 20 轮后开始反弹这时候要加早停或者在 Transformer 里的注意力输出后加 Dropout。5.3 表格数据上 Transformer 打不过树模型的常见原因如果你真的把这个脚本跑完很可能发现 Transformer 在测试集上的 macro-F1 不如随机森林。原因有三个层面数据量层面— Transformer 靠注意力去学特征交互但 1701 条训练样本不足以支撑数万参数的拟合树模型只有几百个分裂点需要决定样本利用率高得多。特征结构层面— 这 20 个特征本质是 5 组生理指标的统计量汇总组内相关性强、组间关系相对独立树模型每次在一个特征上做二分就能利用这种结构注意力机制却要同时权衡全部特征复杂度不降反升。顺序假设层面— 表格数据的列顺序没有语义histogram_min排在第 15 列还是第 18 列对模型没有意义。Transformer 的自注意力虽然不在乎距离但 pool 操作丢失了这些特征各自的统计意义。所以这个数据集上Transformer 的角色更接近「验证新思路」而不是「刷新分数」。如果你真想让它变强把 20 个特征按临床分组重排成多段序列或者拼上 ECG 单周期数据才是更有潜力的方向。6. 从脚本到可用服务joblib 模型持久化与一键预测6.1 joblib.dump 保存完整 Pipeline19 个脚本把训练、调参、评估都演示了一遍但实际工作中还差最后一步把训练好的模型存下来给别人或其他服务用。单独保存模型文件不够必须连同StandardScaler一起存否则新数据进来还得手动做一遍标准化容易忘记fit过的参数。import joblib from sklearn.pipeline import Pipeline from sklearn.ensemble import RandomForestClassifier from sklearn.preprocessing import StandardScaler pipe Pipeline([ (scaler, StandardScaler()), (rf, RandomForestClassifier(random_state42, n_estimators200)) ]) pipe.fit(X_train_scaled, y_train) joblib.dump(pipe, fetal_health_model.joblib)加载时只需要一行pipe_loaded joblib.load(fetal_health_model.joblib)StandardScaler和模型参数都在里面。要顺手做一个特征列顺序校准函数保证新传进来的样本和训练时列顺序一致。6.2 用 classification_report 做上线前的最后一测模型落盘后再载入跑一遍测试集把classification_report的结果和训练时的记录对齐。pipe_loaded joblib.load(fetal_health_model.joblib) y_pred_final pipe_loaded.predict(X_test) print(classification_report(y_test, y_pred_final, digits3))同时验证三件事诊断类别的召回率是否和训练时一致尤其在病理性类别上predict_proba输出的概率是否单调即倾向性分数能否自然解释以及单条样本推断时耗时是否达标——对于 425 条测试样本整个流程应该在毫秒级。如果一切正常这个模型文件就能对接简单的 FastAPI 接口或离线预测脚本真正从「能跑通的源码」变成「能用的模型服务」。本文还有配套的精品资源点击获取