Repository: narwhals-dev/narwhals
Pull request #583 (2024-07-23 06:58:15)

feat: `Series.value_count` spec enhancement 
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## 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.

Expands on `Series.value_counts` available arguments to match polars 1.0 

FBruzzesi: CI fails:

- pre-commit: I though I fixed mypy, apparently not. Ideas on how to fix it are welcomed as I can't manage to find a way
- all other errors are related to `pyarrow_hotfix` not being installed. Should we add it to test requirements?
MarcoGorelli: sorry messed up the merge, will fix now

Patch:
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index efc825d1..ed5c7b37 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -427,7 +427,7 @@ class ArrowDataFrame:
         subset: str | list[str] | None,
         *,
         keep: Literal["any", "first", "last", "none"] = "any",
-        maintain_order: bool = False,  # noqa: ARG002
+        maintain_order: bool = False,
     ) -> Self:
         """
         NOTE:
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 0346fabb..07e3313a 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -344,24 +344,36 @@ class ArrowSeries:
             return self._native_series[0].as_py()
         return self._native_series[index].as_py()
 
-    def value_counts(self: Self, *, sort: bool = False, parallel: bool = False) -> Any:  # noqa: ARG002
+    def value_counts(
+        self: Self,
+        *,
+        sort: bool = False,
+        parallel: bool = False,
+        name: str | None = None,
+        normalize: bool = False,
+    ) -> ArrowDataFrame:
         """Parallel is unused, exists for compatibility"""
         from narwhals._arrow.dataframe import ArrowDataFrame
 
         pc = get_pyarrow_compute()
         pa = get_pyarrow()
 
-        name_ = (
-            "index" if self._native_series._name is None else self._native_series._name
-        )
+        index_name_ = "index" if self._name is None else self._name
+        value_name_ = name or ("proportion" if normalize else "count")
 
         val_count = pc.value_counts(self._native_series)
+        values = val_count.field("values")
+        counts = val_count.field("counts")
+
+        if normalize:
+            counts = pc.divide(*cast_for_truediv(counts, pc.sum(counts)))
+
         val_count = pa.Table.from_arrays(
-            [val_count.field("values"), val_count.field("counts")], names=[name_, "count"]
+            [values, counts], names=[index_name_, value_name_]
         )
 
         if sort:
-            val_count = val_count.sort_by([("count", "descending")])
+            val_count = val_count.sort_by([(value_name_, "descending")])
 
         return ArrowDataFrame(
             val_count,
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index d214a1f4..b8645e6e 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -167,7 +167,7 @@ class PandasLikeDataFrame:
         self,
         *,
         named: bool = False,
-        buffer_size: int = 512,  # noqa: ARG002
+        buffer_size: int = 512,
     ) -> Iterator[list[tuple[Any, ...]]] | Iterator[list[dict[str, Any]]]:
         """
         NOTE:
@@ -454,7 +454,7 @@ class PandasLikeDataFrame:
         subset: str | list[str] | None,
         *,
         keep: Literal["any", "first", "last", "none"] = "any",
