NLP笔记:浅谈交叉熵(cross entropy)

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实现的差异如下:

  1. tensorflow的cross entropy函数输入为(y_true, y_pred),而pytorch刚好相反,输入为(y_pred, y_true)
  2. tensorflow的cross entropy函数输入要求y_true与y_pred具有相同的shape,即y_true需要为one_hot形式的向量,而pytorch则相反,要求输入的y_true为id形式,内部会自行实现one_hot过程
  3. 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. 引申思考

最后,我们来回头看看上面的两个遗留的两个问题:

  1. 最早我们注意到这个问题是因为我们发现loss收敛不下去,这个原因在于我们用了两次softmax,但是为什么两次softmax之后就会导致loss下降如此之慢呢?
  2. 我们一开始关于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)=∑i​exi​exi​​

因此,他除了是一个归一化的过程,还会对预测的概率进行一个调整,而这个概率调整的过程是一个平滑的抹平过程。

我们假设共有n个预测类,考察最好和最坏的情况:

  1. 最坏的情况下,所有类的一级预测概率值都是 1 n \frac{1}{n} n1​,此时经过二次softmax计算之后依然所有的值都是 1 n \frac{1}{n} n1​;
  2. 最好的情况下第一级输入只有一个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的实验,运行得到结果如下:

  1. 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
    
  2. 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=−∑i​p(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变化如下图所示:

NLP笔记:浅谈交叉熵(cross entropy)

可以看到:

  • 两条曲线是极其相似的,非要说的话就是前期cross entropy上升较慢,后期我们的伪cross entropy函数更快地达到了过拟合的状态。

这和我们之前的预期是一致的:

  • 我们定义的这个伪cross entropy函数考虑了负信号的影响,因此收敛会更快,从而也更容易达到过拟合的情况。

因此,在数据量较大模型难以学习的情况,也许由于我们的这个伪cross entropy公式反而可以比正版的cross entropy损失函数达到更好的一个效果表达。

当然,这里也就是一个定性的分析,要想获得更加确切的结论,还需要我们做更多的实验进行验证。

5. 参考链接

  1. 【机器学习】信息量,信息熵,交叉熵,KL散度和互信息(信息增益)
  2. 信息熵、交叉熵和相对熵
  3. 香浓熵(Shannon)与冯诺伊曼熵(Von Neumann)
  4. 如何理解K-L散度(相对熵)
  5. KL散度理解
上一篇:Tensorflow踩坑系列---softmax_cross_entropy_with_logits


下一篇:vux-cli3 项目文件分析 webpack-bundle-analyzer