12 种机器学习模型 + 交叉验证 + Pipeline + 网格搜索调参:用 Python 做胰腺癌生存状态预测

这篇文章用一份 50000 行的胰腺癌预测数据集,从数据理解、EDA 可视化、预处理 Pipeline、多模型横向比较、GridSearchCV 超参数调优、交叉验证到特征重要性,完整走一遍医学表格数据的机器学习流程。本文不是医学诊断建议,而是一次以第一性原理和费曼学习法来拆解机器学习建模的实战教程。

本期摘要

第一性原理:预测不是“套模型”,而是把病人的结构化信息 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()
plot_2025-02-25 16-05-35_0.png

图 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()
plot_2025-02-25 16-05-35_1.png

图 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()
plot_2025-02-25 16-05-35_2.png

图 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()
plot_2025-02-25 16-05-35_3.png

图 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()
plot_2025-02-25 16-05-35_4.png

图 5 九个离散变量计数图:先看类别比例,才能判断模型学到的是信号还是样本占比。

连续变量的 KDE、箱线图、小提琴图、饼图、散点图、Pairplot 和 Jointplot 继续从不同角度看同一批数据。读图时要抓住三个问题:变量是否偏态,类别之间是否分离,变量之间是否存在可学习的边界。

plot_2025-02-25 16-05-35_5.png

图 6 年龄与生存时间 KDE:年龄近似钟形分布,生存时间明显右偏。

plot_2025-02-25 16-05-35_6.png

图 7 箱线图:分期和生存时间的阶梯关系非常清楚,性别与年龄差异不大。

plot_2025-02-25 16-05-35_7.png

图 8 小提琴图:不同治疗方式的生存时间分布大量重叠,提示单变量很难直接决定结局。

plot_2025-02-25 16-05-35_8.png

图 9 饼图:Stage IV 与化疗样本占比较高,是必须向读者交代的数据背景。

plot_2025-02-25 16-05-35_9.png

图 10 散点图:年龄、生存时间、性别、饮酒和体力活动之间没有简单线性边界。

plot_2025-02-25 16-05-35_10.png

图 11 Pairplot:把多个变量放在同一张图中看,能快速发现分期与生存时间的分层。

plot_2025-02-25 16-05-35_11.png

图 12 年龄与生存时间联合分布:高密度区域集中在 55-75 岁、短生存时间附近。

plot_2025-02-25 16-05-35_12.png

图 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}")
模型测试集 Accuracy5 折 CV Accuracy
Random Forest0.87300.8715
Gradient Boosting0.87300.8715
SVM0.87300.8716
KNN0.86000.8582
Decision Tree0.75600.7529
Naive Bayes0.87300.8716
Logistic Regression0.87300.8716
XGBoost0.87060.8700
LightGBM0.87300.8715
CatBoost0.87290.8715
LDA0.87300.8716
QDA0.41490.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=Nonemin_samples_split=5n_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))
排名特征重要性
1num__Age0.145216
2num__Survival_Time_Months0.130356
3num__Weight_Loss0.025595
4num__Abdominal_Discomfort0.024002
5num__Smoking_History0.023476
6num__Alcohol_Consumption0.022799
7num__Back_Pain0.022718
8num__Obesity0.021172
9num__Development_of_Type2_Diabetes0.020319
10cat__Country_United States0.019833
11num__Family_History0.018883
12cat__Treatment_Type_Chemotherapy0.018526
13cat__Economic_Status_Middle0.018510
14cat__Access_to_Healthcare_Medium0.017709
15num__Jaundice0.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 分析单个样本为什么被模型判成某类。这样才是从“代码能跑”走向“模型可信”的路径。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