Skip to content

test: cover both unchecked get_feature_names_out call shapes, fix the CRMCleaner crash they found - #198

Merged
shivamlalakiya merged 2 commits into
mainfrom
test/157-feature-names-out-contract
Sep 8, 2026
Merged

test: cover both unchecked get_feature_names_out call shapes, fix the CRMCleaner crash they found#198
shivamlalakiya merged 2 commits into
mainfrom
test/157-feature-names-out-contract

Conversation

@shivamlalakiya

Copy link
Copy Markdown
Contributor

Closes #157.

What the issue predicted, and what actually happened

The issue said the value would be "the transformers this finds, not the two tests," and to report it either way. It found one real defect and cleared the rest.

Found: CRMCleaner.get_feature_names_out crashes after an unnamed-array fit.

AttributeError: 'CRMCleaner' object has no attribute 'feature_names_in_'

check_is_fitted(self) passes, because n_features_in_ is set, and the next line then reads self.feature_names_in_, which scikit-learn only assigns when the fitted input carried column names. So the failure lands one line after the guard that is supposed to catch exactly this class of problem, and it surfaces as an AttributeError rather than anything a caller can handle.

The fix is the fallback WealthScreeningImputer and WealthScreeningImputerKNN already had, and nothing else:

if hasattr(self, "feature_names_in_"):
    names = list(self.feature_names_in_)
else:
    names = [f"x{i}" for i in range(self.n_features_in_)]

Before and after, on the same fixture:

named  -> ['gift_date', 'gift_amount']
array  -> AttributeError        # was
array  -> ['x0', 'x1']          # now
widths -> 2 2

Cleared: input_features is honoured everywhere it is claimed to be. All 13 non-exempt public transformers already return the right width when passed input_features=list(X.columns). Zero failures. That is a real result and worth stating rather than leaving as an absence.

The two new contracts

Both parametrize over sorted(preprocessing.__all__) and reuse the existing _transformer_instance / _transformer_input helpers, so there is no second fixture table to drift against __all__.

  • test_feature_names_out_accepts_input_features: fits on the DataFrame, then passes the real column names. This is the call ColumnTransformer and Pipeline.get_feature_names_out actually make, and nothing in the suite made it before.
  • test_feature_names_out_width_after_array_fit: fits on X.to_numpy(). This is what caught CRMCleaner.

One deliberate deviation from the issue text

The issue says to add array-fit failures to _EXEMPT. I put them in a separate _EXEMPT_ARRAY_FIT instead, because _EXEMPT skips every check: folding these two in would also have dropped them from the width and input_features contracts, which they both pass. Losing two passing contracts to record one legitimate limitation is a bad trade, and it would have been invisible.

Two entries, one written reason each, same discipline as _EXEMPT:

  • EncounterTransformer merges an encounter table on a named donor_id and raises a written ValueError naming merge_key when it is absent.
  • MatchingGiftFeaturizer rejects a non-DataFrame outright with TypeError: X must be a pandas DataFrame.

Three hygiene tests police the new table so it cannot rot: no stale entry, no name in both tables, and a reason on every entry in both.

Follow-up question, deliberately not changed here

CRMCleaner.get_feature_names_out documents input_features as "Ignored", and FiscalYearTransformer and ShareOfWalletScorer likewise return fixed arrays regardless of what is passed. scikit-learn's own convention is to validate input_features against feature_names_in_ and raise on a mismatch. Ignoring it means a caller who passes the wrong names silently gets the wrong answer instead of an error.

That is a behaviour change, not a bug fix, so per the issue's own instruction it is flagged rather than shipped inside this PR. Worth its own issue if you agree; defensible as-is for the two fixed-output transformers, less so for CRMCleaner.

Verification

python -m pytest tests/test_public_api_contract.py -q      # 109 passed
python -m pytest tests/ --cov=philanthropy -q              # 2003 passed, 8 skipped, 98.18%
make ci
make riskcov

The behavioural check above is the direct evidence for the fix: the array-fit call raised before the change and returns ['x0', 'x1'] after it.

… CRMCleaner crash they found

The public-API contract asserted that every transformer's
get_feature_names_out is as wide as its transform output, but only ever
made one call: no argument, on a transformer fitted from a DataFrame.
The two shapes a real pipeline uses were unchecked. ColumnTransformer
and Pipeline.get_feature_names_out both pass input_features down
explicitly, and an estimator fitted on a bare array has no column names
to fall back on.

Adding both shapes found one defect. CRMCleaner.get_feature_names_out
read self.feature_names_in_ unconditionally, and scikit-learn only sets
that attribute when the fitted input carried column names. After an
array fit it raised AttributeError one line after the check_is_fitted
guard that exists to catch this class of problem, and as an
AttributeError rather than anything a caller can handle. It now falls
back to x0, x1, ... exactly as WealthScreeningImputer and
WealthScreeningImputerKNN already did.

The input_features shape found nothing: all 13 non-exempt transformers
already honour it. That is worth recording rather than leaving as an
absence.

Two transformers genuinely cannot fit on an unnamed array, so they are
exempted from that shape only, in a new _EXEMPT_ARRAY_FIT table with a
written reason each. It is kept separate from _EXEMPT deliberately:
_EXEMPT skips every check, so folding them in would have silently
dropped both from the width and input_features contracts they do pass.
Three hygiene tests police the new table for stale entries, names listed
in both tables, and missing reasons.

Closes #157.
@shivamlalakiya
shivamlalakiya merged commit 38548d1 into main Sep 8, 2026
15 checks passed
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.

test: the public-API contract never calls get_feature_names_out(input_features=...), nor checks its width after an array fit

1 participant