从零手写前馈神经网络:前向传播与反向传播的numpy实现

发布时间:2026/10/26 8:07:04
从零手写前馈神经网络:前向传播与反向传播的numpy实现

1. 从零搭网络这件事,为什么值得反复折腾

很多人学神经网络,卡在第一步不是数学,而是"我到底该从哪一行代码开始写"。看别人的项目,model.fit()一行就出结果,轮到自己动手,连输入层该放几个神经元都要查半天。这个系列写到第六篇,我想聊的已经不是"怎么调库",而是怎么用纯 Python 把前馈神经网络从零搭出来,并且真正跑通一次完整的前向传播和反向传播。

标题里的"从零开始",我的理解是不依赖 PyTorch、TensorFlow 这类高层框架,只用numpy甚至纯 Python 列表来实现。为什么非要这么干?因为调库的时候,反向传播对你来说是个黑盒,梯度算错了你也不知道;而自己手写一遍,链式法则怎么传导、权重怎么更新、损失怎么下降,每一步都看得见。这对后面理解 CNN、RNN、LSTM、GNN 这些结构,是绕不过去的基本功。

这篇适合两类人:一类是刚学完 Python 基础、装了 numpy 想动手但不知道从哪下手的;另一类是用惯了框架、想回头补一补底层原理的。我会把网络结构设计、前向传播、反向传播、参数更新、训练调参这几个环节拆开讲,每个环节都给出可以直接抄的代码和参数计算过程。全文基于我自己的实操经验,踩过的坑也会一并说清楚。

2. 网络整体设计与思路拆解

2.1 为什么选前馈神经网络作为从零实现的第一站

神经网络家族很庞大,卷积神经网络、循环神经网络、LSTM、图神经网络各有各的适用场景。但如果目标是"理解神经网络到底怎么学习",前馈神经网络(也叫多层感知机,MLP)是最合适的起点。原因很直接:它的结构最简单,只有全连接层,没有卷积核的滑动窗口,没有循环结构的时间依赖,也没有图结构里的邻接矩阵。所有的复杂度都集中在"矩阵乘法 + 激活函数 + 梯度回传"这三件事上。

我见过不少人一上来就想手写 CNN,结果卡在卷积的反向传播上,因为那里涉及卷积核翻转、padding 对齐、stride 步长这些额外维度。前馈网络把这些干扰项全去掉了,你只需要专注一件事:误差怎么从输出层一层层传回输入层,并且让每个权重知道自己该往哪个方向调整。把这个搞透,再去理解卷积神经网络里"权值共享"为什么能减少参数量,理解 LSTM 里"门控机制"为什么能缓解梯度消失,都会顺很多。

从工程角度看,前馈网络也是很多实际任务的基础组件。比如做表格数据的分类预测、做简单的回归拟合,甚至在一些推荐系统里做特征交叉,MLP 都是标配。你把它手写一遍,以后看到nn.Linear就知道它背后在算什么。

2.2 网络层数与神经元数量的取舍逻辑

设计网络结构时,第一个要回答的问题是:几层?每层几个神经元?这个问题没有标准答案,但有几条我总结出来的经验规则。

输入层神经元数量由特征维度决定,这个没得选。比如输入是 784 维的手写数字像素,输入层就是 784 个节点。输出层由任务决定:二分类就 1 个节点配 Sigmoid,多分类就 N 个节点配 Softmax,回归就 1 个节点不加激活。

真正需要权衡的是隐藏层。我的经验是:先从一层隐藏层开始,神经元数量取输入维度和输出维度之间的一个值,通常是 2 的幂次,比如 64、128、256。为什么不建议一上来就堆很多层?因为层数越多,反向传播时梯度越容易出问题,而且参数量会爆炸。一个 784 到 128 的全连接层,参数量是 784×128 + 128 = 100480 个,这还只是一层。如果堆五层,参数量轻松上百万,在小数据集上必然过拟合。

隐藏层神经元数量也不是越多越好。太少会导致欠拟合,网络学不到复杂模式;太多会导致过拟合,而且训练慢。我一般会这样试:先设一个中等规模(比如 128),跑一轮看训练集和验证集的损失曲线。如果训练损失降不下去,说明容量不够,加神经元或加层;如果训练损失很低但验证损失往上走,说明过拟合,减神经元或加正则。

激活函数的选择也影响结构设计。隐藏层我默认用 ReLU,因为它计算简单、梯度不容易饱和。输出层根据任务选:二分类用 Sigmoid,多分类用 Softmax,回归直接线性输出。这个组合在绝大多数场景下都能跑出合理结果。

