autoforge-engine 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.
@@ -0,0 +1,490 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import pandas as pd
11
+
12
+
13
+ class ExperimentTracker:
14
+ """
15
+ Local experiment tracking for ModelForge.
16
+
17
+ Experiments are stored as individual JSON files so that
18
+ users can inspect, copy, archive, or version them easily.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ directory: str | Path = ".modelforge/experiments",
24
+ experiment_directory: str | Path | None = None,
25
+ ):
26
+ self.directory = Path(
27
+ experiment_directory
28
+ if experiment_directory is not None
29
+ else directory
30
+ )
31
+ self.directory.mkdir(
32
+ parents=True,
33
+ exist_ok=True,
34
+ )
35
+
36
+ def create_experiment_id(self) -> str:
37
+ """
38
+ Generate a unique experiment identifier.
39
+ """
40
+
41
+ timestamp = datetime.now(
42
+ timezone.utc
43
+ ).strftime("%Y%m%d%H%M%S")
44
+
45
+ short_uuid = uuid.uuid4().hex[:8]
46
+
47
+ return f"exp_{timestamp}_{short_uuid}"
48
+
49
+ def record(
50
+ self,
51
+ result: dict[str, Any],
52
+ configuration: dict[str, Any] | None = None,
53
+ experiment_id: str | None = None,
54
+ ) -> str:
55
+ """
56
+ Save an AutoML result as an experiment.
57
+
58
+ Returns the experiment ID.
59
+
60
+ The reproducibility snapshot is copied exactly from
61
+ the AutoML result before serialization.
62
+ """
63
+
64
+ if not isinstance(result, dict):
65
+ raise TypeError(
66
+ "result must be a dictionary."
67
+ )
68
+
69
+ if configuration is not None:
70
+ if not isinstance(configuration, dict):
71
+ raise TypeError(
72
+ "configuration must be a dictionary."
73
+ )
74
+
75
+ experiment_id = (
76
+ experiment_id
77
+ or self.create_experiment_id()
78
+ )
79
+
80
+ reproducibility = copy.deepcopy(
81
+ result.get("reproducibility")
82
+ )
83
+
84
+ run_summary = result.get(
85
+ "run_summary"
86
+ )
87
+
88
+ if not isinstance(run_summary, dict):
89
+ run_summary = {}
90
+
91
+ run_id = result.get(
92
+ "run_id"
93
+ ) or run_summary.get(
94
+ "run_id"
95
+ )
96
+
97
+ status = result.get(
98
+ "status"
99
+ ) or run_summary.get(
100
+ "status"
101
+ )
102
+
103
+ experiment = {
104
+ "experiment_id": experiment_id,
105
+ "timestamp": datetime.now(
106
+ timezone.utc
107
+ ).isoformat(),
108
+ "target": self._safe_value(
109
+ result.get("target")
110
+ ),
111
+ "profile": self._safe_value(
112
+ result.get("profile")
113
+ ),
114
+ "column_intelligence": self._safe_value(
115
+ result.get("column_intelligence")
116
+ ),
117
+ "audit": self._safe_value(
118
+ result.get("audit")
119
+ ),
120
+ "models_evaluated": self._safe_value(
121
+ result.get("models_evaluated")
122
+ ),
123
+ "screening_results": self._dataframe_to_records(
124
+ result.get("screening_results")
125
+ ),
126
+ "cv_results": self._dataframe_to_records(
127
+ result.get("cv_results")
128
+ ),
129
+ "initial_ranking": self._dataframe_to_records(
130
+ result.get("initial_ranking")
131
+ ),
132
+ "optimization_enabled": self._safe_value(
133
+ result.get("optimization_enabled")
134
+ ),
135
+ "optimization_results": self._safe_value(
136
+ result.get("optimization_results")
137
+ ),
138
+ "ranking": self._dataframe_to_records(
139
+ result.get("ranking")
140
+ ),
141
+ "best_model": self._safe_value(
142
+ result.get("best_model")
143
+ ),
144
+ "feature_selection": self._safe_value(
145
+ result.get("feature_selection")
146
+ ),
147
+ "configuration": self._safe_value(
148
+ configuration
149
+ ),
150
+ "reproducibility": self._safe_value(
151
+ reproducibility
152
+ ),
153
+ "run_id": self._safe_value(
154
+ run_id
155
+ ),
156
+ "run_summary": self._safe_value(
157
+ run_summary
158
+ ),
159
+ "status": self._safe_value(
160
+ status
161
+ ),
162
+ "error": self._safe_value(
163
+ result.get("error")
164
+ ),
165
+ }
166
+
167
+ path = self._experiment_path(
168
+ experiment_id
169
+ )
170
+
171
+ self._write_json(
172
+ path,
173
+ experiment,
174
+ )
175
+
176
+ return experiment_id
177
+
178
+ def get(
179
+ self,
180
+ experiment_id: str,
181
+ ) -> dict[str, Any]:
182
+ """
183
+ Load one experiment by ID.
184
+ """
185
+
186
+ self._validate_experiment_id(
187
+ experiment_id
188
+ )
189
+
190
+ path = self._experiment_path(
191
+ experiment_id
192
+ )
193
+
194
+ if not path.exists():
195
+ raise FileNotFoundError(
196
+ f"Experiment '{experiment_id}' "
197
+ "does not exist."
198
+ )
199
+
200
+ with path.open(
201
+ "r",
202
+ encoding="utf-8",
203
+ ) as file:
204
+ return json.load(file)
205
+
206
+ def list_experiments(
207
+ self,
208
+ ) -> list[dict[str, Any]]:
209
+ """
210
+ Return a compact list of tracked experiments.
211
+
212
+ Experiments are returned newest first.
213
+ """
214
+
215
+ experiments = []
216
+
217
+ for path in self.directory.glob(
218
+ "exp_*.json"
219
+ ):
220
+ try:
221
+ with path.open(
222
+ "r",
223
+ encoding="utf-8",
224
+ ) as file:
225
+ experiment = json.load(file)
226
+
227
+ reproducibility = experiment.get(
228
+ "reproducibility"
229
+ )
230
+
231
+ experiments.append(
232
+ {
233
+ "experiment_id": experiment.get(
234
+ "experiment_id"
235
+ ),
236
+ "timestamp": experiment.get(
237
+ "timestamp"
238
+ ),
239
+ "target": experiment.get(
240
+ "target"
241
+ ),
242
+ "task_type": self._extract_task_type(
243
+ experiment
244
+ ),
245
+ "best_model": experiment.get(
246
+ "best_model"
247
+ ),
248
+ "models_evaluated": experiment.get(
249
+ "models_evaluated"
250
+ ),
251
+ "optimization_enabled": experiment.get(
252
+ "optimization_enabled"
253
+ ),
254
+ "run_id": experiment.get(
255
+ "run_id"
256
+ ),
257
+ "status": experiment.get(
258
+ "status"
259
+ ),
260
+ "reproducibility": reproducibility,
261
+ "reproducibility_available": (
262
+ reproducibility is not None
263
+ ),
264
+ }
265
+ )
266
+
267
+ except (
268
+ OSError,
269
+ json.JSONDecodeError,
270
+ ):
271
+ continue
272
+
273
+ experiments.sort(
274
+ key=lambda item: item.get(
275
+ "timestamp",
276
+ "",
277
+ ),
278
+ reverse=True,
279
+ )
280
+
281
+ return experiments
282
+
283
+ def delete(
284
+ self,
285
+ experiment_id: str,
286
+ ) -> None:
287
+ """
288
+ Delete an experiment by ID.
289
+ """
290
+
291
+ self._validate_experiment_id(
292
+ experiment_id
293
+ )
294
+
295
+ path = self._experiment_path(
296
+ experiment_id
297
+ )
298
+
299
+ if not path.exists():
300
+ raise FileNotFoundError(
301
+ f"Experiment '{experiment_id}' "
302
+ "does not exist."
303
+ )
304
+
305
+ path.unlink()
306
+
307
+ def clear(self) -> int:
308
+ """
309
+ Delete all tracked experiments.
310
+
311
+ Returns the number of deleted experiments.
312
+ """
313
+
314
+ deleted = 0
315
+
316
+ for path in self.directory.glob(
317
+ "exp_*.json"
318
+ ):
319
+ try:
320
+ path.unlink()
321
+ deleted += 1
322
+ except OSError:
323
+ continue
324
+
325
+ return deleted
326
+
327
+ def count(self) -> int:
328
+ """
329
+ Return the number of tracked experiments.
330
+ """
331
+
332
+ return len(
333
+ list(
334
+ self.directory.glob(
335
+ "exp_*.json"
336
+ )
337
+ )
338
+ )
339
+
340
+ def _experiment_path(
341
+ self,
342
+ experiment_id: str,
343
+ ) -> Path:
344
+ return (
345
+ self.directory
346
+ / f"{experiment_id}.json"
347
+ )
348
+
349
+ @staticmethod
350
+ def _validate_experiment_id(
351
+ experiment_id: str,
352
+ ) -> None:
353
+ if not isinstance(
354
+ experiment_id,
355
+ str,
356
+ ):
357
+ raise TypeError(
358
+ "experiment_id must be a string."
359
+ )
360
+
361
+ if not experiment_id:
362
+ raise ValueError(
363
+ "experiment_id cannot be empty."
364
+ )
365
+
366
+ if Path(experiment_id).name != experiment_id:
367
+ raise ValueError(
368
+ "Invalid experiment_id."
369
+ )
370
+
371
+ if not experiment_id.startswith(
372
+ "exp_"
373
+ ):
374
+ raise ValueError(
375
+ "Invalid experiment_id."
376
+ )
377
+
378
+ @staticmethod
379
+ def _write_json(
380
+ path: Path,
381
+ data: dict[str, Any],
382
+ ) -> None:
383
+ with path.open(
384
+ "w",
385
+ encoding="utf-8",
386
+ ) as file:
387
+ json.dump(
388
+ data,
389
+ file,
390
+ indent=2,
391
+ ensure_ascii=False,
392
+ )
393
+
394
+ @staticmethod
395
+ def _dataframe_to_records(
396
+ value: Any,
397
+ ) -> Any:
398
+ if isinstance(
399
+ value,
400
+ pd.DataFrame,
401
+ ):
402
+ return value.to_dict(
403
+ orient="records"
404
+ )
405
+
406
+ return ExperimentTracker._safe_value(
407
+ value
408
+ )
409
+
410
+ @staticmethod
411
+ def _extract_task_type(
412
+ experiment: dict[str, Any],
413
+ ) -> str | None:
414
+ target = experiment.get(
415
+ "target"
416
+ )
417
+
418
+ if isinstance(
419
+ target,
420
+ dict,
421
+ ):
422
+ return target.get(
423
+ "task_type"
424
+ )
425
+
426
+ return None
427
+
428
+ @staticmethod
429
+ def _safe_value(
430
+ value: Any,
431
+ ) -> Any:
432
+ if value is None:
433
+ return None
434
+
435
+ if isinstance(
436
+ value,
437
+ (str, int, float, bool),
438
+ ):
439
+ return value
440
+
441
+ if isinstance(
442
+ value,
443
+ Path,
444
+ ):
445
+ return str(value)
446
+
447
+ if isinstance(
448
+ value,
449
+ pd.DataFrame,
450
+ ):
451
+ return value.to_dict(
452
+ orient="records"
453
+ )
454
+
455
+ if isinstance(
456
+ value,
457
+ pd.Series,
458
+ ):
459
+ return value.to_list()
460
+
461
+ if isinstance(
462
+ value,
463
+ dict,
464
+ ):
465
+ return {
466
+ str(key): ExperimentTracker._safe_value(
467
+ item
468
+ )
469
+ for key, item in value.items()
470
+ }
471
+
472
+ if isinstance(
473
+ value,
474
+ (list, tuple, set),
475
+ ):
476
+ return [
477
+ ExperimentTracker._safe_value(
478
+ item
479
+ )
480
+ for item in value
481
+ ]
482
+
483
+ try:
484
+ json.dumps(value)
485
+ return value
486
+ except (
487
+ TypeError,
488
+ ValueError,
489
+ ):
490
+ return str(value)