简介这份基于深度学习的医疗诊断系统Python源码面向医疗AI开发者、深度学习初学者及相关课题研究者可直接用于搭建医疗影像分类、病历数据分析等诊断辅助流程。资源包共48个文件压缩后大小4.44MB以13个Python源文件为核心覆盖模型设计、训练与测试另含7个pyc字节码、3个Qt界面文件、13张JPEG图像以及JSON、SQLite数据库等支撑文件完整展现了从数据预处理、模型推理到界面交互的工程链路。系统实现了登录认证、图像诊断、病历数据管理、日志记录等关键模块其中main.py、diagnose.py等源码展示了深度学习模型与PyQt界面集成、xlsx与sql模块处理业务数据的典型写法界面样式由qss文件定义配置与日志分别由conf和log模块管理代码结构清晰便于二次开发。目前已有454人学习下载这份源码既能作为医疗AI项目落地的参考样板也可作为Python工程实践与深度学习的入门案例帮助读者理解真实系统如何组织代码、管理配置与异常日志甚至在此基础上接入新的医学影像数据。1. 基于深度学习的医疗诊断系统为什么值得用 Python 从头搭一套源码“基于深度学习的医疗诊断系统Python实现源码”这个标题背后是一整套把影像科医生的工作方式搬进代码的做法。常见的落地形态是输入一张胸部 X 光片、CT 切片或病理切片模型输出“有没有病灶、病灶在哪个区域、恶性概率多高”。它适合两类人来读一是想给自己的科研课题或产品原型搭一套可复现基线的工程师二是刚入门深度学习、想找一个比 MNIST 更真实场景练手的 Python 开发者。这套东西真正的难点不在网络结构抄得对不对而在数据怎么切、损失函数怎么加权、阈值怎么定——这些恰恰是网上开源项目里最容易敷衍、又最影响最终效果的部分。2. 模型选型与损失函数从 ResNet 到 EfficientNet 的取舍2.1 为什么医疗场景首选 CNN 而不是直接上 Transformer医疗诊断系统里输入数据绝大多数是医学影像——X 光、CT、MRI 或病理全切片。这类数据的共同特点是数据量小一个科室攒几年的标注数据可能也就几千到几万张、病灶尺寸占比小、类别分布极度不均衡。在这些约束下CNN 的归纳偏置局部连接、权值共享、平移等变性让它天然比 Transformer 更省数据、更容易收敛。我一般会按这样的顺序做选型数据量在 1 万张以下ResNet-50 或 ResNet-101 作为骨干。预训练权重好找PyTorch 里torchvision.models一行就能加载微调成本低。数据量在 1 万到 5 万张换 EfficientNet-b3 或 b4它在同样 FLOPs 下精度更高但对预训练权重和输入分辨率更敏感。数据量在 5 万张以上、且有大显存可以考虑 Swin Transformer 小版本但训练技巧要求高学习率、warmup、正则都要单独调不建议作为第一版基线。这里有个现实考量医疗影像本身没有 ImageNet 里的那种物体多样性所以很多人觉得加载预训练权重意义不大。实测下来恰恰相反用 ImageNet 预训练权重做初始化即便像素分布差异很大迁移后的收敛速度和最终 auc 也显著优于随机初始化。原因在于浅层卷积核学到了通用的边缘、纹理、频率特征这些在医学影像里同样有用。2.2 损失函数的中医带权重的交叉熵与 Focal Loss医疗诊断里最常见的公开数据集比如 ChestX-ray14、RSNA 肺炎检测有一个共同特点正常样本远多于阳性样本有的标签阳性率只有 1% 到 3%。直接拿普通交叉熵训练模型会学会“永远预测正常”来拿低 loss。解决这个问题有两个常用手段实践中可以组合使用第一个是加权交叉熵。给每个类别的 loss 乘一个权重权重的常见设置是w 样本总数 / (类别数 * 该类样本数)。PyTorch 里nn.CrossEntropyLoss(weighttorch.tensor([...]))直接支持这个参数不需要手写。第二个是 Focal Loss。它把交叉熵改造成-alpha * (1 - p)^gamma * log(p)gamma 通常在 2.0 附近。当模型对难样本的输出概率 p 较低时(1-p)^gamma接近 1梯度贡献保留当模型已经预测得很自信时这个系数会把梯度压下去。这样模型就不会被大量简单负样本带偏。我的经验是当正负样本比在 1:10 以内时加权交叉熵就够用了调参更简单超过 1:10 时再上 Focal Lossgamma 从 2.0 起步alpha 按负样本比例设成 0.25 左右。不要一上来就 Focal Loss它的超参数更多翻车后不好定位问题。2.3 常见的设计取舍二分类还是多标签单任务还是多任务很多初学者拿到胸部 X 光数据集看到里面有“肺不张、肺炎、气胸、结节”等十几个标签就直接套多分类 Softmax。这是个很容易踩的坑医疗影像的诊断标签往往是共存的——一个病人可能同时有肺炎和胸腔积液多分类 Softmax 强制所有标签概率加起来等于 1等于在数学上排除了共存可能。正确的做法是拆成多标签二分类每个标签独立一个二分类输出头用 Sigmoid BCEWithLogitsLoss。如果你的任务同时要“判断有没有病灶”和“定位病灶在哪”那更合理的架构是检测网络比如 Faster R-CNN 或 YOLOv8它天然输出去边框加类别而不是靠分类网络出定位能力。多任务是另一个提效技巧主任务是疾病分类副任务可以是分割 mask 预测或关键点回归比如心脏边界。共享骨干、多分支输出副任务能为骨干提供更强的特征监督这在数据量不足时有明显收益。代价是标注成本翻倍、训练脚本复杂度上升建议主任务稳定之后再加。3. 医疗影像数据准备归一化、窗宽窗位与按病人划分3.1 影像读入与 NumPy 化的标准流程医疗影像和自然图像最大的差异在读取方式。自然图像用PIL.Image.open或者cv2.imread就能拿到一个 0 到 255 的 RGB 数组但医学影像常见格式是 DICOM后缀 .dcm和 NIfTI后缀 .nii/.nii.gz。处理 DICOM 的标准工具是pydicom读取后拿到的是像素值和一堆关键元数据。CT 图像的像素值单位是 Hounsfield UnitHU范围大约从 -1024 到 3000 以上直接丢给神经网络等于把数值范围完全搞乱。下面是处理 CT 切片的标准代码import pydicom import numpy as np def load_dicom_to_hu(dicom_path, window_center40, window_width400): ds pydicom.dcmread(dicom_path) # 有些设备存的像素值带缩放这里还原成真实的 HU if hasattr(ds, RescaleIntercept) and hasattr(ds, RescaleSlope): slope float(ds.RescaleSlope) intercept float(ds.RescaleIntercept) hu ds.pixel_array.astype(np.float32) * slope intercept else: hu ds.pixel_array.astype(np.float32) # 窗宽窗位把感兴趣范围内的灰度拉伸到 [0, 1] lower window_center - window_width / 2.0 upper window_center window_width / 2.0 hu np.clip(hu, lower, upper) hu (hu - lower) / (upper - lower) return hu这段代码的核心是两个操作RescaleSlope/RescaleIntercept把设备原始存储值换算成真实的 CT 值窗宽窗位把关注范围外的像素裁掉并映射到 [0,1]。不同任务要选不同的窗宽窗位——看肺部选窗宽 1500、窗位 -600看纵隔选窗宽 400、窗位 40。这个参数直接影响模型能看到的对比度比换网络结构影响还大。3.2 数据增强要克制翻转方向与像素操作有讲究自然图像里随手用的 RandomCrop、RandomRotation90度、180度在医疗影像里不能直接用。解剖结构有严格的方位语义胸部 X 光片左右颠倒会导致心脏和主动脉弓位置错位模型学到的是错误的位置相关性测试时遇到正常摆位的图反而更差。我一般保留下面几种增强它们不破坏解剖语义from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.RandomHorizontalFlip(p0.5), # 左右翻转胸部X光左右对称可用 transforms.RandomAffine(degrees10, translate(0.05, 0.05)), # 小角度旋转、小位移 transforms.ColorJitter(brightness0.1, contrast0.1), # 轻微亮度/对比度扰动 transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])注意这里没有用垂直翻转也没有大于 15 度的旋转。胸部 X 光如果旋转角度过大肺尖和肋膈角的位置关系会被破坏。RandomHorizontalFlip能用是因为人体左右基本对称但如果你做的是乳腺癌钼靶检查左右乳在图像位置不一样、纹理不对称连水平翻转都要慎用。增强策略要和影像科医生确认一遍再定这是血泪经验。3.3 数据集划分最容易忽略的一步按病人分组切分医疗数据集的划分有一个和自然图像完全不同的铁律训练集、验证集、测试集必须按病人Patient ID分组而不是按图像切片分组。同一个病人的多张切片之间高度相关如果一部分进了训练集、一部分进了测试集等于把答案提前给了模型——测试指标会被虚高很多。常见的正确划分代码是import random from collections import defaultdict def split_by_patient(df, patient_colpatient_id, ratios(0.7, 0.15, 0.15), seed42): random.seed(seed) patients sorted(df[patient_col].unique()) random.shuffle(patients) n_train int(len(patients) * ratios[0]) n_val int(len(patients) * ratios[1]) train_patients set(patients[:n_train]) val_patients set(patients[n_train:n_train n_val]) test_patients set(patients[n_train n_val:]) df_train df[df[patient_col].isin(train_patients)] df_val df[df[patient_col].isin(val_patients)] df_test df[df[patient_col].isin(test_patients)] print(ftrain: {len(df_train)} 张 / {len(train_patients)} 人, fval: {len(df_val)} 张 / {len(val_patients)} 人, ftest: {len(df_test)} 张 / {len(test_patients)} 人) return df_train, df_val, df_test这段代码先按patient_id去重、打乱、按比例分成三个集合再去 DataFrame 里筛出对应切片。这样能确保同一个病人的所有图像只出现在一个集合里。打印出来的信息里如果发现某个集合的病人数量很少、切片很多要当心是不是某个病人的切片数量异常大必要时按病人权重采样做平衡。4. PyTorch 训练与评估源码实现训练循环、早停与 AUC4.1 完整的训练脚本骨架可直接改路径跑通下面给出一套完整的 PyTorch 训练脚本骨架它基于 ResNet-50 预训练权重做迁移学习适配上一节的数据划分。这套代码我反复用了很多个医疗项目结构稳定、排错容易。import torch import torch.nn as nn from torch.utils.data import DataLoader, Dataset from torchvision import models, transforms import numpy as np from sklearn.metrics import roc_auc_score, accuracy_score, confusion_matrix class MedicalDataset(Dataset): def __init__(self, df, img_dir, transformNone, target_collabel): self.df df.reset_index(dropTrue) self.img_dir img_dir self.transform transform self.target_col target_col def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img_path f{self.img_dir}/{row[image_id]}.npy img np.load(img_path) # 内存里存处理好的npy避免DICOM读盘耗时 img torch.from_numpy(img).float().unsqueeze(0) # [1, H, W] img img.repeat(3, 1, 1) # 灰度转3通道适配ImageNet预训练 if self.transform: img self.transform(img) label torch.tensor(row[self.target_col], dtypetorch.float32) return img, label def build_model(num_classes1, model_nameresnet50): if model_name resnet50: model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) elif model_name efficientnet_b3: model models.efficientnet_b3(weightsmodels.EfficientNet_B3_Weights.IMAGENET1K_V1) in_features model.classifier[1].in_features model.classifier[1] nn.Linear(in_features, num_classes) return model def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, total_correct, total_num 0.0, 0, 0 for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() logits model(imgs).squeeze(1) loss criterion(logits, labels) loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) preds (torch.sigmoid(logits) 0.5).long() total_correct (preds labels.long()).sum().item() total_num imgs.size(0) return total_loss / total_num, total_correct / total_num这段代码的关键设计有三处对应三个实际项目里容易翻车的点一是把 DICOM 预处理成 npy 存在本地而不是每次读.dcm。DICOM 解析非常慢训练时实时解析二进制的开销会让每个 epoch 多出几倍时间。离线批量把 DICOM 转成 npy 后__getitem__只做数组加载速度快一个数量级。二是灰度图复制三通道。ImageNet 预训练模型要求 3 通道输入把单通道复制三次是最简单且不引入信息损失的适配方式。不要随便用伪彩色映射那会改变像素语义。三是分类头改成nn.Linear(in_features, 1)输出单值配合BCEWithLogitsLoss。这是单标签二分类的标准组合squeeze(1)把 batch 维度恢复避免 shape 不匹配。4.2 验证与早停只看 loss 不够要看 AUC医疗诊断场景里验证阶段通常关注两个指标aucROC 曲线下面积和敏感度/特异度这对组合。准确率在正负样本比 1:9 的场景下参考价值很低——模型全部预测负样本准确率也有 90%。完整的验证和早停逻辑我一般这样写def evaluate(model, loader, device): model.eval() all_labels, all_preds, all_probs [], [], [] with torch.no_grad(): for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) logits model(imgs).squeeze(1) probs torch.sigmoid(logits) all_labels.append(labels.cpu().numpy()) all_probs.append(probs.cpu().numpy()) all_preds.append((probs 0.5).long().cpu().numpy()) y_true np.concatenate(all_labels) y_prob np.concatenate(all_probs) y_pred np.concatenate(all_preds) auc roc_auc_score(y_true, y_prob) acc accuracy_score(y_true, y_pred) tn, fp, fn, tp confusion_matrix(y_true, y_pred).ravel() sensitivity tp / (tp fn) if (tp fn) 0 else 0.0 specificity tn / (tn fp) if (tn fp) 0 else 0.0 return {auc: auc, acc: acc, sensitivity: sensitivity, specificity: specificity} best_auc 0.0 patience 8 bad_epochs 0 save_path best_model.pth for epoch in range(1, 31): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) val_metrics evaluate(model, val_loader, device) print(fEpoch {epoch:02d} | train_loss {train_loss:.4f} | train_acc {train_acc:.4f} | fval_auc {val_metrics[auc]:.4f} | val_sens {val_metrics[sensitivity]:.4f}) if val_metrics[auc] best_auc: best_auc val_metrics[auc] bad_epochs 0 torch.save({model_state_dict: model.state_dict(), epoch: epoch, val_auc: best_auc}, save_path) print(f - 保存新最优模型val_auc{best_auc:.4f}) else: bad_epochs 1 if bad_epochs patience: print(f连续 {patience} 轮没有提升早停。) break4.3 训练参数怎么设抄这套基线再微调医疗影像的迁移学习训练参数和从头训练差很多我常用的基线参数是下表这样直接作为起点通常能得到一个不错的结果参数推荐值说明优化器AdamW比 Adam 多了权重衰减解耦正则更稳初始学习率3e-5 1e-4骨干用预训练权重学习率必须小否则微调震荡权重衰减1e-4 5e-4对医疗小数据集非常关键Batch size16 / 32取决于显存小于 16 时考虑增大学习率衰减频率学习率调度CosineAnnealingLR总 epoch 30~50 时效果好输入分辨率224×224 起步可提到 384病灶小的话 384 有明显提升但显存翻倍一个容易忽略的参数是骨干网络和分类头要用不同的学习率。分类头是随机初始化的梯度尺度大骨干是预训练权重只需要微调。常见做法是给骨干乘 0.1 的系数代码上可以用两个param_groupoptimizer torch.optim.AdamW([ {params: model.fc.parameters(), lr: 1e-4}, {params: [p for n, p in model.named_parameters() if fc not in n], lr: 1e-5} ], weight_decay1e-4)这个设置的逻辑是新初始化的分类头需要更大的步长去拟合目标任务而骨干特征提取器改动过大会遗忘 ImageNet 上学到的通用特征小学习率让它在原有特征空间里做微调。5. 避坑排查医疗 AI 最常见的 5 个翻车现场5.1 验证集 AUC 极高但测试集崩了大概率是数据划分泄漏现象验证集 AUC 能到 0.98测试集 AUC 却只有 0.75且差的不是一点点。原因最常见的是同一个病人的切片同时出现在训练集和测试集。肺部 CT 一个病人动辄几百张切片相邻切片之间的相似度极高模型其实在记忆病人特征而不是病灶特征。另一个常见泄漏源是数据预处理时用了全局统计量比如用整个数据集的均值和标准差做归一化信息间接泄漏。解决严格按第 3.3 节的split_by_patient按病人分集合。归一化的均值和标准差只从训练集统计保存下来供验证集和测试集复用。检查原始数据里有没有重复图像名用 MD5 哈希去重是最后一道保险。5.2 训练 loss 正常下降但敏感度一直为 0阈值问题还是类别权重问题现象AUC 有 0.9但设阈值 0.5 时敏感度极低阳性样本几乎全被判定为阴性。原因这不是模型坏了而是正负样本比悬殊时模型输出概率整体偏低0.5 这个默认阈值完全不适合。AUC 只看排序能力不看绝对概率值所以 AUC 高和 0.5 阈值下敏感度低可以同时成立。解决不要在 0.5 处卡死改用验证集寻找最优阈值。最常用的方法是遍历概率阈值找约登指数Youdens J 敏感度 特异度 - 1最大的点。实际部署时还要考虑临床需求——如果任务是筛查目的是不漏掉病人宁可特异度低一些也要优先保证敏感度。这个取舍要写进模型报告不能只给一个 AUC 数字。5.3 用了预训练权重反而比随机初始化差输入通道和预处理不匹配现象加载 ImageNet 权重后训练初期 loss 比随机初始化还高最终收敛也差。原因有三类问题会触发这个现象。第一输入不是 3 通道或者用ToTensor把值映射到 [0,1] 同时又把 Mean/Std 设成 ImageNet 的统计量两套归一化打架。第二Resize 尺寸和预训练时的尺寸不一致比如预训练用 224 微调用 96卷积感受野完全变形。第三输入图像有大量黑色背景全局均值拉低等于喂给模型一张“灰蒙蒙”的图。解决统一走transforms.ToTensor()Normalize(mean[0.485,0.456,0.406], std[0.229,0.224,0.225])保证输入分布接近 ImageNet 分布。图像中背景占比超 30% 时考虑用肺部分割 mask 裁掉背景再进网络这个预处理通常能带来 3 到 5 个点的 AUC 提升。5.4 显存不够batch size 调到 4训练不稳定怎么办现象8G 显存跑 ResNet-50 224 输入batch size 只能设 4loss 曲线震荡剧烈验证指标上不去。原因batch size 太小导致每个 batch 的梯度方差大BN 层的统计量也不稳定。这不是调学习率能解决的是批量统计本身的数学问题。解决两层办法。第一层用梯度累积模拟大 batch。第二层把输入分辨率从 224 降到 192 或 176ResNet-50 显存占用会明显下降分辨率略降对病灶检测的影响通常小于 batch 太小的影响。第三层如果任务里病灶尺寸很小干脆换轻量骨干如 ResNet-34 或 EfficientNet-b1。注意梯度累积时学习率要按真实 batch 倍数上调否则收敛变慢。accum_steps 4 # 实际batch 4 * 4 16 optimizer.zero_grad() for step, (imgs, labels) in enumerate(loader): logits model(imgs.to(device)).squeeze(1) loss criterion(logits, labels.to(device)) loss loss / accum_steps # 平均每个mini-batch的loss loss.backward() if (step 1) % accum_steps 0: optimizer.step() optimizer.zero_grad()5.5 模型反复预测同一类别标签噪声和数据质量的问题现象不管输入什么图模型输出概率都偏向某个类别例如总是预测“正常”。原因除了类别不平衡更隐蔽的原因是标签噪声——医疗影像标注本身就存在 observer variability同一个病灶不同医生判读结果不一定一致。如果训练集里阳性样本有 20% 是误标模型学到的是“阳性样本长这个样 一部分乱标噪声”预测自然会偏向样本量大的正常类。解决先做标签清洗。最简单有效的方式是用模型本身的预测做交叉验证把训练集里多次预测为相反类别的样本筛出来人工复审。也可以做 Hard Example Mining每个 epoch 结束按 loss 排序挑出 loss 最高的 20% 样本打印出来人工看一遍通常能发现一批标注错误。这个步骤脏但收益大很多项目靠清洗标签比换网络涨点更多。6. 用 Grad-CAM 给模型做体检热力图验证与临床交付习惯模型训练完、AUC 也达标了距离能交付还差一步医生不会相信一个黑匣子。即使你只是做技术原型也要能回答“模型根据什么做的判断”这个问题。Grad-CAM 是最常用也最好解释的工具它用最后一层卷积特征图的梯度加权来生成热力图标出模型注意力集中在图像哪个区域。下面这段代码基于 PyTorch 实现 Grad-CAM核心是注册两个 hook 分别拿到中间层特征图和梯度import cv2 import numpy as np import torch class GradCAM: def __init__(self, model, target_layer): self.model model self.gradients None self.activations None handle_a target_layer.register_forward_hook(self._save_activation) handle_g target_layer.register_full_backward_hook(self._save_gradient) def _save_activation(self, module, input, output): self.activations output.detach() def _save_gradient(self, module, grad_input, grad_output): self.gradients grad_output[0].detach() def generate(self, img_tensor): logits self.model(img_tensor.unsqueeze(0)) self.model.zero_grad() logits[0, 0].backward() # 对目标类别的logit反向传播 weights self.gradients.mean(dim(2, 3), keepdimTrue) cam (weights * self.activations).sum(dim1, keepdimTrue) cam torch.relu(cam).squeeze().cpu().numpy() cam cv2.resize(cam, (img_tensor.shape[2], img_tensor.shape[1])) cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) return cam这段代码里值得注意的有两点。一是register_full_backward_hook拿到的grad_output是 loss 对输出 feature map 的梯度Grad-CAM 需要的就是它二是cam做了一次cv2.resize把热力图恢复到输入图像分辨率叠加显示时才能对齐。生成热力图后把一个批次的假阳性和假阴性样本全部可视化出来人工过一遍——如果模型把正常区域标红说明它学到了数据集里的伪影比如图片角落的设备标签、病床边框必须回到数据层面清洗掉。这个习惯我在所有项目里都保留每次训练出一个候选模型先跑一批 Grad-CAM再谈指标。有一次模型 AUC 0.93热力图显示它的注意力集中在每张图的左上角——那里恰好是设备水印的位置。删掉这批带水印的图重新训练AUC 降到 0.90但热力图终于落在肺野上了。前者是好看的假成绩后者才是能上线的东西。我现在的交付习惯是每条测试样本都保留模型输出概率、Grad-CAM 热力图和医生判读记录三件套。术前验证时让医生直接看热力图评“有没有道理”比任何数值指标都更有说服力。希望这个从数据到可解释输出的完整路径能帮你少走几次弯路。本文还有配套的精品资源点击获取