☰
基于深度学习的皮肤病识别系统:从数据准备到可解释部署全流程
2026/10/9 23:44:43 网站建设 项目流程

简介:这是一套面向深度学习入门者与毕业设计、课程设计需求者的皮肤病识别完整项目源码,围绕卷积神经网络与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_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(df, groups=df["patient_id"])) train_df, val_df = df.iloc[train_idx], df.iloc[val_idx] # 再对 train 做一次按患者的切分得到 test,避免 test 泄漏 gss2 = GroupShuffleSplit(n_splits=1, test_size=0.25, random_state=42) tr_idx, te_idx = next(gss2.split(train_df, groups=train_df["patient_id"]))

参数说明:test_size=0.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, dtype=torch.float32) criterion = nn.CrossEntropyLoss(weight=class_weights, label_smoothing=0.1)

逻辑说明:label_smoothing=0.1缓解标注噪声,皮肤病标注本身有分歧,硬标签会逼模型过拟合。参数上,如果少数类样本少于 50 张,建议先做数据增强而不是继续加权重,否则模型只是记住那几十张图。

3. 模型选型与训练:从 ResNet 到 ViT 的取舍和最小可跑通配置

皮肤病图像和自然图像不同:皮损边界模糊、颜色纹理是主要线索、全局形状也有意义。卷积网络擅长局部纹理,ViT 擅长全局关系,但 ViT 需要更多数据。我的建议是先用 ResNet 或 EfficientNet 打底,数据量超过两万张再考虑 ViT 或混合结构。

3.1 骨干网络对比:EfficientNet-B3 为什么常作为起点

在皮损分类任务上,EfficientNet-B3 在参数量和准确率之间比较平衡,输入 300x300 时显存占用约 6GB(batch 16),单卡 8GB 能跑。ResNet-50 更稳但参数量大,ViT-B/16 在小数据集上容易过拟合。常见做法是用 ImageNet 预训练权重,冻结前几层,只训后面 stage 和分类头。

import torchvision.models as models import torch.nn as nn def build_model(num_classes, backbone="efficientnet_b3", pretrained=True): if backbone == "efficientnet_b3": model = models.efficientnet_b3(weights="IMAGENET1K_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(weights="IMAGENET1K_V2" if pretrained else None) model.fc = nn.Linear(model.fc.in_features, num_classes) return model # 冻结前 5 个 stage,只训后面 model = build_model(num_classes=len(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

逻辑说明:weights="IMAGENET1K_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, dim=1).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_class="ovr", average="macro") return auc # 训练主循环片段 optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) 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_decay=1e-4是常用起点,太大欠拟合,太小过拟合。CosineAnnealingLR的T_max设成总 epoch 数。早停patience=7是经验值,验证集小的时候可以设 5。注意roc_auc_score的multi_class="ovr"在类别数多时计算慢,可以每两个 epoch 算一次。

3.3 数据增强:哪些增强对皮损有效,哪些会帮倒忙

皮损图像的颜色和纹理是诊断线索,所以颜色抖动要谨慎。水平翻转、垂直翻转、小角度旋转、随机裁剪是安全的。颜色抖动幅度大了会把红斑调成正常肤色,模型学不到颜色特征。我一般用ColorJitter(brightness=0.1, contrast=0.1, saturation=0.1, hue=0.02),hue 几乎不动。

from torchvision import transforms train_tf = transforms.Compose([ transforms.Resize((320, 320)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomVerticalFlip(p=0.3), transforms.RandomRotation(degrees=15), transforms.RandomResizedCrop(300, scale=(0.8, 1.0)), transforms.ColorJitter(brightness=0.1, contrast=0.1, saturation=0.1, hue=0.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_idx=None): 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(dim=1).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_classes=len(SUB2IDX)) model.load_state_dict(torch.load("best.pth", map_location="cpu")) 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, dim=1)[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_location="cpu"保证没有 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, threshold=0.6, margin=0.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控制相对差距。参数上,threshold=0.6和margin=0.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(axis=0) 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, dim=1) 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, k=1, dims=[1, 2]), # 旋转90度 ]

逻辑说明:torch.flip的dims参数注意,输入是 C,H,W,水平翻转是 dims=2,垂直是 dims=1。rot90的dims=[1,2]在 H,W 上旋转。参数上,TTA 变换数量不是越多越好,四种足够,八种边际收益很低。

模型集成方面,我一般训三个模型:EfficientNet-B3、ResNet-50、一个用不同随机种子的 EfficientNet-B3。推理时三个模型概率平均。如果要做加权平均,权重在验证集上用网格搜索或简单按 AUC 归一化。集成的前提是模型之间要有差异性,如果都是同一个骨干同一个种子,集成没意义。

def ensemble_predict(models, img_tensor, weights=None): all_probs = [] with torch.no_grad(): for m in models: logits = m(img_tensor.unsqueeze(0)) all_probs.append(torch.softmax(logits, dim=1)) stacked = torch.stack(all_probs, dim=0) # N,1,C if weights is None: return stacked.mean(dim=0) w = torch.tensor(weights).view(-1, 1, 1) return (stacked * w).sum(dim=0) / 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], lr=0.01, max_iter=50) 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_iter=50足够。校准后把 T 存下来,推理时用softmax(logits / T)。参数上,T 一般落在 1.2 到 2.5 之间,如果 T 小于 1 说明模型欠自信,比较少见。

我自己的习惯是每做完一个医学图像分类项目,先跑一遍按患者切分的基线,再上 TTA 和集成,最后做温度校准。这套流程下来,模型指标扎实,医生看到的热力图和拒识逻辑也站得住。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询