☰
Affine层与Softmax层反向传播推导及Python实现详解
2026/10/17 14:59:46 网站建设 项目流程

1. 先弄清楚Affine层和Softmax层在整个反向传播流程里的角色

1.1 一个最简单的全连接网络长什么样

我最初接触反向传播时,最困惑的事情不是数学公式本身,而是“这些层到底是怎么一个个咬合在一起的”。拿一个刚入门的图像分类任务来说,网络通常长这样:输入一批图片,每个图片拉成一维向量;然后过第一个全连接层(Affine层),再过激活函数(比如ReLU),再过一个全连接层,最后接Softmax和交叉熵损失函数得出损失值。所谓“学习”,就是通过梯度下降不断调整每一层的权重W和偏置b,让损失越来越小。

这里有一个很基础但容易被忽略的事实:前一层的输出是后一层的输入,前一层对参数的调整,必须依赖后一层传回来的“误差信号”。误差反向传播这个名字起得非常直白——误差从输出端开始,逆向流过每一层,给每个权重和偏置提供“该往哪个方向、迈多大步子”的信息。整个链条可以简单记为:

输入 → Affine层1 → ReLU → Affine层2 → Softmax + 交叉熵误差 → 损失值

对入门项目来说,中间层越少越直观。两个Affine层加一个Softmax层,这块结构在绝大多数基础网络里都跑不掉。正因为如此,理解“Affine层反向传播”和“Softmax层反向传播”几乎可以覆盖全连接网络80%以上梯度的手动推导工作。

1.2 反向传播这条链子上,到底谁在“干活”

很多人学反向传播时会盯着每一层的局部计算看,结果越看越乱。我习惯换一种方式理解:把每一层看作一个“搬家公司”。上游层负责接收布局的订单,计算本站需要多少车载量(也就是对输入X的梯度),并把同样的订单转给前一层;而每个参数W和b的梯度,则像“这趟运费里的分成”,在本站当场结算。

在这个比喻下,Affine层承担的角色是“矩阵乘法搬运工”,它一边要把误差对输入X的导数算出来传给前面,一边要算误差对这个层自身的权重W、偏置b的导数,供优化器更新参数。而Softmax层比较特殊,它承担的是整个网络的“终点站”:把最终输出的得分转化成概率分布,同时结算出网络与真实标签之间的差异(损失)。它的上游要么没有其他层(推理时直接输出概率),要么只有损失函数(训练时把梯度再往Affine层推)。

我还见过一个特别经典的误区:把Softmax层当成“最后一层”后就觉得它不需要反向传播了。实际上训练时几乎总是把Softmax和交叉熵误差放在一起处理,组合成一个“Softmax-with-Loss层”,因为两者的梯度计算合在一起会简洁到近乎不可思议的程度。这个组合,是整条链子上最值得亲手推一遍的部分之一。

2. Affine层的反向传播推导

2.1 维数不写清楚,推导全是浆糊

先明确一下记号。设输入为X,形状是(N, D),也就是N个样本,每个样本D维;权重W形状是(D, H);偏置b形状是(H, ),更严谨一点说是(1, H),方便广播。Affine层正向传播就是:

Y = X * W + b

这里Y的形状是(N, H),正好喂给下一层ReLU或者Softmax。

刚开始推反向传播时,很多人会照着标量链式法则去套矩阵,结果维度对不上,越推越怀疑人生。我的经验是:永远先写维度,再写公式。设上游传回来的梯度是dY,形状和Y相同,也就是(N, H)。我们要做的只有三件事:

第一,求梯度对输入的偏导dX,形状应当和X相同,也就是(N, D); 第二,求梯度对权重的偏导dW,形状应当和W相同,也就是(D, H); 第三,求梯度对偏置的偏导db,形状应当和b相同,也就是(H, )。

只要维度对得上,公式基本不会写错。这是我反复教给A同学的一句老话:反向传播里的每一个梯度,形状必须和自己对应的那个变量一模一样。这句话几乎能排除掉一大半新手错误。

2.2 用链式法则推出三个关键梯度

既然Y = X * W + b,那么把Y拆开,对单个元素看。Y中的一个元素y_ij,它的表达式是:

y_ij = Σ_d x_id * w_dj + b_j

这里Σ_d表示对d从1到D求和。当上游梯度dY_ij已经拿到时,我们可以对X、W、b分别求偏导。

先求dX。看y_ij对x_ik的依赖,只有w_kj那一项有贡献,所以:

∂y_ij / ∂x_ik = w_kj

于是:

dX_ik = Σ_j dY_ij * w_kj

写成矩阵形式就是dX = dY * W^T,其中W^T是W的转置,形状(H, D)。这一步乘法做完,dX形状是(N, H)乘(H, D),结果是(N, D),完美对上。

