Download tools/compare_teammate.py from Karar-git/hack4dev-building-age-winners: direct link, hf CLI and curl.
- Browser
- Download file 12.2 kB
-
https://huggingface.co/Karar-git/hack4dev-building-age-winners/resolve/main/tools/compare_teammate.py
- Command line
-
hf download hf://Karar-git/hack4dev-building-age-winners/tools/compare_teammate.py
-
curl -L -o compare_teammate.py https://huggingface.co/Karar-git/hack4dev-building-age-winners/resolve/main/tools/compare_teammate.py
12.2 kB
| """ | |
| Head-to-head: a teammate's pipeline against ours, on two common yardsticks. | |
| NOTE FOR THIS BUNDLE. This file is here because tools/full_score_combo.py imports | |
| it to rebuild the zero-shot anchor of FINAL-2 (its XGBoost, DANN, region | |
| normalisation and smoothing functions). The head-to-head in main() needs the | |
| teammate's lockbox block list, which is not in this repo, so main() cannot be run | |
| from the bundle. | |
| lockbox their own Madrid test: train on their 80% dev blocks, score once on | |
| their 29 locked 2 km blocks (15,157 px). Rebuilt from our pixel keys | |
| with their block rule. Their own lockbox scores, which are not in | |
| this repo, were the fidelity check. | |
| amsterdam train on ALL of Madrid, score ALL 25,992 Amsterdam pixels with no | |
| Amsterdam labels used except to score (zero-shot: the setting their | |
| pipeline was built for). | |
| The functions here are adapted from the teammate's code and published with their | |
| agreement. One difference: XGBoost and the DANN run | |
| on CPU because this PC has no CUDA (different random streams; their DANN seeds | |
| vary by 0.002-0.004). Their features are the official 60 on the same pixels: | |
| pass `first` as the third argument to use the organisers' exact construction | |
| (tools/build_official60_first.py); the default is our median-per-year rebuild. | |
| Every model is scored under two decision rules: | |
| argmax what their pipeline uses | |
| tau+plugin ours: tau=0.5 logit adjustment then the macro-F1 plug-in, Madrid prior | |
| and with/without their neighbour smoothing (r = 6 px = 180 m, alpha 0.5). | |
| python -I tools/compare_teammate.py <lockbox_blocks.json> reports/teammate_compare.csv | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import sys | |
| import time | |
| from pathlib import Path | |
| ROOT = Path(__file__).resolve().parent.parent | |
| sys.path.insert(0, str(ROOT / "src")) | |
| os.chdir(ROOT) | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| import torch.nn as nn | |
| from lightgbm import LGBMClassifier | |
| from scipy.spatial import cKDTree | |
| from sklearn.ensemble import RandomForestClassifier | |
| from sklearn.utils.class_weight import compute_sample_weight | |
| from xgboost import XGBClassifier | |
| import config as C | |
| import evaluation as E | |
| import gbdt as G | |
| torch.set_num_threads(max(1, (os.cpu_count() or 4) - 2)) | |
| DEV = torch.device("cpu") | |
| BLOCK = 67 | |
| SMOOTH_ALPHA = 0.5 | |
| R_PX = 6 | |
| XGB_PARAMS = {"n_estimators": 500, "learning_rate": 0.08, "max_depth": 8, | |
| "min_child_weight": 3.0, "subsample": 0.8, "colsample_bytree": 1.0, | |
| "reg_lambda": 10.0, "gamma": 1.0} # their best_config.json | |
| DANN_EPOCHS, DANN_SEEDS = 40, (0, 1, 2) | |
| # ---- copied from their compare_models.py ---------------------------------- # | |
| def block_ids(pid): | |
| return (pid[:, 0] // BLOCK) * 100000 + (pid[:, 1] // BLOCK) | |
| def region_norm(X): | |
| mu, sd = X.mean(0), X.std(0) | |
| sd[sd == 0] = 1 | |
| return (X - mu) / sd | |
| def smooth(P, coords, r): | |
| tree = cKDTree(coords) | |
| nbrs = tree.query_ball_point(coords, r=r) | |
| out = np.empty_like(P) | |
| for i, nb in enumerate(nbrs): | |
| out[i] = SMOOTH_ALPHA * P[i] + (1 - SMOOTH_ALPHA) * P[nb].mean(0) | |
| return out | |
| # ---- copied from their dann.py --------------------------------------------- # | |
| class GradReverse(torch.autograd.Function): | |
| def forward(ctx, x, lam): | |
| ctx.lam = lam | |
| return x.view_as(x) | |
| def backward(ctx, g): | |
| return -ctx.lam * g, None | |
| class DANN(nn.Module): | |
| def __init__(self, d_in=60, h=256, z=64): | |
| super().__init__() | |
| self.feat = nn.Sequential(nn.Linear(d_in, h), nn.BatchNorm1d(h), nn.ReLU(), nn.Dropout(0.2), | |
| nn.Linear(h, h), nn.BatchNorm1d(h), nn.ReLU(), nn.Dropout(0.2), | |
| nn.Linear(h, z), nn.ReLU()) | |
| self.age = nn.Sequential(nn.Linear(z, 64), nn.ReLU(), nn.Linear(64, 4)) | |
| self.city = nn.Sequential(nn.Linear(z, 64), nn.ReLU(), nn.Linear(64, 1)) | |
| def forward(self, x, lam=0.0): | |
| f = self.feat(x) | |
| return self.age(f), self.city(GradReverse.apply(f, lam)), f | |
| def train_dann(Xs, ys, Xt, epochs, adversarial=True, seed=0): | |
| torch.manual_seed(seed) | |
| np.random.seed(seed) | |
| net = DANN(Xs.shape[1]).to(DEV) | |
| opt = torch.optim.AdamW(net.parameters(), lr=1e-3, weight_decay=1e-4) | |
| counts = np.bincount(ys, minlength=5)[1:] | |
| w = torch.tensor(len(ys) / (4 * counts), dtype=torch.float32, device=DEV) | |
| ce, bce = nn.CrossEntropyLoss(weight=w), nn.BCEWithLogitsLoss() | |
| S, Y = torch.tensor(Xs, dtype=torch.float32, device=DEV), torch.tensor(ys - 1, device=DEV) | |
| T = torch.tensor(Xt, dtype=torch.float32, device=DEV) | |
| bs, steps = 512, int(np.ceil(len(S) / 512)) | |
| for ep in range(epochs): | |
| net.train() | |
| perm_s = torch.randperm(len(S), device=DEV) | |
| for k in range(steps): | |
| p = (ep * steps + k) / (epochs * steps) | |
| lam = (2 / (1 + np.exp(-10 * p)) - 1) if adversarial else 0.0 | |
| i = perm_s[k * bs:(k + 1) * bs] | |
| j = torch.randint(0, len(T), (len(i),), device=DEV) | |
| ya, cs, _ = net(S[i], lam) | |
| _, ct, _ = net(T[j], lam) | |
| loss = ce(ya, Y[i]) | |
| if adversarial: | |
| loss = loss + bce(cs.squeeze(1), torch.zeros(len(i), device=DEV)) \ | |
| + bce(ct.squeeze(1), torch.ones(len(j), device=DEV)) | |
| opt.zero_grad() | |
| loss.backward() | |
| opt.step() | |
| net.eval() | |
| return net | |
| def dann_proba(net, X): | |
| a, _, _ = net(torch.tensor(X, dtype=torch.float32, device=DEV)) | |
| return torch.softmax(a, 1).cpu().numpy() | |
| # ---- their tree models (final_lockbox.py / tune_boosting.py) --------------- # | |
| def their_trees(Xtr, ytr, Xte): | |
| sw = compute_sample_weight("balanced", ytr) | |
| xgb = XGBClassifier(objective="multi:softprob", tree_method="hist", device="cpu", | |
| eval_metric="mlogloss", random_state=42, **XGB_PARAMS) | |
| xgb.fit(Xtr, ytr - 1, sample_weight=sw) | |
| lgb = LGBMClassifier(n_estimators=600, learning_rate=0.05, num_leaves=63, subsample=0.8, | |
| subsample_freq=1, colsample_bytree=0.8, class_weight="balanced", | |
| n_jobs=6, random_state=42, verbose=-1).fit(Xtr, ytr) | |
| rf = RandomForestClassifier(n_estimators=300, class_weight="balanced", n_jobs=6, | |
| random_state=42).fit(Xtr, ytr) | |
| return {"T: XGBoost (tuned)": xgb.predict_proba(Xte), | |
| "T: LightGBM": lgb.predict_proba(Xte), | |
| "T: Random Forest": rf.predict_proba(Xte)} | |
| # ---- ours ------------------------------------------------------------------ # | |
| def our_lgbm(Xtr, ytr, Xte): | |
| """notebook 3's 'zero-shot, no normalisation': full LGBM_PARAMS, balanced.""" | |
| cw = G.balanced_class_weights(ytr) | |
| m = G.fit_lgbm(Xtr, ytr, class_weight=dict(zip(range(4), cw))) | |
| return m.predict_proba(Xte) | |
| def load(official: str = "median"): | |
| mad = pd.read_parquet(C.PROC_DIR / "madrid_features.parquet") | |
| ams = pd.read_parquet(C.PROC_DIR / "amsterdam_features.parquet") | |
| drop = {"px_key", "py_key", "age_class", "weighted_mean_year", "city"} | |
| feats = [c for c in mad.columns if c not in drop and mad[c].dtype.kind in "fiu"] | |
| feats = [c for c in feats if not (mad[c].std() == 0 or ams[c].std() == 0)] | |
| assert len(feats) == 393, len(feats) | |
| f60 = list(C.FEATURE_COLS_60) # all 60, as their pkl has | |
| if official == "first": | |
| # The organisers' exact construction (first QA-valid observation per | |
| # year), from tools/build_official60_first.py, aligned row by row. | |
| def aligned(city, ref): | |
| o = pd.read_parquet(C.PROC_DIR / f"{city}_official60_first.parquet") | |
| has_label = "age_class" in o.columns # the published tables carry no label | |
| o = ref[C.PID + ["age_class"]].merge(o, on=C.PID, how="left", | |
| suffixes=("", "_o")) | |
| assert o[f60].notna().all().all() and len(o) == len(ref) | |
| assert not has_label or (o["age_class"] == o["age_class_o"]).all() | |
| return o[f60].to_numpy(float) | |
| Xm60, Xa60 = aligned("madrid", mad), aligned("amsterdam", ams) | |
| else: | |
| Xm60, Xa60 = mad[f60].to_numpy(float), ams[f60].to_numpy(float) | |
| print(f"official 60 built with agg={official}", flush=True) | |
| return dict( | |
| Xm60=Xm60, Xa60=Xa60, | |
| Xm=mad[feats].to_numpy(float), Xa=ams[feats].to_numpy(float), | |
| ym=mad["age_class"].to_numpy().astype(int), ya=ams["age_class"].to_numpy().astype(int), | |
| pm=mad[C.PID].to_numpy().astype(np.int64), pa=ams[C.PID].to_numpy().astype(np.int64)) | |
| def run_setting(name, d, tr, te_X60, te_X, te_y, te_xy, Xadv60): | |
| """Fit every model on Madrid rows `tr`, score on the given test set.""" | |
| t0 = time.time() | |
| Xtr60, Xtr, ytr = d["Xm60"][tr], d["Xm"][tr], d["ym"][tr] | |
| prior = np.array([(ytr == c).mean() for c in E.CLASSES]) | |
| P = their_trees(Xtr60, ytr, te_X60) | |
| print(f"[{name}] their trees done {time.time() - t0:.0f}s", flush=True) | |
| P["O: LightGBM, 393 features (our zero-shot)"] = our_lgbm(Xtr, ytr, te_X) | |
| P["O: LightGBM, official 60 (our params)"] = our_lgbm(Xtr60, ytr, te_X60) | |
| print(f"[{name}] our LightGBM done {time.time() - t0:.0f}s", flush=True) | |
| seeds = {} | |
| Xtr_n, Xte_n, Xadv_n = region_norm(Xtr60), region_norm(te_X60), region_norm(Xadv60) | |
| for s in DANN_SEEDS: | |
| net = train_dann(Xtr_n, ytr, Xadv_n, DANN_EPOCHS, adversarial=True, seed=s) | |
| Pd = dann_proba(net, Xte_n) | |
| seeds[s] = {"T: DANN": Pd, | |
| "T: XGBoost + DANN (their FINAL)": (P["T: XGBoost (tuned)"] + Pd) / 2} | |
| print(f"[{name}] DANN seed {s} done {time.time() - t0:.0f}s", flush=True) | |
| coords = te_xy.astype(float) | |
| rows = [] | |
| def score(model, seed, PP): | |
| for sm in (False, True): | |
| Q = smooth(PP, coords, R_PX) if sm else PP | |
| for rule in ("argmax", "tau+plugin"): | |
| lab = (np.array(E.CLASSES)[Q.argmax(1)] if rule == "argmax" | |
| else E.adapt_pipeline(Q, prior, tau=0.5)[0]) | |
| pc = E.per_class_f1(te_y, lab) | |
| rows.append(dict(setting=name, model=model, seed=seed, smooth=sm, rule=rule, | |
| macro_f1=E.macro_f1(te_y, lab), | |
| **{f"f1_c{c}": float(v) for c, v in zip(E.CLASSES, pc)}, | |
| n_test=len(te_y))) | |
| for k, PP in P.items(): | |
| score(k, 0, PP) | |
| for s, dd in seeds.items(): | |
| for k, PP in dd.items(): | |
| score(k, s, PP) | |
| print(f"[{name}] scored, {time.time() - t0:.0f}s total", flush=True) | |
| return rows | |
| def main(): | |
| lock_json, out_csv = Path(sys.argv[1]), Path(sys.argv[2]) | |
| d = load(sys.argv[3] if len(sys.argv) > 3 else "median") | |
| g = block_ids(d["pm"]) | |
| lock = np.isin(g, json.loads(lock_json.read_text())["blocks"]) | |
| print(f"lockbox pixels {lock.sum():,} (theirs: 15,157); dev {(~lock).sum():,} " | |
| f"(theirs: 61,106)", flush=True) | |
| assert lock.sum() == 15157 and (~lock).sum() == 61106 | |
| rows = [] | |
| dev, lk = np.flatnonzero(~lock), np.flatnonzero(lock) | |
| rows += run_setting("lockbox", d, dev, d["Xm60"][lk], d["Xm"][lk], d["ym"][lk], | |
| d["pm"][lk], d["Xa60"]) | |
| pd.DataFrame(rows).to_csv(out_csv, index=False) | |
| allm = np.arange(len(d["ym"])) | |
| rows += run_setting("amsterdam", d, allm, d["Xa60"], d["Xa"], d["ya"], d["pa"], d["Xa60"]) | |
| df = pd.DataFrame(rows) | |
| df.to_csv(out_csv, index=False) | |
| summ = (df.groupby(["setting", "model", "smooth", "rule"])["macro_f1"] | |
| .agg(["mean", "std", "size"]).round(4).reset_index()) | |
| for st in ("lockbox", "amsterdam"): | |
| t = summ[summ.setting == st].pivot_table(index="model", columns=["rule", "smooth"], | |
| values="mean") | |
| print(f"\n=== {st} (macro F1; DANN rows = mean of 3 seeds) ===") | |
| print(t.sort_values(("argmax", True), ascending=False).to_string()) | |
| if __name__ == "__main__": | |
| main() | |