Download winners.py from Karar-git/hack4dev-building-age-winners: direct link, hf CLI and curl.
- Browser
- Download file 11.1 kB
-
https://huggingface.co/Karar-git/hack4dev-building-age-winners/resolve/main/winners.py
- Command line
-
hf download hf://Karar-git/hack4dev-building-age-winners/winners.py
-
curl -L -o winners.py https://huggingface.co/Karar-git/hack4dev-building-age-winners/resolve/main/winners.py
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() | |