☰
纯NumPy手写SVM:SMO求解器、核函数与参数调优实战
2026/10/29 20:10:05 网站建设 项目流程

简介:这份资源是一套面向机器学习初学者与算法实践者的支持向量机 Python 实现代码,重点解决在缺乏框架依赖的情况下理解 SVM 训练与预测流程的问题,适合具备一定 Python 基础、希望从零读懂分类算法内部机制的学习者参考。压缩包共 6 个文件,约 9KB,体量轻巧,以 py 源码为主体,辅以一份 md 说明文档和一份 txt 数据集,另含编译缓存文件,结构上把算法实现、数据样本与使用说明分开组织,便于对照阅读和逐段调试。目前已有 1944 人学习下载,说明该实现具备一定的参考价值与传播基础。读者可从中获得一套可直接运行的 SVM 主程序与测试脚本,通过测试入口加载文本数据集、观察分类结果输出,并结合说明文档理解核函数选择、间隔最大化与拉格朗日乘子求解等关键环节;数据文件便于替换为自己的样本做小规模实验,说明文档则帮助快速理清代码组织与调用关系,适合作为课程作业、算法复现或面试复习的练手材料。

1. 一行 svm.SVC 背后的黑匣子:为什么要亲手写一遍 SMO

调包调了几年,svm.SVC的 C 和 gamma 一直是靠网格搜索反复试出来的——直到某次高维稀疏特征上模型分数突然下降,才回头翻这份用纯 NumPy 写的 SVM 支持向量机 Python 实现。它没调 sklearn,硬间隔、软间隔、线性核、多项式核、RBF 核、SMO 求解器、决策边界可视化全是手写,一共几个文件,读一遍不到两小时。价值不在替代 sklearn,而在于你能看清 alpha 落在盒约束边界上意味着什么、支持向量到底是哪几个样本、b 是怎么被两个乘子联合定出来的。适合已经会用 SVM 但说不清对偶问题的人,也适合想拿一份能改、能打断点、能打印中间量的源码做实验的同学。

2. 拆包读源码:SMO 求解器的模块划分与三个核心公式

拿到一份手写实现,最怕上来就从头读fit,读到一半陷进索引里出不来。合理的读法是先看文件职责,再看它把哪个数学式子翻译成了哪几行 NumPy,最后才去盯循环里的收敛判据。这份代码的结构就是按这个顺序铺的:数据与核函数在底层,SMO 单步更新在中间,训练循环和可视化在上层。先把调用链摸清,后面每一段代码你都知道它被谁调用、返回给谁。

2.1 文件清单与调用链:先看谁调谁

文件职责关键函数/类读它的时机
svm.py模型主体,参数、fit/predict、决策函数SVM.fit/SVM.decision_function第二遍读
smo.py两变量解析更新、工作集选择smo_step/select_pair第一遍重点
kernels.py线性、多项式、RBF 核矩阵linear_kernel/rbf_kernel第一遍重点
utils.py标准化、标签检查、网格生成standardize/check_labels随手翻
demo_*.py二分类、非线性、可视化示例main跑通时读

调用链很短:demo造数据 →utils.standardize做列标准化 →SVM.fit先调kernels算一次全量核矩阵,再把K、y、C、tol丢给smo循环 → 循环内每轮调select_pair挑两个下标、调smo_step更新 → 收敛后predict用alpha * y和b在核矩阵上做一次矩阵乘。

注意:alpha * y这个向量是整个模型的“权重”,不要和alpha本身混着用。预测时乘的是alpha_i * y_i,少了y_i符号就全反了。

2.2 从对偶到单步更新:两个乘子怎么解出来

软间隔原问题是

min 1/2 * ||w||^2 + C * sum(xi_i) s.t. y_i * (w · phi(x_i) + b) >= 1 - xi_i, xi_i >= 0

拉格朗日对偶之后变成只含 alpha 的二次规划:

max sum(alpha_i) - 1/2 * sum sum(alpha_i * alpha_j * y_i * y_j * K(x_i, x_j)) s.t. 0 <= alpha_i <= C, sum(alpha_i * y_i) = 0

