---
title: "5.7.4 Graph neural networks (GNN) for asset relationships"
description: "GCN, GAT and a relational layer in PyTorch Geometric on a customer-supplier graph with a planted spillover, compared with a linear regression."
url: "https://pythonparatrading.com/en/python-trading-course/level-5/graph-neural-networks-for-assets/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-5/graph-neural-networks-for-assets.png"
---

# 5.7.4 · 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.

- Course: [Level 5: Machine learning and deep learning (Keras/PyTorch)](https://pythonparatrading.com/en/python-trading-course/level-5/)
- Module: 5.7 · Deep learning with PyTorch
- Duration: 70 min
- Prerequisites: [5.7.1 · PyTorch: tensors, autograd and the training loop](https://pythonparatrading.com/en/python-trading-course/level-5/pytorch-tensors-autograd-training-loop/), [5.4.3 · Walk-forward with retraining: windows and frequency](https://pythonparatrading.com/en/python-trading-course/level-5/walk-forward-retraining-windows/), [5.1.3 · Economic metrics for trading models: beyond accuracy](https://pythonparatrading.com/en/python-trading-course/level-5/economic-metrics-for-trading-models/)

## By the end of this lesson

- Represent a graph of relationships between companies in PyTorch Geometric (`edge_index`, weights, types and message direction) and a weekly panel as a static graph with changing features.
- Measure the linear baseline: Fama-MacBeth of the supplier's return on the weighted average of its customers, compared with the control edges.
- Write a custom message passing layer with `MessagePassing` and check that, with the right structure, it recovers the same λ as the regression.
- Train a GCN and a GAT without telling them which edge is which, evaluate them out of sample with IC, standard error and paired differences, and read the attention by edge type.

## 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
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.

<aside class="callout" data-kind="aviso">

Synthetic data with the effect planted. What this lesson measures is whether each method recovers an effect that is
known to exist and whether it invents one where there is nothing. It is not evidence that a GNN works in real markets.

</aside>

## 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.

```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())
```
```output
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 $i$ and week $w$:

$$
s_{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 $x_{j,w}$ the return of customer $j$ in the week ending on date $w$, minus the mean of its sector, and $y$ 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.

```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%}")
```
```output
the label of w ends on date w+1: True
usable weeks: 728 | mean dispersion of y: 3.26%
```

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

```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)
```
```output
                 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 λ.* Three points with ±2 standard error bars. From customers: 0.0478 (highlighted), between 0.033 and 0.063, above zero and close to the truth λ = 0.035 (dashed line). From suppliers (reverse direction): 0.0071, interval including zero. From alliances: 0.0039, also including zero.

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 $i$ receives a
**message** from each neighbor $j$ that points to it, **aggregates** them with an operation that does not depend on the
order (sum, mean, maximum) and **updates** its representation:

$$
h_i' = \gamma\Big(h_i,\ \bigoplus_{j \to i} \phi(h_i, h_j, e_{ji})\Big)
$$

```mermaid
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 $j$ to $i$ | What it knows about the edge |
|---|---|---|
| GCN (Kipf and Welling, 2017) | $\Theta h_j \cdot p_{ji} / \sqrt{d_i d_j}$ | only its weight; normalizes by the degrees |
| GAT (Veličković et al., 2018) | $\alpha_{ij}\, \Theta h_j$, with $\alpha$ a learned softmax over the neighbors of $i$ | whatever goes into the attention (attributes, if given) |
| R-GCN (Schlichtkrull et al., 2018) | $\Theta_{r} h_j$, one matrix per relation type $r$ | the type, explicitly |

In PyG, a graph is a `Data` object with `x` (node features, $N \times F$) and `edge_index`, a $2 \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:

```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))
```
```output
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 $p_{ji}/\sqrt{d_i d_j}$, with $d$ 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 $p$: in the planted model, the effect of a customer is $\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 \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).

```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
```
```output
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.

```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
```
```output
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 $j$ to $i$ is
$\theta_{r}\, p_{ji}\, x_j$, with one coefficient $\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.

```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"]))
```
```output
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.

```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"]))
```
```output
parameters: 25 | training error: 10.759
{'IC': 0.0096, 'std. error': 0.0032, 't': 3.0}
```

```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"]))
```
```output
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):

