ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

双层神经网络矩阵运算全解析:从维度推导到反向传播实现

双层神经网络矩阵运算全解析:从维度推导到反向传播实现 最近重新整理了手写双层神经网络的代码发现每次回头去看最值得琢磨的还是那几步矩阵运算。网上讲反向传播的教程很多但大部分都停留在单个神经元的导数推导上一旦上升到矩阵层面很多人就懵了。这篇东西就围绕一件事展开在一个输入层-隐藏层-输出层的双层网络里矩阵到底是怎么参与运算的每一步又在数学上意味着什么。我会用一个具体的例子贯穿全文3个样本、每个样本4个特征隐藏层5个神经元输出层2个类别。这个规模足够小小到你可以把每一步的维度变化手算出来同时又不失一般性。代码用numpy实现所有步骤都贴出来方便你直接跑。这篇文章适合谁看正被反向传播中各种转置搞晕的初学者以及能读懂公式但没搞懂矩阵形状为什么要这么设计的进阶学习者。看完之后你应该能独立写出一个双层网络的前向传播和反向传播并且做到每一步都知道自己在算什么、为什么要这么算。1. 双层神经网络的结构与矩阵视角1.1 三层结构里的线性变换与非线性变换先把这个双层网络的结构固定下来。所谓的“双层”指的是隐藏层和输出层这两层参与了权重运算输入层只是数据的入口不参与参数更新。输入层有4个特征记为 (x)维度是 (4 \times 1)。隐藏层有5个神经元权重 (W_1) 的维度是 (5 \times 4)偏置 (b_1) 的维度是 (5 \times 1)。输出层有2个神经元权重 (W_2) 的维度是 (2 \times 5)偏置 (b_2) 的维度是 (2 \times 1)。前向传播的过程可以写成隐藏层输入(z_1 W_1 x b_1)隐藏层输出(a_1 \sigma(z_1))输出层输入(z_2 W_2 a_1 b_2)输出层输出(a_2 \text{softmax}(z_2))这个过程的核心逻辑是权重矩阵 (W) 承担“线性变换”的职责它把上一层的特征空间映射到当前层的特征空间激活函数 (\sigma) 承担“非线性变换”的职责它让网络具备拟合非线性函数的能力。两层交替进行线性变换和非线性变换就构成了网络的表达能力。如果去掉激活函数把 (a_1 \sigma(z_1)) 改成 (a_1 z_1)那么整个网络无论堆多少层本质上都等价于一个线性分类器因为线性变换的复合仍然是线性变换。这就是矩阵运算和处理非线性之间最根本的关系。1.2 为什么要把多个样本拼成矩阵而不是一条一条算很多初学者第一次接触单样本的前向传播公式 (z Wx b) 时觉得很简单但一看到代码里用 (Z XW^T b) 就懵了。差别就在向量化和批处理。对于单个样本输入 (x) 是 (4 \times 1)(W_1) 是 (5 \times 4)相乘得到 (5 \times 1)没问题。但深度学习训练时往往同时处理一批样本比如一次喂3个样本。如果一条一条算就需要写一个for循环遍历每一个样本效率很低而且没法利用底层线性代数库的并行优化。把3个样本拼成一个矩阵 (X)维度是 (3 \times 4)每一行是一个样本。此时前向传播变成[ Z_1 X W_1^T b_1 ]注意 (W_1) 要转置。因为 (W_1) 原来是 (5 \times 4)转置后变成 (4 \times 5)(X) 是 (3 \times 4)相乘得到 (3 \times 5)。每一行就是对应样本的隐藏层输出。(b_1) 是 (5 \times 1)在numpy里会通过广播机制自动加到每一行上。这个过程可以用一个生活化的类比来理解单独给3个人发快递要发3次把3个人的地址收件人信息整合到一张表里快递公司一次就能规划好路线批量派送。矩阵批处理就是这张表权重矩阵是快递公司的路线规则一次处理一行数据互不干扰但共享同一套规则。1.3 特征映射的两层视角把双层网络拆成两个阶段来看第一阶段从4维输入空间映射到5维隐藏空间。这一阶段由 (W_1) 完成特征提取(W_1) 的每一行相当于一个特征检测器对输入特征做加权组合。5行就代表学习了5种不同的特征组合模式。第二阶段从5维隐藏空间映射到2维输出空间。这一阶段由 (W_2) 完成分类决策把隐藏层的5个特征压缩成2个类别的得分。这种矩阵视角最直接的价值在于你可以通过检查权重矩阵的行列来看网络到底学到了什么。比如每一行的权重数值分布是否合理是否存在某一列权重始终接近0说明对应特征几乎没有参与决策可能要考虑数据预处理是否出了问题。2. 前向传播中的矩阵运算每一步的维度变化和物理含义2.1 初始化与维度设计写代码的第一步是初始化权重。以numpy为例import numpy as np # 数据3个样本4个特征 X np.random.randn(3, 4) # 网络结构 input_dim 4 hidden_dim 5 output_dim 2 # 初始化权重 np.random.seed(42) W1 np.random.randn(hidden_dim, input_dim) * 0.01 b1 np.zeros((hidden_dim, 1)) W2 np.random.randn(output_dim, hidden_dim) * 0.01 b2 np.zeros((output_dim, 1))这里 (W_1) 的形状是 ((5, 4))(W_2) 的形状是 ((2, 5))。注意一个问题为什么初始化时要用小随机数而不是0如果所有权重初始化为0那么在第一次前向传播时隐藏层的所有神经元会收到完全相同的输入信号经过相同的激活函数产生相同的输出反向传播时梯度也完全相同这意味着所有隐藏单元都在做同样的事情网络退化成一个只有单一隐藏单元的结构。这就是对称性问题。用小随机数打破对称性让不同神经元初始时向不同方向优化。2.2 隐藏层计算XW1^T b1的拆解实现隐藏层前向传播Z1 np.dot(X, W1.T) b1.T A1 np.tanh(Z1) # 激活函数先解释 (XW_1^T)。(X) 是 ((3, 4))(W_1^T) 是 ((4, 5))点积结果 (Z_1) 是 ((3, 5))。(Z_1) 中第 (i) 行第 (j) 列的元素计算方式是[ Z_1[i, j] \sum_{k1}^{4} X[i, k] \times W_1^T[k, j] \sum_{k1}^{4} X[i, k] \times W_1[j, k] ]也就是说第 (i) 个样本在隐藏层第 (j) 个神经元的得分是输入样本的4个特征值与 (W_1) 第 (j) 行对应权重的加权和。这正好对应了“每个隐藏神经元学习一个特征组合模板”的说法。(b_1.T) 这一项要注意。初始化时 (b_1) 是 ((5, 1))但 (Z_1) 是 ((3, 5))直接把两者相加会报维度错误。在numpy中(b_1.T) 变成 ((1, 5))广播机制会将这个一维向量加到 (Z_1) 的每一行上。这个细节看起来小但实际写代码时很容易被形状搞混。如果不用numpy的广播机制也可以显式地用np.add或者用np.tile把偏置复制成 ((3, 5))但广播机制更快、更简洁推荐直接用。2.3 激活函数为何隐藏层必须经过非线性变换隐藏层计算完线性得分 (Z_1) 后必须经过激活函数。我上面用的是 (\text{tanh})输出范围是 ([-1, 1])。如果不加激活函数假设 (Z_1 XW_1^T)(Z_2 Z_1W_2^T)则 (Z_2 XW_1^TW_2^T)令 (W_{\text{eff}} W_1^T W_2^T)整个网络就退化成 (Z_2 X W_{\text{eff}})和单层线性网络没有区别。激活函数的意义在于打破这个线性复合。在矩阵视角中激活函数是按元素操作的不改变矩阵的形状。(\text{tanh}) 接受 ((3, 5)) 的矩阵输出仍是 ((3, 5))。这意味着非线性变换是独立作用于每个神经元的输出不跨神经元混合信息。跨神经元混合信息的活由下一步的权重矩阵 (W_2) 来完成。2.4 输出层计算与损失函数中的矩阵操作输出层实现Z2 np.dot(A1, W2.T) b2.T exp_scores np.exp(Z2) probs exp_scores / np.sum(exp_scores, axis1, keepdimsTrue)这里做的是softmax。(A_1) 是 ((3, 5))(W_2^T) 是 ((5, 2))点积得到 ((3, 2))。每一行是两个类别的原始得分。Softmax的公式[ \text{probs}[i, j] \frac{e^{Z_2[i, j]}}{\sum_{k1}^{2} e^{Z_2[i, k]}} ](np.sum(exp_scores, axis1, keepdimsTrue)) 计算每一行的总和结果形状是 ((3, 1))。用keepdimsTrue是为了保持维度使得广播时能正确对齐到 ((3, 2)) 的矩阵上。这一步输出的是概率分布每一行之和为1。对于二分类问题可以简单地把 softmax 理解为把得分转换成“属于每个类别的置信度”。损失函数用交叉熵correct_logprobs -np.log(probs[range(3), y]) loss np.sum(correct_logprobs) / 3其中 (y) 是真实标签形状为 ((3,))。(probs[range(3), y]) 用整数数组索引从每一行中取出真实类别对应的概率。这个操作的矩阵思维是不关心模型对其他类别的预测概率只关心真实类别的预测概率交叉熵越小说明真实类别的预测概率越大模型越准。3. 反向传播中的矩阵运算梯度如何在矩阵间流动3.1 从标量链式法则到矩阵梯度反向传播的目标是计算损失 (L) 对每个参数的梯度(\frac{\partial L}{\partial W_1})、(\frac{\partial L}{\partial b_1})、(\frac{\partial L}{\partial W_2})、(\frac{\partial L}{\partial b_2})。有了梯度才能做梯度下降更新参数。单个神经元的链式法则如果 (L f(z))(z Wx b)那么 (\frac{\partial L}{\partial W} \frac{\partial L}{\partial z} \frac{\partial z}{\partial W} \frac{\partial L}{\partial z} x^T)。这个结论可以扩展到矩阵形式。关键在于搞清楚每一步的梯度矩阵形状和计算方式。一个有效的技巧是先确定梯度矩阵的形状必须和原参数矩阵的形状一致然后用这个约束反推运算顺序。3.2 输出层梯度W2和b2的更新从损失函数开始反向传播。[ \frac{\partial L}{\partial Z_2} \text{probs} - y_{\text{onehot}} ]这个结论在softmax cross-entropy的组合中非常简洁。如果 (y) 用one-hot编码形状为 ((3, 2))那么y_onehot np.zeros((3, 2)) y_onehot[range(3), y] 1 dZ2 probs - y_onehot(dZ2) 的形状是 ((3, 2))。接下来求 (W_2) 的梯度。已知 (Z_2 A_1 W_2^T b_2^T)推导[ \frac{\partial L}{\partial W_2} dZ2^T \cdot A_1 ]代码实现dW2 np.dot(dZ2.T, A1) db2 np.sum(dZ2, axis0, keepdimsTrue)理解方式(dZ2) 是 ((3, 2))转置后是 ((2, 3))(A_1) 是 ((3, 5))相乘得到 ((2, 5))正好和 (W_2) 形状一致。用维度反推法可以很容易验证。偏置 (b_2) 的梯度是 (dZ2) 对每个维度求平均或求和。因为我们用的损失是除以样本数 (N3) 的所以这里应该除以N。实际代码中很多实现用求和再用学习率调节但严格来说除以N更标准。N 3 dW2 np.dot(dZ2.T, A1) / N db2 np.sum(dZ2, axis0, keepdimsTrue) / N3.3 隐藏层梯度梯度穿过激活函数反向传播继续往前需要计算 (\frac{\partial L}{\partial A_1})。因为 (Z_2 A_1 W_2^T b_2)所以 (\frac{\partial Z_2}{\partial A_1} W_2^T)。[ \frac{\partial L}{\partial A_1} \frac{\partial L}{\partial Z_2} \cdot W_2 ]矩阵形式dA1 np.dot(dZ2, W2)维度检查(dZ2) 是 ((3, 2))(W_2) 是 ((2, 5))相乘得到 ((3, 5))和 (A_1) 形状一致。注意这里用的是 (W_2) 而不是 (W_2^T)很多初学者在这里会搞混。原因在于前向传播时 (Z_2 A_1 W_2^T)反向流动时转置关系会反转。然后经过激活函数 tanh。(\tanh) 的导数是 (1 - \tanh^2(x))在代码中就是 (1 - A_1^2)。[ \frac{\partial L}{\partial Z_1} \frac{\partial L}{\partial A_1} \cdot (1 - A_1^2) ]dZ1 dA1 * (1 - A1 ** 2)这是逐元素相乘因为激活函数不混合神经元间的信息。最后求 (W_1) 和 (b_1) 的梯度。已知 (Z_1 X W_1^T b_1)推导[ \frac{\partial L}{\partial W_1} dZ1^T \cdot X ]dW1 np.dot(dZ1.T, X) / N db1 np.sum(dZ1, axis0, keepdimsTrue) / N维度检查(dZ1) 是 ((3, 5))转置后是 ((5, 3))(X) 是 ((3, 4))相乘得到 ((5, 4))和 (W_1) 形状一致。整个反向传播的矩阵流动路线可以概括为[ L \rightarrow dZ_2 \rightarrow dW_2 \rightarrow dA_1 \rightarrow dZ_1 \rightarrow dW_1 ]每一层梯度的计算都依赖前一层已经算好的梯度这个链条就是链式法则的矩阵版本。3.4 参数更新与梯度消失的初步观察拿到所有梯度后用梯度下降更新参数learning_rate 1.0 W1 - learning_rate * dW1 b1 - learning_rate * db1 W2 - learning_rate * dW2 b2 - learning_rate * db2这里可以顺便观察一个现象如果网络层数增加梯度是要经过多层激活函数导数的连乘才能传回浅层的。对于tanh来说导数 (1 - \tanh^2(x)) 在 (x0) 时取最大值为1其他地方都小于1。多层连乘后梯度会指数级缩小这就是梯度消失问题。双层网络里这个问题还不明显但理解矩阵运算时脑子里要有这根弦后续在深层的网络里会遇到更明显的数值问题。4. 完整实现与维度调试拿numpy手写一个训练循环4.1 从零实现前向、反向、训练一步不落把上面的推导综合起来写一个完整的、可运行的二分类网络import numpy as np import matplotlib.pyplot as plt np.random.seed(42) # 生成数据两个类别4个特征 N 100 D 4 K 2 X np.random.randn(N, D) * 2 y (X[:, 0] X[:, 1] 0).astype(int) # 线性可分的二分类 # 网络结构 input_dim D hidden_dim 10 output_dim K # 初始化 W1 np.random.randn(hidden_dim, input_dim) * 0.01 b1 np.zeros((hidden_dim, 1)) W2 np.random.randn(output_dim, hidden_dim) * 0.01 b2 np.zeros((output_dim, 1)) learning_rate 1.0 reg_lambda 0.01 # L2正则化系数 losses [] for epoch in range(2000): # 前向传播 Z1 np.dot(X, W1.T) b1.T A1 np.tanh(Z1) Z2 np.dot(A1, W2.T) b2.T exp_scores np.exp(Z2) probs exp_scores / np.sum(exp_scores, axis1, keepdimsTrue) # 交叉熵损失 L2正则 y_onehot np.zeros((N, K)) y_onehot[range(N), y] 1 corect_logprobs -np.log(probs[range(N), y]) data_loss np.sum(corect_logprobs) / N reg_loss 0.5 * reg_lambda * (np.sum(W1 * W1) np.sum(W2 * W2)) loss data_loss reg_loss losses.append(loss) # 反向传播 dZ2 probs - y_onehot # (N, K) dW2 np.dot(dZ2.T, A1) / N reg_lambda * W2 db2 np.sum(dZ2, axis0, keepdimsTrue) / N dA1 np.dot(dZ2, W2) # (N, hidden_dim) dZ1 dA1 * (1 - A1 ** 2) dW1 np.dot(dZ1.T, X) / N reg_lambda * W1 db1 np.sum(dZ1, axis0, keepdimsTrue) / N # 参数更新 W1 - learning_rate * dW1 b1 - learning_rate * db1 W2 - learning_rate * dW2 b2 - learning_rate * db2 if epoch % 200 0: print(fepoch {epoch}, loss {loss:.4f})训练过程中打印的loss应该逐步下降。我加了L2正则化项凡是把权重平方和加入损失的地方对应的梯度里就要多一项reg_lambda * W。这个细节很容易遗漏如果损失函数里加了正则项反向传播只算数据损失梯度却不把正则梯度和权重放进去参数更新就会出问题。4.2 一个值得反复检查的细节除法还是求和我在每个梯度里都除以了样本数 (N)这是为了统一量纲。如果你用求和学习率可能要按比例调小。两种方式在数学上是等价的只是学习率的尺度不同。但混用就会出现问题。比如你在 (db_2) 里用求和但在 (dW_2) 里用了平均两者的梯度尺度差异会达到 (N) 倍训练时会对学习率非常敏感。我建议统一用平均这样损失函数对每个样本做一个平均梯度的数值期望更稳定。4.3 维度调试我的排错SOP写矩阵运算代码最常见的错误就是维度不匹配。我总结了三个步骤基本能解决90%的报错。第一步前向传播前把每个张量的形状写下来。按照输入 (X) 的维度逐步推演每一层的输出维度。卡片式地记录X(100,4)Z1(100,10)A1(100,10)Z2(100,2)probs(100,2)。第二步反向传播时每次计算完一个梯度检查它是否和对应参数的形状一致。不一致就一定是哪里转置错了或点积顺序错了。第三步如果报错信息提示广播错误多半是偏置向量的形状没有处理好。一个常见的坑是初始化时定义成了 ((5,))而不是 ((5,1))。((5,)) 和 ((3,5)) 广播时numpy会尝试逐元素对齐导致奇怪的错误或者默默算错。建议统一使用列向量或行向量的明确形状不要依赖numpy的隐式广播。注意在写神经网络代码时建议对所有矩阵形状做显式的断言例如assert dW1.shape W1.shape。这个习惯能让你在早期就发现方向错误而不是等到训练几轮后loss不降才开始排查。5. 常见问题与排查技巧实录5.1 矩阵相关的报错速查表报错信息产生原因解决方案shapes (3,4) and (3,4) not aligned忘记对权重矩阵转置检查前向公式确保是 X·W1.T 或 W1·X.T 的维度匹配形式operands could not be broadcast together偏置向量形状不对将偏置统一为 (1, n) 或 (n, 1) 的明确二维形状loss 始终不下降输出概率接近均匀分布权重初始化为0导致对称性问题改用随机初始化如 np.random.randn 乘以 0.01训练后期 loss 出现 NaN学习率过大或数值不稳定减小学习率检查是否有 overflow 的 exp 操作可考虑 log-sum-exp 技巧验证集精度远低于训练集过拟合正则项作用不足增大 reg_lambda或减少隐藏层神经元数量5.2 关于转置的三个记忆锚点反向传播中最容易错的永远是转置。我自己记了三个锚点用了很久都没出错。第一个锚点前向传播用了 (W^T)反向传播第一次求梯度时就会用到 (W) 本身。比如 (Z_2 A_1 W_2^T)那么 (dA_1 dZ_2 W_2)。第二个锚点某个参数的梯度总是由“上游梯度的转置”乘以“当前层的输入”组成。比如 (dW_2 dZ_2^T A_1)其中 (dZ_2) 是上游梯度(A_1) 是当前层输入。第三个锚点如果推导出来的梯度形状和参数形状不一致一定错了。这个约束简单粗暴但非常有效。5.3 一个容易被忽略的数值稳定性问题上面代码中用了np.exp(Z2)后除以总和做softmax。当得分较大时比如某类得分达到1000np.exp(1000)会溢出成inf。虽然小数据上不会出现但在实际任务中很容易遇到。解决方法是softmax的数值稳定版本shifted Z2 - np.max(Z2, axis1, keepdimsTrue) exp_scores np.exp(shifted) probs exp_scores / np.sum(exp_scores, axis1, keepdimsTrue)这里先减去每行的最大值再求指数。因为softmax的分子分母同时乘以 (e^{-\max})结果不变但数值范围被控制在 ((-\infty, 0]) 内不会溢出。这个技巧在矩阵运算中非常常用尤其是当输出层维度较大或分数无界时。从矩阵运算的角度理解np.max(Z2, axis1, keepdimsTrue)是对每一行做归约得到 ((N, 1)) 的列向量。这个列向量和 ((N, 2)) 的矩阵广播相减是numpy广播机制的一个经典应用场景也是理解矩阵逐行操作的一个好例子。6. 从双层到更深的网络矩阵运算如何扩展6.1 代码层面从两层到多层的矩阵抽象双层网络的矩阵运算模式可以抽象成几个固定的模板。如果网络扩展到4层每层之间的运算规律是完全一样的线性变换、偏置相加、激活函数。这就意味着你可以用循环来处理任意层数的网络。核心模板可以归纳为# 前向 Z np.dot(A_prev, W.T) b.T A activation(Z) # 反向 dA_prev np.dot(dZ, W) dZ dA_prev * activation_derivative(A) dW np.dot(dZ.T, A_prev) / N reg_lambda * W db np.sum(dZ, axis0, keepdimsTrue) / N这个模式就是深度学习框架中nn.Linear层的本质。PyTorch 或其他框架帮你做的事情就是把这些矩阵运算封装起来、自动求导、在GPU上并行计算。理解了双层网络的矩阵运算再看框架源码会轻松得多。6.2 数值稳定性与梯度流的进一步思考层数加深后矩阵运算的维度更大、乘积更多数值问题会更突出。除了激活函数导致的梯度消失还有权重矩阵特征值带来的影响。如果每一层的权重矩阵特征值大于1经过多层连乘后数值会爆炸小于1则会衰减。这和“矩阵连乘的谱半径”直接相关。从双层网络入手理解这个现象是最划算的投资。因为双层网络里还能手工推导每一步三层以上手推就会非常繁琐。当你亲手验证了两层网络的梯度流动再去看深度网络中的梯度消失、梯度爆炸问题就会意识到问题出在连乘效应上而不是某一层的计算错误。在实际调参时我习惯把每一层的梯度范数打印出来观察它是不是在合理范围内。如果某一层梯度特别小或者特别大先调整初始化方式或激活函数再动学习率。这个习惯比盲目搜索学习率更有效。另外补充一个调试技巧当网络训练结果不理想时先用小数据做过拟合测试。拿2个样本训练看看网络能不能把loss降到接近0。如果连小数据都无法过拟合说明代码有bug如果可以过拟合再换成全量数据训练此时问题大概率出在正则化或学习率上。这个方法的本质是把模型容量调到最大值验证反向传播代码是否正确。6.3 关于学习率的经验思考矩阵运算中的权重更新 (W W - \eta dW)(\eta) 就是学习率。这个式子看起来简单但学习率对训练稳定性影响极大。学习率太大权重更新步长过大loss会震荡甚至发散学习率太小收敛速度慢。在双层网络里如果loss刚开始下降得很快随后停滞可以去检查一下是不是学习率设置得偏大。此时矩阵梯度的数值可能已经出现震荡。我通常的做法是初始学习率从1.0开始如果loss波动大就减半直到找到稳定的范围。对于手写的双层网络1.0的问题基本不大因为tanh激活函数的梯度范围可控但换用ReLU等其他激活函数时需要重新调整。注意学习率的调整必须结合正则化系数。如果加了较强的L2正则权重被压得更小梯度更新的“有效自由度”变小可能需要稍微大一点的学习率。最后再分享一个小技巧我在调试这类网络时最顺手的一个技巧是在反向传播代码里临时加几行 “梯度检查” 的代码用数值方法验证解析梯度是否正确。具体做法是对某个参数 (W_{ij}) 加一个极小的 (\epsilon)分别计算损失变化用 ((L(W\epsilon) - L(W-\epsilon)) / (2\epsilon)) 来和解析梯度比较。两条线的误差在1e-6量级就说明反向传播正确。这个技巧能帮你节省数小时的查错时间。毕竟矩阵运算的推导在纸上再完美落到代码里也容易出转置或形状的错误。数值梯度验证相当于给反向传播上了一道保险无论是对初学者还是老手都值得作为标准流程执行。
返回列表