环境:PyCharm + python3.8
👉【深度学习计算】
1. 层和块
块(block)可以描述
- 单个层、
- 由多个层组成的组件
- 或整个模型本身。
使用块进行抽象的好处:
- 可将块组合成更大的组件(这一过程通常是递归) 如 图5.1.1所示。
- 通过定义代码来按需生成任意复杂度的块,可以通过简洁的代码实现复杂的神经网络。

从编程的角度来看,块由类(class)表示。
- 其子类都必须定义一个前向传播函数(将其输入转换为输出),且必须存储任何必需的参数(有些块不需要任何参数)。
- 最后,为了计算梯度,块必须具有反向传播函数。在定义自己的块时,由于自动微分(在 2.5节 中引入)提供了一些后端实现,我们只需要考虑前向传播函数和必需的参数。
下面代码生成一个网络,其中包含
- 具有 256个单元 和 ReLU激活函数 的全连接隐藏层,
- 具有 10个隐藏单元 且 不带激活函数 的全连接输出层。
import torch
from torch import nn
from torch.nn import functional as Fnet = nn.Sequential(nn.Linear(20, 256),nn.ReLU(),nn.Linear(256, 10))X = torch.rand(2, 20)
print(f"随机生成的原始输入:\n{X}")
print(f"模型输出:\n{net(X)}")
该例子通过实例化nn.Sequential
来构建模型,层的执行顺序是作为参数传递的。
nn.Sequential
定义了一种特殊的Module
,即在PyTorch中表示一个块的类,它维护了一个由Module
组成的有序列表。- 注意:两个全连接层都是
Linear
类的实例,Linear
类本身就是Module
的子类。 net(X)
实际上是net.__call__(X)
的简写 (我们前面一直在通过net(X)
调用我们的模型来获得模型的输出)。- 这个前向传播函数非常简单:它将列表中的每个块连接在一起,将每个块的输出作为下一个块的输入。
1.1. 自定义块
简要总结每个块必须提供的基本功能:
- 将输入数据作为其前向传播函数的参数。
- 通过前向传播函数来生成输出。(注意:输出的形状可能与输入的形状不同。例如,上面模型中的第一个全连接的层接收一个20维的输入,但是返回一个维度为256的输出)。
- 计算其输出关于输入的梯度,可通过其反向传播函数进行访问(通常是自动发生)。
- 存储和访问前向传播计算所需的参数。
- 根据需要初始化模型参数。
下面的代码片段从零开始编写一个块。(包含一个多层感知机,其具有256个隐藏单元的隐藏层和一个10维输出层)。 注意,下面的MLP
类继承了表示块的类。我们的实现只需要提供我们自己的构造函数(Python中的__init__
函数)和前向传播函数。
class MLP(nn.Module):# 用模型参数声明层,这里声明两个全连接的层def __init__(self):# 调用MLP的父类Module的构造函数来执行必要的初始化# 这样,在类实例化时也可以指定其他函数参数,例如模型参数params(稍后将介绍)super().__init__()self.hidden = nn.Linear(20, 256) # 隐藏层self.out = nn.Linear(256, 10) # 输出层# 定义模型的前向传播,即如何根据输入X返回所需的模型输出def forward(self, X):# 注意:这里使用ReLU的函数版本,其在nn.functional模块中定义。return self.out(F.relu(self.hidden(X)))
前向传播函数:
- 以
X
作为输入, - 计算带有激活函数的隐藏表示,
- 并输出其未规范化的输出值。
在这个MLP
实现中,两个层都是实例变量。要了解这为什么是合理的,可以想象实例化两个多层感知机(net1
和net2
),并根据不同的数据对它们进行训练。
接着我们实例化多层感知机的层,然后在每次调用前向传播函数时调用这些层。注意一些关键细节:
- 首先,定制的
__init__
函数通过super().__init__()
调用父类的__init__
函数 (避免了重复编写模版代码)。 - 然后,实例化两个全连接层, 分别为
self.hidden
和self.out
。 - 注意,除非要实现一个新的运算符,否则不必担心反向传播函数或参数初始化,系统将自动生成这些。
net = MLP()
mlp_Out = net(X)
print(f"MLP模型输出:\n{mlp_Out}")
块的一个主要优点是它的多功能性。我们可以子类化块以创建
- 层(如全连接层的类)、
- 整个模型(如上面的
MLP
类) - 或具有中等复杂度的各种组件。
1.2. 顺序块
Sequential
的设计是为了把其他模块串起来。
构建自己的简化的MySequential
,只需定义两个关键函数:
- 一种将块逐个追加到列表中的函数;
- 一种前向传播函数,用于将输入按追加块的顺序传递给块组成的“链条”。
下面的MySequential
类提供了与默认Sequential
类相同的功能。
# 直接将神经网络的每个层当作参数传进来
class MySequential(nn.Module):def __init__(self, *args):super().__init__() # 调用父类的构造函数for idx, module in enumerate(args):# 这里,module是Module子类的一个实例。我们把它保存在'Module'类的成员# 变量_modules中。_module的类型是OrderedDictself._modules[str(idx)] = moduledef forward(self, X):# OrderedDict保证了按照成员添加的顺序遍历它们for block in self._modules.values():X = block(X)return X
__init__
函数将每个模块逐个添加到有序字典_modules
中。_modules
的主要优点是:在模块的参数初始化过程中,系统知道在_modules
字典中查找需要初始化参数的子块。
当MySequential
的前向传播函数被调用时,每个添加的块都按照它们被添加的顺序执行。下面使用MySequential
类重新实现多层感知机。
net = MySequential(nn.Linear(20, 256),nn.ReLU(),nn.Linear(256, 10))
myModule_Out = net(X)
print(f"MySequential模型输出:\n{myModule_Out}")
1.3. 在前向传播函数中执行代码
当架构需要更强的灵活性时,则需要定义自己的块。
常数参数 (constant parameter):既不是上一层的结果 也不是可更新参数 的项。
- 例如,我们需要一个计算函数
的层,其中
- x 是 输入,
- w 是 参数,
- c 是 某个在优化过程中没有更新的指定常量。
因此下面实现一个FixedHiddenMLP
类,如下所示:
class FixedHiddenMLP(nn.Module):def __init__(self):super().__init__()# 不计算梯度的随机权重参数。因此其在训练期间保持不变self.rand_weight = torch.rand((20, 20), requires_grad=False)self.linear = nn.Linear(20, 20)def forward(self, X):X = self.linear(X)# 使用创建的常量参数以及relu和mm函数X = F.relu(torch.mm(X, self.rand_weight) + 1)# 复用全连接层。这相当于两个全连接层共享参数X = self.linear(X)# 控制流while X.abs().sum() > 1:X /= 2return X.sum()
在这个FixedHiddenMLP
模型中,
- 实现了一个隐藏层,其权重(
self.rand_weight
) 在实例化时被随机初始化,之后为常量。 - 这个权重不是一个模型参数,因此它永远不会被反向传播更新。
- 然后,神经网络将这个固定层的输出通过一个全连接层。
- 注意:在返回输出之前,模型做了一些不寻常的事情:
- 它运行了一个while循环,在 L1范数>1 的条件下,将输出向量除以2,直到它满足条件为止。
- 最后,模型返回了
X
中所有项的和。 - 注意:此操作可能不会常用于在任何实际任务中,这里只展示如何将任意代码集成到神经网络计算的流程中。
net = FixedHiddenMLP()
fixMLP_Out = net(X)
print(f"FixedHiddenMLP模型输出:\n{fixMLP_Out}")
也可以混合搭配各种组合块的方法。如下例子,我们以一些想到的方法嵌套块:(网络结构如下)
- NestMLP(),
- net()
- nn.Linear(20, 64), nn.ReLU()
- nn.Linear(64, 32), nn.ReLU()
- nn.Linear(32, 16)
- net()
- nn.Linear(16, 20),
- FixedHiddenMLP()
- nn.Linear(20, 20)
- x*w+1, nn.ReLU()
- nn.Linear(20, 20)
- x一直减半直到 其值的和>1。
class NestMLP(nn.Module):def __init__(self):super().__init__()self.net = nn.Sequential(nn.Linear(20, 64), nn.ReLU(),nn.Linear(64, 32), nn.ReLU())self.linear = nn.Linear(32, 16)def forward(self, X):return self.linear(self.net(X))chimera = nn.Sequential(NestMLP(), nn.Linear(16, 20), FixedHiddenMLP())
print(f"NestMLP模型输出:\n{chimera(X)}")
1.4. 效率
读者可能会开始担心操作效率的问题。毕竟,我们在一个高性能的深度学习库中进行了大量的字典查找、代码执行和许多其他的Python代码。Python的问题全局解释器锁 是众所周知的。在深度学习环境中,我们担心速度极快的GPU可能要等到CPU运行Python代码后才能运行另一个作业。
小结
一个块可以由许多层组成;一个块可以由许多块组成。
块可以包含代码。
块负责大量的内部处理,包括参数初始化和反向传播。
层和块的顺序连接由
Sequential
块处理。
2. 参数管理
选择好架构并设置好超参数后,下一阶段就是训练阶段。
训练阶段的目标:找到 使损失函数最小化 的模型参数值。(这些参数就是用于做出未来的预测)
本节内容:
- 访问参数,用于调试、诊断和可视化;
- 参数初始化;
- 在不同模型组件间共享参数。
首先看一下具有单隐藏层的多层感知机:
import torch
from torch import nnnet = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 1))
X = torch.rand(size=(2, 4))
print(f"随机生成的原始输入:\n{X}")
print(f"模型输出:\n{net(X)}")
2.1. 参数访问
从已有模型中访问参数:
- 当通过
Sequential
类定义模型时,可以通过索引来访问模型的任意层。 - 可以把模型当作一个列表,每层的参数都在其属性中。
- 如下所示,可以检查第二个全连接层的参数。
print(f"第二个全连接层的参数:\n{net[2].state_dict()}")
输出的结果显示:
- 该全连接层包含两个参数,分别是该层的 权重 和 偏置。
- 两者都存储为单精度浮点数(float32)。
- 注意,参数名称允许唯一标识每个参数,即使在包含数百个层的网络中也是如此。
2.1.1. 目标参数
每个参数都表示为参数类的一个实例。要对参数执行任何操作,首先需要访问底层的数值。有几种方法可以做到这一点。下面的代码从第二个全连接层(即第三个神经网络层)提取偏置, 提取后返回的是一个参数类实例,并进一步访问该参数的值。
# 从第二个全连接层(即第三个神经网络层)提取偏置:
# 参数是复合的对象,包含值、梯度和额外信息,因此需要显式参数值
print(f"类型:{type(net[2].bias)}")
print(f"值(包括其形状和数据类型):\n{net[2].bias}")
print(f"数据部分(偏置参数的底层数据张量):{net[2].bias.data}")
除了值之外,还可以访问每个参数的梯度。在上面这个网络中,由于还没有调用反向传播,所以参数的梯度处于初始状态。
print(f"参数的梯度:{net[2].weight.grad == None}")
2.1.2. 一次性访问所有参数
- 当需要对所有参数执行操作时,逐个访问会很麻烦。
- 当处理更复杂的块(例如,嵌套块)时,情况会变得特别复杂,因为需要递归整个树来提取每个子块的参数。
下面,将通过演示来比较 访问第一个全连接层的参数 和 访问所有层。
print(f"---访问第一个全连接层的参数:\n{[(name, param.shape) for name, param in net[0].named_parameters()]}")
print(f"---访问所有层:\n{[(name, param.shape) for name, param in net.named_parameters()]}")
# print(*[(name, param.shape) for name, param in net[0].named_parameters()])
# print(*[(name, param.shape) for name, param in net.named_parameters()])
这为我们提供了另一种访问网络参数的方式,如下所示
# net.state_dict() 返回模型的参数字典
# ['2.bias'] 从参数字典中 获取模型中第三个模块(索引从0开始)的偏置参数
print(f"另一种访问网络参数的方式:{net.state_dict()['2.bias'].data}")
2.1.3. 从嵌套块收集参数
若将多个块相互嵌套,参数命名约定是如何工作的。首先定义一个生成块的函数(可以说是“块工厂”),然后将这些块组合到更大的块中。
def block1():return nn.Sequential(nn.Linear(4, 8), nn.ReLU(),nn.Linear(8, 4), nn.ReLU())def block2():net = nn.Sequential()for i in range(4):# 在这里嵌套net.add_module(f'block {i}', block1())return netrgnet = nn.Sequential(block2(), nn.Linear(4, 1))
rgnet(X)
print(f"嵌套块 模型输出:\n{rgnet(X)}")
设计了网络后,接下来看看它是如何工作的。
print(f"嵌套块 模型结构:\n{rgnet}")
因为层是分层嵌套的,所以我们也可以像通过嵌套列表索引一样访问它们。
访问 第一个主要的块中、第二个子块的第一层的偏置项:
print(f"第一个主要的块中、第二个子块的第一层的偏置项:\n{rgnet[0][1][0].bias.data}")
2.2. 参数初始化
- 深度学习框架提供默认随机初始化,
- 也允许创建自定义初始化方法,满足我们通过其他规则实现初始化权重。
- 默认情况下,PyTorch会根据一个范围均匀地初始化权重和偏置矩阵,
- 这个范围是根据输入和输出维度计算出的。
- PyTorch的
nn.init
模块提供了多种预置初始化方法。
2.2.1. 内置初始化
首先调用内置的初始化器。
2.2.1.1 初始化为高斯随机变量
- 将所有权重参数初始化为标准差为0.01的高斯随机变量,且将偏置参数设置为0。
# 将所有权重参数初始化为 标准差=0.01的高斯随机变量,且将偏置参数设置为0
def init_normal(m):if type(m) == nn.Linear:nn.init.normal_(m.weight, mean=0, std=0.01)nn.init.zeros_(m.bias)
net.apply(init_normal)
print(f"权重初始化为 标准差=0.01的高斯随机变量:\n{net[0].weight.data[0]}")
print(f"偏置初始化为 常量0:\n{net[0].bias.data[0]}")
2.2.1.2 初始化为给定的常数
- 将所有参数初始化为给定的常数,比如初始化为1。
# 将所有参数初始化为给定的常数,比如初始化为1
def init_constant(m):if type(m) == nn.Linear:nn.init.constant_(m.weight, 1)nn.init.zeros_(m.bias)
net.apply(init_constant)
print(f"权重初始化为 常量1:\n{net[0].weight.data[0]}")
print(f"偏置初始化为 常量0:\n{net[0].bias.data[0]}")
2.2.1.3 对不同块应用不同的初始化方法
- 对不同块应用不同的初始化方法。
例如,使用Xavier初始化方法初始化第一个神经网络层,然后将第三个神经网络层初始化为常量值42。
'''
Xavier初始化:根据输入和输出的维度自动调整初始化的范围,使得每一层的输出的方差在训练初期保持一致。(助于缓解梯度消失和梯度爆炸)
均匀分布:xavier_uniform_()使用均匀分布来初始化权重,而不是正态分布。均匀分布的范围是[-limit, limit],其中limit是根据输入和输出维度计算得出的
'''
# 对某些块应用不同的初始化方法
def init_xavier(m):if type(m) == nn.Linear:nn.init.xavier_uniform_(m.weight)
def init_42(m):if type(m) == nn.Linear:nn.init.constant_(m.weight, 42)net[0].apply(init_xavier) # 使用Xavier初始化方法初始化第一个神经网络层
net[2].apply(init_42) # 将第三个神经网络层初始化为常量值42
print(f"第一层网络,使用Xavier初始化权重:\n{net[0].weight.data[0]}")
print(f"第三层网络,权重初始化为 常量42:\n{net[2].weight.data}")
2.2.2. 自定义初始化
有时,深度学习框架没有提供我们需要的初始化方法。在下例中,使用以下的分布为任意权重参数w定义初始化方法:
同样,实现一个my_init
函数来应用到net
。
def my_init(m):if type(m) == nn.Linear:print("Init", *[(name, param.shape)for name, param in m.named_parameters()][0])nn.init.uniform_(m.weight, -10, 10) # 使用均匀分布初始化权重,范围是[-10, 10]# 对权重进行阈值处理,将绝对值小于 5 的元素设置为 0。# 即 只有 绝对值>=5 的权重会被保留,其余的被置零m.weight.data *= m.weight.data.abs() >= 5
net.apply(my_init)
print(f"自定义初始化:\n{net[0].weight[:2]}")
注意,我们始终可以直接设置参数。
net[0].weight.data[:] += 1 # 将第一个模块的所有权重值加 1
net[0].weight.data[0, 0] = 42 # 将第一个模块的权重张量的 [0, 0] 位置的值设置为 42
print(f"对权重进行手动修改后:\n{net[0].weight[:2]}")
2.2.3. 参数绑定 (共享参数)
有时希望在多个层间共享参数:我们可以定义一个稠密层,然后使用它的参数来设置另一个层的参数。
# 参数绑定
# 我们需要给共享层一个名称,以便可以引用它的参数
shared = nn.Linear(8, 8)
net = nn.Sequential(nn.Linear(4, 8), nn.ReLU(),shared, nn.ReLU(),shared, nn.ReLU(),nn.Linear(8, 1))
net(X)
print(f"检查参数是否相同:\n"f"{net[2].weight.data[0] == net[4].weight.data[0]}")
net[2].weight.data[0, 0] = 100
print(f"确保它们实际上是同一个对象,而不只是有相同的值:\n"f"{net[2].weight.data[0] == net[4].weight.data[0]}")
这个例子表明第三个和第五个神经网络层的参数是绑定的。
- 它们不仅值相等,而且由相同的张量表示。
- 因此,若改变其中一个参数,另一个参数也会改变。
- 当参数绑定时,由于模型参数包含梯度,因此在反向传播期间第二个隐藏层 (即第三个神经网络层)和第三个隐藏层(即第五个神经网络层)的梯度会加在一起。
共享参数通常可以节省内存,并在以下方面具有特定的好处:
- 对于图像识别中的CNN,共享参数使网络能够在图像中的任何地方而不是仅在某个区域中查找给定的功能。
- 对于RNN,它在序列的各个时间步之间共享参数,因此可以很好地推广到不同序列长度的示例。
- 对于自动编码器,编码器和解码器共享参数。 在具有线性激活的单层自动编码器中,共享权重会在权重矩阵的不同隐藏层之间强制正交。
小结
我们有几种方法可以访问、初始化和绑定模型参数。
可以使用自定义初始化方法。
3. 延后初始化
延后初始化 (defers initialization):直到数据第一次通过模型传递时,框架才会动态地推断出每个层的大小。
tensorflow 版本:
def delay_initialization_tf(): # tensorflow版本import tensorflow as tfnet = tf.keras.models.Sequential([tf.keras.layers.Dense(256, activation=tf.nn.relu),tf.keras.layers.Dense(10),])print(f"访问输入层的权重参数:\n{[net.layers[i].get_weights() for i in range(len(net.layers))]}")X = tf.random.uniform((2, 20))net(X)print(f"将数据通过网络,使框架初始化参数:\n{[w.shape for w in net.get_weights()]}") delay_initialization_tf()
def delay_initialization():net = nn.Sequential(nn.LazyLinear(64),nn.ReLU(),nn.LazyLinear(10))print(f"尚未初始化:\n{net}")print(net[0].weight) # 尚未初始化,会报错print(f"输入层的权重参数:\n{[net[i].state_dict() for i in range(len(net))]}")X = torch.rand(2, 20)net(X) # 数据第一次通过模型传递print(f"数据第一次通过模型传递后,完成初始化:{net}")print(f"输入层的权重参数:\n{net[0].weight}")
delay_initialization()
3.1. 实例化网络
让实例化一个多层感知机:
net = nn.Sequential(nn.LazyLinear(64),nn.ReLU(),nn.LazyLinear(10))
此时输入维数是未知的,因此网络不可能知道输入层权重的维数。因此,框架尚未初始化任何参数:
print(f"尚未初始化:\n{net}")print(net[0].weight) # 尚未初始化,会报错print(f"输入层的权重参数:\n{[net[i].state_dict() for i in range(len(net))]}")
注意:每个层对象都存在,但权重为空,参数显示未初始化。使用net.get_weights()
将抛出一个错误,因为权重尚未初始化。
接下来将数据通过网络,最终使框架初始化参数:
X = torch.rand(2, 20)net(X) # 数据第一次通过模型传递print(f"数据第一次通过模型传递后,完成初始化:{net}")print(f"输入层的权重参数:\n{net[0].weight}")
- 一旦知道输入维数是20,框架可以通过代入值20来识别第一层权重矩阵的形状。
- 识别出第一层的形状后,框架处理第二层,
- 依此类推,直到所有形状都已知为止。
- 注意:在这种情况下,只有第一层需要延迟初始化,但是框架仍是按顺序初始化的。等到知道了所有的参数形状,框架就可以初始化参数。
小结
延后初始化使框架能够自动推断参数形状,使修改模型架构变得容易,避免了一些常见的错误。
我们可以通过模型传递数据,使框架最终初始化参数。
练习
- Q1: 如果指定了第一层的输入尺寸,但没有指定后续层的尺寸,会发生什么?是否立即进行初始化?
- A1: 可以正常运行。第一层会立即初始化,但其他层同样是直到数据第一次通过模型传递才会初始化(不知道题目理解的对不对)
- Q2: 如果指定了不匹配的维度会发生什么?
- A2:
- 如果在定义模型时,模块的输出尺寸和下一个模块的输入尺寸不匹配,PyTorch会在第一次调用
forward
时抛出错误。 - 例如,如果你有一个
LazyLinear
层输出尺寸为10,但下一个LazyLinear
层期望输入尺寸为20,则在第一次forward
调用时会引发错误
- 如果在定义模型时,模块的输出尺寸和下一个模块的输入尺寸不匹配,PyTorch会在第一次调用
- A2:
- Q3: 如果输入具有不同的维度,你需要做什么?提示:查看参数绑定的相关内容。
- A3:
- 如果输入维度比指定维度小,可以考虑使用padding填充;
- 如果输入维度比指定维度大,可以考虑用pca等降维方法,将维度降至指定维度。
- A3:
4. 自定义层
4.1. 不带参数的层
下面的CenteredLayer
类要从其输入中减去均值。要构建它,只需继承基础层类并实现前向传播功能。
import torch
import torch.nn.functional as F
from torch import nnclass CenteredLayer(nn.Module): # 从输入中减去均值def __init__(self):super().__init__()def forward(self, X):return X - X.mean()
向该层提供一些数据,验证它是否能按预期工作:
layer = CenteredLayer()
X = torch.FloatTensor([1, 2, 3, 4, 5])
print(f"输入:{X}")
print(f"均值:{X.mean()}")
print(f"网络输出:{layer(X)}")
现在,可以将层作为组件合并到更复杂的模型中:
# 将层作为组件合并到更复杂的模型中
net = nn.Sequential(nn.Linear(8, 128), CenteredLayer())
作为额外的健全性检查,我们可以在向该网络发送随机数据后,检查均值是否为0。由于处理的是浮点数,因为存储精度的原因,我们仍然可能会看到一个非常小的非零数。
Y = net(torch.rand(4, 8)) # 向该网络发送随机数据
# print(f"将层作为组件合并到更复杂的模型中:{Y}")
print(f"检查均值是否为0 Y.mean():{Y.mean()}")
4.2. 带参数的层
定义具有参数(参数可以通过训练进行调整)的层,这些参数可以使用内置函数来创建。
- 内置函数提供一些基本的管理功能 (比如管理访问、初始化、共享、保存和加载模型参数)。
- 如此可以不需要为每个自定义层编写自定义的序列化程序。
下面实现自定义版本的全连接层,该层需要权重和偏置项两个参数。 这里使用修正线性单元作为激活函数。 该层需要输入参数:in_units
和units
,分别表示输入数和输出数。
class MyLinear(nn.Module):def __init__(self, in_units, units):super().__init__()# 权重和偏置项皆初始化为随机正态分布self.weight = nn.Parameter(torch.randn(in_units, units))self.bias = nn.Parameter(torch.randn(units,))def forward(self, X):linear = torch.matmul(X, self.weight.data) + self.bias.datareturn F.relu(linear)
接下来,实例化MyLinear
类并访问其模型参数:
linear = MyLinear(5, 3)
print(f"自定义全连接层的权重:\n{linear.weight}")
可以使用自定义层直接执行前向传播计算:
print(f"使用自定义层直接执行前向传播计算:\n{linear(torch.rand(2, 5))}")
还可以使用自定义层构建模型,就像使用内置的全连接层一样使用自定义层:
net = nn.Sequential(MyLinear(64, 8), MyLinear(8, 1))
print(f"使用自定义全连接层构建的复杂模型,预测结果:\n{net(torch.rand(2, 64))}")
小结
可以通过基本层类 设计自定义层。以此来定义 行为与深度学习框架中的任何现有层不同的 灵活的 新层。
在自定义层定义完成后,就可以在任意环境和网络架构中调用该自定义层。
层可以有局部参数,这些参数可以通过内置函数创建。
5. 读写文件
- 保存训练的模型,可以 以备将来在各种环境中使用(比如在部署中进行预测)。
- 当运行一个耗时较长的训练过程时,定期保存中间结果,可以防止在服务器电源被不小心断掉时,损失几天的计算结果。
5.1. 加载和保存张量
对于单个张量,可以直接调用load
和save
函数分别读写它们。这两个函数都要求提供一个名称,save
要求将要保存的变量作为输入。
import torch
from torch import nn
from torch.nn import functional as Fx = torch.arange(4)
torch.save(x, 'outputFile/x-file') # 将张量保存到文件中
然后可以将存储在文件中的数据读回内存:
x2 = torch.load('outputFile/x-file', weights_only=False) # 从文件中加载张量,并赋值
print(f"x2={x2}")
可以存储一个张量列表,然后把它们读回内存:
# 存储一个张量列表,然后把它们读回内存:
y = torch.zeros(4)
torch.save([x, y],'outputFile/x-files') # 将两个张量保存到文件中
x2, y2 = torch.load('outputFile/x-files', weights_only=False) # 加载列表中的张量,并将其解包到 x2 和 y2
print(f"x2, y2 = {x2, y2}")
也可以写入或读取从字符串映射到张量的字典。这利于读取或写入模型中的所有权重:
# 写入或读取从字符串映射到张量的字典
mydict = {'x': x, 'y': y} # 创建字典
torch.save(mydict, 'outputFile/mydict') # 将字典保存到文件 mydict 中
mydict2 = torch.load('outputFile/mydict', weights_only=False) # 加载字典,并将其赋值给 mydict2
print(f"mydict2={mydict2}")
5.2. 加载和保存模型参数
若想保存整个模型,并在以后加载它们,单独保存每个向量则会变得很麻烦。 毕竟,我们可能有数百个参数散布在各处。 因此,
- 深度学习框架提供了内置函数来保存和加载整个网络。
- 注意,保存的是 模型的参数,而不是保存整个模型。
例如,有一个3层多层感知机,我们需要单独指定架构。因为模型本身可以包含任意代码,所以模型本身难以序列化。 因此,恢复模型需要用代码生成架构,然后从磁盘加载参数。
下面以多层感知机为例:
class MLP(nn.Module):def __init__(self):super().__init__()self.hidden = nn.Linear(20, 256)self.output = nn.Linear(256, 10)def forward(self, x):return self.output(F.relu(self.hidden(x)))net = MLP()
X = torch.randn(size=(2, 20))
Y = net(X)
接下来,将模型的参数存储在一个叫做“mlp.params”的文件中:
torch.save(net.state_dict(), 'outputFile/mlp.params')
为了恢复模型,我们实例化了原始多层感知机模型的一个备份。这里不需要随机初始化模型参数,而是直接读取文件中存储的参数:
clone = MLP()
print(f"模型:\n{clone}")
# 直接读取文件中存储的参数
# clone.load_state_dict(...) 将加载的状态字典应用到clone中
clone.load_state_dict(torch.load('outputFile/mlp.params', weights_only=False))
clone.eval() # 设置模型为评估模式
由于两个实例具有相同的模型参数,在输入相同的X
时, 两个实例的计算结果应该相同。 下面进行验证:
Y_clone = clone(X)
print(f"副本与读上来版本的异同:\n{Y_clone == Y}")
小结
save
和load
函数可用于张量对象的文件读写。我们可以通过参数字典保存和加载网络的全部参数。
保存架构必须在代码中完成,而不是在参数中完成。
6. GPU
下载NVIDIA驱动和CUDA 并按照提示设置适当的路径。然后使用nvidia-smi
命令来查看显卡信息:
!nvidia-smi
在PyTorch中,每个数组都有一个设备(device),通常将其称为环境(context)。 默认情况下,所有变量和相关的计算都分配给CPU。有时环境可能是GPU。当跨多个服务器部署作业时,通过智能地将数组分配给环境,可以最大限度地减少在设备之间传输数据的时间。 例如,当在带有GPU的服务器上训练神经网络时, 我们通常希望模型的参数在GPU上。
首先确认电脑上是否有GPU,可以使用nvidia-smi查看;
如果有的话,可以先安装显卡驱动,然后安装pytorch的GPU版本就行。
6.1. 计算设备
可以指定用于存储和计算的设备,如CPU和GPU。
- 默认情况下,张量是在内存中创建的,然后使用CPU计算它。
在PyTorch中,CPU和GPU可以用torch.device('cpu')
和torch.device('cuda')
表示。注意:
cpu
设备意味着所有物理CPU和内存,这意味着PyTorch的计算将尝试使用所有CPU核心。gpu
设备只代表一个卡和相应的显存。- 若有多个GPU,使用
torch.device(f'cuda:{i}')
来表示第 i 块GPU(i从0开始)。
- 若有多个GPU,使用
cuda:0
与cuda
是等价。
import torch
from torch import nnprint(f"{torch.device('cpu'), torch.device('cuda'), torch.device('cuda:1')}")
print(f"CPU:{torch.device('cpu')}")
print(f"GPU:{torch.device('cuda')}")
print(f"第1块GPU:{torch.device('cuda:1')}")
可以查询可用gpu的数量:
print(f"GPU的数量:{torch.cuda.device_count()}")
现在定义两个方便的函数:允许我们在不存在所需所有GPU的情况下运行代码:
def try_gpu(i=0): #@save"""如果存在,则返回gpu(i),否则返回cpu()"""if torch.cuda.device_count() >= i + 1: # 如果存在第 i 个 GPUreturn torch.device(f'cuda:{i}') # 返回第 i 个 GPU 设备return torch.device('cpu') # 若系统中无足够的GPU设备(即GPU数量<i+1),则返回CPU设备def try_all_gpus(): #@save"""返回所有可用的GPU,如果没有GPU,则返回[cpu(),]"""devices = [torch.device(f'cuda:{i}')for i in range(torch.cuda.device_count())]# 如果存在可用的 GPU,则返回一个包含所有 GPU 设备的列表return devices if devices else [torch.device('cpu')]print(f"{try_gpu(), try_gpu(10), try_all_gpus()}")
print(f"默认尝试返回第1个GPU设备:{try_gpu()}")
print(f"尝试返回第11个GPU设备:{try_gpu(10)}")
print(f"返回所有可用的GPU:{try_all_gpus()}")
6.2. 张量与GPU
可以查询张量所在的设备。 默认情况下,张量是在CPU上创建的。
x = torch.tensor([1, 2, 3])
print(f"{x.device}")
注意:无论何时我们要对多个项进行操作,它们都必须在同一个设备上。 例如,要确保求和的两个张量都位于同一设备上,否则框架将不知道在哪里存储结果,甚至不知道在哪里执行计算。
6.2.1. 存储在GPU上
有几种方法可以在GPU上存储张量。 例如,在创建张量的同时指定存储设备。接下来,我们在第一个gpu
上创建张量变量X
。 在GPU上创建的张量只消耗这个GPU的显存。可以使用nvidia-smi
命令查看显存使用情况。一般来说,我们需要确保不创建超过GPU显存限制的数据。
X = torch.ones(2, 3, device=try_gpu())
X
假设至少有两个GPU,下面的代码将在第二个GPU上创建一个随机张量:
Y = torch.rand(2, 3, device=try_gpu(1))
Y
6.2.2. 复制
若要计算 X+Y
,则需要决定在哪里执行该操作。 例如,如 图5.6.1所示,
- 可以将
X
传输到第二个GPU并在那里执行操作。 - 不要 简单地
X
加上Y
,因为这会导致异常,运行时引擎不知道该怎么做:它在同一设备上找不到数据会导致失败。 - 由于
Y
位于第二个GPU上,所以需要将X
移到那里, 然后才能执行相加运算。
Z = X.cuda(1)
print(X)
print(Z)
现在数据在同一个GPU上(Z
和Y
都在),可以将它们相:
Y + Z
假设变量Z
已经存在于第二个GPU上。如果我们还是调用Z.cuda(1)
会发生什么? 它将返回Z
,而不会复制并分配新内存。
Z.cuda(1) is Z
6.2.3. 旁注
- 人们使用GPU来进行机器学习,因为单个GPU相对运行速度快。
- 但是,在设备(CPU、GPU和其他机器)之间传输数据比计算慢得多。这也使得并行化变得更加困难。
- 因为我们必须等待数据被发送(或者接收), 然后才能继续进行更多的操作。这就是为什么拷贝操作要格外小心。根据经验,多个小操作比一个大操作糟糕得多。
- 此外,一次执行几个操作比代码中散布的许多单个操作要好得多。如果一个设备必须等待另一个设备才能执行其他操作,那么这样的操作可能会阻塞。类似排队订购咖啡,而不像通过电话预先订购:当客人到店的时候,咖啡已经准备好了。
最后,当我们打印张量或将张量转换为NumPy格式时, 如果数据不在内存中,框架会首先将其复制到内存中, 这会导致额外的传输开销。更糟糕的是,它现在受制于全局解释器锁,使得一切都得等待Python完成。
6.3. 神经网络与GPU
类似地,神经网络模型可以指定设备。下面的代码将模型参数放在GPU上。
net = nn.Sequential(nn.Linear(3, 1))
net = net.to(device=try_gpu())
# 或 net = net.to(torch.device('cuda:0')) # 将模型移动到GPU上
接下来会有更多关于如何在GPU上运行模型的例子, 因为它们将变得更加计算密集。
当输入为GPU上的张量时,模型将在同一GPU上计算结果。
net(X)
确认模型参数存储在同一个GPU上:
net[0].weight.data.device
总之,只要所有的数据和参数都在同一个设备上,就可以有效地学习模型。
小结
可以指定用于存储和计算的设备,例如CPU或GPU。默认情况下,数据在主内存中创建,然后使用CPU进行计算。
深度学习框架要求计算的所有输入数据都在同一设备上,无论是CPU还是GPU。
不经意地移动数据可能会显著降低性能。一个典型的错误如下:计算GPU上每个小批量的损失,并在命令行中将其报告给用户(或将其记录在NumPy
ndarray
中)时,将触发全局解释器锁,从而使所有GPU阻塞。最好是为GPU内部的日志分配内存,并且只移动较大的日志。
👉【卷积神经网络】
1. 从全连接层到卷积
卷积神经网络(convolutional neural networks,CNN)是机器学习利用自然图像中一些已知结构的创造性方法。
1.1. 不变性
假设需要从一张图片中找到某个物体:儿童游戏”沃尔多在哪里”。
沃尔多的样子并不取决于他潜藏的地方,因此
- 可以使用一个“沃尔多检测器”扫描图像。
- 该检测器将图像分割成多个区域,并为每个区域包含沃尔多的可能性打分。
- 卷积神经网络正是将空间不变性(spatial invariance)的这一概念系统化,从而基于这个模型使用较少的参数来学习有用的表示。

