一、数据集的下载和处理
下载数据
直接调用官方库
train_dataset = datasets.MNIST(root='./data', train=True, download=True)
test_dataset = datasets.MNIST(root='./data', train=False)
数据处理与分割
将图像数据从二维数组展平为一维数组,方便MLP处理,并将数据除以255以归一化;官方以为我们划分好了训练集和测试集,我们还需从训练集划分10000条数据(与测试集同种规模)到验证集,代码如下:
# 将数据转换为适合训练的格式
img_train = train_dataset.data.view(-1, 28*28).float() / 255.0
label_train = train_dataset.targets
img_test = test_dataset.data.view(-1, 28*28).float() / 255.0
label_test = test_dataset.targets
# 将训练集的后 10,000 条数据作为验证集
img_val = img_train[-10000:]
label_val = label_train[-10000:]
img_train = img_train[:-10000]
label_train = label_train[:-10000]
当前数据集是torch形式,因为我们要用numpy建立神经网络,应该将数据集转化为numpy数组:
img_train_np = img_train.numpy()
label_train_np = label_train.numpy()
img_val_np = img_val.numpy()
label_val_np = label_val.numpy()
img_test_np = img_test.numpy()
label_test_np = label_test.numpy()
数据集加载器
模仿torch的DataLoader,我们也需要一个数据集加载器,以实现下面两种功能
- 将每次训练所需的batch_size数据进行封装,制成一个迭代器。
- 在每次epoch开始时,将训练集数据随机打乱,使训练更充分,防止过拟合。
class NumpyDataLoader:
def __init__(self, dataset, labels, batch_size=64, shuffle=True):
self.dataset = dataset
self.labels = labels
self.batch_size = batch_size
self.shuffle = shuffle
self.indices = np.arange(len(dataset))
self.current_index = 0
if self.shuffle:
np.random.shuffle(self.indices)
def __iter__(self):
self.current_index = 0
if self.shuffle:
np.random.shuffle(self.indices)
return self
def __next__(self):
if self.current_index >= len(self.dataset):
raise StopIteration
batch_indices = self.indices[self.current_index:self.current_index + self.batch_size]
batch_data = self.dataset[batch_indices]
batch_labels = self.labels[batch_indices]
self.current_index += self.batch_size
return batch_data, batch_labels
def __len__(self):
return int(np.ceil(len(self.dataset) / self.batch_size))
二、全连接层
全连接层->向前传播
数学模型
其中
代码
def forward(self, x):
self.x = x
return np.dot(x, self.W) + self.b
全连接层->反向传播
数学推导
用指标运算重写全连接层的数学模型(采用爱因斯坦求和约定,后续公式也均采用此约定)。
将
由链式法则,交叉熵损失
在反向传播过程中,
代码
def backward(self, top_grad):
self.dW = np.dot(self.x.T, top_grad)
self.db = np.sum(top_grad, axis=0)
bottom_grad = np.dot(top_grad, self.W.T)
return bottom_grad
全连接层->参数初始化
数学模型
模仿torch 使用 Kaiming (He) 均匀初始化的参数范围
代码
def __init__(self, input_size, out_size):
# 模仿torch 使用 Kaiming (He) 均匀初始化的参数范围
limit_weight = np.sqrt(6 / input_size)
self.W = np.random.uniform(-limit_weight, limit_weight, (out_size, input_size))
limit_bias = 1 / np.sqrt(input_size)
self.b = np.random.uniform(-limit_bias, limit_bias, (1, out_size))
self.x = None
self.db = None
self.dW = None
全连接层->参数更新
数学模型
在所有网络完成反向传播后,对全连接层进行参数更新,采用mini-batch SGD,每一次迭代按照一定的学习率沿梯度的反方向更新参数,直至收敛。
代码
def update_params(self, lr=0.01):
self.W -= lr * self.dW
self.b -= lr * self.db
三、ReLU激活层
ReLU层->前向传播
数学模型
代码
def forward(self, x):
self.x = x
return np.maximum(0, x)
ReLU层->反向传播
数学推导
代码
def backward(self, top_grad):
return (self.x > 0) * top_grad
四、Softmax-Loss层
Softmax层->前向传播
数学模型
实际使用中,为了避免指数发散,我们可以对
代码
def forward(self, x):
exp_x = np.exp(x - np.max(x, axis=1, keepdims=True))
y_pred = exp_x / np.sum(exp_x, axis=1, keepdims=True)
self.x = x
self.y_pred = y_pred
return y_pred
Loss层->前向传播
数学模型
各变量含义如下:
:真实标签的one-hot编码向量。 :预测的概率分布向量。 :单个样本的交叉熵损失。 :一个batch的平均损失。
(13) 式在单分类任务中可化简为
其中,
代码
def forward(self, x):
exp_x = np.exp(x - np.max(x, axis=1, keepdims=True))
y_pred = exp_x / np.sum(exp_x, axis=1, keepdims=True)
self.x = x
self.y_pred = y_pred
return y_pred
def get_loss(self, labels):
batch_size = len(labels)
self.one_hot_labels = np.zeros_like(self.y_pred)
self.one_hot_labels[np.arange(batch_size), labels] = 1
loss = -np.sum(self.one_hot_labels * np.log(self.y_pred)) / batch_size
return loss
Softmax-Loss层->反向传播
数学推导
之所以将Softmax层和Loss层合二为一,是因为两层的反向传播的结果比较简单,为:
接下来是推导
由
对于
对于
当
当
综上所述,
向量形式为
由(14)式即得证
代码
def backward(self):
batch_size = len(self.one_hot_labels)
bottom_grad = (self.y_pred - self.one_hot_labels) / batch_size
return bottom_grad
五、搭建全连接神经网络
模型
我们要搭建只有⼀层hidden layer的全连接神经网络,即搭建三层全连接层,再加上最后的Softmax-Loss层。
代码
class NumpyMLP:
def __init__(self, input_size=784, hidden_size=128, num_classes=10):
self.linear1 = NumpyLinear(input_size, hidden_size)
self.relu1 = NumpyReLU()
self.linear2 = NumpyLinear(hidden_size, hidden_size)
self.relu2 = NumpyReLU()
self.linear3 = NumpyLinear(hidden_size, num_classes)
self.softmax = NumpySoftmax()
self.update_layers = [self.linear1, self.linear2, self.linear3]
def forward(self, x):
x = self.linear1(x)
x = self.relu1(x)
x = self.linear2(x)
x = self.relu2(x)
x = self.linear3(x)
x = self.softmax(x)
return x
def get_loss(self, labels):
return self.softmax.get_loss(labels)
def backward(self):
grad = self.softmax.backward()
grad = self.linear3.backward(grad)
grad = self.relu2.backward(grad)
grad = self.linear2.backward(grad)
grad = self.relu1.backward(grad)
grad = self.linear1.backward(grad)
def update_params(self, lr=0.01):
for layer in self.update_layers:
layer.update_params(lr)
def __call__(self, x):
return self.forward(x)
六、模型训练
训练过程描述
在一个 mini-batch 随机梯度下降 (SGD) 训练过程中,我们会逐步执行以下步骤来训练 模型:
- 前向传播:对于每个 mini-batch 数据,我们将输入
x
传入forward
方法。模型按层顺序计算线性变换、ReLU 激活和 softmax 预测概率。 - 计算损失:前向传播得到的预测输出将与实际标签
labels
一起传入get_loss
方法,计算交叉熵损失。 - 反向传播:损失计算完成后,通过调用
backward
方法,模型按层逆序逐步传播梯度。softmax 层的backward
返回初始梯度,后续每层根据前一层传入的梯度进行自身的反向传播计算,并传递给下一层。 - 参数更新:反向传播完成后,调用
update_params
方法,按学习率lr
更新各层权重和偏置。该更新通过遍历update_layers
中的可训练层(例如每个NumpyLinear
层)逐步完成。 - 重复迭代:完成一个 mini-batch 的训练后,data_loader从下一个 mini-batch 获取新的训练样本,重复上述步骤,直到所有 mini-batch 均参与训练。在一个训练集训练完毕后,还需执行多个 epoch。
一次完整训练
为了减少超参,将隐藏层的大小都设置为hidden_size
- hidden_size
- batch_size
- num_epochs // 训练集
- lr // 学习率
对于一个超参组合,一次完整训练过程如下所示:
hidden_size = 128
batch_size = 32
num_epochs = 10
lr = 0.01
train_loader = NumpyDataLoader(img_train_np, label_train_np, batch_size=batch_size, shuffle=True)
test_loader = NumpyDataLoader(img_test_np, label_test_np, batch_size=batch_size, shuffle=False)
model = NumpyMLP(hidden_size=hidden_size)
init_params = model.export_parameters()
print('Training Numpy model...')
for epoch in range(num_epochs):
running_loss = 0.0
for images, labels in train_loader:
y_pred = model(images)
running_loss += model.get_loss(labels)
model.backward()
model.update_params(lr=lr)
print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {running_loss/len(train_loader):.4f}')
寻找最优超参
num_epochs 和lr直接影响一次训练的程度,不好直接比较优劣,因此我们讨论hidden_size和batch_size对模型的影响,通过验证集来评估。
# 参数设置
hidden_sizes = [32, 64, 128, 256]
batch_sizes = [8, 16, 32, 64]
learning_rate = 0.01
num_epochs = 10
# 用于保存验证集准确率的矩阵
accuracy_matrix = np.zeros((len(hidden_sizes), len(batch_sizes)))
best_accuracy = 0
best_params = {}
# 超参数搜索
for i, hidden_size in enumerate(hidden_sizes):
for j, batch_size in enumerate(batch_sizes):
# 初始化数据加载器和模型
train_loader = NumpyDataLoader(img_train_np, label_train_np, batch_size=batch_size, shuffle=True)
val_loader = NumpyDataLoader(img_val_np, label_val_np, batch_size=batch_size, shuffle=False)
model = NumpyMLP(hidden_size=hidden_size)
# 训练模型
for epoch in range(num_epochs):
for images, labels in train_loader:
y_pred = model(images)
model.get_loss(labels)
model.backward()
model.update_params(lr=learning_rate)
# 计算验证集准确率
correct = 0
total = 0
for images, labels in val_loader:
y_pred = model(images)
predicted = np.argmax(y_pred, axis=1)
total += labels.size
correct += (predicted == labels).sum()
accuracy = 100 * correct / total
accuracy_matrix[i, j] = accuracy # 保存准确率到矩阵中
# 打印并更新最佳参数
print(f'Params: hidden_size={hidden_size}, batch_size={batch_size} | Validation Accuracy: {accuracy:.2f}%')
if accuracy > best_accuracy:
best_accuracy = accuracy
best_params = {
'hidden_size': hidden_size,
'batch_size': batch_size,
}
print(f'Best params: {best_params} with accuracy: {best_accuracy:.2f}%')
可视化结果
将得到的最优超参训练的模型用于测试集,结果如下,热力图的幅值表示该超参组合的模型在验证集上的准确率。
大体趋势为Batch_size越小,Hidden_size越大,效果越好,但也需要更多训练时间和更多epochs数,如左下角 (8, 256) 组合略逊于上方 (8, 128) 组合,可能是因为训练不够充分所致。
最终,我们采用 (8, 128) 组合最为 (Batch_size, Hidden_size) 超参组合。
七、模型评估
用Pytorch重写一份相同的MLP模型,在相同的超参数设置下进行训练,并且网络初始化参数也保持一致,结果如下:
可以看出,训练过程相差无几,证明了我们手动构建的模型与Pytorch的一致性,同时两者在测试集上的准确度如下:
- Numpy:97.68%
- Pytorch:97.61%
结果也非常接近。
附录
文件清单
MLP.ipynb:jupyter notebook 文件,含实验的完整可运行代码