❝参数初始化很简单,但是简单的东西也容易出现知识盲区,本文全文 4000 字,将从数理和代码两个角度带大家认识初始化,希望能给大家带来更加形象的认识。
❞
参数初始化分为:固定值初始化、预训练初始化和随机初始化。
「固定初始化」是指将模型参数初始化为一个固定的常数,这意味着所有单元具有相同的初始化状态,所有的神经元都具有相同的输出和更新梯度,并进行完全相同的更新,这种初始化方法使得神经元间不存在非对称性,从而使得模型效果大打折扣。
「预训练初始化」是神经网络初始化的有效方式,比较早期的方法是使用 greedy layerwise auto-encoder 做无监督学习的预训练,经典代表为 Deep Belief Network;而现在更为常见的是有监督的预训练+模型微调。
「随机初始化」是指随机进行参数初始化,但如果不考虑随机初始化的分布则会导致梯度爆炸和梯度消失的问题。
我们这里主要关注随机初始化的分布状态。
先介绍两个用的比较多的初始化方法:高斯分布和均匀分布。
以均匀分布为例,通常情况下我们会将参数初始化为 ,我们来看下效果:
class MLP(nn.Module):
def __init__(self, neurals, layers):
super(MLP, self).__init__()
self.linears = nn.ModuleList(
[nn.Linear(neurals, neurals, bias=False) for 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,方差为 ,所以:
此时,神经元的标准差为 。
通过上式进行计算,每一层神经元的标准差都将会是前一层神经元的 倍。
我们可以看一下上面打印的输出,是不是正好验证了这个规律。
「而这种初始化方式合理吗?有没有更好的初始化方法?」
Xavier Glorot 认为:优秀的初始化应该使得各层的激活值和状态梯度在传播过程中的方差保持一致。即「方差一致性」。
所以我们需要同时考虑正向传播和反向传播的输入输出的方差相同。
在开始推导之前,我们先引入一些必要的假设:
考虑前向传播:
我们令输入的方差等于输出得到方差:
则有:
此外,我们还要考虑反向传播的梯度状态。
反向传播:
我们也可以得到下一层的方差:
我们取其平均,得到权重的方差为:
此时,均匀分布为:
我们来看下实验部分,只需修改类里面的初始化函数:
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=False) for 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 初始化之前,这里补充下饱和激活函数的概念。
同样遵循方差一致性原则。
激活函数为 ,所以输入值的均值就不为 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,方差也计算出来了,所服从的分布自然可知。
这一节我们来看下源码解析,以 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 |
尽管初始化很简单,但从数理角度出发去分析神经网络并不轻松,且需要加上假设才能进行分析。但不管怎么说初始化对于训练神经网络至关重要,那些非常深的网络如 GoogleNet、ResNet 都 stack 了这些方法,并且非常 work。
推荐阅读
征稿启示| 200元稿费+5000DBC(价值20个小时GPU算力)
文本自动摘要任务的“不完全”心得总结番外篇——submodular函数优化
斯坦福大学NLP组Python深度学习自然语言处理工具Stanza试用
关于AINLP
AINLP 是一个有趣有AI的自然语言处理社区,专注于 AI、NLP、机器学习、深度学习、推荐算法等相关技术的分享,主题包括文本摘要、智能问答、聊天机器人、机器翻译、自动生成、知识图谱、预训练模型、推荐系统、计算广告、招聘信息、求职经验分享等,欢迎关注!加技术交流群请添加AINLPer(id:ainlper),备注工作/研究方向+加群目的。
阅读至此了,分享、点赞、在看三选一吧🙏