pandas-dev/pandas · #71770

ENH: astype("boolean") converts values as astype(bool) (GH#37614)

jbrockmendel · merged Nov 7, 20267 files · 155 + / 56 −
doc/source/whatsnew/v3.2.0.rst1 + / 0 −
@@ -36,6 +36,7 @@ Other enhancements - :meth:`DataFrame.plot.area` and :meth:`Series.plot.area` now accept keyword arguments that only the lines or only the filled areas support, such as ``hatch``, ``edgecolor`` and ``marker``, instead of raising ``AttributeError``; ``hatch`` also accepts a list with one hatch per column (:issue:`12354`) - :meth:`DataFrame.to_csv` and other writers now accept an ``os.PathLike`` such as :class:`pathlib.Path` for the ``archive_name`` entry of a zip or tar ``compression`` dict (:issue:`31934`) - :meth:`DataFrame.to_json`, :meth:`Series.to_json` and :meth:`DataFrame.to_xml` now warn when a compression method is requested but no path is given, since the returned string is not compressed, and raise for an unrecognized ``compression`` as they do with a path (:issue:`32326`)+- :meth:`Series.astype` and :func:`array` with ``dtype="boolean"`` now convert values the same way as ``astype(bool)`` instead of raising for values other than booleans, 0 and 1 (e.g. ``"False"`` becomes ``True``), with missing values becoming ``NA`` (:issue:`37614`, :issue:`40566`) - :meth:`Series.groupby` now accepts ``as_index=False``, returning aggregation results as a :class:`DataFrame` with the group labels as columns (:issue:`36507`) - Added :meth:`Categorical.to_dense` and :meth:`Series.cat.to_dense` to get the values with the dtype of the categories, including extension dtypes (:issue:`26410`) - Added :meth:`DatetimeIndex.set_freq` and :meth:`TimedeltaIndex.set_freq`, which return a new index with the given ``freq`` instead of modifying it in place (:issue:`61094`)
pandas/core/arrays/boolean.py39 + / 29 −
@@ -5,12 +5,13 @@     TYPE_CHECKING,     ClassVar,     Self,-    cast, ) import warnings  import numpy as np +from pandas._config import is_nan_na+ from pandas._libs import (     lib,     missing as libmissing,@@ -20,16 +21,21 @@ from pandas.util._exceptions import find_stack_level  from pandas.core.dtypes.common import is_list_like-from pandas.core.dtypes.dtypes import register_extension_dtype+from pandas.core.dtypes.dtypes import (+    CategoricalDtype,+    register_extension_dtype,+) from pandas.core.dtypes.missing import isna  from pandas.core import ops from pandas.core.array_algos import masked_accumulations+from pandas.core.array_algos.take import take_nd from pandas.core.arrays import ExtensionArray from pandas.core.arrays.masked import (     BaseMaskedArray,     BaseMaskedDtype, )+from pandas.core.construction import extract_array  if TYPE_CHECKING:     import pyarrow@@ -200,45 +206,49 @@ def coerce_to_array(             mask = mask.copy()         return values, mask +    # Values are converted as by astype(bool), except that missing values+    #  (NA, None, NaT, and NaN unless future.distinguish_nan_and_na) are masked+    values = extract_array(values, extract_numpy=True)     mask_values = None     if isinstance(values, np.ndarray) and values.dtype == np.bool_:         if copy:             values = values.copy()-    elif isinstance(values, np.ndarray) and values.dtype.kind in "iufcb":-        mask_values = isna(values)-+    elif isinstance(values, np.ndarray) and values.dtype.kind in "iufcmM":+        if values.dtype.kind in "fc" and not is_nan_na():+            mask_values = np.zeros(values.shape, dtype=bool)+        else:+            mask_values = isna(values)+        values = values.astype(bool)+        values[mask_values] = False+    elif isinstance(getattr(values, "dtype", None), CategoricalDtype):+        # convert each category once; Categorical.astype(bool) can warn here+        cat_values, _ = coerce_to_array(values.categories._values)+        mask_values = values.codes == -1+        values = take_nd(cat_values, values.codes, fill_value=False)+    elif isinstance(values, ExtensionArray):+        mask_values = np.asarray(values.isna(), dtype=bool)         values_bool = np.zeros(len(values), dtype=bool)         values_bool[~mask_values] = values[~mask_values].astype(bool)--        if not np.all(-            values_bool[~mask_values].astype(values.dtype) == values[~mask_values]-        ):-            raise TypeError("Need to pass bool-like values")-         values = values_bool     else:         values_object = np.asarray(values, dtype=object)--        inferred_dtype = lib.infer_dtype(values_object, skipna=True)-        integer_like = ("floating", "integer", "mixed-integer-float")-        if inferred_dtype not in ("boolean", "empty", *integer_like):-            raise TypeError("Need to pass bool-like values")--        # mypy does not narrow the type of mask_values to npt.NDArray[np.bool_]-        # within this branch, it assumes it can also be None-        mask_values = cast("npt.NDArray[np.bool_]", isna(values_object))  # type: ignore[redundant-cast]+        if values_object.ndim != 1:+            raise TypeError("values must be a 1D list-like")+        mask_values = isna(values_object)+        if not is_nan_na():+            # NaN is a value rather than missing, so it converts to True+            idx = mask_values.nonzero()[0]+            is_nan = np.array(+                [+                    lib.is_float(x) or lib.is_complex(x) or lib.is_decimal(x)+                    for x in values_object[idx]+                ],+                dtype=bool,+            )+            mask_values[idx[is_nan]] = False         values = np.zeros(len(values), dtype=bool)         values[~mask_values] = values_object[~mask_values].astype(bool) -        # if the values were integer-like, validate it were actually 0/1's-        if (inferred_dtype in integer_like) and not (-            np.all(-                values[~mask_values].astype(float)-                == values_object[~mask_values].astype(float)-            )-        ):-            raise TypeError("Need to pass bool-like values")-     if mask is None and mask_values is None:         mask = np.zeros(values.shape, dtype=bool)     elif mask is None:
pandas/core/reshape/encoding.py3 + / 5 −
@@ -547,11 +547,9 @@ def from_dummies(             f"Dummy DataFrame contains NA value in column: '{col_isna_mask.idxmax()}'"         ) -    # index data with a list of all columns that are dummies-    try:-        data_to_decode = data.astype("boolean")-    except TypeError as err:-        raise TypeError("Passed DataFrame contains non-dummy data") from err+    if not data.isin([0, 1]).all().all():+        raise TypeError("Passed DataFrame contains non-dummy data")+    data_to_decode = data.astype("boolean")      # collect prefixes and get lists to slice data for each prefix     variables_slice = defaultdict(list)
pandas/tests/arrays/boolean/test_astype.py31 + / 0 −
@@ -50,6 +50,37 @@ def test_astype_to_boolean_array():     tm.assert_extension_array_equal(result, arr)  +@pytest.mark.parametrize(+    "values, dtype, expected",+    [+        ([0, 1, 2], "int64", [False, True, True]),+        ([0.0, 0.5, np.nan], "float64", [False, True, None]),+        (["", "False", None], "object", [False, True, None]),+        (["", "False", None], "string", [False, True, None]),+        (["", "False", None], pd.StringDtype(na_value=np.nan), [False, True, None]),+        ([0, 2, None], "category", [False, True, None]),+        (["", "x", None], "category", [False, True, None]),+        ([None, None], "category", [None, None]),+        ([0, 5, None], "timedelta64[ns]", [False, True, None]),+        (["1970-01-01", "2000-01-01", None], "M8[ns]", [False, True, None]),+        (["1970-01-01", "2000-01-01", None], "M8[ns, UTC]", [False, True, None]),+        (["1970-01-01", None], "period[D]", [True, None]),+        ([0, 2, None], "int64[pyarrow]", [False, True, None]),+    ],+)+def test_astype_from_other_dtypes_matches_astype_bool(values, dtype, expected):+    # GH#37614, GH#40566+    if dtype == "int64[pyarrow]":+        pytest.importorskip("pyarrow")+    ser = pd.Series(values, dtype=dtype)+    result = ser.astype("boolean")+    expected = pd.Series(expected, dtype="boolean")+    tm.assert_series_equal(result, expected)++    notna = ser.notna()+    tm.assert_series_equal(result[notna], ser[notna].astype(bool).astype("boolean"))++ def test_astype_to_integer_array():     # astype to IntegerArray     arr = pd.array([True, False, None], dtype="boolean")
pandas/tests/arrays/boolean/test_construction.py58 + / 16 −
@@ -1,3 +1,5 @@+from decimal import Decimal+ import numpy as np import pytest @@ -96,25 +98,65 @@ def test_to_boolean_array_missing_indicators(a, b):   @pytest.mark.parametrize(-    "values",+    "values, expected",+    [+        (["foo", "", "False", None], [True, False, True, None]),+        ([0, 1, 2, -1, None], [False, True, True, True, None]),+        ([0.0, 0.5, np.nan], [False, True, None]),+        ([0, "a", 1.5, pd.NA, pd.NaT], [False, True, True, None, None]),+        ([np.nan, {"a": 1}, {}], [None, True, False]),+        # a list is treated as object dtype, where every Timestamp is truthy+        ([pd.Timestamp(0), pd.NaT], [True, None]),+        (np.array(["foo", ""]), [True, False]),+        (np.array([0, 1, 2]), [False, True, True]),+        (np.array([0.0, 0.5, np.nan]), [False, True, None]),+        (np.array([0j, 1j, complex(np.nan, 0)]), [False, True, None]),+        (np.array([0, 5, "NaT"], dtype="m8[ns]"), [False, True, None]),+        (+            np.array(["1970-01-01", "2000-01-01", "NaT"], dtype="M8[ns]"),+            [False, True, None],+        ),+        (pd.date_range("1970-01-01", periods=2, tz="UTC"), [False, True]),+        (pd.period_range("2000", periods=2), [True, True]),+    ],+)+def test_to_boolean_array_matches_astype_bool(values, expected):+    # GH#37614 values are converted as by astype(bool), with missing values masked+    result = pd.array(values, dtype="boolean")+    expected = pd.array(expected, dtype="boolean")+    tm.assert_extension_array_equal(result, expected)+++@pytest.mark.parametrize(+    "values, expected",     [-        ["foo", "bar"],-        ["1", "2"],-        # "foo",-        [1, 2],-        [1.0, 2.0],-        pd.date_range("20130101", periods=2),-        np.array(["foo"]),-        np.array([1, 2]),-        np.array([1.0, 2.0]),-        [np.nan, {"a": 1}],+        (+            [0.0, np.nan, None, pd.NA, pd.NaT, Decimal("nan")],+            [False, True, None, None, None, True],+        ),+        (np.array([0.0, np.nan]), [False, True]),+        (np.array([0j, complex(np.nan, 0)]), [False, True]),     ], )-def test_to_boolean_array_error(values):-    # error in converting existing arrays to BooleanArray-    msg = "Need to pass bool-like value"-    with pytest.raises(TypeError, match=msg):-        pd.array(values, dtype="boolean")+def test_to_boolean_array_distinguish_nan_and_na(values, expected):+    # with NaN distinct from NA, NaN is a value and converts as bool(np.nan)+    with pd.option_context("future.distinguish_nan_and_na", True):+        result = pd.array(values, dtype="boolean")+    expected = pd.array(expected, dtype="boolean")+    tm.assert_extension_array_equal(result, expected)+++def test_float_ea_astype_boolean_distinguish_nan_and_na():+    with pd.option_context("future.distinguish_nan_and_na", True):+        arr = pd.array([0.0, np.nan, None], dtype="Float64")+        result = arr.astype("boolean")+    expected = pd.array([False, True, None], dtype="boolean")+    tm.assert_extension_array_equal(result, expected)+++def test_to_boolean_array_2d_raises():+    with pytest.raises(TypeError, match="values must be a 1D list-like"):+        pd.array([[True, False]], dtype="boolean")   def test_to_boolean_array_from_integer_array():
pandas/tests/reshape/test_from_dummies.py22 + / 0 −
@@ -95,6 +95,14 @@ def test_error_contains_non_dummies():         pd.from_dummies(dummies)  +@pytest.mark.parametrize("values", [[0, 0.5], ["0", "1"]])+def test_error_contains_non_dummies_bool_castable(values):+    # values that astype("boolean") accepts but are not 0 or 1+    dummies = pd.DataFrame({"a": values, "b": [1, 0]})+    with pytest.raises(TypeError, match=r"Passed DataFrame contains non-dummy data"):+        pd.from_dummies(dummies)++ def test_error_with_prefix_multiple_separators():     dummies = pd.DataFrame(         {@@ -250,6 +258,20 @@ def test_no_prefix_string_cats_basic_mixed_bool_values():     tm.assert_frame_equal(result, expected)  +@pytest.mark.parametrize(+    "dtype", ["int64", "uint8", "float64", "Int64", "boolean", "int64[pyarrow]"]+)+def test_no_prefix_string_cats_basic_dtypes(dtype):+    if dtype == "int64[pyarrow]":+        pytest.importorskip("pyarrow")+    dummies = pd.DataFrame(+        {"a": [1, 0, 0], "b": [0, 1, 0], "c": [0, 0, 1]}, dtype=dtype+    )+    expected = pd.DataFrame({"": ["a", "b", "c"]})+    result = pd.from_dummies(dummies)+    tm.assert_frame_equal(result, expected)++ def test_no_prefix_int_cats_basic():     dummies = pd.DataFrame(         {1: [1, 0, 0, 0], 25: [0, 1, 0, 0], 2: [0, 0, 1, 0], 5: [0, 0, 0, 1]}
pandas/tests/series/methods/test_astype.py1 + / 6 −
@@ -511,13 +511,8 @@ class TestAstypeString:         ],     )     def test_astype_string_to_extension_dtype_roundtrip(-        self, data, dtype, request, nullable_string_dtype+        self, data, dtype, nullable_string_dtype     ):-        if dtype == "boolean":-            mark = pytest.mark.xfail(-                reason="TODO StringArray.astype() with missing values #GH40566"-            )-            request.applymarker(mark)         # GH-40351         ser = pd.Series(data, dtype=dtype)