Karar-git's picture
Add FINAL-2 pipeline, overnight search scripts and results (11 Nov 2026)
2c2dd6c verified
Raw History Blame Contribute Delete
19.3 kB
"""
metric.py - METRIC LEARNING family: an embedding trained on Madrid so that distance
means "same age class", then adapted per protocol draw with the n Amsterdam shots.
python tools/nnlab/metric.py [--smoke] [--only <substring>]
Encoders (MLP 393-256-256-64, BN/ReLU/Dropout 0.2, AdamW lr 1e-3 wd 1e-4, 2 seeds):
supcon supervised contrastive (T = 0.1), class-balanced batches of 512
proto prototypical episodes (4-way, k in {5, 25}, 50 queries/class, scale 10),
cross-block: support from one random half of the 2 km blocks, queries from the other
protoda proto + deep-CORAL (weight 1) to an unlabelled Amsterdam batch every step
cos Baseline++ cosine classifier (scale 16), class-balanced batches
Heads (encoder frozen, CL2N per city, seeds' probabilities averaged):
pn, maha, tpn, knn, ftproj -> methods met_{encoder}_{head}
Training length per encoder is chosen ONCE on Madrid (lab.madrid_split pseudo-target,
25-shot prototype macro F1, candidates evaluated as checkpoints of one constant-LR run),
then the encoder is retrained on all Madrid. Amsterdam labels: only lab.shot_labels.
"""
from __future__ import annotations
import argparse
import json
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
T0 = time.time()
def log(msg):
print(f"[{time.time() - T0:7.1f}s] {msg}", flush=True)
ap = argparse.ArgumentParser()
ap.add_argument("--smoke", action="store_true")
ap.add_argument("--only", default=None)
args = ap.parse_args()
SMOKE = args.smoke
SFX = "_SMOKE" if SMOKE else ""
ENCODERS = ["supcon", "proto", "protoda", "cos"]
HEADS = ["pn", "maha", "tpn", "knn", "ftproj"]
# configuration ---------------------------------------------------------------- #
# candidate training lengths (optimizer steps; one step = one batch / one episode)
# (sized to the measured ~0.09-0.2 s/step on the shared CPU so the full run fits ~60 min;
# 1500 steps x 512 = ~10 Madrid epochs)
CAND = {"supcon": [300, 700, 1500], "cos": [300, 700, 1500],
"proto": [400, 800, 1600], "protoda": [400, 800, 1600]}
if SMOKE:
CAND = {k: [15, 30] for k in CAND}
SEEDS = [0] if SMOKE else [0, 1]
FT_SEEDS = 1 # ftproj uses only the first seed's encoder (time budget)
SEL_SEED = 100 # seed of the Madrid-only selection run
SEL_DRAWS = 2 if SMOKE else 5 # pseudo-target 25-shot draws per checkpoint
DRAWS = [(5, 0), (200, 0)] if SMOKE else list(lab.draws())
BATCH = 512
TEMP = 0.1
PROTO_SCALE = 10.0
PROTO_K = (5, 25)
PROTO_Q = 50
CORAL_W = 1.0
DA_BATCH = 300 # Amsterdam batch for CORAL ~ size of a Madrid episode
COS_SCALE = 16.0
LR, WD = 1e-3, 1e-4
# heads
K_MIX, S_PN = 5.0, 20.0
SHRINK, RIDGE = 50.0, 1e-3
TPN_IT, TPN_CAP = 10, 5.0
KNN_K = 15
FT_STEPS, FT_LR, FT_L2SP = 100, 1e-3, 1e-2
MC = lab.CACHE / "metric"
MC.mkdir(parents=True, exist_ok=True)
def mname(enc, head):
return f"met_{enc}_{head}{SFX}"
WANT = {e: [h for h in HEADS if args.only is None or args.only in f"met_{e}_{h}"] for e in ENCODERS}
WANT = {e: hs for e, hs in WANT.items() if hs}
# data ------------------------------------------------------------------------- #
d = lab.load(official60=False)
ym, pm = d["ym"], d["pm"]
Xm = lab.robustz(d["Xm"])
Xa = lab.robustz(d["Xa"])
del d["Xa"] # raw Madrid kept for the selection split
nM, nA = len(Xm), len(Xa)
log(f"loaded Madrid {Xm.shape} Amsterdam {Xa.shape}; encoders {list(WANT)}")
torch = lab.torch_setup(0)
nn = torch.nn
F = torch.nn.functional
# --------------------------------------------------------------------------- #
# Encoder + losses
# --------------------------------------------------------------------------- #
class Enc(nn.Module):
def __init__(self, din=393):
super().__init__()
self.body = nn.Sequential(
nn.Linear(din, 256), nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(0.2),
nn.Linear(256, 256), nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(0.2))
self.proj = nn.Linear(256, 64)
def forward(self, x):
return self.proj(self.body(x))
def supcon_loss(z, y, T=TEMP):
z = F.normalize(z, dim=1)
sim = z @ z.T / T
n = len(y)
eye = torch.eye(n, dtype=torch.bool)
sim = sim.masked_fill(eye, -1e9)
logp = sim - torch.logsumexp(sim, 1, keepdim=True)
pos = (y[:, None] == y[None, :]) & ~eye
return -((logp * pos).sum(1) / pos.sum(1).clamp(min=1)).mean()
def proto_loss(zs, ys, zq, yq, scale=PROTO_SCALE, ncls=4):
zs, zq = F.normalize(zs, dim=1), F.normalize(zq, dim=1)
P = torch.stack([zs[ys == c].mean(0) for c in range(ncls)])
d2 = ((zq[:, None, :] - P[None]) ** 2).sum(-1)
return F.cross_entropy(-scale * d2, yq)
def coral(a, b):
def cov(x):
x = x - x.mean(0, keepdim=True)
return x.T @ x / (len(x) - 1)
dd = a.shape[1]
return ((cov(a) - cov(b)) ** 2).sum() / (4 * dd * dd)
class Sampler:
"""Class-balanced batches and cross-block episodes over one labelled pool."""
def __init__(self, y, p, rng):
self.y, self.rng = y, rng
self.cls = [np.flatnonzero(y == c) for c in lab.CLASSES]
b = lab.block_ids(p)
_, self.code = np.unique(b, return_inverse=True)
self.nb = self.code.max() + 1
self.ccode = [self.code[ix] for ix in self.cls]
def batch(self, size=BATCH):
m = size // 4
return np.concatenate([ix[self.rng.integers(0, len(ix), m)] for ix in self.cls])
def episode(self, k=None):
"""Cross-block episode. Sampling is with replacement inside pools of thousands of
pixels (duplicates are rare; it avoids a full permutation per class per step)."""
k = k or int(self.rng.choice(PROTO_K))
half = self.rng.random(self.nb) < 0.5
S, Q = [], []
for ix, cc in zip(self.cls, self.ccode):
m = half[cc]
a, b = ix[m], ix[~m]
if len(a) < k or len(b) < PROTO_Q: # degenerate split: fall back to any pixel
a = b = ix
S.append(a[self.rng.integers(0, len(a), k)])
Q.append(b[self.rng.integers(0, len(b), PROTO_Q)])
ys = np.repeat(np.arange(4), k)
yq = np.repeat(np.arange(4), PROTO_Q)
return np.concatenate(S), ys, np.concatenate(Q), yq
@torch.no_grad()
def embed_H(model, X, bs=8192):
model.eval()
out = [model.body(torch.from_numpy(X[i:i + bs])).numpy() for i in range(0, len(X), bs)]
return np.vstack(out).astype(np.float32)
def train_encoder(kind, X, y, p, Xt, lengths, seed, eval_fn=None):
"""Constant-LR training; returns (model, {length: eval score}) with checkpoints at
`lengths` (the model returned is the one at max(lengths))."""
torch.manual_seed(seed)
rng = np.random.default_rng(seed)
model = Enc(X.shape[1])
params = list(model.parameters())
Wc = None
if kind == "cos":
Wc = nn.Parameter(torch.randn(4, 64) * 0.1)
params.append(Wc)
opt = torch.optim.AdamW(params, lr=LR, weight_decay=WD)
smp = Sampler(y, p, rng)
Xt_t = torch.from_numpy(X)
yt = torch.from_numpy(y - 1)
scores = {}
t0 = time.time()
for step in range(1, max(lengths) + 1):
model.train()
if kind in ("supcon", "cos"):
ix = smp.batch()
z = model(Xt_t[ix])
if kind == "supcon":
loss = supcon_loss(z, yt[ix])
else:
logits = COS_SCALE * F.normalize(z, dim=1) @ F.normalize(Wc, dim=1).T
loss = F.cross_entropy(logits, yt[ix])
else:
S, ys, Q, yq = smp.episode()
z = model(Xt_t[np.concatenate([S, Q])])
zs, zq = z[:len(S)], z[len(S):]
loss = proto_loss(zs, torch.from_numpy(ys), zq, torch.from_numpy(yq))
if kind == "protoda":
it = rng.integers(0, len(Xt), DA_BATCH)
zt = model(torch.from_numpy(Xt[it]))
loss = loss + CORAL_W * coral(z, zt)
opt.zero_grad()
loss.backward()
opt.step()
if step in lengths:
if eval_fn is not None:
scores[step] = eval_fn(model)
log(f" {kind} seed{seed} step {step}: loss {loss.item():.4f} "
f"pseudo-target F1 {scores[step]:.4f} ({time.time() - t0:.0f}s)")
else:
log(f" {kind} seed{seed} step {step}: loss {loss.item():.4f} ({time.time() - t0:.0f}s)")
return model, scores
# --------------------------------------------------------------------------- #
# Embedding utilities
# --------------------------------------------------------------------------- #
def l2n(Z):
return Z / np.maximum(np.linalg.norm(Z, axis=1, keepdims=True), 1e-12)
def cl2n(Z):
"""SimpleShot CL2N with this city's own mean (label-free)."""
E = l2n(np.asarray(Z, np.float64))
return l2n(E - E.mean(0))
def macro_f1(y, yhat):
from sklearn.metrics import f1_score
return float(f1_score(y, yhat, average="macro"))
def proto_f1_eval(Xpt, ypt, ndraw=SEL_DRAWS, shots=25):
"""Pseudo-target score: 25-shot pure prototype classifier on CL2N embeddings."""
rs = np.random.default_rng(7)
draws_ = []
for _ in range(ndraw):
sh = np.concatenate([rs.choice(np.flatnonzero(ypt == c), shots, replace=False) for c in lab.CLASSES])
draws_.append(sh)
def fn(model):
H = embed_H(model, Xpt)
with torch.no_grad():
Z = model.proj(torch.from_numpy(H)).numpy()
E = cl2n(Z)
out = []
for sh in draws_:
P = np.stack([E[sh][ypt[sh] == c].mean(0) for c in lab.CLASSES])
keep = np.setdiff1d(np.arange(len(E)), sh)
pred = lab.CLASSES[(-((E[keep, None, :] - P[None]) ** 2).sum(-1)).argmax(1)]
out.append(macro_f1(ypt[keep], pred))
return float(np.mean(out))
return fn
def select_length(kind):
p = MC / f"{kind}_select{SFX}.json"
if p.exists():
return json.loads(p.read_text())
src, pt = lab.madrid_split(pm)
Xs, Xpt = lab.robustz(d["Xm"][src]), lab.robustz(d["Xm"][pt])
log(f"{kind}: selecting length on Madrid split (src {len(src)}, pseudo-target {len(pt)}) "
f"from {CAND[kind]}")
# protoda: the unlabelled 'target' of the selection run is the pseudo-target itself
_, sc = train_encoder(kind, Xs, ym[src], pm[src], Xpt, CAND[kind], SEL_SEED,
eval_fn=proto_f1_eval(Xpt, ym[pt]))
best = max(sc, key=lambda s: (sc[s], -s))
res = {"candidates": CAND[kind], "pseudo_target_f1": {str(k): v for k, v in sc.items()},
"chosen": int(best), "seed": SEL_SEED, "split": "lab.madrid_split(pm) default",
"da_target_in_selection": "pseudo-target (unlabelled)" if kind == "protoda" else None}
p.write_text(json.dumps(res, indent=1))
log(f"{kind}: chose {best} steps")
return res
def encoder_cache(kind, seed, length):
p = MC / f"{kind}_s{seed}_L{length}{SFX}.npz"
if p.exists():
z = np.load(p)
return {k: z[k] for k in z.files}
log(f"{kind}: training seed {seed} on all Madrid for {length} steps")
model, _ = train_encoder(kind, Xm, ym, pm, Xa, [length], seed)
out = dict(Hm=embed_H(model, Xm), Ha=embed_H(model, Xa),
W=model.proj.weight.detach().numpy().copy(), b=model.proj.bias.detach().numpy().copy())
np.savez(p, **out)
return out
# --------------------------------------------------------------------------- #
# Heads
# --------------------------------------------------------------------------- #
def class_means(E, y):
return np.stack([E[y == c].mean(0) for c in lab.CLASSES])
def within_cov(E, y):
R = np.vstack([E[y == c] - E[y == c].mean(0) for c in lab.CLASSES])
return R.T @ R / max(len(R) - 4, 1)
def pn_protos(Ea, sh, ysh, mu_m):
P = []
for j, c in enumerate(lab.CLASSES):
S = Ea[sh[ysh == c]]
P.append((len(S) * S.mean(0) + K_MIX * mu_m[j]) / (len(S) + K_MIX))
return np.stack(P)
def sqd(E, P):
return (E ** 2).sum(1)[:, None] - 2 * E @ P.T + (P ** 2).sum(1)[None]
def head_pn(Ea, sh, ysh, mu_m):
return lab.softmax(-S_PN * sqd(Ea, pn_protos(Ea, sh, ysh, mu_m)))
def head_maha(Ea, sh, ysh, mu_m, S_m):
from scipy.linalg import cho_factor, cho_solve
P = pn_protos(Ea, sh, ysh, mu_m)
nt = len(sh)
S = (nt * within_cov(Ea[sh], ysh) + SHRINK * S_m) / (nt + SHRINK) + RIDGE * np.eye(Ea.shape[1])
cf = cho_factor(S)
D = np.empty((len(Ea), 4))
for j in range(4):
R = Ea - P[j]
D[:, j] = (R * cho_solve(cf, R.T).T).sum(1)
return lab.softmax(-0.5 * D)
def head_tpn(Ea, sh, ysh, mu_m):
P = pn_protos(Ea, sh, ysh, mu_m)
unl = np.setdiff1d(np.arange(len(Ea)), sh)
Eu = Ea[unl]
sums = np.stack([Ea[sh[ysh == c]].sum(0) for c in lab.CLASSES])
nc = np.array([(ysh == c).sum() for c in lab.CLASSES], float)
for _ in range(TPN_IT):
R = lab.softmax(-S_PN * sqd(Eu, P)) # soft assignments of the unlabelled pool
mass = R.sum(0)
w = np.minimum(1.0, TPN_CAP * nc / np.maximum(mass, 1e-12))
P = (sums + w[:, None] * (R.T @ Eu)) / (nc + w * mass)[:, None]
return lab.softmax(-S_PN * sqd(Ea, P))
def head_knn(Ea, sh, ysh, mu_m, n):
if n < 25:
return head_pn(Ea, sh, ysh, mu_m)
Es = Ea[sh]
C = Ea @ Es.T # cosine (unit vectors)
k = min(KNN_K, len(sh))
nb = np.argpartition(-C, k - 1, axis=1)[:, :k]
cs = np.take_along_axis(C, nb, 1)
w = 1.0 / (1.0 - cs + 1e-3) # inverse cosine distance
lab_nb = ysh[nb] - 1
P = np.zeros((len(Ea), 4))
for j in range(4):
P[:, j] = (w * (lab_nb == j)).sum(1)
P += 1e-3
return P / P.sum(1, keepdims=True)
def head_ftproj(enc, Hm, Ha, W0, b0, sh, ysh, n, seed):
"""Fine-tune ONLY the projection Linear(256,64) on cached frozen activations."""
torch.manual_seed(seed + 1000 * n)
rng = np.random.default_rng(seed + 1000 * n)
Wt = torch.tensor(W0, requires_grad=True)
bt = torch.tensor(b0, requires_grad=True)
W0t, b0t = torch.tensor(W0), torch.tensor(b0)
opt = torch.optim.Adam([Wt, bt], lr=FT_LR)
Hs = torch.from_numpy(Ha[sh])
Hm_t = torch.from_numpy(Hm)
smp = MAD_SMP
smp.rng = rng
cls_ix = [np.flatnonzero(ysh == c) for c in lab.CLASSES]
for _ in range(FT_STEPS):
S, Q = [], []
for ix in cls_ix:
pr = rng.permutation(ix)
ns = 3 if len(ix) <= 5 else len(ix) // 2
S.append(pr[:ns]); Q.append(pr[ns:])
ys = torch.from_numpy(np.concatenate([np.full(len(s), j) for j, s in enumerate(S)]))
yq = torch.from_numpy(np.concatenate([np.full(len(q), j) for j, q in enumerate(Q)]))
S, Q = np.concatenate(S), np.concatenate(Q)
loss = proto_loss(Hs[S] @ Wt.T + bt, ys, Hs[Q] @ Wt.T + bt, yq)
mS, mys, mQ, myq = smp.episode()
loss = loss + proto_loss(Hm_t[mS] @ Wt.T + bt, torch.from_numpy(mys),
Hm_t[mQ] @ Wt.T + bt, torch.from_numpy(myq))
loss = loss + FT_L2SP * (((Wt - W0t) ** 2).sum() + ((bt - b0t) ** 2).sum())
opt.zero_grad()
loss.backward()
opt.step()
W, b = Wt.detach().numpy(), bt.detach().numpy()
Em, Ea = cl2n(Hm @ W.T + b), cl2n(Ha @ W.T + b)
return head_pn(Ea, sh, ysh, class_means(Em, ym))
# --------------------------------------------------------------------------- #
# Main loop
# --------------------------------------------------------------------------- #
def meta_for(enc, head, sel):
return dict(family="metric", encoder=enc, head=head, smoke=SMOKE,
seeds=SEEDS[:FT_SEEDS] if head == "ftproj" else SEEDS,
input="lab.robustz per city (clip 10)",
encoder_arch="393-256-BN-ReLU-Do0.2-256-BN-ReLU-Do0.2-64, L2-normalised",
optim=f"AdamW lr {LR} wd {WD}, constant LR",
encoder_cfg=dict(batch=BATCH, supcon_T=TEMP, proto_scale=PROTO_SCALE, proto_k=PROTO_K,
proto_q=PROTO_Q, cross_block="2 km blocks random halves per episode",
coral_w=CORAL_W, da_batch=DA_BATCH, cos_scale=COS_SCALE,
cos_balance="class-balanced batches (128 per class), plain CE"),
length_selection=sel,
madrid_choices=f"training steps chosen on lab.madrid_split pseudo-target: {sel['chosen']}",
head_cfg=dict(CL2N="per-city mean, re-normalised", k_mix=K_MIX, s=S_PN, shrink=SHRINK,
ridge=RIDGE, tpn_iters=TPN_IT, tpn_cap=TPN_CAP, knn_k=KNN_K,
knn_weight="1/(1-cos+1e-3)", knn_n5="pn fallback",
ft_steps=FT_STEPS, ft_lr=FT_LR, ft_l2sp=FT_L2SP,
ft_episode="shots half/half (n=5: 3/2) + one Madrid cross-block episode"),
hyperparams_from_amsterdam="none")
MAD_SMP = None
def main():
global MAD_SMP
MAD_SMP = Sampler(ym, pm, np.random.default_rng(0))
for enc, heads in WANT.items():
todo = [(n, r) for n, r in DRAWS if not all(lab.have_pred(mname(enc, h), n, r) for h in heads)]
if not todo:
log(f"{enc}: all predictions exist, skipping")
continue
sel = select_length(enc)
L = sel["chosen"]
caches = [encoder_cache(enc, s, L) for s in SEEDS]
prep = []
for c in caches:
Zm, Za = c["Hm"] @ c["W"].T + c["b"], c["Ha"] @ c["W"].T + c["b"]
Em, Ea = cl2n(Zm), cl2n(Za)
prep.append(dict(Ea=Ea, mu=class_means(Em, ym), Sm=within_cov(Em, ym)))
del Em
metas = {h: meta_for(enc, h, sel) for h in heads}
for n, r in todo:
t0 = time.time()
sh = lab.shots_for(d, n, r)
ysh = lab.shot_labels(d, sh)
for h in heads:
if lab.have_pred(mname(enc, h), n, r):
continue
Ps = []
for si, (c, pp) in enumerate(zip(caches, prep)):
if h == "ftproj" and si >= FT_SEEDS:
break
Ea, mu = pp["Ea"], pp["mu"]
if h == "pn":
P = head_pn(Ea, sh, ysh, mu)
elif h == "maha":
P = head_maha(Ea, sh, ysh, mu, pp["Sm"])
elif h == "tpn":
P = head_tpn(Ea, sh, ysh, mu)
elif h == "knn":
P = head_knn(Ea, sh, ysh, mu, n)
else:
P = head_ftproj(enc, c["Hm"], c["Ha"], c["W"], c["b"], sh, ysh, n, SEEDS[si] + 10 * r)
Ps.append(P)
lab.save_pred(mname(enc, h), n, r, np.mean(Ps, 0), metas[h])
log(f"{enc} n={n} rep={r}: saved {len(heads)} heads ({time.time() - t0:.1f}s)")
del caches, prep
log("done")
if __name__ == "__main__":
main()