Repository: narwhals-dev/narwhals

Issue:
[Enh]: support mean_horizontal
### We would like to learn about your use case. For example, if this feature is needed  to adopt Narwhals in an open source project, could you please enter the link to it below?


_No response_

### Please describe the purpose of the new feature or describe the problem to solve.

It would be great to add support for [`mean_horizontal`](https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.mean_horizontal.html) from the polars API. Note that it is not the same as `sum_horizontal(cols) / N`, since it excludes null columns from mean.

### Suggest a solution if possible.

_No response_

### If you have tried alternatives, please describe them below.

_No response_

### Additional information that may help us understand your needs.

_No response_

Discussion:
FBruzzesi: Ok, so this ended up being a bit more than initially expected on the polars side 😂

Patch:
diff --git a/docs/api-reference/narwhals.md b/docs/api-reference/narwhals.md
index 275a865c..2774a093 100644
--- a/docs/api-reference/narwhals.md
+++ b/docs/api-reference/narwhals.md
@@ -23,6 +23,7 @@ Here are the top-level functions available in Narwhals.
         - maybe_convert_dtypes
         - maybe_set_index
         - mean
+        - mean_horizontal
         - min
         - narwhalify
         - new_series
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index 59adbb41..a23bd7b7 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -30,6 +30,7 @@ from narwhals.expr import len
 from narwhals.expr import lit
 from narwhals.expr import max
 from narwhals.expr import mean
+from narwhals.expr import mean_horizontal
 from narwhals.expr import min
 from narwhals.expr import sum
 from narwhals.expr import sum_horizontal
@@ -73,6 +74,7 @@ __all__ = [
     "min",
     "max",
     "mean",
+    "mean_horizontal",
     "sum",
     "sum_horizontal",
     "DataFrame",
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index b5a14f5a..65a155e0 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -137,6 +137,32 @@ class ArrowDataFrame:
             )
 
         elif isinstance(item, tuple) and len(item) == 2:
+            if isinstance(item[1], slice):
+                columns = self.columns
+                if isinstance(item[1].start, str) or isinstance(item[1].stop, str):
+                    start = (
+                        columns.index(item[1].start)
+                        if item[1].start is not None
+                        else None
+                    )
+                    stop = (
+                        columns.index(item[1].stop) + 1
+                        if item[1].stop is not None
+                        else None
+                    )
+                    step = item[1].step
+                    return self._from_native_frame(
+                        self._native_frame.take(item[0]).select(columns[start:stop:step])
+                    )
+                if isinstance(item[1].start, int) or isinstance(item[1].stop, int):
+                    return self._from_native_frame(
+                        self._native_frame.take(item[0]).select(
+                            columns[item[1].start : item[1].stop : item[1].step]
+                        )
+                    )
+                msg = f"Expected slice of integers or strings, got: {type(item[1])}"  # pragma: no cover
+                raise TypeError(msg)  # pragma: no cover
+
             from narwhals._arrow.series import ArrowSeries
 
             # PyArrow columns are always strings
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index bb90b179..a17a6a62 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -175,6 +175,15 @@ class ArrowNamespace:
     def sum_horizontal(self, *exprs: IntoArrowExpr) -> ArrowExpr:
         return reduce(lambda x, y: x + y, parse_into_exprs(*exprs, namespace=self))
 
