简介这是一份面向计算机相关专业毕业生的深度学习实战项目聚焦乳腺肿瘤超声图像的BI-RADS自动分级。项目基于Python实现包含完整源码、预处理后的超声图像数据集与训练权重可直接运行用于模型训练、评估与可视化也可作为毕业设计、课程设计或期末大作业的参考范本。资源共1219个文件其中1200张png超声图像构成核心数据6个py源码文件覆盖数据加载、模型构建与训练推理流程另含2个pth权重文件、配置文件和说明文档整体压缩包约589MB目录结构清晰便于按模块复现。目前已有53人学习下载适合希望快速上手深度学习医学图像分类任务的入门者。项目经导师指导并获得高分代码完整度较高下载后按说明即可启动实验有助于从数据处理到模型评估完整理解BI-RADS分级任务的解决链路是兼顾实战与论文研究的可靠资料。1. 深度学习做乳腺超声 BI-RADS 分级这份源码能帮你把毕设从 60 分冲到 90 分乳腺超声的 BI-RADS 分级是放射科医生每天都要写的结论从 0 到 6 级级别越高恶性风险越大。过去靠人眼判断形态、边界、钙化这些特征新手医生和资深医生之间的判读一致性并不高。用深度学习 CNN 自动做分级本质上是一个图像分类任务输入乳腺超声图输出 BI-RADS 等级。这个方向作为毕业设计很讨巧——医学背景、算法落地、可视化展示全都有而且数据本身是公开可获取的不需要自己跑医院采集。这份源码把整个流程都串起来了数据预处理、ROI 裁剪、模型训练、评估可视化完整跑通就是一套可直接答辩的毕设。适合正在做深度学习方向课设或毕设的同学也适合想快速上手医学图像分类项目的人。2. 数据与预处理先搞懂超声图像再谈模型2.1 为什么超声图不能直接扔进网络乳腺超声图像和自然图像最大的区别在于它是灰度图、噪声多、病灶和正常组织的对比度低而且一张图里往往只有一小块区域是真正的病灶。直接拿整张图去训练模型大概率学到的是探头位置、图像亮度这些无关特征而不是病灶本身的纹理和形态。这就是为什么源码里专门做了 ROIRegion of Interest裁剪。从项目文件里的 sample97_pseudo_rf1_roi1.png、sample63_pseudo_rf2_roi2.png 这些命名可以看出数据是按样本编号、随机森林特征序号、ROI 序号来组织的。也就是说每个样本对应多张 ROI 图这些图才是真正喂给网络的输入。我一般拿到超声数据的第一步不是写模型而是把所有图过一遍统计尺寸分布和灰度范围确认病灶区域是不是都集中在图像中部。import cv2 import numpy as np import glob # 统计所有样本图的尺寸和灰度分布 paths glob.glob(data/roi/*.png) sizes [] means [] for p in paths: img cv2.imread(p, cv2.IMREAD_GRAYSCALE) sizes.append(img.shape) means.append(img.mean()) print(尺寸分布:, set(sizes)) print(灰度均值范围:, min(means), -, max(means))这段代码用 OpenCV 批量读取 ROI 图片统计尺寸和灰度均值。为什么要做这一步因为后续数据增强和归一化的参数都依赖这些统计量。如果发现有的图是 224x224有的是 256x256就需要统一 resize如果灰度均值差异很大说明可能存在不同的采集设备或增益设置归一化时就要特别小心。2.2 数据增强不要为了凑数量乱增强乳腺超声数据量通常不大一份毕设的样本量可能在几百到一千张左右这种规模直接训练深度网络几乎必然过拟合。数据增强是必须的但不是随便翻翻转转就行。我见过有人对超声图做强烈的色彩抖动增强结果完全失真——超声图是灰度图色调抖动这类针对自然图像设计的增强手段在这里会产生大量不真实的样本让模型学到错误的纹理。超声图合理的增强应该围绕几何变换和轻度噪声扰动来做。import albumentations as A train_transform A.Compose([ A.Resize(224, 224), A.HorizontalFlip(p0.5), A.VerticalFlip(p0.3), # 超声图上下翻转不改变语义 A.RandomRotate90(p0.5), A.ShiftScaleRotate(shift_limit0.05, scale_limit0.1, rotate_limit15, p0.5), A.RandomBrightnessContrast(brightness_limit0.1, contrast_limit0.1, p0.3), A.GaussNoise(var_limit(10.0, 30.0), p0.2), # 模拟超声噪声 ]) val_transform A.Compose([ A.Resize(224, 224), ])这里用 Albumentations 库做增强它是医学图像领域最常用的增强库。重点看两个参数GaussNoise 的 var_limit 控制在 10 到 30因为超声图本身就有斑点噪声加一点高斯噪声可以让模型更鲁棒但加太多会掩盖真实纹理。RandomBrightnessContrast 的幅度也调得很小亮度变化过大在超声图像里意味着探头压力或增益变化属于物理上不合理的扰动。2.3 数据划分最容易被忽略的坑数据集划分看似简单但医学影像数据有一个天然陷阱同一个病人的多张图可能同时出现在训练集和验证集里。这在超声数据里很常见——同一病灶拍了多个切面或者同一个样本生成了多张 ROI。如果按文件随机划分模型实际上已经见过验证集的病人了验证分数会虚高答辩时经不起老师追问。import pandas as pd from sklearn.model_selection import GroupShuffleSplit # 假设 data.csv 里有 patient_id 和 label 两列 df pd.read_csv(data.csv) gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(gss.split(df, groupsdf[patient_id])) train_df df.iloc[train_idx] val_df df.iloc[val_idx] print(训练集病人数:, train_df[patient_id].nunique()) print(验证集病人数:, val_df[patient_id].nunique())GroupShuffleSplit 是 sklearn 里专门做分组划分的工具。核心在 groups 参数传 patient_id它就能保证同一个病人的所有图像只会出现在训练集或验证集的一侧。这一步直接影响模型评估的可信度很多医学影像比赛和论文里都明确要求按病人划分毕设里主动做这一点答辩时是加分项。3. 模型选型与训练ResNet50 为什么是稳妥选择3.1 骨干网络别在选型上浪费时间BI-RADS 分级本质上是一个多分类任务类别一般是 0-6 中的几个有效等级。骨ϗ干网络的选择直接决定训练速度和最终精度。对于毕设场景我的建议是首选 ResNet50原因有三点预训练权重好找且稳定、显存占用在 RTX 3060 级别就能跑、调参资料多遇到问题容易搜到方案。EfficientNet 在 ImageNet 上精度更高但 B4 以上的版本输入分辨率大、显存吃紧而且超声这种纹理简单的图像上EfficientNet 的增益并不明显。ResNet50 在这个任务上已经够用训练时间也可控。如果数据量特别少几百张可以换成 ResNet18训练更快且过拟合风险更低。import torch import torch.nn as nn from torchvision import models class BIRADSClassifier(nn.Module): def __init__(self, num_classes4, pretrainedTrue): super().__init__() self.backbone models.resnet50(pretrainedpretrained) in_features self.backbone.fc.in_features # 替换最后一层适配 BI-RADS 分类数 self.backbone.fc nn.Sequential( nn.Dropout(p0.3), nn.Linear(in_features, 256), nn.ReLU(inplaceTrue), nn.Dropout(p0.2), nn.Linear(256, num_classes) ) def forward(self, x): return self.backbone(x)这个分类头加了两层 Dropout 和一层中间全连接。为什么要这么做因为超声图像数据集通常不大直接用一个线性层输出分类结果模型很容易记住训练集的特征组合。中间的 256 维全连接层相当于让模型先学习一个压缩特征再映射到类别能提升泛化能力。Dropout 0.3 和 0.2 是根据经验设置的如果训练集很小可以加到 0.5。3.2 类别不均衡加权损失函数解决BI-RADS 分级数据天然不均衡BI-RADS 3 类可能良性和 4A 类低度可疑的样本通常很多而 BI-RADS 5 类高度提示恶性和 0 类评估不完整样本很少。如果直接用普通的交叉熵损失模型会倾向于把所有样本都预测成样本量最多的类别整体准确率看着挺高但少数类的召回率几乎为 0。解决办法是计算每个类别的样本权重在损失函数里给少数类更高的惩罚。import torch.nn.functional as F from sklearn.utils.class_weight import compute_class_weight import numpy as np # 计算类别权重 labels train_df[label].values class_weights compute_class_weight(balanced, classesnp.unique(labels), ylabels) class_weights torch.tensor(class_weights, dtypetorch.float32).cuda() criterion nn.CrossEntropyLoss(weightclass_weights) # 训练循环中使用 for images, targets in train_loader: images, targets images.cuda(), targets.cuda() outputs model(images) loss criterion(outputs, targets) optimizer.zero_grad() loss.backward() optimizer.step()compute_class_weight 的 balanced 模式会自动计算权重样本数越少的类别权重越大。具体公式是 n_samples / (n_classes * np.bincount(y))。这里要注意的是权重必须基于训练集的标签计算不能混入验证集的信息否则会有轻微的数据泄漏。3.3 训练流程与学习率策略训练过程的设置比模型结构更影响最终效果。一个常见的错误是全程用一个固定学习率结果就是前期收敛慢后期在最优解附近震荡。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR EPOCHS 50 BATCH_SIZE 16 LR 1e-4 model BIRADSClassifier(num_classes4).cuda() optimizer optim.AdamW(model.parameters(), lrLR, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_maxEPOCHS, eta_min1e-6) for epoch in range(EPOCHS): model.train() train_loss 0.0 for images, labels in train_loader: images, labels images.cuda(), labels.cuda() outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() train_loss loss.item() * images.size(0) scheduler.step() avg_loss train_loss / len(train_loader.dataset) print(fEpoch {epoch1}/{EPOCHS}, Loss: {avg_loss:.4f})用了 AdamW 而不是 Adam因为 AdamW 的解耦权重衰减在迁移学习场景下能更好地抑制过拟合。weight_decay 设成 1e-4太小起不到正则化作用太大会让模型欠拟合。CosineAnnealingLR 让学习率从 1e-4 余弦下降到 1e-6前期快速收敛后期精细调整。Batch Size 设 16 是因为医学图像通常分辨率高、显存消耗大如果你的显卡有 12G 以上显存可以试着调到 32。4. 避坑与常见问题训练医学图像分类的五个血泪教训4.1 显存不足OOM 爆掉现象训练到第二个 epoch 时报 CUDA out of memory进程直接死掉。原因输入图像尺寸太大或者 Batch Size 设置过高。医学图像经常是 512x512 甚至更高分辨率如果预处理时没有统一缩放到 224x224 或 256x256显存开销会翻好几倍。解决先检查数据加载器里有没有正确的 Resize 变换。如果已经是 224x224 还爆显存把 Batch Size 从 16 降到 8或者开启 gradient accumulation——每 2 个 batch 累积一次梯度再更新相当于不增加显存但模拟了更大的 Batch Size。# 梯度累积示例 accumulation_steps 2 for i, (images, labels) in enumerate(train_loader): outputs model(images) loss criterion(outputs, labels) loss loss / accumulation_steps # 归一化累积损失 loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()4.2 预训练权重加载失败size mismatch现象load_state_dict 报错提示 fc.weight 尺寸不匹配。原因因为替换了最后一层全连接层预训练权重的 fc.weight shape 和模型定义的 shape 不一样。直接 load 整个状态字典必然报错。解决用 strictFalse 加载跳过不匹配的层然后只把 backbone 部分加载进来。checkpoint torch.load(resnet50.pth) model_dict model.state_dict() pretrained_dict {k: v for k, v in checkpoint.items() if k in model_dict and fc not in k} model_dict.update(pretrained_dict) model.load_state_dict(model_dict)这里的关键是过滤条件fc not in k把 backbone 的权重匹配进来fc 层保持随机初始化。如果不确定哪些 key 匹配可以先打印出来对比再过滤。4.3 过拟合训练集 99 分验证集 70 分现象训练 Loss 持续下降验证集准确率在某个 epoch 后开始掉头向下。原因数据量太少、模型容量太大、正则化不够。很多同学的第一个反应是换更复杂的模型但方向反了——应该先做数据增强和正则化。解决优先检查数据增强是否生效、Dropout 是否加了、weight_decay 是否设置。还可以用早停机制监控验证集 Loss 连续 5 个 epoch 不下降就停止训练。best_val_loss float(inf) patience 5 counter 0 for epoch in range(EPOCHS): train_one_epoch() val_loss validate() if val_loss best_val_loss: best_val_loss val_loss counter 0 torch.save(model.state_dict(), best_model.pth) else: counter 1 if counter patience: print(fEarly stopping at epoch {epoch}) break4.4 验证集虚高数据泄漏的隐蔽形式现象验证集准确率 98%但自己拿一张新图测试效果很差。原因同病人在训练集和验证集都有图像。这是医学图像分类里最常见的隐藏错误普通随机划分根本发现不了。解决用前面提到的 GroupShuffleSplit 按病人 ID 划分。如果没有病人 ID至少按文件名前缀分组。这个坑在高分毕设里特别值得注意答辩老师一问你怎么保证没有数据泄漏这就是一个非常加分的答点。4.5 预测结果全部集中在一个类别现象混淆矩阵显示模型对所有样本都预测成 BI-RADS 3 类准确率看着有 60%但其他类别全是 0。原因类别不均衡严重且没有用加权损失。解决用 compute_class_weight 计算权重并传入 CrossEntropyLoss。如果加权后还是不均衡可以试试 Focal Loss它对难分类样本的关注度更高。class FocalLoss(nn.Module): def __init__(self, alpha0.25, gamma2.0): super().__init__() self.alpha alpha self.gamma gamma def forward(self, inputs, targets): ce_loss F.cross_entropy(inputs, targets, reductionnone) pt torch.exp(-ce_loss) focal_loss (1 - pt) ** self.gamma * ce_loss return focal_loss.mean()Focal Loss 的思想是降低易分类样本的损失权重让模型更关注难分的样本。gamma2.0 是论文里的推荐值alpha 通常需要根据数据分布调整如果正负样本极度失衡可以尝试 alpha0.5 甚至更大。5. 评估与可视化不只用一个 Accuracy 糊弄答辩5.1 混淆矩阵与分类报告毕设答辩时老师最常问的问题是你这个模型每一类的表现怎么样。一个整体的 accuracy 完全回答不了这个问题。混淆矩阵能直观展示每一类的预测情况分类报告能给出 precision、recall、f1-score 三个指标。from sklearn.metrics import classification_report, confusion_matrix import seaborn as sns import matplotlib.pyplot as plt def evaluate(model, val_loader, class_names): model.eval() all_preds [] all_labels [] with torch.no_grad(): for images, labels in val_loader: images images.cuda() outputs model(images) _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds, target_namesclass_names)) cm confusion_matrix(all_labels, all_preds) plt.figure(figsize(8, 6)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelsclass_names, yticklabelsclass_names) plt.xlabel(Predicted) plt.ylabel(True) plt.savefig(confusion_matrix.png, dpi150, bbox_inchestight)classification_report 输出每一类的 precision、recall、f1-score 以及加权平均这部分可以直接贴到论文的实验章节。需要注意的是f1-score 在多分类不均衡数据下是最值得看的一个指标它综合了 precision 和 recall比 accuracy 靠谱得多。5.2 ROC 曲线与 AUC多分类怎么画很多同学不知道多分类的 ROC 怎么画。标准做法是 one-vs-rest也就是把每个类别当作正类其余所有类别当作负类每个类别画一条 ROC 曲线计算对应的 AUC。from sklearn.preprocessing import label_binarize from sklearn.metrics import roc_curve, auc def plot_multiclass_roc(model, val_loader, num_classes, class_names): model.eval() all_probs [] all_labels [] with torch.no_grad(): for images, labels in val_loader: images images.cuda() outputs torch.softmax(model(images), dim1) all_probs.extend(outputs.cpu().numpy()) all_labels.extend(labels.numpy()) all_probs np.array(all_probs) all_labels np.array(all_labels) # one-vs-rest 二值化 y_bin label_binarize(all_labels, classesrange(num_classes)) plt.figure(figsize(8, 6)) for i in range(num_classes): fpr, tpr, _ roc_curve(y_bin[:, i], all_probs[:, i]) roc_auc auc(fpr, tpr) plt.plot(fpr, tpr, labelf{class_names[i]} (AUC{roc_auc:.3f})) plt.plot([0, 1], [0, 1], k--, linewidth0.8) plt.xlabel(False Positive Rate) plt.ylabel(True Positive Rate) plt.legend(loclower right) plt.grid(alpha0.3) plt.savefig(roc_curves.png, dpi150, bbox_inchestight)label_binarize 会把多分类标签转成多列的 one-hot 格式然后用 roc_curve 逐类计算 FPR 和 TPR。注意这里要传入 softmax 之后的概率值而不是 logits这样画出来的 ROC 曲线才有意义。如果某个类别的样本特别少它的 ROC 曲线会表现出明显的锯齿形状这也是一个可以写进论文分析的点。5.3 Grad-CAM让模型看图说话的可解释性医学图像分类在毕设答辩中最容易被问的就是模型凭什么这么分类。Grad-CAM 能生成热力图指出来模型重点关注了图像的哪个区域。如果热力图集中在病灶区域而不是背景就说明模型学到了有意义的特征这个可视化是论文实验部分非常有力的证据。import torch import torch.nn.functional as F from torchvision.transforms import functional as TF import cv2 import numpy as np def grad_cam(model, image_tensor, target_classNone): model.eval() # 注册钩子获取最后一层卷积层的输出和梯度 feature_maps [] gradients [] def forward_hook(module, input, output): feature_maps.append(output) def backward_hook(module, grad_input, grad_output): gradients.append(grad_output[0]) # ResNet50 最后一个卷积层是 layer4 target_layer model.backbone.layer4 forward_handle target_layer.register_forward_hook(forward_hook) backward_handle target_layer.register_full_backward_hook(backward_hook) output model(image_tensor.unsqueeze(0)) if target_class is None: target_class output.argmax(dim1).item() model.zero_grad() one_hot torch.zeros_like(output) one_hot[0, target_class] 1 output.backward(gradientone_hot) # 计算权重并生成热力图 weights gradients[0].mean(dim(2, 3), keepdimTrue) cam F.relu((weights * feature_maps[0]).sum(dim1, keepdimTrue)) cam F.interpolate(cam, size(224, 224), modebilinear, align_cornersFalse) cam cam.squeeze().cpu().detach().numpy() cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) forward_handle.remove() backward_handle.remove() return cam, target_class这段代码的核心是注册 forward hook 和 backward hook 来捕获中间层的特征图和梯度。Grad-CAM 的原理是最后一层卷积层的每个通道的梯度平均值代表了该通道对目标类别的贡献度用这个权重对特征图加权求和再经过 ReLU 过滤负值就得到显著区域。最后的归一化让热力图的值域落在 0 到 1 之间方便叠加到原图上。生成热力图后可以用 OpenCV 的 applyColorMap 和 addWeighted 叠加到原始超声图上保存成可插入论文的效果图。6. 进阶用法迁移学习的正确打开方式与参数调优技巧6.1 分阶段解冻微调如果你不想只停留在跑通 Baseline想让模型精度再上一个台阶可以试试分阶段微调。做法是先冻结 backbone 只训练分类头等分类头收敛后再解冻 backbone 的部分层。# 第一阶段冻结 backbone只训练 fc 层 for param in model.backbone.parameters(): param.requires_grad False optimizer optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr5e-4) # 第一个阶段训练 10 个 epoch 后进入第二阶段 # 第二阶段解冻 layer3 和 layer4 for name, param in model.backbone.named_parameters(): if layer3 in name or layer4 in name: param.requires_grad True optimizer optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr1e-4)这个方案的核心逻辑是预训练模型在 ImageNet 上学会了通用纹理特征低层卷积的特征对超声图也有参考价值但高层的语义特征和乳腺超声差异较大。所以先把高层解冻做自适应低层保持冻结以免被小数据集带偏。第一阶段的学习率可以调大些第二阶段必须调小否则容易破坏已经收敛的权重。6.2 寻找最佳输入分辨率224x224 是 ImageNet 预训练的标准分辨率但并不一定是超声图像的最优分辨率。超声病灶的纹理细节比如微小钙化点可能在 224x224 下被降采样抹掉了。如果你的显卡允许可以试试 256x256 或 288x288通常在 224 的基础上能提升 2-3 个点的准确率。# 对比不同分辨率的验证集准确率 resolutions [224, 256, 288] for res in resolutions: transform A.Compose([ A.Resize(res, res), A.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_loader create_dataloader(val_df, transform, batch_size16) acc evaluate_accuracy(model, val_loader) print(fResolution {res}: Accuracy {acc:.4f})注意分辨率变大后除了显存占用增加数据加载和预处理时间也会变长。做这个对比实验时保持其他所有超参数一致才能确定是分辨率带来的收益。6.3 推理阶段的 TTA 技巧Test-Time Augmentation 是推理阶段提升精度的一个白嫖技巧把同一张测试图做几次不同的增强分别预测然后把预测概率取平均。它能提升模型对轻微位移和翻转的鲁棒性通常能提高 1-2 个点。def predict_with_tta(model, image, n_aug4): model.eval() tta_transform A.Compose([ A.HorizontalFlip(p0.5), A.VerticalFlip(p0.5), A.RandomRotate90(p0.5), ]) probs_list [] with torch.no_grad(): for _ in range(n_aug): augmented tta_transform(imageimage)[image] tensor torch.from_numpy(augmented).permute(2, 0, 1).float().unsqueeze(0).cuda() prob torch.softmax(model(tensor), dim1) probs_list.append(prob) avg_prob torch.mean(torch.cat(probs_list), dim0) return avg_prob.argmax().item()TTA 的原理很简单虽然模型在训练时见过翻转和旋转的样本但推理时只看了原图一个视角。通过多次增强平均相当于让模型对同一个病灶从多个角度投票。不过要注意测试时增强的次数并不是越多越好4 次到 8 次之间的提升最明显再增加收益就递减了。6.4 部署与导出最后一步是把训练好的模型导出成可复用的格式。如果是毕设展示写一个简单的推理脚本就够了如果想做成 Web 演示可以导出成 ONNX 格式方便接入后端服务。dummy_input torch.randn(1, 3, 224, 224).cuda() torch.onnx.export( model, dummy_input, birads_model.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, opset_version13 ) print(ONNX export done.)dynamic_axes 允许 batch 维度是动态的这样导出后的模型可以一次预测多张图。ONNX 模型的推理速度通常比 PyTorch 原生的 eager mode 快而且不依赖 PyTorch 环境部署时候更省事。这套流程走下来从数据预处理到模型训练、评估、可视化和导出整个闭环我都完整跑过。印象最深的一次是检查数据泄漏问题——一个同组同学的报告里准确率 97%我帮他按病人 ID 重新划分后直接掉到 78%。从那以后我每次拿到医学影像数据第一件事就是确认有没有 patient_id 字段第二件事就是按 GroupShuffleSplit 重新划分。这个习惯让后续所有实验的结论都站得住脚。希望这份拆解能帮你在毕设路上少踩几个坑。本文还有配套的精品资源点击获取
