
简介针对第七届“泰迪杯”数据挖掘挑战赛B题“直肠癌淋巴结转移的智能诊断”这份面向初学者的完整解决方案打包了源码、实验笔记与说明文档。资源以Python脚本和Jupyter Notebook为主包含Unet.py模型定义、数据预处理与HDF5数据集生成/读写脚本、训练与预测脚本以及多个辅助理解代码的Notebook实验另配有训练、测试、展示说明txt和README图片与动图则直观呈现了实验过程与结果。全包共24个文件大小仅2.37MB轻量易用适合数据挖掘或医学图像分析入门者对照复现。设计上从数据生成、模型训练到结果检查逐步拆解覆盖了医学影像分割任务的主要环节能帮助小白理解U-Net在医学影像分割中的完整流程。压缩包由作者lijunhcn整理目前已有114人学习整体目录清晰值得作为同类赛事备赛或课堂实训的重要参考。1. 泰迪杯B题直肠癌淋巴结转移智能诊断这套Unet源码包好在哪里第七届泰迪杯B题要求做直肠癌淋巴结转移的智能诊断本质上是一个医学图像分割问题从CT影像里把淋巴结区域标出来再根据区域特征辅助判断转移风险。把开源的挑战杯源码包拆完一遍后我比较直接的感受是这份代码包非常适合入门者做第一次医学影像分割实战。它没有一上来就上复杂的多模型融合而是老老实实地用Unet做了端到端的分割训练再配合HDF5做数据封装预处理、训练、测试、后处理环节都很完整适合“想从0到1跑通并提交一个能打的结果”的同学。这个包里值得关注的不是它拿了几等奖而是整个pipeline的完整性数据怎么写进HDF5、模型怎么搭、训练曲线怎么监控、预测时二维概率图怎么复原成三维结果并求体积。对于做数据挖掘竞赛的人来说最痛苦的不是不会调参而是不知道一份医疗影像数据该从哪个环节切进去。本文按“工程结构 → 数据生成 → 模型搭建 → 训练调参 → 测试后处理 → 避坑”的顺序拆解新手可以照着跑通熟手可以看参数边界和踩坑记录。2. 从文件清单到数据生产先把项目结构和预处理链路理清楚2.1 工程结构拆解理解每个文件的职责把压缩包解压之后整个项目的代码逻辑比较清晰关键文件如下utils.py、train.py、test.py、Unet.py、HDF5DatasetGenerator.py、HDF5DatasetWriter.py、generate_train.py、generate_test.py、result_test.py、result_name.py、result_generate.py。还有几个Jupyter Notebook比如助于理解代码的实验.ipynb、求体积.ipynb、【学习】测试函数.ipynb、检查模型结果.ipynb适合顺着跑一遍来理解中间结果。初看这个结构不少人容易犯一个错误直接打开train.py开始跑。实际上这个工程的数据处理入口是generate_train.py和generate_test.py。这两个脚本负责把原始训练数据读进来经过图像处理后写入HDF5文件。后面train.py不再直接读取原始图片而是从HDF5里一批批取数据这样做训练时IO效率会高不少尤其在图片数量大、单张体积大的时候优势比较明显。HDF5DatasetGenerator.py提供的是一个Keras风格的数据生成器从HDF5里按batch读取数据和标签。HDF5DatasetWriter.py则是把预处理后的图像数据写进HDF5的写入器。这两个文件的职责很干净一个管写一个管读。Unet.py是网络结构定义文件核心就是那个build_unet函数。utils.py里放的是一些公共函数比如图像直方图均衡化、对比度调整、文件列表读取等。2.2 数据预处理细节为什么直方图均衡化对CT影像分割很重要先打开generate_train.py看数据是怎么被处理的。核心逻辑是把原始图像和目标掩膜mask成对读入然后做预处理、resize、写入HDF5。预处理部分有一个值得注意的细节对原始图像做了直方图均衡化。为什么要做这一步因为CT影像的灰度分布通常比较集中不同病人的扫描参数不一致直接丢给网络训练容易让模型学到扫描设备本身的特征而不是组织特征。通过直方图均衡化可以把灰度分布拉开增强对比度尤其有利于淋巴结这种与周围组织灰度差异较小的目标。以下是generate_train.py里最核心的处理流程简化版import cv2 import numpy as np import h5py def preprocess_image(image): # 将图像转为灰度图并做直方图均衡化 gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) enhanced clahe.apply(gray) return enhanced def generate_train_hdf5(file_list, mask_list, output_path, target_size(512, 512)): # file_list: 原始CT图像路径列表 # mask_list: 对应的标签掩膜路径列表 dataset_count len(file_list) with h5py.File(output_path, w) as h5: # 预创建数据集避免反复写入导致文件碎片化 images h5.create_dataset(images, shape(dataset_count, target_size[0], target_size[1], 1), dtypenp.float32) masks h5.create_dataset(masks, shape(dataset_count, target_size[0], target_size[1], 1), dtypenp.float32) for i, (img_path, mask_path) in enumerate(zip(file_list, mask_list)): image cv2.imread(img_path) mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 统一尺寸 image_resized cv2.resize(image, target_size, interpolationcv2.INTER_LINEAR) mask_resized cv2.resize(mask, target_size, interpolationcv2.INTER_NEAREST) # 归一化到[0, 1] preprocessed preprocess_image(image_resized) preprocessed preprocessed.astype(np.float32) / 255.0 mask_resized (mask_resized 127).astype(np.float32) images[i] preprocessed[..., np.newaxis] masks[i] mask_resized[..., np.newaxis] h5.close()这里有几个参数值得留意。cv2.createCLAHE里clipLimit2.0是直方图裁剪阈值它控制对比度增强的力度太大容易把噪声也放大太小效果不明显tileGridSize(8, 8)是把图像分成8×8的小块做局部直方图均衡这比全局均衡更能保留局部细节。mask在resize时用cv2.INTER_NEAREST这一步很关键因为标签是类别索引不能做线性插值。还有一个容易踩坑的地方images和masks的数据类型设成了np.float32而不是np.uint8。工程上这样做的原因是训练时网络输入通常需要归一化后的浮点数提前转成float32可以省掉训练循环里的类型转换开销。代价是文件体积变大512×512单通道float32每张固定1MB几百张图就是几百MB磁盘空间要提前预留。2.3 数据生成器训练时为什么要用HDF5DatasetGenerator而不直接读图HDF5DatasetGenerator.py做的事情是配合Keras的fit_generator按batch取数据。它内部维护了一个索引数组每个epoch结束时打乱顺序然后按batch_size切片返回数据和标签。这样做的好处是训练集全部放进内存或磁盘映射Python端不用反复做图像解码训练速度明显快于在__getitem__里现读现处理。看一下这个生成器的初始化参数就明白它的设定了class HDF5DatasetGenerator: def __init__(self, h5_path, batch_size8, preprocessorsNone, augmentorsNone): self.db h5py.File(h5_path, r) self.batch_size batch_size self.num_images self.db[images].shape[0] self.preprocessors preprocessors self.augmentors augmentors self.indices np.arange(self.num_images)batch_size8对于Unet来说是一个平衡显存和梯度稳定性的选择如果显卡显存只有6GBbatch_size可以降到4甚至2preprocessors和augmentors是扩展点如果想做随机翻转、旋转、亮度扰动只需要传入对应的函数列表就行。这个设计同时保证了训练时数据增强的灵活性和数据读取的高效性是一个值得借鉴的工程模式。3. Unet.py逐段解析网络结构改动与baseline的差距3.1 build_unet核心结构双重下采样与跳跃连接Unet.py里的build_unet函数不是严格意义上的标准Unet它在结构和细节上做了改动。标准的Unet是编码器—解码器对称结构编码器逐步下采样提取特征解码器逐步上采样恢复分辨率同一层的编码器特征通过跳跃连接传给解码器融合低级空间信息和高级语义信息。这套代码在这个基础上做了一些微调让模型更适配CT影像中淋巴结这种小目标的检测场景。def build_unet(input_shape(512, 512, 1), num_classes1, depth3): inputs Input(input_shape) # 编码器逐层下采样 skip_connections [] x inputs for i in range(depth): x Conv2D(64 * (2 ** i), (3, 3), paddingsame, activationrelu)(x) x BatchNormalization()(x) x Conv2D(64 * (2 ** i), (3, 3), paddingsame, activationrelu)(x) x BatchNormalization()(x) skip_connections.append(x) x MaxPooling2D(pool_size(2, 2))(x) # 瓶颈层 x Conv2D(64 * (2 ** depth), (3, 3), paddingsame, activationrelu)(x) x BatchNormalization()(x) x Conv2D(64 * (2 ** depth), (3, 3), paddingsame, activationrelu)(x) x BatchNormalization()(x) # 解码器逐层上采样并拼接跳跃连接 for i in range(depth - 1, -1, -1): filters 64 * (2 ** i) x Conv2DTranspose(filters, (2, 2), strides(2, 2), paddingsame)(x) x Concatenate()([x, skip_connections[i]]) x Conv2D(filters, (3, 3), paddingsame, activationrelu)(x) x BatchNormalization()(x) x Conv2D(filters, (3, 3), paddingsame, activationrelu)(x) x BatchNormalization()(x) outputs Conv2D(num_classes, (1, 1), activationsigmoid)(x) model Model(inputsinputs, outputsoutputs) return model# 使用示例 unet_model build_unet(input_shape(512, 512, 1), num_classes1, depth3) unet_model.compile(optimizerAdam(lr1e-4), lossbinary_crossentropy, metrics[accuracy])理解这个函数的关键是把depth3当作一个可调参数来看。depth控制网络的层数直接影响模型的参数量。depth3时第一层卷积是64个滤波器每下采样一次翻倍最终瓶颈层是512个滤波器。如果数据量较小depth2可以降低过拟合风险如果显存充足、数据量也大depth4能提取更丰富的语义特征。Conv2DTranspose是转置卷积用来把特征图尺寸翻倍。这里值得注意的是转置卷积之后紧跟Concatenate把编码器对应层的输出拼回来通道数合并后再做卷积这样解码器能同时利用深层语义和浅层纹理信息对分割边缘有好处。每个Conv2D后面都跟了BatchNormalization能够显著加速收敛同时起到一定的正则化效果。3.2 损失函数与指标选择的细节差异train.py里选择的损失函数是binary_crossentropy在num_classes1且有sigmoid激活的情况下这本质上是二分类的交叉熵。对淋巴结分割来说正负样本比例可能严重失衡淋巴结区域在整张CT切片中占比很少直接套用binary_crossentropy会导致模型倾向于把所有像素都预测为背景。常见的替代方案是Dice loss或Focal loss。Dice loss直接优化分割区域的Dice系数对小目标更友好Focal loss通过调制系数降低易分类样本的损失权重让模型更关注难样本。这套代码选择binary_crossentropy的原因在于简单、稳定、容易复现且对于基础baseline来说已经足够进入评分范围。如果你想在它的基础上提升成绩我一般会建议先把loss换成Dice loss或Dicebinary cross entropy的组合def dice_coef(y_true, y_pred, smooth1.0): y_true_f K.flatten(y_true) y_pred_f K.flatten(y_pred) intersection K.sum(y_true_f * y_pred_f) return (2.0 * intersection smooth) / (K.sum(y_true_f) K.sum(y_pred_f) smooth) def dice_loss(y_true, y_pred): return 1.0 - dice_coef(y_true, y_pred)例如使用model.compile(optimizerAdam(lr1e-4), lossdice_loss, metrics[dice_coef])这个组合对于CT分割来说通常比纯交叉熵更稳因为训练过程不再受背景像素数量主导。但要提醒一句Dice loss在极端情况下可能出现训练曲线震荡需要把学习率适当调低预留更长的训练轮数。如果网络权重初始化不当或者学习率太大网络输出可能为0或全1导致loss为nan。遇到这种情况时可以检查输入数据里是否有NaN值以及标签是否完全为0这两个都是竞赛场景中比较常见的翻车原因。4. 训练流程与调参实战从generate_train到epoch设置的关键决策4.1 train.py的启动方式monitor、early stopping和checkpoint的作用在train.py里整个训练流程可以拆成几个阶段加载数据生成器、编译模型、设置回调函数、执行fit_generator。这里最值得关注的是回调函数配置它直接决定训练是否能在有限算力下尽早收敛、是否能保存最佳权重。from keras.callbacks import ModelCheckpoint, EarlyStopping, ReduceLROnPlateau checkpoint ModelCheckpoint(unet_weights.hdf5, monitorval_loss, save_best_onlyTrue, verbose1) early_stopping EarlyStopping(monitorval_loss, patience10, verbose1) reduce_lr ReduceLROnPlateau(monitorval_loss, factor0.5, patience5, min_lr1e-6, verbose1) history model.fit_generator( train_generator, steps_per_epochtrain_generator.num_images // batch_size, validation_dataval_generator, validation_stepsval_generator.num_images // batch_size, epochs100, callbacks[checkpoint, early_stopping, reduce_lr])patience10表示验证集loss连续10个epoch没有改善就停止训练这是防止过拟合和节省时间的重要机制。factor0.5表示验证集loss连续5个epoch不下降时学习率减半直到min_lr1e-6。这种“学习率自动衰减”的做法在医学图像分割训练中几乎成了标配它允许模型在接近收敛时用更小的步长在损失曲面里找到更优的位置。一个容易被忽略的细节是steps_per_epoch的计算方式。如果没有按num_images // batch_size来设置而直接省略该参数Keras会根据生成器长度自动推断。但在HDF5DatasetGenerator这类自定义生成器上自动推断有时会出问题导致每个epoch读取的batch数量不对影响训练曲线的一致性判断。手动写清楚这个值是一种比较稳妥的做法。4.2 数据集划分验证集该怎么从HDF5里拆出来generate_train.py在写HDF5的时候通常会把全部训练数据写成一个文件。而在训练时需要一个验证集来监控过拟合常见做法是在训练脚本里做第二次划分把HDF5里的数据按比例拆出20%作为验证集。这个做法需要配合HDF5DatasetGenerator的起始索引和终止索引来实现train_generator HDF5DatasetGenerator(h5_path, batch_sizebatch_size) val_generator HDF5DatasetGenerator(h5_path, batch_sizebatch_size) # 假设前80%的数据作为训练后20%作为验证 train_generator.indices np.arange(int(train_generator.num_images * 0.8)) val_generator.indices np.arange(int(train_generator.num_images * 0.8), train_generator.num_images)两个生成器指向同一个HDF5文件只是用不同索引范围切分数据。这里有一个需要注意的边界问题如果HDF5的写入顺序是按病人逐一切片组织的直接切前80%和后20%会将同一个病人的样本同时分到训练集和验证集造成数据泄漏导致验证指标虚高。我一般会先按病人编号分组再以病人为单位划分而不是以切片为单位。这一点对医学影像竞赛相当重要在CT序列里相邻切片高度相似切片级划分会高估模型性能。4.3 epoch数量和一二次训练的经验区间先baseline后精调对于这种规模的工程epoch设置在50到120之间是比较常见的区间。数据量不大时100个epoch足够让Unet收敛到不错的水平如果数据量较大超过2000张切片50到80个epoch可能就够了更多epoch反而容易过拟合。第一次训练建议完全不设early stopping直接跑30个epoch观察loss曲线是否平滑下降如果抖动剧烈首先检查batch size是否过小、学习率是否过大其次确认数据预处理和标签是否有问题。我的个人习惯是先跑一轮baseline记录验证集Dice或accuracy再针对loss曲线调整学习率和batch size。学习率初始值1e-4配合Adam在Unet训练中相对安全如果想把初始阶段跑得更快可以先用1e-3跑5个epoch再降回1e-4正式训练但这种方式需要手动干预且容易让模型陷入局部最优不太建议新手尝试。5. 测试与结果生成预测阶段最容易翻车的四个细节5.1 test.py和result_test.py之间的数据流关系看完训练再来看测试阶段。test.py负责对测试集CT影像做预测生成概率图result_test.py负责对概率图做后处理比如二值化、连通域分析并输出最终的分割结果。两个脚本前后衔接test.py生成的中间结果会被result_test.py读取。在test.py里测试图片同样先经过预处理再逐张做预测。由于医学CT影像原始尺寸一般不是512×512需要在预测前做resize并在预测后把结果还原到原始尺寸。这里最容易出现的问题是resize插值方式不一致训练时对mask用的是INTER_NEAREST预测时对概率图如果用线性插值去还原尺寸会造成边缘区域概率值过渡平滑二值化后边界位置出现一到两个像素的偏移。因此预测概率图还原尺寸时推荐继续用INTER_NEAREST或者先还原再二值化。5.2 预测时没有批量归一化统计量的问题BatchNormalization层在训练和预测时的行为不同。训练时使用当前batch的均值和方差预测时使用训练阶段累积的全局均值和方差。如果训练过程中频繁保存并加载权重加载后直接跑预测BatchNormalization的滑动均值可能还没有充分更新预测结果会略差。解决办法很简单在训练结束后用model.predict验证一遍训练集确认输出形态正常再把权重用于测试集。另外test.py里面如果采用的是先加载模型再逐个预测的方式每次预测前都要确认模型处于eval模式。在Keras中调用model.predict时框架会自动切换到测试模式这一步通常不需要手动设置但如果你在测试脚本里使用了自定义层或中间张量输出就要小心learning_phase被错误设置的问题。5.3 结果写入与命名result_name.py和result_generate.py在做什么最后一步是把预测的每个连通区域提取出来并跟原始测试文件的命名对应上。result_name.py负责建立测试图片文件名和预测结果索引之间的映射result_generate.py负责遍历预测结果对每个样本做连通域分析找到最大连通域或筛选符合条件的区域最终生成提交所需的结果文件。这里有一个医学图像竞赛的典型后处理问题分割结果中可能会出现多个零散的假阳性区域。如果不做后处理直接提交完整掩膜假阳性会被计入评估指标导致分数偏低。一个实用的做法是后处理时保留面积最大的连通域或者按体积/面积阈值过滤小区域。以result_generate.py的逻辑为例通常做法是import cv2 import numpy as np def extract_largest_connected_component(binary_mask): # binary_mask: 单通道二值图值为0或255 num_labels, labels, stats, centroids cv2.connectedComponentsWithStats(binary_mask, connectivity8) if num_labels 1: return binary_mask # 第一个连通域是背景从索引1开始找最大面积 largest_label 1 np.argmax(stats[1:, cv2.CC_STAT_AREA]) largest_component (labels largest_label).astype(np.uint8) * 255 return largest_componentconnectedComponentsWithStats返回四个值stats矩阵的cv2.CC_STAT_AREA列是各连通域的面积。根据这个面积做阈值筛选可以过滤掉明显不合理的预测区域。但阈值不是固定的你需要先用验证集统计一下假阳性区域和真实阳性区域的面积分布再确定具体阈值这比拍脑袋设定更可靠。6. 避坑与常见问题排查复现这套代码的五个血泪经验6.1 显存溢出错误提示“ResourceExhaustedError”现象训练跑到第二个epoch就报ResourceExhaustedError程序直接退出。原因Unet结构加上512×512输入在batch_size较大时对显存的峰值占用很恐怖。解决先把batch_size降到2或4同时把fit_generator里的workers参数调低。如果还溢出用build_unet(input_shape(256, 256, 1))降分辨率训练等训练稳定后再回到512×512做精调。这个降分辨率方案可以作为备选手段。6.2 验证集loss不降反升多数是数据划分泄漏现象训练loss稳步下降但验证集loss在第10个epoch后开始反弹。原因验证集和训练集来自同一个病人的相邻切片信息高度重叠导致验证loss开始时很低而当训练集过拟合后验证集也跟着被“污染”了。解决以病人为单位划分数据集确保同一个病人的所有切片要么全在训练集要么全在验证集不要交叉分布。6.3 预测mask是空的检查图像归一化与阈值现象跑完test.py后输出的mask图全黑。原因预测概率图比较低所有值都小于0.5的二值化阈值。解决先不要直接二值化把概率图保存为图像并查看它的数值分布。常见做法是改用np.percentile(prob_map, 95)作为动态阈值或者对概率图先做一次中值滤波。如果概率图整体数值偏低需要检查输入图像预处理是否与训练时完全一致。6.4 激活函数对输出概率值的影响sigmoid和softmax别搞混现象mask预测结果呈现出多个类别区域重叠。原因Unet.py里最后用的是num_classes1加sigmoid但在后处理阶段使用了argmax去取类别索引导致只取到了一个通道的值结果错乱。解决确认输出层的激活函数与后处理逻辑相匹配。二分类单通道用sigmoid后处理直接对单通道概率图做阈值多分类多通道用softmax后处理用argmax。6.5 训练到一半因磁盘写满而中断HDF5文件占用空间怎么算现象训练没问题但程序在写checkpoint时系统提示No space left on device。原因HDF5文件是预分配空间的加上每次epoch的checkpoint权重文件磁盘占用很容易超过预期。解决生成HDF5之前先计算好总大小并预留同等大小的剩余空间ModelCheckpoint里只保留最优的unet_weights.hdf5不要每一轮都保存新权重。7. 进阶技巧用检查模型结果.ipynb快速定位bad case并动态评估分割效果与其反复跑完整训练流程不如学会高频使用检查模型结果.ipynb这个Notebook来定位问题。它本质上是一个可视化调试工具把测试图片、预测概率图、二值化mask和真实标签并排展示让人一眼看出模型在哪些切片上分割失败。经过前面的训练和测试你已经有了模型权重文件和预测结果这时候按下面的方式把bad case挑出来比盯着loss曲线有效得多。import matplotlib.pyplot as plt import numpy as np def visualize_prediction(image, mask_true, mask_pred, save_pathNone): fig, axes plt.subplots(1, 3, figsize(15, 5)) axes[0].imshow(image, cmapgray) axes[0].set_title(Original Image) axes[1].imshow(mask_true, cmapgray) axes[1].set_title(Ground Truth) axes[2].imshow(mask_pred, cmapgray) axes[2].set_title(Prediction) for ax in axes: ax.axis(off) plt.tight_layout() if save_path: plt.savefig(save_path, dpi100, bbox_inchestight) plt.show()这段可视化函数有三个重点。第一个是必须把img、mask_true、mask_pred按同样尺寸送进去第二个是cmapgray对医学图像是标准显示方式彩图容易造成视觉误判第三个是保存时设置dpi100和bbox_inchestight避免输出图片四周留白过多。对于大批量bad case筛选可以循环遍历预测结果按Dice系数从小到大排序把最差的5张图和最好的5张图分别保存到不同目录然后逐张分析。观察顺序建议是先看原图目标是否清晰再看预测结果是否偏移最后看标签是否准确。很多情况下你以为模型错了仔细对比才发现是标签本身的标注瑕疵。从那以后我每次跑完一轮训练都会强制走一遍动态可视化流程不再直接拿数字指标下结论。希望这些拆解对你派得上用场。本文还有配套的精品资源点击获取