合并隐藏层

如果模型中只通过单个仿射变换将输入直接映射到输出,这其中的“线性”意味着单调假设:特征任何增大都有可能导致模型输出增大(若权重为正)或者模型输出减少(为负)

对于感知机,它无法拟合 XOR 函数,只能产生线性分割面

为克服线性模型的限制,可将许多连接层堆叠在一起,每一层都输出到上面的层,直到生成最后的输出。可将前L1L-1 层看作“表示”,将最后一层看作“线性预测器”,该架构称为多层感知机(multilayer perceptron,简称 MLP

多层感知机解决了感知机 XOR 问题,但相较于 SVM:①需要选择多个超参数;②收敛较难;③SVM数学性更好一些

例如下图的多层感知机(4输入3输出,隐藏层有5个隐藏单元,层数为2且均为全连接层),每个输入都会影响隐藏层中每个神经元,同理,隐藏层中每个神经元又会影响输出层中的每个神经元。

其中,隐藏层的大小为超参数。

“一层”通常指可学习的权重的一层,hh 则是在激活函数之后的

多隐藏层

为构建更通用的多层感知机,从而产生更有表达能力的模型,可以堆叠多个上述的隐藏层。使用更深(注意不是更广)的网络能够更容易地逼近许多函数。

此时,超参数:隐藏层数、每层隐藏层的大小

如下:

h1=σ(W1x+b1)h2=σ(W2h1+b2)h3=σ(W3h2+b3)o=W4h3+b4\mathbf{h}_1 = \sigma(\mathbf{W}_1\mathbf{x} + \mathbf{b}_1) \\ \mathbf{h}_2 = \sigma(\mathbf{W}_2\mathbf{h}_1 + \mathbf{b}_2) \\ \mathbf{h}_3 = \sigma(\mathbf{W}_3\mathbf{h}_2 + \mathbf{b}_3) \\ \mathbf{o} = \mathbf{W}_4\mathbf{h}_3 + \mathbf{b}_4 \\

激活函数——从线性到非线性

为发挥多层结构的潜力,需要在仿射变换后对每个隐藏单元应用非线性激活函数σ\sigma,其输出值称为激活值,从而保证多层感知机不会退化为线性模型,如下:

H=σ(XW(1)+b(1))O=XW(2)+b(2)\mathbf{H} = \sigma(\mathbf{XW}^{(1)} + \mathbf{b}^{(1)}) \\ \mathbf{O} = \mathbf{XW}^{(2)} + \mathbf{b}^{(2)}

它们将输入信号转换为输出的可微运算

大多数激活函数为非线性,主要为了避免层数的“塌陷”

常用的激活函数包括 ReLU 函数、sigmod 函数、tanh 函数

最常用的即是 线性整流单元(Rectified linear unit, ReLU),实现简单且在各种预测任务中表现良好。

ReLU(x)=max(x,0){\rm ReLU(x)} = \max(x, 0)

也就说,ReLU 函数将正元素保留,所有负元素都被置为0

当输⼊为负时,ReLU 函数的导数为0,而当输⼊为正时,ReLU 函数的导数为1。注意,当输⼊值精确等于0时,ReLU 函数不可导。在此时,我们默认使⽤左侧的导数,即当输⼊为0时导数为0。

使用 ReLU 的原因:求导表现良好,要么使参数消失、要么使参数通过;优化表现更好,减轻神经网络的梯度消失问题(见下文)

使用 sigmod 函数,指数运算开销较大,在CPU上进行一次指数运算,其开销相当于100次乘法运算。

梯度爆炸与梯度消失

考虑具有dd 层的神经网络,在计算损失函数ll 的梯度Wt\mathbf{W}_t 时,可以观察到共有dtd - t 个矩阵进行相乘:

lWt=lhdhdhd1...ht+1hthtWt\frac{\partial l}{\partial \mathbf{W}^t} = \frac{\partial l}{\partial \mathbf{h}^d} \frac{\partial \mathbf{h}^d}{\partial \mathbf{h}^{d-1}} ... \frac{\partial \mathbf{h}^{t+1}}{\partial \mathbf{h}^{t}} \frac{\partial \mathbf{h}^{t}}{\partial \mathbf{W}^{t}}

梯度爆炸

梯度爆炸(gradient exploding)问题:参数更新过大,破坏了模型的稳定收敛

导致的问题:

  • 梯度值超出值域(infinity):对于16位浮点数尤为严重

    在使用GPU训练时,浮点数通常会使用 16 位。

  • 对学习率敏感:不得不在训练期间大幅度改变学习率

    学习率偏大,导致权值更大,梯度更大;学习率偏小,导致模型训练没有进展

梯度消失

梯度消失(gradient vanishing)问题:参数更新过小,在每次更新时几乎不会移动,导致无法学习

常见原因:

梯度是累乘回传的,因此靠近数据的层得到的梯度会变得很小

每层线性运算之后的激活函数σ\sigma —— Sigmoid 函数。如下图,当 Sigmoid 函数输入比较大或比较小时,其梯度就会消失。当反向传播通过许多层时,除非Sigmoid 函数输入接近于0,否则整个乘积的梯度可能会消失。

由此,才会选择更加稳定的 ReLU 系列函数

导致的问题:

  • 梯度值将趋近于为0的渐变:对于16位浮点数,当梯度值小于 $2^{-24} \approx 5.96\times 10^{-8} $ 即为0
  • 训练没有进展:无论如何选择学习率
  • 底层训练基本无效:只有顶层训练会有效。将网络变得更深,可能并没有更好的效果

稳定模型训练

目标:确保渐变值在适当的范围内,如[106,103][10^{-6}, 10^3]

方法:

  • 改变神经网络框架结构(乘法变为加法),如 ResNet,LSTM
  • 批量归一化,渐变修剪
  • 适当的权重初始化以及激活函数

PyTorch 神经网络的块

更多复杂的神经网络结构,是层组的重复模式组成。

对于神经网络的块,它可以描述单个层、由多个层组成的组件或者整个模型本身。

多个层被组合成块,再使用块来抽象,能够将一些块组合成更大的组件。

在PyTorch中,块由类(class)表示。

顺序块

在线性回归模型中,我们通过实例化 nn.Sequential 来构建模型。该 nn.Sequential 定义了一种特殊的 Module (即在 PyTorch 中表示一个块的类),它维护了一个由 Module 组成的有序列表。层的执行顺序,是作为参数进行传递的。

1
2
3
4
5
6
7
8
9
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)
net(X) # 调用模型获得模型的输出
1
2
3
4
tensor([[-0.1277, -0.0921, -0.0567,  0.0012, -0.1954, -0.0261,  0.1818, -0.0811,
0.0870, -0.2461],
[-0.0374, -0.0249, -0.0311, -0.0926, -0.1382, -0.1261, 0.2243, -0.1285,
0.0920, -0.3513]], grad_fn=<AddmmBackward>)

