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.

Fig. — Correlación de la ocupación trimestral de los aparcamientos con el choque de demanda de ocho cadenas, según cómo se cuentan los coches
Lecciones del nivel 6
  1. 6.1 · NLP financiero clásico y moderno

    1. 6.1.1 Tokenización, embeddings, diccionarios financieros y FinBERT
    2. 6.1.2 Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales
    3. 6.1.3 Event studies con señales de texto, alineación temporal y topic modeling
  2. 6.2 · LLMs para investigación y trading

    1. 6.2.1 Cómo funciona un LLM: del siguiente token a la API
    2. 6.2.2 Prompting estructurado: JSON que se puede validar
    3. 6.2.3 Guidance, KPIs y tono: qué extrae bien un LLM
    4. 6.2.4 Fine-tuning ligero para tareas financieras: LoRA y QLoRA
    5. 6.2.5 Sesgo de anticipación en LLMs: el modelo que ya conoce el futuro
  3. 6.3 · RAG financiero

    1. 6.3.1 Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant
    2. 6.3.2 Chunking de documentos financieros: tablas, notas al pie y XBRL
    3. 6.3.3 RAG sobre informes, folletos y KID, y cómo evaluarlo
  4. 6.4 · Agentes de IA para trading

    1. 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
    2. 6.4.2 Marcos de agentes y MCP
    3. 6.4.3 Arquitecturas multiagente tipo TradingAgents
    4. 6.4.4 Evidencia y límites de los agentes
    5. 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
    6. 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
  5. 6.5 · IA como copiloto del quant

    1. 6.5.1 Asistentes de código con un flujo de tests primero
    2. 6.5.2 Revisión automática de backtests y migraciones de código
    3. 6.5.3 Riesgos del código generado por IA
  6. 6.6 · Big data para trading

    1. 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
    2. 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
    3. 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
    4. 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
    5. 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
  7. 6.7 · Datos alternativos

    1. 6.7.1 Panorama y tamaño del mercado de datos alternativos
    2. 6.7.2 Tipos de datos alternativos: qué mide cada uno
    3. 6.7.3 Web scraping responsable
    4. 6.7.4 Proveedores y due diligence de datos alternativos
    5. 6.7.5 Del dato alternativo a la señal
    6. 6.7.6 Satélite, visión por computador y cadena de suministro

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:

sínoImagen del aparcamientoNubesDescartar o modelo robustoContar cochesOcupación por plaza útilQuitar calendarioMedia del trimestreSorpresa de ventas
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:

ocupacioˊn=0,50+0,07 z+0,10⋅fin de semana+0,08⋅diciembre−0,25⋅cierre de 2020+ε,\text{ocupación} = 0{,}50 + 0{,}07\,z + 0{,}10\cdot\text{fin de semana} + 0{,}08\cdot\text{diciembre} - 0{,}25\cdot\text{cierre de 2020} + \varepsilon ,

donde zz 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

IN [01]python
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()}")
OUT [01]Cargar las imágenes
(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 44

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

Fig. — Una imagen de los datos leída plaza a plaza: las plazas cuyo valor medio se aparta del asfalto (rellenas) son coches o isletas

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:

IN [02]python
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")
OUT [02]Recuento por umbral de píxeles
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 coches

En 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:

IN [03]python
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")))
OUT [03]Una máscara de nubes
detectada       False  True
nubes (verdad)
False            1441      0
True                0    179

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

Imagen 64x64Conv 3x3, 8 filtrosMax pool: 32x32Conv 3x3, 16 filtrosMax pool: 16x16Conv 3x3 y 1x1Mapa de densidadSuma: coches
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.

IN [04]python
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")
OUT [04]Entrenar la CNN de recuento
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 coches
Fig. — Error absoluto medio del recuento de coches en las imágenes de prueba (2023-2025): umbral de píxeles frente a una CNN de 3.585 parámetros

Con 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í):

IN [05]python
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ática

Con 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 zz y la sorpresa de ventas.

IN [06]python
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)
OUT [06]De coches a una señal trimestral
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.669

Es 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):

exposiciónexposiciónpeso clientepeso clientePuertoExportadoraRegiónEléctricaProveedorProveedor
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.

IN [07]python
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 actual
OUT [07]Un grafo de exposiciones y clientes
380 nodos · 531 aristas
cliente    509
clima       12
puerto      10

Las 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:

IN [08]python
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)
OUT [08]Propagar la actividad de puertos y el clima
               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.216
Fig. — Correlación de una señal propagada por el grafo con la sorpresa de ventas de las empresas que la reciben: un salto (exposición directa) y dos saltos (a sus proveedores)

Un 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:

IN [09]python
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}")
OUT [09]Potencia: ¿se ve el traspaso de cliente a proveedor?
aristas: 44 · eventos: 1232
error típico de la pendiente: 0.296 (efecto: 0,035)
eventos para t = 2: 351,524

Con 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

  1. 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
IN [10]python
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}")
OUT [10]Sensibilidad al umbral de píxeles
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.22

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

  1. Mide el error de la CNN en el conjunto de entrenamiento (2018-2022) y compáralo con el de prueba. ¿Hay sobreajuste?
Solución
IN [11]python
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}")
OUT [11]Error de la CNN en entrenamiento y prueba
entrenamiento despejadas 1.36 · con nubes 4.79
prueba        despejadas 1.33 · con nubes 4.69

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

  1. 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
IN [12]python
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}")
OUT [12]Clima sin quitar la estacionalidad
correlación: -0.002

Sin 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

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

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

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

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

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