Back to Deep Learning
AI/ML · Deep Learning

Long Short-Term Memory

A recurrent cell that carries an explicit memory track alongside its hidden state, and learns three gates that decide what to erase from that memory, what to write into it, and what to expose. The memory passes forward through addition rather than repeated multiplication, which is the entire trick: gradients can travel back hundreds of steps without vanishing.

SupervisedLSTM / GRUSequence modelingRegressionClassificationAnomaly detection

Also known as: LSTM, Gated recurrent network

Intuition

What it actually does

A recurrent cell that carries an explicit memory track alongside its hidden state, and learns three gates that decide what to erase from that memory, what to write into it, and what to expose. The memory passes forward through addition rather than repeated multiplication, which is the entire trick: gradients can travel back hundreds of steps without vanishing.

Assumptions it makes
  • The generating process is stationary enough that patterns learned on history persist into the forecast window
  • Sequence order is meaningful and the sampling interval is consistent (or the interval is given as a feature)
  • Enough sequences exist to fit a high-capacity model without memorizing them
  • The relevant history fits inside the truncation window used for backpropagation
Objective

Loss function

The loss is whatever the task loss would be, summed over every timestep and averaged over sequences. Nothing about the objective is special — what is special is that the same weights produce every term in the sum, so one gradient update has to serve all T steps at once.

sequence length — the number of steps unrolled
the input prefix up to step t; the model may never see beyond it
the per-step loss — squared error for forecasting, cross-entropy for labels
the unrolled recurrence, sharing one parameter set across all T steps
Optimization

How it is minimized

Backpropagation through time with gradient clipping, minimized by Adam

the accumulated gradient over the unrolled graph
the clipping threshold — the norm the gradient is rescaled down to
truncation length; the graph is cut here to bound memory

Unrolling the recurrence turns it into a very deep feed-forward network sharing one weight matrix, so the gradient is a sum over T paths. The additive cell state stops those paths from vanishing, but it does nothing about them exploding — a single steep step can send the norm to infinity. Clipping is therefore not optional hygiene, it is what makes the training stable at all. Adam is standard because the per-parameter scaling copes with gates that saturate at very different rates.

Hyperparameters
  • hidden size

    Memory capacity per step; the main lever on both fit and cost (32 to 512)

  • layers

    Depth of stacked recurrence; rarely helps past 2–3 (1 to 3)

  • truncation length (BPTT)

    How far back gradients flow; bounds memory and runtime (20 to 200 steps)

  • gradient clip norm

    Caps the update size so one steep batch cannot destroy the weights (0.5 to 5.0)

  • dropout

    Applied between layers, not across time, or it destroys the memory (0.0 to 0.3)

Convergence & cost

No convexity and no guarantee — the objective is a deep non-convex surface and you are looking for a good local minimum, not the global one. The characteristic failure is not divergence but a plateau: the model learns the mean of the series, drives the loss down, and produces a flat forecast that looks fine on MSE and is useless. Exploding gradients show up as a sudden loss spike to NaN; vanishing ones show up as long-range dependencies simply never being learned, which is silent.

O(T · H²) per sequence for both forward and backward, with H the hidden size. Strictly sequential in T — the recurrence cannot be parallelized across time, which is the structural reason Transformers displaced LSTMs at scale.

Applications

How, where, and why it is used

Time-Series Forecasting

Primary fit
How

Feed a sliding window of past observations and predict the next value or the next h values. For multi-horizon, either decode autoregressively (feeding predictions back, which compounds error) or emit all h outputs at once from the final hidden state (which does not, and is usually better).

Where
  • Retail and supply-chain demand forecasting across thousands of SKUs with one shared global model
  • Energy load and generation forecasting, where daily and weekly cycles interact with weather
  • Sensor and telemetry prediction in industrial monitoring, feeding a downstream control loop
Why

Worth its cost only when you have many long, related series — a global LSTM learns patterns across series that no per-series ARIMA can see. On one short series it will lose to exponential smoothing, and it is important to say so: the trigger is series count and history length, not model sophistication.

Featurization
  • Window the series into overlapping (input, target) pairs, split strictly by time
  • Scale per series, not globally, so a high-volume series does not dominate the loss
  • Append calendar features (hour, day-of-week, holiday) as extra input channels
  • Difference or log-transform when the trend is multiplicative rather than additive
Evaluation

Rolling-origin backtesting with an expanding window, scored with MASE against a seasonal-naive baseline, evaluated at the horizon the business actually acts on rather than at h=1.

Pitfalls
  • Scaling fit on the full series before splitting leaks future statistics into training
  • Autoregressive decoding compounds error — a good h=1 model can be terrible at h=24
  • A model that has learned the series mean shows a respectable MSE and a flat, worthless forecast

