尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python手写BP神经网络:从零实现与避坑指南

Python手写BP神经网络:从零实现与避坑指南 简介这份PDF资源面向希望入门机器学习、理解神经网络底层原理的Python开发者与在校学生聚焦于用纯Python从零实现一个可运行的多层前馈BP神经网络帮助读者打通从公式推导到代码落地的完整链路。压缩包内仅含1个PDF文件体积约373KB轻量便携适合随时查阅与对照练习。内容围绕三层网络结构展开涵盖输入层、隐藏层与输出层的单元设计Sigmoid激励函数的定义与作用含正则化项的交叉熵代价函数推导以及反向传播中链式法则求梯度、逐层回传误差并更新权重的完整流程并配有代价函数与梯度计算的示例代码。目前已有6975人学习说明其在入门群体中具备一定参考价值。读者可借此掌握权重矩阵Theta1、Theta2的维度关系、偏置单元处理方式与正则化防过拟合思路并将其迁移到多类分类等实际任务中。1. 用 Python 从零搭一个 BP 神经网络为什么很多人第一步就翻车很多人第一次接触 BP 神经网络是在 Python 环境里抄了一段代码跑出来 loss 不降或者准确率卡在 0.5 不动然后开始怀疑人生。问题往往不在反向传播公式而在数据没归一化、学习率拍脑袋设成 1.0、权重初始化全填 0 这些细节上。BP 神经网络Back Propagation Neural Network本质就是「前向算输出、反向传梯度、按梯度更新权重」这三步循环结构图看着复杂落到代码里其实不到一百行。这篇笔记面向两类人一类是刚学完 Python 基础语法、想找个能跑通的实战项目练手的新手另一类是用过 sklearn 的 MLPClassifier但想自己手写一遍搞懂内部机制的从业者。我会从零实现一个可训练、可验证、可调参的 BP 网络把每个参数为什么这么设、改哪里会出什么问题讲清楚代码可以直接复制到 PyCharm 或 VS Code 里跑。不依赖任何深度学习框架只用 numpy这样你能看清每一步在算什么。2. BP 神经网络的结构与手写前向传播从输入到输出的每一步2.1 网络结构怎么定输入层、隐藏层、输出层的维度选择BP 神经网络的结构图里通常画三层输入层、一个或多个隐藏层、输出层。层与层之间是全连接每个连接上有一个权重每个神经元上有一个偏置。结构定下来之后参数量就定了。假设输入特征是 n 维隐藏层有 h 个神经元输出是 m 类那么第一层权重矩阵形状是 (n, h)第二层是 (h, m)偏置分别是 (h,) 和 (m,)。隐藏层神经元数量怎么选是新手最容易卡住的地方。常见做法是先用一个经验公式起步h sqrt(n m) a其中 a 取 1 到 10 之间的常数。但这个公式只是起点不是答案。我一般会先按这个值跑一遍看训练集和验证集的 loss 曲线如果训练 loss 降得很慢说明容量不够加神经元如果训练 loss 很低但验证 loss 开始上升说明过拟合减神经元或者加正则。隐藏层数量上绝大多数表格类任务一层就够了两层以上只在特征交互非常复杂时才有明显收益而且训练难度会上升。激活函数的选择也直接影响能不能训起来。隐藏层常用 ReLU 或 tanh输出层根据任务定二分类用 sigmoid多分类用 softmax回归用线性。ReLU 的好处是正区间梯度不衰减训练快但要注意神经元死亡问题tanh 输出零中心收敛稳定但深层会梯度消失。我一般隐藏层先用 ReLU如果发现 loss 震荡厉害再换 tanh 试试。2.2 前向传播的代码实现与矩阵维度检查前向传播就是把输入 X 一层层乘权重加偏置再过激活函数。下面是最小实现包含初始化、前向、激活函数和损失函数。import numpy as np def init_params(n_input, n_hidden, n_output): 初始化权重和偏置使用 He 初始化适配 ReLU np.random.seed(42) W1 np.random.randn(n_input, n_hidden) * np.sqrt(2.0 / n_input) b1 np.zeros((1, n_hidden)) W2 np.random.randn(n_hidden, n_output) * np.sqrt(2.0 / n_hidden) b2 np.zeros((1, n_output)) return W1, b1, W2, b2 def relu(z): return np.maximum(0, z) def relu_deriv(z): return (z 0).astype(float) def softmax(z): # 减去最大值防止指数溢出 z_shift z - np.max(z, axis1, keepdimsTrue) exp_z np.exp(z_shift) return exp_z / np.sum(exp_z, axis1, keepdimsTrue) def forward(X, W1, b1, W2, b2): 返回每层中间结果反向传播要用 Z1 X W1 b1 # (batch, n_hidden) A1 relu(Z1) # (batch, n_hidden) Z2 A1 W2 b2 # (batch, n_output) A2 softmax(Z2) # (batch, n_output) cache (X, Z1, A1, Z2, A2) return A2, cache def cross_entropy(y_pred, y_true): y_true 为 one-hot 编码 m y_true.shape[0] log_likelihood -np.log(y_pred[np.arange(m), y_true.argmax(axis1)] 1e-12) return np.sum(log_likelihood) / m这段代码有几个关键点。第一权重初始化用 He 初始化乘以 sqrt(2/n)这是为了保持前向传播时每层输出的方差稳定如果用全零初始化所有神经元梯度相同网络永远学不到东西。第二softmax 里减最大值是数值稳定技巧不做的话 exp 大数会溢出成 inf。第三forward 返回 cache因为反向传播需要 Z1、A1 这些中间值重新算一遍浪费计算。第四交叉熵里加 1e-12 防止 log(0)。维度检查是新手最容易忽略的一步。X 形状是 (batch, n_input)W1 是 (n_input, n_hidden)矩阵乘法后是 (batch, n_hidden)加上 b1 的 (1, n_hidden) 广播后形状不变。如果报错说维度不匹配先打印每一步的 shape九成是某一层权重转置写反了。3. 反向传播与参数更新梯度怎么算、学习率怎么调3.1 链式法则在代码里的落地从损失到每一层权重反向传播的核心是链式法则从损失函数对输出层的梯度开始一层层往前推。对于 softmax 交叉熵的组合输出层梯度有一个很简洁的形式dZ2 y_pred - y_true。这个结论省掉了手动求 softmax 导数的麻烦也是为什么分类任务偏爱这个组合。def backward(cache, W2, y_true): X, Z1, A1, Z2, A2 cache m X.shape[0] # 输出层梯度softmax 交叉熵的简化形式 dZ2 (A2 - y_true) / m # (batch, n_output) dW2 A1.T dZ2 # (n_hidden, n_output) db2 np.sum(dZ2, axis0, keepdimsTrue) # 隐藏层梯度 dA1 dZ2 W2.T # (batch, n_hidden) dZ1 dA1 * relu_deriv(Z1) # (batch, n_hidden) dW1 X.T dZ1 # (n_input, n_hidden) db1 np.sum(dZ1, axis0, keepdimsTrue) return dW1, db1, dW2, db2 def update_params(W1, b1, W2, b2, dW1, db1, dW2, db2, lr): W1 - lr * dW1 b1 - lr * db1 W2 - lr * dW2 b2 - lr * db2 return W1, b1, W2, b2dZ2 除以 m 是对 batch 求平均这样学习率不会随 batch size 变化而需要重新调。dW2 A1.T dZ2 这一步A1 是 (batch, n_hidden)dZ2 是 (batch, n_output)转置相乘后得到 (n_hidden, n_output)正好和 W2 形状一致。隐藏层的 dZ1 要乘 relu_deriv(Z1)这是链式法则里激活函数导数的位置如果这里忘了乘梯度就传不回去loss 会完全不降。3.2 学习率与 batch size 的配合三个必调参数学习率 lr 是最关键的超参数。设太大loss 震荡甚至发散设太小收敛慢到你以为代码写错了。我一般从 0.01 起步跑 100 个 epoch 看 loss 曲线如果前 10 个 epoch loss 几乎不动说明太小乘 10如果 loss 上下跳说明太大除以 10。Adam 这类自适应优化器对学习率没那么敏感但手写 SGD 时这个调参步骤省不掉。batch size 影响梯度估计的噪声。全量梯度下降batch 全部样本稳定但慢随机梯度下降batch 1快但震荡。常见做法是 32 或 64兼顾稳定和速度。注意 batch size 和学习率要配合调batch 翻倍时学习率通常也可以适当放大因为梯度估计更准了。epoch 数不是越多越好。判断标准是验证集 loss如果验证 loss 连续多个 epoch 不降反而升就该停了这就是早停。下面是把训练循环串起来的代码。def train(X, y_onehot, n_hidden16, lr0.01, epochs500, batch_size32): n_input X.shape[1] n_output y_onehot.shape[1] W1, b1, W2, b2 init_params(n_input, n_hidden, n_output) losses [] for epoch in range(epochs): # 每个 epoch 打乱数据 idx np.random.permutation(X.shape[0]) X_shuf, y_shuf X[idx], y_onehot[idx] for i in range(0, X.shape[0], batch_size): Xb X_shuf[i:ibatch_size] yb y_shuf[i:ibatch_size] A2, cache forward(Xb, W1, b1, W2, b2) dW1, db1, dW2, db2 backward(cache, W2, yb) W1, b1, W2, b2 update_params(W1, b1, W2, b2, dW1, db1, dW2, db2, lr) # 每个 epoch 记录全量 loss A2_full, _ forward(X, W1, b1, W2, b2) loss cross_entropy(A2_full, y_onehot) losses.append(loss) if epoch % 50 0: print(fepoch {epoch}, loss {loss:.4f}) return W1, b1, W2, b2, losses打乱数据这一步很重要如果按类别顺序喂进去每个 batch 的梯度方向会偏向某一类训练会不稳定。loss 记录用全量数据算这样曲线平滑方便判断趋势。4. 避坑与排查BP 神经网络训练不收敛的五个血泪经验4.1 现象loss 从头到尾不变准确率等于随机猜原因通常是权重初始化有问题或者梯度根本没传回去。全零初始化会让所有隐藏层神经元输出相同反向传播时梯度也相同更新后还是相同网络退化成线性模型。另一个可能是 relu_deriv 写错比如写成返回常数 1那隐藏层梯度就断了。解决确认权重用随机初始化He 或 Xavier 都行但绝不能全零。打印第一层权重的标准差应该在 0.1 到 1 之间。再检查 backward 里 dZ1 有没有乘激活函数导数可以在训练前用数值梯度校验一下对某个权重加一个极小量看 loss 变化和解析梯度对比误差应该在 1e-6 量级。4.2 现象loss 变成 nan 或者突然爆炸原因一般是学习率太大或者 softmax 没做数值稳定。学习率 1.0 配 ReLU 很容易在第一轮就把权重更新到发散。softmax 里不减最大值当 logits 到几百时 exp 直接溢出。解决学习率从 0.01 甚至 0.001 起步观察前几个 epoch 的 loss。softmax 实现里必须减最大值这是标准操作。如果已经出现 nan检查输入数据有没有异常大的值做归一化。4.3 现象训练 loss 降但验证 loss 不降准确率上不去这是过拟合的典型表现模型把训练集背下来了。隐藏层神经元太多、训练 epoch 太多、数据量太少都会导致。解决先减隐藏层神经元从 64 降到 16 试试。再加 L2 正则在 loss 里加 lambda * sum(W^2)反向传播时 dW 加上 lambda * W。还可以加 dropout训练时随机把一部分隐藏层输出置零。数据层面如果样本太少考虑数据增强或者换更简单的模型。4.4 现象训练速度极慢一个 epoch 要跑几分钟原因可能是用了 Python 循环逐样本更新没有用矩阵运算。BP 网络的手写实现必须向量化把 batch 当成一个矩阵整体算。另一个可能是数据没归一化特征量纲差异大导致梯度方向乱。解决确认 forward 和 backward 里全是矩阵乘法没有 for 循环遍历样本。数据做标准化均值 0 方差 1这一步对收敛速度影响很大。如果数据量确实大batch size 可以适当加大但注意学习率配合调整。4.5 现象换了数据集后准确率骤降原因通常是输出层激活函数和损失函数不匹配。多分类必须用 softmax 交叉熵二分类用 sigmoid 二元交叉熵回归用线性 均方误差。用错了组合梯度形式就不对训练会非常慢甚至不收敛。解决先确认任务类型再检查输出层和损失函数。多分类的标签要做 one-hot 编码如果标签是整数类别记得转换。输入特征维度也要对齐新数据集的列数和网络输入层不一致会直接报错。5. 从手写 BP 到能用的模型验证方法与调参技巧代码跑通只是第一步怎么确认它真的学到了东西而不是碰巧 loss 降了需要一套验证方法。我一般会把数据集按 7:1.5:1.5 切成训练集、验证集、测试集。训练集用来更新权重验证集用来调超参数和早停测试集只在最后跑一次用来报告真实性能。如果只有训练集和测试集调参时就会不自觉地过拟合测试集最后报告的数字虚高。梯度校验是手写网络必做的检查。原理很简单解析梯度是反向传播算出来的数值梯度是用 (loss(weps) - loss(w-eps)) / (2*eps) 近似出来的两者应该非常接近。下面是一个校验函数。def grad_check(X, y_onehot, W1, b1, W2, b2, eps1e-5): 抽查 W1 中几个元素的数值梯度和解析梯度 A2, cache forward(X, W1, b1, W2, b2) dW1, _, _, _ backward(cache, W2, y_onehot) it np.nditer(W1, flags[multi_index]) count 0 while not it.finished and count 5: i, j it.multi_index old W1[i, j] W1[i, j] old eps loss_plus cross_entropy(forward(X, W1, b1, W2, b2)[0], y_onehot) W1[i, j] old - eps loss_minus cross_entropy(forward(X, W1, b1, W2, b2)[0], y_onehot) W1[i, j] old num_grad (loss_plus - loss_minus) / (2 * eps) ana_grad dW1[i, j] rel_err abs(num_grad - ana_grad) / max(1e-8, abs(num_grad) abs(ana_grad)) print(fW1[{i},{j}] 数值梯度 {num_grad:.6f} 解析梯度 {ana_grad:.6f} 相对误差 {rel_err:.2e}) count 1 it.iternext()相对误差在 1e-7 量级说明反向传播写对了如果到 1e-2 以上说明某处梯度算错了。这个检查在调试新网络结构时特别有用比盯着 loss 曲线猜要快得多。调参上我习惯按这个顺序先定网络结构隐藏层数和神经元数再调学习率然后调 batch size 和 epoch最后加正则。每次只动一个参数记录验证集准确率。下面这张表是我在几个常见数据集上总结的起步参数可以直接拿来当初始值。参数小数据集1000 样本中等数据集1万左右备注隐藏层神经元8~1632~64从少往多试学习率0.010.001~0.01配 SGDbatch size16 或全量32~128小数据全量更稳epoch200~50050~200看验证 loss 早停L2 正则系数1e-41e-4~1e-3过拟合时加还有一个实用技巧是学习率衰减训练初期用大学习率快速下降后期用小学习率精细收敛。最简单的实现是每过 100 个 epoch 把学习率乘 0.5。这个改动通常能让最终准确率再涨一两个点代价只是几行代码。最后说一个我踩过的坑不要用测试集反复调参。我曾经在一个项目里测试集准确率从 0.82 调到 0.89以为模型变好了上线后真实数据只有 0.79。后来才意识到那 0.07 的提升全是我对着测试集调出来的。正确做法是测试集锁死所有调参看验证集最后一次才碰测试集。这个习惯看起来简单但能帮你省掉很多上线后的意外。希望帮到你。本文还有配套的精品资源点击获取
返回列表