Back to Deep Learning
AI/ML · Deep Learning

N-BEATS / N-HiTS

Give a block the last few hundred observations and ask it for two things: a forecast, and a reconstruction of the input it thinks it explained. Subtract that reconstruction from the input and hand the remainder to the next block. Each block therefore works on what its predecessors could not explain, and the forecasts add up — which is gradient boosting’s idea rearranged into a neural network, and exactly the same idea as classical decomposition into trend and seasonality, except the components are learned instead of specified.

SupervisedFeedforwardRegressionSequence modelingAnomaly detection

Classified feedforward because that is literally all it is — stacked fully connected layers with residual connections, and no sequence-specific machinery anywhere. That is the finding rather than a simplification: it beat the statistical ensembles that won the M4 competition without a single recurrent or attentional component.

Also known as: N-BEATS, N-HiTS, Doubly residual stacking, Basis expansion forecaster

Intuition

What it actually does

Give a block the last few hundred observations and ask it for two things: a forecast, and a reconstruction of the input it thinks it explained. Subtract that reconstruction from the input and hand the remainder to the next block. Each block therefore works on what its predecessors could not explain, and the forecasts add up — which is gradient boosting’s idea rearranged into a neural network, and exactly the same idea as classical decomposition into trend and seasonality, except the components are learned instead of specified.

Assumptions it makes
  • The series carries enough signal in its own history alone, since nothing else can enter the model
  • Every series has at least a full lookback window of history; shorter series are not forecastable by this architecture
  • Series are comparable after scale-free normalization, which is what lets one global model serve the whole panel
  • The sampling interval is fixed and regular, with gaps handled upstream because no mask exists
  • A point forecast is sufficient, or intervals can be manufactured from historical residuals after the fact
Objective

Loss function

Two things are doing the work and only one is the loss. MASE divides by the in-sample error of a seasonal-naive forecast, which makes the loss comparable across series of wildly different scale — essential when one global model covers a hundred thousand series, and the reason a plain MSE trains a model that only cares about the largest ones. The structural half is the two recursions: forecasts add, and inputs have their explained part subtracted. That means no block ever has to represent the whole signal, and it is why a stack of ordinary MLPs is sufficient where intuition says it should not be.

block l’s forecast contribution; the final forecast is their sum, never a final layer’s output
block l’s backcast — its reconstruction of the input, which is subtracted before the next block
seasonal period used by the MASE denominator; the scale-free normalizer
forecast horizon, emitted entirely in one pass rather than step by step
number of blocks; depth here means more residual refinement, not more abstraction
Optimization

How it is minimized

Adam on a scale-free loss, with the ensemble treated as part of the model rather than as a refinement

basis coefficients — the only thing the MLP predicts; the shape comes from the basis
forecast and backcast basis matrices: identity for generic blocks, polynomial for trend, Fourier for seasonality
four fully connected ReLU layers; the entire nonlinearity in the model

The design decision worth extracting is that the network predicts coefficients rather than values. With a generic identity basis that distinction is vacuous; with a polynomial or Fourier basis it constrains the output to a smooth low-order function, which regularizes far more effectively than any weight penalty and simultaneously makes the component interpretable — this stack is trend, that one is seasonality, and you can plot them. N-HiTS adds the second idea: pool the input at different rates in different stacks and interpolate the outputs back up, so each stack specializes in a frequency band. That is multi-rate signal processing, and it cuts long-horizon cost substantially. The uncomfortable finding is the ensemble: the published results average dozens of models trained with different lookback windows and losses, and a single model is materially worse. The ensemble is not a refinement here, it is part of the method, and comparisons that omit it are comparing different things.

Hyperparameters
  • lookback multiplier

    History length as a multiple of the horizon; the strongest single lever and the main source of ensemble diversity (2x to 7x horizon)

  • blocks per stack

    Residual refinement depth within a stack; returns flatten quickly (1 to 3)

  • stacks

    Generic stacks for the interpretable-free variant, or trend plus seasonality for the interpretable one (2 to 30)

  • hidden width

    Width of each fully connected layer; the dominant parameter count (256 to 512)

  • polynomial degree (trend)

    Order of the trend basis. Past 3 it stops being a trend and starts fitting noise (2 to 4)

  • pooling kernel (N-HiTS)

    Input downsampling rate per stack; what assigns each stack a frequency band (1 to 16)

  • ensemble size

    Not a tuning knob — the published results depend on it, and a single model is measurably worse (18 to 180 models)

Convergence & cost

Stable and fast to train, which is a large part of the practical appeal: there is no recurrence to explode and no attention to saturate, so it converges reliably with default settings. Two characteristic failures. The first is silent capacity waste — later blocks in a deep stack receive a residual that is already near zero and learn nothing, which shows as validation loss flat in depth while parameter count grows, and is easy to miss if depth is never ablated. The second is the interpretable variant overfitting its own basis: a polynomial trend of degree five will happily extrapolate to implausible values at the far end of the horizon, because nothing in the loss penalizes a forecast that is smooth and absurd. The single-model versus ensemble gap is the third thing to watch, and it is a reporting failure rather than a training one.