-        maintain_order: bool = False,  # noqa: ARG002
+        maintain_order: bool = False,
     ) -> Self:
         """
         NOTE:
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 946725eb..900995f6 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -195,7 +195,7 @@ class PandasLikeSeries:
             return self._native_series.iloc[0]
         return self._native_series.iloc[index]
 
-    def to_frame(self) -> Any:
+    def to_frame(self) -> PandasLikeDataFrame:
         from narwhals._pandas_like.dataframe import PandasLikeDataFrame
 
         return PandasLikeDataFrame(
@@ -557,17 +557,30 @@ class PandasLikeSeries:
         else:
             return self._native_series.is_monotonic_increasing  # type: ignore[no-any-return]
 
-    def value_counts(self: Self, *, sort: bool = False, parallel: bool = False) -> Any:  # noqa: ARG002
+    def value_counts(
+        self: Self,
+        *,
+        sort: bool = False,
+        parallel: bool = False,
+        name: str | None = None,
+        normalize: bool = False,
+    ) -> PandasLikeDataFrame:
         """Parallel is unused, exists for compatibility"""
         from narwhals._pandas_like.dataframe import PandasLikeDataFrame
 
-        name_ = "index" if self._native_series.name is None else self._native_series.name
+        index_name_ = "index" if self._name is None else self._name
+        value_name_ = name or ("proportion" if normalize else "count")
+
         val_count = self._native_series.value_counts(
-            dropna=False, sort=False
+            dropna=False,
+            sort=False,
+            normalize=normalize,
         ).reset_index()
-        val_count.columns = [name_, "count"]
+
+        val_count.columns = [index_name_, value_name_]
+
         if sort:
-            val_count = val_count.sort_values("count", ascending=False)
+            val_count = val_count.sort_values(value_name_, ascending=False)
 
         return PandasLikeDataFrame(
             val_count,
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index 880edbb2..d64d4fe9 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -199,6 +199,37 @@ class PolarsSeries:
 
         return self._from_native_series(result)
 
+    def value_counts(
+        self: Self,
+        *,
+        sort: bool = False,
+        parallel: bool = False,
+        name: str | None = None,
+        normalize: bool = False,
+    ) -> PolarsDataFrame:
+        from narwhals._polars.dataframe import PolarsDataFrame
+
+        if self._backend_version < (1, 0, 0):  # pragma: no cover
+            pl = get_polars()
+            value_name_ = name or ("proportion" if normalize else "count")
+
+            result = self._native_series.value_counts(sort=sort, parallel=parallel)
+            result = result.select(
+                **{
+                    (self._native_series.name): pl.col(self._native_series.name),
+                    value_name_: pl.col("count") / pl.sum("count")
+                    if normalize
+                    else pl.col("count"),
+                }
+            )
+
+        else:
+            result = self._native_series.value_counts(
+                sort=sort, parallel=parallel, name=name, normalize=normalize
+            )
+
+        return PolarsDataFrame(result, backend_version=self._backend_version)
+
     @property
     def dt(self) -> PolarsSeriesDateTimeNamespace:
         return PolarsSeriesDateTimeNamespace(self)
diff --git a/narwhals/series.py b/narwhals/series.py
index 9e0e8c17..1dd54480 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -1654,7 +1654,12 @@ class Series:
         return self._compliant_series.is_sorted(descending=descending)  # type: ignore[no-any-return]
 
     def value_counts(
-        self: Self, *, sort: bool = False, parallel: bool = False
+        self: Self,
+        *,
+        sort: bool = False,
+        parallel: bool = False,
+        name: str | None = None,
+        normalize: bool = False,
     ) -> DataFrame[Any]:
         r"""
         Count the occurrences of unique values.
@@ -1662,7 +1667,10 @@ class Series:
         Arguments:
             sort: Sort the output by count in descending order. If set to False (default),
                 the order of the output is random.
-            parallel: Execute the computation in parallel. Unused for pandas-like APIs.
+            parallel: Execute the computation in parallel. Used for Polars only.
+            name: Give the resulting count column a specific name; if `normalize` is True
+                defaults to "proportion", otherwise defaults to "count".
+            normalize: If true gives relative frequencies of the unique values
 
         Examples:
             >>> import narwhals as nw
