☰
从零实现SVM:Python源码拆解SMO与核技巧
2026/10/21 21:08:52 网站建设 项目流程

简介:这份资源是支持向量机(SVM)的Python实现代码包,面向正在学习机器学习算法、希望从零理解SVM原理并动手实践的学生与开发者。包内共6个文件,以3个Python源码文件为核心,分别承担SVM主体算法实现、测试脚本与辅助处理功能,另附1个编译缓存文件、1份Markdown说明文档和1份txt格式的数据集,整体压缩包约9KB,体量轻巧,便于快速阅读与本地运行。资源围绕SVM分类器的训练与测试展开,读者可借助源码理清核函数、超平面求解与样本分类的代码逻辑,配合测试数据验证模型效果,并通过说明文档了解项目结构与使用方式。目前已有1942人学习下载,适合作为机器学习入门阶段对照理论、调试代码的实践素材。

1. 从一份手写 SVM 源码说起:为什么我还要看 Python 实现

很多人第一次接触支持向量机,都是在某门课上被拉格朗日乘子和对偶问题绕得云里雾里,最后靠调包sklearn.svm.SVC蒙混过关。但真到了要改核函数、要看支持向量到底长什么样、要排查为什么模型在小样本上表现玄学的时候,黑箱就不好使了。这份 SVM 的 Python 实现,就是拿来拆黑箱的:它用纯 Python 加 NumPy 把最大间隔分类器、软间隔、核技巧这几件事从头写了一遍,适合已经会用 sklearn 但想搞懂内部机理的开发者,也适合拿它当二次开发的底座,比如换核函数、加多分类策略、接自己的数据管道。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序,把这份源码拆开讲。

2. 项目定位与技术选型:它到底实现了哪些 SVM 能力

2.1 从功能边界看这份实现覆盖了什么

拿到一份源码,我习惯先看它到底实现了哪些能力,而不是急着跑。这份 SVM 实现的核心功能可以归成四块:线性可分情况下的硬间隔分类、带松弛变量的软间隔分类、核函数支持(至少覆盖线性核和 RBF 核)、以及基于 SMO 的求解过程。换句话说,它不是一个只会跑鸢尾花数据集的玩具,而是一个把 SVM 从原始问题到对偶问题再到数值求解这条链路走通的完整实现。

它没有做的事也很明确:没有做多分类的 one-vs-one 或 one-vs-rest 封装,没有做概率输出(Platt scaling),也没有做大规模数据的分布式求解。这些恰恰是二次开发最自然的切入点。理解了这个边界,你就知道这份代码适合拿来学原理和做中小规模实验,不适合直接上生产环境跑百万级样本。

从技术选型上看,它只依赖 NumPy 做矩阵运算,没有引入 scipy.optimize 之类的现成优化器,说明作者是刻意要把 SMO 的迭代逻辑暴露出来。这个选择对学习者非常友好,因为你能看到每一次乘子更新、每一次误差缓存是怎么发生的。常见做法是用 scipy 的 minimize 直接解对偶问题,代码会短很多,但 SMO 的工程细节就全被藏起来了。

2.2 目录结构与模块职责

一份结构清晰的 SVM 实现,目录通常长这样:

svm/ ├── svm.py # 核心类,fit/predict 入口 ├── kernel.py # 核函数实现与缓存 ├── smo.py # SMO 求解器 ├── utils.py # 数据加载、标准化、切分 └── examples/ └── demo.py # 最小可运行示例

svm.py里定义主类,对外暴露fit(X, y)和predict(X),内部持有alphas、b、支持向量索引这些状态。kernel.py负责把原始特征映射成核矩阵,RBF 核里那个 gamma 参数就在这里生效。smo.py是整份代码最值得读的部分,它实现了外层循环选第一个乘子、内层循环选第二个乘子的启发式策略。utils.py做数据预处理,因为 SVM 对特征尺度非常敏感,标准化几乎是必须的。

我一般会先读svm.py的fit方法,看它怎么调用 SMO,再看smo.py的主循环,最后回头看kernel.py。这个顺序能让你先建立整体数据流,再钻细节,不至于一上来就陷进公式里。

2.3 关键参数与它们对结果的影响

在动手跑之前,有几个参数必须先搞清楚,否则调出来的结果会让你怀疑人生:

参数含义典型取值影响
C惩罚系数0.1 ~ 100越大越不容忍误分类,容易过拟合
kernel核类型linear / rbf决定决策边界形状
gammaRBF 核宽度0.001 ~ 10越大边界越复杂,容易过拟合
tol收敛容差1e-3越小迭代越久,结果越精确
max_iter最大迭代100 ~ 1000防止死循环

