Lesson 5.7.4 · 70 min

Graph neural networks for asset relationships

Stocks are not independent rows: a supplier depends on its customers, and news about a customer takes time to reach the price of the company that sells to it. A graph neural network (GNN) learns to pass information along those edges. On a graph with the effect planted and control edges with no effect, the question is whether the GNN finds the right edge and whether it beats a regression on the weighted average of the neighbors.

Fig. — Out-of-sample IC (2019-2025) of the level 5 customer-supplier graph signal: oracle, linear regression and three GNNs trained on 2012-2018
Level 5 lessons
  1. 5.1 · ML fundamentals applied to finance

    1. 5.1.1 Why financial machine learning is different
    2. 5.1.2 Supervised, unsupervised and reinforcement learning; scikit-learn pipelines
    3. 5.1.3 Economic metrics for trading models: beyond accuracy
  2. 5.2 · Feature engineering

    1. 5.2.1 Price, volume and microstructure features
    2. 5.2.2 Point-in-time fundamentals, macro and alternative data
    3. 5.2.3 Fractional differentiation for features
    4. 5.2.4 Leakage-free normalization and cross-sectional rank
    5. 5.2.5 Feature importance: MDI, MDA, SFI and SHAP
  3. 5.3 · Labelling and sampling

    1. 5.3.1 Fixed-horizon labeling and its problems
    2. 5.3.2 Triple-barrier method: labels that mimic a trade
    3. 5.3.3 Meta-labeling: one model for the side and another for the size
    4. 5.3.4 Label uniqueness, overlap weights and the sequential bootstrap
    5. 5.3.5 Event-based sampling: the symmetric CUSUM filter
  4. 5.4 · Validation for financial series

    1. 5.4.1 Why random k-fold fails on financial data
    2. 5.4.2 Purged k-fold with embargo and CPCV for ML models
    3. 5.4.3 Walk-forward with retraining: windows and frequency
    4. 5.4.4 Backtesting ML signals with costs and Deflated Sharpe
  5. 5.5 · Classical models

    1. 5.5.1 Regularized regressions: Ridge, Lasso, Elastic Net and logistic
    2. 5.5.2 Decision trees, Random Forest and bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM and CatBoost
    4. 5.5.4 Optuna and the risk of overfitting the validation
    5. 5.5.5 Unsupervised learning: PCA, clustering and autoencoders
    6. 5.5.6 Learning to rank for stock selection
  6. 5.6 · Deep learning with Keras 3

    1. 5.6.1 Keras 3: one model, several compute engines
    2. 5.6.2 An MLP on a stock panel: regularize and compare
    3. 5.6.3 1D CNN, LSTM and GRU: windows, scaling and leaks
    4. 5.6.4 Callbacks, a Sharpe loss and the model in production
  7. 5.7 · Deep learning with PyTorch

    1. 5.7.1 PyTorch: tensors, autograd and the training loop
    2. 5.7.2 Time series architectures: TCN, N-BEATS, PatchTST and iTransformer
    3. 5.7.3 Probabilistic models: quantile loss, DeepAR and intervals
    4. 5.7.4 Graph neural networks for asset relationships
    5. 5.7.5 End-to-end portfolio optimization: differentiable layers and Sharpe loss
  8. 5.8 · Time series foundation models

    1. 5.8.1 Time series foundation models: pretrain once, forecast without training
    2. 5.8.2 Foundation model families: architecture, data and license
    3. 5.8.3 In practice: context, quantiles, covariates and fine-tuning
    4. 5.8.4 Foundation models in finance: what the evidence says
    5. 5.8.5 Lab: foundation models vs GARCH, HAR and LightGBM
  9. 5.9 · Reinforcement learning

    1. 5.9.1 MDP, Q-learning, PPO and Gymnasium environments
    2. 5.9.2 RL for execution, market making and dynamic hedging
    3. 5.9.3 Why reinforcement learning overfits in directional trading
  10. 5.10 · Interpretability, robustness and model risk

    1. 5.10.1 SHAP, partial dependence and the stability of feature importances
    2. 5.10.2 Data drift and concept drift: detecting them and what to do
    3. 5.10.3 Ensembles, signal combination and synthetic data

Connected assets

The models of the previous lessons treat each stock as a row: its features, its label. But companies are tied together by economic relationships. A supplier sells part of its output to a few customers; when a customer does badly, the supplier will sell less. Cohen and Frazzini (2008) documented in US stocks that the return of the customers predicts that of their suppliers with a lag, and attributed it to limited attention: the relationship is public, but investors take time to carry a customer's news into the price of the company that sells to it.

A graph represents those relationships: each stock is a node and each relationship, an edge with a direction, a type and a weight. A graph neural network (GNN) learns functions that combine, for each node, its own information with that of its neighbors. This is what the graph looks like around a supplier:

weight 0.35weight 0.10alliancer of the weekCustomer ASupplierCustomer BAllynext r
Mermaid source
flowchart LR
  C1[Customer A] -- weight 0.35 --> P[Supplier]
  C2[Customer B] -- weight 0.10 --> P
  P -. alliance .- S[Ally]
  P -- r of the week --> F[next r]

