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

2021-03-28 14:46:21 浏览数 (1)

0. 引言

故事起源于我之前博客【NLP笔记:fastText模型考察】遇到的一个问题,即pytorch实现的fasttext模型收敛极慢的问题,后来我们在word2vec的demo实验中又一次遇到了这个问题,因此感觉再也不能忽视这个奇葩的问题了,于是我们单独测了一下tensorflow与pytorch的cross entropy实现,发现了如下现象:

代码语言:javascript复制
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的代码实现进行了检验,结果发现:

代码语言:javascript复制
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熵的定义公式如下:

在Shannon的原始定义中,log的底数为2,不过显然不同的底数之间其值事实上也只相差了一个常数倍,因此整体上这个事实上并没有特别重要,一般用e作为底也没啥问题。

后来冯·诺伊曼将其推广至了量子体系下,使用概率密度矩阵的形式重新定义了量子体系的信息熵,又称之为冯·诺依曼熵,但这已经是后话了,这里就不需要多做展开了。

2. 相对熵(KL散度)

在信息熵的基础上,我们可以引入相对熵,即KL散度的概念:

  • KL散度是指,当我们用一个分布q(x)来拟合另一个分布p(x)时,会导致的信息增量。

因此,我们可以快速地得到KL散度的定义公式如下:

当然,对于连续分布,只要将求和换为积分即可。

3. 交叉熵

交叉熵是信息熵与KL散度的伴生产物,我们给出交叉熵的定义如下:

写到这里,相信大多数读者也清楚了,上面我自己实现cross entropy函数在代码实现上是错误的,原因在于我记错公式了。。。

果然太经常使用工具毁一生啊,有必要把这些基础的概念全部复习一遍了,见鬼。。。

不过尽管如此,我们给出的定义事实上也是在一定意义上不是完全不合理,这个我们在后面第四节中会进行一些讨论,这里就先继续我们的话题吧。

2. 交叉熵的实现

现在,我们已经有了交叉熵的真实定义公式如下:

有了这个公式,我们可以自行给出cross entropy的代码实现如下:

1. tensorflow实现

给出tensorflow的代码实现如下:

代码语言:javascript复制
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结果为:

代码语言:javascript复制
tf.Tensor(1.753278948659991, shape=(), dtype=float64)

2. pytorch实现

给出pytorch框架下的cross entropy代码实现如下:

代码语言:javascript复制
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结果为:

代码语言:javascript复制
tensor(1.7533)

3. tensorflow与pytorch中交叉熵的区别

由上述第二节的内容中我们已经发现,1.75才应该是cross entropy的正解,也就是说,pytorch的cross entropy内置算法居然是错的,这显然是不太可能的,更大的概率是我们在使用上存在着偏差。

于是,我们细看了pytorch关于torch.nn.CrossEntropyLoss的文档,发现其中有这么一段描述:

代码语言:javascript复制
This criterion combines nn.LogSoftmax() and nn.NLLLoss() in one single class.

emmmm…

好吧,也许pytorch的cross entropy函数实现当中内置了softmax的计算,也就是说,输入向量我们不需要手动将其进行归一化操作。

我们对这一假设进行尝试,重新定义cross entropy函数:

代码语言:javascript复制
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))

对上述输入重新计算得到:

代码语言:javascript复制
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]**,即输出处于第二维!

这简直是神坑啊,唉。。。

给出代码示例如下:

代码语言:javascript复制
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)

又注:

  • 经过测试,我们在pytorch中自行实现的cross entropy函数在实际的运行中发现效率略低于pytorch内置的函数实现,因此,在实际的应用中,更建议使用系统内置的cross entropy函数,尽管其定义真心奇葩,唉。。。

又又注:

  • 像pytorch那样自带one-hot内置实现的cross entropy函数在tensorflow中也有相应的代码实现,即:tf.keras.losses.SparseCategoricalCrossentropy类。

给出其测试内容如下:

代码语言:javascript复制
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]]])

tf.keras.losses.SparseCategoricalCrossentropy()(y_true, y_pred)
# <tf.Tensor: shape=(), dtype=float64, numpy=1.5080716609954834>

tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)(y_true, y_pred)
# <tf.Tensor: shape=(), dtype=float64, numpy=1.3925354480743408>

4. 引申思考

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

  1. 最早我们注意到这个问题是因为我们发现loss收敛不下去,这个原因在于我们用了两次softmax,但是为什么两次softmax之后就会导致loss下降如此之慢呢?
  2. 我们一开始关于cross entropy虽然是因为记错了公式,但是,我们也想看一下,如果真的这么定义cross entropy,是否是一个合理的loss定义呢?

1. 两次softmax的影响

这里,我们来看一下两次softmax对结果的影响。

我们首先给出softmax的公式如下:

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

因此,我们就可以理解了,两次softmax过程之后导致所有的预测概率基本都被平均了,从而导致模型的学习难度大大增加,无怪乎loss下降如此之慢,最终的效果如此之差。

2. 伪cross entropy合理性分析

这里,我们重新给出我们错误的cross entropy的公式如下:

记错这个公式的浅层原因其实也直接,因为当问题恰好为二分类时,那么cross entropy刚好可以写为:

不过,请容我为自己辩护一下,我之所以会因此而记错公式,是因为确实上述的loss函数定义也具有一定的合理性。

那么,是不是说我们的loss定义反而会更好一些呢?

事实上,我们使用这个loss定义方式重新跑了一下fasttext的实验,运行得到结果如下:

代码语言:javascript复制
              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
代码语言:javascript复制
              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变化如下图所示:

这部分相关的实验代码可以参看我们的GitHub代码仓库。

可以看到:

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

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

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

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

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

5. 参考链接

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

0 人点赞