对应的 KKT 条件只有三行,但它决定了整个算法的停止判据:

alpha_i = 0 => y_i * f(x_i) >= 1 (样本在间隔外,不是支持向量) 0 < alpha_i < C => y_i * f(x_i) = 1 (正好压在间隔边界上) alpha_i = C => y_i * f(x_i) <= 1 (落在间隔内或被误分)

等式约束sum(alpha_i * y_i) = 0是 SMO 存在的理由:一次只动一个乘子必然破坏约束,所以每次必须成对更新。固定其余 n-2 个乘子,把两变量子问题解析求解,就得到下面这段更新逻辑。

import numpy as np def smo_step(alpha, y, K, b, C, i1, i2): """对一对乘子 (i1, i2) 做解析更新,返回 (新 b, 是否发生实质更新)。""" a1_old, a2_old = alpha[i1], alpha[i2] y1, y2 = y[i1], y[i2] # 误差 E = f(x) - y,其中 f(x) = sum(alpha*y*K[:,i]) + b E1 = float(alpha @ (y * K[i1]) + b - y1) E2 = float(alpha @ (y * K[i2]) + b - y2) # 两变量子问题的可行域上下界(盒约束 + 等式约束的交) if y1 != y2: L = max(0.0, a2_old - a1_old) H = min(C, C + a2_old - a1_old) else: L = max(0.0, a2_old + a1_old - C) H = min(C, a2_old + a1_old) if H - L < 1e-12: # 可行域退化成一点,本轮无意义 return b, False # 二阶导数 eta = K11 + K22 - 2*K12,等价于 ||phi(x1)-phi(x2)||^2 eta = K[i1, i1] + K[i2, i2] - 2.0 * K[i1, i2] if eta <= 1e-12: # 两点在特征空间里几乎重合 return b, False a2_new = a2_old + y2 * (E1 - E2) / eta a2_new = min(H, max(L, a2_new)) # 裁剪到 [L, H],这一步漏了必崩 if abs(a2_new - a2_old) < 1e-8 * (a2_new + a2_old + 1e-8): return b, False a1_new = a1_old + y1 * y2 * (a2_old - a2_new) # 由等式约束反推 a1 # b 的更新:优先用落在 (0, C) 内部的乘子,两个都不在内部才取平均 b1 = b - E1 - y1 * (a1_new - a1_old) * K[i1, i1] - y2 * (a2_new - a2_old) * K[i1, i2] b2 = b - E2 - y1 * (a1_new - a1_old) * K[i1, i2] - y2 * (a2_new - a2_old) * K[i2, i2] if 0.0 < a1_new < C: b = b1 elif 0.0 < a2_new < C: b = b2 else: b = 0.5 * (b1 + b2) alpha[i1], alpha[i2] = a1_new, a2_new return b, True

逻辑拆解:先算E1、E2,是因为解析解里分子就是y2 * (E1 - E2),这一步等价于沿约束方向做一次线性搜索;eta是目标函数在方向上的曲率,只要核矩阵半正定它就非负,但浮点误差会让它变成-1e-15,所以用1e-12兜底跳过而不是硬除。L、H的分支是最容易写错的地方:y1 != y2时约束是a2 - a1 = 常数,两条线夹出来的是a2_old - a1_old到C + a2_old - a1_old;同号时是a2 + a1 = 常数,夹出来的是a2_old + a1_old - C到C。裁剪之后a1_new必须由等式约束反推,不能独立裁剪,否则sum(alpha_i * y_i)会漂移,训练到最后预测全偏一类。

参数含义:C是盒约束上界,越大越允许违背间隔;b是阈值,直接决定决策面平移;i1、i2是选出来的两个下标,i1应该是当前违背 KKT 最严重的那个。返回值里那个布尔量别扔,它是判断“这一轮到底有没有干活”的唯一依据,外层循环要靠它决定是继续还是退出。

2.3 核矩阵怎么算才不炸内存

