1. 是什么让我动手做这个皮肤癌检测系统
说实话,图像检测这个方向我做过不少,从工业质检到遥感影像都碰过,但真正让我觉得"这玩意儿能救人"的,还是这套基于支持向量机SVM的皮肤癌检测系统。皮肤癌和普通疾病不太一样,早期发现和晚期发现的生存率差距大得吓人,而皮肤镜图像的判读又高度依赖医生的经验,基层医疗机构和偏远地区的皮肤科医生缺口很现实。于是我当时就想:能不能用一篇Matlab代码把"看图分类"这件事自动化,让非专业的人也能快速拿到一个初步判断?
这套系统的核心思路并不复杂:输入一张皮肤病变图像,预处理之后提取特征,然后扔给SVM分类器去判断这是良性还是恶性。为什么选SVM而不是深度学习?这个我放到第三节细说。简单来讲,SVM在小样本、高维特征的情况下表现非常稳,而且Matlab工具箱里实现得很成熟,不需要显卡、不需要搭环境,一台普通办公电脑就能跑完训练和推理,非常适合快速搭建原型验证坑。整个项目做完之后,我最大的感受是:真正难的不是训练模型的几行代码,而是图像预处理和特征工程那一大堆琐碎的工作。
这篇博文面向谁?如果你正在做本科毕业设计,或者想入门机器学习图像分类但不想一上来就碰深度学习框架,或者你已经用Python写腻了想看看Matlab这边怎么做——这篇文章都会对你有用。我不会只贴一段能跑的训练代码然后让你自己折腾,而是把从数据准备到模型评估的整个过程拆开揉碎,包括那些代码注释里不会告诉你的坑。
2. 数据准备与特征工程:图像到底该喂什么给SVM
2.1 数据从哪里来,怎么组织
做任何图像分类项目,第一步永远不是写模型,而是整理数据。我之前见过很多人上来就load一张图开始训,最后准确率上不去又不知道问题出在哪。这套系统我使用的是公开的皮肤镜图像数据集,里面包含良性和恶性两类病变图像,文件结构长这样:
skin_data/ ├── train/ │ ├── benign/ │ │ ├── img_001.jpg │ │ ├── img_002.jpg │ │ └── ... │ └── malignant/ │ ├── img_001.jpg │ ├── img_002.jpg │ └── ... └── test/ ├── benign/ └── malignant/这种按类别分文件夹的组织方式看起来很简单,但有个讲究:Matlab的imageDatastore可以直接读这种结构,自动把文件夹名映射成标签,能省掉你手动写标签文件的时间。如果你拿到的数据是那种一张CSV记录文件名和标签的格式,自己写个循环读取也很快,但我不建议你把所有图片和标签混在一个文件夹里,后期做交叉验证时会非常难受。
有一点必须强调:训练集和测试集的分割要保证类别比例一致。我见过有同学直接把所有图片扔进去随机划分,结果恶性图像占比过低,模型训练时几乎没见到几个正样本,测试时又全被分到良性那一侧,整体准确率虚高到90%以上,一算召回率直接崩。正确做法是按类别分别划分,每类内部随机取80%训练、20%测试。
2.2 预处理:把图像统一到同一个表达空间
皮肤镜图像不像普通照片,拍摄设备、光照条件、患者肤色差异都会让同一类病变在像素层面差异巨大。如果直接把原始图像缩放到224x224喂给SVM,SVM这种基于距离度量的模型会非常痛苦。所以预处理这一步的目标是:尽量抹掉与病变无关的干扰因素,突出组织结构、颜色分布的差异。
我在这套系统里做的预处理流程依次是:
- 图像缩放:统一到256x256,避免不同分辨率导致特征维度不一致。
- 去噪:用中值滤波做一次平滑,皮肤镜图像的高频噪声用中值滤波比高斯滤波效果好,原因在于中值滤波在去除孤立噪声点的同时能保留边缘信息,而高斯滤波会把边界也给模糊掉。
- 颜色空间转换:这是最关键的一步。RGB空间对光照变化太敏感,而LAB颜色空间中L通道只管亮度、A和B通道只含色彩信息,分离得彻底。皮肤病变的颜色特征(比如黑色素瘤常见的不规则色素网络)在LAB空间下比RGB空间更容易被SVM抓到。我实测下来,用LAB空间的A、B通道代替RGB直接作为特征输入,准确率大约能提升4到6个百分点。
- 分割ROI:把图像的背景(黑色边框、皮肤正常区域)尽量去掉,只保留病变区域。这一步我用的是简单的阈值分割加形态学开闭运算。不要追求百分百精确,因为SVM不是像素级分类器,ROI的微小误差影响不大,但大面积的背景残留会显著干扰颜色直方图特征。
2.3 特征提取的组合拳:颜色矩、纹理与分块统计
SVM本身不具备从原始像素中自动学习特征表示的能力,这也是它和CNN的本质区别。所以你需要人为地告诉模型"该看图像的哪些指标"。我在系统里提取了三组特征,串联成一个一维向量作为SVM的输入:
- 颜色矩:对每个通道(LAB的L、A、B三个通道)计算一阶矩(均值)、二阶矩(标准差)、三阶矩(偏度)。这是最简单的颜色全局特征,9个维度,计算量几乎为零,但对整体色调变化非常敏感。
- 灰度共生矩阵GLCM特征:把彩色图转灰度,计算四个方向(0°、45°、90°、135°)的GLCM,然后从每个GLCM中提取对比度、相关性、能量、同质性四个标量。这组特征描述的是纹理粗糙度和方向性,黑色素瘤常见的"不规则纹理"在这组特征上反应很明显。
- 分块颜色直方图:把图像划分成4x4的网格,每块分别计算LAB三通道的直方图,然后拼接。这个特征保留了颜色的空间分布信息,能捕捉到"色素网络分布不规则"这类局部变化。
这样组合下来的特征向量维度大约是9 + 16 + 48 = 73维,不算高,SVM处理起来毫无压力。需要提醒的是,特征提取时一定要用训练集的数据做标准化参数拟合。我举个例子,标准化就是用训练集算出的均值和标准差去减、除,如果你把测试集混进来一起算均值标准差,就相当于提前让模型"偷看了"测试数据,测试准确率会虚高,部署到真实场景立刻现原形。
3. SVM训练与Matlab实现的关键代码拆解
3.1 为什么选SVM:与深度学习的取舍
在动手之前,先聊一个绕不开的问题:现在深度学习这么流行,做个图像分类为什么还用SVM?我当时的考虑非常实际:
- 数据量:公开数据集里带精确标注的皮肤镜图像,规模通常在几千到一万张级别。这点数据量对CNN来说勉强够用,但很容易过拟合;对SVM来说,几千个样本学一个决策边界绰绰有余。
- 可解释性:SVM的训练结果就是支持向量和权重,你可以清楚看到哪些样本点在支撑决策边界,甚至可以画出二维投影来观察分类器怎么工作。这在论文审稿和答辩时是加分项。
- 资源需求:深度学习训练要GPU、要CUDA、要调参,SVM在Matlab里一行fitcsvm就完了,CPU跑几秒到几十秒,迭代实验的成本低得让人感动。
- 硬件约束:太多场景下根本没有GPU可选。
当然SVM也有它的短板——对复杂非线性问题的表达能力不如深度学习。但在这个场景里,如果特征工程做得足够好,SVM的表达能力已经完全够用了。
3.2 完整的训练脚本:从特征矩阵到模型保存
下面是这套系统的核心训练部分,Matlab代码可以直接跑:
%% 皮肤癌SVM分类器训练脚本 clc; clear; close all; % 1. 读取训练数据 trainFolder = 'skin_data/train'; imdsTrain = imageDatastore(trainFolder, ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames'); % 2. 提取特征 trainFeatures = []; trainLabels = []; for i = 1:numel(imdsTrain.Files) img = readimage(imdsTrain, i); img = imresize(img, [256, 256]); % 中值滤波去噪 img = medfilt3(img, [3 3 1]); % LAB颜色空间转换 labImg = rgb2lab(img); % 调用自定义特征提取函数 feat = extractSkinFeatures(labImg); trainFeatures = [trainFeatures; feat]; trainLabels = [trainLabels; imdsTrain.Labels(i)]; end % 3. 特征标准化 [mu, sigma] = computeStandardizationParams(trainFeatures); trainFeaturesNorm = (trainFeatures - mu) ./ sigma; % 4. 训练SVM模型 svmModel = fitcsvm(trainFeaturesNorm, trainLabels, ... 'KernelFunction', 'rbf', ... 'BoxConstraint', 1, ... 'KernelScale', 'auto', ... 'Standardize', false, ... 'ClassNames', {'benign', 'malignant'}); % 5. 保存特征提取参数和模型 save('skinSVM.mat', 'svmModel', 'mu', 'sigma'); disp('模型训练完成并已保存至 skinSVM.mat');这个脚本里fitcsvm那几行参数值得逐个说清楚。KernelFunction选rbf(径向基核)是因为皮肤癌图像特征的类别边界通常不是线性的,rbf核可以把特征映射到高维空间寻找曲面边界。BoxConstraint是正则化强度的倒数,它控制"模型允许在训练集上犯多少错"来换取更平滑的决策边界。这个参数设得越大,模型对训练集的拟合越用力,但过拟合风险也越高。KernelScale如果设为'auto',Matlab会自动用启发式算法估计一个合适的尺度参数,省事但未必最优,后面第五节我会讲怎么手动调它。
3.3 特征提取函数:核心代码与设计意图
extractSkinFeatures这个函数是整个系统的特征来源,值得单独写一段。它的代码是这样的:
function feat = extractSkinFeatures(labImg) % 输入: LAB颜色空间图像 % 输出: 73维特征向量 % 通道分离与颜色矩 L = labImg(:, :, 1); A = labImg(:, :, 2); B = labImg(:, :, 3); colorMoments = [ mean(L(:)), std(L(:)), skewness(L(:)), ... mean(A(:)), std(A(:)), skewness(A(:)), ... mean(B(:)), std(B(:)), skewness(B(:)) ]; % GLCM纹理特征 grayImg = rgb2gray(lab2rgb(labImg)); glcm = graycomatrix(grayImg, 'Offset', [0 1; -1 1; -1 0; -1 -1]); stats = graycoprops(glcm, {'Contrast', 'Correlation', 'Energy', 'Homogeneity'}); glcmFeatures = [mean(stats.Contrast), mean(stats.Correlation), ... mean(stats.Energy), mean(stats.Homogeneity)]; % 4x4分块颜色直方图 blockFeatures = []; blockSize = [64, 64]; for row = 0:3 for col = 0:3 block = labImg(row*64+1 : row*64+64, col*64+1 : col*64+64, :); histA = histcounts(block(:, :, 2), 4); histB = histcounts(block(:, :, 3), 4); blockFeatures = [blockFeatures, histA, histB]; end end feat = [colorMoments, glcmFeatures, blockFeatures]; end这个函数的设计思路有几个关键点。第一,颜色矩只用了L、A、B三通道的低阶统计量,虽然简单,但对整体色调异常敏感,恶性黑色素瘤在LAB空间的A、B通道偏置方向和良性痣有明显差异。第二,GLCM我用的是四个方向的平均值而不是单独某个方向,原因在于皮肤病变的纹理方向性并不固定,取平均能把方向依赖降到最低。第三,分块直方图的bin数只取4,不是舍不得信息量,是因为bin数过多会让特征维度爆炸,同时容易过拟合。我测试过bin取8的时候训练准确率上去了3%,但交叉验证准确率反而掉了5%,典型的过拟合信号。
4. 系统评估:混淆矩阵、ROC与临床场景的可靠性验证
4.1 交叉验证:别被单个测试集的数字骗了
模型训练完,第一件事不是跑测试集,而是先做K折交叉验证。我在这套系统里用5折交叉验证,方法如下:
% 5折交叉验证 cv = cvpartition(trainLabels, 'KFold', 5); cvAccuracy = zeros(cv.NumTestSets, 1); for i = 1:cv.NumTestSets trainIdx = cv.training(i); testIdx = cv.test(i); tempModel = fitcsvm(trainFeaturesNorm(trainIdx, :), trainLabels(trainIdx), ... 'KernelFunction', 'rbf', 'BoxConstraint', 1, 'KernelScale', 'auto'); predLabels = predict(tempModel, trainFeaturesNorm(testIdx, :)); cvAccuracy(i) = sum(predLabels == trainLabels(testIdx)) / numel(predLabels); end fprintf('交叉验证平均准确率: %.2f%%\n', mean(cvAccuracy) * 100);为什么非要交叉验证?因为你的测试集只有几百张图,模型在这几百张上的表现可能恰好很好或恰好很差,单次划分的结果方差太大。5折交叉验证等于是用五个不同组合的"训练/验证"来估计模型泛化能力,这个平均值比你单跑一次测试集可信得多。我第一次跑这个项目时,单次划分测试准确率89%,交叉验证平均只有84%,差距很明显,后者才是真实水平。
4.2 混淆矩阵:准确率不告诉你的信息
准确率这个指标在医学图像场景里有很大的欺骗性。假设测试集里良性和恶性的比例是85:15,一个把所有图像都预测为良性的"傻瓜模型",准确率也有85%。但临床医生需要的是"别漏掉恶性",也就是恶性肿瘤的召回率(真阳性率)要高,哪怕牺牲一点精度。所以评估时我建议你输出完整的混淆矩阵:
% 测试集评估 testFeatures = []; testLabels = []; % ... 循环读取测试集并提取特征,与训练时相同 ... testFeaturesNorm = (testFeatures - mu) ./ sigma; predLabels = predict(svmModel, testFeaturesNorm); % 混淆矩阵 confMat = confusionmat(testLabels, predLabels); disp('混淆矩阵:'); disp(confMat); % 自定义计算指标 TP = confMat(2,2); % malignant正确预测数 FN = confMat(2,1); % malignant漏检数 FP = confMat(1,2); % benign误报数 TN = confMat(1,1); % benign正确预测数 sensitivity = TP / (TP + FN); % 召回率 specificity = TN / (TN + FP); % 特异度 precision = TP / (TP + FP); % 精确率 f1Score = 2 * precision * sensitivity / (precision + sensitivity);我最终跑出来的结果,交叉验证的召回率在86%左右,特异度在88%左右。这个数字放到临床辅助诊断场景里够不够用?坦白讲,作为独立的自动诊断系统还远不够,但作为医生阅片的辅助参考,提示"这个病例高度可疑,请重点复核",价值是实实在在的。如果你论文或者报告里要用这个系统,建议把召回率放在第一屏,而不是准确率,因为召回率才是这个场景下真正重要的指标。
4.3 ROC曲线与AUC:理解分类器的"可调节性"
SVM输出的不是直接的类别标签,而是每个样本到决策边界的距离分数。fitcsvm默认用predict返回分类标签,但你可以用fitSVMPosterior把分数转换成后验概率,或者直接读它的原始分数来画ROC曲线:
% 获取决策分数 [~, score] = predict(svmModel, testFeaturesNorm); % 对于二分类SVM,score第二列是正类(malignant)的分数 [Xroc, Yroc, ~, AUC] = perfcurve(testLabels, score(:, 2), 'malignant'); figure; plot(Xroc, Yroc); xlabel('假阳性率'); ylabel('真阳性率'); title(sprintf('ROC曲线, AUC=%.3f', AUC));ROC曲线的意义在于它展示了分类器在不同阈值下的表现,而AUC则是"随机抽一个正样本和一个负样本,模型把正样本排在负样本前面的概率"。AUC不受类别不平衡影响,比准确率稳健得多。我优化完特征组合之后AUC大约在0.92到0.94之间。如果你看到某个模型的准确率很高但AUC在0.7以下,那大概率是类别不平衡造成的虚假繁荣,ROC才是那个照妖镜。
5. 踩坑实录:调参、类别不平衡与推理速度优化
5.1 网格搜索:KernelScale和BoxConstraint该怎么设
很多教程讲到这里就停了,但实际应用中模型的参数很少能一把调好。我用的方法是网格搜索加交叉验证,把KernelScale和BoxConstraint分别设几个候选值,穷举组合找到交叉验证最优的组合:
kernelScales = [0.5, 1, 2, 5, 10]; boxConstraints = [0.1, 1, 10, 50]; bestCV = 0; bestParams = []; for ks = kernelScales for bc = boxConstraints tempModel = fitcsvm(trainFeaturesNorm, trainLabels, ... 'KernelFunction', 'rbf', ... 'BoxConstraint', bc, ... 'KernelScale', ks, ... 'CrossVal', 'on', 'KFold', 5); cvAcc = kfoldLoss(tempModel, 'LossFun', 'classiferror'); cvAcc = 1 - cvAcc; if cvAcc > bestCV bestCV = cvAcc; bestParams = [ks, bc]; end fprintf('KernelScale=%.1f, BoxConstraint=%.1f, CV Acc=%.2f%%\n', ... ks, bc, cvAcc * 100); end end fprintf('最优参数: KernelScale=%.1f, BoxConstraint=%.1f, 交叉验证准确率=%.2f%%\n', ... bestParams(1), bestParams(2), bestCV * 100);网格搜索听起来是笨办法,但在特征维度只有73维、样本量几千的情况下,穷举也就十几秒的事情,不值得用贝叶斯优化那套花活。我调参时发现一个规律:KernelScale太大(超过10)时模型变得过于平滑,几乎所有样本都被划为良性,训练集和测试集准确率同步下跌;BoxConstraint太大(超过50)时训练集准确率几乎100%,但交叉验证准确率骤降,这是过拟合的典型特征。最终我选到的组合大概是KernelScale=2、BoxConstraint=1,大家可以根据自己的数据重新搜索,没有一个参数组合是放之四海皆准的。
5.2 类别不平衡:用权重和采样策略纠偏
医学数据普遍存在类别不平衡,恶性样本往往远少于良性样本。如果你的数据集里恶性图像只有良性图像的五分之一,SVM会倾向于把边界划向样本多的一侧,导致恶性样本召回率很低。我做了两类处理:
- 类别权重:给少数类(恶性)更高的误分类惩罚。fitcsvm里通过'Cost'参数实现,把"把恶性分为良性"的代价设为5,把"把良性分为恶性"的代价设为1。
- 随机欠采样/过采样:如果不希望修改代价矩阵,可以在训练前分别对每类样本做操作。欠采样是从多数类里随机抽一部分,让两类数量一致,损失的是部分良性样本信息;过采样是对少数类做简单复制,不会增加新信息但能平衡训练过程。
实际效果上,代价矩阵的方法更平滑,不会丢失数据,我推荐优先尝试。但代价矩阵的倍数需要调,不是越大越好,代价倍数过头会让模型矫枉过正,把一大堆良性也判成恶性,特异度崩掉。
5.3 部署阶段没有说的坑:推理速度与代码集成
模型训练完成后,部署到实际使用场景时还有几个容易被忽略的问题。第一个是推理速度。我在一个包含800张图像的真实场景里测试,每张图的预处理加特征提取大约耗时0.3秒左右,其中瓶颈在rgb2lab转换和分块直方图计算。如果未来要接实时摄像头之类的场景,建议用Matlab Code Generation把特征提取部分编译成C代码,或者把图像缩小到128x128,特征提取速度能快一倍不止,准确率下降很有限。
第二个问题是Matlab的GUI界面集成。如果你要做一个可以导入图片、显示诊断结果的界面,推荐用MATLAB App Designer而不是老旧的GUIDE。界面逻辑非常简单,拖一个"导入图像"按钮、一个"开始诊断"按钮、一个坐标轴显示原图和一个文本标签显示结果。回调函数里调用readimage、预处理、特征提取、predict四个步骤,十几行代码的事。具体代码结构如下:
% 按钮回调函数示例 function importButtonPushed(app, event) [file, path] = uigetfile({'*.jpg;*.png;*.bmp'}, '选择皮肤镜图像'); if isequal(file, 0) return; end imgPath = fullfile(path, file); app.img = imread(imgPath); imshow(app.img, 'Parent', app.ImageAxis); end function diagnoseButtonPushed(app, event) % 加载模型 loaded = load('skinSVM.mat'); % 预处理 + 特征提取 img = imresize(app.img, [256, 256]); labImg = rgb2lab(medfilt3(img, [3 3 1])); feat = extractSkinFeatures(labImg); % 标准化 + 预测 featNorm = (feat - loaded.mu) ./ loaded.sigma; [label, score] = predict(loaded.svmModel, featNorm); if strcmp(label, 'malignant') app.ResultLabel.Text = sprintf('诊断结果:恶性可疑(置信度 %.2f)', score(2)); else app.ResultLabel.Text = sprintf('诊断结果:良性(置信度 %.2f)', score(2)); end end要注意一个细节,predict返回的score行向量里,正类和负类的分数顺序取决于fitcsvm训练时ClassNames的顺序。我训练时写的是{'benign','malignant'},所以score第二列对应恶性。这个顺序问题很容易踩坑,建议你在代码里写注释固定住,不然换了个模型文件就会得到完全相反的诊断结果。
最后再分享一点个人体会。做完这个项目之后,我最大的收获不是学会了fitcsvm那几条命令,而是理解了"模型之外"的东西有多重要——数据怎么清洗、特征怎么设计、评估指标怎么选,每一个环节对最终结果的影响都不亚于模型本身。如果你正准备复现这套系统,我建议不要一上来就追求把准确率刷到99%,那既不可能也没必要。先跑通一个完整的基线版本,把混淆矩阵和ROC曲线打印出来,然后一个环节一个环节地优化,你会发现每一步改进都能看得见摸得着,这种体验比直接拿别人现成的模型要踏实得多。