GELU 激活函数

GELU 激活函数

我们知道,神经网络里每个神经元算完线性加权之后,都要过一个激活函数。没有它,多层网络叠起来还是线性变换,学不了复杂的东西。激活函数里最常见的要数 ReLU,输入大于 0 原样输出,小于等于 0 直接归零,简单高效,但它有个粗暴的地方,负值的信息被一刀切全扔了。

GELU 是 2016 年提出的替代方案,全称高斯误差线性单元。它不做硬开关,而是按概率决定放行多少,负值不是全丢,而是保留一点。BERT、GPT、ALBERT 这些 Transformer 模型内部用的都是它,是如今 NLP 里出现频率最高的激活函数之一。这一节我们把它的公式、直觉、数值例子和代码验证一次讲清,读之前只需要知道激活函数是干嘛的,以及 ReLU 的基本规则。

1. 硬开关的代价

ReLU 的规则就一句话,输入大于 0 就原样输出,小于等于 0 就归零。它让网络计算快、梯度好传,代价也藏在这句话里。输入一旦是负的,输出就是 0,梯度也是 0,这个神经元对后面的输出不再有任何贡献。如果一批神经元的输入长期落在负区,它们就再也学不动了,这就是常说的神经元死亡。

另外,负值信息本身可能也有用。一个特征的负响应往往代表抑制信号,例如:某个输入不出现时,这个特征是在强烈地表达否定,ReLU 把这样的值整个抹成 0,等于把这一类信息也丢了。硬开关的好处是简单,代价就是对负区间太绝情。

2. 软开关的思路

那能不能不做非 0 即 1 的开关,而是给每个输入一个放行比例?GELU 就是这个思路,公式只有一行,GELU(x) = x 乘 Φ(x)。这里的 Φ 是标准正态分布的累计分布函数,它给出一个 0 到 1 之间的值,x 越大这个值越接近 1,x 越小越接近 0。

直觉上可以这样理解。输入值大,说明这个特征很活跃,大概率是重要的,模型就几乎全放行。输入值很小甚至为负,说明这个特征不活跃,模型就把它压到接近 0,但不完全抹掉。要提醒一句,这只是直观理解,模型并不是真的在抛硬币,整个计算是确定性的,概率只是一个解释框架。

3. 数值例子

我们拿几个具体的 x 代进去算一遍,比盯着公式直观得多。查标准正态分布表,Φ(1) 约等于 0.8413,所以 GELU(1) = 1 × 0.8413 = 0.8413。Φ(0) 是 0.5,GELU(0) = 0。Φ(-1) 约等于 0.1587,GELU(-1) = -0.1587。把几个点列在一起,和 ReLU 的输出对照:

输入 xΦ(x)GELU(x)ReLU(x)
20.97721.95452
10.84130.84131
00.500
-10.1587-0.15870
-20.0228-0.04550

我们看负值这几行,ReLU 全部归零,GELU 留下一个小小的负值,这正是两者的本质差别。负值保留一点点,梯度就能继续流动,神经元不容易死掉,网络的表达能力也更平滑。

4. 名字与近似

为什么叫误差线性单元?因为 Φ(x) 的解析形式要借助误差函数 erf,展开写就是 GELU(x) = 0.5x(1 + erf(x 除以 √2)),误差函数的名字就这样进了激活函数的名字。

标准正态的累计分布没有初等函数的精确表达式,实际代码里常用一个 tanh 近似,GELU(x) 约等于 0.5x(1 + tanh(√(2/π)(x + 0.044715x³)))。要记住这只是近似,误差在 0.0001 这个量级,PyTorch 的默认实现用的就是这个近似,它也提供了基于 erf 的精确实现。

5. 代码验证

原理讲完了,我们用代码把上面的数值跑出来验证一遍。先手写精确实现,用 math 库里的 erf:

import math

def gelu_exact(x):
    # GELU(x) = x * Phi(x), 其中 Phi 是标准正态累计分布
    # Phi(x) = 0.5 * (1 + erf(x / sqrt(2)))
    return 0.5 * x * (1 + math.erf(x / math.sqrt(2)))

for x in [2.0, 1.0, 0.0, -1.0, -2.0]:
    print(x, round(gelu_exact(x), 4))

# 输出:
# 2.0  1.9545
# 1.0  0.8413
# 0.0  0.0
# -1.0 -0.1587
# -2.0 -0.0455

跑出来的结果和上面表格完全对得上。我们再看 PyTorch 一行怎么算:

import torch

x = torch.tensor([2.0, 1.0, 0.0, -1.0, -2.0])
print(torch.nn.functional.gelu(x))
# tensor([ 1.9545,  0.8413,  0.0000, -0.1587, -0.0455])

最后我们把 GELU 和 ReLU 的曲线画在一起,一眼看出软开关和硬开关的区别。范围取 -6 到 6 就够,再往外两条线都接近直线,看不出名堂:

import torch
import matplotlib.pyplot as plt
import torch.nn.functional as F

x = torch.linspace(-6, 6, 1000)

# GELU 与 ReLU 对比
y_gelu = F.gelu(x)
y_relu = torch.relu(x)

plt.plot(x, y_gelu, label='GELU')
plt.plot(x, y_relu, '--', label='ReLU')
plt.grid()
plt.legend()
plt.title('GELU 与 ReLU 对比')
plt.show()

# 导数图像,看负区间梯度是否断裂
x = torch.linspace(-6, 6, 1000, requires_grad=True)
F.gelu(x).sum().backward()

plt.plot(x.detach(), x.grad)
plt.grid()
plt.title('GELU 导数')
plt.show()

6. 常见误区

最容易踩的坑,是把 GELU 和 SiLU 当成同一个函数。SiLU 是 x 乘 sigmoid(x),公式长得像,数值也接近,但一个是标准正态累计分布,一个是 sigmoid,来源和精确值都不同,论文里也明确是两回事。

第二个坑,是把 tanh 那个公式当成精确值。它只是近似,复现论文或者对精度敏感的时候,用基于 erf 的精确实现更稳。

第三个坑,是把按概率放行理解成模型真的会随机。前向计算每一步都是确定性的,同样的输入永远得到同样的输出,概率只是帮助理解的框架。

回到开头的问题。ReLU 的硬开关把负值信息全丢,神经元还可能死亡。GELU 用标准正态累计分布做软开关,值大的放行多,值小的保留一点负值,梯度不断裂。Transformer 的前馈层选它,看中的就是这种平滑和稳定。验证也简单,把上面的代码跑一遍,数值和表格对得上,原理就立住了。原论文见 https://arxiv.org/pdf/1606.08415.pdf