☰
SVM核技巧与手写SMO:内积替换、支持向量与数值避坑
2026/10/29 2:24:16 网站建设 项目流程

简介:面向机器学习入门者与算法实现练习者的支持向量机 Python 代码包,聚焦用 Python 复现 SVM 训练与测试流程,适合课堂实验、课程作业及自学时对照理解分类器原理。资源共 6 个文件,包含 3 个 Python 源码、1 个字节码缓存、1 个 Markdown 说明和 1 个 txt 文本数据,压缩包约 9KB,体积轻便,便于快速下载与本地运行。已有 1944 人学习或下载,说明其在 SVM 基础实践中具有一定参考价值。包内围绕核心算法、测试入口、说明文档与样例数据组织,读者可据此查看模型实现思路、运行分类测试、结合数据观察结果,并借助说明与辅助脚本完成代码阅读和调试,适合作为短小精悍的 SVM 入门实践材料。

1. 核函数没有升维:SVM 只是偷偷换掉了内积

很多人对 SVM 的第一印象是“核函数把数据映射到高维空间,然后在高维里切一刀”,于是觉得这套东西又强又玄。实际情况要朴素得多:训练和预测的整个流程里,没有任何一个样本被显式映射过,代码里自始至终只出现一个标量K(x_i, x_j),也就是两个样本在某个空间中的内积。核技巧解决的从来不是“高维存不下”,而是“高维内积算不动”。把这句话记牢,后面那些看着唬人的对偶形式、为什么只有少数样本真正起作用、为什么 C 和 gamma 一改结果就天翻地覆,全都能顺着这条线推出来。下面从间隔的几何定义开始,一路拆到手写 Python 实现里最容易翻车的几个数值细节。

2. 最大间隔:为什么分类器要主动离两边都远一点

2.1 函数间隔可以被随意放大,所以它不能当目标

SVM 的模型是一个仿射函数f(x) = w·x + b,符号决定类别,f(x) = 0就是分割超平面,w是法向量,决定平面的朝向。一个样本点到平面的带符号距离是y_i(w·x_i + b) / ||w||,其中y_i ∈ {-1, +1}用来把符号掰成正的——分类正确时它为正,值越大表示离边界越远、越“安全”。

这里立刻出现一个坑:y_i(w·x_i + b)这个量,也就是函数间隔,是可以被任意放大的。把w和b同时乘上 2,超平面一个像素都没动,但所有函数间隔翻了一倍。拿一个能被缩放任意倍数的量当优化目标,数学上没有意义。所以要先把尺度钉死:通过缩放w, b,让离平面最近的那些样本恰好满足y_i(w·x_i + b) = 1。此时所有样本都满足y_i(w·x_i + b) ≥ 1,而几何间隔等于1/||w||。

于是“最大化几何间隔”这件听起来很几何的事,等价于“最小化||w||”,再套上平方和 1/2 的系数,就是那个经典形式:

min_{w,b} 1/2 · ||w||² s.t. y_i(w·x_i + b) ≥ 1, i = 1..m

系数取 1/2 纯粹是为了求导之后系数变成 1,写起来干净。约束里等号成立的那些样本,正是后面要反复提到的支持向量——它们卡在间隔边界上,是唯一“顶住”超平面的力量。

从设计动机上说,为什么非要最大间隔而不是随便找一个能分开的平面?感知机也能分开,但它的解有无数个,取决于初始化和样本顺序,换个随机种子就换个平面,血泪经验是模型完全不可复现。最大间隔在这里充当了一个正则化器:间隔越大,模型对训练样本的微小扰动越不敏感,泛化误差的上界也越紧。常见的实现思路是把这理解成“在假设空间里主动收缩有效容量”,而不是玄学式的“更宽敞的路更好走”。

2.2 拉格朗日对偶:把不等式约束折进目标函数里

上面那个问题是有不等式约束的凸二次规划。约束优化不好直接梯度下降,标准做法是拉格朗日乘子法:给每个约束配一个乘子α_i ≥ 0,写出

