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")# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("AlexWortega/openjev", device_map="auto") - Notebooks
- Google Colab
- Kaggle
Share Qwen3.5 prefix cache across NLI hypotheses
#1
by epsilon3 - opened
- README.md +98 -85
- modeling_openjev.py +251 -187
- test_shared_prefix.py +83 -0
README.md
CHANGED
|
@@ -1,90 +1,103 @@
|
|
| 1 |
-
---
|
| 2 |
-
license: mit
|
| 3 |
-
base_model: Qwen/Qwen3.5-4B
|
| 4 |
-
pipeline_tag: text-classification
|
| 5 |
-
library_name: transformers
|
| 6 |
-
tags:
|
| 7 |
-
- nli
|
| 8 |
-
- cross-encoder
|
| 9 |
-
- qwen3.5
|
| 10 |
-
- reranker
|
| 11 |
-
- text-classification
|
| 12 |
-
- image-text-to-text
|
| 13 |
-
language:
|
| 14 |
-
- en
|
| 15 |
-
---
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
# openjev — Qwen3.5 trained as jev model
|
| 19 |
-
|
| 20 |
-
<video controls src="https://huggingface.co/AlexWortega/openjev/resolve/main/videos/v2/doom_vision_v2.mp4" width="720"></video>
|
| 21 |
-
|
| 22 |
-
<video controls src="https://huggingface.co/AlexWortega/openjev/resolve/main/videos/minecraft_chain.mp4" width="720"></video>
|
| 23 |
-
|
| 24 |
-
**openjev** is Qwen3.5 turned into a *jev* model: a single cross-encoder that reads a premise and a hypothesis and
|
| 25 |
-
answers with entailment, contradiction or neutral. That one primitive is enough to rerank answers, grade them against a
|
| 26 |
-
reference, guard content, and play games in real time: hand it the game state and a few statements about it, and the
|
| 27 |
-
argmax entailment is the move. Nothing is trained per task.
|
| 28 |
-
|
| 29 |
-
## openjev-4B v2: text, images and agents
|
| 30 |
-
|
| 31 |
-
The new 4B checkpoint (`qwen3.5-4b-nli-v2/`) reads images as well as text and was trained on a much larger and harder
|
| 32 |
-
mixture. It is strictly zero-shot on everything shown here.
|
| 33 |
-
|
| 34 |
-
* Doom straight from the pixels (first video): **10.4 kills** per episode, twice the v1 model (5.2); random play gets 1.
|
| 35 |
-
* Crafts an **iron pickaxe from nothing in real Minecraft** (second video): 11 milestones in ~22 decisions, driven by a
|
| 36 |
-
backward-chaining scaffold where the jev model only checks statements about the inventory and the world.
|
| 37 |
-
* Much stronger on adversarial NLI (ANLI r3 0.42 → 0.63, WANLI 0.63 → 0.77) and on image claims (0.52 → 0.84),
|
| 38 |
-
better reranking (ARC-Challenge 0.59 → 0.72, MMLU 0.47 → 0.53), same MNLI (0.91).
|
| 39 |
-
|
| 40 |
-
Doom from the text state (v2, 11 kills per episode; a perfect-information bot gets 18.8):
|
| 41 |
-
|
| 42 |
-
<video controls src="https://huggingface.co/AlexWortega/openjev/resolve/main/videos/v2/doom_position_v2.mp4" width="720"></video>
|
| 43 |
-
|
| 44 |
-

|
| 45 |
-
|
| 46 |
-
Bigger jev: Qwen3.5-35B-A3B (MoE) as the backbone (`qwen3.5-35b-a3b-nli/`). Zero-shot, and with the backbone frozen plus
|
| 47 |
-
a small MLP head on the last-token latent (`mlp_heads_35b/`, one head per task, loadable with `LatentMLPHead.load`):
|
| 48 |
-
|
| 49 |
-

