0. 引言
故事起源于我之前博客【NLP笔记:fastText模型考察】遇到的一个问题,即pytorch实现的fasttext模型收敛极慢的问题,后来我们在word2vec的demo实验中又一次遇到了这个问题,因此感觉再也不能忽视这个奇葩的问题了,于是我们单独测了一下tensorflow与pytorch的cross entropy实现,发现了如下现象:
import numpy
import torch
import tensorflow as tf
y_true = numpy.array([[1,0,0,0], [0,1,0,0]])
y_pred = numpy.array([[0.1,0.2,0.3,0.4], [0.4,0.3,0.2,0.1]])
s1 = torch.nn.CrossEntropyLoss()(torch.Tensor(y_pred), torch.argmax(torch.Tensor(y_true), dim=-1))
print(s1) # tensor(1.4425)
s2 = tf.keras.losses.CategoricalCrossentropy()(y_true, y_pred)
print(s2) # tf.Tensor(1.7532789707183838, shape=(), dtype=float64)
emmmm…
于是我赶紧自己写了一个cross entropy的代码实现进行了检验,结果发现:
def cross_entropy(y_pred, y_true):
num_classes = y_pred.size()[-1]
y_true = torch.nn.functional.one_hot(y_true, num_classes=num_classes)
loss = - (y_true * torch.log(y_pred) + (1-y_true) * torch.log(1-y_pred))
return torch.mean(torch.sum(loss, dim=-1))
s3 = cross_entropy(torch.Tensor(y_pred), torch.argmax(torch.Tensor(y_true), dim=-1))
print(s3) # tensor(2.7183)
WTF!!!
emmmm,好吧,看来tensorflow用太多了,对这些基础的概念都有些生疏了,就趁这个机会稍微复习一下交叉熵(cross entropy)这个基础的概念吧。。。
1. 交叉熵的定义
这里,我们就来系统的整理一下交叉熵的定义问题。要讲清楚交叉熵,我们首先要看一下信息熵的定义。
1. 信息熵
信息熵最先是由Shannon提出来的,它用于衡量事件发生所带有的信息量,Shannon熵的定义公式如下:
H ( x ) = − P ( x ) ⋅ l o g ( P ( x ) ) = − ∑ i p ( x i ) ⋅ l o g ( p ( x i ) ) \begin{aligned} H(x) &= - P(x) \cdot log(P(x)) \\ &= -\sum_{i} p(x_{i})\cdot log(p(x_i)) \end{aligned} H(x)=−P(x)⋅log(P(x))=−i∑p(xi)⋅log(p(xi))
在Shannon的原始定义中, l o g log log的底数为2,不过显然不同的底数之间其值事实上也只相差了一个常数倍,因此整体上这个事实上并没有特别重要,一般用 e e e作为底也没啥问题。
后来冯·诺伊曼将其推广至了量子体系下,使用概率密度矩阵的形式重新定义了量子体系的信息熵,又称之为冯·诺依曼熵,但这已经是后话了,这里就不需要多做展开了。
2. 相对熵(KL散度)
在信息熵的基础上,我们可以引入相对熵,即KL散度的概念:
- KL散度是指,当我们用一个分布 q ( x ) q(x) q(x)来拟合另一个分布 p ( x ) p(x) p(x)时,会导致的信息增量。
因此,我们可以快速地得到KL散度的定义公式如下:
K L ( p ∣ ∣ q ) = ∑ i ( − p ( x i ) ⋅ l o g ( q ( x i ) − ( − p ( x i ) ⋅ l o g ( p ( x i ) ) ) ) = − ∑ i p ( x i ) ⋅ l o g ( q ( x i ) p ( x i ) ) \begin{aligned} KL(p||q) &= \sum_{i}(-p(x_i)\cdot log(q(x_i) - (-p(x_i) \cdot log(p(x_i)))) \\ &= -\sum_{i} p(x_i) \cdot log(\frac{q(x_i)}{p(x_i)}) \end{aligned} KL(p∣∣q)=i∑(−p(xi)⋅log(q(xi)−(−p(xi)⋅log(p(xi))))=−i∑p(xi)⋅log(p(xi)q(xi))
当然,对于连续分布,只要将求和换为积分即可。
3. 交叉熵
交叉熵是信息熵与KL散度的伴生产物,我们给出交叉熵的定义如下:
c r o s s e n t r o p y = − ∑ i p ( x i ) ⋅ l o g ( q ( x i ) ) = H ( x ) + K L ( p ∣ ∣ q ) \begin{aligned} cross\ entropy &= -\sum_{i} p(x_i) \cdot log(q(x_i)) \\ &= H(x) + KL(p||q) \end{aligned} cross entropy=−i∑p(xi)⋅log(q(xi))=H(x)+KL(p∣∣q)
写到这里,相信大多数读者也清楚了,上面我自己实现cross entropy函数在代码实现上是错误的,原因在于我记错公式了。。。
果然太经常使用工具毁一生啊,有必要把这些基础的概念全部复习一遍了,见鬼。。。
不过尽管如此,我们给出的定义事实上也是在一定意义上不是完全不合理,这个我们在后面第四节中会进行一些讨论,这里就先继续我们的话题吧。
2. 交叉熵的实现
现在,我们已经有了交叉熵的真实定义公式如下:
c r o s s e n t r o p y = − ∑ i p ( x i ) ⋅ l o g ( q ( x i ) ) cross\ entropy = -\sum_{i} p(x_i) \cdot log(q(x_i)) cross entropy=−i∑p(xi)⋅log(q(xi))
有了这个公式,我们可以自行给出cross entropy的代码实现如下:
1. tensorflow实现
给出tensorflow的代码实现如下:
def cross_entropy(y_true, y_pred):
loss = -y_true * tf.math.log(y_pred)
return tf.reduce_mean(tf.reduce_sum(loss, axis=1))
在上述同样的测试数据下,计算得到cross entropy结果为:
tf.Tensor(1.753278948659991, shape=(), dtype=float64)
2. pytorch实现
给出pytorch框架下的cross entropy代码实现如下:
def cross_entropy(y_pred, y_true):
num_classes = y_pred.size()[-1]
y_true = torch.nn.functional.one_hot(y_true, num_classes=num_classes)
loss = - y_true * torch.log(y_pred)
return torch.mean(torch.sum(loss, dim=-1))
在上述同样的测试数据下,计算得到cross entropy结果为:
tensor(1.7533)
3. tensorflow与pytorch中交叉熵的区别
由上述第二节的内容中我们已经发现,1.75才应该是cross entropy的正解,也就是说,pytorch的cross entropy内置算法居然是错的,这显然是不太可能的,更大的概率是我们在使用上存在着偏差。
于是,我们细看了pytorch关于torch.nn.CrossEntropyLoss
的文档,发现其中有这么一段描述:
This criterion combines nn.LogSoftmax() and nn.NLLLoss() in one single class.
emmmm…
好吧,也许pytorch的cross entropy函数实现当中内置了softmax的计算,也就是说,输入向量我们不需要手动将其进行归一化操作。
我们对这一假设进行尝试,重新定义cross entropy函数:
def cross_entropy(y_true, y_pred):
y_pred = tf.nn.softmax(y_pred, axis=-1)
loss = -y_true * tf.math.log(y_pred)
return tf.reduce_mean(tf.reduce_sum(loss, axis=1))
对上述输入重新计算得到:
tf.Tensor(1.4425355294551627, shape=(), dtype=float64)
好吧,真相大白。。。
重要的事说上两遍,我们重新整理tensorflow与pytorch的cross entropy实现的差异如下:
-
tensorflow的cross entropy函数输入为
(y_true, y_pred)
,而pytorch刚好相反,输入为(y_pred, y_true)
; - tensorflow的cross entropy函数输入要求y_true与y_pred具有相同的shape,即y_true需要为one_hot形式的向量,而pytorch则相反,要求输入的y_true为id形式,内部会自行实现one_hot过程;
-
tensorflow的cross entropy方法默认输入已经做好了softmax计算,否则需要特殊指定
from_logits=True
,而pytorch则不需要输入执行softmax计算,它内部会自行进行一次softmax计算。
因此,我们在之前的实验当中取出掉代码中的softmax部分,果然一切都恢复正常了。。。
更一般的,我们在sequence_labelling问题中考察tf与pytorch当中的crossentropy实现,发现他们之间还有一个坑存在,即:
-
tensorflow的cross entropy函数在sequence labeling问题中要求输出格式为:
[N, L, C]
,即要求label的概率分布处在最后一维; - 而pytorch的cross entropy函数定义要求y_pred与y_true的输入格式为:
[N, C, L]
与[N, L]
,即输出处于第二维!
这简直是神坑啊,唉。。。
给出代码示例如下:
y_true = numpy.array([[1,0],[2,3]])
y_pred = numpy.array([[[0.1,0.2,0.3,0.4], [0.4,0.3,0.2,0.1]], [[0.1,0.2,0.3,0.4], [0.4,0.3,0.2,0.1]]])
torch.nn.CrossEntropyLoss()(torch.tensor(y_pred.swapaxes(1,2)), torch.tensor(y_true))
# tensor(1.3925, dtype=torch.float64)
tf.keras.losses.CategoricalCrossentropy()(tf.one_hot(y_true, 4), tf.nn.softmax(y_pred))
# <tf.Tensor: shape=(), dtype=float64, numpy=1.3925354480743408>
def cross_entropy(y_pred, y_true):
num_classes = y_pred.size()[-1]
y_pred = torch.nn.functional.softmax(y_pred, dim=-1)
y_true = torch.nn.functional.one_hot(y_true, num_classes=num_classes)
loss = - y_true * torch.log(y_pred)
return torch.mean(torch.sum(loss, dim=-1))
cross_entropy(torch.tensor(y_pred), torch.tensor(y_true, dtype=torch.long))
# tensor(1.3925)
4. 引申思考
最后,我们来回头看看上面的两个遗留的两个问题:
- 最早我们注意到这个问题是因为我们发现loss收敛不下去,这个原因在于我们用了两次softmax,但是为什么两次softmax之后就会导致loss下降如此之慢呢?
- 我们一开始关于cross entropy虽然是因为记错了公式,但是,我们也想看一下,如果真的这么定义cross entropy,是否是一个合理的loss定义呢?
1. 两次softmax的影响
这里,我们来看一下两次softmax对结果的影响。
我们首先给出softmax的公式如下:
s o f t m a x ( x ) = e x i ∑ i e x i softmax(x) = \frac{e^{x_i}}{\sum_{i}e^{x_i}} softmax(x)=∑iexiexi
因此,他除了是一个归一化的过程,还会对预测的概率进行一个调整,而这个概率调整的过程是一个平滑的抹平过程。
我们假设共有n个预测类,考察最好和最坏的情况:
- 最坏的情况下,所有类的一级预测概率值都是 1 n \frac{1}{n} n1,此时经过二次softmax计算之后依然所有的值都是 1 n \frac{1}{n} n1;
- 最好的情况下第一级输入只有一个1,其他全是0,此时经过第二次softmax过程之后,1变为 e e + n − 1 \frac{e}{e+n-1} e+n−1e,而0变为 1 e + n − 1 \frac{1}{e+n-1} e+n−11,当n比较大时,差值 e − 1 e + n − 1 \frac{e-1}{e+n-1} e+n−1e−1趋向于0。
因此,我们就可以理解了,两次softmax过程之后导致所有的预测概率基本都被平均了,从而导致模型的学习难度大大增加,无怪乎loss下降如此之慢,最终的效果如此之差。
2. 伪cross entropy合理性分析
这里,我们重新给出我们错误的cross entropy的公式如下:
L = − ∑ i ( p ( x i ) ⋅ l o g ( q ( x i ) ) + ( 1 − p ( x i ) ) ⋅ l o g ( 1 − q ( x i ) ) ) L = -\sum_{i}(p(x_i) \cdot log(q(x_i)) + (1-p(x_i)) \cdot log(1- q(x_i))) L=−i∑(p(xi)⋅log(q(xi))+(1−p(xi))⋅log(1−q(xi)))
记错这个公式的浅层原因其实也直接,因为当问题恰好为二分类时,那么cross entropy刚好可以写为:
L
=
−
y
⋅
l
o
g
(
q
(
x
)
)
−
(
1
−
y
)
⋅
l
o
g
(
1
−
q
(
x
)
)
L = -y \cdot log(q(x)) - (1-y) \cdot log(1- q(x))
L=−y⋅log(q(x))−(1−y)⋅log(1−q(x))
不过,请容我为自己辩护一下,我之所以会因此而记错公式,是因为确实上述的loss函数定义也具有一定的合理性。
显然,在正常的cross entropy定义下,我们就只能看到对于正确标签的那一项结果(记作 q i q_i qi)的预测概率,根据loss进行参数优化之后,也只能令这个 q i q_i qi尽可能向1靠拢,而无法学到其他 q j ( j ≠ i ) q_j(j \neq i) qj(j=i)应该向0靠拢的信息,他们的优化只能通过 ∑ i ( q i ) = 1 \sum_i(q_i) = 1 ∑i(qi)=1这个隐藏条件间接地进行学习。
但是,通过我们给出的这个伪cross entropy的损失函数定义公式,却可以同时学到 q i = 1 q_i = 1 qi=1以及 q j = 0 ( j ≠ i ) q_j = 0\ (j \neq i) qj=0 (j=i)的信息。
那么,是不是说我们的loss定义反而会更好一些呢?
事实上,我们使用这个loss定义方式重新跑了一下fasttext的实验,运行得到结果如下:
-
l o s s = − ∑ i ( p ( x i ) ⋅ l o g ( q ( x i ) ) + ( 1 − p ( x i ) ) ⋅ l o g ( 1 − q ( x i ) ) ) loss = -\sum_{i}(p(x_i) \cdot log(q(x_i)) + (1-p(x_i)) \cdot log(1- q(x_i))) loss=−∑i(p(xi)⋅log(q(xi))+(1−p(xi))⋅log(1−q(xi)))
precision recall f1-score support 0 0.52 0.68 0.59 5022 1 0.21 0.13 0.16 2302 2 0.21 0.15 0.17 2541 3 0.26 0.25 0.25 2635 4 0.22 0.24 0.23 2307 5 0.24 0.21 0.22 2850 6 0.20 0.10 0.13 2344 7 0.48 0.62 0.54 4999 accuracy 0.37 25000 macro avg 0.29 0.30 0.29 25000 weighted avg 0.34 0.37 0.35 25000
-
l o s s = − ∑ i p ( x i ) ⋅ l o g ( q ( x i ) ) loss = -\sum_{i}p(x_i) \cdot log(q(x_i)) loss=−∑ip(xi)⋅log(q(xi))
precision recall f1-score support 0 0.52 0.68 0.59 5022 1 0.22 0.10 0.14 2302 2 0.21 0.17 0.19 2541 3 0.25 0.24 0.24 2635 4 0.22 0.22 0.22 2307 5 0.23 0.23 0.23 2850 6 0.21 0.12 0.15 2344 7 0.48 0.61 0.54 4999 accuracy 0.37 25000 macro avg 0.29 0.30 0.29 25000 weighted avg 0.33 0.37 0.34 25000
可以看到:
- 两者的实验效果事实上相差无几。
因此,我们需要做一些更加具体的实验,考察模型的收敛速度和最终的收敛值变化。
我们同样在fasttext的实验中运行100个epoch,考察模型在测试集上的accuracy变化如下图所示:
可以看到:
- 两条曲线是极其相似的,非要说的话就是前期cross entropy上升较慢,后期我们的伪cross entropy函数更快地达到了过拟合的状态。
这和我们之前的预期是一致的:
- 我们定义的这个伪cross entropy函数考虑了负信号的影响,因此收敛会更快,从而也更容易达到过拟合的情况。
因此,在数据量较大模型难以学习的情况,也许由于我们的这个伪cross entropy公式反而可以比正版的cross entropy损失函数达到更好的一个效果表达。
当然,这里也就是一个定性的分析,要想获得更加确切的结论,还需要我们做更多的实验进行验证。