Back to Generative AI
AI/ML · Generative AI

Time-Series Diffusion & Foundation Models

Two ideas arriving at the same time. The first: a forecast and an imputation are the same operation — some values are known and some are not, and a conditional generative model fills in the unknown ones. Mask the future and you get forecasting; mask the middle and you get imputation; the model does not distinguish. The second: a model pretrained on millions of unrelated series learns what series look like in general, so it can forecast one it has never seen without being fitted to it. That is a genuinely new capability for forecasting, and it is also the one that most needs careful evaluation, because the corpus is enormous and the benchmarks are public.

Self-supervisedSupervisedGenerationRegressionSequence modelingAnomaly detection

Self-supervised is the interesting half: a foundation forecaster is pretrained on a corpus of unrelated series by predicting masked or future segments, with no task labels anywhere, and is then applied zero-shot to a series it has never seen. Supervised appears because fine-tuning on a target panel is the common and usually better deployment, and because the diffusion variants are trained on explicit history-to-horizon pairs.

Also known as: TimeGrad, CSDI, TSDiff, Chronos, TimesFM, Moirai, Lag-Llama, Zero-shot forecasting

Intuition

What it actually does

Two ideas arriving at the same time. The first: a forecast and an imputation are the same operation — some values are known and some are not, and a conditional generative model fills in the unknown ones. Mask the future and you get forecasting; mask the middle and you get imputation; the model does not distinguish. The second: a model pretrained on millions of unrelated series learns what series look like in general, so it can forecast one it has never seen without being fitted to it. That is a genuinely new capability for forecasting, and it is also the one that most needs careful evaluation, because the corpus is enormous and the benchmarks are public.

Assumptions it makes
  • Series in the pretraining corpus are similar enough to the target that the learned structure transfers — the load-bearing assumption for anything zero-shot
  • Per-window scaling makes series of wildly different magnitude comparable to a single model
  • The forecast horizon and context fit inside the pretrained window, since neither can be exceeded
  • Inference compute is available at panel scale, which is the constraint that most often makes this infeasible
  • The evaluation data genuinely postdates the pretraining corpus, which is difficult to establish and frequently assumed
Objective

Loss function

Read the mask as the whole idea. The loss is applied only where the mask says a value is being predicted, and the observed values enter as conditioning rather than as targets — so the identical objective trains a forecaster when the mask covers the future and an imputer when it covers gaps. Training randomizes the mask, which means one model handles both and handles arbitrary missingness patterns without a separate design. The notational hazard is worth naming because it causes real bugs: t here is the diffusion step, not the position in the series, and the two index completely different axes.

the target mask: which positions are being predicted. Forecasting and imputation differ only in this
the observed values, supplied as conditioning at every denoising step rather than only at the start
the denoiser, conditioned on both what is observed and which positions are being asked for
the diffusion timestep — unrelated to the series time index, and confusing them is a real source of bugs
Optimization

How it is minimized

Adam on the masked noise-prediction loss, with randomized masks during training and the observed values re-imposed at every sampling step

the observed positions re-noised to the current level and re-imposed — the step everyone forgets
only the masked positions take the model’s prediction
per-window normalization, which for a foundation model is what makes one model span every series scale

The sampling detail that matters is re-imposing the observed values at every step rather than only at initialization. If the known positions are fixed once and then left alone, the denoiser drifts away from them as it works — it is filling a canvas and has no reason to respect a region it was not asked about — so the reconstruction ends up inconsistent with the data you actually had. Re-noising the observed values to the current noise level and substituting them back at every step keeps the trajectory anchored, and it is the single most commonly omitted step in implementations. For the foundation-model half, the operationally important detail is per-window scaling: a pretraining corpus spans many orders of magnitude, and normalizing each window before the model sees it is what allows a single set of weights to serve a series measured in units and one measured in millions. That scaler is part of the inference path, not preprocessing.

Hyperparameters
  • mask strategy

    What the training masks look like. It determines which tasks the model can do; a forecast-only mask gives a model that imputes badly (random / block / forecast)

  • sampling steps

    The quality-versus-latency dial, multiplied by trajectories per series and series in the panel — which is where the cost problem lives (10 to 100)

  • trajectories per series

    How many samples define the predictive distribution. Too few and tail quantiles are noise (50 to 500)

  • context length

    History the model conditions on. Foundation models fix this at pretraining and it cannot be exceeded (96 to 2048 steps)

  • scaling strategy

    Per-window normalization. What lets one model span every magnitude, and part of the inference path rather than preprocessing (mean / standard / robust)

  • fine-tune versus zero-shot

    A compute-allocation decision rather than a modelling one; fine-tuning almost always wins if any target data exists (zero-shot / LoRA / full)

Convergence & cost

Training inherits diffusion’s good behaviour — one regression loss against a known target, stable, no equilibrium. The difficulties are in evaluation rather than optimization, and they are serious enough to be the main thing worth knowing. Foundation forecasters are pretrained on corpora assembled from public sources, and the standard forecasting benchmarks are public, so contamination is a live possibility that is genuinely hard to rule out — a zero-shot result on a public benchmark may not be zero-shot at all. Separately, the headline comparisons frequently omit properly tuned classical baselines, and seasonal-naive, exponential smoothing and a well-configured gradient booster remain competitive on a large fraction of real panels. Neither point means the models are not useful; both mean that a claimed improvement needs to be re-verified on your own data before it is believed.

Training is O(1) network evaluations per example. Inference is the problem: steps times trajectories times series. A hundred trajectories at fifty steps across ten thousand series is fifty million network evaluations for one forecast cycle, which is a different operational regime from every classical forecaster and is the constraint that decides feasibility.

Applications

How, where, and why it is used

Time-Series Forecasting

