1. 为什么说PyTorch是深度学习入门的首选项
这两年经常有人问我,想入门深度学习到底该先学哪个框架。我的答案一直很稳定:如果没有任何基础,直接选PyTorch,别犹豫。这个结论不是拍脑袋得出来的。你在各大招聘JD里翻一翻,算法工程师岗位下面“熟悉PyTorch”几乎是标配;去开源社区看看,HuggingFace上最新的大模型权重、最前沿的研究论文代码,十有八九是用PyTorch写的。生态这东西,你入门的时候感受不深,真到了跑别人代码、复现论文、改模型结构的时候,就知道跟随大流有多重要了。
PyTorch解决的核心问题有两个。一是动态计算图,它允许你在运行过程中实时修改网络结构,这对调试和理解模型提供了极大的便利。二是一套设计直觉非常统一的使用习惯——张量、自动求导、模块化定义,整个过程能让你快速上手,减少挫败感。
这篇文章不只讲API怎么用,我尽量把背后的原理、常见的坑和我自己踩过的雷也一并说出来。适合谁看?完全没碰过深度学习的初学者,或者学过理论、但一直卡在代码层面的朋友。看完之后,你应该能独立搭建并训练一个简单的图像分类模型,遇到报错时也有基本的排查思路。
提示:全文默认安装的是CPU/GPU通用版本,示例代码以图像分类任务为主线展开,这是最经典的入门场景,语言风格保持实操为主。
2. PyTorch环境准备与工程目录规划
2.1 用最小代价装好环境
很多初学者第一步就栽在环境上。先说结论,最稳妥的方式是用Anaconda创建独立环境,然后在环境中安装PyTorch。为什么要单独建环境?因为PyTorch对Python版本、CUDA版本都有要求,如果你系统里同时有TensorFlow旧项目、OpenCV依赖等,混着装很可能把依赖搞得一团糟。独立环境就是给你一个干净的实验空间,搞坏了随时删掉重建,成本为零。
打开终端依次执行:
conda create -n torch_learn python=3.9 conda activate torch_learnPython版本我推荐3.9或3.10,太新的版本偶尔会遇到个别依赖包还没适配的情况,没必要为这种问题浪费时间。激活环境后,去PyTorch官网首页找到对应系统的安装命令。重点看CUDA那一栏,很多人不知道自己该选哪个。
如何确认自己的CUDA版本?Windows下打开终端执行nvidia-smi,右上角能看到CUDA Version。如果你的显卡驱动较新而CUDA显示是12.x,直接选官网对应12.x的安装命令。没有NVIDIA显卡、或者只想先跑通代码的人,选CPU版本就好,后续所有功能都能学,只是训练速度慢一些。
安装完成后验证一下:
python -c "import torch; print(torch.__version__)"能正常输出版本号,说明环境装好了。如果报错提示找不到torch模块,基本就是没激活正确的conda环境,注意检查终端前面的环境名。
2.2 工程目录怎么组织才不混乱
等代码量稍微上来,你会发现随手写一个脚本文件的方式撑不住了。自己练习的时候,我建议按这个最小结构来组织项目:
my_project/ ├── data/ # 数据集存放 ├── models/ # 网络结构定义 ├── utils/ # 工具函数,比如数据加载 ├── train.py # 训练脚本 └── config.py # 超参数配置刚开始不用强行分层,但至少把“网络定义”和“训练逻辑”分开。我看到太多新手把模型结构、数据处理、训练循环全塞进一个文件里,改个学习率要找半天,报错定位也极其痛苦。分文件的意义不是追求架构美感,而是降低你自己排错时的认知负担。
3. 核心概念拆解:张量、自动求导与动态计算图
3.1 张量就是“带计算的数组”
你如果接触过NumPy,理解张量几乎零成本。张量本质上就是多维数组,最大的区别在于张量能在GPU上加速计算,并且能自动记录操作路径以用于梯度计算。这是后续所有深度学习操作的基础。
创建张量的几种常用方式直接看代码:
import torch # 从列表创建 a = torch.tensor([1.0, 2.0, 3.0]) # 全零张量 b = torch.zeros(2, 3) # 随机初始化 c = torch.randn(2, 3) # 指定数据类型和设备 d = torch.ones(4, 4, dtype=torch.float32, device="cuda")代码本身没什么稀奇的,但有两个习惯我建议一开始就养成。第一,默认统一用float32。PyTorch中很多模型权重默认都是float32,如果你创建数据时用了float64或int64,后续拼接层、计算loss很可能出现类型不匹配的报错。第二,能指定device就指定device。数据在CPU和GPU之间的搬运很耗时,一次训练循环里反复搬运数据不仅速度慢,而且代码会变得极难维护。
张量的操作和NumPy也高度一致。像torch.cat、torch.stack、torch.reshape、torch.transpose这些都是高频操作,建议把官方文档里张量那一节的常用函数扫一遍,花不了半小时,但能省下以后大量的查文档时间。
3.2 自动求导:反向传播黑盒的打开方式
深度学习训练的核心其实是求梯度。模型有大量参数,每个参数对损失函数的影响程度各不相同,训练的目标就是让参数沿着让损失变小的方向更新。手动算梯度在多层神经网络里是不可能的任务,于是框架帮你自动完成了这个步骤。
在PyTorch里,这个过程的核心在于requires_grad属性。一个张量如果设置了requires_grad=True,任何基于它的运算都会被记录下来,之后调用backward()就能自动算出所有相关张量的梯度。看下面这个例子:
x = torch.tensor([2.0], requires_grad=True) y = x ** 2 + 3 * x y.backward() print(x.grad) # 输出 tensor([7.0])手算一下,y对x的导数是2x+3,代入x=2得到7,完全一致。这个小例子背后就是整个深度学习训练的微观模型。你定义好网络、算好损失、调用一次backward(),每个参数的梯度就都自动算好了。
这里有个特别容易忽略的细节:梯度是累积的。每调用一次backward(),参数的梯度会在原有值上累加。所以在每次更新参数之前,需要先把梯度清零。通常用optimizer.zero_grad()一行完成。忘了做这一步,梯度就会越积越大,最终的训练曲线会呈现奇怪的震荡,很多人遇到“loss越训越高”却没往这个方向想。
3.3 动态计算图到底在说什么
所谓计算图,就是把一次前向传播的所有运算串成一个有向图,数据从输入节点流向输出节点。“动态”的意思是,这个图是运行时实时构建的,每次前向传播的图都可能不同。这个特性带来的直接好处是:你可以在代码里随意写if分支、for循环来动态改变网络结构,框架不会限制你。
对比一下其他框架的静态图模式——需要先定义好完整的计算图再执行,调试时会觉得很别扭。PyTorch这种灵活模式最直观的体验是调试友好。当代码报错时,你能直接定位到出错的那一行Python代码,看到一个中间张量的具体值,这对新手期至关重要。我记得自己第一次调试一个复杂的视觉模型时,就是因为能直接打印中间层的输出shape,才快速定位到了维度匹配问题。换作静态图模式,这个过程会曲折很多。
4. 完整训练流程实战:搭建第一个图像分类模型
这一节我们直接做一个完整的实战项目:用PyTorch实现一个手写数字识别模型。数据集用经典的MNIST,因为即使训练资源很有限,普通笔记本电脑CPU也能在几分钟内跑完,非常适合用来走通整个流程。
4.1 数据加载:不要自己手写数据读取逻辑
新手最容易踩的坑之一,就是试图自己写代码去读图片、做预处理、切片打乱。这些事情PyTorch已经给你封装好了,而且封装得非常好,直接复用是性价比最高的选择。
核心概念是Dataset和DataLoader。Dataset定义了“怎么取一条数据”,DataLoader负责“怎么把数据高效地一批批喂给模型”。
from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST( root='./data', train=True, download=True, transform=transform ) test_dataset = datasets.MNIST( root='./data', train=False, download=True, transform=transform ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=1000, shuffle=False)这段代码做的事情包括下载数据集、把图片转成张量、归一化到近似标准正态分布、每批取64张并打乱顺序。全部四行搞定,几乎没有让你犯错的空间。
这里有一个关于数据预处理的细节值得展开说。ToTensor()会把PIL图片或NumPy数组从H×W×C的格式转换成C×H×W的张量,并把像素值从0到255缩放到0.0到1.0。而Normalize((0.1307,), (0.3081,))中的两个数值是MNIST数据集的全局均值和标准差,用这两个值归一化可以让梯度更新更平稳。不同数据集的归一化参数通常是别人的经验值,自己训练时可以直接用大白话理解:把数据的分布拉回到均值为0、方差为1的状态。
第一次运行代码时download=True会自动下载数据,如果网络慢,可以考虑手动下载后放到./data目录,PyTorch能识别已存在的文件而跳过下载。
4.2 定义网络结构:经典三层卷积网络
模型结构的定义在PyTorch里是继承nn.Module类。这个类帮你管理了参数注册、设备迁移、训练/评估模式切换等大量底层工作,你只需实现__init__和forward两个方法。
import torch.nn as nn import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 32, kernel_size=3) self.conv2 = nn.Conv2d(32, 64, kernel_size=3) self.fc1 = nn.Linear(64 * 5 * 5, 128) self.fc2 = nn.Linear(128, 10) def forward(self, x): x = F.relu(self.conv1(x)) x = F.max_pool2d(x, 2) x = F.relu(self.conv2(x)) x = F.max_pool2d(x, 2) x = x.view(x.size(0), -1) x = F.relu(self.fc1(x)) x = self.fc2(x) return x这个网络结构不复杂,但值得逐行理解。输入是一张28×28的灰度图,经过第一层卷积后变成32通道、尺寸略缩小的特征图,再通过最大池化把尺寸减半。第二层卷积把通道数从32提升到64,再次池化。最后把二维特征图展平成一维向量,送入全连接层,输出10个类别的分数。
有一个新手常见的困惑是:从卷积层到全连接层的维度是怎么确定的?这里有一个计算过程。初始输入是28×28,第一次卷积核为3×3,由于没有padding,输出尺寸变为26×26,池化后变为13×13。第二次卷积后变为11×11,池化后变为5×5。所以展平前的特征图大小是64通道×5×5,对应全连接层的输入维度64×5×5=1600。如果你改动了网络结构,这个数字必须手动重新计算。我自己的经验是,与其死记公式,不如在forward里加一行打印x.shape来确认维度的变化,要直观得多。
4.3 训练循环:核心三步曲
训练一个模型,每一轮迭代都严格遵循同样的步骤:
for images, labels in train_loader: optimizer.zero_grad() # 1. 清空梯度 outputs = model(images) # 2. 前向传播 loss = criterion(outputs, labels) # 3. 计算损失 loss.backward() # 4. 反向传播计算梯度 optimizer.step() # 5. 更新参数这个五步循环是整个深度学习训练的原子操作。我见过很多人把这五行的顺序搞反,比如先backward再zero_grad,或者在optimizer.step()之后再backward,都会导致训练异常。这个顺序背后是有逻辑的:
- 任何优化器都维护着参数的梯度缓存,先清空是为了避免上一轮的梯度累积干扰;
- 前向传播得到预测结果,与真实标签对比算出损失;
- 反向传播的时候,框架会从损失出发,沿着计算图一路回传梯度到每一个参数上;
- 最后优化器拿着这些梯度去更新参数,完成一步学习。
没有任何一步是多余的,也没有顺序调换的余地。你可以把整个过程类比成“做一张卷子、对答案、找出错题、订正错题、再拿下一张卷子”。zero_grad就是把上一张卷子用过的草稿纸扔掉,防止上一轮的错误答案影响本轮订正。
完整的训练脚本还需要加上损失函数和优化器的选择:
model = SimpleCNN() criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) epochs = 5 for epoch in range(epochs): running_loss = 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() print(f"Epoch {epoch+1}: loss = {running_loss/len(train_loader):.4f}")损失函数这里用交叉熵,它把网络输出的原始分数和真实标签放到一起,计算出一个表示“预测得有多差”的标量。优化器用Adam,它对学习率的选择不像SGD那么敏感,对新手最友好。等后续有经验了,再换到SGD+动量调参。
4.4 评估:你的模型到底学到了什么
训练完的模型需要评估它的真实水平。评估阶段与训练的写法有几处明显不同:
model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in test_loader: outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print(f"Test accuracy: {100 * correct / total:.2f}%")先调用model.eval(),这个操作会把模型中所有BatchNorm、Dropout层切换到推理模式。很多人在评估时忘了这一步,导致结果时好时坏、极不稳定——尤其是在模型里用了Dropout时,忘了切模式等于推理时还在随机丢神经元,结果当然不准。
torch.no_grad()是一个上下文管理器,告诉框架“这一段不需要计算梯度”。评估阶段不做反向传播,关闭梯度计算能显著节省内存和提升速度。如果你在评估时忘了加这行,程序一般不会报错,只是会慢不少,而且占用内存明显增高。
torch.max(outputs, 1)返回两个值,第一个是最大值,第二个是最大值的索引。这里的索引就是模型预测的类别数字。得到预测值后再与真实标签比较,计算准确率。
4.5 完整代码串联
把上面的代码片段拼起来,就是这个项目的全貌。总代码量不到80行,但麻雀虽小,五脏俱全,包含了一条深度学习项目完整的生产链路:数据准备、模型定义、训练循环、评估验证。
完整跑完之后,MNIST测试集准确率应该能到99%左右。如果你跑出来的是90%上下,先检查一下是不是训练轮数太少或者学习率设置不当。如果准确率低得离谱,比如50%以下,大概率是哪里出错了,优先排查数据归一化参数和模型结构。
5. 保存加载模型:踩坑率最高的几个点
训练好的模型不保存就相当于白练了。PyTorch保存模型的官方推荐做法是只保存state_dict而已:
5.1 推荐方案:只保存权重
# 保存 torch.save(model.state_dict(), 'mnist_cnn.pth') # 加载 model = SimpleCNN() model.load_state_dict(torch.load('mnist_cnn.pth', weights_only=True)) model.eval()state_dict是一个字典,里面存了所有参数的张量值。只保存权重的好处是文件小、结构清晰,加载时也方便在不同网络结构之间迁移。加载时还有两个关键步骤,第一是必须先实例化一个同结构的模型再加载权重,第二是加载完成后要调用model.eval()切换到评估模式。
5.2 完整保存的适用场景
直接把整个模型和优化器状态都保存下来也是合法的:
torch.save({ 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'epoch': epoch, 'loss': loss, }, 'checkpoint.pth')这是训练中断后恢复现场的标准做法,尤其在长训练任务中。因为训练中断往往意味着前面几十个小时的计算全部作废,所以定时保存checkpoint是一个极其重要的习惯。我自己的做法是每隔几个epoch保存一次,并保留最近两个版本,这样即使最新版本损坏了,还有上一个版本兜底。完整保存方案配合定时保存是一种很可靠的组合,值得养成习惯。
5.3 一个让你少掉头发的GPU与CPU转换问题
很多人会遇到这样的报错:在一台有GPU的机器上训练并保存了模型,换到一台只有CPU的机器上加载时直接报错。类似的场景还有在一台机器上同时有多个GPU卡。原因是模型权重被保存时绑定了设备信息。
解决方案是在加载时指定map_location参数:
model.load_state_dict(torch.load('mnist_cnn.pth', map_location='cpu'))如果你明确只做CPU推理,加个map_location='cpu'一劳永逸。反过来,如果你的模型在CPU上保存的,加载后想搬到GPU上跑,加载后调用model.to('cuda')即可。设备管理是一项持续性工作,最佳实践是把设备定义为一个变量,比如device = torch.device('cuda' if torch.cuda.is_available() else 'cpu'),然后用model.to(device)和tensor.to(device)统一控制。
6. 常见错误速查表与排除思路
这一节整理的是我在实际使用中遇到频率最高的几类问题,每个都附上直观的解决方向,可以直接对照自查。
| 报错信息 | 常见原因 | 解决方向 |
|---|---|---|
RuntimeError: Expected all tensors to be on the same device | 数据或模型一部分在CPU、一部分在GPU | 统一用to(device)把所有输入和模型放到同一设备 |
RuntimeError: mat1 and mat2 shapes cannot be multiplied | 全连接层输入维度与你前一层输出的特征维度不匹配 | 打印x.shape确认实际维度,改nn.Linear的输入参数 |
RuntimeError: CUDA out of memory | 显存不足,通常是batch_size过大或输入分辨率过大 | 减小batch_size、降低分辨率,或使用with torch.no_grad()降低评估阶段显存占用 |
ValueError: Expected more than 1 value per channel | 最后一个batch可能只剩1条样本,导致BatchNorm无法工作 | 设置DataLoader的drop_last=True,或调整数据集大小 |
梯度为None | requires_grad未开启、计算图被断开、或该参数未参与前向传播 | 确认参数参与运算;检查是否有detach()误用 |
loss一直是nan | 学习率过大、输入数据存在NaN、或数值不稳定 | 降低学习率、检查数据、在损失中加微小epsilon |
这些错误几乎每个PyTorch初学者都会遇到。我的建议是,遇到报错先冷静读最后几行错误信息,PyTorch的错误提示其实已经很良好了,多数会直接告诉你发生在哪一行、张量的shape是多少。不要一上来就复制粘贴到搜索引擎,先自己观察一分钟,往往能直接定位问题。
常常被忽略的是数值稳定性问题。虽然MNIST这种数据集很少出现nan,但在处理真实业务数据时,原始数据里可能含异常值或缺失值。如果某个特征数值极大,经过网络传播后loss就可能溢出。这种情况下,先标准化输入数据,再考虑调低学习率,很多时候问题就消失了。
7. 让训练更高效的几个习惯
训练跑通只是起点,真正把PyTorch用好,还需要养成一些让你省时省力的好习惯。
第一个习惯是用TensorBoard或简单的可视化工具记录训练曲线。哪怕是最朴素的方式——每隔一定轮数打印一次loss,也比你什么都不看强得多。我见过不少人训练完直接报“不收敛”,但问他loss曲线长什么样,完全答不上来。可训练曲线恰恰能告诉你模型在哪一步开始过拟合、学习率是否合适、数据是否存在分布问题。如果你不想装额外工具,从第一行训练代码开始就打印loss和准确率,这个习惯能让你少走无数弯路。
第二个习惯是固定随机种子。深度学习涉及大量随机过程,比如权重初始化、数据打乱顺序,都带有随机性。如果不固定种子,你复现实验时会发现同样代码跑两次结果完全不同。固定种子的方法很简单:
import random import numpy as np import torch def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)训练开始时调用一次set_seed(42),代码的可复现性会大幅提升。这对做实验、调参数、写代码的过程都有不小的帮助。
第三个习惯是在开始训练之前,先用一批少量数据把代码调试通过。很多新手一上来就把全部数据投入训练,结果模型结构维度错了、数据预处理写错了,白白浪费大把时间。我自己的做法是:第一次跑通时用很小的batch_size,比如2或4,单独检查一遍前向传播、损失计算和反向传播是否通畅。确认无误后再换成正式batch_size和完整数据集。这个习惯能帮你把“代码有bug”和“模型不收敛”两类问题分开,显著减少排查范围。
第四个建议是关于资源使用的:如果条件允许尽量用GPU训练。当前主流显卡哪怕是入门级的,训练速度也能比CPU快上几十倍。没有GPU的人也不用气馁,小数据集用CPU训练也可以接受,但模型的复杂度和数据量都会受到限制。等代码跑通、理解加深后,再考虑云GPU或更高配置的机器。
8. 下一步往哪走
如果这个手写数字识别项目已经跑通并且能理解每一段代码,恭喜你,深度学习的大门已经打开了。我建议的后续路线大致是这样:
先尝试换一个更有挑战性的数据集,比如CIFAR-10。它比MNIST复杂很多,有10类彩色小图片,模型需要更复杂的结构和更多的训练轮数才能达到像样的准确率。这个过程能让你理解过拟合:训练集准确率不断上升,测试集准确率却停滞不前,然后你自然会去了解正则化、数据增强、Dropout等概念。
然后再尝试改网络结构。把SimpleCNN换成经典的ResNet或VGG结构,也可以尝试PyTorch官方torchvision.models里预训练好的模型进行迁移学习。迁移学习在真实项目里应用很广泛——自己从头训练一个大规模模型需要很庞大的数据,但用别人训练好的模型参数做微调,一小部分数据就能取得不错的效果。
最后,如果你对自然语言处理更感兴趣,可以从简单的情感分类任务开始。PyTorch社区最流行的transformers库提供了大量预训练模型,调用方式和nn.Module一脉相承。你已有的知识框架能直接迁移过去,学起来不会费力。
我个人在实际操作中的体会是:PyTorch入门这件事,最难的从来不是API记忆,而是亲手把一个完整的流程跑通。所以不管看多少篇教程,最终都要坐下来把代码逐行敲一遍,把每个报错亲手解决掉。踩坑不可怕,恰恰是那些报错让你记住了张量维度、设备管理、梯度清零这些无法从文档里学到的东西。跑出第一个准确率98%的模型时,那种感觉会告诉你,之前所有的折腾都值了。