☰
纯NumPy手写全连接神经网络:从零实现前向传播与反向传播
2026/10/21 6:57:35 网站建设 项目流程

简介:这份资源面向深度学习入门者与希望夯实底层原理的开发者,提供一份用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和backward,model.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, axis=0),以及传给下一层的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, axis=0) # (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.mask

Softmax 加交叉熵的组合,反向传播可以化简成一个非常简洁的形式: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, axis=1, keepdims=True)) self.probs = exp_logits / np.sum(exp_logits, axis=1, keepdims=True) self.y_true = y_true loss = -np.mean(np.sum(y_true * np.log(self.probs + 1e-12), axis=1)) 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, lr=0.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, lr=0.01, momentum=0.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_size=32): 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:i+batch_size] y_batch = y[i:i+batch_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_size=0.2, random_state=42)

参数说明:digits.data是 8x8 的灰度图展平后的 64 维向量,像素值范围 0 到 16,除以 16 归一化。独热编码用 NumPy 花式索引一行搞定,比循环快得多。random_state=42保证每次划分一致,方便复现结果。

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(lr=0.05, momentum=0.9) loss_fn = SoftmaxCrossEntropy() for epoch in range(100): loss = train_one_epoch(model, optimizer, loss_fn, X_train, y_train, batch_size=32) if epoch % 10 == 0: acc = evaluate(model, X_test, y_test) print(f"Epoch {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(W+eps) - 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 初始化配 ReLU,Xavier 初始化配 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。从那以后我每次改完网络结构,都强制走一遍梯度检查,确认反向传播没写错再上全量训练。希望这个手写实现能帮你把反向传播的每个细节都摸清楚,而不是停留在调库的层面。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询