@@ -1700,7 +1708,9 @@ class Series:
         from narwhals.dataframe import DataFrame
 
         return DataFrame(
-            self._compliant_series.value_counts(sort=sort, parallel=parallel),
+            self._compliant_series.value_counts(
+                sort=sort, parallel=parallel, name=name, normalize=normalize
+            ),
             level=self._level,
         )
 
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1.py
index 18f520bd..9ca7c639 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1.py
@@ -361,7 +361,12 @@ class Series(NwSeries):
         return _stableify(super().to_frame())  # type: ignore[no-any-return]
 
     def value_counts(
-        self: Self, *, sort: bool = False, parallel: bool = False
+        self: Self,
+        *,
+        sort: bool = False,
+        parallel: bool = False,
+        name: str | None = None,
+        normalize: bool = False,
     ) -> DataFrame[Any]:
         r"""
         Count the occurrences of unique values.
@@ -369,7 +374,10 @@ class Series(NwSeries):
         Arguments:
             sort: Sort the output by count in descending order. If set to False (default),
                 the order of the output is random.
-            parallel: Execute the computation in parallel. Unused for pandas-like APIs.
+            parallel: Execute the computation in parallel. Used for Polars only.
+            name: Give the resulting count column a specific name; if `normalize` is True
+                defaults to "proportion", otherwise defaults to "count".
+            normalize: If true gives relative frequencies of the unique values
 
         Examples:
             >>> import narwhals.stable.v1 as nw
@@ -404,7 +412,11 @@ class Series(NwSeries):
             │ 3   ┆ 1     │
             └─────┴───────┘
         """
-        return _stableify(super().value_counts(sort=sort, parallel=parallel))  # type: ignore[no-any-return]
+        return _stableify(  # type: ignore[no-any-return]
+            super().value_counts(
+                sort=sort, parallel=parallel, name=name, normalize=normalize
+            )
+        )
 
 
 class Expr(NwExpr):
diff --git a/pyproject.toml b/pyproject.toml
index e392abc1..d9fe8f4f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,6 +44,7 @@ lint.select = [
 ]
 lint.ignore = [
   "A001",
+  "ARG002",
   "ANN101",
   "ANN401",
   "C901",


Test patch:
diff --git a/tests/series_only/value_counts_test.py b/tests/series_only/value_counts_test.py
index 2a6825c1..f6b5dc96 100644
--- a/tests/series_only/value_counts_test.py
+++ b/tests/series_only/value_counts_test.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 from typing import Any
 
 import pandas as pd
@@ -7,21 +9,35 @@ import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 from tests.utils import compare_dicts
 
-data = {"a": [4, 4, 6, 4, 1, 1]}
+data = [4, 4, 4, 1, 6, 6, 4, 4, 1, 1]
 
 
-def test_value_counts(request: Any, constructor: Any) -> None:
-    if "pandas_nullable_constructor" in str(constructor) and parse_version(
+@pytest.mark.parametrize("normalize", [True, False])
+@pytest.mark.parametrize("name", [None, "count_name"])
+def test_value_counts(
+    request: Any, constructor_series: Any, normalize: Any, name: str | None
+) -> None:
+    if "pandas_series_nullable_constructor" in str(constructor_series) and parse_version(
         pd.__version__
     ) < (2, 2):
         # bug in old pandas
         request.applymarker(pytest.mark.xfail)
 
-    series = nw.from_native(constructor(data), eager_only=True)["a"]
+    expected_count = [5, 3, 2]
+    expected_index = [4, 1, 6]
+
+    if normalize:
+        expected_count = [v / len(data) for v in expected_count]  # type: ignore[misc]
+
+    expected_name = name or ("proportion" if normalize else "count")
+    expected = {"a": expected_index, expected_name: expected_count}
+
+    series = nw.from_native(constructor_series(data), series_only=True).alias("a")
 
-    sorted_result = series.value_counts(sort=True)
-    expected = {"a": [4, 1, 6], "count": [3, 2, 1]}
+    sorted_result = series.value_counts(sort=True, name=name, normalize=normalize)
     compare_dicts(sorted_result, expected)
 
-    unsorted_result = series.value_counts(sort=False).sort("count", descending=True)
+    unsorted_result = series.value_counts(
+        sort=False, name=name, normalize=normalize
+    ).sort(expected_name, descending=True)
     compare_dicts(unsorted_result, expected)