Repository: narwhals-dev/narwhals

Issue:
Series.to_dummies
Bit of a tricky one, but it's one that Formulaic use

Polars has `Series.to_dummies`, pandas `pd.get_dummies`, no big deal

🤔 need to figure out how to do it for PyArrow

Discussion:
MarcoGorelli: this is literal 🔥 🔥 🔥 

looks like there's just an issue with old versions of Polars not having `drop_first`
FBruzzesi: Damn! I could check only until pandas 1.0, not even the docs get so far in the past!
I will need to come back to this a bit later today
MarcoGorelli: > pandas 1.0

Polars, not pandas 😄 

Patch:
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index 0d409408..bf607855 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -44,6 +44,7 @@
         - std
         - sum
         - tail
+        - to_dummies
         - to_frame
         - to_list
         - to_numpy
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index d0082a31..a04c70bb 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -248,6 +248,12 @@ class ArrowExpr:
     def is_last_distinct(self: Self) -> Self:
         return reuse_series_implementation(self, "is_last_distinct")
 
+    def unique(self: Self) -> Self:
+        return reuse_series_implementation(self, "unique")
+
+    def sort(self: Self, *, descending: bool = False) -> Self:
+        return reuse_series_implementation(self, "sort", descending=descending)
+
     @property
     def dt(self: Self) -> ArrowExprDateTimeNamespace:
         return ArrowExprDateTimeNamespace(self)
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index b51ca401..53524278 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -468,6 +468,39 @@ class ArrowSeries:
         else:
             return pc.all(pc.less_equal(ser[:-1], ser[1:])).as_py()  # type: ignore[no-any-return]
 
+    def unique(self: Self) -> ArrowSeries:
+        pc = get_pyarrow_compute()
+        return self._from_native_series(pc.unique(self._native_series))
+
+    def sort(self: Self, *, descending: bool = False) -> ArrowSeries:
+        pc = get_pyarrow_compute()
+        series = self._native_series
+        order = "descending" if descending else "ascending"
+        sorted_indices = pc.array_sort_indices(
+            series, order=order, null_placement="at_start"
+        )
+
+        return self._from_native_series(pc.take(series, sorted_indices))
+
+    def to_dummies(
+        self: Self, *, separator: str = "_", drop_first: bool = False
+    ) -> ArrowDataFrame:
+        from narwhals._arrow.dataframe import ArrowDataFrame
+
+        pa = get_pyarrow()
+        pc = get_pyarrow_compute()
+        series = self._native_series
+        unique_values = self.unique().sort()._native_series
+        columns = [pc.cast(pc.equal(series, v), pa.uint8()) for v in unique_values][
+            int(drop_first) :
+        ]
+        names = [f"{self._name}{separator}{v}" for v in unique_values][int(drop_first) :]
+
+        return ArrowDataFrame(
+            pa.Table.from_arrays(columns, names=names),
+            backend_version=self._backend_version,
+        )
+
     @property
     def shape(self) -> tuple[int]:
         return (len(self._native_series),)
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index de85df4e..36d69f0d 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -23,6 +23,7 @@ from narwhals.dependencies import get_pyarrow_compute
 if TYPE_CHECKING:
     from typing_extensions import Self
 
+    from narwhals._pandas_like.dataframe import PandasLikeDataFrame
     from narwhals._pandas_like.namespace import PandasLikeNamespace
     from narwhals.dtypes import DType
 
@@ -596,6 +597,25 @@ class PandasLikeSeries:
     def round(self: Self, decimals: int) -> Self:
         return self._from_native_series(self._native_series.round(decimals=decimals))
 
+    def to_dummies(
+        self: Self, *, separator: str = "_", drop_first: bool = False
+    ) -> PandasLikeDataFrame:
+        from narwhals._pandas_like.dataframe import PandasLikeDataFrame
+
+        plx = self.__native_namespace__()
+        series = self._native_series
+        name = str(self._name) if self._name else ""
+        return PandasLikeDataFrame(
+            plx.get_dummies(
+                series,
+                prefix=name,
+                prefix_sep=separator,
+                drop_first=drop_first,
+            ).astype(int),
+            implementation=self._implementation,
+            backend_version=self._backend_version,
+        )
+
     @property
     def str(self) -> PandasLikeSeriesStringNamespace:
         return PandasLikeSeriesStringNamespace(self)
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index 07f7a82d..11bc121e 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
     import numpy as np
     from typing_extensions import Self
 
