1. 为什么我要整理这份Pytorch实践笔记索引
动手写这个目录之前,我把刘二大人的《Pytorch深度学习实践》系列前前后后刷了三遍。第一遍1.5倍速过剧情,第二遍0.75倍速记代码,第三遍直接关掉视频凭记忆复现,遇到卡壳的地方再定点回看。三遍下来最深的感受是:这套课程的内容密度极高,但知识点散落在几十个视频里,没有一个统一的索引,复习时找某个具体操作(比如DataLoader的num_workers到底怎么设)要来回翻好几个视频,效率很低。
所以这份笔记目录不是简单的视频列表搬运,而是我按自己的学习路径重新编排的知识地图。每一节都标注了这个知识点在整个深度学习体系里的位置、它解决什么问题、和前后知识点的关联,以及我在实操中踩过的坑。对于刚接触Pytorch的朋友,你可以把它当作课程学习的导航仪,按图索骥地推进;对于已经入门的同学,这份索引更适合当速查手册,哪个参数忘了怎么调、哪个API不确定用法,直接定位到对应章节快速回顾。整套笔记覆盖了从环境搭建到CNN实战的完整链路,代码全部基于Pytorch 2.x版本实测通过。
需要提前说明的是,刘二大人的课程偏重“从零手写”来理解底层逻辑,而不是直接调库。这个理念贯穿整套笔记——我们不会一上来就用torchvision.models.resnet50(),而是先自己用nn.Module把卷积、池化、全连接一层层搭出来,理解每一层的输入输出维度怎么算、梯度怎么回传。这个过程会稍微“笨”一点,但走完一遍之后再看那些封装好的高级API,你会清楚地知道它们在背后做了什么。
这套笔记适合的人群:有Python基础但没接触过深度学习框架的在校学生、需要快速上手Pytorch做科研的转行开发者、以及上过理论课但不知道怎么把公式变成代码的自学者。如果你已经能熟练用Keras或TensorFlow搭建模型,这份索引也可以帮你快速建立起Pytorch的思维方式。
2. 笔记整体框架与学习路线设计
2.1 从“线性模型”到“CNN”的递进逻辑
整套课程的设计思路非常清晰,走的是“线性模型 → 梯度下降 → 反向传播 → 神经网络 → CNN”的递进路线。这个顺序不是随便排的,背后有很强的教学考量。
先看线性模型这一章。它用最简单的y = w*x + b作为切入点,让你先理解“模型”到底是什么——本质上就是一个从输入到输出的映射函数,而“训练”就是找到让这个映射最贴合数据的参数。这个阶段代码量极少,重点在于建立直觉:损失函数怎么定义、为什么用均方误差、参数更新方向怎么确定。
接着进入梯度下降。这里开始涉及优化算法的核心——沿着损失函数的梯度反方向走,逐步逼近最小值。课程从最朴素的批量梯度下降讲起,然后引入随机梯度下降(SGD)和小批量梯度下降,解释为什么实际训练中几乎不用全量梯度下降(计算量太大)也不用单样本SGD(震荡太剧烈)。
反向传播是整个课程的第一个难点。刘二大人用计算图的方式把链式法则拆解得非常细,从最简单的两层网络开始,手推每个节点的梯度。这一步绝对不能跳过,因为后面所有的loss.backward()都是在做这件事,只是Pytorch帮你自动完成了。如果你不理解计算图,调backward()的时候就像在盲人摸象。
神经网络章节开始引入nn.Module和nn.Linear,把前面手写的参数封装成层。这里的关键跃迁是学会用类的方式组织模型结构,以及理解forward()函数的作用。到了CNN部分,则是把全连接层替换成卷积层和池化层,核心要掌握的是特征图尺寸的推导公式、通道数的变化规律,以及nn.Conv2d那几个参数到底怎么配。
2.2 每个阶段的核心产出物
我给自己每个阶段都设定了明确的产出目标,有产出才有反馈,有反馈才有动力继续往下走:
| 学习阶段 | 核心产出物 | 检验标准 |
|---|---|---|
| 线性模型 | 手写forward()和loss()函数 | 能解释为什么用(y_pred - y)^2而不是绝对值 |
| 梯度下降 | 手动实现参数更新循环 | 能画出损失随epoch下降的曲线 |
| 反向传播 | 用计算图推导两层网络的梯度 | 手推结果与backward()输出一致 |
| 神经网络 | 用nn.Module搭建三分类网络 | 测试集准确率超过85% |
| CNN | 搭建含两个卷积层的图像分类器 | 理解每层特征图尺寸的变化 |
这个表格我在笔记本上贴了一份,每完成一个阶段就打个勾。实测下来,这种“产出物驱动”的学习方式比单纯看视频有效得多,因为你看完视频产生的“我懂了”的感觉往往是错觉,只有真正把代码写出来跑通了,才算真的掌握。
2.3 哪些章节可以跳过哪些必须反复看
根据我的经验,如果你时间有限,可以按下面的优先级来分配精力:
必须反复看的章节:
- 反向传播的手动推导(对应视频第7-8讲左右):这是整个Pytorch体系的地基,不懂这个后面全是空中楼阁
DataLoader和Dataset的使用:实际项目中数据加载占了大量工作量,这个不熟会非常痛苦- CNN的卷积层参数计算:输入通道、输出通道、卷积核大小、padding、stride这五个参数怎么配合,直接决定模型能不能跑起来
可以快速过的章节:
- 线性模型的数学推导:如果你有微积分基础,这部分看一遍知道思路就行
- 各种优化器的对比介绍:知道SGD、Adam、RMSprop的区别即可,具体用哪个后面做项目时再深入
- 部分案例的完整代码:理解了核心概念后,案例代码可以当作练习自己先写再看
建议跳过的章节(如果你已经有基础):
- Python基础语法复习部分
- 环境搭建的详细步骤(除非你卡在某个具体报错上)
3. 核心知识点索引与深度拆解
3.1 环境搭建:Anaconda + Pytorch + IDE的选择
环境搭建是劝退新人的第一道门槛,我周围至少有五个人卡在这一步超过一天。刘二大人推荐的是Anaconda + Pycharm的组合,这个方案确实最稳妥,但有几个关键决策点需要提前想清楚。
第一个决策:装CPU版还是GPU版?
如果你手头没有NVIDIA显卡(或者显卡显存小于6GB),直接装CPU版,别折腾。GPU版需要装CUDA和cuDNN,版本匹配非常严格——Pytorch 2.0要求CUDA 11.7或11.8,而CUDA版本又受显卡驱动版本限制。我见过太多人在这三个版本之间来回卸载重装,浪费一整天。CPU版虽然训练慢,但用于课程中的小数据集(MNIST、CIFAR-10)完全够用,等确定了要深入做项目再折腾GPU环境也不迟。
第二个决策:用conda还是pip安装?
conda的优势是能自动处理依赖关系,特别是科学计算相关的库(numpy、scipy),conda会一并帮你装好兼容版本。pip的优势是速度快、包更全。我的习惯是:创建conda虚拟环境来隔离项目,然后在环境里用pip安装Pytorch。具体命令如下:
# 创建名为pytorch_env的虚拟环境,指定Python版本 conda create -n pytorch_env python=3.9 -y # 激活环境 conda activate pytorch_env # 用pip安装CPU版Pytorch(Pytorch 2.x版本) pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu # 验证安装 python -c "import torch; print(torch.__version__); print(torch.cuda.is_available())"最后一行如果输出False(CPU版)或True(GPU版),说明安装成功。
踩坑提醒:不要在base环境里直接装Pytorch。base环境是Anaconda的根基,装坏了修复很麻烦,而且不同项目需要的Pytorch版本可能不同,全部塞在base里早晚冲突。
第三个决策:用Pycharm还是VSCode?
这个纯粹看个人习惯。Pycharm的专业版对科学计算支持更好,能直接在IDE里看到变量矩阵的形状;VSCode更轻量,配合Jupyter插件也能交互式运行。我在课程前期用的是Pycharm,因为调试方便,可以打断点一步步看张量的变化。后期转向VSCode + Jupyter Notebook的模式,因为做实验时经常需要改一行跑一次,Notebook的即时反馈更顺手。
3.2 张量操作:Pytorch最基础也最容易翻车的部分
张量是Pytorch里一切数据的基本单位,你可以把它理解成NumPy的ndarray加上GPU加速和自动求导。课程里涉及张量操作的地方非常多,我总结了几个高频操作和对应的注意点:
创建张量的几种方式,区别在于是否初始化以及是否指定数据类型:
import torch # 从Python列表创建,数据类型自动推断 a = torch.tensor([[1, 2], [3, 4]]) # 创建全零张量,指定形状和数据类型 b = torch.zeros(2, 3, dtype=torch.float32) # 创建未初始化的张量(速度快但值是随机的) c = torch.empty(2, 3) # 创建单位矩阵 d = torch.eye(3) # 创建随机张量(正态分布) e = torch.randn(2, 3)关键区别:torch.tensor()会复制数据,torch.from_numpy()会共享内存(改一个另一个也变)。这个在数据预处理阶段非常容易出bug,比如你从NumPy数组创建了张量,然后修改了原数组,发现张量也跟着变了——这就是共享内存导致的。
张量的形状变换是另一个重灾区。view()和reshape()在大多数情况下可以互换,但有一个关键区别:view()要求张量在内存中是连续的,reshape()不要求(它会自动拷贝一份)。所以如果你对一个转置过的张量用view(),可能会报错,这时候换成reshape()就好了。
# 假设有一个4x4的张量 x = torch.randn(4, 4) # 展平成16维向量 x_flat = x.view(16) # 要求x连续 y = x.t() # 转置后不再连续 # z = y.view(16) # 这行会报错 z = y.reshape(16) # 这样写就没问题广播机制需要特别小心。当你对两个形状不同的张量做运算时,Pytorch会自动扩展维度来匹配,但这个扩展规则如果不理解,很容易得到意料之外的结果:
a = torch.tensor([[1], [2], [3]]) # 形状(3, 1) b = torch.tensor([10, 20]) # 形状(2,) c = a + b # 形状(3, 2) # c = [[11, 21], [12, 22], [13, 23]]这里的规则是:把形状(3,1)和(2,)从右往左对齐,维度为1的可以扩展,缺失的维度补1。所以a扩展成(3,2),b扩展成(3,2),然后逐元素相加。
实操心得:每次做完张量运算,养成打印
.shape的习惯。我调试模型时80%的报错都是形状不匹配,比如把(batch_size, 1, 28, 28)的卷积输出直接喂给了需要(batch_size, 784)的全连接层。
3.3 自动求导机制:理解backward()到底做了什么
Pytorch最核心的功能就是自动求导,它让反向传播从“手推公式”变成了“一行代码”。但便利的背后,理解它的工作机制能帮你避开很多坑。
自动求导的核心是计算图(Computation Graph)。当你对一个设置了requires_grad=True的张量做运算时,Pytorch会在后台记录每一步操作,形成一个有向无环图。调用backward()时,它从这个图的末端开始,沿着每条边反向传播梯度,用链式法则算出每个叶子节点的梯度。
# 创建需要求导的张量 x = torch.tensor([2.0, 3.0], requires_grad=True) # 前向计算 y = x ** 2 # y = [4, 9] z = y.sum() # z = 13 # 反向传播,计算z对x的梯度 z.backward() # 查看梯度:dz/dx = 2x = [4, 6] print(x.grad) # 输出: tensor([4., 6.])这里有个关键概念叫叶子节点。只有通过requires_grad=True直接创建的张量才是叶子节点,它们在反向传播后会累积梯度到.grad属性里。中间计算产生的张量(比如上面的y和z)不是叶子节点,它们的梯度不会保留。
梯度累积是另一个容易踩坑的点。每次调用backward(),梯度会累加到.grad上,而不是替换。所以标准的训练循环里必须手动清零:
# 标准训练循环的简写 optimizer.zero_grad() # 清零梯度,不写这行梯度会一直累加 loss.backward() # 反向传播计算梯度 optimizer.step() # 更新参数如果忘了zero_grad(),第二次迭代的梯度会是第一次的两倍,损失曲线会直接飞掉。
detach()和with torch.no_grad()也是必须掌握的。前者从计算图中分离出一个张量,后者在一个代码块内禁用梯度追踪。在模型推理和验证阶段,一定要用它们来节省显存和计算时间:
# 验证阶段,不记录梯度 model.eval() with torch.no_grad(): for data, target in test_loader: output = model(data) # 计算准确率等指标实测经验:如果你在验证阶段忘了加
torch.no_grad(),显存占用会暴涨,小显存显卡直接OOM(内存溢出)。我有一次用6GB显存的笔记本跑CIFAR-10,训练正常但验证时报显存不够,排查了半天才发现是这个问题。
3.4 数据集与数据加载:从Dataset到DataLoader的完整链路
数据是深度学习的燃料,而Pytorch的数据加载机制是课程中段的核心内容。很多教程只教你调DataLoader,但刘二大人会让你先继承Dataset类自己写一个,理解数据从磁盘到模型的完整链路。
Dataset类的核心接口是两个方法:__len__()返回数据集大小,__getitem__(index)返回一条数据。下面是一个自定义数据集的示例框架:
from torch.utils.data import Dataset, DataLoader class MyDataset(Dataset): def __init__(self, data, labels): # 通常在初始化时把数据读进内存或记录文件路径 self.data = data self.labels = labels def __len__(self): # 返回样本总数 return len(self.labels) def __getitem__(self, idx): # 根据索引返回一条数据和标签 sample = self.data[idx] label = self.labels[idx] return sample, labelDataLoader的核心参数我整理成了一张表,这几个参数直接关系到训练效率和显存占用:
| 参数名 | 作用 | 推荐设置 | 注意事项 |
|---|---|---|---|
batch_size | 每次加载多少条数据 | 32/64/128,根据显存调整 | 必须是2的幂次,过大容易OOM |
shuffle | 是否打乱顺序 | 训练集True,验证集False | 验证集打乱会导致指标不稳定 |
num_workers | 用几个子进程加载数据 | Windows下设0,Linux/Mac设4或8 | Windows多进程有坑,设0最稳 |
drop_last | 是否丢弃最后不足一个batch的数据 | 训练集视情况,验证集False | 丢弃可能导致最后几条数据没用到 |
pin_memory | 是否锁页内存加速CPU到GPU的拷贝 | GPU训练时设True | CPU训练时设了反而更慢 |
关于num_workers,有个反直觉的事实:它设得越大不一定越快。当num_workers超过CPU核心数时,进程切换的开销会抵消并行加载的收益。我的经验是设为CPU物理核心数的一半左右比较合适。另外在Windows上,num_workers > 0经常报BrokenPipeError,最省心的做法是直接设0,用主进程加载(速度慢一点但稳定)。
4. 各模块实操要点与容易踩的坑
4.1 手写线性回归时最常见的三个错误
线性回归是课程的第一个实操案例,代码不到20行,但我在做练习时发现周围同学(包括我自己)几乎都在这三个地方翻过车。
错误一:损失函数返回的是张量而不是标量。如果你用loss = (y_pred - y) ** 2,得到的是一个向量,对这个向量调用backward()会报错“grad can be implicitly created only for scalar outputs”。正确做法是对batch内所有样本的损失取平均或求和:
# 错误写法,loss是向量 loss = (y_pred - y) ** 2 # 正确写法,对batch维度取平均 loss = ((y_pred - y) ** 2).mean()错误二:优化器接收的是参数列表而不是模型本身。用torch.optim.SGD(model, lr=0.01)会报错,必须传model.parameters():
# 错误写法 optimizer = torch.optim.SGD(model, lr=0.01) # 正确写法 optimizer = torch.optim.SGD(model.parameters(), lr=0.01)错误三:学习率设得太大导致损失震荡甚至发散。线性回归的损失函数是凸的,理论上一定能找到全局最优,但如果学习率超过临界值(与数据尺度有关),参数更新会直接飞出去变成NaN。我的经验是先用1e-3或1e-4试,观察损失曲线,如果震荡就除以10,如果下降太慢就乘以3。
4.2 逻辑回归与交叉熵:为什么不能用均方误差
逻辑回归用于二分类,输出层通常用Sigmoid把值压到(0,1)区间,然后接一个阈值判断类别。这里的关键问题是:为什么分类问题要用交叉熵损失而不是均方误差?
从理论层面解释:均方误差配合Sigmoid函数时,损失函数关于参数的梯度会包含Sigmoid的导数项sigmoid'(z)。当z很大或很小时,Sigmoid导数趋近于0,导致梯度消失,参数几乎不更新。而交叉熵损失配合Sigmoid时,梯度中的Sigmoid导数项会被约掉,梯度只与预测值和真实值的差有关,更新效率高得多。
从实操层面看,用均方误差做分类时,损失下降很慢,经常需要调很大的学习率才能动,但学习率一大又容易震荡。换成nn.BCELoss或nn.BCEWithLogitsLoss之后,收敛速度会有肉眼可见的提升。
# 二分类的两种损失写法 # 方式一:网络输出经过Sigmoid,损失用BCELoss class Net(nn.Module): def forward(self, x): return torch.sigmoid(self.fc(x)) criterion = nn.BCELoss() # 方式二(推荐):网络输出不接Sigmoid,损失用BCEWithLogitsLoss class Net(nn.Module): def forward(self, x): return self.fc(x) # 不接激活函数 criterion = nn.BCEWithLogitsLoss() # 内部自动做Sigmoid方式二更好,因为BCEWithLogitsLoss在内部用了一种数值更稳定的实现方式,避免了Sigmoid在极端值处溢出。
实操心得:如果你在多分类任务中看到损失不下降,先检查是不是用错了损失函数。多分类必须用
nn.CrossEntropyLoss,它内部已经包含了LogSoftmax操作,所以网络最后一层不要加Softmax。
4.3 多层感知机的维度变换:全连接层之间的衔接
多层感知机(MLP)是课程从线性模型到CNN的过渡,核心在于理解全连接层之间维度怎么衔接。全连接层的本质是一个矩阵乘法:输入向量乘以权重矩阵,加上偏置。
假设输入是28x28的灰度图像,展平后是784维。如果第一层全连接输出256维,第二层输出128维,最后输出10类,那么:
class MLP(nn.Module): def __init__(self): super().__init__() self.flatten = nn.Flatten() # 将(28,28)展平为784 self.fc1 = nn.Linear(784, 256) # 784 -> 256 self.fc2 = nn.Linear(256, 128) # 256 -> 128 self.fc3 = nn.Linear(128, 10) # 128 -> 10 def forward(self, x): x = self.flatten(x) x = torch.relu(self.fc1(x)) x = torch.relu(self.fc2(x)) x = self.fc3(x) # 最后一层不加激活,CrossEntropyLoss会处理 return x这段代码里有两个容易出问题的地方。第一是nn.Flatten()的位置:它默认从第1维开始展平(第0维是batch_size),所以输入形状是(batch, 1, 28, 28)时会变成(batch, 784),这是正确的。如果你手动用x.view(-1, 784),要注意-1表示自动推断batch维度,不要写成固定值。
第二个是最后一层的激活函数:用nn.CrossEntropyLoss时,网络输出不要加Softmax,因为它内部会先做LogSoftmax再做NLLLoss,加了两遍会导致概率分布异常。
4.4 卷积神经网络:特征图尺寸计算的万能公式
CNN部分是课程的重头戏,而特征图尺寸的计算是最核心也最容易算错的地方。我见过太多人因为维度对不上而报错,最后发现是卷积层参数没配对。
先记住这个万能公式,输入特征图尺寸为H_in,卷积核大小为K,填充为P,步长为S,那么输出尺寸H_out为:
H_out = floor((H_in + 2P - K) / S) + 1
下面用几个具体例子来验证:
| 输入尺寸 | 卷积核 | Padding | Stride | 输出尺寸 | 计算过程 |
|---|---|---|---|---|---|
| 28 | 3 | 0 | 1 | 26 | (28+0-3)/1+1 = 26 |
| 28 | 3 | 1 | 1 | 28 | (28+2-3)/1+1 = 28 |
| 28 | 5 | 2 | 1 | 28 | (28+4-5)/1+1 = 28 |
| 28 | 3 | 0 | 2 | 13 | (28+0-3)/2+1 = 13 |
注意第三个例子:卷积核5x5,padding=2时输出尺寸和输入一样,这就是所谓的“same padding”,在需要保持空间尺寸不变时非常有用。
通道数的变化规则更简单:输出通道数等于卷积核的个数,与输入通道数无关(但每个卷积核的深度必须等于输入通道数)。比如输入是(3, 32, 32)的彩色图像,用16个3x3的卷积核,输出就是(16, 30, 30)——通道从3变成了16,空间尺寸从32变成了30。
# 一个典型的CNN结构 class SimpleCNN(nn.Module): def __init__(self): super().__init__() # 输入: (batch, 1, 28, 28) self.conv1 = nn.Conv2d(1, 16, kernel_size=3, padding=1) # 输出(batch, 16, 28, 28) self.pool1 = nn.MaxPool2d(2) # 输出(batch, 16, 14, 14) self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1) # 输出(batch, 32, 14, 14) self.pool2 = nn.MaxPool2d(2) # 输出(batch, 32, 7, 7) self.fc = nn.Linear(32 * 7 * 7, 10) # 全连接分类 def forward(self, x): x = torch.relu(self.conv1(x)) x = self.pool1(x) x = torch.relu(self.conv2(x)) x = self.pool2(x) x = x.view(x.size(0), -1) # 展平,保留batch维度 x = self.fc(x) return x这段代码里x.view(x.size(0), -1)是关键一步。x.size(0)就是batch_size,-1让Pytorch自动计算剩余维度的大小(这里是3277=1568)。如果你硬编码成x.view(-1, 1568),当batch_size变化时不会出问题(因为-1会自动适配),但如果特征图尺寸变了(比如输入图像从28变成32),1568这个数字就需要跟着改。
避坑技巧:在
forward()里加一行print(x.shape)来调试是最高效的方法。尤其是在搭建新网络时,每一步的维度变化都打印出来,和上面表格里的计算结果对照,哪里对不上一眼就能看出来。
4.5 训练循环:从“能跑”到“跑得好”的关键细节
训练循环的代码框架看起来很简单——前向传播、计算损失、反向传播、更新参数——但要让模型真正收敛,里面有几个细节决定成败。
细节一:训练和验证的切换。model.train()和model.eval()不只是形式上的调用,它们会影响Dropout和BatchNorm层的行为。训练时Dropout随机丢弃神经元,验证时保留全部;训练时BatchNorm用当前batch的统计量,验证时用全局移动平均。忘了切换会导致验证结果波动很大。
细节二:准确率的计算方式。分类任务的准确率不能直接比较输出向量,而要取最大值的索引:
# 正确做法:取输出中最大概率对应的类别 _, predicted = torch.max(output, dim=1) correct += (predicted == labels).sum().item()细节三:多个epoch的训练节奏。课程中的案例通常训练10个epoch左右,但实际项目中要看损失曲线来决定何时停止。我的经验是:如果验证损失连续3个epoch不下降,就可以停下来了。继续训练只会让模型在训练集上过拟合,验证集表现反而变差。
# 完整的训练循环框架 for epoch in range(num_epochs): # 训练阶段 model.train() for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() # 验证阶段 model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in test_loader: outputs = model(images) _, predicted = torch.max(outputs, dim=1) total += labels.size(0) correct += (predicted == labels).sum().item() print(f'Epoch {epoch+1}, Accuracy: {100 * correct / total:.2f}%')5. 常见报错与问题排查实录
5.1 维度不匹配类报错
这应该是出现频率最高的一类错误,典型报错信息是RuntimeError: mat1 and mat2 shapes cannot be multiplied或者size mismatch。排查思路是检查全连接层的输入维度是否等于特征图展平后的大小。
举个例子:如果你定义了self.fc = nn.Linear(128, 10),但实际展平后是1568维,就会报错。解决方法有两个:要么调整前面的卷积层让输出特征图大小匹配,要么把全连接层的输入改成正确的维度。我个人的习惯是先用x.view(x.size(0), -1).shape打印出展平后的维度,再据此设置全连接层。
还有一类维度错误发生在数据加载阶段:RuntimeError: Expected 4-dimensional input for 4-dimensional weight。这是因为卷积层需要(batch, channel, height, width)的四维输入,但你可能不小心把channel维度丢了。检查DataLoader返回的数据形状,如果是(batch, 784)的二维数组,需要在模型里加一个nn.Unflatten或手动x.view(-1, 1, 28, 28)。
5.2 梯度异常类报错
RuntimeError: Trying to backward through the graph a second time是因为你对同一个计算图调用了两次backward()而没有保留图。这种情况通常出现在RNN的训练中,或者你在一个迭代里对多个损失分别调用了backward()。解决方法是设置retain_graph=True,或者检查代码逻辑是不是重复调用了。
RuntimeError: grad can be implicitly created only for scalar outputs是因为你对非标量张量调用了backward()。前面提过,要对损失取平均或求和变成标量,再调用backward()。
梯度爆炸表现为损失变成NaN,解决方法是降低学习率、使用梯度裁剪(torch.nn.utils.clip_grad_norm_)、或者在网络中加入BatchNorm层。
5.3 显存不足类报错
CUDA out of memory是GPU训练时的常见报错。解决方法按优先级排序:
| 方法 | 操作 | 效果 | 代价 |
|---|---|---|---|
| 减小batch_size | 从64降到32或16 | 立即见效 | 训练速度变慢,梯度噪声变大 |
用torch.no_grad() | 验证阶段禁用梯度 | 释放大量显存 | 无 |
| 删除无用变量 | del x; torch.cuda.empty_cache() | 释放部分显存 | 无 |
| 用混合精度训练 | torch.cuda.amp | 显存减半 | 需要改少量代码 |
| 减小模型复杂度 | 减少通道数或层数 | 显存大幅降低 | 模型能力下降 |
我个人的经验是先用torch.cuda.empty_cache()清一下缓存,很多时候显存其实没有被真正占用,只是碎片化了。如果还不行再降batch_size。
5.4 训练不收敛类问题
如果损失一直不下降或者震荡剧烈,按以下顺序排查:
学习率是不是太大了。这是最常见的原因。把学习率除以10再试一次,如果损失开始下降就说明原来太大。
数据有没有归一化。如果输入图像的像素值在0-255之间而不是0-1之间,梯度会非常大,训练很容易发散。用transforms.ToTensor()会把像素自动归一化到0-1,别忘了加。
损失函数用对了吗。多分类用CrossEntropyLoss,二分类用BCEWithLogitsLoss,回归用MSELoss。用错了损失函数,模型优化目标就错了,自然不收敛。
模型结构有没有问题。检查最后一层的输出维度是不是等于类别数,中间层的激活函数有没有加,全连接层的输入维度对不对。
速查表:我把自己遇到过的报错和解决方法整理成了一个表格贴在显示器旁边,现在分享出来:
| 报错关键词 | 可能原因 | 优先排查 |
|---|---|---|
| shapes cannot be multiplied | 全连接层维度不匹配 | 打印展平后的shape |
| Expected 4-dimensional input | 卷积层输入少了channel维 | 检查DataLoader输出形状 |
| grad can be implicitly created | 对非标量调用了backward | 损失是否取mean/sum |
| backward through graph a second time | 重复backward | 加retain_graph或改逻辑 |
| CUDA out of memory | 显存不够 | 降batch_size或加no_grad |
| loss is NaN | 学习率太大或数据未归一化 | 降学习率,检查数据范围 |
6. 学习路径建议与后续延伸
6.1 不同基础的人该怎么用这套笔记
零基础(没学过Python和微积分):先花一周时间补Python基础,重点掌握列表、循环、函数、类的概念。然后过一遍吴恩达的机器学习前三周课程,理解什么是梯度下降。再回来看刘二大人的课,从线性模型开始按顺序推进。每看完一个视频,把代码默写一遍再运行,报错就根据第5章的排查表来定位问题。
有Python基础但没接触过深度学习:跳过Python语法部分,直接从线性模型开始。重点是理解计算图和反向传播,这两块花多少时间都值得。我建议手写三遍反向传播的推导过程,直到能不看笔记写出两层网络的梯度公式。
用过其他框架(TensorFlow/Keras):你的优势是理解训练流程,重点看Pytorch和它们的不同之处。Pytorch的“动态图”机制比TensorFlow 1.x的静态图灵活很多——你可以随时print()中间张量的值,不用先搭图再运行。另外nn.Module的写法也需要适应一下。
6.2 从这套课程出发还能往哪些方向深入
刘二大人的课程止步于CNN的基础实现,但深度学习的版图远不止这些。学完这套课后,我建议按下面的方向继续扩展:
方向一:现代CNN架构。课程里手写的CNN是最朴素的版本,实际应用中用的都是ResNet、EfficientNet这些带有残差连接、注意力机制的现代架构。可以在torchvision.models里直接调用预训练模型,理解它们的结构设计思路。
方向二:目标检测与语义分割。这是CNN在计算机视觉中的两大主流应用。目标检测解决“图里有什么,在哪里”的问题,经典算法有YOLO系列和Faster R-CNN。语义分割解决“每个像素属于什么类别”的问题,代表算法是U-Net和DeepLab。
方向三:序列模型与Transformer。CNN擅长处理图像这种空间结构的数据,而文本、语音、时间序列这些有顺序关系的数据需要RNN或Transformer。Transformer现在是NLP领域的绝对主流,理解了它的自注意力机制,再看BERT、GPT这些模型就不会觉得陌生。
方向四:迁移学习。实际项目中很少有人从零训练一个大模型,更常见的做法是拿在ImageNet上预训练好的模型,在自己的数据集上微调几层。Pytorch里实现迁移学习非常方便,只需要冻结前面的层,替换最后一层的输出维度即可。
6.3 我个人的三条学习建议
第一,不要追求一次看懂。我第一遍看反向传播的时候完全懵,第二遍有点感觉,第三遍才真正理解。深度学习的内容密度就是这样,第一遍能建立整体印象就够了,细节在重复中自然就通了。
第二,代码必须自己写,不能复制粘贴。看视频时觉得“这代码很简单”,自己写时才发现各种细节记不住——是optim.zero_grad()还是optimizer.zero_grad(),是loss.backward()还是loss.backward(没有括号)。这些细节只有亲手打过一遍才会形成肌肉记忆。
第三,尽早做一个小项目。课程里的MNIST和CIFAR-10都是玩具数据集,真正做项目时会遇到更多问题:数据不平衡、过拟合、超参数调优、模型部署。我学完课程后花了三天做了一个猫狗分类的小项目,踩的坑比看一个月视频还多,但收获也大得多。
最后分享一个我调参的小技巧:先用一个非常小的子集(比如100条数据)跑通整个训练流程,确认代码没有bug、损失能下降,再换全量数据训练。这样可以快速验证代码正确性,避免在完整数据集上浪费几十分钟才发现写错了。这个习惯帮我省下了大量等待时间。