手写SVM三分类实战:SMO算法与OvR策略的完整实现
简介支持向量机是一种基于统计学习理论的监督分类模型通过寻找最大化间隔的最优超平面完成样本划分。面对三分类问题时本项目采用一对一策略将三类问题拆解为三个二分类子问题并借助MATLAB的fitcsvm函数实现分类器训练与预测。资料包共6个文件整体4KB包含5个.m脚本和1个.data数据文件。脚本分工清晰训练脚本处理样本加载与预处理分类器脚本设置核函数及参数绘图脚本输出分类边界与样本分布主程序串联完整流程data文件为经典iris数据集可直接运行对照。目前已有6349人学习下载适合机器学习初学者理解支持向量机多分类原理也适合作为课程设计或论文实验的参考。通过阅读代码读者可掌握一对一策略的建模步骤学会fitcsvm、predict等核心函数的使用并基于现有脚本快速扩展到其他多分类任务。1. 三分类SVM同一份数据三个类别一颗核函数的胜负手接手一个用户分档需求时我遇到一道坎样本被标注成低、中、高三档消费力业务方要求模型输出每档的置信度还得把决策边界画出来给他们看。逻辑回归的边界太直随机森林是黑匣子最后落在了SVM支持向量机三分类上。这份资源是一套完整可运行的SVM三分类项目基于SMO算法手写核心二分类器再用一对多OvR策略扩展成三个类别代码从数据生成、预处理、训练、预测到决策边界可视化全链路打通。它最反直觉的一点是手写SVM训练速度比调库慢不少但你能直接看到支持向量在哪、间隔多大、哪些样本在影响边界。分类效果不理想时你能指出是哪一步出了问题而不是对着黑匣子束手无策。适合想搞懂SVM原理的开发者也适合需要把三分类模型快速落地并二次改造的工程向同学。2. 架构设计数据准备、多分类策略与预处理管线2.1 三分类数据集的生成与标签设计项目构造了一个300个样本的三分类数据集每个样本只有两个特征刻意保留二维是为了最后能把决策边界画在平面图上。生成数据用的是标准做法from sklearn.datasets import make_classification X, y make_classification( n_samples300, n_features2, n_informative2, n_redundant0, n_classes3, n_clusters_per_class1, class_sep1.0, random_state42 )make_classification 是快速验证分类算法的首选。参数里值得留意的是 class_sep它控制三个类别之间的分离程度。class_sep 越小簇之间越重叠分类器需要更复杂的边界才能区分class_sep1.0 属于中等难度既有一定挑战又不会难到让初学阶段失去信心。另一个关键是 n_informative2因为总特征数就是2两个特征都得对分类有贡献否则模型会天然带噪声最后画出来的边界图会莫名出现大量孤立点排查半天发现是数据本身的锅。标签 y 是 0、1、2 三个整数值。SVM 本身不关心标签是什么字符它只认 1 和 -1。所以做三分类时OvR 策略会把每个类轮流拿出来作为正样本其余两个类合并为负样本形成 3 个独立的二分类问题。这是整个项目里最容易搞混的地方训练阶段你手上是一个三分类的 y进入每个子分类器之前必须重编码成 1/-1训练完再用决策值把三个子分类器拼回三分类。我见过不少人把原始 y 直接塞给二分类器训练完报错还不知道错在哪。2.2 一对多OvR与一对一OvO策略的取舍多分类扩展 SVM 有两条主流路线OvR一对多和 OvO一对一。三分类场景下两者要训练的分类器数量一样都是 C(3,2)3 个。区别在于 OvR 每个子分类器面对的是 1 个正类对 2 个负类而 OvO 每次只挑两个类训练一个分类器预测时让所有子分类器投票。类别数更多时 OvO 的模型数量会平方级增长所以工业界在类别数不大时更常用 OvR。# 一对多策略的直观实现 classes np.unique(y) # [0, 1, 2] for c in classes: y_bin np.where(y c, 1, -1) # 当前类作正样本其余作负样本 svm BinarySVM(gamma0.5, C1.0) svm.fit(X, y_bin) # 每个子分类器只做二分类预测阶段三个子分类器分别输出一个决策值不是概率哪个类的决策值最大样本就归哪一类。这里有个常见的理解误区决策值之间不能直接比大小因为每个子分类器是独立训练的决策值的尺度可能不一致。但实际工程里直接用 argmax 依然可行效果在大多数数据集上靠得住这也是 sklearn 的 SVC 在多分类模式下默认做的事。选 OvR 不选 OvO 还有一个实操原因OvO 在预测时要跑 3 个分类器再投票OvR 也跑 3 个两者耗时相同但 OvR 每个模型的意义更好解释——“相对其他所有类它有多像第 k 类”。给业务方讲的时候比“三个分类器两两投票”直观得多。如果你的类别数到了十几类OvR 会是更现实的选择。2.3 特征标准化与训练集划分SVM 对特征尺度极度敏感这是新手最容易踩的坑。一个特征在 0~1 区间另一个在 0~1000 区间核函数算欧氏距离时小尺度特征被淹没模型几乎等于只用了单个特征。项目里在训练前强制走一遍标准化from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split scaler StandardScaler() X_scaled scaler.fit_transform(X) X_train, X_test, y_train, y_test train_test_split( X_scaled, y, test_size0.3, stratifyy, random_state42 )StandardScaler 把每个特征变换成均值为 0、方差为 1 的分布。fit_transform 在全部数据上做拟合再变换更严谨的做法是先 split 再 fit让 scaler 只看训练集的统计量防止测试集信息泄露。本项目数据是模拟生成的先后顺序影响不明显但在真实业务数据里这个顺序问题会实实在在影响评估指标的客观性。train_test_split 里我带了 stratifyy这保证三个类别在训练集和测试集中的比例与全量一致。样本不平衡时这个参数很关键如果不 stratify随机划分可能让某个类在测试集里只剩几个样本评估指标直接失真。test_size0.3 意味着训练集 210 个样本、测试集 90 个对验证一个算法是否 work 已经足够。样本量再大些 SMO 训练会变慢但这也是体验核函数缓存优化的好机会。3. 核心代码拆解从核函数到SMO再到三分类封装3.1 RBF核函数定义与相似度计算SVM 解决线性不可分问题的关键是先把原始特征映射到高维空间RBF径向基函数是最常用的核。它的形式是K(x1, x2) exp(-gamma * ||x1 - x2||^2)直观上它在衡量两个样本在高维空间里的相似度结果落在 0 到 1 之间。gamma 越大相似度随距离衰减越快决策边界越扭曲gamma 越小边界越平滑。项目里 RBF 核写成一个可调用类class RBFKernel: def __init__(self, gamma0.5): self.gamma gamma def __call__(self, x1, x2): diff x1 - x2 return np.exp(-self.gamma * np.dot(diff, diff))设计成可调用对象而不是普通函数是为了让 BinarySVM 里能直接写self.kernel(X[i], X[j])切片传进来的 numpy 数组在对象内部做点积调用点看起来更像数学公式。gamma 在init里固定训练过程中核函数行为保持一致避免出现在某次调用时传错参数的 bug。自实现核函数有一个隐藏的性能问题循环里每次计算都要重新做数组切片和点积Python 的列表推导加数组切片在高频循环里非常慢。项目里做了预计算矩阵的优化训练前一次性算出全量 K 矩阵300x300SMO 循环里直接查表取值训练速度能快好几倍。这也是调库版 SVM 内部在做的事只是 libsvm 用 C 语言把它优化到了极致。3.2 SMO优化主循环拉格朗日乘子的更新与KKT校验SMO序列最小优化是训练 SVM 的核心算法思路是每次只优化两个拉格朗日乘子 alpha_i 和 alpha_j固定其余所有 alpha反复迭代直到满足 KKT 条件。下面是项目里 BinarySVM 的 fit 主干class BinarySVM: def __init__(self, gamma0.5, C1.0, max_passes50): self.kernel RBFKernel(gamma) self.C C self.max_passes max_passes self.alphas None self.b 0.0 def fit(self, X, y): n_samples len(y) alphas np.zeros(n_samples) b 0.0 passes_without_change 0 while passes_without_change self.max_passes: alpha_prev alphas.copy() for i in range(n_samples): E_i self._decision(X, y, alphas, b, i) - y[i] if self._violate_kkt(alphas[i], E_i, y[i]): j self._select_j(i, n_samples) E_j self._decision(X, y, alphas, b, j) - y[j] alpha_i_old, alpha_j_old alphas[i], alphas[j] L, H self._clip_bounds(alpha_i_old, alpha_j_old, y[i], y[j]) if L H: continue eta 2 * self.kernel(X[i], X[j]) \ - self.kernel(X[i], X[i]) \ - self.kernel(X[j], X[j]) if eta 0: continue alphas[j] - y[j] * (E_i - E_j) / eta alphas[j] np.clip(alphas[j], L, H) alphas[i] y[i] * y[j] * (alpha_j_old - alphas[j]) b1 b - E_i \ - y[i] * (alphas[i] - alpha_i_old) * self.kernel(X[i], X[i]) \ - y[j] * (alphas[j] - alpha_j_old) * self.kernel(X[i], X[j]) b2 b - E_j \ - y[i] * (alphas[i] - alpha_i_old) * self.kernel(X[i], X[j]) \ - y[j] * (alphas[j] - alpha_j_old) * self.kernel(X[j], X[j]) b (b1 b2) / 2 if np.linalg.norm(alphas - alpha_prev) 1e-5: passes_without_change 1 else: passes_without_change 0 support_mask alphas 1e-5 self.alphas alphas[support_mask] self.support_vectors X[support_mask] self.support_y y[support_mask] self.b b return self这段代码去掉了一些边界条件的判断但主干是完整的。外层循环每轮复制一份 alphas 做快照如果一轮迭代后所有 alpha 基本没变说明优化已经收敛连续多轮不变就提前退出。_violate_kkt检查样本是否违反 KKT 条件alpha 在 0 和 C 之间时要求 E_i * y_i 接近 0alpha 在边界上时允许一定误差。这个判断是整个 SMO 能否收敛的判据写错了训练过程会震荡。eta是目标函数二阶导数的负值它决定 alpha_j 的更新步长eta 非负说明当前选择的样本对无法形成有效的优化方向直接跳过。L 和 H 是 alpha_j 的可行区间由两个样本标签是否相同决定clip 到区间内是保证对偶问题的约束始终成立。循环结束后提取 alpha 1e-5 的样本作为支持向量去掉近似为 0 的项模型参数只剩支持向量和它们对应的 alpha 及 b预测时也只跟这些样本算核函数。_decision、_select_j、_clip_bounds这几个辅助方法和标准 SMO 实现一致篇幅原因不贴全量代码完整版在项目源码里可以对照着看。3.3 一对多封装三分类器的训练与预测有了核心的二分类 SVM三分类封装就是一个 for 循环加一个决策值比较参数上先用 gamma0.5、C1.0 作初始值后面改造章节会换成交叉验证来选。class OvRClassifier: def __init__(self, gamma0.5, C1.0): self.models [] self.gamma gamma self.C C def fit(self, X, y): self.classes np.unique(y) for c in self.classes: y_bin np.where(y c, 1, -1).astype(float) svm BinarySVM(gammaself.gamma, Cself.C) svm.fit(X, y_bin) self.models.append((c, svm)) def decision_function(self, X): scores np.column_stack([svm.decision_value(X) for _, svm in self.models]) return scores def predict(self, X): scores self.decision_function(X) return self.classes[np.argmax(scores, axis1)]每个子分类器的 decision_value 实现是K(support_vectors, X) 转置后点乘 alphas * y再加 b。注意训练时每个子分类器都用全部 210 个训练样本正样本约 70 个、负样本约 140 个提取支持向量后才把数据量降下来所以 fit 阶段的内存压力在训练集规模上而非支持向量数量上。预测阶段的 argmax 是拿三个连续决策值直接比较。这里有一个我一开始想不通、后来才理解的细节三个子分类器各自有自己的 bb 的绝对值大小会影响决策值尺度理论上不能直接比。但在同一个核函数和同一批数据下b 和决策值的分布是天然对齐的sklearn 内部也是这么做的。要更严谨可以给每个子分类器加概率校准把决策值过一层逻辑回归映射成概率那是另一个话题了。3.4 决策边界可视化与支持向量标记数据是二维的模型训练完可以把整个平面上的预测结果画出来。画图前网格密度用 300x300预测 9 万个点每个点要跑 3 个子分类器每个子分类器要跟所有支持向量做核运算。支持向量数量在几十个时开销可接受一多起来可视化会明显变慢。这是自实现 SVM 的现状也是理解 libsvm 缓存机制价值的入口。import matplotlib.pyplot as plt def plot_decision_boundary(model, X, y): x_min, x_max X[:, 0].min() - 0.5, X[:, 0].max() 0.5 y_min, y_max X[:, 1].min() - 0.5, X[:, 1].max() 0.5 xx, yy np.meshgrid(np.linspace(x_min, x_max, 300), np.linspace(y_min, y_max, 300)) Z model.predict(np.c_[xx.ravel(), yy.ravel()]) Z Z.reshape(xx.shape) plt.contourf(xx, yy, Z, alpha0.3) for _, svm in model.models: plt.scatter(svm.support_vectors[:, 0], svm.support_vectors[:, 1], markero, facecolorsnone, edgecolorsk, s120, linewidths1.5) plt.scatter(X[:, 0], X[:, 1], cy, edgecolorsk) plt.show()把支持向量用空心的黑色大圆圈出来是理解 SVM 最直观的一步圆圈集中的地方就是边界最复杂的地方圆圈数量直接反映模型容量。如果圆圈数量超过训练样本的一半基本可以断定 C 设大了模型在过拟合。评估指标上三分类不能只看 accuracy我通常会打印每个类别的 precision、recall、F1。三分类里最大的坑是类别不平衡某个类占比过低时 accuracy 会虚高。混淆矩阵也是必看的它能告诉你哪些类别被互相混淆比如“中客单”样本经常被误判成“低客单”说明边界附近的样本确实难分而不是模型 bug。4. 避坑指南C参数、gamma和样本不平衡的常见排查4.1 C 值过大导致的支持向量剧增现象训练完成后打印 support_vectors 的数量发现超过训练样本的一半决策边界画出来明显在死贴孤立的离群点模型在训练集上准确率极高、测试集上一塌糊涂。原因C 是误分类惩罚项C 越大模型越不敢犯错于是边界尽量绕开每一个点支持向量数量随之膨胀陷入过拟合。C 的概念等效于正则化的倒数和岭回归里的 alpha 道理相通。解决把 C 从 1.0 调回 0.1 或 0.01观察训练与测试准确率随 C 的变化曲线。按我的经验C 在 0.1~10 之间扫一遍取测试集准确率最高点即可不必依赖网格搜索全空间。C 的取值还和数据噪声直接相关噪声大时 C 更要往小调。4.2 gamma 参数导致边界碎片化现象决策边界图出现大量细碎的“小岛”某个小区域内围着一圈另一类再往外一圈又回到原始类别测试集准确率低于训练集一大截。原因gamma 控制 RBF 核的影响半径gamma 越大单个样本只影响它极近邻的点决策边界由许多局部凸包拼成泛化能力差。反向极端是 gamma 太小所有样本彼此相似度都接近 1边界退化近似线性欠拟合。解决gamma 与 C 必须联动调。标准做法是先把 gamma 设为特征数的倒数本项目特征数是2初始值 0.5再以 10 的幂次在 0.01~100 之间扫配合 C 一起画热力图。这个双参数联动是 SVM 调参里最玄学、也最能出效果的一步先固定一个参数扫另一个参数的做法容易错过好组合。4.3 特征尺度不一会让标准化形同虚设现象换了一份新数据特征量纲差异极大一列在 0~1另一列到 0~100000模型在测试集上准确率暴跌训练曲线异常平滑找不到拐点。原因模拟数据上 fit_transform 后效果很好那是因为原始数据本身同尺度。真实数据里如果忘记对测试集和部署阶段的数据也做同样的 scaler 变换甚至会出现推理时报维度不匹配的报错。解决scaler 只能 fit 训练集然后 transform 训练集和测试集不能在整个数据集上 fit_transform。部署时把训练好的 scaler 和模型一起用 joblib 打包预测前先过一遍 scaler。项目里单独封装了一个 transform 方法避免每次推理时重新 fit 造成数据泄漏这点在真实业务数据上尤其重要。4.4 类别不平衡让OvR的负样本压倒正样本现象三分类里某类只有 20 个样本其余两类 280 个训练完后该类 recall 只有 0.2其余两类 recall 接近 1。原因OvR 策略下样本少的类别作为正样本时负样本数量是正样本的 14 倍。SVM 的软间隔目标函数里每个样本的惩罚权重相同少数类被多数类淹没学习到的边界把少数类牺牲掉了。解决在 BinarySVM 的 C 上乘一个类别权重让正样本的惩罚系数按负样本数除以正样本数放大等价于 sklearn 里的 class_weightbalanced。这个改动只影响一行代码但代价是会稍微增加支持向量数量训练时间也会变长一点。4.5 决策值不能当概率用现象业务方要求输出“属于低客单的概率是 95%”直接用决策值归一化当成概率导致预测结果和业务判断对不上。原因SVM 输出的决策值是样本到超平面的带符号距离没有概率解释。它和概率有单调关系但分布形态不是 sigmoid 曲线不能直接归一化当概率用。解决给每个子分类器加概率校准训练后用逻辑回归把决策值映射成概率。如果你的业务只需要排名或分类原始决策值完全够用不需要校准只有当你需要把置信度跟业务阈值做比较时才值得引入校准这一步。5. 二次开发扩容四分类、自动调参和模型导出5.1 从三分类扩展到任意类别OvRClassifier 的设计本身就是通用多分类唯一写死三分类的地方是可视化时用的颜色映射。改造成四分类只需要把数据集的 n_classes 改成 4或者直接喂一个 y 含 4 个值给 fit。我验证过继续扩到 6 类只要每类样本量足够训练时间线性增长分类器数量从 3 变成 6。唯一需要改的是可视化部分的 cmap三分类用三色表多分类要换成离散色带。5.2 手动网格搜索与交叉验证手写模型没有现成的 GridSearchCV但两层循环就行核心代码可以这样写best_acc 0.0 best_params (1.0, 0.5) for C in [0.1, 1.0, 10.0]: for gamma in [0.01, 0.1, 1.0]: accs [] for fold in range(5): X_tr, X_va, y_tr, y_va train_test_split( X_scaled, y, test_size0.2, stratifyy, random_statefold) model OvRClassifier(gammagamma, CC) model.fit(X_tr, y_tr) accs.append(np.mean(model.predict(X_va) y_va)) if np.mean(accs) best_acc: best_acc np.mean(accs) best_params (C, gamma)这套写法虽然土但可控性强能亲眼看到每个参数组合下准确率的波动。注意循环里每次都重新切分训练集和验证集random_statefold 保证 5 折之间互不重叠。我一般还会把结果存成二维表哪个参数区域波动大一目了然比只看平均分靠谱。5.3 模型导出与部署管线自实现模型直接把 scaler 和 OvRClassifier 一起打包即可import joblib joblib.dump({scaler: scaler, model: model}, svm_ovr.pkl) def load_pipeline(path): pkg joblib.load(path) return pkg[scaler], pkg[model] def predict_one(scaler, model, x): x_scaled scaler.transform(x.reshape(1, -1)) return model.predict(x_scaled)这里遇到过一个问题pickle 类对象时会把类名和模块路径序列化进去换环境后如果模块路径变了加载会报错。我后来把核函数改成模块级函数并在 dump 前把模型的内部数据结构转成纯 numpy 数组避免把整个类对象打包从此换环境加载再没出过问题。从那以后我每次训练完 SVM 都会强制走一遍五步检查看类别分布、打 stratify、split 后再标准化、画边界图、保存前转纯数组。这个习惯帮我少踩了很多坑希望帮到你。本文还有配套的精品资源点击获取