返回介绍

4 神经网络工具箱 nn

发布于 2024-01-28 10:28:54 字数 38589 浏览 0 评论 0 收藏 0

上一章中提到,使用 autograd 可实现深度学习模型,但其抽象程度较低,如果用其来实现深度学习模型,则需要编写的代码量极大。在这种情况下,torch.nn 应运而生,其是专门为深度学习而设计的模块。torch.nn 的核心数据结构是 Module ,它是一个抽象概念,既可以表示神经网络中的某个层(layer),也可以表示一个包含很多层的神经网络。在实际使用中,最常见的做法是继承 nn.Module ,撰写自己的网络/层。下面先来看看如何用 nn.Module 实现自己的全连接层。全连接层,又名仿射层,输出 $\textbf{y}$ 和输入$\textbf{x}$ 满足 $\textbf{y=Wx+b}$,$\textbf{W}$ 和 $\textbf{b}$ 是可学习的参数。

import torch as t
from torch import nn
from torch.autograd import Variable as V
class Linear(nn.Module): # 继承 nn.Module
    def __init__(self, in_features, out_features):
        super(Linear, self).__init__() # 等价于 nn.Module.__init__(self)
        self.w = nn.Parameter(t.randn(in_features, out_features))
        self.b = nn.Parameter(t.randn(out_features))

    def forward(self, x):
        x = x.mm(self.w) # x.@(self.w)
        return x + self.b.expand_as(x)
layer = Linear(4,3)
input = V(t.randn(2,4))
output = layer(input)
output
Variable containing:
 0.6614  2.4618  1.6848
 1.7110  2.8197 -1.7891
[torch.FloatTensor of size 2x3]
for name, parameter in layer.named_parameters():
    print(name, parameter) # w and b 
w Parameter containing:
 0.7730  0.1062 -1.4568
-0.0182  0.3505  1.9311
-0.6398 -1.5122  0.5403
 0.1200 -0.3439  0.3741
[torch.FloatTensor of size 4x3]

b Parameter containing:
 0.4206
 1.5090
 1.1140
[torch.FloatTensor of size 3]

可见,全连接层的实现非常简单,其代码量不超过 10 行,但需注意以下几点:

  • 自定义层 Linear 必须继承 nn.Module ,并且在其构造函数中需调用 nn.Module 的构造函数,即 super(Linear, self).__init__()nn.Module.__init__(self) ,推荐使用第一种用法,尽管第二种写法更直观。
  • 在构造函数 __init__ 中必须自己定义可学习的参数,并封装成 Parameter ,如在本例中我们把 wb 封装成 parameterparameter 是一种特殊的 Variable ,但其默认需要求导(requires_grad = True),感兴趣的读者可以通过 nn.Parameter?? ,查看 Parameter 类的源代码。
  • forward 函数实现前向传播过程,其输入可以是一个或多个 variable,对 x 的任何操作也必须是 variable 支持的操作。
  • 无需写反向传播函数,因其前向传播都是对 variable 进行操作,nn.Module 能够利用 autograd 自动实现反向传播,这点比 Function 简单许多。
  • 使用时,直观上可将 layer 看成数学概念中的函数,调用 layer(input) 即可得到 input 对应的结果。它等价于 layers.__call__(input) ,在 __call__ 函数中,主要调用的是 layer.forward(x) ,另外还对钩子做了一些处理。所以在实际使用中应尽量使用 layer(x) 而不是使用 layer.forward(x) ,关于钩子技术将在下文讲解。
  • Module 中的可学习参数可以通过 named_parameters() 或者 parameters() 返回迭代器,前者会给每个 parameter 都附上名字,使其更具有辨识度。

可见利用 Module 实现的全连接层,比利用 Function 实现的更为简单,因其不再需要写反向传播函数。

Module 能够自动检测到自己的 Parameter ,并将其作为学习参数。除了 parameter 之外,Module 还包含子 Module ,主 Module 能够递归查找子 Module 中的 parameter 。下面再来看看稍微复杂一点的网络,多层感知机。

多层感知机的网络结构如图 4-1 所示,它由两个全连接层组成,采用$sigmoid$函数作为激活函数,图中没有画出。

图 4-1;多层感知机

class Perceptron(nn.Module):
    def __init__(self, in_features, hidden_features, out_features):
        nn.Module.__init__(self)
        self.layer1 = Linear(in_features, hidden_features) # 此处的 Linear 是前面自定义的全连接层
        self.layer2 = Linear(hidden_features, out_features)
    def forward(self,x):
        x = self.layer1(x)
        x = t.sigmoid(x)
        return self.layer2(x)
perceptron = Perceptron(3,4,1)
for name, param in perceptron.named_parameters():
    print(name, param.size())
layer1.w torch.Size([3, 4])
layer1.b torch.Size([4])
layer2.w torch.Size([4, 1])
layer2.b torch.Size([1])

可见,即使是稍复杂的多层感知机,其实现依旧很简单。这里新增两个知识点:

  • 构造函数 __init__ 中,可利用前面自定义的 Linear 层(module),作为当前 module 对象的一个子 module,它的可学习参数,也会成为当前 module 的可学习参数。
  • 在前向传播函数中,我们有意识地将输出变量都命名成 x ,是为了能让 Python 回收一些中间层的输出,从而节省内存。但并不是所有都会被回收,有些 variable 虽然名字被覆盖,但其在反向传播仍需要用到,此时 Python 的内存回收模块将通过检查引用计数,不会回收这一部分内存。

module 中 parameter 的命名规范:

  • 对于类似 self.param_name = nn.Parameter(t.randn(3, 4)) ,命名为 param_name
  • 对于子 Module 中的 parameter,会其名字之前加上当前 Module 的名字。如对于 self.sub_module = SubModel() ,SubModel 中有个 parameter 的名字叫做 param_name,那么二者拼接而成的 parameter name 就是 sub_module.param_name

