What it actually does
Most forecasting inputs are not the same kind of thing. Store size never changes; a public holiday next Tuesday is known now; yesterday’s footfall was observed and tomorrow’s will not be. A plain sequence model that concatenates all of these into one vector per timestep either leaks future information or throws away covariates it legitimately has. TFT keeps the three kinds separate by construction, learns which variables matter at each step, and emits quantiles rather than a point — which is what a forecast is actually for, since the decision downstream is almost always asymmetric in cost.
- Covariates can be honestly classified as static, known-future or observed-past — the central assumption, and the one that invalidates everything when it is wrong
- Known-future covariates really will be available across the whole horizon at scoring time, not just historically
- Enough related series exist to fit a high-capacity model; on a single series this is the wrong tool by a wide margin
- The relationship between covariates and target is stable enough over the horizon that patterns learned on history persist
- The quantiles of interest are estimable from the data, which fails for extreme tails on short histories
Loss function
The pinball loss, summed over quantile levels. Read one term at a time: for q equal to 0.9, under-predicting is penalized nine times as heavily as over-predicting, so the minimizer sits at the 90th percentile of the conditional distribution. That asymmetry is the whole mechanism, and it is why the model produces a distribution without ever assuming one — no Gaussian, no likelihood, just a loss whose minimizer is the quantile you asked for. The consequence people forget is that the levels are trained independently, so nothing forces the 90th percentile to exceed the 50th, and quantile crossing genuinely happens in the tails.
- a quantile level, typically {0.1, 0.5, 0.9}; the head emits one output per level
- the predicted q-quantile — a separate output, not a transform of a mean
- forecast horizon; every horizon in the window is predicted at once, not recursively
- observed (series, time, horizon) triples — masked, since real panels are ragged
- the quantile set. Their monotonicity is not enforced and crossing does occur
How it is minimized
Adam on the quantile loss, with gated residual units and variable selection trained jointly end to end
- gated linear unit — the gate can drive a whole sublayer to zero, which is how unused capacity is skipped
- variable-selection weights: a softmax over input variables, per timestep, and the model’s headline interpretability claim
- the flattened per-variable embeddings the selection network scores
- the residual input; a GRN can always fall back to passing it through unchanged
The gating is the architectural idea worth taking away. Every nonlinear component is wrapped so that a gate can zero it out, which means the model can degrade to a linear pass-through wherever the extra capacity does not help — and on small forecasting datasets, which is most of them, that is frequently the right answer. It is an architecture that can choose to be smaller, and it is why TFT does not fall apart on a few thousand series the way a plain transformer does. Variable selection is a softmax over inputs computed per timestep, so the weights are both a regularizer and the model’s interpretability story; they should be read as importance to this model rather than as causal attribution, a distinction the paper is careful about and downstream users routinely are not. Training is otherwise ordinary Adam, and the whole thing is stable in a way this category rarely is.
hidden size
Width shared by every GRN and the LSTM; the dominant capacity and cost lever (16 to 160)
attention heads
Interpretable heads share value weights so the attention averages into one readable map (1 to 4)
dropout
Higher than usual for a deep model, because the datasets are small (0.1 to 0.3)
encoder length
History window fed to the LSTM; attention can reach anywhere inside it (2 to 10 seasonal cycles)
prediction length
Horizons emitted at once; all are produced directly rather than recursively (1 to 60 steps)
quantile levels
Which quantiles to emit. More levels cost almost nothing and are rarely regretted ({0.1, 0.5, 0.9} to deciles)
gradient clip norm
The LSTM component still explodes without it, transformer parts notwithstanding (0.1 to 1.0)
Unusually well-behaved for something this elaborate, largely because the gating lets unhelpful components switch themselves off rather than fighting the data. The characteristic failures are not optimization failures. Quantile crossing appears in the tails because the levels are trained independently and nothing couples them — the 90th percentile can come out below the 50th, and it must be repaired by sorting or by a monotone parameterization. Variable-selection weights are unstable across seeds when inputs are correlated, so an importance ranking that changes run to run is the normal case rather than a bug, and treating it as a finding is a real risk. And the model quietly overfits a small panel: the validation loss looks fine while the selection weights concentrate on one spurious covariate.
O(L · d²) for the recurrent encoder plus O((L+H)² · d) for attention over the combined window, with L the encoder length and H the horizon. The LSTM is sequential in L and is usually the wall-clock bottleneck, since the windows are short enough that the quadratic attention term rarely dominates.
How, where, and why it is used
Time-Series Forecasting
Primary fitDeclare every input by type — static (never changes per series), known-future (calendar, promotions, prices you set), observed-past (everything measured) — and the architecture routes each to where it is legitimately available. A shared LSTM encodes the history, attention selects across it, and a quantile head emits every horizon at once. Static covariates are used to condition the encoder rather than being repeated at every timestep, which is both cheaper and more effective.
- Retail demand forecasting across thousands of SKUs with promotions and holidays known in advance
- Energy load and price forecasting, where weather forecasts are genuine known-future inputs
- Traffic and occupancy forecasting with calendar effects that dominate the signal
- Any panel where uncertainty matters as much as the point forecast — inventory, staffing, capacity
It wins where the typed input split is real: if you genuinely have known-future covariates, a model that consumes them correctly beats one that cannot, and most sequence models cannot without leaking. Native quantiles are the second reason and arguably the bigger one, because inventory and capacity decisions need a tail rather than a mean, and bolting quantiles onto a point model afterwards is worse than training them. Against it: on a single series or a few dozen it loses to exponential smoothing or ARIMA and costs orders of magnitude more; against gradient boosting on well-constructed lag features it frequently wins by only a few percent, which is a poor trade against the operational complexity unless the quantiles or the interpretability are genuinely used.
- Classify every covariate as static, known-future or observed-past before anything else — this is the modelling decision, and getting it wrong either leaks or discards information
- Scale per series and persist the scalers with the model; they are part of the artifact, not preprocessing
- Encode calendar structure explicitly as known-future inputs rather than leaving the model to rediscover the weekly cycle
- Keep the encoder window at several seasonal cycles so attention has something to select from
- Predict every horizon directly, never recursively, which avoids compounding the model’s own errors
Rolling-origin backtesting with quantile loss reported per level and per horizon, plus MASE at the median against seasonal-naive and exponential smoothing. Calibration matters separately from accuracy: check empirical coverage of the 10th and 90th percentiles, because a well-scored model with 70 percent coverage on a nominal 80 percent interval will systematically under-stock.
- Mislabelling an observed-past covariate as known-future, which is a complete and silent invalidation of the backtest
- Reporting only median accuracy while the intervals are badly calibrated, which is the failure that actually costs money
- Quantile crossing in the tails going unnoticed and unrepaired
- Skipping the gradient-boosting baseline, which on many panels is within a few percent for a fraction of the complexity
Anomaly Detection
AdaptedTrain the forecaster on normal operation and use the predicted interval as the detector: an observation outside the 1st-to-99th-percentile band is flagged, and the degree of exceedance is the score. Because the intervals are conditional on calendar and known-future covariates, the band widens on a holiday or a promotion by itself, which is exactly what a fixed threshold cannot do.
- Demand and revenue monitoring, where the question is whether today is unusual given everything known in advance
- Energy and utility consumption monitoring with weather-conditioned expectations
- Business KPI monitoring where the baseline has strong calendar structure and a static threshold alarms every Monday
- Capacity and SLA monitoring, where the actionable signal is breaching a predicted upper bound
The conditional interval is a genuinely better detector than a fixed band because the model already knows the day is a holiday, and that removes the largest single source of false positives in calendar-driven metrics. It is also free, in the sense that the forecaster was going to be built anyway. The honest framing is that this is a by-product: the model optimizes forecast accuracy and nothing about anomalies, so a subtle anomaly well inside a wide interval is invisible, and the interval width itself becomes the sensitivity knob whether you intended that or not. On point outliers with no temporal structure an isolation forest is both better and vastly cheaper.
- Emit wider quantiles than you forecast with — 1st and 99th percentiles for detection, not the 10th and 90th used for planning
- Calibrate coverage on a held-out normal period, since nominal and empirical coverage differ more than expected
- Score by exceedance relative to interval width, so a breach on a naturally volatile series is not ranked above one on a stable series
- Persist known-future covariates for the scoring window; without the holiday flag the interval is wrong exactly when it matters
Precision and recall against labelled incidents with a detection window rather than exact-timestep matching. Report empirical interval coverage separately from detection quality — an uncalibrated interval produces a detector whose false-positive rate has nothing to do with the nominal level, and that is invisible in a precision-recall curve alone.
- A training period that contains the incidents you want to detect, teaching the model they are normal
- Using planning quantiles for detection, which alarms roughly twenty percent of the time by construction
- Missing known-future covariates at scoring time, so the interval is narrow on exactly the days it should be wide
- Treating interval width as a modelling artifact rather than as the sensitivity setting it actually is
Optimization
ViableQuantile forecasts feed asymmetric-cost decisions directly, and the connection is exact rather than analogical. The newsvendor result says the optimal order quantity is the demand quantile at the critical fractile — underage cost over the sum of underage and overage cost — so a model that emits quantiles hands the optimizer precisely what it needs, with no distributional assumption in between. That is the cleanest bridge in this reference between a forecast and a decision.
- Inventory ordering at the newsvendor critical fractile, where the quantile IS the decision
- Staffing and capacity planning, where understaffing and overstaffing have different and known costs
- Energy reserve procurement, sized against an upper quantile of load rather than against a mean
- Safety-stock setting, where the service level is a quantile by definition
Worth studying because it makes explicit something usually left implicit: the forecast metric and the decision metric are different, and a model with worse MAE can produce better decisions if its tail is better calibrated. The newsvendor fractile turns a vague "we need uncertainty" into an exact quantile to request, which is unusually actionable. The limits are real though — the closed form covers a single period and a single product, and any of multi-period dynamics, capacity constraints or substitution between products puts you into a stochastic program that a quantile alone does not solve.
- Emit the specific quantile the critical fractile calls for, rather than a standard set that happens not to include it
- Check empirical coverage before trusting the fractile; an uncalibrated quantile produces a confidently wrong order quantity
- Model the cost asymmetry explicitly rather than assuming it is symmetric, which is the default and is almost never true
- Treat crossing quantiles as a correctness bug here, since a non-monotone quantile function makes the fractile meaningless
Score the decision rather than the forecast: realized cost against the hindsight-optimal order. Report it alongside quantile loss, because the two rank models differently and the decision cost is the one that matters.
- Using a point forecast plus a safety factor, which implicitly assumes a symmetric distribution that the data rarely has
- Applying the single-period newsvendor formula to a multi-period problem with carryover, where it is simply wrong
- Trusting a quantile whose empirical coverage was never measured
Control & Operations
ViableThe quantile forecast becomes the uncertain input to an operational plan: predicted load with intervals feeds unit commitment, predicted demand with intervals feeds fulfilment and dispatch. Known-future covariates are what make this work in practice, since an operational planner already knows next week’s promotions and the model should too.
- Workforce scheduling against a forecast demand distribution rather than a point
- Grid unit commitment and reserve sizing at an upper load quantile
- Fulfilment and logistics planning where lead times force a decision before demand is observed
The typed-input structure matches how operations actually work — the plan is made with knowledge of what is scheduled — and quantiles are what a robust or chance-constrained plan consumes. The recurring difficulty is the feedback loop: the plan changes the system, so the model is trained on operations that the deployment then alters, and backtesting cannot reveal it. Retrain cadence has to be tied to how fast the policy changes, not to the calendar.
- Feed the planned action as a known-future covariate where it is genuinely known, or the model attributes its effect elsewhere
- Match the forecast horizon to the decision lead time; a longer horizon is wasted effort and a shorter one is unusable
- Keep the quantile set aligned with the service levels the operation is actually committed to
Regret against the hindsight-optimal plan, plus realized service level against target. Forecast accuracy is a diagnostic here, not the objective.
- The plan shifting the distribution the model was trained on, which no amount of backtesting exposes
- Optimizing median accuracy when the plan consumes a tail quantile
- Reading variable-selection weights as causal and changing operational policy on that basis
Risk & Fraud
AdaptedUsed for forecasting risk aggregates rather than for scoring individual events: expected claim volume, transaction throughput, chargeback rates — quantities with strong calendar structure where the upper quantile drives a capacity or reserve decision.
- Claims and loss volume forecasting for reserving and staffing
- Transaction volume forecasting for fraud-review capacity planning
- Portfolio-level exposure forecasting where the tail is the decision-relevant quantity
The tail is the point in risk work, and a model that emits quantiles natively is a better fit than a point model with an assumed error distribution bolted on. But this is aggregate forecasting, not per-entity risk scoring: the model has no notion of an individual transaction and should never be pointed at one. For that, a supervised classifier on entity features is the right tool and this is not a substitute.
- Forecast at the aggregation level the decision is made at, not finer, since the noise at entity level swamps the signal
- Include known-future operational covariates — campaigns, releases, policy changes — which drive most of the visible variance
- Validate tail coverage specifically, because that is the only part of the distribution the decision uses
Quantile loss at the decision-relevant levels plus empirical exceedance frequency against nominal. Backtest strictly by time; a reserving model validated on a random split is meaningless.
- Mistaking aggregate forecasting for entity-level risk scoring, which this model cannot do
- Regime changes from a policy or product launch invalidating the learned dynamics with no warning
- Tail quantiles fitted on a period containing no tail events, which are then confidently too narrow
What it costs to run
Hours on a single GPU for a panel of a few thousand series — small by deep-learning standards. The sequential LSTM is the bottleneck rather than attention, since encoder windows are short enough that the quadratic term rarely binds.
One forward pass per series per cycle, producing every horizon and every quantile at once: milliseconds per series, and trivially batched across a panel. Forecasting is a batch workload by nature, so latency is almost never the constraint here.
Monthly to quarterly for stable demand, and immediately after any change to the covariate set or the promotion calendar — a new covariate is a structural change, not a data update.
- Track empirical coverage of each quantile against its nominal level; drifting coverage is the first symptom and it appears before accuracy moves
- Watch per-series bias rather than the panel aggregate, since a handful of badly drifting series vanish in the mean
- Log variable-selection weights over time — a large shift means the learned structure changed, which is worth investigating even when accuracy has not moved
- Alert on missing known-future covariates at scoring time, which silently narrows intervals exactly when they should widen
- The static, known-future and observed-past split is a contract between the feature pipeline and the model. Move a column between categories and the model is invalid, usually without any error
- Known-future covariates must be available for the full horizon at scoring time. Missing them does not fail — it produces a confident forecast built on defaults
- Quantiles can cross. Nothing in the loss prevents it, and any downstream consumer must sort or otherwise enforce monotonicity
- Per-series scalers are part of the model artifact and must be versioned with the weights; refitting at inference silently changes every forecast
- A series unseen at training time has no learned static embedding, so cold-start needs an explicit path rather than a zero vector
Pros and cons, in context
Consumes known-future covariates correctly
A promotion calendar or a weather forecast enters where it is legitimately available, which most sequence models cannot arrange without leaking. Decisive in retail and energy; worth nothing when no future covariates exist.
Native quantiles rather than a point forecast
The decision downstream is almost always asymmetric in cost, and the quantile is what it needs. Training the quantiles directly beats assuming an error distribution around a mean, and it connects exactly to the newsvendor fractile.
Gating lets the model choose to be smaller
Every nonlinear component can be gated to zero, so it degrades gracefully toward linear on small data. This is why it works on a few thousand series where a plain transformer collapses, and it is the most transferable idea in the architecture.
Variable-selection weights are genuinely readable
Not causal, and unstable under correlated inputs, but a real diagnostic that stakeholders can inspect — which is rare and often decisive in getting a forecasting model deployed at all.
One model across the whole panel
Static embeddings let a short-history series borrow structure from long-history ones, which is where most of the practical accuracy gain comes from. The same argument as for any global forecasting model.
Substantially more machinery than the baseline it must beat
Against gradient boosting on good lag features it frequently wins by a few percent for an order of magnitude more complexity. Justified when the quantiles or the future covariates are genuinely used, and hard to justify otherwise.
Quantiles are trained independently and can cross
Nothing in the loss couples the levels, so the 90th percentile can fall below the 50th in the tails. Every downstream consumer has to repair it, and it is a correctness bug wherever the quantile function itself is used.
The input-type split is a rigid contract
A covariate moved between categories invalidates the model with no error raised. This is the most common production failure with TFT and it is entirely a pipeline-discipline problem.
Interpretability is easy to over-read
Selection weights are importance to this model, not causal effect, and they shift between seeds when inputs are correlated. Presenting them as findings to a business audience is a real and frequently realized risk.
Wrong tool on few series
On one series with a few hundred observations, exponential smoothing wins and costs nothing. The honest trigger is a wide panel with real covariates, not the sophistication of the problem.
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.
The math, transcribed literally. No dependencies.
"""The three distinctive pieces of TFT, transcribed literally.
1. Gated Residual Network - the component that can switch itself off
2. Variable Selection Network - a softmax over INPUTS, per timestep
3. Quantile (pinball) loss - what makes the output a distribution
Attention and the LSTM are standard and covered elsewhere in this reference;
what is specific to TFT is these three, so these three are what is written
out. Plain loops, no libraries.
"""
import math
def sigmoid(value):
if value >= 0.0:
return 1.0 / (1.0 + math.exp(-value))
positive = math.exp(value)
return positive / (1.0 + positive)
def elu(value):
"""ELU rather than ReLU, deliberately: a GRN that has gated itself off
should pass small negative values through smoothly rather than clipping
them, so the residual path stays informative."""
return value if value > 0.0 else math.exp(value) - 1.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 layer_norm(vector, gain, shift, epsilon=1e-6):
mean = sum(vector) / len(vector)
variance = sum((value - mean) ** 2 for value in vector) / len(vector)
denominator = math.sqrt(variance + epsilon)
return [
(value - mean) / denominator * gain[i] + shift[i]
for i, value in enumerate(vector)
]
def gated_linear_unit(vector, weight_value, bias_value, weight_gate, bias_gate):
"""GLU: one branch is the value, the other is a gate in [0, 1].
The gate CAN go to zero, and that is the point - it lets the network
delete a whole sublayer rather than having to learn the identity through
it. On the small datasets forecasting actually has, switching capacity
off is frequently the right answer.
"""
values = matvec(weight_value, vector, bias_value)
gates = matvec(weight_gate, vector, bias_gate)
return [value * sigmoid(gate) for value, gate in zip(values, gates)]
def gated_residual_network(primary, context, params):
"""GRN(a, c) = LayerNorm(a + GLU(eta)), eta = ELU(W1 a + W2 c).
`context` is optional static conditioning - a static covariate enters
here rather than being repeated at every timestep, which is both cheaper
and, more importantly, keeps it recognizably static.
"""
hidden = matvec(params['w1'], primary, params['b1'])
if context is not None:
# No bias on the context projection: the primary branch already has
# one and two would be redundant.
contribution = matvec(params['w2'], context, [0.0] * len(hidden))
hidden = [a + b for a, b in zip(hidden, contribution)]
hidden = [elu(value) for value in hidden]
gated = gated_linear_unit(
hidden, params['w_value'], params['b_value'],
params['w_gate'], params['b_gate'],
)
# The residual is what makes "switch yourself off" meaningful: with the
# gate at zero, the whole component becomes LayerNorm(a).
residual = [a + g for a, g in zip(primary, gated)]
return layer_norm(residual, params['ln_gain'], params['ln_shift'])
def softmax(scores):
peak = max(scores)
exponentials = [math.exp(score - peak) for score in scores]
total = sum(exponentials)
return [value / total for value in exponentials]
def variable_selection(variable_embeddings, static_context, params):
"""A softmax over VARIABLES, computed fresh at every timestep.
variable_embeddings: one vector per input variable, all the same width.
Returns the weighted combination and the weights themselves - the weights
are the model's interpretability story and have to come back out.
"""
flattened = [value for embedding in variable_embeddings for value in embedding]
# Selection is conditioned on the static context, so a large store and a
# small store can weight promotions differently.
scores = gated_residual_network(flattened, static_context, params['selection'])
weights = softmax(scores[:len(variable_embeddings)])
# Each variable also passes through its OWN GRN before being combined,
# so selection and transformation are separate jobs.
transformed = [
gated_residual_network(embedding, None, params['per_variable'][index])
for index, embedding in enumerate(variable_embeddings)
]
width = len(transformed[0])
combined = [0.0] * width
for weight, vector in zip(weights, transformed):
for d in range(width):
combined[d] += weight * vector[d]
return combined, weights
def quantile_loss(predictions, target, quantiles):
"""Pinball loss, summed over quantile levels.
For q = 0.9, under-predicting costs 9x what over-predicting costs, so the
minimizer sits at the 90th percentile. That asymmetry is the entire
mechanism - no distribution is ever assumed.
"""
total = 0.0
for prediction, q in zip(predictions, quantiles):
error = target - prediction
total += max(q * error, (q - 1.0) * error)
return total
def masked_quantile_loss(predictions, targets, observed, quantiles):
"""Real panels are ragged. An unmasked loss trains on the padding."""
total = 0.0
counted = 0
for series in range(len(predictions)):
for horizon in range(len(predictions[series])):
if not observed[series][horizon]:
continue
total += quantile_loss(
predictions[series][horizon], targets[series][horizon], quantiles
)
counted += 1
if counted == 0:
raise ValueError("every target in the batch is masked out")
return total / counted
def repair_crossing(prediction_row):
"""Quantile levels are trained INDEPENDENTLY, so nothing stops the 90th
percentile coming out below the 50th. Sorting is the crude repair and it
is what most production systems actually do."""
return sorted(prediction_row)
Each GRN is O(d^2) and variable selection runs one per input variable per timestep, so a block is O(V * L * d^2). Illustrative, not a measured benchmark: with a dozen variables and a 90-step window this is already millions of interpreter iterations before the LSTM or attention is reached.
How this language actually wants it written.
"""The same three components, with the input-type contract made a type.
The substantive change is not the arithmetic. It is that the static /
known-future / observed-past split - which is TFT's central idea and its most
common production failure - stops being a convention the pipeline is trusted
to honour and becomes a structure that checks itself.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from enum import Enum
from typing import NamedTuple, Sequence
class InputKind(Enum):
"""The classification the whole architecture is built on.
Getting this wrong is the model's signature failure: label an observed
covariate as known-future and the backtest is invalidated completely,
silently, and in the optimistic direction.
"""
STATIC = 'static'
KNOWN_FUTURE = 'known-future'
OBSERVED_PAST = 'observed-past'
class LeakageError(ValueError):
"""Raised when a covariate is used beyond the point it is available.
Its own type because the consequence is specific and severe: a model that
has leaked does not fail, it reports excellent validation accuracy.
"""
class ShapeMismatch(ValueError):
"""Raised on a shape violation instead of broadcasting past it."""
@dataclass(frozen=True)
class InputSpec:
"""One covariate and where it may legitimately be read."""
name: str
kind: InputKind
cardinality: int | None = None # None means continuous
@property
def is_categorical(self) -> bool:
return self.cardinality is not None
@dataclass(frozen=True)
class InputSchema:
"""The contract between the feature pipeline and the model.
Frozen and validated, so a covariate cannot drift between categories
between training and serving without the mismatch being visible.
"""
specs: tuple[InputSpec, ...]
def __post_init__(self) -> None:
names = [spec.name for spec in self.specs]
if len(names) != len(set(names)):
raise ShapeMismatch('duplicate covariate names in the schema')
if not self.by_kind(InputKind.OBSERVED_PAST):
raise ShapeMismatch('a forecaster needs at least one observed history')
def by_kind(self, kind: InputKind) -> tuple[InputSpec, ...]:
return tuple(spec for spec in self.specs if spec.kind is kind)
def encoder_variables(self) -> tuple[InputSpec, ...]:
"""History sees everything: the past is fully observed."""
return tuple(
spec for spec in self.specs if spec.kind is not InputKind.STATIC
)
def decoder_variables(self) -> tuple[InputSpec, ...]:
"""The future sees ONLY known-future covariates. This method is the
whole leakage guarantee, expressed once rather than per call site."""
return self.by_kind(InputKind.KNOWN_FUTURE)
def fingerprint(self) -> tuple[tuple[str, str], ...]:
"""Comparable across training and serving, so a silently reclassified
covariate is caught at load time rather than never."""
return tuple((spec.name, spec.kind.value) for spec in self.specs)
def assert_schema_matches(trained: InputSchema, serving: InputSchema) -> None:
"""Guard clause for the failure that has no other symptom."""
if trained.fingerprint() != serving.fingerprint():
raise LeakageError(
'serving schema differs from the trained schema; a covariate has '
'changed kind and the model is no longer valid'
)
@dataclass(frozen=True)
class QuantileSet:
"""Quantile levels, validated and kept sorted.
Sorted because the crossing repair depends on it, and validated because a
level outside (0, 1) produces a loss that is silently unbounded.
"""
levels: tuple[float, ...] = (0.1, 0.5, 0.9)
def __post_init__(self) -> None:
if not self.levels:
raise ShapeMismatch('at least one quantile level is required')
if any(not 0.0 < level < 1.0 for level in self.levels):
raise ShapeMismatch('quantile levels must lie strictly inside (0, 1)')
if list(self.levels) != sorted(self.levels):
raise ShapeMismatch('quantile levels must be given in ascending order')
@property
def median_index(self) -> int:
"""Where the point forecast lives, if one is needed at all."""
return min(
range(len(self.levels)),
key=lambda i: abs(self.levels[i] - 0.5),
)
class SelectionResult(NamedTuple):
"""The combined vector AND the weights.
The weights are the interpretability story and must come back out rather
than being discarded inside the layer - but they are importance to this
model, not causal effect, and the name says combined first for a reason.
"""
combined: list[float]
weights: list[float]
@dataclass
class GrnParams:
"""One gated residual network's parameters."""
w1: list[list[float]]
b1: list[float]
w_value: list[list[float]]
b_value: list[float]
w_gate: list[list[float]]
b_gate: list[float]
ln_gain: list[float]
ln_shift: list[float]
w_context: list[list[float]] | None = field(default=None)
def sigmoid(value: float) -> float:
"""Branch on sign so exp cannot overflow on a large negative input."""
if value >= 0.0:
return 1.0 / (1.0 + math.exp(-value))
positive = math.exp(value)
return positive / (1.0 + positive)
def elu(value: float) -> float:
"""ELU, not ReLU: a gated-off GRN should pass small negatives through
smoothly rather than clipping them, keeping the residual path useful."""
return value if value > 0.0 else math.expm1(value)
def matvec(weight: Sequence[Sequence[float]], vector: Sequence[float],
bias: Sequence[float] | None = None) -> list[float]:
if weight and len(weight[0]) != len(vector):
raise ShapeMismatch(f'weight expects {len(weight[0])} inputs, got {len(vector)}')
return [
(bias[i] if bias is not None else 0.0) + math.fsum(
w * v for w, v in zip(row, vector)
)
for i, row in enumerate(weight)
]
def layer_norm(vector: Sequence[float], gain: Sequence[float],
shift: Sequence[float], epsilon: float = 1e-6) -> list[float]:
mean = math.fsum(vector) / len(vector)
variance = math.fsum((value - mean) ** 2 for value in vector) / len(vector)
denominator = math.sqrt(variance + epsilon)
return [
(value - mean) / denominator * g + s
for value, g, s in zip(vector, gain, shift)
]
def gated_residual_network(
primary: Sequence[float],
params: GrnParams,
context: Sequence[float] | None = None,
) -> list[float]:
"""GRN(a, c) = LayerNorm(a + GLU(ELU(W1 a + W2 c))).
With the gate at zero the whole component collapses to LayerNorm(a),
which is what lets the network choose to be smaller. On the dataset sizes
forecasting actually has, that is frequently the right choice.
"""
if context is not None and params.w_context is None:
raise ShapeMismatch('context supplied but this GRN has no context projection')
hidden = matvec(params.w1, primary, params.b1)
if context is not None and params.w_context is not None:
# No bias here: the primary branch already carries one.
for i, value in enumerate(matvec(params.w_context, context)):
hidden[i] += value
hidden = [elu(value) for value in hidden]
values = matvec(params.w_value, hidden, params.b_value)
gates = matvec(params.w_gate, hidden, params.b_gate)
gated = [value * sigmoid(gate) for value, gate in zip(values, gates)]
residual = [a + g for a, g in zip(primary, gated)]
return layer_norm(residual, params.ln_gain, params.ln_shift)
def stable_softmax(scores: Sequence[float]) -> list[float]:
peak = max(scores)
exponentials = [math.exp(score - peak) for score in scores]
total = math.fsum(exponentials)
return [value / total for value in exponentials]
def variable_selection(
embeddings: Sequence[Sequence[float]],
selection_params: GrnParams,
per_variable_params: Sequence[GrnParams],
static_context: Sequence[float] | None = None,
) -> SelectionResult:
"""Softmax over VARIABLES, computed fresh at every timestep."""
if len(embeddings) != len(per_variable_params):
raise ShapeMismatch('one per-variable GRN is required for each variable')
flattened = [value for embedding in embeddings for value in embedding]
scores = gated_residual_network(flattened, selection_params, static_context)
weights = stable_softmax(scores[:len(embeddings)])
# Selection and transformation are separate jobs: each variable passes
# through its own GRN before the weighted combination.
transformed = [
gated_residual_network(embedding, params)
for embedding, params in zip(embeddings, per_variable_params)
]
width = len(transformed[0])
combined = [0.0] * width
for weight, vector in zip(weights, transformed):
for d in range(width):
combined[d] += weight * vector[d]
return SelectionResult(combined=combined, weights=weights)
def quantile_loss(
predictions: Sequence[float],
target: float,
quantiles: QuantileSet,
) -> float:
"""Pinball loss summed over levels.
For q = 0.9 under-prediction costs nine times over-prediction, so the
minimizer is the 90th percentile. No distribution is assumed anywhere.
"""
if len(predictions) != len(quantiles.levels):
raise ShapeMismatch(
f'{len(predictions)} predictions for {len(quantiles.levels)} levels'
)
total = 0.0
for prediction, level in zip(predictions, quantiles.levels):
error = target - prediction
total += max(level * error, (level - 1.0) * error)
return total
def repair_crossing(predictions: Sequence[float]) -> list[float]:
"""Levels are trained independently, so nothing prevents the 90th
percentile falling below the 50th. Sorting is the crude repair and it is
what most production systems do - but it should be COUNTED, because
frequent crossing means the tail quantiles are not trustworthy."""
return sorted(predictions)
def crossing_rate(batch: Sequence[Sequence[float]]) -> float:
"""Monitor rather than silently repair: this belongs on a dashboard."""
if not batch:
return 0.0
crossed = sum(1 for row in batch if list(row) != sorted(row))
return crossed / len(batch)
What changed: The arithmetic barely changes; the contract does. TFT’s central idea is the static / known-future / observed-past classification, and its signature production failure is a covariate silently changing category between training and serving — which does not raise an error, it reports excellent validation accuracy. So the classification becomes an enum, the schema becomes a frozen validated structure that derives which variables the encoder and decoder may each see, and the leakage guarantee is expressed once in decoder_variables rather than being re-derived at every call site. A schema fingerprint makes the mismatch detectable at load time, with its own exception type because the consequence is specific and severe. The quantile set is validated and kept sorted, since a level outside the unit interval makes the loss silently unbounded and the crossing repair depends on the ordering. Crossing itself gets a rate function rather than only a repair, because repairing quietly hides that the tail quantiles have stopped being trustworthy. ELU rather than ReLU is documented as deliberate, and the sigmoid branches on sign so a large negative gate cannot overflow.
Identical asymptotics; the change is in what fails loudly rather than in speed. Illustrative, not a measured benchmark: fsum and the validation add a small constant and buy a contract that holds across the training-to-serving boundary.
Faster, and the justification for each change.
"""Batched over series, horizons and variables. Every GRN becomes a GEMM.
Three changes:
1. A GRN is two matrix products and pointwise arithmetic. Batched across
(series x timestep) it is one GEMM, and the value and gate branches
share ONE weight with 2 * d columns so the pair costs one product.
2. The per-variable GRNs, which the loop version runs V times, become a
single batched matmul over a (V, d_in, d_out) weight stack - einsum,
not a loop, because V is typically a dozen and the loop overhead
dominates the arithmetic at that size.
3. Quantile loss vectorizes exactly: the pinball max is a where over a
sign, so every level and horizon is scored in one pass.
"""
from __future__ import annotations
import numpy as np
from numpy.typing import NDArray
FLOAT = np.float32
def elu_(values: NDArray[np.float32]) -> NDArray[np.float32]:
"""In-place ELU. expm1 rather than exp minus one: for small negative
inputs the naive form loses every significant digit."""
negative = values < 0.0
values[negative] = np.expm1(values[negative])
return values
def gated_residual_network(
primary: NDArray[np.float32],
w1: NDArray[np.float32],
b1: NDArray[np.float32],
w_gated: NDArray[np.float32],
b_gated: NDArray[np.float32],
gain: NDArray[np.float32],
shift: NDArray[np.float32],
context: NDArray[np.float32] | None = None,
w_context: NDArray[np.float32] | None = None,
) -> NDArray[np.float32]:
"""primary is (..., d). Everything leading batches.
w_gated is (d_hidden, 2 * d): value and gate side by side, so the GLU
costs one GEMM rather than two over the same input.
"""
hidden = primary @ w1
hidden += b1
if context is not None and w_context is not None:
# Static context broadcasts over the time axis rather than being
# tiled - the whole reason static covariates are kept separate.
hidden += context @ w_context
elu_(hidden)
projected = hidden @ w_gated
projected += b_gated
width = projected.shape[-1] // 2
value = projected[..., :width]
gate = projected[..., width:]
# Fused sigmoid-and-multiply, in place over the gate buffer, so the
# gated result reuses memory the projection already owns.
np.negative(gate, out=gate)
np.exp(gate, out=gate)
gate += 1.0
np.divide(value, gate, out=value)
value += primary
# Layer norm with the two-pass variance: the one-pass E[x^2] - E[x]^2
# form cancels catastrophically in float32 once the mean is large
# relative to the spread, which is where residual streams end up.
mean = value.mean(axis=-1, keepdims=True, dtype=np.float32)
value -= mean
variance = np.mean(value * value, axis=-1, keepdims=True, dtype=np.float32)
value /= np.sqrt(variance + FLOAT(1e-6))
value *= gain
value += shift
return value
def variable_selection(
embeddings: NDArray[np.float32],
selection_weights: tuple[NDArray[np.float32], ...],
per_variable_w1: NDArray[np.float32],
per_variable_b1: NDArray[np.float32],
per_variable_gated: NDArray[np.float32],
per_variable_gain: NDArray[np.float32],
per_variable_shift: NDArray[np.float32],
static_context: NDArray[np.float32] | None = None,
) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
"""embeddings is (batch, time, num_variables, d).
Returns the combined vector and the selection weights, which have to come
back out - they are the interpretability story, and they are importance
to this model rather than causal effect.
"""
batch, steps, num_variables, width = embeddings.shape
# Selection scores from the flattened per-variable embeddings.
flattened = embeddings.reshape(batch, steps, num_variables * width)
scores = gated_residual_network(
flattened, *selection_weights, context=static_context,
)[..., :num_variables]
scores -= scores.max(axis=-1, keepdims=True)
np.exp(scores, out=scores)
scores /= scores.sum(axis=-1, keepdims=True)
# Every per-variable GRN at once: a (V, d, d_hidden) weight stack
# contracted against a (B, T, V, d) input. The loop version runs V small
# GEMMs; this runs one batched contraction, and with V around a dozen
# the loop overhead was the dominant cost.
hidden = np.einsum('btvd,vdh->btvh', embeddings, per_variable_w1, optimize=True)
hidden += per_variable_b1
elu_(hidden)
projected = np.einsum('btvh,vhg->btvg', hidden, per_variable_gated, optimize=True)
half = projected.shape[-1] // 2
value, gate = projected[..., :half], projected[..., half:]
np.negative(gate, out=gate)
np.exp(gate, out=gate)
gate += 1.0
np.divide(value, gate, out=value)
value += embeddings
mean = value.mean(axis=-1, keepdims=True, dtype=np.float32)
value -= mean
variance = np.mean(value * value, axis=-1, keepdims=True, dtype=np.float32)
value /= np.sqrt(variance + FLOAT(1e-6))
value *= per_variable_gain
value += per_variable_shift
# The weighted combination is a contraction over the variable axis, not
# a loop with an accumulator.
combined = np.einsum('btv,btvd->btd', scores, value, optimize=True)
return combined, scores
def quantile_loss(
predictions: NDArray[np.float32],
targets: NDArray[np.float32],
levels: NDArray[np.float32],
observed: NDArray[np.bool_],
) -> np.float64:
"""predictions (batch, horizon, num_quantiles), targets (batch, horizon).
The pinball max reduces to a single where on the sign of the error, so
every level and every horizon is scored in one pass with no branching.
"""
counted = observed.sum(dtype=np.int64)
if counted == 0:
raise ValueError('every target in the batch is masked out')
error = targets[..., None] - predictions
# max(q*e, (q-1)*e) is q*e when e >= 0 and (q-1)*e otherwise. Written as
# a select rather than two products and a maximum, which halves the
# arithmetic and materializes one fewer full-size temporary.
loss = np.where(error >= 0.0, levels * error, (levels - 1.0) * error)
loss = loss.sum(axis=-1)
loss *= observed
return loss.sum(dtype=np.float64) / counted
def repair_crossing(predictions: NDArray[np.float32]) -> NDArray[np.float32]:
"""Sort along the quantile axis. Levels are trained independently, so
nothing prevents the 90th percentile falling below the 50th."""
return np.sort(predictions, axis=-1)
def crossing_rate(predictions: NDArray[np.float32]) -> float:
"""Monitor rather than silently repair: frequent crossing means the tail
quantiles have stopped being trustworthy, and sorting hides that."""
ordered = np.diff(predictions, axis=-1) >= 0.0
return float(1.0 - ordered.all(axis=-1).mean())
What changed: Everything distinctive about TFT reduces to matrix products once the batch, time and variable axes are treated as array dimensions. A gated residual network becomes two GEMMs and pointwise arithmetic, with the value and gate branches sharing one weight of twice the output width so the gated unit costs one product rather than two over the same input — and the sigmoid-and-multiply then runs in place over that buffer so the gated result allocates nothing. The per-variable networks, which the loop version runs once per variable, become a single einsum against a stacked weight tensor: with a dozen variables the per-call overhead was dominating genuinely small matrices, so batching them is a larger win than the arithmetic suggests. Static context broadcasts over the time axis rather than being tiled, which is the concrete payoff of keeping static covariates separate in the first place. The quantile loss vectorizes exactly, and the pinball maximum is written as a select on the sign of the error rather than two products and a maximum, which halves the arithmetic and removes a full-size temporary. ELU uses expm1 because the naive exp-minus-one form loses every significant digit on small negative inputs.
Vectorize to NumPy/BLAS (@, np.dot, einsum)
Every GRN becomes a GEMM over the flattened batch-and-time axis, and the per-variable stack becomes one einsum instead of a dozen small products.
Trade-off: einsum over a four-axis operand picks a contraction order that is not always the best one, and on small variable counts the optimize pass can cost more than the contraction it plans.
Fuse adjacent operations so intermediates are never materialized
The value and gate come from one projection, the sigmoid-and-multiply runs in place over it, and the layer-norm centring reuses the same buffer through to the output.
Trade-off: The pre-gate activations are destroyed, so diagnosing a GRN that has gated itself entirely off — the failure mode this architecture is most prone to — needs an unfused pass to inspect.
Eliminate Python-level loops over samples
Series, timesteps, variables and quantile levels all become array axes, so a batch flows through selection and loss with a constant number of interpreter operations.
Trade-off: All variables are padded to a common embedding width and all series to a common window, so a panel with heterogeneous covariate sets wastes work on the padding.
Pre-allocate output arrays and use in-place operations
The gate, the layer-norm centring and the loss reduction all write into buffers that already exist, so the hot path does not enter the allocator.
Trade-off: The functions mutate arrays they were handed, so a caller that reuses an input after the call gets a silently modified one — a real hazard when the same embedding tensor feeds both encoder and decoder selection.
Each GRN becomes one GEMM for the whole batch; the per-variable stack becomes one contraction rather than V small ones. Illustrative, not a measured benchmark: with the GRNs vectorized the profile shifts to the sequential LSTM, which is where a real TFT implementation spends most of its wall clock.