L(w, b, α) = 1/2·||w||² - Σ_i α_i [ y_i(w·x_i + b) - 1 ]

原始问题是min_{w,b} max_{α≥0} L:如果某个约束被违反,方括号里是负的,max就会把α_i推到无穷大,惩罚无穷;约束满足时方括号非负,max取到α_i = 0,惩罚消失。这样一来约束就自动被“折进”目标里了。

接下来交换 min 和 max 的顺序,得到对偶问题max_{α≥0} min_{w,b} L。这一步交换不是无条件成立的,需要原始问题是凸的、约束是仿射的,并且满足 Slater 条件(存在严格可行点)。SVM 恰好满足这些条件,所以强对偶成立,两个问题的最优值相等——这是整条推导链能继续走下去的前提。

对w和b求偏导并令其为零:

∂L/∂w = 0 => w = Σ_i α_i y_i x_i ∂L/∂b = 0 => Σ_i α_i y_i = 0

第一个式子说明最优的w一定是训练样本的线性组合,系数是α_i y_i。第二个式子是后面 SMO 每次必须动两个变量的根源。把这两个式子代回L,b项和常数项相互抵消,只剩二次型:

max_α Σ_i α_i - 1/2 · Σ_i Σ_j α_i α_j y_i y_j (x_i · x_j) s.t. Σ_i α_i y_i = 0, α_i ≥ 0

这个式子里有一个决定性的细节:样本只以x_i · x_j这个内积的形式出现,单个样本的坐标完全消失了。这是核技巧唯一的入口,没有这个内积结构,后面所有的核函数都无从谈起。另一个细节是,原始问题里的m个不等式约束没了,只剩下一个等式约束和一组非负约束,问题规模从“样本数 × 特征维”变成了“样本数”,对高维稀疏数据尤其划算。

3. 对偶与 KKT:到底哪些样本有资格当支持向量

3.1 软间隔把 α 夹进了一个盒子

上面推的是硬间隔,要求所有样本都被正确分类且在间隔外。真实数据里总有噪声和重叠,硬间隔会导致两个后果:数据线性不可分时问题无解;即便可分,模型也会为了迁就一个离群点把超平面拧得很歪。

软间隔的做法是给每个样本发一张“违规许可证”ξ_i ≥ 0,约束放宽成y_i(w·x_i + b) ≥ 1 - ξ_i,同时在目标里为许可证付费:

min 1/2·||w||² + C·Σ_i ξ_i

C是单价。C越大,违规越贵,模型越倾向于把所有点都分对,退化成硬间隔,对噪声极度敏感;C越小,违规越便宜,模型更愿意牺牲几个点去换一个宽的间隔,但太小就会欠拟合,最后w几乎由正则项主导。

经过同样的对偶推导,软间隔的对偶问题只改了一处:α_i ≥ 0变成了0 ≤ α_i ≤ C。所有α被夹进一个盒子,这就是 SMO 里那个[L, H]裁剪区间的来源。

另一个等价视角更直白:软间隔 SVM 等于“合页损失 + L2 正则”,即min 1/2·||w||² + C·Σ_i max(0, 1 - y_i f(x_i))。合页损失在y_i f(x_i) > 1时梯度恰好为零,这些样本对w毫无贡献——稀疏性从损失函数的形状里就注定了,不是训练完才发生的巧合。

3.2 互补松弛:α 的三种取值对应三种样本命运

KKT 条件是理解支持向量的关键。对偶可行性是0 ≤ α_i ≤ C,原始可行性是y_i f(x_i) ≥ 1 - ξ_i且ξ_i ≥ 0,互补松弛有两组:

α_i · [ y_i f(x_i) - 1 + ξ_i ] = 0 (C - α_i) · ξ_i = 0

把这两组式子放在一起,α_i只有三种取值,每种对应一类样本:

α 取值样本位置对模型的作用
α_i = 0间隔外,正确分类完全不参与w的计算,删掉不影响结果
0 < α_i < C恰好在间隔边界上自由支持向量,ξ_i = 0,是计算b的唯一可靠来源
α_i = C间隔内部或被错分被C顶住,ξ_i > 0,是噪声和重叠区样本

