首页 / 资讯中心 / 文章详情

浅层神经网络入门:从逻辑回归到隐藏层,手写反向传播与梯度检查

浅层神经网络入门:从逻辑回归到隐藏层,手写反向传播与梯度检查 ★ FEATURED ARTICLE
1. 从逻辑回归到浅层神经网络为什么需要“隐藏层”这层窗户纸很多人学完逻辑回归之后觉得分类问题不过如此——一个线性函数套一个激活函数梯度下降跑一跑准确率也能看。但当你真正拿它去处理异或XOR这类问题时就会发现无论怎么调参、怎么加迭代次数准确率始终卡在50%左右跟抛硬币没什么区别。这不是调参的问题而是模型表达能力的天花板——单层线性分类器根本无法拟合非线性的决策边界。浅层神经网络Shallow Neural Networks就是在这个背景下登场的。所谓“浅层”通常指只包含一个隐藏层的神经网络结构输入层 → 一个隐藏层 → 输出层。它比逻辑回归多了一层“隐藏层”和对应的激活函数但还没有深到需要讨论梯度消失、残差连接这些深层网络的问题。可以说浅层神经网络是理解所有深度学习模型的“最小完整样本”——前向传播、反向传播、激活函数、参数初始化、梯度检查这些核心机制在浅层网络里全都有只是规模小、推导清晰、便于手算验证。这篇文章适合谁看如果你已经搞懂了逻辑回归的前向和反向传播想迈出通往深度学习的第一步那浅层神经网络就是最合适的跳板。如果你已经学过但当时囫囵吞枣对手写反向传播的维度对不上、激活函数选不明白、参数初始化为什么不能全零这些问题还有疑惑那这篇笔记也能帮你把这些坑一个个填上。我会从设计思路讲到公式推导从代码实现讲到调试技巧尽量把每个“为什么”都讲透。2. 浅层神经网络的结构设计与核心思路拆解2.1 为什么是“一个隐藏层”而不是零个或很多个先说说为什么选择“一个隐藏层”这个结构。零个隐藏层就是线性分类器前面说了表达能力受限。那为什么不直接上很多层因为对于大多数入门学习和中小规模任务来说一个隐藏层已经足够逼近很多非线性函数了。神经网络有一个经典结论单隐藏层的前馈网络在隐藏层神经元足够多的前提下可以以任意精度逼近任意连续函数。这就是所谓的通用逼近定理。当然理论上的“足够多”可能意味着实际需要非常宽的隐藏层而深层网络可以用更少的参数达到同样的表达能力。但对于学习来说一个隐藏层的网络已经能让你完整地理解所有核心机制而且计算量小、调试方便、不容易遇到梯度消失问题。所以浅层神经网络不是“简化版”而是一个精心选择的教学与工程平衡点。2.2 隐藏层的本质学习新的特征表示我习惯把隐藏层理解为一个“特征加工车间”。原始输入的特征比如像素值、房屋面积、用户年龄往往和最终要预测的目标之间没有直接的线性关系。隐藏层做的事情就是通过线性组合加非线性激活把原始特征变换成一组新的特征表示这组新特征和最终目标之间的关系更接近线性可分。举个例子假设你要做一个“判断图片里有没有猫”的任务。原始输入是像素值这些像素值和“有没有猫”之间显然不是线性关系。隐藏层的每个神经元可以学到类似“有没有尖耳朵”“有没有胡须纹理”这样的中间特征输出层再基于这些中间特征做线性组合判断是不是猫。这就是“表示学习”的雏形——网络自己学出有用的特征而不是靠人工设计。2.3 前向传播的计算图从输入到输出的完整链路浅层神经网络的前向传播可以拆成两步。第一步是隐藏层的线性组合加激活Z1 W1 * X b1A1 g1(Z1)。第二步是输出层的线性组合加激活Z2 W2 * A1 b2A2 g2(Z2)。其中X是输入矩阵每一列是一个样本W1和W2是权重矩阵b1和b2是偏置向量g1和g2是激活函数。这里有一个容易混淆的点W1的维度是隐藏层神经元数输入特征数W2的维度是输出层神经元数隐藏层神经元数。为什么是这样因为W1要把输入特征数映射到隐藏层神经元数所以行数是隐藏层大小列数是输入特征数。矩阵乘法W1 * X的结果行数是隐藏层大小列数是样本数。这个维度关系在反向传播推导时非常关键一旦搞错就会导致矩阵乘法报错。2.4 激活函数的选择逻辑为什么隐藏层不能用线性函数如果隐藏层用线性激活函数那整个网络就退化成一个线性模型了。因为线性函数的复合还是线性函数W2 * (W1 * X b1) b2 (W2 * W1) * X (W2 * b1 b2)本质上还是一个线性变换。所以隐藏层必须用非线性激活函数这是神经网络能够拟合非线性函数的根本原因。那具体选哪个激活函数常见的有Sigmoid、Tanh、ReLU及其变体。Sigmoid在隐藏层现在基本不用了因为它的输出不是零中心的而且当输入很大或很小时梯度接近零容易导致梯度消失。Tanh是零中心的比Sigmoid好一些但两端饱和的问题依然存在。ReLURectified Linear Unit是目前隐藏层的默认选择计算简单、梯度不饱和、收敛快。但它也有缺点当输入为负时梯度为零可能导致某些神经元“死亡”。为了解决这个问题后来又出现了Leaky ReLU、ELU等变体。输出层的激活函数则取决于任务类型。二分类用Sigmoid多分类用Softmax回归任务用线性激活。这个选择逻辑和逻辑回归是一致的因为输出层的目标和逻辑回归完全一样。3. 反向传播的数学推导与维度匹配实操3.1 反向传播的本质链式法则的矩阵形式反向传播听起来吓人但本质就是链式法则在计算图上的应用。对于浅层神经网络我们需要计算损失函数对W1、b1、W2、b2的梯度。以二分类交叉熵损失为例损失函数L对输出层线性输出Z2的梯度是dZ2 A2 - Y这个结果和逻辑回归完全一样非常简洁。接下来要往隐藏层传。dZ2的维度是输出层神经元数样本数。我们要计算dW2 (1/m) * dZ2 * A1.Tdb2 (1/m) * sum(dZ2, axis1)dA1 W2.T * dZ2。然后根据隐藏层的激活函数计算dZ1 dA1 * g1(Z1)最后dW1 (1/m) * dZ1 * X.Tdb1 (1/m) * sum(dZ1, axis1)。这里最容易出错的地方是维度匹配。我建议每次写反向传播之前先把所有矩阵的维度列出来然后逐个检查矩阵乘法是否合法。比如dW2 dZ2 * A1.TdZ2是n_out, mA1.T是m, n_h乘出来是n_out, n_h正好和W2的维度一致。如果维度对不上要么是转置搞错了要么是某个中间变量的定义有问题。3.2 梯度检查数值梯度与解析梯度的对比反向传播写完之后怎么确认推导是对的梯度检查是最可靠的方法。它的原理很简单用数值方法近似计算梯度然后和反向传播算出来的解析梯度对比。数值梯度的公式是(f(θε) - f(θ-ε)) / (2ε)其中ε通常取1e-7左右。具体操作时把所有参数W1、b1、W2、b2展平成一个长向量θ然后对每个元素计算数值梯度再和解析梯度对比。判断标准是相对误差如果相对误差小于1e-7说明反向传播实现正确如果在1e-5到1e-7之间基本没问题如果大于1e-3那肯定有bug。梯度检查有两个坑要注意。第一必须在训练之前做因为训练之后参数已经更新了梯度检查的意义就不大了。第二如果网络里有Dropout或BatchNorm做梯度检查时要关掉这些层否则数值梯度和解析梯度会对不上。3.3 参数初始化为什么不能全零以及怎么选初始值参数初始化是浅层神经网络里最容易被忽视但影响很大的环节。首先权重不能全部初始化为零。如果W1和W2全是零那么隐藏层所有神经元的输出完全相同反向传播时梯度也完全相同参数更新后依然完全相同。这意味着隐藏层的多个神经元在做完全一样的事情网络失去了“多个神经元学习不同特征”的能力。这就是所谓的对称性问题。那怎么初始化常见的方法有几种。第一种是小的随机数比如用np.random.randn乘以0.01。这种方法简单但对于较深的网络或使用Sigmoid/Tanh激活函数时可能导致激活值太小梯度消失。第二种是Xavier初始化也叫Glorot初始化它根据输入和输出的神经元数量来缩放随机数sqrt(1 / n_input)或sqrt(2 / (n_input n_output))。这种方法适合Sigmoid和Tanh激活函数。第三种是He初始化专门为ReLU设计sqrt(2 / n_input)。因为ReLU把一半的输入置零了所以需要更大的方差来保持信号强度。偏置项b通常初始化为零这没有问题因为对称性问题只存在于权重矩阵中。4. 完整实操从零实现一个浅层神经网络4.1 数据准备与网络结构定义我以一个经典的二分类数据集为例手动生成一个非线性可分的二维数据集这样方便可视化决策边界。网络结构定义为输入层2个特征隐藏层4个神经元输出层1个神经元。隐藏层用Tanh激活函数输出层用Sigmoid。import numpy as np def generate_data(n400): np.random.seed(42) X np.random.randn(2, n) Y ((X[0]**2 X[1]**2) 1.5).astype(float).reshape(1, -1) return X, Y def initialize_params(n_input, n_hidden, n_output): np.random.seed(1) W1 np.random.randn(n_hidden, n_input) * np.sqrt(2.0 / n_input) b1 np.zeros((n_hidden, 1)) W2 np.random.randn(n_output, n_hidden) * np.sqrt(2.0 / n_hidden) b2 np.zeros((n_output, 1)) return {W1: W1, b1: b1, W2: W2, b2: b2}这里我用了He初始化的变体因为隐藏层用的是Tanh其实用Xavier更合适但He初始化在这个小网络上也能工作。实际项目中如果隐藏层用ReLU就选He用Tanh或Sigmoid就选Xavier。4.2 前向传播与损失计算前向传播的代码很直接但要注意缓存中间变量因为反向传播需要用到。def forward(X, params): W1, b1 params[W1], params[b1] W2, b2 params[W2], params[b2] Z1 np.dot(W1, X) b1 A1 np.tanh(Z1) Z2 np.dot(W2, A1) b2 A2 1 / (1 np.exp(-Z2)) cache {Z1: Z1, A1: A1, Z2: Z2, A2: A2} return A2, cache def compute_loss(A2, Y): m Y.shape[1] cost -np.sum(Y * np.log(A2 1e-8) (1 - Y) * np.log(1 - A2 1e-8)) / m return cost注意log里面加了一个极小值1e-8防止log(0)导致数值错误。这个技巧在实际项目中很常用尤其是当预测值接近0或1的时候。4.3 反向传播与参数更新反向传播的代码需要严格按照前面推导的公式来写每一步都检查维度。def backward(X, Y, params, cache): m X.shape[1] W2 params[W2] A1, A2 cache[A1], cache[A2] dZ2 A2 - Y dW2 np.dot(dZ2, A1.T) / m db2 np.sum(dZ2, axis1, keepdimsTrue) / m dA1 np.dot(W2.T, dZ2) dZ1 dA1 * (1 - np.power(A1, 2)) dW1 np.dot(dZ1, X.T) / m db1 np.sum(dZ1, axis1, keepdimsTrue) / m grads {dW1: dW1, db1: db1, dW2: dW2, db2: db2} return grads def update_params(params, grads, lr0.1): for key in params: params[key] - lr * grads[d key] return params这里Tanh的导数是1 - A1^2因为A1 tanh(Z1)所以tanh(Z1) 1 - tanh(Z1)^2 1 - A1^2。这个导数形式很简洁也是Tanh的一个优点。4.4 训练循环与决策边界可视化训练循环就是把前向、损失、反向、更新串起来跑若干轮。def train(X, Y, n_hidden4, epochs10000, lr0.5): params initialize_params(X.shape[0], n_hidden, 1) losses [] for i in range(epochs): A2, cache forward(X, params) loss compute_loss(A2, Y) grads backward(X, Y, params, cache) params update_params(params, grads, lr) if i % 1000 0: losses.append(loss) print(fEpoch {i}, Loss: {loss:.4f}) return params, losses跑完之后你可以用matplotlib画出决策边界直观地看到网络学到的非线性分界线。这个可视化对于理解隐藏层的作用非常有帮助——你会看到决策边界不再是直线而是弯曲的曲线能够把环形分布的两类数据分开。5. 常见问题与排查技巧实录5.1 损失不下降或震荡严重这是新手最常遇到的问题。可能的原因有几个。第一学习率太大导致参数更新步长过大损失在最小值附近震荡甚至发散。解决办法是减小学习率比如从0.5降到0.1或0.01或者用学习率衰减策略。第二参数初始化太小导致激活值接近零梯度消失。可以换用Xavier或He初始化。第三数据没有归一化不同特征的尺度差异太大导致梯度方向偏向尺度大的特征。解决办法是对输入做标准化让每个特征的均值为零、方差为一。5.2 梯度检查不通过如果梯度检查的相对误差很大先检查反向传播的公式有没有写错。最常见的错误是忘了除以m或者转置搞错了。另一个常见错误是激活函数的导数用错了比如Tanh的导数写成了1 - A1而不是1 - A1^2。还有一个容易被忽视的点如果损失函数里加了正则化项反向传播里也要加上对应的梯度否则梯度检查会不通过。5.3 过拟合与欠拟合的判断训练集损失很低但验证集损失很高说明过拟合了。对于浅层神经网络过拟合的解决办法包括增加正则化L2正则化或Dropout、减少隐藏层神经元数量、增加训练数据。如果训练集损失本身就很高说明欠拟合了可以增加隐藏层神经元数量、增加训练轮数、或者换更好的激活函数。5.4 隐藏层神经元数量怎么选这个问题没有标准答案但有一些经验规则。隐藏层神经元数量通常介于输入特征数和输出神经元数之间很多时候取两者的平均值或稍大一些。对于简单任务4到16个神经元通常够用对于复杂任务可能需要64到256个。我的建议是从小开始逐步增加观察验证集性能的变化。如果增加神经元数量后验证集性能不再提升甚至下降说明已经够了或者过拟合了。5.5 常见问题速查表问题现象可能原因排查方法解决办法损失不下降学习率太小打印每轮损失增大学习率损失震荡学习率太大观察损失曲线减小学习率损失变成NaNlog(0)或除零检查损失计算加极小值梯度检查失败反向传播公式错误逐层检查维度重新推导公式验证集性能差过拟合对比训练/验证损失加正则化所有预测值相同参数初始化问题检查隐藏层输出换初始化方法6. 浅层神经网络的扩展方向与实战建议6.1 从二分类到多分类Softmax输出层把输出层的Sigmoid换成Softmax损失函数从二分类交叉熵换成多分类交叉熵浅层神经网络就能处理多分类任务了。Softmax的函数形式是e^Zi / sum(e^Zj)它把输出变成概率分布所有输出之和为1。多分类交叉熵损失对Z2的梯度依然是A2 - Y只不过Y变成了one-hot编码的形式。这个扩展非常自然代码改动量很小。6.2 从浅层到深层需要额外注意什么当你把隐藏层从一层增加到多层时会遇到一些浅层网络没有的问题。第一是梯度消失或梯度爆炸尤其是用Sigmoid或Tanh激活函数时。解决办法包括用ReLU、做BatchNorm、用残差连接。第二是参数初始化变得更关键深层网络对初始化更敏感。第三是训练变得更慢可能需要更复杂的优化器如Adam。所以我的建议是先把浅层网络吃透再往深层走这样遇到问题时你知道是哪个环节出了变化。6.3 工程实践中的几个小技巧第一个技巧是向量化。我上面所有的代码都是向量化的一次处理整个batch而不是用for循环逐个样本处理。向量化在Python里能带来几十倍甚至上百倍的加速因为NumPy底层是C实现的。第二个技巧是用小批量梯度下降而不是全批量。全批量每次更新要用所有样本计算量大随机梯度下降每次只用一个样本方差大小批量介于两者之间通常取32到256个样本兼顾稳定性和效率。第三个技巧是监控训练过程中的激活值分布和梯度范数如果发现某些层的激活值全是零或者梯度范数异常大就要及时调整。6.4 我个人踩过的一个坑最后分享一个我实际踩过的坑。有一次我实现了一个浅层神经网络梯度检查通过了损失也在下降但验证集准确率始终上不去。排查了很久才发现问题出在数据预处理上——我忘了对输入做标准化导致某些特征的尺度是其他特征的几百倍。网络在训练时梯度方向被大尺度特征主导小尺度特征几乎学不到东西。加上标准化之后验证集准确率立刻提升了十几个百分点。这个教训让我养成了一个习惯不管什么模型拿到数据第一件事就是检查分布和尺度该标准化的标准化该归一化的归一化。这个步骤花不了几分钟但能省下几个小时的调试时间。另一个值得注意的点是随机种子的设置。在做实验对比时一定要固定随机种子否则每次运行的结果都不一样你无法判断性能变化是来自你的改动还是来自随机性。我通常会在代码开头设置np.random.seed和框架对应的随机种子确保实验可复现。
阅读完成 · 觉得有帮助?
咨询建站