diff --git a/deeptab/configs/core.py b/deeptab/configs/core.py index c4f11a29..6709c87f 100644 --- a/deeptab/configs/core.py +++ b/deeptab/configs/core.py @@ -2,7 +2,7 @@ import warnings from collections.abc import Callable -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from typing import Any import torch.nn as nn @@ -513,6 +513,14 @@ def __post_init__(self) -> None: # type: ignore[override] "placement instead.", ) + # Remember which field values the resolved fields above were derived + # from, so `to_preprocessor_kwargs` can tell when they went stale. + self._resolved_from = self._field_values() + + def _field_values(self) -> tuple: + """Return the current values of all public fields, in declaration order.""" + return tuple(getattr(self, f.name) for f in fields(self)) + def _resolve_output_dim(self) -> int | None: """Resolve `n_bins`/`n_knots` into `output_dim`, defaulting to 7 when unset.""" legacy_values = {v for v in (self.n_bins, self.n_knots) if v is not None} @@ -587,12 +595,19 @@ def to_preprocessor_kwargs(self) -> dict: equivalents in ``__post_init__``; only canonical names are ever returned here, since PreTab 1.0 no longer accepts the legacy ones. + Fields changed after construction, through ``set_params`` (e.g. the + ``preprocessing_config__*`` parameters of a scikit-learn search) or by + direct attribute assignment, are validated and resolved again first, + so the returned kwargs always reflect the current field values. + Returns ------- dict Mapping of canonical PreTab field name → value for every resolved field that is not ``None``. """ + if self._field_values() != getattr(self, "_resolved_from", None): + self.__post_init__() kwargs = { "numerical_method": self._resolved_numerical_method, "categorical_method": self._resolved_categorical_method, diff --git a/tests/configs/test_config_contracts.py b/tests/configs/test_config_contracts.py index e0106133..674a13c7 100644 --- a/tests/configs/test_config_contracts.py +++ b/tests/configs/test_config_contracts.py @@ -31,6 +31,7 @@ TabulaRNNConfig, TrainerConfig, ) +from deeptab.core.exceptions import InvalidParamError class TestTrainerConfig: @@ -220,6 +221,28 @@ def test_sklearn_clone_independence(self): cloned.set_params(n_bins=999) assert cfg.n_bins == 32 + def test_set_params_is_reflected_in_preprocessor_kwargs(self): + cfg = PreprocessingConfig(numerical_method="ple", output_dim=7) + cfg.set_params(numerical_method="standardization", output_dim=12, scaling="minmax") + kwargs = cfg.to_preprocessor_kwargs() + assert kwargs["numerical_method"] == "standardization" + assert kwargs["output_dim"] == 12 + assert kwargs["scaling"] == "minmax" + + def test_attribute_assignment_is_reflected_in_preprocessor_kwargs(self): + cfg = PreprocessingConfig(numerical_method="ple", output_dim=7) + cfg.output_dim = 3 + cfg.categorical_method = "one-hot" + kwargs = cfg.to_preprocessor_kwargs() + assert kwargs["output_dim"] == 3 + assert kwargs["categorical_method"] == "one-hot" + + def test_set_params_values_are_validated_on_use(self): + cfg = PreprocessingConfig(output_dim=7) + cfg.set_params(output_dim=0) + with pytest.raises(InvalidParamError, match="output_dim"): + cfg.to_preprocessor_kwargs() + N = 120 diff --git a/tests/models/test_estimator_configuration.py b/tests/models/test_estimator_configuration.py index fe2f8aad..cd1ec34e 100644 --- a/tests/models/test_estimator_configuration.py +++ b/tests/models/test_estimator_configuration.py @@ -142,6 +142,32 @@ def test_set_params_nested_preprocessing_config(self): model.set_params(preprocessing_config__numerical_preprocessing="quantile") assert model.preprocessing_config.numerical_preprocessing == "quantile" + def test_set_params_nested_preprocessing_config_reaches_fit(self): + model = MLPRegressor( + model_config=MLPConfig(layer_sizes=[8]), + preprocessing_config=PreprocessingConfig(numerical_method="ple", output_dim=5), + trainer_config=_FAST_TRAINER, + random_state=0, + ) + model.set_params(preprocessing_config__numerical_method="quantile", preprocessing_config__output_dim=3) + assert model._preprocessor_kwargs["numerical_method"] == "quantile" + assert model._preprocessor_kwargs["output_dim"] == 3 + + model.fit(X_reg, y_reg, accelerator="cpu", logger=False, enable_progress_bar=False) + feature_info = model._data_module.num_feature_info + assert all(info["preprocessing"].endswith("quantile") for info in feature_info.values()) + + def test_preprocessing_config_attribute_mutation_reaches_fit(self): + model = MLPRegressor( + model_config=MLPConfig(layer_sizes=[8]), + preprocessing_config=PreprocessingConfig(numerical_method="ple", output_dim=5), + trainer_config=_FAST_TRAINER, + random_state=0, + ) + model.preprocessing_config.output_dim = 3 + model.fit(X_reg, y_reg, accelerator="cpu", logger=False, enable_progress_bar=False) + assert all(info["dimension"] == 3 for info in model._data_module.num_feature_info.values()) + def test_set_params_replace_whole_config(self): model = MLPClassifier( model_config=MLPConfig(),