Xavier 初始化

Xavier 初始化

我们知道,神经网络大多靠随机梯度下降训练,参数从一组初值出发一步步更新。初值选得好不好,直接决定网络能不能训起来、收敛快不快。初值偏大,每层输出越乘越大,几层之后数值冲到溢出,这就是梯度爆炸。初值偏小,信号一层层衰减,传到后面几乎不动,梯度也跟着消失,这就是梯度消失。最极端的是把所有权重都设成同一个值,比如全 0,同一层每个神经元算出一样的输出,反向传回一样的梯度,更新完还是一模一样,网络永远学不出差别,等于白搭。

自然的想法是随机初始化,从高斯分布或均匀分布里抽权重。可随便定个方差又会引出新麻烦,每层输出的方差会跟着这一层输入神经元的个数一起放大或缩小,网络一深就失控。Xavier 初始化要做的,就是给权重方差定一个和层尺寸挂钩的公式,让信号和梯度在前向、反向传播时都别跑太远。接下来,我们先把方差在层间怎么传播讲清楚,再推导出高斯分布和均匀分布两种 Xavier 的具体取值,然后拿一个具体尺寸的网络把数字算一遍,最后落到 PyTorch 的现成接口。读之前你需要知道反向传播和方差的基本概念,手写过全连接层会更顺。

1. 方差传播

我们先看清楚,为什么权重随便选个方差,深层网络就会失控。这得回到一层里信号是怎么算的。

一层全连接,输出 y 是 n_in 个输入 x_i 乘权重 w_i 再加起来,即:y = Σ w_i x_i,偏置我们先忽略不看。这里要关心的是 y 的方差。假设权重和输入互相独立、各自均值为 0,把乘积方差展开,每一项 w_i x_i 的方差等于 E[w_i²]E[x_i²],均值为 0 时就是 Var(w_i)·Var(x_i)。n_in 项加起来,就得到 Var(y) = n_in·Var(w)·Var(x)。

这个式子正好解释了失控。如果权重固定用某个方差,每过一层,输出方差就比输入多乘一个 n_in 倍的因子。n_in 大的层,比如第一层接 784 个像素,乘出来就是几百倍,方差瞬间被撑大。层数一多,要么层层放大溢出,要么层层缩小趋零。所以初始化合适的目标,就是让 Var(y) 和 Var(x) 大致相等,信号在每层之间不放大也不缩小。

2. 前后向折中

目标定成 Var(y)=Var(x),代入上式就能解出权重该取多大方差。可这只照顾了前向,反向传播还有另一头要顾及。

令 Var(y)=Var(x),从 Var(y)=n_in·Var(w)·Var(x) 两边约掉,得到 n_in·Var(w)=1,也就是前向要求 Var(w)=1/n_in。再看反向,传给前一层的梯度要对这一层 n_out 个输出节点各自求导再求和,推导和前向对称,得到反向要求 Var(w)=1/n_out。前向要 1/n_in,反向要 1/n_out,这两个数通常不一样,没法同时满足。Xavier 的折中办法是取两者平均,令 N=(n_in+n_out)/2,让 Var(w)=1/N,也就是 Var(w)=2/(n_in+n_out)。这样前向和反向的方差都不会离目标太远。

2.1 高斯分布取值

高斯版本就是让权重服从均值 0、方差 2/(n_in+n_out) 的正态分布,标准差是它的开方。我们拿一个具体网络算一遍,假设某层输入 784 个神经元、输出 256 个,那么 std = sqrt(2/(784+256)) = sqrt(2/1040) ≈ 0.044,权重就从 N(0, 0.044²) 里抽。对比一下,如果图省事直接用 N(0,1),这一层输出方差会被放大 784 倍,数字立刻失控,这就是随便初始化的下场。

3. 均匀区间

高斯分布好推导,可实际框架里更常用均匀分布版本。区间端点怎么定,思路完全一样。

