Week 10:深度学习小项目——MLP / CNN baseline、checkpoint、loss 曲线与评估

Published 2026-07-26 10:00 4001 words 20 min read

This post is not yet available in English. Showing the original.
Week 10:深度学习小项目——MLP / CNN baseline、checkpoint、loss 曲线与评估。fish-first 终端教学,面向 CachyOS、VS Code、uv 和 Python 学习路线。
Oh My Pi / weekly tutorial / week10-deep-learning-mini-project
miku@cachyos:~/Code/python-learning$ omp teach week10-deep-learning-mini-project --fish-first --step-by-step
source658 行教学文档
weekWeek 10
shellfish-first 命令版
backlink20 周计划

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 代码语法精讲

下面的代码不是最终答案,而是本周必须理解的最小骨架:

omppython
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 本周和主线的连接

1. 本周目标

完成后你会得到一个小而完整的深度学习项目:

  1. train.py 支持 MLP 和 CNN 两种 baseline。
  2. 训练过程保存 best checkpoint。
  3. 训练过程记录 train_lossvalid_lossvalid_acc
  4. 自动生成 loss 曲线图。
  5. evaluate.py 可以从 checkpoint 重新加载模型并评估。
  6. 项目结构接近以后比赛和科研项目的最小形态。

本周不追求复杂模型,重点是工程闭环:能训练、能保存、能复评、能解释。

2. 前置条件

你需要已经完成 Week 09,至少理解:

  • Tensor 的 shape 和 dtype。
  • Dataset / DataLoader
  • nn.Module
  • model.train()model.eval() 的区别。
  • loss.backward()optimizer.step()

确认工具:

ompfish
python --version
uv --version
git --version

3. 建立项目

ompfish
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. 文件布局

创建目录:

ompfish
mkdir -p src configs checkpoints reports figures

推荐布局:

ompprompt
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

omptoml
[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

omppython
from __future__ import annotations

import 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_examples

def 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:

ompfish
source .venv/bin/activate.fish
python src/train.py --model mlp --epochs 5

再运行 CNN baseline:

ompfish
source .venv/bin/activate.fish
python src/train.py --model cnn --epochs 5

MLP 和 CNN 都可以在 CPU 上很快跑完。digits 数据集很小,所以这是练工程流程的项目,不是刷榜项目。

7. 评估脚本:从 checkpoint 复现结果

训练脚本能打印验证集指标,但项目里还需要独立评估入口。这样别人拿到你的 checkpoint 后,不需要重新训练。

创建 src/evaluate.py

omppython
from __future__ import annotations

import 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:

ompfish
source .venv/bin/activate.fish
python src/evaluate.py --checkpoint checkpoints/best_mlp.pt

评估 CNN checkpoint:

ompfish
source .venv/bin/activate.fish
python src/evaluate.py --checkpoint checkpoints/best_cnn.pt

8. 看训练产物

训练后检查:

ompfish
ls checkpoints
ls reports
ls figures

应该看到类似:

ompprompt
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,至少包含:

ompmarkdown
# 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

ModelBest Valid AccuracyNotes
MLPfill after trainingCPU baseline
CNNfill after trainingsmall convolution baseline

Limitations

  • Dataset is small and clean.
  • No external test set.
  • Hyperparameter search is minimal.

如果 Markdown 中嵌套代码块让 VS Code 高亮混乱,可以先只写命令,不必追求复杂排版。

10. 练习

  1. 比较 mlpcnn 的 best validation accuracy。
  2. configs/digits.tomllearning_rate 改成 0.01,观察 loss 是否更不稳定。
  3. hidden_dim128 改成 32,比较 MLP 表现。
  4. 把训练 epoch 从 5 增加到 20,观察是否过拟合。
  5. 在 README 里写 3 句话解释为什么 CNN 可能比 MLP 更适合图像。

11. 验收检查

在项目根目录执行:

ompfish
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 虚拟环境

每次打开新终端后,先进入项目目录,再执行:

ompfish
source .venv/bin/activate.fish

12.2 checkpoint 路径写错

先检查文件名:

ompfish
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 已安装,并且训练脚本真的跑到了最后:

ompfish
uv add matplotlib
python src/train.py --model mlp --epochs 3

13. 下一步

进入 Week 11:把一个 AI 比赛或课程项目整理成可以展示的工程项目。你会清理目录、抽出配置、固定随机种子、写训练入口和 inference 脚本,让项目从“能在我电脑上跑”变成“别人也能复现”。

plain

If you enjoyed this, leave a comment~

© 2026 江无没有月 @miku
Powered by theme astro-koharu · Inspired by Shoka