优化实验对比
优化选择经验
在深度学习圈子里,大家经常自嘲是在“炼丹”,因为很多时候各种配置看起来就像是毫无逻辑的玄学配方。
但其实,经过这十来年的发展,业界早就沉淀出了一套非常清晰的“判定决策树”(Best Practices / Baselines)。当你拿到一个新任务时,我们并不是随便抓一个模型就试,而是有一套标准的“起手式”。
我帮你把这套工业界和学术界都在用的“黄金决策框架”梳理出来,以后你再遇到新任务,直接按图索骥就行了:
第一步:看数据类型,选定“架构 (Architecture)”
架构的选择,完全取决于你的数据长什么样。
- 1. 图像/视觉任务 (CV)
- 小图/简单分类 (如 CIFAR-10, MNIST): 毫不犹豫地选 ResNet-18 或 ResNet-34 。它们不仅跑得快,而且是目前公认的完美基线(Baseline)。(VGG 已经是历史书上的古董了,通常只用来教学,实战中几乎绝迹)
- 大图/复杂任务 (如 ImageNet, 目标检测): 起步选 ResNet-50 。如果算力充足且追求极限性能,会换成现代的 ConvNeXt 或视觉大模型 ViT (Vision Transformer) 。
- 2. 文本/自然语言处理 (NLP)
- 任何文本任务: 时代变了,现在不用纠结,直接上 Transformer 架构系列。小任务用 BERT 家族(RoBERTa),大任务直接调大语言模型(LLM)的 API 或微调开源的 LLaMA/Qwen。以前的 RNN/LSTM 已经基本退役。
- 3. 表格/结构化数据 (如 Excel 里的金融数据、房价预测)
- 反直觉的真相: 深度学习在表格数据上打不过传统的机器学习!直接用 XGBoost、LightGBM 或 Random Forest(随机森林) 。它们训练只要几秒钟,效果却常常秒杀花好几天训练的神经网络。
第二步:看模型类型,选定“优化器 (Optimizer)”
优化器的选择,就像是选车子的变速箱,主要看你的模型是什么体质:
- 自动挡:AdamW
- 适用场景: Transformer 架构(NLP 任务)、ViT、复杂的生成模型(GAN/扩散模型),以及你想在一天之内快速验证代码对不对的时候。
- 判定理由: 它自带自适应学习率调整,极其省心,收敛极快。对超参数(学习率大小)不敏感,你随便给个
1e-3或1e-4,它都能稳稳地跑起来。
- 手动挡:SGD + Momentum
- 适用场景: 纯粹的卷积神经网络(CNN,如 ResNet),以及你想在比赛中刷出极限最高分的时候。
- 判定理由: 它像手动挡一样难开(对初始学习率极其敏感,通常要从
0.1这种大数值试起),前期还会疯狂震荡。但只要配合好学习率调度器(LR Scheduler),它最终能找到比 AdamW 更平坦、泛化能力更强的“全局最优解”。
第三步:看训练周期,选定“学习率调度 (LR Scheduler)”
决定了优化器,还要决定怎么踩油门和刹车:
- MultiStepLR (阶梯下降):
- 何时用: 当你使用 SGD 训练 CNN,且你明确知道要跑多少轮(比如经典的跑 100 轮,在 60 和 80 轮下降)时。这是打比赛刷分的最稳妥组合。
- CosineAnnealingLR (余弦退火):
- 何时用: 当你使用 AdamW 时,或者你懒得去猜要在第几轮切分阶梯时。它是一条平滑下降的曲线,现在被视为深度学习中最万能的“默认调度器”。
- Warmup (预热):
- 何时用: 训练极其庞大、极其深的模型(特别是 Transformer)时。一开始学习率极小,慢慢增加到正常值,然后再衰减,防止模型在刚初始化的脆弱期直接崩溃。
MNIST与LeNet-5
本小节将介绍一个非常经典的深度学习入门项目。
MNIST是一个非常著名的手写数字图片库,包含了数万张、内容为0到9的数字,每张图片都是28x28像素的灰度图:
- black: 0.0
- white: 1.0
每一张图片都被label(标注)了其对应的 0-9之间的某个数字。换句话说,这是一个非常适合监督学习的数据集。一开始,这个数据集也的确主要用于监督学习研究,比如:
- 支持向量机 SVM
- K近邻 k-NN
- 逻辑回归
- 多层感知机 MLP
Yann LeCun是谁?今天学习AI的人应该没有不知道的。LeCun的团队是世界上最早系统性使用卷积神经网络(CNN)在MNIST上进行实验的人。在1990年代初期,LeCun在贝尔实验室完成了LeNet-1/LeNet-4,应用于手写数字识别和支票。而在1998年的经典论文《Gradient-based learning applied to document recognition》中,LeCun提出了LeNet-5架构,并在MNIST上进行了完整实验。CNN第一次作为一个端到端的系统,在MNIST上展示出了优异的性能。这篇论文的名字反映了其野心:不仅仅是数字,还包括更大范围的应用场景,比如表单处理、OCR等。而当时的主流方法并不依赖梯度下降,因此论文的标题叫做Gradient-Based Learning,LeCun想突出的就是:神经网络可以通过误差反向传播和梯度下降来训练。
此外,这篇论文当时发表于Proceedings of the IEEE,属于综述性质的长文,其定位是系统性介绍基于梯度的方法,其意义不仅仅是一份实验报告,而是要给整个领域提供一种通用框架。
如今,MNIST项目已经成为深度学习领域的"hello world"。

