news 2026/10/16 2:00:57

手写全连接神经网络:Numpy实现前向传播、反向传播与梯度下降

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写全连接神经网络:Numpy实现前向传播、反向传播与梯度下降

简介:这是用Python和NumPy从零实现的一个简单全连接神经网络代码包,面向希望理解深度学习底层原理的入门开发者。全连接神经网络(多层感知器)由输入层、隐藏层和输出层组成,信息逐层前向传递,代码不依赖TensorFlow或PyTorch等高层框架,完整演示了多层感知器的运作流程。资源共7个Python脚本,压缩包仅4KB,包含数据生成、工具函数、网络初始化、前向传播、均方误差损失、反向传播、训练循环与预测输出等模块,每个步骤均有独立代码,并实现了sigmoid激活函数及链式法则求导,便于对照理论学习逐行查看。目前已有4213人学习下载,适合机器学习初学者通过阅读和运行代码掌握神经网络内部机制,也可作为课程实验或毕业设计的基础模板,为后续迁移学习更复杂的深度框架打下扎实认知基础。

1. 为什么还要手写一个全连接神经网络:把框架的黑匣子拆开

深度学习框架几行代码就能搭好一个神经网络,但我依然建议你做一件看起来有点返祖的事:用纯 Python 和 Numpy 从零实现一个全连接神经网络。原因很直接,当你用框架的 API 训练好一个模型,网络内部究竟发生了什么,对你来说始终是个黑匣子。梯度怎么传、每一层的参数怎么更新,框架替你代劳了,你反而失去了最核心的调试能力。这个项目做的事就是拆开这个黑匣子:用一个只依赖 Numpy 和标准库的两层全连接网络,把前向传播、反向传播、梯度下降、训练循环全部显式写出来,并且用一份带干扰的合成数据集跑通完整流程。适合三类人:想彻底看懂反向传播公式的新手,被框架封装挡住、想改又不知道从哪下手的人,以及需要快速搭一个可解释 baseline 模型的工程师。

2. 数据和网络骨架:Numpy 实现全连接网络的两层模块与初始化细节

2.1 先造一份能一眼看穿结果的合成数据集

训练神经网络之前,数据是第一步。很多人在入门阶段直接拿图像或真实业务数据开跑,结果模型不收敛时根本分不清是数据问题、实现 bug 还是超参没调好。所以这个项目第一步不是接真实数据,而是生成一份「答案早就知道」的合成二分类数据集:两个高斯簇,中心点分别在(2, 2)和(-2, -2),各 300 个样本,叠加少量高斯噪声。网络只要能学出一个线性或非线性边界把两簇分开,就算真正理解了全连接网络在干什么。

import numpy as np np.random.seed(0) # 固定随机种子,保证结果可复现 def make_data(n_samples=300, centers=((2.0, 2.0), (-2.0, -2.0)), noise=0.4): """生成两个高斯簇构成的二分类数据集。 centers: 两个簇的中心点坐标 noise: 高斯噪声标准差,越大两个簇越重叠 """ n_per_class = n_samples // 2 x0 = np.random.randn(n_per_class, 2) * noise + centers[0] x1 = np.random.randn(n_per_class, 2) * noise + centers[1] X = np.vstack([x0, x1]) y = np.hstack([np.zeros(n_per_class), np.ones(n_per_class)]) return X, y X, y = make_data() # 归一化:把特征缩放到均值 0,标准差 1 X = (X - X.mean(axis=0)) / X.std(axis=0)

这里有两件事值得解释。第一,randn生成的是标准正态分布样本,乘noise表示围绕中心点的离散程度,noise越大两簇边界越模糊,训练难度越高。第二,归一化这一步看起来多余,但实际上极其关键:如果不做标准化,特征数值大的维度会在反向传播里拿到更大的梯度,导致参数更新被某一维主导。我一般会在生成数据后立刻检查X.mean(axis=0)和X.std(axis=0),确认已经缩放到 0 均值单位方差。这份数据总共 600 个样本,按 8:2 拆成训练集和验证集,验证集用来观察泛化情况,而不是只看训练集的 loss 自嗨。

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

网络结构定了,接下来是参数初始化。这个项目用的是两层全连接网络,输入维度 2,隐层 16 个神经元,输出维度 1。你可能觉得层数少,初始化随便给个随机数就行,但初始化方式直接决定你能不能收敛。