手写实现里第二个容易翻车的地方是核函数。线性核直接X @ Z.T就完事,但 RBF 核如果老老实实展开(X[:, None, :] - Z[None, :, :]) ** 2,1000×1000 的样本就直接构造出一个 1000×1000×d 的三维数组,内存瞬间飙到几个 G。常见做法是用范数展开,把距离化成三个矩阵运算。

import numpy as np def linear_kernel(X, Z=None): Z = X if Z is None else Z return X @ Z.T def poly_kernel(X, Z=None, gamma=1.0, coef0=1.0, degree=3): Z = X if Z is None else Z return (gamma * (X @ Z.T) + coef0) ** degree def rbf_kernel(X, Z=None, gamma=1.0): Z = X if Z is None else Z x2 = np.einsum('ij,ij->i', X, X).reshape(-1, 1) # ||x||^2 z2 = np.einsum('ij,ij->i', Z, Z).reshape(1, -1) # ||z||^2 d2 = x2 + z2 - 2.0 * (X @ Z.T) np.maximum(d2, 0.0, out=d2) # 浮点误差可能给出 -1e-12,开根号前先夹住 return np.exp(-gamma * d2) def build_kernel(name, **kw): table = {'linear': linear_kernel, 'poly': poly_kernel, 'rbf': rbf_kernel} if name not in table: raise ValueError(f"未知核类型 {name},可选 {list(table)}") return lambda X, Z=None: table[name](X, Z, **kw)

d2 = x2 + z2 - 2*X@Z.T这一步把内存从O(n*m*d)压到O(n*m),代价是两处精度损失:一是两个大数相减带来的抵消误差,二是不满足三角不等式的距离可能出负数。np.maximum(d2, 0.0)就是补这个洞,少了它exp(-gamma * d2)会算出大于 1 的值,非对角线元素比对角线还大,核矩阵不再半正定,SMO 的eta就会频繁触发保护分支,训练看起来在跑但alpha几乎不动。

参数含义:gamma是 RBF 的带宽倒数,等价于1 / (2 * sigma^2),越大单个样本影响范围越小;coef0控制多项式核里低阶项的相对权重,degree是多项式次数。这里build_kernel返回的是闭包,好处是SVM内部只需要调self._kernel(X, X)和self._kernel(X, self.X_fit)两种形态,不用在每个函数里判断核类型。

提示:gamma的量纲完全取决于数据尺度。原始特征没标准化时gamma=0.5可能合适,做了 z-score 之后同一个数据集上gamma往往要放大到1/d附近,这也是为什么第 3 章的第一步永远是标准化。

2.4 工作集选择:先挑谁,直接决定收敛快慢

SMO 的收敛速度几乎全押在选对上。最简单的做法是对所有不满足 KKT 的下标随机取两个,能跑通但慢;这份实现用的是“最大违背对”变体:第一乘子取违背 KKT 最严重的,第二乘子取让|E1 - E2|最大的,也就是二阶信息最大的那个方向。

def select_pair(alpha, y, K, b, C, tol): """返回 (i1, i2)。i1 取违背 KKT 最严重者,i2 取步长最大者。""" f = K @ (alpha * y) + b margin = y * f # 违背量:等于 0 表示该样本满足 KKT up = np.where(alpha < C - tol, 1.0 - margin, -np.inf) # 该升却没升够 low = np.where(alpha > tol, margin - 1.0, -np.inf) # 该降却没降够 i1 = int(np.argmax(up)) if up.max() > low.max() else int(np.argmax(low)) if max(up.max(), low.max()) < tol: return None, None # 全部满足,收敛 E = f - y score = np.abs(E - E[i1]) score[i1] = -np.inf # 不能选自己 i2 = int(np.argmax(score)) return i1, i2

up和low两个数组分别对应 KKT 条件的两侧:alpha < C的样本如果y*f < 1,说明它还没被推到间隔上,属于“该升没升”;alpha > 0的样本如果y*f > 1,说明它该从支持向量里退出去但没退。取两者最大值的较大者作为违背度,只要超过tol就说明还有得优化。score里把i1置成-inf是为了避免自己和自己配对,eta会等于 0 直接返回失败,白跑一轮。

