本节你会学到
- 说清楚「深度学习项目标准模板:训练、验证、日志和配置」解决的核心问题
- 知道它在「深度学习项目」中的位置
- 把 深度学习、DataLoader、Linear 层 这些关键词联系起来
把散落在单文件里的 PyTorch 代码整理成可维护项目:配置、数据、模型、训练引擎、日志、检查点和推理各归其位,并保留可复现实验所需信息。
学习路线:深度学习项目 · 第 68 课
通过回归、二维分类、表格分类和图像分类项目,把 Dataset、模型、损失、优化器、评估和推理串起来。
学完本阶段你能做到:能独立完成一个完整深度学习项目,从数据到推理全流程,并整理出可复用的项目目录结构。
推荐读法:建议顺序阅读并同步跑代码;每个项目都要形成可复用目录。
查看完整阶段 · 10 篇先看目标,再带着问题读正文。读完后用练习确认自己真的理解了。
先检查模型参数、输入 Tensor、标签 Tensor 是否在同一个设备上。常见修正是把它们统一 `.to(device)`。
因为训练是否正确经常取决于 shape、dtype、device 和梯度流,API 名字只能告诉你工具,不会保证数据契约正确。
`输入数据 → forward → loss → backward → optimizer.step()`,并知道本文主题位于这条链路的哪一环。
第一个模型通常写在一个 Notebook 或一个 train.py 里,这没有问题。但项目一旦继续增长,单文件会很快变成这样:
项目模板不是为了“看起来专业”,而是为了让每一次实验都能回答三个问题:
image-classification/
├── configs/
│ └── default.yaml
├── data/
│ ├── train/
│ └── val/
├── outputs/
│ └── experiment-001/
│ ├── config.yaml
│ ├── metrics.csv
│ └── best.pt
├── src/
│ ├── __init__.py
│ ├── config.py
│ ├── datasets.py
│ ├── models.py
│ ├── engine.py
│ └── utils.py
├── train.py
├── predict.py
└── requirements.txt
看图抓主线: 整理项目配置 → 封装训练引擎 → 记录实验日志 → 复用项目模板。
先遮住上面的正文,只看图回答:
能用自己的话串起四张卡片,就说明你已经抓住了本课骨架。再回到正文补充公式、代码和边界条件。
每个模块只承担一个主要职责:
| 文件 | 职责 |
|---|---|
config.py |
读取和校验超参数 |
datasets.py |
数据集、变换和 DataLoader |
models.py |
模型结构 |
engine.py |
训练和验证一个 epoch |
utils.py |
随机种子、日志、保存权重 |
train.py |
组合所有模块并启动训练 |
predict.py |
独立加载权重做推理 |
一个最小的 configs/default.yaml:
experiment_name: cifar10-cnn-v1
seed: 42
data:
root: data
image_size: 32
batch_size: 128
num_workers: 0
model:
num_classes: 10
dropout: 0.3
train:
epochs: 20
learning_rate: 0.001
weight_decay: 0.0001
device: auto
配置文件的价值不是少写几行 Python,而是让实验参数可以被保存、比较和复用。
# src/config.py
from dataclasses import dataclass
from pathlib import Path
import yaml
@dataclass
class Config:
raw: dict
@property
def experiment_name(self):
return self.raw["experiment_name"]
def load_config(path):
path = Path(path)
with path.open("r", encoding="utf-8") as file:
raw = yaml.safe_load(file)
required = {"experiment_name", "seed", "data", "model", "train"}
missing = required - raw.keys()
if missing:
raise ValueError(f"配置缺少字段: {sorted(missing)}")
return Config(raw=raw)
大型项目可以使用专门的配置框架;学习阶段先把“配置与实现分离”的习惯建立起来更重要。
# src/datasets.py
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
def build_loaders(config):
data_cfg = config.raw["data"]
image_size = data_cfg["image_size"]
train_transform = transforms.Compose([
transforms.RandomCrop(image_size, padding=4),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
])
val_transform = transforms.ToTensor()
train_dataset = datasets.CIFAR10(
root=data_cfg["root"],
train=True,
download=True,
transform=train_transform,
)
val_dataset = datasets.CIFAR10(
root=data_cfg["root"],
train=False,
download=True,
transform=val_transform,
)
common = {
"num_workers": data_cfg["num_workers"],
"pin_memory": True,
}
train_loader = DataLoader(
train_dataset,
batch_size=data_cfg["batch_size"],
shuffle=True,
**common,
)
val_loader = DataLoader(
val_dataset,
batch_size=data_cfg["batch_size"] * 2,
shuffle=False,
**common,
)
return train_loader, val_loader
train.py 不需要知道图片怎样裁剪;它只拿到可迭代的 loader。
# src/models.py
import torch.nn as nn
class SmallCNN(nn.Module):
def __init__(self, num_classes=10, dropout=0.3):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, 3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(2),
nn.Conv2d(64, 128, 3, padding=1),
nn.ReLU(inplace=True),
nn.AdaptiveAvgPool2d(1),
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Dropout(dropout),
nn.Linear(128, num_classes),
)
def forward(self, x):
return self.classifier(self.features(x))
def build_model(config):
model_cfg = config.raw["model"]
return SmallCNN(
num_classes=model_cfg["num_classes"],
dropout=model_cfg["dropout"],
)
这里使用 AdaptiveAvgPool2d(1),把任意空间尺寸压成 1×1,减少 Linear 与输入图片尺寸的耦合。
# src/engine.py
import torch
def run_epoch(model, loader, criterion, device, optimizer=None):
training = optimizer is not None
model.train(training)
loss_sum = 0.0
correct = 0
count = 0
context = torch.enable_grad() if training else torch.no_grad()
with context:
for images, labels in loader:
images = images.to(device, non_blocking=True)
labels = labels.to(device, non_blocking=True)
if training:
optimizer.zero_grad()
logits = model(images)
loss = criterion(logits, labels)
if training:
loss.backward()
optimizer.step()
batch_size = labels.size(0)
loss_sum += loss.item() * batch_size
correct += (logits.argmax(1) == labels).sum().item()
count += batch_size
return {
"loss": loss_sum / count,
"accuracy": correct / count,
}
同一函数通过是否传入 optimizer 区分训练和验证,避免两套几乎一样的循环长期漂移。
# src/utils.py
import random
import numpy as np
import torch
def seed_everything(seed):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
固定随机种子不代表不同硬件、不同 PyTorch 版本一定得到完全相同结果,但能减少无意义波动。
实验记录还应至少保存:
import csv
from pathlib import Path
def append_metrics(path, row):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
exists = path.exists()
with path.open("a", newline="", encoding="utf-8") as file:
writer = csv.DictWriter(file, fieldnames=row.keys())
if not exists:
writer.writeheader()
writer.writerow(row)
每轮写入:
append_metrics(
output_dir / "metrics.csv",
{
"epoch": epoch,
"train_loss": train_metrics["loss"],
"train_accuracy": train_metrics["accuracy"],
"val_loss": val_metrics["loss"],
"val_accuracy": val_metrics["accuracy"],
"learning_rate": optimizer.param_groups[0]["lr"],
},
)
CSV 简单、透明,既能用 Excel 打开,也能用 pandas 和 Matplotlib 画曲线。
def save_checkpoint(
path,
model,
optimizer,
scheduler,
epoch,
best_metric,
config,
):
torch.save(
{
"model_state": model.state_dict(),
"optimizer_state": optimizer.state_dict(),
"scheduler_state": scheduler.state_dict(),
"epoch": epoch,
"best_metric": best_metric,
"config": config.raw,
},
path,
)
只做推理时只需模型权重;要继续训练,就还要恢复 optimizer、scheduler、epoch 和最佳指标。
from pathlib import Path
import shutil
import torch
import torch.nn as nn
from src.config import load_config
from src.datasets import build_loaders
from src.engine import run_epoch
from src.models import build_model
from src.utils import seed_everything
def main():
config_path = Path("configs/default.yaml")
config = load_config(config_path)
seed_everything(config.raw["seed"])
device_name = config.raw["train"]["device"]
if device_name == "auto":
device_name = "cuda" if torch.cuda.is_available() else "cpu"
device = torch.device(device_name)
output_dir = Path("outputs") / config.experiment_name
output_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(config_path, output_dir / "config.yaml")
train_loader, val_loader = build_loaders(config)
model = build_model(config).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(
model.parameters(),
lr=config.raw["train"]["learning_rate"],
weight_decay=config.raw["train"]["weight_decay"],
)
best_accuracy = 0.0
for epoch in range(1, config.raw["train"]["epochs"] + 1):
train_metrics = run_epoch(
model, train_loader, criterion, device, optimizer
)
val_metrics = run_epoch(
model, val_loader, criterion, device
)
if val_metrics["accuracy"] > best_accuracy:
best_accuracy = val_metrics["accuracy"]
torch.save(model.state_dict(), output_dir / "best.pt")
print(
f"{epoch:02d} | "
f"train {train_metrics['accuracy']:.2%} | "
f"val {val_metrics['accuracy']:.2%}"
)
if __name__ == "__main__":
main()
这时 train.py 像导演:它不亲自处理每张图片,也不定义每层网络,只负责把模块按配置组织起来。
predict.py 最容易犯的错,是重新“凭记忆”写一套 Resize 和 Normalize。正确做法是从保存的配置或统一函数构建验证变换。
训练时:Resize → ToTensor → Normalize
推理时:必须完全一致
否则模型输入分布改变,即使权重正确,预测也可能明显变差。
几周后你只看到 best.pt,却不知道它对应哪个学习率。启动训练时就把配置复制到实验目录。
验证集用于调参和选模型;测试集只在方案确定后做最终评估。反复看测试集,本质上会对测试集过拟合。
utils.py 不应塞进所有逻辑。数据处理留在 datasets,模型留在 models,训练逻辑留在 engine;utils 只放真正通用的小工具。
练习 1:为配置文件增加 optimizer.name,支持在 AdamW 和 SGD 间切换。
练习 2:让训练脚本同时保存 last.pt 和 best.pt,说明二者用途。
练习 3:在每个实验目录生成 environment.txt,记录 Python、PyTorch 与 CUDA 版本。
练习 1:写 build_optimizer(config, model),读取 name 后分支创建优化器;未知名称应抛出 ValueError。
练习 2:last.pt 每轮覆盖,用于中断恢复;best.pt 只在验证指标改善时覆盖,用于部署和最终评估。
练习 3:
import platform
import torch
text = (
f"python={platform.python_version()}\n"
f"torch={torch.__version__}\n"
f"cuda={torch.version.cuda}\n"
)
(output_dir / "environment.txt").write_text(text, encoding="utf-8")
至此,深度学习项目阶段完成。下一篇开始进入自然语言处理:先不急着上 RNN 和 Transformer,我们从最基础的问题开始——一句文字怎样被拆成 Token,并建立成模型能使用的词表。
留言讨论