在补机器学习基础之前,我其实已经先用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,卷积神经网络为什么更适合处理图片。