资讯动态

从零手写前馈神经网络:NumPy实现与梯度检查实战

发布时间:2026/10/26 16:29:28 来源:尧图企业网站定制
1. 从零搭建前馈神经网络的核心设计思路1.1 为什么还要从零手写神经网络很多人第一次接触神经网络都是从调库开始的。sklearn几行代码就能跑一个分类器PyTorch搭个nn.Sequential也能很快出结果。但真到要排查问题的时候比如损失不下降、梯度爆炸、准确率卡在某个值上不去如果不知道底层在算什么基本就只能靠猜。这个系列做到第十一篇核心目的就是把前馈神经网络从输入到输出的每一步都摊开用纯 Python 加 NumPy 实现一遍不依赖任何深度学习框架。前馈神经网络Feedforward Neural Network是最基础的神经网络结构数据从输入层单向流向隐藏层再到输出层没有循环、没有反馈。它的核心计算就是矩阵乘法加激活函数听起来简单但真正手写一遍你会遇到权重初始化、激活函数选择、反向传播链式求导、学习率设置等一系列具体问题。这些问题的处理方式直接决定了网络能不能收敛、收敛得快不快。这篇文章适合已经了解 Python 基础语法、知道 NumPy 数组怎么用、但对神经网络内部机制还比较模糊的读者。如果你之前只是调过库没自己推导过反向传播那这篇内容会帮你把整个流程串起来。如果你已经有一定基础也可以把它当作一次系统复习重点看参数初始化和梯度检查那几节那些是实际项目里最容易踩坑的地方。1.2 整体架构三层结构够用但每层都有讲究这次实现的是一个标准的三层前馈网络输入层、一个隐藏层、输出层。输入层节点数由特征维度决定输出层节点数由分类类别数或回归目标维度决定隐藏层节点数则需要人为设定。隐藏层用 ReLU 激活输出层根据任务类型选择 Softmax多分类或恒等映射回归。为什么选 ReLU 而不是 SigmoidSigmoid 在输入较大或较小时梯度接近零反向传播时梯度会逐层衰减深层网络里这个问题尤其明显。ReLU 在正区间梯度恒为 1能有效缓解梯度消失。但 ReLU 也有自己的问题负区间梯度为零神经元可能“死亡”所以隐藏层节点数不能设得太少学习率也不能太大。输出层用 Softmax 配合交叉熵损失这是多分类任务的标准组合。Softmax 把原始得分转成概率分布交叉熵衡量预测分布和真实分布的差异。两者结合后反向传播时输出层的梯度形式非常简洁预测概率减去真实标签。这个推导过程后面会详细展开。整个网络的前向传播就是输入乘以权重矩阵加上偏置过激活函数再乘以第二层权重加上偏置最后过 Softmax。反向传播则是从损失函数出发逐层求导更新权重和偏置。听起来是链式法则的机械应用但矩阵维度的对齐、转置的位置、求和的轴这些细节错一个梯度就全乱了。1.3 模块划分把每件事拆开做代码结构上我把它拆成几个独立模块数据生成与预处理、参数初始化、前向传播、损失计算、反向传播、参数更新、训练循环、评估与可视化。每个模块单独写成函数方便调试和替换。比如你想换激活函数只需要改激活函数模块和前向传播里对应的调用反向传播里对应的导数计算改一下就行不用动整个训练流程。这种拆分方式还有一个好处梯度检查的时候可以单独调用前向传播和反向传播用数值梯度对比解析梯度快速定位是哪一层的导数算错了。如果所有逻辑揉在一个大函数里排查起来会非常痛苦。数据方面为了聚焦网络本身我用 NumPy 生成一个简单的二维分类数据集类似两个交错的半圆。这个数据集线性不可分必须靠隐藏层的非线性变换才能分开正好能验证网络是否真的学到了非线性特征。生成之后做标准化让每个特征的均值为零、方差为一这样梯度下降更稳定收敛更快。2. 核心细节解析与实操要点2.1 权重初始化别再用全零了权重初始化是很多人容易忽略的一步。如果所有权重初始化为零那么同一层的所有神经元在前向传播时输出相同反向传播时梯度也相同更新后权重依然相同相当于这一层只有一个神经元在起作用网络容量被严重浪费。这叫对称性问题。正确的做法是随机初始化但随机也不是随便随。如果权重太小信号在层层传递中会逐渐缩小到输出层时几乎为零梯度也接近零网络学不动。如果权重太大信号会逐层放大激活函数进入饱和区梯度同样消失。所以初始化的方差需要和输入节点数、输出节点数匹配。对于 ReLU 激活的隐藏层常用 He 初始化权重服从均值为零、方差为2 / n_in的正态分布其中n_in是该层的输入节点数。对于输出层接 Softmax 的情况可以用 Xavier 初始化方差为1 / n_in。偏置通常初始化为零因为权重已经随机了偏置为零不会引入对称性问题。实际操作中我用np.random.randn生成标准正态分布再乘以sqrt(2 / n_in)来缩放。注意n_in是前一层的输出维度不是当前层的维度。这个细节搞错方差就不对了。2.2 激活函数与导数成对出现不能分开写ReLU 的定义很简单max(0, x)。它的导数在正区间为 1负区间为 0零点通常取 0。实现的时候前向传播保存输入或输出反向传播根据输入的正负来判断梯度是保留还是置零。Softmax 稍微麻烦一点。它的输出是一个概率向量每个元素是exp(x_i) / sum(exp(x_j))。直接按这个公式算如果x_i很大exp(x_i)会溢出。所以实际实现要先减去最大值exp(x_i - max(x)) / sum(exp(x_j - max(x)))。数学上等价但数值上稳定得多。Softmax 的导数是一个雅可比矩阵但在配合交叉熵损失时不需要显式计算雅可比。因为交叉熵对 Softmax 输入的梯度就是预测概率 - 真实标签这个结果可以直接用在反向传播里。这也是为什么 Softmax 和交叉熵要绑定使用分开写反而容易出错。注意如果你在输出层用了 Softmax损失函数就必须用交叉熵不要用均方误差。均方误差配 Softmax 会导致梯度形式复杂收敛变慢而且概率输出的物理意义也不匹配。2.3 反向传播链式法则的矩阵形式反向传播的本质是链式法则但写成矩阵形式后维度对齐是关键。假设第l层的权重是W_l偏置是b_l输入是a_{l-1}线性输出是z_l a_{l-1} W_l b_l激活输出是a_l f(z_l)。从损失L对z_l的梯度delta_l出发反向传播的递推关系是delta_{l-1} (delta_l W_l^T) * f(z_{l-1})。这里*是逐元素乘法W_l^T是权重矩阵的转置。权重的梯度是a_{l-1}^T delta_l偏置的梯度是delta_l在样本维度上求和。维度检查假设批量大小为N第l-1层有D个节点第l层有H个节点。那么a_{l-1}是N x DW_l是D x Hz_l和delta_l是N x H。delta_l W_l^T是N x D和a_{l-1}维度一致。a_{l-1}^T delta_l是D x H和W_l维度一致。偏置梯度对N求和后是H维和b_l一致。每一步都检查维度能避免大部分低级错误。2.4 学习率与批量大小两个最影响收敛的超参数学习率决定每次参数更新的步长。太大损失会震荡甚至发散太小收敛慢容易卡在局部极小值。实践中我通常从 0.01 或 0.001 开始试观察损失曲线。如果损失在前几个 epoch 就变成 NaN说明学习率太大如果损失几乎不降说明学习率太小。批量大小影响梯度估计的噪声。批量越大梯度越接近真实梯度但计算开销大而且容易陷入尖锐极小值泛化可能变差。批量越小噪声越大反而可能帮助跳出局部极小值但训练不稳定。对于这个小规模数据集全批量梯度下降就够用因为样本少计算快梯度准确。如果数据量大通常用 mini-batch大小取 32、64、128 这类 2 的幂次方便 GPU 并行。但这次是纯 NumPy 实现CPU 上跑全批量更简单也更容易复现。3. 实操过程与核心环节实现3.1 数据生成与预处理先定义一个生成交错半圆数据集的函数。两个类别每个类别 200 个样本特征维度为 2。类别 0 的点分布在上半圆类别 1 的点分布在下半圆两者有重叠线性分类器无法分开。import numpy as np def make_semicircles(n_samples200, noise0.1): np.random.seed(42) theta np.linspace(0, np.pi, n_samples) x0 np.stack([np.cos(theta), np.sin(theta)], axis1) x1 np.stack([np.cos(theta), -np.sin(theta)], axis1) x0 np.random.randn(n_samples, 2) * noise x1 np.random.randn(n_samples, 2) * noise X np.vstack([x0, x1]) y np.hstack([np.zeros(n_samples), np.ones(n_samples)]).astype(int) return X, y生成之后做标准化减去均值除以标准差。注意标准差按特征维度计算不要按样本计算。标准化后特征的尺度一致梯度下降的等高线更接近圆形收敛更快。def standardize(X): mean X.mean(axis0) std X.std(axis0) return (X - mean) / std, mean, std标签做 one-hot 编码方便和 Softmax 输出计算交叉熵。def one_hot(y, num_classes): return np.eye(num_classes)[y]3.2 参数初始化与网络配置隐藏层节点数设为 64。输入维度是 2输出维度是 2二分类。权重用 He 初始化偏置为零。def init_params(input_dim, hidden_dim, output_dim): np.random.seed(1) W1 np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim) b1 np.zeros((1, hidden_dim)) W2 np.random.randn(hidden_dim, output_dim) * np.sqrt(1.0 / hidden_dim) b2 np.zeros((1, output_dim)) return {W1: W1, b1: b1, W2: W2, b2: b2}这里W1的缩放因子是sqrt(2 / input_dim)因为后面接 ReLU。W2的缩放因子是sqrt(1 / hidden_dim)因为后面接 Softmax。偏置初始化为零形状是1 x hidden_dim和1 x output_dim方便广播。3.3 前向传播与损失计算前向传播分两步第一层线性变换加 ReLU第二层线性变换加 Softmax。def relu(x): return np.maximum(0, x) def softmax(x): x_shift x - np.max(x, axis1, keepdimsTrue) exp_x np.exp(x_shift) return exp_x / np.sum(exp_x, axis1, keepdimsTrue) def forward(X, params): W1, b1, W2, b2 params[W1], params[b1], params[W2], params[b2] z1 X W1 b1 a1 relu(z1) z2 a1 W2 b2 a2 softmax(z2) cache {X: X, z1: z1, a1: a1, z2: z2, a2: a2} return a2, cache交叉熵损失对每个样本取真实类别对应的预测概率的负对数然后求平均。def cross_entropy_loss(y_onehot, probs): n y_onehot.shape[0] log_likelihood -np.log(probs[np.arange(n), y_onehot.argmax(axis1)] 1e-12) return np.sum(log_likelihood) / n加1e-12是防止log(0)出现负无穷。虽然 Softmax 输出不会精确为零但浮点运算下可能非常小加一个极小值更安全。3.4 反向传播与参数更新反向传播从输出层开始。delta2 probs - y_onehot这是 Softmax 加交叉熵的梯度。然后dW2 a1.T delta2db2 sum(delta2, axis0)。接着传到隐藏层delta1 (delta2 W2.T) * (z1 0)dW1 X.T delta1db1 sum(delta1, axis0)。def backward(cache, params, y_onehot): X, z1, a1, a2 cache[X], cache[z1], cache[a1], cache[a2] W2 params[W2] n X.shape[0] delta2 a2 - y_onehot dW2 a1.T delta2 / n db2 np.sum(delta2, axis0, keepdimsTrue) / n delta1 (delta2 W2.T) * (z1 0) dW1 X.T delta1 / n db1 np.sum(delta1, axis0, keepdimsTrue) / n return {dW1: dW1, db1: db1, dW2: dW2, db2: db2}注意这里除以了n因为损失是平均损失梯度也要平均。如果损失是求和梯度就不除。这个细节要和损失函数保持一致否则学习率需要相应调整。参数更新就是梯度下降W W - lr * dW。def update_params(params, grads, lr): for key in params: params[key] - lr * grads[d key] return params3.5 训练循环与损失曲线训练循环里每个 epoch 做一次前向传播、计算损失、反向传播、更新参数。每 100 个 epoch 打印一次损失和准确率。def train(X, y, hidden_dim64, lr0.1, epochs5000): input_dim X.shape[1] output_dim len(np.unique(y)) y_onehot one_hot(y, output_dim) params init_params(input_dim, hidden_dim, output_dim) losses [] for epoch in range(epochs): probs, cache forward(X, params) loss cross_entropy_loss(y_onehot, probs) grads backward(cache, params, y_onehot) params update_params(params, grads, lr) losses.append(loss) if epoch % 500 0: preds np.argmax(probs, axis1) acc np.mean(preds y) print(fEpoch {epoch}, Loss: {loss:.4f}, Acc: {acc:.4f}) return params, losses学习率设 0.1epoch 设 5000。这个数据集简单5000 轮足够收敛到 95% 以上准确率。如果损失曲线震荡可以降到 0.05 或 0.01。如果收敛太慢可以升到 0.2但再大可能发散。3.6 梯度检查数值梯度对比解析梯度梯度检查是验证反向传播实现是否正确的重要手段。数值梯度的定义是对某个参数加上微小扰动epsilon计算损失变化除以2 * epsilon。然后和解析梯度对比相对误差应该在1e-6以下。def gradient_check(X, y, params, epsilon1e-6): y_onehot one_hot(y, 2) probs, cache forward(X, params) grads backward(cache, params, y_onehot) for key in params: param params[key] 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 epsilon loss_plus cross_entropy_loss(y_onehot, forward(X, params)[0]) param[idx] old_val - epsilon loss_minus cross_entropy_loss(y_onehot, forward(X, params)[0]) param[idx] old_val num_grad[idx] (loss_plus - loss_minus) / (2 * epsilon) it.iternext() rel_error np.abs(num_grad - grads[d key]) / (np.abs(num_grad) np.abs(grads[d key]) 1e-8) print(f{key} max relative error: {np.max(rel_error):.2e})跑一遍梯度检查如果某个参数的相对误差超过1e-4说明那一层的反向传播有问题。常见原因包括激活函数导数写错、矩阵转置位置不对、求和轴不对、忘记除以批量大小。4. 常见问题与排查技巧实录4.1 损失不下降或变成 NaN损失变成 NaN 通常是因为学习率太大导致参数更新后 Softmax 输入过大exp溢出。解决办法是降低学习率或者在 Softmax 里减去最大值。如果损失不下降先检查梯度是否为零。可能是权重初始化太小或者 ReLU 全部死亡。可以打印每层激活值的均值和方差如果隐藏层输出全是零说明 ReLU 死了需要减小学习率或换用 Leaky ReLU。另一个常见原因是标签和输出维度不匹配。比如二分类用了两个输出节点但标签是 0 和 1one-hot 编码后是[1,0]和[0,1]没问题。但如果标签是 -1 和 1one-hot 编码就会出错。检查标签的取值范围和num_classes是否一致。4.2 准确率卡在 50% 左右二分类任务准确率 50% 相当于随机猜。如果训练损失在下降但准确率不升可能是标签和预测的类别顺序反了。比如argmax取的是索引但标签是 1 和 2索引是 0 和 1对不上。统一用 0 开始的整数标签one-hot 编码后就没问题。如果损失也不降检查数据是否标准化。未标准化的数据特征尺度差异大梯度下降会走 Z 字形收敛很慢。另外检查权重初始化如果W1全部初始化为零隐藏层所有神经元输出相同网络退化成线性模型无法拟合异或类问题。4.3 梯度检查相对误差偏大相对误差在1e-6到1e-4之间通常可以接受因为数值梯度本身有截断误差。但如果超过1e-3基本可以确定反向传播有 bug。常见问题包括ReLU 导数在零点取 1 还是 0这个影响不大但如果在零点附近有样本误差会略大。更常见的是 Softmax 和交叉熵的梯度推导错误比如忘了除以批量大小或者delta2写成了a2 - y_onehot但y_onehot没有转置。还有一个隐蔽的问题z1 0返回的是布尔数组乘以delta1时会自动转成 0 和 1。但如果z1的形状是N x Hdelta2 W2.T也是N x H逐元素乘法没问题。如果形状不一致NumPy 会广播可能产生错误结果但不报错。所以每一步都打印形状确认维度对齐。4.4 训练速度慢纯 NumPy 实现矩阵乘法在 CPU 上跑数据量小的时候还好数据量大了会明显变慢。优化方向有几个用float32代替float64减少内存带宽和计算量用批量梯度下降代替全批量减少每次迭代的计算量用np.dot代替虽然现在两者等价但某些 NumPy 版本下dot更快。如果隐藏层节点数很大比如 1024矩阵乘法会成为瓶颈。这时候可以考虑用numba加速或者直接用 PyTorch 的 CPU 版本。但作为学习目的纯 NumPy 实现的价值在于理解每一步的计算速度不是首要考虑。4.5 常见问题速查表问题现象可能原因排查方法解决办法损失 NaN学习率太大打印每轮损失降低学习率损失不降权重初始化太小打印激活值方差增大初始化方差准确率 50%标签编码错误检查标签范围统一从 0 开始编码梯度误差大反向传播公式错逐层梯度检查检查转置和求和轴训练慢全批量 float64计时矩阵乘法换 float32 或 mini-batchReLU 死亡学习率太大统计零激活比例降低学习率或换 Leaky ReLU提示梯度检查只在调试时用训练时不要开因为数值梯度计算量是解析梯度的几十倍会严重拖慢训练速度。4.6 几个容易被忽略的实操心得第一个心得标准化参数要保存。训练时用训练集的均值和标准差做标准化预测时要用同样的均值和标准差不能重新计算。否则预测时的数据分布和训练时不一致准确率会下降。第二个心得随机种子要固定。np.random.seed在数据生成和参数初始化时都要设否则每次跑的结果不一样没法对比不同超参数的效果。但注意固定种子不代表结果完全可复现因为 NumPy 的随机数生成器在不同版本间可能有差异。第三个心得损失曲线比准确率更能反映训练状态。准确率是离散的小数据集上波动大。损失是连续的下降趋势更明显。如果损失在下降但准确率不升可能是模型在调整决策边界再等几百轮可能就上去了。第四个心得隐藏层节点数不是越多越好。节点太多参数多容易过拟合而且计算慢。这个数据集 64 个节点足够32 个也能跑到 90% 以上。实际项目中先用小网络试不够再加比一开始就上大网络更高效。第五个心得学习率可以动态调整。前几百轮用大学习率快速下降后面用小学习率精细调整。比如每 1000 轮乘以 0.9。这个技巧在纯梯度下降里也适用不需要 Adam 这类自适应优化器。4.7 从二分类扩展到多分类和回归二分类的输出层是 2 个节点加 Softmax多分类就是K个节点加 Softmax代码几乎不用改只要把output_dim设成类别数标签 one-hot 编码成K维就行。回归任务则不同输出层不用 Softmax直接用恒等映射损失函数换成均方误差。反向传播时输出层的梯度是(预测值 - 真实值) / N再往前传。如果要做人脸识别这类高维输出任务输出层可能是一个高维向量比如 128 维或 512 维。这时候输出层不用 Softmax而是用 L2 归一化损失函数用三元组损失或对比损失。前向传播的过程类似只是最后一层的处理和损失计算不同。这也是为什么理解前馈网络的基础结构很重要换任务时只需要改输出层和损失函数中间的特征提取部分可以复用。4.8 后续可以扩展的方向这个纯 NumPy 实现虽然简单但扩展性不错。你可以加更多隐藏层把前向和反向传播写成循环支持任意深度。也可以加 Dropout 正则化训练时随机置零一部分神经元防止过拟合。还可以加动量项加速收敛。这些改动都不需要换框架在现有代码上改几十行就能实现。另一个方向是可视化。把决策边界画出来能直观看到网络学到的非线性分界面。二维数据可以直接画等高线高维数据可以用 PCA 降到二维再画。训练损失曲线也可以实时画出来观察收敛过程。这些可视化手段在调试超参数时非常有用。最后如果你想把这份代码用到实际项目里建议先在小规模数据上验证流程确认梯度检查通过、损失正常下降再迁移到大数据集。直接上大数据集一旦出错排查成本会高很多。先用小数据跑通再逐步放大这是我在实际项目里反复验证过的稳妥做法。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