1. 从零上手PyTorch:为什么它成了深度学习项目的第一选择

如果你刚开始接触深度学习,或者从其他框架迁移过来,大概率会听到身边人反复提到PyTorch这个名字。它到底是什么?简单说,PyTorch是一个开源的深度学习框架,核心能力是帮你用Python代码搭建神经网络、自动计算梯度、并在CPU或GPU上高效执行训练。它解决的核心问题是:让研究者能把精力放在模型结构设计和实验迭代上,而不是手写反向传播和内存管理。

我第一次接触PyTorch是在一个图像分类的小项目里。当时用其他静态图框架,每次改网络结构都要重新编译计算图,调试起来像在黑盒里摸路。换成PyTorch之后,代码可以逐行执行,张量的值随时能打印出来,那种“所见即所得”的感觉直接改变了我的开发节奏。这也是PyTorch在学术圈和工业界迅速铺开的核心原因——动态计算图带来的调试友好性,对快速试错太重要了。

这篇文章适合谁看?如果你满足以下任意一条,接下来的内容会对你有直接帮助:刚学完Python基础,想找一个能跑起来的深度学习框架;用过其他框架但被静态图折磨过,想看看PyTorch的写法差异;已经能跑通简单模型,但想系统梳理张量操作、自动求导、数据加载、模型保存这些环节的细节。我会从最基础的张量讲起,一路覆盖到完整的训练流程和实际踩坑经验,所有代码都可以直接复制运行。

需要提前说明的是,PyTorch的版本迭代比较快,本文的代码基于2.x版本的稳定API。如果你用的是更早的版本,个别接口可能有差异,但核心概念完全通用。另外,文章里的所有示例都尽量保持最小可运行,不依赖额外的数据集下载,方便你直接验证。

2. 张量:PyTorch里一切数据的载体

2.1 张量到底是什么,和NumPy数组有什么区别

张量是PyTorch中最基础的数据结构。你可以把它理解成一个多维数组,标量是0维张量,向量是1维张量,矩阵是2维张量,再往上就是高维张量。如果用过NumPy,你会发现两者的API非常像,很多函数名甚至一模一样。但关键区别在于两点:第一,PyTorch张量可以在GPU上运行,NumPy数组只能在CPU上;第二,PyTorch张量支持自动求导,这是构建神经网络的前提。

我刚开始学的时候有个疑问:既然NumPy已经能做矩阵运算,为什么还要再学一套张量操作?答案就在自动求导上。神经网络的训练本质是链式法则求梯度,如果手动推导每一层的梯度公式,稍微深一点的网络就会让人崩溃。PyTorch的张量通过 requires_grad 属性记录计算历史,调用 backward() 就能自动算出所有参数的梯度。这个机制后面会详细展开。

创建一个张量有很多种方式,最常用的是从Python列表或NumPy数组转换:

import torch
import numpy as np

# 从列表创建
a = torch.tensor([[1, 2], [3, 4]])
print(a)
# tensor([[1, 2],
#         [3, 4]])

# 从NumPy数组创建
np_array = np.array([1.0, 2.0, 3.0])
b = torch.from_numpy(np_array)
print(b)
# tensor([1., 2., 3.])

# 创建全零、全一、随机张量
zeros = torch.zeros(2, 3)
ones = torch.ones(2, 3)
randn = torch.randn(2, 3)  # 标准正态分布

这里有个细节值得注意: torch.from_numpy 创建的张量和原NumPy数组共享内存,修改其中一个会影响另一个。如果你不希望这种联动,用 torch.tensor(np_array) 会复制一份数据。这个坑我在处理数据预处理时踩过,当时改了张量的值,回头发现原始数组也变了,排查了半天才反应过来。

2.2 张量的设备迁移与类型转换

PyTorch最实用的特性之一就是设备无关的代码写法。你可以先写好CPU版本的逻辑,然后通过 .to(device) 把张量和模型搬到GPU上,代码几乎不用改。设备对象的创建方式如下:

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(device)  # 有GPU时输出cuda,否则输出cpu

