
简介这份资料围绕支持向量机完成数据三分类的MATLAB实现面向已了解SVM二分类基础、希望进一步掌握多分类策略与代码落地的学习者也可供课程实验和算法对比使用。包内共6个文件以5个m脚本和1个data数据文件为主脚本覆盖训练、分类、核函数计算与结果可视化等环节数据文件提供鸢尾花样本用于跑通流程压缩包约4KB体量轻便便于直接运行、拆解和二次修改。资源已有6351人学习下载说明其在SVM多分类入门与教学实践场景中具有一定参考价值。读者可据此理解一对一策略如何把三分类拆解为多个二分类器并结合核函数选择、参数设置、预测结果汇总与分类边界绘制形成从训练到评估的完整实践链路也可作为MATLAB机器学习作业和算法对比实验的起点。1. 三分类不是跑三遍二分类一次投票平局踩出来的认知第一次拿 SVM支持向量机做数据的三分类我以为把标签拆成三个「是不是」的二分类问题就完事了结果预测结果里出现了同一批样本被两个分类器同时判成正例、第三个分类器完全不投票的情况准确率卡在 0.66 上不去——正好是三分之二。后来才搞明白多分类不是把二分类复制三份OvR 与 OvO 的拆解方式、投票平局的打破规则、每个子分类器里正负样本的比例都会直接改写最终边界。这套代码把数据生成、SMO 训练、三分类投票、边界可视化串成一条能跑的链路适合刚接触核方法、想看清 SVM 内部到底在算什么的人。2. 项目骨架四个模块拆开「造数据—训练—投票—画图」SVM 三分类的代码量其实不大但如果把核矩阵、SMO 循环、多分类投票、画图全塞进一个main.py调参时会很难受改一个 gamma不知道是数据的锅还是可视化采样的锅。我一般按「数据、算法、调度、展示」四层切每层只依赖下一层的接口换核函数不用动train.py换数据格式不用动smo.py。2.1 目录结构与每个文件的职责svm-tri-class/ ├── config.yaml # 超参统一入口训练和画图都读它避免两处不一致 ├── requirements.txt ├── data/ │ └── gen_data.py # 三类合成数据固定随机种子保证可复现 ├── svm/ │ ├── kernel.py # linear / rbf / poly 三种核全部矩阵化实现 │ ├── smo.py # 简化 SMO输出 alpha、支持向量、截距 b │ └── multiclass.py # OvO 封装两两配对训练 决策值累加 投票 ├── train.py # 训练入口产出 model.npz ├── evaluate.py # 混淆矩阵、macro-F1、分类报告 ├── viz/ │ └── boundary.py # 网格采样 contourf 画三分类边界 └── notebooks/ └── quickstart.ipynb # 五分钟跑通版参数写死方便对照kernel.py里只有函数没有类因为它本质是纯计算不需要状态smo.py也只暴露一个smo_simple(X, y, ...)故意不带类的包装这样你在 notebook 里能直接单步进去看 alpha 的变化。真正有状态的是multiclass.py里的OvOClassifier它持有三对二分类器模型和类别顺序classes_。这个划分带来的好处很直接想验证「线性核和 RBF 核在同一个三分类任务上差多少」只要改config.yaml里的一个kind字段其余代码一行不动。还有一个容易被忽略的点——data/gen_data.py必须固定random_state。核方法的决策边界对样本分布极其敏感同一组超参在不同随机种子下测试集准确率能差 8 个百分点调参时如果种子每次变你会陷入「改了参数变好、再跑一遍又变差」的玄学循环最后谁都不信。2.2 依赖与两条运行路径依赖刻意压到最少除了 numpy 和 matplotlib只有 scikit-learn 用来做对照实验和网格搜索不想用它也能跑# requirements.txt numpy1.24 scikit-learn1.3 matplotlib3.7 pyyaml6.0安装和两条运行路径pip install -r requirements.txt # 路径一numpy 手写版走自己的 SMO python train.py --config config.yaml --backend numpy # 路径二sklearn 对照版走 SVC用来验证手写实现没跑偏 python train.py --config config.yaml --backend sklearn # 训练完画边界 python viz/boundary.py --model outputs/model.npz--backend这个开关是我特意留的。手写 SMO 最容易出的问题不是算错而是「看起来对了」——准确率 0.9 就以为收敛了实际上支持向量只挑出来 3 个。有个 sklearn 版本在旁边当参照同样的 C 和 gamma两边的 macro-F1 应该在 2 个百分点以内超过这个数就说明手写那版有问题。2.3 config.yaml 的参数与调它会发生什么参数默认值作用调大之后的现象kernel.kindrbf核类型linear 只能切直线换 rbf 边界变弯训练集分数上升kernel.gamma0.5RBF 的宽度倒数过大边界碎裂成孤岛过小平滑欠拟合svm.C1.0惩罚系数容忍多少错分过大过拟合支持向量占比下降svm.tol1e-3KKT 条件的容忍阈值过小训练变慢过大提前停svm.max_iter40连续无更新轮数上限过小直接不收敛alpha 全零data.n_samples450三类样本总数过少时 OvO 某对样本失衡C和gamma是一对互相牵制的参数单独调其中一个很容易得出错误结论。我的经验是先固定C1在gamma上按 0.01、0.1、0.5、1、10 扫一遍看大致形状再回到C上按十倍量级细调最后两队参数一起做二维网格搜索。跳过第一步直接从网格搜索开始也不是不行只是搜索空间大、耗时翻倍中间过程你完全看不到边界是怎么变的。3. 核心实现SMO 求解器与 OvO 投票器的逐段拆解这一章是整套代码里唯一值得逐行读的部分。SVM 的公式推导在纸上是优雅的落到代码里就三件事算核矩阵、在约束下更新两个拉格朗日乘子、把乘子换算成决策值。三分类的复杂度全部压在「怎么把 k 个类拆成 k(k-1)/2 个二分类」这一步上。3.1 核函数先把 K 矩阵算对SMO 才有意义import numpy as np def kernel_matrix(X, Z, kindrbf, gamma0.5, degree3, coef01.0): X: (m, n), Z: (k, n) - 返回 (m, k) 的核矩阵 if kind linear: return X Z.T if kind poly: return (gamma * (X Z.T) coef0) ** degree if kind rbf: # ||x-z||^2 ||x||^2 ||z||^2 - 2*x·z展开算比双重循环快一个量级 x2 np.sum(X ** 2, axis1)[:, None] z2 np.sum(Z ** 2, axis1)[None, :] d2 x2 z2 - 2 * (X Z.T) # 浮点误差可能让距离出现 -1e-16np.exp 会把它当正常值放过去先夹一下 return np.exp(-gamma * np.maximum(d2, 0.0)) raise ValueError(f未知核类型: {kind})三个参数的分工要说清楚gamma只对 rbf 和 poly 生效物理含义是「单个样本的影响半径」gamma越大每个支持向量的势力范围越小边界越贴近样本点degree控制多项式次数通常 2 到 3 足够再高数值会溢出coef0是多项式里的常数项只在 poly 核里起作用。np.maximum(d2, 0.0)这一行不是多余的。当两个样本几乎重合时展开式算出来的平方距离可能是负的极小值np.exp(-gamma * 负数)会得到略大于 1 的核值破坏核矩阵的半正定性SMO 迭代时表现为 eta 反复为正、乘子更新被continue掉训练静默停住。这种 bug 不会报错只会让你觉得「怎么一个支持向量都没有」。3.2 简化 SMO两个乘子怎么选、怎么剪def smo_simple(X, y, C1.0, tol1e-3, max_iter40, kindrbf, gamma0.5): m X.shape[0] K kernel_matrix(X, X, kindkind, gammagamma) alphas np.zeros(m) b 0.0 passes 0 # 连续「没有任何乘子被更新」的轮数 while passes max_iter: changed 0 for i in range(m): Ei float((alphas * y) K[:, i] b - y[i]) # 第 i 个样本的误差 # KKT 违反检查外侧越界且乘子还没顶到 0 或 C if (y[i] * Ei -tol and alphas[i] C) or \ (y[i] * Ei tol and alphas[i] 0): j i while j i: j np.random.randint(0, m) # 随机选第二个乘子 Ej float((alphas * y) K[:, j] b - y[j]) ai_old, aj_old alphas[i], alphas[j] # 1. 先把 alpha_j 的可行区间 [L, H] 卡出来 if y[i] ! y[j]: L max(0.0, aj_old - ai_old) H min(C, C aj_old - ai_old) else: L max(0.0, ai_old aj_old - C) H min(C, ai_old aj_old) if L H: continue # 区间退化这对没得调 # 2. 沿约束方向求极值eta 是目标函数的二阶导 eta 2 * K[i, j] - K[i, i] - K[j, j] if eta 0: continue alphas[j] np.clip(aj_old - y[j] * (Ei - Ej) / eta, L, H) if abs(alphas[j] - aj_old) 1e-5: continue # 移动太小不值得更新 b # 3. 由等式约束 sum(alpha*y)0 回推 alpha_i alphas[i] ai_old y[i] * y[j] * (aj_old - alphas[j]) # 4. 两个候选截距落在 (0, C) 内部的那个更稳 b1 b - Ei - y[i] * (alphas[i] - ai_old) * K[i, i] \ - y[j] * (alphas[j] - aj_old) * K[i, j] b2 b - Ej - y[i] * (alphas[i] - ai_old) * K[i, j] \ - y[j] * (alphas[j] - aj_old) * K[j, j] if 0 alphas[i] C: b b1 elif 0 alphas[j] C: b b2 else: b (b1 b2) / 2 changed 1 passes passes 1 if changed 0 else 0 sv alphas 1e-5 return {alphas: alphas[sv], sv: X[sv], sv_y: y[sv], b: b, kind: kind, gamma: gamma}这段代码逐块对过去外层while控制的是「连续空转次数」不是迭代总数所以passes只在changed 0时累加一旦有任何乘子被更新就清零。这是 Platt 简化版 SMO 的标志性写法比完整版少了启发式选点的加速但在几百个样本的量级上完全够用。C是每个乘子的上界直接决定支持向量的数量。C趋于无穷时变成硬间隔噪声点会把边界拽歪C很小则允许大量样本落在间隔内模型退化成近似线性。tol是 KKT 条件的容忍度它同时出现在两个判断里调小会让循环检查更严格、训练更慢调大到 1e-1 时很多该更新的乘子会被跳过。max_iter我默认给 40样本超过一千时建议提到 100 以上。eta 0那个continue是核矩阵不正定时的保命符正常的半正定核不会走到这里一旦频繁触发先回头查kernel_matrix有没有算错。最后返回的是压缩过的支持向量不是完整的 alpha 向量——后续预测只需要支持向量参与计算alphas里 0 的位置留着也没用还占内存。决策函数很短两行就够def decision_function(model, X): K kernel_matrix(X, model[sv], kindmodel[kind], gammamodel[gamma]) return K (model[alphas] * model[sv_y]) model[b]输出大于 0 判为正类小于 0 判为负类绝对值大小可以粗略理解为置信度。3.3 OvO 封装三个类别两两配对与投票class OvOClassifier: def __init__(self, C1.0, gamma0.5, kindrbf, tol1e-3, max_iter40): self.C, self.gamma, self.kind C, gamma, kind self.tol, self.max_iter tol, max_iter def fit(self, X, y): self.classes_ np.unique(y) # 排序后的类别回映射全靠它 self.pairs_ {} k len(self.classes_) for a in range(k): for b in range(a 1, k): # 3 个类 - 3 对 mask (y self.classes_[a]) | (y self.classes_[b]) # 只在这一对内部重贴标签a 记 -1b 记 1 yab np.where(y[mask] self.classes_[a], -1, 1) self.pairs_[(a, b)] smo_simple( X[mask], yab, Cself.C, tolself.tol, max_iterself.max_iter, kindself.kind, gammaself.gamma) return self def decision_function(self, X): k len(self.classes_) scores np.zeros((X.shape[0], k)) for (a, b), model in self.pairs_.items(): f decision_function(model, X) # f0 偏 bf0 偏 a scores[:, b] f scores[:, a] - f return scores # 列顺序与 classes_ 一致 def predict(self, X): return self.classes_[np.argmax(self.decision_function(X), axis1)]fit里的mask是 OvO 的核心每一对分类器只在自己那两个类的样本上训练第三个类的样本完全不参与。三个类产生三对模型(0,1)、(0,2)、(1,2)每对内部把前一个类映射成 -1、后一个类映射成 1——这个映射必须在局部完成不能直接用原始标签否则smo_simple里y[i] ! y[j]的判断会失效可行区间算错。decision_function用的是分数累加而不是硬投票这是三分类场景下的关键选择。硬投票的规则是每个分类器投一票给它认为的类三个分类器一共三票完全可能出现 1:1:1 的平局argmax遇到平局永远返回第一个下标于是所有平局样本都被判成第 0 类混淆矩阵上表现为某一类的召回率异常高、其余两类被吞掉。分数累加把「赢多少」也纳入了比较平局概率大幅下降。3.4 换成 sklearn 的 SVC三行代码与一处语义差异手写版跑通之后用 sklearn 做一次对照能快速定位问题是算法错还是参数错from sklearn.svm import SVC from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler clf make_pipeline( StandardScaler(), SVC(C1.0, gamma0.5, kernelrbf, decision_function_shapeovr) # 只影响 decision_function 的输出形状 ) clf.fit(X_train, y_train) pred clf.predict(X_test)要注意decision_function_shape这个参数名字很容易误导人。它不改变训练方式——SVC底层永远是 OvO三个类照样训三个二分类器它只决定decision_function返回的矩阵形状是(n_samples, 3)还是(n_samples, 3)的成对形式ovo模式下 k3 时返回 3 列对应三对。真正影响predict结果的是 sklearn 内部那套基于投票加置信度的聚合逻辑。写对照实验时先把StandardScaler关掉再比。我第一版忘了这茬手写版没做标准化、sklearn 版在管道里做了同一组参数下 macro-F1 差了 11 个百分点查了半天以为是 SMO 的收敛条件写错了。4. 训练与评估三类数据下 C、gamma 与 macro-F1 怎么配合二分类看准确率基本够用三分类不行。三个类里只要有一个类样本偏少模型把它整个预测成多数类准确率照样能到 0.7 以上但那一类的召回率是 0。评估环节必须把每一类的表现拆开看。4.1 造三类数据与标准化管道import numpy as np from sklearn.datasets import make_blobs def make_three_class(n_samples450, seed42, noise0.0): X, y make_blobs( n_samplesn_samples, centers3, # 三个簇心对应三个类 n_features2, # 二维便于画决策边界 cluster_std[0.8, 1.1, 0.9], # 每个类的松散程度不同故意制造难度差 center_box(-5.0, 5.0), random_stateseed, ) if noise 0: # 按比例翻标签模拟真实脏数据 rng np.random.default_rng(seed) flip rng.random(len(y)) noise y[flip] rng.integers(0, 3, flip.sum()) return X, ycluster_std给三个类设了不同的值这不是随手写的。三个类松散程度一致时RBF 核用一组 gamma 就能把三类都切开调参过程毫无挑战把中间那类弄得散一点你会看到 gamma 稍微大一点松散类的边界就先碎掉这个现象在真实数据里非常常见。center_box(-5, 5)配合cluster_std大约 1三个簇心之间距离在 3 到 7 之间边界不会重叠到不可分。noise参数用来注入标签噪声翻 5% 到 10% 的标签模型的测试准确率会掉到 0.85 附近这个水平比较接近真实业务数据调参时也更有区分度。标准化必须放进管道不能在划分数据集之前对整个 X 做fit_transformfrom sklearn.model_selection import train_test_split X, y make_three_class(n_samples450, noise0.05) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, stratifyy, random_state42)stratifyy保证训练集和测试集里三类的比例一致。样本量 450 时分层的效果不明显但如果你的数据只有一百多条不分层很可能出现某个类在测试集里只有 2 个样本混淆矩阵上那一行全是噪声。4.2 网格搜索C 和 gamma 的二维扫描from sklearn.model_selection import GridSearchCV pipe make_pipeline(StandardScaler(), SVC(kernelrbf, decision_function_shapeovr)) param_grid { svc__C: [0.1, 1, 10, 100], svc__gamma: [0.01, 0.1, 0.5, 1.0, scale], } gs GridSearchCV( pipe, param_grid, cv5, scoringf1_macro, # 三分类优先看宏平均别用 accuracy n_jobs-1, return_train_scoreTrue, # 顺手拿到训练分用来看过拟合程度 ) gs.fit(X_train, y_train) print(gs.best_params_, gs.best_score_)scoring选f1_macro而不是默认的accuracy原因是宏平均对每个类一视同仁某一类崩了会立刻反映在分数上。return_train_scoreTrue拿到的训练分和验证分之差是判断过拟合最直接的信号差值小于 0.03 说明还在欠拟合区可以继续加大C或gamma差值超过 0.15 就是明显过拟合往回退。n_jobs-1在 20 组参数、5 折的组合下大概能省三分之二的时间。参数网格本身有讲究——C和gamma都按十倍量级铺而不是线性铺因为这两个参数对边界的影响是指数级的从 1 到 2 的变化远小于从 1 到 10。4.3 混淆矩阵与决策边界三类问题该看哪张图from sklearn.metrics import confusion_matrix, classification_report pred gs.predict(X_test) cm confusion_matrix(y_test, pred, labels[0, 1, 2]) print(cm) print(classification_report(y_test, pred, digits3))labels[0, 1, 2]这个参数建议显式传。不传的时候混淆矩阵的行列顺序取决于y_test里出现了哪些标签及其排序某次测试集恰好漏掉一个类矩阵会从 3×3 缩成 2×2和上一次的结果没法直接对比。显式指定之后矩阵形状永远稳定。三分类的混淆矩阵里最该看的是对角线的三个数和最大的那个非对角元素。如果 (0,1) 位置特别大而 (0,2) 很小说明 0 类和 1 类的边界纠缠严重2 类相对独立这时调参的重点应该是让 0 和 1 分得更开而不是全网格乱搜。决策边界图用网格采样画比混淆矩阵更直观xx, yy np.meshgrid( np.linspace(X[:, 0].min() - 1, X[:, 0].max() 1, 400), np.linspace(X[:, 1].min() - 1, X[:, 1].max() 1, 400), ) Z gs.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape) ax.contourf(xx, yy, Z, alpha0.25, levels[-0.5, 0.5, 1.5, 2.5])levels必须手动指定成[-0.5, 0.5, 1.5, 2.5]这是contourf给离散类别上色的固定写法不指定的话 matplotlib 会按连续值插值画出来是一片渐变完全看不出边界在哪。网格密度 400×400 是精度和速度的平衡点再密一点在笔记本上要等十几秒。5. 常见问题排查三类 SVM 最容易翻车的五个点下面五条都是我在三分类任务里真实撞过的按「现象—原因—解决」的顺序写遇到对应的现象可以直接对照。5.1 标签是字符串或 1/2/3预测结果整体错位现象混淆矩阵看起来像被转置了一部分准确率稳定在 0.33 左右而且每次跑都是同一类被大量误判。原因np.unique会对标签排序classes_的顺序和你手写的映射表不一致。标签是字符串时按字典序排cat dog tiger标签是1, 2, 3时按数值排但如果原始数据里混了0顺序就变成0, 1, 2, 3多出来一个类。解决永远用self.classes_[pred_index]回映射不要写死{0: A, 1: B, 2: C}。在fit里把classes_打印一次确认顺序符合预期再往下走。5.2 标准化放在 train_test_split 之前现象交叉验证分数 0.98测试集只有 0.71两者差了近 30 个百分点调参怎么调都拉不近。原因StandardScaler在整个数据集上fit测试集的均值和方差泄漏进了训练过程。样本量小的时候这种泄漏特别明显等于模型提前看到了测试数据的分布。解决把StandardScaler放进Pipeline让GridSearchCV在每一折内部单独 fit。判断有没有踩这个坑看交叉验证分数和测试分数的差距——正常差距在 3 到 8 个百分点超过 15 就先去查数据泄漏别急着调参。5.3 硬投票在 k3 时出现 1:1:1 平局现象predict的结果里某一类占比异常高另外两类被大量吞并混淆矩阵上表现为一整列特别宽。原因三个二分类器各投一票票数 1:1:1 时np.argmax返回第一个下标所有平局样本统一被判成第 0 类。三个类的 OvO 一共只有三票平局概率远比想象中高。解决改用决策值累加也就是第 3.3 节里scores[:, b] f的写法把「赢多少」纳入比较。如果一定要用硬投票平局时按各类分类器的平均置信度打破别让它落到argmax的默认行为上。5.4 gamma 过大且特征没标准化现象训练集准确率 1.0测试集 0.6画出来的边界是一堆互不相连的小岛每个岛里只圈着一两个样本。原因RBF 核的gamma实质是 1/(2σ²)特征量纲不统一时数值范围大的那一维会主导距离计算gamma稍微大一点就退化成「每个样本自成一类」。这就是典型的过拟合支持向量占比会从 30% 掉到 5% 以下。解决先标准化gamma从scale起步sklearn 会按 1/(n_features * X.var()) 自动算再在对数网格上细搜。训练完检查支持向量占比健康区间大致在 10% 到 50%。5.5 max_iter 太小SMO 没收敛却没有任何报错现象手写版跑完alphas几乎全是 0只有两三个非零值b是 0predict把所有样本判成同一类但程序没有任何异常。原因简化 SMO 的外层循环只统计「连续无更新」的轮数样本少或者tol设得过大时第一轮就可能changed 0直接退出。这是静默失败比抛异常更难查。解决把max_iter提到 100 以上、tol降到 1e-3训练后强制检查支持向量比例低于 5% 或高于 80% 都当成异常回退去看核矩阵和可行区间。我现在的习惯是每次训练完先打印支持向量数量和b的值b恰好在 0 附近就该警觉了。6. 二次开发换核、加权、换部署的三条改造路径拿到这套代码之后最常做的改动其实就三类换数据、换核、把训练好的模型搬到服务里。三条路径的改动位置完全不同先看这张表对号入座改造目标要改的文件关键点换业务数据data/gen_data.py保持返回(X, y)y 用连续整数加自定义核svm/kernel.py必须返回半正定矩阵对称性要自己保证类别不均衡加权svm/smo.pysvm/multiclass.py把标量C换成按样本的向量上线推理新增serve.py只序列化sv / sv_y / alphas / b加权是最值得先做的。三分类里某一类样本只有几十条时不加权的话那个类的召回率会低得没法看。实现思路很简单把smo_simple里的标量C换成向量def class_weight(y): 按类别频率的倒数给权重与 sklearn 的 balanced 等价 cls, cnt np.unique(y, return_countsTrue) w len(y) / (len(cls) * cnt) return dict(zip(cls, w)) # 在 OvOClassifier.fit 里为每一对分类器算它自己的 C 向量 w class_weight(y) C_i np.where(y[mask] self.classes_[a], C * w[self.classes_[a]], C * w[self.classes_[b]])然后把smo_simple里所有alphas[i] C改成alphas[i] C_i[i]min(C, ...)改成min(C_i[j], ...)。改完用同一份不均衡数据跑一遍加权前后对比每一类的召回率多数类会掉一两个点少数类能涨十几个点宏平均 F1 整体是上升的。这一改动的收益在样本比例超过 3:1 之后特别明显。上线推理时有个必须注意的点手写版模型只依赖支持向量序列化sv、sv_y、alphas、b、kind、gamma六个字段就够了整个文件通常只有几十 KB。别把整个OvOClassifier用 pickle 存下来它内部持有三对模型的完整训练集引用文件会大一两个数量级加载时还要依赖 numpy 版本一致。验证改造是否成功我固定用三步先跑--backend numpy和--backend sklearn对比 macro-F1差距超过 2 个百分点就回退再画决策边界看形状是否合理最后手动构造几个落在边界附近的样本单独看它们的决策值确认没有出现符号翻转。说个血泪经验。我做这个三分类的第一版花了整整一个下午调 gamma边界怎么调都是碎的最后一气之下把训练集的支持向量数量打出来发现只有 2 个——根因是kernel_matrix里那个np.maximum(d2, 0)当时还没加负距离让核值超过 1SMO 的 eta 一直为正乘子全被continue掉了。从那以后我每次改完核函数或者 SMO 循环都强制先打印支持向量数量和 b 值再谈调参不再靠准确率猜。希望帮到你。本文还有配套的精品资源点击获取