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.
- Module
- 5.7 · Deep learning with PyTorch
- Duration
- 70 min
- Prerequisites
- 5.7.1 · PyTorch: tensors, autograd and the training loop, 5.4.3 · Walk-forward with retraining: windows and frequency, 5.1.3 · Economic metrics for trading models: beyond accuracy
- Author
- Jesús Cuesta
- Updated
Level 5 lessons
5.1 · ML fundamentals applied to finance
5.2 · Feature engineering
5.3 · Labelling and sampling
5.4 · Validation for financial series
5.5 · Classical models
- 5.5.1 Regularized regressions: Ridge, Lasso, Elastic Net and logistic
- 5.5.2 Decision trees, Random Forest and bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM and CatBoost
- 5.5.4 Optuna and the risk of overfitting the validation
- 5.5.5 Unsupervised learning: PCA, clustering and autoencoders
- 5.5.6 Learning to rank for stock selection
5.6 · Deep learning with Keras 3
5.7 · Deep learning with PyTorch
- 5.7.1 PyTorch: tensors, autograd and the training loop
- 5.7.2 Time series architectures: TCN, N-BEATS, PatchTST and iTransformer
- 5.7.3 Probabilistic models: quantile loss, DeepAR and intervals
- 5.7.4 Graph neural networks for asset relationships
- 5.7.5 End-to-end portfolio optimization: differentiable layers and Sharpe loss
5.8 · Time series foundation models
- 5.8.1 Time series foundation models: pretrain once, forecast without training
- 5.8.2 Foundation model families: architecture, data and license
- 5.8.3 In practice: context, quantiles, covariates and fine-tuning
- 5.8.4 Foundation models in finance: what the evidence says
- 5.8.5 Lab: foundation models vs GARCH, HAR and LightGBM
5.9 · Reinforcement learning
5.10 · Interpretability, robustness and model risk
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:
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.
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())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 and week :
with the return of customer in the week ending on date , minus the mean of its sector, and 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.
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%}")the label of w ends on date w+1: True
usable weeks: 728 | mean dispersion of y: 3.26%Since the label of date ends on date , the return of the week ending on is the label of the previous row: it is obtained without looking beyond the decision date.
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) 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.0The 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 receives a message from each neighbor that points to it, aggregates them with an operation that does not depend on the order (sum, mean, maximum) and updates its representation:
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:
| Layer | Message from to | What it knows about the edge |
|---|---|---|
| GCN (Kipf and Welling, 2017) | only its weight; normalizes by the degrees | |
| GAT (Veličković et al., 2018) | , with a learned softmax over the neighbors of | whatever goes into the attention (attributes, if given) |
| R-GCN (Schlichtkrull et al., 2018) | , one matrix per relation type | the type, explicitly |
In PyG, a graph is a Data object with x (node features, ) and edge_index, a 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:
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))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 , with 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 : in the planted model, the effect of a customer is , 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 (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).
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) > 0edge_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-04The 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.
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()}).TOLS (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.3Trained 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 to is
, with one coefficient 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.
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"]))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.
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"]))parameters: 25 | training error: 10.759
{'IC': 0.0096, 'std. error': 0.0032, 't': 3.0}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"]))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):
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 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.8The 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
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.
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%}")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%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:
| Situation | What the GNN adds | Which baseline has to be beaten |
|---|---|---|
| Linear effect in the average of neighbors of a known type | nothing: it is the regression | Fama-MacBeth on the weighted average |
| Several relation types and many features per node | combining neighbor features with learned weights | regression with the weighted averages of each feature and type |
| Multi-hop effects (customers of customers) | stacked layers | averages of , |
| Nonlinear effect or one that depends on the neighbor (attention) | nonlinear messages | trees on the neighbor averages |
| Large graphs that change over time | temporal 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
- Compute the two-hop signal: the weighted average of the customers of the customers (). 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
W2 = Wc @ Wc
fm2 = fama_macbeth(X0 @ W2.T, W2.sum(1) > 0)
{k: round(float(v), 4) for k, v in fm2.items()}{'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 is the cheap test before stacking layers.
- 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
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)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.
- 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
In
grafo5_relaciones.csv, the rowBANE,LEKA,cliente,0.346says that LEKA is a customer of BANE. In which direction does the message have to travel inedge_indexto 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) toedge_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.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.
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.
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.
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
Books
- Cohen, L. and Frazzini, A. (2008): Economic Links and Predictable Returns. The Journal of Finance 63(4), 1977-2011
- Kipf, T. N. and Welling, M. (2017): Semi-Supervised Classification with Graph Convolutional Networks (ICLR; arXiv:1609.02907)
- Veličković, P. et al. (2018): Graph Attention Networks (ICLR; arXiv:1710.10903)
- Schlichtkrull, M. et al. (2018): Modeling Relational Data with Graph Convolutional Networks (arXiv:1703.06103)
- Feng, F. et al. (2019): Temporal Relational Ranking for Stock Prediction (arXiv:1809.09441)