再看dW。固定某个w_kj,它会影响所有样本的第j个输出分量,于是:

∂y_ij / ∂w_kj = x_ik

所以:

dW_kj = Σ_i x_ik * dY_ij

也就是dW = X^T * dY,这里X^T形状(D, N),dY形状(N, H),乘积(D, H),和W形状吻合。

最后是db。因为b_j会加到所有样本的第j个输出上,所以梯度要对所有样本求和:

db_j = Σ_i dY_ij

也就是对dY沿样本轴求和,得到一个长度为H的向量。这里最容易忘记的是sum这一步,因为标量情况下没有“样本”这根轴,但对批量训练来说,偏置梯度必须跨样本聚合。

这三步推导不复杂,但价值极高。一旦你把dX、dW、db的矩阵形式都写对,Affine层在任意网络里的梯度流转就彻底打通了。链式法则在这里的体现很简单:每一份局部雅可比矩阵都被上游梯度“乘”了一遍,只是落实到具体编程时,我们不去构造雅可比矩阵,而是用矩阵乘法直接算出最终梯度,省时省内存。

2.3 为什么误差要往“上游”传

仔细看dX的表达式就会发现,它和dW的表达式很像,只是乘法的顺序和转置不同。这说明反向传播的一个核心事实:每个层除了要给出自己参数的更新量(dW和db),还要把对输入X的梯度继续往前送。因为前面那层(比如前一个Affine层或ReLU层)也需要拿这个梯度来更新它自己的参数。

有人会把“往回传梯度”理解成“误差往回复制”,这不准确。梯度不是简单的误差复制,而是通过当前层参数矩阵线性变换后的结果。生活类比是这样的:某个环节出了10块钱的纰漏,这个纰漏会按照合同规定的分配比例(W),把责任分摊到几个上游环节上去,每个上游环节该承担多少,取决于它的权重贡献。Affine层的“责任分摊”就是乘W^T这件事。

我实际见过不少人的代码只更新了W和b,忘了算dX,结果前一层永远拿不到梯度,网络训练毫无反应。这个坑绝对值得在调试流程里单列一条检查项:每个中间变量的梯度在反向传播时都要经过真实赋值,否则链子就在那一环断了。

3. Softmax-with-Loss层:把损失函数和概率输出绑在一起设计

3.1 单独实现Softmax的坑在哪里

Softmax的公式本身不复杂。设输入得分向量a,形状是(H, )(先考虑单个样本),第k个分量的输出为:

y_k = exp(a_k) / Σ_j exp(a_j)

这个输出的含义是把得分变成一组和为1的正数,也就是概率分布。但一旦把它放在反向传播链路上单独处理,会遇到两个问题。

第一个问题是数值稳定性。如果a的某些分量很大,比如几十甚至上百,exp(a)直接溢出成inf,就算不溢出,分母也可能大得离谱,导致除法结果精度变差。常见解决办法是让每个分量都减去该batch里得分的最大值,也就是实现softmax时先算m = max(a),然后对a - m取指数。exp(a_k - m)不会超过1,彻底避免溢出。数学上,因为分子分母同时乘了exp(-m),概率分布的结果完全不变。

第二个问题是误差来源。单独的Softmax层通常只做推理用,比如预测时输出概率;而训练时必须再往下接一个损失函数,比如交叉熵误差。如果分别写Softmax层和交叉熵损失层,反向传播时梯度要从损失函数传到Softmax的输入,中间要先经过Softmax层里的概率输出和log运算,推导虽然能做,但链式法则绕来绕去,代码也容易出bug。真正漂亮的做法是把两者合成一个层,让反向传播结果变得极其简单——就是输出概率减去真实标签的one-hot向量。

3.2 交叉熵误差与Softmax的绝配

交叉熵误差的定义通常写作:

L = -Σ_k t_k * log y_k

其中t是真实标签的one-hot编码,y是Softmax输出的概率。这里log y_k如果取的是自然对数,那就是梯度下降里最常用的一组搭档。

现在我们来考虑反向传播,看∂L/∂a_k怎么求。先把链式法则拆开,L是y的函数,y是a的函数。由于softmax的每个输出y_i都依赖于所有输入a_k(因为分母里有所有指数项),这个偏导不是简单的一对一关系。完整推一趟的关键步骤是:先求∂L/∂y_i,这个直接是 -t_i / y_i;再求∂y_i/∂a_k。

∂y_i/∂a_k在i = k时是y_k * (1 - y_k),在i ≠ k时是 -y_i * y_k。把这两条用克罗内克记号写成统一形式就是:

∂y_i / ∂a_k = y_i * (δ_ik - y_k)

把这个结果带进链式法则:

