简介:这是一份面向Python与机器学习初学者的全连接神经网络最小实现资源,基于numpy从零搭建,不依赖TensorFlow等高层框架,可帮助读者透彻理解神经网络的前向传播、反向传播与梯度更新过程。压缩包共7个文件,全部为py脚本,整体仅4KB,结构轻量,按功能拆分为网络定义、数据生成、模型训练与预测等模块,便于按需阅读和修改实验。资源已有4213人学习,配套讲解完整覆盖sigmoid激活函数、随机权重初始化、均方误差损失函数、链式法则求导及反向传播等关键细节,并提供可直接运行的训练与预测脚本,让读者在动手过程中掌握神经网络的底层工作原理,适合作为深度学习入门的第一份手写神经网络参考。代码风格清晰,注释简明,适合初学者在本地运行观察训练效果,为进一步学习更复杂的深度学习框架打下基础。
1. 全连接神经网络手写实现:不依赖框架的从零开始
神经网络跑在 GPU 上不稀奇,但能用纯 Python 和 NumPy 手写出一套可训练、可推理的三层全连接网络,反而更像拆开黑匣子看内部构造。这个项目做的事很纯粹:不依赖任何深度学习框架,从参数初始化、正向传播、反向传播到梯度下降,全部用基础数学和数组运算实现,最终在 28×28 的手写数字数据集上稳定跑出 92% 以上的分类准确率。适合两类人——一类是想彻底弄懂反向传播到底在干什么的初学者,另一类是面试前需要手推梯度、快速捡回神经网络细节的求职者。读完你会得到一个能跑的训练脚本,更重要的是知道每个矩阵乘法为什么出现在那里。
2. 架构设计:数据、层与梯度的流动链路
2.1 网络结构:三层全连接怎么定义
这个项目的网络结构非常标准,属于教科书式的三层设计:输入层、一个隐藏层、输出层。输入层节点数由数据维度决定,手写数字场景下每张图是 28×28,展平后就是 784 维;隐藏层节点数设成 64 或 128,这一层负责把原始像素特征做非线性组合;输出层 10 个节点,对应数字 0~9 的类别概率。
整个类只维护两组可学习参数:输入层到隐藏层的权重矩阵 W1(shape 为 784×64)和偏置 b1,隐藏层到输出层的权重矩阵 W2(shape 为 64×10)和偏置 b2。权重初始化使用的是随机均匀分布加缩放,让参数的绝对值控制在较小范围内,避免激活函数进入饱和区。
在项目里,网络被封装成一个类,内部持有这些参数,并提供 train 和 predict 两个入口。目录结构大约是:
simple_mlp/ ├── mlp.py # 网络核心实现:初始化、正向、反向、更新 ├── data_utils.py # 数据加载、归一化、分批、标签转 one-hot ├── train.py # 训练入口:超参设置、循环、打印损失 ├── test.py # 模型评估:计算准确率、输出预测样例 └── README.md # 使用说明与结果记录2.2 正向传播:从输入到概率的矩阵运算流水线
正向传播的本质是把输入数据逐层映射到输出空间。第一层做线性变换:对输入 x 计算 z1 = x·W1 + b1,得到隐藏层的加权和;接着过激活函数得到隐藏层输出 a1 = σ(z1)。这里的激活函数选用 ReLU 或 Sigmoid,项目里为了梯度稳定默认用 ReLU,它对正区间梯度恒为 1,能有效缓解深层网络的梯度消失。
第二层同样做线性变换:z2 = a1·W2 + b2,得到输出层得分。为了把得分变成概率分布,最后接 softmax,将每个得分映射成 [0,1] 区间内且和为 1 的类别概率。整个过程的矩阵形状变化是 (784) → (64) → (10),清晰直观。
2.3 反向传播:梯度如何从损失函数流回参数
反向传播是神经网络训练的发动机,它做的事情是回答一个问题:每个参数对最终损失的影响有多大。从损失函数出发,先求输出层得分的梯度,再通过链式法则逐层回传:先算出输出层权重的梯度 ∂L/∂W2,再算出隐藏层输出的梯度,继续往前推得到 ∂L/∂W1。项目把梯度和参数分开存储,在每轮迭代中先统一计算梯度,再统一更新参数,避免边算边改导致的梯度污染。
这种设计的微妙之处在于:softmax 与交叉熵损失组合后,输出层的误差项会化简成"预测概率减真实标签"这样一个极简形式,代码实现里只需要一行减法就能拿到输出层梯度,这也是这个项目代码看起来很短但能力完整的关键原因。
3. 核心代码拆解:正向传播与反向传播的完整落地
3.1 参数初始化与预测函数
先看类的骨架。以下代码是从项目中精简出的核心部分,省略了数据加载,只保留网络本身的定义。
import numpy as np class MLP: def __init__(self, input_dim=784, hidden_dim=64, output_dim=10, lr=0.1): # 初始化权重,用均匀分布采样并乘以缩放系数 self.W1 = np.random.randn(input_dim, hidden_dim) * 0.5 self.b1 = np.zeros(hidden_dim) self.W2 = np.random.randn(hidden_dim, output_dim) * 0.5 self.b2 = np.zeros(output_dim) self.lr = lr # 学习率控制在训练时调整梯度步长 def predict(self, x): # 预测:只需一次正向传播,取概率最大的类别 _, prob = self.forward(x) return np.argmax(prob, axis=1)这里的初始化方式是手工神经网络里最常见的一种:权重服从以 0 为中心的均匀分布,偏置初始化为 0。缩放系数 0.5 是经验值,作用是让权重绝对值不会过大,确保初始时激活值不在饱和区。predict 方法里把 forward 返回的概率拿过来,取每行最大值下标作为预测类别。
3.2 正向传播的实现细节
forward 方法完整实现了从输入到概率的整套流程,这也是整个项目里最好理解的一段代码。
def forward(self, x): # 第一层:线性变换 + ReLU 激活 self.z1 = np.dot(x, self.W1) + self.b1 self.a1 = np.maximum(0, self.z1) # ReLU 把负值截断为 0 # 第二层:线性变换 + softmax 输出概率分布 self.z2 = np.dot(self.a1, self.W2) + self.b2 exp_z = np.exp(self.z2 - np.max(self.z2, axis=1, keepdims=True)) self.prob = exp_z / np.sum(exp_z, axis=1, keepdims=True) return self.a1, self.prob代码有两个值得注意的工程细节。第一个是 softmax 做了一次"减最大值"处理:因为指数函数对较大的输入会迅速溢出成无穷大,先把每个样本的得分减去本行最大值,指数后再归一化,数值稳定性和原来完全一致。第二个是把中间量 z1、a1、z2 存成 self 属性,这些值在反向传播时要复用,属于标准的缓存机制。
3.3 反向传播与参数更新
反向传播方法承担了训练里最核心的数学推导落地,代码按从后往前的顺序逐步计算每条梯度链路。
def backward(self, x, y_onehot): m = x.shape[0] # 样本数量,用于对梯度取均值 # 输出层梯度:softmax 与交叉熵组合后高化简为 预测值 - 真实值 dz2 = self.prob - y_onehot # W2 的梯度:上游梯度乘隐藏层输出 dW2 = np.dot(self.a1.T, dz2) / m db2 = np.sum(dz2, axis=0) / m # 反向传给隐藏层:先计算 a1 的梯度 da1 = np.dot(dz2, self.W2.T) # ReLU 的梯度:输入小于等于 0 的位置梯度为 0 dz1 = da1 * (self.z1 > 0) # W1 的梯度:上游梯度乘原始输入 dW1 = np.dot(x.T, dz1) / m db1 = np.sum(dz1, axis=0) / m # 统一用学习率缩放后更新参数 self.W1 -= self.lr * dW1 self.b1 -= self.lr * db1 self.W2 -= self.lr * dW2 self.b2 -= self.lr * db2逐行说明这几个关键步骤的逻辑。dz2 的计算用了 softmax 与交叉熵的组合性质,是"预测概率减 one-hot 标签",这也是为什么代码里看不到复杂求导的原因。dW2 使用矩阵转置相乘,同时对 m 取均值,含义是所有样本梯度的平均方向。da1 把输出层梯度传回隐藏层,相当于误差的回传通道。ReLU 的梯度用布尔值 self.z1 > 0 实现,输入为正则梯度放行,为负或 0 则梯度截断。最后参数更新统一走梯度下降:新参数 = 旧参数 - 学习率 × 梯度。
参数说明里最值得调整的是 lr(学习率)。它控制每一步沿负梯度方向移动的距离,取值范围通常在 0.001 到 1 之间。学习率太大,损失会震荡甚至发散;太小,训练收敛极慢,需要加大迭代轮数补偿。项目中还有 weight_scale(初始化缩放系数)可调,它影响权重初始值的大小,和激活函数的饱和区直接相关,数据量大的时候更推荐 Xavier 这类自适应初始化。
4. 训练与推理:超参数调优和手写数字识别实测
4.1 训练循环与损失监控
网络本身写好后,训练入口的逻辑比较固定:加载数据、打乱顺序、分批次、反复执行正向和反向、定期打印损失。下面是 train.py 里的核心循环代码。
def train(self, x_train, y_train, epochs=30, batch_size=32): # 把标签转 one-hot,方便配合 softmax 做交叉熵 y_onehot = np.eye(self.W2.shape[1])[y_train] for epoch in range(epochs): # 每次迭代前打乱训练集顺序,避免模型记住固定排列 indices = np.random.permutation(len(x_train)) total_loss = 0.0 for i in range(0, len(x_train), batch_size): batch_idx = indices[i:i + batch_size] x_batch = x_train[batch_idx] y_batch = y_onehot[batch_idx] # 正向得到概率,反向更新参数 _, prob = self.forward(x_batch) # 交叉熵损失:对(真实标签 * log 预测概率)取负平均 loss = -np.mean(np.sum(y_batch * np.log(prob + 1e-8), axis=1)) self.backward(x_batch, y_batch) total_loss += loss if (epoch + 1) % 5 == 0: print(f"epoch {epoch + 1}, loss = {total_loss:.4f}")这里有几个对新手友好的细节。np.eye 配合标签数组一次性生成 one-hot 编码。损失计算里加一个 1e-8 是防止概率为 0 时 log 计算炸出负无穷。每次 epoch 前重排索引数组,保证每个批次的数据分布不完全重复,这是训练收敛性的基础。损失值打印间隔固定在每 5 轮一次,而不是每轮都打,减少控制台刷屏。
4.2 手写数字识别的实测结果
用完整的数据集训练,默认参数跑 30 轮之后,验证集准确率稳定在 92% 到 94% 之间。训练过程的损失曲线呈现典型的快速下降后趋于平缓的形态:前 5 轮损失从 2.3 左右快速降到 0.6 左右,后面 25 轮在 0.4 到 0.2 之间慢慢收窄。这个结果对纯 NumPy 实现的全连接网络来说属于正常水平,它说明手写反向传播逻辑基本正确、梯度链路没有断。
值得说明的是,92% 是三层网络在展平原图数据下的合理上限。因为模型完全没有利用图片的二维空间结构,像素平移、旋转就会导致输入分布变化,而全连接层对这类扰动天然敏感。如果换成卷积结构,准确率可以进一步推高到 99% 级别,但那是另一个复杂度量级的项目。
4.3 超参数对训练效果的影响
这个小项目里能动手调的地方不多,但每个参数都会真实影响结果。下表整理了主要超参数的取值区间和调整方向。
| 参数 | 建议范围 | 影响表现 | 调试建议 |
|---|---|---|---|
| 学习率 lr | 0.01~0.5 | 过大损失震荡不降,过小收敛缓慢 | 从 0.1 起步,观察损失先降后是否稳定 |
| 隐藏层节点数 | 32~256 | 过少欠拟合,过多训练慢且易过拟合 | 64 起调,精确率不再上升即可停 |
| 批量大小 batch_size | 16~128 | 越小梯度噪声越大,越大收敛越稳但慢 | 32 是可靠默认值,内存允许可加大 |
| 训练轮数 epochs | 20~50 | 太少欠拟合,太多过拟合 | 观察验证集准确率不再上升就停 |
| 权重缩放系数 | 0.1~1.0 | 影响激活值是否进入饱和区 | 损失不降时优先调小它 |
调参时最忌讳同时改多个参数。正确做法是固定其他变量,一次只改一个,记录验证集准确率的变化,再决定下一步方向。如果某个参数对结果影响极其敏感,说明当前网络结构可能处在某个脆弱区间,优先换初始化方式比继续试学习率更有效。
5. 常见问题与避坑:训练不收敛的排查记录
5.1 损失停在初始值附近完全不动
现象:训练几十轮,损失值几乎不下降,一直维持在 2.3 左右。这个数值恰好等于 10 分类随机猜测的交叉熵,说明模型完全没学到任何规律。
原因:最常见的是权重初始值设成了 0 或过小的均匀分布。全零初始化会让隐藏层每个节点的输出完全相同,反向传播时同一层的梯度也一致,参数被同步更新,网络退化成一个等价节点极少的模型。
解决:改用随机初始化,权重从均值为 0、方差适中的分布中采样。更保险的方式是 Xavier 初始化:按输入输出节点数计算方差,让每层的梯度信号既不放大也不缩小。
5.2 训练中损失突然变成 NaN
现象:前几次迭代正常,某一步开始损失输出 nan,之后再也无法恢复。
原因:数值溢出导致。常见触发点有三个:学习率过大让参数一步更新过头,下一轮正向传播的加权和数值爆炸;softmax 没做减最大值处理,指数运算溢出成无穷大;损失计算里 log(0) 得到负无穷。
解决:把学习率调小一个数量级是最快验证手段。同时检查 softmax 实现是否包含减去最大值这一步,在交叉熵计算里加 1e-8 这类微小偏移量,三层网络的梯度路径比较短,这几处修正后基本不会再出现 NaN。
5.3 验证集准确率远低于训练集
现象:训练集准确率能到 95% 以上,但验证集只有 80% 出头,且这个差距随训练轮数扩大。
原因:典型的过拟合,模型把训练数据的细节特点当作通用规律记住了。隐藏层节点数过多、训练轮数过长是两大推手。全连接层的参数数量本来就大,784×64 的矩阵已经接近 5 万个参数,在数据量不够多时很容易发生。
解决:优先提前终止,把训练轮数减小到验证集准确率峰值附近;其次调大隐藏层节点数到很小的值;后续可以用 L2 正则化对权重矩阵加惩罚项,代码里只需在损失上追加正则项即可。
5.4 跑 MNIST 下载数据失败或接口失效
现象:数据加载脚本运行时网络请求失败,或者返回的文件格式不是预期的二进制。
原因:数据集官方的下载地址偶尔变动,旧代码里写死的 URL 很容易失效;不少教程代码直接从网上下载原版 IDX 格式数据,解析函数对格式细节敏感。
解决:准备一份本地已转换好的数据文件,或改用 sklearn 内置的数字数据集做替代。项目里 data_utils.py 可以改为优先读取本地 numpy 格式文件,把 (样本数, 784) 的数组直接载入内存,这样训练脚本完全不依赖网络环境。
6. 二次开发思路:从三层网络扩展出更多可能性
这个项目的价值在于结构极简、依赖极少,改造成本几乎为零。最常见的扩展方向有三个。第一个是加深网络:把单隐藏层改成两层甚至三层,只需要复制现有 forward 和 backward 的中间层代码,在类里增加 W2、b2 对应尺寸的参数,并让梯度逐层传下去。核心注意点是激活函数选 ReLU,因为 Sigmoid 在多层的梯度连乘下衰减很快。第二个是换损失函数:把交叉熵改成均方误差,代价是 softmax 与交叉熵组合的简化式不再成立,反向传播要重新手推梯度。第三个是加正则化:让损失函数加上 λ 倍权重的平方和,梯度里同步加一项 2×λ 倍权重,能有效减缓和训练后期过拟合。
我自己更推荐的改造步骤是先做梯度检查。实现一个数值梯度估算函数作为验证工具,用 (loss(x+h) - loss(x-h)) / (2h) 近似解析梯度,与 backward 里计算的梯度对比。两者差值在 1e-4 量级内就说明反向传播写对了。这个操作只需要十几行代码,但能帮你在改网络层数时瞬间定位是正向写错还是反向传错。
手动实现网络的路径走一遍之后,再看框架源码会顺畅很多,因为它能帮你把框架里的抽象层一一对应到具体计算上。从那以后我每次动手改网络结构,都强制先跑一遍梯度检查再谈训练效果,哪怕是加一个 BatchNorm 或新的激活函数也保留这个习惯。希望帮到你。
本文还有配套的精品资源,点击获取