☰
浅层神经网络入门:从逻辑回归到非线性分类的完整推导
2026/10/15 18:45:56 网站建设 项目流程

1. 从逻辑回归到浅层神经网络:为什么需要"多一层"

很多人学完逻辑回归之后,觉得分类问题不过如此——一个线性决策边界,加上一个Sigmoid激活函数,搞定。但真正上手做项目的时候,你会发现一个残酷的事实:绝大多数真实数据不是线性可分的。你拿逻辑回归去拟合一个"异或"形状的数据分布,无论怎么调学习率、怎么增加迭代次数,准确率就是卡在50%左右上不去。

这不是调参的问题,是模型表达能力的天花板问题。

浅层神经网络——也就是只包含一个隐藏层的神经网络——恰恰是打破这个天花板的最小可用结构。它不需要你去理解几十层的残差连接、注意力机制,只需要搞清楚一件事:为什么加一个隐藏层,模型就能拟合非线性边界了?

这篇文章我会从零拆解浅层神经网络的完整逻辑:前向传播怎么算、反向传播的链式法则怎么推、参数初始化为什么不能全零、激活函数怎么选、梯度消失怎么排查。适合已经搞懂逻辑回归、准备往深度学习方向迈第一步的人。如果你还没写过逻辑回归的代码,建议先补上那块,不然反向传播的推导会让你很痛苦。

我当初学这块的时候,最大的坑不是数学推导,而是维度对不上。矩阵乘法的顺序、转置的时机、广播机制的隐式行为,这些东西文档里不会专门讲,但实际写代码的时候错一个维度,整个训练就崩了。所以下面我会把维度变化标注得特别清楚。

2. 浅层神经网络的核心架构拆解

2.1 为什么是"一个隐藏层"就够了

先明确"浅层"的定义。在深度学习的语境里,浅层神经网络通常指只含一个隐藏层的网络结构。输入层不算"层"(它只是数据入口),输出层负责给出最终结果,中间那一个隐藏层才是真正做特征变换的地方。

那为什么是一个隐藏层,不是两个、三个?这里有个经典的结论:一个包含足够多神经元的单隐藏层网络,理论上可以以任意精度逼近任何连续函数。这就是通用近似定理。注意关键词——"足够多神经元"。理论上可行不代表实际好用,神经元太多会导致参数爆炸、过拟合、训练困难。但至少说明,一个隐藏层在表达能力上已经跨过了线性的门槛。

从逻辑回归到浅层神经网络,本质的变化是:逻辑回归直接对输入做线性变换再激活,而浅层神经网络先对输入做一次线性变换+激活,得到隐藏层的表示,再对隐藏层做一次线性变换+激活,得到输出。多出来的这一步,让模型可以学到输入的中间表示,而不是直接从输入映射到输出。

打个比方:逻辑回归像是一个人直接判断一道菜好不好吃;浅层神经网络像是先请一个帮厨把食材切成合适的大小、调好味,再请主厨来最终评判。帮厨这一步就是隐藏层做的事——把原始输入转换成更容易被最终决策利用的形式。

2.2 网络结构的维度设计

假设我们处理的是一个二分类问题,输入特征是n维的,隐藏层有m个神经元,输出是1维(二分类用Sigmoid输出概率)。

各层的维度关系如下:

层参数维度说明
输入层X(n, batch_size)n个特征,batch_size个样本
隐藏层权重W1(m, n)m个神经元,每个接收n个输入
隐藏层偏置b1(m, 1)每个神经元一个偏置
隐藏层输出A1(m, batch_size)激活后的隐藏层表示
输出层权重W2(1, m)1个输出,接收m个隐藏层输出
输出层偏置b2(1, 1)输出层一个偏置
最终输出A2(1, batch_size)预测概率

这里有个容易搞混的地方:W1的维度是(m, n)而不是(n, m)。原因是我们在做矩阵乘法的时候,习惯把样本按列排列,即X的shape是(n, batch_size)。那么W1·X的结果就是(m, batch_size),正好是隐藏层的输出维度。如果你习惯把样本按行排列,那所有权重矩阵都要转置,两种写法都对,但不能混用。

我见过太多人在这上面翻车:前向传播用了一种约定,反向传播推导的时候脑子里换了另一种约定,结果梯度算出来维度对不上,调试半天找不到原因。建议从一开始就固定一种约定,我个人的习惯是特征维度在前、样本维度在后,即X的shape是(n, batch_size)。

