Skip to content

fix(transformers): record and check DataFrame feature names - #135

Open
ChrisW09 wants to merge 1 commit into
mainfrom
fix/85-transformer-feature-names
Open

ChrisW09 wants to merge 1 commit into
mainfrom
fix/85-transformer-feature-names

Conversation

@ChrisW09

@ChrisW09 ChrisW09 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #85.

The transformers exported from pretab.transformers did not record feature_names_in_ when fitted on a pandas DataFrame. A frame with reordered or renamed columns at transform was processed by position, so a feature silently went through another column's fitted basis. Output names were x0_..., and PLE, numeric binning and the tensor-product spline accepted an input_features of the wrong length.

Root cause

The shared validator validate_2d_allow_nan (used by every BasePreTabTransformer), the PLE and binning validation, and the np.asarray conversion of the other exported transformers never called scikit-learn's feature-name check. Each get_feature_names_out built x{i} names itself, and only the BasePreTabTransformer version checked the length of input_features.

Fix

  • validate_2d_allow_nan, PLETransformer, NumericBinningTransformer._check_array, ContinuousOrdinalTransformer, OneHotFromOrdinalTransformer, ToFloatTransformer, NoTransformer, MissingStateIndicator and LanguageEmbeddingTransformer call scikit-learn's _check_feature_names: at fit it records feature_names_in_ (or drops it for an array), and at transform renamed or reordered columns raise and input without names warns. This covers every class exported from pretab.transformers.
  • The new resolve_input_features (pretab/core/validation.py) returns the names that output names are built from. Explicit input_features need one entry per input feature and, after a DataFrame fit, must equal feature_names_in_. None uses feature_names_in_, else x0, x1, .... BasePreTabTransformer, PLE, binning, the tensor-product spline and the two ordinal-based encoders use it. ToFloatTransformer, NoTransformer, MissingStateIndicator and LanguageEmbeddingTransformer use scikit-learn's _check_feature_names_in instead, so pretab.preprocessing and pretab.embedding keep importing nothing from pretab.core.
  • OneHotFromOrdinalTransformer.get_feature_names_out checks that the transformer is fitted and accepts input_features=None, which raised TypeError before.
  • The binning feature-count error uses scikit-learn's wording.

On main, other code relied on get_feature_names_out accepting any names, so the stricter check needs these changes as well:

  • RepresentationSpecMixin.get_representation_spec resolves its input names with resolve_input_features instead of passing x0, x1, ..., which a DataFrame-fitted transformer now rejects.
  • _representation_summary (pretab/compose/serialize.py) passes each block's column names to the spec and no longer swallows spec errors. With the strict names alone it would have silently dropped every representation fitted directly on its column.
  • ToFloatTransformer and NoTransformer read the width of their input from np.shape. The one-hot encoder hands them a sparse matrix, which np.asarray turned into a 0-d object array, so one input feature was recorded instead of the encoder's width; the strict get_feature_names_out exposed this in every categorical_method="one-hot" preprocessor.
  • Block-width rule: in _representation_summary and in the feature lineage (_resolve_block_representation in pretab/compose/inspection.py), a step fitted on more inputs than its block has columns names its own inputs. On main this is the representation behind the imputer's built-in missing indicator (add_missing_indicator=True), which receives the indicator as a second input. Without the rule, the lineage of such a block would newly raise for PLE and binning.
  • build_feature_info probes a step fitted on the DataFrame column with a one-row frame that carries the step's feature_names_in_ (_probe_input), so get_feature_info() does not warn about missing feature names.

