1. 项目概述:从“猴痘”到“识别”,一个AI医疗影像项目的诞生
最近在整理过往项目资料时,翻到了一个挺有意思的实战项目,代号“第P4周:猴痘病识别”。这名字听起来有点神秘,其实就是我们团队当时为了验证一个快速原型开发流程,用深度学习技术做的一个皮肤病变图像分类器,核心目标是区分猴痘(Monkeypox)与其他几种常见的、外观相似的皮肤病症。当时市面上公开的、标注好的猴痘图像数据集还非常稀缺,这个项目从数据搜集、清洗、模型选型到部署测试,完整走了一遍,踩了不少坑,也积累了一些在资源受限条件下做医疗AI项目的实用经验。今天就跟大家详细拆解一下这个项目的来龙去脉、技术选型的思考,以及那些教科书里不会写的实操细节。
猴痘作为一种病毒性人畜共患病,其皮肤表现(如皮疹、水疱、脓疱)与天花、水痘、甚至某些细菌感染或过敏反应有相似之处。对于非皮肤科专科医生,尤其在基层医疗单位或疫情初期,快速、准确的初步识别存在挑战。我们这个项目的初衷,并非要替代专业医生,而是探索能否利用计算机视觉技术,构建一个辅助筛查工具,帮助医护人员在大量疑似病例中快速进行初筛和分流,提升工作效率,并为后续的实验室确诊争取时间。项目适合对深度学习、计算机视觉,特别是医疗AI应用感兴趣的开发者、研究人员,以及希望了解一个完整AI项目从0到1落地过程的朋友。下面,我就从项目设计思路开始,一步步还原我们当时是怎么做的。
2. 项目整体设计与核心思路拆解
2.1 核心需求与问题定义
接到这个任务时,首要任务是明确我们要解决的具体问题。泛泛而谈“识别猴痘”是不行的。经过与医学顾问的初步沟通,我们将问题精确定义为一个多类皮肤病变图像分类任务。具体来说,我们计划区分以下四类:
- 猴痘 (Monkeypox): 目标类别。
- 水痘 (Chickenpox): 同样表现为水疱,是重要的鉴别诊断对象。
- 麻疹 (Measles): 斑丘疹,有时可能与猴痘早期皮疹混淆。
- 正常皮肤或其他 (Normal/Others): 作为一个“兜底”类别,包含健康皮肤或其他不相关的皮肤状况,以提高模型的鲁棒性。
选择这四类,是基于医学上的鉴别诊断需求和数据可获得性的平衡。天花虽然最相似,但已绝迹,数据极少;而水痘和麻疹是常见病,有相对丰富的公开图像数据。定义“其他”类别至关重要,可以防止模型对任何输入都强行归入前三类,减少误报。
注意:在医疗AI项目中,问题定义的准确性直接决定了项目的价值和天花板。必须与领域专家(Domain Expert)紧密合作,确保技术目标与临床实际需求对齐。我们最初曾考虑加入“疥疮”、“脓疱疮”等,但因图像特征差异较大且数据混杂,为避免任务过于复杂而暂缓。
2.2 技术路线选型与考量
确定了分类任务后,接下来是选择技术路线。对于图像分类,卷积神经网络(CNN)是毋庸置疑的首选。但具体用哪种架构?是从头训练(Training from Scratch)还是使用迁移学习(Transfer Learning)?
迁移学习 vs. 从头训练:医疗影像数据,尤其是针对特定病种如猴痘的数据,通常量少且标注成本极高。我们的项目初期预计只能收集到几百到几千张有效图像,这对于动辄需要数百万ImageNet图像训练的大型CNN来说是远远不够的。因此,迁移学习是唯一可行的方案。我们利用在大型通用图像数据集(如ImageNet)上预训练好的模型,将其知识迁移到我们的特定医疗图像任务上。
模型架构选型:我们评估了当时几种主流的高效CNN架构:
- ResNet50: 经典且强大,在ImageNet上表现优异,结构相对规整,是可靠的基线选择。
- EfficientNet-B3/B4: 通过复合缩放(Compound Scaling)在精度和效率之间取得了更好平衡,在计算资源有限的情况下尤其有吸引力。
- DenseNet121: 特征复用率高,参数相对较少,可能在小数据集上表现更好。
- Vision Transformer (ViT): 当时Transformer在视觉领域刚兴起,虽然潜力巨大,但在小规模医疗数据集上容易过拟合,且训练成本高。
经过初步调研和简单的原型测试,我们最终选择了EfficientNet-B4作为主干网络。主要理由是:在相近的精度下,EfficientNet通常比ResNet更小、更快;相较于更复杂的模型,它在我们的数据规模上过拟合的风险相对可控;并且有丰富的开源预训练权重和易于使用的框架支持(如TensorFlow/Keras, PyTorch)。
整体流程设计:我们规划了标准的深度学习流水线:
数据收集与清洗 -> 数据预处理与增强 -> 模型构建与迁移学习 -> 模型训练与验证 -> 模型评估与测试 -> 简易部署与测试。 这个流程看似标准,但在医疗数据的每一个环节都有特殊的“坑”需要特别注意。
3. 数据工程:医疗AI项目的基石与最大挑战
3.1 数据搜集与面临的困境
数据是AI模型的燃料,但对于“猴痘”这个相对新兴且敏感的课题,获取高质量、标注准确的图像数据集是第一个巨大挑战。我们主要通过以下途径:
- 公开医学数据集:搜索如Kaggle、GitHub上的相关竞赛数据集。幸运的是,当时已有一个名为“Monkeypox Skin Lesion Dataset (MSLD)”的公开数据集开始流传,它包含了猴痘、水痘、麻疹和正常的图像,但数量有限(总计约2000张左右),且图像质量参差不齐。
- 医学文献与机构网站:从已发表的医学论文的附图、世界卫生组织(WHO)、疾病控制与预防中心(CDC)等权威机构的公开教育材料中,手动收集并截图。这里必须严格遵守版权和伦理规定,仅用于研究验证,并计划在最终产品中替换为合规授权数据。
- 网络爬虫(谨慎使用):在严格遵守robots协议和版权法的前提下,从少数专业的医学图像库网站进行定向采集。这是一个灰色地带,必须极度谨慎。我们严格限定了来源网站(仅限明确允许研究使用的),并进行了严格的去标识化处理,移除所有患者个人信息和元数据。
即使这样,我们最初收集到的原始图像也不足3000张,且存在严重问题:
- 类别不平衡:猴痘图像最少,水痘和麻疹较多。
- 质量差异大:分辨率不一、光照不均、拍摄角度多样、背景杂乱,有些图片甚至包含文字标注或测量尺。
- 标注噪声:来自网络的图片标注可能不准确,例如将“带状疱疹”误标为“水痘”。
3.2 数据清洗与标注标准化
面对“脏数据”,我们花了近一半的项目时间进行清洗和标准化,这是确保模型可信度的关键。
自动过滤:
- 去除低质图像:使用OpenCV检测并删除分辨率过低(如小于224x224)、严重模糊(通过拉普拉斯方差计算)或几乎全黑/全白的图像。
- 去重:计算图像的感知哈希(pHash),移除高度相似的重复图像。
手动审核与标注:
- 这是最耗时但无法替代的步骤。我们邀请了一位医学专业的同学作为顾问,对每一张自动过滤后的图像进行复核。
- 制定标注规范:明确各类别的视觉特征。例如,猴痘皮疹通常更离心分布(面向手掌、脚底、面部),水疱形态更一致;水痘皮疹呈“向心性”分布(躯干多),且可见不同期的皮疹(丘疹、水疱、结痂)共存。我们将这些特征写成文档,供标注参考。
- 使用标注工具:采用LabelImg或更简单的分类文件夹方式进行整理。对于不确定的图像,直接剔除,绝不保留存疑样本。
数据扩增策略: 为了应对数据量小和类别不平衡,我们采用了强化的数据增强(Data Augmentation)。除了常见的旋转、翻转、缩放、裁剪外,针对医疗图像特点,我们增加了:
- 颜色扰动:轻微调整亮度、对比度、饱和度,模拟不同拍摄设备的光照条件。
- 弹性形变:模拟皮肤表面的细微纹理变化。
- 添加噪声:模拟图像传输或采集过程中的高斯噪声、椒盐噪声。
- 混合增强:如MixUp,将两张图像按比例混合,同时混合其标签,有助于模型学习更平滑的决策边界,减轻过拟合。
我们使用
TensorFlow的ImageDataGenerator或Albumentations库(功能更强大)来实现这些增强。关键点:增强操作必须在训练阶段实时进行,而不是预先增强保存,以最大化数据多样性。
4. 模型构建、训练与调优实战
4.1 迁移学习的实现细节
我们使用PyTorch框架,以EfficientNet-B4的ImageNet预训练权重为基础。
import torch import torch.nn as nn from torchvision import models, transforms import torch.optim as optim # 1. 加载预训练模型,并冻结所有底层参数 model = models.efficientnet_b4(pretrained=True) for param in model.parameters(): param.requires_grad = False # 冻结特征提取层 # 2. 替换分类头(Classifier Head) # EfficientNet的最后一层是 `classifier`,其结构为 Sequential( Dropout, Linear(1792, num_classes) ) num_ftrs = model.classifier[1].in_features # 获取原全连接层输入特征数 model.classifier[1] = nn.Linear(num_ftrs, 4) # 替换为我们的4分类全连接层 # 将新的分类头参数设置为可训练 for param in model.classifier.parameters(): param.requires_grad = True model = model.to(device) # 移至GPU为什么先冻结再微调?预训练模型底层学习的是通用边缘、纹理、形状特征,这些对医学图像同样有用。直接在小数据上训练所有层会导致这些宝贵知识被“洗掉”,并极易过拟合。冻结底层,只训练新换上的分类头,相当于让模型快速适应新任务。
4.2 训练策略与超参数设置
训练分为两个阶段:
- 第一阶段(分类头训练):只训练我们新添加的
classifier层。使用较大的学习率(如1e-3),让模型快速适应新的分类任务。这个阶段通常很快,5-10个epoch即可。 - 第二阶段(整体微调):解冻模型的部分或全部底层卷积层,用较小的学习率(如1e-4, 1e-5)进行精细微调。我们采用分层解冻策略,从靠近分类头的后几层开始解冻,逐步解冻更前面的层,这样更稳定。
# 优化器与损失函数 criterion = nn.CrossEntropyLoss() # 第一阶段优化器:只优化分类头参数 optimizer_stage1 = optim.Adam(model.classifier.parameters(), lr=1e-3) # 第二阶段优化器:优化所有参数,但使用更小的学习率 optimizer_stage2 = optim.Adam(model.parameters(), lr=1e-4) # 学习率调度器:在验证集性能停滞时降低学习率 scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer_stage2, mode='min', factor=0.5, patience=3)关键超参数经验:
- 批次大小 (Batch Size):受GPU内存限制,我们设为16或32。较小的批次大小可能带来正则化效果,但训练噪声更大。
- 图像尺寸:EfficientNet-B4的默认输入是380x380,但我们初始尝试了224x224以加快训练速度,后期再尝试提升分辨率。
- 正则化:除了数据增强,我们在全连接层使用了较高的Dropout率(0.5),并使用了权重衰减(Weight Decay, 1e-4)来防止过拟合。
- 早停 (Early Stopping):监控验证集损失,如果连续5-10个epoch没有下降,则停止训练,并回滚到验证集性能最好的模型权重。
4.3 评估指标的选择与解读
对于医疗分类模型,不能只看准确率(Accuracy),特别是当数据不平衡时。我们主要关注以下指标:
- 混淆矩阵:直观展示每个类别的分类情况,看模型主要混淆了哪两类。
- 精确率(Precision)、召回率(Recall)和F1-Score:针对每一个类别计算。对于“猴痘”这个目标类别,我们更关注召回率,即“尽可能找出所有真正的猴痘病例”(宁可误报,不可漏报)。但同时要用精确率来平衡,避免误报过多。
- 宏平均(Macro-average)和加权平均(Weighted-average)F1:综合评估模型在所有类别上的表现。
我们使用sklearn.metrics来全面计算这些指标。在测试集上的结果大致如下(模拟数据):
| 类别 | 精确率 | 召回率 | F1-Score | 支持数 |
|---|---|---|---|---|
| 猴痘 | 0.86 | 0.82 | 0.84 | 112 |
| 水痘 | 0.91 | 0.88 | 0.89 | 150 |
| 麻疹 | 0.89 | 0.93 | 0.91 | 135 |
| 正常 | 0.95 | 0.94 | 0.95 | 203 |
| 准确率 | 0.90 | 600 | ||
| 宏平均 | 0.90 | 0.89 | 0.90 | 600 |
| 加权平均 | 0.91 | 0.90 | 0.90 | 600 |
从混淆矩阵发现,模型最主要的错误是将少数猴痘病例预测为水痘,反之亦然。这符合医学常识,也指明了模型改进的方向——需要更多这两种病的对比学习数据。
5. 部署尝试与性能优化思考
项目后期,我们尝试了简单的本地部署,以验证模型的可用性。
模型导出:将训练好的PyTorch模型转换为
TorchScript格式,或使用ONNX Runtime进行转换,以获得更好的跨平台推理性能。# 示例:转换为TorchScript model.eval() example_input = torch.rand(1, 3, 380, 380).to(device) traced_script_module = torch.jit.trace(model, example_input) traced_script_module.save("monkeypox_classifier.pt")构建简易Web接口:使用
Flask或FastAPI快速搭建一个本地服务。from fastapi import FastAPI, File, UploadFile import torch from PIL import Image import io app = FastAPI() model = torch.jit.load("monkeypox_classifier.pt") model.eval() @app.post("/predict/") async def predict(file: UploadFile = File(...)): image_data = await file.read() image = Image.open(io.BytesIO(image_data)) # 进行与训练时相同的数据预处理 processed_image = preprocess(image).unsqueeze(0) with torch.no_grad(): outputs = model(processed_image) _, predicted = torch.max(outputs, 1) class_names = ['Monkeypox', 'Chickenpox', 'Measles', 'Normal'] return {"prediction": class_names[predicted.item()]}性能优化考量:
- 模型轻量化:考虑到未来可能在移动设备或边缘设备上使用,我们探索了模型剪枝(Pruning)和量化(Quantization)。使用PyTorch的
torch.quantization将FP32模型转换为INT8模型,模型大小减少约75%,推理速度提升2-3倍,精度损失在可接受的1%以内。 - 推理加速:对于服务器端,可以考虑使用TensorRT或OpenVINO等推理引擎进一步优化。
- 模型轻量化:考虑到未来可能在移动设备或边缘设备上使用,我们探索了模型剪枝(Pruning)和量化(Quantization)。使用PyTorch的
实操心得:部署时最大的坑是前后端数据预处理的一致性。训练时我们用了一套复杂的
Compose(包括Resize、ToTensor、Normalize),在部署的API中必须完全复现,包括归一化所用的均值和标准差。任何细微差别都会导致模型性能急剧下降。最佳实践是将预处理函数与模型一起打包或严格文档化。
6. 遇到的典型问题、排查过程与解决方案实录
在项目开发过程中,我们遇到了几乎所有做小规模医疗AI项目都会遇到的典型问题。
6.1 问题一:模型过拟合严重,验证集准确率远低于训练集
- 现象:训练集准确率很快达到95%以上,但验证集准确率卡在70%左右,且波动大。
- 排查:
- 首先检查数据划分:确认训练集和验证集是随机划分的,且类别分布大致相同(使用
sklearn的StratifiedKFold思想进行分层划分)。 - 检查数据增强:确认增强只应用于训练集,验证集应使用确定性的预处理(仅Resize和Normalize)。
- 观察损失曲线:训练损失持续下降,验证损失在几个epoch后开始上升,这是过拟合的典型标志。
- 首先检查数据划分:确认训练集和验证集是随机划分的,且类别分布大致相同(使用
- 解决方案:
- 增强数据多样性:增加了更激进的数据增强,如CutMix、RandomErasing。
- 加强正则化:提高了Dropout率(从0.3到0.5),增加了权重衰减系数。
- 简化模型:从EfficientNet-B4降级到B3,甚至尝试了更轻量的MobileNetV3,发现B3在验证集上表现更稳定。
- 采用更保守的训练策略:缩短第二阶段微调的解冻层数,使用更小的学习率,并更早地触发早停。
- 标签平滑(Label Smoothing):在损失函数中引入标签平滑,防止模型对训练标签过于自信,提升了约1.5%的验证集准确率。
6.2 问题二:模型对“猴痘”和“水痘”的混淆率最高
- 现象:混淆矩阵显示,这两类之间的相互误判占到了总错误的一半以上。
- 排查:这是医学图像本身的难点。我们和医学顾问一起,重新审视了被错误分类的样本图像。发现一些猴痘早期或不典型的皮疹,确实与水痘非常相似,甚至资深医生也可能需要结合病史和其他检查。
- 解决方案:
- 针对性数据收集:重点寻找那些能清晰展示两者区别的病例图像,例如展示猴痘皮疹离心分布(手掌、脚底)的图片,以及水痘向心分布和“四世同堂”(不同期皮疹共存)特点的图片。
- 集成学习:训练了多个不同架构(ResNet50, DenseNet121)或不同数据增强策略下的模型,让它们对“猴痘 vs. 水痘”这个二分类子任务进行投票,集成模型的混淆率有所降低。
- 后处理规则:作为一种临时方案,我们设计了一个简单的后处理逻辑:如果模型预测为“猴痘”但置信度低于某个阈值(如0.7),且次高预测是“水痘”,则输出“需进一步鉴别:猴痘或水痘”,并将置信度一并返回给用户。这明确了模型的不确定性,比强行给出一个可能错误的单一结果更负责任。
6.3 问题三:推理速度在CPU上较慢
- 现象:本地Flask API在CPU上处理一张图片需要1-2秒,无法满足实时性要求。
- 排查:使用
torch.utils.bottleneck或PyTorch Profiler进行分析,发现时间主要消耗在模型前向传播和图像预处理上。 - 解决方案:
- 模型量化:如前所述,INT8量化是提升CPU推理速度最有效的手段之一。
- 批处理:在API设计上,支持批量图片预测,能更好地利用计算资源。
- 使用更快的图像处理库:将PIL替换为
opencv-python进行基本的图像读取和缩放,有轻微提升。 - 考虑边缘部署:如果场景允许,可以将模型部署在带有NPU的边缘设备(如某些型号的手机、开发板)上,专门为推理优化。
7. 项目反思、伦理考量与未来方向
回顾整个“第P4周”项目,它更像一个技术验证原型(Proof of Concept),而非一个成熟的产品。但它完整地走完了从问题定义到简易部署的闭环,价值巨大。
核心收获与反思:
- 数据质量远大于模型复杂度:在医疗领域,一个用1000张高质量、标注精准的图像训练的简单模型,其可信度可能远超用1万张噪声数据训练的复杂模型。数据清洗和标注的时间投入回报率最高。
- 领域知识不可或缺:没有医学顾问的指导,我们连该区分哪些病、该关注图像的哪些特征都无从下手。AI工程师必须与领域专家深度协作。
- 理解模型的局限性:我们的模型只是在有限的图像数据上学习到了统计规律。它不能理解病因、病理,也无法结合患者病史、实验室检查。它永远只是一个辅助工具,最终的诊断决策权必须在医生手中。
- 伦理与责任:开发医疗AI模型必须怀有敬畏之心。我们需要考虑模型偏差(如果数据主要来自某一人种,对其他人群的识别率可能下降)、可解释性(为什么模型做出这个判断?)、数据隐私和安全等一系列问题。在项目早期,我们就应制定相应的伦理审查清单。
未来可探索的方向:
- 多模态学习:结合皮肤镜图像、患者病史文本(去标识化后)等多源信息进行综合判断。
- 异常检测与不确定性估计:当输入图像不属于任何已知类别时,模型应能给出“未知”或“低置信度”的提示,而不是强行分类。
- 持续学习:设计机制,让模型能在获得新的、经过严格审核的标注数据后,安全地进行增量学习,而不会遗忘旧知识。
- 真正的临床验证:与医疗机构合作,进行前瞻性的临床试验,在真实世界环境中评估其敏感性、特异性、对临床工作流程的影响以及最终对患者结局的改善。
这个项目让我深刻体会到,将AI技术应用于像医疗这样的严肃领域,技术实现只是冰山一角。对问题的深刻理解、对数据的敬畏、对伦理的考量以及对模型局限性的清醒认识,共同构成了冰山下那更庞大、更关键的部分。希望这次分享,不仅能提供一些技术上的参考,更能引发大家对AI应用边界和责任的思考。