Back to Deep Learning
AI/ML · Deep Learning

Recurrent Neural Network

A feedforward network sees a fixed-size input and has no way to represent "what came before". Give it a hidden state that is fed back in alongside the next input and it does: the state is a running summary of everything seen so far, updated once per step. Weights are shared across steps, so the same update rule applies at position three and position three hundred — which is what lets one model handle sequences of any length. The trouble is in the gradient. To learn from step three hundred what happened at step three, the error has to be multiplied back through three hundred copies of the same matrix, and repeated multiplication by a matrix either shrinks toward zero or grows without bound. Almost nothing in between.

SupervisedSelf-supervisedRecurrentSequence modelingRegressionClassificationAnomaly detection

Supervised when the targets come from outside — a label per sequence, a value per step. Self-supervised when the target is the sequence’s own next element, which is the same objective a language model uses and is where the architecture came from.

Also known as: Vanilla RNN, Elman network, BPTT, Simple recurrent network, RNN

Intuition

What it actually does

A feedforward network sees a fixed-size input and has no way to represent "what came before". Give it a hidden state that is fed back in alongside the next input and it does: the state is a running summary of everything seen so far, updated once per step. Weights are shared across steps, so the same update rule applies at position three and position three hundred — which is what lets one model handle sequences of any length. The trouble is in the gradient. To learn from step three hundred what happened at step three, the error has to be multiplied back through three hundred copies of the same matrix, and repeated multiplication by a matrix either shrinks toward zero or grows without bound. Almost nothing in between.

Assumptions it makes
  • The sequence has temporal structure worth carrying forward — with independent steps the recurrence adds cost and nothing else
  • Relevant dependencies are short enough to survive the gradient, which for a plain RNN means a few dozen steps at most
  • A fixed-size hidden state can summarize everything from the past that matters, which is a hard bottleneck for long or information-dense sequences
  • Steps are evenly spaced, or the irregularity is supplied as an explicit input feature
  • Sequences are independent of each other, since the state must be reset between them
Objective

Loss function

Sum the loss over the sequence, where the prediction at each step depends on a state carrying everything before it. The weight sharing across steps is the architectural commitment and it does two things at once. It makes the parameter count independent of sequence length, which is why an RNN can read a sentence of any size. And it makes the backward pass a repeated product of the same Jacobian, which is the entire reason the architecture fails on long sequences — the property that gives it its generality is the property that breaks it.

the hidden state — a running summary of the sequence so far, and the only memory the model has
the recurrent matrix, applied once per timestep; the same matrix, which is what makes the gradient a repeated product
the input projection, also shared across every step — which is why one model handles any sequence length
loss accumulated over the sequence; for a per-sequence label only the final step contributes
Optimization

How it is minimized

Backpropagation through time — the network unrolled into a deep feedforward graph with tied weights — usually truncated, with gradient clipping

the repeated product — the whole problem in one symbol, since a product of T matrices is exponential in T
the same matrix at every factor, so its spectral radius is raised to the power of the distance
the activation derivative, at most 0.25 for a sigmoid — which guarantees shrinkage regardless of the weights
how far back the signal must travel; the gradient decays or grows exponentially in this distance

Backpropagation through time is ordinary backpropagation applied to the unrolled graph, with the twist that the same weights appear at every layer so their gradients are summed across all of them. The product in the update rule is where everything difficult lives. If the recurrent matrix has spectral radius below one, the product decays geometrically and a signal from a hundred steps back arrives as numerical zero — the model literally cannot learn the dependency, and no amount of training changes that. Above one and the product explodes, producing NaN losses. Gradient clipping handles the explosion cheaply and does nothing for the vanishing case, which is asymmetric and worth knowing: exploding gradients are an inconvenience, vanishing gradients are a capability limit. Truncating the unroll to a fixed window bounds the memory and the compute, and it also bounds what the model can learn to a horizon you chose in advance.

Hyperparameters
  • hidden size

    The capacity of the memory. Everything the model knows about the past is compressed into this vector, so it bounds how much context can be retained (128 to 1024)

  • truncation length

    How many steps the unroll spans. Sets both the memory cost and the longest dependency the model can learn — a limit chosen rather than discovered (32 to 256 steps)

  • gradient clipping

    Norm threshold on the global gradient. Not optional: without it a single exploding batch produces NaN weights and the run is over (global norm 1.0 to 5.0)

  • activation

    tanh by default. ReLU makes explosion far more likely in a recurrent setting, which is the opposite of its effect in a feedforward one

  • recurrent initialization

    Orthogonal or identity-scaled, which puts the initial spectral radius near one and delays the onset of both failures

  • teacher forcing

    Feeding the true previous token during training rather than the model’s own prediction. Speeds convergence and creates exposure bias at inference

