先从一次很典型的困惑说起。很多人把 NYU 那套 Deep Learning 课程的播放列表加入收藏夹之后,看前两讲觉得豁然开朗,看到能量基模型(Energy-Based Model)那一讲开始卡住:公式能看懂,但不知道这些公式落到代码里长什么样,也不知道自己该动手写点什么才算“学会”。这篇文章不打鸡血,也不做课程推荐,而是把这套课程的知识主线和动手环节对齐,给出一条可以边看边写的路线:课程每一块理论,对应一段能用 PyTorch 跑起来的代码,跑完能观察到一个具体现象。
文章面向两类读者:一类是有一定 Python 基础、想系统补齐深度学习底层概念的同学;另一类是已经会调 nn.Module、但只会照着模板改网络结构的开发者,想在“为什么这么设计”上补一层认知。读完你能拿到一套可复现的实验代码骨架、一份训练不收敛时的排查清单,以及把课程笔记转成工程资产的具体做法。
1. 这门课程到底讲什么:定位与知识地图
1.1 课程的讲授背景与内容边界
这套课程是纽约大学 DS-GA 1008《Deep Learning》的研究生课,2021 年春季学期的公开版本由 Yann LeCun 与 Alfredo Canziani 主讲,讲义、幻灯片和配套 Notebook 都在课程主页上公开。它在视频平台上的传播量很大,输入材料里提到的“原片 63 万+”说的就是公开版本的播放规模。播放量本身不重要,重要的是它带了一套完整的实践材料,包括每一讲的 notebook、可视化脚本和练习,这是很多纯理论公开课不具备的。
内容边界要先划清楚,避免预期错位。这门课不是“PyTorch 入门课”,也不会手把手教你调参刷榜。它的重心是深度学习的建模视角:把监督学习、无监督学习、强化学习统一到“能量函数 + 推断 + 损失”的框架下,然后逐个拆解卷积、正则化、注意力、图网络这些组件在这个框架里的位置。所以你会在课里看到大量关于“模型在做什么”的讨论,而不是“这个 API 怎么调”。
理解这一点很关键。如果你带着“学完就能复现 SOTA”的预期去看,会觉得它太“虚”;如果你带着“我要搞明白 CNN 为什么能共享权重、自编码器为什么能学到表征”的问题去看,会发现它几乎是目前公开材料里讲得最透的一档。
1.2 知识地图:从模块划分看学习顺序
课程大致覆盖以下几个模块,顺序上是由基础到专门化:
| 模块 | 主要问题 | 对应动手内容 |
|---|---|---|
| 导论与历史 | 深度学习与前馈网络的关系、表示学习的基本假设 | 无代码,建立概念 |
| 梯度下降与反向传播 | 参数如何更新、链式法则如何在计算图上流动 | 手写线性回归 + 自动求导验证 |
| 卷积网络 | 局部连接、权值共享、平移等变性 | MNIST 上的小型 CNN |
| 能量基模型 | 如何用标量能量刻画输入与输出的相容性 | 自编码器、对比损失 |
| 优化与正则化 | 动量、学习率、BatchNorm、Dropout | 对照实验 |
| 自监督与表征学习 | 不用标签如何构造学习信号 | 重构任务、对比任务 |
| 注意力与 Transformer | 序列建模中的全局依赖 | 最小注意力模块 |
| 图卷积与基于模型的强化学习 | 非欧结构与世界模型 | 视课程材料而定 |
这张表建议你打印出来贴在显示器旁边。每看完一讲,就在对应行打一个勾,勾的标准不是“看完了”,而是“对应动手内容我跑通了并且能解释输出”。这个标准看着宽松,实际执行起来会发现卡点非常集中:绝大多数人卡在能量基模型和自监督这两块,因为它们的“损失函数”不像交叉熵那样有直观的物理意义。
1.3 和前馈网络教程的差异在哪里
市面上大多数深度学习教程的叙述顺序是“线性层 → 激活 → 损失 → 反向传播 → CNN → RNN → Transformer”,本质上是按 API 组织知识。这套课程的组织方式不同,它先建立“模型是一个能量函数,推断是最小化能量,学习是塑造能量地形”的统一视角,再把这个视角套到具体结构上。好处是你一旦接受这个视角,很多看似无关的技术会串起来:自编码器的重构误差就是一种能量,对比学习的 InfoNCE 也是一种能量,甚至分类器的 logits 也可以看作能量的一种参数化。
代价是入门陡度更高。前几讲会反复出现“能量地形”“流形”“低能量区域”这类词,如果没有代码对照,很容易变成背名词。所以后文的实战部分,我会刻意把每个概念都落成一个能跑的实验,让你看到“能量”在数值上到底是什么。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与运行环境说明
2.1 硬件、系统与依赖版本
课程材料默认在 Linux 或 macOS 上运行,Windows 建议用 WSL2,避免路径和文件权限上的一些小问题。硬件方面,MNIST 级别的小实验用 CPU 完全够,一个 epoch 在普通笔记本上也就几十秒;一旦你开始跑 CIFAR-10 或者更深的网络,就需要一块显存 6GB 以上的 GPU。
版本这块要说清楚:本文示例使用 Python 3.9 及以上、PyTorch 2.x 系列、torchvision 与之配套的版本。PyTorch 的次版本迭代很快,torchvision 和 torch 必须版本对齐,否则会出现 RuntimeError: operator torchvision::nms does not exist 这类经典报错。稳妥做法是去 PyTorch 官网的安装命令生成器里选好系统和 CUDA 版本,直接用生成的命令安装,不要自己拼版本号。
如果你只是想跑通本文的代码,最省事的组合是:
bash复制# 创建独立环境,避免污染全局
conda create -n nyu-dl python=3.10 -y
conda activate nyu-dl
# CPU 版本,先跑通逻辑再说
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
# 常用辅助库
pip install numpy matplotlib tqdm
有 GPU 的读者把第二行换成官网生成的 CUDA 版本命令即可。这里强调一点:不要在没有验证环境的情况下直接照搬别人的 requirements.txt。深度学习项目的依赖冲突大多来自 CUDA 驱动版本、PyTorch 版本、torchvision 版本三者不匹配,而不是业务代码写错。
2.2 项目目录结构
一个人做实验最容易失控的地方是文件散落。建议从第一天就用下面这种结构,后续所有实验都往里塞:
text复制nyu-dl-lab/
├── data/ # 数据集缓存目录,不提交到 git
├── notebooks/ # 跟课笔记,按讲次命名
│ ├── 01_intro.ipynb
│ ├── 02_backprop.ipynb
│ └── 03_cnn.ipynb
├── src/
│ ├── __init__.py
│ ├── models.py # 网络结构集中放这里
│ ├── data.py # 数据加载
│ ├── train.py # 训练循环
│ └── utils.py # 随机种子、计时、日志
├── runs/ # 每次实验的输出,按时间戳建子目录
├── requirements.txt
└── README.md
runs/ 这个目录值得单独说。每次实验开一个子目录,里面存模型权重、训练曲线、配置快照。这样做的好处是,两周后你回来看某个实验结果,能立刻知道当时用了什么超参数,而不是对着一堆 model_final.pt 猜。这个习惯的收益在第三周之后会非常明显。
2.3 固定随机种子:让结果可复现
深度学习实验最烦的事情之一是同样代码跑两次结果不一样。原因是权重初始化、数据打乱、Dropout 采样都带随机性。下面这个工具函数建议放在 src/utils.py 里,每个实验开头调用一次:
python复制# 文件路径:src/utils.py
import os
import random
import numpy as np
import torch
def set_seed(seed: int = 42) -> None:
"""固定所有随机源,便于复现实验。
注意:即使固定了种子,GPU 上的某些算子仍可能带来微小差异,
这是正常现象,不必追求 bit-level 完全一致。
"""
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
# 关闭 cuDNN 的非确定性算法,牺牲一点速度换可复现性
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
os.environ["PYTHONHASHSEED"] = str(seed)
这里有个常见误解要澄清:torch.backends.cudnn.deterministic = True 并不能保证 100% 完全一致。GPU 上部分归约操作的累加顺序本身就不固定,浮点误差会放大。所以复现的标准应该是“指标波动在可接受范围内”,而不是“loss 小数点后六位一致”。
2.4 验证环境是否真的可用
装完之后别急着写网络,先跑一段最小验证,确认张量运算、自动求导、GPU 可用性三者都正常:
python复制# 文件路径:verify_env.py
import torch
print("PyTorch 版本:", torch.__version__)
print("CUDA 是否可用:", torch.cuda.is_available())
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("当前设备:", device)
x = torch.randn(4, 3, requires_grad=True)
w = torch.randn(3, 2, requires_grad=True)
y = (x @ w).sum()
y.backward()
print("x 的梯度形状:", x.grad.shape)
print("w 的梯度形状:", w.grad.shape)
预期输出里 x.grad.shape 是 torch.Size([4, 3]),w.grad.shape 是 torch.Size([3, 2])。如果这一步的梯度是 None,说明 requires_grad 没设对,后面所有训练都会白跑。这个检查只要 5 秒,能省掉后面半小时的困惑。
3. 核心概念拆解:从能量函数到反向传播
3.1 能量基模型到底在说什么
先说人话版本。传统分类器做的事是:输入一张图,输出 10 个分数,取最大的那个当答案。能量基模型换了个说法:定义一个函数 $E(x, y)$,输入是数据 $x$ 和候选答案 $y$,输出一个标量,这个标量越小,说明 $y$ 和 $x$ 越匹配。预测的时候,在所有可能的 $y$ 里找让 $E$ 最小的那个;训练的时候,想办法让正确答案的能量降下去,让错误答案的能量升上去。
这个视角的价值在于统一性。分类问题可以用它描述(把 logits 取负就是能量),回归问题可以用它描述(预测值和真实值的距离就是能量),自编码器可以用它描述(重构误差就是能量),甚至强化学习里的价值函数也可以塞进这个框架。LeCun 在课上花大量篇幅讲这个,目的不是让你换个词说“损失函数”,而是让你在遇到新任务时,能先问一句:这个任务的“相容性”该怎么定义成标量?
举个具体例子帮助理解。假设我们做图像去噪,输入是加噪图 $\tilde{x}$,输出是干净图 $\hat{x}$。用能量视角描述就是:$E(\tilde{x}, \hat{x}) = |\hat{x} - \tilde{x}|^2$ 加上一个先验项,最小化这个能量就等价于“既要贴近观测,又要符合自然图像的分布”。这个描述方式比“定义损失函数然后反向传播”更能提示你:先验项怎么设计,才是这类任务的核心难点。
3.2 反向传播:链式法则在计算图上的流动
反向传播不是什么神秘机制,本质就是链式法则加上动态规划。PyTorch 的自动求导做了两件事:前向计算时记录每个算子构成的图,反向时从输出往输入逐层应用链式法则,把中间结果缓存起来复用,避免重复计算。
下面这段代码用最朴素的写法验证这一点,手算一遍线性回归的梯度,再和 autograd 的结果对比:
python复制# 文件路径:src/check_grad.py
import torch
# 构造一个小问题:y = Xw + b
torch.manual_seed(0)
X = torch.randn(5, 3, dtype=torch.float64)
w_true = torch.tensor([[1.5], [-2.0], [0.5]], dtype=torch.float64)
b_true = torch.tensor([0.3], dtype=torch.float64)
y = X @ w_true + b_true
# 待学习参数
w = torch.zeros(3, 1, dtype=torch.float64, requires_grad=True)
b = torch.zeros(1, dtype=torch.float64, requires_grad=True)
# 前向
pred = X @ w + b
loss = ((pred - y) ** 2).mean()
# 反向
loss.backward()
# 手算:dL/dw = 2/n * X^T (pred - y)
n = X.shape[0]
residual = pred.detach() - y
grad_w_manual = 2.0 / n * (X.T @ residual)
grad_b_manual = 2.0 / n * residual.sum(dim=0)
print("autograd 的 w 梯度:\n", w.grad)
print("手算的 w 梯度:\n", grad_w_manual)
print("最大误差:", (w.grad - grad_w_manual).abs().max().item())
预期输出里最大误差在 1e-15 量级。用 float64 而不是默认的 float32,就是为了把数值误差压到可以忽略,方便对比。这种“手算验证自动求导”的练习看起来笨,但在你自己实现自定义损失函数时非常有用——很多 nan 问题就是梯度推导写错了符号或者漏了归一化系数。
这里顺便强调一个新手高频误区:loss.backward() 之后梯度是累加的,不是覆盖的。如果你在训练循环里忘了 optimizer.zero_grad(),梯度会一轮轮叠加,表现为 loss 先降后炸。这不是 bug,是设计如此,为了支持梯度累积这种多步合并的技巧。
3.3 卷积的两个核心假设:局部性与平移等变性
卷积层之所以在图像上有效,是因为它把两个先验假设编码进了结构里。
第一个是局部性:相邻像素的相关性远大于远距离像素,所以每个输出只连接输入的一个小窗口。全连接层在 28×28 的图上,第一层就要 784×N 个参数,而 3×3 卷积核只要 9×N 个。
第二个是平移等变性:同一个图案出现在图的左上角还是右下角,检测它的卷积核应该是同一个。这就是权值共享,它带来两个好处——参数大幅减少,以及模型对位置变化天然鲁棒。
这两个假设不是万能钥匙。如果你的任务里位置信息极其重要(比如某些细粒度分类),或者数据本身没有平移不变性(比如某些表格数据),卷积未必合适。理解了它成立的前提,你才知道什么时候该换结构,而不是无脑套 CNN。
3.4 自监督:把标签从数据本身里“造”出来
自监督学习的核心思想很朴素:数据本身含有大量结构信息,可以拿来当监督信号用。比如给一张图打乱若干小块,让模型预测原始顺序;或者对同一张图做两种不同增强,让模型判断这两个视图是不是来自同一张图。
课程里讲这块时强调了一个关键点:代理任务(pretext task)的设计决定了表征的质量。如果代理任务太简单,模型会学到一些捷径特征,迁移到下游任务时效果很差;如果太难,模型学不动。对比学习里的温度系数、增强策略的强度,本质上都是在调这个难度。
这部分内容的理论密度很高,但落到代码上其实不难。下一节的实战二会给一个自编码器版本,第五节给一个对比损失的简化版本,两者对照着看,能比较直观地感受到“重构式”和“判别式”这两条自监督路线的差异。
4. 实战一:最小可训练 CNN 的完整实现
4.1 数据加载与预处理
先解决数据。用 torchvision 自带的 MNIST,第一次运行会自动下载到 data/ 目录。预处理只做两件事:转张量和标准化。
python复制# 文件路径:src/data.py
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
def get_mnist_loaders(batch_size: int = 128, data_root: str = "./data"):
"""返回 MNIST 的训练和测试 DataLoader。
标准化参数使用 MNIST 的全局均值和标准差,
这两个值是数据集统计出来的常量,直接用即可。
"""
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)),
])
train_set = datasets.MNIST(
root=data_root, train=True, download=True, transform=transform
)
test_set = datasets.MNIST(
root=data_root, train=False, download=True, transform=transform
)
train_loader = DataLoader(
train_set, batch_size=batch_size, shuffle=True, num_workers=2
)
test_loader = DataLoader(
test_set, batch_size=batch_size, shuffle=False, num_workers=2
)
return train_loader, test_loader
num_workers=2 在 Linux 和 macOS 上没问题,在 Windows 上如果你把这段代码放在脚本顶层直接跑,可能会因为多进程启动方式报错。Windows 用户把它改成 num_workers=0 先跑通,再考虑优化。另外 transforms.Normalize 用的 (0.1307,), (0.3081,) 是 MNIST 训练集的均值和标准差,换成别的数据集要重新统计,不能照抄。
4.2 网络结构设计
网络不追求精度,追求“结构清晰、每一层都有明确作用”。两层卷积 + 两层池化 + 一个全连接,足够把 MNIST 做到 98% 以上。
python复制# 文件路径:src/models.py
import torch.nn as nn
import torch.nn.functional as F
class SmallCNN(nn.Module):
"""一个用于 MNIST 的小型卷积网络。
输入: (N, 1, 28, 28)
输出: (N, 10)
"""
def __init__(self, num_classes: int = 10):
super().__init__()
# 第一层:1 通道 -> 16 通道,padding=1 保证特征图尺寸不变
self.conv1 = nn.Conv2d(1, 16, kernel_size=3, padding=1)
self.bn1 = nn.BatchNorm2d(16)
# 第二层:16 -> 32 通道
self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1)
self.bn2 = nn.BatchNorm2d(32)
self.pool = nn.MaxPool2d(2)
# 28 -> 14 -> 7,两次池化后空间尺寸是 7x7
self.fc = nn.Linear(32 * 7 * 7, num_classes)
def forward(self, x):
x = self.pool(F.relu(self.bn1(self.conv1(x)))) # (N,16,14,14)
x = self.pool(F.relu(self.bn2(self.conv2(x)))) # (N,32,7,7)
x = x.flatten(1) # (N,32*7*7)
return self.fc(x)
几个设计点解释一下。padding=1 配 kernel_size=3 是保持空间尺寸不变的标准组合,不加 padding 的话每次卷积边缘会丢一圈。BatchNorm 放在卷积之后、激活之前,这是主流做法,它的作用是把每层输入的分布拉回稳定范围,让更深的网络也能训练。x.flatten(1) 从第 1 维开始展平,保留 batch 维,这比写 x.view(x.size(0), -1) 更安全——后者在输入不是连续内存时会报错。
4.3 训练与评估循环
训练循环写成函数,接受模型、优化器和数据加载器,返回每个 epoch 的平均损失和准确率。这样后续做对照实验时可以直接复用。
python复制# 文件路径:src/train.py
import torch
def train_one_epoch(model, loader, optimizer, criterion, device):
model.train() # 切到训练模式,启用 Dropout 和 BatchNorm 的批统计
total_loss, correct, total = 0.0, 0, 0
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad() # 清空上一轮累积的梯度
logits = model(images)
loss = criterion(logits, labels)
loss.backward()
optimizer.step()
total_loss += loss.item() * images.size(0)
correct += (logits.argmax(dim=1) == labels).sum().item()
total += images.size(0)
return total_loss / total, correct / total
@torch.no_grad()
def evaluate(model, loader, criterion, device):
model.eval() # 切到评估模式,使用滑动平均统计量
total_loss, correct, total = 0.0, 0, 0
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
logits = model(images)
loss = criterion(logits, labels)
total_loss += loss.item() * images.size(0)
correct += (logits.argmax(dim=1) == labels).sum().item()
total += images.size(0)
return total_loss / total, correct / total
model.train() 和 model.eval() 这两行绝对不能省。BatchNorm 在训练时用当前 batch 的均值方差,在评估时用全局滑动平均;Dropout 在训练时随机置零,在评估时关闭。忘了切换的最典型症状是:训练集准确率 99%,测试集只有 60%,你会误以为是过拟合,其实是评估时把训练专用的行为带进去了。@torch.no_grad() 装饰器则是关闭梯度记录,评估阶段不需要反向传播,开着会白白占用显存。
4.4 组装运行与结果说明
把上面几块拼起来,主程序大概长这样:
python复制# 文件路径:main_train.py
import torch
import torch.nn as nn
from src.data import get_mnist_loaders
from src.models import SmallCNN
from src.train import train_one_epoch, evaluate
from src.utils import set_seed
set_seed(42)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
train_loader, test_loader = get_mnist_loaders(batch_size=128)
model = SmallCNN().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
for epoch in range(5):
train_loss, train_acc = train_one_epoch(
model, train_loader, optimizer, criterion, device
)
test_loss, test_acc = evaluate(model, test_loader, criterion, device)
print(
f"epoch {epoch+1} | "
f"train_loss={train_loss:.4f} train_acc={train_acc:.4f} | "
f"test_loss={test_loss:.4f} test_acc={test_acc:.4f}"
)
运行命令:
bash复制python main_train.py
在 CPU 上大约几分钟能跑完 5 个 epoch,预期结果是测试准确率在 98.5% 到 99.2% 之间。如果第一轮结束后准确率只有 10% 左右,基本可以断定是标签和输出没有对齐,或者 optimizer 传进去的是空参数列表。如果准确率卡在 50% 上下不动,检查一下 zero_grad() 是不是漏了——梯度累加会让优化器在错误的梯度上反复横跳。
跑通这个之后,建议做一个小改动验证自己的理解:把 self.pool 从 MaxPool2d(2) 换成 AvgPool2d(2),看看测试准确率变化多少;再把 BatchNorm 去掉,观察训练曲线是不是变得更抖。这类改动比单纯调学习率更能建立对结构的直觉。
5. 实战二:用自编码器理解“能量最小化”
5.1 自编码器的能量解释
自编码器做的事很简单:输入 $x$,编码成低维向量 $z$,再解码回 $\hat{x}$,最小化 $|x - \hat{x}|^2$。按能量视角看,这就是定义一个能量 $E(x) = |x - f(g(x))|^2$,训练过程是让真实数据点落在低能量区域,然后任何新样本的“异常程度”就可以用它的能量值衡量。
这个解释不是文字游戏,它有直接的实际用途。比如工业质检里的异常检测,正常样本训练自编码器,测试时如果某张图的能量显著高于阈值,就判定为异常。这种方法的优势是只需要正常样本,不需要标注异常数据。
5.2 完整实现
python复制# 文件路径:src/autoencoder.py
import torch
import torch.nn as nn
import torch.nn.functional as F
class ConvAutoencoder(nn.Module):
"""卷积自编码器,用于 28x28 灰度图。
encoder 把图像压到 8 维隐变量,decoder 还原回原尺寸。
"""
def __init__(self, latent_dim: int = 8):
super().__init__()
# 编码器:28 -> 14 -> 7 -> 展平
self.encoder = nn.Sequential(
nn.Conv2d(1, 16, 3, stride=2, padding=1), # (N,16,14,14)
nn.ReLU(inplace=True),
nn.Conv2d(16, 32, 3, stride=2, padding=1), # (N,32,7,7)
nn.ReLU(inplace=True),
nn.Flatten(),
nn.Linear(32 * 7 * 7, latent_dim),
)
# 解码器:线性 -> 反卷积还原
self.decoder_input = nn.Linear(latent_dim, 32 * 7 * 7)
self.decoder = nn.Sequential(
nn.ConvTranspose2d(32, 16, 3, stride=2, padding=1, output_padding=1),
nn.ReLU(inplace=True),
nn.ConvTranspose2d(16, 1, 3, stride=2, padding=1, output_padding=1),
nn.Sigmoid(), # 输出归一到 [0,1]
)
def forward(self, x):
z = self.encoder(x)
h = self.decoder_input(z).view(-1, 32, 7, 7)
return self.decoder(h)
def energy(self, x):
"""能量 = 重构误差,逐样本返回。"""
recon = self.forward(x)
return F.mse_loss(recon, x, reduction="none").flatten(1).mean(dim=1)
这里 ConvTranspose2d 的 output_padding=1 是必须的,否则 7 → 14 → 28 的尺寸还原不上,会变成 7 → 13 → 25,最后一算 loss 就报尺寸不匹配。这是反卷积里最容易踩的坑,记住一条经验:stride=2, kernel=3, padding=1 的反卷积,要输出恰好两倍尺寸,必须配 output_padding=1。
训练目标用 MSE,因为输入已经归一化到 [0,1](后面的 Sigmoid 保证输出也在这个范围),用交叉熵反而不合适。
python复制# 文件路径:train_ae.py
import torch
import torch.nn as nn
from src.autoencoder import ConvAutoencoder
from src.data import get_mnist_loaders
from src.utils import set_seed
set_seed(42)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
train_loader, test_loader = get_mnist_loaders(batch_size=128)
model = ConvAutoencoder(latent_dim=8).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.MSELoss()
for epoch in range(10):
model.train()
running = 0.0
for images, _ in train_loader: # 注意:自编码器不用标签
images = images.to(device)
optimizer.zero_grad()
recon = model(images)
loss = criterion(recon, images)
loss.backward()
optimizer.step()
running += loss.item() * images.size(0)
print(f"epoch {epoch+1} | mse={running / len(train_loader.dataset):.5f}")
# 用能量做一次简单的异常打分
model.eval()
with torch.no_grad():
images, _ = next(iter(test_loader))
images = images.to(device)
energies = model.energy(images)
print("前 10 个样本的能量:", energies[:10].cpu().numpy().round(4))
5.3 结果观察与延伸
训练 10 个 epoch 后,MSE 大概会降到 0.01 到 0.02 区间,重构图像能看出数字轮廓但比较模糊,这是 8 维隐变量的信息瓶颈造成的。想看清楚一点,把 latent_dim 调到 32 试试,MSE 会明显下降,但隐空间的可解释性会弱一些。
这里有个值得动手的实验:把测试集里的数字“1”和“7”混在一起,看能量分布有没有明显分层。理论上,训练集里出现频率低的模式,重构误差会偏大。如果你的能量分布几乎重合,说明模型欠拟合或者隐变量维度太高(相当于没压缩,什么都能重构出来)。
这个能量打分的思路直接可以迁移到实际项目里。比如一批传感器时序数据,正常状态训练一个自编码器,线上用能量值做阈值告警。要注意的是阈值不能拍脑袋定,得在验证集上按分位数来选,比如取正常样本能量的 99% 分位,同时留出人工复核的缓冲区间。
6. 实战三:BatchNorm 与 Dropout 的对照实验
6.1 设计一个可对比的实验
课程里讲正则化时反复强调一个观点:任何正则化手段都是在用训练时的噪声换泛化能力。这句话光看没用,得自己跑一遍对照。实验设计如下:同一份数据、同一个随机种子、同一个优化器,只改模型里的正则化配置,跑三组。
python复制# 文件路径:src/models_variants.py
import torch.nn as nn
import torch.nn.functional as F
class CNNVariant(nn.Module):
"""可通过开关控制 BatchNorm 和 Dropout 的 CNN。"""
def __init__(self, use_bn: bool = True, dropout: float = 0.0):
super().__init__()
self.use_bn = use_bn
self.conv1 = nn.Conv2d(1, 16, 3, padding=1)
self.conv2 = nn.Conv2d(16, 32, 3, padding=1)
self.bn1 = nn.BatchNorm2d(16) if use_bn else nn.Identity()
self.bn2 = nn.BatchNorm2d(32) if use_bn else nn.Identity()
self.dropout = nn.Dropout(dropout)
self.pool = nn.MaxPool2d(2)
self.fc = nn.Linear(32 * 7 * 7, 10)
def forward(self, x):
x = self.pool(F.relu(self.bn1(self.conv1(x))))
x = self.pool(F.relu(self.bn2(self.conv2(x))))
x = self.dropout(x.flatten(1))
return self.fc(x)
注意 nn.Identity() 这个技巧:用占位模块代替条件分支,可以让 forward 里不出现 if,代码更干净,也不会影响计算图。
6.2 跑三组配置并记录结果
python复制# 文件路径:run_ablation.py
import torch
import torch.nn as nn
from src.data import get_mnist_loaders
from src.models_variants import CNNVariant
from src.train import train_one_epoch, evaluate
from src.utils import set_seed
configs = [
{"name": "baseline", "use_bn": False, "dropout": 0.0},
{"name": "bn_only", "use_bn": True, "dropout": 0.0},
{"name": "bn_drop", "use_bn": True, "dropout": 0.3},
]
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
train_loader, test_loader = get_mnist_loaders(batch_size=128)
criterion = nn.CrossEntropyLoss()
for cfg in configs:
set_seed(42) # 每组实验前重置种子,保证起点一致
model = CNNVariant(use_bn=cfg["use_bn"], dropout=cfg["dropout"]).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
history = []
for epoch in range(8):
tr_loss, tr_acc = train_one_epoch(
model, train_loader, optimizer, criterion, device
)
te_loss, te_acc = evaluate(model, test_loader, criterion, device)
history.append((tr_acc, te_acc))
final_train, final_test = history[-1]
gap = final_train - final_test
print(
f"{cfg['name']:>10} | train_acc={final_train:.4f} "
f"test_acc={final_test:.4f} gap={gap:.4f}"
)
6.3 如何解读结果
在 MNIST 这种简单任务上,三组的测试准确率可能都在 98% 以上,差距不大,这时候要看的不是绝对值,而是两点:收敛速度(前两个 epoch 的准确率爬升)和训练与测试的差距。
按经验,加了 BatchNorm 的组在第一轮结束时准确率明显更高,因为归一化让损失曲面更平滑,梯度方向更稳定。加 Dropout 的组训练准确率会下降,但训练-测试差距会缩小,这是 Dropout 用训练噪声换泛化能力的直接体现。如果开了 Dropout 之后测试准确率反而下降,通常说明模型容量本来就不够,这时候加正则化只会雪上加霜。
这个实验的框架可以反复用。想做学习率对照、优化器对照、数据增强对照,只要改 configs 列表和对应的模型构造参数就行。建议每次实验把配置和结果写进一个 CSV,日积月累就是自己的经验库。
7. 训练不收敛时的排查思路
7.1 高频问题对照表
| 现象 | 常见原因 | 排查与解决 |
|---|---|---|
| loss 变成 nan | 学习率过大、除零、log(0) | 降学习率、加 eps、检查归一化 |
| loss 完全不降 | 漏 zero_grad、参数没传给优化器、标签错位 |
打印参数个数、检查 model.parameters() |
| 训练准确率高测试低 | 忘了 model.eval()、过拟合 |
检查模式切换、观察 gap 大小 |
| 准确率卡在 10% | 分类数配错、输出维度不匹配 | 打印 logits.shape |
| 显存爆掉 | batch 太大、中间张量没释放 | 降 batch、评估加 no_grad |
| 速度异常慢 | num_workers=0、数据在 CPU 未搬移 |
调 worker 数、确认 .to(device) |
| 结果每次都不一样 | 种子未固定、cuDNN 非确定性 | 调用 set_seed、开 deterministic |
| 尺寸不匹配报错 | 卷积/池化后空间尺寸算错 | 打印每层输出 shape 逐层核对 |
7.2 用 hook 打印每一层的输出尺寸
尺寸对不上是新手最常见的报错。与其手算,不如让代码告诉你:
python复制# 文件路径:inspect_shapes.py
import torch
from src.models import SmallCNN
model = SmallCNN()
x = torch.randn(2, 1, 28, 28)
def hook_fn(module, inputs, output):
print(f"{module.__class__.__name__:>15} | 输出形状: {tuple(output.shape)}")
handles = []
for name, module in model.named_modules():
if name == "":
continue
handles.append(module.register_forward_hook(hook_fn))
with torch.no_grad():
model(x)
for h in handles:
h.remove()
把 x 换成你实际的输入尺寸,一眼就能看出在哪一层维度崩了。这个技巧在接手别人的模型、或者移植网络到新输入分辨率时特别省时间。
7.3 定位问题的顺序建议
遇到不收敛,按这个顺序查,能覆盖八成情况:
- 先确认能过拟合一个小批量。取 8 到 16 个样本,关掉所有正则化,训练几百步,loss 应该能降到接近 0。如果连这个都做不到,问题一定在模型结构、损失函数或者优化器配置上,跟数据量、正则化无关。
- 再确认数据管道正确。随机取几个 batch,把图像反归一化后可视化,把标签打印出来,肉眼核对一遍对应关系。数据错位是隐蔽性最强的 bug,因为它不会报错,只是让模型学不到东西。
- 最后才调超参数。学习率、batch size、权重衰减这些,在确认前两步没问题之后再动。
这个顺序的价值在于排除法:每一步都把问题范围缩小一半,比你随机改参数撞运气高效得多。
8. 把课程内容转成工程能力的实践建议
8.1 实验配置与日志管理
课程里的 notebook 适合理解原理,但不适合做需要反复对比的实验。建议从第四个实验开始,把超参数从代码里抽出来,用 dataclass 或 YAML 管理:
python复制# 文件路径:src/config.py
from dataclasses import dataclass, asdict, field
from typing import List
@dataclass
class TrainConfig:
exp_name: str = "baseline"
seed: int = 42
batch_size: int = 128
lr: float = 1e-3
epochs: int = 10
latent_dim: int = 8
tags: List[str] = field(default_factory=list)
def to_dict(self):
return asdict(self)
每次实验开始时把 to_dict() 的结果序列化成 JSON 存进 runs/<时间戳>/config.json,同时把 git commit hash 也记上。这件事听起来繁琐,但当你两周后想复现某个结果、却发现代码已经改过三轮时,会庆幸当时多写了这几行。配置和代码分离还有一个好处:批量跑实验时只需要改配置列表,不用动业务代码,出错概率大幅降低。
日志方面,前期用 print 加一个 CSV 就够了,不必上来就接 TensorBoard 或 WandB。等到你需要同时跑十几组实验、或者需要远程查看曲线时再引入,工具是为需求服务的,提前引入只会增加心智负担。
8.2 代码组织与可维护性
几个具体建议,都是从踩坑里总结出来的:
模型定义和训练逻辑分开。 models.py 只放 nn.Module,不出现任何数据加载和优化器代码。这样模型可以被不同脚本复用,也方便单元测试——喂一个随机张量进去,检查输出形状对不对,这是最快的结构性测试。
不要在 forward 里写副作用。 见过不少代码在 forward 里做 print、改全局变量、写文件,一旦进入 DataParallel 或者编译模式就会出各种诡异问题。调试信息用 hook 拿,不要塞进前向逻辑。
函数返回值要明确。 train_one_epoch 返回 (loss, acc),比返回一个字典更容易在调用处被正确使用,也更容易看出漏了哪个。如果参数超过四个,考虑打包成配置对象。
边界条件显式处理。 空数据集、batch size 大于样本数、类别数为 1,这些情况在实验阶段经常出现,提前加断言比事后 debug 便宜得多。
8.3 从“跑通”到“可信”的验证习惯
跑通一个实验和相信一个实验结论是两件事,中间隔着好几层验证。可以刻意培养下面几个习惯:
每次改动只动一个变量。同时改学习率和网络结构,即使结果变好了也不知道是谁的功劳。这个原则听起来简单,实践中最容易被破坏,因为人总是想快点看到效果。
把基线跑够。新方法比基线好,前提是基线被认真调过。很多“改进有效”的结论,实际上是基线没调好。
记录失败实验。失败实验的价值往往高于成功实验,因为它告诉你边界在哪里。在 README 里开一节“已知无效的尝试”,写上“试过把隐变量加到 128 维,MSE 降低但下游分类没提升”,后来者能省下大量时间。
对异常数值保持敏感。loss 出现 nan、梯度范数突然变成 1e8、准确率单轮跳变 30%,这些都不是“偶然现象”,背后一定有原因。养成看到异常就停下来查的习惯,比多跑十个实验更有价值。
9. 学习节奏安排与配套资料的使用方式
9.1 一条可执行的跟课节奏
按每周两讲的节奏走比较合适,一讲看视频,一讲动手复现,周末做一次整理。具体可以这样安排:看视频时不要暂停记笔记,先完整过一遍建立印象;第二遍看的时候对照讲义里的公式,在 notebook 里把关键推导敲一遍;最后关掉所有材料,从空白文件开始写实现,卡住的地方再回去查。这个“看—抄—默写”的三段式,比单纯看视频的效率高得多。
每个模块结束后做一次小结,形式不限,可以是一段文字、一张手绘图或者一个对比表格。小结要回答三个问题:这个模块解决什么问题、它依赖前面哪个模块的结论、它在整个框架里处于什么位置。能答上来,说明真的理解了结构,而不是记住了一堆名词。
课程配套的 notebook 建议这样用:先自己写一遍,再打开官方 notebook 对照。顺序反了的话,你会不自觉地跟着别人的思路走,失去自己踩坑的机会。而踩坑恰恰是学习效率最高的时刻。
9.2 一份可以直接用的实验记录模板
最后给一个模板,每次实验复制一份填好,存在 runs/ 对应目录下。这份记录比任何笔记都值钱,因为它记录的是你自己的实验事实。
text复制实验编号:EXP-YYYYMMDD-序号
目的:一句话说明这次想验证什么
假设:预期结果是什么,为什么这么预期
配置:lr / batch / epochs / 结构关键参数
改动:相对上一次实验改了什么(只写一处)
结果:train_acc / test_acc / loss 曲线要点
异常:有没有 nan、跳变、速度异常
结论:假设成立 / 不成立 / 无法判断
下一步:基于这个结论,下一个实验做什么
这份模板的核心是“假设”和“改动”两栏。大多数无效实验的根源,是开始之前没想清楚要验证什么,跑完之后又同时改了好几个地方,导致结果无法归因。把这两栏填满,实验的有效性会明显提升。
课程视频和讲义属于公开的教学资源,建议通过官方渠道获取,尊重作者的授权方式。真正决定你能否学到东西的,从来不是有没有拿到全套资料,而是有没有把每一讲的核心概念落成一段能跑、能改、能解释的代码。上面三个实战覆盖了卷积、自编码器和正则化对照,改动量都不大,建议先照着跑通,再按自己的项目场景替换数据,这个过程走完一遍,课程里的那些公式就不再是纸上的符号了。
