Skip to content
16 changes: 16 additions & 0 deletions python/cudf/cudf/core/column/numerical.py
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,22 @@ def _process_values_for_isin(
rhs = rhs.astype(lhs.dtype)
elif lhs.can_cast_safely(rhs.dtype):
lhs = lhs.astype(rhs.dtype)
elif (
isinstance(lhs.dtype, np.dtype)
and isinstance(rhs.dtype, np.dtype)
and lhs.dtype.kind in "biuf"
and rhs.dtype.kind in "biuf"
and "b" in (lhs.dtype.kind, rhs.dtype.kind)
):
# A boolean column compares by value against numeric needles
# (``True == 1``) like numpy/pandas. ``can_cast_safely`` reports
# bool<->numeric as unsafe, so promote the boolean side to the
# numeric dtype (a bool always fits) rather than bailing out to
# an all-False result.
if lhs.dtype.kind == "b":
lhs = lhs.astype(rhs.dtype)
else:
rhs = rhs.astype(lhs.dtype)
return lhs, rhs

def _can_return_nan(self, skipna: bool | None = None) -> bool:
Expand Down
38 changes: 36 additions & 2 deletions python/cudf/cudf/core/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@
get_dtype_of_same_kind,
is_mixed_with_object_dtype,
is_pandas_nullable_extension_dtype,
is_pandas_nullable_numpy_dtype,
)
from cudf.utils.performance_tracking import _performance_tracking
from cudf.utils.utils import _EQUALITY_OPS, _is_same_name
from cudf.utils.utils import _EQUALITY_OPS, _is_same_name, is_na_like

if TYPE_CHECKING:
from collections.abc import Hashable, Iterable, MutableMapping
Expand Down Expand Up @@ -3141,8 +3142,41 @@ def isin(self, values):
f"to isin(), you passed a [{type(values).__name__}]"
)

if is_pandas_nullable_numpy_dtype(self.dtype) and not isinstance(
self.dtype, pd.StringDtype
):
Comment thread
galipremsagar marked this conversation as resolved.
Resolved
# Mirror pandas' BaseMaskedArray.isin for masked (nullable
# integer/float/boolean) dtypes:
# * matching is done on the underlying numpy values, so e.g. a
# boolean element equals the integer 1,
# * an NA element is considered present only when pd.NA itself
# is one of the passed values (a plain NaN/None/NaT does not
# match), and
# * the result is a nullable BooleanDtype with no missing values.
numpy_dtype = self.dtype.numpy_dtype
na_mask = self._column.isnull()
placeholder = False if numpy_dtype.kind == "b" else 0
data_col = self._column.astype(numpy_dtype).fillna(placeholder)
# NA-like sentinels (pd.NA/None/NaT) never match a real value and
# would otherwise raise when mixed with numeric values; NA rows
# are handled by the mask below instead.
cleaned_values = [
value for value in values if not is_na_like(value)
]
result_col = data_col.isin(cleaned_values)
if na_mask.any():
values_have_NA = any(value is pd.NA for value in values)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
result_col = (
result_col | na_mask
if values_have_NA
else result_col & ~na_mask
)
result_col = result_col.astype(pd.BooleanDtype())
else:
result_col = self._column.isin(values)

return Series._from_column(
self._column.isin(values),
result_col,
name=self.name,
index=self.index,
attrs=self.attrs,
Expand Down
12 changes: 0 additions & 12 deletions python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4116,18 +4116,6 @@ def pytest_unconfigure(config):
"tests/series/methods/test_info.py::test_info_series[False]": "TODO: Add a reason for failure",
"tests/series/methods/test_info.py::test_info_series[True]": "TODO: Add a reason for failure",
"tests/series/methods/test_info.py::test_info_show_counts_false": "assert '<class 'pand...: 140.0 bytes' == '<class 'pand...ge: 8.0 bytes'",
"tests/series/methods/test_isin.py::TestSeriesIsIn::test_isin_masked_types[data0-values0-expected0-Float64]": "TODO: Add a reason for failure",
"tests/series/methods/test_isin.py::TestSeriesIsIn::test_isin_masked_types[data0-values0-expected0-Int64]": "TODO: Add a reason for failure",
"tests/series/methods/test_isin.py::TestSeriesIsIn::test_isin_masked_types[data0-values0-expected0-boolean]": "TODO: Add a reason for failure",
"tests/series/methods/test_isin.py::TestSeriesIsIn::test_isin_masked_types[data2-values2-expected2-Float64]": "TODO: Add a reason for failure",
"tests/series/methods/test_isin.py::TestSeriesIsIn::test_isin_masked_types[data2-values2-expected2-Int64]": "TODO: Add a reason for failure",
"tests/series/methods/test_isin.py::TestSeriesIsIn::test_isin_masked_types[data2-values2-expected2-boolean]": "TODO: Add a reason for failure",
"tests/series/methods/test_isin.py::TestSeriesIsIn::test_isin_masked_types[data4-values4-expected4-Float64]": "TODO: Add a reason for failure",
"tests/series/methods/test_isin.py::TestSeriesIsIn::test_isin_masked_types[data4-values4-expected4-Int64]": "TODO: Add a reason for failure",
"tests/series/methods/test_isin.py::TestSeriesIsIn::test_isin_masked_types[data4-values4-expected4-boolean]": "TODO: Add a reason for failure",
"tests/series/methods/test_isin.py::TestSeriesIsIn::test_isin_masked_types[data5-values5-expected5-Int64]": "TODO: Add a reason for failure",
"tests/series/methods/test_isin.py::TestSeriesIsIn::test_isin_masked_types[data5-values5-expected5-boolean]": "TODO: Add a reason for failure",
"tests/series/methods/test_isin.py::test_isin_large_series_and_pdNA[boolean-data3-values3-expected3]": "AssertionError: Attributes of Series are different",
"tests/series/methods/test_map.py::test_map_callable[MockEngineDecorator]": "AssertionError: assert Index([], dtype='object', name='bar') is Index([], dtype='object', name='bar')",
"tests/series/methods/test_map.py::test_map_callable[None]": "AssertionError: assert Index([], dtype='object', name='bar') is Index([], dtype='object', name='bar')",
"tests/series/methods/test_map.py::test_map_categorical_na_action[None-expected0]": "TODO: Add a reason for failure",
Expand Down
67 changes: 66 additions & 1 deletion python/cudf/cudf/tests/series/methods/test_isin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION.
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

import numpy as np
import pandas as pd
import pyarrow as pa
import pytest

import cudf
Expand Down Expand Up @@ -159,3 +160,67 @@ def test_isin_categorical(data, values):
got = gsr.isin(values)
expected = psr.isin(values)
assert_eq(got, expected)


@pytest.mark.parametrize("dtype", ["boolean", "Int64", "Float64"])
@pytest.mark.parametrize(
"data,values",
[
([0, 1, 0], [1]),
([0, 1, 0], [1, pd.NA]),
([0, pd.NA, 0], [1, 0]),
([0, 1, pd.NA], [1, pd.NA]),
([0, 1, pd.NA], [1, np.nan]),
([0, pd.NA, pd.NA], [np.nan, pd.NaT, None]),
],
)
def test_isin_masked_types(dtype, data, values):
# Series.isin on a pandas masked (nullable integer/float/boolean) dtype
# returns a nullable BooleanDtype result and matches pandas' NA semantics:
# * comparison is done on the underlying values (a boolean element equals
# the integer 1), and
# * an NA element is considered present only when pd.NA itself is one of
# ``values`` (a plain NaN/None/NaT does not match).
psr = pd.Series(data, dtype=dtype)
gsr = cudf.Series(data, dtype=dtype)

got = gsr.isin(values)
expected = psr.isin(values)

assert got.dtype == pd.BooleanDtype()
assert_eq(got, expected)


@pytest.mark.parametrize(
"values",
[[1], [0], [1, 0], [2], [1.0], [1.5], [True], [False]],
)
def test_isin_bool_against_numeric(values):
# A boolean Series compares equal to the integers/floats 0 and 1, matching
# numpy/pandas value semantics (previously cudf returned all-False for a
# numeric ``values`` argument).
psr = pd.Series([True, False, False, True])
gsr = cudf.Series([True, False, False, True])

got = gsr.isin(values)
assert got.dtype == np.dtype("bool")
assert_eq(got, psr.isin(values))


@pytest.mark.parametrize(
"psr",
[
pd.Series([0, 1, 0], dtype=pd.ArrowDtype(pa.int64())),
pd.Series([1.0, 2.0, None], dtype=pd.ArrowDtype(pa.float64())),
pd.Series([True, False, True], dtype=pd.ArrowDtype(pa.bool_())),
pd.Series(["a", "b", "a"], dtype="category"),
],
)
def test_isin_non_masked_extension_returns_numpy_bool(psr):
# Arrow and categorical inputs yield a numpy bool result (only masked
# numeric/boolean dtypes upgrade to nullable boolean).
gsr = cudf.from_pandas(psr)

got = gsr.isin([psr.iloc[0]])
assert got.dtype == np.dtype("bool")
assert_eq(got, psr.isin([psr.iloc[0]]))
Loading