PowerLZY's Blog

本博客主要用于记录个人学习笔记(测试阶段)

[PyTorch 学习笔记] 可视化

在 PyTorch 中也可以使用 TensorBoard,具体是使用 TensorboardX 来调用 TensorBoard。除了安装 TensorboardX,还要安装 TensorFlow 和 TensorBoard,其中 TensorFlow 和 TensorBoard 需要一致。

  • Captum 【可解释性】: https://captum.ai/docs/introduction

[PyTorch 学习笔记] 模型保存与加载

这篇文章主要介绍了序列化与反序列化,以及 PyTorch 中的模型保存于加载的两种方式,模型的断点续训练。

一、序列化与反序列化

模型在内存中是以对象的逻辑结构保存的,但是在硬盘中是以二进制流的方式保存的。

  • 序列化是指将内存中的数据以二进制序列的方式保存到硬盘中。PyTorch 的模型保存就是序列化。
  • 反序列化是指将硬盘中的二进制序列加载到内存中,得到模型的对象。PyTorch 的模型加载就是反序列化。

二、PyTorch 中的模型保存与加载

2.1 torch.save

1
torch.save(obj, f, pickle_module, pickle_protocol=2, _use_new_zipfile_serialization=False)

主要参数:

  • obj:保存的对象,可以是模型。也可以是 dict。因为一般在保存模型时,不仅要保存模型,还需要保存优化器、此时对应的 epoch 等参数。这时就可以用 dict 包装起来。
  • f:输出路径

2.2 torch.save

其中模型保存还有两种方式:

2.2.1 保存整个 Module

这种方法比较耗时,保存的文件大

1
torch.savev(net, path)
2.2.2 只保存模型的参数

推荐这种方法,运行比较快,保存的文件比较小

1
2
state_sict = net.state_dict()
torch.savev(state_sict, path)

下面是保存 LeNet 的例子。在网络初始化中,把权值都设置为 2020,然后保存模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
net = LeNet2(classes=2019)
# "训练"
print("训练前: ", net.features[0].weight[0, ...])
net.initialize()
print("训练后: ", net.features[0].weight[0, ...])

path_model = "./model.pkl"
path_state_dict = "./model_state_dict.pkl"

# 保存整个模型
torch.save(net, path_model)

# 保存模型参数
net_state_dict = net.state_dict()
torch.save(net_state_dict, path_state_dict)

运行完之后,文件夹中生成了model.pklmodel_state_dict.pkl,分别保存了整个网络和网络的参数

2.3 torch.load

1
torch.load(f, map_location=None, pickle_module, **pickle_load_args)

主要参数:

  • f:文件路径
  • map_location:指定存在 CPU 或者 GPU。

三、模型的断点续训练

在训练过程中,可能由于某种意外原因如断点等导致训练终止,这时需要重新开始训练。断点续练是在训练过程中每隔一定次数的 epoch 就保存模型的参数和优化器的参数,这样如果意外终止训练了,下次就可以重新加载最新的模型参数和优化器的参数,在这个基础上继续训练。

下面的代码中,每隔 5 个 epoch 就保存一次,保存的是一个 dict,包括模型参数、优化器的参数、epoch。然后在 epoch 大于 5 时,就break模拟训练意外终止。关键代码如下:

1
2
3
4
5
6
7
if (epoch+1) % checkpoint_interval == 0:

checkpoint = {"model_state_dict": net.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"epoch": epoch}
path_checkpoint = "./checkpoint_{}_epoch.pkl".format(epoch)
torch.save(checkpoint, path_checkpoint)

在 epoch 大于 5 时,就break模拟训练意外终止

1
2
3
if epoch > 5:
print("训练意外中断...")
break

断点续训练的恢复代码如下:

1
2
3
4
5
6
7
8
9
10
path_checkpoint = "./checkpoint_4_epoch.pkl"
checkpoint = torch.load(path_checkpoint)

net.load_state_dict(checkpoint['model_state_dict'])

optimizer.load_state_dict(checkpoint['optimizer_state_dict'])

start_epoch = checkpoint['epoch']

scheduler.last_epoch = start_epoch

需要注意的是,还要设置scheduler.last_epoch参数为保存的 epoch。模型训练的起始 epoch 也要修改为保存的 epoch。

[PyTorch 学习笔记] 模型 Finetune【微调】

迁移学习:把在 source domain 任务上的学习到的模型应用到 target domain 的任务。

Finetune 就是一种迁移学习的方法。比如做人脸识别,可以把 ImageNet 看作 source domain,人脸数据集看作 target domain。通常来说 source domain 要比 target domain 大得多。可以利用 ImageNet 训练好的网络应用到人脸识别中。