2.3 激活函数的选择逻辑

隐藏层用什么激活函数?输出层用什么激活函数?这两个问题的答案不一样。

隐藏层:优先用ReLU(Rectified Linear Unit),即f(z) = max(0, z)。原因有三:第一,计算极快,就是取个最大值;第二,在正区间梯度恒为1,不会像Sigmoid那样在深层网络中导致梯度消失;第三,稀疏激活特性,负输入直接输出0,相当于让部分神经元"沉默",有助于减少过拟合。

输出层:二分类用Sigmoid,多分类用Softmax。这个和逻辑回归是一致的,因为输出层需要给出概率解释。

那为什么隐藏层不用Sigmoid?Sigmoid有个致命问题:当z很大或很小时,梯度趋近于0。在反向传播中,梯度是一层一层乘上去的,如果每层都乘一个小于0.25的数(Sigmoid导数的最大值是0.25),几层下来梯度就趋近于0了,这就是梯度消失。浅层网络只有一层隐藏层,用Sigmoid还能撑住,但养成用ReLU的习惯对后面学深层网络有好处。

不过ReLU也有个坑:神经元死亡。如果一个神经元的输入永远为负,ReLU的输出永远是0,梯度也永远是0,这个神经元就再也更新不了了。解决办法包括用Leaky ReLU(负区间给一个很小的斜率,比如0.01)或者调小学习率。实际项目中,如果发现训练过程中loss不降且大量神经元输出为0,大概率就是这个问题。

3. 前向传播与反向传播的完整推导

3.1 前向传播:一步步算出来

前向传播就是从输入X算出最终输出A2的过程。以单隐藏层、二分类为例:

第一步:计算隐藏层线性部分

Z1 = W1 · X + b1

维度变化:(m, n) × (n, batch_size) + (m, 1) = (m, batch_size)。注意b1的shape是(m, 1),但通过广播机制会自动扩展成(m, batch_size),也就是每个样本都加上同一个偏置向量。

第二步:隐藏层激活

A1 = ReLU(Z1) = max(0, Z1)

维度不变,仍是(m, batch_size)。

第三步:计算输出层线性部分

Z2 = W2 · A1 + b2

维度变化:(1, m) × (m, batch_size) + (1, 1) = (1, batch_size)。

第四步:输出层激活

A2 = Sigmoid(Z2) = 1 / (1 + e^(-Z2))

维度不变,仍是(1, batch_size)。A2的每个元素就是对应样本属于正类的预测概率。

整个前向传播的过程可以用一个简单的Python函数实现:

import numpy as np def forward_propagation(X, W1, b1, W2, b2): Z1 = np.dot(W1, X) + b1 A1 = np.maximum(0, Z1) # ReLU Z2 = np.dot(W2, A1) + b2 A2 = 1 / (1 + np.exp(-Z2)) # Sigmoid cache = (Z1, A1, Z2, A2) return A2, cache

注意:np.maximum(0, Z1)和np.maximum(Z1, 0)等价,但不要写成np.max(Z1, 0),后者是求最大值而不是逐元素比较。

3.2 反向传播:链式法则的工程化表达

反向传播是浅层神经网络里最容易让人放弃的部分。但如果你把链式法则写清楚,其实没有那么可怕。

先定义损失函数。二分类用交叉熵损失:

L = -(1/batch_size) · Σ [y·log(a2) + (1-y)·log(1-a2)]

其中y是真实标签,a2是预测概率。

输出层梯度

先算dZ2 = A2 - Y。这个结果不是巧合,而是Sigmoid+交叉熵组合后的简化形式。推导过程是:

dL/dZ2 = dL/dA2 · dA2/dZ2

dL/dA2 = -(Y/A2) + (1-Y)/(1-A2)

dA2/dZ2 = A2·(1-A2)

两者相乘,化简后得到A2 - Y。这也是为什么逻辑回归和神经网络的输出层梯度都这么简洁。

然后:

dW2 = (1/batch_size) · dZ2 · A1^T

db2 = (1/batch_size) · np.sum(dZ2, axis=1, keepdims=True)

维度验证:dZ2是(1, batch_size),A1^T是(batch_size, m),相乘得到(1, m),和W2的维度一致。db2对batch_size个样本的梯度求和,得到(1, 1),和b2一致。

