PyTorch 深度学习(GPT 重译)(三)(3)https://developer.aliyun.com/article/1485215
8.3.2 PyTorch 如何跟踪参数和子模块
有趣的是,在nn.Module
中的属性中分配一个nn.Module
实例,就像我们在早期的构造函数中所做的那样,会自动将模块注册为子模块。
注意 子模块必须是顶级属性,而不是嵌套在list
或dict
实例中!否则优化器将无法定位子模块(因此也无法定位它们的参数)。对于需要子模块列表或字典的模型情况,PyTorch 提供了nn.ModuleList
和nn.ModuleDict
。
我们可以调用nn.Module
子类的任意方法。例如,对于一个模型,训练与预测等使用方式明显不同的情况下,可能有一个predict
方法是有意义的。请注意,调用这些方法将类似于调用forward
而不是模块本身–它们将忽略钩子,并且 JIT 在使用它们时不会看到模块结构,因为我们缺少第 6.2.1 节中显示的__call__
位的等价物。
这使得Net
可以访问其子模块的参数,而无需用户进一步操作:
# In[27]: model = Net() numel_list = [p.numel() for p in model.parameters()] sum(numel_list), numel_list # Out[27]: (18090, [432, 16, 1152, 8, 16384, 32, 64, 2])
这里发生的情况是,parameters()
调用深入到构造函数中分配为属性的所有子模块,并递归调用它们的parameters()
。无论子模块嵌套多深,任何nn.Module
都可以访问所有子参数的列表。通过访问它们的grad
属性,该属性已被autograd
填充,优化器将知道如何更改参数以最小化损失。我们从第五章中了解到这个故事。
现在我们知道如何实现我们自己的模块了–这在第 2 部分中我们将需要很多。回顾Net
类的实现,并考虑在构造函数中注册子模块的实用性,以便我们可以访问它们的参数,看起来有点浪费,因为我们还注册了没有参数的子模块,如nn.Tanh
和nn.MaxPool2d
。直接在forward
函数中调用这些是否更容易,就像我们调用view
一样?
8.3.3 功能 API
当然会!这就是为什么 PyTorch 为每个nn
模块都提供了functional对应项。这里所说的“functional”是指“没有内部状态”–换句话说,“其输出值完全由输入参数的值决定”。实际上,torch.nn.functional
提供了许多像我们在nn
中找到的模块一样工作的函数。但是,与模块对应项不同,它们不会像模块对应项那样在输入参数和存储参数上工作,而是将输入和参数作为函数调用的参数。例如,nn.Linear
的功能对应项是nn.functional.linear
,它是一个具有签名linear(input, weight, bias=None)
的函数。weight
和bias
参数是函数调用的参数。
回到我们的模型,继续使用nn.Linear
和nn.Conv2d
的nn
模块是有意义的,这样Net
在训练期间将能够管理它们的Parameter
。但是,我们可以安全地切换到池化和激活的功能对应项,因为它们没有参数:
# In[28]: import torch.nn.functional as F class Net(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(16, 8, kernel_size=3, padding=1) self.fc1 = nn.Linear(8 * 8 * 8, 32) self.fc2 = nn.Linear(32, 2) def forward(self, x): out = F.max_pool2d(torch.tanh(self.conv1(x)), 2) out = F.max_pool2d(torch.tanh(self.conv2(out)), 2) out = out.view(-1, 8 * 8 * 8) out = torch.tanh(self.fc1(out)) out = self.fc2(out) return out
这比我们在第 8.3.1 节中之前定义的Net
的定义要简洁得多,完全等效。请注意,在构造函数中实例化需要多个参数进行初始化的模块仍然是有意义的。
提示 虽然通用科学函数如tanh
仍然存在于版本 1.0 的torch.nn.functional
中,但这些入口点已被弃用,而是推荐使用顶级torch
命名空间中的函数。像max_pool2d
这样的更专业的函数将保留在torch.nn.functional
中。
因此,功能方式也揭示了nn.Module
API 的含义:Module
是一个状态的容器,其中包含Parameter
和子模块,以及执行前向操作的指令。
使用功能 API 还是模块化 API 是基于风格和品味的决定。当网络的一部分如此简单以至于我们想使用nn.Sequential
时,我们处于模块化领域。当我们编写自己的前向传播时,对于不需要参数形式状态的事物,使用功能接口可能更自然。
在第十五章,我们将简要涉及量化。然后像激活这样的无状态位突然变得有状态,因为需要捕获有关量化的信息。这意味着如果我们打算量化我们的模型,如果我们选择非 JIT 量化,坚持使用模块化 API 可能是值得的。有一个风格问题将帮助您避免(最初未预料到的)用途带来的意外:如果您需要多个无状态模块的应用(如nn.HardTanh
或nn.ReLU
),最好为每个模块实例化一个单独的实例。重用相同的模块似乎很聪明,并且在这里使用标准 Python 时会给出正确的结果,但是分析您的模型的工具可能会出错。
现在我们可以自己制作nn.Module
,并且在需要时还有功能 API 可用,当实例化然后调用nn.Module
过于繁琐时。这是了解在 PyTorch 中实现的几乎任何神经网络的代码组织方式的最后一部分。
让我们再次检查我们的模型是否运行正常,然后我们将进入训练循环:
# In[29]: model = Net() model(img.unsqueeze(0)) # Out[29]: tensor([[-0.0157, 0.1143]], grad_fn=<AddmmBackward>)
我们得到了两个数字!信息正确传递。我们现在可能意识不到,但在更复杂的模型中,正确设置第一个线性层的大小有时会导致挫折。我们听说过一些著名从业者输入任意数字,然后依靠 PyTorch 的错误消息来回溯线性层的正确大小。很烦人,对吧?不,这都是合法的!
8.4 训练我们的卷积网络
现在我们已经到了组装完整训练循环的时候。我们在第五章中已经开发了整体结构,训练循环看起来很像第六章的循环,但在这里我们将重新审视它以添加一些细节,如一些用于准确性跟踪的内容。在运行我们的模型之后,我们还会对更快速度有所期待,因此我们将学习如何在 GPU 上快速运行我们的模型。但首先让我们看看训练循环。
请记住,我们的卷积网络的核心是两个嵌套循环:一个是epochs上的外部循环,另一个是从我们的Dataset
生成批次的DataLoader
上的内部循环。在每个循环中,我们需要
- 通过模型传递输入(前向传播)。
- 计算损失(也是前向传播的一部分)。
- 将任何旧的梯度清零。
- 调用
loss.backward()
来计算损失相对于所有参数的梯度(反向传播)。 - 使优化器朝着更低的损失方向迈出一步。
同时,我们收集并打印一些信息。所以这是我们的训练循环,看起来几乎与上一章相同–但记住每个事物的作用是很重要的:
# In[30]: import datetime # ❶ def training_loop(n_epochs, optimizer, model, loss_fn, train_loader): for epoch in range(1, n_epochs + 1): # ❷ loss_train = 0.0 for imgs, labels in train_loader: # ❸ outputs = model(imgs) # ❹ loss = loss_fn(outputs, labels) # ❺ optimizer.zero_grad() # ❻ loss.backward() # ❼ optimizer.step() # ❽ loss_train += loss.item() # ❾ if epoch == 1 or epoch % 10 == 0: print('{} Epoch {}, Training loss {}'.format( datetime.datetime.now(), epoch, loss_train / len(train_loader))) # ❿
❶ 使用 Python 内置的 datetime 模块
❷ 我们在从 1 到 n_epochs 编号的 epochs 上循环,而不是从 0 开始
❸ 在数据加载器为我们创建的批次中循环遍历我们的数据集
❹ 通过我们的模型传递一个批次…
❺ … 并计算我们希望最小化的损失
❻ 在摆脱上一轮梯度之后…
❼ … 执行反向步骤。也就是说,我们计算我们希望网络学习的所有参数的梯度。
❽ 更新模型
❾ 对我们在 epoch 中看到的损失求和。请记住,将损失转换为 Python 数字并使用.item()
是很重要的,以避免梯度。
❿ 除以训练数据加载器的长度以获得每批的平均损失。这比总和更直观。
我们使用第七章的Dataset
;将其包装成DataLoader
;像以前一样实例化我们的网络、优化器和损失函数;然后调用我们的训练循环。
与上一章相比,我们模型的重大变化是现在我们的模型是 nn.Module
的自定义子类,并且我们正在使用卷积。让我们在打印损失的同时运行 100 个周期的训练。根据您的硬件,这可能需要 20 分钟或更长时间才能完成!
# In[31]: train_loader = torch.utils.data.DataLoader(cifar2, batch_size=64, shuffle=True) # ❶ model = Net() # # ❷ optimizer = optim.SGD(model.parameters(), lr=1e-2) # # ❸ loss_fn = nn.CrossEntropyLoss() # # ❹ training_loop( # ❺ n_epochs = 100, optimizer = optimizer, model = model, loss_fn = loss_fn, train_loader = train_loader, ) # Out[31]: 2020-01-16 23:07:21.889707 Epoch 1, Training loss 0.5634813266954605 2020-01-16 23:07:37.560610 Epoch 10, Training loss 0.3277610331109375 2020-01-16 23:07:54.966180 Epoch 20, Training loss 0.3035225479086493 2020-01-16 23:08:12.361597 Epoch 30, Training loss 0.28249378549824855 2020-01-16 23:08:29.769820 Epoch 40, Training loss 0.2611226033253275 2020-01-16 23:08:47.185401 Epoch 50, Training loss 0.24105800626574048 2020-01-16 23:09:04.644522 Epoch 60, Training loss 0.21997178820477928 2020-01-16 23:09:22.079625 Epoch 70, Training loss 0.20370126601047578 2020-01-16 23:09:39.593780 Epoch 80, Training loss 0.18939699422401987 2020-01-16 23:09:57.111441 Epoch 90, Training loss 0.17283396527266046 2020-01-16 23:10:14.632351 Epoch 100, Training loss 0.1614033816868712
❶ DataLoader 对我们的 cifar2 数据集的示例进行批处理。Shuffling 使数据集中示例的顺序随机化。
❷ 实例化我们的网络 …
❸ … 我们一直在使用的随机梯度下降优化器 …
❹ … 以及我们在第 7.10 节中遇到的交叉熵损失
❺ 调用我们之前定义的训练循环
现在我们可以训练我们的网络了。但是,我们的鸟类观察者朋友在告诉她我们训练到非常低的训练损失时可能不会感到满意。
8.4.1 测量准确性
为了得到比损失更具可解释性的度量,我们可以查看训练和验证数据集上的准确率。我们使用了与第七章相同的代码:
# In[32]: train_loader = torch.utils.data.DataLoader(cifar2, batch_size=64, shuffle=False) val_loader = torch.utils.data.DataLoader(cifar2_val, batch_size=64, shuffle=False) def validate(model, train_loader, val_loader): for name, loader in [("train", train_loader), ("val", val_loader)]: correct = 0 total = 0 with torch.no_grad(): # ❶ for imgs, labels in loader: outputs = model(imgs) _, predicted = torch.max(outputs, dim=1) # ❷ total += labels.shape[0] # ❸ correct += int((predicted == labels).sum()) # ❹ print("Accuracy {}: {:.2f}".format(name , correct / total)) validate(model, train_loader, val_loader) # Out[32]: Accuracy train: 0.93 Accuracy val: 0.89
❶ 我们这里不需要梯度,因为我们不想更新参数。
❷ 将最高值的索引作为输出给出
❸ 计算示例的数量,因此总数增加了批次大小
❹ 比较具有最大概率的预测类和地面真实标签,我们首先得到一个布尔数组。求和得到批次中预测和地面真实一致的项目数。
我们将转换为 Python 的 int
–对于整数张量,这等同于使用 .item()
,类似于我们在训练循环中所做的。
这比全连接模型要好得多,全连接模型只能达到 79%的准确率。我们在验证集上的错误数量几乎减半。而且,我们使用的参数要少得多。这告诉我们,模型在通过局部性和平移不变性从新样本中识别图像主题的任务中更好地泛化。现在我们可以让它运行更多周期,看看我们能够挤出什么性能。
8.4.2 保存和加载我们的模型
由于我们目前对我们的模型感到满意,所以实际上保存它会很好,对吧?这很容易做到。让我们将模型保存到一个文件中:
# In[33]: torch.save(model.state_dict(), data_path + 'birds_vs_airplanes.pt')
birds_vs_airplanes.pt 文件现在包含了 model
的所有参数:即两个卷积模块和两个线性模块的权重和偏置。因此,没有结构–只有权重。这意味着当我们为我们的朋友在生产中部署模型时,我们需要保持 model
类方便,创建一个实例,然后将参数加载回去:
# In[34]: loaded_model = Net() # ❶ loaded_model.load_state_dict(torch.load(data_path + 'birds_vs_airplanes.pt')) # Out[34]: <All keys matched successfully>
❶ 我们必须确保在保存和后续加载模型状态之间不更改 Net 的定义。
我们还在我们的代码库中包含了一个预训练模型,保存在 …/data/ p1ch7/birds_vs_airplanes.pt 中。
8.4.3 在 GPU 上训练
我们有一个网络并且可以训练它!但是让它变得更快会很好。到现在为止,我们通过将训练移至 GPU 来实现这一点并不奇怪。使用我们在第三章中看到的 .to
方法,我们可以将从数据加载器获取的张量移动到 GPU,之后我们的计算将自动在那里进行。但是我们还需要将参数移动到 GPU。令人高兴的是,nn.Module
实现了一个 .to
函数,将其所有参数移动到 GPU(或在传递 dtype
参数时转换类型)。
Module.to
和 Tensor.to
之间有一些微妙的区别。Module.to
是就地操作:模块实例被修改。但 Tensor.to
是非就地操作(在某种程度上是计算,就像 Tensor.tanh
一样),返回一个新的张量。一个影响是在将参数移动到适当设备后创建 Optimizer
是一个良好的实践。
如果有 GPU 可用,将事物移动到 GPU 被认为是一种良好的风格。一个好的模式是根据 torch.cuda.is_available
设置一个变量 device
:
# In[35]: device = (torch.device('cuda') if torch.cuda.is_available() else torch.device('cpu')) print(f"Training on device {device}.")
然后我们可以通过使用Tensor.to
方法将从数据加载器获取的张量移动到 GPU 来修改训练循环。请注意,代码与本节开头的第一个版本完全相同,除了将输入移动到 GPU 的两行代码:
# In[36]: import datetime def training_loop(n_epochs, optimizer, model, loss_fn, train_loader): for epoch in range(1, n_epochs + 1): loss_train = 0.0 for imgs, labels in train_loader: imgs = imgs.to(device=device) # ❶ labels = labels.to(device=device) outputs = model(imgs) loss = loss_fn(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() loss_train += loss.item() if epoch == 1 or epoch % 10 == 0: print('{} Epoch {}, Training loss {}'.format( datetime.datetime.now(), epoch, loss_train / len(train_loader)))
❶ 将图像和标签移动到我们正在训练的设备上的这两行是与我们之前版本的唯一区别。
对validate
函数必须做出相同的修正。然后我们可以实例化我们的模型,将其移动到device
,并像以前一样运行它:⁸
# In[37]: train_loader = torch.utils.data.DataLoader(cifar2, batch_size=64, shuffle=True) model = Net().to(device=device) # ❶ optimizer = optim.SGD(model.parameters(), lr=1e-2) loss_fn = nn.CrossEntropyLoss() training_loop( n_epochs = 100, optimizer = optimizer, model = model, loss_fn = loss_fn, train_loader = train_loader, ) # Out[37]: 2020-01-16 23:10:35.563216 Epoch 1, Training loss 0.5717791349265227 2020-01-16 23:10:39.730262 Epoch 10, Training loss 0.3285350770137872 2020-01-16 23:10:45.906321 Epoch 20, Training loss 0.29493294959994637 2020-01-16 23:10:52.086905 Epoch 30, Training loss 0.26962305994550134 2020-01-16 23:10:56.551582 Epoch 40, Training loss 0.24709946277794564 2020-01-16 23:11:00.991432 Epoch 50, Training loss 0.22623272664892446 2020-01-16 23:11:05.421524 Epoch 60, Training loss 0.20996672821462534 2020-01-16 23:11:09.951312 Epoch 70, Training loss 0.1934866009719053 2020-01-16 23:11:14.499484 Epoch 80, Training loss 0.1799132404908253 2020-01-16 23:11:19.047609 Epoch 90, Training loss 0.16620008706761774 2020-01-16 23:11:23.590435 Epoch 100, Training loss 0.15667157247662544
❶ 将我们的模型(所有参数)移动到 GPU。如果忘记将模型或输入移动到 GPU,将会出现关于张量不在同一设备上的错误,因为 PyTorch 运算符不支持混合 GPU 和 CPU 输入。
即使对于我们这里的小型网络,我们也看到了速度的显著增加。在大型模型上,使用 GPU 进行计算的优势更加明显。
在加载网络权重时存在一个小复杂性:PyTorch 将尝试将权重加载到与保存时相同的设备上–也就是说,GPU 上的权重将被恢复到 GPU 上。由于我们不知道是否要相同的设备,我们有两个选择:我们可以在保存之前将网络移动到 CPU,或者在恢复后将其移回。通过将map_location
关键字参数传递给torch.load
,更简洁地指示 PyTorch 在加载权重时覆盖设备信息:
# In[39]: loaded_model = Net().to(device=device) loaded_model.load_state_dict(torch.load(data_path + 'birds_vs_airplanes.pt', map_location=device)) # Out[39]: <All keys matched successfully>
8.5 模型设计
我们将我们的模型构建为nn.Module
的子类,这是除了最简单的模型之外的事实标准。然后我们成功地训练了它,并看到了如何使用 GPU 来训练我们的模型。我们已经达到了可以构建一个前馈卷积神经网络并成功训练它来对图像进行分类的程度。自然的问题是,接下来呢?如果我们面对一个更加复杂的问题会怎么样?诚然,我们的鸟类与飞机数据集并不那么复杂:图像非常小,而且所研究的对象位于中心并占据了大部分视口。
如果我们转向,比如说,ImageNet,我们会发现更大、更复杂的图像,正确答案将取决于多个视觉线索,通常是按层次组织的。例如,当试图预测一个黑色砖块形状是遥控器还是手机时,网络可能正在寻找类似屏幕的东西。
此外,在现实世界中,图像可能不是我们唯一关注的焦点,我们还有表格数据、序列和文本。神经网络的承诺在于提供足够的灵活性,以解决所有这些类型数据的问题,只要有适当的架构(即层或模块的互连)和适当的损失函数。
PyTorch 提供了一个非常全面的模块和损失函数集合,用于实现从前馈组件到长短期记忆(LSTM)模块和变压器网络(这两种非常流行的顺序数据架构)的最新架构。通过 PyTorch Hub 或作为torchvision
和其他垂直社区努力的一部分提供了几种模型。
我们将在第 2 部分看到一些更高级的架构,我们将通过分析 CT 扫描的端到端问题来介绍,但总的来说,探讨神经网络架构的变化超出了本书的范围。然而,我们可以借助迄今为止积累的知识来理解如何通过 PyTorch 的表现力实现几乎任何架构。本节的目的正是提供概念工具,使我们能够阅读最新的研究论文并开始在 PyTorch 中实现它–或者,由于作者经常发布他们论文的 PyTorch 实现,也可以在不被咖啡呛到的情况下阅读实现。
8.5.1 添加内存容量:宽度
鉴于我们的前馈架构,在进一步复杂化之前,我们可能想要探索一些维度。第一个维度是网络的宽度:每层的神经元数量,或者每个卷积的通道数。在 PyTorch 中,我们可以很容易地使模型更宽。我们只需在第一个卷积中指定更多的输出通道数,并相应增加后续层,同时要注意更改forward
函数以反映这样一个事实,即一旦我们转换到全连接层,我们现在将有一个更长的向量:
# In[40]: class NetWidth(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(32, 16, kernel_size=3, padding=1) self.fc1 = nn.Linear(16 * 8 * 8, 32) self.fc2 = nn.Linear(32, 2) def forward(self, x): out = F.max_pool2d(torch.tanh(self.conv1(x)), 2) out = F.max_pool2d(torch.tanh(self.conv2(out)), 2) out = out.view(-1, 16 * 8 * 8) out = torch.tanh(self.fc1(out)) out = self.fc2(out) return out
如果我们想避免在模型定义中硬编码数字,我们可以很容易地将一个参数传递给init,并将宽度参数化,同时要注意在forward
函数中也将view
的调用参数化:
# In[42]: class NetWidth(nn.Module): def __init__(self, n_chans1=32): super().__init__() self.n_chans1 = n_chans1 self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3, padding=1) self.fc1 = nn.Linear(8 * 8 * n_chans1 // 2, 32) self.fc2 = nn.Linear(32, 2) def forward(self, x): out = F.max_pool2d(torch.tanh(self.conv1(x)), 2) out = F.max_pool2d(torch.tanh(self.conv2(out)), 2) out = out.view(-1, 8 * 8 * self.n_chans1 // 2) out = torch.tanh(self.fc1(out)) out = self.fc2(out) return out
每一层指定通道和特征的数字与模型中的参数数量直接相关;其他条件相同的情况下,它们会增加模型的容量。就像之前所做的那样,我们可以看看我们的模型现在有多少参数:
# In[44]: sum(p.numel() for p in model.parameters()) # Out[44]: 38386
容量越大,模型将能够处理输入的变化性就越多;但与此同时,过拟合的可能性也越大,因为模型可以使用更多的参数来记忆输入的不重要方面。我们已经探讨了对抗过拟合的方法,最好的方法是增加样本量,或者在没有新数据的情况下,通过对同一数据进行人工修改来增加现有数据。
在模型级别(而不是在数据上)我们可以采取一些更多的技巧来控制过拟合。让我们回顾一下最常见的几种。
8.5.2 帮助我们的模型收敛和泛化:正则化
训练模型涉及两个关键步骤:优化,当我们需要在训练集上减少损失时;和泛化,当模型不仅需要在训练集上工作,还需要在之前未见过的数据上工作,如验证集。旨在简化这两个步骤的数学工具有时被归纳为正则化的标签下。
控制参数:权重惩罚
稳定泛化的第一种方法是向损失中添加正则化项。这个项被设计成使模型的权重自行趋向于较小,限制训练使它们增长的程度。换句话说,这是对较大权重值的惩罚。这使得损失具有更加平滑的拓扑结构,从拟合单个样本中获得的收益相对较少。
这种类型的最受欢迎的正则化项是 L2 正则化,它是模型中所有权重的平方和,以及 L1 正则化,它是模型中所有权重的绝对值之和。它们都由一个(小)因子缩放,这是我们在训练之前设置的超参数。
L2 正则化也被称为权重衰减。这个名称的原因是,考虑到 SGD 和反向传播,L2 正则化项对参数w_i
的负梯度为- 2 * lambda * w_i
,其中lambda
是前面提到的超参数,在 PyTorch 中简称为权重衰减。因此,将 L2 正则化添加到损失函数中等同于在优化步骤中减少每个权重的数量与其当前值成比例的量(因此,称为权重衰减)。请注意,权重衰减适用于网络的所有参数,如偏置。
在 PyTorch 中,我们可以通过向损失中添加一个项来很容易地实现正则化。在计算损失后,无论损失函数是什么,我们都可以迭代模型的参数,对它们各自的平方(对于 L2)或abs
(对于 L1)求和,并进行反向传播:
# In[45]: def training_loop_l2reg(n_epochs, optimizer, model, loss_fn, train_loader): for epoch in range(1, n_epochs + 1): loss_train = 0.0 for imgs, labels in train_loader: imgs = imgs.to(device=device) labels = labels.to(device=device) outputs = model(imgs) loss = loss_fn(outputs, labels) l2_lambda = 0.001 l2_norm = sum(p.pow(2.0).sum() for p in model.parameters()) # ❶ loss = loss + l2_lambda * l2_norm optimizer.zero_grad() loss.backward() optimizer.step() loss_train += loss.item() if epoch == 1 or epoch % 10 == 0: print('{} Epoch {}, Training loss {}'.format( datetime.datetime.now(), epoch, loss_train / len(train_loader)))
❶ 用 abs()替换 pow(2.0)以进行 L1 正则化
然而,PyTorch 中的 SGD 优化器已经有一个weight_decay
参数,对应于2 * lambda
,并且在更新过程中直接执行权重衰减,如前所述。这完全等同于将权重的 L2 范数添加到损失中,而无需在损失中累积项并涉及 autograd。
不要过分依赖单个输入:Dropout
一种有效的对抗过拟合策略最初是由 2014 年多伦多 Geoff Hinton 小组的 Nitish Srivastava 及其合著者提出的,题为“Dropout:一种简单防止神经网络过拟合的方法”(mng.bz/nPMa
)。听起来就像是我们正在寻找的东西,对吧?dropout 背后的想法确实很简单:在整个网络中随机将一部分神经元的输出置零,其中随机化发生在每个训练迭代中。
该过程有效地在每次迭代中生成具有不同神经元拓扑的略有不同的模型,使模型中的神经元在发生过拟合时的记忆过程中有更少的协调机会。另一个观点是 dropout 扰乱了模型生成的特征,产生了一种接近增强的效果,但这次是在整个网络中。
在 PyTorch 中,我们可以通过在非线性激活函数和后续层的线性或卷积模块之间添加一个nn.Dropout
模块来实现模型中的 dropout。作为参数,我们需要指定输入被置零的概率。在卷积的情况下,我们将使用专门的nn.Dropout2d
或nn.Dropout3d
,它们会将输入的整个通道置零:
# In[47]: class NetDropout(nn.Module): def __init__(self, n_chans1=32): super().__init__() self.n_chans1 = n_chans1 self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1) self.conv1_dropout = nn.Dropout2d(p=0.4) self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3, padding=1) self.conv2_dropout = nn.Dropout2d(p=0.4) self.fc1 = nn.Linear(8 * 8 * n_chans1 // 2, 32) self.fc2 = nn.Linear(32, 2) def forward(self, x): out = F.max_pool2d(torch.tanh(self.conv1(x)), 2) out = self.conv1_dropout(out) out = F.max_pool2d(torch.tanh(self.conv2(out)), 2) out = self.conv2_dropout(out) out = out.view(-1, 8 * 8 * self.n_chans1 // 2) out = torch.tanh(self.fc1(out)) out = self.fc2(out) return out
注意,在训练期间通常会激活 dropout,而在生产中评估经过训练的模型时,会绕过 dropout,或者等效地将概率分配为零。这通过Dropout
模块的train
属性来控制。请记住,PyTorch 允许我们通过调用在两种模式之间切换
model.train()
或
model.eval()
在任何nn.Model
子类上。调用将自动复制到子模块,因此如果其中包含Dropout
,它将在后续的前向和后向传递中相应地行为。
保持激活在适当范围内:批量归一化
在 2015 年,谷歌的 Sergey Ioffe 和 Christian Szegedy 发表了另一篇具有开创性意义的论文,名为“批量归一化:通过减少内部协变量转移加速深度网络训练”(arxiv.org/abs/1502.03167
)。该论文描述了一种对训练有多种有益影响的技术:使我们能够增加学习率,使训练不那么依赖初始化并充当正则化器,从而代替了 dropout。
批量归一化背后的主要思想是重新缩放网络的激活输入,以便小批量具有某种理想的分布。回顾学习的机制和非线性激活函数的作用,这有助于避免输入到激活函数过于饱和部分,从而杀死梯度并减慢训练速度。
在实际操作中,批量归一化使用小批量样本中在该中间位置收集的均值和标准差来移位和缩放中间输入。正则化效果是因为模型始终将单个样本及其下游激活视为根据随机提取的小批量样本的统计数据而移位和缩放。这本身就是一种原则性的增强。论文的作者建议使用批量归一化消除或至少减轻了对 dropout 的需求。
在 PyTorch 中,批量归一化通过nn.BatchNorm1D
、nn.BatchNorm2d
和nn.BatchNorm3d
模块提供,取决于输入的维度。由于批量归一化的目的是重新缩放激活的输入,自然的位置是在线性变换(在这种情况下是卷积)和激活之后,如下所示:
# In[49]: class NetBatchNorm(nn.Module): def __init__(self, n_chans1=32): super().__init__() self.n_chans1 = n_chans1 self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1) self.conv1_batchnorm = nn.BatchNorm2d(num_features=n_chans1) self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3, padding=1) self.conv2_batchnorm = nn.BatchNorm2d(num_features=n_chans1 // 2) self.fc1 = nn.Linear(8 * 8 * n_chans1 // 2, 32) self.fc2 = nn.Linear(32, 2) def forward(self, x): out = self.conv1_batchnorm(self.conv1(x)) out = F.max_pool2d(torch.tanh(out), 2) out = self.conv2_batchnorm(self.conv2(out)) out = F.max_pool2d(torch.tanh(out), 2) out = out.view(-1, 8 * 8 * self.n_chans1 // 2) out = torch.tanh(self.fc1(out)) out = self.fc2(out) return out
与 dropout 一样,批量归一化在训练和推断期间需要有不同的行为。实际上,在推断时,我们希望避免特定输入的输出依赖于我们向模型呈现的其他输入的统计信息。因此,我们需要一种方法来进行归一化,但这次是一次性固定归一化参数。
当处理小批量时,除了估计当前小批量的均值和标准差之外,PyTorch 还更新代表整个数据集的均值和标准差的运行估计,作为近似值。这样,当用户指定时
model.eval()
如果模型包含批量归一化模块,则冻结运行估计并用于归一化。要解冻运行估计并返回使用小批量统计信息,我们调用model.train()
,就像我们对待 dropout 一样。
8.5.3 深入学习更复杂的结构:深度
早些时候,我们谈到宽度作为第一个要处理的维度,以使模型更大,从某种意义上说,更有能力。第二个基本维度显然是深度。由于这是一本深度学习书,深度是我们应该关注的东西。毕竟,深层模型总是比浅层模型更好,不是吗?嗯,这取决于情况。随着深度增加,网络能够逼近的函数的复杂性通常会增加。就计算机视觉而言,一个较浅的网络可以识别照片中的人的形状,而一个更深的网络可以识别人、头部上半部分的脸和脸部内的嘴巴。深度使模型能够处理分层信息,当我们需要理解上下文以便对某些输入进行分析时。
还有另一种思考深度的方式:增加深度与增加网络在处理输入时能够执行的操作序列的长度有关。这种观点–一个执行顺序操作以完成任务的深度网络–对于习惯于将算法视为“找到人的边界,寻找边界上方的头部,寻找头部内的嘴巴”等操作序列的软件开发人员可能是迷人的。
跳过连接
深度带来了一些额外的挑战,这些挑战阻碍了深度学习模型在 2015 年之前达到 20 层或更多层。增加模型的深度通常会使训练更难收敛。让我们回顾反向传播,并在非常深的网络环境中思考一下。损失函数对参数的导数,特别是早期层中的导数,需要乘以许多其他数字,这些数字来自于损失和参数之间的导数操作链。这些被乘以的数字可能很小,生成越来越小的数字,或者很大,由于浮点近似而吞噬较小的数字。归根结底,长链的乘法将使参数对梯度的贡献消失,导致该层的训练无效,因为该参数和类似的其他参数将无法得到适当更新。
2015 年 12 月,Kaiming He 和合著者提出了残差网络(ResNets),这是一种使用简单技巧的架构,使得非常深的网络能够成功训练( arxiv.org/abs/1512.03385
)。该工作为从几十层到 100 层深度的网络打开了大门,超越了当时计算机视觉基准问题的最新技术。我们在第二章中使用预训练模型时遇到了残差网络。我们提到的技巧是:使用跳跃连接来绕过一组层,如图 8.11 所示。
图 8.11 我们具有三个卷积层的网络架构。跳跃连接是NetRes
与NetDepth
的区别所在。
跳跃连接只是将输入添加到一组层的输出中。这正是在 PyTorch 中所做的。让我们向我们简单的卷积模型添加一层,并让我们使用 ReLU 作为激活函数。带有额外一层的香草模块如下所示:
# In[51]: class NetDepth(nn.Module): def __init__(self, n_chans1=32): super().__init__() self.n_chans1 = n_chans1 self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3, padding=1) self.conv3 = nn.Conv2d(n_chans1 // 2, n_chans1 // 2, kernel_size=3, padding=1) self.fc1 = nn.Linear(4 * 4 * n_chans1 // 2, 32) self.fc2 = nn.Linear(32, 2) def forward(self, x): out = F.max_pool2d(torch.relu(self.conv1(x)), 2) out = F.max_pool2d(torch.relu(self.conv2(out)), 2) out = F.max_pool2d(torch.relu(self.conv3(out)), 2) out = out.view(-1, 4 * 4 * self.n_chans1 // 2) out = torch.relu(self.fc1(out)) out = self.fc2(out) return out
向这个模型添加一个类 ResNet 的跳跃连接相当于将第一层的输出添加到第三层的输入中的forward
函数中:
# In[53]: class NetRes(nn.Module): def __init__(self, n_chans1=32): super().__init__() self.n_chans1 = n_chans1 self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3, padding=1) self.conv3 = nn.Conv2d(n_chans1 // 2, n_chans1 // 2, kernel_size=3, padding=1) self.fc1 = nn.Linear(4 * 4 * n_chans1 // 2, 32) self.fc2 = nn.Linear(32, 2) def forward(self, x): out = F.max_pool2d(torch.relu(self.conv1(x)), 2) out = F.max_pool2d(torch.relu(self.conv2(out)), 2) out1 = out out = F.max_pool2d(torch.relu(self.conv3(out)) + out1, 2) out = out.view(-1, 4 * 4 * self.n_chans1 // 2) out = torch.relu(self.fc1(out)) out = self.fc2(out) return out
换句话说,我们将第一个激活的输出用作最后一个的输入,除了标准的前馈路径。这也被称为恒等映射。那么,这如何缓解我们之前提到的梯度消失问题呢?
想想反向传播,我们可以欣赏到在深度网络中的跳跃连接,或者一系列跳跃连接,为深层参数到损失创建了一条直接路径。这使得它们对损失的梯度贡献更直接,因为对这些参数的损失的偏导数有机会不被一长串其他操作相乘。
已经观察到跳跃连接对收敛特别是在训练的初始阶段有益。此外,深度残差网络的损失景观比相同深度和宽度的前馈网络要平滑得多。
值得注意的是,当 ResNets 出现时,跳跃连接并不是新鲜事物。Highway 网络和 U-Net 使用了各种形式的跳跃连接。然而,ResNets 使用跳跃连接的方式使得深度大于 100 的模型易于训练。
自 ResNets 出现以来,其他架构已经将跳跃连接提升到了一个新水平。特别是 DenseNet,提出通过跳跃连接将每一层与数个下游层连接起来,以较少的参数实现了最先进的结果。到目前为止,我们知道如何实现类似 DenseNets 的东西:只需将早期中间输出算术地添加到下游中间输出中。
在 PyTorch 中构建非常深的模型
我们谈到了在卷积神经网络中超过 100 层。我们如何在 PyTorch 中构建该网络而不至于在过程中迷失方向?标准策略是定义一个构建块,例如(Conv2d,ReLU,Conv2d) + 跳跃连接
块,然后在for
循环中动态构建网络。让我们看看实践中是如何完成的。我们将创建图 8.12 中所示的网络。
图 8.12 我们带有残差连接的深度架构。在左侧,我们定义了一个简单的残差块。如右侧所示,我们将其用作网络中的构建块。
我们首先创建一个模块子类,其唯一任务是为一个块提供计算–也就是说,一组卷积、激活和跳跃连接:
# In[55]: class ResBlock(nn.Module): def __init__(self, n_chans): super(ResBlock, self).__init__() self.conv = nn.Conv2d(n_chans, n_chans, kernel_size=3, padding=1, bias=False) # ❶ self.batch_norm = nn.BatchNorm2d(num_features=n_chans) torch.nn.init.kaiming_normal_(self.conv.weight, nonlinearity='relu') # ❷ torch.nn.init.constant_(self.batch_norm.weight, 0.5) torch.nn.init.zeros_(self.batch_norm.bias) def forward(self, x): out = self.conv(x) out = self.batch_norm(out) out = torch.relu(out) return out + x
❶ BatchNorm 层会抵消偏差的影响,因此通常会被省略。
❷ 使用自定义初始化。kaiming_normal_ 使用正态随机元素进行初始化,标准差与 ResNet 论文中计算的一致。批量归一化被初始化为产生初始具有 0 均值和 0.5 方差的输出分布。
由于我们计划生成一个深度模型,我们在块中包含了批量归一化,因为这将有助于防止训练过程中梯度消失。我们现在想生成一个包含 100 个块的网络。这意味着我们需要做一些严肃的剪切和粘贴吗?一点也不;我们已经有了想象这个模型可能是什么样子的所有要素。
首先,在init中,我们创建包含一系列ResBlock
实例的nn.Sequential
。nn.Sequential
将确保一个块的输出被用作下一个块的输入。它还将确保块中的所有参数对Net
可见。然后,在forward
中,我们只需调用顺序遍历 100 个块并生成输出:
# In[56]: class NetResDeep(nn.Module): def __init__(self, n_chans1=32, n_blocks=10): super().__init__() self.n_chans1 = n_chans1 self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1) self.resblocks = nn.Sequential( *(n_blocks * [ResBlock(n_chans=n_chans1)])) self.fc1 = nn.Linear(8 * 8 * n_chans1, 32) self.fc2 = nn.Linear(32, 2) def forward(self, x): out = F.max_pool2d(torch.relu(self.conv1(x)), 2) out = self.resblocks(out) out = F.max_pool2d(out, 2) out = out.view(-1, 8 * 8 * self.n_chans1) out = torch.relu(self.fc1(out)) out = self.fc2(out) return out
在实现中,我们参数化了实际层数,这对实验和重复使用很重要。此外,不用说,反向传播将按预期工作。毫不奇怪,网络收敛速度要慢得多。它在收敛方面也更加脆弱。这就是为什么我们使用更详细的初始化,并将我们的NetRes
训练学习率设置为 3e - 3,而不是我们为其他网络使用的 1e - 2。我们没有训练任何网络到收敛,但如果没有这些调整,我们将一事无成。
所有这些都不应该鼓励我们在一个 32×32 像素的数据集上寻求深度,但它清楚地展示了如何在更具挑战性的数据集(如 ImageNet)上实现这一点。它还为理解像 ResNet 这样的现有模型实现提供了关键要素,例如在torchvision
中。
初始化
让我们简要评论一下早期的初始化。初始化是训练神经网络的重要技巧之一。不幸的是,出于历史原因,PyTorch 具有不理想的默认权重初始化。人们正在努力解决这个问题;如果取得进展,可以在 GitHub 上跟踪(github.com/pytorch/pytorch/issues/18182
)。与此同时,我们需要自己修复权重初始化。我们发现我们的模型无法收敛,查看了人们通常选择的初始化方式(权重较小的方差;批量归一化的输出为零均值和单位方差),然后在网络无法收敛时,将批量归一化的输出方差减半。
权重初始化可能需要一个完整的章节来讨论,但我们认为那可能有些过分。在第十一章中,我们将再次遇到初始化,并使用可能是 PyTorch 默认值的内容,而不做过多解释。一旦你进步到对权重初始化的细节感兴趣的程度–可能在完成本书之前–你可能会重新访问这个主题。
8.5.4 比较本节中的设计
我们在图 8.13 中总结了我们每个设计修改的效果。我们不应该过分解释任何具体的数字–我们的问题设置和实验是简单的,使用不同的随机种子重复实验可能会产生至少与验证准确性差异一样大的变化。在这个演示中,我们保持了所有其他因素不变,从学习率到训练的时代数;在实践中,我们会通过变化这些因素来获得最佳结果。此外,我们可能会想要结合一些额外的设计元素。
但是可能需要进行定性观察:正如我们在第 5.5.3 节中看到的,在讨论验证和过拟合时,权重衰减和丢弃正则化,比批量归一化更具有更严格的统计估计解释作为正则化,两个准确率之间的差距要小得多。批量归一化更像是一个收敛助手,让我们将网络训练到接近 100%的训练准确率,因此我们将前两者解释为正则化。
图 8.13 修改后的网络表现都相似。
8.5.5 它已经过时了
深度学习从业者的诅咒和祝福是神经网络架构以非常快的速度发展。这并不是说我们在本章中看到的内容一定是老派的,但对最新和最伟大的架构进行全面说明是另一本书的事情(而且它们很快就会不再是最新和最伟大的)。重要的是我们应该尽一切努力将论文背后的数学精通地转化为实际的 PyTorch 代码,或者至少理解其他人为了相同目的编写的代码。在最近的几章中,您已经希望积累了一些将想法转化为 PyTorch 中实现模型的基本技能。
8.6 结论
经过相当多的工作,我们现在有一个模型,我们的虚构朋友简可以用来过滤她博客中的图像。我们所要做的就是拿到一张进入的图像,裁剪并调整大小为 32 × 32,看看模型对此有何看法。诚然,我们只解决了问题的一部分,但这本身就是一段旅程。
我们只解决了问题的一部分,因为还有一些有趣的未知问题我们仍然需要面对。其中一个是从较大图像中挑选出鸟或飞机。在图像中创建物体周围的边界框是我们这种模型无法做到的。
另一个障碍是当猫弗雷德走到摄像头前会发生什么。我们的模型会毫不犹豫地发表关于猫有多像鸟的观点!它会高兴地输出“飞机”或“鸟”,也许概率为 0.99。这种对远离训练分布的样本非常自信的问题被称为过度泛化。当我们将一个(假设良好的)模型投入生产中时,我们无法真正信任输入的情况下,这是主要问题之一(遗憾的是,这是大多数真实世界案例)。
在本章中,我们已经在 PyTorch 中构建了合理的、可工作的模型,可以从图像中学习。我们以一种有助于我们建立对卷积网络直觉的方式来做到这一点。我们还探讨了如何使我们的模型更宽更深,同时控制过拟合等影响。虽然我们仍然只是触及了表面,但我们已经比上一章更进一步了。我们现在有了一个坚实的基础,可以面对在深度学习项目中遇到的挑战。
现在我们熟悉了 PyTorch 的约定和常见特性,我们准备着手处理更大的问题。我们将从每一章或两章呈现一个小问题的模式转变为花费多章来解决一个更大的、现实世界的问题。第 2 部分以肺癌的自动检测作为一个持续的例子;我们将从熟悉 PyTorch API 到能够使用 PyTorch 实现整个项目。我们将在下一章开始从高层次解释问题,然后深入了解我们将要使用的数据的细节。
8.7 练习
- 更改我们的模型,使用
kernel_size=5
传递给nn.Conv2d
构造函数的 5 × 5 内核。
- 这种变化对模型中的参数数量有什么影响?
- 这种变化是改善还是恶化了过拟合?
- 阅读
pytorch.org/docs/stable/nn.html#conv2d
。 - 你能描述
kernel_size=(1,3)
会做什么吗? - 这种卷积核会如何影响模型的行为?
- 你能找到一张既不含鸟也不含飞机的图像,但模型声称其中有一个或另一个的置信度超过 95%吗?
- 你能手动编辑一张中性图像,使其更像飞机吗?
- 你能手动编辑一张飞机图像,以欺骗模型报告有鸟吗?
- 这些任务随着容量较小的网络变得更容易吗?容量更大呢?
8.8 总结
- 卷积可用作处理图像的前馈网络的线性操作。使用卷积可以产生参数更少的网络,利用局部性并具有平移不变性。
- 将多个卷积层及其激活函数依次堆叠在一起,并在它们之间使用最大池化,可以使卷积应用于越来越小的特征图像,从而在深度增加时有效地考虑输入图像更大部分的空间关系。
- 任何
nn.Module
子类都可以递归收集并返回其自身和其子类的参数。这种技术可用于计数参数、将其馈送到优化器中或检查其值。 - 函数式 API 提供了不依赖于存储内部状态的模块。它用于不持有参数且因此不被训练的操作。
- 训练后,模型的参数可以保存到磁盘并用一行代码加载回来。
¹PyTorch 的卷积与数学的卷积之间存在微妙的差异:一个参数的符号被翻转了。如果我们情绪低落,我们可以称 PyTorch 的卷积为离散互相关。
² 这是彩票票据假设的一部分:许多卷积核将像丢失的彩票一样有用。参见 Jonathan Frankle 和 Michael Carbin,“The Lottery Ticket Hypothesis: Finding Sparse, Trainable Neural Networks,” 2019,arxiv.org/abs/1803.03635
。
³ 对于偶数大小的卷积核,我们需要在左右(和上下)填充不同数量。PyTorch 本身不提供在卷积中执行此操作的功能,但函数torch.nn.functional.pad
可以处理。但最好保持奇数大小的卷积核;偶数大小的卷积核只是奇数大小的。
⁴ 无法在nn.Sequential
内执行此类操作是 PyTorch 作者明确的设计选择,并且长时间保持不变;请参阅@soumith 在github.com/pytorch/pytorch/issues/2486
中的评论。最近,PyTorch 增加了一个nn.Flatten
层。
⁵ 我们可以从 PyTorch 1.3 开始使用nn.Flatten
。
⁶ 由第一个卷积定义的像素级线性映射中的维度在 Jeremy Howard 的 fast.ai 课程中得到强调(www.fast.ai
)。
⁷ 在深度学习之外且比其更古老的,将投影到高维空间然后进行概念上更简单(比线性更简单)的机器学习通常被称为核技巧。通道数量的初始增加可以被视为一种类似的现象,但在嵌入的巧妙性和处理嵌入的模型的简单性之间达到不同的平衡。
⁸ 数据加载器有一个pin_memory
选项,将导致数据加载器使用固定到 GPU 的内存,目的是加快传输速度。然而,我们是否获得了什么是不确定的,因此我们不会在这里追求这个。
⁹ 我们将重点放在 L2 正则化上。L1 正则化–在更一般的统计文献中因其在 Lasso 中的应用而广为流行–具有产生稀疏训练权重的吸引人特性。
¹⁰ 该主题的开创性论文是由 X. Glorot 和 Y. Bengio 撰写的:“理解训练深度前馈神经网络的困难”(2010 年),介绍了 PyTorch 的Xavier初始化( mng.bz/vxz7
)。我们提到的 ResNet 论文也扩展了这个主题,提供了之前使用的 Kaiming 初始化。最近,H. Zhang 等人对初始化进行了调整,以至于在他们对非常深的残差网络进行实验时不需要批量归一化(arxiv.org/abs/1901.09321
)。