对于一个模型,通常可以分为前面的 feature extractor (卷积层)和后面的 classifier,在 Finetune 时,通常不改变 feature extractor 的权值,也就是冻结卷积层;并且改变最后一个全连接层的输出来适应目标任务,训练后面 classifier 的权值,这就是 Finetune。通常 target domain 的数据比较小,不足以训练全部参数,容易导致过拟合,因此不改变 feature extractor 的权值

Finetune 步骤如下:

  1. 获取预训练模型的参数

  2. 使用load_state_dict()把参数加载到模型中

  3. 修改输出层

  4. 固定 feature extractor 的参数。这部分通常有 2 种做法:

    • ==固定卷积层的预训练参数。可以设置requires_grad=False或者lr=0==
    • 可以通过params_group给 feature extractor ==设置一个较小的学习率==

不使用 Finetune

第一次我们首先不使用 Finetune,而是从零开始训练模型,这时只需要修改全连接层即可:

1
2
3
4
# 首先拿到 fc 层的输入个数
num_ftrs = resnet18_ft.fc.in_features
# 然后构造新的 fc 层替换原来的 fc 层
resnet18_ft.fc = nn.Linear(num_ftrs, classes)

训练了 25 个 epoch 后的准确率为:70.59%。训练的 loss 曲线如下:

img

使用 Finetune, 不冻结卷积层

然后我们把下载的模型参数加载到模型中:

1
2
3
path_pretrained_model = enviroments.resnet18_path
state_dict_load = torch.load(path_pretrained_model)
resnet18_ft.load_state_dict(state_dict_load)

训练了 25 个 epoch 后的准确率为:96.08%。训练的 loss 曲线如下:

img

使用 Finetune、 冻结卷积层

设置requires_grad=False这里先冻结所有参数,然后再替换全连接层,相当于冻结了卷积层的参数:
1
2
3
4
5
6
for param in resnet18_ft.parameters():
param.requires_grad = False
# 首先拿到 fc 层的输入个数
num_ftrs = resnet18_ft.fc.in_features
# 然后构造新的 fc 层替换原来的 fc 层
resnet18_ft.fc = nn.Linear(num_ftrs, classes)

使用 Finetune、设置学习率为 0

这里把卷积层的学习率设置为 0,需要在优化器里设置不同的学习率。首先获取全连接层参数的地址,然后使用 filter 过滤不属于全连接层的参数,也就是保留卷积层的参数;接着设置优化器的分组学习率,传入一个 list,包含 2 个元素,每个元素是字典,对应 2 个参数组。其中卷积层的学习率设置为 全连接层的 0.1 倍。

1
2
3
4
5
6
7
# 首先获取全连接层参数的地址
fc_params_id = list(map(id, resnet18_ft.fc.parameters()))
# 返回的是parameters的 内存地址
# 然后使用 filter 过滤不属于全连接层的参数,也就是保留卷积层的参数
base_params = filter(lambda p: id(p) not in fc_params_id, resnet18_ft.parameters())
# 设置优化器的分组学习率,传入一个 list,包含 2 个元素,每个元素是字典,对应 2 个参数组
optimizer = optim.SGD([{'params': base_params, 'lr': 0}, {'params': resnet18_ft.fc.parameters(), 'lr': LR}], momentum=0.9)

使用分组学习率、对卷积层使用较小的学习率

这里不冻结卷积层,而是对卷积层使用较小的学习率,对全连接层使用较大的学习率,需要在优化器里设置不同的学习率。首先获取全连接层参数的地址,然后使用 filter 过滤不属于全连接层的参数,也就是保留卷积层的参数;接着设置优化器的分组学习率,传入一个 list,包含 2 个元素,每个元素是字典,对应 2 个参数组。其中卷积层的学习率设置为 全连接层的 0.1 倍。

1
2
3
4
5
6
7
# 首先获取全连接层参数的地址
fc_params_id = list(map(id, resnet18_ft.fc.parameters()))
# 返回的是parameters的 内存地址
# 然后使用 filter 过滤不属于全连接层的参数,也就是保留卷积层的参数
base_params = filter(lambda p: id(p) not in fc_params_id, resnet18_ft.parameters())
# 设置优化器的分组学习率,传入一个 list,包含 2 个元素,每个元素是字典,对应 2 个参数组
optimizer = optim.SGD([{'params': base_params, 'lr': LR*0}, {'params': resnet18_ft.fc.parameters(), 'lr': LR}], momentum=0.9)

[PyTorch 学习笔记] 模型应用

  • Neural Network Malware Binary Classification:https://github.com/jaketae/deep-malware-detection

下面的代码是使用 Generator 来生成人脸图像,Generator 已经训练好保存在 pkl 文件中,只需要加载参数即可。由于模型是在多 GPU 的机器上训练的,因此加载参数后需要使用remove_module()函数来修改state_dict中的key

1
2
3
4
5
6
7
8
9
10
11
# 多 GPU 的机器上训练模型参数修改
def remove_module(state_dict_g):
# remove module.
from collections import OrderedDict

