Repository: narwhals-dev/narwhals
Pull request #1384 (2024-11-15 21:14:25)

feat: add `Series|Expr` `cum_min` and `cum_max` methods
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #1371 

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added
- [x] Documented the changes

## If you have comments or can explain your changes, please do so below


FBruzzesi: Pyarrow introduced `cumulative_min`, `cumulative_max` and `cumulative_prod` in 13.0
AlessandroMiola: > Pyarrow introduced `cumulative_min`, `cumulative_max` and `cumulative_prod` in 13.0

Not sure if relevant, should we also re-map these into their polars native exprs via `POLARS_TO_ARROW_AGGREGATIONS` for use in `group_by` like exprs? Found this might be relevant on #1177.
FBruzzesi: > > Pyarrow introduced `cumulative_min`, `cumulative_max` and `cumulative_prod` in 13.0
> 
> Not sure if relevant, should we maybe also re-map these into their polars native exprs via `POLARS_TO_ARROW_AGGREGATIONS` for use in `group_by` like exprs? Found this might be relevant on #1177.

That's a very good point. However for arrow this is not possible, unless we move operations unsupported natively to use `__iter__`, but that would most likely be slow. [List of pyarrow grouped aggregations](https://arrow.apache.org/docs/14.0/python/compute.html#grouped-aggregations).

Regarding pandas, I will check that's possible to achieve
AlessandroMiola: > That's a very good point. However for arrow this is not possible, unless we move operations unsupported natively to use `__iter__`, but that would most likely be slow ([List of pyarrow grouped aggregations](https://arrow.apache.org/docs/14.0/python/compute.html#grouped-aggregations)).

ahhh, right, I see ✌️ 

Patch:
diff --git a/docs/api-reference/expr.md b/docs/api-reference/expr.md
index 72ea830f..4bc18070 100644
--- a/docs/api-reference/expr.md
+++ b/docs/api-reference/expr.md
@@ -12,6 +12,8 @@
         - cast
         - count
         - cum_count
+        - cum_max
+        - cum_min
         - cum_sum
         - diff
         - drop_nulls
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index 76132051..25e14de4 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -16,6 +16,8 @@
         - clip
         - count
         - cum_count
+        - cum_max
+        - cum_min
         - cum_sum
         - diff
         - drop_nulls
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 71646001..4fc998b0 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -433,6 +433,12 @@ class ArrowExpr:
     def cum_count(self: Self, *, reverse: bool) -> Self:
         return reuse_series_implementation(self, "cum_count", reverse=reverse)
 
+    def cum_min(self: Self, *, reverse: bool) -> Self:
+        return reuse_series_implementation(self, "cum_min", reverse=reverse)
+
+    def cum_max(self: Self, *, reverse: bool) -> Self:
+        return reuse_series_implementation(self, "cum_max", reverse=reverse)
+
     @property
     def dt(self: Self) -> ArrowExprDateTimeNamespace:
         return ArrowExprDateTimeNamespace(self)
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index be8ec994..1ddf9d3d 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -822,6 +822,38 @@ class ArrowSeries:
             else len(self) - not_na_series.cum_sum() + not_na_series - 1
         )
 
+    def cum_min(self: Self, *, reverse: bool) -> Self:
+        if self._backend_version < (13, 0, 0):
+            msg = "cum_min method is not supported for pyarrow < 13.0.0"
+            raise NotImplementedError(msg)
+
+        import pyarrow.compute as pc  # ignore-banned-import
+
+        native_series = self._native_series
+
+        result = (
+            pc.cumulative_min(native_series, skip_nulls=True)
+            if not reverse
+            else pc.cumulative_min(native_series[::-1], skip_nulls=True)[::-1]
+        )
+        return self._from_native_series(result)
+
+    def cum_max(self: Self, *, reverse: bool) -> Self:
+        if self._backend_version < (13, 0, 0):
+            msg = "cum_max method is not supported for pyarrow < 13.0.0"
+            raise NotImplementedError(msg)
+
+        import pyarrow.compute as pc  # ignore-banned-import
+
+        native_series = self._native_series
+
+        result = (
+            pc.cumulative_max(native_series, skip_nulls=True)
+            if not reverse
+            else pc.cumulative_max(native_series[::-1], skip_nulls=True)[::-1]
+        )
+        return self._from_native_series(result)
+
     def __iter__(self: Self) -> Iterator[Any]:
         yield from self._native_series.__iter__()
 
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index 7b2d5b70..cdab7d96 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -444,6 +444,12 @@ class PandasLikeExpr:
     def cum_count(self: Self, *, reverse: bool) -> Self:
         return reuse_series_implementation(self, "cum_count", reverse=reverse)
 
