故事起源于我之前博客【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)这个基础的概念吧。。。
这里,我们就来系统的整理一下交叉熵的定义问题。要讲清楚交叉熵,我们首先要看一下信息熵的定义。
信息熵最先是由Shannon提出来的,它用于衡量事件发生所带有的信息量,Shannon熵的定义公式如下:

在Shannon的原始定义中,log的底数为2,不过显然不同的底数之间其值事实上也只相差了一个常数倍,因此整体上这个事实上并没有特别重要,一般用e作为底也没啥问题。
后来冯·诺伊曼将其推广至了量子体系下,使用概率密度矩阵的形式重新定义了量子体系的信息熵,又称之为冯·诺依曼熵,但这已经是后话了,这里就不需要多做展开了。
在信息熵的基础上,我们可以引入相对熵,即KL散度的概念:
因此,我们可以快速地得到KL散度的定义公式如下:

当然,对于连续分布,只要将求和换为积分即可。
交叉熵是信息熵与KL散度的伴生产物,我们给出交叉熵的定义如下:

写到这里,相信大多数读者也清楚了,上面我自己实现cross entropy函数在代码实现上是错误的,原因在于我记错公式了。。。
果然太经常使用工具毁一生啊,有必要把这些基础的概念全部复习一遍了,见鬼。。。
不过尽管如此,我们给出的定义事实上也是在一定意义上不是完全不合理,这个我们在后面第四节中会进行一些讨论,这里就先继续我们的话题吧。
现在,我们已经有了交叉熵的真实定义公式如下:

有了这个公式,我们可以自行给出cross entropy的代码实现如下:
给出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)给出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)由上述第二节的内容中我们已经发现,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实现的差异如下:
(y_true, y_pred)**,而pytorch刚好相反,输入为**(y_pred, y_true)**;from_logits=True**,而pytorch则不需要输入执行softmax计算,它内部会自行进行一次softmax计算。因此,我们在之前的实验当中取出掉代码中的softmax部分,果然一切都恢复正常了。。。
更一般的,我们在sequence_labelling问题中考察tf与pytorch当中的crossentropy实现,发现他们之间还有一个坑存在,即:
[N, L, C]**,即要求label的概率分布处在最后一维;[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)又注:
又又注:
tf.keras.losses.SparseCategoricalCrossentropy类。给出其测试内容如下:
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>最后,我们来回头看看上面的两个遗留的两个问题:
这里,我们来看一下两次softmax对结果的影响。
我们首先给出softmax的公式如下:

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

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

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

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

那么,是不是说我们的loss定义反而会更好一些呢?
事实上,我们使用这个loss定义方式重新跑了一下fasttext的实验,运行得到结果如下:

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
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损失函数达到更好的一个效果表达。
当然,这里也就是一个定性的分析,要想获得更加确切的结论,还需要我们做更多的实验进行验证。