【Deep Learning】初始化:你真的瞭解我嗎?

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

上圖摘自李飛飛的 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))

把各個激活函數所對應的信息增益表畫下來:

nonlinearitygain
Linear / Identity1
Conv{1,2,3}D1
Sigmoid1
Tanh5/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》

更多精彩內容(請點擊圖片進行閱讀)

公衆號:AI蝸牛車

保持謙遜、保持自律、保持進步

個人微信

備註:暱稱+學校/公司+方向

如果沒有備註不拉羣!

拉你進AI蝸牛車交流羣

點個在看,麼麼噠!
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章