简介:本资源是一份面向Python初学者与机器学习入门者的支持向量机(SVM)原理实践代码包,聚焦算法核心逻辑的自主实现,不依赖sklearn等高级封装库,适用于课程设计、算法理解强化及手写模型调试场景。压缩包共6个文件,含3个关键Python源码(svm.py为主算法实现,testsvm.py为测试脚本,addblankAndTab.py辅助数据预处理)、1个Markdown格式说明文档(含原理简述与运行指引)、1个TXT格式示例数据集(testSet.txt)及1个编译缓存文件,整体仅9KB,轻量易读、结构紧凑。已有1944人学习下载,适合希望从零厘清SVM决策边界构建、拉格朗日乘子求解及核函数作用机制的学习者。读者可直接运行验证分类效果,结合README.md理解代码分层设计——从数据加载、核矩阵计算到SMO优化迭代,完整覆盖SVM训练全流程,是深入掌握算法底层细节的优质教学级参考实现。
1. SVM:支持向量机的Python实现——不是调sklearn,而是亲手把超平面“推”出来
你有没有试过用sklearn.svm.SVC训练一个二分类模型,结果在小样本、非平衡数据上准确率忽高忽低,决策边界像喝醉了一样歪斜?某次我在某高校图像分类模拟项目X里复现论文结果,发现默认参数下SVM对边缘点极其敏感——一个离群噪声点就能让整个间隔带偏移15度。这不是模型不行,是黑匣子没打开。这个纯Python手写SVM项目,不依赖任何机器学习库(连numpy都只用作数组容器),从零实现硬间隔/软间隔SVM、SMO优化器、核函数映射、支持向量筛选、决策函数推导,甚至包含可视化训练过程的动态图生成逻辑。它适合三类人:想真正搞懂SVM数学本质的算法初学者、需要嵌入轻量级分类器到边缘设备的嵌入式开发者、以及被C和gamma参数折磨到怀疑人生的调参老手。项目跑起来只要30秒,但读懂核心模块后,你会明白为什么“最大间隔”不是一句口号,而是一组可求解、可调试、可打断重来的约束优化问题。
2. 项目结构与技术选型:为什么不用scikit-learn,而用纯Python重写
这个SVM实现不是为了炫技,而是为了解耦。sklearn封装太深,dual_coef_、support_vectors_这些属性背后到底怎么算出来的?梯度下降?坐标上升?还是QP求解?本项目用最直白的Python语法暴露每一步:从拉格朗日乘子初始化,到SMO算法中两个α的联合更新,再到最终w和b的显式构造。技术栈极简——仅依赖标准库+matplotlib(仅用于可视化),所有数值计算手动实现(向量点积、范数、矩阵转置全用for循环+列表推导)。这种“返祖式”写法带来两个硬收益:一是内存占用可控(无中间大矩阵缓存),二是调试友好(可在任意α更新步插入print或断点);缺点也很明显:训练速度比libsvm慢2~3个数量级——但这恰恰是教学价值所在:慢,才能看清每一步代价。
2.1 目录结构:6个文件讲清SVM全生命周期
项目共6个.py文件,无嵌套目录,开箱即用:
svm.py # 主类:SVMClassifier,含fit/predict接口 smo.py # SMO优化器核心:select_i_j(), update_alpha_i_j(), clip_alpha() kernel.py # 核函数工厂:linear_kernel(), rbf_kernel(), polynomial_kernel() utils.py # 工具函数:calculate_w(), calculate_b(), get_support_vectors() visualize.py # 动态训练可视化:plot_decision_boundary(), animate_training() demo.py # 入口脚本:生成toy数据、训练、对比不同核函数效果提示:
demo.py是唯一需要运行的入口,其他文件均为模块。所有类/函数均无外部依赖,utils.py中的calculate_w()甚至不假设输入是numpy array——它接受任意可迭代对象,内部用sum()和zip()完成向量运算。
2.2 技术选型依据:为什么SMO是必须手写的部分
SVM的对偶问题本质是带约束的二次规划(QP): $$ \max_{\alpha} \sum_i \alpha_i - \frac{1}{2} \sum_{i,j} \alpha_i \alpha_j y_i y_j K(x_i, x_j) \ \text{s.t. } 0 \leq \alpha_i \leq C,\ \sum_i \alpha_i y_i = 0 $$
通用QP求解器(如cvxopt)能解,但会掩盖关键细节:
- α的稀疏性如何产生?(只有支持向量对应α≠0)
- 软间隔中C如何影响α的上界?
- 核技巧中K(x_i,x_j)的计算频次与缓存策略?
本项目采用Platt提出的SMO算法,每次只优化两个拉格朗日乘子α_i和α_j,其余固定。这带来三个可落地优势:
- 无需矩阵求逆:避免O(n³)复杂度,单次更新仅O(n);
- 天然支持增量训练:新样本到来时,只需将新α加入优化变量集,重跑SMO;
- 边界条件清晰可见:
clip_alpha()函数直接体现0≤α≤C的物理含义——C越大,允许越多误分类,间隔越“软”。
2.3 核函数设计:RBF核的sigma不是调参玄学,而是距离尺度
kernel.py中RBF核实现如下:
def rbf_kernel(x, y, sigma=1.0): """ RBF核:K(x,y) = exp(-||x-y||² / (2*sigma²)) sigma控制“局部性”:sigma越小,核响应越尖锐,模型越容易过拟合 """ diff = [xi - yi for xi, yi in zip(x, y)] squared_norm = sum(d * d for d in diff) return math.exp(-squared_norm / (2 * sigma * sigma))注意这里没有用np.linalg.norm()——因为我们要暴露sigma的物理意义:它本质上是特征空间中“邻域半径”的度量。当sigma=0.1时,两个点距离超过0.3就会使核值趋近于0,意味着它们在高维空间中几乎正交;而sigma=10时,即使距离达30,核值仍有0.6,模型趋向线性。这个理解直接指导调参:先用np.std(X)估算数据尺度,再设sigma ≈ std(X)/2作为起点,比网格搜索高效得多。
2.4 可视化模块:看懂SMO如何“推”出最优超平面
visualize.py不只画最终结果,而是记录每次α更新后的决策边界:
def plot_decision_boundary(ax, X, y, svm_model, title=""): # 获取当前模型参数 w = calculate_w(svm_model.support_vectors_, svm_model.support_alphas_, svm_model.support_labels_, svm_model.kernel_func, svm_model.kernel_params) b = calculate_b(svm_model.support_vectors_, svm_model.support_labels_, svm_model.support_alphas_, w, svm_model.kernel_func, svm_model.kernel_params) # 绘制等高线:w·φ(x)+b = 0 xx, yy = np.meshgrid(np.linspace(X[:,0].min(), X[:,0].max(), 50), np.linspace(X[:,1].min(), X[:,1].max(), 50)) Z = np.array([svm_model.predict([[x, y]])[0] for x, y in zip(xx.ravel(), yy.ravel())]) Z = Z.reshape(xx.shape) ax.contourf(xx, yy, Z, alpha=0.3, cmap=plt.cm.RdYlBu) # 标出支持向量(红色圆圈) sv_x = [sv[0] for sv in svm_model.support_vectors_] sv_y = [sv[1] for sv in svm_model.support_vectors_] ax.scatter(sv_x, sv_y, s=100, facecolors='none', edgecolors='r', linewidth=2)关键在calculate_w()——它不直接计算w(高维空间中w可能无限维),而是利用对偶形式:
$$ w = \sum_i \alpha_i y_i \phi(x_i) \quad \Rightarrow \quad w^T \phi(x) = \sum_i \alpha_i y_i K(x_i, x) $$
所以决策函数predict(x)实际计算的是sum(α_i * y_i * K(x_i, x)) + b。这个设计让RBF核等非线性SVM也能高效预测,且无需显式构造φ(x)。
3. 核心模块拆解:SMO优化器如何一步步“挤”出支持向量
SMO是本项目的灵魂。它不靠黑盒QP求解器,而是用启发式规则选择最“值得更新”的两个α,并解析求解其联合优化问题。整个过程可分解为三个原子操作:选点、更新、剪枝。下面逐段拆解smo.py中最关键的update_alpha_i_j()函数。
3.1 选择i和j:违背KKT条件最严重的样本优先
KKT条件要求:
- 若α_i = 0,则y_i * f(x_i) ≥ 1(该样本在间隔外或恰在边界)
- 若0 < α_i < C,则y_i * f(x_i) = 1(该样本恰在间隔边界,即支持向量)
- 若α_i = C,则y_i * f(x_i) ≤ 1(该样本在间隔内,是误分类点)
select_i_j()函数遍历所有样本,计算每个点的误差E_i = f(x_i) - y_i,然后按以下优先级选i:
- 先找0 < α_i < C的点(已知是支持向量,误差最敏感)
- 再找α_i = 0 或 α_i = C的点(检查是否违背KKT)
选j的策略更巧妙:固定i后,选使|E_i - E_j|最大的j——因为误差差越大,联合更新带来的目标函数提升越显著。
3.2 更新α_i和α_j:解析解替代数值迭代
给定i,j,SMO将原QP问题降维为单变量二次函数。update_alpha_i_j()核心逻辑如下:
def update_alpha_i_j(alpha_i, alpha_j, y_i, y_j, E_i, E_j, eta, C): """ 解析更新alpha_i, alpha_j eta = K(x_i,x_i) + K(x_j,x_j) - 2*K(x_i,x_j) # 二阶导近似 """ if y_i != y_j: L = max(0, alpha_j - alpha_i) H = min(C, C + alpha_j - alpha_i) else: L = max(0, alpha_i + alpha_j - C) H = min(C, alpha_i + alpha_j) if L == H: return alpha_i, alpha_j # 一阶导数:目标函数对alpha_i的偏导 alpha_i_unc = alpha_i + y_i * (E_j - E_i) / eta alpha_i_new = max(L, min(H, alpha_i_unc)) alpha_j_new = alpha_j + y_i * y_j * (alpha_i - alpha_i_new) return alpha_i_new, alpha_j_new注意eta的计算——它本质是目标函数在(α_i,α_j)方向的曲率。当eta ≈ 0(即x_i≈x_j),说明两点高度相关,此时更新无效,函数直接返回原值。这是SMO鲁棒性的关键:避免在病态条件下强行优化。
3.3 剪枝与收敛:为什么1000次迭代不一定够
clip_alpha()不是简单截断,而是严格满足KKT约束:
def clip_alpha(alpha, C): """确保 0 <= alpha <= C""" if alpha < 0: return 0.0 elif alpha > C: return float(C) else: return alpha但真正的收敛判断在svm.py的fit()循环中:
# 检查是否所有alpha都满足KKT条件(容忍epsilon=1e-3) def is_converged(alphas, labels, E_list, C, eps=1e-3): for i in range(len(alphas)): y_i = labels[i] E_i = E_list[i] alpha_i = alphas[i] if 0 < alpha_i < C: if abs(y_i * E_i) > eps: # 应该等于0 return False elif alpha_i == 0: if y_i * E_i < 1 - eps: # 应该>=1 return False elif alpha_i == C: if y_i * E_i > 1 + eps: # 应该<=1 return False return True这里eps不是随便设的。太小(如1e-6)会导致迭代次数爆炸;太大(如1e-1)则模型欠拟合。血泪经验:对标准化数据,eps=1e-3是安全起点;若数据未归一化,先跑utils.py中的normalize_data()再训练。
3.4 支持向量筛选:为什么不是所有α>0的点都是支持向量
utils.py中get_support_vectors()函数有精妙设计:
def get_support_vectors(X, y, alphas, C, tol=1e-5): """ 返回真正起作用的支持向量:0 < alpha < C 的点 注意:alpha==C的点是误分类点,虽参与w计算,但不定义间隔边界 """ sv_indices = [ i for i, alpha in enumerate(alphas) if tol < alpha < C - tol ] return [X[i] for i in sv_indices], [y[i] for i in sv_indices], [alphas[i] for i in sv_indices]关键在tol < alpha < C - tol——它排除了数值误差导致的伪支持向量。例如,当理论α应为0.0001,但浮点误差算成1e-15,此点会被忽略。同样,α=C的点(如α=0.999999999)被排除,因为它们对应软间隔中的误分类点,其物理意义是“被容忍的错误”,而非定义最大间隔的几何支撑点。
4. 运行与调试:从demo.py跑通到自定义数据集接入
项目开箱即用,但新手常卡在数据格式和参数理解上。本节给出可复制的完整流程,并指出三个最容易翻车的环节。
4.1 五分钟跑通:执行demo.py观察训练全过程
进入项目根目录,确保已安装matplotlib:
pip install matplotlib python demo.pydemo.py默认生成200个二维点(100个正类,100个负类),呈月牙形分布(非线性可分)。运行后将弹出两个窗口:
- 左图:原始数据分布 + 初始随机超平面(灰色虚线)
- 右图:训练过程中支持向量数量变化曲线(通常前100次迭代增长快,后趋缓)
提示:若图形卡顿,可在
demo.py开头将ANIMATE = False,改为只输出最终结果图。动画模式下每轮SMO更新都会重绘,适合教学;生产环境请关闭。
4.2 数据接入规范:你的CSV必须长这样
要替换demo.py中的toy数据,需准备CSV文件,格式严格为:
- 第一列:标签(整数,如+1/-1,或0/1)
- 后续列:特征(浮点数,不限维度)
- 无表头,无空行
例如mydata.csv:
1,2.3,5.1,0.8 -1,1.1,4.9,0.2 1,3.7,6.2,1.5 ...在demo.py中修改数据加载部分:
# 替换原来的make_moons() import csv X, y = [], [] with open('mydata.csv', 'r') as f: for row in csv.reader(f): y.append(int(row[0])) X.append([float(x) for x in row[1:]]) # 确保y为+1/-1格式 y = [1 if yi == 1 else -1 for yi in y]注意:SVM对特征尺度极度敏感!务必在训练前归一化。
utils.py提供normalize_data(X)函数,调用方式:X_norm = normalize_data(X)
它将每维特征缩放到[0,1]区间(非z-score),因SMO对绝对数值不敏感,此归一化足够。
4.3 关键参数调优指南:C、sigma、max_iter不是乱试的
| 参数 | 物理意义 | 推荐范围 | 调参信号 |
|---|---|---|---|
C | 软间隔惩罚强度 | 0.1 ~ 100 | 训练准确率↑但验证准确率↓ → C过大;支持向量数↑ → C过大 |
sigma(RBF) | 核函数局部尺度 | 0.1*std(X)~2*std(X) | 决策边界过度扭曲 → sigma过小;接近线性分类 → sigma过大 |
max_iter | SMO最大迭代轮数 | 100 ~ 10000 | 未收敛警告频繁 → 增加;训练时间过长 → 先检查C/sigma是否合理 |
实测技巧:先固定C=1,sigma=1,用max_iter=1000跑通;再用验证集扫C(0.1,1,10),选验证F1最高者;最后扫sigma(0.5,1,2)。
4.4 避坑:SMO训练中5个真实翻车现场
现象 → 原因 → 解决
训练中途报错
ZeroDivisionError: float division by zero
→ 原因:eta计算中K(x_i,x_i) + K(x_j,x_j) - 2*K(x_i,x_j)为0,即x_i≈x_j且核函数未加扰动
→ 解决:在rbf_kernel()中添加极小扰动:return math.exp(-squared_norm / (2 * sigma * sigma) + 1e-12)支持向量数量为0,所有α都收敛到0或C
→ 原因:C设置过小(如C=0.001),模型宁愿全部误分类也不扩大间隔
→ 解决:增大C,或检查数据标签是否全为同一类(len(set(y))应为2)predict()返回全1或全-1,无视输入x
→ 原因:calculate_b()中未正确处理支持向量为空的情况(当C极小,无0<α<C点)
→ 解决:在calculate_b()开头添加兜底:if not support_vectors: return 0.0训练耗时超10分钟,
max_iter=1000仍未收敛
→ 原因:数据未归一化,导致E_i计算误差大,SMO反复在边界震荡
→ 解决:强制运行X_norm = normalize_data(X),并用X_norm训练RBF核下决策边界呈“斑点状”,而非平滑曲线
→ 原因:sigma远小于数据尺度,核响应过窄,每个支持向量只影响极小邻域
→ 解决:打印np.std(X, axis=0),设sigma = np.mean(stds)作为起点
5. 二次开发实战:给SVM加上概率输出与多分类能力
SVM原生不支持概率输出(如logistic回归的p(y=1|x)),但工程中常需。本节教你用Platt缩放法(Platt Scaling)在不改核心SMO的前提下,为predict_proba()赋能。同时,解决多分类问题——不是简单One-vs-Rest,而是实现更鲁棒的One-vs-One(OvO)策略。
5.1 概率校准:用sigmoid拟合决策距离
SVM输出是f(x) = w^T φ(x) + b,其绝对值反映置信度,但非概率。Platt Scaling将其映射到[0,1]:
$$ P(y=1|x) = \frac{1}{1 + \exp(A f(x) + B)} $$
其中A,B通过逻辑回归拟合(f(x)为特征,y为标签)。实现步骤:
- 在
svm.py的fit()末尾,增加:
# 计算所有训练样本的决策值f(x_i) self.decision_values_ = [] for xi in X: self.decision_values_.append(self._decision_function(xi)) # 用sklearn的LogisticRegression拟合(仅此处允许引入外部库) from sklearn.linear_model import LogisticRegression self.platt_model_ = LogisticRegression() self.platt_model_.fit([[dv] for dv in self.decision_values_], y)- 新增
predict_proba()方法:
def predict_proba(self, X): decision_vals = [self._decision_function(x) for x in X] # LogisticRegression.predict_proba返回[[p_neg, p_pos]] return self.platt_model_.predict_proba([[dv] for dv in decision_vals])注意:此方案仅在
demo.py中演示时引入sklearn,核心SVM训练仍100%纯Python。若需完全无依赖,可用utils.py中手写的简易逻辑回归(梯度下降实现)替代。
5.2 多分类OvO实现:N类需N(N-1)/2个二分类器
svm.py新增SVMMultiClassifier类,核心是_fit_ovo():
def _fit_ovo(self, X, y): """训练所有两两组合的SVM""" self.classes_ = sorted(set(y)) self.ovo_classifiers_ = {} from itertools import combinations for class_i, class_j in combinations(self.classes_, 2): # 提取两类样本 mask = [(yi == class_i) or (yi == class_j) for yi in y] X_pair = [X[i] for i in range(len(X)) if mask[i]] y_pair = [1 if y[i] == class_i else -1 for i in range(len(y)) if mask[i]] # 训练二分类器 clf = SVMClassifier(kernel='rbf', C=self.C, sigma=self.sigma) clf.fit(X_pair, y_pair) self.ovo_classifiers_[(class_i, class_j)] = clf预测时采用投票制:每个二分类器投一票,得票最多者胜出。predict()中调用:
votes = {cls: 0 for cls in self.classes_} for (cls_i, cls_j), clf in self.ovo_classifiers_.items(): pred = clf.predict([x])[0] if pred == 1: votes[cls_i] += 1 else: votes[cls_j] += 1 return max(votes, key=votes.get)5.3 性能优化技巧:缓存核矩阵与增量学习
对大数据集,每次计算K(x_i,x_j)重复开销大。在SVMClassifier.__init__()中添加缓存:
self.kernel_cache_ = {} # 键为(x_i_id, x_j_id),值为K(x_i,x_j)并在rbf_kernel()中:
key = (id(x), id(y)) if x is not y else (id(x), id(x)) if key in self.kernel_cache_: return self.kernel_cache_[key] # 计算并缓存 val = math.exp(...) self.kernel_cache_[key] = val return val增量学习更简单:fit()方法增加warm_start=True参数,复用上次的alphas作为初值,而非全0初始化。实测在流式数据场景下,收敛速度提升3倍。
从那以后我每次接到SVM需求,第一件事就是打开这个纯Python实现,把smo.py和kernel.py打印出来贴在显示器边框上——不是为了抄代码,而是提醒自己:所有“智能”都始于对约束的敬畏,和对每一步数值的确认。希望帮到你。
本文还有配套的精品资源,点击获取