Repository: narwhals-dev/narwhals

Issue:
bug: `nw.mean_horizontal(nw.all())` raises unnecessarily
Follow-up to https://github.com/narwhals-dev/narwhals/pull/1075

Discussion:
Cheukting: mean_horizontal was implemented in Polars 0.20.8: https://github.com/pola-rs/polars/releases/tag/py-0.20.8
Cheukting: All checks have passed @MarcoGorelli ship it! (What cute animal do we have time?)

@FBruzzesi are you sure you want me to refactor them? Because in the for loop there is already a list comprehension, it will become a list comprehension within a list comprehension...  it will not be very readable (IMHO)... here is a preview example:
```
            series = [
                series.extend([_series.fill_null(0) for _series in _expr._call(df)]) for _expr in parsed_exprs
            ]
```

I can refactor with no problem if it is preferred. Just wanna double check.
FBruzzesi: Hey hey @Cheukting 

> All checks have passed @MarcoGorelli ship it! (What cute animal do we have time?)

Awesome! I will let Marco pick animal(s) 😂 

> are you sure you want me to refactor them? Because in the for loop there is already a list comprehension, it will become a list comprehension within a list comprehension... it will not be very readable (IMHO)

I did the same in #1090, e.g. generator like the following:
```py
series = (s for _expr in parsed_exprs for s in _expr._call(df))
```

Actually I think this PR should take #1090 into account and set 
```py
output_names=reduce_output_names(parsed_exprs),
```
in `mean_horizontal` as well, otherwise we can end up in a similar issue to what's addressed there

Patch:
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index 3e7f4ecc..7f0bcbec 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -215,13 +215,26 @@ class ArrowNamespace:
         )
 
     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._dtypes.Int64())) for e in arrow_exprs),
+        parsed_exprs = parse_into_exprs(*exprs, namespace=self)
+
+        def func(df: ArrowDataFrame) -> list[ArrowSeries]:
+            series = (s.fill_null(0) for _expr in parsed_exprs for s in _expr._call(df))
+            non_na = (
+                1 - s.is_null().cast(self._dtypes.Int64())
+                for _expr in parsed_exprs
+                for s in _expr._call(df)
+            )
+            return [
+                reduce(lambda x, y: x + y, series) / reduce(lambda x, y: x + y, non_na)
+            ]
+
+        return self._create_expr_from_callable(
+            func=func,
+            depth=max(x._depth for x in parsed_exprs) + 1,
+            function_name="mean_horizontal",
+            root_names=combine_root_names(parsed_exprs),
+            output_names=reduce_output_names(parsed_exprs),
         )