上图是原论文中的神经网络架构图,其中:
- INPUT 输入层,32x32的灰度图
- C1 Convolutions 卷积层,6个5x5的卷积核,得到6张28x28的特征图(feature maps)
- S2 Subsampling 下采样层:现在一般叫做 pooling 池化,每个特征图降采样,得到6张14x14的特征图
- C3 Convolutions: 16 卷积核,得到16张10x10的特征图
- S4 下采样层:池化,得到16张5x5的特征图
- C5 Full Connection 全连接卷积层:虽然还是卷积核,但是因为输入只有5x5,卷积核覆盖整个区域,相当于全连接层(120个节点)
- F6 全连接层:84个节点
- OUTPUT 输出层:10个单元,对应0-9的数字分类
具体的每一个内容的详细含义,我们在后面会慢慢接触到。现在一般把LeCun的这个图画成这样:

现代版本和原始版本的主要区别有:
- input处画成28x28,省略了补零的步骤
- output处直接写dense/output10,不再强调gaussian connections,因为现代实现基本都用softmax
- 现代习惯更倾向于使用convolution/pooling/dense来标注
convolution(卷积层)的意思是从输入图像中提取局部特征,用一个小的滤波器(kernel,核)在图像上滑动,每次取一个局部区域,计算加权和,得到一个特征图。其目的是寻找某种模式,比如边缘、角点、曲线等。
pooling(池化/下采样层)的意思是缩小特征图的尺寸,并同时保留主要的信息。这样做的目的是降低计算量。常见方法包括:
- Average Pooling: 取区域平均值
- Max Pooling: 取区域最大值(现代网络更常用)
LeNet-5中的S2(把28x28缩小到14x14)和S4(把10x10缩小到5x5)用的就是平均池化。
dense(全连接层,Fully Connected Layer)的意思是把前面提取到的特征整合起来,映射到最终的分类或回归输出。其做法就是让每个神经元与前一层的所有神经元相连,进行加权求和后,传递到下一层。在LeNet-5中,F5有120个神经元,F6有84个神经元,Output有10个神经元(对应0-9的类别)。
了解了上述基本概念后,我们就可以用LeNet-5来尝试做一下MNIST实验了。现在我们不需要掌握所有的细节内容(我自己其实也才刚开始学深度学习),这个实验的目的就是让我们大概对神经网络训练内容和流程有一个整体的印象。我自己的话顺便还练习使用了一下学校的HPC资源,学费都付了,可不得好好使用使用,也省得我自己花钱买计算资源或者消耗我的电脑了。
实验相关文件如下:
- mnist.py: 数据准备,下载、加载和预处理MNIST数据集(对应notebook第一个cell)
- lenet5.py: 模型结构定义,实现LeNet-5卷积神经网络(对应notebook第二个cell)
- lenet5_train.py: 训练LeNet-5模型(对应notebook第三个cell)并绘制训练过程中的曲线(对应notebook第五个cell)
- checkpoint.py: 断点保存和恢复训练的工具(对应notebook第四个cell)
(1)mnist.py
import torch # torch是PyTorch主库
from torchvision import datasets, transforms
# torchvision是PyTorch的官方视觉工具包
# torchvision.datasets是torchvision的常见视觉数据集
# torchvision.transforms是图像预处理
# torchvision.models是预训练模型
# torchvision提供了许多经典的CNN结构和预训练模型,例如:
# AlexNet, VGG, ResNet, DenseNet, MobileNet, EfficientNet
# 这些预训练模型可以直接加载并用在迁移学习尚
from torch.utils.data import random_split, DataLoader
# random_split是用来切分训练集和验证集的
# DataLoader把数据打包成可迭代的mini-batch,训练时循环读取
DATASET_DIR = "datasets/downloads"
# 数据集的存放目录,如果本地没有,PyTorch会自动下载到这个目录
def get_loaders(batch_size: int = 128, val_fraction: float = 0.2):
"""
INPUT: MNIST的数据流水线
RETURN: 三个迭代器:
train_loader 训练用
val_loader 验证用
test_loader 测试用
参数:
batch_size: 每个mini-batch的样本数(默认128)
val_fraction: 从训练集里拿出多少比例做验证(默认20%)
"""
# 预处理:把图片变成张量(tensor)
# 标量 scalar是0维tensor,比如3.14
# 向量 vector是1维tensor,比如[1,2,3]
# 矩阵 matrix是2维tensor
# 原始 MNIST 图片的像素值范围是 0–255 (uint8)。
# transforms.ToTensor() 会把它们转成 [0,1] 的浮点张量:
# 黑色像素 = 0
# 白色像素 = 1
# 灰色像素 = 介于 0 和 1 之间的小数
#
# 转换后,每一个像素点,比如0.5,就是一个0D tensor
# 一张MNIST图片是28x28个像素,转换张量后是一个2D tensor,形状是[28,28]
# 因为MNIST是灰度图,所以还有一个通道维度channel,加上去后就变成3D tensor, [1,28,28]
#
# 用DataLoader打包成批量后(比如batch size = 128)
# 一个batch的数据会变成4D tensor,比如[128,1,28,28]
transform = transforms.ToTensor()
# 下载数据集,并指定split
full_train = datasets.MNIST(DATASET_DIR, train=True, download=True, transform=transform)
test_ds = datasets.MNIST(DATASET_DIR, train=False, download=False, transform=transform)
# Use the given val_fraction to define train_size and val_size
# 训练/验证划分(可复现的随即划分,随机种子是42)
train_size = int((1 - val_fraction) * len(full_train))
val_size = len(full_train) - train_size
generator = torch.Generator().manual_seed(42)
train_ds, val_ds = random_split(full_train, [train_size, val_size], generator=generator)
# Wrap the datasets with DataLoader, similar to train_loader
# 封装成DataLoader,批量、打乱、并行加载
train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False)
test_loader = DataLoader(test_ds, batch_size=batch_size, shuffle=False)
return train_loader, val_loader, test_loader
(2)lenet5.py
import torch
import torch.nn as nn
# torch.nn: 神经网络模块,包括各种层:卷积、线性、池化等
# torch.nn.functional: 提供底层函数接口
import torch.nn.functional as F
class LeNet5(nn.Module):
"""
定义模型类型,初始化网络结构
LeNet-5 for MNIST (28x28, 1 channel).
C1: 1 -> 6 (5x5) -> 28x28
S2 -> 6@14x14
C3: 6 -> 16 (5x5) -> 10x10
S4: -> 16@5x5
F5: 16@5x5 -> 120
F6: 120 -> 84
Out: 84 ->10
"""
def __init__(self):
super().__init__()
# 特征提取部分
self.net = nn.Sequential(
nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5, stride=1, padding=2), # 28*28->32*32-->28*28
nn.Tanh(),
nn.AvgPool2d(kernel_size=2, stride=2), # 14*14
nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5, stride=1), # 10*10
nn.Tanh(),
nn.AvgPool2d(kernel_size=2, stride=2), # 5*5
)
# 分类部分
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(in_features=16*5*5, out_features=120),
nn.Tanh(),
nn.Linear(in_features=120, out_features=84),
nn.Tanh(),
nn.Linear(in_features=84, out_features=10),
)
def forward(self, x):
"""
前向传播:
输入 x(形状 [batch, 1, 28, 28])。
先经过 卷积 + 池化 提取特征 (self.net)。
再经过 全连接层 做分类 (self.classifier)。
输出形状 [batch, 10],每个样本对应 10 个类别的 logits。
"""
return self.classifier(self.net(x))
(3)lenet5_train.py
import torch
import torch.nn as nn
import matplotlib.pyplot as plt
import os
import time
from mnist import get_loaders
from lenet5 import LeNet5
from checkpoint import Checkpointer
def train_one_epoch(model, loader, criterion, optimizer, device):
"""
Train `model` for a single epoch.
Args:
model: the neural network being trained
loader: DataLoader that yields (inputs, labels) mini-batches
criterion: l
s function (e.g., CrossEntropyLoss)
optimizer: optimizer instance (e.g., SGD/Adam)
device: torch.device("cuda") for GPUs or torch.device("cpu") for CPUs
Returns:
avg_loss: average training loss over all samples in this epoch
acc: training accuracy (%) over all samples in this epoch
"""
model.train() # enable training mode
running_loss = 0.0
correct = total = 0
for x, y in loader:
# Move the mini-batch to the target device (GPU/CPU)
x, y = x.to(device), y.to(device)
# 1) Important!!!: Clear gradients from the previous optimization step
optimizer.zero_grad()
# 2) Forward pass: compute logits/predictions
pred = model(x)
# 3) Compute loss between predictions and the true labels
loss = criterion(pred, y)
# 4) Backward propagation: compute gradients w.r.t. model parameters
loss.backward()
# 5) Update model parameters (weight, bias)
optimizer.step()
# Accumulate loss (sum over samples) for epoch-level averaging
running_loss += loss.item() * x.size(0)
# Convert logits to predicted class indices and count correct predictions
preds = pred.argmax(dim=1)
correct += (preds == y).sum().item()
total += y.size(0)
return running_loss / total, 100 * correct / total
def eval_one_epoch(model, loader, criterion, device):
"""
Evaluate `model` for a single epoch on the validation/test dataset.
Args:
model: the neural network being trained
loader: DataLoader that yields (inputs, labels) mini-batches
criterion: loss function (e.g., CrossEntropyLoss)
device: torch.device("cuda") for GPUs or torch.device("cpu") for CPUs
Returns:
avg_loss: average eval loss over all samples
acc: eval accuracy (%) over all samples in this epoch
Notes:
- We wrap the loop with `torch.no_grad()` so no gradients are tracked:
* no backward pass, no optimizer steps
"""
model.eval() # enable eval mode
running_loss = 0.0
correct = total = 0
# Disable autograd during evaluation (no grad tracking / no backward)
with torch.no_grad():
for x, y in loader:
x, y = x.to(device), y.to(device)
pred = model(x) # Forward pass only
loss = criterion(pred, y)
running_loss += loss.item() * x.size(0)
preds = pred.argmax(dim=1)
correct += (preds == y).sum().item()
total += y.size(0)
return running_loss / total, 100 * correct / total
#===================================================================================================================================
# ADD YOUR CODES HERE
# Plot and save curves for train/val loss/accuracy.
# def plot_results():
import csv
def _ensure_dir(path: str):
os.makedirs(path, exist_ok=True)
def save_history_csv(hist, outdir="plots", run_name="run"):
_ensure_dir(outdir)
csv_path = os.path.join(outdir, f"{run_name}_history.csv")
epochs = len(hist["train_loss"])
with open(csv_path, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["epoch", "train_loss", "train_acc", "val_loss", "val_acc", "epoch_time"])
for e in range(epochs):
w.writerow([
e + 1,
hist["train_loss"][e],
hist["train_acc"][e],
hist["val_loss"][e],
hist["val_acc"][e],
hist["epoch_time"][e] if e < len(hist["epoch_time"]) else 0.0
])
return csv_path
def plot_results_single(hist, outdir="plots", run_name="run"):
_ensure_dir(outdir)
epochs = list(range(1, len(hist["train_loss"]) + 1))
plt.figure()
plt.plot(epochs, hist["train_loss"], label="train loss")
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.title(f"Training Loss ({run_name})")
plt.grid(True)
plt.legend()
plt.savefig(os.path.join(outdir, f"{run_name}_train_loss.png"), bbox_inches="tight")
plt.close()
plt.figure()
plt.plot(epochs, hist["train_acc"], label="train acc")
plt.xlabel("Epoch")
plt.ylabel("Accuracy (%)")
plt.title(f"Training Accuracy ({run_name})")
plt.grid(True)
plt.legend()
plt.savefig(os.path.join(outdir, f"{run_name}_train_acc.png"), bbox_inches="tight")
plt.close()
plt.figure()
plt.plot(epochs, hist["val_loss"], label="val loss")
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.title(f"Validation Loss ({run_name})")
plt.grid(True)
plt.legend()
plt.savefig(os.path.join(outdir, f"{run_name}_val_loss.png"), bbox_inches="tight")
plt.close()
plt.figure()
plt.plot(epochs, hist["val_acc"], label="val acc")
plt.xlabel("Epoch")
plt.ylabel("Accuracy (%)")
plt.title(f"Validation Accuracy ({run_name})")
plt.grid(True)
plt.legend()
plt.savefig(os.path.join(outdir, f"{run_name}_val_acc.png"), bbox_inches="tight")
plt.close()
def plot_results_multi(all_histories, all_labels, outdir="plots"):
_ensure_dir(outdir)
metrics = [
("train_loss", "Training Loss", "Loss"),
("train_acc", "Training Accuracy", "Accuracy (%)"),
("val_loss", "Validation Loss", "Loss"),
("val_acc", "Validation Accuracy", "Accuracy (%)"),
]
for key, title, ylabel in metrics:
plt.figure()
for hist, label in zip(all_histories, all_labels):
epochs = list(range(1, len(hist[key]) + 1))
plt.plot(epochs, hist[key], label=label)
plt.xlabel("Epoch")
plt.ylabel(ylabel)
plt.title(title)
plt.grid(True)
plt.legend()
plt.savefig(os.path.join(outdir, f"{key}.png"), bbox_inches="tight")
plt.close()
def main(ckpt, device, seed, lr, epochs, batch_size, run_name=None):
if run_name is None:
run_name = f"lenet5_lr{lr}_bs{batch_size}"
train_loader, val_loader, test_loader = get_loaders(batch_size=batch_size)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
model = LeNet5().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=lr)
hist = {"train_loss": [], "train_acc": [], "val_loss": [], "val_acc": [], "epoch_time": []}
start_epoch = 0
try:
out = ckpt.resume(model, optimizer, hist)
if isinstance(out, tuple) and len(out) == 2:
start_epoch, hist = out
elif isinstance(out, int):
start_epoch = out
else:
start_epoch = 0
except (FileNotFoundError, IOError, AttributeError):
start_epoch = 0
for epoch in range(start_epoch + 1, epochs + 1):
t0 = time.perf_counter()
tr_loss, tr_acc = train_one_epoch(model, train_loader, criterion, optimizer, device)
va_loss, va_acc = eval_one_epoch(model, val_loader, criterion, device)
elapsed = time.perf_counter() - t0
hist["train_loss"].append(tr_loss)
hist["train_acc"].append(tr_acc)
hist["val_loss"].append(va_loss)
hist["val_acc"].append(va_acc)
hist["epoch_time"].append(elapsed)
print(f"[{run_name}] Epoch {epoch}/{epochs} | "
f"Train: loss={tr_loss:.4f}, acc={tr_acc:.2f}% | "
f"Val: loss={va_loss:.4f}, acc={va_acc:.2f}% | "
f"time: {elapsed:.2f}s")
try:
ckpt.save(epoch, model, optimizer, hist,
config={"lr": lr, "batch_size": batch_size, "run_name": run_name})
except Exception as e:
print(f"[{run_name}] Warning: checkpoint save failed: {e}")
t1 = time.perf_counter()
te_loss, te_acc = eval_one_epoch(model, test_loader, criterion, device)
elapsed1 = time.perf_counter() - t1
print(f"[{run_name}] Test: loss={te_loss:.4f}, acc={te_acc:.2f}% | time: {elapsed1:.2f}s")
plot_results_single(hist, outdir="plots", run_name=run_name)
save_history_csv(hist, outdir="plots", run_name=run_name)
return hist
if __name__ == "__main__":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
learning_rates = [1e-1, 1e-2, 1e-3]
batch_sizes = [64, 128]
epochs = 100
seed = 42
all_histories = []
all_labels = []
for lr in learning_rates:
for bs in batch_sizes:
run_name = f"lenet5_lr{lr}_bs{bs}"
ckpt_dir = os.path.join("checkpoints", run_name)
os.makedirs(ckpt_dir, exist_ok=True)
ckpt_path = os.path.join(ckpt_dir, "ckpt.pth")
ckpt = Checkpointer(path=ckpt_path, device=device)
hist = main(
ckpt=ckpt,
device=device,
seed=seed,
lr=lr,
epochs=epochs,
batch_size=bs,
run_name=run_name
)
all_histories.append(hist)
all_labels.append(f"lr={lr}, bs={bs}")
plot_results_multi(all_histories, all_labels, outdir="plots")
(4)checkpoint.py
import os, torch
from pathlib import Path
from dataclasses import dataclass
@dataclass
class Checkpointer:
path: str
device: torch.device
def save(self, epoch: int, model, optimizer=None, hist=None, config=None):
"""
Atomically save a checkpoint to `path`.
This reduces the risk of a half-written file if the job is killed mid-save.
"""
payload = {
"epoch": epoch,
"model_state_dict": model.state_dict(),
"cpu_rng_state": torch.get_rng_state(),
}
if torch.cuda.is_available():
payload["cuda_rng_state_all"] = torch.cuda.get_rng_state_all()
if optimizer is not None:
payload["optimizer_state_dict"] = optimizer.state_dict()
if hist is not None:
payload["hist"] = hist
if config is not None:
payload["config"] = config
tmp = self.path + ".tmp"
torch.save(payload, tmp)
os.replace(tmp, self.path)
def resume(self, model, optimizer=None, hist=None):
"""
Resume training state from `ckpt_path` if it exists.
Restores:
- model parameters
- optimizer state (if provided)
- RNG states (CPU + all CUDA devices, if available)
- last finished epoch
- training/validation history
Returns:
start_epoch (int): last finished epoch number (0 if none)
hist (dict): metric history dict with lists
"""
p = Path(self.path)
start_epoch = 0
hist = hist or {"train_loss": [], "train_acc": [], "val_loss": [], "val_acc": [], "epoch_time": [] }
if not p.exists():
print(f"[resume] No checkpoint at {p}; starting from scratch.")
print("-------------------------------------------------------------------")
return start_epoch, hist
ckpt = torch.load(p, map_location="cpu", weights_only=False)
model.load_state_dict(ckpt["model_state_dict"])
if optimizer is not None and "optimizer_state_dict" in ckpt:
optimizer.load_state_dict(ckpt["optimizer_state_dict"])
if "cpu_rng_state" in ckpt:
torch.set_rng_state(ckpt["cpu_rng_state"])
if torch.cuda.is_available() and "cuda_rng_state_all" in ckpt:
torch.cuda.set_rng_state_all(ckpt["cuda_rng_state_all"])
start_epoch = ckpt.get("epoch", 0)
if "hist" in ckpt and ckpt["hist"] is not None:
hist = ckpt["hist"]
print(f"[resume] Loaded {p}: last finished epoch = {start_epoch}")
print("-------------------------------------------------------------------")
return start_epoch, hist
def remove(self):
"""
Delete checkpoint file if it exists (fresh start).
"""
p = Path(self.path)
if p.exists():
p.unlink()
print(f"Removed checkpoint: {p.resolve()}")
else:
print(f"No checkpoint found at: {p.resolve()}")
经过train后,可以得到如下四张图:

简单来说,整个实验的具体流程如下:
- 任务设定 : 使用经典的 LeNet-5 卷积神经网络架构,对包含6万张训练图片的 MNIST 数据集进行分类训练。
- 实验设计 : 设计了一个包含6种不同超参数组合的网格搜索实验,变量为学习率 (
0.1,0.01,0.001) 和批次大小 (64,128)。 - 执行与评估 : 将6组实验任务分别进行100轮(epoch)的训练。在每一轮训练后,记录模型在训练集和验证集上的准确率(accuracy)与损失(loss),以监控模型性能。
从最终结果的四张图中能清晰地看到不同超参数组合对模型训练过程和最终性能的显著影响。比如说,可以发现:
学习率是决定性因素 :
- 学习率设置为
0.1时(蓝色和橙色曲线),模型表现 最佳 。它能以最快的速度收敛,并在验证集上达到了约99%的最高准确率。 - 学习率设置为
0.01时(绿色和红色曲线),模型表现 良好 ,但收敛速度和最终性能略逊于前者。 - 学习率设置为
0.001时(紫色和棕色曲线),模型表现 较差 。学习率过低导致模型收敛极其缓慢,在100轮训练后远未达到最优状态。
批次大小影响较小 :
- 在高效的学习率(0.1 和 0.01)下,批次大小为
128的曲线比64更平滑一些,但最终性能相差无几。
也就是说,通过上述实验,我们成功地验证了经典的LeNet-5架构在选择合适的超参数后,在MNIST任务上可以达到非常高的识别准确率。这个例子作为一个先启引例,让我们了解什么是神经网络架构,如何把该架构转换为python代码,并使用PyTorch进行训练测验,以及如何去设计实验、测试架构等。在后续章节中,我们会讨论更加复杂的架构、更加复杂的实验,但是其核心方法与上述过程并无太大的差异。即,一般来说,一个实验包含如下步骤和内容:
- 明确问题,如这个问题是图像分类、目标检测,还是什么别的问题。
- 准备数据,进行数据预处理,并将数据分为训练集、验证集、测试集等;其中验证集将被用来调优,非常关键;测试集则是用来最终的评估。
- 选择架构:根据任务的复杂度和数据特点,选择一个合适的神经网络架构,比如LeNet5, ResNet, Transformer等。
- 将选择的架构,利用PyTorch或TensorFlow这种成熟的深度学习框架,转换为具体的代码;代码中定义好每一层和数据的前向传播路径等。
- 定义训练流程,包括损失函数、优化器,实现训练循环等。
- 设计实验,系统性寻找最佳超参数组合(比如学习率、批次大小、网络深度等)。一般用网格搜索。
- 执行训练。小型任务可以用自己的电脑完成,大型任务需要在HPC集群计算资源上完成,并使用checkpoint机制来保存进度。在训练过程中,持续在验证集上监控模型的性能,以判断模型是否在改进、是否出现过拟合,并最终选择在验证集上表现最佳的模型。
- 最后分析结果并得出结论。一般会将训练过程中的各项指标,如损失、准确率曲线等,进行可视化。然后对比不同实验的结果图表,分析超参数如何影响模型性能,找出最优配置,并解释其背后的原因。记得进行泛化测试,即找一个模型从未见过的测试集,或者和测试集显著不同的现实案例,来得到一个最终的、客观的性能报告。
CIFAR-10与不同优化器对比
在入门章节,我们在MNIST手写数据集上进行了LeNet-5实验,了解了入门深度学习训练的过程。在学习优化器后,我们今天做一个新的实验,在CIFAR-10数据集上测试不同优化器的训练效果。