x = torch.randn(3, 3).to(device)
print(x.device)

这里有个常见误区:不是所有操作都支持GPU。比如某些自定义的Python循环逻辑,如果里面混用了NumPy函数,搬到GPU上会直接报错。我的经验是,尽量用PyTorch自带的张量操作替代NumPy调用,这样设备迁移才能无缝进行。

类型转换也是高频操作。PyTorch默认的浮点类型是 torch.float32 ,但有些场景需要 float64 或者半精度 float16 。转换方式有两种: tensor.float() 、 tensor.double() 这类快捷方法,或者 tensor.to(torch.float64) 这种通用写法。需要提醒的是,整数张量不能直接求导,如果你对一个 torch.int64 的张量设置 requires_grad=True ,PyTorch会直接报错。训练时输入数据通常是浮点型,标签可以是整型,但标签不需要求导。

2.3 张量的形状操作:view、reshape、permute的区别

形状变换是写模型时最频繁的操作之一。 view 和 reshape 都能改变张量的形状,但有个关键差异: view 要求张量在内存中是连续的,而 reshape 会在必要时自动复制一份数据。如果你不确定张量是否连续,用 reshape 更安全,但会有额外的内存开销。 permute 则是交换维度顺序,比如把 (batch, channel, height, width) 变成 (batch, height, width, channel) ,这在处理图像数据时很常见。

x = torch.randn(2, 3, 4)
print(x.shape)  # torch.Size([2, 3, 4])

# view和reshape
y = x.view(2, 12)
z = x.reshape(6, 4)
print(y.shape, z.shape)  # torch.Size([2, 12]) torch.Size([6, 4])

# permute交换维度
w = x.permute(2, 0, 1)
print(w.shape)  # torch.Size([4, 2, 3])

还有一个容易混淆的点: squeeze 和 unsqueeze 。 squeeze 去掉大小为1的维度, unsqueeze 在指定位置插入一个大小为1的维度。比如模型输出是 (batch, 1) ,而损失函数要求 (batch,) ,这时候用 squeeze(1) 就能搞定。反过来,如果单张图片是 (channel, height, width) ,要凑成批次维度就得用 unsqueeze(0) 变成 (1, channel, height, width) 。

3. 自动求导机制:PyTorch怎么帮你算梯度

3.1 计算图的构建与反向传播的触发条件

自动求导是PyTorch的核心竞争力。当你对一个张量设置 requires_grad=True ,PyTorch会追踪所有基于它的运算,动态构建一张计算图。每个运算节点都记录了正向传播的输入和输出,反向传播时沿着这张图从后往前计算梯度。这个过程不需要你手动推导任何公式。

x = torch.tensor([2.0, 3.0], requires_grad=True)
y = x ** 2 + 3 * x + 1
z = y.sum()
z.backward()
print(x.grad)
# tensor([7., 9.])

手动验证一下:z对x的导数,对于第一个元素是2 2+3=7,第二个是2 3+3=9,和输出一致。这里的关键点是 backward() 只能对标量调用,如果z不是标量,需要传入一个梯度参数。实际训练中,损失函数通常已经通过 mean() 或 sum() 变成了标量,所以直接调用即可。

有个细节需要注意:每次反向传播后,梯度会累积在 .grad 属性里,而不是被覆盖。如果你在一个循环里多次调用 backward() 而没有清零梯度,梯度会越加越大。这就是为什么训练循环里必须写 optimizer.zero_grad() 。我见过不少新手忘记这一步,结果损失不降反升,排查很久才发现是梯度累积导致的。

3.2 梯度清零、detach与no_grad的使用场景

梯度清零的位置有讲究。标准写法是在每个批次的反向传播之前清零:

for data, target in dataloader:
    optimizer.zero_grad()
    output = model(data)
    loss = criterion(output, target)
    loss.backward()
    optimizer.step()