-        return total / n_non_zero
 
     def concat(
         self,
diff --git a/narwhals/_dask/namespace.py b/narwhals/_dask/namespace.py
index 82fc2949..580689c1 100644
--- a/narwhals/_dask/namespace.py
+++ b/narwhals/_dask/namespace.py
@@ -12,6 +12,8 @@ from typing import cast
 from narwhals._dask.dataframe import DaskLazyFrame
 from narwhals._dask.expr import DaskExpr
 from narwhals._dask.selectors import DaskSelectorNamespace
+from narwhals._dask.utils import name_preserving_div
+from narwhals._dask.utils import name_preserving_sum
 from narwhals._dask.utils import narwhals_to_native_dtype
 from narwhals._dask.utils import validate_comparand
 from narwhals._expression_parsing import combine_root_names
@@ -231,10 +233,28 @@ class DaskNamespace:
         raise NotImplementedError
 
     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
+        parsed_exprs = parse_into_exprs(*exprs, namespace=self)
+
+        def func(df: DaskLazyFrame) -> list[dask_expr.Series]:
+            series = (s.fillna(0) for _expr in parsed_exprs for s in _expr._call(df))
+            non_na = (1 - s.isna() for _expr in parsed_exprs for s in _expr._call(df))
+            return [
+                name_preserving_div(
+                    reduce(name_preserving_sum, series),
+                    reduce(name_preserving_sum, non_na),
+                )
+            ]
+
+        return DaskExpr(
+            call=func,
+            depth=max(x._depth for x in parsed_exprs) + 1,
+            function_name="mean_horizontal",
+            root_names=combine_root_names(parsed_exprs),
+            output_names=reduce_output_names(parsed_exprs),
+            returns_scalar=False,
+            backend_version=self._backend_version,
+            dtypes=self._dtypes,
+        )
 
     def _create_expr_from_series(self, _: Any) -> NoReturn:
         msg = "`_create_expr_from_series` for DaskNamespace exists only for compatibility"
diff --git a/narwhals/_dask/utils.py b/narwhals/_dask/utils.py
index f7636bd5..2ba7cdcb 100644
--- a/narwhals/_dask/utils.py
+++ b/narwhals/_dask/utils.py
@@ -133,3 +133,11 @@ def narwhals_to_native_dtype(dtype: DType | type[DType], dtypes: DTypes) -> Any:
 
     msg = f"Unknown dtype: {dtype}"  # pragma: no cover
     raise AssertionError(msg)
+
+
+def name_preserving_sum(s1: dask_expr.Series, s2: dask_expr.Series) -> dask_expr.Series:
+    return (s1 + s2).rename(s1.name)
+
+
+def name_preserving_div(s1: dask_expr.Series, s2: dask_expr.Series) -> dask_expr.Series:
+    return (s1 / s2).rename(s1.name)
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index 6aacf285..35fd878b 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -261,12 +261,22 @@ class PandasLikeNamespace:
         )
 
     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)
+        parsed_exprs = parse_into_exprs(*exprs, namespace=self)
+
+        def func(df: PandasLikeDataFrame) -> list[PandasLikeSeries]:
+            series = (s.fill_null(0) for _expr in parsed_exprs for s in _expr._call(df))
+            non_na = (1 - s.is_null() for _expr in parsed_exprs for s in _expr._call(df))
+            return [
+                reduce(lambda x, y: x + y, series) / reduce(lambda x, y: x + y, non_na)
+            ]
+
+        return self._create_expr_from_callable(
+            func=func,
+            depth=max(x._depth for x in parsed_exprs) + 1,
+            function_name="mean_horizontal",
+            root_names=combine_root_names(parsed_exprs),
+            output_names=reduce_output_names(parsed_exprs),
         )
-        return total / n_non_zero
 
     def concat(
         self,
diff --git a/narwhals/_polars/namespace.py b/narwhals/_polars/namespace.py
index 21facd81..c43acbb2 100644
--- a/narwhals/_polars/namespace.py
+++ b/narwhals/_polars/namespace.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-from functools import reduce
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Iterable
@@ -107,16 +106,14 @@ class PolarsNamespace:
         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, dtypes=self._dtypes
+                pl.sum_horizontal(e._native_expr for e in polars_exprs)
+                / pl.sum_horizontal(1 - e.is_null()._native_expr for e in polars_exprs),
+                dtypes=self._dtypes,
             )
 
         return PolarsExpr(
-            pl.mean_horizontal([e._native_expr for e in polars_exprs]),
+            pl.mean_horizontal(e._native_expr for e in polars_exprs),
             dtypes=self._dtypes,
         )
 


Test patch:
diff --git a/tests/expr_and_series/mean_horizontal_test.py b/tests/expr_and_series/mean_horizontal_test.py
index f4ad35b9..ce9ac8fe 100644
--- a/tests/expr_and_series/mean_horizontal_test.py
+++ b/tests/expr_and_series/mean_horizontal_test.py
@@ -14,3 +14,18 @@ def test_meanh(constructor: Constructor, col_expr: Any) -> None:
     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)
+
+
+def test_meanh_all(constructor: Constructor) -> None:
+    data = {"a": [2, 4, 6], "b": [10, 20, 30]}
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.mean_horizontal(nw.all()))
+    expected = {
+        "a": [6, 12, 18],
+    }
+    compare_dicts(result, expected)
+    result = df.select(c=nw.mean_horizontal(nw.all()))
+    expected = {
+        "c": [6, 12, 18],
+    }
+    compare_dicts(result, expected)