D2L学习笔记-PyTorch神经网络基础

  1. 1. 层和块
  2. 2. 参数管理
  3. 3. 自定义层
  4. 4. 读写文件
  5. 5. GPU

李沐动手学深度学习(PyTorch)课程学习笔记第四章:PyTorch 神经网络基础。

1. 层和块

在构造自定义块之前,我们先回顾一下多层感知机(第三章第二节)的代码,下面的代码生成一个网络,其中包含一个具有256个单元和 ReLU 激活函数的全连接隐藏层,然后是一个具有10个隐藏单元且不带激活函数的全连接输出层:

1
2
3
4
5
6
7
8
import torch
from torch import nn
from torch.nn import functional as F

net = nn.Sequential(nn.Linear(20, 256), nn.ReLU(), nn.Linear(256, 10))

X = torch.rand(2, 20)
print(net(X).shape) # torch.Size([2, 10])

在这个例子中,我们通过实例化 nn.Sequential 来构建我们的模型,层的执行顺序是作为参数传递的。简而言之,nn.Sequential 定义了一种特殊的 Module,即在 PyTorch 中表示一个块的类,它维护了一个由 Module 组成的有序列表。注意,两个全连接层都是 Linear 类的实例,Linear 类本身就是 Module 的子类。另外,到目前为止,我们一直在通过 net(X) 调用我们的模型来获得模型的输出。这实际上是 net.__call__(X) 的简写。这个前向传播函数非常简单:它将列表中的每个块连接在一起,将每个块的输出作为下一个块的输入。

在下面的代码片段中,我们从零开始编写一个块。它包含一个多层感知机,其具有256个隐藏单元的隐藏层和一个10维输出层。注意,下面的 MLP 类继承了表示块的类。我们的实现只需要提供我们自己的构造函数(Python 中的 __init__ 函数)和前向传播函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
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)))

接着我们实例化多层感知机的层,然后在每次调用前向传播函数时调用这些层:

1
2
net = MLP()
print(net(X).shape) # torch.Size([2, 10])

现在我们可以更仔细地看看 Sequential 类是如何工作的,回想一下 Sequential 的设计是为了把其他模块串起来。为了构建我们自己的简化的 MySequential,我们只需要定义两个关键函数:

  • 一种将块逐个追加到列表中的函数;
  • 一种前向传播函数,用于将输入按追加块的顺序传递给块组成的“链条”。

下面的 MySequential 类提供了与默认 Sequential 类相同的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class MySequential(nn.Module):
def __init__(self, *args):
super().__init__()
for idx, module in enumerate(args):
# 这里,module是Module子类的一个实例,我们把它保存在'Module'类的成员
# 变量_modules中,_module的类型是OrderedDict
self._modules[str(idx)] = module

def forward(self, X):
# OrderedDict保证了按照成员添加的顺序遍历它们
for block in self._modules.values():
X = block(X)
return X

net = MySequential(nn.Linear(20, 256), nn.ReLU(), nn.Linear(256, 10))
print(net(X).shape) # torch.Size([2, 10])

Sequential 类使模型构造变得简单,允许我们组合新的架构,而不必定义自己的类。然而,并不是所有的架构都是简单的顺序架构。当需要更强的灵活性时,我们需要定义自己的块。例如,我们可能希望在前向传播函数中执行 Python 的控制流。此外,我们可能希望执行任意的数学运算,而不是简单地依赖预定义的神经网络层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 /= 2
return X.sum()

我们可以混合搭配各种组合块的方法。在下面的例子中,我们以一些想到的方法嵌套块:

1
2
3
4
5
6
7
8
9
10
11
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(chimera(X)) # tensor(-0.2911, grad_fn=<SumBackward0>)

2. 参数管理

我们首先看一下具有单隐藏层的多层感知机:

1
2
3
4
5
6
import torch
from torch import nn

net = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 1))
X = torch.rand(size=(2, 4))
print(net(X).shape) # torch.Size([2, 1])

我们从已有模型中访问参数。当通过 Sequential 类定义模型时,我们可以通过索引来访问模型的任意层。这就像模型是一个列表一样,每层的参数都在其属性中。如下所示,我们可以检查第二个全连接层的参数:

1
2
print(net[2].state_dict())
# OrderedDict([('weight', tensor([[-0.1326, 0.1692, -0.0925, -0.1721, 0.0828, -0.0890, -0.0742, -0.2730]])), ('bias', tensor([0.2011]))])

这个全连接层包含两个参数,分别是该层的权重和偏置,每个参数都表示为参数类的一个实例。要对参数执行任何操作,首先我们需要访问底层的数值:

