神经网络之梯度不平稳性

1,322 阅读13分钟

这是我参与8月更文挑战的第26天,活动详情查看:8月更文挑战

一 梯度消失与梯度爆炸

对于神经网络这个复杂系统来说,在模型训练过程中,一个最基础、同时也最常见的问题,就是梯度消失和梯度爆炸。

神经网络在进行反向传播的过程中,各参数层的梯度计算会涉及到激活函数导函数取值,具体来说,假设现在有一个三层的神经网络,其中两个隐藏层的激活函数为F(x),对应的导函数为𝑓(𝑥,设X为输入训练的数据特征,y为标签,𝑦̂ 为模型向前传播输出结果,𝑤1为第一层参数、𝑤2为第二层参数、𝑤3为第三层参数,loss为损失函数,则有如下计算公式: 每一次正向传播计算结果:

image.png 而loss是一个关于y和𝑦̂ y^的函数,而y是常量,𝑦̂ y^是一个关于w的函数,因此loss也进行如下表示:

image.png 在进行梯度求解时候,假设𝑤1对应梯度为𝑔𝑟𝑎𝑑1,𝑤2对应梯度为𝑔𝑟𝑎𝑑2,𝑤3对应梯度为𝑔𝑟𝑎𝑑3,假设所有的𝑥、𝑤1、𝑤2、𝑤3都是标量,根据链式法则,有计算过程如下:

image.png 此时𝑔𝑟𝑎𝑑1计算了两次激活函数的导函数,𝑋∗𝑤1是第一层隐藏层接收到的数据,而𝐹(𝑋∗𝑤1)∗𝑤2则是第二层隐藏层接收到的数据。而对比如果是计算𝑤2的梯度,则有如下过程

image.png 在计算过程中只出现了一次激活函数的导函数。计算𝑤3的梯度,有如下计算过程:

image.png

  • 此时𝑔𝑟𝑎𝑑3在计算过程中就已经不涉及激活函数的导函数的计算
  • 如果当神经网络层数继续增加、激活函数的数量继续增加,第一层参数在计算梯度的过程中需要相乘的激活函数导函数个数也会随之增加,而后面几层参数的梯度计算中涉及到的激活函数导函数个数逐级递减。
  • 累乘就容易造成指数级变化,当激活函数值𝐹(𝐹(𝑋∗𝑤1))、激活函数导函数值𝑓(𝑋∗𝑤1)或者参与相乘的参数取值(𝑤3)较大(>1)时,会出现𝑔𝑟𝑎𝑑1远大于𝑔𝑟𝑎𝑑2远大于𝑔𝑟𝑎𝑑3的情况,即神经网络前几层参数梯度非常大、而后几层参数梯度相对较小的情况,此时就被称为梯度爆炸,
  • 并且受到累乘效应的影响,前几层梯度也会大于甚至远大于1,此时就会造成模型迭代过程不稳定的情况发生;而反之如果上述几个变量均小于1,甚至远小于1,则会出现前几层参数梯度非常小、而后几层参数梯度非常大的情况,此时就被称为梯度消失,
  • 此时由于模型各层参数学习率伴随层数增加逐渐增加,并且由于构成梯度的基本参数均小于1,因此最后几层梯度也会小于1甚至远小于1,此时前几层参数梯度取值将非常小,甚至趋于0,因而会使得前几层的参数无法在迭代中得到更新。

二 sigmoid 激活函数的梯度消失

import random
import matplotlib as mpl
import matplotlib.pyplot as plt
from mpl_toolkits .mplot3d  import Axes3D
import numpy as np
import torch
from torch import nn,optim
import torch.nn.functional as F
from torch.utils .data import Dataset ,TensorDataset,DataLoader
from torch.utils .data import random_split
from torch.utils.tensorboard import SummaryWriter
from IPython.core.interactiveshell import InteractiveShell
InteractiveShell.ast_node_interactivity='all'
#绘制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')

image.png

plt.plot(X.detach(),X.grad.detach())
plt.title('sigmoid derivative function')

image.png Sigmoid导函数最大值为0.25(在0点处取到),当x较大或者较小时,导函数取值趋于0。梯度消失或者梯度爆炸,始终是个概率问题。我们不能说导函数取值取值小就一定会发生梯度消失问题,只是导函数最大值越小,越有可能发生梯度消失。

2.1 sigmoid 函数饱和区间

靠近sigmoid函数的左右两端的区间称为函数的饱和区间(如下图圈出部分)(也就是自变量绝对值较大的区间),不难发现,当自变量落入饱和区间时,因变量会趋于0或者1,而无论自变量是极小(负数绝对值极大)还是极大,都会使得导函数取值趋于0,从而更容易导致模型梯度消失。

image.png

# 回归类数据集创建函数
def tensorGenReg(num_examples = 1000, w = [2, -1, 1], bias = True, delta = 0.01, deg = 1):
    """回归类数据集创建函数。

    :param num_examples: 创建数据集的数据量
    :param w: 包括截距的(如果存在)特a征系数向量
    :param bias:是否需要截距
    :param delta:扰动项取值
    :param deg:方程次数
    :return: 生成的特征张和标签张量
    """
    
    if bias == True:
        num_inputs = len(w)-1                                                        # 特征张量
        features_true = torch.randn(num_examples, num_inputs)                        # 不包含全是1的列的特征张量
        w_true = torch.tensor(w[:-1]).reshape(-1, 1).float()                         # 自变量系数
        b_true = torch.tensor(w[-1]).float()                                         # 截距
        if num_inputs == 1:                                                          # 若输入特征只有1个,则不能使用矩阵乘法
            labels_true = torch.pow(features_true, deg) * w_true + b_true
        else:
            labels_true = torch.mm(torch.pow(features_true, deg), w_true) + b_true
        features = torch.cat((features_true, torch.ones(len(features_true), 1)), 1)  # 在特征张量的最后添加一列全是1的列
        labels = labels_true + torch.randn(size = labels_true.shape) * delta         
                
    else: 
        num_inputs = len(w)
        features = torch.randn(num_examples, num_inputs)
        w_true = torch.tensor(w).reshape(-1, 1).float()
        if num_inputs == 1:
            labels_true = torch.pow(features, deg) * w_true
        else:
            labels_true = torch.mm(torch.pow(features, deg), w_true)
        labels = labels_true + torch.randn(size = labels_true.shape) * delta
    return features, labels

# 常用数据处理类
# 适用于封装自定义数据集的类
class GenData(Dataset):
    def __init__(self, features, labels):           
        self.features = features                    
        self.labels = labels                       
        self.lens = len(features)                  

    def __getitem__(self, index):
        return self.features[index,:],self.labels[index]    

    def __len__(self):
        return self.lens

def split_loader(features, labels, batch_size=10, rate=0.7):
    data = GenData(features, labels) 
    num_train = int(data.lens * 0.7)
    num_test = data.lens - num_train
    data_train, data_test = random_split(data, [num_train, num_test])
    train_loader = DataLoader(data_train, batch_size=batch_size, shuffle=True)
    test_loader = DataLoader(data_test, batch_size=batch_size, shuffle=False)
    return(train_loader, test_loader)

2.2 实例观察sigmoid激活函数叠加后梯度消失问题

#设置随机种子
torch.manual_seed(420)
#创建最高项为2的多项式回归数据集
features,labels=tensorGenReg(w=[2,-1],bias=False,deg=2)

#进行数据集切分与加载
train_loader,test_loader=split_loader(features,labels)
class Sigmoid_class3(nn.Module):                                   
    def __init__(self, in_features=2, n_hidden1=4, n_hidden2=4, n_hidden3=4, out_features=1):       
        super(Sigmoid_class3, self).__init__()
        self.linear1 = nn.Linear(in_features, n_hidden1)
        self.linear2 = nn.Linear(n_hidden1, n_hidden2)
        self.linear3 = nn.Linear(n_hidden2, n_hidden3)
        self.linear4 = nn.Linear(n_hidden3, out_features) 
        
    def forward(self, x):                                    
        z1 = self.linear1(x)
        p1 = torch.sigmoid(z1)
        z2 = self.linear2(p1)
        p2 = torch.sigmoid(z2)
        z3 = self.linear3(p2)
        p3 = torch.sigmoid(z3)
        out = self.linear4(p3)
        return out
torch.manual_seed(420)
#实例化模型,利用parameters()方法观察模型所有参数
sigmoid_model3=Sigmoid_class3()
pl=list(sigmoid_model3.parameters())
pl

image.png .parameters()方法返回模型所有参数,包括截距,但需要使用list将其转化为显式的列表。此时列表中的每个元素都是带名称的张量,推导过程中使用数据是按照行排列,一行代表一条数据,一列代表数据的一个特征,而PyTorch在进行计算时,会将数据转化为按列排列,一列代表一条数据,因此PyTorch中我们查看到的模型参数和推导的模型参数互为转置关系。

2.3 模型训练

对模型进行训练,在遍历五次数据后,观察参数变化情况。

def fit(net,criterion,optimizer,batchdata,epochs=3,cla=False):
    """
    模型训练函数
    param net:待训练的模型
    param criterion:损失函数
    param optimizer:优化算法
    param batchdata:训练数据集
    Param cla :是否是分类问题
    param epochs:遍历数据次数
    """
    for epochs in range(epochs):
        for X,y in batchdata:
            if cla==True:
                y=y.flatten().long()
            yhat=net.forward(X)
            loss=criterion(yhat,y)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
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)