C 和 gamma 是 SVM 里最玄学的两个参数,网格搜索基本是标配。tol 和 max_iter 则决定了你等结果要等多久。我一般先把 tol 设成 1e-3 跑通流程,确认逻辑没问题后再收紧到 1e-4 看收敛曲线。

3. 把项目跑起来:环境、数据与最小可运行示例

3.1 环境准备与依赖安装

这份实现依赖很轻,Python 3.8 以上加 NumPy 就够了。如果你还想画决策边界,再装个 matplotlib。我一般用虚拟环境隔离,避免和系统里的包打架:

python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install numpy matplotlib

装完之后先别急着跑 demo,用python -c "import numpy; print(numpy.__version__)"确认 NumPy 能正常导入。这一步看着多余,但我踩过坑:某些环境下 NumPy 装是装了,但 BLAS 库没链接好,矩阵运算会报奇怪的错,提前确认能省很多排查时间。

3.2 数据准备与预处理

SVM 对特征尺度敏感,这是血泪经验。如果一列特征范围是 0 到 1,另一列是 0 到 10000,那第二列会主导整个距离计算,模型基本学不到第一列的信息。所以标准化是跑之前必须做的一步:

import numpy as np def standardize(X): # 按列计算均值和标准差 mu = X.mean(axis=0) sigma = X.std(axis=0) # 防止除零,标准差为 0 的列保持原样 sigma[sigma == 0] = 1.0 return (X - mu) / sigma # 假设 X 是 (n_samples, n_features) 的数组 X = np.array([[1.0, 200.0], [2.0, 300.0], [3.0, 150.0]]) X_std = standardize(X) print(X_std)

这段代码按列做 z-score 标准化,mu和sigma都是长度为特征数的向量。注意sigma[sigma == 0] = 1.0这行,如果某列所有值相同,标准差是 0,直接除会得到 nan,把它设成 1 就安全了。标准化参数必须从训练集算出来,然后应用到测试集,不能各算各的,否则就是数据泄露。

3.3 训练与预测的最小示例

环境好了、数据也标准化了,接下来就是最小可运行示例。我一般用两类线性可分的数据先验证逻辑:

import numpy as np from svm import SVM # 构造一个简单的二分类数据集 X = np.array([[1, 2], [2, 3], [3, 3], [2, 1], [3, 2], [6, 7], [7, 8], [8, 8], [7, 6], [8, 7]]) y = np.array([-1, -1, -1, -1, -1, 1, 1, 1, 1, 1]) # 初始化 SVM,先用线性核 model = SVM(C=1.0, kernel='linear', tol=1e-3, max_iter=100) model.fit(X, y) # 预测 preds = model.predict(X) acc = (preds == y).mean() print(f"训练集准确率: {acc:.4f}") print(f"支持向量个数: {len(model.support_vectors)}")

这段代码里,SVM(C=1.0, kernel='linear')构造了一个软间隔线性分类器,fit内部会调用 SMO 求解alphas和b。predict用决策函数sign(w·x + b)输出标签。跑通之后你会看到准确率是 1.0,支持向量个数大概是 2 到 4 个,这符合线性可分数据的预期。

如果换成kernel='rbf'和gamma=0.5,再跑一遍,你会发现支持向量个数变多,决策边界也更弯曲。这就是核技巧的作用:把数据映射到高维空间,让原本线性不可分的数据变得可分。

提示:第一次跑如果准确率很低,先检查标签是不是 -1 和 1,而不是 0 和 1。很多 SVM 实现内部用 sign 函数,标签必须是 ±1。

4. 核心模块拆解:SMO 求解器与核函数怎么写的

4.1 SMO 主循环:两个乘子怎么选、怎么更新

SMO 是这份代码的心脏。它的思路是每次只优化两个拉格朗日乘子,把其余固定,这样对偶问题就退化成一个可以解析求解的二元问题。外层循环选第一个违反 KKT 条件最严重的乘子,内层循环选让目标函数下降最快的第二个乘子:

def smo_loop(X, y, C, tol, max_iter, kernel): n = X.shape[0] alphas = np.zeros(n) b = 0.0 # 预计算核矩阵,避免重复计算 K = kernel(X, X) for it in range(max_iter): num_changed = 0 for i in range(n): # 计算第 i 个样本的误差 Ei = np.dot(alphas * y, K[:, i]) + b - y[i] # 检查是否违反 KKT 条件 if (y[i] * Ei < -tol and alphas[i] < C) or \ (y[i] * Ei > tol and alphas[i] > 0): # 内层循环:选第二个乘子 j j = select_j(i, n) Ej = np.dot(alphas * y, K[:, j]) + b - y[j] # 保存旧值 alpha_i_old, alpha_j_old = alphas[i], alphas[j] # 计算上下界 L 和 H if y[i] != y[j]: L = max(0, alphas[j] - alphas[i]) H = min(C, C + alphas[j] - alphas[i]) else: L = max(0, alphas[i] + alphas[j] - C) H = min(C, alphas[i] + alphas[j]) if L == H: continue # 计算 eta,即二阶导数 eta = 2 * K[i, j] - K[i, i] - K[j, j] if eta >= 0: continue # 更新 alpha_j alphas[j] = alpha_j_old - y[j] * (Ei - Ej) / eta alphas[j] = np.clip(alphas[j], L, H) if abs(alphas[j] - alpha_j_old) < 1e-5: continue # 更新 alpha_i alphas[i] = alpha_i_old + y[i] * y[j] * (alpha_j_old - alphas[j]) # 更新 b b1 = b - Ei - y[i] * (alphas[i] - alpha_i_old) * K[i, i] \ - y[j] * (alphas[j] - alpha_j_old) * K[i, j] b2 = b - Ej - y[i] * (alphas[i] - alpha_i_old) * K[i, j] \ - y[j] * (alphas[j] - alpha_j_old) * K[j, j] if 0 < alphas[i] < C: b = b1 elif 0 < alphas[j] < C: b = b2 else: b = (b1 + b2) / 2 num_changed += 1 # 如果一轮下来没有任何乘子被更新,说明收敛 if num_changed == 0: break return alphas, b

这段代码有几个关键点。Ei是第 i 个样本的预测误差,y[i] * Ei用来判断是否违反 KKT 条件:如果y[i]*Ei < -tol且alphas[i] < C,说明这个样本被正确分类但还有优化空间;如果y[i]*Ei > tol且alphas[i] > 0,说明它被错误分类或者落在间隔内。eta是目标函数沿更新方向的二阶导数,如果eta >= 0说明这个方向不是下降方向,直接跳过。np.clip把alpha_j限制在[L, H]内,保证满足约束。b 的更新分三种情况:如果alpha_i在 (0, C) 内,用 b1;如果alpha_j在 (0, C) 内,用 b2;否则取平均。

select_j是内层循环的启发式,常见做法是选误差差最大的那个,也就是max(|Ei - Ej|)。这份实现里我一般会加一个随机兜底,避免在误差相等时死循环。

4.2 核函数实现:线性核与 RBF 核的矩阵化写法

核函数决定了 SVM 能处理多复杂的边界。线性核就是内积,RBF 核是高斯函数。矩阵化写法能避免 Python 循环,速度差几十倍:

def linear_kernel(X, Y): # 线性核:直接内积 return np.dot(X, Y.T) def rbf_kernel(X, Y, gamma=0.5): # RBF 核:exp(-gamma * ||x - y||^2) # 用 (a-b)^2 = a^2 + b^2 - 2ab 展开,避免逐样本循环 X_norm = np.sum(X ** 2, axis=1).reshape(-1, 1) Y_norm = np.sum(Y ** 2, axis=1).reshape(1, -1) sq_dist = X_norm + Y_norm - 2 * np.dot(X, Y.T) # 数值误差可能让 sq_dist 出现极小负值,截断到 0 sq_dist = np.maximum(sq_dist, 0) return np.exp(-gamma * sq_dist)

linear_kernel直接返回X和Y的内积矩阵,形状是(n_X, n_Y)。rbf_kernel用平方展开把逐样本的距离计算变成矩阵运算,X_norm是每个样本的模平方,Y_norm同理,np.dot(X, Y.T)是交叉项。np.maximum(sq_dist, 0)这行很重要,浮点误差可能让理论上为 0 的距离变成 -1e-12,开根号或取 exp 前必须截断,否则会得到 nan。

gamma 参数控制核的宽度:gamma 越大,单个样本的影响范围越小,边界越复杂;gamma 越小,边界越平滑。我一般从1 / n_features开始试,再按倍数上下搜索。

4.3 预测阶段:支持向量与决策函数

训练完之后,真正参与预测的只有支持向量,也就是alpha > 0的那些样本。预测时计算新样本和支持向量的核函数值,加权求和再加 b:

def predict(self, X): # 找出支持向量 sv_idx = self.alphas > 1e-5 sv = self.X_train[sv_idx] sv_y = self.y_train[sv_idx] sv_alpha = self.alphas[sv_idx] # 计算核矩阵 K = self.kernel(X, sv) # 决策函数:sum(alpha_i * y_i * K(x, sv_i)) + b decision = np.dot(K, sv_alpha * sv_y) + self.b return np.sign(decision)

sv_idx用alphas > 1e-5而不是> 0,是为了过滤掉数值误差产生的极小值。decision是每个样本的决策值,正数判为正类,负数判为负类,np.sign直接输出 ±1。这里没有做概率输出,如果你需要概率,得额外做 Platt scaling,那是二次开发的一个方向。