代价是每轮都要重算一次全量f = K @ (alpha * y) + b,复杂度O(n^2)。样本量大时这是主要开销,优化方向是增量维护f:每次更新只涉及两个下标,f的增量就是Δalpha1*y1*K[:, i1] + Δalpha2*y2*K[:, i2],改完再做一次 KKT 检查。这份代码把小规模场景写在前面,正是为了可读性优先,别指望它直接扛十万样本。

3. 跑通二分类全流程:从造数据到决策边界可视化

读完数学部分,第一件事是跑通一个最小闭环,而不是急着换数据集。闭环要短到出问题时你能一眼定位:造数据、对标签、标准化、训练、画图。这份实现的 demo 就是按这个顺序写的,照着跑一遍,再逐步替换成自己的数据,比直接怼真实业务数据要稳得多。

3.1 标签必须是 ±1:造数据与预处理的三步

几乎所有手写 SVM 的第一个报错都和标签有关。sklearn 能吃 0/1 标签,是因为内部做了转换;这份实现没有,y直接参与alpha * y的运算,喂 0/1 进去,sum(alpha_i * y_i) = 0这个约束就变成sum(alpha_i * 0/1) = 0,只有一类样本的乘子能涨,另一类永远是 0。

import numpy as np from sklearn.datasets import make_moons # 1) 造一个线性不可分的数据集,方便看出 RBF 核的价值 X, y = make_moons(n_samples=300, noise=0.18, random_state=7) # 2) 标签映射到 ±1,这一步不能省 y = np.where(y == 0, -1, 1).astype(float) assert set(np.unique(y)) == {-1.0, 1.0} # 3) 列标准化:均值 0、方差 1,核参数才有可迁移的量纲 mu, sd = X.mean(axis=0), X.std(axis=0) sd[sd == 0] = 1.0 # 常数列防除零 X = (X - mu) / sd rng = np.random.default_rng(7) idx = rng.permutation(len(X)) X_train, y_train = X[idx[:240]], y[idx[:240]] X_test, y_test = X[idx[240:]], y[idx[240:]]

np.where(y == 0, -1, 1)之后立刻assert,是我从线上事故里留下的习惯:有一次做多分类 one-vs-rest,某一折的正类标签恰好全是 0,输出-1,整折的模型b一直是负的,评估指标正常但业务侧判定全反。sd[sd == 0] = 1.0防的是独热编码之后出现的常量列,除零会产出nan,nan进核矩阵之后d2全是nan,np.maximum也救不回来,最终表现是训练不报错但alpha全 0。

参数上,noise=0.18是刻意留的:噪声太小数据线性可分,你会误以为线性核够用;噪声太大两类重叠严重,支持向量多到画不下。0.15~0.25 之间最适合观察边界。

3.2 fit 的五个参数与一次最小可运行训练

模型参数不多,但每一个都会实质改变结果,先记一张表再看调用。

参数默认量级作用调大的后果
C1.0盒约束上界,控制容忍度支持向量变少,间隔变窄,过拟合
kernel'rbf'核类型,决定特征空间形状线性→RBF 表达力上升,可解释性下降
gamma1/n_featuresRBF 带宽倒数边界绕着单点转,泛化崩
tol1e-3KKT 违反度容差收敛快但乘子不准,预测抖动
max_iter200外层循环上限不收敛时不报错,静默返回半成品
from svm import SVM clf = SVM(C=1.0, kernel='rbf', gamma=2.0, tol=1e-3, max_iter=300) clf.fit(X_train, y_train) # 决策函数:f(x) = sum(alpha_i * y_i * K(x_i, x)) + b score = clf.decision_function(X_test) pred = np.where(score >= 0, 1, -1) acc = (pred == y_test).mean() n_sv = int((clf.alpha > 1e-6).sum()) print(f"测试集准确率 {acc:.4f} 支持向量 {n_sv}/{len(X_train)} b={clf.b:.6f}") print("训练是否收敛:", "是" if clf.converged else "否,可能需要调大 max_iter 或放宽 tol")

