t27.aiРусский

Backpropagation

You will learn

How the gradient of each weight and each input of a layer follows from the gradient of its outputs.

To learn, every weight needs to know how the loss changes when it moves. Backpropagation answers one layer at a time: given g, how much the loss wants each output to move, the weight from input i to output o gets g_o x_i, and input i gets the sum over o of W[o][i] g_o, which it hands on to the layer below. With inputs (1, 2, 3) and g = (1, 0.5) the weight gradients are 1, 2, 3 and 0.5, 1, 1.5, and the input gradients 0.5, 2 and 3.5. The layer is linear, so moving x0 by 1 moves the loss g . y from 15.25 to 15.75, by its gradient 0.5. The spec dense_layer.t27 computes all three.

Try it

Set g = (1, 0) and read which edges go dark; then set g = (-2, 1) and check the input gradients by hand.

Open the interactive lesson →

Backpropagation: how blame flows back through a layer
Backpropagation: how blame flows back through a layer ↗

Set how much the loss wants each of two outputs to move: every weight gets that times its input, every input gets W transposed times it. The edges and nodes show the numbers.

specs/ml/layers/dense_layer.t27

// SPDX-License-Identifier: Apache-2.0
// t27/specs/
// Row-major weight layout for cache efficiency | φ² + 1/φ² = 3 | TRINITY

