emrevrg commited on
Commit
995bd7d
·
verified ·
1 Parent(s): f5d2dae

Upload folder using huggingface_hub

Browse files
code/aubin_mini/__init__.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AUBIN Mini by Norovox — on-device typed decisions (choice / yes-no) with calibrated probabilities.
2
+
3
+ A 395M-parameter cross-encoder: every option is read together with the question and the state, scored once,
4
+ and the option scores of a question are turned into probabilities with a fitted temperature.
5
+ Runs on CPU with ONNX Runtime only (no torch, no GPU).
6
+
7
+ from aubin_mini import AubinMini
8
+ m = AubinMini.from_pretrained("emrevrg/AUBIN-Mini") # phone build, 255 MB
9
+ m.decide({"ticket": "Package arrived broken, customer wants money back"},
10
+ {"route": {"type": "choice", "instructions": "Which team?",
11
+ "criteria": {"billing": "refunds", "shipping": "delivery", "tech": "software"}},
12
+ "urgent": {"type": "noul", "instructions": "Is this urgent?"}})
13
+ """
14
+ import json
15
+ import os
16
+
17
+ import numpy as np
18
+
19
+ __version__ = "0.1.0"
20
+ __all__ = ["AubinMini", "options_of"]
21
+
22
+ VARIANTS = { # file name, size on disk, accuracy on the same 300 Kev development questions
23
+ "phone": "aubin_mini_nb4_g8.onnx", # 4-bit block weights + 8-bit embeddings, 255 MB, dev 0.833
24
+ "nb4": "aubin_mini_nb4.onnx", # 4-bit block weights, 402 MB, dev 0.840
25
+ }
26
+
27
+
28
+ def options_of(q):
29
+ """Question → (answer keys, option texts). Same rendering the model was trained with."""
30
+ t, c = q.get("type", "choice"), q.get("criteria")
31
+ if t == "noul":
32
+ if isinstance(c, dict):
33
+ return [False, True], [f"false — {c.get('false', 'no')}", f"true — {c.get('true', 'yes')}"]
34
+ return [False, True], ["false — no", "true — yes"]
35
+ if isinstance(c, list):
36
+ return list(range(len(c))), [str(v) for v in c]
37
+ if not isinstance(c, dict) or not c:
38
+ raise ValueError("a 'choice' question needs a non-empty 'criteria' dict {option: description}")
39
+ return list(c.keys()), [f"{k}: {v}" for k, v in c.items()]
40
+
41
+
42
+ def _state_text(state):
43
+ return state if isinstance(state, str) else json.dumps(state, ensure_ascii=False)
44
+
45
+
46
+ class AubinMini:
47
+ def __init__(self, model_dir, variant="phone", threads=None, max_len=512, batch=32):
48
+ import onnxruntime as ort
49
+ from tokenizers import Tokenizer
50
+ cfg_path = os.path.join(model_dir, "aubin_mini.json")
51
+ self.cfg = json.load(open(cfg_path, encoding="utf-8")) if os.path.exists(cfg_path) else {}
52
+ self.temperature = float(self.cfg.get("temperature", 1.0))
53
+ self.max_len, self.batch = max_len, batch
54
+ self.tok = Tokenizer.from_file(os.path.join(model_dir, "tokenizer.json"))
55
+ self.tok.enable_truncation(max_length=max_len, strategy="only_second") # the state is cut, never the question/option
56
+ self.tok.no_padding()
57
+ self.tok_long = Tokenizer.from_file(os.path.join(model_dir, "tokenizer.json")) # only when question+option alone exceed max_len
58
+ self.tok_long.enable_truncation(max_length=max_len, strategy="longest_first")
59
+ self.tok_long.no_padding()
60
+ pad = self.cfg.get("pad_token", "[PAD]")
61
+ self.pad_id = self.tok.token_to_id(pad) or 0
62
+ so = ort.SessionOptions()
63
+ so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
64
+ if threads:
65
+ so.intra_op_num_threads = int(threads)
66
+ path = os.path.join(model_dir, VARIANTS.get(variant, variant))
67
+ self.sess = ort.InferenceSession(path, so, providers=["CPUExecutionProvider"])
68
+ self.variant = variant
69
+
70
+ @classmethod
71
+ def from_pretrained(cls, repo_or_dir="emrevrg/AUBIN-Mini", variant="phone", token=None, **kw):
72
+ if os.path.isdir(repo_or_dir):
73
+ return cls(repo_or_dir, variant, **kw)
74
+ from huggingface_hub import snapshot_download
75
+ name = VARIANTS.get(variant, variant)
76
+ d = snapshot_download(repo_or_dir, token=token,
77
+ allow_patterns=[name, "tokenizer.json", "tokenizer_config.json", "aubin_mini.json"])
78
+ return cls(d, variant, **kw)
79
+
80
+ # ---- scoring -------------------------------------------------------------------------------------------
81
+ def _scores(self, pairs):
82
+ """[(question+option, state)] → raw scores, length-sorted batches (less padding)."""
83
+ try:
84
+ enc = self.tok.encode_batch(pairs)
85
+ except Exception:
86
+ enc = []
87
+ for p in pairs:
88
+ try:
89
+ enc.append(self.tok.encode(*p))
90
+ except Exception:
91
+ enc.append(self.tok_long.encode(*p))
92
+ order = sorted(range(len(enc)), key=lambda i: len(enc[i].ids))
93
+ out = np.zeros(len(enc), dtype=np.float32)
94
+ for k in range(0, len(order), self.batch):
95
+ idx = order[k:k + self.batch]
96
+ T = max(len(enc[i].ids) for i in idx)
97
+ ids = np.full((len(idx), T), self.pad_id, dtype=np.int64)
98
+ am = np.zeros((len(idx), T), dtype=np.int64)
99
+ for b, i in enumerate(idx):
100
+ n = len(enc[i].ids)
101
+ ids[b, :n] = enc[i].ids
102
+ am[b, :n] = 1
103
+ s = self.sess.run(["score"], {"input_ids": ids, "attention_mask": am})[0]
104
+ out[idx] = np.asarray(s, dtype=np.float32).reshape(-1)
105
+ return out
106
+
107
+ def log_probs(self, question, option_texts, state=""):
108
+ """Calibrated log-probabilities over option_texts (any number of options)."""
109
+ st = "STATE: " + _state_text(state)
110
+ s = self._scores([(f"QUESTION: {question}\nOPTION: {t}", st) for t in option_texts]) / self.temperature
111
+ s = s - s.max()
112
+ return s - np.log(np.exp(s).sum())
113
+
114
+ def choose(self, question, options, state=""):
115
+ """options: dict {key: description} or list of texts → {answer, confidence, probabilities}."""
116
+ q = {"type": "choice", "criteria": options}
117
+ keys, texts = options_of(q)
118
+ return self._pack(keys, self.log_probs(question, texts, state))
119
+
120
+ def decide(self, state, questions):
121
+ """Kev/AUBIN typed-decision JSON: {qid: {type: choice|noul, instructions, criteria}} → {qid: result}."""
122
+ flat, spans = [], {}
123
+ st = "STATE: " + _state_text(state)
124
+ for qid, q in questions.items():
125
+ keys, texts = options_of(q)
126
+ ins = q.get("instructions") or qid
127
+ spans[qid] = (len(flat), keys)
128
+ flat += [(f"QUESTION: {ins}\nOPTION: {t}", st) for t in texts]
129
+ s = self._scores(flat) / self.temperature
130
+ res = {}
131
+ for qid, (a, keys) in spans.items():
132
+ x = s[a:a + len(keys)]
133
+ x = x - x.max()
134
+ res[qid] = self._pack(keys, x - np.log(np.exp(x).sum()))
135
+ return res
136
+
137
+ @staticmethod
138
+ def _pack(keys, lp):
139
+ p = np.exp(lp)
140
+ j = int(p.argmax())
141
+ return {"answer": keys[j], "confidence": round(float(p[j]), 4),
142
+ "probabilities": {str(k).lower() if isinstance(k, bool) else str(k): round(float(v), 4) for k, v in zip(keys, p)}}
code/aubin_mini/__main__.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """aubin-mini command line.
2
+
3
+ aubin-mini decide request.json # {"state": ..., "questions": {...}} → JSON result
4
+ aubin-mini serve --port 8009 # POST /decide with the same JSON, GET /health
5
+ """
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
+
11
+ from . import AubinMini, __version__
12
+
13
+
14
+ def main(argv=None):
15
+ ap = argparse.ArgumentParser(prog="aubin-mini")
16
+ ap.add_argument("--model", default="emrevrg/AUBIN-Mini", help="HF repo or local directory")
17
+ ap.add_argument("--variant", default="phone", choices=["phone", "nb4"])
18
+ ap.add_argument("--threads", type=int, default=None)
19
+ sub = ap.add_subparsers(dest="cmd", required=True)
20
+ d = sub.add_parser("decide"); d.add_argument("request", help="JSON file, or - for stdin")
21
+ s = sub.add_parser("serve"); s.add_argument("--host", default="127.0.0.1"); s.add_argument("--port", type=int, default=8009)
22
+ a = ap.parse_args(argv)
23
+ m = AubinMini.from_pretrained(a.model, a.variant, threads=a.threads)
24
+
25
+ if a.cmd == "decide":
26
+ req = json.load(sys.stdin if a.request == "-" else open(a.request, encoding="utf-8"))
27
+ print(json.dumps(m.decide(req.get("state", ""), req["questions"]), ensure_ascii=False, indent=2))
28
+ return
29
+
30
+ class H(BaseHTTPRequestHandler):
31
+ def _send(self, code, obj):
32
+ b = json.dumps(obj, ensure_ascii=False).encode("utf-8")
33
+ self.send_response(code); self.send_header("Content-Type", "application/json; charset=utf-8")
34
+ self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
35
+
36
+ def do_GET(self):
37
+ if self.path == "/health":
38
+ self._send(200, {"ok": True, "model": "AUBIN Mini", "version": __version__, "variant": m.variant})
39
+ else:
40
+ self._send(404, {"error": "not found"})
41
+
42
+ def do_POST(self):
43
+ if self.path != "/decide":
44
+ return self._send(404, {"error": "not found"})
45
+ try:
46
+ req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
47
+ self._send(200, m.decide(req.get("state", ""), req["questions"]))
48
+ except (KeyError, ValueError, TypeError) as e:
49
+ self._send(400, {"error": str(e)})
50
+
51
+ def log_message(self, *args):
52
+ pass
53
+
54
+ print(f"AUBIN Mini {__version__} ({m.variant}) on http://{a.host}:{a.port} POST /decide", flush=True)
55
+ ThreadingHTTPServer((a.host, a.port), H).serve_forever()
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
code/eval_kev.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reproduce AUBIN Mini's numbers on Kev's public locked suites (jaredpalmer/kev-suites, Apache-2.0).
2
+
3
+ pip install ./code && python code/eval_kev.py --model emrevrg/AUBIN-Mini --variant phone --out kev_phone.json
4
+ python code/eval_kev.py --shard 0/4 ... # split the work across processes / machines, then --merge a.json b.json ...
5
+
6
+ Same items, same metrics as the AUBIN/Kev protocol: accuracy, Brier, NLL, 15-bin ECE (overall and per source).
7
+ decision-v7 test = sources Kev trained on; transfer-v4 test = 764 records from sources Kev never trained on.
8
+ """
9
+ import argparse
10
+ import json
11
+ import math
12
+ import time
13
+
14
+ import numpy as np
15
+
16
+ from aubin_mini import AubinMini, options_of
17
+
18
+ SUITES = {"kev_test": "v7/decision-v7/test.jsonl", "kev_transfer_test": "v4/transfer-v4/test.jsonl",
19
+ "kev_dev": "v7/decision-v7/development.jsonl", "kev_transfer_dev": "v4/transfer-v4/development.jsonl"}
20
+
21
+
22
+ def load(name):
23
+ from huggingface_hub import hf_hub_download
24
+ p = hf_hub_download("jaredpalmer/kev-suites", SUITES[name], repo_type="dataset")
25
+ items = []
26
+ for i, line in enumerate(open(p, encoding="utf-8")):
27
+ r = json.loads(line)
28
+ for qid, q in r["questions"].items():
29
+ keys, texts = options_of(q)
30
+ lab = q["label"]
31
+ lab = str(lab).lower() if isinstance(lab, bool) else str(lab)
32
+ skeys = [str(k).lower() if isinstance(k, bool) else str(k) for k in keys]
33
+ if lab not in skeys:
34
+ continue
35
+ items.append({"src": q.get("src") or r.get("_meta", {}).get("source") or name, "state": r["state"],
36
+ "q": q.get("instructions") or qid, "texts": texts, "y": skeys.index(lab)})
37
+ return items
38
+
39
+
40
+ def metrics(rows):
41
+ by, allr = {}, []
42
+ for src, y, lp in rows:
43
+ p = np.exp(np.asarray(lp)); oh = np.zeros(len(p)); oh[y] = 1
44
+ r = (int(p.argmax()) == y, float(((p - oh) ** 2).sum()), float(p.max()), float(-math.log(max(p[y], 1e-12))))
45
+ allr.append(r); by.setdefault(src, []).append(r)
46
+
47
+ def m(v):
48
+ ece = 0.0
49
+ for j in range(15):
50
+ bb = [x for x in v if j / 15 < x[2] <= (j + 1) / 15]
51
+ if bb:
52
+ ece += len(bb) / len(v) * abs(np.mean([x[2] for x in bb]) - np.mean([x[0] for x in bb]))
53
+ return {"n": len(v), "accuracy": round(float(np.mean([x[0] for x in v])), 4), "brier": round(float(np.mean([x[1] for x in v])), 4),
54
+ "nll": round(float(np.mean([x[3] for x in v])), 4), "ece15": round(float(ece), 4)}
55
+ return {"all": m(allr), "by_source": {k: m(v) for k, v in sorted(by.items())}}
56
+
57
+
58
+ def main():
59
+ ap = argparse.ArgumentParser()
60
+ ap.add_argument("--model", default="emrevrg/AUBIN-Mini"); ap.add_argument("--variant", default="phone")
61
+ ap.add_argument("--suites", default="kev_test,kev_transfer_test"); ap.add_argument("--threads", type=int, default=None)
62
+ ap.add_argument("--shard", default="0/1"); ap.add_argument("--out", default="aubin_mini_kev.json")
63
+ ap.add_argument("--merge", nargs="*", default=None, help="merge shard outputs instead of running")
64
+ a = ap.parse_args()
65
+ if a.merge:
66
+ rows = {}
67
+ for f in a.merge:
68
+ for s, v in json.load(open(f))["rows"].items():
69
+ rows.setdefault(s, []).extend(v)
70
+ R = {s: metrics(v) for s, v in rows.items()}
71
+ json.dump(R, open(a.out, "w"), indent=1); print(json.dumps({s: R[s]["all"] for s in R}))
72
+ return
73
+ k, n = map(int, a.shard.split("/"))
74
+ m = AubinMini.from_pretrained(a.model, a.variant, threads=a.threads)
75
+ out, ms = {"variant": a.variant, "rows": {}}, []
76
+ for s in a.suites.split(","):
77
+ its = load(s)[k::n]
78
+ rows = []
79
+ for it in its:
80
+ t = time.perf_counter()
81
+ lp = m.log_probs(it["q"], it["texts"], it["state"])
82
+ ms.append((time.perf_counter() - t) * 1000)
83
+ rows.append((it["src"], it["y"], [round(float(x), 6) for x in lp]))
84
+ out["rows"][s] = rows
85
+ print(s, json.dumps(metrics(rows)["all"]), flush=True)
86
+ out["ms_per_question"] = {"median": round(float(np.median(ms)), 1), "p95": round(float(np.percentile(ms, 95)), 1)}
87
+ json.dump(out, open(a.out, "w"))
88
+ print("ms/question", out["ms_per_question"], flush=True)
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
code/pyproject.toml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "aubin-mini"
7
+ version = "0.1.0"
8
+ description = "AUBIN Mini by Norovox — on-device typed decisions with calibrated probabilities (ONNX Runtime, CPU)"
9
+ requires-python = ">=3.9"
10
+ license = {text = "Apache-2.0"}
11
+ dependencies = ["onnxruntime>=1.18", "tokenizers>=0.19", "numpy", "huggingface_hub"]
12
+
13
+ [project.scripts]
14
+ aubin-mini = "aubin_mini.__main__:main"
15
+
16
+ [tool.setuptools]
17
+ packages = ["aubin_mini"]