1. 项目缘起与整体思路拆解
1.1 为什么盯上了这个细胞分类数据集
第一次接触这个方向,是因为手头有个医学图像分类的活儿,需要一套标注质量过硬、类别层次清晰的细胞图像数据。翻了一圈公开资源,发现大部分要么类别太粗,要么标注噪声大,直到看到这个宫颈细胞数据集,才算找到能真正拿来练手的东西。
这个数据集的核心价值在于两点:一是它把细胞按病理程度做了细粒度的类别划分,不是简单的“正常/异常”二分;二是它同时提供了特征文件和原始图像两条路径,这意味着你可以做传统机器学习,也可以直接上深度学习,两条路都能走通。对于想复现论文、又不想从零标注数据的人来说,这简直是省了半个月的活。
我这次复现的目标很明确:把论文里那套“特征+图像”双路分类的思路跑通,验证它在自己的环境里能不能达到论文报告的水平,顺便把踩到的坑记下来。适合谁看?如果你正在做医学图像分类、想找一套靠谱的基准数据,或者单纯想学怎么把特征工程和CNN结合起来,这篇记录应该能帮你少走弯路。
1.2 论文的核心思路:两条腿走路
论文最让我感兴趣的地方,是它没有死磕单一模态。传统做法要么只用人工特征喂给SVM、随机森林,要么直接把图像丢进CNN端到端训练。但这篇的思路是:特征和图像各走一条路,最后再融合决策。
为什么这么设计?我琢磨了一下,逻辑其实很实在。人工特征(比如形态学、纹理、颜色统计)擅长捕捉细胞核的面积、周长、凹凸度这些可解释的几何属性,对标注量要求低,训练快;而CNN擅长从像素里挖出人眼说不清的深层纹理和边缘模式,但需要大量数据才能训得稳。两条路各有盲区,融合起来正好互补。
具体到实现,论文里特征路径用的是经典的形状+纹理+颜色组合,图像路径则是一个轻量级CNN backbone。融合策略上,它没有搞复杂的注意力机制,而是用了相对朴素的决策级融合——两条路各自输出概率,再按权重加权。这个选择很务实,因为决策级融合对两条路的训练独立性要求低,调试起来也简单,不像特征级融合那样需要对齐维度和尺度。
提示:如果你打算复现,建议先把两条路单独跑通,确认各自准确率后再做融合。一上来就端到端调融合权重,出了问题你根本不知道是哪条路拖了后腿。
1.3 复现前需要想清楚的三个问题
动手之前,我先问了自己三个问题,后来证明这三个问题决定了整个复现的成败。
第一个是数据划分方式。论文里用的是按细胞类别分层抽样,但没明确说是否按“患者”维度做隔离。医学图像里同一个患者的细胞可能同时出现在训练集和测试集,这会导致准确率虚高。我后来采用了按样本来源分组的划分,虽然准确率掉了一点,但结果更可信。
第二个是特征提取的工具链。论文提到的形态学特征,用Python的scikit-image就能算,但纹理特征(比如GLCM、LBP)的参数设置论文没写全。我试了几组参数,发现GLCM的距离和角度对结果影响不小,最后固定了一组在验证集上表现最稳的。
第三个是图像预处理的一致性。特征路径和图像路径对输入的要求不一样:特征路径需要分割好的细胞核掩膜,图像路径需要归一化后的RGB图。如果预处理没对齐,融合时会出现“特征算的是A区域,图像看的是B区域”的尴尬。我的做法是先统一做细胞区域裁剪,再分别走两条预处理管线。
这三个问题想清楚之后,后面的实操就顺多了。下面我把整个流程拆开,一步步说。
2. 数据准备与核心细节解析
2.1 数据集结构长什么样
拿到数据的第一件事是摸清目录结构。这个数据集的组织方式比较规整,大致分三层:最外层是类别文件夹,每个类别下面是若干细胞样本,每个样本包含一张原始图像和一个对应的掩膜文件。类别数量在论文里有明确说明,我这里不重复具体数字,但可以告诉你,类别之间样本量并不均衡,最多的类大概是最少类的三到四倍。
这种不均衡在医学数据里太常见了,直接训练会让模型偏向多数类。我的处理办法是分层抽样+类别权重双管齐下:划分数据时保证每个类在训练集和测试集里的比例一致,训练时再给少数类更高的损失权重。实测下来,这比单纯过采样效果稳,因为过采样容易让少数类过拟合。
特征文件那边,数据集提供了一份预先算好的特征表,每行对应一个细胞样本,列是各种数值特征。我对比了一下自己算的特征和它提供的,发现大部分能对上,但有几个纹理特征的量纲不太一样,估计是计算时的归一化方式不同。所以我的建议是:要么全用官方特征,要么全自己算,别混着用,否则融合时尺度对不齐。
2.2 特征路径:手工特征到底怎么算
特征路径的核心是把每个细胞变成一个固定长度的向量。论文里提到的特征大致分三组,我逐个说下怎么落地。
形态学特征是最直观的,基于细胞核掩膜就能算。常用的有面积、周长、圆形度、凹凸度、长短轴比。这里有个坑:面积和周长的绝对值受图像分辨率影响很大,如果不同批次的图像分辨率不一致,这两个特征就会引入噪声。我的做法是对面积做图像总像素数的归一化,周长则除以等效圆的周长,这样得到的是相对值,跨批次更稳。
纹理特征稍微麻烦点。论文用了GLCM(灰度共生矩阵)和LBP(局部二值模式)。GLCM的关键参数是距离和角度,我试了距离取1、2、4,角度取0、45、90、135四个方向,最后发现距离取2、四个方向取均值和方差的效果最好。LBP的话,半径和邻域点数需要调,我用的是半径1、邻域8,这个组合在细胞纹理上区分度不错。
颜色特征在细胞分类里容易被忽视,但其实有用。我提取了细胞区域在RGB和HSV空间的均值和标准差,一共12维。注意,颜色特征对染色批次很敏感,如果数据来自不同染色条件,最好先做颜色归一化,比如用Macenko方法或者简单的灰度世界假设。
把这三组拼起来,一个细胞大概能得到60到80维的特征向量。维度不算高,用SVM或者随机森林都能跑得动。
2.3 图像路径:CNN backbone怎么选
图像路径这边,论文用的是一个轻量级CNN,具体结构没完全公开,但从参数量推断,应该是类似ResNet-18或MobileNet这个量级的。我复现时选了ResNet-18,原因是它在小数据集上不容易过拟合,而且预训练权重好找。
输入图像的处理有几个细节要注意。首先是尺寸,论文里没说具体resize到多少,我试了224和128两种,224的准确率略高但训练慢一倍,128在加了数据增强之后差距不大,最后选了128。其次是归一化,我用的是ImageNet的均值和标准差,虽然细胞图像和自然图像分布不同,但预训练权重是在ImageNet上训的,保持一致的归一化能让微调更顺。
数据增强方面,我用了随机水平翻转、随机旋转15度、随机裁剪加缩放。注意,垂直翻转要慎用,因为细胞图像有方向性,上下颠倒可能改变形态学含义。颜色抖动我也试了,但效果不稳定,最后去掉了。
训练策略上,我用的是Adam优化器,学习率1e-4,batch size 32,跑了50个epoch,在第30个epoch左右验证集准确率就基本饱和了。损失函数用的是带类别权重的交叉熵,和特征路径那边的权重保持一致。
2.4 两条路怎么融合才不打架
融合策略我试了三种:早期融合(特征拼接后一起分类)、中期融合(CNN中间层特征和手工特征拼接)、决策级融合(各自输出概率后加权)。
早期融合的问题最明显:手工特征和CNN特征的尺度、分布差异太大,直接拼接后模型很难学。我试过标准化后再拼,效果依然不如单独跑。中期融合稍微好点,但需要改网络结构,调试成本高。
最后我选了决策级融合,具体做法是:特征路径用SVM输出每个类的概率,图像路径用CNN的softmax输出概率,然后按权重加权。权重的确定我用的是网格搜索,从0.1到0.9,步长0.1,在验证集上找最优。实测下来,特征路径权重0.4、图像路径0.6的时候效果最好,比任何单一路径都高了几个百分点。
注意:决策级融合的前提是两条路的输出概率都是“校准”过的。SVM的输出概率需要用Platt scaling校准,CNN的softmax一般还好,但如果用了标签平滑,概率分布会偏平,需要重新校准。
2.5 评估指标不能只看准确率
医学图像分类里,准确率是最容易骗人的指标。类别不均衡的时候,模型全预测多数类也能有不错的准确率。所以我额外看了宏平均F1和每类的召回率。
宏平均F1对少数类更敏感,能反映模型是不是真的学到了少数类的特征。每类召回率则能告诉你哪些类容易被混淆。我跑下来发现,两个形态相近的类别之间混淆最多,后来专门针对这两类做了难例挖掘,把混淆样本挑出来重新训练,召回率提了不少。
另外,我还画了混淆矩阵,直观看到错误集中在哪些类对之间。这个图在论文里通常会有,但复现时自己画一遍,对理解模型行为帮助很大。
3. 实操过程与核心环节实现
3.1 环境搭建与依赖安装
环境这块我踩过坑,所以先说清楚。我用的是Python 3.8,主要依赖包括:scikit-image做特征提取,scikit-learn做SVM和评估,PyTorch做CNN,OpenCV做图像预处理。版本上,scikit-image建议0.19以上,PyTorch建议1.12以上,避免一些API不兼容。
安装命令大概是这样:
pip install scikit-image scikit-learn torch torchvision opencv-python numpy pandas matplotlib如果你有GPU,记得装对应CUDA版本的PyTorch。我一开始装了CPU版,训练一个epoch要十几分钟,换成GPU后降到一分钟以内,差距巨大。
提示:特征提取那部分其实CPU就够了,因为计算量不大。但CNN训练强烈建议用GPU,否则调参周期会拉得很长。
3.2 数据加载与预处理流水线
数据加载我写了一个自定义的Dataset类,同时返回图像和手工特征。这样在训练CNN的时候,可以顺便把特征也取出来,方便后面融合。
预处理的流程分两条线:
图像线:读原图 -> 转RGB -> resize到128x128 -> 转Tensor -> 用ImageNet均值方差归一化。
特征线:读掩膜 -> 计算形态学特征 -> 在灰度图上算GLCM和LBP -> 在RGB和HSV上算颜色统计 -> 拼接成向量 -> 标准化。
这里有个细节:特征标准化用的均值和方差,必须只在训练集上算,然后应用到验证集和测试集。如果全量数据一起算,会引入数据泄露,测试准确率会虚高。
数据划分我用的是分层抽样,训练:验证:测试 = 7:1.5:1.5。划分时固定随机种子,保证可复现。
3.3 特征路径的训练与调参
特征路径我用的是SVM,核函数选RBF。调参主要调两个:C和gamma。我用的是网格搜索,C从0.1到100,gamma从0.001到1,5折交叉验证。最后选出的C大概是10,gamma是0.01。
训练完之后,我用Platt scaling校准了概率输出。校准前后的准确率差不多,但概率分布更合理了,融合时效果更好。
这里有个经验:特征标准化对SVM的影响很大。我一开始忘了标准化,准确率比标准化后低了将近10个百分点。因为形态学特征的数值范围差异很大,面积可能是几千,圆形度是0到1,不标准化的话RBF核会被大数值特征主导。
3.4 图像路径的训练与微调
CNN训练我分了两个阶段:先冻结backbone只训分类头,再解冻全部微调。
第一阶段跑了10个epoch,学习率1e-3,让分类头先适应数据。第二阶段跑了40个epoch,学习率降到1e-4,避免破坏预训练权重。这个两阶段策略在小数据集上很管用,比直接端到端微调稳定得多。
数据增强我是在线做的,每个epoch随机变换。验证集不做增强,只做resize和归一化。
训练过程中我监控了训练损失和验证损失。如果验证损失连续5个epoch不降,就降低学习率。这个策略帮我省了不少调参时间。
3.5 融合决策的实现细节
融合那部分代码不复杂,核心就是加权平均。但有几个细节要注意:
第一,两条路的类别顺序必须一致。我一开始SVM的类别标签是字符串,CNN的是数字索引,融合时对不上,排查了半天。后来统一用数字索引,问题解决。
第二,权重的搜索要在验证集上做,不能在测试集上做。我见过有人直接在测试集上找最优权重,然后报告那个结果,这是典型的数据泄露。
第三,融合后的概率要重新归一化,保证和为1。虽然加权平均后一般还是和为1,但如果权重不是和为1,就需要手动归一化。
我最终的融合公式是:P_final = 0.4 * P_svm + 0.6 * P_cnn,然后取argmax作为预测类别。
3.6 完整跑一遍的结果记录
我完整跑了一遍,记录了几个关键数字。特征路径单独跑,宏平均F1大概在0.85左右;图像路径单独跑,宏平均F1在0.88左右;融合之后,宏平均F1到了0.92。这个提升幅度和论文报告的差不多,说明复现是成功的。
每类的召回率我也看了,多数类的召回率都在0.9以上,少数类稍微低一点,但也在0.8以上。混淆主要集中在两个形态相近的类之间,这个和论文里的分析一致。
训练时间上,特征提取大概花了20分钟,SVM训练几秒钟,CNN训练在GPU上大概40分钟。整体下来一个多小时能跑完一轮,调参的话时间会长一些。
4. 常见问题与排查技巧实录
4.1 特征提取时的典型报错
问题一:掩膜和图像尺寸不匹配。这个报错很常见,原因是数据集里有些样本的掩膜和原图分辨率不一致。解决办法是先检查所有样本的尺寸,对不匹配的做resize对齐。我写了个脚本批量检查,发现大概有5%的样本有问题。
问题二:GLCM计算时灰度级数太多导致内存溢出。GLCM的灰度级数默认是256,对于大图像会生成256x256的矩阵,多个角度叠加后内存占用很大。解决办法是把灰度图量化到16或32级,对纹理特征的影响很小,但内存占用降了一个数量级。
问题三:LBP报“图像数据类型不支持”。LBP要求输入是uint8类型,如果图像是float类型会报错。解决办法是在调用前先转成uint8,注意归一化后的float要乘255再转。
4.2 CNN训练不收敛的排查思路
CNN不收敛的原因很多,我按排查顺序列一下:
首先看学习率。学习率太大,损失会震荡甚至发散;太小,损失下降很慢。我一般先用1e-3试,如果损失不降就降到1e-4。
其次看数据归一化。如果忘了归一化,或者归一化参数不对,CNN很难训。检查方法是打印输入图像的均值和方差,应该在0附近和1附近。
然后看标签。如果标签有错位或者类别索引从1开始而不是0,会导致损失异常。我遇到过标签从1开始的情况,CrossEntropyLoss要求标签从0开始,改过来就好了。
最后看batch size。batch size太小,梯度噪声大,训练不稳定;太大,泛化可能变差。我一般用32或64。
4.3 融合后效果反而变差的可能原因
融合后效果变差,通常有这几个原因:
一是两条路的准确率差距太大。如果一条路准确率0.9,另一条0.6,融合时低准确率的那条会拖后腿。解决办法是调整权重,给高准确率的路更高权重,或者干脆只用一条路。
二是概率未校准。SVM的输出概率如果不校准,可能集中在0.5附近,融合时贡献很小。校准之后概率分布更分散,融合效果更好。
三是类别顺序不一致。这个前面提过,但值得再强调,因为排查起来很费时间。建议在融合前打印两条路的类别映射,确认一致。
四是数据泄露导致单路虚高。如果单路评估时用了测试集调参,单路准确率会虚高,融合后反而下降。确保所有调参都在验证集上做。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
| 特征提取报尺寸不匹配 | 掩膜与原图分辨率不一致 | 批量检查样本尺寸 | resize对齐 |
| GLCM内存溢出 | 灰度级数过多 | 查看图像灰度范围 | 量化到16或32级 |
| LBP数据类型报错 | 输入非uint8 | 打印图像dtype | 转uint8 |
| CNN损失不降 | 学习率过大或过小 | 打印损失曲线 | 调整学习率 |
| CNN损失异常 | 标签未从0开始 | 打印标签范围 | 标签减1 |
| 融合后效果变差 | 概率未校准 | 查看概率分布 | Platt scaling校准 |
| 融合后效果变差 | 类别顺序不一致 | 打印类别映射 | 统一类别索引 |
| 测试准确率虚高 | 数据泄露 | 检查调参流程 | 只在验证集调参 |
4.5 几个让我印象深刻的坑
第一个坑是特征标准化用了全量数据。我一开始图省事,把训练集、验证集、测试集拼在一起算均值和方差,结果测试准确率比预期高了5个百分点。后来改成只用训练集算,准确率掉回正常水平,但结果更可信了。
第二个坑是CNN的输入尺寸和预训练权重不匹配。ResNet-18的预训练权重是在224x224上训的,我一开始输入128x128,虽然能跑,但第一层卷积的感受野对不上,效果打了折扣。后来改成先resize到224再随机裁剪到128,效果好了一些。
第三个坑是融合权重在测试集上搜。这个错误我犯过一次,发现后重新在验证集上搜,虽然最终数字低了一点,但心里踏实。
第四个坑是忘了固定随机种子。有一次调参调了半天,结果每次跑出来的数字都不一样,排查后发现是没固定种子。固定之后,结果可复现了,调参效率高了很多。
4.6 提升效果的两个实用技巧
第一个技巧是难例挖掘。训练完一轮后,把验证集里预测错误的样本挑出来,分析它们的共同点。我发现错误样本大多是细胞核边界模糊或者染色较浅的,后来针对性地加了对比度增强,这部分样本的召回率提了不少。
第二个技巧是模型集成。除了特征和图像的融合,我还试了多个CNN seed的集成,把三个不同随机种子训练的CNN概率平均,准确率又提了一个百分点。这个技巧在比赛里很常用,代价是训练时间翻倍。
提示:难例挖掘和模型集成都是提效的利器,但前提是单路已经调得差不多了。如果单路本身还有明显问题,先解决单路,再考虑这些进阶技巧。
4.7 复现论文时的心态建议
复现论文最忌讳的是“一步到位”的心态。我见过不少人,上来就想把论文的所有细节都复现出来,结果卡在某个小环节上,几天都推进不下去。
我的建议是先跑通再优化。先把数据加载、特征提取、CNN训练、融合决策这条主线跑通,哪怕效果差一点也没关系。跑通之后,再逐个环节优化,每次只改一个变量,看效果变化。
另外,论文没写清楚的细节,不要死磕。论文受篇幅限制,很多参数不会写全。这时候根据常识和实验来定就行,没必要追求和论文完全一致。复现的目的是理解方法、验证思路,不是逐字逐句对齐。
最后,记录每一次实验。我用的是简单的表格,记录每次改了什么、结果如何。这样回头看的时候,能清楚知道哪些改动有效、哪些无效,避免重复踩坑。
这个内容后续还可以这样扩展:一是把特征路径换成深度特征,看看和手工特征比效果如何;二是试试多模态融合的其他策略,比如注意力加权;三是把方法迁移到其他细胞分类任务上,验证泛化性。这些我后面有空会继续折腾,到时候再记录。