引言

今天来学习下LogSumExp(LSE)​1​​技巧,主要解决计算Softmax或CrossEntropy​2​时出现的上溢(overflow)或下溢(underflow)问题。

我们知道编程语言中的数值都有一个表示范围的,如果数值过大,超过最大的范围,就是上溢;如果过小,超过最小的范围,就是下溢。

什么是LSE

LSE被定义为参数指数之和的对数:
一文弄懂LogSumExp技巧_机器学习
输入可以看成是一个n维的向量,输出是一个标量。

为什么需要LSE

在机器学习中,计算概率输出基本都需要经过Softmax函数,它的公式应该很熟悉了吧
一文弄懂LogSumExp技巧_LogSumExp_02
但是Softmax存在上溢和下溢大问题。如果一文弄懂LogSumExp技巧_代码实现_03太大,对应的指数函数也非常大,此时很容易就溢出,得到​​​nan​​​结果;如果一文弄懂LogSumExp技巧_代码实现_03太小,或者说负的太多,就会导致出现下溢而变成0,如果分母变成0,就会出现除0的结果。

此时我们经常看到一个常见的做法是(其实用到的是指数归一化技巧, exp-normalize​3​​),先计算一文弄懂LogSumExp技巧_机器学习_05中的最大值一文弄懂LogSumExp技巧_LogSumExp_06,然后根据
一文弄懂LogSumExp技巧_LogSumExp_07
这种转换是等价的,经过这一变换,就避免了上溢,最大值变成了一文弄懂LogSumExp技巧_ide_08;同时分母中也会有一个1,就避免了下溢。

我们通过实例来理解一下。

def bad_softmax(x):
y = np.exp(x)
return y / y.sum()

x = np.array([1, -10, 1000])
print(bad_softmax(x))
... RuntimeWarning: overflow encountered in exp
... RuntimeWarning: invalid value encountered in true_divide
array([ 0., 0., nan])

接下来进行上面的优化,并进行测试:

def softmax(x):
b = x.max()
y = np.exp(x - b)
return y / y.sum()

print(softmax(x))
array([0., 0., 1.])

我们再看下是否会出现下溢:

x = np.array([-800, -1000, -1000])
print(bad_softmax(x))
# array([nan, nan, nan])
print(softmax(x))
# array([1.00000000e+00, 3.72007598e-44, 3.72007598e-44])

嗯,看来解决了这个两个问题。

一文弄懂LogSumExp技巧_LogSumExp_09

等等,不是说LSE吗,怎么整了个什么归一化技巧。

好吧,回到LSE。

我们对Softmax取对数,得到:
一文弄懂LogSumExp技巧_机器学习_10
因为上面最后一项也有上溢的问题,所以应用同样的技巧,得
一文弄懂LogSumExp技巧_机器学习_11
一文弄懂LogSumExp技巧_归一化_12同样是取一文弄懂LogSumExp技巧_机器学习_05中的最大值。

这样,我们就得到了LSE的最终表示:
一文弄懂LogSumExp技巧_代码实现_14
此时,Softmax也可以这样表示:
一文弄懂LogSumExp技巧_LogSumExp_15
对LogSumExp求导就得到了exp-normalize(Softmax)的形式,
一文弄懂LogSumExp技巧_ide_16

那我们是使用exp-normalize还是使用LogSumExp呢?

如果你需要保留Log空间,那么就计算一文弄懂LogSumExp技巧_机器学习_17,此时使用LogSumExp技巧;如果你只需要计算Softmax,那么就使用exp-normalize技巧。

怎么实现LSE

实现LSE就很简单了,我们通过代码实现一下。

def logsumexp(x):
b = x.max()
return b + np.log(np.sum(np.exp(x - b)))

def softmax_lse(x):
return np.exp(x - logsumexp(x))

上面是基于LSE实现了Softmax,下面测试一下:

> x1 = np.array([1, -10, 1000])
> x2 = np.array([-900, -1000, -1000])
> softmax_lse(x1)
array([0., 0., 1.])
> softmax(x1)
array([0., 0., 1.])
> softmax_lse(x2)
array([1.00000000e+00, 3.72007598e-44, 3.72007598e-44])
> softmax(x2)
> array([1.00000000e+00, 3.72007598e-44, 3.72007598e-44])

最后我们看一下数值稳定版的Sigmoid函数

数值稳定的Sigmoid函数

我们知道Sigmoid函数公式为:
一文弄懂LogSumExp技巧_LogSumExp_18
对应的图像如下:

一文弄懂LogSumExp技巧_归一化_19

其中包含一个一文弄懂LogSumExp技巧_机器学习_20,我们看一下一文弄懂LogSumExp技巧_归一化_21的图像:

一文弄懂LogSumExp技巧_LogSumExp_22

从上图可以看出,如果一文弄懂LogSumExp技巧_机器学习_05很大,一文弄懂LogSumExp技巧_归一化_21会非常大,而很小就没事,变成无限接近一文弄懂LogSumExp技巧_代码实现_25

当Sigmoid函数中的一文弄懂LogSumExp技巧_机器学习_05负的特别多,那么一文弄懂LogSumExp技巧_机器学习_20就会变成一文弄懂LogSumExp技巧_机器学习_28,就出现了上溢;

那么如何解决这个问题呢?一文弄懂LogSumExp技巧_代码实现_29可以表示成两种形式:
一文弄懂LogSumExp技巧_代码实现_30
一文弄懂LogSumExp技巧_代码实现_31时,我们根据一文弄懂LogSumExp技巧_LogSumExp_32的图像,我们取一文弄懂LogSumExp技巧_LogSumExp_33的形式;

一文弄懂LogSumExp技巧_LogSumExp_34时,我们取一文弄懂LogSumExp技巧_LogSumExp_35

# 原来的做法
def sigmoid_naive(x):
return 1 / (1 + math.exp(-x))

# 优化后的做法
def sigmoid(x):
if x < 0:
return math.exp(x) / (1 + math.exp(x))
else:
return 1 / (1 + math.exp(-x))

然后用不同的数值进行测试:

> sigmoid_naive(2000)
1.0
> sigmoid(2000)
1.0
> sigmoid_naive(-2000)
OverflowError: math range error
> sigmoid(-2000)
0.0

References


  1. ​The Log-Sum-Exp Trick ​​​ ​​↩︎​
  2. ​一文弄懂交叉熵损失 ​​​ ​​↩︎​
  3. ​Exp-normalize trick​​​ ​​↩︎​