hack4dev-building-age-winners / tools /compare_teammate.py
Karar-git's picture
Add FINAL-2 pipeline, overnight search scripts and results (11 Nov 2026)
fe9543f verified
Raw History Blame Contribute Delete
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):
@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 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
@torch.no_grad()
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()