+    from narwhals._polars.dataframe import PolarsDataFrame
     from narwhals.dtypes import DType
 
 from narwhals._polars.namespace import PolarsNamespace
@@ -168,6 +169,21 @@ class PolarsSeries:
     def __invert__(self) -> Self:
         return self._from_native_series(self._native_series.__invert__())
 
+    def to_dummies(
+        self: Self, *, separator: str = "_", drop_first: bool = False
+    ) -> PolarsDataFrame:
+        from narwhals._polars.dataframe import PolarsDataFrame
+
+        if self._backend_version < (0, 20, 15):  # pragma: no cover
+            result = self._native_series.to_dummies(separator=separator)
+            result = result.select(result.columns[int(drop_first) :])
+        else:
+            result = self._native_series.to_dummies(
+                separator=separator, drop_first=drop_first
+            )
+
+        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 833a0b05..9da9a6c5 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -1963,6 +1963,79 @@ class Series:
         """
         return self._from_compliant_series(self._compliant_series.round(decimals))
 
+    def to_dummies(
+        self: Self, *, separator: str = "_", drop_first: bool = False
+    ) -> DataFrame[Any]:
+        r"""
+        Get dummy/indicator variables.
+
+        Arguments
+            separator: Separator/delimiter used when generating column names.
+            drop_first: Remove the first category from the variable being encoded.
+
+        Notes:
+            pandas and Polars handle null values differently. Polars distinguishes
+            between NaN and Null, whereas pandas doesn't.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> data = [1, 2, 3]
+            >>> s_pd = pd.Series(data, name="a")
+            >>> s_pl = pl.Series("a", data)
+
+            Let's define a dataframe-agnostic function that rounds to the first decimal:
+
+            >>> @nw.narwhalify
+            ... def func(s_any, drop_first: bool = False):
+            ...     return s_any.to_dummies(drop_first=drop_first)
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(s_pd)
+               a_1  a_2  a_3
+            0    1    0    0
+            1    0    1    0
+            2    0    0    1
+
+            >>> func(s_pd, drop_first=True)
+               a_2  a_3
+            0    0    0
+            1    1    0
+            2    0    1
+
+            >>> func(s_pl)
+            shape: (3, 3)
+            ┌─────┬─────┬─────┐
+            │ a_1 ┆ a_2 ┆ a_3 │
+            │ --- ┆ --- ┆ --- │
+            │ u8  ┆ u8  ┆ u8  │
+            ╞═════╪═════╪═════╡
+            │ 1   ┆ 0   ┆ 0   │
+            │ 0   ┆ 1   ┆ 0   │
+            │ 0   ┆ 0   ┆ 1   │
+            └─────┴─────┴─────┘
+            >>> func(s_pl, drop_first=True)
+            shape: (3, 2)
+            ┌─────┬─────┐
+            │ a_2 ┆ a_3 │
+            │ --- ┆ --- │
+            │ u8  ┆ u8  │
+            ╞═════╪═════╡
+            │ 0   ┆ 0   │
+            │ 1   ┆ 0   │
+            │ 0   ┆ 1   │
+            └─────┴─────┘
+        """
+
+        from narwhals.dataframe import DataFrame
+
+        return DataFrame(
+            self._compliant_series.to_dummies(separator=separator, drop_first=drop_first),
+            level=self._level,
+        )
+
     @property
     def str(self) -> SeriesStringNamespace:
         return SeriesStringNamespace(self)
diff --git a/utils/check_api_reference.py b/utils/check_api_reference.py
index 0f9eff26..80ee5d7a 100644
--- a/utils/check_api_reference.py
+++ b/utils/check_api_reference.py
@@ -148,6 +148,7 @@ if (
     .difference(expr)
     .difference(
         {
+            "to_dummies",
             "to_pandas",
             "to_list",
             "to_numpy",
diff --git a/utils/check_backend_completeness.py b/utils/check_backend_completeness.py
index ad486f4b..1fe99749 100644
--- a/utils/check_backend_completeness.py
+++ b/utils/check_backend_completeness.py
@@ -19,8 +19,6 @@ MISSING = [
     "Series.quantile",
     "Series.round",
     "Series.shift",
-    "Series.sort",
-    "Series.unique",
 ]
 
 


Test patch:
diff --git a/tests/expr_and_series/sort_test.py b/tests/expr_and_series/sort_test.py
new file mode 100644
index 00000000..22c0be6b
--- /dev/null
+++ b/tests/expr_and_series/sort_test.py
@@ -0,0 +1,31 @@
+from typing import Any
+
+import numpy as np
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+data = {"a": [1, 3, 2], "b": [0, 2, -1]}
+
+
+@pytest.mark.parametrize(
+    ("descending", "expected"),
+    [
+        (True, {"a": [3, 2, 1], "b": [0, 2, -1]}),
+        (False, {"a": [1, 2, 3], "b": [0, 2, -1]}),
+    ],
+)
+def test_sort_expr(constructor: Any, descending: Any, expected: Any) -> None:
+    df = nw.from_native(constructor(data), eager_only=True)
+    result = df.select(nw.col("a").sort(descending=descending), "b")
+    compare_dicts(result, expected)
+
+
+@pytest.mark.parametrize(
+    ("descending", "expected"), [(True, [3, 2, 1]), (False, [1, 2, 3])]
+)
+def test_sort_series(constructor_series: Any, descending: Any, expected: Any) -> None:
+    series = nw.from_native(constructor_series(data["a"]), series_only=True)
+    result = series.sort(descending=descending)
+    assert (result.to_numpy() == np.array(expected)).all()
diff --git a/tests/expr_and_series/unary_test.py b/tests/expr_and_series/unary_test.py
index 474ef879..0d9b6874 100644
--- a/tests/expr_and_series/unary_test.py
+++ b/tests/expr_and_series/unary_test.py
@@ -1,14 +1,10 @@
 from typing import Any
 
-import pytest
-
 import narwhals as nw
 from tests.utils import compare_dicts
 
 
-def test_unary(request: Any, constructor_with_lazy: Any) -> None:
-    if "pyarrow_table" in str(constructor_with_lazy):
-        request.applymarker(pytest.mark.xfail)
+def test_unary(constructor_with_lazy: Any) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
     result = (
         nw.from_native(constructor_with_lazy(data))
diff --git a/tests/expr_and_series/unique_test.py b/tests/expr_and_series/unique_test.py
new file mode 100644
index 00000000..f9d2198f
--- /dev/null
+++ b/tests/expr_and_series/unique_test.py
@@ -0,0 +1,22 @@
+from typing import Any
+
+import numpy as np
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+data = {"a": [1, 1, 2]}
+
+
+def test_unique_expr(constructor: Any) -> None:
+    df = nw.from_native(constructor(data), eager_only=True)
+    result = df.select(nw.col("a").unique())
+    expected = {"a": [1, 2]}
+    compare_dicts(result, expected)
+
+
+def test_unique_series(constructor_series: Any) -> None:
+    series = nw.from_native(constructor_series(data["a"]), series_only=True)
+    result = series.unique()
+    expected = np.array([1, 2])
+    assert (result.to_numpy() == expected).all()
diff --git a/tests/series_only/to_dummy_test.py b/tests/series_only/to_dummy_test.py
new file mode 100644
index 00000000..5bd80cc3
--- /dev/null
+++ b/tests/series_only/to_dummy_test.py
@@ -0,0 +1,26 @@
+from typing import Any
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+data = [1, 2, 3]
+
+
+@pytest.mark.parametrize("sep", ["_", "-"])
+def test_to_dummies(constructor_series: Any, sep: str) -> None:
+    s = nw.from_native(constructor_series(data), series_only=True).alias("a")
+    result = s.to_dummies(separator=sep)
+    expected = {f"a{sep}1": [1, 0, 0], f"a{sep}2": [0, 1, 0], f"a{sep}3": [0, 0, 1]}
+
+    compare_dicts(result, expected)
+
+
+@pytest.mark.parametrize("sep", ["_", "-"])
+def test_to_dummies_drop_first(constructor_series: Any, sep: str) -> None:
+    s = nw.from_native(constructor_series(data), series_only=True).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]}
+
+    compare_dicts(result, expected)