简介:这是一份基于深度学习构建的糖尿病足溃疡(DFU)风险评分系统完整代码包,面向医学图像分析、AI辅助诊断方向的开发者与研究者,也适合作为深度学习的课程设计或毕业设计参考。压缩包共54个文件,以24个Python源码为主体,涵盖数据处理、模型训练、验证评估与风险评分等核心流程,另有pyc编译文件、5张图像结果、4份Markdown说明文档和一个交互式Jupyter Notebook,整体仅2.37MB,轻量易用。目前已有71人学习下载,足见其在医疗AI入门中的实用价值。资源内置Det4DFU与Clf4DFU双任务框架,分别对应病灶检测与分类评级,并包含GradCAM可视化、多折交叉验证、RGB像素统计、多种数据读取脚本等工具,可直接复现实验结果,也可迁移到其他医学病灶风险评分任务,帮助开发者快速掌握整套深度学习工程实现思路,为模型调优与可解释性分析提供基础。
1. 糖尿病足溃疡风险评分,深度学习到底在评什么
糖尿病足溃疡(DFU)是糖尿病最棘手的并发症之一,临床上的困境在于:溃疡一旦发展到深部感染,截肢风险成倍上升,而大量早期患者在基层门诊只能靠医生目测和经验评分。把深度学习模型接到这个场景里,目标不是替代医生做诊断,而是给出一致的、可量化的风险评分——同一张足部图像,模型输出的分数应该在多次评估间保持稳定,并且与临床结局有相关性。这样一个基于深度学习开发的DFU风险评分系统,包含图像预处理、卷积神经网络推理、分数映射和后端服务封装四个部分;对软件工程师和医学影像研究者来说,它同时涉及迁移学习、数据增强和模型部署,是一条完整可复现的落地路径。这个方向适合两类人:手上已有DFU图像数据但不知道怎么组织训练的算法工程师,以及想用深度学习框架验证“AI辅助筛查”是否值得投入的临床科研工作者。
2. 方案选型与系统骨架:把DFU评分拆成可落地的模型管线
2.1 为什么要用CNN做DFU风险评分而不是手工特征
早几年做足溃疡评估,主流做法是先做分割网络把溃疡区域抠出来,再统计面积、深度、色调等手工特征,最后套一个逻辑回归打分。这个方案的缺点很明显:分割模型的错误会直接传导到评分模型,而且“色调偏暗=缺血”这类规则在不同光线、不同肤色条件下非常脆弱。后来大家逐步转向端到端的CNN思路——把一张经过标准化处理的足部图像直接映射到风险分数。这样做确实损失了计算溃疡面积的直观指标,但换来了两个临床更关心的能力:一是对溃疡边缘、周围红肿、肉芽组织状态等复合特征的自动感知,二是评分的一致性不再依赖某条拍脑袋定的阈值。
按我做过类似医学影像项目的经验,DFU风险评分本质上是细粒度图像分类问题,而不是语义分割问题。用ResNet18/34这类成熟分类网络做骨干,在ImageNet预训练权重上微调,是投入产出比最高的起点。CNN自己会去学习“局部破损+周边红肿”这类共现特征,比手工特征对光照和角度的鲁棒性更好;这也是深度学习与经典方法在医学影像场景里最大的差别。如果你只想要一个能跑通的基线系统,不要一开始就上U-Net或者Transformer分割方案,先把分类评分跑出一个可解释的分数,再逐步升级结构。
2.2 系统骨架:数据流、模型输出与评分映射
一个可落地的DFU风险评分系统通常按四段组装。第一段是图像入库与预处理,包括统一缩放、归一化、数据增强;第二段是模型推理,骨干网络输出一个特征向量;第三段是评分头,把特征向量映射成我们定义的0~10分或0~4级;第四段是服务封装,用FastAPI或Flask把模型包成一个可调用的HTTP服务,前端上传图像就能拿到分数和置信度。
评分映射这块我建议不要直接从网络输出层接“回归一个连续值”,而是先做多分类,再按类别对应分数加权求和。原因是:连续回归训练在样本量小的时候极不稳定,模型倾向于输出一个平均值,而分类任务每个类别的样本相对独立,训练更稳。比如定义四个等级:无风险(0分)、低风险(2分)、中风险(5分)、高风险(9分),推理时按Softmax概率加权得到最终评分。这个分数对齐到SINBAD这类临床评分体系也相对容易——SINBAD本身就是按部位、缺血、神经病变、细菌感染、面积、深度六个维度分别打分的结构。
这里的关键点是“模型输出的是概率分布,不是单一的有/无风险判断”。我会把概率分布直接透传给调用方,这样医生能看到模型对“高风险”到底有多确信,而不是只收到一个干巴巴的数字。系统骨架定型后再做数据准备,顺序不要反:先定评分口径,再定模型结构,最后才整理数据,否则后面样本标注会反复返工。
3. 构建DFU训练数据:标注规范与预处理管道,别让脏数据砸了模型
3.1 数据来源与标注规范:先定评分口径再定标签
做DFU系统第一个要确认的是评分口径。常见做法是对齐英国皇家医院SINBAD评分或IWGDF指南里可观察的标准:是否存在溃疡、溃疡是否延伸到深层组织、周边是否有感染迹象、血供状况如何。每条标准映射到一个风险等级,再汇总成0~10分。这个映射关系必须在标注前定死,否则两个医生标同一张图会给出完全不同的分数。
数据方面,公开的DFU图像数据集能覆盖一部分训练需求,比如DFUC挑战赛发布的那类数据,但真实项目里我更建议同时收集院内历史病例,因为公开数据集大多经过筛选,与你实际部署场景的脚光、肤色、拍摄距离有明显偏差。标注时应该让有经验的临床医生按“等级标签+边界框或分割掩码”双重标注:等级标签用于训练评分分类,掩码用于后续可解释性分析。每个样本至少两人独立标注,分歧样本由第三人仲裁,这套流程能显著降低噪声标签比例。
提示:标注字段建议包含 severity_level(0/1/2/3)、has_ischemia(是否缺血)、ulcer_location(足趾/足背/足跟等),以及临床随访结局 label_outcome(两周后是否恶化)。后者在验证评分有效性时价值极大,但多数团队会忽略这字段,导致模型评分无法与临床结局挂钩。
3.2 预处理管道的参考实现:归一化、裁切与增强参数
下面是一段我常用的数据加载与预处理实现,可以直接套进PyTorch项目。
import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class DFUDataset(Dataset): def __init__(self, image_paths, labels, train=True): self.paths = image_paths self.labels = labels if train: self.transform = T.Compose([ T.Resize((256, 256)), # 先统一放缩,保证进入网络的图像尺寸一致 T.RandomHorizontalFlip(p=0.5), # 水平翻转增强,足部图像左右对称,不引入不真实样本 T.RandomRotation(10), # 小角度旋转,模拟拍摄角度偏差 T.ColorJitter(0.15, 0.15, 0.15, 0.05), # 轻微亮度/对比度抖动,抵抗光照不均 T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], # ImageNet统计均值,迁移学习必须沿用 std=[0.229, 0.224, 0.225]) ]) else: self.transform = T.Compose([ T.Resize((256, 256)), # 推理阶段不做随机增强,保证结果可复现 T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.paths) def __getitem__(self, idx): img = Image.open(self.paths[idx]).convert("RGB") label = torch.tensor(self.labels[idx], dtype=torch.long) return self.transform(img), label这里有两处特别说明。第一,Resize选256×256而不是直接上224×224,目的是让模型能吃到更大一点的溃疡边缘细节,再靠骨干网络内部的全局池化把特征压缩到固定维度;如果你以后换EfficientNet,直接Resize到对应输入尺寸即可。第二,Normalize的mean和std必须用ImageNet统计值,因为骨干网络是在ImageNet上预训练的;换成别的归一化参数会导致迁移学习的特征分布错位,这是很多复现“翻车”的第一现场。
关于增强参数,RandomRotation的角度不建议超过15度。溃疡图像不像自然图像,大幅旋转会让溃疡区域的相对位置偏离临床常识。ColorJitter的幅度也宜小不宜大——皮肤色调是DFU评分的重要线索,色调增强过头会把红肿特征洗掉,等于给模型灌噪声。这组参数在多数足部数据集上能用,但你在自己数据上仍应做一轮消融实验:对比增强前后验证集评分的均方差变化。
还有一个容易被忽略的点:DFU特征由溃疡区域和周边皮肤上下文共同构成。若做局部裁切,必须在溃疡掩码外保留至少10~15%像素的环境皮肤,否则模型会丢失“红肿范围”这个关键信息,导致中风险和高风险之间区分度下降。
4. 用PyTorch实现DFU风险评分模型:网络设计、L2正则化与训练参数怎么定
4.1 网络结构:从预训练ResNet到评分头的完整代码
模型部分我常用ResNet18作为骨干,兼顾推理速度和精度。下面是一个带评分头的完整实现。
import torch.nn as nn from torchvision import models class DFUScorer(nn.Module): def __init__(self, num_classes=4, pretrained=True): super().__init__() # 用ResNet18作为骨干,预训练权重来自ImageNet if pretrained: self.backbone = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) else: self.backbone = models.resnet18(weights=None) in_features = self.backbone.fc.in_features self.backbone.fc = nn.Identity() # 去掉原始分类头,只拿特征向量 self.drop = nn.Dropout(p=0.3) # 全连接前加Dropout,缓解小样本过拟合 self.head = nn.Sequential( nn.Linear(in_features, 128), nn.ReLU(inplace=True), nn.Linear(128, num_classes) # 输出4个类别的logits ) def forward(self, x): feats = self.backbone(x) return self.head(self.drop(feats))为什么保留整个ResNet而不是只取前几层冻结?因为DFU图像的纹理和边缘细节集中在浅层和中层特征里,如果只微调最后一个全连接层,模型学不到“周边红肿扩大”这类中层特征。常见做法是解冻所有层,用一个小学习率端到端微调;只有当数据量低于500张时,才退回到只解冻最后两个stage的策略。
评分头也不要堆太多全连接层。医学图像样本量通常只有几千张,全连接层参数越多,越容易把训练集分布背下来。一层128维中间层加Dropout已经够用,往上再加层的收益很小,验证集只会出现过拟合。这里的4类输出对应前面定义的0/2/5/9分映射,推理时再按Softmax概率加权。
4.2 训练循环与关键超参:学习率、批大小与调度策略
训练代码直接用标准PyTorch训练循环即可,重点是几个超参怎么选。优化器我用AdamW而不是SGD;对医学图像微调来说,AdamW的自适应学习率能省去大量手动摸索时间,配合weight_decay做L2正则化比SGD稳定。学习率在迁移学习场景里通常以1e-4起步,逐层微调时再用1e-5收尾。批大小取决于显存,ResNet18在224×224输入下,batch size 32是常见起点。
from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR model = DFUScorer(num_classes=4, pretrained=True) optimizer = AdamW(model.parameters(), lr=1e-4, weight_decay=1e-2) scheduler = CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-6) criterion = nn.CrossEntropyLoss()weight_decay设1e-2,这是L2正则化的直接体现:把参数的平方和加进损失约束,让模型权重不要长得过大,从而压缩对训练集噪声的拟合。这个数值配AdamW是常见组合,不是拍脑袋——下面一节单独说怎么调。学习率调度用余弦退火,T_max设为30个epoch,eta_min设为1e-6,让学习率在后半段缓慢逼近极小值,比阶梯下降更容易落在平滑的收敛点。
best_loss = float("inf") for epoch in range(30): model.train() for batch_x, batch_y in train_loader: optimizer.zero_grad() out = model(batch_x) loss = criterion(out, batch_y) loss.backward() optimizer.step() model.eval() val_loss = 0.0 with torch.no_grad(): for batch_x, batch_y in val_loader: out = model(batch_x) val_loss += criterion(out, batch_y).item() scheduler.step() if val_loss < best_loss: best_loss = val_loss torch.save(model.state_dict(), "best_model.pt")这里有个容易踩的细节:每个epoch结束必须调用model.eval()再验证,而且验证过程要包在torch.no_grad()里。否则BatchNorm层的运行统计会继续被验证集更新,验证损失永远降不到稳定值。最终模型不是取第30轮的权重,而是取val_loss最小时保存的best_model.pt。
4.3 L2正则化的取舍:weight_decay到底设多少
关于L2正则化,有一件事容易被误导:以为weight_decay越大,验证集分数一定越稳。实际表现是“先降后升”——太小(1e-4或更低)时训练损失降得很快但验证集抖动剧烈,太大(1e-1量级)时模型欠拟合,训练损失都压不下去。在DFU这类千张级数据集上,我的常见落点是1e-2,偶尔用5e-3。判断标准很简单:训练损失正常下降、验证损失逐步走平且抖动幅度小,就说明正则强度合适。
另一个正则化手段是early stopping。上面代码里已经体现了:保留验证集上最小损失的权重副本,不拿最后一轮的权重当最终模型。这相当于在训练过程中自动选择正则强度,成本低收益高。配合Dropout的0.3,三者合起来能明显压住小样本场景下的过拟合。
下表是我在类似DFU项目里常用的参数组合,可以直接抄作业:
| 参数 | 设置 | 说明 |
|---|---|---|
| 骨干网络 | ResNet18 | 预训练权重必须加载 |
| 优化器 | AdamW | 学习率1e-4,weight_decay 1e-2 |
| 输入尺寸 | 256×256(内部池化压缩) | 不要直接用224×224,损失边缘细节 |
| 评分头 | 128维FC + Dropout(0.3) | 不加更多全连接层 |
| 损失函数 | CrossEntropyLoss(weight=类权重) | 类别不平衡时按样本数反比加权 |
| 调度器 | CosineAnnealingLR T_max=30 | eta_min=1e-6 |
| 早停 | 保存最小验证损失权重 | 不拿最后一轮权重做最终模型 |
如果你用EfficientNet-B0替代ResNet18,输入尺寸改成240×240,其余参数不用动。模型容量更大,但DFU评分场景优先保证可复现性和低方差,我一般还是先跑ResNet18基线,再对比EfficientNet的增益是否值得额外的推理耗时。
5. 封装评分系统与避坑排查:从模型到可调用服务的5个高频故障
5.1 封装:把模型输出转成可调用的评分接口
模型训练完,下一步是把权重打包成可调用的评分服务。常见方案是FastAPI写一个最小接口:接收上传图像,跑预处理,推理,输出风险分数和置信度。我一般还会记录一份评估日志(病例ID、图像hash、分数、模型版本号),方便后续复盘;交付包通常是一个zip压缩包,里面包含模型权重、预处理配置、推理脚本和一份README,解压后按说明启动服务即可,不用再依赖训练环境。
from fastapi import FastAPI, UploadFile import torch from PIL import Image app = FastAPI() model = DFUScorer(num_classes=4, pretrained=False) model.load_state_dict(torch.load("best_model.pt")) model.eval() # 必须切到评估模式,否则BatchNorm和Dropout行为不对 with torch.no_grad(): @app.post("/score") async def score_image(file: UploadFile): img = Image.open(file.file).convert("RGB") # 推理预处理必须与训练时完全一致 img = img.resize((256, 256)) import torchvision.transforms as T tf = T.Compose([ T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) x = tf(img).unsqueeze(0) logits = model(x) probs = torch.softmax(logits, dim=1).squeeze().tolist() risk_score = round(0 * probs[0] + 2 * probs[1] + 5 * probs[2] + 9 * probs[3], 1) return {"risk_score": risk_score, "probabilities": probs}这段代码里最值得注意的是model.eval()那行。很多翻车案例就是漏了它,导致同一张图每次调用分数都不同。另外推理预处理必须与训练脚本共用同一份配置,不要手动写一遍Resize和Normalize,否则一处符号不对,评分整体漂移。
5.2 踩坑一:换一批图像分数集体偏高或偏低
现象:训练集和验证集上loss都很漂亮,模型看起来收敛了,但换上临床新拍的图像后,评分系统给出的分数整体偏离医生判断。
原因:预处理不一致。训练时用了ColorJitter和RandomRotation,本地推理脚本却忘了写,或Normalize的mean/std填错;另一个常见原因是色彩空间处理不一致,部分手机拍摄的广色域图像在PIL打开后RGB通道数值范围和训练数据不一致。
解决:把预处理管道抽成一份公共配置文件,训练和推理都从这里读取。图像统一convert("RGB")。验证阶段单独留一个采集批次做推理对比,不能只看训练集同一来源的数据。
5.3 踩坑二:类别不平衡导致模型“都评低分”
现象:训练集里约80%是无风险样本,模型在测试集上准确率很高,但把所有高风险溃疡都判成低分,调阈值也拉不回来。
原因:损失函数直接用了默认的CrossEntropyLoss,模型学会了“全部输出低风险”这种捷径——这样做整体loss最小,但临床意义为零。
解决:改用带类别权重的损失,按每个类别样本数的倒数归一化成权重。做法很简单:
class_weights = torch.tensor([1.0, 2.0, 4.0, 8.0], device=device) criterion = nn.CrossEntropyLoss(weight=class_weights)权重具体值按训练集各类别数量比例的反比设置,让少数类样本的梯度贡献放大。这比在数据层面复制少数类样本更稳定,也不容易过拟合。
5.4 踩坑三:评分服务时好时坏
现象:同一个服务,上午调用和下午调用结果不一致,同一张图评分波动很大。
原因:模型被加载后处于训练模式,BatchNorm层在接收新输入时仍会更新运行统计量;模型权重在服务容器里反复保存和加载时也容易丢掉评估模式状态。
解决:在加载封装函数里显式调用model.eval(),并把推理包进torch.no_grad()或torch.inference_mode()。这个问题大多源于“训练代码直接改成了推理代码”,把训练脚本里的dropout残留带进了服务。
5.5 踩坑四:只有溃疡特写图导致评分失真
现象:模型评分与医生直觉分数相关性差,溃疡面积很小的重感染病例被低估。
原因:训练数据包含了完整足部轮廓,真实部署输入却只裁剪了溃疡局部放大图。模型没有“溃疡在整足中的占比”这个概念,自然无法正确推断严重程度。
解决:输入口径要在标注时明确——训练用包含足部整体的图像,推理时要求调用方上传这样的图像而不是特写。在接口文档里用示意图标出可接受的图像范围,这条在落地时能挡掉一大部分后续投诉。如果确实只能拿到特写图,那就得在同一口径下重新标注一批特写数据,不能混合训练。
6. 验证评分可用性的一个技巧:校准曲线与阈值复盘
6.1 用校准曲线判断评分是否“可信”
很多做深度学习评分的人只看准确率,但DFU风险评分场景里,医生更关心的是“模型说高风险,真的高风险吗”。这就需要画校准曲线:把测试集按模型输出的分数分箱,横轴是模型预测分数,纵轴是真实高风险比例;理想情况下两者应该落在45度对角线上。如果校准曲线明显偏离,说明模型输出的是“排名”而不是“概率”,需要做温度缩放后再对外提供分数。
这里给一段直接可用的绘图代码:
import matplotlib.pyplot as plt import numpy as np def plot_calibration(probs, y_true, n_bins=10): bins = np.linspace(0, 1, n_bins + 1) pred_mean = np.zeros(n_bins) true_mean = np.zeros(n_bins) for i in range(n_bins): mask = (probs >= bins[i]) & (probs < bins[i + 1]) if mask.sum() > 0: pred_mean[i] = probs[mask].mean() true_mean[i] = y_true[mask].mean() plt.plot(pred_mean, true_mean, marker="o") plt.plot([0, 1], [0, 1], linestyle="--", color="gray") plt.xlabel("model predicted probability") plt.ylabel("observed high-risk rate") plt.show()用的时候注意只对“高风险”类别的概率做校准,而不是对加权后的风险分数做。校准曲线偏离45度线时,先用温度缩放:在验证集上搜索一个温度T,让Softmax(logits / T)的期望概率与真实频率对齐。温度缩放不改变排序,只改变置信度,特别适合DFU这类评分场景。
6.2 阈值复盘:把“漏报”单列出来看
在实际复查时我习惯结合混淆矩阵,把“误报高风险”和“漏报高风险”分开看。漏报高风险指模型判0分但临床结局两周后恶化,这是最不能接受的错误。每次迭代我都把漏报样本单独挑出来,逐张看模型和医生判断的分歧来源:是图像光照太暗,还是溃疡被趾缝遮挡,还是这类病例本身在训练集里就没有相似样本。把这些样本加进训练集,比单纯调阈值有效得多。
另一个实践是输出结构化评分报告:风险等级、类别概率、预测所针对的图像区域。用梯度加权类激活映射标出模型关注区域,一方面方便医生复核,另一方面能暴露模型是否在“看背景不看伤口”。如果热力图标到了正常皮肤和背景物体上,说明训练数据里混入了与标签相关的环境噪声,这时候优先查数据而不是查网络结构。这个复盘习惯帮我挡掉了好几次临床试用的信任危机——医生第一眼不会信模型,但看到热力图标在溃疡边缘时,沟通成本立刻降下来。希望帮到你。
本文还有配套的精品资源,点击获取