📋 前言
各位伙伴们,大家好!在过去的几天里,我们成功地定义了模型、加载了数据。今天,我们要解决一个更深层次的问题:如何组织我们的代码,让它从一堆杂乱的脚本,变成一个结构清晰、逻辑分明、易于复用和维护的项目?
Day 40 是一个分水岭。我们学习的重点将从"如何实现某个功能",转向"如何优雅地组织所有功能"。我们将把训练和测试过程封装成独立的函数,并深入探讨监控模型训练的两种"分辨率":Epoch 级和 Iteration 级。更重要的是,通过在彩色图像上的实验,我们将亲身体会到当前模型(MLP)的局限性,从而为迎接更强大的 CNN 架构做好铺垫。
一、编程思想的飞跃:为什么要封装函数?
在我们之前的简单示例中,所有的训练逻辑都"平铺"在主流程里。当项目变复杂时,这种写法会变成一场噩梦。将核心逻辑封装成函数,至少有三大好处:
- 逻辑清晰,关注点分离 :
train函数只管训练,test函数只管测试,main部分只管调用。各司其职,代码的可读性指数级提升。 - 参数与实现分离 :封装后,函数的参数(如
learning_rate,epochs)就是我们的"仪表盘"。我们可以轻松调整这些超参数,而无需深入到复杂的训练循环代码中去修改。 - 代码复用,效率倍增 :这是最重要的优点!当我们想测试不同的模型时,我们不需要重写训练和测试代码 。我们只需在主流程中实例化一个新模型,然后把它传入同一个
train函数即可。
二、训练的两种"分辨率":Epoch Loss vs. Iteration Loss
在监控模型训练时,我们通常会看损失曲线。但这条曲线其实有两种不同的"画法"。
-
Epoch Loss (宏观视角)
- 是什么 :一个 Epoch(完整过一遍训练集)中,所有 Iteration (batch) 损失的平均值。
- 特点:平滑,能很好地反映模型整体的收敛趋势。
- 比喻 :就像一份月度财务报告,告诉你这个月总体的盈利状况。
-
Iteration Loss (微观视角)
- 是什么 :每一个 Iteration (batch) 计算出的瞬时损失值。
- 特点:非常"抖动"或"嘈杂",因为它受到当前小批量数据随机性的影响。但它能揭示训练过程中的不稳定性。
- 比喻 :就像实时更新的股票价格,虽然波动剧烈,但能让你看到市场最即时的反应。
观察 Iteration Loss 可以帮助我们诊断问题,比如学习率过大可能导致损失剧烈震荡。在今天的作业中,我们特意绘制了 Iteration Loss 曲线,来获得对训练过程更精细的洞察。
三、作业一:单通道图像(MNIST)的规范化模板
这是我们构建的第一个"标准"训练框架。它包含了数据加载、模型定义、函数封装和结果可视化的完整流程。
3.1 完整代码框架
python
# 导入必要的库
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings("ignore")
torch.manual_seed(42)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# 1. 数据预处理与加载
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)) # MNIST官方均值和标准差
])
train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST(root='./data', train=False, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)
# 2. 模型定义
class MLP(nn.Module):
def __init__(self):
super(MLP, self).__init__()
self.flatten = nn.Flatten()
self.layers = nn.Sequential(
nn.Linear(784, 128),
nn.ReLU(),
nn.Linear(128, 10)
)
def forward(self, x):
return self.layers(self.flatten(x))
# 3. 测试函数
def test(model, test_loader, criterion, device):
model.eval()
test_loss = 0
correct = 0
with torch.no_grad():
for data, target in test_loader:
data, target = data.to(device), target.to(device)
output = model(data)
test_loss += criterion(output, target).item()
_, predicted = output.max(1)
correct += predicted.eq(target).sum().item()
avg_loss = test_loss / len(test_loader)
accuracy = 100. * correct / len(test_loader.dataset)
return avg_loss, accuracy
# 4. 训练函数
def train(model, train_loader, test_loader, criterion, optimizer, device, epochs):
model.train()
all_iter_losses = []
iter_indices = []
for epoch in range(epochs):
for batch_idx, (data, target) in enumerate(train_loader):
data, target = data.to(device), target.to(device)
optimizer.zero_grad()
output = model(data)
loss = criterion(output, target)
loss.backward()
optimizer.step()
# 记录 iteration 级损失
all_iter_losses.append(loss.item())
iter_indices.append(epoch * len(train_loader) + batch_idx + 1)
if (batch_idx + 1) % 100 == 0:
print(f'Epoch: {epoch+1}/{epochs} | Batch: {batch_idx+1}/{len(train_loader)} '
f'| 单Batch损失: {loss.item():.4f}')
_, epoch_test_acc = test(model, test_loader, criterion, device)
print(f'Epoch {epoch+1}/{epochs} 完成 | 测试准确率: {epoch_test_acc:.2f}%')
plot_iter_losses(all_iter_losses, iter_indices)
return epoch_test_acc
# 5. 绘图函数
def plot_iter_losses(losses, indices):
plt.figure(figsize=(10, 4))
plt.plot(indices, losses, alpha=0.7, label='Iteration Loss')
plt.xlabel('Iteration')
plt.ylabel('Loss')
plt.title('Training Loss per Iteration')
plt.grid(True)
plt.show()
# 6. 主执行流程
model = MLP().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
print("开始训练MNIST模型...")
final_accuracy = train(model, train_loader, test_loader, criterion, optimizer, device, epochs=2)
print(f"训练完成!最终测试准确率: {final_accuracy:.2f}%")
3.2 结果分析
从结果可以看到,Iteration Loss 曲线虽然有许多毛刺和抖动,但整体趋势是稳步下降的。这表明我们的模型在每个小批量上都在学习,并且训练过程是健康的。最终在测试集上达到了很高的准确率(通常 >95%),证明 MLP 对于 MNIST 这种相对简单的图像分类任务是足够有效的。
四、作业二:彩色图像(CIFAR-10)与MLP的"天花板"
现在,我们把同样的 MLP 架构应用到更复杂的 CIFAR-10 彩色图像数据集上。我们只是修改了数据加载部分和模型的第一层以适应新的输入尺寸(3x32x32=3072)。
4.1 核心代码改动
python
# 数据加载 (CIFAR-10)
transform_cifar = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)) # CIFAR-10 常用均值和标准差
])
train_dataset_cifar = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_cifar)
# ... (DataLoader 定义类似)
# 模型定义 (适应 CIFAR-10)
class MLP_CIFAR(nn.Module):
def __init__(self):
super(MLP_CIFAR, self).__init__()
self.flatten = nn.Flatten()
self.layers = nn.Sequential(
nn.Linear(3 * 32 * 32, 512), # 输入维度变为 3072
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(512, 256),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(256, 10)
)
# ... forward a pass
4.2 令人深思的结果
即使我们增加了模型的深度、引入了 Dropout 并训练了更多轮次 (20 epochs),最终的测试准确率也仅仅在 50-55% 之间徘徊。这比瞎猜(10%)好,但远远达不到实用的标准。
观察损失曲线,虽然它也在下降,但下降得非常缓慢,且最终的损失值仍然很高。这说明模型已经尽力了,但它的结构限制了它无法学得更好。
4.3 为什么MLP在图像任务上失败了?
这次"失败"的实验,恰恰是我们最重要的学习机会。原因主要有两点:
-
丢失空间信息 (Spatial Information Loss) :
nn.Flatten()操作是"罪魁祸首"。它将一个32x32的像素矩阵粗暴地拉成一个3072的长向量。图像中,一个像素和它邻近的像素关系极为重要(比如构成一条边、一个角),这种局部空间结构在展平后被完全破坏了。MLP 无法利用这种像素间的邻里关系。 -
参数爆炸与过拟合 (Parameter Explosion):全连接层意味着每个输入神经元都与每个输出神经元相连。对于高分辨率图像,这会导致参数数量急剧增加,模型变得异常庞大,不仅训练缓慢,而且极易在有限的数据上发生过拟ટું合。
五、学习心得
今天是我编程思维和深度学习认知的一次重要升级。
- 从"脚本小子"到"工程师":我学会了将代码模块化、函数化。这不仅让代码更美观,更重要的是提升了它的可维护性和可复用性。这套训练框架将是我未来项目的宝贵起点。
- 学会用"显微镜"看训练:通过观察 Iteration 级的损失,我对模型的学习过程有了更细致的了解。我明白了宏观趋势(Epoch Loss)和微观动态(Iteration Loss)各自的价值。
- "失败"是成功之母 :MLP 在 CIFAR-10 上的不佳表现,让我深刻理解了"没有最好的模型,只有最适合的模型"。它让我从实践上认识到了 MLP 处理图像的根本缺陷,并对即将到来的、专为图像而生的 CNN (卷积神经网络) 充满了期待。
我们正站在一座山的山脚下,MLP 帮助我们爬了上来,但要攀登更高峰,我们需要更强大的登山工具。下一站,CNN!
再次感谢 @浙大疏锦行 老师设计的这条精妙的学习路径,通过一次"失败"的实验,完美地激发了我们对新知识的渴望!