b不能从w里读出来,只能靠第二类样本反解:对任意一个0 < α_i < C的样本,y_i f(x_i) = 1,于是b = y_i - Σ_j α_j y_j K(x_j, x_i)。工程上一般会对所有自由支持向量算一遍再取平均,单个样本算出来的b受浮点误差影响明显,这是踩过的坑。

第三种样本的存在解释了一个常见现象:训练时把C设得很大,会发现几乎所有样本的α都等于C,模型退化成“每个点都在硬撑”,间隔窄得可怜。真正撑起决策边界的样本数量,比想象中少得多。

3.3 SMO:一次只动两个乘子,剩下的用解析解

对偶问题有m个变量和一个等式约束Σ α_i y_i = 0。坐标下降的常规做法是每次只优化一个变量,但这里一动就破坏等式约束,所以最小粒度是两个:固定其余所有α,只让α_i和α_j变化,等式约束退化成一条直线,二次规划在直线上求极值有闭式解。

思路拆开就是四步。第一,找出违反 KKT 最严重的α_i;第二,挑一个能让目标下降最多的α_j;第三,用解析公式更新这两个乘子并裁剪到可行区间;第四,更新b。下面是简化版 SMO 的完整实现,够用来跑通几百个样本的规模,也足够暴露所有数值问题:

import numpy as np def rbf_kernel(A, B, gamma): # 用 |a|^2 + |b|^2 - 2a·b 展开,避免写三重循环 a2 = np.einsum('ij,ij->i', A, A)[:, None] b2 = np.einsum('ij,ij->i', B, B)[None, :] d2 = a2 + b2 - 2.0 * (A @ B.T) # 浮点误差会让本该为 0 的距离变成负数,不裁掉 exp 会大于 1 d2 = np.maximum(d2, 0.0) return np.exp(-gamma * d2) def smo_simple(X, y, C=1.0, tol=1e-3, max_passes=5, gamma=0.5, seed=0): rng = np.random.default_rng(seed) m = X.shape[0] K = rbf_kernel(X, X, gamma) # 一次性算完,O(m^2) 内存,后面全靠它 alpha = np.zeros(m) b = 0.0 passes = 0 while passes < max_passes: num_changed = 0 for i in range(m): # E_i = f(x_i) - y_i,注意 f 里已经包含了 b E_i = b + np.dot(alpha * y, K[:, i]) - y[i] # KKT 违反判定:间隔不足且 alpha 还能涨,或间隔有余且 alpha 还能降 if not ((y[i] * E_i < -tol and alpha[i] < C) or (y[i] * E_i > tol and alpha[i] > 0)): continue j = rng.integers(0, m - 1) j = j if j < i else j + 1 # 保证 j != i E_j = b + np.dot(alpha * y, K[:, j]) - y[j] ai_old, aj_old = alpha[i], alpha[j] # 等式约束 yi*ai + yj*aj = 常数,把 aj 的可行区间推出来 if y[i] != y[j]: L = max(0.0, alpha[j] - alpha[i]) H = min(C, C + alpha[j] - alpha[i]) else: L = max(0.0, alpha[i] + alpha[j] - C) H = min(C, alpha[i] + alpha[j]) if L >= H: continue # eta = K(i,i) + K(j,j) - 2K(i,j) = ||phi(xi) - phi(xj)||^2 >= 0 eta = K[i, i] + K[j, j] - 2.0 * K[i, j] if eta <= 1e-12: # 两点几乎重合,没有可优化方向 continue alpha[j] -= y[j] * (E_i - E_j) / eta alpha[j] = np.clip(alpha[j], L, H) if abs(alpha[j] - aj_old) < 1e-8: # 裁剪后没实质变化,跳过 continue alpha[i] += y[i] * y[j] * (aj_old - alpha[j]) # b 用两个乘子各算一次,取平均以压住浮点抖动 b1 = b - E_i - y[i] * (alpha[i] - ai_old) * K[i, i] \ - y[j] * (alpha[j] - aj_old) * K[i, j] b2 = b - E_j - y[i] * (alpha[i] - ai_old) * K[i, j] \ - y[j] * (alpha[j] - aj_old) * K[j, j] if 0 < alpha[i] < C: b = b1 elif 0 < alpha[j] < C: b = b2 else: b = 0.5 * (b1 + b2) num_changed += 1 # 一整轮没有任何变量被更新,才认为真的收敛 passes = passes + 1 if num_changed == 0 else 0 sv = alpha > 1e-8 return alpha[sv], y[sv], X[sv], b