总结就是:
-
平移不变性(translation invariance):不管检测对象出现在图像中的哪个位置,神经网络的前面几层应该对相同的图像区域具有相似的反应,即为“平移不变性”。
-
局部性(locality):神经网络的前面几层应该只探索输入图像中的局部区域,而不过度在意图像中相隔较远区域的关系,这就是“局部性”原则。最终,可以聚合这些局部特征,以在整个图像级别进行预测。
1.2. 多层感知机的限制
首先,多层感知机的
- 输入是二维图像X,
- 其隐藏表示H在数学上是一个矩阵,在代码中表示为二维张量。
- 其中X和H具有相同的形状。
- 为了方便理解,我们可以认为,无论是输入还是隐藏表示都拥有空间结构。
使用 和
分别表示输入图像和隐藏表示中位置(i,j)处的像素。 为了使每个隐藏神经元都能接收到每个输入像素的信息,我们将参数从权重矩阵(如同我们先前在多层感知机中所做的那样)替换为四阶权重张量W。假设U包含偏置参数,我们可以将全连接层形式化地表示为
其中,从 W 到 V 的转换只是形式上的转换,因为在这两个四阶张量的元素之间存在一一对应的关系 我们只需重新索引下标 ,使
、
,由此可得
。 索引a和b通过在正偏移和负偏移之间移动覆盖了整个图像。 对于隐藏表示中任意给定位置(i,j)处的像素值
,可以通过在x中以(i,j)为中心对像素进行加权求和得到,加权使用的权重为
。
1.2.1. 平移不变性
现在引用上述的第一个原则:平移不变性。 这意味着检测对象在输入X中的平移,应该仅导致隐藏表示H中的平移。也就是说,V和U实际上不依赖于(i,j)的值,即 。并且U是一个常数,比如u。因此,我们可以简化H定义为:
这就是卷积(convolution)。我们是在使用系数 对位置(i,j)附近的像素
进行加权得到
。注意,
的系数比
少很多,因为前者不再依赖于图像中的位置。这就是显著的进步!
1.2.2. 局部性
现在引用上述的第二个原则:局部性。如上所述,为了收集用来训练参数 的相关信息,我们不应偏离到距 (i,j) 很远的地方。这意味着在
或
的范围之外,我们可以设置
。因此,我们可以将
重写为
简而言之, (6.1.3)是一个卷积层 (convolutional layer),而卷积神经网络是包含卷积层的一类特殊的神经网络。在深度学习研究社区中,V被称为卷积核(convolution kernel)或者滤波器(filter),亦或简单地称之为该卷积层的权重,通常该权重是可学习的参数。 当图像处理的局部区域很小时,卷积神经网络与多层感知机的训练差异可能是巨大的:以前,多层感知机可能需要数十亿个参数来表示网络中的一层,而现在卷积神经网络通常只需要几百个参数,而且不需要改变输入或隐藏表示的维数。 参数大幅减少的代价是,我们的特征现在是平移不变的,并且当确定每个隐藏活性值时,每一层只包含局部的信息。 以上所有的权重学习都将依赖于归纳偏置。当这种偏置与现实相符时,我们就能得到样本有效的模型,并且这些模型能很好地泛化到未知数据中。 但如果这偏置与现实不符时,比如当图像不满足平移不变时,我们的模型可能难以拟合我们的训练数据。
1.3. 卷积
这看起来类似于 (6.1.3),但有一个主要区别:这里不是使用 ,而是使用差值。然而,这种区别是表面的,因为我们总是可以匹配 (6.1.3)和 (6.1.6)之间的符号。我们在 (6.1.3)中的原始定义更正确地描述了互相关(cross-correlation),这个问题将在下一节中讨论。
1.4. “沃尔多在哪里”回顾
回到上面的“沃尔多在哪里”游戏,让我们看看它到底是什么样子。卷积层根据滤波器V选取给定大小的窗口,并加权处理图片,如 图6.1.2中所示。我们的目标是学习一个模型,以便探测出在“沃尔多”最可能出现的地方。

