Lección 6.7.6 · 70 min
Satélite, visión por computador y cadena de suministro
Contar coches en el aparcamiento de una cadena de tiendas es el ejemplo clásico de dato alternativo, y también el mejor para ver dónde se rompe: nubes, calendario y pocas tiendas. Esta lección entrena una CNN de 3.585 parámetros que cuenta coches mejor que un umbral de píxeles, convierte los recuentos en una señal de ventas y después propaga señales por un grafo de puertos, regiones, clientes y proveedores, comprobando cada salto con la verdad de los datos.
- Módulo
- 6.7 · Datos alternativos
- Duración
- 70 min
Lecciones del nivel 6
6.1 · NLP financiero clásico y moderno
6.2 · LLMs para investigación y trading
6.3 · RAG financiero
6.4 · Agentes de IA para trading
- 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
- 6.4.2 Marcos de agentes y MCP
- 6.4.3 Arquitecturas multiagente tipo TradingAgents
- 6.4.4 Evidencia y límites de los agentes
- 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
- 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
6.5 · IA como copiloto del quant
6.6 · Big data para trading
- 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
- 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
- 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
- 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
- 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
6.7 · Datos alternativos
Coches desde el espacio
La idea es antigua y fácil de contar: si el aparcamiento de una cadena de tiendas está más lleno de lo normal durante el trimestre, sus ventas probablemente superarán lo que espera el consenso. Hay proveedores que venden recuentos de coches a partir de imágenes de satélite de miles de aparcamientos, y la literatura ha medido sus consecuencias. Katona, Painter, Patatoukas y Zeng (2025) usan la entrada de la cobertura por satélite de grandes minoristas para estudiar el acceso desigual a big data: según su resumen, los inversores con acceso a esos datos formularon estrategias rentables, sobre todo contra minoristas con malas noticias en el trimestre, y la llegada de los datos se asoció a ventas en corto más informadas y a menos liquidez alrededor de los resultados.
Entre la imagen y la señal hay una cadena de decisiones, y cada una es un sitio donde equivocarse:
Fuente Mermaid
flowchart TB
A[Imagen del aparcamiento] --> B{Nubes}
B -->|sí| C[Descartar o modelo robusto]
B -->|no| D[Contar coches]
C --> D
D --> E[Ocupación por plaza útil]
E --> F[Quitar calendario]
F --> G[Media del trimestre]
G --> H[Sorpresa de ventas]La lección usa satelite6_imagenes.parquet: 1.620 imágenes sintéticas de 64×64 píxeles en gris de 16 aparcamientos
(dos por cada una de ocho cadenas), una cada 24-32 días entre 2018 y 2025, con el número exacto de coches como
etiqueta. El generador publica cómo se forma la ocupación:
donde es el choque de demanda del trimestre, la parte de las ventas que no conoce el consenso (lección 6.7.5). El resto es calendario o ruido. Son datos sintéticos con el efecto plantado: sirven para medir cada paso contra la verdad, no para estimar cuánto valen los recuentos reales.
Las imágenes
import numpy as np
import pandas as pd
img = pd.read_parquet("satelite6_imagenes.parquet")
X = np.stack([np.frombuffer(b, np.uint8).reshape(64, 64)
for b in img["pixeles"]]).astype(np.float32)
coches = img["coches"].to_numpy()
nubes = img["nubes"].to_numpy() > 0
print(X.shape, "·", img["ticker"].nunique(), "cadenas ·",
img["aparcamiento"].nunique(), "aparcamientos")
print(img[["fecha", "aparcamiento", "plazas", "nubes", "coches"]]
.head(3).to_string(index=False))
print(f"con nubes: {nubes.mean():.1%} · coches por imagen: "
f"media {coches.mean():.1f}, de {coches.min()} a {coches.max()}")(1620, 64, 64) · 8 cadenas · 16 aparcamientos
fecha aparcamiento plazas nubes coches
2018-01-01 BUPA-1 44 0.0 19
2018-01-01 LELE-1 48 0.0 21
2018-01-03 TISE-1 43 0.0 15
con nubes: 11.0% · coches por imagen: media 23.3, de 0 a 44Cada imagen es un aparcamiento visto desde arriba: asfalto con ruido, cinco filas de diez plazas separadas por líneas
claras, algunas isletas con árboles que inutilizan plazas, coches de 4×7 píxeles de tonos distintos (oscuros y
claros) con parabrisas y sombra, y un brillo que cambia de una toma a otra. En el 11 % de las imágenes hay nubes:
manchas claras y difuminadas que tapan una parte del aparcamiento, coches incluidos. La figura muestra una imagen
despejada de los datos (LELE-1, 2018-01-25) leída plaza a plaza: cada rectángulo es una plaza, relleno si el valor
medio de sus píxeles se aparta del asfalto en más de 20 niveles de gris. Se apartan 26 plazas: los 24 coches de la
etiqueta y las 2 isletas (la imagen tiene 48 plazas útiles de 50). Contar plazas «llenas» sin saber dónde están las
isletas daría 26, no 24.
Línea base: contar píxeles
Antes de una red neuronal, lo más simple que podría funcionar: un coche es un bloque de píxeles que se aparta del asfalto (más oscuro o más claro), así que el número de píxeles «raros» de la imagen debería ser proporcional al de coches. La proporción se calibra con una recta en las imágenes despejadas de 2018-2022 y se evalúa en 2023-2025, la misma partición temporal que usará la CNN:
entreno = (img["fecha"] < "2023-01-01").to_numpy()
prueba = ~entreno
mediana = np.median(X.reshape(len(X), -1), axis=1)
raros = (np.abs(X - mediana[:, None, None]) > 35).sum(axis=(1, 2))
ok = entreno & ~nubes
b = np.polyfit(raros[ok], coches[ok], 1)
umbral = np.polyval(b, raros)
def mae(pred, m):
return np.abs(pred - coches)[m].mean()
print(f"{b[0]:.4f} coches por píxel raro (un coche ocupa unos "
f"{1 / b[0]:.0f} píxeles)")
print(f"error medio en prueba: despejadas "
f"{mae(umbral, prueba & ~nubes):.2f}"
f" · con nubes {mae(umbral, prueba & nubes):.2f} coches")0.0314 coches por píxel raro (un coche ocupa unos 32 píxeles)
error medio en prueba: despejadas 1.69 · con nubes 21.95 cochesEn las imágenes despejadas, el umbral se equivoca en menos de dos coches de media: no está mal para tres líneas de código. Con nubes, el error se multiplica por más de diez, porque las nubes son manchas claras que el umbral cuenta como coches. La línea base ya dice dónde está el problema. La primera defensa no es un modelo, sino detectar las nubes: una nube es una zona grande y uniformemente clara, mientras que un coche claro es pequeño. El brillo medio en una ventana de 9×9 píxeles las separa:
from scipy import ndimage
zona_clara = np.array([(ndimage.uniform_filter(x, 9) > 150).mean()
for x in X])
nublada = zona_clara > 0.09
print(pd.crosstab(pd.Series(nubes, name="nubes (verdad)"),
pd.Series(nublada, name="detectada")))detectada False True
nubes (verdad)
False 1441 0
True 0 179En estos datos la máscara separa las imágenes sin un solo error. Con imágenes reales no es tan limpio: las nubes finas, sus sombras y la nieve se confunden con el asfalto, y los proveedores combinan bandas espectrales que aquí no hay. La regla es la misma: medir la máscara contra una muestra etiquetada antes de fiarse.
Una CNN pequeña que cuenta
Una red convolucional (lección 5.6.3 para el caso de una dimensión) aplica los mismos filtros pequeños en todas las posiciones de la imagen. Para contar objetos parecidos repartidos por la imagen es el sesgo adecuado: un filtro que reconoce un coche lo reconoce en cualquier plaza. La red de la celda tiene tres capas convolucionales con dos reducciones a la mitad y termina en una convolución de 1×1 que produce un mapa de densidad de 16×16; la predicción es la suma del mapa. Así cada zona aporta su parte del recuento, en lugar de pedir a la red que adivine un número global.
Fuente Mermaid
flowchart TB A[Imagen 64x64] --> B[Conv 3x3, 8 filtros] B --> C[Max pool: 32x32] C --> D[Conv 3x3, 16 filtros] D --> E[Max pool: 16x16] E --> F[Conv 3x3 y 1x1] F --> G[Mapa de densidad] G --> H[Suma: coches]
Se entrena con las imágenes de 2018-2022 (con y sin nubes), 25 pasadas, lotes de 32 y la pérdida cuadrática; las imágenes se voltean en horizontal en una de cada dos pasadas (un aparcamiento volteado tiene los mismos coches). Todo en CPU y con las semillas y los algoritmos deterministas del entorno (lección 5.7.1). En producción se entrenaría más, con más imágenes y con validación para elegir la parada; aquí basta para ver la diferencia.
import torch
from torch import nn
torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
Xn = (X - X[entreno].mean()) / X[entreno].std()
xt = torch.tensor(Xn)[:, None]
yt = torch.tensor(coches, dtype=torch.float32)
class Contador(nn.Module):
def __init__(self):
super().__init__()
self.capas = nn.Sequential(
nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 16, 3, padding=1), nn.ReLU(),
nn.Conv2d(16, 1, 1))
def forward(self, x):
return self.capas(x).sum(dim=(1, 2, 3)) # suma del mapa
red = Contador()
print("parámetros:", sum(p.numel() for p in red.parameters()))
opt = torch.optim.Adam(red.parameters(), lr=3e-3)
idx = np.flatnonzero(entreno)
gen = torch.Generator().manual_seed(0)
for epoca in range(25):
orden = idx[torch.randperm(len(idx), generator=gen).numpy()]
red.train()
for k in range(0, len(orden), 32):
lote = orden[k:k + 32]
xb = xt[lote].flip(3) if epoca % 2 else xt[lote]
perdida = ((red(xb) - yt[lote]) ** 2).mean()
opt.zero_grad()
perdida.backward()
opt.step()
red.eval()
with torch.no_grad():
cnn = red(xt).numpy()
for nombre, p in [("umbral", umbral), ("CNN", cnn)]:
print(f"{nombre:6s} error medio en prueba: despejadas "
f"{mae(p, prueba & ~nubes):.1f} · con nubes "
f"{mae(p, prueba & nubes):.1f} coches")parámetros: 3585
umbral error medio en prueba: despejadas 1.7 · con nubes 22.0 coches
CNN error medio en prueba: despejadas 1.3 · con nubes 4.7 cochesCon 3.585 parámetros, la CNN baja el error en las imágenes despejadas de 2023-2025 por debajo del de la línea base y, sobre todo, lo divide en las imágenes con nubes: ha aprendido a no contar las manchas claras. No puede ver los coches que la nube tapa, así que su error con nubes sigue siendo mayor; en esas imágenes predice algo parecido a un aparcamiento medio. La partición es temporal (se entrena con el pasado y se evalúa con el futuro) aunque contar coches no dependa del mercado: si la cámara, la resolución o el aparcamiento cambian con los años, solo una evaluación posterior lo detecta.
Vision Transformers
Un Vision Transformer (ViT; Dosovitskiy y coautores, 2020) prescinde de las convoluciones: corta la imagen en parches (por ejemplo de 16×16 píxeles), proyecta cada parche a un vector y pasa la secuencia por un transformer como el de la lección 6.2.1. La atención permite relacionar zonas lejanas de la imagen desde la primera capa, a cambio de perder el sesgo de la convolución (filtros locales compartidos), que el modelo tiene que aprender de los datos. El artículo muestra que un transformer aplicado directamente a secuencias de parches da muy buenos resultados en clasificación de imágenes cuando se preentrena con conjuntos grandes y se transfiere a tareas más pequeñas.
Para 1.000 imágenes de 64×64 con un patrón local y repetido, la CNN tiene el sesgo correcto y un ViT entrenado desde
cero no aporta nada. El ViT compensa con muchas imágenes, con escenas donde el contexto global importa (tipo de
suelo, sombras, nubes) o, sobre todo, ajustando un modelo preentrenado con imágenes de satélite. Un ViT mínimo para
estas imágenes con transformers, sin ejecutar en la lección (se ha comprobado que construye y calcula la pérdida en
el entorno ml; entrenarlo bien llevaría más tiempo del que cabe aquí):
from transformers import ViTConfig, ViTForImageClassification
cfg = ViTConfig(image_size=64, patch_size=8, num_channels=1,
hidden_size=64, num_hidden_layers=4,
num_attention_heads=4, intermediate_size=128,
num_labels=1, problem_type="regression")
vit = ViTForImageClassification(cfg) # 142.465 parámetros
salida = vit(pixel_values=xt[:32], labels=yt[:32])
salida.loss.backward() # pérdida cuadráticaCon parches de 8×8, la imagen es una secuencia de 64 parches. El modelo tiene 142.465 parámetros, unas 40 veces la CNN, para una tarea que la CNN ya resuelve.
Del recuento a la señal
Un recuento no es una señal. Hay que pasar a ocupación (coches por plaza útil, porque los aparcamientos tienen tamaños distintos), quitar lo que el consenso ya sabe (los fines de semana, diciembre y el cierre de la primavera de 2020 llenan o vacían el aparcamiento sin decir nada de la demanda), y promediar por trimestre. Los efectos de calendario se estiman con una regresión sobre las propias imágenes. Después, la ocupación media del trimestre se compara con la verdad del generador: el choque de demanda y la sorpresa de ventas.
verdad = pd.read_csv("alt6_verdad_trimestral.csv",
parse_dates=["fin_trimestre"])
def senal(recuento, usar):
d = img.loc[usar, ["fecha", "ticker", "plazas"]].copy()
d["ocupacion"] = recuento[usar] / d["plazas"]
cal = np.c_[np.ones(len(d)), d["fecha"].dt.weekday >= 5,
d["fecha"].dt.month == 12,
d["fecha"].between("2020-03-20", "2020-06-15")]
beta = np.linalg.lstsq(cal, d["ocupacion"], rcond=None)[0]
d["residuo"] = d["ocupacion"] - cal @ beta
d["fin_trimestre"] = (d["fecha"].dt.to_period("Q")
.dt.end_time.dt.normalize())
q = (d.groupby(["ticker", "fin_trimestre"])["residuo"].mean()
.rename("ocupacion").reset_index()
.merge(verdad, on=["ticker", "fin_trimestre"]))
q = q[~q["fin_trimestre"].between("2020-03-01", "2020-09-30")]
return q, beta
todas = np.ones(len(img), bool)
filas = {}
for nombre, rec, usar in [
("recuento verdadero", coches, todas),
("umbral, todas", umbral, todas),
("umbral, sin nubes", umbral, ~nublada),
("CNN, todas", cnn, todas),
("CNN, sin nubes", cnn, ~nublada)]:
q, beta = senal(rec, usar)
filas[nombre] = {"trimestres": len(q),
"corr. con z": q["ocupacion"].corr(q["z_demanda"]),
"corr. con sorpresa":
q["ocupacion"].corr(q["sorpresa_ventas"])}
_, beta = senal(coches, todas)
print("calendario estimado (constante, fin de semana, diciembre, "
"cierre):", beta.round(3))
pd.DataFrame(filas).T.round(3)calendario estimado (constante, fin de semana, diciembre, cierre): [ 0.498 0.096 0.034 -0.2 ]
trimestres corr. con z corr. con sorpresa
recuento verdadero 224.0 0.840 0.706
umbral, todas 224.0 0.512 0.459
umbral, sin nubes 224.0 0.788 0.667
CNN, todas 224.0 0.825 0.698
CNN, sin nubes 224.0 0.822 0.669Es la figura de la cabecera. Los coeficientes de calendario recuperan bien la base y el fin de semana (0,498 y 0,096, frente a 0,50 y 0,10 en el generador); diciembre (0,034 frente a 0,08) y el cierre de 2020 (−0,20 frente a −0,25) salen por debajo, porque hay pocas imágenes de cada (132 de diciembre y 49 del cierre), la regresión no controla el choque de demanda de cada trimestre y la ocupación está acotada entre 0 y 1. Con los recuentos verdaderos, la ocupación trimestral tiene una correlación de 0,84 con el choque de demanda y de 0,71 con la sorpresa de ventas, en 224 empresa-trimestres (sin el segundo y tercer trimestre de 2020). Es el techo de la fuente: una imagen al mes por aparcamiento y dos aparcamientos por cadena dejan unas seis imágenes por trimestre, y el ruido de cada toma no se promedia del todo.
La línea base sin máscara pierde casi la mitad de la señal por las nubes; con la máscara, recupera la mayor parte. La CNN se acerca al techo con todas las imágenes, y con la máscara pierde las tomas nubladas, que también tenían algo de información. La diferencia entre métodos es mayor en el recuento que en la señal: el trimestre promedia errores de coches sueltos, pero no un sesgo sistemático como el de las nubes. Lo que queda es el paso de la lección 6.7.5: comparar con el consenso, validar fuera de muestra y backtestear con costes, sabiendo que el alfa de estos datos también decae cuando se difunden.
Grafos de cadena de suministro
Una señal de una empresa puede decir algo de otras: las llegadas de buques a un puerto, de las exportadoras que lo
usan; el clima de una región, de sus eléctricas; las ventas de un cliente, de sus proveedores. Un grafo dirigido
guarda esas relaciones y permite propagar una señal: el valor de un nodo pasa a sus vecinos, multiplicado por el
peso de la arista. El nivel 5 (lección 5.7.4) usó el grafo cliente-proveedor del panel para una GNN; aquí se combina
con las exposiciones de los datos alternativos (alt6_exposiciones.csv):
Fuente Mermaid
flowchart TB P[Puerto] -->|exposición| X[Exportadora] R[Región] -->|exposición| U[Eléctrica] X -->|peso cliente| S[Proveedor] U -->|peso cliente| S2[Proveedor]
En grafo5_relaciones.csv, una arista cliente va de origen (el proveedor) a destino (su cliente) con el peso de
las ventas del proveedor que hace ese cliente. Para propagar demanda hacia arriba en la cadena, la arista se invierte:
de cliente a proveedor.
import networkx as nx
expo = pd.read_csv("alt6_exposiciones.csv")
rel = pd.read_csv("grafo5_relaciones.csv")
G = nx.DiGraph()
for r in expo.itertuples():
G.add_edge(r.zona, r.ticker, peso=1.0, tipo=r.tipo)
for r in rel[rel["tipo"] == "cliente"].itertuples():
G.add_edge(r.destino, r.origen, peso=r.peso, tipo="cliente")
print(G.number_of_nodes(), "nodos ·", G.number_of_edges(), "aristas")
print(pd.Series(nx.get_edge_attributes(G, "tipo")).value_counts()
.to_string())
def propagar(senal_nodos, saltos):
"""Valor de cada nodo tras `saltos` pasos por las aristas."""
actual = dict(senal_nodos)
for _ in range(saltos):
siguiente = {}
for n, v in actual.items():
for m in G.successors(n) if n in G else []:
siguiente[m] = (siguiente.get(m, 0.0)
+ v * G[n][m]["peso"])
actual = siguiente
return actual380 nodos · 531 aristas
cliente 509
clima 12
puerto 10Las señales de origen se construyen con los datos, sin la verdad: la actividad de cada puerto es el logaritmo de las llegadas de portacontenedores del trimestre menos su media de los trimestres anteriores, y el índice de clima de cada región, los grados-día de calefacción más 1,5 veces los de refrigeración del trimestre frente a su media histórica del mismo trimestre (ambas point-in-time). Se propagan uno y dos saltos y se comparan con la sorpresa de ventas de las empresas que reciben la señal:
ais = pd.read_parquet("alt6_ais_llegadas.parquet")
clima = pd.read_parquet("alt6_clima.parquet")
def fin_trim(f):
return f.dt.to_period("Q").dt.end_time.dt.normalize()
def anomalia(s, por):
"""Valor menos la media de los trimestres anteriores de `por`."""
previa = s.groupby(por).transform(
lambda x: x.expanding().mean().shift(1))
return s - previa
c = ais[ais["tipo_buque"] == "contenedores"]
pto = (np.log(c.groupby(["puerto", fin_trim(c["fecha"])])["llegadas"]
.sum()).rename("v").reset_index())
pto["v"] = anomalia(pto["v"], pto["puerto"])
pto = pto.rename(columns={"puerto": "nodo", "fecha": "fin_trimestre"})
clima["gd"] = clima["hdd"] + 1.5 * clima["cdd"]
reg = (clima.groupby(["region", fin_trim(clima["fecha"])])["gd"]
.sum().rename("v").reset_index()
.rename(columns={"region": "nodo", "fecha": "fin_trimestre"}))
reg["v"] = anomalia(reg["v"], [reg["nodo"],
reg["fin_trimestre"].dt.quarter])
reg["v"] = reg["v"] / reg.groupby("nodo")["v"].transform("std")
filas = []
for fuente, tabla in [("puerto", pto), ("clima", reg)]:
for q, d in tabla.dropna().groupby("fin_trimestre"):
origen = dict(zip(d["nodo"], d["v"]))
for saltos in (1, 2):
for tk, v in propagar(origen, saltos).items():
filas.append((fuente, saltos, q, tk, v))
P = pd.DataFrame(filas, columns=["fuente", "saltos", "fin_trimestre",
"ticker", "senal"])
P = P.merge(verdad[["ticker", "fin_trimestre", "sorpresa_ventas"]],
on=["ticker", "fin_trimestre"])
P = P[~P["fin_trimestre"].between("2020-03-01", "2020-09-30")]
P.groupby(["fuente", "saltos"]).apply(lambda d: pd.Series({
"empresas": d["ticker"].nunique(), "observaciones": len(d),
"correlación": d["senal"].corr(d["sorpresa_ventas"])})).round(3) empresas observaciones correlación
fuente saltos
clima 1 12.0 288.0 0.673
2 2.0 48.0 -0.078
puerto 1 10.0 270.0 0.423
2 1.0 27.0 -0.216Un salto lleva señal: la actividad del puerto tiene una correlación de 0,42 con la sorpresa de ventas de sus exportadoras y el índice de clima, de 0,67 con la de sus eléctricas. Es lo plantado en el generador (el choque de demanda de una exportadora es 0,8 veces el de su puerto más uno propio; el de una eléctrica, 0,9 veces el clima de su región). El segundo salto, de esas empresas a sus proveedores, no lleva nada que se distinga de cero (−0,08 con 48 observaciones y −0,22 con 27, cuando el error típico de una correlación con 27 datos es de unos 0,19): en estos datos las ventas de un proveedor no dependen de las de sus clientes. Cada arista de un grafo es una hipótesis económica y se mide por separado; propagar «por si acaso» solo añade ruido con apariencia de estructura.
En rendimientos sí hay un mecanismo plantado: en el nivel 5, el 3,5 % del residuo semanal de un cliente pasa a su proveedor la semana siguiente (λ = 0,035), el tipo de efecto que Cohen y Frazzini (2008) documentan con clientes principales y atención limitada de los inversores. ¿Se puede medir con los resultados del nivel 6, que es donde los datos alternativos mueven a los clientes? Antes de buscarlo, un cálculo de potencia con las aristas cliente-proveedor entre empresas del nivel 6:
precios = pd.read_parquet("acciones6_precios.parquet")
r = precios.pivot(index="fecha", columns="ticker", values="cierre")
r = r.pct_change()
anormal = r.sub(r.mean(axis=1), axis=0)
cli = rel[(rel["tipo"] == "cliente")
& rel["origen"].isin(r.columns)
& rel["destino"].isin(r.columns)]
semana = anormal.rolling(5).sum()
ruido_proveedor = semana[cli["origen"].unique()].std().median()
reaccion_cliente = anormal[cli["destino"].unique()].std().median()
x_sd = cli["peso"].mean() * reaccion_cliente
eventos = len(cli) * 4 * 7 # una reacción por trimestre, 7 años
error = ruido_proveedor / (x_sd * np.sqrt(eventos))
print(f"aristas: {len(cli)} · eventos: {eventos}")
print(f"error típico de la pendiente: {error:.3f} (efecto: 0,035)")
necesarios = (2 * ruido_proveedor / (0.035 * x_sd)) ** 2
print(f"eventos para t = 2: {necesarios:,.0f}")aristas: 44 · eventos: 1232
error típico de la pendiente: 0.296 (efecto: 0,035)
eventos para t = 2: 351,524Con 44 aristas y siete años de resultados trimestrales, el error típico de la pendiente es varias veces el efecto: el traspaso existe, pero no se puede ver así (error típico 0,296 frente a un efecto de 0,035). Para distinguirlo de cero con t = 2 harían falta unos 350.000 eventos. La lección 5.7.4 lo midió con las 400 acciones del panel, 509 aristas y todas las semanas, que es cuando la muestra alcanza. Un grafo no crea información: la reparte. Si el efecto por arista es pequeño, solo muchas aristas y muchas observaciones lo hacen visible, y el cálculo de potencia se hace antes de entrenar ningún modelo.
Ejercicios
- Repite el recuento por umbral con un umbral de 25 y de 45 niveles de gris. ¿Qué umbral funciona mejor en las imágenes despejadas y cuál con nubes? ¿Por qué no coinciden?
Solución
for u in (25, 35, 45):
raros_u = (np.abs(X - mediana[:, None, None]) > u).sum(axis=(1, 2))
bu = np.polyfit(raros_u[ok], coches[ok], 1)
pu = np.polyval(bu, raros_u)
print(f"umbral {u}: despejadas {mae(pu, prueba & ~nubes):.2f} · "
f"con nubes {mae(pu, prueba & nubes):.2f}")umbral 25: despejadas 1.84 · con nubes 28.97
umbral 35: despejadas 1.69 · con nubes 21.95
umbral 45: despejadas 2.88 · con nubes 16.22Un umbral bajo recoge también el ruido del asfalto y los bordes de las nubes; uno alto pierde los coches de tono parecido al asfalto. En las imágenes despejadas el óptimo está en medio; con nubes, cuanto más alto el umbral, menos nube cuenta como coche. Ajustar el umbral para las nubes empeora las despejadas: el problema no es el parámetro, es que el método no distingue una nube de un coche. Por eso la máscara (o un modelo que aprenda la diferencia) es la solución, y no el ajuste fino.
- Mide el error de la CNN en el conjunto de entrenamiento (2018-2022) y compáralo con el de prueba. ¿Hay sobreajuste?
Solución
for nombre, m in [("entrenamiento", entreno), ("prueba", prueba)]:
print(f"{nombre:13s} despejadas {mae(cnn, m & ~nubes):.2f} · "
f"con nubes {mae(cnn, m & nubes):.2f}")entrenamiento despejadas 1.36 · con nubes 4.79
prueba despejadas 1.33 · con nubes 4.69Los errores de entrenamiento y de prueba son del mismo orden: con 3.585 parámetros y unas mil imágenes, el modelo no tiene capacidad para memorizarlas. Si el error de prueba fuera mucho mayor, habría que sospechar un cambio en las imágenes con el tiempo (otra cámara, otra luz) antes que un modelo demasiado grande.
- Construye la señal de clima con un salto pero sin quitar la media del mismo trimestre (solo la media de todos los trimestres anteriores). ¿Qué pasa con la correlación y por qué?
Solución
reg2 = (clima.groupby(["region", fin_trim(clima["fecha"])])["gd"]
.sum().rename("v").reset_index()
.rename(columns={"region": "nodo", "fecha": "fin_trimestre"}))
reg2["v"] = anomalia(reg2["v"], reg2["nodo"])
filas = []
for q, d in reg2.dropna().groupby("fin_trimestre"):
for tk, v in propagar(dict(zip(d["nodo"], d["v"])), 1).items():
filas.append((q, tk, v))
P2 = pd.DataFrame(filas, columns=["fin_trimestre", "ticker", "senal"])
P2 = P2.merge(verdad[["ticker", "fin_trimestre", "sorpresa_ventas"]],
on=["ticker", "fin_trimestre"])
print(f"correlación: {P2['senal'].corr(P2['sorpresa_ventas']):.3f}")correlación: -0.002Sin quitar la estacionalidad, la señal es sobre todo «invierno frente a verano»: los grados-día de calefacción del primer trimestre siempre superan la media del año. El consenso ya conoce la estación (está en la base de las ventas), así que esa parte no predice la sorpresa y la correlación cae. La anomalía tiene que medirse frente a lo que el mercado ya espera, igual que el gasto con tarjeta se mide frente al consenso en la lección 6.7.5.
Respuesta plegada en cada pregunta
Autoevaluación
El umbral de píxeles cuenta coches con un error absoluto medio de 1,7 en imágenes despejadas y de 22 en las que tienen nubes. ¿Qué conviene hacer antes de entrenar un modelo más complejo?
- ANada: el error medio de todas las imágenes es aceptable
- BDetectar las imágenes con nubes y tratarlas aparte (descartarlas o pasarlas a un modelo que las tolere): el error se concentra en el 11 % de las imágenes
- CSubir el umbral hasta que el error con nubes baje
- DPasar las imágenes a color
Ver respuesta
Respuesta B. Detectar las imágenes con nubes y tratarlas aparte (descartarlas o pasarlas a un modelo que las tolere): el error se concentra en el 11 % de las imágenes
Las nubes son manchas claras que el umbral confunde con coches. Una máscara de nubes sencilla (brillo medio local) las separa en estos datos sin errores, y con ella la línea base da una señal con correlación 0,79 con el choque de demanda.
La CNN de la lección suma un mapa de densidad en lugar de clasificar la imagen. ¿Por qué tiene sentido para contar?
- APorque es más rápido de entrenar en GPU
- BPorque así no necesita etiquetas
- CPorque los ViT no pueden contar
- DPorque cada coche aporta una cantidad local parecida esté donde esté: la convolución reconoce el patrón en cualquier posición y la suma convierte las detecciones locales en un número
Ver respuesta
Respuesta D. Porque cada coche aporta una cantidad local parecida esté donde esté: la convolución reconoce el patrón en cualquier posición y la suma convierte las detecciones locales en un número
Es el sesgo inductivo de las CNN: los mismos filtros en toda la imagen (equivarianza a traslaciones). Con 1.000 imágenes de 64×64 y 3.585 parámetros basta para bajar el error a 1,3 coches en las imágenes despejadas de 2023-2025.
Un Vision Transformer (Dosovitskiy et al., 2020) divide la imagen en parches y los procesa con un transformer. ¿Cuándo compensa frente a una CNN pequeña?
- ASobre todo cuando hay muchos datos o un modelo preentrenado que ajustar; con unas pocas imágenes pequeñas y un patrón local simple, la CNN tiene el sesgo adecuado
- BSiempre, porque es más moderno
- CNunca en imágenes de satélite
- DSolo con imágenes en color
Ver respuesta
Respuesta A. Sobre todo cuando hay muchos datos o un modelo preentrenado que ajustar; con unas pocas imágenes pequeñas y un patrón local simple, la CNN tiene el sesgo adecuado
El artículo del ViT muestra resultados fuertes cuando se preentrena con conjuntos grandes y se transfiere. Un ViT mínimo para estas imágenes ya tiene 142.465 parámetros, 40 veces la CNN, sin la ventaja de la convolución.
Las llegadas de portacontenedores a un puerto anticipan la sorpresa de ventas de las exportadoras que lo usan (correlación 0,42), pero la señal propagada un salto más, a los proveedores de esas exportadoras, no. ¿Qué enseña?
- AQue los grafos no sirven para datos alternativos
- BQue hay que propagar más saltos
- CQue cada arista propaga solo si hay un mecanismo económico detrás: en estos datos el puerto mueve las ventas de las exportadoras, pero las ventas de un cliente no mueven las de su proveedor
- DQue la correlación debería ser negativa
Ver respuesta
Respuesta C. Que cada arista propaga solo si hay un mecanismo económico detrás: en estos datos el puerto mueve las ventas de las exportadoras, pero las ventas de un cliente no mueven las de su proveedor
En el generador, el choque de demanda de una exportadora depende de su puerto (0,8·p); las ventas de los proveedores no dependen de las de sus clientes. Una señal propagada por un grafo es una hipótesis que hay que medir, salto a salto.
En el nivel 5, el 3,5 % del residuo semanal de un cliente pasa a su proveedor la semana siguiente. Con 44 aristas cliente-proveedor entre empresas del nivel 6 y un evento por trimestre, ¿se puede medir con la reacción a los resultados?
- ASí, con cualquier muestra
- BNo: el error típico de la pendiente es unas ocho veces el efecto; harían falta cientos de miles de eventos. 5.7.4 lo mide con 400 acciones y todas las semanas
- CSí, si se usa una GNN
- DNo, porque el efecto es negativo
Ver respuesta
Respuesta B. No: el error típico de la pendiente es unas ocho veces el efecto; harían falta cientos de miles de eventos. 5.7.4 lo mide con 400 acciones y todas las semanas
Es un cálculo de potencia, previo a cualquier modelo. La lección lo hace: con el ruido semanal de un proveedor y la dispersión de la señal del cliente, el error típico de la pendiente supera con mucho a 0,035.
Ideas clave
- Un recuento por umbral de píxeles funciona en imágenes despejadas (error medio 1,7 coches) y se rompe con nubes (22): la línea base dice dónde está el problema antes de entrenar nada.
- Una CNN de 3.585 parámetros que suma un mapa de densidad, entrenada en CPU con 2018-2022 y evaluada en 2023-2025, baja el error a 1,3 coches despejado y a unos 5 con nubes. Un ViT es la alternativa cuando hay muchos datos o un modelo preentrenado; con esto, sobra.
- De recuento a señal: máscara de nubes, ocupación por plaza útil, efectos de calendario (fin de semana, diciembre, cierres) y media trimestral. La ocupación trimestral tiene una correlación de 0,84 con el choque de demanda usando los recuentos verdaderos; el modelo se acerca y el umbral sin máscara se queda en 0,51.
- Un grafo de exposiciones (puerto y exportadora, región y eléctrica) propaga señales con mecanismo: correlación de 0,42 y 0,67 con la sorpresa de ventas. Un salto más, de cliente a proveedor, no lleva nada en las ventas de estos datos.
- Antes de buscar un efecto en un grafo, potencia: el traspaso de rendimientos de cliente a proveedor del nivel 5 existe, pero con 44 aristas y eventos trimestrales no se distingue del ruido.
Recursos
Documentación
Libros
- Katona, Z., Painter, M., Patatoukas, P. N. y Zeng, J. (2025): On the Capital Market Consequences of Big Data: Evidence from Outer Space. JFQA 60(2), 551-579. DOI 10.1017/S0022109023001448
- Dosovitskiy, A. et al. (2020): An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale. arXiv:2010.11929
- Cohen, L. y Frazzini, A. (2008): Economic Links and Predictable Returns. Journal of Finance 63(4), 1977-2011. DOI 10.1111/j.1540-6261.2008.01379.x