Repository navigation
Conversation
A global sklearn.set_config(transform_output="pandas") (or "polars") reached the steps of the internal ColumnTransformer. The sparse OneHotEncoder refuses pandas output, so categorical_method="one-hot" and preset="expanded" failed to fit, and mixed float / bool blocks (the missing indicator) were stacked into an object-dtype frame. The internal ColumnTransformer now always produces plain arrays; the Preprocessor still wraps its stacked output in the requested container itself. Fixes #76
…fier RepresentationSearchCV stratified its folds for a classifier but built every candidate Preprocessor with the default task="regression". Target-aware placement then crashed on string class labels and treated integer labels as a regression target. A classifier now defaults the preprocessors to task="classification"; an explicit task in preprocessor_params still wins. Fixes #77
Array input was always relabelled feature_0, feature_1, ... and routed by name, so a Preprocessor fitted on a DataFrame raised 'columns are missing' for the same data as a NumPy array (NumPy-based serving, shap.KernelExplainer), and one fitted on an array raised for a DataFrame. Following scikit-learn's convention, input of the fitted width that does not carry the fitted labels is now matched by position (numeric columns of an object array are re-inferred) with scikit-learn's usual feature-name warning; input of a different width still raises. Fixes #78
…reprocessor per fold CrossFittedTransformer refit a fresh clone of the wrapped estimator on each fold. For a Preprocessor that re-detected column types (the cat_cutoff ratio depends on the row count) and relearned category vocabularies on fewer rows, so the out-of-fold training features used different integer codes, a different numerical/categorical split, or a different one-hot width than transform. A Preprocessor now derives each fold model from the all-data fit and refits only the blocks that consume the target; everything else is shared, so out-of-fold rows are encoded exactly like transform encodes them while target-aware placement still never sees a row's own target. Other estimators keep the clone and refit path. Sparse fold output is densified, dict output raises a clear error, and the width-mismatch message no longer blames adaptive sizing only. Fixes #79
… tokens The imputers' missing_values=np.nan (and any other non-finite float in fitted state) was written as a bare NaN token, so every default spec file was rejected by strict JSON parsers outside Python. Non-finite floats, as scalars and as float-array elements, are now encoded as a tagged value and decoded back, and specs are dumped with allow_nan=False. Specs written by earlier versions still load. Fixes #80
BaseSplineTransformer documented that output_dim is ignored when knot_locations is given, but it still checked the knot count against output_dim - degree - 1 (raising for any other count), trimmed the knots to the adaptive window, and clipped out-of-range knots onto the boundary. Explicit knots now fix the width (len(knot_locations) + degree + 1 basis functions per feature) and bypass output_dim and the adaptive bounds. Repeated knots are merged, knots on or outside a feature's fitted range are dropped with a DataWarning, and knot_locations must be a 1-D sequence of finite numbers. Fixes #81
QuantilePlacement returned the data quantiles verbatim, so on zero-inflated, top-coded or discrete features the RBF / ReLU / sigmoid / tanh feature maps got repeated centers and exactly duplicated output columns (4 of 8 distinct on a zero-inflated feature). The locations are now made distinct: endpoints are kept once and the rest is topped up with strictly interior quantile / uniform candidates, as the splines already do. Untied data is unchanged, and a zero-range feature keeps its repeated location so the width holds. Fixes #82
…aining maximum On the uniform / quantile path the ReLU expansion reused the feature maps' endpoint-inclusive centers, so its last ramp max(0, x - c) started at the training maximum and was identically zero on every training row. The ReLU now places output_dim + 1 range-spanning locations and drops the right endpoint, so every column carries signal; RBF / sigmoid / tanh and the target-aware path are unchanged. Fixes #83
…xtrapolate out-of-range rows
A policy mapping such as {"constant": "warn"} was expanded with the dataclass
defaults, so it reset out_of_range to "extrapolate" and dropped the clip default
of the B/M/I-spline, P-spline and tensor-product families. Under
"extrapolate" / "warn" those bases then evaluated to zero outside the knot
span, turning every out-of-range row into an all-zero basis row.
A mapping now overrides only the axes it names on top of the family defaults (an
unknown axis raises a typed error; an instance is still used verbatim), and
out-of-range rows are evaluated by extending the boundary polynomial pieces
(BSpline(extrapolate=True) for B/M-splines, and for the out-of-range rows of the
P-spline / tensor-product basis). In-range values are unchanged.
Fixes #84
…it-learn The standalone numerical transformers validated input with check_array 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. The shared validator, and the PLE and binning validation, 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. Fixes #85
The #85 fix (record and check DataFrame feature names like scikit-learn) made get_feature_names_out(input_features) require the fitted column names, but get_representation_spec() still built x0, x1, ... itself and passed them on. So it raised "input_features is not equal to feature_names_in_" for every transformer fitted on a DataFrame. The Preprocessor's spec summary swallowed that error, so to_spec() and reproducibility_report() listed no representations whenever one was fitted directly on its column (no imputer or scaler), and get_feature_info() warned that X had no valid feature names. get_representation_spec() now resolves its input names like get_feature_names_out (explicit names validated, else feature_names_in_, else x0, x1, ...). The CrossFittedTransformer fallback uses the wrapped estimator's feature_names_in_, which also fixes a wrapped Preprocessor fitted on a frame. The summary passes each block's column names (a leaf fitted on more inputs, as before the #62 fix, names its own), so its entries read age_rbf0 instead of x0_rbf0, and it no longer hides spec errors. get_feature_info() probes a step with a frame carrying its fitted names. Follow-up to #85
…tion summary The #62 fix (keep the imputer's missing indicator out of the representation) builds add_missing_indicator=True and missing_policy='impute_with_indicator' blocks as a FeatureUnion of the representation pipeline and a MissingIndicator, as missing_policy='separate_state' already did. The spec summary read each block's last step, which for such a block is the union itself, so to_spec() and reproducibility_report() listed no representation for them (separate_state was already empty before), and the verbose=3 log skipped their fitted thresholds, knots and centers. A representation_leaf() helper now returns the last step of the union's representation branch, found as the feature lineage finds it, and the summary and the verbose=3 log use it. Blocks ending in scikit-learn steps still get no summary entry, and the fingerprint does not hash the summary, so it does not change. Follow-up to #62
A wrapped Preprocessor refits only its target-aware blocks on each fold, and it recognised them from each block's RepresentationSpec or a fixed list of scikit-learn step names. A class registered with register_representation(..., supervision="supervised" | "optional") that has no RepresentationSpec, such as scikit-learn's TargetEncoder, was therefore taken to ignore y, and CrossFittedTransformer encoded every out-of-fold row with the all-data fit that had seen the row's own target: on a pure-noise target the out-of-fold encoding correlated with y at about 0.5. Such a block is now resolved from its registry entry: a "supervised" class, or an "optional" one with target_aware set, counts as target-aware, so it is refit on every fold and the lineage reports uses_target. Follow-up to #79
ChrisW09
added a commit
that referenced
this pull request
Oct 9, 2026
- A list of rows passed to transform after a DataFrame fit is matched by position (#94). - The fold-column check of cross-fitting is tested with deterministic folds instead of a frequency tie that a future scikit-learn could break differently (#90). - The unsupervised B/M/I splines never read y, even a y that cannot be aligned with X (#88). - The pipeline set_output test moves out of the polars-only module, so its pandas case also runs where polars is not installed (#94). - An optional registered class that defaults to target_aware=False follows the Preprocessor's target_aware and is then refit on every fold (#96 with the cross-fitting fix from #86).
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.
This PR fixes the 10 most important bugs from a new audit of the library. It is stacked on #75 and targets
fix/open-bug-batch, so this diff shows only the new commits. After #75 is merged, change the base tomain(or deletefix/open-bug-batch, which retargets automatically).How the bugs were selected
I audited five areas of the code:
That produced about 38 candidates. Each one was reproduced with a script that I ran, and each of the 10 selected bugs was reproduced again on
main(6bacaba). Candidates were ranked by impact × likelihood:Each bug has its own issue with a reproduction, its own commit, and regression tests. Every new test set fails on the parent commit and passes here.
Fixes
CrossFittedTransformer(Preprocessor)refit column types and category codes per fold, so out-of-fold features were encoded differently fromtransformPreprocessornow derives each fold model from the all-data fit and refits only the target-aware blocks (Preprocessor._cross_fit_fold). Out-of-fold rows use exactly the encoding oftransform, and target-aware placement still never sees a row's own target. Other estimators keep the clone-and-refit path. Sparse fold output is densified, dict output raises a clear error, and the width-mismatch message no longer blames adaptive sizing only.preset="expanded"crashed undersklearn.set_config(transform_output="pandas")ColumnTransformeris pinned toset_output(transform="default"). The Preprocessor still wraps its own output. This also stops the #75MissingIndicatorbranch from producing an object-dtype frame under that setting.RepresentationSearchCVwith a classifier kepttask="regression"task="classification". An explicittaskinpreprocessor_paramsstill wins.Preprocessorrejected a same-width NumPy array attransform, and vice versapolicymapping switched the clip-family splines to"extrapolate", and"extrapolate"/"warn"returned all-zero rowsBSpline(extrapolate=True)for B/M-splines, and the out-of-range rows of the P-spline / tensor-product basis. In-range values are unchanged._check_feature_names.get_feature_names_out()builds names fromfeature_names_in_and validatesinput_features. scikit-learn'scheck_dataframe_column_names_consistencyandcheck_transformer_get_feature_names_out_pandasnow pass.to_spec()files contained bareNaNtokens and were not valid JSON for strict parsers{"__float__": "nan"}and specs are dumped withallow_nan=False. Specs written bymainstill load and reproduce bit-for-bit (verified with a spec written bymain).knot_locationswas still sized byoutput_dimlen + degree + 1and bypassoutput_dimand the adaptive bounds. Repeated knots are merged. Knots on or outside the range are dropped with aDataWarning. Malformed input raisesInvalidParamError.QuantilePlacementreturns distinct locations: the endpoints are kept once and the interior is topped up with strictly interior candidates, using the same helper as the splines. Untied data and constant columns are unchanged.output_dim + 1range-spanning locations and drops the right endpoint. RBF / sigmoid / tanh and the target-aware path are unchanged.Follow-up commits: regressions found by the next audit
The audit behind #97, and a review of it, found three regressions introduced by this PR and #75. They are fixed by the last three commits here, so none of them reaches
main:fix(representation): resolve spec input names like get_feature_names_outget_feature_names_out(input_features)require the fitted column names, butget_representation_spec()still passedx0, x1, .... It raisedinput_features is not equal to feature_names_in_for every numerical transformer fitted on a DataFrame. The Preprocessor's spec summary swallowed that error, soto_spec()["representations"]andreproducibility_report()came back empty whenever a representation was fitted directly on its column.get_feature_names_out. TheCrossFittedTransformerfallback uses the wrapped estimator's names. The summary passes each block's column names and no longer hides errors; a leaf fitted on more inputs (1.0.0 objects withadd_missing_indicator) names its own.get_feature_info()probes with the fitted names, so it no longer warns.fix(serialization): unwrap missing-indicator blocks in the representation summaryFeatureUnion, and the summary treated the union as the leaf. Soadd_missing_indicator=True/impute_with_indicatorlisted no representations (separate_statenever did), and theverbose=3log skipped them.representation_leaf()helper unwraps the representation branch for the summary and for the log.fix(cross-fitting): refit registered target-using classes on every foldPreprocessorper fold, and recognised them only from aRepresentationSpecor a fixed list of scikit-learn step names. A class registered withsupervision="supervised"/"optional"that has no spec (for example scikit-learn'sTargetEncoder) was treated as target-free, soCrossFittedTransformerencoded every out-of-fold row with the all-data fit that had seen the row's own target. On a pure-noise target the out-of-fold encoding correlated withyat about 0.5. Onmainthe whole Preprocessor was refit per fold, so this is a regression."supervised"class, or an"optional"one withtarget_awareset, counts as target-aware, so it is refit on every fold and lineage reportsuses_target.Behaviour changes from these commits:
age_rbf0instead ofx0_rbf0).separate_stateblocks now appear in the summary.fingerprint_does not hash the summary, so it is unchanged by these commits.RepresentationSpecare refit per fold byCrossFittedTransformerand reported withuses_target=Truein the lineage.Each commit has regression tests that fail on its parent. A spec and a pickle written by 1.0.0 with
add_missing_indicator/impute_with_indicatorstill giveto_spec,fingerprint_andreproducibility_report.Behaviour changes to review
ReLU (unsupervised path): centers move from
linspace(min, max, k)tolinspace(min, max, k + 1)[:-1](and the quantile analogue). Width is unchanged.Quantile placement: on tied data the interior locations change. On untied data they are bit-identical.
Explicit spline policies: an explicit
RepresentationPolicy(out_of_range="extrapolate" | "warn")now produces extrapolated B/M/P/tensor rows instead of zeros. Values grow quickly far outside the range, as polynomial extrapolation does. The default stays"clip".Standalone transformers fitted on a DataFrame:
age_rbf0instead ofx0_rbf0);transformwarns, as in scikit-learn.Preprocessoroutput names are unchanged.Fingerprints:
fingerprint_changes for every newly fitted preprocessor: the internalColumnTransformernow records its pinned output setting (Preprocessor with one-hot encoding (and preset="expanded") crashes under sklearn.set_config(transform_output="pandas") #76), the spec format tags non-finite floats (Default to_spec() files contain bare NaN tokens and are not valid JSON for strict parsers #80), and a representation fitted directly on a frame column recordsfeature_names_in_(Standalone numerical transformers drop DataFrame feature names: reordered columns are silently accepted and outputs are named x0_... #85). A preprocessor loaded from a 1.0.0 spec or pickle whose state holds a NaN (every one with an imputer) also reports a newfingerprint_; its output is unchanged.The golden regression baselines pass unchanged.
Testing
scripts/quickstart.pypasses.ruff checkandruff format --checkare clean. pyright reports only the 6 errors that also appear onmainin this environment.Not included (confirmed, lower priority, or design changes)
gamma=1/scale=1), so extraoutput_dimadds little capacity.x, which makes them unit-dependent and ill-conditioned for years or timestamps.dtype=Noneis documented as float64.fitleaves the lifecycle state at FITTED.cat_cutoffcan flip betweenRepresentationSearchCVfolds and the refit.check_representationand embedding-backend contract issues.Closes #76, closes #77, closes #78, closes #79, closes #80, closes #81, closes #82, closes #83, closes #84, closes #85