几个参数和写法的含义值得展开。tol是 KKT 违反的容忍度,取 1e-3 是常见默认值,调小会让训练变慢但边界更准;max_passes不是迭代轮数,而是“连续多少轮没有任何变量变化”才停,这个判定写错是新手最常见的死循环来源;eta那行是整段代码里最容易被忽略的防御,它的几何意义是两个样本在特征空间里的距离平方,当两个样本几乎重合时这个值趋近于 0,除法会把浮点误差放大成天文数字,alpha直接飞掉。

L和H的推导值得单独说清楚。因为α_i y_i + α_j y_j在其余变量固定时是常数,α_i完全由α_j决定。y_i ≠ y_j时两者一增一减,y_i = y_j时两者同增同减,两种情况下α_j的可行区间公式不同,写反了不会报错,只会让模型精度莫名其妙地掉几个点,这种 bug 靠看损失曲线是发现不了的。

4. 核矩阵落地:从内积替换到 O(m²) 内存的那笔账

4.1 内积可替换的合法性从哪来

对偶问题里样本只以x_i · x_j出现,这意味着只要能找到一个函数K(a, b)满足“存在某个映射φ使得K(a, b) = φ(a)·φ(b)”,就可以把对偶式里所有的内积替换掉,而完全不用知道φ长什么样。

这个条件有可判定的等价形式,就是 Mercer 条件:对任意有限样本集,Gram 矩阵K_ij = K(x_i, x_j)都是半正定的。满足这条,就保证了存在一个希尔伯特空间和映射φ,K是该空间里的内积。

RBF 核是这个机制的典型样本:exp(-γ||a-b||²)可以拆成exp(-γ|a|²)·exp(-γ|b|²)·exp(2γ a·b),最后一项做泰勒展开会产生所有阶的交叉项,对应无穷维特征空间。也就是说,用 RBF 核的 SVM 实际上在一个无穷维空间里找超平面,但代码里从头到尾没有构造过任何一个无穷维向量,只算了一个标量。这就是“核技巧解决的是算不动而不是存不下”的确切含义——如果真要显式构造,无穷维连存的资格都没有。

多项式核(γ a·b + r)^d则是在有限维里升维,n维输入展开后维度是C(n+d, d),n=100, d=3就已经是十七万维,显式算内积要十七万次乘法,用核函数只要一次点积加一次幂运算。

4.2 核矩阵的数值实现与内存代价

工程上不会在 SMO 循环里反复调核函数,而是训练开始前一次性算好m × m的核矩阵。这个决定换来了速度,也埋下了内存炸弹:

def kernel_matrix(X, Y, kind='rbf', gamma=0.5, degree=3, coef0=1.0): if kind == 'linear': return X @ Y.T if kind == 'poly': return (gamma * (X @ Y.T) + coef0) ** degree if kind == 'rbf': x2 = np.einsum('ij,ij->i', X, X)[:, None] y2 = np.einsum('ij,ij->i', Y, Y)[None, :] d2 = np.maximum(x2 + y2 - 2.0 * (X @ Y.T), 0.0) return np.exp(-gamma * d2) raise ValueError(f'unknown kernel: {kind}')

