predictabl 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.
- predictabl/__init__.py +105 -0
- predictabl/__main__.py +52 -0
- predictabl/_http.py +129 -0
- predictabl/_progress.py +103 -0
- predictabl/_version.py +1 -0
- predictabl/assets/logo-128.png +0 -0
- predictabl/client.py +831 -0
- predictabl/errors.py +61 -0
- predictabl/mcp.py +262 -0
- predictabl-0.1.0.dist-info/METADATA +160 -0
- predictabl-0.1.0.dist-info/RECORD +14 -0
- predictabl-0.1.0.dist-info/WHEEL +4 -0
- predictabl-0.1.0.dist-info/entry_points.txt +2 -0
- predictabl-0.1.0.dist-info/licenses/LICENSE +21 -0
predictabl/client.py
ADDED
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
"""Client and the objects it returns: Data, Plan, Model, Predictions.
|
|
2
|
+
|
|
3
|
+
The server does the work (understanding the data, planning features, training,
|
|
4
|
+
scoring); this module turns its endpoints into a few calls with progress, and
|
|
5
|
+
waits on background jobs for you.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Iterable, Mapping
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import date, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from . import errors
|
|
20
|
+
from ._http import Transport, resolve_base_url
|
|
21
|
+
from ._progress import Callback, Progress
|
|
22
|
+
|
|
23
|
+
# Run states, in order, with the name people see.
|
|
24
|
+
_STEPS = {
|
|
25
|
+
"QUEUED": "Queued",
|
|
26
|
+
"PLAN": "Preparing",
|
|
27
|
+
"BUILD_DATASET": "Building data",
|
|
28
|
+
"VALIDATE_DATASET": "Checking data",
|
|
29
|
+
"TRAIN": "Training",
|
|
30
|
+
"EVALUATE": "Testing",
|
|
31
|
+
"SCORE": "Final model",
|
|
32
|
+
}
|
|
33
|
+
_TERMINAL = {"SUCCEEDED", "FAILED", "CANCELLED"}
|
|
34
|
+
_RECORDS_PER_CALL = 1000 # the server's cap for predict-records
|
|
35
|
+
_MAX_LIMIT = 2000 # the server's cap for `top`
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Client:
|
|
39
|
+
"""A connection to Predictabl.
|
|
40
|
+
|
|
41
|
+
`api_key` defaults to the PREDICTABL_API_KEY environment variable and
|
|
42
|
+
`base_url` to PREDICTABL_BASE_URL, else the hosted app. For a local
|
|
43
|
+
instance use `base_url="http://localhost:8000/api/v1"`.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
api_key: str | None = None,
|
|
49
|
+
base_url: str | None = None,
|
|
50
|
+
*,
|
|
51
|
+
timeout: float = 120.0,
|
|
52
|
+
poll_interval: float = 2.0,
|
|
53
|
+
client_tag: str | None = None,
|
|
54
|
+
_http=None,
|
|
55
|
+
):
|
|
56
|
+
self.api_key = api_key or os.environ.get("PREDICTABL_API_KEY")
|
|
57
|
+
self.base_url = resolve_base_url(base_url)
|
|
58
|
+
self.poll_interval = poll_interval
|
|
59
|
+
self._t = Transport(
|
|
60
|
+
self.api_key,
|
|
61
|
+
self.base_url,
|
|
62
|
+
timeout=timeout,
|
|
63
|
+
client_tag=client_tag,
|
|
64
|
+
http=_http,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def close(self) -> None:
|
|
68
|
+
"""Close the HTTP connection. `with pb.Client() as c:` does it for you."""
|
|
69
|
+
self._t.close()
|
|
70
|
+
|
|
71
|
+
def __enter__(self) -> Client:
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(self, *exc) -> None:
|
|
75
|
+
self.close()
|
|
76
|
+
|
|
77
|
+
def __repr__(self) -> str:
|
|
78
|
+
return f"<predictabl.Client {self.base_url}>"
|
|
79
|
+
|
|
80
|
+
# --------------------------------------------------------------- account
|
|
81
|
+
|
|
82
|
+
def account(self) -> dict:
|
|
83
|
+
"""Who you are, your plan, and builds used/left."""
|
|
84
|
+
return self._t.get("/account")
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------ data
|
|
87
|
+
|
|
88
|
+
def datasets(self) -> list[Data]:
|
|
89
|
+
"""Every dataset on this account: uploads and connected databases."""
|
|
90
|
+
return [Data._from(self, row) for row in self._t.get("/connections") or []]
|
|
91
|
+
|
|
92
|
+
def data(self, data_id: str) -> Data:
|
|
93
|
+
"""One dataset by id (from `datasets()` or the web app)."""
|
|
94
|
+
for row in self._t.get("/connections") or []:
|
|
95
|
+
if row["id"] == data_id:
|
|
96
|
+
return Data._from(self, row)
|
|
97
|
+
raise errors.NotFound(f"No dataset {data_id!r} on this account.")
|
|
98
|
+
|
|
99
|
+
def upload(
|
|
100
|
+
self,
|
|
101
|
+
source: str | Path | Iterable[str | Path] | Any,
|
|
102
|
+
name: str | None = None,
|
|
103
|
+
*,
|
|
104
|
+
wait: bool = True,
|
|
105
|
+
quiet: bool = False,
|
|
106
|
+
on_progress: Callback | None = None,
|
|
107
|
+
timeout: float | None = 1800,
|
|
108
|
+
) -> Data:
|
|
109
|
+
"""Upload CSV, Excel or Parquet files (or a pandas DataFrame) as a new
|
|
110
|
+
dataset. Several files become several tables Predictabl can join.
|
|
111
|
+
|
|
112
|
+
With `wait` (default) returns once Predictabl has read the tables."""
|
|
113
|
+
files, label = _files_of(source)
|
|
114
|
+
progress = Progress(quiet=quiet, on_update=on_progress)
|
|
115
|
+
size = sum(len(content) for _, content, _ in files)
|
|
116
|
+
progress.running("Uploading", f"{label} · {_human_bytes(size)}")
|
|
117
|
+
try:
|
|
118
|
+
row = self._t.post(
|
|
119
|
+
"/connections/from-csv",
|
|
120
|
+
data={"name": name or label},
|
|
121
|
+
files=[
|
|
122
|
+
("files", (fname, content, ctype)) for fname, content, ctype in files
|
|
123
|
+
],
|
|
124
|
+
)
|
|
125
|
+
except errors.PredictablError:
|
|
126
|
+
progress.failed("Uploading")
|
|
127
|
+
raise
|
|
128
|
+
progress.done("Uploading", f"{label} · {_human_bytes(size)}")
|
|
129
|
+
data = Data._from(self, row)
|
|
130
|
+
if wait:
|
|
131
|
+
data.wait_ready(progress=progress, timeout=timeout)
|
|
132
|
+
return data
|
|
133
|
+
|
|
134
|
+
def plan(self, question_id: str) -> Plan:
|
|
135
|
+
"""The latest plan of a question (opportunity) planned earlier, e.g.
|
|
136
|
+
in the web app, to retrain it from code without planning again."""
|
|
137
|
+
got = self._t.get(f"/opportunities/{question_id}/spec") or {}
|
|
138
|
+
spec = got.get("spec")
|
|
139
|
+
if not spec:
|
|
140
|
+
raise errors.NotFound(f"Question {question_id!r} has no plan yet.")
|
|
141
|
+
return Plan(
|
|
142
|
+
id=spec["id"],
|
|
143
|
+
version=int(got.get("version") or 1),
|
|
144
|
+
opportunity_id=question_id,
|
|
145
|
+
spec=spec,
|
|
146
|
+
_client=self,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------- models
|
|
150
|
+
|
|
151
|
+
def models(self, limit: int = 50) -> list[Model]:
|
|
152
|
+
"""Your models, newest first, trained or not (check `model.ready`)."""
|
|
153
|
+
rows = self._t.get("/runs", limit=limit) or []
|
|
154
|
+
return [Model._from(self, row) for row in rows[:limit]]
|
|
155
|
+
|
|
156
|
+
def model(self, model_id: str) -> Model:
|
|
157
|
+
"""One model by id (from `models()` or the web app address)."""
|
|
158
|
+
return Model._from(self, self._t.get(f"/runs/{model_id}"))
|
|
159
|
+
|
|
160
|
+
# --------------------------------------------------------------- helpers
|
|
161
|
+
|
|
162
|
+
def _sleep(self, progress: Progress | None) -> None:
|
|
163
|
+
end = time.monotonic() + self.poll_interval
|
|
164
|
+
while True:
|
|
165
|
+
left = end - time.monotonic()
|
|
166
|
+
if left <= 0:
|
|
167
|
+
return
|
|
168
|
+
if progress is not None:
|
|
169
|
+
progress.tick()
|
|
170
|
+
time.sleep(min(0.2, left))
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def web_url(self) -> str:
|
|
174
|
+
"""Where the web app for this instance lives (best effort)."""
|
|
175
|
+
base = self.base_url
|
|
176
|
+
for suffix in ("/pub/v1", "/api/v1"):
|
|
177
|
+
if base.endswith(suffix):
|
|
178
|
+
base = base[: -len(suffix)]
|
|
179
|
+
if base.startswith(("http://localhost:8000", "http://127.0.0.1:8000")):
|
|
180
|
+
return base.replace(":8000", ":3000")
|
|
181
|
+
return base
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ============================================================================
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class Data:
|
|
189
|
+
"""A dataset on Predictabl: uploaded files or a connected database."""
|
|
190
|
+
|
|
191
|
+
id: str
|
|
192
|
+
name: str
|
|
193
|
+
status: str
|
|
194
|
+
kind: str | None = None
|
|
195
|
+
table_count: int | None = None
|
|
196
|
+
error: str | None = None
|
|
197
|
+
_client: Client = field(repr=False, default=None) # type: ignore[assignment]
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def _from(cls, client: Client, row: Mapping) -> Data:
|
|
201
|
+
return cls(
|
|
202
|
+
id=row["id"],
|
|
203
|
+
name=row.get("name") or "",
|
|
204
|
+
status=row.get("status") or "",
|
|
205
|
+
kind=row.get("kind"),
|
|
206
|
+
table_count=row.get("table_count"),
|
|
207
|
+
error=row.get("error"),
|
|
208
|
+
_client=client,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def refresh(self) -> Data:
|
|
212
|
+
"""Reload status and table count from the server."""
|
|
213
|
+
fresh = self._client.data(self.id)
|
|
214
|
+
self.status, self.table_count, self.error = (
|
|
215
|
+
fresh.status,
|
|
216
|
+
fresh.table_count,
|
|
217
|
+
fresh.error,
|
|
218
|
+
)
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
def wait_ready(
|
|
222
|
+
self, *, progress: Progress | None = None, timeout: float | None = 1800
|
|
223
|
+
) -> Data:
|
|
224
|
+
"""Wait until Predictabl has read the tables (status "ready")."""
|
|
225
|
+
progress = progress or Progress(quiet=True)
|
|
226
|
+
started = time.monotonic()
|
|
227
|
+
progress.running("Reading data", "understanding the tables")
|
|
228
|
+
while True:
|
|
229
|
+
self.refresh()
|
|
230
|
+
if self.status == "ready":
|
|
231
|
+
n = self.table_count
|
|
232
|
+
progress.done(
|
|
233
|
+
"Reading data", f"{n} table{'s' if n != 1 else ''}" if n else ""
|
|
234
|
+
)
|
|
235
|
+
return self
|
|
236
|
+
if self.status in ("error", "failed"):
|
|
237
|
+
progress.failed("Reading data", self.error or "")
|
|
238
|
+
reason = self.error or "unknown error"
|
|
239
|
+
raise errors.BuildFailed(f"Could not read this dataset: {reason}")
|
|
240
|
+
_check_timeout(started, timeout, "reading the data")
|
|
241
|
+
self._client._sleep(progress)
|
|
242
|
+
|
|
243
|
+
def questions(self) -> list[dict]:
|
|
244
|
+
"""Questions (prediction opportunities) found or asked on this data."""
|
|
245
|
+
return self._client._t.get(f"/connections/{self.id}/opportunities") or []
|
|
246
|
+
|
|
247
|
+
def plan(
|
|
248
|
+
self,
|
|
249
|
+
question: str,
|
|
250
|
+
*,
|
|
251
|
+
quiet: bool = False,
|
|
252
|
+
on_progress: Callback | None = None,
|
|
253
|
+
timeout: float | None = 1800,
|
|
254
|
+
) -> Plan:
|
|
255
|
+
"""Turn a question into a reviewable plan: target, features and what
|
|
256
|
+
was excluded as leakage. Spends one build on Predictabl's AI key."""
|
|
257
|
+
progress = Progress(quiet=quiet, on_update=on_progress)
|
|
258
|
+
opp = self._ask(question, progress, timeout)
|
|
259
|
+
return Plan._generate(self._client, opp, progress, timeout)
|
|
260
|
+
|
|
261
|
+
def predict(
|
|
262
|
+
self,
|
|
263
|
+
question: str,
|
|
264
|
+
*,
|
|
265
|
+
preset: str = "full_quality",
|
|
266
|
+
engine: str = "auto",
|
|
267
|
+
quiet: bool = False,
|
|
268
|
+
on_progress: Callback | None = None,
|
|
269
|
+
timeout: float | None = None,
|
|
270
|
+
) -> Model:
|
|
271
|
+
"""The whole path: question -> plan -> trained, tested model."""
|
|
272
|
+
progress = Progress(quiet=quiet, on_update=on_progress)
|
|
273
|
+
opp = self._ask(question, progress, 1800)
|
|
274
|
+
plan = Plan._generate(self._client, opp, progress, 1800)
|
|
275
|
+
return plan._train(preset, engine, progress, timeout)
|
|
276
|
+
|
|
277
|
+
def delete(self) -> None:
|
|
278
|
+
"""Delete this dataset and its uploaded files. Models trained on it can
|
|
279
|
+
no longer read it, so delete only data you no longer score from."""
|
|
280
|
+
self._client._t.delete(f"/connections/{self.id}")
|
|
281
|
+
|
|
282
|
+
# ------------------------------------------------------------- internals
|
|
283
|
+
|
|
284
|
+
def _ask(self, question: str, progress: Progress, timeout: float | None) -> dict:
|
|
285
|
+
t = self._client._t
|
|
286
|
+
if self.status != "ready":
|
|
287
|
+
self.wait_ready(progress=progress)
|
|
288
|
+
progress.running("Understanding", question)
|
|
289
|
+
before = {o["id"] for o in self.questions()}
|
|
290
|
+
queued = (
|
|
291
|
+
t.post(
|
|
292
|
+
f"/connections/{self.id}/opportunities",
|
|
293
|
+
json={"mode": "single", "user_context": question},
|
|
294
|
+
)
|
|
295
|
+
or {}
|
|
296
|
+
)
|
|
297
|
+
job_id = queued.get("job_id")
|
|
298
|
+
started = time.monotonic()
|
|
299
|
+
while True:
|
|
300
|
+
job = t.get(f"/connections/{self.id}/opportunities/job") or {}
|
|
301
|
+
state = job.get("state")
|
|
302
|
+
# Only OUR job counts: an earlier one on this data may be "done".
|
|
303
|
+
mine = job_id is None or job.get("id") == job_id
|
|
304
|
+
if mine and state in ("done", "failed"):
|
|
305
|
+
break
|
|
306
|
+
_check_timeout(started, timeout, "understanding the question")
|
|
307
|
+
self._client._sleep(progress)
|
|
308
|
+
new = [o for o in self.questions() if o["id"] not in before]
|
|
309
|
+
if state == "failed" or not new:
|
|
310
|
+
msg = job.get("message") or "Predictabl could not frame this question."
|
|
311
|
+
progress.failed("Understanding", msg)
|
|
312
|
+
raise errors.BuildFailed(msg)
|
|
313
|
+
opp = max(new, key=lambda o: o.get("created_at") or "")
|
|
314
|
+
progress.done("Understanding", opp.get("title") or question)
|
|
315
|
+
return opp
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ============================================================================
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@dataclass
|
|
322
|
+
class Plan:
|
|
323
|
+
"""What Predictabl will train: the target, the features, and what it
|
|
324
|
+
excluded as leakage. Review it, refine it in plain words, then train."""
|
|
325
|
+
|
|
326
|
+
id: str
|
|
327
|
+
version: int
|
|
328
|
+
opportunity_id: str
|
|
329
|
+
spec: dict = field(repr=False)
|
|
330
|
+
_client: Client = field(repr=False, default=None) # type: ignore[assignment]
|
|
331
|
+
|
|
332
|
+
@property
|
|
333
|
+
def task(self) -> str | None:
|
|
334
|
+
return self.spec.get("task")
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def target(self) -> str:
|
|
338
|
+
t = self.spec.get("target") or {}
|
|
339
|
+
return t.get("definition") or t.get("name") or ""
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def features(self) -> list[str]:
|
|
343
|
+
return [
|
|
344
|
+
f["name"] for f in self.spec.get("features") or [] if f.get("enabled", True)
|
|
345
|
+
]
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def excluded(self) -> list[dict]:
|
|
349
|
+
"""Columns kept out of the model, with the reason (usually leakage)."""
|
|
350
|
+
return list(self.spec.get("leakage_exclusions") or [])
|
|
351
|
+
|
|
352
|
+
def summary(self) -> str:
|
|
353
|
+
"""The plan in a few readable lines; also what `print(plan)` shows."""
|
|
354
|
+
lines = [f"Predicting: {self.target}", f"Task: {self.task}"]
|
|
355
|
+
lines.append(f"Features ({len(self.features)}): " + ", ".join(self.features[:12]))
|
|
356
|
+
if len(self.features) > 12:
|
|
357
|
+
lines[-1] += f", … (+{len(self.features) - 12})"
|
|
358
|
+
for ex in self.excluded:
|
|
359
|
+
col = ex.get("column") or ex.get("name") or "?"
|
|
360
|
+
lines.append(f"Excluded {col}: {ex.get('reason') or ''}".rstrip(": "))
|
|
361
|
+
return "\n".join(lines)
|
|
362
|
+
|
|
363
|
+
def __str__(self) -> str:
|
|
364
|
+
return self.summary()
|
|
365
|
+
|
|
366
|
+
def refine(
|
|
367
|
+
self,
|
|
368
|
+
message: str,
|
|
369
|
+
*,
|
|
370
|
+
quiet: bool = False,
|
|
371
|
+
on_progress: Callback | None = None,
|
|
372
|
+
timeout: float | None = 1800,
|
|
373
|
+
) -> Plan:
|
|
374
|
+
"""Change the plan in plain words ("don't use the courier column").
|
|
375
|
+
Returns the new version; this object stays the version it was."""
|
|
376
|
+
t = self._client._t
|
|
377
|
+
progress = Progress(quiet=quiet, on_update=on_progress)
|
|
378
|
+
progress.running("Refining", message)
|
|
379
|
+
t.post(
|
|
380
|
+
f"/opportunities/{self.opportunity_id}/spec/refine", json={"message": message}
|
|
381
|
+
)
|
|
382
|
+
new = _wait_for_spec(
|
|
383
|
+
self._client, self.opportunity_id, progress, timeout, newer_than=self.version
|
|
384
|
+
)
|
|
385
|
+
progress.done("Refining", f"version {new.version}")
|
|
386
|
+
return new
|
|
387
|
+
|
|
388
|
+
def train(
|
|
389
|
+
self,
|
|
390
|
+
*,
|
|
391
|
+
preset: str = "full_quality",
|
|
392
|
+
engine: str = "auto",
|
|
393
|
+
wait: bool = True,
|
|
394
|
+
quiet: bool = False,
|
|
395
|
+
on_progress: Callback | None = None,
|
|
396
|
+
timeout: float | None = None,
|
|
397
|
+
) -> Model:
|
|
398
|
+
"""Train and test this plan. `preset="fast_preview"` is a ~15-second
|
|
399
|
+
sanity check on a sample; the default is the real model."""
|
|
400
|
+
progress = Progress(quiet=quiet, on_update=on_progress)
|
|
401
|
+
return self._train(preset, engine, progress, timeout, wait=wait)
|
|
402
|
+
|
|
403
|
+
# ------------------------------------------------------------- internals
|
|
404
|
+
|
|
405
|
+
@classmethod
|
|
406
|
+
def _generate(
|
|
407
|
+
cls, client: Client, opp: dict, progress: Progress, timeout: float | None
|
|
408
|
+
) -> Plan:
|
|
409
|
+
progress.running("Planning", "target, features, leakage checks")
|
|
410
|
+
client._t.post(f"/opportunities/{opp['id']}/spec", json={})
|
|
411
|
+
plan = _wait_for_spec(client, opp["id"], progress, timeout)
|
|
412
|
+
progress.done(
|
|
413
|
+
"Planning",
|
|
414
|
+
f"{len(plan.features)} features · {len(plan.excluded)} excluded",
|
|
415
|
+
)
|
|
416
|
+
return plan
|
|
417
|
+
|
|
418
|
+
def _train(
|
|
419
|
+
self,
|
|
420
|
+
preset: str,
|
|
421
|
+
engine: str,
|
|
422
|
+
progress: Progress,
|
|
423
|
+
timeout: float | None,
|
|
424
|
+
*,
|
|
425
|
+
wait: bool = True,
|
|
426
|
+
) -> Model:
|
|
427
|
+
row = self._client._t.post(
|
|
428
|
+
"/runs", json={"spec_id": self.id, "preset": preset, "engine": engine}
|
|
429
|
+
)
|
|
430
|
+
model = Model._from(self._client, row)
|
|
431
|
+
if wait:
|
|
432
|
+
model._wait(progress, timeout)
|
|
433
|
+
return model
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _wait_for_spec(
|
|
437
|
+
client: Client,
|
|
438
|
+
opportunity_id: str,
|
|
439
|
+
progress: Progress,
|
|
440
|
+
timeout: float | None,
|
|
441
|
+
*,
|
|
442
|
+
newer_than: int | None = None,
|
|
443
|
+
) -> Plan:
|
|
444
|
+
t = client._t
|
|
445
|
+
started = time.monotonic()
|
|
446
|
+
while True:
|
|
447
|
+
got = t.get(f"/opportunities/{opportunity_id}/spec") or {}
|
|
448
|
+
spec = got.get("spec")
|
|
449
|
+
if spec and (newer_than is None or int(got.get("version") or 0) > newer_than):
|
|
450
|
+
return Plan(
|
|
451
|
+
id=spec["id"],
|
|
452
|
+
version=int(got.get("version") or spec.get("version") or 1),
|
|
453
|
+
opportunity_id=opportunity_id,
|
|
454
|
+
spec=spec,
|
|
455
|
+
_client=client,
|
|
456
|
+
)
|
|
457
|
+
opp = t.get(f"/opportunities/{opportunity_id}") or {}
|
|
458
|
+
failure = opp.get("last_generation_error")
|
|
459
|
+
if failure:
|
|
460
|
+
msg = failure.get("message") if isinstance(failure, dict) else str(failure)
|
|
461
|
+
progress.failed(detail=msg or "")
|
|
462
|
+
raise errors.BuildFailed(msg or "Planning failed.")
|
|
463
|
+
_check_timeout(started, timeout, "planning")
|
|
464
|
+
client._sleep(progress)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# ============================================================================
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@dataclass
|
|
471
|
+
class Model:
|
|
472
|
+
"""A trained (or training) model. `predict()` scores with it."""
|
|
473
|
+
|
|
474
|
+
id: str
|
|
475
|
+
state: str
|
|
476
|
+
title: str | None = None
|
|
477
|
+
kind: str | None = None # forecast | event | flat
|
|
478
|
+
meaning: str | None = None
|
|
479
|
+
created_via: str | None = None
|
|
480
|
+
error_code: str | None = None
|
|
481
|
+
error_message: str | None = None
|
|
482
|
+
suggestions: list = field(default_factory=list)
|
|
483
|
+
_client: Client = field(repr=False, default=None) # type: ignore[assignment]
|
|
484
|
+
|
|
485
|
+
@classmethod
|
|
486
|
+
def _from(cls, client: Client, row: Mapping) -> Model:
|
|
487
|
+
return cls(
|
|
488
|
+
id=row["id"],
|
|
489
|
+
state=row.get("state") or "",
|
|
490
|
+
title=row.get("opportunity_title"),
|
|
491
|
+
kind=row.get("kind"),
|
|
492
|
+
meaning=row.get("meaning"),
|
|
493
|
+
created_via=row.get("created_via"),
|
|
494
|
+
error_code=row.get("error_code"),
|
|
495
|
+
error_message=row.get("error_message"),
|
|
496
|
+
suggestions=row.get("suggestions") or [],
|
|
497
|
+
_client=client,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
@property
|
|
501
|
+
def url(self) -> str:
|
|
502
|
+
"""This model in the web app."""
|
|
503
|
+
return f"{self._client.web_url}/runs/{self.id}"
|
|
504
|
+
|
|
505
|
+
@property
|
|
506
|
+
def ready(self) -> bool:
|
|
507
|
+
return self.state == "SUCCEEDED"
|
|
508
|
+
|
|
509
|
+
def refresh(self) -> Model:
|
|
510
|
+
"""Reload state, kind and errors from the server."""
|
|
511
|
+
fresh = self._client.model(self.id)
|
|
512
|
+
self.__dict__.update({k: v for k, v in fresh.__dict__.items() if k != "_client"})
|
|
513
|
+
return self
|
|
514
|
+
|
|
515
|
+
def wait(
|
|
516
|
+
self,
|
|
517
|
+
*,
|
|
518
|
+
quiet: bool = False,
|
|
519
|
+
on_progress: Callback | None = None,
|
|
520
|
+
timeout: float | None = None,
|
|
521
|
+
) -> Model:
|
|
522
|
+
"""Block until training ends. Raises BuildFailed if it failed."""
|
|
523
|
+
return self._wait(Progress(quiet=quiet, on_update=on_progress), timeout)
|
|
524
|
+
|
|
525
|
+
def events(self) -> list[dict]:
|
|
526
|
+
"""Everything the run reported, step by step."""
|
|
527
|
+
return self._client._t.get(f"/runs/{self.id}/events.json") or []
|
|
528
|
+
|
|
529
|
+
def cancel(self) -> None:
|
|
530
|
+
"""Stop training. The model ends CANCELLED."""
|
|
531
|
+
self._client._t.post(f"/runs/{self.id}/cancel")
|
|
532
|
+
|
|
533
|
+
def report(self) -> dict:
|
|
534
|
+
"""The tested result: metric, rows, top features, what it predicts."""
|
|
535
|
+
res = self._client._t.get(f"/runs/{self.id}/results") or {}
|
|
536
|
+
winner = res.get("winner")
|
|
537
|
+
scores = (res.get("engines") or {}).get(winner) or {}
|
|
538
|
+
metric = res.get("primary_metric") or _default_metric(res.get("task"))
|
|
539
|
+
imp = res.get("feature_importance") or []
|
|
540
|
+
if isinstance(imp, dict):
|
|
541
|
+
imp = imp.get(winner) or next(iter(imp.values()), [])
|
|
542
|
+
top = [
|
|
543
|
+
(f.get("feature") or f.get("name")) if isinstance(f, dict) else f
|
|
544
|
+
for f in (imp or [])[:10]
|
|
545
|
+
]
|
|
546
|
+
if self.kind is None:
|
|
547
|
+
self.refresh()
|
|
548
|
+
return {
|
|
549
|
+
"model": self.id,
|
|
550
|
+
"predicts": self.title,
|
|
551
|
+
"meaning": self.meaning,
|
|
552
|
+
"kind": self.kind,
|
|
553
|
+
"task": res.get("task"),
|
|
554
|
+
"engine": winner,
|
|
555
|
+
"metric": metric,
|
|
556
|
+
"score": scores.get(metric) if metric else None,
|
|
557
|
+
"score_std": (scores.get("metric_std") or {}).get(metric) if metric else None,
|
|
558
|
+
"rows_trained": res.get("n_train"),
|
|
559
|
+
"rows_tested": res.get("n_test"),
|
|
560
|
+
"top_features": [t for t in top if t],
|
|
561
|
+
"url": self.url,
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
def predict(
|
|
565
|
+
self,
|
|
566
|
+
rows: Any = None,
|
|
567
|
+
*,
|
|
568
|
+
ids: Iterable[str] | str | None = None,
|
|
569
|
+
since: str | date | datetime | None = None,
|
|
570
|
+
until: str | date | datetime | None = None,
|
|
571
|
+
since_days: int | None = None,
|
|
572
|
+
as_of: str | date | datetime | None = None,
|
|
573
|
+
limit: int | None = None,
|
|
574
|
+
filters: list[dict] | None = None,
|
|
575
|
+
include: list[str] | None = None,
|
|
576
|
+
calibrated: bool = False,
|
|
577
|
+
) -> Predictions:
|
|
578
|
+
"""Score with this model.
|
|
579
|
+
|
|
580
|
+
* `rows` (DataFrame, list of dicts or one dict): score these records.
|
|
581
|
+
Predictabl builds the features from them with the model's own SQL.
|
|
582
|
+
* otherwise Predictabl scores from the data it was trained on:
|
|
583
|
+
forecasts from the latest data (or `as_of`), event models for rows
|
|
584
|
+
dated today (or `since`/`until`/`since_days`), optionally only
|
|
585
|
+
`ids`, highest first, at most `limit` (server cap 2000).
|
|
586
|
+
"""
|
|
587
|
+
if rows is not None:
|
|
588
|
+
return self._predict_rows(rows, calibrated)
|
|
589
|
+
body: dict[str, Any] = {
|
|
590
|
+
"top": min(int(limit), _MAX_LIMIT) if limit else _MAX_LIMIT,
|
|
591
|
+
"calibrated": calibrated,
|
|
592
|
+
}
|
|
593
|
+
if since is not None:
|
|
594
|
+
body["since"] = _iso(since)
|
|
595
|
+
if until is not None:
|
|
596
|
+
body["until"] = _iso(until)
|
|
597
|
+
if since_days is not None:
|
|
598
|
+
body["since_days"] = int(since_days)
|
|
599
|
+
if as_of is not None:
|
|
600
|
+
body["cutoff"] = _iso(as_of)
|
|
601
|
+
if filters:
|
|
602
|
+
body["filters"] = filters
|
|
603
|
+
if include:
|
|
604
|
+
body["include"] = include
|
|
605
|
+
if ids is None:
|
|
606
|
+
return Predictions._from(
|
|
607
|
+
self._client._t.post(f"/runs/{self.id}/predict", json=body)
|
|
608
|
+
)
|
|
609
|
+
wanted = [ids] if isinstance(ids, str) else [str(i) for i in ids]
|
|
610
|
+
merged: Predictions | None = None
|
|
611
|
+
for one in wanted:
|
|
612
|
+
got = self._client._t.post(
|
|
613
|
+
f"/runs/{self.id}/predict", json={**body, "entity_contains": one}
|
|
614
|
+
)
|
|
615
|
+
part = Predictions._from(got)
|
|
616
|
+
# entity_contains is a substring match; keep exact ids only.
|
|
617
|
+
keep = [p for p in part if _is_entity(p.get("entity_id"), one)]
|
|
618
|
+
if merged is None:
|
|
619
|
+
merged = part
|
|
620
|
+
merged[:] = keep
|
|
621
|
+
else:
|
|
622
|
+
merged.extend(keep)
|
|
623
|
+
assert merged is not None
|
|
624
|
+
merged.count = len(merged)
|
|
625
|
+
return merged
|
|
626
|
+
|
|
627
|
+
# ------------------------------------------------------------- internals
|
|
628
|
+
|
|
629
|
+
def _predict_rows(self, rows: Any, calibrated: bool) -> Predictions:
|
|
630
|
+
records = _records_of(rows)
|
|
631
|
+
if not records:
|
|
632
|
+
raise errors.InvalidRequest("No rows to score.")
|
|
633
|
+
out: Predictions | None = None
|
|
634
|
+
for i in range(0, len(records), _RECORDS_PER_CALL):
|
|
635
|
+
got = self._client._t.post(
|
|
636
|
+
f"/runs/{self.id}/predict-records",
|
|
637
|
+
json={
|
|
638
|
+
"rows": records[i : i + _RECORDS_PER_CALL],
|
|
639
|
+
"calibrated": calibrated,
|
|
640
|
+
},
|
|
641
|
+
)
|
|
642
|
+
part = Predictions._from(got)
|
|
643
|
+
if out is None:
|
|
644
|
+
out = part
|
|
645
|
+
else:
|
|
646
|
+
out.extend(part)
|
|
647
|
+
assert out is not None
|
|
648
|
+
out.count = len(out)
|
|
649
|
+
return out
|
|
650
|
+
|
|
651
|
+
def _wait(self, progress: Progress, timeout: float | None) -> Model:
|
|
652
|
+
started = time.monotonic()
|
|
653
|
+
seen = 0
|
|
654
|
+
while True:
|
|
655
|
+
self.refresh()
|
|
656
|
+
if self.state in _STEPS:
|
|
657
|
+
detail = ""
|
|
658
|
+
events = self.events()
|
|
659
|
+
for ev in events[seen:]:
|
|
660
|
+
if ev.get("state") == self.state and ev.get("message"):
|
|
661
|
+
detail = _short(ev["message"])
|
|
662
|
+
seen = len(events)
|
|
663
|
+
progress.running(_STEPS[self.state], detail)
|
|
664
|
+
if self.state in _TERMINAL:
|
|
665
|
+
break
|
|
666
|
+
_check_timeout(started, timeout, "training")
|
|
667
|
+
self._client._sleep(progress)
|
|
668
|
+
if self.state == "SUCCEEDED":
|
|
669
|
+
rep = self.report()
|
|
670
|
+
score = rep.get("score")
|
|
671
|
+
detail = (
|
|
672
|
+
f"{rep['metric']} {score:.4f}" if isinstance(score, float) else "done"
|
|
673
|
+
)
|
|
674
|
+
progress.done("Final model", detail)
|
|
675
|
+
return self
|
|
676
|
+
progress.failed(detail=self.error_message or self.state.lower())
|
|
677
|
+
raise errors.BuildFailed(
|
|
678
|
+
self.error_message or f"Run {self.state.lower()}.",
|
|
679
|
+
code=self.error_code,
|
|
680
|
+
suggestions=self.suggestions,
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# ============================================================================
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
class Predictions(list):
|
|
688
|
+
"""A list of predictions (dicts with `entity_id`, `prediction`, ...),
|
|
689
|
+
plus what they mean: `meaning`, `kind`, `as_of`, and any `note` or
|
|
690
|
+
`warning` the server attached."""
|
|
691
|
+
|
|
692
|
+
meaning: str | None
|
|
693
|
+
kind: str | None
|
|
694
|
+
as_of: str | None
|
|
695
|
+
note: str | None
|
|
696
|
+
warning: str | None
|
|
697
|
+
where_used: str | None
|
|
698
|
+
count: int
|
|
699
|
+
raw: dict
|
|
700
|
+
|
|
701
|
+
@classmethod
|
|
702
|
+
def _from(cls, data: Mapping) -> Predictions:
|
|
703
|
+
p = cls(data.get("predictions") or [])
|
|
704
|
+
p.meaning = data.get("meaning")
|
|
705
|
+
p.kind = data.get("kind")
|
|
706
|
+
p.as_of = data.get("as_of")
|
|
707
|
+
p.note = data.get("note")
|
|
708
|
+
p.warning = data.get("warning")
|
|
709
|
+
p.where_used = data.get("where_used")
|
|
710
|
+
p.count = int(data.get("count") or len(p))
|
|
711
|
+
p.raw = {k: v for k, v in data.items() if k != "predictions"}
|
|
712
|
+
return p
|
|
713
|
+
|
|
714
|
+
def to_pandas(self):
|
|
715
|
+
"""The predictions as a DataFrame (needs pandas). Context columns
|
|
716
|
+
requested with `include` become their own columns."""
|
|
717
|
+
import pandas as pd
|
|
718
|
+
|
|
719
|
+
flat = []
|
|
720
|
+
for p in self:
|
|
721
|
+
row = {k: v for k, v in p.items() if k not in ("context", "probabilities")}
|
|
722
|
+
for k, v in (p.get("context") or {}).items():
|
|
723
|
+
row[k] = v
|
|
724
|
+
for k, v in (p.get("probabilities") or {}).items():
|
|
725
|
+
row[f"p_{k}"] = v
|
|
726
|
+
flat.append(row)
|
|
727
|
+
return pd.DataFrame(flat)
|
|
728
|
+
|
|
729
|
+
def __repr__(self) -> str:
|
|
730
|
+
head = f"<Predictions {len(self)}"
|
|
731
|
+
if self.count and self.count > len(self):
|
|
732
|
+
head += f" of {self.count}"
|
|
733
|
+
if self.meaning:
|
|
734
|
+
head += f" · {self.meaning}"
|
|
735
|
+
if self.as_of:
|
|
736
|
+
head += f" · as of {self.as_of}"
|
|
737
|
+
return head + ">"
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
# ============================================================================
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _files_of(source: Any) -> tuple[list[tuple[str, bytes, str]], str]:
|
|
744
|
+
"""(files, label) for an upload: paths, a list of paths, or a DataFrame."""
|
|
745
|
+
if hasattr(source, "to_csv") and hasattr(source, "columns"): # DataFrame
|
|
746
|
+
buf = io.StringIO()
|
|
747
|
+
source.to_csv(buf, index=False)
|
|
748
|
+
return [("data.csv", buf.getvalue().encode(), "text/csv")], "data.csv"
|
|
749
|
+
paths = [source] if isinstance(source, str | Path) else list(source)
|
|
750
|
+
files = []
|
|
751
|
+
for p in paths:
|
|
752
|
+
path = Path(p)
|
|
753
|
+
if not path.exists():
|
|
754
|
+
raise errors.InvalidRequest(f"No such file: {path}")
|
|
755
|
+
files.append((path.name, path.read_bytes(), _content_type(path)))
|
|
756
|
+
label = files[0][0] if len(files) == 1 else f"{len(files)} files"
|
|
757
|
+
return files, label
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _content_type(path: Path) -> str:
|
|
761
|
+
return {
|
|
762
|
+
".csv": "text/csv",
|
|
763
|
+
".parquet": "application/octet-stream",
|
|
764
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
765
|
+
".xls": "application/vnd.ms-excel",
|
|
766
|
+
}.get(path.suffix.lower(), "application/octet-stream")
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _records_of(rows: Any) -> list[dict]:
|
|
770
|
+
if isinstance(rows, Mapping):
|
|
771
|
+
return [_jsonable(dict(rows))]
|
|
772
|
+
if hasattr(rows, "to_dict") and hasattr(rows, "columns"): # DataFrame
|
|
773
|
+
import json
|
|
774
|
+
|
|
775
|
+
return json.loads(rows.to_json(orient="records", date_format="iso"))
|
|
776
|
+
return [_jsonable(dict(r)) for r in rows]
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _jsonable(row: dict) -> dict:
|
|
780
|
+
out = {}
|
|
781
|
+
for k, v in row.items():
|
|
782
|
+
if isinstance(v, datetime | date):
|
|
783
|
+
out[k] = v.isoformat()
|
|
784
|
+
elif hasattr(v, "item") and callable(v.item): # numpy scalar
|
|
785
|
+
out[k] = v.item()
|
|
786
|
+
else:
|
|
787
|
+
out[k] = v
|
|
788
|
+
return out
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _iso(v: str | date | datetime) -> str:
|
|
792
|
+
if isinstance(v, datetime):
|
|
793
|
+
return v.isoformat(sep=" ", timespec="seconds")
|
|
794
|
+
if isinstance(v, date):
|
|
795
|
+
return v.isoformat()
|
|
796
|
+
return str(v)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _is_entity(entity_id: str | None, wanted: str) -> bool:
|
|
800
|
+
if entity_id is None:
|
|
801
|
+
return False
|
|
802
|
+
return entity_id == wanted or entity_id.split("·")[0] == wanted
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _default_metric(task: str | None) -> str | None:
|
|
806
|
+
return {
|
|
807
|
+
"binary_classification": "auc",
|
|
808
|
+
"multiclass": "accuracy",
|
|
809
|
+
"regression": "rmse",
|
|
810
|
+
}.get(task or "")
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _short(message: str, width: int = 70) -> str:
|
|
814
|
+
message = " ".join(message.split())
|
|
815
|
+
return message if len(message) <= width else message[: width - 1] + "…"
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _human_bytes(n: int) -> str:
|
|
819
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
820
|
+
if n < 1024 or unit == "GB":
|
|
821
|
+
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
822
|
+
n /= 1024
|
|
823
|
+
return f"{n} B" # pragma: no cover
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _check_timeout(started: float, timeout: float | None, what: str) -> None:
|
|
827
|
+
if timeout is not None and time.monotonic() - started > timeout:
|
|
828
|
+
raise errors.PredictablError(
|
|
829
|
+
f"Timed out after {timeout:.0f}s while {what}. It may still be running "
|
|
830
|
+
"on the server; check the web app."
|
|
831
|
+
)
|