基于深度学习的皮肤病识别系统:从数据准备到可解释部署全流程 简介这是一套面向深度学习入门者与毕业设计、课程设计需求者的皮肤病识别完整项目源码围绕卷积神经网络与YOLO目标检测思路实现从皮肤病变图像训练到病灶识别的全流程适合作为期末大作业或医疗AI方向的实践参考。压缩包共49个文件、约285KB以Python脚本为主涵盖模型定义、训练、预测与集成推理等核心逻辑另含微信小程序的js、wxml、wxss与json配置以及png图片、csv标注数据和说明文档服务端与客户端结构划分清晰。目前已有88人学习下载。读者可据此了解服务端模型训练与推理、客户端图像上传与结果展示的完整链路并参考数据预处理、模型部署与前后端通信的组织方式快速搭建可运行的原型系统。1. 皮肤病识别系统到底在识别什么从一张手机照片到可解释的诊断建议皮肤科门诊里最不缺的就是「看起来差不多」的皮损照片。某开发者拿手机拍下一块红斑问这套基于深度学习的皮肤病识别系统能不能判断是湿疹、银屑病还是真菌感染——这正是标题里「皮肤病识别系统」要回答的问题。它本质是一个图像分类工程输入是皮损区域的 RGB 图像输出是若干候选疾病标签加置信度再叠加一层可解释性热力图告诉医生「模型是看哪块区域下的判断」。它解决的不是替代医生而是分诊、初筛和教学辅助基层机构没有皮肤科医生时先跑一遍模型把高风险样本挑出来优先转诊。适合谁做有 Python 基础、懂一点 PyTorch、手里能拿到几千张标注皮损图的工程师或医学信息方向的学生。难点从来不在模型结构而在数据不平衡、拍摄条件不一致和「模型说 92% 是黑色素瘤」这种结论怎么让临床信。2. 数据准备与标签体系决定上限的不是网络而是标注质量皮肤病识别的天花板在数据集不在骨干网络。公开的皮损数据集常见问题是类别极度不平衡——基底细胞癌可能只有几百张而脂溢性角化有几千张另一个问题是同一张图被不同医生标成不同病。我一般会先做三件事统一标签体系、按患者维度切分、做类别权重统计。2.1 标签体系怎么定按临床层级还是按病种平铺最省事的做法是平铺多分类比如 7 类或 23 类。但皮肤病有层级关系黑色素瘤和痣同属黑色素细胞病变银屑病和扁平苔藓同属炎症性丘疹鳞屑病。如果直接平铺模型会把「黑色素细胞病变」这个大类的共性学走细分反而学不好。常见做法是两级标签先分大类黑色素细胞性、炎症性、感染性、良性增生再在大类内细分。代码里用一个字典维护映射训练时同时输出两级 logits损失加权求和。# 两级标签映射大类 - 子类列表 LABEL_HIERARCHY { melanocytic: [nevus, melanoma, dysplastic_nevus], inflammatory: [eczema, psoriasis, lichen_planus], infectious: [tinea, candidiasis, impetigo], benign: [seborrheic_keratosis, dermatofibroma], } # 反向索引子类 - 大类 id SUB2MAIN {sub: i for i, (main, subs) in enumerate(LABEL_HIERARCHY.items()) for sub in subs} SUB2IDX {sub: i for i, sub in enumerate(sum(LABEL_HIERARCHY.values(), []))}逻辑说明LABEL_HIERARCHY是唯一事实来源改标签只改这里。SUB2MAIN给每个子类一个大类 id训练时主分类头用这个 id 做监督。参数上主分类损失权重我一般设 0.3子分类设 0.7因为临床更关心具体病种大类只是辅助正则。2.2 按患者切分而不是按图片切分一个被低估的泄漏点同一个患者可能有多张不同角度的皮损图如果随机按图片切分训练集和验证集会包含同一患者的照片验证准确率虚高十几个点。血泪经验一定要按患者 ID 切分。做法是先拿到patient_id列用GroupShuffleSplit或自己按患者分组后整组划入 train/val/test。import pandas as pd from sklearn.model_selection import GroupShuffleSplit df pd.read_csv(lesions_meta.csv) # 至少含 image_path, label, patient_id gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(gss.split(df, groupsdf[patient_id])) train_df, val_df df.iloc[train_idx], df.iloc[val_idx] # 再对 train 做一次按患者的切分得到 test避免 test 泄漏 gss2 GroupShuffleSplit(n_splits1, test_size0.25, random_state42) tr_idx, te_idx next(gss2.split(train_df, groupstrain_df[patient_id]))参数说明test_size0.2是验证集比例random_state固定保证可复现。切完后打印train_df[patient_id].nunique()和val_df[patient_id].nunique()确认没有交集。如果发现交集说明patient_id有缺失值被当成同一组要先填充或剔除。2.3 类别不平衡的三种处理与选择依据处理不平衡常见三种重采样、类别权重、损失函数改造。重采样在医学图像上容易过拟合少数类因为同一张图被反复采样。我更倾向类别权重加 focal loss。权重按1 / sqrt(freq)算比1 / freq温和避免少数类权重爆炸。import numpy as np from torch import nn counts train_df[label].value_counts().sort_index().values weights 1.0 / np.sqrt(counts) weights weights / weights.sum() * len(weights) # 归一化到均值为1 class_weights torch.tensor(weights, dtypetorch.float32) criterion nn.CrossEntropyLoss(weightclass_weights, label_smoothing0.1)逻辑说明label_smoothing0.1缓解标注噪声皮肤病标注本身有分歧硬标签会逼模型过拟合。参数上如果少数类样本少于 50 张建议先做数据增强而不是继续加权重否则模型只是记住那几十张图。3. 模型选型与训练从 ResNet 到 ViT 的取舍和最小可跑通配置皮肤病图像和自然图像不同皮损边界模糊、颜色纹理是主要线索、全局形状也有意义。卷积网络擅长局部纹理ViT 擅长全局关系但 ViT 需要更多数据。我的建议是先用 ResNet 或 EfficientNet 打底数据量超过两万张再考虑 ViT 或混合结构。3.1 骨干网络对比EfficientNet-B3 为什么常作为起点在皮损分类任务上EfficientNet-B3 在参数量和准确率之间比较平衡输入 300x300 时显存占用约 6GBbatch 16单卡 8GB 能跑。ResNet-50 更稳但参数量大ViT-B/16 在小数据集上容易过拟合。常见做法是用 ImageNet 预训练权重冻结前几层只训后面 stage 和分类头。import torchvision.models as models import torch.nn as nn def build_model(num_classes, backboneefficientnet_b3, pretrainedTrue): if backbone efficientnet_b3: model models.efficientnet_b3(weightsIMAGENET1K_V1 if pretrained else None) in_features model.classifier[1].in_features model.classifier[1] nn.Linear(in_features, num_classes) elif backbone resnet50: model models.resnet50(weightsIMAGENET1K_V2 if pretrained else None) model.fc nn.Linear(model.fc.in_features, num_classes) return model # 冻结前 5 个 stage只训后面 model build_model(num_classeslen(SUB2IDX)) for name, param in model.named_parameters(): if features.0 in name or features.1 in name or features.2 in name: param.requires_grad False逻辑说明weightsIMAGENET1K_V1是 torchvision 的预训练权重标识不同版本字符串可能不同以本地 torchvision 文档为准。冻结策略按层名匹配EfficientNet 的features是卷积主干classifier是分类头。参数上解冻后学习率用 1e-4分类头用 1e-3差分学习率比统一学习率收敛更稳。3.2 训练循环与关键超参batch size、学习率、早停训练循环里最容易被忽略的是验证指标的选择。皮肤病识别不能只看准确率要看每类召回率和 AUC。我一般用宏平均 AUC 做早停依据因为它对类别不平衡不敏感。from torch.utils.data import DataLoader from sklearn.metrics import roc_auc_score import numpy as np def evaluate(model, loader, device): model.eval() all_probs, all_labels [], [] with torch.no_grad(): for imgs, labels in loader: imgs imgs.to(device) logits model(imgs) probs torch.softmax(logits, dim1).cpu().numpy() all_probs.append(probs) all_labels.append(labels.numpy()) probs np.concatenate(all_probs) labels np.concatenate(all_labels) # 宏平均 AUC多分类用 one-vs-rest auc roc_auc_score(labels, probs, multi_classovr, averagemacro) return auc # 训练主循环片段 optimizer torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) best_auc, patience, counter 0.0, 7, 0 for epoch in range(30): model.train() for imgs, labels in train_loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() logits model(imgs) loss criterion(logits, labels) loss.backward() optimizer.step() val_auc evaluate(model, val_loader, device) scheduler.step() if val_auc best_auc: best_auc, counter val_auc, 0 torch.save(model.state_dict(), best.pth) else: counter 1 if counter patience: break参数说明AdamW的weight_decay1e-4是常用起点太大欠拟合太小过拟合。CosineAnnealingLR的T_max设成总 epoch 数。早停patience7是经验值验证集小的时候可以设 5。注意roc_auc_score的multi_classovr在类别数多时计算慢可以每两个 epoch 算一次。3.3 数据增强哪些增强对皮损有效哪些会帮倒忙皮损图像的颜色和纹理是诊断线索所以颜色抖动要谨慎。水平翻转、垂直翻转、小角度旋转、随机裁剪是安全的。颜色抖动幅度大了会把红斑调成正常肤色模型学不到颜色特征。我一般用ColorJitter(brightness0.1, contrast0.1, saturation0.1, hue0.02)hue 几乎不动。from torchvision import transforms train_tf transforms.Compose([ transforms.Resize((320, 320)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomVerticalFlip(p0.3), transforms.RandomRotation(degrees15), transforms.RandomResizedCrop(300, scale(0.8, 1.0)), transforms.ColorJitter(brightness0.1, contrast0.1, saturation0.1, hue0.02), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_tf transforms.Compose([ transforms.Resize((300, 300)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])逻辑说明RandomResizedCrop的scale(0.8, 1.0)保证不裁掉太多皮损区域。Normalize用 ImageNet 统计量因为骨干是 ImageNet 预训练的。如果自己从头训统计量要换成数据集自身的均值和方差。4. 可解释性与部署让医生敢用、让系统能跑起来模型准确率再高医生看不到依据就不会用。可解释性不是锦上添花是落地门槛。同时系统要能部署到实际环境不能只在 notebook 里跑。4.1 Grad-CAM 热力图定位模型关注的皮损区域Grad-CAM 通过梯度加权特征图生成热力图叠加在原图上。皮肤病识别里热力图应该落在皮损区域而不是背景。如果热力图落在尺子或标记笔上说明数据有捷径特征要清理。import cv2 import numpy as np import torch def grad_cam(model, img_tensor, target_layer, class_idxNone): model.eval() features, grads [], [] def forward_hook(module, inp, out): features.append(out) def backward_hook(module, grad_in, grad_out): grads.append(grad_out[0]) handle_f target_layer.register_forward_hook(forward_hook) handle_b target_layer.register_full_backward_hook(backward_hook) logits model(img_tensor.unsqueeze(0)) if class_idx is None: class_idx logits.argmax(dim1).item() model.zero_grad() logits[0, class_idx].backward() fmap features[0].detach().cpu().numpy()[0] # C,H,W grad grads[0].detach().cpu().numpy()[0] # C,H,W weights grad.mean(axis(1, 2)) # C cam np.tensordot(weights, fmap, axes([0], [0])) cam np.maximum(cam, 0) cam cv2.resize(cam, (img_tensor.shape[2], img_tensor.shape[1])) cam (cam - cam.min()) / (cam.max() 1e-8) handle_f.remove(); handle_b.remove() return cam, class_idx逻辑说明target_layer一般选最后一个卷积 stage 的输出。register_full_backward_hook拿梯度weights是每个通道的梯度均值tensordot做加权求和。参数上cam.max()归一化到 0-1 方便叠加。注意model.zero_grad()要在 backward 前调用否则梯度累积。4.2 部署形态FastAPI 服务加前端上传的最小闭环部署最常见的是 FastAPI 包一层推理接口前端上传图片返回 top-3 标签和热力图 base64。模型加载一次常驻内存推理时用torch.no_grad()。from fastapi import FastAPI, File, UploadFile from PIL import Image import io, base64, torch app FastAPI() model build_model(num_classeslen(SUB2IDX)) model.load_state_dict(torch.load(best.pth, map_locationcpu)) model.eval() app.post(/predict) async def predict(file: UploadFile File(...)): img Image.open(io.BytesIO(await file.read())).convert(RGB) tensor val_tf(img) with torch.no_grad(): logits model(tensor.unsqueeze(0)) probs torch.softmax(logits, dim1)[0] top3 torch.topk(probs, 3) idx2sub {v: k for k, v in SUB2IDX.items()} results [{label: idx2sub[i.item()], prob: round(p.item(), 4)} for p, i in zip(top3.values, top3.indices)] return {predictions: results}参数说明map_locationcpu保证没有 GPU 也能加载。topk(probs, 3)返回 top-3临床场景下给候选比给单一结论更安全。如果要做热力图在接口里调grad_cam并把 cam 转成 base64 返回注意grad_cam需要梯度不能包在no_grad里。4.3 阈值与拒识什么时候系统应该说「我不确定」皮肤病识别系统必须能拒识。当 top-1 概率低于某个阈值或者 top-1 和 top-2 差距太小应该返回「建议人工复核」。阈值不能拍脑袋要在验证集上按召回率要求调。比如要求黑色素瘤召回不低于 95%就找那个阈值。def decide(probs, idx2sub, threshold0.6, margin0.15): top2 torch.topk(probs, 2) p1, p2 top2.values[0].item(), top2.values[1].item() if p1 threshold or (p1 - p2) margin: return {decision: refer, reason: low_confidence} return {decision: predict, label: idx2sub[top2.indices[0].item()], prob: p1}逻辑说明threshold控制绝对置信度margin控制相对差距。参数上threshold0.6和margin0.15是起点实际要在验证集上画召回率-拒识率曲线来定。拒识率太高医生会觉得没用太低又失去安全意义。5. 避坑与排查那些让模型指标虚高、上线翻车的细节这一章记录我踩过的坑每条按现象、原因、解决写。5.1 验证集 AUC 0.98测试集掉到 0.7现象训练时验证集宏平均 AUC 到 0.98换测试集只有 0.7。原因按图片随机切分导致同一患者多张图泄漏或者数据增强在验证集上也开了。解决按患者 ID 切分验证集只用 resize 和 normalize不开随机增强。检查val_tf里有没有Random开头的变换。5.2 热力图总是落在图像边缘和尺子上现象Grad-CAM 高亮区域在图片角落的尺子或标记笔上。原因数据集中有标注尺或色卡模型学了捷径特征。解决训练前裁剪掉边缘区域或者用分割模型先抠出皮损区域再分类。简单做法是中心裁剪 80% 区域但会损失边缘皮损要权衡。5.3 少数类召回率始终上不去现象黑色素瘤召回只有 0.5其他类都 0.9 以上。原因少数类样本太少类别权重不够或者过拟合。解决先确认少数类样本数少于 100 张时优先做针对性数据增强旋转、翻转、弹性形变再加 focal loss。如果还不行考虑用度量学习或原型网络但工程复杂度上升。5.4 推理服务内存持续增长现象FastAPI 服务跑几天后 OOM。原因每次请求都加载模型或者热力图计算图没释放。解决模型全局加载一次推理包torch.no_grad()热力图计算后调model.zero_grad()并del中间变量。用torch.cuda.empty_cache()在 GPU 场景下清理缓存。5.5 不同手机拍的照片颜色差异导致误判现象同一块皮损不同手机拍出来模型结论不同。原因白平衡和色彩配置文件不同。解决训练时加入强颜色增强模拟不同设备或者推理前做白平衡校正灰度世界算法。灰度世界算法简单算 RGB 三通道均值按均值比例缩放。def gray_world(img_array): # img_array: H,W,3 float means img_array.reshape(-1, 3).mean(axis0) gray means.mean() scale gray / (means 1e-6) return np.clip(img_array * scale, 0, 255).astype(np.uint8)逻辑说明means是三通道均值gray是整体均值scale让每个通道向整体均值靠拢。参数上没有需要调的但注意这个算法在单色场景下会失效皮损图像一般有足够颜色信息。6. 进阶技巧用测试时增强和模型集成把 AUC 再抬两个点训练完一个模型不是终点。在验证集上稳定后测试时增强TTA和模型集成是性价比最高的提点手段。TTA 是对同一张图做多种变换分别推理再平均集成是训多个不同种子或不同骨干的模型投票。这两招在皮肤病识别上通常能抬 1 到 3 个点 AUC代价是推理时间线性增加。TTA 的实现要点是变换要覆盖训练时见过的分布但不要引入训练时没有的变换。我一般用原始、水平翻转、垂直翻转、旋转 90 度四种推理四次取平均概率。def tta_predict(model, img_tensor, transforms_list): model.eval() probs_sum None with torch.no_grad(): for tf in transforms_list: aug tf(img_tensor) logits model(aug.unsqueeze(0)) probs torch.softmax(logits, dim1) probs_sum probs if probs_sum is None else probs_sum probs return probs_sum / len(transforms_list) tta_transforms [ lambda x: x, lambda x: torch.flip(x, dims[2]), # 水平翻转 lambda x: torch.flip(x, dims[1]), # 垂直翻转 lambda x: torch.rot90(x, k1, dims[1, 2]), # 旋转90度 ]逻辑说明torch.flip的dims参数注意输入是 C,H,W水平翻转是 dims2垂直是 dims1。rot90的dims[1,2]在 H,W 上旋转。参数上TTA 变换数量不是越多越好四种足够八种边际收益很低。模型集成方面我一般训三个模型EfficientNet-B3、ResNet-50、一个用不同随机种子的 EfficientNet-B3。推理时三个模型概率平均。如果要做加权平均权重在验证集上用网格搜索或简单按 AUC 归一化。集成的前提是模型之间要有差异性如果都是同一个骨干同一个种子集成没意义。def ensemble_predict(models, img_tensor, weightsNone): all_probs [] with torch.no_grad(): for m in models: logits m(img_tensor.unsqueeze(0)) all_probs.append(torch.softmax(logits, dim1)) stacked torch.stack(all_probs, dim0) # N,1,C if weights is None: return stacked.mean(dim0) w torch.tensor(weights).view(-1, 1, 1) return (stacked * w).sum(dim0) / w.sum()参数说明weights长度要和模型数一致按验证集 AUC 归一化即可。注意集成后推理时间翻倍如果部署环境有延迟要求要权衡。最后一个技巧是关于阈值校准的。模型输出的 softmax 概率往往过于自信直接拿 0.5 当阈值会出问题。用温度缩放temperature scaling在验证集上校准在验证集上优化一个温度参数 T让softmax(logits / T)的负对数似然最小。T 通常大于 1校准后概率更接近真实频率拒识阈值才有意义。import torch.optim as optim def calibrate_temperature(model, val_loader, device): model.eval() logits_list, labels_list [], [] with torch.no_grad(): for imgs, labels in val_loader: logits_list.append(model(imgs.to(device)).cpu()) labels_list.append(labels) logits torch.cat(logits_list) labels torch.cat(labels_list) T torch.nn.Parameter(torch.ones(1)) optimizer optim.LBFGS([T], lr0.01, max_iter50) criterion nn.CrossEntropyLoss() def closure(): optimizer.zero_grad() loss criterion(logits / T, labels) loss.backward() return loss optimizer.step(closure) return T.item()逻辑说明LBFGS适合小参数优化max_iter50足够。校准后把 T 存下来推理时用softmax(logits / T)。参数上T 一般落在 1.2 到 2.5 之间如果 T 小于 1 说明模型欠自信比较少见。我自己的习惯是每做完一个医学图像分类项目先跑一遍按患者切分的基线再上 TTA 和集成最后做温度校准。这套流程下来模型指标扎实医生看到的热力图和拒识逻辑也站得住。希望帮到你。本文还有配套的精品资源点击获取