hayriyigit's picture
decider LoRA fine-tune
8fe5d78 verified
Raw History Blame
22.4 kB
"""Usable inference API: typed decisions with probabilities, all from one forward pass.
from decider.infer import Decider
d = Decider("runs/r2_full/model")
out = d.decide("My card was charged twice for the same purchase.",
[{"question": "Which department should handle this?", "options": ["billing", "technical", "sales"]},
{"question": "How urgent is this?", "options": ["low", "medium", "high"]}])
# -> [{'choice': 'billing', 'confidence': 0.97, 'probs': {...}}, {...}]
"""
import logging
import torch
from decider.model import DecisionModel, collate
from decider.prompt import build, MAX_OPTIONS, resolve_layout, chat_template
from decider import temperature as TT
from dataclasses import dataclass
@dataclass
class Q:
text: str; options: list; gold: int = 0
@dataclass
class Example:
context: str; qs: list; task: str = "infer"; image: bytes = None
logger = logging.getLogger(__name__)
NEUTRAL_NONE = "not listed here"
def neutralize_options(options):
"""The training augmentation used the literal 'none of the above', and the model learned that exact string as an
abstain signal (it abstains even on clear cases when the string is offered). Any option that reads like it is
rewritten to a neutral phrasing for the model and mapped back in the output."""
out, back = [], {}
for o in options:
key = o.strip().lower()
if key.startswith("none of the above") or key in ("none of the above", "none", "n/a", "none of these"):
out.append(NEUTRAL_NONE); back[NEUTRAL_NONE] = o
else:
out.append(o)
return out, back
class _NoShuffle: # keep option order as given
def shuffle(self, x): pass
def sample(self, xs, k): return xs[:k]
class CompiledSchema:
def __init__(self, d, rqs, h, index):
from decider.systemone import row_types
self.d, self.rqs, self.h, self.index = d, rqs, h, index
self.types = row_types(rqs, index) # answer type of every schema row (decider.temperature)
def batch(self, states, max_state_tokens=32768):
from decider.systemone import render_state, assemble
T = TT.for_types(self.d.T_schema, self.d.T_schema_by_type, self.types)
probs = self.d._se.score(self.h, [render_state(s) for s in states], temperature=T, max_ctx_tokens=max_state_tokens)
return [{"model": self.d.name, "answers": assemble(self.rqs, self.index, [p.tolist() for p in pr])} for pr in probs]
def __call__(self, state, max_state_tokens=32768):
return self.batch([state], max_state_tokens)[0]
class Decider:
"""One-pass decisions with automatic CUDA, MPS, or CPU device selection.
CUDA uses shape-bucketed graphs by default. MPS defaults to float16 and uses
the optional MPS patch; CPU defaults to bfloat16. Set ``use_graphs=False``
for eager execution or debugging.
"""
def __init__(self, path, device=None, dtype=None, temperature=None, abstain_below=0.0, use_graphs=None, temperature_by_type=None):
"""The prompt layout comes from decider_config.json: "layout": "chat" (chat-trained checkpoints) wraps every prompt in the
tokenizer's chat template (decider.prompt.build_chat); no "layout" key is the plain layout of every earlier model.
An unknown layout raises ValueError before the weights are loaded.
Temperatures (decider.temperature): decider_config.json "temperature" and the optional "temperature_by_type"
{"choice": T, "noul": T, "score": T}. temperature= overrides "temperature" and switches the config's by-type map off;
temperature_by_type= sets the map explicitly. An invalid temperature or map key raises ValueError before the weights
are loaded."""
if device is None:
device = "cuda" if torch.cuda.is_available() else ("mps" if torch.backends.mps.is_available() else "cpu")
if dtype is None:
dtype = torch.float16 if str(device).startswith("mps") else torch.bfloat16
logger.info("Decider device=%s dtype=%s", device, dtype)
import json, os
cfg = {}
try: # model folder may carry decider_config.json (temperature, flags)
from huggingface_hub import hf_hub_download
cfg_path = os.path.join(path, "decider_config.json") if os.path.isdir(path) else hf_hub_download(path, "decider_config.json")
cfg = json.load(open(cfg_path))
except Exception:
pass
self.layout = resolve_layout(cfg)
(temperature, self.T_by_type), (T_schema, self.T_schema_by_type) = TT.from_config(cfg, temperature, temperature_by_type)
self.neutralize_none = bool(cfg.get("neutralize_none", True)) # v4 and earlier learned the literal string as an abstain signal
if use_graphs is None:
use_graphs = str(device).startswith("cuda")
if use_graphs:
from decider.engine import Engine
self.eng = Engine(path, device=device, dtype=dtype); self.m = self.eng.m
else:
if str(device).startswith("mps"):
from decider.mps_ops import patch_mps
patch_mps()
self.eng = None; self.m = DecisionModel(path, dtype=dtype, grad_ckpt=False).to(device).eval()
self.chat = chat_template(self.m.tok) if self.layout == "chat" else None # None: plain layout, prompts as in 1.1.x
self.dev = device; self.T = temperature; self.abstain_below = abstain_below
self.name = "decider-" + str(cfg.get("version", "dev"))
self.schema_first = bool(cfg.get("schema_first", False)) and self.eng is not None # default layout. Questions-first (the cacheable one) costs accuracy
self.T_schema = T_schema # (about 1.5 points on fixed label sets, more elsewhere): opt in with schema()
self.isolated_levels = bool(cfg.get("isolated_levels", False)) # Score levels judged one per row (v8+)
self._se = None; self._schemas = {}
def _decide_items(self, requests, max_ctx_tokens=1536):
"""The prompt rows of decide_batch: -> (requests with neutralized options, one item per request)."""
exs = []
if self.neutralize_none:
requests = [(context, [dict(q, options=neutralize_options(q["options"])[0], _back=neutralize_options(q["options"])[1]) for q in qs]) for context, qs in requests]
for context, qs in requests:
for q in qs:
assert 2 <= len(q["options"]) <= MAX_OPTIONS, f"2..{MAX_OPTIONS} options required"
exs.append(Example(context, [Q(q["question"], list(q["options"]), 0) for q in qs], "infer"))
items = [build(e, self.m.tok, _NoShuffle(), max_options=MAX_OPTIONS, max_ctx_tokens=max_ctx_tokens, chat=self.chat) for e in exs]
for it, (_, qs) in zip(items, requests): # answer type per slot: a plain question is "choice"
it["types"] = [q.get("_type", "choice") for q in qs]
return requests, items
@torch.no_grad()
def decide_batch(self, requests, max_ctx_tokens=1536):
"""requests: list of (context:str, questions:list[dict(question, options)]). One forward pass for everything."""
requests, items = self._decide_items(requests, max_ctx_tokens)
T = TT.for_items(self.T, self.T_by_type, items) # the scalar self.T when there is no by-type map
if self.eng is not None:
probs = torch.cat(self.eng.score_items(items, temperature=T))
else:
b = collate(items, self.m.tok.pad_token_id)
logits = self.m.slot_logits(b["input_ids"].to(self.dev), b["attention_mask"].to(self.dev), b["slot_idx"].to(self.dev),
b["slot_batch"].to(self.dev), b["nopts"].to(self.dev))
probs = TT.scaled_softmax(logits, TT.slot_temperatures(T, items)).cpu()
out, k = [], 0
for context, qs in requests:
res = []
for q in qs:
p = probs[k, :len(q["options"])].tolist(); k += 1
j = max(range(len(p)), key=p.__getitem__); back = q.get("_back", {})
names = [back.get(o, o) for o in q["options"]]
res.append(dict(choice=names[j] if p[j] >= self.abstain_below else None, confidence=p[j],
probs={o: pi for o, pi in zip(names, p)}, probs_list=p))
out.append(res)
return out
def decide(self, context, questions, **kw):
return self.decide_batch([(context, questions)], **kw)[0]
# ---- Jev-shaped interface (decider.systemone): state + {id: Choice | Score | Noul with criteria}
# ---- schema cache (v7+): the questions are run once, requests only run the state (decider.schema_engine)
def schema(self, questions, independent=True, isolated=None, compile=False):
"""Compile a fixed set of Jev-shaped questions: schema(state) -> answers; schema.batch([state, ...]) -> [answers]."""
import json
from decider.schema_engine import SchemaEngine
from decider.systemone import render_question
isolated = self.isolated_levels if isolated is None else isolated
key = (json.dumps(questions, sort_keys=True, ensure_ascii=False), independent, isolated)
if key not in self._schemas:
if self._se is None: self._se = SchemaEngine(self.eng, chat=self.chat)
if len(self._schemas) >= 64: # drop the oldest schema and its graphs
old = next(iter(self._schemas)); hid = self._schemas.pop(old)[1].id
for k in [k for k in self._se.graphs if k[0] == hid]: del self._se.graphs[k]
from decider.systemone import plan_rows
rqs = {k: render_question(v) for k, v in questions.items()}
rows, index = plan_rows(rqs, isolated and independent)
h = self._se.prepare(rows, independent=independent, compile=compile) # compile=True: ~25 s per (batch, length) shape, 1.6x faster after
self._schemas[key] = (rqs, h, index)
return CompiledSchema(self, *self._schemas[key])
def system_one(self, state, questions, independent=True, max_state_tokens=32768, max_fwd_tokens=65536, layout=None, isolated=None):
layout = layout or ("schema_first" if self.schema_first else "state_first")
isolated = (self.isolated_levels if isolated is None else isolated) and independent
if layout == "schema_first" and self.eng is not None:
return self.schema(questions, independent, isolated)(state, max_state_tokens)
"""independent=True scores every question in its own row (state + that question only), so adding, removing or
reordering questions cannot change any other answer; the state is run once and its cache forked to every
question (Engine.score_shared). independent=False packs all questions behind one copy of the state in one row
(later questions can then see earlier question texts)."""
from decider.systemone import unique_tokens, assemble
rqs, index, items = self._system_one_items(state, questions, independent, max_state_tokens, layout, isolated)
flatp = self._system_one_probs(items, layout, max_fwd_tokens, TT.for_items(self.T, self.T_by_type, items))
return {"model": self.name, "answers": assemble(rqs, index, flatp),
"usage": {"input_tokens": unique_tokens(items), "output_tokens": 0}}
@torch.no_grad()
def _system_one_probs(self, items, layout, max_fwd_tokens=65536, temperature=None):
"""Score system_one's uncached rows. temperature: as Engine.score_items takes it (a number, or one entry per item).
-> one probability list per question row, in row order."""
if self.eng is not None and len(items) > 1 and layout == "state_first":
probs = self.eng.score_shared(items, temperature=temperature)
else:
probs = []; per = max(1, max_fwd_tokens // max(len(it["ids"]) for it in items))
for i in range(0, len(items), per):
T = TT.item_slice(temperature, i, i + per)
if self.eng is not None:
probs += self.eng.score_items(items[i:i + per], temperature=T)
else:
bt = collate(items[i:i + per], self.m.tok.pad_token_id)
lg = self.m.slot_logits(*[bt[k].to(self.dev) for k in ("input_ids", "attention_mask", "slot_idx", "slot_batch", "nopts")])
pr = TT.scaled_softmax(lg, TT.slot_temperatures(T, items[i:i + per])).cpu(); c = 0
for it in items[i:i + per]:
probs.append(pr[c:c + len(it["slots"])]); c += len(it["slots"])
return [p.tolist() for ps in probs for p in ps]
def _system_one_items(self, state, questions, independent=True, max_state_tokens=32768, layout=None, isolated=None):
"""The prompt rows of system_one's uncached path: -> (rendered questions, answer index, items)."""
from decider.systemone import render_state, render_question, plan_rows, row_types
layout = layout or ("schema_first" if self.schema_first else "state_first")
isolated = (self.isolated_levels if isolated is None else isolated) and independent
ctx = render_state(state); rqs = {k: render_question(v) for k, v in questions.items()}
opts = (lambda r: neutralize_options(r["options"])[0]) if self.neutralize_none else (lambda r: list(r["options"]))
flat, index = plan_rows(rqs, isolated)
rows = [[r] for r in flat] if independent else [flat]
items = [build(Example(ctx, [Q(r["question"], opts(r), 0) for r in row]), self.m.tok, _NoShuffle(), max_options=MAX_OPTIONS,
max_ctx_tokens=max_state_tokens, layout=layout, chat=self.chat) for row in rows]
types = row_types(rqs, index) # answer type per row (decider.temperature)
for it, ts in zip(items, [[t] for t in types] if independent else [types]):
it["types"] = ts
return rqs, index, items
# ---- typed schema interface: {question: {"type": "bool"} | {"type": "choice", "options": [...]}
# | {"type": "scale", "legend": {"0": "none", "1": "low", ...}}}
SCHEMA_FORM = ('schema: a map {question: field}, each field one of {"type": "choice", "options": [option strings]}, '
'{"type": "bool"} or {"type": "scale", "legend": [descriptions] or {level number: description}}')
@staticmethod
def _check_schema(schema):
"""Raise ValueError naming the expected form when `schema` does not have it (the HTTP server turns this into a 422).
Every schema that 1.1.2 answered stays accepted, except options or a legend given as a bare string (1.1.2 split it into
characters); an empty schema is answered with {}."""
import json, math
form = Decider.SCHEMA_FORM
if schema is None:
raise ValueError("schema is required; " + form)
if not isinstance(schema, dict):
raise ValueError(f"schema is a {type(schema).__name__}; " + form)
for qtext, spec in schema.items():
where = f"schema[{qtext!r}]"
if not isinstance(spec, dict):
raise ValueError(f"{where} is a {type(spec).__name__}, not a field object; " + form)
t = spec.get("type", "choice")
if t == "choice":
opts = spec.get("options")
if not isinstance(opts, (list, tuple, dict)) or not opts or not all(isinstance(o, str) for o in opts):
raise ValueError(f'{where}: "options" must be a non-empty list of strings; ' + form)
elif t == "scale":
leg = spec.get("legend")
if not isinstance(leg, (list, tuple, dict)) or not leg:
raise ValueError(f'{where}: "legend" must be a non-empty list or {{level number: description}} map; ' + form)
try: # the legend is echoed in the answer, which must serialise
json.dumps(leg, allow_nan=False)
except (TypeError, ValueError):
raise ValueError(f'{where}: the "legend" contains a value that is not finite JSON (NaN or Infinity); ' + form)
if isinstance(leg, dict):
try:
ok = all(math.isfinite(float(k)) for k in leg)
except (TypeError, ValueError, OverflowError):
ok = False
if not ok:
raise ValueError(f'{where}: the keys of a "legend" map must be finite numbers, e.g. {{"0": "none", "1": "low"}}; ' + form)
elif t != "bool":
raise ValueError(f"{where}: unknown field type {t!r}; " + form)
@staticmethod
def _schema_to_questions(schema):
Decider._check_schema(schema)
qs = []
for qtext, spec in schema.items():
t = spec.get("type", "choice")
if t == "bool":
qs.append(dict(question=qtext, options=["no", "yes"], _type="noul"))
elif t == "choice":
qs.append(dict(question=qtext, options=list(spec["options"]), _type="choice"))
elif t == "scale":
leg = spec["legend"]
keys = sorted(leg, key=lambda k: float(k)) if isinstance(leg, dict) else list(range(len(leg)))
labels = [f"{k}: {leg[k]}" if isinstance(leg, dict) else f"{i}: {leg[i]}" for i, k in enumerate(keys)]
qs.append(dict(question=qtext, options=labels, _keys=keys, _legend=leg, _type="score"))
else:
raise ValueError(f"unknown field type {t}")
return qs
def decide_json_batch(self, requests, **kw):
"""requests: list of (context, schema). Returns one dict per context keyed by question."""
qss = [self._schema_to_questions(schema) for _, schema in requests]
raw = self.decide_batch([(ctx, qs) for (ctx, _), qs in zip(requests, qss)], **kw)
out = []
for (ctx, schema), qs, res in zip(requests, qss, raw):
o = {}
for (qtext, spec), q, r in zip(schema.items(), qs, res):
t = spec.get("type", "choice")
if t == "bool":
o[qtext] = {"noul": round(r["probs"]["yes"], 4), "type": "noul"}
elif t == "choice":
o[qtext] = {"choice": r["choice"], "confidence": round(r["confidence"], 4), "type": "choice",
"probabilities": {k: round(v, 4) for k, v in r["probs"].items()}}
else:
p = [r["probs"][lab] for lab in q["options"]]
keys = q["_keys"]; n = len(p)
score = sum(float(k) * pi for k, pi in zip(keys, p)) # expected level on the legend scale
j = max(range(n), key=p.__getitem__)
o[qtext] = {"score": round(score, 2), "confidence": round(p[j], 4), "type": "scale", "legend": q["_legend"],
"probabilities": {str(keys[i]): round(pi, 4) for i, pi in enumerate(p)}}
out.append(o)
return out
def decide_json(self, context, schema, **kw):
return self.decide_json_batch([(context, schema)], **kw)[0]
if __name__ == "__main__":
import sys, json, time
d = Decider(sys.argv[1] if len(sys.argv) > 1 else "runs/r1_200k/model")
demo = [
("My card was charged twice for the same purchase and I want the extra charge refunded.",
[{"question": "Which department should handle this?", "options": ["billing", "technical support", "sales"]},
{"question": "What is the customer's sentiment?", "options": ["angry", "neutral", "happy"]},
{"question": "Does this need a refund action?", "options": ["no", "yes"]}]),
("hey can u turn the lights off in the kitchen",
[{"question": "What is the intent?", "options": ["smart home control", "set alarm", "play music", "none of the above"]},
{"question": "Is this request toxic?", "options": ["no", "yes"]}]),
("The quarterly report shows revenue fell 12% while costs rose sharply.",
[{"question": "What is the financial sentiment?", "options": ["bearish", "neutral", "bullish"]}]),
]
t = time.time(); res = d.decide_batch(demo); dt = time.time() - t
for (ctx, qs), r in zip(demo, res):
print("\n>>", ctx)
for q, a in zip(qs, r):
print(f" {q['question']:45s} -> {a['choice']!s:22s} p={a['confidence']:.2f} " + " ".join(f"{o}:{p:.2f}" for o, p in a['probs'].items()))
print(f"\n{sum(len(q) for _, q in demo)} decisions in {dt*1000:.0f} ms (one forward pass)")
schema = {
"Revenue currently impacted?": {"type": "bool"},
"What business impact?": {"type": "choice", "options": ["none", "degraded", "outage"]},
"Integration issue present?": {"type": "bool"},
"Account health status?": {"type": "choice", "options": ["healthy", "watch", "at risk"]},
"Which incident scope?": {"type": "choice", "options": ["single_account", "multi_account", "platform_wide"]},
"Security concern present?": {"type": "bool"},
"Duplicate charge reported?": {"type": "bool"},
"Churn likelihood level?": {"type": "scale", "legend": {"0": "none", "1": "low", "2": "medium", "3": "high"}},
"Human attention needed?": {"type": "bool"},
"Immediate feature request?": {"type": "bool"},
}
ctx = ("Hi, since this morning our Stripe webhook integration stopped firing and our checkout is down for all customers. "
"We are losing orders every minute and our partner launch is on Thursday. Also I think we got billed twice last week. "
"If this is not fixed today we will have to look at other providers.")
t = time.time(); js = d.decide_json(ctx, schema); dt = time.time() - t
print(f"\n>> {ctx[:80]}...\n" + json.dumps(js, indent=1)[:3000]); print(f"{len(schema)} typed fields in {dt*1000:.0f} ms (one forward pass)")