```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
```
```output
                         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
$\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.

```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%}")
```
```output
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).* Nineteen points, one per value of the share of customer edges (0.11 to 0.75), with the GAT's mean attention on those edges. All lie on the attention-equals-share diagonal or slightly below; none moves upwards, as an attention that preferred customers would. Highlighted mean: attention 0.30 against a share of 0.32.

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 $W^2 x$, $W^3 x$ |
| 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.

<aside class="callout" data-kind="riesgo">

The graph is dated data. On real data, customer relationships are published in annual reports months late, and they
change and disappear. Building the graph with today's customer list and applying it to 2012 is look-ahead (lesson
5.2.2): a relationship that is known because the customer grew a lot encodes exactly the return you want to predict. In
level 5 the graph is static and known from the start, by construction.

</aside>

## Exercises

1. Compute the **two-hop** signal: the weighted average of the customers of the customers ($W_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.**

```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()}
```
```output
{'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 $W^2 x$ is the cheap test before stacking layers.

2. 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.**

```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)
```
```output
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.

3. 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.

## 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?

   - A. From BANE to LEKA: the supplier informs the customer
   - B. In both directions with the same coefficient
   - C. From LEKA to BANE: the customer's return this week predicts the supplier's return the next week
   - D. It does not matter: a GNN learns the direction on its own

   *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?

   - A. The same linear regression as the baseline, estimated by gradient descent: the coefficients match the least squares ones
   - B. Proof that the GNN is better than the regression
   - C. Overfitting: the coefficients should be exactly 0.035
   - D. An error, because a GNN cannot have only three parameters

   *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?

   - A. That the GAT is the best because it has attention
   - B. That the graph has no signal
   - C. That the GNNs have detected nonlinear effects that the regression does not see
   - 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

   *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?

   - A. That the attention identified the customers
   - B. That the attention did not learn to prefer the customers: it spreads the weight almost in proportion to the number of edges
   - C. That more attention heads should be added until the proportion goes up
   - D. That the alliances carry more signal than the customers

   *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?

   - A. That the graph is too small
   - B. That GNNs do not support directed graphs
   - 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
   - D. That customer-supplier relationships have no effect in real markets

   *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

- [PyTorch Geometric: Creating Message Passing Networks](https://pytorch-geometric.readthedocs.io/en/latest/tutorial/create_gnn.html) · pytorch-geometric.readthedocs.io

### Books

- [Cohen, L. and Frazzini, A. (2008): Economic Links and Predictable Returns. The Journal of Finance 63(4), 1977-2011](https://doi.org/10.1111/j.1540-6261.2008.01379.x) · doi.org
- [Kipf, T. N. and Welling, M. (2017): Semi-Supervised Classification with Graph Convolutional Networks (ICLR; arXiv:1609.02907)](https://arxiv.org/abs/1609.02907) · arxiv.org
- [Veličković, P. et al. (2018): Graph Attention Networks (ICLR; arXiv:1710.10903)](https://arxiv.org/abs/1710.10903) · arxiv.org
- [Schlichtkrull, M. et al. (2018): Modeling Relational Data with Graph Convolutional Networks (arXiv:1703.06103)](https://arxiv.org/abs/1703.06103) · arxiv.org
- [Feng, F. et al. (2019): Temporal Relational Ranking for Stock Prediction (arXiv:1809.09441)](https://arxiv.org/abs/1809.09441) · arxiv.org

### Data

- [Level 5 data: customer-supplier graph and alliances (CSV)](https://pythonparatrading.com/curso/datos/nivel-5/grafo5_relaciones.csv) · Download · grafo5_relaciones.csv

Previous lesson: [5.7.3 · Probabilistic models: quantile loss, DeepAR and intervals](https://pythonparatrading.com/en/python-trading-course/level-5/probabilistic-forecasting-quantile-loss-deepar/)

Next lesson: [5.7.5 · End-to-end portfolio optimization: differentiable layers and Sharpe loss](https://pythonparatrading.com/en/python-trading-course/level-5/end-to-end-portfolio-optimization/)
