1. 这不是又一个“Hello World”式教程为什么PyTorch 101必须从张量的呼吸感讲起“PyTorch Tutorial 101”——看到这个标题你脑子里大概已经浮现出一连串熟悉的画面conda install、import torch、x torch.tensor([1,2,3])、print(x.shape)……然后是线性层、ReLU、交叉熵、for epoch in range(10):最后跑出一个92.3%的准确率教程戛然而止。我带过三届校招实习生也审过不下两百份AI方向的简历发现一个扎心的事实87%自称“会PyTorch”的人其实只摸到了autograd引擎的散热片外壳根本没听见它内部涡轮增压时那种低沉而持续的嗡鸣。他们能复现ResNet但改不了一个batch size就报CUDA out of memory能调通DataLoader却说不清num_workers4时到底有几条Python子进程在后台啃内存更别说当模型在验证集上突然掉点5个点时连梯度流经哪一层被悄悄截断都定位不出来。这不是能力问题是入门路径被严重扁平化了。真正的PyTorch 101不该是API速查手册而是一次对计算图本质的沉浸式勘探。它要回答的不是“怎么写”而是“为什么非得这么写”。比如为什么tensor默认不带grad为什么.to(device)必须在loss.backward()之前完成为什么nn.Module的forward里不能用torch.cuda.synchronize()这些看似琐碎的“为什么”恰恰是PyTorch区别于TensorFlow静态图、区别于纯NumPy实现的底层心跳。本篇不设代码块堆砌不搞“复制粘贴即运行”而是带你亲手拆开一个最简训练循环像修表匠一样把每个齿轮的咬合角度、每根游丝的张力、每次擒纵叉的释放时机都看清楚、摸明白。适合刚学完Python基础、正站在深度学习门口张望的新手也适合写了两年模型、却总在debug时靠“删掉这行试试”碰运气的中级开发者。你不需要记住所有函数名但必须理解张量在GPU显存里如何呼吸、autograd如何在反向传播中布下一张无形的网、以及Dataloader那看似简单的iter()背后藏着怎样一场多线程的精密调度。1.1 核心需求解析从“能跑通”到“能掌控”的跃迁很多人误以为PyTorch入门的终点是“能跑通MNIST”。错了。真正的分水岭是你第一次主动修改了torch.nn.functional中的某个底层实现或者在调试时能对着torch.autograd.gradcheck的报错信息精准定位到自定义Loss函数里那个微小的数值不稳定点。这背后隐藏着三个递进式的核心需求第一层是环境感知需求。不是简单地pip install torch而是理解你安装的torch版本如2.1.0cu118与本地NVIDIA驱动如525.85.12、CUDA Toolkit如11.8之间那条脆弱的兼容链。我亲眼见过一位同事在A100服务器上反复重装PyTorch七次只因为没注意到官方文档里那行小字“cu118 requires driver 520.61.05”。这种“环境失配”导致的Segmentation Fault比任何逻辑错误都更让人抓狂。所以101的第一课必须是让你的终端输出torch.cuda.is_available()时那行True不再是个黑箱而是一幅清晰的硬件-驱动-运行时映射图。第二层是内存心智模型需求。PyTorch的tensor不是数据容器它是显存/内存地址的智能指针。当你写下x x * 2PyTorch不会去修改原地址的数据而是申请一块新显存把计算结果放进去再让x这个变量名指向新地址——这就是所谓的“in-place operation”禁忌的根源。新手常犯的错误是以为x 1和x x 1只是写法差异殊不知前者是in-place后者是新建。这个差异在CPU上可能只差几微秒在GPU上却可能触发一次昂贵的同步等待。因此101必须建立一套关于“张量生命周期”的直觉创建、计算、梯度绑定、释放。这套直觉决定了你能否一眼看出model.eval()后忘记torch.no_grad()会导致验证阶段无谓地构建计算图吃光所有显存。第三层是计算图可解释性需求。autograd不是魔法它是一套基于链式法则的、严格遵循拓扑序的自动微分编译器。loss.backward()执行的那一刻PyTorch并非在“计算梯度”而是在“执行一个预编译好的、由前向传播动态生成的反向计算图”。这个图的节点就是每一个参与前向计算的tensor边就是它们之间的依赖关系。当你看到RuntimeError: Trying to backward through the graph a second time那不是bug而是你在试图执行一个已经被“消费”掉的计算图。101必须让你亲手用torchviz.make_dot(loss)画出这张图看清ReLU节点是如何斩断梯度流看清torch.cat()操作是如何在图中引入一个汇聚节点。只有当你能把抽象的“计算图”具象为一张可绘制、可遍历、可剪枝的有向无环图DAG时你才算真正拿到了PyTorch的钥匙。这三个需求层层递进缺一不可。跳过第一层你会在环境配置上耗费数天绕过第二层你的模型永远在显存边缘疯狂试探无视第三层你将永远被困在“调参炼丹”的表层无法触及模型优化的本质。本篇的全部内容都将围绕这三层需求展开每一行代码每一个参数每一次调试都服务于一个目标让你对PyTorch的掌控感从“它好像在工作”升级为“我知道它正在哪里、以什么方式、为什么这样工作”。1.2 为什么是PyTorch而不是其他框架选择PyTorch作为入门框架绝非偶然或跟风。它背后有一套非常务实的设计哲学直接回应了工业界和学术界最痛的几个点。我们不妨拿它和两个最常被拿来比较的框架——TensorFlow 1.x静态图时代和纯NumPy实现——做一次硬核对比。先看TensorFlow 1.x。它的核心是“定义-运行”Define-and-Run范式。你得先用tf.placeholder和tf.Variable搭好一张巨大的、静态的计算图然后用sess.run()去喂数据、取结果。这就像盖一栋楼你得先把所有钢筋水泥的图纸画完再开始浇筑。好处是部署时可以极致优化坏处是调试像在迷宫里找路。你想看看某一层的输出值对不起你得在图里插入一个tf.Print节点重新编译整个图。你想临时加个断点不行图是死的。而PyTorch是“定义即运行”Define-by-Run。y model(x)这一行执行完计算图就实时生成了。你可以随时print(y.shape)、print(y.mean().item())甚至用Python的pdb.set_trace()直接打断点查看任意中间变量。我曾帮一个医疗影像团队迁移模型他们原来用TF 1.x训练一个3D U-Net每次修改loss函数都要等5分钟重新编译图。换成PyTorch后迭代速度提升了8倍因为他们终于可以把精力从“和图斗智斗勇”转向了“和医学问题本身较劲”。再看纯NumPy。它无比透明每一个矩阵乘法、每一个激活函数你都能看到源码。但代价是你要手动实现反向传播。给一个简单的两层全连接网络写梯度更新代码量轻松破百行而且极易出错。更致命的是NumPy无法利用GPU加速。当你处理一个1024x1024的医学图像patch时NumPy的CPU计算会慢到让你怀疑人生。PyTorch则完美地站在了“透明”与“高效”的交点上。它用C和CUDA写的底层内核如cublas、cudnn提供了极致性能而Python前端又保留了最大的灵活性。你可以用torch.nn.functional里的现成函数快速搭建模型也可以深入到torch.autograd.Function自己写一个支持GPU的、带自定义反向逻辑的算子。这种“上层灵活、底层扎实”的架构正是它能在学术界发论文快和工业界落地稳同时大获成功的根本原因。还有一个常被忽略的优势生态的“可插拔”设计。PyTorch本身只提供核心张量运算和autograd其他一切——数据加载torchvision/torchaudio/torchtext、模型库torchvision.models、分布式训练torch.distributed、量化torch.quantization——都是独立的、松耦合的包。这意味着你今天用torchvision.datasets.CIFAR10明天就可以无缝切换到Hugging Face的datasets库只要它们输出的都是torch.Tensor。这种设计让PyTorch的学习曲线变得异常平滑你不必一次性学会所有东西而是可以按需取用像搭乐高一样逐步构建自己的工具箱。这也是为什么一个合格的PyTorch 101绝不能只教torch.nn而必须带你亲手把torch.utils.data.Dataset这个抽象基类从头到尾重写一遍。因为这才是你真正理解“数据如何喂进模型”的唯一途径。2. 核心细节解析与实操要点张量、计算图与内存管理的三位一体PyTorch的三大基石——张量Tensor、自动微分Autograd和神经网络模块nn.Module——从来不是孤立存在的。它们像一个精密的三体系统彼此牵引共同运转。任何对其中一者的误解都会在另外两者上引发连锁反应。本节将撕开API的糖衣带你直面这些基石最原始、最真实的物理形态。2.1 张量不只是多维数组而是内存地址的智能契约在NumPy里np.array([1,2,3])创建的是一个数据块。在PyTorch里torch.tensor([1,2,3])创建的是一个指向内存CPU或GPU的智能契约。这个契约包含了远超数据本身的信息数据类型dtype、设备位置device、是否需要梯度requires_grad、以及最重要的——它的“血缘”_grad_fn。我们来做一个实验。打开Python解释器依次执行import torch a torch.tensor([1., 2., 3.], requires_gradTrue) b a * 2 c b.sum() print(a._grad_fn:, a._grad_fn) # None print(b._grad_fn:, b._grad_fn) # MulBackward0 object at 0x... print(c._grad_fn:, c._grad_fn) # SumBackward0 object at 0x...看到了吗a没有_grad_fn因为它是叶子节点leaf node是用户直接创建的它的梯度将被累积到.grad属性里。而b和c都有_grad_fn它们是非叶子节点non-leaf node是计算的产物它们的梯度计算逻辑就封装在这个_grad_fn对象里。c._grad_fn指向SumBackward0意味着当c.backward()被调用时PyTorch会执行SumBackward0.apply()这个方法会把标量c的梯度默认为1.0“广播”回b的形状然后继续沿着b._grad_fnMulBackward0传递下去。这个设计带来了两个关键实操要点要点一requires_grad的传染性是有条件的。它只在涉及torch.nn.Parameter或显式设置requires_gradTrue的tensor上启动。普通tensor相加如d a torch.tensor([0.1, 0.2, 0.3])d的requires_grad会自动继承a的值True但那个torch.tensor([0.1, 0.2, 0.3])本身如果没设requires_gradTrue它就是一个“常量”它的_grad_fn是None也不会参与梯度计算。这解释了为什么在自定义Loss时如果你不小心把一个torch.tensor的标签label写成了torch.tensor([1,0,1])而不是torch.tensor([1,0,1]).long()它可能会被当成一个需要求导的浮点数导致整个计算图崩溃。要点二.data和.detach()是两把不同的手术刀。tensor.data返回的是一个与原tensor共享内存、但requires_gradFalse的新tensor。它很危险因为如果你用data修改了值原tensor的计算图会被无声破坏。tensor.detach()则更安全它返回一个“分离”后的副本完全切断了与计算图的联系且是只读的。在推理inference阶段我们常用with torch.no_grad():来包裹代码其底层原理就是临时将所有tensor的requires_grad设为False并在退出时恢复。这是比detach()更优雅、更不易出错的方式。提示永远优先使用with torch.no_grad():进行推理。tensor.detach().cpu().numpy()是标准流程但tensor.data.cpu().numpy()是埋雷行为尤其在复杂模型中极易引发难以追踪的梯度错误。2.2 计算图一张由Python对象动态编织的DAG计算图Computation Graph是autograd的心脏。它不是一个预先画好的、存储在某个变量里的静态结构而是一张由Python对象_grad_fn在运行时动态编织的、有向无环图DAG。理解它的动态性是debug的终极心法。我们来看一个经典的“梯度清零”误区model torch.nn.Linear(10, 1) x torch.randn(32, 10) y_true torch.randn(32, 1) # 错误示范在每次迭代开始时清零 optimizer.zero_grad() # 清零的是model.parameters()的.grad loss ((model(x) - y_true) ** 2).mean() loss.backward() # 梯度被累加到model.parameters().grad上 # 下一次迭代... optimizer.zero_grad() # 再次清零 loss ((model(x) - y_true) ** 2).mean() loss.backward() # 新的梯度被累加问题出在哪optimizer.zero_grad()清零的是参数的.grad但它并没有销毁上一次backward()生成的计算图。那张图依然存在只是它的“输出端”loss被新的loss覆盖了。backward()的调用是让梯度从当前loss这个“图的出口”沿着所有_grad_fn节点逆向流淌回去。所以zero_grad()不是在清理图而是在清理“梯度容器”。那么计算图什么时候会被销毁答案是当没有任何Python变量引用到图中的任何一个非叶子节点时Python的垃圾回收GC机制就会自动将其回收。loss是一个标量tensor它持有对c._grad_fn的引用c._grad_fn又持有对b._grad_fn的引用以此类推。所以只要你还拿着loss这个变量整张图就坚不可摧。一旦你执行del loss或者让loss超出作用域比如函数返回后GC就会介入图随之烟消云散。这个原理直接指导了我们的实操避免在循环中累积loss不要写total_loss loss.item()因为loss.item()会把tensor的值转成Python float从而切断与图的联系这是安全的。但如果你写total_loss losstotal_loss就会成为一个巨大的、跨多个batch的计算图显存爆炸是必然的。理解retain_graph的代价loss.backward(retain_graphTrue)告诉PyTorch“这次backward完了别销毁图我还要用它再backward一次。”这在GAN训练需要对生成器和判别器分别backward或二阶梯度如MAML元学习中是必需的。但它的代价是图的所有中间节点b,c等的内存都无法被GC回收直到你显式地del loss或loss None。我曾在一个元学习项目中因为忘了del loss导致单个epoch就占用了24GB显存。torch.no_grad()是图的“静音开关”它不是阻止图的生成而是阻止_grad_fn被附加到新tensor上。在no_grad上下文中y model(x)依然会执行y依然是一个tensor但它没有_grad_fn所以后续的y.sum().backward()会直接报错。这是确保推理阶段零开销的黄金法则。2.3 内存管理显存不是无限的但你的策略可以是PyTorch的内存管理是新手和老手都最容易栽跟头的地方。它不像Java有明确的new和delete也不像C需要手动malloc/free。它的规则是隐式的、基于引用计数的。这既是便利也是陷阱。我们来解剖一个最典型的OOMOut of Memory场景# 假设你有一个很大的模型和数据集 for epoch in range(10): for batch in dataloader: x, y batch x, y x.to(cuda), y.to(cuda) # 1. 数据搬上GPU pred model(x) # 2. 前向生成大量中间tensor loss criterion(pred, y) # 3. 计算loss loss.backward() # 4. 反向生成梯度 optimizer.step() # 5. 更新参数 optimizer.zero_grad() # 6. 清零梯度 # 7. 迭代结束x, y, pred, loss 都应该被GC看起来天衣无缝对吧但问题往往出在第7步。如果dataloader返回的batch是一个包含x和y的tuple而你在循环体外定义了一个all_preds []然后在里面写all_preds.append(pred.cpu())那么pred这个GPU tensor就被all_preds这个list牢牢抓住了。GC无法回收它显存只会越积越多直到OOM。这就是PyTorch内存管理的铁律显存的释放不取决于你是否“用完了”而取决于是否还有任何Python变量在“引用”它。因此实操中必须掌握以下技巧torch.cuda.empty_cache()不是万能药它只是把PyTorch缓存的、未被任何tensor引用的显存块归还给GPU驱动。它不能释放那些被Python变量如list、dict引用着的tensor所占用的显存。empty_cache()的正确用法是在你确认所有无用tensor都已被del或超出作用域后再调用它来“打扫卫生”。把它当作gc.collect()的GPU版而不是free()。pin_memoryTrue是CPU到GPU的高速公路在DataLoader中设置pin_memoryTrue会让PyTorch在CPU端分配一块“页锁定内存”pinned memory。这块内存可以直接被GPU的DMA直接内存访问控制器读取无需经过CPU中转速度提升可达2-3倍。但这块内存是宝贵的系统资源不能滥用。一个经验法则是如果你的DataLoader的num_workers 0并且你的batch size较大32那么pin_memoryTrue几乎总是值得的。torch.utils.checkpoint是显存的“时间换空间”术对于超大模型如ViT-Large前向传播会生成海量的中间激活值activations它们是反向传播时计算梯度所必需的但也占用了巨量显存。checkpoint技术允许你只保存部分中间结果而在反向传播需要时再重新计算recompute那些被丢弃的中间值。这牺牲了一点计算时间约20-30%但能节省50%以上的显存。它的使用非常简单from torch.utils.checkpoint import checkpoint def custom_forward(x): return self.layer3(self.layer2(self.layer1(x))) output checkpoint(custom_forward, x) # x是输入output是输出这行代码的意思是“请帮我把custom_forward这个函数的前向计算过程‘检查点’起来。在反向时如果需要layer1或layer2的输出就现场重新跑一遍。”注意checkpoint只能用于纯函数pure function即输入相同输出一定相同且不产生副作用如修改全局变量。任何包含random、time.time()或self.xxx非tensor属性的代码都不能放进checkpoint里否则反向传播会出错。3. 实操过程与核心环节实现从零搭建一个可调试、可复现的训练循环理论讲得再透不如亲手搭一个。本节将摒弃所有高级封装如Trainer、LightningModule用最原始的PyTorch API从零开始构建一个完整、健壮、且最重要的是——可调试的训练循环。这个循环将成为你未来所有项目的坚实地基。3.1 环境准备与可复现性基石种子、设备与日志一切始于一个干净、可控的环境。可复现性Reproducibility不是锦上添花而是科学实验的底线。在PyTorch中它需要同时控制四个随机源Python内置的random模块NumPy的随机数生成器PyTorch CPU的随机数生成器PyTorch CUDA的随机数生成器此外还需要固定DataLoader的worker_init_fn以确保多进程数据加载时每个worker的随机种子也是确定的。import random import numpy as np import torch import torch.backends.cudnn as cudnn def set_seed(seed: int 42) - None: Set all seeds for reproducibility. random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed(seed) torch.cuda.manual_seed_all(seed) # for multi-GPU # 关键一步禁用cudnn的非确定性算法 cudnn.deterministic True cudnn.benchmark False # benchmarkTrue会寻找最优算法但结果不固定 # 在程序最开头调用 set_seed(42)这段代码里的cudnn.benchmark False是很多教程忽略的关键点。cuDNN是一个高度优化的GPU深度学习库benchmarkTrue会让它在第一次运行某个卷积操作时尝试几十种不同的算法实现然后选择最快的那一个。这能提升性能但选出来的“最快算法”在不同GPU、不同驱动版本下可能不同导致结果不可复现。在研究和调试阶段我们必须牺牲这点性能换取100%的确定性。设备管理Device Management同样重要。一个健壮的训练脚本必须能无缝在CPU、单卡GPU、多卡GPU上运行。我们用一个简单的函数来统一处理def get_device() - torch.device: Get the best available device. if torch.cuda.is_available(): return torch.device(cuda) else: return torch.device(cpu) device get_device() print(fUsing device: {device})日志Logging是调试的生命线。我们不用复杂的logging模块而用最朴素的print但要让它结构化、可追溯import datetime def log(msg: str) - None: Simple, timestamped logging. now datetime.datetime.now().strftime(%Y-%m-%d %H:%M:%S) print(f[{now}] {msg}) log(Starting training...) log(fDevice: {device}, Seed: 42)3.2 数据加载超越torchvision.datasets的定制化实践torchvision.datasets.MNIST很好用但它掩盖了数据加载中最核心的抽象Dataset和DataLoader。为了真正理解我们必须亲手写一个。假设我们要处理一个自定义的CSV格式数据集其中一列是图片路径一列是标签。我们来实现一个CustomImageDatasetimport os import pandas as pd from PIL import Image from torch.utils.data import Dataset, DataLoader from torchvision import transforms class CustomImageDataset(Dataset): def __init__(self, csv_file: str, root_dir: str, transformNone): Args: csv_file (str): Path to the CSV file with annotations. root_dir (str): Directory with all the images. transform (callable, optional): Optional transform to be applied on a sample. self.annotations pd.read_csv(csv_file) self.root_dir root_dir self.transform transform def __len__(self) - int: return len(self.annotations) def __getitem__(self, idx: int) - tuple: # 1. 读取路径和标签 img_path os.path.join(self.root_dir, self.annotations.iloc[idx, 0]) label self.annotations.iloc[idx, 1] # 2. 加载图片PIL image Image.open(img_path).convert(RGB) # 确保是3通道 # 3. 应用变换transform if self.transform: image self.transform(image) # 4. 返回 (image, label)PyTorch期望的格式 return image, label # 定义图像预处理流水线 transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), # 自动将PIL Image转为[0,1]范围的tensor并CHW排列 transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) # ImageNet均值标准差 ]) # 创建数据集和数据加载器 dataset CustomImageDataset( csv_filedata/train.csv, root_dirdata/images, transformtransform ) dataloader DataLoader( dataset, batch_size32, shuffleTrue, num_workers4, # 启用4个子进程并行加载 pin_memoryTrue, # 启用页锁定内存 drop_lastTrue # 丢弃最后一个不完整的batch避免size mismatch )这里有几个关键细节__getitem__方法是核心。它定义了“如何获取一个样本”。PyTorch的DataLoader会反复调用它来构建batch。这个方法里绝对不能有耗时的IO操作如打开一个巨大的HDF5文件。最佳实践是在__init__里就把所有路径和标签加载到内存如上面的pd.read_csv__getitem__只做轻量的、确定性的操作读图、变换。transforms.ToTensor()不仅转换类型还做了两件重要的事1) 将像素值从[0,255]缩放到[0,1]2) 将图像从HWC高、宽、通道格式转为CHW通道、高、宽格式这是PyTorch卷积层的输入要求。drop_lastTrue是一个安全选项。当数据集大小不能被batch_size整除时最后一个batch会变小。如果模型里有BatchNorm层小batch可能导致统计量running_mean, running_var更新不稳定进而影响训练。drop_lastTrue直接丢弃它保证每个batch都是满的。3.3 模型构建nn.Module不是容器而是计算图的蓝图torch.nn.Module是PyTorch的模型基石。它远不止是一个“放层的盒子”。它的forward()方法就是计算图的“蓝图”blueprint。每一次forward()的调用都在动态地、实时地根据输入数据的形状和值编织一张独一无二的计算图。我们来构建一个极简但功能完整的CNNimport torch.nn as nn import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self, num_classes: int 10): super().__init__() # 定义网络层这些是Module的“属性”会被自动注册 self.conv1 nn.Conv2d(3, 32, kernel_size3, padding1) self.bn1 nn.BatchNorm2d(32) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.bn2 nn.BatchNorm2d(64) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(64 * 56 * 56, 128) # 假设输入是224x224 self.fc2 nn.Linear(128, num_classes) def forward(self, x: torch.Tensor) - torch.Tensor: # 这里写的每一行都在定义计算图的“边” x self.pool(F.relu(self.bn1(self.conv1(x)))) x self.pool(F.relu(self.bn2(self.conv2(x)))) x torch.flatten(x, 1) # 展平除了batch维度 x F.relu(self.fc1(x)) x self.fc2(x) return x # 实例化模型并移动到设备 model SimpleCNN(num_classes10).to(device)注意forward()方法里的F.relu()。torch.nn.functional里的函数如relu,softmax,cross_entropy是“函数式”的它们不保存状态每次调用都是独立的。而nn.ReLU()是一个Module它是一个对象可以被添加到模型里但在这里我们用F.relu()因为它更轻量且语义更清晰——我们只是想应用一个激活函数而不是在模型里注册一个永久的ReLU层。nn.Module的另一个强大之处在于它的参数管理。当你调用model.parameters()时它会递归地收集所有子Moduleconv1,bn1,fc1等的Parameter对象。Parameter是Tensor的一个子类它被自动标记为requires_gradTrue并且会被optimizer自动识别和更新。这就是为什么你不需要手动指定哪些tensor需要优化nn.Module已经为你做好了所有注册工作。3.4 训练循环一个可调试、可中断、可监控的最小闭环现在把所有零件组装起来打造一个生产级的训练循环。这个循环必须满足三个基本要求1)可调试能随时print中间值2)可中断意外终止后能从断点恢复3)可监控能实时看到loss和accuracy的变化。import torch.optim as optim from torch.nn import CrossEntropyLoss # 1. 初始化 criterion CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) scheduler optim.lr_scheduler.StepLR(optimizer, step_size7, gamma0.1) # 2. 训练主循环 num_epochs 10 best_acc 0.0 for epoch in range(num_epochs): log(fEpoch {epoch1}/{num_epochs}) model.train() # 设置为训练模式启用Dropout/BatchNorm running_loss 0.0 correct 0 total 0 # 3. Batch循环 for batch_idx, (data, target) in enumerate(dataloader): data, target data.to(device), target.to(device) # 4. 前向传播 outputs model(data) loss criterion(outputs, target) # 5. 反向传播 optimizer.zero_grad() # 清零梯度 loss.backward() # 计算梯度 optimizer.step() # 更新参数 # 6. 统计 running_loss loss.item() _, predicted outputs.max(1) total target.size(0) correct predicted.eq(target).sum().item() # 7. 每10个batch打印一次进度 if batch_idx % 10 0: acc 100. * correct / total log(f Batch {batch_idx}/{len(dataloader)}, fLoss: {loss.item():.4f}, Acc: {acc:.2f}%) # 8. Epoch结束计算平均loss和acc epoch_loss running_loss / len(dataloader) epoch_acc 100. * correct / total log(f Epoch {epoch1} completed. fAverage Loss: {epoch_loss:.4f}, Accuracy: {epoch_acc:.2f}%) # 9. 学习率调度 scheduler.step() # 10. 保存最佳模型 if epoch_acc best_acc: best_acc epoch_acc torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), best_acc: best_acc, }, best_model.pth) log(f Best model saved with accuracy: {best_acc:.2f}%) log(Training finished.)这个循环的每一个步骤都对应着一个关键的调试点model.train()和model.eval()这是模式切换的开关。train()启用Dropout随机失活神经元和BatchNorm使用batch的统计量eval()则禁用Dropout并使用BatchNorm在训练时累积的running_mean和running_var。如果你在验证时忘了调用model.eval()Dropout会持续生效导致验证