自定义块

自定义块能够更加灵活地定义参数,以及如何进行正向传播(执行控制流,或者执行自定义的数学运算)

实现自定义块需要提高下列方法:

  • 自己的构造方法:__init__(),根据需要初始化模型参数
  • 正向传播方法:forward(),其中将输入数据作为参数。通过正向传播方法来生成输出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 任何一个层或者神经网络,都可看做Module的子类
class MLP(nn.Module):

# 定义一些需要的类或者参数
def __init__(self):
# 已经包括对weight进行初始化
super().__init__()

self.hidden = nn.Linear(20, 256)
self.out = nn.Linear(256, 10)

# __call__方法定义了forward(),而__call__方法可以直接通过"对象名()"方式调用forward()
# 定义前向计算如何计算
def forward(self, X):
return self.out(
F.relu( ## nn.Module已经实现了ReLU函数
self.hidden(X) # 得到隐藏层的输出
)
)

net = MLP() # 实例化多层感知机的层
net(X)
1
2
3
4
tensor([[ 0.0847,  0.1138,  0.1566, -0.0616,  0.1425, -0.1011,  0.0108,  0.1416,
-0.0965, -0.0415],
[ 0.0932, 0.0636, 0.2682, -0.0951, 0.0942, -0.0673, -0.0293, 0.1489,
-0.1779, -0.0966]], grad_fn=<AddmmBackward>)

