本着“凡我不能创造的,我就不能理解”的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导。
要深入理解深度学习,从零开始创建的经验非常重要,从自己可以理解的角度出发,尽量不使用外部完备的框架前提下,实现我们想要的模型。本系列文章的宗旨就是通过这样的过程,让大家切实掌握深度学习底层实现,而不是仅做一个调包侠。
本系列文章首发于微信公众号:JavaNLP
我们已经了解了线性回归和逻辑回归,本文来学习深度学习中神经网络的基础构建——神经元,以及常见的激活函数。
神经网络和逻辑回归很像,但神经网络更强大。而神经网络是由很多个神经元(Neuron)组成的。一个神经元将实数集作为输入,然后应用某种运算,产生一个实数输出。
在神经元内部,如上图所示,神经元首先计算输入的加权和 ∑ i w i x i \sum_i w_i x_i ∑iwixi,然后加上偏置项 b b b。给定输入 x 1 , ⋯ , x n x_1,\cdots,x_n x1,⋯,xn,每个输入对应一个权重,得到加权和 z z z:
z = b + ∑ i w i x i (1) z = b + \sum_i w_i x_i \tag 1 z=b+i∑wixi(1)
通常使用向量的形式描述更加方便。这样 z z z由向量 w w w和标量 b b b,以及输入向量 x x x来描述:
z = w ⋅ x + b (2) z =w \cdot x +b \tag 2 z=w⋅x+b(2)
注意这里得到的 z z z只是一个实数(标量)。
最后,我们不是直接使用 z z z作为输出,神经元内部应用一个非线性函数 f f f到 z z z上:
y = a = f ( z ) y = a = f(z) y=a=f(z)
这里的非线性函数称为激活函数,该函数的输出值称为激活值 a a a,我们已经见过的一种激活函数是Sigmoid函数:
y = σ ( z ) = 1 1 + e − z (3) y = \sigma(z) = \frac{1}{1 + e^{-z}} \tag 3 y=σ(z)=1+e−z1(3)
这里神经元的输出 y y y和激活值 a a a相同,但在神经网络中,我们通常用 y y y表示整个网络最终的输出。把 ( 2 ) (2) (2)代入 ( 3 ) (3) (3),得到神经元的输出:
y = σ ( w ⋅ x + b ) = 1 1 + exp ( − ( w ⋅ x + b ) ) (4) y = \sigma(w\cdot x + b) = \frac{1}{1 + \exp(-(w\cdot x + b))} \tag 4 y=σ(w⋅x+b)=1+exp(−(w⋅x+b))1(4)
除了Sigmoid之外,还有很多其他比较常见的激活函数。
激活函数(activation function)通过计算加权和并加上偏置来确定神经元是否应该被激活,大多数激活函数都是非线性的。所有
最常用的激活函数是修正线性单元(Rectified linear unit,ReLU),提供了一种非常简单的非线性变换。给定元素 x x x,ReLU函数被定义为该元素与 0 0 0的最大值:
ReLU ( x ) = max ( 0 , x ) (5) \text{ReLU}(x) = \max(0, x) \tag 5 ReLU(x)=max(0,x)(5)
ReLU函数通过将相应的激活值设为 0 0 0,仅保留正元素并丢弃所有负元素。我们可以画出函数的图形感受一下:
from metagrad.functions import *
from metagrad.utils import plot
if __name__ == '__main__':
x = Tensor.arange(-8.0, 8.0, 0.1, requires_grad=True)
y = relu(x)
plot(x.numpy(), y.numpy(), 'x', 'relu(x)', random_fname=True, figsize=(5, 2.5))
当输入为负时,ReLU函数的导数为 0 0 0,当输入为正时,ReLU函数的导数为 1 1 1。当输入为 0 0 0时,我们让其导数也为 0 0 0。
y.backward(Tensor.ones_like(x))
plot(x.numpy(), x.grad.numpy(), 'x', 'grad of relu', figsize=(5, 2.5))
由于ReLU的简单性,没有包含 e x e^x ex,导致它的计算效率极高。同时它的梯度要么为 0 0 0,要么为 1 1 1,这使得优化变现得更好,减轻了困扰神经网络的梯度消息问题。
ReLU导数的函数图形如上图所示,我们可以看到,在 x < 0 x < 0 x<0的一侧,梯度值永远是 0 0 0。因此,在反向传播的过程中,可能有些神经元的权重不会被更新(因为导数为 0 0 0)。这可能会导致永不激活的死节点(神经元)。这个问题可以被ReLU的变种:Leaky ReLU解决。
Leaky ReLU是ReLU的改进版本,主要用于解决上面跳到的死节点问题,通过给所有负值赋予一个小的正斜率来解决
Leaky ReLu ( x ) = m a x ( a x , x ) (6) \text{Leaky ReLu}(x) = max(ax, x) \tag 6 Leaky ReLu(x)=max(ax,x)(6)
通常这里的 a = 0.01 a=0.01 a=0.01,为了看出效果。在画图时让 a = 0.1 a=0.1 a=0.1,我们看一下它的图形:
y = leaky_relu(x)
plot(x.numpy(), y.numpy(), 'x', 'leaky relu(x)', random_fname=True, figsize=(5, 2.5))
Leaky ReLU的优点与ReLU相同,同时对于负输入,其导数也变成了一个非零值(即 a a a)。
从上图可以看到,对于 x < 0 x < 0 x<0的一侧,它们也有非零的导数。不至于出现死节点,但是由于 a a a通常很小,导致在在此侧的模型参数学习缓慢。
除了Leaky ReLU外,类似地还有两种变体,分别是Parametric ReLU和Randomized Leaky ReLU。
Parametric ReLU称为参数化的ReLU,即令Leaky ReLU中的 a a a变成了一个可学习的参数。
而Randomized Leaky ReLU让 a a a取自一个连续均匀概率分布。
ELU(Exponential Linear Unit)也是ReLU的一种变体,类似Leaky ReLU修改在 x < 0 x < 0 x<0侧的斜率,但在负区域不是一条直线,而是一条对数曲线。
ELU ( x ) = max ( 0 , x ) + min ( 0 , α ∗ ( exp ( x ) − 1 ) ) (7) \text{ELU}(x) = \max(0,x) + \min(0, \alpha *(\exp(x)-1)) \tag 7 ELU(x)=max(0,x)+min(0,α∗(exp(x)−1))(7)
通常 α = 1 \alpha=1 α=1,我们画出ELU的图像:
y = elu(x)
plot(x.numpy(), y.numpy(), 'x', 'elu(x)', random_fname=True, figsize=(5, 2.5))
ELU在负值部分缓慢变得平滑,直到输出等于 − α -\alpha −α,且 α α α是一个可调整的参数,它控制着ELU负值部分在何时饱和。但是引入了 e x e^x ex。其导数的图像为:
SoftPlus函数与ReLU函数接近,但比较平滑,也是单边抑制的。
SoftPlus ( x ) = 1 β ∗ log ( 1 + exp ( β ∗ x ) ) (8) \text{SoftPlus}(x) = \frac{1}{\beta} * \log(1 + \exp(\beta * x)) \tag 8 SoftPlus(x)=β1∗log(1+exp(β∗x))(8)
其中 β \beta β默认为 1 1 1,随着 β β β的增加,该函数越来越像ReLU。
我们看一下默认情况下的函数图像:
y = softplus(x, beta=10)
plot(x.numpy(), y.numpy(), 'x', 'softplus(x)', random_fname=True, figsize=(5, 2.5))
当 β = 10 \beta=10 β=10时,我们看一下函数图像:
y = softplus(x, beta=10)
plot(x.numpy(), y.numpy(), 'x', r'softplus(x) with $\beta$ = 10', random_fname=True, figsize=(5, 2.5))
其导数为:
d d x Swish ( x ) = d d x ( 1 β log ( 1 + exp ( β x ) ) ) = 1 β exp ( β x ) β 1 + exp ( β x ) = 1 1 + exp ( − β x ) = σ ( β x ) \begin{aligned} \frac{d}{dx}\text{Swish}(x) &= \frac{d}{dx} \left(\frac{1}{\beta} \log(1 + \exp(\beta x)) \right) \\ &= \frac{1}{\beta} \frac{\exp(\beta x) \beta}{1 + \exp(\beta x)}\\ &= \frac{1}{1 + \exp(-\beta x)} \\ &= \sigma(\beta x) \end{aligned} dxdSwish(x)=dxd(β1log(1+exp(βx)))=β11+exp(βx)exp(βx)β=1+exp(−βx)1=σ(βx)
当 β = 1 \beta=1 β=1,其导数就是 σ ( x ) \sigma(x) σ(x)。我们来看下其导数图像:
Swish在更深层次的模型上显示出比ReLU更好的性能。Swish的输入从负无穷到正无穷。函数定义为
Swish = x ∗ σ ( x ) = x 1 + exp ( − x ) (9) \text{Swish} = x * \sigma(x) = \frac{x}{1 + \exp(-x)} \tag 9 Swish=x∗σ(x)=1+exp(−x)x(9)
相当于是对输入 x x x进行了门控(通过 σ \sigma σ函数),我们看一下它的图像:
y = swish(x)
plot(x.numpy(), y.numpy(), 'x', 'swish(x)', random_fname=True, figsize=(5, 2.5))
它的曲线都是光滑的,且处处可导。当 x x x增大时,函数值趋于无穷大;当 x x x减小时,函数值趋于常数。
Swish函数的导数为下面的公式:
d d x Swish ( x ) = d d x x σ ( x ) = σ ( x ) + σ ( x ) ′ x = σ ( x ) + σ ( x ) ( 1 − σ ( x ) ) x \begin{aligned} \frac{d}{dx}\text{Swish}(x) &= \frac{d}{dx} x \sigma(x) \\ &= \sigma(x) + \sigma(x)^\prime x \\ &= \sigma(x) + \sigma(x)(1 - \sigma(x)) x \end{aligned} dxdSwish(x)=dxdxσ(x)=σ(x)+σ(x)′x=σ(x)+σ(x)(1−σ(x))x
其导数的图像为:
Swish的特性:
无上边界:不像sigmoid和tanh函数,Swish没有上边界的,因为它避免了在接近零的梯度中缓慢的训练时间——像sigmoid或tanh这样的函数是有界的,因此需要小心地初始化网络,以保持在这些函数的界限内。
曲线的平滑性:平滑性在泛化和优化中起着重要的作用。与ReLU不同,Swish是一个平滑的函数,这使得它对初始化权值和学习率不那么敏感。
有下边界:这有助于增强正则化效果( x x x左侧慢慢接近于 0 0 0,一定程度过滤掉一部分信息,起到正则化的效果)。
Sigmoid函数将输入压缩为区间 ( 0 , 1 ) (0,1) (0,1)上的输出。因此,Sigmoid函数通常称为挤压函数(squashing function):
sigmoid ( x ) = 1 1 + exp ( − x ) (10) \text{sigmoid}(x) = \frac{1}{1 + \exp(-x)} \tag {10} sigmoid(x)=1+exp(−x)1(10)
当我们想要将输出看成二分类的概率时,sigmoid此时最常用。然而,sigmoid在隐藏层中较少使用,它通常被更简单、更容易训练的ReLU所取代。
下面我们画出sigmoid函数。
y = sigmoid(x)
plot(x.numpy(), y.numpy(), 'x', 'sigmoid(x)', random_fname=True, figsize=(5, 2.5))
其导数计算如下:
d d x sigmoid ( x ) = d d x 1 1 + e − x = 0 × ( 1 + e − x ) − 1 × ( 1 + e − x ) ′ ( 1 + e − x ) 2 = − ( − e − x ) ( 1 + e − x ) 2 = 1 + e − x − 1 ( 1 + e − x ) 2 = 1 1 + e − x − 1 ( 1 + e − x ) 2 = 1 1 + e − x ( 1 − 1 1 + e − x ) = σ ( x ) ( 1 − σ ( x ) ) \begin{aligned} \frac{d}{dx}\text{sigmoid}(x) &= \frac{d}{dx} \frac{1}{1 + e^{-x}} \\ &= \frac{0 \times (1+e^{-x}) - 1\times (1+e^{-x})^\prime}{(1 + e^{-x})^2} \\ &= \frac{- (-e^{-x})}{(1+e^{-x})^2} \\ &= \frac{1 + e^{-x} - 1}{(1+e^{-x})^2} \\ &= \frac{1}{1 + e^{-x}} - \frac{1}{(1 + e^{-x})^2} \\ &= \frac{1}{1 + e^{-x}} \left( 1 - \frac{1}{1 + e^{-x}} \right) \\ &= \sigma(x)(1 - \sigma(x)) \end{aligned} dxdsigmoid(x)=dxd1+e−x1=(1+e−x)20×(1+e−x)−1×(1+e−x)′=(1+e−x)2−(−e−x)=(1+e−x)21+e−x−1=1+e−x1−(1+e−x)21=1+e−x1(1−1+e−x1)=σ(x)(1−σ(x))
其导数的图像为:
在深层网络中,Sigmoid存在三个问题:
tanh是sigmoid函数的变种,它的函数值变成范围从 − 1 -1 −1到 + 1 +1 +1,即变成了以 0 0 0为中心的。
tanh ( x ) = e x − e − x e x + e − x (11) \tanh(x) = \frac{e^x - e^{-x}}{e^x + e^{-x}} \tag{11} tanh(x)=ex+e−xex−e−x(11)
我们来画出该函数的图像:
y = tanh(x)
plot(x.numpy(), y.numpy(), 'x', 'tanh(x)', random_fname=True, figsize=(5, 2.5))
tanh函数具有平滑可微性,和将离群值映射到均值的良好性质。
为什么说tanh函数是sigmoid函数的变种呢?我们来推导一下:
tanh ( x ) = e x − e − x e x + e − x = e x + e − x − e − x − e − x e x + e − x = 1 + − 2 e − x e x + e − x = 1 − 2 e 2 x + 1 = 1 − 2 σ ( − 2 x ) = 1 − 2 ( 1 − σ ( 2 x ) ) = 1 − 2 + 2 σ ( 2 x ) = 2 σ ( 2 x ) − 1 \begin{aligned} \tanh(x) &= \frac{e^x - e^{-x}}{e^x + e^{-x}} \\ &= \frac{e^x + e^{-x} - e^{-x} - e^{-x} }{e^x + e^{-x}} \\ &= 1 + \frac{-2e^{-x}}{e^x + e^{-x}} \\ &= 1 - \frac{2}{e^{2x}+ 1}\\ &= 1 - 2\sigma(-2x) \\ &= 1 - 2(1 - \sigma(2x)) \\ &= 1 - 2 + 2\sigma(2x) \\ &= 2\sigma(2x) - 1 \end{aligned} tanh(x)=ex+e−xex−e−x=ex+e−xex+e−x−e−x−e−x=1+ex+e−x−2e−x=1−e2x+12=1−2σ(−2x)=1−2(1−σ(2x))=1−2+2σ(2x)=2σ(2x)−1
因此,我们可以看到tanh只是sigmoid函数的缩放版本。
tanh函数的导数是:
d d x tanh ( x ) = d d x e x − e − x e x + e − x = ( e x − e − x ) ′ ( e x + e − x ) − ( e x − e − x ) ( e x + e − x ) ′ ( e x + e − x ) 2 = ( e x + e − x ) 2 − ( e x − e − x ) 2 ( e x + e − x ) 2 = 1 − ( e x − e − x e x + e − x ) 2 = 1 − tanh 2 ( x ) \begin{aligned} \frac{d}{dx}\tanh(x) &= \frac{d}{dx} \frac{e^x - e^{-x}}{e^x + e^{-x}} \\ &= \frac{(e^x - e^{-x})^\prime(e^x + e^{-x}) -(e^x - e^{-x})(e^x + e^{-x})^\prime}{(e^x + e^{-x})^2} \\ &= \frac{(e^x + e^{-x})^2 - (e^x - e^{-x})^2}{(e^x + e^{-x})^2} \\ &= 1 - \left ( \frac{e^x - e^{-x}}{e^x + e^{-x}} \right)^2 \\ &= 1 - \tanh^2(x) \end{aligned} dxdtanh(x)=dxdex+e−xex−e−x=(ex+e−x)2(ex−e−x)′(ex+e−x)−(ex−e−x)(ex+e−x)′=(ex+e−x)2(ex+e−x)2−(ex−e−x)2=1−(ex+e−xex−e−x)2=1−tanh2(x)
其中 d d x e x = e x \frac{d}{dx} e^x = e^x dxdex=ex; d d x e − x = − e − x \frac{d}{dx}e^{-x} = - e^{-x} dxde−x=−e−x
其导数图像如下所示:
可以看到,当输入接近于 0 0 0时,tanh函数的导数接近于最大值 1 1 1。而输入在任一方向上越远离 0 0 0点,导数越接近 0 0 0。
Tanh的缺点类似Sigmoid,不过它是以 0 0 0为中心的,避免了zig zag问题。
我们看了这么多激活函数,到底要如何选择呢?
在深层网络中,首先要尝试ReLU,它具有速度快的优点,如果效果欠佳;
那么尝试Leaky ReLU;
或者tanh这种以零为中心的;
另外,在RNN中常用sigmoid或tanh,作为门控或概率值。
完整代码笔者上传到了程序员最大交友网站上去了,地址: https://github.com/nlp-greyfoss/metagrad