File size: 7,276 Bytes
995bd7d
 
 
 
 
 
 
6620bb4
995bd7d
 
 
 
 
 
 
 
 
 
 
 
 
 
6620bb4
 
995bd7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6620bb4
 
 
 
995bd7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
"""AUBIN Mini by Norovox β€” on-device typed decisions (choice / yes-no) with calibrated probabilities.



A 395M-parameter cross-encoder: every option is read together with the question and the state, scored once,

and the option scores of a question are turned into probabilities with a fitted temperature.

Runs on CPU with ONNX Runtime only (no torch, no GPU).



    from aubin_mini import AubinMini

    m = AubinMini.from_pretrained("emrevrg/AUBIN-Mini")          # phone build, 267 MB

    m.decide({"ticket": "Package arrived broken, customer wants money back"},

             {"route": {"type": "choice", "instructions": "Which team?",

                        "criteria": {"billing": "refunds", "shipping": "delivery", "tech": "software"}},

              "urgent": {"type": "noul", "instructions": "Is this urgent?"}})

"""
import json
import os

import numpy as np

__version__ = "0.1.0"
__all__ = ["AubinMini", "options_of"]

VARIANTS = {                      # file name, size on disk, accuracy on the same 300 Kev development questions
    "phone": "aubin_mini_nb4_g8.onnx",   # 4-bit block weights + 8-bit embeddings, 267 MB, locked Kev test 0.847
    "nb4": "aubin_mini_nb4.onnx",        # 4-bit block weights, 422 MB, locked Kev test 0.846
}


def options_of(q):
    """Question β†’ (answer keys, option texts). Same rendering the model was trained with."""
    t, c = q.get("type", "choice"), q.get("criteria")
    if t == "noul":
        if isinstance(c, dict):
            return [False, True], [f"false β€” {c.get('false', 'no')}", f"true β€” {c.get('true', 'yes')}"]
        return [False, True], ["false β€” no", "true β€” yes"]
    if isinstance(c, list):
        return list(range(len(c))), [str(v) for v in c]
    if not isinstance(c, dict) or not c:
        raise ValueError("a 'choice' question needs a non-empty 'criteria' dict {option: description}")
    return list(c.keys()), [f"{k}: {v}" for k, v in c.items()]


def _state_text(state):
    return state if isinstance(state, str) else json.dumps(state, ensure_ascii=False)


class AubinMini:
    def __init__(self, model_dir, variant="phone", threads=None, max_len=512, batch=32):
        import onnxruntime as ort
        from tokenizers import Tokenizer
        cfg_path = os.path.join(model_dir, "aubin_mini.json")
        self.cfg = json.load(open(cfg_path, encoding="utf-8")) if os.path.exists(cfg_path) else {}
        self.temperature = float(self.cfg.get("temperature", 1.0))
        self.max_len, self.batch = max_len, batch
        self.tok = Tokenizer.from_file(os.path.join(model_dir, "tokenizer.json"))
        self.tok.enable_truncation(max_length=max_len, strategy="only_second")   # the state is cut, never the question/option
        self.tok.no_padding()
        self.tok_long = Tokenizer.from_file(os.path.join(model_dir, "tokenizer.json"))   # only when question+option alone exceed max_len
        self.tok_long.enable_truncation(max_length=max_len, strategy="longest_first")
        self.tok_long.no_padding()
        pad = self.cfg.get("pad_token", "[PAD]")
        self.pad_id = self.tok.token_to_id(pad) or 0
        so = ort.SessionOptions()
        so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
        if threads:
            so.intra_op_num_threads = int(threads)
        path = os.path.join(model_dir, VARIANTS.get(variant, variant))
        self.sess = ort.InferenceSession(path, so, providers=["CPUExecutionProvider"])
        self.variant = variant

    @classmethod
    def from_pretrained(cls, repo_or_dir="emrevrg/AUBIN-Mini", variant="phone", token=None, **kw):
        if os.path.isdir(repo_or_dir):
            return cls(repo_or_dir, variant, **kw)
        from huggingface_hub import snapshot_download
        name = VARIANTS.get(variant, variant)
        d = snapshot_download(repo_or_dir, token=token,
                              allow_patterns=[name, "tokenizer.json", "tokenizer_config.json", "aubin_mini.json"])
        return cls(d, variant, **kw)

    # ---- scoring -------------------------------------------------------------------------------------------
    def _scores(self, pairs):
        """[(question+option, state)] β†’ raw scores, length-sorted batches (less padding)."""
        try:
            enc = self.tok.encode_batch(pairs)
        except Exception:
            enc = []
            for p in pairs:
                try:
                    enc.append(self.tok.encode(*p))
                except Exception:
                    enc.append(self.tok_long.encode(*p))
        order = sorted(range(len(enc)), key=lambda i: len(enc[i].ids))
        out = np.zeros(len(enc), dtype=np.float32)
        for k in range(0, len(order), self.batch):
            idx = order[k:k + self.batch]
            T = max(len(enc[i].ids) for i in idx)
            ids = np.full((len(idx), T), self.pad_id, dtype=np.int64)
            am = np.zeros((len(idx), T), dtype=np.int64)
            for b, i in enumerate(idx):
                n = len(enc[i].ids)
                ids[b, :n] = enc[i].ids
                am[b, :n] = 1
            s = self.sess.run(["score"], {"input_ids": ids, "attention_mask": am})[0]
            out[idx] = np.asarray(s, dtype=np.float32).reshape(-1)
        return out

    def log_probs(self, question, option_texts, state=""):
        """Calibrated log-probabilities over option_texts (any number of options)."""
        st = "STATE: " + _state_text(state)
        s = self._scores([(f"QUESTION: {question}\nOPTION: {t}", st) for t in option_texts]) / self.temperature
        s = s - s.max()
        return s - np.log(np.exp(s).sum())

    def choose(self, question, options, state=""):
        """options: dict {key: description} or list of texts β†’ {answer, confidence, probabilities}."""
        if isinstance(options, (list, tuple)):              # plain list: the answer is the option text itself
            keys = texts = [str(o) for o in options]
        else:
            keys, texts = options_of({"type": "choice", "criteria": options})
        return self._pack(keys, self.log_probs(question, texts, state))

    def decide(self, state, questions):
        """Kev/AUBIN typed-decision JSON: {qid: {type: choice|noul, instructions, criteria}} β†’ {qid: result}."""
        flat, spans = [], {}
        st = "STATE: " + _state_text(state)
        for qid, q in questions.items():
            keys, texts = options_of(q)
            ins = q.get("instructions") or qid
            spans[qid] = (len(flat), keys)
            flat += [(f"QUESTION: {ins}\nOPTION: {t}", st) for t in texts]
        s = self._scores(flat) / self.temperature
        res = {}
        for qid, (a, keys) in spans.items():
            x = s[a:a + len(keys)]
            x = x - x.max()
            res[qid] = self._pack(keys, x - np.log(np.exp(x).sum()))
        return res

    @staticmethod
    def _pack(keys, lp):
        p = np.exp(lp)
        j = int(p.argmax())
        return {"answer": keys[j], "confidence": round(float(p[j]), 4),
                "probabilities": {str(k).lower() if isinstance(k, bool) else str(k): round(float(v), 4) for k, v in zip(keys, p)}}