Download tools/nnlab/transductive.py from Karar-git/hack4dev-building-age-winners: direct link, hf CLI and curl.
- Browser
- Download file 17.5 kB
-
https://huggingface.co/Karar-git/hack4dev-building-age-winners/resolve/main/tools/nnlab/transductive.py
- Command line
-
hf download hf://Karar-git/hack4dev-building-age-winners/tools/nnlab/transductive.py
-
curl -L -o transductive.py https://huggingface.co/Karar-git/hack4dev-building-age-winners/resolve/main/tools/nnlab/transductive.py
17.5 kB
| """ | |
| nnlab / transductive - refine probabilities ALREADY in the prediction store with | |
| the unlabelled Amsterdam pool, the pixel coordinates and the n labelled shots. | |
| python tools/nnlab/transductive.py --base lgbm_upw [--base zs_xgb_dann,zs_lgbm] | |
| [--smoke] [--only lp] [--madrid-check] | |
| For every base B and protocol draw (n, rep) it writes: | |
| B__clamp shot rows -> one-hot of their known label, then the lab's neighbour | |
| smoothing (r=6, alpha=0.5) over ALL pixels, shots included as | |
| neighbours. The smoothing is already inside the prediction, so READ | |
| THE "rule" COLUMN of `lab.py score` for these methods: its | |
| "rule_smooth" column would smooth a second time. | |
| B__lp label propagation (Zhou et al. 2004): F <- a S F + (1-a) Y0, 20 power | |
| iterations from F = Y0 (-> (1-a)(I-aS)^-1 Y0), S = D^-1/2 A D^-1/2, | |
| A = spatial graph (lab.adjacency(pa, r=3)) UNION feature kNN graph | |
| (k=10, cosine, PCA-32 of lab.robustz(Xa)), self-loops removed, | |
| Y0 = base probabilities with shot rows = 10 x one-hot, a = 0.8. | |
| F row-normalised. | |
| B__lapshot LaplacianShot-style (Ziko et al. 2020): y <- softmax(log p + lam * G y), | |
| 10 iterations from y = p, lam = 1.0, shots clamped to one-hot every | |
| iteration. G = the same union graph, ROW-NORMALISED (D^-1 A, i.e. the | |
| mean over neighbours) - see DEVIATIONS. | |
| B__em label-shift EM (Saerens et al. 2002), training prior = Madrid prior, | |
| 50 iterations on the non-shot rows, estimated target prior shrunk 50% | |
| toward the Madrid prior, posteriors re-weighted with that prior. | |
| For a shot-independent base it is label-free and saved once (n=0). | |
| B__st (only B == lgbm_upw) one round of self-training: per class c, among | |
| non-shot pixels whose base argmax is c, the top 15% by base p(c) | |
| become pseudo-labels (weight 3.0); Madrid weight 1, shots weight 30, | |
| then class-balanced over the union; refit with winners.train_retrained's | |
| robust-z scaling and LightGBM parameters; predict all Amsterdam rows. | |
| ~1-2 min per draw, run LAST. | |
| The "rule" column is the one to read for __clamp; for the others both columns are | |
| legitimate (rule_smooth is a further, separate smoothing). | |
| DEVIATIONS (all fixed in advance, none chosen on Amsterdam): | |
| * lapshot uses the row-normalised graph. With the raw 0/1 union graph a pixel has | |
| ~22 neighbours on average (min 10; measured), so lam=1 puts a pairwise logit of up to ~22 against a unary of | |
| a few nats: the unary term is drowned and the result is a hard spatial majority | |
| vote. `--madrid-check` compares both on the Madrid pseudo-target. | |
| * Self-loops are dropped from the union graph (Zhou et al. set W_ii = 0). | |
| * LightGBM in __st uses n_jobs = NNLAB_THREADS instead of -1 (shared laptop). | |
| * __st reads the features from lab.load() (float32 parquet columns -> float64), | |
| not winners.load_data (which also loads Amsterdam age_class). | |
| Hard rules kept: Amsterdam labels only via lab.shot_labels(lab.shots_for(...)). | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import sys | |
| import time | |
| from pathlib import Path | |
| sys.path.insert(0, str(Path(__file__).resolve().parent)) | |
| import lab # noqa: E402 | |
| import numpy as np # noqa: E402 | |
| from scipy import sparse # noqa: E402 | |
| W = lab.W | |
| T0 = time.time() | |
| # fixed knobs (spec) ---------------------------------------------------------- # | |
| CLAMP_R, CLAMP_ALPHA = lab.SMOOTH_R, lab.SMOOTH_ALPHA # 6, 0.5 | |
| GRAPH_R, KNN_K, PCA_K = 3, 10, 32 | |
| LP_ALPHA, LP_ITERS, LP_SEED_W = 0.8, 20, 10.0 | |
| LS_LAM, LS_ITERS = 1.0, 10 | |
| LS_NORM = "row" # see DEVIATIONS | |
| EM_ITERS, EM_SHRINK = 50, 0.5 | |
| ST_SHARE, ST_W, ST_SHOT_W = 0.15, 3.0, 30.0 | |
| FAST = ("clamp", "lp", "lapshot", "em") | |
| SHOT_FREE_BASES = {"zs_lgbm", "zs_xgb_dann", "zs_dann"} | |
| def log(msg): | |
| print(f"[{time.time() - T0:7.1f}s] {msg}", flush=True) | |
| def onehot(y): | |
| Y = np.zeros((len(y), 4)) | |
| Y[np.arange(len(y)), np.asarray(y) - 1] = 1.0 | |
| return Y | |
| def rownorm(P): | |
| P = np.clip(P, 1e-12, None) | |
| return P / P.sum(1, keepdims=True) | |
| # --------------------------------------------------------------------------- # | |
| # Graphs (draw independent) | |
| # --------------------------------------------------------------------------- # | |
| def knn_graph(Z, k=KNN_K): | |
| """Symmetric 0/1 kNN graph, cosine similarity (= euclidean on unit vectors).""" | |
| from scipy.spatial import cKDTree | |
| U = Z / np.clip(np.linalg.norm(Z, axis=1, keepdims=True), 1e-12, None) | |
| _, nb = cKDTree(U).query(U, k=k + 1) | |
| n = len(U) | |
| rows = np.repeat(np.arange(n), k) | |
| cols = nb[:, 1:].ravel() | |
| A = sparse.coo_matrix((np.ones(len(rows)), (rows, cols)), shape=(n, n)).tocsr() | |
| return A.maximum(A.T).tocsr() | |
| def union_graph(X, coords, tag=None): | |
| """Spatial r=3 graph UNION feature kNN graph on PCA-32 of robustz(X); no self-loops. | |
| X must be ONE city's (or one pseudo-city's) raw features.""" | |
| path = lab.CACHE / f"transd_graph_{tag}.npz" if tag else None | |
| if path is not None and path.exists(): | |
| return sparse.load_npz(path).tocsr() | |
| from sklearn.decomposition import PCA | |
| Z = PCA(PCA_K, random_state=0).fit_transform(lab.robustz(X)) | |
| A_sp = lab.adjacency(coords, GRAPH_R) | |
| A = A_sp.maximum(knn_graph(Z)).tocsr() | |
| A.setdiag(0) | |
| A.eliminate_zeros() | |
| A.data[:] = 1.0 | |
| if path is not None: | |
| sparse.save_npz(path, A) | |
| return A | |
| class Graph: | |
| def __init__(self, A, coords): | |
| self.A = A | |
| deg = np.asarray(A.sum(1)).ravel() | |
| self.deg = deg | |
| dinv = 1.0 / np.sqrt(np.clip(deg, 1e-12, None)) | |
| self.S = sparse.diags(dinv) @ A @ sparse.diags(dinv) # Zhou | |
| self.R = sparse.diags(1.0 / np.clip(deg, 1e-12, None)) @ A # mean over nbrs | |
| self.coords = coords | |
| # --------------------------------------------------------------------------- # | |
| # Refinements (all take base P (N,4), shot idx, shot labels) | |
| # --------------------------------------------------------------------------- # | |
| def r_clamp(P, shot, ys, g): | |
| Q = P.copy() | |
| Q[shot] = onehot(ys) | |
| return lab.smooth(Q, g.coords, r=CLAMP_R, alpha=CLAMP_ALPHA) | |
| def r_lp(P, shot, ys, g, a=LP_ALPHA, iters=LP_ITERS): | |
| Y0 = P.copy() | |
| Y0[shot] = LP_SEED_W * onehot(ys) | |
| F = Y0.copy() | |
| for _ in range(iters): | |
| F = a * (g.S @ F) + (1 - a) * Y0 | |
| return rownorm(F) | |
| def r_lapshot(P, shot, ys, g, lam=LS_LAM, iters=LS_ITERS, norm=LS_NORM): | |
| G = g.R if norm == "row" else g.A | |
| U = np.log(np.clip(P, 1e-6, None)) | |
| Ys = onehot(ys) | |
| Y = P.copy() | |
| Y[shot] = Ys | |
| for _ in range(iters): | |
| Y = lab.softmax(U + lam * (G @ Y)) | |
| Y[shot] = Ys | |
| return Y | |
| def em_prior(P, prior_s, iters=EM_ITERS): | |
| pi_s = np.asarray(prior_s, float) / np.sum(prior_s) | |
| pi = pi_s.copy() | |
| for _ in range(iters): | |
| Q = rownorm(P * (pi / pi_s)) | |
| pi = Q.mean(0) | |
| return pi | |
| def r_em(P, shot, prior_s): | |
| pi_s = np.asarray(prior_s, float) / np.sum(prior_s) | |
| keep = np.setdiff1d(np.arange(len(P)), shot) if shot is not None else slice(None) | |
| pi_t = em_prior(P[keep], pi_s) | |
| pi_hat = (1 - EM_SHRINK) * pi_t + EM_SHRINK * pi_s | |
| return rownorm(P * (pi_hat / pi_s)), pi_t, pi_hat | |
| def pseudo_labels(P, shot, share=ST_SHARE): | |
| """Per class c: among non-shot rows with argmax c, the top `share` by p(c).""" | |
| free = np.ones(len(P), bool) | |
| free[shot] = False | |
| am = P.argmax(1) | |
| idx, lab_ = [], [] | |
| for k in range(4): | |
| cand = np.flatnonzero(free & (am == k)) | |
| m = int(round(share * len(cand))) | |
| if m == 0: | |
| continue | |
| top = cand[np.argsort(-P[cand, k], kind="stable")[:m]] | |
| idx.append(top) | |
| lab_.append(np.full(m, k + 1)) | |
| return np.concatenate(idx), np.concatenate(lab_) | |
| def r_st(P, shot, ys, Xm, ym, Xa, params): | |
| pidx, plab = pseudo_labels(P, shot) | |
| ms, ams = W.robustz_fit(Xm), W.robustz_fit(Xa) | |
| Za = W.robustz(Xa, ams) | |
| X = np.vstack([W.robustz(Xm, ms), Za[shot], Za[pidx]]) | |
| y = np.r_[ym, ys, plab] | |
| w = np.r_[np.ones(len(ym)), np.full(len(ys), ST_SHOT_W), np.full(len(plab), ST_W)] | |
| w = w * W.balanced_class_weights(y)[y - 1] | |
| m = W._fit_lgbm(X, y, params, sample_weight=w) | |
| del X | |
| return W.predict(m, Za), np.bincount(plab, minlength=5)[1:] | |
| # --------------------------------------------------------------------------- # | |
| # Madrid validation (pseudo-target, Madrid labels only) | |
| # --------------------------------------------------------------------------- # | |
| def madrid_check(d, smoke): | |
| """Train a retrained-style LightGBM on the Madrid source split, predict the | |
| spatial pseudo-target, draw pseudo-shots there, apply every refinement and | |
| print the rule macro F1 on the pseudo-target's non-shot rows.""" | |
| src, tgt = lab.madrid_split(d["pm"]) | |
| Xm = d["Xm"].astype(np.float64) | |
| ym = d["ym"] | |
| tag = "SMOKE" if smoke else "full" | |
| cp = lab.CACHE / f"transd_madrid_base_{tag}.npy" | |
| params = {**W.LGBM_PARAMS, **W.RETRAIN_OVERRIDES, "n_jobs": lab.THREADS} | |
| if smoke: | |
| params.update(n_estimators=40) | |
| if cp.exists(): | |
| Pt = np.load(cp) | |
| else: | |
| ss, ts = W.robustz_fit(Xm[src]), W.robustz_fit(Xm[tgt]) | |
| cw = W.balanced_class_weights(ym[src]) | |
| m = W._fit_lgbm(W.robustz(Xm[src], ss), ym[src], params, sample_weight=cw[ym[src] - 1]) | |
| Pt = W.predict(m, W.robustz(Xm[tgt], ts)) | |
| np.save(cp, Pt) | |
| log(f"madrid-check: base model fitted on {len(src)} src rows") | |
| yt, pt = ym[tgt], d["pm"][tgt] | |
| prior = np.array([(ym[src] == c).mean() for c in lab.CLASSES]) | |
| g = Graph(union_graph(Xm[tgt], pt, tag=f"madrid_tgt_{len(tgt)}"), pt) | |
| log(f"madrid-check: pseudo-target {len(tgt)} rows, mean degree {g.deg.mean():.1f}") | |
| variants = { | |
| "base": lambda P, s, y: P, | |
| "clamp": lambda P, s, y: r_clamp(P, s, y, g), | |
| "lp": lambda P, s, y: r_lp(P, s, y, g), | |
| "lapshot_row": lambda P, s, y: r_lapshot(P, s, y, g, norm="row"), | |
| "lapshot_raw": lambda P, s, y: r_lapshot(P, s, y, g, norm="raw"), | |
| "em": lambda P, s, y: r_em(P, s, prior)[0], | |
| } | |
| res = {k: {} for k in variants} | |
| for n in ((5, 200) if smoke else lab.N_LIST): | |
| for rep in range(1 if smoke else 3): | |
| rng = np.random.default_rng(7 + 1000 * n + rep) | |
| shot = np.concatenate([rng.choice(np.flatnonzero(yt == c), n, replace=False) | |
| for c in lab.CLASSES]) | |
| keep = np.setdiff1d(np.arange(len(yt)), shot) | |
| for k, f in variants.items(): | |
| Q = f(Pt, shot, yt[shot]) | |
| res[k].setdefault(n, []).append(W.macro_f1(yt[keep], W.decide(Q[keep], prior))) | |
| log("madrid-check: rule macro F1 on pseudo-target (non-shot rows), mean over reps") | |
| out = {} | |
| for k, r in res.items(): | |
| m = {n: float(np.mean(v)) for n, v in r.items()} | |
| out[k] = m | |
| print(f" {k:<12s} " + " ".join(f"n{n}={v:.4f}" for n, v in m.items()) | |
| + f" avg={np.mean(list(m.values())):.4f}", flush=True) | |
| return out | |
| # --------------------------------------------------------------------------- # | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--base", action="append", default=None, | |
| help="base method(s); repeatable or comma-separated") | |
| ap.add_argument("--smoke", action="store_true") | |
| ap.add_argument("--only", default=None, help="substring filter on method names") | |
| ap.add_argument("--madrid-check", action="store_true", | |
| help="validate the refinements on the Madrid pseudo-target and exit") | |
| a = ap.parse_args() | |
| bases = [b for s in (a.base or ["lgbm_upw"]) for b in s.split(",") if b] | |
| sfx = "_SMOKE" if a.smoke else "" | |
| draws = [(5, 0), (200, 0)] if a.smoke else list(lab.draws()) | |
| d = lab.load(official60=False) | |
| log(f"loaded: Madrid {d['Xm'].shape}, Amsterdam {d['Xa'].shape}") | |
| if a.madrid_check: | |
| madrid_check(d, a.smoke) | |
| return | |
| g = Graph(union_graph(d["Xa"], d["pa"], tag=f"ams_r{GRAPH_R}_k{KNN_K}_pca{PCA_K}"), d["pa"]) | |
| log(f"graph: {g.A.nnz} edges, mean degree {g.deg.mean():.1f}, min {g.deg.min():.0f}") | |
| knobs = dict(clamp=dict(r=CLAMP_R, alpha=CLAMP_ALPHA), | |
| graph=dict(spatial_r=GRAPH_R, knn_k=KNN_K, knn_metric="cosine", | |
| pca=PCA_K, on="lab.robustz(Xa)", self_loops=False, weights="0/1 union"), | |
| lp=dict(a=LP_ALPHA, iters=LP_ITERS, seed_weight=LP_SEED_W, S="D^-1/2 A D^-1/2"), | |
| lapshot=dict(lam=LS_LAM, iters=LS_ITERS, graph_norm=LS_NORM, clamp=True), | |
| em=dict(iters=EM_ITERS, shrink_to_madrid_prior=EM_SHRINK, train_prior="Madrid"), | |
| st=dict(share=ST_SHARE, pseudo_w=ST_W, shot_w=ST_SHOT_W, madrid_w=1.0, | |
| class_balanced=True, params="winners LGBM_PARAMS+RETRAIN_OVERRIDES")) | |
| choice_note = ("all knobs fixed in advance by the task spec; lapshot row-normalisation " | |
| "fixed a priori (raw 0/1 graph with lam=1 drowns the unary term); " | |
| "checked on Madrid pseudo-target via --madrid-check, never on Amsterdam") | |
| def want(name): | |
| return a.only is None or a.only in name | |
| def base_pred(B, n, rep): | |
| """Base probabilities for a draw. In smoke mode a missing per-draw base | |
| falls back to zs_xgb_dann (lgbm_upw is still being written).""" | |
| if lab.have_pred(B, n, rep) or lab.have_pred(B, 0, 0): | |
| return lab.load_pred(B, n, rep), B | |
| if a.smoke: | |
| return lab.load_pred("zs_xgb_dann", 0, 0), "zs_xgb_dann" | |
| return None, None | |
| # ---- fast methods ---------------------------------------------------- # | |
| for B in bases: | |
| for kind in FAST: | |
| name = f"{B}__{kind}{sfx}" | |
| if not want(name): | |
| continue | |
| zero = kind == "em" and B in SHOT_FREE_BASES | |
| for n, rep in ([(0, 0)] if zero else draws): | |
| if lab.have_pred(name, n, rep): | |
| continue | |
| t = time.time() | |
| P, used = base_pred(B, max(n, 0), rep) if not zero else (lab.load_pred(B, 0, 0), B) | |
| if P is None: | |
| log(f"{name} n={n} rep={rep}: base {B} missing, skipped") | |
| continue | |
| meta = dict(base=B, base_used=used, kind=kind, knobs=knobs[kind] if kind in knobs else None, | |
| graph=knobs["graph"] if kind in ("lp", "lapshot") else None, | |
| choices=choice_note, read_column="rule" if kind == "clamp" else "rule or rule_smooth") | |
| if zero: | |
| Q, pi_t, pi_hat = r_em(P, None, d["prior"]) | |
| meta.update(pi_t=pi_t.tolist(), pi_hat=pi_hat.tolist()) | |
| else: | |
| shot = lab.shots_for(d, n, rep) | |
| ys = lab.shot_labels(d, shot) | |
| if kind == "clamp": | |
| Q = r_clamp(P, shot, ys, g) | |
| elif kind == "lp": | |
| Q = r_lp(P, shot, ys, g) | |
| elif kind == "lapshot": | |
| Q = r_lapshot(P, shot, ys, g) | |
| else: | |
| Q, pi_t, pi_hat = r_em(P, shot, d["prior"]) | |
| meta.update(pi_t=pi_t.tolist(), pi_hat=pi_hat.tolist()) | |
| lab.save_pred(name, n, rep, Q, meta) | |
| log(f"{name} n={n} rep={rep} base={used} done in {time.time() - t:.1f}s") | |
| # ---- self-training (last, slow) -------------------------------------- # | |
| st_bases = [B for B in bases if B == "lgbm_upw" or B in __import__("os").environ.get("NNLAB_ST_BASES", "").split(",")] # pseudo-labels may come from any stored method | |
| if not st_bases and a.smoke: | |
| st_bases = [bases[0]] # exercise the path in smoke mode | |
| params = {**W.LGBM_PARAMS, **W.RETRAIN_OVERRIDES, "n_jobs": lab.THREADS} | |
| if a.smoke: | |
| params.update(n_estimators=40) | |
| Xm = Xa = None | |
| for B in st_bases: | |
| name = f"{B}__st{sfx}" | |
| if not want(name): | |
| continue | |
| for n, rep in draws: | |
| if lab.have_pred(name, n, rep): | |
| continue | |
| P, used = base_pred(B, n, rep) | |
| if P is None: | |
| log(f"{name} n={n} rep={rep}: base {B} missing, skipped") | |
| continue | |
| if Xm is None: | |
| Xm, Xa = d["Xm"].astype(np.float64), d["Xa"].astype(np.float64) | |
| t = time.time() | |
| shot = lab.shots_for(d, n, rep) | |
| ys = lab.shot_labels(d, shot) | |
| Q, counts = r_st(P, shot, ys, Xm, d["ym"], Xa, params) | |
| meta = dict(base=B, base_used=used, kind="st", knobs=knobs["st"], | |
| n_estimators=params["n_estimators"], n_jobs=params["n_jobs"], | |
| choices=choice_note, pseudo_counts_last=counts.tolist()) | |
| lab.save_pred(name, n, rep, Q, meta) | |
| log(f"{name} n={n} rep={rep} base={used} pseudo={counts.tolist()} " | |
| f"done in {time.time() - t:.1f}s") | |
| log("done") | |
| if __name__ == "__main__": | |
| main() | |