为什么分类交叉熵的梯度不正确?

zih*_*hao 5 python deep-learning tensorflow

在回答这个问题之后,我在 tensorflow 2.0 中遇到了一些有趣但令人困惑的发现。logits对我来说看起来不正确的渐变。假设我们有logitslabels这里。

logits = tf.Variable([[0.8, 0.1, 0.1]], dtype=tf.float32)
labels = tf.constant([[1, 0, 0]],dtype=tf.float32)

with tf.GradientTape(persistent=True) as tape:
    loss = tf.reduce_sum(tf.keras.losses.categorical_crossentropy(labels, logits, 
                                                                  from_logits=False))
grads = tape.gradient(loss, logits)
print(grads)
Run Code Online (Sandbox Code Playgroud)

由于logits已经是概率分布,所以我设置from_logits=False了损失函数。

我认为 tensorflow 将用于loss=-\Sigma_i(p_i)\log(q_i)计算损失,如果我们推导q_i,我们将得到导数-p_i/q_i。因此,预期的成绩应该是 [-1.25,0,0]。但是,tensorflow 将返回 [-0.25,1,1]。

阅读 的源代码后tf.categorical_crossentropy,我发现即使我们设置了from_logits=False,它仍然对概率进行了归一化。这将改变最终的梯度表达式。具体来说,梯度将为-p_i/q_i+p_i/sum_j(q_j)。如果p_i=1sum_j(q_j)=1,最终梯度将加一。这就是梯度为 -0.25 的原因,但是,我还没有弄清楚为什么最后两个梯度为 1。

为了证明所有的梯度都增加了1/sum_j(q_j),我做了一个 logits,它不是概率分布,并设置为from_logits=False静止。

logits = tf.Variable([[0.5, 0.1, 0.1]], dtype=tf.float32)
labels = tf.constant([[1, 0, 0]],dtype=tf.float32)

with tf.GradientTape(persistent=True) as tape:
    loss = tf.reduce_sum(tf.keras.losses.categorical_crossentropy(labels, logits,
                                                                  from_logits=False))
grads = tape.gradient(loss, logits)
print(grads)
Run Code Online (Sandbox Code Playgroud)

tensorflow 返回的 grads 是[-0.57142866,1.4285713,1.4285713 ],我认为应该是[-2,0,0]

它表明所有梯度都增加了1/(0.5+0.1+0.1)。对于p_i==1,梯度增加1/(0.5+0.1+0.1)对我来说很有意义。但我不明白为什么p_i==0,梯度仍然增加了1/(0.5+0.1+0.1)

更新

感谢@OverLordGoldDragon 的善意提醒。将概率归一化后,正确的梯度公式应该是-p_i/q_i+1/sum_j(q_j)。所以问题中的行为是预期的。

Ove*_*gon 3

分类交叉熵是很棘手的,特别是对于 one-hot 编码;问题的产生是因为在查看损失的计算方式时,假设某些预测在计算损失或梯度时被“抛弃”:

loss = f(labels * preds) = f([1, 0, 0] * preds)

为什么梯度不正确?上面可能表明这preds[1:]并不重要,但请注意,这实际上不是preds - 它是preds_normalized,它涉及 的单个元素preds。为了更好地了解正在发生的事情,Numpy 后端很有帮助;假设from_logits=False

losses = []
for label, pred in zip(labels, preds):
    pred_norm = pred / pred.sum(axis=-1, keepdims=True)
    losses.append(np.sum(label * -np.log(pred_norm), axis=-1, keepdims=False))
Run Code Online (Sandbox Code Playgroud)

上面的更完整的解释 -这里。下面是我对梯度公式的推导,并通过示例将其 Numpy 实现与tf.GradientTape结果进行了比较。要跳过重要的细节,请滚动到“主要想法”。


公式+推导:底部证明正确性。

"""
grad = -y * sum(p_zeros) / (p_one * sum(pred)) + p_mask / sum(pred)

p_mask  = abs(y - 1)
p_zeros = p_mask * pred

y = label:      1D array of length N, one-hot 
p = prediction: 1D array of length N, float32 from 0 to 1
p_norm = normalized predictions
p_mask = prediction masks (see below)
"""
Run Code Online (Sandbox Code Playgroud)

发生了什么?从一个简单的例子开始,了解tf.GradientTape正在做什么:

w = tf.Variable([0.5, 0.1, 0.1])

with tf.GradientTape(persistent=True) as tape:
    f1 = w[0] + w[1]  # f = function
    f2 = w[0] / w[1]
    f3 = w[0] / (w[0] + w[1] + w[2])
print(tape.gradient(f1, w))  # [1.    1.  0.]
print(tape.gradient(f2, w))  # [10. -50.  0.]
print(tape.gradient(f3, w))  # [0.40816 -1.02040 -1.02040]
Run Code Online (Sandbox Code Playgroud)

w = [w1, w2, w3]。然后:

"""
grad = [df1/dw1, df1/dw2, df1/dw3]

grad1 = [d(w1 + w2)/w1, d(w1 + w2)/w2, d(w1 + w2)/w3] = [1, 1, 0]
grad2 = [d(w1 / w2)/w1, d(w1 / w2)/w2, d(w1 + w2)/w3] = [1/w2, -w1/w2^2, 0] = [10, -50, 0]
grad3 = [(w1 + w2)/K, - w2/K, -w3/K] = [0.40816 -1.02040 -1.02040] -- K = (w1 + w2 + w3)^2
"""
Run Code Online (Sandbox Code Playgroud)

换句话说,tf.GradientTape将输入张量的每个元素视为一个变量。考虑到这一点,通过初等函数实现分类交叉熵就足够了tf,然后手动导出它的导数,看看它们是否一致。这就是我在底部代码中所做的,上面链接的答案中更好地解释了损失。


公式解释

f3以上是最有洞察力的,因为它实际上是pred_norm;我们现在需要做的就是添加一个自然对数,并处理两种不同的情况: 的梯度y==1和 的梯度y==0;有了方便的 Wolf,导数就可以瞬间计算出来。在分母上添加更多变量,我们可以看到以下模式:

其中p_onepred其中label == 1p_non_one是任何其他pred元素,并且p_zeros是除 之外的所有pred元素p_one。底部的代码只是使用紧凑语法的实现。


解释示例

认为label = [1, 0, 0]; pred = [.5, .1, .1]。下面是numpy_gradient逐步的:

p_mask == [0, 1, 1]  # effectively `label` "inverted", to exclude `p_one`
p_one  == .5         # pred where `label` == 1

## grad_zeros
p_mask / np.sum(pred) == [0, 1, 1] / (.5 + .1 + .1) = [0, 1/.7, 1/.7]

## grad_one
p_one  * np.sum(pred) == .5 * (.5 + .1 + .1) = .5 * .7 = .35
p_mask * pred         == [0, 1, 1] * [.5, .1, .1] = [0, .1, .1]
np.sum(p_mask * pred) == .2
label * np.sum(p_mask * pred) == .2 * [1, 0, 0] = [.2, 0, 0]

label * np.sum(p_mask * pred) / (p_one * np.sum(pred)) 
== [.2, 0, 0] / .35 = 0.57142854
Run Code Online (Sandbox Code Playgroud)

根据上面的内容,我们可以看到梯度有效地分为两个计算:grad_one、 和grad_zeros


主要思想:可以理解,这有很多细节,所以主要思想如下:和的每个元素都会影响,并且损失是使用, not计算的,并且标准化步骤是反向传播。我们可以运行一些视觉效果来确认这一点:labelpredgradpred_normpred

labels = tf.constant([[1, 0, 0]],dtype=tf.float32)
grads = []
for i in np.linspace(0, 1, 100):
    logits = tf.Variable([[0.5, 0.1, i]], dtype=tf.float32)
    with tf.GradientTape(persistent=True) as tape:
        loss = tf.keras.losses.categorical_crossentropy(
              labels, logits, from_logits=False)  
    grads.append(tape.gradient(loss, logits))
grads = np.vstack(grads)
plt.plot(grads)
Run Code Online (Sandbox Code Playgroud)

虽只是不同logits[2]grads[1]但变化却一模一样。从上面的解释很清楚grad_zeros,但更直观的是,分类交叉熵并不关心零标签预测单独“有多错误”,只关心集体-因为它只是半直接计算来自pred[0](ie pred[0] / sum(pred)) 的损失,这是由所有其他标准化的pred。所以无论pred[1] == .9pred[2] == .2反之亦然,p_norm都是完全相同的。


结束语:为了简单起见,派生公式适用于一维情况,可能不适用于 N 维labelspreds张量,但可以轻松推广。


Numpy 与 tf.GradientTape

def numpy_gradient(label, pred):
    p_mask = np.abs(label - 1)
    p_one  = pred[np.where(label==1)[0][0]]
    return p_mask / np.sum(pred) \
           - label * np.sum(p_mask * pred) / (p_one * np.sum(pred))

def gtape_gradient(label, pred):
    pred  = tf.Variable(pred)
    label = tf.Variable(label)

    with tf.GradientTape() as tape:
        loss = - tf.math.log(tf.reduce_sum(label * pred) / tf.reduce_sum(pred))
    return tape.gradient(loss, pred).numpy()
Run Code Online (Sandbox Code Playgroud)
label = np.array([1.,   0., 0. ])
pred  = np.array([0.5, 0.1, 0.1])
print(numpy_gradient(label, pred))
print(gtape_gradient(label, pred))

# [-0.57142854  1.4285713   1.4285713 ]  <-- 100% agreement
# [-0.57142866  1.4285713   1.4285713 ]  <-- 100% agreement
Run Code Online (Sandbox Code Playgroud)