CIFAR-10相比于MNIST,类别数量并没有增加,还是10个类别,但是识别对象的复杂度明显有了一个巨大的提升:图片采用32x32像素的RGB三通道色彩图像,背景嘈杂,图像分辨率低。
我们使用如下三个架构来在MNIST和CIFAR10上进行实验:
- A smiple MLP on MNIST
- A simple CNN on CIFAR-10
- VGG-13 on CIFAR-10
简单的MLP:(针对MNIST任务)
- 输入1张1x28x28的图片,1就是1个通道(灰度图)
- Flatten把二维图片压平成一个1x784的一维向量(也就是把像素方阵的所有像素排成一队)
- 接着通过第一个隐藏层Gemm:这是神经网络的第一个全连接层,它接收长度为784的输入向量,并通过一个1024x784的权重矩阵B,将其线性变换为一个长度为1024的隐藏特征向量,这是网络从原始像素中学习抽象特征的开始
- Relu:紧跟其后的激活函数,用于增加非线性,让网络能学习更复杂的关系
- 接着跟着第二个隐藏层
- 最后连接到最后一个Gemm,这是输出层,它将来自第二个隐藏层的1024的高级特征,把他们映射为10个输出值

三层CNN:(针对CIFAR任务)
- 输入为1张RGB彩色3通道的32x32像素的图片
- 数据从左到右依次经过三个相同的处理单元:Conv 卷积层 -> Relu 激活层 -> MaxPool 最大池化层
- Flatten 展平层
- Gemm 全连接层
- 最终输出一个1x10的向量,对应10个类别的可能性,分数最高的就是预测结果

