Week 09:PyTorch 基础——从 Tensor 到完整训练循环
回到总路线:USTC 统计 AI / 量化 20 周成长计划
本周目标对应计划中的 Week 09:Tensor、Dataset / DataLoader、nn.Module、loss、optimizer、train / eval loop。
本教程默认你在 CachyOS + fish shell + VS Code + uv 环境下学习。所有命令默认都是 fish;如果你使用 Bash / Zsh,需要自己换成对应激活脚本。
0. 本周详细教学:语法、规范、验收
本节不是追加在尾部的复习,而是本周正文的入口。先读这里,再做后面的命令和项目。
0.1 本周真正要学会什么
| 维度 | 要求 |
|---|---|
| 知识点 | Tensor、Dataset、DataLoader、nn.Module、loss、optimizer |
| 代码语法 | 能从空文件写出本周核心脚本,而不是只复制运行 |
| 程序规范 | 函数拆分、路径清楚、输入输出明确、错误能解释 |
| 交付物 | src/pytorch_mlp.py |
| 验收方式 | 从 fish 终端运行命令,得到可复查的文件或指标 |
0.2 代码语法精讲
下面的代码不是最终答案,而是本周必须理解的最小骨架:
for epoch in range(epochs): model.train() for xb, yb in train_loader: logits = model(xb) loss = criterion(logits, yb) optimizer.zero_grad() loss.backward() optimizer.step()
plain model.eval() with torch.no_grad(): valid_logits = model(X_valid_tensor)
读代码时按四步检查:输入从哪里来;中间变量的类型和 shape 是什么;函数或脚本输出什么;哪些错误应该显式报出来。
0.3 本周程序规范
- 所有路径用相对路径或 `pathlib.Path`,不要写死 `/home/miku/...`。
- 核心逻辑进 `src/`,notebook 只做探索和解释。
- 每个脚本能从 fish 终端运行,并在 README 写出命令。
- 输出必须落盘到 `reports/`、`figures/` 或 `outputs/`,不能只在屏幕上看。
0.4 本周练习分层
| 层级 | 任务 | 不合格表现 | 合格验收 |
|---|---|---|---|
| 最小练习 | 手写上面的最小骨架 | 只在 notebook 里运行 | 终端运行成功 |
| 标准练习 | 把逻辑拆成函数/模块 | 一个大脚本从头写到尾 | 至少 2 个函数,职责清楚 |
| 项目练习 | 生成本周交付物 src/pytorch_mlp.py | 只有屏幕输出 | 文件落盘,可复查 |
| 复盘练习 | 写 3 个错误和修复 | 只写“已解决” | 写清报错、原因、修复、预防 |
0.5 本周和主线的连接
- 回到总计划:USTC AI / Quant 练习手册
- 查详细练习索引:技术练习详解
- 查质量评分:最终质量门槛
1. 本周目标
完成后你应该能做到:
- 知道 PyTorch 的 Tensor 和 NumPy array 有什么相似与不同。
- 会把表格数据包装成
Dataset,再交给DataLoader分批训练。 - 会写一个最小但完整的
nn.Module。 - 会写 CPU-first 的训练循环:
model.train()、前向传播、loss、反向传播、optimizer step。 - 会写验证循环:
model.eval()、torch.no_grad()、accuracy。 - 留下可运行的
src/pytorch_mlp.py和学习笔记。
本周不追求高精度,也不追求 GPU。重点是把深度学习训练的骨架写明白。
2. 前置条件
你需要已经具备:
- VS Code 能打开项目目录。
- 终端默认 shell 是 fish。
uv可用。- 会运行普通 Python 脚本。
- 知道监督学习中的 train / validation split。
先确认工具:
python --version
uv --version
fish --version
如果这些命令都能输出版本号,就可以继续。
3. 建立 Week 09 项目
建议每周一个独立目录,避免依赖混在一起。
mkdir -p ~/Code/ustc-ai/week09-pytorch-basics
cd ~/Code/ustc-ai/week09-pytorch-basics
uv init --name week09-pytorch-basics
uv venv
source .venv/bin/activate.fish
uv add torch scikit-learn matplotlib
code .
逐句解释:
| 命令 | 作用 |
|---|---|
mkdir -p |
创建项目目录;如果上级目录不存在就一起创建 |
cd |
进入本周项目目录 |
uv init |
生成 pyproject.toml 等项目文件 |
uv venv |
创建当前项目专用的 .venv |
source .venv/bin/activate.fish |
用 fish 方式激活虚拟环境 |
uv add ... |
安装本周需要的依赖 |
code . |
用 VS Code 打开当前目录 |
检查 PyTorch 是否可用:
python -c "import torch; print(torch.__version__); print('cuda:', torch.cuda.is_available())"
在 CachyOS 笔记本上,cuda: False 很正常。本教程所有代码都按 CPU 设计。
4. 推荐文件布局
创建本周目录结构:
mkdir -p src notes outputs
目标布局:
week09-pytorch-basics/
├── .venv/
├── pyproject.toml
├── src/
│ ├── tensor_basics.py
│ └── pytorch_mlp.py
├── notes/
│ └── week09_pytorch.md
└── outputs/
文件用途:
| 文件 | 用途 |
|---|---|
src/tensor_basics.py |
练习 Tensor、shape、dtype、autograd |
src/pytorch_mlp.py |
本周核心:Dataset、DataLoader、MLP、训练循环 |
notes/week09_pytorch.md |
记录你理解的训练流程和错误 |
outputs/ |
以后放日志、图、模型文件 |
5. Tensor 入门:先理解数据容器
在 VS Code 中创建 src/tensor_basics.py:
import torchx = torch.tensor([[1.0, 2.0], [3.0, 4.0]]) y = torch.ones((2, 2))
print(“x =”) print(x) print(“shape:”, x.shape) print(“dtype:”, x.dtype) print(“x + y =”) print(x + y) print(“x @ y =”) print(x @ y)
autograd: PyTorch 自动记录计算图,用于反向传播
w = torch.tensor([2.0], requires_grad=True) b = torch.tensor([1.0], requires_grad=True) feature = torch.tensor([3.0]) target = torch.tensor([10.0])
prediction = w * feature + b loss = (prediction - target).pow(2).mean() loss.backward()
print(“prediction:”, prediction.item()) print(“loss:”, loss.item()) print(“d loss / d w:”, w.grad.item()) print(“d loss / d b:”, b.grad.item())
运行:
source .venv/bin/activate.fish
python src/tensor_basics.py
你需要重点观察:
shape:深度学习代码里最常见的错误来源。dtype:模型通常使用float32,标签分类通常使用long。requires_grad=True:告诉 PyTorch 这个 Tensor 需要梯度。loss.backward():从 loss 反向计算所有参数的梯度。
6. Dataset 和 DataLoader:把数据交给训练循环
PyTorch 不希望你在训练循环里手动切片全部数据。更常见的结构是:
原始数据 -> Dataset -> DataLoader -> 每个 batch 的 x, y -> model
概念区分:
| 名称 | 作用 |
|---|---|
Dataset |
定义“第 i 条样本怎么取” |
DataLoader |
负责 batch、shuffle、迭代 |
batch_size |
每次给模型多少条样本 |
shuffle=True |
每个 epoch 打乱训练样本顺序 |
7. 写一个完整的 CPU-first MLP
创建 src/pytorch_mlp.py:
from __future__ import annotationsimport argparse import random from dataclasses import dataclass
import numpy as np import torch from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from torch import nn from torch.utils.data import DataLoader, Dataset
@dataclass(frozen=True) class TrainConfig: epochs: int batch_size: int lr: float seed: int
class TabularClassificationDataset(Dataset): def init(self, features: np.ndarray, labels: np.ndarray) -> None: self.features = torch.tensor(features, 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.features[index], self.labels[index]
class MLP(nn.Module): def init(self, input_dim: int, hidden_dim: int, num_classes: int) -> None: super().init() self.net = nn.Sequential( nn.Linear(input_dim, 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)
def set_seed(seed: int) -> None: random.seed(seed) np.random.seed(seed) torch.manual_seed(seed)
def build_loaders(config: TrainConfig) -> tuple[DataLoader, DataLoader, int, int]: x, y = make_classification( n_samples=1200, n_features=20, n_informative=10, n_redundant=4, n_classes=2, random_state=config.seed, ) x_train, x_valid, y_train, y_valid = train_test_split( x, y, test_size=0.2, stratify=y, random_state=config.seed, )
plain scaler = StandardScaler() x_train = scaler.fit_transform(x_train) x_valid = scaler.transform(x_valid)
plain train_dataset = TabularClassificationDataset(x_train, y_train) valid_dataset = TabularClassificationDataset(x_valid, y_valid)
plain train_loader = DataLoader( train_dataset, batch_size=config.batch_size, shuffle=True, ) valid_loader = DataLoader( valid_dataset, batch_size=config.batch_size, shuffle=False, ) return train_loader, valid_loader, x_train.shape[1], len(np.unique(y))
def train_one_epoch( model: nn.Module, loader: DataLoader, criterion: nn.Module, optimizer: torch.optim.Optimizer, device: torch.device, ) -> float: model.train() total_loss = 0.0 total_examples = 0
plain for features, labels in loader: features = features.to(device) labels = labels.to(device)
plain logits = model(features) loss = criterion(logits, labels)
plain optimizer.zero_grad() loss.backward() optimizer.step()
total_loss += loss.item() * len(labels) total_examples += len(labels) return total_loss / total_examplesdef evaluate(model: nn.Module, loader: DataLoader, criterion: nn.Module, device: torch.device) -> tuple[float, float]: model.eval() total_loss = 0.0 total_correct = 0 total_examples = 0
plain with torch.no_grad(): for features, labels in loader: features = features.to(device) labels = labels.to(device)
plain logits = model(features) loss = criterion(logits, labels) predictions = logits.argmax(dim=1)
plain total_loss += loss.item() * len(labels) total_correct += (predictions == labels).sum().item() total_examples += len(labels)
return total_loss / total_examples, total_correct / total_examplesdef parse_args() -> TrainConfig: parser = argparse.ArgumentParser() parser.add_argument(“—epochs”, type=int, default=20) parser.add_argument(“—batch-size”, type=int, default=32) parser.add_argument(“—lr”, type=float, default=0.01) parser.add_argument(“—seed”, type=int, default=42) args = parser.parse_args() return TrainConfig( epochs=args.epochs, batch_size=args.batch_size, lr=args.lr, seed=args.seed, )
def main() -> None: config = parse_args() set_seed(config.seed)
plain device = torch.device(“cpu”) train_loader, valid_loader, input_dim, num_classes = build_loaders(config) model = MLP(input_dim=input_dim, hidden_dim=32, num_classes=num_classes).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=config.lr)
plain for epoch in range(1, config.epochs + 1): train_loss = train_one_epoch(model, train_loader, criterion, optimizer, device) valid_loss, valid_acc = evaluate(model, valid_loader, criterion, device) print( f”epoch={epoch
} ” f”train_loss={train_loss:.4f} ” f”valid_loss={valid_loss:.4f} ” f”valid_acc={valid_acc:.3f}” )
if name == “main”: main()
运行一个短训练:
source .venv/bin/activate.fish
python src/pytorch_mlp.py --epochs 5 --batch-size 32 --lr 0.01
如果你看到类似下面的输出,说明训练循环已经跑通:
epoch=01 train_loss=0.6200 valid_loss=0.5100 valid_acc=0.760
epoch=02 train_loss=0.4200 valid_loss=0.3500 valid_acc=0.850
数字不需要完全一致,因为初始化和环境可能不同。你要看的是:脚本能运行,loss 大体下降,验证集 accuracy 高于随机猜测。
8. 训练循环逐句解释
核心结构是:
for epoch in range(num_epochs):
model.train()
for x, y in train_loader:
pred = model(x)
loss = criterion(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
逐句解释:
| 代码 | 含义 |
|---|---|
model.train() |
切换到训练模式;以后遇到 Dropout / BatchNorm 时很重要 |
for x, y in train_loader |
从 DataLoader 取一个 batch |
pred = model(x) |
前向传播,得到 logits 或预测值 |
loss = criterion(pred, y) |
计算当前 batch 的损失 |
optimizer.zero_grad() |
清空上一轮梯度;否则梯度会累加 |
loss.backward() |
反向传播,计算每个参数的梯度 |
optimizer.step() |
根据梯度更新参数 |
验证循环必须不同:
model.eval()
with torch.no_grad():
...
原因:验证时不更新参数,也不需要保存计算图。这样更快,也更不容易写错。
9. 写学习笔记
创建 notes/week09_pytorch.md,至少回答这些问题:
# Week 09 PyTorch Notes
Tensor
- Tensor 和 NumPy array 的共同点:
- Tensor 多出来的重要能力:
Dataset / DataLoader
- Dataset 负责:
- DataLoader 负责:
Train Loop
- 为什么要 zero_grad:
- 为什么 train 和 eval 要分开:
Errors I Met
- 错误信息:
- 原因:
修复方式:
10. 练习
- 把
hidden_dim=32改成hidden_dim=64,观察验证集 accuracy 是否变化。 - 把
lr=0.01改成lr=0.001,比较 loss 下降速度。 - 把
batch_size=32改成batch_size=128,观察每个 epoch 是否更快。 - 在
MLP里增加一层nn.Dropout(0.2),再比较训练集和验证集表现。 - 故意删掉
optimizer.zero_grad(),观察训练是否变得不稳定,然后恢复。
每次只改一个因素,否则你无法判断到底是什么导致结果变化。
11. 验收检查
在项目根目录执行:
source .venv/bin/activate.fish
test -f src/pytorch_mlp.py; and test -f notes/week09_pytorch.md
python src/tensor_basics.py
python src/pytorch_mlp.py --epochs 3 --batch-size 32 --lr 0.01
通过标准:
tensor_basics.py能打印 Tensor、shape、dtype 和梯度。pytorch_mlp.py能完成至少 3 个 epoch。- 输出中包含
train_loss、valid_loss、valid_acc。 notes/week09_pytorch.md里有你自己的解释,而不是只复制代码。
12. 常见错误
12.1 fish 激活脚本用错
错误做法是执行 Bash / Zsh 版激活脚本。fish 里应该执行:
source .venv/bin/activate.fish
12.2 标签 dtype 不对
nn.CrossEntropyLoss() 要求分类标签是整数类别,并且 dtype 通常是 torch.long。如果你把标签做成 float32,可能会报 dtype 错误。
12.3 忘记 optimizer.zero_grad()
PyTorch 默认会累加梯度。训练循环里每个 batch 更新前都要清空上一轮梯度。
12.4 训练和验证没有分开
训练时用:
model.train()
验证时用:
model.eval()
with torch.no_grad():
...
不要在验证集上调用 optimizer.step()。
12.5 shape 对不上
如果看到矩阵乘法 shape 错误,先打印:
print(features.shape)
print(logits.shape)
print(labels.shape)
分类任务中,logits 常见 shape 是 [batch_size, num_classes],labels 常见 shape 是 [batch_size]。
13. 下一步
进入 Week 10:把这个最小训练脚本升级成一个小型深度学习项目。你会加入 MLP / CNN baseline、checkpoint、loss 曲线、验证集评估和独立的 evaluate.py。
plain
If you enjoyed this, leave a comment~