三个实现细节。第一,np.maximum(..., 0.0)不是可有可无的保险,|a|² + |b|² - 2a·b在数学上恒等于距离平方,但浮点减法会让本该为 0 的结果变成-1e-16,直接送进exp会得到略大于 1 的值,Gram 矩阵的对角线不再等于 1,半正定性被破坏,SMO 会在某些样本对上得到正的eta。第二,gamma的语义是1/(2σ²),值越大单个样本的影响半径越小,核矩阵会退化成近似单位阵,每个点只跟自己相似,模型能把训练集分到 100% 但对新样本毫无泛化。第三,内存是m² × 8字节,m = 20000时是 3.2 GB,m = 100000时是 80 GB,这个量级决定了核 SVM 的训练样本上限大概在几万这个数量级,再往上必须换线性核或者做近似。

4.3 预测阶段:稀疏性让成本与支持向量数挂钩

训练完之后,α_i = 0的样本可以整行删掉,决策函数只剩支持向量参与:

def decision_function(X_test, sv, sv_y, sv_alpha, b, gamma): K = kernel_matrix(X_test, sv, kind='rbf', gamma=gamma) # (n_test, n_sv) return K @ (sv_alpha * sv_y) + b def predict(X_test, sv, sv_y, sv_alpha, b, gamma): return np.where(decision_function(X_test, sv, sv_y, sv_alpha, b, gamma) >= 0, 1, -1)

sv_alpha * sv_y把乘子符号并进系数里,一次矩阵向量乘就出结果。注意这里用的是sv而不是完整训练集——如果预测时拿全量样本算,结果在数学上一样(因为非支持向量的α是 0),但会白白多算几十倍的核函数值,还会引入额外的浮点累积误差,导致同一个样本在训练时算出来的分数和预测时算出来的差一个 1e-12,正好卡在 0 附近时分类结果翻转。这种问题极难排查,因为看起来“只是慢了一点”。

支持向量的数量本身也是一个诊断信号:如果它接近训练集大小,说明C太大或gamma太大,模型没有学到稀疏结构,退化成了一种昂贵的近邻法;如果只有三五个,可能是C太小,模型欠拟合,间隔宽得把所有点都划到同一边。

5. 避坑清单:手写 SVM 时最常翻车的五处细节

5.1 数据尺度和核参数:精度掉下来先怀疑这里

现象:训练集准确率 99%,交叉验证 55%,损失曲线看起来完美收敛,没有任何报错。

原因:RBF 核算的是欧氏距离,如果某一维的量纲是另一维的几百倍,距离完全被这一维主导,核函数实际上只对这一个特征敏感。再叠加一个偏大的gamma,模型会把每个训练点都圈成一座孤岛,训练集当然全对。

解决:训练前强制走一遍标准化,把每维缩放到零均值单位方差,并且把 scaler 的均值方差存下来,预测时用同一套参数变换。参数搜索不要用线性网格,C和gamma都按 2 的幂次在2^-5到2^5之间搜,配合分层交叉验证。搜索范围不要贪大,gamma取到10以上时核矩阵基本就是单位阵了,搜索再远也没有意义。

5.2 训练不收敛:eta趋零和收敛判据写错

现象:跑了几千轮alpha还在大幅抖动,或者十几轮就“收敛”了但精度很差。

原因:两个可能。一是eta = K(i,i) + K(j,j) - 2K(i,j)在两点几乎重合时趋近于 0,除法把误差放大,alpha直接跳出可行域再被裁回来,来回震荡。二是收敛判据写成了“遍历完一轮就passes += 1”,而不是“一整轮没有任何变量被更新才加一”,结果外部循环提前退出。

解决:eta < 1e-12时直接跳过这对样本,不做更新;用num_changed计数器严格判断是否真的停止;打印每轮的num_changed和sum(alpha),sum(alpha)应该在合理区间缓慢变化,如果它剧烈跳动说明更新公式的符号写反了。

5.3 同一个模型两次训练结果不同

现象:换一个随机种子,支持向量集合变了,边界也微微移动。

原因:j是随机选的,不同的更新顺序会导致不同的收敛路径。对于严格凸的二次规划,最优解唯一,理论上应该收敛到同一个点,但如果tol定得松、max_passes定得小,两次都会停在不同的近似位置。另外一个隐蔽原因是裁剪后alpha变化小于 1e-8 仍然继续更新,制造了无意义的状态漂移。

