有一类作业,你明明在调用现成的库,但真正想拿满分还得靠理解原理——支持向量机 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], marker='o', c='blue', label='y=0') plt.scatter(X[y.ravel() == 1, 0], X[y.ravel() == 1, 1], marker='+', c='red', label='y=1') plt.xlabel('X1') plt.ylabel('X2') plt.legend() plt.show()从图上能看到两类样本大体上是沿一条斜线分开的,但有个别样本靠近对方阵营。这些点就是后面观察 C 参数影响的突破口。
接下来用线性核训练一个基础分类器。scikit-learn 的SVC默认核就是 RBF,所以必须显式指定kernel='linear'。
from sklearn.svm import SVC model = SVC(kernel='linear', C=1.0) model.fit(X, y.ravel())训练完成后,用model.score(X, y.ravel())看训练集准确率。在 ex6data1 上,C=1 的线性 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], linewidths=2, colors='green') plt.scatter(X[y.ravel() == 0, 0], X[y.ravel() == 0, 1], marker='o', c='blue') plt.scatter(X[y.ravel() == 1, 0], X[y.ravel() == 1, 1], marker='+', c='red') plt.show()分别用 C=1 和 C=100 训练两个模型,放在同一张图里看对比。
C=1 时,决策边界会比较偏向中间位置,哪怕训练集上多了一个误分类点,模型也更愿意保持平滑。C=100 时,模型会拼命把边界推向那个误分类点,想办法把它绕过去,结果边界变得扭曲,泛化能力反而下降。
这个对比就是偏差方差权衡的直观体现。C 是误分类惩罚项,C 越大,SVM 越不愿意放过任何一个训练集错误;C 越小,SVM 越倾向于忽略局部错误,换取更平滑的边界。作业里要求你看到两种边界后自己复述这一层关系,比背公式有用得多。
3.3 支撑向量到底是谁
在 scikit-learn 中,训练完成后可以用model.support_vectors_打印出被选为支持向量的样本坐标。你会发现,真正决定决策边界的只是少量样本,而不是全部数据。
你可以把支持向量用不同颜色叠加在散点图上。看到这个结果之后再回头理解讲义里那句“SVM 的模型由支持向量决定”,印象会完全不同。这也是为什么 SVM 在样本量较小时训练速度反而有优势,因为它的优化目标只关心边界附近的点。
4. 高斯核:实现非线性 SVM
4.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(kernel='rbf', C=100, gamma=0.1) model2.fit(X2, y2.ravel())我最初跑这个数据集时,gamma 取 0.1,C 取 100,画出来的决策边界能够把内圈的圆完整包住。如果把 gamma 调成 1 甚至 10,决策边界会开始出现明显凸起,沿着单个样本的轮廓走,这是过拟合的信号。
这里有个值得思考的点:为什么 C 要设成 100?因为高斯核已经把样本映射到高维空间,模型拟合能力很强,如果 C 太小,模型会为了平滑丢弃太多边界样本,导致外圈分类出错。C 和 gamma 是配合作用的,不是单独调某一个。
4.3 核参数对决策边界形状的影响
在 RBF 核里,gamma 控制的是高斯函数的宽度。gamma 越小,高斯函数越矮胖,每个样本的影响范围越大,决策边界越平滑;gamma 越大,高斯函数越尖,每个样本只影响附近很小的区域,决策边界越容易剧烈弯曲。
用生活化类比来说,gamma 大时,每个样本都说“我身边这一小块我说了算”,于是边界像拼图一样碎;gamma 小时,每个样本都说“方圆几里内大家商量着来”,边界自然平滑得多。
我之前做过一组对比实验,C 固定在 100,gamma 分别取 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(kernel='rbf', C=C, gamma=gamma) 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 这个区间,但也有可能选出 C=30、gamma=30 这种极端组合,关键是看你的验证集精度。如果验证集精度能达到 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(kernel='linear', C=0.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(f'Training accuracy: {train_acc:.4f}') print(f'Test accuracy: {test_acc:.4f}')在我自己的运行环境里,C=0.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(kernel='rbf', gamma=0.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 的关系,理解了就不怕报错;比如验证集的作用,想通了就不会在调参时绕弯。你花在手动实现和可视化上的每一分钟,最后都会折算成对机器学习整体认知的提升。