t27.aiРусский

Cross-entropy

You will learn

Why cross-entropy charges a confident wrong answer so much, and why that is what trains a classifier.

A classifier outputs a probability, and cross-entropy charges -log of the probability it gave to the true answer. Predictions 0.8, 0.3 and 0.9 against answers 1, 0 and 1 cost 0.2284 on average. Being sure and right is nearly free: 0.99, 0.01 and 0.99 cost 0.0101. Being sure and wrong is expensive: 0.01, 0.99 and 0.01 cost 4.6052, and toward 0 the cost grows without bound, which is why the spec clips p to [1e-7, 1 - 1e-7]. Its gradient is largest exactly where the model is sure and wrong. The spec binary_crossentropy_loss.t27 computes the loss and the gradient, from probabilities or from logits.

Try it

Drag prediction 0 toward 0.01 and watch the curve; then read the gradient bars at 0.5, 0.5 and 0.5.

Open the interactive lesson →

Cross-entropy: why a confident wrong answer costs so much
Cross-entropy: why a confident wrong answer costs so much ↗

Set three predicted probabilities against the answers 1, 0, 1: the loss is -log of the chance given to the truth. Drag toward a confident mistake and the cost climbs without bound.

specs/ml/loss/binary_crossentropy_loss.t27

// SPDX-License-Identifier: Apache-2.0
// t27/specs/
// Gradient w.r.t predictions | φ² + 1/φ² = 3 | TRINITY

// #8980: never compiled (`new f32[n]`, a float loop index, `predictions.size`,
// an empty enum), and it approximated sigmoid and log with polynomials that
// were 0.3 off at x = 1. The probability gradient also had the wrong sign on its
// second term: d/dp of -(t log p + (1-t) log(1-p)) is -t/p + (1-t)/(1-p). It now
// uses exp and log, writes gradients into a caller's buffer, and reduces by mean.

