简介:这份深度学习PyTorch源码包面向正在学习深度学习框架的开发者,旨在提供一批可运行、可调试的示例代码,帮助读者熟悉PyTorch建模流程。资源共包含140个文件,核心为45个ipynb交互式笔记本与37个py脚本,覆盖从数据预处理、模型搭建到训练评估的常见环节;辅以14张png图表、9篇md笔记、若干txt说明及配置文件,兼顾理论学习与动手实践。压缩包采用7z格式,整体大小29.33MB,便于下载与解压使用。已有1943人学习下载,社区反馈代码大多能直接跑通,个别版本兼容性问题可参考网上常见解法处理。对希望快速上手PyTorch、寻找可复现代码片段或参考项目组织方式的学习者而言,这份源码包提供了较完整的实验素材与排错思路,是一份轻量而实用的深度学习工具资源。
1. 拿到深度学习 PyTorch 源码包:别急着 Ctrl+C,先看懂这四层再动手
做深度学习的人电脑里多少都囤着几个 PyTorch 源码包——要么是经典分类网络的复现,要么是带数据管道的完整训练工程。但大部分资源下载下来就进了网盘吃灰,真正打开的人里,又有大半卡在第一步:环境装好了,train.py一跑就报错,于是得出结论"源码有问题"。其实问题往往不在源码,而在你没搞懂这套代码的运行逻辑。我把这类源码包拆开看过很多遍,发现不管项目多复杂,都跑不出四层结构:数据层(Dataset 怎么写)、模型层(网络怎么搭)、训练层(优化器和学习率怎么配)、工具层(日志和 checkpoint 怎么管)。这篇文章就按这个顺序把这四层拆开讲,每一层都给出可以直接抄的代码和参数,最后单独开一章聊我踩过的坑——尤其是那些报错信息完全没法看、但原因其实很简单的翻车现场。
2. 数据层是第一道坎:Dataset 与 DataLoader 的高效写法
2.1 先从最简单的图像分类 Dataset 说起
源码包里最常见的就是图像分类项目。不管数据集是 CIFAR、ImageNet 子集还是自己爬的图片,Dataset 的核心就三件事:读路径、做变换、返回样本和标签。很多新手在这里翻车,是因为把路径拼接、标签编码、数据增强全揉在一个类里,写到后面自己都看不懂。
import torch from torch.utils.data import Dataset from PIL import Image import os class ImageFolderDataset(Dataset): def __init__(self, root_dir, transform=None): # root_dir 下按类别分子文件夹,文件夹名即标签 self.samples = [] self.classes = sorted(os.listdir(root_dir)) self.class_to_idx = {cls: i for i, cls in enumerate(self.classes)} for cls in self.classes: cls_path = os.path.join(root_dir, cls) for fname in os.listdir(cls_path): if fname.lower().endswith(('.jpg', '.jpeg', '.png')): self.samples.append((os.path.join(cls_path, fname), self.class_to_idx[cls])) self.transform = transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] image = Image.open(path).convert('RGB') if self.transform: image = self.transform(image) return image, label这段代码的逻辑很直白:__init__里扫描文件夹建立样本列表,__getitem__里按索引读图并应用变换。transform参数专门用来接数据增强,放到外面传而不是写死在 Dataset 里,这样训练和推理就能用不同的变换策略。convert('RGB')这一步常被忽略,但灰度图、RGBA 图混在一起时,不统一通道数直接会让模型输入维度报错。
2.2 DataLoader 的参数不是随便填的
Dataset 写好了只是第一步,真正喂给模型的 DataLoader 有一堆参数值得抠。
from torch.utils.data import DataLoader train_loader = DataLoader( dataset=train_dataset, batch_size=64, # 显存不够时从 64 降到 32 或 16 shuffle=True, # 训练集必须打乱,验证集设 False num_workers=4, # Windows 建议设 0,Linux 按 CPU 核数设 pin_memory=True, # 使用 GPU 时开启,减少数据传输时间 drop_last=True # 最后一批数据不足 batch_size 时丢弃,避免 BN 层报错 )shuffle只在训练集开,验证集和测试集开了反而会影响评估的一致性。num_workers是个很玄学的参数——不是越大越好,线程多了反而会因为切换开销变慢,我一般取 CPU 物理核数的一半。pin_memory开了以后,显存和内存之间的拷贝会走锁页内存,速度快一截,代价是占用的系统内存不能换页,内存紧张时反而得不偿失。drop_last这个选项很多人不注意,当数据集大小不是 batch_size 的整数倍时,最后一批的样本数会少于 batch_size,如果你的模型里用了 BatchNorm 且 batch 特别小,跑着跑着就会出奇异值。
2.3 数据增强要克制:先看你的任务再决定用哪套
源码包里通常自带一套数据增强,但别直接照搬。图像分类的标配是 RandomResizedCrop 加 RandomHorizontalFlip,检测和分割还会加随机颜色扰动。
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(size=224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(p=0.5), 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]) ]) val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这里最坑的一点是 Normalize 的均值和标准差。源码包里如果写的是 ImageNet 的统计值,但你用的是自己的数据集,那这个归一化其实帮不了太多,但也不会有大问题——因为模型的预训练权重也是用的这套统计值,两者配套就行。真正的问题是如果你换了自己算的均值和标准差,但模型权重是 ImageNet 预训练的,那训练初期 loss 会莫名其妙地高。RandomResizedCrop的scale参数控制裁剪面积占原图比例,最小 0.08 是 ImageNet 训练的经典值,但小数据集上裁剪太狠会让模型学不到东西,我也常把下界调到 0.5 以上。
3. 模型层:看清源码里的网络是怎么搭起来的
3.1 从分类头到骨干网络,逐行拆解
源码包里的模型定义一般分两种:一种是完整写死的小网络,另一种是支持多种骨干的框架。后者更常见也更实用,因为换骨干只需要改一行配置。
import torch.nn as nn import torchvision.models as models class CustomClassifier(nn.Module): def __init__(self, backbone='resnet18', num_classes=10, dropout=0.5): super().__init__() # 加载预训练骨干,去掉自带分类头 self.backbone = models.__dict__[backbone](pretrained=True) in_features = self.backbone.fc.in_features self.backbone.fc = nn.Identity() # 替换成恒等映射,特征直出 self.classifier = nn.Sequential( nn.Dropout(p=dropout), nn.Linear(in_features, 256), nn.ReLU(inplace=True), nn.Dropout(p=dropout), nn.Linear(256, num_classes) ) def forward(self, x): features = self.backbone(x) return self.classifier(features)pretrained参数是预训练权重的总开关。自己在小数据集上训练,强烈建议开着,哪怕你的数据和 ImageNet 八竿子打不着,浅层学到的边缘和纹理特征也是通用的。nn.Identity()这步是源码里常见的技巧——把最后的全连接层替换掉,让骨干直接输出特征向量,因为不同骨干的输出维度不一样,统一走自定义分类头就方便换骨干了。注意 ResNet 的属性名是backbone.fc,但如果换成 VGG 或 DenseNet,属性名可能就不同了,换骨干时报错多半是在这里。
3.2 冻结骨干参数:迁移学习的核心操作
训练资源有限时,把骨干冻结只训分类头是最稳的做法。等分类头收敛了,再解冻骨干微调,效果通常更好。
def freeze_backbone(model, freeze=True): for name, param in model.named_parameters(): if 'backbone' in name: param.requires_grad = not freeze # 打印可训练的层,确认冻结生效 trainable = [n for n, p in model.named_parameters() if p.requires_grad] print(f"Trainable layers: {trainable}")这段逻辑留意两点。第一,named_parameters返回的 name 里包含了模块前缀,所以判断'backbone' in name就能精确命中骨干层。第二,冻结不是把参数设为不可修改,而是让requires_grad为 False,这样反向传播就不会计算这些层的梯度,显存占用和训练时间都能降下来。解冻时把freeze传 False 就行,但别忘了同时把优化器重新构建一遍——PyTorch 的优化器是在初始化时扫描参数的,后期才改requires_grad不会让旧的优化器自动忽略冻结层。
3.3 多卡训练与 BatchNorm 的兼容性问题
源码包里如果带了多卡训练的代码,你大概率会遇到 BatchNorm 相关的诡异报错。多卡同步 BatchNorm 的实现方式是把所有卡的统计量收集到一起再计算,PyTorch 原生只支持单卡内计算,跨卡同步需要额外处理。
import torch.distributed as dist import torch.multiprocessing as mp from torch.nn.parallel import DistributedDataParallel as DDP def setup(rank, world_size): dist.init_process_group('nccl', init_method='tcp://127.0.0.1:23456', rank=rank, world_size=world_size) def cleanup(): dist.destroy_process_group() # 在 main 函数里按进程启动 def main_worker(rank, world_size, args): setup(rank, world_size) model = CustomClassifier() # 必须先把模型移到自己这张卡上,再包 DDP torch.cuda.set_device(rank) model.cuda(rank) model = DDP(model, device_ids=[rank]) # 训练循环... cleanup()init_method指向的本机地址和端口只是进程间的握手地址,第一张卡起来之后会在这个地址上交换信息。torch.cuda.set_device(rank)这行很关键——DDP 要求模型参数所在的设备和你传入的device_ids完全一致,少一步就会报设备不匹配的错误。DDP 包完之后,模型的状态字典键名会多出module.前缀,保存 checkpoint 时记得先取model.module.state_dict(),否则单卡加载时会报键名不匹配。跨卡同步 BatchNorm 在检测、分割这种 batch 本身就小的任务上有明显收益,但分类任务如果单卡 batch 已经到了 32 以上,收益就不那么明显了。
4. 训练层:优化器、学习率调度与 Loss 的调参逻辑
4.1 优化器选型:SGD 还是 Adam,源码里为什么这么选
PyTorch 源码包里出现频率最高的优化器就是 SGD 带动量 和 Adam 两种,选型逻辑其实很清晰。Adam 收敛快、对学习率不敏感,适合刚起步探路;SGD 加上合适的动量后收敛更稳、泛化更好,适合在调参的中后期追求最终精度。
import torch.optim as optim # 骨干和分类头用不同的学习率,常见的分层学习率策略 optimizer = optim.SGD([ {'params': model.backbone.parameters(), 'lr': 1e-4}, {'params': model.classifier.parameters(), 'lr': 1e-3} ], momentum=0.9, weight_decay=1e-4)分层学习率是迁移学习的实用技巧。预训练骨干的权重已经比较接近最优,小学习率就够了,分类头是随机初始化的,步子可以迈大一点。用params列表传多个参数组,每个组可以单独设lr,这样就不需要两个优化器了。momentum一般都取 0.9,这是一个经过大量实验检验的稳定值。weight_decay是 L2 正则的系数,分类小数据集从 1e-4 起步,如果过拟合明显再加到 5e-4。
4.2 学习率调度:不要把 warmup 和 cosine 写死
源码包里常见的调度策略有两种趋势——StepLR 按固定步长衰减,CosineAnnealingLR 按余弦曲线走完整个训练周期。前者适合短训练,后者适合长训练而且几乎不需要调衰减节点。
from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR # 先线性热身 5 个 epoch,再余弦退火到 1e-6 warmup_epochs = 5 total_epochs = 100 scheduler = LinearLR(optimizer, start_factor=0.1, total_iters=warmup_epochs) # 注意:这里的数学逻辑是先 warmup 再 cosine,两个调度器串联 scheduler = CosineAnnealingLR(optimizer, T_max=total_epochs - warmup_epochs, eta_min=1e-6)串联调度器时顺序有讲究。LinearLR负责把学习率从起始值的 10% 线性升到全量,这个过程如果太短,模型会在学习率偏高时震荡;如果太长,前期收敛太慢。CosineAnnealingLR的T_max要从总 epoch 里减去 warmup 的花费,否则实际训练长度和调度器预期不匹配,最后学习率还没降到最低训练就停了。.step()的调用时机也要注意——本应该在每次优化器.step()之后调用,但多卡训练或梯度累积时,容易在错误的位置触发,导致学习率提前衰减。我的习惯是在每个 epoch 结束时手动调一次 scheduler.step(),而不是每步都调,这样更直观。
4.3 Loss 的隐藏坑:标签平滑与样本不均衡
分类任务里 CrossEntropyLoss 是最主流的,但源码包往往直接nn.CrossEntropyLoss()一行写完,没人告诉你类别不均衡时它会让模型偏向样本多的类。
class LabelSmoothCrossEntropy(nn.Module): def __init__(self, smoothing=0.1, weight=None): super().__init__() self.smoothing = smoothing self.weight = weight def forward(self, logits, targets): # 将 hard label 转成 soft label:真实类保留 1 - smoothing,其余均匀分配 log_probs = torch.log_softmax(logits, dim=-1) n_classes = logits.size(-1) with torch.no_grad(): true_dist = torch.zeros_like(log_probs) true_dist.fill_(self.smoothing / (n_classes - 1)) true_dist.scatter_(1, targets.unsqueeze(1), 1 - self.smoothing) if self.weight is not None: weights = self.weight[targets].unsqueeze(1) loss = -(weights * true_dist * log_probs).sum(dim=-1) else: loss = -(true_dist * log_probs).sum(dim=-1) return loss.mean()标签平滑的作用是防止模型对训练集的预测过于自信,从而提升泛化能力。smoothing取 0.1 是经验值,太大会把真实类别的信号稀释,导致欠拟合。weight参数用来处理样本不均衡,比如某个类别只有几百张图,另一个类别有上万张,把少数类的 loss 权重调大就能缓解偏向。这里用了torch.no_grad()包裹 one-hot 构建的部分,因为标签转换不需要梯度,能省一点显存。注意fill_和scatter_是在原地操作的,别拿这个张量去做别的事。
5. 避坑与排查:PyTorch 源码跑不起来的真实原因记录
5.1 显存不足报错,不是 batch_size 的锅
现象:训练跑到第二个 epoch 突然CUDA out of memory,按网上的建议把batch_size从 64 降到 8 还是炸。 原因:模型里用了torch.utils.checkpoint梯度检查点或者大量的中间变量缓存没有释放,比如在循环里不断把loss.item()之外的东西存下来。另一个常见原因是验证阶段忘记套torch.no_grad(),导致验证时也算了梯度。 解决:先检查验证循环,确认每个 batch 的推理都在with torch.no_grad():里;再用torch.cuda.empty_cache()在 epoch 之间清一次缓存;最后才考虑降 batch。如果这三步都不行,改用梯度累积来模拟大 batch。
5.2 单个张量直接传进模型报错
现象:Expected 4-dimensional input for 4-dimensional weight [64, 3, 7, 7], but got 3-dimensional input of size [3, 224, 224] instead。 原因:数据管道里少了batch这个维度。我见过最多的情况是自定义 Dataset 的__getitem__返回了单张图,而没有在 DataLoader 里被正确堆叠,或者你在代码里手动model(img)但 img 忘了unsqueeze(0)。 解决:调试时优先打印数据形状,确认第一个维度是 batch。如果数据集本身就返回单张图,在 DataLoader 外面处理时记得加img = img.unsqueeze(0)。
5.3 多卡训练时 DDP 报错找不到主进程
现象:Address already in use或connection refused,有时候还会卡死不动。 原因:init_method的端口被系统占用,或者多个训练脚本同时启动后抢同一个端口。更隐蔽的情况是 Windows 下 mp.spawn 启动子进程的方式不兼容,子进程报错后主进程直接挂起。 解决:换一个高位端口如 29500 以上的随机值;Windows 用户不要用mp.spawn,改用启动时传WORLD_SIZE和RANK环境变量的方式,在命令行里分别启动多个进程更稳。
5.4 复现实验时结果对不上
现象:同一份源码,同一批数据,跑出来的结果和资源包里的精度差了两个点以上。 原因:没固定随机种子是最常见的,其次是一些库的浮点运算在 GPU 上不保证完全确定。还有人把shuffle设成了 True 但没有设 seed,导致每次跑的数据顺序不同。 解决:在训练脚本开头固定所有随机源,代码顺序不要乱动。重点是把torch.backends.cudnn.benchmark设成 False,这个选项默认自动选择最优卷积算法,会引入不确定性,需要精确复现时得关掉。
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 = False5.5 模型加载权重时键名不匹配
现象:Missing key(s) in state_dict: "backbone.fc.weight"或者多出一堆module.前缀。 原因:保存时用了model.module.state_dict()(DDP 模型),加载时却用原始单卡模型去 load;或者反过来。另一个原因是源码里改了模型结构,比如分类头输出维度变了,但权重文件还是旧结构的。 解决:统一保存和加载的入口。我一般保存时只存model.state_dict(),并在 DDP 包装之前取一次;加载时用一个工具函数自动剥离module.前缀。
def load_state_dict_with_flexible_keys(model, checkpoint_path): state_dict = torch.load(checkpoint_path, map_location='cpu') # 自动适配 DDP 保存的权重 new_state_dict = {} for k, v in state_dict.items(): if k.startswith('module.'): k = k[7:] # 剥掉 'module.' new_state_dict[k] = v model.load_state_dict(new_state_dict)6. 进阶:用子类化重写训练循环,把源码包改造成你自己的框架
源码包里的训练循环通常是一次性的脚本,改参数要动内部代码,维护起来很累。我习惯把训练逻辑重构成一个 Trainer 类,这样每次复现新项目时只需要替换模型、数据集和配置,训练循环不用再动。
class BaseTrainer: def __init__(self, model, train_loader, val_loader, optimizer, scheduler, cfg): self.model = model self.train_loader = train_loader self.val_loader = val_loader self.optimizer = optimizer self.scheduler = scheduler self.cfg = cfg self.best_acc = 0.0 def train_one_epoch(self): self.model.train() total_loss = 0.0 correct = 0 total = 0 for images, labels in self.train_loader: images = images.cuda(non_blocking=True) labels = labels.cuda(non_blocking=True) self.optimizer.zero_grad() outputs = self.model(images) loss = nn.CrossEntropyLoss()(outputs, labels) loss.backward() # 梯度裁剪:防止 RNN 或深层网络梯度爆炸 torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=5.0) self.optimizer.step() total_loss += loss.item() * images.size(0) _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() avg_loss = total_loss / total acc = 100.0 * correct / total return avg_loss, acc @torch.no_grad() def validate(self): self.model.eval() total_loss = 0.0 correct = 0 total = 0 for images, labels in self.val_loader: images = images.cuda(non_blocking=True) labels = labels.cuda(non_blocking=True) outputs = self.model(images) loss = nn.CrossEntropyLoss()(outputs, labels) total_loss += loss.item() * images.size(0) _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() return total_loss / total, 100.0 * correct / total def fit(self, epochs): for epoch in range(epochs): train_loss, train_acc = self.train_one_epoch() val_loss, val_acc = self.validate() # 只在验证集提升时保存 checkpoint,省磁盘也省时间 if val_acc > self.best_acc: self.best_acc = val_acc torch.save({ 'epoch': epoch, 'model_state_dict': self.model.state_dict(), 'optimizer_state_dict': self.optimizer.state_dict(), 'best_acc': self.best_acc, }, 'best_model.pth') if self.scheduler: self.scheduler.step() print(f"Epoch {epoch+1}: train_loss={train_loss:.4f}, train_acc={train_acc:.2f}%, " f"val_loss={val_loss:.4f}, val_acc={val_acc:.2f}%")torch.no_grad()不仅省显存,还能让推理路径的计算图不被保留,验证速度能快不少。clip_grad_norm_是我加在反向传播之后、优化器更新之前的保险丝——梯度范数超过阈值就等比缩小,防止数值爆炸。checkpoint 里同时保存模型、优化器和最优 acc,这样中断训练后能无缝续跑,不用从头来。
后续要扩展时,在这个基类上做子类化就行。比如加混合精度训练,只需要重写train_one_epoch,在 forward 前后包一层torch.cuda.amp.autocast()和GradScaler;要加早停就监听验证 acc 连续多少个 epoch 不增长就提前退出。经过这一轮改造,任何源码包在你手里都变成了一个可插拔的框架。从那以后我每次拿到新的 PyTorch 源码,第一件事不是跑训练,而是先把数据管道和训练逻辑拆出来装进这套 Trainer 壳子里,跑通了再去研究模型结构。这个习惯帮我省掉了大量重复调试的时间,希望也能帮到你。
本文还有配套的精品资源,点击获取