【DL】初始化:你真的了解我吗?

2020 年 7 月 2 日 AINLP

参数初始化很简单,但是简单的东西也容易出现知识盲区,本文全文 4000 字,将从数理和代码两个角度带大家认识初始化,希望能给大家带来更加形象的认识。

参数初始化分为:固定值初始化、预训练初始化和随机初始化。

「固定初始化」是指将模型参数初始化为一个固定的常数,这意味着所有单元具有相同的初始化状态,所有的神经元都具有相同的输出和更新梯度,并进行完全相同的更新,这种初始化方法使得神经元间不存在非对称性,从而使得模型效果大打折扣。

「预训练初始化」是神经网络初始化的有效方式,比较早期的方法是使用 greedy layerwise auto-encoder 做无监督学习的预训练,经典代表为 Deep Belief Network;而现在更为常见的是有监督的预训练+模型微调。

「随机初始化」是指随机进行参数初始化,但如果不考虑随机初始化的分布则会导致梯度爆炸和梯度消失的问题。

我们这里主要关注随机初始化的分布状态。

1.Naive Initialization

先介绍两个用的比较多的初始化方法:高斯分布和均匀分布。

以均匀分布为例,通常情况下我们会将参数初始化为 ,我们来看下效果:

class MLP(nn.Module):
    def __init__(self, neurals, layers):
        super(MLP, self).__init__()
        self.linears = nn.ModuleList(
            [nn.Linear(neurals, neurals, bias=Falsefor i in range(layers)])
        self.neurals = neurals

    def forward(self, x):
        for (i, linear) in enumerate(self.linears):
            x = linear(x)
            print("layer:{}, std:{}".format(i+1, x.std()))
            if torch.isnan(x.std()):
                break
        return x
    
    def initialize(self):
        a = np.sqrt(1/self.neurals)
        for m in self.modules():
            if isinstance(m, nn.Linear):
                nn.init.uniform_(m.weight.data, -a, a)
neural_nums=256
layers_nums=100
batch_size=16

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

inputs = torch.randn((batch_size, neural_nums))  
output = net(inputs)
layer:0, std:0.5743116140365601
layer:1, std:0.3258207142353058
layer:2, std:0.18501722812652588
layer:3, std:0.10656329244375229
... ...
layer:95, std:9.287707510161138e-24
layer:96, std:5.310323679717446e-24
layer:97, std:3.170952429065466e-24
layer:98, std:1.7578611563776362e-24
layer:99, std:9.757115839154053e-25

我们可以看到,随着网络层数加深,权重的方差越来越小,直到最后超出精度范围。

我们先通过数学推导来解释一下这个现象,以第一层隐藏层的第一个单元为例。

首先,我们是没有激活函数的线性网络:

其中,n 为输入层神经元个数。

通过方差公式我们有:

这里,我们的输入均值为 0,方差为 1,权重的均值为 0,方差为 ,所以:

此时,神经元的标准差为

通过上式进行计算,每一层神经元的标准差都将会是前一层神经元的 倍。

我们可以看一下上面打印的输出,是不是正好验证了这个规律。

「而这种初始化方式合理吗?有没有更好的初始化方法?」

2.Xavier Initialization

Xavier Glorot 认为:优秀的初始化应该使得各层的激活值和状态梯度在传播过程中的方差保持一致。即「方差一致性」

所以我们需要同时考虑正向传播和反向传播的输入输出的方差相同。

在开始推导之前,我们先引入一些必要的假设:

  1. x、w、b 相同独立;
  2. 各层的权重 w 独立同分布,且均值为 0;
  3. 偏置项 b 独立同分布,且方差为 0;
  4. 输入项 x 独立同分布,且均值为 0;

2.1 Forward

考虑前向传播:

我们令输入的方差等于输出得到方差:

则有:

2.2 Backward

此外,我们还要考虑反向传播的梯度状态。

反向传播:

我们也可以得到下一层的方差:

我们取其平均,得到权重的方差为:

此时,均匀分布为:

我们来看下实验部分,只需修改类里面的初始化函数:

class MLP(nn.Module):
   ...
    def initialize(self):
        a = np.sqrt(3/self.neurals)
        for m in self.modules():
            if isinstance(m, nn.Linear):
                nn.init.uniform_(m.weight.data, -a, a)
layer:0, std:0.9798752665519714
layer:1, std:0.9927620887756348
layer:2, std:0.9769216179847717
layer:3, std:0.9821343421936035
...
layer:97, std:0.9224138855934143
layer:98, std:0.9622119069099426
layer:99, std:0.9693211317062378

这便达到了我们的目的,即「输入和输出的方差保持一致」

但在实际过程中,我们还会使用激活函数,所以我们在 forward 中加入 sigmoid 函数:

class MLP(nn.Module):
  ...
    def forward(self, x):
        for (i, linear) in enumerate(self.linears):
            x = linear(x)
            x = torch.sigmoid(x)
            print("layer:{}, std:{}".format(i, x.std()))
            if torch.isnan(x.std()):
                break
        return x
    ...

在看下输出结果:

layer:0, std:0.21153637766838074
layer:1, std:0.13094832003116608
layer:2, std:0.11587061733007431
...
layer:97, std:0.11739246547222137
layer:98, std:0.11711347848176956
layer:99, std:0.11028502136468887

好像还不错,也没有出现方差爆炸的问题。

不知道大家看到这个结果会不会有些疑问:为什么方差不是 1 了?

这是因为 sigmoid 的输出都为正数,所以会影响到均值的分布,所以会导致下一层的输入不满足均值为 0 的条件。我们将均值和方差一并打出:

layer:0, mean:0.5062727928161621
layer:0, std:0.20512282848358154
layer:1, mean:0.47972571849823
layer:1, std:0.12843772768974304
...
layer:98, mean:0.5053208470344543
layer:98, std:0.11949671059846878
layer:99, mean:0.49752169847488403
layer:99, std:0.1192963495850563

可以看到,第一层隐藏层(layer 0)的均值就已经变成了 0.5。

这又会出现什么问题呢?

答案是出现 “zigzag” 现象:

上图摘自李飞飞的 cs231n 课程。

在反向传播过程中:

因为 是经过 sigmoid 输出得到的,所以恒大于零,所以每个神经元 的梯度方向都取决于偏导数 ,这也意味着所有梯度方向都是相同的,梯度的更新方向被固定(以二维坐标系为例,只能是水平向右和水平向下),会降低优化效率。

为此,我们可以使用,改变 sigmoid 的尺度与范围,改用 tanh:

tanh 的收敛速度要比 sigmoid 快,这是因为 sigmoid 的均值更加接近 0,SGD 会更加接近 natural gradient,从而降低所需的迭代次数。

我们使用 tanh 做一下实验,看下输出结果:

layer:0, mean:-0.011172479018568993
layer:0, std:0.6305743455886841
layer:1, mean:0.0025750682689249516
layer:1, std:0.4874609708786011
...
layer:98, mean:0.0003803471918217838
layer:98, std:0.06665021181106567
layer:99, mean:0.0013235544320195913
layer:99, std:0.06700969487428665

可以看到,在前向传播过程中,均值没有出问题,但是方差一直在减小。

这是因为,输出的数据经过 tanh 后标准差发生了变换,所以在实际初始化过程中我们还需要考虑激活函数的计算增益:

class MLP(nn.Module):
   ...
    def initialize(self):
        for m in self.modules():
            if isinstance(m, nn.Linear):
                tanh_gain = nn.init.calculate_gain('tanh')
                a = np.sqrt(3/self.neurals)
                a *= tanh_gain
                nn.init.uniform_(m.weight.data, -a, a)
layer:0, std:0.7603299617767334
layer:1, std:0.6884239315986633
layer:2, std:0.6604527831077576
...
layer:97, std:0.6512776613235474
layer:98, std:0.643700897693634
layer:99, std:0.6490980386734009

此时,方差就被修正过来了。

当然,在实际过程中我们也不需要自己写,可以直接调用现成的函数:

class MLP(nn.Module):
   ...
    def initialize(self):
        a = np.sqrt(3/self.neurals)
        for m in self.modules():
            if isinstance(m, nn.Linear):
                tanh_gain = nn.init.calculate_gain('tanh')
                nn.init.xavier_uniform_(m.weight.data, gain=tanh_gain)
layer:0, std:0.7628788948059082
layer:1, std:0.6932843923568726
layer:2, std:0.6658385396003723
...
layer:97, std:0.6544962525367737
layer:98, std:0.6497417092323303
layer:99, std:0.653872549533844

可以看到其输出是差不多的。

在这里,不知道同学们会不会有一个疑问,为什么 sigmoid 不会出现 tanh 的情况呢?

这是因为 sigmoid 的信息增益为 1,而 tanh 的信息增益为 5/3,理论证明这里就略过了。

tanh 和 sigmoid 有两大缺点:

  • 需要进行指数运算;
  • 有软饱和区域,导致梯度更新速度很慢。

所以我们经常会用到 ReLU,所以我们试一下效果:

class MLP(nn.Module):
    def __init__(self, neurals, layers):
        super(MLP, self).__init__()
        self.linears = nn.ModuleList(
            [nn.Linear(neurals, neurals, bias=Falsefor i in range(layers)])
        self.neurals = neurals

    def forward(self, x):
        for (i, linear) in enumerate(self.linears):
            x = linear(x)
            x = torch.relu(x)
            print("layer:{}, std:{}".format(i, x.std()))
        return x
    
    def initialize(self):
        for m in self.modules():
            if isinstance(m, nn.Linear):
                tanh_gain = nn.init.calculate_gain('relu')
                a = np.sqrt(3/self.neurals)
                a *= tanh_gain
                nn.init.uniform_(m.weight.data, -a, a)
layer:0, std:1.4423831701278687
layer:1, std:2.3559958934783936
layer:2, std:4.320342540740967
...
layer:97, std:1.3732810130782195e+23
layer:98, std:2.3027095847369547e+23
layer:99, std:4.05964954791109e+23

为什么 Xavier 突然失灵了呢?

这是因为 Xavier 只能针对类似 sigmoid 和 tanh 之类的饱和激活函数,而无法应用于 ReLU 之类的非饱和激活函数。

针对这一问题,何凯明于 2015 年发表了一篇论文《Delving Deep into Rectifiers: Surpassing Human-Level Performance on ImageNet Classification》,给出了解决方案。

在介绍 kaiming 初始化之前,这里补充下饱和激活函数的概念。

  1. x 趋于正无穷时,激活函数的导数趋于 0,则我们称之为 「右饱和」
  2. x 趋于负无穷时,激活函数的导数趋于 0,则我们称之为 「左饱和」
  3. 当一个函数既满足右饱和又满足左饱和时,我们称之为 「饱和激活函数」,代表有 sigmoid,tanh;
  4. 存在常数 c,当 x>c 时,激活函数的导数恒为 0,我们称之为 「右硬饱和」,同理 「左硬饱和」。两者同时满足时,我们称之为硬饱和激活函数,ReLU 则为 「左硬饱和激活函数」
  5. 存在常数 c,当 x>c 时,激活函数的导数趋于 0,我们称之为 「右软饱和」,同理 「左软饱和」。两者同时满足时,我们称之为软饱和激活函数,sigmoid,tanh 则为 「软饱和激活函数」

3.Kaiming Initialization

同样遵循方差一致性原则。

激活函数为 ,所以输入值的均值就不为 0 了,所以:

其中:

我们将其带入,可以得到:

所以参数服从 。(这里注意,凯明初始化的时候,默认是使用输入的神经元个数)

我们试一下结果:

class MLP(nn.Module):
   ...    
    def initialize(self):
        a = np.sqrt(3/self.neurals)
        for m in self.modules():
            if isinstance(m, nn.Linear):
                a = np.sqrt(6 / self.neurals)
                nn.init.uniform_(m.weight.data, -a, a)
layer:0, std:0.8505409955978394
layer:1, std:0.8492708802223206
layer:2, std:0.8718656301498413
...
layer:97, std:0.8371583223342896
layer:98, std:0.7432138919830322
layer:99, std:0.6938706636428833

可以看到,结果要好很多。

再试一下凯明均匀分布:

class MLP(nn.Module):
   ...    
    def initialize(self):
        a = np.sqrt(3/self.neurals)
        for m in self.modules():
            if isinstance(m, nn.Linear):
                nn.init.kaiming_uniform_(m.weight.data)
layer:0, std:0.8123029470443726
layer:1, std:0.802753210067749
layer:2, std:0.758887529373169
...
layer:97, std:0.2888352870941162
layer:98, std:0.26769548654556274
layer:99, std:0.2554236054420471

那如果激活函数是 ReLU 的变种怎么办呢?

这里直接给结论:

我们上述介绍的都是以均匀分布为例,而正态分布也是一样的。均值 0,方差也计算出来了,所服从的分布自然可知。

4.Source Code

这一节我们来看下源码解析,以 Pytorch 为例子。

def xavier_uniform_(tensor, gain=1.):
    """ xavier 均匀分布
    """

  fan_in, fan_out = _calculate_fan_in_and_fan_out(tensor)
    std = gain * math.sqrt(2.0 / float(fan_in + fan_out))
    a = math.sqrt(3.0) * std  
    return _no_grad_uniform_(tensor, -a, a)

def xavier_normal_(tensor, gain=1.):
    """ xavier 正态分布
    """

   fan_in, fan_out = _calculate_fan_in_and_fan_out(tensor)
    std = gain * math.sqrt(2.0 / float(fan_in + fan_out))
    return _no_grad_normal_(tensor, 0., std)

def kaiming_uniform_(tensor, a=0, mode='fan_in', nonlinearity='leaky_relu'):
    """ kaiming 均匀分布
    """

   fan = _calculate_correct_fan(tensor, mode)
    gain = calculate_gain(nonlinearity, a)
    std = gain / math.sqrt(fan)
    bound = math.sqrt(3.0) * std
    with torch.no_grad():
        return tensor.uniform_(-bound, bound)

def kaiming_normal_(tensor, a=0, mode='fan_in', nonlinearity='leaky_relu'):
    """ kaiming 正态分布
    """

    fan = _calculate_correct_fan(tensor, mode)
    gain = calculate_gain(nonlinearity, a)
    std = gain / math.sqrt(fan)
    with torch.no_grad():
        return tensor.normal_(0, std)

可以看到,xavier 初始化会调用 _calculate_fan_in_and_fan_out 函数,而 kaiming 初始化会调用 _calculate_correct_fan 函数,具体看下这两个函数。

def _calculate_fan_in_and_fan_out(tensor):
    """ 计算输入输出的大小
    """

    dimensions = tensor.dim()
    if dimensions < 2:
        raise ValueError("Fan in and fan out can not be computed for tensor with fewer than 2 dimensions")

    num_input_fmaps = tensor.size(1)
    num_output_fmaps = tensor.size(0)
    receptive_field_size = 1
    if tensor.dim() > 2:
        receptive_field_size = tensor[0][0].numel()
    fan_in = num_input_fmaps * receptive_field_size
    fan_out = num_output_fmaps * receptive_field_size

    return fan_in, fan_out

def _calculate_correct_fan(tensor, mode):
   """ 根据 mode 计算输入或输出的大小
   """

    mode = mode.lower()
    valid_modes = ['fan_in''fan_out']
    if mode not in valid_modes:
        raise ValueError("Mode {} not supported, please use one of {}".format(mode, valid_modes))

    fan_in, fan_out = _calculate_fan_in_and_fan_out(tensor)
    return fan_in if mode == 'fan_in' else fan_out

xavier 初始化是外部传入信息增益,而 kaiming 初始化是在内部包装了信息增益,我们来看下信息增益的函数:

def calculate_gain(nonlinearity, param=None):
    linear_fns = ['linear''conv1d''conv2d''conv3d''conv_transpose1d''conv_transpose2d''conv_transpose3d']
    if nonlinearity in linear_fns or nonlinearity == 'sigmoid':
        return 1
    elif nonlinearity == 'tanh':
        return 5.0 / 3
    elif nonlinearity == 'relu':
        return math.sqrt(2.0)
    elif nonlinearity == 'leaky_relu':
        if param is None:
            negative_slope = 0.01
        elif not isinstance(param, bool) and isinstance(param, int) or isinstance(param, float):
            # True/False are instances of int, hence check above
            negative_slope = param
        else:
            raise ValueError("negative_slope {} not a valid number".format(param))
        return math.sqrt(2.0 / (1 + negative_slope ** 2))
    else:
        raise ValueError("Unsupported nonlinearity {}".format(nonlinearity))

把各个激活函数所对应的信息增益表画下来:

nonlinearity gain
Linear / Identity 1
Conv{1,2,3}D 1
Sigmoid 1
Tanh 5/3
ReLU
Leaky Relu

5.Conclusion

尽管初始化很简单,但从数理角度出发去分析神经网络并不轻松,且需要加上假设才能进行分析。但不管怎么说初始化对于训练神经网络至关重要,那些非常深的网络如 GoogleNet、ResNet 都 stack 了这些方法,并且非常 work。

6.Reference

  1. 《Understanding the difficulty of training deep feedforward neural networks》
  2. 《Delving Deep into Rectifiers: Surpassing Human-Level Performance on ImageNet Classification》

推荐阅读

这个NLP工具,玩得根本停不下来

征稿启示| 200元稿费+5000DBC(价值20个小时GPU算力)

文本自动摘要任务的“不完全”心得总结番外篇——submodular函数优化

Node2Vec 论文+代码笔记

模型压缩实践收尾篇——模型蒸馏以及其他一些技巧实践小结

中文命名实体识别工具(NER)哪家强?

学自然语言处理,其实更应该学好英语

斯坦福大学NLP组Python深度学习自然语言处理工具Stanza试用

关于AINLP

AINLP 是一个有趣有AI的自然语言处理社区,专注于 AI、NLP、机器学习、深度学习、推荐算法等相关技术的分享,主题包括文本摘要、智能问答、聊天机器人、机器翻译、自动生成、知识图谱、预训练模型、推荐系统、计算广告、招聘信息、求职经验分享等,欢迎关注!加技术交流群请添加AINLPer(id:ainlper),备注工作/研究方向+加群目的。


阅读至此了,分享、点赞、在看三选一吧🙏

登录查看更多
0

相关内容

迁移学习简明教程,11页ppt
专知会员服务
106+阅读 · 2020年8月4日
一份简单《图神经网络》教程,28页ppt
专知会员服务
120+阅读 · 2020年8月2日
一份循环神经网络RNNs简明教程,37页ppt
专知会员服务
168+阅读 · 2020年5月6日
TensorFlow Lite指南实战《TensorFlow Lite A primer》,附48页PPT
专知会员服务
68+阅读 · 2020年1月17日
模型压缩究竟在做什么?我们真的需要模型压缩么?
专知会员服务
27+阅读 · 2020年1月16日
开源书:PyTorch深度学习起步
专知会员服务
49+阅读 · 2019年10月11日
2019年机器学习框架回顾
专知会员服务
35+阅读 · 2019年10月11日
机器学习入门的经验与建议
专知会员服务
90+阅读 · 2019年10月10日
激活函数还是有一点意思的!
计算机视觉战队
12+阅读 · 2019年6月28日
吴恩达团队:神经网络如何正确初始化?
AI100
10+阅读 · 2019年5月15日
PyTorch 学习笔记(四):权值初始化的十种方法
极市平台
14+阅读 · 2019年5月1日
PyTorch中在反向传播前为什么要手动将梯度清零?
极市平台
39+阅读 · 2019年1月23日
从零开始深度学习:dropout与正则化
数萃大数据
7+阅读 · 2018年7月22日
从零开始深度学习:手动搭建深度神经网络(DNN)
数萃大数据
9+阅读 · 2018年7月5日
当前训练神经网络最快的方式:AdamW优化算法+超级收敛
中国人工智能学会
6+阅读 · 2018年7月4日
[学习] 这些深度学习网络训练技巧,你了解吗?
菜鸟的机器学习
7+阅读 · 2017年7月29日
Arxiv
17+阅读 · 2019年3月28日
Arxiv
11+阅读 · 2018年7月8日
Arxiv
5+阅读 · 2018年4月30日
Arxiv
7+阅读 · 2018年3月21日
Arxiv
5+阅读 · 2018年1月17日
VIP会员
相关VIP内容
迁移学习简明教程,11页ppt
专知会员服务
106+阅读 · 2020年8月4日
一份简单《图神经网络》教程,28页ppt
专知会员服务
120+阅读 · 2020年8月2日
一份循环神经网络RNNs简明教程,37页ppt
专知会员服务
168+阅读 · 2020年5月6日
TensorFlow Lite指南实战《TensorFlow Lite A primer》,附48页PPT
专知会员服务
68+阅读 · 2020年1月17日
模型压缩究竟在做什么?我们真的需要模型压缩么?
专知会员服务
27+阅读 · 2020年1月16日
开源书:PyTorch深度学习起步
专知会员服务
49+阅读 · 2019年10月11日
2019年机器学习框架回顾
专知会员服务
35+阅读 · 2019年10月11日
机器学习入门的经验与建议
专知会员服务
90+阅读 · 2019年10月10日
相关资讯
激活函数还是有一点意思的!
计算机视觉战队
12+阅读 · 2019年6月28日
吴恩达团队:神经网络如何正确初始化?
AI100
10+阅读 · 2019年5月15日
PyTorch 学习笔记(四):权值初始化的十种方法
极市平台
14+阅读 · 2019年5月1日
PyTorch中在反向传播前为什么要手动将梯度清零?
极市平台
39+阅读 · 2019年1月23日
从零开始深度学习:dropout与正则化
数萃大数据
7+阅读 · 2018年7月22日
从零开始深度学习:手动搭建深度神经网络(DNN)
数萃大数据
9+阅读 · 2018年7月5日
当前训练神经网络最快的方式:AdamW优化算法+超级收敛
中国人工智能学会
6+阅读 · 2018年7月4日
[学习] 这些深度学习网络训练技巧,你了解吗?
菜鸟的机器学习
7+阅读 · 2017年7月29日
Top
微信扫码咨询专知VIP会员