Level 5 comes with that graph and the known answer (data README, “Grafo cliente-proveedor” section):

  • 509 cliente (customer) edges (from 0 to 3 customers per supplier, weights from 0.05 to 0.35: the fraction of the supplier's sales that goes to that customer).
  • Planted effect: λ = 0.035 of the customers' weekly residual passes to the supplier the following week, weighted by the edge weight. It is a small part of the panel's alpha: its standard deviation is 2.9 bp per week, against a cross-sectional dispersion of weekly returns of about 360 bp.
  • 520 random alianza (alliance) edges, with no direction and no effect: the control. A model that gives them weight is overfitting.

The level 5 graph

PyTorch Geometric (PyG) 2.8.1 is in the ml environment. On Python 3.14, its import emits a FutureWarning because torch.jit.script is no longer supported on that Python version; the layers used here do not need it. The warning includes the site-packages path (different on each machine), so it is filtered by its message before the import, as the environment README says.

IN [01]python
import warnings
warnings.filterwarnings("ignore", message=".*torch.jit.script.*")
import numpy as np
import pandas as pd
import torch
import torch_geometric
from scipy.stats import rankdata

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)

g = pd.read_csv("grafo5_relaciones.csv")
act = pd.read_csv("panel5_activos.csv").sort_values("ticker")
tick = act["ticker"].to_numpy()
pos = {t: i for i, t in enumerate(tick)}
N = len(tick)
print("PyG", torch_geometric.__version__, "|", N, "stocks")
print(g.groupby("tipo")["peso"]
      .agg(["size", "mean", "min", "max"]).round(3))
grado = g[g["tipo"] == "cliente"].groupby("origen").size()
print("customers per supplier:",
      grado.value_counts().sort_index().to_dict(),
      "| without customers:", N - grado.size)
sector = dict(zip(act["ticker"], act["sector"]))
otro = g["origen"].map(sector) != g["destino"].map(sector)
print("edges between different sectors:",
      otro.groupby(g["tipo"]).mean().round(3).to_dict())
OUT [01]The graph: types, weights and degrees
PyG 2.8.1 | 400 stocks
         size   mean    min   max
tipo
alianza   520  0.201  0.050  0.35
cliente   509  0.203  0.052  0.35
customers per supplier: {1: 147, 2: 94, 3: 58} | without customers: 101
edges between different sectors: {'alianza': 0.885, 'cliente': 0.939}

Customer relationships cross sectors more often than the random alliances (the generator makes a customer from the same sector less likely, as in the real economy). That matters for the label: the work is done with the return minus the mean of its sector, so a customer from another sector brings information that the mean of the supplier's sector does not contain.

The linear baseline: the average of the neighbors

Before any network, the simplest model. For each supplier ii and week ww:

si,w=∑j∈customers(i)pij xj,w,yi,w+1=aw+λw si,w+εi,w+1s_{i,w} = \sum_{j \in \text{customers}(i)} p_{ij}\, x_{j,w}, \qquad y_{i,w+1} = a_w + \lambda_w\, s_{i,w} + \varepsilon_{i,w+1}

with xj,wx_{j,w} the return of customer jj in the week ending on date ww, minus the mean of its sector, and yy the same for the supplier the following week. The cross-sectional regression of each week and the average of the slopes (Fama-MacBeth, lesson 2.3.5) give λ with its standard error. It is computed the same way with the two control edges: the opposite direction (the return of the suppliers on their customers) and the alliances.

IN [02]python
etiq = pd.read_parquet("panel5_etiquetas.parquet")
etiq = etiq.sort_values(["fecha", "ticker"])
fechas = pd.DatetimeIndex(etiq["fecha"].unique())
nW = len(fechas)
R = etiq["ret_sig_semana"].to_numpy().reshape(nW, N)
fin = etiq["fin_sig_semana"].to_numpy().reshape(nW, N)[:, 0]
print("the label of w ends on date w+1:",
      bool((fin[:-1] == fechas[1:].to_numpy()).all()))

sec = pd.factorize(act["sector"])[0]
media_sector = np.zeros((N, N))       # row i: mean of the sector of i
for s_ in np.unique(sec):
    m = sec == s_
    media_sector[np.ix_(m, m)] = 1 / m.sum()
Y = R - R @ media_sector.T            # y: next week
X = np.full_like(Y, np.nan)
X[1:] = Y[:-1]                        # x: the week ending on w
ok = np.isfinite(X).all(axis=1) & np.isfinite(Y).all(axis=1)
print(f"usable weeks: {ok.sum()} | mean dispersion of y: "
      f"{np.nanstd(Y[ok], axis=1).mean():.2%}")
OUT [02]Weekly returns minus the sector mean
the label of w ends on date w+1: True
usable weeks: 728 | mean dispersion of y: 3.26%

Since the label of date ww ends on date w+1w+1, the return of the week ending on ww is the label of the previous row: it is obtained without looking beyond the decision date.