+    def mean_horizontal(self, *exprs: IntoArrowExpr) -> IntoArrowExpr:
+        arrow_exprs = parse_into_exprs(*exprs, namespace=self)
+        total = reduce(lambda x, y: x + y, (e.fill_null(0.0) for e in arrow_exprs))
+        n_non_zero = reduce(
+            lambda x, y: x + y,
+            ((1 - e.is_null().cast(self.Int64())) for e in arrow_exprs),
+        )
+        return total / n_non_zero
+
     def concat(
         self,
         items: Iterable[ArrowDataFrame],
diff --git a/narwhals/_dask/namespace.py b/narwhals/_dask/namespace.py
index 2baf1cf3..868cc20d 100644
--- a/narwhals/_dask/namespace.py
+++ b/narwhals/_dask/namespace.py
@@ -135,6 +135,12 @@ class DaskNamespace:
     def sum_horizontal(self, *exprs: IntoDaskExpr) -> DaskExpr:
         return reduce(lambda x, y: x + y, parse_into_exprs(*exprs, namespace=self))
 
+    def mean_horizontal(self, *exprs: IntoDaskExpr) -> IntoDaskExpr:
+        dask_exprs = parse_into_exprs(*exprs, namespace=self)
+        total = reduce(lambda x, y: x + y, (e.fill_null(0.0) for e in dask_exprs))
+        n_non_zero = reduce(lambda x, y: x + y, ((1 - e.is_null()) for e in dask_exprs))
+        return total / n_non_zero
+
     def _create_expr_from_series(self, _: Any) -> NoReturn:
         msg = "`_create_expr_from_series` for DaskNamespace exists only for compatibility"
         raise NotImplementedError(msg)
diff --git a/narwhals/_expression_parsing.py b/narwhals/_expression_parsing.py
index 161c3aca..06383821 100644
--- a/narwhals/_expression_parsing.py
+++ b/narwhals/_expression_parsing.py
@@ -30,17 +30,27 @@ if TYPE_CHECKING:
     from narwhals._pandas_like.namespace import PandasLikeNamespace
     from narwhals._pandas_like.series import PandasLikeSeries
     from narwhals._pandas_like.typing import IntoPandasLikeExpr
+    from narwhals._polars.expr import PolarsExpr
+    from narwhals._polars.namespace import PolarsNamespace
+    from narwhals._polars.series import PolarsSeries
+    from narwhals._polars.typing import IntoPolarsExpr
 
-    CompliantNamespace = Union[PandasLikeNamespace, ArrowNamespace, DaskNamespace]
-    CompliantExpr = Union[PandasLikeExpr, ArrowExpr, DaskExpr]
-    IntoCompliantExpr = Union[IntoPandasLikeExpr, IntoArrowExpr, IntoDaskExpr]
+    CompliantNamespace = Union[
+        PandasLikeNamespace, ArrowNamespace, DaskNamespace, PolarsNamespace
+    ]
+    CompliantExpr = Union[PandasLikeExpr, ArrowExpr, DaskExpr, PolarsExpr]
+    IntoCompliantExpr = Union[
+        IntoPandasLikeExpr, IntoArrowExpr, IntoDaskExpr, IntoPolarsExpr
+    ]
     IntoCompliantExprT = TypeVar("IntoCompliantExprT", bound=IntoCompliantExpr)
     CompliantExprT = TypeVar("CompliantExprT", bound=CompliantExpr)
-    CompliantSeries = Union[PandasLikeSeries, ArrowSeries]
+    CompliantSeries = Union[PandasLikeSeries, ArrowSeries, PolarsSeries]
     ListOfCompliantSeries = Union[
-        list[PandasLikeSeries], list[ArrowSeries], list[DaskExpr]
+        list[PandasLikeSeries], list[ArrowSeries], list[DaskExpr], list[PolarsSeries]
+    ]
+    ListOfCompliantExpr = Union[
+        list[PandasLikeExpr], list[ArrowExpr], list[DaskExpr], list[PolarsExpr]
     ]
-    ListOfCompliantExpr = Union[list[PandasLikeExpr], list[ArrowExpr], list[DaskExpr]]
     CompliantDataFrame = Union[PandasLikeDataFrame, ArrowDataFrame, DaskLazyFrame]
 
     T = TypeVar("T")
@@ -133,6 +143,14 @@ def parse_into_exprs(
 ) -> list[DaskExpr]: ...
 
 
+@overload
+def parse_into_exprs(
+    *exprs: IntoPolarsExpr,
+    namespace: PolarsNamespace,
+    **named_exprs: IntoPolarsExpr,
+) -> list[PolarsExpr]: ...
+
+
 def parse_into_exprs(
     *exprs: IntoCompliantExpr,
     namespace: CompliantNamespace,
@@ -140,7 +158,7 @@ def parse_into_exprs(
 ) -> ListOfCompliantExpr:
     """Parse each input as an expression (if it's not already one). See `parse_into_expr` for
     more details."""
-    return [  # type: ignore[return-value]
+    return [
         parse_into_expr(into_expr, namespace=namespace) for into_expr in flatten(exprs)
     ] + [
         parse_into_expr(expr, namespace=namespace).alias(name)
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 817d28cd..c6a51be2 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -146,6 +146,30 @@ class PandasLikeDataFrame:
             )
             raise TypeError(msg)  # pragma: no cover
 
+        elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[1], slice):
+            columns = self._native_frame.columns
+            if isinstance(item[1].start, str) or isinstance(item[1].stop, str):
+                start = (
+                    columns.get_loc(item[1].start) if item[1].start is not None else None
+                )
+                stop = (
+                    columns.get_loc(item[1].stop) + 1
+                    if item[1].stop is not None
+                    else None
+                )
+                step = item[1].step
+                return self._from_native_frame(
+                    self._native_frame.iloc[item[0], slice(start, stop, step)]
+                )
+            if isinstance(item[1].start, int) or isinstance(item[1].stop, int):
+                return self._from_native_frame(
+                    self._native_frame.iloc[
+                        item[0], slice(item[1].start, item[1].stop, item[1].step)
+                    ]
+                )
+            msg = f"Expected slice of integers or strings, got: {type(item[1])}"  # pragma: no cover
+            raise TypeError(msg)  # pragma: no cover
+
         elif isinstance(item, tuple) and len(item) == 2:
             from narwhals._pandas_like.series import PandasLikeSeries
 
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index 1d07fde2..3c378e3c 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -210,6 +210,14 @@ class PandasLikeNamespace:
     def any_horizontal(self, *exprs: IntoPandasLikeExpr) -> PandasLikeExpr:
         return reduce(lambda x, y: x | y, parse_into_exprs(*exprs, namespace=self))
 
+    def mean_horizontal(self, *exprs: IntoPandasLikeExpr) -> PandasLikeExpr:
+        pandas_like_exprs = parse_into_exprs(*exprs, namespace=self)
+        total = reduce(lambda x, y: x + y, (e.fill_null(0.0) for e in pandas_like_exprs))
+        n_non_zero = reduce(
+            lambda x, y: x + y, ((1 - e.is_null()) for e in pandas_like_exprs)
+        )
+        return total / n_non_zero
+
     def concat(
         self,
         items: Iterable[PandasLikeDataFrame],
diff --git a/narwhals/_polars/dataframe.py b/narwhals/_polars/dataframe.py
index c2b771f9..c5164147 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -84,6 +84,31 @@ class PolarsDataFrame:
         return self._native_frame.shape  # type: ignore[no-any-return]
 
     def __getitem__(self, item: Any) -> Any:
+        if isinstance(item, tuple) and len(item) == 2 and isinstance(item[1], slice):
+            # TODO(marco): we can delete this branch after Polars==0.20.30 becomes the minimum
+            # Polars version we support
+            columns = self.columns
+            if isinstance(item[1].start, str) or isinstance(item[1].stop, str):
+                start = (
+                    columns.index(item[1].start) if item[1].start is not None else None
+                )
+                stop = (
+                    columns.index(item[1].stop) + 1 if item[1].stop is not None else None
+                )
+                step = item[1].step
+                return self._from_native_frame(
+                    self._native_frame.select(columns[start:stop:step]).__getitem__(
+                        item[0]
+                    )
+                )
+            if isinstance(item[1].start, int) or isinstance(item[1].stop, int):
+                return self._from_native_frame(
+                    self._native_frame.select(
+                        columns[item[1].start : item[1].stop : item[1].step]
+                    ).__getitem__(item[0])
+                )
+            msg = f"Expected slice of integers or strings, got: {type(item[1])}"  # pragma: no cover
+            raise TypeError(msg)  # pragma: no cover
         pl = get_polars()
         result = self._native_frame.__getitem__(item)
         if isinstance(result, pl.Series):
diff --git a/narwhals/_polars/namespace.py b/narwhals/_polars/namespace.py
index ef6b0073..48ee8ebc 100644
--- a/narwhals/_polars/namespace.py
+++ b/narwhals/_polars/namespace.py
@@ -1,11 +1,13 @@
 from __future__ import annotations
 
+from functools import reduce
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Iterable
 from typing import Sequence
 
 from narwhals import dtypes
+from narwhals._expression_parsing import parse_into_exprs
 from narwhals._polars.utils import extract_args_kwargs
 from narwhals._polars.utils import narwhals_to_native_dtype
 from narwhals.dependencies import get_polars
@@ -15,6 +17,7 @@ if TYPE_CHECKING:
     from narwhals._polars.dataframe import PolarsDataFrame
     from narwhals._polars.dataframe import PolarsLazyFrame
     from narwhals._polars.expr import PolarsExpr
+    from narwhals._polars.typing import IntoPolarsExpr
 
 
 class PolarsNamespace:
@@ -85,11 +88,28 @@ class PolarsNamespace:
             return PolarsExpr(pl.lit(value, dtype=narwhals_to_native_dtype(dtype)))
         return PolarsExpr(pl.lit(value))
 
-    def mean(self, *column_names: str) -> Any:
+    def mean(self, *column_names: str) -> PolarsExpr:
+        from narwhals._polars.expr import PolarsExpr
+
         pl = get_polars()
         if self._backend_version < (0, 20, 4):  # pragma: no cover
-            return pl.mean([*column_names])
-        return pl.mean(*column_names)
+            return PolarsExpr(pl.mean([*column_names]))
+        return PolarsExpr(pl.mean(*column_names))
+
+    def mean_horizontal(self, *exprs: IntoPolarsExpr) -> PolarsExpr:
+        from narwhals._polars.expr import PolarsExpr
+
+        pl = get_polars()
+        polars_exprs = parse_into_exprs(*exprs, namespace=self)
+
+        if self._backend_version < (0, 20, 8):  # pragma: no cover
+            total = reduce(lambda x, y: x + y, (e.fill_null(0.0) for e in polars_exprs))
+            n_non_zero = reduce(
+                lambda x, y: x + y, ((1 - e.is_null()) for e in polars_exprs)
+            )
+            return PolarsExpr(total._native_expr / n_non_zero._native_expr)
+
+        return PolarsExpr(pl.mean_horizontal([e._native_expr for e in polars_exprs]))
 
     @property
     def selectors(self) -> PolarsSelectors:
diff --git a/narwhals/_polars/typing.py b/narwhals/_polars/typing.py
new file mode 100644
index 00000000..3aed2899
--- /dev/null
+++ b/narwhals/_polars/typing.py
@@ -0,0 +1,17 @@
+from __future__ import annotations  # pragma: no cover
+
+from typing import TYPE_CHECKING  # pragma: no cover
+from typing import Union  # pragma: no cover
+
+if TYPE_CHECKING:
+    import sys
+
+    if sys.version_info >= (3, 10):
+        from typing import TypeAlias
+    else:
+        from typing_extensions import TypeAlias
+
+    from narwhals._polars.expr import PolarsExpr
+    from narwhals._polars.series import PolarsSeries
+
+    IntoPolarsExpr: TypeAlias = Union[PolarsExpr, str, PolarsSeries]
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 4fa7e913..16120374 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -512,6 +512,8 @@ class DataFrame(BaseFrame[FrameT]):
             level=self._level,
         )
 
+    @overload
+    def __getitem__(self, item: tuple[Sequence[int], slice]) -> Self: ...
     @overload
     def __getitem__(self, item: tuple[Sequence[int], Sequence[int]]) -> Self: ...
     @overload
@@ -536,20 +538,33 @@ class DataFrame(BaseFrame[FrameT]):
         | slice
         | Sequence[int]
         | tuple[Sequence[int], str | int]
-        | tuple[Sequence[int], Sequence[int] | Sequence[str]],
+        | tuple[Sequence[int], Sequence[int] | Sequence[str] | slice],
     ) -> Series | Self:
         """
         Extract column or slice of DataFrame.
 
         Arguments:
-            item: how to slice dataframe:
+            item: how to slice dataframe. What happens depends on what is passed. It's easiest
+                to explain by example. Suppose we have a Dataframe `df`:
+
+                    - `df['a']` extracts column `'a'` and returns a `Series`.
+                    - `df[0:2]` extracts the first two rows and returns a `DataFrame`.
+                    - `df[0:2, 'a']` extracts the first two rows from column `'a'` and returns
+                        a `Series`.
+                    - `df[0:2, 0]` extracts the first two rows from the first column and returns
+                        a `Series`.
+                    - `df[[0, 1], [0, 1, 2]]` extracts the first two rows and the first three columns
+                        and returns a `DataFrame`
+                    - `df[0: 2, ['a', 'c']]` extracts the first two rows and columns `'a'` and `'c'` and
+                        returns a `DataFrame`
+                    - `df[:, 0: 2]` extracts all rows from the first two columns and returns a `DataFrame`
+                    - `df[:, 'a': 'c']` extracts all rows and all columns positioned between `'a'` and `'c'`
+                        _inclusive_ and returns a `DataFrame`. For example, if the columns are
+                        `'a', 'b', 'c', 'd'`, then that would extract columns `'a'`, `'b'`, and `'c'`.
 
-                - str: extract column
-                - slice or Sequence of integers: slice rows from dataframe.
-                - tuple of Sequence of integers and str or int: slice rows and extract column at the same time.
-                - tuple of Sequence of integers and Sequence of integers: slice rows and extract columns at the same time.
         Notes:
-            Integers are always interpreted as positions, and strings always as column names.
+            - Integers are always interpreted as positions
+            - Strings are always interpreted as column names.
 
             In contrast with Polars, pandas allows non-string column names.
             If you don't know whether the column name you're trying to extract
@@ -598,7 +613,7 @@ class DataFrame(BaseFrame[FrameT]):
         if (
             isinstance(item, tuple)
             and len(item) == 2
-            and isinstance(item[1], (list, tuple))
+            and isinstance(item[1], (list, tuple, slice))
         ):
             return self._from_compliant_dataframe(self._compliant_frame[item])
         if isinstance(item, str) or (isinstance(item, tuple) and len(item) == 2):
diff --git a/narwhals/expr.py b/narwhals/expr.py
index a8097a2a..660e4b76 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -4118,6 +4118,59 @@ def any_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     )
 
 
+def mean_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
+    """
+    Compute the mean of all values horizontally across columns.
+
+    Arguments:
+        exprs: Name(s) of the columns to use in the aggregation function. Accepts
+            expression input.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals as nw
+        >>> data = {
+        ...     "a": [1, 8, 3],
+        ...     "b": [4, 5, None],
+        ...     "c": ["x", "y", "z"],
+        ... }
+        >>> df_pl = pl.DataFrame(data)
+        >>> df_pd = pd.DataFrame(data)
+
+        We define a dataframe-agnostic function that computes the horizontal mean of "a"
+        and "b" columns:
+
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.select(nw.mean_horizontal("a", "b"))
+
+        We can then pass either pandas or polars to `func`:
+
+        >>> func(df_pd)
+             a
+        0  2.5
+        1  6.5
+        2  3.0
+        >>> func(df_pl)
+        shape: (3, 1)
+        ┌─────┐
+        │ a   │
+        │ --- │
+        │ f64 │
+        ╞═════╡
+        │ 2.5 │
+        │ 6.5 │
+        │ 3.0 │
+        └─────┘
+    """
+    return Expr(
+        lambda plx: plx.mean_horizontal(
+            *[extract_compliant(plx, v) for v in flatten(exprs)]
+        )
+    )
+
+
 __all__ = [
     "Expr",
 ]
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1.py
index 65d42080..961fff16 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1.py
@@ -68,6 +68,8 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
     `narwhals.from_native`.
     """
 
+    @overload
+    def __getitem__(self, item: tuple[Sequence[int], slice]) -> Self: ...
     @overload
     def __getitem__(self, item: tuple[Sequence[int], Sequence[int]]) -> Self: ...
 
@@ -1277,6 +1279,55 @@ def any_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     return _stableify(nw.any_horizontal(*exprs))
 
 
+def mean_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
+    """
+    Compute the mean of all values horizontally across columns.
+
+    Arguments:
+        exprs: Name(s) of the columns to use in the aggregation function. Accepts
+            expression input.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals.stable.v1 as nw
+        >>> data = {
+        ...     "a": [1, 8, 3],
+        ...     "b": [4, 5, None],
+        ...     "c": ["x", "y", "z"],
+        ... }
+        >>> df_pl = pl.DataFrame(data)
+        >>> df_pd = pd.DataFrame(data)
+
+        We define a dataframe-agnostic function that computes the horizontal mean of "a"
+        and "b" columns:
+
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.select(nw.mean_horizontal("a", "b"))
+
+        We can then pass either pandas or polars to `func`:
+
+        >>> func(df_pd)
+             a
+        0  2.5
+        1  6.5
+        2  3.0
+        >>> func(df_pl)
+        shape: (3, 1)
+        ┌─────┐
+        │ a   │
+        │ --- │
+        │ f64 │
+        ╞═════╡
+        │ 2.5 │
+        │ 6.5 │
+        │ 3.0 │
+        └─────┘
+    """
+    return _stableify(nw.mean_horizontal(*exprs))
+
+
 def is_ordered_categorical(series: Series) -> bool:
     """
     Return whether indices of categories are semantically meaningful.
@@ -1558,6 +1609,7 @@ __all__ = [
     "min",
     "max",
     "mean",
+    "mean_horizontal",
     "sum",
     "sum_horizontal",
     "DataFrame",


Test patch:
diff --git a/tests/expr_and_series/dt/to_string_test.py b/tests/expr_and_series/dt/to_string_test.py
index 8cd7ae4c..7cbbf72f 100644
--- a/tests/expr_and_series/dt/to_string_test.py
+++ b/tests/expr_and_series/dt/to_string_test.py
@@ -105,16 +105,16 @@ def test_dt_to_string_iso_local_datetime_series(
     result = (
         nw.from_native(df, eager_only=True)["a"]
         .dt.to_string("%Y-%m-%dT%H:%M:%S.%f")
-        .to_list()[0]
+        .item(0)
     )
-    assert _clean_string(result) == _clean_string(expected)
+    assert _clean_string(str(result)) == _clean_string(expected)
 
     result = (
         nw.from_native(df, eager_only=True)["a"]
         .dt.to_string("%Y-%m-%dT%H:%M:%S%.f")
-        .to_list()[0]
+        .item(0)
     )
-    assert _clean_string(result) == _clean_string(expected)
+    assert _clean_string(str(result)) == _clean_string(expected)
 
 
 @pytest.mark.parametrize(
@@ -156,10 +156,8 @@ def test_dt_to_string_iso_local_date_series(
     constructor_eager: Any, data: datetime, expected: str
 ) -> None:
     df = constructor_eager({"a": [data]})
-    result = (
-        nw.from_native(df, eager_only=True)["a"].dt.to_string("%Y-%m-%d").to_list()[0]
-    )
-    assert result == expected
+    result = nw.from_native(df, eager_only=True)["a"].dt.to_string("%Y-%m-%d").item(0)
+    assert str(result) == expected
 
 
 @pytest.mark.parametrize(
diff --git a/tests/expr_and_series/mean_horizontal_test.py b/tests/expr_and_series/mean_horizontal_test.py
new file mode 100644
index 00000000..d42d5e32
--- /dev/null
+++ b/tests/expr_and_series/mean_horizontal_test.py
@@ -0,0 +1,15 @@
+from typing import Any
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+@pytest.mark.parametrize("col_expr", [nw.col("a"), "a"])
+def test_meanh(constructor: Any, col_expr: Any) -> None:
+    data = {"a": [1, 3, None, None], "b": [4, None, 6, None]}
+    df = nw.from_native(constructor(data))
+    result = df.select(horizontal_mean=nw.mean_horizontal(col_expr, nw.col("b")))
+    expected = {"horizontal_mean": [2.5, 3.0, 6.0, float("nan")]}
+    compare_dicts(result, expected)
diff --git a/tests/frame/slice_test.py b/tests/frame/slice_test.py
index 4fa5d86b..222717d1 100644
--- a/tests/frame/slice_test.py
+++ b/tests/frame/slice_test.py
@@ -5,10 +5,8 @@ import pandas as pd
 import polars as pl
 import pyarrow as pa
 import pytest
-from pandas.testing import assert_series_equal
 
 import narwhals.stable.v1 as nw
-from narwhals.utils import parse_version
 from tests.utils import compare_dicts
 
 data = {
@@ -83,21 +81,14 @@ def test_gather_pandas_index() -> None:
 def test_gather_rows_cols(constructor_eager: Any) -> None:
     native_df = constructor_eager(data)
     df = nw.from_native(native_df, eager_only=True)
-    is_pandas_wo_pyarrow = parse_version(pd.__version__) < parse_version("1.0.0")
-    if isinstance(native_df, pa.Table) or is_pandas_wo_pyarrow:
-        # PyArrowSeries do not have `to_pandas`
-        result = df[[0, 3, 1], 1].to_numpy()
-        expected = np.array([11, 14, 12])
-        assert np.array_equal(result, expected)
-        result = df[np.array([0, 3, 1]), "b"].to_numpy()
-        assert np.array_equal(result, expected)
-    else:
-        result = df[[0, 3, 1], 1].to_pandas()
-        expected_index = range(3) if isinstance(native_df, pl.DataFrame) else [0, 3, 1]
-        expected = pd.Series([11, 14, 12], name="b", index=expected_index)
-        assert_series_equal(result, expected, check_dtype=False)
-        result = df[np.array([0, 3, 1]), "b"].to_pandas()
-        assert_series_equal(result, expected, check_dtype=False)
+
+    expected = {"b": [11, 14, 12]}
+
+    result = {"b": df[[0, 3, 1], 1]}
+    compare_dicts(result, expected)
+
+    result = {"b": df[np.array([0, 3, 1]), "b"]}
+    compare_dicts(result, expected)
 
 
 def test_slice_both_tuples_of_ints(constructor_eager: Any) -> None:
@@ -116,6 +107,35 @@ def test_slice_int_rows_str_columns(constructor_eager: Any) -> None:
     compare_dicts(result, expected)
 
 
+def test_slice_slice_columns(constructor_eager: Any) -> None:
+    data = {"a": [1, 2, 3], "b": [4, 5, 6], "c": [7, 8, 9], "d": [1, 4, 2]}
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result = df[[0, 1], "b":"c"]  # type: ignore[misc]
+    expected = {"b": [4, 5], "c": [7, 8]}
+    compare_dicts(result, expected)
+    result = df[[0, 1], :"c"]  # type: ignore[misc]
+    expected = {"a": [1, 2], "b": [4, 5], "c": [7, 8]}
+    compare_dicts(result, expected)
+    result = df[[0, 1], "a":"d":2]  # type: ignore[misc]
+    expected = {"a": [1, 2], "c": [7, 8]}
+    compare_dicts(result, expected)
+    result = df[[0, 1], "b":]  # type: ignore[misc]
+    expected = {"b": [4, 5], "c": [7, 8], "d": [1, 4]}
+    compare_dicts(result, expected)
+    result = df[[0, 1], 1:3]
+    expected = {"b": [4, 5], "c": [7, 8]}
+    compare_dicts(result, expected)
+    result = df[[0, 1], :3]
+    expected = {"a": [1, 2], "b": [4, 5], "c": [7, 8]}
+    compare_dicts(result, expected)
+    result = df[[0, 1], 0:4:2]
+    expected = {"a": [1, 2], "c": [7, 8]}
+    compare_dicts(result, expected)
+    result = df[[0, 1], 1:]
+    expected = {"b": [4, 5], "c": [7, 8], "d": [1, 4]}
+    compare_dicts(result, expected)
+
+
 def test_slice_invalid(constructor_eager: Any) -> None:
     data = {"a": [1, 2], "b": [4, 5]}
     df = nw.from_native(constructor_eager(data), eager_only=True)
diff --git a/tests/series_only/to_list_test.py b/tests/series_only/to_list_test.py
index adb0ceb1..10e41591 100644
--- a/tests/series_only/to_list_test.py
+++ b/tests/series_only/to_list_test.py
@@ -12,4 +12,4 @@ def test_to_list(constructor_eager: Any, request: Any) -> None:
     if "cudf" in str(constructor_eager):  # pragma: no cover
         request.applymarker(pytest.mark.xfail)
     s = nw.from_native(constructor_eager({"a": data}), eager_only=True)["a"]
-    compare_dicts({"a": s}, {"a": [1, 2, 3]})
+    compare_dicts({"a": s.to_list()}, {"a": [1, 2, 3]})