Lección 5.7.4 · 70 min

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.

Fig. — IC fuera de muestra (2019-2025) de la señal del grafo cliente-proveedor del nivel 5: oráculo, regresión lineal y tres GNN entrenadas con 2012-2018
Lecciones del nivel 5
  1. 5.1 · Fundamentos de ML aplicados a finanzas

    1. 5.1.1 Por qué el machine learning financiero es distinto
    2. 5.1.2 Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn
    3. 5.1.3 Métricas económicas para modelos de trading: más allá del acierto
  2. 5.2 · Ingeniería de características

    1. 5.2.1 Features de precio, volumen y microestructura
    2. 5.2.2 Fundamentales point-in-time, macro y datos alternativos
    3. 5.2.3 Diferenciación fraccional para features
    4. 5.2.4 Normalización sin leakage y rank transversal
    5. 5.2.5 Importancia de features: MDI, MDA, SFI y SHAP
  3. 5.3 · Etiquetado y muestreo

    1. 5.3.1 Etiquetado de horizonte fijo y sus problemas
    2. 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
    3. 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
    4. 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
    5. 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
  4. 5.4 · Validación para series financieras

    1. 5.4.1 Por qué falla el k-fold aleatorio en datos financieros
    2. 5.4.2 Purged k-fold con embargo y CPCV para modelos de ML
    3. 5.4.3 Walk-forward con reentrenamiento: ventanas y frecuencia
    4. 5.4.4 Backtest de señales de ML con costes y Deflated Sharpe
  5. 5.5 · Modelos clásicos

    1. 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
    2. 5.5.2 Árboles de decisión, Random Forest y bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
    4. 5.5.4 Optuna y el riesgo de sobreajustar la validación
    5. 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
    6. 5.5.6 Learning to rank para la selección de acciones
  6. 5.6 · Deep learning con Keras 3

    1. 5.6.1 Keras 3: un modelo, varios motores de cálculo
    2. 5.6.2 Un MLP sobre un panel de acciones: regularizar y comparar
    3. 5.6.3 CNN 1D, LSTM y GRU: ventanas, escalado y fugas
    4. 5.6.4 Callbacks, una pérdida de Sharpe y el modelo en producción
  7. 5.7 · Deep learning con PyTorch

    1. 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
    2. 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
    3. 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
    4. 5.7.4 Redes de grafos para relaciones entre activos
    5. 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
  8. 5.8 · Modelos fundacionales de series temporales

    1. 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
    2. 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
    3. 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
    4. 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
    5. 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
  9. 5.9 · Aprendizaje por refuerzo

    1. 5.9.1 MDP, Q-learning, PPO y entornos Gymnasium
    2. 5.9.2 RL para ejecución, market making y cobertura dinámica
    3. 5.9.3 Por qué el RL sobreajusta en trading direccional
  10. 5.10 · Interpretabilidad, robustez y riesgo de modelo

    1. 5.10.1 SHAP, dependencia parcial y estabilidad de las importancias
    2. 5.10.2 Deriva de datos y de concepto: detectarla y qué hacer
    3. 5.10.3 Ensembles, combinación de señales y datos sintéticos

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:

peso 0,35peso 0,10alianzar de la semanaCliente AProveedorCliente BAliador siguiente
Fuente 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.

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.

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

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

