Download decider/infer.py from hayriyigit/decider-4b-tr-rag: direct link, hf CLI and curl.
- Browser
- Download file 22.4 kB
-
https://huggingface.co/hayriyigit/decider-4b-tr-rag/resolve/8fe5d78d3bb3962506fab1493b9cae08cba542c5/decider/infer.py
- Command line
-
hf download hf://hayriyigit/decider-4b-tr-rag@8fe5d78d3bb3962506fab1493b9cae08cba542c5/decider/infer.py
-
curl -L -o infer.py https://huggingface.co/hayriyigit/decider-4b-tr-rag/resolve/8fe5d78d3bb3962506fab1493b9cae08cba542c5/decider/infer.py
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 | |
| class Q: | |
| text: str; options: list; gold: int = 0 | |
| 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 | |
| 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}} | |
| 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}}') | |
| 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) | |
| 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)") | |