Convergence & cost

Trains by SGD like any network, and the failures are specific to the recurrence. Vanishing gradients: the product of Jacobians decays geometrically, so dependencies more than a few dozen steps apart are unlearnable — this is a capability ceiling, not a tuning problem, and it is precisely what the gating in an LSTM exists to bypass. Exploding gradients: the same product in the other direction, which shows as a loss that trains normally and then jumps to NaN in one batch; clipping fixes it and is cheap enough that it should always be on. Exposure bias follows from teacher forcing — the model is trained on true prefixes and evaluated on its own generated ones, so errors compound at inference in a way training never showed it. And the sequential dependency means no parallelism across time, which is not a convergence problem but is the reason the architecture lost to attention.

O(T · (h^2 + h·d)) per sequence for the forward pass, and roughly twice that backward — linear in sequence length, which compares well against attention’s quadratic. Memory is O(T · h) for the stored states, which is what truncation bounds. The decisive cost is not in the asymptotics: the recurrence is sequential in T, so timesteps cannot be computed in parallel, and on hardware built for parallel throughput that is worth more than the asymptotic advantage.

Applications

How, where, and why it is used

Time-Series Forecasting

Viable
How

Feed observations in order and read a prediction from the hidden state, either one step at a time or by decoding a whole horizon. Exogenous drivers concatenate onto the input at each step, and a single network trained across many series learns shared dynamics — which is where the recurrent approach actually earns its place, since a per-series RNN has far too many parameters for the data.

Where
  • Global forecasting models trained across thousands of related series
  • Irregular or variable-length histories, which a fixed-window model cannot accept without padding decisions
  • Multivariate series where the interaction between channels evolves over time
  • The recurrent baseline that establishes whether sequence modelling helps at all before an LSTM or attention model is tried
Why

It handles variable-length input natively and learns the temporal structure rather than having it supplied as lag features, which is a genuine advantage over a dense network on the same data. In practice the plain RNN is almost always replaced by an LSTM or GRU before deployment, for exactly the reason this entry documents — seasonal dependencies span more steps than the gradient survives. And on a single ordinary business series, exponential smoothing generally beats all of them, because a few hundred observations cannot identify a recurrent model of any kind.

Featurization
  • Scale inputs and targets per series before training; a network across series is otherwise dominated by whichever has the largest values
  • Difference or detrend, since the network learns a fitted shape and extrapolates it unpredictably rather than continuing a trend
  • Encode seasonality explicitly as a calendar feature at each step — a plain RNN will not learn an annual cycle through the gradient
  • Truncate the unroll to a window that covers the dependencies you actually need, and accept that anything longer is unlearnable
Evaluation

Rolling-origin backtesting with MASE against seasonal-naive and against exponential smoothing specifically. Compare against an LSTM on the same setup — if the gap is large, the vanishing gradient is the explanation rather than capacity.

Pitfalls
  • Expecting a plain RNN to learn a seasonal dependency, which is exactly the range where the gradient has already vanished
  • Shuffled validation splits, which leak the future and invalidate every number
  • Scaling statistics computed over the whole series before splitting, which is the most common leak in neural forecasting
  • Comparing only against a weak baseline, which is how most recurrent forecasting results were made to look good

Anomaly Detection

Adapted
How

Train to predict the next observation on normal data, then score by prediction error: a point the model did not expect is a candidate. Because the model conditions on the whole prefix rather than a fixed window, it can flag a value that is unremarkable in isolation and wrong given what preceded it — which is the class of anomaly a threshold cannot see.

Where
  • Multivariate telemetry monitoring, where the anomaly is a broken relationship between channels over time
  • Sequence anomaly detection in logs and event streams, where the surprising thing is the order rather than any single event
  • Industrial process monitoring with long, structured operating cycles
Why

It learns what normal dynamics look like rather than what normal values look like, which is the right frame when the process has structure a static model cannot express. Against it: the vanishing gradient limits how much context genuinely informs the prediction, so a plain RNN detector is effectively conditioning on the recent past regardless of how much history it was fed — and an LSTM or a temporal convolution does the same job with a longer effective memory. It also needs clean training data, and the reconstruction-error threshold is uncalibrated in the way every neural detector’s is.

