
简介这份资源是一份使用 Python 与 NumPy 从零搭建全连接神经网络的入门实现面向想理解深度学习底层原理的初学者与进阶开发者解决“只会调用框架、不懂内部机制”的问题。资源共含 7 个 py 文件压缩包仅 4KB代码轻量却覆盖完整流程从数据生成、网络初始化、前向传播与反向传播到损失计算、训练与预测均有独立脚本便于按模块对照学习。已有 4211 人学习下载社区认可度较高。读者拿到后可直接运行并修改参数通过逐行阅读和调试直观掌握权重更新、梯度计算等核心概念为后续学习 TensorFlow、PyTorch 等框架打下扎实基础。整体体积小巧特别适合作为神经网络课程实验或自学练习的参考模板。1. 全连接神经网络是什么一个Python实现就能讲清的最小分类器当你想判断“这个用户会不会流失”“这台设备要不要停机检修”又不想一上来就抱TensorFlow、PyTorch大腿时最该先动手写的就是一个简单的全连接神经网络。全连接神经网络是所有深度学习框架里最底层的积木一个输入层、若干隐藏层、一个输出层层与层之间每个神经元都两两相连。用Python动手实现一遍你才能真正看懂后面那些框架里Linear、Dense到底替你做了什么也才知道报错时该往哪个方向查。这篇文章面向两类人刚入门Python、想弄懂神经网络内部机制的新手以及熟手想找一个可复现的最小基线做算法对比或者调试参照。我会直接给出能跑的代码再有针对性地讲参数怎么调、坑在哪里。2. 全连接网络的核心机制从矩阵乘法到链式法则的四块基石很多人写全连接网络把代码从开源仓库复制下来能跑通但换一个数据集就崩。原因不是代码问题是没吃透层与层之间到底在算什么。这一章把四个最关键的概念拆开讲并且每一节都会落到你能在Python里亲手验证的细节上。2.1 全连接层的数学表达权重矩阵与偏置到底在做什么一个全连接层做的事可以浓缩成一行式子Z XW b。这里的X是形状为(m, n)的输入矩阵m是样本数n是特征数W是形状为(n, h)的权重矩阵h是本层神经元的个数b是形状为(1, h)的偏置。矩阵乘法XW的本质是把每个样本的n维特征向量分别与W的每一列做点积得到一个h维的新向量。你可以用Python做一个最小实验体会“全连接”这三个字。假设有3个样本、4个特征隐藏层设5个神经元权重形状就是(4, 5)。样本i在隐藏层第j个神经元的输入值等于该样本的4个特征分别乘以W[j]这4个权重再求和最后加上b[j]。这就是“全连接”的含义上一层的每个输出都参与了下一层每个神经元的计算没有哪个连接被省略。import numpy as np m, n, h 3, 4, 5 X np.random.randn(m, n) # 3个样本每个4个特征 W np.random.randn(n, h) # 权重矩阵4x5 b np.zeros((1, h)) # 偏置1x5 Z X W b # 前向计算的线性部分 print(Z.shape) # 输出 (3, 5)这段代码里是NumPy的矩阵乘法运算符。b的形状是(1, h)它和(m, h)的X W相加时靠广播机制自动扩展到每一行。你唯一需要死记的就是矩阵乘法要求左边矩阵的列数等于右边矩阵的行数也就是特征数必须等于权重矩阵的行数。很多人写代码报维度错误的根因就是把X W写成了W X。2.2 激活函数为什么非线性是神经网络的灵魂如果每一层都只做Z XW b那么无论叠多少层整个网络最终等价于一个线性变换分类能力不会比逻辑回归强。激活函数的作用就是给网络注入非线性。最常见的两个起点是Sigmoid和ReLU。Sigmoid把任意实数压缩到0到1之间适合二分类输出层但饱和区的梯度几乎为0深层网络里容易让梯度消失。ReLU是max(0, z)计算极快正区间梯度恒为1是目前隐藏层的默认选择。def sigmoid(z): return 1 / (1 np.exp(-z)) def relu(z): return np.maximum(0, z) # 数值稳定性更好的sigmoid def sigmoid_stable(z): return np.where(z 0, 1 / (1 np.exp(-z)), np.exp(z) / (1 np.exp(z)))sigmoid_stable里用了分段计算避免z为很大的负数时np.exp(-z)溢出。这个细节新手很容易忽略但实际训练中一旦特征没归一化z的绝对值可能冲到几十甚至上百普通sigmoid会直接算出NaN或0。选择激活函数时有个经验隐藏层首选ReLU输出层做二分类用Sigmoid多分类用Softmax回归问题输出层可以不用激活函数。2.3 反向传播与链式法则从损失函数一路回传的梯度反向传播是整个网络的“学习”机制。前向传播算出的预测值和真实标签之间有误差反向传播要做的事就是把这个误差对每一个权重和偏置求偏导然后沿梯度反方向更新参数。求偏导的理论基础是链式法则损失对某一层权重的梯度等于“损失对输出的梯度”乘以“输出对该层线性输出的梯度”乘以“线性输出对权重的梯度”。以二分类为例输出层用Sigmoid损失用均方误差MSE。设a为预测输出y为真实标签z为输出层线性输出a sigmoid(z)。损失对z的梯度是dL/dz (a - y) * a * (1 - a)这个式子在代码里常写为d_z (y_pred - y) * y_pred * (1 - y_pred)。注意必须在除以样本数m之后再更新权重否则梯度大小随批量样本数膨胀学习率就很难调。# 假设 y_pred 和 y 都是形状 (m, 1) 的列向量 m y.shape[0] d_z (y_pred - y) * y_pred * (1 - y_pred) / m d_W2 hidden_output.T d_z # hidden_output 是上一层的激活值 d_b2 np.sum(d_z, axis0, keepdimsTrue)这里hidden_output.T d_z又是矩阵乘法上一层的激活值形状是(m, h)转置后是(h, m)与(m, 1)的d_z相乘得到(h, 1)正好和W2形状一致。我一般会每一步都打印中间结果的形状这是排查反向传播错误最有效的习惯。2.4 参数初始化与学习率影响收敛的两个隐性变量权重初始化决定了训练起点。如果把所有权重初始化为0那么同一层所有神经元会收到完全相同的梯度无论训练多久都学不到差异化特征。常见做法是用随机初始化ReLU配合He初始化Sigmoid配合Xavier初始化。He初始化是np.random.randn(shape) * np.sqrt(2.0 / n_in)其中n_in是上一层的神经元数。学习率则是每次参数更新的步长。学习率太大损失函数会在最优值附近来回震荡甚至发散太小则训练慢到你失去耐心。一个参考值SGD优化器下ReLU网络的学习率从0.01起步Adam优化器下可以放宽到0.001。学习率不是越准越好而是要配合损失曲线观察。n_input, n_hidden, n_output 64, 32, 1 W1 np.random.randn(n_input, n_hidden) * np.sqrt(2.0 / n_input) W2 np.random.randn(n_hidden, n_output) * np.sqrt(2.0 / n_hidden) b1 np.zeros((1, n_hidden)) b2 np.zeros((1, n_output))偏置初始化为0是没有问题的因为梯度不会因此对称。权重初始化这一环很多人觉得是玄学其实它和激活函数是配套的你选了ReLU却用Xavier初始时负区间会有大量神经元死亡你选了Sigmoid却用He初始化初始输出饱和概率升高梯度直接消失。3. 用NumPy从零实现简单全连接网络可运行的最小代码这一章直接给出完整的NumPy实现目标是在不依赖深度学习框架的前提下训练一个能区分手写数字0和1的全连接神经网络。数据用sklearn自带的digits数据集子集不需要额外下载文件方便你快速复现。3.1 环境准备与数据准备先检验Python和NumPy环境动手前先确认环境是完好的。Python环境变量配置有问题或者NumPy没装好后面所有代码都会卡在import这一步。常见做法是在终端里分别运行下面两条命令确认输出正常。python --version python -c import numpy; print(numpy.__version__)如果你还没有安装numpy库最简单的安装方法是用pip安装pip install numpy scikit-learn提示如果你在VS Code里配置了多个Python解释器务必确认终端里的python命令和你当前选择的是同一个解释器否则会出现“明明装了numpy却import不到”的典型问题。数据准备这部分我一般会直接复用sklearn自带的手写数字集但只保留标签为0和1的样本变成二分类问题这样网络结构可以做得最简单训练时间也短。import numpy as np from sklearn.datasets import load_digits from sklearn.model_selection import train_test_split digits load_digits() mask (digits.target 0) | (digits.target 1) X digits.data[mask] # 形状 (360, 64) y digits.target[mask].reshape(-1, 1) # 形状 (360, 1) X X / 16.0 # 像素范围0-16归一化到0-1 X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, random_state42 ) print(X_train.shape, X_val.shape)这里除以16.0是关键一步。sklearn的digits数据每个像素取值0到16如果不归一化特征量级偏大会让Sigmoid输出进入饱和区梯度直接消失。train_test_split里的random_state42固定了划分方式保证你每次运行结果一致。3.2 前向传播代码从输入到预测的完整流程网络结构设计为输入层64个神经元隐藏层32个输出层1个。隐藏层用ReLU输出层用Sigmoid。代码里我会把前向传播写成类方法缓存中间结果方便反向传播复用。class SimpleFCN: def __init__(self, n_input, n_hidden, n_output, lr0.1): # He初始化适配ReLU self.W1 np.random.randn(n_input, n_hidden) * np.sqrt(2.0 / n_input) self.b1 np.zeros((1, n_hidden)) self.W2 np.random.randn(n_hidden, n_output) * np.sqrt(2.0 / n_hidden) self.b2 np.zeros((1, n_output)) self.lr lr def forward(self, X): # 隐藏层线性变换 ReLU self.z1 X self.W1 self.b1 self.a1 np.maximum(0, self.z1) # 输出层线性变换 Sigmoid self.z2 self.a1 self.W2 self.b2 self.a2 1 / (1 np.exp(-self.z2)) return self.a2self.z1的维度是(m, 32)self.a1与它同形状。self.a2的形状是(m, 1)代表每个样本预测为正类的概率。逻辑说明forward里保存的z1、a1、z2都是中间变量反向传播需要它们计算梯度。如果把中间结果写成局部变量而不存到self上反向传播时就得重新算一遍代码没问题但多花一倍时间。参数说明lr0.1是学习率针对NumPy手写版本的SGD这个数值在这个小数据集上是安全的。隐藏层神经元数量32是经验值太大容易过拟合太小拟合能力不足。换数据集时可以先固定这个值跑通流程再回头调。3.3 反向传播与参数更新核心代码与形状对照反向传播的关键是每一步梯度矩阵的形状都要和前向传播里对应参数的形状一致。我写代码时有一个习惯先写出所有梯度的形状注释再写计算逻辑。def backward(self, X, y): m X.shape[0] # 输出层梯度 d_z2 (self.a2 - y) * self.a2 * (1 - self.a2) / m # (m, 1) d_W2 self.a1.T d_z2 # (32, 1) d_b2 np.sum(d_z2, axis0, keepdimsTrue) # (1, 1) # 隐藏层梯度 d_a1 d_z2 self.W2.T # (m, 32) d_z1 d_a1 * (self.z1 0) # ReLU导数 d_W1 X.T d_z1 # (64, 32) d_b1 np.sum(d_z1, axis0, keepdimsTrue) # (1, 32) # 参数更新 self.W2 - self.lr * d_W2 self.b2 - self.lr * d_b2 self.W1 - self.lr * d_W1 self.b1 - self.lr * d_b1 def train(self, X, y, epochs200): for epoch in range(epochs): y_pred self.forward(X) self.backward(X, y) if epoch % 50 0: loss np.mean((y_pred - y) ** 2) print(fepoch {epoch}, loss {loss:.4f})代码逻辑说明d_z2公式里的self.a2 * (1 - self.a2)是Sigmoid函数的导数。/m这个除法决定了梯度是“平均梯度”这样学习率不会因为batch变大而需要等比缩小。d_z1用(self.z1 0)实现ReLU的导数z1大于0的位置导数为1否则为0。注意这里直接用布尔矩阵和d_a1相乘不需要转成floatNumPy会正确处理。这个训练方式是全批量梯度下降每次迭代用全部训练样本计算梯度并更新一次参数。样本数只有288全批量完全跑得动。如果你的数据集超过几万条就要改成小批量或者随机梯度下降否则单次迭代计算太慢。3.4 训练主循环与损失观察怎样才算训练成功训练一个模型不要只看最终准确率要观察损失曲线的下降过程。理想情况是损失单调下降前几十个epoch下降快后面趋于平缓。model SimpleFCN(n_input64, n_hidden32, n_output1, lr0.1) model.train(X_train, y_train, epochs200) y_pred_val model.forward(X_val) val_acc ((y_pred_val 0.5).astype(int) y_val).mean() print(f验证集准确率: {val_acc:.4f})这段代码里forward(X_val)只做前向计算不会修改模型参数。预测概率大于等于0.5判为正类其余判为负类。验证集准确率在这个二分类任务上跑通代码后应该能得到98%以上如果低于95%优先检查归一化有没有做、学习率是否合适。训练结束后模型会存在model.W1、model.b1、model.W2、model.b2这四个数组里。这就是“训练好的模型”——它没有任何特殊结构就是一组浮点数。你想把这个模型保存下来用np.savez把这四个数组打包即可下次预测时重新加载再调用forward。4. 用PyTorch实现同一个网络两版代码的取舍与迁移时机NumPy版能帮你建立底层直觉但真实项目里我们通常直接用框架。这一章用PyTorch实现完全相同的三层全连接网络并把两版代码的关键差异摊开来讲。4.1 PyTorch模型定义三层结构的代码形态PyTorch里定义一个全连接网络通常继承torch.nn.Module。网络结构和NumPy版完全一致64输入、32隐藏、1输出ReLU加Sigmoid。import torch import torch.nn as nn class SimpleFNN(nn.Module): def __init__(self, n_input64, n_hidden32, n_output1): super().__init__() self.fc1 nn.Linear(n_input, n_hidden) self.relu nn.ReLU() self.fc2 nn.Linear(n_hidden, n_output) self.sigmoid nn.Sigmoid() def forward(self, x): x self.fc1(x) x self.relu(x) x self.fc2(x) x self.sigmoid(x) return xnn.Linear(n_input, n_hidden)内部做的事情和NumPy里的X W b完全一样但权重初始化策略由PyTorch自动选择默认是Kaiming均匀初始化对ReLU是安全的。相比NumPy版你不需要自己操心初始化公式框架已经把最优实践封装进去了。模型实例化和推理代码如下model SimpleFNN() criterion nn.BCELoss() # 二分类交叉熵损失 optimizer torch.optim.SGD(model.parameters(), lr0.1)nn.BCELoss是二分类交叉熵损失它在数学上比MSE更适合分类任务交叉熵在预测错误且置信度高时给出更大梯度训练效率更高。这体现了框架版的第一个优势你不需要手写损失函数的导数criterion已经内置了前向和反向的全部逻辑。4.2 训练循环与数据集装载DataLoader的正确用法PyTorch训练循环的标准写法是清空梯度、前向计算、反向传播、更新参数、记录损失。from torch.utils.data import TensorDataset, DataLoader X_train_t torch.tensor(X_train, dtypetorch.float32) y_train_t torch.tensor(y_train, dtypetorch.float32) train_dataset TensorDataset(X_train_t, y_train_t) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue) for epoch in range(200): for x_batch, y_batch in train_loader: optimizer.zero_grad() pred model(x_batch) loss criterion(pred, y_batch) loss.backward() optimizer.step() if epoch % 50 0: print(fepoch {epoch}, loss {loss.item():.4f})逻辑说明optimizer.zero_grad()每轮迭代必须调用否则梯度会累积在tensor.grad里导致参数更新步长被成倍放大。loss.backward()自动计算整个计算图的梯度optimizer.step()用优化器把梯度应用到参数上。DataLoader里的batch_size32就是小批量梯度下降。和NumPy版的全批量不同每个batch只包含32个样本因此每轮epoch会更新多次参数。batch_size是训练中最重要的参数之一太小梯度噪声大、收敛不稳太大则每次更新方向过于平均训练慢。32是一个久经考验的起点。4.3 迁移到PyTorch的时机什么场景值得换如果你只是学习原理、写个几百行数据的演示NumPy版完全够用。但遇到下面几种情况我会立刻切到PyTorch第一数据量超过十万条纯NumPy矩阵运算的内存和速度都吃紧第二需要GPU训练PyTorch只需把模型和数据移动到GPU设备第三网络结构要加深或改造成卷积网络手写反向传播的复杂度会指数上升。PyTorch还有一个隐藏优势自动求导。NumPy版里你得手写d_z1 d_a1 * (self.z1 0)一旦网络多几层这种推导非常容易出错。框架的loss.backward()会自动从损失回传到所有参数并且支持任意复杂的计算图。你只需要关心网络结构和数据流动不用碰梯度公式。切换时的代价也很明确需要理解tensor和numpy.ndarray的区别以及设备位置问题。常见报错“Expected all tensors to be on the same device”就是模型在CPU而数据在GPU或者反过来。排查方式是在模型和数据创建时都指定devicecuda或devicecpu保持两边一致即可。5. 避坑全连接网络训练中常见的5个问题排查这一章是我在实际项目里反复踩过的坑按“现象、原因、解决”的排查思路整理。如果你训练时遇到类似状况可以直接对照定位。5.1 损失一直停在0.69附近不下降现象训练几十个epoch损失始终在0.69附近波动准确率只有50%。原因0.69是二分类交叉熵损失在随机猜测时的理论值。损失卡在这里意味着模型根本没有学到任何模式。最常见的原因是输入特征没有归一化数值量级差异大导致Sigmoid饱和。其次是标签的取值范围有问题比如把二分类标签写成了0和1之外的数值。解决先检查数据预处理把每个特征的取值范围压缩到0到1之间。再检查y是不是只有0和1两种取值。如果都正常把学习率从0.1降到0.01再试排除学习率过大导致损失震荡的假象。5.2 训练震荡剧烈损失曲线像锯齿一样现象损失整体在下降但每几个epoch就出现一次明显反弹曲线不平滑。原因最常见是学习率偏高。每次参数更新的步长太大跨过了最优区域下一次迭代又折返回来。另一种可能是batch_size设得太小比如在小型数据集上用了batch_size1每个样本的梯度方向不一致造成更新方向来回摇摆。解决先把学习率降一个数量级看曲线是否变平滑。如果有效再进一步用学习率衰减策略常见做法是每20个epoch把学习率乘以0.9。如果数据量不大直接改用全批量训练或batch_size设为64以上能显著降低震荡。5.3 加深网络后训练集损失都降不下去现象把网络从一层隐藏层加到三层理论上拟合能力更强但训练集损失反而比浅层网络还高。原因梯度消失。隐藏层中使用Sigmoid激活时每经过一层梯度要乘一次a(1-a)而a(1-a)最大值只有0.25。三层之后梯度已经接近0前几层的权重几乎不再更新。解决隐藏层的激活函数全部换成ReLU它正区间的导数是1梯度可以顺畅地穿透多层。如果仍不行检查每一层的权重标准差初始值不宜太小。更深层的网络还要配Batch Normalization但那是后话全连接网络优先保证激活函数选型正确。5.4 训练集表现很好、验证集差一大截现象训练集准确率99%验证集只有80%差距逐年增加。原因过拟合。全连接网络的参数量通常很大这个例子中64输入、32隐藏的参数是64*32 32 2080个而训练样本只有288个参数数量远超样本量模型完全有能力“背下”训练集。解决第一优先级是加正则化。常见做法是在损失函数里增加L2正则项PyTorch里设置optimizer的weight_decay0.001即可NumPy版则需要在更新权重时额外减去lr * weight_decay * W。第二优先级是早停每训练一轮看验证集loss连续几个epoch不下降就停止训练。第三优先级是加大数据量或数据增强二分类数字识别可以通过旋转、平移扩充样本。5.5 全连接层输出了NaN概率变成None现象训练到某个epoch损失输出nan预测结果全是nan。原因数值上溢。最常见是Sigmoid的np.exp(-z)在z为很负的数时溢出或者学习率过大导致权重更新后误差爆炸中间激活值变得极大。另一个容易被忽视的原因是没有做数值稳定的Sigmoid计算直接在代码里写了1 / (1 np.exp(-z))。解决把Sigmoid换成前面提过的np.where(z 0, ...)分段实现。然后检查学习率nan出现通常意味着学习率超过安全上界一个数量级以上。最后检查标签和特征里有没有inf或nan混入数据清洗时用np.isnan(X).any()排查。6. 验证你的实现对不对梯度检查、损失曲线判读与下一步模型写完不是结束要验证它真的“学对了”。这一章给你三个实用的自检手段全部不依赖额外库。6.1 数值梯度检查最直接的反向传播自检反向传播只要有一处推导错误模型也能训只是训得差且难以定位。标准的做法是用数值梯度做差分验证对某个参数w按下式近似计算梯度和反向传播算出的梯度对比def numerical_gradient(model, X, y, param_name, eps1e-5): param getattr(model, param_name) grad np.zeros_like(param) for i in range(param.shape[0]): for j in range(param.shape[1]): old param[i, j] param[i, j] old eps loss_plus np.mean((model.forward(X) - y) ** 2) param[i, j] old - eps loss_minus np.mean((model.forward(X) - y) ** 2) param[i, j] old grad[i, j] (loss_plus - loss_minus) / (2 * eps) return gradeps通常取1e-5到1e-4之间太小会有浮点误差太大会偏离真实梯度。对比时用相对误差norm(analytic - numerical) / norm(analytic)小于1e-6说明实现正确。这个检查只需在小模型、少量样本上做比如只取10个样本、网络设成2个隐藏神经元几秒钟就能跑完。6.2 损失曲线的三种形态欠拟合、正常收敛与过拟合损失曲线的形态能告诉你训练该停在哪。第一种形态损失一直高且平稳验证集准确率也上不去这是欠拟合需要加层或加神经元。第二种形态训练损失持续下降后走平验证损失同时走平这是我们追求的正常收敛可以放心用。第三种形态训练损失继续下降但验证损失掉头上升这是过拟合的典型信号早停的时机就选在验证损失开始上升的前一个epoch。6.3 从简单全连接网络出发还能做什么当你把这一套代码跑通并完成梯度检查后续的进阶路径会非常清晰把输出层的Sigmoid换成Softmax损失改成nn.CrossEntropyLoss就能直接扩成多分类把权重更新改成小批量并加入动量收敛速度会明显加快。我自己每次搭新模型都会先用这个级别的全连接网络跑通数据和预处理流程确认损失能降、准确率能看再上复杂结构。这个习惯帮我省下了很多在debug模型前先debug数据的时间。希望这些过程能帮到你。本文还有配套的精品资源点击获取