IN [03]python
o = g["origen"].map(pos).to_numpy()
d = g["destino"].map(pos).to_numpy()
p = g["peso"].to_numpy()
cl = (g["tipo"] == "cliente").to_numpy()
Wc = np.zeros((N, N))                  # Wc[i, j]: weight of customer j
Wc[o[cl], d[cl]] = p[cl]
Wa = np.zeros((N, N))                  # alliances, symmetric
Wa[o[~cl], d[~cl]] = p[~cl]
Wa = Wa + Wa.T

X0 = np.nan_to_num(X)
senal = {"from customers": (X0 @ Wc.T, Wc.sum(1) > 0),
         "from suppliers": (X0 @ Wc, Wc.sum(0) > 0),
         "from alliances": (X0 @ Wa.T, Wa.sum(1) > 0)}

def fama_macbeth(s, m):
    pend = []
    for w in np.flatnonzero(ok):
        A = np.c_[np.ones(m.sum()), s[w, m]]
        pend.append(np.linalg.lstsq(A, Y[w, m], rcond=None)[0][1])
    pend = np.array(pend)
    et = pend.std(ddof=1) / np.sqrt(len(pend))
    return {"slope": pend.mean(), "std. error": et,
            "t": pend.mean() / et, "stocks": m.sum()}

fm = pd.DataFrame({k: fama_macbeth(*v) for k, v in senal.items()}).T
fm.round(4)
OUT [03]Fama-MacBeth on the weighted average of the neighbors
                 slope  std. error      t  stocks
from customers  0.0478      0.0074  6.458   299.0
from suppliers  0.0071      0.0078  0.912   269.0
from alliances  0.0039      0.0051  0.763   370.0
Fig. — Fama-MacBeth slope of next-week return (minus sector mean) on the weighted mean of each neighbour type, against the planted λ

The weighted average of the customers predicts the supplier with a slope of 0.048 and t = 6.5. The truth is 0.035: the estimate is about two standard errors away, off the truth by chance but clearly above zero. The README publishes 0.0474 (t = 6.4) with the same construction; the difference comes from a few weeks at the edges of the sample. The two control edges have no effect: the opposite direction and the alliances give t below 1.

This is the baseline. Since the effect is linear in the weighted average of the neighbors, no GNN can improve on it much: at best, match it. What is being tested is whether a GNN matches it without being told which edge is which.

Message passing

Almost all GNNs follow the message passing scheme of Gilmer et al. (2017). In each layer, each node ii receives a message from each neighbor jj that points to it, aggregates them with an operation that does not depend on the order (sum, mean, maximum) and updates its representation:

hi′=γ(hi, ⨁j→iϕ(hi,hj,eji))h_i' = \gamma\Big(h_i,\ \bigoplus_{j \to i} \phi(h_i, h_j, e_{ji})\Big)
neighbor hMessage phiEdge attributeAggregate sumUpdatenew h
Mermaid source
flowchart TB
  A[neighbor h] --> B[Message phi]
  E[Edge attribute] --> B
  B --> C[Aggregate sum]
  C --> D[Update]
  D --> F[new h]

The layers differ in the message:

LayerMessage from jj to iiWhat it knows about the edge
GCN (Kipf and Welling, 2017)Θhj⋅pji/didj\Theta h_j \cdot p_{ji} / \sqrt{d_i d_j}only its weight; normalizes by the degrees
GAT (Veličković et al., 2018)αij Θhj\alpha_{ij}\, \Theta h_j, with α\alpha a learned softmax over the neighbors of iiwhatever goes into the attention (attributes, if given)
R-GCN (Schlichtkrull et al., 2018)Θrhj\Theta_{r} h_j, one matrix per relation type rrthe type, explicitly

In PyG, a graph is a Data object with x (node features, N×FN \times F) and edge_index, a 2×E2 \times E tensor with the source of each edge in the first row and the target in the second: the message goes from source to target. An example with four nodes (a supplier, two customers and an ally), with the GCN computed by hand:

IN [04]python
from torch_geometric.data import Data
from torch_geometric.nn import GCNConv

# 0 = supplier, 1 and 2 = customers, 3 = ally; edges in both
# directions (the classic GCN works with undirected graphs)
ei_toy = torch.tensor([[1, 0, 2, 0, 3, 0],
                       [0, 1, 0, 2, 0, 3]])
w_toy = torch.tensor([0.35, 0.35, 0.10, 0.10, 0.20, 0.20])
x_toy = torch.tensor([[1.0], [2.0], [-1.0], [4.0]])
datos = Data(x=x_toy, edge_index=ei_toy, edge_attr=w_toy)
print(datos, "| undirected:", datos.is_undirected())

conv = GCNConv(1, 1, add_self_loops=False, bias=False)
with torch.no_grad():
    conv.lin.weight.fill_(1.0)
    pyg = conv(x_toy, ei_toy, w_toy).squeeze()
grad = torch.zeros(4).index_add_(0, ei_toy[1], w_toy)
mano = torch.zeros(4)
for (j, i), wji in zip(ei_toy.T.tolist(), w_toy.tolist()):
    mano[i] += wji / (grad[i] * grad[j]).sqrt() * x_toy[j, 0]