为方便用户使用,PyTorch 实现了神经网络中绝大多数的 layer,这些 layer 都继承于 nn.Module,封装了可学习参数 parameter ,并实现了 forward 函数,且很多都专门针对 GPU 运算进行了 CuDNN 优化,其速度和性能都十分优异。本书不准备对 nn.Module 中的所有层进行详细介绍,具体内容读者可参照官方文档 ^1 或在 IPython/Jupyter 中使用 nn.layer?来查看。阅读文档时应主要关注以下几点:

  • 构造函数的参数,如 nn.Linear(infeatures, outfeatures, bias),需关注这三个参数的作用。
  • 属性,可学习参数,子 module。如 nn.Linear 中有 weightbias 两个可学习参数,不包含子 module。
  • 输入输出的形状,如 nn.linear 的输入形状是(N, inputfeatures),输出为(N,outputfeatures),N 是 batch_size。

这些自定义 layer 对输入形状都有假设:输入的不是单个数据,而是一个 batch。若想输入一个数据,则必须调用 unsqueeze(0) 函数将数据伪装成 batch_size=1 的 batch

下面将从应用层面出发,对一些常用的 layer 做简单介绍,更详细的用法请查看文档,这里只作概览参考。

4.1 常用神经网络层

4.1.1 图像相关层

图像相关层主要包括卷积层(Conv)、池化层(Pool)等,这些层在实际使用中可分为一维(1D)、二维(2D)、三维(3D),池化方式又分为平均池化(AvgPool)、最大值池化(MaxPool)、自适应池化(AdaptiveAvgPool)等。而卷积层除了常用的前向卷积之外,还有逆卷积(TransposeConv)。下面举例说明一些基础的使用。

from PIL import Image
from torchvision.transforms import ToTensor, ToPILImage
to_tensor = ToTensor() # img -> tensor
to_pil = ToPILImage()
lena = Image.open('lena.png')
lena

<PIL.PngImagePlugin.PngImageFile image mode=L size=200x200 at 0x7F076FF0BE10>
# 输入是一个 batch,batch_size=1
input = to_tensor(lena).unsqueeze(0) 

# 锐化卷积核
kernel = t.ones(3, 3)/-9.
kernel[1][1] = 1
conv = nn.Conv2d(1, 1, (3, 3), 1, bias=False)
conv.weight.data = kernel.view(1, 1, 3, 3)

out = conv(V(input))
to_pil(out.data.squeeze(0))

<PIL.Image.Image image mode=L size=198x198 at 0x7F0859E27E48>

除了上述的使用,图像的卷积操作还有各种变体,具体可以参照此处动图 ^2 介绍。 池化层可以看作是一种特殊的卷积层,用来下采样。但池化层没有可学习参数,其 weight 是固定的。

pool = nn.AvgPool2d(2,2)
list(pool.parameters())
[]
out = pool(V(input))
to_pil(out.data.squeeze(0))

<PIL.Image.Image image mode=L size=100x100 at 0x7F076E896A58>

除了卷积层和池化层,深度学习中还将常用到以下几个层:

  • Linear:全连接层。
  • BatchNorm:批规范化层,分为 1D、2D 和 3D。除了标准的 BatchNorm 之外,还有在风格迁移中常用到的 InstanceNorm 层。
  • Dropout:dropout 层,用来防止过拟合,同样分为 1D、2D 和 3D。 下面通过例子来说明它们的使用。
# 输入 batch_size=2,维度 3
input = V(t.randn(2, 3))
linear = nn.Linear(3, 4)
h = linear(input)
h
Variable containing:
 0.0529  0.4152  0.6688  0.4281
 0.4504 -0.3291  0.4206  1.4391
[torch.FloatTensor of size 2x4]
# 4 channel,初始化标准差为 4,均值为 0
bn = nn.BatchNorm1d(4)
bn.weight.data = t.ones(4) * 4
bn.bias.data = t.zeros(4)

bn_out = bn(h)
# 注意输出的均值和方差
# 方差是标准差的平方,计算无偏方差分母会减 1
# 使用 unbiased=False 分母不减 1
bn_out.mean(0), bn_out.var(0, unbiased=False)
(Variable containing:
 1.00000e-07 *
   0.0000
   0.0000
   0.0000
   2.3842
 [torch.FloatTensor of size 4], Variable containing:
  15.9960
  15.9988
  15.9896
  15.9994
 [torch.FloatTensor of size 4])
# 每个元素以 0.5 的概率舍弃
dropout = nn.Dropout(0.5)
o = dropout(bn_out)
o # 有一半左右的数变为 0
Variable containing:
-7.9990  0.0000  7.9974 -7.9998
 0.0000 -0.0000 -0.0000  7.9998
[torch.FloatTensor of size 2x4]

以上很多例子中都对 module 的属性直接操作,其大多数是可学习参数,一般会随着学习的进行而不断改变。实际使用中除非需要使用特殊的初始化,应尽量不要直接修改这些参数。

4.1.2 激活函数

PyTorch 实现了常见的激活函数,其具体的接口信息可参见官方文档[^3],这些激活函数可作为独立的 layer 使用。这里将介绍最常用的激活函数 ReLU,其数学表达式为: $$ReLU(x)=max(0,x)$$

relu = nn.ReLU(inplace=True)
input = V(t.randn(2, 3))
print(input)
output = relu(input)
print(output) # 小于 0 的都被截断为 0
# 等价于 input.clamp(min=0)
Variable containing:
-0.6869  0.7347 -0.2196
 0.9445 -0.9042 -1.2652
[torch.FloatTensor of size 2x3]

Variable containing:
 0.0000  0.7347  0.0000
 0.9445  0.0000  0.0000
[torch.FloatTensor of size 2x3]

