从零开始的神经网络学习 (二):实用技巧与进阶
在上一篇文章中,我们从零开始构建了一个简单的神经网络,并理解了前向传播、反向传播和梯度下降等核心概念。然而,要让神经网络在现实世界的问题中高效工作,我们还需要掌握更多的工具和技巧。
这篇文章将作为第二部分,专注于第一部分中未能详尽涵盖的几个关键领域:
- 我将用上一篇文章中的代码来训练一个网络,解决一个经典问题,并观察损失函数的变化。
- 除了 ReLU,我还将介绍 Sigmoid 和 Tanh 等其他常用激活函数,并讨论如何为输出层选择合适的激活函数。
- 探讨权重初始化的重要性,并介绍批量归一化 (Batch Normalization) 和 Dropout 等强大的技术。
- 最后,分享一些关于调试神经网络和选择超参数的实用技巧。
训练一个 XOR 网络
理论需要实践来检验。让我们使用上一篇文章中定义的
NeuralNetwork
类来解决经典的 XOR 问题。XOR
是一个非线性问题,单个神经元无法解决,因此很适合作为我们神经网络的测试案例。
XOR 的真值表如下:
输入 A | 输入 B | 输出 |
---|---|---|
0 | 0 | 0 |
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 0 |
准备数据和网络
1 | # existing codes... |
训练并观察损失
我们将使用较小的学习率和足够多的迭代次数来训练网络。
1 | # 3. 训练网络 |
正如我们所见,损失 (Loss) 随着训练的进行而稳步下降,这表明我们的网络确实在学习如何解决 XOR 问题。
查看结果
1 | # 4. 进行预测并展示结果 |
预测值非常接近真实值,证明这个简单的神经网络框架是有效的。
其实这个结果是我精挑细选,训练了很多很多次才得到的成功预测结果,训练其实是有随机性的,上面的代码在我本地测试中成功率非常低,训练十次都不一定能有一次成功,极大概率会训练失败,可以说这是一个失败的网络实现。这时就要调整参数或者更换合适的激活函数。
其它的激活函数
前面我们只介绍了 ReLU。虽然它非常流行且有效,但了解其他激活函数以及如何为输出层做选择也同样重要。
Sigmoid
Sigmoid 函数将任意实数压缩到 (0, 1) 区间内,所以它很适合用来表示概率。
公式: $$ \sigma(z) = \frac{1}{1 + e^{-z}} $$
Python 实现:
1
2
3
4
5
6
7
8
9import numpy as np
def sigmoid(z):
return 1 / (1 + np.exp(-z))
print(sigmoid(np.array([-2, 0, 2])))
# [0.11920292 0.5 0.88079708]优点: 输出在 (0, 1) 之间,平滑且易于求导。
缺点:
- 梯度消失: 当输入非常大或非常小时,函数的导数趋近于 0,导致梯度在反向传播时消失,使网络难以训练。
- 输出不以 0 为中心: 输出总是正数,这可能导致后续层权重更新时朝同一个方向移动,降低收敛速度。
Tanh (双曲正切)
Tanh 函数会将输入压缩到 (-1, 1) 区间。
公式: $$ tanh(z) = \frac{e^z - e^{-z}}{e^z + e^{-z}} $$
Python 实现:
1
2
3
4
5
6
7
8
9import numpy as np
def tanh(z):
return np.tanh(z)
print(tanh(np.array([-2, 0, 2])))
# [-0.96402758 0. 0.96402758]优点:
- 以 0 为中心: 输出在 -1 和 1 之间,解决了 Sigmoid 的一个主要缺点。
- 通常比 Sigmoid 收敛更快。
缺点: 仍然存在梯度消失的问题。
如何为输出层选择激活函数?
输出层的激活函数选择至关重要,因为它决定了网络输出的格式。
二元分类 (Binary Classification): 当你预测两个类别之一时(例如,是猫 / 不是猫),使用 Sigmoid 函数。它输出一个 0 到 1 之间的值,可以解释为属于正类的概率。
多元分类 (Multi-class Classification): 当你在多个类别中选择一个时(例如,数字识别 0-9),使用 Softmax 函数。它能将一组数字转换成概率分布,所有输出的总和为 1。
Softmax 实现:
1
2
3
4
5
6
7
8
9
10import numpy as np
def softmax(z):
exp_z = np.exp(z - np.max(z, axis=1, keepdims=True))
return exp_z / np.sum(exp_z, axis=1, keepdims=True)
print(softmax(np.array([[2.0, 1.0, 0.1]])))
# [[0.65900114 0.24243297 0.09856589]]回归 (Regression): 当预测一个连续值时(例如,房价),输出层不使用任何激活函数。这样,网络就可以输出任意范围的数值。
优化与正则化技巧
为了构建更强大、更稳定的神经网络,我们需要一些高级的优化和正则化技术。
权重初始化的重要性
我们在第一部分中用简单的 np.random.randn() * 0.01
来初始化权重。这虽然行得通,但不是最好的解决方案。糟糕的权重初始化可能导致梯度消失或梯度爆炸,即梯度在反向传播过程中变得过小或过大。
现代的初始化方法,如 Xavier (Glorot) 初始化 和 He 初始化,通过智能地根据上一层的神经元数量来调整初始权重的方差,从而确保信号在网络中更稳定地传播,显著加快训练速度并提高性能。
Xavier 初始化: 通常与 Sigmoid 或 Tanh 激活函数配合使用。
He 初始化: 专为 ReLU 及其变体设计,是现代深度网络中的首选。
Xavier/He 初始化示例:
1
2
3
4
5
6
7import numpy as np
# Xavier 初始化 (适合Sigmoid/Tanh)
fan_in, fan_out = 64, 32
xavier = np.random.randn(fan_in, fan_out) * np.sqrt(1.0 / fan_in)
# He 初始化 (适合ReLU)
he = np.random.randn(fan_in, fan_out) * np.sqrt(2.0 / fan_in)
批量归一化 (Batch Normalization)
在每个小批量数据通过网络时,对每一层的输入进行归一化(调整为均值为 0,方差为 1),然后再进行缩放和平移。
好处:
加速训练: 允许使用更高的学习率。
稳定训练: 减少了对权重初始化的敏感度。
轻微的正则化效果: 由于是在小批量上计算均值和方差,引入的噪声可以起到类似 Dropout 的效果。
BatchNorm 示例:
1
2
3
4
5
6
7
8
9
10
11
12import numpy as np
def batch_norm(x, gamma, beta, eps=1e-5):
mu = np.mean(x, axis=0)
var = np.var(x, axis=0)
x_norm = (x - mu) / np.sqrt(var + eps)
return gamma * x_norm + beta
# x: (batch_size, features)
# gamma/beta: 可学习参数,初始为1和0
Dropout
Dropout
是一种简单而有效的正则化技术,用于防止网络过拟合。在训练过程中的每一步,它会以一定的概率
p
随机地 “丢弃” 网络中的一部分神经元。
这意味着网络不能依赖于任何一个特定的神经元,迫使它学习到更健壮、更冗余的特征表示。在测试时,所有神经元都会被使用,但它们的输出会按比例
(1-p)
缩小,以平衡训练时的丢弃行为。
- Dropout 示例:
1
2
3
4
5
6
7
8
9
10import numpy as np
def dropout(x, p):
mask = (np.random.rand(*x.shape) > p).astype(float)
return x * mask / (1 - p)
# 训练时: x = dropout(x, p=0.5)
# 推理时: 不用dropout
损失函数的选择
在二元分类任务中,输出层通常用 sigmoid 激活,最合适的损失函数是二元交叉熵(Binary Cross Entropy, BCE),而不是均方误差(MSE)。
BCE 公式,其中 y 是真实标签(0 或 1),p 是 sigmoid 输出概率: L = −[y ⋅ ln p + (1 − y) ⋅ ln (1 − p)]
BCE 导数: $$ \frac{\partial L}{\partial p} = -\frac{y}{p} + \frac{1-y}{1-p} $$
区别:
- MSE 在 sigmoid 饱和区间梯度更容易消失,收敛慢。
- BCE 更适合概率输出,收敛快。
建议
调试神经网络的技巧
当神经网络不工作时,调试让人心旷神怡。下面是一些实用的检查步骤:
- 先用一个非常小的网络(例如一个隐藏层,少量神经元)来过拟合一小部分训练数据(例如,仅 10-20 个样本)。如果连这一步都做不到,说明模型结构或代码实现有根本性问题。
- 过拟合小数据集:
1
2
3
4# X_small, y_small = 10个样本
nn = NeuralNetwork([2, 2, 1])
nn.train(X_small, y_small, epochs=5000, learning_rate=0.01)
# 观察loss是否能降到极低
确保输入数据
X
和标签y
是正确配对的。可以对数据进行可视化,检查是否存在异常值或错误。确保数据已经正确归一化。一个太高的学习率是导致损失爆炸或不收敛的最常见原因。尝试将学习率降低一个数量级(例如从
0.01
到0.001
)。
- 学习率调参:
1
2
3for lr in [0.1, 0.01, 0.001]:
nn.train(X, y, epochs=1000, learning_rate=lr)
# 观察loss曲线
- 确保选择了正确的损失函数,并且在反向传播时正确计算了对应的导数。
超参数的选择
超参数是在训练开始前设置的参数,例如学习率、层数等。
- 学习率 (Learning Rate):
学习率是最重要的超参数。通常从
0.1
,0.01
,0.001
等值开始尝试。可以使用学习率衰减 (Learning Rate Decay),即在训练过程中逐渐降低学习率。 - 网络架构 (层数和神经元数量): 从一个隐藏层开始。如果网络无法很好地拟合训练数据,再逐步增加层的深度和 / 或宽度。通常,增加深度比增加宽度更有效。
- 批量大小 (Batch Size): 以前通常选择 2 的幂,如 32,
64, 128,但现在的说法是,随便选什么数都行。比如,你可以选 520
训练一个神经网络,送给你的对象(笑
- 小批量: 训练速度快,引入的噪声可能有助于泛化。
- 大批量: 梯度估计更准确,但可能陷入局部最小值,且需要更多内存。
- 优化器 (Optimizer): 我们只讨论了基本的梯度下降。现代优化器如 Adam, RMSprop 通常能提供更快的收敛速度和更好的性能,它们会自动调整学习率。在平常实践中,Adam 是一个非常好的默认选择。
优化 XOR 网络
相信看完上面的内容后,我们对神经网络有了更多的了解。现在对 XOR 网络的代码添加以下优化:
- Neuron 支持 tanh、sigmoid、relu 三种激活函数。
- 隐藏层用 tanh,输出层用 sigmoid。
- 权重初始化方式根据激活函数自动选择(tanh/sigmoid 用 Xavier,relu 用 He)。
- 训练和预测流程自动适配。
下面是优化后的 XOR 神经网络训练完整代码:
1 | import numpy as np |
可以明显看到 XOR 网络的预测更加精准了,并且我在本地连续训练了很多次,几乎不再有训练失败的情况发生。显然,我们的神经网络实现可以说成功了。
本篇中的 Layer/Neuron 结构采用 for-loop + np.hstack,便于理解原理。实际程序中建议采用全矩阵化实现以提升效率。