print("PyG :", pyg.numpy().round(4))
print("hand:", mano.numpy().round(4))
OUT [04]A GCN by hand and in PyG
Data(x=[4, 1], edge_index=[2, 6], edge_attr=[6]) | undirected: True
PyG : [3.2942 0.7338 0.3922 0.5547]
hand: [3.2942 0.7338 0.3922 0.5547]

The supplier (node 0) receives the sum of its neighbors' returns weighted by pji/didjp_{ji}/\sqrt{d_i d_j}, with dd the weighted degree. Two details matter for the problem. The GCN does not distinguish the customer from the ally: the two messages are the same except for the weight. And the normalization makes the effect depend on the degrees, not only on pp: in the planted model, the effect of a customer is λ pij\lambda\, p_{ij}, unnormalized.

The panel as a static graph

The graph is the same every week; what changes are the node features. PyG supports that case without repeating the graph: with x of shape T×N×FT \times N \times F (weeks, nodes, features), the layers that aggregate over dimension −2 (GCN and custom ones written with node_dim=-2) process all the weeks at once. The GAT aggregates over dimension 0, so for it a large graph is built with one disjoint copy per week.

The edges go from the sender of the message to the receiver, with one type per relation: 0 = from customer to supplier (the effect), 1 = from supplier to customer (direction control) and 2 = alliance (in both directions).

IN [05]python
src = np.r_[d[cl], o[cl], o[~cl], d[~cl]]
dst = np.r_[o[cl], d[cl], d[~cl], o[~cl]]
rel = np.r_[np.zeros(cl.sum()), np.ones(cl.sum()),
            np.full(2 * (~cl).sum(), 2)].astype(int)
ei = torch.tensor(np.vstack([src, dst]))
ew = torch.tensor(np.r_[p[cl], p[cl], p[~cl], p[~cl]],
                  dtype=torch.float32)
rt = torch.tensor(rel)
print("edge_index:", tuple(ei.shape), "| edges per type:",
      np.bincount(rel).tolist())

anyo = fechas.year.to_numpy()
ent = np.flatnonzero(ok & (anyo <= 2018))[:-1]   # one-week gap
pru = np.flatnonzero(ok & (anyo >= 2019))
print(f"training: {len(ent)} weeks up to "
      f"{fechas[ent[-1]].date()} (its label ends on "
      f"{pd.Timestamp(fin[ent[-1]]).date()})")
print(f"test: {len(pru)} weeks from {fechas[pru[0]].date()}")

Xt = torch.tensor(X0 * 100, dtype=torch.float32).unsqueeze(-1)
Yt = torch.tensor(np.nan_to_num(Y) * 100, dtype=torch.float32)
proveedores = Wc.sum(1) > 0
OUT [05]edge_index with types and the time split
edge_index: (2, 2058) | edges per type: [509, 509, 1040]
training: 362 weeks up to 2018-12-21 (its label ends on 2018-12-28)
test: 365 weeks from 2019-01-04

The last week of 2018 is left out of training: its label ends on the first day of the test, and the fin < fecha rule of lesson 5.4.3 leaves that gap. Returns are in percent so that the gradients have a reasonable scale.

The metric for every model is the weekly IC (rank correlation between the prediction and the next return) on the 299 suppliers, which are the stocks where the effect exists; its mean and its standard error come from the 365 test weeks. The ceiling is the oracle: alfa_grafo, the part of the true alpha that comes from the graph.

IN [06]python
def ic_semanal(pred, semanas=pru, m=proveedores):
    return np.array([np.corrcoef(rankdata(pred[k][m]),
                                 rankdata(Y[w][m]))[0, 1]
                     for k, w in enumerate(semanas)])

def resumen(ic):
    et = ic.std(ddof=1) / np.sqrt(len(ic))
    return {"IC": round(float(ic.mean()), 4),
            "std. error": round(float(et), 4),
            "t": round(float(ic.mean() / et), 1)}

ver = pd.read_parquet("panel5_verdad.parquet")
ver = ver.sort_values(["fecha", "ticker"])
alfa_grafo = ver["alfa_grafo"].to_numpy().reshape(nW, N)
ics = {"oracle (alfa_grafo)": ic_semanal(alfa_grafo[pru])}

Sc, Sp, Sa = (senal[k][0] * 100 for k in senal)
A = np.c_[np.ones(len(ent) * N), Sc[ent].ravel(), Sp[ent].ravel(),
          Sa[ent].ravel()]
coef_mco = np.linalg.lstsq(A, Yt[ent].numpy().ravel(), rcond=None)[0]
print("OLS (customers, suppliers, alliances):",
      coef_mco[1:].round(4))
pred_lin = (coef_mco[1] * Sc + coef_mco[2] * Sp
            + coef_mco[3] * Sa)[pru]
