What it actually does
Each node builds a representation by gathering messages from its neighbours, combining them, and updating itself — then repeats. After k rounds a node has absorbed information from everything within k hops. It is convolution generalized off the grid: a CNN assumes neighbours are the adjacent pixels, a GNN asks you to supply the adjacency explicitly. That single change is what lets it operate on molecules, payment networks, and road systems, where "nearby" is a relationship rather than a coordinate.
- A meaningful graph structure exists and is known — this is the prior, and a wrong graph is worse than none
- The homophily assumption roughly holds: connected nodes tend to be similar
- Relevant information lies within a few hops, since depth is capped by over-smoothing
- The graph is stable enough between training and serving that the learned structure still applies
Loss function
Gather, aggregate, update — repeated k times. The aggregator must be permutation-invariant because a node's neighbours have no canonical order, and getting that wrong is the difference between a graph network and a broken sequence model. The choice among sum, mean and max is not cosmetic: sum preserves degree information and is strictly more expressive, mean discards it, and max captures the single most salient neighbour.
- node u's representation after k rounds of message passing
- the neighbours of u — supplied by the graph, not inferred
- a PERMUTATION-INVARIANT aggregator: sum, mean, or max
- learned message and update functions, shared across all nodes
How it is minimized
Backpropagation through the unrolled message-passing rounds, AdamW, neighbour sampling at scale
- adjacency plus self-loops, so a node keeps its own signal
- symmetric degree normalization — stops hub nodes dominating
- the shared weight matrix for this layer
In the GCN formulation the whole layer collapses into one sparse matrix product, which is why it is fast despite the irregular structure. Symmetric normalization is load-bearing: without it a node with ten thousand neighbours produces activations orders of magnitude larger than a node with three, and the network spends its capacity fighting degree rather than learning structure. The depth limit is the interesting part — past roughly three layers, over-smoothing sets in and every node representation converges toward the same vector, so unlike a CNN, deeper is actively worse.
layers (hops)
Receptive field in hops; 2-3 is near-universal because of over-smoothing (2 to 3)
aggregator
sum / mean / max — expressiveness versus degree-invariance (mean for GCN, sum for GIN)
hidden width
Representation capacity per node (32 to 256)
neighbour sample size
Caps fan-out per hop so the computation graph does not explode (10 to 25 per hop)
dropout / DropEdge
Regularization; DropEdge also directly mitigates over-smoothing (0.1 to 0.5)
Trains reliably at the shallow depths it is used at. The dominant failure is over-smoothing: repeated neighbourhood averaging is a low-pass filter, so after enough rounds every node in a connected component converges to essentially the same representation and accuracy collapses. It looks like underfitting and is cured by fewer layers, not more capacity. The second failure is neighbourhood explosion — at three hops with average degree fifty, one node's computation graph touches over a hundred thousand nodes, which is why sampling is mandatory at any real scale.
O(|E| · d) per layer with sparse operations — linear in edges, not in nodes squared. Memory is the practical constraint: full-batch training requires the whole graph resident, which is what neighbour sampling exists to avoid.
How, where, and why it is used
Time-Series Forecasting
ViableSpatio-temporal GNN: a temporal module (recurrence, dilated convolution, or attention) handles the within-series dynamics, and message passing handles the between-series coupling. Alternate the two per layer.
- Traffic forecasting where road-network topology determines how congestion propagates
- Power-grid load forecasting across physically connected substations
- Multi-site sensor forecasting where the physical layout is known
The right choice exactly when the series are *coupled by a known structure*. That is the honest trigger — if you have many series but no meaningful adjacency, a global sequence model is simpler and does as well. When the topology is real, encoding it directly beats making an unconstrained model rediscover it from correlations.
- Build the adjacency from physical structure where it exists; a correlation-thresholded graph is a much weaker prior
- Normalize per node — series on the same graph often differ in scale by orders of magnitude
- Include edge features such as distance or capacity; they usually carry real signal
Rolling-origin backtesting with MASE, plus a comparison against a per-series classical model to confirm the graph structure is actually earning its cost.
- A graph built from correlations can encode leakage if the correlations were computed over the full series
- Over-smoothing flattens the spatial signal, so more hops usually makes forecasts worse
Anomaly Detection
ViableScore by reconstruction of node features or graph structure, or supervised where labels exist. The distinguishing capability is detecting anomalous *structure* — a node whose connectivity pattern is unusual even though its own features look ordinary.
- Fraud-ring detection where individually clean accounts form a suspicious subgraph
- Network intrusion detection over communication topology
- Money-laundering detection across transaction chains
This is where it genuinely beats tabular methods: gradient boosting sees one account at a time and cannot see that twelve of them transact only with each other. Relational anomalies are invisible per-row and obvious in the graph, and that is the whole argument for the extra complexity.
- Include structural features — degree, clustering coefficient, centrality — alongside node attributes
- Train on confirmed-clean subgraphs; a contaminated graph teaches the ring as normal
Precision@k against confirmed cases, evaluated at the subgraph level rather than per node, since the finding is a group.
- Adversaries deliberately structure transactions to look topologically ordinary
- Graph construction choices — what counts as an edge — change the answer more than the model does
Optimization
AdaptedLearns heuristics for combinatorial problems that are naturally graph-shaped: predict which edges belong in a good solution, then hand that to a classical solver as a warm start or a branching prior.
- Routing and vehicle-scheduling warm starts
- Learned branching heuristics inside a mixed-integer solver
Useful as a heuristic accelerator rather than a solver: it offers no feasibility guarantee and no optimality bound, so in any setting where constraints are hard it belongs inside the loop. What it contributes is a good starting point, which on large instances is where most of the solver time goes.
- Encode constraints as edges so the structure the solver cares about is visible to the model
- Train on solved instances from the same distribution — transfer across instance families is poor
Optimality gap and solver wall-clock against a cold start, with constraint violations counted as hard failures.
- No feasibility guarantee, so raw output can never be the final answer
- Generalizes poorly to instance sizes far outside the training distribution
Risk & Fraud
Primary fitBuild the graph from shared attributes — device, address, payment instrument, IP — then classify nodes or subgraphs. The edges are usually more informative than the node features.
- Fraud-ring and synthetic-identity detection
- Account-takeover detection through device and session sharing
- Anti-money-laundering across transaction networks
Fraud is fundamentally relational: individual accounts are engineered to look legitimate, and the tell is who they connect to. This is one of the clearest cases in applied ML where the graph formulation finds something no amount of per-row feature engineering will.
- Choose edge semantics deliberately — shared-device and shared-address graphs behave very differently
- Time-box edges; a device shared three years ago is not evidence today
PR-AUC and value-weighted recall on strictly time-based splits, evaluated per ring rather than per account.
- Hub nodes such as shared corporate IPs create enormous spurious neighbourhoods and must be pruned
- Label latency and adversarial adaptation both hit harder here than in tabular fraud
Recommendation & Ranking
ViableMessage passing over the user-item bipartite interaction graph, so a user's representation absorbs information from items they engaged with and from users similar to them.
- Collaborative filtering with explicit higher-order connectivity
- Cold-start mitigation through side-information graphs linking items by attribute
Captures multi-hop collaborative signal that matrix factorization approximates only implicitly. In production the added complexity often does not beat a well-tuned two-tower model plus gradient boosting, so it is a considered choice rather than a default.
- Sample neighbours aggressively — interaction graphs have extreme degree skew
- Include side-information edges to give cold items any structure at all
NDCG@k offline with online A/B as the decision, benchmarked against a two-tower baseline.
- Popularity hubs dominate message passing unless degree is normalized carefully
- Full-graph training is infeasible at catalogue scale, so sampling artefacts become part of the model
Control & Operations
ViableModel the physical or logistical network directly — substations, intersections, warehouse nodes — and predict state or recommend actions per node.
- Grid state estimation and contingency analysis
- Traffic-signal coordination across an intersection network
The topology is known, physical and stable, which is the ideal case for a graph prior: you are encoding a fact rather than hoping the model infers it. Usually paired with a classical controller that owns the safety constraints.
- Edge features carry physical properties — line capacity, road length, travel time
- Keep the graph static where the physical network is static; a changing topology needs inductive methods
Simulation against the incumbent controller with an explicit sim-to-real gap estimate.
- A topology change — a closed road, a downed line — invalidates a transductive model entirely
- No safety guarantees, so a classical override layer is mandatory
What it costs to run
Minutes to hours with neighbour sampling. Full-batch training on a large graph is memory-bound rather than compute-bound, which is the usual surprise.
Milliseconds per node once neighbourhoods are fetched — and fetching them is usually the real cost, since it is a multi-hop database traversal rather than a matrix multiply.
Weekly to monthly. In fraud, whenever the adversarial pattern shifts, which is faster than any fixed cadence.
- Track degree distribution — a shift means the graph construction upstream has changed
- Monitor for new hub nodes, which silently distort message passing across the whole component
- Watch node-representation variance across layers; collapsing variance is over-smoothing appearing in production
- Neighbourhood fetching dominates serving latency and is a graph-database problem, not an ML one
- Transductive models (plain GCN) cannot score a node absent at training time — inductive variants such as GraphSAGE are required for anything with new entities
- The graph construction rules are part of the model contract; changing what counts as an edge changes predictions everywhere
Pros and cons, in context
Encodes known relational structure directly
When the topology is real — a road network, a payment graph, a molecule — this is a fact given to the model rather than a pattern it must infer. That is a much stronger prior than anything a tabular model can express.
Detects relational patterns invisible per-row
A fraud ring of individually clean accounts is the canonical case. No amount of per-account feature engineering surfaces it, and this is the strongest argument for the architecture.
Linear in edges, not quadratic in nodes
Sparse operations keep it tractable on large graphs. The binding constraint is memory and neighbourhood fetching, not arithmetic.
Semi-supervised by nature
Unlabelled nodes still contribute through message passing, so a small labelled fraction goes a long way — genuinely valuable where labelling is expensive.
Over-smoothing caps useful depth at two or three layers
Repeated averaging is a low-pass filter, so deep GNNs converge every node to the same representation. It presents as underfitting and is cured by removing layers, which is counter-intuitive for anyone coming from CNNs.
Neighbourhood explosion makes exact training infeasible at scale
Three hops on a degree-50 graph reaches over a hundred thousand nodes per example. Sampling is mandatory and introduces variance that becomes part of the model's behaviour.
Entirely dependent on the graph you construct
Edge-definition choices move results more than architecture choices do. A poorly constructed graph is a bad prior, and a bad prior is worse than no prior.
Serving requires graph infrastructure
Multi-hop neighbourhood fetching at request time is a database problem most ML platforms are not built for, and it is where these projects usually stall operationally.
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 round of message passing - gather, aggregate, update.
The aggregator MUST be permutation-invariant: a node's neighbours have no
canonical order, so anything order-sensitive is silently wrong.
"""
def message_passing(features, adjacency, weight, aggregator="mean"):
"""features[n][d], adjacency[n] -> list of neighbour indices."""
n = len(features)
d_out = len(weight[0])
updated = []
for node in range(n):
# Self-loop: without it a node loses its own signal entirely after
# the first round.
neighbours = adjacency[node] + [node]
# Aggregate. Sum keeps degree information; mean discards it and is
# more stable when degrees vary wildly.
pooled = [0.0] * len(features[0])
for neighbour in neighbours:
for j in range(len(pooled)):
pooled[j] += features[neighbour][j]
if aggregator == "mean":
pooled = [p / len(neighbours) for p in pooled]
# Update: a shared linear transform, then a nonlinearity.
out = [0.0] * d_out
for j in range(d_out):
total = sum(pooled[i] * weight[i][j] for i in range(len(pooled)))
out[j] = max(0.0, total) # ReLU
updated.append(out)
return updated
def forward(features, adjacency, weights):
"""Stack rounds. Two or three - past that, over-smoothing collapses every
node toward the same representation."""
h = features
for weight in weights:
h = message_passing(h, adjacency, weight)
return hO(|E|·d + n·d²) per layer, with a Python loop per node and per neighbour.
How this language actually wants it written.
"""GCN - typed, sparse, with the normalization that makes it work."""
from dataclasses import dataclass
import torch
from torch import Tensor, nn
@dataclass(frozen=True)
class GnnConfig:
in_features: int
hidden: int = 64
out_features: int = 2
# Two layers. Not a shrug - over-smoothing means three is usually worse
# and four is usually much worse.
layers: int = 2
dropout: float = 0.5
def normalize_adjacency(edge_index: Tensor, num_nodes: int) -> Tensor:
"""Build the symmetric normalized adjacency with self-loops.
D^-1/2 (A + I) D^-1/2. The normalization is load-bearing: without it a
hub with 10,000 neighbours produces activations orders of magnitude
larger than a node with three, and the network spends capacity fighting
degree instead of learning structure.
"""
if edge_index.dim() != 2 or edge_index.size(0) != 2:
raise ValueError(f"edge_index must be (2, num_edges), got {tuple(edge_index.shape)}")
self_loops = torch.arange(num_nodes, device=edge_index.device)
row = torch.cat([edge_index[0], self_loops])
col = torch.cat([edge_index[1], self_loops])
degree = torch.zeros(num_nodes, device=edge_index.device)
degree.scatter_add_(0, row, torch.ones_like(row, dtype=torch.float))
inv_sqrt = degree.pow(-0.5)
# An isolated node has degree 0, and 0^-0.5 is inf. Left unhandled this
# produces NaN that spreads through the whole graph on the first matmul.
inv_sqrt[torch.isinf(inv_sqrt)] = 0.0
values = inv_sqrt[row] * inv_sqrt[col]
return torch.sparse_coo_tensor(
torch.stack([row, col]), values, (num_nodes, num_nodes)
).coalesce()
class GcnLayer(nn.Module):
def __init__(self, in_features: int, out_features: int) -> None:
super().__init__()
self.linear = nn.Linear(in_features, out_features, bias=True)
def forward(self, x: Tensor, adjacency: Tensor) -> Tensor:
# Transform FIRST, then propagate. Doing it in this order costs
# O(n·d_in·d_out + |E|·d_out); the reverse costs O(|E|·d_in + ...),
# which is worse whenever d_out < d_in, i.e. almost always.
return torch.sparse.mm(adjacency, self.linear(x))
class Gcn(nn.Module):
def __init__(self, config: GnnConfig) -> None:
super().__init__()
if config.layers < 1:
raise ValueError(f"need at least one layer, got {config.layers}")
widths = [config.in_features] + [config.hidden] * (config.layers - 1)
self.layers = nn.ModuleList(
GcnLayer(widths[i], widths[i + 1] if i + 1 < len(widths) else config.hidden)
for i in range(config.layers - 1)
)
self.output = GcnLayer(config.hidden if config.layers > 1 else config.in_features,
config.out_features)
self.dropout = nn.Dropout(config.dropout)
def forward(self, x: Tensor, adjacency: Tensor) -> Tensor:
for layer in self.layers:
x = self.dropout(torch.relu(layer(x, adjacency)))
return self.output(x, adjacency)What changed: Message passing becomes a sparse matrix product, which is the whole reason GCN is fast despite irregular structure. Symmetric degree normalization is added explicitly — without it hub nodes dominate — along with the isolated-node guard, since degree-zero yields an infinity that turns the entire graph to NaN on the first matmul. The transform-then-propagate ordering is chosen deliberately for its complexity profile.
O(|E|·d + n·d²) per layer via sparse mm.
Faster, and the justification for each change.
"""GNN at scale - neighbour sampling, CSR adjacency, fused scatter."""
import numpy as np
import torch
from numpy.typing import NDArray
from torch import Tensor
def build_csr(edge_index: NDArray[np.int64], num_nodes: int) -> tuple[NDArray, NDArray]:
"""Compressed sparse row adjacency: (indptr, indices).
A COO edge list means a node's neighbours are scattered across memory.
CSR puts them in one contiguous run, so sampling a neighbourhood is a
single slice rather than a filtered scan of every edge.
"""
order = np.argsort(edge_index[0], kind="stable")
sorted_src = edge_index[0][order]
indices = edge_index[1][order].astype(np.int64)
counts = np.bincount(sorted_src, minlength=num_nodes)
indptr = np.zeros(num_nodes + 1, dtype=np.int64)
np.cumsum(counts, out=indptr[1:])
return indptr, indices
def sample_neighbours(
indptr: NDArray[np.int64],
indices: NDArray[np.int64],
seeds: NDArray[np.int64],
fanout: int,
rng: np.random.Generator,
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
"""Sample up to `fanout` neighbours per seed node.
This is not an approximation for speed - it is what makes training
possible at all. Three hops on a degree-50 graph reaches >100k nodes per
example, so the exact computation graph does not fit in memory.
"""
src_out = []
dst_out = []
for seed in seeds:
start, end = indptr[seed], indptr[seed + 1]
degree = end - start
if degree <= fanout:
chosen = indices[start:end]
else:
# Sample WITHOUT replacement so a neighbour cannot be double-counted
# into the aggregate, which would silently reweight it.
offsets = rng.choice(degree, size=fanout, replace=False)
chosen = indices[start + offsets]
src_out.append(np.full(len(chosen), seed, dtype=np.int64))
dst_out.append(chosen)
return np.concatenate(src_out), np.concatenate(dst_out)
def aggregate_mean(features: Tensor, src: Tensor, dst: Tensor, num_nodes: int) -> Tensor:
"""Mean-aggregate neighbour features with a fused scatter-add.
index_add_ accumulates in ONE pass over the edge list, in place. Building
per-node neighbour lists and looping would allocate per node and destroy
the memory locality CSR just bought.
"""
output = torch.zeros(num_nodes, features.size(1), device=features.device)
output.index_add_(0, src, features[dst])
counts = torch.zeros(num_nodes, device=features.device)
counts.index_add_(0, src, torch.ones_like(src, dtype=torch.float))
# clamp_min, not a mask: isolated nodes divide by 1 and keep their zeros,
# which is correct and avoids a branch.
return output / counts.clamp_min(1.0).unsqueeze(1)What changed: Three changes for scale. The adjacency moves to CSR so a node's neighbours are one contiguous slice rather than scattered across an edge list. Neighbour sampling caps the fan-out, which is not an approximation for speed but the thing that makes training fit in memory at all. And aggregation becomes a fused in-place scatter-add over the edge list rather than per-node Python loops.
Ensure contiguous memory layout and a single dtype
CSR puts each node's neighbours in one contiguous run, turning neighbourhood sampling from a filtered scan of all edges into a single slice.
Trade-off: Building CSR requires a full sort of the edge list up front, and it must be rebuilt whenever the graph changes.
Pre-allocate output arrays and use in-place operations
index_add_ accumulates neighbour features in one in-place pass over the edge list, with no per-node intermediate allocation.
Trade-off: Scatter-add has non-deterministic float accumulation order on GPU, so results vary slightly run to run — which complicates exact reproducibility.
Batch work to amortize interpreter overhead
Sampling caps the computation graph so many seed nodes can be processed as one batch instead of one enormous exact neighbourhood.
Trade-off: Sampling variance becomes part of the model's behaviour, and results shift with the fan-out setting.
O(|E|·d) per layer, O(fanout^hops) per seed. Illustrative, not a measured benchmark.