image.png 第一层线性层参数变化非常小,而最后一层参数值变化较大。不过这种观察还是比较粗糙的,我们希望能够观察到每一轮迭代结束后各层参数的梯度。由于我们定义的fit函数是在每一轮开始时将梯度清零,而每一轮迭代结束时还会保留梯度,因此我们可以直接使用.grad查看当前各层参数梯度情况。

for m in sigmoid_model3.modules():
    if isinstance(m,nn.Linear):
        print(m.weight.grad)

image.png 前几层梯度较小,后几层梯度较大。一种更加直观的观测手段,是通过绘制小提琴图来对各层梯度进行观察

import seaborn as sns  #导入seaborn绘图包,
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)
ax=sns.violinplot(y=vp_r[:,0],x=vp_r[:,1])
ax.set(xlabel='num_hidden',title='Gradients')        

image.png

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='weight')

image.png 将上述过程进行函数封装

def weights_vp(model,att='grad'):
    """
    观察各层参数取值和梯度的小提琴绘图函数
    param model:观察对象(模型)
    param att:选择参数梯度(grad)还是参数取值(weights)进行观察
    return:对应att的小提琴图
    """
    vp=[]
    for i ,m in enumerate(model.modules()):
        if isinstance(m,nn.Linear):
            if att=='grad':
                vp_x=m.weight.grad.detach().reshape(-1,1).numpy()
                
            else:
                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=att)
