Skip to content

fix(binning): learn bin edges at fit instead of per transform call - #30

Open
ChrisW09 wants to merge 1 commit into
mainfrom
fix/custombin-learns-edges-at-fit
Open

fix(binning): learn bin edges at fit instead of per transform call#30
ChrisW09 wants to merge 1 commit into
mainfrom
fix/custombin-learns-edges-at-fit

Conversation

@ChrisW09

Copy link
Copy Markdown
Collaborator

Fixes #11.

Problem

fit recorded only n_features_in_ and total_output_dim_. transform called
pd.cut(X.squeeze(), bins=bins_spec, retbins=True) on whatever batch it was handed and used
those edges — so the encoding depended on the transform batch, not on the training data:

t = CustomBinTransformer(output_dim=4).fit(np.linspace(0, 10, 100).reshape(-1, 1))
t.transform([[0], [2.5], [5], [7.5], [10]])           # [0 0 1 2 3]
t.transform([[0], [2.5], [5], [7.5], [10], [100]])    # [0 0 0 0 0 3]

Same five rows, different codes — and neither result reflects the 100-row fit. This is silent
train/serve skew: no error, and the codes look entirely plausible.

transform also rejected fewer than three rows, so single-row inference was impossible.

Fix

  • Compute edges in fit, store as bin_edges_ (one array per column), reuse verbatim.
  • Clip into the fitted range at transform. pd.cut returns NaN outside its edges, which
    would put NaN into an otherwise integer column; clipping puts unseen extremes in the
    first/last bin instead. Flagged as an open question in the issue — this is the choice I
    made, easy to change to a reserved code if you'd rather.
  • Move the numeric-dtype guard to fit, alongside the edge computation, so string input is
    rejected at fit rather than at first transform.
  • Drop the X.shape[0] <= 2 guard from transform. It only ever mattered for deriving an
    equal-width range, which now happens at fit — where pandas handles small inputs by
    widening the range.

Result

transform(rows)              : [0 0 1 2 3]
transform(rows + outlier)    : [0 0 1 2 3 3]     <- first five unchanged
transform(single row [5.0])  : [1]
Preprocessor [0, 5, 10]      : [0 2 4]
   with an outlier appended  : [0 2 4 4]         <- was [0 0 0]

One existing test changed

test_custom_bin_transformer_raises_on_invalid_shape asserted that
transform(np.array([[0.1]])) raises "Input must have more than 2 observations." — i.e. it
encoded the behaviour this issue asks to remove. I repurposed it to cover the real shape
guard (a 2-column input raises PretabDataError) and added
test_custom_bin_transformer_raises_before_fit for the NotFittedError path that the
un-fitted call now takes.

No other existing test needed touching. Two tests that fit on a single row still pass,
since pandas widens the range for degenerate input.

Tests

Seven added: edges learned at fit, codes independent of batch composition, codes reflecting
the fitted range, single-row transform, out-of-range clipping, explicit-edge round-trip, and
string input rejected at fit.

Full suite: 488 passed, 9 xfailed. pyright unchanged at 71. Ruff on the touched test file
goes from 4 pre-existing errors to 3 — the RUF043 disappears because the test I rewrote now
uses a raw-string match=.

🤖 Generated with Claude Code

``CustomBinTransformer.fit`` recorded only ``n_features_in_``; ``transform``
called ``pd.cut(..., retbins=True)`` on whatever batch it was handed and used
those edges. The encoding therefore depended on the batch, not on the training
data:

    fit(linspace(0, 10, 100))
    transform([0, 2.5, 5, 7.5, 10])            -> [0 0 1 2 3]
    transform([0, 2.5, 5, 7.5, 10, 100])       -> [0 0 0 0 0 3]

Identical rows, different codes, and neither result reflected the 100-row fit.
Silent train/serve skew: no error, and the codes look plausible.

Compute the edges in ``fit``, store them as ``bin_edges_``, and reuse them
verbatim. Values outside the fitted range are clipped into the end bins so
``transform`` never emits NaN into an integer column (``pd.cut`` returns NaN
outside its edges).

The numeric-dtype guard moves to ``fit`` alongside the edge computation, so
string input is now rejected at fit rather than at first transform.

Also removes the ``X.shape[0] <= 2`` guard from ``transform``, which made
single-row inference impossible. It was only meaningful for deriving an
equal-width range -- something that now happens at fit, where pandas handles
small inputs by widening the range.

Closes #11

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

fix(binning): CustomBinTransformer learns no bin edges at fit time

1 participant