t27.aiРусский

A recurrent cell

You will learn

How a recurrent cell carries a state from step to step, and why that memory fades.

A sentence comes one word at a time, and a recurrent cell carries a state h from one step to the next: h' = tanh(W_x x + W_h h + b). In the spec's tiny cell W_x is 1 and W_h 0.5, so a single pulse gives 0.7616, 0.3634 and 0.1797: the memory of it roughly halves each step. A second pulse at step 3 lifts h to 0.7968, a little above where the single pulse began, because the old memory is still there. After 8 steps only 0.0466 is left. That fading is why plain cells forget long contexts. The spec rnn_cell.t27 computes each step.

Try it

Load one pulse and read how many steps it takes to fall under 0.1; then make the input alternate.

Open the interactive lesson →

Recurrent cells: how a network remembers a moment ago
Recurrent cells: how a network remembers a moment ago ↗

Tap the inputs of eight steps: the cell feeds its own last output back, h' = tanh(x + 0.5 h), so every pulse echoes on and fades by about half a step. Two pulses add up.

specs/ml/recurrent/rnn_cell.t27

// SPDX-License-Identifier: Apache-2.0
// t27/specs/
// Basic RNN recurrence equation | φ² + 1/φ² = 3 | TRINITY

// #8980: written against undeclared `matmul`, `add`, `zeros`, `len` and `append`,
// it applied tanh twice (h = tanh(tanh(z))), and the tests asserted
// `result != undefined`. It is now the Elman cell,
// h' = tanh(W_x x + W_h h + b), with W_x hidden x input and W_h hidden x hidden
// (row-major), stepping over buffers the caller owns.

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

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

    const DEFAULT_INPUT_SIZE : u32 = 128;
    const DEFAULT_HIDDEN_SIZE : u32 = 128;

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

    pub const RNNCellConfig = struct {
        input_size : u32,
        hidden_size : u32,
    };

    pub const RNNState = struct {
        hidden : []f32,
        cell : []f32,
    };

    pub const RNNParams = struct {
        w_x : []f32,
        w_h : []f32,
        b : []f32,
    };

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

    // new_hidden[i] = tanh(sum_j W_x[i][j] x[j] + sum_k W_h[i][k] h[k] + b[i])
    fn forward_step(input: []const f32, hidden: []const f32, params: RNNParams, config: RNNCellConfig, new_hidden: []f32) -> void {
        var i : usize = 0;
        while (i < config.hidden_size) {
            var z : f32 = params.b[i];
            var j : usize = 0;
            while (j < config.input_size) {
                z = z + params.w_x[i * config.input_size + j] * input[j];
                j = j + 1;
            }
            var k : usize = 0;
            while (k < config.hidden_size) {
                z = z + params.w_h[i * config.hidden_size + k] * hidden[k];
                k = k + 1;
            }
            new_hidden[i] = tanh(z);
            i = i + 1;
        }
    }

    // inputs: steps x input_size; outputs: steps x hidden_size, row t = h after step t.
    // The state starts at initial_state and each step reads the previous row.
    fn forward_sequence(inputs: []const f32, steps: usize, initial_state: []const f32, params: RNNParams, config: RNNCellConfig, outputs: []f32) -> void {
        const n_in : usize = config.input_size;
        const n_h : usize = config.hidden_size;
        var t : usize = 0;
        while (t < steps) {
            if (t == 0) {
                forward_step(inputs[0..n_in], initial_state, params, config, outputs[0..n_h]);
            } else {
                forward_step(inputs[t * n_in .. (t + 1) * n_in], outputs[(t - 1) * n_h .. t * n_h], params, config, outputs[t * n_h .. (t + 1) * n_h]);
            }
            t = t + 1;
        }
    }

    // The first hidden_size entries of `buffer`, zeroed
    fn init_state(buffer: []f32, hidden_size: u32) -> []f32 {
        var i : usize = 0;
        while (i < hidden_size) {
            buffer[i] = 0.0;
            i = i + 1;
        }
        return buffer[0..hidden_size];
    }

    // A 1-in, 1-hidden cell with W_x = 1, W_h = 0.5, b = 0: h' = tanh(x + 0.5 h)
    fn tiny() -> RNNCellConfig {
        return RNNCellConfig{ .input_size = 1, .hidden_size = 1 };
    }

    fn tiny_step(x: f32, h: f32) -> f32 {
        var w_x : [1]f32 = [1.0];
        var w_h : [1]f32 = [0.5];
        var b : [1]f32 = [0.0];
        const input : [1]f32 = [x];
        const hidden : [1]f32 = [h];
        var out : [1]f32 = [9.0];
        forward_step(&input, &hidden, RNNParams{ .w_x = &w_x, .w_h = &w_h, .b = &b }, tiny(), &out);
        return out[0];
    }

    // The sequence (1, 0, 0) from h = 0: h after step t
    fn tiny_sequence(t: usize) -> f32 {
        var w_x : [1]f32 = [1.0];
        var w_h : [1]f32 = [0.5];
        var b : [1]f32 = [0.0];
        const inputs : [3]f32 = [1.0, 0.0, 0.0];
        const h0 : [1]f32 = [0.0];
        var outputs : [3]f32 = [9.0] ** 3;
        forward_sequence(&inputs, 3, &h0, RNNParams{ .w_x = &w_x, .w_h = &w_h, .b = &b }, tiny(), &outputs);
        return outputs[t];
    }

    // A 2-in, 2-hidden cell: h' = tanh(W_x x + W_h h + b) with W_x = [[1, -1], [0.5, 0.5]],
    // W_h = [[0.1, 0], [0, 0.1]], b = (0, -0.5), at x = (1, 2), h = (1, -1)
    fn wide_step(index: usize) -> f32 {
        var w_x : [4]f32 = [1.0, -1.0, 0.5, 0.5];
        var w_h : [4]f32 = [0.1, 0.0, 0.0, 0.1];
        var b : [2]f32 = [0.0, -0.5];
        const input : [2]f32 = [1.0, 2.0];
        const hidden : [2]f32 = [1.0, -1.0];
        var out : [2]f32 = [9.0] ** 2;
        forward_step(&input, &hidden, RNNParams{ .w_x = &w_x, .w_h = &w_h, .b = &b }, RNNCellConfig{ .input_size = 2, .hidden_size = 2 }, &out);
        return out[index];
    }

    fn init_len(hidden_size: u32) -> usize {
        var buffer : [64]f32 = [9.0] ** 64;
        const h = init_state(&buffer, hidden_size);
        if (h[hidden_size - 1] != 0.0) {
            return 0;
        }
        return h.len;
    }

    fn valid_input() -> f32 {
        return tiny_step(1.0, 0.0);
    }

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

    // z = (1 - 2 + 0.1, 0.5 + 1 - 0.1 - 0.5) = (-0.9, 0.9): h' = (tanh(-0.9), tanh(0.9))
    test forward_step_basic_case
        given h0 = wide_step(0)
        when h1 = wide_step(1)
        then @abs(h0 + 0.7162979) < 0.000001 and @abs(h1 - 0.7162979) < 0.000001 and @abs(tiny_step(1.0, 0.0) - 0.7615942) < 0.000001

    // The cell remembers: after the input pulse, h decays through the recurrent weight,
    // h1 = tanh(1), h2 = tanh(0.5 h1), h3 = tanh(0.5 h2)
    test forward_sequence_basic_case
        given h1 = tiny_sequence(0)
        when h3 = tiny_sequence(2)
        then @abs(h1 - 0.7615942) < 0.000001 and @abs(tiny_sequence(1) - tanh(0.5 * h1)) < 0.000001 and @abs(h3 - tanh(0.5 * tiny_sequence(1))) < 0.000001 and h3 > 0.0 and h3 < tiny_sequence(1)

    test init_state_basic_case
        given hidden_size = 64
        when result = init_len(hidden_size)
        then result == hidden_size

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

    // input_size >= 1
    invariant rnn_cell_constraint_0
        assert DEFAULT_INPUT_SIZE >= 1

    // hidden_size >= 1
    invariant rnn_cell_constraint_1
        assert DEFAULT_HIDDEN_SIZE >= 1

    // name: rnn_formula -- one tanh, not two: tanh(1) = 0.7615942, tanh(tanh(1)) = 0.6420
    invariant rnn_cell_constraint_2
        assert valid_input() > 0.76 and valid_input() < 0.77

    // "Elman (1990) - Finding Structure in Time": the state carries the past forward
    invariant rnn_cell_constraint_3
        assert tiny_step(0.0, 1.0) > 0.0

    // "Goodfellow et al. (2016) - Sequence Modeling with RNNs and LSTMs": tanh bounds the state
    invariant rnn_cell_constraint_4
        assert tiny_step(100.0, 1.0) <= 1.0 and tiny_step(-100.0, -1.0) >= -1.0

Open the lesson's spec in the player ↗

All lessons