简介:这份资源面向图像分类入门者与机器学习实践者,聚焦支持向量机(SVM)在图片分类任务中的完整实现。内容围绕核函数映射、最优超平面求解、图像预处理与特征提取展开,涵盖灰度化、归一化、降噪以及颜色直方图、纹理、边缘等特征描述子的构建思路,并借助MATLAB工具箱完成模型训练、预测与性能评估,适合需要理解SVM分类流程、动手复现小样本高维图像分类方案的学习者。资源包共5个文件,以4个m脚本和1个mat数据文件为主,脚本承担特征提取、模型训练与预测等核心环节,mat文件用于存放分类器模型数据,压缩包整体约14.03MB,结构紧凑便于直接运行调试。目前已有384人学习下载,读者可据此掌握从特征工程到参数调优的完整链路,并借助交叉验证、准确率与召回率等指标评估模型表现。
1. 从一堆像素到一条边界:SVM 图片分类到底在分什么
手里有一批图片,想按内容自动分成几类,但既不想上深度学习那套 GPU 训练流程,又希望在小样本上拿到能看的准确率——这种场景下,支持向量机(SVM)图片分类依然是很多从业者的第一选择。它的核心思路不复杂:把每张图片转成一个固定长度的特征向量,再在高维空间里找一条能把不同类别尽量分开的决策边界,离边界最近的那些样本点就是支持向量,它们决定了边界的位置。放到图片分类任务里,SVM 特别适合样本量在几百到几千、类别数不多、特征维度可控的情况,比如工业质检里的缺陷二分类、医学影像的良恶性初筛、遥感小图斑的粗分类。它训练快、可解释、调参维度少,不用堆显卡,一台普通笔记本就能跑完整个流程。但前提是你得把特征工程做对,否则再好的分类器也救不回来。
2. 特征怎么提:把图片变成 SVM 能吃的向量
2.1 为什么不能直接把像素铺平丢进去
一张 128×128 的彩色图片,铺平后是 49152 维。直接拿这个原始像素向量去训 SVM,会撞上两个硬问题。第一是维度灾难:样本数远小于特征维度时,任何分类器都容易过拟合,SVM 也不例外,它会找到一条在训练集上完美分割但在测试集上崩掉的边界。第二是像素本身不具备语义不变性,同一类物体稍微平移、旋转、缩放,像素值就面目全非,SVM 学到的边界根本泛化不了。
常见做法是先做特征提取,把原始像素压缩成几十到几百维的、对光照和几何变换更鲁棒的特征。传统方案里用得最多的是 HOG(方向梯度直方图)、LBP(局部二值模式)、颜色直方图,以及用预训练 CNN 的倒数第二层输出做特征。这几种各有适用面:HOG 对形状和边缘敏感,适合行人、车辆、字符这类轮廓清晰的物体;LBP 对纹理敏感,适合材质分类、表面缺陷检测;颜色直方图对颜色分布敏感,适合场景分类;预训练 CNN 特征则在小样本上表现最稳,但需要额外装一个深度学习框架来抽特征。
我一般会先跑一个基线:把图片统一缩到 128×128 灰度,提 HOG 特征,直接喂给线性 SVM。这个基线跑通之后,再根据混淆矩阵看哪些类容易混,决定要不要换特征或加特征。
2.2 用 HOG 提特征的完整代码
下面这段代码用 scikit-image 提 HOG 特征,用 scikit-learn 训 SVM,整个流程可以直接抄。
import os import numpy as np from skimage.io import imread from skimage.transform import resize from skimage.feature import hog from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report # 1. 读取图片并统一尺寸 def load_images(data_dir, img_size=(128, 128)): X, y = [], [] # 假设目录结构为 data_dir/类别名/xxx.jpg for label_name in sorted(os.listdir(data_dir)): class_dir = os.path.join(data_dir, label_name) if not os.path.isdir(class_dir): continue for fname in os.listdir(class_dir): if not fname.lower().endswith(('.jpg', '.png', '.jpeg')): continue img = imread(os.path.join(class_dir, fname), as_gray=True) img = resize(img, img_size, anti_aliasing=True) X.append(img) y.append(label_name) return np.array(X), np.array(y) # 2. 提取 HOG 特征 def extract_hog(images): features = [] for img in images: # orientations=9: 梯度方向分9个bin # pixels_per_cell=(16,16): 每个cell 16x16像素 # cells_per_block=(2,2): 每2x2个cell归一化一次 fd = hog(img, orientations=9, pixels_per_cell=(16, 16), cells_per_block=(2, 2), block_norm='L2-Hys', visualize=False) features.append(fd) return np.array(features) # 3. 主流程 data_dir = './dataset' # 替换成你的数据目录 X_img, y = load_images(data_dir) X_feat = extract_hog(X_img) print(f"特征维度: {X_feat.shape}") # 例如 (500, 1764) # 4. 划分训练集和测试集,stratify保证类别比例一致 X_train, X_test, y_train, y_test = train_test_split( X_feat, y, test_size=0.2, random_state=42, stratify=y) # 5. 标准化 + SVM 管道 # StandardScaler 对 HOG 特征很重要,因为不同维度的梯度幅值范围差异大 pipe = Pipeline([ ('scaler', StandardScaler()), ('svm', SVC(kernel='rbf', C=10, gamma='scale', probability=True)) ]) pipe.fit(X_train, y_train) y_pred = pipe.predict(X_test) print(classification_report(y_test, y_pred))这段代码的逻辑链条是:先统一图片尺寸消除尺度差异,再用 HOG 把每张图压成一个一维向量,然后标准化让各维特征在同一量级,最后送进 RBF 核 SVM。参数上,orientations=9是 HOG 最常用的默认值,方向分得太细会增加维度但收益递减;pixels_per_cell=(16,16)决定了局部纹理的粒度,图片内容越细碎这个值应该越小,但最小不建议低于 8;cells_per_block=(2,2)配合L2-Hys归一化能显著提升对光照变化的鲁棒性。SVM 这边,C=10是我在多数小样本任务上的起手值,gamma='scale'让 scikit-learn 自动按特征方差算一个合理初始值,比手动猜省事。
2.3 核函数怎么选:线性、RBF 还是多项式
选核函数本质上是在选决策边界的形状。线性核只能画直线(高维空间里的超平面),适合特征维度已经很高、样本本身近似线性可分的情况,比如用预训练 CNN 特征做分类时,线性核往往就够了,而且训练速度最快。RBF 核能把数据映射到无限维空间,理论上可以拟合任意形状的边界,是图片分类里最通用的选择,代价是多了gamma这个参数要调。多项式核在图像任务里用得少,因为它的参数更多、数值稳定性更差,除非你有明确理由认为特征之间存在多项式级别的交互关系。
我的经验是:先用线性核跑一个基线,看交叉验证准确率。如果线性核和 RBF 核差距在 3 个百分点以内,优先用线性核,因为推理速度快、模型文件小、部署省心。如果差距明显,再上 RBF 核,然后用网格搜索把C和gamma一起调。
3. 训练与调参:把准确率从 70% 推到 90%
3.1 网格搜索的代码与参数解释
SVM 的两个核心超参数是C和gamma。C控制对误分类的惩罚力度:C越大,模型越不允许训练样本被分错,边界越紧,容易过拟合;C越小,允许更多样本落在间隔内,边界越平滑,容易欠拟合。gamma控制单个样本的影响半径:gamma越大,影响范围越小,边界越围绕训练点转,过拟合风险高;gamma越小,影响范围越大,边界越趋向线性。
下面用GridSearchCV做交叉验证搜索:
from sklearn.model_selection import GridSearchCV param_grid = [ # 线性核只需要调 C {'svm__kernel': ['linear'], 'svm__C': [0.1, 1, 10, 100]}, # RBF 核需要同时调 C 和 gamma {'svm__kernel': ['rbf'], 'svm__C': [0.1, 1, 10, 100], 'svm__gamma': ['scale', 0.001, 0.01, 0.1]}, ] grid = GridSearchCV( pipe, param_grid, cv=5, # 5折交叉验证 scoring='f1_macro', # 类别不均衡时用f1_macro比accuracy更稳 n_jobs=-1, # 用满所有CPU核 verbose=1 ) grid.fit(X_train, y_train) print("最佳参数:", grid.best_params_) print("最佳交叉验证分数:", grid.best_score_) # 用最佳模型在测试集上评估 best_model = grid.best_estimator_ y_pred = best_model.predict(X_test) print(classification_report(y_test, y_pred))这段搜索一共会训练 (4 + 16) × 5 = 100 个模型。在几百到几千样本的规模下,用普通 CPU 几分钟到十几分钟能跑完。scoring='f1_macro'是关键选择:如果各类样本数量不均衡,accuracy 会被多数类主导,f1_macro 对每个类平等对待,更能反映模型在少数类上的表现。cv=5是默认起手值,样本量少于 200 时可以降到 3,样本量超过 2000 时可以升到 10。
3.2 类别不均衡怎么处理
图片分类数据集里类别不均衡是常态,比如缺陷检测中正常样本远多于缺陷样本。SVM 默认对所有样本一视同仁,结果就是模型倾向于把少数类也判成多数类。scikit-learn 的SVC提供了class_weight='balanced'参数,会自动按类别频率反比设置权重,让少数类的误分类代价更高。
# 在 SVC 里加上 class_weight pipe_balanced = Pipeline([ ('scaler', StandardScaler()), ('svm', SVC(kernel='rbf', C=10, gamma='scale', class_weight='balanced', # 自动平衡类别权重 probability=True)) ])如果balanced还不够,可以手动传一个字典,比如class_weight={'正常': 1, '缺陷': 5},把缺陷类的权重拉到 5 倍。这个值没有理论最优解,一般从 3 到 10 之间试,看少数类的召回率能不能提上来,同时多数类的精确率不要掉太多。
另一个常见做法是过采样少数类或欠采样多数类,但我更倾向于先用class_weight,因为它不改变数据分布,不会引入合成样本带来的噪声。如果class_weight调完效果仍不理想,再考虑 SMOTE 过采样,但要注意 SMOTE 应该在训练集上做,不能碰测试集。
3.3 用交叉验证曲线判断过拟合还是欠拟合
调参不能盲调,得先判断模型当前是过拟合还是欠拟合。画一条C值的学习曲线,同时看训练集分数和交叉验证分数:
import matplotlib.pyplot as plt from sklearn.model_selection import validation_curve C_range = [0.01, 0.1, 1, 10, 100, 1000] train_scores, val_scores = validation_curve( pipe, X_train, y_train, param_name='svm__C', param_range=C_range, cv=5, scoring='f1_macro', n_jobs=-1 ) train_mean = train_scores.mean(axis=1) val_mean = val_scores.mean(axis=1) plt.figure(figsize=(8, 5)) plt.semilogx(C_range, train_mean, 'o-', label='训练集') plt.semilogx(C_range, val_mean, 's-', label='交叉验证') plt.xlabel('C') plt.ylabel('F1-macro') plt.legend() plt.grid(True) plt.show()判读方法很直接:如果训练集分数很高、验证集分数明显低,说明过拟合,应该减小C或增大gamma的约束;如果两者都低且接近,说明欠拟合,应该增大C或换更强的核函数;如果两者都高且接近,说明当前参数区间已经合适。这条曲线我每次调 SVM 都会画一遍,比盲目网格搜索省时间。
4. 避坑与排查:五个让准确率莫名掉点的坑
4.1 现象:训练集准确率 99%,测试集只有 60%
原因:典型过拟合。最常见的原因是C设得太大,或者 HOG 特征维度远高于样本数,模型把训练样本的噪声也学进去了。另一个隐蔽原因是数据泄漏——在划分训练测试集之前就做了全局标准化,导致测试集的统计信息渗进了训练过程。
解决:先把C从 100 降到 1 或 0.1 试;再把标准化放进Pipeline里,确保fit只见到训练数据;如果特征维度确实过高(比如超过样本数的 1/10),用PCA降维或减少 HOG 的orientations。
4.2 现象:所有测试样本都被判成同一类
原因:类别极度不均衡,且没有设class_weight。SVM 在优化时发现把所有样本判成多数类就能让总体损失最小,于是直接躺平。另一个可能是gamma设得过大,每个样本的影响半径极小,模型退化成最近邻,而最近邻又被多数类淹没。
解决:加class_weight='balanced';检查gamma是否在合理范围,RBF 核的gamma起手值可以用1 / n_features估算;如果少数类样本确实太少(少于 20 个),考虑先收集更多数据或做过采样。
4.3 现象:交叉验证分数波动很大,换一个随机种子结果差 10 个点
原因:样本量太小,或者某些类别的样本数太少,导致每折验证集的类别分布不稳定。train_test_split如果没用stratify,也可能让某一折里某个类完全缺失。
解决:train_test_split和GridSearchCV里都加上分层策略。GridSearchCV可以用StratifiedKFold作为cv参数:
from sklearn.model_selection import StratifiedKFold cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) grid = GridSearchCV(pipe, param_grid, cv=cv, scoring='f1_macro', n_jobs=-1)如果样本量实在太小(每类不到 30 个),交叉验证的波动天然就大,这时候应该报告多次随机划分的均值和标准差,而不是只报一个最好看的数。
4.4 现象:预测新图片时报错,提示特征维度不匹配
原因:训练时用的图片尺寸和预测时不一致。HOG 特征维度由图片尺寸、pixels_per_cell、cells_per_block共同决定,任何一项变了,特征维度就变了。常见翻车场景是训练时用了 128×128,预测时随手丢了一张 256×256 的图进去。
解决:把图片预处理(resize、灰度化)封装成一个函数,训练和推理都走同一个函数。更稳妥的做法是把整个预处理加特征提取加 SVM 推理打包成一个Pipeline或一个自定义类,对外只暴露一个predict(image_path)接口。
4.5 现象:模型在验证集上表现正常,部署后准确率持续下降
原因:训练数据的采集条件和实际部署环境不一致。比如训练图片是实验室光照下拍的,部署现场是强逆光或暗光;或者训练时用的是某个型号的相机,部署时换了镜头导致颜色偏移。
解决:在部署环境里采集一批新数据,人工标注后作为测试集重新评估。如果确实存在分布偏移,要么在训练数据里加入部署环境的样本,要么在预处理阶段做颜色归一化(比如直方图均衡化或白平衡校正)。这个坑没有一劳永逸的解法,只能靠持续监控和定期更新训练集来缓解。
5. 进阶技巧:用预训练特征 + 概率校准把 SVM 推到生产可用
HOG 特征在简单场景够用,但遇到类内差异大、类间差异小的任务(比如不同品种的花、不同型号的零件),HOG 的表征能力就不够了。这时候可以用预训练 CNN 抽特征,再喂给 SVM。具体做法是:拿一个在 ImageNet 上预训练好的网络(比如 ResNet18),去掉最后的全连接层,把倒数第二层的 512 维输出作为每张图片的特征向量。这个特征已经包含了丰富的语义信息,在小样本上通常比 HOG 高 10 到 20 个百分点。
import torch import torchvision.models as models import torchvision.transforms as transforms from PIL import Image # 加载预训练 ResNet18,去掉最后的分类层 model = models.resnet18(pretrained=True) model = torch.nn.Sequential(*list(model.children())[:-1]) # 输出 512 维 model.eval() # 图片预处理:ResNet 要求 224x224,且需要 ImageNet 标准化 preprocess = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) def extract_cnn_feature(img_path): img = Image.open(img_path).convert('RGB') tensor = preprocess(img).unsqueeze(0) # 加 batch 维度 with torch.no_grad(): feat = model(tensor) return feat.squeeze().numpy() # 512 维向量拿到 512 维特征后,直接送进线性 SVM 就行,不需要 RBF 核,因为 CNN 特征本身已经近似线性可分。这里有个细节:model.eval()必须调用,否则 BatchNorm 层会用当前 batch 的统计量而不是训练时保存的全局统计量,导致同一张图每次抽的特征都不一样。另外torch.no_grad()能省显存和计算时间,推理阶段不需要梯度。
第二个进阶点是概率校准。SVM 的predict_proba输出并不是真正的概率,而是用 Platt 缩放拟合出来的近似值,在样本量少的时候偏差很大。如果下游业务需要拿概率做阈值决策(比如缺陷概率超过 0.9 才报警),建议用CalibratedClassifierCV做一层校准:
from sklearn.calibration import CalibratedClassifierCV # 用 sigmoid 校准,cv=5 表示内部交叉验证 calibrated_svm = CalibratedClassifierCV( SVC(kernel='linear', C=1), method='sigmoid', cv=5 ) calibrated_svm.fit(X_train_cnn, y_train) # 输出的概率更可靠 proba = calibrated_svm.predict_proba(X_test_cnn)校准之后,概率的排序不变,但数值更接近真实频率。我一般会在模型上线前画一条可靠性曲线(reliability diagram),看预测概率和实际正例率是否贴合。如果偏离太大,说明校准没做到位,需要检查校准集的样本量是否足够。
从那以后我每次把 SVM 模型交给业务方之前,都会强制走一遍概率校准和可靠性曲线检查,因为业务方拿到的不是一个分类标签,而是一个可以设阈值的概率值。希望帮到你。
本文还有配套的精品资源,点击获取