隐藏层梯度

dA1 = W2^T · dZ2

维度:(m, 1) × (1, batch_size) = (m, batch_size),和A1一致。

然后要过ReLU的导数。ReLU的导数是:z > 0时为1,z ≤ 0时为0。所以:

dZ1 = dA1 * (Z1 > 0)

这里的*是逐元素乘法,(Z1 > 0)生成一个布尔矩阵,在NumPy中会自动转换为0/1矩阵。

最后:

dW1 = (1/batch_size) · dZ1 · X^T

db1 = (1/batch_size) · np.sum(dZ1, axis=1, keepdims=True)

维度验证:dZ1是(m, batch_size),X^T是(batch_size, n),相乘得到(m, n),和W1一致。

用代码实现:

def backward_propagation(X, Y, cache, W1, W2): m = X.shape[1] Z1, A1, Z2, A2 = cache dZ2 = A2 - Y dW2 = (1/m) * np.dot(dZ2, A1.T) db2 = (1/m) * np.sum(dZ2, axis=1, keepdims=True) dA1 = np.dot(W2.T, dZ2) dZ1 = dA1 * (Z1 > 0) dW1 = (1/m) * np.dot(dZ1, X.T) db1 = (1/m) * np.sum(dZ1, axis=1, keepdims=True) gradients = {"dW1": dW1, "db1": db1, "dW2": dW2, "db2": db2} return gradients

3.3 参数初始化:为什么不能全零

这是新手最容易犯的错误之一:把W1和W2全部初始化为0。如果你这么做,会发生什么?

隐藏层的每个神经元接收相同的输入、有相同的权重,那么它们的输出完全相同。在反向传播时,它们得到的梯度也完全相同。结果就是,所有隐藏层神经元永远保持相同的值,等价于只有一个神经元在工作。这就是所谓的"对称性问题"。

正确的做法是随机初始化,并且要控制初始值的范围。常用的方案是:

W1 = np.random.randn(m, n) * 0.01

为什么乘0.01?因为如果初始权重太大,Z1的值会很大,经过Sigmoid或tanh激活后会饱和,梯度接近0,学习极慢。乘一个小系数可以让初始的Z1落在激活函数的线性区域附近,梯度较大,学习更快。

但0.01也不是万能的。如果隐藏层神经元很多(比如1000个),0.01可能导致每层输出方差过小,信号在传播中逐渐消失。更科学的做法是使用Xavier初始化或He初始化:

  • Xavier初始化:W ~ N(0, 1/n_in),适合Sigmoid和tanh激活函数
  • He初始化:W ~ N(0, 2/n_in),适合ReLU激活函数

其中n_in是该层的输入神经元数量。以W1为例,n_in就是输入特征数n。

def initialize_parameters(n, m): W1 = np.random.randn(m, n) * np.sqrt(2.0 / n) # He初始化 b1 = np.zeros((m, 1)) W2 = np.random.randn(1, m) * np.sqrt(2.0 / m) b2 = np.zeros((1, 1)) return {"W1": W1, "b1": b1, "W2": W2, "b2": b2}

偏置b可以初始化为0,因为对称性问题只存在于权重上。偏置不影响对称性,因为每个神经元的偏置是独立的。

4. 完整训练流程与实操细节

4.1 训练循环的搭建

把前向传播、损失计算、反向传播、参数更新串起来,就是一个完整的训练循环。下面是一个可直接运行的实现:

def train(X, Y, hidden_size, learning_rate=0.01, epochs=10000): n = X.shape[0] params = initialize_parameters(n, hidden_size) losses = [] for i in range(epochs): # 前向传播 A2, cache = forward_propagation(X, params["W1"], params["b1"], params["W2"], params["b2"]) # 计算损失 m = X.shape[1] loss = -(1/m) * np.sum(Y * np.log(A2 + 1e-8) + (1-Y) * np.log(1-A2 + 1e-8)) losses.append(loss) # 反向传播 grads = backward_propagation(X, Y, cache, params["W1"], params["W2"]) # 参数更新 params["W1"] -= learning_rate * grads["dW1"] params["b1"] -= learning_rate * grads["db1"] params["W2"] -= learning_rate * grads["dW2"] params["b2"] -= learning_rate * grads["db2"] if i % 1000 == 0: print(f"Epoch {i}, Loss: {loss:.6f}") return params, losses

