Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api-reference/dataframe.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- columns
- drop
- drop_nulls
- equals
- estimated_size
- explode
- filter
Expand Down
1 change: 1 addition & 0 deletions docs/api-reference/series.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
- diff
- drop_nulls
- dtype
- equals
- ewm_mean
- exp
- fill_nan
Expand Down
16 changes: 16 additions & 0 deletions src/narwhals/_arrow/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,22 @@ def schema(self) -> dict[str, DType]:
def collect_schema(self) -> dict[str, DType]:
return self.schema

def equals(self, other: Self, *, null_equal: bool) -> bool:
if self.shape != other.shape:
return False
if self.schema != other.schema:
return False

return all(
self.get_column(name).equals(
other.get_column(name),
check_dtypes=False,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we check also the dtype match? I think it's ok to turn off the check as long as above

- if self.columns != other.columns:
+ if self.schema != other.schema:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check_names=False,
null_equal=null_equal,
)
for name in self.columns
)

def estimated_size(self, unit: SizeUnit) -> int | float:
sz = self.native.nbytes
return scale_bytes(sz, unit)
Expand Down
34 changes: 34 additions & 0 deletions src/narwhals/_arrow/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,40 @@ def cast(self, dtype: IntoDType) -> Self:
def null_count(self, *, _return_py_scalar: bool = True) -> int:
return maybe_extract_py_scalar(self.native.null_count, _return_py_scalar)

def equals(
self, other: Self, *, check_dtypes: bool, check_names: bool, null_equal: bool
) -> bool:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should also compare NaNs, which will always be equivalent to one another.

You could do something like

lhs = ...
rhs = ...

try:
    both_nan = pc.fill_null(pc.and_(pc.is_nan(lhs), pc.is_nan(rhs)), False)
except ArrowNotImplementedError:
    both_nan = pa.scalar(False) # may need to broadcast this back out to a full array?

values_equal = pc.equal(lhs, rhs)
values_or_nan_equal = pc.fill_null(pc.or_(values_equal, both_nan), False) # no nulls in this array

if null_equal:
    # make the extra comparison with null values if null_equal is True
    ....
    return ...
return bool(pc.all(values_or_nan_equal).as_py())

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

16c7980

@camriddell: Thanks for the suggestion πŸ‘ŒπŸΌ Note that I slightly rewrote so that the question of broadcasting doesn't even arise in the first place

if (
(check_names and self._name != other._name)
or (check_dtypes and self.dtype != other.dtype)
or len(self) != len(other)
):
return False

lhs = self.native
rhs = other.native

if not null_equal and (lhs.null_count or rhs.null_count):
return False

try:
values_equal = pc.equal(lhs, rhs)
except pa.lib.ArrowNotImplementedError:
return False

try:
both_nan = pc.fill_null(pc.and_(pc.is_nan(lhs), pc.is_nan(rhs)), False)
values_or_nan_equal = pc.fill_null(pc.or_(values_equal, both_nan), False)
except pa.lib.ArrowNotImplementedError:
values_or_nan_equal = pc.fill_null(values_equal, False)

if null_equal:
both_null = pc.and_(pc.is_null(lhs), pc.is_null(rhs))
result = pc.if_else(both_null, True, values_or_nan_equal)
return bool(pc.all(result, skip_nulls=False).as_py())

return bool(pc.all(values_or_nan_equal).as_py())

