简介:这份压缩包提供了一套完整的卷积神经网络与支持向量机融合分类实现源码,面向深度学习与图像分类方向的学习者和研究者,旨在解决单一模型在特征提取或分类边界上的局限问题。包内共有8个文件,其中6个为Python脚本,分别承担模型训练、特征提取、分类器训练与预测等任务,另有1个说明文档和1个README手册,压缩包整体仅8KB,非常轻量。目前该资源已有2085人学习下载。从内容看,代码完整覆盖了使用卷积网络从原始图像中提取深度特征,再输入支持向量机进行分类的典型流程,并附带了t_SNE可视化脚本用于观察特征分布。读者既可以参考这些脚本搭建自己的分类流程,也可以直接替换数据集进行实验,是一份实用价值较高的入门参考。
1. CNN-SVM 到底是什么:把特征提取和分类拆成两阶段来用
同样是图像分类,把 CNN 最后一层的 Softmax 换成一个支持向量机,在数据集只有几百到几千张的小样本场景里,不少任务的效果反而更稳。这个反直觉的现象,就是 CNN-SVM(也叫卷积支持向量机)混合模型存在的意义。它做的事很朴素:CNN 负责把图像压缩成一段高级语义向量,SVM 负责在这段向量上切分类边界,而不是像端到端网络那样把所有事压在一个反向传播闭环里。
这个方案适合手里数据量不大、用纯 CNN 容易过拟合或欠拟合的从业者,也适合想把「特征提取」和「分类决策」分开维护的工程场景。你不需要在 CNN 结构上做任何特殊改动,只需去掉最后的分类层,把中间的特征向量导出来喂给 SVM 即可。读完之后,你能跑通一套完整的 Python 实现,知道特征维度、核函数、C 值这些参数怎么调,也能避开那些最容易让人翻车的坑。
2. CNN-SVM 的原理拆解:CNN 提供特征,SVM 提供分类边界
2.1 卷积层到全连接层:CNN 提取的是什么
卷积核在图像上滑动,做的是局部模式匹配。浅层卷积核学到的是边缘、角点、颜色块,越往深层,学到的东西越接近语义——比如眼睛、轮子、羽毛这类有明确含义的部件。每一层卷积叠加上激活函数和池化,本质是在逐步把「像素空间」映射到「语义空间」,同时把空间尺寸压小、把通道数加深。
池化的作用是压缩空间分辨率,顺手带来一点平移不变性。当特征图被压到 1x1 或者被展平之后,CNN 就产出了一个一维向量。这里要明确一个容易被忽略的事实:全连接分类层只是在这个向量上做了一次线性打分,而这个向量本身才是 CNN 真正学到的东西。这个向量通常叫深度特征,它把一张 32x32 或 224x224 的图像浓缩成几十到几百个浮点数,CNN-SVM 用的就是它。
所以整个拆解手术的位置很清晰:训练完 CNN 之后,把最后那层 Linear(对应 Softmax 分类器)丢掉,拿它前一层的输出当作特征。这一步没有任何结构魔法,只是改变了「谁来做最终决策」。在纯 CNN 里,分类边界是 Softmax 线性层 + 交叉熵学出来的;在 CNN-SVM 里,分类边界换成了 SVM 的最大间隔超平面。
2.2 SVM 为什么能接在 CNN 后面:深度特征的小样本优势
先说纯 CNN 在小样本上的问题。Softmax 分类头靠交叉熵驱动整个网络反向传播,样本少的时候,最后那个线性层很容易被个别离群样本带偏。你可以观察到典型症状:训练集准确率冲到 99%,验证集却卡在 70% 上下波动。这是过拟合,也是小样本图像分类最常见的失败方式。
SVM 的训练走的是另一条路。它解一个二次规划问题,目标不是拟合每一个训练样本,而是在特征空间里找一条最大间隔的分类面。最大间隔这个约束天然带有正则化效果,在样本量只有几百的时候,它给出的边界通常比交叉熵学出来的更稳,不容易被少数样本牵着走。这也是 CNN-SVM 混合方案在学术界被反复验证有效的原因:小样本下,SVM 的归纳偏置比 Softmax 更适合做最终决策。
但 SVM 不能直接用在原始像素上。一张 32x32 的彩色图展开就是 3072 维,一张 224x224 的图展开超过 15 万维,在这个维度下做核计算基本是灾难,而且高维小样本会让 SVM 自己也过拟合。CNN 正好补上这块:它先把原始图像压到几十到几百维的特征空间,把真正有用的语义信息提炼出来,同时把维度降到 SVM 擅长的区间。CNN 负责降维和抽象,SVM 负责在低维空间里找稳定边界,这就是「卷积支持向量」组合的核心逻辑。
2.3 两阶段训练是核心:为什么不能当成一个网络端到端训练
这是新手最容易误解的地方。CNN-SVM 不是把 SVM 作为一层塞进 CNN 里然后一起反向传播,而是两阶段训练。原因很实际:SVM 的训练目标是合页损失加正则项,求解用的是二次规划,它不是一个「能直接参与反向传播」的层。虽然理论上可以把合页损失当成 CNN 的损失函数做端到端优化,但工程上收敛慢、超参数敏感,实践中很少有人这么干。
常见做法是两阶段:先用交叉熵训练一个带临时分类头的 CNN,训到收敛后丢掉这个分类头,冻结卷积层权重,把训练集和验证集都过一遍前向,导出特征向量;然后对特征做标准化,再训练 SVM。这里有个值得注意的点:临时分类头的质量不需要多高,它的作用是逼着 CNN 把特征学出来,真正做决策的是后面那个 SVM。
反过来也有一个常见误解,觉得两阶段训练一定比端到端差。在小样本场景里恰恰相反:两阶段让 CNN 和 SVM 可以各自调参,特征质量可以单独检验,出问题的时候能明确知道是「特征没学好」还是「分类边界没切好」。这种可拆解性在工程排障上的价值,往往比理论上的一体化优化更实在。后面所有代码和调参套路,都是围绕这个两阶段框架展开的。
3. 用 Python 落地 CNN-SVM:从数据预处理到训练评估的完整代码
3.1 环境准备与数据集选择
CNN-SVM 对 Python 环境相当宽容,不需要碰那些动不动就版本冲突的深度学习环境配置。核心依赖就四样:torch 和 torchvision 负责 CNN 部分,sklearn 负责 SVM 部分,numpy 做中间数据传递。只要这四个库能正常 import,基本不会遇到挑版本的问题。GPU 不是必须的,CIFAR-10 这种规模的数据集用 CPU 跑几个 epoch 也能出结果,只是慢一点。
数据集选 CIFAR-10 的猫和狗两类做演示,torchvision 可以直接下载,不依赖私有数据。选它的原因有三点:大家跑过同一份数据,结果有可比性;二分类是实际项目最常见的起步形态;类别数少,SVM 的训练速度和调参迭代都很快。如果你想把流程迁移到自己的数据集上,只需要把数据加载部分换成自己的图片目录,后面的 CNN-SVM 训练代码一行都不用改。
3.2 定义 CNN 特征提取器
特征提取器用一个小型 CNN:两个卷积块加全局平均池化,最后输出 128 维特征向量。不用 ResNet 这类大模型的原因在第四章细说,这里先记住一个原则:特征维度是给 SVM 服务的,不是越大越好。
import torch import torch.nn as nn class FeatureExtractor(nn.Module): def __init__(self, feat_dim=128): super().__init__() # 第一个卷积块:3 通道输入,32 个卷积核,提取浅层纹理特征 self.block1 = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 32x32 -> 16x16 ) # 第二个卷积块:32 -> 64,提取更深层的语义特征 self.block2 = nn.Sequential( nn.Conv2d(64, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 16x16 -> 8x8 ) # 全局平均池化把空间维度压成 1x1 self.global_pool = nn.AdaptiveAvgPool2d(1) # 最后的线性层输出特征向量,这个才是给 SVM 用的东西 self.fc = nn.Linear(64, feat_dim) def forward(self, x): x = self.block1(x) x = self.block2(x) x = self.global_pool(x).flatten(1) feat = self.fc(x) return feat这段代码的关键在最后两行:AdaptiveAvgPool2d(1)把任意尺寸的特征图压成 1x1,flatten(1)拉平成向量,fc输出 128 维。这里的feat_dim是整条流水线的核心参数,以后调特征维度只需要改这一个数字。卷积核数量没有用特别大的值,因为这个演示的目标是跑通流程,不是刷榜。
3.3 两阶段训练主流程
先加载 CIFAR-10 的猫狗子集。torchvision 默认下载整个数据集,我们通过标签过滤只留猫和狗,再手动切分训练集和验证集,保证每个类别在两个集合里都有足够样本。
import torchvision import torchvision.transforms as T from torch.utils.data import Subset, DataLoader import numpy as np transform_train = T.Compose([ T.RandomHorizontalFlip(), T.RandomCrop(32, padding=4), T.ToTensor(), T.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) transform_val = T.Compose([ T.ToTensor(), T.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) # 下载 CIFAR-10,只保留标签为 3(猫)和 5(狗)的样本 trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train) testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform_val) def filter_cat_dog(dataset): indices = [i for i, (_, y) in enumerate(dataset) if y in (3, 5)] return Subset(dataset, indices) train_sub = filter_cat_dog(trainset) test_sub = filter_cat_dog(testset) train_loader = DataLoader(train_sub, batch_size=64, shuffle=True) test_loader = DataLoader(test_sub, batch_size=64, shuffle=False)数据增强只加了水平翻转和随机裁剪,这是 CIFAR 上最常用的两个增强操作。注意Normalize用的均值标准差是 CIFAR-10 数据集的统计值,如果你换自己的数据集,需要重新统计,这个细节直接关系到特征分布的稳定性,后面避坑章节还会提到。
接下来是第一阶段:训练 CNN 和临时分类头。这里用交叉熵损失,训练若干轮直到收敛,然后丢掉分类头提取特征。
import torch.nn as nn import torch.optim as optim device = 'cuda' if torch.cuda.is_available() else 'cpu' extractor = FeatureExtractor(feat_dim=128).to(device) # 临时分类头:训练完就会被丢掉,它只是逼 CNN 学到有用的特征 classifier = nn.Linear(128, 2).to(device) optimizer = optim.Adam(list(extractor.parameters()) + list(classifier.parameters()), lr=1e-3) criterion = nn.CrossEntropyLoss() for epoch in range(20): extractor.train() classifier.train() for images, labels in train_loader: labels = torch.where(labels == 3, torch.tensor(0), torch.tensor(1)) # 猫->0, 狗->1 images, labels = images.to(device), labels.to(device) feat = extractor(images) out = classifier(feat) loss = criterion(out, labels) optimizer.zero_grad() loss.backward() optimizer.step() print(f"epoch {epoch+1}, loss: {loss.item():.4f}")这个训练循环最值得注意的地方是标签映射:CIFAR-10 原本的标签 3 和 5 被映射成 0 和 1,因为 SVM 是二分类器,标签必须从 0 开始连续编码。optimizer同时更新特征提取器和临时分类头的参数,这是标准做法,千万不要只更新分类头而冻结特征提取器,那样特征学不出来。
第二阶段是核心:提取特征、标准化、训练 SVM。
from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.metrics import accuracy_score def extract_features(model, loader): model.eval() feats, labels = [], [] with torch.no_grad(): for images, ys in loader: images = images.to(device) f = model(images).cpu().numpy() feats.append(f) labels.append(np.where(ys.numpy() == 3, 0, 1)) return np.concatenate(feats), np.concatenate(labels) train_feat, train_label = extract_features(extractor, train_loader) test_feat, test_label = extract_features(extractor, test_loader) # 标准化:SVM 对特征尺度极其敏感,这一步不做后面必翻车 scaler = StandardScaler() train_feat = scaler.fit_transform(train_feat) test_feat = scaler.transform(test_feat) # 训练 SVM svm = SVC(kernel='rbf', C=1.0, gamma='scale', class_weight='balanced') svm.fit(train_feat, train_label) pred = svm.predict(test_feat) print(f"SVM accuracy: {accuracy_score(test_label, pred):.4f}")逻辑说明分成三层。第一层,extract_features函数必须用model.eval()和torch.no_grad(),前者关闭 BatchNorm 和 Dropout 的训练状态,后者禁止梯度计算,这两个缺一个都会让提取出的特征和训练时的分布不一致。第二层,StandardScaler用训练集的均值和方差做标准化,验证集只用transform不用fit_transform,这是防止数据泄露的标准操作。第三层,SVM 用 RBF 核,gamma='scale'会自动根据特征维度计算一个合理的默认值,这个设置在多数特征维度下都适用。
这段代码跑完后,你应该能在测试集上看到一个比纯 CNN 更稳的准确率。如果结果明显异常,不要急着调参,先回看每个函数是否严格按上面的写法执行,90% 的问题出在忘了model.eval()或者标准化写错。
3.4 评估:不能只看准确率
二分类只看 accuracy 会漏掉很多信息,尤其是类别不平衡的时候。这里给出完整的评估代码,除了 accuracy 还看混淆矩阵和 F1 分数。
from sklearn.metrics import confusion_matrix, f1_score cm = confusion_matrix(test_label, pred) print("混淆矩阵:") print(cm) f1 = f1_score(test_label, pred, average='binary') print(f"F1 score: {f1:.4f}")混淆矩阵的读法:主对角线是正确分类的样本数,副对角线是错误分类。如果右下角(狗类)的召回明显低于左上角(猫类),说明分类面对狗类样本偏保守,这种情况可以调整 SVM 的class_weight或直接在decision_function输出上做阈值平移,后面第四章细讲。F1 分数适合在正负样本不均时做一个综合参考,它同时惩罚漏报和误报。
4. CNN-SVM 参数调试:特征维度、学习率、核函数与 C 值的搭配
4.1 特征维度设多少:64 到 256 是多数任务的甜点区
特征维度是 CNN-SVM 里最容易被忽略、却对结果影响最大的参数。特征维度太低,CNN 没有足够的容量表达语义差异,比如猫和狗在 32 维特征空间里可能重叠严重;特征维度太高,SVM 在高维空间里做核计算的开销变大,小样本下还容易跟着过拟合。结合我在几个项目上的踩坑经验,64 到 256 是多数图像任务的安全区间。
实际操作建议从 128 起步,看验证集结果再决定方向:如果 SVM 在验证集上欠拟合,可以试着把维度降到 64 或 96,特征更紧凑时 SVM 的边界往往更干净;如果特征维度降了之后准确率也跟着降,说明信息量不够,往 256 方向调。调维度只需要改FeatureExtractor(feat_dim=128)这一个数字,然后重新跑两阶段训练。但要记住,维度变了,CNN 的训练也要重新跑,因为它改变了全连接层的参数数量和整个网络的容量。
4.2 学习率:调的是特征质量,不是分类器
CNN-SVM 里有两套学习率逻辑,很多人只调 SVM 的 C 和 gamma,忘了 CNN 阶段的学习率才是特征质量的上限。用 Adam 优化器时,1e-3 是大多数场景的稳妥起点;如果你的数据集特别小(几百张),建议降到 1e-4,避免 CNN 在少量样本上来回震荡,特征空间不稳定。
这里有个很容易踩的坑:训练 CNN 时看训练 loss 很低就觉得特征没问题了。实际上验证集 loss 最低的那个 epoch,对应的特征才是最适合 SVM 的。我一般会保存验证集 loss 最低的权重文件,而不是直接拿最后一个 epoch 的权重去提特征,后者的特征可能已经开始过拟合训练集中的噪声。特征质量决定了 SVM 效果的上限,SVM 只是在给定特征下找最优边界,它救不回一个烂特征。
4.3 SVM 核函数、C 和 gamma:先线性核后 RBF
深度学习课本里讲 SVM 总是从核函数讲起,但工程上的选择逻辑其实很朴素:先试线性核,因为快、参数少、可解释性强;效果不行再上 RBF 核。CNN 特征本身已经高度抽象,很多任务下它是近似线性可分的,线性核够用且训练速度快一个数量级。只有当线性 SVM 在验证集上表现明显不佳时,才需要 RBF 核去切非线性边界。
| 核函数 | 特点 | 适用场景 | 典型参数范围 |
|---|---|---|---|
| linear | 训练快、可解释、参数少 | 特征近似线性可分 | C: 0.1 ~ 10 |
| RBF | 能切非线性边界,最常用 | 特征分布未知 | C: 1 ~ 10,gamma: 0.01 ~ 0.1 |
| poly | 可拟合复杂边界,但容易过拟合 | 极少用,需要额外调 degree | degree: 2 ~ 3 |
C 是误分类惩罚系数。C 越大,SVM 越努力把训练样本分对,越容易过拟合;C 越小,边界越平滑,但欠拟合风险上升。gamma 控制 RBF 核的作用半径,gamma 越大,每个支持向量的影响范围越小,边界越复杂;gamma 越小,边界越平滑。这两个参数的搭配有点像玄学,但只要记住「C 和 gamma 同时增大 = 过拟合,同时减小 = 欠拟合」这个方向感,就不容易调反。
4.4 类别不平衡:class_weight 与阈值平移
很多实际场景里正负样本并不均衡,比如缺陷检测里良品占 90% 以上。SVM 对类别比例非常敏感,因为它的目标函数是最大化整体间隔,少数类样本少,对间隔的贡献就小,边界会明显偏向多数类。最简单的处理是给SVC加一个class_weight='balanced',sklearn 会根据类别频率自动放大少数类的惩罚权重,这一行代码往往能带来肉眼可见的召回提升。
如果class_weight='balanced'之后少数类召回还是不够,可以用阈值平移。SVM 的decision_function输出的是样本到分类面的带符号距离,默认阈值是 0,你可以手动把这个阈值往负数方向移,让更多样本被分到少数类。
# 用验证集找最佳阈值 from sklearn.metrics import f1_score scores = svm.decision_function(val_feat_scaled) for threshold in np.linspace(-1, 1, 20): pred_t = (scores > threshold).astype(int) f1 = f1_score(val_label, pred_t) best_threshold = threshold if f1 > best_f1 else best_threshold阈值平移的本质是在召回率和精确率之间做权衡。不用改模型、不用重新训练,只需要在验证集上跑一遍decision_function找最佳阈值,上线预测时把判断条件从scores > 0改成scores > best_threshold即可。
4.5 用网格搜索收尾:别手撸 C 和 gamma
C 和 gamma 的组合空间太大,手撸太慢而且容易漏掉最优组合。常见做法是先用粗网格锁定一个大致区间,再在附近做细网格搜索。
from sklearn.model_selection import GridSearchCV from sklearn.svm import SVC param_grid = { 'C': [0.1, 1, 10], 'gamma': [0.01, 0.1, 1], 'kernel': ['rbf'], } svm = SVC() grid = GridSearchCV(svm, param_grid, cv=3, scoring='f1') grid.fit(train_feat, train_label) print(f"best params: {grid.best_params_}") print(f"best f1: {grid.best_score_:.4f}")网格搜索的 cv 参数在数据量小时建议用 3,因为 5 折会进一步缩小每折的训练样本量,SVM 在小数据上对样本量变化很敏感。scoring 选 'f1' 而不是 'accuracy',理由和前面一样,类别不平衡时 accuracy 会掩盖问题。网格搜索结束后,拿grid.best_estimator_在测试集上做最终评估,这个评估结果才是可信的指标。
5. CNN-SVM 避坑记录:5 个让模型翻车的低级错误
5.1 特征不缩放直接喂 SVM:RBF 核全军覆没
现象:提取完特征后直接svm.fit(train_feat, train_label),训练集准确率很高,但验证集准确率差得离谱,甚至不如随便猜。
原因:CNN 提取的特征经过了 ReLU 和全连接层,各个维度的数值范围可能差异巨大,有的维度集中在 0 到 1,有的维度能到几十。RBF 核计算的是样本间的欧氏距离,数值范围大的维度会完全主导距离计算,其他维度的区分能力被淹没。
解决:特征必须在喂进 SVM 之前做标准化。记住一条铁律——scaler.fit_transform只用训练集,验证集和测试集只调transform。用训练集的均值和方差去标准化验证集,是为了保证特征分布的一致性,重新对验证集做fit属于数据泄露,会让评估结果虚高。
5.2 特征提取时模型停在训练模式:验证集分数忽高忽低
现象:SVM 在训练集上表现正常,但验证集分数波动极大,多次运行同一份代码结果都不一样。
原因:提取特征时忘了调用model.eval()。模型停留在训练模式时,BatchNorm 会使用当前 batch 的统计量而不是训练阶段累积的全局统计量,Dropout 也在随机丢弃神经元。每次前向提取的特征都在波动,SVM 学到的边界自然不稳定。
解决:特征提取函数里的第一行必须是model.eval(),配合torch.no_grad()一起用。前者关掉训练模式的随机行为,后者关掉梯度计算。这两个操作是特征提取的标配,缺一个都会让后面所有结果失去可复现性。
5.3 训练集和验证集预处理不一致:SVM 学到的分布是偏的
现象:训练 CNN 阶段 loss 正常下降,SVM 训练阶段验证集 F1 分数也正常,但换到真实数据上效果断崖式下跌。
原因:训练集用了数据增强(随机裁剪、翻转),验证集只做了归一化,这本身没问题。问题通常出在归一化参数上——训练集和验证集如果用了不同方式计算 mean 和 std,或者图像缩放尺寸不一致,特征分布就整体偏移了。SVM 对分布偏移特别敏感,因为它学到的分类面是基于训练集特征空间的。
解决:图像预处理管线必须严格统一,尤其是Normalize的均值和标准差,训练集、验证集、测试集必须用同一组统计值。建议把预处理写成同一个函数或同一个transforms.Compose对象,不要各自复制一份再改。数据增强可以不同,但归一化必须一致。
5.4 只盯着 accuracy:正负样本 9:1 时它会骗你
现象:测试集 accuracy 达到 98%,看起来很漂亮,但看混淆矩阵发现少数类全部被分错了,F1 分数惨不忍睹。
原因:正负样本比例 9:1 时,模型只要把全部样本都判成多数类,accuracy 就有 90%。SVM 本身也会受到样本比例影响,边界会偏向多数类。只报告 accuracy 会让问题被完美掩盖。
解决:每次评估至少同时打印 accuracy、F1、混淆矩阵三个指标。如果正负样本比例悬殊,直接以 F1 或 AUC 为主要调参指标。同时考虑给 SVM 加class_weight='balanced',并检查多数类召回和少数类召回的差值,这个差值往往比整体准确率更能说明问题。
5.5 乱开 probability=True:SVM 的概率输出不可靠
现象:为了拿概率做置信度过滤,给SVC加了probability=True,结果训练时间明显变长,而且概率值分布很奇怪,阈值怎么调都不好用。
原因:probability=True会触发 Platt 缩放,内部要做一次额外的交叉验证来拟合概率校准函数,这既显著拖慢训练,校准质量又依赖样本量。在小样本场景下,Platt 缩放的校准效果很差,输出的概率值并不代表真实置信度。
解决:除非业务上必须输出概率(比如要给下游系统一个置信度分数),否则直接用decision_function的得分做阈值判断即可。SVM 的距离分数和概率没有直接的换算关系,但它单调,排序和阈值选择都比不可靠的概率值更实用。真正需要概率的时候,建议换成经过良好校准的模型,或者对decision_function输出做独立的校准步骤。
6. 进阶:预训练迁移 + 特征融合,把 CNN-SVM 用在更多场景
6.1 用预训练 ResNet 提取特征:迁移学习版本
自己训练的 CNN 在小数据集上特征质量有限,一个更省事的做法是直接用 torchvision 的预训练 ResNet 当特征提取器,去掉最后的全连接层,拿 2048 维特征,PCA 降到 256 维再喂 SVM。这个组合在工业项目里出现频率很高,因为不需要训练 CNN,特征稳定,半天就能验证一个想法。
import torchvision.models as models from sklearn.decomposition import PCA resnet = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) # 去掉最后的全连接层,保留特征提取部分 resnet.fc = nn.Identity() resnet.eval().to(device) # 提取特征(代码与之前 extract_features 一致) feat_all, label_all = extract_features(resnet, train_loader) # 2048 维对 SVM 来说偏大,先降维再标准化 pca = PCA(n_components=256) feat_pca = pca.fit_transform(feat_all) feat_pca = scaler.fit_transform(feat_pca)这里的关键是 PCA 和 StandardScaler 的顺序:先 PCA 降维,再标准化。PCA 本身对尺度敏感,所以可以先标准化再 PCA,也可以 PCA 后标准化,两种都常见,但降维后再标准化通常效果更稳。预训练模型加上 SVM 这种组合,特别适合那些标注数据很少、但任务和 ImageNet 语义比较接近的项目。
6.2 特征融合:CNN 特征加手工特征
在工业场景里,光照变化、遮挡、噪声会让纯 CNN 特征在真实环境下失稳。一个务实的做法是把手工特征和 CNN 特征拼接起来再喂 SVM。手工特征包括颜色直方图、方向梯度直方图(HOG)、局部二值模式(LBP)等,它们对光照和形变的响应规律和 CNN 完全不同,两者互补性很强。
拼接操作本身没有任何技术含量,np.concatenate([cnn_feat, hog_feat], axis=1)即可,关键在两点。第一,拼接后的特征维度如果超过 500,建议先做 PCA 降到 256 维左右,否则 SVM 的训练时间和过拟合风险都会上升。第二,手工特征和 CNN 特征的数值范围往往差很远,拼接后必须做标准化,否则距离计算会偏向数值范围大的那部分特征。这个技巧解决过不少现场环境比训练环境复杂的问题,可以说是值得常备的后悔药之一。
6.3 离线特征缓存:SVM 推理只需要 CPU
CNN-SVM 在工程部署上有个巨大优势:特征可以离线算好缓存,线上推理时只需要跑 SVM。具体做法是训练阶段把训练集和测试集的特征全部导出成 npy 文件存到磁盘,之后调 SVM 参数时直接读文件,不用再跑一遍 CNN。这个习惯能大幅缩短实验迭代周期,因为 CNN 特征提取是最耗时的一步,而 SVM 训练在 CPU 上几乎秒级完成。线上部署时,特征提取部分用 GPU 批量算好,SVM 模型文件只有几十到几百 KB,CPU 毫秒级出结果,完全不需要在推理服务器上配 GPU。这是我做项目时的一个固定习惯:先离线缓存特征,再做后续实验;需要快速验证新想法时,几分钟就能出结果。
使用 CNN-SVM 的最大收益,其实是让「特征学习」和「分类决策」解耦,各自独立优化、独立验证、独立部署。遇到问题时可以先判断是特征的问题还是边界的问题,对症下药而不是盲目调参。希望这个两阶段框架能帮你在自己的数据上少走弯路,快速拿到一个稳健的基线。
本文还有配套的精品资源,点击获取