O(L · d²) for the stack plus O(L · (H + W) · |θ|) for the basis projections, with W the lookback and H the horizon. No sequential dependency anywhere, so it parallelizes completely across time, batch and — because blocks are independent given their inputs — considerably within a forward pass. Multiply everything by the ensemble size, which is where the real cost sits.

Applications

How, where, and why it is used

Time-Series Forecasting

Primary fit
How

Feed a fixed-length window of past values and emit the whole horizon at once. Nothing else enters — no covariates, no calendar, no static features, just the series. Train one global model across every series in the panel with a scale-free loss, then ensemble over lookback lengths and losses.

Where
  • Large univariate panels where the series share structure but carry no useful covariates
  • Competition and benchmark forecasting, where it set the reference results on M4 and has been hard to displace
  • Baseline forecasting before committing to anything with covariates, since it establishes what pure history is worth
  • Long-horizon forecasting via N-HiTS, where multi-rate pooling is what keeps the cost tractable
Why

It is the right default when you have many series and no covariates worth having: it beats the statistical ensembles that won M4, trains in minutes, and has no sequence machinery to misconfigure. The pointed part of the finding is what it implies about everything else — if a transformer with attention and covariates cannot beat a stack of MLPs on your panel, the extra machinery is not earning its cost. Against it: it cannot use covariates at all, which is disqualifying wherever promotions or weather drive the signal; it emits a point forecast with no uncertainty; and the published accuracy assumes an ensemble, so a single model is a materially weaker thing than the papers describe.

Featurization
  • Use a scale-free loss (MASE or sMAPE) rather than MSE, or the largest series dominate the gradient and everything else is ignored
  • Set the lookback as a multiple of the horizon rather than an absolute length, which is what makes one global model work across periodicities
  • Vary the lookback across ensemble members deliberately — that diversity is where most of the ensemble gain comes from
  • Handle missing values before the window is built; the architecture has no mask and no way to express a gap
  • Predict every horizon directly, which the architecture does natively and which avoids compounding errors
Evaluation

Rolling-origin backtesting with MASE against seasonal-naive and against exponential smoothing, per horizon. Report single-model and ensemble numbers separately and always — conflating them is the most common way results in this literature become non-reproducible, and the gap is not small.

Pitfalls
  • Quoting ensemble accuracy while deploying a single model, which is a real and frequently unnoticed regression
  • Training with MSE on a mixed-scale panel, so the model optimizes the handful of largest series
  • Deep stacks whose later blocks receive a near-zero residual and contribute nothing but parameters
  • The interpretable trend basis extrapolating smoothly to implausible values at long horizons

Anomaly Detection

Adapted
How

Train on normal history and score each observation by its forecast residual, normalized per series. It works in the ordinary way a forecaster does — but with a specific and important weakness worth stating up front rather than discovering in production.

Where
  • Univariate metric monitoring where no covariates exist and the baseline is purely historical
  • Cheap large-scale monitoring across many series, where the training cost per series is what matters
  • Establishing a residual baseline before investing in a covariate-aware detector
Why

Honestly, this is the weakest featured fit in this entry, and the reason is structural rather than incidental: the model consumes no covariates, so it cannot know that today is a public holiday. A promotion or a bank holiday produces an enormous residual that is not an anomaly at all, and there is no way to tell the model otherwise. Against a quantile forecaster that consumes a calendar, this will generate far more false positives on exactly the days operators care about. It is also a point forecaster with no native uncertainty, so the threshold has to be manufactured from residual quantiles rather than predicted. Use it when there genuinely are no covariates and cost dominates; reach for something covariate-aware otherwise.

Featurization
  • Normalize residuals per series before thresholding, since the model is trained scale-free but the residuals are not
  • Maintain an explicit calendar exclusion list, because the model cannot be told about holidays through its inputs at all
  • Calibrate thresholds from residual quantiles on a held-out normal period, as there is no predicted interval to use
  • Score over a short window rather than a single step, since one-step residuals are dominated by noise
Evaluation

Precision and recall on labelled incidents with a detection window, and crucially a separate false-positive count on known event days — that number is the honest measure of this approach’s central weakness and it should be reported rather than averaged away.

Pitfalls
  • Holidays and promotions firing as anomalies, which is inherent here rather than fixable by tuning
  • A training period containing the incidents to be detected, which teaches the model they are normal
  • Assuming a residual threshold transfers across series when the residual scale does not
  • Treating this as equivalent to a covariate-aware detector in a comparison, which it is not

Optimization

Adapted
How

Two ideas with lives well outside forecasting. Basis expansion means the network predicts coefficients of a chosen function family rather than raw outputs, which constrains the solution to a low-dimensional smooth subspace — the same move as fitting splines instead of points, and a far stronger regularizer than any weight penalty. Doubly residual stacking is sequential refinement: each block fits the residual of its predecessors, which is exactly gradient boosting restated, and N-HiTS then adds multi-rate decomposition where different stacks operate at different sampling rates and their outputs are interpolated back and summed.

Where
  • Basis expansion as constrained function approximation — polynomial and Fourier bases as a structural prior on the output
  • Residual stacking as boosting expressed inside a differentiable network trained end to end
  • Multi-rate decomposition and interpolation, which is signal processing imported wholesale into a neural architecture
  • Ensembling over hyperparameters as the method rather than as a finishing step
