资讯动态

从零手写全连接神经网络:Python与NumPy实现反向传播

发布时间:2026/10/2 22:54:26 来源:尧图企业网站定制
简介一份基于Python与NumPy从零实现的全连接神经网络代码包面向希望理解深度学习底层原理的初学者以及打算快速掌握网络训练与预测流程的开发者。压缩包内共有7个可运行Python脚本整体文件仅4KB其中涵盖数据生成、网络结构定义、训练循环、前向与反向传播、均方误差损失计算以及预测推理等模块目录划分干净明确。已有4211人学习下载。代码不依赖高级框架通过numpy完成随机权重初始化、sigmoid激活函数、梯度计算与权重更新能够清晰展示多层感知器从输入到输出的数据流动并体会反向传播求导细节为后续使用TensorFlow、Keras、PyTorch等工具奠定坚实基础。对机器学习与深度学习的入门者而言这是一份轻量、可直接运行的学习脚本能辅助完成课程设计或自学实践理解神经网络从构建到预测的完整通路。1. 简单全连接神经网络python实现先跑通再谈原理的最小路径“识别人手写的数字、预测明天的气温、区分垃圾短信”这些日常任务背后都能找到一个共同的基础模型——全连接神经网络。很多人一上手就扎进 TensorFlow、PyTorch被model.fit()这类封装接口一挡反而说不清“网络到底在学什么”。这篇文章给你一条更慢但更扎实的路径用 Python 和 NumPy从零手写一个简单全连接神经网络不用任何深度学习框架代码量控制在 200 行以内。它适合刚入门 python、想真正弄懂反向传播的新手也适合被框架黑匣子困扰的工程师——当你能亲手写出每一层的梯度再回头用框架很多报错看一眼就能定位问题。2. 全连接网络的计算图拆解前向传播、反向传播与链式法则2.1 为什么选全连接神经网络从单层感知机到多层拟合能力全连接神经网络也叫多层感知机MLP是目前所有深度学习模型的“地基”。一个最简单的全连接网络由输入层、若干隐藏层、输出层组成每一层的每个神经元都和上一层的所有神经元相连。这种“全连接”结构看起来笨重但数学性质极好只要隐藏层足够宽、激活函数选对它就能逼近任意连续函数这就是万能逼近定理。单层感知机只能处理线性可分问题经典的反例是异或XOR问题二维平面上两条对角线上的点无法用一条直线分开。给感知机加一个隐藏层和激活函数之后异或问题立刻迎刃而解。理解这个跳跃很重要——添加的非线性激活函数让网络不再只是线性变换的堆叠而是真正拥有了“拟合曲面”的能力。选全连接网络作为入门实践对象还有一个务实理由它的前向传播就是一连串矩阵乘法反向传播就是链式法则的逐层展开不需要卷积、注意力这些复杂算子。你只需要会np.dot、会求导就能把整个学习过程握在手里。考虑到 python 数据分析与可视化是多数人熟悉的技能用 NumPy 手写训练循环比直接调框架更能打通“数学公式—代码—数据”这条链路。2.2 用 numpy 实现前向传播矩阵形状与激活函数的选择前向传播的本质是把输入数据逐层“搬运”到输出。假设输入X的形状是(m, d)代表m条样本、每条d个特征第一层权重W1的形状是(d, h)偏置b1是(1, h)经过矩阵乘法后得到(m, h)的隐藏层输出再接第二层权重W2: (h, c)最终输出(m, c)。这里的h是隐藏神经元数量c是类别数或输出维度。先看一个最小前向传播实现隐藏层用 tanh输出层用 sigmoid 做二分类import numpy as np def sigmoid(z): # 数值稳定版的 sigmoidz 很大时 exp(-z) 不会溢出 return 1.0 / (1.0 np.exp(-np.clip(z, -500, 500))) def forward_propagation(X, params): W1, b1 params[W1], params[b1] W2, b2 params[W2], params[b2] Z1 np.dot(X, W1) b1 # (m, h) A1 np.tanh(Z1) # 隐藏层激活输出 Z2 np.dot(A1, W2) b2 # (m, c) A2 sigmoid(Z2) # 输出层概率值范围 (0, 1) return A2, (Z1, A1, Z2)代码里的形状变化是第一优先级X(m, d)乘W1(d, h)得到(m, h)这个乘法要求“X 的第二维等于 W1 的第一维”写代码前先在注释里标好形状能省掉大量调试时间。np.tanh的输出范围是(-1, 1)梯度的最大值为 1比 sigmoid 更容易传递误差sigmoid 输出范围(0, 1)天然适合二分类输出概率。如果你做多分类最后一层通常换成 softmax。这里有个关键选型隐藏层激活函数尽量不要用 sigmoid。sigmoid 在输入绝对值较大时梯度会变得非常小堆叠多层后梯度连乘浅层的权重几乎收不到有效更新。tanh 虽然也有饱和区但零中心特性让收敛更稳。实际项目里隐藏层更常用 ReLU 系列后面的避坑章会详细说。2.3 反向传播的核心推导以交叉熵和 tanh 的组合为例反向传播是让网络“学习”的关键。训练时我们会定义一个损失函数衡量预测与真实标签的差距然后求损失对每个参数的偏导数沿负梯度方向更新权重。对两层网络来说链式法则只展开两层手推并不困难。以二分类交叉熵损失为例损失函数定义为L -[y * log(p) (1-y) * log(1-p)]其中p是 sigmoid 输出。交叉熵配合 sigmoid 有一个非常漂亮的化简结果输出层误差dZ2 p - y。这个结论推一次记一辈子因为 sigmoid 的导数p(1-p)刚好抵消了交叉熵求导后分母里的p(1-p)。def backward_propagation(X, Y, A2, cache, params): m X.shape[0] Z1, A1, Z2 cache # 输出层误差交叉熵 sigmoid 的化简结果 dZ2 A2 - Y.reshape(-1, 1) # (m, 1) dW2 np.dot(A1.T, dZ2) / m # (h, 1) db2 np.sum(dZ2, axis0, keepdimsTrue) / m # 隐藏层误差tanh 导数是 1 - tanh^2 dA1 np.dot(dZ2, params[W2].T) # (m, h) dZ1 dA1 * (1 - A1 ** 2) # 逐元素乘不是点积 dW1 np.dot(X.T, dZ1) / m # (d, h) db1 np.sum(dZ1, axis0, keepdimsTrue) / m grads {dW1: dW1, db1: db1, dW2: dW2, db2: db2} return grads这里最值得注意的坑是dZ1的计算用了逐元素乘法*不是矩阵乘法np.dot。dA1的形状是(m, h)1 - A1**2的形状也是(m, h)逐元素相乘得到每个样本在隐藏层的误差。如果把*写成np.dot形状不匹配会直接报错如果形状碰巧匹配数值也完全是错的。每一层梯度的计算顺序遵循链式法则先输出层再逐层向输入方向回传。对权重求梯度时用“上一层的激活值”转置点乘“当前层的误差”这个模式在更深的网络中同样成立。3. 从零搭建训练闭环数据准备、批次训练与超参数设置3.1 训练数据怎么组织归一化、标签编码与 train/val 划分网络的训练效果三分靠结构、七分靠数据。一个常见翻车场景原始特征取值范围差异巨大比如房价预测里“面积”是几十到几百“房龄”是 0 到 50“总价”是几百万。如果直接喂进网络权重更新会被数值大的特征主导损失函数像在走迷宫。解决办法是做标准化Standardization让每个特征均值为 0、方差为 1def standardize(X, meanNone, stdNone): # 用训练集的均值和标准差去标准化测试集必须复用同一组参数 if mean is None: mean np.mean(X, axis0) std np.std(X, axis0) 1e-8 # 加极小值防止除零 return (X - mean) / std, mean, stdstd里加的1e-8是防守性编程防止某个特征在所有样本中取值完全相同比如全是 0 的哑变量时除零。标准化时有个容易忽视的约束必须用训练集计算出来的mean和std去处理验证集和测试集不能用测试集重新计算。原因很实在——测试集模拟的是未来未知数据它的分布信息不能被“偷看”进预处理流程。标签方面二分类用 0/1 即可但多分类就不要再标成 1、2、3 这样的整数了那会引入不存在的顺序关系。常见做法是 one-hot 编码3 个类别就是 3 列每行只有对应类别的位置为 1。输出层的神经元数量等于类别数配合 softmax 输出概率分布。3.2 训练循环怎么写epoch、batch、learning rate 的相互作用训练循环里有三个最基础但最容易搞混的单位样本sample、批次batch、轮次epoch。一个 epoch 表示网络完整看过一遍训练集batch 是每次权重更新时实际参与计算的数据量iteration迭代是一个 epoch 内完成参数更新的次数等于训练集样本数除以 batch 大小。假设训练集有 1000 条数据、batch size 为 100一个 epoch 里就有 10 次迭代。batch size 选多大直接影响收敛质量。batch 太小比如 1梯度噪声大损失曲线像心电图batch 太大一个 epoch 只更新几次参数收敛慢且容易陷入尖锐极小值。工程上 16 到 128 是常用区间具体看数据量。学习率是另一个杠杆学习率大步子大可能跳过最优点学习率小收敛慢还可能在沟谷里来回震荡。这两个参数要一起调把 batch 调大通常可以配合把学习率调大一点。3.3 完整可运行的训练代码一个两层隐藏层的最小实现把前向、反向和参数更新串起来就是一个完整的最小训练闭环。下面这段代码用 NumPy 实现两层网络在随机生成的数据上做二分类训练。为了方便验证效果我加了简单的准确率打印import numpy as np np.random.seed(0) def sigmoid(z): return 1.0 / (1.0 np.exp(-np.clip(z, -500, 500))) def initialize(hidden_size, input_size2): # 用 0.5 * 随机数初始化避免全零导致对称性问题 W1 np.random.randn(input_size, hidden_size) * 0.5 b1 np.zeros((1, hidden_size)) W2 np.random.randn(hidden_size, 1) * 0.5 b2 np.zeros((1, 1)) return {W1: W1, b1: b1, W2: W2, b2: b2} def forward(X, p): Z1 np.dot(X, p[W1]) p[b1] A1 np.tanh(Z1) Z2 np.dot(A1, p[W2]) p[b2] A2 sigmoid(Z2) return A2, (Z1, A1, Z2) def loss(Y, A2): # 交叉熵损失加 1e-8 防止 log(0) return -np.mean(Y * np.log(A2 1e-8) (1 - Y) * np.log(1 - A2 1e-8)) def backward(X, Y, A2, cache, p): m X.shape[0] Z1, A1, Z2 cache dZ2 A2 - Y.reshape(-1, 1) dW2 np.dot(A1.T, dZ2) / m db2 np.sum(dZ2, axis0, keepdimsTrue) / m dA1 np.dot(dZ2, p[W2].T) dZ1 dA1 * (1 - A1 ** 2) dW1 np.dot(X.T, dZ1) / m db1 np.sum(dZ1, axis0, keepdimsTrue) / m return {dW1: dW1, db1: db1, dW2: dW2, db2: db2} # 生成一个线性不可分的二分类数据集 m 300 X np.random.randn(m, 2) Y ((X[:, 0] ** 2 X[:, 1] ** 2 1.5) (X[:, 0] * X[:, 1] -0.2)).astype(int).reshape(-1, 1) params initialize(hidden_size8) learning_rate 0.5 epochs 300 for epoch in range(epochs): A2, cache forward(X, params) grads backward(X, Y, A2, cache, params) for key in params: params[key] - learning_rate * grads[d key] if epoch % 50 0: preds (A2 0.5).astype(int) acc np.mean(preds Y) print(fepoch {epoch:3d} loss {loss(Y, A2):.4f} acc {acc:.2%})initialize里* 0.5把初始权重压到较小范围避免 tanh 一开始就进入饱和区。更新参数时用params[key] - learning_rate * grads[d key]注意grads的键是dW1去掉开头的d正好是params的键W1这种命名方式在批量更新时特别省事。训练循环的核心逻辑只有三步前向算预测、反向算梯度、沿负梯度更新。epoch 打印损失和准确率是为了观察收敛趋势——损失上升通常意味着学习率过大或数据组织有误。4. 全连接神经网络必调的 4 个超参数初始化、学习率、正则项和激活函数4.1 权重初始化为什么影响收敛从全零初始化到 Xavier/He权重初始化是深度学习里最容易被新手忽略、也最“玄学”的一个环节。如果所有权重都初始化为 0反向传播时同一层的每个神经元会收到完全相同的梯度更新后仍然完全相同网络退化成只有一个神经元的线性模型这就是对称性问题。常见的稳健做法是随机初始化但随机范围很讲究。权重初始值太大tanh 或 sigmoid 直接饱和梯度趋近于 0网络学不动初始值太小信号逐层衰减深层神经元接收到的输入几乎一样。Xavier 初始化根据输入输出维度自动缩放W ~ U(-sqrt(6/(n_inn_out)), sqrt(6/(n_inn_out)))它保证信号在前向和反向传播时方差大致不变。如果隐藏层用 ReLU更推荐 He 初始化把缩放系数改为sqrt(2/n_in)。实际训练中换掉错误的初始化方式往往比调学习率更有效果。4.2 学习率怎么设固定、衰减与 Adam 的差异学习率是训练曲线形态最直接的决定因素。固定学习率实现最简单但要选一个“中间值”太大损失曲线先降后升甚至直接 NaN太小损失曲线平滑下降但要跑几千轮才收敛。经验做法是先跑 100 轮观察损失如果损失剧烈震荡则把学习率除以 10如果下降缓慢则乘以 10快速定位数量级。策略典型值特征适用场景固定学习率0.01 ~ 0.1实现简单后期损失波动小型网络、快速实验步进衰减每 50 轮 ×0.9前期快、后期稳训练轮次较多时指数衰减lr × 0.95^epoch平滑递减模型接近收敛阶段Adam 自适应默认 0.001每参数单独调步长大多数中大型任务Adam 之所以流行本质是把“学习率怎么调”这个人工问题部分自动化了它对每个参数维护一阶动量梯度的指数平均和二阶动量梯度平方的指数平均再用两者比值动态调整更新步长。新手直接用 Adam 搭配 0.001 的初始学习率通常能得到一个可接受的收敛结果但这不意味着 Adam 免调——它的beta1、beta2默认值效果虽好损失函数曲面复杂时仍需要配合衰减策略。4.3 正则化手段对比L2、Dropout 与早停模型在训练集上表现好、在验证集上表现差是过拟合的直接信号。全连接网络的参数量很大几层隐藏层就能轻松达到百万参数级正则化不是可选优化而是必备手段。L2 正则化在损失函数里加上所有权重的平方和。代价是梯度更新时多一项lambda * W实现上只需在反向传播后执行W - learning_rate * lambda * W。它假设权重越靠近 0 越小网络输出越平滑。Dropout 的思路则相反训练时每次迭代随机让一部分神经元失效输出置 0使网络不依赖单个神经元。实现 Dropout 只需两步前向时生成掩码keep_mask np.random.rand(*A1.shape) keep_prob将A1 * keep_mask; A1 / keep_prob反向传播时用同一个掩码过滤梯度。注意测试阶段必须关掉 Dropout并保持完整权重。早停是最被低估的正则化手段每个 epoch 后在验证集上计算损失如果连续若干个 epoch 验证损失不再下降就停止训练并保存最佳参数。它不改变网络结构也不增加计算负担却能从根本上限制造化能力。我通常三者并用L2 控制权重范数、Dropout 打破神经元共适应、早停兜底防止过拟合。5. 全连接网络实战避坑指南五个必查项与排查方法5.1 损失不下降先看数据归一化再看学习率数量级现象训练几十个 epoch损失始终在高位震荡准确率停在随机水平。原因一数据未归一化。特征值动辄上千梯度更新方向被大数值特征绑架。原因二学习率过小或过大。学习率过小损失下降极慢肉眼几乎看不到变化学习率过大损失一开始就反弹甚至飙升到 inf。解决先对数据做标准化再按 4.2 节的“百轮快速试探法”调整学习率。如果归一化后损失仍然不降打印每一层梯度的均值与方差确认梯度没有全部归零——梯度全为零意味着反向传播链路断了多半卡在初始化或激活函数饱和区。5.2 训练集表现好但测试集差过拟合的三个标准解法现象训练准确率 98%验证准确率只有 72%且验证损失随训练轮次先降后升。原因网络容量超过任务需求把训练集的噪声也一起背下来了。全连接网络的参数量随隐藏层神经元数量平方级增长100 个神经元的隐藏层就有约 100 个权重输入维度不高时记忆能力远超想象。解决最先做的是加早停观察验证损失曲线取最低点对应的权重。其次是加 Dropoutkeep_prob从 0.8 起步过拟合严重时降到 0.5。最后才考虑缩网络或加 L2。一个实用技巧把训练集和验证集的损失曲线画在同一张图里两条曲线开始分离的那个 epoch就是提前停止的最佳时机。5.3 梯度消失与梯度爆炸深层网络的数值阴影现象隐藏层超过 5 层时浅层权重的梯度打印出来全是 1e-10 级别消失或者全是 NaN爆炸损失曲线直接断崖。原因反向传播是逐层连乘假设每层梯度缩放因子为 0.310 层之后就是 0.3 的 10 次方约 5e-6梯度自然消失。激活函数选 sigmoid 或 tanh 会加剧这个问题。爆炸则常见于学习率过大或权重初始化不够小数值在更新中快速累积溢出。解决把隐藏层激活函数换成 ReLU正向梯度为 1配合 He 初始化不要用 sigmoid 做隐藏层激活。如果网络必须很深加残差连接把输入直接加到输出上是更结构化的方案。定期打印梯度范数也是个好习惯梯度范数超过权重范数一个数量级就要小心梯度爆炸了。5.4 激活函数选错导致输出全是同一值现象第一个 epoch 后所有样本的预测概率都接近 0.5后续概率逐渐极化但准确率不升——网络在输出“平均答案”。原因隐藏层用 sigmoid配合过大或过小的初始化权重所有神经元输出几乎相同向后传播的梯度也相同特征多样性被抹平。另一种常见情形输出层用 sigmoid 做多分类每个类别概率不独立也不和为 1。解决二分类输出层用 sigmoid多分类输出层必换 softmax且损失函数对应换成交叉熵。隐藏层优先 ReLU它的死不活性负数梯度为 0虽然偶尔导致神经元永久失活但配合小学习率和 He 初始化整体效果远好于 sigmoid。如果你的任务要求输出非负值输出层可以考虑用 ReLU 而不是 sigmoid。5.5 矩阵维度不匹配从报错信息定位网络结构错误现象ValueError: shapes (100,4) and (3,1) not aligned这类报错频繁出现初学者常常靠碰运气改 reshape越改越乱。原因前向传播里np.dot(X, W1)要求 X 的第二维等于 W1 的第一维。数据维度、网络输入维度、上一个隐藏层的输出维度之间没有对齐通常在“加了层”或“换了数据集”之后爆发。解决把维度的约束关系写在注释里每层注释当前张量的形状。接到报错信息时先算一下两组数字报错里说(100,4) and (3,1)意思就是某个np.dot左边矩阵的列数 4 不等于右边矩阵的行数 3。检查上一层的输出维度是否等于下一层权重的第一维改权重形状或改input_size配置不要盲目转置。养成“先注释形状再写计算”的习惯后这类报错几乎可以绝迹。6. 把全连接网络应用到真实小任务先做梯度检查再谈训练效果当你把上面的代码跑通下一步不是急着上大任务而是先验证你写的反向传播梯度是否正确——这是全连接网络从“能跑”到“可信”的分水岭。数值梯度检查的思路来自导数的定义对每个参数W[i,j]分别加一个极小量epsilon和减一个极小量计算两次损失的差值除以2*epsilon所得近似梯度与反向传播算出的梯度对比。虽然数值法很慢但它不依赖任何推导是检验你手写反向传播的后悔药。def gradient_check(params, X, Y, epsilon1e-5): # 只检查第一个权重矩阵的其中几个元素避免全量检查太慢 for (i, j) in [(0, 0), (1, 2), (3, 1)]: original params[W1][i, j] params[W1][i, j] original epsilon A2_plus, _ forward(X, params) loss_plus loss(Y, A2_plus) params[W1][i, j] original - epsilon A2_minus, _ forward(X, params) loss_minus loss(Y, A2_minus) params[W1][i, j] original numerical (loss_plus - loss_minus) / (2 * epsilon) A2_cache, cache forward(X, params) grads backward(X, Y, A2_cache, cache, params) analytic grads[dW1][i, j] print(fW1[{i},{j}] numerical{numerical:.8f} analytic{analytic:.8f}) assert abs(numerical - analytic) 1e-3, 梯度误差过大检查反向传播epsilon取1e-5是经验值太大了导数近似误差明显太小了浮点数精度会干扰计算结果。1e-3的容忍阈值适合两层小网络如果网络更深或者用的损失函数更复杂把阈值放宽到1e-2仍能排除方向性错误。我当年第一次手写反向传播代码跑起来 loss 也降了但降到一定程度就卡住不动正是梯度检查暴露出 tanh 求导那一步的符号写反了。梯度检查通过后你可以把代码稍作扩展去接真实任务把数据源换成一列 CSV 特征输出层改成多类别softmax就是一个能用的分类器。想提升性能再引入 mini-batch 训练——每次随机采样一小批数据更新参数而不是全体数据算完再更新。我在这个标题上学到的最有价值一个教训是手写全连接网络的意义不在于让你不用框架而在于让你亲手摸过每一处梯度引力的来源。之后不管你转向 PyTorch 还是 TensorFlow看到“维度不匹配”会先检查形状注释看到“损失不降”会先检查学习率数量级——这些排查方向全是从这份 200 行的最小实现里长出来的。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