解决:固定随机种子保证可复现;把裁剪后的变化量检查补上;如果对一致性要求高,把tol调到 1e-4 以下并增加max_passes,代价是训练时间线性增长。

5.4 训练时算的分数和预测时对不上

现象:同一个样本,训练过程中打印的决策值和新写的预测函数算出来的值差一点点,偶尔分类结果相反。

原因:训练时用全量样本算f(x),预测时只用支持向量算,两者的浮点求和顺序不同,结果在 1e-14 量级上有差异。这个差异平时无害,但当决策值恰好落在 0 附近时,符号翻转。

解决:训练和预测统一用“支持向量 + b”这一条路径;写一个单元测试,取训练集里的样本,对比两条路径的输出差值是否在 1e-9 以内;分类时用>= 0而不是> 0,保证 0 这个边界值有确定归属。

5.5 样本量一大就内存爆掉

现象:几万条样本直接触发内存错误,或者训练卡死几分钟没动静。

原因:核矩阵是m × m × 8字节。两万条是 3.2 GB,五万条是 20 GB,十万条是 80 GB。这不是实现写得烂,是核方法的固有代价。

解决:先判断线性核够不够用,线性核的K = X @ X.T同样占内存但可以分块算,而且线性 SVM 可以直接在原空间用对偶坐标下降,完全不用构造核矩阵。确实需要非线性时,用子采样、分块训练或者低秩近似把矩阵压下来。这一点直接决定了“手写核 SVM”的适用范围大概在几千到几万样本之间,再往上不是调参能解决的问题。

6. 手写与调库:复杂度、稀疏性和可控性的取舍

手写一遍 SMO 最大的收获,不是得到了一个能用的分类器,而是搞清楚了这个模型的成本结构长什么样。整个算法的开销可以拆成三块:核矩阵的O(m²)内存和O(m²d)计算、SMO 迭代轮数乘以每轮的O(m)内积、预测时的O(n_sv · d)。三块里最要命的是第一块,它把核 SVM 的适用边界钉死在了中小规模数据上,任何声称“SVM 在大数据上也能跑”的说法,要么用的是线性核,要么用的是近似方法。

线性核和非线性核在实际工程里的取舍,用一张表能说清:

维度线性核RBF 核
训练复杂度可做到接近O(m·d)O(m²)内存,迭代成本高
支持向量数通常较少参数不当时接近全量
超参数只有CC与gamma耦合,二维搜索
适用场景高维稀疏、特征数远大于样本数低维稠密、边界明显非线性

超参数的耦合是 RBF 最让人头疼的地方。C和gamma不是独立的:gamma变大相当于把每个样本的影响半径缩小,模型复杂度上升,此时需要相应降低C来抑制;反过来gamma很小时模型接近线性,C需要调大才能拟合。这就是为什么二维网格搜索必须用交叉验证而不是单次划分——单次划分上的最优点往往是噪声恰好合适的位置。

稀疏性带来的另一个隐性收益是可解释性。支持向量就是那些“卡在边界上、最不确定”的样本,把它们打出来看一眼,往往能发现标注错误、离群点或者两类的真实重叠区域。这个能力在调库时被封装掉了,只有自己实现过一遍才知道可以这样用。

从实现角度说,简化版 SMO 和工业级实现之间还差一个关键设计:工作集选择。简化版是顺序扫描所有样本,遇到违反 KKT 的就更新;成熟实现用一阶或二阶的工作集选择启发式,优先处理违反最严重的样本,能在同样的精度下把迭代次数压掉一个数量级。这是算法层面的优化,不是常数级调优,样本量越大差距越明显。

从那以后我每次写核方法相关的代码,都会先做三件事:把数据标准化跑一遍看看精度变化,把核矩阵的对角线打出来确认它全是 1,再拿训练集里的样本对比训练和预测两条路径的决策值。这三步花不了两分钟,但能挡掉绝大多数看起来像“模型不行”其实是数值问题的翻车现场。希望帮到你。

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

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

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

立即咨询