2.3 用 numpy 还是纯 Python 列表:一个实际的取舍

"从零开始"有个绕不开的选择:底层数据结构用什么。纯 Python 列表写起来直观,但矩阵乘法要自己写三重循环,速度慢到没法训练。numpy 提供了向量化运算,一行np.dot()就能完成矩阵乘法,速度快几个数量级。

我的建议是:用 numpy,但不要用任何深度学习框架。numpy 是数值计算的基础库,它不算"框架",用它来实现神经网络,你依然需要自己推导梯度、自己写反向传播、自己更新权重。这跟调model.fit()是两回事。

具体来说,权重矩阵用np.random.randn()初始化,前向传播用np.dot()和激活函数,反向传播用转置矩阵乘法和逐元素乘法。整个过程你完全掌控,没有任何黑盒。如果你连 numpy 都不想用,想纯列表实现,那也可以,但建议只在小规模数据上验证逻辑,真正训练还是换 numpy。

提示:numpy 的广播机制在反向传播里非常有用,但也是最容易出错的地方。偏置项的梯度求和、激活函数的逐元素求导,都要注意维度对齐。我后面会专门讲这个坑。

3. 核心细节解析与实操要点

3.1 权重初始化:为什么不能全零,也不能太大

权重初始化是很多人忽略的一步,但它直接决定网络能不能训起来。最常见的错误是把权重全部初始化为零。这样做的话,同一层所有神经元的输出完全一样,反向传播时梯度也一样,更新后权重还是完全一样。网络对称性永远打破不了,等于白搭。

另一个极端是初始化得太大。如果权重服从标准差为 1 的正态分布,经过多层矩阵乘法后,激活值会指数级放大,导致数值溢出;反向传播时梯度也会爆炸。反过来,如果初始化得太小,激活值会逐层缩小,梯度消失,深层网络根本学不动。

我常用的初始化方法是He 初始化,专门配合 ReLU 激活函数。公式是:权重服从均值为 0、标准差为sqrt(2 / n_in)的正态分布,其中n_in是该层的输入神经元数量。为什么是 2 而不是 1?因为 ReLU 会把一半的神经元置零,方差减半,所以需要乘以 2 来补偿。偏置项直接初始化为零就行,因为权重已经打破了对称性。

import numpy as np def he_init(n_in, n_out): std = np.sqrt(2.0 / n_in) W = np.random.randn(n_in, n_out) * std b = np.zeros((1, n_out)) return W, b

实测下来,He 初始化配合 ReLU,在大多数前馈网络上都能稳定训练。如果你用的是 Sigmoid 或 Tanh,可以换成 Xavier 初始化,标准差是sqrt(1 / n_in)。

3.2 前向传播:矩阵维度必须对得上

前向传播的本质是一连串的矩阵乘法和激活函数。假设第 l 层的输入是A_prev,形状是(batch_size, n_in),权重W形状是(n_in, n_out),偏置b形状是(1, n_out),那么线性输出Z = A_prev @ W + b,形状是(batch_size, n_out)。然后经过激活函数得到A = activation(Z)。

这里最容易出错的是维度对齐。A_prev的列数必须等于W的行数,否则矩阵乘法直接报错。偏置b的列数必须等于W的列数,numpy 的广播机制会自动把它加到每一行上。我建议在写代码时,每一步都打印一下 shape,确认无误再往下走。

def forward(X, params): caches = [] A = X L = len(params) // 2 for l in range(1, L): W, b = params[f'W{l}'], params[f'b{l}'] Z = np.dot(A, W) + b A = np.maximum(0, Z) # ReLU caches.append((A, Z)) # 输出层用 Sigmoid W, b = params[f'W{L}'], params[f'b{L}'] Z = np.dot(A, W) + b A = 1 / (1 + np.exp(-Z)) caches.append((A, Z)) return A, caches

激活函数的选择上,隐藏层用 ReLU 有个细节:ReLU 在负半轴梯度为零,会导致部分神经元"死亡"。如果学习率设得太大,某些神经元的权重更新后永远落在负半轴,再也不会被激活。缓解办法是控制学习率,或者用 Leaky ReLU 给负半轴一个很小的斜率。

3.3 损失函数:交叉熵为什么比均方误差更适合分类

损失函数衡量的是网络输出和真实标签之间的差距。回归任务常用均方误差,分类任务我强烈建议用交叉熵。原因在于梯度特性:交叉熵配合 Sigmoid 或 Softmax,输出层的梯度是A - Y,形式非常简洁,而且当预测偏离真实值越远时,梯度越大,学习越快。均方误差配合 Sigmoid 的话,梯度里会多出一个sigmoid'(Z)因子,当输出接近 0 或 1 时这个因子趋近于零,梯度消失,学习停滞。