detach() 用于切断计算图。当你需要把某个张量从计算图中分离出来,比如记录损失值用于打印,但不希望它影响梯度计算,就用 loss.detach() 。如果直接打印 loss ,虽然不会报错,但会保留计算图引用,可能导致内存泄漏。在训练循环里累积损失时,正确做法是 total_loss += loss.detach() 。

torch.no_grad() 是一个上下文管理器,里面的所有运算都不会构建计算图。推理阶段必须用它,否则会白白消耗显存。验证集评估的标准写法:

model.eval()
with torch.no_grad():
    for data, target in val_loader:
        output = model(data)
        val_loss += criterion(output, target).item()

model.eval() 和 torch.no_grad() 是两回事。前者改变模型的行为(比如关闭Dropout、固定BatchNorm的统计量),后者关闭梯度追踪。两者通常一起用,但作用不同,不能互相替代。

3.3 自定义autograd函数的实际需求

大多数情况下,PyTorch内置的运算已经覆盖了常见需求。但如果你要实现一个特殊的操作,比如自定义的激活函数或损失函数,而它又需要反向传播,就得继承 torch.autograd.Function 并实现 forward 和 backward 两个静态方法。

class MyReLU(torch.autograd.Function):
    @staticmethod
    def forward(ctx, x):
        ctx.save_for_backward(x)
        return x.clamp(min=0)
    
    @staticmethod
    def backward(ctx, grad_output):
        x, = ctx.saved_tensors
        grad_input = grad_output.clone()
        grad_input[x < 0] = 0
        return grad_input

这个例子实现了一个ReLU, ctx 用于保存反向传播需要的数据。实际项目中,除非你要做底层优化或者实现论文里的特殊算子,否则很少需要手写autograd函数。但理解这个机制有助于你明白PyTorch内部是怎么运作的,遇到梯度异常时也能更快定位问题。

4. 搭建神经网络的完整流程与关键决策

4.1 nn.Module的继承规范与层定义

PyTorch搭建网络的标准方式是继承 nn.Module ,在 __init__ 里定义层,在 forward 里定义前向传播逻辑。这个模式看起来简单,但有几个容易忽略的细节。

import torch.nn as nn

class SimpleNet(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        self.relu = nn.ReLU()
        self.fc2 = nn.Linear(hidden_dim, output_dim)
    
    def forward(self, x):
        x = self.fc1(x)
        x = self.relu(x)
        x = self.fc2(x)
        return x

第一, super().__init__() 必须调用,否则PyTorch无法正确注册子模块,后续的 parameters() 和 to(device) 都会失效。第二,层必须定义为实例属性,不能放在列表里直接赋值,否则不会被注册。如果需要动态创建多个层,用 nn.ModuleList 或 nn.Sequential 。第三, forward 方法的名字不能改,PyTorch通过 __call__ 触发它,你调用 model(x) 时实际执行的是 forward 。

关于层的选择,全连接层 nn.Linear 适合处理向量数据,卷积层 nn.Conv2d 适合图像,循环层 nn.LSTM 或 nn.GRU 适合序列。激活函数方面,ReLU仍然是默认选择,但GELU在Transformer类模型中更常见。这些选择没有绝对优劣,取决于你的任务和数据特性。

4.2 损失函数与优化器的搭配逻辑

损失函数衡量模型输出和目标之间的差距,优化器根据梯度更新参数。这两者的搭配直接影响训练效果。

分类任务常用 nn.CrossEntropyLoss ,它内部已经包含了Softmax操作,所以模型的最后一层不要加Softmax,否则会重复计算导致数值不稳定。回归任务用 nn.MSELoss 或 nn.L1Loss 。如果数据有类别不平衡问题,可以在CrossEntropyLoss里传入 weight 参数。

优化器方面, torch.optim.Adam 是默认首选,学习率通常设1e-3。 SGD 配合动量在计算机视觉任务中仍然常见,但需要更细致的学习率调优。 AdamW 是Adam的改进版,把权重衰减和梯度更新解耦,在Transformer训练中表现更好。

model = SimpleNet(784, 256, 10)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)

