从零手写前馈神经网络:Python+NumPy实现前向传播与反向传播

发布时间:2026/10/26 8:07:52
从零手写前馈神经网络:Python+NumPy实现前向传播与反向传播 1. 从零搭网络这件事为什么值得反复折腾很多人学神经网络路径都差不多先看一遍反向传播的公式推导再跑一个框架自带的示例最后调包训练一个手写数字识别准确率刷到98%就觉得自己会了。可真到了要改结构、换损失、排查梯度异常的时候脑子里那套东西立刻散架。我自己就经历过这个阶段当时用现成的库跑模型跑得飞起结果有一次遇到一个自定义的前馈网络需求输入维度不固定输出要映射到一个高维向量空间我盯着那几行配置愣是不知道从哪下手。这就是“从零开始”系列存在的意义。第六篇要聊的是把一个最基础的前馈神经网络从数据结构、前向传播、反向传播到参数更新全部用原生Python加NumPy手写出来。不依赖任何深度学习框架不调用现成的层和优化器。你可能会问现在框架这么成熟为什么还要手写我的答案很直接手写一遍你才知道框架帮你藏了多少东西也才知道当模型不收敛时该从哪个环节去查。这篇文章适合谁看如果你已经会Python基础语法知道列表和字典怎么用对矩阵乘法有点印象但不太熟那正好。如果你已经用过PyTorch或TensorFlow但一直没搞明白反向传播里那些梯度到底怎么算出来的这篇也能帮你把底层逻辑补齐。我会把每一步的计算过程、参数选择理由、以及实际踩过的坑都摊开讲代码可以直接复制运行改几个数字就能做自己的实验。核心关键词就两个Python和神经网络。但我不想把它写成又一篇教科书式的推导而是当成一次真实的项目复盘——从需求拆解到代码落地中间哪些地方卡住了哪些地方有更省事的写法哪些参数一改就崩我都会说清楚。2. 整体设计与思路拆解2.1 为什么选前馈网络作为手写对象神经网络的种类很多卷积网络擅长处理图像循环网络适合序列图神经网络处理关系数据。但如果目标是理解“神经网络到底怎么学习”这件事前馈网络是最干净的载体。它没有卷积的权值共享没有循环的时间展开没有图结构的邻接矩阵就是一层一层做矩阵乘法加激活函数最后算损失、求梯度、更新参数。所有复杂的网络拆到最底层都是这个逻辑的堆叠。我在实际项目里遇到过一个人脸识别的需求输入是一张人脸图像输出是一个高维向量用来做特征比对。当时用的就是类似前馈网络的结构只不过前面加了几层卷积做特征提取。但真正把图像映射到向量空间的那部分本质还是全连接层。所以把前馈网络吃透后面加卷积、加注意力机制都只是在这个骨架上做扩展。另一个考虑是计算量。手写网络如果层数太深、维度太大纯NumPy跑起来会很慢调试体验很差。前馈网络结构简单用几百个样本、几十个特征就能跑出明显的学习效果适合反复实验。我建议一开始不要把网络搞得太复杂两层隐藏层足够验证你的实现是否正确。2.2 不用框架的代价与收益不用框架意味着你要自己处理几件事参数的初始化、前向传播的矩阵运算、损失函数的计算、反向传播的链式求导、参数的更新规则。每一件事都有坑。比如参数初始化如果你全初始化为零网络永远学不到东西因为所有神经元的梯度都一样更新后还是对称的。如果你初始化得太大激活函数会饱和梯度接近零网络也学不动。但收益也很明显。当你自己实现过一遍反向传播再去看框架里的loss.backward()你就知道那一行背后发生了什么。当模型不收敛时你可以打印每一层的梯度范数看是哪一层出了问题而不是盲目调学习率。当你要实现一个框架里没有的激活函数或损失函数时你知道需要同时实现它的前向和反向而不是只写一个公式就完事。我个人的经验是手写一遍之后再用框架调参效率至少提升一倍。因为你对参数的敏感度有了直觉知道学习率大概在什么量级知道批大小对梯度的影响知道什么时候该加正则化。2.3 整体架构的模块划分为了让代码清晰可调试我把整个网络拆成几个独立的模块。第一个是数据准备模块负责生成或加载训练数据做归一化处理。第二个是网络结构定义模块包括层的定义、激活函数、损失函数。第三个是前向传播模块负责从输入一路算到输出。第四个是反向传播模块负责从损失往回算每一层的梯度。第五个是训练循环模块负责迭代、更新参数、打印日志。这种拆分方式的好处是每个模块可以单独测试。比如你可以先固定一组参数手动算一遍前向传播的结果再和代码的输出对比确认前向没问题了再去调反向。如果一上来就把所有东西写在一起出了bug很难定位是前向算错了还是反向算错了。我见过很多人写代码喜欢一口气写完再跑结果报错之后到处加print效率很低。我的习惯是每写一个函数就用一个小例子验证一下确认输入输出符合预期再往下写。这个习惯在写神经网络时尤其重要因为矩阵维度对不上是家常便饭早发现早解决。3. 核心细节解析与实操要点3.1 参数初始化为什么不能全零也不能太大参数初始化是很多人忽略的一步但它直接决定网络能不能学起来。我先说结论全零初始化绝对不行。原因很简单如果第一层隐藏层的所有权重都是零那么所有神经元的输出都一样反向传播时梯度也一样更新之后权重还是相同的。这意味着无论你训练多久这一层的所有神经元都在做同一件事网络的表达能力被浪费了。那初始化成小的随机数呢这是常见做法但“小”是多少有讲究。如果权重太小信号在层间传递时会逐层衰减到了深层几乎为零梯度也接近零网络学不动。如果权重太大经过激活函数时容易进入饱和区比如Sigmoid函数在输入很大或很小时梯度接近零同样导致学习停滞。我常用的做法是Xavier初始化也叫Glorot初始化。它的核心思想是让每一层的输出方差和输入方差保持一致避免信号在传播过程中被放大或缩小。具体来说权重从均值为零、方差为2/(fan_in fan_out)的正态分布中采样其中fan_in是输入神经元数量fan_out是输出神经元数量。如果激活函数是ReLU可以用He初始化方差改为2/fan_in因为ReLU会把一半的神经元置零需要更大的方差来补偿。偏置项通常初始化为零这没问题因为偏置不参与对称性问题只要权重是随机的神经元就会分化。注意初始化之后建议打印一下每层权重的均值和标准差确认没有出现极端值。如果标准差比预期大一个数量级检查一下方差公式是不是用错了。3.2 前向传播矩阵维度对齐是第一步前向传播的公式看起来简单Z W * X bA activation(Z)。但实际写代码时最容易出错的是矩阵维度。假设输入是一个样本特征维度是n_in那么输入X的形状是(n_in, 1)。第一层权重W1的形状应该是(n_hidden, n_in)偏置b1的形状是(n_hidden, 1)。这样W1 * X得到(n_hidden, 1)加上b1之后形状不变再经过激活函数得到第一层的输出A1。如果处理的是批量样本比如一批有m个样本输入X的形状变成(n_in, m)那么W1 * X得到(n_hidden, m)偏置b1仍然是(n_hidden, 1)但NumPy的广播机制会自动把它扩展到(n_hidden, m)所以代码不用改。这一点很方便但也要小心如果你不小心把偏置写成了(n_hidden, m)广播会出错或者结果不对。我习惯在每一层的前向函数里加一个断言检查输入维度和权重维度是否匹配。比如assert X.shape[0] self.W.shape[1]这样一旦维度不对立刻报错而不是等到后面计算出奇怪的结果才发现。激活函数的选择也影响前向传播的数值稳定性。Sigmoid和Tanh在输入较大时容易饱和导致梯度消失。ReLU计算简单在正区间梯度恒为1能缓解梯度消失但负区间梯度为零可能导致神经元“死亡”。实际项目中隐藏层我通常用ReLU输出层根据任务选Sigmoid二分类或Softmax多分类。如果是回归任务输出层不加激活函数。3.3 损失函数从均方误差到交叉熵损失函数衡量的是网络输出和真实标签之间的差距。回归任务常用均方误差公式是L (1/m) * sum((y_pred - y_true)^2)。分类任务常用交叉熵公式是L -(1/m) * sum(y_true * log(y_pred))。为什么分类不用均方误差因为均方误差配合Sigmoid输出时梯度里会包含Sigmoid的导数项而Sigmoid在饱和区导数接近零导致梯度消失。交叉熵的梯度形式更简洁对于Softmax输出梯度就是y_pred - y_true没有额外的饱和项学习更稳定。我在实现交叉熵时踩过一个坑如果y_pred里有零log(0)会变成负无穷导致损失为NaN。解决办法是在取对数之前给y_pred加一个极小值比如1e-12或者用NumPy的clip函数把概率限制在[1e-12, 1-1e-12]之间。这个细节在框架里通常被自动处理了手写的时候必须自己注意。3.4 反向传播链式法则的工程实现反向传播是手写神经网络的核心难点。原理就是链式法则从损失开始逐层往回求梯度。假设最后一层的输出是A_L损失是L那么dL/dZ_L可以根据损失函数和激活函数的组合直接算出来。对于Softmax加交叉熵的组合dL/dZ_L A_L - Y非常简洁。对于Sigmoid加均方误差dL/dZ_L (A_L - Y) * A_L * (1 - A_L)。得到dZ_L之后往回传播的公式是dW_l dZ_l * A_{l-1}.T / mdb_l sum(dZ_l) / mdA_{l-1} W_l.T * dZ_l然后dZ_{l-1} dA_{l-1} * activation_derivative(Z_{l-1})。这个链条要一层一层往回走直到第一层。实现时最容易出错的地方是转置和求和。dW_l的形状必须和W_l一致db_l的形状必须和b_l一致。我建议每算完一个梯度就打印一下形状和对应的参数对比确认一致再继续。另外除以m是做平均如果损失函数里已经除了m这里就不用再除否则梯度会偏小。这个细节要根据损失函数的定义来定不能想当然。提示反向传播的调试技巧是“梯度检验”。用数值近似的方法算一遍梯度和反向传播算出来的梯度对比如果相对误差在1e-7以内说明实现正确。这个方法虽然慢但能帮你排除绝大部分bug。4. 实操过程与核心环节实现4.1 环境准备与依赖安装我用的环境是Python 3.8以上只需要NumPy一个第三方库。如果你还没装NumPy在命令行里执行pip install numpy就行。不需要安装深度学习框架也不需要GPU。代码在任何能跑Python的机器上都能运行包括树莓派这类小型设备。我建议用虚拟环境来管理依赖避免和系统里的其他包冲突。创建虚拟环境的命令是python -m venv nn_env激活之后再用pip安装NumPy。这样即使你后面要装其他版本的库也不会影响这个项目。代码编辑器用什么都行VS Code、PyCharm、甚至Jupyter Notebook都可以。我个人喜欢用Jupyter Notebook做实验因为可以分块运行方便查看中间结果。但如果你要写成一个完整的脚本用普通的.py文件也没问题。4.2 数据生成与预处理为了验证网络的学习能力我构造一个简单的二分类数据集。生成两组二维数据一组围绕点(2, 2)分布标签为1另一组围绕点(-2, -2)分布标签为0。每组生成500个样本加上一些随机噪声让数据不是完全线性可分。生成数据的代码如下import numpy as np np.random.seed(42) def generate_data(n_samples500): X1 np.random.randn(n_samples, 2) * 0.8 np.array([2, 2]) y1 np.ones((n_samples, 1)) X2 np.random.randn(n_samples, 2) * 0.8 np.array([-2, -2]) y2 np.zeros((n_samples, 1)) X np.vstack([X1, X2]) y np.vstack([y1, y2]) indices np.random.permutation(len(X)) return X[indices], y[indices]生成之后要做归一化把每个特征的均值变成零标准差变成一。这一步很重要因为如果特征尺度差异大梯度下降会走得很慢甚至震荡。归一化的代码是X (X - X.mean(axis0)) / X.std(axis0)。数据还要划分训练集和测试集我通常按8:2的比例分。训练集用来更新参数测试集用来评估泛化能力。如果测试集准确率远低于训练集说明过拟合了需要加正则化或减少网络容量。4.3 网络类的完整实现我把网络封装成一个类包含初始化、前向传播、反向传播、参数更新这几个方法。先定义网络结构输入层2个神经元第一隐藏层8个神经元第二隐藏层4个神经元输出层1个神经元。激活函数隐藏层用ReLU输出层用Sigmoid。初始化的代码class NeuralNetwork: def __init__(self, layer_sizes): self.params {} self.layer_sizes layer_sizes for i in range(1, len(layer_sizes)): fan_in layer_sizes[i-1] fan_out layer_sizes[i] std np.sqrt(2.0 / fan_in) self.params[fW{i}] np.random.randn(fan_out, fan_in) * std self.params[fb{i}] np.zeros((fan_out, 1))前向传播的代码def forward(self, X): cache {A0: X} A X L len(self.layer_sizes) - 1 for i in range(1, L): Z self.params[fW{i}] A self.params[fb{i}] A np.maximum(0, Z) cache[fZ{i}] Z cache[fA{i}] A Z self.params[fW{L}] A self.params[fb{L}] A 1 / (1 np.exp(-Z)) cache[fZ{L}] Z cache[fA{L}] A return A, cache反向传播的代码def backward(self, X, y, cache): m X.shape[1] grads {} L len(self.layer_sizes) - 1 A_L cache[fA{L}] dZ A_L - y for i in range(L, 0, -1): A_prev cache[fA{i-1}] grads[fdW{i}] dZ A_prev.T / m grads[fdb{i}] np.sum(dZ, axis1, keepdimsTrue) / m if i 1: dA_prev self.params[fW{i}].T dZ dZ dA_prev * (cache[fZ{i-1}] 0) return grads参数更新用梯度下降def update(self, grads, lr): for i in range(1, len(self.layer_sizes)): self.params[fW{i}] - lr * grads[fdW{i}] self.params[fb{i}] - lr * grads[fdb{i}]4.4 训练循环与参数选择训练循环里每个epoch把训练数据分成若干批次每批做一次前向、反向、更新。批大小的选择有讲究太小的话梯度噪声大训练不稳定太大的话内存占用高而且梯度太平均可能陷入尖锐的局部极小值。我通常从32或64开始试根据数据量调整。学习率是最关键的参数。太大容易震荡甚至发散太小收敛慢。我一般先试0.01如果损失下降太慢就调到0.1如果损失震荡就降到0.001。配合学习率衰减效果更好比如每10个epoch乘以0.9。训练过程中要打印损失和准确率观察趋势。如果损失不下降检查梯度是否为零或者学习率是否太小。如果损失变成NaN检查是否有log(0)或除以零的情况。def train(model, X_train, y_train, epochs100, batch_size32, lr0.1): m X_train.shape[1] for epoch in range(epochs): indices np.random.permutation(m) X_shuffled X_train[:, indices] y_shuffled y_train[:, indices] for i in range(0, m, batch_size): X_batch X_shuffled[:, i:ibatch_size] y_batch y_shuffled[:, i:ibatch_size] A, cache model.forward(X_batch) grads model.backward(X_batch, y_batch, cache) model.update(grads, lr) if epoch % 10 0: A_full, _ model.forward(X_train) loss -np.mean(y_train * np.log(A_full 1e-12) (1 - y_train) * np.log(1 - A_full 1e-12)) acc np.mean((A_full 0.5) y_train) print(fEpoch {epoch}, Loss: {loss:.4f}, Acc: {acc:.4f})4.5 结果验证与梯度检验训练完成后在测试集上评估准确率。如果测试准确率在95%以上说明网络学到了数据的分布。如果只有70%左右可能是网络容量不够或者训练不够充分。梯度检验是验证反向传播正确性的黄金标准。取一组小数据用数值近似算梯度grad_approx (L(theta eps) - L(theta - eps)) / (2 * eps)然后和反向传播算出的梯度对比。如果相对误差小于1e-7说明实现正确。这个步骤虽然费时间但能帮你排除很多隐蔽的bug。def gradient_check(model, X, y, eps1e-7): A, cache model.forward(X) grads model.backward(X, y, cache) for key in grads: param model.params[key.replace(d, )] num_grad np.zeros_like(param) it np.nditer(param, flags[multi_index]) while not it.finished: idx it.multi_index old_val param[idx] param[idx] old_val eps A_plus, _ model.forward(X) loss_plus -np.mean(y * np.log(A_plus 1e-12) (1 - y) * np.log(1 - A_plus 1e-12)) param[idx] old_val - eps A_minus, _ model.forward(X) loss_minus -np.mean(y * np.log(A_minus 1e-12) (1 - y) * np.log(1 - A_minus 1e-12)) param[idx] old_val num_grad[idx] (loss_plus - loss_minus) / (2 * eps) it.iternext() diff np.linalg.norm(grads[key] - num_grad) / (np.linalg.norm(grads[key]) np.linalg.norm(num_grad) 1e-12) print(f{key}: relative error {diff:.2e})5. 常见问题与排查技巧实录5.1 损失不下降的几种可能损失不下降是最常见的问题原因可能有很多。第一学习率太小参数更新幅度不够。解决办法是调大学习率比如从0.01调到0.1。第二梯度消失深层网络的梯度接近零。检查每一层的梯度范数如果某一层特别小可能是激活函数饱和了换ReLU试试。第三数据没有归一化特征尺度差异大导致梯度方向震荡。第四标签和输出不匹配比如输出是概率但标签是类别索引导致损失计算错误。我遇到过一次损失卡在0.69不动的情况后来发现是权重初始化太小所有输出都接近0.5梯度几乎为零。换成Xavier初始化之后损失立刻开始下降。5.2 梯度爆炸与梯度裁剪梯度爆炸通常发生在网络较深或学习率较大的时候。表现是损失突然变成NaN或者参数值变得极大。解决办法是梯度裁剪把梯度的范数限制在一个阈值内。具体做法是算完梯度后如果梯度的L2范数超过阈值就按比例缩小。def clip_gradients(grads, max_norm5.0): total_norm np.sqrt(sum(np.sum(g**2) for g in grads.values())) if total_norm max_norm: scale max_norm / (total_norm 1e-12) for key in grads: grads[key] * scale return grads梯度裁剪在训练循环网络时尤其重要因为循环网络的梯度会沿时间步累积容易爆炸。前馈网络虽然没那么严重但加上裁剪能提高稳定性。5.3 过拟合与正则化如果训练准确率很高但测试准确率低说明过拟合了。解决办法有几种增加数据量、减少网络容量、加L2正则化、加Dropout。L2正则化是在损失函数里加上权重的平方和乘以一个系数迫使权重变小。Dropout是在训练时随机把一部分神经元的输出置零防止网络过度依赖某些神经元。我在手写网络里实现L2正则化很简单在损失里加0.5 * lambda * sum(W^2)在梯度里加lambda * W。lambda通常取0.001到0.01之间。Dropout实现稍复杂需要在训练时生成一个掩码前向传播时乘以掩码反向传播时梯度也乘以掩码。5.4 常见问题速查表问题现象可能原因排查方法解决办法损失不下降学习率太小打印梯度范数调大学习率损失变成NaN梯度爆炸或log(0)检查梯度范数和概率值梯度裁剪加极小值训练准确率高但测试低过拟合对比训练和测试损失加正则化或减少容量准确率震荡批大小太小观察每个batch的损失增大批大小输出全是0.5权重初始化太小打印权重标准差换Xavier初始化梯度为零激活函数饱和打印激活值分布换ReLU或调小学习率提示排查问题时先确认数据没问题再确认前向传播没问题最后查反向传播。这个顺序能帮你快速定位问题所在。5.5 实操心得与避坑技巧第一个心得是每次改代码只改一个地方改完立刻跑一遍看结果。如果一次改多个地方出了问题不知道是哪个改动导致的。第二个心得是把中间结果保存下来比如每层的输出、梯度、损失方便事后分析。第三个心得是不要迷信默认参数学习率、批大小、初始化方法都要根据具体任务调。我踩过最大的坑是在反向传播里忘了除以批大小导致梯度比预期大32倍训练直接发散。后来养成了习惯每算完一个梯度就检查它的量级和参数的量级对比如果差了几个数量级肯定有问题。还有一个坑是数据泄露。我在划分训练集和测试集之前就做了归一化导致测试集的统计信息泄露到了训练过程中。正确的做法是先划分再分别对训练集和测试集做归一化用训练集的均值和标准差去处理测试集。6. 从手写网络到真实项目的扩展思路手写前馈网络只是起点。当你理解了前向、反向、更新的完整流程就可以在这个骨架上加东西。比如加卷积层核心是把矩阵乘法换成卷积运算反向传播时卷积的梯度计算稍微复杂一点但原理一样。加循环层核心是在时间步上展开梯度要沿时间步反向传播。加注意力机制核心是计算注意力权重然后对值加权求和。我在实际项目里做过一个建材价格预测的任务输入是历史价格、原材料成本、季节因素等多个特征输出是未来一段时间的价格。用的就是类似前馈网络的结构只不过在特征工程上花了很多功夫。神经网络本身并不复杂难的是把业务问题转化成网络能处理的输入输出格式。如果你想把这篇的代码用到真实数据上我建议先从结构化数据入手比如表格数据。把类别特征做独热编码数值特征做归一化然后套用这个网络结构。如果效果不好再考虑加层、加正则化、换激活函数。不要一上来就搞很复杂的结构简单模型先跑通再逐步优化。最后分享一个小技巧在训练之前先用一个极小的数据集比如10个样本跑一遍确认网络能过拟合到这10个样本。如果连10个样本都拟合不了说明代码有bug不用浪费时间调参。这个技巧帮我省了很多调试时间。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询