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
Download model.py from ternary/Arbiter-8B: direct link, hf CLI and curl.
- Browser
- Download file 13.7 kB
-
https://huggingface.co/ternary/Arbiter-8B/resolve/main/model.py
- Command line
-
hf download hf://ternary/Arbiter-8B/model.py
-
curl -L -o model.py https://huggingface.co/ternary/Arbiter-8B/resolve/main/model.py
13.7 kB
| """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} | |