VGG13:(针对CIFAR任务)
- 下图所示是VGG16,我们在任务中使用的是同一家族谱系的VGG13,并且针对CIFAR进行了简化。VGG是牛津大学视觉几何研究组(Visual Geometry Group )发明的,并以此为名。VGG13有10个卷积层+3个全连接层;VGG16有13个卷积层+3个全连接层。
- VGG13/VGG16在卷积核尺寸上有着严格的设定:统一使用3x3尺寸的卷积核

具体的训练代码可以参见REPO:
项目报告参见:
结果如下:
| Optimizer | MLP on MNIST | CNN on CIFAR-10 | VGG13 on CIFAR-10 |
|---|---|---|---|
| SGD | 91.82% | 50.64% | 52.68% |
| AdaGrad | 97.77% | 69.98% | 68.11% |
| RMSProp | 98.29% | 68.65% | 73.61% |
| Adam | 98.32% | 70.15% | 74.61% |
| Polyak_0.9 | 97.64% | 73.00% | 65.75% |
| Nesterov_0.9 | 97.62% | 72.91% | 66.02% |
其中Adam整体表现最好,Polyak和Nesterov动量法在CNN on CIFAR-10任务上优于Adam。
ImageNet-50与不同初始化和学习率调度器实验
数据集和DNN
ImageNet-50 (Customized)
ImageNet是一个用于图像分类任务的、相对较小的ImageNet数据集子集。ImageNet原始版本包含超过1400万张图像,覆盖了大约22000个类别,是推动深度学习在CV领域发展的关键数据集。
本Project筛选了来自于ImageNet的50个目标类别(来自ILSVRC-2012)。在preprocessing步骤,每个image都被转换为RGB格式,并resize到224 x 224的大小,来保证输入数据的一致性。每个类别大约有1300张照片,包含丰富的动物、植物和其他日常生活中耳熟能详的东西。
VGG-19
VGG-19是VGG(Visual Geometry Group)系列的卷积神经网络架构,以小尺寸的卷积核(3x3)和深层结构而闻名。
本Project部署了一个改进版本的VGG-19(由He et al改进的),与原版VGG-19相比,该改进版本在早期阶段采用了更大的convolutional layer,并增加了model的深度,来提升在ImageNet训练集上的efficiency和performance。
ResNet-34
ResNet-34是残差网络(Residual Network, ResNet) 架构中的一个特定版本,34代表这个特定网络结构中可学习的权重层(含卷积层、全连接层)的总数。
残差学习引入了残差块(Residual Block) 的概念,有时候也被称为捷径连接(Skip Connection)。每个残差块包含两个3x3的卷积层,然后是batch normalization和ReLU激活。残差块允许信息跳过一些层直接传递,从而缓解了DNN中梯度消失和网络退化的问题,这使得网络可以构建得非常深而保持训练效果。