ReLU 函数有个 inplace 参数,如果设为 True,它会把输出直接覆盖到输入中,这样可以节省内存/显存。之所以可以覆盖是因为在计算 ReLU 的反向传播时,只需根据输出就能够推算出反向传播的梯度。但是只有少数的 autograd 操作支持 inplace 操作(如 variable.sigmoid_()),除非你明确地知道自己在做什么,否则一般不要使用 inplace 操作。

在以上的例子中,基本上都是将每一层的输出直接作为下一层的输入,这种网络称为前馈传播网络(feedforward neural network)。对于此类网络如果每次都写复杂的 forward 函数会有些麻烦,在此就有两种简化方式,ModuleList 和 Sequential。其中 Sequential 是一个特殊的 module,它包含几个子 Module,前向传播时会将输入一层接一层的传递下去。ModuleList 也是一个特殊的 module,可以包含几个子 module,可以像用 list 一样使用它,但不能直接把输入传给 ModuleList。下面举例说明。

# Sequential 的三种写法
net1 = nn.Sequential()
net1.add_module('conv', nn.Conv2d(3, 3, 3))
net1.add_module('batchnorm', nn.BatchNorm2d(3))
net1.add_module('activation_layer', nn.ReLU())

net2 = nn.Sequential(
        nn.Conv2d(3, 3, 3),
        nn.BatchNorm2d(3),
        nn.ReLU()
        )

from collections import OrderedDict
net3= nn.Sequential(OrderedDict([
          ('conv1', nn.Conv2d(3, 3, 3)),
          ('bn1', nn.BatchNorm2d(3)),
          ('relu1', nn.ReLU())
        ]))
print('net1:', net1)
print('net2:', net2)
print('net3:', net3)
net1: Sequential(
  (conv): Conv2d (3, 3, kernel_size=(3, 3), stride=(1, 1))
  (batchnorm): BatchNorm2d(3, eps=1e-05, momentum=0.1, affine=True)
  (activation_layer): ReLU()
)
net2: Sequential(
  (0): Conv2d (3, 3, kernel_size=(3, 3), stride=(1, 1))
  (1): BatchNorm2d(3, eps=1e-05, momentum=0.1, affine=True)
  (2): ReLU()
)
net3: Sequential(
  (conv1): Conv2d (3, 3, kernel_size=(3, 3), stride=(1, 1))
  (bn1): BatchNorm2d(3, eps=1e-05, momentum=0.1, affine=True)
  (relu1): ReLU()
)
# 可根据名字或序号取出子 module
net1.conv, net2[0], net3.conv1
(Conv2d (3, 3, kernel_size=(3, 3), stride=(1, 1)),
 Conv2d (3, 3, kernel_size=(3, 3), stride=(1, 1)),
 Conv2d (3, 3, kernel_size=(3, 3), stride=(1, 1)))
input = V(t.rand(1, 3, 4, 4))
output = net1(input)
output = net2(input)
output = net3(input)
output = net3.relu1(net1.batchnorm(net1.conv(input)))
modellist = nn.ModuleList([nn.Linear(3,4), nn.ReLU(), nn.Linear(4,2)])
input = V(t.randn(1, 3))
for model in modellist:
    input = model(input)
# 下面会报错,因为 modellist 没有实现 forward 方法
# output = modelist(input)

看到这里,读者可能会问,为何不直接使用 Python 中自带的 list,而非要多此一举呢?这是因为 ModuleListModule 的子类,当在 Module 中使用它的时候,就能自动识别为子 module。

下面举例说明。

class MyModule(nn.Module):
    def __init__(self):
        super(MyModule, self).__init__()
        self.list = [nn.Linear(3, 4), nn.ReLU()]
        self.module_list = nn.ModuleList([nn.Conv2d(3, 3, 3), nn.ReLU()])
    def forward(self):
        pass
model = MyModule()
model
MyModule(
  (module_list): ModuleList(
    (0): Conv2d (3, 3, kernel_size=(3, 3), stride=(1, 1))
    (1): ReLU()
  )
)
for name, param in model.named_parameters():
    print(name, param.size())
module_list.0.weight torch.Size([3, 3, 3, 3])
module_list.0.bias torch.Size([3])

可见,list 中的子 module 并不能被主 module 所识别,而 ModuleList 中的子 module 能够被主 module 所识别。这意味着如果用 list 保存子 module,将无法调整其参数,因其未加入到主 module 的参数中。

除 ModuleList 之外还有 ParameterList,其是一个可以包含多个 parameter 的类 list 对象。在实际应用中,使用方式与 ModuleList 类似。如果在构造函数 __init__ 中用到 list、tuple、dict 等对象时,一定要思考是否应该用 ModuleList 或 ParameterList 代替。

4.1.3 循环神经网络层(RNN)

近些年随着深度学习和自然语言处理的结合加深,RNN 的使用也越来越多,关于 RNN 的基础知识,推荐阅读 colah 的文章 ^4 入门。PyTorch 中实现了如今最常用的三种 RNN:RNN(vanilla RNN)、LSTM 和 GRU。此外还有对应的三种 RNNCell。

RNN 和 RNNCell 层的区别在于前者一次能够处理整个序列,而后者一次只处理序列中一个时间点的数据,前者封装更完备更易于使用,后者更具灵活性。实际上 RNN 层的一种后端实现方式就是调用 RNNCell 来实现的。

t.manual_seed(1000)
# 输入:batch_size=3,序列长度都为 2,序列中每个元素占 4 维
input = V(t.randn(2, 3, 4))
# lstm 输入向量 4 维,隐藏元 3,1 层
lstm = nn.LSTM(4, 3, 1)
# 初始状态:1 层,batch_size=3,3 个隐藏元
h0 = V(t.randn(1, 3, 3))
c0 = V(t.randn(1, 3, 3))
out, hn = lstm(input, (h0, c0))
out
Variable containing:
(0 ,.,.) = 
  0.0545 -0.0061  0.5615
 -0.1251  0.4490  0.2640
  0.1405 -0.1624  0.0303

