decisionnode-edge 0.9.2__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.
- decisionnode_edge/__init__.py +13 -0
- decisionnode_edge/__main__.py +42 -0
- decisionnode_edge/answers.py +234 -0
- decisionnode_edge/backends.py +397 -0
- decisionnode_edge/build.py +169 -0
- decisionnode_edge/model.py +273 -0
- decisionnode_edge/places.py +536 -0
- decisionnode_edge/request.py +337 -0
- decisionnode_edge/server.py +282 -0
- decisionnode_edge/systemone.py +597 -0
- decisionnode_edge/template.py +273 -0
- decisionnode_edge-0.9.2.dist-info/METADATA +316 -0
- decisionnode_edge-0.9.2.dist-info/RECORD +16 -0
- decisionnode_edge-0.9.2.dist-info/WHEEL +5 -0
- decisionnode_edge-0.9.2.dist-info/licenses/LICENSE +661 -0
- decisionnode_edge-0.9.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""DecisionNode Edge: typed, calibrated decisions from a model that runs on your own hardware.
|
|
2
|
+
|
|
3
|
+
from decisionnode_edge import EdgeModel
|
|
4
|
+
|
|
5
|
+
model = EdgeModel.load("path/to/decisionnode-edge")
|
|
6
|
+
model.decide({"state": "...", "questions": {"escalate": {"type": "truth", "instructions": "Escalate now?"}}})
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .model import EdgeModel
|
|
10
|
+
from .request import RequestError
|
|
11
|
+
|
|
12
|
+
__all__ = ["EdgeModel", "RequestError"]
|
|
13
|
+
__version__ = "0.9.2"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Answer one request from the command line, or serve the model over HTTP (server.py).
|
|
2
|
+
|
|
3
|
+
python -m decisionnode_edge path/to/decisionnode-edge request.json # or "-" to read the request from stdin
|
|
4
|
+
python -m decisionnode_edge serve path/to/decisionnode-edge --port 8000
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from . import EdgeModel, RequestError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: list[str] | None = None) -> int:
|
|
17
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
18
|
+
if argv[:1] == ["serve"]:
|
|
19
|
+
from .server import main as serve
|
|
20
|
+
|
|
21
|
+
return serve(argv[1:])
|
|
22
|
+
parser = argparse.ArgumentParser(prog="python -m decisionnode_edge", description="Answer a /v1/decide request with a local DecisionNode Edge model.")
|
|
23
|
+
parser.add_argument("model", help="the model directory")
|
|
24
|
+
parser.add_argument("request", help="a JSON file with the request body, or - for stdin")
|
|
25
|
+
parser.add_argument("--device", default=None, help="cuda or cpu (default: cuda when present)")
|
|
26
|
+
parser.add_argument("--dtype", default=None, help="bfloat16 or float32 (default: bfloat16 on cuda, float32 on cpu)")
|
|
27
|
+
parser.add_argument("--backend", default=None, help="auto, torch, vllm, openvino, mlx or llamacpp (default: auto, the fastest installed)")
|
|
28
|
+
parser.add_argument("--artifacts", default=None, help="the directory python -m decisionnode_edge.build wrote (openvino, llamacpp)")
|
|
29
|
+
args = parser.parse_args(argv)
|
|
30
|
+
body = json.load(sys.stdin if args.request == "-" else open(args.request, encoding="utf-8"))
|
|
31
|
+
model = EdgeModel.load(args.model, device=args.device, dtype=args.dtype, backend=args.backend, artifacts=args.artifacts)
|
|
32
|
+
try:
|
|
33
|
+
response = model.decide(body)
|
|
34
|
+
except RequestError as error:
|
|
35
|
+
print(json.dumps({"error": str(error), "status": error.status}), file=sys.stderr)
|
|
36
|
+
return 1
|
|
37
|
+
print(json.dumps(response, ensure_ascii=False, indent=2))
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
sys.exit(main())
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Calibration and the answer objects of the hosted API.
|
|
2
|
+
|
|
3
|
+
Calibration (calibration.json):
|
|
4
|
+
|
|
5
|
+
choice, score p = softmax(z / T(K)), T(K) = t0 + t1 * ln K, K clamped to the fitted range
|
|
6
|
+
truth p_yes = sigmoid((z_yes - z_no) / T + b)
|
|
7
|
+
coord p = softmax(z), a point or box question's reading, never calibrated (places.py)
|
|
8
|
+
|
|
9
|
+
A parameter set exists per input kind ("text", "image"); a missing one is the identity.
|
|
10
|
+
|
|
11
|
+
The answers, rounded as the hosted API rounds them:
|
|
12
|
+
|
|
13
|
+
choice {"type", "choice", "confidence", "probabilities": {option: p}} options in alphabetical order
|
|
14
|
+
score {"type", "score", "confidence", "legend": {"0": level}, "probabilities": {"0": p}}
|
|
15
|
+
truth {"type", "truth"}
|
|
16
|
+
number {"type", "number", "expected", "confidence", "probabilities": {value: p}}
|
|
17
|
+
point {"type", "x", "y", "present", "confidence", "image", "width", "height"} places.py
|
|
18
|
+
box {"type", "x1", "y1", "x2", "y2", "present", "confidence", "image", "width", "height"}
|
|
19
|
+
with `max`: {"type", "count", "present", "complete", "points" | "boxes": [...], "image", "width", "height"}
|
|
20
|
+
|
|
21
|
+
`choice`, `score`, `number` and the confidences come from the unrounded probabilities. A probability list is rounded
|
|
22
|
+
to 2 decimals entry by entry, then its largest entries move by 0.01 until it sums to 1 (`round_probabilities`).
|
|
23
|
+
`exact_sum` (the rounding of /v1/systemone, where benchmarks check the sum): the probabilities are normalised first, and
|
|
24
|
+
the list sums to exactly 1.00 in hundredths, every value a clean 2-decimal number.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
from decimal import Decimal, localcontext
|
|
31
|
+
from typing import Sequence
|
|
32
|
+
|
|
33
|
+
T_MIN, T_MAX = 0.05, 100.0
|
|
34
|
+
UNIT = 0.01
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Calibration
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _temperature(params: dict, k: int) -> float:
|
|
43
|
+
if params.get("k_min") is not None:
|
|
44
|
+
k = min(max(k, params["k_min"]), params["k_max"])
|
|
45
|
+
t = float(params.get("t0", 1.0)) + float(params.get("t1", 0.0)) * math.log(k)
|
|
46
|
+
return min(T_MAX, max(T_MIN, t))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def calibrated(logits: Sequence[float], kind: str, calibration: dict, has_images: bool) -> list[float]:
|
|
50
|
+
"""Calibrated class probabilities from a question's code logits (class order: options or levels as given,
|
|
51
|
+
[yes, no] for truth)."""
|
|
52
|
+
if kind == "coord":
|
|
53
|
+
return softmax(logits)
|
|
54
|
+
params = (calibration.get("image" if has_images else "text") or {}).get("choice" if kind == "number" else kind) or {}
|
|
55
|
+
z = [float(x) for x in logits]
|
|
56
|
+
if kind == "truth":
|
|
57
|
+
x = (z[0] - z[1]) / float(params.get("temperature", 1.0)) + float(params.get("bias", 0.0))
|
|
58
|
+
p = 1.0 / (1.0 + math.exp(-x)) if x >= 0 else math.exp(x) / (1.0 + math.exp(x))
|
|
59
|
+
return [p, 1.0 - p]
|
|
60
|
+
if len(z) == 1:
|
|
61
|
+
return [1.0]
|
|
62
|
+
t = _temperature(params, len(z))
|
|
63
|
+
scaled = [x / t for x in z]
|
|
64
|
+
top = max(scaled)
|
|
65
|
+
log_total = math.log(sum(math.exp(x - top) for x in scaled))
|
|
66
|
+
return [math.exp(x - top - log_total) for x in scaled]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def softmax(logits: Sequence[float]) -> list[float]:
|
|
70
|
+
"""A plain softmax (the largest score subtracted first): a coordinate row's probabilities."""
|
|
71
|
+
z = [float(x) for x in logits]
|
|
72
|
+
if not z or not all(math.isfinite(x) for x in z):
|
|
73
|
+
raise ValueError("scores must be a list of finite numbers")
|
|
74
|
+
top = max(z)
|
|
75
|
+
e = [math.exp(x - top) for x in z]
|
|
76
|
+
total = math.fsum(e)
|
|
77
|
+
return [x / total for x in e]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Rounding
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _units(x: float) -> int:
|
|
86
|
+
"""x in hundredths, halves up (the epsilon absorbs binary error: 0.805 rounds to 0.81)."""
|
|
87
|
+
return int(math.floor(x * 100.0 + 0.5 + 1e-9))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def round2(x: float) -> float:
|
|
91
|
+
value = _units(x) / 100.0
|
|
92
|
+
return 0.0 if value == 0 else value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def normalised(probs: Sequence[float]) -> list[float]:
|
|
96
|
+
"""The probabilities divided by their float64 sum (a sum within 1e-9 of 1); a list that sums to 0 as it is."""
|
|
97
|
+
p = [float(x) for x in probs]
|
|
98
|
+
total = math.fsum(p)
|
|
99
|
+
if total <= 0 or not math.isfinite(total):
|
|
100
|
+
return p
|
|
101
|
+
return [x / total for x in p]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def round_probabilities(probs: Sequence[float], exact_sum: bool = False) -> list[float]:
|
|
105
|
+
"""Round to 2 decimals, then move the |n| largest entries by 0.01, n = int((1 - fsum(rounded)) / 0.01).
|
|
106
|
+
A moved entry is not rounded again (0.81 + 0.01 is 0.8200000000000001), as the hosted API returns it.
|
|
107
|
+
|
|
108
|
+
`exact_sum` (pass normalised probabilities): n is 100 less the sum of the rounded hundredths, and every value is the
|
|
109
|
+
clean 2-decimal number, so the hundredths sum to exactly 100."""
|
|
110
|
+
p = [float(x) for x in probs]
|
|
111
|
+
units = [_units(x) for x in p]
|
|
112
|
+
rounded = [u / 100.0 for u in units]
|
|
113
|
+
if exact_sum:
|
|
114
|
+
left = abs(100 - sum(units))
|
|
115
|
+
step = 1 if sum(units) < 100 else -1
|
|
116
|
+
order = sorted(range(len(p)), key=lambda i: (-p[i], i))
|
|
117
|
+
while left: # every entry moves once, the largest first; a list that needs more moves round again
|
|
118
|
+
progressed = False
|
|
119
|
+
for i in order:
|
|
120
|
+
if not left:
|
|
121
|
+
break
|
|
122
|
+
if units[i] + step < 0:
|
|
123
|
+
continue
|
|
124
|
+
units[i] += step
|
|
125
|
+
left -= 1
|
|
126
|
+
progressed = True
|
|
127
|
+
if not progressed:
|
|
128
|
+
raise ValueError("probabilities cannot be rounded to a list that sums to 1")
|
|
129
|
+
return [0.0 if u == 0 else u / 100.0 for u in units]
|
|
130
|
+
n = int((1.0 - math.fsum(rounded)) / UNIT)
|
|
131
|
+
if n:
|
|
132
|
+
step = 1 if n > 0 else -1
|
|
133
|
+
for i in sorted(range(len(p)), key=lambda i: (-p[i], i))[: abs(n)]:
|
|
134
|
+
rounded[i] = rounded[i] + UNIT if step > 0 else rounded[i] - UNIT
|
|
135
|
+
return [0.0 if x == 0 else x for x in rounded]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _argmax_first(probs: Sequence[float]) -> int:
|
|
139
|
+
best = 0
|
|
140
|
+
for i, p in enumerate(probs):
|
|
141
|
+
if p > probs[best]:
|
|
142
|
+
best = i
|
|
143
|
+
return best
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Answers
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def choice_answer(keys: Sequence[str], probs: Sequence[float], exact_sum: bool = False) -> dict:
|
|
152
|
+
if exact_sum:
|
|
153
|
+
probs = normalised(probs)
|
|
154
|
+
best = _argmax_first(probs)
|
|
155
|
+
k = len(probs)
|
|
156
|
+
confidence = 1.0 if k == 1 else min(1.0, max(0.0, (k * probs[best] - 1.0) / (k - 1.0)))
|
|
157
|
+
shown = round_probabilities(probs, exact_sum)
|
|
158
|
+
return {"type": "choice", "choice": keys[best], "confidence": round2(confidence), "probabilities": dict(sorted(zip(keys, shown)))}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def score_answer(levels: Sequence, probs: Sequence[float], exact_sum: bool = False) -> dict:
|
|
162
|
+
if exact_sum:
|
|
163
|
+
probs = normalised(probs)
|
|
164
|
+
k = len(probs)
|
|
165
|
+
if k == 1:
|
|
166
|
+
confidence = 1.0
|
|
167
|
+
else:
|
|
168
|
+
mode = _argmax_first(probs)
|
|
169
|
+
spread = sum(p * abs(i - mode) for i, p in enumerate(probs))
|
|
170
|
+
confidence = min(1.0, max(0.0, 1.0 - spread / ((k * k // 4) / k)))
|
|
171
|
+
return {
|
|
172
|
+
"type": "score",
|
|
173
|
+
"score": round2(sum(i * p for i, p in enumerate(probs))),
|
|
174
|
+
"confidence": round2(confidence),
|
|
175
|
+
"legend": {str(i): level for i, level in enumerate(levels)},
|
|
176
|
+
"probabilities": {str(i): x for i, x in enumerate(round_probabilities(probs, exact_sum))},
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def truth_answer(probs: Sequence[float]) -> dict:
|
|
181
|
+
return {"type": "truth", "truth": round2(probs[0])}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _expected_text(value: float, decimals: int) -> str:
|
|
185
|
+
"""Halves up to `decimals` (at most 15 significant digits), with a decimal point, never -0.0."""
|
|
186
|
+
with localcontext() as ctx:
|
|
187
|
+
ctx.prec = 200
|
|
188
|
+
exact = Decimal(value)
|
|
189
|
+
whole = abs(int(exact))
|
|
190
|
+
decimals = min(decimals, 15 - (len(str(whole)) if whole else 0))
|
|
191
|
+
units = (exact * Decimal(10) ** decimals + Decimal("0.5") + Decimal("1e-9")).to_integral_value(rounding="ROUND_FLOOR")
|
|
192
|
+
text = format(units * Decimal(10) ** -decimals, "f")
|
|
193
|
+
if "." in text:
|
|
194
|
+
text = text.rstrip("0")
|
|
195
|
+
text = text + "0" if text.endswith(".") else text
|
|
196
|
+
else:
|
|
197
|
+
text += ".0"
|
|
198
|
+
return "0.0" if text == "-0.0" else text
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _number(text: str) -> int | float:
|
|
202
|
+
return float(text) if "." in text else int(text)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def number_answer(grid, probs: Sequence[float]) -> dict:
|
|
206
|
+
"""The most probable value, the probability-weighted mean (`expected`), the probability of the value
|
|
207
|
+
(`confidence`) and every value above 0.001 to 3 decimals (rounded entries that sum above 1 lose 0.001 each,
|
|
208
|
+
the largest first, the answer's own last)."""
|
|
209
|
+
best = _argmax_first(probs)
|
|
210
|
+
texts = grid.texts()
|
|
211
|
+
expected = math.fsum(float(text) * p for text, p in zip(texts, probs))
|
|
212
|
+
listed = [i for i in range(grid.n) if probs[i] > 0.001 or i == best]
|
|
213
|
+
millis = {i: int(math.floor(probs[i] * 1000.0 + 0.5 + 1e-9)) for i in listed}
|
|
214
|
+
excess = sum(millis.values()) - 1000
|
|
215
|
+
order = sorted((i for i in listed if i != best), key=lambda i: (-probs[i], i))
|
|
216
|
+
while excess > 0:
|
|
217
|
+
lowered = False
|
|
218
|
+
for i in order:
|
|
219
|
+
if excess <= 0:
|
|
220
|
+
break
|
|
221
|
+
if millis[i] > 0:
|
|
222
|
+
millis[i] -= 1
|
|
223
|
+
excess -= 1
|
|
224
|
+
lowered = True
|
|
225
|
+
if not lowered:
|
|
226
|
+
millis[best] -= 1
|
|
227
|
+
excess -= 1
|
|
228
|
+
return {
|
|
229
|
+
"type": "number",
|
|
230
|
+
"number": _number(texts[best]),
|
|
231
|
+
"expected": float(_expected_text(expected, max(2, grid.d))),
|
|
232
|
+
"confidence": round2(probs[best]),
|
|
233
|
+
"probabilities": {texts[i]: millis[i] / 1000.0 for i in listed},
|
|
234
|
+
}
|