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
的文档,发现其中有这么一段描述:
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实现的差异如下:
- 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]
**,即输出处于第二维!
这简直是神坑啊,唉。。。
给出代码示例如下:
代码语言: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. 引申思考
最后,我们来回头看看上面的两个遗留的两个问题:
- 最早我们注意到这个问题是因为我们发现loss收敛不下去,这个原因在于我们用了两次softmax,但是为什么两次softmax之后就会导致loss下降如此之慢呢?
- 我们一开始关于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. 参考链接
- 【机器学习】信息量,信息熵,交叉熵,KL散度和互信息(信息增益)
- 信息熵、交叉熵和相对熵
- 香浓熵(Shannon)与冯诺伊曼熵(Von Neumann)
- 如何理解K-L散度(相对熵)
- KL散度理解