#!/usr/bin/env python """Frozen NLI-Qwen latent -> MLP head trained with soft BCE to score multiple-choice options. extract: run the fine-tuned NLI model once per (question, option) pair, save the pooled last-token hidden state (the input of the `score` head) + the NLI logits for train and test splits of every MC task. train: MLP on the latents, soft-BCE loss (gold=1-eps, others=eps), per-task and joint; report per-question argmax accuracy vs the plain NLI entailment rerank on the same test pairs. python latent_mlp.py extract --ckpt ckpt/qwen3.5-4b-nli --out data/latents_4b python latent_mlp.py train --latents data/latents_4b --out results/latent_mlp_4b.json """ import argparse import csv import json import os import random import numpy as np import torch import torch.nn as nn from datasets import load_dataset import eval as E TASKS = ["gpqa", "mmlu", "arc_easy", "arc_challenge", "winogrande", "chess"] EXTRA_TASKS = ["hellaswag", "gsm8k_mc4", "gsm8k_mc10"] # ----------------------------------------------------------------------------- train splits BIG = {"mmlu": 30000, "winogrande": 40398, "chess": 10000} # --big train sets (GPQA/ARC have no more data) def train_items(task, seed=0, big=False): if big and task == "mmlu": # MMLU auxiliary_train (ARC/OBQA/RACE-style MC, 99.8k) subsample ds = load_dataset("cais/mmlu", "all", split="auxiliary_train").shuffle(seed=seed).select(range(BIG["mmlu"])) return [{"q": ex["question"].strip(), "opts": [c.strip() for c in ex["choices"]], "gold": int(ex["answer"])} for ex in ds] if big and task == "winogrande": ds = load_dataset("allenai/winogrande", "winogrande_xl", split="train") return [{"q": ex["sentence"], "opts": [ex["option1"], ex["option2"]], "gold": int(ex["answer"]) - 1, "hyp": (lambda o, s=ex["sentence"]: s.replace("_", o))} for ex in ds] if big and task == "chess": return E.load_chess(BIG["chess"], seed=1) if task == "hellaswag": return E.load_hellaswag(8000, seed=seed, split="train") if task == "gsm8k_mc4": return E.load_gsm8k_mc(4, split="train") if task == "gsm8k_mc10": return E.load_gsm8k_mc(10, split="train") if task == "gpqa": # gpqa_main minus the diamond questions diamond = {it["q"] for it in E.load_gpqa()} items = [] for ex in csv.DictReader(open("data/gpqa_main.csv")): q = ex["Question"].strip() if q in diamond: continue opts = [ex["Correct Answer"], ex["Incorrect Answer 1"], ex["Incorrect Answer 2"], ex["Incorrect Answer 3"]] items.append({"q": q, "opts": [o.strip() for o in opts], "gold": 0}) return items if task == "mmlu": # validation + dev (test is the eval split) items = [] for split in ["validation", "dev"]: for ex in load_dataset("cais/mmlu", "all", split=split): items.append({"q": ex["question"].strip(), "opts": [c.strip() for c in ex["choices"]], "gold": int(ex["answer"])}) return items if task in ("arc_easy", "arc_challenge"): cfg = "ARC-Easy" if task == "arc_easy" else "ARC-Challenge" ds = load_dataset("allenai/ai2_arc", cfg, split="train") items = [] for ex in ds: labels = ex["choices"]["label"] if ex["answerKey"] in labels: items.append({"q": ex["question"].strip(), "opts": [t.strip() for t in ex["choices"]["text"]], "gold": labels.index(ex["answerKey"])}) return items if task == "winogrande": ds = load_dataset("allenai/winogrande", "winogrande_xl", split="train").shuffle(seed=seed).select(range(8000)) return [{"q": ex["sentence"], "opts": [ex["option1"], ex["option2"]], "gold": int(ex["answer"]) - 1, "hyp": (lambda o, s=ex["sentence"]: s.replace("_", o))} for ex in ds] if task == "chess": return E.load_chess(2000, seed=1) # test uses seed 0 raise ValueError(task) def test_items(task): ns = argparse.Namespace(mc_n=None, chess_n=500, fewshot=0) return E.MC_TASKS[task](ns) # ----------------------------------------------------------------------------- extract @torch.no_grad() def extract(args): from transformers import AutoConfig, AutoModelForSequenceClassification, AutoTokenizer tok = AutoTokenizer.from_pretrained(args.ckpt) cls = AutoModelForSequenceClassification if getattr(AutoConfig.from_pretrained(args.ckpt), "model_type", "") == "qwen3_5_moe": from modeling_qwen35_moe_seqcls import Qwen3_5MoeForSequenceClassification as cls model = cls.from_pretrained(args.ckpt, dtype=torch.bfloat16).cuda().eval() template = getattr(model.config, "nli_template", None) or "Premise: {premise}\nHypothesis: {hypothesis}" # raw base models if model.config.get_text_config().pad_token_id is None: model.config.get_text_config().pad_token_id = tok.pad_token_id tok.padding_side = "right" backbone = getattr(model, model.base_model_prefix) os.makedirs(args.out, exist_ok=True) def run(items, path): texts, qid, gold, nopts = [], [], [], [] for i, it in enumerate(items): hyp = it.get("hyp") or (lambda o: f"The correct answer is: {o}") nopts.append(len(it["opts"])) for j, o in enumerate(it["opts"]): texts.append(template.format(premise=it["q"].strip(), hypothesis=hyp(o).strip())) qid.append(i); gold.append(int(j == it["gold"])) X, L = [], [] for s in range(0, len(texts), args.bs): enc = tok(texts[s:s + args.bs], truncation=True, max_length=args.max_len, padding=True, return_tensors="pt").to("cuda") h = backbone(**enc).last_hidden_state last = enc["attention_mask"].sum(1) - 1 pooled = h[torch.arange(h.shape[0], device=h.device), last] X.append(pooled.float().cpu().numpy().astype(np.float16)) L.append(model.score(pooled).float().cpu().numpy()) np.savez(path, X=np.concatenate(X), nli=np.concatenate(L), qid=np.array(qid), gold=np.array(gold), nopts=np.array(nopts)) print(f" {path}: {len(items)} questions, {len(texts)} pairs", flush=True) for task in args.tasks: print(task, flush=True) run(train_items(task, big=args.big), f"{args.out}/{task}_train.npz") if not args.skip_test: run(test_items(task), f"{args.out}/{task}_test.npz") # ----------------------------------------------------------------------------- train class MLP(nn.Module): def __init__(self, d, hidden=512, p=0.1): super().__init__() self.net = nn.Sequential(nn.Linear(d, hidden), nn.GELU(), nn.Dropout(p), nn.Linear(hidden, 1)) def forward(self, x): return self.net(x).squeeze(-1) def load_npz(path, use_nli): z = np.load(path) X = z["X"].astype(np.float32) if use_nli: X = np.concatenate([X, z["nli"].astype(np.float32)], 1) return X, z["qid"], z["gold"], z["nopts"], z["nli"] def per_question_acc(scores, qid, gold): """argmax over each question's options == gold option""" hits, n = 0, 0 order = np.argsort(qid, kind="stable") scores, qid, gold = scores[order], qid[order], gold[order] starts = np.r_[0, np.flatnonzero(np.diff(qid)) + 1, len(qid)] for a, b in zip(starts[:-1], starts[1:]): hits += int(gold[a:b][scores[a:b].argmax()] == 1); n += 1 return hits / n def soft_bce(logits, y, eps, pos_weight): target = y * (1 - eps) + (1 - y) * eps w = torch.where(y > 0.5, pos_weight, 1.0) return (w * nn.functional.binary_cross_entropy_with_logits(logits, target, reduction="none")).mean() def fit(Xtr, ytr, qtr, Xva, yva, qva, args, dev="cuda"): mu, sd = Xtr.mean(0, keepdims=True), Xtr.std(0, keepdims=True) + 1e-6 norm = lambda X: torch.tensor((X - mu) / sd, dtype=torch.float32, device=dev) Xtr_t, ytr_t = norm(Xtr), torch.tensor(ytr, dtype=torch.float32, device=dev) Xva_t = norm(Xva) pos_weight = torch.tensor(float((1 - ytr.mean()) / max(ytr.mean(), 1e-6)), device=dev) torch.manual_seed(args.seed) model = MLP(Xtr.shape[1], args.hidden, args.dropout).to(dev) opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.wd) best, best_state, bad = -1, None, 0 n = len(Xtr_t) for ep in range(args.epochs): model.train() perm = torch.randperm(n, device=dev) for s in range(0, n, args.bs): idx = perm[s:s + args.bs] loss = soft_bce(model(Xtr_t[idx]), ytr_t[idx], args.eps, pos_weight) opt.zero_grad(); loss.backward(); opt.step() model.eval() with torch.no_grad(): acc = per_question_acc(model(Xva_t).cpu().numpy(), qva, yva) if acc > best: best, bad, best_state = acc, 0, {k: v.clone() for k, v in model.state_dict().items()} else: bad += 1 if bad >= args.patience: break model.load_state_dict(best_state) model.eval() return model, (mu, sd), best, ep + 1 def predict(model, stats, X, dev="cuda"): mu, sd = stats with torch.no_grad(): return model(torch.tensor((X - mu) / sd, dtype=torch.float32, device=dev)).cpu().numpy() def grouped_split(qid, frac, seed): qs = np.unique(qid) rng = np.random.RandomState(seed) rng.shuffle(qs) hold = set(qs[: max(1, int(len(qs) * frac))].tolist()) mask = np.array([q in hold for q in qid]) return ~mask, mask def train(args): train_dir = args.train_dir or args.latents data = {t: {"train": load_npz(f"{train_dir}/{t}_train.npz", args.use_nli), "test": load_npz(f"{args.latents}/{t}_test.npz", args.use_nli)} for t in args.tasks} if args.frac < 1.0: # data-scaling: keep a grouped fraction of the train questions for t in args.tasks: X, qid, gold, nopts, nli = data[t]["train"] keep, _ = grouped_split(qid, 1 - args.frac, args.seed + 1) data[t]["train"] = (X[keep], qid[keep], gold[keep], nopts, nli[keep]) results = {} print(f"features: {data[args.tasks[0]]['train'][0].shape[1]}d eps={args.eps} use_nli={args.use_nli}") # baseline: NLI entailment rerank on the same pairs (sanity vs eval.py numbers) for t in args.tasks: X, qid, gold, nopts, nli = data[t]["test"] p = torch.softmax(torch.tensor(nli), -1).numpy() results[t] = {"n_test_q": int(len(nopts)), "n_train_q": int(len(data[t]["train"][3])), "random": float(np.mean(1.0 / nopts)), "nli_rerank": per_question_acc(p[:, E.ENT], qid, gold)} # per-task MLP for t in args.tasks: Xtr, qtr, ytr, _, _ = data[t]["train"] tr, va = grouped_split(qtr, 0.1, args.seed) model, stats, va_acc, eps_run = fit(Xtr[tr], ytr[tr], qtr[tr], Xtr[va], ytr[va], qtr[va], args) Xte, qte, yte, _, _ = data[t]["test"] results[t]["mlp_per_task"] = per_question_acc(predict(model, stats, Xte), qte, yte) results[t]["mlp_per_task_val"] = va_acc if args.save_dir: # same layout as modeling_openjev.LatentMLPHead.load: head.pt + norm.npz (+ meta.json) d = os.path.join(args.save_dir, t); os.makedirs(d, exist_ok=True) torch.save(model.state_dict(), os.path.join(d, "head.pt")); np.savez(os.path.join(d, "norm.npz"), mu=stats[0], sd=stats[1]) json.dump({"task": t, "d": int(Xtr.shape[1]), "hidden": args.hidden, "dropout": args.dropout, "eps": args.eps, "test_acc": results[t]["mlp_per_task"], "hypothesis": "The correct answer is: {option}"}, open(os.path.join(d, "meta.json"), "w"), indent=2) print(f"{t:14s} per-task: val {va_acc:.3f} test {results[t]['mlp_per_task']:.3f} ({eps_run} ep)", flush=True) # joint MLP: all tasks pooled (question ids offset per task) Xs, ys, qs, off = [], [], [], 0 for t in args.tasks: Xtr, qtr, ytr, _, _ = data[t]["train"] Xs.append(Xtr); ys.append(ytr); qs.append(qtr + off); off += qtr.max() + 1 Xtr, ytr, qtr = np.concatenate(Xs), np.concatenate(ys), np.concatenate(qs) tr, va = grouped_split(qtr, 0.1, args.seed) model, stats, va_acc, eps_run = fit(Xtr[tr], ytr[tr], qtr[tr], Xtr[va], ytr[va], qtr[va], args) for t in args.tasks: Xte, qte, yte, _, _ = data[t]["test"] results[t]["mlp_joint"] = per_question_acc(predict(model, stats, Xte), qte, yte) print(f"joint: val {va_acc:.3f} ({eps_run} ep)") os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True) json.dump({"args": vars(args), "results": results}, open(args.out, "w"), indent=2) print("\n| task | train q | test q | random | NLI rerank | MLP per-task | MLP joint |\n|---|---|---|---|---|---|---|") for t in args.tasks: r = results[t] print(f"| {t} | {r['n_train_q']} | {r['n_test_q']} | {r['random']:.3f} | {r['nli_rerank']:.3f} | {r['mlp_per_task']:.3f} | {r['mlp_joint']:.3f} |") def main(): ap = argparse.ArgumentParser() sub = ap.add_subparsers(dest="cmd", required=True) e = sub.add_parser("extract") e.add_argument("--ckpt", required=True); e.add_argument("--out", required=True) e.add_argument("--tasks", nargs="+", default=TASKS); e.add_argument("--bs", type=int, default=32); e.add_argument("--max-len", type=int, default=1024) e.add_argument("--big", action="store_true", help="large train sets for mmlu/winogrande/chess"); e.add_argument("--skip-test", action="store_true") t = sub.add_parser("train") t.add_argument("--latents", required=True); t.add_argument("--out", required=True) t.add_argument("--tasks", nargs="+", default=TASKS) t.add_argument("--eps", type=float, default=0.1, help="soft-BCE label smoothing: gold=1-eps, others=eps") t.add_argument("--use-nli", action="store_true", help="append the 3 NLI logits to the latent") t.add_argument("--hidden", type=int, default=512); t.add_argument("--dropout", type=float, default=0.1) t.add_argument("--lr", type=float, default=1e-3); t.add_argument("--wd", type=float, default=1e-2) t.add_argument("--bs", type=int, default=512); t.add_argument("--epochs", type=int, default=60); t.add_argument("--patience", type=int, default=8) t.add_argument("--seed", type=int, default=0) t.add_argument("--train-dir", default=None, help="take *_train.npz from here (e.g. a --big extraction)") t.add_argument("--frac", type=float, default=1.0, help="fraction of train questions to use") t.add_argument("--save-dir", default=None, help="save per-task heads here (head.pt + norm.npz + meta.json)") args = ap.parse_args() random.seed(args.seed if hasattr(args, "seed") else 0) (extract if args.cmd == "extract" else train)(args) if __name__ == "__main__": main()