简介:这是一套围绕深度学习与皮肤医学影像的检测方案,覆盖从数据预处理到模型部署的全流程,目标用户是医学影像分析学习者和AI辅助诊断入门开发者。项目包含42个文件,压缩包约10.58MB,目录结构清晰:7个Jupyter Notebook分别对应MobileNetV3Small、ResNet152、VGG19及三模型融合的独立训练与对比,数据增强、DullRazor毛发去除等预处理步骤也以Notebook形式呈现;同时配有基于HTML/CSS/JS的分类展示网站,包含Python运行脚本、预测CSV结果、JPEG/PNG图片和MP4演示视频,便于直接理解项目运行逻辑、复现实验或改造成自定义皮肤检测工具。除了模型层面的精确率和损失曲线,还提供了将预测结果可视化的细节,方便查看诊断依据。当前已有159人学习,适合希望快速上手CNN医学图像分类,或需要参考完整项目骨架来扩展更复杂医学图像系统的读者。
1. 把皮肤检测落在深度学习上,先想清楚数据和问题边界
皮肤状况检测是深度学习在医疗影像里最接近“可落地”的方向之一:输入是标准光学照片,输出是分类或分割结果,不依赖昂贵设备。但真正跑过ISIC这类公开数据集的人会告诉你,难点从来不在模型,而在数据规模小、标注噪声大、类别极度不平衡,以及“拍了照但没拍好”带来的成像差异。这篇文章按我平时做医疗影像项目的思路,从任务定义、CNN网络选型、PyTorch训练流程、类别不平衡处理到本地推理部署,完整讲一遍。
我会把“检测皮肤状况”拆成最常用的图像分类任务处理:输入一张皮肤照片,模型输出它属于哪个类别(如色素痣、黑色素瘤、脂溢性角化病等)。如果你有二分类、多分类、后续分割的不同目标,也能在这套框架上直接改。文章默认你已经装了PyTorch,知道最简单的model.train()是什么意思——不知道也没关系,命令和参数我会逐步解释。
2. 用CNN搭建皮肤状况检测的最小框架:问题定义与网络选型
2.1 先确认任务类型:分类、多标签还是分割
皮肤状况检测至少有三种常见设定,选错一个后面全偏。
- 单标签多分类:一张图对应一个主要诊断。ISIC 2018基准的7类病变(黑色素瘤、色素痣、角化病、基底细胞癌等)就是这类。最常用,也是本文重点。
- 多标签分类:一张图同时存在多种问题(例如炎症+色素沉着),需要sigmoid输出多个独立概率。
- 分割:只识别病变轮廓,输出像素级mask,相当于在分类前多了一个定位任务。
做项目前先和数据提供方对齐:到底要“这是什么病”还是要“病灶在哪”。如果只需要前者,别上来就上U-Net。我见过太多团队把预算烧在分割标注上,最后业务只吃得下一个分类结果。
网络选型上,常见做法是先用ResNet-18或EfficientNet-B0做baseline。这两个模型在ImageNet上有充分预训练,在皮肤图像上做迁移学习起步快。为什么不用ViT?皮肤公开数据集一般不超过两万张,Vision Transformer在小数据上预训练优势不明显,而且训练超参更挑剔。等分类baseline跑通、确认数据质量没问题,再考虑升级到Swin Transformer这类更强编码器。
2.2 数据管道怎么写:ImageFolder还是自定义Dataset
先跑通最小demo,torchvision.datasets.ImageFolder就够。目录结构这样组织:
data/ train/ nevus/ # 色素痣 melanoma/ # 黑色素瘤 bcc/ # 基底细胞癌 val/ nevus/ melanoma/ bcc/加载代码:
from torchvision import datasets, transforms # 训练集:轻量增强,ColorJitter幅度不要给太大 train_tf = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop((224, 224), scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.1, contrast=0.1, saturation=0.05), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 验证集:只做resize和归一化,不做随机增强 val_tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_ds = datasets.ImageFolder("data/train", transform=train_tf) val_ds = datasets.ImageFolder("data/val", transform=val_tf)这段代码里三个细节直接决定效果:
归一化用了ImageNet的均值和标准差。预训练模型是在这个分布上学的,迁移时不换。RandomResizedCrop的scale下限设为0.8,是因为皮肤病灶通常占画面面积不小,裁太狠会丢掉关键纹理。ColorJitter的saturation只给0.05,这是皮肤影像特有的坑:颜色是诊断的重要线索(黑色素瘤的“蓝白幕”特征),色调抖动过大等于制造噪声。医学图像增强要克制,不是越强越好。
2.3 最小可训练模型:ResNet-18迁移
import torch.nn as nn from torchvision import models def build_model(num_classes, pretrained=True): model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) # 把最后一层全连接换掉,输出类别数 model.fc = nn.Linear(model.fc.in_features, num_classes) return model model = build_model(num_classes=3)model.fc = nn.Linear(...)这行很关键。ResNet-18的fc层输入维度是512,预训练分类1000类,我们替换成自己的类别数。冻结不冻结骨干?数据量在千级时,建议先保持全网络可训练,只把学习率设低(1e-4量级)。数据量在万级以上、且和ImageNet分布差异大(皮肤镜图和自然图像差很多),照样全量微调,让骨干适应皮肤纹理的底层特征。
小规模数据(每类不足500张)时也不要急着冻结骨干。皮肤图像的低层特征(边缘、颜色块)和自然图像有一定共性但不等价,全量微调配合早停、正则化,通常比冻结骨干好5~10个点。
下表是选型对比,按“先baseline后升级”的思路排列:
| 模型 | 输入尺寸 | 参数量 | 适合阶段 | 备注 |
|---|---|---|---|---|
| ResNet-18 | 224 | 约11M | baseline | 训练快,显存占用小 |
| EfficientNet-B0 | 224 | 约5M | baseline/瓶颈时 | 精度/算力比高 |
| ResNet-50 | 224 | 约25M | 数据量充足 | 比18约高2~3点 |
| Swin-T | 224 | 约28M | 数据1万+ | 训练超参更敏感 |
数据量低于一万张时优先选前两个。很多皮肤数据集就几千张,上大模型只会让过拟合提前到来。
3. PyTorch实战训练:从环境配置到完整训练脚本
3.1 环境配置:conda一键装好训练栈
深度学习环境配置是新手第一天就卡住的地方。网上关于CUDA、cuDNN的教程很多,但踩过一遍后我最推荐这个组合:
conda create -n skin python=3.10 -y conda activate skin # PyTorch官方源,给CUDA 11.8;具体版本以pytorch.org为准 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install matplotlib scikit-learn tensorboard onnxruntime环境配置有个原则:PyTorch和CUDA版本由torch的wheel决定,不要自己单独装CUDA。用--index-url指定cu118,torch会自动拉对应CUDA runtime。装完验证:
python -c "import torch; print(torch.__version__, torch.cuda.is_available())"输出True说明GPU可用。要是False,先检查驱动nvidia-smi能不能看到卡。很多人卡在这里是因为驱动太老,和CUDA 11.8不匹配。如果只是CPU训练,把--index-url的cu118改成cpu版本即可,代码不用改。
3.2 训练脚本:epoch、batch size、学习率怎么配合
训练循环本身不难,难在参数配合。下面这个脚本是我日常会写的基础骨架:
import torch import torch.nn as nn from torch.utils.data import DataLoader from torch.optim import AdamW def train_one_epoch(model, loader, optimizer, criterion, device): model.train() # 训练模式:dropout和BN生效 total_loss, correct, total = 0, 0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() # 梯度裁剪防NaN,皮肤数据噪声大容易梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() _, preds = torch.max(outputs, 1) total_loss += loss.item() * images.size(0) correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total @torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() # 验证模式:BN用统计量,dropout关闭 total_loss, correct, total = 0, 0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) _, preds = torch.max(outputs, 1) total_loss += loss.item() * images.size(0) correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total device = "cuda" if torch.cuda.is_available() else "cpu" model = build_model(num_classes=3).to(device) criterion = nn.CrossEntropyLoss() # AdamW比Adam好调,weight_decay直接防过拟合 optimizer = AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) # CosineAnnealing让学习率平滑下降,比固定lr省心 scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4) best_acc = 0 # epoch:1个epoch=把训练集完整过一遍 for epoch in range(30): train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc = evaluate(model, val_loader, criterion, device) scheduler.step() print(f"Epoch {epoch+1:02d} | train_loss {train_loss:.4f} | train_acc {train_acc:.4f} | val_acc {val_acc:.4f}") # 只看验证集acc,别拿训练集acc自我感动 if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_model.pth")逐行说参数:
epoch设为30。基础设定,后面配合早停。皮肤数据少,通常20~50轮内见分晓,跑100轮大概率过拟合。
batch size 32。显存不够降到16,学习率也同步减半。batch size翻倍,学习率最好翻倍(线性缩放规则),但1e-4这个量级不调整问题也不大。
AdamW + weight_decay。相比常用Adam,AdamW把权值衰减和动量解耦,正则效果更可控。weight_decay=1e-4是通用起始值,过拟合严重时提到5e-4。
CosineAnnealingLR。30轮内学习率从1e-4余弦降到接近0。比固定学习率好在后期自动缩小步长,让loss在小范围波动时能继续往下走到更平坦的区域。
梯度裁剪max_norm=1.0。皮肤图像有的曝光过度、有的带毛发遮挡,个别样本产生大梯度时,裁剪能防止一次更新把整个模型参数推出正常范围。
验证集模型选择只看val_acc,别拿训练集acc当参照——训练集达到99%,验证集70%,是过拟合的典型症状。
3.3 用TensorBoard盯训练曲线
训练时终端只打印数值,等曲线才能看出门道:
from torch.utils.tensorboard import SummaryWriter import torchvision writer = SummaryWriter("runs/skin_experiment") # 每隔几步把模型图和图像batch写进去 writer.add_image("train_batch", torchvision.utils.make_grid(images[:8]), global_step=epoch) writer.add_scalar("loss/train", train_loss, epoch) writer.add_scalar("acc/val", val_acc, epoch) writer.add_histogram("fc.weight", model.fc.weight, epoch)跑完后启动:
tensorboard --logdir runs/skin_experiment浏览器打开localhost:6006。重点看两个现象:
train_loss持续下降但val_loss先降后升——过拟合信号,触发早停。train_acc和val_acc差距始终大于15%——增强不够或模型容量过大,先加增强,不够再换小模型(ResNet-18换EfficientNet-B0)。loss曲线出现突然的尖峰——那多半是batch里有损坏图片或标签错乱,去查数据管道,不是调参问题。
3.4 推理:保存的模型怎么用
训练完的模型,单独写推理脚本,注意不要直接复制train函数里的逻辑:
import torch from torchvision import transforms def predict(image_path, model_path, class_names): device = "cuda" if torch.cuda.is_available() else "cpu" model = build_model(num_classes=len(class_names)) model.load_state_dict(torch.load(model_path, map_location=device)) model.to(device).eval() tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) from PIL import Image img = Image.open(image_path).convert("RGB") # 统一转RGB,别丢alpha通道 x = tf(img).unsqueeze(0).to(device) with torch.no_grad(): prob = torch.softmax(model(x), dim=1)[0] return {name: float(p) for name, p in zip(class_names, prob)} class_names = ["nevus", "melanoma", "bcc"] print(predict("test.jpg", "best_model.pth", class_names))推理时必须model.eval()。这个细节很多人漏:训练时Dropout随机丢弃神经元、BatchNorm用batch统计量,不切eval模式,同一个输入每次输出都不同,部署时直接翻车。调用softmax把logits转成概率,输出类似{'melanoma': 0.62, 'nevus': 0.30, 'bcc': 0.08}。之后阈值怎么定,看第5章。
4. 类别不平衡与过拟合:皮肤数据集的两个硬骨头
4.1 类别不平衡:别让准确率骗了你
皮肤公开数据集极度偏爱常见病。比如某数据集里色素痣有6000张,黑色素瘤只有1000张。模型全猜“色素痣”准确率也有85%,但一个黑色素瘤都没识别出来。这种场景只看accuracy毫无意义,必须盯sensitivity(召回率)和specificity(特异度)。
处理不平衡有两条路,实践中常常一起用:
过采样/欠采样。对少数类重复采样或对多数类降采样。PyTorch自带WeightedRandomSampler:
from torch.utils.data import WeightedRandomSampler import numpy as np labels = [s for _, s in train_ds.samples] # 计算每个类别的权重,样本越多,被采到的概率越低 class_counts = np.bincount(labels) weights = 1.0 / class_counts[labels] sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True) train_loader = DataLoader(train_ds, batch_size=32, sampler=sampler, num_workers=4)weights = 1.0 / class_counts[labels]这一步:每个样本的权重是其类别样本数的倒数,少数类权重高,多数类权重低。num_samples=len(weights)表示每个epoch采样总数和原数据集相同,replacement=True允许重复采样少数类。
调整损失函数。给CrossEntropyLoss的少数类更高的权重:
class_weights = torch.tensor([1.0, 3.0, 5.0]).to(device) # 手动或按统计 criterion = nn.CrossEntropyLoss(weight=class_weights)weight参数直接乘在每个类别的loss上。类别权重怎么定?最简单是按样本数反比:max_count / class_count。权重给太大模型会过度关注少数类、把多数类搞崩,通常不要超过5倍,特殊情况另说。
这组做法在ISIC这类数据上能把黑色素瘤召回率从50%拉到70%以上,代价是色素痣的精度掉几个点。临床筛查场景召回率比精度重要,漏诊代价远高于误报。
4.2 过拟合:皮肤图像太容易被“记住”
皮肤数据集另一个通病是图像和标签之间的伪相关性。ISIC老版本里很多黑色素瘤照片带皮肤镜的黑色边框、有尺子刻度,模型学会了“有尺子就是黑色素瘤”,在外部数据上准确率崩到随机水平。这就是为什么增强和验证集隔离都重要。
第一层防线是增强,但增强要做对方向。皮肤诊断依赖纹理和颜色分布,所以空间翻转安全,颜色变换要小心:
train_tf = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop((224, 224), scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomVerticalFlip(p=0.5), transforms.RandomRotation(degrees=15), # 小角度旋转 transforms.ColorJitter(brightness=0.15, contrast=0.15, saturation=0.05, hue=0.02), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])hue=0.02只能给极小值。色调偏移会直接改变皮肤红斑、黄斑的视觉判断,给0.1模型基本学不到真实颜色分布。degrees=15同样克制,暗含“拍照角度轻微偏差”的物理假设,旋转90度在皮肤影像是没有物理意义的等价的——增强操作要能映射到真实世界的拍照变化。
RandAugment、AutoAugment这类自动增强在自然图像上很强,但对皮肤图像往往过猛,颜色变换过度导致验证集掉点。先用上面这几组保守增强,不要一上来就上重型自动增强。
第二层防线是早停和模型保存。训练中每个epoch记录val_loss,连续10轮不下降就停。
4.3 验证策略:一定要分层抽样
皮肤数据不平衡,随机划分验证集会雪上加霜。用sklearn做分层划分:
from sklearn.model_selection import train_test_split # 按类别比例划分,避免验证集里偶然少了某类 train_paths, val_paths, train_labels, val_labels = train_test_split( paths, labels, test_size=0.2, stratify=labels, random_state=42 )stratify=labels保证训练集和验证集中各类别占比与原始数据一致。如果黑色素瘤只占5%,只分一次验证集容易在验证集里一个都没有。
数据量够且时间充裕,就做5折交叉验证。皮肤公开数据集规模通常能Hold住5折,每折训练一个模型,最后用平均概率做集成预测。注意交叉验证只能在小规模实验里用,全量数据上每一折都训练一次,耗时直接乘以5。数据量上千的实验可以用,上万张就按单次划分+集成已经足够。
4.4 什么情况用Focal Loss
如果WeightedRandomSampler和Class Weight都上了,少数类召回率还是拉不起来,考虑Focal Loss。它的思路是让模型把注意力集中在难分的样本上:
import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, gamma=2.0, alpha=None): super().__init__() self.gamma = gamma self.alpha = alpha # 类别权重,一维tensor def forward(self, logits, targets): ce = F.cross_entropy(logits, targets, weight=self.alpha, reduction="none") p = torch.exp(-ce) # 预测正确的概率 focal = (1 - p) ** self.gamma * ce return focal.mean()gamma=2.0是原论文的默认值。它的作用是:当某个样本已经预测正确(p接近1)时,(1-p)^2接近0,loss被压低;难分样本p小,loss占比上升。少数字类如果本身好分,模型会“不去管它”,这在皮肤数据里不一定是坏事——把余力集中在难区分的早期黑色素瘤上。alpha给少数类再乘一个权重,两个机制叠加。
Focal Loss不是无脑换。当模型已经欠拟合、连训练集都分不开时,先修数据管道,别用Focal Loss。它是解决难例挖掘问题的,不是解决模型容量不足的。
5. 从准确率到可用的检测:解释输出与本地部署
5.1 加一层Grad-CAM热力图,别让模型变成黑盒
模型在验证集上达到90%准确率,不等于医生愿意用它。临床使用者第一反应是“凭什么信你”。给模型加可解释性是常规做法,Grad-CAM生成热力图让使用方看到模型关注图像的哪个区域。
from torchvision.models import resnet18 import torch.nn.functional as F model = resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) # 把最后一层替换成你自己的分类头 num_classes = 3 model.fc = torch.nn.Linear(model.fc.in_features, num_classes) model.load_state_dict(torch.load("best_model.pth")) model.eval() # 注册钩子,抓取最后一个卷积层的输出 activations = {} def hook_fn(module, input, output): activations["value"] = output model.layer4.register_forward_hook(hook_fn) img = ... # 预处理后的图像,shape=[1,3,224,224] out = model(img) # 取预测类别的得分 class_idx = out.argmax(dim=1).item() score = out[0, class_idx] model.zero_grad() score.backward() # 取梯度的均值作为每个通道的权重 grads = model.layer4.weight.grad # 不直接取,用hooks取layer4输出对应梯度对layer4输出的激活值做全局平均池化得到权重,加权求和后经过ReLU,再上采样到输入尺寸叠加在原图上,就是一个标准热力图。主要看两类结果:
热力图集中在病灶本体——正常,模型学到了病变纹理。热力图集中在图像角落、尺子、头发丝——模型学到了伪相关性,需要检查数据标注和图像预处理有没有泄漏。如果热力图在肤色均匀区域高亮,说明模型可能在靠整体颜色做判断,这对黑色素瘤检测不够。
常见做法是先把热力图叠到原始图上做成可视化,再攒一批错误case逐张看,比任何指标都管用。
5.2 导出ONNX:脱离PyTorch也能跑推理
模型最终要进服务端或客户端,纯PyTorch部署依赖torch环境,太重。导出ONNX是常规路线:
import torch from torchvision import models model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = torch.nn.Linear(model.fc.in_features, 3) model.load_state_dict(torch.load("best_model.pth")) model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "skin_model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, opset_version=17 )dynamic_axes允许推理时batch size不固定,服务端并发请求时有用。opset_version别追新,17是稳妥选择。导出后用onnxruntime验证一下输出一致性:
python -c "import onnxruntime as ort; s=ort.InferenceSession('skin_model.onnx'); print(s.run(None, {'input': __import__('numpy').random.randn(1,3,224,224).astype('float32')}))"ONNX推理代码和PyTorch版几乎一样,但不需要torch环境,部署体积小一个数量级。要注意预处理必须和训练时完全一致:同一个Resize((224, 224))实现细节、同一组mean/std。很多坑不在模型,在预处理不一致——训练时用RandomResizedCrop,推理时变成直接resize,分布就偏了。
5.3 模型上线后的三个后续动作
模型部署不是终点。上线后还要:
记录预处理参数到工程文档。图像尺寸、归一化取值、通道顺序(RGB还是BGR),全部写死在配置里,后端换人也不出错。
做好输入图像的成像校验。分辨率过低、过暗、过曝的照片提前打回。皮肤的拍照条件直接影响准确率,模型训练时见过的是标准条件照片,到了实际采集链路若照片质量参差,准确率会先崩。
在真实数据上持续评估。ISIC等公开数据集和真实场景的分布有差异,本地调好的阈值上线后通常要重新校准。把线上预测结果按周抽样回顾,比在实验室里继续刷准确率有意义。
皮肤状况检测这个项目,模型只占一半工作量,另一半是数据治理、可解释性和部署一致性。把第4章说到的类别不平衡问题解决好、把本章的ONNX导出和热力图机制接入工程链路,这个项目才能从“demo能跑”变成“业务真在用”。
本文还有配套的精品资源,点击获取