new_state_dict = OrderedDict()
for k, v in state_dict_g.items():
namekey = k[7:] if k.startswith('module.') else k
new_state_dict[namekey] = v

return new_state_dict

在 GAN 的训练模式中,Generator 接收随机数得到输出值,目标是让输出值的分布与训练数据的分布接近,但是这里==不是使用人为定义的损失函数来计算输出值与训练数据分布之间的差异,而是使用 Discriminator 来计算这个差异==。需要注意的是这个差异不是单个数字上的差异,而是分布上的差异。

[PyTorch 学习笔记] 权值初始化

在搭建好网络模型之后,一个重要的步骤就是对网络模型中的权值进行初始化==适当的权值初始化可以加快模型的收敛,而不恰当的权值初始化可能引发梯度消失或者梯度爆炸,最终导致模型无法收敛==。下面分 3 部分介绍。第一部分介绍不恰当的权值初始化是如何引发梯度消失与梯度爆炸的,第二部分介绍常用的 Xavier 方法与 Kaiming 方法,第三部分介绍 PyTorch 中的 10 种初始化方法。

一、梯度消失与梯度爆炸

考虑一个 3 层的全连接网络。

[公式][公式][公式]

img

其中第 2 层的权重梯度如下:

[公式]

所以 [公式] 依赖于前一层的输出 [公式]。如果 [公式] 趋近于零,那么 [公式]接近于 0,造成梯度消失。如果 [公式] 趋近于无穷大,那么 [公式] 也接近于无穷大,造成梯度爆炸。要避免梯度爆炸或者梯度消失,就要严格控制网络层输出的数值范围。

下面构建 100 层全连接网络,先不使用非线性激活函数,每层的权重初始化为服从 [公式] 的正态分布,输出数据使用随机初始化的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import torch
import torch.nn as nn
from common_tools import set_seed

set_seed(1) # 设置随机种子
class MLP(nn.Module):
def __init__(self, neural_num, layers):
super(MLP, self).__init__()
self.linears = nn.ModuleList([nn.Linear(neural_num, neural_num, bias=False) for i in range(layers)])
self.neural_num = neural_num

def forward(self, x):
for (i, linear) in enumerate(self.linears):
x = linear(x)
return x

def forward_new(self, x):
for (i, linear) in enumerate(self.linears):
x = linear(x)
print("layer:{}, std:{}".format(i, x.std()))
if torch.isnan(x.std()):
print("output is nan in {} layers".format(i))
break
return x

def initialize(self):
for m in self.modules():
# 判断这一层是否为线性层,如果为线性层则初始化权值
if isinstance(m, nn.Linear):
nn.init.normal_(m.weight.data) # normal: mean=0, std=1

layer_nums = 100
neural_nums = 256
batch_size = 16

net = MLP(neural_nums, layer_nums)
net.initialize()

inputs = torch.randn((batch_size, neural_nums)) # normal: mean=0, std=1
output = net(inputs)
print(output)

输出为:

1
2
3
4
5
6
7
tensor([[nan, nan, nan,  ..., nan, nan, nan],
[nan, nan, nan, ..., nan, nan, nan],
[nan, nan, nan, ..., nan, nan, nan],
...,
[nan, nan, nan, ..., nan, nan, nan],
[nan, nan, nan, ..., nan, nan, nan],
[nan, nan, nan, ..., nan, nan, nan]], grad_fn=<MmBackward>)

也就是==数据太大(梯度爆炸)或者太小(梯度消失)==了。接下来我们在forward()函数中判断每一次前向传播的输出的标准差是否为 nan,如果是 nan 则停止前向传播。

以输入层第一个神经元为例:

[公式]

其中输入 X 和权值 W 都是服从 [公式] 的正态分布,所以这个神经元的方差为:

[公式]
  • [公式]:两个相互独立的随机变量的乘积的期望等于它们的期望的乘积
  • [公式]:一个随机变量的方差等于它的平方的期望减去期望的平方
  • [公式]:两个相互独立的随机变量之和的方差等于它们的方差的和

可以推导出两个随机变量的乘积的方差如下:

[公式]

如果 [公式][公式],那么 [公式]

标准差为:[公式],所以每经过一个网络层,方差就会扩大 n 倍,标准差就会扩大 [公式] 倍,n 为每层神经元个数,直到超出数值表示范围。对比上面的代码可以看到,每层神经元个数为 256,输出数据的标准差为 1,所以第一个网络层输出的标准差为 16 左右,第二个网络层输出的标准差为 256 左右,以此类推,直到 31 层超出数据表示范围。可以把每层神经元个数改为 400,那么每层标准差扩大 20 倍左右。从 [公式],可以看出,每一层网络输出的方差与神经元个数、输入数据的方差、权值方差有关,其中比较好改变的是权值的方差 [公式],所以 [公式],标准差为 [公式]

