ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零手写多类SVM分类器:损失函数推导与向量化实现

从零手写多类SVM分类器:损失函数推导与向量化实现

1. 从零手写SVM分类器:为什么我不建议你直接调库

很多人第一次接触计算机视觉课程作业,看到SVM这三个字母,第一反应就是打开常用机器学习库,三行代码拟合完事。我当初也动过这个念头,但真正把作业要求读完才发现,这一题的核心根本不是让你训练一个分类器,而是让你亲手把多类SVM的损失函数和解析梯度推导并实现出来。换句话说,它考的是你对线性分类器底层运作机制的理解,而不是调包能力。

这个作业通常出现在视觉识别课程的早期阶段,配套的数据集是经典的图像分类小数据集,包含10个类别、几万张32×32的小图。任务目标很明确:实现一个基于合页损失(Hinge Loss)的多类SVM,并且要同时完成两件容易出错的事——向量化计算损失和向量化计算梯度。这两件事如果只求跑通,用循环硬写也能过,但作业的评分脚本会卡时间,循环版本在几万张图上跑一次梯度下降慢到让人怀疑人生。

所以这篇内容适合三类人看:正在做这个作业但卡在梯度推导上的同学、想搞明白SVM损失函数到底怎么算的初学者、以及写过但想回头把向量化技巧吃透的从业者。我会把损失函数的数学形式、梯度的逐元素推导、向量化的实现思路、数值梯度校验的方法,以及我踩过的几个坑,全部摊开讲清楚。你跟着走一遍,应该能独立写出一个既正确又跑得快的版本。

先说结论性的判断:这一题真正的难点不在SVM本身,而在矩阵维度对齐和梯度中指示函数的处理。我见过太多人损失算对了、梯度差一点点,训练时loss下降但准确率上不去,最后卡在数值校验那一步反复调。下面我按设计思路、核心推导、代码实现、问题排查四个层次展开。

2. 整体设计思路与方案选型

2.1 为什么选多类SVM而不是一对多

多类SVM和一对多SVM是两种常见思路。一对多是训练10个二分类器,每个负责区分“是不是某一类”,预测时取分数最高的那个。多类SVM则是用一套权重矩阵直接输出10个类别的分数,损失函数要求正确类别的分数比错误类别高出至少一个边界值。

我选多类SVM的原因很实际:它的权重矩阵是单一的,形状是“特征维度×类别数”,梯度推导干净,向量化实现也统一。一对多需要维护10套权重,每套还要处理正负样本不均衡,代码量和出错概率都翻倍。作业的评分脚本通常也是按多类SVM的接口设计的,你硬套一对多反而对不上。

从数学上看,多类SVM的评分函数是线性的:给定一张图展平后的特征向量x,第j类的分数就是权重矩阵第j列与x的点积,再加上偏置项。这个形式简单到可以用一行矩阵乘法搞定,这也是它适合教学的原因——所有复杂度都集中在损失和梯度上。

2.2 合页损失的核心思想

合页损失的名字来自它的形状,像一个合页。它的逻辑是:如果正确类别的分数已经比某个错误类别高出至少一个边界值Δ,那这个错误类别就不再产生损失;否则就产生“差距还差多少”的损失。

用生活化的类比:假设你在考试,正确类别是“及格线”,错误类别是“其他考生的分数”。规则是只要你的分数比某个其他考生高出Δ分,就不用管他;如果没高出,就要按差距扣你的分。最终损失是所有其他考生扣分的总和。这个设计的好处是只惩罚那些真正造成混淆的类别,已经分得很开的类别不贡献梯度,训练更聚焦。

Δ的取值通常设为1.0,这是一个经验值。它不影响最优解的方向,只影响权重的缩放。我试过设成0.5和2.0,最终准确率差别很小,但Δ太小会让损失对噪声更敏感,太大则初期梯度偏大、收敛慢。作业里一般直接给1.0,不用纠结。

2.3 正则化项为什么不能省

如果只有合页损失,权重矩阵可以无限缩放而不改变分类结果,这会导致解不唯一,也容易过拟合。所以必须加L2正则化,惩罚权重过大。正则化强度由超参数λ控制,它和损失项之间是此消彼长的关系。

这里有个容易忽略的点:正则化只作用于权重W,不作用于偏置b。因为偏置只是平移决策边界,不控制边界的方向和陡峭程度,惩罚它没有正则化的意义。我在第一次实现时把b也加进去了,结果数值梯度校验一直对不上,排查了半天才发现是这个原因。