让 w 服从对称均匀分布 U(-a, a)。我们知道,均匀分布在长度为 L 的区间上的方差是 L²/12,这里区间长度是 2a,所以 Var(w)=(2a)²/12=a²/3。把它和 Xavier 要求的方差 2/(n_in+n_out) 划等号,即:a²/3=2/(n_in+n_out),解出 a=sqrt(6/(n_in+n_out)),权重就在这个对称区间内均匀抽样。那个常被问起的除以 12 就来自这里,它是均匀分布方差积分出来的固定系数,不是随便定的。

回到刚才那层 784 进 256 出,端点 a=sqrt(6/1040)≈0.076,权重从 U(-0.076, 0.076) 里抽。两种分布的取值汇总在这张表里:

分布抽样方式关键参数
高斯 Xavier正态分布 N(0, 方差)std = sqrt(2/(n_in+n_out))
均匀 Xavier对称均匀分布 U(-a, a)a = sqrt(6/(n_in+n_out))

4. 接口用法

推导归推导,真写代码的时候不用自己开方,PyTorch 把这些都包好了。下面是 xavier_uniform_ 的实现,你对照上面的公式看,std 就是 sqrt(2/(fan_in+fan_out)),端点 a 再乘一个 sqrt(3)。

def xavier_uniform_(tensor: Tensor, gain: float = 1.) -> Tensor:
    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)

代码里多出来的 gain 是个补偿项。前面推导假设激活函数近似线性、关于原点对称,可不同激活函数会改变输出分布,PyTorch 就用一个增益系数 gain 来修正。它具体怎么算,写在 calculate_gain 里:

def calculate_gain(nonlinearity, param=None):
    """
    Return the recommended gain value for the given nonlinearity function.
    The values are as follows:
    ================= ====================================================
    nonlinearity      gain
    ================= ====================================================
    Linear / Identity :math:`1`
    Conv{1,2,3}D      :math:`1`
    Sigmoid           :math:`1`
    Tanh              :math:`\frac{5}{3}`
    ReLU              :math:`\sqrt{2}`
    Leaky Relu        :math:`\sqrt{\frac{2}{1 + \text{negative\_slope}^2}}`
    SELU              :math:`\frac{3}{4}`
    ================= ====================================================
    """

    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))
    elif nonlinearity == 'selu':
        return 3.0 / 4  # Value found empirically (https://github.com/pytorch/pytorch/pull/50664)
    else:
        raise ValueError("Unsupported nonlinearity {}".format(nonlinearity))

把返回值列成表更直观:

激活函数gain 推荐值
Linear / Identity / Conv1
Sigmoid1
Tanh5/3 ≈ 1.667
ReLUsqrt(2) ≈ 1.414
Leaky ReLUsqrt(2/(1+negative_slope²))
SELU3/4 = 0.75

实际调用时,传入激活函数的名字就能拿到对应的增益:

import torch.nn as nn


if __name__ == '__main__':

    print('relu:', nn.init.calculate_gain('relu'))
    print('sigmoid:', nn.init.calculate_gain('sigmoid'))
    print('tanh:', nn.init.calculate_gain('tanh'))
    print('leaky_relu:', nn.init.calculate_gain('leaky_relu'))
    print('selu:', nn.init.calculate_gain('selu'))

程序输出结果:

relu: 1.4142135623730951
sigmoid: 1
tanh: 1.6666666666666667
leaky_relu: 1.4141428569978354
selu: 0.75

5. 局限与延伸

不过 Xavier 不是万能的,它有一个明确前提,碰到 ReLU 这类激活函数就会露短板。

整个推导建立在一个假设上,激活函数近似线性、关于原点对称,输出均值为 0。tanh 接近这个条件,所以 Xavier 配 tanh 效果不错。但 ReLU 会把所有负值压成 0,经过它之后一半信号没了,方差直接腰斩,前面算好的平衡就被打破。2015 年 He 等人针对这个问题提出改进,把 ReLU 造成的方差损失算进去,把权重方差改成 2/n_in,这就是后来的 He 初始化,也叫 Kaiming 初始化。现在网络里用 ReLU 居多,选初始化时先看激活函数,tanh 配 Xavier,ReLU 配 He,别一套初始化用到所有网络上。