有一类作业你明明在调用现成的库但真正想拿满分还得靠理解原理——支持向量机 SVM 那课就是典型。这篇要拆的是经典机器学习课程的编程作业Python 版第 6 节用 SVM 做完 2D 样本分类和垃圾邮件识别。我会把数据集结构、线性核和高斯核的用法、参数选择、邮件文本转向量的过程全部过一遍顺便把我当年踩过的坑和调参经验也放进来。适合正在刷这套课程的初学者也适合想用 Python 重新复现一遍但不想看 Octave 代码的人。这个作业最核心的价值不是让你学会调用SVC而是让你亲手体会三个问题什么是间隔最大化、为什么核函数能把线性不可分的问题搬到高维空间、以及正则化参数 C 和核参数到底在调试什么。下面直接进入正题。1. 作业内容拆解SVM 在这里到底要做哪些事1.1 四个数据集与两条主线练习 6 表面上是一堆 mat 文件和几个函数拆开来看其实是两条主线一条是分类任务另一条是模型选择。分类任务又分为线性可分、非线性可分、文本分类三种场景对应着三个数据集。第一个是 ex6data1一个二维平面上的二分类数据集。样本量不大正负样本分布有明显间隔但仍然有少量交叉点。这个数据集的目标很直接训练一个线性 SVM画出决策边界观察正则化参数 C 对边界的影响。第二个是 ex6data2同样是二维二分类但样本分布呈现出内圈和外圈的嵌套结构线性分类器根本不可能分开。这一节要求你实现或调用高斯核函数把映射到高维空间之后的 SVM 用在原始特征上得到一条能够包住内圈样本的非线性边界。第三个是 ex6data3原始作业已经把数据拆成了训练集和验证集并明确要求你用验证集去挑出最优的超参数组合(C, sigma)。这里 sigma 是高斯核的尺度参数在 scikit-learn 里对应的是gamma两者有固定的换算关系后面会详细说。第四条线是垃圾邮件分类使用的是spamTrain.mat和spamTest.mat。需要你把邮件文本按词汇表转成 1899 维的特征向量然后训练 SVM 判断一封邮件是不是垃圾邮件。这一节名义上是在讲 SVM实际上还打包了文本预处理、特征工程、精度评估这些工程问题。1.2 完成作业后你真正要掌握的知识点我看到很多人抄完参考代码就把作业提交了一问原理反而说不上来。这个作业至少应该带走四个知识点。第一个是最大间隔分类器。SVM 不只是画一条线而是要找到离两类样本尽可能远的那条线这个距离叫做间隔间隔最大化的解只由少部分边界点决定这些点就是支持向量。理解这一点你才会明白为什么 SVM 对样本量不敏感但对噪声样本特别敏感。第二个是核技巧。高斯核的本质是把两个样本的相似度重新定义为一个带尺度的高斯函数值。你不需要显式计算高维特征只需要算内积这就是”核技巧“。作业里手动写一次gaussianKernel(x1, x2, sigma)比看十遍课件都管用。第三个是正则化参数 C 的偏差方差权衡。C 大模型对误分类的惩罚大边界会尽量贴合训练数据容易过拟合C 小边界更平滑但可能欠拟合。这个作业专门安排了 ex6data1 做这个对比实验。第四个是模型选择的标准流程。用验证集误差而不是训练集误差来选参数是机器学习里最容易忽略的常识。ex6data3 就是用一个两层循环帮你建立这个习惯。2. 开发环境与数据加载准备一个能跑的 Python 作业环境2.1 依赖安装与版本选择三步搭好环境开始写代码之前先把环境配好。这一步比你想的重要因为作业原始数据是 MATLAB 的.mat格式只装 numpy 和 matplotlib 不够还需要 scipy 来读取。建议直接用 Python 3.8 以上的版本然后建一个干净的虚拟环境安装下面这四个库pip install numpy scipy scikit-learn matplotlib这里有三个容易忽略的点。第一scikit-learn 的版本不要太老也不要太新我实测 1.0 到 1.3 之间的版本都能正常跑完这个作业太新的版本只是有一些参数默认值变动影响不大。第二如果你想尽可能贴近原作业的实现思路可以用sklearn.svm.SVC因为作业的数据规模很小不需要用线性内核的LinearSVC来提速。第三matplotlib 建议装上ipykernel或直接在 Jupyter Notebook 里跑因为后面画决策边界需要反复观察脚本式执行会非常痛苦。这个作业不是工业级项目所以不用上虚拟化容器或者远程 GPU本地环境足够了。2.2 用 scipy 正确读取 mat 文件原始作业的数据集都是.mat文件Python 里读取它们要用scipy.io.loadmat。有个非常容易踩的坑loadmat返回的是字典里面除了变量本身还会多出__header__、__version__、__globals__这些键取数据时必须用对应的变量名。以 ex6data1 为例加载代码长这样import scipy.io as sio import numpy as np data sio.loadmat(ex6data1.mat) X data[X] y data[y] print(X.shape, y.shape)输出结果是(51, 2)和(51, 1)。注意 y 是二维列向量后面喂给 scikit-learn 之前要压成一维否则会有警告这个细节放到避坑部分重点说。如果你用的是作业提供的原始文件名加载 ex6data2、ex6data3、spamTrain 和 spamTest 都同理。建议提前封装一个通用加载函数避免每个数据集写一遍重复代码def load_mat(file_name): data sio.loadmat(file_name) return data然后根据文件里的键名去取 X、y、Xval、yval。这一步本身不复杂但它是整个作业的地基数据没读对后面画出来的图全是乱的。3. 线性 SVM第一个分类器与决策边界3.1 先可视化数据再建模别上来就训练拿到 ex6data1 之后第一件事不是训练而是画散点图。这一步能让你直观看到正负样本的分布也能帮你判断后续决策边界画得对不对。import matplotlib.pyplot as plt plt.figure(figsize(8, 6)) plt.scatter(X[y.ravel() 0, 0], X[y.ravel() 0, 1], markero, cblue, labely0) plt.scatter(X[y.ravel() 1, 0], X[y.ravel() 1, 1], marker, cred, labely1) plt.xlabel(X1) plt.ylabel(X2) plt.legend() plt.show()从图上能看到两类样本大体上是沿一条斜线分开的但有个别样本靠近对方阵营。这些点就是后面观察 C 参数影响的突破口。接下来用线性核训练一个基础分类器。scikit-learn 的SVC默认核就是 RBF所以必须显式指定kernellinear。from sklearn.svm import SVC model SVC(kernellinear, C1.0) model.fit(X, y.ravel())训练完成后用model.score(X, y.ravel())看训练集准确率。在 ex6data1 上C1 的线性 SVM 会达到接近满分的训练精度但它其实是留了一个小错误在边界附近这就引出了 3.2 的参数对比。3.2 决策边界可视化与 C 参数的真实作用光看准确率没有意义必须把决策边界画出来。一种简单粗暴的方式是生成网格点逐点预测再画等高线。def plot_decision_boundary(model, X, y): x_min, x_max X[:, 0].min() - 0.2, X[:, 0].max() 0.2 y_min, y_max X[:, 1].min() - 0.2, X[:, 1].max() 0.2 xx, yy np.meshgrid( np.linspace(x_min, x_max, 500), np.linspace(y_min, y_max, 500) ) Z model.predict(np.c_[xx.ravel(), yy.ravel()]) Z Z.reshape(xx.shape) plt.contour(xx, yy, Z, levels[0], linewidths2, colorsgreen) plt.scatter(X[y.ravel() 0, 0], X[y.ravel() 0, 1], markero, cblue) plt.scatter(X[y.ravel() 1, 0], X[y.ravel() 1, 1], marker, cred) plt.show()分别用 C1 和 C100 训练两个模型放在同一张图里看对比。C1 时决策边界会比较偏向中间位置哪怕训练集上多了一个误分类点模型也更愿意保持平滑。C100 时模型会拼命把边界推向那个误分类点想办法把它绕过去结果边界变得扭曲泛化能力反而下降。这个对比就是偏差方差权衡的直观体现。C 是误分类惩罚项C 越大SVM 越不愿意放过任何一个训练集错误C 越小SVM 越倾向于忽略局部错误换取更平滑的边界。作业里要求你看到两种边界后自己复述这一层关系比背公式有用得多。3.3 支撑向量到底是谁在 scikit-learn 中训练完成后可以用model.support_vectors_打印出被选为支持向量的样本坐标。你会发现真正决定决策边界的只是少量样本而不是全部数据。你可以把支持向量用不同颜色叠加在散点图上。看到这个结果之后再回头理解讲义里那句“SVM 的模型由支持向量决定”印象会完全不同。这也是为什么 SVM 在样本量较小时训练速度反而有优势因为它的优化目标只关心边界附近的点。4. 高斯核实现非线性 SVM4.1 高斯核公式与 gamma 的换算关系ex6data2 的内圈外圈问题线性核无解。这时需要把样本映射到高维空间但直接在特征空间里做计算非常昂贵。高斯核的巧思在于它用核函数计算两个样本在高维空间的内积而整个过程只需要在原始特征空间算一个指数函数。作业里给出的高斯核定义是K(x1, x2) exp(-||x1 - x2||^2 / (2 * sigma^2))在 scikit-learn 的SVC里RBF 核写成K(x1, x2) exp(-gamma * ||x1 - x2||^2)所以对应关系是gamma 1 / (2 * sigma^2)这个换算关系非常容易踩坑。很多人直接把作业里的 sigma 当成 scikit-learn 的 gamma 传进去结果边界要么忽大忽小要么裂成碎片。作业还要求你手动实现一个gaussianKernel函数用来做相似度计算验证def gaussian_kernel(x1, x2, sigma): return np.exp(-np.sum((x1 - x2) ** 2) / (2 * sigma ** 2))这里np.sum((x1 - x2) ** 2)就是两个向量的欧氏距离平方。自己写一遍这个函数你才会明白为什么高斯核也叫 RBF因为它的函数值只取决于两个样本之间的距离离得越近相似度越接近 1离得越远相似度趋向 0。4.2 ex6data2 实验从外圈到内圈对 ex6data2 做训练核心代码只有几行data2 sio.loadmat(ex6data2.mat) X2, y2 data2[X], data2[y] model2 SVC(kernelrbf, C100, gamma0.1) model2.fit(X2, y2.ravel())我最初跑这个数据集时gamma 取 0.1C 取 100画出来的决策边界能够把内圈的圆完整包住。如果把 gamma 调成 1 甚至 10决策边界会开始出现明显凸起沿着单个样本的轮廓走这是过拟合的信号。这里有个值得思考的点为什么 C 要设成 100因为高斯核已经把样本映射到高维空间模型拟合能力很强如果 C 太小模型会为了平滑丢弃太多边界样本导致外圈分类出错。C 和 gamma 是配合作用的不是单独调某一个。4.3 核参数对决策边界形状的影响在 RBF 核里gamma 控制的是高斯函数的宽度。gamma 越小高斯函数越矮胖每个样本的影响范围越大决策边界越平滑gamma 越大高斯函数越尖每个样本只影响附近很小的区域决策边界越容易剧烈弯曲。用生活化类比来说gamma 大时每个样本都说“我身边这一小块我说了算”于是边界像拼图一样碎gamma 小时每个样本都说“方圆几里内大家商量着来”边界自然平滑得多。我之前做过一组对比实验C 固定在 100gamma 分别取 0.01、0.1、1、100四个边界画在同一个图上效果差异肉眼可见。这个实验强烈建议你自己做一遍比任何文字描述都直观。5. ex6data3 自动调参模型选择的标准思路5.1 为什么不能用训练集误差挑参数ex6data3 是最容易让人悟到“调参真谛”的一节。作业给了训练集 X、y还给了验证集 Xval、yval任务是从若干候选参数里挑出一组最优的(C, gamma)。新手最容易犯的错误是用训练集误差最小来选参数。这样做会导致模型过拟合因为参数越复杂训练集误差一定越低但它没有见过验证集泛化能力并不知道。正确的做法是训练集负责训练模型验证集负责评估参数好坏。谁在验证集上误差最低就选谁。这就是机器学习的标准模型选择流程也是后面做交叉验证的基础。5.2 网格搜索代码实现候选参数通常是作业里指定的那组值C [0.01, 0.03, 0.1, 0.3, 1, 3, 10, 30] gamma [0.01, 0.03, 0.1, 0.3, 1, 3, 10, 30]两层循环逐对训练记录验证集精度。代码结构并不复杂def dataset3_params(X, y, Xval, yval, C_list, gamma_list): best_C None best_gamma None best_score 0 for C in C_list: for gamma in gamma_list: model SVC(kernelrbf, CC, gammagamma) model.fit(X, y.ravel()) score model.score(Xval, yval.ravel()) if score best_score: best_score score best_C C best_gamma gamma return best_C, best_gamma, best_score这里要注意model.score返回的是验证集精度而不是训练集精度。之所以用验证集精度是因为它更能代表模型在新数据上的表现。不同环境和不同 mat 版本下最优参数可能会有细微差别。常见的结果落在 C 为 0.3 到 3、gamma 为 0.1 到 1 这个区间但也有可能选出 C30、gamma30 这种极端组合关键是看你的验证集精度。如果验证集精度能达到 0.95 以上说明参数选择是有效的。5.3 网格搜索的局限与改进方向两层循环是作业要求也是理解模型选择的最简实现。但在真实项目里这种网格搜索不是最优解因为参数类别增加时会指数爆炸。改进方向有两个一是用 scikit-learn 的GridSearchCV它在内部会做 K 折交叉验证可以更稳健地评估参数二是用RandomizedSearchCV当参数取值范围很大或维度很高时随机采样比穷举更高效。不过对练习 6 来说如果你直接跳到GridSearchCV反而会错过理解验证集意义的机会。我的建议是先用两层循环写一遍再顺手看一眼GridSearchCV的用法这样既接地气又不会固步自封。6. 垃圾邮件分类从邮件文本到特征向量6.1 作业提供的数据到底是什么垃圾邮件分类算是练习 6 的”毕业项目“。原始作业提供了两份数据训练集spamTrain.mat和测试集spamTest.mat。训练集有 4000 个样本测试集有 1000 个样本每个样本的特征维度是 1899。这个 1899 维特征向量是从一封邮件的正文里提取出来的。每个维度对应词汇表里的一个单词取值通常是 0 或 1表示该邮件中是否出现了这个单词。所以这不是原始文本而是已经处理成数值向量的数据。加载方式和其他数据集一致spam_train sio.loadmat(spamTrain.mat) spam_test sio.loadmat(spamTest.mat) X_train spam_train[X] y_train spam_train[y].ravel() X_test spam_test[Xtest] y_test spam_test[ytest].ravel() print(X_train.shape, y_train.shape, X_test.shape, y_test.shape)打印出来是(4000, 1899)、(4000,)、(1000, 1899)、(1000,)。6.2 词汇表与文本向量化的逻辑如果你用原始作业文件还会看到一个vocab.txt里面是 1899 个单词每行一个编号和一个单词编号从 1 开始。比如 “1 a”、“2 able”、“12 all” 这种格式。要理解特征向量怎么来的可以自己写一个简单的文本转向量函数。首先把词汇表读成字典再统计邮件正文里出现了哪些词最后把对应位置置 1。vocab_dict {} with open(vocab.txt, r) as f: for line in f: idx, word line.strip().split() vocab_dict[word] int(idx) def email_to_features(email_text, vocab_dict): vec np.zeros((len(vocab_dict) 1,)) for word in email_text.split(): word word.lower() if word in vocab_dict: vec[vocab_dict[word]] 1 return vec[1:]这里有个细节vocab.txt 里的编号是从 1 开始的如果直接拿编号当索引数组长度必须比词汇表大小多 1最后再砍掉索引 0。否则你写出来的向量维度就对不上。真实的邮件预处理还包括去掉 HTML 标签、统一大小写、词干提取、过滤非字母字符等。原始作业把这一步精简成了处理好的向量但我在重做时还是会手动走一遍这个流程因为这对理解后续 SVM 为什么能生效很关键。6.3 训练线性 SVM 并评估对于 1899 维的稀疏文本特征线性核往往比 RBF 核更合适。因为文本特征维度已经很高了线性边界通常就能分得不错RBF 核反而容易过拟合训练也会更慢。训练代码svc_spam SVC(kernellinear, C0.1) svc_spam.fit(X_train, y_train) train_acc svc_spam.score(X_train, y_train) test_acc svc_spam.score(X_test, y_test) print(fTraining accuracy: {train_acc:.4f}) print(fTest accuracy: {test_acc:.4f})在我自己的运行环境里C0.1 的线性 SVM 在训练集上能达到 99.8% 左右在测试集上能达到 98.5% 以上。这个精度相当高说明垃圾邮件和正常邮件在词汇分布上的差异非常明显。如果你发现测试精度比训练精度低很多优先检查你是不是把训练样本的特征向量维度搞错了或者有没有把验证集混进训练集。练习题里最容易出现的数据泄漏就是这种。6.4 对邮件处理流水线的延伸思考做完这一节值得停下来想一想为什么文本转成词频向量之后线性 SVM 就能把垃圾邮件分得这么准原因是垃圾邮件的词汇特征非常强。比如“免费”“点击链接”“中奖”这些词在正常邮件里很少出现而在垃圾邮件里高频出现。SVM 在万维特征空间里找到一条线性边界本质上是在给这些强特征分配较大的权重。真实的垃圾邮件过滤系统不会只依赖词频还会加入发信人信誉、邮件域名、URL 特征、拼写错误率等。但从练习角度说能跑通完整的数据预处理、模型训练、评估流程你已经把这一章的核心吃透了。7. 避坑手册SVM 编程作业中最常见的几个问题7.1 数据形状不对模型报警告scikit-learn 对 y 的输入形状很挑剔。从.mat文件读出来的 y 是(m, 1)的二维列向量而fit方法希望 y 是一维的(m,)或者形状是(m, n_classes)。解决办法是在所有fit和score前调用y.ravel()。这个坑几乎每个人都会遇到处理完就没事了但如果你用原始 y 直接训练会看到一大堆 DeprecationWarning之后还可能因为多出的一维形状导致调试混乱。7.2 高斯核的 sigma 与 gamma 混用这是练习 6 排名第一的认知坑。作业原始公式用 sigma 描述高斯核宽度scikit-learn 用 gamma 控制 RBF 核。如果你在SVC(kernelrbf, gamma0.1)里把 0.1 当作 sigma等于把真正的 gamma 写成了1/(2*0.1^2)50边界会变得极度复杂。反过来如果你写Gaussian核也要注意sigma和gamma的一次项换算。强烈建议在代码里单独定义换算函数def sigma_to_gamma(sigma): return 1.0 / (2 * sigma ** 2)7.3 特征缩放被忽略SVM 对特征尺度敏感。如果某个特征的数值范围是 0 到 1另一个特征是 0 到 10000后者会主导距离计算导致核函数失效。原始作业数据大多已经做过标准化或者本身就是稀疏二值特征所以直接跑没问题。但如果你自己构造特征或者换一个真实数据集来练手一定要先做标准化from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_scaled scaler.fit_transform(X)标准化之后再用 SVM边界和精度都会更稳定。7.4 直接调库却没有手动实现核函数我理解刚接触机器学习时看到SVC一行搞定会觉得很爽。但练习 6 明确要求手动实现gaussianKernel这个步骤不能跳过。原因是如果你不亲手把指数、距离平方、sigma 之间的关系写一遍后面做调参时根本无法判断边界异常到底出在哪一步。同理dataset3Params那个两层循环也不要直接用现成的GridSearchCV替代。先自己数一遍有多少种组合跑一遍记录结果再用工具这样你的模型选择直觉才能建立起来。7.5 用训练集上的误差来选超参数这个问题在第 5 章已经反复强调但这里还是要再提醒一次你调的是模型在未见数据上的表现不是训练数据上的表现。训练集误差只能说明模型有没有拟合训练数据无法说明模型泛化能力。练习 6 里 ex6data3 之所以单独给你一个验证集就是让你养成”训练集训练、验证集选择、测试集评估“的三段式习惯。如果你把验证集也拿来训练或者用测试集去调参最终的精度报告就失去意义了。7.6 决策边界可视化时网格过粗画决策边界时网格步长如果选太大边界会呈锯齿状网格步长太小绘图会很慢。我一般用 500 乘 500 的网格密度既能保证边界平滑又不会卡顿。还有一个细节plt.contour(xx, yy, Z, levels[0])只画一条等高线是在原始数据坐标空间中的边界。如果你做过标准化画图前要把网格点也做同样的标准化变换否则边界会和散点图错位。8. 做完这个作业后还应该再往前看几步练习 6 本身是围绕 SVM 的入门实验但它的意义不止于此。做完之后我建议你按顺序再做这三件事。第一用decision_function看看 SVM 输出的距离值。SVC.predict只给类别decision_function会给每个样本到超平面的距离。距离越大置信度越高。这对于排序、阈值调整非常有价值。第二试试不同核函数在同一数据集上的表现差异。线性核、RBF 核、多项式核分别跑 ex6data1 和 ex6data2记录精度、边界形状、训练时间。你会发现核函数不是越高级越好而是越适配数据越好。第三去理解一下为什么 SVM 在某些问题上训练很慢。SVC 背后的 SMO 算法和带交叉验证的网格搜索在小数据集上很轻松但遇到上百万样本时就会吃力。理解这一点之后你再回头看LinearSVC、随机梯度下降 SVM 这类变体就能明白它们存在的意义。我在实际重做这个作业时最大的体会是真正让你卡住的往往不是代码而是概念。比如 sigma 和 gamma 的关系理解了就不怕报错比如验证集的作用想通了就不会在调参时绕弯。你花在手动实现和可视化上的每一分钟最后都会折算成对机器学习整体认知的提升。