Anomaly Detection

Primary fit
How

Train on known-normal sequences only, then score by prediction or reconstruction error: an observation the model could not have anticipated from its own history is an anomaly. The LSTM-autoencoder variant compresses a window and reconstructs it, which catches shape anomalies a next-step predictor misses.

Where
  • Server and network telemetry monitoring, where normal load has strong daily and weekly shape
  • Predictive maintenance on rotating machinery, catching drift before a hard failure
  • Cardiac and physiological monitoring, where the anomaly is a deviation from a personal baseline
Why

This is where an LSTM beats every static detector: it models what is normal *for this time of day, given what just happened*. A z-score on raw values will fire every weekday morning; a sequence model will not. The cost is that a large enough network learns to reconstruct anomalies too, so capacity has to be constrained on purpose.

Featurization
  • Train exclusively on a confirmed-clean window, or the model learns to accept anomalies
  • Keep the bottleneck narrow in the autoencoder variant — capacity is the detector
  • Score on a rolling window of error, not a single point, to suppress single-sample noise
Evaluation

Precision@k and time-to-detection against confirmed incidents, with the threshold set from the error quantiles of a held-out clean period, and whole anomaly episodes held out rather than individual points.

Pitfalls
  • Contaminated training data silently redefines normal to include the failure mode
  • Too much capacity reconstructs anomalies perfectly and the detector goes blind
  • Thresholds drift with the system — recalibrate on a schedule even when the model does not change

Optimization

Adapted
How

Not an optimizer itself. It enters an optimization pipeline as the learned dynamics model: predict how the system evolves under a candidate action sequence, then let a classical solver or a model-predictive controller search over those sequences.

Where
  • World models inside model-based RL, where rollouts are cheaper than touching the real system
  • Demand prediction feeding an inventory or dispatch optimizer
Why

It supplies the forecast the optimizer consumes, and the quality of the decision is usually bounded by that forecast rather than by the solver. But it offers no constraint guarantees and no optimality argument, so it belongs inside the loop, never in place of it.

Featurization
  • Include the action as an input channel — a dynamics model must be conditioned on what you did
  • Train on trajectories under a diverse action distribution, or the model only knows one regime
Evaluation

Multi-step rollout error under held-out action sequences, not one-step accuracy: an optimizer will exploit exactly the horizon where the model is weakest.

Pitfalls
  • One-step accuracy is a poor proxy for rollout accuracy, and the optimizer only cares about the latter
  • The planner will find and exploit regions where the learned model is confidently wrong
Also applied in

Risk & Fraud

Viable
How

Model a customer or account as a sequence of events rather than a feature vector, so the signal is behavioural change over time rather than any single transaction.

Where
  • Account-takeover detection from session and transaction sequences
  • Merchant risk scoring where the pattern of activity matters more than its aggregate
Why

Catches drift within an account that per-transaction tabular features flatten away. Gradient boosting still wins on engineered aggregates, so this is a complement rather than a replacement — and it costs the explainability that regulated decisions often require.

Featurization
  • Sequence by event with explicit time deltas as a channel; irregular spacing is information
  • Cap sequence length and truncate from the start, keeping recent behaviour
Evaluation

PR-AUC and recall at a fixed false-positive budget, on strictly time-based splits — random splits leak future fraud patterns backwards.

Pitfalls
  • Label latency means recent sequences are not yet fully labelled
  • Adversaries adapt, so the "normal" the model learned decays faster than in other domains

Natural Language

Adapted
How

Consume token embeddings step by step and emit a per-token or per-sequence representation, the standard architecture for NLP before attention displaced it.

Where
  • Small-corpus sequence labelling where a pretrained transformer is overkill
  • On-device text classification under tight memory limits
Why

Largely superseded: the sequential recurrence cannot be parallelized across time, so it does not scale to pretraining, which is where nearly all modern NLP performance comes from. Still reasonable when the corpus is small and the latency budget is tight.

Featurization
  • Bucket by sequence length to limit padding waste
  • Bidirectional encoding when the whole sequence is available at inference
Evaluation

Task F1 on a held-out set, benchmarked against a fine-tuned encoder to confirm the simpler model is genuinely sufficient.

Pitfalls
  • Long-range dependency is still weaker than attention, however good the gating is
  • No pretraining transfer means it needs far more labelled data
Deployment

What it costs to run

Training

Hours on a single GPU for a typical global forecasting model. The sequential recurrence is the bottleneck — you can batch across sequences but never across time.

Inference

Cheap per step and naturally streaming: carry the hidden state forward and each new observation costs one cell update. Milliseconds on CPU for modest hidden sizes.

Retraining

Weekly to monthly for forecasting; more often for anomaly detection, where the definition of normal drifts with the system.