def init_network(input_dim=2, hidden_dim=16, output_dim=1): """He 初始化两层全连接网络参数。""" # 隐层权重按 He 初始化:标准差 sqrt(2/input_dim) w1 = np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim) b1 = np.zeros(hidden_dim) # 输出层权重同样用 He 初始化 w2 = np.random.randn(hidden_dim, output_dim) * np.sqrt(2.0 / hidden_dim) b2 = np.zeros(output_dim) params = { 'w1': w1, 'b1': b1, 'w2': w2, 'b2': b2 } return params

之所以不能把 W 设成全零,是因为如果某一层所有神经元参数相同,反向传播计算出的梯度也完全相同,每个隐层神经元都在做一模一样的更新,网络退化成一个只有单宽度的线性模型,隐层数量再多也没意义。He 初始化里np.sqrt(2.0 / input_dim)的来历,是为了让输入经过线性变换后方差保持在 1 附近,配合 ReLU 激活函数时,梯度在深层传递不容易衰减或爆炸。

如果你把np.random.seed(0)抽掉,每次运行得到的结果都会不同,这对调参很不利。我在实际调试时一定固定随机种子,否则你今天调好的一组超参,明天重跑一遍完全对不上,这是神经网络工程里最常见的时间和精力粉碎机。

2.3 参数统一管理:forward / backward / update 共用一个字典

前面这两个函数看起来很简单,但它们背后是一个设计决策:所有参数放在一个params字典里,前向传播、反向传播、参数更新三个函数都只依赖这个字典作为输入输出。这不是为了花哨,而是为了后面调试验证时能随时把某一层的参数和梯度打印出来对照。

def linear_forward(x, w, b): """单层线性变换 y = x @ w + b。""" return x @ w + b # 在训练循环里: # z1 = linear_forward(X_batch, params['w1'], params['b1'])

把参数集中在字典里的好处在排查问题时特别明显。比如怀疑w2更新异常,你只要打印params['w2']的前后变化,就能确认这一层到底有没有在学东西。如果改用散落的全局变量,调试时追踪起来会非常痛苦。我做过不少次因为变量名写错,改了w1却用w2去算梯度,导致整个网络表现完全不正常的蠢事,参数集中管理之后这类低级错误基本能避免。

3. 前向传播的维度纪律:矩阵乘法 shape 与中间缓存怎么设计

3.1 激活函数选择:二分类任务里 Sigmoid 够用吗

前向传播要做的事很直接:数据从输入层进入,经过权重矩阵线性变换,再过激活函数,逐层往后传,最后输出预测值。激活函数的选择决定了这个网络能表达什么样的非线性。这个项目里隐层用 ReLU,输出层用 Sigmoid。ReLU 在隐层几乎是最省心的选择,计算简单、梯度在正区间恒为 1,不容易出现梯度消失;Sigmoid 则是因为输出层要做二分类概率输出。

def relu(z): """ReLU 激活函数,同时返回激活后的值。""" return np.maximum(0, z) def sigmoid(z): """Sigmoid 激活函数,把实数压缩到 (0, 1) 区间。""" z = np.clip(z, -500, 500) # 防御溢出 return 1.0 / (1.0 + np.exp(-z))

Sigmoid 的经典问题是当输入绝对值很大时导函数趋近于 0,模型参数几乎不再更新;而 ReLU 在正区间导数为 1,不会压缩梯度。这也是隐层不选 Sigmoid 的原因——如果隐层和输出层都用 Sigmoid,两层梯度一乘,反向传到输入层时梯度基本就消失了,网络很难训练。为什么最终输出还是 Sigmoid?因为它能把任意实数映射成概率值,配合二分类交叉熵损失时,梯度计算会非常简洁,这一点到反向传播那章会细说。

3.2 单层前向计算与中间缓存的数据结构

前向传播本身不复杂,真正的难点在于中间结果必须保存。反向传播需要用到每一层的线性输出 z 和激活输出 a,如果前向不缓存,反向阶段就要重新算一遍,浪费算力还容易出错。这个项目设计了一个cache字典,专门存这些中间变量。

def forward(X, params): """两层全连接网络前向传播。 X: shape (batch_size, input_dim),一个 batch 的输入 params: 网络参数字典 """ w1, b1 = params['w1'], params['b1'] w2, b2 = params['w2'], params['b2'] # 第一层:线性变换 + ReLU z1 = X @ w1 + b1 # (batch, hidden_dim) a1 = relu(z1) # (batch, hidden_dim) # 第二层:线性变换 + Sigmoid z2 = a1 @ w2 + b2 # (batch, 1) prob = sigmoid(z2) # (batch, 1),预测概率 cache = { 'X': X, 'z1': z1, 'a1': a1, 'z2': z2, 'prob': prob } return prob, cache