因此修改权值初始化代码为nn.init.normal_(m.weight.data, std=np.sqrt(1/self.neural_num))

上述是没有使用非线性变换的实验结果,如果在forward()中添加非线性变换tanh,每一层的输出方差还是会越来越小,会导致梯度消失。因此出现了 Xavier 初始化方法与 Kaiming 初始化方法。

二、Xavier 方法与 Kaiming 方法

2.1 Xavier 方法 sigmod、tanh

Xavier 是 2010 年提出的,针对有非线性激活函数时的权值初始化方法,目标是保持数据的方差维持在 1 左右,主要针对饱和激活函数如 sigmoid 和 tanh 等。同时考虑前向传播和反向传播,需要满足两个等式:[公式][公式],可得:[公式]。为了使 Xavier 方法初始化的权值服从均匀分布,假设 [公式] 服从均匀分布 [公式],那么方差 [公式],令 [公式],解得:[公式],所以 [公式] 服从分布 [公式]

所以初始化方法改为:

1
2
3
4
5
6
a = np.sqrt(6 / (self.neural_num + self.neural_num))
# 把 a 变换到 tanh,计算增益
tanh_gain = nn.init.calculate_gain('tanh')
a *= tanh_gain

nn.init.uniform_(m.weight.data, -a, a)

并且每一层的激活函数都使用 tanh,输出如下:

1
2
3
4
5
6
7
8
9
layer:0, std:0.7571136355400085
layer:1, std:0.6924336552619934
layer:2, std:0.6677976846694946
.
.
.
layer:97, std:0.6426210403442383
layer:98, std:0.6407480835914612
layer:99, std:0.6442216038703918

可以看到每层输出的方差都维持在 0.6 左右。

PyTorch 也提供了 Xavier 初始化方法,可以直接调用:

1
2
tanh_gain = nn.init.calculate_gain('tanh')
nn.init.xavier_uniform_(m.weight.data, gain=tanh_gain)

#### nn.init.calculate_gain()

上面的初始化方法都使用了tanh_gain = nn.init.calculate_gain('tanh')

nn.init.calculate_gain(nonlinearity,param=**None**)==主要功能是经过一个分布的方差经过激活函数后的变化尺度==,主要有两个参数:

  • nonlinearity:激活函数名称
  • param:激活函数的参数,如 Leaky ReLU 的 negative_slop。

下面是计算标准差经过激活函数的变化尺度的代码。

1
2
3
4
5
6
7
8
x = torch.randn(10000)
out = torch.tanh(x)

gain = x.std() / out.std()
print('gain:{}'.format(gain))

tanh_gain = nn.init.calculate_gain('tanh')
print('tanh_gain in PyTorch:', tanh_gain)

输出如下:

1
2
gain:1.5982500314712524
tanh_gain in PyTorch: 1.6666666666666667

结果表示,原有数据分布的方差经过 tanh 之后,标准差会变小 1.6 倍左右。

2.2 Kaiming 方法

虽然 Xavier 方法提出了针对饱和激活函数的权值初始化方法,但是 AlexNet 出现后,大量网络开始使用非饱和的激活函数如 ReLU 等,这时 Xavier 方法不再适用。2015 年针对 ReLU 及其变种等激活函数提出了 Kaiming 初始化方法。

针对 ReLU,方差应该满足:[公式];针对 ReLu 的变种,方差应该满足:[公式],a 表示负半轴的斜率,如 PReLU 方法,标准差满足 [公式]。代码如下:nn.init.normal_(m.weight.data, std=np.sqrt(2 / self.neural_num)),或者使用 PyTorch 提供的初始化方法:nn.init.kaiming_normal_(m.weight.data),同时把激活函数改为 ReLU。

2.3 常用初始化方法

PyTorch 中提供了 10 中初始化方法

  1. Xavier 均匀分布
  2. Xavier 正态分布
  3. Kaiming 均匀分布
  4. Kaiming 正态分布
  5. 均匀分布
  6. 正态分布
  7. 常数分布
  8. 正交矩阵初始化
  9. 单位矩阵初始化
  10. 稀疏矩阵初始化

每种初始化方法都有它自己适用的场景,原则是保持每一层输出的方差不能太大,也不能太小。

[PyTorch 学习笔记] 模型创建步骤 与 nn.Module

这篇文章来看下 PyTorch 中网络模型的创建步骤。网络模型的内容如下,包括模型创建和权值初始化,这些内容都在nn.Module中有实现。

img

一、网络模型的创建步骤

创建模型有 2 个要素:构建子模块拼接子模块。如 LeNet 里包含很多卷积层、池化层、全连接层,当我们构建好所有的子模块之后,按照一定的顺序拼接起来。

img

