Skip to content
Open
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
49 changes: 49 additions & 0 deletions docs/user_guide/imputation/RandomSampleImputer.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,55 @@ the np.nan in the variable colour will be replaced using pandas sample as follow
the imputer will return an error. In addition, the variables indicated as seed
should not contain missing values themselves.

With polars
-----------

:class:`RandomSampleImputer()` also accepts polars dataframes as input to `fit()` and
`transform()`.

.. note::

pandas' ``.sample()`` and polars' ``.sample()`` are backed by different random
number generators. Setting the same integer `random_state` on pandas and on
polars input will **not** draw the same values, even from identical data. The
reproducibility guarantee is: same seed, same backend (pandas or polars) →
same sampled values. It is not a cross-backend guarantee.

.. code:: python

import polars as pl
from feature_engine.imputation import RandomSampleImputer

X_train = pl.DataFrame({
"MSSubClass": [60, 20, 60, 20, 50],
"YrSold": [2008, 2007, 2008, 2007, 2009],
"LotFrontage": [65.0, None, 68.0, 60.0, None],
})

imputer = RandomSampleImputer(
variables=["LotFrontage"],
random_state=["MSSubClass", "YrSold"],
seed="observation",
seeding_method="add",
)
imputer.fit(X_train)
imputer.transform(X_train)

.. code:: text

shape: (5, 3)
┌────────────┬────────┬─────────────┐
│ MSSubClass ┆ YrSold ┆ LotFrontage │
│ --- ┆ --- ┆ --- │
│ i64 ┆ i64 ┆ f64 │
╞════════════╪════════╪═════════════╡
│ 60 ┆ 2008 ┆ 65.0 │
│ 20 ┆ 2007 ┆ 68.0 │
│ 60 ┆ 2008 ┆ 68.0 │
│ 20 ┆ 2007 ┆ 60.0 │
│ 50 ┆ 2009 ┆ 65.0 │
└────────────┴────────┴─────────────┘

Important for GDPR
------------------

Expand Down
69 changes: 49 additions & 20 deletions feature_engine/imputation/base_imputer.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import pandas as pd
import narwhals as nw
import narwhals.dependencies as nwd
from narwhals.typing import IntoDataFrame
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted

from feature_engine._base_transformers.mixins import GetFeatureNamesOutMixin
from feature_engine.dataframe_checks import _check_X_matches_training_df, check_X
from feature_engine.tags import _return_tags

_PANDAS_LT_3 = int(pd.__version__.split(".")[0]) < 3


class BaseImputer(TransformerMixin, BaseEstimator, GetFeatureNamesOutMixin):
"""shared set-up checks and methods across imputers"""

def _transform(self, X: pd.DataFrame) -> pd.DataFrame:
def _transform(self, X: IntoDataFrame) -> IntoDataFrame:
"""
Common checks before transforming data:

Expand All @@ -23,11 +23,11 @@ def _transform(self, X: pd.DataFrame) -> pd.DataFrame:

Parameters
----------
X: Pandas DataFrame
X: dataframe of shape = [n_samples, n_features]

Returns
-------
X: Pandas DataFrame
X: dataframe.
The same dataframe entered by the user.
"""
# Check method fit has been called
Expand All @@ -40,42 +40,71 @@ def _transform(self, X: pd.DataFrame) -> pd.DataFrame:
_check_X_matches_training_df(X, self.n_features_in_)

# reorder df to match train set
X = X[self.feature_names_in_]
is_pandas = nwd.is_pandas_dataframe(X)
if is_pandas is True:
X = X[self.feature_names_in_]
else:
X = (
nw.from_native(X, eager_only=True)
.select(self.feature_names_in_)
.to_native()
)

return X

def transform(self, X: pd.DataFrame) -> pd.DataFrame:
def transform(self, X: IntoDataFrame) -> IntoDataFrame:
"""
Replace missing data with the learned parameters.

Parameters
----------
X: pandas dataframe of shape = [n_samples, n_features]
X: dataframe of shape = [n_samples, n_features]
The data to be transformed.

Returns
-------
X_new: pandas dataframe of shape = [n_samples, n_features]
X_new: dataframe of shape = [n_samples, n_features]
The dataframe without missing values in the selected variables.
"""

X = self._transform(X)

