共计 4056 个字符,预计需要花费 11 分钟才能阅读完成。
前言
最近在学习机器学习中的神经网络,特别是 BP 神经网络。作为一个初学者,我发现很多教程要么过于理论化,要么代码实现不够清晰。所以决定自己动手实现一个 BP 神经网络,并在 MNIST 手写数字数据集上进行测试。这篇文章将记录我的学习过程,希望能帮助到同样正在入门的小伙伴们。
1. BP 神经网络基础
BP(Back Propagation)神经网络是最基础的前馈神经网络之一。它主要由三部分组成:输入层、隐藏层和输出层。数据从输入层进入,经过隐藏层处理后,最终在输出层得到结果。
1.1 前向传播
前向传播是指数据从输入层流向输出层的过程。对于每一层,我们都会进行两个操作:
- 线性变换:$z = Wx + b$
- 非线性激活:$a = \sigma(z)$
其中 $W$ 是权重矩阵,$b$ 是偏置向量,$\sigma$ 是激活函数。
1.2 反向传播
反向传播是根据输出误差来调整网络参数的过程。这是 BP 神经网络最核心的部分,也是它名称的由来。
主要步骤包括:
- 计算输出层误差:$\delta^L = \nabla_a C \odot \sigma'(z^L)$
- 反向传播误差:$\delta^l = ((W^{l+1})^T \delta^{l+1}) \odot \sigma'(z^l)$
- 计算梯度:$\frac{\partial C}{\partial W^l} = \delta^l (a^{l-1})^T$
- 更新参数:$W^l = W^l – \eta \frac{\partial C}{\partial W^l}$
2. 代码实现
下面我们用 Python 和 numpy 来实现一个完整的 BP 神经网络。
2.1 网络初始化
import numpy as np
class NeuralNetwork:
def __init__(self, layers, learning_rate=0.1):
"""
初始化神经网络
:param layers: 网络结构,如 [784, 30, 10] 表示输入层 784 个神经元,隐层 30 个,输出层 10 个
:param learning_rate: 学习率
"""
self.layers = layers
self.learning_rate = learning_rate
# 初始化权重和偏置
self.weights = []
self.biases = []
# 使用 Xavier 初始化方法
for i in range(len(layers)-1):
# 权重初始化为均值为 0,方差为 sqrt(2/(n_in+n_out))的随机数
w = np.random.randn(layers[i+1], layers[i]) * np.sqrt(2.0/(layers[i]+layers[i+1]))
b = np.zeros((layers[i+1], 1))
self.weights.append(w)
self.biases.append(b)
2.2 激活函数选择
不同的激活函数会对网络性能产生显著影响。我们比较三种常用激活函数:
- Sigmoid: $\sigma(z) = \frac{1}{1+e^{-z}}$
- Tanh: $tanh(z) = \frac{e^z – e^{-z}}{e^z + e^{-z}}$
- ReLU: $ReLU(z) = max(0, z)$
def sigmoid(self, z):
return 1.0 / (1.0 + np.exp(-z))
def sigmoid_prime(self, z):
"""Sigmoid 函数的导数"""
s = self.sigmoid(z)
return s * (1 - s)
def relu(self, z):
return np.maximum(0, z)
def relu_prime(self, z):
"""ReLU 函数的导数"""
return (z > 0).astype(float)
2.3 前向传播实现
def forward(self, x):
"""前向传播"""
activations = [x] # 存储各层激活值
zs = [] # 存储各层线性变换结果
a = x
for w, b in zip(self.weights, self.biases):
z = np.dot(w, a) + b
zs.append(z)
a = self.relu(z) # 隐层使用 ReLU
activations.append(a)
# 输出层使用 softmax
z = zs[-1]
exp_z = np.exp(z - np.max(z)) # 防止数值溢出
a = exp_z / np.sum(exp_z)
activations[-1] = a
return activations, zs
2.4 反向传播实现
def backward(self, x, y, activations, zs):
"""反向传播"""
nabla_w = [np.zeros(w.shape) for w in self.weights]
nabla_b = [np.zeros(b.shape) for b in self.biases]
# 输出层误差
delta = activations[-1] - y
nabla_b[-1] = delta
nabla_w[-1] = np.dot(delta, activations[-2].T)
# 反向传播误差
for l in range(2, len(self.layers)):
z = zs[-l]
sp = self.relu_prime(z)
delta = np.dot(self.weights[-l+1].T, delta) * sp
nabla_b[-l] = delta
nabla_w[-l] = np.dot(delta, activations[-l-1].T)
return nabla_w, nabla_b
2.5 参数更新
def update_params(self, nabla_w, nabla_b):
"""更新参数"""
for i in range(len(self.weights)):
self.weights[i] -= self.learning_rate * nabla_w[i]
self.biases[i] -= self.learning_rate * nabla_b[i]
3. 在 MNIST 上的实战
现在我们用这个网络来识别手写数字。MNIST 数据集包含 60000 张训练图片和 10000 张测试图片。
3.1 数据处理
from tensorflow.keras.datasets import mnist
# 加载数据
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
# 归一化
train_images = train_images.reshape((60000, 28*28)) / 255.0
test_images = test_images.reshape((10000, 28*28)) / 255.0
# 标签转为 one-hot
num_classes = 10
train_labels = np.eye(num_classes)[train_labels]
test_labels = np.eye(num_classes)[test_labels]
3.2 训练网络
# 初始化网络
nn = NeuralNetwork([784, 128, 64, 10], learning_rate=0.01)
# 训练参数
epochs = 10
batch_size = 64
# 训练循环
for epoch in range(epochs):
for i in range(0, len(train_images), batch_size):
# 获取 batch
batch_images = train_images[i:i+batch_size].T
batch_labels = train_labels[i:i+batch_size].T
# 前向传播
activations, zs = nn.forward(batch_images)
# 反向传播
nabla_w, nabla_b = nn.backward(batch_images, batch_labels, activations, zs)
# 更新参数
nn.update_params(nabla_w, nabla_b)
# 每个 epoch 评估一次
test_accuracy = evaluate(nn, test_images, test_labels)
print(f"Epoch {epoch+1}, Test Accuracy: {test_accuracy:.2%}")
4. 调优技巧
4.1 学习率调整
学习率太大可能导致震荡,太小则收敛缓慢。可以尝试:
- 学习率衰减:随着训练进行逐渐减小学习率
- 自适应学习率方法:如 Adam 优化器
4.2 批量归一化(BatchNorm)
批量归一化可以加速训练,允许使用更高的学习率。通常在激活函数前加入:
def batch_norm(self, z):
mean = np.mean(z, axis=1, keepdims=True)
var = np.var(z, axis=1, keepdims=True)
z_norm = (z - mean) / np.sqrt(var + 1e-8)
return self.gamma * z_norm + self.beta
4.3 梯度裁剪
当梯度爆炸时,可以限制梯度的大小:
def clip_gradients(self, grads, max_norm):
total_norm = np.sum([np.sum(np.square(g)) for g in grads])
scale = max_norm / (np.sqrt(total_norm) + 1e-6)
if scale < 1:
for g in grads:
g *= scale
return grads
5. 结果分析
经过 10 个 epoch 的训练,我们的网络在测试集上达到了约 96% 的准确率。Loss 曲线如下:

6. 总结
通过这个项目,我深刻理解了 BP 神经网络的工作原理。关键点包括:
- 合理的网络初始化非常重要,Xavier 初始化是个不错的选择
- 隐层激活函数的选择会影响训练效果,ReLU 通常表现不错
- 学习率需要仔细调整,可以考虑自适应方法
- 批量归一化可以显著提升训练速度和稳定性
完整的代码可以在我的 GitHub 上找到。希望这篇文章对你有帮助,欢迎留言讨论!
正文完