ics["linear regression"] = ic_semanal(pred_lin)
pd.DataFrame({k: resumen(v) for k, v in ics.items()}).T
OUT [06]Metric, oracle and out-of-sample linear baseline
OLS (customers, suppliers, alliances): [ 0.0509  0.0031 -0.0031]
                         IC  std. error    t
oracle (alfa_grafo)  0.0144      0.0031  4.7
linear regression    0.0133      0.0031  4.3

Trained on 2012-2018, the linear regression with the three weighted averages has an IC of 0.0133 in 2019-2025, one thousandth below the oracle (0.0144). It is little in absolute terms, because the graph effect is a tiny fraction of the variance, but it is almost everything there is.

A relational layer that is the regression

The first GNN is written by hand with MessagePassing, PyG's base class: the message from jj to ii is θr pji xj\theta_{r}\, p_{ji}\, x_j, with one coefficient θr\theta_r per edge type, and the aggregation is the sum. It is a one-layer, unnormalized linear R-GCN. It is trained by gradient descent with the squared error, on all stocks and all training weeks at once.

IN [07]python
from torch_geometric.nn import MessagePassing

class Relacional(MessagePassing):
    """Sum of theta[type] * weight * x_j over the edges j -> i."""
    def __init__(self, n_tipos=3):
        super().__init__(aggr="add", node_dim=-2)
        self.theta = torch.nn.Parameter(torch.zeros(n_tipos))
        self.sesgo = torch.nn.Parameter(torch.zeros(1))

    def forward(self, x, edge_index, peso, tipo):
        coef = self.theta[tipo] * peso
        out = self.propagate(edge_index, x=x, coef=coef)
        return out.squeeze(-1) + self.sesgo

    def message(self, x_j, coef):
        return coef.view(-1, 1) * x_j

def entrenar(modelo, fwd, epocas=300, lr=0.01):
    opt = torch.optim.Adam(modelo.parameters(), lr=lr)
    for _ in range(epocas):
        opt.zero_grad()
        perdida = ((fwd(Xt[ent]) - Yt[ent]) ** 2).mean()
        perdida.backward()
        opt.step()
    return perdida.item()

torch.manual_seed(0)
rgnn = Relacional()
mse = entrenar(rgnn, lambda x: rgnn(x, ei, ew, rt))
print(f"training squared error: {mse:.3f}")
print("theta (customers, suppliers, alliances):",
      rgnn.theta.detach().numpy().round(4))
with torch.no_grad():
    ics["relational GNN"] = ic_semanal(
        rgnn(Xt[pru], ei, ew, rt).numpy())
print(resumen(ics["relational GNN"]))
OUT [07]Relational layer with MessagePassing
training squared error: 10.757
theta (customers, suppliers, alliances): [ 0.0509  0.0031 -0.0031]
{'IC': 0.0133, 'std. error': 0.0031, 't': 4.3}

The coefficients are the least squares ones (0.0509, 0.0031 and −0.0031) and the out-of-sample IC is the regression's. It is no coincidence: with a linear message, a sum and one coefficient per relation, the “GNN” is the linear model on the three weighted averages, and the gradient converges to its solution. The lesson is about design: the right structure (edge type and direction, unnormalized) does the work; gradient descent only estimates it.

GCN and GAT without knowing which edge is which

Now, two standard GNNs with hidden layers and nonlinearities, which receive all the edges. The GCN sees the weight of each edge and nothing else: it does not know whether it comes from a customer, a supplier or an ally. The GAT also receives the type as an edge attribute (edge_dim), so in principle it can learn to attend to the customers and ignore the rest. Both without self-loops (add_self_loops=False): the stock's own past return has its own effect (the panel's reversal, lesson 5.2.1), and here what the neighbors contribute is isolated.

IN [08]python
class GCN(torch.nn.Module):
    def __init__(self, oculta=8):
        super().__init__()
        self.conv = GCNConv(1, oculta, add_self_loops=False)
        self.salida = torch.nn.Linear(oculta, 1)

    def forward(self, x):
        h = torch.relu(self.conv(x, ei, ew))
        return self.salida(h).squeeze(-1)

torch.manual_seed(0)
gcn = GCN()
print(f"parameters: {sum(q.numel() for q in gcn.parameters())}"
      f" | training error: {entrenar(gcn, gcn):.3f}")
with torch.no_grad():
    ics["GCN without types"] = ic_semanal(gcn(Xt[pru]).numpy())
print(resumen(ics["GCN without types"]))
OUT [08]GCN with all the edges, without types
parameters: 25 | training error: 10.759
{'IC': 0.0096, 'std. error': 0.0032, 't': 3.0}
IN [09]python
from torch_geometric.nn import GATConv

atrib = torch.cat([torch.nn.functional.one_hot(rt, 3).float(),
                   ew.view(-1, 1)], dim=1)       # type and weight

def bloque(semanas):
    """A graph with one disjoint copy of each week's graph."""
    T, E = len(semanas), ei.shape[1]
    desp = (torch.arange(T) * N).repeat_interleave(E)
    return (ei.repeat(1, T) + desp, atrib.repeat(T, 1),
            Xt[semanas].reshape(-1, 1))