Featurization
  • Train on a confirmed-clean window; contamination teaches the model that the anomaly is a normal continuation
  • Score standardized residuals per channel, since raw prediction error is dominated by whichever channel has the largest scale
  • Distinguish point anomalies from sustained regime shifts by looking at runs of errors rather than individual values
Evaluation

Precision@k against confirmed incidents with the threshold from clean-period error quantiles, holding out entire anomaly episodes rather than points from inside one. PR-AUC rather than ROC-AUC at the usual imbalance.

Pitfalls
  • Contaminated training data, after which the model predicts the anomaly and the error is small
  • Assuming long context is being used when the vanishing gradient means it is not — the model is conditioning on far less than it was given
  • A single global error threshold across channels of different scales and volatilities

Optimization

Adapted
How

The vanishing and exploding gradient problem is the clearest concrete instance in this section of an optimization pathology caused by the model rather than by the optimizer. The gradient is a product of T Jacobians of the same matrix, so its magnitude is governed by that matrix’s spectral radius raised to the power of the distance — geometric decay below one, geometric growth above. No learning rate schedule fixes it, because the signal that would drive the update has already become numerically zero.

Where
  • The reference example of why deep computation graphs are hard to optimize, and the direct motivation for gating, residual connections and normalization
  • Gradient clipping as the standard remedy for the exploding half, and a demonstration that the two failures are not symmetric
  • Orthogonal and identity initialization as an attempt to place the spectral radius near one deliberately
Why

It is worth studying because the diagnosis is exact and the remedies follow from it rather than from experiment: the problem is a repeated product, so either bound the product (clipping), or give the gradient a path that avoids the multiplication entirely (the LSTM’s additive cell state, or a residual connection). That second idea — build a route through the graph where the gradient is not repeatedly multiplied — is one of the most transferable in deep learning, and it is easiest to see here where the failure is unambiguous.

Featurization
  • Initialize the recurrent matrix orthogonally, which places the initial spectral radius at exactly one and delays both failures
  • Clip the global gradient norm rather than per-parameter, since clipping tensors separately changes the gradient direction and clipping the global norm does not
Evaluation

Log the gradient norm per step: a norm decaying geometrically with unroll depth is the vanishing case made visible, and occasional enormous spikes are the exploding one. Both are diagnosable directly rather than inferred from the loss.

Pitfalls
  • Treating a plateau as a learning-rate problem when the gradient reaching the early steps is numerically zero
  • Clipping per parameter tensor, which silently changes the update direction
  • Using ReLU in the recurrence, where its unbounded derivative makes explosion far more likely than in a feedforward stack
Also applied in

Natural Language

Adapted
How

Read tokens in order, carry a hidden state, and predict the next token or a label for the sequence. This was the standard formulation for language modelling, tagging and classification, and it is where the encoder-decoder structure that underpins translation was first built — the encoder compresses a source sentence into a state, the decoder generates from it.

Where
  • Character and word-level language modelling, historically the demonstration task for the architecture
  • Sequence labelling and text classification before transformers
  • The encoder-decoder formulation that machine translation was built on, and that attention was introduced to fix
  • Small-vocabulary streaming tasks where a fixed-size state and O(1) per-token inference genuinely matter
Why

Language is exactly the setting where the architecture’s limitation is most visible: agreement and reference routinely span more tokens than the gradient survives, so the plain RNN could not learn them. The history is instructive — LSTMs pushed the usable range out, attention removed the bottleneck of compressing a whole sentence into one vector, and then transformers removed the recurrence entirely for parallelism. What survives is the inference-time property: a recurrent model processes a token in O(1) with fixed state, where attention is O(sequence) per token, which is why recurrent and state-space formulations keep reappearing for long-context and streaming work.

Featurization
  • Bucket sequences by length before batching, or padding dominates the compute on a batch with mixed lengths
  • Use teacher forcing during training and accept exposure bias, or schedule a gradual switch to the model’s own predictions
  • Reverse the source sequence in encoder-decoder setups — a genuine trick from the era, and a direct symptom of the memory limitation
Evaluation

Perplexity for language modelling, task metrics for labelling. Measure performance against dependency distance specifically: accuracy that falls off sharply with distance is the vanishing gradient rather than a data problem.