Drift & monitoring
  • Track per-segment forecast bias — sustained one-sided error means the learned dynamics no longer hold
  • Watch the reconstruction/prediction error distribution on known-normal traffic, not just alert volume
  • Alert on the input scaler going out of range; an unscaled input silently produces nonsense
Production gotchas
  • Hidden state must be reset at sequence boundaries or one series bleeds into the next
  • The scaler is part of the model — persist and version it together, never refit at inference
  • Training uses teacher forcing while inference feeds back predictions; that gap is a real accuracy cliff
Trade-offs

Pros and cons, in context

Strengths
  • Learns long-range temporal dependency without hand-specified lags

    The decisive advantage over ARIMA and lag-feature boosting when the relevant history is long or the lag structure is unknown. Worth nothing when the dependency is a known seasonal period you could simply encode.

  • One global model shares strength across thousands of series

    Where nearly all the real-world gain comes from — a series with 30 observations borrows structure from ones with 3,000. Irrelevant if you only have one series.

  • Streaming inference with O(1) state per step

    Makes it genuinely deployable on live telemetry, unlike an attention model that must re-attend over a window each step.

  • The same trained model serves forecasting and anomaly detection

    Prediction error IS the anomaly score, so one model covers both goals — an operational simplification that matters more than it sounds.

Limitations
  • Cannot be parallelized across time

    The structural reason Transformers displaced LSTMs at scale. Barely matters at hundreds of steps; decisive at tens of thousands.

  • Needs substantially more data than classical alternatives

    On a single short series it will lose to two-parameter exponential smoothing, and confidently so. The honest trigger is many long series.

  • Point forecasts with no native uncertainty

    A real problem wherever the decision needs the tail rather than the mean — inventory, capacity, risk. Requires quantile loss or a probabilistic head bolted on.

  • Effectively opaque

    Fatal in regulated or causal work where the coefficient is the deliverable. Irrelevant when only the prediction is acted on.

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.

"""One LSTM cell and a forward pass over a sequence - the gates, written out.

Four gates, all the same shape: forget decides what to drop from the cell
state, input decides what to consider writing, candidate is what to write,
and output decides what to expose as the hidden state.
"""

import math


def sigmoid(z):
    return 1.0 / (1.0 + math.exp(-z))


def lstm_cell(x, h_prev, c_prev, weights, hidden_size):
    """One timestep. x, h_prev, c_prev are plain lists of floats."""
    combined = list(x) + list(h_prev)

    def gate(name):
        w, b = weights[name]
        out = []
        for j in range(hidden_size):
            acc = b[j]
            for k in range(len(combined)):
                acc += w[j][k] * combined[k]
            out.append(acc)
        return out

    f = [sigmoid(v) for v in gate("forget")]     # what to erase
    i = [sigmoid(v) for v in gate("input")]      # what to admit
    g = [math.tanh(v) for v in gate("candidate")]  # what to write
    o = [sigmoid(v) for v in gate("output")]     # what to expose

    # The additive update is the whole point: c flows forward through a sum,
    # not a repeated matrix product, so its gradient does not vanish.
    c = [f[j] * c_prev[j] + i[j] * g[j] for j in range(hidden_size)]
    h = [o[j] * math.tanh(c[j]) for j in range(hidden_size)]
    return h, c


def forward(sequence, weights, hidden_size):
    h = [0.0] * hidden_size
    c = [0.0] * hidden_size
    for x in sequence:
        h, c = lstm_cell(x, h, c, weights, hidden_size)
    return h

O(T · H · (D+H)) in pure Python — correct, and far too slow to train anything.

2. IdiomaticPyTorch

How this language actually wants it written.

"""LSTM forecaster - typed, validated, and expressed in a framework."""

from dataclasses import dataclass

import torch
from torch import Tensor, nn


@dataclass(frozen=True)
class ForecastConfig:
    input_size: int
    hidden_size: int = 64
    num_layers: int = 2
    horizon: int = 24
    dropout: float = 0.1


class LstmForecaster(nn.Module):
    """Encodes a window and emits all `horizon` steps at once.

    Direct multi-horizon output rather than autoregressive decoding: feeding
    predictions back compounds error across the horizon, and the head costs
    almost nothing.
    """

    def __init__(self, config: ForecastConfig) -> None:
        super().__init__()
        if config.num_layers < 1:
            raise ValueError(f"num_layers must be >= 1, got {config.num_layers}")
        if not 0.0 <= config.dropout < 1.0:
            raise ValueError(f"dropout must be in [0, 1), got {config.dropout}")

        self.config = config
        self.lstm = nn.LSTM(
            input_size=config.input_size,
            hidden_size=config.hidden_size,
            num_layers=config.num_layers,
            # Dropout between layers only. Applying it across time would sever
            # the memory the architecture exists to preserve.
            dropout=config.dropout if config.num_layers > 1 else 0.0,
            batch_first=True,
        )
        self.head = nn.Linear(config.hidden_size, config.horizon)

    def forward(self, windows: Tensor) -> Tensor:
        if windows.dim() != 3:
            raise ValueError(f"expected (batch, time, features), got {tuple(windows.shape)}")

        outputs, _ = self.lstm(windows)
        return self.head(outputs[:, -1, :])   # last step summarizes the window