Primary fit
How

For the diffusion half: condition on the observed history and mask the horizon, then sample trajectories that form the predictive distribution. For the foundation half: pass the history to a pretrained model and read the forecast, with no fitting at all — or fine-tune on the target panel if any of it exists, which almost always helps.

Where
  • Cold-start forecasting for a series with too little history to fit anything, where zero-shot is the only option
  • Imputation and forecasting from one model, since the mask is the only difference between them
  • Panels with many short series, where pretrained structure substitutes for the data each series lacks
  • Rapid baselining — a foundation model gives a non-trivial forecast in minutes, which is genuinely useful as a starting point
Why

Zero-shot forecasting is a real new capability and the cold-start case is where it is unambiguously valuable: a series with thirty observations cannot support a fitted model, and a pretrained one produces something reasonable immediately. Unifying forecasting and imputation is the diffusion half’s genuine contribution, because ragged real-world panels need both and maintaining two models is a real cost. But the claims need care. Contamination between pretraining corpora and public benchmarks is difficult to exclude, published comparisons frequently omit tuned classical baselines, and inference cost is orders of magnitude above an ARIMA fit. The honest position is that these are excellent for cold start and rapid baselining, competitive after fine-tuning, and not automatically better than a well-tuned conventional model on a panel you have data for.

Featurization
  • Scale per window, since that is what lets one model span magnitudes — and persist the scaler, because it is part of inference rather than preprocessing
  • Randomize masks during training so one model handles forecasting and arbitrary missingness rather than only the pattern it saw
  • Re-impose observed values at every sampling step, not only at initialization; omitting this is the most common implementation error
  • Fine-tune if any target data exists at all — the gain over zero-shot is usually large and the cost is small
  • Respect the pretrained context length, which cannot be exceeded and silently truncates if you try
Evaluation

Rolling-origin backtesting against seasonal-naive, exponential smoothing and a tuned gradient booster — all three, because the published comparisons frequently omit them and they win more often than the headline results suggest. For any zero-shot claim, verify on data that provably postdates the pretraining corpus, since contamination on public benchmarks cannot otherwise be excluded. Report step count and trajectory count with every number.

Pitfalls
  • Accepting a published zero-shot benchmark result without checking for corpus contamination
  • Skipping the classical baselines, which are competitive far more often than the literature implies
  • Not re-imposing observed values at every sampling step, so the reconstruction drifts from the known data
  • Inference cost making a large panel infeasible within the forecasting cycle
  • Exceeding the pretrained context length, which truncates silently

Anomaly Detection

Adapted
How

Mask a window and ask the model to reconstruct it from its surroundings, then compare. Because the model is conditioned on both sides rather than only the past, this is a stronger reconstruction than a forecaster’s — and the sampled distribution gives a calibrated notion of how surprising the observed value is rather than just a residual.

Where
  • Retrospective anomaly detection where future context is available and should be used
  • Detecting anomalies in the middle of a record rather than at its end
  • Multivariate detection where one channel can be reconstructed from the others
  • Settings needing a predictive distribution rather than a point residual for the score
Why

Two-sided conditioning is a genuine advantage over a forecaster-based detector, since a value that is unremarkable given the past and implausible given what followed is exactly the case a one-sided model misses. The sampled distribution also gives a principled score rather than a thresholded residual. Against it: this is retrospective by construction, so it cannot detect in real time; sampling cost makes scoring expensive per window; and if the pretraining corpus contained similar anomalies the model will reconstruct them faithfully, which is a failure mode specific to pretrained models and does not exist for a model fitted only on your normal data.

Featurization
  • Mask a window wider than the anomaly you expect, or the model reconstructs the anomaly from its own edges
  • Score by position in the sampled distribution rather than by distance to the mean, which is the advantage of having a distribution at all
  • Use a deterministic sampler so the same window gives the same score between runs
  • Be explicit that this is retrospective; it is not a streaming detector and cannot be made into one
Evaluation

Precision and recall on labelled incidents with a detection window, against a forecast-residual detector on the same data — that comparison isolates what two-sided conditioning contributes. Report scoring latency per window, which is what decides deployability.

Pitfalls
  • Treating it as a streaming detector when it structurally requires future context
  • Masks too narrow, so the anomaly is reconstructed from its own surroundings
  • A pretrained model reconstructing anomalies it saw during pretraining
  • Sampling cost making per-window scoring impractical at panel scale

Optimization

Viable
How

The interesting optimization question here is not the training but the allocation. Inference cost is steps times trajectories times series, and all three are dials; the quality obtained per unit of compute differs sharply between spending it on more sampling steps, more trajectories, or fine-tuning the model instead. Fine-tuning in particular is a compute-allocation decision rather than a modelling one, and a small amount of it usually beats a large amount of extra sampling.

Where
  • Allocating a fixed inference budget across steps, trajectories and series
  • Fine-tuning versus zero-shot as a compute trade, where a little adaptation usually beats more sampling
  • Parameter-efficient adaptation, getting most of the fine-tuning benefit for a fraction of the cost
  • Deciding sampling depth per series by value rather than uniformly, since not every series justifies the same budget
Why

Worth studying because the levers are unusually explicit and the right answer is usually counterintuitive: given a fixed budget, most people spend it on sampling steps, and most of the time a modest fine-tune plus fewer steps wins. The per-series allocation question is also real and rarely asked — a panel has a long tail of low-value series that do not justify a hundred trajectories, and spending uniformly across them is straightforwardly wasteful. What this is not is a solver: the forecast feeds a downstream decision, and treating the sampling budget as the only optimization available misses that the decision itself has structure.

