1. 项目概述:从“黑盒”到“白盒”的认知之旅
前馈神经网络,这个名字听起来可能有点学术,但如果你用过任何一款带人脸识别的手机、体验过智能音箱的语音交互,或者被电商平台的“猜你喜欢”精准推荐过,那么你已经和它打过无数次交道了。它就像一个信息处理的“流水线”,数据从一端输入,经过层层加工,最终从另一端输出结果,整个过程单向流动,没有回头路。这个看似简单的结构,却是现代人工智能,特别是深度学习浪潮中最基础、最核心的基石。
我最初接触前馈神经网络时,也把它当作一个神秘的“黑盒”——输入数据,得到预测,至于中间发生了什么,总觉得是数学家在玩的复杂游戏。直到自己动手,一行代码一行代码地搭建,一个参数一个参数地调整,才真正体会到,将这个“黑盒”拆解、剖析、重建的过程,才是理解深度学习精髓的必经之路。本次实验,正是这样一次“白盒化”的实践。我们不仅仅是为了完成一个分类或回归任务,更是要亲手揭开神经网络的面纱,理解每一个神经元如何被激活,每一层权重如何被更新,误差如何从输出层反向传播并指导模型自我进化。
这个实验适合所有希望从“调包侠”进阶为“造轮子者”的朋友。无论你是刚学完线性代数和微积分,对反向传播充满好奇的学生,还是已经会用TensorFlow或PyTorch完成项目,但想夯实底层知识的工程师,通过这次从零搭建的过程,你将对神经网络的运作机制获得前所未有的、具象化的掌控感。我们将从最基础的矩阵运算开始,逐步构建一个完整的、可训练的多层感知机,并用手写数字识别这个经典任务来验证我们的成果。你会发现,那些框架里封装好的model.fit()背后,藏着如此多精妙的设计与“坑”,而跨越这些“坑”的经验,正是本实验要分享的核心价值。
2. 实验核心思路与设计哲学
2.1 为什么选择“从零实现”?
在Keras或PyTorch只需几行代码就能定义一个网络的今天,坚持从零开始用NumPy实现前馈神经网络,似乎是一种“复古”的行为。但这恰恰是本实验设计的精髓所在。框架的强大在于其抽象和自动化,它让我们能快速验证想法,但也容易让我们与底层细节失联。例如,当你面对梯度爆炸、神经元死亡、训练震荡等问题时,如果只停留在调用optimizer.step()的层面,调试将变得异常困难。
从零实现迫使我们去思考并亲手处理以下核心问题:
- 前向传播的实质:它不仅仅是层的堆叠,更是输入数据与权重矩阵的连续线性变换,以及非线性激活函数的逐元素作用。我们需要清晰地计算每一层的净输入和激活值。
- 反向传播的奥秘:这是神经网络学习的引擎。我们需要精确地推导出损失函数对每一层权重和偏置的梯度公式,并用代码实现它。这个过程会让你真正理解链式法则如何在计算图中流动。
- 参数初始化的艺术:为什么不能全初始化为0?Xavier和He初始化方法背后的数学直觉是什么?不同的初始化对训练动态有何深远影响?
- 优化器的选择:最基础的随机梯度下降(SGD)有哪些缺陷?带动量的SGD是如何帮助逃离局部极小值和鞍点的?学习率衰减策略又该如何设计?
通过亲手编码解决这些问题,你获得的不是“如何调用一个API”,而是“这个API为什么这样设计”的深刻洞察。这种洞察力是解决未来更复杂模型问题的钥匙。
2.2 实验整体架构设计
我们的实验将围绕一个经典的三层神经网络(输入层、隐藏层、输出层)展开,并遵循“构建-训练-分析”的闭环。整个系统的设计框图虽不复杂,但每个模块都需精心实现。
网络结构设计:
- 输入层:维度由数据决定,例如对于展平后的28x28手写数字图像,输入维度为784。
- 隐藏层:我们设计一个包含
n_hidden个神经元的全连接层。这里的关键决策是激活函数的选择。我们将实现并对比Sigmoid、Tanh和ReLU家族(ReLU, LeakyReLU)。每种激活函数都有其独特的梯度特性,直接影响训练的难易和速度。 - 输出层:对于十分类任务(如MNIST),输出层通常有10个神经元。这里需要根据任务类型选择输出激活函数和损失函数。对于多分类,标准搭配是
Softmax激活函数 +交叉熵损失。Softmax将神经元的原始输出(logits)转化为概率分布,而交叉熵则衡量预测概率分布与真实标签(one-hot编码)之间的差异。
训练流程设计:
- 前向传播:完成从输入到输出的计算,并缓存每一层的中间结果(激活值、净输入值),为反向传播做准备。
- 计算损失:使用损失函数评估预测值与真实值的差距。
- 反向传播:利用链式法则,从输出层开始,逐层计算损失对权重和偏置的梯度。这是整个实验的数学核心。
- 参数更新:使用优化器(如SGD with Momentum)根据计算出的梯度更新网络参数。
- 迭代循环:在多个epoch上重复上述过程,并在验证集上监控性能,防止过拟合。
注意:在设计之初就要考虑代码的模块化和可扩展性。例如,将层(Layer)、激活函数(Activation)、损失函数(Loss)设计为独立的类。这样,未来想要增加批归一化层(BatchNorm)或更换优化器(Adam)时,只需添加新的模块,而无需重写整个训练循环。
3. 核心模块的深度解析与实现要点
3.1 激活函数:网络非线性的源泉
没有激活函数,无论堆叠多少层,神经网络都只能表示线性变换,能力将大打折扣。激活函数引入了非线性,使得网络能够逼近任意复杂的函数。我们的实验将实现并对比几种经典激活函数。
Sigmoid (σ):σ(z) = 1 / (1 + e^{-z})
- 特点:将输入压缩到(0,1)区间,输出可视为概率。历史上曾非常流行。
- 致命缺点:梯度饱和。当输入z的绝对值很大时,其导数
σ'(z) = σ(z)(1-σ(z))会趋近于0。在反向传播时,梯度会以连乘方式迅速消失,导致深层网络的权重几乎无法更新,这就是著名的“梯度消失”问题。因此,在深度网络隐藏层中已基本被淘汰。 - 实现要点:计算时,可直接用公式计算函数值和导数值。为提高数值稳定性,可对输入z进行裁剪,防止
exp(-z)溢出。
Tanh:tanh(z) = (e^z - e^{-z}) / (e^z + e^{-z})
- 特点:输出范围(-1, 1),是零中心化的。其梯度形状与Sigmoid类似,但最大梯度为1(Sigmoid最大为0.25),因此在实践中通常比Sigmoid表现更好。
- 缺点:同样存在梯度饱和问题,只是比Sigmoid稍好。
ReLU (Rectified Linear Unit):ReLU(z) = max(0, z)
- 特点:计算极其简单,在正区间梯度恒为1,彻底解决了梯度消失问题(在正区间),极大地加速了深度网络的训练。
- 缺点:“死亡ReLU”问题。当输入为负时,梯度为0,对应的神经元将永远无法被激活,权重也不再更新。部分神经元“死亡”会导致网络容量下降。
- 实现要点:使用
np.maximum(0, z)实现。反向传播时,构建一个掩码mask = (z > 0),梯度只流向激活的神经元。
Leaky ReLU:LeakyReLU(z) = max(αz, z), 其中α是一个小的正数,如0.01。
- 特点:针对ReLU的改进,在负区间给予一个很小的斜率α,使得负输入也有微小的梯度,缓解了“神经元死亡”问题。
- 实现要点:实现时,
np.where(z > 0, z, alpha * z)。参数α通常设为超参数,但0.01是常用默认值。
实操心得:在本次实验中,强烈建议在隐藏层使用ReLU或其变种。你会发现,相比于Sigmoid,使用ReLU的网络训练速度会有数量级的提升。这是深度学习发展史上一个关键的实践认知。你可以通过绘制训练损失曲线,直观地对比不同激活函数下损失下降的速度,这个对比实验会给你留下深刻印象。
3.2 损失函数与输出层:学习目标的定义
损失函数是衡量模型预测好坏的标尺,它定义了我们的学习目标。输出层的设计必须与损失函数相匹配。
多分类交叉熵损失 (Categorical Cross-Entropy)这是多分类任务的标准选择。假设我们有C个类别,对于单个样本,真实标签是one-hot向量y(如[0,0,1,0]),模型预测的概率分布为ŷ(由Softmax输出)。
- 损失公式:
L = - Σ_{i=1}^{C} y_i * log(ŷ_i) - 直观理解:它只关心正确类别的预测概率。如果模型对正确类别的预测概率ŷ_c越接近1,log(ŷ_c)越接近0,损失就越小;如果预测概率很低,log(ŷ_c)会是一个很大的负数,取负后就是很大的正损失,惩罚很重。
- 与Softmax的协同:Softmax函数
ŷ_i = e^{z_i} / Σ_{j=1}^{C} e^{z_j}将输出层的原始分数z(logits)转化为概率。一个非常关键且实用的技巧是:将Softmax和交叉熵损失合并计算。在反向传播求梯度时,单独计算Softmax的梯度再与交叉熵的梯度链式相乘,得到的公式非常简洁:∂L/∂z = ŷ - y。这意味着梯度直接就是预测概率与真实标签的差值,计算高效且数值稳定。 - 实现要点:在代码中,我们通常会实现一个
SoftmaxWithCrossEntropy层。前向传播时计算损失值,并缓存Softmax输出ŷ;反向传播时直接返回梯度ŷ - y。
注意事项:计算log(ŷ_i)时,需要防止ŷ_i为0导致数值错误(
log(0)为负无穷)。通常的做法是在计算Softmax后,对概率向量ŷ施加一个微小的裁剪,如ŷ = np.clip(ŷ, 1e-12, 1.0),或者更优雅地,在计算log时使用np.log(ŷ + epsilon)。
3.3 参数初始化:训练成功的起跑线
错误的初始化足以让一个优秀的网络架构无法被训练。我们不能再像对待线性回归一样简单地将权重初始化为0,因为那会导致同一层所有神经元的梯度完全相同,失去不对称性,网络将无法学习到有用的特征。
Xavier初始化 (Glorot初始化)这是为配合Sigmoid/Tanh等饱和型激活函数设计的。其核心思想是:保持每一层激活值的方差和梯度的方差在网络中向前向后传播时大致稳定。
- 公式:对于一层有
n_in个输入和n_out个输出的全连接层,权重W从以下分布中采样:- 均匀分布:
W ~ U[-sqrt(6/(n_in + n_out)), sqrt(6/(n_in + n_out))] - 正态分布:
W ~ N(0, sqrt(2/(n_in + n_out)))
- 均匀分布:
- 原理:方差缩放因子
sqrt(2/(n_in + n_out))确保了信号在前向和反向传播中既不会爆炸也不会消失。
He初始化这是为ReLU及其变种设计的。由于ReLU会将一半的激活值置零,其输出的方差会比线性激活减少约一半。因此,He初始化放大了初始化时的方差以作补偿。
- 公式:
W ~ N(0, sqrt(2/n_in))或均匀分布版本。 - 实践:对于使用ReLU的网络,He初始化是默认的最佳选择。在我们的实验中,对隐藏层使用He初始化,你会观察到网络在初始几个epoch的收敛速度明显快于使用Xavier初始化。
偏置初始化:通常简单地将偏置项b初始化为0即可,这不会引起对称性问题。
实现示例:
def he_init(fan_in): """为ReLU激活的层进行He初始化""" std = np.sqrt(2.0 / fan_in) return np.random.randn(fan_in, fan_out) * std def xavier_init(fan_in, fan_out): """为Sigmoid/Tanh激活的层进行Xavier初始化""" limit = np.sqrt(6.0 / (fan_in + fan_out)) return np.random.uniform(-limit, limit, (fan_in, fan_out))4. 前向与反向传播的完整实现与推导
4.1 前向传播的矩阵化实现
前向传播是推理过程。假设我们有一个三层网络(不计输入层):X -> [Linear1 -> ReLU] -> [Linear2 -> Softmax] -> Loss。
设:
X: 输入矩阵,形状为(batch_size, input_dim)W1,b1: 第一层权重和偏置,形状分别为(input_dim, hidden_dim)和(hidden_dim,)Z1: 第一层净输入,Z1 = X @ W1 + b1(@表示矩阵乘法,广播机制处理偏置)A1: 第一层激活输出,A1 = ReLU(Z1)W2,b2: 第二层(输出层)权重和偏置,形状为(hidden_dim, output_dim)和(output_dim,)Z2: 输出层净输入,Z2 = A1 @ W2 + b2A2: 输出层激活(Softmax),A2 = softmax(Z2)L: 损失值,L = cross_entropy_loss(A2, Y),Y是真实标签的one-hot编码。
在代码实现中,我们需要缓存每一层的Z和A,因为它们在反向传播中会被用到。
def forward(self, X): # 第一层 self.Z1 = np.dot(X, self.W1) + self.b1 self.A1 = self.relu(self.Z1) # 第二层(输出层) self.Z2 = np.dot(self.A1, self.W2) + self.b2 # Softmax前向传播,返回概率和损失 self.A2, self.loss = self.softmax_cross_entropy_forward(self.Z2, self.Y) return self.A24.2 反向传播的梯度推导与代码实现
反向传播是学习的核心。我们的目标是计算损失L对所有权重和偏置的梯度:∂L/∂W2,∂L/∂b2,∂L/∂W1,∂L/∂b1。我们使用链式法则从后往前计算。
步骤1: 输出层梯度 (Softmax + CrossEntropy)如前所述,对于Softmax+交叉熵组合,损失L对输出层净输入Z2的梯度异常简洁:dZ2 = A2 - Y, 形状为(batch_size, output_dim)。 由此可得:dW2 = (A1.T) @ dZ2 / batch_size(除以batch_size是求平均梯度,对应损失的平均值)db2 = np.sum(dZ2, axis=0) / batch_size
步骤2: 隐藏层梯度现在我们需要计算dA1,即损失对第一层激活输出A1的梯度。dA1 = dZ2 @ (W2.T), 形状为(batch_size, hidden_dim)。 接下来,需要计算损失对第一层净输入Z1的梯度。由于A1 = ReLU(Z1),所以dZ1 = dA1 * ReLU'(Z1)。ReLU的导数在输入>0时为1,否则为0。dZ1 = dA1 * (Z1 > 0)(这里(Z1 > 0)是一个布尔掩码,在Python中可视为0和1) 最后,计算第一层参数的梯度:dW1 = (X.T) @ dZ1 / batch_sizedb1 = np.sum(dZ1, axis=0) / batch_size
代码实现:
def backward(self, X, Y): batch_size = X.shape[0] # 输出层梯度 dZ2 = self.A2 - Y # Softmax+CrossEntropy的联合梯度 self.dW2 = np.dot(self.A1.T, dZ2) / batch_size self.db2 = np.sum(dZ2, axis=0) / batch_size # 隐藏层梯度 dA1 = np.dot(dZ2, self.W2.T) dZ1 = dA1 * (self.Z1 > 0) # ReLU的导数 self.dW1 = np.dot(X.T, dZ1) / batch_size self.db1 = np.sum(dZ1, axis=0) / batch_size实操心得:反向传播的推导和实现是本次实验的“硬骨头”。一个非常有效的调试方法是梯度检查。使用数值梯度(通过微小的扰动计算损失的变化)来验证我们解析计算出的梯度(反向传播得到的梯度)是否正确。虽然计算很慢,不能用于训练,但在开发阶段是确保代码正确的“金标准”。如果两者差异在很小的误差范围内(如1e-7),则证明你的反向传播实现是正确的。
5. 优化器与训练技巧:让网络真正学会
5.1 从SGD到带动量的SGD
有了梯度,我们如何更新参数?最朴素的方法是随机梯度下降:W = W - learning_rate * dW但朴素的SGD存在明显问题:在损失函数曲面存在“峡谷”状地形时(一个方向陡峭,另一个方向平缓),更新路径会剧烈震荡,收敛缓慢。
带动量的SGD引入了物理学中动量的概念,它积累了之前梯度的指数衰减移动平均,用来加速当前梯度方向,并抑制震荡。
- 更新公式:
v = beta * v - learning_rate * dW(计算动量)W = W + v(更新参数) - 超参数beta:通常取0.9或0.99,决定了保留多少历史梯度信息。动量项
v就像一个下坡的小球,有了惯性,在稳定方向加速,在震荡方向抵消。 - 效果:在实践中,带动量的SGD几乎总是优于朴素的SGD,收敛更快,更稳定。
5.2 学习率调度:动态调整步伐
学习率是训练中最重要的超参数之一。固定学习率可能面临问题:初期太大导致震荡或不收敛,后期太小导致收敛过慢。学习率衰减是一种简单有效的策略:
- 指数衰减:
lr = initial_lr * (decay_rate ^ (epoch / decay_steps)) - 阶梯衰减:每经过固定的epoch数,将学习率乘以一个衰减因子(如0.1)。
- 余弦退火:学习率随epoch变化遵循余弦函数的一半周期,从初始值缓慢下降到0。
在我们的实验中,可以从固定学习率开始(如0.01),观察训练后期损失是否停滞。如果停滞,尝试加入简单的阶梯衰减,例如每20个epoch将学习率减半,往往能带来惊喜。
5.3 批归一化(Batch Normalization)的引入
虽然本次基础实验可能不强制实现,但批归一化(BN)是一个革命性的技巧,值得深入理解。它在每个小批量数据进入激活函数前,对其进行归一化(减均值,除以标准差),并引入可学习的缩放和平移参数。
- 作用:
- 加速训练:BN减少了内部协变量偏移,允许使用更大的学习率。
- 缓解梯度问题:通过将激活值稳定在合适的范围,减轻了梯度消失/爆炸问题。
- 轻微的正则化效果:由于每个批次的统计量不同,引入了噪声,类似于Dropout。
- 实现位置:通常放在
线性变换 -> BN -> 激活函数这个顺序中。 - 对初始化依赖降低:使用了BN的网络,对权重初始化的尺度不那么敏感。
6. 实验全流程实操与核心参数记录
6.1 数据准备与预处理
我们使用经典的MNIST手写数字数据集。它包含60000张训练图和10000张测试图,每张图是28x28的灰度图。
- 加载数据:使用
keras.datasets.mnist.load_data()或类似库直接获取。 - 数据展平:将每张28x28的图片拉平成长度为784的向量。
X_train = X_train.reshape(-1, 28*28)。 - 归一化:将像素值从[0, 255]缩放到[0, 1]或[-1, 1]。简单除以255.0是最常用的方法。
X_train = X_train / 255.0。这一步至关重要,能帮助梯度下降更平稳地工作。 - 标签One-hot编码:将数字标签0-9转化为10维的one-hot向量。
Y_train = np.eye(10)[y_train]。
6.2 网络超参数设置与训练循环
以下是一组可以工作的初始超参数,你需要根据实验情况进行调整:
# 网络结构 input_dim = 784 hidden_dim = 128 # 可以尝试64, 256等 output_dim = 10 # 训练参数 learning_rate = 0.01 epochs = 50 batch_size = 64 beta = 0.9 # 动量项系数 # 初始化参数 W1 = he_init(input_dim, hidden_dim) # 隐藏层用He初始化 b1 = np.zeros(hidden_dim) W2 = xavier_init(hidden_dim, output_dim) # 输出层用Xavier初始化(接Softmax) b2 = np.zeros(output_dim) # 初始化动量 v_W1, v_b1, v_W2, v_b2 = 0, 0, 0, 0 # 训练循环 for epoch in range(epochs): # 打乱训练数据 permutation = np.random.permutation(len(X_train)) X_shuffled = X_train[permutation] Y_shuffled = Y_train[permutation] epoch_loss = 0 num_batches = 0 for i in range(0, len(X_train), batch_size): X_batch = X_shuffled[i:i+batch_size] Y_batch = Y_shuffled[i:i+batch_size] # 前向传播 A2, loss = forward_pass(X_batch, Y_batch, W1, b1, W2, b2) epoch_loss += loss # 反向传播 dW1, db1, dW2, db2 = backward_pass(X_batch, Y_batch, ...) # 传入缓存的值 # 带动量的SGD更新 v_W1 = beta * v_W1 - learning_rate * dW1 v_b1 = beta * v_b1 - learning_rate * db1 v_W2 = beta * v_W2 - learning_rate * dW2 v_b2 = beta * v_b2 - learning_rate * db2 W1 += v_W1 b1 += v_b1 W2 += v_W2 b2 += v_b2 num_batches += 1 avg_loss = epoch_loss / num_batches # 在每个epoch结束后,在验证集上计算准确率 val_acc = evaluate(X_val, y_val, W1, b1, W2, b2) print(f"Epoch {epoch+1}/{epochs}, Loss: {avg_loss:.4f}, Val Acc: {val_acc:.4f}")6.3 模型评估与可视化分析
训练过程中,除了记录损失,更重要的是监控在验证集上的准确率,这是判断模型是否过拟合、是否需要早停的关键。
- 评估函数:前向传播得到预测概率
A2,取argmax得到预测类别,与真实标签比较计算准确率。 - 可视化:
- 损失/准确率曲线:绘制训练损失和验证准确率随epoch变化的曲线。理想的曲线是训练损失平稳下降,验证准确率稳步上升后趋于平缓。如果验证准确率开始下降而训练损失继续下降,则是过拟合的典型信号。
- 权重可视化:将第一层权重
W1(形状为784x128)的每一列(128个隐藏神经元)reshape回28x28的图像并显示。你会发现这些“神经元”学习到了类似边缘、笔画等基础视觉特征,这是理解神经网络如何工作的一个直观窗口。 - 混淆矩阵:在测试集上生成混淆矩阵,可以清晰看到模型容易混淆哪些数字(如“4”和“9”、“3”和“8”),为后续改进提供方向。
7. 常见问题、调试技巧与性能优化实录
7.1 训练过程问题诊断
在从零实现的过程中,你几乎一定会遇到下面这些问题。以下是诊断清单:
| 问题现象 | 可能原因 | 排查与解决方法 |
|---|---|---|
| 损失值为NaN | 1. 学习率过大,导致梯度更新爆炸。 2. 计算Softmax或log时出现数值溢出(如 exp(z)过大)。3. 数据未归一化,输入值过大。 | 1.立即将学习率调小一个数量级(如从0.1调到0.01)。 2. 实现Softmax时使用数值稳定版本: z_shifted = z - np.max(z, axis=1, keepdims=True),再计算exp(z_shifted)。这是关键技巧!3. 检查输入数据,确保已归一化到合理范围(如[0,1])。 |
| 损失几乎不下降 | 1. 学习率太小。 2. 梯度计算有误(反向传播bug)。 3. 权重初始化不当(如全零初始化)。 4. 激活函数饱和(如深层网络用了Sigmoid)。 | 1. 尝试增大学习率。 2.进行梯度检查,这是定位反向传播bug的最可靠方法。 3. 检查是否使用了正确的初始化方法(如ReLU用He初始化)。 4. 将激活函数换成ReLU。 |
| 训练损失下降,但验证准确率不升或下降 | 过拟合。模型记住了训练数据的噪声,而非一般规律。 | 1.获取更多数据(或使用数据增强)。 2.降低模型复杂度(减少隐藏层神经元数)。 3.引入正则化:L2正则化(权重衰减)、Dropout。 4.早停:当验证集性能不再提升时停止训练。 |
| 训练初期损失震荡剧烈 | 1. 学习率太大。 2. 批量大小太小,梯度估计噪声大。 | 1. 降低学习率。 2. 适当增大 batch_size(如从32增至64或128)。 |
7.2 梯度检查的实现
梯度检查是确保你反向传播代码正确的“安全网”。虽然慢,但实现一次,受益无穷。
def gradient_check(X_batch, Y_batch, W, b, grad_W, grad_b, epsilon=1e-7): """ 数值梯度检查 W, b: 待检查的参数 grad_W, grad_b: 反向传播计算出的梯度 """ param_list = [(W, grad_W, 'W'), (b, grad_b, 'b')] for param, grad, name in param_list: it = np.nditer(param, flags=['multi_index'], op_flags=['readwrite']) while not it.finished: idx = it.multi_index original_val = param[idx].copy() # 计算 f(θ + ε) param[idx] = original_val + epsilon loss_plus = forward_pass(X_batch, Y_batch)[1] # 假设forward_pass返回损失 # 计算 f(θ - ε) param[idx] = original_val - epsilon loss_minus = forward_pass(X_batch, Y_batch)[1] # 数值梯度 grad_numerical = (loss_plus - loss_minus) / (2 * epsilon) # 解析梯度 grad_analytic = grad[idx] # 计算相对误差 numerator = np.abs(grad_numerical - grad_analytic) denominator = np.abs(grad_numerical) + np.abs(grad_analytic) relative_error = numerator / denominator if denominator > 1e-12 else 0 if relative_error > 1e-5: print(f"Gradient check FAILED at {name}{idx}. Analytic: {grad_analytic}, Numerical: {grad_numerical}, Rel Error: {relative_error}") return False # 恢复参数值 param[idx] = original_val it.iternext() print("Gradient check passed!") return True7.3 性能优化与扩展思考
当你的基础网络能正确运行后,可以尝试以下挑战,进一步提升模型性能和你的理解:
- 增加网络深度:尝试搭建一个4层或5层的网络(如784->256->128->64->10)。观察:
- 是否需要调整初始化方法?
- 梯度消失/爆炸问题是否出现?如何缓解(如使用ReLU、BN、残差连接的思想)?
- 实现Dropout:在前向传播时,以概率p随机将一部分神经元的激活值置零;在反向传播时,这些神经元的梯度也为零。在测试时,所有神经元都参与,但权重需要乘以(1-p)以保持期望一致。Dropout是防止过拟合的强有力工具。
- 实现L2正则化:在损失函数中加入所有权重平方和的惩罚项
(lambda/2) * ||W||^2。这会使优化器倾向于选择更小的权重,从而简化模型,防止过拟合。反向传播时,梯度需额外加上lambda * W。 - 尝试不同的优化器:实现Adam优化器。它结合了动量(一阶矩估计)和自适应学习率(二阶矩估计),在实践中通常比SGD with Momentum收敛更快、更鲁棒。理解其更新公式背后的思想是进阶的重要一步。
通过这个从零开始的实验,你收获的不仅仅是一个能在MNIST上达到98%+准确率的模型,更是一套完整的、可迁移的关于神经网络内部运作机制的知识体系。下次当你使用高级框架时,你会清楚地知道每一行代码背后发生了什么,面对训练中的异常也能有的放矢地进行调试。这才是“造轮子”的真正价值——不是为了重复发明,而是为了透彻理解。