
简介一份基于Python的CNN-SVM融合分类源码包面向深度学习图像分类初学者与希望快速验证混合模型的开发者。资源利用卷积神经网络自动提取图像特征再交由支持向量机SVM完成分类弥补单独使用CNN或SVM的不足广泛适用于图像分类等视觉任务。压缩包体积仅8KB共8个文件以Python脚本为主6个.py涵盖CNN训练、特征提取、SVM训练与预测、可视化等环节另有说明文档txt和README.md辅助阅读。目前已有2086人学习。代码使用Keras搭建CNN训练后提取全连接层特征作为SVM输入Scikit-learn负责分类器训练与结果评估。核心脚本覆盖数据预处理、模型构建、特征导出、SVM调参与预测的完整流程并包含特征可视化模块便于观察样本在特征空间中的分布。整个流程逻辑清晰脚本按功能拆分方便按需修改和复用即使初学者也能按序执行。适合在本地快速跑通实验也可作为课程设计或论文复现的参考。对于需要理解深度学习特征表达与经典分类器结合方式的读者这款轻量源码提供了一条可直接运行的实践路径。1. CNN-SVM 到底是什么小样本图像分类里被低估的组合拳做图像分类的第一反应是端到端 CNNsoftmax 一梭子打完。但样本量只要掉到几千张甚至几百张卷积神经网络的泛化能力就开始露怯验证集准确率抖得像过山车。这时候把 CNN 和 SVM 拼起来——CNN 负责把图像压成高质量特征向量SVM 在特征空间里找最大间隔的决策边界——往往能把准确率抬回五个点以上。这个 CNN-SVM 组合在工程里并不新鲜却被深度学习教程长期当作“过时方案”忽略。它特别适合三类人手里只有小数据集的研究生、做工业缺陷检测的算法工程师、想在 Python 里快速验证特征提取思路的开发者。下面按我自己的落地顺序把原理、代码、参数和翻车点一次讲完。2. CNN-SVM 的工作原理特征提取与决策边界怎么分工CNN-SVM 不是把两个模型随便接在一起而是让每个模型干自己最擅长的事CNN 擅长从像素里逐层抽象出边缘、纹理、形状这些底层结构SVM 擅长在已经给定的特征空间里找一条泛化性更好的决策边界。两段式流水线的训练逻辑和端到端 CNN 完全不同——先用交叉熵把 CNN 训到特征基本可分然后把最后一层分类器丢掉换成 SVM。我最早是在手写数字这类灰度小数据集上验证这个组合的类似 optdigits 那种几千张图的场景后来在工业缺陷检测里也一直在用样本量越紧张这个分工的优势越明显。顺带划清适用边界如果手里有几万张以上干净标注图端到端 CNN 通常更好CNN-SVM 的那点提升不值得额外的工程复杂度反过来只有几千张图、类别又多CNN-SVM 往往比单独调 CNN 更省钱。还有一类经典场景是迁移学习用 ImageNet 预训练模型当特征提取器SVM 当分类器这在小型比赛里几乎成了标配做法。2.1 为什么 CNN 只在任务里当特征提取器不直接上 softmax先看清楚 softmax 在 CNN 里是什么它本质上就是全连接层加一个归一化前面接多少维特征这里就是多少维的线性打分。端到端训练时CNN 的卷积层和这个线性层是一起优化的数据量足够时两者配合得很好数据量一变小线性层很容易把训练集的噪声当成规律记住验证集表现就崩了。而卷积层学到的边缘、纹理、形状这些结构在几千张图的数据量下反而还能保持相对稳定——因为它们描述的是图像本身的结构不是某个类别的偶然分布。所以 CNN-SVM 的常见做法是CNN 继续用交叉熵训练甚至直接用别人训好的权重但分类时不走它的 softmax 层而是把某一层的输出截出来当作特征向量交给 SVM。关键问题就变成“从哪一层截”。我一般会截全局平均池化GAP之后的输出而不是倒数第二层全连接。对于一层两层卷积的小网络GAP 输出是一个 64 或 128 维的向量维度等于最后一个卷积层的通道数如果从全连接层截常见是 512 或 1024 维。SVM 在小样本下能处理高维但维度过高、特征高度冗余时它同样会过拟合交叉验证时间也成倍增加。GAP 的另一个好处是空间不变性——它对整张特征图做平均目标位置偏移不会显著改变特征值对分类任务正好合适。我在多个数据集上对比过GAP 特征进 SVM 的准确率通常不输全连接特征还更稳。如果你不想从零训练 CNN用预训练模型当提取器也很常见代码上更省事把 ResNet 这类模型的最后一层分类器替换成恒等映射forward 出来就是特征向量。但要注意领域差异医学影像、工业缺陷这类图和 ImageNet 的自然图像差别很大直接提特征的可分性可能不理想我会先微调几轮再提取效果差很远。无论哪种方式“截哪一层”这个决策都值得单独验证不要默认最后一层全连接就是最好的。2.2 SVM 在特征空间里找最大间隔RBF 核、C 与 gamma 到底在管什么SVM 的目标是在特征空间里找一个最大间隔的超平面让不同类别样本离决策边界尽量远。和 softmax 的全局拟合不同SVM 的决策边界只由支持向量决定——就是那些离边界最近的少数样本。小样本场景下这个性质的直接好处是它不会为了照顾某一个远离群体的样本而大幅扭曲边界泛化能力相对稳。CNN 提取的特征经过非线性变换在原始空间里往往不是线性可分的所以实际用的多是核 SVM最常用的是 RBF 核K(x, z) exp(-gamma × ||x - z||²)gamma 决定了一个训练样本的影响力半径gamma 小每个样本只影响离它很近的区域边界平滑但容易欠拟合gamma 大边界变得锯齿状几乎每个样本各自为政过拟合风险很高。C 则是误分类惩罚强度C 小允许一些样本被错分来换取平缓边界C 大训练集上每个点都要尽可能分对边界随之变得复杂。多分类场景下sklearn 的 SVC 默认用 one-vs-rest 策略也就是每个类别都跟其他所有类别单独训练一个二分类器。类别不平衡时我一般会设 class_weightbalanced让少数类的误分类代价自动提高如果业务需要概率输出要设 probabilityTrue但代价是内部会做 Platt 缩放和额外的交叉验证训练时间明显变长能不用就不用。参数的作用和大致范围可以先记一张表参数作用调大的趋势经验范围C对误分类样本的惩罚强度决策边界更贴合训练点易过拟合0.1 ~ 100gammaRBF 核中单个样本的影响半径边界锯齿化几乎只认近邻样本0.001 ~ 0.1class_weight类别不平衡处理少数类误分类代价更高balanced这张表只能当起点真正定参要在特征归一化之后做网格搜索下面一章直接给可复现的代码。3. 用 Python 复现 CNN-SVMPyTorch 提取特征 sklearn 分类的完整代码常用环境组合是 Python 3.8 以上、PyTorch 2.x、scikit-learn 1.x安装依赖用 pip install torch scikit-learn numpy 就能跑起来。整体流程三步搭 CNN 并训练到特征可分离冻结网络导出特征用 SVM 加交叉验证定参。下面代码以单通道灰度图、输入 28x28 为例换成 RGB 或更大分辨率时只需要改输入通道数或换预训练 backbone。3.1 训练 CNN 并导出特征向量全局平均池化才是稳定的输出口我用一个两层卷积的小网络输出口放在全局平均池化之后import torch import torch.nn as nn import numpy as np class FeatureCNN(nn.Module): def __init__(self, in_channels1, num_classes10): super().__init__() # 两个卷积块32 - 64 通道中间各接一次最大池化 self.features nn.Sequential( nn.Conv2d(in_channels, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 28x28 - 14x14 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 14x14 - 7x7 ) # 全局平均池化每个通道压成 1 个值特征维度固定为 64 self.gap nn.AdaptiveAvgPool2d(1) # 这个线性层只在训练时用交叉熵监督提特征时会被 SVM 取代 self.classifier nn.Linear(64, num_classes) def forward(self, x): x self.features(x) x self.gap(x) # [B, 64, 1, 1] feat torch.flatten(x, 1) # [B, 64] logits self.classifier(feat) return logits, feat # 训练用 logits提特征用 featforward 同时返回 logits 和特征好处是训练和监督共用一套前向逻辑不需要写两遍。分类层要保留吗要因为你想先让网络学到可分特征交叉熵是最简单的监督信号等特征稳定了这个线性层就可以丢掉。训练部分用最常见的 Adam 加交叉熵30 个 epoch 足够当基线model FeatureCNN() optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() # train_loader 里的 images 形状是 [B, 1, 28, 28]labels 是 LongTensor for epoch in range(30): model.train() for images, labels in train_loader: optimizer.zero_grad() logits, _ model(images) loss criterion(logits, labels) loss.backward() optimizer.step() if (epoch 1) % 10 0: print(fepoch {epoch1:02d} loss {loss.item():.4f})lr1e-3 是 Adam 的稳妥起点如果 loss 在 10 个 epoch 内不下降调低到 3e-4 而不是调高学习率。训练完的网络不一定在验证集上有很高的 softmax 准确率这不要紧只要特征已经能区分类别SVM 还有机会把它拉起来。接下来是导出特征这里有两个坑要在代码里提前规避忘记 model.eval() 和忘记 torch.no_grad()。eval 模式让 BatchNorm 使用训练阶段统计好的 running mean/var而不是当前 batch 的统计量否则同一个样本在不同 batch 里取到的特征会不一样no_grad 则避免构建计算图省显存也提速def extract_features(model, loader): model.eval() feats, labels [], [] with torch.no_grad(): for images, labels_batch in loader: _, feat model(images) # 只要 GAP 之后的 64 维特征 feats.append(feat.cpu().numpy()) labels.append(labels_batch.numpy()) return np.vstack(feats), np.concatenate(labels) X_train, y_train extract_features(model, train_loader) X_val, y_val extract_features(model, val_loader) np.save(train_feat.npy, X_train) np.save(train_label.npy, y_train) np.save(val_feat.npy, X_val) np.save(val_label.npy, y_val)把特征和标签直接存成 npy 文件SVM 调参阶段完全不用再跑 CNN后面反复试 C 和 gamma 都是零成本。如果不想自己训练用预训练模型换掉这个网络也很简单import torchvision.models as models backbone models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) backbone.fc nn.Identity() # 把最后的 1000 类分类层替换成恒等映射 backbone.eval()这样 forward 出来就是 512 维特征。但强烈建议先在你的目标数据上微调几轮再提取理由上一章说过预训练权重和你的图像分布差距太大时特征只在底层结构上有效。3.2 用 sklearn 训练 SVMC、gamma 网格搜索与交叉验证特征导出后SVM 部分就是纯 sklearn 的事了。第一步永远是归一化这一步不做后面网格搜索的结果基本不可信from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.model_selection import GridSearchCV, StratifiedKFold from sklearn.metrics import accuracy_score scaler StandardScaler() X_train_s scaler.fit_transform(X_train) # 只 fit 训练集 X_val_s scaler.transform(X_val) # 验证集只 transform param_grid { C: [0.1, 1, 10, 100], gamma: [0.001, 0.01, 0.1], } svm SVC(kernelrbf, class_weightbalanced, max_iter100000) grid GridSearchCV( svm, param_grid, cvStratifiedKFold(n_splits5, shuffleTrue, random_state42), scoringaccuracy, n_jobs-1, ) grid.fit(X_train_s, y_train) print(best params:, grid.best_params_) print(cv accuracy:, grid.best_score_) print(val accuracy:, accuracy_score(y_val, grid.predict(X_val_s)))C 和 gamma 都用对数刻度采样0.1、1、10、100 这种乘性步长是因为它们对决策边界的影响接近乘性关系用等差数列 0, 50, 100 去扫基本扫不到有效区间。gamma 选 0.001~0.1是因为特征经过 StandardScaler 之后大致落在零均值单位方差附近如果特征维度很高比如 1000 维以上可以先把 gamma 下限再往下探一档或者先用默认 scale 拿到一个参考点再围绕它展开网格。StratifiedKFold 保证每一折里的类别比例和整体一致类别不平衡的数据集上尤其重要普通 KFold 可能让某一折完全没有某个少数类。max_iter100000 是防止 RBF 核在部分条件下不收敛而提前停止实际一般跑不满但能避免看到一行莫名其妙的 did not converge 警告。样本量到几万时 SVM 训练会明显变慢常见做法是先用线性核跑一遍当基线确认提升来自决策边界而不是噪声再上 RBF。网格搜索跑完还有个判断标准如果最优参数落在搜索边界上比如 C 取到 100 正好是上限说明真实最优可能更大把范围往 1000 方向扩一轮再搜如果最优值落在中间就不必再扩张了。提示把 StandardScaler 和 SVC 一起封装进 sklearn Pipeline再交给 GridSearchCV 做交叉验证每一折里归一化都会重新 fit能从机制上避免数据泄漏细节在下一章的翻车记录里展开。4. CNN-SVM 避坑指南五个把准确率打回原形的翻车点这个组合看起来简单实际上大部分翻车都发生在数据预处理和提特征的细节参数反而背了最多的锅。下面五条是我自己踩过、或者帮别人排查过的按出现频率排序。4.1 特征没归一化SVM 的高端核函数白装了现象CNN 的 softmax 验证集准确率 80%换成 SVM 反而只有 60% 上下甚至不如线性分类器。原因CNN 卷积层输出的特征每个维度的量级是网络自己学出来的有的维度集中在 0.01 附近有的可能到 10 附近。RBF 核计算的是样本间的欧氏距离大数值维度会直接压过小数值维度gamma 怎么调都救不回来SVM 等于只看特征里的少数几个维度。解决提取特征后先做 StandardScaler并且只 fit 训练集。我见过有人把整个数据集一起 fit测试集信息提前泄漏进训练流程交叉验证分数虚高上线就露馅。正确顺序是 scaler.fit(X_train)再分别 transform 训练集和验证集。4.2 从全连接层提特征维度高还冗余现象选倒数第二层全连接当特征SVM 网格搜索一次跑十几分钟结果准确率还不如用 GAP 特征。原因全连接层输出通常是 512、1024 维维度之间有大量冗余而且它是在交叉熵监督下为分类任务特化的很多维度只在训练集上有区分度。SVM 在冗余高维特征上容易过拟合训练复杂度还按维度线性上涨。解决优先用全局平均池化输出当特征如果 GAP 特征维度太低导致信息丢失做一个 PCA 降到 64~128 维再接 SVM。我一般先用 GAP 特征跑通基线确认瓶颈之后再考虑要不要降维或换层。4.3 C 和 gamma 用默认值直接跑把锅推给数据集现象直接 SVC() 一把梭准确率一般于是得出结论“CNN-SVM 没用”。原因sklearn 默认 C1.0、gammascale 是面向通用场景的默认值不是为你的特征分布调的。gammascale 会按 1/(特征维度×方差) 自动估计方向对但往往不是最优C 更是全靠猜。解决用 3.2 节的对数网格搜索。第一轮粗扫 C 和 gamma 各三四个值第二轮围绕第一轮最优点细化两个轮次加起来几十个组合对几千样本的数据集来说几分钟就结束这个成本完全值得。4.4 提特征时模型状态不对BatchNorm 让特征漂移现象同一个模型、同一批验证图像跑两次得到的 SVM 准确率不一样有时候差 5 个点以上。原因特征提取时模型如果还停留在训练模式BatchNorm 会用当前 batch 的均值和方差做归一化而不是训练累计的全局统计量如果网络里有 Dropout它还会随机丢弃神经元。特征一变SVM 决策边界当然跟着变。解决提取前必须 model.eval()并且放在 torch.no_grad() 里。如果怀疑特征不稳定在 eval 模式下对同一批数据跑两次特征计算两次特征的余弦距离接近 0 就说明特征稳定了。4.5 最优参数落在网格边界上就以为搜完了现象GridSearchCV 打印的 best_params_ 里 gamma0.001 正好是范围下限C100 正好是上限于是直接拿去上线结果验证集和线上表现差一截。原因网格搜索的候选集是离散的真正的最优值可能比边界值更极端。边界命中通常意味着值域选小了需要往外扩而不是真的到了最优。解决参数落在边界就沿那个方向扩展一轮比如 gamma 命中 0.001就往 0.0001、0.00001 探C 命中 100 就往 1000 探。连续两轮都落在同一方向的最外侧才说明这个参数在这个数据集上确实该取极值或者特征本身有问题需要回头检查 CNN。5. 让 CNN-SVM 更稳的两个习惯先验证特征再缓存特征5.1 用 PCA 和散点图先验证特征可分性我在正式调 SVM 参数之前会花十分钟做一件事把特征用 PCA 降到两维画散点图按标签着色看是否成簇。这一步能提前暴露“特征根本没学好”的问题——如果两类样本在二维投影里完全混在一起后面把 C、gamma 调出花来也救不回来问题在 CNN 训练阶段不在 SVM。from sklearn.decomposition import PCA import matplotlib.pyplot as plt pca PCA(n_components2).fit_transform(X_train_s) plt.scatter(pca[:, 0], pca[:, 1], cy_train, cmaptab10, s8) plt.show()如果簇与簇之间有明显重叠先回头调 CNN 的训练轮数、学习率或者换预训练模型微调而不是在 SVM 上硬磨。我见过太多人把时间花在核函数对比上最后发现是特征本身不可分。5.2 特征缓存落盘调参才能放开手脚特征导出后立刻存成 npy 文件SVM 网格搜索在内存里跑几分钟出结果。这样你才敢把 C 扫到 0.01~1000 的大范围也敢一次对比 linear、rbf、poly 三种核函数。我现在的固定流程是CNN 训练并导出特征落盘跑一个线性核基线再跑 RBF 网格如果 RBF 没赢过线性核回头检查特征而不是继续调核参数。这套流程帮我省掉了大量无效调参。最后把归一化和 SVM 封装成 Pipeline让网格搜索的每一折都重新 fit scaler从机制上杜绝数据泄漏from sklearn.pipeline import make_pipeline pipe make_pipeline(StandardScaler(), SVC(kernelrbf)) grid GridSearchCV(pipe, param_grid, cv5, n_jobs-1) grid.fit(X_train, y_train)我现在拿到新数据集第一件事就是把特征和标签落盘跑基线确认特征可分再谈参数。这个顺序是从无数次翻车里攒出来的血泪经验希望帮到你。本文还有配套的精品资源点击获取