二分类交叉熵的公式是:L = -[Y * log(A) + (1 - Y) * log(1 - A)],对 batch 取平均。多分类的话,输出层用 Softmax,损失用-sum(Y * log(A))。这里有个数值稳定性的坑:log(0)会变成负无穷,所以计算时要给A加一个极小值,比如1e-15。

def compute_loss(A, Y): m = Y.shape[0] A = np.clip(A, 1e-15, 1 - 1e-15) loss = -np.sum(Y * np.log(A) + (1 - Y) * np.log(1 - A)) / m return loss

3.4 反向传播:链式法则的工程实现

反向传播是整个网络最核心也最容易写错的部分。它的本质是链式法则:从损失函数出发,逐层往回求每个权重和偏置的偏导数。对于输出层,如果损失是交叉熵、激活是 Sigmoid,那么dZ = A - Y,这个结论可以直接用,不需要再推导。然后dW = A_prev.T @ dZ / m,db = sum(dZ) / m。

隐藏层的反向传播稍微复杂一点。假设第 l 层的线性输出是Z,激活输出是A,从后一层传回来的梯度是dA,那么dZ = dA * relu'(Z),其中relu'(Z)在Z > 0时为 1,否则为 0。然后dW = A_prev.T @ dZ / m,db = sum(dZ) / m,dA_prev = dZ @ W.T。

def backward(caches, Y, params): grads = {} L = len(params) // 2 A_out, _ = caches[-1] m = Y.shape[0] dZ = A_out - Y for l in reversed(range(1, L + 1)): A_prev = caches[l - 2][0] if l > 1 else X W = params[f'W{l}'] dW = np.dot(A_prev.T, dZ) / m db = np.sum(dZ, axis=0, keepdims=True) / m grads[f'dW{l}'] = dW grads[f'db{l}'] = db if l > 1: dA_prev = np.dot(dZ, W.T) Z_prev = caches[l - 2][1] dZ = dA_prev * (Z_prev > 0) return grads

这里有个维度陷阱:np.sum(dZ, axis=0, keepdims=True)里的keepdims=True必须加,否则db会变成一维数组,跟b的二维形状不匹配,更新时会广播出错。我第一次写的时候就是漏了这个参数,调了半天才发现。

3.5 参数更新:学习率是最重要的超参数

参数更新就是梯度下降:W = W - learning_rate * dW,b = b - learning_rate * db。学习率决定了每次更新的步长。设得太小,收敛慢,训练半天损失降不下去;设得太大,损失会震荡甚至发散,权重直接变成 NaN。

我的经验是:从 0.01 开始试,如果损失震荡就减半,如果收敛太慢就加倍。对于前馈网络,0.01 到 0.1 之间通常能找到合适的值。如果数据做了标准化,学习率可以适当大一点;如果没做标准化,学习率要小一些,否则梯度方向会被大尺度特征主导。

def update_params(params, grads, lr): L = len(params) // 2 for l in range(1, L + 1): params[f'W{l}'] -= lr * grads[f'dW{l}'] params[f'b{l}'] -= lr * grads[f'db{l}'] return params

注意:更新参数时不要用params[f'W{l}'] = params[f'W{l}'] - lr * grads[f'dW{l}']这种写法在循环里反复创建新数组,直接用-=原地修改更高效,也避免引用混乱。

4. 完整实操流程与关键环节实现

4.1 数据准备与标准化处理

训练之前,数据必须处理好。我拿一个二分类任务举例,假设特征有 10 维,样本 1000 条。原始数据可能量纲差异很大,比如一个特征是年龄(0-100),另一个是收入(0-1000000)。如果不做标准化,收入这个特征会主导梯度方向,网络学不到年龄的影响。

标准化的做法是:对每个特征减去均值、除以标准差,让所有特征都落在相近的尺度上。这一步在训练集上计算均值和标准差,然后应用到验证集和测试集,不能用全量数据算,否则会数据泄露。

def normalize(X_train, X_val, X_test): mu = np.mean(X_train, axis=0) sigma = np.std(X_train, axis=0) + 1e-8 X_train = (X_train - mu) / sigma X_val = (X_val - mu) / sigma X_test = (X_test - mu) / sigma return X_train, X_val, X_test

标签如果是二分类,整理成(m, 1)的二维数组,取值 0 或 1。多分类的话,用 one-hot 编码,形状是(m, num_classes)。

4.2 网络搭建与参数初始化实操

