Text Classification
Transformers
Safetensors
English
qwen3
text-generation
qwen
decision-model
finance
wealth-management
sec-filings
news
calibration
lora
jev
text-embeddings-inference
Instructions to use ternary/Arbiter-8B with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use ternary/Arbiter-8B with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="ternary/Arbiter-8B")# pip install -U transformers accelerate # Load model directly from transformers import AutoTokenizer, AutoModelForCausalLM tokenizer = AutoTokenizer.from_pretrained("ternary/Arbiter-8B") model = AutoModelForCausalLM.from_pretrained("ternary/Arbiter-8B", device_map="auto") - Notebooks
- Google Colab
- Kaggle
File size: 13,686 Bytes
53ab3d4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 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 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 | """Jev-style decision model following "Releasing the Inner Jev" (Geuter et al., 2026).
A decision is rendered as a lettered multiple-choice prompt (SemIf system message, JSON user
message, the model's own chat template with thinking disabled). One forward pass gives the final
hidden state ``h`` at the last prompt position, and the decision head maps it to one logit per
option slot:
z = W0 h + A std(h) + b
``W0`` holds the frozen language-model-head rows for the letters A..P, so the untrained model is the
zero-shot letter readout. ``A`` and ``b`` start at zero and are trained jointly with LoRA adapters.
A softmax over the question's K options (divided by a per-type temperature) gives the probabilities.
"""
from __future__ import annotations
import json
import random
from collections import Counter
from pathlib import Path
from typing import Any
import torch
from torch import nn
SYSTEM_PROMPT = (
"Apply the supplied criterion to the supplied evidence. Choose exactly one listed option. "
"Respond with only its uppercase letter, with no explanation or reasoning."
)
LETTERS = "ABCDEFGHIJKLMNOP"
MAX_OPTIONS = len(LETTERS)
QUESTION_TYPES = ("choice", "noul", "score")
TRUNCATION_MARK = " [truncated]"
MASKED_LOGIT = -1e9
def question_type(row: dict[str, Any]) -> str:
return row["question"]["type"]
def request_order(row: dict[str, Any]) -> list[str]:
"""Evaluation order: choice in request order, yes/no as true then false, score low to high."""
if question_type(row) == "noul":
return ["true", "false"]
return list(row["labels"])
def training_order(row: dict[str, Any], rng: random.Random) -> list[str]:
"""Options are shuffled every presentation; score levels keep their order."""
order = request_order(row)
if question_type(row) != "score":
rng.shuffle(order)
return order
def user_message(row: dict[str, Any], order: list[str], state: str | None = None) -> str:
criteria = row["question"]["criteria"]
criterion = row["question"]["instructions"]
surface = (row.get("focus") or {}).get("surface")
if surface:
criterion = f"{criterion} Focus entity: {surface}."
return json.dumps({
"evidence": row["state"] if state is None else state,
"criterion": criterion,
"options": [
{"letter": LETTERS[index], "description": f"{label}: {criteria[label]}"}
for index, label in enumerate(order)
],
}, ensure_ascii=False)
def _chat_ids(tokenizer: Any, user: str) -> list[int]:
messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
)
return tokenizer(text, add_special_tokens=False)["input_ids"]
def encode(
tokenizer: Any,
row: dict[str, Any],
order: list[str],
max_length: int,
truncate_state: bool,
) -> tuple[list[int] | None, bool]:
"""Token ids for one decision and whether the state was truncated.
Over-long inputs are shortened from the end of the state, never the options or the final
answer position. With ``truncate_state=False`` (training, as in the paper) they return None.
"""
if len(order) > MAX_OPTIONS:
raise ValueError(f"{row['id']}: {len(order)} options; the letter readout supports {MAX_OPTIONS}")
ids = _chat_ids(tokenizer, user_message(row, order))
if len(ids) <= max_length:
return ids, False
if not truncate_state:
return None, False
state_ids = tokenizer(row["state"], add_special_tokens=False)["input_ids"]
overflow = len(ids) - max_length
for _ in range(4):
keep = len(state_ids) - overflow - 8
if keep <= 0:
break
state = tokenizer.decode(state_ids[:keep]) + TRUNCATION_MARK
ids = _chat_ids(tokenizer, user_message(row, order, state))
if len(ids) <= max_length:
return ids, True
overflow += len(ids) - max_length
raise ValueError(f"{row['id']}: question and options alone exceed max_length={max_length}")
def letter_token_ids(tokenizer: Any) -> list[int]:
ids = []
for letter in LETTERS:
token_ids = tokenizer.encode(letter, add_special_tokens=False)
if len(token_ids) != 1:
raise ValueError(f"letter {letter!r} is not a single token for this tokenizer: {token_ids}")
ids.append(token_ids[0])
return ids
class DecisionHead(nn.Module):
"""z = W0 h + A std(h) + b with W0 frozen from the LM head and A, b initialised to zero."""
def __init__(self, letter_rows: torch.Tensor, letter_bias: torch.Tensor | None = None,
softcap: float | None = None):
super().__init__()
slots, hidden = letter_rows.shape
self.register_buffer("letter_rows", letter_rows.detach().float().clone())
self.register_buffer(
"letter_bias",
torch.zeros(slots) if letter_bias is None else letter_bias.detach().float().clone(),
)
self.register_buffer("feature_mean", torch.zeros(hidden))
self.register_buffer("feature_std", torch.ones(hidden))
self.correction = nn.Parameter(torch.zeros(slots, hidden))
self.bias = nn.Parameter(torch.zeros(slots))
self.softcap = softcap
def set_feature_stats(self, features: torch.Tensor) -> None:
"""Fix the standardisation once on training features, before training."""
features = features.float()
self.feature_mean.copy_(features.mean(dim=0))
self.feature_std.copy_(features.std(dim=0, unbiased=False).clamp(min=1e-6))
def forward(self, hidden: torch.Tensor) -> torch.Tensor:
hidden = hidden.float()
readout = hidden @ self.letter_rows.T + self.letter_bias
if self.softcap:
readout = self.softcap * torch.tanh(readout / self.softcap)
standardized = (hidden - self.feature_mean) / self.feature_std
return readout + standardized @ self.correction.T + self.bias
class DecisionModel(nn.Module):
"""Decoder backbone (optionally LoRA-wrapped) plus the letter-initialised decision head."""
def __init__(self, backbone: nn.Module, head: DecisionHead):
super().__init__()
self.backbone = backbone
self.head = head
def last_hidden(self, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
hidden = self.backbone(
input_ids=input_ids, attention_mask=attention_mask, use_cache=False
).last_hidden_state
last = attention_mask.sum(dim=1).clamp(min=1) - 1
pooled = hidden[torch.arange(hidden.size(0), device=hidden.device), last.to(hidden.device)]
return pooled.to(self.head.letter_rows.device)
def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor,
option_counts: torch.Tensor) -> torch.Tensor:
logits = self.head(self.last_hidden(input_ids, attention_mask))
slots = torch.arange(logits.size(1), device=logits.device)
invalid = slots.unsqueeze(0) >= option_counts.to(logits.device).unsqueeze(1)
return logits.masked_fill(invalid, MASKED_LOGIT)
def pad_batch(sequences: list[list[int]], pad_id: int, device: torch.device | str):
width = max(len(sequence) for sequence in sequences)
input_ids = torch.full((len(sequences), width), pad_id, dtype=torch.long)
attention_mask = torch.zeros((len(sequences), width), dtype=torch.long)
for index, sequence in enumerate(sequences):
input_ids[index, :len(sequence)] = torch.tensor(sequence, dtype=torch.long)
attention_mask[index, :len(sequence)] = 1
return input_ids.to(device), attention_mask.to(device)
def parse_max_memory(spec: str | None) -> dict[Any, str] | None:
if not spec:
return None
result: dict[Any, str] = {}
for part in spec.split(","):
key, value = part.split("=")
result[int(key) if key.strip().isdigit() else key.strip()] = value.strip()
return result
def pick_dtype(counter: Counter) -> torch.dtype:
if not torch.cuda.is_available():
counter["cpu_execution_float32"] += 1
return torch.float32
if all(torch.cuda.get_device_capability(index)[0] >= 8 for index in range(torch.cuda.device_count())):
return torch.bfloat16
counter["float16_no_bf16_device"] += 1
return torch.float16
def load_decision_model(
model_id: str,
*,
quantization: str = "none",
max_memory: str | None = None,
adapter_dir: Path | None = None,
head_path: Path | None = None,
trainable_adapter: bool = False,
counter: Counter | None = None,
local_files_only: bool = False,
):
"""Load backbone, tokenizer and head. Returns (model, tokenizer, letter_ids, dtype)."""
from transformers import AutoModelForCausalLM, AutoTokenizer
counter = counter if counter is not None else Counter()
tokenizer = AutoTokenizer.from_pretrained(model_id, local_files_only=local_files_only)
if tokenizer.pad_token_id is None:
tokenizer.pad_token = tokenizer.eos_token
counter["pad_token_from_eos"] += 1
dtype = pick_dtype(counter)
if torch.cuda.is_available() and any(
torch.cuda.get_device_capability(index)[0] < 8 for index in range(torch.cuda.device_count())
):
# The flash SDPA kernel needs Ampere+ (the RTX 2070 SUPER is Turing); keep mem-efficient SDPA.
torch.backends.cuda.enable_flash_sdp(False)
counter["flash_sdp_disabled_pre_ampere_gpu"] += 1
kwargs: dict[str, Any] = {"dtype": dtype, "local_files_only": local_files_only,
"attn_implementation": "sdpa"}
if torch.cuda.is_available():
kwargs["device_map"] = "auto"
memory = parse_max_memory(max_memory)
if memory:
kwargs["max_memory"] = memory
if quantization == "4bit":
from transformers import BitsAndBytesConfig
kwargs["quantization_config"] = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=dtype,
)
elif quantization != "none":
raise ValueError(f"unknown quantization {quantization!r}")
language_model = AutoModelForCausalLM.from_pretrained(model_id, **kwargs)
letter_ids = letter_token_ids(tokenizer)
offloaded = sorted({str(d) for d in (getattr(language_model, "hf_device_map", None) or {}).values()
if str(d) in ("cpu", "disk")})
if offloaded:
counter["layers_offloaded_to_" + "_".join(offloaded)] += 1
output = language_model.get_output_embeddings()
weight = output.weight
if weight.device.type == "meta":
# Offloaded by accelerate: the real tensor lives in the module hook's weights map.
weight = output._hf_hook.weights_map["weight"]
letter_rows = weight[letter_ids].detach().float().cpu()
letter_bias = output.bias[letter_ids].detach().float().cpu() if getattr(output, "bias", None) is not None else None
config = language_model.config
softcap = getattr(config, "final_logit_softcapping", None) or getattr(
getattr(config, "text_config", None), "final_logit_softcapping", None
)
backbone = language_model.get_decoder()
del language_model
head = DecisionHead(letter_rows, letter_bias, softcap)
if head_path is not None:
payload = torch.load(head_path, map_location="cpu", weights_only=True)
if payload["letter_ids"] != letter_ids:
raise ValueError("checkpoint letter ids do not match this tokenizer")
head.load_state_dict(payload["head_state_dict"])
head_device = next(backbone.parameters()).device
head.to(head_device)
if adapter_dir is not None:
from peft import PeftModel
backbone = PeftModel.from_pretrained(backbone, str(adapter_dir), is_trainable=trainable_adapter)
model = DecisionModel(backbone, head)
return model, tokenizer, letter_ids, dtype
def option_logits(model: DecisionModel, tokenizer: Any, row: dict[str, Any], max_length: int,
counter: Counter) -> tuple[dict[str, float], bool]:
"""Raw decision-head logits for the row's options, in request order (one forward pass)."""
order = request_order(row)
ids, truncated = encode(tokenizer, row, order, max_length, truncate_state=True)
if truncated:
counter["state_truncated"] += 1
device = next(model.backbone.parameters()).device
input_ids, attention_mask = pad_batch([ids], tokenizer.pad_token_id, device)
counts = torch.tensor([len(order)])
model.eval()
with torch.inference_mode():
logits = model(input_ids, attention_mask, counts)[0, :len(order)].float().cpu()
return {label: float(logits[index]) for index, label in enumerate(order)}, truncated
def probabilities(logits: dict[str, float], temperature: float = 1.0) -> dict[str, float]:
values = torch.tensor(list(logits.values()), dtype=torch.float64) / temperature
probs = torch.softmax(values, dim=0).tolist()
return dict(zip(logits, probs))
def jev_answer(row: dict[str, Any], probs: dict[str, float]) -> dict[str, Any]:
"""Typed answer in TypeSafe's format: choice, noul (P(true)) or score."""
kind = question_type(row)
best = max(probs, key=probs.get)
if kind == "noul":
return {"type": "noul", "noul": probs["true"]}
if kind == "score":
return {"type": "score", "score": int(best) if best.isdigit() else best, "probabilities": probs}
return {"type": "choice", "choice": best, "probabilities": probs}
|