news 2026/11/6 2:08:31

SVM三分类实战:SMO求解、OvO投票与决策边界可视化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SVM三分类实战:SMO求解、OvO投票与决策边界可视化

简介:这份资料围绕支持向量机完成数据三分类的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 numpy>=1.24 scikit-learn>=1.3 matplotlib>=3.7 pyyaml>=6.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是一对互相牵制的参数,单独调其中一个很容易得出错误结论。我的经验是先固定C=1,在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, kind="rbf", gamma=0.5, degree=3, coef0=1.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, axis=1)[:, None] z2 = np.sum(Z ** 2, axis=1)[None, :] d2 = x2 + z2 - 2 * (X @ Z.T) # 浮点误差可能让距离出现 -1e-16,np.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, C=1.0, tol=1e-3, max_iter=40, kind="rbf", gamma=0.5): m = X.shape[0] K = kernel_matrix(X, X, kind=kind, gamma=gamma) 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"], kind=model["kind"], gamma=model["gamma"]) return K @ (model["alphas"] * model["sv_y"]) + model["b"]

输出大于 0 判为正类,小于 0 判为负类,绝对值大小可以粗略理解为置信度。

3.3 OvO 封装:三个类别两两配对与投票

class OvOClassifier: def __init__(self, C=1.0, gamma=0.5, kind="rbf", tol=1e-3, max_iter=40): 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 记 -1,b 记 +1 yab = np.where(y[mask] == self.classes_[a], -1, 1) self.pairs_[(a, b)] = smo_simple( X[mask], yab, C=self.C, tol=self.tol, max_iter=self.max_iter, kind=self.kind, gamma=self.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) # f>0 偏 b,f<0 偏 a scores[:, b] += f scores[:, a] -= f return scores # 列顺序与 classes_ 一致 def predict(self, X): return self.classes_[np.argmax(self.decision_function(X), axis=1)]

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(C=1.0, gamma=0.5, kernel="rbf", decision_function_shape="ovr") # 只影响 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模式下 k=3 时返回 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_samples=450, seed=42, noise=0.0): X, y = make_blobs( n_samples=n_samples, centers=3, # 三个簇心,对应三个类 n_features=2, # 二维便于画决策边界 cluster_std=[0.8, 1.1, 0.9], # 每个类的松散程度不同,故意制造难度差 center_box=(-5.0, 5.0), random_state=seed, ) 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, y

cluster_std给三个类设了不同的值,这不是随手写的。三个类松散程度一致时,RBF 核用一组 gamma 就能把三类都切开,调参过程毫无挑战;把中间那类弄得散一点,你会看到 gamma 稍微大一点,松散类的边界就先碎掉,这个现象在真实数据里非常常见。

center_box=(-5, 5)配合cluster_std大约 1,三个簇心之间距离在 3 到 7 之间,边界不会重叠到不可分。noise参数用来注入标签噪声,翻 5% 到 10% 的标签,模型的测试准确率会掉到 0.85 附近,这个水平比较接近真实业务数据,调参时也更有区分度。

标准化必须放进管道,不能在划分数据集之前对整个 X 做fit_transform:

from sklearn.model_selection import train_test_split X, y = make_three_class(n_samples=450, noise=0.05) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, stratify=y, random_state=42)

stratify=y保证训练集和测试集里三类的比例一致。样本量 450 时分层的效果不明显,但如果你的数据只有一百多条,不分层很可能出现某个类在测试集里只有 2 个样本,混淆矩阵上那一行全是噪声。

4.2 网格搜索:C 和 gamma 的二维扫描

from sklearn.model_selection import GridSearchCV pipe = make_pipeline(StandardScaler(), SVC(kernel="rbf", decision_function_shape="ovr")) 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, cv=5, scoring="f1_macro", # 三分类优先看宏平均,别用 accuracy n_jobs=-1, return_train_score=True, # 顺手拿到训练分,用来看过拟合程度 ) gs.fit(X_train, y_train) print(gs.best_params_, gs.best_score_)

scoring选f1_macro而不是默认的accuracy,原因是宏平均对每个类一视同仁,某一类崩了会立刻反映在分数上。return_train_score=True拿到的训练分和验证分之差,是判断过拟合最直接的信号:差值小于 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, digits=3))

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, alpha=0.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 硬投票在 k=3 时出现 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是 0,predict把所有样本判成同一类,但程序没有任何异常。

原因:简化 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.py+svm/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_counts=True) 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)当时还没加,负距离让核值超过 1,SMO 的 eta 一直为正,乘子全被continue掉了。从那以后我每次改完核函数或者 SMO 循环,都强制先打印支持向量数量和 b 值再谈调参,不再靠准确率猜。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/6 2:07:58

530/930 RAID阵列卡驱动:Windows Server装机与DISM注入排错

简介&#xff1a;面向在 Windows Server 2012 R2 与 2016 环境中维护 530、930 系列阵列卡的管理员和运维人员&#xff0c;这套驱动集合用于解决系统无法正确识别阵列卡、RAID 功能不可用及安装部署阶段的兼容性问题&#xff0c;也可作为服务器重装或磁盘阵列排障时的驱动备份。…

作者头像 李华
网站建设 2026/11/6 2:06:30

单机VT调试器从入门到避坑:VMXON、EPT与内核观测实战

简介&#xff1a;「滴水单机VT调试器」是面向虚拟化环境的专业调试工具&#xff0c;适合排查虚拟机底层问题的软件开发者、系统管理员与安全研究人员&#xff0c;可支撑代码级单步调试、CPU/内存与I/O资源监测、异常故障定位及多平台兼容性验证等场景&#xff0c;专业性强、上手…

作者头像 李华
网站建设 2026/11/6 2:06:10

教程能跑通,链接却不可信?把工具推荐当成依赖来维护

很多技术文章的问题&#xff0c;不是没人看&#xff0c;而是读者看完以后不敢照着做。 页面上写着“亲测可用”&#xff0c;却没有测试时间&#xff1b;说某个服务很稳定&#xff0c;却不讲调用量和使用环境&#xff1b;贴了一段能运行的代码&#xff0c;关键参数已经过期&…

作者头像 李华
网站建设 2026/11/6 2:01:45

线性结构:顺序表(1)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/6 2:01:27

Python78:核心语法-面向对象基础-类与对象

类与对象定义类与创建对象类的定义定义类的语法如下&#xff1a;&#xff08;动态添加属性&#xff09;说明&#xff1a;类名的命名规范&#xff0c;遵循大驼峰命名法&#xff0c;每个单词的首字母都是大写&#xff0c;单词之间没有分隔符&#xff0c;比如&#xff1a;UserInfo…

作者头像 李华
网站建设 2026/11/6 2:01:02

7.训练相关

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华