diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml index 4cc8541..5507358 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-check.yml @@ -19,6 +19,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python + id: setup-python uses: actions/setup-python@v5 with: python-version: "3.10" @@ -33,7 +34,7 @@ jobs: uses: actions/cache@v4 with: path: .venv - key: venv-build-check-${{ runner.os }}-3.10-${{ hashFiles('poetry.lock') }} + key: venv-build-check-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }} - name: Install dependencies run: poetry install --only main diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60c4d2b..5d5412d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python + id: setup-python uses: actions/setup-python@v5 with: python-version: "3.10" @@ -41,7 +42,7 @@ jobs: uses: actions/cache@v4 with: path: .venv - key: venv-lint-${{ runner.os }}-3.10-${{ hashFiles('poetry.lock') }} + key: venv-lint-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }} - name: Install dependencies run: poetry install @@ -60,6 +61,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python + id: setup-python uses: actions/setup-python@v5 with: python-version: "3.10" @@ -74,7 +76,7 @@ jobs: uses: actions/cache@v4 with: path: .venv - key: venv-typecheck-${{ runner.os }}-3.10-${{ hashFiles('poetry.lock') }} + key: venv-typecheck-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }} - name: Install dependencies run: poetry install @@ -90,6 +92,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python + id: setup-python uses: actions/setup-python@v5 with: python-version: "3.10" @@ -104,7 +107,7 @@ jobs: uses: actions/cache@v4 with: path: .venv - key: venv-build-${{ runner.os }}-3.10-${{ hashFiles('poetry.lock') }} + key: venv-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }} - name: Install dependencies run: poetry install --only main @@ -130,6 +133,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python + id: setup-python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -140,11 +144,12 @@ jobs: - name: Configure Poetry run: poetry config virtualenvs.in-project true + # Virtualenvs retain interpreter paths, so patch releases need separate caches. - name: Cache virtualenv uses: actions/cache@v4 with: path: .venv - key: venv-tests-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }} + key: venv-tests-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }} - name: Install dependencies run: poetry install @@ -178,6 +183,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python + id: setup-python uses: actions/setup-python@v5 with: python-version: "3.12" @@ -192,7 +198,7 @@ jobs: uses: actions/cache@v4 with: path: .venv - key: venv-smoke-${{ runner.os }}-3.12-${{ hashFiles('poetry.lock') }} + key: venv-smoke-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }} - name: Install dependencies run: poetry install @@ -212,6 +218,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python + id: setup-python uses: actions/setup-python@v5 with: python-version: "3.12" @@ -226,7 +233,7 @@ jobs: uses: actions/cache@v4 with: path: .venv - key: venv-coverage-${{ runner.os }}-3.12-${{ hashFiles('poetry.lock') }} + key: venv-coverage-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }} - name: Install dependencies run: poetry install @@ -274,6 +281,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python + id: setup-python uses: actions/setup-python@v5 with: python-version: "3.12" @@ -288,7 +296,7 @@ jobs: uses: actions/cache@v4 with: path: .venv - key: venv-optdeps-${{ matrix.extra }}-${{ runner.os }}-3.12-${{ hashFiles('poetry.lock') }} + key: venv-optdeps-${{ matrix.extra }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }} - name: Install dependencies with the ${{ matrix.extra }} extra run: poetry install --extras "${{ matrix.extra }}" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a1e8e79..09325da 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -33,6 +33,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python + id: setup-python uses: actions/setup-python@v5 with: python-version: "3.12" @@ -47,7 +48,7 @@ jobs: uses: actions/cache@v4 with: path: .venv - key: venv-docs-${{ runner.os }}-3.12-${{ hashFiles('poetry.lock') }} + key: venv-docs-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }} - name: Install package and docs dependencies run: poetry install --with docs diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 9e81a9c..ada08e2 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -41,6 +41,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python + id: setup-python uses: actions/setup-python@v5 with: python-version: "3.10" @@ -55,7 +56,7 @@ jobs: uses: actions/cache@v4 with: path: .venv - key: venv-publish-${{ runner.os }}-3.10-${{ hashFiles('poetry.lock') }} + key: venv-publish-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }} - name: Install dependencies run: poetry install --only main diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml index bac0de6..f50e178 100644 --- a/.github/workflows/publish-testpypi.yml +++ b/.github/workflows/publish-testpypi.yml @@ -36,6 +36,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python + id: setup-python uses: actions/setup-python@v5 with: python-version: "3.10" @@ -50,7 +51,7 @@ jobs: uses: actions/cache@v4 with: path: .venv - key: venv-publish-rc-${{ runner.os }}-3.10-${{ hashFiles('poetry.lock') }} + key: venv-publish-rc-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }} - name: Install dependencies run: poetry install --only main diff --git a/docs/core_concepts/outputs_and_inspection.md b/docs/core_concepts/outputs_and_inspection.md index da1e103..68f1989 100644 --- a/docs/core_concepts/outputs_and_inspection.md +++ b/docs/core_concepts/outputs_and_inspection.md @@ -94,7 +94,9 @@ Two parameters control the physical layout of the stacked output. `output_format` : One of `"dense"`, `"sparse"`, or `"auto"`. `"auto"` picks sparse when it saves memory (for -example wide one-hot blocks) and dense otherwise. Default `"dense"`. +example wide one-hot blocks) and dense otherwise. The choice is made once at `fit`, from the +training output, and stored in `output_format_`, so every `transform` returns the same container, +even for a single row. Default `"dense"`. `dtype` : The floating-point precision of the output, for example `numpy.float32` to halve memory. diff --git a/pretab/compose/output.py b/pretab/compose/output.py index 2ab992e..a1f8c66 100644 --- a/pretab/compose/output.py +++ b/pretab/compose/output.py @@ -22,6 +22,7 @@ "compute_output_report", "format_output", "resolve_embedding_dimensions", + "resolve_output_format", "to_dataframe_output", "validate_embedding_request", ] @@ -62,6 +63,26 @@ def validate_embedding_request(embeddings, *, expected: bool, output_kind: str = raise IncompatibleParamsError(_EMBEDDINGS_DICT_ONLY) +def _density(array) -> float: + """Fraction of non-zero entries in a dense or sparse array (0.0 when empty).""" + size = int(np.prod(array.shape, dtype=np.int64)) + nonzero = int(array.count_nonzero()) if sp.issparse(array) else int(np.count_nonzero(array)) + return float(nonzero) / size if size else 0.0 + + +def resolve_output_format(array, output_format, *, threshold=_SPARSE_AUTO_THRESHOLD) -> str: + """Resolve ``output_format`` to ``"dense"`` or ``"sparse"`` for ``array``. + + ``"auto"`` picks ``"sparse"`` when the density of ``array`` is below + ``threshold``. The Preprocessor calls this once at ``fit`` on the training + output, so the container is fixed per fit rather than chosen per batch (a + sparse single row would break a dense-only downstream estimator). + """ + if output_format == "auto": + return "sparse" if _density(array) < threshold else "dense" + return output_format + + def compute_output_report(array, output_format, *, threshold=_SPARSE_AUTO_THRESHOLD): """Resolve the concrete output format and build the memory report. @@ -71,8 +92,9 @@ def compute_output_report(array, output_format, *, threshold=_SPARSE_AUTO_THRESH The stacked output. Sparse inputs are inspected through their shape, dtype, and stored values without converting them to a dense array. output_format : {"auto", "dense", "sparse"} - Requested format. ``"auto"`` picks ``"sparse"`` when the density is below - ``threshold``. + Requested format. ``"auto"`` picks ``"sparse"`` when the density of this + ``array`` is below ``threshold``; pass the format resolved at fit (see + :func:`resolve_output_format`) to keep the container stable across calls. threshold : float, default=0.3 Density cut-off for the ``"auto"`` decision. @@ -84,16 +106,9 @@ def compute_output_report(array, output_format, *, threshold=_SPARSE_AUTO_THRESH ``memory_saved_bytes``. """ size = int(np.prod(array.shape, dtype=np.int64)) - nonzero = int(array.count_nonzero()) if sp.issparse(array) else int(np.count_nonzero(array)) - density = float(nonzero) / size if size else 0.0 + density = _density(array) dense_bytes = size * int(array.dtype.itemsize) - - if output_format == "sparse": - use_sparse = True - elif output_format == "auto": - use_sparse = density < threshold - else: # "dense" - use_sparse = False + use_sparse = resolve_output_format(array, output_format, threshold=threshold) == "sparse" if use_sparse: csr = array.tocsr(copy=False) if sp.issparse(array) else sp.csr_matrix(array) diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 3d48a6a..9a9d991 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -27,6 +27,7 @@ compute_output_report, format_output, resolve_embedding_dimensions, + resolve_output_format, to_dataframe_output, validate_embedding_request, ) @@ -271,10 +272,12 @@ class Preprocessor(TransformerMixin, BaseEstimator): Container used for the transformed output. ``"dense"`` (the default, for backward compatibility) returns NumPy arrays; ``"sparse"`` returns SciPy CSR matrices (a single stacked CSR when ``return_array=True``, otherwise CSR - blocks in the output dict); ``"auto"`` selects ``"sparse"`` when the output - density falls below ``0.3`` and ``"dense"`` otherwise. Ignored when + blocks in the output dict); ``"auto"`` selects ``"sparse"`` when the density of + the training output falls below ``0.3`` and ``"dense"`` otherwise. The choice is + made once at ``fit`` (stored in ``output_format_``), so every ``transform`` + returns the same container regardless of the batch. Ignored when :meth:`set_output` requests a pandas or polars DataFrame. Every ``transform`` - records the resolved choice and its memory footprint in ``output_report_``. + records the format and its memory footprint in ``output_report_``. dtype : numpy dtype or None, default=None Optional dtype to cast the transformed output to (e.g. ``numpy.float32`` to halve memory). ``None`` keeps the native ``float64`` output. @@ -329,6 +332,9 @@ class Preprocessor(TransformerMixin, BaseEstimator): output_dims\_ : dict Per-feature expanded output-column counts, keyed by input feature name. The values sum to ``total_output_dim_``. + output_format\_ : {"dense", "sparse"} + The output container resolved at ``fit`` from ``output_format`` (``"auto"`` + is decided from the training output's density). output_report\_ : dict Memory report for the most recent ``transform``, with keys ``format`` (``"dense"`` or ``"sparse"``), ``shape``, ``density``, ``dense_bytes``, @@ -617,7 +623,12 @@ def fit(self, X, y=None, embeddings=None): # ``sklearn.set_config(transform_output="pandas")`` reaches them and the # sparse OneHotEncoder refuses pandas output. self.column_transformer_.set_output(transform="default") - self.column_transformer_.fit(self._column_transformer_input(X), y) + # ColumnTransformer.fit runs fit_transform internally; keep the training + # output to resolve output_format="auto" once, from the training density. + training_output = self.column_transformer_.fit_transform(self._column_transformer_input(X), y) + if not sp.issparse(training_output): + training_output = np.asarray(training_output) + self.output_format_ = resolve_output_format(training_output, self.output_format) self.n_features_in_ = X.shape[1] # scikit-learn convention: feature names are recorded only for named input. if not fitted_on_array and all(isinstance(label, str) for label in X.columns): @@ -713,7 +724,9 @@ def transform(self, X, embeddings=None, return_array=None): if self.dtype is not None: transformed_X = transformed_X.astype(self.dtype, copy=False) - fmt, self.output_report_ = compute_output_report(transformed_X, self.output_format) + # Spec files written before output_format_ existed keep the per-call choice. + output_format = getattr(self, "output_format_", self.output_format) + fmt, self.output_report_ = compute_output_report(transformed_X, output_format) if container in ("pandas", "polars"): # Pass X's index explicitly: sklearn's set_output wrapper keeps the index of diff --git a/tests/integration/test_output_format.py b/tests/integration/test_output_format.py index ab13700..384369d 100644 --- a/tests/integration/test_output_format.py +++ b/tests/integration/test_output_format.py @@ -5,6 +5,8 @@ wrapping. """ +from typing import cast + import numpy as np import pandas as pd import pytest @@ -280,6 +282,53 @@ def test_invalid_output_format_raises(frame, y): _bspline(output_format="nope").fit(frame, y) +# --- "auto" is resolved once per fit (issue #73) ---------------------------------- + + +def test_auto_format_is_fixed_at_fit_for_dense_training_output(): + """Regression guard for issue #73: low-density single rows came back sparse.""" + from sklearn.datasets import load_diabetes + + X, y = cast("tuple[pd.DataFrame, pd.Series]", load_diabetes(return_X_y=True, as_frame=True)) + pre = Preprocessor(output_format="auto", random_state=0).fit(X, y) + assert pre.output_format_ == "dense" + for i in range(len(X)): + assert isinstance(pre.transform(X.iloc[[i]]), np.ndarray) + # The report still describes each call's own density. + pre.transform(X.iloc[[0]]) + assert pre.output_report_["format"] == "dense" + assert pre.output_report_["shape"] == (1, pre.total_output_dim_) + + +def test_auto_format_is_fixed_at_fit_for_sparse_training_output(): + rng = np.random.default_rng(0) + frame = pd.DataFrame({"city": rng.choice([f"c{i}" for i in range(20)], 200)}) + pre = Preprocessor(categorical_method="one-hot", output_format="auto").fit(frame) + assert pre.output_format_ == "sparse" + assert sp.issparse(pre.transform(frame.iloc[:1])) + assert sp.issparse(pre.transform(frame)) + + +def test_auto_sparse_format_remains_sparse_for_a_dense_single_row(): + frame = pd.DataFrame(np.zeros((40, 3)), columns=pd.Index(["a", "b", "c"])) + frame.iloc[0] = [1.0, 2.0, 3.0] + pre = Preprocessor(numerical_method="none", output_format="auto").fit(frame) + assert pre.output_format_ == "sparse" + assert sp.issparse(pre.transform(frame)) + assert pre.output_report_["density"] == pytest.approx(1.0 / 40) + row = pre.transform(frame.iloc[:1]) + assert isinstance(row, sp.csr_matrix) + np.testing.assert_array_equal(row.toarray(), [[1.0, 2.0, 3.0]]) + assert pre.output_report_["format"] == "sparse" + assert pre.output_report_["density"] == 1.0 + + +@pytest.mark.parametrize("output_format", ["dense", "sparse"]) +def test_explicit_formats_are_stored_as_resolved(frame, y, output_format): + pre = Preprocessor(numerical_method="minmax", output_format=output_format).fit(frame, y) + assert pre.output_format_ == output_format + + # --- global scikit-learn output config ---------------------------------------------- @@ -292,6 +341,7 @@ def mixed_frame(): @pytest.mark.parametrize("fit_before_config", [False, True]) +@pytest.mark.parametrize("output_format", ["dense", "sparse", "auto"]) @pytest.mark.parametrize( "options", [ @@ -300,19 +350,23 @@ def mixed_frame(): {"feature_preprocessing": {"color": "one-hot"}}, ], ) -def test_one_hot_works_under_a_global_pandas_output_config(mixed_frame, options, fit_before_config): +def test_one_hot_works_under_a_global_pandas_output_config(mixed_frame, options, fit_before_config, output_format): """The global transform_output reached the inner sparse OneHotEncoder, which refused it.""" import sklearn X, y = mixed_frame X = X.set_axis(pd.Index(np.arange(len(X))[::-1] + 100, name="row_id")) - pre = Preprocessor(**options).fit(X, y) - expected = np.asarray(pre.transform(X)) + pre = Preprocessor(output_format=output_format, **options).fit(X, y) + expected_output = pre.transform(X) + expected = expected_output.toarray() if isinstance(expected_output, sp.csr_matrix) else np.asarray(expected_output) + expected_format = pre.output_format_ with sklearn.config_context(transform_output="pandas"): if not fit_before_config: - pre = Preprocessor(**options).fit(X, y) + pre = Preprocessor(output_format=output_format, **options).fit(X, y) out = pre.transform(X) assert isinstance(out, pd.DataFrame) + assert pre.output_format_ == expected_format + assert pre.output_report_["format"] == expected_format pd.testing.assert_index_equal(out.index, X.index) assert list(out.columns) == list(pre.get_feature_names_out()) assert set(map(str, out.dtypes)) == {"float64"}