1. 为什么我最终选择了这套入门路径
1.1 从“看了就忘”到“抄完能跑”的转折
我在学PyTorch之前,已经被“看教程”这件事坑过很多次。今天收藏一份深度学习入门清单,明天刷到一段讲解反向传播的短视频,收藏夹装得满满当当,真正打开终端敲代码的次数却屈指可数。直到我跟着“小土堆pytorch学习笔记”重新过了一遍基础知识,才意识到问题不是出在教程数量上,而是出在学习节奏和练习密度上。这套笔记给我最大的感受是:它没有假设你会Python,也没有默认你懂深度学习,它假设的只是“你愿意打开终端跟着敲”。
所以这篇笔记并不是课程目录的复述,而是我学完上半部分之后,把那些真正卡住过我的点、网上很少讲清楚的细节、以及我自己调试过程中攒下来的经验做一个整理。如果你正准备入门PyTorch,或者学了一段时间但总觉得代码是在“抄别人的”,这篇笔记应该能帮你把前面那段最陡的坡走得稍微平坦一些。
1.2 这套笔记的覆盖面与我的筛选逻辑
上半部分的核心内容,从学习路径上看其实是围绕这几根主线展开的:张量的创建与操作、数据加载管线、自动求导机制、神经网络模块的基本写法、训练循环的完整搭建。这几块正好构成一个最简模型的完整闭环——从原始数据到最终输出的梯度更新,每一步都有对应的代码组件。
我自己的习惯是,学任何一个框架先抓住它的“最小可用闭环”,也就是“数据进来、模型算完、梯度回来、参数更新”这一圈。只要这个圈子能跑通,后续再往里面加卷积、加注意力机制、加分布式训练,都只是往这个闭环里塞组件而已。所以我学下半部分时会明显感觉,真正的分水岭并不在模型结构有多复杂,而是在于对这套闭环里每一个环节的边界条件是否足够熟悉。这篇笔记记录的,正是我在这段闭环里反复碰壁后留下的东西。
2. 环境搭建里那些没人提前告诉你的暗坑
2.1 Python版本与PyTorch版本之间的微妙关系
环境搭建是我认为这套笔记最容易被低估的部分。很多人觉得装环境嘛,跟着命令粘贴复制就行。但实际情况是,PyTorch的安装失败案例里,有相当一部分不是因为网络,而是因为Python版本和PyTorch版本之间的匹配关系没搞清楚。
我在配置环境时踩的第一个坑就是:conda默认创建的新环境Python版本往往是当时的最新版,而这个最新版不一定有对应的PyTorch预编译包。比如某些PyTorch版本在发布时,官方支持列表里写的还是Python 3.8到3.11,如果你用的是Python 3.12,pip在解析依赖时可能找不到匹配的wheel包,最后直接给你抛一个“No matching distribution found”的报错。我当时在这个错误上卡了快半小时,一开始还以为是网络问题,换了镜像源还是不行,最后检查Python版本才发现匹配关系出了问题。
所以我的建议是:先确定PyTorch版本,再反推Python版本。不要先建环境再装包,而是先上官方网站查清楚你需要的版本支持哪些Python版本,然后用conda创建指定Python版本的环境。比如我需要稳定版PyTorch 2.x搭配CUDA 11.8,那创建环境时就直接写conda create -n torch_env python=3.10,这样后面安装基本顺风顺水。
另外还有一个环境细节容易被忽略:CUDA的版本对应关系。我一开始看到电脑上装有CUDA 12.x就以为万事大吉,结果发现PyTorch官方提供的预编译包可能只覆盖部分CUDA版本。这里的关键是先确认显卡驱动支持的CUDA版本,再选择对应的PyTorch安装命令。只要记住这个顺序,就不会出现“装上了但GPU用不了,跑起来还是CPU”的情况。
2.2 下载速度和本地验证的两个实用技巧
下载速度慢是另一个被反复提起的问题。官方源下载PyTorch这种大小以GB为单位的包,在国内网络环境下经常让人等到怀疑人生。我的做法是直接用镜像源,比如在安装命令后面加上镜像地址,速度提升非常明显。这里顺便提一个小经验:不要在基础环境里直接装PyTorch,以后项目多了互相踩依赖会很难受。用一个独立的环境来学习和实验,出了问题直接删掉重建,几分钟就能恢复到干净状态。
装完之后,第一步验证除了那句常规的torch.__version__,我强烈建议再做两个检查:一是跑一下torch.cuda.is_available(),确认GPU是否真的可用;二是随便生成两个张量做一次矩阵乘法,并把结果打印出来,确认计算过程没有报错。我遇到过一种情况:torch.cuda.is_available()返回True,但真正在GPU上执行运算时却直接崩了。那其实是依赖库的版本冲突,只有实际跑计算才能暴露出来。所以环境装完不要急着看版本号,跑通一个最小计算才算真正装好了。
3. 张量与数据操作:前期最多的时间花在这里
3.1 用一套“形状检查”的思路替代死记API
张量是PyTorch里最基础的数据结构,可以说后面所有操作都是在和张量打交道。我学这一部分时最大的体会是:不要试图一开始就背下所有张量操作API,而是先建立一套“形状变化”的直觉。这套笔记里反复强调的torch.zeros、torch.ones、torch.randn这些创建函数,其实真正需要记住的并不多。更关键的是理解每个操作传入参数和你手头数据形状之间的对应关系。
举个例子,在图像处理的任务里,一个批次的数据通常被组织成四维张量(batch_size, channels, height, width),简称BCHW。我第一次处理图像数据时,完全不理解为什么要在这几个维度之间来回折腾。后来发现,所有卷积、池化操作的参数设计都基于这个四维结构。你只要在每次写代码之前先打印一遍数据的shape,把维度顺序写下来,再去看你调用的API期望的形状,基本不会犯方向性的错误。这个方法听起来简单,但真的能省掉大量调试时间。
另外一个值得养成的习惯是:操作完张量之后立刻检查形状。我不止一次遇到过代码直接报错的情况,错误信息是维度不匹配,结果回溯一下发现是某个中间步骤的squeeze或reshape改变了维度数量。所以我在自己的笔记里反复提醒:每次做完一个操作,加一行print(x.shape),确认形状符合预期再往后写。这个习惯在前期可能显得有点笨拙,但等模型结构复杂起来之后,它能帮你迅速定位问题出在哪一层。
3.2 Dataset与DataLoader里最容易忽略的细节
数据加载这一部分,我觉得是上半场学习的“隐形分水岭”。很多教程会带你快速过一遍Dataset和DataLoader,然后直接进入模型搭建。但实际写代码时,这个环节才是最容易埋坑的地方。
我在实现自定义Dataset时犯的第一个错误,就是没有正确实现__getitem__的返回类型。当时我以为返回一个PIL图像和一个整数标签就够了,结果在后续做数据增强时,发现某些操作要求输入张量而不是PIL图像,不得不回头改数据结构。这里想分享一个小经验:自定义Dataset时,尽量让__getitem__直接返回张量类型的数据。虽然可以在训练循环里再做转换,但在Dataset内部处理好类型,会让后面的流程干净很多。
DataLoader里另一个容易被忽略的参数是shuffle。训练集一般设置成True,测试集和验证集设置成False。这个道理很简单:训练时需要打乱数据顺序,避免模型学到样本顺序带来的虚假规律;而验证时要保证每次评估的数据顺序一致,结果才可比较。我刚开始学的时候没太在意这个参数,结果训练过程天真地以为模型学得不错,换到测试集上一看数字掉得厉害,排查了半天才发现是数据顺序的问题。这类小细节文档里都会写,但写代码时脑子一热就容易忽略。
还有一个关于DataLoader的经验:batch_size不是越大越好。我刚开始训练时为了图省事,直接把整个数据集作为一个batch塞进去,结果内存直接扛不住。后来才明白,batch_size的选择要同时考虑显存容量,模型的参数量也直接决定了单batch能放多大。实用做法是先从一个比较保守的值开始,比如32或64,然后观察显存占用,再逐步往上调。
3.3 TensorBoard:训练过程的可视化,比你想象的更重要
在数据操作阶段,这套笔记已经把TensorBoard的基础用法讲了一遍。我当时觉得这只是一个锦上添花的工具,直到自己开始训练模型才发现,没有可视化几乎等于在盲飞。SummaryWriter可以记录损失曲线、验证精度,甚至直接查看输入模型的数据图片。我在训练一个简单的图像分类模型时,曾经通过TensorBoard发现某个类别的样本在训练集里数量极少,导致这个类别的损失一直降不下去。如果没有可视化,这个问题可能要等到最后查看混淆矩阵才能暴露出来,而看到时往往已经浪费了大把训练时间。
在TensorBoard的使用上,一个值得注意的细节是:确保不同的实验使用不同的日志目录。因为TensorBoard默认会把同一个目录下的所有日志聚合显示,如果你跑了两组对比实验都写在同一个目录里,你会看到一条诡异的混合曲线,完全分不清是哪次实验的结果。我当时就犯了这个错误,后来才养成每次训练前手动指定一个带时间戳的日志目录。
4. 自动求导与反向传播:理解PyTorch的发动机
4.1 requires_grad 到底做了什么
自动求导是PyTorch和其他传统数值计算库最本质的区别。我学到这里时,最大的认知转变是理解了requires_grad参数不是“开关”那么简单,而是一个影响整个计算图构建过程的标志。当你把一个张量的requires_grad设为True,PyTorch就会在后续所有涉及该张量的操作中,自动记录运算路径,以便之后反向传播时计算梯度。
这里有一个非常容易踩的坑:原地操作和requires_grad之间的冲突。比如x.requires_grad_(True)这种原地修改方式,在某些情况下会导致计算图记录出错。另外,如果你对某个叶子张量做了inplace操作,后面反向传播时经常会抛出RuntimeError,提示变量被修改过了。老实说,我第一次遇到这个报错完全摸不着头脑,后来查资料才明白,PyTorch的记录机制不允许在计算图构建之后修改输入值。解决办法很简单:非必要不使用原地操作;如果确实需要修改数据,就用torch.no_grad()包住或者重新创建张量。
再来说说grad_fn这个属性。很多入门教程不会花篇幅讲它,但它在调试时非常有用。每一个经过张量运算得到的新张量,都会有一个grad_fn属性,指向生成它的那个运算。输出一个张量的grad_fn,你就可以看到它是由AddBackward0还是MulBackward0之类的操作产生的。这在验证自己构建的计算图结构时是极好的工具。我在学这块的时候习惯打印每一层输出的grad_fn,确保计算图按照预期方向延伸。这种方式虽然朴素,但比单纯看文档更容易建立起“计算图”的直觉。
4.2 loss、backward() 与 optimizer.step() 的衔接关系
很多人第一次写训练循环时,会把loss.backward()和optimizer.step()当成两个并列的步骤,背下来就完事了。但真正理解它们之间的关系,调试时才能少走弯路。简单来说,loss.backward()做的事是根据计算图反向传播,计算出每个叶子张量对应的梯度,并把梯度保存在张量的.grad属性里;optimizer.step()做的事则是根据优化器策略(比如SGD、Adam)和这些保存好的梯度,去更新模型参数。所以顺序一定不能反:先算梯度,再更新参数。
我把这个流程类比成“先看导航地图再踩油门”:backward()是查看当前位置和方向的偏差,step()才是真的踩油门往前修正。如果没看地图就踩油门,车就不知道往哪个方向开。早年我常犯的一个错误是在同一个batch上多次调用backward(),导致梯度累加,参数更新量被放大。这是因为PyTorch就算梯度是累加的。后来我养成了每次更新参数后都手动调用optimizer.zero_grad()的习惯,把梯度清零,避免上一次的梯度影响到这一轮更新。
关于zero_grad()的位置还有一个细节值得提:它应该在backward()之前调用,还是调用之后?从执行效果来说,只要出现在下一轮backward()之前都行。但我个人习惯把它放在loss.backward()之前,也就是每个batch最开始就清空梯度,这样即使中途报错退出,也不会把上次遗留的梯度带到下一次调试里。这个习惯帮我避免过好几次“莫名其妙的损失爆炸”。
4.3 一个小实验帮助理解计算图
如果你觉得以上概念还是抽象,我推荐亲自动手做一个小实验:创建一个简单的张量a,设置requires_grad=True,然后定义b = a * 2,c = b + 1,再调用c.backward(),最后打印a.grad。这个结果应该是2,因为c对a的梯度是b对a的梯度乘以c对b的梯度,也就是2 × 1。这个小实验虽然简单,但它是理解链式法则和计算图关系最好的起点。我学到这里的时候,专门在笔记本上手写了推导过程,把代码输出和手算结果对照,发现一致的那一刻,确实有“原来如此”的感觉。
5. 用 nn.Module 搭出第一个像样的模型
5.1 模型结构里的几个小习惯
从零开始搭建模型时,nn.Module无疑是核心基类。继承它并实现__init__和forward两个方法,是任何PyTorch模型的基本骨架。我刚学的时候总觉得forward就是按顺序把各层调用一遍,没什么特别之处。后来才逐渐意识到,forward里写的是每个batch数据真正经过的计算路径,它的灵活程度远超我的想象。你可以在这里加入条件判断、循环,甚至多个分支的合并,只要输入输出形状匹配,逻辑就成立。
我在写第一个自定义模型时,犯过一个很低级的错误:在__init__里定义了一个nn.Linear(28*28, 64),但在forward里直接把二维输入传了进去,结果因为输入形状里包含了batch维度,矩阵乘法时维度对不上。后来才发现,Linear层的输入形状默认不包括batch维度,你要么传入一个二维张量(batch_size, features),要么在forward里手动把输入reshape成正确形状。这类问题在文档里其实写得很明确,但实际操作时很容易因为“惯性思维”犯错。
还有一个习惯想分享:把层定义和forward里的计算分开思考。__init__中主要是创建参数,比如各层权重和偏置;forward中才是数据在这些参数之间的流动过程。把这两件事在脑海里分开,设计复杂模型时思路会清晰很多。而且,PyTorch的nn.Module会自动管理__init__中注册的子模块的参数,确保它们被optimizer找到。这个机制的好处是你不需要手动维护参数列表,但前提是:所有需要训练的参数都必须作为nn.Module的属性挂载,而不是放在普通的Python列表里。我之前试过用列表存了几个nn.Linear层,结果优化器一个参数都没更新,训练了半天loss纹丝不动。后来才知道需要改用nn.ModuleList而不是list。这个坑非常隐蔽,真的希望后来者少踩。
5.2 损失函数与优化器的搭配思路
损失函数与优化器是训练闭环里最重要的两个选择。对于分类问题,最常见的损失函数是交叉熵损失nn.CrossEntropyLoss。这个函数有一点需要注意:它在PyTorch里的输入是未经过softmax的原始logits,而不是概率输出。我第一次用时,习惯性地先加了一层softmax,再把softmax的结果传给损失函数,结果训练出来的模型效果总是差一口气。后来查文档才知道,nn.CrossEntropyLoss内部已经包含了softmax操作,外部再加softmax相当于做了两次,反而破坏了数值稳定性。这个错误很隐蔽,因为程序不会报错,但训练效果就是不对。
优化器方面,SGD和Adam是两大主流。我的经验是:入门阶段用Adam最友好,它自带学习率自适应,对初始学习率不太敏感。但如果你希望模型收敛到更好的结果,或者想深入理解优化过程,SGD加动量(momentum)是值得研究的方案。学习率的选择也很关键,我习惯从一个相对大的值开始,比如0.01或0.001,然后观察损失曲线的变化趋势来调整。如果损失震荡剧烈,就调低学习率;如果损失下降过慢,就适当调高。这套“观察再调整”的方法,比盲目设置一个固定值要可靠得多。
6. 训练循环里的意外与解决
6.1 model.train() 和 model.eval() 的差别和意义
训练循环里最容易被忽略的两行代码,我认为是model.train()和model.eval()。在入门阶段,很多教程甚至不会明确解释它们,导致很多人直接不写。但实际上,这两行代码控制着模型在训练和推理两种状态下的行为。对于只包含Linear层的最简模型,这两行确实看不出差别;但一旦模型里加入了BatchNorm或者Dropout,忘记调用model.eval()就可能导致验证结果严重失真。
我在实验里栽过一个跟头:给模型加了Dropout层后,每次验证时的准确率都不稳定,来回波动很大。我一度以为是数据加载或者随机种子的原因,排查了很久才发现,是因为验证阶段没有调用model.eval(),Dropout层在推理时仍然随机丢弃了一部分神经元,导致每次预测结果都不一样。这是一个典型的“API不起眼但作用巨大”的例子。所以我的习惯是:在训练循环开始前调用model.train(),在进入验证阶段前调用model.eval(),并配合torch.no_grad()上下文管理器,这样既能保证推理结果的确定性,也能节省内存占用。你不需要往深处理解它,但一定要养成这个习惯。
6.2 验证精度还是训练精度:评估该盯谁
训练循环里的另一个问题是:我们到底应该关注训练集上的损失和精度,还是验证集上的?从实际经验来看,验证集上的指标才是衡量模型泛化能力最接近真实的尺度。我做过一个实验,训练集上的准确率一路飙升到接近99%,但验证集准确率在某个epoch之后反而开始下降,这就是典型的过拟合信号。如果没有关注验证集,我可能还会沾沾自喜地继续训练,直到最后提交测试才发现问题。
所以,手动写训练循环时,我建议每个epoch结束后都跑一遍验证集,并把验证损失、验证精度记下来,加入TensorBoard。如果发现验证损失连续多个epoch不降反升,就考虑提前停止或者调整正则化策略。这个过程听起来繁琐,但对模型调优至关重要。PyTorch本身没有内置验证流程,这块需要自己写代码——而这恰恰是学习框架的好机会。
6.3 一个从失败到跑通的完整小Demo
为了让你对整套流程有完整画面,我分享一个我实际写过的最小图像分类Demo。数据集是一组模拟的灰度图像,尺寸统一为28×28,共10个类别。我用Dataset封装数据,在__getitem__里把图像转换为张量并归一化到0到1之间。模型只用了一个包含两个全连接层的简单结构:第一层从784维映射到128维,激活函数用ReLU,第二层从128维映射到10维。优化器选择Adam,学习率设为0.001,损失函数用交叉熵。
训练时,我以64为一个batch划分数据。每个epoch开始时把数据加载器里的shuffle设为True。前向传播后计算损失,调用backward()更新梯度,再用优化器更新参数,最后清零梯度。头几个epoch,损失下降得很慢,我一度怀疑是学习率设置错了。后来查看TensorBoard发现,损失虽然下降慢但曲线稳定,原来是数据归一化没做好,像素值范围不一致导致梯度量级偏小。修正归一化之后,模型大概在第10个epoch左右在验证集上达到了接近95%的准确率。整个过程跑下来没有任何魔法,就是踩坑、看曲线、找规律。谁都能做到,只是需要耐心。
6.4 保存与加载模型:类型匹配问题
训练结束之后,保存和加载模型也是一个细节较多的环节。PyTorch官方推荐的保存方式是保存state_dict,而不是直接保存整个模型对象。state_dict本质上是一个Python字典,包含每一层参数的张量值。加载时,需要先重新实例化一个相同结构的模型,再调用load_state_dict方法。这个流程很多人觉得多此一举,但它的好处是可移植性和灵活性都更好。直接把整个模型对象保存下来虽然方便,但如果你改了模型类的嵌套结构,或者代码文件路径变了,很可能导致加载失败。
我遇到过的一个具体问题是:在CPU上训练的模型,换到GPU上加载时直接报错,提示张量设备不匹配。解决办法是在加载时给load_state_dict传入map_location参数,指定设备为CPU或GPU。还有一个常见问题是:保存模型时的类别映射和加载后实际使用的类别映射不一致。如果推理时标签顺序换了,模型输出看起来正确,但实际意义完全变了。这个问题在数据预处理后期做类别筛选时特别容易发生,需要特别留意。
写在最后的一个实操建议
学到这里,整理一下个人体会。如果你也在跟着这套笔记学习,我强烈建议不要一口气把视频全刷完,而是每学完一小节就合上视频,凭自己记忆把代码重写一遍。写不出来就回去再看,直到能独立写出来为止。这个过程虽然慢,但比连续刷十集视频要有效得多。我自己的经验是:我真正把张量操作和训练循环记住,不是在看视频的时候,而是在关掉视频之后自己从头敲代码、反复报错排错的那些夜晚。正是这些过程中的碰撞,才让我对PyTorch不再感到陌生。