假设我们搭一个三层网络:输入 10 维,隐藏层 64 维,再隐藏层 32 维,输出 1 维。参数用字典存储,键名统一为W1、b1、W2、b2、W3、b3。

def init_network(layer_dims): params = {} L = len(layer_dims) - 1 for l in range(1, L + 1): W, b = he_init(layer_dims[l-1], layer_dims[l]) params[f'W{l}'] = W params[f'b{l}'] = b return params layer_dims = [10, 64, 32, 1] params = init_network(layer_dims)

初始化后,我习惯打印每一层的权重形状,确认跟预期一致。W1应该是(10, 64),W2是(64, 32),W3是(32, 1)。偏置分别是(1, 64)、(1, 32)、(1, 1)。

4.3 训练循环:前向、反向、更新的完整串联

训练循环是整篇代码的主干。每一轮迭代做四件事:前向传播算输出,计算损失,反向传播算梯度,更新参数。我一般会每 100 轮打印一次损失,观察下降趋势。

def train(X, Y, layer_dims, lr=0.05, epochs=5000, print_every=500): params = init_network(layer_dims) losses = [] for i in range(epochs): A_out, caches = forward(X, params) loss = compute_loss(A_out, Y) grads = backward(caches, Y, params, X) params = update_params(params, grads, lr) losses.append(loss) if i % print_every == 0: print(f'Epoch {i}, Loss: {loss:.6f}') return params, losses

这里有个细节:backward函数需要传入X,因为第一层的A_prev就是输入数据。我在前面的backward代码里用了X变量,实际调用时要确保它作为参数传进去,或者定义成全局变量。更规范的做法是显式传参。

4.4 训练过程监控与收敛判断

损失曲线是判断训练是否正常的核心依据。健康的曲线应该是单调下降,前期下降快,后期逐渐平缓。如果曲线震荡剧烈,说明学习率太大;如果曲线几乎不降,说明学习率太小或者初始化有问题;如果曲线先降后升,说明过拟合了。

我一般会同时记录训练集和验证集的损失。如果训练损失持续下降但验证损失开始上升,就是过拟合的信号,这时候可以加 L2 正则、Dropout,或者减少网络容量。如果两者都降不下去,就是欠拟合,需要增加网络容量或训练轮数。

def evaluate(X, Y, params): A_out, _ = forward(X, params) preds = (A_out > 0.5).astype(int) acc = np.mean(preds == Y) return acc

准确率只是参考,类别不平衡时准确率会失真。更靠谱的指标是精确率、召回率和 F1 值,这些可以基于混淆矩阵算出来。

4.5 一次完整的训练记录与结果分析

我用一个模拟数据集跑了一次完整训练。数据 1000 条,10 维特征,二分类,训练集 800 条,验证集 200 条。网络结构[10, 64, 32, 1],学习率 0.05,训练 5000 轮。

前 500 轮损失从 0.693 降到 0.312,下降很快。1000 轮时降到 0.198,2000 轮时 0.121,5000 轮时 0.087。验证集准确率最终到 0.91,训练集准确率 0.94,差距不大,没有明显过拟合。

这个结果说明网络容量和训练轮数基本合适。如果验证准确率明显低于训练准确率,我会考虑加正则;如果两者都低,我会加宽隐藏层或者增加层数。整个过程没有用到任何框架,全部是 numpy 手写,每一步都可追溯。

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

5.1 损失变成 NaN 的几种原因和排查顺序

损失变 NaN 是最常见也最让人头疼的问题。我遇到过好几次,排查下来无非这几个原因:

第一,学习率太大。梯度更新步子太猛,权重直接飞到很大的值,下一轮前向传播时指数溢出。解决办法是把学习率调小一个数量级再试。

第二,log(0) 问题。交叉熵里如果预测值恰好是 0 或 1,log会返回负无穷。解决办法是给预测值加 clip,限制在[1e-15, 1-1e-15]之间。

第三,数据里有 NaN 或 Inf。原始数据没清洗干净,标准化之后依然有异常值。解决办法是在训练前用np.isnan()和np.isinf()检查一遍,把异常样本剔除或填充。

第四,权重初始化太大。前面说过,初始化标准差太大导致激活值爆炸。换成 He 初始化通常能解决。

排查顺序我建议从学习率开始,因为这是最容易改的;然后检查数据;最后检查初始化和损失函数。

5.2 梯度消失与梯度爆炸的识别与缓解

梯度消失的表现是:靠近输入层的权重几乎不更新,损失下降极慢,深层网络尤其明显。梯度爆炸的表现是:损失剧烈震荡,权重数值快速增大,最终变成 NaN。

