天天看點

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

參數初始化很簡單,但是簡單的東西也容易出現知識盲區,本文全文 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=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,方差為 ,是以:

此時,神經元的标準差為 。

通過上式進行計算,每一層神經元的标準差都将會是前一層神經元的 倍。

我們可以看一下上面列印的輸出,是不是正好驗證了這個規律。

「而這種初始化方式合理嗎?有沒有更好的初始化方法?」

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” 現象:

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

上圖摘自李飛飛的 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 初始化之前,這裡補充下飽和激活函數的概念。

  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
Sigmoid
Tanh 5/3
ReLU
Leaky Relu

5.Conclusion

6.Reference

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