Featurization
  • Measure quality against inference cost across steps, trajectories and fine-tuning separately, since they trade very differently
  • Prefer parameter-efficient fine-tuning, which captures most of the benefit at a small fraction of the cost
  • Allocate trajectories by decision value rather than uniformly across the panel
  • Cache and reuse forecasts where the decision cadence is slower than the data cadence
Evaluation

Plot forecast quality against total inference cost for each allocation, which is the actual result — a single accuracy number at an unstated budget says nothing about whether the budget was well spent. Compare against the cost of simply fitting a classical model per series, which is frequently a fraction of the sampling cost.

Pitfalls
  • Spending the whole budget on sampling steps when a small fine-tune would have bought more
  • Uniform allocation across a panel with a long tail of low-value series
  • Ignoring that a per-series classical fit may cost less than one sampled forecast
Also applied in

Control & Operations

Viable
How

Sampled trajectories feed an operational plan as a scenario set, exactly as with any probabilistic forecaster — with the difference that a pretrained model produces them for a newly instrumented asset with no history, which is precisely when a plan is hardest to make.

Where
  • Planning for new assets, sites or products with no operating history
  • Rapid scenario generation when a new situation arises faster than a model can be fitted
  • Panels with many short-lived entities where per-entity fitting is impractical
Why

The cold-start argument carries directly into operations and is the strongest case: a new site has no history, a plan is still needed, and a pretrained forecaster gives something defensible immediately. Against it, the constraints are the familiar ones sharpened by an operational context — inference cost inside a short planning cycle, a pretrained model with no knowledge of your specific operational dynamics, and no error bound anywhere. Treat it as a starting point that is replaced as real data accumulates, rather than as a permanent component.

Featurization
  • Match the horizon to the decision lead time; sampled steps beyond it are pure cost
  • Fine-tune as soon as any operating history exists, since the gain is immediate
  • Budget inference explicitly against the planning cycle, which is the constraint that usually binds
Evaluation

Regret against the hindsight-optimal plan, and a scheduled re-comparison against a conventional model fitted on the accumulating data — the point at which the conventional model wins is the point to switch.

Pitfalls
  • Keeping the pretrained model after enough data exists to fit a better conventional one
  • Sampling cost breaking a short planning cycle
  • No error bound in a setting where one is expected

Risk & Fraud

Viable
How

Forecasting risk aggregates — claim volumes, transaction counts, exposure — where the sampled distribution’s tail drives a reserve or capacity decision, and where a pretrained model covers segments too small to fit individually.

Where
  • Tail forecasting for reserving, where the upper quantile is the decision-relevant quantity
  • Long-tail segment forecasting where per-segment fitting is impractical
  • Rapid scenario generation for stress testing
Why

Covering a long tail of small segments without fitting each one is a real operational saving, and the sampled distribution gives the tail directly rather than through an assumed error model. The caveats are heavier here than elsewhere. A pretrained model has no knowledge of your specific risk dynamics and cannot be assumed to have learned them from unrelated series; far-tail quantiles need many trajectories to estimate stably, which multiplies an already high cost; and regulated contexts generally require model explainability and validation evidence that a large pretrained forecaster cannot easily provide. This belongs as a complement rather than as a system of record.

Featurization
  • Draw substantially more trajectories than the default when a far-tail quantile drives the decision
  • Forecast at the aggregation level the decision is made at, since entity-level noise swamps the signal
  • Validate tail coverage explicitly on held-out periods, which is the only part of the distribution the decision uses
  • Document provenance and validation carefully, since a pretrained model in a regulated context attracts scrutiny
Evaluation

Quantile loss at the decision-relevant levels plus empirical exceedance against nominal, backtested strictly by time. A reserving model validated on a random split is meaningless, and a pretrained one additionally needs evidence that the evaluation period postdates the pretraining corpus.

Pitfalls
  • Assuming pretraining on unrelated series transfers to your specific risk dynamics
  • Far-tail quantiles estimated from too few trajectories, so the reserve moves with the seed
  • Regulatory validation requirements that a large pretrained model cannot readily satisfy
Deployment

What it costs to run

Training

Pretraining is a large-scale undertaking that essentially nobody repeats — the practical decision is which pretrained model to start from. Fine-tuning is hours, and parameter-efficient fine-tuning is less, which makes adaptation the affordable half by a wide margin.

Inference

The binding constraint, and the number that decides feasibility: steps times trajectories times series. A hundred trajectories at fifty steps across ten thousand series is fifty million network evaluations per cycle. Every deployment design for this family starts from that arithmetic rather than arriving at it.

Retraining

The base model is replaced rather than retrained. Fine-tuned adapters are refreshed as the target data accumulates, which is cheap and should be frequent early in a deployment.

Drift & monitoring
  • Compare against a classical baseline continuously in production, not only at selection time — the pretrained advantage can erode as target data accumulates and the classical model improves
  • Track the fraction of series whose context exceeds the pretrained window, which truncates silently
  • Report step and trajectory counts with every quality measurement, since both change the result as much as the model does
  • Monitor per-window scaler statistics, since a series drifting outside the range the model was pretrained on degrades without any error
Production gotchas
  • Inference cost is steps times trajectories times series and it is easy to under-plan by an order of magnitude. This arithmetic belongs at the start of the design, not at the end
  • Observed values must be re-imposed at every sampling step. Fixing them only at initialization lets the denoiser drift away from data you actually had, and it is the single most common implementation error
  • The pretrained context length is a hard limit. A longer history is silently truncated, and the truncation is not reported anywhere
  • Per-window scaling is part of the inference path, not preprocessing. A scaler computed differently at serving time changes every forecast
  • Zero-shot benchmark results may be contaminated. Before trusting one, verify on data that provably postdates the pretraining corpus
  • The diffusion timestep and the series time index are different axes. Confusing them is a real and surprisingly common source of bugs