def head(self, n: int) -> Self:
if n >= 0:
return self._with_native(self.native.slice(0, n))
Expand Down
1 change: 1 addition & 0 deletions src/narwhals/_compliant/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ def __getitem__(
@property
def shape(self) -> tuple[int, int]: ...
def clone(self) -> Self: ...
def equals(self, other: Self, *, null_equal: bool) -> bool: ...
def estimated_size(self, unit: SizeUnit) -> int | float: ...
def gather_every(self, n: int, offset: int) -> Self: ...
def get_column(self, name: str) -> CompliantSeriesT: ...
Expand Down
3 changes: 3 additions & 0 deletions src/narwhals/_compliant/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ def arg_max(self) -> int: ...
def arg_min(self) -> int: ...
def arg_true(self) -> Self: ...
def count(self) -> int: ...
def equals(
self, other: Self, *, check_dtypes: bool, check_names: bool, null_equal: bool
) -> bool: ...
def filter(self, predicate: Any) -> Self: ...
def first(self) -> PythonLiteral: ...
def last(self) -> PythonLiteral: ...
Expand Down
9 changes: 9 additions & 0 deletions src/narwhals/_pandas_like/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,15 @@ def drop_nulls(self, subset: Sequence[str] | None) -> Self:
mask = ~plx.any_horizontal(plx.col(*subset).is_null(), ignore_nulls=True)
return self.filter(mask)

def equals(self, other: Self, *, null_equal: bool) -> bool:
if self.shape != other.shape:
return False # not needed but can short-circuit the isna() scan below
if not null_equal and (
self.native.isna().any(axis=None) or other.native.isna().any(axis=None)
):
return False
return self.native.equals(other.native)

def estimated_size(self, unit: SizeUnit) -> int | float:
sz = self.native.memory_usage(deep=True).sum()
return scale_bytes(sz, unit=unit)
Expand Down
19 changes: 19 additions & 0 deletions src/narwhals/_pandas_like/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,25 @@ def struct(self) -> PandasLikeSeriesStructNamespace:
raise TypeError(msg)
return PandasLikeSeriesStructNamespace(self)

def equals(
self, other: Self, *, check_dtypes: bool, check_names: bool, null_equal: bool
) -> bool:
if check_names and self._name != other._name:
return False
if check_dtypes and self.dtype != other.dtype:
return False
if not null_equal:
if len(self) != len(other):
return False # not needed but can short-circuit the isna() checks below
if self.native.isna().any() or other.native.isna().any():
return False
other_native = (
other.native.astype(self.native.dtype)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how I feel about this, can't downcasting potentially raise an error? This is where a supertype(left_dtype, right_dtype) would be quite useful

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed we should avoid manually casting here.

>>> import pandas as pd
>>> self = pd.Series([1, 2, 3])
>>> other = pd.Series([*'123'])
>>> self.equals(other)
False
>>> self.equals(other.astype(self.dtype))
True
dtype: int64
>>> other.astype(self.dtype)
0    1
1    2
2    3

Instead of directly using pandas.Series.equals we should probably use the == operator along with a .isna() check on both sides since the == operator will take care of the casting for us:

>>> pd.Series([1,2,3], dtype="int64") == pd.Series([1.0, 2.0, 3.0], dtype="float64")
0    True
1    True
2    True
dtype: bool

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! But I guess the supertype utility does not yet exist, right? Is this something for a separate PR? Also, for which backends would we need it? All of them, or just pandas, specifically for this PR? For the numpy backend, I think that np.result_type may do the job, but unclear how to best handle the pyarrow backend...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the "not enough" context - that's correct get_supertype is still not here. Hopefully it will eventually get merged #3396

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, so I guess I'll be waiting then until this happens

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll be waiting then until this happens

Please don't! There is not enough consensus on it for now - I brought it up as a nice use case for it, not for blocking you

if not check_dtypes and self.native.dtype != other.native.dtype
else other.native
)
return self.native.equals(other_native)


class _PandasHist(EagerSeriesHist["pd.Series[Any]", "list[float]"]):
_series: PandasLikeSeries
Expand Down
2 changes: 2 additions & 0 deletions src/narwhals/_polars/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
[
"clone",
"drop_nulls",
"equals",
"estimated_size",
"explode",
"filter",
Expand Down Expand Up @@ -108,6 +109,7 @@

class PolarsBaseFrame(Generic[NativePolarsFrame]):
drop_nulls: Method[Self]
equals: Method[bool]
filter: Method[Self]
gather_every: Method[Self]
head: Method[Self]
Expand Down
17 changes: 17 additions & 0 deletions src/narwhals/_polars/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,23 @@ def value_counts(
)
return PolarsDataFrame.from_native(result, context=self)

def equals(
self, other: Self, *, check_dtypes: bool, check_names: bool, null_equal: bool
) -> bool:
if self._backend_version < (0, 20, 31): # pragma: no cover
return self.native.equals(
other.native,
strict=check_dtypes,
check_names=check_names,
null_equal=null_equal,
)
return self.native.equals(
other.native,
check_dtypes=check_dtypes,
check_names=check_names,
null_equal=null_equal,
)

def cum_count(self, *, reverse: bool) -> Self:
return self._with_native(self.native.cum_count(reverse=reverse))

Expand Down
3 changes: 3 additions & 0 deletions src/narwhals/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,9 @@ def drop_nulls(self, subset: str | list[str] | None = None) -> Self:
"""
return super().drop_nulls(subset=subset)

def equals(self, other: Self, *, null_equal: bool = True) -> bool:
return self._compliant_frame.equals(other._compliant_frame, null_equal=null_equal)

def with_row_index(
self, name: str = "index", *, order_by: str | Sequence[str] | None = None
) -> Self:
Expand Down
15 changes: 15 additions & 0 deletions src/narwhals/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -2946,3 +2946,18 @@ def list(self) -> SeriesListNamespace[Self]:
@property
def struct(self) -> SeriesStructNamespace[Self]:
return SeriesStructNamespace(self)

def equals(
self,
other: Self,
*,
check_dtypes: bool = False,
check_names: bool = False,
null_equal: bool = True,
) -> bool:
return self._compliant_series.equals(
other._compliant_series,
check_dtypes=check_dtypes,
check_names=check_names,
null_equal=null_equal,
)
136 changes: 136 additions & 0 deletions tests/series_only/equals_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

import narwhals as nw

if TYPE_CHECKING:
from tests.utils import ConstructorEager

TBD = None
_CHECK_NAMES = pytest.mark.xfail(
strict=True,
reason=(
"Bare ChunkedArray carries no name metadata, resulting in empty names - "
"TBD whether check_names=True should raise for pyarrow in case of empty names"
),
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The names should exist on the narwhals._arrow.series.ArrowSeries object. The code seems to already have checks for names in them, unless this note was about something else?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the _name field exists on ArrowSeries, but my question is a different one: pa.ChunkedArray is an anonymous array. So when narwhals wraps one, _name is always set to "" regardless of what column it came from:

import pyarrow as pa
import narwhals as nw

t = pa.table({"left": [1, 2], "right": [1, 2]})
left  = nw.from_native(t["left"],  series_only=True)
right = nw.from_native(t["right"], series_only=True)

print(left.name)   # ""
print(right.name)  # ""
print(left.equals(right, check_names=True))  # True β€” names are both "", so check passes silently

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see the where the issue arises now. Can you rewrite the test in this fashion so the metadata is preserved appropriately?

left_native = constructor_eager({left_name: data})
right_native = constructor_eager({right_name: data})
left_nw = nw.from_native(left_native)[left_name]
right_nw = nw.from_native(right_native)[right_name]

result = left_nw.equals(right_nw, check_names=check_names)
assert result == expected



@pytest.mark.parametrize(
("left", "right", "expected"),
[
# --- Same dtype: int ---
([1, 2], [1, 2], True),
# --- Same dtype: float ---
([1.1, 2.2], [1.1, 2.2], True),
# --- Value differs ---
([1, 2], [1, 0], False),
# --- Cross-supertype: float vs int ---
([1.0, 2.0], [1, 2], True),
([1.1, 2.2], [1, 2], False),
# --- Cross-supertype: int vs string ---
([1, 2], ["1", "2"], False),
],
)
def test_series_equals(
constructor_eager: ConstructorEager,
left: list,
right: list,
expected: bool | None, # noqa: FBT001
) -> None:
left_native = constructor_eager({"left": left})["left"]
right_native = constructor_eager({"right": right})["right"]
left_nw = nw.from_native(left_native, series_only=True)
right_nw = nw.from_native(right_native, series_only=True)

result = left_nw.equals(right_nw)
assert result == expected


@pytest.mark.parametrize(
("left", "right", "null_equal", "expected"),
[
# --- Null vs value ---
([1, None], [1, 2], True, False),
([1, None], [1, 2], False, False),
# --- Null vs Null ---
([1, None], [1, None], True, True),
([1, None], [1, None], False, False),
# --- NaN vs NaN ---
([1.0, float("nan")], [1.0, float("nan")], True, True),
([1.0, float("nan")], [1.0, float("nan")], False, True),
# --- NaN vs value ---
# pandas: False, polars: False, pyarrow: False
([1.0, float("nan")], [1.0, 2.0], True, False),
],
)
def test_series_equals_null_equal(
constructor_eager: ConstructorEager,
left: list,
right: list,
null_equal: bool, # noqa: FBT001
expected: bool | None, # noqa: FBT001
) -> None:
left_native = constructor_eager({"left": left})["left"]
right_native = constructor_eager({"right": right})["right"]
left_nw = nw.from_native(left_native, series_only=True)
right_nw = nw.from_native(right_native, series_only=True)

result = left_nw.equals(right_nw, null_equal=null_equal)
assert result == expected


@pytest.mark.parametrize(
("left", "right", "check_dtypes", "expected"),
[
# --- Same values, different dtype ---
([1.1, 2.2], [1, 2], False, False),
# --- Whole-number floats, check_dtypes=False ---
([1.0, 2.0], [1, 2], False, True),
# --- Whole-number floats, check_dtypes=True ---
([1.0, 2.0], [1, 2], True, False),
],
)
def test_series_equals_check_dtypes(
constructor_eager: ConstructorEager,
left: list,
right: list,
check_dtypes: bool, # noqa: FBT001
expected: bool | None, # noqa: FBT001
) -> None:
left_native = constructor_eager({"a": left})["a"]
right_native = constructor_eager({"a": right})["a"]
left_nw = nw.from_native(left_native, series_only=True)
right_nw = nw.from_native(right_native, series_only=True)

result = left_nw.equals(right_nw, check_dtypes=check_dtypes)
assert result == expected


@pytest.mark.parametrize(
("left_name", "right_name", "check_names", "expected"),
[
# --- Different names ---
("left", "right", False, True),
# pandas: False, polars: False, pyarrow: True (ChunkedArray has no name metadata)
pytest.param("left", "right", True, TBD, marks=_CHECK_NAMES),
],
)
def test_series_equals_check_names(
constructor_eager: ConstructorEager,
left_name: str,
right_name: str,
check_names: bool, # noqa: FBT001
expected: bool | None, # noqa: FBT001
) -> None:
data = [1, 2]
left_native = constructor_eager({left_name: data})[left_name]
right_native = constructor_eager({right_name: data})[right_name]
left_nw = nw.from_native(left_native, series_only=True)
right_nw = nw.from_native(right_native, series_only=True)

result = left_nw.equals(right_nw, check_names=check_names)
assert result == expected
1 change: 1 addition & 0 deletions utils/check_api_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def read_documented_members(source: str | Path) -> list[str]:
"arg_max",
"arg_min",
"arg_true",
"equals",
"dtype",
"from_iterable",
"from_numpy",
Expand Down
Loading