Why

Worth studying because basis expansion is a genuinely transferable idea: constraining outputs to a well-chosen family gets you regularization and interpretability at once, and it is available in far more settings than people use it in. The residual-stacking equivalence to boosting is also clarifying — it says the architecture is a boosting schedule, which explains both why depth helps at first and why it stops helping. The ensemble finding is the uncomfortable one and deserves to be stated as an optimization result: the variance across hyperparameter settings is large enough that averaging over them is part of the method, which is a statement about the loss surface rather than about the architecture.

Featurization
  • Choose the basis to match known structure — polynomial for trend, Fourier for seasonality — and treat it as a prior, because that is what it is
  • Keep the polynomial degree low; past three it stops being a trend constraint and becomes a way to extrapolate absurdly
  • Vary lookback and loss across ensemble members rather than just the seed, since that is where the diversity actually comes from
  • Ablate depth explicitly, because later blocks receiving a near-zero residual cost parameters and contribute nothing
Evaluation

Compare a generic basis against the interpretable one on the same data: the accuracy gap tells you what the structural prior costs, and the interpretability gain tells you what it buys. Report single-model variance across seeds and lookbacks, which is the measurement that justifies the ensemble.

Pitfalls
  • Treating the ensemble as optional when the published results depend on it
  • A high-degree polynomial basis extrapolating smoothly and implausibly past the data
  • Adding depth without checking that the residual reaching later blocks is still informative
Also applied in

Control & Operations

Viable
How

Used as a cheap, reliable demand or load forecast feeding an operational plan, in settings where the relevant covariates either do not exist or are not available in time. Its speed is the operational argument: forecasting an entire panel takes minutes, so it fits inside a planning cycle without special infrastructure.

Where
  • High-volume operational forecasting where per-series cost dominates and covariates are unavailable
  • Baseline capacity planning against pure demand history
  • Backup or challenger forecasts running alongside a covariate-aware primary model
Why

The appeal is operational rather than statistical: it is fast, has nothing to misconfigure, and fails in obvious ways. The limits are the same ones as everywhere else in this entry and they bite harder here — planners know about next week’s promotion and the model cannot be told, and the plan needs a distribution while the model emits a point. In an operation with a real promotion calendar this is the wrong primary model, however good its benchmark numbers are.

Featurization
  • Match the forecast horizon to the decision lead time, since there is no mechanism to weight horizons differently
  • Manufacture intervals from historical residual quantiles, and label them clearly as such rather than as predicted uncertainty
  • Exclude known event periods from training or accept that the model will average them into the baseline
Evaluation

Regret against the hindsight-optimal plan alongside MASE. The comparison that matters is against a covariate-aware forecaster on the days covariates exist, because that is where the gap appears and where the decision cost is concentrated.

Pitfalls
  • No mechanism to consume planned actions, so the plan cannot inform the forecast that informs the plan
  • Point forecasts pushed into an optimizer that needs a distribution
  • Ensemble inference cost multiplying the serving budget in a way single-model benchmarks never show
Deployment

What it costs to run

Training

Minutes to an hour on a single GPU for a large panel — among the cheapest deep forecasters, because there is no recurrence and nothing sequential. The honest number multiplies by the ensemble size, which is where a benchmark-faithful deployment gets expensive.

Inference

One forward pass per series producing the whole horizon: microseconds, and trivially batched. Multiply by ensemble members, which turns a negligible cost into a merely small one but is the line item people forget when they size a deployment from single-model numbers.

Retraining

Monthly or quarterly. Because it consumes only history, there is no covariate schema to drift and retraining is a pure data refresh — genuinely simpler to operate than covariate-aware alternatives.

Drift & monitoring
  • Track MASE per series against seasonal-naive continuously; a series where the model stops beating naive should fall back to naive
  • Watch residual scale per series, since the thresholds any downstream detector uses are derived from it
  • Monitor the spread across ensemble members as a free uncertainty proxy — widening disagreement is an early signal
  • Check that later blocks still receive informative residuals after a retrain, since a shifted data distribution can leave them idle
Production gotchas
  • The lookback window is fixed at training time. A series with less history than the window cannot be forecast at all and needs an explicit fallback
  • No masking exists, so missing values must be handled before the window is built — a zero-fill reads as a genuine observation of zero
  • Ensemble members must be versioned together; swapping one changes the ensemble in a way no individual metric reveals
  • The scale-free loss normalizes training, not output — residuals come back on the original scale and any threshold must too
  • There is no path for covariates. Adding a promotion feature later is an architecture change, not a feature-pipeline change
Trade-offs

Pros and cons, in context

Strengths
  • Beats elaborate architectures with plain MLPs

    The finding that makes this entry worth studying: no recurrence, no attention, and it set the reference results on M4. It is the right first thing to try, and the right yardstick for whether a fancier model is earning its complexity.

  • Fully parallel — no sequential dependency anywhere

    Trains and serves dramatically faster than recurrent alternatives, and the whole horizon comes out in one pass. This is what makes a large ensemble affordable at all.

  • The interpretable variant genuinely decomposes

    Trend and seasonality stacks can be plotted separately, which is a real stakeholder benefit and rare in a deep model. It costs a little accuracy against the generic basis, and that trade is usually worth making explicit.

  • Almost nothing to misconfigure

    No gates, no attention, no covariate schema, no warmup. Compared with a transformer forecaster the operational surface is tiny, and that reliability is worth more in practice than a couple of percent of accuracy.