Pitfalls
  • Expecting long-range agreement to be learned, which is exactly what the architecture cannot do
  • Padding without masking, so the loss and the state are contaminated by pad tokens
  • Exposure bias at generation time, where errors compound in a regime training never showed the model
Deployment

What it costs to run

Training

Linear in sequence length and sequential in it, which is the operative fact: timesteps cannot be computed in parallel, so a GPU is badly underused relative to an attention model of the same size. Truncated backpropagation bounds the memory and the per-step cost, and it also bounds what can be learned.

Inference

O(1) per token with a fixed-size state — genuinely constant, unlike attention which is linear in the context so far. That is why recurrent and state-space formulations keep returning for streaming and long-context work, and it is the architecture’s most durable advantage.

Retraining

Periodic and full, warm-started from the previous weights. The state is not persisted between deployments — it is a summary of the current sequence, and a stale state is worse than no state.

Drift & monitoring
  • Track the gradient norm during training as a first-class metric: geometric decay with unroll depth is the vanishing case and spikes are the exploding one, both diagnosable before the loss moves
  • Monitor performance against dependency distance rather than in aggregate, since long-range accuracy degrades first and is invisible in an average
  • Watch the hidden-state norm at inference; a state growing without bound over a long sequence indicates the dynamics have gone unstable
  • For generation, compare teacher-forced and free-running metrics — a large gap is exposure bias rather than a modelling failure
Production gotchas
  • Hidden state must be reset between independent sequences; carrying it over leaks one sequence into the next and produces subtly wrong output with nothing raised
  • Padding must be masked in both the loss and the state update, or pad tokens are learned as content
  • Gradient clipping is not optional — one exploding batch produces NaN weights and the entire run is lost
  • Variable-length batching requires either bucketing or packed sequences; naive padding to the longest member wastes most of the compute on a mixed batch
  • The sequential recurrence means throughput does not improve with a bigger GPU the way a feedforward model’s does, which surprises teams sizing hardware from parameter count
Trade-offs

Pros and cons, in context

Strengths
  • Handles variable-length sequences with a fixed parameter count

    Weight sharing across steps means one model reads a sentence of any length. The property that makes it general, and the same property that makes its gradient a repeated product.

  • O(1) inference per token with constant state

    Genuinely constant, where attention is linear in the context so far. This is the architecture’s most durable advantage and the reason recurrent and state-space formulations keep returning for streaming and long-context work.

  • Linear in sequence length rather than quadratic

    Compares well against attention on paper for long sequences. In practice the sequential dependency usually costs more than the asymptotic advantage saves, which is a good lesson about asymptotics.

  • Learns temporal structure rather than being given it

    No lag features to design, and the model can represent dependencies a fixed window would miss. Worth much less when the useful lags are few and known, where a dense network on lag features is simpler and trains faster.

Limitations
  • Vanishing gradients make long dependencies unlearnable

    A capability ceiling rather than a tuning problem: the signal from far back arrives as numerical zero. This is the entire motivation for the LSTM and the GRU, and it means the plain RNN is essentially never the right production choice.

  • Exploding gradients require clipping as standard equipment

    One bad batch produces NaN weights and ends the run. Cheap to fix and easy to forget, and the asymmetry is worth noting — this failure is an inconvenience where the vanishing one is a limit.

  • Sequential in time, so no parallelism across the sequence

    The reason attention displaced it despite worse asymptotics: modern hardware rewards parallel throughput far more than it rewards linear complexity. A structural mismatch with the machines, not with the problem.

  • A fixed-size state is a hard information bottleneck

    Everything from the past must be compressed into one vector, which is what made encoder-decoder translation fail on long sentences and what attention was introduced to fix.

Implementation

Intuitive, idiomatic, optimized

The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.

1. Intuitive

The math, transcribed literally. No dependencies.

"""A vanilla RNN - forward through time and BPTT, transcribed.

The forward pass carries a state; the backward pass walks the same chain in
reverse, multiplying by the recurrent Jacobian once per step. That repeated
multiplication is the entire vanishing-gradient problem, and it is visible in
the loop below.
"""

import math
import random


def tanh(z):
    return math.tanh(z)


