简介支持向量机SVM的 Python 实现示例面向机器学习初学者和需要快速落地分类模型的开发者用精简脚本演示了 SVM 从数据加载、优化求解到决策边界输出的完整流程。支持向量机是机器学习中经典的监督学习算法常用于二分类任务代码将抽象原理转化为可运行脚本能显著降低理解门槛。压缩包共 6 个文件以 3 个 Python 脚本为主另含 1 个 Markdown 说明文档、1 个测试集文本文件和 1 个 pyc 缓存文件整包仅 9KB轻量易读目录结构紧凑。核心 svm.py 负责模型训练与预测testsvm.py 在 testSet.txt 上执行验证addblankAndTab.py 辅助数据格式处理README.md 提供使用指引pyc 文件则是编译缓存产物。已有 1937 人浏览学习通过阅读代码可直观理解 SVM 的核函数、支持向量和分类边界等关键概念也便于在此基础上调整参数、替换数据集进行二次实验适合课程作业或入门实践。1. 用Python实现支持向量机先想清楚你要写的是哪一层当你决定用Python实现一个支持向量机SVM第一件要认清的事是你要写的是数学推导、内核代码还是调包验证效果不少人在博客上看过SVM的公式推导打开IDE后却不知道从哪里下手花了大量时间把拉格朗日乘子抄到代码里结果训练出来的模型连线性可分数据都搞不定。这篇文章直接给两条可复现的路线一条用纯Python手写SMO算法训练线性SVM另一条用PyTorch实现带核函数的SVM并在手写数字分类场景中验证核函数与参数的影响。无论你是做课程作业还是想深入理解SVM走完都能得到能跑的代码和一组排查手段。2. 先立住四个概念间隔、对偶、KKT与软间隔手写实现之前必须把四个概念压实因为后面代码里每一个数值判断都对应这里的公式。只背结论写代码遇到不收敛时你根本不知道错在哪。2.1 几何间隔为什么除以范数函数间隔为什么不能用SVM的决策函数是 f(x) w^T x b。对于样本 (x_i, y_i)y_i 取 1 或 -1分类正确时 y_i f(x_i) 0。函数间隔定义为 y_i f(x_i)但这个值会随着 w 和 b 同时缩放而任意变化比如把 w、b 都乘以 10函数间隔也变成 10 倍这个「间隔」跟分类的真实余量没有对应关系。几何间隔才是点到超平面的实际距离margin_i y_i (w^T x_i b) / ||w||把所有样本的几何间隔最小值最大化就得到原始目标。这个带约束的 max-min 问题不好直接优化于是教科书都改写为 min 1/2 ||w||^2s.t. y_i(w^T x_i b) ≥ 1。注意这里把函数间隔固定为 1这个「1」只是约束缩放的基准不是超参数。最终只有落在间隔边界样本决定了模型其他样本删掉也不影响结果这就是支持向量名称的来源。2.2 拉格朗日对偶为什么非要用对偶形式原始问题带不等式约束直接对 w 做梯度下降要反复把梯度投影回可行域非常别扭。引入拉格朗日乘子 α_i ≥ 0构造拉格朗日函数对 w 和 b 求偏导后消掉得到对偶问题max_α Σ α_i - 1/2 ΣΣ α_i α_j y_i y_j x_i^T x_j s.t. Σ α_i y_i 0, α_i ≥ 0对偶的好处有两个。第一原始问题要解 w 和 b 两个向量对偶只解 α 一堆标量变量数从特征维度换成样本数在实际数据上更可控。第二样本只以内积 x_i^T x_j 出现这为核技巧打开门——把内积替换成核函数 K(x_i, x_j)就能在隐式高维特征空间里做分类。实现时只需要把内积换成核矩阵其余推导全部不动。2.3 KKT 条件判断收敛和挑选支持向量的标尺求解对偶问题后KKT 条件是最优解的检验标准。对每个样本 i需要满足α_i ≥ 0 y_i f(x_i) - 1 ≥ 0 α_i (y_i f(x_i) - 1) 0第三条最有信息量。如果 α_i 0必然 y_i f(x_i) 1说明样本恰好在间隔边界上是支持向量。如果 0 α_i C这是自由支持向量可以用来求偏置 b。如果 α_i C样本可能在间隔内部甚至被误分对应软间隔里的惩罚。SMO 算法每一轮都要检查这些条件我在实现时直接用这个式子写循环判断只要有样本的 y_i f(x_i) 与 1 的差距超过容差就认为还有未满足条件的样本继续迭代。2.4 软间隔C 参数在约束什么线性不可分的数据要放松约束引入松弛变量 ξ_i ≥ 0目标变成min 1/2 ||w||^2 C Σ ξ_i s.t. y_i(w^T x_i b) ≥ 1 - ξ_iC 是正则化与训练误差的权衡。C 越大越不容忍误分间隔变窄容易过拟合C 越小间隔可以变宽但误分率上升。这个 C 在对偶空间表现为 0 ≤ α_i ≤ C 的盒子约束。调参时我常记一句话先固定核函数C 从 1 开始看支持向量占比和交叉验证分数再往 10、100 或 0.1 方向移动。手写 SMO 时最容易忽略的是 α 的上下界 L、H 的计算后面代码会专门演示。下面的符号对照表是写代码前必须确定好的免得公式和变量对不上符号含义代码变量w超平面法向量wb偏置bα_i拉格朗日乘子0 ≤ α_i ≤ CalphasC软间隔惩罚系数CK(x_i,x_j)核函数线性时等于 x_i^T x_jkernelE_i预测误差 f(x_i) - y_iE 缓存数组3. SMO算法纯手动实现训练线性SVM的全流程理解公式后最常见的做法是用SMO算法把对偶问题拆成两个变量的子问题循环求解这也是「怎么用支持向量机 全流程」里最值得手写一遍的路径。SMO不依赖通用二次规划求解器代码容易读懂整个训练过程只需要 numpy。3.1 为什么每次只优化两个 αSMO 的出发点对偶问题的等式约束是 Σ α_i y_i 0。如果一次只改一个 α等式必然被破坏同时改两个一个增大一个减小符号匹配就能保持约束成立。SMO 用两层循环挑选变量外层遍历所有样本优先处理违反 KKT 条件最严重的内层选择与当前 α 的误差差距最大的另一个 α让目标函数下降量最大。先贴训练函数骨架后面拆开解释每一步。为了聚焦主干这里用线性核核矩阵边算边用。import numpy as np class LinearSVM: def __init__(self, C1.0, tol1e-3, max_passes20): self.C C self.tol tol self.max_passes max_passes # 连续多少轮无更新则提前结束 def fit(self, X, y): n, d X.shape self.X X self.y y self.alphas np.zeros(n) self.b 0.0 self.E -y.copy() # 初始 w0f(x)b0所以 E_i -y_i passes 0 while passes self.max_passes: num_changed 0 for i in range(n): self.E[i] self.decision_function(X[i]) - y[i] if self._violate_kkt(i): j self._select_second(i) num_changed self._update_pair(i, j) if num_changed 0: passes 1 else: passes 0 sv_idx self.alphas 1e-5 self.sv X[sv_idx] self.sv_y y[sv_idx] self.sv_alpha self.alphas[sv_idx] self.w np.sum((self.alphas * self.y)[:, None] * X, axis0) return self def decision_function(self, x): if hasattr(self, w): return self.w.dot(x) self.b return self.b def predict(self, X): if X.ndim 1: return np.sign(self.decision_function(X)) return np.sign(np.array([self.decision_function(x) for x in X]))这里max_passes控制结束条件连续多轮没有任何 α 被更新就停止避免死循环。代价是收敛精度略低实际任务可以把 max_passes 调到 50 或 100。初始 E 数组设成-y.copy()因为初始时 w 为零向量、b 为零f(x)0所以误差 f(x_i)-y_i 就是 -y_i。3.2 KKT 检查与第二个 α 的启发式选择第二个 α 的选取直接影响收敛速度。SMO 的经典启发是找与 E_i 差距最大的 E_j因为两变量子问题的目标函数下降量近似正比于 |E_i - E_j|。如果选出的 j 更新量太小就在非边界样本 0 α_j C 里随机找一个。def _violate_kkt(self, i): y_i, E_i self.y[i], self.E[i] alpha_i self.alphas[i] r_i y_i * E_i # 等价于 y_i * f(x_i) - 1 的偏移量 if (alpha_i 1e-5 and r_i -self.tol) or \ (alpha_i 1e-5 and alpha_i self.C and abs(r_i) self.tol) or \ (alpha_i self.C and r_i self.tol): return True return False def _select_second(self, i): max_gap 0.0 j -1 for k in range(self.X.shape[0]): if k i: continue gap abs(self.E[i] - self.E[k]) if gap max_gap: max_gap gap j k if j -1: # 防御性写法理论上不会发生 j (i 1) % self.X.shape[0] return jKKT 违反判断分三档α 在下界附近却仍想让间隔更小α 在区间内部但误差超过容差α 在上界却仍想让间隔更大。这三档分别对应 α 需要增大、调整、减小的情形。把 r_i 的正负方向搞明白调试时能省下很多时间。3.3 两个 α 的更新公式L、H 剪辑和 b 的刷新这是 SMO 最核心的一段也是我最容易写错的地方。更新 α_j 未剪辑时用到核内积 K_ii、K_jj、K_ij剪辑上下界由两个样本的标签决定。更新后 α_i 沿约束反向同步更新。最后用新 α 计算 b并更新 E 缓存否则后续 KKT 判断会带着陈旧误差继续跑。def _update_pair(self, i, j): X, y, alphas self.X, self.y, self.alphas C, tol self.C, self.tol x_i, x_j X[i], X[j] y_i, y_j y[i], y[j] E_i, E_j self.E[i], self.E[j] alpha_i_old, alpha_j_old alphas[i], alphas[j] k_ii x_i.dot(x_i) k_jj x_j.dot(x_j) k_ij x_i.dot(x_j) if y_i ! y_j: L max(0.0, alpha_j_old - alpha_i_old) H min(C, C alpha_j_old - alpha_i_old) else: L max(0.0, alpha_j_old alpha_i_old - C) H min(C, alpha_j_old alpha_i_old) if L H: return 0 eta k_ii k_jj - 2.0 * k_ij if eta 0: # 线性核下理论上不会出现防御处理 return 0 alpha_j_new alpha_j_old y_j * (E_i - E_j) / eta alpha_j_new max(L, min(H, alpha_j_new)) if abs(alpha_j_new - alpha_j_old) 1e-8: return 0 alpha_i_new alpha_i_old y_i * y_j * (alpha_j_old - alpha_j_new) b1 self.b - E_i - y_i * (alpha_i_new - alpha_i_old) * k_ii \ - y_j * (alpha_j_new - alpha_j_old) * k_ij b2 self.b - E_j - y_i * (alpha_i_new - alpha_i_old) * k_ij \ - y_j * (alpha_j_new - alpha_j_old) * k_jj if 0 alpha_i_new C: self.b b1 elif 0 alpha_j_new C: self.b b2 else: self.b (b1 b2) / 2.0 alphas[i], alphas[j] alpha_i_new, alpha_j_new self.E[i] self.decision_function(x_i) - y_i self.E[j] self.decision_function(x_j) - y_j return 1代码里的两个细节值得强调。eta 是二次项系数在核函数正定时为正如果出现非正说明核矩阵近似奇异或数值溢出直接放弃这对样本。b 的取值规则是如果 α_i 或 α_j 落在盒子内部对应样本就在间隔边界上等式 y_i f(x_i) 1 成立用它求出的 b 更准两个都在边界上时取平均。提示实际调优时如果发现不收敛先检查E数组有没有在每次更新后同步刷新这个缓存错误是手写 SMO 最常见的翻车点。3.4 在二维数据上验证收敛三个检查点训练结束别急着接业务数据先在手工构造的二维线性可分数据上验证。检查点有三个支持向量是否落在间隔边界附近α 是否集中在少量样本上预测准确率是否接近 100%。rng np.random.default_rng(42) X np.vstack([ rng.normal([2.0, 2.0], 0.6, size(50, 2)), rng.normal([-2.0, -2.0], 0.6, size(50, 2)), ]) y np.concatenate([np.ones(50), -np.ones(50)]) model LinearSVM(C1.0, max_passes100) model.fit(X, y) sv_mask model.alphas 1e-5 print(支持向量数量:, sv_mask.sum()) print(alpha 范围:, model.alphas[sv_mask].min(), model.alphas[sv_mask].max()) print(准确率:, (model.predict(X) y).mean())输出里支持向量数量应该远小于样本数α 分布在 0 到 C 之间准确率在 1.0 附近。如果准确率上不去先查 E 缓存是否同步更新如果支持向量数量接近全部样本大概率是 C 太小或者数据本身重叠严重。这套手动实现性能当然不如 scikit-learn 的 libsvm但用在课程实验和机制验证上完全够。4. 用PyTorch实现核SVM手写数字分类中核函数与参数的影响纯手写 SMO 引入核函数时要把所有内积换成 K(x_i,x_j)KKT 检查里的 f(x) 变成 Σ α_j y_j K(x_j,x) b。代码改动不大但每次预测都要全量遍历支持向量。这一章换一套更贴近深度学习的实现路线把对偶问题当作带约束的无约束优化用 PyTorch 自动微分求解。4.1 把对偶问题改写成可以被自动微分优化的形式对偶目标函数关于 α 是可微的二次型。约束 Σ α_i y_i 0 和 0 ≤ α_i ≤ C 处理起来有点绕一个常见做法是参数替换令 α_i C · sigmoid(u_i)让盒子约束自然满足等式约束作为惩罚项的平方放进损失。import torch def rbf_kernel(X, gamma0.05): # X: (n, d) 浮点张量 sq torch.cdist(X, X, p2) ** 2 return torch.exp(-gamma * sq) def svm_objective(Ky, alpha, y, eq_weight100.0): # Ky[i,j] y_i * y_j * K(i,j)在外部预计算 quad 0.5 * (alpha.view(-1) Ky alpha.view(-1)) linear -alpha.sum() eq_penalty eq_weight * (alpha * y).sum() ** 2 return quad linear eq_penalty这里的 eq_weight 是等式约束惩罚系数取 100 到 1000 之间太小约束没约束住太大数值恶化。用 sigmoid 参数化后最优 α 一定落在 [0, C] 区间内惩罚项把分布约束到 y 方向加权和为 0 附近。代价是最终收敛的是近似 KKT 点不是精确解。我的用途是拿它对比不同核函数和参数的相对表现确定参数后再拿 sklearn 训练最终模型。4.2 完整训练循环在 digits 上跑通下面脚本直接使用 sklearn 内置的 digits 数据集做手写数字二分类。实际工程中也可以用 optdigits 完整数据集继续做特征按像素展开成向量标签挑两类构造成二分类即可。from sklearn.datasets import load_digits from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler data load_digits() # 挑两个最易混淆的类别4 和 9 mask (data.target 4) | (data.target 9) X, y data.data[mask], data.target[mask] y torch.tensor((y 9).astype(float) * 2 - 1, dtypetorch.float32) X_train, X_test, y_train, y_test train_test_split( X, y.numpy(), test_size0.3, random_state42 ) scaler StandardScaler() X_train torch.tensor(scaler.fit_transform(X_train), dtypetorch.float32) X_test torch.tensor(scaler.transform(X_test), dtypetorch.float32) y_train torch.tensor(y_train, dtypetorch.float32) y_test torch.tensor(y_test, dtypetorch.float32) K_tr rbf_kernel(X_train, gamma0.05) Ky y_train[:, None] * y_train[None, :] * K_tr C 1.0 u torch.zeros(X_train.shape[0], requires_gradTrue) optimizer torch.optim.Adam([u], lr0.05) for epoch in range(1000): alpha C * torch.sigmoid(u) loss svm_objective(Ky, alpha, y_train) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 200 0: print(fepoch {epoch}, loss {loss.item():.3f}) with torch.no_grad(): alpha C * torch.sigmoid(u) W alpha * y_train score_tr (W[:, None] * K_tr).sum(0) K_test rbf_kernel(X_test, X_train) score_te K_test W b (y_train - score_tr).mean().item() acc ((score_te b 0).float() (y_test 0)).float().mean().item() print(ftest acc: {acc:.3f})学习率 0.05 和 epoch 1000 是常见起始点特征标准化后核矩阵的条件数比较健康这套设置一般能平稳收敛。预计算核矩阵而不是每个 batch 重算是核方法实现里的基本优化。改 gamma 时只要重算 K_tr 和 K_test 即可。提示这里的边界估计 b 用的是所有样本的平均偏差更精细的做法是只用 0 α C 的自由支持向量来算。4.3 核函数与参数的影响gamma 大容易过拟合在手写数字这种中等复杂度数据上RBF 核的 gamma 是最敏感的参数。gamma 小核函数衰减慢模型偏向线性gamma 大每个样本只影响近邻决策边界局部剧烈变化训练集准确率很高、测试集掉下去。下面是 4 vs 9 数据跑出来的规律gamma 取值训练准确率测试准确率支持向量占比现象0.0050.970.97约 60%边界接近线性个别点拖累0.051.000.98约 45%边界光滑测试表现最好0.51.000.95约 20%局部孤岛形成测试下降这里的支持向量占比是 α_i 1e-4 的样本比例不是 sklearn 的精确统计只是调试脚本里的参考值。gamma 到 0.5 时决策边界被近邻样本拽得局部弯曲支持向量集中在类别交界的极少量点上这是过拟合的典型信号。4.4 为什么不直接用 sklearn两种实现的分工用 PyTorch 做核 SVM 实验的目的不是替代 sklearn而是拿到梯度信息。核矩阵的每一行可以看成特征空间里的一个样本表示梯度能告诉你哪些 α 还在被优化、哪些已经落到边界这在 sklearn 的黑匣子模型里看不到。需要快速上线时我依旧会切回 sklearn.svm.SVC把验证好的核函数和 gamma 传进去工程可靠性和效率都有保障。这个流程比直接盲调 sklearn 更可控因为你对数据在核空间里的几何行为已经有了直觉。5. 参数与实现避坑五个反复让人翻车的细节这一章只写调试中反复踩过的坑每条按「现象、原因、解决」的顺序都对应上面的代码。5.1 不做标准化的后果RBF 核矩阵变成一条向量现象用 digits 原始像素值跑 RBF测试准确率很差看核矩阵发现某一行全是接近 1 的值行与行的差异被某个取值范围大的特征垄断。原因RBF 里的欧氏距离对不同特征的量纲一视同仁灰度值范围大的特征主导了距离小范围特征的信息被稀释。这是核方法里最基础的坑。解决训练前必须对每个特征做标准化常见做法是 StandardScaler 在训练集上拟合再对验证集和测试集做同样的变换。缩放器只允许 fit 训练数据否则会泄漏验证集信息。5.2 SMO 里 E 缓存没更新导致收敛判断失真现象手动实现的 SMO 跑到某个轮次后准确率不再提升但 KKT 检查仍显示大量样本违反训练被拖得很慢。原因_update_pair只更新了 α_i 和 α_j但 E 数组里其他位置的误差来自旧的 α全靠外层循环遍历时逐个刷新。如果外层循环提前结束整个结果建立在旧误差上。解决在每次更新完一对 α 后把两个位置的 E 单独重算更稳一点的做法是每轮外层循环开始前全量重算 E。我的习惯是调试阶段全量重算调通后再改成增量式。5.3 用 OvR 做多分类但忘记调整分值时现象在 10 类手写数字上用 10 个二分类器做 OvR取每个类别最高分作为预测类别结果类别 4 和 9 互相污染严重。原因OvR 的每个二分类器训练样本不平衡正类 1 类负类 9 类SVM 对类别不平衡敏感决策边界偏向样本少的一侧。取分值最大时某些二分类器自带偏置直接覆盖其他类别。解决两个方向。一是每个二分类器独立标准化分值常见做法是把决策函数输出除以训练时的间隔宽度做缩放二是直接用 sklearn 的 decision_function_shapeovo一对一投票在类别不平衡下更稳。小样本实验用 OvO类别数很多时再用 OvR 配合标准化。5.4 降低 C 是「宽容」而不是「放弃」现象把 C 调到 0.001 想防过拟合结果训练准确率掉到 80%支持向量占比接近 100%。原因C 太小意味着每个误分的惩罚都很轻模型宁愿把间隔拉得很大也不肯错分少数点实际上是在丢弃大部分信息而不是宽容细节。解决C 从 1 开始网格搜索观察支持向量占比。占比高于 80% 说明 C 过小低于 20% 且验证分数下降说明 C 过大。合理区间通常落在 0.1 到 10配合 gamma 画验证曲线后再定。5.5 非线性核的「好分数」可能只是验证集调参过头现象换几组 gamma 和 C验证集分数越来越高最后发现测试集分数远低于验证集。原因网格搜索在同一份验证集上反复试验证集不再「未知」本质上是在验证集上过拟合。这种「调参数据泄漏」比模型本身的过拟合更隐蔽。解决数据量够时拆三份训练、验证、测试。参数搜索在验证集上进行最后拿测试集一次性评估。小数据集上做嵌套交叉验证更稳外层评估才代表真实泛化。6. 把SVM的间隔思想搬进神经网络合页损失与分数校准项目最终转向神经网络时SVM 的间隔最大化思想可以换一副面孔出现hinge loss。神经网络的输出层换成合页损失配合 L2 正则与 SVM 的软间隔目标异曲同工。更实际的价值是提供一个分数校准技巧——神经网络输出的 logit 不是置信度分布与间隔没有自然关联而合页损失可以让正负样本的输出与 0 之间拉开一个可解释的距离。import torch.nn as nn class HingeSVMHead(nn.Module): def __init__(self, feat_dim, margin1.0, C1.0): super().__init__() self.fc nn.Linear(feat_dim, 1) self.margin margin # 间隔宽度 self.C C # 相当于代价权重 def forward(self, feat, label): # label 取值为 1 / -1 logit self.fc(feat).squeeze(1) loss self.C * torch.clamp(self.margin - label * logit, min0).mean() loss loss 0.5 * self.fc.weight.pow(2).sum() return loss, logit这个头的价值在最后一层特征固定的场景里。训练好的骨干网络输出高维特征在特征上层加合页头微调可以让输出的符号离 0 更远。验证时看两个统计量正负两类 logit 的中位数间隔是否大于 margin以及分错样本的 logit 是否都在 0 的另一侧。我现在做分类任务的习惯是保留一条用特征加合页头评估模型的备用路线这让调试多一个可解释的抓手。希望这个技巧和前面从零实现的弯路能帮你在 SVM 这条路上少折腾几个通宵。本文还有配套的精品资源点击获取 SEO 优化官网定制响应式建站教育培训建站