From c7658b6c35ef8c4f7a5de1c4241502c80cea1969 Mon Sep 17 00:00:00 2001 From: Riccardo Cappuzzo Date: Thu, 25 Sep 2025 10:17:48 +0200 Subject: [PATCH 1/4] refactoring get_feature_names_out --- skrub/_apply_to_cols.py | 24 ------------------------ skrub/_minhash_encoder.py | 24 ++++++++++++++++++++++++ skrub/_string_encoder.py | 25 +++++++++++++++++++++++++ skrub/_text_encoder.py | 24 ++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 24 deletions(-) diff --git a/skrub/_apply_to_cols.py b/skrub/_apply_to_cols.py index 2983a68c2..e8fbf21fd 100644 --- a/skrub/_apply_to_cols.py +++ b/skrub/_apply_to_cols.py @@ -123,30 +123,6 @@ def __init_subclass__(subclass, **kwargs): wrapped = _wrap_add_check_single_column(getattr(subclass, method)) setattr(subclass, method, wrapped) - def get_feature_names_out(self, input_features=None): - """Return a list of features generated by the transformer. - - Each feature has format ``{input_name}_{n_component}`` where ``input_name`` - is the name of the input column, or a default name for the encoder, and - ``n_component`` is the idx of the specific feature. - - Parameters - ---------- - input_features : None - The input features. Ignored, only here for compatibility. - - Returns - ------- - list of str - The list of feature names. - """ - check_is_fitted(self, "n_components_") - num_digits = len(str(self.n_components_ - 1)) - return [ - f"{self.input_name_}_{str(i).zfill(num_digits)}" - for i in range(self.n_components_) - ] - def _wrap_add_check_single_column(f): # as we have only a few predefined functions to handle, using their exact diff --git a/skrub/_minhash_encoder.py b/skrub/_minhash_encoder.py index c079b8fe1..ed57e1ba7 100644 --- a/skrub/_minhash_encoder.py +++ b/skrub/_minhash_encoder.py @@ -297,3 +297,27 @@ def transform(self, X): result = sbd.make_dataframe_like(X, dict(zip(names, X_out.T))) result = sbd.copy_index(X, result) return result + + def get_feature_names_out(self, input_features=None): + """Return a list of features generated by the transformer. + + Each feature has format ``{input_name}_{n_component}`` where ``input_name`` + is the name of the input column, or a default name for the encoder, and + ``n_component`` is the idx of the specific feature. + + Parameters + ---------- + input_features : None + The input features. Ignored, only here for compatibility. + + Returns + ------- + list of str + The list of feature names. + """ + check_is_fitted(self, "n_components_") + num_digits = len(str(self.n_components_ - 1)) + return [ + f"{self.input_name_}_{str(i).zfill(num_digits)}" + for i in range(self.n_components_) + ] diff --git a/skrub/_string_encoder.py b/skrub/_string_encoder.py index 9d15de6b1..1bf3aa886 100644 --- a/skrub/_string_encoder.py +++ b/skrub/_string_encoder.py @@ -7,6 +7,7 @@ TfidfVectorizer, ) from sklearn.pipeline import Pipeline +from sklearn.utils.validation import check_is_fitted from . import _dataframe as sbd from ._apply_to_cols import SingleColumnTransformer @@ -263,3 +264,27 @@ def _post_process(self, X, result): result = sbd.copy_index(X, result) return result + + def get_feature_names_out(self, input_features=None): + """Return a list of features generated by the transformer. + + Each feature has format ``{input_name}_{n_component}`` where ``input_name`` + is the name of the input column, or a default name for the encoder, and + ``n_component`` is the idx of the specific feature. + + Parameters + ---------- + input_features : None + The input features. Ignored, only here for compatibility. + + Returns + ------- + list of str + The list of feature names. + """ + check_is_fitted(self, "n_components_") + num_digits = len(str(self.n_components_ - 1)) + return [ + f"{self.input_name_}_{str(i).zfill(num_digits)}" + for i in range(self.n_components_) + ] diff --git a/skrub/_text_encoder.py b/skrub/_text_encoder.py index 2c0f734d0..1dd5b3299 100644 --- a/skrub/_text_encoder.py +++ b/skrub/_text_encoder.py @@ -413,3 +413,27 @@ def __getstate__(self): del state[prop] return state + + def get_feature_names_out(self, input_features=None): + """Return a list of features generated by the transformer. + + Each feature has format ``{input_name}_{n_component}`` where ``input_name`` + is the name of the input column, or a default name for the encoder, and + ``n_component`` is the idx of the specific feature. + + Parameters + ---------- + input_features : None + The input features. Ignored, only here for compatibility. + + Returns + ------- + list of str + The list of feature names. + """ + check_is_fitted(self, "n_components_") + num_digits = len(str(self.n_components_ - 1)) + return [ + f"{self.input_name_}_{str(i).zfill(num_digits)}" + for i in range(self.n_components_) + ] From ed4fc4fc34f9beadeb8d244d0181de887afecda6 Mon Sep 17 00:00:00 2001 From: Riccardo Cappuzzo Date: Thu, 25 Sep 2025 10:28:18 +0200 Subject: [PATCH 2/4] fixing tests --- skrub/_datetime_encoder.py | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/skrub/_datetime_encoder.py b/skrub/_datetime_encoder.py index 07256b219..820a87861 100644 --- a/skrub/_datetime_encoder.py +++ b/skrub/_datetime_encoder.py @@ -608,6 +608,30 @@ def _periodic_spline_transformer(self): include_bias=True, ) + def get_feature_names_out(self, input_features=None): + """Return a list of features generated by the transformer. + + Each feature has format ``{input_name}_{n_component}`` where ``input_name`` + is the name of the input column, or a default name for the encoder, and + ``n_component`` is the idx of the specific feature. + + Parameters + ---------- + input_features : None + The input features. Ignored, only here for compatibility. + + Returns + ------- + list of str + The list of feature names. + """ + check_is_fitted(self, "n_components_") + num_digits = len(str(self.n_components_ - 1)) + return [ + f"{self.input_name_}_{str(i).zfill(num_digits)}" + for i in range(self.n_components_) + ] + class _CircularEncoder(SingleColumnTransformer): """Generate trigonometric features for the given feature. @@ -685,3 +709,27 @@ def _post_process(self, X, result): result = sbd.copy_index(X, result) return result + + def get_feature_names_out(self, input_features=None): + """Return a list of features generated by the transformer. + + Each feature has format ``{input_name}_{n_component}`` where ``input_name`` + is the name of the input column, or a default name for the encoder, and + ``n_component`` is the idx of the specific feature. + + Parameters + ---------- + input_features : None + The input features. Ignored, only here for compatibility. + + Returns + ------- + list of str + The list of feature names. + """ + check_is_fitted(self, "n_components_") + num_digits = len(str(self.n_components_ - 1)) + return [ + f"{self.input_name_}_{str(i).zfill(num_digits)}" + for i in range(self.n_components_) + ] From 4c8b1e74b1605e37cef91200c90efa3bf416fa80 Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Thu, 25 Sep 2025 11:21:39 +0200 Subject: [PATCH 3/4] create base class for the priviate periodic encoder --- skrub/_datetime_encoder.py | 133 +++++++++++++++---------------------- 1 file changed, 52 insertions(+), 81 deletions(-) diff --git a/skrub/_datetime_encoder.py b/skrub/_datetime_encoder.py index 820a87861..3089b8627 100644 --- a/skrub/_datetime_encoder.py +++ b/skrub/_datetime_encoder.py @@ -511,7 +511,42 @@ def get_feature_names_out(self, input_features=None): return self.all_outputs_ -class _SplineEncoder(SingleColumnTransformer): +class _BasePeriodicEncoder(SingleColumnTransformer): + """Base class for periodic encoders.""" + + def __init__(self, period=24): + self.period = period + + def _post_process(self, X, new_features): + result = sbd.make_dataframe_like(X, dict(zip(self.all_outputs_, new_features))) + return sbd.copy_index(X, result) + + def get_feature_names_out(self, input_features=None): + """Return a list of features generated by the transformer. + + Each feature has format ``{input_name}_{n_component}`` where ``input_name`` + is the name of the input column, or a default name for the encoder, and + ``n_component`` is the idx of the specific feature. + + Parameters + ---------- + input_features : None + The input features. Ignored, only here for compatibility. + + Returns + ------- + list of str + The list of feature names. + """ + check_is_fitted(self, "n_components_") + num_digits = len(str(self.n_components_ - 1)) + return [ + f"{self.input_name_}_{str(i).zfill(num_digits)}" + for i in range(self.n_components_) + ] + + +class _SplineEncoder(_BasePeriodicEncoder): """Generate univariate B-spline bases for features. This encoder will apply the scikit-learn SplineTransformer to the given @@ -533,10 +568,22 @@ class _SplineEncoder(SingleColumnTransformer): """ def __init__(self, period=24, n_splines=None, degree=3): - self.period = period + super().__init__(period=period) self.n_splines = n_splines self.degree = degree + def _periodic_spline_transformer(self): + if self.n_splines is None: + self.n_splines = self.period + n_knots = self.n_splines + 1 # periodic and include_bias is True + return SplineTransformer( + degree=self.degree, + n_knots=n_knots, + knots=np.linspace(0, self.period, n_knots).reshape(n_knots, 1), + extrapolation="periodic", + include_bias=True, + ) + def fit_transform(self, X, y=None): """Fit the encoder and transform a column. @@ -560,7 +607,6 @@ def fit_transform(self, X, y=None): X_out = self.transformer_.fit_transform(sbd.to_numpy(X).reshape(-1, 1)) - self.is_fitted = True self.n_components_ = X_out.shape[1] # TODO: this will raise an error if X is None, but it should not happen @@ -570,7 +616,7 @@ def fit_transform(self, X, y=None): self.input_name_ = sbd.name(X) + "_spline" self.all_outputs_ = self.get_feature_names_out() - return self._post_process(X, X_out) + return self._post_process(X, X_out.T) def transform(self, X): """Transform a column. @@ -588,52 +634,10 @@ def transform(self, X): X_out = self.transformer_.transform(sbd.to_numpy(X).reshape(-1, 1)) - return self._post_process(X, X_out) - - def _post_process(self, X, result): - result = sbd.make_dataframe_like(X, dict(zip(self.all_outputs_, result.T))) - result = sbd.copy_index(X, result) - - return result - - def _periodic_spline_transformer(self): - if self.n_splines is None: - self.n_splines = self.period - n_knots = self.n_splines + 1 # periodic and include_bias is True - return SplineTransformer( - degree=self.degree, - n_knots=n_knots, - knots=np.linspace(0, self.period, n_knots).reshape(n_knots, 1), - extrapolation="periodic", - include_bias=True, - ) - - def get_feature_names_out(self, input_features=None): - """Return a list of features generated by the transformer. - - Each feature has format ``{input_name}_{n_component}`` where ``input_name`` - is the name of the input column, or a default name for the encoder, and - ``n_component`` is the idx of the specific feature. - - Parameters - ---------- - input_features : None - The input features. Ignored, only here for compatibility. - - Returns - ------- - list of str - The list of feature names. - """ - check_is_fitted(self, "n_components_") - num_digits = len(str(self.n_components_ - 1)) - return [ - f"{self.input_name_}_{str(i).zfill(num_digits)}" - for i in range(self.n_components_) - ] + return self._post_process(X, X_out.T) -class _CircularEncoder(SingleColumnTransformer): +class _CircularEncoder(_BasePeriodicEncoder): """Generate trigonometric features for the given feature. This encoder will generate two features corresponding to the sine and cosine @@ -645,9 +649,6 @@ class _CircularEncoder(SingleColumnTransformer): Period to be used as basis of the trigonometric function. """ - def __init__(self, period=24): - self.period = period - def fit_transform(self, X, y=None): """Fit the encoder and transform a column. @@ -703,33 +704,3 @@ def transform(self, X): ] return self._post_process(X, new_features) - - def _post_process(self, X, result): - result = sbd.make_dataframe_like(X, dict(zip(self.all_outputs_, result))) - result = sbd.copy_index(X, result) - - return result - - def get_feature_names_out(self, input_features=None): - """Return a list of features generated by the transformer. - - Each feature has format ``{input_name}_{n_component}`` where ``input_name`` - is the name of the input column, or a default name for the encoder, and - ``n_component`` is the idx of the specific feature. - - Parameters - ---------- - input_features : None - The input features. Ignored, only here for compatibility. - - Returns - ------- - list of str - The list of feature names. - """ - check_is_fitted(self, "n_components_") - num_digits = len(str(self.n_components_ - 1)) - return [ - f"{self.input_name_}_{str(i).zfill(num_digits)}" - for i in range(self.n_components_) - ] From 21e2b0535f8d87866c7ad9afaf02659c084adacf Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Thu, 25 Sep 2025 12:01:07 +0200 Subject: [PATCH 4/4] fix to be compliant with sklearn and add non-regression test --- skrub/_datetime_encoder.py | 5 ++--- skrub/tests/test_datetime_encoder.py | 8 ++++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/skrub/_datetime_encoder.py b/skrub/_datetime_encoder.py index 3089b8627..ca6cf61b5 100644 --- a/skrub/_datetime_encoder.py +++ b/skrub/_datetime_encoder.py @@ -573,9 +573,8 @@ def __init__(self, period=24, n_splines=None, degree=3): self.degree = degree def _periodic_spline_transformer(self): - if self.n_splines is None: - self.n_splines = self.period - n_knots = self.n_splines + 1 # periodic and include_bias is True + n_splines = self.period if self.n_splines is None else self.n_splines + n_knots = n_splines + 1 # periodic and include_bias is True return SplineTransformer( degree=self.degree, n_knots=n_knots, diff --git a/skrub/tests/test_datetime_encoder.py b/skrub/tests/test_datetime_encoder.py index 9087bf2e8..af40a98c7 100644 --- a/skrub/tests/test_datetime_encoder.py +++ b/skrub/tests/test_datetime_encoder.py @@ -369,3 +369,11 @@ def test_error_checking_periodic_encoder(a_datetime_col): def test_error_dispatch(func): with pytest.raises(TypeError, match="Expecting a Pandas or Polars Series"): func(np.array([1])) + + +def test_n_splines_default_value(df_module): + """Check that when `n_splines is None`, it defaults to the `period` value.""" + period = 15 + enc = _SplineEncoder(period=period) + result = enc.fit_transform(df_module.make_column("when", [20, 20, 20])) + assert sbd.column_names(result) == [f"when_spline_{i:02d}" for i in range(period)]