def fit(sequences, targets, hidden, lr=0.01, epochs=100, seed=0):
    """One hidden layer, tanh activation, scalar output per sequence."""
    rng = random.Random(seed)
    d = len(sequences[0][0])

    W_xh = [[rng.gauss(0.0, 0.1) for _ in range(hidden)] for _ in range(d)]
    W_hh = [[rng.gauss(0.0, 0.1) for _ in range(hidden)] for _ in range(hidden)]
    W_hy = [rng.gauss(0.0, 0.1) for _ in range(hidden)]
    b_h = [0.0] * hidden
    b_y = 0.0

    for _ in range(epochs):
        for sequence, target in zip(sequences, targets):
            T = len(sequence)

            # ---- forward, carrying the state --------------------------
            # Every state is kept: the backward pass needs all of them,
            # which is why memory grows with sequence length and why
            # truncation exists.
            states = [[0.0] * hidden]
            for t in range(T):
                current = [0.0] * hidden
                for h in range(hidden):
                    total = b_h[h]
                    for j in range(d):
                        total += sequence[t][j] * W_xh[j][h]
                    for k in range(hidden):
                        total += states[t][k] * W_hh[k][h]
                    current[h] = tanh(total)
                states.append(current)

            prediction = b_y
            for h in range(hidden):
                prediction += states[T][h] * W_hy[h]

            # ---- backward through time --------------------------------
            error = 2.0 * (prediction - target)

            grad_W_hy = [error * states[T][h] for h in range(hidden)]
            grad_b_y = error

            grad_W_xh = [[0.0] * hidden for _ in range(d)]
            grad_W_hh = [[0.0] * hidden for _ in range(hidden)]
            grad_b_h = [0.0] * hidden

            # delta at the final state, then walked backwards.
            delta = [error * W_hy[h] for h in range(hidden)]

            for t in reversed(range(T)):
                # Multiply by the activation derivative: 1 - tanh^2, which is
                # at most 1 and usually much less. That factor alone shrinks
                # the signal at every step.
                gated = [delta[h] * (1.0 - states[t + 1][h] ** 2) for h in range(hidden)]

                for h in range(hidden):
                    grad_b_h[h] += gated[h]
                    for j in range(d):
                        grad_W_xh[j][h] += gated[h] * sequence[t][j]
                    for k in range(hidden):
                        grad_W_hh[k][h] += gated[h] * states[t][k]

                # THE PRODUCT. delta is multiplied by W_hh once per step, so
                # after T steps it has been multiplied by the same matrix T
                # times - geometric decay if its spectral radius is below one,
                # geometric growth if above. No learning rate fixes either.
                delta = [
                    sum(gated[h] * W_hh[k][h] for h in range(hidden))
                    for k in range(hidden)
                ]

            # ---- update ------------------------------------------------
            for h in range(hidden):
                W_hy[h] -= lr * grad_W_hy[h]
                b_h[h] -= lr * grad_b_h[h]
                for j in range(d):
                    W_xh[j][h] -= lr * grad_W_xh[j][h]
                for k in range(hidden):
                    W_hh[k][h] -= lr * grad_W_hh[k][h]
            b_y -= lr * grad_b_y

    return W_xh, W_hh, W_hy, b_h, b_y

O(T * hidden^2) forward and backward per sequence in interpreter loops, one sequence at a time, with no clipping and no truncation.

2. IdiomaticNumPy

How this language actually wants it written.

"""An RNN - typed, batched, orthogonally initialized, clipped, truncated."""

from dataclasses import dataclass

import numpy as np
from numpy.typing import NDArray

Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
Tensor = NDArray[np.float64]


