What it actually does
A hundred traffic sensors are not a hundred independent series: congestion at one junction shows up downstream twenty minutes later, and the road network says which junctions are downstream. So alternate two operations — mix each node with its graph neighbours, then mix each node with its own recent past — and stack them. Spatial dependency comes from the graph rather than from being learned blind, which is the entire reason this beats a per-series model when the network is real.
- The graph encodes real dependency between nodes and is stable over the deployment horizon — this is the load-bearing assumption and the one most often false
- Node set and topology are fixed between retrains; the architecture has no native way to handle a node it has never seen
- Spatial dependency is local enough to be captured within K hops, so the truncated operator loses nothing that matters
- All nodes are sampled on a common clock, with missingness handled by masking rather than by resampling
- The relationship between neighbours is stationary enough that patterns learned on history persist into the forecast window
Loss function
An ordinary masked mean-absolute-error over every node and every horizon — nothing about the loss is special, and that is worth noticing. All of the modelling is in f, specifically in the adjacency it is handed. MAE rather than MSE is near-universal here because traffic and load series have heavy-tailed spikes that squared error chases at the expense of everything else. The mask is not hygiene either: on real sensor networks five to fifteen percent of readings are missing at any moment, and an unmasked loss trains the model to predict the imputation.
- the observed (node, time, horizon) triples — sensor networks always have gaps, and the loss must be masked to them
- the normalized adjacency or transition matrix; the structural prior, given rather than learned
- the input window: L timesteps by N nodes by C channels
- forecast horizon, typically emitted for all h at once rather than recursively
- number of nodes; the spatial cost is quadratic in it only if the graph is dense
How it is minimized
Adam on the masked MAE, with the graph operator applied as repeated sparse matrix products rather than as a materialized power
- k-step diffusion — reach exactly k hops, never materialized as a dense matrix
- diffusion order, the receptive field in hops; the single most important structural hyperparameter
- a separate channel mixing matrix per hop, so near and far neighbours are weighted differently
- the gated linear unit on the temporal convolution — the gate decides which timesteps pass
Two independent choices, and conflating them is the usual source of confusion. Spatially, the operator is a truncated polynomial in the transition matrix: hop k reaches exactly k steps out and gets its own weights, so the model is not forced to treat an immediate neighbour like a distant one. It is computed as K successive sparse products against the feature matrix, never as a matrix power — P raised to K is dense even when P is not, and materializing it turns a linear-in-edges operation into a quadratic-in-nodes one. Temporally, a causal gated convolution replaces a recurrence in the modern variants, which buys parallelism across time at the cost of a receptive field fixed by depth. Adam is standard; the loss surface is unremarkable and the training is stable, which is not true of most architectures in this category.
diffusion order K
Receptive field in hops. Past 3 the operator over-smooths and every node converges to the graph average (1 to 3)
temporal kernel width
Timesteps mixed per layer; total temporal receptive field is depth times width minus depth (2 to 6)
hidden channels
Capacity per node per step; cost is quadratic in it for the channel mixing (16 to 128)
input window L
History fed in; must exceed the total temporal receptive field or the deeper layers see nothing (12 to 96 steps)
adjacency threshold
Where a Gaussian-kernel distance graph is cut to zero; controls sparsity and therefore the whole cost profile (0.05 to 0.2)
adaptive adjacency rank
For the learned-graph variants: rank of the node-embedding product that supplements the given graph (10 to 40)
Stable and unremarkable to train, which is the appeal — but with two characteristic and unrelated failures. The first is over-smoothing: increase K or depth and node representations converge toward the graph mean, so the model produces a well-behaved network-average forecast and loses exactly the local detail it was built for. The signature is validation error that improves with depth and then plateaus above the per-node baseline. The second is a wrong graph, which never announces itself: a physically implausible adjacency trains perfectly happily to a slightly worse result than no graph at all, and the only way to detect it is to ablate the graph and compare against an identity adjacency. That ablation should be the first experiment, not the last.
O(K · |E| · C + N · C²) per layer per timestep — linear in edges for the diffusion, quadratic in channels for the mixing. The channel term usually dominates on a sparse road network. Parallel across nodes and, in the convolutional variants, across time; the recurrent variants surrender the latter.
How, where, and why it is used
Time-Series Forecasting
Primary fitFeed a window of L timesteps over all N nodes plus the adjacency, and emit all H horizons for all nodes in one pass. The graph is built from whatever encodes proximity in the domain — road-network distance, electrical topology, physical adjacency — and normalized to a transition matrix. Known covariates such as time of day enter as extra input channels on every node.
- Traffic speed and volume forecasting on road sensor networks, the benchmark task this family was created for
- Electricity load and renewable generation across a grid, where the network topology is known exactly
- Ride-hailing and delivery demand over a spatial cell grid, with adjacency from cell contiguity
- Water and gas distribution network flow, where physical connectivity is documented and hydraulically meaningful
Worth its cost when the cross-series dependency is real, directional and known — and then the gain over a per-series model is large, because a downstream sensor genuinely does see upstream congestion later. The honest trigger is the graph, not the data volume: with a physically meaningful adjacency this is the right architecture, and with a graph built from correlation of the target it usually is not, since that graph is fitted to the training period and drifts. On a handful of series with no meaningful network it loses to gradient boosting on lag features, and it loses while costing far more to serve.
- Build the adjacency from domain distance with a thresholded Gaussian kernel, and keep it sparse — a dense graph destroys both the cost profile and the locality
- Mask missing readings in the loss rather than imputing them, or the model learns the imputation as a target
- Scale globally across nodes rather than per node, so a node with sparse history borrows the shared scale
- Add time-of-day and day-of-week as input channels; without them the model spends capacity rediscovering the daily cycle
- Emit all horizons directly rather than decoding recursively, which stops the error compounding across the horizon
Rolling-origin backtesting reporting MAE, RMSE and MAPE per horizon separately — an aggregate over horizons hides that the short ones are easy. Two ablations are mandatory rather than optional: replace the adjacency with the identity, which measures what the graph is actually worth, and replace it with a random graph of the same density, which catches a model that is merely benefiting from smoothing.
- A correlation-derived adjacency that is fitted to the training window and silently stale by deployment
- Over-smoothing at higher K producing a plausible network-average forecast that is worse than a per-node model
- Skipping the identity-adjacency ablation and therefore never learning whether the graph contributed anything
- Nodes added or removed after training, which most implementations cannot accommodate without a full retrain
Anomaly Detection
ViableTrain the forecaster on normal operation, then score each node at each step by how far the observation falls from its own prediction, normalized per node. Because the prediction conditions on the neighbours, a reading that is unremarkable in isolation but inconsistent with the rest of the network is caught — which is the specific class of fault a per-sensor threshold cannot see.
- Sensor fault and drift detection in industrial and utility networks, distinguishing a broken sensor from a genuine local event
- Grid and pipeline monitoring, where a real fault propagates along the topology and an instrument failure does not
- Cellular and CDN performance monitoring, where the graph is the service topology
- Detecting the actual event — an accident, a burst main — as a coherent deviation across a neighbourhood of nodes
The graph earns its place here more clearly than in plain forecasting: the discriminating question is whether the neighbours agree, and a per-series detector cannot ask it. That makes the sensor-fault versus real-event distinction tractable, which is the one operators actually care about and the one that generates most false alarms. Against it: it needs a clean training period that is genuinely normal, the residual threshold has to be calibrated per node because sensors differ in noise, and it is the wrong tool entirely for point outliers in tabular data, where an isolation forest costs a thousandth as much.
- Normalize residuals per node before thresholding; sensors differ in noise by an order of magnitude and a shared threshold alarms on the noisiest
- Score over a short window rather than a single step, since one-step residuals are dominated by noise
- Hold out a known-normal period for calibration that is separate from the training period
- Keep a graph-agreement term explicitly — whether the neighbours also deviated — as the feature that separates a sensor fault from an event
Precision and recall at a fixed alert budget on labelled incidents, evaluated with a detection window rather than exact-timestep matching, since an alarm a minute early is a success. Report sensor faults and genuine events separately: they are different problems and a single number lets one hide behind the other.
- A training period that quietly contains the faults you are trying to detect, which teaches the model they are normal
- One global residual threshold across heterogeneous sensors, which produces alerts ranked by noise rather than by anomaly
- Missing data reading as a large residual and alarming, when the correct response is to abstain
- Alarm storms as one real event propagates through the graph and every neighbouring node fires separately
Optimization
AdaptedTwo genuinely separate optimization problems sit inside this architecture. The first is the graph operator itself: a truncated polynomial in the transition matrix is a deliberate low-order approximation to a spectral filter, and the reason it is computed as K sparse products rather than a matrix power is that the exact power is dense — the same sparse-versus-dense reasoning that governs iterative linear solvers. The second is the adaptive-adjacency variants, where the graph becomes a learned low-rank node-embedding product, turning graph construction from a modelling decision into a constrained parameter estimation problem.
- Chebyshev and diffusion polynomials as truncated approximations to a full spectral graph filter, avoiding an eigendecomposition entirely
- Repeated sparse matrix-vector products in place of a materialized matrix power, exactly as in Krylov methods
- Low-rank adaptive adjacency as learned structure under a rank constraint, when the true graph is unknown or wrong
- Downstream: the forecast feeds a network-level control problem — signal timing, dispatch, unit commitment — which is where the actual objective lives
Worth studying for the sparse-versus-dense lesson, which generalizes far beyond graphs: the whole architecture is fast because a k-hop operator is applied and never formed. It is also an honest example of a learned structural prior, where the rank constraint is doing the work that the missing domain knowledge would have done. What it is not is a solver — the forecast is an input to a downstream optimization, never the optimization itself, and treating a prediction as a decision is the standard mistake in the operational deployments.
- Apply the operator as K successive sparse products; forming the power turns a linear-in-edges cost into a quadratic-in-nodes one
- Row-normalize the transition matrix so repeated application does not change the scale of the features
- Bound the adaptive-adjacency rank explicitly, or it fits training-period correlation and generalizes worse than the physical graph
- Keep the given graph alongside any learned one rather than replacing it, so the physical prior survives
Compare the truncated operator against a full spectral filter on a graph small enough to eigendecompose, to see what the truncation actually costs. For adaptive adjacency, compare the learned graph against the physical one structurally, not just by validation error — a learned graph that has no physical interpretation is a warning about the next distribution shift.
- Materializing the k-hop matrix, which is the difference between a model that scales to a city and one that does not
- Raising K until the operator over-smooths, which improves training loss and degrades everything else
- Letting a learned adjacency replace the physical one, which discards the only part of the model that is guaranteed to still be true next year
Control & Operations
ViableThe forecast becomes the state estimate a network-level controller acts on: predicted flows across a road network feed signal timing, predicted load across a grid feeds unit commitment and reserve procurement. The graph in the model is the same graph the controller acts over, which is what makes the two composable at all.
- Adaptive traffic signal control taking a short-horizon network flow forecast as input
- Grid operations — reserve sizing, congestion management, curtailment planning
- Fleet repositioning against a forecast demand surface over spatial cells
The pairing is natural because the decision is network-level and so is the forecast; a per-node forecast fed to a network-level controller produces mutually inconsistent inputs. The real difficulty is not accuracy but that the model is trained on observed operation and the controller then changes that operation, so the deployment shifts the very distribution the model was fitted on — a feedback loop no amount of forecast accuracy addresses. Uncertainty also matters more than the point forecast here, and this family emits point forecasts natively.
- Emit quantiles or an interval rather than a point, since the control decision is asymmetric in cost
- Include the control action as an input channel where it is known, or the model attributes its effect to the weather
- Keep the forecast horizon matched to the control interval — a longer horizon is wasted and a shorter one is unusable
Score the decision, not the forecast: regret against the decision made with hindsight. A model with worse MAE that is better calibrated in the tail frequently produces better decisions, and forecast-accuracy metrics will never show it.
- The controller shifting the distribution the model was trained on, which degrades it in production in a way backtesting cannot reveal
- Optimizing MAE when the decision cost is asymmetric, so the model is tuned against the wrong objective
- Treating a point forecast as certain in a downstream optimizer, which produces confident and brittle plans
What it costs to run
Hours on a single GPU for a few hundred nodes and a year of history — small by deep-learning standards, because the sparse operator is cheap and the model is shallow. The convolutional variants train several times faster than the recurrent ones, since they parallelize across time as well as nodes.
One forward pass per network per interval, producing every node and horizon at once: milliseconds for a few hundred nodes. The graph is fixed, so the normalized adjacency is precomputed once and the serving path is a handful of sparse products. Batching over time windows is trivial and rarely needed, since the natural request rate is one per sampling interval.
Monthly to quarterly under stable topology, and immediately on any topology change — a new sensor or a closed road invalidates the adjacency, which is a structural input rather than a feature.
- Track per-node error, not the network aggregate; a few degraded sensors are invisible in the mean and are exactly what needs replacing
- Alert on the missing-data rate per node, since the mask silently absorbs a sensor that has stopped reporting
- Re-verify the adjacency against the current topology on a schedule; it is the input most likely to be quietly wrong
- Compare against the identity-adjacency baseline in production, not just at development time — the graph can stop helping
- Node identity is baked into the model. Adding or removing a sensor changes N and most implementations cannot serve the new network without a retrain
- The adjacency and its normalization are part of the model artifact and must be versioned with the weights; rebuilding it at load time from a changed distance table silently changes the model
- The scaler is fitted across nodes and must be persisted, never refitted at inference
- Missing inputs at serving time need an explicit policy — a mask channel the model was trained with, not a zero-fill that reads as a genuine reading of zero
Pros and cons, in context
Exploits known network structure instead of learning it blind
The decisive advantage when a physical graph exists: an upstream-to-downstream lag that a per-series model must infer from data is simply given. Worth nothing, and worse than nothing, when the graph is guessed or derived from target correlation.
One model serves every node, sharing strength across the network
A newly instrumented sensor with weeks of history borrows the learned dynamics of the whole network. This is where most of the practical gain comes from and it is the same argument as for any global forecasting model.
Cost is linear in edges, not quadratic in nodes
The k-hop operator is applied rather than formed, so a sparse city-scale network is entirely tractable. Only true if the graph is genuinely sparse — a dense adjacency removes this property completely.
Detects network-inconsistent readings, not just per-sensor outliers
The prediction conditions on the neighbours, which makes the sensor-fault versus real-event distinction possible. That specific question is what a per-series detector fundamentally cannot answer.
Requires a graph, and a wrong graph fails silently
A physically implausible adjacency trains happily to a slightly worse result than no graph at all, with no diagnostic anywhere. The identity-adjacency ablation is the only honest check and it is routinely skipped.
Over-smooths as depth or hop count grows
Node representations converge toward the graph average, producing a smooth network-average forecast that loses the local detail the model exists for. Caps useful depth at two or three layers, which is genuinely limiting.
The node set is structural, not data
Add a sensor and most implementations need a retrain. Decisive in a growing network, irrelevant in a fixed installation — and the difference is worth establishing before choosing the architecture.
Point forecasts with no native uncertainty
A problem wherever the decision needs the tail — reserve sizing, capacity, dispatch. Requires a quantile head bolted on, and the interpretable-attention alternatives in this reference give it natively.
Substantially more machinery than the baseline it must beat
Against gradient boosting on lag and neighbour-lag features it often wins by a few percent for an order of magnitude more operational complexity. On a small network with no meaningful topology it simply loses.
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.
"""One spatio-temporal block, transcribed the way the equations read.
Two operations, alternated:
spatial Z = sum_k P^k X W_k mix each node with its k-hop neighbours
temporal H = A * sigmoid(B) a causal gated convolution over time
P is the row-normalized transition matrix D^-1 A. Note carefully that P^k is
APPLIED k times and never formed - forming it makes a sparse graph dense,
which is the difference between a model that scales to a city and one that
does not. Here that is just a loop; in the fast stage it is the whole point.
Plain loops, no libraries. Adjacency as a list of neighbour lists.
"""
import math
BLANK_MASK = -1.0
def row_normalize(adjacency):
"""D^-1 A as neighbour lists of (node, weight) with weights summing to 1."""
normalized = []
for row in adjacency:
total = sum(weight for _, weight in row)
if total == 0.0:
normalized.append([])
continue
normalized.append([(node, weight / total) for node, weight in row])
return normalized
def diffusion_conv(features, transition, weights_per_hop):
"""Z[n][c_out] = sum over hops k of (P^k X)[n] . W_k[:, c_out].
features: N x C_in
transition: neighbour lists of (node, weight)
weights_per_hop: K + 1 matrices, each C_in x C_out
"""
num_nodes = len(features)
num_in = len(features[0])
num_out = len(weights_per_hop[0][0])
output = [[0.0] * num_out for _ in range(num_nodes)]
# hop_features starts at X (k = 0) and is advanced one hop at a time by a
# single sparse product. K products total, no matrix power anywhere.
hop_features = [row[:] for row in features]
for weights in weights_per_hop:
for node in range(num_nodes):
for channel_out in range(num_out):
accumulated = 0.0
for channel_in in range(num_in):
accumulated += hop_features[node][channel_in] * weights[channel_in][channel_out]
output[node][channel_out] += accumulated
# Advance one hop: next[n] = sum over neighbours m of P[n][m] * hop[m].
advanced = [[0.0] * num_in for _ in range(num_nodes)]
for node in range(num_nodes):
for neighbour, weight in transition[node]:
for channel_in in range(num_in):
advanced[node][channel_in] += weight * hop_features[neighbour][channel_in]
hop_features = advanced
return output
def gated_temporal_conv(sequence, kernel_value, kernel_gate, bias_value, bias_gate):
"""Causal 1D convolution over time with a gated linear unit.
sequence: T x N x C_in. Returns (T - width + 1) x N x C_out - a causal
convolution consumes width - 1 steps of context and cannot invent them.
"""
num_steps = len(sequence)
num_nodes = len(sequence[0])
num_in = len(sequence[0][0])
width = len(kernel_value)
num_out = len(kernel_value[0][0])
out_steps = num_steps - width + 1
output = [[[0.0] * num_out for _ in range(num_nodes)] for _ in range(out_steps)]
for step in range(out_steps):
for node in range(num_nodes):
for channel_out in range(num_out):
value = bias_value[channel_out]
gate = bias_gate[channel_out]
for offset in range(width):
for channel_in in range(num_in):
observation = sequence[step + offset][node][channel_in]
value += observation * kernel_value[offset][channel_in][channel_out]
gate += observation * kernel_gate[offset][channel_in][channel_out]
# The gate decides which timesteps pass. sigmoid(gate) near
# zero closes the channel for this step entirely.
opened = 1.0 / (1.0 + math.exp(-gate))
output[step][node][channel_out] = value * opened
return output
def masked_mae(predictions, targets, mask):
"""Sensor networks always have gaps. An unmasked loss trains the model to
predict whatever was used to fill them."""
total = 0.0
counted = 0
for step in range(len(predictions)):
for node in range(len(predictions[step])):
for horizon in range(len(predictions[step][node])):
if not mask[step][node][horizon]:
continue
total += abs(predictions[step][node][horizon] - targets[step][node][horizon])
counted += 1
if counted == 0:
raise ValueError("every target in the batch is masked out")
return total / counted
def stgcn_block(sequence, adjacency, temporal_a, temporal_b, spatial_weights):
"""The STGCN sandwich: temporal, then spatial, then temporal."""
transition = row_normalize(adjacency)
hidden = gated_temporal_conv(sequence, *temporal_a)
# The graph operator is per-timestep: it mixes space, not time.
mixed = [diffusion_conv(frame, transition, spatial_weights) for frame in hidden]
return gated_temporal_conv(mixed, *temporal_b)
O(T * (K * |E| * C + N * C^2)) per block, with the channel mixing dominating on a sparse graph. Illustrative, not a measured benchmark: five nested Python loops in the temporal convolution means a 300-node network over 12 steps is already millions of interpreter iterations per block.
How this language actually wants it written.
"""The same block, typed, with the graph in CSR and the shape contract checked.
What changes is the representation and the boundary. The adjacency becomes a
compressed sparse row structure, which is what makes "linear in edges" a
property of the code rather than of the maths. Every shape precondition is
checked once, up front, because a silent shape mismatch in a graph model
produces a plausible forecast rather than an error.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import NamedTuple, Sequence
Matrix = list[list[float]]
Tensor3 = list[list[list[float]]]
class CsrGraph(NamedTuple):
"""Row-normalized transition matrix in compressed sparse row form.
indptr has num_nodes + 1 entries; the neighbours of node n are
indices[indptr[n]:indptr[n + 1]] with the matching weights.
"""
indptr: list[int]
indices: list[int]
weights: list[float]
num_nodes: int
def out_degree(self, node: int) -> int:
return self.indptr[node + 1] - self.indptr[node]
@dataclass(frozen=True)
class BlockConfig:
"""Frozen so a block's shape contract cannot drift after construction."""
num_nodes: int
in_channels: int
hidden_channels: int
diffusion_order: int
temporal_width: int
def receptive_field(self) -> int:
"""A block is two causal convolutions, so it consumes twice the context."""
return 2 * (self.temporal_width - 1) + 1
class ShapeMismatch(ValueError):
"""Raised on a shape violation rather than broadcasting past it."""
class DegenerateGraph(ValueError):
"""Raised when the graph cannot support the requested diffusion order."""
def build_csr(edges: Sequence[tuple[int, int, float]], num_nodes: int) -> CsrGraph:
"""Build a row-normalized CSR transition matrix from a weighted edge list.
Row normalization matters: repeated application of an unnormalized
adjacency changes the feature scale at every hop, and the model then
spends its capacity undoing that.
"""
if num_nodes <= 0:
raise ShapeMismatch("a graph needs at least one node")
by_row: list[list[tuple[int, float]]] = [[] for _ in range(num_nodes)]
for source, target, weight in edges:
if not 0 <= source < num_nodes or not 0 <= target < num_nodes:
raise ShapeMismatch(f"edge ({source}, {target}) is outside the node range")
if weight < 0.0:
raise ShapeMismatch("a transition weight cannot be negative")
by_row[source].append((target, weight))
isolated = sum(1 for row in by_row if not row)
if isolated == num_nodes:
raise DegenerateGraph("every node is isolated; the graph operator is the identity")
indptr = [0]
indices: list[int] = []
weights: list[float] = []
for row in by_row:
total = math.fsum(weight for _, weight in row)
for target, weight in row:
indices.append(target)
weights.append(weight / total if total > 0.0 else 0.0)
indptr.append(len(indices))
return CsrGraph(indptr=indptr, indices=indices, weights=weights, num_nodes=num_nodes)
def spmm(graph: CsrGraph, features: Matrix) -> Matrix:
"""One sparse product: out[n] = sum over neighbours m of P[n][m] * X[m].
The only place the graph is touched. Applying this k times gives P^k X
without ever forming P^k, which is what keeps the cost linear in edges.
"""
if len(features) != graph.num_nodes:
raise ShapeMismatch(f"expected {graph.num_nodes} rows, got {len(features)}")
num_channels = len(features[0])
output = [[0.0] * num_channels for _ in range(graph.num_nodes)]
for node in range(graph.num_nodes):
row = output[node]
for slot in range(graph.indptr[node], graph.indptr[node + 1]):
weight = graph.weights[slot]
neighbour = features[graph.indices[slot]]
for channel in range(num_channels):
row[channel] += weight * neighbour[channel]
return output
def diffusion_conv(
features: Matrix,
graph: CsrGraph,
weights_per_hop: Sequence[Matrix],
) -> Matrix:
"""Z = sum over hops k of (P^k X) W_k, hop by hop."""
if not weights_per_hop:
raise ShapeMismatch("a diffusion convolution needs at least the zero-hop term")
num_out = len(weights_per_hop[0][0])
output = [[0.0] * num_out for _ in range(graph.num_nodes)]
hop = features
for hop_index, weights in enumerate(weights_per_hop):
if len(weights) != len(hop[0]):
raise ShapeMismatch(f"hop {hop_index} weight has the wrong input width")
for node, row in enumerate(hop):
target = output[node]
for channel_in, activation in enumerate(row):
if activation == 0.0:
continue
weight_row = weights[channel_in]
for channel_out in range(num_out):
target[channel_out] += activation * weight_row[channel_out]
if hop_index + 1 < len(weights_per_hop):
hop = spmm(graph, hop)
return output
def sigmoid(value: float) -> float:
"""Branch on the sign so exp never overflows 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)
class TemporalKernel(NamedTuple):
"""Value and gate branches of one gated causal convolution."""
value: Tensor3 # width x in_channels x out_channels
gate: Tensor3
value_bias: list[float]
gate_bias: list[float]
@property
def width(self) -> int:
return len(self.value)
def gated_temporal_conv(sequence: Tensor3, kernel: TemporalKernel) -> Tensor3:
"""Causal gated convolution over time. Output is width - 1 steps shorter."""
if len(sequence) < kernel.width:
raise ShapeMismatch(
f"a width-{kernel.width} kernel needs at least that many steps, got {len(sequence)}"
)
num_nodes = len(sequence[0])
num_out = len(kernel.value[0][0])
out_steps = len(sequence) - kernel.width + 1
output: Tensor3 = []
for step in range(out_steps):
frame = [[0.0] * num_out for _ in range(num_nodes)]
for node in range(num_nodes):
values = list(kernel.value_bias)
gates = list(kernel.gate_bias)
for offset in range(kernel.width):
observations = sequence[step + offset][node]
value_slab = kernel.value[offset]
gate_slab = kernel.gate[offset]
for channel_in, activation in enumerate(observations):
if activation == 0.0:
continue
value_row = value_slab[channel_in]
gate_row = gate_slab[channel_in]
for channel_out in range(num_out):
values[channel_out] += activation * value_row[channel_out]
gates[channel_out] += activation * gate_row[channel_out]
frame[node] = [
value * sigmoid(gate) for value, gate in zip(values, gates)
]
output.append(frame)
return output
def masked_mae(predictions: Tensor3, targets: Tensor3, observed: Sequence[Sequence[Sequence[bool]]]) -> float:
"""Mean absolute error over observed entries only.
Guard clause first: an all-masked batch is a data-pipeline bug, and
returning zero for it would let training proceed on nothing at all.
"""
total = 0.0
counted = 0
for step, (prediction_frame, target_frame, mask_frame) in enumerate(
zip(predictions, targets, observed)
):
for node, (prediction_row, target_row, mask_row) in enumerate(
zip(prediction_frame, target_frame, mask_frame)
):
if len(prediction_row) != len(target_row):
raise ShapeMismatch(f"step {step} node {node} horizon width disagrees")
for prediction, target, is_observed in zip(prediction_row, target_row, mask_row):
if not is_observed:
continue
total += abs(prediction - target)
counted += 1
if counted == 0:
raise ShapeMismatch("every target in the batch is masked out")
return total / counted
def stgcn_block(
sequence: Tensor3,
graph: CsrGraph,
config: BlockConfig,
first: TemporalKernel,
spatial: Sequence[Matrix],
second: TemporalKernel,
) -> Tensor3:
"""Temporal, spatial, temporal. Preconditions checked before any work."""
if len(sequence) < config.receptive_field():
raise ShapeMismatch(
f"block needs {config.receptive_field()} steps, got {len(sequence)}"
)
if len(spatial) != config.diffusion_order + 1:
raise ShapeMismatch("one weight matrix per hop, including the zero-hop term")
hidden = gated_temporal_conv(sequence, first)
# The graph operator mixes space and never time: applied per frame.
mixed = [diffusion_conv(frame, graph, spatial) for frame in hidden]
return gated_temporal_conv(mixed, second)
What changed: The graph representation is the substantive change. Neighbour lists become compressed sparse row, which is what turns "linear in the number of edges" from a claim about the mathematics into a property of the code — one flat traversal of indices and weights per hop, with no per-node allocation. Row normalization moves into the CSR construction where it belongs, since applying an unnormalized transition matrix repeatedly rescales the features at every hop and the model then wastes capacity undoing it. Shape preconditions are checked once at the boundary rather than trusted, because a graph model that receives the wrong shape produces a plausible forecast instead of an error, and the causal-convolution length contract — a block consumes twice the kernel width minus two steps of context — is stated in the config rather than rediscovered by the caller. The sigmoid branches on sign so a large negative gate cannot overflow, which the literal version does not survive.
Same asymptotics with a much better constant: CSR traversal is one pass over the edge arrays per hop instead of a nested list walk. Illustrative, not a measured benchmark — the sparsity skip on zero activations helps on ReLU-heavy hidden states and does nothing on dense inputs.
Faster, and the justification for each change.
"""Batched, vectorized, and fused. The hop loop stays; every other loop goes.
Three structural changes:
1. The temporal convolution becomes one matrix product. A causal window of
width w over T steps is a strided view of shape (T - w + 1, w, N, C),
which reshapes to a 2-D matrix and multiplies against the flattened
kernel. Value and gate branches are one product with 2 * C_out columns,
so the two are fused rather than computed separately.
2. The graph operator becomes a sparse-dense product per hop. K products,
never a matrix power - the same rule as the literal version, but now the
reason is visible: forming P^K would be a dense N x N allocation.
3. The batch and time axes collapse into the leading dimension of a single
BLAS call, so channel mixing is one GEMM instead of T separate ones.
"""
from __future__ import annotations
import numpy as np
from numpy.typing import NDArray
from numpy.lib.stride_tricks import sliding_window_view
from scipy.sparse import csr_matrix
FLOAT = np.float32
class SpatioTemporalBlock:
"""Holds the graph and the pre-flattened kernels. Both are call-invariant."""
def __init__(
self,
transition: csr_matrix,
diffusion_order: int,
temporal_width: int,
) -> None:
if transition.shape[0] != transition.shape[1]:
raise ValueError("the transition matrix must be square")
# CSR with sorted indices and a single dtype: scipy dispatches to its
# fast path only when both hold, and silently copies when they do not.
transition = transition.astype(FLOAT)
transition.sort_indices()
self._transition = transition
self._order = diffusion_order
self._width = temporal_width
self._num_nodes = transition.shape[0]
def gated_temporal(
self,
sequence: NDArray[np.float32],
kernel: NDArray[np.float32],
bias: NDArray[np.float32],
) -> NDArray[np.float32]:
"""sequence (B, T, N, C_in) -> (B, T - w + 1, N, C_out).
kernel is (w * C_in, 2 * C_out): value and gate side by side, so one
GEMM produces both branches and the gate is never a second pass.
"""
batch, steps, nodes, channels = sequence.shape
if steps < self._width:
raise ValueError(f"need at least {self._width} steps, got {steps}")
# A strided view, not a copy: the (w, C) window per output step is
# materialized only by the reshape below, and only once.
windows = sliding_window_view(sequence, self._width, axis=1)
# (B, T', N, C, w) -> (B, T', N, w, C) -> flat rows of w * C
windows = np.moveaxis(windows, -1, -2)
out_steps = steps - self._width + 1
flat = np.ascontiguousarray(
windows.reshape(batch * out_steps * nodes, self._width * channels)
)
projected = flat @ kernel
projected += bias
value, gate = np.split(projected, 2, axis=1)
# In-place sigmoid on the gate, then in-place multiply: the gated
# output reuses the value buffer and no intermediate is materialized.
np.negative(gate, out=gate)
np.exp(gate, out=gate)
gate += 1.0
np.divide(value, gate, out=value)
return value.reshape(batch, out_steps, nodes, -1)
def diffusion(
self,
frames: NDArray[np.float32],
weights: NDArray[np.float32],
) -> NDArray[np.float32]:
"""frames (B, T, N, C_in), weights (K + 1, C_in, C_out) -> (B, T, N, C_out).
The hop loop is the only Python-level loop left, and it is K long.
"""
batch, steps, nodes, channels = frames.shape
if nodes != self._num_nodes:
raise ValueError(f"expected {self._num_nodes} nodes, got {nodes}")
# (B * T, N, C) with N in the middle: the sparse product needs nodes
# as the leading axis of a 2-D operand, so the reshape below puts
# (N, B * T * C) contiguously and the SpMM sees one dense block.
hop = frames.reshape(batch * steps, nodes, channels)
out_channels = weights.shape[2]
output = np.zeros((batch * steps, nodes, out_channels), dtype=FLOAT)
for hop_index in range(self._order + 1):
# einsum over (BT, N, C_in) x (C_in, C_out) is one GEMM after the
# implicit reshape, and it accumulates straight into output.
output += np.einsum('bnc,co->bno', hop, weights[hop_index], optimize=True)
if hop_index < self._order:
# One sparse product advances every batch and timestep at
# once: transpose nodes to the front, multiply, transpose back.
as_matrix = hop.transpose(1, 0, 2).reshape(nodes, -1)
advanced = self._transition @ as_matrix
hop = advanced.reshape(nodes, batch * steps, channels).transpose(1, 0, 2)
hop = np.ascontiguousarray(hop)
return output.reshape(batch, steps, nodes, out_channels)
def masked_mae(
predictions: NDArray[np.float32],
targets: NDArray[np.float32],
observed: NDArray[np.bool_],
) -> np.float32:
"""One pass, no boolean indexing - the mask becomes a weight.
Boolean indexing would allocate a compacted copy of every observed value;
multiplying by the mask and dividing by its sum touches each element once.
"""
counted = observed.sum(dtype=np.int64)
if counted == 0:
raise ValueError("every target in the batch is masked out")
residual = np.subtract(predictions, targets, dtype=FLOAT)
np.abs(residual, out=residual)
residual *= observed
return residual.sum(dtype=np.float64) / counted
What changed: The temporal convolution stops being a loop and becomes a single matrix product: a causal window of width w is a strided view over the time axis, and once flattened, mixing (w * C_in) inputs to C_out outputs is exactly a GEMM. The value and gate branches are concatenated into one kernel with twice the output width, so the gated linear unit costs one product rather than two, and the sigmoid-and-multiply runs in place over that buffer so the gated result never allocates. The graph operator becomes one sparse-dense product per hop with the batch and time axes folded into the dense operand, which means K SpMM calls total instead of K per timestep — and the hop loop remains, deliberately, because forming the matrix power is the thing this whole architecture is built to avoid. The loss turns the mask into a multiplicative weight rather than a boolean index, which avoids allocating a compacted copy of every observed value.
Vectorize to NumPy/BLAS (@, np.dot, einsum)
Both the temporal window mixing and the per-hop channel mixing become single GEMM calls covering the whole batch and every timestep at once.
Trade-off: The window flattening materializes a (T * N) by (w * C) matrix, so peak memory scales with the kernel width — a width-6 kernel holds six copies of the activations while the product runs.
Fuse adjacent operations so intermediates are never materialized
Value and gate share one kernel and one product, the sigmoid and the multiply run in place over that buffer, and the per-hop mixing accumulates directly into the output array.
Trade-off: The value buffer is destroyed by the in-place gating, so nothing downstream can inspect the pre-gate activations — which is exactly what you want when debugging a dead gate.
Ensure contiguous memory layout and a single dtype
Sorted-index float32 CSR is the only form scipy dispatches to its fast SpMM path for, and the transposes are re-contiguized so each hop product reads a dense block rather than a strided one.
Trade-off: Each hop pays an explicit copy to restore contiguity, and float32 accumulation over many hops and channels drifts measurably against float64 — visible on long-horizon forecasts where errors compound.
K sparse products plus K GEMMs per block for the whole batch, against T * N * C^2 interpreter iterations. Illustrative, not a measured benchmark: the remaining cost is dominated by the channel-mixing GEMM on a sparse graph, which is why hidden width rather than node count sets the training budget.