1.4.1. 通道
然而这种方法有一个问题:我们忽略了图像一般包含三个通道/三种原色(红色、绿色和蓝色)。 实际上,图像不是二维张量,而是一个由高度、宽度和颜色组成的三维张量,比如包含1024×1024×3个像素。 前两个轴与像素的空间位置有关,而第三个轴可以看作每个像素的多维表示。 因此,我们将X索引为 。由此卷积相应地调整为
,而不是
。
此外,由于输入图像是三维的,我们的隐藏表示H也最好采用三维张量。 换句话说,对于每一个空间位置,我们想要采用一组而不是一个隐藏表示。这样一组隐藏表示可以想象成一些互相堆叠的二维网格。 因此,我们可以把隐藏表示想象为一系列具有二维张量的通道(channel)。 这些通道有时也被称为特征映射(feature maps),因为每个通道都向后续层提供一组空间化的学习特征。 直观上可以想象在靠近输入的底层,一些通道专门识别边缘,而一些通道专门识别纹理。
为了支持输入X和隐藏表示H中的多个通道,我们可以在V中添加第四个坐标,即 。综上所述,
其中隐藏表示H中的索引d表示输出通道,而随后的输出将继续以三维张量H作为输入进入下一个卷积层。 所以, (6.1.7)可以定义具有多个通道的卷积层,而其中V是该卷积层的权重。
然而,仍有许多问题亟待解决。 例如,图像中是否到处都有存在沃尔多的可能?如何有效地计算输出层?如何选择适当的激活函数?为了训练有效的网络,如何做出合理的网络设计选择?我们将在本章的其它部分讨论这些问题。
1.5. 小结
图像的平移不变性使我们以相同的方式处理局部图像,而不在乎它的位置。
局部性意味着计算相应的隐藏表示只需一小部分局部图像像素。
在图像处理中,卷积层通常比全连接层需要更少的参数,但依旧获得高效用的模型。
卷积神经网络(CNN)是一类特殊的神经网络,它可以包含多个卷积层。
多个输入和输出通道使模型在每个空间位置可以获取图像的多方面特征。
2. 图像卷积
2.1. 互相关运算
2.2. 卷积层
2.3. 图像中目标的边缘检测
2.4. 学习卷积核
2.5. 互相关和卷积
2.6. 特征映射和感受野
2.7. 小结
二维卷积层的核心计算是二维互相关运算。最简单的形式是,对二维输入数据和卷积核执行互相关操作,然后添加一个偏置。
我们可以设计一个卷积核来检测图像的边缘。
我们可以从数据中学习卷积核的参数。
学习卷积核时,无论用严格卷积运算或互相关运算,卷积层的输出不会受太大影响。
当需要检测输入特征中更广区域时,我们可以构建一个更深的卷积网络。
3. 填充和步幅
3.1. 填充
3.2. 步幅
3.3. 小结
填充可以增加输出的高度和宽度。这常用来使输出与输入具有相同的高和宽。
步幅可以减小输出的高和宽,例如输出的高和宽仅为输入的高和宽的1/n(n是一个大于1的整数)。
填充和步幅可用于有效地调整数据的维度。
4. 多输入多输出通道
4.1. 多输入通道
4.2. 多输出通道
4.3. 1×1 卷积层
4.4. 小结
多输入多输出通道可以用来扩展卷积层的模型。
当以每像素为基础应用时,1×1卷积层相当于全连接层。
1×1卷积层通常用于调整网络层的通道数量和控制模型复杂性。
5. 汇聚层
5.1. 最大汇聚层和平均汇聚层
5.2. 填充和步幅
5.3. 多个通道
5.4. 小结
对于给定输入元素,最大汇聚层会输出该窗口内的最大值,平均汇聚层会输出该窗口内的平均值。
汇聚层的主要优点之一是减轻卷积层对位置的过度敏感。
我们可以指定汇聚层的填充和步幅。
使用最大汇聚层以及大于1的步幅,可减少空间维度(如高度和宽度)。
汇聚层的输出通道数与输入通道数相同。
6. 卷积神经网络(LeNet)
6.1. LeNet
6.2. 模型训练
6.3. 小结
卷积神经网络(CNN)是一类使用卷积层的网络。
在卷积神经网络中,我们组合使用卷积层、非线性激活函数和汇聚层。
为了构造高性能的卷积神经网络,我们通常对卷积层进行排列,逐渐降低其表示的空间分辨率,同时增加通道数。
在传统的卷积神经网络中,卷积块编码得到的表征在输出之前需由一个或多个全连接层进行处理。
LeNet是最早发布的卷积神经网络之一。