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,273 @@
|
|
|
1
|
+
"""Load a DecisionNode Edge model directory and answer requests.
|
|
2
|
+
|
|
3
|
+
from decisionnode_edge import EdgeModel
|
|
4
|
+
|
|
5
|
+
model = EdgeModel.load("path/to/decisionnode-edge")
|
|
6
|
+
response = model.decide({"state": "...", "questions": {"route": {"type": "choice", "criteria": {...}}}})
|
|
7
|
+
|
|
8
|
+
The model reads the prompt once per question and never generates text. Its answer is read at the
|
|
9
|
+
<|answer|> position: the last hidden state there is scored against a small readout table
|
|
10
|
+
(readout.safetensors), one row per answer code, and only the rows of the question's codes are read.
|
|
11
|
+
Calibration (calibration.json) turns those scores into probabilities.
|
|
12
|
+
|
|
13
|
+
A point or box question is a few such prompts, pass by pass (places.py): whether the thing is in the picture,
|
|
14
|
+
then where along each side, each later prompt given what the earlier ones read.
|
|
15
|
+
|
|
16
|
+
The backbone is read by torch (transformers) or by a faster backend (backends.py: vLLM, OpenVINO, MLX,
|
|
17
|
+
llama.cpp); the readout and calibration are this module's in every case.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import io
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import torch
|
|
30
|
+
from safetensors.torch import load_file
|
|
31
|
+
|
|
32
|
+
from . import answers, backends, places, request as requests
|
|
33
|
+
from .template import Template
|
|
34
|
+
|
|
35
|
+
CONFIG_FILE = "decisionnode_edge.json"
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger("decisionnode_edge")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_backbone(path: str, device: str | None, dtype: str | torch.dtype | None) -> tuple[Any, bool]:
|
|
41
|
+
"""The transformers backbone on `device` (default CUDA when present, else the CPU) in `dtype` (default bfloat16 on
|
|
42
|
+
CUDA, float32 elsewhere), and whether it runs under bf16 autocast."""
|
|
43
|
+
from transformers import AutoModelForImageTextToText
|
|
44
|
+
|
|
45
|
+
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
|
46
|
+
if dtype is None:
|
|
47
|
+
dtype = torch.bfloat16 if str(device).startswith("cuda") else torch.float32
|
|
48
|
+
dtype = getattr(torch, dtype) if isinstance(dtype, str) else dtype
|
|
49
|
+
backbone = AutoModelForImageTextToText.from_pretrained(path, dtype=dtype).to(device).eval()
|
|
50
|
+
# bf16 weights run under bf16 autocast, the reference numerics: some operations then compute in float32
|
|
51
|
+
return backbone, dtype == torch.bfloat16
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EdgeModel:
|
|
55
|
+
def __init__(self, backbone: Any, tokenizer: Any, readout: torch.Tensor, config: dict, calibration: dict, path: str, autocast: bool,
|
|
56
|
+
runner: Any = None, torch_options: dict | None = None): # fmt: skip
|
|
57
|
+
self.backbone = backbone # the transformers model; None until torch is needed when a faster backend reads the prompts
|
|
58
|
+
self.tokenizer = tokenizer
|
|
59
|
+
self.readout = readout
|
|
60
|
+
self.config = config
|
|
61
|
+
self.calibration = calibration
|
|
62
|
+
self.path = path
|
|
63
|
+
self.autocast = autocast
|
|
64
|
+
self.runner = runner # the backend object (backends.py), or None: torch reads every prompt
|
|
65
|
+
self.torch_options = torch_options or {} # device and dtype for torch when it is loaded on first use
|
|
66
|
+
self.template = Template(tokenizer, bytes.fromhex(config["template"]["code_seed_hex"]).decode("utf-8"))
|
|
67
|
+
if self.template.first_id != config["readout"]["first_token_id"]:
|
|
68
|
+
raise ValueError("the tokenizer and the readout table disagree on the ids of the added tokens")
|
|
69
|
+
self.limits = config["limits"]
|
|
70
|
+
self._images = None
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def load(cls, path: str, device: str | None = None, dtype: str | torch.dtype | None = None, backend: str | None = None,
|
|
74
|
+
artifacts: str | None = None, options: dict | None = None) -> "EdgeModel": # fmt: skip
|
|
75
|
+
"""Load the model directory.
|
|
76
|
+
|
|
77
|
+
`backend`: "auto" (the default, or DECISIONNODE_EDGE_BACKEND), "torch", "vllm", "openvino", "mlx" or "llamacpp".
|
|
78
|
+
Auto takes the fastest that is installed and can run here (backends.ORDER). `artifacts`: the directory the build
|
|
79
|
+
tool wrote for openvino or llamacpp (or DECISIONNODE_EDGE_ARTIFACTS); for either asked for by name it defaults to
|
|
80
|
+
the model directory when that holds a build (the GGUF that ships with the model). `options`: the backend's settings (the
|
|
81
|
+
README; DECISIONNODE_EDGE_OPTIONS as JSON is read under them).
|
|
82
|
+
|
|
83
|
+
`device` defaults to CUDA when present, else the CPU, and `dtype` to bfloat16 on CUDA (the reference
|
|
84
|
+
precision) and float32 elsewhere; both are torch's, also when torch only reads the prompts a
|
|
85
|
+
faster backend cannot take."""
|
|
86
|
+
from transformers import AutoTokenizer
|
|
87
|
+
|
|
88
|
+
with open(os.path.join(path, CONFIG_FILE), encoding="utf-8") as f:
|
|
89
|
+
config = json.load(f)
|
|
90
|
+
with open(os.path.join(path, config["calibration"]), encoding="utf-8") as f:
|
|
91
|
+
calibration = json.load(f)
|
|
92
|
+
tokenizer = AutoTokenizer.from_pretrained(path)
|
|
93
|
+
table = load_file(os.path.join(path, config["readout"]["file"]))[config["readout"]["tensor"]]
|
|
94
|
+
artifacts = artifacts or os.environ.get(backends.ENV_ARTIFACTS) or None
|
|
95
|
+
if artifacts is None and (backend or os.environ.get(backends.ENV_BACKEND) or "").strip().lower() in ("openvino", "llamacpp") and backends.read_build(path):
|
|
96
|
+
artifacts = path # a build that ships in the model folder (the GGUF), for a backend asked for by name
|
|
97
|
+
options = {**backends.env_options(), **(options or {})}
|
|
98
|
+
name = backends.choose(backend, device, artifacts, options)
|
|
99
|
+
log.info("the %s backend reads the prompts", name)
|
|
100
|
+
if name == "torch":
|
|
101
|
+
backbone, autocast = load_backbone(path, device, dtype)
|
|
102
|
+
return cls(backbone, tokenizer, table.float().to(backbone.device), config, calibration, path, autocast)
|
|
103
|
+
runner = backends.load(name, path, config, artifacts, options, device)
|
|
104
|
+
# the readout and calibration stay here, in float32 on the CPU
|
|
105
|
+
return cls(None, tokenizer, table.float(), config, calibration, path, False, runner=runner, torch_options={"device": device, "dtype": dtype})
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def device(self) -> torch.device:
|
|
109
|
+
return self.readout.device
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def backend(self) -> str:
|
|
113
|
+
"""The backend that reads the prompts ("torch" also reads, on any backend, the pictures and the prompts longer than
|
|
114
|
+
the backend takes)."""
|
|
115
|
+
return self.runner.name if self.runner is not None else "torch"
|
|
116
|
+
|
|
117
|
+
# -- public -----------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def decide(self, body: dict) -> dict:
|
|
120
|
+
"""Answer a `/v1/decide` request body. Returns {"model", "answers", "usage"} as the hosted API does."""
|
|
121
|
+
return self._decide(requests.parse(body))
|
|
122
|
+
|
|
123
|
+
def _decide(self, parsed: requests.Request, exact_sum: bool = False) -> dict:
|
|
124
|
+
"""The answers of a parsed request; `exact_sum`: choice and score lists that sum to exactly 1.00 (/v1/systemone)."""
|
|
125
|
+
probs, input_tokens, placed = self._predict(parsed)
|
|
126
|
+
out = {}
|
|
127
|
+
for q in parsed.questions:
|
|
128
|
+
if q.place is not None:
|
|
129
|
+
out[q.id] = placed[q.id]
|
|
130
|
+
continue
|
|
131
|
+
p = probs[q.id]
|
|
132
|
+
if q.type == "choice":
|
|
133
|
+
out[q.id] = answers.choice_answer(q.keys, p, exact_sum)
|
|
134
|
+
elif q.type == "score":
|
|
135
|
+
out[q.id] = answers.score_answer(q.levels, p, exact_sum)
|
|
136
|
+
elif q.type == "truth":
|
|
137
|
+
out[q.id] = answers.truth_answer(p)
|
|
138
|
+
else:
|
|
139
|
+
out[q.id] = answers.number_answer(q.grid, p)
|
|
140
|
+
return {"model": self.config["name"], "answers": out, "usage": {"input_tokens": input_tokens, "output_tokens": 0}}
|
|
141
|
+
|
|
142
|
+
def predict(self, body: dict) -> dict[str, list[float]]:
|
|
143
|
+
"""The unrounded calibrated probabilities of every question: per option or level in request order, [yes, no]
|
|
144
|
+
for truth, per grid value for number. A point or box question is answered by `decide` only."""
|
|
145
|
+
parsed = requests.parse(body)
|
|
146
|
+
for q in parsed.questions:
|
|
147
|
+
if q.place is not None:
|
|
148
|
+
raise requests.RequestError(f"question {q.id!r}: a {q.type} question is answered by decide(), not predict()")
|
|
149
|
+
return self._predict(parsed)[0]
|
|
150
|
+
|
|
151
|
+
# -- internals --------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _predict(self, parsed: requests.Request) -> tuple[dict[str, list[float]], int, dict[str, dict]]:
|
|
154
|
+
"""(the probabilities of every question but the point and box ones, the input tokens, the point and box answers)."""
|
|
155
|
+
images = self._load_images(parsed.images) if parsed.images else None
|
|
156
|
+
image_ids = [image["id"] for image in parsed.images]
|
|
157
|
+
out: dict[str, list[float]] = {}
|
|
158
|
+
state_tokens, total = None, 0
|
|
159
|
+
|
|
160
|
+
def read(qid: str, row: dict) -> list[float]:
|
|
161
|
+
nonlocal state_tokens, total
|
|
162
|
+
encoded = self.template.encode(row, parsed.state, image_ids, images[2] if images else None)
|
|
163
|
+
if len(encoded.input_ids) > self.limits["max_input_tokens"]:
|
|
164
|
+
raise requests.RequestError(f"question {qid!r} needs {len(encoded.input_ids):,} tokens; the maximum context length is {self.limits['max_input_tokens']:,} tokens",
|
|
165
|
+
error_type="max_tokens_exceeded") # fmt: skip
|
|
166
|
+
probs = self._probabilities(encoded, row, images, bool(image_ids))
|
|
167
|
+
# the state and its pictures count once, every question's own tokens on top (as the hosted usage counts)
|
|
168
|
+
total += len(encoded.input_ids) - (encoded.state_tokens if state_tokens is not None else 0)
|
|
169
|
+
state_tokens = encoded.state_tokens
|
|
170
|
+
return probs
|
|
171
|
+
|
|
172
|
+
specs = []
|
|
173
|
+
for q in parsed.questions:
|
|
174
|
+
if q.place is not None:
|
|
175
|
+
specs.append(q.place)
|
|
176
|
+
continue
|
|
177
|
+
answered = [read(q.id, row) for row in q.rows]
|
|
178
|
+
if q.type == "number":
|
|
179
|
+
out[q.id] = [1.0] if not q.rows else requests.compose(q.grid, answered)
|
|
180
|
+
else:
|
|
181
|
+
out[q.id] = answered[0] if answered else [1.0]
|
|
182
|
+
placed: dict[str, dict] = {}
|
|
183
|
+
if specs: # every point and box question's readings, pass by pass, each pass given the readings before it
|
|
184
|
+
plan = places.Plan(specs)
|
|
185
|
+
while True:
|
|
186
|
+
rows = plan.rows()
|
|
187
|
+
if not rows:
|
|
188
|
+
break
|
|
189
|
+
plan.take(rows, [read(plan.chains[n].spec.qid, row) for n, row in rows])
|
|
190
|
+
placed = plan.answers(self._image_sizes(parsed.images))
|
|
191
|
+
return out, total, placed
|
|
192
|
+
|
|
193
|
+
def _probabilities(self, encoded: Any, row: dict, images: tuple | None, has_images: bool) -> list[float]:
|
|
194
|
+
"""One prompt's class probabilities: the readout's scores of its codes, calibrated (a coordinate row's softmax)."""
|
|
195
|
+
return answers.calibrated(self._logits(encoded, images), row["type"], self.calibration, has_images)
|
|
196
|
+
|
|
197
|
+
@torch.inference_mode()
|
|
198
|
+
def _logits(self, encoded: Any, images: tuple | None) -> list[float]:
|
|
199
|
+
if self.runner is not None and images is None and self.runner.accepts(len(encoded.input_ids)):
|
|
200
|
+
h = self.runner.hidden(encoded.input_ids).to(self.device, torch.float32)
|
|
201
|
+
else:
|
|
202
|
+
h = self._torch_hidden(encoded, images).to(self.device)
|
|
203
|
+
scores = h @ self.readout.t() # one score per added token, never the vocabulary
|
|
204
|
+
rows = torch.tensor(encoded.class_ids, device=self.device) - self.template.first_id
|
|
205
|
+
return scores[rows].tolist()
|
|
206
|
+
|
|
207
|
+
def _torch_hidden(self, encoded: Any, images: tuple | None) -> torch.Tensor:
|
|
208
|
+
"""The hidden state at the <|answer|> position (the last token) from the transformers backbone, float32."""
|
|
209
|
+
if self.backbone is None:
|
|
210
|
+
log.warning("loading torch for the prompts the %s backend does not take (pictures, longer inputs)", self.backend)
|
|
211
|
+
self.backbone, self.autocast = load_backbone(self.path, self.torch_options.get("device"), self.torch_options.get("dtype"))
|
|
212
|
+
device = self.backbone.device
|
|
213
|
+
input_ids = torch.tensor([encoded.input_ids], device=device)
|
|
214
|
+
extra = {}
|
|
215
|
+
if images is not None:
|
|
216
|
+
extra = {
|
|
217
|
+
"pixel_values": images[0].to(device),
|
|
218
|
+
"image_grid_thw": images[1].to(device),
|
|
219
|
+
"mm_token_type_ids": (input_ids == self.template.image_pad).long(),
|
|
220
|
+
}
|
|
221
|
+
with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=self.autocast):
|
|
222
|
+
hidden = self.backbone.model(input_ids=input_ids, attention_mask=torch.ones_like(input_ids), use_cache=False, **extra).last_hidden_state
|
|
223
|
+
return hidden[0, -1].float()
|
|
224
|
+
|
|
225
|
+
def _image_sizes(self, images: list[dict]) -> dict[str, tuple[int, int]]:
|
|
226
|
+
return image_sizes(images)
|
|
227
|
+
|
|
228
|
+
def _load_images(self, images: list[dict]) -> tuple[torch.Tensor, torch.Tensor, dict[str, int]]:
|
|
229
|
+
"""(pixel values, grid, placeholder tokens per image id) of a request's pictures: EXIF orientation applied,
|
|
230
|
+
RGB, the longest side at most max_image_side, then the backbone's own image processor."""
|
|
231
|
+
from PIL import Image, ImageOps
|
|
232
|
+
from transformers import AutoImageProcessor
|
|
233
|
+
|
|
234
|
+
if self._images is None:
|
|
235
|
+
self._images = AutoImageProcessor.from_pretrained(self.path)
|
|
236
|
+
max_side = int(self.limits["max_image_side"])
|
|
237
|
+
fitted = []
|
|
238
|
+
for image in images:
|
|
239
|
+
source = image["path"] if image.get("path") is not None else io.BytesIO(base64.b64decode(image["data"]))
|
|
240
|
+
try:
|
|
241
|
+
with Image.open(source) as im:
|
|
242
|
+
im = ImageOps.exif_transpose(im).convert("RGB")
|
|
243
|
+
if max(im.size) > max_side:
|
|
244
|
+
scale = max_side / max(im.size)
|
|
245
|
+
im = im.resize((max(1, round(im.width * scale)), max(1, round(im.height * scale))), Image.BICUBIC)
|
|
246
|
+
fitted.append(im.copy())
|
|
247
|
+
except (OSError, ValueError) as error:
|
|
248
|
+
raise requests.RequestError(f"Image {image['id']!r} could not be read: {error}") from None
|
|
249
|
+
out = self._images(images=fitted, return_tensors="pt")
|
|
250
|
+
merge = int(getattr(self._images, "merge_size", 2))
|
|
251
|
+
counts = (out["image_grid_thw"].prod(dim=-1) // merge**2).tolist()
|
|
252
|
+
return out["pixel_values"], out["image_grid_thw"], {image["id"]: int(n) for image, n in zip(images, counts)}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def image_sizes(images: list[dict]) -> dict[str, tuple[int, int]]:
|
|
256
|
+
"""Per picture id, its width and height as sent after its EXIF orientation (the sides swapped when the orientation
|
|
257
|
+
turns it a quarter, 5 to 8), read from the file's header: the size a point or box answer is given in."""
|
|
258
|
+
from PIL import Image
|
|
259
|
+
|
|
260
|
+
out = {}
|
|
261
|
+
for image in images:
|
|
262
|
+
source = image["path"] if image.get("path") is not None else io.BytesIO(base64.b64decode(image["data"]))
|
|
263
|
+
try:
|
|
264
|
+
with Image.open(source) as im:
|
|
265
|
+
width, height = im.size
|
|
266
|
+
try:
|
|
267
|
+
orientation = int(im.getexif().get(0x0112, 1))
|
|
268
|
+
except Exception:
|
|
269
|
+
orientation = 1
|
|
270
|
+
except (OSError, ValueError) as error:
|
|
271
|
+
raise requests.RequestError(f"Image {image['id']!r} could not be read: {error}") from None
|
|
272
|
+
out[image["id"]] = (height, width) if orientation in (5, 6, 7, 8) else (width, height)
|
|
273
|
+
return out
|