openjev / code /modeling_openjev.py
AlexWortega's picture
code: modeling_openjev.py
106dab5 verified
Raw
History Blame Contribute Delete
10.6 kB
"""openjev: a Qwen3.5 decoder used as an NLI cross-encoder, plus a latent + MLP head trained with soft BCE.
Architecture
------------
* `OpenJevCrossEncoder`: `Qwen3_5ForSequenceClassification` — the Qwen3.5 text backbone followed by a linear
`score` head over the hidden state of the last non-pad token. Three labels in dleemiller order
(0 = contradiction, 1 = entailment, 2 = neutral). Input is one string
`"Premise: {premise}\\nHypothesis: {hypothesis}"` (template stored in `config.nli_template`), right-padded.
The vision tower that ships with Qwen3.5 checkpoints is kept but unused for text. Loss for the NLI head:
cross-entropy over the three classes.
* `LatentMLPHead`: a small MLP (d -> 512 -> 1, GELU, dropout 0.1) on top of the frozen cross-encoder latent
(the pooled hidden state that feeds `score`), one scalar per (question, option) pair. Loss: soft BCE —
`BCEWithLogits(logit, y*(1-eps) + (1-y)*eps)` with positives re-weighted by (1-p)/p, p = positive rate.
At inference the option with the highest score wins (per-question argmax).
Usage
-----
from modeling_openjev import OpenJevCrossEncoder, LatentMLPHead
ce = OpenJevCrossEncoder("AlexWortega/openjev", subfolder="qwen3.5-4b-nli")
ce.predict([("A man is playing a guitar.", "Someone is making music.")]) # -> [[p_con, p_ent, p_neu]]
ce.rerank("What is the capital of France?", ["Paris", "Lyon", "Berlin"]) # -> index of the best option
X = ce.latents(pairs) # (n_pairs, d) float32
head = LatentMLPHead(X.shape[1]).fit(X, gold, qid) # gold in {0,1} per pair, qid groups pairs by question
head.predict(X) # (n_pairs,) logits; argmax within each qid
"""
from __future__ import annotations
import json
import os
import numpy as np
import torch
import torch.nn as nn
CON, ENT, NEU = 0, 1, 2
DEFAULT_TEMPLATE = "Premise: {premise}\nHypothesis: {hypothesis}"
class OpenJevCrossEncoder:
def __init__(self, path: str, subfolder: str | None = None, device: str | None = None, dtype=torch.bfloat16,
bs: int = 32, max_len: int = 4096):
from transformers import AutoModelForSequenceClassification, AutoTokenizer
kw = {"subfolder": subfolder} if subfolder else {}
self.tok = AutoTokenizer.from_pretrained(path, **kw)
self.model = AutoModelForSequenceClassification.from_pretrained(path, dtype=dtype, **kw)
h = getattr(self.model.config, "openjev_mlp_head", 0)
if h: # frozen-backbone MLP head (train.py --mlp-head)
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
d = self.model.score.in_features
self.model.score = nn.Sequential(nn.Linear(d, h), nn.GELU(), nn.Dropout(0.1), nn.Linear(h, 3)).to(dtype)
f = os.path.join(path, subfolder or "", "model.safetensors") if os.path.isdir(path) else \
hf_hub_download(path, "model.safetensors", **kw)
sd = load_file(f)
self.model.score.load_state_dict({k[6:]: v.to(dtype) for k, v in sd.items() if k.startswith("score.")})
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.model.to(self.device).eval()
self.template = getattr(self.model.config, "nli_template", None) or DEFAULT_TEMPLATE
if self.tok.pad_token is None:
self.tok.pad_token = self.tok.eos_token
self.tok.padding_side = "right" # the head pools the last non-pad token
tc = self.model.config.get_text_config()
if tc.pad_token_id is None:
tc.pad_token_id = self.tok.pad_token_id
self.backbone = getattr(self.model, self.model.base_model_prefix)
self.bs, self.max_len = bs, max_len
def _encode(self, pairs):
texts = [self.template.format(premise=p.strip(), hypothesis=h.strip()) for p, h in pairs]
enc = self.tok(texts, truncation=True, max_length=self.max_len, padding=True, return_tensors="pt")
return {k: v.to(self.device) for k, v in enc.items()}
@torch.no_grad()
def _pooled(self, enc):
h = self.backbone(**enc).last_hidden_state
last = enc["attention_mask"].sum(1) - 1
return h[torch.arange(h.shape[0], device=h.device), last]
@torch.no_grad()
def latents(self, pairs) -> np.ndarray:
"""Pooled last-token hidden state (the input of the `score` head) for each (premise, hypothesis) pair."""
out = []
for i in range(0, len(pairs), self.bs):
out.append(self._pooled(self._encode(pairs[i:i + self.bs])).float().cpu().numpy())
return np.concatenate(out, 0)
@torch.no_grad()
def predict(self, pairs) -> np.ndarray:
"""Softmax probabilities [contradiction, entailment, neutral] per pair."""
out = []
for i in range(0, len(pairs), self.bs):
logits = self.model.score(self._pooled(self._encode(pairs[i:i + self.bs]))).float()
out.append(torch.softmax(logits, -1).cpu().numpy())
return np.concatenate(out, 0)
def rerank(self, question: str, options, hyp_fmt: str = "The correct answer is: {}") -> int:
"""Zero-shot multiple choice: option with the highest P(entailment) given the question as premise."""
p = self.predict([(question, hyp_fmt.format(o)) for o in options])
return int(p[:, ENT].argmax())
def grade(self, question: str, reference: str, candidate: str) -> str:
"""Reference-based grading: premise = question + reference, hypothesis = candidate."""
p = self.predict([(f"{question}\nReference answer: {reference}", f"Answer: {candidate}")])[0]
return ["contradiction", "entailment", "neutral"][int(p.argmax())]
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 soft_bce(logits, y, eps, pos_weight):
"""BCE with soft targets: gold -> 1-eps, others -> eps; positives up-weighted by pos_weight."""
target = y * (1 - eps) + (1 - y) * eps
w = torch.where(y > 0.5, pos_weight, torch.ones_like(y))
return (w * nn.functional.binary_cross_entropy_with_logits(logits, target, reduction="none")).mean()
def per_question_acc(scores, qid, gold):
"""Fraction of questions whose argmax-scored option is the gold one."""
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)]
hits = [gold[a:b][scores[a:b].argmax()] == 1 for a, b in zip(starts[:-1], starts[1:])]
return float(np.mean(hits))
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
class LatentMLPHead:
def __init__(self, d: int, hidden: int = 512, dropout: float = 0.1, eps: float = 0.1, lr: float = 1e-3,
wd: float = 1e-2, bs: int = 512, epochs: int = 60, patience: int = 8, seed: int = 0, device: str | None = None):
self.cfg = dict(d=d, hidden=hidden, dropout=dropout, eps=eps, lr=lr, wd=wd, bs=bs, epochs=epochs, patience=patience, seed=seed)
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
torch.manual_seed(seed)
self.model = _MLP(d, hidden, dropout).to(self.device)
self.mu, self.sd = np.zeros((1, d), np.float32), np.ones((1, d), np.float32)
def _t(self, X):
return torch.tensor((X - self.mu) / self.sd, dtype=torch.float32, device=self.device)
def fit(self, X, gold, qid, val_frac: float = 0.1):
"""X: (n_pairs, d); gold: 1 for the correct option of a question, 0 otherwise; qid: question id per pair.
Early stopping on per-question accuracy of a grouped hold-out; restores the best weights."""
X, gold, qid = np.asarray(X, np.float32), np.asarray(gold, np.float32), np.asarray(qid)
tr, va = grouped_split(qid, val_frac, self.cfg["seed"])
self.mu, self.sd = X[tr].mean(0, keepdims=True), X[tr].std(0, keepdims=True) + 1e-6
Xtr, ytr, Xva = self._t(X[tr]), torch.tensor(gold[tr], device=self.device), self._t(X[va])
p = float(gold[tr].mean())
pos_weight = torch.tensor((1 - p) / max(p, 1e-6), device=self.device)
opt = torch.optim.AdamW(self.model.parameters(), lr=self.cfg["lr"], weight_decay=self.cfg["wd"])
best, best_state, bad = -1.0, None, 0
for ep in range(self.cfg["epochs"]):
self.model.train()
perm = torch.randperm(len(Xtr), device=self.device)
for s in range(0, len(Xtr), self.cfg["bs"]):
idx = perm[s:s + self.cfg["bs"]]
loss = soft_bce(self.model(Xtr[idx]), ytr[idx], self.cfg["eps"], pos_weight)
opt.zero_grad(); loss.backward(); opt.step()
self.model.eval()
with torch.no_grad():
acc = per_question_acc(self.model(Xva).cpu().numpy(), qid[va], gold[va])
if acc > best:
best, bad, best_state = acc, 0, {k: v.clone() for k, v in self.model.state_dict().items()}
else:
bad += 1
if bad >= self.cfg["patience"]:
break
self.model.load_state_dict(best_state)
self.model.eval()
self.val_acc = best
return self
@torch.no_grad()
def predict(self, X) -> np.ndarray:
"""One logit per pair; pick the argmax within each question (sigmoid gives a per-option probability)."""
self.model.eval()
return self.model(self._t(np.asarray(X, np.float32))).cpu().numpy()
def save(self, path: str):
os.makedirs(path, exist_ok=True)
torch.save(self.model.state_dict(), os.path.join(path, "head.pt"))
np.savez(os.path.join(path, "norm.npz"), mu=self.mu, sd=self.sd)
json.dump(self.cfg, open(os.path.join(path, "config.json"), "w"), indent=2)
@classmethod
def load(cls, path: str, device: str | None = None) -> "LatentMLPHead":
cfg = json.load(open(os.path.join(path, "config.json")))
head = cls(device=device, **cfg)
head.model.load_state_dict(torch.load(os.path.join(path, "head.pt"), map_location=head.device))
z = np.load(os.path.join(path, "norm.npz"))
head.mu, head.sd = z["mu"], z["sd"]
head.model.eval()
return head