这里有个经验: weight_decay 不是越大越好。我试过在小型网络上设1e-2,结果模型欠拟合,训练损失都降不下去。一般从1e-4或1e-5开始试,观察验证集表现再调整。

4.3 学习率调度器的实际效果对比

固定学习率在训练初期可能收敛慢,后期可能震荡。学习率调度器让学习率随训练进程变化,常见的有 StepLR 、 CosineAnnealingLR 和 ReduceLROnPlateau 。

StepLR 每隔固定轮数乘以一个衰减系数,简单直接。 CosineAnnealingLR 按余弦曲线从初始值降到接近零,适合长时间训练。 ReduceLROnPlateau 监控验证集损失,当损失不再下降时自动降低学习率,比较省心但需要传入验证指标。

scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)
for epoch in range(50):
    train_one_epoch()
    scheduler.step()

实测下来,CosineAnnealing在大多数任务上比StepLR更平滑,最终精度也略好。但如果你不确定训练总轮数,ReduceLROnPlateau更灵活。需要注意的是,调度器的 step() 调用位置和优化器的 step() 不同,前者通常每个epoch调用一次,后者每个批次调用一次。

5. 数据加载与预处理中的效率陷阱

5.1 Dataset和DataLoader的分工

PyTorch的数据加载由 Dataset 和 DataLoader 两个类配合完成。 Dataset 负责定义单条数据的获取逻辑, DataLoader 负责批量组装、打乱顺序和多进程加载。

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.data)
    
    def __getitem__(self, idx):
        return self.data[idx], self.labels[idx]

dataset = MyDataset(features, targets)
dataloader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=4)

__getitem__ 里不要做太重的计算,比如实时读取大文件或复杂的数据增强。这些操作会阻塞数据加载进程,导致GPU等数据。如果必须做,考虑提前预处理并缓存到磁盘。

5.2 num_workers设置不当引发的卡顿

num_workers 控制数据加载的并行进程数。设0表示在主进程加载,设大于0会启动额外进程。理论上越大越快,但实际上受限于CPU核心数和磁盘IO速度。我试过在4核机器上设8,结果进程切换开销反而让训练变慢。

经验值是设为CPU核心数的一半到全部。如果数据集很小或者已经全部加载到内存,设0反而更稳定。在Windows上, num_workers 大于0需要把主训练代码放在 if __name__ == '__main__': 保护块里,否则会无限递归创建进程。这个坑在Linux上不会遇到,但跨平台开发时经常被忽略。

5.3 数据增强与归一化的实操细节

图像任务中,数据增强是提升泛化能力的关键手段。 torchvision.transforms 提供了常用变换:

from torchvision import transforms

transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.RandomHorizontalFlip(),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

ToTensor() 把PIL图像或NumPy数组转成张量,同时把像素值从0-255缩放到0-1。 Normalize 再按通道做标准化。这里的均值和标准差是ImageNet数据集的统计值,如果你在自己的数据集上训练,最好重新计算。我见过有人直接套用ImageNet的参数,结果模型收敛很慢,换成自己数据集的统计值后明显改善。

验证集和测试集只能用 Resize 、 ToTensor 、 Normalize ,不能加随机翻转或裁剪。这个细节在写代码时容易混淆,建议把训练和验证的transform分开定义。

6. 模型训练、验证与保存的完整闭环

6.1 训练循环的标准结构与常见错误

一个完整的训练循环包含前向传播、损失计算、反向传播、参数更新四个步骤。加上验证和日志记录,标准结构如下:

