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.
- Módulo
- 5.7 · Deep learning con PyTorch
- Duración
- 70 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
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:
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
alianzaal 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.
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())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 y semana :
con el rendimiento del cliente en la semana que termina en la fecha , menos la media de su sector, e 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.
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%}")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 termina en la fecha , el rendimiento de la semana que acaba en es la etiqueta de la fila anterior: se obtiene sin mirar más allá de la fecha de decisión.
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) 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.0La 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 recibe un mensaje de cada vecino que le apunta, los agrega con una operación que no depende del orden (suma, media, máximo) y actualiza su representación:
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:
| Capa | Mensaje de a | Qué sabe de la arista |
|---|---|---|
| GCN (Kipf y Welling, 2017) | solo su peso; normaliza por los grados | |
| GAT (Veličković et al., 2018) | , con un softmax aprendido entre los vecinos de | lo que entre en la atención (atributos, si se le dan) |
| R-GCN (Schlichtkrull et al., 2018) | , una matriz por tipo de relación | el tipo, explícitamente |
En PyG, un grafo es un objeto Data con x (features de los nodos, ) y edge_index, un tensor de
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:
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))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 , con 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 : en el modelo plantado, el efecto de un cliente es , 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 (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).
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) > 0edge_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-04La ú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.
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()}).TMCO (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.3Entrenada 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 a es
, con un coeficiente 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.
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"]))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.
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"]))parámetros: 25 | error de entrenamiento: 10.759
{'IC': 0.0096, 'error típico': 0.0032, 't': 3.0}from torch_geometric.nn import GATConv
atrib = torch.cat([torch.nn.functional.one_hot(rt, 3).float(),
ew.view(-1, 1)], dim=1) # 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"]))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):
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 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.8La 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
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.
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%}")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%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 , |
| 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.
Ejercicios
- Calcula la señal de dos saltos: la media ponderada de los clientes de los clientes (). ¿Tiene efecto en el proveedor según Fama-MacBeth? ¿Qué te dice sobre apilar dos capas de GNN en este grafo?
Solución
W2 = Wc @ Wc
fm2 = fama_macbeth(X0 @ W2.T, W2.sum(1) > 0)
{k: round(float(v), 4) for k, v in fm2.items()}{'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 es la prueba barata antes de apilar capas.
- 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
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)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.
- 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
En
grafo5_relaciones.csv, la filaBANE,LEKA,cliente,0.346dice que LEKA es cliente de BANE. ¿En qué dirección tiene que viajar el mensaje enedge_indexpara 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) aedge_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.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.
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.
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.
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
Libros
- Cohen, L. y Frazzini, A. (2008): Economic Links and Predictable Returns. The Journal of Finance 63(4), 1977-2011
- Kipf, T. N. y Welling, M. (2017): Semi-Supervised Classification with Graph Convolutional Networks (ICLR; arXiv:1609.02907)
- Veličković, P. et al. (2018): Graph Attention Networks (ICLR; arXiv:1710.10903)
- Schlichtkrull, M. et al. (2018): Modeling Relational Data with Graph Convolutional Networks (arXiv:1703.06103)
- Feng, F. et al. (2019): Temporal Relational Ranking for Stock Prediction (arXiv:1809.09441)