Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions feature_engine/datetime/_datetime_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"quarter": lambda x: x.dt.quarter,
"semester": lambda x: np.where(x.dt.month <= 6, 1, 2).astype(np.int64),
"year": lambda x: x.dt.year,
"week": lambda x: x.dt.isocalendar().week.astype(np.int64),
"week": lambda x: x.dt.isocalendar().week.astype("float64"),
"day_of_week": lambda x: x.dt.dayofweek,
"day_of_month": lambda x: x.dt.day,
"day_of_year": lambda x: x.dt.dayofyear,
Expand All @@ -74,7 +74,7 @@
"year_start": lambda x: x.dt.is_year_start.astype(np.int64),
"year_end": lambda x: x.dt.is_year_end.astype(np.int64),
"leap_year": lambda x: x.dt.is_leap_year.astype(np.int64),
"days_in_month": lambda x: x.dt.days_in_month.astype(np.int64),
"days_in_month": lambda x: x.dt.days_in_month,
"hour": lambda x: x.dt.hour,
"minute": lambda x: x.dt.minute,
"second": lambda x: x.dt.second,
Expand Down
4 changes: 2 additions & 2 deletions feature_engine/timeseries/forecasting/window_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ def transform(self, X: pd.DataFrame) -> pd.DataFrame:
for win in self.window:
tmp = (
X[self.variables_]
.rolling(window=win)
.rolling(window=win, min_periods=self.min_periods)
.agg(self.functions)
.shift(periods=self.periods, freq=self.freq)
)
Expand All @@ -231,7 +231,7 @@ def transform(self, X: pd.DataFrame) -> pd.DataFrame:
else:
tmp = (
X[self.variables_]
.rolling(window=self.window)
.rolling(window=self.window, min_periods=self.min_periods)
.agg(self.functions)
.shift(periods=self.periods, freq=self.freq)
)
Expand Down
119 changes: 119 additions & 0 deletions tests/estimator_checks/sklearn_check_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Adapter that lets sklearn's ``check_estimator`` run against Feature-engine
transformers.

``check_estimator`` feeds transformers numpy arrays. Feature-engine transformers
only accept dataframes: ``feature_engine.dataframe_checks.check_X`` raises
``TypeError`` on anything that is not a dataframe from a narwhals-supported
library. Historically ``check_X`` itself converted numpy arrays into pandas
dataframes (naming the columns ``x0``, ``x1``, ...) precisely so that these
tests could run; that conversion is being removed as the checks migrate to
narwhals.

``wrap_for_check_estimator`` moves that adaptation into the test layer. It wraps
a transformer in a thin meta-estimator that turns the numpy arrays coming from
``check_estimator`` into pandas dataframes -- with the same ``x{i}`` column names
``check_X`` used to assign -- before delegating to the wrapped transformer. Tags
are delegated too, so ``check_estimator`` runs exactly the same set of checks it
would run against the bare transformer, and the ``expected_failed_checks``
dictionaries in the test files stay unchanged.
"""

import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator, TransformerMixin, clone
from sklearn.utils.validation import check_is_fitted


def wrap_for_check_estimator(estimator):
"""Wrap ``estimator`` so sklearn's ``check_estimator`` can feed it arrays.

Parameters
----------
estimator : feature-engine transformer instance.

Returns
-------
_SklearnCheckInputWrapper
A meta-estimator delegating to ``estimator`` that converts numpy array
input into a pandas dataframe before every call.
"""
return _SklearnCheckInputWrapper(estimator)


class _SklearnCheckInputWrapper(TransformerMixin, BaseEstimator):
"""Test-only shim: feeds the wrapped transformer a pandas dataframe when
sklearn's ``check_estimator`` passes numpy arrays.

Reproduces the numpy-array handling that
``feature_engine.dataframe_checks.check_X`` used to provide.
"""

def __init__(self, estimator=None, random_state=None):
self.estimator = estimator
self.random_state = random_state

@staticmethod
def _to_df(X):
"""Convert a numpy array into a dataframe, mirroring the old ``check_X``.

