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 +10 -0
- treeton/box/__init__.py +1 -0
- treeton/box/base.py +619 -0
- treeton/cli/__init__.py +0 -0
- treeton/cli/cli.py +166 -0
- treeton/cli/predict.py +50 -0
- treeton/cli/train.py +41 -0
- treeton/config.py +175 -0
- treeton/metrics/__init__.py +1 -0
- treeton/metrics/base.py +131 -0
- treeton/typing.py +18 -0
- treeton/utils/__init__.py +0 -0
- treeton/utils/plotting.py +70 -0
- treeton-0.0.1.dist-info/METADATA +630 -0
- treeton-0.0.1.dist-info/RECORD +19 -0
- treeton-0.0.1.dist-info/WHEEL +5 -0
- treeton-0.0.1.dist-info/entry_points.txt +2 -0
- treeton-0.0.1.dist-info/licenses/LICENSE +21 -0
- treeton-0.0.1.dist-info/top_level.txt +1 -0
treeton/__init__.py
ADDED
treeton/box/__init__.py
ADDED
|
@@ -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
|
+
|
treeton/cli/__init__.py
ADDED
|
File without changes
|