diff --git a/README.md b/README.md
index 99f60ef..3f7fe86 100644
--- a/README.md
+++ b/README.md
@@ -309,9 +309,10 @@ Preprocessor(preset="adaptive") # same numerical method, int codes, adaptive w
### Automatic feature-type detection
By default PreTab inspects each column and classifies it as numerical or categorical.
-String and object columns are treated as categorical, low-cardinality integer columns are
-categorical, and integer columns with enough distinct values stay numerical. Tune the
-behavior with `cat_cutoff` and `treat_all_integers_as_numerical`.
+String, object and boolean columns are treated as categorical, low-cardinality integer
+columns are categorical, and integer columns with enough distinct values stay numerical.
+A boolean column is encoded like any other binary categorical column, with the categories
+`False` and `True`. Tune the behavior with `cat_cutoff` and `treat_all_integers_as_numerical`.
```python
preprocessor = Preprocessor(
diff --git a/pretab/compose/feature_detection.py b/pretab/compose/feature_detection.py
index ebdb1cc..edbdd54 100644
--- a/pretab/compose/feature_detection.py
+++ b/pretab/compose/feature_detection.py
@@ -10,7 +10,7 @@
from ..exceptions import PretabDataError, invalid_param_error
-__all__ = ["detect_column_types", "to_dataframe"]
+__all__ = ["bool_columns_as_object", "detect_column_types", "to_dataframe"]
def to_dataframe(X, *, copy: bool = False) -> pd.DataFrame:
@@ -41,14 +41,35 @@ def to_dataframe(X, *, copy: bool = False) -> pd.DataFrame:
return X
+def bool_columns_as_object(X: pd.DataFrame) -> pd.DataFrame:
+ """Return ``X`` with its boolean columns cast to ``object``.
+
+ Boolean columns are categorical (see :func:`detect_column_types`), but the
+ categorical pipeline starts with a :class:`~sklearn.impute.SimpleImputer`,
+ which rejects the ``bool`` dtype. As ``object`` columns of ``True`` /
+ ``False`` they are encoded like any other binary categorical column; a
+ missing value of pandas' nullable ``boolean`` dtype becomes ``NaN``, the
+ missing marker the imputer recognizes. ``X`` itself is returned when it has
+ no boolean column, so the caller's frame is never modified.
+ """
+ bool_columns = [label for label, dtype in X.dtypes.items() if dtype.kind == "b"]
+ if not bool_columns:
+ return X
+ cast = X.copy(deep=False)
+ for label in bool_columns:
+ column = X[label].astype(object)
+ cast[label] = column.where(column.notna(), np.nan)
+ return cast
+
+
def detect_column_types(X, *, cat_cutoff, treat_all_integers_as_numerical, estimator_name="Preprocessor"):
"""Classify each column of ``X`` as numerical or categorical.
An integer column is treated as categorical when its cardinality falls below
``cat_cutoff`` -- interpreted as a unique-ratio cutoff when a float, or an
- absolute unique-count cutoff when an int. Non-numeric dtypes are always
- categorical; ``treat_all_integers_as_numerical`` bypasses the heuristic for
- integer columns.
+ absolute unique-count cutoff when an int. Non-numeric dtypes, including
+ ``bool`` and pandas' nullable ``boolean``, are always categorical;
+ ``treat_all_integers_as_numerical`` bypasses the heuristic for integer columns.
Returns
-------
diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py
index a8193b8..51314b7 100644
--- a/pretab/preprocessor.py
+++ b/pretab/preprocessor.py
@@ -13,7 +13,7 @@
from .compose.config import PreprocessorConfig
from .compose.factory import build_column_transformer
-from .compose.feature_detection import detect_column_types, to_dataframe
+from .compose.feature_detection import bool_columns_as_object, detect_column_types, to_dataframe
from .compose.inspection import (
build_feature_info,
build_feature_lineage,
@@ -341,6 +341,10 @@ class Preprocessor(TransformerMixin, BaseEstimator):
``"dummy"`` -> ``"one-hot"``, ``"ordinal"`` / ``"label"`` -> ``"int"``, ``"poly"`` ->
``"polynomial"``, and ``"passthrough"`` -> ``"none"``.
+ Boolean columns (``bool`` and pandas' nullable ``boolean``) are categorical and are encoded
+ like any other binary categorical column, with the categories ``False`` and ``True``; a
+ missing value of the nullable ``boolean`` dtype is treated as missing (``NaN``).
+
``transform`` returns a single stacked array by default (``output_structure="matrix"``),
or a dict of per-feature blocks keyed ``num_
`` / ``cat_`` when
``output_structure="blocks"``. Passing ``return_array`` explicitly to ``transform`` /
@@ -580,7 +584,7 @@ def fit(self, X, y=None, embeddings=None):
categorical_features,
sparse_threshold=sparse_threshold,
)
- self.column_transformer_.fit(X, y)
+ self.column_transformer_.fit(self._column_transformer_input(X), y)
self.n_features_in_ = X.shape[1]
self._enforce_output_budget(X.shape[0])
@@ -647,7 +651,7 @@ def transform(self, X, embeddings=None, return_array=None):
output_kind = container if container in ("pandas", "polars") else ("array" if resolved_return_array else "dict")
validate_embedding_request(embeddings, expected=self.embeddings_, output_kind=output_kind)
- transformed_X = self.column_transformer_.transform(X)
+ transformed_X = self.column_transformer_.transform(self._column_transformer_input(X))
if not sp.issparse(transformed_X):
transformed_X = np.asarray(transformed_X)
if self.dtype is not None:
@@ -822,6 +826,17 @@ def output_dims_(self) -> dict:
dims[columns[0]] = width
return dims
+ @staticmethod
+ def _column_transformer_input(X):
+ """Adapt a frame to what the internal ColumnTransformer expects.
+
+ Boolean columns become ``object`` columns, because the categorical
+ imputer rejects ``bool`` (see
+ :func:`~pretab.compose.feature_detection.bool_columns_as_object`). The
+ caller's frame is never modified.
+ """
+ return bool_columns_as_object(X)
+
def _output_itemsize(self) -> int:
"""Bytes per element of the dense transformed array.
diff --git a/tests/compose/test_feature_detection.py b/tests/compose/test_feature_detection.py
index 670e8c4..ec98875 100644
--- a/tests/compose/test_feature_detection.py
+++ b/tests/compose/test_feature_detection.py
@@ -4,7 +4,7 @@
import pandas as pd
import pytest
-from pretab.compose.feature_detection import detect_column_types, to_dataframe
+from pretab.compose.feature_detection import bool_columns_as_object, detect_column_types, to_dataframe
from pretab.exceptions import InvalidParamError, PretabDataError
@@ -105,3 +105,19 @@ def test_invalid_cat_cutoff_type_raises():
df = pd.DataFrame({"x": [1, 2, 3]})
with pytest.raises(InvalidParamError):
detect_column_types(df, cat_cutoff="bad", treat_all_integers_as_numerical=False)
+
+
+def test_bool_columns_as_object_casts_only_boolean_columns():
+ frame = pd.DataFrame({"x": [0.5, 1.5], "flag": [True, False], "nullable": pd.array([True, None], dtype="boolean")})
+ cast = bool_columns_as_object(frame)
+ assert cast["x"].dtype == np.float64
+ assert cast["flag"].dtype == object and cast["flag"].tolist() == [True, False]
+ assert cast["nullable"].dtype == object
+ assert cast["nullable"][0] is True and np.isnan(cast["nullable"][1])
+ # The caller's frame keeps its dtypes.
+ assert frame["flag"].dtype == bool and frame["nullable"].dtype == "boolean"
+
+
+def test_bool_columns_as_object_returns_frame_without_bool_columns_unchanged():
+ frame = pd.DataFrame({"x": [0.5, 1.5]})
+ assert bool_columns_as_object(frame) is frame
diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py
index df9d224..191ce43 100644
--- a/tests/integration/test_preprocessor.py
+++ b/tests/integration/test_preprocessor.py
@@ -377,3 +377,70 @@ 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)
+
+
+# --- boolean columns (issue #54) ------------------------------------------------
+
+
+@pytest.fixture
+def bool_frame():
+ return pd.DataFrame({"x": [0.1, 0.4, 0.2, 0.9, 0.5, 0.7], "flag": [True, False, True, False, True, True]})
+
+
+@pytest.mark.parametrize(
+ ("options", "expected"),
+ [
+ ({}, ["cat_flag"]),
+ ({"categorical_method": "one-hot"}, ["cat_flag_False", "cat_flag_True"]),
+ pytest.param(
+ {"categorical_method": "onehot_from_ordinal"},
+ ["cat_flag_bin_0", "cat_flag_bin_1"],
+ marks=pytest.mark.filterwarnings("ignore:OneHotFromOrdinalTransformer is deprecated:DeprecationWarning"),
+ ),
+ ({"categorical_method": "none"}, ["cat_flag"]),
+ ({"preset": "standard"}, ["cat_flag"]),
+ ({"preset": "expanded"}, ["cat_flag_False", "cat_flag_True"]),
+ ({"preset": "adaptive"}, ["cat_flag"]),
+ ({"missing_policy": "impute"}, ["cat_flag"]),
+ ({"missing_policy": "impute_with_indicator"}, ["cat_flag"]),
+ ({"missing_policy": "separate_state"}, ["cat_flag__representation__flag", "cat_flag__missing__flag__missing"]),
+ ({"treat_all_integers_as_numerical": True}, ["cat_flag"]),
+ ],
+)
+def test_bool_column_is_encoded_as_a_binary_categorical(bool_frame, options, expected):
+ """Regression guard for issue #54: SimpleImputer rejected the bool dtype."""
+ pre = Preprocessor(**options).fit(bool_frame, np.arange(6.0))
+ names = pre.get_feature_names_out().tolist()
+ assert names[-len(expected) :] == expected
+ assert pre.categorical_features_ == ["flag"]
+ assert np.asarray(pre.transform(bool_frame)).shape == (6, len(names))
+
+
+def test_bool_column_integer_codes_follow_the_values(bool_frame):
+ pre = Preprocessor(categorical_method="int").fit(bool_frame, np.arange(6.0))
+ codes = np.asarray(pre.transform(bool_frame))[:, -1]
+ np.testing.assert_array_equal(codes, np.where(bool_frame["flag"], 2.0, 1.0))
+
+
+def test_nullable_boolean_column_with_missing_values_is_imputed(bool_frame):
+ frame = bool_frame.astype({"flag": "boolean"})
+ frame.loc[2, "flag"] = pd.NA
+ pre = Preprocessor(categorical_method="one-hot").fit(frame, np.arange(6.0))
+ assert pre.get_feature_names_out().tolist()[-2:] == ["cat_flag_False", "cat_flag_True"]
+ one_hot = np.asarray(pre.transform(frame))[:, -2:]
+ np.testing.assert_array_equal(one_hot[2], [0.0, 1.0]) # imputed with the most frequent value, True
+
+
+def test_nullable_boolean_missing_value_gets_the_missing_code_without_imputation(bool_frame):
+ frame = bool_frame.astype({"flag": "boolean"})
+ frame.loc[2, "flag"] = pd.NA
+ pre = Preprocessor(categorical_method="int", missing_policy="propagate").fit(frame, np.arange(6.0))
+ codes = np.asarray(pre.transform(frame))[:, -1]
+ np.testing.assert_array_equal(codes, [2.0, 1.0, 0.0, 1.0, 2.0, 2.0]) # False -> 1, True -> 2, missing -> 0
+
+
+def test_bool_numpy_array_is_supported():
+ X = np.array([[True], [False], [True], [True]])
+ pre = Preprocessor().fit(X)
+ assert pre.categorical_features_ == ["feature_0"]
+ assert np.asarray(pre.transform(X)).shape == (4, 1)