
简介基于深度学习的皮肤状况检测完整项目面向医学影像分析开发者与医疗AI学习者聚焦皮肤疾病自动识别与分类。提供端到端方案涵盖MobileNetV3、VGG19、ResNet152等CNN模型的独立训练与集成对比结合DullRazor毛发去除、数据增强等预处理帮助用户理解不同架构在皮肤特征提取上的表现差异。资源共42个文件压缩包约10.58MB以7个ipynb模型训练与预处理脚本为核心辅以jpg/jpeg皮肤图像样本、png训练曲线图、HTMLCSSJS搭建的分类演示网站、Python后端脚本、csv预测结果以及mp4操作演示和md说明文档目录按数据预处理、单模型实验、集成对比、网页部署分模块组织便于逐环节复现。当前已有159人学习适合需要构建皮肤检测系统或深入研究多模型融合策略的开发者可完整走通图像清洗、模型训练、性能评估与结果可视化流程为后续医学图像项目提供可扩展基线。1. 为什么皮肤状况检测会把深度学习顶到前台皮肤科里图片量增长最快能审图的专科医生却没跟上。基层机构拍下一张皮损照片通常要等上级医院远程会诊而分诊的第一步就是把普通痣、脂溢性角化病、基底细胞癌和需要尽快活检的黑色素瘤区分开。深度学习在这类任务上不是玄学用 ImageNet 预训练 CNN 迁移到皮肤镜图像AUC 普遍在 0.850.95 之间足以承担辅助分诊工作。真正有价值的投入点是数据划分、类别不均衡处理和置信度校准这些环节的效果往往比堆叠网络层数更明显。下面这条流水线覆盖数据组织、模型选型、训练参数、评估指标和上线前的细节处理适合写过图像分类但没碰过医疗影像的工程师也适合想从通用分类过渡到医学图像任务的算法同学。整套方案围绕一条主线展开皮肤病变分类的错判代价不对称漏掉黑色素瘤和把痣误判成恶性成本完全不同。后续的损失函数、评估指标和阈值设定本质上都在管理这种不对称性。2. 选型与数据皮肤病变检测的模型基线和数据预处理2.1 从 ImageNet 预训练迁移比从零开始训练更稳皮肤病变数据集的规模通常在一万张左右HAM10000 约一万张皮肤镜图像单类样本少的只有几百张。数据量决定了训练策略随机初始化一套深层卷积网络硬训不仅收敛慢验证集波动也会非常大。常见做法是直接把 ImageNet 预训练的卷积骨干拿来做迁移学习只把最后的分类头替换成目标类别数。这个选择的依据有两个层次。第一预训练模型前几层学到的是边缘、纹理和色彩分布这些底层视觉结构和皮肤镜图像是共享的深层语义虽然和自然物体绑定较紧但可以通过分层学习率、冻结浅层微调深层来缓解。第二模型容量不是越大越好。皮肤镜原图分辨率通常 600×600 起步显存限制下训练输入一般缩到 224 或 288。EfficientNet-B3 在这个尺度下比 ResNet50 参数更少、精度持平或更好是我在这类任务上的默认基线。如果想压推理延迟再对比 MobileNetV3-LargeResNet50 留着当对照用来判断新模型究竟带来多少提升。2.2 数据组织方式用 CSV 清单而不是按病种建目录公开皮肤镜数据集的常见形态是一份 CSV 元数据加一个图像目录而不是按病种分好的文件夹。我一般先把 CSV 读进来将 image_id、patient_id、label 整理成一张清单再统一映射成整数索引。按病种建目录的做法在实验阶段很不方便按病人分组、按病灶属性过滤都要重新移动文件容易产生脏数据。下面这段代码处理三件事定义类别映射、按病人划分训练验证集、构造类别均衡采样器。# dataset_skin.py import pandas as pd import numpy as np from torch.utils.data import Dataset, WeightedRandomSampler from PIL import Image SKIN_CLASSES [nv, mel, bcc, akiec, bkl, df, vasc] df pd.read_csv(metadata.csv) df[label_idx] df[label].map({name: i for i, name in enumerate(SKIN_CLASSES)}) # 按病人划分同一个病人的多张照片只能进入同一个集合 patients df[patient_id].unique() rng np.random.default_rng(42) train_patients set(rng.choice(patients, sizeint(len(patients) * 0.8), replaceFalse)) train_df df[df[patient_id].isin(train_patients)].reset_index(dropTrue) val_df df[~df[patient_id].isin(train_patients)].reset_index(dropTrue) # 类别不均衡用逆频率权重让低频类别更容易被采到 counts train_df[label].value_counts() sample_weights (1.0 / counts[train_df[label]]).to_numpy() train_sampler WeightedRandomSampler(sample_weights, num_sampleslen(train_df), replacementTrue) class SkinDataset(Dataset): def __init__(self, df, img_dir, transformNone): self.df df self.img_dir img_dir self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img Image.open(f{self.img_dir}/{row[image_id]}.jpg).convert(RGB) if self.transform: img self.transform(img) return img, int(row[label_idx])rng.choice固定随机种子后人群划分结果可复现replaceFalse保证同一个病人只进一个集合。label_idx的顺序一旦确定训练和评估阶段都要沿用同一个SKIN_CLASSES列表否则混淆矩阵和图谱解读会整体错位。WeightedRandomSampler的权重是每个样本的采样概率低频类别的样本天然获得更高被选概率相比直接复制低频样本这种方式不会改变验证集分布。2.3 数据增强与归一化的参数清单皮肤镜图像对颜色变化很敏感增强幅度必须克制。旋转、翻转、轻微裁剪可用但 ColorJitter 的 brightness 和 contrast 超过 0.3就很容易改变痣的颜色语义让验证 AUC 明显下滑。下面这组参数是我在皮肤病变分类任务上的默认起点。增强项参数作用与风险随机水平翻转p0.5皮肤镜图像左右对称性成立成本最低随机旋转25°模拟拍摄角度差异角度过大产生黑边伪影随机裁剪缩放0.85~1.0模拟尺度扰动裁剪过小会丢掉病灶主体ColorJitterbrightness0.15, contrast0.15幅度超过 0.3 会改变颜色语义归一化mean(0.485,0.456,0.406), std(0.229,0.224,0.225)沿用 ImageNet 统计量足够重算收益有限训练和验证的 transforms 必须分开验证阶段只做 Resize、中心裁剪和归一化不能带入随机翻转。另一个高频坑是皮肤镜图像四周的黑色背景和标尺随机裁剪一旦把标尺裁进视野模型会学到标尺特征。我一般先对图像做一次 0.9 中心裁剪去掉边缘伪影再进入常规增强流程对黑边明显的图用阈值掩膜统一背景为黑色。这类预处理在不少实验中能直接带来 12 个点的提升。3. 用 CNN 训练皮肤病变分类最小可复现的 PyTorch 流程3.1 训练主循环代码下面这段用纯 PyTorch 写训练循环不依赖重量级深度学习框架方便看清每一步在做什么也方便改造成自己的工程结构。默认用 EfficientNet-B3 加 ImageNet 预训练权重。import torch import torch.nn as nn from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR from torchvision import models def build_model(num_classes7): weights models.EfficientNet_B3_Weights.IMAGENET1K_V1 model models.efficientnet_b3(weightsweights) in_features model.classifier[1].in_features model.classifier[1] nn.Linear(in_features, num_classes) return model model build_model().cuda() criterion nn.CrossEntropyLoss(label_smoothing0.1) optimizer AdamW(model.parameters(), lr3e-4, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max30) scaler torch.cuda.amp.GradScaler() for epoch in range(30): model.train() total_loss 0.0 for images, labels in train_loader: images, labels images.cuda(), labels.cuda() optimizer.zero_grad() with torch.cuda.amp.autocast(): logits model(images) loss criterion(logits, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() total_loss loss.item() * images.size(0) scheduler.step() print(fepoch {epoch:02d} loss {total_loss / len(train_loader.dataset):.4f})EfficientNet_B3_Weights.IMAGENET1K_V1是 torchvision 的预训练权重入口加载时自动下载不需要手动处理。classifier[1]是 EfficientNet 末尾的线性分类层替换成 7 类输出即可。label_smoothing0.1让标签不再是非 0 即 1对皮肤病变这类类间相似度高的任务能减少过拟合。CosineAnnealingLR在 30 个 epoch 内把学习率从初始值余弦下降到接近 0前中期下降平缓非常契合预训练模型的微调节奏。提示混合精度训练需要 PyTorch 1.6 以上版本显存不紧张时直接去掉autocast和GradScaler也不影响收敛只是速度会慢一些。3.2 损失函数与优化器的取舍分类任务默认用交叉熵但在皮肤病变检测场景下我会额外评估两个变体带类别权重的交叉熵和 Focal Loss。类别权重交叉熵实现直接权重用逆频率或对数逆频率效果稳定是我在类别不均衡数据上的首选。Focal Loss 的思路是让模型把梯度重心放到困难样本上对已经学得好的高频类降低贡献天然适配长尾分布。但它在原始交叉熵前多了一个调制系数引入了alpha和gamma两个超参数调参成本高如果又叠加WeightedRandomSampler低频类别容易被重复惩罚训练早期会出现 loss 震荡。优化器方面微调场景我通常用 AdamW。它对学习率不敏感不容易出现梯度爆炸配余弦退火几乎不踩坑。SGD 加 momentum 在充分调优后精度可能更高但需要手工安排学习率下降策略第一次跑通流程时不值得投入。下面这组参数是我在单卡 24G 显存上的常用起点。参数初值说明learning_rate3e-4AdamW 微调默认起点换大模型可降到 1e-4batch_size32EfficientNet-B3 224 输入显存余量足可提到 64weight_decay1e-4防止微调过拟合超过 1e-3 会让特征快速退化label_smoothing0.10.050.15 都可过大导致欠拟合train_epochs30配合早停观察验证损失不再下降即停input_size224 或 288显存够时用 288皮肤镜细粒度纹理更清晰3.3 训练过程的监控与早停训练损失下降不代表验证指标在改善。皮肤病变数据集类别严重不均衡训练 loss 一路向下验证 AUC 不动甚至下滑是常态。我一般在每个 epoch 结束同时保存验证损失最小和验证 AUC 最高的两个权重最后对比哪个更适合当前业务目标。早停条件设置为连续 8 个 epoch 验证损失没有下降触发后把学习率降到当前值的十分之一用相同数据再训五六轮往往能再压出一点提升。验证集必须按病人划分这个约束要前置到数据处理阶段而不是训练阶段。同一病人的多张照片如果同时出现在训练集和验证集模型容易靠记忆拍摄背景而得分验证指标看起来很高换一批真实病人立刻垮掉。按patient_id分组划分会增加一点代码量但它是医疗图像评估可信度的底线。4. 评估、可解释性与部署推理的关键步骤4.1 评估指标不能只看 accuracy要看 AUC、敏感度与特异度在类别严重不均衡的皮肤病变数据上accuracy 是个误导性指标。假设 92% 的样本都是痣模型把一切输入都预测成痣accuracy 也能到 0.92这在临床上毫无价值。评估时至少要看三个维度macro AUC、重点类别的敏感度/特异度、混淆矩阵。AUC 不依赖阈值适合模型之间对比敏感度和特异度依赖阈值决定线上系统实际会放走多少病例。from sklearn.metrics import roc_auc_score, confusion_matrix, accuracy_score import numpy as np import torch def evaluate_model(model, loader, devicecuda): model.eval() all_labels, all_probs [], [] with torch.no_grad(): for images, labels in loader: images images.to(device) logits model(images) probs torch.softmax(logits, dim1) all_labels.append(labels.numpy()) all_probs.append(probs.cpu().numpy()) y_true np.concatenate(all_labels) y_probs np.concatenate(all_probs) auc_macro roc_auc_score(y_true, y_probs, multi_classovr, averagemacro) y_pred y_probs.argmax(axis1) print(macro AUC:, round(auc_macro, 4)) print(accuracy:, round(accuracy_score(y_true, y_pred), 4)) print(confusion_matrix(y_true, y_pred)) return y_true, y_probsmulti_classovr对每个类别做 one-vs-rest 的 AUC 计算再取平均不依赖类别分布适合多分类不均衡场景。如果想看黑色素瘤单类别区分能力取y_probs[:, mel_idx]和对应的二值标签去算roc_auc_score即可。混淆矩阵最好按行归一化打印行是真实类别列是预测类别能直观暴露哪些类别互相混淆。皮肤病变数据里脂溢性角化病和痣常年互相误判这是数据本身的拓扑问题调阈值也只能部分改善。4.2 用 Grad-CAM 定位模型关注的区域分类模型给出概率后临床使用者一定会问模型依据图像的哪块区域做判断。我常用 Grad-CAM 生成热力图取最后一个卷积层的梯度做全局平均池化得到每个通道的权重再对特征图加权求和归一化后叠加到原图上。EfficientNet 的最后一个卷积层是model.features常规 hook 代码可以处理。def grad_cam(model, images, target_classNone): activations, gradients {}, {} def forward_hook(module, input, output): activations[feat] output.detach() def backward_hook(module, grad_input, grad_output): gradients[feat] grad_output[0].detach() layer model.features fh layer.register_forward_hook(forward_hook) bh layer.register_full_backward_hook(backward_hook) model.eval() logits model(images) if target_class is None: target_class logits.argmax(dim1) model.zero_grad() one_hot torch.zeros_like(logits) one_hot[0, target_class] 1.0 logits.backward(gradientone_hot) fh.remove() bh.remove() weights gradients[feat].mean(dim(2, 3), keepdimTrue) cam torch.relu(activations[feat] * weights).sum(dim1, keepdimTrue) cam torch.nn.functional.interpolate(cam, sizeimages.shape[-2:], modebilinear, align_cornersFalse) cam cam.squeeze().cpu().numpy() cam (cam - cam.min()) / (cam.max() - cam.min() 1e-6) return camregister_full_backward_hook拿到的是梯度输出relu截断负贡献区域只显示对预测起正向作用的像素。拿到热力图后我会做一次简单的区域校验把热区中心和病灶标注框做重叠度计算如果模型关注区域和病灶主体明显错位说明模型学到的是标尺、毛发或者拍摄背景需要回看数据增强和预处理。这一步不增加训练成本但能迅速筛掉一大批伪影驱动的假模型。4.3 导出 ONNX 并在服务端推理验证通过的模型要上线通常会从 PyTorch 权重转成 ONNX。.pth文件和服务端推理框架的耦合成本高ONNX 可以同时覆盖 CPU 和 GPU后续还能继续转 TensorRT。import torch.onnx model.eval() dummy torch.randn(1, 3, 224, 224).cuda() torch.onnx.export( model, dummy, skin.onnx, input_names[input], output_names[logits], dynamic_axes{input: {0: batch}, logits: {0: batch}}, opset_version17 )dynamic_axes把 batch 维度标成动态线上服务可以单张推理也能合并请求批量推理。导出后推荐用 ONNX Runtime 验证数值一致性。import onnxruntime as ort sess ort.InferenceSession(skin.onnx, providers[CUDAExecutionProvider, CPUExecutionProvider]) onnx_logits sess.run(None, {input: batch_numpy})[0]对比onnx_logits和 PyTorch 输出的 logits误差在 1e-4 量级以内算正常。如果差异明显优先检查模型里是否存在自定义算子或动态 shape 相关逻辑这类问题在导出阶段不报错推理阶段才会暴露。5. 上线前的高价值技巧置信度校准与低质量图像拒识5.1 温度缩放让概率值更可信深度学习的 softmax 概率经常过度自信验证集上 AUC 很高可模型输出 0.95 的样本实际正确率可能只有 0.7。皮肤筛查系统按概率排序时过度自信会把一大批假阳性排到最前面干扰人工复核队列。温度缩放是成本最低的解决方案只在验证集上拟合一个标量温度 T把 logits 除以 T 后再做 softmax不改变 AUC只提升概率校准度。import numpy as np from scipy.optimize import minimize_scalar def compute_temperature(val_logits, val_labels): # 用验证集拟合温度 T目标是让 softmax 概率更贴近真实正确率 def nll(T): p np.exp(val_logits / T) p / p.sum(axis1, keepdimsTrue) eps 1e-12 return -np.log(p[np.arange(len(val_labels)), val_labels] eps).mean() res minimize_scalar(nll, bounds(0.5, 5.0), methodbounded) return res.xT 大于 1 时概率分布变平滑小于 1 时更尖锐。拟合只能在验证集上进行训练集拟合出的 T 会再次过拟合。拟合结果是一个浮点数部署时在 ONNX 模型前加一层除法或者在预处理服务里对 logits 手动处理不影响推理吞吐。我会把这个 T 值和模型版本一起记录方便后续排查线上阈值迁移问题。5.2 模糊图像拒识别把低质量输入挡在模型之前真实接入的图片不全是标准皮肤镜照片手机拍摄常出现模糊、过曝、阴影遮挡。与其让分类器硬着头皮预测不如在进入模型前加一个质量闸门。一个简单可靠的实现是计算拉普拉斯算子的方差方差低于预设阈值就判定为模糊直接返回“图像质量不足”提示。import cv2 def is_blurry(image_rgb, threshold60.0): gray cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY) variance cv2.Laplacian(gray, cv2.CV_64F).var() return variance threshold阈值需要根据真实输入渠道重新标定皮肤镜图像的边缘信息通常比手机照片丰富阈值可以适当提高。质量闸门和温度缩放后的概率阈值叠加形成“质量闸门 → 概率排序 → 人工复核”的完整链路。这个技巧不改变模型本身也不影响 AUC但能显著减少线上争议病例灰度期间先把模糊拒识阈值调激进一点观察误拒率再逐步放宽是我在新场景落地的默认顺序。本文还有配套的精品资源点击获取