
简介这份资源面向希望深入理解深度学习底层原理的Python初学者用NumPy从零搭建一个简单的全连接神经网络完整覆盖网络参数初始化、sigmoid激活函数、前向传播、均方误差损失计算、反向传播与权重更新、训练及预测流程帮助读者摆脱单纯调用框架的迷思直观掌握多层感知器的核心工作机制。压缩包体积仅4KB包含7个Python脚本模块划分清晰既有神经网络核心模型与训练算法也有数据生成、公共工具和独立预测脚本可直接运行并灵活修改。当前已有4211人学习下载适合机器学习入门者、高校学生以及需要快速回顾神经网络基础的程序员。通过这份小巧的代码包读者能够边运行边观察不同初始化和训练参数对结果的影响清晰理解梯度下降在反向传播中的实际作用为后续学习TensorFlow、PyTorch等深度学习框架打下扎实基础。1. 简单全连接神经网络为什么从零手写比调库更值得用纯 Python 实现一个简单全连接神经网络核心代码不过几十行却能让你把反向传播的每一个梯度算明白而不是把 PyTorch 当黑匣子用。这个方向最适合两类人一类是刚入门深度学习的初学者想搞清楚神经网络内部到底发生了什么另一类是需要在轻量环境里快速验证想法、又不想引入重型框架的从业者。全连接神经网络是一切深度模型的地基卷积、循环、注意力都是在这套前向传播加反向传播的骨架上长出来的。我见过太多人调了一两年框架却说不清权重矩阵为什么是这个形状、学习率为什么调大就炸。手写一个全连接网络你只需要 NumPy 就够环境准备成本几乎为零装好 Python 3 和 numpy 库一条 pip 命令的事就能把整个训练闭环跑通。这个实现虽然简单但它五脏俱全初始化、前向传播、反向传播、参数更新、预测一个不少。本文就用异或 XOR 数据集作为验证目标——它恰好是单层感知机解决不了、而一个两层全连接网络能轻松搞定的经典案例。2. 网络结构与前向传播先定义形状再写代码2.1 两层全连接网络的形状推演全连接神经网络的核心操作就是矩阵乘法加激活函数。一个两层网络输入层不算层的结构是输入层 → 隐藏层 → 输出层。每一层做的事情都一样拿上一层的输出乘当前层的权重矩阵加上偏置再过激活函数。拿 XOR 数据集来说输入是二维的两个 0/1 值输出是一维的0 或 1。我一般会这样设计网络形状输入层 2 个神经元隐藏层 4 个神经元输出层 1 个神经元。为什么隐藏层选 4XOR 需要至少两个隐藏神经元来构造决策边界4 个是给足冗余让训练更容易收敛。权重和偏置的形状是新手第一个翻车点。规则只有一条权重矩阵的行数等于上一层神经元数列数等于当前层神经元数。偏置的形状等于当前层神经元数。具体到我们的结构输入到隐藏层W1 形状 (2, 4)b1 形状 (4,)隐藏层到输出层W2 形状 (4, 1)b2 形状 (1,)当批量数据 X 的形状是 (m, 2)m 是样本数时前向传播就是两次矩阵乘法加激活。第一次得到 z1 X·W1 b1形状 (m, 4)过激活函数后还是 (m, 4)。第二次得到 z2 a1·W2 b2形状 (m, 1)。这个形状推演一定要在写代码前自己画一遍不然 debug 维度错误会耗掉大量时间。2.2 初始化函数与 sigmoid 激活激活函数我选 sigmoid原因就两个它的导数形式极其简洁σ(x) σ(x)(1-σ(x))而且值域在 0 到 1 之间天然适合做二分类的输出。缺点后面避坑章节再讲这里先把代码写出来跑通。import numpy as np def sigmoid(x): sigmoid激活函数对输入逐元素计算返回与输入同形状的数组 return 1 / (1 np.exp(-x)) def sigmoid_derivative(x): sigmoid的导数输入x是激活前的值z不是激活后的值a s sigmoid(x) return s * (1 - s) def init_network(input_size, hidden_size, output_size, seed42): 初始化两层的权重和偏置 返回一个列表 [W1, b1, W2, b2] W1: (input_size, hidden_size) b1: (hidden_size,) W2: (hidden_size, output_size) b2: (output_size,) rng np.random.default_rng(seed) # 权重用标准正态分布采样数值小一点避免一开始就落进sigmoid饱和区 W1 rng.normal(0, 1.0, (input_size, hidden_size)) b1 np.zeros(hidden_size) W2 rng.normal(0, 1.0, (hidden_size, output_size)) b2 np.zeros(output_size) return [W1, b1, W2, b2]初始化逻辑说明偏置全零是常见做法因为偏置的初始值不影响网络的表达能力训练几轮后会自动调整。权重用正态分布采样但没有做缩放隐藏层规模小4 个神经元时问题不大后面如果加宽网络需要换成 Xavier 初始化来避免梯度消失或爆炸这个在避坑章细说。seed参数保证每次运行初始化一致排错时特别有用——你总不希望两次运行结果不同却找不到原因。2.3 前向传播函数与缓存的必要性前向传播除了算出预测值还必须把中间变量缓存起来这些变量在反向传播时要用来计算梯度。很多新手漏了这一步到反向传播时只能重新算或者干脆卡住。def forward(X, params): 两层全连接网络的前向传播 参数X: (m, input_size)m是批次大小 参数params: [W1, b1, W2, b2] 返回: (预测值a2, 缓存元组) W1, b1, W2, b2 params # 第一层线性变换 sigmoid z1 np.dot(X, W1) b1 # (m, hidden_size) a1 sigmoid(z1) # (m, hidden_size) # 第二层线性变换 sigmoid z2 np.dot(a1, W2) b2 # (m, output_size) a2 sigmoid(z2) # (m, output_size) # 缓存里存的是反向传播要用的中间变量 cache (z1, a1, z2, a2) return a2, cache参数说明np.dot做矩阵乘法这里 X 是二维数组所以不需要手动 broadcasting。 b1是 NumPy 的广播机制在起效b1 的形状是 (hidden_size,)会沿样本维度自动平铺。缓存里 a1 和 a2 是激活值z1 和 z2 是线性输出sigmoid 的输入反向传播时两个都要用。我习惯用元组存缓存而不是字典取用更快但这纯粹是个人偏好。3. 反向传播链式法则的代码翻译3.1 从损失函数到权重梯度的推导反向传播是整个手写神经网络的重头戏也是很多人劝退的地方。它本质上就是链式法则的反复套用损失函数对权重的梯度等于损失对激活值的梯度乘以激活值对线性输出的梯度再乘以线性输出对权重的梯度。先说损失函数。我们用的是均方误差MSEL (1/m) * Σ(a2 - y)²。这个函数简单梯度好算但配上 sigmoid 输出层有一个特殊性质MSE 对输出层线性输出 z2 的梯度恰好等于 (a2 - y)中间的 sigmoid 导数被约掉了。推导过程是这样的dL/da2 a2 - y这里乘了 1/m 但最终取均值时会处理da2/dz2 a2(1-a2)两者相乘得到 dL/dz2 (a2-y)·a2·(1-a2)。注意这里有个隐藏的坑如果是二分类问题且用 sigmoid 加 MSE梯度里是有 a2(1-a2) 这一项的。但很多教材里会写 dL/dz2 a2 - y那是交叉熵损失加 sigmoid 的结论不是 MSE 的。我踩过这个坑训练死活不收敛后来才发现是 MSE 场景下用了交叉熵的梯度公式。为了代码简洁后面统一用交叉熵损失的变体写法输出层梯度直接取 dz2 a2 - y这在二分类输出层搭配 sigmoid、损失函数为交叉熵时是严格正确的。如果你坚持要用 MSE把梯度改成 (a2-y) * a2 * (1-a2) 就行。两种损失在 XOR 这种简单数据集上都能收敛但交叉熵收敛更快更稳。3.2 反向传播实现逐个梯度的形状验证def backward(X, y, params, cache): 反向传播计算所有参数的梯度 参数X: (m, input_size) 参数y: (m, output_size)真实标签 参数cache: forward返回的元组(z1, a1, z2, a2) 返回: [dW1, db1, dW2, db2]形状与params一一对应 W1, b1, W2, b2 params z1, a1, z2, a2 cache m X.shape[0] # 输出层梯度二分类输出用sigmoid时这个式子是准确的 dz2 a2 - y # (m, output_size) dW2 np.dot(a1.T, dz2) / m # (hidden_size, output_size) db2 np.sum(dz2, axis0, keepdimsTrue) / m # (output_size,) # 隐藏层梯度链式法则往回传 dz1 np.dot(dz2, W2.T) * sigmoid_derivative(z1) # (m, hidden_size) dW1 np.dot(X.T, dz1) / m # (input_size, hidden_size) db1 np.sum(dz1, axis0, keepdimsTrue) / m # (hidden_size,) return [dW1, db1, dW2, db2]梯度形状验证是这段代码最重要的习惯动作dW2 必须和 W2 形状完全一致db2 必须和 b2 一致否则参数更新那一步params[i] - lr * grads[i]会直接报错或者悄悄产生错误更新。检查方法很朴素——在返回前逐个打印 grads 和 params 的形状人工对一遍。我每次写新网络结构都会做这一步看起来繁琐但能省下后面几小时的 debug 时间。为什么每项都要除以 m因为我们用的是全批量梯度下降一次迭代用全部样本算一个平均梯度。除以 m 是为了让梯度不随样本量变化学习率才能在不同数据规模下保持相对稳定。如果之后改成 mini-batch 梯度下降除以的是 batch size 而不是总样本数。3.3 参数更新与训练循环的完整组装有了前向传播和反向传播训练就是重复三件事前向算出预测值反向算出梯度按学习率更新参数。def train(X, y, input_size, hidden_size, output_size, lr0.5, epochs10000, print_every2000): 完整的训练循环全批量梯度下降 参数lr: 学习率默认0.5 参数epochs: 迭代轮数 参数print_every: 每多少轮打印一次损失 返回训练好的参数列表 params init_network(input_size, hidden_size, output_size) for epoch in range(epochs): # 前向传播拿预测值和缓存 a2, cache forward(X, params) # 反向传播拿梯度 grads backward(X, y, params, cache) # 参数更新原参数减去学习率乘以梯度 for i in range(len(params)): params[i] - lr * grads[i] # 周期性打印损失观察收敛情况 if epoch % print_every 0: loss np.mean((a2 - y) ** 2) print(fepoch {epoch}, loss {loss:.6f}) return params训练循环的参数说明lr0.5是我在这个数据集上调出来的经验值太大比如 2.0会导致损失震荡不下降太小比如 0.01收敛要一万轮以上。epochs10000看着多但全批量更新在 XOR 这种 4 个样本的数据集上一轮就是 4 次矩阵运算跑完不到一秒钟。print_every2000是为了观察收敛趋势训练结束如果损失还在下降说明轮数不够。把训练跑通只需要下面几行# XOR 数据集输入和标签 X np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtypefloat) y np.array([[0], [1], [1], [0]], dtypefloat) # 训练一个 2-4-1 结构的网络 params train(X, y, input_size2, hidden_size4, output_size1, lr0.5, epochs10000)跑出来的 loss 序列应该呈现稳定下降趋势最终停在 0.001 以下说明网络已经拟合了 XOR 的决策边界。如果 loss 卡在 0.25 附近不动大概率是权重初始化落进了对称区重新调整 seed 或者增加隐藏层神经元数量就能解决。4. 损失函数、学习率与收敛观测训练闭环的调参三板斧4.1 损失函数选择对收敛路径的影响MSE 和交叉熵是二分类任务最常用的两个损失函数它们的选择会直接改变输出层梯度公式。前面提到过交叉熵加 sigmoid 的梯度是a2 - yMSE 加 sigmoid 的梯度是(a2-y) * a2 * (1-a2)。区别看起来只是多乘了一项实际影响很大sigmoid 在输出接近 0 或 1 时导数趋近于 0MSE 的梯度会被这个导数压得极小导致训练后期收敛速度非常慢这就是梯度消失的一种早期形态。两相对比交叉熵的梯度只由预测值和真实值的差异决定差异大梯度就大天然没有这个问题。所以如果你手写网络做二分类我建议直接用交叉熵对应的梯度公式代码写起来反而更简洁。MSE 也不是一无是处回归任务里它是默认选择只是不要拿它配 sigmoid 输出做分类。4.2 学习率的玄学从 0.5 开始手动粗调学习率是这个领域里最像玄学的参数没有之一。我见过 Gradient Descent 在 lr0.3 时完美收敛改成 0.5 就直接炸成 NaN。它的作用机制很直白更新量 学习率 × 梯度。学习率太大参数一步跨过头越过最优点在两侧震荡学习率太小参数挪动如蜗牛epochs 翻倍还未必收敛。我的粗调套路是固定层数和初始化 seed从 lr0.1 开始跑观察 loss 变化如果 loss 前几百轮就在震荡、完全不降说明学习率偏大降到 0.05 或 0.01如果 loss 稳定下降但斜率很缓说明偏小升到 0.5 或 1.0。XOR 这个例子我最后落在 0.5是因为隐藏层只有 4 个神经元网络容量小可容纳更大的更新步长。换成更宽的网络或者真实数据集学习率往往要降到 0.01 量级。有个辅助技巧值得养成习惯打印每次更新的参数范数变化。参数更新量如果超过参数本身的百分之一那学习率大概率偏大如果不到百万分之一那就太小了。这个检查比肉眼看 loss 曲线更灵敏。4.3 训练闭环的收敛观测方法训练代码里我留了print_every2000的打印但真实项目中光看终端输出不够。我一般会做三件事一是把 loss 序列存成数组训练完画个曲线图看整体趋势和尾部波动二是直接打印最终预测值和真实标签逐行对比XOR 这种小数据集一眼就能看出有没有学对三是记录训练过程中预测值从模糊0.5 附近到清晰接近 0 或 1的变化节奏。# 训练结束后直接看预测值不要只看loss a2, _ forward(X, params) print(预测值) print(a2.reshape(-1)) print(真实值) print(y.reshape(-1)) # 期望输出预测值应该接近 [0, 1, 1, 0]每个值偏离不超过0.1这段输出的价值在于区分“过拟合”和“正确拟合”如果训练集上预测值清晰收敛到 0 和 1但测试集上表现差才是过拟合的问题。XOR 数据集没有划分训练测试的概念但这个习惯要保留到真实项目里。5. 全连接网络避坑维度、梯度消失与收敛失败的常见问题排查5.1 维度不匹配形状错误是新手最大拦路虎现象运行到np.dot(X, W1)报 ValueError提示 shapes not aligned或者在参数更新时出现广播错误。原因权重初始化形状和前向传播里矩阵乘法的期望形状不一致。常见的是把 W1 写成了 (input_size, hidden_size)却在初始化时用了 (hidden_size, input_size)或者偏置的形状和当前层神经元数对不上。解决在初始化函数和 forward 函数入口各加一个 shape 打印跑一次训练立刻能定位哪一层形状不对。我的检查顺序是先确认 X 的形状是 (m, input_size)再确认 W1 是 (input_size, hidden_size)z1 X·W1 b1 的结果是 (m, hidden_size)以此类推。另外反向传播里 dW2 的形状必须和 W2 一致a1.T和dz2点乘的顺序不能写反写成np.dot(dz2, a1.T)形状就完全错了。5.2 sigmoid 输出层训练后期几乎不动现象loss 前几千轮下降正常之后曲线变得非常平缓甚至停在 0.01 附近不再下降。原因sigmoid 函数在输入绝对值较大的区域导数趋近于 0当网络的线性输出 z2 落到这个饱和区梯度被压得极小MSE 损失下这个现象更严重。训练后期预测值接近 0 或 1 时恰好是 sigmoid 饱和区等于自己给自己踩了刹车。解决把输出层的激活函数去掉线性输出或者换成交叉熵损失的梯度公式。保留隐藏层 sigmoid 没问题输出层换线性后反向传播公式只需要改 dz2 的表达式其余代码全不动。XOR 数据集上这个改动能把收敛速度提升好几倍loss 能压到 0.0001 以下而不停滞。5.3 隐藏层神经元数量与过拟合的权衡现象隐藏层从 4 加到 32训练集 loss 下降飞快且趋近于 0但换一批数据就一塌糊涂。原因模型容量超出任务复杂度网络把训练样本的噪声和细节都背下来了这就是过拟合。XOR 本身只有 4 个样本隐藏层给到 32 个神经元完全可以把每个样本单独记住它不需要学到普遍的决策规则。解决对简单数据集隐藏层神经元数不要超过输入的 2 到 4 倍。XOR 用 2 到 6 个就够。真实数据集上先用小网络跑通再逐步加宽每加宽一次对比一次验证集损失验证集回升的那一刻就是容量过头的信号。5.4 训练结果不可复现忘记固定随机种子现象同样的代码跑两次loss 曲线和最终准确率不一样有时候收敛有时候不收敛。原因初始化权重用的正态分布采样是随机的不同的初始点对应不同的损失曲面路径可能落入局部最优甚至发散。解决在 init_network 里接收 seed 参数并传给np.random.default_rng(seed)需要对比实验时固定 seed 等于 42。这个习惯能帮你排除“随机性”这个干扰变量否则你会把网络结构的缺陷误当成运气不好。等模型设计定型后再放开 seed 跑多次实验统计指标分布。6. 预测封装与进阶方向让简单网络走向实用训练完成后一个能直接用的预测函数是最后的收尾工作。它和 forward 的区别是predict 只关心输出层的结果不需要缓存中间变量还要加一个阈值转换把连续值变成类别。def predict(X, params, threshold0.5): 用训练好的参数做预测返回0/1类别 参数X: (m, input_size) 参数threshold: 二分类阈值默认0.5 a2, _ forward(X, params) return (a2 threshold).astype(int) # 在XOR完整数据上预测 preds predict(X, params) print(preds.reshape(-1)) # 期望输出 [0 1 1 0]这个封装虽然简单但它把“训练”和“推理”两个阶段分开真实项目里这两个阶段的要求完全不同推理阶段更关注延迟和稳定性不会每次预测都重跑训练。把 predict 和 train 解耦后你可以把 params 存成文件下次直接加载推理不需要重新训练。进阶方向上我建议按这个顺序往下走先把 sigmoid 全部换成 ReLU 并观察收敛速度变化你会发现 ReLU 收敛更快但偶尔神经元会“死亡”输出恒为 0这是它的代价然后给 loss 加 L2 正则项在参数更新时多减一项lambda * param验证集上泛化能力会明显提升最后把全批量梯度下降改成 mini-batch 随机梯度下降这会让 loss 曲线带上抖动但真实数据集上训练效率会大幅提升。我的个人习惯是每改一个点就跑一次 XOR 验证正确性再上真实数据。这个手写网络或许跑不动大模型但作为理解深度学习底层机制的最小骨架它的价值远超任何框架的黑盒抽象。希望你顺着这套代码改出来的第一个网络能让你对梯度下降这四个字有真正的手感。希望帮到你。本文还有配套的精品资源点击获取