这里以上一篇文章中 lenet.py的 LeNet 为例,继承nn.Module,必须实现__init__() 方法和forward()方法。其中__init__() 方法里创建子模块,在forward()方法里拼接子模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class LeNet(nn.Module):
# 子模块创建
def __init__(self, classes):
super(LeNet, 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, classes)
# 子模块拼接
def forward(self, x):
out = F.relu(self.conv1(x))
out = F.max_pool2d(out, 2)
out = F.relu(self.conv2(out))
out = F.max_pool2d(out, 2)
out = out.view(out.size(0), -1)
out = F.relu(self.fc1(out))
out = F.relu(self.fc2(out))
out = self.fc3(out)
return out
  • 调用net = LeNet(classes=2)创建模型时,会调用__init__()方法创建模型的子模块。

  • 在训练时调用outputs = net(inputs)时,会进入module.pycall()函数中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def __call__(self, *input, **kwargs):
for hook in self._forward_pre_hooks.values():
result = hook(self, input)
if result is not None:
if not isinstance(result, tuple):
result = (result,)
input = result
if torch._C._get_tracing_state():
result = self._slow_forward(*input, **kwargs)
else:
result = self.forward(*input, **kwargs)
...
...
...
  • 最终会调用result = self.forward(*input, **kwargs)函数,该函数会进入模型的forward()函数中,进行前向传播。

torch.nn中包含 4 个模块,如下图所示。

img

二、nn.Module

nn.Module 有 8 个属性,都是OrderDict(有序字典)。在 LeNet 的__init__()方法中会调用父类nn.Module__init__()方法,创建这 8 个属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def __init__(self):
"""
Initializes internal Module state, shared by both nn.Module and ScriptModule.
"""
torch._C._log_api_usage_once("python.nn_module")

self.training = True
self._parameters = OrderedDict()
self._buffers = OrderedDict()
self._backward_hooks = OrderedDict()
self._forward_hooks = OrderedDict()
self._forward_pre_hooks = OrderedDict()
self._state_dict_hooks = OrderedDict()
self._load_state_dict_pre_hooks = OrderedDict()
self._modules = OrderedDict()
  • **_parameters 属性**:存储管理 nn.Parameter 类型的参数
  • **_modules 属性**:存储管理 nn.Module 类型的参数
  • _buffers 属性:存储管理缓冲属性,如 BN 层中的 running_mean
  • 5 个 ***_hooks 属性:存储管理钩子函数

[PyTorch 学习笔记] 卷积层与nn.Conv

一、1D/2D/3D 卷积

卷积有一维卷积、二维卷积、三维卷积。一般情况下,卷积核在几个维度上滑动,就是几维卷积。比如在图片上的卷积就是二维卷积。

一维卷积

img

二维卷积

img

三维卷积

img

二、nn.Conv2d() 二维卷积

1
2
3
nn.Conv2d(self, in_channels, out_channels, kernel_size, stride=1,
padding=0, dilation=1, groups=1,
bias=True, padding_mode='zeros')

这个函数的功能是对多个二维信号进行二维卷积,主要参数如下:

  • in_channels输入通道数
  • out_channels输出通道数,等价于卷积核个数
  • kernel_size卷积核尺寸
  • stride步长
  • padding:填充宽度,主要是为了调整输出的特征图大小,一般把 padding 设置合适的值后,保持输入和输出的图像尺寸不变。
  • dilation:空洞卷积大小,默认为 1,这时是标准卷积,常用于图像分割任务中,主要是为了提升感受野
  • groups:分组卷积设置,主要是为了模型的轻量化,如在 ShuffleNet、MobileNet、SqueezeNet 中用到
  • bias:偏置

2.1 卷积尺寸计算(简化版)

这里不考虑空洞卷积,假设输入图片大小为 [公式],卷积核大小为 [公式],stride 为 [公式],padding 的像素数为 [公式],图片经过卷积之后的尺寸 [公式] 如下:

[公式]

下面例子的输入图片大小为 [公式],卷积大小为 [公式],stride 为 1,padding 为 0,所以输出图片大小为 [公式]

2.2 卷积网络示例

这里使用 input * channel 为 3,output_channel 为 1 ,卷积核大小为 [公式] 的卷积核 nn.Conv2d(3, 1, 3),使用 nn.init.xavier_normal_() 方法初始化网络的权值。代码如下:

1
2
3
4
conv_layer = nn.Conv2d(3, 1, 3)   
# 初始化卷积层权值
nn.init.xavier_normal_(conv_layer.weight.data)
img_conv = conv_layer(img_tensor)

我们通过conv_layer.weight.shape查看卷积核的 shape 是(1, 3, 3, 3),对应是(output_channel, input_channel, kernel_size, kernel_size)。所以第一个维度对应的是卷积核的个数,每个卷积核都是(3,3,3)。虽然每个卷积核都是 3 维的,执行的却是 2 维卷积。下面这个图展示了这个过程。

