Share Qwen3.5 prefix cache across NLI hypotheses

#1
by epsilon3 - opened
Files changed (3) hide show
  1. README.md +98 -85
  2. modeling_openjev.py +251 -187
  3. 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
- ![radar](assets/radar_openjev.png)
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
- ![radar 35B](assets/radar_openjev_35b.png)
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`, `rerank`, `grade`, `latents`; `LatentMLPHead` for the per-task heads.
59
- * `modeling_qwen35_moe_seqcls.py` — `Qwen3_5MoeForSequenceClassification` for the 35B-A3B backbone.
60
- * `mlp_heads_35b/<task>/` — `head.pt` + `norm.npz` + `meta.json`, the 35B latent + MLP heads behind the second radar.
61
- * `code/` — everything used here: the trainer and data mixture builder, the evaluation harness, Flappy Bird, Doom
62
- (text and pixels), the Minecraft scaffold and bot, the radar.
63
- * `videos/` — Flappy Bird, Doom and Minecraft replays; `results/` — raw JSON for every run and the full report.
64
-
65
- ## Use it
66
-
67
- ```python
68
- from modeling_openjev import OpenJevCrossEncoder
69
- jev = OpenJevCrossEncoder("AlexWortega/openjev", subfolder="qwen3.5-4b-nli-v2")
70
-
71
- jev.predict([("The bird is 0.05 below the centre of the gap.", "The bird is below the centre of the gap.")])
72
- # -> [[contradiction, entailment, neutral]] probabilities
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
- Or with plain transformers:
79
-
80
- ```python
81
- from transformers import AutoModelForSequenceClassification, AutoTokenizer
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
- Images go inside the premise as `<|vision_start|><|image_pad|>…<|vision_end|>` with `pixel_values` / `image_grid_thw`
88
- from the Qwen3.5 image processor; see `code/doom_vision.py` and `code/eval_image_nli.py`.
89
-
90
- Reference point: [dleemiller's NLI cross-encoders](https://huggingface.co/blog/dleemiller/nli-xenc-ways-to-use). Licence MIT.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ![radar](assets/radar_openjev.png)
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
+ ![radar 35B](assets/radar_openjev_35b.png)
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
- def rerank(self, question: str, options, hyp_fmt: str = "The correct answer is: {}") -> int:
89
- """Zero-shot multiple choice: option with the highest P(entailment) given the question as premise."""
90
- p = self.predict([(question, hyp_fmt.format(o)) for o in options])
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
- class LatentMLPHead:
134
- def __init__(self, d: int, hidden: int = 512, dropout: float = 0.1, eps: float = 0.1, lr: float = 1e-3,
135
- wd: float = 1e-2, bs: int = 512, epochs: int = 60, patience: int = 8, seed: int = 0, device: str | None = None):
136
- self.cfg = dict(d=d, hidden=hidden, dropout=dropout, eps=eps, lr=lr, wd=wd, bs=bs, epochs=epochs, patience=patience, seed=seed)
137
- self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
138
- torch.manual_seed(seed)
139
- self.model = _MLP(d, hidden, dropout).to(self.device)
140
- self.mu, self.sd = np.zeros((1, d), np.float32), np.ones((1, d), np.float32)
 
 
 
 
141
 
142
- def _t(self, X):
143
- return torch.tensor((X - self.mu) / self.sd, dtype=torch.float32, device=self.device)
 
 
 
 
144
 
145
- def fit(self, X, gold, qid, val_frac: float = 0.1):
146
- """X: (n_pairs, d); gold: 1 for the correct option of a question, 0 otherwise; qid: question id per pair.
147
- Early stopping on per-question accuracy of a grouped hold-out; restores the best weights."""
148
- X, gold, qid = np.asarray(X, np.float32), np.asarray(gold, np.float32), np.asarray(qid)
149
- tr, va = grouped_split(qid, val_frac, self.cfg["seed"])
150
- self.mu, self.sd = X[tr].mean(0, keepdims=True), X[tr].std(0, keepdims=True) + 1e-6
151
- Xtr, ytr, Xva = self._t(X[tr]), torch.tensor(gold[tr], device=self.device), self._t(X[va])
152
- p = float(gold[tr].mean())
153
- pos_weight = torch.tensor((1 - p) / max(p, 1e-6), device=self.device)
154
- opt = torch.optim.AdamW(self.model.parameters(), lr=self.cfg["lr"], weight_decay=self.cfg["wd"])
155
- best, best_state, bad = -1.0, None, 0
156
- for ep in range(self.cfg["epochs"]):
157
- self.model.train()
158
- perm = torch.randperm(len(Xtr), device=self.device)
159
- for s in range(0, len(Xtr), self.cfg["bs"]):
160
- idx = perm[s:s + self.cfg["bs"]]
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 predict(self, X) -> np.ndarray:
179
- """One logit per pair; pick the argmax within each question (sigmoid gives a per-option probability)."""
180
- self.model.eval()
181
- return self.model(self._t(np.asarray(X, np.float32))).cpu().numpy()
182
 
183
- def save(self, path: str):
184
- os.makedirs(path, exist_ok=True)
185
- torch.save(self.model.state_dict(), os.path.join(path, "head.pt"))
186
- np.savez(os.path.join(path, "norm.npz"), mu=self.mu, sd=self.sd)
187
- json.dump(self.cfg, open(os.path.join(path, "config.json"), "w"), indent=2)
188
-
189
- @classmethod
190
- def load(cls, path: str, device: str | None = None) -> "LatentMLPHead":
191
- cfg = json.load(open(os.path.join(path, "config.json")))
192
- head = cls(device=device, **cfg)
193
- head.model.load_state_dict(torch.load(os.path.join(path, "head.pt"), map_location=head.device))
194
- z = np.load(os.path.join(path, "norm.npz"))
195
- head.mu, head.sd = z["mu"], z["sd"]
196
- head.model.eval()
197
- return head
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()