Limitations
  • Cannot consume covariates at all

    No promotions, no weather, no calendar, no static features. Disqualifying in retail and energy where those drive the signal, and not a gap that can be patched — it is an architecture change.

  • Point forecasts with no native uncertainty

    Inventory, capacity and risk decisions need a tail. Intervals have to be manufactured from historical residuals afterwards, which is strictly worse than training quantiles directly.

  • The published accuracy assumes an ensemble

    A single model is materially worse, and the gap is routinely omitted from comparisons. Anyone deploying one model while quoting ensemble numbers has a regression they have not measured.

  • Fixed lookback window excludes short series

    A series without a full window cannot be forecast, which in a growing catalogue is exactly the newest and most interesting items. Requires a separate cold-start path.

  • Depth silently stops paying

    Later blocks receive a near-zero residual and learn nothing while still costing parameters and inference time. Invisible unless depth is ablated deliberately, which it usually is not.

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.

"""N-BEATS, transcribed the way the paper reads.

One block does four things:

    theta       = MLP(input)              four ReLU layers, nothing else
    backcast    = V_b @ theta_b           what this block claims to explain
    forecast    = V_f @ theta_f           what it predicts
    next_input  = input - backcast        the residual the next block sees

Forecasts ADD and inputs have their explained part SUBTRACTED. That pair of
recursions is the whole architecture, and it is gradient boosting rearranged
into a neural network.

Plain loops, no libraries.
"""

import math


def relu(value):
    return value if value > 0.0 else 0.0


def matvec(weight, vector, bias):
    """weight is out_dim rows of in_dim."""
    return [
        bias[i] + sum(w * v for w, v in zip(row, vector))
        for i, row in enumerate(weight)
    ]


def mlp(vector, layers):
    """Four fully connected ReLU layers. The entire nonlinearity in N-BEATS."""
    hidden = vector
    for weight, bias in layers:
        hidden = [relu(value) for value in matvec(weight, hidden, bias)]
    return hidden


def generic_basis(length):
    """Identity: theta IS the output, one coefficient per position.

    Maximum flexibility, zero structure, and no interpretability at all.
    """
    return [[1.0 if i == j else 0.0 for j in range(length)] for i in range(length)]


def trend_basis(length, degree):
    """Polynomial in normalized time: V[t][p] = (t / length)^p.

    This is the point of basis expansion. The MLP predicts a handful of
    polynomial coefficients rather than every output value, so the output is
    forced to be a smooth low-order curve. That constrains the solution far
    more effectively than any weight penalty, and it is why this stack can be
    labelled "trend" and plotted.
    """
    return [
        [(index / length) ** power for power in range(degree + 1)]
        for index in range(length)
    ]


def seasonality_basis(length, harmonics):
    """Fourier: alternating cosine and sine at increasing frequencies.

    Same idea as the trend basis with a periodic family instead. The MLP
    predicts amplitudes; the basis guarantees the output is periodic.
    """
    rows = []
    for index in range(length):
        row = []
        for harmonic in range(1, harmonics + 1):
            angle = 2.0 * math.pi * harmonic * index / length
            row.append(math.cos(angle))
            row.append(math.sin(angle))
        rows.append(row)
    return rows


def project(basis, theta):
    """basis is length rows of coefficient_count."""
    return [
        sum(b * t for b, t in zip(row, theta))
        for row in basis
    ]


def block_forward(window, layers, theta_backcast_weight, theta_forecast_weight,
                  backcast_basis, forecast_basis):
    """One block: shared trunk, two heads, two basis projections."""
    hidden = mlp(window, layers)

    # The two heads predict COEFFICIENTS, not values. With a generic basis
    # that distinction is vacuous; with trend or seasonality it is everything.
    theta_backcast = matvec(theta_backcast_weight, hidden,
                            [0.0] * len(theta_backcast_weight))
    theta_forecast = matvec(theta_forecast_weight, hidden,
                            [0.0] * len(theta_forecast_weight))

    backcast = project(backcast_basis, theta_backcast)
    forecast = project(forecast_basis, theta_forecast)
    return backcast, forecast


def nbeats_forward(window, blocks):
    """Doubly residual stacking: forecasts add, inputs shrink."""
    residual = list(window)
    horizon = len(blocks[0]['forecast_basis'])
    total_forecast = [0.0] * horizon

    for block in blocks:
        backcast, forecast = block_forward(
            residual,
            block['layers'],
            block['theta_backcast_weight'],
            block['theta_forecast_weight'],
            block['backcast_basis'],
            block['forecast_basis'],
        )
        # Subtract what this block explained. A later block that receives a
        # near-zero residual has nothing left to learn - which is exactly
        # how depth stops paying, and it is invisible unless you look.
        residual = [r - b for r, b in zip(residual, backcast)]
        total_forecast = [t + f for t, f in zip(total_forecast, forecast)]

    return total_forecast, residual


