2024年时候有个很有意思的现象,身边原本写TensorFlow的老伙计,一个个都悄悄把主力框架换成了PyTorch。不只是学术圈子,工业界的招聘JD上也越来越频繁地出现“熟悉PyTorch优先”。我自己的学习路径是从TensorFlow 1.x时代摸过来的,中间被静态图的调试折磨过不少次,后来转PyTorch之后才真正体会到“动态图加自动求导”有多顺手。这篇笔记算是我把踩过的坑、看过的源码、实际项目的经验重新梳理了一遍,写给想从零开始或者是被环境配置劝退过的朋友。文章覆盖范围比较广:从环境搭建、张量和反向传播的原理,到完整训练一个图像分类模型,再到LSTM、注意力机制、模型转ONNX这些进阶话题,按顺序读就行,也可以直接跳到对应章节查答案。
1. 劝退最多人的不是算法,是环境搭建
先说一个反直觉的事实:很多人学PyTorch第一天就放弃了,不是卡在张量乘法,也不是卡在反向传播,而是卡在了“装完环境之后根本 import 不进来”。PyTorch的环境配置之所以劝退,是因为它牵扯到Python版本、CUDA驱动、显卡驱动、包管理器、操作系统好几条线,任何一环不匹配就是各种奇奇怪怪的报错。
1.1 Anaconda虚拟环境:隔离是省心的前提
我最开始学的时候直接在系统Python里pip install torch,后来才发现这是给自己埋雷。不同项目的依赖是互相打架的,你今天装了个需要Python 3.10的项目,明天另一个项目又要求3.8,系统环境很快就成了一锅粥。所以我建议的第一步永远是先装Anaconda或者Miniconda。
conda create -n pytorch python=3.10 conda activate pytorch创建虚拟环境之后,再安装PyTorch就相对安全了。这里有个小建议,conda镜像和pip镜像在国内环境下有时候会很慢,建议给conda配好清华镜像源,给pip配好清华PyPI镜像。具体配置方法网上随便一搜就有,我在这里就不赘述了。
1.2 GPU版本还是CPU版本?先回答三个问题
很多人上来就搜“pytorch安装教程gpu”,但实际自己到底需不需要GPU,其实没想清楚。我一般会问三个问题:
- 你的电脑有没有NVIDIA独立显卡?没有的话,直接装CPU版本,别折腾。
- 你的显卡显存多大?跑个小模型、做做学习实验,4GB以上就够入门了。
- 你的CUDA驱动版本支持哪个CUDA toolkit?这个用
nvidia-smi命令能看到。
如果显卡是AMD或者Intel的核显,或者你是Mac用户,那PyTorch也有对应的CPU版本甚至MPS加速支持。比如有个热词是“安装pytorch是不是必须装有GPU”,这里可以明确回复:不是。GPU只影响训练速度,不影响你能不能学。用CPU版本跑MNIST、跑小型的全连接网络完全没问题,只是跑大模型或者大batch的时候你会觉得时间走得特别慢而已。
确定了这三个问题之后,去PyTorch官网首页找下面这种命令就行:
# CUDA 12.4 版本 pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124 # CPU 版本 pip3 install torch torchvision torchaudio安装之前,你最好确认一下python -c "import torch; print(torch.__version__)"能够正常输出,再顺手print(torch.cuda.is_available())看看GPU是否可用。
1.3 WSL2方案:在Windows底下获得接近原生的体验
热词里有“pytorch环境搭建wsl”和“7900xtx pytorch wsl”,说明这块现在也有不少人关心。WSL2本身就是Windows Subsystem for Linux,Windows 10/11用户开启它之后,可以原生跑一个Linux环境。这对PyTorch用户有几个好处:很多开源项目只给了Linux的安装脚本,你在WSL2里直接跑就行,完全不用把代码搬到云服务器;其次WSL2里Linux内核对于NVIDIA显卡的驱动支持现在很成熟,torch.cuda.is_available()通常返回True。
具体步骤大致是:
# 在管理员PowerShell里启用WSL2 wsl --install # 安装Ubuntu发行版 wsl --install -d Ubuntu-22.04进入Ubuntu终端之后,先更新系统,再装Miniconda,接下来就完全照搬Linux环境下的安装流程了。需要注意的一点是,WSL2里是没有/usr/local/cuda这种东西的,它依赖Windows侧的NVIDIA驱动,所以不要单独在WSL2里再装一套显卡驱动,否则有时候会搞出兼容性问题。
1.4 离线安装与特殊架构的兜底方案
如果你所在的环境没法访问外网,那就只能走离线安装路线。在能联网的机器上下载对应的wheel包,然后拷贝过去用pip install xxx.whl离线安装。关键是选对版本,比如torch-2.3.1+cu121-cp310-cp310-linux_x86_64.whl这个文件名里面,cp310表示Python 3.10,cu121表示CUDA 12.1,linux_x86_64表示Linux 64位,任何一个不匹配都会安装失败。
另一个特殊场景是国产化环境,比如热词里提到的“麒麟系统v10 + 海光GPU安装pytorch”。海光GPU目前走的是ROCm生态,PyTorch官方其实已经逐步支持ROCm了,所以思路就是先确认系统里的ROCm版本,然后到PyTorch官网找对应的ROCm wheel,找不到的情况下再看厂商有没有提供适配包。总体原则就是提前确认三件事:系统架构(x86还是arm)、Python版本、GPU的软件生态(CUDA还是ROCm),然后对着选包,不要盲目复制网上的命令。
2. 张量与自动求导:先忘掉手写梯度那套东西
环境搞定之后,第一个要过的概念关就是张量。我见过很多初学者把PyTorch的Tensor当成了普通的数组来用,其实它远远不止是个多维数组。
2.1 张量:带方向盘的多维数组
你可以把Tensor理解成一辆装了方向盘和油门的车。普通数组你只能“看”里面的数据,而Tensor这个东西不仅存了数据,还记住了“我是怎么算出来的”。这句话是理解整个自动求导的关键。
创建一个Tensor很简单:
import torch # 从列表创建 a = torch.tensor([1, 2, 3.0]) # 全零 b = torch.zeros(2, 3) # 随机初始化 c = torch.randn(2, 3, requires_grad=True) # 创建之后的数据类型和形状 print(a.dtype, a.shape) # torch.float32, torch.Size([3])这里有个很常见的坑:torch.tensor([1, 2, 3])创建出来的是整数类型int64,如果你后面想让它参与梯度计算,可能会报错。所以我一般会写成torch.tensor([1.0, 2.0, 3.0]),或者用tensor = tensor.float()显式转换。
再说一个偏远但实用的点,热词里有个“pytorch返回实例的类对象名称”,这个说的是在调试代码的时候,你想知道一个模型或者层到底是什么类。常规做法是type(model),但更好的做法是:
model = torch.nn.Linear(10, 5) print(model.__class__.__name__) # Linear print(type(model)) # <class 'torch.nn.modules.linear.Linear'>在debug大型模型结构的时候,用__class__.__name__拿到的纯字符串更干净,打印结构图也方便,这是我实际写项目时的一个小习惯。
2.2 自动求导的底层逻辑:计算图就是一个记账本
PyTorch的自动求导机制,官方叫Autograd,底层是靠计算图实现的。我经常用记账本来类比:你每次做一次张量运算,PyTorch都会在背后记一笔账——“这个结果是由哪两个数怎么加出来的、乘出来的”。当你调用backward()的时候,它就像把整本账从后往前翻一遍,按照链式法则把每个变量的梯度算出来。
x = torch.tensor([2.0], requires_grad=True) y = x ** 2 + 3 * x + 1 y.backward() print(x.grad) # tensor([7.])上面这个例子里,y = x² + 3x + 1的导数是2x + 3,所以x=2时梯度正好是7。backward()执行的就是反向传播算法,它会自动沿着计算图回传,把每个叶子节点的梯度算出来。
如果你想让某个变量不参与梯度计算,可以用requires_grad=False或者with torch.no_grad():。推理阶段的循环里我强烈建议加上no_grad,因为推理不需要梯度,不关掉的话会白白浪费大量显存和算力。
2.3 backward() 的真实行为和常见的坑
backward()这个接口看着简单,用起来还是有几个坑的。
第一个坑是梯度累积。PyTorch默认每次backward()之后不会自动清零梯度,而是把新算出来的梯度累加到上一次的数值上。这意味着如果你在训练循环里忘了optimizer.zero_grad(),模型参数会以肉眼可见的速度失控,损失函数直接变成NaN或者跳变。正确的训练循环模板一定是先清空梯度、再前向计算、再反向传播、最后更新参数。
第二个坑是标量要求。backward()默认是要求目标是一个标量,如果你对一个包含多个元素的张量直接调用backward(),就会报错提示“grad can be implicitly created only for scalar outputs”。这种时候要么你先对张量求和变成一个标量,要么传一个和形状相同的权重参数进去。
第三个坑是数据类型和设备的匹配。CPU上的Tensor参与GPU上的Tensor运算一定会报错,float32和float64混着算有时候也会出问题。这些坑单独看都很小,但每一个都能让你debug一个晚上。经验就是先看报错信息的最后一行,90%的autograd问题都在最后一行里写明白了。
3. 搭建第一个训练任务:用MNIST手写数字分类串起全流程
学PyTorch最容易获得成就感的一个小项目就是用手写数字识别练手。模型简单,数据集也不需要额外下载,跑通整个流程之后你对“训练”这件事就有了整体感知。这里我选全连接神经网络而不是CNN,因为初学者应该先把训练循环里的每个组件看明白,而不是一开始就被卷积、池化这些层分散注意力。
3.1 数据管道四件套:Dataset、DataLoader、transform、normalize
PyTorch里面数据这块由几个组件配合完成。torchvision.datasets.MNIST负责下载和管理数据文件,transform负责对图片做预处理(比如把PIL图像转成Tensor、做归一化),DataLoader负责把数据打包成一个个batch,并且支持多线程加载和乱序打乱。
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 ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=2)这里要详细说说归一化为什么重要。MNIST数据集的像素值范围是0到255,如果不做归一化直接扔进模型,那么第一层的输出会非常大,激活函数很容易进入饱和区,梯度要么消失要么爆炸。Normalize((0.1307,), (0.3081,))里面的两个数分别是MNIST数据集的均值和标准差,它的作用是把数据分布拉到一个接近标准正态分布的状态。以后你处理自己的数据时,也先花两分钟算一下均值和标准差再训练,这是提升训练稳定性的一个关键细节。
3.2 用nn.Module定义模型:forward远比你想的重要
nn.Module是PyTorch里所有模型和网络层的基类。定义一个模型,本质上就是做两件事:在__init__里面搭建你的层,在forward里面规定数据怎么流过这些层。
import torch.nn as nn class MLP(nn.Module): def __init__(self, input_dim=784, hidden_dim=128, num_classes=10): super().__init__() self.fc1 = nn.Linear(input_dim, hidden_dim) self.relu = nn.ReLU() self.fc2 = nn.Linear(hidden_dim, num_classes) def forward(self, x): # x: [batch_size, 1, 28, 28] x = x.view(x.size(0), -1) # 展平成 [batch_size, 784] x = self.fc1(x) x = self.relu(x) x = self.fc2(x) return x这里特别强调一下forward方法。nn.Module的__call__内部会先去执行一些底层的hook逻辑,然后再调用你定义的forward,所以你在外部直接调用model(x)而不是model.forward(x)。这两者在语义上有区别,直接调forward会跳过某些模块行为,比如Dropout在推理和训练模式下的切换逻辑。正确的做法永远是output = model(x)。
3.3 三个循环:训练、验证、保存与加载
数据管道有了,模型定义了,接下来就是最关键的三段式流程。我把训练循环的基本模板放在下面,这个模板你在后续几乎所有PyTorch项目里都能见到,建议直接背下来。
import torch.optim as optim model = MLP() optimizer = optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() for epoch in range(5): 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 valid_loader: outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print(f"Epoch {epoch}, accuracy: {correct / total:.4f}")训练循环里有两个初学者经常疑惑的点。
第一个是model.train()和model.eval()。这两个方法并不是用来控制梯度计算的,它们是切换模型中那些在训练和推理时行为不同的层,比如Dropout和BatchNorm。你如果不切eval(),推理阶段的预测结果会因为Dropout随机丢弃而波动,明明同样的输入可能得到不同的输出。
第二个是torch.max(outputs, 1)。outputs的维度是[batch_size, num_classes],torch.max的第二个参数表示沿着哪一维找最大值。找出来的predicted是每个样本预测的类别索引,然后和真实标签比较就能算准确率了。
模型的保存与加载是另一个高频操作。PyTorch有两种主流保存方式,我建议你保存state_dict而不是整个模型:
# 保存 torch.save(model.state_dict(), "mnist_mlp.pt") # 加载 model = MLP() model.load_state_dict(torch.load("mnist_mlp.pt")) model.eval()保存state_dict的好处是跨设备兼容性好,而且只存了权重参数,文件体积小。如果你还想把优化器的状态、当前epoch、随机种子这些一起保存下来做断点续训,可以把它们打包成字典一起存。
3.4 训练中的高频问题排查手册
跑通MNIST之后,你会开始遇到各种问题,这里我整理一份高频问题排查清单,每一类都是我实际遇到过的。
loss不降:先看是不是学习率太大或者太小。学习率大容易震荡甚至发散,学习率小则收敛极慢,像是蜗牛爬。建议从1e-3起步,损失不降就依次尝试1e-4和1e-2。然后确认数据归一化有没有做,标签有没有对应正确。
维度对不上:这是最常见的报错类型。做法是在forward里手动print每一步的shape,一行一行检查,很快就能定位到是哪一层把形状整错了。还有个小技巧:写代码时多写注释,把每个张量的形状标在旁边,例如# [B, 784]。
显存溢出OOM:先检查一下num_workers是不是开太多了,然后看batch size是不是太大了。实在不行就把模型放到CPU上跑,训练慢一点总比直接崩溃好。
结果为NaN:在损失函数前后打印一下数值,看是不是输入包含了Inf或者NaN,梯度爆炸也经常导致NaN,可以试试加一个gradient clipping:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)这个技巧在训练RNN、Transformer这类模型时特别常用,我现在写训练循环的时候基本上都会顺手加上。
4. 进阶实战:从玩具模型到真实项目
当你把MNIST跑通、把训练循环彻底理解之后,PyTorch的大门才算真正打开。这一节我会挑几个进阶方向来聊,这些方向分别对应热词里的LSTM源码、attention机制、模型转ONNX、以及强化学习的TD3代码。
4.1 序列模型怎么学:从读LSTM源码开始
热词里有“pytorch lstm源码”,说明很多人会卡在循环神经网络这块。LSTM对外接口看着复杂,其实就是三个参数的事:input_size是每个时间步输入特征的维度,hidden_size是隐状态向量的维度,num_layers是堆叠的层数。
import torch.nn as nn lstm = nn.LSTM(input_size=50, hidden_size=128, num_layers=2, batch_first=True) # x: [batch, seq_len, input_size] x = torch.randn(32, 20, 50) output, (h_n, c_n) = lstm(x) print(output.shape) # [32, 20, 128]很多人不理解output和(h_n, c_n)之间的关系。output保存的是最后一个LSTM层在每个时间步的输出,shape是[batch, seq_len, hidden_size];h_n是最后一层的最终隐藏状态,shape是[num_layers, batch, hidden_size];c_n是细胞状态。如果你想取最后一个时间步的输出用于分类,可以用output[:, -1, :],它和h_n[-1]是同一个东西,这是一个很容易被绕晕的点。
我实际用LSTM的体会是,如果你要处理的是长序列,LSTM的梯度仍然容易不稳定,记得加上上一节提到的梯度裁剪。如果你要处理的是超长序列或者需要考虑并行计算,那LSTM就不太合适了,需要转向Transformer。
4.2 注意力机制:seq2seq decoder里最核心的一环
热词里有“a generic attention module for a decoder in seq2seq pytorch”,这是一个非常经典的注意力模块。简单讲,seq2seq模型里,编码器把输入序列压缩成一个语义向量,但如果句子很长,单个向量装不下全部信息。注意力机制的思路是:解码器在生成当前位置输出的时候,不要只依赖那个向量,而是“回看”编码器每一步的隐状态,给它们分配不同的权重,重点关注与当前输出最相关的部分。
import torch import torch.nn.functional as F class Attention(nn.Module): def __init__(self, hidden_size): super().__init__() self.W = nn.Linear(hidden_size, hidden_size, bias=False) def forward(self, decoder_hidden, encoder_outputs): # decoder_hidden: [batch, hidden] # encoder_outputs: [batch, seq_len, hidden] score = torch.bmm(encoder_outputs, self.W(decoder_hidden).unsqueeze(2)).squeeze(2) attn_weight = F.softmax(score, dim=1) context = torch.bmm(attn_weight.unsqueeze(1), encoder_outputs).squeeze(1) return context, attn_weight这里面bmm是批量矩阵乘法,score计算每个编码器位置的得分,经过softmax转成注意力权重,再用权重把编码器输出加权求和得到上下文向量。这个模块虽然短,但它是整个注意力机制的骨架。往细了说还有加性注意力、点积注意力、多头注意力这些变体,但原理都是“求相似度、算权重、加权求和”,把这个思路吃透,再看Transformer源码就会轻松很多。
4.3 PyTorch到ONNX:模型部署的关键一步
热词里“pytorch转onnx”出现的频率很高,这确实是很多人在模型开发完后面对的第一个部署问题。ONNX全称是开放神经网络交换格式,它相当于一个跨框架的通用格式。你把PyTorch模型转成ONNX之后,可以对接ONNX Runtime、TensorRT,甚至可以在网页端跑。
转ONNX的操作非常简单:
model = MLP() model.load_state_dict(torch.load("mnist_mlp.pt")) model.eval() dummy_input = torch.randn(1, 1, 28, 28) torch.onnx.export( model, dummy_input, "mnist_mlp.onnx", opset_version=17, input_names=["input"], output_names=["output"] )导出时有几个关键点容易被忽略。第一个,model.eval()必须调用,否则BatchNorm或Dropout的行为会被固化成训练态。第二个,dummy_input的shape必须和实际输入一致,特别是batch维度,ONNX导出之后计算图结构就固定了。第三个,opset版本不是越高越好,要看目标推理引擎支持到多少。
导出之后怎么验证呢?用ONNX Runtime跑一下,比对输出和PyTorch原模型的输出是否一致。我之前遇到过动态shape的处理问题,如果输入序列长度可变,需要在export时设置dynamic_axes参数,这样才能保证不同长度的输入都能被ONNX模型处理。
4.4 从TD3代码谈强化学习代码的阅读方法
热词里“td3代码pytorch”把强化学习也带进来了。TD3全称是Twin Delayed DDPG,一个基于演员-评论家架构的强化学习算法。说实话,第一次看TD3代码时我也挺晕的,因为里面又包含Actor网络、Critic网络、目标网络、经验回放缓冲池好几套结构。
我推荐的学习方法不是从头到尾一行行读,而是先找到主训练循环,通常在一个train_one_step函数里,理解每一步在做什么:选择一个动作、环境执行、存储经验、从经验池采样、更新Critic、定期更新Actor、软更新目标网络。把这条主线理清楚之后,再回头一层层看各个网络的定义,就会清晰得多。
# TD3训练循环里的极简核心示意 def train_step(self): s, a, r, s_next, done = self.replay_buffer.sample(256) # 用目标网络计算目标Q值 target_q = self.target_critic(s_next, self.target_actor(s_next)) target_q = r + self.gamma * (1 - done) * target_q # 更新Critic loss = F.mse_loss(self.critic(s, a), target_q.detach()) self.critic_optimizer.zero_grad() loss.backward() self.critic_optimizer.step()强化学习代码和深度学习代码最大的差异是,它里面到处是detach()和“目标网络延迟更新”这种反直觉的设计,不管在读代码还是调参时都容易迷失。但好消息是,只要你前面的autograd和训练循环基础牢固,至少能看明白每一步在干嘛。
4.5 最后一个实用技巧:善用模型结构打印
最后分享一个我每次拿到新模型都会做的动作:打印模型结构。PyTorch里直接print(model)就能看到每一层的名字和参数规模,我用这个来核对模型是否搭对、参数量是否符合预期。热词里那个“pytorch返回实例的类对象名称”也与此相关,当你写通用代码或者调试脚本时,用model.__class__.__name__判断当前模块类型,比比较字符串型的type输出更加稳定。
我个人在实际操作中的体会是,PyTorch的学习曲线其实可以拆成三段:第一段是会跑通官方MNIST例程,搞清楚训练循环里每个组件是干嘛的;第二段是能够自己定义模型、改造数据管道、处理训练中出现的各种问题;第三段就是看懂进阶代码,比如LSTM源码、注意力模块、强化学习算法,并具备自己写出来的能力。到了第三段,PyTorch对你来说就不再是一个库,而是一种思维方式了。希望这篇笔记能帮你少走几个我走过的弯路,剩下的就靠你自己去跑代码、踩坑、再总结,这个过程本身就是最好的学习方式。