Skip to content

Commit 01fe5a5

Browse files
fix: improve feature name resolution (#175)
* fix: improve feature name resolution * chore: bump version to 1.2.2 * chore: reduce example iterations
1 parent 58ef52b commit 01fe5a5

8 files changed

Lines changed: 176 additions & 20 deletions

File tree

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ run-titanic: build
276276
--user-id dev_user \
277277
--intent "predict whether a passenger was transported" \
278278
--experiment-id titanic \
279-
--max-iterations 10 \
279+
--max-iterations 5 \
280280
--work-dir /workdir/titanic/$(TIMESTAMP) \
281281
--spark-mode local \
282282
--enable-final-evaluation
@@ -302,7 +302,7 @@ run-house-prices: build
302302
--user-id dev_user \
303303
--intent "predict house sale price" \
304304
--experiment-id house_prices \
305-
--max-iterations 10 \
305+
--max-iterations 5 \
306306
--work-dir /workdir/house_prices/$(TIMESTAMP) \
307307
--spark-mode local \
308308
--enable-final-evaluation

plexe/CODE_INDEX.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Code Index: plexe
22

3-
> Generated on 2026-02-26 15:06:43
3+
> Generated on 2026-02-26 19:02:04
44
55
Code structure and public interface documentation for the **plexe** package.
66

plexe/templates/features/pipeline_runner.py

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import logging
1010
from collections.abc import Iterator
1111
from textwrap import shorten
12-
from typing import TYPE_CHECKING
12+
from typing import TYPE_CHECKING, Any
1313

1414
import cloudpickle
1515
import pandas as pd
@@ -21,6 +21,61 @@
2121
logger = logging.getLogger(__name__)
2222

2323

24+
def _generic_feature_names(num_output_features: int) -> list[str]:
25+
return [f"feature_{i}" for i in range(num_output_features)]
26+
27+
28+
def _call_get_feature_names_out(transformer: Any, feature_columns: list[str]) -> list[str]:
29+
try:
30+
names = transformer.get_feature_names_out(input_features=feature_columns)
31+
except TypeError:
32+
names = transformer.get_feature_names_out()
33+
return list(names)
34+
35+
36+
def _try_get_feature_names_out(
37+
fitted_pipeline: Pipeline, feature_columns: list[str]
38+
) -> tuple[list[str] | None, str | None]:
39+
strategies: list[tuple[str, Any]] = []
40+
41+
if hasattr(fitted_pipeline, "get_feature_names_out"):
42+
strategies.append(("pipeline.get_feature_names_out()", fitted_pipeline))
43+
44+
if isinstance(fitted_pipeline, Pipeline) and fitted_pipeline.steps:
45+
last_step_name, last_step = fitted_pipeline.steps[-1]
46+
47+
if not hasattr(last_step, "get_feature_names_out") and len(fitted_pipeline.steps) > 1:
48+
strategies.append(
49+
(f"pipeline[:-1].get_feature_names_out() (skipped {last_step_name})", fitted_pipeline[:-1])
50+
)
51+
52+
if hasattr(last_step, "get_feature_names_out"):
53+
strategies.append((f"{last_step_name}.get_feature_names_out()", last_step))
54+
55+
for label, transformer in strategies:
56+
try:
57+
names = _call_get_feature_names_out(transformer, feature_columns)
58+
except Exception:
59+
continue
60+
return names, label
61+
62+
return None, None
63+
64+
65+
def _resolve_transformed_feature_names(
66+
fitted_pipeline: Pipeline, feature_columns: list[str], num_output_features: int
67+
) -> tuple[list[str], str]:
68+
names, source = _try_get_feature_names_out(fitted_pipeline, feature_columns)
69+
70+
if names is None:
71+
return _generic_feature_names(num_output_features), "generic"
72+
73+
if len(names) != num_output_features:
74+
return _generic_feature_names(num_output_features), "generic_mismatch"
75+
76+
return names, source or "pipeline.get_feature_names_out()"
77+
78+
2479
def transform_dataset_via_spark(
2580
spark: SparkSession,
2681
dataset_uri: str,
@@ -101,20 +156,19 @@ def transform_dataset_via_spark(
101156
sample_transformed_features = sample_transformed
102157
else:
103158
# Pipeline returned numpy array - need to assign column names
104-
try:
105-
# Try sklearn's get_feature_names_out() (sklearn 1.0+)
106-
if hasattr(fitted_pipeline, "get_feature_names_out"):
107-
transformed_feature_cols = fitted_pipeline.get_feature_names_out(
108-
input_features=feature_columns
109-
).tolist()
110-
logger.info("Using feature names from pipeline.get_feature_names_out()")
111-
else:
112-
raise AttributeError("No get_feature_names_out method")
113-
except Exception as e:
114-
# Fallback to generic names
115-
num_output_features = sample_transformed.shape[1]
116-
transformed_feature_cols = [f"feature_{i}" for i in range(num_output_features)]
117-
logger.warning(f"Using generic feature names ({e})")
159+
num_output_features = sample_transformed.shape[1]
160+
transformed_feature_cols, feature_name_source = _resolve_transformed_feature_names(
161+
fitted_pipeline=fitted_pipeline,
162+
feature_columns=feature_columns,
163+
num_output_features=num_output_features,
164+
)
165+
166+
if feature_name_source == "generic":
167+
logger.debug("Falling back to generic feature names (pipeline has no get_feature_names_out).")
168+
elif feature_name_source == "generic_mismatch":
169+
logger.debug("Falling back to generic feature names (pipeline feature count mismatch).")
170+
else:
171+
logger.info("Using feature names from %s", feature_name_source)
118172

119173
sample_transformed_features = pd.DataFrame(sample_transformed, columns=transformed_feature_cols)
120174

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "plexe"
3-
version = "1.2.1"
3+
version = "1.2.2"
44
description = "An agentic framework for building ML models from natural language"
55
authors = [
66
"Marcello De Bernardi <mdebernardi@plexe.ai>",

tests/CODE_INDEX.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Code Index: tests
22

3-
> Generated on 2026-02-26 15:06:43
3+
> Generated on 2026-02-26 19:02:04
44
55
Test suite structure and test case documentation.
66

@@ -49,6 +49,23 @@ Unit tests for SearchJournal.
4949
- `test_journal_improvement_trend_improving()` - Test improvement trend with steadily improving solutions.
5050
- `test_journal_improvement_trend_insufficient_data()` - Test improvement trend with fewer than 2 successful solutions.
5151

52+
---
53+
## `unit/templates/features/test_pipeline_runner.py`
54+
Unit tests for pipeline_runner feature name resolution.
55+
56+
**`NoFeatureNamesTransformer`** - Transformer without get_feature_names_out.
57+
- `fit(self, x, y)` - No description
58+
- `transform(self, x)` - No description
59+
60+
**`SelectFirstColumnTransformer`** - Transformer that reduces output to a single column.
61+
- `fit(self, x, y)` - No description
62+
- `transform(self, x)` - No description
63+
64+
**Functions:**
65+
- `test_resolve_feature_names_uses_pipeline_minus_last()` - Falls back to pipeline[:-1] when last step lacks get_feature_names_out.
66+
- `test_resolve_feature_names_falls_back_on_mismatch()` - Returns generic names when resolved names don't match output count.
67+
- `test_resolve_feature_names_falls_back_when_unavailable()` - Returns generic names when no get_feature_names_out is available.
68+
5269
---
5370
## `unit/test_config.py`
5471
Unit tests for config helpers.

tests/unit/templates/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Unit tests for templates."""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Unit tests for feature templates."""
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""
2+
Unit tests for pipeline_runner feature name resolution.
3+
"""
4+
5+
import pandas as pd
6+
from sklearn.base import BaseEstimator, TransformerMixin
7+
from sklearn.pipeline import Pipeline
8+
from sklearn.preprocessing import StandardScaler
9+
10+
from plexe.templates.features.pipeline_runner import _resolve_transformed_feature_names
11+
12+
13+
class NoFeatureNamesTransformer(BaseEstimator, TransformerMixin):
14+
"""Transformer without get_feature_names_out."""
15+
16+
def fit(self, x, y=None):
17+
return self
18+
19+
def transform(self, x):
20+
return x
21+
22+
23+
class SelectFirstColumnTransformer(BaseEstimator, TransformerMixin):
24+
"""Transformer that reduces output to a single column."""
25+
26+
def fit(self, x, y=None):
27+
return self
28+
29+
def transform(self, x):
30+
if hasattr(x, "iloc"):
31+
return x.iloc[:, [0]]
32+
return x[:, [0]]
33+
34+
35+
def test_resolve_feature_names_uses_pipeline_minus_last():
36+
"""Falls back to pipeline[:-1] when last step lacks get_feature_names_out."""
37+
df = pd.DataFrame({"a": [1.0, 2.0], "b": [3.0, 4.0]})
38+
pipeline = Pipeline([("scale", StandardScaler()), ("noop", NoFeatureNamesTransformer())])
39+
40+
pipeline.fit(df)
41+
42+
names, source = _resolve_transformed_feature_names(
43+
fitted_pipeline=pipeline,
44+
feature_columns=["a", "b"],
45+
num_output_features=2,
46+
)
47+
48+
assert names == ["a", "b"]
49+
assert "pipeline[:-1]" in source
50+
51+
52+
def test_resolve_feature_names_falls_back_on_mismatch():
53+
"""Returns generic names when resolved names don't match output count."""
54+
df = pd.DataFrame({"a": [1.0, 2.0], "b": [3.0, 4.0]})
55+
pipeline = Pipeline([("scale", StandardScaler()), ("select", SelectFirstColumnTransformer())])
56+
57+
pipeline.fit(df)
58+
59+
names, source = _resolve_transformed_feature_names(
60+
fitted_pipeline=pipeline,
61+
feature_columns=["a", "b"],
62+
num_output_features=1,
63+
)
64+
65+
assert names == ["feature_0"]
66+
assert source == "generic_mismatch"
67+
68+
69+
def test_resolve_feature_names_falls_back_when_unavailable():
70+
"""Returns generic names when no get_feature_names_out is available."""
71+
df = pd.DataFrame({"a": [1.0, 2.0], "b": [3.0, 4.0]})
72+
pipeline = Pipeline([("noop", NoFeatureNamesTransformer())])
73+
74+
pipeline.fit(df)
75+
76+
names, source = _resolve_transformed_feature_names(
77+
fitted_pipeline=pipeline,
78+
feature_columns=["a", "b"],
79+
num_output_features=2,
80+
)
81+
82+
assert names == ["feature_0", "feature_1"]
83+
assert source == "generic"

0 commit comments

Comments
 (0)