vegaml 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vegaml/__init__.py ADDED
@@ -0,0 +1,192 @@
1
+ """vegaml — typed decisions from a frozen language model and a small physics engine.
2
+
3
+ import vegaml
4
+
5
+ v = vegaml.load("nandakishorm/vega-08b-public-intents")
6
+ v.decide("I was billed twice, refund me or I cancel.",
7
+ {"team": {"type": "choice", "instructions": "Which team handles this?",
8
+ "criteria": {"billing": "payments and refunds", "technical": "bugs"}}})
9
+
10
+ Two readouts over the same frozen features, chosen with `mode`:
11
+
12
+ * ``"engine"`` (the default) — the physics engine and its adapters. Zero-shot: it answers a question
13
+ it has never seen, and every answer carries a calibrated probability, a conformal set and an
14
+ abstain flag. Every benchmark figure published for this model is measured in this mode.
15
+ * ``"ttt"`` — a small head fitted here, on your labelled examples, over the same features. Sharper
16
+ where your label space matches the examples, and worthless outside them. It carries no calibration
17
+ and no abstain flag, so it will answer confidently when it should not.
18
+ * ``"both"`` — both, side by side, which is the only honest way to pick between them.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from typing import Dict, List, Mapping, Optional, Sequence, Tuple
23
+
24
+ from .hub import fetch
25
+ from .ttt import TTTHead
26
+
27
+ __all__ = ["load", "Vega", "TTTHead", "fetch", "__version__"]
28
+ __version__ = "0.1.0"
29
+
30
+ MODES = ("engine", "ttt", "both")
31
+
32
+
33
+ class Vega:
34
+ """A loaded checkpoint, with both readouts over one set of frozen features.
35
+
36
+ `mode` sets the default readout for :meth:`decide`; "engine" unless you change it.
37
+ """
38
+
39
+ def __init__(self, engine, mode: str = "engine"):
40
+ if mode not in MODES:
41
+ raise ValueError("mode must be one of %s, got %r" % (", ".join(MODES), mode))
42
+ self.engine = engine
43
+ self.mode = mode
44
+ self.heads: Dict[str, Tuple[TTTHead, Optional[float]]] = {}
45
+
46
+ # ------------------------------------------------------------------ loading
47
+ @classmethod
48
+ def from_pretrained(cls, repo_or_path: str, backbone: Optional[str] = None,
49
+ device: Optional[str] = None, adapters: str = "auto",
50
+ mode: str = "engine", revision: Optional[str] = None,
51
+ token: Optional[str] = None, max_len: Optional[int] = None) -> "Vega":
52
+ # Imported here, not at module import: torch and transformers are only needed once a
53
+ # checkpoint is actually being loaded.
54
+ from ._api import Vega as _Engine
55
+
56
+ path = fetch(repo_or_path, revision=revision, token=token)
57
+ return cls(_Engine(path, backbone=backbone, device=device, adapters=adapters,
58
+ max_len=max_len), mode=mode)
59
+
60
+ # ------------------------------------------------------------------ reading
61
+ def features(self, state, question: Mapping):
62
+ """Pooled frozen features for one (state, question) — what both readouts see."""
63
+ import numpy as np
64
+ import torch
65
+
66
+ from ._common import build_prompt, option_texts, serialize_state
67
+
68
+ v = self.engine
69
+ qi = v._internal(question)
70
+ k = len(option_texts(qi))
71
+ text = state if isinstance(state, str) else serialize_state(state)
72
+ ids, spans = build_prompt(v.perc.tok, text, qi, v.cfg["max_len"],
73
+ option_order=list(range(k)),
74
+ opt_max_tokens=v.cfg["opt_max_tokens"],
75
+ query_first_max_state=v.cfg.get("query_first_max_state", 0))
76
+ with torch.no_grad():
77
+ f, _ = v.perc.pooled([{"ids": np.asarray(ids, dtype=np.int32), "spans": spans}],
78
+ prefix_min=v.cfg.get("prefix_min_tokens", 4096))
79
+ return f[0].float().cpu().numpy().ravel()
80
+
81
+ # ------------------------------------------------------------------ fitting
82
+ def fit(self, examples: Sequence[Tuple], questions: Mapping, folds: int = 5,
83
+ min_examples: int = 6) -> Dict[str, Dict]:
84
+ """Fit one TTT head per question from labelled examples.
85
+
86
+ `examples` is a sequence of ``(state, {question_id: label})``. One forward pass is run per
87
+ example per question, so the cost is ``len(examples) × len(questions)`` reads of the frozen
88
+ backbone — seconds on a GPU for a few dozen examples, and paid once.
89
+
90
+ Returns, per question id, the number of examples used and a cross-validated accuracy. **Read
91
+ that number.** A head at or below chance has learned nothing, and its answers are noise
92
+ dressed up as probabilities.
93
+ """
94
+ report: Dict[str, Dict] = {}
95
+ for qid, q in questions.items():
96
+ X, y = [], []
97
+ for state, labels in examples:
98
+ if qid in labels:
99
+ X.append(self.features(state, q))
100
+ y.append(str(labels[qid]))
101
+ if len(X) < min_examples:
102
+ report[qid] = {"fitted": False, "examples": len(X),
103
+ "reason": "needs at least %d labelled examples" % min_examples}
104
+ continue
105
+ labels_for_q = label_ids(q)
106
+ head = TTTHead(labels_for_q).fit(X, y)
107
+ cv = head.cross_val_accuracy(X, y, folds=folds)
108
+ chance = 1.0 / max(len(labels_for_q), 2)
109
+ self.heads[qid] = (head, cv)
110
+ # cv is None when there are too few examples to cross-validate. That is "unknown", not
111
+ # "fine": reporting False there would read as a pass the data cannot support.
112
+ report[qid] = {"fitted": True, "examples": len(X), "cv_accuracy": cv,
113
+ "chance": round(chance, 3),
114
+ "at_or_below_chance": None if cv is None else cv <= chance}
115
+ if cv is None:
116
+ report[qid]["cv_note"] = ("too few examples for %d-fold cross-validation, so the "
117
+ "head's accuracy is unknown; %d or more would measure it"
118
+ % (folds, 2 * folds))
119
+ return report
120
+
121
+ # ------------------------------------------------------------------ deciding
122
+ def decide(self, state, questions: Mapping, mode: Optional[str] = None, alpha: str = "0.1",
123
+ views: int = 1) -> Dict:
124
+ """Answer every question from one read of the state.
125
+
126
+ With ``mode="engine"`` the result is the engine's own, unchanged. With ``"ttt"`` each answer
127
+ comes from the fitted head for that question. With ``"both"`` the result carries
128
+ ``answers`` (engine) and ``ttt`` side by side.
129
+ """
130
+ mode = mode or self.mode
131
+ if mode not in MODES:
132
+ raise ValueError("mode must be one of %s, got %r" % (", ".join(MODES), mode))
133
+ if mode in ("ttt", "both"):
134
+ missing = [q for q in questions if q not in self.heads]
135
+ if missing and mode == "ttt":
136
+ raise RuntimeError(
137
+ "no TTT head for %s. Call fit(examples, questions) first, or use "
138
+ "mode='engine'." % ", ".join(sorted(missing)))
139
+ if mode == "ttt":
140
+ return {"mode": "ttt", "answers": self._ttt_answers(state, questions)}
141
+ out = self.engine.decide(state, questions, alpha=alpha, views=views)
142
+ if mode == "engine":
143
+ out["mode"] = "engine"
144
+ return out
145
+ out["mode"] = "both"
146
+ out["ttt"] = self._ttt_answers(state, questions, skip_missing=True)
147
+ return out
148
+
149
+ def _ttt_answers(self, state, questions: Mapping, skip_missing: bool = False) -> Dict:
150
+ answers: Dict[str, Dict] = {}
151
+ for qid, q in questions.items():
152
+ hv = self.heads.get(qid)
153
+ if hv is None:
154
+ if skip_missing:
155
+ continue
156
+ raise RuntimeError("no TTT head for %r" % qid)
157
+ head, cv = hv
158
+ probs = head.predict(self.features(state, q), allowed=label_ids(q))
159
+ best = max(probs, key=probs.get)
160
+ answers[qid] = {"probs": probs, "choice": best, "confidence": probs[best],
161
+ "cv_accuracy": cv}
162
+ return answers
163
+
164
+ # ------------------------------------------------------------------ images
165
+ def decide_image(self, image, questions: Mapping, **kw) -> Dict:
166
+ """The same typed interface over a picture, through the backbone's frozen vision encoder."""
167
+ if getattr(self, "_vision", None) is None:
168
+ from ._vision import VegaVision
169
+
170
+ self._vision = VegaVision(self.engine.model_dir,
171
+ backbone=self.engine.cfg.get("backbone_name"),
172
+ device=str(getattr(self.engine, "device", "cpu")), **kw)
173
+ return self._vision.decide_image(image, questions)
174
+
175
+ def __repr__(self) -> str:
176
+ return "Vega(mode=%r, heads=%d, max_len=%d)" % (
177
+ self.mode, len(self.heads), self.engine.cfg.get("max_len", 0))
178
+
179
+
180
+ def label_ids(question: Mapping) -> List[str]:
181
+ """The option ids a readout must span, for any of the three answer types."""
182
+ t = question["type"]
183
+ if t == "noul":
184
+ return ["false", "true"]
185
+ if t == "score":
186
+ return [str(i) for i in range(len(question.get("criteria") or []))]
187
+ return [str(k) for k in question["criteria"]]
188
+
189
+
190
+ def load(repo_or_path: str, **kw) -> Vega:
191
+ """Load a checkpoint. See :meth:`Vega.from_pretrained` for the arguments."""
192
+ return Vega.from_pretrained(repo_or_path, **kw)
vegaml/_api.py ADDED
@@ -0,0 +1,222 @@
1
+ """Vega inference: decide(state, questions) -> calibrated typed answers, with the physics behind each one.
2
+
3
+ d = Vega("/kaggle/working/vega_model") # backbone read from vega_config.json (or backbone=...)
4
+ d.decide("I was billed twice, refund me or I cancel.",
5
+ {"team": {"type": "choice", "instructions": "Which team handles this?",
6
+ "criteria": {"billing": "payments and refunds", "technical": "bugs", "sales": "pricing"}},
7
+ "churn": {"type": "noul", "instructions": "Does the customer threaten to leave?"}})
8
+
9
+ Every answer carries: probabilities (thermally calibrated), confidence, a split-conformal prediction set, whether to
10
+ abstain at the configured risk, whether the particle ended unbound (far from every well: likely out of
11
+ distribution), how many integration steps it took to settle, and its final energy.
12
+
13
+ Adapters (model_dir/adapters/*.safetensors + .json) extend the frozen engine to new decision families without
14
+ changing it. adapters="auto" (default): a learned gate per question decides whether an adapter applies, and each
15
+ answer uses that adapter's calibration or the base one; "on": every adapter always on; "off": the base engine only.
16
+ Answers then also carry "adapter" (name or None) and "gate".
17
+ """
18
+ import glob
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import time
23
+
24
+ import numpy as np
25
+ import torch
26
+
27
+ from ._common import (QTYPES, VegaEngine, Perception, build_prompt, load_adapter, option_texts, prose_state,
28
+ route_adapters, serialize_state, softmax_np, thermal_features, thermal_tau)
29
+
30
+
31
+ class Vega:
32
+ def __init__(self, model_dir, backbone=None, device=None, halt=True, calibration="default", adapters="auto",
33
+ perception=None, max_len=None):
34
+ """calibration="teacher" loads the teacher-matching calibration of a typed-decisions fine-tune (if present)
35
+ instead of the default one fitted to the correct answers. perception: an already loaded Perception to share.
36
+ max_len: prompt token limit, overriding the trained one (e.g. 65536 to read long records; the engine was
37
+ trained at its configured length, so accuracy beyond it has to be measured)."""
38
+ from safetensors.torch import load_file
39
+ with open(os.path.join(model_dir, "vega_config.json")) as f:
40
+ self.cfg = json.load(f)
41
+ if max_len:
42
+ self.cfg["trained_max_len"] = self.cfg["max_len"]
43
+ self.cfg["max_len"] = int(max_len)
44
+ self.model_dir = model_dir
45
+ self.calibration = calibration
46
+ self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
47
+ bb = backbone or self.cfg["backbone"]
48
+ local = os.path.join(model_dir, "backbone")
49
+ if backbone is None and os.path.isdir(local):
50
+ bb = local
51
+ self.perc = perception or Perception(bb, self.device, self.cfg["feature_layers"], self.cfg["option_layers"])
52
+ assert self.perc.feature_layers == self.cfg["feature_layers"], "backbone depth differs from training"
53
+ self.engine = VegaEngine(self.cfg["d_model"], len(self.cfg["feature_layers"]), len(self.cfg["option_layers"]),
54
+ self.cfg)
55
+ self.engine.load_state_dict(load_file(os.path.join(model_dir, "engine.safetensors")))
56
+ self.engine.to(self.device).eval()
57
+ self.halt = halt
58
+ cal = self.cfg.get("calibration_teacher" if calibration == "teacher" else "calibration") or {}
59
+ if calibration == "teacher" and not cal:
60
+ raise ValueError("this model has no teacher calibration block (only typed-decisions fine-tunes do)")
61
+ self.theta = np.array(cal.get("theta", np.zeros((3, 5))), dtype=np.float64)
62
+ self.conformal = cal.get("conformal", {})
63
+ self.abstain = cal.get("abstain_threshold", {})
64
+ self.risk = cal.get("default_risk", "0.1")
65
+ self.unbound = cal.get("unbound_threshold", float("inf"))
66
+ if not cal:
67
+ print("vega: no calibration block in vega_config.json (run evaluate.py); probabilities are raw")
68
+ self.adapters = []
69
+ self._sha = None
70
+ assert adapters in ("auto", "on", "off"), adapters
71
+ if adapters != "off":
72
+ for js in sorted(glob.glob(os.path.join(model_dir, "adapters", "*.json"))):
73
+ meta = json.load(open(js))
74
+ if adapters == "auto" and not meta.get("gate_trained"):
75
+ print("vega: adapter '%s' has no trained gate (fitted without replay data); load it with "
76
+ "adapters=\"on\" to use it" % meta["name"])
77
+ continue
78
+ if meta.get("base_engine_sha256") and meta["base_engine_sha256"] != self.base_sha256():
79
+ print("vega: adapter '%s' was trained on a different base engine - skipped" % meta["name"])
80
+ continue
81
+ self.add_adapter(load_file(js[:-len(".json")] + ".safetensors"), meta)
82
+ self.set_adapters(adapters)
83
+
84
+ def base_sha256(self):
85
+ if self._sha is None:
86
+ h = hashlib.sha256()
87
+ with open(os.path.join(self.model_dir, "engine.safetensors"), "rb") as f:
88
+ for chunk in iter(lambda: f.read(1 << 22), b""):
89
+ h.update(chunk)
90
+ self._sha = h.hexdigest()
91
+ return self._sha
92
+
93
+ def add_adapter(self, state, meta):
94
+ """Attach one adapter (tensors + its json) to the engine; returns its index."""
95
+ j = load_adapter(self.engine, state, meta["rank"], meta["alpha"])
96
+ self.engine.to(self.device).eval()
97
+ cal = (meta.get("calibration_teacher") if self.calibration == "teacher" else None) or meta["calibration"]
98
+ self.adapters.append({"name": meta["name"], "meta": meta, "cal": {
99
+ "theta": np.array(cal["theta"], dtype=np.float64), "conformal": cal.get("conformal", {}),
100
+ "abstain": cal.get("abstain_threshold", {}), "unbound": cal.get("unbound_threshold", float("inf"))},
101
+ "threshold": meta.get("gate_threshold", 0.5)})
102
+ return j
103
+
104
+ def set_adapters(self, mode):
105
+ """"auto" (gated), "on" (all adapters always on) or "off" (base engine exactly)."""
106
+ assert mode in ("auto", "on", "off"), mode
107
+ if mode == "on" and len(self.adapters) > 1:
108
+ print("vega: %d adapters all on at once; \"auto\" lets each question pick" % len(self.adapters))
109
+ self.engine.adapter_mode = mode if self.adapters else "off"
110
+
111
+ def _cal(self, r):
112
+ if r < 0:
113
+ return {"theta": self.theta, "conformal": self.conformal, "abstain": self.abstain, "unbound": self.unbound}
114
+ return self.adapters[r]["cal"]
115
+
116
+ @staticmethod
117
+ def _internal(qdef):
118
+ t = qdef["type"]
119
+ crit = qdef.get("criteria")
120
+ if t == "choice" and isinstance(crit, list):
121
+ crit = {c: None for c in crit}
122
+ ins = qdef["instructions"] if isinstance(qdef["instructions"], str) else json.dumps(qdef["instructions"])
123
+ return {"t": t, "ins": ins, "crit": crit}
124
+
125
+ def _view(self, text, qs, template, orders):
126
+ """One reading of the state under one surface form. Returns calibrated probabilities per question in ORIGINAL
127
+ option order, plus the engine output / route / raw logits (in prompt order) for the physics fields."""
128
+ items = []
129
+ for q, order in zip(qs, orders):
130
+ ids, spans = build_prompt(self.perc.tok, text, q, self.cfg["max_len"], template=template, option_order=order,
131
+ opt_max_tokens=self.cfg["opt_max_tokens"],
132
+ query_first_max_state=self.cfg.get("query_first_max_state", 0))
133
+ items.append({"ids": np.asarray(ids, dtype=np.int32), "spans": spans})
134
+ C = self.engine.memory_chunks if self.engine.memory is not None else 0
135
+ pm = self.cfg.get("prefix_min_tokens", 4096)
136
+ if C: # long states are read once for all questions; chunk summaries feed the memory reader
137
+ feats, opt_flat, chunks, cmask = self.perc.pooled(items, chunks=C, prefix_min=pm,
138
+ chunk_min_tokens=self.cfg.get("memory_min_tokens", 2048),
139
+ chunk_tokens=self.cfg.get("memory_chunk_tokens", 128))
140
+ else:
141
+ (feats, opt_flat), chunks, cmask = self.perc.pooled(items, prefix_min=pm), None, None
142
+ K = max(len(it["spans"]["options"]) for it in items)
143
+ B = len(items)
144
+ opts = torch.zeros((B, K) + opt_flat.shape[1:], device=self.device)
145
+ mask = torch.zeros((B, K), dtype=torch.bool, device=self.device)
146
+ o = 0
147
+ for b, it in enumerate(items):
148
+ k = len(it["spans"]["options"])
149
+ opts[b, :k] = opt_flat[o:o + k]
150
+ mask[b, :k] = True
151
+ o += k
152
+ qtype = torch.tensor([QTYPES[q["t"]] for q in qs], device=self.device)
153
+ out = self.engine(feats, opts, mask, qtype, halt=self.halt, return_traj=False, chunks=chunks, chunk_mask=cmask)
154
+ logits = out["logits"].float().cpu().numpy()
155
+ phys = thermal_features(out, mask, self.engine.max_steps).cpu().numpy()
156
+ qt = qtype.cpu().numpy()
157
+ gates = out["gates"].float().cpu().numpy() if out.get("gates") is not None else None
158
+ route = route_adapters(gates, self.adapters[0]["threshold"] if self.adapters else 0.5)
159
+ route = np.full(B, -1) if route is None else route
160
+ P = np.zeros_like(logits, dtype=np.float64)
161
+ for rt in np.unique(route): # each question calibrated by the adapter it went through (or the base)
162
+ sel = route == rt
163
+ P[sel] = softmax_np(logits[sel], thermal_tau(self._cal(rt)["theta"], qt[sel], phys[sel]))
164
+ P_orig = []
165
+ for r, order in enumerate(orders):
166
+ po = np.zeros(len(order))
167
+ po[order] = P[r, :len(order)]
168
+ P_orig.append(po)
169
+ return {"P": P_orig, "out": out, "route": route, "gates": gates, "logits": logits, "mask": mask,
170
+ "n_tokens": int(sum(len(it["ids"]) for it in items))}
171
+
172
+ @torch.no_grad()
173
+ def decide(self, state, questions, alpha="0.1", risk=None, views=1):
174
+ """views = 1: one reading. views = 2 adds a reading with every choice / yes-no question's options in reverse
175
+ order; views = 3 also adds the state rendered as prose with the second template. Probabilities are averaged
176
+ (test-time averaging over surface forms: steadier answers, about views x the cost)."""
177
+ t0 = time.time()
178
+ qids = list(questions)
179
+ qs = [self._internal(questions[k]) for k in qids]
180
+ n_opt = [len(option_texts(q)) for q in qs]
181
+ ident = [list(range(k)) for k in n_opt]
182
+ text = serialize_state(state)
183
+ runs = [self._view(text, qs, 0, ident)]
184
+ if views >= 2:
185
+ rev = [o[::-1] if q["t"] != "score" else o for q, o in zip(qs, ident)]
186
+ runs.append(self._view(text, qs, 0, rev))
187
+ if views >= 3:
188
+ runs.append(self._view(prose_state(state) if not isinstance(state, str) else text, qs, 1, ident))
189
+ base = runs[0]
190
+ out, route, gates, logits, mask = base["out"], base["route"], base["gates"], base["logits"], base["mask"]
191
+ risk = str(risk) if risk is not None else self.risk
192
+ answers = {}
193
+ for r, (qid, q) in enumerate(zip(qids, qs)):
194
+ k = n_opt[r]
195
+ p = np.mean([run["P"][r] for run in runs], 0)
196
+ cal = self._cal(int(route[r]))
197
+ labels = list(q["crit"].keys()) if q["t"] == "choice" else list(range(k)) if q["t"] == "score" else [False, True]
198
+ conf = float(p.max())
199
+ th = cal["conformal"].get(q["t"], {}).get(alpha)
200
+ best = int(p.argmax())
201
+ # LAC set; the top option is always kept so the set is never empty
202
+ pset = [labels[i] for i in range(k) if th is not None and (p[i] >= max(1.0 - th, 1e-12) or i == best)]
203
+ a = {"probs": {str(labels[i]): float(p[i]) for i in range(k)}, "confidence": conf,
204
+ "prediction_set": pset if th is not None else None,
205
+ "abstain": bool(conf < cal["abstain"].get(risk, 0.0)),
206
+ "unbound": bool(float(out["nearest"][r]) > cal["unbound"]),
207
+ "settle_steps": int(out["steps_used"][r]), "energy": float(-logits[r, :k].max())}
208
+ if len(runs) > 1:
209
+ a["view_agreement"] = float(np.mean([int(run["P"][r].argmax()) == best for run in runs]))
210
+ if self.adapters:
211
+ a["adapter"] = self.adapters[int(route[r])]["name"] if route[r] >= 0 else None
212
+ a["gate"] = float(gates[r].max()) if gates is not None else 0.0
213
+ if q["t"] == "choice":
214
+ a["choice"] = labels[best]
215
+ elif q["t"] == "score":
216
+ a["score"] = best
217
+ a["expected_score"] = float((np.arange(k) * p).sum())
218
+ else:
219
+ a["noul"] = float(p[1])
220
+ answers[qid] = a
221
+ return {"answers": answers, "latency_ms": round(1000 * (time.time() - t0), 1),
222
+ "n_tokens": sum(run["n_tokens"] for run in runs), "views": len(runs)}