""" nnlab - shared harness for the overnight model search (2026-11-11). Every model family writes, for each protocol draw (n, rep), ONE array of class probabilities over ALL 25,992 Amsterdam pixels (row order = the feature table): lab.save_pred("my_method", n, rep, P) # P: (25992, 4), columns = class 1..4 and nothing else. Scoring is done here, identically for everybody: python tools/nnlab/lab.py score my_method [other_method ...] python tools/nnlab/lab.py board RULES every module must keep (reviewers check these): * Amsterdam LABELS may be read only through lab.shot_labels(d, idx) for the indices returned by lab.shots_for(d, n, rep). Never touch d["ya"] directly. * Unlabelled Amsterdam features / coordinates may be used freely (transductive). * Hyper-parameters are fixed in advance or chosen on Madrid (lab.madrid_split gives a spatial pseudo-target). Never chosen by looking at an Amsterdam score. * The held-out organisers' data is never opened. The draws are the protocol's: seed 42, rng(42 + 1000 n + rep), n in (5, 25, 50, 100, 200). Shots are removed from the scored set. """ from __future__ import annotations import json import os import sys import time from pathlib import Path HERE = Path(__file__).resolve().parent ROOT = HERE.parent.parent # ROOT itself holds winners.py when these scripts run from the published bundle for _p in (ROOT, HERE, ROOT / "src", ROOT / "tools"): if str(_p) not in sys.path: sys.path.insert(0, str(_p)) THREADS = int(os.environ.get("NNLAB_THREADS", "3")) os.environ.setdefault("OMP_NUM_THREADS", str(THREADS)) os.environ.setdefault("MKL_NUM_THREADS", str(THREADS)) import numpy as np # noqa: E402 import pandas as pd # noqa: E402 import winners as W # noqa: E402 (verified port of the protocol + rule) PROC = ROOT / "data" / "processed" OUT = ROOT / "reports" / "nnlab" PRED = OUT / "preds" SCORES = OUT / "scores" CACHE = OUT / "cache" # embeddings, time-series tensors, ... for _d in (PRED, SCORES, CACHE): _d.mkdir(parents=True, exist_ok=True) SEED = 42 N_LIST = (5, 25, 50, 100, 200) REPS = int(os.environ.get("NNLAB_REPS", "5")) # reps 0..4 by default; NNLAB_REPS=12 adds the confirmation draws CLASSES = np.array([1, 2, 3, 4]) PID = ["px_key", "py_key"] BLOCK = 67 # 67 px = 2 km SMOOTH_R, SMOOTH_ALPHA = 6, 0.5 # teammate's choice, made on Madrid def torch_setup(seed: int = 0): import torch torch.set_num_threads(THREADS) torch.manual_seed(seed) np.random.seed(seed) return torch # --------------------------------------------------------------------------- # # Data # --------------------------------------------------------------------------- # def load(official60: bool = True) -> dict: """Madrid + Amsterdam tables. Xm/Xa: our 393 features (float32, raw scale); Xm60/Xa60: the organisers' official 60 (first valid observation per year); pm/pa: integer pixel coordinates (30 m units); ym: Madrid labels; prior: Madrid class shares. Amsterdam labels are kept under a private key.""" feats = W.feature_names() mad = pd.read_parquet(PROC / "madrid_features.parquet") ams = pd.read_parquet(PROC / "amsterdam_features.parquet") d = dict( feats=feats, Xm=mad[feats].to_numpy(np.float32), Xa=ams[feats].to_numpy(np.float32), ym=mad["age_class"].to_numpy().astype(int), pm=mad[PID].to_numpy().astype(np.int64), pa=ams[PID].to_numpy().astype(np.int64), _ya=ams["age_class"].to_numpy().astype(int), ) d["prior"] = np.array([(d["ym"] == c).mean() for c in CLASSES]) if official60: import config as C f60 = list(C.FEATURE_COLS_60) for city, ref, key in (("madrid", mad, "Xm60"), ("amsterdam", ams, "Xa60")): o = pd.read_parquet(PROC / f"{city}_official60_first.parquet") o = ref[PID].merge(o, on=PID, how="left") assert len(o) == len(ref) and o[f60].notna().all().all() d[key] = o[f60].to_numpy(np.float32) d["feats60"] = f60 return d def shots_for(d: dict, n: int, rep: int) -> np.ndarray: """Row indices of the n-per-class labelled Amsterdam pixels of draw (n, rep).""" return W.shots_for(d["_ya"], n, rep) def shot_labels(d: dict, idx: np.ndarray) -> np.ndarray: """Labels (1..4) of shot rows. The ONLY sanctioned way to read Amsterdam labels.""" return d["_ya"][np.asarray(idx)] def draws(reps: int = REPS, n_list=N_LIST): for n in n_list: for r in range(reps): yield n, r def balanced_class_weights(y) -> np.ndarray: return W.balanced_class_weights(y) # per-city input transforms (label-free) ------------------------------------- # def robustz(X: np.ndarray, clip: float | None = 10.0) -> np.ndarray: """Median/IQR scaling with this matrix's own statistics (call once per city).""" X = np.asarray(X, np.float64) med = np.median(X, 0) q75, q25 = np.percentile(X, [75, 25], 0) iqr = np.where((q75 - q25) > 1e-12, q75 - q25, 1.0) Z = (X - med) / iqr if clip: Z = np.clip(Z, -clip, clip) return Z.astype(np.float32) def standardise(X: np.ndarray) -> np.ndarray: X = np.asarray(X, np.float64) sd = X.std(0) sd[sd == 0] = 1 return ((X - X.mean(0)) / sd).astype(np.float32) def rankgauss(X: np.ndarray) -> np.ndarray: """Per-column rank -> standard normal quantile, within this matrix (one city).""" from scipy.special import ndtri from scipy.stats import rankdata X = np.asarray(X, np.float64) R = rankdata(X, axis=0, method="average") return ndtri((R - 0.5) / len(X)).astype(np.float32) # Madrid-only model selection ------------------------------------------------- # def block_ids(p: np.ndarray, block: int = BLOCK) -> np.ndarray: b = p // block return (b[:, 0] * 100_003 + b[:, 1]).astype(np.int64) def madrid_split(pm: np.ndarray, frac: float = 0.25, axis: int = 0, high: bool = True): """A spatial pseudo-target inside Madrid: the `frac` of 2 km blocks furthest along one axis. Returns (source_idx, pseudo_target_idx). Use it to choose epochs / regularisation without touching Amsterdam labels.""" b = pm[:, axis] // BLOCK cut = np.quantile(b, 1 - frac) if high else np.quantile(b, frac) tgt = b > cut if high else b < cut return np.flatnonzero(~tgt), np.flatnonzero(tgt) # --------------------------------------------------------------------------- # # Heads on a frozen backbone # --------------------------------------------------------------------------- # def softmax(L: np.ndarray) -> np.ndarray: L = L - L.max(1, keepdims=True) E = np.exp(L) return E / E.sum(1, keepdims=True) def finetune_last_layer(W0, b0, Zs, ys, lam: float = 1.0, class_balanced: bool = True, max_iter: int = 200): """True last-layer fine-tuning: start from the Madrid-trained last layer (W0: (4, k), b0: (4,)) and minimise, on the labelled shots only, sum_i w_i CE(softmax(W z_i + b), y_i) + lam * (||W - W0||^2 + ||b - b0||^2). The CE is a SUM, so the pull toward the source weights fades as n grows. Returns (W, b). Probabilities: softmax(Z @ W.T + b).""" torch = torch_setup(0) Z = torch.tensor(np.asarray(Zs), dtype=torch.float64) y = torch.tensor(np.asarray(ys) - 1, dtype=torch.long) w = torch.ones(len(y), dtype=torch.float64) if class_balanced: w = torch.tensor(balanced_class_weights(np.asarray(ys))[np.asarray(ys) - 1], dtype=torch.float64) W0t = torch.tensor(np.asarray(W0), dtype=torch.float64) b0t = torch.tensor(np.asarray(b0), dtype=torch.float64) Wt, bt = W0t.clone().requires_grad_(True), b0t.clone().requires_grad_(True) opt = torch.optim.LBFGS([Wt, bt], max_iter=max_iter, line_search_fn="strong_wolfe") def closure(): opt.zero_grad() ce = torch.nn.functional.cross_entropy(Z @ Wt.T + bt, y, reduction="none") loss = (w * ce).sum() + lam * (((Wt - W0t) ** 2).sum() + ((bt - b0t) ** 2).sum()) loss.backward() return loss opt.step(closure) return Wt.detach().numpy(), bt.detach().numpy() def joint_head(Zm, ym, Zs, ys, shot_weight: float = 30.0, C: float = 1.0, max_iter: int = 400): """Last layer re-fitted on frozen embeddings of Madrid + shots (shots weighted `shot_weight`x, classes balanced) - the frozen-backbone analogue of the retrained LightGBM. Returns a fitted sklearn LogisticRegression (predict_proba columns = classes 1..4).""" from sklearn.linear_model import LogisticRegression X = np.vstack([Zm, Zs]) y = np.r_[np.asarray(ym), np.asarray(ys)] w = np.r_[np.ones(len(ym)), np.full(len(ys), shot_weight)] w = w * balanced_class_weights(y)[y - 1] return LogisticRegression(C=C, max_iter=max_iter).fit(X, y, sample_weight=w) # --------------------------------------------------------------------------- # # Prediction store # --------------------------------------------------------------------------- # def pred_path(method: str, n: int, rep: int) -> Path: return PRED / method / f"n{n}_r{rep}.npy" def have_pred(method: str, n: int, rep: int) -> bool: return pred_path(method, n, rep).exists() def save_pred(method: str, n: int, rep: int, P: np.ndarray, meta: dict | None = None): """P: (25992, 4) probabilities for ALL Amsterdam pixels, feature-table row order. A method that ignores the shots saves once with n=0, rep=0.""" P = np.asarray(P, np.float32) assert P.shape == (25992, 4) and np.isfinite(P).all(), P.shape p = pred_path(method, n, rep) p.parent.mkdir(parents=True, exist_ok=True) np.save(p, P) if meta is not None: (p.parent / "meta.json").write_text(json.dumps(meta, indent=1, default=str)) def load_pred(method: str, n: int, rep: int) -> np.ndarray: p = pred_path(method, n, rep) if not p.exists(): p = pred_path(method, 0, 0) # shot-independent method return np.load(p).astype(np.float64) # --------------------------------------------------------------------------- # # Scoring (the only place Amsterdam labels are compared with predictions) # --------------------------------------------------------------------------- # _ADJ = {} def adjacency(coords: np.ndarray, r: float = SMOOTH_R): """Sparse 0/1 matrix: pixels within r (Euclidean, pixel units), self included.""" from scipy.sparse import coo_matrix, identity from scipy.spatial import cKDTree key = (coords.shape[0], float(r), int(coords[:, 0].sum()), int(coords[:, 1].sum())) if key not in _ADJ: pr = cKDTree(coords.astype(float)).query_pairs(r, output_type="ndarray") n = len(coords) A = coo_matrix((np.ones(len(pr)), (pr[:, 0], pr[:, 1])), shape=(n, n)).tocsr() _ADJ[key] = (A + A.T + identity(n, format="csr")).tocsr() return _ADJ[key] def smooth(P: np.ndarray, coords: np.ndarray, r: float = SMOOTH_R, alpha: float = SMOOTH_ALPHA, keep: np.ndarray | None = None) -> np.ndarray: """Teammate's neighbour smoothing: alpha * own + (1 - alpha) * mean over pixels within r (self included). With `keep`, only those rows take part (P must then already be P_all[keep]) - shots are never neighbours.""" A = adjacency(coords, r) if keep is not None: A = A[keep][:, keep] deg = np.asarray(A.sum(1)).ravel() return alpha * P + (1 - alpha) * (A @ P) / deg[:, None] def half_A(pa: np.ndarray) -> np.ndarray: """Checkerboard of 2 km blocks. Half A is for comparing variants; half B is looked at only to confirm the final picks.""" b = pa // BLOCK return ((b[:, 0] + b[:, 1]) % 2) == 0 def score_method(d: dict, method: str, reps: int = REPS, n_list=N_LIST, verbose: bool = True) -> dict: ya, pa, prior = d["_ya"], d["pa"], d["prior"] inA = half_A(pa) rows = [] for n, rep in draws(reps, n_list): if not (have_pred(method, n, rep) or have_pred(method, 0, 0)): continue P_all = load_pred(method, n, rep) shot = shots_for(d, n, rep) keep = np.setdiff1d(np.arange(len(ya)), shot) P, y = P_all[keep], ya[keep] lab = { "argmax": CLASSES[P.argmax(1)], "rule": W.decide(P, prior), "rule_smooth": W.decide(smooth(P, pa, keep=keep), prior), } row = {"n": n, "rep": rep} for k, l in lab.items(): row[k] = W.macro_f1(y, l) row[k + "_A"] = W.macro_f1(y[inA[keep]], l[inA[keep]]) row[k + "_B"] = W.macro_f1(y[~inA[keep]], l[~inA[keep]]) rows.append(row) if not rows: return {} df = pd.DataFrame(rows) summ = df.groupby("n").mean(numeric_only=True).drop(columns="rep") std = df.groupby("n").std(numeric_only=True).drop(columns="rep") out = {"method": method, "draws": rows, "n_draws": int(len(rows)), "mean": {c: {int(n): float(v) for n, v in summ[c].items()} for c in summ.columns}, "std": {c: {int(n): float(v) for n, v in std[c].items()} for c in std.columns}} (SCORES / f"{method}.json").write_text(json.dumps(out, indent=1)) if verbose: for k in ("argmax", "rule", "rule_smooth"): m = summ[k] print(f"{method:<34s} {k:<12s} " + " ".join(f"n{n}={m[n]:.4f}" for n in m.index) + f" avg={m.mean():.4f} peak={m.max():.4f} ({len(rows)} draws)", flush=True) return out def board(metric_suffix: str = "") -> pd.DataFrame: """All scored methods, one row per (method, decision). suffix '' / '_A' / '_B'.""" rows = [] for f in sorted(SCORES.glob("*.json")): s = json.loads(f.read_text()) for k in ("argmax", "rule", "rule_smooth"): m = s["mean"].get(k + metric_suffix) if not m: continue r = {"method": s["method"], "decision": k, **{f"n{n}": m.get(str(n), np.nan) for n in N_LIST}} v = [r[f"n{n}"] for n in N_LIST] r["avg"], r["peak"], r["draws"] = float(np.nanmean(v)), float(np.nanmax(v)), s["n_draws"] rows.append(r) df = pd.DataFrame(rows).sort_values("avg", ascending=False) df.to_csv(OUT / f"leaderboard{metric_suffix}.csv", index=False) return df if __name__ == "__main__": cmd = sys.argv[1] if len(sys.argv) > 1 else "board" if cmd == "score": d = load(official60=False) names = sys.argv[2:] or sorted(p.name for p in PRED.iterdir() if p.is_dir()) t0 = time.time() for m in names: score_method(d, m) print(f"scored {len(names)} methods in {time.time() - t0:.0f}s") else: suffix = sys.argv[2] if len(sys.argv) > 2 else "" pd.set_option("display.width", 250) print(board(suffix).round(4).to_string(index=False))