---
title: "6.7.6 Satellite imagery, computer vision and supply chains"
description: "Two alternative data cases: counting cars in images with a small PyTorch CNN and propagating signals through a graph of exposures and customers."
url: "https://pythonparatrading.com/en/python-trading-course/level-6/satellite-computer-vision-supply-chain-data/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-6/satellite-computer-vision-supply-chain-data.png"
---

# 6.7.6 · Satellite imagery, computer vision and supply chains

> Counting cars in a retail chain's parking lots is the classic example of alternative data, and also the best one for seeing where it breaks: clouds, the calendar and too few stores. This lesson trains a 3,585-parameter CNN that counts cars better than a pixel threshold, turns the counts into a sales signal and then propagates signals through a graph of ports, regions, customers and suppliers, checking each hop against the ground truth of the data.

- Course: [Level 6: Generative AI, LLMs, agents, big data and alternative data](https://pythonparatrading.com/en/python-trading-course/level-6/)
- Module: 6.7 · Alternative data
- Duration: 70 min
- Prerequisites: [5.6.3 · 1D CNN, LSTM and GRU: windows, scaling and leaks](https://pythonparatrading.com/en/python-trading-course/level-5/cnn-1d-lstm-gru-sequences/), [5.7.1 · PyTorch: tensors, autograd and the training loop](https://pythonparatrading.com/en/python-trading-course/level-5/pytorch-tensors-autograd-training-loop/), [5.7.4 · Graph neural networks for asset relationships](https://pythonparatrading.com/en/python-trading-course/level-5/graph-neural-networks-for-assets/), [6.7.5 · From alternative data to a trading signal](https://pythonparatrading.com/en/python-trading-course/level-6/from-alternative-data-to-trading-signals/)

## By the end of this lesson

- Build a pixel-threshold counting baseline and measure where it fails (clouds) before training a model.
- Train a small counting CNN in PyTorch on CPU, with a time-based split, and compare it with the baseline by image type.
- Explain what a Vision Transformer changes compared with a CNN and when it pays off with little data.
- Go from counts to a quarterly sales signal (cloud mask, calendar effects, aggregation) and measure it against the surprise.
- Propagate signals through a graph of exposures and customers with networkx, tell hops with an economic mechanism from those without one, and compute the statistical power before looking for an effect.

## Cars from space

The idea is old and easy to explain: if a retail chain's parking lots are fuller than usual during the quarter, its
sales will probably beat what the consensus expects. Some vendors sell car counts built from satellite images of
thousands of parking lots, and the literature has measured the consequences. Katona, Painter, Patatoukas and Zeng
(2025) use the start of satellite coverage of large retailers to study unequal access to big data: according to their
abstract, investors with access to that data formed profitable strategies, above all against retailers with bad news
in the quarter, and the arrival of the data was associated with more informed short selling and with lower liquidity
around earnings.

Between the image and the signal there is a chain of decisions, and each one is a place to get it wrong:

```mermaid
flowchart TB
  A[Parking lot image] --> B{Clouds}
  B -->|yes| C[Drop or robust model]
  B -->|no| D[Count cars]
  C --> D
  D --> E[Occupancy per usable space]
  E --> F[Remove calendar]
  F --> G[Quarterly mean]
  G --> H[Sales surprise]
```

The lesson uses `satelite6_imagenes.parquet`: 1,620 synthetic 64×64-pixel grayscale images of 16 parking lots (two
for each of eight chains), one every 24-32 days between 2018 and 2025, with the exact number of cars as the label. The
data's columns and values are in Spanish (`coches` is cars, `nubes` clouds, `plazas` spaces, `aparcamiento` parking
lot). The generator publishes how occupancy is formed:

$$
\text{occupancy} = 0.50 + 0.07\,z + 0.10\cdot\text{weekend} + 0.08\cdot\text{December} - 0.25\cdot\text{2020 lockdown} + \varepsilon ,
$$

where $z$ is the quarter's demand shock, the part of sales the consensus does not know (lesson 6.7.5). The rest is
calendar or noise. This is synthetic data with the effect planted: it serves to measure each step against the truth,
not to estimate how much real counts are worth.

## The images

```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(), "chains ·",
      img["aparcamiento"].nunique(), "parking lots")
print(img[["fecha", "aparcamiento", "plazas", "nubes", "coches"]]
      .head(3).to_string(index=False))
print(f"cloudy: {nubes.mean():.1%} · cars per image: "
      f"mean {coches.mean():.1f}, from {coches.min()} to {coches.max()}")
```
```output
(1620, 64, 64) · 8 chains · 16 parking lots
     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
cloudy: 11.0% · cars per image: mean 23.3, from 0 to 44
```

Each image is a parking lot seen from above: noisy asphalt, five rows of ten spaces separated by light lines, a few
tree islands that make spaces unusable, 4×7-pixel cars in different tones (dark and light) with windshield and
shadow, and a brightness that changes from one shot to the next. Clouds appear in 11% of the images: bright, blurred
patches that cover part of the lot, cars included. The figure shows a clear image from the data (`LELE-1`,
2018-01-25) read space by space: each rectangle is a space, filled if the mean value of its pixels differs from the
asphalt by more than 20 gray levels. 26 spaces differ: the 24 cars in the label and the 2 islands (the image has 48
usable spaces out of 50). Counting “full” spaces without knowing where the islands are would give 26, not 24.

*Fig. — One image from the data read space by space: spaces whose mean value differs from the asphalt (filled) are cars or islands.* Grid of 5 rows by 10 spaces of parking lot LELE-1 on 25 January 2018. 26 spaces appear filled because their mean value differs from the asphalt and 24 empty. The image label says 24 cars: the other 2 filled spaces are tree islands (48 usable spaces out of 50).

## Baseline: counting pixels

Before a neural network, the simplest thing that could work: a car is a block of pixels that differs from the asphalt
(darker or lighter), so the number of “odd” pixels in the image should be proportional to the number of cars. The
ratio is calibrated with a straight line on the clear images from 2018-2022 and evaluated on 2023-2025, the same
time-based split the CNN will use:

```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} cars per odd pixel (a car takes up about "
      f"{1 / b[0]:.0f} pixels)")
print(f"mean test error: clear "
      f"{mae(umbral, prueba & ~nubes):.2f}"
      f" · cloudy {mae(umbral, prueba & nubes):.2f} cars")
```
```output
0.0314 cars per odd pixel (a car takes up about 32 pixels)
mean test error: clear 1.69 · cloudy 21.95 cars
```

On the clear images, the threshold is off by fewer than two cars on average: not bad for three lines of code. With
clouds, the error grows more than tenfold, because clouds are bright patches that the threshold counts as cars. The
baseline already tells you where the problem is. The first defense is not a model but **detecting the clouds**: a
cloud is a large, uniformly bright area, while a light car is small. The mean brightness over a 9×9-pixel window
separates them:

```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="clouds (truth)"),
                  pd.Series(nublada, name="detected")))
```
```output
detected        False  True
clouds (truth)
False            1441      0
True                0    179
```

In this data the mask separates the images without a single error. With real images it is not that clean: thin
clouds, their shadows and snow get confused with the asphalt, and vendors combine spectral bands that are not
available here. The rule is the same: measure the mask against a labeled sample before trusting it.

## A small CNN that counts

A convolutional network (lesson 5.6.3 for the one-dimensional case) applies the same small filters at every position
in the image. For counting similar objects spread across the image it is the right bias: a filter that recognizes a
car recognizes it in any space. The network in the cell has three convolutional layers with two halvings and ends in a
1×1 convolution that produces a 16×16 **density map**; the prediction is the sum of the map. That way each area
contributes its share of the count, instead of asking the network to guess a global number.

```mermaid
flowchart TB
  A[64x64 image] --> B[Conv 3x3, 8 filters]
  B --> C[Max pool: 32x32]
  C --> D[Conv 3x3, 16 filters]
  D --> E[Max pool: 16x16]
  E --> F[Conv 3x3 and 1x1]
  F --> G[Density map]
  G --> H[Sum: cars]
```

It is trained on the 2018-2022 images (cloudy and clear), 25 epochs, batches of 32 and squared loss; the images are
flipped horizontally in every other epoch (a flipped parking lot has the same cars). Everything runs on CPU with the
environment's seeds and deterministic algorithms (lesson 5.7.1). In production you would train longer, with more
images and with validation to choose when to stop; here it is enough to see the difference.

```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))  # sum of the map

red = Contador()
print("parameters:", 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 [("threshold", umbral), ("CNN", cnn)]:
    print(f"{nombre:9s} mean test error: clear "
          f"{mae(p, prueba & ~nubes):.1f} · cloudy "
          f"{mae(p, prueba & nubes):.1f} cars")
```
```output
parameters: 3585
threshold mean test error: clear 1.7 · cloudy 22.0 cars
CNN       mean test error: clear 1.3 · cloudy 4.7 cars
```

*Fig. — Mean absolute error of the car count on the test images (2023-2025): pixel threshold against a 3,585-parameter CNN.* Four horizontal bars. The threshold is off by 1.69 cars on average in clear images and by 21.95 in cloudy ones. The CNN, by 1.33 in clear images and by 4.69 in cloudy ones, highlighted.

With 3,585 parameters, the CNN brings the error on the clear 2023-2025 images below the baseline's and, above all,
divides it on the cloudy images: it has learned not to count the bright patches. It cannot see the cars the cloud
hides, so its cloudy error remains higher; on those images it predicts something close to an average parking lot.
The split is time-based (train on the past, evaluate on the future) even though counting cars does not depend on the
market: if the camera, the resolution or the parking lot change over the years, only an out-of-time evaluation
detects it.

## Vision Transformers

A Vision Transformer (ViT; Dosovitskiy and coauthors, 2020) does away with convolutions: it cuts the image into
patches (for example 16×16 pixels), projects each patch to a vector and passes the sequence through a transformer
like the one in lesson 6.2.1. Attention can relate distant areas of the image from the first layer, at the cost of
losing the bias of convolution (shared local filters), which the model has to learn from the data. The paper shows
that a transformer applied directly to sequences of patches performs very well on image classification when it is
pretrained on large datasets and transferred to smaller tasks.

For 1,000 images of 64×64 with a local, repeated pattern, the CNN has the right bias and a ViT trained from scratch
adds nothing. A ViT pays off with many images, with scenes where global context matters (ground type, shadows,
clouds) or, above all, when fine-tuning a model pretrained on satellite images. A minimal ViT for these images with
`transformers`, not run in the lesson (it has been checked that it builds and computes the loss in the `ml`
environment; training it properly would take more time than fits here):

```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 parameters
salida = vit(pixel_values=xt[:32], labels=yt[:32])
salida.loss.backward()                 # squared loss
```

With 8×8 patches, the image is a sequence of 64 patches. The model has 142,465 parameters, about 40 times the CNN,
for a task the CNN already solves.

## From count to signal

A count is not a signal. You have to convert it to occupancy (cars per usable space, because parking lots have
different sizes), remove what the consensus already knows (weekends, December and the spring 2020 lockdown fill or
empty the lot without saying anything about demand), and average by quarter. The calendar effects are estimated with
a regression on the images themselves. Then the quarter's mean occupancy is compared with the generator's truth: the
demand shock $z$ and the sales surprise.

```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 [
        ("true count", coches, todas),
        ("threshold, all", umbral, todas),
        ("threshold, no clouds", umbral, ~nublada),
        ("CNN, all", cnn, todas),
        ("CNN, no clouds", cnn, ~nublada)]:
    q, beta = senal(rec, usar)
    filas[nombre] = {"quarters": len(q),
                     "corr. with z": q["ocupacion"].corr(q["z_demanda"]),
                     "corr. with surprise":
                         q["ocupacion"].corr(q["sorpresa_ventas"])}
_, beta = senal(coches, todas)
print("estimated calendar (constant, weekend, December, "
      "lockdown):", beta.round(3))
pd.DataFrame(filas).T.round(3)
```
```output
estimated calendar (constant, weekend, December, lockdown): [ 0.498  0.096  0.034 -0.2  ]
                      quarters  corr. with z  corr. with surprise
true count               224.0         0.840                0.706
threshold, all           224.0         0.512                0.459
threshold, no clouds     224.0         0.788                0.667
CNN, all                 224.0         0.825                0.698
CNN, no clouds           224.0         0.822                0.669
```

This is the figure at the top of the lesson. The calendar coefficients recover the base and the weekend well (0.498
and 0.096, against 0.50 and 0.10 in the generator); December (0.034 against 0.08) and the 2020 lockdown (−0.20
against −0.25) come out lower, because there are few images of each (132 from December and 49 from the lockdown), the
regression does not control for each quarter's demand shock and occupancy is bounded between 0 and 1. With the true
counts, quarterly occupancy has a correlation of 0.84 with the demand shock and 0.71 with the sales surprise, over 224
company-quarters (excluding the second and third quarters of 2020). That is the ceiling of the source: one image a
month per parking lot and two parking lots per chain leave about six images per quarter, and the noise of each shot
does not fully average out.

The baseline without a mask loses almost half of the signal to clouds; with the mask, it recovers most of it. The CNN
gets close to the ceiling with all the images, and with the mask it loses the cloudy shots, which also carried some
information. The difference between methods is larger in the count than in the signal: the quarter averages out
errors on individual cars, but not a systematic bias like the clouds. What remains is the step from lesson 6.7.5:
compare with the consensus, validate out of sample and backtest with costs, knowing that the alpha of this data also
decays as it spreads.

## Supply chain graphs

A signal from one company can say something about others: ship arrivals at a port, about the exporters that use it;
the weather in a region, about its utilities; a customer's sales, about its suppliers. A directed graph stores those
relationships and lets you **propagate** a signal: the value of a node passes to its neighbors, multiplied by the
weight of the edge. Level 5 (lesson 5.7.4) used the panel's customer-supplier graph for a GNN; here it is combined
with the exposures in the alternative data (`alt6_exposiciones.csv`):

```mermaid
flowchart TB
  P[Port] -->|exposure| X[Exporter]
  R[Region] -->|exposure| U[Utility]
  X -->|customer weight| S[Supplier]
  U -->|customer weight| S2[Supplier]
```

In `grafo5_relaciones.csv`, a `cliente` (customer) edge goes from `origen` (the supplier) to `destino` (its customer)
with the weight of the supplier's sales that this customer accounts for. To propagate demand up the chain, the edge
is reversed: from customer to supplier. The exposure edges keep their `tipo` from the data: `clima` (weather) and
`puerto` (port).

```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(), "nodes ·", G.number_of_edges(), "edges")
print(pd.Series(nx.get_edge_attributes(G, "tipo")).value_counts()
      .to_string())

def propagar(senal_nodos, saltos):
    """Value of each node after `saltos` steps along the edges."""
    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 nodes · 531 edges
cliente    509
clima       12
puerto      10
```

The source signals are built from the data, without the truth: each port's activity is the logarithm of the
quarter's container ship arrivals minus its mean over previous quarters, and each region's weather index is the
quarter's heating degree days plus 1.5 times its cooling degree days, against its historical mean for the same
quarter (both point-in-time). They are propagated one and two hops and compared with the sales surprise of the
companies that receive the signal:

```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):
    """Value minus the mean of the previous quarters of `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 [("port", pto), ("weather", 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({
    "companies": d["ticker"].nunique(), "observations": len(d),
    "correlation": d["senal"].corr(d["sorpresa_ventas"])})).round(3)
```
```output
                companies  observations  correlation
fuente  saltos
port    1            10.0         270.0        0.423
        2             1.0          27.0       -0.216
weather 1            12.0         288.0        0.673
        2             2.0          48.0       -0.078
```

*Fig. — Correlation of a signal propagated through the graph with the sales surprise of the receiving companies: one hop (direct exposure) and two hops (to their suppliers).* Four horizontal bars. One hop: port to exporter 0.423 and weather to utility 0.673, highlighted. Two hops, to suppliers: −0.216 with 27 observations and −0.078 with 48, consistent with zero.

One hop carries signal: port activity has a correlation of 0.42 with the sales surprise of its exporters, and the
weather index, 0.67 with that of its utilities. That is what was planted in the generator (an exporter's demand shock
is 0.8 times its port's plus one of its own; a utility's, 0.9 times its region's weather). The second hop, from those
companies to their suppliers, carries nothing that can be told apart from zero (−0.08 with 48 observations and −0.22
with 27, when the standard error of a correlation with 27 data points is about 0.19): in this data a supplier's sales
do not depend on its customers'. Each edge of a graph is an economic hypothesis and is measured separately;
propagating “just in case” only adds noise that looks like structure.

In returns there is a planted mechanism: in level 5, 3.5% of a customer's weekly residual passes to its supplier the
following week (λ = 0.035), the kind of effect Cohen and Frazzini (2008) document with major customers and limited
investor attention. Can it be measured with the level 6 earnings, which is where the alternative data moves the
customers? Before looking for it, a power calculation with the customer-supplier edges between level 6 companies:

```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          # one reaction per quarter, 7 years
error = ruido_proveedor / (x_sd * np.sqrt(eventos))
print(f"edges: {len(cli)} · events: {eventos}")
print(f"standard error of the slope: {error:.3f} (effect: 0.035)")
necesarios = (2 * ruido_proveedor / (0.035 * x_sd)) ** 2
print(f"events for t = 2: {necesarios:,.0f}")
```
```output
edges: 44 · events: 1232
standard error of the slope: 0.296 (effect: 0.035)
events for t = 2: 351,524
```

With 44 edges and seven years of quarterly earnings, the standard error of the slope is several times the effect: the
spillover exists, but it cannot be seen this way (standard error 0.296 against an effect of 0.035). To tell it apart
from zero with t = 2 it would take about 350,000 events. Lesson 5.7.4 measured it with the panel's 400 stocks, 509
edges and every week, which is when the sample is large enough. A graph does not create information: it distributes
it. If the effect per edge is small, only many edges and many observations make it visible, and the power
calculation is done before training any model.

## Exercises

1. Repeat the threshold count with a threshold of 25 and of 45 gray levels. Which threshold works best on the clear
   images and which on the cloudy ones? Why do they not coincide?

**Solution.**

```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"threshold {u}: clear {mae(pu, prueba & ~nubes):.2f} · "
          f"cloudy {mae(pu, prueba & nubes):.2f}")
```
```output
threshold 25: clear 1.84 · cloudy 28.97
threshold 35: clear 1.69 · cloudy 21.95
threshold 45: clear 2.88 · cloudy 16.22
```

A low threshold also picks up the asphalt noise and the edges of the clouds; a high one misses the cars whose tone is
close to the asphalt. On the clear images the optimum lies in between; with clouds, the higher the threshold, the less
cloud counts as a car. Tuning the threshold for the clouds makes the clear images worse: the problem is not the
parameter, it is that the method cannot tell a cloud from a car. That is why the mask (or a model that learns the
difference) is the solution, and not fine-tuning.

2. Measure the CNN's error on the **training set** (2018-2022) and compare it with the test error. Is there
   overfitting?

**Solution.**

```python
for nombre, m in [("training", entreno), ("test", prueba)]:
    print(f"{nombre:8s} clear {mae(cnn, m & ~nubes):.2f} · "
          f"cloudy {mae(cnn, m & nubes):.2f}")
```
```output
training clear 1.36 · cloudy 4.79
test     clear 1.33 · cloudy 4.69
```

The training and test errors are of the same order: with 3,585 parameters and about a thousand images, the model does
not have the capacity to memorize them. If the test error were much larger, you should suspect a change in the images
over time (another camera, different light) before a model that is too big.

3. Build the weather signal with one hop but without removing the mean of the same quarter (only the mean of all
   previous quarters). What happens to the correlation and why?

**Solution.**

```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"correlation: {P2['senal'].corr(P2['sorpresa_ventas']):.3f}")
```
```output
correlation: -0.002
```

Without removing seasonality, the signal is mostly “winter versus summer”: first-quarter heating degree days always
exceed the yearly mean. The consensus already knows the season (it is built into baseline sales), so that part does
not predict the surprise and the correlation drops. The anomaly has to be measured against what the market already
expects, just as card spending is measured against the consensus in lesson 6.7.5.

## Self-assessment

1. The pixel threshold counts cars with a mean absolute error of 1.7 on clear images and 22 on cloudy ones. What should you do before training a more complex model?

   - A. Nothing: the mean error over all images is acceptable
   - B. Detect the cloudy images and treat them separately (drop them or send them to a model that tolerates them): the error is concentrated in 11% of the images
   - C. Raise the threshold until the cloudy error goes down
   - D. Switch the images to color

   *Answer B: Detect the cloudy images and treat them separately (drop them or send them to a model that tolerates them): the error is concentrated in 11% of the images.* Clouds are bright patches that the threshold mistakes for cars. A simple cloud mask (local mean brightness) separates them without a single error in this data, and with it the baseline gives a signal with a correlation of 0.79 with the demand shock.

2. The lesson's CNN sums a density map instead of classifying the image. Why does that make sense for counting?

   - A. Because it is faster to train on a GPU
   - B. Because then it needs no labels
   - C. Because ViTs cannot count
   - D. Because each car contributes a similar local amount wherever it is: the convolution recognizes the pattern in any position and the sum turns the local detections into a number

   *Answer D: Because each car contributes a similar local amount wherever it is: the convolution recognizes the pattern in any position and the sum turns the local detections into a number.* It is the inductive bias of CNNs: the same filters across the whole image (translation equivariance). With 1,000 images of 64×64 and 3,585 parameters it is enough to bring the error down to 1.3 cars on the clear images from 2023-2025.

3. A Vision Transformer (Dosovitskiy et al., 2020) splits the image into patches and processes them with a transformer. When does it pay off against a small CNN?

   - A. Mainly when there is a lot of data or a pretrained model to fine-tune; with a few small images and a simple local pattern, the CNN has the right bias
   - B. Always, because it is more modern
   - C. Never on satellite images
   - D. Only with color images

   *Answer A: Mainly when there is a lot of data or a pretrained model to fine-tune; with a few small images and a simple local pattern, the CNN has the right bias.* The ViT paper shows strong results when the model is pretrained on large datasets and transferred. A minimal ViT for these images already has 142,465 parameters, 40 times the CNN, without the advantage of convolution.

4. Container ship arrivals at a port anticipate the sales surprise of the exporters that use it (correlation 0.42), but the signal propagated one hop further, to those exporters' suppliers, does not. What does this teach?

   - A. That graphs are useless for alternative data
   - B. That you have to propagate more hops
   - C. That each edge propagates only if there is an economic mechanism behind it: in this data the port moves the exporters' sales, but a customer's sales do not move its supplier's
   - D. That the correlation should be negative

   *Answer C: That each edge propagates only if there is an economic mechanism behind it: in this data the port moves the exporters' sales, but a customer's sales do not move its supplier's.* In the generator, an exporter's demand shock depends on its port (0.8·p); suppliers' sales do not depend on their customers'. A signal propagated through a graph is a hypothesis that has to be measured, hop by hop.

5. In level 5, 3.5% of a customer's weekly residual passes to its supplier the following week. With 44 customer-supplier edges between level 6 companies and one event per quarter, can it be measured with the reaction to earnings?

   - A. Yes, with any sample
   - B. No: the standard error of the slope is about eight times the effect; it would take hundreds of thousands of events. 5.7.4 measures it with 400 stocks and every week
   - C. Yes, if a GNN is used
   - D. No, because the effect is negative

   *Answer B: No: the standard error of the slope is about eight times the effect; it would take hundreds of thousands of events. 5.7.4 measures it with 400 stocks and every week.* It is a power calculation, done before any model. The lesson does it: with a supplier's weekly noise and the dispersion of the customer's signal, the standard error of the slope far exceeds 0.035.

## Key points

- A pixel-threshold count works on clear images (mean error 1.7 cars) and breaks with clouds (22): the baseline tells you where the problem is before you train anything.
- A 3,585-parameter CNN that sums a density map, trained on CPU with 2018-2022 and evaluated on 2023-2025, brings the error down to 1.3 cars on clear images and to about 5 with clouds. A ViT is the alternative when there is a lot of data or a pretrained model; for this, it is overkill.
- From count to signal: cloud mask, occupancy per usable space, calendar effects (weekends, December, lockdowns) and quarterly mean. Quarterly occupancy has a correlation of 0.84 with the demand shock using the true counts; the model gets close and the threshold without a mask stays at 0.51.
- A graph of exposures (port and exporter, region and utility) propagates signals that have a mechanism: correlations of 0.42 and 0.67 with the sales surprise. One hop further, from customer to supplier, carries nothing in the sales of this data.
- Before looking for an effect in a graph, check power: the level 5 customer-to-supplier return spillover exists, but with 44 edges and quarterly events it cannot be told apart from noise.

## Resources

### Documentation

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

### Books

- [Katona, Z., Painter, M., Patatoukas, P. N. and 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. and 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

### Data

- [Level 6 data: satellite-style parking lot images (Parquet)](https://pythonparatrading.com/curso/datos/nivel-6/satelite6_imagenes.parquet) · Download · satelite6_imagenes.parquet
- [Level 6 data: weather and port exposures (CSV)](https://pythonparatrading.com/curso/datos/nivel-6/alt6_exposiciones.csv) · Download · alt6_exposiciones.csv

Previous lesson: [6.7.5 · From alternative data to a trading signal](https://pythonparatrading.com/en/python-trading-course/level-6/from-alternative-data-to-trading-signals/)