这里有几个实操细节值得注意:

log中的1e-8:当A2接近0或1时,log(A2)会趋向负无穷,导致数值溢出。加一个极小值1e-8可以避免这个问题。这是工程上必须做的保护,不是可选项。

学习率的选择:0.01是一个比较安全的起点。如果loss震荡不降,说明学习率太大;如果loss下降极慢,说明学习率太小。实际项目中可以用学习率衰减策略,比如每1000轮乘以0.95。

迭代次数:浅层网络通常几千到几万轮就能收敛。如果超过5万轮loss还在明显下降,可能是学习率太小或者隐藏层神经元不够。

4.2 隐藏层神经元数量的选择

隐藏层多少个神经元合适?这个问题没有标准答案,但有一些经验法则:

  • 神经元太少:模型欠拟合,训练集和测试集准确率都低
  • 神经元太多:模型过拟合,训练集准确率高但测试集低,同时训练速度变慢
  • 起点建议:输入特征数的1到2倍,或者取2的幂次(如8、16、32、64)

我一般的做法是从一个较小的值开始(比如8),观察训练集和验证集的准确率差距。如果两者都很低,增加神经元;如果训练集高但验证集低,减少神经元或加正则化。

4.3 梯度检查:验证反向传播是否正确

反向传播的推导很容易出错,而且错了之后loss可能仍然在下降(只是下降得慢或者收敛到错误的地方)。所以梯度检查是必须做的步骤。

原理很简单:用数值近似的方法计算梯度,和反向传播算出来的梯度对比。数值梯度的公式是:

dθ ≈ (J(θ+ε) - J(θ-ε)) / (2ε)

其中ε是一个很小的数,通常取1e-7。

def gradient_check(X, Y, params, epsilon=1e-7): # 反向传播梯度 A2, cache = forward_propagation(X, params["W1"], params["b1"], params["W2"], params["b2"]) grads = backward_propagation(X, Y, cache, params["W1"], params["W2"]) # 数值梯度 params_values = {k: v.copy() for k, v in params.items()} numerical_grads = {} for key in params_values: numerical_grads["d" + key] = np.zeros_like(params_values[key]) it = np.nditer(params_values[key], flags=['multi_index']) while not it.finished: idx = it.multi_index original = params_values[key][idx] params_values[key][idx] = original + epsilon A2_plus, _ = forward_propagation(X, params_values["W1"], params_values["b1"], params_values["W2"], params_values["b2"]) loss_plus = -(1/X.shape[1]) * np.sum(Y * np.log(A2_plus + 1e-8) + (1-Y) * np.log(1-A2_plus + 1e-8)) params_values[key][idx] = original - epsilon A2_minus, _ = forward_propagation(X, params_values["W1"], params_values["b1"], params_values["W2"], params_values["b2"]) loss_minus = -(1/X.shape[1]) * np.sum(Y * np.log(A2_minus + 1e-8) + (1-Y) * np.log(1-A2_minus + 1e-8)) numerical_grads["d" + key][idx] = (loss_plus - loss_minus) / (2 * epsilon) params_values[key][idx] = original it.iternext() # 对比 for key in grads: diff = np.linalg.norm(grads[key] - numerical_grads[key]) / ( np.linalg.norm(grads[key]) + np.linalg.norm(numerical_grads[key]) + 1e-8) print(f"{key}: relative difference = {diff:.2e}")

判断标准:相对差异小于1e-7说明反向传播正确;1e-5到1e-7之间需要警惕;大于1e-5说明有bug。

梯度检查只在调试时使用,训练时不要开,因为数值梯度的计算量是反向传播的几十倍。

5. 常见问题与排查技巧实录

5.1 Loss不下降或震荡

这是最常见的问题,原因通常有以下几种:

现象可能原因排查方法解决方案
Loss完全不降学习率太大打印每轮loss减小学习率到0.001
Loss震荡学习率偏大或batch太小观察loss曲线减小学习率或增大batch
Loss下降极慢学习率太小对比不同学习率增大学习率或换优化器
Loss卡在某个值神经元死亡检查A1中0的比例换Leaky ReLU或调小学习率
Loss先降后升过拟合对比训练/验证loss加正则化或减少神经元

