提示:仅仅是学习记录笔记,搬运了学习课程的ppt内容,本意不是抄袭!望大家不要误解!纯属学习记录笔记!!!!!!
文章目录
前言
模型病灶与模型调优。实际上模型优化更像是对“患病”的模型进行诊断、然后对症下药,通过这些“治疗”方法,最终让模型运行恢复正常且健康的状态,并得出较好的模型预测结果。尽管优化方法就像一种种药品,各自都标注了适用的病症,但深度学习模型在某种程度上就像人体一样,是个非常复杂的系统,很多时候并不是“A药治A病”、并不是一个“只要…就…”的命题。模型优化和疾病治疗一样,很多时候都是多种手段作用于复杂系统、最终通过这个复杂系统自身的特性发挥作用。业余算法工程师进行模型调优就像我们平时去药店买药,啥病买啥药,然后挑个贵的;而专业的算法工程师进行模型调优则更像医生为患者进行诊断,在了解病理的基础上,通过一些列更加专业的诊断手段判断病情,然后提出更加高效的综合性解决方案。
初级算法工程师只需要知道模型会有什么样的问题(什么病)以及分别可以用什么方法解决(都有什么药),模型调优时主要靠套用和反复调试(多尝试各种办法),而更加专业的算法工程师则需要知道这些模型问题背后的成因,也就是到底是什么原因导致了模型出现这些问题(病理),同时也需要掌握更加丰富的诊断模型问题的一些方法(不仅仅是看模型评估指标结果),然后了解优化方法的基本原理以及发挥作用的方式,当然至关重要的一段是需要在长期实践中不断积累经验,才能最终获得专业性的长足的进步。
一、梯度消失与梯度爆炸
对于神经网络这个复杂系统来说,在模型训练过程中,一个最基础、同时也最常见的问题,就是梯度消失和梯度爆炸。
二、Sigmoid和tanh激活函数的梯度更新问题
理论说明
对于sigmoid激活函数来说,简答的叠加是极容易出现梯度消失的问题。sigmoid函数及导函数图像如下所示:
x = torch.arange(-5, 5, 0.1)
x.requires_grad = True
sigmoid_y = torch.sigmoid(x)
#反向传播
sigmoid_y.sum().backward()
#sigmoid函数图像
plt.subplot(121)
plt.plot(x.detach(), sigmoid_y.detach())
plt.title('sigmoid Function')
#sigmoid导数图像
plt.subplot(122)
plt.plot(x.detach(), x.grad.detach())
plt.title('sigmoid Derivative function')
plt.show()
单独观察sigmoid导数的情况
#sigmoid函数图像
plt.plot(x.detach(), x.grad.detach())
plt.title("Sigmoid Derivative function")
plt.show()
我们发现,Sigmoid导函数最大值为0.25(在0点处取到),当x较大或者较小时,导函数取值趋于0。此时如果我们假设还是上述结构的三层神经网络,则第一层参数梯度
g
r
a
d
1
grad_1
grad1由于计算过程出现两次导函数连乘,哪怕两次都导函数都取到最大值(虽然可能性小),
g
r
a
d
1
grad_1
grad1都将在0.0625的基础上进行其余部分相乘,最终结果也极有可能是个非常小的值,因此对于Sigmoid激活函数叠加的情况来说,是极容易出现梯度消失情况的。
梯度消失或者梯度爆炸,始终是个概率问题。我们不能说导函数取值取值小就一定会发生梯度消失问题,只是导函数最大值越小,越有可能发生梯度消失。
Sigmoid函数饱和区间
一般来说我们会将靠近sigmoid函数的左右两端的区间称为函数的饱和区间(如下图圈出部分)(也就是自变量绝对值较大的区间),不难发现,当自变量落入饱和区间时,因变量会趋于0或者1,而无论自变量是极小(负数绝对值极大)还是极大,都会使得导函数取值趋于0,从而更容易导致模型梯度消失。
Sigmoid激活函数叠加后的梯度消失问题
接下来,我们通过创建一个实例来观察Sigmoid激活函数叠加后梯度消失问题。
# 设置随机数种子
torch.manual_seed(420)
# 创建最高项为2的多项式回归数据集
features, labels = tensorGenReg(w=[2, -1], bias=False, deg=2)
# 进行数据集切分与加载
train_loader, test_loader = split_loader(features, labels)
# 创建随机数种子
#创建随机数种子
torch.manual_seed(420)
#实例化
sigmoid_model3 = Sigmoid_class3()
#然后让我们来观察各层参数的梯度随着迭代过程变化情况。
模型参数及梯度提取方法
pl = list(sigmoid_model3.parameters())
print(pl)
从结果可以看出有四层网络的w、b参数,一共八个参数值
.parameters()方法返回模型所有参数,包括截距,但需要使用list将其转化为显式的列表。此时列表中的每个元素都是带名称的张量,关于带名称的张量会在此后课程中介绍,此处我们只需要知道模型参数本身也是张量,并且是可微张量即可。并且,就像此前说到的一样,推导过程中我们使用数据是按照行排列,一行代表一条数据,一列代表数据的一个特征,而PyTorch在进行计算时,会将数据转化为按列排列,一列代表一条数据,因此PyTorch中我们查看到的模型参数和推导的模型参数互为转置关系。我们可以通过如下过程简单说明。
#print(list(sigmoid_model3.modules())[1])
#查看神经网络第一层的结构
#Linear(in_features=2, out_features=4, bias=True)
#print(list(sigmoid_model3.modules())[1](f))
#将f带入模型中得到的第一层神经网络的输出
#tensor([[-0.7721, -0.9973, -1.5997, 0.9395]], grad_fn=<AddmmBackward0>)
p = list(sigmoid_model3.parameters())
#print(p)
print(p[0]) #取出第一层神经网络的w值
print(p[1]) #取出第一层神经网络的b值
a = torch.mm(f, p[0].t()) + p[1].t()
print(a)
#tensor([[-0.7721, -0.9973, -1.5997, 0.9395]], grad_fn=<AddBackward0>)
#跟我们直接提取出来的第一层神经网络的输出值一致
模型.modules()的使用
#print(sigmoid_model3.modules())
#模型继承了modules的内容
#<generator object Module.modules at 0x7fba6228e660>是一个对象,我们可以通过list打开
#print(list(sigmoid_model3.modules()))
'''
[Sigmoid_class3(
(linear1): Linear(in_features=2, out_features=4, bias=True)
(linear2): Linear(in_features=4, out_features=4, bias=True)
(linear3): Linear(in_features=4, out_features=4, bias=True)
(linear4): Linear(in_features=4, out_features=1, bias=True)
), Linear(in_features=2, out_features=4, bias=True), Linear(in_features=4, out_features=4, bias=True), Linear(in_features=4, out_features=4, bias=True), Linear(in_features=4, out_features=1, bias=True)]
'''
print(list(sigmoid_model3.modules())[1])
#查看神经网络第一层的结构
#Linear(in_features=2, out_features=4, bias=True)
print(list(sigmoid_model3.modules())[1](f))
#将f带入模型中得到的第一层神经网络的输出
#tensor([[-0.7721, -0.9973, -1.5997, 0.9395]], grad_fn=<AddmmBackward0>)
p = list(sigmoid_model3.modules())
print(p)
print(list(p))
'''
[Sigmoid_class3(
(linear1): Linear(in_features=2, out_features=4, bias=True)
(linear2): Linear(in_features=4, out_features=4, bias=True)
(linear3): Linear(in_features=4, out_features=4, bias=True)
(linear4): Linear(in_features=4, out_features=1, bias=True)
), Linear(in_features=2, out_features=4, bias=True), Linear(in_features=4, out_features=4, bias=True), Linear(in_features=4, out_features=4, bias=True), Linear(in_features=4, out_features=1, bias=True)]
'''
#print(list(p[0].parameters()))
'''
[Sigmoid_class3(
(linear1): Linear(in_features=2, out_features=4, bias=True)
(linear2): Linear(in_features=4, out_features=4, bias=True)
(linear3): Linear(in_features=4, out_features=4, bias=True)
(linear4): Linear(in_features=4, out_features=1, bias=True)
), Linear(in_features=2, out_features=4, bias=True), Linear(in_features=4, out_features=4, bias=True), Linear(in_features=4, out_features=4, bias=True), Linear(in_features=4, out_features=1, bias=True)]
'''
#说明p[0]等同于list(sigmoid_model3.modules()),可以查看所有神经网络层的结构
print(p[1])
#从模型里面提出来第一层网络结构
#Linear(in_features=2, out_features=4, bias=True)
print(list(p[1].parameters()))
#提取出第一层神经网络的所有参数值,包括权重和偏置
'''
tensor([[ 0.4318, -0.4256],
[ 0.6730, -0.5617],
[-0.2157, -0.4873],
[ 0.5453, 0.2653]], requires_grad=True), Parameter containing:
tensor([-0.3527, -0.5469, -0.4094, -0.1364], requires_grad=True)]
'''
print(list(p[1].weight))
#[tensor([ 0.4318, -0.4256], grad_fn=<UnbindBackward0>),
# tensor([ 0.6730, -0.5617], grad_fn=<UnbindBackward0>),
# tensor([-0.2157, -0.4873], grad_fn=<UnbindBackward0>),
# tensor([0.5453, 0.2653], grad_fn=<UnbindBackward0>)]
#提取第一层神经网络中的权重值
print(list(p[1].bias))
#[tensor(-0.3527, grad_fn=<UnbindBackward0>),
# tensor(-0.5469, grad_fn=<UnbindBackward0>),
# tensor(-0.4094, grad_fn=<UnbindBackward0>),
# tensor(-0.1364, grad_fn=<UnbindBackward0>)]
#这是提取出第一层神经网路的偏置,需要注意的是第0层是没有权重和偏置的
print(p[1](f))
#求出把f带入神经网络中的第一层神经网络上的参数值
#tensor([[-0.7721, -0.9973, -1.5997, 0.9395]], grad_fn=<AddmmBackward0>)
print(sigmoid_model3(f)[1])
#tensor([[-0.7721, -0.9973, -1.5997, 0.9395]], grad_fn=<AddmmBackward0>)
#sigmoid_model3(f)[1]和p[1](f)的效果是一致的
查看sigmoid激活函数下的权重变化
#提取weight,
for m in sigmoid_model3.modules():
if isinstance(m, nn.Linear): #这一步的主要用处是排除第0层的影响
print(m.weight)
'''
Parameter containing:
tensor([[ 0.4318, -0.4256],
[ 0.6730, -0.5617],
[-0.2157, -0.4873],
[ 0.5453, 0.2653]], requires_grad=True)
Parameter containing:
tensor([[-0.2552, 0.3644, -0.2104, -0.3271],
[-0.1542, -0.4883, -0.2428, -0.2728],
[ 0.1076, 0.4066, 0.0540, -0.2914],
[ 0.2058, -0.2129, -0.2367, -0.0958]], requires_grad=True)
Parameter containing:
tensor([[ 3.0199e-01, -4.3436e-01, -3.9335e-01, -6.6525e-02],
[ 4.5806e-04, 3.1209e-01, -4.3974e-01, 2.0861e-01],
[-4.2916e-01, 8.0655e-02, 3.3044e-01, 6.8971e-02],
[ 1.5964e-01, 3.1789e-01, 4.9465e-01, -3.1377e-01]],
requires_grad=True)
Parameter containing:
tensor([[-0.3468, -0.4897, 0.2213, 0.4947]], requires_grad=True)
'''
#提取bias也是同理
for m in sigmoid_model3.modules():
if isinstance(m, nn.Linear): #这一步的主要用处是排除第0层的影响
print(m.bias)
'''
Parameter containing:
tensor([-0.2609, 0.0550, 0.4059, 0.0682], requires_grad=True)
Parameter containing:
tensor([ 0.1638, 0.4116, 0.2843, -0.4529], requires_grad=True)
Parameter containing:
tensor([-0.4843], requires_grad=True)
'''
#提取每一个权重的梯度值
for m in sigmoid_model3.modules():
if isinstance(m, nn.Linear):
print(m.weight.grad)
'''
None
None
None
None
'''
#之所以是None,是因为我们还没有进行反向传播
fit(net=sigmoid_model3,
criterion=nn.MSELoss(),
optimizer=optim.SGD(sigmoid_model3.parameters(), lr=0.03),
batchdata=train_loader,
epochs=5,
cla=False)
for m in sigmoid_model3.modules():
if isinstance(m, nn.Linear):
print(m.weight)
'''
tensor([[ 0.4372, -0.4246],
[ 0.7055, -0.5486],
[-0.2128, -0.4854],
[ 0.5289, 0.2731]], requires_grad=True)
Parameter containing:
tensor([[-0.2624, 0.3539, -0.2097, -0.3360],
[-0.1441, -0.4683, -0.2514, -0.2627],
[ 0.1494, 0.4727, 0.0456, -0.2496],
[ 0.1916, -0.2341, -0.2344, -0.1131]], requires_grad=True)
Parameter containing:
tensor([[ 0.2618, -0.4739, -0.4603, -0.1163],
[-0.0543, 0.2618, -0.5319, 0.1416],
[-0.4360, 0.0395, 0.3270, 0.0522],
[ 0.1733, 0.2853, 0.5280, -0.3078]], requires_grad=True)
Parameter containing:
tensor([[-0.0287, -0.1232, 0.5845, 0.7604]], requires_grad=True)
'''
#可以看出来我们的神经网络是已经得到了学习和训练,权重值发生了变化
通过和模型训练前的惯出对比,不难看出,第一层线性层参数变化非常小,而最后一层参数值变化较大。不过这种观察还是比较粗糙的,我们希望能够观察到每一轮迭代结束后各层参数的梯度。由于我们定义的fit函数是在每一轮开始时将梯度清零,而每一轮迭代结束时还会保留梯度,因此我们可以直接使用.grad查看当前各层参数梯度情况。
for m in sigmoid_model3.modules():
if isinstance(m, nn.Linear):
print(m.weight.grad)
'''
tensor([[-0.0043, -0.0027],
[-0.0176, -0.0117],
[-0.0040, -0.0021],
[ 0.0098, 0.0052]])
tensor([[ 0.0062, 0.0077, -0.0007, 0.0115],
[-0.0107, -0.0133, 0.0014, -0.0202],
[-0.0341, -0.0421, 0.0027, -0.0627],
[ 0.0124, 0.0154, -0.0012, 0.0229]])
tensor([[ 0.0066, 0.0060, 0.0113, 0.0083],
[ 0.0209, 0.0191, 0.0359, 0.0265],
[-0.0773, -0.0708, -0.1329, -0.0979],
[-0.1050, -0.0960, -0.1804, -0.1329]])
tensor([[-0.6299, -0.7897, -0.8889, -0.7292]])
'''
从上述结果能够看出前几层梯度较小,后几层梯度较大。当然,一种更加直观的观测手段,是通过绘制小提琴图来对各层梯度进行观察,具体过程如下:
vp = []
for i,m in enumerate(sigmoid_model3.modules()):
if isinstance(m, nn.Linear):
vp_x = m.weight.grad.detach().reshape(-1,1).numpy()
#每一层的参数梯度值
vp_y = np.full_like(vp_x, i)
#对每一层的参数梯度值定义隐藏层数
vp_a = np.concatenate((vp_x, vp_y), 1) #从列方向上进行拼接
vp.append(vp_a)
#print(vp)
'''
[array([[-0.00434157, 1. ],
[-0.00269753, 1. ],
[-0.01757522, 1. ],
[-0.01171168, 1. ],
[-0.00395311, 1. ],
[-0.00208129, 1. ],
[ 0.00981917, 1. ],
[ 0.0052211 , 1. ]], dtype=float32), array([[ 6.2324703e-03, 2.0000000e+00],
[ 7.7386899e-03, 2.0000000e+00],
[-6.5047480e-04, 2.0000000e+00],
[ 1.1534125e-02, 2.0000000e+00],
[-1.0680488e-02, 2.0000000e+00],
[-1.3310125e-02, 2.0000000e+00],
[ 1.4156206e-03, 2.0000000e+00],
[-2.0234289e-02, 2.0000000e+00],
[-3.4127973e-02, 2.0000000e+00],
[-4.2117201e-02, 2.0000000e+00],
[ 2.7033817e-03, 2.0000000e+00],
[-6.2722228e-02, 2.0000000e+00],
[ 1.2428438e-02, 2.0000000e+00],
[ 1.5413593e-02, 2.0000000e+00],
[-1.2172726e-03, 2.0000000e+00],
[ 2.2919590e-02, 2.0000000e+00]], dtype=float32), array([[ 0.00657502, 3. ],
[ 0.00601593, 3. ],
[ 0.01130069, 3. ],
[ 0.00832436, 3. ],
[ 0.02089001, 3. ],
[ 0.01911173, 3. ],
[ 0.03590287, 3. ],
[ 0.02645173, 3. ],
[-0.0773131 , 3. ],
[-0.07075455, 3. ],
[-0.1328626 , 3. ],
[-0.09789874, 3. ],
[-0.10497086, 3. ],
[-0.09604399, 3. ],
[-0.18040222, 3. ],
[-0.13292234, 3. ]], dtype=float32), array([[-0.6299378 , 4. ],
[-0.7896851 , 4. ],
[-0.88887113, 4. ],
[-0.7292463 , 4. ]], dtype=float32)]
'''
#然后再按照行进行拼接
vp_r= np.concatenate((vp), 0)
print(vp_r)
'''
[[-4.34156507e-03 1.00000000e+00]
[-2.69753067e-03 1.00000000e+00]
[-1.75752193e-02 1.00000000e+00]
[-1.17116775e-02 1.00000000e+00]
[-3.95311229e-03 1.00000000e+00]
[-2.08128570e-03 1.00000000e+00]
[ 9.81916953e-03 1.00000000e+00]
[ 5.22109680e-03 1.00000000e+00]
[ 6.23247027e-03 2.00000000e+00]
[ 7.73868989e-03 2.00000000e+00]
[-6.50474802e-04 2.00000000e+00]
[ 1.15341246e-02 2.00000000e+00]
[-1.06804883e-02 2.00000000e+00]
[-1.33101251e-02 2.00000000e+00]
[ 1.41562056e-03 2.00000000e+00]
[-2.02342886e-02 2.00000000e+00]
[-3.41279730e-02 2.00000000e+00]
[-4.21172008e-02 2.00000000e+00]
[ 2.70338170e-03 2.00000000e+00]
[-6.27222285e-02 2.00000000e+00]
[ 1.24284383e-02 2.00000000e+00]
[ 1.54135926e-02 2.00000000e+00]
[-1.21727260e-03 2.00000000e+00]
[ 2.29195897e-02 2.00000000e+00]
[ 6.57501677e-03 3.00000000e+00]
[ 6.01593032e-03 3.00000000e+00]
[ 1.13006877e-02 3.00000000e+00]
[ 8.32436327e-03 3.00000000e+00]
[ 2.08900124e-02 3.00000000e+00]
[ 1.91117264e-02 3.00000000e+00]
[ 3.59028727e-02 3.00000000e+00]
[ 2.64517292e-02 3.00000000e+00]
[-7.73131028e-02 3.00000000e+00]
[-7.07545504e-02 3.00000000e+00]
[-1.32862598e-01 3.00000000e+00]
[-9.78987366e-02 3.00000000e+00]
[-1.04970865e-01 3.00000000e+00]
[-9.60439891e-02 3.00000000e+00]
[-1.80402219e-01 3.00000000e+00]
[-1.32922336e-01 3.00000000e+00]
[-6.29937828e-01 4.00000000e+00]
[-7.89685071e-01 4.00000000e+00]
[-8.88871133e-01 4.00000000e+00]
[-7.29246318e-01 4.00000000e+00]]
'''
ax = sns.violinplot(y = vp_r[:, 0], x=vp_r[:, 1])
ax.set(xlabel='num_hidden', title='Gradients')
plt.show()
由这个图我们可以看出,最后一层的梯度变化是比较大的,越是靠里的隐藏层,其梯度值越小,第一个隐藏层的梯度值处于0附近,已经出现了梯度消失的情况。
小提琴图其实是一种统计分析图像,基本含义如下所示:
当然,为了更加全面的观测模型迭代过程中数据和参数的变化情况,我们继续添加用于观测每一层输入数据(也被称为扇入数据)、激活函数处理后的输出数据(也被称为扇出数据)以及每一个线性层自身参数情况的小提琴图。
vp = []
for i, m in enumerate(sigmoid_model3.modules()):
if isinstance(m, nn.Linear):
vp_x = m.weight.detach().reshape(-1, 1).numpy()
vp_y = np.full_like(vp_x, i)
vp_a = np.concatenate((vp_x, vp_y), 1)
vp.append(vp_a)
vp_r = np.concatenate((vp), 0)
ax = sns.violinplot(y=vp_r[:, 0], x=vp_r[:, 1])
ax.set(xlabel='num_hidden', title='weights')
plt.show()
至此,我们可以回顾lesson13.2里面的多层sigmoid激活函数loss效果图
由于Sigmoid激活函数叠加会造成严重梯度消失问题,因此复杂模型,如Sigmoid3和Sigmoid4的前几层在迭代过程中逐渐丧失变化的可能性,也就是学习能力,从而导致经过了很多轮的迭代,但最终结果只能和Sigmoid2和Sigmoid3持平的情况。
需要注意的是,对于复杂模型来说,如果部分层失去学习能力(参数迭代的可能性),其实模型判别效力就和简单模型无异。从此也能看出对复杂模型进行有效训练的重要性。
三、tanh函数的梯度计算问题
#绘制tanh函数的图像和导数图像
x = torch.arange(-5, 5, 0.1)
x.requires_grad=True
tanh_y = torch.tanh(x)
# 反向传播
tanh_y.sum().backward()
# tanh函数图像
plt.subplot(121)
plt.plot(x.detach(), tanh_y.detach())
plt.title("tanh Function")
# tanh导函数图像
plt.subplot(122)
plt.plot(x.detach(), x.grad.detach())
plt.title("tanh Derivative function")
plt.show()
对于tanh函数来说,导函数的取值分布在0-1之间的,看似导函数取值累乘之后也是趋于0的,但实际上,tanh激活函数的叠加即可能造成梯度消失、同时也可能造成梯度爆炸,原因是在实际建模过程中,影响前几层梯度的其他变量大多数情况都大于1,因此对于一个导函数极大值可以取到1的激活函数来说,还是有可能出现梯度爆炸的情况的。tanh激活函数有正有负,其数值更容易分布在0左右,也就是说其导数值更容易分布在1左右。
梯度爆炸和梯度消失中,所谓的前几层参数梯度过大或者过小也都是相对的概念,并没有明确定义梯度大过多少就是梯度爆炸、梯度小过多少就是梯度消失。另外,梯度爆炸和梯度消失的直接表现可归结为前后参数层梯度不一致,而二者的根本问题都是影响迭代收敛过程。
作为Sigmoid激活函数的“升级版”,tanh激活函数除了能够一定程度规避梯度消失问题外,还能够生成Zero-Centered Data,而确保输入层接收到Zero-Centered Data,则是解决梯度消失和梯度爆炸问题的关键。
torch.manual_seed(420)
#实例化模型
tanh_model4 = tanh_class4()
# 模型训练
train_l, test_l = model_train_test(tanh_model4, train_loader, test_loader, num_epochs=5, criterion=nn.MSELoss(), optimizer=optim.SGD, lr=0.03, cla=False, eva=mse_cal)
#观察各层参数
for m in tanh_model4.modules():
if isinstance(m, nn.Linear):
print(m.weight)
for m in tanh_model4.modules():
if isinstance(m, nn.Linear):
print(m.weight.grad)
weights_vp(tanh_model4, att="grad")
plt.show()
训练5个epochs的梯度分布情况如上,能够看出,上述模型存在一定程度的梯度爆炸的情况。当然,对于tanh激活函数来说,由于激活函数本身的良好特性(也就是能够输出Zero-Centered Data),一般不会出现典型的梯度消失情况。但梯度爆炸同样会极大影响模型训练过程的稳定性,并且这种现象并不会因为模型迭代次数增加而消失。
# 实例化模型
tanh_model4 = tanh_class4()
train_l, test_l = model_train_test(tanh_model4,
train_loader,
test_loader,
num_epochs = 20,
criterion = nn.MSELoss(),
optimizer = optim.SGD,
lr = 0.03,
cla = False,
eva = mse_cal)
# 观察各层梯度
for m in tanh_model4.modules():
if isinstance(m, nn.Linear):
print(m.weight.grad)
weights_vp(tanh_model4, att="grad")
plt.show()
训练了20个epochs,得到的梯度分布情况如下:
我们发现,随着迭代次数增加,这种梯度爆炸的情况有增无减。从该角度出发,我们也能理解为何tanh4在Lesson 13.2中迭代过程如此不平稳的原因。
从根本上来说,tanh激活函数的迭代不平稳就是因为部分层的部分梯度存在极端值,当然,这种极端值也导致部分层无法有效学习、最终影响模型效果。
四、Zero-Centered Data与Glorot条件
通过对Sigmoid和tanh激活函数叠加后的模型梯度变化情况分析,我们不难发现,梯度不平稳是影响模型建模效果的非常核心的因素。而这个看似简单问题的解决方案,却花费了研究人员数十年的时间才逐渐完善,我们现在所接触到的优化方法,也基本上是在15年前后提出的,而这些被验证的切实可行的优化方法,也是推动这一轮深度学习浪潮的技术因素。
整体来看,针对梯度不平稳的解决方案(优化方法)总共分为五类,分别是参数初始化方法、输入数据的归一化方法、衍生激活函数使用方法、学习率调度方法以及梯度下降优化方法。接下来,先介绍所有上述优化算法的一个基本理论,由Xavier Glorot在2010提出的Glorot条件。
1.Zero-centered Data
在介绍Glorot条件之前,我们先从一个更加朴素的角度出发,讨论关于Zero-Centered Data相关作用,从而帮助我们理解后续Glorot条件。
class Sigmoid_class1_test(nn.Module):
def __init__(self, in_features=2, n_hidden=2, out_features=1, bias=False):
super(Sigmoid_class1_test, self).__init__()
self.linear1 = nn.Linear(in_features, n_hidden, bias=bias)
self.linear2 = nn.Linear(n_hidden, out_features, bias=bias)
def forward(self, x):
z1 = self.linear1(x)
p1 = torch.sigmoid(z1)
out = self.linear2(p1)
return out
# 创建随机数种子
torch.manual_seed(420)
# 创建模型
sigmoid_test = Sigmoid_class1_test()
# 观察各层参数
a = list(sigmoid_test.parameters())
#print(a)
'''
tensor([[ 0.4318, -0.4256],
[ 0.6730, -0.5617]], requires_grad=True), Parameter containing:
tensor([[-0.2157, -0.4873]], requires_grad=True)]
'''
# 将各层参数修改为0
list(sigmoid_test.parameters())[0].data = torch.tensor([[0., 0.], [0., 0.]])
list(sigmoid_test.parameters())[1].data = torch.tensor([0., 0.])
# 查看修改结果
print(list(sigmoid_test.parameters()))
'''
[Parameter containing:
tensor([[0., 0.],
[0., 0.]], requires_grad=True), Parameter containing:
tensor([0., 0.], requires_grad=True)]
'''
torch.manual_seed(420)
# 遍历五次查看结果
fit(net = sigmoid_test,
criterion = nn.MSELoss(),
optimizer = optim.SGD(sigmoid_test.parameters(), lr = 0.03),
batchdata = train_loader,
epochs=5,
cla=False)
print(list(sigmoid_test.parameters()))
'''
tensor([[ 0.0952, -0.0016],
[ 0.0952, -0.0016]], requires_grad=True), Parameter containing:
tensor([0.9945, 0.9945], requires_grad=True)]
'''
我们发现,参数的每一列(最后一个参数的一行)都是同步变化的,很明显,我们不能将参数的初始值全部设为0,我们只能考虑借助统计工具生成均值是0的随机数,也就是0均值的均匀分布或者是0均值的高斯分布,但这里需要考虑的另一个问题就是,该随机数的方差应该如何确定?
2.Glorot条件和Xavier方法
初始化参数的方差如何确定这一问题在一个严谨论述如何保证模型有效性的论文中,从另一个角度出发,得到了回答。根据Xavier Glorot在2010年发表的《Understanding the difficulty of training deep feedforward neural networks》论文中的观点,为保证模型本身的有效性和稳定性,我们希望正向传播时,每个线性层输入数据的方差等于输出数据的方差,同时我们也希望反向传播时,数据流经某层之前和流经某层之后该层的梯度也具有相同的方差,虽然二者很难同时满足(除非相邻两层神经元个数相同),但Glorot和Bengio(论文第二作者)表示,如果我们适当修改计算过程、是可以找到一种折中方案去设计初始参数取值,从而同时保证二者条件尽可能得到满足,这种设计参数初始值的方法也被称为Xavier方法,而这种方法也经过一段时间的实践验证被证明是很好的一种初始化模型参数的方法,尤其是对于使用tanh激活函数的神经网络来说,效果更为显著。
而这种正向传播时数据方差保持一致、反向传播时参数梯度方差保持一致的条件,也被称为Glorot条件,满足该条件的模型能够进行有效平稳的训练,而为了满足该条件而创建的(当然也是由上述论文提出的)模型初始化参数值设计方法,也被称为Xavier方法。而在Xavier方法中,最核心解决的问题,也就是为了创建Zero-Centered的初始化参数时参数的方差。和我们从朴素的角度思考的方向是一致的。
由于Glorot条件和Xavier方法是在2010年提出的,彼时ReLU激活函数还未兴起,因此Xavier方法主要是围绕tanh激活函数可能存在的梯度爆炸或梯度消失进行的优化,Sigmoid激活函数效果次之。不过尽管如此,Glorot条件却是一个通用条件,后续围绕ReLU激活函数、用于解决神经元活性失效的优化方法(如HE初始化方法),也是遵照Glorot条件进行的方法设计。
3.模型初始化参数取值影响
Xavier初始化方法的推导和使用我们将在下一节详细介绍,此处我们先通过另外一个实例,去展示为何初始参数取值不同,会够得到不同的建模结果。模型初始化时得到的不同参数,本质上等价于在损失函数上找到了不同的初始点,而同一损失函数,初始点选取的不同应该不会影响最终迭代结果才对,但事实情况并非如此。
接下来我们通过一个实验来说明初始值的更换对模型结果的影响。在模型实例化过程中,采用不同随机数种子,就相当于选取了不同的模型初始参数。
#创建随机数种子
torch.manual_seed(420)
#实例化模型
relu_model3 = ReLU_class3(bias=False)
#设置核心参数
num_epochs=20
lr=0.03
#模型训练
train_l, test_l = model_train_test(relu_model3,
train_loader,
test_loader,
num_epochs=num_epochs,
criterion=nn.MSELoss(),
optimizer=optim.SGD,
lr=0.03,
cla=False,
eva=mse_cal)
#绘制图像,查看MSE变化情况
plt.plot(list(range(num_epochs)), train_l, label='train_mse')
plt.plot(list(range(num_epochs)), test_l, label='test_mse')
plt.legend(loc=4)
plt.show()
# 创建随机数种子
torch.manual_seed(29)
# 实例化模型
relu_model3 = ReLU_class3(bias=False)
# 核心参数
num_epochs = 20
lr = 0.03
# 模型训练
train_l, test_l = model_train_test(relu_model3,
train_loader,
test_loader,
num_epochs = num_epochs,
criterion = nn.MSELoss(),
optimizer = optim.SGD,
lr = 0.03,
cla = False,
eva = mse_cal)
# 绘制图像,查看MSE变化情况
plt.plot(list(range(num_epochs)), train_l, label='train_mse')
plt.plot(list(range(num_epochs)), test_l, label='test_mse')
plt.legend(loc = 4)
plt.show()
我们发现,初始参数值的选取不仅会影响模型收敛速度,甚至在某些情况下还会影响模型的最终表现。造成此现象的根本原因还是在于神经网络模型在进行训练时,不确定性过多,而在一个拥有诸多不确定性的系统中再加上不确定的初始参数,初始参数的不确定性会被这个系统放大。并且,值得一提的是,每一个epoch中的每一次迭代并不是在一个损失函数上一步步下降的,当我们使用小批量梯度下降算法时,带入不同批的数据,实际创建的损失函数也会不同。