img

也就是每个卷积核在 input_channel 维度再划分,这里 input_channel 为 3,那么这时每个卷积核的 shape 是(3, 3)3 个卷积核在输入图像的每个 channel 上卷积后得到 3 个数,把这 3 个数相加,再加上 bias,得到最后的一个输出。

img

三、nn.ConvTranspose() 转置卷积

3.1 转置卷积原理

转置卷积又称为反卷积 (Deconvolution) 和部分跨越卷积 (Fractionally strided Convolution),用于对图像进行上采样

正常卷积如下:

img

原始的图片尺寸为 [公式],卷积核大小为 [公式][公式][公式]。由于卷积操作可以通过矩阵运算来解决,因此原始图片可以看作 [公式] 的矩阵 [公式],卷积核可以看作 [公式] 的矩阵 [公式],那么输出是 [公式]

转置卷积如下:

img

原始的图片尺寸[公式]卷积核大小[公式][公式][公式]。由于卷积操作可以通过矩阵运算来解决,因此原始图片可以看作 [公式] 的矩阵 [公式],卷积核可以看作 [公式] 的矩阵 [公式],那么输出是 [公式]

正常卷积核转置卷积矩阵的形状刚好是转置关系,因此称为转置卷积,但里面的权值不是一样的,卷积操作也是不可逆的

PyTorch 中的转置卷积函数如下:

1
2
3
nn.ConvTranspose2d(self, in_channels, out_channels, kernel_size, stride=1,
padding=0, output_padding=0, groups=1, bias=True,
dilation=1, padding_mode='zeros')

和普通卷积的参数基本相同,不再赘述。

3.2 转置卷积尺寸计算

简化版转置卷积尺寸计算

这里不考虑空洞卷积,假设输入图片大小为 [公式],卷积核大小为 [公式],stride 为 [公式],padding 的像素数为 [公式],图片经过卷积之后的尺寸 [公式] 如下,刚好和普通卷积的计算是相反的:

[公式]

转置卷积代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import os
import torch.nn as nn
from PIL import Image
from torchvision import transforms
from matplotlib import pyplot as plt
from common_tools import transform_invert, set_seed

set_seed(3) # 设置随机种子

# load img
path_img = os.path.join(os.path.dirname(os.path.abspath(__file__)), "imgs", "lena.png")
print(path_img)
img = Image.open(path_img).convert('RGB') # 0~255

# convert to tensor
img_transform = transforms.Compose([transforms.ToTensor()])
img_tensor = img_transform(img)
# 添加 batch 维度
img_tensor.unsqueeze_(dim=0) # C*H*W to B*C*H*W

# create convolution layer
# flag = 1
flag = 0
if flag:
conv_layer = nn.Conv2d(3, 1, 3) # input:(i, o, size) weights:(o, i , h, w)
# 初始化卷积层权值
nn.init.xavier_normal_(conv_layer.weight.data)
# nn.init.xavier_uniform_(conv_layer.weight.data)

# calculation
img_conv = conv_layer(img_tensor)

# transposed
flag = 1
# flag = 0
if flag:
conv_layer = nn.ConvTranspose2d(3, 1, 3, stride=2) # input:(input_channel, output_channel, size)
# 初始化网络层的权值
nn.init.xavier_normal_(conv_layer.weight.data)

# calculation
img_conv = conv_layer(img_tensor)

# ================================= visualization ==================================
print("卷积前尺寸:{}\n卷积后尺寸:{}".format(img_tensor.shape, img_conv.shape))
img_conv = transform_invert(img_conv[0, 0:1, ...], img_transform)
img_raw = transform_invert(img_tensor.squeeze(), img_transform)
plt.subplot(122).imshow(img_conv, cmap='gray')
plt.subplot(121).imshow(img_raw)
plt.show()

转置卷积前后图片显示如下,左边原图片的尺寸是 (512, 512),右边转置卷积后的图片尺寸是 (1025, 1025)。

img

转置卷积后的图片一般都会有棋盘效应,像一格一格的棋盘,这是转置卷积的通病。

关于棋盘效应的解释以及解决方法,推荐阅读Deconvolution And Checkerboard Artifacts[1]

3.3 DCGAN

  • 生成器
    • nz = 100 : 潜在向量 z 的大小
    • ngf = 64 : 生成器中特征图的大小
    • ndf = 64 : 判别器中的特征映射的大小