g = pd.read_csv("grafo5_relaciones.csv")
act = pd.read_csv("panel5_activos.csv").sort_values("ticker")
tick = act["ticker"].to_numpy()
pos = {t: i for i, t in enumerate(tick)}
N = len(tick)
print("PyG", torch_geometric.__version__, "|", N, "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())
OUT [01]El grafo: tipos, pesos y grados
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 ii y semana ww:

si,w=∑j∈clientes(i)pij xj,w,yi,w+1=aw+λw si,w+εi,w+1s_{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 xj,wx_{j,w} el rendimiento del cliente jj en la semana que termina en la fecha ww, menos la media de su sector, e yy 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.

IN [02]python
etiq = pd.read_parquet("panel5_etiquetas.parquet")
etiq = etiq.sort_values(["fecha", "ticker"])
fechas = pd.DatetimeIndex(etiq["fecha"].unique())
nW = len(fechas)
R = etiq["ret_sig_semana"].to_numpy().reshape(nW, N)
fin = etiq["fin_sig_semana"].to_numpy().reshape(nW, N)[:, 0]
print("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%}")
OUT [02]Rendimientos semanales menos la media del sector
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 ww termina en la fecha w+1w+1, el rendimiento de la semana que acaba en ww es la etiqueta de la fila anterior: se obtiene sin mirar más allá de la fecha de decisión.

IN [03]python
o = g["origen"].map(pos).to_numpy()
d = g["destino"].map(pos).to_numpy()
p = g["peso"].to_numpy()
cl = (g["tipo"] == "cliente").to_numpy()
Wc = np.zeros((N, N))                  # Wc[i, j]: 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)
OUT [03]Fama-MacBeth sobre la media ponderada de los vecinos
                    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

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 ii recibe un mensaje de cada vecino jj que le apunta, los agrega con una operación que no depende del orden (suma, media, máximo) y actualiza su representación:

hi′=γ(hi, ⨁j→iϕ(hi,hj,eji))h_i' = \gamma\Big(h_i,\ \bigoplus_{j \to i} \phi(h_i, h_j, e_{ji})\Big)
h de vecinosMensaje phiAtributo aristaAgregar sumaActualizarh nueva
Fuente 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:

CapaMensaje de jj a iiQué sabe de la arista
GCN (Kipf y Welling, 2017)Θhj⋅pji/didj\Theta h_j \cdot p_{ji} / \sqrt{d_i d_j}solo su peso; normaliza por los grados
GAT (Veličković et al., 2018)αij Θhj\alpha_{ij}\, \Theta h_j, con α\alpha un softmax aprendido entre los vecinos de iilo que entre en la atención (atributos, si se le dan)
R-GCN (Schlichtkrull et al., 2018)Θrhj\Theta_{r} h_j, una matriz por tipo de relación rrel tipo, explícitamente

En PyG, un grafo es un objeto Data con x (features de los nodos, N×FN \times F) y edge_index, un tensor de 2×E2 \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:

IN [04]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))
OUT [04]Una GCN a mano y en PyG
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 pji/didjp_{ji}/\sqrt{d_i d_j}, con dd 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 pp: en el modelo plantado, el efecto de un cliente es λ pij\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×N×FT \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).

IN [05]python
src = np.r_[d[cl], o[cl], o[~cl], d[~cl]]
dst = np.r_[o[cl], d[cl], d[~cl], o[~cl]]
rel = np.r_[np.zeros(cl.sum()), np.ones(cl.sum()),
            np.full(2 * (~cl).sum(), 2)].astype(int)
ei = torch.tensor(np.vstack([src, dst]))
ew = torch.tensor(np.r_[p[cl], p[cl], p[~cl], p[~cl]],
                  dtype=torch.float32)
rt = torch.tensor(rel)
print("edge_index:", tuple(ei.shape), "| 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
OUT [05]edge_index con tipos y la partición temporal
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.

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

def resumen(ic):
    et = ic.std(ddof=1) / np.sqrt(len(ic))
    return {"IC": round(float(ic.mean()), 4),
            "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
OUT [06]Métrica, oráculo y referencia lineal fuera de muestra
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 jj a ii es θr pji xj\theta_{r}\, p_{ji}\, x_j, con un coeficiente θr\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.

IN [07]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"]))
OUT [07]Capa relacional con MessagePassing
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.

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

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

torch.manual_seed(0)
gcn = GCN()
print(f"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"]))
OUT [08]GCN con todas las aristas, sin tipos
parámetros: 25 | error de entrenamiento: 10.759
{'IC': 0.0096, 'error típico': 0.0032, 't': 3.0}
IN [09]python
from torch_geometric.nn import GATConv