def train_step(
    model: LstmForecaster,
    optimizer: torch.optim.Optimizer,
    windows: Tensor,
    targets: Tensor,
    clip_norm: float = 1.0,
) -> float:
    model.train()
    optimizer.zero_grad(set_to_none=True)
    loss = nn.functional.mse_loss(model(windows), targets)
    loss.backward()
    # Not optional hygiene: one steep batch can send the gradient norm to
    # infinity and destroy the weights irrecoverably.
    nn.utils.clip_grad_norm_(model.parameters(), clip_norm)
    optimizer.step()
    return float(loss.item())

What changed: The hand-rolled cell is replaced by a framework module that is already fused and differentiable, the config becomes a frozen dataclass instead of loose keyword arguments, and the two things that actually break LSTM training in practice — dropout applied across time, and unclipped gradients — are handled explicitly rather than left to chance.

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²) per sequence, fused CUDA kernels, batched across sequences.

3. OptimizedPyTorch (AMP)

Faster, and the justification for each change.

"""LSTM forecaster - packed batches, fused kernels, mixed precision."""

import torch
from torch import Tensor, nn
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence


class FastLstmForecaster(nn.Module):
    def __init__(self, input_size: int, hidden_size: int = 64, horizon: int = 24) -> None:
        super().__init__()
        self.lstm = nn.LSTM(input_size, hidden_size, num_layers=2, batch_first=True)
        self.head = nn.Linear(hidden_size, horizon)

    def forward(self, windows: Tensor, lengths: Tensor) -> Tensor:
        # Packing skips padded timesteps entirely. On a batch bucketed by
        # length this is a large constant-factor win, and it also stops the
        # padding from polluting the final hidden state.
        packed = pack_padded_sequence(
            windows, lengths.cpu(), batch_first=True, enforce_sorted=False
        )
        packed_out, (hidden, _) = self.lstm(packed)
        pad_packed_sequence(packed_out, batch_first=True)
        return self.head(hidden[-1])


def train_epoch(model, optimizer, loader, device, clip_norm: float = 1.0) -> float:
    model.train()
    scaler = torch.amp.GradScaler(device)
    total = 0.0

    for windows, lengths, targets in loader:
        windows = windows.to(device, non_blocking=True)
        targets = targets.to(device, non_blocking=True)

        optimizer.zero_grad(set_to_none=True)
        # bfloat16 autocast: the recurrence is memory-bandwidth bound, so
        # halving the activation traffic is close to halving the step time.
        with torch.autocast(device_type=device, dtype=torch.bfloat16):
            loss = nn.functional.mse_loss(model(windows, lengths), targets)

        scaler.scale(loss).backward()
        # Unscale before clipping, or the threshold is applied to scaled
        # gradients and means nothing.
        scaler.unscale_(optimizer)
        nn.utils.clip_grad_norm_(model.parameters(), clip_norm)
        scaler.step(optimizer)
        scaler.update()
        total += float(loss.item())

    return total / max(len(loader), 1)

What changed: Three changes, none of which touch the model. Variable-length batches are packed so padded steps are never computed. The forward and backward pass run in bfloat16 under autocast, which matters because the recurrence is bandwidth-bound rather than compute-bound. And transfers are non-blocking so the host is not stalling the device between batches.

  • Batch work to amortize interpreter overhead

    Packed variable-length batches let one kernel launch cover many sequences without wasting work on padding — the dominant cost at short sequence lengths is launch overhead, not arithmetic.

    Trade-off: Requires bucketing the loader by length; a badly bucketed batch packs poorly and the win evaporates.

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

    zero_grad(set_to_none=True) and in-place scaler updates avoid reallocating gradient buffers every step.

    Trade-off: set_to_none changes .grad from a zero tensor to None, which breaks any code that reads gradients before the first backward pass.

  • Ensure contiguous memory layout and a single dtype

    bfloat16 autocast halves activation traffic through the recurrence, which is where a memory-bound LSTM actually spends its time.

    Trade-off: Reduced precision can destabilize training on ill-conditioned data; the gradient scaler exists precisely to paper over that and adds its own failure mode.

Roughly 2x step-time reduction on bandwidth-bound recurrences. Illustrative, not a measured benchmark.