12种机器学习模型+交叉验证+Pipeline+网格搜索调参:胰腺癌生存状态预测
12 种机器学习模型 + 交叉验证 + Pipeline + 网格搜索调参:用 Python 做胰腺癌生存状态预测
本期摘要
第一性原理:预测不是“套模型”,而是把病人的结构化信息 X 映射到生存状态 y。每一步都要服务于这个映射:清洗字段、统一尺度、编码类别、控制数据泄漏、用交叉验证估计泛化能力。
数据集:50000 条样本,24 个字段,目标变量是 Survival_Status。其中 0 类有 43578 条,1 类有 6422 条,类别明显不均衡。
核心流程:先 EDA 看数据脾气,再用 ColumnTransformer + Pipeline 把数值标准化、类别 One-Hot 和模型训练绑成一条可复现流水线。
模型比较:随机森林、梯度提升、SVM、KNN、决策树、朴素贝叶斯、逻辑回归、XGBoost、LightGBM、CatBoost、LDA、QDA 共 12 种模型横向比较。
关键提醒:很多模型 Accuracy 都在 0.873 左右,看似不错,但多数类基线本身就是 43578 / 50000 = 0.87156。医学预测里只看 Accuracy 很危险,必须继续看 Recall、Precision、F1、ROC-AUC、PR-AUC 和混淆矩阵。
引子:把模型讲给没学过机器学习的人听
费曼学习法要求我们把复杂概念讲到像日常语言一样直观。这里可以这样理解:每个病人是一张表格化病历卡,里面有年龄、性别、分期、症状、治疗方式、生活方式和医疗可及性等信息。模型要做的事,是看过很多历史病历卡之后,学习“哪些组合更可能对应某个生存状态”。
第一性原理再往下拆:机器学习不神秘,它只做三件事。第一,把真实世界变成数字;第二,在数字之间找稳定规律;第三,用没见过的新样本检验这个规律是否仍然成立。本文的 Pipeline、交叉验证、调参,本质上都是为了让这三件事更可靠。
第 0 部分:环境与数据读取
先导入依赖。这里既有传统机器学习模型,也有 XGBoost、LightGBM、CatBoost 这类梯度提升框架。医学表格数据通常不是图像,也不是长文本,结构化表格模型仍然非常有竞争力。
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings("ignore")
from sklearn.model_selection import train_test_split, GridSearchCV, cross_val_score
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.naive_bayes import GaussianNB
from sklearn.linear_model import LogisticRegression
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis, QuadraticDiscriminantAnalysis
from xgboost import XGBClassifier
from lightgbm import LGBMClassifier
from catboost import CatBoostClassifier
读取 CSV 后,第一步不要急着训练模型,而是先看形状、字段类型、缺失值和类别取值。你可以把这一步理解成“接诊问诊”:先确认病历卡有没有漏项、字段是不是写错、每个变量到底表达什么。
df = pd.read_csv("pancreatic_cancer_prediction_sample.csv")
print(df.shape)
print(df.info())
print(df.describe())
print(df.isnull().sum())
categorical_cols = df.select_dtypes(include=["object"]).columns
for col in categorical_cols:
print(f"{col}: {df[col].unique()}")
本数据集共有 50000 行、24 列。目标列 Survival_Status 是二分类标签:0 类 43578 条,1 类 6422 条。这个比例会直接影响后面对 Accuracy 的解读。
第 1 部分:先看数据,不要先套模型
年龄是医学预测里最常见、也最容易被模型重视的变量。先看年龄分布,是为了确认数据是否集中在合理区间,是否存在明显异常值。
plt.figure(figsize=(8, 5))
sns.histplot(df["Age"], bins=30, kde=True, color="blue")
plt.title("Age Distribution of Patients")
plt.xlabel("Age")
plt.ylabel("Frequency")
plt.show()
图 1 年龄分布:样本年龄以 60-70 岁附近最集中,符合胰腺癌高发年龄段的常识背景。
从图上看,年龄主要集中在 60-70 岁附近。用费曼式说法:如果一个班级里大多数学生都在同一个年龄段,模型就很容易把“年龄段”当作重要线索。后面的特征重要性也印证了这一点。
接着看诊断分期与生存时间。第一性原理上,分期越晚,肿瘤扩散越严重,治疗窗口越窄,生存时间通常越短。图像的价值就是让这个医学常识在数据里显形。
plt.figure(figsize=(8, 5))
sns.boxplot(x="Stage_at_Diagnosis", y="Survival_Time_Months", data=df, palette="coolwarm")
plt.title("Survival Time Across Stages of Diagnosis")
plt.xlabel("Stage at Diagnosis")
plt.ylabel("Survival Time (Months)")
plt.show()
图 2 不同诊断分期下的生存时间:Stage I 的生存时间明显更长,Stage IV 更短。
治疗方式分布也要看。不是因为柱状图高级,而是因为样本数量会影响模型对某类治疗方式的学习。如果某个治疗方式样本很少,模型对它的判断就更容易不稳定。
sns.countplot(x="Treatment_Type", data=df, palette="Set2")
plt.title("Distribution of Treatment Types")
plt.xlabel("Treatment Type")
plt.ylabel("Count")
plt.xticks(rotation=45)
plt.show()
图 3 治疗方式分布:化疗样本最多,其次为放疗和手术。
第 2 部分:相关性、分布与异常值
热力图不是为了好看,而是为了回答一个问题:变量之间有没有强相关?如果两个变量几乎表达同一件事,它们会让模型重复计分,也会影响解释。
plt.figure(figsize=(10, 6))
sns.heatmap(
df.select_dtypes(include=["int64", "float64"]).corr(),
annot=True,
cmap="coolwarm",
linewidths=0.5,
)
plt.title("Correlation Heatmap of Numerical Features")
plt.show()
图 4 数值特征相关性热力图:多数风险因素之间相关性较弱,Age 与 Survival_Time_Months 是后续模型最敏感的连续变量。
从相关性图看,多数变量之间相关性较弱。这意味着模型需要从多变量组合里找信号,而不是靠某两个变量的简单线性关系吃遍全场。
下面的九宫格计数图,把主要离散变量的样本比例放到一起看。第一性原理上,分类模型学习的是“特征取值和标签之间的条件分布”。如果某些取值本来就占多数,模型可能只是学会了样本比例,而不是真正学会疾病风险。
fig, axes = plt.subplots(3, 3, figsize=(18, 12))
sns.countplot(x="Gender", data=df, palette="pastel", ax=axes[0, 0])
sns.countplot(x="Smoking_History", data=df, palette="coolwarm", ax=axes[0, 1])
sns.countplot(x="Diabetes", data=df, palette="Set2", ax=axes[0, 2])
sns.countplot(x="Chronic_Pancreatitis", data=df, palette="magma", ax=axes[1, 0])
sns.countplot(x="Stage_at_Diagnosis", data=df, palette="viridis", ax=axes[1, 1])
sns.countplot(x="Treatment_Type", data=df, palette="Blues", ax=axes[1, 2])
sns.countplot(x="Survival_Status", data=df, palette="husl", ax=axes[2, 0])
sns.countplot(x="Alcohol_Consumption", data=df, palette="Set1", ax=axes[2, 1])
sns.countplot(x="Physical_Activity_Level", data=df, palette="crest", ax=axes[2, 2])
plt.tight_layout()
plt.show()
图 5 九个离散变量计数图:先看类别比例,才能判断模型学到的是信号还是样本占比。
连续变量的 KDE、箱线图、小提琴图、饼图、散点图、Pairplot 和 Jointplot 继续从不同角度看同一批数据。读图时要抓住三个问题:变量是否偏态,类别之间是否分离,变量之间是否存在可学习的边界。
图 6 年龄与生存时间 KDE:年龄近似钟形分布,生存时间明显右偏。
图 7 箱线图:分期和生存时间的阶梯关系非常清楚,性别与年龄差异不大。
图 8 小提琴图:不同治疗方式的生存时间分布大量重叠,提示单变量很难直接决定结局。
图 9 饼图:Stage IV 与化疗样本占比较高,是必须向读者交代的数据背景。
图 10 散点图:年龄、生存时间、性别、饮酒和体力活动之间没有简单线性边界。
图 11 Pairplot:把多个变量放在同一张图中看,能快速发现分期与生存时间的分层。
图 12 年龄与生存时间联合分布:高密度区域集中在 55-75 岁、短生存时间附近。
图 13 饮酒与生存时间联合分布:二值变量常形成两条竖带,读图时不要误解成连续趋势。
第 3 部分:为什么一定要用 Pipeline
表格数据里有两类变量:数值变量和类别变量。数值变量可以直接参与计算,但尺度不同,例如年龄是几十,二值风险因素是 0/1,生存时间是 1-59。类别变量如国家、治疗方式、分期,不能直接喂给大多数 sklearn 模型,需要编码。
如果手工先标准化、再 One-Hot、再切训练测试集,很容易把测试集信息泄漏进训练过程。Pipeline 的意义是把“预处理”和“训练”锁在同一条流程里,让交叉验证时每一折都只用训练折拟合预处理器。
X = df.drop(columns=["Survival_Status"])
y = df["Survival_Status"]
categorical_cols = X.select_dtypes(include=["object", "bool"]).columns
numerical_cols = X.select_dtypes(include=["int64", "float64"]).columns
numerical_transformer = StandardScaler()
categorical_transformer = OneHotEncoder(handle_unknown="ignore")
preprocessor = ColumnTransformer(
transformers=[
("num", numerical_transformer, numerical_cols),
("cat", categorical_transformer, categorical_cols),
]
)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
用一句费曼式解释:Pipeline 就像医院的标准化检查流程。每位病人先按同一规则量体温、验血、填表,然后医生再判断。如果检查流程每次都不一样,诊断结果就没法比较。
第 4 部分:12 种模型横向比较
单个模型表现好,不代表它真的适合数据。更稳妥的做法是把多个模型放到同一预处理流程、同一训练测试划分、同一评价指标下比较。这样比较出来的差异才有意义。
models = [
("Random Forest", RandomForestClassifier(random_state=42)),
("Gradient Boosting", GradientBoostingClassifier(random_state=42)),
("SVM", SVC(random_state=42)),
("KNN", KNeighborsClassifier()),
("Decision Tree", DecisionTreeClassifier(random_state=42)),
("Naive Bayes", GaussianNB()),
("Logistic Regression", LogisticRegression(random_state=42)),
("XGBoost", XGBClassifier(random_state=42)),
("LightGBM", LGBMClassifier(random_state=42)),
("CatBoost", CatBoostClassifier(random_state=42, verbose=0)),
("LDA", LinearDiscriminantAnalysis()),
("QDA", QuadraticDiscriminantAnalysis()),
]
results = []
names = []
for name, model in models:
pipeline = Pipeline(steps=[("preprocessor", preprocessor), ("model", model)])
pipeline.fit(X_train, y_train)
y_pred = pipeline.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
results.append(accuracy)
names.append(name)
print(f"{name}: {accuracy:.4f}")
| 模型 | 测试集 Accuracy | 5 折 CV Accuracy |
|---|---|---|
| Random Forest | 0.8730 | 0.8715 |
| Gradient Boosting | 0.8730 | 0.8715 |
| SVM | 0.8730 | 0.8716 |
| KNN | 0.8600 | 0.8582 |
| Decision Tree | 0.7560 | 0.7529 |
| Naive Bayes | 0.8730 | 0.8716 |
| Logistic Regression | 0.8730 | 0.8716 |
| XGBoost | 0.8706 | 0.8700 |
| LightGBM | 0.8730 | 0.8715 |
| CatBoost | 0.8729 | 0.8715 |
| LDA | 0.8730 | 0.8716 |
| QDA | 0.4149 | 0.4325 |
这个结果最值得讲的地方,恰恰不是“最高准确率 0.8730”,而是它和多数类基线非常接近。因为 0 类占 87.156%,一个什么都不学、永远预测 0 的模型,Accuracy 也能达到 0.87156。
这就是医学二分类任务最容易踩的坑:如果阳性或死亡等少数类才是我们真正关心的类别,Accuracy 可能会把模型表现讲得过于乐观。下一步应该补充 classification_report、混淆矩阵、ROC-AUC、PR-AUC,并考虑类别权重或阈值移动。
第 5 部分:GridSearchCV 做超参数调优
超参数不是模型从数据里学出来的参数,而是我们训练前给模型设定的控制旋钮。随机森林里,树的数量、最大深度、节点分裂最小样本数都会影响偏差和方差。
param_grid = {
"model__n_estimators": [100, 200, 300],
"model__max_depth": [None, 10, 20, 30],
"model__min_samples_split": [2, 5, 10],
}
rf_pipeline = Pipeline(
steps=[
("preprocessor", preprocessor),
("model", RandomForestClassifier(random_state=42)),
]
)
grid_search = GridSearchCV(rf_pipeline, param_grid, cv=5, scoring="accuracy")
grid_search.fit(X_train, y_train)
print(f"Best parameters: {grid_search.best_params_}")
print(f"Best cross-validation accuracy: {grid_search.best_score_:.4f}")
best_model = grid_search.best_estimator_
cv_scores = cross_val_score(best_model, X, y, cv=5, scoring="accuracy")
print(f"Cross-validation accuracy: {np.mean(cv_scores):.4f}")
本次随机森林网格搜索得到的最优参数为:max_depth=None、min_samples_split=5、n_estimators=100。最佳交叉验证 Accuracy 为 0.8712,最终 5 折交叉验证平均 Accuracy 为 0.8716。
这个结果告诉我们:调参不是魔法。若数据中的可分信号有限,或者评价指标被类别不均衡主导,调参只能小幅改变结果,不能凭空制造信息。
第 6 部分:特征重要性
调好随机森林后,可以提取特征重要性。它的含义不是“因果影响”,而是“这个特征在树模型分裂中被使用、并降低不纯度的贡献”。所以它适合做解释线索,不适合直接当医学结论。
if hasattr(best_model.named_steps["model"], "feature_importances_"):
importances = best_model.named_steps["model"].feature_importances_
feature_names = preprocessor.get_feature_names_out()
feature_importance_df = pd.DataFrame(
{"Feature": feature_names, "Importance": importances}
).sort_values(by="Importance", ascending=False)
print(feature_importance_df.head(15))
| 排名 | 特征 | 重要性 |
|---|---|---|
| 1 | num__Age | 0.145216 |
| 2 | num__Survival_Time_Months | 0.130356 |
| 3 | num__Weight_Loss | 0.025595 |
| 4 | num__Abdominal_Discomfort | 0.024002 |
| 5 | num__Smoking_History | 0.023476 |
| 6 | num__Alcohol_Consumption | 0.022799 |
| 7 | num__Back_Pain | 0.022718 |
| 8 | num__Obesity | 0.021172 |
| 9 | num__Development_of_Type2_Diabetes | 0.020319 |
| 10 | cat__Country_United States | 0.019833 |
| 11 | num__Family_History | 0.018883 |
| 12 | cat__Treatment_Type_Chemotherapy | 0.018526 |
| 13 | cat__Economic_Status_Middle | 0.018510 |
| 14 | cat__Access_to_Healthcare_Medium | 0.017709 |
| 15 | num__Jaundice | 0.017406 |
可以看到,Age 和 Survival_Time_Months 排在最前面。注意,若目标是预测生存状态,而 Survival_Time_Months 是结局之后才知道的信息,那么真实部署时要谨慎:预测时拿不到的变量不能放进模型,否则会形成“事后信息泄漏”。这是医学建模里比模型选择更重要的问题。
第 7 部分:保存模型
训练完成后,把整个 Pipeline 保存下来,而不是只保存裸模型。原因很简单:预测新样本时必须复用同一套标准化和 One-Hot 规则。
import joblib
joblib.dump(best_model, "best_model.pkl")
这样保存出来的 best_model.pkl 包含预处理器和随机森林模型,新数据进来后可以直接调用 predict。
最后:这篇实战真正要学会什么
如果只记住一件事,请记住:机器学习项目的核心不是“哪个模型最强”,而是“数据、目标、评价指标、验证方式是否自洽”。本案例里,Pipeline 和交叉验证让流程变得规范;多模型比较让我们知道不同算法的表现边界;而类别不均衡提醒我们,Accuracy 不能独自承担医学预测的评价责任。
下一步可以继续升级:使用 StratifiedKFold 保持每折类别比例;给模型加入 class_weight;用 PR-AUC 评估少数类;画混淆矩阵检查漏判;再用 SHAP 分析单个样本为什么被模型判成某类。这样才是从“代码能跑”走向“模型可信”的路径。
更多推荐




所有评论(0)