上图展示了三种DNN网络架构的对比:
| 左侧 | 中间 | 右侧 |
|---|---|---|
| VGG-19 | 普通34层 | ResNet-34 |
| CNN | CNN | CNN |
| 无残差 | 无残差 | 有残差 |
.
项目目标与结果分析
分析研究weight initializatio methods和learning rate schedulers如何影响DNN model training。
Weight initialization决定了model的参数在训练开始前是如何设置的,其将影响model的:
- 是否能收敛(converge)
- 收敛速度(convergence speed)
- 稳定性(stability)
Learning rate schedulers决定了梯度更新(gradient updates)的步长(step size),影响cost function如何适应training step和convergence performance。
Learning rate scheduler将严重影响:
- training results:loss function
- test results:test accuacy
Weight Initialization on VGG-19
在DNN训练中设置合适的初始值是一个巨大的挑战。不合适的初始值会导致梯度爆炸、梯度消失、收敛缓慢、degraded accuracy等问题。
本实验的目标是对比下列两种initialization strategies在VGG-19上的表现:
- Xavier Initialization
- Kaiming Initialization
以及下面两种distributions:
- Uniform Distribution
- Normal Distribution
在本次实验中,我们使用SGD optimizer来突出观测不同initialziation的表现。
因此实验应当由四组对照:
- Xavier Initialization + Uniform Distribution
- Xavier Initialization + Normal Distribution
- Kaiming Initialization + Uniform Distribution
- Kaiming Initialization + Normal Distribution
产出的结果包括:
- Training loss vs. epochs
- Training accuracy vs. epochs
- Validation loss vs. epochs
- Validation accuracy vs. epochs
- Final test accuracy and test loss
LR Scheduling on ResNet-34
在DNN训练中的另一大挑战就是设置合适的learning rate schedule。不合适的设置会导致训练不稳定、收敛过慢、收敛到局部最优等。
在本次实验中,我们将使用ResNet model来探索不同学习率调度对训练的影响:
(1)Constant Learning Rate
恒定学习率,即学习率在整个训练过程中保持不变(不进行任何调度或调整)。这是最简单的基准方法。
(2)Exponential Learning Rate
Gradually reducing the LR from max to min according to some fixed schedule,即根据一些固定的调度表,逐步将学习率从最大值减小到最小值。
Exponential Learning Rate (指数学习率)是上述所描述的一种具体方法数,即让学习率按指数函数或几何级数衰减。
(3)Multistep Learning Rate
多步学习率也是固定调度表的一种,就是在预定的训练周期(epochs)点上,突然下降一个固定的因子(例如每隔 30 个周期降为原来的 0.1 倍)。
(4)Cyclic Learning Rate
Having multiple cycles of LR reduction from max to min,即具有从最大值到最小值多次循环衰减的学习率。
循环学习率是让学习率在一个预定的边界(最大值和最小值)之间周期性变化(通常呈三角形或正弦波)。
(5)Cosine Annealing Warm Restarts
带温暖重启的余弦退火也是上述从最大值到最小值多次循环衰减的学习率,其遵循余弦函数的形状衰减。当学习率达到最小值时,它会“温暖重启”并跳回最大值,然后再次开始余弦衰减。
在ResNet-34上,我们使用Adam优化器。初始化权重采用Kaiming Uniform Distribution(也是PyTorch默认的初始化方法)。
下面列出四种非常数的学习率调度器的具体参数设置要求:
Exponential (指数衰减):
- 参数设置是 \(\text{Gamma} = 0.95\)。
- 作用机制是学习率在每个周期(或步数)结束后,都会乘以衰减因子 \(0.95\),实现学习率的平滑下降。
Multistep (多步衰减):
- 参数设置是 \(\text{Milestones} = [40, 60, 80]\) 和 \(\text{Gamma} = 0.1\)。
- 作用机制是学习率会在预先设定的第 \(40\)、\(60\) 和 \(80\) 个训练周期时骤降。
- 在每个里程碑点,当前学习率将乘以 \(0.1\)。
Cyclic (循环学习率):
- 参数设置是 \(\text{base\_lr} = 5\text{e-}4\) (\(\text{最小学习率}\)),\(\text{max\_lr} = 5\text{e-}3\) (\(\text{最大学习率}\)),\(\text{step\_size\_up} = 2380\),以及 \(\text{mode} = \text{'triangular'}\)。
- 作用机制是学习率将在 \(0.0005\) 和 \(0.005\) 之间周期性地循环变化。
- 一个完整的上升或下降半周期需要 \(2380\) 步(迭代)。
- 循环波形采用基础的 三角形模式 。
Cosine Annealing Warm Restarts (带温暖重启的余弦退火):
- 参数设置是 \(\text{T\_0} = 10\) 和 \(\text{T\_mult} = 3\)。
- 作用机制是学习率将遵循余弦函数进行衰减,并周期性地重启。
- 初始周期长度 (\(\text{T\_0}\)) 为 \(10\) 个周期。
- 周期乘法因子 (\(\text{T\_mult}\)) 为 \(3\)。每次学习率重启后,下一个衰减周期的时间长度将是上一次的 \(3\) 倍。