+    def cum_min(self: Self, *, reverse: bool) -> Self:
+        return reuse_series_implementation(self, "cum_min", reverse=reverse)
+
+    def cum_max(self: Self, *, reverse: bool) -> Self:
+        return reuse_series_implementation(self, "cum_max", reverse=reverse)
+
     @property
     def str(self: Self) -> PandasLikeExprStringNamespace:
         return PandasLikeExprStringNamespace(self)
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index d235be0e..5b0e3aee 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -765,6 +765,24 @@ class PandasLikeSeries:
         )
         return self._from_native_series(result)
 
+    def cum_min(self: Self, *, reverse: bool) -> Self:
+        native_series = self._native_series
+        result = (
+            native_series.cummin(skipna=True)
+            if not reverse
+            else native_series[::-1].cummin(skipna=True)[::-1]
+        )
+        return self._from_native_series(result)
+
+    def cum_max(self: Self, *, reverse: bool) -> Self:
+        native_series = self._native_series
+        result = (
+            native_series.cummax(skipna=True)
+            if not reverse
+            else native_series[::-1].cummax(skipna=True)[::-1]
+        )
+        return self._from_native_series(result)
+
     def __iter__(self: Self) -> Iterator[Any]:
         yield from self._native_series.__iter__()
 
diff --git a/narwhals/expr.py b/narwhals/expr.py
index be28fd07..4db54341 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -2747,6 +2747,118 @@ class Expr:
         """
         return self.__class__(lambda plx: self._call(plx).cum_count(reverse=reverse))
 
+    def cum_min(self: Self, *, reverse: bool = False) -> Self:
+        r"""Return the cumulative min of the non-null values in the column.
+
+        Arguments:
+            reverse: reverse the operation
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = {"a": [3, 1, None, 2]}
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
+            ...         nw.col("a").cum_min().alias("cum_min"),
+            ...         nw.col("a").cum_min(reverse=True).alias("cum_min_reverse"),
+            ...     )
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> func(pd.DataFrame(data))
+                 a  cum_min  cum_min_reverse
+            0  3.0      3.0              1.0
+            1  1.0      1.0              1.0
+            2  NaN      NaN              NaN
+            3  2.0      1.0              2.0
+
+            >>> func(pl.DataFrame(data))
+            shape: (4, 3)
+            ┌──────┬─────────┬─────────────────┐
+            │ a    ┆ cum_min ┆ cum_min_reverse │
+            │ ---  ┆ ---     ┆ ---             │
+            │ i64  ┆ i64     ┆ i64             │
+            ╞══════╪═════════╪═════════════════╡
+            │ 3    ┆ 3       ┆ 1               │
+            │ 1    ┆ 1       ┆ 1               │
+            │ null ┆ null    ┆ null            │
+            │ 2    ┆ 1       ┆ 2               │
+            └──────┴─────────┴─────────────────┘
+
+            >>> func(pa.table(data))
+            pyarrow.Table
+            a: int64
+            cum_min: int64
+            cum_min_reverse: int64
+            ----
+            a: [[3,1,null,2]]
+            cum_min: [[3,1,null,1]]
+            cum_min_reverse: [[1,1,null,2]]
+        """
+        return self.__class__(lambda plx: self._call(plx).cum_min(reverse=reverse))
+
+    def cum_max(self: Self, *, reverse: bool = False) -> Self:
+        r"""Return the cumulative max of the non-null values in the column.
+
+        Arguments:
+            reverse: reverse the operation
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = {"a": [1, 3, None, 2]}
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
+            ...         nw.col("a").cum_max().alias("cum_max"),
+            ...         nw.col("a").cum_max(reverse=True).alias("cum_max_reverse"),
+            ...     )
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> func(pd.DataFrame(data))
+                 a  cum_max  cum_max_reverse
+            0  1.0      1.0              3.0
+            1  3.0      3.0              3.0
+            2  NaN      NaN              NaN
+            3  2.0      3.0              2.0
+
+            >>> func(pl.DataFrame(data))
+            shape: (4, 3)
+            ┌──────┬─────────┬─────────────────┐
+            │ a    ┆ cum_max ┆ cum_max_reverse │
+            │ ---  ┆ ---     ┆ ---             │
+            │ i64  ┆ i64     ┆ i64             │
+            ╞══════╪═════════╪═════════════════╡
+            │ 1    ┆ 1       ┆ 3               │
+            │ 3    ┆ 3       ┆ 3               │
+            │ null ┆ null    ┆ null            │
+            │ 2    ┆ 3       ┆ 2               │
+            └──────┴─────────┴─────────────────┘
+
+            >>> func(pa.table(data))
+            pyarrow.Table
+            a: int64
+            cum_max: int64
+            cum_max_reverse: int64
+            ----
+            a: [[1,3,null,2]]
+            cum_max: [[1,3,null,3]]
+            cum_max_reverse: [[3,3,null,2]]
+        """
+        return self.__class__(lambda plx: self._call(plx).cum_max(reverse=reverse))
+
     @property
     def str(self: Self) -> ExprStringNamespace[Self]:
         return ExprStringNamespace(self)
diff --git a/narwhals/series.py b/narwhals/series.py
index 2fe4b243..b1142a2a 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -2681,6 +2681,110 @@ class Series:
             self._compliant_series.cum_count(reverse=reverse)
         )
 
+    def cum_min(self: Self, *, reverse: bool = False) -> Self:
+        r"""Return the cumulative min of the non-null values in the series.
+
+        Arguments:
+            reverse: reverse the operation
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = [3, 1, None, 2]
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(s):
+            ...     return s.cum_min()
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> func(pd.Series(data))
+            0    3.0
+            1    1.0
+            2    NaN
+            3    1.0
+            dtype: float64
+            >>> func(pl.Series(data))  # doctest:+NORMALIZE_WHITESPACE
+            shape: (4,)
+            Series: '' [i64]
+            [
+               3
+               1
+               null
+               1
+            ]
+            >>> func(pa.chunked_array([data]))  # doctest:+ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                3,
+                1,
+                null,
+                1
+              ]
+            ]
+
+        """
+        return self._from_compliant_series(
+            self._compliant_series.cum_min(reverse=reverse)
+        )
+
+    def cum_max(self: Self, *, reverse: bool = False) -> Self:
+        r"""Return the cumulative max of the non-null values in the series.
+
+        Arguments:
+            reverse: reverse the operation
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = [1, 3, None, 2]
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(s):
+            ...     return s.cum_max()
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> func(pd.Series(data))
+            0    1.0
+            1    3.0
+            2    NaN
+            3    3.0
+            dtype: float64
+            >>> func(pl.Series(data))  # doctest:+NORMALIZE_WHITESPACE
+            shape: (4,)
+            Series: '' [i64]
+            [
+               1
+               3
+               null
+               3
+            ]
+            >>> func(pa.chunked_array([data]))  # doctest:+ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                1,
+                3,
+                null,
+                3
+              ]
+            ]
+
+        """
+        return self._from_compliant_series(
+            self._compliant_series.cum_max(reverse=reverse)
+        )
+
     def __iter__(self: Self) -> Iterator[Any]:
         yield from self._compliant_series.__iter__()
 


Test patch:
diff --git a/tests/expr_and_series/cum_count_test.py b/tests/expr_and_series/cum_count_test.py
index 3b8e7e09..e147fe58 100644
--- a/tests/expr_and_series/cum_count_test.py
+++ b/tests/expr_and_series/cum_count_test.py
@@ -7,9 +7,7 @@ from tests.utils import Constructor
 from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 
-data = {
-    "a": ["x", "y", None, "z"],
-}
+data = {"a": ["x", "y", None, "z"]}
 
 
 def test_cum_count_expr(request: pytest.FixtureRequest, constructor: Constructor) -> None:
diff --git a/tests/expr_and_series/cum_max_test.py b/tests/expr_and_series/cum_max_test.py
new file mode 100644
index 00000000..5351fb19
--- /dev/null
+++ b/tests/expr_and_series/cum_max_test.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import PANDAS_VERSION
+from tests.utils import PYARROW_VERSION
+from tests.utils import Constructor
+from tests.utils import ConstructorEager
+from tests.utils import assert_equal_data
+
+data = {"a": [1, 3, None, 2]}
+
+expected = {
+    "cum_max": [1, 3, None, 3],
+    "reverse_cum_max": [3, 3, None, 2],
+}
+
+
+def test_cum_max_expr(request: pytest.FixtureRequest, constructor: Constructor) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    if PYARROW_VERSION < (13, 0, 0) and "pyarrow_table" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    if PANDAS_VERSION < (2, 1) and "pandas_pyarrow" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    result = df.select(
+        cum_max=nw.col("a").cum_max(),
+        reverse_cum_max=nw.col("a").cum_max(reverse=True),
+    )
+
+    assert_equal_data(result, expected)
+
+
+def test_cum_max_series(
+    request: pytest.FixtureRequest, constructor_eager: ConstructorEager
+) -> None:
+    if PYARROW_VERSION < (13, 0, 0) and "pyarrow_table" in str(constructor_eager):
+        request.applymarker(pytest.mark.xfail)
+
+    if PANDAS_VERSION < (2, 1) and "pandas_pyarrow" in str(constructor_eager):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result = df.select(
+        cum_max=df["a"].cum_max(),
+        reverse_cum_max=df["a"].cum_max(reverse=True),
+    )
+    assert_equal_data(result, expected)
diff --git a/tests/expr_and_series/cum_min_test.py b/tests/expr_and_series/cum_min_test.py
new file mode 100644
index 00000000..b72f5a16
--- /dev/null
+++ b/tests/expr_and_series/cum_min_test.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import PANDAS_VERSION
+from tests.utils import PYARROW_VERSION
+from tests.utils import Constructor
+from tests.utils import ConstructorEager
+from tests.utils import assert_equal_data
+
+data = {"a": [3, 1, None, 2]}
+
+expected = {
+    "cum_min": [3, 1, None, 1],
+    "reverse_cum_min": [1, 1, None, 2],
+}
+
+
+def test_cum_min_expr(request: pytest.FixtureRequest, constructor: Constructor) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    if PYARROW_VERSION < (13, 0, 0) and "pyarrow_table" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    if PANDAS_VERSION < (2, 1) and "pandas_pyarrow" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    result = df.select(
+        cum_min=nw.col("a").cum_min(),
+        reverse_cum_min=nw.col("a").cum_min(reverse=True),
+    )
+
+    assert_equal_data(result, expected)
+
+
+def test_cum_min_series(
+    request: pytest.FixtureRequest, constructor_eager: ConstructorEager
+) -> None:
+    if PYARROW_VERSION < (13, 0, 0) and "pyarrow_table" in str(constructor_eager):
+        request.applymarker(pytest.mark.xfail)
+
+    if PANDAS_VERSION < (2, 1) and "pandas_pyarrow" in str(constructor_eager):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result = df.select(
+        cum_min=df["a"].cum_min(),
+        reverse_cum_min=df["a"].cum_min(reverse=True),
+    )
+    assert_equal_data(result, expected)