λ的选择需要调。太小则正则化形同虚设,训练集准确率很高但验证集上不去;太大则权重被压得太狠,模型欠拟合。作业里通常会给一个推荐范围,我一般从1e-3开始试,配合学习率一起调。

3. 核心细节解析与实操要点

3.1 损失函数的逐样本计算逻辑

先看单个样本的损失怎么算。给定样本特征x、正确类别y、权重W,先算出所有类别的分数向量s = W·x。然后遍历所有错误类别j(j不等于y),计算max(0, s_j - s_y + Δ),把这些值累加,就得到这个样本的合页损失。

注意这里有个细节:正确类别的分数s_y在每一项里都要减。也就是说,每个错误类别都在和同一个正确分数比较。这个设计让正确类别的分数被“推高”,错误类别的分数被“压低”,两者之间的差距被拉开。

整个数据集的损失是所有样本损失的平均值,再加上正则化项。取平均而不是求和,是为了让损失的量级不随样本数变化,方便设置学习率。正则化项是λ乘以W所有元素平方和的一半,那个1/2是为了求导时抵消平方的2,让梯度形式更干净。

3.2 梯度推导的关键:指示函数

梯度推导是这一题最容易翻车的地方。对权重W求导,需要分两种情况:对正确类别的那一列求导,和对错误类别的那一列求导。

对于某个样本,如果错误类别j满足s_j - s_y + Δ > 0,说明它产生了损失。此时对W的第j列求导,梯度贡献是x(因为s_j = W_j·x,对W_j求导得x);对W的第y列求导,梯度贡献是-x(因为s_y前面有负号)。如果某个错误类别没产生损失,它对该样本的梯度贡献就是0。

这里的关键是指示函数:只有当margin大于0时,梯度才非零。实现时可以用一个布尔掩码来标记哪些类别产生了损失,然后只对这些类别累加梯度。我见过有人对所有类别都累加,结果梯度偏大,训练震荡。

还有一个容易错的点:如果一个样本的所有错误类别都没产生损失,那它对W的梯度贡献是0,但正则化项的梯度仍然存在。正则化项的梯度是λ·W,这个是对整个矩阵的,和样本无关。

3.3 向量化:把循环干掉

循环版本写起来直观,但跑得慢。向量化的核心思路是把“对每个样本、每个错误类别”的双重循环,转成矩阵运算。

具体做法:先算分数矩阵S = X·W,形状是“样本数×类别数”。然后取出每个样本正确类别的分数,构造一个“样本数×1”的向量,广播减到S的每一列上,得到“样本数×类别数”的差值矩阵。再加上Δ,把正确类别位置的值设为0(因为正确类别不参与损失),然后取max(0, ·),最后对所有元素求和再除以样本数。

梯度向量化稍微绕一点。需要构造一个掩码矩阵,标记哪些位置产生了损失。然后用这个掩码对分数矩阵做加权,再和特征矩阵做矩阵乘法。正确类别那一列的梯度要单独处理,因为它的符号是负的。我建议先把逐样本的梯度写清楚,再对照着做向量化,不要一上来就硬凑矩阵维度。

3.4 数值梯度校验:别跳过这一步

解析梯度写完,一定要用数值梯度校验。方法是:对权重矩阵的每个元素,分别加上和减去一个极小的h,算两次损失,用(f(x+h) - f(x-h)) / (2h)近似梯度,和解析梯度比较相对误差。

相对误差的计算公式是:|解析梯度 - 数值梯度| / max(|解析梯度| + |数值梯度|, 1e-8)。如果相对误差在1e-6到1e-4之间,说明解析梯度基本正确;如果大于1e-2,肯定有bug。

这一步不能省。我见过太多人解析梯度写错了但训练还能跑,因为SVM对梯度的小误差有一定容忍度,但准确率会卡在一个不上不下的水平。数值校验能帮你定位到具体是哪个元素的梯度不对,比盲调高效得多。

提示:数值校验时h不要取太小,1e-5左右比较合适。太小会受浮点精度影响,太大则近似不准。另外校验前先把正则化强度设小一点,比如1e-5,避免正则化项掩盖损失项的梯度错误。

4. 实操过程与核心环节实现

4.1 数据预处理:减均值与展平

