简介:《动手学深度学习2.0-李沐Pytorch版》是一本面向深度学习初学者与有一定经验研发人员的实践型教材,要求读者具备Python编程基础。全书围绕PyTorch框架展开,从线性代数、微积分、概率与自动微分等预备知识讲起,逐步深入到线性回归、卷积神经网络、循环神经网络与注意力机制等现代深度学习技术,并延伸至计算机视觉、自然语言处理等应用场景,同时涉及监督学习、非监督学习与强化学习的差异。资源包内共1个PDF文件,大小约26.65MB,内容为Release 2.0.0正式版,目录结构完整,涵盖数据操作、数据预处理、线性代数、微积分、自动微分等章节,便于按知识模块系统学习。目前已有778人学习下载。读者可借助书中大量可运行的代码示例,动手理解神经网络结构、训练方法与模型实现流程,并掌握用PyTorch搭建与调试深度学习模型的核心技能,适合作为入门到进阶的案头参考。
1. 动手学深度学习2.0:为什么“跑通”比“看懂”值钱十倍
如果你正在搜《动手学深度学习2.0-李沐Pytorch版》,大概率不是想找一本“讲得深”的教材,而是想找一条能真正把深度学习跑起来的路。我见过太多人卡在同一个地方:公式能看懂,论文能读个大概,但一打开编辑器就不知道从哪下手。这个标题对应的内容,核心价值恰恰不在“讲”,而在“动手”——它把每个概念都配了可运行的 PyTorch 代码,让你在调试器里看张量怎么变、梯度怎么传、损失怎么降。
它适合三类人:刚学完 Python 想进深度学习的新手、从 TensorFlow 转 PyTorch 的熟手、以及需要快速复现经典模型的一线工程师。不适合只想背概念应付考试的人。我自己的血泪经验是:看十遍反向传播公式,不如亲手把loss.backward()跑崩一次,然后搞清楚为什么grad是None。这篇笔记就按“环境怎么搭、代码怎么跑、参数怎么调、坑在哪”的顺序,把这条动手路线拆开讲。
2. 环境搭建与最小可跑闭环:先把第一个张量跑起来
2.1 为什么选 PyTorch 而不是继续用静态图
这个标题里“Pytorch版”四个字是关键。深度学习框架选型直接决定你调试的体验。静态图框架(比如早期 TensorFlow 1.x)需要先定义完整计算图再运行,调试时你看到的是一个黑匣子,报错信息经常指向图构建阶段而不是真正的计算阶段。PyTorch 的动态图机制让计算图在运行时才构建,你可以像写普通 Python 一样逐行执行、逐行打印。
我一般会这样判断:如果你需要频繁改模型结构、看中间变量、做研究型实验,PyTorch 的动态图省下来的调试时间远超它的性能开销。如果你是要部署一个固定结构到生产环境,静态图在推理优化上仍有优势。但学习阶段,动态图是唯一正确的选择——因为你需要“看见”每一步发生了什么。
2.2 用 conda 建一个不污染系统的隔离环境
不要直接在系统 Python 里装 PyTorch。我踩过的坑是:系统里已经有 numpy 1.x,PyTorch 2.x 需要 numpy 2.x,装完之后整个环境崩掉,连 pip 都用不了。正确做法是用 conda 建独立环境。
# 创建名为 d2l 的独立环境,指定 Python 3.10 conda create -n d2l python=3.10 -y # 激活环境 conda activate d2l # 安装 PyTorch(CPU 版本,适合没有独显的机器) # 如果有 NVIDIA 显卡,去 pytorch.org 查对应 CUDA 版本的安装命令 pip install torch torchvision torchaudio # 安装 d2l 包,这是配套代码的辅助库 pip install d2l # 验证安装 python -c "import torch; print(torch.__version__); print(torch.cuda.is_available())"逻辑说明:conda create的-n d2l是环境名,python=3.10是版本约束,-y跳过确认。pip install torch默认装 CPU 版,如果你有显卡,需要去官网查对应 CUDA 版本的命令,不要直接pip install torch然后抱怨cuda.is_available()返回False。最后一行验证会打印版本号和 GPU 是否可用。
参数说明:Python 版本建议 3.9 到 3.11,太新可能没有预编译的 PyTorch 轮子。d2l包是配套的,封装了一些绘图和数据加载函数,不装的话书里的d2l.plot之类会报错。
2.3 跑通第一个线性回归:从数据到梯度的完整链路
环境好了之后,不要急着上 ResNet。先用一个线性回归把“数据→模型→损失→梯度→更新”这条链路走通。下面这段代码可以直接复制运行。
import torch # 1. 构造数据:y = 2x + 1 + 噪声 def synthetic_data(w, b, num_examples): X = torch.normal(0, 1, (num_examples, len(w))) # 标准正态分布 y = X @ w + b # 矩阵乘法 y += torch.normal(0, 0.01, y.shape) # 加噪声 return X, y.reshape((-1, 1)) true_w = torch.tensor([2.0]) true_b = 1.0 features, labels = synthetic_data(true_w, true_b, 1000) # 2. 定义模型和损失 def linreg(X, w, b): return X @ w + b def squared_loss(y_hat, y): return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2 # 3. 初始化参数 w = torch.normal(0, 0.01, size=(1, 1), requires_grad=True) b = torch.zeros(1, requires_grad=True) # 4. 训练循环 lr = 0.03 batch_size = 10 for epoch in range(3): for i in range(0, len(features), batch_size): X_batch = features[i:i+batch_size] y_batch = labels[i:i+batch_size] loss = squared_loss(linreg(X_batch, w, b), y_batch).sum() loss.backward() # 反向传播算梯度 with torch.no_grad(): # 更新时不要记录梯度 w -= lr * w.grad b -= lr * b.grad w.grad.zero_() # 梯度清零,否则会累加 b.grad.zero_() print(f'epoch {epoch+1}, loss {loss.item():.6f}') print(f'估计的 w: {w.detach().numpy().flatten()}, 真实 w: {true_w.numpy()}') print(f'估计的 b: {b.detach().numpy()}, 真实 b: {true_b}')逻辑说明:requires_grad=True告诉 PyTorch 这个张量需要计算梯度。loss.backward()自动求导,梯度存在w.grad和b.grad里。torch.no_grad()包裹更新步骤,因为更新参数本身不需要被记录进计算图。zero_()是必须的,PyTorch 默认会累加梯度,不清零的话第二次迭代的梯度是错的。
参数说明:lr=0.03是学习率,太大震荡,太小收敛慢。batch_size=10是每次喂给模型的样本数。torch.normal(0, 0.01, ...)是参数初始化,标准差 0.01 是常见起点。跑完 3 个 epoch,估计的 w 应该接近 2.0,b 接近 1.0。如果 loss 不降,先检查学习率是不是太大,再检查zero_()有没有漏。
3. 从线性模型到卷积网络:把 LeNet 在 Fashion-MNIST 上跑出 85% 准确率
3.1 数据加载:为什么 num_workers 设 4 而不是 0
数据管道是新手最容易忽略的性能瓶颈。DataLoader的num_workers参数决定用几个进程加载数据。设 0 表示在主进程加载,训练时你会看到 GPU 利用率忽高忽低,因为 GPU 在等数据。设 4 表示开 4 个子进程并行加载,GPU 能吃饱。
import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 定义预处理:转张量 + 归一化 trans = transforms.Compose([ transforms.ToTensor(), # PIL 图像转 [0,1] 张量 transforms.Normalize((0.5,), (0.5,)) # 均值 0.5 标准差 0.5 归一化到 [-1,1] ]) # 加载 Fashion-MNIST train_data = datasets.FashionMNIST( root='./data', train=True, download=True, transform=trans) test_data = datasets.FashionMNIST( root='./data', train=False, download=True, transform=trans) # 创建 DataLoader train_loader = DataLoader(train_data, batch_size=256, shuffle=True, num_workers=4) test_loader = DataLoader(test_data, batch_size=256, shuffle=False, num_workers=4) # 看一个 batch 的形状 X, y = next(iter(train_loader)) print(f'X shape: {X.shape}, y shape: {y.shape}')逻辑说明:transforms.ToTensor()把 PIL 图像转成[C,H,W]格式的浮点张量,值域[0,1]。Normalize((0.5,),(0.5,))做(x-0.5)/0.5,把值域拉到[-1,1],这对训练稳定性有帮助。shuffle=True只在训练集开,测试集不要打乱。num_workers=4在 Linux 上有效,Windows 上如果报错就改成 0。
参数说明:batch_size=256是常见起点,显存不够就降到 128 或 64。num_workers一般设成 CPU 核心数的一半到全部,设太大反而因为进程切换开销变慢。root='./data'是数据存放路径,第一次运行会自动下载。
3.2 LeNet 结构:每个卷积层后面为什么跟池化
LeNet 是卷积网络的“Hello World”。它的结构是:卷积→池化→卷积→池化→全连接→全连接→输出。卷积层负责提取局部特征,池化层负责降采样和增加平移不变性。
import torch.nn as nn net = nn.Sequential( nn.Conv2d(1, 6, kernel_size=5, padding=2), # 输入1通道,输出6通道,5x5卷积 nn.Sigmoid(), # 早期用 Sigmoid,现在多用 ReLU nn.AvgPool2d(kernel_size=2, stride=2), # 2x2 平均池化,步长2 nn.Conv2d(6, 16, kernel_size=5), # 6通道进,16通道出 nn.Sigmoid(), nn.AvgPool2d(kernel_size=2, stride=2), nn.Flatten(), # 展平成一维 nn.Linear(16*5*5, 120), # 全连接 nn.Sigmoid(), nn.Linear(120, 84), nn.Sigmoid(), nn.Linear(84, 10) # 输出10类 ) # 验证输入输出形状 X = torch.randn(1, 1, 28, 28) print(net(X).shape) # 应该是 torch.Size([1, 10])逻辑说明:Conv2d(1,6,5,padding=2)中padding=2是为了让 28x28 经过 5x5 卷积后还是 28x28(28+2*2-5+1=28)。AvgPool2d(2,2)把 28x28 变成 14x14。第二个卷积后 14x14 变 10x10,再池化变 5x5。Flatten后是16*5*5=400维,接全连接。
参数说明:kernel_size=5是 LeNet 原始设计,现代网络多用 3x3。Sigmoid在深层网络会梯度消失,这里因为只有几层还能用,换成ReLU收敛更快。Linear(84,10)输出 10 维对应 10 个类别。
3.3 训练循环:把准确率从 10% 拉到 85% 的关键设置
import torch.optim as optim def evaluate_accuracy(net, data_loader): net.eval() # 切换到评估模式 correct, total = 0, 0 with torch.no_grad(): # 评估时不计算梯度 for X, y in data_loader: y_hat = net(X) correct += (y_hat.argmax(axis=1) == y).sum().item() total += y.numel() return correct / total # 损失函数和优化器 loss_fn = nn.CrossEntropyLoss() # 内部包含 softmax optimizer = optim.SGD(net.parameters(), lr=0.1) # 随机梯度下降 num_epochs = 10 for epoch in range(num_epochs): net.train() # 切换回训练模式 for X, y in train_loader: y_hat = net(X) loss = loss_fn(y_hat, y) optimizer.zero_grad() # 清梯度 loss.backward() # 反向传播 optimizer.step() # 更新参数 test_acc = evaluate_accuracy(net, test_loader) print(f'epoch {epoch+1}, test acc {test_acc:.4f}')逻辑说明:net.train()和net.eval()切换模式,影响 Dropout 和 BatchNorm 的行为。CrossEntropyLoss内部做了 softmax,所以网络最后一层不要加 softmax。optimizer.zero_grad()清空上一轮梯度,step()执行更新。argmax(axis=1)取预测类别。
参数说明:lr=0.1对 SGD 是常见起点,太大不收敛,太小收敛慢。num_epochs=10在 Fashion-MNIST 上足够到 85% 左右。如果准确率卡在 10% 不动,检查CrossEntropyLoss的输入是不是 logits(没经过 softmax),以及标签是不是[0,9]的整数。
4. 避坑与排查:那些让训练崩掉的参数和写法
4.1 现象:loss 变成 nan,训练直接崩
原因:学习率太大,梯度爆炸。或者数据里有 nan,或者log(0)之类的数学运算。解决:先把学习率降 10 倍试。然后在loss.backward()后加torch.nn.utils.clip_grad_norm_(net.parameters(), 1.0)做梯度裁剪。再检查数据管道,用torch.isnan(X).any()确认输入没有 nan。
4.2 现象:GPU 利用率只有 20%,训练很慢
原因:数据加载是瓶颈,num_workers=0导致 GPU 等数据。或者 batch_size 太小,每次计算量不够。解决:把num_workers设成 4 或 8,batch_size加大到显存允许的上限。用nvidia-smi看 GPU 利用率,如果一直在 0% 和 100% 之间跳,就是数据管道问题。
4.3 现象:训练准确率很高,测试准确率很低
原因:过拟合。模型记住了训练集但没学到泛化特征。解决:加 Dropout 层、加 L2 正则化(optimizer的weight_decay参数)、做数据增强(随机翻转、裁剪)、或者直接减小模型容量。我一般先加weight_decay=1e-4试,不行再上 Dropout。
4.4 现象:RuntimeError: expected scalar type Float but found Double
原因:PyTorch 默认张量是 float32,但 numpy 默认是 float64。从 numpy 转过来的张量是 double,和模型参数类型不匹配。解决:用.float()显式转换,或者创建张量时指定dtype=torch.float32。这个坑在把 sklearn 数据喂给 PyTorch 时特别常见。
4.5 现象:显存够但报CUDA out of memory
原因:不是显存不够,是碎片化。或者你在循环里不断创建新张量而没有释放。解决:用torch.cuda.empty_cache()清缓存。更根本的是检查有没有在训练循环里累积计算图——比如把loss存进列表而不是.item(),这样整个计算图都被保留,显存会爆。记住:只需要数值就.item(),不要保留张量引用。
5. 进阶技巧:用 torch.utils.data 自定义数据集和混合精度训练
5.1 自定义 Dataset:把任意格式数据喂进 DataLoader
书里的数据集是封装好的,但实际工作中你面对的是 CSV、图片文件夹、HDF5。torch.utils.data.Dataset是标准接口,只需要实现__len__和__getitem__。
from torch.utils.data import Dataset import pandas as pd from PIL import Image class CustomImageDataset(Dataset): def __init__(self, csv_file, img_dir, transform=None): self.labels = pd.read_csv(csv_file) # CSV 有两列:filename, label self.img_dir = img_dir self.transform = transform def __len__(self): return len(self.labels) # 返回样本总数 def __getitem__(self, idx): img_path = f'{self.img_dir}/{self.labels.iloc[idx, 0]}' image = Image.open(img_path).convert('RGB') label = self.labels.iloc[idx, 1] if self.transform: image = self.transform(image) return image, label # 使用 dataset = CustomImageDataset('labels.csv', './images', transform=trans) loader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=4)逻辑说明:__len__返回数据集大小,DataLoader用它决定采样范围。__getitem__接收索引返回(图像张量, 标签)。transform在__getitem__里应用,这样每个样本被加载时才做增强,而不是预先全部处理。
参数说明:convert('RGB')确保三通道,即使原图是灰度。iloc[idx, 0]是 pandas 的按位置索引,比loc更适合这里。如果数据量大,__getitem__里不要做耗时操作,否则num_workers也救不了。
5.2 混合精度训练:用 AMP 把显存砍半、速度提三成
混合精度(Automatic Mixed Precision)让部分计算用 float16,部分用 float32。float16 省显存、计算快,但容易溢出。PyTorch 的torch.cuda.amp自动处理缩放,你只需要改几行。
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() # 梯度缩放器 for epoch in range(num_epochs): net.train() for X, y in train_loader: optimizer.zero_grad() with autocast(): # 自动选择精度 y_hat = net(X) loss = loss_fn(y_hat, y) scaler.scale(loss).backward() # 缩放损失再反向传播 scaler.step(optimizer) # 自动判断是否跳过更新 scaler.update() # 更新缩放因子逻辑说明:autocast()上下文里的运算自动选择 float16 或 float32。scaler.scale(loss)把损失放大,防止 float16 下梯度下溢。scaler.step()代替optimizer.step(),如果检测到 inf/nan 就跳过这一步。scaler.update()调整缩放因子。
参数说明:AMP 只在 NVIDIA GPU 上有效,CPU 上autocast是空操作。GradScaler的默认初始缩放因子是 65536,一般不用改。如果训练不稳定,可以试GradScaler(init_scale=1024)降低初始值。这个技巧在显存紧张时特别有用,我实测能把 batch_size 翻倍。
5.3 用 TensorBoard 看训练曲线:别靠猜
训练时不要只盯着 loss 数字。用 TensorBoard 把 loss、准确率、学习率画出来,一眼就能看出是过拟合还是欠拟合。
pip install tensorboardfrom torch.utils.tensorboard import SummaryWriter writer = SummaryWriter('runs/lenet_experiment') # 日志目录 for epoch in range(num_epochs): # ... 训练代码 ... writer.add_scalar('Loss/train', loss.item(), epoch) writer.add_scalar('Accuracy/test', test_acc, epoch) writer.add_scalar('Learning_rate', optimizer.param_groups[0]['lr'], epoch) writer.close()然后在终端运行tensorboard --logdir=runs,浏览器打开提示的地址。逻辑说明:add_scalar记录标量,第一个参数是标签,第二个是值,第三个是步数。param_groups[0]['lr']取当前学习率,如果你用了学习率调度器,这条曲线能告诉你调度有没有生效。
参数说明:runs/lenet_experiment是日志目录,不同实验用不同子目录,方便对比。writer.close()必须调用,否则最后几条日志可能没写进去。我习惯在每个 epoch 结束后记录一次,不要每步都记,否则日志文件会很大。
5.4 一个我反复用的习惯:先跑 10 个样本过拟合
在正式训练之前,我一般会拿 10 个样本跑 100 个 epoch,看模型能不能把这 10 个样本的 loss 降到接近 0。如果降不下去,说明模型结构或训练逻辑有 bug,不用浪费时间去跑全量数据。这个“过拟合小样本”的检查能提前暴露 80% 的低级错误,比如标签对不上、损失函数用错、梯度没回传。希望帮到你。
本文还有配套的精品资源,点击获取