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
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ object that's already an instance of that module's class.
- Check container emptiness with `len(x) == 0`, never `if not x:`.
- `isinstance(...)` checks and `in`/`not in` membership tests are already
explicit — leave them as-is, this rule isn't about those.
- The explicit `is True`/`is False` comparison is for flow control
(`if`/`while` conditions) only — don't tack it onto a variable
assignment. When a function already returns a strict bool (e.g.
`nwd.is_pandas_dataframe(X)`), assign it directly:
`is_pandas = nwd.is_pandas_dataframe(X)`, not
`is_pandas = nwd.is_pandas_dataframe(X) is True`. The `if`/`while` site
that later consumes `is_pandas` still spells out `if is_pandas is True:`.

## Comments

Expand Down Expand Up @@ -75,6 +82,16 @@ and easy to miss without an actual comparison.

- `pytest.raises(ExceptionType, match=msg)`, never
`with pytest.raises() as record: ... assert str(record.value) == msg`.
- Dataframe-agnostic means one test, both backends: parametrize each
behavior over `@pytest.mark.parametrize("make_df", [pd.DataFrame,
pl.DataFrame])` and assert the same input produces the same output
values on both. Never write a separate pandas-only test and a
separate polars-only test for the same behavior — that duplicates
the test and hides the point of being dataframe-agnostic, which is
that the same input gives the same output regardless of backend.
Keep a test single-backend only when the behavior itself is
backend-specific (e.g. integer column names, which polars doesn't
support; pandas nullable extension dtypes).

## API changes

Expand Down
87 changes: 87 additions & 0 deletions docs/user_guide/creation/DecisionTreeFeatures.rst
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,53 @@ are not there:
2670 1.843904
15709 1.843904

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

:class:`DecisionTreeFeatures()` works in the same way with a polars dataframe:

.. code:: python

import polars as pl
from feature_engine.creation import DecisionTreeFeatures

X = pl.DataFrame({
"Age": [20, 44, 19, 33, 51, 40, 41, 37, 30, 54],
"Height": [164, 150, 178, 158, 188, 190, 168, 174, 176, 171],
})
y = [4.1, 5.8, 3.9, 6.2, 4.3, 4.5, 7.2, 4.4, 4.1, 6.7]

dtf = DecisionTreeFeatures(features_to_combine=2, drop_original=True)
dtf.fit(X, y)

print(dtf.transform(X))

The resulting values match those found with pandas:

.. code:: text

shape: (10, 3)
┌───────────┬──────────────┬─────────────────────────┐
│ tree(Age) ┆ tree(Height) ┆ tree(['Age', 'Height']) │
│ --- ┆ --- ┆ --- │
│ f64 ┆ f64 ┆ f64 │
╞═══════════╪══════════════╪═════════════════════════╡
│ 4.533333 ┆ 5.366667 ┆ 4.1 │
│ 6.0 ┆ 5.366667 ┆ 6.475 │
│ 4.533333 ┆ 4.133333 ┆ 4.0 │
│ 4.533333 ┆ 5.366667 ┆ 6.475 │
│ 6.0 ┆ 4.4 ┆ 4.4 │
│ 4.533333 ┆ 4.4 ┆ 4.4 │
│ 6.0 ┆ 6.95 ┆ 6.475 │
│ 4.533333 ┆ 4.133333 ┆ 4.4 │
│ 4.533333 ┆ 4.133333 ┆ 4.0 │
│ 6.0 ┆ 6.95 ┆ 6.475 │
└───────────┴──────────────┴─────────────────────────┘

`get_feature_names_out()`, classification, and every other parameter shown
above with pandas work identically with polars.


Creating features for classification
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand All @@ -498,6 +545,46 @@ identical. We just need to set the parameter `regression` to False.
classification, on the other hand, the features will contain the prediction of the class.


Training trees in parallel
~~~~~~~~~~~~~~~~~~~~~~~~~~

Each tree is trained on its own feature combination independently of the others, so
when there are many combinations (a large number of variables and/or a high
`features_to_combine`) or a large `param_grid` to search, training can be
parallelized across combinations with the `n_jobs` parameter:

.. code:: python

import pandas as pd
from feature_engine.creation import DecisionTreeFeatures

X = pd.DataFrame({
"Age": [20, 44, 19, 33, 51, 40, 41, 37, 30, 54],
"Height": [164, 150, 178, 158, 188, 190, 168, 174, 176, 171],
"Marks": [1.0, 0.8, 0.6, 0.1, 0.3, 0.4, 0.8, 0.6, 0.5, 0.2],
})
y = [4.1, 5.8, 3.9, 6.2, 4.3, 4.5, 7.2, 4.4, 4.1, 6.7]

dtf = DecisionTreeFeatures(features_to_combine=3, n_jobs=2, random_state=0)
dtf.fit(X, y)

print(dtf.transform(X).columns.tolist())

.. code:: text

['Age', 'Height', 'Marks', 'tree(Age)', 'tree(Height)', 'tree(Marks)',
"tree(['Age', 'Height'])", "tree(['Age', 'Marks'])",
"tree(['Height', 'Marks'])", "tree(['Age', 'Height', 'Marks'])"]

`n_jobs` defaults to `None`, which trains the trees sequentially, matching this
transformer's original behaviour. Setting it trains multiple trees at the same
time using threads, which only pays off once there are enough feature
combinations or a large enough `param_grid` to outweigh the overhead of
dispatching work to threads — with just a handful of combinations, sequential
training is faster. The resulting trees and predictions are identical
regardless of `n_jobs`; only training speed changes.


Additional resources
--------------------

Expand Down
Loading