拿到图像数据后,第一步是展平。每张32×32×3的图展平成3072维的向量,所有样本堆成“样本数×3072”的矩阵X。然后做减均值:算每个特征维度在所有样本上的均值,从X里减掉。

减均值的目的是让数据以0为中心,这样梯度下降时各个方向的更新更均衡,收敛更快。注意均值只能从训练集算,然后应用到验证集和测试集,不能把验证集的信息泄露进训练。这个细节在作业里经常被忽略,但它是机器学习的基本纪律。

有些版本还会做归一化,把每个维度除以标准差。图像数据一般减均值就够了,因为像素值本身就在0到255之间,量级统一。如果做了归一化,记得保存均值和标准差,预测时要复用。

4.2 权重初始化与偏置处理

权重W用小随机数初始化,比如乘以0.001的正态分布。不要全零初始化,否则所有类别的分数相同,梯度也相同,对称性破不开。也不要初始化太大,否则初期分数差异过大,合页损失里大部分类别都产生损失,梯度爆炸。

偏置b初始化为0就行。有些实现会把偏置合并进权重,做法是在特征向量末尾加一个1,权重矩阵多一行。这样代码更统一,但梯度校验时要注意最后一行。我倾向于分开处理,逻辑更清晰。

学习率的选择很关键。太大则损失震荡甚至发散,太小则收敛慢。我一般先用1e-3试,观察前100次迭代的损失曲线。如果损失下降很平滑,说明学习率合适;如果上下跳动,就除以3再试;如果下降太慢,就乘以3。配合学习率衰减效果更好,比如每轮乘以0.95。

4.3 训练循环的完整实现

训练循环的结构是:每次迭代取一个batch,前向算损失,反向算梯度,用梯度更新权重。batch大小通常取200到500,太小则梯度噪声大,太大则内存吃紧且收敛慢。

更新公式是W = W - learning_rate * dW,b同理。注意这里用的是随机梯度下降,不是批量梯度下降。每个batch算出的梯度只是整个数据集梯度的估计,但计算量小得多,迭代次数多,整体收敛更快。

我习惯在训练过程中记录每轮的损失和训练集准确率。如果损失不降反升,八成是学习率太大或者梯度符号错了。如果损失降但准确率不涨,可能是正则化太强或者数据有问题。这些现象和对应的排查方向,下一节详细说。

4.4 超参数调优的实操方法

超参数主要有两个:学习率和正则化强度λ。调参的方法是网格搜索:选几组候选值,分别训练,看验证集准确率。

候选值的选取有技巧。学习率按3倍或10倍递增,比如1e-4、3e-4、1e-3、3e-3、1e-2。λ也类似,从1e-5到1e-1。不要一上来就搜很大的范围,先用粗网格定位大致区间,再用细网格精调。

我一般先固定λ=1e-3,调学习率,找到让损失下降最快的那组;然后固定学习率,调λ,找到验证集准确率最高的那组。最后用这两组值再跑一次完整训练,看测试集表现。这个过程可能要跑十几次训练,每次几分钟,但比瞎调高效得多。

注意:验证集和测试集要严格分开。调参只看验证集,测试集只在最后用一次。如果反复用测试集调参,测试集就变成了验证集,最终报告的性能会虚高。

5. 常见问题与排查技巧实录

5.1 损失不下降的几种可能

损失不下降是最常见的问题,原因可能有四类。第一类是学习率太小,梯度更新幅度不够,损失几乎不动。解决办法是加大学习率,观察损失是否开始下降。第二类是梯度符号错了,更新方向反了,损失会震荡或上升。这时候要用数值校验确认梯度方向。第三类是数据没减均值,特征量级差异大,梯度下降走得很慢。第四类是正则化太强,损失被正则化项主导,模型学不动。

排查顺序建议是:先看损失曲线形状,再查梯度校验,最后调学习率。我遇到过一次损失完全不降,查了半天发现是权重初始化成了全零,所有类别分数相同,梯度也相同,对称性破不开。改成小随机数就好了。

5.2 数值梯度校验不通过的定位方法

校验不通过时,不要盯着整个矩阵看,要定位到具体元素。做法是打印出解析梯度和数值梯度差异最大的那几个位置,看它们的索引对应哪个样本、哪个类别。

常见原因有三个。一是正则化项没算对,比如忘了乘λ或者忘了除以样本数。二是正确类别的梯度符号错了,应该是负的写成了正的。三是指示函数用错了,把没产生损失的类别也算进了梯度。我踩过的坑是把偏置b也加了正则化,导致b的梯度校验一直差一点,去掉就好了。