这个函数里每个 shape 都有迹可循。X @ w1:(batch, 2)乘(2, 16)得到(batch, 16),对应 16 个隐层神经元的线性输出;a1 @ w2:(batch, 16)乘(16, 1)得到(batch, 1),这是最终输出。Numpy 的广播机制会把 bias 自动加到每一行上,所以不用担心b1的 shape 是(16,)而不是(1, 16)的问题。

很多人写前向的时候不缓存,直接在反向里再调一次前向计算中间变量,这在网络很大时会对性能造成不小影响,更重要的是代码逻辑会绕来绕去。这个项目里我习惯把缓存设计成和网络参数对称的结构:cache['z1']对应params['w1']这一层,cache['a1']对应下一层的输入。这样反向传播函数里拿梯度时,能快速找到对应的层输入,调试时打印某个变量的 shape 也方便。所有矩阵乘法的维度如果对不上,Numpy 会在运行时直接报错,建议在写每个线性层之后手动加一行assert校验 shape,列子存了 10 分钟。「维度纪律」的意思就是:任何一个张量的 shape 变化,你心里都要有数。

4. 反向传播的两个关键:梯度公式推导与逐层 shape 校验

4.1 梯度公式推导:交叉熵损失和 Sigmoid 的相互成就

反向传播是让很多人直接放弃的部分,但事实上把公式推导清楚后,代码实现反而是最简单的。设网络输出为prob = sigmoid(z2),二分类交叉熵损失定义为:

L = -[y * log(prob) + (1 - y) * log(1 - prob)]

这里的核心技巧是:Sigmoid 的导数sigmoid'(z) = sigmoid(z) * (1 - sigmoid(z)),把它代入交叉熵对z2的链式求导,所有prob * (1-prob)项会被消掉,最后得到极简的结果dz2 = prob - y。这就是为什么输出层用 Sigmoid、损失用交叉熵——两者搭配时梯度计算最干净,既没有除法也没有指数项,数值上还非常稳定。

def sigmoid_cross_entropy_grad(prob, y): """输出层梯度 dz2 = prob - y。 prob: 模型预测概率,shape (batch, 1) y: 真实标签,shape (batch, 1) 返回: 损失对 z2 的梯度 """ return prob - y

prob - y的物理含义很直观:预测值与真实值的残差。预测偏离真实值越远,梯度越大;预测对了,梯度趋近于 0。反向传播从输出层开始,把这个残差逐层往回传。接下来需要算隐层梯度。设a1 = relu(z1),则:

da1 = dz2 @ w2.T,因为z2 = a1 @ w2 + b2,所以把上游梯度dz2乘上w2的转置,就能得到 loss 对a1的梯度。ReLU 的导数是分段函数:输入大于 0 时为 1,否则为 0。所以dz1 = da1 * (z1 > 0),这一步是逐元素相乘,不是矩阵乘法。

4.2 反向传播的逐层实现与梯度 shape 校验

def backward(cache, params, y): """反向传播,计算所有参数的梯度。""" X = cache['X'] a1 = cache['a1'] z1 = cache['z1'] prob = cache['prob'] batch_size = X.shape[0] # 输出层梯度 dz2 = prob - y # (batch, 1) dw2 = a1.T @ dz2 / batch_size # (hidden, 1) db2 = np.sum(dz2, axis=0) / batch_size # (1,) # 隐层梯度 da1 = dz2 @ params['w2'].T # (batch, hidden) dz1 = da1 * (z1 > 0) # ReLU 的导数 dw1 = X.T @ dz1 / batch_size # (2, hidden) db1 = np.sum(dz1, axis=0) / batch_size # (hidden,) grads = {'dw1': dw1, 'db1': db1, 'dw2': dw2, 'db2': db2} return grads

每个梯度的 shape 我都写在注释里了,这里值得专门对照检查一遍:dw2的 shape 必须和w2完全一样,都是(hidden, 1);dw1和w1一样是(2, hidden)。这是因为梯度用于参数更新,shape 不一样会导致params['w1'] -= learning_rate * grads['dw1']直接报错或广播出脏数据。我建议在训练循环里加几个断言式的检查,就一行:

assert grads['dw1'].shape == params['w1'].shape assert grads['dw2'].shape == params['w2'].shape

另外注意梯度里除以batch_size。如果不除,梯度的绝对值会随着 batch 大小线性增长,同样的学习率在大 batch 和小 batch 下学习行为完全不同。统一除以 batch 之后,学习率这个超参就只受网络结构影响,不随数据规模漂移。还有更隐蔽的一个细节:参数的更新必须所有梯度计算完之后统一进行。如果你先更新w2,再拿更新后的w2去算da1,整个反向传播的数学前提就被推翻了,梯度里混入了已经更新过的参数信息,训练结果会变得非常诡异。