for epoch in range(num_epochs):
    model.train()
    for batch_idx, (data, target) in enumerate(train_loader):
        data, target = data.to(device), target.to(device)
        optimizer.zero_grad()
        output = model(data)
        loss = criterion(output, target)
        loss.backward()
        optimizer.step()
    
    model.eval()
    val_loss = 0
    correct = 0
    with torch.no_grad():
        for data, target in val_loader:
            data, target = data.to(device), target.to(device)
            output = model(data)
            val_loss += criterion(output, target).item()
            pred = output.argmax(dim=1)
            correct += pred.eq(target).sum().item()
    print(f'Epoch {epoch}: val_loss={val_loss/len(val_loader):.4f}, acc={correct/len(val_dataset):.4f}')

常见错误包括:忘记 model.train() 导致Dropout和BatchNorm行为异常;忘记把数据搬到设备上导致设备不匹配报错;验证时忘记 model.eval() 导致验证结果波动大。这些错误不会让程序崩溃,但会让结果莫名其妙地差,排查起来很费时间。

6.2 模型保存与加载的两种方式

PyTorch保存模型有两种方式:保存整个模型对象和只保存参数字典。推荐后者,因为前者依赖具体的类定义,代码重构后可能加载失败。

# 保存参数
torch.save(model.state_dict(), 'model.pth')

# 加载参数
model = SimpleNet(784, 256, 10)
model.load_state_dict(torch.load('model.pth'))
model.to(device)
model.eval()

如果需要保存优化器状态以便恢复训练,可以一起存:

checkpoint = {
    'epoch': epoch,
    'model_state_dict': model.state_dict(),
    'optimizer_state_dict': optimizer.state_dict(),
    'loss': loss,
}
torch.save(checkpoint, 'checkpoint.pth')

加载时先创建模型和优化器实例,再调用 load_state_dict 。注意 torch.load 在新版本中默认 weights_only=True ,如果保存的checkpoint包含非张量对象,需要显式设置 weights_only=False 。这个变化在升级PyTorch版本时容易导致加载失败。

6.3 过拟合与欠拟合的排查思路

训练过程中,观察训练损失和验证损失的曲线能判断模型状态。训练损失下降但验证损失上升,说明过拟合。两者都居高不下,说明欠拟合。

过拟合的应对手段包括:增加数据增强、添加Dropout层、增大权重衰减、减小模型容量、早停。欠拟合则相反:增大模型、减少正则化、延长训练、提高学习率。我的一般顺序是先确保模型能过拟合小批量数据,再逐步加正则化。如果连小批量都拟合不了,说明模型结构或学习率有问题,加正则化只会更糟。

还有一个容易被忽略的点:BatchNorm在批次很小时统计量不准,会导致验证效果差。如果显存有限只能用很小的批次,考虑用GroupNorm替代BatchNorm。

7. 从CPU到GPU:设备管理与性能调优

7.1 设备不匹配报错的根因与统一写法

设备不匹配是PyTorch新手最常见的报错之一。错误信息通常是“Expected all tensors to be on the same device”。根因是模型在GPU上,但输入数据还在CPU上,或者反过来。

统一写法是在训练开始前定义 device ,然后确保模型和数据都搬到这个设备上:

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SimpleNet(784, 256, 10).to(device)

for data, target in train_loader:
    data, target = data.to(device), target.to(device)
    # ...

注意 model.to(device) 是原地操作,不需要重新赋值。但 tensor.to(device) 返回新的张量,必须接收返回值。这个差异导致很多人写了 data.to(device) 却没赋值,数据实际上还在CPU上。

7.2 显存不足时的排查顺序

显存不足报错“CUDA out of memory”时,按以下顺序排查:第一,减小批次大小,这是最直接有效的方法。第二,检查是否有不必要的张量保留了计算图,比如在训练循环里累积了带梯度的损失。第三,用 torch.cuda.empty_cache() 清理缓存,但注意这只能释放未被引用的显存。第四,考虑用混合精度训练, torch.cuda.amp 能把部分运算降到float16,显存占用减少近一半。

scaler = torch.cuda.amp.GradScaler()
for data, target in train_loader:
    with torch.cuda.amp.autocast():
        output = model(data)
        loss = criterion(output, target)
    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()
    optimizer.zero_grad()

