Repository: narwhals-dev/narwhals
Pull request #1859 (2025-01-23 23:17:41)

feat: Series.hist
<!--
# 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

- Related issue #1561

## 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

Narwhals Expressions do not yet allow one to return a DataFrame (or a struct), so `.hist` is only implemented at the Series level to be compliant with the rest of the API.

The PyArrow implementation can likely be streamlined a bit more, so a review on that section would be appreciated.


MarcoGorelli: wow, amazing!

> .hist is only implemented at the Series level to be compliant with the rest of the API.

agree, good design decision here, I think it would be quite awkward to add Expr.hist because:
- struct dtype is not supported by default in pandas (and not at all in pandas pre 2.0. Maybe 1.5. But not before that)
- it's a length-changing expression which it doesn't make sense to aggregate (e.g. `nw.col('a').unique().len()` makes sense but `nw.col('a').hist().struct.field('value').sum()` doesn't seem useful..), so supporting this for pyspark / duckdb / ibis could be a real issue. Ibis does seem to have [bucket](https://ibis-project.org/reference/operations#ibis.expr.operations.histograms.Bucket) but there's no examples and I have no idea what it does

Hi @mscolnick - just wanted to check that Series.hist would still be useful to you?
mscolnick: awesome work @camriddell! yes, @MarcoGorelli this is definitely valuable for us (marimo) and would use it right away, thank you all for the efforts.
camriddell: Found a new breaking change that is bisected on Polars version 1.5 for `bin_count=...`. I'll manually calculate the bins for backwards compat.

```zsh
❯ python -c 'import polars as pl; print(pl.__version__); print(pl.Series([1,2,3]).hist(bin_count=3))'
1.6.0
shape: (3, 3)
┌────────────┬──────────────────────┬───────┐
│ breakpoint ┆ category             ┆ count │
│ ---        ┆ ---                  ┆ ---   │
│ f64        ┆ cat                  ┆ u32   │
╞════════════╪══════════════════════╪═══════╡
│ 1.666667   ┆ (0.998, 1.666667]    ┆ 1     │
│ 2.333333   ┆ (1.666667, 2.333333] ┆ 1     │
│ 3.0        ┆ (2.333333, 3.0]      ┆ 1     │
└────────────┴──────────────────────┴───────┘
```

```zsh
❯ python -c 'import polars as pl; print(pl.__version__); print(pl.Series([1,2,3]).hist(bin_count=3))'
1.5.0
shape: (4, 3)
┌────────────┬──────────────────────┬───────┐
│ breakpoint ┆ category             ┆ count │
│ ---        ┆ ---                  ┆ ---   │
│ f64        ┆ cat                  ┆ u32   │
╞════════════╪══════════════════════╪═══════╡
│ 0.0        ┆ (-inf, 0.0]          ┆ 0     │
│ 1.333333   ┆ (0.0, 1.333333]      ┆ 1     │
│ 2.666667   ┆ (1.333333, 2.666667] ┆ 1     │
│ inf        ┆ (2.666667, inf]      ┆ 1     │
└────────────┴──────────────────────┴───────┘
```
FBruzzesi: Amazing discoveries @camriddell , I am so sorry you need to take care of all these edge cases 🙈
MarcoGorelli: awesome, thanks both!

CI logs show
```
FAILED tests/series_only/hist_test.py::test_hist_count[pyarrow-False-True-params1] - pyarrow.lib.ArrowInvalid: Could not convert <pyarrow.DoubleScalar: -0.006> with type pyarrow.lib.DoubleScalar: did not recognize Python value type when inferring an Arrow data type
```
if it's too much bother to fix, we could just set a minimum pyarrow version for this feature, no big deal
MarcoGorelli: I took a closer look, and have a couple of more comments, sorry

For `.hist()` without arguments, it looks like pyarrow produces different results, is this expected?
```
In [25]: nw.from_native(pd.Series([1, 3, 8, 8, 2, 1, 3], name='a'), allow_series=True).hist()
Out[25]: 
┌──────────────────────────────────┐
|        Narwhals DataFrame        |
|----------------------------------|
|   breakpoint      category  count|
|0         1.7  (0.993, 1.7]      2|
|1         2.4    (1.7, 2.4]      1|
|2         3.1    (2.4, 3.1]      2|
|3         3.8    (3.1, 3.8]      0|
|4         4.5    (3.8, 4.5]      0|
|5         5.2    (4.5, 5.2]      0|
|6         5.9    (5.2, 5.9]      0|
|7         6.6    (5.9, 6.6]      0|
|8         7.3    (6.6, 7.3]      0|
|9         8.0    (7.3, 8.0]      2|
└──────────────────────────────────┘

In [26]: nw.from_native(pa.chunked_array([pl.Series(values=[1, 3, 8, 8, 2, 1, 3], name='a').to_arrow()]), allow_series=True).hist().to_polars()
    ...: 
Out[26]: 
shape: (10, 3)
┌────────────┬─────────────────────────────────┬───────┐
│ breakpoint ┆ category                        ┆ count │
│ ---        ┆ ---                             ┆ ---   │
│ f64        ┆ str                             ┆ i64   │
╞════════════╪═════════════════════════════════╪═══════╡
│ 0.7        ┆ (-0.007, 0.7]                   ┆ 2     │
│ 1.4        ┆ (0.7, 1.4]                      ┆ 1     │
│ 2.1        ┆ (1.4, 2.0999999999999996]       ┆ 2     │
│ 2.8        ┆ (2.0999999999999996, 2.8]       ┆ 0     │
│ 3.5        ┆ (2.8, 3.5]                      ┆ 0     │
│ 4.2        ┆ (3.5, 4.2]                      ┆ 0     │
│ 4.9        ┆ (4.199999999999999, 4.89999999… ┆ 0     │
│ 5.6        ┆ (4.8999999999999995, 5.6]       ┆ 0     │
│ 6.3        ┆ (5.6, 6.3]                      ┆ 0     │
│ 7.0        ┆ (6.3, 7.0]                      ┆ 2     │
└────────────┴─────────────────────────────────┴───────┘
```

Second, I'm not totally sure about the categories column being a categorical in Polars in the first place (https://github.com/pola-rs/polars/issues/18645). Do we need it all though? We could always start by just not including that argument and column, and can always add it back later if necessary
camriddell: > I took a closer look, and have a couple of more comments, sorry
> 
> For `.hist()` without arguments, it looks like pyarrow produces different results, is this expected?
> 
> ```
> In [25]: nw.from_native(pd.Series([1, 3, 8, 8, 2, 1, 3], name='a'), allow_series=True).hist()
> Out[25]: 
> ┌──────────────────────────────────┐
> |        Narwhals DataFrame        |
> |----------------------------------|
> |   breakpoint      category  count|
> |0         1.7  (0.993, 1.7]      2|
> |1         2.4    (1.7, 2.4]      1|
> |2         3.1    (2.4, 3.1]      2|
> |3         3.8    (3.1, 3.8]      0|
> |4         4.5    (3.8, 4.5]      0|
> |5         5.2    (4.5, 5.2]      0|
> |6         5.9    (5.2, 5.9]      0|
> |7         6.6    (5.9, 6.6]      0|
> |8         7.3    (6.6, 7.3]      0|
> |9         8.0    (7.3, 8.0]      2|
> └──────────────────────────────────┘
> 
> In [26]: nw.from_native(pa.chunked_array([pl.Series(values=[1, 3, 8, 8, 2, 1, 3], name='a').to_arrow()]), allow_series=True).hist().to_polars()
>     ...: 
> Out[26]: 
> shape: (10, 3)
> ┌────────────┬─────────────────────────────────┬───────┐
> │ breakpoint ┆ category                        ┆ count │
> │ ---        ┆ ---                             ┆ ---   │
> │ f64        ┆ str                             ┆ i64   │
> ╞════════════╪═════════════════════════════════╪═══════╡
> │ 0.7        ┆ (-0.007, 0.7]                   ┆ 2     │
> │ 1.4        ┆ (0.7, 1.4]                      ┆ 1     │
> │ 2.1        ┆ (1.4, 2.0999999999999996]       ┆ 2     │
> │ 2.8        ┆ (2.0999999999999996, 2.8]       ┆ 0     │
> │ 3.5        ┆ (2.8, 3.5]                      ┆ 0     │
> │ 4.2        ┆ (3.5, 4.2]                      ┆ 0     │
> │ 4.9        ┆ (4.199999999999999, 4.89999999… ┆ 0     │
> │ 5.6        ┆ (4.8999999999999995, 5.6]       ┆ 0     │
> │ 6.3        ┆ (5.6, 6.3]                      ┆ 0     │
> │ 7.0        ┆ (6.3, 7.0]                      ┆ 2     │
> └────────────┴─────────────────────────────────┴───────┘
> ```
Yep, I know the culprit here (forgot to add the minimum value back in when performing a bin calculation)- I'll add this as a property test as well.


> Second, I'm not totally sure about the categories column being a categorical in Polars in the first place ([pola-rs/polars#18645](https://github.com/pola-rs/polars/issues/18645)). Do we need it all though? We could always start by just not including that argument and column, and can always add it back later if necessary

If I understood some of the discussion going on- aren't we also in trouble by keeping the `"breakpoint"` column around as well? It seems like the new result may just be counts as an integer and the remaining information as a struct.

IMO, the results would be more usable as a struct, but then we lose the closedness information where the string representation encodes 4 pieces of information, left open/closed, right open/closed, left value, right value and a struct of this fashion (or `pandas.IntervalIndex`) loses two of those pieces of information. I'll think on this some more.

Patch:
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index d3bbd62f..95a6539a 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -30,6 +30,7 @@
         - filter
         - gather_every
         - head
+        - hist
         - implementation
         - is_between
         - is_duplicated
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index e0d18c06..15193b8e 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -21,6 +21,7 @@ from narwhals._arrow.utils import floordiv_compat
 from narwhals._arrow.utils import narwhals_to_native_dtype
 from narwhals._arrow.utils import native_to_narwhals_dtype
 from narwhals._arrow.utils import pad_series
+from narwhals.exceptions import InvalidOperationError
 from narwhals.typing import CompliantSeries
 from narwhals.utils import Implementation
 from narwhals.utils import generate_temporary_column_name
@@ -1005,6 +1006,129 @@ class ArrowSeries(CompliantSeries):
         result = pc.if_else(null_mask, pa.scalar(None), rank)
         return self._from_native_series(result)
 
+    def hist(  # noqa: PLR0915
+        self: Self,
+        bins: list[float | int] | None,
+        *,
+        bin_count: int | None,
+        include_breakpoint: bool,
+    ) -> ArrowDataFrame:
+        if self._backend_version < (13,):
+            msg = f"`Series.hist` requires PyArrow>=13.0.0, found PyArrow version: {self._backend_version}"
+            raise NotImplementedError(msg)
+        import numpy as np  # ignore-banned-import
+
+        from narwhals._arrow.dataframe import ArrowDataFrame
+
+        def _hist_from_bin_count(
+            bin_count: int,
+        ) -> tuple[Sequence[int], Sequence[int | float], Sequence[int | float]]:
+            d = pc.min_max(self._native_series)
+            lower, upper = d["min"], d["max"]
+            pad_lowest_bin = False
+            if lower == upper:
+                range_ = pa.scalar(1.0)
+                width = pc.divide(range_, bin_count)
+                lower = pc.subtract(lower, 0.5)
+                upper = pc.add(upper, 0.5)
+            else:
+                pad_lowest_bin = True
+                range_ = pc.subtract(upper, lower)
+                width = pc.divide(range_.cast("float"), float(bin_count))
+
+            bin_proportions = pc.divide(pc.subtract(self._native_series, lower), width)
+            bin_indices = pc.floor(bin_proportions)
+
+            bin_indices = pc.if_else(  # shift bins so they are right-closed
+                pc.and_(
+                    pc.equal(bin_indices, bin_proportions),
+                    pc.greater(bin_indices, 0),
+                ),
+                pc.subtract(bin_indices, 1),
+                bin_indices,
+            )
+            counts = (  # count bin id occurrences
+                pa.Table.from_arrays(
+                    pc.value_counts(bin_indices).flatten(),
+                    names=["values", "counts"],
+                )
+                # nan values are implicitly dropped in value_counts
+                .filter(~pc.field("values").is_nan())
+                .cast(pa.schema([("values", pa.int64()), ("counts", pa.int64())]))
+                .join(  # align bin ids to all possible bin ids (populate in missing bins)
+                    pa.Table.from_arrays(
+                        [np.arange(bin_count, dtype="int64")], ["values"]
+                    ),
+                    keys="values",
+                    join_type="right outer",
+                )
+                .sort_by("values")
+            )
+            counts = counts.set_column(  # empty bin intervals should have a 0 count
+                0, "counts", pc.coalesce(counts.column("counts"), 0)
+            )
+
+            # extract left/right side of the intervals
+            bin_left = pc.add(lower, pc.multiply(counts.column("values"), width))
+            bin_right = pc.add(bin_left, width)
+            if pad_lowest_bin:
+                bin_left = pa.chunked_array(
+                    [  # pad lowest bin by 1% of range
+                        [
+                            pc.subtract(
+                                bin_left[0], pc.multiply(range_.cast("float"), 0.001)
+                            )
+                        ],
+                        bin_left[1:],  # pyarrow==11.0 needs to infer
+                    ]
+                )
+            return counts.column("counts"), bin_left, bin_right
+
+        def _hist_from_bins(
+            bins: Sequence[int | float],
+        ) -> tuple[Sequence[int], Sequence[int | float], Sequence[int | float]]:
+            bin_indices = np.searchsorted(bins, self._native_series, side="left")
+            obs_cats, obs_counts = np.unique(bin_indices, return_counts=True)
+            obj_cats = np.arange(1, len(bins))
+            counts = np.zeros_like(obj_cats)
+            counts[np.isin(obj_cats, obs_cats)] = obs_counts[np.isin(obs_cats, obj_cats)]
+
+            bin_right = bins[1:]
+            bin_left = bins[:-1]
+            return counts, bin_left, bin_right
+
+        counts: Sequence[int]
+        bin_left: Sequence[int | float]
+        bin_right: Sequence[int | float]
+        if bins is not None:
+            if len(bins) < 2:
+                counts, bin_left, bin_right = [], [], []
+            else:
+                counts, bin_left, bin_right = _hist_from_bins(bins)
+
+        elif bin_count is not None:
+            if bin_count == 0:
+                counts, bin_left, bin_right = [], [], []
+            else:
+                counts, bin_left, bin_right = _hist_from_bin_count(bin_count)
+
+        else:  # pragma: no cover
+            # caller guarantees that either bins or bin_count is specified
+            msg = "must provide one of `bin_count` or `bins`"
+            raise InvalidOperationError(msg)
+
+        data: dict[str, Sequence[int | float | str]] = {}
+        if include_breakpoint:
+            data["breakpoint"] = bin_right
+        data["count"] = counts
+
+        return ArrowDataFrame(
+            pa.Table.from_pydict(data),
+            backend_version=self._backend_version,
+            version=self._version,
+            validate_column_names=True,
+        )
+
     def __iter__(self: Self) -> Iterator[Any]:
         yield from (
             maybe_extract_py_scalar(x, return_py_scalar=True)
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 317eb396..bef85779 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -1026,6 +1026,95 @@ class PandasLikeSeries(CompliantSeries):
 
         return self._from_native_series(ranked_series)
 
+    def hist(
+        self: Self,
+        bins: list[float | int] | None,
+        *,
+        bin_count: int | None,
+        include_breakpoint: bool,
+    ) -> PandasLikeDataFrame:
+        from numpy import linspace
+        from numpy import zeros
+
+        from narwhals._pandas_like.dataframe import PandasLikeDataFrame
+
+        ns = self.__native_namespace__()
+        data: dict[str, Sequence[int | float | str]]
+
+        if bin_count == 0 or (bins is not None and len(bins) <= 1):
+            data = {}
+            if include_breakpoint:
+                data["breakpoint"] = []
+            data["count"] = []
+
+            return PandasLikeDataFrame(
+                ns.DataFrame(data),
+                implementation=self._implementation,
+                backend_version=self._backend_version,
+                version=self._version,
+                validate_column_names=True,
+            )
+        elif self._native_series.count() < 1:
+            if bins is not None:
+                data = {
+                    "breakpoint": bins[1:],
+                    "count": zeros(shape=len(bins) - 1),
+                }
+            else:
+                data = {
+                    "breakpoint": linspace(0, 1, bin_count),
+                    "count": zeros(shape=bin_count),
+                }
+
+            if not include_breakpoint:
+                del data["breakpoint"]
+
+            return PandasLikeDataFrame(
+                ns.DataFrame(data),
+                implementation=self._implementation,
+                backend_version=self._backend_version,
+                version=self._version,
+                validate_column_names=True,
+            )
+
+        elif bin_count is not None:  # use Polars binning behavior
+            lower, upper = self._native_series.min(), self._native_series.max()
+            pad_lowest_bin = False
+            if lower == upper:
+                lower -= 0.5
+                upper += 0.5
+            else:
+                pad_lowest_bin = True
+
+            bins = linspace(lower, upper, bin_count + 1)
+            if pad_lowest_bin and bins is not None:
+                bins[0] -= 0.001 * abs(bins[0]) if bins[0] != 0 else 0.001
+            bin_count = None
+
+        # pandas (2.2.*) .value_counts(bins=int) adjusts the lowest bin twice, result in improper counts.
+        # pandas (2.2.*) .value_counts(bins=[...]) adjusts the lowest bin which should not happen since
+        #   the bins were explicitly passed in.
+        categories = ns.cut(
+            self._native_series, bins=bins if bin_count is None else bin_count
+        )
+        # modin (0.32.0) .value_counts(...) silently drops bins with empty observations, .reindex
+        #   is necessary to restore these bins.
+        result = categories.value_counts(dropna=True, sort=False).reindex(
+            categories.cat.categories, fill_value=0
+        )
+        data = {}
+        if include_breakpoint:
+            data["breakpoint"] = bins[1:] if bins is not None else result.index.right
+        data["count"] = result.reset_index(drop=True)
+
+        return PandasLikeDataFrame(
+            ns.DataFrame(data),
+            implementation=self._implementation,
+            backend_version=self._backend_version,
+            version=self._version,
+            validate_column_names=True,
+        )
+
     @property
     def str(self: Self) -> PandasLikeSeriesStringNamespace:
         return PandasLikeSeriesStringNamespace(self)
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index 19ec94d8..e0f0c9f6 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -3,6 +3,8 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Sequence
+from typing import Union
+from typing import cast
 from typing import overload
 
 import polars as pl
@@ -459,6 +461,99 @@ class PolarsSeries:
         except Exception as e:  # noqa: BLE001
             raise catch_polars_exception(e, self._backend_version) from None
 
+    def hist(
+        self: Self,
+        bins: list[float | int] | None,
+        *,
+        bin_count: int | None,
+        include_breakpoint: bool,
+    ) -> PolarsDataFrame:
+        from narwhals._polars.dataframe import PolarsDataFrame
+
+        if (bins is not None and len(bins) <= 1) or (bin_count == 0):  # pragma: no cover
+            data: list[pl.Series] = []
+            if include_breakpoint:
+                data.append(pl.Series("breakpoint", [], dtype=pl.Float64))
+            data.append(pl.Series("count", [], dtype=pl.UInt32))
+            return PolarsDataFrame(
+                pl.DataFrame(data),
+                backend_version=self._backend_version,
+                version=self._version,
+            )
+        elif (self._backend_version < (1, 15)) and self._native_series.count() < 1:
+            data_dict: dict[str, list[int | float] | pl.Series | pl.Expr]
+            if bins is not None:
+                data_dict = {
+                    "breakpoint": bins[1:],
+                    "count": pl.zeros(n=len(bins) - 1, dtype=pl.Int64, eager=True),
+                }
+            elif bin_count is not None:
+                data_dict = {
+                    "breakpoint": pl.int_range(0, bin_count, eager=True) / bin_count,
+                    "count": pl.zeros(n=bin_count, dtype=pl.Int64, eager=True),
+                }
+
+            if not include_breakpoint:
+                del data_dict["breakpoint"]
+
+            return PolarsDataFrame(
+                pl.DataFrame(data_dict),
+                backend_version=self._backend_version,
+                version=self._version,
+            )
+
+        # polars <1.15 does not adjust the bins when they have equivalent min/max
+        # polars <1.5 with bin_count=...
+        # returns bins that range from -inf to +inf and has bin_count + 1 bins.
+        #   for compat: convert `bin_count=` call to `bins=`
+        if (
+            (self._backend_version < (1, 15))
+            and (bin_count is not None)
+            and (self._native_series.count() > 0)
+        ):  # pragma: no cover
+            lower = cast(Union[int, float], self._native_series.min())
+            upper = cast(Union[int, float], self._native_series.max())
+            pad_lowest_bin = False
+            if lower == upper:
+                width = 1 / bin_count
+                lower -= 0.5
+                upper += 0.5
+            else:
+                pad_lowest_bin = True
+                width = (upper - lower) / bin_count
+
+            bins = (pl.int_range(0, bin_count + 1, eager=True) * width + lower).to_list()
+            if pad_lowest_bin:
+                bins[0] -= 0.001 * abs(bins[0]) if bins[0] != 0 else 0.001
+            bin_count = None
+
+        # Polars inconsistently handles NaN values when computing histograms
+        #   against predefined bins: https://github.com/pola-rs/polars/issues/21082
+        series = self._native_series
+        if self._backend_version < (1, 15) or bins is not None:
+            series = series.set(series.is_nan(), None)
+
+        df = series.hist(
+            bins=bins,
+            bin_count=bin_count,
+            include_category=False,
+            include_breakpoint=include_breakpoint,
+        )
+        if not include_breakpoint:
+            df.columns = ["count"]
+
+        #  polars<1.15 implicitly adds -inf and inf to either end of bins
+        if self._backend_version < (1, 15) and bins is not None:  # pragma: no cover
+            r = pl.int_range(0, len(df))
+            df = df.filter((r > 0) & (r < len(df) - 1))
+
+        if self._backend_version < (1, 0) and include_breakpoint:
+            df = df.rename({"break_point": "breakpoint"})
+
+        return PolarsDataFrame(
+            df, backend_version=self._backend_version, version=self._version
+        )
+
     def to_polars(self: Self) -> pl.Series:
         return self._native_series
 
diff --git a/narwhals/_polars/utils.py b/narwhals/_polars/utils.py
index 7d8320ff..eaae9152 100644
--- a/narwhals/_polars/utils.py
+++ b/narwhals/_polars/utils.py
@@ -9,6 +9,7 @@ from typing import overload
 import polars as pl
 
 from narwhals.exceptions import ColumnNotFoundError
+from narwhals.exceptions import ComputeError
 from narwhals.exceptions import InvalidOperationError
 from narwhals.exceptions import NarwhalsError
 from narwhals.exceptions import ShapeError
@@ -237,8 +238,7 @@ def catch_polars_exception(
     elif isinstance(exception, pl.exceptions.InvalidOperationError):
         return InvalidOperationError(str(exception))
     elif isinstance(exception, pl.exceptions.ComputeError):
-        # We don't (yet?) have a Narwhals ComputeError.
-        return NarwhalsError(str(exception))
+        return ComputeError(str(exception))
     if backend_version >= (1,) and isinstance(exception, pl.exceptions.PolarsError):
         # Old versions of Polars didn't have PolarsError.
         return NarwhalsError(str(exception))
diff --git a/narwhals/exceptions.py b/narwhals/exceptions.py
index 5df7b346..f7ca85e1 100644
--- a/narwhals/exceptions.py
+++ b/narwhals/exceptions.py
@@ -44,6 +44,10 @@ class ColumnNotFoundError(FormattedKeyError, NarwhalsError):
         return ColumnNotFoundError(message)
 
 
+class ComputeError(NarwhalsError):
+    """Exception raised when the underlying computation could not be evaluated."""
+
+
 class ShapeError(NarwhalsError):
     """Exception raised when trying to perform operations on data structures with incompatible shapes."""
 
diff --git a/narwhals/series.py b/narwhals/series.py
index ac2e7d6a..a6a40276 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -12,6 +12,7 @@ from typing import overload
 
 from narwhals.dependencies import is_numpy_scalar
 from narwhals.dtypes import _validate_dtype
+from narwhals.exceptions import ComputeError
 from narwhals.series_cat import SeriesCatNamespace
 from narwhals.series_dt import SeriesDateTimeNamespace
 from narwhals.series_list import SeriesListNamespace
@@ -2551,6 +2552,63 @@ class Series(Generic[IntoSeriesT]):
             self._compliant_series.rank(method=method, descending=descending)
         )
 
+    def hist(
+        self: Self,
+        bins: list[float | int] | None = None,
+        *,
+        bin_count: int | None = None,
+        include_breakpoint: bool = True,
+    ) -> DataFrame[Any]:
+        """Bin values into buckets and count their occurrences.
+
+        !!! warning
+            This functionality is considered **unstable**. It may be changed at any point
+            without it being considered a breaking change.
+
+        Arguments:
+            bins: A monotonically increasing sequence of values.
+            bin_count: If no bins provided, this will be used to determine the distance of the bins.
+            include_breakpoint: Include a column that shows the intervals as categories.
+
+        Returns:
+            A new DataFrame containing the counts of values that occur within each passed bin.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import narwhals as nw
+            >>> s_native = pd.Series([1, 3, 8, 8, 2, 1, 3], name="a")
+            >>> nw.from_native(s_native, series_only=True).hist(bin_count=4)
+            ┌────────────────────┐
+            | Narwhals DataFrame |
+            |--------------------|
+            |   breakpoint  count|
+            |0        2.75      3|
+            |1        4.50      2|
+            |2        6.25      0|
+            |3        8.00      2|
+            └────────────────────┘
+        """
+        if bins is not None and bin_count is not None:
+            msg = "can only provide one of `bin_count` or `bins`"
+            raise ComputeError(msg)
+        if bins is None and bin_count is None:
+            bin_count = 10  # polars (v1.20) sets bin=10 if neither are provided.
+
+        if bins is not None:
+            for i in range(1, len(bins)):
+                if bins[i - 1] >= bins[i]:
+                    msg = "bins must increase monotonically"
+                    raise ComputeError(msg)
+
+        return self._dataframe(
+            self._compliant_series.hist(
+                bins=bins,
+                bin_count=bin_count,
+                include_breakpoint=include_breakpoint,
+            ),
+            level=self._level,
+        )
+
     @property
     def str(self: Self) -> SeriesStringNamespace[Self]:
         return SeriesStringNamespace(self)
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index b9082a7b..4b5511d6 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -659,6 +659,41 @@ class Series(NwSeries[Any]):
             ddof=ddof,
         )
 
+    def hist(
+        self: Self,
+        bins: list[float | int] | None = None,
+        *,
+        bin_count: int | None = None,
+        include_breakpoint: bool = True,
+    ) -> DataFrame[Any]:
+        """Bin values into buckets and count their occurrences.
+
+        !!! warning
+            This functionality is considered **unstable**. It may be changed at any point
+            without it being considered a breaking change.
+
+        Arguments:
+            bins: A monotonically increasing sequence of values.
+            bin_count: If no bins provided, this will be used to determine the distance of the bins.
+            include_breakpoint: Include a column that shows the intervals as categories.
+
+        Returns:
+            A new DataFrame containing the counts of values that occur within each passed bin.
+        """
+        from narwhals.exceptions import NarwhalsUnstableWarning
+        from narwhals.utils import find_stacklevel
+
+        msg = (
+            "`Series.hist` is being called from the stable API although considered "
+            "an unstable feature."
+        )
+        warn(message=msg, category=NarwhalsUnstableWarning, stacklevel=find_stacklevel())
+        return super().hist(  # type: ignore[return-value]
+            bins=bins,
+            bin_count=bin_count,
+            include_breakpoint=include_breakpoint,
+        )
+
 
 class Expr(NwExpr):
     def _l1_norm(self: Self) -> Self:
diff --git a/utils/check_api_reference.py b/utils/check_api_reference.py
index e0ebf97a..fa5a4a5c 100644
--- a/utils/check_api_reference.py
+++ b/utils/check_api_reference.py
@@ -18,6 +18,7 @@ SERIES_ONLY_METHODS = {
     "implementation",
     "is_empty",
     "is_sorted",
+    "hist",
     "item",
     "name",
     "rename",


Test patch:
diff --git a/tests/series_only/hist_test.py b/tests/series_only/hist_test.py
new file mode 100644
index 00000000..fc0feed6
--- /dev/null
+++ b/tests/series_only/hist_test.py
@@ -0,0 +1,449 @@
+from __future__ import annotations
+
+from typing import Any
+
+import hypothesis.strategies as st
+import pytest
+from hypothesis import given
+
+import narwhals.stable.v1 as nw
+from narwhals.exceptions import ComputeError
+from tests.utils import POLARS_VERSION
+from tests.utils import PYARROW_VERSION
+from tests.utils import ConstructorEager
+from tests.utils import assert_equal_data
+
+data = {
+    "int": [0, 1, 2, 3, 4, 5, 6],
+}
+
+bins_and_expected = [
+    {
+        "bins": [-float("inf"), 2.5, 5.5, float("inf")],
+        "expected": [3, 3, 1],
+    },
+    {
+        "bins": [1.0, 2.5, 5.5, float("inf")],
+        "expected": [1, 3, 1],
+    },
+    {
+        "bins": [1.0, 2.5, 5.5],
+        "expected": [1, 3],
+    },
+    {
+        "bins": [-10.0, -1.0, 2.5, 5.5],
+        "expected": [0, 3, 3],
+    },
+    {
+        "bins": [1.0, 2.0625],
+        "expected": [1],
+    },
+    {
+        "bins": [1],
+        "expected": [],
+    },
+]
+counts_and_expected = [
+    {
+        "bin_count": 4,
+        "expected_bins": [-0.006, 1.5, 3.0, 4.5, 6.0],
+        "expected_count": [2, 2, 1, 2],
+    },
+    {
+        "bin_count": 12,
+        "expected_bins": [
+            -0.006,
+            0.5,
+            1.0,
+            1.5,
+            2.0,
+            2.5,
+            3.0,
+            3.5,
+            4.0,
+            4.5,
+            5.0,
+            5.5,
+            6.0,
+        ],
+        "expected_count": [1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1],
+    },
+    {
+        "bin_count": 0,
+        "expected_bins": [],
+        "expected_count": [],
+    },
+]
+
+
+@pytest.mark.parametrize("params", bins_and_expected)
+@pytest.mark.parametrize("include_breakpoint", [True, False])
+@pytest.mark.filterwarnings(
+    "ignore:`Series.hist` is being called from the stable API although considered an unstable feature."
+)
+def test_hist_bin(
+    constructor_eager: ConstructorEager,
+    *,
+    params: dict[str, Any],
+    include_breakpoint: bool,
+    request: pytest.FixtureRequest,
+) -> None:
+    if "pyarrow_table" in str(constructor_eager) and PYARROW_VERSION < (13,):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor_eager(data)).with_columns(
+        float=nw.col("int").cast(nw.Float64),
+    )
+    bins = params["bins"]
+
+    expected = {
+        "breakpoint": bins[1:],
+        "count": params["expected"],
+    }
+    if not include_breakpoint:
+        del expected["breakpoint"]
+
+    # smoke tests
+    for col in df.columns:
+        result = df[col].hist(
+            bins=bins,
+            include_breakpoint=include_breakpoint,
+        )
+        assert_equal_data(result, expected)
+
+        # result size property
+        if len(bins) < 2:
+            assert len(result) == 0
+
+    # shift bins property
+    shift_by = 10
+    shifted_bins = [b + shift_by for b in bins]
+    expected = {
+        "breakpoint": shifted_bins[1:],
+        "count": params["expected"],
+    }
+    if not include_breakpoint:
+        del expected["breakpoint"]
+
+    for col in df.columns:
+        result = (df[col] + shift_by).hist(
+            bins=shifted_bins,
+            include_breakpoint=include_breakpoint,
+        )
+        assert_equal_data(result, expected)
+
+    # missing/nan results
+    df = nw.from_native(
+        constructor_eager(
+            {
+                "has_nan": [float("nan"), *data["int"]],
+                "has_null": [None, *data["int"]],
+            }
+        )
+    )
+    bins = params["bins"]
+    expected = {
+        "breakpoint": bins[1:],
+        "count": params["expected"],
+    }
+    if not include_breakpoint:
+        del expected["breakpoint"]
+
+    for col in df.columns:
+        result = df[col].hist(
+            bins=bins,
+            include_breakpoint=include_breakpoint,
+        )
+
+        assert_equal_data(result, expected)
+
+
+@pytest.mark.parametrize("params", counts_and_expected)
+@pytest.mark.parametrize("include_breakpoint", [True, False])
+@pytest.mark.filterwarnings(
+    "ignore:`Series.hist` is being called from the stable API although considered an unstable feature."
+)
+def test_hist_count(
+    constructor_eager: ConstructorEager,
+    *,
+    params: dict[str, Any],
+    include_breakpoint: bool,
+    request: pytest.FixtureRequest,
+) -> None:
+    if "pyarrow_table" in str(constructor_eager) and PYARROW_VERSION < (13,):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor_eager(data)).with_columns(
+        float=nw.col("int").cast(nw.Float64),
+    )
+    bin_count = params["bin_count"]
+
+    expected_bins = params["expected_bins"]
+    expected = {
+        "breakpoint": expected_bins[1:],
+        "count": params["expected_count"],
+    }
+    if not include_breakpoint:
+        del expected["breakpoint"]
+
+    # smoke tests
+    for col in df.columns:
+        result = df[col].hist(
+            bin_count=bin_count,
+            include_breakpoint=include_breakpoint,
+        )
+        assert_equal_data(result, expected)
+
+        # result size property
+        if bin_count < 2:
+            assert len(result) == 0
+        else:
+            assert result["count"].sum() == df[col].count()
+
+    # missing/nan results
+    df = nw.from_native(
+        constructor_eager(
+            {
+                "has_nan": [float("nan"), *data["int"]],
+                "has_null": [None, *data["int"]],
+            }
+        )
+    )
+
+    for col in df.columns:
+        result = df[col].hist(
+            bin_count=bin_count,
+            include_breakpoint=include_breakpoint,
+        )
+        assert_equal_data(result, expected)
+
+        # result size property
+        if bin_count < 2:
+            assert len(result) == 0
+        else:
+            assert (
+                result["count"].sum() == (~(df[col].is_nan() | df[col].is_null())).sum()
+            )
+
+
+@pytest.mark.filterwarnings(
+    "ignore:`Series.hist` is being called from the stable API although considered an unstable feature."
+)
+def test_hist_count_no_spread(
+    constructor_eager: ConstructorEager,
+    *,
+    request: pytest.FixtureRequest,
+) -> None:
+    if "pyarrow_table" in str(constructor_eager) and PYARROW_VERSION < (13,):
+        request.applymarker(pytest.mark.xfail)
+
+    data = {
+        "all_zero": [0, 0, 0],
+        "all_non_zero": [5, 5, 5],
+    }
+    df = nw.from_native(constructor_eager(data))
+
+    result = df["all_zero"].hist(bin_count=4, include_breakpoint=True)
+    expected = {"breakpoint": [-0.25, 0.0, 0.25, 0.5], "count": [0, 3, 0, 0]}
+    assert_equal_data(result, expected)
+
+    result = df["all_non_zero"].hist(bin_count=4, include_breakpoint=True)
+    expected = {"breakpoint": [4.75, 5.0, 5.25, 5.5], "count": [0, 3, 0, 0]}
+    assert_equal_data(result, expected)
+
+    result = df["all_zero"].hist(bin_count=1, include_breakpoint=True)
+    expected = {
+        "breakpoint": [0.5],
+        "count": [3],
+    }
+    assert_equal_data(result, expected)
+
+
+@pytest.mark.filterwarnings(
+    "ignore:`Series.hist` is being called from the stable API although considered an unstable feature."
+)
+def test_hist_bin_and_bin_count() -> None:
+    import polars as pl
+
+    s = nw.from_native(pl.Series([1, 2, 3]), series_only=True)
+    result = s.hist(bins=None, bin_count=None)
+    assert len(result) == 10
+
+    with pytest.raises(ComputeError, match="can only provide one of"):
+        s.hist(bins=[1, 3], bin_count=4)
+
+
+@pytest.mark.filterwarnings(
+    "ignore:`Series.hist` is being called from the stable API although considered an unstable feature."
+)
+def test_hist_no_data(
+    constructor_eager: ConstructorEager,
+    *,
+    request: pytest.FixtureRequest,
+) -> None:
+    if "pyarrow_table" in str(constructor_eager) and PYARROW_VERSION < (13,):
+        request.applymarker(pytest.mark.xfail)
+    s = nw.from_native(constructor_eager({"values": []})).select(
+        nw.col("values").cast(nw.Float64)
+    )["values"]
+    for include_breakpoint in [True, False]:
+        result = s.hist(bin_count=10, include_breakpoint=include_breakpoint)
+        assert len(result) == 10
+        assert result["count"].sum() == 0
+
+    for include_breakpoint in [True, False]:
+        result = s.hist(bins=[1, 5, 10], include_breakpoint=include_breakpoint)
+        assert len(result) == 2
+        assert result["count"].sum() == 0
+
+
+@pytest.mark.filterwarnings(
+    "ignore:`Series.hist` is being called from the stable API although considered an unstable feature."
+)
+def test_hist_small_bins(
+    constructor_eager: ConstructorEager,
+    *,
+    request: pytest.FixtureRequest,
+) -> None:
+    if "pyarrow_table" in str(constructor_eager) and PYARROW_VERSION < (13,):
+        request.applymarker(pytest.mark.xfail)
+    s = nw.from_native(constructor_eager({"values": [1, 2, 3]}))
+    result = s["values"].hist(bins=None, bin_count=None)
+    assert len(result) == 10
+
+    with pytest.raises(ComputeError, match="can only provide one of"):
+        s["values"].hist(bins=[1, 3], bin_count=4)
+
+
+@pytest.mark.filterwarnings(
+    "ignore:`Series.hist` is being called from the stable API although considered an unstable feature."
+)
+def test_hist_non_monotonic(constructor_eager: ConstructorEager) -> None:
+    df = nw.from_native(constructor_eager({"int": [0, 1, 2, 3, 4, 5, 6]}))
+
+    with pytest.raises(ComputeError, match="monotonic"):
+        df["int"].hist(bins=[5, 0, 2])
+
+    with pytest.raises(ComputeError, match="monotonic"):
+        df["int"].hist(bins=[5, 2, 0])
+
+
+@given(  # type: ignore[misc]
+    data=st.lists(
+        # Bug in Polars <= 1.21; computing histograms with NaN data and passed bins can be unreliable
+        #   this leads to flaky hypothesis testing https://github.com/pola-rs/polars/issues/21082
+        # min_value/max_value from PyArrows min/max boundaries
+        st.floats(
+            min_value=-9007199254740992,
+            max_value=9007199254740992,
+            allow_nan=False,
+            allow_infinity=False,
+        ),
+        min_size=0,
+        max_size=100,
+    ),
+    bin_deltas=st.lists(
+        st.floats(min_value=0.001, max_value=1_000, allow_nan=False), max_size=50
+    ),
+)
+@pytest.mark.filterwarnings(
+    "ignore:`Series.hist` is being called from the stable API although considered an unstable feature.",
+    "ignore:invalid value encountered in cast:RuntimeWarning",
+)
+@pytest.mark.skipif(
+    POLARS_VERSION < (1, 15),
+    reason="hist(bins=...) cannot be used for compatibility checks since narwhals aims to mimic polars>=1.2.0 behavior",
+)
+@pytest.mark.slow
+def test_hist_bin_hypotheis(
+    constructor_eager: ConstructorEager,
+    data: list[float],
+    bin_deltas: list[float],
+) -> None:
+    import polars as pl
+
+    df = nw.from_native(constructor_eager({"values": data})).select(
+        nw.col("values").cast(nw.Float64)
+    )
+    bins = (
+        nw.from_native(constructor_eager({"bins": bin_deltas})["bins"], series_only=True)  # type:ignore[index]
+        .cast(nw.Float64)
+        .cum_sum()
+    )
+
+    result = df["values"].hist(
+        bins=bins.to_list(),
+        include_breakpoint=True,
+    )
+    expected_data = pl.Series(data, dtype=pl.Float64)
+    expected = expected_data.hist(
+        bins=bins.to_list(),
+        include_breakpoint=True,
+        include_category=False,
+    )
+
+    assert_equal_data(result, expected.to_dict(as_series=False))
+
+
+@given(  # type: ignore[misc]
+    data=st.lists(
+        # min_value/max_value from PyArrows min/max boundaries
+        st.floats(
+            min_value=-9007199254740992,
+            max_value=9007199254740992,
+            allow_nan=False,
+            allow_infinity=False,
+        ),
+        min_size=0,
+        max_size=100,
+    ),
+    bin_count=st.integers(min_value=0, max_value=1_000),
+)
+@pytest.mark.skipif(
+    POLARS_VERSION < (1, 15),
+    reason="hist(bin_count=...) behavior significantly changed after this version",
+)
+@pytest.mark.filterwarnings(
+    "ignore:`Series.hist` is being called from the stable API although considered an unstable feature.",
+    "ignore:invalid value encountered in cast:RuntimeWarning",
+)
+@pytest.mark.slow
+def test_hist_count_hypothesis(
+    constructor_eager: ConstructorEager,
+    data: list[float],
+    bin_count: int,
+    request: pytest.FixtureRequest,
+) -> None:
+    import polars as pl
+
+    df = nw.from_native(constructor_eager({"values": data})).select(
+        nw.col("values").cast(nw.Float64)
+    )
+
+    try:
+        result = df["values"].hist(
+            bin_count=bin_count,
+            include_breakpoint=True,
+        )
+    except pl.exceptions.PanicException:  # pragma: no cover
+        # panic occurs from specific float inputs on Polars 1.15
+        if (1, 14) < POLARS_VERSION < (1, 16):
+            request.applymarker(pytest.mark.xfail)
+        raise
+
+    expected_data = pl.Series(data, dtype=pl.Float64)
+    expected = expected_data.hist(
+        bin_count=bin_count,
+        include_breakpoint=True,
+        include_category=False,
+    )
+
+    # Bug in Polars <= 1.21; hist becomes unreliable when passing bin_counts
+    #   for data with a wide range and a large number of passed bins
+    #   https://github.com/pola-rs/polars/issues/20879
+    if expected["count"].sum() != expected_data.count() and "polars" not in str(
+        constructor_eager
+    ):
+        request.applymarker(pytest.mark.xfail)
+
+    assert_equal_data(result, expected.to_dict(as_series=False))