提示:如果发现 loss 曲线一下降后又剧烈震荡,可以检查是不是在反向传播过程中穿插了参数更新。正确做法是先backward()算出全部梯度,再统一执行SGD更新。

5. 训练循环与避坑排查:loss 不降、梯度消失、模型学不动怎么查

5.1 训练主循环:mini-batch 划分与学习率衰减

训练循环是这个项目的主引擎,它的任务是串起前向传播、损失计算、反向传播、参数更新这四个环节。这个项目用 mini-batch 随机梯度下降,而不是把所有样本一次性喂进去。每个 batch 用 32 个样本计算梯度并更新一次,比全量梯度下降收敛更快,也更容易跳出局部极小点。

def train(X_train, y_train, params, epochs=2000, batch_size=32, learning_rate=0.1, decay=0.99, decay_every=100): n = X_train.shape[0] history = {'loss': [], 'accuracy': []} for epoch in range(epochs): # 每轮打乱数据顺序 indices = np.random.permutation(n) epoch_loss = 0 for start in range(0, n, batch_size): idx = indices[start:start + batch_size] X_batch = X_train[idx] y_batch = y_train[idx].reshape(-1, 1) # 前向 + 反向 + 更新 prob, cache = forward(X_batch, params) loss = binary_cross_entropy(prob, y_batch) grads = backward(cache, params, y_batch) # SGD 更新 params['w1'] -= learning_rate * grads['dw1'] params['b1'] -= learning_rate * grads['db1'] params['w2'] -= learning_rate * grads['dw2'] params['b2'] -= learning_rate * grads['db2'] epoch_loss += loss * len(idx) # 学习率衰减 if (epoch + 1) % decay_every == 0: learning_rate *= decay history['loss'].append(epoch_loss / n) if epoch % 200 == 0: errors = ((prob > 0.5).astype(int) != y_batch).mean() print(f"epoch {epoch}, loss {history['loss'][-1]:.4f}, batch_acc err {errors:.4f}") return history

顺带一提,np.random.permutation(n)是每个 epoch 随机打乱样本顺序。如果不打乱,模型会学到样本顺序带来的偏差,每个 mini-batch 内样本分布变得不均衡,收敛速度明显变慢。我一般固定一个很大的 epoch 数,配合动态学习率衰减,让模型在前期大步快速下降、后期小步精细收敛。learning_rate=0.1是在归一化数据上比较安全的起点,如果 loss 直接发散,先降一个量级到0.01再试。

5.2 高频踩坑记录:现象、原因与解决

这是最值得记录的部分。这个项目在调试阶段遇到的坑,几乎每个都在真实工程里反复出现过。

坑一:loss 一开始就是 NaN

现象:第一次迭代 loss 就输出nan,连下降的趋势都没有。

原因:最常见的是学习率设置过大,导致梯度更新一步跨过最优点,参数变成极大值,后续计算出现数值溢出。另一类是输入特征没有归一化,某一维特征数值上万,对应的梯度也极大。

解决:先把学习率降到0.0001试跑 50 个 epoch,如果 loss 正常就逐步往大调。同时确认输入特征已经标准化到接近标准正态分布。这个方法能解决八成以上的 NaN 问题。

坑二:训练集 loss 降到 0.05,但用训练好的模型去预测,结果全是一个类

现象:训练过程看起来完美,loss 曲线非常漂亮,但拿一批新样本预测,输出全是同一个概率值。

原因:这类问题通常出在验证集或测试数据没有走同一个预处理管道。训练时对特征做了归一化,但预测时忘了减去训练集的均值、除以训练集的标准差,输入分布完全变了。或者模型隐层节点太少,表达能力确实不够,学成了一个「平均预测」。

解决:把归一化参数(均值和标准差)固化下来,预测时统一复用。如果是表达力问题,就增加hidden_dim,从 16 加到 64 再观察。

坑三:隐层梯度整体趋近于 0,网络不再学习

现象:打印grads['dw1'],发现数值全都在1e-10以下,模型 loss 几乎不动。

原因:最经典的是 ReLU 死亡问题:如果初始化时某些神经元权重为负,输入经过线性变换后 z 始终小于 0,ReLU 输出恒为 0,梯度传导到这一层后全部被截断。换个角度说,如果某一层神经元死了,它永远不会再“醒”过来。

