Skip to content

fix(preprocessor): keep a block for columns with no observed value at fit - #130

Open
ChrisW09 wants to merge 1 commit into
mainfrom
fix/95-all-missing-columns
Open

ChrisW09 wants to merge 1 commit into
mainfrom
fix/95-all-missing-columns

Conversation

@ChrisW09

@ChrisW09 ChrisW09 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #95.

A column that is missing on every fit row, such as an optional field that is empty in a training window or a cross-validation fold, broke the Preprocessor. Numerical columns and "one-hot" crashed with scikit-learn's "Found array with 0 feature(s)". With "int" and "none", the column silently disappeared from the output while the metadata still listed it, so get_feature_info() and verbose=2 fits raised IndexError.

Root cause

Each per-column pipeline starts with a SimpleImputer built with scikit-learn's default keep_empty_features=False, which drops a feature without any observed value. A pipeline holds exactly one column, so the next step received an (n, 0) array. The scalers, the numerical methods and OneHotEncoder reject it. "int" and "none" accept it and return a 0-width block. build_feature_info then indexed mapping_ of an ordinal encoder that had been fitted on no column.

Fix

  • pretab/compose/factory.py: the new _imputer() helper builds the leading imputer with keep_empty_features=True. get_numerical_transformer_steps and get_categorical_transformer_steps use it. An empty column is filled with 0, or with fill_value for strategy="constant", and keeps its block. imputer_kwargs can still override the setting.
  • Preprocessor.fit finds the columns with no observed value and emits a DataWarning naming them. The constant policy sees an empty numerical column as the constant it becomes once imputed.
  • When fitting the ColumnTransformer raises a ValueError other than a PretabConfigError and an empty column exists, the new Preprocessor._raise_for_empty_features refits each empty column's block on its own. If the block fails, but fits a stand-in column of observed values with the same y, it raises a PretabDataError naming the column and its method, chained to the method's error. Otherwise the original error propagates, so an unrelated failure (for example a y of the wrong length) is not blamed on the empty column.
  • build_feature_info reports an "int" block fitted on no column as dimension 0 with no categories instead of raising IndexError.

Behaviour changes

  • fit emits a DataWarning naming each column with no observed value. scikit-learn's "Skipping features without any observed values" UserWarning no longer appears.
  • An empty column is fitted as a constant: PLE, the scalers, the feature maps, "int" and "one-hot" keep a block for it, and later data where the column is observed transforms with the fitted width.
  • Methods that cannot be fitted on a constant column (B/M/I, P, natural and cubic regression splines, Box-Cox) raise a PretabDataError ("Column 'x' has no observed (non-missing) value at fit, and its 'bspline' representation cannot be fitted on such a column ...") instead of scikit-learn's "0 feature(s)" error. With imputation disabled, the column stays missing: the scalers, "quantile" and "none" keep its block as missing values, and every other method raises the same error.
  • policy={"constant": "error"} / "warn" now also fires for an all-missing numerical column.
  • The step builders get_numerical_transformer_steps / get_categorical_transformer_steps default to keep_empty_features=True. Passing imputer_kwargs={"keep_empty_features": False} restores scikit-learn's default.
  • The golden regression baselines pass unchanged.

Documentation

  • docs/core_concepts/missing_values.md: a note that a column with no observed value at fit keeps its block, how the imputers fill it, and the DataWarning.
  • docs/representations/choosing_a_method.md (edge-case behaviour): a new "Column with no observed value at fit" entry. It lists which methods fit such a column with and without imputation, which ones raise the typed error naming the column, and that policy={"constant": "error"} rejects it. This backs the page's statement that "PreTab is explicit about degenerate inputs rather than failing silently", which the issue quotes.
  • Docstrings: a paragraph in the Preprocessor class docstring; Raises (PretabDataError) and Warns (DataWarning) sections in Preprocessor.fit; _imputer and _raise_for_empty_features.

