Repository: narwhals-dev/narwhals
Pull request #1212 (2024-10-17 22:46:53)

feat: add support for `median`
<!-- 
# Thanks for contributing a pull request! 
## Please make sure you see our contribution guidelines: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues 

- Closes https://github.com/narwhals-dev/narwhals/issues/1190

## 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.
WIP: I would need to better dig into details for `pyarrow` and `dask`. Already opened the PR to (also) get advices and/or guidance:)

My understanding per now (might be missing bits, though) is the following:

- neither `pyarrow` nor `dask` do implement a "proper" `median`; otoh, they respectively implement `approximate_median` and `median_approximate` (`dask`'s `median_approximate` fails tests when `npartitions=2`, though)
- relying on already implemented `quantile`(s) would come with the following issues: for `pyarrow`, the issue of having an `interpolation` parameter which would not quite fit the `nw.median()` signature; for dask, the issue of having to hardcode a `"linear"` interpolation strategy and having to `xfail` tests as well when encountering `dask_lazy_p2_constructor`.

Also, need to revise docstrings so as to comply with https://github.com/narwhals-dev/narwhals/issues/1000 for `median`.

FBruzzesi: Hey @AlessandroMiola thanks for the effort! This already looks very promising. 

> neither pyarrow nor dask do implement a "proper" median; otoh, they respectively implement approximate_median and median_approximate

I think that's good enough as long as we document that results may slightly differ between backend because of the difference of underlying algorithms used

>  dask's median_approximate fails tests when npartitions=2, though

This may need some investigation

> Also, need to revise docstrings so as to comply with https://github.com/narwhals-dev/narwhals/issues/1000 for median.

This is much appreciated 😁


I have a couple of additional considerations:

- Polars on string series returns None, pandas raises an error, I don't know about others - should we check that the input is numeric and limit the functionality to that? cc @MarcoGorelli 
- Can we check that all algorithms ignore nulls/nans?
AlessandroMiola: Thanks for your help @FBruzzesi! :) I'll try to address all of your comments!
AlessandroMiola: > I find it a bit odd that `Series.median` doesn't raise for Polars, and think it'd be ok for us to just always raise

A (possibly silly) question on my side for clarification. Should we make it so that it passes tests like the ones below (thus raising a custom error within the underlying backends) or should we just raise where it natively does not while keeping native behaviour as is?
I would have gone the first way, but I'm all ears! :D 
```
data = {
    "a": [3, 8, 2, None],
    "b": [5, 5, None, 7],
    "z": [7.0, 8, 9, None],
    "s": ["f", "a", "x", "x"],
}

@pytest.mark.parametrize(
    "expr", [nw.col("s").median(), nw.median("s")]
)
def test_median_expr_raises_on_str(constructor: Constructor, expr: nw.Expr) -> None:
    df = nw.from_native(constructor(data))
    with pytest.raises(
        TypeError,
        match="`median` operation not supported for non-numeric input type."
    ):
        df.select(expr)

@pytest.mark.parametrize(("col"), [("s")])
def test_median_series_raises_on_str(
    constructor_eager: Any,
    col: str,
) -> None:
    series = nw.from_native(constructor_eager(data), eager_only=True)[col]
    with pytest.raises(
        TypeError,
        match="`median` operation not supported for non-numeric input type."
    ):
        series.median()
```
Please correct me if I'm completely off-road! Thanks
FBruzzesi: > A (possibly silly) question on my side for clarification. Should we make it so that it passes tests like the ones below (thus raising a custom error within the underlying backends) or should we just raise where it natively does not while keeping native behaviour as is? I would have gone the first way, but I'm all ears! :D

Hey @AlessandroMiola , I would personally opt for the second option: you could create a `InvalidOperationError` class  in `_exceptions.py` and raise that one. For series it would be even possible to do so in `narwhals.Series` so that the type checking and raise does not get duplicated. For `narwhals.Expr` I would not know how to do it directly though, since datatype is not know until computation in polars. 

So maybe first option 😂
FBruzzesi: Hey @AlessandroMiola , I think this is fairly ready for review, however in #1224 , `compare_dicts` was renamed to `assert_equal_data` and that's where the error in CI comes from.
AlessandroMiola: > Hey @AlessandroMiola , I think this is fairly ready for review, however in #1224 , `compare_dicts` was renamed to `assert_equal_data` and that's where the error in CI comes from.

Hi @FBruzzesi, thanks for having a look. Besides what you mention, I still have uncommitted changes related to:

> * Can we check that all algorithms ignore nulls/nans?

This is indeed pretty straightforward (as all backends _seem to_ natively do it) and potentially ready for being committed and pushed.

> I think that's good enough as long as we document that results may slightly differ between backends because of the difference of underlying algorithms used

As above.