clf.converged这个标志务必自己加上。原版实现如果只按max_iter退出,不设标志,跑完之后alpha是半成品,predict照常返回结果,你根本不知道它没收敛。判断收敛的写法就在第 2.4 节的select_pair里:max(up.max(), low.max()) < tol就置converged=True并 break。n_sv是第二个健康指标,二分类里支持向量占训练样本的比例通常在 10%~50%,超过 70% 基本说明C太小或者gamma太大,模型已经在硬记数据了。

3.3 画决策边界和支持向量:验证模型没跑偏的最快方式

准确率只给一个数,决策边界会告诉你模型是怎么错的。画图这一步在调试期比任何指标都值钱。

import matplotlib.pyplot as plt def plot_boundary(clf, X, y, step=0.02, title=""): 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.arange(x_min, x_max, step), np.arange(y_min, y_max, step)) grid = np.c_[xx.ravel(), yy.ravel()] zz = clf.decision_function(grid).reshape(xx.shape) # 不取 sign,留连续值 plt.figure(figsize=(6, 5)) plt.contourf(xx, yy, np.sign(zz), levels=[-1, 0, 1], alpha=0.2, colors=['#8fbcd4', '#f3c7a1']) plt.contour(xx, yy, zz, levels=[-1, 0, 1], linestyles=['--', '-', '--'], colors='k', linewidths=1) sv = clf.alpha > 1e-6 plt.scatter(X[~sv, 0], X[~sv, 1], c=y[~sv], cmap='coolwarm', s=18, edgecolors='k') plt.scatter(X[sv, 0], X[sv, 1], c=y[sv], cmap='coolwarm', s=60, marker='o', edgecolors='k', linewidths=1.2, label='支持向量') plt.title(title) plt.legend(loc='best') plt.tight_layout() plt.show() plot_boundary(clf, X_train, y_train, title="RBF, C=1.0, gamma=2.0")

关键在decision_function返回的是连续值,不要先np.sign再画。contour的三条等值线-1 / 0 / +1刚好对应两个间隔边界和一个决策面,中间那条实线是f(x)=0,两侧虚线是f(x)=±1。支持向量应该几乎全部压在虚线上,如果出现大量支持向量落在虚线内侧很远的位置,说明C被设得太大、容差tol收得太紧,或者训练根本没收敛。

step=0.02是分辨率与速度的折中,网格点数是(x_max-x_min)/step × (y_max-y_min)/step,每一步都要算一次核矩阵。数据范围大时把step放到 0.05,别用 0.005,否则画一次图能卡住半分钟,而且那个耗时全在rbf_kernel的矩阵乘法上,跟你调参毫无关系。

注意:plot_boundary里的X必须是标准化之后的坐标。如果传原始数据进去,边界形状和散点会错位,画出来的图看着像模型崩了,其实只是坐标系不一致。

4. 参数标定实战:C、gamma、tol 怎么设才不飘

手写实现调参最大的坑是没有 sklearn 那层校验,参数不合理它不会拦你,照样跑完返回一个能预测的模型。所以调参前先建立两个观测手段:一个是 KKT 违反度曲线,一个是和 sklearn 的对拍。前者告诉你优化过程健不健康,后者告诉你结果对不对。

4.1 C 与 tol 的联动:用 KKT 违反度判断收敛

tol不是精度装饰,它直接决定“什么算收敛”。设成1e-3意味着允许每个乘子在最优点附近有千分之一的违背量;设成1e-8会让 SMO 在最后几个乘子上反复横跳,几百轮下来alpha变化不到1e-6,白烧 CPU。

