1. 从图像分类说起:为什么线性分类器是CNN绕不开的前置课
1. 从图像分类说起:为什么线性分类器是CNN绕不开的前置课
如果你准备用Python手写一个卷积神经网络,但还没搞懂线性分类器,那我建议你先停一下。CNN最后一层几乎都是线性分类器(通常搭配Softmax),前面的卷积、池化只是在“提取特征”,真正做出“判断”的,还是那个最简单的矩阵乘法加偏置。这一章我们就把这块基石砸实。
很多初学者一开始就抱着PyTorch或者TensorFlow撸CNN,调几个卷积层、池化层,模型也能跑,但一遇到“为什么最后一层是nn.Linear”“为什么损失函数要选CrossEntropyLoss”“梯度是怎么传回来的”这类问题就卡壳。根源就在于线性分类器这个环节没吃透。
线性分类器做的事情可以概括成一句话:给定一张图片,把它展平成向量,通过与权重矩阵相乘加上偏置,得到每个类别的得分,得分最高的类别就是预测结果。写成数学式就是:
f(x, W, b) = Wx + b
其中 x 是输入图像展平后的向量(比如一张 32x32x3 的彩色图,展平后就是 3072 维),W 是权重矩阵(形状为 C x D,C 是类别数,D 是输入维度),b 是偏置向量(长度 C)。输出是一个长度为 C 的向量,每个元素代表该类的得分。
听着很简单对吧?但就是这样一个简单的式子,背后牵扯出得分函数的设计、损失函数的选取、梯度推导与数值校验、参数优化策略等等一整套深度学习的基础方法论。这些方法论在CNN里会换着花样出现,但底层逻辑完全一致。
我在带项目时经常强调:线性分类器是深度学习中少有的“完全可控”的模块。它的前向计算、反向传播都非常直观,你可以一行行手动推导梯度,用数值梯度校验解析梯度的正确性,然后观察训练曲线。这些基本功练扎实了,后面理解CNN的反向传播就是水到渠成的事,因为CNN的BP本质就是“复合函数求导的链式法则 + 稀疏连接带来的索引技巧”。
这一章的内容适合以下几类读者:
- 正在学“CS231N风格”的深度学习入门教程,需要Python实现线性分类器的人;
- 已经会用PyTorch但想深入理解底层原理,尤其想搞清楚Softmax和CrossEntropy背后的计算细节的人;
- 打算自己从零写CNN,需要一个可靠的特征分类模块作为最后落地层的人。
我会按照“设计思路 → 损失函数 → 梯度计算 → 完整训练流程 → 常见坑”这条线来讲,代码全部用Python + NumPy实现,不依赖任何深度学习框架。这样你既看清了底层原理,也可以直接把这段代码嵌到你手写的CNN里当分类头用。
2. 得分函数与参数设计:从“像素矩阵”到“类别得分”
2.1 为什么是线性模型?先搞清楚它的能力边界
很多人第一次接触线性分类器会犯嘀咕:图像分类本质上是高度非线性的问题,线性模型怎么可能分得好?这个疑问很合理。
线性分类器对输入做的是“每个像素独立加权求和”的操作,它只能学到“某个位置的某个颜色通道对某个类别有多大的正/负贡献”。比如在某个数据集里,如果“图像的左上角偏蓝”是“轮船”的一个线索,那权重矩阵里就会在对应位置产生一个较大的正值。但是,线性分类器学不到“某个物体出现在图像的左上角”这种位置无关的语义——它天生假定“每个特征与类别的关系是固定不变的”。
这既是限制,也是理解深度学习的切入点。CNN的思路就是:先用卷积层把原始像素转换成高层次的语义特征(比如“有没有轮子”“有没有羽毛”),这些特征和类别之间的关系更接近线性可分,然后末端再用线性分类器做决策。所以线性分类器在CNN里承担的是“最终决策者”的角色,前面所有层都在努力让这个线性决策变得可行。
2.2 权重矩阵与偏置的维度推导
我们拿一个具体的例子来算一遍。
假设数据集是 CIFAR-10 风格的:图像大小 32x32x3,类别数 10。那么:
- 输入向量 x 的维度 D = 32 * 32 * 3 = 3072
- 权重矩阵 W 的形状 = [10, 3072]
- 偏置向量 b 的形状 = [10]
计算过程:将 3072 维的 x 与 W 做矩阵乘法(W.dot(x)),得到一个 10 维向量,再加上 b,就是每个类别的得分。
这里有一个很多人容易忽略的点:为什么把 W 放在左边,x 放在右边?约定W.shape = [C, D]而不是[D, C],是因为这样写得分向量就是 Wx,直接得到一个 C 维列向量,在代码里用W.dot(x)或x.dot(W.T)都可以,但前者更符合数学表达习惯。这个约定在反向传播推导时非常有用——梯度矩阵的形状会与 W 完全一致,更新时直接相减。
2.3 偏置融合技巧:把 b 并进 W 里
实操中有一个很常见的做法是把偏置 b 合并到权重矩阵 W 中,从而只需要维护一个参数矩阵。
做法是:给输入向量 x 额外增加一个恒为 1 的维度(通常放在末尾),同时给 W 增加一个对应的列。比如 x 从 3072 维变成 3073 维,W 从 [10, 3072] 变成 [10, 3073]。这样原来Wx + b就简化成了Wx,最后一列 W 对应的就是原来 b 的值。
import numpy as np def add_bias_to_input(X): # X: 形状为 (N, D),N是样本数,D是特征维度 # 返回 (N, D+1),最后一列全为1 N = X.shape[0] bias_col = np.ones((N, 1)) return np.hstack([X, bias_col]) # 示例:2个样本,每个3维特征 X_example = np.array([[0.5, 1.2, -0.3], [-0.8, 0.4, 2.1]]) X_with_bias = add_bias_to_input(X_example) print(X_with_bias.shape) # (2, 4)为什么推荐这个技巧?两个原因:
- 代码统一:不管有没有偏置,都是
X.dot(W.T)一种写法; - 梯度的更新规则统一:更新 W 时不需要区别对待偏置那一列,反正它跟着 X 末尾的 1 一起参与梯度计算。
代价是显式减少了代码语义上的可读性,所以在注释里要写清楚。我自己在写手写CNN框架时反而更推荐“显式保留 b”的风格——因为后续要跟卷积层的参数管理方式对齐。这里是个人偏好问题,核心是搞清楚维度对应关系。
2.4 数据集预处理和权重初始化
线性分类器的训练对数据预处理和初始化非常敏感,这里先说结论,后面在训练流程里再详细展开:
- 数据预处理:均值减除(mean subtraction)和归一化(normalization)是必须的。比如把每个通道的像素值从 [0, 255] 缩放到 [-1, 1] 或 [0, 1]。如果不做,权重更新会非常不稳定,梯度在某些维度上可能过大,导致损失震荡甚至发散。
- 权重初始化:不能全零。如果 W 初始化为零矩阵,那么所有样本的得分都相同,梯度对所有样本也相同,参数更新后每一行仍然相同——这就是“对称性破缺”问题。一般用小随机数初始化,比如
W = np.random.randn(C, D) * 0.01,让权重足够小,确保初始得分分布比较均匀。
def init_weights(C, D, bias=True): """ 初始化权重矩阵。 C: 类别数 D: 输入特征维度 返回 W, b;如果 bias=False,b 为 None """ W = np.random.randn(C, D) * 0.01 b = np.zeros(C) if bias else None return W, b这里np.random.randn(C, D) * 0.01的意思是把权重的标准差控制在0.01附近。太大的初始化会让初始损失巨大,Softmax的梯度在反向传播时可能饱和;太小则训练变慢。在深度网络里初始化策略更讲究(何恺明的He初始化等),但在线性分类器这里,小随机数完全够用。
3. 损失函数:怎么量化“得分有多差”
有了得分函数,我们需要一个数值来衡量当前权重的好坏。这个数值就是损失函数。线性分类器阶段有两个经典损失:多类SVM损失(Hinge Loss)和 Softmax + 交叉熵损失。CNN最后分类层90%用的是后者,但前者在理解“边界”和“分类间隔”上很有帮助,所以两个我都讲。
3.1 多类SVM损失:被“安全边界”驱动的分类器
多类SVM损失函数的思想来自支持向量机的最大间隔原则。对于第 i 个样本,真实类别是 y_i,分类器对第 j 类别的得分是 s_j,则损失定义为:
L_i = Σ_{j ≠ y_i} max(0, s_j - s_{y_i} + Δ)
其中 Δ 是安全边界(margin),通常取 1。直观理解:真实类别的得分应该比其他所有类别的得分至少高出 Δ,如果差值不足 Δ 甚至反超,就要累计损失。
举例:某样本真实类别是“猫”,三个类别的得分是 [3.2, 5.1, -1.7],真实类别索引是 0,Δ = 1。那么损失就是:
- 对 j=1(狗):max(0, 5.1 - 3.2 + 1) = max(0, 2.9) = 2.9
- 对 j=2(船):max(0, -1.7 - 3.2 + 1) = max(0, -3.9) = 0
总损失 L_i = 2.9。如果真实类别的得分足够高(比其他类高出至少 Δ),损失就是 0;否则产生正损失,驱动权重更新。
下面是Python实现:
def svm_loss(scores, y): """ scores: 形状 (N, C),每行是第i个样本的各类别得分 y: 形状 (N,),真实类别索引 delta: 边界值,默认1 返回: 所有样本的平均损失 """ N = scores.shape[0] correct_scores = scores[np.arange(N), y] # 提取真实类别得分 margins = np.maximum(0, scores - correct_scores[:, np.newaxis] + 1.0) # 真实类别自己的那一项不需要计入损失,需要排除 margins[np.arange(N), y] = 0 loss = np.sum(margins) / N return loss注意上面correct_scores[:, np.newaxis]的形状变换:从 (N,) 变成 (N, 1),这样与 (N, C) 的 scores 相减时,NumPy会做广播,得到每个样本的每一类得分与真实类别得分的差值矩阵。
3.2 Softmax + 交叉熵损失:输出变成概率分布
Softmax 的核心思想是将得分向量转换为概率分布。第 i 个样本、第 j 类的概率为:
P(y = j | x_i) = exp(s_j) / Σ_k exp(s_k)
为了防止 exp 溢出,通常把每个得分减去该样本的最大得分(数值稳定性),即:
s_j' = s_j - max_k(s_k)
这样做不会改变概率结果,因为分子分母同时除以了 exp(max)。这个技巧在实现 softmax 时几乎是必须的。
交叉熵损失的表达式是:
L_i = -log(P(y = y_i | x_i))
直观理解:真实类别的概率越接近 1,损失越小;越接近 0,损失越大。
def softmax_loss(scores, y): """ 计算 Softmax + 交叉熵损失。 scores: (N, C) y: (N,) 返回: 平均损失, 以及Softmax概率矩阵 probs(用于梯度计算) """ N = scores.shape[0] # 数值稳定性处理 shifted = scores - np.max(scores, axis=1, keepdims=True) exp_scores = np.exp(shifted) probs = exp_scores / np.sum(exp_scores, axis=1, keepdims=True) # 提取真实类别的概率 correct_log_probs = -np.log(probs[np.arange(N), y] + 1e-9) # 加小常数防log(0) loss = np.sum(correct_log_probs) / N return loss, probs这里加1e-9是防止概率出现0导致log(0) = -inf,这在数值计算里是很常见的防护手段。
3.3 两个损失函数的对比与选择
| 对比维度 | 多类SVM损失 | Softmax + 交叉熵 |
|---|---|---|
| 输出解释 | 得分,不构成概率分布 | 概率分布,具有可解释性 |
| 对得分差异的敏感度 | 只要满足边界,细节得分不敏感 | 始终对概率变化敏感 |
| 优化目标 | 真实类别比其它类高 Δ | 真实类别概率尽可能接近 1 |
| 在CNN中的应用 | 早期模型用过,现在少见 | 分类层标配 |
我个人建议:在自写CNN框架的最后一层,直接采用Softmax + 交叉熵。它不仅天然适配交叉熵损失的梯度形式(后面会看到梯度化简后极其优雅),而且概率输出方便做置信度估计和后续的模型融合等操作。SVM损失在讲解分类间隔时有用,但工程上已经很少作为CNN的分类损失使用。
4. 梯度计算与数值校验:反向传播的小热身
4.1 手推Softmax + 交叉熵的梯度
这是很多教程里直接给结论但不讲过程的部分,我在这里把推导尽量写清楚。设第 i 个样本的得分向量为 s = [s_1, ..., s_C],softmax 输出概率向量为 p = [p_1, ..., p_C],真实类别 y_i 的 one-hot 向量为 t(只在真实类别处为1,其余为0),交叉熵损失为:
L_i = -Σ_k t_k log(p_k)
对得分 s_j 求偏导。因为 p_k 依赖所有得分(分母是求和),需要分两种情况讨论。
先求 ∂p_k / ∂s_j。记分母 Z = Σ_m exp(s_m)。
如果 k = j: ∂p_j / ∂s_j = (exp(s_j) * Z - exp(s_j) * exp(s_j)) / Z^2 = p_j - p_j^2 = p_j(1 - p_j)
如果 k ≠ j: ∂p_k / ∂s_j = (0 - exp(s_k) * exp(s_j)) / Z^2 = -p_k p_j
然后利用链式法则:
∂L_i / ∂s_j = -Σ_k (t_k / p_k) * (∂p_k / ∂s_j)
代入上面两种情况的偏导:
- 对 k = j:贡献为 -t_j / p_j * p_j(1 - p_j) = -t_j(1 - p_j)
- 对 k ≠ j:贡献为 -Σ_{k≠j} t_k / p_k * (-p_k p_j) = Σ_{k≠j} t_k p_j
合并(因为 Σ_k t_k = 1):
∂L_i / ∂s_j = -t_j(1 - p_j) + (Σ_{k≠j} t_k) p_j = -t_j + t_j p_j + (1 - t_j) p_j = p_j - t_j
Wow,结果出乎意料地简洁:梯度就是概率向量减去one-hot标签向量。这就是交叉熵损失在深度学习中如此流行的巨大优势——梯度表达式简洁优雅,实现起来几乎不可能出错。
向量化形式:∂L_i / ∂s_i = p_i - t_i。
def softmax_gradient(scores, y): """ 计算 softmax + cross-entropy 的梯度。 scores: (N, C) y: (N,) 返回: dscores,形状与 scores 相同 """ N = scores.shape[0] # 先算概率 shifted = scores - np.max(scores, axis=1, keepdims=True) exp_scores = np.exp(shifted) probs = exp_scores / np.sum(exp_scores, axis=1, keepdims=True) # 形成 one-hot 矩阵 one_hot = np.zeros_like(probs) one_hot[np.arange(N), y] = 1.0 # 梯度 = (probs - one_hot) / N dscores = (probs - one_hot) / N return dscores注意上面最后除了 N,因为损失是所有样本的平均损失,梯度也要做平均。
4.2 SVM损失的梯度
多类SVM损失的梯度相对繁琐,但手动推一遍也很有价值。对第 i 个样本,如果第 j 类(j ≠ y_i)得分超过真实类别得分加 Δ,则该样本对 s_j 的梯度是 +1,对 s_{y_i} 的梯度是 -1(因为损失里有一项 max(0, s_j - s_{y_i} + Δ),对 s_{y_i} 求导是 -1)。也就是:
∂L_i / ∂s_j = 1(如果 max(0, s_j - s_{y_i} + Δ) > 0 且 j ≠ y_i) ∂L_i / ∂s_{y_i} = -(满足条件的类别数量) ∂L_i / ∂s_j = 0(其他情况)
代码实现:
def svm_gradient(scores, y): N, C = scores.shape dscores = np.zeros_like(scores) for i in range(N): correct = scores[i, y[i]] for j in range(C): if j == y[i]: continue margin = scores[i, j] - correct + 1.0 if margin > 0: dscores[i, j] += 1.0 dscores[i, y[i]] -= 1.0 dscores /= N return dscores虽然这个双层循环在数据量大时很慢,但在理解阶段非常直观。后面可以向量化优化,不过一般也不会单独用SVM损失做CNN分类头,所以这里知道思想即可。
4.3 数值梯度校验:确保你的公式没推错
解析梯度公式容易推错,一个靠谱的验证手段是数值梯度。数值梯度的原理很简单:用导数的定义近似求梯度。
f'(x) ≈ (f(x + h) - f(x - h)) / (2h)
其中 h 取一个很小的值,比如 1e-5。对权重矩阵 W 的每个元素,都做一次这样的扰动,计算损失变化,得到近似的偏导值,然后与解析梯度比较。如果两者的相对误差在 1e-6 级别,说明解析梯度正确。
def numeric_gradient(loss_func, W, X, y, h=1e-5): """ 对权重矩阵 W 做数值梯度近似。 loss_func: 接受 (W, X, y) 并返回标量损失的函数 """ grad = np.zeros_like(W) it = np.nditer(W, flags=['multi_index']) while not it.finished: idx = it.multi_index old_val = W[idx] W[idx] = old_val + h loss_plus = loss_func(W, X, y) W[idx] = old_val - h loss_minus = loss_func(W, X, y) grad[idx] = (loss_plus - loss_minus) / (2 * h) W[idx] = old_val it.iternext() return grad def check_gradient(analytic_grad, numeric_grad): numerator = np.linalg.norm(analytic_grad - numeric_grad) denominator = np.linalg.norm(analytic_grad) + np.linalg.norm(numeric_grad) rel_err = numerator / denominator return rel_err用一个小规模数据集(比如 100 个样本、10个类别)做校验,一般相对误差在 1e-7 到 1e-6 之间都是正常的。这个习惯请务必养成——后面写CNN的反向传播时,数值梯度校验是排查bug的核心工具。我每次写完一个层的backward,都会立即写数值梯度测试,通过之后再进入下一个模块。
5. 完整训练流程:从零写一个可运行的线性分类器
5.1 数据准备与预处理
我用一个小型模拟数据集来演示整体流程。假设类别数是3,输入维度是4,这样训练速度快,方便观察每一步的数值变化。
def generate_dummy_data(N=300, D=4, C=3, seed=42): np.random.seed(seed) X = np.random.randn(N, D) # 构造一个近似线性可分的标签分布 y = np.random.randint(0, C, size=N) # 给X加上一些类别倾向,让特征带有一定区分度 X[:100, :] += 0.5 * (np.eye(C)[y[:100]] @ np.ones((C, D))) # 简单偏移 return X, y数据量小没关系,这里的目的是跑通整个训练闭环。预处理阶段一定要做均值减除和归一化:
def preprocess(X): # 每个特征维度上减去均值,再除以标准差 mean = np.mean(X, axis=0) std = np.std(X, axis=0) + 1e-8 # 防止除零 X_norm = (X - mean) / std return X_norm, mean, std注意:mean和std必须在训练集上计算,验证集和测试集也用训练集的统计量做变换,而不是重新计算。这是数据预处理里特别容易犯的错误,相当于让模型在测试时“偷看”了测试集的分布。
5.2 训练循环:SGD参数更新
参数更新采用随机梯度下降(SGD)。小批量(mini-batch)的方式是每次从中抽取一部分样本计算梯度,更新参数。
def train_linear_classifier(X_train, y_train, X_val, y_val, learning_rate=0.1, num_epochs=2000, batch_size=64, reg_strength=1e-3): N, D = X_train.shape C = len(np.unique(y_train)) # 初始化 W = np.random.randn(C, D) * 0.01 b = np.zeros(C) # 记录训练历史 train_loss_history = [] val_acc_history = [] num_samples = X_train.shape[0] for epoch in range(num_epochs): # 小批量抽样 indices = np.random.choice(num_samples, batch_size, replace=False) X_batch = X_train[indices] y_batch = y_train[indices] # 前向计算得分 scores = X_batch.dot(W.T) + b # 计算损失和梯度 loss, probs = softmax_loss(scores, y_batch) dscores = softmax_gradient(scores, y_batch) # 从dscores反推W和b的梯度 # scores = X.dot(W.T) + b # dscores 对 scores 的梯度已知,对W的梯度是 dscores.T.dot(X_batch) dW = dscores.T.dot(X_batch) db = np.sum(dscores, axis=0) # L2正则化梯度(可选) dW += reg_strength * W # 更新参数 W -= learning_rate * dW b -= learning_rate * db # 记录 if epoch % 100 == 0: train_loss_history.append(loss) val_acc = compute_accuracy(X_val, y_val, W, b) val_acc_history.append(val_acc) print(f"epoch {epoch}, loss = {loss:.4f}, val_acc = {val_acc:.4f}") return W, b, train_loss_history, val_acc_history注意dW的推导:scores = X_batch.dot(W.T) + b,对 W 而言,scores的每一行是W与该行 X 的内积。链式法则下,dW的表达式是dscores.T.dot(X_batch)。为什么是转置相乘而不是反过来?可以从维度上验证:dscores形状 (batch_size, C),X_batch形状 (batch_size, D),dscores.T.dot(X_batch)得到 (C, D) 的梯度矩阵,正好与 W 同形。
5.3 学习率与正则化强度:两个最关键的超参数
在小规模数据集上,学习率的选择直接决定模型能否收敛。我实测的经验值:
- 学习率
learning_rate在 0.1 到 1.0 之间通常能较快收敛(数据归一化后); - 学习率大于 1.0 容易震荡;
- 学习率小于 0.001 时训练速度极慢,1000轮可能还在原地打转。
正则化强度reg_strength控制权重的大小,防止过拟合。在线性分类器里,L2正则化项是reg_strength * np.sum(W ** 2),对应梯度里的reg_strength * W。如果正则化强度太大,模型偏向“简单解”,训练精度和验证精度都会下降;太小则可能过拟合。
| 参数 | 范围建议 | 调参策略 |
|---|---|---|
| learning_rate | log空间:1e-3 ~ 1.0 | 先粗扫,在看准确率上升或下降的趋势 |
| reg_strength | log空间:1e-5 ~ 0.1 | 先固定学习率,再扫描正则强度 |
| batch_size | 16 ~ 256 | 小数据集多用64,大数据集多用128或256 |
我的经验是:先用极小的 batch(比如16)和一个中等学习率,跑 500 轮观察损失曲线是否稳定下降,确认没有NaN或爆炸后再放大规模。损失曲线是最重要的信号,比验证集准确率更敏感。
5.4 验证与可视化:别只盯着准确率看
训练完成后,在验证集上计算准确率,同时可以画出每类的混淆矩阵。线性分类器在小数据集上很容易过拟合,所以务必将训练集准确率和验证集准确率并列观察:
def compute_accuracy(X, y, W, b): scores = X.dot(W.T) + b preds = np.argmax(scores, axis=1) return np.mean(preds == y)如果训练集准确率接近1.0而验证集准确率明显低于训练集,说明过拟合已发生。这时候优先加大reg_strength,而不是减少网络容量(线性分类器也没多少容量可减,所以正则化几乎是唯一手段)。
6. 常见问题与排查技巧实录
6.1 loss变成NaN了怎么办
这是我在带项目时最常被问到的问题。loss变成NaN通常有三个原因:
- 学习率太大,参数更新步长过猛,导致分数量级溢出;
- softmax计算出现
exp溢出,尤其在得分很大时; - 数据未归一化,某个特征维度数值极大,梯度爆炸。
排查顺序:先检查数据是否有异常大值(打印X.max(), X.min()),再降低学习率(除以10试一次),最后确认softmax实现里有没有做最大值平移。
# 安全版softmax(带最大值平移) def stable_softmax(scores): shifted = scores - np.max(scores, axis=1, keepdims=True) exp_scores = np.exp(shifted) probs = exp_scores / np.sum(exp_scores, axis=1, keepdims=True) return probs6.2 损失下降了但验证集准确率不涨
出现这种“假学习”通常是因为正则化太强,把权重压得太小,模型只能学到均值解。把reg_strength调小(比如从1e-3调到1e-5),或者直接打印一下权重矩阵的范数,如果范数在训练过程中几乎不变,多半是正则化压制了模型。
另一个可能是学习率在后期过小,参数更新进入平台期。这时候可以尝试学习率衰减策略,或者干脆用一个元优化器(手动调参)在不同阶段切换学习率。在自写代码阶段,我建议先固定学习率,跑通流程后再考虑衰减,避免一次引入太多变量。
6.3 SVM损失和Softmax损失表现差异
在同一个数据集上,SVM损失训练出来的分类边界通常是“硬间隔”,而Softmax是“软概率”。当你将两类损失都实现好后,可以用小样本对比它们:
- Softmax在训练过程中,概率分布会逐渐集中于真实类别,损失变化更平滑;
- SVM损失的loss可能出现平台期(margin足够大,loss为0),此时虽然loss为0,但模型可能仍有分类错误——因为margin和准确率不是严格等价。
所以如果你的验证指标是准确率,不要只看loss是否为0。loss为0只代表训练样本满足间隔条件,不代表样本被完全正确分类。
6.4 数值梯度校验pass但训练不收敛
这种情况多出在参数更新环节。检查三件事:
dW是否除过batch_size?如果没有,梯度会被放大N倍,导致参数更新不稳定;- 正则化梯度有没有加入
dW?如果损失函数里加了正则项但梯度没加,数值梯度校验一定不通过; - 学习率是否合理?数值梯度校验通过只能证明梯度正确,学习率过大仍可能让损失发散。
6.5 手写代码的调试策略
我给项目组定了三条调试纪律,现在分享出来:
- 每次修改代码后,先在小规模数据上(比如20个样本、3个类别)跑一次数值梯度校验,确保梯度仍然正确;
- 训练过程中每100轮打印一次
loss, W_norm, grad_norm,观察三者变化趋势。W范数应该缓慢上升或稳定,grad范数应该逐渐减小——如果grad范数反而增大,大概率学习率过大; - 不要同时调整多个超参数。一次只动一个,记录前一轮的结果再改,否则你永远不知道是哪个改动导致的问题。
7. 收尾:一个很值得养成的习惯
这块内容做完后,建议你把“线性分类器”封装成一个独立的类,接口设计成fit(X, y)和predict(X),这样后面写CNN时,只需要把CNN提取的特征传给这个分类器做最终判断即可。我在实际项目里就是这样干的:把特征提取和分类决策解耦,然后可以单独测试分类头的表现,甚至先训练一个线性分类器当作和CNN对比的baseline——这个baseline的价值远比想象中大,它能告诉你你的特征提取层到底有没有比线性模型学到的特征更有区分度。
另一个建议是:在训练好的权重矩阵 W 上做可视化。把权重按像素网格还原成图像形状(如果输入是图像),每一类对应的权重图像会显示出该类的“模板”效果。这是一种非常直观的调试方式,能让你一眼看出模型有没有学到有意义的模式。我第一次做这事的时候,看到每个类别的权重图像确实出现了类似该类原型的轮廓,那种对“模型在学什么”的实感是阅读再多文档也换不来的。
线性分类器到这里就聊透了。下一步你可以带着这套“得分→损失→梯度→更新”的闭环框架去啃卷积层的实现,会发现很多东西都是熟悉的套路——卷积层也不过是一个新奇的、共享权重的“特征变换器”,变换完之后的分类决策,还是我们这一章反复打磨的那套线性操作。