学习深度学习课程最容易走的弯路,是把视频从头拉到尾,却不留任何可以运行的东西。我见过不少同学笔记记了几十页公式,让他写一个卷积层的前向传播却卡住;也见过有人把课程里讲的能量函数视角当成“玄学”,回头还是只用交叉熵调包。这篇笔记围绕纽约大学那门由 LeCun 参与的深度学习公开课展开,把课程的知识地图、本地实验环境、核心概念拆解、可运行代码、排错清单整理成一条完整链路。无论你是刚学完机器学习基础的学生,还是想补齐深度学习底层认知的后端开发者,都可以跟着本文把“看视频”变成“能复现”。文中涉及的课程模块以公开资料常见组织方式整理,具体课时与顺序请以你手上拿到的版本为准。
1. 这门课的知识地图与学习定位
1.1 课程的基本情况与讲授者背景
这门课是纽约大学开设的深度学习研究生课程,公开版本由 Yann LeCun 与 Alfredo Canziani 等人讲授,在公开平台上累计了相当可观的播放量,也因此成为很多人接触“学院派深度学习”的第一站。与市面上大量偏应用的速成课不同,它的重心不在教你快速调通一个业务模型,而在于讲清楚“为什么这么设计”。
LeCun 是卷积神经网络的主要推动者之一,也是深度学习领域长期的基础研究者。这种学术背景决定了课程的叙述方式:它不会一上来就甩出十几个 API,而是先从函数逼近、能量最小化的角度重新描述“学习”这件事,再把卷积、注意力、自监督、生成模型依次挂到这条主线上。理解这一点非常关键,因为很多人看前几集觉得“怎么还在讲线性回归”,实际上后面所有的模型都在复用同一套推导框架。
从学习收益看,这门课能帮你建立三层认知:第一层是数学层,知道梯度、雅可比、链式法则在张量世界里长什么样;第二层是结构层,知道卷积的平移等变性、注意力的置换等变性分别解决什么问题;第三层是工程层,知道损失函数、正则化、优化器之间如何相互制约。三层认知都打通,你在实际项目里调参时,才不是盲猜。
需要提醒的是,公开课的视频是课堂录像,教师的板书推导密度很高,语速也不慢。这就带来一个现实问题:直接看原片,非英语母语的学习者很容易在推导段落掉队。这也是为什么“中文配音 + 配套资料”这种组合形式很受欢迎——配音降低语言门槛,资料降低笔记门槛。但配音终究是辅助,真正的理解还是要在自己的编辑器里跑出来。
1.2 贯穿全课的核心视角:能量函数
如果只能从这门课带走一个概念,那应该是“能量函数”(Energy Function,也常译作能量泛函)。传统教材讲分类,习惯从概率出发:模型输出一组概率,用最大似然去拟合。LeCun 的讲法换了一个角度:模型输出的是一个标量能量值,能量越低表示这个输入与标签越“兼容”。
这两种描述在很多情况下是等价的。比如线性打分器输出 $E(x, y) = -w_y^\top x$,再对它做 softmax,就退化成了大家熟悉的交叉熵。但能量视角的好处是通用性更强:分类、回归、结构化预测、生成建模,都可以统一写成“给正确的配置低能量,给错误的配置高能量”。当课程讲到自监督学习和基于能量的模型时,这个视角会反复出现。
更实际的一点是,能量视角能帮助你理解“损失函数为什么长这样”。很多初学者觉得合页损失、对比损失、交叉熵是三套互不相关的东西,其实它们都是在对能量做不同的约束:交叉熵约束的是“正确类的能量要比其他类低一个 softmax 分布下的期望差距”,合页损失约束的是“正确类能量至少比最强的错误类低一个间隔(margin)”,对比损失约束的是“正样本能量要比最难负样本低一个间隔”。看到这层关系,你面对一个陌生任务时,就知道该往哪个方向设计损失,而不是只会抄 CrossEntropyLoss。
1.3 适合的读者与前置基础
这门课不是零基础入门课。想要跟得顺畅,建议至少具备以下基础:微积分里的偏导与链式法则、线性代数里的矩阵乘法与特征值、概率论里的条件概率与期望、以及一门编程语言的熟练使用能力(Python 最合适)。机器学习方面的基础可以弱一些,但至少要理解“训练集/验证集”“过拟合”“梯度下降”这些概念。
从人群上划分,有三类读者收益最大。第一类是计算机、自动化、数学等相关专业的研究生,课程内容与他们的科研方向高度契合。第二类是已经工作一两年的算法工程师或后端开发者,日常在用 PyTorch 或 TensorFlow,但对底层原理理解不深,想补课。第三类是准备转方向的同学,需要一套体系化的知识框架来对抗零散教程带来的碎片感。
反过来看,如果你连 Python 的类和继承都用不熟,或者完全没接触过 NumPy,建议先花两周补基础再回来。硬啃课程的代价是每集都要暂停查资料,学习曲线会陡峭到让人放弃。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 本地实验环境的搭建与资料管理
2.1 硬件、系统与框架选型
课程里的大量演示基于 PyTorch,这不是强制要求,但强烈建议跟随。原因有两个:一是课程作业和示例代码通常以 PyTorch 风格给出,换成别的框架你会额外承担一层“翻译成本”;二是 PyTorch 的动态图机制和自动微分设计,恰好与课程讲计算图的方式对得上,边看边写非常顺手。
硬件方面,如果你有一块支持 CUDA 的独立显卡,训练小规模实验会快很多;没有也没关系,本文的示例都在 MNIST 和随机张量上跑,CPU 完全可以胜任,只是训练轮数要少一些。操作系统选 Linux、macOS 或 Windows 都行,差别主要在包管理和路径写法上。本文的命令以 Linux/macOS 的 bash 为主,Windows 用户把路径分隔符换成反斜杠即可。
关于版本,这里不给死具体数字,因为 PyTorch 与 CUDA 的版本匹配关系变化较快,写死容易误导。通用的原则是:先确定你的显卡驱动支持的 CUDA 版本,再去 PyTorch 官网的安装选择器里选对应的命令,不要凭记忆手敲。
2.2 用虚拟环境隔离依赖
深度学习项目最怕依赖污染。建议每个课程实验都建独立环境,用 conda 或 venv 都可以。
bash复制# 方案一:conda(推荐,对 CUDA 依赖管理更友好)
conda create -n dl-nyu python=3.10 -y
conda activate dl-nyu
# 方案二:venv(不想装 conda 时使用)
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
环境建好后安装核心依赖。下面这条命令是 CPU 版本的通用写法,如果你要用 GPU,请到官网按你的 CUDA 版本替换安装源地址:
bash复制pip install --upgrade pip
pip install torch torchvision torchaudio
pip install numpy matplotlib jupyter tqdm
安装完成后一定要验证,别等到写代码时才发现装错了:
python复制# 文件路径:check_env.py
import torch
print("PyTorch 版本:", torch.__version__)
print("CUDA 是否可用:", torch.cuda.is_available())
if torch.cuda.is_available():
print("显卡型号:", torch.cuda.get_device_name(0))
print("CUDA 版本:", torch.version.cuda)
# 做一个最小张量运算,确认后端正常
x = torch.randn(3, 3)
y = torch.randn(3, 3)
print("矩阵乘法结果形状:", (x @ y).shape)
预期输出会打印出版本号、True 或 False,以及 torch.Size([3, 3])。如果 torch.cuda.is_available() 返回 False 但你确实有显卡,八成是驱动版本与 PyTorch 编译的 CUDA 版本不匹配,这时候不要硬改代码,回去重装对应版本更省时间。
2.3 目录结构与资料归档方式
资料多、代码多的时候,目录结构混乱会直接摧毁学习节奏。建议按“课程模块 + 实验编号”组织:
code复制dl-nyu-2021/
├── envs/ # 环境导出文件
│ └── environment.yml
├── notes/ # 每讲的 Markdown 笔记
│ ├── 01-intro.md
│ ├── 02-gradient-descent.md
│ └── ...
├── labs/ # 可运行实验
│ ├── lab01_energy/
│ │ ├── main.py
│ │ └── README.md
│ ├── lab02_autograd/
│ └── lab03_lenet/
├── data/ # 数据集缓存,加入 .gitignore
└── slides/ # 讲义 PDF,只做本地留存
这个结构有两个好处。一是 notes 和 labs 分离,笔记可以写得随意,实验代码必须能跑。二是 data 单独放,避免数据集被误提交到版本库。如果你用 Git 管理,记得写 .gitignore:
bash复制# 文件路径:.gitignore
data/
__pycache__/
*.pyc
.ipynb_checkpoints/
.venv/
*.pth
*.pt
把权重文件和数据集排除掉,仓库体积才能控制住。导出环境也顺手做一下,换机器时能一键还原:
bash复制conda env export > envs/environment.yml
# 还原时
conda env create -f envs/environment.yml
3. 核心概念拆解:能量函数与损失设计
3.1 从打分到能量:模型到底输出了什么
先建立一个最朴素的模型。假设输入是一个 $d$ 维向量 $x$,我们要把它分到 $C$ 个类别中的一类。模型内部有一个权重矩阵 $W \in \mathbb{R}^{C \times d}$,那么对每个类别 $y$,可以定义一个能量:
$$E(x, y) = -W_y^\top x$$
这里 $W_y$ 是权重矩阵的第 $y$ 行。能量越低,说明这个类别与输入越匹配。推理时取能量最低的类别:
$$\hat{y} = \arg\min_{y} E(x, y)$$
如果把它写成矩阵形式,$E(x, \cdot) = -Wx$,就是一个线性层取负号。注意这里没有 softmax,也没有概率归一化。这是课程反复强调的一点:概率不是必需品,它只是能量在一个特定形式下的副产品。
为什么这个视角有用?因为当你面对一个“输出不是类别而是结构”的任务时,概率框架会变得别扭。比如图像修复,输出是一整张图,你没法对 $256^{1000}$ 种可能的图做归一化。但你可以定义一个能量:真实图像能量低,破损图像能量高,然后用优化方法去找低能量区域。这就是基于能量的模型(EBM)的核心思想,也是课程后半段生成模型部分的基础。
3.2 三种常见损失函数在能量视角下的统一
理解了能量,再回头看损失函数,会发现它们都在做同一件事:把正确配置的能量往下压,把错误配置的能量往上推,只是“推多高”的规则不同。
交叉熵(Cross Entropy)等价于对能量做 softmax 后的负对数似然。它的特点是所有错误类别都会被“温和地”惩罚,梯度不会饱和得太快。合页损失(Hinge Loss)只惩罚那些“能量已经接近正确类”的错误类,间隔之外的错误类直接不管,这让模型更关注难样本。对比损失(Contrastive Loss)则把“最难负样本”单独拎出来比较,常用于自监督和度量学习。
下面这段代码把三者放在一起跑,你能直观看到同一个能量矩阵下不同损失的数值差异:
python复制# 文件路径:labs/lab01_energy/loss_compare.py
import torch
import torch.nn as nn
import torch.nn.functional as F
class LinearEnergy(nn.Module):
"""最朴素的能量函数:线性打分器,输出值越小表示越匹配"""
def __init__(self, in_dim: int, out_dim: int):
super().__init__()
self.linear = nn.Linear(in_dim, out_dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# 返回形状 (B, C) 的能量矩阵
return self.linear(x)
def compare_losses() -> None:
torch.manual_seed(0)
batch_size, num_classes = 4, 3
# 模拟一个能量矩阵,注意这里是"能量",不是概率
energy = torch.randn(batch_size, num_classes)
target = torch.tensor([0, 2, 1, 0])
# 1) 交叉熵:softmax 之后取负对数似然
ce = F.cross_entropy(energy, target)
# 2) 合页损失:只惩罚落在间隔内的错误类
hinge = F.multi_margin_loss(energy, target, margin=1.0)
# 3) 对比式能量损失:正样本能量 vs 最难负样本能量
e_pos = energy.gather(1, target.view(-1, 1)).squeeze(1)
mask = F.one_hot(target, num_classes).bool()
e_neg = energy.masked_fill(mask, float("inf")).min(dim=1).values
contrastive = F.relu(1.0 + e_pos - e_neg).mean()
print(f"energy matrix:\n{energy}")
print(f"cross_entropy = {ce.item():.4f}")
print(f"hinge = {hinge.item():.4f}")
print(f"contrastive = {contrastive.item():.4f}")
if __name__ == "__main__":
compare_losses()
运行后你会看到三个数值,它们量级不同但趋势一致。这里有一个容易踩的坑:F.cross_entropy 内部会自动做 log-softmax,所以你传进去的必须是原始能量(logits),不要再手动 softmax 一次,否则结果会完全错乱。这是新手最常犯的错误之一。
3.3 损失函数选择的实践判断
在真实项目里怎么选?给几条可操作的判断标准。如果任务是标准的多分类,交叉熵基本是默认答案,它的数值稳定性和梯度性质都经过充分验证。如果任务对“错得离谱”的样本不敏感、更关注边界样本,可以考虑合页类损失。如果任务是检索、聚类、人脸比对这类需要“度量相似度”的场景,对比损失或三元组损失更合适。
还有一点常被忽略:损失函数要和输出层的激活函数配套。用交叉熵时输出层不要加 softmax(框架内部会处理),用均方误差做回归时输出层通常不加激活,用二元交叉熵时输出层加 sigmoid 更自然。这套对应关系搞错,训练会莫名其妙地不收敛。
4. 核心概念拆解:反向传播与自动微分
4.1 计算图与链式法则的实际含义
反向传播(Backpropagation)的本质就是链式法则在计算图上的高效实现。课程推导时会引入雅可比矩阵(Jacobian),看起来吓人,但落到工程上其实只有一句话:每个算子都知道“输出对输入的局部导数”,框架把这些局部导数按拓扑逆序乘起来,就得到了全局梯度。
理解这一点,你就明白为什么 PyTorch 里自定义算子需要实现 forward 和 backward 两个方法。forward 算值,backward 拿上游传来的梯度乘以自己的局部导数,再往下传。这也解释了为什么中间变量必须保存:某些算子的局部导数依赖前向时的输入值,比如 exp 的导数是它自己,relu 的导数依赖输入是否大于零。
有一个很实用的检查手段叫“梯度校验”(Gradient Check),用数值差分去逼近解析梯度,两者接近说明你的反向传播写对了。虽然日常用框架不需要手写反向,但一旦你要实现自定义损失或者自定义层,这招就是救命稻草。
4.2 PyTorch autograd 的工作机制
PyTorch 的自动微分有三条规则,记住它们能避免 90% 的报错。第一,只有 requires_grad=True 的张量才会被追踪,模型参数默认满足这个条件,输入数据默认不满足。第二,每调用一次 backward(),梯度会累加到 .grad 上而不是覆盖,所以训练循环里必须手动 zero_grad()。第三,torch.no_grad() 会关闭追踪,推理和参数更新时必须用它包起来,否则会白白占用显存。
还有一个隐式规则:计算图在 backward() 之后默认会被释放。如果你想对同一个损失反向两次,需要传 retain_graph=True。这个参数在实现某些对抗训练或多任务损失时会用到,但滥用会导致显存泄漏。
4.3 手写前向并验证梯度
下面这个例子用纯张量运算写一个两层网络,然后用数值差分验证 autograd 算出来的梯度是否正确。这段代码不长,但它把“课程里的公式”和“框架里的实现”直接对上了:
python复制# 文件路径:labs/lab02_autograd/grad_check.py
import torch
import torch.nn.functional as F
def forward(x, w1, b1, w2, b2):
"""两层网络的前向:ReLU 隐藏层 + 线性输出"""
hidden = torch.relu(x @ w1 + b1)
return hidden @ w2 + b2
def numeric_grad(x, w1, b1, w2, b2, target, index, eps=1e-6):
"""用中心差分估计某个参数的梯度"""
with torch.no_grad():
w1[index] += eps
loss_plus = F.cross_entropy(forward(x, w1, b1, w2, b2), target).item()
w1[index] -= 2 * eps
loss_minus = F.cross_entropy(forward(x, w1, b1, w2, b2), target).item()
w1[index] += eps # 复原
return (loss_plus - loss_minus) / (2 * eps)
def main():
torch.manual_seed(42)
# 用 float64 提高数值精度,float32 下差分误差会偏大
dtype = torch.float64
x = torch.randn(8, 4, dtype=dtype)
w1 = torch.randn(4, 6, dtype=dtype, requires_grad=True)
b1 = torch.zeros(6, dtype=dtype, requires_grad=True)
w2 = torch.randn(6, 3, dtype=dtype, requires_grad=True)
b2 = torch.zeros(3, dtype=dtype, requires_grad=True)
target = torch.randint(0, 3, (8,))
logits = forward(x, w1, b1, w2, b2)
loss = F.cross_entropy(logits, target)
loss.backward()
idx = (0, 0)
print(f"loss = {loss.item():.6f}")
print(f"autograd grad = {w1.grad[idx].item():.8f}")
print(f"numerical grad = {numeric_grad(x, w1, b1, w2, b2, target, idx):.8f}")
if __name__ == "__main__":
main()
预期两个梯度值在 1e-6 量级上一致。如果差距很大,通常有几种原因:一是用了 float32 导致差分精度不够,二是数值扰动时忘了 torch.no_grad(),三是参数复原时步长写错。这套排查思路同样适用于你自己实现新算子时的调试。
5. 核心概念拆解:卷积网络与归纳偏置
5.1 卷积到底带来了什么
课程讲卷积时,会强调“归纳偏置”(Inductive Bias)这个词。它的意思是:模型在设计时就带上了对数据结构的先验假设。全连接层假设输入的各维度是可以任意重排的,而卷积层假设输入具有局部性和平移等变性——相邻像素更相关,物体平移之后特征也应该平移。
这个假设在图像上极其有效。一个 $224 \times 224 \times 3$ 的输入,全连接层第一层就要几百万参数,而一个 $3 \times 3$ 卷积核只有 27 个参数,靠权重共享扫过整张图。参数量的差距不是几十倍,而是几个数量级。这也是为什么在小数据集上卷积网络不容易过拟合,而全连接网络几乎必然过拟合。
需要注意的是,卷积的平移等变性是有条件的。标准卷积对整数像素的平移是等变的,但对旋转、缩放并不等变。后续的很多工作(可变形卷积、等变网络)就是在补这个短板。理解“模型带了什么假设”,你才知道它在哪里会失效。
5.2 感受野、步长与池化的配合
感受野(Receptive Field)指输出特征图上一个位置能“看到”输入图像的多少区域。它由卷积核大小、层数、步长共同决定。计算方式可以递归地推:每增加一层 $k \times k$ 卷积(步长 1),感受野加 $k-1$。所以三个 $3 \times 3$ 卷积堆叠的感受野是 $7 \times 7$,和单个 $7 \times 7$ 卷积一样,但参数量更少、非线性更强。
池化(Pooling)的作用是降采样,同时带来一定的平移不变性。最大池化保留局部最强响应,平均池化做平滑。现代架构里池化的使用在减少,很多网络用步长大于 1 的卷积代替,或者用全局平均池化替代最后的全连接层。这个演进背后的逻辑是:池化丢失了位置信息,而在需要精确定位的任务(如分割、检测)里,位置信息很宝贵。
工程上一个常见误区是盲目加深网络。每加一层都会让梯度传播路径变长,如果没有残差连接或归一化层,深层网络反而更难训练。这也是为什么 ResNet 的残差连接在课程里被反复提及——它本质上是在给梯度提供一条“高速公路”。
5.3 完整跑通一个 LeNet 风格网络
理论说再多,不如跑一遍。下面是一个 LeNet 风格的网络,在 MNIST 上训练,代码完整可复制。选择 LeNet 而不是 ResNet,是因为它足够小,CPU 上几分钟就能跑完,适合用来验证环境。
python复制# 文件路径:labs/lab03_lenet/lenet_mnist.py
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
class LeNet5(nn.Module):
"""LeNet-5 的现代改写:ReLU 替代 tanh,MaxPool 替代平均池化"""
def __init__(self, num_classes: int = 10):
super().__init__()
self.conv1 = nn.Conv2d(1, 6, kernel_size=5, padding=2) # 28x28 -> 28x28
self.conv2 = nn.Conv2d(6, 16, kernel_size=5) # 14x14 -> 10x10
self.fc1 = nn.Linear(16 * 5 * 5, 120)
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, num_classes)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = F.max_pool2d(F.relu(self.conv1(x)), 2) # 28 -> 14
x = F.max_pool2d(F.relu(self.conv2(x)), 2) # 10 -> 5
x = x.flatten(1)
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
return self.fc3(x)
def build_loaders(batch_size: int = 128):
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)), # MNIST 全局均值与标准差
])
train_set = datasets.MNIST("./data", train=True, download=True, transform=transform)
test_set = datasets.MNIST("./data", 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=256, shuffle=False, num_workers=2)
return train_loader, test_loader
@torch.no_grad()
def evaluate(model, loader, device):
model.eval()
correct, total, loss_sum = 0, 0, 0.0
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
logits = model(images)
loss_sum += F.cross_entropy(logits, labels, reduction="sum").item()
correct += (logits.argmax(dim=1) == labels).sum().item()
total += labels.size(0)
return loss_sum / total, correct / total
def train(epochs: int = 3, lr: float = 1e-3):
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = LeNet5().to(device)
train_loader, test_loader = build_loaders()
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
for epoch in range(1, epochs + 1):
model.train()
running_loss = 0.0
for step, (images, labels) in enumerate(train_loader, start=1):
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad() # 必须清空上一轮梯度
logits = model(images)
loss = F.cross_entropy(logits, labels)
loss.backward() # 反向传播
optimizer.step() # 更新参数
running_loss += loss.item()
if step % 100 == 0:
print(f"epoch {epoch} step {step} loss {running_loss / 100:.4f}")
running_loss = 0.0
test_loss, test_acc = evaluate(model, test_loader, device)
print(f"[epoch {epoch}] test_loss={test_loss:.4f} test_acc={test_acc:.4f}")
return model
if __name__ == "__main__":
trained = train(epochs=3)
torch.save(trained.state_dict(), "lenet_mnist.pth")
print("模型已保存到 lenet_mnist.pth")
预期输出里,测试准确率在三轮之后通常在 0.98 以上。如果低于 0.95,先检查归一化参数有没有写错,再检查数据加载时 ToTensor 是否生效(它会把 0-255 的像素值缩放到 0-1)。这两个点是最容易出问题的。
6. 核心概念拆解:注意力机制与 Transformer
6.1 从卷积到注意力:动机在哪
卷积的局限在于它只做局部交互。一个 $3 \times 3$ 卷积核只能看到 9 个位置,要建立远距离依赖必须堆很多层。注意力机制的思路完全不同:它让每个位置直接和序列里所有位置做加权求和,一步就能建立全局依赖。
课程讲这部分时,会强调注意力的“置换等变性”(Permutation Equivariance)。也就是说,如果你把输入序列的顺序打乱,注意力的输出也会相应地打乱,内容本身不变。这既是优点也是缺点:优点是不依赖顺序假设,通用性强;缺点是序列的顺序信息丢失了,必须额外加位置编码补回来。
6.2 自注意力的计算过程
标准自注意力分四步。第一步,把输入 $X$ 分别投影成查询 $Q$、键 $K$、值 $V$ 三个矩阵,投影用三个独立的线性层。第二步,计算 $QK^\top$ 得到相似度矩阵,再除以 $\sqrt{d_k}$ 做缩放。第三步,对相似度做 softmax 得到注意力权重。第四步,用权重对 $V$ 加权求和。
这里的缩放因子 $\sqrt{d_k}$ 不是随便写的。当 $d_k$ 较大时,$Q$ 和 $K$ 的点积方差会随维度线性增长,softmax 的输入过大会让梯度接近于零,训练直接停滞。除以 $\sqrt{d_k}$ 是把方差拉回 1 附近的标准化手段。这是个很小的细节,但很多自己实现注意力的人忘了它,然后就发现模型完全不学习。
另外,因果掩码(Causal Mask)在语言模型里是必需的。它把当前位置之后的所有位置 mask 成负无穷,保证模型只能看到过去的 token。这个机制在训练自回归模型时如果不加,会造成信息泄漏,训练损失很低但推理效果奇差。
6.3 最小可运行的单头注意力
下面这个实现把上面的四步拆开写,方便对照公式。多头的实现只是把它复制几份、拼起来再投影,逻辑完全一致:
python复制# 文件路径:labs/lab04_attention/single_head.py
import torch
import torch.nn as nn
import torch.nn.functional as F
class SingleHeadSelfAttention(nn.Module):
"""单头自注意力,支持可选的因果掩码"""
def __init__(self, model_dim: int, d_k: int):
super().__init__()
self.d_k = d_k
# 三个投影层,通常不带偏置
self.w_q = nn.Linear(model_dim, d_k, bias=False)
self.w_k = nn.Linear(model_dim, d_k, bias=False)
self.w_v = nn.Linear(model_dim, d_k, bias=False)
def forward(self, x: torch.Tensor, causal: bool = False):
# x: (B, T, model_dim)
q = self.w_q(x) # (B, T, d_k)
k = self.w_k(x) # (B, T, d_k)
v = self.w_v(x) # (B, T, d_k)
# 缩放点积,注意除以 sqrt(d_k)
scores = q @ k.transpose(-2, -1) / (self.d_k ** 0.5) # (B, T, T)
if causal:
T = x.size(1)
mask = torch.triu(
torch.ones(T, T, device=x.device, dtype=torch.bool), diagonal=1
)
scores = scores.masked_fill(mask, float("-inf"))
attn = F.softmax(scores, dim=-1) # (B, T, T)
out = attn @ v # (B, T, d_k)
return out, attn
if __name__ == "__main__":
torch.manual_seed(0)
B, T, D, DK = 2, 5, 16, 8
x = torch.randn(B, T, D)
attn_layer = SingleHeadSelfAttention(model_dim=D, d_k=DK)
out_full, w_full = attn_layer(x, causal=False)
out_causal, w_causal = attn_layer(x, causal=True)
print("输出形状:", out_full.shape) # 期望 (2, 5, 8)
print("注意力权重每行和:", w_full.sum(-1)[0]) # 期望每行约为 1.0
# 因果掩码下,第一个位置只能看到自己
print("因果注意力第一行:", w_causal[0, 0])
print("因果注意力第二行:", w_causal[0, 1])
运行后你会看到,非因果模式下注意力权重每一行的和是 1.0(softmax 的性质),而因果模式下第一行只有一个非零值 1.0,第二行只有前两个位置有值。这个输出可以当作自检:如果因果掩码没生效,第二行之后会出现非零值,那就说明 mask 写反了或者用了 tril。
7. 完整实战:把训练闭环搭起来
7.1 数据管道的组织方式
前面几节都是零散模块,这一节把它们串成一条完整链路。一个规范的训练项目通常拆成四块:配置、数据、模型、训练脚本。拆分的好处是实验可复用——换数据集只改数据模块,换模型只改模型模块。
配置用简单的字典或 dataclass 就够了,不必引入重量级框架:
python复制# 文件路径:labs/common/config.py
from dataclasses import dataclass
@dataclass
class TrainConfig:
batch_size: int = 128
epochs: int = 3
lr: float = 1e-3
weight_decay: float = 1e-4
num_workers: int = 2
data_dir: str = "./data"
ckpt_path: str = "checkpoints/best.pth"
seed: int = 42
固定随机种子是个容易被忽略但很重要的习惯。深度学习里有大量随机性来源:参数初始化、数据打乱、Dropout、数据增强。不固定种子,两次运行的指标差异可能比你想验证的改动还大,实验就失去了可比性。
python复制# 文件路径:labs/common/utils.py
import random
import numpy as np
import torch
def set_seed(seed: int = 42) -> None:
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
需要注意,开启确定性会带来一定的性能损失。在需要严格复现的实验里值得,在追求吞吐量的生产训练里通常关掉。
7.2 训练循环中的关键细节
训练循环看起来很套路,但有几个细节决定成败。第一,model.train() 和 model.eval() 必须显式切换。Dropout 和 BatchNorm 在两种模式下的行为完全不同,忘了切换会导致验证指标异常偏低。第二,验证阶段必须用 torch.no_grad(),否则会构建计算图,显存会随验证集规模线性增长。第三,学习率调度器的调用时机要搞清楚,StepLR 是按 epoch 调,OneCycleLR 是按 step 调,弄错了曲线会很难看。
保存模型也有讲究。推荐保存 state_dict 而不是整个模型对象,前者只存参数,可移植性更好,也不会因为类定义变化而加载失败。同时建议保存优化器状态,方便断点续训:
python复制# 文件路径:labs/lab03_lenet/checkpoint.py
import os
import torch
def save_checkpoint(path: str, model, optimizer, epoch: int, best_acc: float) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
torch.save({
"epoch": epoch,
"model_state": model.state_dict(),
"optim_state": optimizer.state_dict(),
"best_acc": best_acc,
}, path)
def load_checkpoint(path: str, model, optimizer=None):
ckpt = torch.load(path, map_location="cpu")
model.load_state_dict(ckpt["model_state"])
if optimizer is not None and "optim_state" in ckpt:
optimizer.load_state_dict(ckpt["optim_state"])
return ckpt.get("epoch", 0), ckpt.get("best_acc", 0.0)
map_location="cpu" 这个参数建议始终加上。它保证了一个在 GPU 上训练的权重能在没有 GPU 的机器上加载,否则会直接报设备不匹配的错误。
7.3 结果分析与指标解读
训练跑完之后,别只看最后一个准确率数字。至少要看三样东西:训练损失曲线、验证损失曲线、两者之间的间隔。训练损失持续下降而验证损失开始上升,是典型的过拟合信号,这时候该加正则化或者早停。两条曲线都在下降但都很慢,可能是学习率太小。训练损失震荡剧烈,通常是学习率太大或者 batch size 太小。
在 MNIST 这种简单任务上,三轮训练后训练准确率通常能到 0.99,验证准确率 0.98 左右,间隔很小,说明模型容量和任务复杂度匹配。如果换成 CIFAR-10,你会看到间隔明显拉大,这就是需要数据增强和正则化的信号。
还有一点值得强调:单次实验的结果不可信。至少跑三个不同的随机种子,看指标的均值和方差。如果改动带来的提升小于种子间的方差,那这个“提升”很可能只是噪声。这是课程里强调的“科学实验方法”在工程上的体现。
8. 常见问题与排查思路
学习过程中会遇到大量报错和异常现象,下面这张表整理了高频问题。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
RuntimeError: Expected all tensors to be on the same device |
模型在 GPU,数据在 CPU | 统一 .to(device),在数据加载后立刻迁移 |
训练损失一直是 nan |
学习率过大、输入未归一化、出现 log(0) | 降低学习率,检查数据范围,给 log 加 eps |
| 损失不下降,准确率等于随机猜测 | 忘记 zero_grad()、标签与输出维度错位 |
检查训练循环顺序,打印一个 batch 的 shape |
| 验证准确率远低于训练准确率 | 忘了 model.eval(),或验证集有分布偏移 |
补上模式切换,检查数据划分是否泄漏 |
CUDA out of memory |
batch size 过大、验证阶段未禁用梯度 | 减小 batch,验证加 torch.no_grad() |
| 两次运行结果差异巨大 | 未固定随机种子 | 统一设置 Python、NumPy、PyTorch 的种子 |
加载权重报 Missing key(s) in state_dict |
模型结构改动后与权重不匹配 | 用 strict=False 定位,或重新训练 |
| 数据加载速度极慢 | num_workers=0 或磁盘 IO 瓶颈 |
增加 worker 数,数据预处理做缓存 |
除了表格里的通用问题,还有一类“看起来没报错但结果不对”的隐性故障,排查起来更费劲。典型的是数据预处理与推理时不一致。比如训练时做了归一化,推理时忘了做,模型照样输出结果,只是准确率暴跌。这类问题不会抛异常,只能靠“训练和推理共用同一份预处理代码”来预防。
另一个隐性故障是标签顺序错乱。用 ImageFolder 加载数据时,类别索引是按文件夹名排序生成的,如果你在别处硬编码了类别名到索引的映射,两者可能对不上。稳妥的做法是训练时把 class_to_idx 一起保存下来,推理时加载使用。
排查的通用顺序建议是:先确认数据(打印 shape、取值范围的 min/max、看几张样本图),再确认模型(打印参数量、跑一次前向看输出 shape),最后确认训练逻辑(用两三个样本做过拟合测试)。如果模型连三个样本都过拟合不了,那一定是代码有 bug,而不是超参数问题。这个“小样本过拟合测试”是非常高效的调试手段,值得养成习惯。
9. 高效学习这门课的实践建议
9.1 笔记与推导的整理方式
课程推导密度高,纯靠看视频记不住。建议采用“三栏笔记法”:左栏记结论公式,中栏记推导的关键步骤,右栏记“这个公式在代码里对应哪一行”。第三栏是最有价值的,它把抽象符号和具体实现绑定起来,复习时看一眼就能想起来。
公式推导不必追求一次写对。第一遍跟着视频抄,第二遍合上视频自己推,第三遍用代码验证。三遍下来,这个知识点基本就固化了。尤其是反向传播、softmax 的雅可比、注意力的梯度这几处,手推一遍的收益远大于看十遍。
9.2 复现策略:从抄到改
复现课程代码时,不建议一上来就自己从零写。合理路径是:先把示例代码跑通,确认环境和数据没问题;然后做“破坏性实验”,改一个参数看结果怎么变;最后才是脱离参考自己重写。
“破坏性实验”是理解模型最有效的方式。比如把学习率调大十倍看会不会发散,把卷积核换成 $1 \times 1$ 看准确率掉多少,把注意力里的 $\sqrt{d_k}$ 去掉看训练是否停滞。这些对照实验做上十几个,你对每个组件的“作用边界”就有直觉了,这比背结论有用得多。
9.3 数学短板的补法
很多人卡在数学上。这里给一个务实的建议:不要为了学深度学习去系统重学一遍数学分析。按需补,缺什么补什么。课程里反复用到的其实就那么几块:矩阵求导、链式法则、期望与方差、softmax 的导数、拉格朗日乘子(讲正则化时会用到)。
补数学的时候要结合代码。比如理解矩阵求导,最好的方式是用 PyTorch 的 autograd 对着一个具体函数算一遍,再用数值差分验证。符号推导和数值验证双管齐下,比纯看教材快得多。另外,向量化思维的训练也很重要——把循环改写成矩阵运算,既是数学能力的体现,也是性能优化的关键。
10. 学习路线与下一步方向
走完上面这些内容,你应该已经具备了三个能力:能独立搭建 PyTorch 实验环境、能看懂课程里的核心推导并写成代码、能用对照实验验证自己的猜想。这三项能力是后续所有深入学习的底座。
下一步的学习路线可以按兴趣分三条。偏视觉方向,可以继续深入检测与分割,重点理解多尺度特征和位置敏感的设计。偏语言方向,可以顺着注意力往下走 Transformer 的完整结构,再进入预训练模型。偏生成方向,可以研究自编码器、变分方法与对抗训练,这条线与能量函数的联系最紧密。
如果你打算把课程内容真正用起来,有几件事建议尽早养成习惯。第一,所有实验都留一份可运行的最小复现,包括配置、种子、数据版本,这是工程可复现性的基础。第二,涉及生产环境的模型改动,务必在测试环境验证、保留旧版本权重、做好回滚预案,不要直接覆盖线上模型。第三,处理用户数据时严格遵守最小权限原则和隐私要求,训练数据里的敏感字段要做脱敏,模型文件也要控制访问权限。
最后提一句实践层面的建议:这门课的信息量很大,一次性看完不现实。与其追求“刷完”,不如每周挑一个模块,把配套实验跑通、把推导推一遍、写一篇自己的笔记。进度慢一点没关系,关键是每一步都留下能运行、能回溯的东西。半年之后回头看,这套沉淀下来的代码和笔记,比看完多少集视频有价值得多。