def kkt_report(alpha, y, K, b, C): f = K @ (alpha * y) + b margin = y * f tol = 1e-6 free = (alpha > tol) & (alpha < C - tol) at_upper = alpha >= C - tol at_zero = alpha <= tol v = np.zeros_like(alpha) v[at_zero] = np.maximum(0.0, 1.0 - margin[at_zero]) # 应为 >= 1 v[free] = np.abs(margin[free] - 1.0) # 应为 = 1 v[at_upper] = np.maximum(0.0, margin[at_upper] - 1.0) # 应为 <= 1 return { 'max_violation': float(v.max()), 'n_free': int(free.sum()), 'n_at_upper': int(at_upper.sum()), 'n_at_zero': int(at_zero.sum()), 'mean_margin_sv': float(margin[alpha > tol].mean()) if (alpha > tol).any() else 0.0, } print(kkt_report(clf.alpha, y_train, clf.K, clf.b, clf.C))

这个报告里最有信息量的是n_free:真正落在(0, C)之间的自由支持向量。它们的 margin 应该严格等于 1,max_violation就是它们偏离 1 的最大幅度。如果n_free接近 0,说明所有支持向量都被顶在alpha = C上,模型在硬撑,C相对数据噪声偏大;如果n_at_zero占绝大多数而n_free也很少,说明C太小,模型几乎放弃了拟合。

C的量级建议从对数刻度上扫:[0.01, 0.1, 1, 10, 100]。同时在每个C下记三个数:测试准确率、支持向量占比、max_violation。三者一起看才能分辨“真的好”和“碰巧对”。max_violation始终大于1e-2说明max_iter不够,加轮次而不是继续调C,否则你是在拿一个没收敛的模型去比参数。

4.2 gamma 和 degree:核参数的量纲陷阱

gamma是这份实现里最容易设错的参数,因为它和特征尺度强耦合。RBF 核是exp(-gamma * ||x - z||^2),标准化之后样本间距离的平方量级约为2d(d 是特征数),要让核值落在有意义的区间,gamma大致在1/d到1/(2d)附近。

核关键参数合理起点表现异常的征兆
linear无高维稀疏文本首选特征数少且非线性时欠拟合
rbfgamma1 / n_features边界碎裂、SV 占比 > 70% 说明gamma过大
polydegree/coef0degree=3,coef0=1degree> 5 数值爆掉
任意C1.0alpha几乎全等于C

多项式核的坑更隐蔽:(gamma * x·z + coef0) ** degree在degree大于 5 且特征维度高时会直接溢出到inf,K里出现inf之后eta也是inf,a2_new变成nan,最终所有预测都是一个类。这不是模型学不动,是浮点数已经废了。经验上degree不要超过 3,超过就换 RBF,别跟数值稳定性较劲。

判断gamma是否合理的快捷办法是看核矩阵的对角线外均值:

def kernel_stats(K): n = K.shape[0] off = (K.sum() - np.trace(K)) / (n * (n - 1)) return {'diag_mean': float(np.trace(K) / n), 'offdiag_mean': float(off)} print(kernel_stats(rbf_kernel(X_train, gamma=2.0))) print(kernel_stats(rbf_kernel(X_train, gamma=0.3)))

offdiag_mean太接近 0(比如小于 0.01)意味着任意两个样本在特征空间里几乎正交,模型退化成最近邻,gamma太大;太接近 1(大于 0.9)意味着所有样本挤在一起,模型退化成常数分类器,gamma太小。健康区间大概在 0.1~0.6。

4.3 与 sklearn 对拍:三个必须对齐的量

手写实现最有效的验证方式不是看准确率,而是和成熟实现逐量对齐。对齐三样东西:预测标签、alpha * y的非零项、b。

from sklearn.svm import SVC import numpy as np ref = SVC(C=1.0, kernel='rbf', gamma=2.0, tol=1e-4) ref.fit(X_train, y_train) # 1) 预测标签一致率 agree = (ref.predict(X_test) == pred).mean() # 2) alpha*y 的非零项(sklearn 里叫 dual_coef_,且只保留支持向量) mine = (clf.alpha * y_train) mine = mine[np.abs(mine) > 1e-6] mine.sort() theirs = np.sort(ref.dual_coef_.ravel()) # 3) 阈值 b print(f"标签一致率 {agree:.4f}") print(f"乘子数量 mine={mine.size} theirs={theirs.size}") if mine.size == theirs.size: print(f"乘子最大偏差 {np.abs(mine - theirs).max():.6f}") print(f"b: mine={clf.b:.6f} theirs={ref.intercept_[0]:.6f}")