def mase(forecast, actual, history, seasonal_period):
    """Mean absolute scaled error.

    The denominator is the in-sample error of a seasonal-naive forecast, so
    the loss is comparable across series of wildly different scale. With a
    plain MSE a global model trained across a panel optimizes only the
    largest series and ignores everything else.
    """
    denominator = 0.0
    counted = 0
    for index in range(seasonal_period, len(history)):
        denominator += abs(history[index] - history[index - seasonal_period])
        counted += 1
    if counted == 0 or denominator == 0.0:
        raise ValueError("history is too short to scale the loss")
    denominator /= counted

    numerator = sum(abs(f - a) for f, a in zip(forecast, actual)) / len(forecast)
    return numerator / denominator


def ensemble_forecast(window, model_list):
    """Not a refinement. The published results average dozens of models
    trained with different lookback windows and losses, and a single model is
    materially worse - so the ensemble is part of the method."""
    horizon = None
    accumulated = None
    for blocks in model_list:
        forecast, _ = nbeats_forward(window, blocks)
        if accumulated is None:
            horizon = len(forecast)
            accumulated = [0.0] * horizon
        for h in range(horizon):
            accumulated[h] += forecast[h]
    return [value / len(model_list) for value in accumulated]

O(L * d^2) for the stack plus O(L * (W + H) * |theta|) for the basis projections, times the ensemble size. Illustrative, not a measured benchmark: every matvec allocates, and the basis matrices are rebuilt per call even though they are fixed constants — both of which the next stages fix.

2. Idiomatic

How this language actually wants it written.

"""The same architecture, with the basis as a type and the windows checked.

Two changes matter. The basis matrices are fixed constants determined by the
horizon and the basis choice, so they are built once at construction rather
than per forward pass. And the preconditions that silently produce nonsense -
a series shorter than the lookback, a polynomial degree high enough to
extrapolate absurdly, an ensemble quoted but not built - become explicit.
"""

from __future__ import annotations

import math
from dataclasses import dataclass
from enum import Enum
from typing import NamedTuple, Sequence


class BasisKind(Enum):
    """What family the block's output is constrained to lie in."""

    GENERIC = 'generic'
    TREND = 'trend'
    SEASONALITY = 'seasonality'


class InsufficientHistory(ValueError):
    """Raised when a series is shorter than the fixed lookback window.

    Its own type because the correct response is specific: fall back to a
    naive forecast for that series. The architecture cannot forecast it at
    all, and there is no partial-window path.
    """


class ShapeMismatch(ValueError):
    """Raised on a shape violation instead of broadcasting past it."""


class ImplausibleBasis(ValueError):
    """Raised for a basis that will extrapolate absurdly.

    A degree-6 polynomial fits history beautifully and reaches implausible
    values at the far end of the horizon, and nothing in the loss penalizes
    that. Refusing it is cheaper than discovering it in production.
    """


@dataclass(frozen=True)
class BasisSpec:
    """Frozen, because a basis is a structural prior, not a tunable."""

    kind: BasisKind
    degree: int = 2        # polynomial degree for TREND
    harmonics: int = 4     # harmonic count for SEASONALITY

    def __post_init__(self) -> None:
        if self.kind is BasisKind.TREND and not 1 <= self.degree <= 4:
            raise ImplausibleBasis(
                f'polynomial degree {self.degree} extrapolates unusably; keep it at 4 or below'
            )
        if self.kind is BasisKind.SEASONALITY and self.harmonics < 1:
            raise ShapeMismatch('at least one harmonic is required')

    def coefficient_count(self, length: int) -> int:
        """How many numbers the MLP head must emit for this basis."""
        if self.kind is BasisKind.GENERIC:
            return length
        if self.kind is BasisKind.TREND:
            return self.degree + 1
        return 2 * self.harmonics

    def build(self, length: int) -> list[list[float]]:
        """The basis matrix: length rows of coefficient_count.

        Built once and reused. It depends only on the length and the spec,
        so rebuilding it per forward pass is pure waste.
        """
        if length <= 0:
            raise ShapeMismatch('basis length must be positive')

        if self.kind is BasisKind.GENERIC:
            return [
                [1.0 if i == j else 0.0 for j in range(length)]
                for i in range(length)
            ]

        if self.kind is BasisKind.TREND:
            # Normalized time, so the coefficients mean the same thing
            # whatever the horizon is.
            return [
                [(index / length) ** power for power in range(self.degree + 1)]
                for index in range(length)
            ]

        rows: list[list[float]] = []
        for index in range(length):
            row: list[float] = []
            for harmonic in range(1, self.harmonics + 1):
                angle = 2.0 * math.pi * harmonic * index / length
                row.extend((math.cos(angle), math.sin(angle)))
            rows.append(row)
        return rows


@dataclass(frozen=True)
class StackConfig:
    """The shape contract, stated once and derived from rather than repeated."""

    lookback: int
    horizon: int
    hidden_width: int = 512
    layers: int = 4
    blocks: int = 3

    def __post_init__(self) -> None:
        if self.horizon < 1:
            raise ShapeMismatch('horizon must be at least one step')
        if self.lookback < self.horizon:
            raise ShapeMismatch('a lookback shorter than the horizon cannot work')

    @property
    def lookback_multiplier(self) -> float:
        """The strongest single lever, and the main source of ensemble
        diversity — so it is worth naming rather than leaving implicit."""
        return self.lookback / self.horizon


