资讯动态

PyTorch深度学习皮肤检测实战:CNN训练、迁移学习与ONNX部署

发布时间:2026/9/17 22:22:23 来源:尧图企业网站定制
简介这是一套围绕深度学习与皮肤医学影像的检测方案覆盖从数据预处理到模型部署的全流程目标用户是医学影像分析学习者和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先跑通最小demotorchvision.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(brightness0.1, contrast0.1, saturation0.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, transformtrain_tf) val_ds datasets.ImageFolder(data/val, transformval_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, pretrainedTrue): model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 把最后一层全连接换掉输出类别数 model.fc nn.Linear(model.fc.in_features, num_classes) return model model build_model(num_classes3)model.fc nn.Linear(...)这行很关键。ResNet-18的fc层输入维度是512预训练分类1000类我们替换成自己的类别数。冻结不冻结骨干数据量在千级时建议先保持全网络可训练只把学习率设低1e-4量级。数据量在万级以上、且和ImageNet分布差异大皮肤镜图和自然图像差很多照样全量微调让骨干适应皮肤纹理的底层特征。小规模数据每类不足500张时也不要急着冻结骨干。皮肤图像的低层特征边缘、颜色块和自然图像有一定共性但不等价全量微调配合早停、正则化通常比冻结骨干好5~10个点。下表是选型对比按“先baseline后升级”的思路排列模型输入尺寸参数量适合阶段备注ResNet-18224约11Mbaseline训练快显存占用小EfficientNet-B0224约5Mbaseline/瓶颈时精度/算力比高ResNet-50224约25M数据量充足比18约高2~3点Swin-T224约28M数据1万训练超参更敏感数据量低于一万张时优先选前两个。很多皮肤数据集就几千张上大模型只会让过拟合提前到来。3. PyTorch实战训练从环境配置到完整训练脚本3.1 环境配置conda一键装好训练栈深度学习环境配置是新手第一天就卡住的地方。网上关于CUDA、cuDNN的教程很多但踩过一遍后我最推荐这个组合conda create -n skin python3.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指定cu118torch会自动拉对应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_norm1.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_classes3).to(device) criterion nn.CrossEntropyLoss() # AdamW比Adam好调weight_decay直接防过拟合 optimizer AdamW(model.parameters(), lr1e-4, weight_decay1e-4) # CosineAnnealing让学习率平滑下降比固定lr省心 scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers4) best_acc 0 # epoch1个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(fEpoch {epoch1: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。相比常用AdamAdamW把权值衰减和动量解耦正则效果更可控。weight_decay1e-4是通用起始值过拟合严重时提到5e-4。CosineAnnealingLR。30轮内学习率从1e-4余弦降到接近0。比固定学习率好在后期自动缩小步长让loss在小范围波动时能继续往下走到更平坦的区域。梯度裁剪max_norm1.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_stepepoch) 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_classeslen(class_names)) model.load_state_dict(torch.load(model_path, map_locationdevice)) 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), dim1)[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自带WeightedRandomSamplerfrom 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_sampleslen(weights), replacementTrue) train_loader DataLoader(train_ds, batch_size32, samplersampler, num_workers4)weights 1.0 / class_counts[labels]这一步每个样本的权重是其类别样本数的倒数少数类权重高多数类权重低。num_sampleslen(weights)表示每个epoch采样总数和原数据集相同replacementTrue允许重复采样少数类。调整损失函数。给CrossEntropyLoss的少数类更高的权重class_weights torch.tensor([1.0, 3.0, 5.0]).to(device) # 手动或按统计 criterion nn.CrossEntropyLoss(weightclass_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(p0.5), transforms.RandomVerticalFlip(p0.5), transforms.RandomRotation(degrees15), # 小角度旋转 transforms.ColorJitter(brightness0.15, contrast0.15, saturation0.05, hue0.02), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])hue0.02只能给极小值。色调偏移会直接改变皮肤红斑、黄斑的视觉判断给0.1模型基本学不到真实颜色分布。degrees15同样克制暗含“拍照角度轻微偏差”的物理假设旋转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_size0.2, stratifylabels, random_state42 )stratifylabels保证训练集和验证集中各类别占比与原始数据一致。如果黑色素瘤只占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, gamma2.0, alphaNone): super().__init__() self.gamma gamma self.alpha alpha # 类别权重一维tensor def forward(self, logits, targets): ce F.cross_entropy(logits, targets, weightself.alpha, reductionnone) p torch.exp(-ce) # 预测正确的概率 focal (1 - p) ** self.gamma * ce return focal.mean()gamma2.0是原论文的默认值。它的作用是当某个样本已经预测正确p接近1时(1-p)^2接近0loss被压低难分样本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(weightsmodels.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(dim1).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(weightsmodels.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_version17 )dynamic_axes允许推理时batch size不固定服务端并发请求时有用。opset_version别追新17是稳妥选择。导出后用onnxruntime验证一下输出一致性python -c import onnxruntime as ort; sort.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能跑”变成“业务真在用”。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价