Skip to content

Commit f1f34ea

Browse files
committed
finalise migration of variable handling module
1 parent 83c0099 commit f1f34ea

9 files changed

Lines changed: 96 additions & 41 deletions

File tree

AGENTS.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# AGENTS.md
2+
3+
Conventions for working in this repo. Optimize for readability and speed,
4+
in that order of how you decide, but don't ship a slow default when a
5+
fast one is free.
6+
7+
## Inputs
8+
9+
Feature-engine transformers take dataframes (pandas, polars, or any other
10+
narwhals-supported backend) as input, not numpy arrays. Don't add
11+
handling for array input.
12+
13+
## Booleans and control flow
14+
15+
- Compare booleans explicitly: `if x is True:` / `if x is False:`, never
16+
`if x:` / `if not x:`.
17+
- Check container emptiness with `len(x) == 0`, never `if not x:`.
18+
- `isinstance(...)` checks and `in`/`not in` membership tests are already
19+
explicit — leave them as-is, this rule isn't about those.
20+
21+
## Comments
22+
23+
Max 2 lines. Only explain a non-obvious WHY (a hidden constraint, a subtle
24+
backend difference, a workaround) — never describe WHAT the code does.
25+
26+
## Don't anticipate errors
27+
28+
Don't add error handling or validation for scenarios that can't happen. If
29+
unsure whether something can happen, check it (grep, run a quick repro) or
30+
ask — don't guess and defensively code around it.
31+
32+
## Redundant lists/sets
33+
34+
- Narwhals' `.columns` is already `list[str]` — don't wrap it in `list()`.
35+
- pandas' `.columns` is an `Index`, not a list — `list()` is required there
36+
(an `Index == list` comparison is elementwise, not a clean bool).
37+
38+
## Verify before applying
39+
40+
Benchmark before claiming a speedup, and diff old-vs-new output across
41+
realistic and edge cases (empty/all-NaN, both backends, both dtype
42+
branches) before trusting a rewrite — logic mistakes here are easy to make
43+
and easy to miss without an actual comparison.
44+
45+
## Tests
46+
47+
- `pytest.raises(ExceptionType, match=msg)`, never
48+
`with pytest.raises() as record: ... assert str(record.value) == msg`.
49+
50+
## API changes
51+
52+
- New parameters default to preserve current behavior.
53+
- When adding a parameter to a function called from multiple sites (or a
54+
shared private helper), thread it through every call site, not just the
55+
one you're looking at.

feature_engine/variable_handling/_variable_type_checks.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import warnings
12
from datetime import date, datetime
23

34
import narwhals as nw
@@ -31,18 +32,35 @@ def _is_convertible_to_num(s: "nw.Series") -> bool:
3132
if len(values) == 0:
3233
return False
3334
try:
34-
for value in values:
35+
for value in values[:100]:
3536
float(value)
3637
except (ValueError, TypeError):
3738
return False
3839
return True
3940

4041

4142
def _is_convertible_to_dt(s: "nw.Series") -> bool:
42-
values = s.drop_nulls().to_list()
43-
if len(values) == 0:
43+
values = s.drop_nulls()
44+
values_list = values.to_list()
45+
if len(values_list) == 0:
4446
return False
45-
for value in values:
47+
48+
first_value = values_list[0]
49+
if not isinstance(first_value, (date, datetime)):
50+
if _looks_like_date_string(first_value) is False:
51+
return False
52+
53+
# Try the backend's own vectorized parser first (faster).
54+
# Fall back to the per-value check (below) when it fails.
55+
with warnings.catch_warnings():
56+
warnings.simplefilter("ignore", UserWarning)
57+
try:
58+
values.str.to_datetime()
59+
return True
60+
except Exception:
61+
pass
62+
63+
for value in values_list[:100]:
4664
if isinstance(value, (date, datetime)):
4765
continue
4866
if _looks_like_date_string(value) is False:

feature_engine/variable_handling/dtypes.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

feature_engine/variable_handling/find_variables.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def _find_nw_categoricals(
3434
| nw.selectors.string()
3535
| nw.selectors.by_dtype(nw.Object)
3636
)
37-
# `|`-combined selectors don't preserve the dataframe's column order,
37+
# `|`-combined selectors don't preserve column order,
3838
# so re-filter over nw_X.columns to restore it.
3939
matched = set(nw_X.select(_NW_SELECTOR).columns)
4040
candidates = [column for column in nw_X.columns if column in matched]

feature_engine/variable_handling/retain_variables.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import List, Union
44

55
import narwhals as nw
6+
import narwhals.dependencies as nwd
67
from narwhals.typing import IntoDataFrame
78

89
Variables = Union[int, str, List[Union[str, int]]]
@@ -43,7 +44,10 @@ def retain_variables_if_in_df(X: IntoDataFrame, variables):
4344
if isinstance(variables, (str, int)):
4445
variables = [variables]
4546

46-
columns = nw.from_native(X, eager_only=True).columns
47+
if nwd.is_pandas_dataframe(X) is True:
48+
columns = set(X.columns)
49+
else:
50+
columns = set(nw.from_native(X, eager_only=True).columns)
4751
variables_in_df = [var for var in variables if var in columns]
4852

4953
# Raise an error if no column is left to work with.

tests/test_variable_handling/conftest.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,14 @@ def cast_categorical(df, columns):
1717
return df.with_columns([pl.col(c).cast(pl.Categorical) for c in columns])
1818

1919