class BlockOutput(NamedTuple):
    """Both halves. The backcast is not a by-product — it is what makes the
    residual recursion work, and discarding it breaks the architecture."""

    backcast: list[float]
    forecast: list[float]


class Block:
    """One N-BEATS block: shared MLP trunk, two coefficient heads, two bases."""

    def __init__(
        self,
        config: StackConfig,
        backcast_spec: BasisSpec,
        forecast_spec: BasisSpec,
    ) -> None:
        self._config = config
        # Built once at construction: these are constants of the geometry.
        self._backcast_basis = backcast_spec.build(config.lookback)
        self._forecast_basis = forecast_spec.build(config.horizon)
        self._backcast_coefficients = backcast_spec.coefficient_count(config.lookback)
        self._forecast_coefficients = forecast_spec.coefficient_count(config.horizon)

        self._layers: list[tuple[list[list[float]], list[float]]] = []
        width = config.lookback
        for _ in range(config.layers):
            self._layers.append((
                [[0.0] * width for _ in range(config.hidden_width)],
                [0.0] * config.hidden_width,
            ))
            width = config.hidden_width

        self._backcast_head = [[0.0] * width for _ in range(self._backcast_coefficients)]
        self._forecast_head = [[0.0] * width for _ in range(self._forecast_coefficients)]

    def forward(self, window: Sequence[float]) -> BlockOutput:
        if len(window) != self._config.lookback:
            raise ShapeMismatch(
                f'expected a {self._config.lookback}-step window, got {len(window)}'
            )

        hidden: Sequence[float] = window
        for weight, bias in self._layers:
            hidden = [
                max(0.0, b + sum(w * v for w, v in zip(row, hidden)))
                for row, b in zip(weight, bias)
            ]

        # The heads predict COEFFICIENTS, not values. With a generic basis
        # that is vacuous; with trend or seasonality it is the whole point.
        theta_backcast = [sum(w * v for w, v in zip(row, hidden)) for row in self._backcast_head]
        theta_forecast = [sum(w * v for w, v in zip(row, hidden)) for row in self._forecast_head]

        return BlockOutput(
            backcast=[
                math.fsum(b * t for b, t in zip(row, theta_backcast))
                for row in self._backcast_basis
            ],
            forecast=[
                math.fsum(b * t for b, t in zip(row, theta_forecast))
                for row in self._forecast_basis
            ],
        )


class ResidualTrace(NamedTuple):
    """Forecast plus the residual norm after each block.

    The norms are returned deliberately: a block whose incoming residual is
    already near zero contributes parameters and inference time and learns
    nothing, and that is invisible unless it is measured.
    """

    forecast: list[float]
    residual_norms: list[float]


def forward(window: Sequence[float], blocks: Sequence[Block], horizon: int) -> ResidualTrace:
    """Doubly residual stacking: forecasts add, inputs shrink."""
    if not blocks:
        raise ShapeMismatch('a stack needs at least one block')

    residual = list(window)
    total = [0.0] * horizon
    norms: list[float] = []

    for block in blocks:
        output = block.forward(residual)
        residual = [r - b for r, b in zip(residual, output.backcast)]
        total = [t + f for t, f in zip(total, output.forecast)]
        norms.append(math.sqrt(math.fsum(value * value for value in residual)))

    return ResidualTrace(forecast=total, residual_norms=norms)


def mase(
    forecast: Sequence[float],
    actual: Sequence[float],
    history: Sequence[float],
    seasonal_period: int,
) -> float:
    """Mean absolute scaled error.

    Guard clauses first: a flat series gives a zero denominator, and a short
    one gives no denominator at all. Both are real and both would otherwise
    produce an infinity that poisons the batch average silently.
    """
    if len(forecast) != len(actual):
        raise ShapeMismatch('forecast and actual lengths disagree')
    if len(history) <= seasonal_period:
        raise InsufficientHistory('history is shorter than one seasonal period')

    differences = [
        abs(history[i] - history[i - seasonal_period])
        for i in range(seasonal_period, len(history))
    ]
    denominator = math.fsum(differences) / len(differences)
    if denominator == 0.0:
        raise ShapeMismatch('a constant history cannot scale the loss')

    numerator = math.fsum(abs(f - a) for f, a in zip(forecast, actual)) / len(forecast)
    return numerator / denominator


def window_or_fail(series: Sequence[float], config: StackConfig) -> list[float]:
    """The fixed lookback is structural. A series shorter than it is not
    forecastable by this architecture at all, and needs a naive fallback."""
    if len(series) < config.lookback:
        raise InsufficientHistory(
            f'{len(series)} observations is short of the {config.lookback}-step window'
        )
    return list(series[-config.lookback:])