解决:检查初始化是否用了 He 初始化。如果已经用了,尝试降低学习率,避免某些样本的极端输入直接把所有神经元推到死区。也可以用 Leaky ReLU 替换 ReLU,让负半轴的梯度保留一个极小值,彻底规避这个问题。

坑四:每次更新后 loss 不减反增,且波动巨大

现象:loss 曲线像锯齿一样上下乱跳,整体没有任何下降趋势。

原因:这一般是学习率过大导致的振荡。参数在最优解两侧来回跳跃,每次跳的幅度过大,不仅跨过了最优点,还可能跳进了更差的区域。

解决:降低学习率,或者引入学习率衰减。在这个项目里我把decay=0.99且每 100 轮衰减一次,前 500 轮用较大部分的学习率粗调,后面逐步缩小步长精细收敛。

6. 训练后三图体检:损失曲线、决策边界与梯度范数的快速排查

训练跑通了,但「loss 小了」不等于「模型真的学会了」。我习惯训练结束后立刻画三张图,把模型内部状态完全摊开来看。第一张是 loss 随 epoch 变化的曲线,第二张是决策边界叠加原始数据点,第三张是每一层梯度范数随训练的变化。

import matplotlib.pyplot as plt def plot_health(X_train, y_train, params, history): fig, axes = plt.subplots(1, 3, figsize=(15, 4)) # 图 1: loss 曲线 axes[0].plot(history['loss']) axes[0].set_title('Training Loss') axes[0].set_xlabel('Epoch') axes[0].set_ylabel('Loss') # 图 2: 决策边界 x_min, x_max = X_train[:, 0].min() - 0.5, X_train[:, 0].max() + 0.5 y_min, y_max = X_train[:, 1].min() - 0.5, X_train[:, 1].max() + 0.5 xx, yy = np.meshgrid(np.linspace(x_min, x_max, 200), np.linspace(y_min, y_max, 200)) grid = np.c_[xx.ravel(), yy.ravel()] prob, _ = forward(grid, params) zz = prob.reshape(xx.shape) axes[1].contourf(xx, yy, zz, levels=20, cmap='RdBu', alpha=0.6) axes[1].scatter(X_train[:, 0], X_train[:, 1], c=y_train, edgecolors='k', cmap='RdBu') axes[1].set_title('Decision Boundary') # 图 3: 每层梯度范数 # 需要训练时记录 norm1 和 norm2,这里演示读取 norm1 = np.sqrt(np.sum(np.array(history['grad_w1']) ** 2, axis=(1, 2))) norm2 = np.sqrt(np.sum(np.array(history['grad_w2']) ** 2, axis=(1, 2))) axes[2].plot(norm1, label='|dW1|') axes[2].plot(norm2, label='|dW2|') axes[2].set_yscale('log') axes[2].set_title('Gradient Norm') axes[2].legend()

三张图分别回答三个问题。loss 曲线看收敛性:如果曲线平滑下降并在末尾趋平,训练过程健康;如果剧烈震荡,说明学习率偏大。决策边界看学习质量:对比真实数据点,边界应该清晰地分在两个簇中间,把绝大多数点分对。梯度范数那张图最容易被忽略,但它往往是发现隐患最快的窗口——训练初期梯度范数较大是正常的,随着收敛应该稳步衰减;如果梯度范数在某个 epoch 突然断崖式下降,说明那一层已经失效;如果某个物体梯度始终比另一层大了几个数量级,训练会极不稳定。

从那以后,我每次训练完神经网络,不管是在这个项目还是后来遇到的更大规模模型,强制自己先走一遍上面这三张图,确认没问题之后再谈下一步调参。这个习惯帮我过滤掉了无数次「看起来在训练、其实没学会」的假象。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 1:57:31

python中的浮点数运算、round()

0.1 ,计算机会想“没前缀,是十进制,我要转成二进制存”;你写 0b0.00011 ,它才会想“有 0b ,是二进制,我直接按二进制存”。 计算机是以二进制来存储数据的,而像 0.1 、 0.2 这样的十…

作者头像 李华
网站建设 2026/10/16 1:54:38

TwinCAT 2.0从入门到精通:PDF手册实战指南与避坑技巧

简介:《TwinCAT 2.0从入门到精通》是面向Beckhoff自动化系统工程师的实践型教程,由拥有近15年倍福技术工作经验的Lizzy Chen编写,非官方发行。内容聚焦TwinCAT 2这一PC-based控制平台,覆盖系统配置、PLC编程、EtherCAT与K-Bus I/O…

作者头像 李华