自定义带参数的层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MyLinear(nn.Module):
def __init__(self, in_units, units):
# 输入维度大小、输出维度大小
super().__init__()
# 注意,参数需要转化为Parameter类的实例
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)

# 实例化自定义带参数的层
dense = MyLinear(5, 3)
print(dense.weight)

# 使用自定义层来构建更大的模型
print(dense(torch.rand(2, 5)))
net = nn.Sequential(MyLinear(64, 8), MyLinear(8, 1))
net(torch.rand(2, 64))
1
2
3
4
5
6
7
8
9
10
Parameter containing:
tensor([[ 1.3052, 0.0695, -0.0955],
[-1.0995, -0.9732, 0.4702],
[ 0.1498, -0.6493, 1.1616],
[ 0.3355, 0.7083, 0.7779],
[-1.1136, -0.4356, -0.4151]], requires_grad=True)
tensor([[0.0000, 0.0000, 0.0000],
[0.7157, 0.0000, 0.0000]])
tensor([[6.6546],
[0.0000]])

PyTorch 模型参数管理

参数管理包括以下三个内容:

  • 访问参数,用于调试、诊断和可视化
  • 参数初始化
  • 在不同模型组件之间共享参数

参数访问

对于 nn.Sequential 类定义的模型,可通过索引来访问模型的任意层(类似于列表)

首先定义模型:

1
2
3
net = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 1))
X = torch.rand(size=(2, 4))
net(X)

访问模型的参数,其中每个参数都表示为参数(Parameter) 类的一个实例,注意是一个复合的对象,包含数值本身、梯度及额外信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# net[2]指nn.Linear(8, 1)
print(net[2].state_dict())
# OrderedDict([('weight', tensor([[ 0.2160, -0.2482, -0.1832, 0.2357, -0.3053, 0.1710, -0.2609, -0.3186]])), ('bias', tensor([-0.2203]))])

print(type(net[2].bias))
# <class 'torch.nn.parameter.Parameter'>

print(net[2].bias)
# Parameter containing:tensor([-0.2203], requires_grad=True)

print(net[2].bias.data)
# tensor([-0.2203])

print(net[2].weight.grad == None) # 还没做反向计算,参数梯度处于初始状态,故返回 True


# 一次性访问所有参数(递归整个树来提取每个子块的参数)
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 定义生成块的函数(可以说是块工厂)
def littleblock():
return nn.Sequential(nn.Linear(4, 8),
nn.ReLU(),
nn.Linear(8, 4),
nn.ReLU())

# 组合为更大的块
def bigblock():
net = nn.Sequential()
for i in range(4):
net.add_module(f'my block {i}', littleblock())
return net

rgnet = nn.Sequential(bigblock(), nn.Linear(4, 1))
rgnet(X)
print(rgnet)
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
Sequential(
(0): Sequential(
(my block 0): Sequential(
(0): Linear(in_features=4, out_features=8, bias=True)
(1): ReLU()
(2): Linear(in_features=8, out_features=4, bias=True)
(3): ReLU()
)
(my block 1): Sequential(
(0): Linear(in_features=4, out_features=8, bias=True)
(1): ReLU()
(2): Linear(in_features=8, out_features=4, bias=True)
(3): ReLU()
)
(my block 2): Sequential(
(0): Linear(in_features=4, out_features=8, bias=True)
(1): ReLU()
(2): Linear(in_features=8, out_features=4, bias=True)
(3): ReLU()
)
(my block 3): Sequential(
(0): Linear(in_features=4, out_features=8, bias=True)
(1): ReLU()
(2): Linear(in_features=8, out_features=4, bias=True)
(3): ReLU()
)
)
(1): Linear(in_features=4, out_features=1, bias=True)
)

参数初始化

默认情况下,PyTorch会根据一个范围(根据输入和输出维度计算)均匀地初始化权重和偏置矩阵

