资讯动态

SVM支持向量机Python实现:SMO、核函数与多分类避坑

发布时间:2026/10/29 20:10:13 来源:尧图企业网站定制
简介面向机器学习初学者与希望手写算法巩固原理的开发者这份支持向量机SVMPython实现资料以纯代码方式呈现分类器核心流程适合在课程实验、算法作业或面试复习中对照理解间隔最大化、拉格朗日乘子与核函数等概念。压缩包共6个文件、约9KB以Python源码为主体含SVM主体实现与测试脚本另配编译缓存、Markdown说明文档及一份用于训练验证的文本数据集体积轻巧却覆盖从算法编写到结果验证的闭环。已有1944人学习下载在入门实践中具备一定参考价值。读者可据此梳理代码组织思路借助测试脚本与样本数据跑通训练与分类流程并通过说明文档理解参数含义与运行方式对希望摆脱调包、亲手实现优化过程的学习者而言这份材料提供了可逐行拆解、便于二次修改的最小示例也能作为课程笔记与实验报告的辅助参考。1. SVM 的 Python 实现先别急着调包手写一遍才知道坑在哪“SVM 支持向量机的 Python 实现”这份资源最容易被当成调个sklearn.svm.SVC就结束的 demo。真把它拆开跑翻车点通常不在公式而在三处工程细节核矩阵算错、乘子裁剪越界、b更新后决策边界整体偏移。它适合已经会 numpy、想把这套东西接进自己数据管线的开发者也适合面试前想把对偶问题从纸面推到代码里的人。资源把线性核、RBF 核、SMO 训练循环和多分类投票分模块写二分类能跑通小样本多分类也能扩展。下面按“先立住原理再拆训练循环最后排查”的顺序走。2. 从几何间隔到对偶问题C 到底在松弛什么2.1 硬间隔、软间隔与 C 的取舍线性可分时SVM 找的是能把两类分开且间隔最大的超平面。点到超平面的函数间隔是y_i(w^T x_i b)几何间隔再除以||w||。硬间隔要求所有点满足y_i(w^T x_i b) 1优化目标写成min 1/2 ||w||^2。现实数据里总有噪声和离群点硬间隔会把少量点放大成整条边界的偏移所以引入松弛变量xi_i 0约束变成y_i(w^T x_i b) 1 - xi_i目标加上C * sum(xi_i)。C是惩罚系数不是正则化强度的倒数那种直观量。C越大对误分类越不容忍边界会贴着少数点走训练集分数高但泛化容易掉C越小允许更多点落在间隔内边界更平滑。我一般先在标准化后的数据上取C1.0跑线性核看支持向量数量和混淆矩阵再决定往哪个方向调。资源里的实现默认也是这个顺序先线性核验证流程再换 RBF 核。特征标准化这一步不能省。SVM 的间隔由欧氏距离定义某个特征量纲是另一个的 100 倍核矩阵会被它主导调参就会变成玄学。常见做法是StandardScaler或手写(X - mean) / std训练集和测试集用同一组均值方差。2.2 拉格朗日对偶为什么求解乘子而不是 w原始问题带不等式约束直接对w求导只能得到w sum(alpha_i y_i x_i)但alpha_i仍受约束。构造拉格朗日函数后对w、b、xi求偏导并代回得到对偶问题max sum(alpha_i) - 1/2 sum_i sum_j alpha_i alpha_j y_i y_j K(x_i, x_j)约束0 alpha_i Csum(alpha_i y_i) 0。这里K(x_i, x_j)是核函数。对偶形式的好处是只依赖样本两两内积可以用核函数替换内积把线性不可分数据映射到高维。训练完成后只有alpha_i 0的样本参与决策它们就是支持向量。alpha_i C的点通常落在间隔内或被误分类0 alpha_i C的点落在间隔边界上适合用来求b。KKT 条件在代码里对应三个检查alpha_i 0时y_i f(x_i) 10 alpha_i C时y_i f(x_i) 1alpha_i C时y_i f(x_i) 1。SMO 的外层循环就是找违反这些条件的乘子。2.3 用 numpy 构造核矩阵并验证半正定先做一个小样本二分类把数据、标准化和核矩阵跑通。核矩阵必须是半正定的数值上允许极小负特征值但如果最小特征值明显小于 0后面的 SMO 会给出奇怪结果。import numpy as np rng np.random.default_rng(42) # 两类二维数据先保证线性可分方便对照 X_pos rng.normal(loc[2.0, 2.0], scale0.7, size(80, 2)) X_neg rng.normal(loc[-2.0, -2.0], scale0.7, size(80, 2)) X np.vstack([X_pos, X_neg]) y np.array([1] * 80 [-1] * 80, dtypefloat) # 标准化SVM 对尺度敏感训练集和测试集必须用同一组参数 mean, std X.mean(axis0), X.std(axis0) X (X - mean) / std # 线性核矩阵 K_linear X X.T # RBF 核矩阵用平方展开避免三维差张量 gamma 0.5 sq1 np.sum(X ** 2, axis1)[:, None] sq2 np.sum(X ** 2, axis1)[None, :] d2 np.maximum(sq1 sq2 - 2 * X X.T, 0.0) K_rbf np.exp(-gamma * d2) for name, K in [(linear, K_linear), (rbf, K_rbf)]: eig_min np.linalg.eigvalsh(K).min() print(name, min_eig, round(float(eig_min), 8))逻辑说明先构造 160 个二维样本正类中心在[2,2]负类中心在[-2,-2]标准化后计算线性核和 RBF 核。RBF 核用||x||^2 ||z||^2 - 2x^Tz展开避免X[:, None, :] - X[None, :, :]这种三维数组160 个样本看不出差别样本量到几千时内存差距很明显。np.maximum(..., 0)是防止浮点误差出现负距离。参数说明gamma0.5只是起步值RBF 核的gamma越大单个样本影响范围越小边界越弯曲C越大越不允许误分类。两个参数一起调时先固定C1.0扫gamma再固定较优gamma扫C比网格一次铺开更容易看出趋势。提示核矩阵半正定检查只在小样本上做。样本量过万时eigvalsh的时间复杂度是O(n^3)不适合当常规检查改成抽查子矩阵或直接看训练是否收敛。3. SMO 逐行拆两个乘子怎么选、怎么剪3.1 外层循环与 KKT 违反量SMO 每次只优化两个乘子因为等式约束sum(alpha_i y_i) 0让单个乘子不能独立变化。外层循环扫描所有样本计算误差E_i f(x_i) - y_i其中f(x_i) sum(alpha_j y_j K(x_j, x_i)) b。违反 KKT 的条件可以写成两类y_i * E_i -tol且alpha_i C说明该点本该有更大的乘子y_i * E_i tol且alpha_i 0说明该点乘子可以继续减小。tol是容忍度常用1e-3。设得太小训练后期会在边界附近反复扫描设得太大乘子没优化到位就提前退出。资源里的实现用max_passes控制连续无变化的扫描次数常见取值 5 到 10。样本量小的时候可以放大到 20样本量大时不要靠放大max_passes硬扛先检查特征和核参数。3.2 内层启发式与乘子裁剪选中第一个乘子i后第二个乘子j常见做法是选误差差最大的那个即|E_i - E_j|最大。简化实现里随机选一个j ! i也能跑但收敛会慢一些尤其是C很大、支持向量很多的时候。选完j后先算裁剪边界L和H若y_i ! y_jL max(0, alpha_j - alpha_i)H min(C, C alpha_j - alpha_i)若y_i y_jL max(0, alpha_i alpha_j - C)H min(C, alpha_i alpha_j)。如果L H这对乘子没有优化空间直接跳过。更新量用eta 2K(i,j) - K(i,i) - K(j,j)核矩阵半正定时eta 0数值上如果eta 0也跳过否则除出来的步长会反向。更新alpha_j后一定要裁剪到[L, H]再反推alpha_i alpha_i y_i * y_j * (alpha_j_old - alpha_j)。这一步是手写 SVM 最容易翻车的地方裁剪顺序写反、alpha_i没跟着更新、b用了裁剪前的乘子都会让决策边界偏移。3.3 完整 SMO 训练循环下面这个版本保留了 SMO 的主干适合小样本对照不是工业级实现。生产环境直接用sklearn.svm.SVC但拿它来验证公式足够。import numpy as np class SimpleSVM: def __init__(self, C1.0, kernellinear, gamma0.5, tol1e-3, max_passes10, seed42): self.C C self.kernel kernel self.gamma gamma self.tol tol self.max_passes max_passes self.rng np.random.default_rng(seed) def _kernel_matrix(self, A, B): if self.kernel linear: return A B.T if self.kernel rbf: sq1 np.sum(A ** 2, axis1)[:, None] sq2 np.sum(B ** 2, axis1)[None, :] d2 np.maximum(sq1 sq2 - 2 * A B.T, 0.0) return np.exp(-self.gamma * d2) raise ValueError(kernel 只支持 linear 或 rbf) def fit(self, X, y): y np.where(y 0, -1.0, 1.0) n X.shape[0] K self._kernel_matrix(X, X) alpha np.zeros(n) b 0.0 passes 0 while passes self.max_passes: num_changed 0 for i in range(n): Ei b np.sum(alpha * y * K[:, i]) - y[i] # 外层 KKT 检查 if not ((y[i] * Ei -self.tol and alpha[i] self.C) or (y[i] * Ei self.tol and alpha[i] 0)): continue # 简化版随机选 j工业实现会选 |Ei-Ej| 最大的 j_candidates [k for k in range(n) if k ! i] j int(self.rng.choice(j_candidates)) Ej b np.sum(alpha * y * K[:, j]) - y[j] ai_old, aj_old alpha[i], alpha[j] if y[i] ! y[j]: L max(0.0, alpha[j] - alpha[i]) H min(self.C, self.C alpha[j] - alpha[i]) else: L max(0.0, alpha[i] alpha[j] - self.C) H min(self.C, alpha[i] alpha[j]) if L H: continue eta 2 * K[i, j] - K[i, i] - K[j, j] if eta 0: continue alpha[j] - y[j] * (Ei - Ej) / eta alpha[j] np.clip(alpha[j], L, H) if abs(alpha[j] - aj_old) 1e-5: continue alpha[i] y[i] * y[j] * (aj_old - alpha[j]) b1 b - Ei - y[i] * (alpha[i] - ai_old) * K[i, i] \ - y[j] * (alpha[j] - aj_old) * K[i, j] b2 b - Ej - y[i] * (alpha[i] - ai_old) * K[i, j] \ - y[j] * (alpha[j] - aj_old) * K[j, j] if 0 alpha[i] self.C: b b1 elif 0 alpha[j] self.C: b b2 else: b (b1 b2) / 2.0 num_changed 1 passes passes 1 if num_changed 0 else 0 self.alpha alpha self.b b self.X X self.y y self.K K self.support_ np.where(alpha 1e-5)[0] self.w None if self.kernel linear: self.w np.sum((alpha * y)[:, None] * X, axis0) return self逻辑说明fit先把标签统一成-1和1避免原始标签是0/1时对偶约束写错。K只算一次训练循环里反复取列。外层用y_i * E_i和alpha_i的范围判断 KKT 违反内层先算L/H再算eta和更新量。b的更新分三种情况alpha_i在开区间内用b1alpha_j在开区间内用b2都在边界上取平均。support_用alpha 1e-5收集包含alpha C的支持向量。参数说明C默认1.0数据标准化后可先试0.1、1、10gamma只在 RBF 核下生效常用范围是1/(n_features * X.var())附近但不要死记配合验证集看tol默认1e-3小样本可以降到1e-4看收敛是否更充分max_passes默认 10连续 10 轮没有乘子变化就停。这个实现没有缓存核矩阵列、没有 shrinking样本量超过两三千时训练会明显变慢属于教学版边界。3.4 从乘子还原 w 和 b线性核下w sum(alpha_i y_i x_i)预测值f(x) w^T x b。RBF 核没有显式w只能保留训练样本和乘子做核展开。下面补上预测和线性权重还原def decision_function(self, X): K self._kernel_matrix(X, self.X) return K (self.alpha * self.y) self.b def predict(self, X): scores self.decision_function(X) return np.where(scores 0, 1, -1) # 用法示例 model SimpleSVM(C1.0, kernellinear, tol1e-3, max_passes20).fit(X, y) scores model.decision_function(X) acc np.mean(model.predict(X) y) print(train acc, round(float(acc), 4), support, len(model.support_)) print(w, np.round(model.w, 4), b, round(float(model.b), 4))逻辑说明decision_function用核矩阵和alpha * y做加权求和再加b。predict用scores 0判正类避免np.sign(0)返回 0 导致类别缺失。线性核下可以直接看w的方向标准化后w的每个分量代表对应特征对决策的贡献方向但不能直接当特征重要性排序特征相关性强时w会互相抵消。参数说明support_的数量和C强相关。C增大支持向量通常变多边界更复杂C减小支持向量减少边界更平。训练集准确率接近 1 但验证集掉得厉害先把C降一个数量级再看gamma是否过大。4. 核函数与多分类RBF 的 gamma 不是随手填的4.1 RBF 核矩阵用平方展开别直接做三维差核函数封装成类训练和预测共用同一套计算逻辑避免训练时用线性核、预测时用 RBF 核这种低级错位。RBF 核的gamma控制单个样本的影响半径gamma越大训练样本只影响很近的点边界会绕着小簇走gamma越小影响范围越大边界接近线性。样本标准化后gamma的常见起步值是1 / n_features再按验证集调整。class Kernel: def __init__(self, kindrbf, gamma0.5, degree3, coef01.0): self.kind kind self.gamma gamma self.degree degree self.coef0 coef0 def __call__(self, A, B): if self.kind linear: return A B.T if self.kind poly: return (self.gamma * A B.T self.coef0) ** self.degree if self.kind rbf: sq1 np.sum(A ** 2, axis1)[:, None] sq2 np.sum(B ** 2, axis1)[None, :] d2 np.maximum(sq1 sq2 - 2 * A B.T, 0.0) return np.exp(-self.gamma * d2) raise ValueError(unsupported kernel) # 分块计算核矩阵控制内存峰值 def kernel_block(K: Kernel, A, B, block512): out np.empty((A.shape[0], B.shape[0]), dtypefloat) for start in range(0, A.shape[0], block): end min(start block, A.shape[0]) out[start:end] K(A[start:end], B) return out逻辑说明Kernel.__call__接受两个矩阵返回len(A) x len(B)的核矩阵。多项式核用(gamma * A B.T coef0) ** degreecoef0控制低阶项影响。RBF 核继续用平方展开kernel_block按行分块避免一次性分配完整n x n矩阵。训练阶段如果必须保留完整核矩阵先估算内存n20000时一个float64矩阵约 3.2 GB加上乘法和临时数组很容易超过普通开发机内存。参数说明gamma只对 RBF 和多项式核生效degree常用 2 或 3再高容易过拟合coef0在多项式核里影响高阶项和低阶项的混合默认 1.0 可以先不动。分块大小block512不是固定值按可用内存调目的是让单次临时矩阵不超过几百 MB。4.2 RBF 的 gamma 与 C 的联动RBF 核下C和gamma不是独立的。gamma大、C大模型会记住训练集里的每个点支持向量多决策边界碎gamma小、C小模型接近线性欠拟合。比较实用的扫参顺序是先固定C1.0让gamma在[0.01, 0.03, 0.1, 0.3, 1.0]里走一遍看验证集分数和支持向量比例再固定较优gamma让C在[0.1, 1, 10, 100]里走。每次只动一个参数曲线更容易读。参数作用调大后的现象建议起点C误分类惩罚支持向量变多边界复杂训练集分数升高标准化后先取 1.0gammaRBF 影响半径单点影响范围变小边界弯曲容易过拟合1 / n_features附近tolKKT 容忍度训练更早停乘子精度下降小样本1e-4常规1e-3max_passes连续无变化轮数训练更久可能空转10 到 20注意不要在没有标准化的情况下扫gamma。特征方差差异大时RBF 核的距离被大量纲特征主导扫出来的最优gamma只对当前尺度有效换一批数据就失效。4.3 一对多与一对一多分类封装和投票SVM 原生是二分类。多分类常见两种封装一对多One-vs-Rest给每个类别训练一个“该类 vs 其余”的分类器预测时取决策值最大的类一对一One-vs-One给每两个类别训练一个分类器预测时投票。类别数少时一对一更常用因为每个子问题样本更均衡类别多时一对一分类器数量是k(k-1)/2训练成本上升。from itertools import combinations def fit_ovo(X, y, base_model): classes np.unique(y) models {} for a, b in combinations(classes, 2): mask (y a) | (y b) m base_model().__class__(**base_model().get_params()) # 子问题标签转成 -1/1复用二分类训练 y_bin np.where(y[mask] a, 1, -1) m.fit(X[mask], y_bin) models[(a, b)] m return classes, models def predict_ovo(X, classes, models): votes np.zeros((X.shape[0], len(classes))) class_to_idx {c: i for i, c in enumerate(classes)} for (a, b), m in models.items(): pred m.predict(X) for row, p in enumerate(pred): winner a if p 1 else b votes[row, class_to_idx[winner]] 1 return classes[np.argmax(votes, axis1)]逻辑说明fit_ovo用combinations遍历类别对只取属于这两个类的样本把第一类标成1、第二类标成-1复用二分类模型。predict_ovo让每个分类器投票票数最多的类作为最终结果。这里用base_model().__class__(**base_model().get_params())复制模型配置实际项目里更推荐传工厂函数避免模型对象被多个子问题共享状态。参数说明多分类时C和gamma仍然按子问题调但类别不平衡会让某些一对子问题的正负样本比例偏斜。遇到这种情况先看每个子问题的混淆矩阵不要只看总体准确率。投票平票时argmax会取第一个类真实项目里可以按决策值绝对值加权或者固定类别顺序后做 tie-break。5. 避坑与排查手写 SVM 最容易翻车的五个现场5.1 乘子全卡在 0 或 C先查特征尺度和 C现象训练循环跑了很多轮alpha不是 0 就是C决策边界几乎不动训练准确率和随机猜差不多。原因通常有三个特征没标准化某个量纲大的特征主导了核矩阵标签还是0/1对偶约束sum(alpha_i y_i)0写错C或gamma设得过大乘子一步就顶到边界。排查时先把标签转成-1/1打印alpha的分布和支持向量数量再把特征标准化后重跑。线性核下如果w的某个分量特别大基本就是尺度问题。解决顺序是标准化、降C一个数量级、RBF 核降gamma每次只动一个。5.2 核矩阵报错或内存爆别一次性铺开现象样本量到几千就MemoryError或者np.exp出现inf、nan。原因RBF 核直接做A[:, None, :] - B[None, :, :]会生成n x n x d的三维数组内存是核矩阵的d倍平方展开里没有做np.maximum(d2, 0)浮点误差产生负距离exp指数爆炸。解决用平方展开负距离截断到 0大样本用分块计算训练阶段只保留需要的核矩阵列样本量超过内存边界时换线性核或降采样。核矩阵不是越大越好先确认数据量是否真的需要 RBF。5.3 预测全是一类检查 b 和类别阈值现象训练集准确率不低预测结果全是一个类混淆矩阵一边倒。原因b更新用了裁剪前的乘子或者predict用np.sign把 0 判成 0 类类别不平衡时阈值 0 不一定合适。排查时打印decision_function的分布看正负样本的分数是否重叠。如果分数全正或全负先检查b1/b2公式里的K[i,i]、K[i,j]下标有没有写反。解决predict用scores 0判正类类别不平衡时看class_weight或按验证集调阈值先在线性核小样本上对齐再换 RBF。5.4 b 值震荡、决策边界偏移裁剪和 eta 检查不能省现象每轮训练b跳来跳去决策边界整体平移支持向量数量异常多。原因alpha_j更新后没裁剪到[L,H]或者eta 0时没有跳过导致步长方向反了b更新时用了更新前的alpha_i/alpha_j。解决严格按alpha_j clip(alpha_j, L, H)、alpha_i y_i*y_j*(aj_old-alpha_j)的顺序写eta 0直接continueb用更新后的乘子算。调试时可以在每轮打印sum(alpha*y)它应该接近 0偏离越大说明乘子更新有问题。5.5 和 sklearn 差很多先统一参数和预处理现象手写实现训练准确率 0.85sklearn.svm.SVC是 0.95调参后差距还在。原因默认参数不同SVC的C1.0、gammascale、tol1e-3还有 shrinking 和缓存手写版没有 shrinkingmax_passes可能提前停预处理没对齐一边标准化一边没标准化。排查时固定random_state统一C、gamma、tol先比decision_function的相关系数再比 hard label。相关系数低于 0.9 时不要继续调参先查核矩阵和b。解决小样本线性核先对齐w和b再换 RBF允许手写版和SVC有少量数值差异但决策值趋势要一致。6. 把 SVM 跑通三个验证技巧和一条血泪习惯6.1 用小样本线性核对齐 w、b 与 sklearn手写 SMO 跑完不要只看准确率先和sklearn.svm.SVC(kernellinear)对齐决策值。下面这段代码比较w的余弦相似度、b的差值和预测一致率from sklearn.svm import SVC # 用同一份标准化数据统一 C 和 tol sk SVC(kernellinear, C1.0, tol1e-3) sk.fit(X, y) my SimpleSVM(C1.0, kernellinear, tol1e-3, max_passes30).fit(X, y) w_sk sk.coef_[0] w_my my.w cos float(w_sk w_my / (np.linalg.norm(w_sk) * np.linalg.norm(w_my))) b_diff float(abs(sk.intercept_[0] - my.b)) agree float(np.mean(sk.predict(X) my.predict(X))) print(cos_w, round(cos, 4), b_diff, round(b_diff, 4), agree, round(agree, 4))逻辑说明SVC的coef_是线性核的wintercept_是b。cos接近 1 说明方向一致b_diff反映边界平移agree看硬分类是否一致。小样本线性可分数据上cos低于 0.95 通常说明 SMO 没收敛或b更新有问题agree高但b_diff大可能是支持向量选择不同先看support_是否落在间隔边界上。参数说明SVC的tol和手写版含义接近但停止条件不完全一样比较时不要拿max_iter硬对齐。C必须一致gamma在线性核下不生效。6.2 决策边界与支持向量可视化二维数据最值得看的是决策边界和支持向量位置。画网格预测值叠加两类散点和support_能快速看出边界是贴着间隔走还是被离群点拽偏。RBF 核下边界弯曲程度和gamma直接相关gamma从 0.1 调到 1.0边界从近似线性变成小岛支持向量数量也会明显上升。这个图不用追求好看目的是在调参前先确认数据分布和核函数是否匹配。6.3 对偶间隙和交叉验证的边界对偶目标值可以和原始目标对照对偶形式是sum(alpha) - 1/2 sum_i sum_j alpha_i alpha_j y_i y_j K_ij原始形式是1/2 ||w||^2 C * sum(xi)。线性核小样本上两者应该接近差距大说明乘子没收敛或b没对齐。交叉验证方面SVM 对参数敏感GridSearchCV的网格不要一开始铺太密先粗扫C和gamma的数量级再局部细化。小样本上留一法方差大5 折更常见类别不平衡时用分层折。验证项通过标准失败动作w余弦相似度小样本线性核大于 0.95查标签、标准化、b更新预测一致率大于 0.95查核矩阵和乘子裁剪对偶间隙相对误差小于 1e-2增大max_passes降tol支持向量比例不异常接近 0 或 1调C、gamma从那以后我每次手写 SVM 都强制先跑一遍线性核小样本对照标签转-1/1、特征标准化、w和b对齐SVC这三步过了再换 RBF 核。顺序反了调参就会变成猜黑匣子。这份 SVM 的 Python 实现按线性核、RBF 核、多分类投票拆开先跑小样本对照再上自己的数据能少走不少弯路。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