What changed: Two changes carry the weight. The basis matrices depend only on the length and the spec, so they are built once at construction rather than rebuilt on every forward pass — and making the basis a frozen spec rather than a function argument reflects what it actually is, a structural prior rather than a tunable. The other change is refusing the failures that otherwise produce plausible nonsense: a polynomial degree above four fits history beautifully and extrapolates to implausible values at the far end of the horizon with nothing in the loss to penalize it, so the constructor refuses it; a series shorter than the fixed lookback is not forecastable by this architecture at all, so it raises a specific exception whose correct handling — fall back to naive for that series — is named in the docstring; a constant or too-short history gives MASE a zero or missing denominator, which would otherwise produce an infinity that silently poisons a batch average. The forward pass also returns the residual norm after each block, because a block receiving a near-zero residual costs parameters and inference time while learning nothing, and that is invisible unless it is measured.

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

Same asymptotics, with the basis construction hoisted out of the hot path entirely. Illustrative, not a measured benchmark: on a generic basis that is an O(W^2) matrix no longer rebuilt per call, which on a 500-step lookback is the single largest waste in the literal version.

3. OptimizedNumPy

Faster, and the justification for each change.

"""Batched, and the whole stack is GEMMs. There is nothing sequential here.

The structural observation: N-BEATS has no recurrence and no attention, so
every layer is a dense matrix product and the only ordering constraint is
between blocks. That makes it the easiest forecaster in this reference to
make fast, and it is why an ensemble of thirty models is affordable at all.

Three changes:
  1. The MLP trunk, both coefficient heads and both basis projections become
     GEMMs over the whole batch. The bases are CONSTANT matrices, so they are
     built once and multiplied, never regenerated.
  2. The two heads share one GEMM: their outputs are concatenated columns of
     a single weight, so the trunk activations are read once rather than twice.
  3. The ensemble batches across models rather than looping. Members share a
     shape, so a (members, batch, lookback) tensor flows through as one
     batched contraction - which is what makes the ensemble cheap enough to
     be the method rather than a luxury.
"""

from __future__ import annotations

import numpy as np
from numpy.typing import NDArray

FLOAT = np.float32


def build_trend_basis(length: int, degree: int) -> NDArray[np.float32]:
    """(length, degree + 1) of normalized-time powers. A constant."""
    time = (np.arange(length, dtype=FLOAT) / FLOAT(length))[:, None]
    powers = np.arange(degree + 1, dtype=FLOAT)[None, :]
    return np.power(time, powers, dtype=FLOAT)


def build_seasonality_basis(length: int, harmonics: int) -> NDArray[np.float32]:
    """(length, 2 * harmonics) of interleaved cosines and sines. A constant."""
    time = np.arange(length, dtype=FLOAT)[:, None]
    frequency = np.arange(1, harmonics + 1, dtype=FLOAT)[None, :]
    angle = FLOAT(2.0 * np.pi) * frequency * time / FLOAT(length)

    basis = np.empty((length, 2 * harmonics), dtype=FLOAT)
    # Interleaved by strided assignment rather than by concatenate-and-
    # reorder, so no intermediate is materialized.
    basis[:, 0::2] = np.cos(angle)
    basis[:, 1::2] = np.sin(angle)
    return basis


class BatchedBlock:
    """One block, evaluated for a whole batch at once.

    The bases are stored as contiguous constants. A basis projection is then
    a GEMM against a fixed matrix — which is the cheapest possible way to
    impose the structural prior, and a good reason to prefer this form over
    generating the output values directly.
    """

    def __init__(
        self,
        trunk_weights: list[NDArray[np.float32]],
        trunk_biases: list[NDArray[np.float32]],
        head_weight: NDArray[np.float32],
        backcast_basis: NDArray[np.float32],
        forecast_basis: NDArray[np.float32],
    ) -> None:
        self._trunk_weights = [np.ascontiguousarray(w, dtype=FLOAT) for w in trunk_weights]
        self._trunk_biases = [np.ascontiguousarray(b, dtype=FLOAT) for b in trunk_biases]
        # ONE head weight whose columns are backcast coefficients followed by
        # forecast coefficients: the trunk output is read once, not twice.
        self._head_weight = np.ascontiguousarray(head_weight, dtype=FLOAT)
        self._backcast_basis = np.ascontiguousarray(backcast_basis, dtype=FLOAT)
        self._forecast_basis = np.ascontiguousarray(forecast_basis, dtype=FLOAT)
        self._split = backcast_basis.shape[1]

    def __call__(
        self, window: NDArray[np.float32]
    ) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
        """window is (batch, lookback). Returns (backcast, forecast)."""
        hidden = window
        for weight, bias in zip(self._trunk_weights, self._trunk_biases):
            hidden = hidden @ weight
            hidden += bias
            # In-place ReLU: the trunk is the widest tensor in the block and
            # a temporary per layer is the largest avoidable allocation.
            np.maximum(hidden, 0.0, out=hidden)

        theta = hidden @ self._head_weight
        backcast = theta[:, :self._split] @ self._backcast_basis.T
        forecast = theta[:, self._split:] @ self._forecast_basis.T
        return backcast, forecast


