Repository: narwhals-dev/narwhals
Pull request #942 (2024-09-10 14:24:13)

feat: add `mode` method
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues 

I started to take a very mild look at skrub... and immediately stumbled upon not having `mode` implemented
  
## 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.




Patch:
diff --git a/README.md b/README.md
index d26107e6..74630fd0 100644
--- a/README.md
+++ b/README.md
@@ -113,6 +113,31 @@ provided some funding / development time:
 If you contribute to Narwhals on your organization's time, please let us know. We'd be happy to add your employer
 to this list!
 
+## Appears on
+
+Narwhals has been featured in several talks, podcasts, and blog posts:
+
+- [Talk Python to me Podcast](https://youtu.be/FSH7BZ0tuE0)
+  Ahoy, Narwhals are bridging the data science APIs
+
+- [Super Data Science: ML & AI Podcast](https://www.youtube.com/watch?v=TeG4U8R0U8U)  
+  Narwhals: For Pandas-to-Polars DataFrame Compatibility
+
+- [Sample Space Podcast | probabl](https://youtu.be/8hYdq4sWbbQ?si=WG0QP1CZ6gkFf18b)  
+  How Narwhals has many end users ... that never use it directly. - Marco Gorelli
+
+- [Pycon Lithuania](https://www.youtube.com/watch?v=-mdx7Cn6_6E)  
+  Marco Gorelli - DataFrame interoperatiblity - what's been achieved, and what comes next?
+
+- [Pycon Italy](https://www.youtube.com/watch?v=3IqUli9XsmQ)  
+  How you can write a dataframe-agnostic library - Marco Gorelli
+
+- [Polars Blog Post](https://pola.rs/posts/lightweight_plotting/)  
+  Polars has a new lightweight plotting backend
+
+- [Quansight Labs blog post (w/ Scikit-Lego)](https://labs.quansight.org/blog/scikit-lego-narwhals)  
+  How Narwhals and scikit-lego came together to achieve dataframe-agnosticism
+
 ## Why "Narwhals"?
 
 [Coz they are so awesome](https://youtu.be/ykwqXuMPsoc?si=A-i8LdR38teYsos4).
diff --git a/docs/api-reference/expr.md b/docs/api-reference/expr.md
index cc1290a8..7188b2c3 100644
--- a/docs/api-reference/expr.md
+++ b/docs/api-reference/expr.md
@@ -30,6 +30,7 @@
         - max
         - mean
         - min
+        - mode
         - null_count
         - n_unique
         - over
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index f9cc2e6b..b637f550 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -36,6 +36,7 @@
         - max
         - mean
         - min
+        - mode
         - name
         - null_count
         - n_unique
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 31052fa5..24e4fe5c 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -323,6 +323,9 @@ class ArrowExpr:
             backend_version=self._backend_version,
         )
 
+    def mode(self: Self) -> Self:
+        return reuse_series_implementation(self, "mode")
+
     @property
     def dt(self: Self) -> ArrowExprDateTimeNamespace:
         return ArrowExprDateTimeNamespace(self)
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 9d3d690d..50927426 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
     from typing_extensions import Self
 
     from narwhals._arrow.dataframe import ArrowDataFrame
+    from narwhals._arrow.namespace import ArrowNamespace
     from narwhals.dtypes import DType
 
 
@@ -65,6 +66,11 @@ class ArrowSeries:
             backend_version=backend_version,
         )
 
+    def __narwhals_namespace__(self) -> ArrowNamespace:
+        from narwhals._arrow.namespace import ArrowNamespace
+
+        return ArrowNamespace(backend_version=self._backend_version)
+
     def __len__(self) -> int:
         return len(self._native_series)
 
@@ -667,6 +673,13 @@ class ArrowSeries:
     def to_arrow(self: Self) -> pa.Array:
         return self._native_series.combine_chunks()
 
+    def mode(self: Self) -> ArrowSeries:
+        plx = self.__narwhals_namespace__()
+        col_token = generate_unique_token(n_bytes=8, columns=[self.name])
+        return self.value_counts(name=col_token, normalize=False).filter(
+            plx.col(col_token) == plx.col(col_token).max()
+        )[self.name]
+
     @property
     def shape(self) -> tuple[int]:
         return (len(self._native_series),)
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index 73082450..b20552b4 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -644,6 +644,10 @@ class DaskExpr:
             backend_version=self._backend_version,
         )
 
+    def mode(self: Self) -> Self:
+        msg = "`Expr.mode` is not supported for the Dask backend."
+        raise NotImplementedError(msg)
+
     @property
     def str(self: Self) -> DaskExprStringNamespace:
         return DaskExprStringNamespace(self)
diff --git a/narwhals/_dask/group_by.py b/narwhals/_dask/group_by.py
index 463d6fc5..d5fbaaf9 100644
--- a/narwhals/_dask/group_by.py
+++ b/narwhals/_dask/group_by.py
@@ -72,6 +72,7 @@ class DaskLazyGroupBy:
             output_names.extend(expr._output_names)
 
         return agg_dask(
+            self._df,
             self._grouped,
             exprs,
             self._keys,
@@ -88,6 +89,7 @@ class DaskLazyGroupBy:
 
 
 def agg_dask(
+    df: DaskLazyFrame,
     grouped: Any,
     exprs: list[DaskExpr],
     keys: list[str],
@@ -99,6 +101,10 @@ def agg_dask(
     - https://github.com/rapidsai/cudf/issues/15118
     - https://github.com/rapidsai/cudf/issues/15084
     """
+    if not exprs:
+        # No aggregation provided
+        return df.select(*keys).unique(subset=keys)
+
     all_simple_aggs = True
     for expr in exprs:
         if not is_simple_aggregation(expr):
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index 8c3536c7..74a2ee31 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -338,6 +338,9 @@ class PandasLikeExpr:
     def gather_every(self: Self, n: int, offset: int = 0) -> Self:
         return reuse_series_implementation(self, "gather_every", n=n, offset=offset)
 
+    def mode(self: Self) -> Self:
+        return reuse_series_implementation(self, "mode")
+
     @property
     def str(self: Self) -> PandasLikeExprStringNamespace:
         return PandasLikeExprStringNamespace(self)
diff --git a/narwhals/_pandas_like/group_by.py b/narwhals/_pandas_like/group_by.py
index 97a477dc..892291d5 100644
--- a/narwhals/_pandas_like/group_by.py
+++ b/narwhals/_pandas_like/group_by.py
@@ -80,6 +80,7 @@ class PandasLikeGroupBy:
             dataframe_is_empty=self._df._native_frame.empty,
             implementation=implementation,
             backend_version=self._df._backend_version,
+            native_namespace=self._df.__native_namespace__(),
         )
 
     def _from_native_frame(self, df: PandasLikeDataFrame) -> PandasLikeDataFrame:
@@ -114,6 +115,7 @@ def agg_pandas(  # noqa: PLR0915
     implementation: Any,
     backend_version: tuple[int, ...],
     dataframe_is_empty: bool,
+    native_namespace: Any,
 ) -> PandasLikeDataFrame:
     """
     This should be the fastpath, but cuDF is too far behind to use it.
@@ -204,9 +206,9 @@ def agg_pandas(  # noqa: PLR0915
             result_aggs = result_nunique_aggs
         elif simple_aggs and not nunique_aggs:
             result_aggs = result_simple_aggs
-        else:  # pragma: no cover
-            msg = "Congrats, you entered unreachable code. Please report a bug to https://github.com/narwhals-dev/narwhals/issues."
-            raise RuntimeError(msg)
+        else:
+            # No aggregation provided
+            result_aggs = native_namespace.DataFrame(grouped.groups.keys(), columns=keys)
         return from_dataframe(result_aggs.loc[:, output_names])
 
     if dataframe_is_empty:
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index a0830784..a13f252e 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -641,6 +641,12 @@ class PandasLikeSeries:
 
         return pa.Array.from_pandas(self._native_series)
 
+    def mode(self: Self) -> Self:
+        native_series = self._native_series
+        result = native_series.mode()
+        result.name = native_series.name
+        return self._from_native_series(result)
+
     @property
     def str(self) -> PandasLikeSeriesStringNamespace:
         return PandasLikeSeriesStringNamespace(self)
diff --git a/narwhals/expr.py b/narwhals/expr.py
index cc8b8695..31544642 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -1585,9 +1585,8 @@ class Expr:
         r"""
         Get the first `n` rows.
 
-        Arguments
-            n : int
-                Number of rows to return.
+        Arguments:
+            n: Number of rows to return.
 
         Examples:
             >>> import narwhals as nw
@@ -1628,9 +1627,8 @@ class Expr:
         r"""
         Get the last `n` rows.
 
-        Arguments
-            n : int
-                Number of rows to return.
+        Arguments:
+            n: Number of rows to return.
 
         Examples:
             >>> import narwhals as nw
@@ -1819,7 +1817,7 @@ class Expr:
             >>> import pandas as pd
             >>> import polars as pl
             >>> import narwhals as nw
-            >>>
+
             >>> s = [1, 2, 3]
             >>> df_pd = pd.DataFrame({"s": s})
             >>> df_pl = pl.DataFrame({"s": s})
@@ -1913,6 +1911,49 @@ class Expr:
         """
         return self.__class__(lambda plx: self._call(plx).clip(lower_bound, upper_bound))
 
+    def mode(self: Self) -> Self:
+        r"""Compute the most occurring value(s).
+
+        Can return multiple values.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals as nw
+
+            >>> data = {
+            ...     "a": [1, 1, 2, 3],
+            ...     "b": [1, 1, 2, 2],
+            ... }
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col("a", "b").mode()).sort("a", "b")
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+               a  b
+            0  1  1
+            1  1  2
+
+            >>> func(df_pl)
+            shape: (2, 2)
+            ┌─────┬─────┐
+            │ a   ┆ b   │
+            │ --- ┆ --- │
+            │ i64 ┆ i64 │
+            ╞═════╪═════╡
+            │ 1   ┆ 1   │
+            │ 1   ┆ 2   │
+            └─────┴─────┘
+        """
+        return self.__class__(lambda plx: self._call(plx).mode())
+
     @property
     def str(self: Self) -> ExprStringNamespace:
         return ExprStringNamespace(self)
diff --git a/narwhals/series.py b/narwhals/series.py
index 0b1645ea..147838e4 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -2003,9 +2003,8 @@ class Series:
         r"""
         Get the first `n` rows.
 
-        Arguments
-            n : int
-                Number of rows to return.
+        Arguments:
+            n: Number of rows to return.
 
         Examples:
             >>> import narwhals as nw
@@ -2044,9 +2043,8 @@ class Series:
         r"""
         Get the last `n` rows.
 
-        Arguments
-            n : int
-                Number of rows to return.
+        Arguments:
+            n: Number of rows to return.
 
         Examples:
             >>> import narwhals as nw
@@ -2084,7 +2082,7 @@ class Series:
         r"""
         Round underlying floating point data by `decimals` digits.
 
-        Arguments
+        Arguments:
             decimals: Number of decimals to round by.
 
         Notes:
@@ -2134,7 +2132,7 @@ class Series:
         r"""
         Get dummy/indicator variables.
 
-        Arguments
+        Arguments:
             separator: Separator/delimiter used when generating column names.
             drop_first: Remove the first category from the variable being encoded.
 
@@ -2278,6 +2276,44 @@ class Series:
         """
         return self._compliant_series.to_arrow()
 
+    def mode(self: Self) -> Self:
+        r"""
+        Compute the most occurring value(s).
+
+        Can return multiple values.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals as nw
+
+            >>> data = [1, 1, 2, 2, 3]
+            >>> s_pd = pd.Series(name="a", data=data)
+            >>> s_pl = pl.Series(name="a", values=data)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(s):
+            ...     return s.mode().sort()
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(s_pd)
+            0    1
+            1    2
+            Name: a, dtype: int64
+
+            >>> func(s_pl)  # doctest:+NORMALIZE_WHITESPACE
+            shape: (2,)
+            Series: 'a' [i64]
+            [
+               1
+               2
+            ]
+        """
+        return self._from_compliant_series(self._compliant_series.mode())
+
     @property
     def str(self) -> SeriesStringNamespace:
         return SeriesStringNamespace(self)


Test patch:
diff --git a/tests/expr_and_series/mode_test.py b/tests/expr_and_series/mode_test.py
new file mode 100644
index 00000000..33a0bef5
--- /dev/null
+++ b/tests/expr_and_series/mode_test.py
@@ -0,0 +1,37 @@
+from typing import Any
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+data = {
+    "a": [1, 1, 2, 2, 3],
+    "b": [1, 2, 3, 3, 4],
+}
+
+
+def test_mode_single_expr(constructor: Any, request: Any) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.col("a").mode()).sort("a")
+    expected = {"a": [1, 2]}
+    compare_dicts(result, expected)
+
+
+def test_mode_multi_expr(constructor: Any, request: Any) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.col("a", "b").mode()).sort("a", "b")
+    expected = {"a": [1, 2], "b": [3, 3]}
+    compare_dicts(result, expected)
+
+
+def test_mode_series(constructor_eager: Any) -> None:
+    series = nw.from_native(constructor_eager(data), eager_only=True)["a"]
+    result = series.mode().sort()
+    expected = {"a": [1, 2]}
+    compare_dicts({"a": result}, expected)
diff --git a/tests/series_only/to_dummy_test.py b/tests/series_only/to_dummy_test.py
index 2cf7f59c..404ac632 100644
--- a/tests/series_only/to_dummy_test.py
+++ b/tests/series_only/to_dummy_test.py
@@ -18,7 +18,9 @@ def test_to_dummies(constructor_eager: Any, sep: str) -> None:
 
 
 @pytest.mark.parametrize("sep", ["_", "-"])
-def test_to_dummies_drop_first(constructor_eager: Any, sep: str) -> None:
+def test_to_dummies_drop_first(request: Any, constructor_eager: Any, sep: str) -> None:
+    if "cudf" in str(constructor_eager):
+        request.applymarker(pytest.mark.xfail)
     s = nw.from_native(constructor_eager({"a": data}), eager_only=True)["a"].alias("a")
     result = s.to_dummies(drop_first=True, separator=sep)
     expected = {f"a{sep}2": [0, 1, 0], f"a{sep}3": [0, 0, 1]}
diff --git a/tests/test_group_by.py b/tests/test_group_by.py
index 4bd3427a..6f12d06b 100644
--- a/tests/test_group_by.py
+++ b/tests/test_group_by.py
@@ -246,3 +246,10 @@ def test_key_with_nulls(constructor: Any, request: Any) -> None:
         )
         expected = {"b": [4.0, 5, float("nan")], "len": [1, 1, 1], "a": [1, 2, 3]}
         compare_dicts(result, expected)
+
+
+def test_no_agg(constructor: Any) -> None:
+    result = nw.from_native(constructor(data)).group_by(["a", "b"]).agg().sort("a", "b")
+
+    expected = {"a": [1, 3], "b": [4, 6]}
+    compare_dicts(result, expected)