img
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 生成器代码
class Generator(nn.Module):
def __init__(self, ngpu):
super(Generator, self).__init__()
self.ngpu = ngpu
self.main = nn.Sequential(
# 输入是Z,进入卷积
nn.ConvTranspose2d(nz, ngf * 8, 4, 1, 0, bias=False),
nn.BatchNorm2d(ngf * 8),
nn.ReLU(True),
# state size. (ngf*8) x 4 x 4
nn.ConvTranspose2d(ngf * 8, ngf * 4, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf * 4),
nn.ReLU(True),
# state size. (ngf*4) x 8 x 8
nn.ConvTranspose2d( ngf * 4, ngf * 2, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf * 2),
nn.ReLU(True),
# state size. (ngf*2) x 16 x 16
nn.ConvTranspose2d( ngf * 2, ngf, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf),
nn.ReLU(True),
# state size. (ngf) x 32 x 32
nn.ConvTranspose2d( ngf, nc, 4, 2, 1, bias=False),
nn.Tanh()
# state size. (nc) x 64 x 64
)

def forward(self, input):
return self.main(input)
  • 判别器代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Discriminator(nn.Module):
def __init__(self, ngpu):
super(Discriminator, self).__init__()
self.ngpu = ngpu
self.main = nn.Sequential(
# input is (nc) x 64 x 64
nn.Conv2d(nc, ndf, 4, 2, 1, bias=False),
nn.LeakyReLU(0.2, inplace=True),
# state size. (ndf) x 32 x 32
nn.Conv2d(ndf, ndf * 2, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 2),
nn.LeakyReLU(0.2, inplace=True),
# state size. (ndf*2) x 16 x 16
nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 4),
nn.LeakyReLU(0.2, inplace=True),
# state size. (ndf*4) x 8 x 8
nn.Conv2d(ndf * 4, ndf * 8, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 8),
nn.LeakyReLU(0.2, inplace=True),
# state size. (ndf*8) x 4 x 4
nn.Conv2d(ndf * 8, 1, 4, 1, 0, bias=False),
nn.Sigmoid()
)

def forward(self, input):
return self.main(input)

三、卷积参数更新

img

与全连接神经网络不同,卷积神经网络每一层中的节点并不是与前一层的所有神经元节点相连,而是只与前一层的部分节点相连。并且和每一个节点相连的那些通路的权重都是相同的。举例来说,对于二维卷积神经网络,其权重就是卷积核里面的那些值,这些值从上而下,从左到右要将图像中每个对应区域卷积一遍然后将积求和输入到下一层节点中激活,得到下一层的特征图。因此其权重和偏置更新公式与全连接神经网络不通。

  • 降低的计算量
  • 权重得到共享,降低了参数量

根据《Deep learning》这本书的描述,卷积神经网络有3个核心思想:

  • 稀疏交互(sparse interactions),即每个节点通过固定个(一般等于卷积核元素的数目,远小于前一层节点数)连接与下一层的神经元节点相连; 尽管是稀疏连接,但是在更深层的神经单元中,其可以间接地连接到全部或大部分输入图像。如果采用了步幅卷积或者池化操作,那么这种间接连接全部图像的可能性将会增加。
  • 参数共享(parameter sharing),以2D卷积为例,每一层都通过固定的卷积核产生下一层的特征图,而这个卷积核将从上到下、从左到右遍历图像每一个对应区域;
  • 等变表示(equivariant representations),卷积和参数共享的形式使得神经网络具有平移等变形,即f(g(x))=g(f(x))。另外,pooling操作也可以使网络具有局部平移不变形。局部平移不变形是一个很有用的性质,尤其是当我们只关心某个特征是否出现而不关心它出现的具体位置时。池化可以看作增加了一个无线强的先验,这一层学的函数必须具有对少量平移的不变形。

3.1 正向传播

如何对卷积核数量和卷积步长进行选择?

  • 卷积核的数量越多,意味着提取的特征种类越多,通常会取2^n个;
  • 步长通常不会超过卷积核宽度或长度,步长大于1的时候有下采样的效果,比如步长为2时,可以让feature map的尺寸缩小一半。

[PyTorch 学习笔记] 池化层、线性层和激活函数层

一、池化层

池化的作用则体现在降采样:保留显著特征、降低特征维度,增大 kernel 的感受野。 另外一点值得注意:pooling 也可以提供一些旋转不变性。 池化层可对提取到的特征信息进行降维,一方面使特征图变小,简化网络计算复杂度并在一定程度上避免过拟合的出现;一方面进行特征压缩,提取主要特征

1.1 nn.MaxPool2d() 最大池化

1
2
3
nn.MaxPool2d(kernel_size, stride=None, padding=0, dilation=1, return_indices=False, ceil_mode=False)
maxpool_layer = nn.MaxPool2d((2, 2), stride=(2, 2))
# input:(i, o, size) weights:(o, i , h, w)