fit(net=sigmoid_model3
   ,criterion=nn.MSELoss()
    ,optimizer=optim.SGD(sigmoid_model3.parameters(),lr=0.03)
    ,batchdata=train_loader
    ,epochs=20
    ,cla=False
   )
weights_vp(sigmoid_model3,att='grad')

image.png 对于Sigmoid激活函数的梯度消失问题,是伴随着迭代次数增加或者隐藏层增加会愈发明显。

三 tanh 函数梯度计算

如果说Sigmoid激活函数叠加更容易造成梯度消失,那tanh激活函数叠加是不是也是如此?观察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')

image.png 对于tanh函数来说,导函数的取值分布在0-1之间的,看似导函数取值累乘之后也是趋于0的,但实际上,tanh激活函数的叠加即可能造成梯度消失、同时也可能造成梯度爆炸,原因是在实际建模过程中,影响前几层梯度的其他变量大多数情况都大于1,因此对于一个导函数极大值可以取到1的激活函数来说,还是有可能出现梯度爆炸的情况的。

image.png 和Sigmoid激活函数一样,我们可以通过绘制小提琴图观察多轮迭代后,tanh激活函数叠加的神经网络是否会出现梯度消失或者梯度爆炸的情况,实例化一个拥有四个隐藏层的以tanh为激活函数的模型。

class tanh_class4(nn.Module):
    def __init__(self,in_features=2,n_hidden1=4,n_hidden2=4,n_hidden3=4,n_hidden4=4,out_features=1):
        super(tanh_class4,self).__init__()
        self.linear1=nn.Linear(in_features,n_hidden1)
        self.linear2=nn.Linear(n_hidden1,n_hidden2)
        self.linear3=nn.Linear(n_hidden2,n_hidden3)
        self.linear4=nn.Linear(n_hidden3,n_hidden4)
        self.linear5=nn.Linear(n_hidden4,out_features)
        
    def forward(self,x):
        z1=self.linear1(x)
        p1=torch.tanh(z1)
        z2=self.linear2(p1)
        p2=torch.tanh(z2)
        z3=self.linear3(p2)
        p3=torch.tanh(z3)
        z4=self.linear4(p3)
        p4=torch.tanh(z4)
        out=self.linear5(p4)
        return out
#创建随机种子
torch.manual_seed(420)
#实例化模型
tanh_model4=tanh_class4()
#观察各层参数
for m in tanh_model4.modules():
    if isinstance(m,nn.Linear):
        print(m.weight)

image.png

def mse_cal(data_loader, net):
    """mse计算函数
    
    :param data_loader:加载好的数据
    :param net: 模型
    :return:根据输入的数据,输出其MSE计算结果
    """
    data = data_loader.dataset                # 还原Dataset类
    X = data[:][0]                            # 还原数据的特征
    y = data[:][1]                            # 还原数据的标签
    yhat = net(X)
    return F.mse_loss(yhat, y)