注意:预测时用的核函数必须和训练时完全一致,包括 gamma 参数。我见过有人训练用 RBF、预测用线性核,结果准确率掉到 0.5,排查了半天才发现是配置没对齐。

5. 避坑与排查:跑 SVM 时最容易翻车的几个地方

5.1 准确率一直是 0.5 左右

现象:模型训练完了,预测结果和随机猜差不多。原因通常是标签格式不对。很多 SVM 实现内部用sign函数,要求标签是 -1 和 1,如果你传的是 0 和 1,sign(0)会得到 0,整个决策就乱了。解决办法是在fit入口加一行校验,把 0 映射成 -1,或者直接抛异常提示用户。我一般会在utils.py里写个check_labels函数,统一处理。

5.2 迭代不收敛,跑了几万轮还在循环

现象:max_iter设了 1000,跑完还没收敛,支持向量个数异常多。原因多半是 C 设得太大或者 gamma 太大,导致优化问题病态。解决办法是先固定 C=1.0、gamma=0.1 跑一遍,确认能收敛后再逐步放大。另外 tol 别设太小,1e-3 通常够用,1e-6 会让迭代次数爆炸。如果数据本身有重叠,软间隔的 C 也不能太大,否则就是在逼模型硬分。

5.3 核矩阵计算爆内存

现象:样本量到几千,rbf_kernel一算就 MemoryError。原因是核矩阵是(n, n)的,n=10000 就是 800MB 的 float64。解决办法是小规模用完整核矩阵,大规模改用分块计算或者用sklearn.metrics.pairwise.rbf_kernel的chunk_size参数。这份实现面向教学,没做分块,二次开发时如果数据量大,这是必须补的一环。

5.4 标准化参数在训练集和测试集上不一致

现象:训练集准确率 0.95,测试集 0.6。原因是你对训练集和测试集分别做了标准化,两者的均值和标准差不同,导致特征空间错位。解决办法是把训练集的mu和sigma存下来,测试集直接用这两个值做变换。我一般把标准化封装成一个类,fit存参数,transform用参数,避免手滑。

5.5 支持向量个数等于样本总数

现象:训练完发现所有样本的alpha都大于 0。原因通常是 C 太大或者 gamma 太大,模型把每个样本都当成了支持向量。这在 RBF 核上尤其常见。解决办法是降低 C 和 gamma,让模型有容错空间。如果数据本身噪声大,硬间隔本来就不合适,软间隔的 C 要调小。

6. 二次开发与改造:从能跑到好用要补的几件事

6.1 加多分类支持

这份实现是二分类的,要扩展到多分类,最直接的是 one-vs-rest:对每个类别训练一个二分类器,预测时取决策值最大的那个。改造点在svm.py外面包一层MultiSVM类,内部维护一个分类器列表。训练时把当前类标为 1,其余标为 -1,预测时收集所有分类器的decision_function输出,取 argmax。这个改造大概几十行代码,但要注意类别不平衡时给每个分类器加权。

6.2 换核函数与自定义核

kernel.py里的核函数是硬编码的,要加新核(比如多项式核、sigmoid 核),最干净的做法是把核函数做成可传入的参数。SVM.__init__接收一个kernel_fn,默认用 RBF,用户也可以传自己的函数。自定义核必须满足 Mercer 条件,也就是核矩阵半正定,否则 SMO 可能不收敛。我一般会加一个check_kernel函数,随机采样几个点算核矩阵的特征值,有负值就警告。

6.3 加早停与收敛曲线

现在的smo_loop只在num_changed == 0时停,没有中间状态输出。二次开发时我建议加一个history列表,每轮记录num_changed和目标函数值,跑完画出来。这样你能直观看到收敛过程,调参时也有依据。目标函数就是对偶问题的值:sum(alphas) - 0.5 * sum(alphas_i * alphas_j * y_i * y_j * K_ij),每轮算一次,开销不大。

6.4 用 sklearn 做交叉验证对比

改完之后怎么验证对不对?最省事的办法是和 sklearn 的SVC对比。同一份数据、同样的 C 和 gamma,两边的支持向量个数和准确率应该接近。如果差很多,说明你的实现有问题。我一般会写个脚本,跑 5 折交叉验证,把两边的准确率打出来对比。注意 sklearn 的gamma默认是1 / n_features,而这份实现默认是 0.5,对比时要显式对齐。

6.5 一个我常犯的错误

早期我改核函数的时候,直接在rbf_kernel里把 gamma 写死了,结果调参时怎么改SVM(gamma=...)都不生效,排查了半天才发现是核函数没接收这个参数。从那以后我每次加新核函数,都强制走一遍「传参 → 打印 → 对比」的流程,确认参数真的透传到了计算层。希望这个习惯能帮到你,少走点我走过的弯路。

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

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

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

立即咨询