# Replace missing data with learned parameters. In pandas < 3, fillna
# downcasts object columns and warns; the option applies the pandas 3
# behavior: no downcasting, and infer_objects restores numeric dtypes.
if _PANDAS_LT_3:
with pd.option_context("future.no_silent_downcasting", True):
# Benchmarked: pandas-native fillna is ~1.3-1.6x faster than the
# narwhals-generic fill_null equivalent at the 10k-100k row sizes
# imputers are typically used at (the gap narrows to parity only
# past ~1M rows), so pandas keeps its own fast path here.
is_pandas = nwd.is_pandas_dataframe(X)
if is_pandas is True:
# Namespace of the dataframe already in hand, not a fresh import:
# pandas can only reach this branch already imported by the caller.
pd = nw.from_native(X, eager_only=True).__native_namespace__()
pandas_lt_3 = int(pd.__version__.split(".")[0]) < 3
# In pandas < 3, fillna downcasts object columns and warns; the
# option applies the pandas 3 behavior: no downcasting, and
# infer_objects restores numeric dtypes.
if pandas_lt_3 is True:
with pd.option_context("future.no_silent_downcasting", True):
X = X.fillna(value=self.imputer_dict_)
else:
X = X.fillna(value=self.imputer_dict_)
X = X.infer_objects()
else:
X = X.fillna(value=self.imputer_dict_)
return X.infer_objects()
nw_X = nw.from_native(X, eager_only=True)
nw_X = nw_X.with_columns(
nw.col(var).fill_null(value)
for var, value in self.imputer_dict_.items()
)
X = nw_X.to_native()

return X

def _get_feature_names_in(self, X):
"""Get the names and number of features in the train set (the dataframe
used during fit)."""

self.feature_names_in_ = X.columns.to_list()
is_pandas = nwd.is_pandas_dataframe(X)
if is_pandas is True:
self.feature_names_in_ = list(X.columns)
else:
self.feature_names_in_ = nw.from_native(X, eager_only=True).columns
self.n_features_in_ = X.shape[1]

return self
Expand Down
124 changes: 110 additions & 14 deletions feature_engine/imputation/random_sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

from typing import List, Optional, Union

import narwhals as nw
import narwhals.dependencies as nwd
import numpy as np
import pandas as pd
from narwhals.typing import IntoDataFrame, IntoSeries

from feature_engine._check_init_parameters.check_variables import (
_check_variables_input_value,
Expand Down Expand Up @@ -33,13 +35,14 @@

# for RandomSampleImputer
def _define_seed(
X: pd.DataFrame,
X: IntoDataFrame,
index: int,
seed_variables: Union[str, int, List[Union[str, int]]],
how: str = "add",
) -> int:
# determine seed by adding or multiplying the value of 1 or
# more variables
# Pandas-only: relies on .loc label-based row access, so it is only
# called from the pandas branch of transform(), where X is already
# confirmed to be a pandas dataframe.
if how == "add":
internal_seed = int(np.round(X.loc[index, seed_variables].sum(), 0))
elif how == "multiply":
Expand Down Expand Up @@ -130,15 +133,40 @@ class RandomSampleImputer(BaseImputer):
>>> x1 = [np.nan,1,1,0,np.nan],
>>> x2 = ["a", np.nan, "b", np.nan, "a"],
>>> ))
>>> rsi = RandomSampleImputer()
>>> rsi = RandomSampleImputer(random_state=42)
>>> rsi.fit(X)
>>> rsi.transform(X)
x1 x2
0 1.0 a
1 1.0 b
0 0.0 a
1 1.0 a
2 1.0 b
3 0.0 a
4 1.0 a

With polars, sampling is reproducible for a given seed and backend, but a
pandas seed and a polars seed do not draw the same values (see the "With
polars" section of the user guide):

>>> import polars as pl
>>> X = pl.DataFrame(dict(
... x1 = [None, 1, 1, 0, None],
... x2 = ["a", None, "b", None, "a"],
... ))
>>> rsi = RandomSampleImputer(random_state=42)
>>> rsi.fit(X)
>>> rsi.transform(X)
shape: (5, 2)
┌─────┬─────┐
│ x1 ┆ x2 │
│ --- ┆ --- │
│ i64 ┆ str │
╞═════╪═════╡
│ 0 ┆ a │
│ 1 ┆ a │
│ 1 ┆ b │
│ 0 ┆ a │
│ 1 ┆ a │
└─────┴─────┘
"""

def __init__(
Expand Down Expand Up @@ -177,7 +205,7 @@ def __init__(
self.seed = seed
self.seeding_method = seeding_method

def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
def fit(self, X: IntoDataFrame, y: Optional[IntoSeries] = None):
"""
Makes a copy of the train set. Only stores a copy of the variables to impute.
This copy is then used to randomly extract the values to fill the missing data
Expand All @@ -186,8 +214,9 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
Parameters
----------

X: pandas dataframe of shape = [n_samples, n_features]
The training dataset.
X: dataframe of shape = [n_samples, n_features]
The training dataset. Can be a pandas, polars, or any other dataframe
supported by narwhals.

y: None
y is not needed in this imputation. You can pass None or y.
Expand All @@ -203,7 +232,11 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
variables_ = check_all_variables(X, self.variables)

# take a copy of the selected variables
X_ = X[variables_].copy()
is_pandas = nwd.is_pandas_dataframe(X)
if is_pandas is True:
X_ = X[variables_].copy()
else:
X_ = nw.from_native(X, eager_only=True).select(variables_).to_native()

# check the variables assigned to the random state
if self.seed == "observation":
Expand All @@ -225,24 +258,40 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):

return self

def transform(self, X: pd.DataFrame) -> pd.DataFrame:
def transform(self, X: IntoDataFrame) -> IntoDataFrame:
"""
Replace missing data with random values taken from the train set.

Parameters
----------

X: pandas dataframe of shape = [n_samples, n_features]
X: dataframe of shape = [n_samples, n_features]
The dataframe to be transformed.

Returns
-------
X_new: pandas dataframe of shape = [n_samples, n_features]
X_new: dataframe of shape = [n_samples, n_features]
The dataframe without missing values in the transformed variables.
"""

X = self._transform(X)

# pandas' .sample() and narwhals/polars' .sample() use different RNGs,
# so they never draw the same values for the same seed - "same seed,
# same backend" is the reproducibility contract here, not cross-backend
# value parity. The pandas branch keeps the original .loc-based logic
# verbatim (bit-identical to pre-migration behaviour); the narwhals
# branch is a positional (index-free) reimplementation for polars and
# other narwhals backends.
is_pandas = nwd.is_pandas_dataframe(X)
if is_pandas is True:
X = self._transform_pandas(X)
else:
X = self._transform_narwhals(X)

return X

def _transform_pandas(self, X):
# random sampling with a general seed
if self.seed == "general":
for feature in self.variables_:
Expand Down Expand Up @@ -287,6 +336,53 @@ def transform(self, X: pd.DataFrame) -> pd.DataFrame:
X.loc[i, feature] = random_sample
return X

def _transform_narwhals(self, X):
nw_X = nw.from_native(X, eager_only=True)
nw_pool = nw.from_native(self.X_, eager_only=True)

if self.seed == "general":
for feature in self.variables_:
col = nw_X[feature]
null_mask = col.is_null()
n_samples = int(null_mask.sum())
if n_samples > 0:
positions = null_mask.arg_true()
random_sample = (
nw_pool[feature]
.drop_nulls()
.sample(
n_samples, with_replacement=True, seed=self.random_state
)
)
nw_X = nw_X.with_columns(col.scatter(positions, random_sample))

elif self.seed == "observation" and self.random_state:
# Vectorized stand-in for pandas' .loc-based per-row seed lookup:
# narwhals dataframes are positional (no row labels), so the seed
# for every row is computed up-front with numpy instead of in a
# per-row .loc lookup.
seed_values = nw_X.select(self.random_state).to_numpy()
if self.seeding_method == "add":
internal_seeds = np.round(seed_values.sum(axis=1), 0).astype(int)
else:
internal_seeds = np.round(seed_values.prod(axis=1), 0).astype(int)

for feature in self.variables_:
col = nw_X[feature]
null_mask = col.is_null()
if int(null_mask.sum()) > 0:
positions = null_mask.arg_true().to_list()
pool = nw_pool[feature].drop_nulls()
random_values = [
pool.sample(
1, with_replacement=True, seed=int(internal_seeds[pos])
).item()
for pos in positions
]
nw_X = nw_X.with_columns(col.scatter(positions, random_values))

return nw_X.to_native()

def _more_tags(self):
tags_dict = _return_tags()
tags_dict["allow_nan"] = True
Expand Down
Loading