纯NumPy手写全连接神经网络:从零实现前向传播与反向传播
简介这份资源面向深度学习入门者与希望夯实底层原理的开发者提供一份用Python从零实现全连接神经网络的完整代码包帮助读者摆脱对高层框架的依赖真正理解多层感知器的前向传播、反向传播与权重更新机制。压缩包共7个文件全部为py脚本整体约4KB涵盖网络结构定义、公共数据处理、自定义算法、预测与数据生成等模块结构清晰便于按功能逐一阅读与调试。资源围绕sigmoid激活函数、均方误差损失、链式法则求梯度等核心知识点展开代码可直接运行并用于小规模实验适合作为课程作业、自学练手或进一步学习TensorFlow、PyTorch前的过渡练习。目前已有4213人学习下载说明其在入门群体中具备一定参考价值能帮助读者快速搭建可运行的最小神经网络并掌握训练流程。1. 从一份手写全连接网络说起为什么它值得你花一个下午跑通很多人第一次接触深度学习是从调库开始的——三行代码加载数据集两行代码定义模型一行fit跑完准确率还挺好看。但真到了要改结构、换损失、排查梯度消失的时候打开框架源码一看满屏的自动微分和计算图直接劝退。这份「简单全连接神经网络 python 实现」走的是另一条路不依赖任何深度学习框架纯 Python 加 NumPy把前向传播、反向传播、权重更新全部手写出来。它适合两类人一类是刚学完反向传播公式、想找个能跑通的代码对照理解的初学者另一类是想在嵌入式或轻量场景里塞一个极简推理逻辑、不想引入大依赖的开发者。整个项目就是一个可读、可改、可断点调试的神经网络黑匣子你甚至可以在每一层打印梯度看它到底是怎么一点点把损失压下去的。2. 项目骨架与技术选型纯 NumPy 手写到底图什么2.1 为什么不用 PyTorch 或 TensorFlow这个项目的核心定位是「教学可解释」而不是「生产可用」。用框架当然省事但框架把反向传播封装成了loss.backward()你只能看到结果看不到链式法则在每一层是怎么展开的。纯 NumPy 实现的好处是每一个矩阵乘法的维度、每一次激活函数的导数、每一轮梯度下降的更新量全部暴露在你眼前。常见做法是只依赖 NumPy 一个库连 Matplotlib 都做成可选——训练曲线能画就画画不了也不影响核心逻辑跑通。这种选型带来的直接收益是环境极简pip install numpy就能起步不会卡在 CUDA 版本和驱动兼容上。2.2 目录结构与模块划分一个合格的手写神经网络项目目录不会太深但职责要分清。我一般会按下面这种方式组织simple-fcnn/ ├── main.py # 入口加载数据、训练、评估 ├── model.py # 网络定义层堆叠、前向传播 ├── layers.py # 各层实现全连接层、激活层 ├── losses.py # 损失函数交叉熵、均方误差 ├── optimizers.py # 优化器SGD、Momentum ├── utils.py # 工具独热编码、归一化、打乱 └── data/ └── load_data.py # 数据加载与预处理这个划分的关键在于layers.py只负责单层的forward和backwardmodel.py负责把层串起来并管理参数optimizers.py只关心怎么用梯度更新权重。三者解耦之后你想换激活函数只需要改layers.py想换优化器只需要改optimizers.py不会牵一发动全身。2.3 核心数据结构参数怎么存、梯度怎么传全连接层的参数就两个权重矩阵W和偏置向量b。假设输入维度是n_in输出维度是n_out那么W的形状是(n_in, n_out)b的形状是(n_out,)。前向传播就是Z X W b其中X的形状是(batch_size, n_in)。反向传播时你需要根据上游传来的梯度dZ算出dW X.T dZ、db sum(dZ, axis0)以及传给下一层的dX dZ W.T。这四个矩阵乘法的维度关系是整个项目最容易翻车的地方后面避坑章节会专门讲。提示所有参数和梯度建议统一用 float64虽然 float32 省内存但在手写实现里数值稳定性更重要尤其是 sigmoid 和 softmax 附近。3. 核心模块拆解从一次前向传播到一轮参数更新3.1 全连接层的前向与反向实现先看layers.py里最核心的Linear层。它的forward做两件事缓存输入X供反向使用计算输出Z。backward接收上游梯度dZ算出dW、db并把dX返回给前一层。import numpy as np class Linear: def __init__(self, n_in, n_out): # He 初始化适合 ReLU 激活 self.W np.random.randn(n_in, n_out) * np.sqrt(2.0 / n_in) self.b np.zeros(n_out) self.dW None self.db None self.X None # 缓存输入 def forward(self, X): self.X X return X self.W self.b def backward(self, dZ): # dZ 形状 (batch_size, n_out) self.dW self.X.T dZ # (n_in, n_out) self.db np.sum(dZ, axis0) # (n_out,) dX dZ self.W.T # (batch_size, n_in) return dX逻辑说明forward里缓存self.X是必须的因为反向传播算dW时要用到输入。backward的三个输出分别对应权重梯度、偏置梯度和传给前一层的梯度。参数说明n_in是输入特征数n_out是输出特征数dZ是损失函数对当前层线性输出的梯度形状和forward的输出一致。这里用 He 初始化而不是全零是因为全零初始化会让所有神经元对称反向传播时梯度相同网络永远学不到东西。3.2 激活函数与损失函数的配对激活函数和损失函数必须配对使用否则梯度推导会出错。常见组合是隐藏层用 ReLU输出层用 Softmax损失用交叉熵。ReLU 的backward很简单输入大于零的地方梯度原样传回小于等于零的地方梯度为零。class ReLU: def __init__(self): self.mask None def forward(self, X): self.mask (X 0) return X * self.mask def backward(self, dZ): return dZ * self.maskSoftmax 加交叉熵的组合反向传播可以化简成一个非常简洁的形式dZ y_pred - y_true。这也是为什么分类任务偏爱这对组合——梯度形式简单数值也稳定。class SoftmaxCrossEntropy: def forward(self, logits, y_true): # logits: (batch_size, n_class) exp_logits np.exp(logits - np.max(logits, axis1, keepdimsTrue)) self.probs exp_logits / np.sum(exp_logits, axis1, keepdimsTrue) self.y_true y_true loss -np.mean(np.sum(y_true * np.log(self.probs 1e-12), axis1)) return loss def backward(self): return (self.probs - self.y_true) / self.y_true.shape[0]参数说明logits是网络最后一层的原始输出y_true是独热编码后的标签。减去np.max是为了防止指数爆炸这是 softmax 实现的标准操作。1e-12是防止log(0)的兜底。3.3 优化器SGD 与 Momentum 的更新逻辑优化器只做一件事拿到每一层的dW和db按规则更新W和b。SGD 最直接W W - lr * dW。Momentum 多了一个速度项能加速收敛并减少震荡。class SGD: def __init__(self, lr0.01): self.lr lr def update(self, layer): layer.W - self.lr * layer.dW layer.b - self.lr * layer.db class Momentum: def __init__(self, lr0.01, momentum0.9): self.lr lr self.momentum momentum self.vW {} self.vb {} def update(self, layer): if id(layer) not in self.vW: self.vW[id(layer)] np.zeros_like(layer.W) self.vb[id(layer)] np.zeros_like(layer.b) self.vW[id(layer)] self.momentum * self.vW[id(layer)] - self.lr * layer.dW self.vb[id(layer)] self.momentum * self.vb[id(layer)] - self.lr * layer.db layer.W self.vW[id(layer)] layer.b self.vb[id(layer)]逻辑说明Momentum 用id(layer)作为字典键来区分不同层的速度缓存这样同一个优化器实例可以管理整个网络。参数说明lr是学习率momentum一般取 0.9表示保留 90% 的历史速度。注意更新方向是速度项因为速度项本身已经带了负号。3.4 训练循环一个 epoch 里发生了什么把上面的模块串起来训练循环就清晰了。每个 batch 走一遍前向传播 → 算损失 → 反向传播 → 更新参数。def train_one_epoch(model, optimizer, loss_fn, X, y, batch_size32): indices np.random.permutation(len(X)) X, y X[indices], y[indices] total_loss 0.0 for i in range(0, len(X), batch_size): X_batch X[i:ibatch_size] y_batch y[i:ibatch_size] # 前向 logits model.forward(X_batch) loss loss_fn.forward(logits, y_batch) total_loss loss * len(X_batch) # 反向 dZ loss_fn.backward() model.backward(dZ) # 更新 for layer in model.layers: if hasattr(layer, W): optimizer.update(layer) return total_loss / len(X)参数说明batch_size控制每次更新用的样本数太小梯度噪声大太大内存吃紧且收敛慢。np.random.permutation每个 epoch 重新打乱数据防止模型记住样本顺序。hasattr(layer, W)用来过滤掉没有参数的激活层。4. 跑起来环境准备、数据加载与训练验证4.1 环境准备与依赖安装这个项目对环境的要求低到不能再低。Python 3.7 以上NumPy 是唯一硬依赖。如果你还想画训练曲线加一个 Matplotlib。python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install numpy matplotlib验证安装是否成功跑一行python -c import numpy; print(numpy.__version__)能打印出版本号就行。常见坑是系统里同时装了多个 Python 版本pip和python指向的不是同一个导致装完了却 import 不到。解决办法是用python -m pip install numpy确保装到当前解释器。4.2 数据加载与预处理项目默认可以用 sklearn 的 digits 数据集也可以换成自己的 CSV。关键是三件事特征归一化、标签独热编码、数据打乱。from sklearn.datasets import load_digits from sklearn.model_selection import train_test_split import numpy as np def load_data(): digits load_digits() X digits.data.astype(np.float64) y digits.target # 归一化到 [0, 1] X X / 16.0 # 独热编码 n_class 10 y_onehot np.zeros((len(y), n_class)) y_onehot[np.arange(len(y)), y] 1 return train_test_split(X, y_onehot, test_size0.2, random_state42)参数说明digits.data是 8x8 的灰度图展平后的 64 维向量像素值范围 0 到 16除以 16 归一化。独热编码用 NumPy 花式索引一行搞定比循环快得多。random_state42保证每次划分一致方便复现结果。4.3 训练与评估的完整入口main.py把所有模块串起来定义网络结构、优化器、训练轮数最后在测试集上算准确率。from model import Model from layers import Linear, ReLU from losses import SoftmaxCrossEntropy from optimizers import Momentum from data.load_data import load_data X_train, X_test, y_train, y_test load_data() model Model([ Linear(64, 128), ReLU(), Linear(128, 64), ReLU(), Linear(64, 10) ]) optimizer Momentum(lr0.05, momentum0.9) loss_fn SoftmaxCrossEntropy() for epoch in range(100): loss train_one_epoch(model, optimizer, loss_fn, X_train, y_train, batch_size32) if epoch % 10 0: acc evaluate(model, X_test, y_test) print(fEpoch {epoch}, Loss: {loss:.4f}, Test Acc: {acc:.4f})参数说明网络结构是 64 → 128 → 64 → 10两个隐藏层用 ReLU输出层 10 类对应数字 0 到 9。学习率 0.05 配 Momentum 在这个数据集上比较稳太大容易震荡太小收敛慢。训练 100 轮通常能到 95% 以上的测试准确率。如果准确率卡在 10% 左右不动大概率是标签没做独热编码或者损失函数和输出层不匹配。5. 避坑与排查手写神经网络最容易翻车的五个地方5.1 现象损失不下降准确率随机猜原因最常见的是反向传播梯度符号搞反了。比如dW X.T dZ写成了dW dZ X.T维度对不上会直接报错但如果维度恰好能对上就会静默地算出错误梯度。另一个原因是学习率太大损失直接飞出去变成 NaN。解决先做梯度检查。用数值梯度(loss(Weps) - loss(W-eps)) / (2*eps)和反向传播算出的梯度对比相对误差应该在 1e-6 量级。如果误差很大逐层检查backward的矩阵乘法顺序。学习率从 0.001 开始试确认能下降再往上加。5.2 现象训练准确率高但测试准确率低原因过拟合。手写实现里没有 Dropout 和正则化网络参数又多很容易记住训练集。另一个隐蔽原因是数据泄露——在划分训练测试集之前就做了归一化导致测试集的统计信息泄露到了训练过程。解决先划分数据集再在训练集上计算均值和方差用同样的参数去归一化测试集。如果还过拟合减小网络规模或者手动加 L2 正则在损失里加上0.5 * reg * sum(W**2)反向传播时dW reg * W。5.3 现象训练到一半损失突然变成 NaN原因softmax 里的指数运算溢出。如果 logits 数值很大np.exp会直接返回 inf除以 inf 得到 NaN。虽然代码里减了np.max但如果某一类的 logit 远大于其他类减完之后其他类变成很大的负数exp之后接近零log的时候还是可能出问题。解决确保 softmax 的数值稳定实现到位log里加1e-12。另外检查学习率过大的学习率会让权重更新幅度过大logits 爆炸。可以在训练循环里加一个判断如果 loss 是 NaN 就打印当前 batch 的 logits 范围定位是哪一层出的问题。5.4 现象梯度检查通过但训练极慢原因初始化不当。如果权重初始化太小每层的输出方差会逐层衰减到最后一层几乎为零梯度也接近零。如果初始化太大ReLU 神经元大量死亡梯度直接为零。解决用 He 初始化配 ReLUXavier 初始化配 tanh。He 初始化的标准差是sqrt(2 / n_in)Xavier 是sqrt(1 / n_in)。检查每层输出的标准差理想情况下应该和输入标准差接近。如果某一层输出标准差骤降说明初始化有问题。5.5 现象换了数据集后维度报错原因全连接层的输入维度是硬编码的。比如Linear(64, 128)里的 64 对应 8x8 图像换成 28x28 的 MNIST 就变成 784不改代码直接报维度不匹配。解决把输入维度做成参数从数据形状自动推断。n_in X_train.shape[1]然后传给第一层。输出维度同理n_out y_train.shape[1]。这样换数据集只需要改数据加载部分网络结构自动适配。注意每次改完网络结构或数据预处理先跑一个极小规模的数据集比如 100 条样本确认能过拟合再上全量数据。如果连 100 条都过拟合不了说明代码有 bug不是数据问题。6. 二次开发从手写网络到自定义扩展的几条路这个项目最大的价值不是它本身能跑多高的准确率而是它给你提供了一个可以随意拆改的骨架。我一般会从三个方向做二次开发。第一个方向是加层。想加 Dropout只需要在layers.py里写一个Dropout类forward时按概率随机置零并缩放backward时把对应位置的梯度也置零。然后在model.py的层列表里插进去就行优化器会自动跳过没有W的层。想加 BatchNorm 稍微复杂一点需要维护 running mean 和 running var但接口和Linear一致都是forward和backward。第二个方向是换优化器。Adam 本质上就是 Momentum 加 RMSProp你可以在optimizers.py里新增一个Adam类维护一阶矩和二阶矩的缓存更新公式稍微长一点但逻辑清晰。换优化器不需要动网络结构和损失函数直接替换main.py里的实例就行。第三个方向是换任务。把交叉熵换成均方误差输出层去掉 softmax就变成了回归网络。数据加载部分改成读 CSV标签改成连续值其他代码几乎不用动。这也是模块化设计的好处——损失函数、优化器、网络结构三者独立换哪个都不影响另外两个。验证改造是否成功我习惯用一个小技巧固定随机种子先跑一遍原始代码记录损失曲线改完之后再跑一遍对比前 10 个 epoch 的损失下降趋势。如果趋势一致但数值略有差异说明改动合理如果损失完全不降说明改动引入了 bug。从那以后我每次改完网络结构都强制走一遍梯度检查确认反向传播没写错再上全量训练。希望这个手写实现能帮你把反向传播的每个细节都摸清楚而不是停留在调库的层面。本文还有配套的精品资源点击获取