1. 先讲清楚一件事:为什么骨关节炎分级会想到用 YOLOv5
最初接触这个项目,是某高校医学影像课题组找过来,说想做一个膝关节骨关节炎(KOA)X光片的自动分级系统。他们给了一堆资料,核心诉求很直白:医生在临床上读膝关节正位片,按照 Kellgren-Lawrence(KL)分级标准,把关节退变程度分成 0 到 4 级,0 级是健康,4 级是重度。这项工作极其依赖经验,同一个片子不同医生打出来的级别经常差出一档,基层医院更缺有经验的影像科医生,所以他们希望用深度学习给一个客观、稳定、可复现的分级结果,辅助诊断。
我一开始听到“用 YOLOv5 做 X 光片分级”,第一反应是:你是不是搞错了?YOLO 是目标检测模型,擅长的是框出物体位置,你这个任务需要的是分级,也就是分类,顶多加个定位,为什么不用纯分类网络?后来把问题拆开才明白,骨关节炎分级和日常的图像分类有个本质区别:模型必须先找准膝关节的解剖结构位置,尤其是关节间隙区域,才能做出可靠的退变程度判断。
为什么?KL 分级的核心依据是:关节间隙是否变窄、是否有骨赘(bone spur)形成、是否有硬化、是否有畸形。其中“关节间隙变窄”是最重要的判据,它要求模型去比对股骨髁和胫骨平台之间的距离。如果网络没有先定位,直接整图分类,模型可能会去关注骨骼以外的纹理、X 光片上的伪影、甚至患者双腿摆放的角度差异,这些干扰会让分类结果很不稳定。
所以项目最终的技术路线确定为:改进 YOLOv5 作为检测骨干,先定位膝关节关键区域,再结合分类分支完成 KL 0-4 级分级。也就是一个“检测引导分级”的两阶段方案。这篇博文就把整个系统的构造思路、踩坑过程、改进细节都拆开来讲,涵盖数据标注、网络结构改动、训练技巧、部署体验,适合做医学影像 AI、横向课题或者毕业设计的同学参考。
需要先说明,本文不是源码逐行注释,而是从项目整体角度讲清楚“每个决定背后的原因”,你可以按这个思路去复现和改进,遇到工程细节问题也知道该往哪个方向排查。
2. 数据是命根子:KOA 分级的难点先从标注开始
2.1 KL 分级标准到底在评估什么
Kellgren-Lawrence 分级从 0 到 IV(这里后续统一说 0-4 级),判据落在四个维度上:
| KL等级 | 关节间隙 | 骨赘 | 骨质硬化/畸形 |
|---|---|---|---|
| 0级 | 正常 | 无 | 无 |
| 1级 | 可疑狭窄 | 轻微骨赘 | 无 |
| 2级 | 明确狭窄 | 明确骨赘 | 无 |
| 3级 | 中度狭窄 | 多发骨赘 | 轻度硬化/畸形 |
| 4级 | 重度狭窄(近乎消失) | 大量骨赘 | 明显硬化/畸形 |
这个分级有个致命特点:相邻等级之间的视觉差异非常小。1 级和 2 级可能只是关节间隙窄了不到 1 毫米,骨赘多了那么一点点;3 级和 4 级的边界也很模糊。这对数据标注的要求是极高的。
我们拿到的数据来自多个来源,包括某医院门诊历史片子和一个公开骨关节数据集。原始标注是放射科医生做的,但不同医生的标注风格不一致,有的医生倾向于把“可疑”打成 1 级,有的直接给 2 级。我在项目中做了一个很关键的动作:筛片后请两位影像科医生对标注分歧比较大的样本重新仲裁,再结合每位医生自己的置信度,生成软标签(soft label),而不是硬性 0/1/2/3/4 的 one-hot。
提示:如果你做类似项目,不要迷信公开数据集的原始标签,尤其是 KL 分级这种主观性强的任务。至少请一位相关领域的人复看一部分数据,统计标注一致性,再决定是直接训练还是先清洗。
2.2 数据清洗流程和我的筛选经验
拿到 X 光片之后,我做了四个处理步骤:
- 格式统一:原始数据里有 DICOM 也有 JPEG,DICOM 需要提取像素数组并做窗宽窗位归一化,统一转成 8bit PNG,避免 JPEG 压缩带来的边缘纹理噪声。
- 方向校正:X 光片有左右膝之分,有的片子是正位,有的是侧位,侧位片的分级意义非常有限。我只保留正位(AP)片,在检测阶段用一个左右翻转和方向分类的小分支做辅助判断,防止模型对位置关系产生歧义。
- 裁剪区域标准化:原图尺寸从 1500×1200 到 4000×3000 都有,如果直接缩放会丢失细节。我们用检测框的坐标把膝关节区域截出来,再按长边等比缩放,保证输入网络的区域内容基本一致。
- 异常剔除:带金属假体、严重造影剂残留、明显运动模糊的片子直接排除,这类样本会让模型学到错误的纹理特征。
2.3 标注格式怎么设计
因为要走 YOLOv5 路线,我用了两套标注:
- 第一套是检测标注:标的是整个膝关节区域,也就是股骨远端 + 胫骨近端这个矩形框。这是为了让模型把注意力集中在关节结构上,而不是整张片子的其他器官或背景。
- 第二套是分类标注:同一张图,附 KL 等级标签。
这个“框 + 类”的结构,让我后面在改进网络时非常灵活。比如我可以把检测分支设置为辅助任务,分类分支设置为主任务,也可以用检测框结果对分类分支做注意力加权。整套标注就用 YOLO 的 txt 格式存,类别字段从 0 到 4 表示 KL 等级。
第一次跑通的时候效果不太好,检测框倒是很准,但分类结果总在 1 级和 2 级之间跳。我后来发现,问题不在分类器,而在数据的样本分布——3 级和 4 级的片子本来就比 0 级和 1 级少得多,而且很多设备拍的轻度片子都存在“看起来像一级又像二级”的模糊边界。所以在数据层面,我后面专门做了重采样和困难样本挖掘,这个后面详细展开。
3. 改进 YOLOv5 的思路:不是结构越复杂越好,而是匹配任务
3.1 为什么不用现成的 YOLOv5s 直接训练
YOLOv5s 是一个通用目标检测模型,它的设计目标是在 COCO 这类多类别数据集上取得精度和速度的平衡。直接拿来做 KOA 分级会遇到三个问题:
- 输出维度不匹配:YOLOv5 的原生输出是框坐标 + 类别置信度,但我的核心任务是分级。如果只把类别数改成 5 去训,模型理解的是“整图里有什么等级的区域”,而不是“某个膝关节区域处于什么等级”。
- 感受野策略不适合:KOA 分级需要同时关注局部骨赘细节和整体关节间隙形态,YOLOv5 在 neck 部分的多尺度融合对这些细微结构不敏感。
- 特征没有对齐解剖语义:检测模型的输出层直接分类,缺少“定位出关键语义点”的过程,等效于让分类器自行找到关节间隙,头部数据有限时学不稳定。
所以我的方案是,保留 YOLOv5 的检测预训练权重做特征提取,但在多个层面加入医学任务特有的监督信息。
3.2 模型骨架的四个具体改动
第一处改进:轻量化 Backbone 替换
原版 YOLOv5 的 Backbone 是 CSPDarknet,对 GPU 显存要求偏高。我换成了一个更轻量的结构,只保留前 4 个 stage 的卷积输出,丢弃高层语义模块,然后把特征图的通道控制在 256 以内。这样做的原因是:X 光片是灰度图,信息密度远不如自然图像高,不需要那么宽的通道去表达颜色和纹理差异。实测下来显存占用降了差不多 30%,精度没有明显损失。
第二处改进:注意力机制插入位置选择
很多改进消融实验都会在 backbone 后面加 SE 或者 CBAM,但我在实际测试中发现,统一加注意力模块不如分阶段加。最终选择是在检测头前两个尺度的特征图上加EMA(Efficient Multi-Scale Attention)模块。EMA 比 SENet 好在能在多尺度上做跨通道信息交互,对骨赘这种小目标的细微特征更友好。把 EMA 加在浅层特征图而不是深层,是因为浅层特征保留了更多的边缘和纹理细节,而这些正是区分 2 级和 3 级骨赘的关键。
第三处改进:检测头改为双头结构
原始 YOLOv5 只有一个输出头,同时输出框坐标和类别概率。我改成两个并行分支:一个分支还是回归检测框坐标(无类别信息),另一个分支在 RoIAlign 后的特征上做 KL 分级回归。
具体实现是:检测分支的候选框生成依然用 anchor-based 机制,但类别置信度这一路改为输出分级结果,也就是 5 个输出节点对应 0-4 级。训练时两个分支的 loss 加权合并,权重配比是 0.3(框): 0.7(分类),梯度回传的时候分类分支占主导,但检测分支同时约束模型对位置的理解。
第四处改进:Anchor 尺度的重设
YOLOv5 默认的 anchor 针对自然图像目标设计的,尺寸跨度是 10 到 300 像素。膝关节区域在标准化后的 X 光片里一般占 70% 以上的面积,远超默认锚框范围。我重新计算了训练集所有标注框的相对面积分布,把 anchor 统一放大 2.5 倍,比例从默认的 1:1、1:2、2:1 改成 0.7:1、1:1、1.3:1 附近,因为膝关节是近似方形略微横长。
设置完这四步之后,验证集上的 mAP(检测部分)从 0.88 提升到了 0.94,这是不改变实验设置、只做结构改动前后的对比。分级准确率变化更明显,后面单独说。
3.3 分类分支的 Loss 函数选择
KL 分级是一个有序多分类问题:2 级比 1 级更接近 3 级,而 5 分类交叉熵把所有类别视为等距离,这不符合医学语义。我在公共实验里测过三种 loss 的表现:
| Loss | 在验证集上的 QWK(加权 Kappa) | 平均分类准确率 |
|---|---|---|
| CrossEntropyLoss | 0.71 | 58.2% |
| Ordinal Loss(有序回归) | 0.79 | 60.5% |
| 改进版 soft-label + Ordinal Loss | 0.87 | 64.1% |
单纯用交叉熵的问题很明显:模型会把 1 级样本硬分到 0 级或 2 级,即使预测错,它自己觉得“错误距离”是一样的。换成 ordinal loss 之后,通过累积分布函数输出,模型学会的是 P(等级 > k) 的排序,监督信息直接和医学语义对齐。
但第三行的改进版是我认为最关键的一步:因为标注本身存在主观性,我采用带不确定性的软标签。具体做法是,让每个样本的标签不是 hard one-hot,而是按标注医生给出的置信率分配概率,例如一位医生认为某片子 90% 是 2 级、10% 是 3 级,那标签就是 [0, 0, 0.9, 0.1, 0]。这样模型不会在模糊边界样本上过拟合某一个绝对等级,让决策边界更平滑。
4. 训练过程与多个回合的踩坑记录
4.1 数据划分:分层抽样而不是随机划分
医学影像数据有很强的患者相关性,同一批患者的左右膝片子也不应该一个放训练集一个放测试集。我在划分数据集时按患者 ID 分组,同一患者的片子全部进同一个集合,避免数据泄漏。训练集、验证集、测试集的比例是 7:2:1,其中训练集按 KL 等级做了分层抽样,保证每个等级都有足够的样本。
实际项目里数据量大概在 1.2 万张左右,其中 0 级和 3 级的样本相对充足,1 级和 4 级偏少。这个分布是符合临床实际的——早发现和极晚期的病例本来就少,但我特意在 1 级和 2 级之间做了 oversampling,因为这个分界恰恰是误诊率最高的区域。
4.2 训练超参数的经验值
经过多轮实验,最终训练配置如下:
- 输入尺寸:640×640(在 512 和 768 之间折中,512 虽然快但 2 级/3 级细节丢失明显,768 显存吃紧且提升有限)
- batch size:32(单卡 3080Ti 可跑,24G 显存刚好)
- 优化器:AdamW,初始学习率 1e-4,采用 cosine 衰减到 1e-6
- 训练轮数:120 epoch,其中前 10 个 epoch 冻结检测分支参数,只训分类头,之后全部解冻联合微调
- 数据增强:随机旋转 ±15 度、水平翻转、随机亮度变化 ±20%、轻微随机缩放 0.8 到 1.2
这里要特别提一下冻结训练的事。如果一开始就全网络联合训练,检测分支会迅速收敛,但分类分支的梯度会被检测分支的梯度淹没,导致分级收敛极慢。先冻结检测,让分类头在固定特征上快速找到一个合理解,再解冻统一精调,可以显著加速收敛,效果也更稳定。
4.3 第一次遇到滑铁卢:全部片子的最低等级预测占比过高
第一次完整训练完,我看了测试集混淆矩阵,发现 0 级样本的召回率 89%,但 1 级样本的召回率只有 30%,大量 1 级都被分到了 0 级。当时第一反应是 loss 权重没调好,调高分类 loss 权重后改善依然有限。
后来细查才知道,问题出在标注:很多“真·0级”的片子关节间隙看起来也挺窄的,因为患者拍片时膝盖位置没摆正,或者股骨髁轻微旋转导致投影变化。模型把这种投影变形学成了“正常”,所以低等级样本的特征空间拉不开。
解决办法有三个:
- 对训练集中的 0 级和 1 级样本做边缘框裁剪,只保留关节正中央区域,减少腿骨旋转的干扰。
- 增加角度扰动增强,同时引入局部仿射变换,模拟投影角度偏差。
- 调整样本权重,把 1 级的损失放大 1.5 倍,迫使模型对模糊边界投入更多学习能力。
这一套组合下来,1 级样本的召回率从 30% 提到了 61%,虽然还是不算高,但已经明显改善。
4.4 检测框不稳定导致分级误判:NMS 阈值要针对性调
YOLOv5 默认的 NMS 阈值是 0.5,目标检测场景没问题,但我们的检测场景是单目标——一张图只有一个膝关节点。如果阈值设太高,同一个目标会出现多个重叠框;设太低又容易丢框。测试下来把 NMS 的 IoU 阈值调到0.7,置信度阈值调到0.35,可以在保证不漏检的同时基本消灭重复框。
有一类特殊样本:当关节变形严重(4级),检测框会缩得很小,只框住胫骨平台而漏掉整个股骨髁。我后来在检测分支后加了一个“框扩展后处理”:如果检测框宽度小于图宽 50%,就按中心向外扩展 1.3 倍,保证分类分支输入包含完整的关节区域。
这一步看起来简单,但实际把 4 级样本的分级准确率从 52% 拉到了 71%,效果非常明显。X 光片分类一旦定位框不完整,大量语义信息被截掉,分类器再强也没用。
4.5 类别不平衡的最终兜底方案
光靠数据增强和调整权重,类别不平衡问题解决得还是不到位。我额外用了两种方法:
- 困难样本挖掘:每训练完 10 个 epoch,用当前模型跑一遍训练集,找出那些预测置信度在 0.4-0.7 之间的“犹豫样本”,把它们单独抽出来按 1:2 的比例融合进下一轮训练批次。相当于让模型总在跟自己较劲。
- Focal Loss 变体:对分类分支的 loss 使用 focal loss 的简化版,gamma 设置为 1.2。如果一个样本的置信度已经很高(比如 0.95),focal loss 会把它的损失压得很低,让模型把精力放在模糊样本上。这个方法比直接调权重更平滑,不容易过采样逃逸出原来的封顶状态。
说实话,Focal Loss 参数在医学影像小数据集上非常敏感。gamma 设到 2.0 会导致严重的欠拟合,我测试下来 1.2 到 1.5 是比较合适的区间。如果你复现,建议先从 1.0 起步,观察验证集 QWK 变化,再微调。
5. 模型评估:只看准确率会骗了你
5.1 为什么用 QWK 而不是准确率
医疗影像分类任务,尤其是分级任务,准确率是一个不充分的指标。原因很简单:样本分布不均衡。假设测试集中 0 级占 60%,一个死模型把所有样本都预测成 0 级,准确率就有 60%,看起来“还不错”,实际一点用没有。
另一个问题,KL 分级的临床代价是非对称的:把 2 级误分为 3 级,可能导致患者接受不必要的治疗;把 4 级误分为 2 级,可能让重度患者耽误治疗。所以评价指标必须包含“预测值与真实值之间的偏差距离”,这时候 Quadratic Weighted Kappa(QWK)就派上用场了。它计算的是预测等级与真实等级之间偏差的加权一致性,预测等级离真实等级越远,惩罚越大。我们整个项目的最终优化目标,也一直是 QWK 而非简单准确率。
5.2 最终测试结果
在独立测试集(从未参与训练和验证调整的 1200 张片子上)的最终结果:
| 指标 | 数值 |
|---|---|
| QWK | 0.87 |
| 平均分类准确率 | 64.1% |
| 0级召回率 | 92% |
| 1级召回率 | 61% |
| 2级召回率 | 66% |
| 3级召回率 | 74% |
| 4级召回率 | 71% |
| 检测mAP | 0.94 |
| 单张平均推理时间(GPU) | 42ms |
从表格可以看到,1 级依然是最难分对的。这也是这个领域的普遍现象——轻度退变本来就没有绝对清晰的边界,就算两个医生独立看,也常常在 1/2 级之间打架。系统能达到 0.87 的 QWK 已经具备辅助诊断价值,但绝不能宣称“替代医生”。
5.3 错误样本的人工复盘
我把测试集中所有预测值和真实值偏差大于等于 2 级的样本挑出来,逐一人工看,归纳出三类高频错误场景:
- 膝关节摆放轻微旋转引发的投影变形,让关节间隙看起来比实际宽或者窄,模型容易在 0/1 级之间翻车。
- X 光片曝光过度导致关节间隙边界模糊,模型对细节的响应弱化。
- 患者存在其他退行性病变,比如骨质疏松或骨质增生背景,干扰模型的局部特征判断。
这三类场景的本质是:医学图像中的诊断信息并不完全由解剖结构决定,还高度依赖成像质量和拍摄标准。这也再次确认了我在第 2 部分强调的:医学影像模型,数据清洗和标准化比改网络结构更值得花精力。
6. 从模型到系统:实际部署和工程化遇到的问题
6.1 系统的整体结构
最终交付的是一个 Web 端辅助诊断系统。用户在浏览器上传 X 光片,后端调用推理接口,返回两个结果:检测框位置的骨关节炎分级结果、和显示在每个等级上概率的直方图,以及一段自动生成的诊断建议文本。
系统架构不算复杂,分三层:
- 前端:一个基于 Vue 的简单页面,支持拖拽上传、图片预览、结果渲染。
- 后端服务:基于 Python FastAPI,负责接收图片、预处理、调用 PyTorch 模型、返回 JSON。
- 推理模块:把训练好的 PyTorch 模型导出成 ONNX,用 ONNX Runtime 在 CPU 或者 GPU 上做推理。
6.2 模型导出踩的坑:Batch 维度和动态形状
把 PyTorch 模型转 ONNX 时,第一个遇到的问题就是 YOLOv5 输出头的动态形状。YOLO 的输出层存在一个非极大值抑制(NMS)过程,但这个 NMS 是不适合直接前向推理的。我处理的方式是:导出模型时只保留检测头和分类头的输出,NMS 步骤放到 ONNX 之外的代码里实现,用 NumPy 写一个轻量 NMS 函数。
如果你直接导出带 NMS 的模型,ONNX Runtime 会要求固定 batch 大小和 anchor 数量,推理时很容易因为输入图片尺寸不同报错。我的建议是:导出不含 NMS 的中间层输出,NMS 放在推理代码里做。这样灵活度高,排查问题也更容易。
6.3 推理性能优化
ONNX Runtime 默认使用的是 CPUExecutionProvider,在我的测试机上单张图片推理需要约 180ms,对于单用户网页版其实响应还能接受,但如果我们希望同时支持多用户并发,CPU 就扛不住了。我启用了 CUDAExecutionProvider 之后,单张推理降低到 42ms,提升了四倍多。
另一个优化点是预处理放到 GPU 上做,包括图像缩放、归一化。用torchvision.transforms的 GPU 版本,省掉了每次 CPU-GPU 间的数据拷贝。
不过要注意,ONNX Runtime 的 GPU 版本对算子支持有限。一定要在导出前检查模型里有没有自定义算子。我的模型结构相对简单,没有自定义算子,所以顺利导出。如果你加入了一些特殊模块(比如自定义激活函数),可能就要走 TensorRT 或者重新实现前向逻辑了。
6.4 和医生实际配合时的反馈
系统交付后小范围试用了一个月,收到的反馈很有价值:
- 医生最看重的不是分类结果本身,而是解释性。概率直方图比单一等级更有用,他们可以根据置信度判断是该采信还是存疑。
- 检测框的稳定性让医生很惊喜。有了框之后,医生能把结果和图像区域对应起来,不是为了看“机器的判断”,而是为了“确认机器判断的依据”。
- 医生提出一个改进需求:希望能自动对比同一患者在不同年份的片子,直接显示关节间隙宽度的变化量。这是后续迭代方向,但已经超出了当前的模型能力范围,会增加时序建模,不是简单改改结构能解决的。
6.5 部署后修复的一个隐蔽 bug:灰度图通道处理
X 光片是单通道灰度图,而 YOLOv5 预训练模型跑在 RGB 三通道输入上。我在预处理里做了单通道复制成三通道的步骤,这个没问题,但 ONNX 导出时输入节点名写错了,导致推理时形状不匹配,报错信息还特别难懂,查了一个下午才定位到。
这种问题很典型。模型训练时一切正常,一到导出部署就出现各种形状、维度、数据类型的兼容问题。建议你在做类似医学影像项目时,部署阶段不要追求一步到位,先在本地写一个最小化推理 demo,用同一张测试图跑通训练阶段输出和部署阶段输出是否一致,再接着做界面和接口。
7. 回到最初:这个系统的核心价值和我的几点体会
做完这个项目,我从头梳理了一遍。技术选型上,改进 YOLOv5 确实不是一个“热门”的选择,现在大家谈到医学影像,更多会提 Transformer、大模型预训练、多模态。但实际做工程,模型的价值除了精度,还要考虑可复现性、部署成本、计算资源。YOLOv5 的生态非常成熟,预训练权重多,社区工具链完善,遇到问题一搜就有解决方案。这些因素在一个实际项目中,远比“用了一个更新的模型”重要。
从效果来看,0.87 的 QWK 基本达到辅助诊断的可用线。但我也必须提一句,这种等级的模型还不能独立出报告。它更适合做预筛和“第二意见”,在小规模筛查场景里帮助医生提高效率,把精力集中在可能有问题的那一部分片子上。
如果让我再重来一次,我会在项目前期花更多时间收集 1 级和 2 级的多中心数据,而不是急着调网络结构。骨关节炎分级的瓶颈始终在数据和标注的边界问题上,模型结构改进带来的提升有上限,但数据分布的改善带来的提升没有上限。这个结论我做得越深越认同。
最后给想复现的同学一个起点建议:不用一上来就追求比我更多的改进点,先把标准 YOLOv5 在 KOA 数据集上跑通,把检测框稳定下来,再加分类分支,然后一步步调注意力模块。每一步改动都能对应到验证集指标的提升或下降,这样做出来的模型你心里才有底,出问题也知道是哪一步引入的。这个项目的完整思路到这里就告一段落,如果你正在做相近的医学影像课题,希望这篇能帮你少踩几个我踩过的坑。