混合精度训练在支持Tensor Core的GPU上还能加速。但要注意,某些操作在float16下会溢出,GradScaler通过动态缩放损失来避免这个问题。如果你的模型里有自定义的autograd函数,需要确认它是否支持float16。

7.3 多卡训练的两种模式与选择建议

数据量大或模型大时,单卡训练太慢,需要考虑多卡。PyTorch提供 DataParallel 和 DistributedDataParallel 两种模式。前者代码改动小,但只在单机多卡有效,且主卡负载高。后者性能更好,支持多机多卡,但需要额外设置进程组。

# DataParallel
model = nn.DataParallel(model)
model = model.to(device)

# DistributedDataParallel需要先初始化进程组
torch.distributed.init_process_group(backend='nccl')
model = nn.parallel.DistributedDataParallel(model, device_ids=[local_rank])

我的建议是:如果只是单机2-4卡,DataParallel够用,代码改动最少。如果卡数更多或者需要跨机,直接上DistributedDataParallel,虽然配置麻烦但扩展性好。注意DataParallel下模型的 state_dict 会多出 module. 前缀,保存和加载时要注意处理。

8. 实际项目中积累的调试与优化经验

8.1 损失不下降时的检查清单

损失不下降是训练中最让人头疼的问题。我整理了一个排查顺序:第一,检查数据标签是否对应正确,曾经遇到标签和特征错位导致模型完全学不到东西。第二,检查学习率是否过大或过小,过大导致震荡,过小导致收敛慢。第三,检查损失函数是否适合任务,比如多分类用了二分类的损失。第四,检查模型最后一层是否加了多余的激活函数。第五,用一个小批量数据做过度拟合测试,如果连几十条数据都拟合不了,说明模型或训练逻辑有根本问题。

8.2 随机种子与可复现性

深度学习实验有随机性,为了复现结果需要固定随机种子:

import random
import numpy as np

def set_seed(seed=42):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False

deterministic=True 会让cuDNN选择确定性的算法,但可能降低速度。 benchmark=False 关闭自动调优。如果对速度要求高,可以只固定种子,不设置这两个选项。完全可复现在GPU上很难做到,因为某些并行操作的顺序不确定,但固定种子能保证大部分情况下结果一致。

8.3 从研究代码到生产部署的过渡要点

研究阶段的代码追求快速迭代,生产环境则要求稳定和高效。过渡时需要注意:第一,把模型切换到 eval() 模式并确认Dropout和BatchNorm行为正确。第二,用 torch.jit.trace 或 torch.jit.script 把模型转成TorchScript,脱离Python解释器运行。第三,考虑用ONNX格式导出,方便在其他推理引擎上部署。第四,移除训练专用的代码路径,减少依赖。

# TorchScript导出
model.eval()
example_input = torch.randn(1, 784).to(device)
traced_model = torch.jit.trace(model, example_input)
traced_model.save('model_traced.pt')

TorchScript对动态控制流支持有限,如果模型里有if-else依赖输入值, trace 会丢失这些分支,需要用 script 模式。导出后务必用相同的输入验证输出是否一致,我遇到过导出后精度下降的情况,原因是某些操作在TorchScript里的实现有细微差异。

8.4 版本升级时的兼容性避坑

PyTorch版本迭代快,升级时容易遇到API变化。比如 torch.load 的 weights_only 默认值变化、 torch.cuda.amp 的接口调整、某些优化器的参数名变更。我的做法是在项目里固定版本号,用 requirements.txt 或 environment.yml 锁定。升级前先在独立环境里跑通测试,确认没有破坏性变化再迁移。

另外,PyTorch和CUDA版本的对应关系也要注意。安装时去官网查兼容表,不要随意混搭。如果用的是云环境,通常预装了匹配的版本,直接使用即可。本地安装时, conda 比 pip 更容易处理CUDA依赖,但 pip 的包通常更新更快。

Logo

聚焦RDK与具身智能,汇集机器人开发教程、开源案例和技术交流

更多推荐