decision-toolkit 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.
- decision_toolkit/__init__.py +102 -0
- decision_toolkit/__main__.py +8 -0
- decision_toolkit/adapters/__init__.py +165 -0
- decision_toolkit/adapters/base.py +134 -0
- decision_toolkit/adapters/gliner2.py +144 -0
- decision_toolkit/adapters/gliner2_decide.py +117 -0
- decision_toolkit/adapters/julia.py +168 -0
- decision_toolkit/adapters/laya.py +225 -0
- decision_toolkit/adapters/opendecider.py +144 -0
- decision_toolkit/calibration.py +79 -0
- decision_toolkit/catalog.py +472 -0
- decision_toolkit/cli.py +1277 -0
- decision_toolkit/config.py +90 -0
- decision_toolkit/contracts/__init__.py +58 -0
- decision_toolkit/contracts/common.py +61 -0
- decision_toolkit/contracts/mapping.py +165 -0
- decision_toolkit/contracts/openai_decisions_beta.py +133 -0
- decision_toolkit/contracts/systemone_v1.py +140 -0
- decision_toolkit/decision_model.py +692 -0
- decision_toolkit/engine/__init__.py +111 -0
- decision_toolkit/errors.py +138 -0
- decision_toolkit/limits.py +114 -0
- decision_toolkit/metrics.py +198 -0
- decision_toolkit/models.py +588 -0
- decision_toolkit/observability.py +145 -0
- decision_toolkit/py.typed +0 -0
- decision_toolkit/registry.py +283 -0
- decision_toolkit/results.py +355 -0
- decision_toolkit/schema.py +516 -0
- decision_toolkit/server.py +930 -0
- decision_toolkit/tracing.py +44 -0
- decision_toolkit-0.1.0.dist-info/METADATA +306 -0
- decision_toolkit-0.1.0.dist-info/RECORD +36 -0
- decision_toolkit-0.1.0.dist-info/WHEEL +4 -0
- decision_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- decision_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""`decision_toolkit.adapters.julia` — Julia-1's single-graph layout.
|
|
2
|
+
|
|
3
|
+
Contract (ported from the official `julia/data.py` sequence builder and the
|
|
4
|
+
ONNX repository's browser runtime):
|
|
5
|
+
|
|
6
|
+
[CLS] <type> question: <instructions> [SEP] ([MASK] opt0) ([MASK] opt1) … [SEP] <state> [SEP]
|
|
7
|
+
one ONNX graph: input_ids, attention_mask, marker_pos, marker_mask, qtype -> logits
|
|
8
|
+
probabilities = softmax(logits); noul options are [false, true], so p(true) = p[1]
|
|
9
|
+
|
|
10
|
+
Unlike Laya there is no separate head and no temperature: the exported graph
|
|
11
|
+
scores the options directly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ..engine import OnnxEngine, encode
|
|
21
|
+
from ..errors import AdapterError
|
|
22
|
+
from .base import (
|
|
23
|
+
build_choice_answer,
|
|
24
|
+
build_noul_answer,
|
|
25
|
+
build_score_answer,
|
|
26
|
+
question_options,
|
|
27
|
+
softmax,
|
|
28
|
+
state_to_text,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
QTYPES = {"choice": 0, "score": 1, "noul": 2}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class JuliaAdapter:
|
|
37
|
+
"""System One over a Julia-1 ONNX export (one graph, marker/qtype inputs)."""
|
|
38
|
+
|
|
39
|
+
name = "julia"
|
|
40
|
+
|
|
41
|
+
def __init__(self, *, max_length: int = 1024, head_length: int = 256) -> None:
|
|
42
|
+
self.max_length = int(max_length)
|
|
43
|
+
self.head_length = int(head_length)
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------ setup
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _special_ids(tokenizer: Any) -> tuple[int, int, int]:
|
|
48
|
+
for mask_name, bos_name, eos_name in (
|
|
49
|
+
("<mask>", "<bos>", "<eos>"),
|
|
50
|
+
("[MASK]", "[CLS]", "[SEP]"),
|
|
51
|
+
):
|
|
52
|
+
ids = (
|
|
53
|
+
tokenizer.token_to_id(mask_name),
|
|
54
|
+
tokenizer.token_to_id(bos_name),
|
|
55
|
+
tokenizer.token_to_id(eos_name),
|
|
56
|
+
)
|
|
57
|
+
if all(token is not None for token in ids):
|
|
58
|
+
return ids # type: ignore[return-value]
|
|
59
|
+
raise AdapterError(
|
|
60
|
+
"the tokenizer exposes neither <mask>/<bos>/<eos> nor [MASK]/[CLS]/[SEP]; "
|
|
61
|
+
"this is not a Julia-1 tokenizer"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _render_options(question: Mapping[str, Any]) -> list[str]:
|
|
66
|
+
criteria = question.get("criteria")
|
|
67
|
+
kind = question.get("type")
|
|
68
|
+
if kind == "choice":
|
|
69
|
+
return [f"{key}: {value}" if value else key for key, value in criteria.items()]
|
|
70
|
+
if kind == "score":
|
|
71
|
+
return [str(level) for level in criteria]
|
|
72
|
+
criteria = criteria or {}
|
|
73
|
+
return [
|
|
74
|
+
"false: " + (criteria.get("false") or "false"),
|
|
75
|
+
"true: " + (criteria.get("true") or "true"),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
def _sequence(
|
|
79
|
+
self,
|
|
80
|
+
tokenizer: Any,
|
|
81
|
+
state_text: str,
|
|
82
|
+
question: Mapping[str, Any],
|
|
83
|
+
options: list[str],
|
|
84
|
+
) -> tuple[list[int], list[int], bool]:
|
|
85
|
+
mask, bos, eos = self._special_ids(tokenizer)
|
|
86
|
+
truncated = False
|
|
87
|
+
instructions = str(question.get("instructions") or "")
|
|
88
|
+
head_ids = encode(tokenizer, f"{question['type']} question: {instructions}")
|
|
89
|
+
option_ids: list[list[int]] = []
|
|
90
|
+
for option in options:
|
|
91
|
+
encoded = encode(tokenizer, " " + option)
|
|
92
|
+
if len(encoded) > 48:
|
|
93
|
+
truncated = True
|
|
94
|
+
option_ids.append([mask, *encoded[:48]])
|
|
95
|
+
|
|
96
|
+
budget = self.head_length - sum(len(option) for option in option_ids)
|
|
97
|
+
if budget < 16:
|
|
98
|
+
truncated = True
|
|
99
|
+
per = max(4, (self.head_length - 16) // max(1, len(option_ids)))
|
|
100
|
+
option_ids = [option[:per] for option in option_ids]
|
|
101
|
+
budget = self.head_length - sum(len(option) for option in option_ids)
|
|
102
|
+
if len(head_ids) > max(8, budget):
|
|
103
|
+
truncated = True
|
|
104
|
+
head_ids = head_ids[: max(8, budget)]
|
|
105
|
+
|
|
106
|
+
ids = [bos, *head_ids, eos]
|
|
107
|
+
markers: list[int] = []
|
|
108
|
+
for option in option_ids:
|
|
109
|
+
markers.append(len(ids))
|
|
110
|
+
ids.extend(option)
|
|
111
|
+
ids.append(eos)
|
|
112
|
+
|
|
113
|
+
room = max(1, self.max_length - len(ids) - 1)
|
|
114
|
+
state_ids = encode(tokenizer, state_text)
|
|
115
|
+
if len(state_ids) > room:
|
|
116
|
+
truncated = True
|
|
117
|
+
state_ids = state_ids[:room]
|
|
118
|
+
all_ids = (ids + state_ids + [eos])[: self.max_length]
|
|
119
|
+
if len(markers) > sum(1 for marker in markers if marker < self.max_length):
|
|
120
|
+
truncated = True
|
|
121
|
+
return all_ids, [marker for marker in markers if marker < self.max_length], truncated
|
|
122
|
+
|
|
123
|
+
# -------------------------------------------------------------- inference
|
|
124
|
+
def system_one(
|
|
125
|
+
self, engine: OnnxEngine, state: Any, questions: Mapping[str, Mapping[str, Any]]
|
|
126
|
+
) -> Mapping[str, Mapping[str, Any]]:
|
|
127
|
+
import numpy as np
|
|
128
|
+
|
|
129
|
+
tokenizer = engine.tokenizer
|
|
130
|
+
state_text = state_to_text(state)
|
|
131
|
+
answers: dict[str, dict[str, Any]] = {}
|
|
132
|
+
|
|
133
|
+
for name, question in questions.items():
|
|
134
|
+
kind, labels, _ = question_options(question)
|
|
135
|
+
options = self._render_options(question)
|
|
136
|
+
ids, markers, truncated = self._sequence(tokenizer, state_text, question, options)
|
|
137
|
+
if len(markers) != len(options):
|
|
138
|
+
raise AdapterError(
|
|
139
|
+
f"{name!r}: the options do not fit in {self.head_length} tokens"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
input_ids = np.asarray([ids], dtype=np.int64)
|
|
143
|
+
attention = np.ones_like(input_ids)
|
|
144
|
+
logits = engine.session("model").run(None, {
|
|
145
|
+
"input_ids": input_ids,
|
|
146
|
+
"attention_mask": attention,
|
|
147
|
+
"marker_pos": np.asarray([markers], dtype=np.int64),
|
|
148
|
+
"marker_mask": np.ones((1, len(markers)), dtype=bool),
|
|
149
|
+
"qtype": np.asarray([QTYPES[kind]], dtype=np.int64),
|
|
150
|
+
})[0]
|
|
151
|
+
|
|
152
|
+
probabilities = softmax(np.asarray(logits)[0][: len(markers)].tolist())
|
|
153
|
+
if kind == "choice":
|
|
154
|
+
answer = build_choice_answer(labels, probabilities)
|
|
155
|
+
elif kind == "noul":
|
|
156
|
+
answer = build_noul_answer(probabilities[1], probabilities[0])
|
|
157
|
+
else:
|
|
158
|
+
answer = build_score_answer(labels, probabilities)
|
|
159
|
+
if truncated:
|
|
160
|
+
answer["truncated"] = True
|
|
161
|
+
answer["warnings"] = [
|
|
162
|
+
f"input was truncated to fit max_length={self.max_length}"
|
|
163
|
+
]
|
|
164
|
+
answers[name] = answer
|
|
165
|
+
return answers
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
__all__ = ["JuliaAdapter"]
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""`decision_toolkit.adapters.laya` — Laya's split encoder+head layout.
|
|
2
|
+
|
|
3
|
+
Contract (ported from the reference implementation and validated against the
|
|
4
|
+
mininvox export, where it reproduced the torch runtime's answers):
|
|
5
|
+
|
|
6
|
+
[CLS] <type> question: <instructions> [SEP] ([MASK] opt0) ([MASK] opt1) … [SEP] <state> [SEP]
|
|
7
|
+
encoder(input_ids, attention_mask) -> last_hidden_state
|
|
8
|
+
head(hidden_states, marker_pos, marker_mask, qtype, attention_mask)
|
|
9
|
+
-> logits per marker + act_logits
|
|
10
|
+
choice / score: softmax(logits / temperature)
|
|
11
|
+
noul: options are [false, true], so p(true) = p[1]
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import math
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ..engine import OnnxEngine, encode
|
|
22
|
+
from ..errors import AdapterError
|
|
23
|
+
from .base import (
|
|
24
|
+
build_choice_answer,
|
|
25
|
+
build_noul_answer,
|
|
26
|
+
build_score_answer,
|
|
27
|
+
question_options,
|
|
28
|
+
softmax,
|
|
29
|
+
state_to_text,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
QTYPES = {"choice": 0, "score": 1, "noul": 2}
|
|
35
|
+
QTYPE_NAMES = ["choice", "score", "noul"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LayaAdapter:
|
|
39
|
+
"""System One over a Laya split export (encoder + head + rl_agent_config)."""
|
|
40
|
+
|
|
41
|
+
name = "laya"
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------ setup
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _special_ids(tokenizer: Any) -> tuple[int, int, int]:
|
|
46
|
+
# Laya checkpoints ship two tokenizer serializations of the same contract:
|
|
47
|
+
# multilingual: <mask>/<bos>/<eos> · English: [MASK]/[CLS]/[SEP]
|
|
48
|
+
for mask_name, bos_name, eos_name in (
|
|
49
|
+
("<mask>", "<bos>", "<eos>"),
|
|
50
|
+
("[MASK]", "[CLS]", "[SEP]"),
|
|
51
|
+
):
|
|
52
|
+
ids = (
|
|
53
|
+
tokenizer.token_to_id(mask_name),
|
|
54
|
+
tokenizer.token_to_id(bos_name),
|
|
55
|
+
tokenizer.token_to_id(eos_name),
|
|
56
|
+
)
|
|
57
|
+
if all(token is not None for token in ids):
|
|
58
|
+
return ids # type: ignore[return-value]
|
|
59
|
+
raise AdapterError(
|
|
60
|
+
"the tokenizer exposes neither <mask>/<bos>/<eos> nor [MASK]/[CLS]/[SEP]; "
|
|
61
|
+
"this is not a Laya tokenizer"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _render_options(question: Mapping[str, Any]) -> list[str]:
|
|
66
|
+
criteria = question.get("criteria")
|
|
67
|
+
kind = question.get("type")
|
|
68
|
+
if kind == "choice":
|
|
69
|
+
return [f"{key}: {value}" if value else key for key, value in criteria.items()]
|
|
70
|
+
if kind == "score":
|
|
71
|
+
return [f"level {index}: {level}" for index, level in enumerate(criteria)]
|
|
72
|
+
criteria = criteria or {}
|
|
73
|
+
return [
|
|
74
|
+
"false: " + (criteria.get("false") or "no, the statement does not hold"),
|
|
75
|
+
"true: " + (criteria.get("true") or "yes, the statement holds"),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
def _sequence(
|
|
79
|
+
self,
|
|
80
|
+
tokenizer: Any,
|
|
81
|
+
state_text: str,
|
|
82
|
+
question: Mapping[str, Any],
|
|
83
|
+
options: list[str],
|
|
84
|
+
config: Mapping[str, Any],
|
|
85
|
+
) -> tuple[list[int], list[int], bool]:
|
|
86
|
+
max_len = int(config.get("max_len", 1024))
|
|
87
|
+
head_max_len = int(config.get("head_max_len", 256))
|
|
88
|
+
mask, bos, eos = self._special_ids(tokenizer)
|
|
89
|
+
truncated = False
|
|
90
|
+
|
|
91
|
+
instructions = str(question.get("instructions") or "")
|
|
92
|
+
head_ids = encode(tokenizer, f"{question['type']} question: {instructions}")
|
|
93
|
+
option_ids: list[list[int]] = []
|
|
94
|
+
for option in options:
|
|
95
|
+
encoded = encode(tokenizer, " " + option)
|
|
96
|
+
if len(encoded) > 48:
|
|
97
|
+
truncated = True
|
|
98
|
+
option_ids.append([mask, *encoded[:48]])
|
|
99
|
+
|
|
100
|
+
budget = head_max_len - sum(len(option) for option in option_ids)
|
|
101
|
+
if budget < 16:
|
|
102
|
+
truncated = True
|
|
103
|
+
per = max(4, (head_max_len - 16) // max(1, len(option_ids)))
|
|
104
|
+
option_ids = [option[:per] for option in option_ids]
|
|
105
|
+
budget = head_max_len - sum(len(option) for option in option_ids)
|
|
106
|
+
if len(head_ids) > max(8, budget):
|
|
107
|
+
truncated = True
|
|
108
|
+
head_ids = head_ids[: max(8, budget)]
|
|
109
|
+
|
|
110
|
+
ids = [bos, *head_ids, eos]
|
|
111
|
+
markers: list[int] = []
|
|
112
|
+
for option in option_ids:
|
|
113
|
+
markers.append(len(ids))
|
|
114
|
+
ids.extend(option)
|
|
115
|
+
ids.append(eos)
|
|
116
|
+
|
|
117
|
+
room = max(0, max_len - len(ids) - 1)
|
|
118
|
+
state_ids = encode(tokenizer, state_text)
|
|
119
|
+
if len(state_ids) > room:
|
|
120
|
+
truncated = True
|
|
121
|
+
state_ids = state_ids[:room]
|
|
122
|
+
all_ids = (ids + state_ids + [eos])[:max_len]
|
|
123
|
+
if len(markers) > sum(1 for marker in markers if marker < max_len):
|
|
124
|
+
truncated = True
|
|
125
|
+
return all_ids, [marker for marker in markers if marker < max_len], truncated
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _temperature(config: Mapping[str, Any], qtype: int, count: int) -> float:
|
|
129
|
+
size = "2" if count <= 2 else "3-5" if count <= 5 else "6-10" if count <= 10 else "11+"
|
|
130
|
+
bucket = f"{QTYPE_NAMES[qtype]}:{size}"
|
|
131
|
+
by_options = config.get("temperature_by_options") or {}
|
|
132
|
+
temperatures = config.get("temperature") or [1.0, 1.0, 1.0]
|
|
133
|
+
value = float(by_options.get(bucket, temperatures[qtype]))
|
|
134
|
+
# the torch runtime clamps temperatures outside [0.5, 5] before applying them
|
|
135
|
+
return min(5.0, max(0.5, value))
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _confidence(probabilities: list[float]) -> float:
|
|
139
|
+
count = len(probabilities)
|
|
140
|
+
if count < 2:
|
|
141
|
+
return 1.0
|
|
142
|
+
entropy = -sum(p * math.log(max(p, 1e-12)) for p in probabilities)
|
|
143
|
+
return 1 - entropy / math.log(count)
|
|
144
|
+
|
|
145
|
+
# -------------------------------------------------------------- inference
|
|
146
|
+
def system_one(
|
|
147
|
+
self, engine: OnnxEngine, state: Any, questions: Mapping[str, Mapping[str, Any]]
|
|
148
|
+
) -> Mapping[str, Mapping[str, Any]]:
|
|
149
|
+
import numpy as np
|
|
150
|
+
|
|
151
|
+
config = engine.read_json("config")
|
|
152
|
+
tokenizer = engine.tokenizer
|
|
153
|
+
state_text = state_to_text(state)
|
|
154
|
+
answers: dict[str, dict[str, Any]] = {}
|
|
155
|
+
|
|
156
|
+
for name, question in questions.items():
|
|
157
|
+
kind, labels, _ = question_options(question)
|
|
158
|
+
options = self._render_options(question)
|
|
159
|
+
ids, markers, truncated = self._sequence(
|
|
160
|
+
tokenizer, state_text, question, options, config
|
|
161
|
+
)
|
|
162
|
+
if len(markers) != len(options):
|
|
163
|
+
raise AdapterError(
|
|
164
|
+
f"{name!r}: the options do not fit in {config.get('head_max_len')} tokens"
|
|
165
|
+
)
|
|
166
|
+
qtype = QTYPES[kind]
|
|
167
|
+
|
|
168
|
+
input_ids = np.asarray([ids], dtype=np.int64)
|
|
169
|
+
attention = np.ones_like(input_ids)
|
|
170
|
+
hidden = engine.session("encoder").run(
|
|
171
|
+
None, {"input_ids": input_ids, "attention_mask": attention}
|
|
172
|
+
)[0]
|
|
173
|
+
|
|
174
|
+
head = engine.session("head")
|
|
175
|
+
inputs = {item.name: item for item in head.get_inputs()}
|
|
176
|
+
hidden_name = next(
|
|
177
|
+
(name for name in ("hidden_states", "hidden", "last_hidden_state") if name in inputs),
|
|
178
|
+
None,
|
|
179
|
+
)
|
|
180
|
+
if hidden_name is None:
|
|
181
|
+
raise AdapterError(
|
|
182
|
+
f"the head exposes no hidden-state input; inputs: {sorted(inputs)}"
|
|
183
|
+
)
|
|
184
|
+
qtype_value = (
|
|
185
|
+
np.asarray([qtype], dtype=np.int64)
|
|
186
|
+
if len(inputs["qtype"].shape) == 1
|
|
187
|
+
else np.asarray([[qtype]], dtype=np.int64)
|
|
188
|
+
)
|
|
189
|
+
logits, act_logits = head.run(None, {
|
|
190
|
+
hidden_name: hidden,
|
|
191
|
+
"attention_mask": attention,
|
|
192
|
+
"marker_pos": np.asarray([markers], dtype=np.int64),
|
|
193
|
+
"marker_mask": np.ones((1, len(markers)), dtype=bool),
|
|
194
|
+
"qtype": qtype_value,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
raw = np.asarray(logits)[0][: len(markers)]
|
|
198
|
+
scaled = (raw / self._temperature(config, qtype, len(markers))).tolist()
|
|
199
|
+
probabilities = softmax(scaled)
|
|
200
|
+
act_probability = softmax(np.asarray(act_logits)[0].tolist())[0]
|
|
201
|
+
|
|
202
|
+
if kind == "choice":
|
|
203
|
+
answer = build_choice_answer(labels, probabilities)
|
|
204
|
+
elif kind == "noul":
|
|
205
|
+
# Laya's options are [false, true]: p[1] is the probability of true
|
|
206
|
+
answer = build_noul_answer(probabilities[1], probabilities[0])
|
|
207
|
+
else:
|
|
208
|
+
answer = build_score_answer(labels, probabilities)
|
|
209
|
+
|
|
210
|
+
if kind in {"choice", "score"}:
|
|
211
|
+
answer["answer_confidence"] = answer["confidence"]
|
|
212
|
+
answer["distribution_confidence"] = self._confidence(probabilities)
|
|
213
|
+
answer["confidence_kind"] = "probability"
|
|
214
|
+
answer["action"] = {"act_probability": float(act_probability)}
|
|
215
|
+
if truncated:
|
|
216
|
+
answer["truncated"] = True
|
|
217
|
+
answer["warnings"] = [
|
|
218
|
+
f"input was truncated to fit max_len={config.get('max_len', 1024)}"
|
|
219
|
+
]
|
|
220
|
+
answers[name] = answer
|
|
221
|
+
|
|
222
|
+
return answers
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
__all__ = ["LayaAdapter"]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""`decision_toolkit.adapters.opendecider` — OpenDecider nano's single-graph layout.
|
|
2
|
+
|
|
3
|
+
Contract (from the project's `opendecider/prompt.py::nano_ids` and the export's
|
|
4
|
+
`opendecider.json`):
|
|
5
|
+
|
|
6
|
+
[CLS] question: <instructions> [SEP] ([MASK] opt) … [SEP] input: <state> [SEP]
|
|
7
|
+
one ONNX graph: (input_ids, attention_mask) -> logits per token
|
|
8
|
+
the answer is softmax over the logits at the [MASK] positions
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..engine import OnnxEngine, encode
|
|
18
|
+
from ..errors import AdapterError
|
|
19
|
+
from .base import (
|
|
20
|
+
build_choice_answer,
|
|
21
|
+
build_noul_answer,
|
|
22
|
+
build_score_answer,
|
|
23
|
+
softmax,
|
|
24
|
+
state_to_text,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OpendeciderAdapter:
|
|
31
|
+
"""System One over the official OpenDecider nano ONNX build."""
|
|
32
|
+
|
|
33
|
+
name = "opendecider"
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------ setup
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _render_options(question: Mapping[str, Any]) -> dict[str, str | None]:
|
|
38
|
+
kind = question.get("type")
|
|
39
|
+
criteria = question.get("criteria")
|
|
40
|
+
if kind == "choice":
|
|
41
|
+
return {str(key): (None if value is None else str(value)) for key, value in criteria.items()}
|
|
42
|
+
if kind == "score":
|
|
43
|
+
return {str(level): None for level in criteria}
|
|
44
|
+
criteria = criteria or {}
|
|
45
|
+
return {
|
|
46
|
+
"false": None if criteria.get("false") is None else str(criteria.get("false")),
|
|
47
|
+
"true": None if criteria.get("true") is None else str(criteria.get("true")),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _special_ids(config: Mapping[str, Any]) -> tuple[int, int, int]:
|
|
52
|
+
ids = config.get("special_ids") or {}
|
|
53
|
+
try:
|
|
54
|
+
return int(ids["cls"]), int(ids["sep"]), int(ids["mask"])
|
|
55
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
56
|
+
raise AdapterError(
|
|
57
|
+
"opendecider.json must declare special_ids.cls/sep/mask"
|
|
58
|
+
) from error
|
|
59
|
+
|
|
60
|
+
def _sequence(
|
|
61
|
+
self,
|
|
62
|
+
tokenizer: Any,
|
|
63
|
+
state_text: str,
|
|
64
|
+
instructions: str,
|
|
65
|
+
options: Mapping[str, str | None],
|
|
66
|
+
special_ids: tuple[int, int, int],
|
|
67
|
+
max_len: int,
|
|
68
|
+
) -> tuple[list[int], list[int], bool]:
|
|
69
|
+
cls_id, sep_id, mask_id = special_ids
|
|
70
|
+
truncated = False
|
|
71
|
+
ids = [cls_id, *encode(tokenizer, f"question: {instructions}"), sep_id]
|
|
72
|
+
markers: list[int] = []
|
|
73
|
+
for key, value in options.items():
|
|
74
|
+
markers.append(len(ids))
|
|
75
|
+
ids.append(mask_id)
|
|
76
|
+
text = f" {key}: {value}" if value and value != key else f" {key}"
|
|
77
|
+
ids.extend(encode(tokenizer, text))
|
|
78
|
+
ids.append(sep_id)
|
|
79
|
+
|
|
80
|
+
state_ids = encode(tokenizer, f"input: {state_text}")
|
|
81
|
+
room = max(max_len - len(ids) - 1, 0)
|
|
82
|
+
if len(state_ids) > room:
|
|
83
|
+
truncated = True
|
|
84
|
+
ids = ids + state_ids[:room] + [sep_id]
|
|
85
|
+
if len(ids) > max_len:
|
|
86
|
+
truncated = True
|
|
87
|
+
if len(markers) > sum(1 for marker in markers if marker < max_len):
|
|
88
|
+
truncated = True
|
|
89
|
+
return ids[:max_len], [marker for marker in markers if marker < max_len], truncated
|
|
90
|
+
|
|
91
|
+
def _scores(
|
|
92
|
+
self,
|
|
93
|
+
engine: OnnxEngine,
|
|
94
|
+
state_text: str,
|
|
95
|
+
instructions: str,
|
|
96
|
+
options: Mapping[str, str | None],
|
|
97
|
+
special_ids: tuple[int, int, int],
|
|
98
|
+
max_len: int,
|
|
99
|
+
) -> tuple[list[float], bool]:
|
|
100
|
+
import numpy as np
|
|
101
|
+
|
|
102
|
+
ids, markers, truncated = self._sequence(
|
|
103
|
+
engine.tokenizer, state_text, instructions, options, special_ids, max_len
|
|
104
|
+
)
|
|
105
|
+
input_ids = np.asarray([ids], dtype=np.int64)
|
|
106
|
+
logits = engine.session("model").run(None, {
|
|
107
|
+
"input_ids": input_ids,
|
|
108
|
+
"attention_mask": np.ones_like(input_ids),
|
|
109
|
+
})[0][0]
|
|
110
|
+
return [float(logits[position]) for position in markers], truncated
|
|
111
|
+
|
|
112
|
+
# -------------------------------------------------------------- inference
|
|
113
|
+
def system_one(
|
|
114
|
+
self, engine: OnnxEngine, state: Any, questions: Mapping[str, Mapping[str, Any]]
|
|
115
|
+
) -> Mapping[str, Mapping[str, Any]]:
|
|
116
|
+
config = engine.read_json("config")
|
|
117
|
+
special_ids = self._special_ids(config)
|
|
118
|
+
max_len = int(config.get("max_len", 2048))
|
|
119
|
+
state_text = state_to_text(state)
|
|
120
|
+
answers: dict[str, dict[str, Any]] = {}
|
|
121
|
+
|
|
122
|
+
for name, question in questions.items():
|
|
123
|
+
options = self._render_options(question)
|
|
124
|
+
scores, truncated = self._scores(
|
|
125
|
+
engine, state_text, str(question.get("instructions") or ""),
|
|
126
|
+
options, special_ids, max_len,
|
|
127
|
+
)
|
|
128
|
+
probabilities = softmax(scores)
|
|
129
|
+
kind = question.get("type")
|
|
130
|
+
labels = list(options)
|
|
131
|
+
if kind == "choice":
|
|
132
|
+
answer = build_choice_answer(labels, probabilities)
|
|
133
|
+
elif kind == "noul":
|
|
134
|
+
answer = build_noul_answer(probabilities[1], probabilities[0])
|
|
135
|
+
else:
|
|
136
|
+
answer = build_score_answer(labels, probabilities)
|
|
137
|
+
if truncated:
|
|
138
|
+
answer["truncated"] = True
|
|
139
|
+
answer["warnings"] = [f"input was truncated to fit max_len={max_len}"]
|
|
140
|
+
answers[name] = answer
|
|
141
|
+
return answers
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
__all__ = ["OpendeciderAdapter"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""`decision_toolkit.calibration` — do the probabilities mean what they say?
|
|
2
|
+
|
|
3
|
+
Decision models answer with probabilities; whether those probabilities are
|
|
4
|
+
*calibrated* (a 0.8 answer is right ~80% of the time) varies by model, by
|
|
5
|
+
quantization and by domain. The toolkit keeps this small but first-class:
|
|
6
|
+
measure before you threshold.
|
|
7
|
+
|
|
8
|
+
A *pair* is ``(probability_given_to_the_chosen_answer, was_it_right)`` — build
|
|
9
|
+
it from your own labelled rows with :func:`pairs_from_rows`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterable, Sequence
|
|
15
|
+
|
|
16
|
+
Pair = tuple[float, bool]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pairs_from_rows(rows: Iterable[tuple[float, bool]]) -> list[Pair]:
|
|
20
|
+
"""Normalize/validate ``(probability, correct)`` rows."""
|
|
21
|
+
pairs: list[Pair] = []
|
|
22
|
+
for probability, correct in rows:
|
|
23
|
+
p = float(probability)
|
|
24
|
+
if not 0.0 <= p <= 1.0:
|
|
25
|
+
raise ValueError(f"probability out of [0, 1]: {p}")
|
|
26
|
+
pairs.append((p, bool(correct)))
|
|
27
|
+
return pairs
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def brier_score(pairs: Sequence[Pair]) -> float:
|
|
31
|
+
"""Mean squared error of the probabilities (lower is better; 0 is perfect)."""
|
|
32
|
+
if not pairs:
|
|
33
|
+
raise ValueError("brier_score needs at least one pair")
|
|
34
|
+
return sum((probability - float(correct)) ** 2 for probability, correct in pairs) / len(pairs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def expected_calibration_error(pairs: Sequence[Pair], n_bins: int = 10) -> float:
|
|
38
|
+
"""Expected calibration error: |accuracy - confidence| averaged over bins."""
|
|
39
|
+
if not pairs:
|
|
40
|
+
raise ValueError("expected_calibration_error needs at least one pair")
|
|
41
|
+
bins: list[list[Pair]] = [[] for _ in range(n_bins)]
|
|
42
|
+
for probability, correct in pairs:
|
|
43
|
+
index = min(int(probability * n_bins), n_bins - 1)
|
|
44
|
+
bins[index].append((probability, correct))
|
|
45
|
+
|
|
46
|
+
total = len(pairs)
|
|
47
|
+
error = 0.0
|
|
48
|
+
for bucket in bins:
|
|
49
|
+
if not bucket:
|
|
50
|
+
continue
|
|
51
|
+
confidence = sum(p for p, _ in bucket) / len(bucket)
|
|
52
|
+
accuracy = sum(1.0 for _, correct in bucket if correct) / len(bucket)
|
|
53
|
+
error += (len(bucket) / total) * abs(accuracy - confidence)
|
|
54
|
+
return error
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def threshold_sweep(pairs: Sequence[Pair], thresholds: Sequence[float] = (0.5, 0.6, 0.7, 0.8, 0.9)
|
|
58
|
+
) -> dict[float, dict[str, float]]:
|
|
59
|
+
"""Coverage and kept accuracy at each confidence threshold.
|
|
60
|
+
|
|
61
|
+
``coverage`` is the share of items the model answers at or above the
|
|
62
|
+
threshold; ``accuracy_kept`` is how often those answers are right. An item
|
|
63
|
+
counts as right when its probability side (>= 0.5) matches its label —
|
|
64
|
+
pass ``correct=True/False`` directly to decide a different rule.
|
|
65
|
+
"""
|
|
66
|
+
if not pairs:
|
|
67
|
+
raise ValueError("threshold_sweep needs at least one pair")
|
|
68
|
+
report: dict[float, dict[str, float]] = {}
|
|
69
|
+
for threshold in thresholds:
|
|
70
|
+
kept = [(p, c) for p, c in pairs if p >= threshold]
|
|
71
|
+
report[float(threshold)] = {
|
|
72
|
+
"coverage": len(kept) / len(pairs),
|
|
73
|
+
"accuracy_kept": (sum(1.0 for _, c in kept) / len(kept)) if kept else 0.0,
|
|
74
|
+
"n_kept": float(len(kept)),
|
|
75
|
+
}
|
|
76
|
+
return report
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["Pair", "brier_score", "expected_calibration_error", "pairs_from_rows", "threshold_sweep"]
|