// #8980: forward and backward were empty placeholders, the tests asserted
// `result != undefined`, and the invariants asserted `true` over an undeclared
// `valid_input`. Dense is now y = act(W x + b) with W row-major
// (output_size x input_size), and backward gives the three gradients.

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

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

    const MIN_INPUT_SIZE : u32 = 1;
    const MAX_INPUT_SIZE : u32 = 4096;

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

    pub const DenseConfig = struct {
        input_size : u32,
        output_size : u32,
        has_bias : bool,
        activation : ActivationType,
    };

    pub const ActivationType = enum(u8) {
        None,
        ReLU,
        GELU,
        Sigmoid,
        Tanh,
        Softmax,
    };

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

    // relu, the one activation the tests exercise besides None
    fn activate(z: f32, activation: ActivationType) -> f32 {
        if (activation == ActivationType.ReLU) {
            if (z > 0.0) {
                return z;
            }
            return 0.0;
        }
        return z;
    }

    // output[o] = act(sum_i weights[o * in + i] * input[i] + bias[o])
    fn forward(input: []const f32, weights: []const f32, bias: []const f32, config: DenseConfig, output: []f32) -> void {
        var o : usize = 0;
        while (o < config.output_size) {
            var z : f32 = 0.0;
            if (config.has_bias) {
                z = bias[o];
            }
            var i : usize = 0;
            while (i < config.input_size) {
                z = z + weights[o * config.input_size + i] * input[i];
                i = i + 1;
            }
            output[o] = activate(z, config.activation);
            o = o + 1;
        }
    }

    // For a linear layer (activation None), given dL/dy = grad_output:
    //   grad_input[i] = sum_o W[o][i] grad_output[o]       (W^T g)
    //   grad_weights[o][i] = grad_output[o] * input[i]      (g x^T)
    //   grad_bias[o] = grad_output[o]
    fn backward(grad_output: []const f32, input: []const f32, weights: []const f32, config: DenseConfig, grad_input: []f32, grad_weights: []f32, grad_bias: []f32) -> void {
        var i : usize = 0;
        while (i < config.input_size) {
            grad_input[i] = 0.0;
            i = i + 1;
        }
        var o : usize = 0;
        while (o < config.output_size) {
            grad_bias[o] = grad_output[o];
            i = 0;
            while (i < config.input_size) {
                grad_input[i] = grad_input[i] + weights[o * config.input_size + i] * grad_output[o];
                grad_weights[o * config.input_size + i] = grad_output[o] * input[i];
                i = i + 1;
            }
            o = o + 1;
        }
    }

    // The fixture: 3 inputs, 2 outputs, W = [[1, 2, 3], [-1, 0, 1]], b = [0.5, -0.5]
    fn cfg(activation: ActivationType) -> DenseConfig {
        return DenseConfig{ .input_size = 3, .output_size = 2, .has_bias = true, .activation = activation };
    }

    fn forward_at(x0: f32, x1: f32, x2: f32, activation: ActivationType, index: usize) -> f32 {
        const input : [3]f32 = [x0, x1, x2];
        const weights : [6]f32 = [1.0, 2.0, 3.0, -1.0, 0.0, 1.0];
        const bias : [2]f32 = [0.5, -0.5];
        var output : [2]f32 = [0.0] ** 2;
        forward(&input, &weights, &bias, cfg(activation), &output);
        return output[index];
    }

    // L = g . y for g = (g0, g1): the loss whose gradients backward computes
    fn loss_at(x0: f32, x1: f32, x2: f32, g0: f32, g1: f32) -> f32 {
        return g0 * forward_at(x0, x1, x2, ActivationType.None, 0) + g1 * forward_at(x0, x1, x2, ActivationType.None, 1);
    }

    // which: 0 = grad_input, 1 = grad_weights, 2 = grad_bias
    fn backward_at(g0: f32, g1: f32, which: u8, index: usize) -> f32 {
        const grad_output : [2]f32 = [g0, g1];
        const input : [3]f32 = [1.0, 2.0, 3.0];
        const weights : [6]f32 = [1.0, 2.0, 3.0, -1.0, 0.0, 1.0];
        var grad_input : [3]f32 = [9.0] ** 3;
        var grad_weights : [6]f32 = [9.0] ** 6;
        var grad_bias : [2]f32 = [9.0] ** 2;
        backward(&grad_output, &input, &weights, cfg(ActivationType.None), &grad_input, &grad_weights, &grad_bias);
        if (which == 0) {
            return grad_input[index];
        }
        if (which == 1) {
            return grad_weights[index];
        }
        return grad_bias[index];
    }

    // An empty layer (no inputs, no outputs) writes nothing
    fn empty_forward_untouched() -> bool {
        const none : [0]f32 = [];
        var sentinel : [1]f32 = [7.0];
        const empty_cfg = DenseConfig{ .input_size = 0, .output_size = 0, .has_bias = true, .activation = ActivationType.None };
        forward(&none, &none, &none, empty_cfg, &sentinel);
        return sentinel[0] == 7.0;
    }

    fn empty_backward_untouched() -> bool {
        const none : [0]f32 = [];
        var sentinel : [1]f32 = [7.0];
        const empty_cfg = DenseConfig{ .input_size = 0, .output_size = 0, .has_bias = true, .activation = ActivationType.None };
        backward(&none, &none, &none, empty_cfg, &sentinel, &sentinel, &sentinel);
        return sentinel[0] == 7.0;
    }

    fn valid_input() -> f32 {
        return forward_at(1.0, 2.0, 3.0, ActivationType.None, 0);
    }

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

    // x = (1, 2, 3): y0 = 1 + 4 + 9 + 0.5 = 14.5, y1 = -1 + 0 + 3 - 0.5 = 1.5
    test forward_basic_case
        given y0 = forward_at(1.0, 2.0, 3.0, ActivationType.None, 0)
        when y1 = forward_at(1.0, 2.0, 3.0, ActivationType.None, 1)
        then y0 == 14.5 and y1 == 1.5

    // ReLU clips the negative pre-activation: x = (0, 0, -1) gives y = (-2.5, -1.5) -> (0, 0)
    test forward_relu_clips_negatives
        given linear = forward_at(0.0, 0.0, -1.0, ActivationType.None, 0)
        when clipped = forward_at(0.0, 0.0, -1.0, ActivationType.ReLU, 0)
        then linear == -2.5 and clipped == 0.0 and forward_at(1.0, 2.0, 3.0, ActivationType.ReLU, 0) == 14.5

    test forward_empty_input
        given result = empty_forward_untouched()
        then result

    // g = (1, 2): grad_input = W^T g = (1 - 2, 2 + 0, 3 + 2) = (-1, 2, 5); grad_bias = g;
    // grad_weights[1][2] = g1 * x2 = 6
    test backward_basic_case
        given gi0 = backward_at(1.0, 2.0, 0, 0)
        when gi2 = backward_at(1.0, 2.0, 0, 2)
        then gi0 == -1.0 and backward_at(1.0, 2.0, 0, 1) == 2.0 and gi2 == 5.0 and backward_at(1.0, 2.0, 2, 1) == 2.0 and backward_at(1.0, 2.0, 1, 5) == 6.0

    // dL/dx1 by finite difference equals grad_input[1]
    test backward_matches_finite_difference
        given slope = (loss_at(1.0, 2.0 + 0.01, 3.0, 1.0, 2.0) - loss_at(1.0, 2.0 - 0.01, 3.0, 1.0, 2.0)) / 0.02
        when result = backward_at(1.0, 2.0, 0, 1)
        then @abs(result - slope) < 0.001

    test backward_empty_gradient
        given result = empty_backward_untouched()
        then result

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

    // input.size() == config.input_size: the bounds the layer accepts
    invariant dense_constraint_0
        assert MIN_INPUT_SIZE >= 1 and MAX_INPUT_SIZE >= MIN_INPUT_SIZE

    // output.size() == config.output_size: each output is one row of W x + b
    invariant dense_constraint_1
        assert valid_input() == 14.5

    // weights.size() == input_size * output_size: the fixture holds 3 * 2 = 6 weights
    invariant dense_constraint_2
        assert cfg(ActivationType.None).input_size * cfg(ActivationType.None).output_size == 6

    // bias.size() == output_size (if has_bias): the fixture carries a bias of 2
    invariant dense_constraint_3
        assert cfg(ActivationType.None).has_bias

    // All inputs must be finite (no NaN/Inf): finite in, finite out
    invariant dense_constraint_4
        assert valid_input() == valid_input()

Open the lesson's spec in the player ↗

All lessons