在补机器学习基础之前,我其实已经先用MNIST跑过一个MLP baseline。代码能跑起来,准确率也能到97%左右,但当时更多是“先跑通”,很多细节还没完全理解。
这篇先把这次实验整理下来。它不是说我已经懂深度学习了,而是记录我第一次完整看到:
数据->模型->loss->反向传播->更新参数->测试准确率
这个流程。
实验目标
MNIST是手写数字识别数据集。每张图片是28x28的灰度图,对应一个0到9的数字标签。
这次实验的目标很简单:用一个最基础的MLP,Multilayer Perceptron,多层感知机,完成MNIST的10分类任务。
加载数据
脚本里先写了一个get_dataloaders函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| def get_dataloaders(batch_size: int) -> tuple[DataLoader, DataLoader]: transform = transforms.ToTensor()
train_dataset = datasets.MNIST( root=str(DATA_ROOT), train=True, download=False, transform=transform, ) test_dataset = datasets.MNIST( root=str(DATA_ROOT), train=False, download=False, transform=transform, )
train_loader = DataLoader( dataset=train_dataset, batch_size=batch_size, shuffle=True, ) test_loader = DataLoader( dataset=test_dataset, batch_size=batch_size, shuffle=False, ) return train_loader, test_loader
|
这部分主要解决“数据怎么进入模型”的问题。
ToTensor()把图片转成PyTorch能计算的张量
train=True是训练集,train=False是测试集
batch_size=64表示一次训练64张图片
shuffle=True用在训练集上,是为了打乱顺序
- 测试集不需要打乱,因为它只是用来评估
我现在把Dataset理解成“数据怎么组织”,DataLoader理解成“每次怎么取一批数据”。
MLP 模型
模型结构很简单:
1 2 3 4 5 6 7 8 9 10 11 12
| class MLP(nn.Module): def __init__(self) -> None: super().__init__() self.network = nn.Sequential( nn.Flatten(), nn.Linear(28 * 28, 128), nn.ReLU(), nn.Linear(128, 10), )
def forward(self, x: torch.Tensor) -> torch.Tensor: return self.network(x)
|
这里最关键的是:
Flatten()把28x28图片拉平成784 个数字
Linear(784,128)是第一层全连接层
ReLU()加入非线性
Linear(128,10)输出10个类别分数
这也让我看到MLP处理图片的一个问题:它一开始就把图片拉平了,原来的空间结构会被弱化。后面写CNN的时候,可以重点对比这一点。
训练一个 epoch
训练函数是这次实验里最重要的部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| def train_one_epoch( model: nn.Module, train_loader: DataLoader, loss_fn: nn.Module, optimizer: torch.optim.Optimizer, device: torch.device, ) -> tuple[float, float]: model.train() total_loss = 0.0 correct = 0 total = 0
for images, labels in train_loader: images = images.to(device) labels = labels.to(device)
predictions = model(images) loss = loss_fn(predictions, labels)
optimizer.zero_grad() loss.backward() optimizer.step()
batch_size = images.size(0) total_loss += loss.item() * batch_size correct += (predictions.argmax(dim=1) == labels).sum().item() total += batch_size
average_loss = total_loss / total accuracy = correct / total return average_loss, accuracy
|
我现在这样理解它:
model.train() 进入训练模式
model(images)是前向传播,先预测
loss_fn(predictions,labels)计算预测错多少
optimizer.zero_grad()清空上一轮梯度
loss.backward()反向传播,计算梯度
optimizer.step()更新参数
- 最后统计平均loss和accuracy
真正让模型变好的,不是单纯跑了一遍model(images),而是后面的反向传播和参数更新。
测试模型
测试函数和训练函数很像,但它不更新参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| def evaluate( model: nn.Module, test_loader: DataLoader, loss_fn: nn.Module, device: torch.device, ) -> tuple[float, float]: model.eval() total_loss = 0.0 correct = 0 total = 0
with torch.no_grad(): for images, labels in test_loader: images = images.to(device) labels = labels.to(device)
predictions = model(images) loss = loss_fn(predictions, labels)
batch_size = images.size(0) total_loss += loss.item() * batch_size correct += (predictions.argmax(dim=1) == labels).sum().item() total += batch_size
average_loss = total_loss / total accuracy = correct / total return average_loss, accuracy
|
这里要注意:
model.eval()是评估模式
torch.no_grad()表示不计算梯度
argmax(dim=1)是从10个类别分数里选最大的
accuracy是预测正确的比例
训练阶段会更新参数,测试阶段只是看模型学得怎么样。
主流程
主函数里把前面的部分串起来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| torch.manual_seed(42)
batch_size = 64 epochs = 5 learning_rate = 1e-3 device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
train_loader, test_loader = get_dataloaders(batch_size=batch_size) model = MLP().to(device) loss_fn = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
for epoch in range(1, epochs + 1): train_loss, train_acc = train_one_epoch( model=model, train_loader=train_loader, loss_fn=loss_fn, optimizer=optimizer, device=device, ) test_loss, test_acc = evaluate( model=model, test_loader=test_loader, loss_fn=loss_fn, device=device, )
print( f"Epoch {epoch:02d} | " f"train loss {train_loss:.4f} | " f"train acc {train_acc:.4f} | " f"test loss {test_loss:.4f} | " f"test acc {test_acc:.4f}" )
torch.save(model.state_dict(), MODEL_PATH)
|
这部分主要做几件事:
- 设置随机种子
- 选择CPU或GPU
- 创建DataLoader
- 创建模型、损失函数和优化器
- 每轮训练后都在测试集上评估
- 最后保存模型参数
实验结果
| Epoch |
Train Loss |
Train Acc |
Test Loss |
Test Acc |
| 1 |
0.3448 |
0.9062 |
0.1869 |
0.9436 |
| 2 |
0.1581 |
0.9545 |
0.1339 |
0.9620 |
| 3 |
0.1082 |
0.9684 |
0.1137 |
0.9651 |
| 4 |
0.0824 |
0.9754 |
0.0879 |
0.9736 |
| 5 |
0.0637 |
0.9814 |
0.0802 |
0.9752 |
从结果看:
- train loss从0.3448降到0.0637
- test acc从94.36%提升到97.52%
- train acc和test acc差距不大,暂时没有明显过拟合
所以这个MLP baseline算是跑通了。
小结
这次实验让我第一次比较完整地看到PyTorch训练流程。以前我可能只是知道“跑模型”,现在至少能把数据加载、前向传播、loss、反向传播、参数更新和测试准确率连起来。
不过MLP只是第一个baseline。它能在MNIST上跑出不错结果,但它把图片直接拉平了,没有很好利用图像的空间结构。
所以下一篇我准备接CNN,看看同样是MNIST,卷积神经网络为什么更适合处理图片。