简介:这份资源是第七届“泰迪杯”数据挖掘挑战赛B题“直肠癌淋巴结转移的智能诊断”的完整参赛方案,面向刚接触数据挖掘竞赛的初学者与在校大学生,帮助其理解医学影像分类任务的建模流程与代码实现。压缩包共24个文件,约2.37MB,以Python脚本和Jupyter Notebook为主,辅以说明文档、结果图片与演示动图,覆盖数据生成、模型训练、测试推理及结果整理等环节。其中Unet.py与train.py、test.py构成分割与训练主线,多个ipynb用于实验验证与结果检查,txt说明文件则交代各模块用途。目前已有114人学习下载。读者可借此获得一套可运行的赛题基线方案,包括数据预处理脚本、网络结构定义、训练与推理代码、结果生成工具及排错思路,适合对照复现并逐步理解直肠癌淋巴结转移智能诊断的完整技术链路。
1. 直肠癌淋巴结转移智能诊断:一个竞赛题为什么值得当成真实项目做
第一次看到“直肠癌淋巴结转移的智能诊断”这个题目,很多人会以为它是一道纯医学题,跟自己写代码的关系不大。但把数据摊开就会发现,它本质是一道结构化数据的二分类问题:给一批直肠癌患者的临床与影像特征,预测其淋巴结是否发生转移。竞赛场景下它叫“泰迪杯B题”,落地场景下它就是一套辅助诊断的表格分类流水线。对刚入门数据挖掘的人来说,这道题的价值在于:数据规模适中、特征语义清晰、评价指标明确,而且从缺失值处理到模型融合的完整链路一个都不缺。你不需要医学背景,只需要把一套标准的表格建模流程走扎实,就能拿到一个能解释、能复现的结果。下面我按自己带新人做这类题的顺序,把整条路径拆开讲。
2. 先看清数据长什么样:字段、标签与分布
2.1 竞赛数据的典型结构
这类医学表格数据通常由三部分构成:患者标识列、若干临床/影像特征列、以及一个淋巴结转移标签列。特征可能包括年龄、性别、肿瘤大小、T分期、N分期、分化程度、CEA等指标,标签一般是0/1二值。不同年份的题目字段名会有差异,但结构高度相似。
拿到数据后第一件事不是建模,而是把下面这张表填出来。我一般会先跑一段探查代码,把每个字段的类型、缺失率、取值范围列清楚,再决定后续处理策略。
| 检查项 | 关注点 | 常见异常 |
|---|---|---|
| 字段类型 | 数值/类别/文本 | 数值列混入字符串 |
| 缺失率 | 单列缺失比例 | 某列缺失超50% |
| 标签分布 | 正负样本比例 | 正样本不足20% |
| 取值范围 | 是否有越界值 | 年龄出现负数 |
| 重复行 | 完全重复记录 | 同一患者多条 |
2.2 用 pandas 做一次完整的数据体检
import pandas as pd import numpy as np # 读取训练数据,具体文件名以实际数据为准 df = pd.read_csv("train.csv") # 1. 基本信息:行列数、字段类型 print("shape:", df.shape) print(df.dtypes) # 2. 缺失率排序,快速定位问题列 missing = df.isnull().mean().sort_values(ascending=False) print(missing[missing > 0]) # 3. 标签分布,判断是否需要处理类别不平衡 label_col = "label" # 标签列名按实际数据替换 print(df[label_col].value_counts(normalize=True)) # 4. 数值列描述统计,看是否有越界值 print(df.describe().T[["min", "max", "mean", "std"]]) # 5. 检查完全重复行 print("duplicated rows:", df.duplicated().sum())这段代码的逻辑是:先看整体规模,再逐层缩小问题范围。isnull().mean()给出每列缺失比例,比isnull().sum()更直观,因为你能立刻判断某列是否值得保留。value_counts(normalize=True)输出的是比例而非计数,方便判断正负样本是否均衡。describe()的 min/max 能快速暴露异常值,比如年龄出现200这种明显错误。
参数上唯一需要你手动改的是label_col,其余都是通用写法。如果数据是Excel格式,把read_csv换成read_excel即可。如果标签列名不确定,可以先print(df.columns.tolist())看一眼。
2.3 标签不平衡时先别急着上采样
医学数据里正样本偏少是常态。假设转移样本只占15%,直接训练会让模型倾向于全预测为负。常见做法有三种:调整class_weight、SMOTE过采样、以及阈值移动。我的建议是先用class_weight='balanced',因为它不改变数据分布,不会引入合成样本带来的噪声。SMOTE适合样本量足够大且特征连续的情况,如果特征里有大量类别变量,SMOTE插值出来的样本可能没有现实意义。
判断是否需要处理的阈值:正样本占比低于20%时值得关注,低于10%时必须处理。但也不要一看到不平衡就上采样,先跑一个基线模型看混淆矩阵,如果召回率还能接受,就不用折腾。
3. 特征工程:把临床指标变成模型能吃的数值
3.1 缺失值填充的三种策略与选择依据
缺失值处理没有万能方案,关键看缺失机制。如果缺失是随机的,均值/中位数填充够用;如果缺失本身携带信息(比如某项检查没做,可能暗示病情特征),就应该把“是否缺失”也作为一个特征。
from sklearn.impute import SimpleImputer # 数值列:中位数填充,抗异常值 num_cols = ["age", "tumor_size", "cea"] # 按实际数值列替换 num_imputer = SimpleImputer(strategy="median") df[num_cols] = num_imputer.fit_transform(df[num_cols]) # 类别列:众数填充 cat_cols = ["gender", "t_stage", "differentiation"] cat_imputer = SimpleImputer(strategy="most_frequent") df[cat_cols] = cat_imputer.fit_transform(df[cat_cols]) # 对缺失率高的列,额外加一个缺失标记特征 for col in num_cols: if df[col].isnull().mean() > 0.1: df[col + "_is_missing"] = df[col].isnull().astype(int)中位数比均值更适合医学指标,因为肿瘤大小、CEA这类值往往右偏,均值会被极端值拉高。most_frequent对类别列是安全选择。缺失标记特征是个容易被忽略的技巧:当某列缺失率超过10%时,缺失本身可能与标签相关,加一个0/1列让模型自己学。
注意fit_transform只能在训练集上做,验证集和测试集要用transform,否则会数据泄漏。这是新手最常翻车的地方之一。
3.2 类别编码:有序变量别用独热
T分期、N分期、分化程度这些是有序类别,直接用LabelEncoder或手动映射成数字,保留顺序信息。性别这种无序类别用独热编码。如果一律用独热,会丢失分期之间的递进关系,模型学起来更费劲。
from sklearn.preprocessing import LabelEncoder # 有序变量:手动映射,保证顺序正确 stage_map = {"I": 1, "II": 2, "III": 3, "IV": 4} df["t_stage_num"] = df["t_stage"].map(stage_map) # 无序变量:独热编码 df = pd.get_dummies(df, columns=["gender"], drop_first=True) # 剩余无法手动映射的类别列,用LabelEncoder兜底 le = LabelEncoder() for col in ["differentiation"]: df[col] = le.fit_transform(df[col].astype(str))drop_first=True是为了避免独热编码产生的多重共线性,对线性模型尤其重要。树模型虽然不太受影响,但少一列能省一点计算。手动映射的好处是可控:你知道I对应1、IV对应4,而不是让编码器随机分配。
3.3 特征筛选:先看相关性,再看重要性
特征不是越多越好。医学数据字段本来就有限,但衍生特征容易膨胀。我一般分两步:先算与标签的相关系数,剔除几乎无关的;再用树模型的特征重要性做二次筛选。
from sklearn.ensemble import RandomForestClassifier # 相关系数筛选(仅适用于数值特征) corr = df.corr()["label"].abs().sort_values(ascending=False) print(corr.head(15)) # 树模型特征重要性 X = df.drop(columns=["label", "patient_id"], errors="ignore") y = df["label"] rf = RandomForestClassifier(n_estimators=200, random_state=42) rf.fit(X, y) importance = pd.Series(rf.feature_importances_, index=X.columns) print(importance.sort_values(ascending=False).head(15))相关系数只能捕捉线性关系,所以它只做粗筛。随机森林的重要性基于分裂增益,能捕捉非线性关系,但要注意:高基数类别特征(取值很多的列)容易被高估。如果发现某个ID类特征重要性异常高,直接删掉,那多半是泄漏。
n_estimators=200是个稳妥的起点,再多收益递减。random_state固定住,保证你每次跑的结果一致,方便对比。
4. 建模与调参:从基线到可提交的结果
4.1 先跑一个不打磨的基线
新手最容易犯的错是一上来就调参。正确顺序是:先用一个默认参数的逻辑回归或随机森林跑通全流程,拿到一个基线分数,再逐步优化。基线的作用是告诉你“数据本身能到什么水平”,后续所有改进都跟它比。
from sklearn.model_selection import cross_val_score, StratifiedKFold from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler # 分层交叉验证,保证每折标签比例一致 cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) # 逻辑回归基线:标准化 + 模型 pipe = Pipeline([ ("scaler", StandardScaler()), ("clf", LogisticRegression(class_weight="balanced", max_iter=1000)) ]) scores = cross_val_score(pipe, X, y, cv=cv, scoring="roc_auc") print("AUC:", scores.mean(), "+/-", scores.std())StratifiedKFold比普通KFold更适合分类问题,因为它保证每折的正负比例与整体一致。class_weight="balanced"自动按类别频率反比加权。max_iter=1000是为了避免逻辑回归不收敛的警告。评价指标用AUC而不是准确率,因为不平衡数据下准确率没有参考价值。
4.2 树模型调参:只调真正重要的几个
XGBoost或LightGBM的参数有几十个,但真正影响大的就那么几个。我一般按这个顺序调:学习率、树的数量、最大深度、正则化项。其余用默认值。
import lightgbm as lgb from sklearn.model_selection import GridSearchCV param_grid = { "n_estimators": [100, 300, 500], "max_depth": [3, 5, 7], "learning_rate": [0.01, 0.05, 0.1], "reg_alpha": [0, 0.1, 1], "reg_lambda": [0, 0.1, 1] } model = lgb.LGBMClassifier(class_weight="balanced", random_state=42) grid = GridSearchCV(model, param_grid, cv=cv, scoring="roc_auc", n_jobs=-1) grid.fit(X, y) print("best params:", grid.best_params_) print("best AUC:", grid.best_score_)参数含义:n_estimators是树的数量,太少欠拟合,太多过拟合;max_depth控制单棵树复杂度,医学数据样本量通常不大,深度3到5就够;learning_rate与树数量此消彼长,小学习率配多棵树通常效果更好但更慢;reg_alpha和reg_lambda分别是L1和L2正则,能抑制过拟合。
n_jobs=-1用满所有CPU核心。网格搜索的组合数是3×3×3×3×3=243种,每种跑5折,计算量不小。如果时间紧,可以先粗调再细调,比如先定learning_rate=0.05,只搜其他三个。
4.3 模型融合:简单平均往往就够了
竞赛里常见的提分手段是融合多个模型。但融合不是越多越好,模型之间要有差异性。我一般选三个:逻辑回归(线性视角)、随机森林(Bagging)、LightGBM(Boosting)。融合方式用加权平均,权重按交叉验证AUC分配。
from sklearn.ensemble import RandomForestClassifier from sklearn.linear_model import LogisticRegression import numpy as np # 三个基模型 models = { "lr": Pipeline([("scaler", StandardScaler()), ("clf", LogisticRegression(class_weight="balanced", max_iter=1000))]), "rf": RandomForestClassifier(n_estimators=300, class_weight="balanced", random_state=42), "lgb": lgb.LGBMClassifier(**grid.best_params_, class_weight="balanced", random_state=42) } # 用交叉验证获取每个模型的OOF预测 from sklearn.model_selection import cross_val_predict oof_preds = {} for name, m in models.items(): oof_preds[name] = cross_val_predict(m, X, y, cv=cv, method="predict_proba")[:, 1] # 按AUC分配权重 from sklearn.metrics import roc_auc_score aucs = {name: roc_auc_score(y, p) for name, p in oof_preds.items()} total = sum(aucs.values()) weights = {name: a / total for name, a in aucs.items()} print("weights:", weights) # 加权平均得到最终预测 final_pred = sum(oof_preds[name] * weights[name] for name in models) print("ensemble AUC:", roc_auc_score(y, final_pred))cross_val_predict返回的是每个样本在未参与训练的那一折上的预测,也就是OOF(Out-of-Fold)预测。用OOF来评估融合效果比用训练集预测靠谱得多,因为后者存在过拟合。权重按AUC归一化是一种朴素但有效的做法,比等权平均略好。
如果融合后AUC提升不到0.005,说明模型之间同质性太强,换模型比调权重更有效。
5. 避坑与排查:那些让分数莫名下降的细节
5.1 数据泄漏:验证集分数虚高的头号原因
现象:交叉验证AUC高达0.95,但提交后测试集只有0.7。原因:在划分训练验证之前就做了全局的缺失值填充或标准化,导致验证集的信息泄漏到训练过程。解决:把所有预处理步骤放进Pipeline,让它们只在每折的训练数据上fit。上面基线代码里的Pipeline就是正确写法。
5.2 标签编码顺序错误
现象:模型效果始终上不去,特征重要性也很分散。原因:有序变量被当无序处理,或者映射顺序搞反了,比如把IV期映射成了1。解决:手动建立映射字典,打印映射后的分布确认一遍。df["t_stage_num"].value_counts().sort_index()能帮你看清每个值有多少样本。
5.3 交叉验证的折数选择不当
现象:5折AUC波动很大,换一次随机种子结果差0.05。原因:样本量小,单折验证集太小导致方差大。解决:改用10折,或者用重复分层交叉验证(RepeatedStratifiedKFold)。如果正样本只有几十个,5折下每折验证集只有十几个正样本,AUC估计本身就不稳定。
5.4 忽略特征量纲导致线性模型失效
现象:逻辑回归AUC只有0.6,但树模型正常。原因:逻辑回归对特征尺度敏感,年龄是0到100,CEA可能是0到1000,不标准化的话梯度下降会被大数值特征主导。解决:线性模型前必须加StandardScaler,树模型则不需要。这也是为什么Pipeline里标准化只放在逻辑回归那一支。
5.5 过拟合训练集却不自知
现象:训练集AUC 0.99,交叉验证0.75。原因:模型太复杂,或者特征数远大于样本数。解决:先降复杂度,减少树深度、增加正则;再考虑减特征。医学数据样本量通常几百到几千,特征几十个,这种比例下深度超过7的树几乎一定过拟合。
6. 把结果讲清楚:从AUC到临床可解释的阈值选择
模型跑出AUC只是第一步,真正要交付的是一个能用的判断规则。AUC衡量的是排序能力,但临床使用需要一个明确的阈值:预测概率超过多少判为转移。默认的0.5在类别不平衡时往往不是最优的。
from sklearn.metrics import roc_curve, confusion_matrix # 基于OOF预测找最佳阈值 fpr, tpr, thresholds = roc_curve(y, final_pred) youden = tpr - fpr best_threshold = thresholds[np.argmax(youden)] print("best threshold:", best_threshold) # 用最佳阈值看混淆矩阵 pred_label = (final_pred >= best_threshold).astype(int) print(confusion_matrix(y, pred_label))Youden指数(tpr - fpr)是常用的阈值选择方法,它最大化真阳性率与假阳性率之差。但实际场景中,漏诊的代价通常高于误诊,所以你可能需要手动把阈值调低,牺牲一点特异度换更高的灵敏度。这个取舍没有标准答案,取决于应用场景对漏诊的容忍度。
我一般会画一张阈值-灵敏度-特异度的对照表,让结果一目了然:
| 阈值 | 灵敏度 | 特异度 | 适用场景 |
|---|---|---|---|
| 0.3 | 高 | 低 | 筛查,宁可误报 |
| 0.5 | 中 | 中 | 默认平衡 |
| 0.7 | 低 | 高 | 确诊,宁可漏报 |
最后说一个我自己的习惯:每次跑完模型,我都会把特征重要性前10名列出来,逐个人工检查是否符合医学常识。如果某个特征重要性很高但逻辑上讲不通,大概率是数据问题而不是模型发现了新规律。这个检查帮我抓到过好几次字段错位和编码错误。希望帮到你。
本文还有配套的精品资源,点击获取