Text Classification
Transformers
Safetensors
English
nli
cross-encoder
qwen3.5
reranker
image-text-to-text
Instructions to use AlexWortega/openjev with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use AlexWortega/openjev with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="AlexWortega/openjev")# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("AlexWortega/openjev", device_map="auto") - Notebooks
- Google Colab
- Kaggle
| """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()} | |
| 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] | |
| 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) | |
| 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 | |
| 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) | |
| 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 | |