简介:这份资源面向深度学习入门者与希望夯实神经网络底层原理的开发者,用纯Python与numpy从零实现一个简单的全连接神经网络(多层感知器),帮助读者摆脱对高层框架的依赖,真正理解前向传播、反向传播与权重更新的计算过程。压缩包共7个文件,全部为py脚本,整体约4KB,涵盖网络结构定义、公共数据处理、自研算法、预测与数据生成等模块,结构轻量,便于逐文件阅读与调试。资源围绕sigmoid激活函数、均方误差损失、链式法则求梯度等核心知识点展开,读者可据此搭建可训练、可预测的完整流程,并在此基础上尝试调整隐藏层规模、学习率与训练轮数,观察模型表现变化。目前已有4213人学习下载,适合作为课程实验、自学练手或进一步学习TensorFlow、PyTorch等框架前的过渡练习。
1. 从零手写一个全连接神经网络:一份能跑通的 Python 实现拆解
很多人第一次接触深度学习,都是从「调包」开始的——几行sklearn或torch代码就能跑出一个模型,但反向传播到底怎么算、梯度从哪来、权重怎么更新,脑子里始终是一团浆糊。这份「简单全连接神经网络 Python 实现」的资源,走的是另一条路:不依赖任何深度学习框架,只用numpy从零把前向传播、损失函数、反向传播、参数更新全部手写一遍。它解决的不是「训一个高精度模型」的问题,而是「让你真正看懂神经网络内部在干什么」的问题。适合刚学完机器学习基础、想动手验证公式的在校学生,也适合工作几年、一直调包但想补底层原理的工程师。下面我按自己拆包复现的顺序,把这份实现讲透。
2. 网络结构设计与前向传播:三层结构怎么定、矩阵维度怎么对
拿到一份从零实现的全连接网络,第一件事不是急着跑,而是先搞清楚它的网络结构是怎么定义的。这份实现采用的是最经典的「输入层 → 一个隐藏层 → 输出层」三层结构,隐藏层用 Sigmoid 或 ReLU 激活,输出层根据任务类型选择 Softmax(多分类)或恒等映射(回归)。结构虽然简单,但它是理解深层网络的地基——把三层的矩阵维度对齐了,堆到十层只是循环的问题。
2.1 权重初始化:为什么不能全零,以及 Xavier 的适用边界
权重初始化是很多人翻车的第一站。如果你把所有权重初始化为 0,那么同一层所有神经元的输出完全相同,反向传播时梯度也完全相同,网络永远学不到东西——这就是所谓的「对称性破缺失败」。这份实现里我一般会改成小随机数初始化,常见做法是用np.random.randn乘以一个缩放因子。
import numpy as np def init_params(n_input, n_hidden, n_output): # 使用缩放后的正态分布初始化,避免初始输出过大导致 Sigmoid 饱和 # 缩放因子 0.01 是经验值,输入维度大时可改用 sqrt(1/n_input) W1 = np.random.randn(n_input, n_hidden) * 0.01 b1 = np.zeros((1, n_hidden)) W2 = np.random.randn(n_hidden, n_output) * 0.01 b2 = np.zeros((1, n_output)) return W1, b1, W2, b2这里W1的形状是(n_input, n_hidden),b1是(1, n_hidden),偏置用零初始化是安全的,因为权重已经打破了对称性。缩放因子取 0.01 是个保守值,如果你的输入特征维度很大(比如上千维),0.01 会让第一层输出过小,梯度消失,这时候应该换成 Xavier 初始化,即缩放因子取sqrt(1/n_input)。反过来,如果隐藏层用 ReLU,Xavier 会偏小,更适合 He 初始化(sqrt(2/n_input))。这份实现默认 Sigmoid,所以 0.01 够用,但你要知道边界在哪。
2.2 前向传播:矩阵乘法与激活函数的串联
前向传播的本质就是「线性变换 + 非线性激活」的反复堆叠。输入数据X形状为(n_samples, n_input),经过第一层权重得到隐藏层预激活值Z1,再经激活函数得到A1,最后经第二层得到输出Z2。
def sigmoid(z): # 限制 z 的范围,防止 exp 溢出,这是数值稳定的常见做法 z = np.clip(z, -500, 500) return 1 / (1 + np.exp(-z)) def forward(X, W1, b1, W2, b2): # 第一层:线性 + 激活 Z1 = np.dot(X, W1) + b1 # 形状 (n_samples, n_hidden) A1 = sigmoid(Z1) # 第二层:线性输出,多分类时后面接 Softmax Z2 = np.dot(A1, W2) + b2 # 形状 (n_samples, n_output) return Z1, A1, Z2np.dot(X, W1)这一步要求X的列数等于W1的行数,也就是n_input必须对齐,这是最常见的维度报错来源。Z2是未经过 Softmax 的 logits,训练时交给损失函数处理,预测时再取argmax。把前向传播写成函数的好处是,反向传播时可以直接复用Z1、A1这些中间变量,不用重新算一遍。
3. 损失函数与反向传播:梯度推导和代码逐行对应
前向传播只是把数据流过去,真正让网络「学习」的是反向传播。这一章是整份实现的核心,也是最多人卡住的地方——公式看得懂,代码对不上。我按「损失 → 输出层梯度 → 隐藏层梯度 → 参数更新」的顺序,把推导和代码一一对应讲清楚。
3.1 交叉熵损失与 Softmax 的梯度合并
多分类任务用交叉熵损失,配合 Softmax 输出。单独看 Softmax 的导数很复杂,但和交叉熵合并后,对 logits 的梯度会化简成一个非常干净的形式:dZ2 = (Y_pred - Y_true) / n_samples。这个化简是反向传播里最漂亮的一步,也是为什么分类任务偏爱交叉熵的原因。
def softmax(z): # 减去每行最大值,防止 exp 溢出,这是标准数值稳定技巧 z = z - np.max(z, axis=1, keepdims=True) exp_z = np.exp(z) return exp_z / np.sum(exp_z, axis=1, keepdims=True) def compute_loss_and_grad_output(Z2, Y_true): n_samples = Z2.shape[0] Y_pred = softmax(Z2) # 交叉熵损失,加 1e-8 防止 log(0) loss = -np.sum(Y_true * np.log(Y_pred + 1e-8)) / n_samples # Softmax + 交叉熵合并后的梯度,形式极简 dZ2 = (Y_pred - Y_true) / n_samples return loss, dZ2Y_true是 one-hot 编码,形状和Y_pred一致。dZ2的形状是(n_samples, n_output),直接就是第二层线性输出的梯度。注意这里除以了n_samples,意味着损失是 batch 平均,梯度也是平均梯度,这样学习率就不用随 batch size 频繁调整。
3.2 隐藏层梯度:链式法则的代码落地
拿到dZ2后,往回推第二层权重梯度dW2、偏置梯度db2,再穿过激活函数得到dZ1,最后得到dW1、db1。链式法则在这里体现得淋漓尽致。
def backward(X, Z1, A1, W2, dZ2): # 第二层参数梯度 dW2 = np.dot(A1.T, dZ2) # (n_hidden, n_output) db2 = np.sum(dZ2, axis=0, keepdims=True) # 梯度回传到隐藏层 dA1 = np.dot(dZ2, W2.T) # (n_samples, n_hidden) # Sigmoid 导数:A1 * (1 - A1) dZ1 = dA1 * A1 * (1 - A1) # 第一层参数梯度 dW1 = np.dot(X.T, dZ1) # (n_input, n_hidden) db1 = np.sum(dZ1, axis=0, keepdims=True) return dW1, db1, dW2, db2A1.T的转置是为了让维度匹配:A1是(n_samples, n_hidden),dZ2是(n_samples, n_output),转置后相乘得到(n_hidden, n_output),正好和W2形状一致。db2用np.sum沿样本维度求和,因为偏置对每个样本都加了同一个值,梯度要累加。dZ1里的A1 * (1 - A1)是 Sigmoid 的导数,如果你把激活函数换成 ReLU,这里就要改成dA1 * (Z1 > 0),这是换激活函数时最容易漏改的地方。
3.3 参数更新与训练循环:学习率怎么定、batch 怎么切
参数更新就是最朴素的梯度下降:W = W - lr * dW。学习率lr是最关键的超参数,太大震荡不收敛,太小收敛慢。这份实现默认用全批量梯度下降,数据量小时没问题,数据量大了要改成 mini-batch。
def train(X, Y, n_hidden, epochs, lr): n_input = X.shape[1] n_output = Y.shape[1] W1, b1, W2, b2 = init_params(n_input, n_hidden, n_output) losses = [] for i in range(epochs): Z1, A1, Z2 = forward(X, W1, b1, W2, b2) loss, dZ2 = compute_loss_and_grad_output(Z2, Y) dW1, db1, dW2, db2 = backward(X, Z1, A1, W2, dZ2) # 梯度下降更新 W1 -= lr * dW1 b1 -= lr * db1 W2 -= lr * dW2 b2 -= lr * db2 losses.append(loss) if i % 100 == 0: print(f"epoch {i}, loss {loss:.4f}") return W1, b1, W2, b2, losses学习率我一般从 0.1 开始试,Sigmoid 网络用 0.1 到 0.5 都算常见,ReLU 网络可以更小。epochs设 1000 到 5000 看损失曲线,如果损失下降很慢就调大学习率,如果上下跳动就调小。n_hidden是隐藏层神经元数量,简单数据集 16 到 64 够用,太大会过拟合,太小欠拟合。训练循环里每 100 轮打印一次损失,方便观察收敛趋势。
4. 避坑与排查:维度报错、梯度消失、过拟合的实战记录
从零实现神经网络,报错信息往往不如框架友好,很多问题要靠自己排查。这一章记录几个我踩过的坑,按「现象 → 原因 → 解决」写清楚,你遇到类似问题时可以对照。
4.1 矩阵维度不匹配:最常见的 ValueError
现象:运行np.dot(X, W1)时报ValueError: shapes not aligned。原因:X的列数和W1的行数不一致,通常是数据预处理时特征维度变了,或者W1初始化时n_input传错。解决:在forward函数开头加一行assert X.shape[1] == W1.shape[0],让报错提前到维度检查阶段,而不是等到矩阵乘法才崩。同时检查数据加载时是否做了 reshape 或转置。
4.2 损失不下降:学习率与初始化的双重嫌疑
现象:训练几百轮后损失几乎不变,或者变成nan。原因:损失变nan通常是学习率太大导致梯度爆炸,或者log(0)没加保护;损失不变可能是学习率太小,或者权重初始化全零导致对称性没打破。解决:先检查np.log里有没有加1e-8,再把学习率降到 0.01 试。如果还不行,打印初始权重看是不是全零。另外 Sigmoid 在输入绝对值大时会饱和,梯度接近零,这时候换 ReLU 或调小初始化缩放因子。
4.3 训练集准确率高但测试集低:过拟合的识别与缓解
现象:训练损失持续下降,训练集准确率到 95% 以上,但测试集准确率只有 60% 左右。原因:隐藏层神经元太多、训练轮数太多,网络把训练数据的噪声也记住了。解决:先减小n_hidden,比如从 128 降到 32;再减少epochs,配合早停策略——每隔若干轮在验证集上评估,验证损失开始上升就停。如果数据量允许,加 L2 正则化,在损失里加0.5 * reg_lambda * (sum(W1**2) + sum(W2**2)),梯度里对应加reg_lambda * W。
4.4 激活函数换 ReLU 后梯度全零:死亡 ReLU 问题
现象:把 Sigmoid 换成 ReLU 后,部分神经元输出恒为 0,梯度也恒为 0,参数再也不更新。原因:ReLU 在输入为负时梯度为零,如果学习率太大,某些神经元权重被更新到一直输出负值,就「死亡」了。解决:降低学习率,或者改用 Leaky ReLU(负半轴给一个小斜率如 0.01)。反向传播里 ReLU 的导数要写成dA1 * (Z1 > 0),注意是Z1不是A1,写错会导致梯度计算完全错误。
4.5 数值溢出:exp 计算的隐藏炸弹
现象:Softmax 或 Sigmoid 计算时出现RuntimeWarning: overflow encountered in exp,随后损失变nan。原因:np.exp对大的正数会溢出,比如exp(1000)直接变成inf。解决:Softmax 里减去每行最大值(代码里已经做了),Sigmoid 里用np.clip限制输入范围。这两个操作不改变数学结果,但能保证数值稳定,是从零实现时必须养成的习惯。
5. 进阶技巧:从手写实现到框架对照的验证方法
手写实现跑通之后,最有价值的动作是拿它和成熟框架对照,验证自己的梯度算得对不对。我一般用「梯度数值检验」这个方法:对某个参数加一个极小扰动,用损失变化除以扰动,得到数值梯度,再和反向传播算出的解析梯度对比,相对误差小于1e-6就说明推导和代码都没问题。
def gradient_check(X, Y, W1, b1, W2, b2, epsilon=1e-6): # 以 W1 的第一个元素为例做数值梯度检验 Z1, A1, Z2 = forward(X, W1, b1, W2, b2) loss, dZ2 = compute_loss_and_grad_output(Z2, Y) dW1, _, _, _ = backward(X, Z1, A1, W2, dZ2) analytic_grad = dW1[0, 0] # 数值梯度:扰动 W1[0,0] W1_plus = W1.copy(); W1_plus[0, 0] += epsilon W1_minus = W1.copy(); W1_minus[0, 0] -= epsilon loss_plus = compute_loss_and_grad_output(forward(X, W1_plus, b1, W2, b2)[2], Y)[0] loss_minus = compute_loss_and_grad_output(forward(X, W1_minus, b1, W2, b2)[2], Y)[0] numeric_grad = (loss_plus - loss_minus) / (2 * epsilon) diff = abs(analytic_grad - numeric_grad) / max(abs(analytic_grad) + abs(numeric_grad), 1e-8) print(f"解析梯度 {analytic_grad:.8f}, 数值梯度 {numeric_grad:.8f}, 相对误差 {diff:.2e}")这个检验只对单个参数做,全量做太慢,但足以暴露链式法则里的符号错误或转置错误。相对误差在1e-7量级是正常的,如果到1e-3以上,基本可以确定反向传播某一步写错了。另一个进阶方向是加动量或 Adam 优化器,把W -= lr * dW换成带动量的更新,收敛速度会明显提升,但前提是基础梯度已经验证正确。
从那以后我每次手写反向传播,都强制先跑一遍梯度检验再开始训练,这个习惯帮我省下了大量对着损失曲线发呆的时间。希望这份拆解能帮你把这份实现真正跑起来、看明白。
本文还有配套的精品资源,点击获取