dual_coef_就是alpha_i * y_i,只是 sklearn 只存了支持向量那部分并且顺序不同,所以必须排序后再比。标签一致率不到 0.98 时,先别怀疑数学,按这个顺序查:标签有没有映射到 ±1、gamma是不是传给了两个实现、tol有没有差一个数量级。乘子数量对不上是最常见的信号——数量差得多说明一边没收敛,或者支持向量的判定阈值不同(这份实现用alpha > 1e-6,sklearn 内部阈值更小,允许有少量偏差)。

b的偏差是最灵敏的指标。两者都收敛时b应该在1e-3以内;偏差大到0.1以上,基本可以断定训练没跑够轮次,去看max_iter和converged。有一次我把tol设成1e-1图快,标签一致率 0.97 看着还行,b却差了 0.4,换成新数据之后准确率直接掉了 8 个点——那次之后我改成每次调参必须打印b的偏差。

5. 避坑与排查:五类把 SVM 跑翻车的现场

下面这五条都是我在不同数据集上真实撞过的,按“现象 → 原因 → 解决”写,遇到时可以直接对照。

5.1 训练过程里的三个现场

现场一:跑完不报错,预测全是同一个类。

现象:predict输出全1(或全-1),准确率恰好等于多数类占比,clf.b是一个大得离谱的数。

原因:标签不是 ±1 而是 0/1,sum(alpha_i * y_i) = 0这个约束把所有y=0的样本的乘子废掉了;另一个常见原因是训练没收敛就退出,alpha还是初值 0,f(x)恒等于b。

解决:fit开头加assert set(np.unique(y)) <= {-1.0, 1.0},并在退化成全 0 时抛错而不是静默返回。同时打印(alpha > 1e-6).sum(),等于 0 就直接判定失败。

现场二:max_iter加了一倍,结果还是不稳。

现象:同一份数据跑两次,b和准确率都有微小差异,max_violation停在1e-2左右降不下去。

原因:eta <= 1e-12的保护分支被频繁触发,说明有大量样本在特征空间里几乎重合(重复样本、或者gamma太小时距离被压平)。这些对的解析更新被跳过,循环在空转。

解决:训练前做一次去重,np.unique(X, axis=0)看能掉多少行;gamma太小就按第 4.2 节的方法把offdiag_mean调到 0.1 以上。如果数据本身就有大量重复,改用C更小的软间隔设置,别硬追收敛。

现场三:alpha更新后sum(alpha * y)不为 0。

现象:每一轮打印np.abs(alpha @ y),它不是1e-16而是缓慢增长到1e-3甚至更大。

原因:a1_new被独立裁剪了。正确做法是a2_new裁剪完之后用等式约束反推a1_new,不能两个都各自clip,否则约束被破坏两次。

解决:回到 2.2 节的smo_step,确认a1_new = a1_old + y1 * y2 * (a2_old - a2_new)这一行存在且没有额外的np.clip。这一行是硬约束,删了模型能跑但结果是错的。

5.2 数据与评估里的两个现场

现场四:换一份新数据,同样的参数准确率掉 20 个点。

现象:训练集上gamma=2.0效果好得离谱,换成另一批特征维度不同的数据后完全失效。

原因:gamma隐含了特征尺度的假设。第一批数据维度是 2,第二批是 20,标准化之后样本间距离的平方量级从 4 变成 40,同一个gamma相当于把核宽度缩了十倍,模型退化成最近邻。

解决:gamma永远不要写死,用1.0 / X.shape[1]做初值,再在对数刻度上扫。把kernel_stats里的offdiag_mean作为准入检查,落在 0.1~0.6 之外就不进入调参环节。