Trade-offs

Pros and cons, in context

Strengths
  • Zero-shot forecasting for a series with no history

    A genuinely new capability, and the cold-start case is where it is unambiguously valuable: thirty observations cannot support a fitted model, and a pretrained one gives something reasonable immediately.

  • Forecasting and imputation are the same operation

    Only the mask differs, so one model covers both — and ragged real panels need both. Maintaining two models is a real cost this removes.

  • Full predictive distributions, jointly consistent

    Sampled trajectories preserve correlation across horizons and channels, which is what any decision aggregating over time actually needs and what marginal quantiles cannot give.

  • Immediate non-trivial baseline

    Minutes to a defensible forecast with no fitting, which is genuinely useful for establishing what is achievable before investing in a bespoke model.

Limitations
  • Inference cost is the binding constraint

    Steps times trajectories times series puts this in a different operational regime from every classical forecaster. The arithmetic decides feasibility and is routinely under-planned by an order of magnitude.

  • Benchmark contamination is hard to exclude

    Pretraining corpora are assembled from public sources and forecasting benchmarks are public. A zero-shot result on a public benchmark may not be zero-shot, and establishing otherwise is genuinely difficult.

  • Classical baselines are competitive more often than claimed

    Seasonal-naive, exponential smoothing and a tuned booster win on a large fraction of real panels, and published comparisons frequently omit them. Re-verify on your own data before believing a headline.

  • Fixed context and horizon

    Pretrained windows cannot be exceeded, and a longer history is silently truncated with nothing logged. A real constraint that surfaces only as unexplained accuracy loss.

  • No mechanism for domain covariates

    A pretrained model knows nothing about your promotions or your holiday calendar, and most have no covariate path at all. Where covariates drive the signal, a purpose-built model wins clearly.

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.

"""Masked conditional diffusion for time series, transcribed literally.

The central idea is the MASK. A forecast and an imputation are the same
operation - some values are known and some are not:

    mask = 0  ->  observed, conditioning
    mask = 1  ->  unknown, predicted

Mask the tail and it is forecasting. Mask the middle and it is imputation. The
model does not distinguish, and randomizing the mask during training is what
makes one model handle both.

NOTATIONAL HAZARD, and a real source of bugs: `t` below is the DIFFUSION
timestep, not the position in the series. They index completely different axes
and are both conventionally called t.

Plain loops, no libraries.
"""

import math
import random

SEED = 37


def build_schedule(steps):
    """Cosine schedule, as in the DDPM entry. Fixed, not learned."""
    def f(index):
        angle = ((index / steps) + 0.008) / 1.008 * math.pi / 2.0
        return math.cos(angle) ** 2

    bars = [f(index) / f(0) for index in range(steps + 1)]
    alpha_bars = bars[1:]

    return {
        'alpha_bars': alpha_bars,
        'sqrt_alpha_bars': [math.sqrt(a) for a in alpha_bars],
        'sqrt_one_minus': [math.sqrt(1.0 - a) for a in alpha_bars],
    }


def scale_window(window, mask):
    """Per-window normalization, computed from the OBSERVED values only.

    This is what lets one model span series measured in units and series
    measured in millions. Two details matter. It uses only the observed
    positions, because including the masked ones would leak the answer. And
    it is part of the INFERENCE path rather than preprocessing - a scaler
    computed differently at serving time changes every forecast.
    """
    observed = [value for value, m in zip(window, mask) if m == 0]
    if not observed:
        return list(window), 0.0, 1.0

    mean = sum(observed) / len(observed)
    variance = sum((value - mean) ** 2 for value in observed) / len(observed)
    scale = math.sqrt(variance) or 1.0

    return [(value - mean) / scale for value in window], mean, scale