@dataclass
class RecurrentLayer:
    """Weights shared across every timestep - which is what makes the
    parameter count independent of sequence length, and the gradient a
    repeated product of the same matrix."""

    W_xh: Matrix
    W_hh: Matrix
    bias: Vector

    @classmethod
    def initialized(cls, input_size: int, hidden: int, rng: np.random.Generator) -> "RecurrentLayer":
        """Orthogonal initialization for the RECURRENT matrix specifically.

        An orthogonal matrix has every singular value equal to one, so the
        repeated product neither shrinks nor grows at initialization. That
        does not solve the vanishing gradient - training moves the weights
        away from orthogonality - but it delays the onset of both failures
        long enough for learning to start, which random init frequently does
        not.
        """
        random_matrix = rng.normal(0.0, 1.0, size=(hidden, hidden))
        orthogonal, _ = np.linalg.qr(random_matrix)

        return cls(
            W_xh=rng.normal(0.0, np.sqrt(2.0 / input_size), size=(input_size, hidden)),
            W_hh=orthogonal,
            bias=np.zeros(hidden, dtype=np.float64),
        )

    def forward(self, sequence: Tensor) -> tuple[Tensor, Vector]:
        """sequence is (batch, time, features). Returns every state, because
        the backward pass needs all of them - which is what makes memory grow
        with sequence length and why truncation exists."""
        if sequence.ndim != 3:
            raise ValueError(f"expected (batch, time, features), got {sequence.shape}")
        if sequence.shape[2] != self.W_xh.shape[0]:
            raise ValueError(
                f"expected {self.W_xh.shape[0]} features, got {sequence.shape[2]}"
            )

        batch, steps, _ = sequence.shape
        hidden = self.bias.size
        states = np.zeros((batch, steps + 1, hidden), dtype=np.float64)

        # The time loop cannot be vectorized - step t reads the state step
        # t-1 produced. What CAN be batched is the batch dimension, so each
        # step is one matrix product over the whole batch rather than a loop
        # over sequences.
        for t in range(steps):
            pre_activation = (
                sequence[:, t] @ self.W_xh + states[:, t] @ self.W_hh + self.bias
            )
            states[:, t + 1] = np.tanh(pre_activation)

        return states, states[:, -1]


def clip_global_norm(gradients: list[Matrix], max_norm: float) -> float:
    """Clip by the GLOBAL norm across every parameter, not per tensor.

    Exploding gradients are the tractable half of the problem, and this is the
    standard remedy. Clipping tensors independently would rescale them by
    different factors and change the gradient DIRECTION; clipping the global
    norm rescales everything by one factor and preserves it.
    """
    total = np.sqrt(sum(float(np.sum(g * g)) for g in gradients))
    if total > max_norm:
        scale = max_norm / total
        for gradient in gradients:
            gradient *= scale
    return total


def truncated_bptt(
    layer: RecurrentLayer,
    sequence: Tensor,
    targets: Tensor,
    window: int = 64,
) -> list[tuple[Tensor, Vector]]:
    """Split the sequence into windows, carrying the state and cutting the
    gradient at each boundary.

    Bounds memory and compute - and also bounds what the model can learn to
    the window length, which is a limit chosen in advance rather than
    discovered. That trade is the whole point of the technique.
    """
    if window < 1:
        raise ValueError(f"window must be at least 1, got {window}")

    chunks: list[tuple[Tensor, Vector]] = []
    steps = sequence.shape[1]

    for start in range(0, steps, window):
        stop = min(start + window, steps)
        states, final = layer.forward(sequence[:, start:stop])
        # The carried state is DETACHED: it flows forward as a value and the
        # gradient does not flow back through it. That cut is the truncation.
        chunks.append((states, final.copy()))

    return chunks

What changed: Three changes, and only the batching is about vectorization. The recurrent matrix gets orthogonal initialization, which sets every singular value to one so the repeated product neither shrinks nor grows at the start — it does not solve the vanishing gradient, since training moves the weights away from orthogonality, but it delays both failures long enough for learning to begin where random initialization frequently does not. Gradient clipping is added on the global norm rather than per tensor, because clipping tensors independently rescales them by different factors and changes the update direction. And truncated backpropagation makes the memory bound explicit, along with the fact that it also bounds what the model can learn. The time loop stays sequential because step t reads the state step t−1 produced; what is batched is the batch dimension.

Explicit type hints on every public signatureDataclasses or NamedTuples over ad-hoc dictsRaise specific exceptions, never bare exceptGuard clauses over nested conditionals

O(T * (h^2 + h*d)) per sequence with each timestep a batched matrix product rather than a loop over sequences.

3. OptimizedNumPy

Faster, and the justification for each change.

"""An RNN - fused input projection, preallocated states, packed batches."""

import numpy as np
from numpy.typing import NDArray

Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
Tensor = NDArray[np.float64]