|
| 50 |
-
|
| 51 |
-
## What's inside
|
| 52 |
-
|
| 53 |
-
* `qwen3.5-4b-nli-v2/` — **recommended**: the 4B v2 jev checkpoint, text + images.
|
| 54 |
-
* `qwen3.5-4b-nli/` — the original 4B jev checkpoint (text).
|
| 55 |
-
* `qwen3.5-35b-a3b-nli/` — the 35B-A3B MoE jev checkpoint (load with `modeling_qwen35_moe_seqcls.py`).
|
| 56 |
-
* All checkpoints: `Qwen3_5ForSequenceClassification`, 3 labels `contradiction`, `entailment`, `neutral`, last-token
|
| 57 |
-
pooling, trained with plain cross-entropy over the three classes.
|
| 58 |
-
* `modeling_openjev.py` — `OpenJevCrossEncoder`: `predict`, `
|
| 59 |
-
|
| 60 |
-
* `
|
| 61 |
-
* `
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
| 74 |
jev.rerank("Which gas do plants absorb during photosynthesis?", ["oxygen", "carbon dioxide", "nitrogen"])
|
| 75 |
# -> index of the option with the highest entailment
|
| 76 |
-
```
|
| 77 |
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
tok = AutoTokenizer.from_pretrained("AlexWortega/openjev", subfolder="qwen3.5-4b-nli-v2")
|
| 83 |
-
model = AutoModelForSequenceClassification.from_pretrained("AlexWortega/openjev", subfolder="qwen3.5-4b-nli-v2")
|
| 84 |
-
text = model.config.nli_template.format(premise="...", hypothesis="...")
|
| 85 |
```
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
base_model: Qwen/Qwen3.5-4B
|
| 4 |
+
pipeline_tag: text-classification
|
| 5 |
+
library_name: transformers
|
| 6 |
+
tags:
|
| 7 |
+
- nli
|
| 8 |
+
- cross-encoder
|
| 9 |
+
- qwen3.5
|
| 10 |
+
- reranker
|
| 11 |
+
- text-classification
|
| 12 |
+
- image-text-to-text
|
| 13 |
+
language:
|
| 14 |
+
- en
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# openjev — Qwen3.5 trained as jev model
|
| 19 |
+
|
| 20 |
+
<video controls src="https://huggingface.co/AlexWortega/openjev/resolve/main/videos/v2/doom_vision_v2.mp4" width="720"></video>
|
| 21 |
+
|
| 22 |
+
<video controls src="https://huggingface.co/AlexWortega/openjev/resolve/main/videos/minecraft_chain.mp4" width="720"></video>
|
| 23 |
+
|
| 24 |
+
**openjev** is Qwen3.5 turned into a *jev* model: a single cross-encoder that reads a premise and a hypothesis and
|
| 25 |
+
answers with entailment, contradiction or neutral. That one primitive is enough to rerank answers, grade them against a
|
| 26 |
+
reference, guard content, and play games in real time: hand it the game state and a few statements about it, and the
|
| 27 |
+
argmax entailment is the move. Nothing is trained per task.
|
| 28 |
+
|
| 29 |
+
## openjev-4B v2: text, images and agents
|
| 30 |
+
|
| 31 |
+
The new 4B checkpoint (`qwen3.5-4b-nli-v2/`) reads images as well as text and was trained on a much larger and harder
|
| 32 |
+
mixture. It is strictly zero-shot on everything shown here.
|
| 33 |
+
|
| 34 |
+
* Doom straight from the pixels (first video): **10.4 kills** per episode, twice the v1 model (5.2); random play gets 1.
|
| 35 |
+
* Crafts an **iron pickaxe from nothing in real Minecraft** (second video): 11 milestones in ~22 decisions, driven by a
|
| 36 |
+
backward-chaining scaffold where the jev model only checks statements about the inventory and the world.
|
| 37 |
+
* Much stronger on adversarial NLI (ANLI r3 0.42 → 0.63, WANLI 0.63 → 0.77) and on image claims (0.52 → 0.84),
|
| 38 |
+
better reranking (ARC-Challenge 0.59 → 0.72, MMLU 0.47 → 0.53), same MNLI (0.91).
|
| 39 |
+
|
| 40 |
+
Doom from the text state (v2, 11 kills per episode; a perfect-information bot gets 18.8):
|
| 41 |
+
|
| 42 |
+
<video controls src="https://huggingface.co/AlexWortega/openjev/resolve/main/videos/v2/doom_position_v2.mp4" width="720"></video>
|
| 43 |
+
|
| 44 |
+

|
| 45 |
+
|
| 46 |
+
Bigger jev: Qwen3.5-35B-A3B (MoE) as the backbone (`qwen3.5-35b-a3b-nli/`). Zero-shot, and with the backbone frozen plus
|
| 47 |
+
a small MLP head on the last-token latent (`mlp_heads_35b/`, one head per task, loadable with `LatentMLPHead.load`):
|
| 48 |
+
|
| 49 |
+

|
| 50 |
+
|
| 51 |
+
## What's inside
|
| 52 |
+
|
| 53 |
+
* `qwen3.5-4b-nli-v2/` — **recommended**: the 4B v2 jev checkpoint, text + images.
|
| 54 |
+
* `qwen3.5-4b-nli/` — the original 4B jev checkpoint (text).
|
| 55 |
+
* `qwen3.5-35b-a3b-nli/` — the 35B-A3B MoE jev checkpoint (load with `modeling_qwen35_moe_seqcls.py`).
|
| 56 |
+
* All checkpoints: `Qwen3_5ForSequenceClassification`, 3 labels `contradiction`, `entailment`, `neutral`, last-token
|
| 57 |
+
pooling, trained with plain cross-entropy over the three classes.
|
| 58 |
+
* `modeling_openjev.py` — `OpenJevCrossEncoder`: `predict`, `predict_hypotheses`, `rerank`, `grade`,
|
| 59 |
+
`latents`, `latents_hypotheses`; `LatentMLPHead` for the per-task heads.
|
| 60 |
+
* `modeling_qwen35_moe_seqcls.py` — `Qwen3_5MoeForSequenceClassification` for the 35B-A3B backbone.
|
| 61 |
+
* `mlp_heads_35b/<task>/` — `head.pt` + `norm.npz` + `meta.json`, the 35B latent + MLP heads behind the second radar.
|
| 62 |
+
* `code/` — everything used here: the trainer and data mixture builder, the evaluation harness, Flappy Bird, Doom
|
| 63 |
+
(text and pixels), the Minecraft scaffold and bot, the radar.
|
| 64 |
+
* `videos/` — Flappy Bird, Doom and Minecraft replays; `results/` — raw JSON for every run and the full report.
|
| 65 |
+
|
| 66 |
+
## Use it
|
| 67 |
+
|
| 68 |
+
```python
|
| 69 |
+
from modeling_openjev import OpenJevCrossEncoder
|
| 70 |
+
jev = OpenJevCrossEncoder("AlexWortega/openjev", subfolder="qwen3.5-4b-nli-v2")
|
| 71 |
+
|
| 72 |
+
jev.predict([("The bird is 0.05 below the centre of the gap.", "The bird is below the centre of the gap.")])
|
| 73 |
+
# -> [[contradiction, entailment, neutral]] probabilities
|
| 74 |
+
|
| 75 |
jev.rerank("Which gas do plants absorb during photosynthesis?", ["oxygen", "carbon dioxide", "nitrogen"])
|
| 76 |
# -> index of the option with the highest entailment
|
|
|
|
| 77 |
|
| 78 |
+
jev.predict_hypotheses("Which gas do plants absorb during photosynthesis?",
|
| 79 |
+
["The correct answer is: oxygen", "The correct answer is: carbon dioxide",
|
| 80 |
+
"The correct answer is: nitrogen"])
|
| 81 |
+
# -> one [contradiction, entailment, neutral] row per hypothesis
|
|
|
|
|
|
|
|
|
|
| 82 |
```
|
| 83 |
|
| 84 |
+
`predict_hypotheses` and `latents_hypotheses` use the existing pairwise batch for one or two hypotheses. For three or
|
| 85 |
+
more, they compute the common token prefix once, then score every hypothesis in one batched continuation. `rerank`
|
| 86 |
+
uses the same rule. Qwen3.5 has recurrent linear-attention layers, so a 4D packed
|
| 87 |
+
tree mask alone would mix branches; the shared prefix cache is copied into separate batch entries for the suffixes.
|
| 88 |
+
This path is for text inputs. Its batch size and suffix padding use memory proportional to the number and length of
|
| 89 |
+
the hypotheses; split very large option sets into smaller calls.
|
| 90 |
+
|
| 91 |
+
Or with plain transformers:
|
| 92 |
+
|
| 93 |
+
```python
|
| 94 |
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
| 95 |
+
tok = AutoTokenizer.from_pretrained("AlexWortega/openjev", subfolder="qwen3.5-4b-nli-v2")
|
| 96 |
+
model = AutoModelForSequenceClassification.from_pretrained("AlexWortega/openjev", subfolder="qwen3.5-4b-nli-v2")
|
| 97 |
+
text = model.config.nli_template.format(premise="...", hypothesis="...")
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
Images go inside the premise as `<|vision_start|><|image_pad|>…<|vision_end|>` with `pixel_values` / `image_grid_thw`
|
| 101 |
+
from the Qwen3.5 image processor; see `code/doom_vision.py` and `code/eval_image_nli.py`.
|
| 102 |
+
|
| 103 |
+
Reference point: [dleemiller's NLI cross-encoders](https://huggingface.co/blog/dleemiller/nli-xenc-ways-to-use). Licence MIT.
|
modeling_openjev.py
CHANGED
|
@@ -1,197 +1,261 @@
|
|
| 1 |
-
"""openjev: a Qwen3.5 decoder used as an NLI cross-encoder, plus a latent + MLP head trained with soft BCE.
|
| 2 |
-
|
| 3 |
-
Architecture
|
| 4 |
-
------------
|
| 5 |
-
* `OpenJevCrossEncoder`: `Qwen3_5ForSequenceClassification` — the Qwen3.5 text backbone followed by a linear
|
| 6 |
-
`score` head over the hidden state of the last non-pad token. Three labels in dleemiller order
|
| 7 |
-
(0 = contradiction, 1 = entailment, 2 = neutral). Input is one string
|
| 8 |
-
`"Premise: {premise}\\nHypothesis: {hypothesis}"` (template stored in `config.nli_template`), right-padded.
|
| 9 |
-
The vision tower that ships with Qwen3.5 checkpoints is kept but unused for text. Loss for the NLI head:
|
| 10 |
-
cross-entropy over the three classes.
|
| 11 |
-
* `LatentMLPHead`: a small MLP (d -> 512 -> 1, GELU, dropout 0.1) on top of the frozen cross-encoder latent
|
| 12 |
-
(the pooled hidden state that feeds `score`), one scalar per (question, option) pair. Loss: soft BCE —
|
| 13 |
-
`BCEWithLogits(logit, y*(1-eps) + (1-y)*eps)` with positives re-weighted by (1-p)/p, p = positive rate.
|
| 14 |
-
At inference the option with the highest score wins (per-question argmax).
|
| 15 |
-
|
| 16 |
-
Usage
|
| 17 |
-
-----
|
| 18 |
-
from modeling_openjev import OpenJevCrossEncoder, LatentMLPHead
|
| 19 |
-
ce = OpenJevCrossEncoder("AlexWortega/openjev", subfolder="qwen3.5-4b-nli")
|
| 20 |
-
ce.predict([("A man is playing a guitar.", "Someone is making music.")]) # -> [[p_con, p_ent, p_neu]]
|
| 21 |
-
ce.rerank("What is the capital of France?", ["Paris", "Lyon", "Berlin"]) # -> index of the best option
|
| 22 |
-
|
| 23 |
-
X = ce.latents(pairs) # (n_pairs, d) float32
|
| 24 |
-
head = LatentMLPHead(X.shape[1]).fit(X, gold, qid) # gold in {0,1} per pair, qid groups pairs by question
|
| 25 |
-
head.predict(X) # (n_pairs,) logits; argmax within each qid
|
| 26 |
-
"""
|
| 27 |
-
from __future__ import annotations
|
| 28 |
-
|
| 29 |
-
import json
|
| 30 |
-
import os
|
| 31 |
-
|
| 32 |
-
import numpy as np
|
| 33 |
-
import torch
|
| 34 |
-
import torch.nn as nn
|
| 35 |
-
|
| 36 |
-
CON, ENT, NEU = 0, 1, 2
|
| 37 |
-
DEFAULT_TEMPLATE = "Premise: {premise}\nHypothesis: {hypothesis}"
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
class OpenJevCrossEncoder:
|
| 41 |
-
def __init__(self, path: str, subfolder: str | None = None, device: str | None = None, dtype=torch.bfloat16,
|
| 42 |
-
bs: int = 32, max_len: int = 4096):
|
| 43 |
-
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
| 44 |
-
|
| 45 |
-
kw = {"subfolder": subfolder} if subfolder else {}
|
| 46 |
-
self.tok = AutoTokenizer.from_pretrained(path, **kw)
|
| 47 |
-
self.model = AutoModelForSequenceClassification.from_pretrained(path, dtype=dtype, **kw)
|
| 48 |
-
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 49 |
-
self.model.to(self.device).eval()
|
| 50 |
-
self.template = getattr(self.model.config, "nli_template", None) or DEFAULT_TEMPLATE
|
| 51 |
-
if self.tok.pad_token is None:
|
| 52 |
-
self.tok.pad_token = self.tok.eos_token
|
| 53 |
-
self.tok.padding_side = "right" # the head pools the last non-pad token
|
| 54 |
-
tc = self.model.config.get_text_config()
|
| 55 |
-
if tc.pad_token_id is None:
|
| 56 |
-
tc.pad_token_id = self.tok.pad_token_id
|
| 57 |
-
self.backbone = getattr(self.model, self.model.base_model_prefix)
|
| 58 |
-
self.bs, self.max_len = bs, max_len
|
| 59 |
-
|
| 60 |
-
def _encode(self, pairs):
|
| 61 |
-
texts = [self.template.format(premise=p.strip(), hypothesis=h.strip()) for p, h in pairs]
|
| 62 |
-
enc = self.tok(texts, truncation=True, max_length=self.max_len, padding=True, return_tensors="pt")
|
| 63 |
-
return {k: v.to(self.device) for k, v in enc.items()}
|
| 64 |
-
|
| 65 |
-
@torch.no_grad()
|
| 66 |
-
def _pooled(self, enc):
|
| 67 |
-
h = self.backbone(**enc).last_hidden_state
|
| 68 |
-
last = enc["attention_mask"].sum(1) - 1
|
| 69 |
-
return h[torch.arange(h.shape[0], device=h.device), last]
|
| 70 |
-
|
| 71 |
-
@torch.no_grad()
|
| 72 |
-
def latents(self, pairs) -> np.ndarray:
|
| 73 |
-
"""Pooled last-token hidden state (the input of the `score` head) for each (premise, hypothesis) pair."""
|
| 74 |
-
out = []
|
| 75 |
-
for i in range(0, len(pairs), self.bs):
|
| 76 |
-
out.append(self._pooled(self._encode(pairs[i:i + self.bs])).float().cpu().numpy())
|
| 77 |
-
return np.concatenate(out, 0)
|
| 78 |
-
|
| 79 |
-
@torch.no_grad()
|
| 80 |
def predict(self, pairs) -> np.ndarray:
|
| 81 |
-
"""Softmax probabilities [contradiction, entailment, neutral] per pair."""
|
| 82 |
-
out = []
|
| 83 |
-
for i in range(0, len(pairs), self.bs):
|
| 84 |
-
logits = self.model.score(self._pooled(self._encode(pairs[i:i + self.bs]))).float()
|
| 85 |
-
out.append(torch.softmax(logits, -1).cpu().numpy())
|
| 86 |
return np.concatenate(out, 0)
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
return int(p[:, ENT].argmax())
|
| 92 |
-
|
| 93 |
-
def grade(self, question: str, reference: str, candidate: str) -> str:
|
| 94 |
-
"""Reference-based grading: premise = question + reference, hypothesis = candidate."""
|
| 95 |
-
p = self.predict([(f"{question}\nReference answer: {reference}", f"Answer: {candidate}")])[0]
|
| 96 |
-
return ["contradiction", "entailment", "neutral"][int(p.argmax())]
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
class _MLP(nn.Module):
|
| 100 |
-
def __init__(self, d, hidden=512, p=0.1):
|
| 101 |
-
super().__init__()
|
| 102 |
-
self.net = nn.Sequential(nn.Linear(d, hidden), nn.GELU(), nn.Dropout(p), nn.Linear(hidden, 1))
|
| 103 |
-
|
| 104 |
-
def forward(self, x):
|
| 105 |
-
return self.net(x).squeeze(-1)
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
def soft_bce(logits, y, eps, pos_weight):
|
| 109 |
-
"""BCE with soft targets: gold -> 1-eps, others -> eps; positives up-weighted by pos_weight."""
|
| 110 |
-
target = y * (1 - eps) + (1 - y) * eps
|
| 111 |
-
w = torch.where(y > 0.5, pos_weight, torch.ones_like(y))
|
| 112 |
-
return (w * nn.functional.binary_cross_entropy_with_logits(logits, target, reduction="none")).mean()
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def per_question_acc(scores, qid, gold):
|
| 116 |
-
"""Fraction of questions whose argmax-scored option is the gold one."""
|
| 117 |
-
order = np.argsort(qid, kind="stable")
|
| 118 |
-
scores, qid, gold = scores[order], qid[order], gold[order]
|
| 119 |
-
starts = np.r_[0, np.flatnonzero(np.diff(qid)) + 1, len(qid)]
|
| 120 |
-
hits = [gold[a:b][scores[a:b].argmax()] == 1 for a, b in zip(starts[:-1], starts[1:])]
|
| 121 |
-
return float(np.mean(hits))
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
def grouped_split(qid, frac, seed):
|
| 125 |
-
qs = np.unique(qid)
|
| 126 |
-
rng = np.random.RandomState(seed)
|
| 127 |
-
rng.shuffle(qs)
|
| 128 |
-
hold = set(qs[: max(1, int(len(qs) * frac))].tolist())
|
| 129 |
-
mask = np.array([q in hold for q in qid])
|
| 130 |
-
return ~mask, mask
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
loss = soft_bce(self.model(Xtr[idx]), ytr[idx], self.cfg["eps"], pos_weight)
|
| 162 |
-
opt.zero_grad(); loss.backward(); opt.step()
|
| 163 |
-
self.model.eval()
|
| 164 |
-
with torch.no_grad():
|
| 165 |
-
acc = per_question_acc(self.model(Xva).cpu().numpy(), qid[va], gold[va])
|
| 166 |
-
if acc > best:
|
| 167 |
-
best, bad, best_state = acc, 0, {k: v.clone() for k, v in self.model.state_dict().items()}
|
| 168 |
-
else:
|
| 169 |
-
bad += 1
|
| 170 |
-
if bad >= self.cfg["patience"]:
|
| 171 |
-
break
|
| 172 |
-
self.model.load_state_dict(best_state)
|
| 173 |
-
self.model.eval()
|
| 174 |
-
self.val_acc = best
|
| 175 |
-
return self
|
| 176 |
|
| 177 |
@torch.no_grad()
|
| 178 |
-
def
|
| 179 |
-
"""
|
| 180 |
-
self.
|
| 181 |
-
return self.model(self._t(np.asarray(X, np.float32))).cpu().numpy()
|
| 182 |
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""openjev: a Qwen3.5 decoder used as an NLI cross-encoder, plus a latent + MLP head trained with soft BCE.
|
| 2 |
+
|
| 3 |
+
Architecture
|
| 4 |
+
------------
|
| 5 |
+
* `OpenJevCrossEncoder`: `Qwen3_5ForSequenceClassification` — the Qwen3.5 text backbone followed by a linear
|
| 6 |
+
`score` head over the hidden state of the last non-pad token. Three labels in dleemiller order
|
| 7 |
+
(0 = contradiction, 1 = entailment, 2 = neutral). Input is one string
|
| 8 |
+
`"Premise: {premise}\\nHypothesis: {hypothesis}"` (template stored in `config.nli_template`), right-padded.
|
| 9 |
+
The vision tower that ships with Qwen3.5 checkpoints is kept but unused for text. Loss for the NLI head:
|
| 10 |
+
cross-entropy over the three classes.
|
| 11 |
+
* `LatentMLPHead`: a small MLP (d -> 512 -> 1, GELU, dropout 0.1) on top of the frozen cross-encoder latent
|
| 12 |
+
(the pooled hidden state that feeds `score`), one scalar per (question, option) pair. Loss: soft BCE —
|
| 13 |
+
`BCEWithLogits(logit, y*(1-eps) + (1-y)*eps)` with positives re-weighted by (1-p)/p, p = positive rate.
|
| 14 |
+
At inference the option with the highest score wins (per-question argmax).
|
| 15 |
+
|
| 16 |
+
Usage
|
| 17 |
+
-----
|
| 18 |
+
from modeling_openjev import OpenJevCrossEncoder, LatentMLPHead
|
| 19 |
+
ce = OpenJevCrossEncoder("AlexWortega/openjev", subfolder="qwen3.5-4b-nli")
|
| 20 |
+
ce.predict([("A man is playing a guitar.", "Someone is making music.")]) # -> [[p_con, p_ent, p_neu]]
|
| 21 |
+
ce.rerank("What is the capital of France?", ["Paris", "Lyon", "Berlin"]) # -> index of the best option
|
| 22 |
+
|
| 23 |
+
X = ce.latents(pairs) # (n_pairs, d) float32
|
| 24 |
+
head = LatentMLPHead(X.shape[1]).fit(X, gold, qid) # gold in {0,1} per pair, qid groups pairs by question
|
| 25 |
+
head.predict(X) # (n_pairs,) logits; argmax within each qid
|
| 26 |
+
"""
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import json
|
| 30 |
+
import os
|
| 31 |
+
|
| 32 |
+
import numpy as np
|
| 33 |
+
import torch
|
| 34 |
+
import torch.nn as nn
|
| 35 |
+
|
| 36 |
+
CON, ENT, NEU = 0, 1, 2
|
| 37 |
+
DEFAULT_TEMPLATE = "Premise: {premise}\nHypothesis: {hypothesis}"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class OpenJevCrossEncoder:
|
| 41 |
+
def __init__(self, path: str, subfolder: str | None = None, device: str | None = None, dtype=torch.bfloat16,
|
| 42 |
+
bs: int = 32, max_len: int = 4096):
|
| 43 |
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
| 44 |
+
|
| 45 |
+
kw = {"subfolder": subfolder} if subfolder else {}
|
| 46 |
+
self.tok = AutoTokenizer.from_pretrained(path, **kw)
|
| 47 |
+
self.model = AutoModelForSequenceClassification.from_pretrained(path, dtype=dtype, **kw)
|
| 48 |
+
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 49 |
+
self.model.to(self.device).eval()
|
| 50 |
+
self.template = getattr(self.model.config, "nli_template", None) or DEFAULT_TEMPLATE
|
| 51 |
+
if self.tok.pad_token is None:
|
| 52 |
+
self.tok.pad_token = self.tok.eos_token
|
| 53 |
+
self.tok.padding_side = "right" # the head pools the last non-pad token
|
| 54 |
+
tc = self.model.config.get_text_config()
|
| 55 |
+
if tc.pad_token_id is None:
|
| 56 |
+
tc.pad_token_id = self.tok.pad_token_id
|
| 57 |
+
self.backbone = getattr(self.model, self.model.base_model_prefix)
|
| 58 |
+
self.bs, self.max_len = bs, max_len
|
| 59 |
+
|
| 60 |
+
def _encode(self, pairs):
|
| 61 |
+
texts = [self.template.format(premise=p.strip(), hypothesis=h.strip()) for p, h in pairs]
|
| 62 |
+
enc = self.tok(texts, truncation=True, max_length=self.max_len, padding=True, return_tensors="pt")
|
| 63 |
+
return {k: v.to(self.device) for k, v in enc.items()}
|
| 64 |
+
|
| 65 |
+
@torch.no_grad()
|
| 66 |
+
def _pooled(self, enc):
|
| 67 |
+
h = self.backbone(**enc).last_hidden_state
|
| 68 |
+
last = enc["attention_mask"].sum(1) - 1
|
| 69 |
+
return h[torch.arange(h.shape[0], device=h.device), last]
|
| 70 |
+
|
| 71 |
+
@torch.no_grad()
|
| 72 |
+
def latents(self, pairs) -> np.ndarray:
|
| 73 |
+
"""Pooled last-token hidden state (the input of the `score` head) for each (premise, hypothesis) pair."""
|
| 74 |
+
out = []
|
| 75 |
+
for i in range(0, len(pairs), self.bs):
|
| 76 |
+
out.append(self._pooled(self._encode(pairs[i:i + self.bs])).float().cpu().numpy())
|
| 77 |
+
return np.concatenate(out, 0)
|
| 78 |
+
|
| 79 |
+
@torch.no_grad()
|
| 80 |
def predict(self, pairs) -> np.ndarray:
|
| 81 |
+
"""Softmax probabilities [contradiction, entailment, neutral] per pair."""
|
| 82 |
+
out = []
|
| 83 |
+
for i in range(0, len(pairs), self.bs):
|
| 84 |
+
logits = self.model.score(self._pooled(self._encode(pairs[i:i + self.bs]))).float()
|
| 85 |
+
out.append(torch.softmax(logits, -1).cpu().numpy())
|
| 86 |
return np.concatenate(out, 0)
|
| 87 |
|
| 88 |
+
@torch.no_grad()
|
| 89 |
+
def _pooled_hypotheses(self, premise: str, hypotheses) -> torch.Tensor:
|
| 90 |
+
"""Score hypotheses with one shared text prefix and a batched cached continuation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
+
Qwen3.5 mixes full and recurrent linear attention. A packed 4D tree mask
|
| 93 |
+
cannot isolate the recurrent layers, so their state and the full-attention
|
| 94 |
+
KV cache are branched into the batch after a single prefix prefill.
|
| 95 |
+
"""
|
| 96 |
+
hypotheses = list(hypotheses)
|
| 97 |
+
if not hypotheses:
|
| 98 |
+
raise ValueError("hypotheses must not be empty")
|
| 99 |
+
pairs = [(premise, h) for h in hypotheses]
|
| 100 |
+
# A second forward and cache branching are unlikely to pay off for
|
| 101 |
+
# one or two hypotheses; use the existing batched path instead.
|
| 102 |
+
if len(pairs) < 3:
|
| 103 |
+
return self._pooled(self._encode(pairs))
|
| 104 |
|
| 105 |
+
# Tokenize complete inputs first: a tokenizer can merge characters at
|
| 106 |
+
# the premise/hypothesis boundary, so a textual split is not reliable.
|
| 107 |
+
texts = [self.template.format(premise=p.strip(), hypothesis=h.strip()) for p, h in pairs]
|
| 108 |
+
sequences = [self.tok(t, truncation=True, max_length=self.max_len)["input_ids"] for t in texts]
|
| 109 |
+
common = 0
|
| 110 |
+
for column in zip(*sequences):
|
| 111 |
+
if len(set(column)) != 1:
|
| 112 |
+
break
|
| 113 |
+
common += 1
|
| 114 |
+
common = min(common, min(map(len, sequences)) - 1)
|
| 115 |
+
if common < 1:
|
| 116 |
+
return self._pooled(self._encode(pairs))
|
| 117 |
|
| 118 |
+
prefix = torch.tensor([sequences[0][:common]], device=self.device)
|
| 119 |
+
prefix_positions = torch.arange(common, device=self.device).unsqueeze(0)
|
| 120 |
+
cache = self.backbone(input_ids=prefix, position_ids=prefix_positions, use_cache=True).past_key_values
|
| 121 |
+
if cache is None:
|
| 122 |
+
raise RuntimeError("Qwen3.5 did not return a cache for shared-prefix inference")
|
| 123 |
+
cache.reorder_cache(torch.zeros(len(sequences), dtype=torch.long, device=self.device))
|
| 124 |
|
| 125 |
+
suffixes = [s[common:] for s in sequences]
|
| 126 |
+
lengths = torch.tensor([len(s) for s in suffixes], device=self.device)
|
| 127 |
+
width = int(lengths.max())
|
| 128 |
+
input_ids = torch.full((len(suffixes), width), self.tok.pad_token_id,
|
| 129 |
+
dtype=torch.long, device=self.device)
|
| 130 |
+
for row, suffix in enumerate(suffixes):
|
| 131 |
+
input_ids[row, :len(suffix)] = torch.tensor(suffix, device=self.device)
|
| 132 |
+
offsets = torch.arange(width, device=self.device).unsqueeze(0)
|
| 133 |
+
valid = offsets < lengths.unsqueeze(1)
|
| 134 |
+
attention_mask = torch.cat((torch.ones((len(suffixes), common), device=self.device,
|
| 135 |
+
dtype=torch.long), valid.long()), dim=1)
|
| 136 |
+
position_ids = (common + offsets).expand(len(suffixes), -1)
|
| 137 |
+
hidden = self.backbone(input_ids=input_ids, attention_mask=attention_mask,
|
| 138 |
+
position_ids=position_ids, past_key_values=cache,
|
| 139 |
+
use_cache=True).last_hidden_state
|
| 140 |
+
return hidden[torch.arange(len(suffixes), device=self.device), lengths - 1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
@torch.no_grad()
|
| 143 |
+
def latents_hypotheses(self, premise: str, hypotheses) -> np.ndarray:
|
| 144 |
+
"""Pooled last-token latents for hypotheses sharing one premise."""
|
| 145 |
+
return self._pooled_hypotheses(premise, hypotheses).float().cpu().numpy()
|
|
|
|
| 146 |
|
| 147 |
+
@torch.no_grad()
|
| 148 |
+
def predict_hypotheses(self, premise: str, hypotheses) -> np.ndarray:
|
| 149 |
+
"""NLI probabilities for one premise and many hypotheses, sharing the prefix."""
|
| 150 |
+
return torch.softmax(self.model.score(self._pooled_hypotheses(premise, hypotheses)).float(), -1).cpu().numpy()
|
| 151 |
+
|
| 152 |
+
def rerank(self, question: str, options, hyp_fmt: str = "The correct answer is: {}") -> int:
|
| 153 |
+
"""Zero-shot multiple choice: option with the highest P(entailment) given the question as premise."""
|
| 154 |
+
p = self.predict_hypotheses(question, [hyp_fmt.format(o) for o in options])
|
| 155 |
+
return int(p[:, ENT].argmax())
|
| 156 |
+
|
| 157 |
+
def grade(self, question: str, reference: str, candidate: str) -> str:
|
| 158 |
+
"""Reference-based grading: premise = question + reference, hypothesis = candidate."""
|
| 159 |
+
p = self.predict([(f"{question}\nReference answer: {reference}", f"Answer: {candidate}")])[0]
|
| 160 |
+
return ["contradiction", "entailment", "neutral"][int(p.argmax())]
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class _MLP(nn.Module):
|
| 164 |
+
def __init__(self, d, hidden=512, p=0.1):
|
| 165 |
+
super().__init__()
|
| 166 |
+
self.net = nn.Sequential(nn.Linear(d, hidden), nn.GELU(), nn.Dropout(p), nn.Linear(hidden, 1))
|
| 167 |
+
|
| 168 |
+
def forward(self, x):
|
| 169 |
+
return self.net(x).squeeze(-1)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def soft_bce(logits, y, eps, pos_weight):
|
| 173 |
+
"""BCE with soft targets: gold -> 1-eps, others -> eps; positives up-weighted by pos_weight."""
|
| 174 |
+
target = y * (1 - eps) + (1 - y) * eps
|
| 175 |
+
w = torch.where(y > 0.5, pos_weight, torch.ones_like(y))
|
| 176 |
+
return (w * nn.functional.binary_cross_entropy_with_logits(logits, target, reduction="none")).mean()
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def per_question_acc(scores, qid, gold):
|
| 180 |
+
"""Fraction of questions whose argmax-scored option is the gold one."""
|
| 181 |
+
order = np.argsort(qid, kind="stable")
|
| 182 |
+
scores, qid, gold = scores[order], qid[order], gold[order]
|
| 183 |
+
starts = np.r_[0, np.flatnonzero(np.diff(qid)) + 1, len(qid)]
|
| 184 |
+
hits = [gold[a:b][scores[a:b].argmax()] == 1 for a, b in zip(starts[:-1], starts[1:])]
|
| 185 |
+
return float(np.mean(hits))
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def grouped_split(qid, frac, seed):
|
| 189 |
+
qs = np.unique(qid)
|
| 190 |
+
rng = np.random.RandomState(seed)
|
| 191 |
+
rng.shuffle(qs)
|
| 192 |
+
hold = set(qs[: max(1, int(len(qs) * frac))].tolist())
|
| 193 |
+
mask = np.array([q in hold for q in qid])
|
| 194 |
+
return ~mask, mask
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
class LatentMLPHead:
|
| 198 |
+
def __init__(self, d: int, hidden: int = 512, dropout: float = 0.1, eps: float = 0.1, lr: float = 1e-3,
|
| 199 |
+
wd: float = 1e-2, bs: int = 512, epochs: int = 60, patience: int = 8, seed: int = 0, device: str | None = None):
|
| 200 |
+
self.cfg = dict(d=d, hidden=hidden, dropout=dropout, eps=eps, lr=lr, wd=wd, bs=bs, epochs=epochs, patience=patience, seed=seed)
|
| 201 |
+
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 202 |
+
torch.manual_seed(seed)
|
| 203 |
+
self.model = _MLP(d, hidden, dropout).to(self.device)
|
| 204 |
+
self.mu, self.sd = np.zeros((1, d), np.float32), np.ones((1, d), np.float32)
|
| 205 |
+
|
| 206 |
+
def _t(self, X):
|
| 207 |
+
return torch.tensor((X - self.mu) / self.sd, dtype=torch.float32, device=self.device)
|
| 208 |
+
|
| 209 |
+
def fit(self, X, gold, qid, val_frac: float = 0.1):
|
| 210 |
+
"""X: (n_pairs, d); gold: 1 for the correct option of a question, 0 otherwise; qid: question id per pair.
|
| 211 |
+
Early stopping on per-question accuracy of a grouped hold-out; restores the best weights."""
|
| 212 |
+
X, gold, qid = np.asarray(X, np.float32), np.asarray(gold, np.float32), np.asarray(qid)
|
| 213 |
+
tr, va = grouped_split(qid, val_frac, self.cfg["seed"])
|
| 214 |
+
self.mu, self.sd = X[tr].mean(0, keepdims=True), X[tr].std(0, keepdims=True) + 1e-6
|
| 215 |
+
Xtr, ytr, Xva = self._t(X[tr]), torch.tensor(gold[tr], device=self.device), self._t(X[va])
|
| 216 |
+
p = float(gold[tr].mean())
|
| 217 |
+
pos_weight = torch.tensor((1 - p) / max(p, 1e-6), device=self.device)
|
| 218 |
+
opt = torch.optim.AdamW(self.model.parameters(), lr=self.cfg["lr"], weight_decay=self.cfg["wd"])
|
| 219 |
+
best, best_state, bad = -1.0, None, 0
|
| 220 |
+
for ep in range(self.cfg["epochs"]):
|
| 221 |
+
self.model.train()
|
| 222 |
+
perm = torch.randperm(len(Xtr), device=self.device)
|
| 223 |
+
for s in range(0, len(Xtr), self.cfg["bs"]):
|
| 224 |
+
idx = perm[s:s + self.cfg["bs"]]
|
| 225 |
+
loss = soft_bce(self.model(Xtr[idx]), ytr[idx], self.cfg["eps"], pos_weight)
|
| 226 |
+
opt.zero_grad(); loss.backward(); opt.step()
|
| 227 |
+
self.model.eval()
|
| 228 |
+
with torch.no_grad():
|
| 229 |
+
acc = per_question_acc(self.model(Xva).cpu().numpy(), qid[va], gold[va])
|
| 230 |
+
if acc > best:
|
| 231 |
+
best, bad, best_state = acc, 0, {k: v.clone() for k, v in self.model.state_dict().items()}
|
| 232 |
+
else:
|
| 233 |
+
bad += 1
|
| 234 |
+
if bad >= self.cfg["patience"]:
|
| 235 |
+
break
|
| 236 |
+
self.model.load_state_dict(best_state)
|
| 237 |
+
self.model.eval()
|
| 238 |
+
self.val_acc = best
|
| 239 |
+
return self
|
| 240 |
+
|
| 241 |
+
@torch.no_grad()
|
| 242 |
+
def predict(self, X) -> np.ndarray:
|
| 243 |
+
"""One logit per pair; pick the argmax within each question (sigmoid gives a per-option probability)."""
|
| 244 |
+
self.model.eval()
|
| 245 |
+
return self.model(self._t(np.asarray(X, np.float32))).cpu().numpy()
|
| 246 |
+
|
| 247 |
+
def save(self, path: str):
|
| 248 |
+
os.makedirs(path, exist_ok=True)
|
| 249 |
+
torch.save(self.model.state_dict(), os.path.join(path, "head.pt"))
|
| 250 |
+
np.savez(os.path.join(path, "norm.npz"), mu=self.mu, sd=self.sd)
|
| 251 |
+
json.dump(self.cfg, open(os.path.join(path, "config.json"), "w"), indent=2)
|
| 252 |
+
|
| 253 |
+
@classmethod
|
| 254 |
+
def load(cls, path: str, device: str | None = None) -> "LatentMLPHead":
|
| 255 |
+
cfg = json.load(open(os.path.join(path, "config.json")))
|
| 256 |
+
head = cls(device=device, **cfg)
|
| 257 |
+
head.model.load_state_dict(torch.load(os.path.join(path, "head.pt"), map_location=head.device))
|
| 258 |
+
z = np.load(os.path.join(path, "norm.npz"))
|
| 259 |
+
head.mu, head.sd = z["mu"], z["sd"]
|
| 260 |
+
head.model.eval()
|
| 261 |
+
return head
|
test_shared_prefix.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small CPU checks for shared-prefix inference; no checkpoint download needed."""
|
| 2 |
+
import unittest
|
| 3 |
+
from unittest.mock import patch
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
from transformers import (Qwen3_5Config, Qwen3_5ForSequenceClassification,
|
| 8 |
+
Qwen3_5TextConfig, Qwen3_5VisionConfig)
|
| 9 |
+
|
| 10 |
+
from modeling_openjev import OpenJevCrossEncoder
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class CharacterTokenizer:
|
| 14 |
+
pad_token_id = 0
|
| 15 |
+
|
| 16 |
+
def __call__(self, texts, truncation=True, max_length=4096, padding=False, return_tensors=None):
|
| 17 |
+
if isinstance(texts, str):
|
| 18 |
+
return {"input_ids": [ord(c) + 1 for c in texts][:max_length]}
|
| 19 |
+
rows = [[ord(c) + 1 for c in text][:max_length] for text in texts]
|
| 20 |
+
width = max(map(len, rows))
|
| 21 |
+
ids = [row + [0] * (width - len(row)) for row in rows]
|
| 22 |
+
mask = [[1] * len(row) + [0] * (width - len(row)) for row in rows]
|
| 23 |
+
return {"input_ids": torch.tensor(ids), "attention_mask": torch.tensor(mask)}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class SharedPrefixTest(unittest.TestCase):
|
| 27 |
+
@classmethod
|
| 28 |
+
def setUpClass(cls):
|
| 29 |
+
torch.manual_seed(7)
|
| 30 |
+
config = Qwen3_5TextConfig(
|
| 31 |
+
vocab_size=256, hidden_size=32, intermediate_size=64,
|
| 32 |
+
num_hidden_layers=4, num_attention_heads=4, num_key_value_heads=2,
|
| 33 |
+
head_dim=8, linear_num_key_heads=2, linear_num_value_heads=4,
|
| 34 |
+
linear_key_head_dim=8, linear_value_head_dim=8,
|
| 35 |
+
layer_types=["linear_attention", "full_attention"] * 2,
|
| 36 |
+
rope_parameters={"rope_type": "default", "rope_theta": 10000,
|
| 37 |
+
"partial_rotary_factor": 0.25, "mrope_section": [1, 0, 0]},
|
| 38 |
+
pad_token_id=0, num_labels=3, use_cache=False,
|
| 39 |
+
)
|
| 40 |
+
vision = Qwen3_5VisionConfig(depth=1, hidden_size=32, intermediate_size=64,
|
| 41 |
+
num_heads=4, out_hidden_size=32)
|
| 42 |
+
model = Qwen3_5ForSequenceClassification(
|
| 43 |
+
Qwen3_5Config(text_config=config, vision_config=vision, num_labels=3,
|
| 44 |
+
pad_token_id=0)).eval()
|
| 45 |
+
cls.jev = OpenJevCrossEncoder.__new__(OpenJevCrossEncoder)
|
| 46 |
+
cls.jev.model = model
|
| 47 |
+
cls.jev.backbone = model.model
|
| 48 |
+
cls.jev.tok = CharacterTokenizer()
|
| 49 |
+
cls.jev.template = "Premise: {premise}\nHypothesis: {hypothesis}"
|
| 50 |
+
cls.jev.device = "cpu"
|
| 51 |
+
cls.jev.bs = 32
|
| 52 |
+
cls.jev.max_len = 128
|
| 53 |
+
|
| 54 |
+
def test_shared_matches_independent_and_isolates_branches(self):
|
| 55 |
+
premise = "A cat sleeps."
|
| 56 |
+
hypotheses = ["A cat rests.", "The dog runs.", "A cat."]
|
| 57 |
+
expected = self.jev.latents([(premise, h) for h in hypotheses])
|
| 58 |
+
actual = self.jev.latents_hypotheses(premise, hypotheses)
|
| 59 |
+
np.testing.assert_allclose(actual, expected, atol=2e-4, rtol=2e-4)
|
| 60 |
+
expected_probs = self.jev.predict([(premise, h) for h in hypotheses])
|
| 61 |
+
actual_probs = self.jev.predict_hypotheses(premise, hypotheses)
|
| 62 |
+
np.testing.assert_allclose(actual_probs, expected_probs, atol=2e-4, rtol=2e-4)
|
| 63 |
+
|
| 64 |
+
changed = hypotheses.copy()
|
| 65 |
+
changed[1] = "Birds fly around today."
|
| 66 |
+
other = self.jev.latents_hypotheses(premise, changed)
|
| 67 |
+
np.testing.assert_allclose(other[[0, 2]], actual[[0, 2]], atol=2e-4, rtol=2e-4)
|
| 68 |
+
|
| 69 |
+
def test_small_option_sets_use_pairwise_batch(self):
|
| 70 |
+
for hypotheses in (["B"], ["B", "C"]):
|
| 71 |
+
with self.subTest(count=len(hypotheses)):
|
| 72 |
+
with patch.object(self.jev, "_pooled", wraps=self.jev._pooled) as pooled:
|
| 73 |
+
actual = self.jev.predict_hypotheses("A", hypotheses)
|
| 74 |
+
self.assertEqual(actual.shape, (len(hypotheses), 3))
|
| 75 |
+
pooled.assert_called_once()
|
| 76 |
+
|
| 77 |
+
def test_empty(self):
|
| 78 |
+
with self.assertRaises(ValueError):
|
| 79 |
+
self.jev.predict_hypotheses("A", [])
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if __name__ == "__main__":
|
| 83 |
+
unittest.main()
|