class GAT(torch.nn.Module):
    def __init__(self, oculta=4, cabezas=2):
        super().__init__()
        self.conv = GATConv(1, oculta, heads=cabezas, edge_dim=4,
                            add_self_loops=False)
        self.salida = torch.nn.Linear(oculta * cabezas, 1)

    def forward(self, x, e, a):
        h = torch.relu(self.conv(x, e, a))
        return self.salida(h).squeeze(-1)

torch.manual_seed(0)
gat = GAT()
e_ent, a_ent, x_ent = bloque(ent)
y_ent = Yt[ent].reshape(-1)
opt = torch.optim.Adam(gat.parameters(), lr=0.01)
for _ in range(150):
    opt.zero_grad()
    perdida = ((gat(x_ent, e_ent, a_ent) - y_ent) ** 2).mean()
    perdida.backward()
    opt.step()
e_pru, a_pru, x_pru = bloque(pru)
with torch.no_grad():
    pred_gat = gat(x_pru, e_pru, a_pru)
ics["GAT with types"] = ic_semanal(pred_gat.reshape(len(pru), N)
                                   .numpy())
print(f"parameters: {sum(q.numel() for q in gat.parameters())}"
      f" | training error: {perdida.item():.3f}")
print(resumen(ics["GAT with types"]))
OUT [09]GAT with the edge type as an attribute
parameters: 81 | training error: 10.758
{'IC': 0.0074, 'std. error': 0.0031, 't': 2.4}

The training squared error is almost the same for every model (from 10.757 to 10.759, in %²): with this signal-to-noise ratio, what the graph improves the loss by is one ten-thousandth of it. Out of sample, the GCN drops to an IC of 0.0096 and the GAT to 0.0074. Both find part of the signal (t of 3.0 and 2.4), but less than the regression.

The honest comparison

The ICs of each model have a standard error of about 0.003, and their intervals overlap. The comparisons are read with the week-by-week paired difference, which removes the common noise (the weeks are the same for all of them):

IN [10]python
tabla = pd.DataFrame({k: resumen(v) for k, v in ics.items()}).T
base = ics["linear regression"]
for k, v in ics.items():
    dif = v - base
    et = dif.std(ddof=1) / np.sqrt(len(dif))
    tabla.loc[k, "diff. vs regression"] = round(dif.mean(), 4)
    tabla.loc[k, "t diff."] = (round(dif.mean() / et, 1)
                               if et > 1e-4 else np.nan)
tabla
OUT [10]Final table and paired differences against the regression
                         IC  std. error    t  diff. vs regression  t diff.
oracle (alfa_grafo)  0.0144      0.0031  4.7               0.0011      1.0
linear regression    0.0133      0.0031  4.3               0.0000      NaN
relational GNN       0.0133      0.0031  4.3               0.0000      NaN
GCN without types    0.0096      0.0032  3.0              -0.0037     -1.1
GAT with types       0.0074      0.0031  2.4              -0.0059     -2.8

The figure at the top summarizes the table. Three readings:

  • The regression and the relational GNN are at the ceiling: 0.0133 against the oracle's 0.0144, a difference smaller than the standard error (t = 1.0). There is no signal left for a bigger network to find.
  • The standard GNNs lose signal by mixing edges: the GCN sums customers, suppliers and allies alike, and its degree normalizations distort the weights; the GAT could separate the types, but it does not learn to do so.
  • The paired differences are modest: −0.0037 with t = −1.1 for the GCN and −0.0059 with t = −2.8 for the GAT. With seven years of weekly data and a signal of this size, losing 28% of the IC cannot be told apart from noise; losing 44%, barely. It is one more reason to start with the baseline: a complex model that matches it adds nothing, and one that “beats” it by less than two standard errors does not prove anything either.

What the attention looks at

The GAT returns its attention coefficients with return_attention_weights=True: for each edge, the weight αij\alpha_{ij} that the target node gives to that neighbor (they sum to 1 over the neighbors of each node). If it had learned the effect, in the suppliers that also have alliances it would give its customers' edges considerably more than their share.

IN [11]python
with torch.no_grad():
    att = np.mean([gat.conv(Xt[w], ei, atrib,
                            return_attention_weights=True)[1][1]
                   .mean(1).numpy() for w in pru[:52]], axis=0)
cuota, proporcion = [], []
for i in np.flatnonzero(proveedores):
    llegan = dst == i
    if (llegan & (rel == 2)).any():
        cuota.append(att[llegan & (rel == 0)].sum())
        proporcion.append((llegan & (rel == 0)).mean()
                          / llegan.mean())
cuota, proporcion = np.array(cuota), np.array(proporcion)
print(f"suppliers with alliances: {len(cuota)}")
print(f"mean attention on their customers: {cuota.mean():.3f} | "
      f"fraction of customer edges: {proporcion.mean():.3f}")
print(f"suppliers with more attention than share: "
      f"{(cuota > proporcion).mean():.1%}")