class FusedRnn:
    """The time loop is irreducibly sequential; everything around it is not.

    Step t reads the state step t-1 produced, so no rewrite parallelizes over
    time - that limitation is the architecture, and it is why attention
    displaced it. What CAN be lifted out of the loop is the input projection:
    x_t @ W_xh does not depend on any state, so all T of them are ONE matrix
    product computed before the loop starts, leaving only the recurrent term
    inside it.

    That halves the work in the hot loop and is the main reason a production
    RNN kernel looks nothing like the textbook formulation.
    """

    def __init__(self, input_size: int, hidden: int, batch: int, max_steps: int,
                 seed: int = 0) -> None:
        rng = np.random.default_rng(seed)

        # Contiguous float32: the recurrent product is memory-bound, and
        # halving the traffic on the state and weight arrays is worth more
        # than the extra mantissa bits on a bounded tanh output.
        self._W_xh = np.ascontiguousarray(
            rng.normal(0.0, np.sqrt(2.0 / input_size), size=(input_size, hidden)),
            dtype=np.float32,
        )
        orthogonal, _ = np.linalg.qr(rng.normal(0.0, 1.0, size=(hidden, hidden)))
        self._W_hh = np.ascontiguousarray(orthogonal, dtype=np.float32)
        self._bias = np.zeros(hidden, dtype=np.float32)

        # Every buffer allocated once for the whole run: states, the
        # projected inputs, and the per-step scratch.
        self._states = np.zeros((batch, max_steps + 1, hidden), dtype=np.float32)
        self._projected = np.zeros((batch, max_steps, hidden), dtype=np.float32)
        self._scratch = np.zeros((batch, hidden), dtype=np.float32)
        self._hidden = hidden

    def forward(self, sequence: Tensor, lengths: NDArray) -> Tensor:
        """sequence is (batch, time, features); lengths gives the true length
        of each member so padding contributes nothing."""
        batch, steps, _ = sequence.shape
        states = self._states[:batch, : steps + 1]
        states.fill(0.0)

        # ALL input projections in one GEMM, before the loop. This term does
        # not depend on the state, so computing it per step inside the loop -
        # as the textbook formulation does - repeats a matrix product T times
        # for no reason.
        projected = self._projected[:batch, :steps]
        np.matmul(sequence.reshape(-1, sequence.shape[2]), self._W_xh,
                  out=projected.reshape(-1, self._hidden))
        projected += self._bias

        for t in range(steps):
            # Only the recurrent term remains inside the loop.
            np.matmul(states[:, t], self._W_hh, out=self._scratch[:batch])
            self._scratch[:batch] += projected[:, t]
            np.tanh(self._scratch[:batch], out=states[:, t + 1])

            # Masking: a sequence that has ended keeps its final state rather
            # than continuing to consume padding. Done in place, because a
            # boolean-indexed copy here would allocate once per timestep.
            finished = lengths <= t
            if finished.any():
                states[finished, t + 1] = states[finished, t]

        return states[:, 1 : steps + 1]

    def final_states(self, lengths: NDArray) -> Matrix:
        """Gather each sequence's state at ITS OWN final step, not the last
        padded one - the classic off-by-one in variable-length batching."""
        rows = np.arange(lengths.size)
        return self._states[rows, lengths]

What changed: The time loop cannot be parallelized — step t reads the state step t−1 produced — and that limitation is the architecture rather than the implementation, which is why attention displaced it. What can be lifted out is the input projection: it does not depend on any state, so all T of them become one matrix product computed before the loop instead of a separate product inside it, which halves the work in the hot loop. Everything else is allocation discipline: states, projections and scratch allocated once, masking applied in place so a boolean-indexed copy is not made per timestep, and float32 because the recurrent product is memory-bound and a bounded tanh output does not need 52 bits.

  • Fuse adjacent operations so intermediates are never materialized

    All T input projections collapse into a single GEMM before the loop, and the bias is folded into the same buffer, so the per-step work is only the recurrent product and the activation.

    Trade-off: Requires the whole sequence resident before the first step, which rules out a genuinely streaming forward pass — the very use case where an RNN’s O(1) per-token inference is the reason to choose it.

  • Pre-allocate output arrays and use in-place operations

    The state tensor, the projection buffer and the per-step scratch are allocated once for the run rather than per call, which across thousands of steps is the dominant allocation.

    Trade-off: Fixes a maximum batch size and sequence length at construction, and the buffers are shared mutable state, so the object cannot be used concurrently or reentered.

  • Ensure contiguous memory layout and a single dtype

    Contiguous float32 halves the memory traffic on the state and weight arrays, and lets each GEMM read them directly rather than through an internal conversion.

    Trade-off: float32 accumulates error over a long unroll, and the recurrent product is exactly where that compounds — which is a genuine reason some implementations keep the state in higher precision than the weights.

O(T * h^2) in the loop after the input projection is lifted out, versus O(T * (h^2 + h*d)). Illustrative, not a measured benchmark.