module BinaryCe;
    use base::types;
    use math::constants;

    // ═══════════════════════════════════════════════════════════
    // 1. Constants
    // ═══════════════════════════════════════════════════════════

    const DEFAULT_EPSILON : f32 = 1e-7;

    // ═══════════════════════════════════════════════════════════
    // 2. Types
    // ═══════════════════════════════════════════════════════════

    pub const BinaryCEConfig = struct {
        from_logits : bool,
        epsilon : f32,
        reduction : Reduction,
    };

    pub const Reduction = enum(u8) {
        Mean,
        Sum,
    };

    // ═══════════════════════════════════════════════════════════
    // 3. Core Functions
    // ═══════════════════════════════════════════════════════════

    fn sigmoid(x: f32) -> f32 {
        if (x >= 0.0) {
            return 1.0 / (1.0 + exp(-x));
        }
        const e = exp(x);
        return e / (1.0 + e);
    }

    // p clipped into [eps, 1 - eps] so log stays finite
    fn clip(p: f32, eps: f32) -> f32 {
        if (p < eps) {
            return eps;
        }
        if (p > 1.0 - eps) {
            return 1.0 - eps;
        }
        return p;
    }

    // -(t log p + (1 - t) log(1 - p)) for one element
    fn element_loss(pred: f32, target: f32, config: BinaryCEConfig) -> f32 {
        var p = pred;
        if (config.from_logits) {
            p = sigmoid(pred);
        }
        p = clip(p, config.epsilon);
        return -(target * log(p) + (1.0 - target) * log(1.0 - p));
    }

    // mean (or sum) of element_loss
    fn forward(predictions: []const f32, targets: []const f32, config: BinaryCEConfig) -> f32 {
        var loss : f32 = 0.0;
        var i : usize = 0;
        while (i < predictions.len) {
            loss = loss + element_loss(predictions[i], targets[i], config);
            i = i + 1;
        }
        if (config.reduction == Reduction.Sum) {
            return loss;
        }
        return loss / @as(f32, @floatFromInt(predictions.len));
    }

    // A batch of rows of width `width`, flattened row-major: the mean over every element
    fn forward_batch(predictions: []const f32, width: usize, targets: []const f32, config: BinaryCEConfig) -> f32 {
        var total : f32 = 0.0;
        var i : usize = 0;
        while (i < predictions.len) {
            total = total + element_loss(predictions[i], targets[i], config);
            i = i + 1;
        }
        _ = width;
        return total / @as(f32, @floatFromInt(predictions.len));
    }

    // Per-element gradient of the (unreduced) loss:
    //   from logits:        sigmoid(z) - t
    //   from probabilities: -t / p + (1 - t) / (1 - p)
    fn gradient(predictions: []const f32, targets: []const f32, config: BinaryCEConfig, grads: []f32) -> void {
        var i : usize = 0;
        while (i < predictions.len) {
            const t = targets[i];
            if (config.from_logits) {
                grads[i] = sigmoid(predictions[i]) - t;
            } else {
                const p = clip(predictions[i], config.epsilon);
                grads[i] = -t / p + (1.0 - t) / (1.0 - p);
            }
            i = i + 1;
        }
    }

    fn cfg(from_logits: bool) -> BinaryCEConfig {
        return BinaryCEConfig{ .from_logits = from_logits, .epsilon = DEFAULT_EPSILON, .reduction = Reduction.Mean };
    }

    // The fixture: predictions (a, b, c) against targets (1, 0, 1)
    fn loss_of(a: f32, b: f32, c: f32, from_logits: bool) -> f32 {
        const predictions : [3]f32 = [a, b, c];
        const targets : [3]f32 = [1.0, 0.0, 1.0];
        return forward(&predictions, &targets, cfg(from_logits));
    }

    fn grad_of(a: f32, b: f32, c: f32, from_logits: bool, index: usize) -> f32 {
        const predictions : [3]f32 = [a, b, c];
        const targets : [3]f32 = [1.0, 0.0, 1.0];
        var grads : [3]f32 = [9.0] ** 3;
        gradient(&predictions, &targets, cfg(from_logits), &grads);
        return grads[index];
    }

    fn batch_loss() -> f32 {
        const predictions : [4]f32 = [0.8, 0.3, 0.9, 0.1];
        const targets : [4]f32 = [1.0, 0.0, 1.0, 0.0];
        return forward_batch(&predictions, 2, &targets, cfg(false));
    }

    fn valid_input() -> f32 {
        return loss_of(0.8, 0.3, 0.9, false);
    }

    // ═══════════════════════════════════════════════════════════
    // TDD: Tests (from .tri behaviors)
    // ═══════════════════════════════════════════════════════════

    test sigmoid_basic_case
        given input = 0.0
        when result = sigmoid(input)
        then @abs(result - 0.5) < 1e-6

    test sigmoid_positive_input
        given input = 1.0
        when result = sigmoid(input)
        then result > 0.5 and result < 1.0 and @abs(result - 0.7310586) < 0.000001

    test sigmoid_negative_input
        given input = -1.0
        when result = sigmoid(input)
        then result > 0.0 and result < 0.5 and @abs(result + sigmoid(1.0) - 1.0) < 0.000001

    // -(ln 0.8 + ln 0.7 + ln 0.9) / 3 = 0.2283930
    test forward_basic_case
        given result = loss_of(0.8, 0.3, 0.9, false)
        then result > 0.0 and @abs(result - 0.2283930) < 0.000001

    // From logits (2, -1, 3) it is the same loss as from their sigmoids
    test forward_from_logits_case
        given result = loss_of(2.0, -1.0, 3.0, true)
        when probs = loss_of(sigmoid(2.0), sigmoid(-1.0), sigmoid(3.0), false)
        then result > 0.0 and @abs(result - probs) < 0.000001

    // (-ln 0.8 - ln 0.7 - ln 0.9 - ln 0.9) / 4 = 0.1976349
    test forward_batch_basic_case
        given result = batch_loss()
        then result > 0.0 and @abs(result - 0.1976349) < 0.000001

    // t = 1 pulls p up (negative gradient), t = 0 pushes it down (positive)
    test gradient_basic_case
        given g0 = grad_of(0.8, 0.3, 0.9, false, 0)
        when g1 = grad_of(0.8, 0.3, 0.9, false, 1)
        then g0 < 0.0 and g1 > 0.0 and grad_of(0.8, 0.3, 0.9, false, 2) < 0.0 and @abs(g0 + 1.25) < 0.000001 and @abs(g1 - 1.0 / 0.7) < 0.000001

    // From logits: sigmoid(z) - t, and it matches a finite difference of the loss
    test gradient_from_logits_case
        given g1 = grad_of(2.0, -1.0, 3.0, true, 1)
        when slope = 3.0 * (loss_of(2.0, -1.0 + 0.001, 3.0, true) - loss_of(2.0, -1.0 - 0.001, 3.0, true)) / 0.002
        then @abs(g1 - sigmoid(-1.0)) < 0.000001 and @abs(g1 - slope) < 0.001

    // ═══════════════════════════════════════════════════════════
    // TDD: Invariants (from .tri constraints)
    // ═══════════════════════════════════════════════════════════

    // targets[i] in {0, 1}: a perfect prediction costs (almost) nothing
    invariant binary_ce_constraint_0
        assert loss_of(1.0, 0.0, 1.0, false) < 0.000001

    // 0 < epsilon << 1
    invariant binary_ce_constraint_1
        assert DEFAULT_EPSILON > 0.0 and DEFAULT_EPSILON < 0.001

    // name: binary_ce -- positive for an imperfect prediction
    invariant binary_ce_constraint_2
        assert valid_input() > 0.0

    // name: gradient -- zero where the logit already gives the target
    invariant binary_ce_constraint_3
        assert @abs(grad_of(30.0, -30.0, 30.0, true, 0)) < 0.000001

    // "Bishop (2006) - Pattern Recognition and Machine Learning": the loss falls as p nears t
    invariant binary_ce_constraint_4
        assert loss_of(0.9, 0.1, 0.9, false) < loss_of(0.6, 0.4, 0.6, false)

    // "Goodfellow et al. (2016) - Deep Learning": clipping keeps a certain wrong answer finite
    invariant binary_ce_constraint_5
        assert loss_of(0.0, 1.0, 0.0, false) < 20.0

Open the lesson's spec in the player ↗

All lessons