Skip to content

Commit ad0675f

Browse files
authored
Fix moo things (#1501)
* Push * `fit_ensemble` now has priority for kwargs to take * Change ordering of prefernce for ensemble params * Add TODO note for metrics * Add `metrics` arg to `fit_ensemble` * Add test for pareto front sizes * Remove uneeded file * Re-added tests to `test_pareto_front` * Add descriptions to test files * Add test to ensure argument priority * Add test to make sure X_data only loaded when required * Remove part of test required for performance history * Default to `self._metrics` if `metrics` not available
1 parent e9c0318 commit ad0675f

22 files changed

+536
-219
lines changed

autosklearn/automl.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1502,6 +1502,7 @@ def fit_ensemble(
15021502
ensemble_nbest: Optional[int] = None,
15031503
ensemble_class: Optional[AbstractEnsemble] = EnsembleSelection,
15041504
ensemble_kwargs: Optional[Dict[str, Any]] = None,
1505+
metrics: Scorer | Sequence[Scorer] | None = None,
15051506
):
15061507
check_is_fitted(self)
15071508

@@ -1532,6 +1533,10 @@ def fit_ensemble(
15321533
else:
15331534
self._is_dask_client_internally_created = False
15341535

1536+
metrics = metrics if metrics is not None else self._metrics
1537+
if not isinstance(metrics, Sequence):
1538+
metrics = [metrics]
1539+
15351540
# Use the current thread to start the ensemble builder process
15361541
# The function ensemble_builder_process will internally create a ensemble
15371542
# builder in the provide dask client
@@ -1541,7 +1546,7 @@ def fit_ensemble(
15411546
backend=copy.deepcopy(self._backend),
15421547
dataset_name=dataset_name if dataset_name else self._dataset_name,
15431548
task=task if task else self._task,
1544-
metrics=self._metrics,
1549+
metrics=metrics if metrics is not None else self._metrics,
15451550
ensemble_class=(
15461551
ensemble_class if ensemble_class is not None else self._ensemble_class
15471552
),
@@ -1652,20 +1657,14 @@ def _load_best_individual_model(self):
16521657
return ensemble
16531658

16541659
def _load_pareto_set(self) -> Sequence[VotingClassifier | VotingRegressor]:
1655-
if len(self._metrics) <= 1:
1656-
raise ValueError("Pareto set is only available for two or more metrics.")
1657-
16581660
if self._ensemble_class is not None:
16591661
self.ensemble_ = self._backend.load_ensemble(self._seed)
16601662
else:
16611663
self.ensemble_ = None
16621664

16631665
# If no ensemble is loaded we cannot do anything
16641666
if not self.ensemble_:
1665-
1666-
raise ValueError(
1667-
"Pareto set can only be accessed if an ensemble is available."
1668-
)
1667+
raise ValueError("Pareto set only available if ensemble can be loaded.")
16691668

16701669
if isinstance(self.ensemble_, AbstractMultiObjectiveEnsemble):
16711670
pareto_set = self.ensemble_.get_pareto_set()

autosklearn/ensemble_building/builder.py

Lines changed: 92 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any, Dict, Iterable, Sequence, Type, cast
3+
from typing import Any, Iterable, Mapping, Sequence, Type, cast
44

55
import logging.handlers
66
import multiprocessing
@@ -46,7 +46,7 @@ def __init__(
4646
task_type: int,
4747
metrics: Sequence[Scorer],
4848
ensemble_class: Type[AbstractEnsemble] = EnsembleSelection,
49-
ensemble_kwargs: Dict[str, Any] | None = None,
49+
ensemble_kwargs: Mapping[str, Any] | None = None,
5050
ensemble_nbest: int | float = 50,
5151
max_models_on_disc: int | float | None = 100,
5252
seed: int = 1,
@@ -71,9 +71,11 @@ def __init__(
7171
metrics: Sequence[Scorer]
7272
Metrics to optimize the ensemble for. These must be non-duplicated.
7373
74-
ensemble_class
74+
ensemble_class: Type[AbstractEnsemble]
75+
Implementation of the ensemble algorithm.
7576
76-
ensemble_kwargs
77+
ensemble_kwargs: Mapping[str, Any] | None
78+
Arguments passed to the constructor of the ensemble algorithm.
7779
7880
ensemble_nbest: int | float = 50
7981
@@ -169,6 +171,8 @@ def __init__(
169171
self.validation_performance_ = np.inf
170172

171173
# Data we may need
174+
# TODO: The test data is needlessly loaded but automl_common has no concept of
175+
# these and is perhaps too rigid
172176
datamanager: XYDataManager = self.backend.load_datamanager()
173177
self._X_test: SUPPORTED_FEAT_TYPES | None = datamanager.data.get("X_test", None)
174178
self._y_test: np.ndarray | None = datamanager.data.get("Y_test", None)
@@ -442,6 +446,17 @@ def main(
442446
self.logger.debug("Found no runs")
443447
raise RuntimeError("Found no runs")
444448

449+
# We load in `X_data` if we need it
450+
if any(m._needs_X for m in self.metrics):
451+
ensemble_X_data = self.X_data("ensemble")
452+
453+
if ensemble_X_data is None:
454+
msg = "No `X_data` for 'ensemble' which was required by metrics"
455+
self.logger.debug(msg)
456+
raise RuntimeError(msg)
457+
else:
458+
ensemble_X_data = None
459+
445460
# Calculate the loss for those that require it
446461
requires_update = self.requires_loss_update(runs)
447462
if self.read_at_most is not None:
@@ -450,9 +465,7 @@ def main(
450465
for run in requires_update:
451466
run.record_modified_times() # So we don't count as modified next time
452467
run.losses = {
453-
metric.name: self.loss(
454-
run, metric=metric, X_data=self.X_data("ensemble")
455-
)
468+
metric.name: self.loss(run, metric=metric, X_data=ensemble_X_data)
456469
for metric in self.metrics
457470
}
458471

@@ -549,15 +562,14 @@ def main(
549562
return self.ensemble_history, self.ensemble_nbest
550563

551564
targets = cast(np.ndarray, self.targets("ensemble")) # Sure they exist
552-
X_data = self.X_data("ensemble")
553565

554566
ensemble = self.fit_ensemble(
555567
candidates=candidates,
556-
X_data=X_data,
557568
targets=targets,
558569
runs=runs,
559570
ensemble_class=self.ensemble_class,
560571
ensemble_kwargs=self.ensemble_kwargs,
572+
X_data=ensemble_X_data,
561573
task=self.task_type,
562574
metrics=self.metrics,
563575
precision=self.precision,
@@ -587,7 +599,15 @@ def main(
587599

588600
run_preds = [r.predictions(kind, precision=self.precision) for r in models]
589601
pred = ensemble.predict(run_preds)
590-
X_data = self.X_data(kind)
602+
603+
if any(m._needs_X for m in self.metrics):
604+
X_data = self.X_data(kind)
605+
if X_data is None:
606+
msg = f"No `X` data for '{kind}' which was required by metrics"
607+
self.logger.debug(msg)
608+
raise RuntimeError(msg)
609+
else:
610+
X_data = None
591611

592612
scores = calculate_scores(
593613
solution=pred_targets,
@@ -597,10 +617,19 @@ def main(
597617
X_data=X_data,
598618
scoring_functions=None,
599619
)
620+
621+
# TODO only one metric in history
622+
#
623+
# We should probably return for all metrics but this makes
624+
# automl::performance_history a lot more complicated, will
625+
# tackle in a future PR
626+
first_metric = self.metrics[0]
600627
performance_stamp[f"ensemble_{score_name}_score"] = scores[
601-
self.metrics[0].name
628+
first_metric.name
602629
]
603-
self.ensemble_history.append(performance_stamp)
630+
631+
# Add the performance stamp to the history
632+
self.ensemble_history.append(performance_stamp)
604633

605634
# Lastly, delete any runs that need to be deleted. We save this as the last step
606635
# so that we have an ensemble saved that is up to date. If we do not do so,
@@ -805,13 +834,13 @@ def candidate_selection(
805834

806835
def fit_ensemble(
807836
self,
808-
candidates: list[Run],
809-
X_data: SUPPORTED_FEAT_TYPES,
810-
targets: np.ndarray,
837+
candidates: Sequence[Run],
838+
runs: Sequence[Run],
811839
*,
812-
runs: list[Run],
840+
targets: np.ndarray | None = None,
813841
ensemble_class: Type[AbstractEnsemble] = EnsembleSelection,
814-
ensemble_kwargs: Dict[str, Any] | None = None,
842+
ensemble_kwargs: Mapping[str, Any] | None = None,
843+
X_data: SUPPORTED_FEAT_TYPES | None = None,
815844
task: int | None = None,
816845
metrics: Sequence[Scorer] | None = None,
817846
precision: int | None = None,
@@ -825,24 +854,24 @@ def fit_ensemble(
825854
826855
Parameters
827856
----------
828-
candidates: list[Run]
857+
candidates: Sequence[Run]
829858
List of runs to build an ensemble from
830859
831-
X_data: SUPPORTED_FEAT_TYPES
832-
The base level data.
860+
runs: Sequence[Run]
861+
List of all runs (also pruned ones and dummy runs)
833862
834-
targets: np.ndarray
863+
targets: np.ndarray | None = None
835864
The targets to build the ensemble with
836865
837-
runs: list[Run]
838-
List of all runs (also pruned ones and dummy runs)
839-
840-
ensemble_class: AbstractEnsemble
866+
ensemble_class: Type[AbstractEnsemble]
841867
Implementation of the ensemble algorithm.
842868
843-
ensemble_kwargs: Dict[str, Any]
869+
ensemble_kwargs: Mapping[str, Any] | None
844870
Arguments passed to the constructor of the ensemble algorithm.
845871
872+
X_data: SUPPORTED_FEAT_TYPES | None = None
873+
The base level data.
874+
846875
task: int | None = None
847876
The kind of task performed
848877
@@ -859,24 +888,42 @@ def fit_ensemble(
859888
-------
860889
AbstractEnsemble
861890
"""
862-
task = task if task is not None else self.task_type
891+
# Validate we have targets if None specified
892+
if targets is None:
893+
targets = self.targets("ensemble")
894+
if targets is None:
895+
path = self.backend._get_targets_ensemble_filename()
896+
raise ValueError(f"`fit_ensemble` could not find any targets at {path}")
897+
863898
ensemble_class = (
864899
ensemble_class if ensemble_class is not None else self.ensemble_class
865900
)
866-
ensemble_kwargs = (
867-
ensemble_kwargs if ensemble_kwargs is not None else self.ensemble_kwargs
868-
)
869-
ensemble_kwargs = ensemble_kwargs if ensemble_kwargs is not None else {}
870-
metrics = metrics if metrics is not None else self.metrics
871-
rs = random_state if random_state is not None else self.random_state
872901

873-
ensemble = ensemble_class(
874-
task_type=task,
875-
metrics=metrics,
876-
random_state=rs,
877-
backend=self.backend,
878-
**ensemble_kwargs,
879-
) # type: AbstractEnsemble
902+
# Create the ensemble_kwargs, favouring in order:
903+
# 1) function kwargs, 2) function params 3) init_kwargs 4) init_params
904+
905+
# Collect func params in dict if they're not None
906+
params = {
907+
k: v
908+
for k, v in [
909+
("task_type", task),
910+
("metrics", metrics),
911+
("random_state", random_state),
912+
]
913+
if v is not None
914+
}
915+
916+
kwargs = {
917+
"backend": self.backend,
918+
"task_type": self.task_type,
919+
"metrics": self.metrics,
920+
"random_state": self.random_state,
921+
**(self.ensemble_kwargs or {}),
922+
**params,
923+
**(ensemble_kwargs or {}),
924+
}
925+
926+
ensemble = ensemble_class(**kwargs) # type: AbstractEnsemble
880927

881928
self.logger.debug(f"Fitting ensemble on {len(candidates)} models")
882929
start_time = time.time()
@@ -995,7 +1042,8 @@ def loss(
9951042
self,
9961043
run: Run,
9971044
metric: Scorer,
998-
X_data: SUPPORTED_FEAT_TYPES,
1045+
*,
1046+
X_data: SUPPORTED_FEAT_TYPES | None = None,
9991047
kind: str = "ensemble",
10001048
) -> float:
10011049
"""Calculate the loss for a run
@@ -1008,6 +1056,9 @@ def loss(
10081056
metric: Scorer
10091057
The metric to calculate the loss of
10101058
1059+
X_data: SUPPORTED_FEAT_TYPES | None = None
1060+
Any X_data required to be passed to the metric
1061+
10111062
kind: str = "ensemble"
10121063
The kind of targets to use for the run
10131064

autosklearn/estimators.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,7 @@ def fit_ensemble(
601601
ensemble_kwargs: Optional[Dict[str, Any]] = None,
602602
ensemble_nbest: Optional[int] = None,
603603
ensemble_class: Optional[AbstractEnsemble] = EnsembleSelection,
604+
metrics: Scorer | Sequence[Scorer] | None = None,
604605
):
605606
"""Fit an ensemble to models trained during an optimization process.
606607
@@ -650,12 +651,13 @@ def fit_ensemble(
650651
to obtain only use the single best model instead of an
651652
ensemble.
652653
654+
metrics: Scorer | Sequence[Scorer] | None = None
655+
A metric or list of metrics to score the ensemble with
656+
653657
Returns
654658
-------
655659
self
656-
657660
"""
658-
659661
# User specified `ensemble_size` explicitly, warn them about deprecation
660662
if ensemble_size is not None:
661663
# Keep consistent behaviour
@@ -708,6 +710,7 @@ def fit_ensemble(
708710
ensemble_nbest=ensemble_nbest,
709711
ensemble_class=ensemble_class,
710712
ensemble_kwargs=ensemble_kwargs,
713+
metrics=metrics,
711714
)
712715
return self
713716

test/fixtures/ensemble_building.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ def _make(
164164
backend.save_additional_data(
165165
datamanager.data["Y_train"], what="targets_ensemble"
166166
)
167+
if "X_train" in datamanager.data:
168+
backend.save_additional_data(
169+
datamanager.data["X_train"], what="input_ensemble"
170+
)
167171

168172
builder = EnsembleBuilder(
169173
backend=backend,

test/fixtures/metrics.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from typing import Any
2+
3+
import numpy as np
4+
5+
from autosklearn.metrics import accuracy, make_scorer
6+
7+
8+
def _accuracy_requiring_X_data(
9+
y_true: np.ndarray,
10+
y_pred: np.ndarray,
11+
X_data: Any,
12+
) -> float:
13+
"""Dummy metric that needs X Data"""
14+
if X_data is None:
15+
raise ValueError()
16+
return accuracy(y_true, y_pred)
17+
18+
19+
acc_with_X_data = make_scorer(
20+
name="acc_with_X_data",
21+
score_func=_accuracy_requiring_X_data,
22+
needs_X=True,
23+
optimum=1,
24+
worst_possible_result=0,
25+
greater_is_better=True,
26+
)

test/test_automl/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +0,0 @@
1-
# -*- encoding: utf-8 -*-

test/test_automl/test_construction.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
"""Property based Tests
2-
3-
These test are for checking properties of already fitted models. Any test that does
4-
tests using cases should not modify the state as these models are cached between tests
5-
to reduce training time.
6-
"""
1+
"""Test things related to only constructing an AutoML instance"""
72
from typing import Any, Dict, Optional, Union
83

94
from autosklearn.automl import AutoML

test/test_automl/test_dataset_compression.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
"""Test things related to how AutoML compresses the dataset size"""
12
from typing import Any, Callable, Dict
23

34
import numpy as np

0 commit comments

Comments
 (0)