SKSurrogate 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.
- SKSurrogate/DataProcess.py +409 -0
- SKSurrogate/NpyProximation.py +457 -0
- SKSurrogate/__init__.py +110 -0
- SKSurrogate/aml.py +1156 -0
- SKSurrogate/ci.py +93 -0
- SKSurrogate/deployment.py +31 -0
- SKSurrogate/eoa.py +381 -0
- SKSurrogate/execution.py +416 -0
- SKSurrogate/inference.py +224 -0
- SKSurrogate/mltrace.py +2138 -0
- SKSurrogate/modelbundle.py +600 -0
- SKSurrogate/monitoring.py +360 -0
- SKSurrogate/retraining.py +49 -0
- SKSurrogate/sensapprx.py +250 -0
- SKSurrogate/structsearch.py +1411 -0
- SKSurrogate/synthdat.py +503 -0
- sksurrogate-0.1.0.dist-info/METADATA +139 -0
- sksurrogate-0.1.0.dist-info/RECORD +21 -0
- sksurrogate-0.1.0.dist-info/WHEEL +5 -0
- sksurrogate-0.1.0.dist-info/entry_points.txt +2 -0
- sksurrogate-0.1.0.dist-info/top_level.txt +1 -0
SKSurrogate/execution.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Execution backends for distributed and resumable search workloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pickle
|
|
9
|
+
import threading
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import cloudpickle
|
|
17
|
+
except ImportError: # pragma: no cover - fallback for minimal environments
|
|
18
|
+
cloudpickle = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExecutionBackend:
|
|
22
|
+
"""Base interface for execution backends used by long-running searches."""
|
|
23
|
+
|
|
24
|
+
def submit_trial(self, params: Any, fn, worker_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None):
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
|
|
27
|
+
def get_trial(self, trial_id: str):
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
|
|
30
|
+
def mark_running(self, trial_id: str, worker_id: Optional[str] = None):
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
def mark_completed(self, trial_id: str, result: Any = None, worker_id: Optional[str] = None):
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
def mark_failed(self, trial_id: str, error: Optional[str] = None, traceback: Optional[str] = None, worker_id: Optional[str] = None):
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
def execute_pending(self, worker_id: Optional[str] = None, limit: Optional[int] = None):
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
def claim_trial(self, trial_id: str, worker_id: Optional[str] = None):
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
def claim_next_trial(self, worker_id: Optional[str] = None):
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
def list_pending(self, *, task_name: Optional[str] = None):
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
|
|
51
|
+
def list_results(self, *, status: Optional[str] = "completed", task_name: Optional[str] = None):
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def resume_incomplete_trials(self):
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LocalProcessExecutionBackend(ExecutionBackend):
|
|
59
|
+
"""Simple filesystem-backed backend for local workers and resumable jobs.
|
|
60
|
+
|
|
61
|
+
Trial state is persisted as JSON so a worker process can resume incomplete
|
|
62
|
+
work after a restart without losing the original parameter set or callable.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, root, task_name="default"):
|
|
66
|
+
self.root = Path(root)
|
|
67
|
+
self.task_name = task_name
|
|
68
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
self.state_path = self.root / f"{task_name}.execution_state.json"
|
|
70
|
+
self._lock = threading.RLock()
|
|
71
|
+
self._state = self._load_state()
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _now_iso():
|
|
75
|
+
return datetime.now(timezone.utc).isoformat()
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _encode_callable(fn):
|
|
79
|
+
if cloudpickle is not None:
|
|
80
|
+
payload = cloudpickle.dumps(fn)
|
|
81
|
+
else:
|
|
82
|
+
payload = pickle.dumps(fn)
|
|
83
|
+
return base64.b64encode(payload).decode("latin1")
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _decode_callable(blob):
|
|
87
|
+
payload = base64.b64decode(blob.encode("latin1"))
|
|
88
|
+
if cloudpickle is not None:
|
|
89
|
+
return cloudpickle.loads(payload)
|
|
90
|
+
return pickle.loads(payload)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _json_default(value):
|
|
94
|
+
if hasattr(value, "tolist"):
|
|
95
|
+
try:
|
|
96
|
+
return value.tolist()
|
|
97
|
+
except TypeError:
|
|
98
|
+
pass
|
|
99
|
+
if hasattr(value, "__dict__") and not isinstance(value, (str, int, float, bool, type(None), dict, list, tuple)):
|
|
100
|
+
return str(value)
|
|
101
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
102
|
+
|
|
103
|
+
def _load_state(self):
|
|
104
|
+
if not self.state_path.exists():
|
|
105
|
+
return {}
|
|
106
|
+
with self.state_path.open("r", encoding="utf-8") as stream:
|
|
107
|
+
data = json.load(stream)
|
|
108
|
+
return dict(data or {})
|
|
109
|
+
|
|
110
|
+
def _save_state(self):
|
|
111
|
+
tmp_path = self.state_path.with_suffix(".tmp")
|
|
112
|
+
with tmp_path.open("w", encoding="utf-8") as stream:
|
|
113
|
+
json.dump(self._state, stream, indent=2, sort_keys=True, default=self._json_default)
|
|
114
|
+
stream.flush()
|
|
115
|
+
os.fsync(stream.fileno())
|
|
116
|
+
os.replace(tmp_path, self.state_path)
|
|
117
|
+
|
|
118
|
+
def _restore_record(self, record):
|
|
119
|
+
if not isinstance(record, dict):
|
|
120
|
+
return record
|
|
121
|
+
restored = dict(record)
|
|
122
|
+
if "_callable_blob" in restored:
|
|
123
|
+
restored["fn"] = self._decode_callable(restored["_callable_blob"])
|
|
124
|
+
restored.pop("_callable_blob", None)
|
|
125
|
+
return restored
|
|
126
|
+
|
|
127
|
+
def _persist_record(self, trial_id, record):
|
|
128
|
+
self._state[trial_id] = {
|
|
129
|
+
**record,
|
|
130
|
+
"trial_id": trial_id,
|
|
131
|
+
"task_name": self.task_name,
|
|
132
|
+
"updated_at": self._now_iso(),
|
|
133
|
+
}
|
|
134
|
+
if "fn" in self._state[trial_id] and callable(self._state[trial_id]["fn"]):
|
|
135
|
+
self._state[trial_id]["_callable_blob"] = self._encode_callable(self._state[trial_id]["fn"])
|
|
136
|
+
self._state[trial_id].pop("fn", None)
|
|
137
|
+
self._save_state()
|
|
138
|
+
|
|
139
|
+
def submit_trial(self, params: Any, fn, worker_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None):
|
|
140
|
+
if not callable(fn):
|
|
141
|
+
raise TypeError("fn must be callable")
|
|
142
|
+
trial_id = uuid.uuid4().hex
|
|
143
|
+
record = {
|
|
144
|
+
"trial_id": trial_id,
|
|
145
|
+
"task_name": self.task_name,
|
|
146
|
+
"status": "queued",
|
|
147
|
+
"params": params,
|
|
148
|
+
"fn": fn,
|
|
149
|
+
"worker_id": worker_id,
|
|
150
|
+
"metadata": {} if metadata is None else dict(metadata),
|
|
151
|
+
"created_at": self._now_iso(),
|
|
152
|
+
"updated_at": self._now_iso(),
|
|
153
|
+
}
|
|
154
|
+
with self._lock:
|
|
155
|
+
self._persist_record(trial_id, record)
|
|
156
|
+
return trial_id
|
|
157
|
+
|
|
158
|
+
def get_trial(self, trial_id: str):
|
|
159
|
+
with self._lock:
|
|
160
|
+
record = self._state.get(trial_id)
|
|
161
|
+
if record is None:
|
|
162
|
+
raise KeyError(f"Unknown trial: {trial_id}")
|
|
163
|
+
restored = self._restore_record(record)
|
|
164
|
+
restored.pop("_callable_blob", None)
|
|
165
|
+
return restored
|
|
166
|
+
|
|
167
|
+
def mark_running(self, trial_id: str, worker_id: Optional[str] = None):
|
|
168
|
+
with self._lock:
|
|
169
|
+
record = self._state.get(trial_id)
|
|
170
|
+
if record is None:
|
|
171
|
+
raise KeyError(f"Unknown trial: {trial_id}")
|
|
172
|
+
record["status"] = "running"
|
|
173
|
+
record["worker_id"] = worker_id or record.get("worker_id")
|
|
174
|
+
record["updated_at"] = self._now_iso()
|
|
175
|
+
self._save_state()
|
|
176
|
+
return self._restore_record(record)
|
|
177
|
+
|
|
178
|
+
def mark_completed(self, trial_id: str, result: Any = None, worker_id: Optional[str] = None):
|
|
179
|
+
with self._lock:
|
|
180
|
+
record = self._state.get(trial_id)
|
|
181
|
+
if record is None:
|
|
182
|
+
raise KeyError(f"Unknown trial: {trial_id}")
|
|
183
|
+
record["status"] = "completed"
|
|
184
|
+
record["result"] = result
|
|
185
|
+
record["worker_id"] = worker_id or record.get("worker_id")
|
|
186
|
+
record["updated_at"] = self._now_iso()
|
|
187
|
+
self._save_state()
|
|
188
|
+
return self._restore_record(record)
|
|
189
|
+
|
|
190
|
+
def mark_failed(self, trial_id: str, error: Optional[str] = None, traceback: Optional[str] = None, worker_id: Optional[str] = None):
|
|
191
|
+
with self._lock:
|
|
192
|
+
record = self._state.get(trial_id)
|
|
193
|
+
if record is None:
|
|
194
|
+
raise KeyError(f"Unknown trial: {trial_id}")
|
|
195
|
+
record["status"] = "failed"
|
|
196
|
+
record["error"] = error
|
|
197
|
+
record["traceback"] = traceback
|
|
198
|
+
record["worker_id"] = worker_id or record.get("worker_id")
|
|
199
|
+
record["updated_at"] = self._now_iso()
|
|
200
|
+
self._save_state()
|
|
201
|
+
return self._restore_record(record)
|
|
202
|
+
|
|
203
|
+
def execute_pending(self, worker_id: Optional[str] = None, limit: Optional[int] = None):
|
|
204
|
+
"""Execute queued trials in a recoverable, persisted loop.
|
|
205
|
+
|
|
206
|
+
Each pending item is marked as running before execution, and the result is
|
|
207
|
+
persisted as completed or failed. The method returns a list of record
|
|
208
|
+
snapshots describing what was executed.
|
|
209
|
+
"""
|
|
210
|
+
with self._lock:
|
|
211
|
+
pending = [
|
|
212
|
+
(trial_id, record)
|
|
213
|
+
for trial_id, record in self._state.items()
|
|
214
|
+
if record.get("status") in {"queued", "running"}
|
|
215
|
+
]
|
|
216
|
+
if limit is not None:
|
|
217
|
+
pending = pending[:limit]
|
|
218
|
+
|
|
219
|
+
executed = []
|
|
220
|
+
for trial_id, record in pending:
|
|
221
|
+
fn = self._restore_record(record).get("fn")
|
|
222
|
+
if fn is None:
|
|
223
|
+
self.mark_failed(trial_id, error="Missing callable for pending trial", worker_id=worker_id)
|
|
224
|
+
executed.append(self.get_trial(trial_id))
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
record["status"] = "running"
|
|
228
|
+
record["worker_id"] = worker_id or record.get("worker_id")
|
|
229
|
+
record["updated_at"] = self._now_iso()
|
|
230
|
+
self._save_state()
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
result = fn(record.get("params"))
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
import traceback
|
|
236
|
+
|
|
237
|
+
message = f"{exc.__class__.__name__}: {exc}"
|
|
238
|
+
record["status"] = "failed"
|
|
239
|
+
record["error"] = message
|
|
240
|
+
record["traceback"] = traceback.format_exc()
|
|
241
|
+
record["worker_id"] = worker_id or record.get("worker_id")
|
|
242
|
+
record["updated_at"] = self._now_iso()
|
|
243
|
+
self._save_state()
|
|
244
|
+
executed.append(self._restore_record(record))
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
record["status"] = "completed"
|
|
248
|
+
record["result"] = result
|
|
249
|
+
record["worker_id"] = worker_id or record.get("worker_id")
|
|
250
|
+
record["updated_at"] = self._now_iso()
|
|
251
|
+
self._save_state()
|
|
252
|
+
executed.append(self._restore_record(record))
|
|
253
|
+
|
|
254
|
+
return executed
|
|
255
|
+
|
|
256
|
+
def claim_trial(self, trial_id: str, worker_id: Optional[str] = None):
|
|
257
|
+
with self._lock:
|
|
258
|
+
record = self._state.get(trial_id)
|
|
259
|
+
if record is None:
|
|
260
|
+
raise KeyError(f"Unknown trial: {trial_id}")
|
|
261
|
+
if record.get("status") not in {"queued", "running"}:
|
|
262
|
+
return None
|
|
263
|
+
if record.get("status") == "running" and record.get("worker_id") not in {None, worker_id}:
|
|
264
|
+
return None
|
|
265
|
+
record["status"] = "running"
|
|
266
|
+
record["worker_id"] = worker_id or record.get("worker_id")
|
|
267
|
+
record["updated_at"] = self._now_iso()
|
|
268
|
+
self._save_state()
|
|
269
|
+
return self._restore_record(record)
|
|
270
|
+
|
|
271
|
+
def claim_next_trial(self, worker_id: Optional[str] = None):
|
|
272
|
+
with self._lock:
|
|
273
|
+
pending = [
|
|
274
|
+
(trial_id, record)
|
|
275
|
+
for trial_id, record in self._state.items()
|
|
276
|
+
if record.get("status") == "queued"
|
|
277
|
+
]
|
|
278
|
+
if not pending:
|
|
279
|
+
return None
|
|
280
|
+
ordered = sorted(pending, key=lambda item: item[1].get("created_at", ""))
|
|
281
|
+
trial_id, record = ordered[0]
|
|
282
|
+
record["status"] = "running"
|
|
283
|
+
record["worker_id"] = worker_id
|
|
284
|
+
record["updated_at"] = self._now_iso()
|
|
285
|
+
self._save_state()
|
|
286
|
+
return self._restore_record(record)
|
|
287
|
+
|
|
288
|
+
def list_pending(self, *, task_name: Optional[str] = None):
|
|
289
|
+
with self._lock:
|
|
290
|
+
pending = []
|
|
291
|
+
for trial_id, record in sorted(self._state.items(), key=lambda item: item[1].get("created_at", "")):
|
|
292
|
+
if record.get("status") != "queued":
|
|
293
|
+
continue
|
|
294
|
+
if task_name is not None and record.get("task_name") != task_name:
|
|
295
|
+
continue
|
|
296
|
+
restored = self._restore_record(record)
|
|
297
|
+
restored.pop("_callable_blob", None)
|
|
298
|
+
pending.append(restored)
|
|
299
|
+
return pending
|
|
300
|
+
|
|
301
|
+
def list_results(self, *, status: Optional[str] = "completed", task_name: Optional[str] = None):
|
|
302
|
+
with self._lock:
|
|
303
|
+
results = []
|
|
304
|
+
for trial_id, record in self._state.items():
|
|
305
|
+
if record.get("status") != status:
|
|
306
|
+
continue
|
|
307
|
+
if task_name is not None and record.get("task_name") != task_name:
|
|
308
|
+
continue
|
|
309
|
+
restored = self._restore_record(record)
|
|
310
|
+
restored.pop("_callable_blob", None)
|
|
311
|
+
if "result" in restored:
|
|
312
|
+
results.append(restored)
|
|
313
|
+
return results
|
|
314
|
+
|
|
315
|
+
def resume_incomplete_trials(self):
|
|
316
|
+
with self._lock:
|
|
317
|
+
incomplete = []
|
|
318
|
+
for trial_id, record in self._state.items():
|
|
319
|
+
status = record.get("status")
|
|
320
|
+
if status in {"queued", "running", "failed", "timed_out"}:
|
|
321
|
+
recovered = self._restore_record(record)
|
|
322
|
+
recovered.pop("_callable_blob", None)
|
|
323
|
+
incomplete.append(recovered)
|
|
324
|
+
return incomplete
|
|
325
|
+
|
|
326
|
+
def list_trials(self, status: Optional[str] = None):
|
|
327
|
+
with self._lock:
|
|
328
|
+
trials = [self._restore_record(record) for record in self._state.values()]
|
|
329
|
+
if status is not None:
|
|
330
|
+
trials = [trial for trial in trials if trial.get("status") == status]
|
|
331
|
+
return trials
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class DaskExecutionBackend(ExecutionBackend):
|
|
335
|
+
"""Optional Dask adapter backed by the durable local trial store."""
|
|
336
|
+
|
|
337
|
+
def __init__(self, root, task_name="default", client=None, address=None):
|
|
338
|
+
if client is None:
|
|
339
|
+
try:
|
|
340
|
+
from dask.distributed import Client
|
|
341
|
+
except ImportError as exc: # pragma: no cover - depends on optional package
|
|
342
|
+
raise ImportError(
|
|
343
|
+
"DaskExecutionBackend requires dask[distributed] or an injected client"
|
|
344
|
+
) from exc
|
|
345
|
+
client = Client(address) if address is not None else Client()
|
|
346
|
+
self.client = client
|
|
347
|
+
self.local = LocalProcessExecutionBackend(root, task_name=task_name)
|
|
348
|
+
self._futures = {}
|
|
349
|
+
|
|
350
|
+
def submit_trial(self, params: Any, fn, worker_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None):
|
|
351
|
+
return self.local.submit_trial(params, fn, worker_id=worker_id, metadata=metadata)
|
|
352
|
+
|
|
353
|
+
def get_trial(self, trial_id: str):
|
|
354
|
+
return self.local.get_trial(trial_id)
|
|
355
|
+
|
|
356
|
+
def mark_running(self, trial_id: str, worker_id: Optional[str] = None):
|
|
357
|
+
return self.local.mark_running(trial_id, worker_id=worker_id)
|
|
358
|
+
|
|
359
|
+
def mark_completed(self, trial_id: str, result: Any = None, worker_id: Optional[str] = None):
|
|
360
|
+
return self.local.mark_completed(trial_id, result=result, worker_id=worker_id)
|
|
361
|
+
|
|
362
|
+
def mark_failed(self, trial_id: str, error: Optional[str] = None, traceback: Optional[str] = None, worker_id: Optional[str] = None):
|
|
363
|
+
return self.local.mark_failed(trial_id, error=error, traceback=traceback, worker_id=worker_id)
|
|
364
|
+
|
|
365
|
+
def _record_future_result(self, trial_id, future):
|
|
366
|
+
worker_id = self.get_trial(trial_id).get("worker_id")
|
|
367
|
+
try:
|
|
368
|
+
self.mark_completed(trial_id, result=future.result(), worker_id=worker_id)
|
|
369
|
+
except Exception as exc:
|
|
370
|
+
import traceback
|
|
371
|
+
|
|
372
|
+
self.mark_failed(
|
|
373
|
+
trial_id,
|
|
374
|
+
error=f"{exc.__class__.__name__}: {exc}",
|
|
375
|
+
traceback=traceback.format_exc(),
|
|
376
|
+
worker_id=worker_id,
|
|
377
|
+
)
|
|
378
|
+
finally:
|
|
379
|
+
self._futures.pop(trial_id, None)
|
|
380
|
+
|
|
381
|
+
def execute_pending(self, worker_id: Optional[str] = None, limit: Optional[int] = None):
|
|
382
|
+
scheduled = []
|
|
383
|
+
while limit is None or len(scheduled) < limit:
|
|
384
|
+
trial = self.local.claim_next_trial(worker_id=worker_id)
|
|
385
|
+
if trial is None:
|
|
386
|
+
break
|
|
387
|
+
future = self.client.submit(trial["fn"], trial.get("params"))
|
|
388
|
+
self._futures[trial["trial_id"]] = future
|
|
389
|
+
future.add_done_callback(
|
|
390
|
+
lambda completed, trial_id=trial["trial_id"]: self._record_future_result(
|
|
391
|
+
trial_id, completed
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
scheduled.append(self.get_trial(trial["trial_id"]))
|
|
395
|
+
return scheduled
|
|
396
|
+
|
|
397
|
+
def claim_trial(self, trial_id: str, worker_id: Optional[str] = None):
|
|
398
|
+
return self.local.claim_trial(trial_id, worker_id=worker_id)
|
|
399
|
+
|
|
400
|
+
def claim_next_trial(self, worker_id: Optional[str] = None):
|
|
401
|
+
return self.local.claim_next_trial(worker_id=worker_id)
|
|
402
|
+
|
|
403
|
+
def list_pending(self, *, task_name: Optional[str] = None):
|
|
404
|
+
return self.local.list_pending(task_name=task_name)
|
|
405
|
+
|
|
406
|
+
def list_results(self, *, status: Optional[str] = "completed", task_name: Optional[str] = None):
|
|
407
|
+
return self.local.list_results(status=status, task_name=task_name)
|
|
408
|
+
|
|
409
|
+
def resume_incomplete_trials(self):
|
|
410
|
+
return self.local.resume_incomplete_trials()
|
|
411
|
+
|
|
412
|
+
def list_trials(self, status: Optional[str] = None):
|
|
413
|
+
return self.local.list_trials(status=status)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
__all__ = ["ExecutionBackend", "LocalProcessExecutionBackend", "DaskExecutionBackend"]
|
SKSurrogate/inference.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Batch inference helpers for portable model bundles."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from .modelbundle import ModelBundle, load_bundle
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SchemaValidationError(ValueError):
|
|
17
|
+
"""Structured input or output schema validation failure."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, errors):
|
|
20
|
+
self.errors = list(errors)
|
|
21
|
+
super().__init__("Input schema validation failed: " + "; ".join(self.errors))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_input(bundle, frame):
|
|
25
|
+
schema = bundle.schema
|
|
26
|
+
if not schema:
|
|
27
|
+
return frame
|
|
28
|
+
expected = list(schema)
|
|
29
|
+
actual = list(frame.columns)
|
|
30
|
+
errors = []
|
|
31
|
+
missing = [column for column in expected if column not in actual]
|
|
32
|
+
extra = [column for column in actual if column not in expected]
|
|
33
|
+
if missing:
|
|
34
|
+
errors.append("missing columns: %s" % ", ".join(missing))
|
|
35
|
+
if extra:
|
|
36
|
+
errors.append("extra columns: %s" % ", ".join(extra))
|
|
37
|
+
if not missing and not extra and actual != expected:
|
|
38
|
+
errors.append("columns are reordered; expected: %s" % ", ".join(expected))
|
|
39
|
+
for column in expected:
|
|
40
|
+
if column not in frame:
|
|
41
|
+
continue
|
|
42
|
+
expected_dtype = schema[column].get("dtype") if isinstance(schema[column], dict) else None
|
|
43
|
+
if expected_dtype is not None and str(frame[column].dtype) != expected_dtype:
|
|
44
|
+
errors.append(
|
|
45
|
+
"column %r has dtype %r; expected %r"
|
|
46
|
+
% (column, str(frame[column].dtype), expected_dtype)
|
|
47
|
+
)
|
|
48
|
+
expected_categories = (
|
|
49
|
+
schema[column].get("categorical_values", [])
|
|
50
|
+
if isinstance(schema[column], dict)
|
|
51
|
+
else []
|
|
52
|
+
)
|
|
53
|
+
if expected_categories:
|
|
54
|
+
unknown = sorted(
|
|
55
|
+
{str(value) for value in frame[column].dropna().unique()}
|
|
56
|
+
- set(expected_categories)
|
|
57
|
+
)
|
|
58
|
+
if unknown:
|
|
59
|
+
errors.append("column %r has unknown categories: %s" % (column, ", ".join(unknown)))
|
|
60
|
+
if errors:
|
|
61
|
+
raise SchemaValidationError(errors)
|
|
62
|
+
return frame[expected]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_input(input_data):
|
|
66
|
+
if isinstance(input_data, pd.DataFrame):
|
|
67
|
+
return input_data.copy()
|
|
68
|
+
return pd.read_csv(input_data)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def predict_batch(
|
|
72
|
+
bundle,
|
|
73
|
+
input_data,
|
|
74
|
+
output_path=None,
|
|
75
|
+
*,
|
|
76
|
+
request_id=None,
|
|
77
|
+
strict_dependencies=True,
|
|
78
|
+
):
|
|
79
|
+
"""Run stable batch predictions and optionally write a CSV atomically.
|
|
80
|
+
|
|
81
|
+
``bundle`` may be a :class:`ModelBundle` or a bundle path. ``input_data`` may
|
|
82
|
+
be a pandas DataFrame or a CSV path. The returned DataFrame always contains
|
|
83
|
+
``prediction``, ``model_version``, and ``request_id`` columns.
|
|
84
|
+
"""
|
|
85
|
+
if isinstance(bundle, (str, Path)):
|
|
86
|
+
bundle = load_bundle(bundle, strict_dependencies=strict_dependencies)
|
|
87
|
+
if not isinstance(bundle, ModelBundle):
|
|
88
|
+
raise TypeError("bundle must be a ModelBundle or bundle path")
|
|
89
|
+
started = time.perf_counter()
|
|
90
|
+
frame = _validate_input(bundle, _read_input(input_data))
|
|
91
|
+
predictions = bundle.predict(frame)
|
|
92
|
+
elapsed = max(time.perf_counter() - started, 0.0)
|
|
93
|
+
result = pd.DataFrame(
|
|
94
|
+
{
|
|
95
|
+
"prediction": predictions,
|
|
96
|
+
"model_version": bundle.model_version,
|
|
97
|
+
"request_id": request_id or uuid.uuid4().hex,
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
result.attrs["inference_metrics"] = {
|
|
101
|
+
"latency_ms": elapsed * 1000.0,
|
|
102
|
+
"rows": int(len(frame)),
|
|
103
|
+
"throughput_rows_per_second": len(frame) / elapsed if elapsed else 0.0,
|
|
104
|
+
}
|
|
105
|
+
if output_path is not None:
|
|
106
|
+
destination = Path(output_path)
|
|
107
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
with tempfile.NamedTemporaryFile(
|
|
109
|
+
mode="w", suffix=".csv", dir=str(destination.parent), delete=False, encoding="utf-8"
|
|
110
|
+
) as temporary:
|
|
111
|
+
temporary_path = Path(temporary.name)
|
|
112
|
+
result.to_csv(temporary, index=False)
|
|
113
|
+
temporary.flush()
|
|
114
|
+
os.fsync(temporary.fileno())
|
|
115
|
+
os.replace(str(temporary_path), str(destination))
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main(argv=None):
|
|
120
|
+
"""Run batch prediction from the command line."""
|
|
121
|
+
parser = argparse.ArgumentParser(description="Run predictions from a SKSurrogate model bundle.")
|
|
122
|
+
parser.add_argument("--bundle", required=True, help="Path to a model bundle")
|
|
123
|
+
parser.add_argument("--input", required=True, help="Input CSV path")
|
|
124
|
+
parser.add_argument("--output", required=True, help="Output prediction CSV path")
|
|
125
|
+
parser.add_argument("--request-id", help="Request ID included in every output row")
|
|
126
|
+
parser.add_argument(
|
|
127
|
+
"--no-strict-dependencies",
|
|
128
|
+
action="store_true",
|
|
129
|
+
help="Skip runtime dependency compatibility checks",
|
|
130
|
+
)
|
|
131
|
+
arguments = parser.parse_args(argv)
|
|
132
|
+
try:
|
|
133
|
+
predict_batch(
|
|
134
|
+
arguments.bundle,
|
|
135
|
+
arguments.input,
|
|
136
|
+
arguments.output,
|
|
137
|
+
request_id=arguments.request_id,
|
|
138
|
+
strict_dependencies=not arguments.no_strict_dependencies,
|
|
139
|
+
)
|
|
140
|
+
except SchemaValidationError as exc:
|
|
141
|
+
parser.error(json.dumps({"errors": exc.errors}))
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def create_app(bundle, *, strict_dependencies=True):
|
|
146
|
+
"""Create a dependency-free WSGI application for online predictions."""
|
|
147
|
+
if isinstance(bundle, (str, Path)):
|
|
148
|
+
bundle = load_bundle(bundle, strict_dependencies=strict_dependencies)
|
|
149
|
+
if not isinstance(bundle, ModelBundle):
|
|
150
|
+
raise TypeError("bundle must be a ModelBundle or bundle path")
|
|
151
|
+
|
|
152
|
+
def response(start_response, status, payload):
|
|
153
|
+
body = json.dumps(payload, default=str).encode("utf-8")
|
|
154
|
+
start_response(
|
|
155
|
+
status,
|
|
156
|
+
[
|
|
157
|
+
("Content-Type", "application/json"),
|
|
158
|
+
("Content-Length", str(len(body))),
|
|
159
|
+
],
|
|
160
|
+
)
|
|
161
|
+
return [body]
|
|
162
|
+
|
|
163
|
+
def application(environ, start_response):
|
|
164
|
+
method = environ.get("REQUEST_METHOD", "GET")
|
|
165
|
+
path = environ.get("PATH_INFO", "/")
|
|
166
|
+
if method == "GET" and path == "/health":
|
|
167
|
+
return response(start_response, "200 OK", {"status": "ok"})
|
|
168
|
+
if method == "GET" and path in {"/ready", "/model"}:
|
|
169
|
+
return response(
|
|
170
|
+
start_response,
|
|
171
|
+
"200 OK",
|
|
172
|
+
{"status": "ready", "model_version": bundle.model_version},
|
|
173
|
+
)
|
|
174
|
+
if method != "POST" or path != "/predict":
|
|
175
|
+
return response(start_response, "404 Not Found", {"error": "not found"})
|
|
176
|
+
try:
|
|
177
|
+
length = int(environ.get("CONTENT_LENGTH") or 0)
|
|
178
|
+
payload = json.loads(environ["wsgi.input"].read(length) or b"{}")
|
|
179
|
+
rows = payload.get("data")
|
|
180
|
+
if not isinstance(rows, list):
|
|
181
|
+
raise ValueError("request field 'data' must be a list of row objects")
|
|
182
|
+
result = predict_batch(
|
|
183
|
+
bundle,
|
|
184
|
+
pd.DataFrame(rows),
|
|
185
|
+
request_id=payload.get("request_id"),
|
|
186
|
+
strict_dependencies=strict_dependencies,
|
|
187
|
+
)
|
|
188
|
+
return response(
|
|
189
|
+
start_response,
|
|
190
|
+
"200 OK",
|
|
191
|
+
{
|
|
192
|
+
"model_version": bundle.model_version,
|
|
193
|
+
"request_id": result["request_id"].iloc[0],
|
|
194
|
+
"predictions": json.loads(result["prediction"].to_json(orient="records")),
|
|
195
|
+
"metrics": result.attrs["inference_metrics"],
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
except SchemaValidationError as exc:
|
|
199
|
+
return response(
|
|
200
|
+
start_response,
|
|
201
|
+
"400 Bad Request",
|
|
202
|
+
{"error": {"type": "SchemaValidationError", "messages": exc.errors}},
|
|
203
|
+
)
|
|
204
|
+
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
205
|
+
return response(
|
|
206
|
+
start_response,
|
|
207
|
+
"400 Bad Request",
|
|
208
|
+
{"error": {"type": exc.__class__.__name__, "message": str(exc)}},
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return application
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def serve(bundle, host="127.0.0.1", port=8000, *, strict_dependencies=True):
|
|
215
|
+
"""Serve a bundle with the standard-library WSGI server."""
|
|
216
|
+
from wsgiref.simple_server import make_server
|
|
217
|
+
|
|
218
|
+
application = create_app(bundle, strict_dependencies=strict_dependencies)
|
|
219
|
+
with make_server(host, port, application) as server:
|
|
220
|
+
server.serve_forever()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
main()
|