(1 ,.,.) = 
  0.0168  0.1562  0.5002
  0.0824  0.1454  0.4007
  0.0180 -0.0267  0.0094
[torch.FloatTensor of size 2x3x3]
t.manual_seed(1000)
input = V(t.randn(2, 3, 4))
# 一个 LSTMCell 对应的层数只能是一层
lstm = nn.LSTMCell(4, 3)
hx = V(t.randn(3, 3))
cx = V(t.randn(3, 3))
out = []
for i_ in input:
    hx, cx=lstm(i_, (hx, cx))
    out.append(hx)
t.stack(out)
Variable containing:
(0 ,.,.) = 
  0.0545 -0.0061  0.5615
 -0.1251  0.4490  0.2640
  0.1405 -0.1624  0.0303

(1 ,.,.) = 
  0.0168  0.1562  0.5002
  0.0824  0.1454  0.4007
  0.0180 -0.0267  0.0094
[torch.FloatTensor of size 2x3x3]

词向量在自然语言中应用十分普及,PyTorch 同样提供了 Embedding 层。

# 有 4 个词,每个词用 5 维的向量表示
embedding = nn.Embedding(4, 5)
# 可以用预训练好的词向量初始化 embedding
embedding.weight.data = t.arange(0,20).view(4,5)
input = V(t.arange(3, 0, -1)).long()
output = embedding(input)
output
Variable containing:
 15  16  17  18  19
 10  11  12  13  14
  5   6   7   8   9
[torch.FloatTensor of size 3x5]

4.1.4 损失函数

在深度学习中要用到各种各样的损失函数(loss function),这些损失函数可看作是一种特殊的 layer,PyTorch 也将这些损失函数实现为`nn.Module`的子类。然而在实际使用中通常将这些 loss function 专门提取出来,和主模型互相独立。详细的 loss 使用请参照文档[^5],这里以分类中最常用的交叉熵损失 CrossEntropyloss 为例说明。

# batch_size=3,计算对应每个类别的分数(只有两个类别)
score = V(t.randn(3, 2))
# 三个样本分别属于 1,0,1 类,label 必须是 LongTensor
label = V(t.Tensor([1, 0, 1])).long()

# loss 与普通的 layer 无差异
criterion = nn.CrossEntropyLoss()
loss = criterion(score, label)
loss
Variable containing:
 1.5544
[torch.FloatTensor of size 1]

4.2 优化器

PyTorch 将深度学习中常用的优化方法全部封装在 torch.optim 中,其设计十分灵活,能够很方便的扩展成自定义的优化方法。

所有的优化方法都是继承基类 optim.Optimizer ,并实现了自己的优化步骤。下面就以最基本的优化方法——随机梯度下降法(SGD)举例说明。这里需重点掌握:

  • 优化方法的基本使用方法
  • 如何对模型的不同部分设置不同的学习率
  • 如何调整学习率
# 首先定义一个 LeNet 网络
class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.features = nn.Sequential(
                    nn.Conv2d(3, 6, 5),
                    nn.ReLU(),
                    nn.MaxPool2d(2,2),
                    nn.Conv2d(6, 16, 5),
                    nn.ReLU(),
                    nn.MaxPool2d(2,2)
        )
        self.classifier = nn.Sequential(
            nn.Linear(16 * 5 * 5, 120),
            nn.ReLU(),
            nn.Linear(120, 84),
            nn.ReLU(),
            nn.Linear(84, 10)
        )

    def forward(self, x):
        x = self.features(x)
        x = x.view(-1, 16 * 5 * 5)
        x = self.classifier(x)
        return x

net = Net()
from torch import  optim
optimizer = optim.SGD(params=net.parameters(), lr=1)
optimizer.zero_grad() # 梯度清零,等价于 net.zero_grad()

input = V(t.randn(1, 3, 32, 32))
output = net(input)
output.backward(output) # fake backward

optimizer.step() # 执行优化
# 为不同子网络设置不同的学习率,在 finetune 中经常用到
# 如果对某个参数不指定学习率,就使用最外层的默认学习率
optimizer =optim.SGD([
                {'params': net.features.parameters()}, # 学习率为 1e-5
                {'params': net.classifier.parameters(), 'lr': 1e-2}
            ], lr=1e-5)
# 只为两个全连接层设置较大的学习率,其余层的学习率较小
special_layers = nn.ModuleList([net.classifier[0], net.classifier[3]])
special_layers_params = list(map(id, special_layers.parameters()))
base_params = filter(lambda p: id(p) not in special_layers_params,
                     net.parameters())

optimizer = t.optim.SGD([
            {'params': base_params},
            {'params': special_layers.parameters(), 'lr': 0.01}
        ], lr=0.001 )

对于如何调整学习率,主要有两种做法。一种是修改 optimizer.param_groups 中对应的学习率,另一种是更简单也是较为推荐的做法——新建优化器,由于 optimizer 十分轻量级,构建开销很小,故而可以构建新的 optimizer。但是后者对于使用动量的优化器(如 Adam),会丢失动量等状态信息,可能会造成损失函数的收敛出现震荡等情况。

# 调整学习率,新建一个 optimizer
old_lr = 0.1
optimizer =optim.SGD([
                {'params': net.features.parameters()},
                {'params': net.classifier.parameters(), 'lr': old_lr*0.1}
            ], lr=1e-5)

4.3 nn.functional

