"""Sequence networks on the RAW yearly Landsat time series, frozen backbone + last-layer adaptation. python tools/nnlab/ts_nn.py [--smoke] [--only ] Step 1 tensor cache lab.CACHE/ts/{city}_T.npy [N, 42, 6] float32 DN, median of QA-valid observations per year (src/features.py make_flat_view(agg="median"): a year is kept only if every band has a valid median and Blue <= BLUE_MAX), NaN where no kept observation; rows aligned with the processed feature table. NOTE: the raw data covers 1984-2025 (42 years), as the feature pipeline uses. Step 2 inputs 6 SR bands + NDVI, NDBI, MNDWI + observed mask = 10 ch x 42 steps, linear interpolation in time (edges nearest, as to_year_matrices), indices from the interpolated SR bands; per-city standardisation per channel. Variant "ry" subtracts the city's per-year median over pixels (9 continuous channels) first. Step 3 backbones tcnn, gru, tcnn_dann; class-balanced CE, AdamW(1e-3, wd 1e-4), batch 256, constant LR, brightness/offset augmentation on the 6 band channels. CPU budget: per (backbone, variant) ONE run on the lab.madrid_split source part, checkpoints at EPOCH_GRID = (5, 10, 15), the checkpoint with the best pseudo-target macro F1 IS the backbone (no retrain on all Madrid); lam for "ft" chosen on the same model. 1 seed. Default combos: tcnn_st, tcnn_ry, tcnn_dann_st; --extended adds tcnn_dann_ry, gru_st, gru_ry (~2.5 h more). Step 4 heads zs (n=0), ft (lab.finetune_last_layer), joint (lab.joint_head); probabilities averaged over seeds. Methods: ts_{backbone}_{variant}_{head}. """ from __future__ import annotations import argparse import copy import json import sys import time import warnings from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import lab # noqa: E402 import numpy as np # noqa: E402 import config as C # noqa: E402 (src/config.py, put on sys.path by lab) T0 = time.time() YEARS = np.arange(1984, 2026) # 42 years present in both raw files NY = len(YEARS) BANDS = list(C.BASE_BANDS) TS_DIR = lab.CACHE / "ts" NN_DIR = lab.CACHE / "ts_nn" for _d in (TS_DIR, NN_DIR): _d.mkdir(parents=True, exist_ok=True) HEADS = ("zs", "ft", "joint") EPOCH_GRID = (5, 10, 15) # spec: (10, 20, 30) - halved to fit the CPU budget LAM_GRID = (0.1, 1.0, 10.0, 100.0) SEEDS = (0,) # spec: 2 seeds - cut to 1 to fit the CPU budget DEFAULT_COMBOS = (("tcnn", "st"), ("tcnn", "ry"), ("tcnn_dann", "st")) EXTENDED_COMBOS = (("tcnn_dann", "ry"), ("gru", "st"), ("gru", "ry")) BATCH = 256 LR, WD = 1e-3, 1e-4 SHOT_WEIGHT = 30.0 def _rss(): try: import psutil mi = psutil.Process().memory_info() return f" rss={mi.rss / 2**30:.2f}G peak={getattr(mi, 'peak_wset', mi.rss) / 2**30:.2f}G" except Exception: return "" def log(msg): print(f"[{time.time() - T0:7.1f}s] {msg}{_rss()}", flush=True) # --------------------------------------------------------------------------- # # Step 1: tensor cache # --------------------------------------------------------------------------- # RAW = {"madrid": lab.ROOT / "data" / "raw" / "madrid_train.parquet", "amsterdam": lab.ROOT / "data" / "raw" / "amsterdam_data.parquet"} def build_tensor(city: str, P: np.ndarray) -> np.ndarray: """P: (N, 2) px_key, py_key of the feature table (row order).""" import pyarrow.parquet as pq path = TS_DIR / f"{city}_T.npy" if path.exists(): T = np.load(path) assert T.shape == (len(P), NY, 6) return T t0 = time.time() cols = ["px_key", "py_key", "year"] + [f"{b}_{k}" for k in (1, 2, 3) for b in BANDS] \ + [f"qa_valid_{k}" for k in (1, 2, 3)] # no label columns are read key_f = P[:, 0].astype(np.int64) * 10_000_000 + P[:, 1].astype(np.int64) assert P[:, 1].min() >= 0 and P[:, 1].max() < 10_000_000 order = np.argsort(key_f) ks = key_f[order] assert (np.diff(ks) > 0).all(), "duplicate pixel keys in feature table" T = np.full((len(P), NY, 6), np.nan, np.float32) seen = np.zeros((len(P), NY), bool) f = pq.ParquetFile(RAW[city]) n_rows = n_kept = 0 for batch in f.iter_batches(batch_size=200_000, columns=cols): tb = {c: batch.column(c) for c in cols} n = batch.num_rows n_rows += n oks = [np.asarray(tb[f"qa_valid_{k}"].fill_null(False).to_numpy(zero_copy_only=False), bool) for k in (1, 2, 3)] med = np.empty((n, 6), np.float32) with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) for j, b in enumerate(BANDS): st = [] for k in (1, 2, 3): v = np.asarray(tb[f"{b}_{k}"].to_numpy(zero_copy_only=False), np.float32).copy() v[~oks[k - 1]] = np.nan st.append(v) med[:, j] = np.nanmedian(np.stack(st), axis=0) good = ~np.isnan(med).any(1) good &= ~(med[:, 0] > C.BLUE_MAX) # make_flat_view: keep Blue <= BLUE_MAX px = np.asarray(tb["px_key"].to_numpy(zero_copy_only=False), np.int64) py = np.asarray(tb["py_key"].to_numpy(zero_copy_only=False), np.int64) yr = np.asarray(tb["year"].to_numpy(zero_copy_only=False), np.int64) key = px * 10_000_000 + py pos = np.searchsorted(ks, key).clip(0, len(ks) - 1) hit = (ks[pos] == key) & good assert ((yr[hit] >= YEARS[0]) & (yr[hit] <= YEARS[-1])).all() r, c = order[pos[hit]], yr[hit] - YEARS[0] assert not seen[r, c].any(), "duplicate pixel-year" seen[r, c] = True T[r, c] = med[hit] n_kept += int(hit.sum()) del batch, tb, med, st found = seen.any(1) assert found.all(), f"{(~found).sum()} feature-table pixels have no observation" np.save(path, T) log(f"tensor {city}: {n_rows:,} raw rows, {n_kept:,} pixel-years kept, " f"shape {T.shape}, observed {seen.mean():.3f}, {time.time() - t0:.0f}s") return T # --------------------------------------------------------------------------- # # Step 2: inputs # --------------------------------------------------------------------------- # def interp_time(T: np.ndarray) -> np.ndarray: """Linear interpolation over the year axis, nearest at the edges (per pixel, band).""" N, Y, B = T.shape out = T.copy() x = np.arange(Y, dtype=np.float32) for b in range(B): M = out[:, :, b] obs = ~np.isnan(M) # index of previous / next observed year for every cell idx = np.where(obs, np.arange(Y)[None, :], -1) prev = np.maximum.accumulate(idx, axis=1) idx2 = np.where(obs, np.arange(Y)[None, :], Y) nxt = np.minimum.accumulate(idx2[:, ::-1], axis=1)[:, ::-1] rows = np.arange(N)[:, None] pv = np.where(prev >= 0, M[rows, prev.clip(0)], np.nan) nv = np.where(nxt < Y, M[rows, nxt.clip(None, Y - 1)], np.nan) w = np.where((prev >= 0) & (nxt < Y) & (nxt > prev), (x[None, :] - prev) / np.maximum(nxt - prev, 1), 0.0).astype(np.float32) lin = pv + w * (nv - pv) fill = np.where(np.isnan(pv), nv, np.where(np.isnan(nv), pv, lin)) out[:, :, b] = np.where(obs, M, fill) assert not np.isnan(out).any() return out def make_inputs(T: np.ndarray, variant: str) -> np.ndarray: """[N, Y, 6] DN -> [N, 10, Y] float32, standardised with this city's statistics.""" obs = (~np.isnan(T).any(2)).astype(np.float32) # year observed S = interp_time(T) * np.float32(C.SR_SCALE) + np.float32(C.SR_OFFSET) B, G, R, N_, S1 = S[..., 0], S[..., 1], S[..., 2], S[..., 3], S[..., 4] eps = np.float32(1e-6) ndvi = (N_ - R) / (N_ + R + eps) ndbi = (S1 - N_) / (S1 + N_ + eps) mndwi = (G - S1) / (G + S1 + eps) X = np.concatenate([S, ndvi[..., None], ndbi[..., None], mndwi[..., None], obs[..., None]], 2) del S X = np.clip(X, -5, 5) # index denominators near 0 on bad pixels if variant == "ry": X[..., :9] -= np.median(X[..., :9], axis=0, keepdims=True) mu = X.reshape(-1, 10).mean(0, dtype=np.float64) sd = X.reshape(-1, 10).std(0, dtype=np.float64) sd[sd < 1e-8] = 1 X = ((X - mu.astype(np.float32)) / sd.astype(np.float32)).astype(np.float32) return np.ascontiguousarray(X.transpose(0, 2, 1)) # [N, C, Y] # --------------------------------------------------------------------------- # # Step 3: backbones # --------------------------------------------------------------------------- # def build_net(kind: str): import torch nn = torch.nn class GradReverse(torch.autograd.Function): @staticmethod def forward(ctx, x, lam): ctx.lam = lam return x.view_as(x) @staticmethod def backward(ctx, g): return -ctx.lam * g, None class TempCNN(nn.Module): def __init__(self, c_in=10, T=NY, dann=False): super().__init__() layers, c = [], c_in for _ in range(3): layers += [nn.Conv1d(c, 64, 5, padding=2), nn.BatchNorm1d(64), nn.ReLU(), nn.Dropout(0.2)] c = 64 self.conv = nn.Sequential(*layers) self.emb = nn.Sequential(nn.Flatten(), nn.Linear(64 * T, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU()) self.head = nn.Linear(64, 4) self.dom = nn.Sequential(nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 1)) if dann else None def embed(self, x): return self.emb(self.conv(x)) def forward(self, x, lam=0.0): z = self.embed(x) dom = self.dom(GradReverse.apply(z, lam)) if self.dom is not None else None return self.head(z), dom class GRUNet(nn.Module): def __init__(self, c_in=10): super().__init__() self.gru = nn.GRU(c_in, 64, num_layers=2, bidirectional=True, batch_first=True) self.emb = nn.Sequential(nn.Linear(128, 64), nn.ReLU()) self.head = nn.Linear(64, 4) self.dom = None def embed(self, x): _, h = self.gru(x.transpose(1, 2)) # h: [4, B, 64] return self.emb(torch.cat([h[-2], h[-1]], 1)) def forward(self, x, lam=0.0): return self.head(self.embed(x)), None if kind == "tcnn": return TempCNN() if kind == "tcnn_dann": return TempCNN(dann=True) if kind == "gru": return GRUNet() raise ValueError(kind) def augment(torch, xb, gen): """Per-sample brightness scale U(0.9,1.1) and offset N(0,0.1) on the 6 band channels.""" B = xb.shape[0] s = torch.empty(B, 1, 1).uniform_(0.9, 1.1, generator=gen) o = torch.randn(B, 1, 1, generator=gen) * 0.1 xb = xb.clone() xb[:, :6] = xb[:, :6] * s + o return xb def train(kind, Xs, ys, Xt, epochs, seed, eval_fn=None, eval_at=(), max_rows=None, sched_epochs=None): """Returns (net, {epoch: eval result}, {epoch: state_dict copy}, sec/epoch). sched_epochs: length of the DANN lambda schedule (default `epochs`); a retrain to a selected checkpoint passes the selection run's length so it reproduces that model.""" sched = sched_epochs or epochs torch = lab.torch_setup(seed) gen = torch.Generator().manual_seed(seed) net = build_net(kind) opt = torch.optim.AdamW(net.parameters(), lr=LR, weight_decay=WD) counts = np.bincount(ys, minlength=5)[1:] w = torch.tensor(len(ys) / (4 * np.maximum(counts, 1)), dtype=torch.float32) ce, bce = torch.nn.CrossEntropyLoss(weight=w), torch.nn.BCEWithLogitsLoss() S, Y = torch.from_numpy(Xs), torch.from_numpy(ys - 1).long() Tt = torch.from_numpy(Xt) if Xt is not None else None dann = kind.endswith("_dann") n = len(S) if max_rows is None else min(len(S), max_rows) steps = int(np.ceil(n / BATCH)) evals, states, t_ep = {}, {}, [] for ep in range(1, epochs + 1): te = time.time() net.train() perm = torch.randperm(len(S), generator=gen)[:n] for k in range(steps): i = perm[k * BATCH:(k + 1) * BATCH] if len(i) < 2: continue xb = augment(torch, S[i], gen) if dann: p = ((ep - 1) * steps + k) / (sched * steps) lam = float(2 / (1 + np.exp(-10 * p)) - 1) logit, dom_s = net(xb, lam) j = torch.randint(0, len(Tt), (len(i),), generator=gen) _, dom_t = net(augment(torch, Tt[j], gen), lam) loss = ce(logit, Y[i]) + bce(dom_s.squeeze(1), torch.zeros(len(i))) + bce(dom_t.squeeze(1), torch.ones(len(i))) else: logit, _ = net(xb, 0.0) loss = ce(logit, Y[i]) opt.zero_grad() loss.backward() opt.step() t_ep.append((time.time() - te) * len(S) / n) # extrapolated to a full epoch if ep in eval_at: states[ep] = copy.deepcopy(net.state_dict()) if eval_fn is not None: evals[ep] = eval_fn(net) log(f" {kind} seed{seed} epoch {ep}/{epochs} {t_ep[-1]:.1f}s/full-epoch" + (f" eval={evals[ep]:.4f}" if ep in evals else "")) net.eval() return net, evals, states, float(np.mean(t_ep)) def embed(net, X, bs=2048): import torch net.eval() out = [] with torch.no_grad(): for k in range(0, len(X), bs): out.append(net.embed(torch.from_numpy(X[k:k + bs])).numpy()) return np.concatenate(out).astype(np.float32) def last_layer(net): return (net.head.weight.detach().numpy().astype(np.float64).copy(), net.head.bias.detach().numpy().astype(np.float64).copy()) def proba(Z, Wt, b): return lab.softmax(Z.astype(np.float64) @ Wt.T + b) def mf1(y, P): return lab.W.macro_f1(y, lab.CLASSES[P.argmax(1)]) # --------------------------------------------------------------------------- # # Backbone per (backbone, variant): one run on the Madrid spatial source split, # checkpoints at the epoch grid, pick the epoch on the pseudo-target, keep that # checkpoint (budget: no retrain on all Madrid), then choose lam on the same model. # --------------------------------------------------------------------------- # def simulate_lam(Zt, yt, W0, b0, reps): lam_score = {} for lam in LAM_GRID: sc = [] for n in (5, 50): for rep in range(reps): rng = np.random.default_rng(7_000 + 1000 * n + rep) sh = np.concatenate([rng.choice(np.flatnonzero(yt == c), min(n, int((yt == c).sum())), replace=False) for c in lab.CLASSES]) rest = np.setdiff1d(np.arange(len(yt)), sh) Wf, bf = lab.finetune_last_layer(W0, b0, Zt[sh], yt[sh], lam) sc.append(mf1(yt[rest], proba(Zt[rest], Wf, bf))) lam_score[lam] = float(np.mean(sc)) return lam_score def backbone(kind, var, d, Xm, Xa, cfg): """Returns (selection dict, [(Zm, Za, W0, b0) per seed]).""" tag = f"{kind}_{var}{cfg['tag']}" sel_path = NN_DIR / f"select_{tag}.json" src, tgt = lab.madrid_split(d["pm"]) ym = d["ym"] grid = cfg["epoch_grid"] embs = [] sel = json.loads(sel_path.read_text()) if sel_path.exists() else None for seed in cfg["seeds"]: path = NN_DIR / f"{tag}_s{seed}.npz" if path.exists() and sel is not None: z = np.load(path) embs.append((z["Zm"], z["Za"], z["W0"], z["b0"])) continue t0 = time.time() if sel is None: def ev(net): Wt, b = last_layer(net) return mf1(ym[tgt], proba(embed(net, Xm[tgt]), Wt, b)) net, evals, states, sec = train(kind, Xm[src], ym[src], Xa, max(grid), seed, eval_fn=ev, eval_at=grid, max_rows=cfg["max_rows"]) E = max(grid, key=lambda e: (evals[e], -e)) # ties -> fewer epochs net.load_state_dict(states[E]) net.eval() W0, b0 = last_layer(net) lam_score = simulate_lam(embed(net, Xm[tgt]), ym[tgt], W0, b0, cfg["lam_reps"]) lam = max(LAM_GRID, key=lambda l: (lam_score[l], l)) # ties -> stronger pull sel = {"backbone": kind, "variant": var, "seed_used": int(seed), "split": "lab.madrid_split(pm) defaults (frac .25, axis 0, high)", "epoch_grid": list(grid), "pseudo_target_f1_by_epoch": {str(k): float(v) for k, v in evals.items()}, "epochs": int(E), "lam_grid": list(LAM_GRID), "pseudo_target_ft_f1_by_lam": {str(k): v for k, v in lam_score.items()}, "lam": float(lam), "lam_sim": f"n per class in (5, 50) x {cfg['lam_reps']} reps on the pseudo-target, " "argmax macro F1 on the rest", "sec_per_full_epoch": sec, "n_src": int(len(src)), "n_pseudo_target": int(len(tgt))} sel_path.write_text(json.dumps(sel, indent=1)) scores = {k: round(v, 4) for k, v in lam_score.items()} log(f"select {tag}: epochs={E} {sel['pseudo_target_f1_by_epoch']} lam={lam} {scores}") else: net, _, _, sec = train(kind, Xm[src], ym[src], Xa, sel["epochs"], seed, eval_at=(sel["epochs"],), max_rows=cfg["max_rows"], sched_epochs=max(sel["epoch_grid"])) Zm, Za = embed(net, Xm), embed(net, Xa) W0, b0 = last_layer(net) np.savez(path, Zm=Zm, Za=Za, W0=W0, b0=b0) embs.append((Zm, Za, W0, b0)) log(f"backbone {tag} seed{seed}: {sec:.1f}s/full-epoch, {time.time() - t0:.0f}s") return sel, embs def gru_check(): """Smoke-only: the GRU network trains and embeds (the rest of its path is shared).""" rng = np.random.default_rng(0) X = rng.standard_normal((1024, 10, NY)).astype(np.float32) y = rng.integers(1, 5, 1024) net, _, _, sec = train("gru", X, y, None, 1, 0, eval_at=(1,)) Z = embed(net, X[:300]) assert Z.shape == (300, 64) and np.isfinite(Z).all() log(f"gru check ok: ~{sec * 61932 / 1024:.0f}s per Madrid-source epoch (extrapolated)") # --------------------------------------------------------------------------- # # main # --------------------------------------------------------------------------- # def main(): ap = argparse.ArgumentParser() ap.add_argument("--smoke", action="store_true") ap.add_argument("--only", default="") ap.add_argument("--extended", action="store_true", help="also tcnn_dann_ry, gru_st, gru_ry (beyond the 70 min budget)") a = ap.parse_args() combos = list(DEFAULT_COMBOS) + (list(EXTENDED_COMBOS) if a.extended else []) if a.smoke: cfg = dict(tag="_SMOKE", suffix="_SMOKE", epoch_grid=(1, 2), max_rows=4096, lam_reps=1, seeds=(0,), draws=[(5, 0), (200, 0)]) else: cfg = dict(tag="", suffix="", epoch_grid=EPOCH_GRID, max_rows=None, lam_reps=5, seeds=SEEDS, draws=list(lab.draws())) def mname(bb, var, head): return f"ts_{bb}_{var}_{head}{cfg['suffix']}" todo = {(bb, var): [h for h in HEADS if a.only in mname(bb, var, h)] for bb, var in combos} todo = {k: v for k, v in todo.items() if v} log(f"methods: {[mname(bb, var, h) for (bb, var), hs in todo.items() for h in hs]}") d = lab.load(official60=False) Tm = build_tensor("madrid", d["pm"]) Ta = build_tensor("amsterdam", d["pa"]) if a.smoke: gru_check() for (bb, var), heads in todo.items(): need = [h for h in heads for (n, rep) in ([(0, 0)] if h == "zs" else cfg["draws"]) if not lab.have_pred(mname(bb, var, h), n, rep)] if not need: log(f"{bb}_{var}: all predictions present, skip") continue Xm, Xa = make_inputs(Tm, var), make_inputs(Ta, var) log(f"inputs {var}: Madrid {Xm.shape} Amsterdam {Xa.shape}") sel, embs = backbone(bb, var, d, Xm, Xa, cfg) del Xm, Xa E, lam = sel["epochs"], sel["lam"] meta = {"family": "ts_nn", "backbone": bb, "variant": var, "seeds": list(cfg["seeds"]), "epochs": E, "lam": lam, "shot_weight": SHOT_WEIGHT, "selection": sel, "backbone_training_rows": "Madrid spatial source split only (lab.madrid_split)", "joint_input": "embeddings standardised with Madrid embedding mean/std", "input": f"10 ch x {NY} years (1984-2025), median of QA-valid obs, Blue<=BLUE_MAX", "augment": "band scale U(0.9,1.1), offset N(0,0.1)", "train": f"AdamW lr={LR} wd={WD} batch={BATCH} class-balanced CE, constant LR", "smoke": bool(a.smoke)} if "zs" in heads and not lab.have_pred(mname(bb, var, "zs"), 0, 0): P = np.mean([proba(Za, W0, b0) for _, Za, W0, b0 in embs], 0) lab.save_pred(mname(bb, var, "zs"), 0, 0, P, {**meta, "head": "zs"}) log(f"{mname(bb, var, 'zs')} saved") scaled = [] for Zm, Za, _, _ in embs: mu, sd = Zm.mean(0), Zm.std(0) + 1e-6 scaled.append(((Zm - mu) / sd, (Za - mu) / sd)) for n, rep in cfg["draws"]: hs = [h for h in heads if h != "zs" and not lab.have_pred(mname(bb, var, h), n, rep)] if not hs: continue t0 = time.time() shot = lab.shots_for(d, n, rep) ys = lab.shot_labels(d, shot) for h in hs: Ps = [] for (Zm, Za, W0, b0), (Sm, Sa) in zip(embs, scaled): if h == "ft": Wf, bf = lab.finetune_last_layer(W0, b0, Za[shot], ys, lam) Ps.append(proba(Za, Wf, bf)) else: with warnings.catch_warnings(): warnings.simplefilter("ignore") m = lab.joint_head(Sm, d["ym"], Sa[shot], ys, shot_weight=SHOT_WEIGHT) Ps.append(m.predict_proba(Sa)) lab.save_pred(mname(bb, var, h), n, rep, np.mean(Ps, 0), {**meta, "head": h}) log(f"{bb}_{var} n={n} rep={rep} heads={hs} {time.time() - t0:.1f}s") log("done") if __name__ == "__main__": main()