nn.init 模块提供了多种预置初始化方法

(一)内置初始化:例如下列代码,调用内置初始化器,对所有权重参数初始化为标准差为0.01的高斯随机变量,且将偏置参数设置为0。

TIPS:诸如 normal_ 中的下划线 _ ,一般指原地替换操作

  • 均匀分布:服从U(a,b)U(a, b)

    均匀分布,即相同长度间隔的分布概率是等可能的。它由两个参数aabb 来定义,分别表示数轴上的最小值和最大值,可写为U(a,b)U(a, b)

    1
    torch.nn.init.uniform_(tensor, a=0, b=1)
  • 高斯分布:服从N(mean,std)N(mean, std)

    1
    torch.nn.init.normal_(tensor, mean=0, std=1)
  • 初始化常数(不推荐)

    1
    torch.nn.init.constant_(tensor, val)
  • Xavier

    1
    2
    torch.nn.init.xavier_uniform_(tensor, gain=1) # 均匀分布
    torch.nn.init.xavier_normal_(tensor, gain=1) # 高斯分布

(二)自定义初始化:

直接设置参数:

1
2
net[0].weight.data[:] += 233
net[1].weight.data[0, 0] = 42

自定义函数应用至 net 中:例如,使用以下分布为任意权重参数ww 来定义初始化函数:

w{U(5,10)with probability 0.250with probability 0U(10,5)with probability 0.25w \sim \begin{cases} U(5, 10) & \text{with probability 0.25} \\ 0 & \text{with probability 0} \\ U(-10, -5) & \text{with probability 0.25} \\ \end{cases}

1
2
3
4
5
6
7
def my_init(m):
if type(m) == nn.Linear:
nn.init.uniform_(m.weight, -10, 10) # 先以U(10, 10)均匀分布初始化参数
m.weight.data *= m.weight.data.abs() >= 5 # 若值在(-5, 5)则置为0

net.apply(my_init)
net[0].weight[:2]
1
2
tensor([[ 5.2321, -5.7124, -5.1867, -0.0000],
[-8.0589, -7.5297, 6.9211, -0.0000]], grad_fn=<SliceBackward>)

参数绑定

若希望在多个层之间能够共享参数,可以如下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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(net[2].weight.data[0] == net[4].weight.data[0])
net[2].weight.data[0, 0] = 100 # 修改其中一个值

# 确保它们实际上是同一个对象,而不只是相同的值
print(net[2].weight.data[0] == net[4].weight.data[0])
1
2
tensor([True, True, True, True, True, True, True, True])
tensor([True, True, True, True, True, True, True, True])

可以观察到第二层与第三层的参数是绑定(共享)的,注意,它们共享的是同一个对象(张量)

只有当创建的实例对象,并放在不同的地方,才会共享参数

因此,它们不仅值相等,梯度也相等

由此,在反向传播期间,第二个隐藏层和第三个隐藏层的梯度会累加在一起

PyTorch 读写文件

加载和保存张量

对于单个张量,可调用 loadsave 方法分别读写它们

1
2
3
4
x = torch.arange(4)
torch.save(x, './x-file') # 保存并命名为x-file
x2 = torch.load("./x-file") # 将存储至文件中的数据,读回内存中
x2 # tensor([0, 1, 2, 3])

当然,可存储一个张量列表,还可将其读回内存

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

此外,还可以写入或读入 从字符串映射到张量 的字典

1
2
3
mydict = {'x': x, 'y': y}
torch.save(mydict, 'mydict')
mydict2 = torch.load('mydict')

加载和保存模型参数

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)

# 将 模型参数 存储为一个叫做"mlp.params"的文件
torch.save(net.state_dict(), 'mlp.params')

# 实例化 原始多层感知机模型的 一个备份
clone = MLP()
# 直接读入磁盘文件中已经存储(之前已经训练好)的模型参数
clone.load_state_dict(torch.load('mlp.params'))

# 从
clone.eval()
1
2
3
4
MLP(
(hidden): Linear(in_features=20, out_features=256, bias=True)
(output): Linear(in_features=256, out_features=10, bias=True)
)