treeton 0.0.1__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.
treeton/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Tree-based learning with reproducible data preparation."""
2
+ from importlib.metadata import version
3
+
4
+ app_name = "treeton"
5
+ __author__ = "Eachan Johnson"
6
+ __version__ = version(app_name)
7
+
8
+ from .box import Box
9
+
10
+ __all__ = ["Box"]
@@ -0,0 +1 @@
1
+ from .base import Box
treeton/box/base.py ADDED
@@ -0,0 +1,619 @@
1
+ """Checkpointable, reproducible composition of data processing and tree estimator."""
2
+
3
+ from typing import TYPE_CHECKING, Any, TypeAlias
4
+ from collections.abc import Callable, Mapping, Iterable
5
+ from copy import deepcopy
6
+ from pathlib import Path
7
+ import json
8
+ import os
9
+
10
+ from carabiner import print_err
11
+ from providence.io import save_json, load_json
12
+
13
+ if TYPE_CHECKING:
14
+ from aspect import DataPipeline
15
+ from datasets import Dataset, IterableDataset
16
+ else:
17
+ DataPipeline = Any
18
+ Dataset, IterableDataset = Any, Any
19
+
20
+ from ..config import instantiate_object
21
+ from ..metrics import DEFAULT_METRICS, METRICS_REGISTRY
22
+ from ..typing import Estimator, DataLike, PipelineLike
23
+
24
+ CONFIG_FILENAME = "config.json"
25
+ DEFAULT_BATCH_SIZE: int = 1024
26
+ DEFAULT_PREDICTION_COLUMN: str = "prediction"
27
+
28
+
29
+ def _json_copy(
30
+ value: Mapping[Any, ...],
31
+ ) -> dict[str, ...]:
32
+ """Return a JSON-native copy, validating serializability."""
33
+ return json.loads(json.dumps(value))
34
+
35
+
36
+ def _resolve_pipeline(
37
+ pipeline: PipelineLike | None = None,
38
+ *,
39
+ cache_dir: str | None = None,
40
+ ) -> DataPipeline:
41
+ """Resolve an Aspect pipeline or its declarative configuration."""
42
+ from aspect import DataPipeline
43
+
44
+ if isinstance(pipeline, DataPipeline):
45
+ return pipeline
46
+
47
+ if pipeline is None:
48
+ pipeline = {}
49
+
50
+ if isinstance(pipeline, os.PathLike):
51
+ pipeline = os.fspath(pipeline)
52
+
53
+ if isinstance(pipeline, (str, Mapping)):
54
+ return DataPipeline.from_config(
55
+ pipeline,
56
+ cache_dir=cache_dir,
57
+ )
58
+
59
+ raise TypeError(
60
+ "pipeline must be an Aspect DataPipeline, "
61
+ "a configuration mapping, or a JSON filename; "
62
+ f"received {type(pipeline).__name__}"
63
+ )
64
+
65
+
66
+ def _resolve_metrics(
67
+ metrics: Iterable[str | Callable] | Mapping[str, str | Callable] | None = None
68
+ ):
69
+ if metrics is None:
70
+ metrics = DEFAULT_METRICS
71
+ if isinstance(metrics, (tuple, list)):
72
+ new_metrics = {}
73
+ for m in metrics:
74
+ if callable(m):
75
+ _m = str(m.__class__.__name__)
76
+ elif isinstance(m, str):
77
+ _m = m
78
+ else:
79
+ raise TypeError(f"Metrics must be callable or str, but was {type(m)}: {m}")
80
+ new_metrics[_m] = m
81
+ elif isinstance(metrics, Mapping):
82
+ new_metrics = dict(metrics)
83
+ else:
84
+ raise TypeError(f"Metrics must be mapping or list, but was {type(m)}: {m}")
85
+ _metrics = {}
86
+ for k, v in new_metrics.items():
87
+ if isinstance(v, str):
88
+ _v = METRICS_REGISTRY.get(v)
89
+ elif callable(v):
90
+ _v = v
91
+ else:
92
+ raise TypeError(
93
+ f"Metrics must be callable or supported str, but was {type(v)}: {v}\n"
94
+ f"Supported: {METRICS_REGISTRY}"
95
+ )
96
+ _metrics[k] = _v
97
+ return _metrics
98
+
99
+
100
+ def _col_names(dataset: Dataset | IterableDataset | Mapping) -> list[str]:
101
+ from datasets import Dataset, IterableDataset
102
+ if isinstance(dataset, (Dataset, IterableDataset)):
103
+ return dataset.column_names
104
+ elif isinstance(dataset, Mapping):
105
+ return list(dataset)
106
+ else:
107
+ raise ValueError(f"Can't get column names for {type(dataset)}: {dataset}")
108
+
109
+
110
+ def _data_len(dataset: Dataset | IterableDataset | Mapping) -> int:
111
+ from datasets import Dataset, IterableDataset
112
+ if isinstance(dataset, Dataset):
113
+ return len(dataset)
114
+ elif isinstance(dataset, IterableDataset):
115
+ for i, _ in enumerate(dataset):
116
+ pass
117
+ return i + 1
118
+ elif isinstance(dataset, Mapping):
119
+ lens = {k: len(v) for k, v in dataset.items()}
120
+ if len(set(lens.values())) == 1:
121
+ return next(iter(lens.values()))
122
+ else:
123
+ raise AttributeError(
124
+ f"Column lengths of Mapping are inconsistent: {lens}"
125
+ )
126
+ else:
127
+ raise ValueError(f"Can't get number of rows for {type(dataset)}: {dataset}")
128
+
129
+
130
+ def get_transformed_column(
131
+ column: str,
132
+ dataset: Dataset | IterableDataset | Mapping,
133
+ pipeline: DataPipeline
134
+ ) -> str:
135
+ transforms = pipeline.column_transforms.get(column, ())
136
+ column_names = _col_names(dataset)
137
+ if len(transforms) > 0:
138
+ generated_column = transforms[-1].output_column
139
+ if generated_column in column_names:
140
+ return generated_column
141
+ if column in column_names:
142
+ return column
143
+ raise KeyError(
144
+ f"Column or Aspect transform {column} unavailable. "
145
+ f"Available columns: {column_names}"
146
+ )
147
+
148
+
149
+ def _matrix(
150
+ dataset: Dataset | Mapping,
151
+ pipeline: DataPipeline,
152
+ column_groups: Iterable[Iterable[str]]
153
+ ):
154
+ import numpy as np
155
+ if len(dataset) == 0:
156
+ raise ValueError(f"No rows in dataset: {dataset}")
157
+ blocks = ()
158
+ for col_group in column_groups:
159
+ group_blocks = []
160
+ for feature in col_group:
161
+ column_name = get_transformed_column(
162
+ feature,
163
+ dataset,
164
+ pipeline,
165
+ )
166
+ values = dataset[column_name]
167
+ try:
168
+ values = np.asarray(values)
169
+ if values.dtype == object:
170
+ values = np.stack(list(values))
171
+ values = values.reshape(_data_len(dataset), -1)
172
+ except (TypeError, ValueError) as error:
173
+ raise ValueError(f"Feature {feature!r} is not a regular array: {dataset[column_name]}") from error
174
+ if not np.issubdtype(values.dtype, np.number):
175
+ raise TypeError(f"Feature {feature!r} must be numeric, got {values.dtype}")
176
+ group_blocks.append(values)
177
+ group_blocks = np.concatenate(group_blocks, axis=1)
178
+ blocks += (group_blocks,)
179
+ return blocks
180
+
181
+
182
+ def _predict_map_batch(
183
+ batch: Mapping[str, ...],
184
+ *,
185
+ model,
186
+ column_groups: Iterable[Iterable[str]],
187
+ pipeline: DataPipeline,
188
+ prediction_column: str = DEFAULT_PREDICTION_COLUMN
189
+ ) -> dict[str, ...]:
190
+ import numpy as np
191
+ feature_cols, target_cols = column_groups
192
+ X, *_ = _matrix(batch, pipeline, column_groups=(feature_cols,))
193
+ y_pred = np.asarray(model.predict(X))
194
+ if y_pred.ndim == 1:
195
+ y_pred = y_pred[:, None]
196
+
197
+ expected_shape = (len(X), len(target_cols))
198
+ if y_pred.shape != expected_shape:
199
+ raise ValueError(
200
+ f"Expected predictions with shape {expected_shape}, "
201
+ f"received {y_pred.shape}"
202
+ )
203
+
204
+ predicted = {
205
+ f"{prediction_column}:{target}": p
206
+ for target, p in zip(target_cols, y_pred.T)
207
+ }
208
+ return batch | predicted
209
+
210
+
211
+ class Box:
212
+ """Data processing, and model training.
213
+
214
+ A Box constructed with :meth:`from_config` retains the declarative model
215
+ specification used to construct its model.
216
+
217
+ A Box constructed directly from an arbitrary existing model has
218
+ no generic reconstruction recipe. Such Boxes remain supported and fall
219
+ back to whole-model serialization.
220
+
221
+ """
222
+
223
+ SCHEMA_VERSION: int = 1
224
+
225
+ def __init__(
226
+ self,
227
+ model: Estimator,
228
+ features: str | Iterable[str],
229
+ target: str | Iterable[str],
230
+ pipeline: PipelineLike | os.PathLike | str | None = None,
231
+ # TODO: Make type hints more specific
232
+ model_config: Mapping[str, ...] | None = None,
233
+ cache_dir: str | None = None
234
+ ):
235
+ if isinstance(features, str):
236
+ features = (features,)
237
+ if isinstance(target, str):
238
+ target = (target,)
239
+ features, target = tuple(features), tuple(target)
240
+ if len(features) == 0:
241
+ raise ValueError("features must be a nonempty list of column names")
242
+ if len(target) == 0:
243
+ raise ValueError("features must be a nonempty list of column names")
244
+ if not all(isinstance(f, str) and f for f in features):
245
+ raise ValueError("features must contain nonempty strings")
246
+ if not all(isinstance(f, str) and f for f in target):
247
+ raise ValueError("target must be a nonempty column name")
248
+ if not (
249
+ callable(getattr(model, "fit", None))
250
+ and callable(getattr(model, "predict", None))
251
+ ):
252
+ raise TypeError("model must implement .fit(X, y) and .predict(X) methods")
253
+
254
+ from providence import DataResolver
255
+
256
+ self.pipeline = _resolve_pipeline(
257
+ pipeline,
258
+ cache_dir=cache_dir,
259
+ )
260
+ self.model = model
261
+ self.features = features
262
+ self.target = target
263
+
264
+ self.model_config = (
265
+ _json_copy(model_config)
266
+ if model_config is not None
267
+ else None
268
+ )
269
+ self._validation_score = None
270
+
271
+
272
+ @property
273
+ def training_data_source(self):
274
+ """Recorded provenance for the training data."""
275
+ return getattr(self.pipeline.data_in, "data_source", None)
276
+
277
+ def verify_training_data_source(self) -> bool | None:
278
+ """Verify the original training-data source when possible."""
279
+ source = self.training_data_source
280
+ if source is None:
281
+ return None
282
+ return source.verify()
283
+
284
+ @classmethod
285
+ def from_config(
286
+ cls,
287
+ config: Mapping[str, ...] | os.PathLike | str,
288
+ *,
289
+ cache_dir: str | None = None
290
+ ) -> "Box":
291
+ """Construct a Box from JSON-compatible configuration."""
292
+ from aspect.data import DataPipeline
293
+ if isinstance(config, (str, Path)):
294
+ if os.path.exists(config):
295
+ config = load_json(str(config))
296
+ else:
297
+ raise FileNotFoundError(
298
+ "`config` was string, but filename "
299
+ f"called `{config}` not found."
300
+ )
301
+
302
+ config = deepcopy(dict(config))
303
+ try:
304
+ model_config = config["model"]
305
+ except KeyError as error:
306
+ raise ValueError(
307
+ f"Box config requires a 'model' specification. Received {config=}"
308
+ ) from error
309
+
310
+ if model_config is None:
311
+ raise ValueError(
312
+ f"Box.from_config() requires a model specification. "
313
+ "Opaque models can only be reconstructed from a "
314
+ "saved whole-model checkpoint. Received {config=}."
315
+ )
316
+
317
+ model_config = _json_copy(model_config)
318
+ model = instantiate_object(model_config)
319
+
320
+ pipeline_config = config.get("pipeline", {})
321
+ pipeline = DataPipeline.from_config(
322
+ pipeline_config,
323
+ cache_dir=cache_dir,
324
+ )
325
+ return cls(
326
+ model=model,
327
+ features=config.get("features", []),
328
+ target=config.get("target", []),
329
+ pipeline=pipeline,
330
+ model_config=model_config,
331
+ )
332
+
333
+ def to_config(
334
+ self,
335
+ path: str | os.PathLike | None = None
336
+ ) -> dict[str, ...]:
337
+ """Return the durable JSON configuration for this Box."""
338
+ from aspect.data import DataPipeline
339
+ if not isinstance(self.pipeline, DataPipeline):
340
+ raise TypeError(
341
+ "Checkpoint serialization currently requires "
342
+ "an aspect.DataPipeline. Arbitrary callable "
343
+ "pipelines do not have a generic reconstruction "
344
+ "representation."
345
+ )
346
+
347
+ config = {
348
+ "schema_version": self.SCHEMA_VERSION,
349
+ "model_config": (
350
+ _json_copy(self.model_config)
351
+ if self.model_config is not None
352
+ else None
353
+ ),
354
+ "pipeline": self.pipeline.to_config(),
355
+ "features": list(self.features),
356
+ "target": list(self.target),
357
+ }
358
+ if path is not None:
359
+ path = Path(path)
360
+ path.parent.mkdir(parents=True, exist_ok=True)
361
+ save_json(config, path)
362
+ return config
363
+
364
+ def save(
365
+ self,
366
+ path: str | os.PathLike,
367
+ **pipeline_kwargs
368
+ ) -> None:
369
+ """Save configuration and model state."""
370
+ import joblib
371
+ path = Path(path)
372
+ path.mkdir(parents=True, exist_ok=True)
373
+
374
+ has_save_method = hasattr(self.model, "save") and callable(getattr(self.model, "save"))
375
+ manifest = {
376
+ "schema_version": self.SCHEMA_VERSION,
377
+ "config": self.to_config(),
378
+ "artefacts": {
379
+ "model_text": "model.json" if has_save_method else None,
380
+ "model_binary": "model.joblib",
381
+ "training_data": "training-data.as",
382
+ }
383
+ }
384
+
385
+ config = self.to_config()
386
+ save_json(config, path / CONFIG_FILENAME)
387
+
388
+ self.pipeline.save(
389
+ path / manifest["artefacts"]["training_data"],
390
+ **pipeline_kwargs,
391
+ )
392
+ joblib.dump(
393
+ self.model,
394
+ path / manifest["artefacts"]["model_binary"],
395
+ )
396
+ if has_save_method:
397
+ self.model.save(
398
+ path / manifest["artefacts"]["model_text"],
399
+ )
400
+ # Write the manifest last, after both artefacts succeed.
401
+ save_json(manifest, path / CONFIG_FILENAME)
402
+ return None
403
+
404
+ @classmethod
405
+ def load(
406
+ cls,
407
+ path: str | os.PathLike,
408
+ *,
409
+ cache_dir: str | None = None
410
+ ) -> "Box":
411
+ """Restore a Box checkpoint."""
412
+ from aspect.data import DataPipeline
413
+ import joblib
414
+ path = Path(path)
415
+ manifest = load_json(path / CONFIG_FILENAME)
416
+ if manifest["schema_version"] != cls.SCHEMA_VERSION:
417
+ raise ValueError(
418
+ "Unsupported Treeton checkpoint schema: "
419
+ f"{manifest['schema_version']}, but package is schema version {cls.SCHEMA_VERSION}"
420
+ )
421
+
422
+ config = manifest["config"]
423
+ artefacts = manifest["artefacts"]
424
+ model = joblib.load(
425
+ path / artefacts["model_binary"]
426
+ )
427
+ pipeline = DataPipeline.load(
428
+ path / artefacts["training_data"],
429
+ cache_dir=cache_dir,
430
+ )
431
+ return cls(
432
+ model=model,
433
+ pipeline=pipeline,
434
+ features=config["features"],
435
+ target=config["target"],
436
+ model_config=config.get("model_config"),
437
+ )
438
+
439
+ def featurize(self, data, *, training: bool = False):
440
+ """Apply the DataPipeline."""
441
+ from aspect import DataPipeline
442
+ if not isinstance(self.pipeline, DataPipeline):
443
+ raise TypeError(
444
+ "Box.featurize() requires an "
445
+ f"aspect.DataPipeline. Found {self.pipeline}"
446
+ )
447
+ pipeline = self.pipeline if training else self.pipeline.clone()
448
+ return self._unwrap(pipeline(data)), pipeline
449
+
450
+ @staticmethod
451
+ def _unwrap(data):
452
+ from providence import DataResolver
453
+ return data.data if isinstance(data, DataResolver) else data
454
+
455
+ def _matrix(
456
+ self,
457
+ dataset: Dataset,
458
+ pipeline: DataPipeline
459
+ ):
460
+ return _matrix(
461
+ dataset=dataset,
462
+ pipeline=pipeline,
463
+ column_groups=(self.features, self.target),
464
+ )
465
+
466
+ def fit(
467
+ self,
468
+ data: DataLike,
469
+ *,
470
+ validation=None,
471
+ skip_eval: bool = False,
472
+ metrics: Iterable[str | Callable] | Mapping[str, str | Callable] | None = None,
473
+ **kwargs
474
+ ):
475
+ """Fit the model using raw training and optional validation and test data."""
476
+ training_data, pipeline = self.featurize(data, training=True)
477
+ X, y = self._matrix(training_data, pipeline)
478
+ # sklearn regressors generally expect a 1D single target.
479
+ if y.shape[1] == 1:
480
+ y = y[:, 0]
481
+ self.model.fit(
482
+ X, y,
483
+ **kwargs
484
+ )
485
+ self._validation_score = None
486
+ if validation is not None and not skip_eval:
487
+ _, self._validation_score = self.evaluate(
488
+ validation,
489
+ metrics=metrics,
490
+ )
491
+ return self
492
+
493
+ def _pre_predict(
494
+ self,
495
+ data=None,
496
+ pipeline: PipelineLike | None = None,
497
+ ):
498
+ if data is None:
499
+ if self.pipeline.data_in is None:
500
+ raise ValueError("No training data or prediction data available")
501
+
502
+ if pipeline is None and self.pipeline.data_out is not None:
503
+ return (
504
+ self._unwrap(self.pipeline.data_out).with_format(None),
505
+ self.pipeline.clone(),
506
+ )
507
+
508
+ data = self.pipeline.data_in
509
+
510
+ working_pipeline = (
511
+ self.pipeline.clone()
512
+ if pipeline is None
513
+ else _resolve_pipeline(pipeline).clone()
514
+ )
515
+
516
+ prepared = self._unwrap(working_pipeline(data))
517
+ return prepared.with_format(None), working_pipeline
518
+
519
+ def predict(
520
+ self,
521
+ data=None,
522
+ pipeline: PipelineLike | None = None,
523
+ prediction_column: str = DEFAULT_PREDICTION_COLUMN,
524
+ batch_size: int = DEFAULT_BATCH_SIZE,
525
+ map_kwargs: Mapping[str, ...] | None = None
526
+ ):
527
+ """Predict over a dataset in bounded-memory batches."""
528
+ map_kwargs = dict(map_kwargs or {})
529
+ if batch_size < 1:
530
+ raise ValueError(f"batch_size must be positive ({batch_size})")
531
+ prepared, pipeline = self._pre_predict(
532
+ data=data,
533
+ pipeline=pipeline
534
+ )
535
+ return prepared.map(
536
+ _predict_map_batch,
537
+ batched=True,
538
+ batch_size=batch_size,
539
+ fn_kwargs={
540
+ "model": self.model,
541
+ "pipeline": pipeline,
542
+ "prediction_column": prediction_column,
543
+ "column_groups": (self.features, self.target),
544
+ },
545
+ desc="Predicting",
546
+ **map_kwargs,
547
+ )
548
+
549
+ def evaluate(
550
+ self,
551
+ data=None,
552
+ pipeline: PipelineLike | None = None,
553
+ *,
554
+ metrics=None,
555
+ metric_mask: Callable | None = None,
556
+ prediction_column: str = DEFAULT_PREDICTION_COLUMN,
557
+ batch_size: int = DEFAULT_BATCH_SIZE,
558
+ **map_kwargs,
559
+ ):
560
+ """Return predicted dataset and evaluation scores."""
561
+ if batch_size < 1:
562
+ raise ValueError(f"batch_size must be positive: {batch_size}")
563
+
564
+ metric_fns = _resolve_metrics(metrics)
565
+ prepared, working_pipeline = self._pre_predict(
566
+ data=data,
567
+ pipeline=pipeline,
568
+ )
569
+
570
+ if metric_mask is not None:
571
+ prepared = prepared.filter(metric_mask)
572
+
573
+ predicted = prepared.map(
574
+ _predict_map_batch,
575
+ batched=True,
576
+ batch_size=batch_size,
577
+ fn_kwargs={
578
+ "model": self.model,
579
+ "column_groups": (self.features, self.target),
580
+ "pipeline": working_pipeline,
581
+ "prediction_column": prediction_column,
582
+ },
583
+ desc="Predicting",
584
+ **map_kwargs,
585
+ )
586
+
587
+ prediction_cols = tuple(
588
+ f"{prediction_column}:{target}"
589
+ for target in self.target
590
+ )
591
+
592
+ (y_pred,) = _matrix(
593
+ predicted,
594
+ working_pipeline,
595
+ (prediction_cols,),
596
+ )
597
+ (y_true,) = _matrix(
598
+ predicted,
599
+ working_pipeline,
600
+ (self.target,),
601
+ )
602
+
603
+ if y_pred.shape != y_true.shape:
604
+ raise ValueError(
605
+ f"Prediction/target shape mismatch: "
606
+ f"{y_pred.shape} versus {y_true.shape}"
607
+ )
608
+
609
+ if y_true.shape[1] == 1:
610
+ y_pred = y_pred[:, 0]
611
+ y_true = y_true[:, 0]
612
+
613
+ scores = {
614
+ name: fn(y_pred, y_true)
615
+ for name, fn in metric_fns.items()
616
+ }
617
+
618
+ return predicted, scores
619
+
File without changes