---
title: "5.7.4 Redes de grafos (GNN) para relaciones entre activos"
description: "GCN, GAT y una capa relacional en PyTorch Geometric sobre un grafo cliente-proveedor con propagación plantada, frente a una regresión lineal."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/redes-de-grafos-para-activos/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/redes-de-grafos-para-activos.png"
---

# 5.7.4 · Redes de grafos para relaciones entre activos

> Las acciones no son filas independientes: un proveedor depende de sus clientes y la noticia de un cliente tarda en llegar al precio de quien le vende. Una red neuronal de grafos (GNN) aprende a pasar información por esas aristas. En un grafo con el efecto plantado y aristas de control sin efecto, la pregunta es si la GNN encuentra la arista buena y si mejora a una regresión sobre la media ponderada de los vecinos.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.7 · Deep learning con PyTorch
- Duración: 70 min
- Requisitos: [5.7.1 · PyTorch: tensores, autograd y el bucle de entrenamiento](https://pythonparatrading.com/curso-python-trading/nivel-5/pytorch-tensores-autograd-y-bucle-de-entrenamiento/), [5.4.3 · Walk-forward con reentrenamiento: ventanas y frecuencia](https://pythonparatrading.com/curso-python-trading/nivel-5/walk-forward-con-reentrenamiento/), [5.1.3 · Métricas económicas para modelos de trading: más allá del acierto](https://pythonparatrading.com/curso-python-trading/nivel-5/metricas-economicas-para-modelos-de-trading/)

## Al terminar esta lección

- Representar un grafo de relaciones entre empresas en PyTorch Geometric (`edge_index`, pesos, tipos y dirección del mensaje) y un panel semanal como un grafo estático con features que cambian.
- Medir la referencia lineal: Fama-MacBeth del rendimiento del proveedor sobre la media ponderada de sus clientes, frente a las aristas de control.
- Escribir una capa de paso de mensajes propia con `MessagePassing` y comprobar que, con la estructura correcta, recupera la misma λ que la regresión.
- Entrenar una GCN y una GAT sin decirles qué arista es cuál, evaluarlas fuera de muestra con IC, error típico y diferencias pareadas, y leer la atención por tipo de arista.

## Activos conectados

Los modelos de las lecciones anteriores tratan cada acción como una fila: sus features, su etiqueta. Pero las empresas
están unidas por relaciones económicas. Un proveedor vende una parte de su producción a unos pocos clientes; cuando a
un cliente le va mal, el proveedor venderá menos. Cohen y Frazzini (2008) documentaron en acciones de EE. UU. que el
rendimiento de los clientes predice el de sus proveedores con retraso, y lo atribuyeron a la **atención limitada**: la
relación es pública, pero los inversores tardan en llevar la noticia de un cliente al precio de quien le vende.

Un **grafo** representa esas relaciones: cada acción es un **nodo** y cada relación, una **arista** con dirección,
tipo y peso. Una **red neuronal de grafos** (GNN) aprende funciones que combinan, para cada nodo, su información con la
de sus vecinos. Así se ve el grafo alrededor de un proveedor:

```mermaid
flowchart LR
  C1[Cliente A] -- peso 0,35 --> P[Proveedor]
  C2[Cliente B] -- peso 0,10 --> P
  P -. alianza .- S[Aliado]
  P -- r de la semana --> F[r siguiente]
```

El nivel 5 trae ese grafo con la respuesta conocida (README de los datos, «Grafo cliente-proveedor»):

- 509 aristas `cliente` (de 0 a 3 clientes por proveedor, pesos de 0,05 a 0,35: la fracción de las ventas del
  proveedor que va a ese cliente).
- **Efecto plantado**: λ = 0,035 del residuo semanal de los clientes pasa al proveedor la semana siguiente, ponderado
  por el peso. Es una parte pequeña del alfa del panel: su desviación es de 2,9 pb a la semana, frente a una
  dispersión transversal de los rendimientos semanales de unos 360 pb.
- 520 aristas `alianza` al azar, sin dirección ni efecto: el **control**. Un modelo que les dé peso está sobreajustando.

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

Datos sintéticos con el efecto plantado. Lo que mide esta lección es si cada método recupera un efecto que se sabe que
existe y si se inventa uno donde no hay nada. No es evidencia de que una GNN funcione en mercados reales.

</aside>

## El grafo del nivel 5

PyTorch Geometric (PyG) 2.8.1 está en el entorno `ml`. En Python 3.14, su `import` emite un `FutureWarning` porque
`torch.jit.script` ya no se admite en esa versión de Python; las capas que se usan aquí no lo necesitan. El aviso lleva
la ruta de `site-packages` (distinta en cada máquina), así que se filtra por su mensaje antes del `import`, como dice
el README del entorno.

```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, "acciones")
print(g.groupby("tipo")["peso"]
      .agg(["size", "mean", "min", "max"]).round(3))
grado = g[g["tipo"] == "cliente"].groupby("origen").size()
print("clientes por proveedor:",
      grado.value_counts().sort_index().to_dict(),
      "| sin clientes:", N - grado.size)
sector = dict(zip(act["ticker"], act["sector"]))
otro = g["origen"].map(sector) != g["destino"].map(sector)
print("aristas entre sectores distintos:",
      otro.groupby(g["tipo"]).mean().round(3).to_dict())
```
```output
PyG 2.8.1 | 400 acciones
         size   mean    min   max
tipo
alianza   520  0.201  0.050  0.35
cliente   509  0.203  0.052  0.35
clientes por proveedor: {1: 147, 2: 94, 3: 58} | sin clientes: 101
aristas entre sectores distintos: {'alianza': 0.885, 'cliente': 0.939}
```

Las relaciones de cliente cruzan sectores con más frecuencia que el azar de las alianzas (el generador hace menos
probable un cliente del mismo sector, como en la economía real). Eso importa para la etiqueta: se trabaja con el
rendimiento **menos la media de su sector**, así que un cliente de otro sector aporta información que la media del
sector del proveedor no contiene.

## La referencia lineal: la media de los vecinos

Antes de cualquier red, el modelo más simple. Para cada proveedor $i$ y semana $w$:

$$
s_{i,w} = \sum_{j \in \text{clientes}(i)} p_{ij}\, x_{j,w}, \qquad
y_{i,w+1} = a_w + \lambda_w\, s_{i,w} + \varepsilon_{i,w+1}
$$

con $x_{j,w}$ el rendimiento del cliente $j$ en la semana que termina en la fecha $w$, menos la media de su sector, e
$y$ lo mismo para el proveedor la semana siguiente. La regresión transversal de cada semana y la media de las
pendientes (Fama-MacBeth, lección 2.3.5) dan λ con su error típico. Se calcula igual con las dos aristas de control: la
dirección contraria (el rendimiento de los proveedores sobre sus clientes) y las alianzas.

```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("la etiqueta de w termina en la fecha w+1:",
      bool((fin[:-1] == fechas[1:].to_numpy()).all()))

sec = pd.factorize(act["sector"])[0]
media_sector = np.zeros((N, N))       # fila i: media del sector de 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: semana siguiente
X = np.full_like(Y, np.nan)
X[1:] = Y[:-1]                        # x: la semana que acaba en w
ok = np.isfinite(X).all(axis=1) & np.isfinite(Y).all(axis=1)
print(f"semanas útiles: {ok.sum()} | dispersión media de y: "
      f"{np.nanstd(Y[ok], axis=1).mean():.2%}")
```
```output
la etiqueta de w termina en la fecha w+1: True
semanas útiles: 728 | dispersión media de y: 3.26%
```

Como la etiqueta de la fecha $w$ termina en la fecha $w+1$, el rendimiento de la semana que acaba en $w$ es la etiqueta
de la fila anterior: se obtiene sin mirar más allá de la fecha de decisión.

```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]: peso del cliente j
Wc[o[cl], d[cl]] = p[cl]
Wa = np.zeros((N, N))                  # alianzas, simétrica
Wa[o[~cl], d[~cl]] = p[~cl]
Wa = Wa + Wa.T

X0 = np.nan_to_num(X)
senal = {"de los clientes": (X0 @ Wc.T, Wc.sum(1) > 0),
         "de los proveedores": (X0 @ Wc, Wc.sum(0) > 0),
         "de las alianzas": (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 {"pendiente": pend.mean(), "error típico": et,
            "t": pend.mean() / et, "acciones": m.sum()}

fm = pd.DataFrame({k: fama_macbeth(*v) for k, v in senal.items()}).T
fm.round(4)
```
```output
                    pendiente  error típico      t  acciones
de los clientes        0.0478        0.0074  6.458     299.0
de los proveedores     0.0071        0.0078  0.912     269.0
de las alianzas        0.0039        0.0051  0.763     370.0
```

*Fig. — Pendiente de Fama-MacBeth del rendimiento siguiente (menos la media del sector) sobre la media ponderada de cada tipo de vecino, frente a la λ plantada.* Tres puntos con ±2 errores típicos. De los clientes: 0,0478 (destacado), entre 0,033 y 0,063, por encima de cero y cerca de la verdad λ = 0,035 (línea discontinua). De los proveedores (dirección contraria): 0,0071, intervalo que incluye el cero. De las alianzas: 0,0039, también con el cero dentro.

La media ponderada de los clientes predice al proveedor con una pendiente de 0,048 y t = 6,5. La verdad es 0,035: la
estimación está a unos dos errores típicos, lejos de la verdad por azar pero claramente por encima de cero. El README
publica 0,0474 (t = 6,4) con la misma construcción; la diferencia es de unas pocas semanas en los bordes de la
muestra. Las dos aristas de control no tienen efecto: la dirección contraria y las alianzas dan t por debajo de 1.

Esta es la referencia. Como el efecto es **lineal en la media ponderada de los vecinos**, ninguna GNN puede mejorarla
mucho: como mucho, igualarla. Lo que se pone a prueba es si una GNN la iguala sin que se le diga qué arista es cuál.

## Paso de mensajes

Casi todas las GNN siguen el esquema de paso de mensajes de Gilmer et al. (2017). En cada capa, cada nodo $i$ recibe
un **mensaje** de cada vecino $j$ que le apunta, los **agrega** con una operación que no depende del orden (suma, media,
máximo) y **actualiza** su representación:

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

```mermaid
flowchart TB
  A[h de vecinos] --> B[Mensaje phi]
  E[Atributo arista] --> B
  B --> C[Agregar suma]
  C --> D[Actualizar]
  D --> F[h nueva]
```

Las capas se distinguen por el mensaje:

| Capa | Mensaje de $j$ a $i$ | Qué sabe de la arista |
|---|---|---|
| GCN (Kipf y Welling, 2017) | $\Theta h_j \cdot p_{ji} / \sqrt{d_i d_j}$ | solo su peso; normaliza por los grados |
| GAT (Veličković et al., 2018) | $\alpha_{ij}\, \Theta h_j$, con $\alpha$ un softmax aprendido entre los vecinos de $i$ | lo que entre en la atención (atributos, si se le dan) |
| R-GCN (Schlichtkrull et al., 2018) | $\Theta_{r} h_j$, una matriz por tipo de relación $r$ | el tipo, explícitamente |

En PyG, un grafo es un objeto `Data` con `x` (features de los nodos, $N \times F$) y `edge_index`, un tensor de
$2 \times E$ con el origen de cada arista en la primera fila y el destino en la segunda: **el mensaje va del origen al
destino**. Un ejemplo de cuatro nodos (un proveedor, dos clientes y un aliado), con la GCN calculada a mano:

```python
from torch_geometric.data import Data
from torch_geometric.nn import GCNConv

# 0 = proveedor, 1 y 2 = clientes, 3 = aliado; aristas en los dos
# sentidos (la GCN clásica trabaja con grafos no dirigidos)
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, "| no dirigido:", 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("mano:", mano.numpy().round(4))
```
```output
Data(x=[4, 1], edge_index=[2, 6], edge_attr=[6]) | no dirigido: True
PyG : [3.2942 0.7338 0.3922 0.5547]
mano: [3.2942 0.7338 0.3922 0.5547]
```

El proveedor (nodo 0) recibe la suma de los rendimientos de sus vecinos ponderada por $p_{ji}/\sqrt{d_i d_j}$, con $d$
el grado ponderado. Dos detalles importan para el problema. La GCN **no distingue** el cliente del aliado: los dos
mensajes son iguales salvo por el peso. Y la normalización hace que el efecto dependa de los grados, no solo de $p$:
en el modelo plantado, el efecto de un cliente es $\lambda\, p_{ij}$, sin normalizar.

## El panel como un grafo estático

El grafo es el mismo todas las semanas; lo que cambia son las features de los nodos. PyG admite ese caso sin repetir
el grafo: con `x` de forma $T \times N \times F$ (semanas, nodos, features), las capas que agregan sobre la dimensión
−2 (GCN y las propias escritas con `node_dim=-2`) procesan todas las semanas a la vez. La GAT agrega sobre la
dimensión 0, así que para ella se forma un grafo grande con una copia disjunta por semana.

Las aristas van del emisor del mensaje al receptor, con un **tipo** por relación: 0 = del cliente al proveedor (el
efecto), 1 = del proveedor al cliente (control de dirección) y 2 = alianza (en los dos sentidos).

```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), "| aristas por tipo:",
      np.bincount(rel).tolist())

anyo = fechas.year.to_numpy()
ent = np.flatnonzero(ok & (anyo <= 2018))[:-1]   # margen de una semana
pru = np.flatnonzero(ok & (anyo >= 2019))
print(f"entrenamiento: {len(ent)} semanas hasta "
      f"{fechas[ent[-1]].date()} (su etiqueta termina el "
      f"{pd.Timestamp(fin[ent[-1]]).date()})")
print(f"prueba: {len(pru)} semanas desde {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) | aristas por tipo: [509, 509, 1040]
entrenamiento: 362 semanas hasta 2018-12-21 (su etiqueta termina el 2018-12-28)
prueba: 365 semanas desde 2019-01-04
```

La última semana de 2018 se deja fuera del entrenamiento: su etiqueta termina el primer día de la prueba, y la regla
`fin < fecha` de la lección 5.4.3 deja ese margen. Rendimientos en tanto por ciento para que los gradientes tengan una
escala razonable.

La métrica de todos los modelos es el **IC** semanal (correlación de rangos entre la predicción y el rendimiento
siguiente) en los 299 proveedores, que son las acciones donde el efecto existe; su media y su error típico salen de
las 365 semanas de prueba. El techo es el oráculo: `alfa_grafo`, la parte del alfa verdadero que viene del grafo.

```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),
            "error típico": 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 = {"oráculo (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("MCO (clientes, proveedores, alianzas):",
      coef_mco[1:].round(4))
pred_lin = (coef_mco[1] * Sc + coef_mco[2] * Sp
            + coef_mco[3] * Sa)[pru]
ics["regresión lineal"] = ic_semanal(pred_lin)
pd.DataFrame({k: resumen(v) for k, v in ics.items()}).T
```
```output
MCO (clientes, proveedores, alianzas): [ 0.0509  0.0031 -0.0031]
                          IC  error típico    t
oráculo (alfa_grafo)  0.0144        0.0031  4.7
regresión lineal      0.0133        0.0031  4.3
```

Entrenada con 2012-2018, la regresión lineal con las tres medias ponderadas tiene en 2019-2025 un IC de 0,0133, a una
milésima del oráculo (0,0144). Es poco en términos absolutos, porque el efecto del grafo es una fracción mínima de la
varianza, pero es casi todo lo que hay.

## Una capa relacional que es la regresión

La primera GNN se escribe a mano con `MessagePassing`, la clase base de PyG: el mensaje de $j$ a $i$ es
$\theta_{r}\, p_{ji}\, x_j$, con un coeficiente $\theta_r$ por tipo de arista, y la agregación es la suma. Es una R-GCN
lineal de una capa sin normalizar. Se entrena por descenso de gradiente con el error cuadrático, sobre todas las
acciones y todas las semanas de entrenamiento a la vez.

```python
from torch_geometric.nn import MessagePassing

class Relacional(MessagePassing):
    """Suma de theta[tipo] * peso * x_j sobre las aristas 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"error cuadrático de entrenamiento: {mse:.3f}")
print("theta (clientes, proveedores, alianzas):",
      rgnn.theta.detach().numpy().round(4))
with torch.no_grad():
    ics["GNN relacional"] = ic_semanal(
        rgnn(Xt[pru], ei, ew, rt).numpy())
print(resumen(ics["GNN relacional"]))
```
```output
error cuadrático de entrenamiento: 10.757
theta (clientes, proveedores, alianzas): [ 0.0509  0.0031 -0.0031]
{'IC': 0.0133, 'error típico': 0.0031, 't': 4.3}
```

Los coeficientes son los de mínimos cuadrados (0,0509, 0,0031 y −0,0031) y el IC fuera de muestra, el de la
regresión. No es casualidad: con mensaje lineal, suma y un coeficiente por relación, la «GNN» es el modelo lineal
sobre las tres medias ponderadas, y el gradiente converge a su solución. La lección es de diseño: **la estructura
correcta (tipo y dirección de la arista, sin normalizar) hace el trabajo**; el descenso de gradiente solo la estima.

## GCN y GAT sin saber qué arista es cuál

Ahora, dos GNN estándar con capas ocultas y no linealidades, que reciben todas las aristas. La **GCN** ve el peso de
cada arista y nada más: no sabe si viene de un cliente, de un proveedor o de un aliado. La **GAT** recibe además el
tipo como atributo de la arista (`edge_dim`), así que en principio puede aprender a atender a los clientes e ignorar
el resto. Las dos sin bucles propios (`add_self_loops=False`): el rendimiento pasado de la propia acción tiene su
propio efecto (la reversión del panel, lección 5.2.1), y aquí se aísla lo que aportan los vecinos.

```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"parámetros: {sum(q.numel() for q in gcn.parameters())}"
      f" | error de entrenamiento: {entrenar(gcn, gcn):.3f}")
with torch.no_grad():
    ics["GCN sin tipos"] = ic_semanal(gcn(Xt[pru]).numpy())
print(resumen(ics["GCN sin tipos"]))
```
```output
parámetros: 25 | error de entrenamiento: 10.759
{'IC': 0.0096, 'error típico': 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)       # tipo y peso

def bloque(semanas):
    """Un grafo con una copia disjunta del de cada semana."""
    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 con tipos"] = ic_semanal(pred_gat.reshape(len(pru), N)
                                  .numpy())
print(f"parámetros: {sum(q.numel() for q in gat.parameters())}"
      f" | error de entrenamiento: {perdida.item():.3f}")
print(resumen(ics["GAT con tipos"]))
```
```output
parámetros: 81 | error de entrenamiento: 10.758
{'IC': 0.0074, 'error típico': 0.0031, 't': 2.4}
```

El error cuadrático de entrenamiento es casi el mismo en todos los modelos (de 10,757 a 10,759, en %²): con esta
relación señal/ruido, lo que el grafo mejora la pérdida es una diezmilésima parte de ella. Fuera de muestra, la GCN baja a un IC
de 0,0096 y la GAT a 0,0074. Las dos encuentran parte de la señal (t de 3,0 y 2,4), pero menos que la regresión.

## La comparación honesta

Los IC de cada modelo tienen un error típico de unas 0,003, y sus intervalos se solapan. Las comparaciones se leen con
la **diferencia pareada** semana a semana, que quita el ruido común (las semanas son las mismas para todos):

```python
tabla = pd.DataFrame({k: resumen(v) for k, v in ics.items()}).T
base = ics["regresión lineal"]
for k, v in ics.items():
    dif = v - base
    et = dif.std(ddof=1) / np.sqrt(len(dif))
    tabla.loc[k, "dif. con la regresión"] = round(dif.mean(), 4)
    tabla.loc[k, "t dif."] = (round(dif.mean() / et, 1)
                              if et > 1e-4 else np.nan)
tabla
```
```output
                          IC  error típico    t  dif. con la regresión  t dif.
oráculo (alfa_grafo)  0.0144        0.0031  4.7                 0.0011     1.0
regresión lineal      0.0133        0.0031  4.3                 0.0000     NaN
GNN relacional        0.0133        0.0031  4.3                 0.0000     NaN
GCN sin tipos         0.0096        0.0032  3.0                -0.0037    -1.1
GAT con tipos         0.0074        0.0031  2.4                -0.0059    -2.8
```

La figura de la cabecera resume la tabla. Tres lecturas:

- **La regresión y la GNN relacional están en el techo**: 0,0133 frente a 0,0144 del oráculo, una diferencia
  menor que el error típico (t = 1,0). No queda señal que una red más grande pueda encontrar.
- **Las GNN estándar pierden señal al mezclar aristas**: la GCN suma por igual clientes, proveedores y aliados, y sus
  normalizaciones por grado deforman los pesos; la GAT podría separar los tipos, pero no aprende a hacerlo.
- **Las diferencias pareadas son modestas**: −0,0037 con t = −1,1 para la GCN y −0,0059 con t = −2,8 para la GAT.
  Con siete años de datos semanales y una señal de este tamaño, perder un 28 % del IC no se distingue del ruido;
  perder un 44 %, apenas. Es otra razón para empezar por la
  baseline: un modelo complejo que la iguala no aporta nada, y uno que la «supera» por menos de dos errores típicos
  tampoco demuestra nada.

## Lo que mira la atención

La GAT devuelve sus coeficientes de atención con `return_attention_weights=True`: para cada arista, el peso
$\alpha_{ij}$ que el nodo destino da a ese vecino (suman 1 entre los vecinos de cada nodo). Si hubiera aprendido el
efecto, en los proveedores que también tienen alianzas daría a las aristas de sus clientes bastante más que su
proporción.

```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"proveedores con alianzas: {len(cuota)}")
print(f"atención media sobre sus clientes: {cuota.mean():.3f} | "
      f"fracción de aristas de clientes: {proporcion.mean():.3f}")
print(f"proveedores con más atención que proporción: "
      f"{(cuota > proporcion).mean():.1%}")
```
```output
proveedores con alianzas: 280
atención media sobre sus clientes: 0.301 | fracción de aristas de clientes: 0.316
proveedores con más atención que proporción: 38.6%
```

*Fig. — Atención de la GAT sobre las aristas de los clientes frente a su fracción entre las aristas que llegan a cada proveedor (280 proveedores con alianzas, agrupados por fracción).* Diecinueve puntos, uno por valor de la fracción de aristas de clientes (de 0,11 a 0,75), con la atención media de la GAT sobre esas aristas. Todos quedan sobre la diagonal atención igual a fracción o un poco por debajo; ninguno se separa hacia arriba, que es lo que haría una atención que prefiriera a los clientes. Media destacada: atención 0,30 frente a una fracción de 0,32.

La atención media sobre los clientes (0,30) es la fracción de aristas que vienen de clientes (0,32), o un poco menos,
y solo el 38,6 % de los proveedores da a sus clientes más atención que su proporción: la GAT reparte su atención casi
en proporción al número de aristas, sin preferir las que tienen el efecto. La razón es
la relación señal/ruido. El gradiente que llega a los parámetros de la atención depende de cuánto mejora la pérdida al
atender a un vecino u otro, y aquí esa mejora es de diezmilésimas, enterrada en el ruido de cada semana. La atención
no es una explicación del modelo: es un parámetro más, tan ruidoso como los demás (lección 5.10.1 para la
interpretabilidad en general).

## Cuándo aporta algo una GNN

El resultado de esta lección no dice que las GNN no sirvan; dice que **no crean señal** y que la estructura importa más
que la arquitectura. Una GNN añade valor sobre la regresión de vecinos cuando hay algo que la regresión no puede
expresar con facilidad:

| Situación | Qué aporta la GNN | Qué baseline hay que batir |
|---|---|---|
| Efecto lineal en la media de vecinos de un tipo conocido | nada: es la regresión | Fama-MacBeth sobre la media ponderada |
| Varios tipos de relación y muchas features por nodo | combinar features de vecinos con pesos aprendidos | regresión con las medias ponderadas de cada feature y tipo |
| Efectos de varios saltos (clientes de los clientes) | capas apiladas | medias de $W^2 x$, $W^3 x$ |
| Efecto no lineal o que depende del vecino (atención) | mensajes no lineales | árboles sobre las medias de vecinos |
| Grafos grandes que cambian en el tiempo | GNN temporales (p. ej. Feng et al., 2019) | la misma regresión con el grafo de cada fecha |

En la literatura de predicción de acciones con grafos (relaciones de sector, de cadena de suministro, de
coocurrencia en noticias o de correlación) abundan los resultados con validaciones débiles: una partición temporal
única, sin costes, sin baseline lineal con las mismas aristas y con grafos construidos con información posterior. Lo
que hay que pedir a cualquiera de ellos es lo mismo que en esta lección: el grafo **point-in-time**, una baseline
lineal con las mismas aristas y la diferencia pareada fuera de muestra.

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

El grafo es un dato con fecha. En datos reales, las relaciones de cliente se publican en informes anuales con meses de
retraso, cambian y desaparecen. Construir el grafo con la lista de clientes de hoy y aplicarlo a 2012 es mirar al futuro
(lección 5.2.2): una relación que se conoce porque el cliente creció mucho codifica justo el rendimiento que se quiere
predecir. En el nivel 5 el grafo es estático y conocido desde el principio, por construcción.

</aside>

## Ejercicios

1. Calcula la señal de **dos saltos**: la media ponderada de los clientes de los clientes ($W_c^2 x$). ¿Tiene efecto
   en el proveedor según Fama-MacBeth? ¿Qué te dice sobre apilar dos capas de GNN en este grafo?

**Solución.**

```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
{'pendiente': 0.0287, 'error típico': 0.0289, 't': 0.9948, 'acciones': 265.0}
```

La pendiente de dos saltos es de 0,029 con un error típico de 0,029 (t = 1,0): no se distingue de cero, porque el generador solo planta un salto (el residuo de los clientes
directos). Una GNN de dos capas tendría acceso a esa información y, con datos tan ruidosos, aprendería algo de ruido
en ella; la baseline dice de antemano que no hay nada que buscar. Con datos reales, la misma regresión sobre
$W^2 x$ es la prueba barata antes de apilar capas.

2. Entrena la capa relacional **sin dirección**: un solo tipo para las aristas de cliente en los dos sentidos (fusiona
   los tipos 0 y 1). ¿Qué coeficiente aprende y qué IC tiene fuera de muestra?

**Solución.**

```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 (cliente sin dirección, -, alianzas):",
      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 (cliente sin dirección, -, alianzas): [ 0.0276  0.     -0.0029]
{'IC': 0.0113, 'error típico': 0.0031, 't': 3.6}
```

Sin dirección, el coeficiente baja a 0,0276 (más o menos la media de 0,0509 y 0,0031): se reparte entre la arista con
efecto y la que no lo tiene, y la predicción de cada proveedor mezcla a sus clientes con sus propios proveedores. El IC
baja de 0,0133 a 0,0113. El parámetro del tipo 1 no recibe gradiente
(no queda ninguna arista de ese tipo) y se queda en 0. Saber la dirección del efecto económico es información que vale
más que cualquier capa oculta.

3. Un proveedor de datos te ofrece un grafo de cadena de suministro con 50 000 empresas y la lista de relaciones
   vigente hoy, y un artículo con una GNN que da un Sharpe de 3 en 2010-2020 con ese grafo. ¿Qué preguntas harías antes
   de usarlo?

**Solución.**

Primero, la fecha de conocimiento de cada arista: si el proveedor guarda el historial de relaciones con la fecha en que
se publicó cada una (point-in-time), o solo la foto de hoy, que mira al futuro. Segundo, la baseline: qué Sharpe da
una regresión sobre la media ponderada de los vecinos con las mismas aristas, y qué diferencia pareada hay entre la GNN
y esa baseline. Tercero, la validación: walk-forward con margen entre entrenamiento y prueba (lección 5.4.3), cuántas
arquitecturas e hiperparámetros se probaron (Deflated Sharpe, lección 5.4.4) y si el Sharpe sobrevive a los costes de
la rotación (lección 5.1.3). Un Sharpe de 3 con una señal que en el mejor de los casos explica una fracción mínima de
la varianza semanal es, casi seguro, una fuga o una selección.

## Autoevaluación

1. En `grafo5_relaciones.csv`, la fila `BANE,LEKA,cliente,0.346` dice que LEKA es cliente de BANE. ¿En qué dirección tiene que viajar el mensaje en `edge_index` para aprender el efecto plantado?

   - A. De BANE a LEKA: el proveedor informa al cliente
   - B. En las dos direcciones con el mismo coeficiente
   - C. De LEKA a BANE: el rendimiento del cliente de esta semana predice el del proveedor la siguiente
   - D. No importa: una GNN aprende la dirección sola

   *Respuesta C: De LEKA a BANE: el rendimiento del cliente de esta semana predice el del proveedor la siguiente.* El efecto plantado es λ·Σ peso·residuo de los clientes sobre el proveedor. En PyG el mensaje va de `edge_index[0]` (origen) a `edge_index[1]` (destino), así que la arista útil es cliente a proveedor. La dirección contraria tiene una pendiente de 0,007 con t = 0,9: nada.

2. La capa relacional (un coeficiente por tipo de arista) aprende 0,0509 para los clientes, 0,0031 para los proveedores y −0,0031 para las alianzas. ¿Qué es esto?

   - A. La misma regresión lineal que la referencia, estimada por descenso de gradiente: los coeficientes coinciden con los de mínimos cuadrados
   - B. Una prueba de que la GNN es mejor que la regresión
   - C. Un sobreajuste: los coeficientes deberían ser 0,035 exactos
   - D. Un error, porque una GNN no puede tener solo tres parámetros

   *Respuesta A: La misma regresión lineal que la referencia, estimada por descenso de gradiente: los coeficientes coinciden con los de mínimos cuadrados.* Con agregación por suma, mensaje lineal y un peso por relación, la capa es Σ_r θ_r·Σ_j peso·x_j: exactamente el modelo lineal sobre las tres medias ponderadas. Converge a la solución de mínimos cuadrados (0,0509 frente a 0,0509). La distancia a 0,035 es error de estimación: unos dos errores típicos.

3. Fuera de muestra (2019-2025), la regresión lineal da un IC de 0,0133 en los 299 proveedores, la GCN sin tipos 0,0096 y la GAT 0,0074. El oráculo (`alfa_grafo`) da 0,0144. ¿Qué se concluye?

   - A. Que la GAT es la mejor porque tiene atención
   - B. Que el grafo no tiene señal
   - C. Que las GNN han detectado efectos no lineales que la regresión no ve
   - D. Que la regresión ya está cerca del techo; las GNN que mezclan aristas con y sin efecto pierden parte de la señal, aunque las diferencias pareadas no son concluyentes

   *Respuesta D: Que la regresión ya está cerca del techo; las GNN que mezclan aristas con y sin efecto pierden parte de la señal, aunque las diferencias pareadas no son concluyentes.* La regresión queda a 0,001 del oráculo. La GCN y la GAT reciben también las alianzas y la dirección contraria, y diluyen la señal. Con un error típico de unas 0,003 por IC, la diferencia pareada de la GCN tiene t = −1,1 y la de la GAT, t = −2,8: la dirección es clara; la evidencia, firme solo para la GAT.

4. La GAT recibe el tipo de arista como atributo. En los 280 proveedores que también tienen alianzas, la atención sobre las aristas de sus clientes suma de media 0,30, y esas aristas son el 32 % de las que les llegan. ¿Qué indica?

   - A. Que la atención identificó a los clientes
   - B. Que la atención no aprendió a preferir a los clientes: reparte el peso casi en proporción al número de aristas
   - C. Que hay que añadir más cabezas de atención hasta que la proporción suba
   - D. Que las alianzas tienen más señal que los clientes

   *Respuesta B: Que la atención no aprendió a preferir a los clientes: reparte el peso casi en proporción al número de aristas.* Si la atención hubiera aprendido el efecto, daría a las aristas de los clientes mucho más que su proporción. Con una señal que explica una fracción mínima de la varianza, el gradiente que llega a los parámetros de la atención es casi ruido. Leer la atención como explicación es arriesgado incluso cuando el modelo funciona.

5. En datos reales, ¿cuál es la trampa principal de usar el grafo cliente-proveedor de hoy para un backtest de 2012-2025?

   - A. Que el grafo es demasiado pequeño
   - B. Que las GNN no admiten grafos dirigidos
   - C. Mirar al futuro: las relaciones se publican con retraso y cambian; el grafo de hoy incluye relaciones que en 2012 no existían o no se conocían
   - D. Que las relaciones cliente-proveedor no tienen efecto en mercados reales

   *Respuesta C: Mirar al futuro: las relaciones se publican con retraso y cambian; el grafo de hoy incluye relaciones que en 2012 no existían o no se conocían.* El grafo es un dato como cualquier otro y tiene su fecha de conocimiento (lección 5.2.2). En el nivel 5 es estático y conocido desde el principio por construcción; en datos reales hay que reconstruirlo point-in-time.

## Ideas clave

- Un grafo de activos en PyG es `edge_index` (origen, destino), pesos y tipos. El mensaje viaja del origen al destino: para el efecto cliente a proveedor, la arista va del cliente al proveedor.
- La referencia lineal es la media ponderada de los vecinos de cada tipo: con los clientes, pendiente de Fama-MacBeth 0,048 (t = 6,5; verdad 0,035); con las alianzas y en la dirección contraria, nada (t < 1).
- Una capa de paso de mensajes lineal con un coeficiente por relación es esa misma regresión: recupera 0,0509 en clientes y ≈ 0 en los controles, igual que mínimos cuadrados, y su IC fuera de muestra (0,0133) queda cerca del oráculo (0,0144).
- Una GCN o una GAT que reciben todas las aristas mezcladas diluyen la señal: IC de 0,0096 y 0,0074. La atención de la GAT no distingue clientes de alianzas.
- Cuando el efecto es lineal en la media de los vecinos, una GNN no puede mejorar mucho a la regresión: lo que aporta la GNN es estructura (tipos, dirección, varios saltos, no linealidades), no magia. Sin estructura correcta, pierde.
- El grafo es un dato con fecha: en datos reales se reconstruye point-in-time, y la comparación con una baseline lineal va siempre delante.

## Recursos

### Documentación

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

### Libros

- [Cohen, L. y 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. y 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

### Datos

- [Datos del nivel 5: grafo cliente-proveedor y alianzas (CSV)](https://pythonparatrading.com/curso/datos/nivel-5/grafo5_relaciones.csv) · Descarga · grafo5_relaciones.csv

Lección anterior: [5.7.3 · Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos](https://pythonparatrading.com/curso-python-trading/nivel-5/modelos-probabilisticos-cuantiles-y-deepar/)

Lección siguiente: [5.7.5 · Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe](https://pythonparatrading.com/curso-python-trading/nivel-5/optimizacion-de-carteras-end-to-end/)