现场五:交叉验证分数很高,上线后一塌糊涂。

现象:5 折 CV 平均 0.95,线上前一周准确率 0.6 出头。

原因:标准化用了全量数据的mu和sd,测试折的信息泄漏进了训练折。SVM 对这种泄漏特别敏感,因为核矩阵是全局计算的,一个样本的尺度变化会影响它和所有其他样本的距离。

解决:把标准化塞进 Pipeline 里,每一折只用训练折算mu、sd,再作用到验证折。手写实现没有 Pipeline,就自己写一层:

def cv_score(X, y, k=5, **params): idx = np.random.default_rng(7).permutation(len(X)) folds = np.array_split(idx, k) scores = [] for i in range(k): val = folds[i] tr = np.concatenate([folds[j] for j in range(k) if j != i]) mu, sd = X[tr].mean(0), X[tr].std(0) # 只用训练折统计 sd[sd == 0] = 1.0 Xtr, Xval = (X[tr] - mu) / sd, (X[val] - mu) / sd m = SVM(**params).fit(Xtr, y[tr]) scores.append((np.where(m.decision_function(Xval) >= 0, 1, -1) == y[val]).mean()) return float(np.mean(scores)), float(np.std(scores))

mu和sd在循环内计算,是这段代码唯一的价值点,别图省事提到循环外。返回值带上标准差也是必要的:均值 0.9、标准差 0.15 的模型比均值 0.85、标准差 0.02 的更危险,说明它在某些折上彻底失败了。

提示:类别不平衡时不要看准确率。SVM 对多数类有天然偏好,先算每类的召回,再决定要不要调C或者手动加权。这份实现没有class_weight参数,简单做法是给少数类样本复制几份,或者把C按类别分别设成不同值。

6. 把手写实现用进工程:三个能直接搬走的技巧

手写 SVM 真正的价值不在替代 sklearn,而在它暴露了三个平时被封装掉的接口,这三个接口在工程里都能直接用。

6.1 用 margin 做置信度与拒识

decision_function返回的连续值就是到决策面的距离(在核空间里),绝对值越小越不可信。业务上经常需要“拿不准就不判”,这时直接卡阈值比再训一个模型划算:

score = clf.decision_function(X_new) confident = np.abs(score) >= 0.8 # 阈值按验证集的分位数定,别拍脑袋 pred = np.where(score >= 0, 1, -1) pred[~confident] = 0 # 0 表示拒识,交给人工或下游规则

阈值不要写死。我一般先算验证集上|score|的 10% 分位数,把拒识率控制在 10% 左右,再看被拒识样本的真实准确率是不是明显低于整体——如果没低,说明拒识白拒了,阈值要再往上抬。这套逻辑在 sklearn 上也能做,但只有自己写过一遍,你才知道score的尺度是由C和核参数共同决定的,换个数据集就不可比。

6.2 用支持向量压样本量

训练完alpha > 0的样本只有一小部分,剩下的对决策面毫无贡献。做模型交付时,我只把支持向量和对应的alpha * y、b、核参数打包出去,推理端不需要原始训练集:

sv = clf.alpha > 1e-6 artifact = { 'X_sv': clf.X_fit[sv], 'coef': (clf.alpha * y_train)[sv], 'b': clf.b, 'kernel': 'rbf', 'gamma': clf.gamma, } score = rbf_kernel(X_new, artifact['X_sv'], gamma=artifact['gamma']) @ artifact['coef'] + artifact['b']

这段推理代码只有一行矩阵乘,比加载完整训练集快一个数量级,模型包也小得多。前提是训练集样本量远大于支持向量数,n_sv占比超过 60% 时压缩收益有限,那时候该做的是回头调C,而不是硬压。

从那以后我每次交付 SVM 模型,都强制走一遍“打印 n_sv 占比 → 导出 artifact → 用 artifact 复算一遍预测并对齐”的流程,中间只要有一环对不上就不发版。真出问题时,能救你的不是准确率,是那个能独立复算的 artifact。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询