Tests

  • test_imputer_keeps_a_column_without_observed_values: an all-NaN column keeps width 1 through the numerical ("minmax") and "one-hot" step pipelines, and imputer_kwargs can restore keep_empty_features=False. Fails on main, passes here.
  • test_build_feature_info_reports_a_zero_width_block: an "int" block whose encoder received no column reports dimension 0. Fails on main (IndexError), passes here.
  • test_verbose_2_lists_a_column_without_observed_values: a verbose=2 fit warns and lists the empty column in the feature table. Fails on main (IndexError), passes here.
  • test_empty_numerical_column_keeps_its_block (27 cases: 14 methods, target-aware on and off): fit warns, the column keeps a block, and output_dims_, feature names, lineage, dict blocks and get_feature_info agree with the output. Data where the column is observed transforms with the fitted width. Fails on main, passes here.
  • test_empty_numerical_column_names_the_column_when_the_method_needs_spread (B/M/I, P, natural and cubic regression splines, Box-Cox): PretabDataError naming the column and the method, chained to the method's error. Fails on main, passes here.
  • test_empty_column_is_not_blamed_for_an_unrelated_fit_failure (3 cases): with a y that is too short or holds strings, the original error is raised, not the empty-column one. Fails on main, passes here.
  • test_empty_numerical_column_under_missing_value_settings (8 settings) and test_empty_categorical_column_keeps_its_block ("int", "one-hot", "none" under 7 settings): the same metadata checks across every missing_policy, with a missing indicator, without imputation, and with constant imputation. Fail on main, pass here.
  • test_empty_numerical_column_counts_as_constant_for_the_policy: policy={"constant": "error"} rejects an empty numerical column. Fails on main, passes here.
  • test_column_empty_in_a_cross_fitting_fold_keeps_the_width: under CrossFittedTransformer, a column that is observed only in the last fold gives out-of-fold and refit outputs of the same width. Fails on main, passes here.

Reproduction

Output of the script from #95 on this branch. The script was adapted to print each call that now succeeds and the warnings fit emits, where the original hid all warnings:

   fit warns DataWarning: Preprocessor received column(s) ['income'] with no observed (non-missing) value at fit. Their blocks are kept but fitted on an entirely missing column (a single constant value once imputed), so they carry no information until the preprocessor is refit on data where those columns are observed.
numerical all-NaN column -> output_dims_: {'age': 7, 'income': 7, 'city': 1} | output shape: (100, 15)
   fit warns DataWarning: Preprocessor received column(s) ['segment'] with no observed (non-missing) value at fit. Their blocks are kept but fitted on an entirely missing column (a single constant value once imputed), so they carry no information until the preprocessor is refit on data where those columns are observed.
categorical all-NaN column -> categorical_features_: ['city', 'segment'] | output_dims_: {'age': 7, 'city': 1, 'segment': 1} | blocks: ['cat_city', 'cat_segment', 'num_age']
   get_feature_info() -> segment: {'preprocessing': 'imputer -> continuous_ordinal', 'dimension': 1, 'categories': 2}
   fit warns DataWarning: Preprocessor received column(s) ['segment'] with no observed (non-missing) value at fit. Their blocks are kept but fitted on an entirely missing column (a single constant value once imputed), so they carry no information until the preprocessor is refit on data where those columns are observed.
one-hot, all-NaN column -> output_dims_: {'age': 7, 'city': 3, 'segment': 1}

Checks

  • just test: 1509 passed, 59 skipped, 7 xfailed; coverage 93.95% (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): 1496 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 #97, which is closed in favour of one pull request per issue.

… fit

A column missing on every fit row (e.g. an optional field that is empty in
a training window or a cross-validation fold) was dropped by the
SimpleImputer, scikit-learn's default for such a column. The rest of its
per-column pipeline then got no input: numerical methods and one-hot
failed with "Found array with 0 feature(s)", while "int" and "none"
silently removed the column from the output, output_dims_, the blocks and
the lineage, so get_feature_info() and verbose=2 fits raised an
IndexError.

The imputers now keep such a column (keep_empty_features=True) and fill
it with 0, or fill_value for strategy="constant", so every feature keeps
its block and later data where it is observed transforms with the fitted
width. fit emits a DataWarning naming the column, and the constant policy
treats an empty numerical column as constant. A method that cannot be
fitted on such a column (the splines and Box-Cox, or with imputation
disabled every method except the scalers, "quantile" and "none") raises a
PretabDataError naming it, chained to the method's own error. The column
is only blamed when its block fits a stand-in column of observed values
with the same y, so an unrelated failure (e.g. a y of the wrong length)
keeps its own error. get_feature_info no longer raises on a 0-width "int"
block.

Fixes #95

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.

Preprocessor crashes on an all-missing column at fit (numerical, one-hot) or silently drops it (int)

1 participant