Arbiter-8B / model.py
jsemrau's picture
Arbiter v0.4: LoRA adapter, decision head, calibration, inference code and model card
53ab3d4 verified
Raw History Blame Contribute Delete
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}