《动手学深度学习》笔记2.1——神经网络从基础→进阶 (模型构建→参数初始化→设计层/块→磁盘读写→多GPU加速)
0. 前言
- 课程全部代码(pytorch版)已上传到附件
- 本章节为原书第5章,名称为:深度学习计算(deep learning computation)
- 本章的代码位置:chapter_deep-learning-computation/index.ipynb
- 本章的视频链接:
- 16 PyTorch 神经网络基础【动手学深度学习v2】_哔哩哔哩_bilibili
- 17 使用和购买 GPU【动手学深度学习v2】_哔哩哔哩_bilibili
- 本章核心内容:深入探索深度学习计算的关键组件, 即模型构建、参数访问与初始化、设计自定义层和块、将模型读写到磁盘, 以及利用GPU实现显著的加速
- 从基础到进阶:本章知识将使读者从深度学习“基础用户”变为“高级用户”
- 与后面高级模型的关系:虽然本章不介绍任何新的模型或数据集, 但后面的高级模型章节在很大程度上依赖于本章的知识
- 同学反馈:本章的学习解开了前后章节的很多困惑,对打牢基础非常有帮助
- 想完全读懂本章节,需要有线性回归、softmax回归、多层感知机等相关基础,主要的前置知识可参考:
- https://blog.csdn.net/weixin_57972634/category_12776752.html
- 还非常需要python基础,得学到面向对象:https://download.csdn.net/download/weixin_57972634/89738887?spm=1001.2014.3001.5503
原书正文
除了庞大的数据集和强大的硬件, 优秀的软件工具在深度学习的快速发展中发挥了不可或缺的作用。 从2007年发布的开创性的Theano库开始, 灵活的开源工具使研究人员能够快速开发模型原型, 避免了我们使用标准组件时的重复工作, 同时仍然保持了我们进行底层修改的能力。 随着时间的推移,深度学习库已经演变成提供越来越粗糙的抽象。 就像半导体设计师从指定晶体管到逻辑电路再到编写代码一样, 神经网络研究人员已经从考虑单个人工神经元的行为转变为从层的角度构思网络, 通常在设计架构时考虑的是更粗糙的块(block)。
之前我们已经介绍了一些基本的机器学习概念, 并慢慢介绍了功能齐全的深度学习模型。 在上一章中,我们从零开始实现了多层感知机的每个组件, 然后展示了如何利用高级API轻松地实现相同的模型。 为了易于学习,我们调用了深度学习库,但是跳过了它们工作的细节。 在本章中,我们将深入探索深度学习计算的关键组件, 即模型构建、参数访问与初始化、设计自定义层和块、将模型读写到磁盘, 以及利用GPU实现显著的加速。 这些知识将使读者从深度学习“基础用户”变为“高级用户”。 虽然本章不介绍任何新的模型或数据集, 但后面的高级模型章节在很大程度上依赖于本章的知识。
1. 层和块 - 从底层讲透 Sequential()
之前首次介绍神经网络时,我们关注的是具有单一输出的线性模型。 在这里,整个模型只有一个输出。
注意,单个神经网络:
(1)接受一些输入;
(2)生成相应的标量输出;
(3)具有一组相关 参数(parameters),更新这些参数可以优化某目标函数。
然后,当考虑具有多个输出的网络时, 我们利用矢量化算法来描述整层神经元。
像单个神经元一样,层:
(1)接受一组输入,
(2)生成相应的输出,
(3)由一组可调整参数描述。
当我们使用softmax回归时,一个单层本身就是模型。 然而,即使我们随后引入了多层感知机,我们仍然可以认为该模型保留了上面所说的基本架构。
对于多层感知机而言,整个模型及其组成层都是这种架构。 整个模型接受原始输入(特征),生成输出(预测), 并包含一些参数(所有组成层的参数集合)。 同样,每个单独的层接收输入(由前一层提供), 生成输出(到下一层的输入),并且具有一组可调参数, 这些参数根据从下一层反向传播的信号进行更新。
事实证明,研究讨论 “比单个层大” 但 “比整个模型小” 的组件更有价值。 例如,在计算机视觉中广泛流行的ResNet-152架构就有数百层, 这些层是由层组(groups of layers)的重复模式组成。 这个ResNet架构赢得了2015年ImageNet和COCO计算机视觉比赛 的识别和检测任务 :cite:He.Zhang.Ren.ea.2016
。 目前ResNet架构仍然是许多视觉任务的首选架构。 在其他的领域,如自然语言处理和语音, 层组以各种重复模式排列的类似架构现在也是普遍存在。
为了实现这些复杂的网络,我们引入了神经网络块的概念。 块(block)可以描述单个层、由多个层组成的组件或整个模型本身。 使用块进行抽象的一个好处是可以将一些块组合成更大的组件, 这一过程通常是递归的,如 :numref:fig_blocks
所示。 通过定义代码来按需生成任意复杂度的块, 我们可以通过简洁的代码实现复杂的神经网络。
从编程的角度来看,块由类(class)表示。 它的任何子类都必须定义一个将其输入转换为输出的前向传播函数, 并且必须存储任何必需的参数。 注意,有些块不需要任何参数。 最后,为了计算梯度,块必须具有反向传播函数。 在定义我们自己的块时,由于自动微分(在 《动手学深度学习》笔记1.3——矩阵求导_方向导数与梯度的关系-CSDN博客 中引入自动求导) 提供了一些后端实现,我们只需要考虑前向传播函数和必需的参数。
在构造自定义块之前,(我们先回顾一下多层感知机) (《动手学深度学习》笔记1.6——多层感知机→代码实现_两层感知机代码函数实现-CSDN博客)的代码。 下面的代码生成一个网络,其中包含一个具有256个单元和ReLU激活函数的全连接隐藏层, 然后是一个具有10个隐藏单元且不带激活函数的全连接输出层。
1.0 Sequential() PyTorch高级API
import torch
from torch import nn
from torch.nn import functional as F # functional Module包括了一些没有参数的函数,下面会看到具体用法net = nn.Sequential(nn.Linear(20, 256), nn.ReLU(), nn.Linear(256, 10)) # 单隐藏层(256个神经元), 输入20→输出10X = torch.rand(2, 20) # 权重X,初始化成2行20列的、均值为0方差为1的随机矩阵
# 2是批量大小,20是输入的维度,10是输出的大小
net(X)
在这个例子中,我们通过实例化nn.Sequential
来构建我们的模型, 层的执行顺序是作为参数传递的。 简而言之,(nn.Sequential
定义了一种特殊的 Module
), 即在PyTorch中表示一个块的类, 它维护了一个由Module
组成的有序列表。 注意,两个全连接层都是Linear
类的实例, Linear
类本身就是Module
的子类。 另外,到目前为止,我们一直在通过net(X)
调用我们的模型来获得模型的输出。 这实际上是net.__call__(X)
的简写。 这个前向传播函数非常简单: 它将列表中的每个块连接在一起,将每个块的输出作为下一个块的输入。
1.1 MLP(nn.Module) 自定义块 (多层)
要想直观地了解块是如何工作的,最简单的方法就是自己实现一个基础版的 nn.Sequential() ,进而从零定义和执行块。 在实现我们自定义块之前,我们简要总结一下每个块必须提供的基本功能。
- 将输入数据作为其前向传播函数的参数。
- 通过前向传播函数来生成输出。请注意,输出的形状可能与输入的形状不同。例如,我们上面模型中的第一个全连接的层接收一个20维的输入,但是返回一个维度为256的输出。
- 计算其输出关于输入的梯度,可通过其反向传播函数进行访问。通常这是自动发生的。
- 存储和访问前向传播计算所需的参数。
- 根据需要初始化模型参数。
在下面的代码片段中,我们从零开始编写一个块。 它包含一个多层感知机,其具有256个隐藏单元的隐藏层和一个10维输出层。 注意,下面的MLP
类继承了表示块的类。 我们的实现只需要提供我们自己的构造函数(Python中的__init__
函数)和前向传播函数。
class MLP(nn.Module): # 定义MLP是nn.Module的一个子类(涉及到python面向对象编程的相关知识)# 用模型参数声明层。这里,我们声明两个全连接的层def __init__(self): # 创建对象(实例)时,__init__会自动执行# 调用MLP的父类Module的构造函数来执行必要的初始化。# 这样,在类实例化时也可以指定其他函数参数,例如模型参数params(稍后将介绍)super().__init__() # super() 调用父类nn.Module的构造函数__init__(),初始化模型(必要)的参数和子模块self.hidden = nn.Linear(20, 256) # 隐藏层:存在类的成员变量hidden里面self.out = nn.Linear(256, 10) # 输出层:存在类的成员变量hidden里面# 定义模型的前向传播,即如何根据输入X返回所需的模型输出def forward(self, X): # 前向传播:逐层运算;更新参数在反向传播过程中# 注意,这里我们使用ReLU的函数版本,其在nn.functional模块中定义。return self.out(F.relu(self.hidden(X))) # 先把输入X存到hidden层里面,得到隐藏层的输出# 用ReLU激活后放入out层里
我们首先看一下前向传播函数,它以X
作为输入, 计算带有激活函数的隐藏表示,并输出其未规范化的输出值。 在这个MLP
实现中,两个层都是实例变量。 要了解这为什么是合理的,可以想象实例化两个多层感知机(net1
和net2
), 并根据不同的数据对它们进行训练。 当然,我们希望它们学到两种不同的模型。
实例化多层感知机的层,然后在每次调用前向传播函数时调用这些层
注意一些关键细节: 首先,我们定制的__init__
函数通过super().__init__()
调用父类的__init__
函数, 省去了重复编写模版代码的痛苦。 然后,我们实例化两个全连接层, 分别为self.hidden
和self.out
。 注意,除非我们用到一个新的运算符, 否则我们不必担心反向传播基础的函数或参数初始化, 系统将调用父类nn.Module的__init__()
函数自动初始化。
我们来试一下这个MLP()函数:
net = MLP()
net(X)
块的一个主要优点是它的多功能性。 我们可以子类化块以创建层(如全连接层的类)、 整个模型(如上面的MLP
类)或具有中等复杂度的各种组件。 我们在接下来的章节中充分利用了这种多功能性, 比如在处理卷积神经网络时。
1.2 MySequential(nn.Module) 按顺序合并与执行 (每层)
现在我们可以更仔细地看看Sequential
类是如何工作的, 回想一下Sequential
的设计是为了把其他模块串起来。 为了构建我们自己的简化的MySequential
, 我们只需要定义两个关键函数:
- 一种将块逐个追加到列表中的函数;
- 一种前向传播函数,用于将输入按追加块的顺序传递给块组成的“链条”。
下面的MySequential
类提供了与默认Sequential
类相同的功能。
class MySequential(nn.Module):def __init__(self, *args): # *args是可变参数列表, 在实例化 MySequential 类时传入任意数量的参数# *args 是一个元组(tuple),可以传入多个 nn.Module 子类实例(层,块),它们会被打包成一个元组super().__init__() # 和上面MLP()一样,调用父类nn.Module中的__init__()来初始化基本的模型和参数for idx, module in enumerate(args): # enumerate()是内置函数,可同时获取可迭代对象的索引和对应的元素# 可以这样理解: 下方MySequential()传入的3个成员nn.Linear(20, 256),nn.ReLU(),nn.Linear(256, 10)# 放到了*args元组里,为每个成员(层,块)生成一个顺序idx,按顺序放进成员变量_module里self._modules[str(idx)] = module # 成员变量_module的类型是OrderedDict(顺序字典)def forward(self, X):# OrderedDict保证了按照成员(层,块)添加的顺序遍历(调用)它们for block in self._modules.values(): # 按顺序每次用values()拿一个成员(层,块),放到block里,传入XX = block(X) # 这里的X可看作中间变量,不断按顺序在成员(层,块)间塑性非线性变换return X # 返回最后输出的X
__init__
函数将每个模块逐个添加到有序字典_modules
中。 读者可能会好奇为什么每个Module
都有一个_modules
属性? 以及为什么我们使用它而不是自己定义一个Python列表? 简而言之,_modules
的主要优点是: 在模块的参数初始化过程中, 系统知道在_modules
字典中查找需要初始化参数的子块。
- 当
MySequential
的前向传播函数被调用时, 每个添加的块都按照它们被添加的顺序执行。
现在可以使用我们的 MySequential
类 重新实现多层感知机。
net = MySequential(nn.Linear(20, 256), nn.ReLU(), nn.Linear(256, 10))
net(X)
请注意,
MySequential
的用法与之前为Sequential
类编写的代码相同(咱的MySequential
算是基础版的Sequential
)
拓展:可参考/回顾 《动手学深度学习》笔记1.6——多层感知机→代码实现_两层感知机代码函数实现-CSDN博客 )
1.3 FixedHiddenMLP() 合并更复杂的层
Sequential
类使模型构造变得简单, 允许我们组合新的架构,而不必定义自己的类。 然而,并不是所有的架构都是简单的顺序架构。 当需要更强的灵活性时,我们需要定义自己的块。 例如,我们可能希望在前向传播函数中执行Python的控制流。 此外,我们可能希望执行任意的数学运算, 而不是简单地依赖预定义的神经网络层。
(持续更新中)