还有一个隐蔽的坑:数值校验时如果h取1e-5,而损失值本身很大,浮点误差会放大。这时候可以把损失除以样本数之后再校验,或者用相对误差而不是绝对误差来判断。

5.3 训练准确率上不去的排查思路

如果损失在降但准确率不涨,先看训练集准确率。如果训练集准确率也很低,说明模型欠拟合,可能是λ太大或者学习率太小。如果训练集准确率高但验证集低,说明过拟合,要加大λ或者加更多正则化。

还有一种情况是准确率卡在某个值不动,比如10%左右,这通常是随机猜测的水平,说明模型根本没学到东西。这时候要检查数据标签有没有对齐,权重更新有没有生效,学习率是不是太小。我有一次把标签和特征错位了,训练了半天准确率一直是10%,排查了好久才发现是数据加载的顺序问题。

5.4 常见问题速查表

现象可能原因排查方法解决方向
损失完全不降学习率太小、梯度符号错、权重全零初始化看损失曲线、数值校验、检查初始化调大学习率、修正梯度、改随机初始化
损失震荡或上升学习率太大、梯度符号错观察损失波动幅度减小学习率、数值校验
数值校验不通过正则化算错、指示函数错、偏置被正则化定位差异最大的元素逐项检查公式
训练准确率低欠拟合、λ太大、学习率小看训练集准确率减小λ、调大学习率
验证准确率低过拟合、λ太小对比训练和验证准确率加大λ、增加数据
准确率卡在随机水平标签错位、更新未生效检查数据对齐、打印权重变化修正数据加载、确认更新公式

5.5 几个我踩过的坑和独家技巧

第一个坑是忘记除以样本数。损失和梯度都要对样本数取平均,漏掉的话量级差几百倍,学习率完全没法设。我建议在代码里显式写一个N = X.shape[0],每次算完都除以N,形成肌肉记忆。

第二个坑是正确类别的分数提取方式。用S[range(N), y]这种花式索引提取,比循环快得多,也不容易错。但要注意y必须是整数数组,不能是浮点。

第三个技巧是先小数据验证。不要一上来就在全量数据上跑,先取100个样本,把损失和梯度校验跑通,再上全量。小数据上调试快,出错也容易定位。

第四个技巧是保存训练曲线。把每次迭代的损失和准确率存下来,画成图。损失曲线的形状能告诉你很多信息:平滑下降说明健康,锯齿状说明学习率大,平台期说明需要衰减学习率。

第五个技巧是梯度检查用双精度。如果框架默认单精度,数值校验的误差会比较大。校验时临时转成双精度,校验完再转回来。

6. 从作业到实战:这套方法还能怎么用

把这一题吃透之后,你会发现它的价值远超一次作业。多类SVM的损失和梯度推导,是理解所有线性分类器的基石。逻辑回归的交叉熵损失、softmax的梯度,推导思路和这里一脉相承,都是“定义分数、定义损失、对权重求导”三步走。

向量化的技巧更是通用。把循环转成矩阵运算,是深度学习框架底层做的事。你手写一遍,再看框架的自动求导,会有种“原来如此”的通透感。我后来做图像检索、特征分类的项目,遇到需要自定义损失函数的时候,这套推导和校验的方法直接就能迁移过去。

数值梯度校验的习惯也值得保留。不管用什么框架,自定义层或损失函数写完,先用数值校验确认一遍,能省掉大量调试时间。这个习惯我保持了几年,帮我抓出过不少隐蔽的bug。

如果你想把这一题再往前推一步,可以试试这几个扩展方向:把合页损失换成softmax交叉熵,对比两者的收敛速度和最终准确率;加入学习率衰减和动量,观察训练曲线的变化;用不同的λ做网格搜索,画出验证集准确率随λ变化的曲线。这些扩展不需要额外数据,改几行代码就能跑,但对理解超参数的影响非常有帮助。

最后分享一个我个人的体会:这一题最值得花时间的不是把代码写对,而是把梯度推导的每一步都想清楚。代码写错了可以调,但推导逻辑不清楚,换个损失函数就又不会了。我当初把推导在纸上手写了两遍,第二遍不看任何参考,写完再对照代码,那之后类似的分类器实现就再没卡过。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进