nn 中还有一个很常用的模块: nn.functional ,nn 中的大多数 layer,在 functional 中都有一个与之相对应的函数。 nn.functional 中的函数和 nn.Module 的主要区别在于,用 nn.Module 实现的 layers 是一个特殊的类,都是由 class layer(nn.Module) 定义,会自动提取可学习的参数。而 nn.functional 中的函数更像是纯函数,由 def function(input) 定义。下面举例说明 functional 的使用,并指出二者的不同之处。

input = V(t.randn(2, 3))
model = nn.Linear(3, 4)
output1 = model(input)
output2 = nn.functional.linear(input, model.weight, model.bias)
output1 == output2
Variable containing:
 1  1  1  1
 1  1  1  1
[torch.ByteTensor of size 2x4]
b = nn.functional.relu(input)
b2 = nn.ReLU()(input)
b == b2
Variable containing:
 1  1  1
 1  1  1
[torch.ByteTensor of size 2x3]

此时读者可能会问,应该什么时候使用 nn.Module,什么时候使用 nn.functional 呢?答案很简单,如果模型有可学习的参数,最好用 nn.Module,否则既可以使用 nn.functional 也可以使用 nn.Module,二者在性能上没有太大差异,具体的使用取决于个人的喜好。如激活函数(ReLU、sigmoid、tanh),池化(MaxPool)等层由于没有可学习参数,则可以使用对应的 functional 函数代替,而对于卷积、全连接等具有可学习参数的网络建议使用 nn.Module。下面举例说明,如何在模型中搭配使用 nn.Module 和 nn.functional。另外虽然 dropout 操作也没有可学习操作,但建议还是使用 nn.Dropout 而不是 nn.functional.dropout ,因为 dropout 在训练和测试两个阶段的行为有所差别,使用 nn.Module 对象能够通过 model.eval 操作加以区分。

from torch.nn import functional as F
class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(3, 6, 5)
        self.conv2 = nn.Conv2d(6, 16, 5)
        self.fc1 = nn.Linear(16 * 5 * 5, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)

    def forward(self, x):
        x = F.pool(F.relu(self.conv1(x)), 2)
        x = F.pool(F.relu(self.conv2(x)), 2)
        x = x.view(-1, 16 * 5 * 5)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x

对于不具备可学习参数的层(激活层、池化层等),将它们用函数代替,这样则可以不用放置在构造函数 __init__ 中。对于有可学习参数的模块,也可以用 functional 来代替,只不过实现起来较为繁琐,需要手动定义参数 parameter,如前面实现自定义的全连接层,就可将 weight 和 bias 两个参数单独拿出来,在构造函数中初始化为 parameter。

class MyLinear(nn.Module):
    def __init__(self):
        super(MyLinear, self).__init__()
        self.weight = nn.Parameter(t.randn(3, 4))
        self.bias = nn.Parameter(t.zeros(3))
    def forward(self):
        return F.linear(input, weight, bias)

关于 nn.functional 的设计初衷,以及它和 nn.Module 更多的比较说明,可参看论坛的讨论和作者说明 ^6

4.4 初始化策略

在深度学习中参数的初始化十分重要,良好的初始化能让模型更快收敛,并达到更高水平,而糟糕的初始化则可能使得模型迅速瘫痪。PyTorch 中 nn.Module 的模块参数都采取了较为合理的初始化策略,因此一般不用我们考虑,当然我们也可以用自定义初始化去代替系统的默认初始化。而当我们在使用 Parameter 时,自定义初始化则尤为重要,因 t.Tensor() 返回的是内存中的随机数,很可能会有极大值,这在实际训练网络中会造成溢出或者梯度消失。PyTorch 中 nn.init 模块就是专门为初始化而设计,如果某种初始化策略 nn.init 不提供,用户也可以自己直接初始化。

# 利用 nn.init 初始化
from torch.nn import init
linear = nn.Linear(3, 4)

t.manual_seed(1)
# 等价于 linear.weight.data.normal_(0, std)
init.xavier_normal(linear.weight)
Parameter containing:
 0.3535  0.1427  0.0330
 0.3321 -0.2416 -0.0888
-0.8140  0.2040 -0.5493
-0.3010 -0.4769 -0.0311
[torch.FloatTensor of size 4x3]
# 直接初始化
import math
t.manual_seed(1)

# xavier 初始化的计算公式
std = math.sqrt(2)/math.sqrt(7.)
linear.weight.data.normal_(0,std)
 0.3535  0.1427  0.0330
 0.3321 -0.2416 -0.0888
-0.8140  0.2040 -0.5493
-0.3010 -0.4769 -0.0311
[torch.FloatTensor of size 4x3]
# 对模型的所有参数进行初始化
for name, params in net.named_parameters():
    if name.find('linear') != -1:
        # init linear
        params[0] # weight
        params[1] # bias
    elif name.find('conv') != -1:
        pass
    elif name.find('norm') != -1:
        pass

4.5 nn.Module 深入分析

如果想要更深入地理解 nn.Module,究其原理是很有必要的。首先来看看 nn.Module 基类的构造函数:

def __init__(self):
    self._parameters = OrderedDict()
    self._modules = OrderedDict()
    self._buffers = OrderedDict()
    self._backward_hooks = OrderedDict()
    self._forward_hooks = OrderedDict()
    self.training = True

其中每个属性的解释如下:

  • _parameters :字典,保存用户直接设置的 parameter, self.param1 = nn.Parameter(t.randn(3, 3)) 会被检测到,在字典中加入一个 key 为'param',value 为对应 parameter 的 item。而 self.submodule = nn.Linear(3, 4) 中的 parameter 则不会存于此。
  • _modules :子 module,通过 self.submodel = nn.Linear(3, 4) 指定的子 module 会保存于此。
  • _buffers :缓存。如 batchnorm 使用 momentum 机制,每次前向传播需用到上一次前向传播的结果。
  • _backward_hooks_forward_hooks :钩子技术,用来提取中间变量,类似 variable 的 hook。
  • training :BatchNorm 与 Dropout 层在训练阶段和测试阶段中采取的策略不同,通过判断 training 值来决定前向传播策略。