这个函数的功能是进行 2 维的最大池化,主要参数如下:

  • kernel_size:池化核尺寸
  • stride:步长,通常与 kernel_size 一致
  • padding:填充宽度,主要是为了调整输出的特征图大小,一般把 padding 设置合适的值后,保持输入和输出的图像尺寸不变。
  • dilation:池化间隔大小,默认为 1。常用于图像分割任务中,主要是为了提升感受野
  • ceil_mode:默认为 False,尺寸向下取整。为 True 时,尺寸向上取整
  • return_indices:为 True 时,返回最大池化所使用的像素的索引,这些记录的索引通常在反最大池化时使用,把小的特征图反池化到大的特征图时,每一个像素放在哪个位置。
下图 (a) 表示反池化,(b) 表示上采样,(c) 表示反卷积。

img

1.2 nn.AvgPool2d() 平均池化

1
torch.nn.AvgPool2d(kernel_size, stride=None, padding=0, ceil_mode=False, count_include_pad=True, divisor_override=None)

这个函数的功能是进行 2 维的平均池化,主要参数如下:

  • kernel_size:池化核尺寸
  • stride:步长,通常与 kernel_size 一致
  • padding:填充宽度,主要是为了调整输出的特征图大小,一般把 padding 设置合适的值后,保持输入和输出的图像尺寸不变。
  • dilation:池化间隔大小,默认为 1。常用于图像分割任务中,主要是为了提升感受野
  • ceil_mode:默认为 False,尺寸向下取整。为 True 时,尺寸向上取整
  • count_include_pad:在计算平均值时,是否把填充值考虑在内计算
  • divisor_override:除法因子。在计算平均值时,分子是像素值的总和,分母默认是像素值的个数。如果设置了 divisor_override,把分母改为 divisor_override。
1
2
3
4
img_tensor = torch.ones((1, 1, 4, 4))
avgpool_layer = nn.AvgPool2d((2, 2), stride=(2, 2))
img_pool = avgpool_layer(img_tensor)
print("raw_img:\n{}\npooling_img:\n{}".format(img_tensor, img_pool))

1.3 nn.MaxUnpool2d() 最大值反池化

1
nn.MaxUnpool2d(kernel_size, stride=None, padding=0)

功能是对二维信号(图像)进行最大值反池化,主要参数如下:

  • kernel_size:池化核尺寸
  • stride:步长,通常与 kernel_size 一致
  • padding:填充宽度

二、 线性层

线性层又称为全连接层,其每个神经元与上一个层所有神经元相连,实现对前一层的线性组合或线性变换。

1
2
3
4
5
6
7
8
9
10
11
12
inputs = torch.tensor([[1., 2, 3]])
linear_layer = nn.Linear(3, 4)
linear_layer.weight.data = torch.tensor([[1., 1., 1.],
[2., 2., 2.],
[3., 3., 3.],
[4., 4., 4.]])

linear_layer.bias.data.fill_(0.5)
output = linear_layer(inputs)
print(inputs, inputs.shape)
print(linear_layer.weight.data, linear_layer.weight.data.shape)
print(output, output.shape)

输出为:

1
2
3
4
5
6
tensor([[1., 2., 3.]]) torch.Size([1, 3])
tensor([[1., 1., 1.],
[2., 2., 2.],
[3., 3., 3.],
[4., 4., 4.]]) torch.Size([4, 3])
tensor([[ 6.5000, 12.5000, 18.5000, 24.5000]], grad_fn=<AddmmBackward>) torch.Size([1, 4])

三、激活函数层

假设第一个隐藏层为:[公式],第二个隐藏层为:[公式],输出层为:

[公式]

如果没有非线性变换,由于矩阵乘法的结合性,多个线性层的组合等价于一个线性层。激活函数对特征进行非线性变换,赋予了多层神经网络具有深度的意义。下面介绍一些激活函数层。

3.1 nn.Sigmoid

  • 计算公式[公式]

  • 梯度公式[公式]

  • 特性

    • 输出值在(0,1),符合概率
    • 导数范围是 [0, 0.25],容易导致梯度消失
    • 输出为非 0 均值,破坏数据分布

img

3.2 nn.tanh

  • 计算公式[公式]

  • 梯度公式[公式]

  • 特性

    • 输出值在(-1, 1),数据符合 0 均值
    • 导数范围是 (0,1),容易导致梯度消失
  • img

3.3 nn.ReLU(修正线性单元)

  • 计算公式:[公式]

  • 梯度公式:[公式]

  • 特性:

    • 输出值均为正数,负半轴的导数为 0,容易导致死神经元
    • 导数是 1,缓解梯度消失,但容易引发梯度爆炸

img

针对 RuLU 会导致==死神经元==的缺点,出现了下面 3 种改进的激活函数。

nn.LeakyReLU

  • 有一个参数negative_slope:设置负半轴斜率

nn.PReLU

  • 有一个参数init:设置初始斜率,这个斜率是可学习的

nn.RReLU

R 是 random 的意思,负半轴每次斜率都是随机取 [lower, upper] 之间的一个数

  • lower:均匀分布下限
  • upper:均匀分布上限