def random_mask(length, rng, strategy='mixed'):
    """Randomize the mask during training.

    A model trained only on tail masks forecasts and imputes badly; a model
    trained on mixed masks does both. The mask strategy determines which tasks
    the model can perform, which makes it a modelling decision rather than a
    data-loading detail.
    """
    if strategy == 'forecast' or (strategy == 'mixed' and rng.random() < 0.5):
        cut = rng.randrange(length // 2, length)
        return [0] * cut + [1] * (length - cut)

    # Block missingness: a contiguous gap somewhere in the middle.
    start = rng.randrange(0, length - length // 4)
    width = rng.randrange(1, length // 4 + 1)
    return [1 if start <= index < start + width else 0 for index in range(length)]


def denoiser(x_t, t, observed, mask, params):
    """Conditioned on the noisy target, the timestep, AND the observed values.

    The observed values and the mask are both inputs at every step. Supplying
    them only at initialization is the classic mistake - see reverse_step.
    """
    features = list(x_t) + list(observed) + [float(m) for m in mask] + [t / 100.0]

    hidden = []
    for row, bias in zip(params['w1'], params['b1']):
        total = bias + sum(w * v for w, v in zip(row, features))
        hidden.append(math.tanh(total))

    return [
        bias + sum(w * h for w, h in zip(row, hidden))
        for row, bias in zip(params['w2'], params['b2'])
    ]


def training_loss(window, params, schedule, rng):
    """Masked noise prediction. The loss applies ONLY where the mask is one.

    Everything outside the mask is conditioning, not a target - which is
    exactly what makes forecasting and imputation the same objective.
    """
    mask = random_mask(len(window), rng)
    scaled, _, _ = scale_window(window, mask)

    t = rng.randrange(len(schedule['alpha_bars']))
    signal = schedule['sqrt_alpha_bars'][t]
    noise_scale = schedule['sqrt_one_minus'][t]

    epsilon = [rng.gauss(0.0, 1.0) for _ in scaled]
    x_t = [signal * value + noise_scale * e for value, e in zip(scaled, epsilon)]

    # Observed positions are passed through unnoised as conditioning.
    observed = [0.0 if m else value for value, m in zip(scaled, mask)]
    predicted = denoiser(x_t, t, observed, mask, params)

    counted = sum(mask)
    if counted == 0:
        raise ValueError('a mask with nothing to predict trains on nothing')

    total = 0.0
    for e, p, m in zip(epsilon, predicted, mask):
        if m:
            total += (e - p) ** 2
    return total / counted


def reverse_step(x_t, t, observed, mask, params, schedule, rng, alphas):
    """One denoising step, with the observed values RE-IMPOSED.

    This is the step everyone forgets, and its absence is the single most
    common implementation error in this family.

    If the known positions are fixed once at initialization and then left
    alone, the denoiser drifts away from them as it works - it is filling a
    canvas and has no reason to respect a region it was not asked about. So
    the reconstruction ends up inconsistent with data you actually had.

    Re-noising the observed values to the CURRENT noise level and substituting
    them back at every step keeps the trajectory anchored to the real data.
    """
    predicted = denoiser(x_t, t, observed, mask, params)

    alpha = alphas[t]
    alpha_bar = schedule['alpha_bars'][t]
    coefficient = (1.0 - alpha) / math.sqrt(1.0 - alpha_bar)

    denoised = [
        (value - coefficient * p) / math.sqrt(alpha)
        for value, p in zip(x_t, predicted)
    ]

    if t == 0:
        return [d if m else o for d, o, m in zip(denoised, observed, mask)]

    # Re-noise the observed values to the PREVIOUS level and substitute back.
    previous = schedule['alpha_bars'][t - 1]
    signal = math.sqrt(previous)
    noise_scale = math.sqrt(1.0 - previous)

    return [
        d if m else signal * o + noise_scale * rng.gauss(0.0, 1.0)
        for d, o, m in zip(denoised, observed, mask)
    ]


def sample_trajectories(window, mask, params, schedule, alphas, count, rng):
    """Draw several completions. Their spread IS the predictive distribution.

    Cost note, because it decides feasibility: this is steps times count times
    however many series are in the panel. A hundred trajectories at fifty
    steps across ten thousand series is fifty million network evaluations for
    one forecast cycle - a different operational regime entirely from fitting
    a classical model per series.
    """
    scaled, mean, scale = scale_window(window, mask)
    observed = [0.0 if m else value for value, m in zip(scaled, mask)]

    trajectories = []
    for _ in range(count):
        x = [rng.gauss(0.0, 1.0) for _ in scaled]
        for t in range(len(schedule['alpha_bars']) - 1, -1, -1):
            x = reverse_step(x, t, observed, mask, params, schedule, rng, alphas)
        # Undo the per-window scaling. The scaler is part of inference.
        trajectories.append([value * scale + mean for value in x])

    return trajectories


def quantile_from_trajectories(trajectories, position, level):
    """Any quantile is a percentile of the sampled completions."""
    values = sorted(path[position] for path in trajectories)
    index = min(int(level * len(values)), len(values) - 1)
    return values[index]


def seasonal_naive_baseline(history, horizon, period):
    """The baseline that wins more often than the literature suggests.

    Worth having in the same file as the model: published comparisons for
    pretrained forecasters frequently omit properly tuned classical baselines,
    and this one is three lines.
    """
    return [history[-period + (index % period)] for index in range(horizon)]

Training is O(1) network evaluations per example; sampling is steps times trajectories times series. Illustrative, not a measured benchmark: that product is the number that decides feasibility, and it is routinely under-planned by an order of magnitude.

2. Idiomatic

How this language actually wants it written.

"""The same model, with the mask as a type and the scaler carried with it.

Two things change. The mask becomes an explicit object that knows what task it
represents and validates that it asks for something, because it is the whole
modelling decision rather than a data-loading detail. And the per-window
scaler becomes part of the returned forecast, because it is part of the
INFERENCE path — a scaler computed differently at serving time changes every
forecast, with nothing to indicate it.
"""

from __future__ import annotations

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


class TaskKind(Enum):
    """What the mask asks for. Determined by its shape, not by configuration.

    Forecasting and imputation are the same operation with different masks, so
    naming the task is a property of the mask rather than a separate setting.
    """

    FORECAST = 'forecast'
    IMPUTATION = 'imputation'
    MIXED = 'mixed'


class EmptyMask(ValueError):
    """Raised when a mask asks for nothing.

    Its own type because the consequence is specific: the loss has no terms,
    so the batch contributes nothing and the training loop reports a perfectly
    healthy zero.
    """


class ContextOverflow(ValueError):
    """Raised when the history exceeds the pretrained context window.

    Its own type because the alternative is silent truncation: a pretrained
    model simply drops the excess and reports nothing, and the symptom is
    unexplained accuracy loss on exactly the series with the most history.
    """


class MissingScaler(ValueError):
    """Raised when a forecast is produced without its fitted scaler.

    Per-window scaling is part of the inference path, not preprocessing.
    Recomputing it differently at serving time changes every forecast.
    """


class ShapeMismatch(ValueError):
    """Raised on a dimension violation instead of computing nonsense."""


@dataclass(frozen=True)
class WindowScaler:
    """Per-window normalization, fitted on the OBSERVED values only.

    Two details matter and both are easy to get wrong. It must use only the
    observed positions, because including the masked ones leaks the answer
    into the scaling. And it travels with the forecast, because undoing it is
    part of producing the output rather than a separate step.
    """

    mean: float
    scale: float

    @staticmethod
    def fit(window: Sequence[float], mask: Sequence[int]) -> WindowScaler:
        observed = [value for value, m in zip(window, mask) if m == 0]
        if not observed:
            raise EmptyMask('cannot scale a window with no observed values')

        mean = math.fsum(observed) / len(observed)
        variance = math.fsum((value - mean) ** 2 for value in observed) / len(observed)
        # A constant window gives zero variance; scaling by one leaves it
        # unchanged rather than dividing by zero.
        return WindowScaler(mean=mean, scale=math.sqrt(variance) or 1.0)

    def apply(self, window: Sequence[float]) -> list[float]:
        return [(value - self.mean) / self.scale for value in window]

    def invert(self, window: Sequence[float]) -> list[float]:
        return [value * self.scale + self.mean for value in window]


class TargetMask:
    """Which positions are predicted. The whole modelling decision.

    A model trained only on tail masks forecasts and imputes badly; one
    trained on mixed masks does both. Making the mask a type with a task
    property means that choice is visible rather than buried in a data loader.
    """

    def __init__(self, values: Sequence[int]) -> None:
        if not any(values):
            raise EmptyMask(
                'the mask asks for nothing; the loss will have no terms and the '
                'batch will report a healthy zero'
            )
        if all(values):
            raise EmptyMask('the mask leaves nothing observed; there is no conditioning')
        self._values = list(values)

    def __len__(self) -> int:
        return len(self._values)

    def __iter__(self):
        return iter(self._values)

    @property
    def predicted_count(self) -> int:
        return sum(self._values)

    @property
    def task(self) -> TaskKind:
        """Derived from the shape rather than declared.

        A mask that is zeros then ones is a forecast; anything else with an
        interior gap is imputation. Deriving it means a mislabelled mask is
        impossible rather than merely unlikely.
        """
        first_one = next(index for index, value in enumerate(self._values) if value)
        if all(self._values[first_one:]):
            return TaskKind.FORECAST
        return TaskKind.IMPUTATION

    @staticmethod
    def forecast(length: int, horizon: int) -> TargetMask:
        if horizon >= length:
            raise EmptyMask('a horizon covering the whole window leaves no context')
        return TargetMask([0] * (length - horizon) + [1] * horizon)

    @staticmethod
    def random(length: int, rng: random.Random) -> TargetMask:
        """Randomized during training, so one model handles both tasks."""
        if rng.random() < 0.5:
            return TargetMask.forecast(length, rng.randrange(1, length // 2))
        start = rng.randrange(0, length - length // 4)
        width = rng.randrange(1, length // 4 + 1)
        return TargetMask(
            [1 if start <= index < start + width else 0 for index in range(length)]
        )


@dataclass(frozen=True)
class ContextWindow:
    """The pretrained window, which is a hard limit rather than a default."""

    length: int

    def assert_fits(self, history: Sequence[float]) -> None:
        """Guard clause for the failure that is otherwise silent.

        A pretrained model given a longer history simply truncates and reports
        nothing, so the symptom is unexplained accuracy loss on exactly the
        series with the most data.
        """
        if len(history) > self.length:
            raise ContextOverflow(
                f'{len(history)} observations exceed the {self.length}-step pretrained '
                'context; the excess would be silently truncated'
            )


class Forecast(NamedTuple):
    """Trajectories, the scaler, and the settings that produced them.

    The scaler travels with the forecast because undoing it is part of
    producing the output. The step and trajectory counts travel because
    quality and cost are on a dial — a forecast reported without them is not
    comparable to any other.
    """

    trajectories: list[list[float]]
    scaler: WindowScaler
    steps: int
    trajectory_count: int

    def quantile(self, position: int, level: float) -> float:
        values = sorted(path[position] for path in self.trajectories)
        return values[min(int(level * len(values)), len(values) - 1)]

    def aggregate_quantile(self, level: float) -> float:
        """Sum WITHIN each trajectory, then take the quantile.

        The joint-distribution advantage: summing per-step quantiles instead
        destroys the correlation between steps and is wrong in the unsafe
        direction for any aggregating decision.
        """
        totals = sorted(math.fsum(path) for path in self.trajectories)
        return totals[min(int(level * len(totals)), len(totals) - 1)]

    def tail_is_estimable(self, level: float, minimum_order_statistics: int = 5) -> bool:
        """Whether there are enough trajectories for this quantile to mean
        anything. A 1st percentile from fifty trajectories is one order
        statistic and is pure noise."""
        tail = min(level, 1.0 - level)
        return tail * self.trajectory_count >= minimum_order_statistics


def masked_loss(
    epsilon: Sequence[float], predicted: Sequence[float], mask: TargetMask
) -> float:
    """The loss applies ONLY where the mask is one.

    Everything outside it is conditioning rather than a target, which is
    exactly what makes forecasting and imputation the same objective.
    """
    if len(epsilon) != len(predicted) or len(epsilon) != len(mask):
        raise ShapeMismatch('noise, prediction and mask widths disagree')

    total = math.fsum(
        (e - p) ** 2 for e, p, m in zip(epsilon, predicted, mask) if m
    )
    return total / mask.predicted_count


def reimpose_observed(
    denoised: Sequence[float],
    observed: Sequence[float],
    mask: TargetMask,
    alpha_bar_previous: float,
    rng: random.Random,
) -> list[float]:
    """Re-noise the observed values and substitute them back. EVERY step.

    The single most commonly omitted step in this family. If the known
    positions are fixed once at initialization and then left alone, the
    denoiser drifts away from them as it works — it is filling a canvas and
    has no reason to respect a region it was not asked about, so the
    reconstruction ends up inconsistent with data you actually had.
    """
    signal = math.sqrt(alpha_bar_previous)
    noise_scale = math.sqrt(1.0 - alpha_bar_previous)

    return [
        d if m else signal * o + noise_scale * rng.gauss(0.0, 1.0)
        for d, o, m in zip(denoised, observed, mask)
    ]


def inference_evaluations(steps: int, trajectories: int, series: int) -> int:
    """The arithmetic that decides feasibility, computed rather than assumed.

    Steps times trajectories times series. A hundred trajectories at fifty
    steps across ten thousand series is fifty million network evaluations for
    one forecast cycle — a different operational regime entirely from fitting
    a classical model per series, and routinely under-planned by an order of
    magnitude.
    """
    return steps * trajectories * series

What changed: Two changes. The mask becomes a type that validates it asks for something and leaves something observed, and that derives its task from its shape rather than being told — because forecasting and imputation are the same operation with different masks, so a mislabelled mask becomes impossible rather than merely unlikely, and a mask with nothing to predict produces a loss with no terms that reports a perfectly healthy zero. The second is that the per-window scaler becomes a frozen object that travels with the forecast, because it is part of the inference path rather than preprocessing: it is fitted on observed positions only, since including the masked ones would leak the answer into the scaling, and undoing it is part of producing the output. Around those, the pretrained context becomes a type whose check names the failure it prevents — silent truncation, whose symptom is unexplained accuracy loss on exactly the series with the most history — the forecast carries its step and trajectory counts since quality and cost are on a dial, and the tail-estimability check exists because a far-tail quantile from fifty trajectories is one order statistic and is noise. The re-imposition step is extracted into its own named function precisely because it is the one everybody omits.

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

Identical asymptotics; the change is in what fails loudly. Illustrative, not a measured benchmark: deriving the task from the mask shape costs one scan and removes an entire class of configuration error.

3. OptimizedNumPy

Faster, and the justification for each change.

"""Batched over series AND trajectories, which is the only lever that helps.

The structural situation is worth stating precisely, because it differs from
every other diffusion entry. Sampling cost here is STEPS x TRAJECTORIES x
SERIES, and only one of those three is sequential. Steps must happen in order;
trajectories and series are entirely independent and fold into one batch axis.

So the optimization is: collapse trajectories and series into a single leading
dimension, advance the whole thing through each sequential step together, and
accept that the step count is the only part that cannot be parallelized away.

Three changes:
  1. (series x trajectories) becomes one batch axis.
  2. The mask and the observed values are precomputed once, not per step.
  3. Re-imposition is a fused select rather than a branch per element.
"""

from __future__ import annotations

import numpy as np
from numpy.typing import NDArray

FLOAT = np.float32


def fit_window_scaler(
    windows: NDArray[np.float32], mask: NDArray[np.bool_]
) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
    """Per-window mean and scale from the OBSERVED positions only.

    Using the masked positions would leak the answer into the scaling, so the
    reduction is weighted by the complement of the mask rather than taken over
    the whole window — one pass, no boolean indexing, no compacted copy.
    """
    observed = ~mask
    counts = observed.sum(axis=1, keepdims=True).astype(FLOAT)
    counts = np.maximum(counts, 1.0)

    mean = (windows * observed).sum(axis=1, keepdims=True, dtype=FLOAT) / counts
    centred = (windows - mean) * observed
    variance = np.einsum('ij,ij->i', centred, centred, optimize=True)[:, None] / counts
    # A constant window gives zero variance; one leaves it unchanged rather
    # than dividing by zero.
    scale = np.maximum(np.sqrt(variance), 1e-6)
    return mean, scale


def expand_for_trajectories(
    windows: NDArray[np.float32], mask: NDArray[np.bool_], trajectories: int
) -> tuple[NDArray[np.float32], NDArray[np.bool_]]:
    """Fold (series, trajectories) into ONE batch axis.

    This is the whole optimization. Trajectories and series are both entirely
    independent, so they belong in the same dimension; only the diffusion
    steps are sequential, and nothing rearranges that.

    repeat rather than tile, so each series' trajectories are contiguous —
    which keeps the per-series quantile reduction at the end a contiguous
    read rather than a strided one.
    """
    return (
        np.repeat(windows, trajectories, axis=0),
        np.repeat(mask, trajectories, axis=0),
    )


class BatchedSampler:
    """Every trajectory of every series advances through each step together."""

    def __init__(self, alpha_bars: NDArray[np.float32]) -> None:
        self._alpha_bars = np.ascontiguousarray(alpha_bars, dtype=FLOAT)
        alphas = np.empty_like(self._alpha_bars)
        alphas[0] = self._alpha_bars[0]
        alphas[1:] = self._alpha_bars[1:] / self._alpha_bars[:-1]

        # Every coefficient precomputed: these are constants of the schedule
        # and recomputing them inside the step loop is pure waste.
        self._sqrt_alpha_bars = np.sqrt(self._alpha_bars)
        self._sqrt_one_minus = np.sqrt(1.0 - self._alpha_bars)
        self._reverse_coefficient = (1.0 - alphas) / self._sqrt_one_minus
        self._root_alphas = np.sqrt(alphas)

    @property
    def steps(self) -> int:
        return len(self._alpha_bars)

    def reverse_step(
        self,
        x_t: NDArray[np.float32],
        predicted: NDArray[np.float32],
        observed: NDArray[np.float32],
        mask: NDArray[np.bool_],
        t: int,
        rng: np.random.Generator,
    ) -> NDArray[np.float32]:
        """One denoising step with the observed values RE-IMPOSED.

        The step everyone forgets, and its absence is the most common
        implementation error in this family: if the known positions are fixed
        once at initialization the denoiser drifts away from them, because it
        is filling a canvas and has no reason to respect a region it was not
        asked about.

        Written as a fused select rather than a branch per element — the mask
        is a boolean array, so the choice is a where over the whole batch.
        """
        # In place over the prediction buffer: the denoised state replaces it.
        out = predicted
        out *= -self._reverse_coefficient[t]
        out += x_t
        out /= self._root_alphas[t]

        if t == 0:
            return np.where(mask, out, observed)

        # Re-noise the observed values to the PREVIOUS level, then select.
        previous = self._alpha_bars[t - 1]
        noise = rng.standard_normal(observed.shape, dtype=FLOAT)
        noise *= FLOAT(np.sqrt(1.0 - previous))
        noise += FLOAT(np.sqrt(previous)) * observed

        return np.where(mask, out, noise)


def masked_loss(
    epsilon: NDArray[np.float32],
    predicted: NDArray[np.float32],
    mask: NDArray[np.bool_],
) -> np.float32:
    """The loss applies ONLY where the mask is one.

    The mask becomes a multiplicative weight rather than a boolean index,
    which avoids allocating a compacted copy of every predicted position —
    and the count comes from the same array.
    """
    counted = mask.sum(dtype=np.int64)
    if counted == 0:
        raise ValueError('the mask asks for nothing; the loss has no terms')

    residual = predicted
    residual -= epsilon
    residual *= mask
    return np.einsum('ij,ij->', residual, residual, optimize=True) / counted


def trajectory_quantiles(
    trajectories: NDArray[np.float32],
    series: int,
    levels: NDArray[np.float32],
) -> NDArray[np.float32]:
    """(series, levels, horizon) from a flat (series * trajectories, horizon).

    The reshape is free because expand_for_trajectories used repeat rather
    than tile, so each series' trajectories are already contiguous.
    """
    reshaped = trajectories.reshape(series, -1, trajectories.shape[1])
    return np.quantile(reshaped, levels, axis=1).transpose(1, 0, 2)


def aggregate_quantiles(
    trajectories: NDArray[np.float32],
    series: int,
    levels: NDArray[np.float32],
) -> NDArray[np.float32]:
    """Sum WITHIN each trajectory, then take the quantile over trajectories.

    The joint-distribution advantage: summing per-step quantiles instead
    destroys the correlation between steps and is wrong in the unsafe
    direction for any decision that aggregates over the horizon.
    """
    reshaped = trajectories.reshape(series, -1, trajectories.shape[1])
    totals = reshaped.sum(axis=2, dtype=FLOAT)
    return np.quantile(totals, levels, axis=1).T


def seasonal_naive(
    histories: NDArray[np.float32], horizon: int, period: int
) -> NDArray[np.float32]:
    """The baseline that wins more often than the literature suggests.

    One strided gather for a whole panel. Worth having in the same file as the
    model: published comparisons for pretrained forecasters frequently omit
    properly tuned classical baselines, and this costs nothing to run.
    """
    positions = -period + (np.arange(horizon) % period)
    return histories[:, positions]


def inference_evaluations(steps: int, trajectories: int, series: int) -> int:
    """The arithmetic that decides feasibility, computed rather than assumed.

    Only ONE of these three factors is sequential. Trajectories and series
    both fold into the batch, so the wall clock is set by steps and the
    hardware — but the total work is the product, and it is routinely
    under-planned by an order of magnitude.
    """
    return steps * trajectories * series

What changed: The structural situation here differs from every other diffusion entry and is worth stating precisely: sampling cost is steps times trajectories times series, and only one of those three is sequential. Steps must happen in order; trajectories and series are entirely independent and therefore belong in the same batch axis. So the optimization is to collapse them into one leading dimension and advance the whole thing through each step together, accepting that the step count is the only part that cannot be parallelized away. The expansion uses repeat rather than tile deliberately, so each series’ trajectories are contiguous and the final per-series quantile reduction is a free reshape rather than a strided gather. The scaler is fitted with the mask as a multiplicative weight rather than by boolean indexing, which avoids allocating a compacted copy and — more importantly — keeps the masked positions out of the statistics, since including them would leak the answer into the scaling. Re-imposition becomes a fused select over the whole batch rather than a branch per element, and every schedule coefficient including the reverse-step term is precomputed since they are constants.

  • Batch work to amortize interpreter overhead

    Trajectories and series fold into one batch axis, so a whole panel with a hundred trajectories each advances through a step in a constant number of interpreter operations.

    Trade-off: Peak memory is series times trajectories times window width simultaneously, so a large panel at a high trajectory count cannot be held at once and must be chunked by series — which reintroduces a loop over chunks.

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

    The scaler variance, the masked loss and the aggregate sums all contract with einsum in one pass rather than squaring into temporaries, and the seasonal-naive baseline is a single strided gather for the whole panel.

    Trade-off: The masked loss multiplies by the mask rather than indexing, so it does the arithmetic for every position and discards most of it — on a mask that predicts a tenth of the window that is nine tenths wasted work, traded against avoiding a compacted copy.

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

    The reverse step writes through the prediction buffer, the loss residual writes through the prediction, and the re-noising builds into the noise buffer — so a sampling step allocates only the noise it draws.

    Trade-off: The prediction is destroyed by the reverse step, so inspecting what the model predicted at a given noise level — the natural diagnostic when completions look wrong — needs an unfused pass.

  • Ensure contiguous memory layout and a single dtype

    Repeat rather than tile keeps each series’ trajectories contiguous, which makes the final quantile reshape free rather than a strided gather across the whole batch.

    Trade-off: That layout is the wrong one for a per-trajectory diagnostic across series — comparing trajectory five of every series reads with a stride equal to the trajectory count, which is exactly the access pattern a sampling-variance investigation wants.

Trajectories and series collapse into one batched pass per step, leaving the step count as the only sequential factor. Illustrative, not a measured benchmark: the total work is unchanged — it is the product of all three factors — and only the wall clock improves, which is why the feasibility arithmetic still has to be done.