Week 10:深度学习小项目——MLP / CNN baseline、checkpoint、loss 曲线与评估
回到总路线:USTC 统计 AI / 量化 20 周成长计划
本周目标对应计划中的 Week 10:在 MNIST / CIFAR10 / 表格数据上训练一个小模型,记录 loss 曲线,保存 checkpoint,做验证集评估。
本教程默认你使用 CachyOS + fish shell + VS Code + uv + Python + Git。所有终端命令默认是 fish。为了 CPU-first、可复现、无需下载大数据,本周使用 scikit-learn 自带的 digits 数据集:8×8 手写数字图片,适合练 MLP 和 CNN baseline。
0. 本周详细教学:语法、规范、验收
本节不是追加在尾部的复习,而是本周正文的入口。先读这里,再做后面的命令和项目。
0.1 本周真正要学会什么
| 维度 | 要求 |
|---|---|
| 知识点 | MLP/CNN、checkpoint、loss 曲线、evaluate.py |
| 代码语法 | 能从空文件写出本周核心脚本,而不是只复制运行 |
| 程序规范 | 函数拆分、路径清楚、输入输出明确、错误能解释 |
| 交付物 | projects/pytorch-baseline/ |
| 验收方式 | 从 fish 终端运行命令,得到可复查的文件或指标 |
0.2 代码语法精讲
下面的代码不是最终答案,而是本周必须理解的最小骨架:
checkpoint = {
"model_state": model.state_dict(),
"epoch": epoch,
"valid_loss": valid_loss,
}
torch.save(checkpoint, "outputs/best.pt")
loaded = torch.load("outputs/best.pt", map_location="cpu")
model.load_state_dict(loaded["model_state"])
读代码时按四步检查:输入从哪里来;中间变量的类型和 shape 是什么;函数或脚本输出什么;哪些错误应该显式报出来。
0.3 本周程序规范
- 所有路径用相对路径或 `pathlib.Path`,不要写死 `/home/miku/...`。
- 核心逻辑进 `src/`,notebook 只做探索和解释。
- 每个脚本能从 fish 终端运行,并在 README 写出命令。
- 输出必须落盘到 `reports/`、`figures/` 或 `outputs/`,不能只在屏幕上看。
0.4 本周练习分层
| 层级 | 任务 | 不合格表现 | 合格验收 |
|---|---|---|---|
| 最小练习 | 手写上面的最小骨架 | 只在 notebook 里运行 | 终端运行成功 |
| 标准练习 | 把逻辑拆成函数/模块 | 一个大脚本从头写到尾 | 至少 2 个函数,职责清楚 |
| 项目练习 | 生成本周交付物 projects/pytorch-baseline/ | 只有屏幕输出 | 文件落盘,可复查 |
| 复盘练习 | 写 3 个错误和修复 | 只写“已解决” | 写清报错、原因、修复、预防 |
0.5 本周和主线的连接
- 回到总计划:USTC AI / Quant 练习手册
- 查详细练习索引:技术练习详解
- 查质量评分:最终质量门槛
1. 本周目标
完成后你会得到一个小而完整的深度学习项目:
train.py支持 MLP 和 CNN 两种 baseline。- 训练过程保存 best checkpoint。
- 训练过程记录
train_loss、valid_loss、valid_acc。 - 自动生成 loss 曲线图。
evaluate.py可以从 checkpoint 重新加载模型并评估。- 项目结构接近以后比赛和科研项目的最小形态。
本周不追求复杂模型,重点是工程闭环:能训练、能保存、能复评、能解释。
2. 前置条件
你需要已经完成 Week 09,至少理解:
- Tensor 的 shape 和 dtype。
Dataset/DataLoader。nn.Module。model.train()和model.eval()的区别。loss.backward()与optimizer.step()。
确认工具:
python --version
uv --version
git --version
3. 建立项目
mkdir -p ~/Code/ustc-ai/week10-deep-learning-mini-project
cd ~/Code/ustc-ai/week10-deep-learning-mini-project
uv init --name week10-deep-learning-mini-project
uv venv
source .venv/bin/activate.fish
uv add torch scikit-learn matplotlib pandas
code .
解释:
| 命令 | 作用 |
|---|---|
uv init |
初始化 Python 项目 |
uv venv |
创建隔离虚拟环境 |
source .venv/bin/activate.fish |
用 fish 激活环境 |
uv add torch ... |
安装 PyTorch、数据集、画图和表格依赖 |
code . |
用 VS Code 打开项目 |
4. 文件布局
创建目录:
mkdir -p src configs checkpoints reports figures
推荐布局:
week10-deep-learning-mini-project/
├── configs/
│ └── digits.toml
├── src/
│ ├── train.py
│ └── evaluate.py
├── checkpoints/
│ └── best_mlp.pt
├── reports/
│ └── metrics_mlp.csv
├── figures/
│ └── loss_curve_mlp.png
├── pyproject.toml
└── README.md
用途说明:
| 路径 | 用途 |
|---|---|
configs/ |
放实验配置,避免超参数散落在代码里 |
src/train.py |
训练入口 |
src/evaluate.py |
独立评估入口 |
checkpoints/ |
保存模型权重和必要元信息 |
reports/ |
保存指标 CSV |
figures/ |
保存 loss 曲线 |
README.md |
给别人说明如何复现 |
5. 写配置文件
创建 configs/digits.toml:
[train] epochs = 20 batch_size = 64 learning_rate = 0.001 seed = 42 valid_size = 0.2
[model] hidden_dim = 128
为什么要有配置文件:
- 你以后会频繁改 epoch、学习率、batch size。
- 配置和代码分开,实验记录更清楚。
- 复现实验时,别人不需要读完整代码才能知道你怎么训练的。
6. 训练脚本:支持 MLP 和 CNN
创建 src/train.py:
from __future__ import annotationsimport argparse import csv import random import tomllib from pathlib import Path
import matplotlib.pyplot as plt import numpy as np import torch from sklearn.datasets import load_digits from sklearn.model_selection import train_test_split from torch import nn from torch.utils.data import DataLoader, Dataset
class DigitsDataset(Dataset): def init(self, images: np.ndarray, labels: np.ndarray, model_type: str) -> None: images = images.astype(“float32”) / 16.0 if model_type == “mlp”: images = images.reshape(len(images), -1) else: images = images[:, None, :, :] self.images = torch.tensor(images, dtype=torch.float32) self.labels = torch.tensor(labels, dtype=torch.long)
plain def len(self) -> int: return len(self.labels)
plain def getitem(self, index: int) -> tuple[torch.Tensor, torch.Tensor]: return self.images[index], self.labels[index]
class MLP(nn.Module): def init(self, hidden_dim: int, num_classes: int = 10) -> None: super().init() self.net = nn.Sequential( nn.Linear(64, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, num_classes), )
plain def forward(self, x: torch.Tensor) -> torch.Tensor: return self.net(x)
class SmallCNN(nn.Module): def init(self, num_classes: int = 10) -> None: super().init() self.features = nn.Sequential( nn.Conv2d(1, 16, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(32 * 2 * 2, 64), nn.ReLU(), nn.Linear(64, num_classes), )
plain def forward(self, x: torch.Tensor) -> torch.Tensor: return self.classifier(self.features(x))
def set_seed(seed: int) -> None: random.seed(seed) np.random.seed(seed) torch.manual_seed(seed)
def load_config(path: Path) -> dict: with path.open(“rb”) as file: return tomllib.load(file)
def build_loaders(config: dict, model_type: str) -> tuple[DataLoader, DataLoader]: digits = load_digits() x_train, x_valid, y_train, y_valid = train_test_split( digits.images, digits.target, test_size=config[“train”][“valid_size”], stratify=digits.target, random_state=config[“train”][“seed”], ) train_dataset = DigitsDataset(x_train, y_train, model_type) valid_dataset = DigitsDataset(x_valid, y_valid, model_type) train_loader = DataLoader( train_dataset, batch_size=config[“train”][“batch_size”], shuffle=True, ) valid_loader = DataLoader( valid_dataset, batch_size=config[“train”][“batch_size”], shuffle=False, ) return train_loader, valid_loader
def build_model(config: dict, model_type: str) -> nn.Module: if model_type == “mlp”: return MLP(hidden_dim=config[“model”][“hidden_dim”]) if model_type == “cnn”: return SmallCNN() raise ValueError(f”unknown model type: {model_type}”)
def run_epoch( model: nn.Module, loader: DataLoader, criterion: nn.Module, optimizer: torch.optim.Optimizer | None, device: torch.device, ) -> tuple[float, float]: is_train = optimizer is not None model.train(is_train) total_loss = 0.0 total_correct = 0 total_examples = 0
plain context = torch.enable_grad() if is_train else torch.no_grad() with context: for images, labels in loader: images = images.to(device) labels = labels.to(device)
plain logits = model(images) loss = criterion(logits, labels)
plain if is_train: optimizer.zero_grad() loss.backward() optimizer.step()
total_loss += loss.item() * len(labels) total_correct += (logits.argmax(dim=1) == labels).sum().item() total_examples += len(labels) return total_loss / total_examples, total_correct / total_examplesdef save_metrics(path: Path, rows: list[dict[str, float]]) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open(“w”, newline="") as file: writer = csv.DictWriter(file, fieldnames=list(rows[0].keys())) writer.writeheader() writer.writerows(rows)
def save_loss_curve(path: Path, rows: list[dict[str, float]]) -> None: path.parent.mkdir(parents=True, exist_ok=True) epochs = [row[“epoch”] for row in rows] train_loss = [row[“train_loss”] for row in rows] valid_loss = [row[“valid_loss”] for row in rows]
plain plt.figure(figsize=(7, 4)) plt.plot(epochs, train_loss, label=“train_loss”) plt.plot(epochs, valid_loss, label=“valid_loss”) plt.xlabel(“epoch”) plt.ylabel(“loss”) plt.title(“Digits baseline loss curve”) plt.legend() plt.tight_layout() plt.savefig(path, dpi=150) plt.close()
def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument(“—config”, type=Path, default=Path(“configs/digits.toml”)) parser.add_argument(“—model”, choices=[“mlp”, “cnn”], default=“mlp”) parser.add_argument(“—epochs”, type=int, default=None) return parser.parse_args()
def main() -> None: args = parse_args() config = load_config(args.config) if args.epochs is not None: config[“train”][“epochs”] = args.epochs
plain set_seed(config[“train”][“seed”]) device = torch.device(“cpu”) train_loader, valid_loader = build_loaders(config, args.model) model = build_model(config, args.model).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=config[“train”][“learning_rate”])
plain best_acc = 0.0 rows: list[dict[str, float]] = [] checkpoint_path = Path(“checkpoints”) / f”best_{args.model}.pt”
plain for epoch in range(1, config[“train”][“epochs”] + 1): train_loss, train_acc = run_epoch(model, train_loader, criterion, optimizer, device) valid_loss, valid_acc = run_epoch(model, valid_loader, criterion, None, device) row = { “epoch”: epoch, “train_loss”: train_loss, “train_acc”: train_acc, “valid_loss”: valid_loss, “valid_acc”: valid_acc, } rows.append(row) print( f”epoch={epoch
} ” f”train_loss={train_loss:.4f} train_acc={train_acc:.3f} ” f”valid_loss={valid_loss:.4f} valid_acc={valid_acc:.3f}” )if valid_acc > best_acc: best_acc = valid_acc checkpoint_path.parent.mkdir(parents=True, exist_ok=True) torch.save( { "model_type": args.model, "model_state_dict": model.state_dict(), "config": config, "best_valid_acc": best_acc, }, checkpoint_path, ) save_metrics(Path("reports") / f"metrics_{args.model}.csv", rows) save_loss_curve(Path("figures") / f"loss_curve_{args.model}.png", rows) print(f"best_valid_acc={best_acc:.3f}") print(f"checkpoint={checkpoint_path}")
if name == “main”: main()
运行 MLP baseline:
source .venv/bin/activate.fish
python src/train.py --model mlp --epochs 5
再运行 CNN baseline:
source .venv/bin/activate.fish
python src/train.py --model cnn --epochs 5
MLP 和 CNN 都可以在 CPU 上很快跑完。digits 数据集很小,所以这是练工程流程的项目,不是刷榜项目。
7. 评估脚本:从 checkpoint 复现结果
训练脚本能打印验证集指标,但项目里还需要独立评估入口。这样别人拿到你的 checkpoint 后,不需要重新训练。
创建 src/evaluate.py:
from __future__ import annotationsimport argparse from pathlib import Path
import numpy as np import torch from sklearn.datasets import load_digits from sklearn.metrics import accuracy_score, classification_report from sklearn.model_selection import train_test_split from torch import nn from torch.utils.data import DataLoader, Dataset
class DigitsDataset(Dataset): def init(self, images: np.ndarray, labels: np.ndarray, model_type: str) -> None: images = images.astype(“float32”) / 16.0 if model_type == “mlp”: images = images.reshape(len(images), -1) else: images = images[:, None, :, :] self.images = torch.tensor(images, dtype=torch.float32) self.labels = torch.tensor(labels, dtype=torch.long)
plain def len(self) -> int: return len(self.labels)
plain def getitem(self, index: int) -> tuple[torch.Tensor, torch.Tensor]: return self.images[index], self.labels[index]
class MLP(nn.Module): def init(self, hidden_dim: int, num_classes: int = 10) -> None: super().init() self.net = nn.Sequential( nn.Linear(64, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, num_classes), )
plain def forward(self, x: torch.Tensor) -> torch.Tensor: return self.net(x)
class SmallCNN(nn.Module): def init(self, num_classes: int = 10) -> None: super().init() self.features = nn.Sequential( nn.Conv2d(1, 16, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(32 * 2 * 2, 64), nn.ReLU(), nn.Linear(64, num_classes), )
plain def forward(self, x: torch.Tensor) -> torch.Tensor: return self.classifier(self.features(x))
def build_model(model_type: str, config: dict) -> nn.Module: if model_type == “mlp”: return MLP(hidden_dim=config[“model”][“hidden_dim”]) if model_type == “cnn”: return SmallCNN() raise ValueError(f”unknown model type: {model_type}”)
def build_valid_loader(config: dict, model_type: str) -> DataLoader: digits = load_digits() _, x_valid, _, y_valid = train_test_split( digits.images, digits.target, test_size=config[“train”][“valid_size”], stratify=digits.target, random_state=config[“train”][“seed”], ) dataset = DigitsDataset(x_valid, y_valid, model_type) return DataLoader(dataset, batch_size=config[“train”][“batch_size”], shuffle=False)
def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument(“—checkpoint”, type=Path, required=True) return parser.parse_args()
def main() -> None: args = parse_args() checkpoint = torch.load(args.checkpoint, map_location=“cpu”, weights_only=False) model_type = checkpoint[“model_type”] config = checkpoint[“config”]
plain model = build_model(model_type, config) model.load_state_dict(checkpoint[“model_state_dict”]) model.eval()
plain loader = build_valid_loader(config, model_type) all_predictions: list[int] = [] all_labels: list[int] = []
plain with torch.no_grad(): for images, labels in loader: logits = model(images) all_predictions.extend(logits.argmax(dim=1).tolist()) all_labels.extend(labels.tolist())
print(f"checkpoint={args.checkpoint}") print(f"accuracy={accuracy_score(all_labels, all_predictions):.3f}") print(classification_report(all_labels, all_predictions, digits=3))
if name == “main”: main()
评估 MLP checkpoint:
source .venv/bin/activate.fish
python src/evaluate.py --checkpoint checkpoints/best_mlp.pt
评估 CNN checkpoint:
source .venv/bin/activate.fish
python src/evaluate.py --checkpoint checkpoints/best_cnn.pt
8. 看训练产物
训练后检查:
ls checkpoints
ls reports
ls figures
应该看到类似:
checkpoints/best_mlp.pt
reports/metrics_mlp.csv
figures/loss_curve_mlp.png
用 VS Code 打开 figures/loss_curve_mlp.png。你要观察:
train_loss是否下降。valid_loss是否下降后变平。- 如果
train_loss下降但valid_loss上升,可能过拟合。
9. README 最小内容
更新项目根目录的 README.md,至少包含:
# Digits Deep Learning Baseline
Task
Classify 8x8 handwritten digit images from sklearn digits.
Environment
- CachyOS
- Python managed by uv
- PyTorch CPU training
How to Run
uv venv
source .venv/bin/activate.fish
uv sync
python src/train.py --model mlp --epochs 20
python src/evaluate.py --checkpoint checkpoints/best_mlp.pt
Results
Model Best Valid Accuracy Notes MLP fill after training CPU baseline CNN fill after training small convolution baseline
Limitations
- Dataset is small and clean.
- No external test set.
Hyperparameter search is minimal.
如果 Markdown 中嵌套代码块让 VS Code 高亮混乱,可以先只写命令,不必追求复杂排版。
10. 练习
- 比较
mlp和cnn的 best validation accuracy。 - 把
configs/digits.toml中learning_rate改成0.01,观察 loss 是否更不稳定。 - 把
hidden_dim从128改成32,比较 MLP 表现。 - 把训练 epoch 从 5 增加到 20,观察是否过拟合。
- 在 README 里写 3 句话解释为什么 CNN 可能比 MLP 更适合图像。
11. 验收检查
在项目根目录执行:
source .venv/bin/activate.fish
test -f src/train.py; and test -f src/evaluate.py; and test -f configs/digits.toml
python src/train.py --model mlp --epochs 3
python src/evaluate.py --checkpoint checkpoints/best_mlp.pt
test -f reports/metrics_mlp.csv; and test -f figures/loss_curve_mlp.png
通过标准:
- 能训练 MLP baseline。
- 训练后生成 checkpoint、CSV 指标、loss 曲线。
evaluate.py能读取 checkpoint 并打印 accuracy。- README 说明了任务、运行方式、结果和限制。
12. 常见错误
12.1 忘记激活 fish 虚拟环境
每次打开新终端后,先进入项目目录,再执行:
source .venv/bin/activate.fish
12.2 checkpoint 路径写错
先检查文件名:
ls checkpoints
如果只训练了 MLP,就不会有 best_cnn.pt。
12.3 MLP 和 CNN 输入 shape 混用
MLP 输入是 [batch, 64],CNN 输入是 [batch, 1, 8, 8]。如果 checkpoint 是 MLP,就必须用 MLP 结构加载;如果 checkpoint 是 CNN,就必须用 CNN 结构加载。
12.4 只保存权重,没有保存配置
只保存 state_dict 不够。你还需要保存 model_type 和训练配置,否则评估脚本不知道该重建哪种模型。
12.5 loss 曲线没有生成
确认 matplotlib 已安装,并且训练脚本真的跑到了最后:
uv add matplotlib
python src/train.py --model mlp --epochs 3
13. 下一步
进入 Week 11:把一个 AI 比赛或课程项目整理成可以展示的工程项目。你会清理目录、抽出配置、固定随机种子、写训练入口和 inference 脚本,让项目从“能在我电脑上跑”变成“别人也能复现”。
plain
If you enjoyed this, leave a comment~