---
title: "6.7.6 Satélite, visión por computador y cadena de suministro"
description: "Dos casos de datos alternativos: contar coches en imágenes con una CNN pequeña en PyTorch y propagar señales por un grafo de exposiciones y clientes."
url: "https://pythonparatrading.com/curso-python-trading/nivel-6/satelite-vision-por-computador-y-cadena-de-suministro/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-6/satelite-vision-por-computador-y-cadena-de-suministro.png"
---

# 6.7.6 · 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.

- Curso: [Nivel 6: IA generativa, LLMs, agentes, big data y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/)
- Módulo: 6.7 · Datos alternativos
- Duración: 70 min
- Requisitos: [5.6.3 · CNN 1D, LSTM y GRU: ventanas, escalado y fugas](https://pythonparatrading.com/curso-python-trading/nivel-5/cnn-1d-lstm-y-gru-para-secuencias/), [5.7.1 · PyTorch: tensores, autograd y el bucle de entrenamiento](https://pythonparatrading.com/curso-python-trading/nivel-5/pytorch-tensores-autograd-y-bucle-de-entrenamiento/), [5.7.4 · Redes de grafos para relaciones entre activos](https://pythonparatrading.com/curso-python-trading/nivel-5/redes-de-grafos-para-activos/), [6.7.5 · Del dato alternativo a la señal](https://pythonparatrading.com/curso-python-trading/nivel-6/del-dato-alternativo-a-la-senal/)

## Al terminar esta lección

- Construir una línea base de recuento con umbral de píxeles y medir dónde falla (nubes) antes de entrenar un modelo.
- Entrenar en CPU una CNN pequeña de recuento en PyTorch, con partición temporal, y compararla con la línea base por tipo de imagen.
- Explicar qué cambia un Vision Transformer frente a una CNN y cuándo compensa con pocos datos.
- Pasar de recuentos a una señal trimestral de ventas (máscara de nubes, efectos de calendario, agregación) y medirla contra la sorpresa.
- Propagar señales por un grafo de exposiciones y de clientes con networkx, distinguir los saltos con mecanismo de los que no lo tienen y calcular la potencia antes de buscar un efecto.

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

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

$$
\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 $z$ 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

```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()}")
```
```output
(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.* Rejilla de 5 filas por 10 plazas del aparcamiento LELE-1 el 25 de enero de 2018. 26 plazas aparecen rellenas porque su valor medio se aparta del asfalto y 24 vacías. La etiqueta de la imagen dice 24 coches: las otras 2 plazas rellenas son isletas con árboles (48 plazas útiles de 50).

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

```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")
```
```output
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:

```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")))
```
```output
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.

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

```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")
```
```output
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.* Cuatro barras horizontales. El umbral se equivoca en 1,69 coches de media en las imágenes despejadas y en 21,95 en las nubladas. La CNN, en 1,33 en las despejadas y en 4,69 en las nubladas, destacada.

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

```python skip
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 $z$ y la sorpresa de ventas.

```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)
```
```output
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`):

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

```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
```
```output
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:

```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)
```
```output
               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).* Cuatro barras horizontales. Un salto: puerto a exportadora 0,423 y clima a eléctrica 0,673, destacada. Dos saltos, a los proveedores: −0,216 con 27 observaciones y −0,078 con 48, compatibles con cero.

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:

```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}")
```
```output
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.**

```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}")
```
```output
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.

2. Mide el error de la CNN en el **conjunto de entrenamiento** (2018-2022) y compáralo con el de prueba. ¿Hay
   sobreajuste?

**Solución.**

```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}")
```
```output
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.

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

```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}")
```
```output
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.

## 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?

   - A. Nada: el error medio de todas las imágenes es aceptable
   - 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
   - C. Subir el umbral hasta que el error con nubes baje
   - D. Pasar las imágenes a color

   *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?

   - A. Porque es más rápido de entrenar en GPU
   - B. Porque así no necesita etiquetas
   - C. Porque los ViT no pueden contar
   - 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

   *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?

   - 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
   - B. Siempre, porque es más moderno
   - C. Nunca en imágenes de satélite
   - D. Solo con imágenes en color

   *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?

   - A. Que los grafos no sirven para datos alternativos
   - B. Que hay que propagar más saltos
   - 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
   - D. Que la correlación debería ser negativa

   *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?

   - A. Sí, con cualquier muestra
   - 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
   - C. Sí, si se usa una GNN
   - D. No, porque el efecto es negativo

   *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

- [PyTorch: torch.nn.Conv2d](https://docs.pytorch.org/docs/stable/generated/torch.nn.Conv2d.html) · docs.pytorch.org
- [NetworkX: documentación](https://networkx.org/documentation/stable/) · networkx.org

### 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](https://doi.org/10.1017/S0022109023001448) · doi.org
- [Dosovitskiy, A. et al. (2020): An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale. arXiv:2010.11929](https://arxiv.org/abs/2010.11929) · arxiv.org
- [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](https://doi.org/10.1111/j.1540-6261.2008.01379.x) · doi.org

### Datos

- [Datos del nivel 6: imágenes tipo satélite de aparcamientos (Parquet)](https://pythonparatrading.com/curso/datos/nivel-6/satelite6_imagenes.parquet) · Descarga · satelite6_imagenes.parquet
- [Datos del nivel 6: exposiciones a clima y puertos (CSV)](https://pythonparatrading.com/curso/datos/nivel-6/alt6_exposiciones.csv) · Descarga · alt6_exposiciones.csv

Lección anterior: [6.7.5 · Del dato alternativo a la señal](https://pythonparatrading.com/curso-python-trading/nivel-6/del-dato-alternativo-a-la-senal/)