识别方法很简单:在反向传播时打印每一层梯度的范数。如果从输出层到输入层,梯度范数指数级减小,就是梯度消失;指数级增大,就是梯度爆炸。

缓解梯度消失的办法:用 ReLU 替代 Sigmoid,用 He 初始化,加 Batch Normalization,或者用残差连接。缓解梯度爆炸的办法:梯度裁剪,把梯度的范数限制在一个阈值内;或者减小学习率。

def clip_gradients(grads, max_norm=5.0): total_norm = 0 for g in grads.values(): total_norm += np.sum(g ** 2) total_norm = np.sqrt(total_norm) if total_norm > max_norm: scale = max_norm / (total_norm + 1e-8) for k in grads: grads[k] *= scale return grads

5.3 过拟合与欠拟合的判断和应对

过拟合和欠拟合是两种相反的病,判断依据是训练损失和验证损失的对比。

现象训练损失验证损失判断应对
过拟合很低明显偏高且上升模型记住了训练集噪声加 L2 正则、Dropout、减少容量、增加数据
欠拟合偏高偏高模型容量不足增加层数或神经元、延长训练、调大学习率
正常低略高于训练泛化良好保持

L2 正则的做法是在损失函数里加上权重平方和乘以系数lambda,反向传播时梯度里也加上lambda * W。Dropout 是在训练时随机把一部分神经元置零,测试时按比例缩放。这两个技巧在前馈网络里都很有效。

5.4 常见问题速查表

问题可能原因排查方法解决方案
损失不下降学习率太小、初始化不当打印梯度范数调大学习率、换 He 初始化
损失震荡学习率太大观察损失曲线学习率减半
损失 NaN学习率大、log(0)、数据异常检查数据和梯度调小学习率、clip 预测值、清洗数据
验证准确率低过拟合对比训练验证损失加正则、Dropout、增数据
训练太慢网络太大、学习率小看每轮耗时减容量、调大学习率、用向量化
梯度消失激活函数饱和、初始化小打印各层梯度范数换 ReLU、He 初始化、加 BN

提示:这张表我建议打印出来贴在显示器旁边,调参的时候对照着看,能省很多时间。

5.5 几个我踩过的坑和独家经验

第一个坑:忘记除以 batch_size。反向传播算梯度时,dW和db都要除以样本数m,因为损失是 batch 平均。漏掉这一步,梯度会大m倍,学习率相当于放大了m倍,直接爆炸。

第二个坑:ReLU 的导数写错。relu'(Z)在Z > 0时为 1,Z <= 0时为 0。我见过有人写成Z >= 0时为 1,差别不大但严格来说Z = 0处不可导,取 0 更稳妥。

第三个坑:参数更新时用了错误的梯度。有一次我把dW和db的键名搞混了,W用了db的梯度,训练了半天损失不降。后来加了断言检查形状才找到问题。

第四个经验:先用小数据验证逻辑。正式训练前,我会拿 10 条样本跑一遍,确认前向输出形状对、损失能算、梯度能回传、参数能更新。逻辑通了再上全量数据,能省很多调试时间。

第五个经验:学习率配合 batch_size 调整。batch_size 变大时,梯度估计更稳,学习率可以适当调大;batch_size 变小时,梯度噪声大,学习率要调小。经验公式是学习率跟 batch_size 的平方根成正比。

6. 从手写网络到理解更复杂结构的桥梁

把前馈网络手写一遍之后,再去看卷积神经网络、循环神经网络、LSTM、图神经网络,你会发现它们的核心机制都是相通的:前向传播算输出,损失函数衡量差距,反向传播算梯度,梯度下降更新参数。区别只在于层与层之间的连接方式不同。

卷积神经网络用卷积核代替全连接,实现了权值共享和局部感受野,参数量大幅减少,适合处理图像。循环神经网络在时间维度上共享权重,适合处理序列。LSTM 在 RNN 基础上加了输入门、遗忘门、输出门,缓解了长序列的梯度消失。图神经网络处理的是图结构数据,邻居节点的信息通过邻接矩阵聚合。

这些结构你都可以在前馈网络的代码框架上扩展。比如把全连接层换成卷积层,只需要改前向传播里的矩阵乘法和反向传播里的梯度计算,训练循环和参数更新逻辑完全不用动。这也是我坚持从零手写的原因:你掌握的不是一段代码,而是一个可以套用到任何网络结构上的思维框架。

后面如果要做图像分类,可以在这个基础上加卷积层和池化层;要做序列预测,可以加循环连接。每一步扩展你都知道自己在改什么、为什么改,而不是对着框架文档猜参数。这个系列写到第六篇,核心想传达的就是这件事:底层通了,上层才不虚。