1
2
3
print(type(net[2].bias))  # <class 'torch.nn.parameter.Parameter'>
print(net[2].bias) # Parameter containing: tensor([-0.2786], requires_grad=True)
print(net[2].bias.data) # tensor([-0.1571])

参数是复合的对象,包含值、梯度和额外信息。这就是我们需要显式参数值的原因。除了值之外,我们还可以访问每个参数的梯度。在上面这个网络中,由于我们还没有调用反向传播,所以参数的梯度处于初始状态:

1
print(net[2].weight.grad == None)  # True

当我们需要对所有参数执行操作时,逐个访问它们可能会很麻烦。下面,我们将通过演示来比较访问第一个全连接层的参数和访问所有层:

1
2
3
4
print(*[(name, param.shape) for name, param in net[0].named_parameters()])
# ('weight', torch.Size([8, 4])) ('bias', torch.Size([8]))
print(*[(name, param.shape) for name, param in net.named_parameters()])
# ('0.weight', torch.Size([8, 4])) ('0.bias', torch.Size([8])) ('2.weight', torch.Size([1, 8])) ('2.bias', torch.Size([1]))

这为我们提供了另一种访问网络参数的方式,如下所示:

1
print(net.state_dict()['2.bias'].data)  # tensor([0.0992])

现在让我们看看如果我们将多个块相互嵌套,参数命名约定是如何工作的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 net

rgnet = nn.Sequential(block2(), nn.Linear(4, 1))
print(rgnet)
# Sequential(
# (0): Sequential(
# (block 0): Sequential(
# (0): Linear(in_features=4, out_features=8, bias=True)
# (1): ReLU()
# ...

因为层是分层嵌套的,所以我们也可以像通过嵌套列表索引一样访问它们:

1
print(rgnet[0][1][0].bias.data)  # tensor([ 0.4102,  0.1565,  0.1458,  0.0826,  0.2460, -0.0115, -0.4241,  0.1192])

知道了如何访问参数后,现在我们看看如何正确地初始化参数。深度学习框架提供默认随机初始化,也允许我们创建自定义初始化方法。

让我们首先调用内置的初始化器。下面的代码将所有权重参数初始化为标准差为0.01的高斯随机变量,且将偏置参数设置为0:

1
2
3
4
5
6
7
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(net[0].weight.data[0], net[0].bias.data[0]) # tensor([0.0037, 0.0052, 0.0020, 0.0028]) tensor(0.)

我们还可以将所有参数初始化为给定的常数,比如初始化为1:

1
nn.init.constant_(m.weight, 1)

我们还可以对某些块应用不同的初始化方法。例如,下面我们使用 Xavier 初始化方法初始化第一个神经网络层,然后将第三个神经网络层初始化为常量值42:

1
2
3
4
5
6
7
8
9
10
11
12
13
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)
net[2].apply(init_42)

print(net[0].weight.data[0]) # tensor([-0.7014, 0.1061, 0.2061, 0.2125])
print(net[2].weight.data) # tensor([[42., 42., 42., 42., 42., 42., 42., 42.]])

有时,深度学习框架没有提供我们需要的初始化方法。在下面的例子中,我们先初始化为-10~10的均匀分布,然后将绝对值小于5的参数置零:

1
2
3
4
5
6
7
8
9
10
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)
m.weight.data *= m.weight.data.abs() >= 5

net.apply(my_init)
print(net[0].weight[:2])
# tensor([[-0.0000, 8.9053, 0.0000, 8.9382],
# [-9.5017, -0.0000, 5.4470, -0.0000]], grad_fn=<SliceBackward0>)

有时我们希望在多个层间共享参数:我们可以定义一个稠密层,然后使用它的参数来设置另一个层的参数:

1
2
3
4
5
6
7
8
shared = nn.Linear(8, 8)  # 我们需要给共享层一个名称,以便可以引用它的参数
net = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), shared, nn.ReLU(),
shared, nn.ReLU(), nn.Linear(8, 1))
# 检查参数是否相同
print(net[2].weight.data[0] == net[4].weight.data[0]) # tensor([True, True, True, True, True, True, True, True])
net[2].weight.data[0, 0] = 100
# 确保它们实际上是同一个对象,而不只是有相同的值
print(net[2].weight.data[0] == net[4].weight.data[0]) # tensor([True, True, True, True, True, True, True, True])

这个例子表明第三个和第五个神经网络层的参数是绑定的。它们不仅值相等,而且由相同的张量表示。因此,如果我们改变其中一个参数,另一个参数也会改变。这里有一个问题:当参数绑定时,梯度会发生什么情况?答案是由于模型参数包含梯度,因此在反向传播期间第二个隐藏层(即第三个神经网络层)和第三个隐藏层(即第五个神经网络层)的梯度会加在一起。