def model_train_test(model
                    ,train_data
                     ,test_data
                     ,num_epochs=20
                     ,criterion=nn.MSELoss()
                     ,optimizer=optim.SGD
                     ,lr=0.03
                     ,cla=False
                     ,eva=mse_cal
                    ):
    """
    模型误差测试函数
    param model_l:模型
    param train_data:训练数据
    param test_data:测试数据
    param num_epochs:迭代轮数
    param criterion:损失函数
    param lr:学习率
    param cla:是否是分类模型
    return :mse列表
    """
    train_l=[]
    test_l=[]
    #模型训练
    for epochs in range(num_epochs):
        model.train()
        fit(net=model
           ,criterion=criterion
            ,optimizer=optimizer(model.parameters(),lr=lr)
            ,batchdata=train_data
            ,epochs=epochs
            ,cla=cla
           )
        model.eval()
        train_l.append(eva(train_data,model).detach())
        test_l.append(eva(test_data,model).detach())
    return train_l,test_l

3.1 模型训练

#模型训练
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
                               )
weights_vp(tanh_model4, att="grad")

image.png

  • 模型存在一定程度的梯度爆炸的情况。当然,对于tanh激活函数来说,由于激活函数本身的良好特性,一般不会出现典型的梯度消失情况。但梯度爆炸同样会极大影响模型训练过程的稳定性,并且这种现象并不会因为模型迭代次数增加而消失。
# 观察各层梯度
for m in tanh_model4.modules():
    if isinstance(m, nn.Linear):
        print(m.weight.grad)

image.png

  • tanh激活函数的迭代不平稳就是因为部分层的部分梯度存在极端值,当然,这种极端值也导致部分层无法有效学习、最终影响模型效果。

四 Zero-Centered Data与Glorot条件

通过对Sigmoid和tanh激活函数叠加后的模型梯度变化情况分析,我们不难发现,梯度不平稳是影响模型建模效果的非常核心的因素。

  • 整体来看,针对梯度不平稳的解决方案(优化方法)总共分为五类,分别是参数初始化方法、输入数据的归一化方法、衍生激活函数使用方法、学习率调度方法以及梯度下降优化方法。
  • 在梯度消失和梯度爆炸中,为了确保多层神经网络的有效性,各层梯度的差距不应太大,此时一个最为基本的想法就是,就是能否让所有的输入数据(也就是X)以及所有层的参数都设置为Zero-Centered Data,也就是零点对称数据,因此每一个线性层中的导函数也取值也能够维持在0-1之间,进而每一层的梯度基本能维持在比较平稳的状态。
  • 另外,除了能够避免梯度不平稳问题以外,创建Zero-Centered的参数和数据集,还能够更好的在正向传播中将信息传播到各层,以及确保各层学习的平稳性。

4.1 创建初始值全为0的参数

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()

4.2 观察各层数

# 观察各层参数
list(sigmoid_test.parameters())

image.png

4.3 将各层参数修改为0

#将各层参数修改为0
list(sigmoid_test.parameters())[0].data=torch.tensor([[0.,0],[0,0]])
list(sigmoid_test.parameters())[1].data=torch.tensor([[0.,0]])

4.4 查看修改结果

#查看修改结果
list(sigmoid_test.parameters())

image.png

4.5 遍历查看结果

# 遍历五次查看结果
fit(net = sigmoid_test, 
    criterion = nn.MSELoss(), 
    optimizer = optim.SGD(sigmoid_test.parameters(), lr = 0.03), 
    batchdata = train_loader, 
    epochs=5, 
    cla=False)
list(sigmoid_test.parameters())

image.png

  • 参数的每一列(最后一个参数的一行)都是同步变化的,很明显,我们不能将参数的初始值全部设为0,我们只能考虑借助统计工具生成均值是0的随机数,也就是0均值的均匀分布或者是0均值的高斯分布,该随机数的方差应该如何确定?
  • 根据Xavier Glorot在2010年发表的《Understanding the difficulty of training deep feedforward neural networks》论文中的观点,为保证模型本身的有效性和稳定性,我们希望正向传播时,每个线性层输入数据的方差等于输出数据的方差,同时我们也希望反向传播时,数据流经某层之前和流经某层之后该层的梯度也具有相同的方差,虽然二者很难同时满足(除非相邻两层神经元个数相同),但Glorot和Bengio(论文第二作者)表示,如果我们适当修改计算过程、是可以找到一种折中方案去设计初始参数取值,从而同时保证二者条件尽可能得到满足,这种设计参数初始值的方法也被称为Xavier方法,
  • 这种正向传播时数据方差保持一致、反向传播时参数梯度方差保持一致的条件,也被称为Glorot条件,满足该条件的模型能够进行有效平稳的训练,而为了满足该条件而创建的模型初始化参数值设计方法,也被称为Xavier方法。而在Xavier方法中,最核心解决的问题,也就是为了创建Zero-Centered的初始化参数时参数的方差。