OUT [11]Attention on the customers' edges
suppliers with alliances: 280
mean attention on their customers: 0.301 | fraction of customer edges: 0.316
suppliers with more attention than share: 38.6%
Fig. — GAT attention on customer edges against their share of the edges reaching each supplier (280 suppliers with alliances, grouped by share)

The mean attention on the customers (0.30) is the fraction of edges that come from customers (0.32), or a little less, and only 38.6% of the suppliers give their customers more attention than their share: the GAT spreads its attention almost in proportion to the number of edges, without preferring the ones that carry the effect. The reason is the signal-to-noise ratio. The gradient that reaches the attention parameters depends on how much the loss improves by attending to one neighbor or another, and here that improvement is in the ten-thousandths, buried in each week's noise. Attention is not an explanation of the model: it is one more parameter, as noisy as the others (lesson 5.10.1 for interpretability in general).

When a GNN adds something

The result of this lesson does not say that GNNs are useless; it says that they do not create signal and that structure matters more than architecture. A GNN adds value over the neighbor regression when there is something the regression cannot easily express:

SituationWhat the GNN addsWhich baseline has to be beaten
Linear effect in the average of neighbors of a known typenothing: it is the regressionFama-MacBeth on the weighted average
Several relation types and many features per nodecombining neighbor features with learned weightsregression with the weighted averages of each feature and type
Multi-hop effects (customers of customers)stacked layersaverages of W2xW^2 x, W3xW^3 x
Nonlinear effect or one that depends on the neighbor (attention)nonlinear messagestrees on the neighbor averages
Large graphs that change over timetemporal GNNs (e.g. Feng et al., 2019)the same regression with the graph of each date

In the literature on stock prediction with graphs (sector, supply chain, news co-occurrence or correlation relationships), results with weak validation abound: a single time split, no costs, no linear baseline with the same edges and graphs built with later information. What has to be asked of any of them is the same as in this lesson: the point-in-time graph, a linear baseline with the same edges and the out-of-sample paired difference.

Exercises

  1. Compute the two-hop signal: the weighted average of the customers of the customers (Wc2xW_c^2 x). Does it have an effect on the supplier according to Fama-MacBeth? What does it tell you about stacking two GNN layers on this graph?
Solution
IN [12]python
W2 = Wc @ Wc
fm2 = fama_macbeth(X0 @ W2.T, W2.sum(1) > 0)
{k: round(float(v), 4) for k, v in fm2.items()}
OUT [12]Customers of the customers
{'slope': 0.0287, 'std. error': 0.0289, 't': 0.9948, 'stocks': 265.0}

The two-hop slope is 0.029 with a standard error of 0.029 (t = 1.0): it cannot be told apart from zero, because the generator only plants one hop (the residual of the direct customers). A two-layer GNN would have access to that information and, with such noisy data, would learn some noise from it; the baseline says beforehand that there is nothing to look for. With real data, the same regression on W2xW^2 x is the cheap test before stacking layers.

  1. Train the relational layer without direction: a single type for the customer edges in both directions (merge types 0 and 1). What coefficient does it learn and what IC does it have out of sample?
Solution
IN [13]python
rt_sin = torch.where(rt == 1, 0, rt)
torch.manual_seed(0)
rsin = Relacional()
entrenar(rsin, lambda x: rsin(x, ei, ew, rt_sin))
print("theta (undirected customer, -, alliances):",
      rsin.theta.detach().numpy().round(4))
with torch.no_grad():
    ic_sin = ic_semanal(rsin(Xt[pru], ei, ew, rt_sin).numpy())
resumen(ic_sin)
OUT [13]The same layer without direction
theta (undirected customer, -, alliances): [ 0.0276  0.     -0.0029]
{'IC': 0.0113, 'std. error': 0.0031, 't': 3.6}

Without direction, the coefficient drops to 0.0276 (roughly the average of 0.0509 and 0.0031): it is split between the edge with the effect and the one without it, and each supplier's prediction mixes its customers with its own suppliers. The IC drops from 0.0133 to 0.0113. The type 1 parameter receives no gradient (no edge of that type is left) and stays at 0. Knowing the direction of the economic effect is information worth more than any hidden layer.

  1. A data vendor offers you a supply chain graph with 50,000 companies and the list of relationships in force today, and a paper with a GNN that gives a Sharpe of 3 in 2010-2020 with that graph. What questions would you ask before using it?
Solution

First, the knowledge date of each edge: whether the vendor keeps the history of relationships with the date on which each one was published (point-in-time), or only today's snapshot, which looks ahead. Second, the baseline: what Sharpe a regression on the weighted average of the neighbors with the same edges gives, and what paired difference there is between the GNN and that baseline. Third, the validation: walk-forward with a gap between training and test (lesson 5.4.3), how many architectures and hyperparameters were tried (Deflated Sharpe, lesson 5.4.4) and whether the Sharpe survives the costs of the turnover (lesson 5.1.3). A Sharpe of 3 with a signal that at best explains a tiny fraction of the weekly variance is, almost certainly, a leak or a selection effect.

Each answer is folded under its question

