简介:这份资源是面向医学图像处理与深度学习入门者的23类皮肤病分类数据集,适合用于图像分类模型训练、迁移学习实验及课程设计。数据按文件夹组织,可直接用ImageFolder加载,无需额外预处理,也可作为YOLOv5分类任务的数据源。压缩包共约2000个文件,以1998张jpeg图像为主,另附1个py可视化脚本和1个json类别字典,整体约933.7MB;其中train训练集15557张、test测试集4002张,覆盖湿疹、肿瘤、真菌感染等23个类别。可视化脚本随机抽取4张图片即可展示并保存到当前目录,无需修改即可运行,便于快速检查数据分布与图像质量。目前已有531人学习下载,适合希望快速搭建皮肤病分类基线、验证数据加载流程或开展医学图像分类研究的读者参考使用。
1. 23种皮肤病分类数据集:从拿到手到跑通第一个baseline
皮肤科门诊每天产生大量临床照片,但真正能拿来训练模型的公开数据并不多。23种皮肤病分类数据集把训练集和验证集都切好了,直接省掉最耗时的清洗和划分环节。它覆盖的类别从常见的脂溢性角化、基底细胞癌,到相对少见的血管瘤、黑色素瘤等,每类都有对应的临床图像。这个数据集适合三类人:想入门医学图像分类的算法工程师、需要快速验证模型结构的科研人员、以及做皮肤辅助诊断产品原型的团队。训练集和验证集的目录结构已经按类别分好,不用自己写划分脚本,拿到就能喂给DataLoader。但要注意,医学图像和自然图像差别很大,直接套ImageNet的预处理参数大概率会翻车,后面会细说。
2. 数据集结构拆解与加载方案选型
2.1 目录结构与类别分布
常见做法是训练集和验证集各有一个根目录,下面按类别名建子文件夹。比如:
skin_dataset/ ├── train/ │ ├── melanoma/ │ ├── basal_cell_carcinoma/ │ ├── ... │ └── seborrheic_keratosis/ └── val/ ├── melanoma/ ├── basal_cell_carcinoma/ ├── ... └── seborrheic_keratosis/这种结构天然适配torchvision.datasets.ImageFolder和tf.keras.utils.image_dataset_from_directory。但23个类别意味着类别不平衡几乎必然存在——黑色素瘤和基底细胞癌的样本量通常远多于血管瘤。我一般会先跑一遍统计脚本,把每类数量打出来,再决定要不要用WeightedRandomSampler或者Focal Loss。
import os from collections import Counter def count_per_class(root): counts = {} for cls in sorted(os.listdir(root)): cls_dir = os.path.join(root, cls) if os.path.isdir(cls_dir): counts[cls] = len([ f for f in os.listdir(cls_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png')) ]) return counts train_counts = count_per_class('skin_dataset/train') val_counts = count_per_class('skin_dataset/val') print('类别数:', len(train_counts)) print('训练集总数:', sum(train_counts.values())) print('验证集总数:', sum(val_counts.values())) for cls in sorted(train_counts, key=train_counts.get): print(f'{cls:35s} train={train_counts[cls]:5d} val={val_counts.get(cls, 0):5d}')这段脚本遍历训练集和验证集的每个类别文件夹,统计图像文件数量。参数上只认.jpg、.jpeg、.png三种后缀,如果你的数据里有.bmp或.tif,需要自己加进去。输出结果里重点看两个东西:最大类和最小类的比例,以及验证集里有没有某个类样本数为零。如果比例超过10:1,后面训练时就要考虑重采样;如果验证集某类为零,那这个类在验证阶段等于没测。
2.2 加载方式:ImageFolder还是自定义Dataset
ImageFolder最省事,但它要求所有图像都能被PIL正常打开。医学图像里偶尔会有灰度图、CMYK图或者损坏文件,直接跑会在某个batch突然报错。我一般会先写一个快速校验脚本,把打不开的文件列出来,要么修复要么剔除。
from PIL import Image import os def validate_images(root): bad = [] for dirpath, _, filenames in os.walk(root): for fn in filenames: if not fn.lower().endswith(('.jpg', '.jpeg', '.png')): continue fp = os.path.join(dirpath, fn) try: with Image.open(fp) as im: im.verify() except Exception as e: bad.append((fp, str(e))) return bad bad_files = validate_images('skin_dataset') print(f'损坏或无法打开的文件数: {len(bad_files)}') for fp, err in bad_files[:20]: print(fp, '->', err)im.verify()只检查文件头,不真正解码像素,速度快。发现坏文件后,常见处理是直接删掉或者用OpenCV重新保存一遍。如果坏文件占比超过1%,建议查一下数据来源,可能是传输过程中损坏。
如果后续要做更强的数据增强(比如同时变换图像和分割掩码),那就得写自定义Dataset。但对纯分类任务,ImageFolder加transforms足够。选型理由很简单:23类分类不是检测或分割,不需要读标注文件,ImageFolder的目录即标签机制正好匹配。
2.3 训练集与验证集的划分逻辑
这个数据集已经给了训练集和验证集,但你要确认一件事:验证集是不是从训练集里随机切出来的,还是按患者ID切的。如果是按图像随机切,同一个患者的不同角度照片可能同时出现在训练和验证里,导致验证指标虚高。医学图像里这叫患者级泄漏,是血泪教训级别的坑。
检查方法:看文件名里有没有患者ID前缀。如果有,按患者ID重新划分;如果没有,至少确认验证集里没有和训练集完全相同的图像(可以用感知哈希查重)。
import hashlib from PIL import Image import os def file_md5(fp): with open(fp, 'rb') as f: return hashlib.md5(f.read()).hexdigest() train_hashes = {} for dirpath, _, filenames in os.walk('skin_dataset/train'): for fn in filenames: if fn.lower().endswith(('.jpg', '.jpeg', '.png')): fp = os.path.join(dirpath, fn) train_hashes[file_md5(fp)] = fp dup = 0 for dirpath, _, filenames in os.walk('skin_dataset/val'): for fn in filenames: if fn.lower().endswith(('.jpg', '.jpeg', '.png')): fp = os.path.join(dirpath, fn) if file_md5(fp) in train_hashes: dup += 1 print('重复:', fp, '<->', train_hashes[file_md5(fp)]) print(f'验证集中与训练集完全相同的图像数: {dup}')MD5只能查完全相同的文件。如果图像经过缩放或压缩,MD5会变,但内容几乎一样。更严格的做法是用pHash,但MD5作为第一道筛查已经能拦住大部分低级错误。
3. 用PyTorch跑通23类皮肤病分类baseline
3.1 数据增强与预处理参数怎么定
医学图像的色彩分布和自然图像差异很大。ImageNet的均值方差是[0.485, 0.456, 0.406]和[0.229, 0.224, 0.225],直接拿来用不是不行,但归一化后的数值范围会偏。我一般会先算一下自己数据集的均值和方差,再决定用哪套参数。
import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader train_tf = transforms.Compose([ transforms.Resize((300, 300)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomVerticalFlip(p=0.3), transforms.RandomRotation(degrees=15), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1, hue=0.05), 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]), ]) train_ds = datasets.ImageFolder('skin_dataset/train', transform=train_tf) val_ds = datasets.ImageFolder('skin_dataset/val', transform=val_tf) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4, pin_memory=True) print('类别到索引:', train_ds.class_to_idx)参数说明:Resize((300, 300))是折中值,皮肤病图像里有些病灶很小,224可能丢细节,384又太吃显存。RandomHorizontalFlip和RandomVerticalFlip对皮肤镜图像是安全的,因为病灶没有固定方向。RandomRotation(15)模拟拍摄角度变化。ColorJitter的幅度要克制,医学图像的颜色本身有诊断意义,饱和度调太狠可能把关键特征改掉。Normalize先用ImageNet参数,如果训练loss震荡厉害,再换成自己算的。
3.2 模型选择:ResNet50还是EfficientNet
23类分类不算特别多,但类间差异可能很细(比如不同亚型的黑色素瘤)。ResNet50是稳妥起点,EfficientNet-B3在同等精度下参数量更少。我一般先用ResNet50跑通,确认流程没问题,再换EfficientNet对比。
import torch.nn as nn from torchvision import models def build_model(num_classes=23, arch='resnet50', pretrained=True): if arch == 'resnet50': model = models.resnet50(weights=models.ResNet50_Weights.DEFAULT if pretrained else None) in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, num_classes) ) elif arch == 'efficientnet_b3': model = models.efficientnet_b3(weights=models.EfficientNet_B3_Weights.DEFAULT if pretrained else None) in_features = model.classifier[1].in_features model.classifier = nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, num_classes) ) return model device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = build_model(num_classes=23, arch='resnet50').to(device)关键改动是把原始分类头换成Dropout + Linear。Dropout率0.3是经验值,如果训练集很小(每类不到200张),可以加到0.5。pretrained=True加载ImageNet权重,医学图像上微调通常比从头训练收敛快得多,除非你的数据量超过10万张。
3.3 训练循环与类别不平衡处理
类别不平衡时,CrossEntropyLoss的weight参数是最简单的补救。权重按类别频率的倒数算,再归一化。
import numpy as np from collections import Counter train_targets = [s[1] for s in train_ds.samples] class_counts = Counter(train_targets) num_classes = len(train_ds.classes) weights = torch.tensor( [1.0 / class_counts[i] for i in range(num_classes)], dtype=torch.float32 ) weights = weights / weights.sum() * num_classes weights = weights.to(device) criterion = nn.CrossEntropyLoss(weight=weights) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total = 0.0, 0, 0 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * imgs.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total @torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0.0, 0, 0 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) outputs = model(imgs) loss = criterion(outputs, labels) total_loss += loss.item() * imgs.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total for epoch in range(30): tr_loss, tr_acc = train_one_epoch(model, train_loader, criterion, optimizer, device) va_loss, va_acc = evaluate(model, val_loader, criterion, device) scheduler.step() print(f'Epoch {epoch+1:02d} | train loss {tr_loss:.4f} acc {tr_acc:.4f} | val loss {va_loss:.4f} acc {va_acc:.4f}')权重计算逻辑:先取每类样本数的倒数,再乘以类别数做归一化,这样权重均值约为1,不会把loss尺度整体放大。AdamW的lr=1e-4适合微调,weight_decay=1e-4抑制过拟合。CosineAnnealingLR的T_max设成总epoch数,让学习率平滑降到接近零。训练时重点看验证loss有没有在后期反弹,如果反弹明显,说明过拟合,要么加数据增强,要么早停。
4. 皮肤病分类训练避坑与排查清单
4.1 验证集准确率虚高但实际推理一塌糊涂
现象:训练时验证集准确率能到0.9以上,但拿几张新图片测试,预测结果完全不对。
原因:最常见的是患者级泄漏——同一患者的图像同时出现在训练和验证集。其次是验证集做了和训练集相同的数据增强,尤其是随机翻转和旋转,导致验证指标被“增强”了。
解决:先查文件名里有没有患者ID,按ID重新划分。验证集的transform只保留Resize和Normalize,不要加任何随机变换。如果数据来源允许,最好留出一个独立的测试集,从头到尾不参与任何训练和调参。
4.2 某些类别永远预测不对
现象:混淆矩阵里某几个类互相混淆严重,比如基底细胞癌和脂溢性角化总是分错。
原因:这两类在临床上本来就容易混,图像特征重叠度高。另外如果这两类样本量都很少,模型学不到足够判别信息。
解决:先看这两类的样本数,如果都少于100,考虑用数据增强扩充或者用Focal Loss降低易分类样本的权重。如果样本量够但依然混淆,可以引入类别间的层次结构,或者用度量学习(比如Triplet Loss)拉大类间距离。实际项目中,我一般会单独把混淆严重的类拎出来,训练一个二分类器做二次判别。
4.3 训练loss震荡不收敛
现象:loss曲线上下跳动,验证准确率长时间卡在随机水平附近。
原因:学习率太大、batch size太小、或者归一化参数不匹配。医学图像如果用了ImageNet的归一化参数,但实际像素分布差异大,梯度会不稳定。
解决:先把学习率降到1e-5试几个epoch。如果还不行,算一下自己数据集的均值和方差,替换掉ImageNet参数。batch size如果因为显存限制只能设8或16,可以开梯度累积,累积4步等效batch size 32。
4.4 图像读取报“cannot identify image file”
现象:训练到某个batch突然报错,提示PIL无法识别图像文件。
原因:数据集中混入了损坏文件、非图像文件(比如.DS_Store或Thumbs.db),或者图像格式是PIL不支持的(比如某些医学专用的DICOM格式被直接改了后缀)。
解决:跑一遍前面给的validate_images脚本,把坏文件列出来。如果是DICOM改后缀,需要用pydicom读取后转成PNG。如果是系统隐藏文件,在Dataset里过滤掉非图像后缀即可。
4.5 显存溢出但batch size已经很小
现象:batch size降到8还是OOM。
原因:图像分辨率太高,或者模型用了EfficientNet-B7这种大模型。另外如果num_workers设太大,每个worker都会复制一份数据到内存,也可能间接导致问题。
解决:先把图像Resize到256或224。如果还不行,换EfficientNet-B0或ResNet18。检查pin_memory和num_workers,一般num_workers=4、pin_memory=True是安全组合。如果用了混合精度训练,确认torch.cuda.amp的GradScaler用法正确,否则可能因为梯度缩放导致显存异常。
5. 把验证集用出花:混淆矩阵、阈值调优与模型集成
跑通baseline只是第一步。验证集的价值不只是算一个准确率,它能告诉你模型到底在哪些类上翻车。我习惯在每个epoch结束后画混淆矩阵,归一化后看每一行的误判流向。
import matplotlib.pyplot as plt import seaborn as sns from sklearn.metrics import confusion_matrix, classification_report import numpy as np @torch.no_grad() def get_predictions(model, loader, device): model.eval() all_preds, all_labels = [], [] for imgs, labels in loader: imgs = imgs.to(device) outputs = model(imgs) preds = outputs.argmax(1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) return np.array(all_labels), np.array(all_preds) y_true, y_pred = get_predictions(model, val_loader, device) cm = confusion_matrix(y_true, y_pred, normalize='true') plt.figure(figsize=(14, 12)) sns.heatmap(cm, annot=False, cmap='Blues', xticklabels=val_ds.classes, yticklabels=val_ds.classes) plt.xlabel('Predicted') plt.ylabel('True') plt.tight_layout() plt.savefig('confusion_matrix.png', dpi=150) print(classification_report(y_true, y_pred, target_names=val_ds.classes, digits=3))归一化混淆矩阵的对角线是每类召回率,非对角线是误判比例。重点看两类:召回率低于0.6的类,以及被大量误判到某一类的那些。classification_report会给出每类的precision、recall、f1-score,比整体准确率有用得多。
如果发现某些类召回率特别低,可以调整预测阈值。默认是取softmax最大值的索引,但对不平衡数据,可以对少数类降低阈值。具体做法是给每个类一个偏置项,在验证集上网格搜索最优偏置。
from sklearn.metrics import f1_score # 获取softmax概率 @torch.no_grad() def get_probs(model, loader, device): model.eval() all_probs, all_labels = [], [] for imgs, labels in loader: imgs = imgs.to(device) outputs = model(imgs) probs = torch.softmax(outputs, dim=1).cpu().numpy() all_probs.append(probs) all_labels.extend(labels.numpy()) return np.vstack(all_probs), np.array(all_labels) probs, labels = get_probs(model, val_loader, device) num_classes = probs.shape[1] best_f1 = 0 best_bias = np.zeros(num_classes) for _ in range(200): bias = np.random.uniform(-0.3, 0.3, size=num_classes) preds = (probs + bias).argmax(1) f1 = f1_score(labels, preds, average='macro') if f1 > best_f1: best_f1 = f1 best_bias = bias print(f'最佳macro F1: {best_f1:.4f}') print('各类偏置:', np.round(best_bias, 3))这个随机搜索虽然粗糙,但比默认argmax通常能提升1到3个点的macro F1。偏置为正的类相当于降低了预测门槛,偏置为负的类提高了门槛。搜索范围[-0.3, 0.3]是经验值,如果概率分布很集中,可以缩小范围。
模型集成是另一个提分手段。训练3到5个不同初始化的ResNet50或EfficientNet,推理时对softmax概率取平均。集成通常能稳定提升2到5个点,代价是推理时间线性增加。如果部署环境算力有限,可以用知识蒸馏把集成模型压到单模型。
最后说一个我自己的习惯:每次跑完实验,把验证集里预测错误的样本单独存到一个文件夹,按“真实类_预测类”命名。攒够几百张后翻一翻,经常能发现标注错误或者图像质量太差导致的脏数据。这个习惯帮我省过好几次“模型明明没问题但指标就是上不去”的纠结。希望帮到你。
本文还有配套的精品资源,点击获取