Dataframes are returned unchanged. Scalars, 1D arrays and complex data
raise the same errors ``check_X`` used to raise for them.
"""
if hasattr(X, "iloc"):
return X

arr = np.asarray(X)
if arr.ndim == 0:
raise ValueError(
"Expected 2D array, got scalar array instead:\narray={}.\n"
"Reshape your data either using array.reshape(-1, 1) if your "
"data has a single feature or array.reshape(1, -1) if it "
"contains a single sample.".format(arr)
)
if arr.ndim == 1:
raise ValueError(
"Expected 2D array, got 1D array instead:\narray={}.\n"
"Reshape your data either using array.reshape(-1, 1) if your "
"data has a single feature or array.reshape(1, -1) if it "
"contains a single sample.".format(arr)
)
if np.iscomplexobj(arr):
raise TypeError("Complex data not supported by this transformer.")

return pd.DataFrame(arr, columns=[f"x{i}" for i in range(arr.shape[1])])

def fit(self, X, y=None):
self.estimator_ = clone(self.estimator)
if (
self.random_state is not None
and "random_state" in self.estimator_.get_params()
):
self.estimator_.set_params(random_state=self.random_state)
if y is None:
self.estimator_.fit(self._to_df(X))
else:
self.estimator_.fit(self._to_df(X), y)

self.n_features_in_ = self.estimator_.n_features_in_
for attr in ("feature_names_in_", "classes_"):
if hasattr(self.estimator_, attr):
setattr(self, attr, getattr(self.estimator_, attr))
return self

def transform(self, X):
check_is_fitted(self)
return self.estimator_.transform(self._to_df(X))

def inverse_transform(self, X):
check_is_fitted(self)
return self.estimator_.inverse_transform(self._to_df(X))

def get_feature_names_out(self, input_features=None):
check_is_fitted(self)
return self.estimator_.get_feature_names_out(input_features)

def __sklearn_tags__(self):
return self.estimator.__sklearn_tags__()

def _more_tags(self):
return self.estimator._more_tags()
17 changes: 10 additions & 7 deletions tests/test_creation/test_check_estimator_creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from tests.estimator_checks.non_fitted_error_checks import (
check_raises_non_fitted_error_when_fit_fails,
)
from tests.estimator_checks.sklearn_check_wrapper import wrap_for_check_estimator

# Estimators for sklearn's check_estimator
# Note: GeoDistanceFeatures is not included here because it requires 4 specific
Expand All @@ -32,7 +33,7 @@
@pytest.mark.parametrize("estimator", _estimators)
def test_check_estimator_from_sklearn(estimator):
return check_estimator(
estimator=estimator,
estimator=wrap_for_check_estimator(estimator),
expected_failed_checks=estimator._more_tags()["_xfail_checks"],
)

Expand Down Expand Up @@ -72,12 +73,14 @@ def test_transformers_in_pipeline_with_set_output_pandas(transformer):
# Test GeoDistanceFeatures in pipeline with proper column names
def test_geo_distance_transformer_in_pipeline():
"""Test GeoDistanceFeatures works in a sklearn pipeline."""
X = pd.DataFrame({
"lat1": [40.7128, 34.0522],
"lon1": [-74.0060, -118.2437],
"lat2": [34.0522, 41.8781],
"lon2": [-118.2437, -87.6298],
})
X = pd.DataFrame(
{
"lat1": [40.7128, 34.0522],
"lon1": [-74.0060, -118.2437],
"lat2": [34.0522, 41.8781],
"lon2": [-118.2437, -87.6298],
}
)
y = pd.Series([0, 1])

transformer = GeoDistanceFeatures(
Expand Down
29 changes: 23 additions & 6 deletions tests/test_datetime/test_datetime_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,11 @@ def test_extract_datetime_features_from_specified_variables(make_df):
# multiple datetime variables, in different order than they appear in X
Xt = DatetimeFeatures(variables=["date2", "date"]).fit_transform(X)
result = nw.from_native(Xt, eager_only=True)
expected_cols = vars_non_dt + [
f"date2{FEATURES_SUFFIXES[feat]}" for feat in FEATURES_DEFAULT
] + feat_names_default_cb
expected_cols = (
vars_non_dt
+ [f"date2{FEATURES_SUFFIXES[feat]}" for feat in FEATURES_DEFAULT]
+ feat_names_default_cb
)
assert result.columns == expected_cols
for col, expected in _expected_cross_backend_features(FEATURES_DEFAULT).items():
assert _to_py_values(result.get_column(col)) == expected
Expand Down Expand Up @@ -294,9 +296,7 @@ def test_extract_all_datetime_features(make_df):


@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame])
@pytest.mark.parametrize(
"features", [["semester", "week"], ["hour", "day_of_week"]]
)
@pytest.mark.parametrize("features", [["semester", "week"], ["hour", "day_of_week"]])
def test_extract_specified_datetime_features(make_df, features):
X = make_df(CROSS_BACKEND_DATA)
Xt = DatetimeFeatures(features_to_extract=features).fit_transform(X)
Expand Down Expand Up @@ -517,6 +517,23 @@ def test_polars_string_parsing_needs_explicit_format_for_ambiguous_dates():
).to_list() == [2010, 1945]


def test_ignore_nan_for_week_and_days_in_month():
# week and days_in_month must propagate NaN like the other features when
# missing_values="ignore", instead of raising on the int cast of a NaT.
X = DatetimeFeatures(
features_to_extract=["week", "days_in_month"], missing_values="ignore"
).fit_transform(dates_nan)
pd.testing.assert_frame_equal(
X,
pd.DataFrame(
{
"dates_na_week": [5.0, np.nan, 22.0, np.nan],
"dates_na_days_in_month": [28.0, np.nan, 30.0, np.nan],
}
),
)


def test_extract_features_with_different_datetime_parsing_options(df_datetime):
X = DatetimeFeatures(
features_to_extract=["day_of_month"], dayfirst=True
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from tests.estimator_checks.non_fitted_error_checks import (
check_raises_non_fitted_error_when_fit_fails,
)
from tests.estimator_checks.sklearn_check_wrapper import wrap_for_check_estimator

_estimators = [
DecisionTreeDiscretiser(regression=False),
Expand All @@ -28,7 +29,7 @@
@pytest.mark.parametrize("estimator", _estimators)
def test_check_estimator_from_sklearn(estimator):
return check_estimator(
estimator=estimator,
estimator=wrap_for_check_estimator(estimator),
expected_failed_checks=estimator._more_tags()["_xfail_checks"],
)

Expand Down
4 changes: 3 additions & 1 deletion tests/test_encoding/test_check_estimator_encoders.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
check_feature_engine_estimator,
test_df,
)
from tests.estimator_checks.sklearn_check_wrapper import wrap_for_check_estimator

_estimators = [
CountEncoder(ignore_format=True),
Expand Down Expand Up @@ -50,7 +51,8 @@
def test_check_estimator_from_sklearn(estimator):
if estimator.__class__.__name__ != "WoEEncoder":
return check_estimator(
estimator=estimator, expected_failed_checks=expected_fails
estimator=wrap_for_check_estimator(estimator),
expected_failed_checks=expected_fails,
)


Expand Down
3 changes: 2 additions & 1 deletion tests/test_imputation/test_check_estimator_imputers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from tests.estimator_checks.non_fitted_error_checks import (
check_raises_non_fitted_error_when_fit_fails,
)
from tests.estimator_checks.sklearn_check_wrapper import wrap_for_check_estimator

_estimators = [
MeanImputer(),
Expand All @@ -31,7 +32,7 @@
@pytest.mark.parametrize("estimator", _estimators)
def test_check_estimator_from_sklearn(estimator):
return check_estimator(
estimator=estimator,
estimator=wrap_for_check_estimator(estimator),
expected_failed_checks=estimator._more_tags()["_xfail_checks"],
)

Expand Down
6 changes: 5 additions & 1 deletion tests/test_outliers/test_check_estimator_outliers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from feature_engine.outliers import ArbitraryOutlierCapper, OutlierTrimmer, Winsoriser
from feature_engine.tags import _return_tags
from tests.estimator_checks.estimator_checks import check_feature_engine_estimator
from tests.estimator_checks.sklearn_check_wrapper import wrap_for_check_estimator

_estimators = [
ArbitraryOutlierCapper(max_capping_dict={"x0": 10}),
Expand Down Expand Up @@ -38,7 +39,10 @@
],
)
def test_check_estimator_from_sklearn(estimator, failed_tests):
return check_estimator(estimator=estimator, expected_failed_checks=failed_tests)
return check_estimator(
estimator=wrap_for_check_estimator(estimator),
expected_failed_checks=failed_tests,
)


@pytest.mark.parametrize("estimator", _estimators)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
check_feature_engine_estimator,
test_df,
)
from tests.estimator_checks.sklearn_check_wrapper import wrap_for_check_estimator

_estimators = [MatchCategories(ignore_format=True), MatchVariables()]

Expand Down Expand Up @@ -41,7 +42,10 @@
],
)
def test_check_estimator_from_sklearn(estimator, failed_tests):
return check_estimator(estimator=estimator, expected_failed_checks=failed_tests)
return check_estimator(
estimator=wrap_for_check_estimator(estimator),
expected_failed_checks=failed_tests,
)


@pytest.mark.parametrize("estimator", [MatchCategories(), MatchVariables()])
Expand Down
6 changes: 5 additions & 1 deletion tests/test_selection/test_check_estimator_selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
check_confirm_variables,
check_raises_error_if_only_1_variable,
)
from tests.estimator_checks.sklearn_check_wrapper import wrap_for_check_estimator

_logreg = LogisticRegression(C=0.0001, max_iter=2, random_state=1)

Expand Down Expand Up @@ -90,7 +91,10 @@ def test_check_estimator_from_sklearn(estimator):
"SelectByInformationValue",
]:
failed_tests = estimator._more_tags()["_xfail_checks"]
return check_estimator(estimator=estimator, expected_failed_checks=failed_tests)
return check_estimator(
estimator=wrap_for_check_estimator(estimator),
expected_failed_checks=failed_tests,
)


@pytest.mark.parametrize("estimator", _univariate_estimators)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
WindowFeatures,
)
from tests.estimator_checks.estimator_checks import check_feature_engine_estimator
from tests.estimator_checks.sklearn_check_wrapper import wrap_for_check_estimator

_estimators = [
LagFeatures(missing_values="ignore"),
Expand All @@ -26,7 +27,7 @@ def test_check_estimator_from_sklearn(estimator):
"or infinity."
}
return check_estimator(
estimator=estimator,
estimator=wrap_for_check_estimator(estimator),
expected_failed_checks={
**extra_failing_checks,
**estimator._more_tags()["_xfail_checks"],
Expand Down
Loading