尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从Softmax与交叉熵损失原理到NumPy实现:掌握分类任务核心梯度推导

从Softmax与交叉熵损失原理到NumPy实现:掌握分类任务核心梯度推导 1. 项目概述从公式到代码的深度穿越如果你正在入门深度学习尤其是分类任务那么“Softmax 交叉熵损失”这个组合对你来说就像学开车必须先学会踩油门和刹车一样基础。但很多人可能只是调用了torch.nn.CrossEntropyLoss()或者tf.nn.softmax_cross_entropy_with_logits对背后发生了什么尤其是那个至关重要的梯度反向传播过程总感觉隔着一层毛玻璃。这个项目就是要把这层毛玻璃彻底砸碎。我们不满足于当一个“调包侠”而是要亲手从数学公式开始一步步推导出 Softmax 函数、交叉熵损失以及最核心的——损失函数关于网络原始输出logits的梯度。最后我们不依赖任何深度学习框架的自动微分用纯 NumPy 实现整个前向传播和反向传播过程。这个过程会让你对神经网络训练中最基础的环节产生肌肉记忆般的理解未来无论是调试损失不下降、梯度爆炸消失还是自定义新的损失函数你都会有十足的底气。2. 核心概念拆解为什么是它们俩在动手之前我们得先搞清楚为什么分类问题普遍采用 Softmax 交叉熵损失这个黄金组合。这背后是严格的数学逻辑和工程实践的完美结合。2.1 Softmax将分数转化为概率神经网络的最后一层通常是一个全连接层它会输出一组实数我们称之为“logits”。这些 logits 可正可负可大可小但它们本身并不是概率——概率要求每个值在0到1之间并且所有值之和为1。Softmax 函数就是干这个的它是一个“多分类”的激活函数负责将这组任意的 logits 转换成一个合法的概率分布。它的公式看起来很简单 $$S_i \frac{e^{z_i}}{\sum_{j1}^{C} e^{z_j}}$$ 其中$z_i$ 是第 i 个类别的 logit 值$C$ 是总类别数$S_i$ 就是该样本属于第 i 类的预测概率。为什么用指数函数 $e$这是 Softmax 的一个关键设计。指数函数 $e^x$ 有两个绝佳的性质单调性它严格单调递增。这意味着 logits 大的经过 Softmax 后概率依然大保持了原始分数的相对大小顺序。非负性与放大差异$e^x$ 永远大于0确保了概率非负。更重要的是它对大的正数响应剧烈对负数或小的正数则进行压制。这相当于放大了 logits 之间的差异让模型对“最可能”的类别更有信心。你可以想象如果两个 logits 分别是 10 和 9差值只有1但 $e^{10}$ 和 $e^{9}$ 的比值是 $e^1 \approx 2.718$差异被显著放大了。一个必须警惕的数值稳定性问题 直接套用上面这个公式在计算机里是危险的。因为 $e^{z_i}$ 增长极快如果某个 $z_i$ 很大比如100$e^{100}$ 会变成一个天文数字导致浮点数上溢出inf。为了解决这个问题我们使用一个经典的数学技巧减去最大值。 $$S_i \frac{e^{z_i - \text{max}(z)}}{\sum_{j1}^{C} e^{z_j - \text{max}(z)}}$$ 因为 $e^{z_i - \text{max}(z)} e^{z_i} / e^{\text{max}(z)}$分子分母同时除以 $e^{\text{max}(z)}$结果在数学上是完全等价的。但这样做之后指数部分的最大值变成了0$e^01$其他都是负数或零$e^{\text{负数}}$ 是一个小于1的数完美避免了溢出。这是实现时必须做的第一步。2.2 交叉熵损失衡量概率分布的差距得到了预测概率分布 $S$我们如何衡量它和真实标签分布 $Y$ 的差距呢这里就用到了交叉熵Cross-Entropy。在分类任务中我们通常使用“one-hot”编码来表示真实标签。比如有3个类别真实类别是第2类那么 $Y [0, 1, 0]$。这是一个“标准答案”概率分布正确的类别概率为1其他为0。交叉熵衡量的是用预测分布 $S$ 去描述真实分布 $Y$ 所需要的“平均信息量”。信息论告诉我们如果两个分布完全一致交叉熵最小。其公式为 $$H(Y, S) -\sum_{i1}^{C} Y_i \log(S_i)$$由于 $Y$ 是 one-hot 向量只有真实类别 $t$ 对应的 $Y_t 1$其他都为0。因此上面的求和公式瞬间被简化了 $$L -\log(S_t)$$ 其中$t$ 是样本的真实类别索引$S_t$ 是 Softmax 后模型预测该样本属于真实类别的概率。这个形式极其优美也揭示了交叉熵损失的本质它只关心模型对正确类别的预测概率 $S_t$。$S_t$ 越大越接近1$-\log(S_t)$ 就越小接近0损失越小反之如果模型预测正确类别的概率很小比如0.1那么 $-\log(0.1) \approx 2.3$损失就会很大。为什么不用均方误差MSE这是一个经典面试题。对于分类问题MSE比如 $\sum (Y_i - S_i)^2$并不是一个好选择。优化效率交叉熵损失配合 Softmax其梯度形式非常干净我们马上会推导能让模型在训练初期快速更新。而 MSE 的梯度在概率接近0或1时会变得非常小导致学习缓慢也就是所谓的“梯度饱和”问题。概率解释交叉熵直接来源于最大似然估计它与 Softmax 输出的概率表示在数学上是自洽的。最小化交叉熵等价于最大化样本的似然函数这是一个非常坚实的统计学基础。所以Softmax 负责产出概率交叉熵负责用信息论的方式衡量这个概率的好坏两者是天作之合。3. 梯度推导反向传播的灵魂这是整个项目的核心难点也是理解神经网络如何学习的关键。我们的目标是求出损失函数 $L$ 关于网络原始输出 $z_j$即 Softmax 的输入 logits的梯度 $\frac{\partial L}{\partial z_j}$。我们有Softmax 函数$S_i \frac{e^{z_i}}{\sum_{k} e^{z_k}}$交叉熵损失$L -\log(S_t)$其中 $t$ 是真实类别索引。我们需要 $\frac{\partial L}{\partial z_j}$。根据链式法则 $$\frac{\partial L}{\partial z_j} \sum_{i1}^{C} \frac{\partial L}{\partial S_i} \cdot \frac{\partial S_i}{\partial z_j}$$ 因为 $S_i$ 是 $z_j$ 的函数一个 $z_j$ 的变化会影响所有的 $S_i$。第一步求 $\frac{\partial L}{\partial S_i}$由于 $L -\log(S_t)$所以当 $i t$真实类别时$\frac{\partial L}{\partial S_t} -\frac{1}{S_t}$当 $i \neq t$ 时$L$ 与 $S_i$ 无关导数为 0。第二步求 $\frac{\partial S_i}{\partial z_j}$这是推导中最需要小心的地方。$S_i \frac{e^{z_i}}{\sum_k e^{z_k}}$。这里需要分两种情况讨论情况一当 $i j$ 时即求 $S_i$ 对自身的输入 $z_i$ 的偏导。 这时$z_i$ 同时出现在分子和分母中。我们需要使用商的求导法则。 令 $D \sum_k e^{z_k}$则 $S_i e^{z_i} / D$。 $$\frac{\partial S_i}{\partial z_i} \frac{e^{z_i} \cdot D - e^{z_i} \cdot e^{z_i}}{D^2} \frac{e^{z_i}}{D} - \frac{e^{z_i}}{D} \cdot \frac{e^{z_i}}{D} S_i - S_i \cdot S_i S_i(1 - S_i)$$情况二当 $i \neq j$ 时即求 $S_i$ 对另一个输入 $z_j$ 的偏导。 此时$z_j$ 只出现在分母 $D$ 中。 $$\frac{\partial S_i}{\partial z_j} \frac{0 \cdot D - e^{z_i} \cdot e^{z_j}}{D^2} -\frac{e^{z_i}}{D} \cdot \frac{e^{z_j}}{D} -S_i S_j$$第三步组合求 $\frac{\partial L}{\partial z_j}$现在我们将两部分组合起来。记住$\frac{\partial L}{\partial S_i}$ 只在 $it$ 时有值。 $$\frac{\partial L}{\partial z_j} \sum_{i} \frac{\partial L}{\partial S_i} \cdot \frac{\partial S_i}{\partial z_j} \frac{\partial L}{\partial S_t} \cdot \frac{\partial S_t}{\partial z_j}$$ 因为只有当 $it$ 时求和项才不为零。将 $\frac{\partial L}{\partial S_t} -\frac{1}{S_t}$ 代入当 $j t$ 时对真实类别的 logit 求导 $$\frac{\partial L}{\partial z_t} -\frac{1}{S_t} \cdot \frac{\partial S_t}{\partial z_t} -\frac{1}{S_t} \cdot [S_t(1 - S_t)] S_t - 1$$当 $j \neq t$ 时对其他类别的 logit 求导 $$\frac{\partial L}{\partial z_j} -\frac{1}{S_t} \cdot \frac{\partial S_t}{\partial z_j} -\frac{1}{S_t} \cdot [-S_t S_j] S_j$$最终我们得到了一个极其简洁、优美的结果$$\frac{\partial L}{\partial z_j} S_j - Y_j$$ 其中$Y$ 是 one-hot 编码的真实标签向量。因为 $Y_t 1$其他 $Y_j 0$。这个结果的意义非同小可 它告诉我们损失函数关于网络原始输出 $z$ 的梯度就是模型的预测概率分布 $S$ 减去真实标签分布 $Y$。这个梯度非常“干净”对于真实类别$jt$梯度是 $S_t - 1$一个负数。这意味着在反向传播时这个 logit 会被增大因为梯度下降是朝负梯度方向更新。对于其他类别$j \neq t$梯度是 $S_j$一个正数。这意味着这些 logits 会被减小。梯度的幅度直接由预测概率 $S_j$ 决定。如果模型对某个错误类别的预测概率很高$S_j$ 大那么对应的负向梯度也会很大惩罚力度就强。这个优雅的梯度形式正是 Softmax 与交叉熵搭配能高效训练的根本原因。4. 纯 NumPy 实现脱离框架的裸奔理解了所有数学原理后我们现在用纯 NumPy 来实现它彻底摆脱对 PyTorch/TensorFlow 自动微分autograd的依赖。我们会实现三个核心函数softmax、cross_entropy_loss和gradient。4.1 Softmax 的稳健实现首先实现 Softmax必须包含数值稳定化技巧。import numpy as np def softmax(z): 计算 softmax 函数具有数值稳定性。 参数: z: 一个形状为 (N, C) 的 numpy 数组N 是样本数C 是类别数。 返回: s: softmax 概率形状同 z。 # 数值稳定化减去每行的最大值 # keepdimsTrue 确保形状为 (N, 1)便于广播 z_stable z - np.max(z, axis1, keepdimsTrue) # 计算指数 exp_z np.exp(z_stable) # 计算每行的和并保持维度用于广播 sum_exp_z np.sum(exp_z, axis1, keepdimsTrue) # 计算 softmax 概率 s exp_z / sum_exp_z return s关键细节解析np.max(z, axis1, keepdimsTrue)axis1表示对每行每个样本求最大值。keepdimsTrue至关重要它让结果的形状从(N,)变为(N, 1)。这样z - max时(N, 1)的数组会自动广播broadcast到(N, C)与每一列相减。如果不用keepdims就需要手动reshape代码不够简洁且易错。整个操作是向量化的一次处理一个批次N个样本的数据效率远高于循环。4.2 交叉熵损失计算接下来实现交叉熵损失。这里假设标签y是类别索引例如[2, 0, 1]而不是 one-hot 编码因为这是更常见的接口形式。def cross_entropy_loss(s, y): 计算交叉熵损失。 参数: s: softmax 概率形状 (N, C)。 y: 真实标签索引形状 (N,)。每个元素在 [0, C-1] 范围内。 返回: loss: 平均交叉熵损失标量。 s: 返回 softmax 概率供后续梯度计算使用。 N s.shape[0] # 样本数量 # 获取每个样本对应真实类别的预测概率 # s[np.arange(N), y] 是高级索引fancy indexing非常高效。 # np.arange(N) 生成行索引 [0, 1, ..., N-1]y 是对应的列索引。 correct_class_probs s[np.arange(N), y] # 计算每个样本的损失: L_i -log(p_{i, y_i}) # 添加一个极小值 epsilon 防止 log(0) 导致 -inf epsilon 1e-12 losses -np.log(correct_class_probs epsilon) # 计算整个批次的平均损失 loss np.mean(losses) return loss, s # 返回损失和 softmax 概率后者用于梯度计算实操心得与避坑指南高级索引Fancy Indexings[np.arange(N), y]是 NumPy 的精华操作之一它一次性提取了所有样本的正确类别概率比用for循环快几个数量级。务必掌握这种写法。对数防御一定要加一个极小值epsilon如1e-12。理论上经过 Softmax 的概率不会绝对为0但由于浮点数精度问题有可能出现np.exp(很大负数)0的情况导致log(0)产生负无穷-inf进而使损失和梯度变成nan。这是一个非常隐蔽的 bug。返回s注意我们把计算好的s也返回了。因为在计算梯度时我们需要用到它避免在梯度函数中重复计算 Softmax这是提高代码效率的一个小技巧。4.3 梯度计算实现最后实现我们推导出的核心梯度公式$\frac{\partial L}{\partial z} S - Y$。def gradient(z, s, y): 计算交叉熵损失关于输入 logits z 的梯度。 参数: z: 原始 logits形状 (N, C)。注意此函数内未直接使用但接口保留以明确含义 s: softmax 概率形状 (N, C)。 y: 真实标签索引形状 (N,)。 返回: dz: 梯度形状同 z (N, C)。 N, C s.shape dz s.copy() # 梯度初始化为预测概率 S # 创建 one-hot 编码的真实标签矩阵 Y # 方法先生成全0矩阵然后在每个样本的真实类别位置置1 y_one_hot np.zeros_like(s) y_one_hot[np.arange(N), y] 1 # 再次使用高级索引 # 核心梯度公式: dL/dz S - Y dz - y_one_hot # 注意这里计算的是每个样本损失的梯度之和。 # 在损失函数中我们计算的是平均损失 (mean)因此梯度也需要除以 N 来保持一致性。 # 但更常见的做法是在优化器更新参数时考虑批次大小。这里我们先返回未平均的梯度。 # 如果使用 SGD通常会用 dz / N 作为平均梯度。 # 为了通用性我们先返回这个“总和梯度”使用者可根据优化器决定是否平均。 return dz梯度公式的工程化细节One-hot 编码的创建np.zeros_like(s)创建了一个和s形状相同的全0矩阵。y_one_hot[np.arange(N), y] 1这行代码又一次利用高级索引高效地将所有样本的真实类别位置赋值为1。这是 NumPy 实现 one-hot 编码的标准且高效的方式。梯度公式的实现dz s.copy()然后dz - y_one_hot清晰对应了 $\frac{\partial L}{\partial z} S - Y$。使用.copy()是为了避免直接修改传入的s数组。关于平均梯度这是一个容易混淆的点。我们的损失函数cross_entropy_loss返回的是平均损失np.mean(losses)。根据微积分平均损失的梯度等于每个样本损失梯度的平均值。因此严格来说这里返回的dz是 N 个样本梯度之和。在参数更新时如果你用的是朴素的 SGD更新公式是w w - learning_rate * gradient。这时你应该使用gradient / N作为平均梯度。而像 PyTorch 这样的框架在调用loss.backward()时如果loss是一个标量通常是平均损失它会自动计算并累积平均梯度。我们的实现为了清晰返回了“总和梯度”把是否平均的决策权交给调用者。4.4 整合测试一个完整的训练步骤模拟让我们把这三个函数串起来模拟一个简单的前向-反向传播过程并验证梯度计算的正确性。# 模拟一个批次的训练数据 np.random.seed(42) # 固定随机种子确保结果可复现 N 5 # 批次大小 C 3 # 类别数 # 随机生成 logits可以理解为线性层的输出 z np.random.randn(N, C) * 0.1 # 小随机数模拟训练初期 # 随机生成真实标签 y np.random.randint(0, C, size(N,)) print(Logits z (raw scores):\n, z) print(True labels y:, y) # 1. 前向传播 s softmax(z) loss, s_retained cross_entropy_loss(s, y) # s_retained 就是 s print(\nSoftmax probabilities S:\n, np.round(s, 4)) print(Cross-entropy loss:, loss) # 2. 反向传播计算梯度 dz gradient(z, s_retained, y) print(\nGradient dL/dz (S - Y):\n, np.round(dz, 4)) # 3. 验证梯度数值有限差分法 def numerical_gradient(f, x, eps1e-6): 使用中心差分法计算标量函数 f 在点 x 处的数值梯度。 grad np.zeros_like(x) it np.nditer(x, flags[multi_index], op_flags[readwrite]) while not it.finished: idx it.multi_index original_val x[idx].copy() x[idx] original_val eps f_plus f(x) x[idx] original_val - eps f_minus f(x) grad[idx] (f_plus - f_minus) / (2 * eps) x[idx] original_val # 恢复原值 it.iternext() return grad # 定义一个包装函数计算给定 logits 下的损失 def loss_wrapper(z_single): 假设其他样本的 logits 不变只计算第一个样本的损失变化。用于数值梯度检验。 z_test z.copy() z_test[0] z_single # 只改变第一个样本的 logits s_test softmax(z_test) loss_test, _ cross_entropy_loss(s_test, y) return loss_test # 计算第一个样本 logits 的数值梯度 z0 z[0].copy() grad_num numerical_gradient(loss_wrapper, z0) print(\nNumerical gradient for sample 0 (finite difference):\n, np.round(grad_num, 4)) print(Analytical gradient for sample 0 (our derivation):\n, np.round(dz[0], 4)) print(\nDifference (should be very small):\n, np.round(grad_num - dz[0], 8))运行这段代码你会看到我们推导的解析梯度dz和通过微扰法计算的数值梯度grad_num基本一致差异在1e-7量级或更小。这强有力地验证了我们梯度推导和代码实现的正确性。数值梯度检验是验证自定义梯度实现是否正确的“金标准”在实现复杂的损失函数或层时这个步骤必不可少。5. 深入讨论与扩展掌握了基础实现后我们可以看看一些相关的变体和工程实践中的细节。5.1 LogSoftmax 与 NLLLoss更稳定的组合在实际的深度学习框架中你经常会看到LogSoftmax和NLLLossNegative Log Likelihood Loss的组合而不是直接的SoftmaxCrossEntropyLoss。这其实是数学等价的但数值上更稳定。回忆一下我们的损失是 $L -\log(S_t)$其中 $S_t \frac{e^{z_t}}{\sum e^{z_j}}$。那么 $$\log(S_t) \log(e^{z_t}) - \log(\sum e^{z_j}) z_t - \log(\sum e^{z_j})$$ 所以 $L -z_t \log(\sum e^{z_j})$。LogSoftmax就是计算 $\log(S_i) z_i - \log(\sum e^{z_j})$。而NLLLoss就是取对应真实类别的负值。这样做的好处是logsumexp函数即 $\log(\sum e^{z_j})$有专门的数值稳定实现它一次性完成了“减最大值”和“取对数求和”的操作避免了先算exp可能导致的中间溢出问题。虽然我们的softmax实现已经做了稳定性处理但LogSoftmax在极端情况下通常更鲁棒。PyTorch 的F.log_softmax和F.nll_loss就是基于此。5.2 标签平滑Label Smoothing一种实用的正则化在分类任务中我们一直使用 one-hot 编码即把正确的类别设为1其他为0。这有时会导致模型过于“自信”对预测概率过度拟合从而可能降低泛化能力。标签平滑是一种简单有效的正则化技术。它的思想是将真实标签的“1”稍微调低一点把减掉的部分平均分给其他类别。例如对于平滑参数 $\epsilon 0.1$原来的 one-hot 标签 $[0, 1, 0]$ 会变成 $[0.05, 0.9, 0.05]$。在我们的梯度公式 $\frac{\partial L}{\partial z_j} S_j - Y_j$ 中这相当于改变了 $Y_j$。对于非目标类梯度从 $S_j - 0 S_j$ 变成了 $S_j - \epsilon/(C-1)$这是一个更小的正数对于目标类梯度从 $S_t - 1$ 变成了 $S_t - (1-\epsilon)$这是一个绝对值更小的负数。整体上这缓和了梯度的强度起到了正则化的作用通常能带来轻微但稳定的性能提升。在 PyTorch 中可以通过CrossEntropyLoss(..., label_smoothing0.1)直接使用。5.3 与 Focal Loss 等进阶损失的关联你提供的热词中提到了focal loss、asymmetric loss等。这些都是在交叉熵损失基础上的改进主要解决类别不平衡或难易样本不平衡的问题。以 Focal Loss 为例它的核心思想是降低那些“容易分类”的样本即预测概率很高的样本对总损失的贡献让模型更专注于学习那些“难分类”的样本。其公式为 $$FL(p_t) -\alpha_t (1 - p_t)^{\gamma} \log(p_t)$$ 其中 $p_t$ 就是模型预测的正确类别概率 $S_t$。相比标准交叉熵 $-\log(p_t)$它多了两个因子$(1-p_t)^{\gamma}$当 $p_t$ 很大接近1易分样本时这个因子接近0从而降低了该样本的损失权重。$\gamma$ 是调节因子越大对易分样本的抑制越强。$\alpha_t$用于平衡不同类别权重的因子可以缓解类别不平衡。当你深刻理解了标准交叉熵损失的梯度$S - Y$后再去推导 Focal Loss 的梯度思路是完全一样的只是链式法则中多了一些项。理解基础版本是理解和自定义这些高级变种的前提。6. 常见问题与调试技巧在实际实现和使用中你可能会遇到以下问题1. 损失为 NaN 或无限大inf原因ALogits 数值过大。即使我们做了“减最大值”操作如果 logits 的原始值相差巨大exp计算后仍可能导致浮点数溢出得到inf或下溢得到0。检查网络初始化避免初始权重过大。可以考虑使用更小的学习率或梯度裁剪。原因B标签错误。如果真实标签y的索引超出了类别范围[0, C-1]在索引s[np.arange(N), y]时会出错或者导致取到无意义的概率值。原因C对数输入为0。这就是为什么我们要在np.log里加epsilon的原因。确保你的softmax函数正确实现了数值稳定化。2. 梯度消失或爆炸现象模型参数更新非常慢梯度太小或更新剧烈导致损失突变为 NaN梯度太大。检查梯度像我们上面做的那样实现一个数值梯度检查函数。这是验证自定义层或损失函数梯度正确性的唯一可靠方法。如果解析梯度与数值梯度差异很大说明推导或代码有误。梯度裁剪如果梯度范数过大可以在更新参数前对其进行裁剪gradient np.clip(gradient, -clip_value, clip_value)。这是训练 RNN 或深层网络时的常用技巧。权重初始化不恰当的初始化如权重全为0可能导致所有神经元的输出相同使得 Softmax 的输出退化为均匀分布梯度也可能出现问题。使用 Xavier 或 He 初始化等方法。3. 训练初期损失不下降检查数据确保输入数据经过了适当的归一化或标准化。确保标签是正确的。检查学习率学习率可能太大了损失震荡或太小了下降极慢。可以尝试一个学习率范围如[1e-5, 1e-1]进行搜索。检查前向传播打印出第一批数据的 Softmax 输出s和损失loss。如果s的概率分布看起来基本均匀例如3分类每个类都接近0.33而损失接近-log(1/C)如3分类约为1.099这是正常的训练起点。如果损失一开始就异常大或小需要检查网络结构。4. 批次大小的影响我们的实现和推导都是基于一个批次N个样本的。在计算最终损失时我们取了平均np.mean。这意味着无论批次大小如何损失的大致尺度是稳定的。但在计算梯度时我们返回的是每个样本梯度的总和。当使用不同的优化器时要注意SGD通常使用梯度 / N作为平均梯度。SGD with Momentum / Adam这些优化器内部会处理梯度的缩放。通常直接使用我们返回的“总和梯度”即可因为优化器的学习率参数已经隐含了对批次大小的考虑。但最稳妥的方式是查阅你所使用的优化器的具体约定。亲手推导一遍 Softmax 和交叉熵损失的梯度再用 NumPy 实现出来这个过程带来的理解深度是只看文档或调用 API 无法比拟的。它让你对神经网络最基础的反向传播有了直接的掌控感。下次当你模型的损失出现异常时你不再只能盲目地调整超参而是可以有条理地检查数据流、梯度值甚至自己计算数值梯度来验证。这才是从一个框架使用者向一个真正的深度学习实践者迈进的关键一步。
返回列表