∂L/∂a_k = Σ_i (∂L/∂y_i) * (∂y_i/∂a_k) = Σ_i (-t_i / y_i) * y_i * (δ_ik - y_k) = Σ_i (-t_i) * (δ_ik - y_k) = -t_k + y_k * Σ_i t_i

因为t是one-hot向量,Σ_i t_i = 1,所以最后得到:

∂L/∂a_k = y_k - t_k

这个结果简直像变魔术。单独推Softmax或单独推交叉熵,中间项都很繁琐,但合在一起,梯度就是“预测值减去真实标签”。这就是为什么几乎所有主流框架在实现Softmax分类时,都把Softmax和交叉熵作为一个组合层来处理。从代码角度讲,这省掉一次不必要的中间梯度计算;从数值角度讲,这个组合式子在数值上也极其稳定。

3.3 数值稳定性处理

即使组成了Softmax-with-Loss层,前向计算里依然要处理exp的溢出问题。一般做法是:先从输入a里减去最大值m,再对a - m做指数运算。这个操作不会改变概率结果,也不会改变最终的梯度,因为减去的只是常数。

还有一个细节是log0的问题。如果y_k恰好是0(理论上softmax输出始终大于0,但数值上极小时可能被下溢成0),log会得到-inf。为了安全,很多实现里会对log加一个极小值epsilon,比如log(y + 1e-7)。在我的实践里,虽然后面接的是one-hot标签,真实类别的概率通常不会极端到0,但遇到半精度训练时,这类边缘情况还是会发生,加一个epsilon会让训练过程稳健很多。

另外一个容易忽略的点:交叉熵损失的batch汇总方式。通常会对所有样本的损失求平均,而不是求和。平均值的好处是损失大小与batch size解耦,梯度也随之缩放,超参数在不同batch size之间更容易迁移。如果写的是求和版本,换batch size时学习率几乎必须跟着调,非常烦人。我建议代码里统一用平均。

4. 用Python实现两个层

4.1 Affine类的实现

这里直接给出一个简化的两层网络会用到的Affine层实现:

import numpy as np class Affine: def __init__(self, W, b): self.W = W self.b = b self.x = None self.dW = None self.db = None def forward(self, x): self.x = x out = np.dot(x, self.W) + self.b return out def backward(self, dout): dx = np.dot(dout, self.W.T) self.dW = np.dot(self.x.T, dout) self.db = np.sum(dout, axis=0) return dx

这个实现把输入x缓存下来,是为了反向传播时要用它来计算dW。dW的计算公式正是前面推导的X^T乘dY,db则是对dout沿第0轴求和。返回的dx会继续向上一层传。这里有个小设计选择:缓存x是内存换时间,如果不缓存,forward时要重新存一份输入,基本没有理由不这么做。

4.2 SoftmaxWithLoss类的实现

实现Softmax和交叉熵的合并层,完整代码如下:

class SoftmaxWithLoss: def __init__(self): self.loss = None self.y = None self.t = None def forward(self, x, t): self.t = t self.y = self._softmax(x) self.loss = self._cross_entropy(self.y, t) return self.loss def backward(self, dout=1): batch_size = self.t.shape[0] dx = (self.y - self.t) / batch_size return dx def _softmax(self, x): x = x - np.max(x, axis=-1, keepdims=True) exp_x = np.exp(x) return exp_x / np.sum(exp_x, axis=-1, keepdims=True) def _cross_entropy(self, y, t): batch_size = y.shape[0] delta = 1e-7 return -np.sum(t * np.log(y + delta)) / batch_size

backward里除以batch_size,是因为我们在forward里对损失取了平均;如果forward里用的是求和,这里就不能除。很多入门代码在这一点上不严谨,梯度方向没错,但幅度差了好几倍,直接导致换batch size后要重新调学习率。

4.3 代码设计里的几个关键细节

第一,backward方法的入参dout。多数情况下上游传过来的值是1,也就是损失的标量梯度。这个设计方便和别的层对接:如果你想把SoftmaxWithLoss后面再接别的结构(比如正则化项),就可以传一个非1的dout进来。虽然常规分类网络用不到,但保留这个参数会让网络设计更灵活。

第二,softmax计算时对x的规约axis必须是-1。因为输入x的形状一般是(batch_size, num_classes),-1表示对最后一维(类别维)做softmax。如果网络前面多了一个时间维或通道维,这个axis就要相应调整。写死成-1是通用性最佳的选择,基本所有分类网络都能这么用。

第三,用one-hot标签还是用整数标签。上面的实现用的是one-hot,也就是t的形状和y完全一致。如果手头数据给的是整数标签,可以先用np.eye(num_classes)[label]把它转成one-hot。如果你不想转,也有直接用整数索引取log的写法,比如 -np.log(y[np.arange(batch_size), t]),但那种写法下的backward公式会略有不同,不过最终梯度效果一样。我建议入门阶段坚持用one-hot,因为推导和代码完全对齐,逻辑最不容易绕晕。