> A (possibly silly) question on my side for clarification. Should we make it so that it passes tests like the ones below (thus raising a custom error within the underlying backends) or should we just raise where it natively does not while keeping native behaviour as is? I would have gone the first way, but I'm all ears! :D
>
> Hey @AlessandroMiola , I would personally opt for the second option: you could create a `InvalidOperationError` class in `_exceptions.py` and raise that one. For series it would be even possible to do so in `narwhals.Series` so that the type checking and raise does not get duplicated. For `narwhals.Expr` I would not know how to do it directly though, since datatype is not know until computation in polars.

Here, I see your point and I indeed had some issues in figuring out how to deal with `Expr` at the "`narwhals`-level" (if even possible), but I would need to revise everything 

> > dask's median_approximate fails tests when npartitions=2, though
> 
> This may need some investigation

On this, I haven't started yet :|

Later today and tomorrow I should have time, so I'll hopefully update you and/or push something.
FBruzzesi: Hey @AlessandroMiola I have one more request 🙈 As `median` returns a scalar, could you add a test verifying that it works in a `group_by` context? I would expect that a remapping is required at least in the pyarrow case (`median` to `hash_approximate_median`)

Patch:
diff --git a/README.md b/README.md
index 9308af7c..ecff6b46 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,7 @@ Join the party!
 - [rio](https://github.com/rio-labs/rio)
 - [scikit-lego](https://github.com/koaning/scikit-lego)
 - [scikit-playtime](https://github.com/koaning/scikit-playtime)
+- [tabmat](https://github.com/Quantco/tabmat)
 - [timebasedcv](https://github.com/FBruzzesi/timebasedcv)
 - [tubular](https://github.com/lvgig/tubular)
 - [wimsey](https://github.com/benrutter/wimsey)
diff --git a/docs/api-reference/expr.md b/docs/api-reference/expr.md
index 88dd86a6..bf68689a 100644
--- a/docs/api-reference/expr.md
+++ b/docs/api-reference/expr.md
@@ -29,6 +29,7 @@
         - len
         - max
         - mean
+        - median
         - min
         - mode
         - null_count
diff --git a/docs/api-reference/narwhals.md b/docs/api-reference/narwhals.md
index 2b5be6e8..165af7f3 100644
--- a/docs/api-reference/narwhals.md
+++ b/docs/api-reference/narwhals.md
@@ -30,6 +30,7 @@ Here are the top-level functions available in Narwhals.
         - maybe_set_index
         - mean
         - mean_horizontal
+        - median
         - min
         - min_horizontal
         - narwhalify
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index 23b2c878..e1b11c38 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -36,6 +36,7 @@
         - len
         - max
         - mean
+        - median
         - min
         - mode
         - name
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index 95abb672..974c2505 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -40,6 +40,7 @@ from narwhals.expr import max
 from narwhals.expr import max_horizontal
 from narwhals.expr import mean
 from narwhals.expr import mean_horizontal
+from narwhals.expr import median
 from narwhals.expr import min
 from narwhals.expr import min_horizontal
 from narwhals.expr import nth
@@ -99,6 +100,7 @@ __all__ = [
     "max_horizontal",
     "mean",
     "mean_horizontal",
+    "median",
     "min",
     "min_horizontal",
     "nth",
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 592fe615..802a900b 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -205,6 +205,9 @@ class ArrowExpr:
     def mean(self) -> Self:
         return reuse_series_implementation(self, "mean", returns_scalar=True)
 
+    def median(self) -> Self:
+        return reuse_series_implementation(self, "median", returns_scalar=True)
+
     def count(self) -> Self:
         return reuse_series_implementation(self, "count", returns_scalar=True)
 
@@ -322,7 +325,7 @@ class ArrowExpr:
         return reuse_series_implementation(self, "unique", maintain_order=maintain_order)
 
     def replace_strict(
-        self: Self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType
+        self: Self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType | None
     ) -> Self:
         return reuse_series_implementation(
             self, "replace_strict", old, new, return_dtype=return_dtype
diff --git a/narwhals/_arrow/group_by.py b/narwhals/_arrow/group_by.py
index 030fcd11..78777e2c 100644
--- a/narwhals/_arrow/group_by.py
+++ b/narwhals/_arrow/group_by.py
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
 
 POLARS_TO_ARROW_AGGREGATIONS = {
     "len": "count",
+    "median": "approximate_median",
     "n_unique": "count_distinct",
     "std": "stddev",
     "var": "variance",  # currently unused, we don't have `var` yet
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index dd9289de..c03fc723 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -325,6 +325,11 @@ class ArrowNamespace:
             *column_names, backend_version=self._backend_version, dtypes=self._dtypes
         ).mean()
 
+    def median(self, *column_names: str) -> ArrowExpr:
+        return ArrowExpr.from_column_names(
+            *column_names, backend_version=self._backend_version, dtypes=self._dtypes
+        ).median()
+
     def max(self, *column_names: str) -> ArrowExpr:
         return ArrowExpr.from_column_names(
             *column_names, backend_version=self._backend_version, dtypes=self._dtypes
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 15f64110..bc22f346 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -261,6 +261,17 @@ class ArrowSeries:
 
         return pc.mean(self._native_series)  # type: ignore[no-any-return]
 
+    def median(self) -> int:
+        import pyarrow.compute as pc  # ignore-banned-import()
+
+        from narwhals._exceptions import InvalidOperationError
+
+        if not self.dtype.is_numeric():
+            msg = "`median` operation not supported for non-numeric input type."
+            raise InvalidOperationError(msg)
+
+        return pc.approximate_median(self._native_series)  # type: ignore[no-any-return]
+
     def min(self) -> int:
         import pyarrow.compute as pc  # ignore-banned-import()
 
@@ -656,16 +667,16 @@ class ArrowSeries:
         return self._from_native_series(pc.unique(self._native_series))
 
     def replace_strict(
-        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType
+        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType | None
     ) -> ArrowSeries:
         import pyarrow as pa  # ignore-banned-import
         import pyarrow.compute as pc  # ignore-banned-import
 
         # https://stackoverflow.com/a/79111029/4451315
         idxs = pc.index_in(self._native_series, pa.array(old))
-        result_native = pc.take(pa.array(new), idxs).cast(
-            narwhals_to_native_dtype(return_dtype, self._dtypes)
-        )
+        result_native = pc.take(pa.array(new), idxs)
+        if return_dtype is not None:
+            result_native.cast(narwhals_to_native_dtype(return_dtype, self._dtypes))
         result = self._from_native_series(result_native)
         if result.is_null().sum() != self.is_null().sum():
             msg = (
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index 3a5e69af..169adc43 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -383,6 +383,19 @@ class DaskExpr:
             returns_scalar=True,
         )
 
+    def median(self) -> Self:
+        from dask_expr._shuffle import _is_numeric_cast_type
+
+        from narwhals._exceptions import InvalidOperationError
+
+        def func(_input: dask_expr.Series) -> dask_expr.Series:
+            if not _is_numeric_cast_type(_input.dtype):
+                msg = "`median` operation not supported for non-numeric input type."
+                raise InvalidOperationError(msg)
+            return _input.median_approximate()
+
+        return self._from_call(func, "median", returns_scalar=True)
+
     def min(self) -> Self:
         return self._from_call(
             lambda _input: _input.min(),
@@ -479,7 +492,7 @@ class DaskExpr:
         raise NotImplementedError(msg)
 
     def replace_strict(
-        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType
+        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType | None
     ) -> Self:
         msg = "`replace_strict` is not yet supported for Dask expressions"
         raise NotImplementedError(msg)
diff --git a/narwhals/_dask/namespace.py b/narwhals/_dask/namespace.py
index 23c133a1..08fa283e 100644
--- a/narwhals/_dask/namespace.py
+++ b/narwhals/_dask/namespace.py
@@ -104,6 +104,11 @@ class DaskNamespace:
             *column_names, backend_version=self._backend_version, dtypes=self._dtypes
         ).mean()
 
+    def median(self, *column_names: str) -> DaskExpr:
+        return DaskExpr.from_column_names(
+            *column_names, backend_version=self._backend_version, dtypes=self._dtypes
+        ).median()
+
     def sum(self, *column_names: str) -> DaskExpr:
         return DaskExpr.from_column_names(
             *column_names, backend_version=self._backend_version, dtypes=self._dtypes
diff --git a/narwhals/_exceptions.py b/narwhals/_exceptions.py
index 18995451..a3d6aa72 100644
--- a/narwhals/_exceptions.py
+++ b/narwhals/_exceptions.py
@@ -2,3 +2,6 @@ from __future__ import annotations
 
 
 class ColumnNotFoundError(Exception): ...
+
+
+class InvalidOperationError(Exception): ...
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index a3c3aae0..9ebbc315 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -220,6 +220,9 @@ class PandasLikeExpr:
     def mean(self) -> Self:
         return reuse_series_implementation(self, "mean", returns_scalar=True)
 
+    def median(self) -> Self:
+        return reuse_series_implementation(self, "median", returns_scalar=True)
+
     def std(self, *, ddof: int = 1) -> Self:
         return reuse_series_implementation(self, "std", ddof=ddof, returns_scalar=True)
 
@@ -274,7 +277,7 @@ class PandasLikeExpr:
         return reuse_series_implementation(self, "drop_nulls")
 
     def replace_strict(
-        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType
+        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType | None
     ) -> Self:
         return reuse_series_implementation(
             self, "replace_strict", old, new, return_dtype=return_dtype
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index bb436890..5646a5f1 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -177,6 +177,14 @@ class PandasLikeNamespace:
             dtypes=self._dtypes,
         ).mean()
 
+    def median(self, *column_names: str) -> PandasLikeExpr:
+        return PandasLikeExpr.from_column_names(
+            *column_names,
+            implementation=self._implementation,
+            backend_version=self._backend_version,
+            dtypes=self._dtypes,
+        ).median()
+
     def max(self, *column_names: str) -> PandasLikeExpr:
         return PandasLikeExpr.from_column_names(
             *column_names,
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index c7e7d67b..eb3c22c7 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -425,6 +425,15 @@ class PandasLikeSeries:
         ser = self._native_series
         return ser.mean()
 
+    def median(self) -> Any:
+        from narwhals._exceptions import InvalidOperationError
+
+        if not self.dtype.is_numeric():
+            msg = "`median` operation not supported for non-numeric input type."
+            raise InvalidOperationError(msg)
+        ser = self._native_series
+        return ser.median()
+
     def std(
         self,
         *,
@@ -492,15 +501,19 @@ class PandasLikeSeries:
         return self._from_native_series(self._native_series.shift(n))
 
     def replace_strict(
-        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType
+        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType | None
     ) -> PandasLikeSeries:
         tmp_name = f"{self.name}_tmp"
-        dtype = narwhals_to_native_dtype(
-            return_dtype,
-            self._native_series.dtype,
-            self._implementation,
-            self._backend_version,
-            self._dtypes,
+        dtype = (
+            narwhals_to_native_dtype(
+                return_dtype,
+                self._native_series.dtype,
+                self._implementation,
+                self._backend_version,
+                self._dtypes,
+            )
+            if return_dtype
+            else None
         )
         other = self.__native_namespace__().DataFrame(
             {
diff --git a/narwhals/_polars/expr.py b/narwhals/_polars/expr.py
index fb531943..46b415dd 100644
--- a/narwhals/_polars/expr.py
+++ b/narwhals/_polars/expr.py
@@ -48,10 +48,12 @@ class PolarsExpr:
         return self._from_native_expr(expr.cast(dtype))
 
     def replace_strict(
-        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType
+        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType | None
     ) -> Self:
         expr = self._native_expr
-        return_dtype = narwhals_to_native_dtype(return_dtype, self._dtypes)
+        return_dtype = (
+            narwhals_to_native_dtype(return_dtype, self._dtypes) if return_dtype else None
+        )
         if self._backend_version < (1,):
             msg = f"`replace_strict` is only available in Polars>=1.0, found version {self._backend_version}"
             raise NotImplementedError(msg)
diff --git a/narwhals/_polars/namespace.py b/narwhals/_polars/namespace.py
index 4969ec9c..0a9f53c8 100644
--- a/narwhals/_polars/namespace.py
+++ b/narwhals/_polars/namespace.py
@@ -142,6 +142,17 @@ class PolarsNamespace:
             backend_version=self._backend_version,
         )
 
+    def median(self, *column_names: str) -> PolarsExpr:
+        import polars as pl  # ignore-banned-import()
+
+        from narwhals._polars.expr import PolarsExpr
+
+        return PolarsExpr(
+            pl.median([*column_names]),  # type: ignore[arg-type]
+            dtypes=self._dtypes,
+            backend_version=self._backend_version,
+        )
+
     def concat_str(
         self,
         exprs: Iterable[IntoPolarsExpr],
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index 2abe0257..3bc684d2 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -106,10 +106,12 @@ class PolarsSeries:
         return self._from_native_series(ser.cast(dtype))
 
     def replace_strict(
-        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType
+        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType | None
     ) -> Self:
         ser = self._native_series
-        dtype = narwhals_to_native_dtype(return_dtype, self._dtypes)
+        dtype = (
+            narwhals_to_native_dtype(return_dtype, self._dtypes) if return_dtype else None
+        )
         if self._backend_version < (1,):
             msg = f"`replace_strict` is only available in Polars>=1.0, found version {self._backend_version}"
             raise NotImplementedError(msg)
@@ -189,6 +191,15 @@ class PolarsSeries:
     def __invert__(self) -> Self:
         return self._from_native_series(self._native_series.__invert__())
 
+    def median(self) -> Any:
+        from narwhals._exceptions import InvalidOperationError
+
+        if not self.dtype.is_numeric():
+            msg = "`median` operation not supported for non-numeric input type."
+            raise InvalidOperationError(msg)
+
+        return self._native_series.median()
+
     def to_dummies(
         self: Self, *, separator: str = "_", drop_first: bool = False
     ) -> PolarsDataFrame:
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 624c253b..593f8ad7 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -6,6 +6,7 @@ from typing import Callable
 from typing import Generic
 from typing import Iterable
 from typing import Literal
+from typing import Mapping
 from typing import Sequence
 from typing import TypeVar
 
@@ -444,6 +445,52 @@ class Expr:
         """
         return self.__class__(lambda plx: self._call(plx).mean())
 
+    def median(self) -> Self:
+        """
+        Get median value.
+
+        Notes:
+            Results might slightly differ across backends due to differences in the underlying algorithms used to compute the median.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> df_pd = pd.DataFrame({"a": [1, 8, 3], "b": [4, 5, 2]})
+            >>> df_pl = pl.DataFrame({"a": [1, 8, 3], "b": [4, 5, 2]})
+            >>> df_pa = pa.table({"a": [1, 8, 3], "b": [4, 5, 2]})
+
+            Let's define a dataframe-agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col("a", "b").median())
+
+            We can then pass any supported library such as pandas, Polars, or PyArrow to `func`:
+
+            >>> func(df_pd)
+                 a    b
+            0  3.0  4.0
+            >>> func(df_pl)
+            shape: (1, 2)
+            ┌─────┬─────┐
+            │ a   ┆ b   │
+            │ --- ┆ --- │
+            │ f64 ┆ f64 │
+            ╞═════╪═════╡
+            │ 3.0 ┆ 4.0 │
+            └─────┴─────┘
+            >>> func(df_pa)
+            pyarrow.Table
+            a: double
+            b: double
+            ----
+            a: [[3]]
+            b: [[4]]
+        """
+        return self.__class__(lambda plx: self._call(plx).median())
+
     def std(self, *, ddof: int = 1) -> Self:
         """
         Get standard deviation.
@@ -975,18 +1022,25 @@ class Expr:
         return self.__class__(lambda plx: self._call(plx).shift(n))
 
     def replace_strict(
-        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType | type[DType]
+        self,
+        old: Sequence[Any] | Mapping[Any, Any],
+        new: Sequence[Any] | None = None,
+        *,
+        return_dtype: DType | type[DType] | None = None,
     ) -> Self:
         """
-        Replace old values with new values.
+        Replace all values by different values.
 
-        This function must replace all non-null input values (else it raises an error),
-        and the return dtype must be specified.
+        This function must replace all non-null input values (else it raises an error).
 
         Arguments:
-            old: Sequence of old values to replace.
-            new: Sequence of new values to replace.
-            return_dtype: Return dtype.
+            old: Sequence of values to replace. It also accepts a mapping of values to
+                their replacement as syntactic sugar for
+                `replace_all(old=list(mapping.keys()), new=list(mapping.values()))`.
+            new: Sequence of values to replace by. Length must match the length of `old`.
+            return_dtype: The data type of the resulting expression. If set to `None`
+                (default), the data type is determined automatically based on the other
+                inputs.
 
         Examples:
             >>> import narwhals as nw
@@ -1003,7 +1057,8 @@ class Expr:
             ... def func(df):
             ...     return df.with_columns(
             ...         b=nw.col("a").replace_strict(
-            ...             [0,1,2,3], ['zero', 'one', 'two', 'three']
+            ...             [0, 1, 2, 3],
+            ...             ["zero", "one", "two", "three"],
             ...             return_dtype=nw.String,
             ...         )
             ...     )
@@ -1036,6 +1091,14 @@ class Expr:
             a: [[3,0,1,2]]
             b: [["three","zero","one","two"]]
         """
+        if new is None:
+            if not isinstance(old, Mapping):
+                msg = "`new` argument is required if `old` argument is not a Mapping type"
+                raise TypeError(msg)
+
+            new = list(old.values())
+            old = list(old.keys())
+
         return self.__class__(
             lambda plx: self._call(plx).replace_strict(
                 old, new, return_dtype=return_dtype
@@ -4623,6 +4686,56 @@ def mean(*columns: str) -> Expr:
     return Expr(lambda plx: plx.mean(*columns))
 
 
+def median(*columns: str) -> Expr:
+    """
+    Get the median value.
+
+    Notes:
+        - Syntactic sugar for ``nw.col(columns).median()``
+        - Results might slightly differ across backends due to differences in the underlying algorithms used to compute the median.
+
+    Arguments:
+        columns: Name(s) of the columns to use in the aggregation function
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import pyarrow as pa
+        >>> import narwhals as nw
+        >>> df_pd = pd.DataFrame({"a": [4, 5, 2]})
+        >>> df_pl = pl.DataFrame({"a": [4, 5, 2]})
+        >>> df_pa = pa.table({"a": [4, 5, 2]})
+
+        Let's define a dataframe agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.select(nw.median("a"))
+
+        We can then pass any supported library such as pandas, Polars, or PyArrow to `func`:
+
+        >>> func(df_pd)
+             a
+        0  4.0
+        >>> func(df_pl)
+        shape: (1, 1)
+        ┌─────┐
+        │ a   │
+        │ --- │
+        │ f64 │
+        ╞═════╡
+        │ 4.0 │
+        └─────┘
+        >>> func(df_pa)
+        pyarrow.Table
+        a: double
+        ----
+        a: [[4]]
+    """
+
+    return Expr(lambda plx: plx.median(*columns))
+
+
 def min(*columns: str) -> Expr:
     """
     Return the minimum value.
diff --git a/narwhals/series.py b/narwhals/series.py
index 7b179cce..8b3ad25f 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -6,6 +6,7 @@ from typing import Callable
 from typing import Generic
 from typing import Iterator
 from typing import Literal
+from typing import Mapping
 from typing import Sequence
 from typing import TypeVar
 from typing import overload
@@ -524,6 +525,40 @@ class Series:
         """
         return self._compliant_series.mean()
 
+    def median(self) -> Any:
+        """
+        Reduce this Series to the median value.
+
+        Notes:
+            Results might slightly differ across backends due to differences in the underlying algorithms used to compute the median.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> s = [5, 3, 8]
+            >>> s_pd = pd.Series(s)
+            >>> s_pl = pl.Series(s)
+            >>> s_pa = pa.chunked_array([s])
+
+            Let's define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(s):
+            ...     return s.median()
+
+            We can then pass any supported library such as pandas, Polars, or PyArrow to `func`:
+
+            >>> func(s_pd)
+            np.float64(5.0)
+            >>> func(s_pl)
+            5.0
+            >>> func(s_pa)
+            <pyarrow.DoubleScalar: 5.0>
+        """
+        return self._compliant_series.median()
+
     def count(self) -> Any:
         """
         Returns the number of non-null elements in the Series.
@@ -1350,18 +1385,25 @@ class Series:
         return self.alias(name=name)
 
     def replace_strict(
-        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType | type[DType]
+        self: Self,
+        old: Sequence[Any] | Mapping[Any, Any],
+        new: Sequence[Any] | None = None,
+        *,
+        return_dtype: DType | type[DType] | None = None,
     ) -> Self:
         """
-        Replace old values with values.
+        Replace all values by different values.
 
-        This function must replace all non-null input values (else it raises an error),
-        and the return dtype must be specified.
+        This function must replace all non-null input values (else it raises an error).
 
         Arguments:
-            old: Sequence of old values to replace.
-            new: Sequence of new values to replace.
-            return_dtype: Return dtype.
+            old: Sequence of values to replace. It also accepts a mapping of values to
+                their replacement as syntactic sugar for
+                `replace_all(old=list(mapping.keys()), new=list(mapping.values()))`.
+            new: Sequence of values to replace by. Length must match the length of `old`.
+            return_dtype: The data type of the resulting expression. If set to `None`
+                (default), the data type is determined automatically based on the other
+                inputs.
 
         Examples:
             >>> import narwhals as nw
@@ -1408,6 +1450,14 @@ class Series:
               ]
             ]
         """
+        if new is None:
+            if not isinstance(old, Mapping):
+                msg = "`new` argument is required if `old` argument is not a Mapping type"
+                raise TypeError(msg)
+
+            new = list(old.values())
+            old = list(old.keys())
+
         return self._from_compliant_series(
             self._compliant_series.replace_strict(old, new, return_dtype=return_dtype)
         )
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index 8e217f08..fca13071 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -1620,6 +1620,55 @@ def mean(*columns: str) -> Expr:
     return _stableify(nw.mean(*columns))
 
 
+def median(*columns: str) -> Expr:
+    """
+    Get the median value.
+
+    Notes:
+        - Syntactic sugar for ``nw.col(columns).median()``
+        - Results might slightly differ across backends due to differences in the underlying algorithms used to compute the median.
+
+    Arguments:
+        columns: Name(s) of the columns to use in the aggregation function
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import pyarrow as pa
+        >>> import narwhals as nw
+        >>> df_pd = pd.DataFrame({"a": [4, 5, 2]})
+        >>> df_pl = pl.DataFrame({"a": [4, 5, 2]})
+        >>> df_pa = pa.table({"a": [4, 5, 2]})
+
+        Let's define a dataframe agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.select(nw.median("a"))
+
+        We can then pass any supported library such as pandas, Polars, or PyArrow to `func`:
+
+        >>> func(df_pd)
+             a
+        0  4.0
+        >>> func(df_pl)
+        shape: (1, 1)
+        ┌─────┐
+        │ a   │
+        │ --- │
+        │ f64 │
+        ╞═════╡
+        │ 4.0 │
+        └─────┘
+        >>> func(df_pa)
+        pyarrow.Table
+        a: double
+        ----
+        a: [[4]]
+    """
+    return _stableify(nw.median(*columns))
+
+
 def sum(*columns: str) -> Expr:
     """
     Sum all values.
@@ -2519,6 +2568,7 @@ __all__ = [
     "max_horizontal",
     "mean",
     "mean_horizontal",
+    "median",
     "min",
     "min_horizontal",
     "sum",


Test patch:
diff --git a/.github/workflows/downstream_tests.yml b/.github/workflows/downstream_tests.yml
index d585a7e2..775da8f3 100644
--- a/.github/workflows/downstream_tests.yml
+++ b/.github/workflows/downstream_tests.yml
@@ -184,6 +184,8 @@ jobs:
         run: |
             uv pip uninstall narwhals --system
             uv pip install -e . --system
+            # temporarily pin websockets to get CI green
+            uv pip install "websockets<14.0" --system
       - name: show-deps
         run: uv pip freeze
       - name: Run `make narwhals-test-integration`
diff --git a/tests/expr_and_series/median_test.py b/tests/expr_and_series/median_test.py
new file mode 100644
index 00000000..e46a0163
--- /dev/null
+++ b/tests/expr_and_series/median_test.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from narwhals._exceptions import InvalidOperationError
+from tests.utils import Constructor
+from tests.utils import ConstructorEager
+from tests.utils import assert_equal_data
+
+data = {
+    "a": [3, 8, 2, None],
+    "b": [5, 5, None, 7],
+    "z": [7.0, 8, 9, None],
+    "s": ["f", "a", "x", "x"],
+}
+
+
+@pytest.mark.parametrize(
+    "expr", [nw.col("a", "b", "z").median(), nw.median("a", "b", "z")]
+)
+def test_median_expr(
+    constructor: Constructor, expr: nw.Expr, request: pytest.FixtureRequest
+) -> None:
+    if "dask_lazy_p2" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+    df = nw.from_native(constructor(data))
+    result = df.select(expr)
+    expected = {"a": [3.0], "b": [5.0], "z": [8.0]}
+    assert_equal_data(result, expected)
+
+
+@pytest.mark.parametrize(("col", "expected"), [("a", 3.0), ("b", 5.0), ("z", 8.0)])
+def test_median_series(
+    constructor_eager: ConstructorEager, col: str, expected: float
+) -> None:
+    series = nw.from_native(constructor_eager(data), eager_only=True)[col]
+    result = series.median()
+    assert_equal_data({col: [result]}, {col: [expected]})
+
+
+@pytest.mark.parametrize("expr", [nw.col("s").median(), nw.median("s")])
+def test_median_expr_raises_on_str(
+    constructor: Constructor,
+    expr: nw.Expr,
+) -> None:
+    from polars.exceptions import InvalidOperationError as PlInvalidOperationError
+
+    df = nw.from_native(constructor(data))
+    if "polars_lazy" in str(constructor):
+        with pytest.raises(
+            PlInvalidOperationError,
+            match="`median` operation not supported for dtype `str`",
+        ):
+            df.select(expr).lazy().collect()
+    else:
+        with pytest.raises(
+            (InvalidOperationError, PlInvalidOperationError),
+            match="`median` operation not supported",
+        ):
+            df.select(expr)
+
+
+@pytest.mark.parametrize(("col"), [("s")])
+def test_median_series_raises_on_str(
+    constructor_eager: ConstructorEager, col: str
+) -> None:
+    series = nw.from_native(constructor_eager(data), eager_only=True)[col]
+    with pytest.raises(
+        InvalidOperationError,
+        match="`median` operation not supported for non-numeric input type.",
+    ):
+        series.median()
diff --git a/tests/expr_and_series/replace_strict_test.py b/tests/expr_and_series/replace_strict_test.py
index 8362897a..b1449af2 100644
--- a/tests/expr_and_series/replace_strict_test.py
+++ b/tests/expr_and_series/replace_strict_test.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+from typing import TYPE_CHECKING
+
 import pytest
 
 import narwhals.stable.v1 as nw
@@ -8,17 +10,23 @@ from tests.utils import Constructor
 from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 
+if TYPE_CHECKING:
+    from narwhals.dtypes import DType
+
 
 @pytest.mark.skipif(
     POLARS_VERSION < (1, 0), reason="replace_strict only available after 1.0"
 )
-def test_replace_strict(constructor: Constructor, request: pytest.FixtureRequest) -> None:
+@pytest.mark.parametrize("return_dtype", [nw.String(), None])
+def test_replace_strict(
+    constructor: Constructor, request: pytest.FixtureRequest, return_dtype: DType | None
+) -> None:
     if "dask" in str(constructor):
         request.applymarker(pytest.mark.xfail)
     df = nw.from_native(constructor({"a": [1, 2, 3]}))
     result = df.select(
         nw.col("a").replace_strict(
-            [1, 2, 3], ["one", "two", "three"], return_dtype=nw.String
+            [1, 2, 3], ["one", "two", "three"], return_dtype=return_dtype
         )
     )
     assert_equal_data(result, {"a": ["one", "two", "three"]})
@@ -27,10 +35,15 @@ def test_replace_strict(constructor: Constructor, request: pytest.FixtureRequest
 @pytest.mark.skipif(
     POLARS_VERSION < (1, 0), reason="replace_strict only available after 1.0"
 )
-def test_replace_strict_series(constructor_eager: ConstructorEager) -> None:
+@pytest.mark.parametrize("return_dtype", [nw.String(), None])
+def test_replace_strict_series(
+    constructor_eager: ConstructorEager, return_dtype: DType | None
+) -> None:
     df = nw.from_native(constructor_eager({"a": [1, 2, 3]}))
     result = df.select(
-        df["a"].replace_strict([1, 2, 3], ["one", "two", "three"], return_dtype=nw.String)
+        df["a"].replace_strict(
+            [1, 2, 3], ["one", "two", "three"], return_dtype=return_dtype
+        )
     )
     assert_equal_data(result, {"a": ["one", "two", "three"]})
 
@@ -54,3 +67,56 @@ def test_replace_non_full(
     else:
         with pytest.raises((ValueError, PolarsError)):
             df.select(nw.col("a").replace_strict([1, 3], [3, 4], return_dtype=nw.Int64))
+
+
+@pytest.mark.skipif(
+    POLARS_VERSION < (1, 0), reason="replace_strict only available after 1.0"
+)
+def test_replace_strict_mapping(
+    constructor: Constructor, request: pytest.FixtureRequest
+) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor({"a": [1, 2, 3]}))
+    result = df.select(
+        nw.col("a").replace_strict(
+            {1: "one", 2: "two", 3: "three"}, return_dtype=nw.String()
+        )
+    )
+    assert_equal_data(result, {"a": ["one", "two", "three"]})
+
+
+@pytest.mark.skipif(
+    POLARS_VERSION < (1, 0), reason="replace_strict only available after 1.0"
+)
+def test_replace_strict_series_mapping(constructor_eager: ConstructorEager) -> None:
+    df = nw.from_native(constructor_eager({"a": [1, 2, 3]}))
+    result = df.select(
+        df["a"].replace_strict({1: "one", 2: "two", 3: "three"}, return_dtype=nw.String())
+    )
+    assert_equal_data(result, {"a": ["one", "two", "three"]})
+
+
+@pytest.mark.skipif(
+    POLARS_VERSION < (1, 0), reason="replace_strict only available after 1.0"
+)
+def test_replace_strict_invalid(constructor: Constructor) -> None:
+    df = nw.from_native(constructor({"a": [1, 2, 3]}))
+    with pytest.raises(
+        TypeError,
+        match="`new` argument is required if `old` argument is not a Mapping type",
+    ):
+        df.select(nw.col("a").replace_strict(old=[1, 2, 3]))
+
+
+@pytest.mark.skipif(
+    POLARS_VERSION < (1, 0), reason="replace_strict only available after 1.0"
+)
+def test_replace_strict_series_invalid(constructor_eager: ConstructorEager) -> None:
+    df = nw.from_native(constructor_eager({"a": [1, 2, 3]}))
+    with pytest.raises(
+        TypeError,
+        match="`new` argument is required if `old` argument is not a Mapping type",
+    ):
+        df["a"].replace_strict([1, 2, 3])
diff --git a/tests/expr_and_series/unary_test.py b/tests/expr_and_series/unary_test.py
index c165be8b..9a0c4caa 100644
--- a/tests/expr_and_series/unary_test.py
+++ b/tests/expr_and_series/unary_test.py
@@ -10,6 +10,7 @@ def test_unary(constructor: Constructor) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
     result = nw.from_native(constructor(data)).select(
         a_mean=nw.col("a").mean(),
+        a_median=nw.col("a").median(),
         a_sum=nw.col("a").sum(),
         b_nunique=nw.col("b").n_unique(),
         z_min=nw.col("z").min(),
@@ -17,6 +18,7 @@ def test_unary(constructor: Constructor) -> None:
     )
     expected = {
         "a_mean": [2],
+        "a_median": [2],
         "a_sum": [6],
         "b_nunique": [2],
         "z_min": [7],
@@ -30,6 +32,7 @@ def test_unary_series(constructor_eager: ConstructorEager) -> None:
     df = nw.from_native(constructor_eager(data), eager_only=True)
     result = {
         "a_mean": [df["a"].mean()],
+        "a_median": [df["a"].median()],
         "a_sum": [df["a"].sum()],
         "b_nunique": [df["b"].n_unique()],
         "z_min": [df["z"].min()],
@@ -37,6 +40,7 @@ def test_unary_series(constructor_eager: ConstructorEager) -> None:
     }
     expected = {
         "a_mean": [2],
+        "a_median": [2],
         "a_sum": [6],
         "b_nunique": [2],
         "z_min": [7],
diff --git a/tests/group_by_test.py b/tests/group_by_test.py
index 09ee213e..5fb14d3c 100644
--- a/tests/group_by_test.py
+++ b/tests/group_by_test.py
@@ -104,6 +104,18 @@ def test_group_by_len(constructor: Constructor) -> None:
     assert_equal_data(result, expected)
 
 
+def test_group_by_median(constructor: Constructor) -> None:
+    data = {"a": [1, 1, 1, 2, 2, 2], "b": [5, 4, 6, 7, 3, 2]}
+    result = (
+        nw.from_native(constructor(data))
+        .group_by("a")
+        .agg(nw.col("b").median())
+        .sort("a")
+    )
+    expected = {"a": [1, 2], "b": [5, 3]}
+    assert_equal_data(result, expected)
+
+
 def test_group_by_n_unique(constructor: Constructor) -> None:
     result = (
         nw.from_native(constructor(data))
diff --git a/tests/selectors_test.py b/tests/selectors_test.py
index 93f5cbd7..01ee1e75 100644
--- a/tests/selectors_test.py
+++ b/tests/selectors_test.py
@@ -44,13 +44,7 @@ def test_boolean(constructor: Constructor) -> None:
     assert_equal_data(result, expected)
 
 
-def test_string(
-    constructor: Constructor,
-    request: pytest.FixtureRequest,
-) -> None:
-    if "dask" in str(constructor) and PYARROW_VERSION < (12,):
-        # Dask doesn't infer `'b'` as String for old PyArrow versions
-        request.applymarker(pytest.mark.xfail)
+def test_string(constructor: Constructor) -> None:
     df = nw.from_native(constructor(data))
     result = df.select(string())
     expected = {"b": ["a", "b", "c"]}