Repository: narwhals-dev/narwhals

Issue:
[Bug]: pandas-like groupby should use `observed=True` flag
### Describe the bug

Grouping by categorical variables leads to inconsistency between pandas and polars.

Current default pandas behaviour leads to all the category combination to appear, while that's not the case in polars and arrow

### Steps or code to reproduce the bug


```py
import narwhals as nw
import pandas as pd
import polars as pl
import pyarrow as pa

data = {"g1": ["a", "a", "b", "b"], "g2": ["x", "y", "x", "z"], "x": [1,2,3,4]}

@nw.narwhalify
def func(df):
    return (df
        .with_columns(
            g1=nw.col("g1").cast(nw.Categorical()),
            g2=nw.col("g2").cast(nw.Categorical()),
        )
        .group_by(["g1", "g2"])
        .agg(nw.col("x").sum())
    )

func(pl.DataFrame(data))
shape: (4, 3)
┌─────┬─────┬─────┐
│ g1  ┆ g2  ┆ x   │
│ --- ┆ --- ┆ --- │
│ cat ┆ cat ┆ i64 │
╞═════╪═════╪═════╡
│ b   ┆ z   ┆ 4   │
│ b   ┆ x   ┆ 3   │
│ a   ┆ x   ┆ 1   │
│ a   ┆ y   ┆ 2   │
└─────┴─────┴─────┘

func(pa.table(data))
pyarrow.Table
g1: dictionary<values=string, indices=uint32, ordered=0>
g2: dictionary<values=string, indices=uint32, ordered=0>
x: int64
----
g1: [  -- dictionary:
["a","b"]  -- indices:
[0,0,1,1]]
g2: [  -- dictionary:
["x","y","z"]  -- indices:
[0,1,0,2]]
x: [[1,2,3,4]]

func(pd.DataFrame(data))
  g1 g2  x
0  a  x  1
1  a  y  2
2  a  z  0
3  b  x  3
4  b  y  0
5  b  z  4
```

### Expected results

Polars above

### Actual results

Pandas above

### Please run narwhals.show_version() and enter the output below.

```shell
System:
    python: 3.11.9 (main, Apr  6 2024, 17:59:24) [GCC 11.4.0]

Python dependencies:
     narwhals: 1.8.3
       pandas: 2.2.3
       polars: 1.8.2
         cudf: 
        modin: 0.32.0
      pyarrow: 17.0.0
        numpy: 1.26.4
```


### Bonus points

Used explicitly in plotly

Discussion:

Patch:
diff --git a/narwhals/_dask/group_by.py b/narwhals/_dask/group_by.py
index d5fbaaf9..d79c95d7 100644
--- a/narwhals/_dask/group_by.py
+++ b/narwhals/_dask/group_by.py
@@ -47,6 +47,7 @@ class DaskLazyGroupBy:
         self._grouped = self._df._native_frame.groupby(
             list(self._keys),
             dropna=False,
+            observed=True,
         )
 
     def agg(
diff --git a/narwhals/_pandas_like/group_by.py b/narwhals/_pandas_like/group_by.py
index a0ee03f7..55c038f9 100644
--- a/narwhals/_pandas_like/group_by.py
+++ b/narwhals/_pandas_like/group_by.py
@@ -40,6 +40,7 @@ class PandasLikeGroupBy:
                 list(self._keys),
                 sort=False,
                 as_index=True,
+                observed=True,
             )
         else:
             self._grouped = self._df._native_frame.groupby(
@@ -47,6 +48,7 @@ class PandasLikeGroupBy:
                 sort=False,
                 as_index=True,
                 dropna=False,
+                observed=True,
             )
 
     def agg(


Test patch:
diff --git a/tests/test_group_by.py b/tests/test_group_by.py
index 666322c7..90cb48c2 100644
--- a/tests/test_group_by.py
+++ b/tests/test_group_by.py
@@ -260,3 +260,27 @@ def test_no_agg(constructor: Constructor) -> None:
 
     expected = {"a": [1, 3], "b": [4, 6]}
     compare_dicts(result, expected)
+
+
+def test_group_by_categorical(
+    constructor: Constructor, request: pytest.FixtureRequest
+) -> None:
+    if "pyarrow_table" in str(constructor) and parse_version(pa.__version__) < (
+        15,
+        0,
+        0,
+    ):  # pragma: no cover
+        request.applymarker(pytest.mark.xfail)
+
+    data = {"g1": ["a", "a", "b", "b"], "g2": ["x", "y", "x", "z"], "x": [1, 2, 3, 4]}
+    df = nw.from_native(constructor(data))
+    result = (
+        df.with_columns(
+            g1=nw.col("g1").cast(nw.Categorical()),
+            g2=nw.col("g2").cast(nw.Categorical()),
+        )
+        .group_by(["g1", "g2"])
+        .agg(nw.col("x").sum())
+        .sort("x")
+    )
+    compare_dicts(result, data)