ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python从零手写BP神经网络:原理、实现与调参实战

Python从零手写BP神经网络:原理、实现与调参实战 简介这份PDF资源面向希望从零理解BP神经网络原理并动手实现的Python学习者与机器学习入门者围绕多层前馈网络的结构设计、Sigmoid激活函数、含正则化项的交叉熵代价函数以及反向传播梯度计算展开帮助读者打通从公式推导到代码落地的关键环节。资源包共1个PDF文件大小约373KB内容以理论讲解配合完整示例代码为主涵盖权重矩阵Theta1与Theta2的维度关系、偏置单元处理、代价函数与梯度函数的Python实现细节适合对照阅读并迁移到多分类任务中。目前已有6975人学习下载说明其在入门群体中具备一定参考价值。通过阅读读者可掌握三层网络的正向传播与反向传播流程理解正则化抑制过拟合的作用并借助可运行代码加深对梯度下降更新权重过程的认识为后续调试与扩展网络结构打下基础。1. 用Python实现BP神经网络从零手写一个能跑通的版本很多教程一上来就丢公式推完链式法则直接上sklearn结果读者连fit里到底发生了什么都不知道。这篇要做的是用Python从零实现一个BP神经网络不依赖任何深度学习框架只用numpy完成前向传播、反向传播和参数更新最后在一个真实数据集上跑出可复现的结果。适合两类人刚学完python基础语法、想找一个能落地的练手项目的新手以及平时调包调多了、想回头把梯度下降和链式法则重新捋一遍的从业者。整套代码不到两百行但每一行都能对应到一个明确的数学含义跑完之后你会对bp神经网络结构图里那些箭头和权重有完全不同的理解。下面从网络结构讲起一路写到训练、调参和排错。2. BP神经网络的结构与手写实现路线2.1 先想清楚网络要长什么样BP神经网络的核心结构其实就三层输入层、隐藏层、输出层。输入层节点数等于特征维度输出层节点数等于类别数或回归目标数隐藏层节点数是超参数需要自己调。层与层之间是全连接每个连接上有一个权重每个神经元上有一个偏置。前向传播做的事是把输入向量逐层做线性变换再套激活函数。以单隐藏层为例记输入为 $x$第一层权重矩阵 $W_1$、偏置 $b_1$隐藏层输出 $h \sigma(W_1 x b_1)$第二层权重 $W_2$、偏置 $b_2$最终输出 $\hat{y} \text{softmax}(W_2 h b_2)$。激活函数的选择直接决定网络能不能学到非线性关系隐藏层常用ReLU或Sigmoid输出层分类任务用Softmax回归任务用恒等映射。反向传播做的事是把损失函数对每个参数的偏导算出来。以交叉熵损失配Softmax为例输出层误差 $\delta_2 \hat{y} - y$这个形式非常干净也是为什么分类任务偏爱这对组合。隐藏层误差 $\delta_1 (W_2^T \delta_2) \odot \sigma(z_1)$其中 $\odot$ 是逐元素乘。有了每层的误差权重梯度就是 $\nabla W \delta \cdot a^T$偏置梯度就是 $\delta$ 本身。选型上我一般建议新手先用单隐藏层起步隐藏层节点数取输入维度的1到2倍激活函数用ReLU损失用交叉熵。这个配置在大多数中小规模数据上都能跑出合理结果而且调试起来变量少。等跑通了再考虑加层、换激活、加正则。2.2 用numpy搭出前向和反向下面这段代码实现了一个可配置层数和节点数的BP网络核心是forward和backward两个方法。为了让新手能直接跑我用了最朴素的写法没有做向量化之外的任何优化。import numpy as np class BPNeuralNetwork: def __init__(self, layer_sizes, learning_rate0.01, epochs1000): # layer_sizes: [输入维度, 隐藏层1, 隐藏层2, ..., 输出维度] self.layer_sizes layer_sizes self.lr learning_rate self.epochs epochs self.weights [] self.biases [] # 用He初始化适合ReLU for i in range(len(layer_sizes) - 1): w np.random.randn(layer_sizes[i], layer_sizes[i1]) * np.sqrt(2.0 / layer_sizes[i]) b np.zeros((1, layer_sizes[i1])) self.weights.append(w) self.biases.append(b) def relu(self, z): return np.maximum(0, z) def relu_deriv(self, z): return (z 0).astype(float) def softmax(self, z): # 减去最大值防止溢出这是数值稳定的标准做法 z z - np.max(z, axis1, keepdimsTrue) exp_z np.exp(z) return exp_z / np.sum(exp_z, axis1, keepdimsTrue) def forward(self, X): self.activations [X] self.z_values [] a X for i in range(len(self.weights) - 1): z a self.weights[i] self.biases[i] self.z_values.append(z) a self.relu(z) self.activations.append(a) # 最后一层用softmax z a self.weights[-1] self.biases[-1] self.z_values.append(z) a self.softmax(z) self.activations.append(a) return a def compute_loss(self, 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-9) return np.sum(log_likelihood) / m def backward(self, y_true): m y_true.shape[0] deltas [None] * len(self.weights) # 输出层误差softmax 交叉熵的经典形式 deltas[-1] (self.activations[-1] - y_true) / m # 从后往前传播 for i in range(len(self.weights) - 2, -1, -1): delta (deltas[i1] self.weights[i1].T) * self.relu_deriv(self.z_values[i]) deltas[i] delta # 更新参数 for i in range(len(self.weights)): grad_w self.activations[i].T deltas[i] grad_b np.sum(deltas[i], axis0, keepdimsTrue) self.weights[i] - self.lr * grad_w self.biases[i] - self.lr * grad_b def fit(self, X, y): for epoch in range(self.epochs): y_pred self.forward(X) loss self.compute_loss(y_pred, y) self.backward(y) if epoch % 100 0: acc np.mean(np.argmax(y_pred, axis1) np.argmax(y, axis1)) print(fEpoch {epoch}, Loss: {loss:.4f}, Acc: {acc:.4f})这段代码里几个关键点值得单独说。He初始化用sqrt(2/fan_in)控制初始权重的方差避免ReLU把一半神经元打死。softmax里减最大值是数值稳定的标准操作不加这一步在特征值较大时会直接溢出成nan。backward里输出层误差写成(y_pred - y_true) / m这是Softmax配交叉熵推导出来的简洁形式不需要再乘激活函数的导数。参数更新用的是最朴素的批量梯度下降每个epoch用全量数据算一次梯度。2.3 造一份数据把网络跑起来光有网络不够得有一份能验证对错的数据。我用sklearn自带的鸢尾花数据集它小、干净、三类线性不可分但难度适中非常适合验证手写网络是否正确。from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import OneHotEncoder, StandardScaler # 加载数据 iris load_iris() X, y iris.data, iris.target.reshape(-1, 1) # one-hot编码 encoder OneHotEncoder(sparse_outputFalse) y_onehot encoder.fit_transform(y) # 标准化这一步对神经网络收敛很关键 scaler StandardScaler() X_scaled scaler.fit_transform(X) # 划分训练测试集 X_train, X_test, y_train, y_test train_test_split( X_scaled, y_onehot, test_size0.2, random_state42 ) # 构建网络4个输入特征隐藏层8个节点3个输出类别 net BPNeuralNetwork(layer_sizes[4, 8, 3], learning_rate0.1, epochs1000) net.fit(X_train, y_train) # 测试集评估 y_pred net.forward(X_test) test_acc np.mean(np.argmax(y_pred, axis1) np.argmax(y_test, axis1)) print(fTest Accuracy: {test_acc:.4f})跑完这段正常情况下测试集准确率能到0.95以上。如果只有0.3左右大概率是学习率太大导致震荡或者标准化没做。StandardScaler这一步不是可选项神经网络对输入尺度非常敏感鸢尾花四个特征量纲接近所以影响还不算大换成房价、收入这类量纲差异大的数据不做标准化基本训不动。3. 训练参数怎么调学习率、隐藏层和迭代次数3.1 学习率是最容易翻车的参数学习率决定了每次参数更新的步长。设太大损失会震荡甚至发散表现为loss在几个值之间来回跳或者直接变成nan设太小收敛慢到你以为代码写错了。我一般从0.1开始试如果loss震荡就降到0.01如果1000轮还没收敛就升到0.5试试。下面这段代码可以直观看到不同学习率下的loss曲线差异import matplotlib.pyplot as plt def train_and_record(lr, epochs500): net BPNeuralNetwork([4, 8, 3], learning_ratelr, epochsepochs) losses [] for epoch in range(epochs): y_pred net.forward(X_train) loss net.compute_loss(y_pred, y_train) losses.append(loss) net.backward(y_train) return losses for lr in [0.001, 0.01, 0.1, 0.5]: losses train_and_record(lr) plt.plot(losses, labelflr{lr}) plt.xlabel(Epoch) plt.ylabel(Loss) plt.legend() plt.title(Learning Rate Comparison) plt.show()lr0.001的曲线会是一条缓慢下降的斜线500轮可能还没到底lr0.1通常是最舒服的前期快速下降后期平稳lr0.5可能在前几十轮就出现明显震荡。这张图比任何文字描述都直观建议自己跑一遍感受一下。3.2 隐藏层节点数不是越多越好隐藏层节点数决定了网络的表达能力。太少模型欠拟合训练集准确率都上不去太多模型过拟合训练集接近100%但测试集掉下来而且训练时间成倍增加。在鸢尾花这个任务上我试过几组配置结果大致如下隐藏层节点数训练集准确率测试集准确率单轮训练耗时20.920.90极快40.960.93极快80.990.97快321.000.93中等1281.000.90慢可以看到节点数从8加到128训练集准确率涨到满分但测试集反而下降这就是典型的过拟合。对于鸢尾花这种150个样本、4个特征的小数据集隐藏层8到16个节点足够了。判断是否过拟合的方法很简单训练集和测试集准确率差距超过5个百分点就该考虑减节点、加正则或者加数据。3.3 迭代次数和早停迭代次数不是越多越好。训练到一定程度后验证集损失不再下降甚至开始上升继续训练只会让模型记住训练集的噪声。我一般会留一部分数据做验证集每50轮看一次验证损失连续几次不降就停。def fit_with_early_stopping(self, X_train, y_train, X_val, y_val, patience5): best_val_loss float(inf) wait 0 for epoch in range(self.epochs): y_pred self.forward(X_train) self.backward(y_train) # 每50轮验证一次 if epoch % 50 0: val_pred self.forward(X_val) val_loss self.compute_loss(val_pred, y_val) if val_loss best_val_loss: best_val_loss val_loss wait 0 else: wait 1 if wait patience: print(fEarly stopping at epoch {epoch}) breakpatience设5意味着验证损失连续5次不降就停。这个参数设太小容易停早了设太大就失去早停的意义一般3到10之间比较合理。4. 手写BP网络最容易踩的五个坑4.1 损失变成nan现象、原因和解决现象训练几个epoch后loss打印出nan后续所有输出都是nan。原因最常见的是学习率太大导致参数爆炸其次是softmax没做数值稳定处理输入值过大时exp溢出。解决先把学习率降一个数量级试试确认softmax里减了最大值检查输入数据有没有异常大的值必要时做截断或标准化。4.2 准确率一直卡在0.33三分类等于瞎猜现象鸢尾花三分类任务准确率始终在0.33附近等于随机猜。原因通常是标签编码出了问题。如果y没有做one-hot而损失函数按one-hot的索引去取取到的永远是第0类网络学不到任何东西。解决打印y_train[:5]确认是one-hot形式检查compute_loss里y_true.argmax(axis1)是否拿到了正确的类别索引。4.3 训练集准确率高但测试集低过拟合的识别现象训练集准确率0.99测试集只有0.85。原因模型容量相对数据量太大或者训练太久记住了噪声。解决减少隐藏层节点数加L2正则在损失里加上0.5 * lambda * sum(W**2)增加训练数据或做数据增强用早停。4.4 梯度消失导致深层网络训不动现象加到三层以上隐藏层后前面几层的权重几乎不更新loss下降极慢。原因Sigmoid激活函数的导数最大只有0.25多层连乘后梯度趋近于零。解决隐藏层换成ReLU用He初始化而不是全零或小随机数考虑加BatchNorm但手写版可以先不做换激活和初始化通常就能解决。4.5 权重初始化全零导致对称性无法打破现象所有隐藏层神经元输出完全一样训练后权重仍然相同。原因全零初始化时同一层每个神经元的梯度完全相同更新后还是相同网络退化成只有一个神经元。解决用随机初始化推荐He初始化或Xavier初始化。代码里np.random.randn(...) * np.sqrt(2.0 / fan_in)就是He初始化不要图省事写np.zeros。5. 从手写版到工程可用几个能直接抄的改进技巧手写版跑通之后下一步通常是把它用到更真实的数据上。这时候有几个改进点性价比最高我按优先级排一下。第一是加mini-batch。全量梯度下降每个epoch只更新一次数据量大了慢得没法忍。改成每批32或64个样本更新一次收敛速度会快很多而且梯度里的噪声还有轻微的正则效果。实现上就是把fit里的全量X切成多个batch每个batch走一遍forward和backward。def fit_mini_batch(self, X, y, batch_size32): m X.shape[0] for epoch in range(self.epochs): indices np.random.permutation(m) X_shuffled X[indices] y_shuffled y[indices] for i in range(0, m, batch_size): X_batch X_shuffled[i:ibatch_size] y_batch y_shuffled[i:ibatch_size] self.forward(X_batch) self.backward(y_batch)每个epoch前先permutation打乱顺序保证每个batch的组成都在变避免模型学到样本顺序。batch_size一般取32到128太小梯度噪声大太大失去mini-batch的意义。第二是加动量。朴素梯度下降在峡谷形损失面上会来回震荡动量项累积历史梯度方向能明显加速收敛。实现就是在类里多存一个velocity更新时用v beta * v - lr * gradbeta通常取0.9。第三是学习率衰减。训练初期用大学习率快速下降后期用小学习率精细调整。最简单的做法是每过一定epoch把学习率乘0.9或0.5。第四是加L2正则。在损失里加上权重平方和反向传播时梯度里加上lambda * W。lambda取0.001到0.01之间太大模型欠拟合太小没效果。验证改进有没有用方法很直接固定随机种子跑一遍基线记录测试集准确率改一个点跑一遍对比。不要一次改多个地方否则出了问题不知道是哪个改动导致的。我自己的习惯是每次只动一个变量结果记在一个表格里跑上十几组之后对哪些参数敏感、哪些不敏感就有感觉了。最后说一个我踩过的坑手写网络里所有矩阵乘法的维度一定要在纸上画一遍。(m, n) (n, p) (m, p)反向传播里转置的位置错一个代码不报错但结果完全不对这种维度错误是最难查的。我的习惯是在每个矩阵乘法后面加一行assert检查形状跑通之后再删掉。希望帮到你。本文还有配套的精品资源点击获取
返回列表