我踩过最坑的一次是学习率设成了1.0,loss直接变成NaN。原因是学习率太大导致参数更新步长过大,Z1的值爆炸,Sigmoid输出精确的0或1,log(0)就是负无穷。所以学习率从0.01开始试,不要一上来就搞大的。

5.2 准确率始终在50%左右

如果二分类任务的准确率一直在50%附近,说明模型没有学到任何有效信息。可能的原因:

  • 参数初始化全零(对称性问题)
  • 反向传播有bug,梯度方向错了
  • 数据标签有问题(比如标签全是一类)
  • 特征没有归一化,导致某些特征主导了梯度

特征归一化这一点特别容易被忽略。如果某个特征的取值范围是0到10000,另一个是0到1,那么前者的梯度会远大于后者,导致权重更新极不平衡。标准做法是对每个特征做零均值化(减均值)和方差归一化(除标准差)。

5.3 过拟合的识别与处理

过拟合的信号很明确:训练集准确率很高(比如99%),但验证集准确率明显低(比如70%)。浅层神经网络虽然参数不多,但在小数据集上仍然可能过拟合。

处理手段包括:

  • 增加数据量:最有效但成本最高
  • L2正则化:在损失函数中加上λ·(||W1||² + ||W2||²),λ通常取0.01到0.1
  • Dropout:训练时随机丢弃一部分神经元,但浅层网络用得少
  • 早停:验证集loss开始上升时就停止训练

L2正则化的梯度修改很简单,在dW1和dW2上加上λ/m·W即可:

dW1 = (1/m) * np.dot(dZ1, X.T) + (lambd / m) * W1 dW2 = (1/m) * np.dot(dZ2, A1.T) + (lambd / m) * W2

5.4 维度不匹配的调试技巧

维度错误是浅层神经网络实现中最常见的bug。我的调试习惯是:在每一个矩阵乘法后面打印shape。

print(f"W1: {W1.shape}, X: {X.shape}, Z1: {Z1.shape}") print(f"W2: {W2.shape}, A1: {A1.shape}, Z2: {Z2.shape}")

一旦发现某个shape不对,立刻往回查。常见的维度错误包括:

  • W1写成了(n, m)而不是(m, n)
  • 忘记对A1转置就去做dW2的计算
  • db的sum没有加keepdims=True,导致shape从(m, 1)变成(m,),后续广播出错

keepdims=True这个参数看起来不起眼,但不加的话,np.sum会把维度压缩掉,导致(m, 1)变成(m,),再和(m, batch_size)做运算时广播行为可能不符合预期。养成加keepdims的习惯。

6. 从浅层到深层:这个结构还能怎么扩展

浅层神经网络虽然简单,但它是理解所有深度学习模型的基础。你后面遇到的所有复杂结构——卷积神经网络、循环神经网络、Transformer——本质上都是在这个基础上做加法。

增加隐藏层数量:从1层变成L层,前向传播就是重复"线性+激活"的过程,反向传播则是链式法则的反复应用。核心逻辑完全一样,只是层数多了之后要处理梯度消失和梯度爆炸的问题。

更换激活函数:隐藏层从ReLU换成Leaky ReLU、ELU、GELU,输出层从Sigmoid换成Softmax(多分类),这些替换不改变网络的基本结构,只是改变了每层的非线性变换方式。

更换损失函数:二分类用交叉熵,回归用均方误差,多分类用多类交叉熵。损失函数的选择决定了输出层梯度的形式,但反向传播的框架不变。

加入正则化:L2正则化、Dropout、Batch Normalization,这些都是在这个基础结构上叠加的工程手段,目的是提升泛化能力。

我自己在学完浅层神经网络之后,最大的收获不是学会了这个模型本身,而是理解了反向传播的思维方式:把整个网络看成一个复合函数,用链式法则从输出往输入方向逐层求导。这个思维方式一旦建立起来,后面学任何新结构都只是"换零件"而已。

最后分享一个我常用的验证方法:写完浅层神经网络的代码后,先在一个简单的二维数据集上测试(比如用sklearn的make_moons生成两个交错半圆),可视化决策边界。如果边界是弯曲的而不是一条直线,说明隐藏层确实在起作用。如果还是一条直线,那大概率是激活函数没生效或者反向传播有问题。这个可视化检查比看loss曲线直观得多,强烈建议试试。

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

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

立即咨询