20-
# Data shared between the pandas and polars variants of a test. Kept as plain
21-
# dicts/lists (not fixtures) so a test can build both `make_df(BASIC_DATA)` and
22-
# `make_df(BASIC_DATA)` for a different backend without needing to convert
23-
# between frame types.
20+
# Data shared between the pandas and polars variants of a test.
2421
BASIC_DATA = {
2522
"Name": ["tom", "nick", "krish", "jack"],
2623
"City": ["London", "Manchester", "Liverpool", "Bristol"],
2724
"Age": [20, 21, 19, 18],
2825
"Marks": [0.9, 0.8, 0.7, 0.6],
2926
}
3027

31-
# Datetime formats that both pandas and polars auto-detect: native
32-
# Datetime/Date columns and ISO-8601 strings. Formats that only pandas'
33-
# flexible, dateutil-backed guessing can parse (e.g. "01-Jan-2010",
34-
# "10/11/12", bare time strings) are exercised separately, in pandas-only
35-
# tests, against the `df_datetime` fixture below.
3628
DATETIME_DATA = {
3729
**BASIC_DATA,
3830
"date_range": [datetime(2020, 2, 24, 0, i) for i in range(4)],

tests/test_variable_handling/test_check_variables.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ def test_check_datetime_variables_returns_datetime_variables(make_df):
129129
assert check_datetime_variables(df, vars_dt) == vars_dt
130130
assert check_datetime_variables(df, tz_time) == [tz_time]
131131

132-
# only the string column can be cast to categorical - native Datetime
132+
# only the string column can be cast to categorical. Native Datetime
133133
# columns can't be cast to Categorical in polars
134134
df = cast_categorical(df, ["date_obj0"])
135135
assert check_datetime_variables(df, "date_obj0") == ["date_obj0"]

tests/test_variable_handling/test_find_variables.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def test_numerical_variables_finds_variables(make_df):
2525

2626

2727
def test_numerical_variables_finds_variables_with_int_column_names(df_int):
28-
# polars requires string column names, so int-named columns are pandas-only
28+
# polars requires string column names. int-named columns are pandas-only
2929
assert find_numerical_variables(df_int) == [3, 4]
3030

3131

@@ -336,11 +336,8 @@ def test_numcat_vars_as_category(make_df):
336336

337337
@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame])
338338
def test_numcat_agrees_with_find_categorical_on_date_like_category(make_df):
339-
# Regression test: the single-variable branch of
340-
# find_categorical_and_numerical_variables used to short-circuit past the
341-
# datetime check for Categorical-dtype columns, so it disagreed with
342-
# find_categorical_variables on a category column holding date-like
343-
# strings. All three calls below must now exclude it consistently.
339+
# Regression test: the single-variable path used to disagree with
340+
# find_categorical_variables on a date-like category column.
344341
df = make_df({"date_cat": DATETIME_DATA["date_obj0"], "num": BASIC_DATA["Age"]})
345342
df = cast_categorical(df, ["date_cat"])
346343

tests/test_variable_handling/test_remove_variables.py renamed to tests/test_variable_handling/test_retain_variables.py

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,46 +4,36 @@
44

55
from feature_engine.variable_handling.retain_variables import retain_variables_if_in_df
66

7-
8-
def make_empty_df(is_pandas: bool, columns):
9-
if is_pandas:
10-
return pd.DataFrame(columns=columns)
11-
return pl.DataFrame(schema=columns)
12-
13-
147
test_dict = [
158
(["A", "C", "B", "G", "H"], ["A", "C", "B"], ["X", "Y"]),
169
("C", ["C"], "G"),
1710
]
1811

1912

20-
@pytest.mark.parametrize("is_pandas", [True, False])
13+
@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame])
2114
@pytest.mark.parametrize("variables, overlap, col_not_in_df", test_dict)
22-
def test_retain_variables_if_in_df(is_pandas, variables, overlap, col_not_in_df):
23-
df = make_empty_df(is_pandas, ["A", "B", "C", "D", "E"])
15+
def test_retain_variables_if_in_df(make_df, variables, overlap, col_not_in_df):
16+
df = make_df({"A": [1], "B": [1], "C": [1], "D": [1], "E": [1]})
2417

2518
msg = "None of the variables in the list are present in the dataframe."
2619

2720
assert retain_variables_if_in_df(df, variables) == overlap
2821

29-
with pytest.raises(ValueError) as record:
22+
with pytest.raises(ValueError, match=msg):
3023
retain_variables_if_in_df(df, col_not_in_df)
31-
assert str(record.value) == msg
3224

3325

3426
def test_retain_variables_if_in_df_int_column_names():
35-
# polars requires string column names, so int-named columns are pandas-only
36-
df = pd.DataFrame(columns=[1, 2, 3, 4, 5])
27+
# polars requires string column names. int-named columns are pandas-only
28+
df = pd.DataFrame({1: [1], 2: [1], 3: [1], 4: [1], 5: [1]})
3729

3830
msg = "None of the variables in the list are present in the dataframe."
3931

4032
assert retain_variables_if_in_df(df, [1, 2, 4, 6]) == [1, 2, 4]
4133
assert retain_variables_if_in_df(df, 1) == [1]
4234

43-
with pytest.raises(ValueError) as record:
35+
with pytest.raises(ValueError, match=msg):
4436
retain_variables_if_in_df(df, [6, 7])
45-
assert str(record.value) == msg
4637

47-
with pytest.raises(ValueError) as record:
38+
with pytest.raises(ValueError, match=msg):
4839
retain_variables_if_in_df(df, 7)
49-
assert str(record.value) == msg

0 commit comments

Comments
 (0)