Karar-git's picture
Winner models, code, notebooks, data and results
2b9ed19 verified
Raw History Blame Contribute Delete
11.1 kB
"""
The two winning models from our Hack4Dev building-age entry, self-contained.
Needs only numpy, pandas, pyarrow, scikit-learn and lightgbm - none of the
project's src/ code. Every function here is a line-for-line port of the code
that produced the reported numbers (src/gbdt.py, src/evaluation.py); the
export script checked that the ports give identical results.
ZERO-SHOT LightGBM trained on Madrid only. Never sees an Amsterdam label.
Load it from models/zero_shot_lgbm.txt, or retrain it.
RETRAINED A fresh LightGBM on all of Madrid plus the n labelled Amsterdam
pixels per class that the protocol hands you, those pixels
weighted 30x. Refit for every draw of labelled pixels, so there
is no single saved model - models/retrained_*.txt are two
example draws, saved so you can inspect one.
Both use the same decision rule: tau = 0.5 logit adjustment by the Madrid class
prior, then the macro-F1 plug-in rule. Chosen on Madrid alone.
Command line (run from this folder):
python winners.py zero-shot # score the saved model
python winners.py retrained --n 25 --rep 0 # one draw, as reported
python winners.py protocol --model retrained --reps 5
python winners.py protocol --model zero-shot --reps 3
Python:
import winners as W
d = W.load_data()
model = W.load_zero_shot()
labels = W.decide(W.predict(model, d["X_ams"]), d["madrid_prior"])
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import numpy as np
import pandas as pd
HERE = Path(__file__).resolve().parent
DATA = HERE / "data" / "processed"
MODELS = HERE / "models"
CLASSES = np.array([1, 2, 3, 4]) # 1 = oldest ... 4 = newest; see README
SEED = 42
# Full-strength LightGBM (src/gbdt.py LGBM_PARAMS) - used by the zero-shot model.
LGBM_PARAMS = dict(
objective="multiclass", num_class=4, learning_rate=0.03, n_estimators=1200,
num_leaves=31, max_depth=-1, min_child_samples=80, subsample=0.8,
subsample_freq=1, colsample_bytree=0.6, reg_alpha=0.5, reg_lambda=5.0,
max_bin=127, n_jobs=-1, random_state=SEED, verbosity=-1,
)
# The retrained variant was measured with notebook 3's cheaper settings,
# because it refits on every draw. Same params otherwise.
RETRAIN_OVERRIDES = dict(n_estimators=400, learning_rate=0.06)
SHOT_WEIGHT = 30.0
# --------------------------------------------------------------------------- #
# Data
# --------------------------------------------------------------------------- #
def feature_names() -> list[str]:
return json.loads((MODELS / "meta.json").read_text())["feature_names"]
def load_data(data_dir: Path | str = DATA) -> dict:
"""Madrid and Amsterdam feature matrices in the exact training column order."""
data_dir = Path(data_dir)
feats = feature_names()
mad = pd.read_parquet(data_dir / "madrid_features.parquet")
ams = pd.read_parquet(data_dir / "amsterdam_features.parquet")
y_mad = mad["age_class"].to_numpy()
return dict(
X_mad=mad[feats].to_numpy(float), y_mad=y_mad,
X_ams=ams[feats].to_numpy(float), y_ams=ams["age_class"].to_numpy(),
coords_ams=ams[["px_key", "py_key"]].to_numpy(float),
madrid_prior=np.array([(y_mad == c).mean() for c in CLASSES]),
feature_names=feats,
)
# --------------------------------------------------------------------------- #
# Decision rule: tau-logit adjustment, then the macro-F1 plug-in
# --------------------------------------------------------------------------- #
def logit_adjust(proba, train_prior, tau=0.5):
p = np.asarray(proba, dtype=float)
adj = p / np.clip(np.asarray(train_prior, float), 1e-12, None) ** float(tau)
return adj / np.clip(adj.sum(axis=1, keepdims=True), 1e-12, None)
def macro_f1_plugin(proba, prior, n_iter=50):
"""Ramaswamy et al.'s plug-in rule for macro F1. Uses no target labels, but
it does look at the predicted class shares of the batch it is given, so
call it on the whole set you want labelled, not row by row."""
P = np.asarray(proba, dtype=float)
pi = np.asarray(prior, dtype=float)
pi = pi / pi.sum()
f1 = np.zeros(4)
best = None
for it in range(n_iter):
q = np.clip(np.bincount(np.argmax(P, axis=1), minlength=4) / len(P), 1e-6, None) \
if it == 0 else q_new
a = 2.0 / np.clip(pi + q, 1e-9, None)
lab_idx = np.argmax(a[None, :] * (P - f1[None, :] / 2.0), axis=1)
q_new = np.clip(np.bincount(lab_idx, minlength=4) / len(P), 1e-6, None)
tp = np.array([P[lab_idx == k, k].sum() for k in range(4)]) / len(P)
f1_new = 2 * tp / np.clip(pi + q_new, 1e-9, None)
obj = float(np.mean(f1_new))
if best is None or obj > best[0]:
best = (obj, CLASSES[lab_idx].copy())
if np.max(np.abs(f1_new - f1)) < 1e-6 and it > 0:
break
f1 = f1_new
return best[1]
def decide(proba, madrid_prior, tau=0.5):
"""Class labels (1-4) from probabilities. Plain argmax would be
CLASSES[proba.argmax(1)]; this rule was worth about +0.01 on Madrid."""
src = np.asarray(madrid_prior, float)
src = src / src.sum()
return macro_f1_plugin(logit_adjust(proba, src, tau), src)
def macro_f1(y_true, y_pred) -> float:
from sklearn.metrics import f1_score
return float(f1_score(y_true, y_pred, labels=CLASSES, average="macro",
zero_division=0))
# --------------------------------------------------------------------------- #
# Models
# --------------------------------------------------------------------------- #
def balanced_class_weights(y):
y = np.asarray(y)
counts = np.array([(y == c).sum() for c in CLASSES], dtype=float)
return len(y) / (4.0 * np.clip(counts, 1, None))
def _fit_lgbm(X, y, params, class_weight=None, sample_weight=None):
import lightgbm as lgb
p = dict(params)
if class_weight is not None:
p["class_weight"] = class_weight
m = lgb.LGBMClassifier(**p)
m.fit(np.asarray(X), np.asarray(y) - 1, sample_weight=sample_weight)
return m
def train_zero_shot(X_mad, y_mad):
"""Madrid only, class-balanced, raw features (no per-city scaling)."""
cw = balanced_class_weights(y_mad)
return _fit_lgbm(X_mad, y_mad, LGBM_PARAMS,
class_weight=dict(zip(range(4), cw)))
def robustz_fit(X):
X = np.asarray(X, dtype=np.float64)
med = np.median(X, axis=0)
q75, q25 = np.percentile(X, [75, 25], axis=0)
iqr = np.where((q75 - q25) > 1e-12, q75 - q25, 1.0)
return med, iqr
def robustz(X, stats):
med, iqr = stats
return (np.asarray(X, dtype=np.float64) - med) / iqr
def train_retrained(X_mad, y_mad, X_ams_all, X_shot, y_shot,
shot_weight=SHOT_WEIGHT):
"""
Retrain on Madrid + the labelled Amsterdam shots, shots upweighted.
Each city is robust-z scaled with its OWN median/IQR. Amsterdam's are taken
from its whole unlabelled feature matrix (features only, no labels) - the
protocol passes that in, and the organisers' baseline does the same.
Returns (model, amsterdam_stats); predict with predict_retrained().
"""
mad_stats = robustz_fit(X_mad)
ams_stats = robustz_fit(X_ams_all)
X = np.vstack([robustz(X_mad, mad_stats), robustz(X_shot, ams_stats)])
y = np.r_[np.asarray(y_mad), np.asarray(y_shot)]
w = np.r_[np.ones(len(y_mad)), np.full(len(y_shot), shot_weight)]
w = w * balanced_class_weights(y)[y - 1]
model = _fit_lgbm(X, y, {**LGBM_PARAMS, **RETRAIN_OVERRIDES}, sample_weight=w)
return model, ams_stats
def predict(model, X):
"""Class probabilities, columns = classes 1..4. Accepts a fitted
LGBMClassifier or a lightgbm.Booster loaded from models/*.txt."""
if hasattr(model, "predict_proba"):
return model.predict_proba(np.asarray(X))
return model.predict(np.asarray(X))
def predict_retrained(model, ams_stats, X):
return predict(model, robustz(X, ams_stats))
def load_zero_shot():
import lightgbm as lgb
return lgb.Booster(model_file=str(MODELS / "zero_shot_lgbm.txt"))
def load_retrained_example(n: int):
"""One saved retrained model (draw rep 0 at this n) and its Amsterdam stats."""
import lightgbm as lgb
booster = lgb.Booster(model_file=str(MODELS / f"retrained_n{n}_rep0_lgbm.txt"))
z = np.load(MODELS / "amsterdam_robustz_stats.npz")
return booster, (z["median"], z["iqr"])
# --------------------------------------------------------------------------- #
# The official few-shot protocol
# --------------------------------------------------------------------------- #
def draw_shots(y_tgt, n_per_class, rng):
"""Stratified n-per-class sample - identical to src/evaluation.draw_shots."""
picks = []
for c in CLASSES:
pool = np.flatnonzero(np.asarray(y_tgt) == c)
if len(pool):
picks.append(rng.choice(pool, size=min(n_per_class, len(pool)),
replace=False))
return np.concatenate(picks)
def shots_for(y_ams, n, rep):
"""The exact draw we reported for (n, rep)."""
return draw_shots(y_ams, n, np.random.default_rng(SEED + 1000 * n + rep))
def score_draw(d, model_name, n, rep, zero_shot_model=None):
"""Macro F1 on all of Amsterdam minus this draw's labelled pixels."""
shot = shots_for(d["y_ams"], n, rep)
keep = np.setdiff1d(np.arange(len(d["y_ams"])), shot)
if model_name == "zero-shot":
m = zero_shot_model or load_zero_shot()
P = predict(m, d["X_ams"][keep])
else:
m, st = train_retrained(d["X_mad"], d["y_mad"], d["X_ams"],
d["X_ams"][shot], d["y_ams"][shot])
P = predict_retrained(m, st, d["X_ams"][keep])
return macro_f1(d["y_ams"][keep], decide(P, d["madrid_prior"]))
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("what", choices=["zero-shot", "retrained", "protocol"])
ap.add_argument("--model", choices=["zero-shot", "retrained"], default="retrained")
ap.add_argument("--n", type=int, default=25)
ap.add_argument("--rep", type=int, default=0)
ap.add_argument("--reps", type=int, default=5)
a = ap.parse_args()
d = load_data()
if a.what == "zero-shot":
P = predict(load_zero_shot(), d["X_ams"])
print(f"zero-shot, all {len(P):,} Amsterdam pixels: macro F1 "
f"{macro_f1(d['y_ams'], decide(P, d['madrid_prior'])):.4f}")
elif a.what == "retrained":
print(f"retrained, n={a.n} rep={a.rep}: macro F1 "
f"{score_draw(d, 'retrained', a.n, a.rep):.4f}")
else:
zs = load_zero_shot() if a.model == "zero-shot" else None
for n in (5, 25, 50, 100, 200):
s = [score_draw(d, a.model, n, r, zs) for r in range(a.reps)]
print(f"{a.model} n={n:<4d} macro F1 {np.mean(s):.4f} "
f"+- {np.std(s, ddof=1) if len(s) > 1 else 0:.4f}", flush=True)
if __name__ == "__main__":
main()