3. 自定义层

我们构造一个没有任何参数的自定义层,下面的 CenteredLayer 类要从其输入中减去均值。要构建它,我们只需继承基础层类并实现前向传播功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
import torch
import torch.nn.functional as F
from torch import nn

class CenteredLayer(nn.Module):
def __init__(self):
super().__init__()

def forward(self, X):
return X - X.mean()

layer = CenteredLayer()
print(layer(torch.FloatTensor([1, 2, 3, 4, 5]))) # tensor([-2., -1., 0., 1., 2.])

下面我们继续定义具有参数的层,这些参数可以通过训练进行调整。让我们实现自定义版本的全连接层。回想一下,该层需要两个参数,一个用于表示权重,另一个用于表示偏置项。在此实现中,我们使用 ReLU 作为激活函数。该层需要输入参数:in_unitsunits,分别表示输入数和输出数:

1
2
3
4
5
6
7
8
9
10
11
12
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.data
return F.relu(linear)

linear = MyLinear(5, 3)
print(linear.weight.shape) # torch.Size([5, 3])

4. 读写文件

加载和保存张量:

1
2
3
4
5
6
import torch
from torch import nn
from torch.nn import functional as F

x = torch.arange(4)
torch.save(x, '../save/x-file')

将存储在文件中的数据读回内存:

1
2
x2 = torch.load('../save/x-file')
print(x2) # tensor([0, 1, 2, 3])

存储一个张量列表,然后把它们读回内存:

1
2
3
4
y = torch.zeros(4)
torch.save([x, y],'../save/x-files')
x2, y2 = torch.load('../save/x-files')
print((x2, y2)) # (tensor([0, 1, 2, 3]), tensor([0., 0., 0., 0.]))

我们可以写入或读取从字符串映射到张量的字典。当我们要读取或写入模型中的所有权重时,这很方便:

1
2
3
4
mydict = {'x': x, 'y': y}
torch.save(mydict, '../save/mydict')
mydict2 = torch.load('../save/mydict')
print(mydict2) # {'x': tensor([0, 1, 2, 3]), 'y': tensor([0., 0., 0., 0.])}

深度学习框架提供了内置函数来保存和加载整个网络。需要注意的一个重要细节是,这将保存模型的参数而不是保存整个模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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)

torch.save(net.state_dict(), '../save/mlp.params')

# 为了恢复模型,我们实例化了原始多层感知机模型的一个备份。这里我们不需要随机初始化模型参数,而是直接读取文件中存储的参数
clone = MLP()
clone.load_state_dict(torch.load('../save/mlp.params'))
clone.eval()
Y_clone = clone(X)
print(Y_clone == Y)
# tensor([[True, True, True, True, True, True, True, True, True, True],
# [True, True, True, True, True, True, True, True, True, True]])

5. GPU

CUDA 的安装配置教程:Anaconda与PyTorch安装教程

在 PyTorch 中,CPU 和 GPU 可以用 torch.device('cpu')torch.device('cuda') 表示:

1
2
3
4
import torch
from torch import nn

print(torch.device('cpu'), torch.device('cuda'), torch.device('cuda:0')) # cpu cuda cuda:0

我们可以查询可用 GPU 的数量:

1
print(torch.cuda.device_count())  # 1

我们可以查询张量所在的设备。默认情况下,张量是在 CPU 上创建的:

1
2
x = torch.tensor([1, 2, 3])
print(x.device) # cpu

需要注意的是,无论何时我们要对多个项进行操作,它们都必须在同一个设备上。例如,如果我们对两个张量求和,我们需要确保两个张量都位于同一个设备上,否则框架将不知道在哪里存储结果,甚至不知道在哪里执行计算。

有几种方法可以在 GPU 上存储张量。例如,我们可以在创建张量时指定存储设备:

1
2
X = torch.ones(2, 3, device='cuda')
print(X.device) # cuda:0

数据在同一个 GPU 上我们才可以将它们相加:

1
2
3
Y = X.cuda(0)
print(Y.device) # cuda:0
print((X + Y).device) # cuda:0

类似地,神经网络模型可以指定设备。下面的代码将模型参数放在 GPU 上:

1
2
3
4
5
net = nn.Sequential(nn.Linear(3, 1))
net = net.to(device='cuda')

print(net(X)) # tensor([[-0.0370], [-0.0370]], device='cuda:0', grad_fn=<AddmmBackward0>)
print(net[0].weight.data.device) # cuda:0

总之,只要所有的数据和参数都在同一个设备上,我们就可以有效地学习模型。

下一章:卷积神经网络