简介:这份资源是基于Python实现的深度学习医疗诊断系统完整源码,面向医疗AI方向的开发者、课程设计者与毕业设计学生,帮助理解深度学习如何落地于疾病预测与医学影像分析场景。压缩包共48个文件、约4.44MB,以13个py核心源码、13张jpg医学影像素材、7个pyc编译文件为主,另含3个ui界面文件、qss样式、json与conf配置、db数据库、license许可及readme说明,覆盖模型设计、训练测试、界面交互与数据存储等模块。目录中main、diagnose、image、sql、login等脚本分工明确,配合PyQt界面与日志配置,可较完整地还原一套可运行的诊断流程。目前已有454人学习下载,适合作为医疗AI入门实践、算法改进与二次开发的参考底本,也便于对照源码梳理从数据到诊断输出的整体链路。
1. 从一份 Python 源码说起:医疗诊断系统到底在做什么
打开任何一个技术社区搜「基于深度学习的医疗诊断系统Python实现源码」,你大概率会看到两种结果:一种是只有界面截图、没有推理逻辑的演示壳子,另一种是把 MNIST 手写数字识别改个名字就敢叫医疗诊断的玩具。真正能跑通、能解释、能换数据集的医疗诊断系统,核心链路其实只有四段:医学影像或结构化数据的读取与预处理、深度学习模型的定义与训练、推理服务的封装、以及面向医生或患者的交互层。这个标题对应的,就是把这四段用 Python 串起来的一套可复现工程。它适合两类人:一类是刚学完深度学习课程、想找一个比猫狗分类更有说服力的实战项目练手的学生;另一类是想把院内积累的影像数据用起来、但不确定从哪下手的临床科研人员。接下来我不讲空泛概念,直接按我实际搭过几套类似系统的顺序,把选型、代码、参数和踩过的坑摊开讲。
2. 医疗诊断系统的技术选型:为什么是 CNN 而不是别的
2.1 影像类诊断任务里 CNN 仍然是默认答案
医疗诊断系统按输入数据分,常见的有三类:医学影像(X 光、CT、病理切片)、生理信号(心电图、脑电图)、结构化检验指标。这个标题没有限定模态,但从「Python 实现源码」这个诉求看,绝大多数人要做的是影像分类或分割,因为这类任务有公开数据集、有成熟的预训练权重、可视化效果也直观。影像任务里,卷积神经网络(CNN)是绕不开的基线。原因不复杂:医学影像的判别信息高度局部化,一个肺结节、一处视网膜出血点,都只占整张图很小一块区域,CNN 的局部感受野和权值共享恰好匹配这种特性。Transformer 这两年在医学影像上确实很猛,但它对数据量和算力的要求高一个量级,个人或小团队拿几百张标注图去训 ViT,大概率欠拟合到怀疑人生。所以我的建议是:先用 CNN 把整条链路跑通,等数据量上来了再考虑换骨干网络。
具体到骨干选择,ResNet18 和 ResNet50 是最稳的两个起点。ResNet18 参数量约 1100 万,单张 224×224 的图在普通显卡上推理只要几毫秒,适合做原型验证;ResNet50 参数量约 2500 万,精度通常高两三个点,但训练时间翻倍。如果你做的是二分类筛查任务(比如肺炎有无),ResNet18 足够;如果是多分类或细粒度分级(比如糖尿病视网膜病变五级),直接上 ResNet50 或 EfficientNet-B3。EfficientNet 系列在同等精度下参数量更小,但它的复合缩放系数需要调,新手容易在输入分辨率上翻车,所以我一般让团队先用 ResNet 打底。
2.2 数据管道的三个关键决策
选完模型,真正决定项目成败的是数据管道。医疗数据和 ImageNet 的分布差异极大:X 光片是单通道灰度、对比度低、不同设备拍出来的亮度差异能到两三倍;病理切片则是超大分辨率、需要切 patch。这里有三个决策点必须提前定。
第一,是否用预训练权重。我的经验是:只要你的数据集超过 500 张,就用 ImageNet 预训练权重做初始化,然后把第一层卷积改成单通道(如果是灰度图),或者复制三通道取平均。从头训练在小数据集上几乎必然过拟合。第二,归一化参数怎么定。不要直接套 ImageNet 的 mean=[0.485,0.456,0.406]、std=[0.229,0.224,0.225],医学影像的像素分布完全不同。正确做法是在训练集上统计均值和标准差,写进配置。第三,类别不平衡怎么处理。临床上正常样本远多于异常样本,比例到 10:1 很常见。直接训练会让模型学会「全预测正常」也能拿 90% 准确率。常见做法是加权采样(WeightedRandomSampler)配合 Focal Loss,或者对少数类做数据增强。
下面这段代码是我常用的数据管道骨架,基于 PyTorch,包含灰度转三通道、自定义归一化和加权采样:
import torch from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler from torchvision import transforms from PIL import Image import numpy as np import os class MedicalImageDataset(Dataset): def __init__(self, root_dir, transform=None): self.samples = [] self.transform = transform # 假设目录结构为 root_dir/类别名/图片文件 for label, cls_name in enumerate(sorted(os.listdir(root_dir))): cls_dir = os.path.join(root_dir, cls_name) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): if fname.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp')): self.samples.append((os.path.join(cls_dir, fname), label)) self.num_classes = len(set(s[1] for s in self.samples)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] # 医学影像常见为灰度,转 RGB 以适配预训练模型 img = Image.open(path).convert('RGB') if self.transform: img = self.transform(img) return img, label def build_dataloaders(root_dir, batch_size=32, num_workers=4): # 训练集增强:翻转、轻微旋转、对比度扰动 train_tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=10), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), # 这里的 mean/std 应替换为你在训练集上统计出的值 transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.25, 0.25, 0.25]), ]) val_tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.25, 0.25, 0.25]), ]) full_ds = MedicalImageDataset(root_dir, transform=train_tf) # 按 8:2 划分训练与验证 n_val = int(len(full_ds) * 0.2) n_train = len(full_ds) - n_val train_ds, val_ds = torch.utils.data.random_split(full_ds, [n_train, n_val]) val_ds.dataset.transform = val_tf # 验证集关闭增强 # 计算每个类别的样本数,构造加权采样器 labels = [full_ds.samples[i][1] for i in train_ds.indices] class_counts = np.bincount(labels, minlength=full_ds.num_classes) class_weights = 1.0 / np.maximum(class_counts, 1) sample_weights = [class_weights[l] for l in labels] sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True) train_loader = DataLoader(train_ds, batch_size=batch_size, sampler=sampler, num_workers=num_workers, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True) return train_loader, val_loader, full_ds.num_classes这段代码里几个参数值得单独说。RandomRotation(degrees=10)的 10 度不是随便写的,医学影像里器官的解剖方向有临床意义,旋转超过 15 度可能把正常结构转成异常形态,反而引入噪声。ColorJitter的 brightness 和 contrast 都设 0.2,是为了模拟不同设备曝光差异,但不能再大,否则可能抹掉病灶的灰度特征。WeightedRandomSampler的replacement=True表示有放回采样,少数类会被反复抽到,这是解决不平衡最直接的手段。pin_memory=True在 GPU 训练时能加速主机到显存的数据搬运,别省这一步。
3. 模型训练与评估:把准确率从 0.7 拉到 0.9 的实操细节
3.1 训练循环里必须监控的三个量
很多人训练医疗模型只看 loss 和 accuracy,结果模型在验证集上准确率 0.95,一上真实数据就崩。问题出在评估指标选错了。医疗诊断场景里,敏感度(Sensitivity,召回率)和特异度(Specificity)比准确率重要得多。一个肺炎筛查模型如果把所有片子都判为正常,准确率可能也有 0.8,但敏感度是 0,这种模型没有任何临床价值。所以训练循环里我强制监控三个量:验证集上的 AUC、敏感度、特异度。AUC 衡量的是模型把正负样本分开的整体能力,不受阈值影响;敏感度和特异度则告诉你当前阈值下漏诊和误诊的比例。
下面是我常用的训练与评估代码,包含早停和最佳模型保存:
import torch import torch.nn as nn from torchvision import models from sklearn.metrics import roc_auc_score, confusion_matrix def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * imgs.size(0) return total_loss / len(loader.dataset) @torch.no_grad() def evaluate(model, loader, device): model.eval() all_probs, all_labels = [], [] for imgs, labels in loader: imgs = imgs.to(device) outputs = model(imgs) probs = torch.softmax(outputs, dim=1)[:, 1] # 二分类取正类概率 all_probs.extend(probs.cpu().numpy()) all_labels.extend(labels.numpy()) auc = roc_auc_score(all_labels, all_probs) # 以 0.5 为阈值计算敏感度与特异度 preds = [1 if p >= 0.5 else 0 for p in all_probs] tn, fp, fn, tp = confusion_matrix(all_labels, preds).ravel() sensitivity = tp / (tp + fn + 1e-8) specificity = tn / (tn + fp + 1e-8) return auc, sensitivity, specificity def train_model(train_loader, val_loader, num_classes=2, epochs=30, lr=1e-4): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 使用预训练 ResNet18,替换最后的全连接层 model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, num_classes) model = model.to(device) # 类别加权交叉熵,权重与采样器配合使用 criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) best_auc = 0.0 patience, wait = 5, 0 for epoch in range(epochs): loss = train_one_epoch(model, train_loader, optimizer, criterion, device) auc, sens, spec = evaluate(model, val_loader, device) scheduler.step() print(f"Epoch {epoch+1}: loss={loss:.4f}, AUC={auc:.4f}, Sens={sens:.4f}, Spec={spec:.4f}") if auc > best_auc: best_auc = auc torch.save(model.state_dict(), 'best_model.pth') wait = 0 else: wait += 1 if wait >= patience: print("早停触发,停止训练") break return model逻辑上,AdamW的 weight_decay 设 1e-4 是防止过拟合的常规操作,比 Adam 的 L2 正则更规范。CosineAnnealingLR让学习率按余弦曲线从 1e-4 降到接近 0,训练后期模型更容易收敛到平坦极小值,泛化更好。早停的 patience 设 5,意思是连续 5 个 epoch AUC 没提升就停,这个值在几百张图的数据集上比较合适;如果数据集上万,可以放宽到 8 到 10。保存模型时只存state_dict,不存整个模型对象,这样加载时不依赖原始类定义,部署更灵活。
3.2 学习率与批大小的搭配经验
学习率和批大小是一对绑定参数,不能单独调。经验公式是:批大小翻倍,学习率也大致翻倍。我常用的组合是 batch_size=32 配 lr=1e-4,batch_size=64 配 lr=2e-4。如果你显存不够只能跑 batch_size=8,那学习率要降到 3e-5 左右,否则梯度噪声太大,loss 会震荡。另外,微调预训练模型时,前几个 epoch 可以只训练最后的全连接层(冻结骨干),等 loss 稳定后再解冻全部层用小学习率微调。这个技巧在医学影像上特别有用,因为预训练权重提取的底层边缘纹理特征本来就通用,没必要一上来就大改。
4. 推理服务与交互层:让模型真正能被用起来
4.1 用 FastAPI 封装推理接口
模型训练完只是半成品,要让人用起来,得包一层 HTTP 接口。我选 FastAPI 而不是 Flask,原因是它自带请求体校验和异步支持,写起来少很多样板代码。下面是一个最小可用的推理服务:
from fastapi import FastAPI, File, UploadFile from PIL import Image import torch import torch.nn as nn from torchvision import models, transforms import io app = FastAPI() device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = models.resnet18(weights=None) model.fc = nn.Linear(model.fc.in_features, 2) model.load_state_dict(torch.load('best_model.pth', map_location=device)) model.to(device).eval() preprocess = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.25, 0.25, 0.25]), ]) @app.post("/predict") async def predict(file: UploadFile = File(...)): contents = await file.read() img = Image.open(io.BytesIO(contents)).convert('RGB') tensor = preprocess(img).unsqueeze(0).to(device) with torch.no_grad(): logits = model(tensor) probs = torch.softmax(logits, dim=1).cpu().numpy()[0] return { "normal_prob": float(probs[0]), "abnormal_prob": float(probs[1]), "prediction": "abnormal" if probs[1] > 0.5 else "normal" }这段代码里model.eval()必须调用,否则 BatchNorm 层会用当前 batch 的统计量而不是训练时保存的滑动平均,推理结果会飘。torch.no_grad()关闭梯度计算,省显存也提速。返回结果里我同时给了两个类别的概率,而不是只给一个标签,因为临床上医生需要知道模型有多确信,概率 0.51 和 0.99 的意义完全不同。启动命令是uvicorn main:app --host 0.0.0.0 --port 8000,然后用 curl 或 Postman 发一张图就能测。
4.2 前端交互的最小实现
如果要做成给医生用的界面,不需要上 React 或 Vue,一个单页 HTML 加 fetch 就够了。核心逻辑是:用户选图、预览、点按钮、把文件 POST 到/predict、把返回的概率渲染成进度条。这里有个容易忽略的点:浏览器上传的图片可能是任意尺寸和格式,前端最好先做一次压缩或尺寸校验,避免几 MB 的大图直接怼到服务端。另外,如果部署在内网,记得在 FastAPI 里加 CORS 中间件,否则前端页面跨域请求会被浏览器拦掉。
5. 避坑与排查:那些让我返工三次的问题
5.1 验证集准确率虚高,一换数据就崩
现象:训练时验证集准确率 0.96,换一家医院的片子测试掉到 0.6。原因:训练集和验证集来自同一批数据,划分时没有按患者或设备分层,同一个人的多张片子被分到了两边,模型实际上在「背患者」而不是学病灶特征。解决:划分数据集时按患者 ID 分组,确保同一个患者的所有影像只出现在训练集或验证集之一。如果数据里没有患者 ID,至少按拍摄设备或日期做分层划分。
5.2 Loss 变成 NaN,训练直接中断
现象:训练到第几个 epoch,loss 突然变成 nan,后续全是 nan。原因:医学影像里常有全黑或全白的无效区域,归一化后出现极端值,加上学习率偏大,梯度爆炸。解决:先把学习率降一个数量级试;然后在数据管道里加像素值裁剪,把超过 99.5 百分位和低于 0.5 百分位的像素截断;最后检查输入里有没有全零图,有就过滤掉。
5.3 模型对某一类样本几乎全预测错
现象:二分类里异常类召回率只有 0.3,正常类接近 1.0。原因:类别极度不平衡,且没有用加权采样或加权损失。解决:先打印训练集的类别分布,如果比例超过 5:1,就上 WeightedRandomSampler;如果超过 20:1,再叠加 Focal Loss,把 gamma 设 2.0,让模型聚焦难分样本。同时把评估阈值从 0.5 往下调,比如 0.3,牺牲一点特异度换召回率,临床上漏诊的代价通常高于误诊。
5.4 推理服务显存越跑越大最后 OOM
现象:服务刚启动正常,跑了几十次请求后显存爆了。原因:每次推理都新建 tensor 但没有及时释放,或者模型没有设 eval 模式导致中间激活被缓存。解决:确保torch.no_grad()包裹推理,model.eval()在加载后立即调用,输入 tensor 用完即弃。如果并发高,用torch.cuda.empty_cache()定期清理,但别每次请求都调,会有性能开销。
5.5 换 GPU 后加载模型报 key 不匹配
现象:在 A 卡上训练的模型,换到 B 卡加载时报 missing keys 或 unexpected keys。原因:保存时用了torch.save(model)存整个对象,或者多卡训练时模型被DataParallel包了一层,state_dict 的 key 多了module.前缀。解决:统一用torch.save(model.state_dict(), path)保存;加载时如果是多卡训练的权重,用{k.replace('module.', ''): v for k, v in state_dict.items()}去掉前缀再 load。
6. 把模型指标变成临床可解释的输出
训练指标好看不等于临床可用,这是我在实际项目里体会最深的一点。AUC 0.92 的模型,如果只输出一个「异常概率 0.87」,医生不敢信,也不敢担责。真正让系统被接受的做法是加一层可解释性输出。最轻量的方案是 Grad-CAM,它能在原图上叠一张热力图,标出模型做判断时关注了哪块区域。如果热力图落在病灶上,医生一眼就能确认模型「看对了地方」;如果落在无关区域,说明模型学到了伪相关,得回去查数据。
下面这段 Grad-CAM 代码可以直接接在前面的 ResNet 模型上:
import torch import numpy as np import cv2 from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image def generate_gradcam(model, img_tensor, target_layer, original_img): # original_img 为 [0,1] 范围的 RGB numpy 数组,shape (H, W, 3) cam = GradCAM(model=model, target_layers=[target_layer]) grayscale_cam = cam(input_tensor=img_tensor.unsqueeze(0))[0] # 将热力图叠加到原图 visualization = show_cam_on_image(original_img, grayscale_cam, use_rgb=True) return visualizationtarget_layer一般选最后一个卷积块的输出,对 ResNet18 就是model.layer4[-1]。热力图的分辨率是 7×7,上采样到原图尺寸后会比较粗糙,但足够指示大致区域。如果要更精细,可以换 Grad-CAM++ 或 Score-CAM,代价是推理时间增加。我的习惯是:在服务端把热力图和原始概率一起返回,前端并排展示,医生看到热力图落在合理位置才签字确认。这一步加上之后,系统从「一个黑匣子打分器」变成了「一个能对话的辅助工具」,科室的接受度完全不一样。
最后说一个我自己的教训:别在项目初期追求 SOTA 精度,先把数据管道、训练循环、推理接口、可解释性这四段跑通,哪怕模型只有 0.85 的 AUC。因为真正卡住项目的从来不是模型不够强,而是数据标注不规范、评估指标选错、部署环境对不上。把这条链路走顺了,再换更强的骨干网络或加更多数据,都是水到渠成的事。希望帮到你。
本文还有配套的精品资源,点击获取