Download code/train_gnom.py from wepiqx/MERNIK-gnom: direct link, hf CLI and curl.
- Browser
- Download file 27.4 kB
-
https://huggingface.co/wepiqx/MERNIK-gnom/resolve/main/code/train_gnom.py
- Command line
-
hf download hf://wepiqx/MERNIK-gnom/code/train_gnom.py
-
curl -L -o train_gnom.py https://huggingface.co/wepiqx/MERNIK-gnom/resolve/main/code/train_gnom.py
27.4 kB
| #!/usr/bin/env python3 | |
| """Gnom trainer — KLD era (rewrite 2026-10-16). | |
| train_gnom.py --pool gnom/pool_v4.json --out gnom/models/v4.npz | |
| [--variant trunk|modelbias|moeshared|rank|all] [--seed 7] | |
| [--metric damage_kld] [--tail-feats] | |
| Targets: the KLD family, 5 columns — | |
| damage_kld mean KLD delta (primary, the bar column) | |
| damage_kld_max tail / detonation | |
| damage_kld_median body / skew | |
| damage_dp_rms behavioral shift | |
| damage_same_top top-1 agreement drop | |
| All five arrive in one deterministic forward pass (new contract); the | |
| loader reads any of them by name ('' as multi-target head sets). | |
| DEAD METRICS (measured dead, kept only for archival pools): | |
| damage PPL delta. Q5->Q4 std 0.77 BELOW the ~1.0 noise floor; | |
| LOMO <= 0.06 across models (gnom/README, 2026-09/10). | |
| ssim_damage structural. 4/4 structural metrics rank together and are | |
| orthogonal to KLD/PPL (README SSIM section) — surrogate, | |
| not discovery. No code path here does anything special | |
| for it anyway. | |
| Default --metric is and stays damage_kld. | |
| Pool spec: [{"features": X.npz, "labels": L.jsonl, "model": tag, | |
| "base_bpw": 5.5, "drop_bpw": 4.5, "metric": "damage_kld"}, ...] | |
| - null != 0. A null metric value (TERMINAL/censored record) is counted | |
| and skipped, never imputed (the guard this file has carried since | |
| 2026-09-28, now explicit per-column). | |
| - HOLE records live in *.failures.jsonl and are not read here at all. | |
| - mapping check: every unit that carries a value must map to feature | |
| rows, tensor-complete. "mapping: U/U units, T/T tensors" is printed on | |
| every load; ANY miss prints details and exits 1 (--allow-partial-map | |
| downgrades the unit-with-no-match case to a warning). A pool that | |
| cannot prove its plumbing must not train. | |
| Report is always: random split + LOMO (needs >= 2 models) + timp | |
| baseline, each with its MDE at 80% power (alpha=0.05) next to it. | |
| Bars: LOMO >= 0.45 (regression), rank LOMO-acc >= 0.60 (coin 0.50). | |
| """ | |
| import argparse | |
| import copy | |
| import json | |
| import os | |
| import sys | |
| import numpy as np | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| from tinynet import (init_params, train, forward, standardize, fit_mixed, | |
| mixed_predict, moe_train_shared, moe_predict_shared, | |
| backward_delta) | |
| KLD_COLUMNS = ["damage_kld", "damage_kld_max", "damage_kld_median", | |
| "damage_dp_rms", "damage_same_top"] | |
| # z_{0.975} + z_{0.80}: two-sided alpha 0.05, power 80% | |
| Z_POWER = 1.959963984540054 + 0.8416212335729143 # ~2.8016 | |
| def mde_rho(n): | |
| """MDE for a single Spearman exam at 80% power (Fisher-z approx).""" | |
| return Z_POWER / np.sqrt(n - 3) if n > 3 else float("inf") | |
| def mde_lomo(ns): | |
| """MDE of the MEAN LOMO rho across folds of sizes ns (z-space).""" | |
| ns = [n for n in ns if n > 3] | |
| if not ns: | |
| return float("inf") | |
| se = np.sqrt(sum(1.0 / (n - 3) for n in ns)) / len(ns) | |
| return Z_POWER * se | |
| def mde_pair(n): | |
| """MDE for pair accuracy vs coin 0.50 at 80% power (n pairs).""" | |
| return Z_POWER * 0.5 / np.sqrt(n) if n > 0 else float("inf") | |
| def build_pairs(y, models, max_pairs=30000, seed=7): | |
| """Within-model ordered pairs (a beats b): decisions, not values. | |
| Cross-model pairs are meaningless (damage scales differ per model). | |
| Returns (model, i, j, gap): RDO-style (2024/26 offline-RLHF) — HOW MUCH | |
| one is preferred matters, not just the order. Gap doubles as the | |
| resolution filter: pairs below measurement noise carry ~no weight.""" | |
| rng = np.random.default_rng(seed) | |
| pairs = [] | |
| for m in sorted(set(models.tolist() if hasattr(models, "tolist") else models)): | |
| idx = np.where(np.asarray(models) == m)[0] | |
| yy = y[idx] | |
| scale = float(np.std(yy) + 1e-12) | |
| cand = [(i, j, abs(float(yy[a] - y[j])) / scale) | |
| for a, i in enumerate(idx) for j in idx | |
| if yy[a] > y[j] + 1e-12] | |
| # (dead-zone tried 2026-10-03 and lost with the cap as a package: | |
| # uniform-over-all-pairs 0.575 stands as the best setting.) | |
| cand = [(i, j, g) for (i, j, g) in cand] | |
| if len(cand) > max_pairs: | |
| cand = [cand[k] for k in rng.choice(len(cand), max_pairs, replace=False)] | |
| pairs += [(m, i, j, g) for (i, j, g) in cand] | |
| return pairs | |
| def pair_acc(scores, y, models, idx): | |
| """Fraction of within-model pairs ordered correctly.""" | |
| idx = np.asarray(idx) | |
| tot, hit = 0, 0 | |
| for m in set(np.asarray(models)[idx].tolist()): | |
| ii = idx[np.asarray(models)[idx] == m] | |
| yy = y[ii] | |
| ss = np.asarray(scores)[ii] | |
| for a in range(len(ii)): | |
| for b in range(len(ii)): | |
| if yy[a] > yy[b] + 1e-12: | |
| tot += 1 | |
| if ss[a] > ss[b]: | |
| hit += 1 | |
| return hit / tot if tot else 0.5 | |
| def pair_count(y, models, idx): | |
| """Number of within-model ordered pairs in the eval set (for MDE).""" | |
| idx = np.asarray(idx) | |
| tot = 0 | |
| for m in set(np.asarray(models)[idx].tolist()): | |
| ii = idx[np.asarray(models)[idx] == m] | |
| yy = y[ii] | |
| for a in range(len(ii)): | |
| for b in range(len(ii)): | |
| if yy[a] > yy[b] + 1e-12: | |
| tot += 1 | |
| return tot | |
| def train_rank(ps, X, pairs_idx, lr=3e-3, epochs=1500, l2=1e-5, seed=7, | |
| activation="mish", log_every=1500): | |
| """RankNet on score differences (operator's System-1 idea): the net | |
| learns WHICH unit suffers more, never how much. Robust to label | |
| noise by construction — a flipped magnitude keeps its order.""" | |
| rng = np.random.default_rng(seed) | |
| P = np.asarray([(a, b) for (_, a, b, _) in pairs_idx]) | |
| W = np.asarray([g for (_, _, _, g) in pairs_idx], dtype=np.float64) | |
| W = W / W.sum() | |
| nval = max(4, int(len(P) * 0.2)) | |
| vidx = rng.choice(len(P), nval, replace=False) | |
| mask = np.ones(len(P), bool) | |
| mask[vidx] = False | |
| trp, vap = P[mask], P[~mask] | |
| # NOTE (2026-10-03): RDO-style gap weighting tried here (W below) and | |
| # LOST (0.563 vs 0.575 unweighted). Why: with n=770 noisy labels the | |
| # big-gap pairs are outliers that mislead; the transfer boundary lives | |
| # in close pairs. Uniform stands; RDO left in code as an option. | |
| _USE_RDO = False # verdict 2026-10-03: uniform 0.575 > huber 0.568 > | |
| # raw-RDO 0.563. Dead-zone killed the close pairs where the boundary | |
| # lives. Uniform stands, finally. | |
| Wtr, Wva = W[mask], W[~mask] | |
| if not _USE_RDO: | |
| Wtr = np.ones_like(Wtr) / len(Wtr) | |
| else: | |
| Wtr = np.minimum(Wtr, 1.0) # Huber cap: nothing above 1 std counts extra | |
| m = [{k: np.zeros_like(v) for k, v in p.items()} for p in ps] | |
| v = [{k: np.zeros_like(v) for k, v in p.items()} for p in ps] | |
| best = (0.0, None, 0) | |
| for ep in range(1, epochs + 1): | |
| bi = rng.choice(len(trp), min(512, len(trp)), replace=False) | |
| A, B = trp[bi][:, 0], trp[bi][:, 1] | |
| fa, preca = forward(ps, X[A], activation) | |
| fb, precb = forward(ps, X[B], activation) | |
| d = (fa[-1].ravel() - fb[-1].ravel()) / 1.0 | |
| sig = 1.0 / (1.0 + np.exp(np.clip(d, -30, 30))) | |
| wb = Wtr[bi] / Wtr[bi].sum() | |
| ga, gb = -sig * wb, +sig * wb | |
| grads = [{k: np.zeros_like(vv) for k, vv in p.items()} for p in ps] | |
| for acts, pre, g in ((fa, preca, ga), (fb, precb, gb)): | |
| delta = g[:, None] | |
| for i in reversed(range(len(ps))): | |
| grads[i]["W"] += acts[i].T @ delta + l2 * ps[i]["W"] / 2 | |
| grads[i]["b"] += delta.sum(axis=0) | |
| if i > 0: | |
| delta = backward_delta(delta @ ps[i]["W"].T, pre[i - 1], | |
| activation) | |
| for i in range(len(ps)): | |
| for k in ("W", "b"): | |
| g = grads[i][k] | |
| m[i][k] = 0.9 * m[i][k] + 0.1 * g | |
| v[i][k] = 0.95 * v[i][k] + 0.05 * g * g | |
| mh = m[i][k] / (1 - 0.9 ** ep) | |
| vh = v[i][k] / (1 - 0.95 ** ep) | |
| ps[i][k] -= lr * mh / (np.sqrt(vh) + 1e-8) | |
| pa = forward(ps, X[vap[:, 0]], activation)[0][-1].ravel() | |
| pb = forward(ps, X[vap[:, 1]], activation)[0][-1].ravel() | |
| acc = float(np.mean(pa > pb)) | |
| if acc > best[0]: | |
| best = (acc, copy.deepcopy(ps), ep) | |
| if ep % log_every == 0 or ep == 1: | |
| print(f" rank ep {ep}: val pair-acc={acc:.4f} " | |
| f"(best {best[0]:.4f} @ {best[2]})", flush=True) | |
| if best[1] is not None: | |
| for i in range(len(ps)): | |
| ps[i] = best[1][i] | |
| return ps | |
| # raw feature columns (tinynet.featurize order): 0-5 onehot, 6 layer, | |
| # 7 logN, 8 timp_mean, 9 timp_max, 10 frag, 11 econc, 12-15 w-stats | |
| SCALE_COLS = [7, 8, 9] | |
| def spearman(a, b): | |
| ra = np.argsort(np.argsort(np.asarray(a, dtype=float))).astype(float) | |
| rb = np.argsort(np.argsort(np.asarray(b, dtype=float))).astype(float) | |
| ra -= ra.mean() | |
| rb -= rb.mean() | |
| d = np.sqrt((ra ** 2).sum() * (rb ** 2).sum()) | |
| return float((ra * rb).sum() / d) if d > 0 else 0.0 | |
| def _unit_tag(names): | |
| # same rule as the allocator lookup (gnom.group_tag) | |
| # and predict_net: tissue@layer per blk tensor, "global" otherwise | |
| return "+".join(n.replace(".weight", "").split(".")[-1] + "@" + | |
| n.split(".")[1] for n in names | |
| if n.startswith("blk.")) or "global" | |
| def load_pool_multi(path, metrics, force_metric=True, strict_map=True, | |
| quiet=False, w_center=True, namefeats="off"): | |
| """Null-safe pooled loader for 1..k KLD-family targets. | |
| Rows are keyed on the PRIMARY metric (metrics[0]): a unit with a null | |
| primary is a censored observation — counted, skipped, never imputed. | |
| Other columns are NaN where the file does not carry them (old files | |
| predate the 5-column contract) or where the value is null. | |
| Returns (X, Y[n x k], models, gtags, stats). X columns: raw features | |
| (16) + base_bpw + drop_bpw [+ caller-appended extras]. | |
| With w_center (default), the 4 weight-stat columns are centered on | |
| each model's own median, so their raw scale cannot leak across | |
| models — the exam is scale-invariant, the columns are not. | |
| """ | |
| specs = json.load(open(path)) | |
| Xs, Ys, models, gtags = [], [], [], [] | |
| stats = {"units": 0, "mapped": 0, "tensors": 0, "missing": 0, | |
| "censored": 0, "files": 0, "n_feat_raw": 0, | |
| "missing_units": [], "files_detail": []} | |
| for sp in specs: | |
| primary = metrics[0] if force_metric else sp.get("metric", metrics[0]) | |
| keys = [primary] + [m for m in metrics[1:]] | |
| z = np.load(sp["features"]) | |
| X0 = np.asarray(z["X"], dtype=np.float64) | |
| stats["n_feat_raw"] = X0.shape[1] | |
| names = [str(t) for t in z["names"]] | |
| tmu = float(z["timp_mean"][0]) if "timp_mean" in z else 0.0 | |
| tsd = float(z["timp_std"][0]) if "timp_std" in z else 1.0 | |
| lmed = float(z["logN_med"][0]) if "logN_med" in z else 0.0 | |
| row = {n: i for i, n in enumerate(names)} | |
| wmed = (np.median(X0[:, 12:16], axis=0) | |
| if w_center and X0.shape[1] >= 16 else None) | |
| base, rows, cens = None, [], 0 | |
| with open(sp["labels"]) as f: | |
| for line in f: | |
| d = json.loads(line) | |
| if d["unit"] == "BASELINE": | |
| base = d.get("ppl") | |
| continue | |
| v0 = d.get(keys[0]) | |
| if v0 is None: | |
| # TERMINAL/censored (null != 0): visible in the file, | |
| # never imputed into training. HOLEs are not in this | |
| # file at all (*.failures.jsonl). | |
| cens += 1 | |
| continue | |
| rows.append((d["unit"], [t for t in d.get("tensors", [])], | |
| [d.get(k) for k in keys])) | |
| # DEAD PATH (archival pools only): PPL damage is relative to the | |
| # file's BASELINE ppl. KLD columns are already deltas (kf = 1). | |
| kf = 1.0 if keys[0] == "damage_kld" else (1.0 / base if base else 1.0) | |
| n_ok = t_ok = t_bad = 0 | |
| for ui, (unit, tensors, vals) in enumerate(rows): | |
| have = [t for t in tensors if t in row] | |
| missing = [t for t in tensors if t not in row] | |
| stats["units"] += 1 | |
| stats["tensors"] += len(tensors) | |
| stats["missing"] += len(missing) | |
| if missing: | |
| stats["missing_units"].append((sp["labels"], unit, missing)) | |
| if not quiet: | |
| print(f" MAPPING MISS: {unit} -> {missing[:3]}" | |
| f"{'...' if len(missing) > 3 else ''}", flush=True) | |
| if not have: | |
| continue | |
| idx = [row[t] for t in have] | |
| A = X0[idx] | |
| if wmed is not None: | |
| A = A.copy() | |
| A[:, 12:16] -= wmed # weight stats vs model's own median | |
| g = A.mean(axis=0) | |
| # scale-invariant: timp z-scored in-model, logN vs model median | |
| g[8] = (A[:, 8].mean() - tmu) / (tsd + 1e-12) | |
| g[9] = (A[:, 9].max() - tmu) / (tsd + 1e-12) | |
| g[7] = A[:, 7].mean() - lmed | |
| if namefeats == "off": | |
| extra = [] | |
| else: | |
| import hashlib as _hl | |
| if namefeats == "hash": | |
| key = _unit_tag(tensors) | |
| elif namefeats == "layerabs": | |
| ls = [n.split(".")[1] for n in tensors | |
| if n.startswith("blk.") and | |
| n.split(".")[1].isdigit()] | |
| key = f"layer@{min(map(int, ls))}" if ls else "global" | |
| else: | |
| key = f"anon@{ui}" | |
| b = _hl.md5(key.encode()).digest()[0] % 16 | |
| extra = [1.0 if i == b else 0.0 for i in range(16)] | |
| Xs.append(np.concatenate([g, extra, | |
| [sp["base_bpw"], sp["drop_bpw"]]])) | |
| y = [vals[0] * kf] | |
| for v in vals[1:]: | |
| y.append(np.nan if v is None else v) | |
| Ys.append(y) | |
| models.append(sp["model"]) | |
| gtags.append(f'{sp["model"]}:{keys[0]}:{unit}') | |
| n_ok += 1 | |
| t_ok += len(have) | |
| t_bad += len(missing) | |
| stats["mapped"] += n_ok | |
| stats["censored"] += cens | |
| stats["files"] += 1 | |
| stats["files_detail"].append( | |
| (sp["labels"], sp["features"], n_ok, len(rows), cens, t_ok, t_bad)) | |
| if not quiet: | |
| for lab, feat, n_ok2, n_rows, cens, t_ok2, t_bad2 in stats["files_detail"]: | |
| print(f" {lab}: mapped {n_ok2}/{n_rows}" | |
| f" (tensors {t_ok2}/{t_ok2 + t_bad2}, censored {cens})" | |
| f" [features {os.path.basename(feat)}]", flush=True) | |
| print(f"mapping: {stats['mapped']}/{stats['units']} units, " | |
| f"{stats['tensors'] - stats['missing']}/{stats['tensors']} tensors, " | |
| f"censored={stats['censored']}", flush=True) | |
| if strict_map and stats["missing"]: | |
| print(f"MAPPING FAIL: {len(stats['missing_units'])} units carry " | |
| f"tensors absent from their feature files — refusing to train " | |
| f"(--allow-partial-map to override)", flush=True) | |
| sys.exit(1) | |
| if stats["mapped"] < stats["units"] and strict_map: | |
| print(f"MAPPING FAIL: {stats['units'] - stats['mapped']} units with " | |
| f"values mapped to ZERO features — refusing to train", | |
| flush=True) | |
| sys.exit(1) | |
| return (np.array(Xs), np.array(Ys), np.array(models), gtags, stats) | |
| def load_pool(path, metric="damage_kld", force_metric=False, strict_map=True): | |
| """Single-target view over load_pool_multi (historic signature).""" | |
| X, Y, models, gtags, stats = load_pool_multi( | |
| path, [metric], force_metric=force_metric, strict_map=strict_map) | |
| return X, Y[:, 0], models, gtags | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--pool", required=True) | |
| ap.add_argument("--out", default="gnom/models/v4.npz") | |
| ap.add_argument("--variant", | |
| choices=["trunk", "modelbias", "moeshared", "rank", "all"], | |
| default="all") | |
| ap.add_argument("--seed", type=int, default=7) | |
| ap.add_argument("--epochs", type=int, default=1500) | |
| ap.add_argument("--hidden", default="8,4", | |
| help="hidden layer widths, comma-separated " | |
| "(default '8,4'; try '16,8' for 2x capacity)") | |
| ap.add_argument("--metric", default="damage_kld", | |
| help="label key; default damage_kld. 'damage' lists as " | |
| "PPL — DEAD (archival pools only), see docstring.") | |
| ap.add_argument("--tail-feats", action="store_true", | |
| help="append 4 explicit drop-depth x tissue interaction " | |
| "columns (tail probe; see README)") | |
| ap.add_argument("--target-z", action="store_true", | |
| help="z-score the target within each pool row (file): " | |
| "equal footing for cross-corpus scales; the exam " | |
| "(per-fold Spearman) is invariant to it") | |
| ap.add_argument("--allow-partial-map", action="store_true", | |
| help="downgrade tensor-level mapping misses to warnings") | |
| ap.add_argument("--no-w-center", action="store_true", | |
| help="keep raw (uncentered) weight-stat columns") | |
| ap.add_argument("--mask-timp", action="store_true", | |
| help="zero the imatrix-derived columns (timp_mean/_max, " | |
| "econc): statics + weight stats only, no lens") | |
| ap.add_argument("--namefeats", choices=["off", "hash", "anon", | |
| "layerabs"], | |
| default="off", | |
| help="append a 16-bucket onehot of unit identity " | |
| "(hash: md5(tag); anon: md5(unit index in file); " | |
| "layerabs: md5(absolute layer number) — same " | |
| "capacity everywhere; curiosity probe: " | |
| "memorization vs transfer)") | |
| args = ap.parse_args() | |
| strict = not args.allow_partial_map | |
| X, Y, models, gtags, stats = load_pool_multi( | |
| args.pool, [args.metric], force_metric=True, strict_map=strict, | |
| w_center=not args.no_w_center, namefeats=args.namefeats) | |
| if args.namefeats != "off": | |
| print(f"namefeats ON ({args.namefeats}): +16 unit-identity columns", | |
| flush=True) | |
| y = Y[:, 0] # single-target view: 1D, exactly the historic engine input | |
| if args.mask_timp: | |
| X[:, [8, 9, 11]] = 0.0 # before tail feats AND standardize/export | |
| print("mask-timp ON: imatrix columns zeroed (no-lens trunk)", | |
| flush=True) | |
| if args.tail_feats: | |
| # explicit tail interactions: drop depth x {body, tail, conc, attn} | |
| d = X[:, -2] | |
| attn = X[:, 0:3].sum(axis=1) | |
| extra = np.stack([d * X[:, 8], d * X[:, 9], d * X[:, 11], | |
| d * attn], axis=1) | |
| X = np.concatenate([X, extra], axis=1) | |
| print("tail-feats ON: +4 columns (d*timp_mean, d*timp_max, d*econc, " | |
| "d*attn)", flush=True) | |
| y_raw = y.copy() # exams always score against the raw target | |
| if args.target_z: | |
| for m in sorted(set(models)): | |
| msk = models == m | |
| yy = y[msk] | |
| y[msk] = (yy - yy.mean()) / (yy.std() + 1e-12) | |
| print("target-z ON: target z-scored within each pool row", flush=True) | |
| n_feat_raw = stats["n_feat_raw"] + (16 if args.namefeats != "off" | |
| else 0) | |
| print(f"pooled: n={len(y)}, models={sorted(set(models))}", flush=True) | |
| uniq_m = sorted(set(models)) | |
| midx = np.array([uniq_m.index(m) for m in models]) | |
| hidden = [int(h) for h in args.hidden.split(",") if h.strip()] | |
| DIMS = [X.shape[1]] + hidden + [1] | |
| print(f"arch: {'-'.join(map(str, DIMS))}", flush=True) | |
| def run_variant(variant, tr_idx, va_idx, seed): | |
| if variant == "rank": | |
| prs = build_pairs(y[tr_idx], midx[tr_idx]) | |
| ps = init_params(DIMS, seed=seed) | |
| Xs, _, _ = standardize(X) | |
| train_rank(ps, Xs, prs, seed=seed) | |
| return forward(ps, Xs, "mish")[0][-1].ravel() | |
| if variant == "moeshared": | |
| exp = moe_train_shared(X[tr_idx][:, :n_feat_raw], y[tr_idx], | |
| seed=seed, epochs=args.epochs) | |
| return moe_predict_shared(exp, X[:, :n_feat_raw]) | |
| if variant == "modelbias": | |
| # honest subset fit: trunk never sees va labels; unseen models | |
| # get bias 0 (pure trunk generalization). | |
| Xtr, ytr = X[tr_idx], y[tr_idx] | |
| mtr = midx[tr_idx] | |
| ug = sorted(set(mtr.tolist())) | |
| r = {g: i for i, g in enumerate(ug)} | |
| gs = np.array([r[g] for g in mtr]) | |
| mp = fit_mixed(Xtr, ytr, gs, len(ug), DIMS, seed=seed, | |
| epochs=args.epochs, log_every=args.epochs) | |
| Xs = (X - mp["xmu"]) / mp["xsd"] | |
| base = forward(mp["trunk"], Xs, "mish")[0][-1].ravel() \ | |
| * mp["tsd"] + mp["tmu"] | |
| b = np.zeros(len(X)) | |
| known = np.array([r[m] if m in r else -1 for m in midx]) | |
| kk = known >= 0 | |
| b[kk] = mp["bias"][known[kk]] | |
| return (base + b) * mp["ysd"] + mp["ymu"] | |
| ps = init_params(DIMS, seed=seed) | |
| Xs, _, _ = standardize(X) | |
| w = np.abs(y[tr_idx]) + 0.05 * np.abs(y[tr_idx]).max() | |
| train(ps, Xs[tr_idx], y[tr_idx], epochs=args.epochs, | |
| log_every=args.epochs, noise_std=0.05, sample_w=w, | |
| activation="mish", dropout=0.0, seed=seed) | |
| mu, sd = float(y.mean()), float(y.std() + 1e-12) | |
| return forward(ps, Xs, "mish")[0][-1].ravel() * sd + mu | |
| variants = (["trunk", "modelbias", "moeshared", "rank"] | |
| if args.variant == "all" else [args.variant]) | |
| rng = np.random.default_rng(args.seed) | |
| # random split (group-disjoint) | |
| uniq_g = sorted(set(gtags)) | |
| gperm = rng.permutation(len(uniq_g)) | |
| gtr = set(gperm[:max(2, len(uniq_g) * 4 // 5)]) | |
| gidx = np.array([uniq_g.index(t) for t in gtags]) | |
| tr = np.array([i for i in range(len(X)) if gidx[i] in gtr]) | |
| va = np.array([i for i in range(len(X)) if gidx[i] not in gtr]) | |
| print(f"split: train {len(tr)} / heldout {len(va)}", flush=True) | |
| results = {} | |
| for v in variants: | |
| p = run_variant(v, tr, va, args.seed) | |
| if v == "rank": | |
| # decisions are scored by pair accuracy, not correlation: | |
| # coin is 0.50, bar is 0.60. | |
| acc_r = pair_acc(p, y_raw, midx, va) | |
| np_r = pair_count(y_raw, midx, va) | |
| acc_lomo, np_lomo = [], [] | |
| if len(uniq_m) > 1: | |
| for m in uniq_m: | |
| trm = np.where(models != m)[0] | |
| vam = np.where(models == m)[0] | |
| if len(vam) < 5 or len(trm) < 5: | |
| continue | |
| pm = run_variant(v, trm, vam, args.seed) | |
| acc_lomo.append(pair_acc(pm, y_raw, midx, vam)) | |
| np_lomo.append(pair_count(y_raw, midx, vam)) | |
| lomo = float(np.mean(acc_lomo)) if acc_lomo else 0.5 | |
| mde_r = mde_pair(np_r) | |
| mde_l = mde_pair(np.mean(np_lomo)) if np_lomo else float("inf") | |
| print(f"{v}: random-acc={acc_r:.3f} (MDE={mde_r:.3f}, " | |
| f"n={np_r}) LOMO-acc={lomo:.3f} (MDE~{mde_l:.3f}) " | |
| f"(per-model {[f'{s:.2f}' for s in acc_lomo]})", flush=True) | |
| results[v] = {"random": acc_r, "lomo": lomo} | |
| continue | |
| sp_r = spearman(p[va], y_raw[va]) | |
| # LOMO needs >=2 models; single-metric pools report random only. | |
| sp_lomo, fold_n, timp_lomo = [], [], [] | |
| if len(uniq_m) > 1: | |
| for m in uniq_m: | |
| trm = np.where(models != m)[0] | |
| vam = np.where(models == m)[0] | |
| if len(vam) < 5 or len(trm) < 5: | |
| continue | |
| pm = run_variant(v, trm, vam, args.seed) | |
| sp_lomo.append(spearman(pm[vam], y_raw[vam])) | |
| fold_n.append(len(vam)) | |
| timp_lomo.append(spearman(X[vam, 8], y_raw[vam])) | |
| lomo = float(np.mean(sp_lomo)) if sp_lomo else 0.0 | |
| base = spearman(X[va, 8], y_raw[va]) | |
| tb_lomo = float(np.mean(timp_lomo)) if timp_lomo else None | |
| mde_r = mde_rho(len(va)) | |
| mde_l = mde_lomo(fold_n) | |
| tb_str = (f"timp_base={base:.3f} timp_LOMO=" | |
| f"{tb_lomo:.3f}" if tb_lomo is not None | |
| else f"timp_base={base:.3f} timp_LOMO=n/a") | |
| print(f"{v}: random={sp_r:.3f} (MDE={mde_r:.3f}, n={len(va)}) " | |
| f"LOMO={lomo:.3f} (MDE~{mde_l:.3f}, folds={fold_n}) " | |
| f"{tb_str}", flush=True) | |
| print(f" per-model {[f'{s:.2f}' for s in sp_lomo]}" | |
| + (f" timp {[f'{s:.2f}' for s in timp_lomo]}" | |
| if timp_lomo else ""), flush=True) | |
| results[v] = {"random": sp_r, "lomo": lomo} | |
| best_c = max([v for v in results if v != "rank"], | |
| key=lambda v: results[v]["lomo"], default=None) | |
| if best_c: | |
| print(f"BEST-corr: {best_c} LOMO={results[best_c]['lomo']:.3f} " | |
| f"(bar 0.45: {'PASS' if results[best_c]['lomo'] >= 0.45 else 'FAIL'})", | |
| flush=True) | |
| if "rank" in results: | |
| print(f"BEST-rank: rank LOMO-acc={results['rank']['lomo']:.3f} " | |
| f"(bar 0.60, coin 0.50: " | |
| f"{'PASS' if results['rank']['lomo'] >= 0.60 else 'FAIL'})", | |
| flush=True) | |
| # Export: trunk trained on FULL data (moeshared/modelbias export TODO). | |
| # Whole gnom: ~220 floats ≈ 2KB in one .npz (precompute per model into | |
| # netpred.json; no live inference needed in the queue). | |
| # predict_net rebuilds hash-identity columns from unit tags, but | |
| # anon indices depend on labels-file order: anon trunks are | |
| # metrics-only, skipped here rather than shipping an unloadable model. | |
| if args.namefeats == "anon": | |
| print("anon trunk: export skipped (predict cannot rebuild " | |
| "file-order indices)", flush=True) | |
| return | |
| ps = init_params(DIMS, seed=args.seed) | |
| Xs, xmu, xsd = standardize(X) | |
| ymu, ysd = float(y.mean()), float(y.std() + 1e-12) | |
| w = np.abs(y) + 0.05 * np.abs(y).max() | |
| train(ps, Xs, (y - ymu) / ysd, epochs=args.epochs, | |
| log_every=args.epochs, noise_std=0.05, sample_w=w, | |
| activation="mish", dropout=0.0, seed=args.seed) | |
| _os = os | |
| _os.makedirs(_os.path.dirname(_os.path.abspath(args.out)) or ".", | |
| exist_ok=True) | |
| np.savez(args.out, xmu=xmu, xsd=xsd, ymu=np.array(ymu), ysd=np.array(ysd), | |
| dims=np.array(DIMS), metric=np.array(args.metric), | |
| **{f"p{i}{k}": v for i, p in enumerate(ps) for k, v in p.items()}) | |
| print(f"saved trunk {args.out}", flush=True) | |
| if __name__ == "__main__": | |
| main() | |