#!/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()