5. 组装一个两层网络,在MNIST上验证

5.1 梯度检查是先于训练的第一道关卡

写完整网络代码后,我强烈建议先做梯度检查,再跑训练。梯度检查的做法非常朴素:对网络中每个参数,用数值微分近似算出梯度,再和反向传播算出来的解析梯度对比,如果两者相对误差很小,说明层的backward实现基本正确。

数值微分的公式是所有微积分教材里都会写的中心差分形式:

grad_numerical = (loss(W + h) - loss(W - h)) / (2 * h)

h取1e-4到1e-7量级。实际操作中,h太小会因为浮点精度误差而失效,太大又偏离真实导数,我用的比较多的是1e-5到1e-6之间,配合float64精度测试比较安全。

训练前做一次梯度检查,能快速识别出到底是哪个层写反了转置,哪个层忘了求和。这类错误如果不检查,往往要等训练好几百步后看到loss不动或乱跳才反应过来,排查成本高得多。我的习惯是:训练启动脚本里先跑一个小模型(batch size取2到4个样本即可)做梯度检查,通过后再跑全量训练。

5.2 训练配置与超参数

验证用的网络结构如下:输入是784维的MNIST像素值,第一个Affine层输出50维,接ReLU,第二个Affine层输出10维,最后接SoftmaxWithLoss。代码封装成一个简单类:

class TwoLayerNet: def __init__(self, input_size, hidden_size, output_size, weight_init_std=0.01): self.params = {} self.params['W1'] = weight_init_std * np.random.randn(input_size, hidden_size) self.params['b1'] = np.zeros(hidden_size) self.params['W2'] = weight_init_std * np.random.randn(hidden_size, output_size) self.params['b2'] = np.zeros(output_size) self.layers = {} self.layers['Affine1'] = Affine(self.params['W1'], self.params['b1']) self.layers['ReLU'] = ReLU() self.layers['Affine2'] = Affine(self.params['W2'], self.params['b2']) self.last_layer = SoftmaxWithLoss()

ReLU层的实现很简单:正向传max(0, x),反向传dout,但记住把x小于0的位置梯度置0。这个层单独实现只有十几行,但它在整个网络里承担了引入非线性的作用,缺了它两层Affine叠一起相当于一个线性模型,分类能力会大打折扣。

训练时我用的超参数如下:batch size取100,学习率0.1,训练10个epoch。优化器先用最简单的SGD。学习率0.1对这个小网络来说偏大但还稳定,主要原因是这个任务足够简单。换更复杂的任务,学习率通常要降到0.001到0.01量级,或者加个学习率衰减。

5.3 实测结果能说明什么

在这个配置下,MNIST测试集准确率一般在95%到97%之间。第一次跑通时我看到的曲线是:前几百个batch损失从2.3附近快速掉到0.3左右,后面下降变平缓,最终稳定在0.1上下。这个现象特别正常,交叉熵损失的起点一般约等于ln(类别数),MNIST是10类,所以初始损失接近2.3026。如果初始化后损失明显偏离这个值,多半是参数初始化出了问题。

这里我想分享一个训练时很值得观察的细节:loss曲线的形态。如果loss在下降后突然反弹,而且反弹呈周期性,多半是学习率太大;如果loss下降极慢、几乎一条直线,可能是梯度消失或学习率太小。这两个问题在两层网络上都不太容易出现,但扩展成深层网络后就会变成家常便饭。深的网络里,Affine层叠多了以后,如果权重初始化不合理,梯度经过多层的矩阵乘法会被缩放到几乎为零,ReLU的死区效应也会加剧这个问题。

另一个实用的经验是确认准确率不是假象。MNIST这个任务简单到离谱,随便调一调就能上95%,所以真正值得关注的是你自己的实现是不是每一步都扎实。我见过有人把softmax的axis写错,依然能跑到93%的准确率,因为MNIST类别间几乎不共享信息,错乱的归一化方向反而修正了一部分不合理初始化。这提醒我们,验证一个层实现正确性不能只看最终准确率,梯度检查才是最能说明问题的证据。

从这两层的实现往前延伸,你其实已经掌握了所有全连接网络的构建零件:Affine层负责线性变换,ReLU负责非线性,SoftmaxWithLoss负责输出概率和监督信号。接下来无论你换成三层、五层、十层,无非是往中间插入更多Affine+ReLU的组合,梯度传播的逻辑完全一致。这也是为什么我坚持让每个初学者亲手实现一遍这两个层——它不只是一个编程练习,而是整个深度学习里最基础、最通用的一段骨架。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询