1. 从零手搓一个PyTorch项目:我踩过的坑和总结出的完整路线图
搞深度学习这几年,PyTorch几乎成了我每天都要打交道的工具。但说实话,真正让我从“会跑Demo”到“能独立扛项目”的转折点,不是看了多少教程,而是自己从头到尾把一个完整项目搭起来的那段时间。今天想聊的就是:如果你手上有一个PyTorch项目要落地,从环境搭建到模型训练再到部署推理,整条链路上到底有哪些关键决策点,以及每个环节我踩过哪些坑、后来怎么解决的。
这篇文章适合谁看?如果你已经了解PyTorch的基本语法,能看懂nn.Module和DataLoader,但一到自己独立做项目就不知道从哪下手,那这篇内容就是写给你的。我不会只讲“怎么做”,更会讲“为什么这么做”以及“不这么做会怎样”。整篇内容基于我实际做过的几个图像分类和序列预测项目经验,把PyTorch项目开发中最容易翻车的环节一个个拆开来讲。
先给一个整体认知:一个PyTorch项目,不管大小,核心链路其实就五块——数据管道、模型定义、训练循环、验证与调参、推理与部署。每一块都有它的“默认最佳实践”,但这些最佳实践不是拍脑袋来的,而是社区在大量踩坑之后沉淀下来的。下面我按这条链路,把每个环节的核心细节和实操要点掰开揉碎讲清楚。
2. 项目整体设计与技术选型思路
2.1 为什么PyTorch成了大多数项目的首选
先说选型这件事。深度学习框架有好几个,为什么我最后基本都落在PyTorch上?核心原因有三个。
第一是动态计算图。PyTorch默认是Eager模式,你写一行代码它就执行一行,调试的时候可以直接用Python的pdb打断点,想看哪个中间变量的值直接print就行。这对排查问题太重要了。相比之下,静态图框架需要你先定义完整计算图再运行,调试体验差很多。虽然现在也有编译模式可以加速,但默认的动态图对开发效率的提升是实打实的。
第二是生态完整度。torchvision管图像,torchaudio管音频,torchtext管文本,torchmetrics管评估指标,Lightning管训练流程抽象,Optuna管超参搜索。你几乎不需要自己造轮子,大部分常见需求都有成熟的库可以直接用。
第三是社区活跃度。遇到问题去搜,大概率已经有人踩过同样的坑。模型权重、预训练参数、开源实现,PyTorch社区的资源是最丰富的。这意味着你的项目起步成本低,不需要从零训练一个骨干网络。
注意:选框架不是信仰问题,是工程问题。如果你的团队已经有TensorFlow的积累,没必要为了“流行”硬切PyTorch。但如果是新项目、小团队、快速迭代的场景,PyTorch的开发效率优势非常明显。
2.2 项目结构怎么组织才不乱
我见过太多人把所有代码塞进一个train.py里,跑通之后想改点东西就痛不欲生。一个可维护的PyTorch项目,目录结构应该从一开始就规划好。下面是我用了几个项目之后固定下来的结构:
project/ ├── configs/ # 配置文件(YAML或Python dict) │ └── default.yaml ├── data/ # 数据目录(原始数据、处理后数据) │ ├── raw/ │ └── processed/ ├── src/ │ ├── dataset.py # Dataset和DataLoader定义 │ ├── model.py # 模型结构 │ ├── train.py # 训练循环 │ ├── validate.py # 验证逻辑 │ ├── predict.py # 推理脚本 │ └── utils.py # 工具函数(日志、种子、指标计算) ├── checkpoints/ # 模型权重保存 ├── logs/ # 训练日志 ├── requirements.txt └── README.md这个结构的好处是职责分离。数据相关的改动只动dataset.py,模型结构的改动只动model.py,训练策略的调整只动train.py。团队协作的时候,不同人负责不同模块,冲突最小化。
配置文件单独抽出来也很关键。学习率、batch size、epoch数、模型保存路径这些参数,不要硬编码在代码里。用YAML或者argparse管理,实验的时候改配置就行,不用动代码。我习惯用YAML加OmegaConf库,支持嵌套配置和命令行覆盖,非常灵活。
2.3 环境管理的几个关键决策
环境这块,我的建议是永远用虚拟环境,不要往系统Python里装包。conda和venv都行,我个人偏好conda,因为它对CUDA版本的管理更友好。
PyTorch安装最大的坑就是CUDA版本匹配。你的显卡驱动支持的CUDA版本、PyTorch编译时用的CUDA版本、你系统里装的CUDA Toolkit版本,这三个是不同概念。最稳妥的做法是去PyTorch官网的安装页面,根据你的环境选择对应的安装命令。比如:
# 示例:安装CUDA 11.8对应的PyTorch pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118安装完之后一定要验证:
import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果cuda.is_available()返回False,大概率是版本不匹配。这时候不要急着重装,先检查驱动版本:nvidia-smi看右上角的CUDA Version,那个是驱动支持的最高CUDA版本,你装的PyTorch CUDA版本不能超过它。
实操心得:我习惯在
requirements.txt里锁定所有包的版本号,包括PyTorch本身。因为不同版本的PyTorch在API上可能有细微差异,比如某些层的默认参数变了,会导致你复现不了之前的实验结果。锁定版本是最省心的做法。
3. 数据管道:整个项目最容易被低估的环节
3.1 Dataset和DataLoader的正确打开方式
很多人觉得数据加载就是写个Dataset类、套个DataLoader就完事了。但实际上,数据管道往往是整个训练流程的瓶颈。GPU利用率上不去,十有八九是数据加载拖了后腿。
先说过Dataset。自定义Dataset需要实现三个方法:__init__、__len__、__getitem__。关键在__getitem__里,它定义了“取第i个样本”的逻辑。这里有个常见的性能陷阱:不要在__getitem__里做耗时的IO操作。比如你每次都要从磁盘读一张大图然后裁剪,那训练速度会被磁盘IO卡死。
我的做法是预处理阶段就把数据转成高效格式。图像数据可以提前resize到目标尺寸,存成.npy或者LMDB格式。文本数据可以提前tokenize好,存成二进制。这样__getitem__里只做内存读取和轻量变换,速度能快好几倍。
DataLoader这边,几个参数需要重点关注:
| 参数 | 作用 | 我的常用值 | 注意事项 |
|---|---|---|---|
batch_size | 每批样本数 | 32/64/128 | 受显存限制,需要实测 |
num_workers | 加载进程数 | 4-8 | 设为0会在主进程加载,容易卡 |
pin_memory | 锁页内存 | True | GPU训练时开启,加速数据传输 |
shuffle | 是否打乱 | 训练True,验证False | 验证集不能打乱 |
drop_last | 丢弃最后不足一批 | 训练True | 避免batch norm出问题 |
num_workers的设置有个经验公式:设为CPU核心数的一半到三分之二。设太大反而会因为进程切换开销导致变慢。另外在Windows上,num_workers > 0需要把训练代码放在if __name__ == '__main__':下面,否则会报错,这个坑我踩过好几次。
3.2 数据增强:不是越多越好
数据增强是提升模型泛化能力最便宜的手段。但增强策略不是拍脑袋加的,要根据你的数据特点和任务类型来选。
图像分类任务里,常用的增强有:随机裁剪、随机翻转、颜色抖动、旋转、Cutout、Mixup。但要注意,有些增强会改变语义。比如数字识别任务里,你把“6”旋转180度就变成了“9”,这种增强反而有害。再比如医学图像里,翻转可能改变左右器官的位置关系,也不能随便用。
我的原则是:先加最安全的增强(翻转、小幅度平移),观察验证集指标,如果过拟合再逐步加更激进的增强。torchvision.transforms里的Compose可以方便地组合多个变换,但顺序有讲究。一般先做几何变换(裁剪、翻转),再做颜色变换,最后做归一化。
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])归一化那一步的均值和方差,如果是用预训练模型,必须和预训练时用的保持一致。上面这组值是ImageNet的统计值,用torchvision的预训练模型时直接抄就行。如果是自己从零训练,可以算一下自己数据集的均值和方差。
3.3 处理类别不平衡的几种实用策略
实际项目里,类别不平衡是常态。比如缺陷检测,正常样本可能占99%,缺陷只占1%。这时候如果直接用交叉熵损失,模型会倾向于全预测成多数类,准确率看起来很高但完全没用。
我常用的几种处理方式,按优先级排列:
第一种是重采样。对少数类过采样,或者对多数类欠采样。torch.utils.data.WeightedRandomSampler可以按权重采样,让每个batch里各类别的比例更均衡。这个方式实现简单,但过采样可能导致少数类过拟合。
第二种是损失函数加权。给少数类的损失乘一个更大的权重。nn.CrossEntropyLoss的weight参数就是干这个的。权重一般设为类别频率的倒数。
# 假设三类样本数分别为1000, 100, 10 weights = torch.tensor([1.0/1000, 1.0/100, 1.0/10]) weights = weights / weights.sum() # 归一化 criterion = nn.CrossEntropyLoss(weight=weights)第三种是Focal Loss。它通过降低易分类样本的权重,让模型聚焦在难分类样本上。对于极度不平衡的场景效果很好。PyTorch没有内置,但实现起来很简单,十几行代码的事。
注意:处理不平衡的时候,评估指标也要跟着换。不要只看准确率,要看F1、AUC或者每类的召回率。否则你优化了半天,指标上根本看不出来。
4. 模型定义与训练循环的核心细节
4.1 nn.Module的写法与常见陷阱
定义模型就是继承nn.Module,在__init__里声明层,在forward里定义前向传播。看起来简单,但有几个坑需要注意。
第一个坑是层注册。只有直接赋值给self属性的层才会被nn.Module注册,才能被parameters()找到、被to(device)移动、被state_dict()保存。如果你把层放在一个Python列表里,比如self.layers = [nn.Linear(10, 10) for _ in range(3)],这些层不会被注册。正确做法是用nn.ModuleList或nn.Sequential。
第二个坑是forward里的控制流。PyTorch的动态图支持在forward里写if-else和循环,但要注意这些控制流在每次前向传播时都会重新执行。如果控制流依赖于输入数据,那没问题;但如果依赖于可学习参数,就需要小心梯度传播的问题。
第三个坑是设备管理。模型和输入数据必须在同一个设备上。我习惯在训练脚本开头就定义好device,然后模型.to(device),每个batch的数据也.to(device)。如果报“Expected all tensors to be on the same device”的错,就是这里没对齐。
class MyModel(nn.Module): def __init__(self, num_classes=10): super().__init__() self.backbone = nn.Sequential( nn.Conv2d(3, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.AdaptiveAvgPool2d(1) ) self.classifier = nn.Linear(128, num_classes) def forward(self, x): features = self.backbone(x) features = features.flatten(1) return self.classifier(features)4.2 训练循环的骨架与关键控制点
训练循环的骨架其实很固定,但每个环节都有细节可以优化。下面是我常用的训练循环结构:
def train_one_epoch(model, dataloader, criterion, optimizer, device, scheduler=None): model.train() running_loss = 0.0 correct = 0 total = 0 for inputs, targets in dataloader: inputs, targets = inputs.to(device), targets.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() if scheduler is not None: scheduler.step() running_loss += loss.item() * inputs.size(0) _, predicted = outputs.max(1) total += targets.size(0) correct += predicted.eq(targets).sum().item() return running_loss / total, correct / total几个关键控制点:
model.train()和model.eval()必须成对出现。训练前调train(),验证前调eval()。这影响Dropout和BatchNorm的行为。忘了切换是新手最常见的错误之一,会导致验证结果异常。
optimizer.zero_grad()的位置。标准做法是在loss.backward()之前清零。如果你用梯度累积,可以多个batch才清一次。但要注意,如果忘了清零,梯度会累加,训练直接跑飞。
学习率调度器的调用时机。StepLR这类按epoch调度的,在epoch结束后调用;OneCycleLR这类按step调度的,在每个batch后调用。搞错了学习率曲线就不对。
4.3 混合精度训练:省显存提速的利器
PyTorch内置了自动混合精度(AMP),用torch.cuda.amp模块。原理是前向传播用float16,反向传播用float32,既省显存又提速。在支持Tensor Core的显卡上,速度提升能有1.5到2倍。
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for inputs, targets in dataloader: inputs, targets = inputs.to(device), targets.to(device) optimizer.zero_grad() with autocast(): outputs = model(inputs) loss = criterion(outputs, targets) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()GradScaler的作用是防止float16下梯度下溢。它会自动缩放损失值,让梯度保持在可表示的范围内。用AMP的时候,scheduler.step()的调用位置也要注意,一般放在scaler.update()之后。
实操心得:AMP不是万能的。有些操作在float16下会出问题,比如某些归一化层或者自定义的数学运算。如果开了AMP之后loss变成NaN,先关掉AMP试试。另外,用AMP的时候模型保存的权重还是float32,不用担心精度损失。
4.4 梯度裁剪与梯度累积
梯度裁剪是防止梯度爆炸的常用手段,尤其在RNN和Transformer里几乎是标配。PyTorch提供了torch.nn.utils.clip_grad_norm_,在loss.backward()之后、optimizer.step()之前调用。
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)max_norm设多少?一般1.0到5.0之间。Transformer常用1.0,RNN常用5.0。这个值需要根据实际情况调,太小会限制模型学习,太大等于没裁。
梯度累积是显存不够时的救命稻草。思路是多个batch的梯度累加后再更新一次参数,等效于增大了batch size。
accumulation_steps = 4 for i, (inputs, targets) in enumerate(dataloader): outputs = model(inputs) loss = criterion(outputs, targets) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()注意loss要除以累积步数,否则梯度会放大。另外BatchNorm的统计量是按实际batch算的,梯度累积不能完全等价于大batch,这点要心里有数。
5. 验证、调参与模型保存的实战经验
5.1 验证集的使用与过拟合判断
验证集的作用是监控模型泛化能力。训练过程中,我习惯每个epoch结束后跑一次验证,记录验证loss和验证指标。判断过拟合的标准很简单:训练loss持续下降,验证loss开始上升,就是过拟合了。
这时候的应对策略有几个:加数据增强、加Dropout、加权重衰减、早停。我一般会同时上几个,然后观察效果。早停是最直接的,设一个patience参数,验证指标连续N个epoch不提升就停。
best_val_loss = float('inf') patience = 10 counter = 0 for epoch in range(num_epochs): train_loss, train_acc = train_one_epoch(...) val_loss, val_acc = validate(...) if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), 'best_model.pth') counter = 0 else: counter += 1 if counter >= patience: print(f"Early stopping at epoch {epoch}") break验证的时候一定要用torch.no_grad()包起来,否则会构建计算图,浪费显存还拖慢速度。
@torch.no_grad() def validate(model, dataloader, criterion, device): model.eval() running_loss = 0.0 correct = 0 total = 0 for inputs, targets in dataloader: inputs, targets = inputs.to(device), targets.to(device) outputs = model(inputs) loss = criterion(outputs, targets) running_loss += loss.item() * inputs.size(0) _, predicted = outputs.max(1) total += targets.size(0) correct += predicted.eq(targets).sum().item() return running_loss / total, correct / total5.2 学习率调参的核心逻辑
学习率是最重要的超参数,没有之一。太大不收敛,太小收敛慢。我的调参流程一般是这样的:
第一步,找学习率的大致范围。用Leslie Smith提出的LR Range Test,从一个很小的学习率开始,每个batch指数增长,记录loss变化。loss下降最快的那个点附近就是合适的学习率。
第二步,选调度策略。常用的是CosineAnnealingLR和OneCycleLR。Cosine退火从初始学习率余弦下降到接近0,适合训练周期确定的场景。OneCycle先升后降,适合快速收敛。
from torch.optim.lr_scheduler import CosineAnnealingLR, OneCycleLR # Cosine退火 scheduler = CosineAnnealingLR(optimizer, T_max=num_epochs, eta_min=1e-6) # OneCycle scheduler = OneCycleLR(optimizer, max_lr=1e-3, total_steps=num_epochs*len(dataloader))第三步,微调。在选定策略下,把初始学习率乘以0.5和2各跑一次,看哪个验证指标最好。一般两三轮就能找到不错的点。
注意:换了batch size之后学习率也要跟着调。经验法则是学习率和batch size的平方根成正比。batch size翻4倍,学习率翻2倍。
5.3 模型保存与恢复的正确姿势
模型保存有两个流派:保存整个模型(torch.save(model, path))和只保存参数(torch.save(model.state_dict(), path))。我强烈推荐后者。保存整个模型会把类的定义也序列化进去,换环境或者改了代码结构就加载不了。只保存参数则很灵活,只要有相同的模型定义就能加载。
# 保存 torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'scheduler_state_dict': scheduler.state_dict(), 'best_val_loss': best_val_loss, }, 'checkpoint.pth') # 恢复 checkpoint = torch.load('checkpoint.pth', map_location=device) model.load_state_dict(checkpoint['model_state_dict']) optimizer.load_state_dict(checkpoint['optimizer_state_dict']) scheduler.load_state_dict(checkpoint['scheduler_state_dict']) start_epoch = checkpoint['epoch'] + 1把optimizer和scheduler的状态也存下来,是为了支持断点续训。如果只存模型参数,恢复训练的时候优化器状态丢失,动量信息没了,学习率调度也得重新开始,效果会打折扣。
map_location参数在跨设备加载时很有用。比如你在GPU上训练的模型要在CPU上加载,不加这个参数会报错。
6. 常见问题与排查技巧实录
6.1 训练不收敛的排查清单
训练不收敛是最让人头疼的问题。我整理了一个排查顺序,从最常见的原因开始查:
| 排查项 | 检查方法 | 常见问题 |
|---|---|---|
| 学习率 | 打印每步学习率 | 太大导致震荡,太小导致不下降 |
| 数据归一化 | 检查输入数据范围 | 没归一化或归一化参数错误 |
| 标签对应 | 可视化几个样本 | 数据和标签不对应 |
| 损失函数 | 确认任务匹配 | 分类用了回归损失等 |
| 梯度 | 打印梯度范数 | 梯度爆炸或消失 |
| 模型初始化 | 检查初始输出 | 全零初始化导致对称性无法打破 |
| 设备 | 确认模型和数据同设备 | 部分层还在CPU上 |
我遇到最多的情况是学习率太大和数据没归一化。这两个问题占了不收敛案例的一大半。先用小学习率(比如1e-4)跑几百步,如果loss能稳定下降,再逐步调大。
6.2 显存不足的几种解决思路
显存不够是另一个高频问题。解决思路按代价从低到高排列:
降低batch size是最简单的,但可能影响BatchNorm效果。可以配合梯度累积来补偿。
使用混合精度能省30%到50%的显存,基本没有副作用。
检查是否有不必要的计算图保留。比如在验证时忘了加torch.no_grad(),或者在循环里累积了loss但没有detach。
用梯度检查点(torch.utils.checkpoint)。它用计算时间换显存,在前向传播时不保存中间激活值,反向传播时重新计算。适合显存极度紧张的情况。
换更小的模型或者冻结部分层。如果用预训练模型,可以只训练最后几层,前面的层冻结不计算梯度。
# 冻结骨干网络 for param in model.backbone.parameters(): param.requires_grad = False6.3 训练速度慢的优化方向
GPU利用率低,训练速度上不去,通常从这几个方向优化:
数据加载是首要排查对象。用nvidia-smi看GPU利用率,如果忽高忽低,说明GPU在等数据。解决办法是增大num_workers、开pin_memory、把数据预处理提前做好。
IO瓶颈。如果数据在机械硬盘上,换成SSD会有明显提升。或者把数据全部加载到内存里(数据量不大的话)。
小算子过多。模型里如果有大量小卷积或者逐元素操作,GPU的并行能力发挥不出来。可以尝试用torch.compile或者把一些小算子融合。
不必要的CPU-GPU同步。比如在训练循环里频繁调用.item()或者print,会强制同步,拖慢速度。把这些操作攒到epoch结束再做。
实操心得:我习惯在训练脚本里加一个简单的计时,记录每个epoch的数据加载时间和前向反向时间。这样一眼就能看出瓶颈在哪。如果数据加载占了总时间的一半以上,那优化数据管道就是当务之急。
6.4 模型部署推理的注意事项
训练完之后,模型要拿去用,这时候有几个坑:
切换到eval模式。推理前必须调model.eval(),否则Dropout会随机丢弃,BatchNorm会用batch统计量而不是全局统计量,结果完全不对。
用torch.no_grad()。推理不需要梯度,关掉能省显存提速。
输入预处理要和训练时一致。训练时用了什么归一化、什么resize,推理时也要一模一样。我见过太多训练和推理预处理不一致导致效果暴跌的案例。
考虑导出为TorchScript或ONNX。如果推理环境没有Python,或者需要更高的性能,可以把模型导出成中间格式。TorchScript用torch.jit.trace或torch.jit.script,ONNX用torch.onnx.export。
# 导出ONNX dummy_input = torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}} )dynamic_axes参数让导出的模型支持动态batch size,不然就固定成导出时的batch size了。
6.5 随机种子与实验可复现性
深度学习实验有随机性,但我们需要尽量保证可复现。PyTorch的随机性来源有好几个:权重初始化、数据打乱、Dropout、数据增强。要固定种子:
import torch import numpy as np import random 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 = Falsecudnn.deterministic = True会让cuDNN只用确定性的算法,但可能会慢一些。benchmark = False关闭自动调优。如果追求极致速度,可以设benchmark = True,但结果就不可复现了。
注意:即使固定了所有种子,不同显卡、不同CUDA版本、不同PyTorch版本之间也可能有微小差异。完全可复现是很难的,能做到“同一环境下可复现”就足够了。实验记录里一定要写清楚环境版本。
7. 从训练到上线的最后一段路
模型训练完、指标达标,只是完成了项目的一半。真正上线还要考虑推理性能、服务稳定性、版本管理这些工程问题。
推理性能优化上,我常用的手段有:量化(把float32转成int8,模型体积缩小4倍,速度提升2到4倍)、剪枝(去掉不重要的权重)、知识蒸馏(用大模型教小模型)。这些技术PyTorch都有对应的工具支持,torch.quantization做量化,torch.nn.utils.prune做剪枝。
服务化方面,简单的场景可以用Flask或FastAPI包一个HTTP接口,把模型加载到内存里,请求来了直接推理。复杂场景可以用TorchServe,它支持多模型管理、动态批处理、指标监控。如果推理延迟要求极高,可以考虑用TensorRT或者ONNX Runtime来加速。
版本管理这块,我的习惯是每次实验都存一个checkpoint,命名带上日期和关键参数,比如20240115_lr1e-3_bs64_acc0.92.pth。同时在日志里记录完整的配置和指标。这样回头找“那个效果最好的模型”的时候不会抓瞎。
模型上线之后不是就完事了,还要持续监控。关注推理延迟、吞吐量、输入数据分布是否漂移、预测结果分布是否异常。如果发现指标下降,可能需要重新训练。这套流程叫MLOps,展开讲能写好几篇,这里就不深入了。
说到底,PyTorch项目从零到一,技术上的难点其实都能查到资料,真正考验人的是工程细节的把控和遇到问题时的排查思路。我上面列的这些坑,每一个都是我实际踩过之后才印象深刻的。希望这些经验能让你少走点弯路。如果你正在做自己的PyTorch项目,遇到什么奇怪的问题,欢迎一起交流,很多时候一个人卡半天的bug,别人一句话就点破了。