def stack_forward(
    window: NDArray[np.float32],
    blocks: list[BatchedBlock],
) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
    """Doubly residual stacking. Both recursions run in place.

    Returns the summed forecast and the per-block residual norms, which are
    the diagnostic for depth that has stopped paying.
    """
    if not blocks:
        raise ValueError('a stack needs at least one block')

    residual = window.copy()
    total: NDArray[np.float32] | None = None
    norms = np.empty((len(blocks), window.shape[0]), dtype=FLOAT)

    for index, block in enumerate(blocks):
        backcast, forecast = block(residual)
        # Both recursions in place: the residual shrinks and the forecast
        # accumulates without either allocating per block.
        residual -= backcast
        if total is None:
            total = forecast
        else:
            total += forecast
        norms[index] = np.linalg.norm(residual, axis=1)

    return total, norms


def ensemble_forward(
    windows: NDArray[np.float32],
    stacked_trunk: NDArray[np.float32],
    stacked_bias: NDArray[np.float32],
) -> NDArray[np.float32]:
    """One layer of every ensemble member at once.

    windows is (members, batch, lookback), stacked_trunk is
    (members, lookback, hidden). matmul broadcasts over the leading axis, so
    all members advance in a single batched GEMM rather than a Python loop -
    which is what turns a thirty-model ensemble from a luxury into the
    default configuration.
    """
    hidden = np.matmul(windows, stacked_trunk)
    hidden += stacked_bias[:, None, :]
    np.maximum(hidden, 0.0, out=hidden)
    return hidden


def mase(
    forecasts: NDArray[np.float32],
    actuals: NDArray[np.float32],
    histories: NDArray[np.float32],
    seasonal_period: int,
) -> NDArray[np.float32]:
    """Per-series MASE, vectorized over the batch.

    The seasonal-naive denominator is a single strided difference over the
    history axis — no loop, and no per-series Python at all.
    """
    if histories.shape[1] <= seasonal_period:
        raise ValueError('history is shorter than one seasonal period')

    seasonal_error = np.abs(histories[:, seasonal_period:] - histories[:, :-seasonal_period])
    denominator = seasonal_error.mean(axis=1, dtype=np.float32)
    # A constant series gives a zero denominator, which would produce an
    # infinity that silently poisons the batch average.
    if np.any(denominator == 0.0):
        raise ValueError('a constant history cannot scale the loss')

    numerator = np.abs(forecasts - actuals).mean(axis=1, dtype=np.float32)
    return numerator / denominator


def ensemble_spread(member_forecasts: NDArray[np.float32]) -> NDArray[np.float32]:
    """Disagreement across members, as a free uncertainty proxy.

    The model emits no interval, but a thirty-member ensemble already
    contains one. It is not calibrated and should not be presented as a
    predictive distribution — but it is a genuinely useful drift signal.
    """
    return member_forecasts.std(axis=0, dtype=np.float32)

What changed: The structural point is that N-BEATS has nothing sequential in it, so every layer is a dense product and the only ordering constraint is between blocks — which makes it the easiest forecaster here to accelerate and explains why a thirty-model ensemble is affordable at all. The basis matrices are constants of the geometry, so they are built once and each projection becomes a GEMM against a fixed matrix, which is the cheapest possible way to impose the structural prior. The two coefficient heads are merged into a single weight whose columns are backcast coefficients followed by forecast coefficients, so the trunk activations are read once rather than twice, and the ReLU runs in place because the trunk is the widest tensor in the block. Both residual recursions run in place, so neither the shrinking input nor the accumulating forecast allocates per block. The ensemble is the largest change: members share a shape, so stacking them into a leading axis lets matmul broadcast and advance every member in one batched product instead of a Python loop over models. The per-block residual norms and the ensemble spread are computed and returned rather than discarded, since one diagnoses depth that has stopped paying and the other is the only uncertainty signal this architecture offers.

  • Vectorize to NumPy/BLAS (@, np.dot, einsum)

    The trunk, the merged head and both basis projections are all GEMMs over the whole batch, and the ensemble becomes a batched matmul broadcasting over the member axis.

    Trade-off: The generic basis is an identity matrix, so projecting through it is a full GEMM that computes nothing — a real waste that only a special case would avoid, at the cost of branching in the hot path.

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

    The ReLU, both residual recursions and the forecast accumulation all write into buffers that already exist, so a deep stack does not allocate once per block.

    Trade-off: The residual buffer is destroyed as it is consumed, so inspecting what a specific block actually subtracted — the natural question when a stack stops improving — requires an unfused pass.

  • Batch work to amortize interpreter overhead

    Series, horizons and ensemble members all become array axes, so a full ensemble forecast for a whole panel costs a constant number of interpreter operations.

    Trade-off: Every ensemble member must share a shape to stack, which forbids exactly the lookback diversity the ensemble depends on for its gain — so members are grouped by lookback and the batching is only within a group.

  • Ensure contiguous memory layout and a single dtype

    float32 weights and bases forced to standard layout at construction keep every product on the BLAS fast path rather than silently copying a strided operand.

    Trade-off: float32 accumulation across a deep residual stack drifts, and because each block subtracts from the same buffer the error compounds along the stack rather than cancelling.

The whole stack becomes a handful of GEMMs per block for the entire batch, and an ensemble advances in one batched product per layer. Illustrative, not a measured benchmark: with no sequential dependency this saturates BLAS readily, which is why an ensemble that would be prohibitive for a recurrent model is routine here.