Behaviour changes

  • Standalone transformers fitted on a DataFrame with string column names set feature_names_in_. Output names and set_output(transform="pandas") columns use the column names (age_rbf0, age__missing). Renamed or reordered columns at transform raise ValueError, an array at transform warns that X does not have valid feature names, and a refit on an array removes feature_names_in_.
  • get_feature_names_out(input_features) raises for a wrong length (InvalidParamError where resolve_input_features is used, scikit-learn's ValueError in the four classes that use _check_feature_names_in) and raises ValueError for names that differ from feature_names_in_.
  • The NumericBinningTransformer feature-count error (still PretabDataError) reads "X has N features, but NumericBinningTransformer is expecting M features as input."
  • Preprocessor: output names and transform output are unchanged (the golden baselines pass unchanged). Entries in to_spec()["representations"] name their inputs and outputs after the block's column (input_features: ["age"], age_rbf0) instead of x0 / x0_rbf0, and a spec entry that fails to build raises from to_spec() instead of being dropped.
  • get_feature_info() no longer warns that X does not have valid feature names for a step fitted on the DataFrame column (on main, a scikit-learn scaler fitted there warned).
  • get_feature_lineage() with add_missing_indicator=True works for RBF, B-spline and the other BasePreTabTransformer representations; it raised on main.
  • fingerprint_ changes for preprocessors with a PreTab step fitted directly on the DataFrame (no imputer and no scaler in front), which now stores feature_names_in_, and for categorical_method="one-hot" preprocessors, whose ToFloatTransformer now records the encoder's width. The default preprocessor's fingerprint is unchanged (compared with main).

Documentation

  • docs/representations/spline_expansions.md ("The idea"): output names follow the DataFrame column (age_bs0; x0_bs0 for array input).
  • docs/tutorials/custom_representation.md ("Subclass BaseRepresentation"): self._validate records and checks feature_names_in_, and the output names use them.
  • Docstrings: BasePreTabTransformer (DataFrame contract); every changed get_feature_names_out (Parameters, Returns, Raises); feature_names_in_ in the Attributes of every class that documents n_features_in_ (n_features_in_ added for the two ordinal-based encoders); validate_2d_allow_nan (reset); RepresentationSpecMixin.get_representation_spec (input_features); _representation_summary; the new resolve_input_features and _probe_input.

Tests

  • tests/transformers/test_feature_names_in.py (new), parametrized over all 24 exported transformers (LanguageEmbeddingTransformer with a stub model, so the optional dependency is not needed). 154 of its 170 cases fail on main.
    • test_dataframe_fit_records_feature_names_and_uses_them, test_reordered_columns_are_rejected, test_renamed_columns_are_rejected, test_array_after_a_dataframe_fit_warns_like_scikit_learn, test_input_features_are_validated: the contract from the issue. Fails on main, passes here.
    • test_scikit_learn_feature_name_checks_pass: scikit-learn's check_dataframe_column_names_consistency and check_transformer_get_feature_names_out_pandas, for all but PeriodicEncodingTransformer (the checks feed data outside its required [0, period] range; the other tests cover it). Fails on main, passes here.
    • test_sparse_input_records_its_width[ToFloatTransformer|NoTransformer]: a sparse input records its real width. Fails on main, passes here.
    • test_array_fit_keeps_generic_names_and_drops_stale_feature_names: after a refit on an array, the names are x0, ... and a wrong-length input_features raises. Fails on main for the 9 transformers without a length check; a guard for the 15 BasePreTabTransformer subclasses, which pass on both.
    • test_cases_cover_every_exported_transformer: the cases cover every exported class. Guard test: passes on both by design.
  • tests/core/test_representation_spec.py::test_spec_after_a_dataframe_fit_follows_get_feature_names_out (20 exported representations): the default spec after a DataFrame fit uses feature_names_in_ and matches get_feature_names_out. Guard test: passes on both by design; fails with the transformer changes alone, without the get_representation_spec change.
  • tests/core/test_representation_spec.py::test_cases_cover_every_exported_representation: the cases above cover every exported representation. Guard test: passes on both by design.
  • tests/extension/test_extension_protocol.py::test_representation_spec_after_a_dataframe_fit_names_the_columns: a BaseRepresentation subclass's spec names the frame's columns. Fails on main, passes here.
  • tests/integration/test_serialization.py::test_representation_summary_names_each_block_after_its_column[imputed|frame]: summary entries named after the block's column, for a leaf behind the imputer and for a leaf fitted on the frame. Fails on main, passes here.
  • tests/integration/test_serialization.py::test_representation_summary_of_a_leaf_behind_the_imputer_indicator: with add_missing_indicator=True, the two-input leaf names its own inputs, and to_spec(), reproducibility_report() and fingerprint_ work. Fails on main, passes here.
  • tests/integration/test_feature_lineage.py::test_lineage_of_a_representation_behind_the_imputer_indicator[rbf|bspline]: lineage with add_missing_indicator=True. Fails on main, passes here. The [ple] case passes on main and fails with the transformer changes alone.
  • tests/compose/test_inspection.py::test_build_feature_info_probes_a_step_fitted_on_the_frame_without_warning[standardization]: build_feature_info raises no warning. Fails on main, passes here. The [rbf] case passes on main and fails with the transformer changes alone.

Reproduction

Output of the script from #85 on this branch:

RBFExpansionTransformer | feature_names_in_: ['age' 'income'] | names: ['age_rbf0', 'age_rbf1']
   reordered columns: The feature names should match those that were passed during fit.
PLETransformer | feature_names_in_: ['age' 'income'] | names: [np.str_('age_ple0'), np.str_('age_ple1')]
   reordered columns: The feature names should match those that were passed during fit.
SplineTransformer | feature_names_in_: ['age' 'income'] | names: ['age_sp_0', 'age_sp_1']
   reordered columns: The feature names should match those that were passed during fit.

(PLETransformer.fit still emits its LeakageWarning for fitting on (X, y) outside a pipeline.)

Checks

  • just test: 1638 passed, 59 skipped, 7 xfailed; coverage 93.76% (gate 90%).
  • Minimum dependencies (CI job: Python 3.10, numpy 1.24.4, pandas 2.0.3, scipy 1.10.1, scikit-learn 1.6.0): 1625 passed, 72 skipped, 7 xfailed; scripts/quickstart.py passes.
  • just docs: strict build succeeds.
  • just check: all pre-commit and pre-push hooks pass (ruff format, ruff lint, prettier, file hygiene, pyright with 0 errors).
  • Import direction between subpackages is unchanged.

Merge notes

This change was previously part of #86, which is closed in favour of one pull request per issue.

The standalone transformers validated input with check_array or np.asarray
only, so fitting on a DataFrame recorded no feature_names_in_: a reordered or
renamed frame was processed by position (silently wrong features), output
names were x0_..., and PLE / binning / tensor-product get_feature_names_out
accepted an input_features of the wrong length (too few names, or an
IndexError). This covered the feature maps, PLE, numeric binning, periodic
encoding, the splines, Fourier features and the kernel approximations, and
the other exported transformers: ContinuousOrdinalTransformer,
OneHotFromOrdinalTransformer, ToFloatTransformer, NoTransformer,
MissingStateIndicator and LanguageEmbeddingTransformer.

The shared validator, the PLE and binning validation and the others now
record feature_names_in_ at fit and check the column names at transform via
scikit-learn's _check_feature_names (renamed or reordered columns raise,
missing names warn). get_feature_names_out() resolves its prefixes from
feature_names_in_ (else x0, x1, ...) and validates input_features. The binning
feature-count error now uses scikit-learn's message. ToFloatTransformer and
NoTransformer read the width of sparse input (the one-hot encoder's output)
from its shape instead of recording a single feature.

With strict input_features, get_representation_spec() could no longer build
x0, x1, ... itself and pass them to get_feature_names_out: it would raise for
every transformer fitted on a DataFrame, and the Preprocessor's spec summary
would swallow the error, so to_spec() and reproducibility_report() would list
no representation fitted directly on its column (no imputer or scaler). The
spec now resolves its input names like get_feature_names_out (explicit names
validated, else feature_names_in_, else x0, x1, ...). The summary passes each
block's column names, so its entries read age_rbf0 instead of x0_rbf0, and no
longer hides spec errors. The summary and the feature lineage let a step
fitted on more inputs than its block has columns (behind the imputer's
built-in missing indicator) name its own inputs, so the lineage of such a
block no longer raises. get_feature_info() probes a step with a frame carrying
its fitted names, so it no longer warns that X has no valid feature names.

Docstrings (get_feature_names_out, feature_names_in_ attributes, the base
transformer), the spline expansion guide and the custom representation
tutorial describe the DataFrame behaviour.

Fixes #85

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Standalone transformers drop DataFrame feature names, so reordered columns are silently misaligned

1 participant