Karar-git's picture
Add FINAL-2 pipeline, overnight search scripts and results (11 Nov 2026)
fe9543f verified
Raw History Blame Contribute Delete
22 kB
"""
tab_nn - tabular neural networks with a FROZEN backbone and last-layer adaptation.
python tools/nnlab/tab_nn.py [--smoke] [--only <substring>]
Backbones (trained ONCE on all Madrid labels, class-balanced CE, AdamW lr 1e-3,
wd 1e-4, batch 512, seeds 0,1,2):
mlp Linear-BN-ReLU-Drop(0.2) x2 (256) -> Linear(64)-ReLU -> Linear(64,64)-ReLU -> Linear(64,4)
res ResNet-MLP (Gorishniy et al. 2021): Linear(256), 3 x [x + Lin(Drop(ReLU(Lin(BN(x)))))],
BN-ReLU -> Linear(64)-ReLU -> Linear(64,4)
dann mlp + domain head (Lin(64,64)-ReLU-Lin(64,1)) on the first 64-d features through
gradient reversal, lambda = 2/(1+exp(-10p))-1, adversary vs UNLABELLED Amsterdam rows
(as tools/compare_teammate.py train_dann; Madrid and Amsterdam batches go through
the net separately)
coral mlp + deep-CORAL loss (weight 1.0) between the penultimate features of a Madrid
batch and an unlabelled Amsterdam batch
Inputs: rz = 393 feats lab.robustz per city; rg = 393 feats lab.rankgauss per city;
o60 = official 60 feats lab.standardise per city.
The EMBEDDING is the input of the final Linear(.,4); (W0, b0) are that layer's weights.
Heads per protocol draw (probabilities averaged over the 3 seeds before saving):
zs source head unchanged (saved once, n=0 rep=0)
ft lab.finetune_last_layer(W0, b0, Z_shot, y_shot, lam) lam chosen on Madrid
joint the objective of lab.joint_head(Z_madrid, y_madrid, Z_shot, y_shot, shot_weight=30)
(same weights, C=1) on embeddings standardised with the Madrid embedding mean/sd
(same affine map for the shots), solved with newton-cholesky warm-started from
the Madrid-only fit (see head_joint: lab.joint_head's lbfgs does not converge
in 400 iterations and would cost ~2 h over the run)
proto prototypes (n_c * shot mean + k * Madrid class mean)/(n_c + k), k=10, embeddings
centred by their own city's mean and L2-normalised; P = softmax(-20 * sq. distance)
Method names: tab_{backbone}_{input}_{head}. "rg" is run for mlp and dann only (time budget),
so 10 backbone x input combos x 4 heads = 40 methods.
Madrid-only choices (lab.madrid_split, pseudo-target = the 25 % of 2 km blocks furthest
along axis 0; transforms computed separately on the source part and on the pseudo-target,
exactly as they are per city; adversary / CORAL target = the unlabelled pseudo-target):
* epochs per backbone type from {15, 30, 45}: input rz, seed 0, trained on the source
part, scored by macro F1 of the decision rule (W.decide, source-part prior) on the
pseudo-target. Non-adversarial nets: one 45-epoch run evaluated after 15/30/45 epochs
(identical to separate runs: constant lr, same seed). dann: separate runs, since its
lambda schedule depends on the total epoch count.
* lam for ft, ONE value for all backbones and n, from {0.1, 1, 10, 100}: on each
backbone's selected source-part net (rz, seed 0), draw n in {5, 50} per class from the
pseudo-target (5 draws, rng(7000 + 100 n + rep)), fine-tune, score the remaining
pseudo-target with the rule; pick the lam with the best mean over backbones x n x draws.
Both are cached in CACHE/tab_nn/selection.json and copied into every meta.json.
CACHE LAYOUT (lab.CACHE / "tab_nn", or "tab_nn_SMOKE" for --smoke):
selection.json epochs per backbone, lam, all pseudo-target scores
sel_{backbone}.npz source-part (madrid_split) net at the chosen epochs:
Zs, Zt (source / pseudo-target embeddings), W0, b0
emb_{backbone}_{input}_s{seed}.npz one file per (backbone, input, seed); inputs rz,o60
for all backbones, rg for mlp and dann; seeds 0,1,2:
Zm (76263, 64) float32 Madrid embeddings (feature-table row order, eval mode)
Za (25992, 64) float32 Amsterdam embeddings (feature-table row order)
W0 (4, 64) float32 final layer weight; b0 (4,) final layer bias
epochs () int epochs it was trained for
Logits = Z @ W0.T + b0, columns = classes 1..4.
"""
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
W = lab.W
T0 = time.time()
BACKBONES = ("mlp", "res", "dann", "coral")
INPUTS = ("rz", "rg", "o60")
# The full run on 3 shared CPU threads is far above 60 min, so per the brief the
# "rg" input is dropped for "res" and "coral" (10 backbone x input combos, 40 methods).
INPUTS_FOR = {"mlp": INPUTS, "dann": INPUTS, "res": ("rz", "o60"), "coral": ("rz", "o60")}
HEADS = ("zs", "ft", "joint", "proto")
SEEDS = (0, 1, 2)
EPOCH_GRID = (15, 30, 45)
LAM_GRID = (0.1, 1.0, 10.0, 100.0)
LAM_NS = (5, 50)
LAM_REPS = 5
SHOT_WEIGHT = 30.0
PROTO_K, PROTO_T = 10.0, 20.0
CORAL_W = 1.0
BATCH, LR, WD = 512, 1e-3, 1e-4
def log(msg):
print(f"[{time.time() - T0:7.1f}s] {msg}", flush=True)
# --------------------------------------------------------------------------- #
# Networks
# --------------------------------------------------------------------------- #
def build_net(kind: str, d_in: int):
import torch.nn as nn
from torch.autograd import Function
class GradReverse(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 ResBlock(nn.Module):
def __init__(self, h, p=0.2):
super().__init__()
self.f = nn.Sequential(nn.BatchNorm1d(h), nn.Linear(h, h), nn.ReLU(), nn.Dropout(p), nn.Linear(h, h))
def forward(self, x):
return x + self.f(x)
class Net(nn.Module):
def __init__(self):
super().__init__()
if kind == "res":
self.f1 = nn.Sequential(nn.Linear(d_in, 256), ResBlock(256), ResBlock(256), ResBlock(256),
nn.BatchNorm1d(256), nn.ReLU(), nn.Linear(256, 64), nn.ReLU())
self.f2 = nn.Identity()
else:
self.f1 = nn.Sequential(nn.Linear(d_in, 256), nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(0.2),
nn.Linear(256, 256), nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(0.2),
nn.Linear(256, 64), nn.ReLU())
self.f2 = nn.Sequential(nn.Linear(64, 64), nn.ReLU())
self.head = nn.Linear(64, 4)
self.dom = nn.Sequential(nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 1)) if kind == "dann" else None
def forward(self, x, lam=0.0):
f = self.f1(x)
z = self.f2(f)
dl = self.dom(GradReverse.apply(f, lam)) if self.dom is not None else None
return self.head(z), z, dl
return Net()
def coral_loss(a, b):
import torch
d = a.shape[1]
def cov(x):
x = x - x.mean(0, keepdim=True)
return x.T @ x / max(len(x) - 1, 1)
return ((cov(a) - cov(b)) ** 2).sum() / (4 * d * d)
SMOKE_ROWS = None # --smoke: train on a random subset of this many rows
def train(kind, Xs, ys, Xt, epochs, seed, eval_at=(), eval_fn=None):
"""Train a backbone. Xt = unlabelled target rows (dann / coral). If eval_at is
given, eval_fn(net) is called after those epochs; returns (net, {ep: result})."""
torch = lab.torch_setup(seed)
if SMOKE_ROWS and len(Xs) > SMOKE_ROWS:
sub = np.random.default_rng(seed).choice(len(Xs), SMOKE_ROWS, replace=False)
Xs, ys = Xs[sub], np.asarray(ys)[sub]
import torch.nn as nn
net = build_net(kind, Xs.shape[1])
opt = torch.optim.AdamW(net.parameters(), lr=LR, weight_decay=WD)
cw = torch.tensor(lab.balanced_class_weights(ys), dtype=torch.float32)
ce, bce = nn.CrossEntropyLoss(weight=cw), nn.BCEWithLogitsLoss()
S, Y = torch.from_numpy(np.ascontiguousarray(Xs, np.float32)), torch.from_numpy(np.asarray(ys) - 1).long()
T = torch.from_numpy(np.ascontiguousarray(Xt, np.float32))
g = torch.Generator().manual_seed(1000 + seed)
steps = int(np.ceil(len(S) / BATCH))
res = {}
for ep in range(epochs):
net.train()
perm = torch.randperm(len(S), generator=g)
for k in range(steps):
i = perm[k * BATCH:(k + 1) * BATCH]
if len(i) < 2:
continue
if kind == "dann":
p = (ep * steps + k) / (epochs * steps)
lam = 2 / (1 + np.exp(-10 * p)) - 1
j = torch.randint(0, len(T), (len(i),), generator=g)
ls, _, ds = net(S[i], lam)
_, _, dt = net(T[j], lam)
loss = ce(ls, Y[i]) + bce(ds.squeeze(1), torch.zeros(len(i))) + bce(dt.squeeze(1), torch.ones(len(j)))
elif kind == "coral":
j = torch.randint(0, len(T), (len(i),), generator=g)
ls, zs, _ = net(S[i])
_, zt, _ = net(T[j])
loss = ce(ls, Y[i]) + CORAL_W * coral_loss(zs, zt)
else:
ls, _, _ = net(S[i])
loss = ce(ls, Y[i])
opt.zero_grad()
loss.backward()
opt.step()
if ep + 1 in eval_at:
res[ep + 1] = eval_fn(net)
net.eval()
return net, res
def embed(net, X):
import torch
net.eval()
out = []
with torch.no_grad():
for k in range(0, len(X), 8192):
out.append(net(torch.from_numpy(np.ascontiguousarray(X[k:k + 8192], np.float32)))[1].numpy())
return np.vstack(out).astype(np.float32)
def last_layer(net):
return (net.head.weight.detach().numpy().astype(np.float32).copy(),
net.head.bias.detach().numpy().astype(np.float32).copy())
# --------------------------------------------------------------------------- #
# Inputs
# --------------------------------------------------------------------------- #
def _by_columns(f, chunk=48):
"""Apply a per-column lab transform in column blocks: identical output (every lab
transform is column-wise), but bounds the float64 / rank temporaries - lab.rankgauss on
the full 76k x 393 Madrid matrix alone peaks at ~1.5 GB extra (reviewer fix, RAM cap)."""
def g(X):
return np.hstack([f(X[:, k:k + chunk]) for k in range(0, X.shape[1], chunk)])
return g
TRANSFORM = {"rz": _by_columns(lab.robustz), "rg": _by_columns(lab.rankgauss), "o60": lab.standardise}
def raw_inputs(d, inp):
return (d["Xm60"], d["Xa60"]) if inp == "o60" else (d["Xm"], d["Xa"])
_INPUT_CACHE = {}
def city_inputs(d, inp):
"""Per-city transformed inputs, kept in memory for the run (~150 MB per 393-feat input)."""
if inp not in _INPUT_CACHE:
Xm, Xa = raw_inputs(d, inp)
f = TRANSFORM[inp]
_INPUT_CACHE[inp] = (f(Xm), f(Xa))
return _INPUT_CACHE[inp]
# --------------------------------------------------------------------------- #
# Heads
# --------------------------------------------------------------------------- #
def head_ft(e, Zs, ys, lam):
Wf, bf = lab.finetune_last_layer(e["W0"], e["b0"], Zs, ys, lam)
return lab.softmax(e["Za"].astype(np.float64) @ Wf.T + bf)
def joint_init(e):
"""Standardise embeddings with the Madrid mean/sd and fit the Madrid-only
weighted LR once per seed (warm start for every draw's joint head)."""
from sklearn.linear_model import LogisticRegression
if "_Zm_std" not in e:
mu, sd = e["Zm"].mean(0), e["Zm"].std(0)
sd = np.where(sd > 1e-8, sd, 1.0)
e["_Zm_std"] = ((e["Zm"] - mu) / sd).astype(np.float64)
e["_Za_std"] = ((e["Za"] - mu) / sd).astype(np.float64)
w = lab.balanced_class_weights(e["ym"])[e["ym"] - 1]
e["_m0"] = LogisticRegression(C=1.0, solver="newton-cholesky", max_iter=100).fit(
e["_Zm_std"], e["ym"], sample_weight=w)
return e
def head_joint(e, shot, ys):
"""Same objective as lab.joint_head(Zm, ym, Zs, ys, shot_weight=30, C=1)
(identical sample weights), solved with sklearn's newton-cholesky solver
warm-started from the Madrid-only fit: lab.joint_head's lbfgs stops at
max_iter=400 unconverged on these embeddings and takes ~9 s per fit; this
converges in 2-4 Newton steps (~2 s) and lands closer to the true optimum."""
from sklearn.linear_model import LogisticRegression
e = joint_init(e)
Zm, Za = e["_Zm_std"], e["_Za_std"]
X = np.vstack([Zm, Za[shot]])
y = np.r_[np.asarray(e["ym"]), np.asarray(ys)]
w = np.r_[np.ones(len(e["ym"])), np.full(len(ys), SHOT_WEIGHT)]
w = w * lab.balanced_class_weights(y)[y - 1]
m = LogisticRegression(C=1.0, solver="newton-cholesky", max_iter=100, warm_start=True)
m.coef_, m.intercept_ = e["_m0"].coef_.copy(), e["_m0"].intercept_.copy()
m.fit(X, y, sample_weight=w)
return m.predict_proba(Za)
def _unit(Z):
Z = Z - Z.mean(0)
return Z / np.clip(np.linalg.norm(Z, axis=1, keepdims=True), 1e-12, None)
def head_proto(e, shot, ys):
if "_Um" not in e: # computed once per seed (setdefault would recompute every call)
e["_Um"], e["_Ua"] = _unit(e["Zm"].astype(np.float64)), _unit(e["Za"].astype(np.float64))
Um, Ua = e["_Um"], e["_Ua"]
protos = []
for c in lab.CLASSES:
mm = Um[e["ym"] == c].mean(0)
sc = Ua[shot[ys == c]]
nc = len(sc)
protos.append((nc * sc.mean(0) + PROTO_K * mm) / (nc + PROTO_K) if nc else mm)
Pr = np.stack(protos)
d2 = (Ua ** 2).sum(1)[:, None] - 2 * Ua @ Pr.T + (Pr ** 2).sum(1)[None]
return lab.softmax(-PROTO_T * d2)
# --------------------------------------------------------------------------- #
# Madrid-only selection
# --------------------------------------------------------------------------- #
def selection(d, cache, smoke):
path = cache / "selection.json"
sel = json.loads(path.read_text()) if path.exists() else {"epochs": {}, "epoch_scores": {}}
grid = (1, 2) if smoke else EPOCH_GRID
src, tgt = lab.madrid_split(d["pm"])
Xs, Xt = TRANSFORM["rz"](d["Xm"][src]), TRANSFORM["rz"](d["Xm"][tgt])
ys, yt = d["ym"][src], d["ym"][tgt]
prior = np.array([(ys == c).mean() for c in lab.CLASSES])
def f1(net):
P = lab.softmax(embed(net, Xt).astype(np.float64) @ last_layer(net)[0].T.astype(np.float64)
+ last_layer(net)[1])
return W.macro_f1(yt, W.decide(P, prior))
emb = {}
for kind in BACKBONES:
ep_path = cache / f"sel_{kind}.npz"
if kind in sel["epochs"] and ep_path.exists():
z = np.load(ep_path)
emb[kind] = dict(Zs=z["Zs"], Zt=z["Zt"], W0=z["W0"], b0=z["b0"])
continue
t0 = time.time()
nets = {}
if kind == "dann":
scores = {}
for E in grid:
net, _ = train(kind, Xs, ys, Xt, E, 0)
scores[E], nets[E] = f1(net), (embed(net, Xs), embed(net, Xt), *last_layer(net))
else:
snap = {}
def ev(net):
r = f1(net)
snap[len(snap)] = (embed(net, Xs), embed(net, Xt), *last_layer(net))
net.train()
return r
_, scores = train(kind, Xs, ys, Xt, max(grid), 0, eval_at=grid, eval_fn=ev)
for k, E in enumerate(grid):
nets[E] = snap[k]
best = max(grid, key=lambda E: (scores[E], -E))
sel["epochs"][kind] = best
sel["epoch_scores"][kind] = {str(k): v for k, v in scores.items()}
Zs_, Zt_, W0, b0 = nets[best]
np.savez(ep_path, Zs=Zs_, Zt=Zt_, W0=W0, b0=b0)
emb[kind] = dict(Zs=Zs_, Zt=Zt_, W0=W0, b0=b0)
path.write_text(json.dumps(sel, indent=1))
log(f"select epochs {kind}: pseudo-target rule F1 {scores} -> {best} ({time.time() - t0:.0f}s)")
if "lam" not in sel:
t0 = time.time()
reps = 1 if smoke else LAM_REPS
tab = {str(l): [] for l in LAM_GRID}
per = {}
for kind in BACKBONES:
e = emb[kind]
for n in LAM_NS:
for rep in range(reps):
rng = np.random.default_rng(7000 + 100 * 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 l in LAM_GRID:
Wf, bf = lab.finetune_last_layer(e["W0"], e["b0"], e["Zt"][shot], yt[shot], l)
P = lab.softmax(e["Zt"][keep].astype(np.float64) @ Wf.T + bf)
s = W.macro_f1(yt[keep], W.decide(P, prior))
tab[str(l)].append(s)
per.setdefault(f"{kind}_n{n}_lam{l}", []).append(s)
means = {l: float(np.mean(v)) for l, v in tab.items()}
sel["lam"] = float(max(LAM_GRID, key=lambda l: (means[str(l)], l)))
sel["lam_scores"] = means
sel["lam_scores_detail"] = {k: float(np.mean(v)) for k, v in per.items()}
path.write_text(json.dumps(sel, indent=1))
log(f"select lam: {means} -> {sel['lam']} ({time.time() - t0:.0f}s)")
return sel
# --------------------------------------------------------------------------- #
# Main
# --------------------------------------------------------------------------- #
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--smoke", action="store_true")
ap.add_argument("--only", default="")
ap.add_argument("--seeds", type=int, default=len(SEEDS),
help="use only the first k of seeds 0,1,2 (default 3; method names unchanged, "
"recorded in meta) - a time-saving fallback, not the planned run")
a = ap.parse_args()
smoke = a.smoke
global SMOKE_ROWS
SMOKE_ROWS = 3000 if smoke else None
sfx = "_SMOKE" if smoke else ""
cache = lab.CACHE / ("tab_nn" + sfx)
cache.mkdir(parents=True, exist_ok=True)
seeds = (0,) if smoke else SEEDS[:a.seeds]
draws = [(5, 0), (200, 0)] if smoke else list(lab.draws())
d = lab.load(official60=True)
log("data loaded")
def mname(bb, inp, h):
return f"tab_{bb}_{inp}_{h}{sfx}"
combos = [(bb, inp) for bb in BACKBONES for inp in INPUTS_FOR[bb]
if any(a.only in mname(bb, inp, h) for h in HEADS)]
sel = selection(d, cache, smoke)
lam = sel["lam"]
for bb, inp in combos:
heads = [h for h in HEADS if a.only in mname(bb, inp, h)]
todo_draws = [(n, r) for n, r in draws
if any(not lab.have_pred(mname(bb, inp, h), n, r) for h in heads if h != "zs")]
need_zs = "zs" in heads and not lab.have_pred(mname(bb, inp, "zs"), 0, 0)
if not todo_draws and not need_zs:
log(f"{bb}/{inp}: all saved, skip")
continue
epochs = int(sel["epochs"][bb])
embs, Xin = [], None
for s in seeds:
f = cache / f"emb_{bb}_{inp}_s{s}.npz"
if f.exists(): # reviewer: never reuse a net trained for another epoch count
with np.load(f) as z0:
stale = int(z0["epochs"]) != epochs
if stale:
log(f"cached {f.name} has a different epoch count than selection.json: retraining")
f.unlink()
if not f.exists():
if Xin is None:
Xin = city_inputs(d, inp)
t0 = time.time()
net, _ = train(bb, Xin[0], d["ym"], Xin[1], epochs, s)
W0, b0 = last_layer(net)
np.savez(f, Zm=embed(net, Xin[0]), Za=embed(net, Xin[1]), W0=W0, b0=b0, epochs=epochs)
log(f"train {bb}/{inp} seed {s}: {epochs} epochs {time.time() - t0:.0f}s")
z = np.load(f)
embs.append(dict(Zm=z["Zm"], Za=z["Za"], W0=z["W0"], b0=z["b0"], ym=d["ym"]))
del Xin
meta = dict(script="tools/nnlab/tab_nn.py", backbone=bb, input=inp, seeds=list(seeds),
epochs=epochs, epoch_choice=sel["epoch_scores"].get(bb), ft_lam=lam,
lam_scores=sel.get("lam_scores"), joint_shot_weight=SHOT_WEIGHT,
proto_k=PROTO_K, proto_temp=PROTO_T, coral_weight=CORAL_W if bb == "coral" else None,
optim=dict(opt="AdamW", lr=LR, wd=WD, batch=BATCH),
joint_solver="newton-cholesky, warm start from Madrid-only fit, C=1",
selection="lab.madrid_split pseudo-target, rule macro F1, input rz seed 0",
smoke=smoke)
if need_zs:
P = np.mean([lab.softmax(e["Za"].astype(np.float64) @ e["W0"].T + e["b0"]) for e in embs], 0)
lab.save_pred(mname(bb, inp, "zs"), 0, 0, P, {**meta, "head": "zs"})
log(f"{mname(bb, inp, 'zs')} n=0 saved")
for n, rep in todo_draws:
t0 = time.time()
shot = lab.shots_for(d, n, rep)
ys = lab.shot_labels(d, shot)
for h in heads:
if h == "zs" or lab.have_pred(mname(bb, inp, h), n, rep):
continue
th = time.time()
if h == "ft":
P = np.mean([head_ft(e, e["Za"][shot], ys, lam) for e in embs], 0)
elif h == "joint":
P = np.mean([head_joint(e, shot, ys) for e in embs], 0)
else:
P = np.mean([head_proto(e, shot, ys) for e in embs], 0)
lab.save_pred(mname(bb, inp, h), n, rep, P, {**meta, "head": h})
log(f"{mname(bb, inp, h)} n={n} rep={rep} {time.time() - th:.1f}s")
del embs
log("done")
if __name__ == "__main__":
main()