Repository navigation
Conversation
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 was referenced Oct 16, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #85.
The transformers exported from
pretab.transformersdid not recordfeature_names_in_when fitted on a pandas DataFrame. A frame with reordered or renamed columns attransformwas processed by position, so a feature silently went through another column's fitted basis. Output names werex0_..., and PLE, numeric binning and the tensor-product spline accepted aninput_featuresof the wrong length.Root cause
The shared validator
validate_2d_allow_nan(used by everyBasePreTabTransformer), the PLE and binning validation, and thenp.asarrayconversion of the other exported transformers never called scikit-learn's feature-name check. Eachget_feature_names_outbuiltx{i}names itself, and only theBasePreTabTransformerversion checked the length ofinput_features.Fix
validate_2d_allow_nan,PLETransformer,NumericBinningTransformer._check_array,ContinuousOrdinalTransformer,OneHotFromOrdinalTransformer,ToFloatTransformer,NoTransformer,MissingStateIndicatorandLanguageEmbeddingTransformercall scikit-learn's_check_feature_names: atfitit recordsfeature_names_in_(or drops it for an array), and attransformrenamed or reordered columns raise and input without names warns. This covers every class exported frompretab.transformers.resolve_input_features(pretab/core/validation.py) returns the names that output names are built from. Explicitinput_featuresneed one entry per input feature and, after a DataFrame fit, must equalfeature_names_in_.Noneusesfeature_names_in_, elsex0, x1, ....BasePreTabTransformer, PLE, binning, the tensor-product spline and the two ordinal-based encoders use it.ToFloatTransformer,NoTransformer,MissingStateIndicatorandLanguageEmbeddingTransformeruse scikit-learn's_check_feature_names_ininstead, sopretab.preprocessingandpretab.embeddingkeep importing nothing frompretab.core.OneHotFromOrdinalTransformer.get_feature_names_outchecks that the transformer is fitted and acceptsinput_features=None, which raisedTypeErrorbefore.On
main, other code relied onget_feature_names_outaccepting any names, so the stricter check needs these changes as well:RepresentationSpecMixin.get_representation_specresolves its input names withresolve_input_featuresinstead of passingx0, 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.ToFloatTransformerandNoTransformerread the width of their input fromnp.shape. The one-hot encoder hands them a sparse matrix, whichnp.asarrayturned into a 0-d object array, so one input feature was recorded instead of the encoder's width; the strictget_feature_names_outexposed this in everycategorical_method="one-hot"preprocessor._representation_summaryand in the feature lineage (_resolve_block_representationinpretab/compose/inspection.py), a step fitted on more inputs than its block has columns names its own inputs. Onmainthis 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_infoprobes a step fitted on the DataFrame column with a one-row frame that carries the step'sfeature_names_in_(_probe_input), soget_feature_info()does not warn about missing feature names.Behaviour changes
feature_names_in_. Output names andset_output(transform="pandas")columns use the column names (age_rbf0,age__missing). Renamed or reordered columns attransformraiseValueError, an array attransformwarns that X does not have valid feature names, and a refit on an array removesfeature_names_in_.get_feature_names_out(input_features)raises for a wrong length (InvalidParamErrorwhereresolve_input_featuresis used, scikit-learn'sValueErrorin the four classes that use_check_feature_names_in) and raisesValueErrorfor names that differ fromfeature_names_in_.NumericBinningTransformerfeature-count error (stillPretabDataError) reads "X has N features, but NumericBinningTransformer is expecting M features as input."Preprocessor: output names andtransformoutput are unchanged (the golden baselines pass unchanged). Entries into_spec()["representations"]name their inputs and outputs after the block's column (input_features: ["age"],age_rbf0) instead ofx0/x0_rbf0, and a spec entry that fails to build raises fromto_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 (onmain, a scikit-learn scaler fitted there warned).get_feature_lineage()withadd_missing_indicator=Trueworks for RBF, B-spline and the otherBasePreTabTransformerrepresentations; it raised onmain.fingerprint_changes for preprocessors with a PreTab step fitted directly on the DataFrame (no imputer and no scaler in front), which now storesfeature_names_in_, and forcategorical_method="one-hot"preprocessors, whoseToFloatTransformernow records the encoder's width. The default preprocessor's fingerprint is unchanged (compared withmain).Documentation
docs/representations/spline_expansions.md("The idea"): output names follow the DataFrame column (age_bs0;x0_bs0for array input).docs/tutorials/custom_representation.md("SubclassBaseRepresentation"):self._validaterecords and checksfeature_names_in_, and the output names use them.BasePreTabTransformer(DataFrame contract); every changedget_feature_names_out(Parameters, Returns, Raises);feature_names_in_in the Attributes of every class that documentsn_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 newresolve_input_featuresand_probe_input.Tests
tests/transformers/test_feature_names_in.py(new), parametrized over all 24 exported transformers (LanguageEmbeddingTransformerwith a stub model, so the optional dependency is not needed). 154 of its 170 cases fail onmain.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 onmain, passes here.test_scikit_learn_feature_name_checks_pass: scikit-learn'scheck_dataframe_column_names_consistencyandcheck_transformer_get_feature_names_out_pandas, for all butPeriodicEncodingTransformer(the checks feed data outside its required[0, period]range; the other tests cover it). Fails onmain, passes here.test_sparse_input_records_its_width[ToFloatTransformer|NoTransformer]: a sparse input records its real width. Fails onmain, passes here.test_array_fit_keeps_generic_names_and_drops_stale_feature_names: after a refit on an array, the names arex0, ...and a wrong-lengthinput_featuresraises. Fails onmainfor the 9 transformers without a length check; a guard for the 15BasePreTabTransformersubclasses, 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 usesfeature_names_in_and matchesget_feature_names_out. Guard test: passes on both by design; fails with the transformer changes alone, without theget_representation_specchange.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: aBaseRepresentationsubclass's spec names the frame's columns. Fails onmain, 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 onmain, passes here.tests/integration/test_serialization.py::test_representation_summary_of_a_leaf_behind_the_imputer_indicator: withadd_missing_indicator=True, the two-input leaf names its own inputs, andto_spec(),reproducibility_report()andfingerprint_work. Fails onmain, passes here.tests/integration/test_feature_lineage.py::test_lineage_of_a_representation_behind_the_imputer_indicator[rbf|bspline]: lineage withadd_missing_indicator=True. Fails onmain, passes here. The[ple]case passes onmainand 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_inforaises no warning. Fails onmain, passes here. The[rbf]case passes onmainand fails with the transformer changes alone.Reproduction
Output of the script from #85 on this branch:
(
PLETransformer.fitstill emits itsLeakageWarningfor fitting on(X, y)outside a pipeline.)Checks
just test: 1638 passed, 59 skipped, 7 xfailed; coverage 93.76% (gate 90%).scripts/quickstart.pypasses.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).Merge notes
Base:
main.The PR for add_missing_indicator / impute_with_indicator feeds the indicator through the scaler and basis; lineage crashes #62 (fix(preprocessor): keep the missing indicator out of the representation #106) conflicts in
pretab/compose/inspection.py, where it addsrepresentation_leafat the place this PR adds_probe_input(keep both), and in the docstring of_representation_summary(keep this PR's docstring and add add_missing_indicator / impute_with_indicator feeds the indicator through the scaler and basis; lineage crashes #62's sentence that the leaf of a missing-indicator / separate-state block is the last step of its representation branch). The loop body merges cleanly intoleaf = representation_leaf(transformer)followed by this PR's block-width rule. With add_missing_indicator / impute_with_indicator feeds the indicator through the scaler and basis; lineage crashes #62 the representation no longer receives the indicator, so the rule no longer applies toPreprocessorblocks, and two tests here that pinmain's structure must be updated: intest_lineage_of_a_representation_behind_the_imputer_indicator, the families become{family, "missing_state"}, and intest_representation_summary_of_a_leaf_behind_the_imputer_indicator,entries["a"]["input_features"]becomes["a"]. With these changes, the full suite passes (1659 passed, 59 skipped, 7 xfailed).The PR for CrossFittedTransformer(Preprocessor) relearns column types and category codes per fold, unlike transform #79 (fix(cross-fitting): refit only target-aware Preprocessor blocks per fold #117) lets
_resolve_block_representationtakecolumns=None, and conflicts on the same lines as this PR's block-width rule. Combine both:fits_block = columns is not None and getattr(transformer, "n_features_in_", len(columns)) == len(columns), theninput_features=list(columns) if fits_block else None. The PR for CrossFittedTransformer(Preprocessor(add_missing_indicator=True)) crashes when a fold's training rows contain no missing value #89 (fix(cross-fitting): keep missing indicators of a wrapped Preprocessor #124), stacked on CrossFittedTransformer(Preprocessor) relearns column types and category codes per fold, unlike transform #79, moves this code into_locate_block_representation, which returnsindex, (...); apply the same two lines there and keep both sides of the import conflict (import pandas as pd,from sklearn.base import clone,from sklearn.pipeline import FeatureUnion, Pipeline). Resolved this way, the full suite passes with CrossFittedTransformer(Preprocessor) relearns column types and category codes per fold, unlike transform #79 (1652 passed, 59 skipped, 7 xfailed) and with CrossFittedTransformer(Preprocessor(add_missing_indicator=True)) crashes when a fold's training rows contain no missing value #89 (1661 passed, 59 skipped, 8 xfailed).Conflicts in import lists and appended tests, described in the other PRs: Preprocessor.get_feature_names_out(input_features) fails after fitting on an ndarray, breaking Pipeline.get_feature_names_out() #65 (
pretab/compose/inspection.pyimports: keepimport pandas as pdandfrom sklearn.pipeline import FeatureUnion, Pipeline; its_without_feature_namesalso strips thefeature_names_in_that PreTab steps now record), A 2-D y (multi-output target) crashes the B/M/I splines even when unsupervised, and target-aware methods with unrelated errors #88 (pretab/core/validation.pyimports and__all__, and thecore.validationimport inple.py), Preprocessor rejects polars DataFrames, so pipelines with set_output(transform="polars") crash #94 (pretab/core/validation.pyimports,__all__and the new functions) and Preprocessor crashes on an all-missing column at fit (numerical, one-hot) or silently drops it (int) #95 (tests/compose/test_inspection.py, both add a test at the same place). Keep both sides in each.Follow-up with CrossFittedTransformer converts DataFrames to ndarray; a wrapped Preprocessor treats every column as categorical #58 and Preprocessor.get_feature_names_out(input_features) fails after fitting on an ndarray, breaking Pipeline.get_feature_names_out() #65: once the PRs for CrossFittedTransformer converts DataFrames to ndarray; a wrapped Preprocessor treats every column as categorical #58 (fix(cross-fitting): pass DataFrames to the wrapped transformer unchanged #103) (cross-fitting passes DataFrames to the wrapped estimator), Preprocessor.get_feature_names_out(input_features) fails after fitting on an ndarray, breaking Pipeline.get_feature_names_out() #65 (
Preprocessorrecords and validatesfeature_names_in_) and this one have all landed,CrossFittedTransformer(Preprocessor(...)).fit(X, y).get_representation_spec()on a DataFrame raisesValueError: input_features is not equal to feature_names_in_, because the fallback spec passesx0, x1, ...to the wrappedPreprocessor. No existing test catches this: with the three branches merged, the suite passes. Onmainthe fix would be dead code, so it is not in this PR. Whichever of the three lands last should add, inCrossFittedTransformer.get_representation_spec(pretab/core/supervised.py), just before the fallbackreturn super().get_representation_spec(input_features):together with tests for a cross-fitted
PLETransformerand a cross-fittedPreprocessorfitted on a DataFrame (both were in fix: resolve 10 high-priority bugs found in a library audit (#76-#85) #86). With this change on the three merged branches, the spec names the columns (('num', 'city')) and the full suite passes.The PRs for to_spec / fingerprint_ fail or write unloadable specs for object arrays of numpy scalars and builtin dtypes #66 (fix(serialization): encode object arrays and builtin dtypes in specs #132) and Default to_spec() files contain bare NaN tokens and are not valid JSON for strict parsers #80 (fix(serialization): write specs as strict JSON without NaN tokens #134) change other parts of
pretab/compose/serialize.py; git merges both cleanly with this branch.This change was previously part of #86, which is closed in favour of one pull request per issue.