前阵子帮某高校的一位同学把控毕业设计,他选的题目是“基于Python和卷积神经网络训练识别牙齿是否健康”。这两年类似的选题确实不少,但真正从零开始做过一遍的人会发现,里面的坑远比想象中多。数据集去哪找、标注怎么画、模型选哪个、训练为什么不收敛、答辩时要怎么解释效果——每一步都能卡住一批人。这篇文章就把整个项目从数据到模型再到答辩交付的完整套路整理出来,给准备做医学图像方向毕设的同学一份可以直接抄作业的参考。
1. 先想清楚题目边界:识别“健康与否”到底属于哪类问题
1.1 不是让机器下诊断,而是做辅助识别
很多同学第一次看到“牙齿健康识别”这个题目,第一反应是:我要做一个能自动诊断口腔疾病的系统。这个定位一上来就会出问题。医院里的诊断流程包含问诊、触诊、影像判读、病史综合,远不止“看一张牙的图片”这么简单。作为本科或硕士毕设,把题目收敛到“图像分类任务”才是靠谱的路线。
实际要做的事很明确:给模型一张牙齿照片或一段牙片区域,让它输出一个类别,比如“健康”或者“不健康”。更细致一点,可以分三类:“健康”、“龋齿”、“牙结石”。本质上就是一个标准的图像分类问题,和猫狗识别、水果分类没有任何区别。这样定位的好处是:公开资料多、评估指标成熟、答辩时不用跟评委在一个模糊的医学定义上纠缠半天。
1.2 为什么卷积神经网络适合处理这类任务
牙齿照片和普通自然图像有个很大的不同:病灶区域往往只占整张图的一小块,颜色、纹理和周围组织高度相似。传统方法用阈值分割、边缘检测去挑病灶,很容易把反光、口水渍、牙龈阴影一起误判进去。卷积神经网络的核心优势在于,它会自动学习到一组层层递进的特征:浅层卷积核寻找边缘、纹理、色块,深层卷积核则组合出“龋洞边缘”、“牙结石附着”这种语义级别的模式。
用人话说,传统图像处理像用手工筛子筛豆子,筛孔的大小是人事先定的;CNN像自己长大的筛子,它会在训练过程中根据数据把筛孔自动调整到最能区分健康牙和病牙的状态。所以只要数据量足够、标注靠谱,模型的泛化能力通常远超手工特征方案,这也是这个题目能当作毕设去做的根本原因。
1.3 先列几个候选方案,再定技术路线
这个题目可选的技术路线其实不少,开题前值得先做一轮对比:
- 用传统图像处理:HOG、LBP提取特征,丢给SVM分类。方案轻量,但牙齿图像环境杂,准确率上限低,实验做完很难有亮点。
- 自己从零设计一个小型CNN:能体现对卷积、池化、全连接的理解,适合课程项目,作为毕设则容易在准确率和鲁棒性上吃亏。
- 用预训练模型做迁移学习:比如ResNet18、EfficientNet,加载ImageNet预训练权重,替换最后的分类头。工程量大减,效果更稳,是大多数毕设的最优解。
- 直接上目标检测:把“识别牙齿是否健康”扩展成“逐颗牙齿框出来并判断状态”,工作量翻倍,不建议在毕设周期内冒进。
我的最终建议是:主方案用迁移学习,对比实验加一个自己搭建的浅层CNN。这样既有工程落地能力,又有对模型原理的独立思考,论文里还能写一组漂亮的对比数据。
2. 数据集是命根子:获取、清洗、标注的实操细节
2.1 公开数据和“自建数据”两条路该怎么选
这个题目最大的不确定性不在模型,而在数据。口腔影像涉及患者隐私,公开的、带标注的牙齿健康分类数据集少得可怜。常见的来源有这么几个方向:
- 国际数据竞赛平台上有一些口腔X光片数据集,但多数是全景片,颗粒大、小牙病灶不清晰,直接拿来做“健康与否”二分类需要额外裁剪。
- 部分学术论文附带的开源子集,可以去找原文的下载链接,但要注意授权条款。
- 最实际的办法:找一家愿意合作的口腔诊所,收集经过脱敏处理的口腔内窥镜照片,自己标注。
如果是自己造数据,操作上必须守住几条底线:所有图片去掉患者姓名、就诊号等可识别信息;不要拿网上随手搜来的患者照片商用;论文里明确交代数据来源和脱敏方式。我们做的是技术演示和算法验证,不是真实临床辅助诊断工具,这一点在论文的方法部分写清楚反而是加分项。
2.2 一颗牙怎么变成一个可训练样本
拿到原始照片后,不能把一整张口腔照片直接丢给模型。原因是背景占比太大,模型很容易“偷懒”,直接依据肤色、嘴唇形状判断,而不是真正看牙齿。我建议的标注方式是:用标注工具把每一颗牙齿单独框出来,存成一张张小图。
操作流程大致是:用LabelImg或CVAT这类工具打开口腔照片,以矩形框框出每颗牙齿的牙冠区域,打上标签。导出时可以采用Pascal VOC或YOLO格式,也可以直接导出成CSV,里面记录文件名、类别和框坐标。之后用Python脚本按坐标裁剪出小图,统一缩放为224×224像素。
这里最容易被忽视的一个步骤是清洗:模糊的、被舌头或手指挡掉大半的、严重过曝的图片必须删掉。模型对脏数据的容忍度没有想象中高,十几张模糊图混进训练集,就可能把验证集准确率拉低三到五个点。宁可最终只留下三五百张干净图,也不要硬凑一千张垃圾图。
最后把数据按目录组织好,训练阶段读取会省很多麻烦:
dataset/ train/ healthy/ (健康牙) caries/ (龋齿) calculus/ (牙结石) val/ healthy/ caries/ calculus/ test/ healthy/ caries/ calculus/2.3 数据增强:没几张图也能把它“变出”多样性
口腔诊所能给的照片数量通常有限,整个数据集可能就几百张。这时候如果直接训练,模型必然过拟合。数据增强就是用来解决这个问题的:对原图做小幅度几何变换和颜色扰动,相当于让模型看到更多“同一样本的不同拍摄条件”。
推荐用albumentations库,处理速度比torchvision自带的快不少。我的常用配置是这样的:
import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform = A.Compose([ A.Resize(224, 224), A.HorizontalFlip(p=0.5), A.Rotate(limit=15, p=0.5), A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.3), A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ToTensorV2(), ]) # 验证集和测试集只用归一化,不做增强 val_transform = A.Compose([ A.Resize(224, 224), A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ToTensorV2(), ])这里有个细节要注意:旋转角度别调太大。牙齿的解剖结构有其自然朝向,旋转超过15度就会生成现实中几乎不可能出现的“歪牙”,模型反而被教坏。亮度扰动也一样,口腔图片有固定的光照条件,扰动范围太大会把龋齿的深色特征抹没。数据增强的目的是模拟拍摄角度的微小差异,不是把数据集变成抽象艺术展。
3. 模型结构选择:与其堆大模型,不如用好迁移学习
3.1 一个最简的可运行CNN长什么样
如果打算做一个“自己的模型”作为对比实验,结构不用复杂,三层卷积加两层全连接就足够。重点是让评委看到你理解卷积、池化、Dropout各自在干什么。下面是一个用PyTorch实现的简化版模型,输入是224×224的彩色牙图:
import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=3): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 16, kernel_size=3, padding=1), nn.BatchNorm2d(16), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 28 * 28, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x))三层池化之后,224×224的图变成28×28,所以全连接层输入维度是64×28×28。BatchNorm的作用是让每层的输出分布不过度波动,训练更稳;Dropout则是随机“关掉”一半神经元,逼模型不要把赌注押在单一路径上。这些细节在答辩时被问到,都是加分答案。
3.2 为什么我建议用ResNet18做迁移学习
自己搭的CNN拿来跑对比实验可以,但作为主模型缺点太明显:数据量小、结构浅、特征提取能力有限,训练出来的准确率通常在75%上下就上不去了。更聪明的做法是利用别人在大数据集上训练好的模型骨架。
ResNet18是很多毕设的默认选择,原因很实际:模型体量适中,单卡训练快,预训练权重质量高,而且残差结构解决了深层网络退化问题,即使不微调也能有不错的特征提取能力。加载方式很简单:
import torchvision.models as models model = models.resnet18(pretrained=True) # 把最后的全连接层换成自己的分类头 num_features = model.fc.in_features model.fc = nn.Linear(num_features, num_classes) # 3类这里有个容易被忽视的坑:牙齿图像和ImageNet自然图像差异很大,如果直接把整个模型一起全量微调,风险是灾难性遗忘,模型一上来就把预训练学到的通用特征全冲掉了。稳妥的做法是分两阶段训练:先冻结主干,只训练新增的全连接层;等分类头收敛后,再解冻主干的部分层,用很小的学习率做微调。我在实际项目中反复验证,这种“先头后身”的迁移策略比一次性端到端训练稳定得多。
4. 训练到底怎么调:loss不掉、过拟合、显存爆掉这三大拦路虎
4.1 一次稳定训练的超参数配置参考
训练医学图像分类模型,超参数并没有多少玄学。我常用的起点配置如下表所示,很多同学上来就用的学习率0.01,对迁移学习来说往往是灾难,因为预训练权重已经被调得足够好,大步长只会把它破坏掉。
| 超参数 | 数值 | 说明 |
|---|---|---|
| 图像尺寸 | 224×224 | ResNet的默认输入尺寸 |
| Batch size | 32 | 显存不够就降到16 |
| 初始学习率 | 1e-4 | 微调阶段尤其要克制,别超过1e-3 |
| 优化器 | AdamW | 比Adam收敛稳定,配合权重衰减效果更好 |
| weight_decay | 1e-4 | 相当于L2正则,抑制过拟合 |
| 学习率调整 | ReduceLROnPlateau | 验证集指标不涨时,学习率降为原来的0.1 |
| Epoch数 | 30到50 | 配合早停,不必死磕固定轮数 |
AdamW里的“W”指weight decay的解耦,和L2正则不完全等价,但对小数据集来说更稳妥。ReduceLROnPlateau这类自适应调度器比固定每10轮降一次学习率的做法更贴心,它会等模型进入平台期再踩油门,适合训练时间不固定、需要反复实验的场景。
4.2 看训练日志的“三步排查法”
训练过程里,不要只看最后准确率,应该盯着每个epoch的日志变化。下面是我某次训练模拟实验的真实记录片段:
| Epoch | train loss | val acc | 观察结论 |
|---|---|---|---|
| 1 | 0.68 | 0.56 | 模型接近随机猜,正常 |
| 5 | 0.42 | 0.74 | 学习率合适,指标在爬升 |
| 10 | 0.18 | 0.82 | 进入稳定提升期 |
| 20 | 0.05 | 0.87 | 训练集已接近饱和 |
| 30 | 0.02 | 0.83 | 训练集还在降,验证集开始回落,过拟合信号出现 |
遇到这类情况,我的排查顺序固定是三件事:
- loss从头到尾纹丝不动在0.69附近徘徊,说明模型根本没学进去,先去查数据归一化是否生效、标签是否错位、学习率是否大得把loss震飞了。
- 训练集准确率一路走高,验证集卡在某个值上不去,先看是不是数据增强加太猛,把真实纹理破坏了。
- 确认增强没问题后,再上正则化手段:weight_decay、Dropout、Early Stopping。
当发现训练集准确率已经99%但验证集开始下滑时,不必追求跑满50个epoch。Early Stopping的核心思是:记录验证集表现最佳的模型权重,一旦连续多个epoch没有刷新纪录就停掉。我在PyTorch里习惯用torch.save保存best模型,再额外维护一个“连续多少个epoch没更新”的计数器,这个习惯帮我省下了大量调参等待时间。
4.3 显存不足的三条低成本出路
学生机用老显卡跑ResNet,batch size设为32经常爆显存。有些人第一反应是换更大的显卡,其实没必要,优先级应该是这样:
- 把batch size降到8或4,显存占用成比例下降,代价是训练更慢,配合梯度累积可以弥补。
- 开PyTorch混合精度训练,用fp16存储中间激活,显存直接砍掉近一半,收敛速度反而可能更快。
- 把图像尺寸从224降到160甚至128,对牙齿这类结构简单的图像,分辨率略微降低损失不大,显存收益却很可观。
我的实测体会是:先降batch size,再开AMP,最后才考虑缩小分辨率。因为大幅度缩小图片尺寸,会影响小龋洞的纹理细节,模型可能因此丢失关键特征。
5. 用证据链做评估:准确率之外还要拿得出这些材料
5.1 混淆矩阵和多分类指标怎么算
模型跑完,很多同学只写一句“测试集准确率约90%”就收工了。这个数据在答辩时毫无说服力。患者数据天然不平衡,健康牙可能占70%,你就算把所有样本都判成健康,准确率也有70%。所以医学图像任务里,重点要交出的指标是精确率、召回率和F1分数。
在四分类场景里,假设牙齿类别包含健康、龋齿、牙结石、牙龈炎。其中“炎症”这类早期病灶的特征很不明显,最容易误判为健康。如果模型把大部分发炎牙都认成健康牙,准确率再高也没有临床参考价值。此时真正要关注的就是炎症类别的召回率,它反映的是模型能不能有效“抓到”病人。
from sklearn.metrics import classification_report, confusion_matrix # y_true为真实标签,y_pred为模型预测标签 print(classification_report(y_true, y_pred, target_names=['健康', '龋齿', '牙结石', '牙龈炎'])) print(confusion_matrix(y_true, y_pred))后续分析时,把混淆矩阵画成热力图,逐个格子看:到底哪两类之间最容易被搞混。如果是龋齿和牙结石之间的误判,说明模型学到的主要是“深色牙斑”,要补充更多典型样本;如果牙龈炎被误判为健康,恭喜,问题指向明确的四分类标注规则缺陷,可以反向推动数据标注的修正。
5.2 Grad-CAM热力图:让模型“交代”它看了哪里
光说准确率高还不够,评委大概率会追问一句:“模型到底靠什么判断的?”这里强烈推荐做一组Grad-CAM可视化。原理不复杂:把输入图片的预测得分反向传播,得到最后一个卷积层各通道的梯度,以此计算各通道的重要度,再加权求和、上采样放大,就能得到一张热力图,标出模型做出判断时重点关注的区域。
实操代码流程可以这样走:
- 把模型的最终全连接层去掉或摘出特征图,记录最后一个卷积层的输出。
- 对于输入的牙齿图片,得到预测类别的得分。
- 对得分做反向传播,得到卷积层输出的梯度。
- 按通道对梯度求平均,得到每个通道的权重。
- 把权重和卷积层输出做加权求和,经过ReLU后归一化,叠加到原图上。
当热力图集中在牙齿本身的龋坏区域、牙缝、齿颈线时,模型的可信度就非常高。反之,如果热力图大面积散落在牙龈外部背景上,说明模型学的不是牙齿特征,而是照片背景色差,这类模型再高的acc都是虚的。Grad-CAM一方面可以作为论文实验的一部分,另一方面也是调整训练方向的有效工具,看到热力图不对,就知道数据或标注出了问题。
5.3 做一个简单的演示Demo,答辩现场直接跑
论文和幻灯片都是静态的,远不如现场演示有冲击力。用Gradio可以快速搭一个网页式的小工具:上传一张牙齿图片,模型自动返回分类结果和各类别概率。
import gradio as gr def predict(img): img = transform(img).unsqueeze(0).to(device) with torch.no_grad(): probs = torch.softmax(model(img), dim=1).cpu().numpy()[0] return {labels[i]: float(probs[i]) for i in range(len(labels))} gr.Interface( fn=predict, inputs=gr.Image(type="numpy"), outputs=gr.Label(num_top_classes=3), title="牙齿健康识别Demo" ).launch()演示时顺手拍两张不同类别的图丢进去,让“不健康”样本确确实实显示出更高的异常概率,这个直观的画面比十页PPT都有说服力。
6. 从项目到毕设交付:论文怎么写、答辩怎么讲更稳
6.1 论文实验部分的三组核心对比
论文的实验章节,直接体现工作量和技术深度。建议安排下面几组对比,模板排成表格:
| 模型方案 | 数据增强 | 测试准确率 | 宏平均F1 |
|---|---|---|---|
| 自己搭的SimpleCNN | 无 | 72.4% | 0.556 |
| 自己搭的SimpleCNN | 有 | 78.8% | 0.622 |
| ResNet18迁移学习 | 有 | 91.6% | 0.753 |
| ResNet18迁移学习+微调 | 有 | 93.2% | 0.811 |
第一组和第二组对比,证明数据增强在数据有限时的必要性;第二组和第三组对比,体现迁移学习的明显优势;第三组和第四组对比,展示分阶段微调的边际收益。这三组对比做完,论文的实验逻辑就非常完整了。再补一张Grad-CAM可视化图,直接把“黑盒”质疑堵回去。
6.2 答辩被问次数最多的几个问题
答辩和面试一样,问题翻来覆去就是那几个,提前准备就好:
- “为什么你的迁移学习要先冻结主干层?”——答:让分类头先适应输出空间,避免预训练特征被大步长梯度破坏。
- “健康和不健康的边界实际上很模糊,你怎么定义标签?”——老实交代标注时按影像学常见特征划分类别,并说明存在的主观性,以及将来如何引入多标注一致性校验。
- “为什么不用更大更深的模型,比如ResNet50?”——答:样本量小,大模型容易过拟合;ResNet18在牙齿小图上已经具备足够容量,训练成本更低、可复现性更好。
- “你这个能直接用在诊所吗?”——禁忌回答“能”。正确的说法是:目前是算法验证级别的辅助识别原型,真正落地还需多中心数据扩充、临床验证和软硬件适配。
最后一个问题是闭眼都能猜到的“送分题”,但每年都有同学翻车。一定要表现出对医学落地严谨性的敬畏,而不是吹嘘准确率,这会直接影响评委的最终印象。
6.3 如果还有余力:从二分类走向更完整的方案
前面的流程做下来,毕业设计已经足够撑场面。如果想再拉开差距,可以在“分类”这一层之上加一个小目标检测模块,用YOLO等目标检测模型把一张全景口腔X光片里的每颗牙齿框出来,再逐框送入分类网络判定健康状态。这个改进路径实际上把题目从“图像分类”提升到了“检测+分类”的完整流程,工作量增加不多,但论文的“系统完整性”会明显上一个档次。数据标注时需要额外给出每个牙齿位置的框,工作量会随之增加,尽量提前规划好。
最后再分享一点个人习惯:代码跑通的那天不要急着兴奋,先把Grad-CAM热力图打出来看看,如果模型关注的位置和口腔科医生看片子时的关注位置一致,你才真正搞定了这个题目。学科交叉类的毕设,最大的加分项不是某个数值提升了好几个点,而是整个项目呈现出“数据—模型—评估—解释”这条闭环都走得通。做到这一步,答辩时心里就有底了。