atrib = torch.cat([torch.nn.functional.one_hot(rt, 3).float(),
                   ew.view(-1, 1)], dim=1)       # 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"]))
OUT [09]GAT con el tipo de arista como atributo
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):

IN [10]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
OUT [10]Tabla final y diferencias pareadas frente a la regresión
                          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 αij\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.

IN [11]python
with torch.no_grad():
    att = np.mean([gat.conv(Xt[w], ei, atrib,
                            return_attention_weights=True)[1][1]
                   .mean(1).numpy() for w in pru[:52]], axis=0)
cuota, proporcion = [], []
for i in np.flatnonzero(proveedores):
    llegan = dst == i
    if (llegan & (rel == 2)).any():
        cuota.append(att[llegan & (rel == 0)].sum())
        proporcion.append((llegan & (rel == 0)).mean()
                          / llegan.mean())
cuota, proporcion = np.array(cuota), np.array(proporcion)
print(f"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%}")
OUT [11]Atención sobre las aristas de los clientes
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)

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ónQué aporta la GNNQué baseline hay que batir
Efecto lineal en la media de vecinos de un tipo conocidonada: es la regresiónFama-MacBeth sobre la media ponderada
Varios tipos de relación y muchas features por nodocombinar features de vecinos con pesos aprendidosregresión con las medias ponderadas de cada feature y tipo
Efectos de varios saltos (clientes de los clientes)capas apiladasmedias de W2xW^2 x, W3xW^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 tiempoGNN 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.

Ejercicios

  1. Calcula la señal de dos saltos: la media ponderada de los clientes de los clientes (Wc2xW_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
IN [12]python
W2 = Wc @ Wc
fm2 = fama_macbeth(X0 @ W2.T, W2.sum(1) > 0)
{k: round(float(v), 4) for k, v in fm2.items()}
OUT [12]Clientes de los clientes
{'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 W2xW^2 x es la prueba barata antes de apilar capas.

  1. 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
IN [13]python
rt_sin = torch.where(rt == 1, 0, rt)
torch.manual_seed(0)
rsin = Relacional()
entrenar(rsin, lambda x: rsin(x, ei, ew, rt_sin))
print("theta (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)
OUT [13]La misma capa sin la dirección
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.

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

Respuesta plegada en cada pregunta

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?

    • ADe BANE a LEKA: el proveedor informa al cliente
    • BEn las dos direcciones con el mismo coeficiente
    • CDe LEKA a BANE: el rendimiento del cliente de esta semana predice el del proveedor la siguiente
    • DNo importa: una GNN aprende la dirección sola
    Ver respuesta

    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?

    • ALa misma regresión lineal que la referencia, estimada por descenso de gradiente: los coeficientes coinciden con los de mínimos cuadrados
    • BUna prueba de que la GNN es mejor que la regresión
    • CUn sobreajuste: los coeficientes deberían ser 0,035 exactos
    • DUn error, porque una GNN no puede tener solo tres parámetros
    Ver respuesta

    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?

    • AQue la GAT es la mejor porque tiene atención
    • BQue el grafo no tiene señal
    • CQue las GNN han detectado efectos no lineales que la regresión no ve
    • DQue 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
    Ver respuesta

    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?

    • AQue la atención identificó a los clientes
    • BQue la atención no aprendió a preferir a los clientes: reparte el peso casi en proporción al número de aristas
    • CQue hay que añadir más cabezas de atención hasta que la proporción suba
    • DQue las alianzas tienen más señal que los clientes
    Ver respuesta

    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?

    • AQue el grafo es demasiado pequeño
    • BQue las GNN no admiten grafos dirigidos
    • CMirar 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
    • DQue las relaciones cliente-proveedor no tienen efecto en mercados reales
    Ver respuesta

    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