上述几个属性中, _parameters_modules_buffers 这三个字典中的键值,都可以通过 self.key 方式获得,效果等价于 self._parameters['key'] .

下面举例说明。

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        # 等价与 self.register_parameter('param1' ,nn.Parameter(t.randn(3, 3)))
        self.param1 = nn.Parameter(t.rand(3, 3))
        self.submodel1 = nn.Linear(3, 4) 
    def forward(self, input):
        x = self.param1.mm(input)
        x = self.submodel1(x)
        return x
net = Net()
net
Net(
  (submodel1): Linear(in_features=3, out_features=4)
)
net._modules
OrderedDict([('submodel1', Linear(in_features=3, out_features=4))])
net._parameters
OrderedDict([('param1', Parameter containing:
               0.3398  0.5239  0.7981
               0.7718  0.0112  0.8100
               0.6397  0.9743  0.8300
              [torch.FloatTensor of size 3x3])])
net.param1 # 等价于 net._parameters['param1']
Parameter containing:
 0.3398  0.5239  0.7981
 0.7718  0.0112  0.8100
 0.6397  0.9743  0.8300
[torch.FloatTensor of size 3x3]
for name, param in net.named_parameters():
    print(name, param.size())
param1 torch.Size([3, 3])
submodel1.weight torch.Size([4, 3])
submodel1.bias torch.Size([4])
for name, submodel in net.named_modules():
    print(name, submodel)
 Net(
  (submodel1): Linear(in_features=3, out_features=4)
)
submodel1 Linear(in_features=3, out_features=4)
bn = nn.BatchNorm1d(2)
input = V(t.rand(3, 2), requires_grad=True)
output = bn(input)
bn._buffers
OrderedDict([('running_mean', 
              1.00000e-02 *
                5.1362
                7.4864
              [torch.FloatTensor of size 2]), ('running_var', 
               0.9116
               0.9068
              [torch.FloatTensor of size 2])])

nn.Module 在实际使用中可能层层嵌套,一个 module 包含若干个子 module,每一个子 module 又包含了更多的子 module。为方便用户访问各个子 module,nn.Module 实现了很多方法,如函数 children 可以查看直接子 module,函数 module 可以查看所有的子 module(包括当前 module)。与之相对应的还有函数 named_childennamed_modules ,其能够在返回 module 列表的同时返回它们的名字。

input = V(t.arange(0, 12).view(3, 4))
model = nn.Dropout()
# 在训练阶段,会有一半左右的数被随机置为 0
model(input)
Variable containing:
  0   2   0   0
  8   0  12  14
 16   0   0  22
[torch.FloatTensor of size 3x4]
model.training  = False
# 在测试阶段,dropout 什么都不做
model(input)
Variable containing:
  0   1   2   3
  4   5   6   7
  8   9  10  11
[torch.FloatTensor of size 3x4]

对于 batchnorm、dropout、instancenorm 等在训练和测试阶段行为差距巨大的层,如果在测试时不将其 training 值设为 True,则可能会有很大影响,这在实际使用中要千万注意。虽然可通过直接设置 training 属性,来将子 module 设为 train 和 eval 模式,但这种方式较为繁琐,因如果一个模型具有多个 dropout 层,就需要为每个 dropout 层指定 training 属性。更为推荐的做法是调用 model.train() 函数,它会将当前 module 及其子 module 中的所有 training 属性都设为 True,相应的, model.eval() 函数会把 training 属性都设为 False。

print(net.training, net.submodel1.training)
net.eval()
net.training, net.submodel1.training
True True
(False, False)
list(net.named_modules())
[('', Net(
    (submodel1): Linear(in_features=3, out_features=4)
  )), ('submodel1', Linear(in_features=3, out_features=4))]

register_forward_hookregister_backward_hook ,这两个函数的功能类似于 variable 函数的 register_hook ,可在 module 前向传播或反向传播时注册钩子。每次前向传播执行结束后会执行钩子函数(hook)。前向传播的钩子函数具有如下形式: hook(module, input, output) -> None ,而反向传播则具有如下形式: hook(module, grad_input, grad_output) -> Tensor or None 。钩子函数不应修改输入和输出,并且在使用后应及时删除,以避免每次都运行钩子增加运行负载。钩子函数主要用在获取某些中间结果的情景,如中间某一层的输出或某一层的梯度。这些结果本应写在 forward 函数中,但如果在 forward 函数中专门加上这些处理,可能会使处理逻辑比较复杂,这时候使用钩子技术就更合适一些。下面考虑一种场景,有一个预训练好的模型,需要提取模型的某一层(不是最后一层)的输出作为特征进行分类,但又不希望修改其原有的模型定义文件,这时就可以利用钩子函数。下面给出实现的伪代码。

model = VGG()
features = t.Tensor()
def hook(module, input, output):
    '''把这层的输出拷贝到 features 中'''
    features.copy_(output.data)

handle = model.layer8.register_forward_hook(hook)
_ = model(input)
# 用完 hook 后删除
handle.remove()

