diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md index c5fa21d..bb7077b 100644 --- a/docs/core_concepts/configuration.md +++ b/docs/core_concepts/configuration.md @@ -27,7 +27,10 @@ strategy strings is in the [representation comparison](../representations/compar `Preprocessor` also accepts a plain `numpy.ndarray`, not just a `DataFrame`. Columns are named `feature_0`, `feature_1`, ... in position order, then detected as numerical or -categorical exactly as they would be for a `DataFrame`. +categorical exactly as they would be for a `DataFrame`. Because array columns are matched by +position, `transform` raises the usual scikit-learn error +(`X has 4 features, but Preprocessor is expecting 3 features as input.`) when an array has a +different number of columns than the one seen at `fit`. ```python import numpy as np diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index a8193b8..9cb8f81 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -632,10 +632,25 @@ def transform(self, X, embeddings=None, return_array=None): (or ``return_array=False``); a SciPy CSR matrix (or CSR blocks) when ``output_format`` resolves to ``"sparse"``; or a pandas / polars DataFrame when configured via :meth:`set_output`. + + Raises + ------ + PretabDataError + If ``X`` is a NumPy array whose number of columns differs from + ``n_features_in_``. Array columns are matched by position, so such an + array can never be routed correctly. """ check_is_fitted(self) + # Array columns are matched by position, so a different width can never be + # routed correctly; an extra column would silently shift or drop features. + if isinstance(X, np.ndarray) and X.ndim == 2 and X.shape[1] != self.n_features_in_: + raise PretabDataError( + f"X has {X.shape[1]} features, but {type(self).__name__} is expecting " + f"{self.n_features_in_} features as input." + ) + X = to_dataframe(X, copy=True) if self.missing_policy == "error": diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index df9d224..4f5df20 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -377,3 +377,23 @@ def test_unknown_feature_preprocessing_key_raises(sample_data): X, y = sample_data with pytest.raises(InvalidParamError, match="feature_preprocessing"): Preprocessor(feature_preprocessing={"nnum1": "minmax"}).fit(X, y) + + +# --- ndarray width check at transform (issue #72) --------------------------------- + + +@pytest.mark.parametrize("width", [2, 4]) +def test_transform_rejects_an_ndarray_of_a_different_width(width): + """Regression guard for issue #72: an extra array column silently shifted features.""" + rng = np.random.default_rng(0) + X, y = rng.normal(size=(60, 3)), rng.normal(size=60) + pre = Preprocessor(random_state=0).fit(X, y) + with pytest.raises(ValueError, match=f"X has {width} features, but Preprocessor is expecting 3"): + pre.transform(rng.normal(size=(5, width))) + + +def test_transform_accepts_an_ndarray_of_the_fitted_width(): + rng = np.random.default_rng(0) + X, y = rng.normal(size=(60, 3)), rng.normal(size=60) + pre = Preprocessor(random_state=0).fit(X, y) + assert np.asarray(pre.transform(X)).shape[0] == 60