Self-assessment

  1. In grafo5_relaciones.csv, the row BANE,LEKA,cliente,0.346 says that LEKA is a customer of BANE. In which direction does the message have to travel in edge_index to learn the planted effect?

    • AFrom BANE to LEKA: the supplier informs the customer
    • BIn both directions with the same coefficient
    • CFrom LEKA to BANE: the customer's return this week predicts the supplier's return the next week
    • DIt does not matter: a GNN learns the direction on its own
    Show answer

    Answer C. From LEKA to BANE: the customer's return this week predicts the supplier's return the next week

    The planted effect is λ·Σ weight·residual of the customers on the supplier. In PyG the message goes from edge_index[0] (source) to edge_index[1] (target), so the useful edge is customer to supplier. The opposite direction has a slope of 0.007 with t = 0.9: nothing.

  2. The relational layer (one coefficient per edge type) learns 0.0509 for customers, 0.0031 for suppliers and −0.0031 for alliances. What is this?

    • AThe same linear regression as the baseline, estimated by gradient descent: the coefficients match the least squares ones
    • BProof that the GNN is better than the regression
    • COverfitting: the coefficients should be exactly 0.035
    • DAn error, because a GNN cannot have only three parameters
    Show answer

    Answer A. The same linear regression as the baseline, estimated by gradient descent: the coefficients match the least squares ones

    With sum aggregation, a linear message and one weight per relation, the layer is Σ_r θ_r·Σ_j weight·x_j: exactly the linear model on the three weighted averages. It converges to the least squares solution (0.0509 versus 0.0509). The distance to 0.035 is estimation error: about two standard errors.

  3. Out of sample (2019-2025), the linear regression gives an IC of 0.0133 on the 299 suppliers, the GCN without types 0.0096 and the GAT 0.0074. The oracle (alfa_grafo) gives 0.0144. What is the conclusion?

    • AThat the GAT is the best because it has attention
    • BThat the graph has no signal
    • CThat the GNNs have detected nonlinear effects that the regression does not see
    • DThat the regression is already close to the ceiling; the GNNs that mix edges with and without effect lose part of the signal, although the paired differences are not conclusive
    Show answer

    Answer D. That the regression is already close to the ceiling; the GNNs that mix edges with and without effect lose part of the signal, although the paired differences are not conclusive

    The regression is 0.001 below the oracle. The GCN and the GAT also receive the alliances and the opposite direction, and dilute the signal. With a standard error of about 0.003 per IC, the GCN's paired difference has t = −1.1 and the GAT's, t = −2.8: the direction is clear; the evidence is firm only for the GAT.

  4. The GAT receives the edge type as an attribute. Across the 280 suppliers that also have alliances, the attention on their customers' edges sums to 0.30 on average, and those edges are 32% of the ones that reach them. What does it indicate?

    • AThat the attention identified the customers
    • BThat the attention did not learn to prefer the customers: it spreads the weight almost in proportion to the number of edges
    • CThat more attention heads should be added until the proportion goes up
    • DThat the alliances carry more signal than the customers
    Show answer

    Answer B. That the attention did not learn to prefer the customers: it spreads the weight almost in proportion to the number of edges

    If the attention had learned the effect, it would give the customers' edges much more than their share. With a signal that explains a tiny fraction of the variance, the gradient that reaches the attention parameters is almost noise. Reading attention as an explanation is risky even when the model works.

  5. On real data, what is the main trap of using today's customer-supplier graph for a 2012-2025 backtest?

    • AThat the graph is too small
    • BThat GNNs do not support directed graphs
    • CLook-ahead: relationships are published with a delay and change; today's graph includes relationships that did not exist or were not known in 2012
    • DThat customer-supplier relationships have no effect in real markets
    Show answer

    Answer C. Look-ahead: relationships are published with a delay and change; today's graph includes relationships that did not exist or were not known in 2012

    The graph is a piece of data like any other and has its knowledge date (lesson 5.2.2). In level 5 it is static and known from the start by construction; on real data it has to be rebuilt point-in-time.

Key points

  • An asset graph in PyG is edge_index (source, target), weights and types. The message travels from source to target: for the customer-to-supplier effect, the edge goes from the customer to the supplier.
  • The linear baseline is the weighted average of the neighbors of each type: with customers, a Fama-MacBeth slope of 0.048 (t = 6.5; truth 0.035); with alliances and in the opposite direction, nothing (t < 1).
  • A linear message passing layer with one coefficient per relation is that same regression: it recovers 0.0509 for customers and ≈ 0 for the controls, just like least squares, and its out-of-sample IC (0.0133) is close to the oracle (0.0144).
  • A GCN or a GAT that receive all the edges mixed together dilute the signal: ICs of 0.0096 and 0.0074. The GAT's attention does not tell customers from alliances.
  • When the effect is linear in the average of the neighbors, a GNN cannot improve much on the regression: what a GNN brings is structure (types, direction, several hops, nonlinearities), not magic. Without the right structure, it loses.
  • The graph is dated data: on real data it is rebuilt point-in-time, and the comparison with a linear baseline always comes first.

Resources

Documentation