nn.Module 对象在构造函数中的行为看起来有些怪异,如果想要真正掌握其原理,就需要看两个魔法方法 __getattr____setattr__ 。在 Python 中有两个常用的 buildin 方法 getattrsetattrgetattr(obj, 'attr1') 等价于 obj.attr ,如果 getattr 函数无法找到所需属性,Python 会转而调用 obj.__getattr__('attr1') 方法,即 getattr 函数无法找到的交给 __getattr__ 函数处理,没有实现 __getattr__ 或者 __getattr__ 也无法处理的就会 raise AttributeError。 setattr(obj, 'name', value) 等价于 obj.name=value ,如果 obj 对象实现了 __setattr__ 方法,setattr 会直接调用 obj.__setattr__('name', value) ,否则调用 buildin 方法。总结一下:

  • result = obj.name 会调用 buildin 函数 getattr(obj, 'name') ,如果该属性找不到,会调用 obj.__getattr__('name')
  • obj.name = value 会调用 buildin 函数 setattr(obj, 'name', value) ,如果 obj 对象实现了 __setattr__ 方法, setattr 会直接调用 obj.__setattr__('name', value')

nn.Module 实现了自定义的 __setattr__ 函数,当执行 module.name=value 时,会在 __setattr__ 中判断 value 是否为 Parameternn.Module 对象,如果是则将这些对象加到 _parameters_modules 两个字典中,而如果是其它类型的对象,如 Variablelistdict 等,则调用默认的操作,将这个值保存在 __dict__ 中。

module = nn.Module()
module.param = nn.Parameter(t.ones(2, 2))
module._parameters
OrderedDict([('param', Parameter containing:
               1  1
               1  1
              [torch.FloatTensor of size 2x2])])
submodule1 = nn.Linear(2, 2)
submodule2 = nn.Linear(2, 2)
module_list =  [submodule1, submodule2]
# 对于 list 对象,调用 buildin 函数,保存在__dict__中
module.submodules = module_list
print('_modules: ', module._modules)
print("__dict__['submodules']:",module.__dict__.get('submodules'))
_modules:  OrderedDict()
__dict__['submodules']: [Linear(in_features=2, out_features=2), Linear(in_features=2, out_features=2)]
module_list = nn.ModuleList(module_list)
module.submodules = module_list
print('ModuleList is instance of nn.Module: ', isinstance(module_list, nn.Module))
print('_modules: ', module._modules)
print("__dict__['submodules']:", module.__dict__.get('submodules'))
ModuleList is instance of nn.Module:  True
_modules:  OrderedDict([('submodules', ModuleList(
  (0): Linear(in_features=2, out_features=2)
  (1): Linear(in_features=2, out_features=2)
))])
__dict__['submodules']: None

_modules_parameters 中的 item 未保存在 __dict__ 中,所以默认的 getattr 方法无法获取它,因而 nn.Module 实现了自定义的 __getattr__ 方法,如果默认的 getattr 无法处理,就调用自定义的 __getattr__ 方法,尝试从 _modules_parameters_buffers 这三个字典中获取。

getattr(module, 'training') # 等价于 module.training
# error
# module.__getattr__('training')
True
module.attr1 = 2
getattr(module, 'attr1')
# 报错
# module.__getattr__('attr1')
2
# 即 module.param, 会调用 module.__getattr__('param')
getattr(module, 'param')
Parameter containing:
 1  1
 1  1
[torch.FloatTensor of size 2x2]

在 PyTorch 中保存模型十分简单,所有的 Module 对象都具有 state_dict() 函数,返回当前 Module 所有的状态数据。将这些状态数据保存后,下次使用模型时即可利用 model.load_state_dict() 函数将状态加载进来。优化器(optimizer)也有类似的机制,不过一般并不需要保存优化器的运行状态。

# 保存模型
t.save(net.state_dict(), 'net.pth')

# 加载已保存的模型
net2 = Net()
net2.load_state_dict(t.load('net.pth'))

实际上还有另外一种保存方法,但因其严重依赖模型定义方式及文件路径结构等,很容易出问题,因而不建议使用。

t.save(net, 'net_all.pth')
net2 = t.load('net_all.pth')
net2
/usr/local/lib/python3.5/dist-packages/torch/serialization.py:158: UserWarning: Couldn't retrieve source code for container of type Net. It won't be checked for correctness upon loading.
  "type " + obj.__name__ + ". It won't be checked "
Net(
  (submodel1): Linear(in_features=3, out_features=4)
)

将 Module 放在 GPU 上运行也十分简单,只需两步:

  • model = model.cuda():将模型的所有参数转存到 GPU
  • input.cuda():将输入数据也放置到 GPU 上

至于如何在多个 GPU 上并行计算,PyTorch 也提供了两个函数,可实现简单高效的并行 GPU 计算

  • nn.parallel.dataparallel(module, inputs, deviceids=None, outputdevice=None, dim=0, modulekwargs=None)
  • class torch.nn.DataParallel(module, deviceids=None, outputdevice=None, dim=0)

可见二者的参数十分相似,通过 device_ids 参数可以指定在哪些 GPU 上进行优化,output_device 指定输出到哪个 GPU 上。唯一的不同就在于前者直接利用多 GPU 并行计算得出结果,而后者则返回一个新的 module,能够自动在多 GPU 上进行并行加速。

# method 1
new_net = nn.DataParallel(net, device_ids=[0, 1])
output = new_net(input)

# method 2
output = nn.parallel.data_parallel(new_net, input, device_ids=[0, 1])

DataParallel 并行的方式,是将输入一个 batch 的数据均分成多份,分别送到对应的 GPU 进行计算,各个 GPU 得到的梯度累加。与 Module 相关的所有数据也都会以浅复制的方式复制多份,在此需要注意,在 module 中属性应该是只读的。

4.6 nn 和 autograd 的关系

nn.Module 利用的也是 autograd 技术,其主要工作是实现前向传播。在 forward 函数中,nn.Module 对输入的 Variable 进行的各种操作,本质上都是用到了 autograd 技术。这里需要对比 autograd.Function 和 nn.Module 之间的区别:

  • autograd.Function 利用了 Tensor 对 autograd 技术的扩展,为 autograd 实现了新的运算 op,不仅要实现前向传播还要手动实现反向传播
  • nn.Module 利用了 autograd 技术,对 nn 的功能进行扩展,实现了深度学习中更多的层。只需实现前向传播功能,autograd 即会自动实现反向传播
  • nn.functional 是一些 autograd 操作的集合,是经过封装的函数

作为两大类扩充 PyTorch 接口的方法,我们在实际使用中应该如何选择呢?如果某一个操作,在 autograd 中尚未支持,那么只能实现 Function 接口对应的前向传播和反向传播。如果某些时候利用 autograd 接口比较复杂,则可以利用 Function 将多个操作聚合,实现优化,正如第三章所实现的 Sigmoid 一样,比直接利用 autograd 低级别的操作要快。而如果只是想在深度学习中增加某一层,使用 nn.Module 进行封装则更为简单高效。

4.7 小试牛刀:搭建 ResNet

Kaiming He 的深度残差网络(ResNet)[^7]在深度学习的发展中起到了很重要的作用,ResNet 不仅一举拿下了当年 CV 下多个比赛项目的冠军,更重要的是这一结构解决了训练极深网络时的梯度消失问题。

首先来看看 ResNet 的网络结构,这里选取的是 ResNet 的一个变种:ResNet34。ResNet 的网络结构如图 4-2 所示,可见除了最开始的卷积池化和最后的池化全连接之外,网络中有很多结构相似的单元,这些重复单元的共同点就是有个跨层直连的 shortcut。ResNet 中将一个跨层直连的单元称为 Residual block,其结构如图 4-3 所示,左边部分是普通的卷积网络结构,右边是直连,但如果输入和输出的通道数不一致,或其步长不为 1,那么就需要有一个专门的单元将二者转成一致,使其可以相加。

另外我们可以发现 Residual block 的大小也是有规律的,在最开始的 pool 之后有连续的几个一模一样的 Residual block 单元,这些单元的通道数一样,在这里我们将这几个拥有多个 Residual block 单元的结构称之为 layer,注意和之前讲的 layer 区分开来,这里的 layer 是几个层的集合。

考虑到 Residual block 和 layer 出现了多次,我们可以把它们实现为一个子 Module 或函数。这里我们将 Residual block 实现为一个子 moduke,而将 layer 实现为一个函数。下面是实现代码,规律总结如下:

  • 对于模型中的重复部分,实现为子 module 或用函数生成相应的 module make_layer
  • nn.Module 和 nn.Functional 结合使用
  • 尽量使用 nn.Seqential

图 4-2: ResNet34 网络结构

图 4-3: Residual block 结构图

[^7]: He K, Zhang X, Ren S, et al. Deep residual learning for image recognition[C]//Proceedings of the IEEE Conference on Computer Vision and Pattern Recognition. 2016: 770-778.

from torch import  nn
import torch as t
from torch.nn import  functional as F
class ResidualBlock(nn.Module):
    '''
    实现子 module: Residual Block
    '''
    def __init__(self, inchannel, outchannel, stride=1, shortcut=None):
        super(ResidualBlock, self).__init__()
        self.left = nn.Sequential(
                nn.Conv2d(inchannel,outchannel,3,stride, 1,bias=False),
                nn.BatchNorm2d(outchannel),
                nn.ReLU(inplace=True),
                nn.Conv2d(outchannel,outchannel,3,1,1,bias=False),
                nn.BatchNorm2d(outchannel) )
        self.right = shortcut

    def forward(self, x):
        out = self.left(x)
        residual = x if self.right is None else self.right(x)
        out += residual
        return F.relu(out)

class ResNet(nn.Module):
    '''
    实现主 module:ResNet34
    ResNet34 包含多个 layer,每个 layer 又包含多个 residual block
    用子 module 来实现 residual block,用_make_layer 函数来实现 layer
    '''
    def __init__(self, num_classes=1000):
        super(ResNet, self).__init__()
        # 前几层图像转换
        self.pre = nn.Sequential(
                nn.Conv2d(3, 64, 7, 2, 3, bias=False),
                nn.BatchNorm2d(64),
                nn.ReLU(inplace=True),
                nn.MaxPool2d(3, 2, 1))

        # 重复的 layer,分别有 3,4,6,3 个 residual block
        self.layer1 = self._make_layer( 64, 64, 3)
        self.layer2 = self._make_layer( 64, 128, 4, stride=2)
        self.layer3 = self._make_layer( 128, 256, 6, stride=2)
        self.layer4 = self._make_layer( 256, 512, 3, stride=2)

        #分类用的全连接
        self.fc = nn.Linear(512, num_classes)

    def _make_layer(self,  inchannel, outchannel, block_num, stride=1):
        '''
        构建 layer,包含多个 residual block
        '''
        shortcut = nn.Sequential(
                nn.Conv2d(inchannel,outchannel,1,stride, bias=False),
                nn.BatchNorm2d(outchannel))

        layers = []
        layers.append(ResidualBlock(inchannel, outchannel, stride, shortcut))

        for i in range(1, block_num):
            layers.append(ResidualBlock(outchannel, outchannel))
        return nn.Sequential(*layers)

    def forward(self, x):
        x = self.pre(x)

        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)

        x = F.avg_pool2d(x, 7)
        x = x.view(x.size(0), -1)
        return self.fc(x)
model = ResNet()
input  = t.autograd.Variable(t.randn(1, 3, 224, 224))
o = model(input)

感兴趣的读者可以尝试实现 Google 的 Inception 网络结构或 ResNet 的其它变体,看看如何能够简洁明了地实现它,实现代码尽量控制在 80 行以内(本例去掉空行和注释总共不超过 50 行)。另外,与 PyTorch 配套的图像工具包 torchvision 已经实现了深度学习中大多数经典的模型,其中就包括 ResNet34,读者可以通过下面两行代码使用:

from torchvision import models
model = models.resnet34()

本例中 ResNet34 的实现就是参考了 torchvision 中的实现并做了简化,感兴趣的读者可以阅读相应的源码,比较这里的实现和 torchvision 中实现的不同。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文