资讯详情

资讯详情

从零手写多类SVM分类器:合页损失、SGD与向量化实现详解

1. 从零手写SVM分类器为什么我不建议你直接调库很多人第一次接触计算机视觉课程作业看到SVM这三个字母第一反应就是打开搜索引擎找现成实现或者直接调sklearn的LinearSVC跑一遍交差。我完全理解这种心态——毕竟作业截止日期摆在那里能跑通就行。但如果你真的想从这门课里带走点东西我强烈建议你耐着性子把这一部分手写一遍。原因很简单多类SVM的损失函数设计、梯度推导、向量化实现这三样东西是后续理解神经网络、甚至Transformer里attention机制的基础。你在这里偷的懒后面会在反向传播推导时加倍还回来。这个作业的核心任务其实就三件事实现一个基于合页损失Hinge Loss的多类SVM包括朴素的循环版本和完全向量化的版本实现随机梯度下降SGD训练流程最后通过交叉验证调超参数并在测试集上评估准确率。听起来不复杂但魔鬼全在细节里。比如数值稳定性的处理、学习率衰减的策略、向量化时维度对齐的陷阱每一个都能让你调试到凌晨三点。这篇文章我会按照我自己做这个作业时的真实思路来写——先讲整体设计逻辑再拆解每个核心函数的实现细节然后给出完整的实操流程和参数选择依据最后把我踩过的坑和排查经验整理成速查表。无论你是正在做这个作业的学生还是想复习SVM底层实现的从业者应该都能从中找到有用的东西。2. 整体设计与方案选型为什么是合页损失加SGD2.1 多类SVM的损失函数设计逻辑先明确一个前提标准的SVM是二分类器但这个作业要求做多类分类CIFAR-10有10个类别。把二分类SVM扩展到多类常见有两种策略一对多One-vs-Rest和一对一One-vs-One。但这两种都需要训练多个二分类器实现起来繁琐且效率不高。作业采用的是另一种更优雅的方案——结构化SVMStructured SVM的思路直接定义一个多类评分函数然后用合页损失来约束正确类别的分数要比错误类别高出至少一个边界值。具体来说对于一张输入图像$x_i$和它的正确标签$y_i$评分函数为$s W x_i$其中$W$是权重矩阵形状为$[C, D]$$C$是类别数$D$是特征维度。第$j$类的分数就是$s_j W_j \cdot x_i$。合页损失的定义是$$L_i \sum_{j \neq y_i} \max(0, s_j - s_{y_i} \Delta)$$这里的$\Delta$是边界超参数通常设为1.0。这个公式的直觉很好理解对于每一个错误类别$j$如果它的分数$s_j$没有比正确类别的分数$s_{y_i}$低至少$\Delta$那就产生损失。换句话说我们要求正确类别的分数不仅要最高还要高出错误类别一个安全距离。注意$\Delta$的取值其实不影响最优的$W$因为缩放$W$可以任意改变分数差距。但它影响$W$的尺度进而影响正则化项的相对权重。所以实际调参时$\Delta$和正则化强度$\lambda$只需要调一个就行。加上正则化项后完整的损失函数是$$L \frac{1}{N} \sum_{i1}^{N} L_i \lambda \sum_k \sum_l W_{k,l}^2$$正则化项用的是L2范数目的是防止过拟合让权重尽量小。这里$\lambda$是超参数控制正则化强度。2.2 为什么选择SGD而不是批量梯度下降有了损失函数接下来就是优化。理论上可以用批量梯度下降BGD每次用全部训练集计算梯度。但CIFAR-10有50000张训练图像每张图像3072维权重矩阵$W$是$10 \times 3073$含偏置每次迭代的计算量太大而且内存也吃不消。所以作业采用小批量随机梯度下降Mini-batch SGD。SGD的核心思想是每次只从训练集中随机抽取一小批样本比如200张用这批样本估计梯度并更新权重。虽然单次更新的方向不是最优的但胜在迭代速度快而且随机性有助于跳出局部最优。实际做下来SGD的收敛速度比BGD快得多尤其是在数据量大的情况下。学习率的选择很关键。太大会导致损失震荡甚至发散太小则收敛太慢。作业里用的是指数衰减学习率每跑完一个epoch学习率乘以一个衰减因子比如0.95。这样前期步子大快速下降后期步子小精细调整。这个策略在实战中非常常用你可以直接抄。2.3 向量化实现的必要性作业明确要求实现两种版本的损失函数和梯度计算朴素循环版和向量化版。朴素版用两层循环遍历每个样本和每个错误类别逻辑清晰但速度极慢。向量化版用矩阵运算一次性处理整个batch速度快几十倍甚至上百倍。为什么向量化这么重要因为Python的循环开销很大而NumPy的底层是C实现矩阵运算经过高度优化。举个例子计算50000张图像的损失朴素版可能要跑几分钟向量化版几秒钟就搞定。在后续调参时你需要反复跑交叉验证没有向量化根本不可行。向量化的难点在于维度对齐。你需要把分数矩阵、正确类别分数、边界值这些东西通过广播机制组合在一起稍有不慎就会得到错误的形状或者错误的结果。我后面会详细拆解向量化的每一步。3. 核心细节解析与实操要点3.1 数据预处理减均值为什么这么重要在开始实现SVM之前有一件事必须先做数据预处理。作业里要求对图像数据减去均值。具体操作是计算所有训练图像的均值得到一个3072维的向量然后每张图像都减去这个均值。为什么减均值这么重要因为SVM的评分函数是线性的如果输入特征的均值不在零附近权重矩阵的某些维度会系统性地偏大或偏小导致优化困难。减均值相当于把数据“居中”让优化地形更平滑梯度下降更容易找到好的解。实际操作时有个细节均值只能从训练集计算然后应用到训练集、验证集和测试集。不能用全部数据算均值否则会造成信息泄露。这个原则在机器学习里是铁律后面做归一化、PCA等操作时也一样。# 假设 X_train 形状为 (50000, 3072) mean_image np.mean(X_train, axis0) # 形状 (3072,) X_train - mean_image X_val - mean_image X_test - mean_image另外作业里通常还会在特征后面拼接一个全1的列作为偏置项这样权重矩阵就变成$[C, D1]$偏置被吸收进权重里实现更简洁。3.2 朴素版损失函数实现先跑通再优化朴素版的核心就是用两层循环外层遍历每个样本内层遍历每个错误类别。对于每个样本先计算所有类别的分数然后对每个错误类别计算合页损失累加得到总损失。def svm_loss_naive(W, X, y, reg): dW np.zeros(W.shape) num_classes W.shape[0] num_train X.shape[1] loss 0.0 for i in range(num_train): scores W.dot(X[:, i]) correct_class_score scores[y[i]] for j in range(num_classes): if j y[i]: continue margin scores[j] - correct_class_score 1.0 if margin 0: loss margin dW[j, :] X[:, i] dW[y[i], :] - X[:, i] loss / num_train dW / num_train loss reg * np.sum(W * W) dW 2 * reg * W return loss, dW这段代码有几个关键点需要注意。第一梯度更新只在margin大于0时进行因为合页损失在margin小于等于0时导数为零。第二正确类别的梯度是减去$X_i$错误类别是加上$X_i$这个符号不能搞反。第三正则化项的梯度是$2\lambda W$不要漏掉系数2。实操心得写朴素版时不要急着向量化先用小数据比如100张图像验证损失值是否合理。如果损失是负数或者特别大说明公式有问题。正常情况下初始损失应该接近类别数减一因为随机权重下每个错误类别的margin大约为1。3.3 向量化损失函数维度对齐是最大的坑向量化版本是作业的重头戏也是调试时间最长的部分。核心思路是用矩阵运算一次性计算所有样本的损失和梯度。我把它拆成几个步骤来讲。第一步计算所有样本的分数矩阵scores W.dot(X)形状为$[C, N]$。第二步提取每个样本正确类别的分数correct_scores scores[y, np.arange(N)]形状为$[N,]$。这里用到了NumPy的高级索引scores[y, np.arange(N)]会返回一个长度为$N$的数组第$i$个元素是scores[y[i], i]。第三步计算margin矩阵margins scores - correct_scores 1.0利用广播机制correct_scores会被自动扩展成$[C, N]$。第四步把正确类别位置的margin置零margins[y, np.arange(N)] 0。第五步用np.maximum(0, margins)得到合页损失然后求和取平均。def svm_loss_vectorized(W, X, y, reg): num_train X.shape[1] scores W.dot(X) correct_scores scores[y, np.arange(num_train)] margins np.maximum(0, scores - correct_scores 1.0) margins[y, np.arange(num_train)] 0 loss np.sum(margins) / num_train reg * np.sum(W * W) # 梯度计算 binary (margins 0).astype(float) row_sum np.sum(binary, axis0) binary[y, np.arange(num_train)] -row_sum dW binary.dot(X.T) / num_train 2 * reg * W return loss, dW梯度部分的向量化稍微绕一点。binary矩阵中margin大于0的位置为1表示这些错误类别产生了梯度。row_sum统计每个样本有多少个错误类别产生了损失。然后正确类别位置的梯度设为负的row_sum因为正确类别在所有产生损失的错误类别中都被减了一次。最后binary.dot(X.T)一次性算出所有样本的梯度贡献。注意margins[y, np.arange(num_train)] 0这一步必须在计算binary之前做否则正确类别也会被算进梯度里。这个坑我踩过当时梯度检查一直不过排查了半天才发现是顺序问题。3.4 梯度检查数值梯度是你最好的朋友写完损失和梯度函数后必须做梯度检查。方法是计算数值梯度用有限差分近似然后和解析梯度对比。如果相对误差在1e-8左右说明梯度实现正确如果误差很大说明有bug。def grad_check(f, x, y, reg, num_checks10, h1e-5): loss, grad f(x, y, reg) for _ in range(num_checks): idx np.random.randint(x.shape[0]) old_val x[idx] x[idx] old_val h loss_plus, _ f(x, y, reg) x[idx] old_val - h loss_minus, _ f(x, y, reg) x[idx] old_val num_grad (loss_plus - loss_minus) / (2 * h) rel_error abs(num_grad - grad[idx]) / max(1e-8, abs(num_grad) abs(grad[idx])) print(f相对误差: {rel_error})梯度检查时有个技巧先用小数据和小权重。如果$W$的值太大损失函数会进入饱和区数值梯度不准确。通常把$W$初始化为很小的随机值比如0.001 * np.random.randn(C, D)这样初始损失接近$C-1$梯度检查也更容易通过。4. 完整实操流程与核心环节实现4.1 训练流程搭建从数据加载到权重更新整个训练流程可以拆成几个模块数据加载与预处理、权重初始化、SGD循环、学习率衰减、验证集评估。我按照实际代码执行的顺序来讲。数据加载部分作业通常提供了CIFAR-10的加载函数返回训练集、验证集、测试集的特征和标签。拿到数据后第一件事是减均值然后拼接偏置列。权重初始化用小的随机值不要用全零否则所有类别的分数相同梯度更新会对称失效。W 0.001 * np.random.randn(num_classes, dim 1)SGD循环是核心。每个epoch里先打乱训练数据然后按batch大小切分对每个batch计算损失和梯度更新权重。学习率在每个epoch结束后乘以衰减因子。for epoch in range(num_epochs): indices np.random.permutation(num_train) for start in range(0, num_train, batch_size): end min(start batch_size, num_train) batch_idx indices[start:end] X_batch X_train[batch_idx].T y_batch y_train[batch_idx] loss, grad svm_loss_vectorized(W, X_batch, y_batch, reg) W - learning_rate * grad learning_rate * lr_decay这里有个细节X_train的形状通常是$[N, D]$但我们的损失函数期望$[D, N]$所以需要转置。这个转置操作在每次batch循环里都会做虽然有点开销但为了代码清晰可以接受。如果追求极致性能可以一开始就把整个训练集转置好。4.2 超参数调优交叉验证的实操细节超参数调优是作业里最耗时的部分。需要调的超参数主要有两个学习率和正则化强度。作业通常要求用验证集做交叉验证从一组候选值中选出最佳组合。我的做法是先粗调再细调。第一轮用较大的步长比如学习率取[1e-7, 5e-7, 1e-6, 5e-6, 1e-5]正则化强度取[1e3, 5e3, 1e4, 5e4, 1e5]跑一个5x5的网格。每个组合训练少量epoch比如5个记录验证集准确率。选出最好的区域后再在附近用更细的步长跑第二轮。learning_rates [1e-7, 5e-7, 1e-6, 5e-6, 1e-5] regularization_strengths [1e3, 5e3, 1e4, 5e4, 1e5] results {} best_val -1 best_svm None for lr in learning_rates: for reg in regularization_strengths: svm LinearSVM() svm.train(X_train, y_train, learning_ratelr, regreg, num_iters1500, batch_size200, verboseFalse) y_train_pred svm.predict(X_train) train_acc np.mean(y_train_pred y_train) y_val_pred svm.predict(X_val) val_acc np.mean(y_val_pred y_val) results[(lr, reg)] (train_acc, val_acc) if val_acc best_val: best_val val_acc best_svm svm实操心得调参时不要只看验证集准确率还要看训练集和验证集的差距。如果训练集准确率远高于验证集说明过拟合需要增大正则化强度。如果两者都很低说明欠拟合需要减小正则化或增大学习率。这个判断逻辑在后续调神经网络时同样适用。4.3 权重可视化看看模型学到了什么训练完成后一个很有意思的环节是可视化权重矩阵。把$W$的每一行对应一个类别还原成图像形状比如32x32x3然后归一化到0-255显示出来。你会看到每个类别的“模板”——比如猫的类别可能学到了一些类似猫脸轮廓的模式车的类别可能学到了车轮和车身的形状。这个可视化不仅能帮你直观理解模型还能诊断问题。如果某个类别的权重图看起来像随机噪声说明该类别的训练样本太少或者学习率不合适。如果所有类别的权重图都很相似说明模型没有学到区分性特征可能需要调整正则化强度。def visualize_weights(W, num_classes10): W W[:, :-1] # 去掉偏置列 W W.reshape(num_classes, 3, 32, 32).transpose(0, 2, 3, 1) W_min, W_max np.min(W), np.max(W) for i in range(num_classes): plt.subplot(2, 5, i 1) plt.imshow((W[i] - W_min) / (W_max - W_min)) plt.axis(off) plt.show()4.4 测试集评估与结果分析最后一步是在测试集上评估最终模型的准确率。正常情况下调好超参数的SVM在CIFAR-10上的准确率大约在35%-40%之间。这个数字看起来不高但考虑到SVM是线性分类器而CIFAR-10的图像复杂度很高这个结果已经合理了。后续的神经网络作业会把这个数字提升到50%以上你可以对比着看。评估时要注意测试集只能用一次。不要反复在测试集上调参否则测试集就变成了验证集评估结果会偏乐观。正确的做法是用验证集选超参数用测试集做最终评估评估完就结束不要再根据测试结果改模型。5. 常见问题与排查技巧实录5.1 损失值异常从NaN到不收敛做这个作业时最常见的报错就是损失值变成NaN或者一直不下降。我整理了一个排查表按可能性从高到低排列。问题现象可能原因排查方法解决方案损失为NaN学习率太大打印每次迭代的损失减小学习率10倍损失不下降梯度符号错误梯度检查检查dW的加减号损失下降后震荡学习率偏大观察损失曲线减小学习率或加衰减损失下降太慢学习率太小对比不同学习率增大学习率验证集准确率低过拟合对比训练/验证准确率增大正则化强度训练集准确率低欠拟合检查损失是否收敛减小正则化或增加迭代梯度检查是排查梯度问题的终极手段。如果数值梯度和解析梯度的相对误差大于1e-6基本可以确定有bug。常见的bug包括忘记除以样本数、正则化梯度漏乘系数、正确类别的梯度符号搞反、margin置零的顺序错误。5.2 向量化实现的维度陷阱向量化实现最容易出错的地方是维度不匹配。我总结了几条经验第一时刻打印形状。在关键步骤后面加print(scores.shape)确保每一步的形状符合预期。比如scores应该是$[C, N]$correct_scores应该是$[N,]$margins应该是$[C, N]$。第二广播机制要小心。scores - correct_scores能正常工作是因为NumPy会自动把$[N,]$广播成$[1, N]$再扩展成$[C, N]$。但如果correct_scores的形状是$[N, 1]$广播结果就会变成$[C, N]$和$[N, 1]$的广播得到$[C, N]$但数值完全错误。所以提取正确类别分数时一定要用scores[y, np.arange(N)]而不是scores[y]。第三高级索引返回的是副本。margins[y, np.arange(N)] 0是原地修改但如果你写m margins[y, np.arange(N)]然后修改m不会影响margins。这个坑在调试时很隐蔽。5.3 学习率衰减的策略选择学习率衰减是SGD训练中的关键技巧。作业里通常要求用指数衰减但衰减因子的选择有讲究。衰减太快比如0.5学习率很快变得很小后期几乎不更新衰减太慢比如0.99学习率一直很大损失在最优值附近震荡。我的经验是衰减因子取0.9到0.95之间比较合适。如果训练epoch数多比如20个可以用0.9如果epoch数少比如10个用0.95。另外初始学习率要配合衰减因子调整——如果衰减快初始学习率可以大一点如果衰减慢初始学习率要小一点。还有一个技巧是阶梯衰减每跑5个epoch学习率乘以0.5。这种方式比指数衰减更激进适合训练后期需要精细调整的场景。不过作业里用指数衰减就够了不用搞太复杂。5.4 内存与速度优化虽然SVM的向量化实现已经很快了但在调参时还是可能遇到速度瓶颈。几个优化技巧第一预转置数据。X_train.T在每次batch循环里都会执行如果提前转置好存成X_train_T可以省下不少时间。第二用float32代替float64。NumPy默认用float64但float32的精度对于这个任务足够了而且内存占用减半运算速度更快。第三减少不必要的拷贝。比如X_batch X_train_T[:, batch_idx]会创建一个副本如果batch很大这个拷贝开销不小。可以用np.ascontiguousarray确保内存连续提高缓存命中率。实操心得调参时先用小数据集比如5000张训练图像快速筛选超参数范围选出候选区域后再用全量数据精细调。这样能把调参时间从几小时压缩到几十分钟。6. 从SVM到神经网络的延伸思考做完这个作业后我对SVM和神经网络的关系有了更深的理解。SVM的评分函数$s Wx$其实就是一个单层线性分类器而神经网络就是在它基础上加了非线性激活函数和多个隐藏层。SVM的合页损失和神经网络的交叉熵损失虽然形式不同但目标都是一样的让正确类别的分数高于错误类别。更重要的是SVM的向量化实现和梯度推导为后续实现神经网络的反向传播打下了基础。你在SVM里学的链式法则、梯度检查、向量化技巧在神经网络里会原封不动地用到。所以如果你现在觉得SVM的梯度推导很痛苦相信我熬过去之后神经网络的反向传播会变得顺理成章。最后分享一个我在调参时发现的小技巧把学习率和正则化强度画成热力图。横轴是学习率的对数纵轴是正则化强度的对数颜色表示验证集准确率。这样一眼就能看出最佳区域在哪里比看表格直观得多。这个技巧在后续调神经网络时同样好用你可以试试。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →