Repository: holoviz/holoviews

Issue:
Histogram operation does not work with datetime input and groupby
``` python
import holoviews as hv
import numpy as np
import pandas as pd
from holoviews.operation import histogram

hv.extension("bokeh")

x = pd.date_range("2020-01-01", periods=100)
y = pd.date_range("2020-01-01", periods=100)
xy = np.concatenate([x, y])
label = ["x"] * 100 + ["y"] * 100

ds = hv.Dataset(pd.DataFrame([xy, label], index=["xy", "label"]).T, vdims=["xy", "label"])
histogram(ds, groupby="label").opts(width=800)
```
``` python-traceback
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[14], line 14
     11 label = ["x"] * 100 + ["y"] * 100
     13 ds = hv.Dataset(pd.DataFrame([xy, label], index=["xy", "label"]).T, vdims=["xy", "label"])
---> 14 histogram(ds, groupby="label").opts(width=800)

File [~/projects/holoviz/repos/param/param/parameterized.py:4468](http://localhost:8889/home/shh/projects/holoviz/repos/param/param/parameterized.py#line=4467), in ParameterizedFunction.__new__(class_, *args, **params)
   4466 inst = class_.instance()
   4467 inst.param._set_name(class_.__name__)
-> 4468 return inst.__call__(*args,**params)

File [~/projects/holoviz/repos/holoviews/holoviews/core/operation.py:220](http://localhost:8889/home/shh/projects/holoviz/repos/holoviews/holoviews/core/operation.py#line=219), in Operation.__call__(self, element, **kwargs)
    218 kwargs['link_dataset'] = self._propagate_dataset
    219 kwargs['link_inputs'] = self.p.link_inputs
--> 220 return element.apply(self, **kwargs)

File [~/projects/holoviz/repos/holoviews/holoviews/core/accessors.py:44](http://localhost:8889/home/shh/projects/holoviz/repos/holoviews/holoviews/core/accessors.py#line=43), in AccessorPipelineMeta.pipelined.<locals>.pipelined_call(*args, **kwargs)
     41     inst._obj._in_method = True
     43 try:
---> 44     result = __call__(*args, **kwargs)
     46     if not in_method:
     47         init_op = factory.instance(
     48             output_type=type(inst),
     49             kwargs={'mode': getattr(inst, 'mode', None)},
     50         )

File [~/projects/holoviz/repos/holoviews/holoviews/core/accessors.py:203](http://localhost:8889/home/shh/projects/holoviz/repos/holoviews/holoviews/core/accessors.py#line=202), in Apply.__call__(self, apply_function, streams, link_inputs, link_dataset, dynamic, per_element, **kwargs)
    201 if hasattr(apply_function, 'dynamic'):
    202     inner_kwargs['dynamic'] = False
--> 203 new_obj = apply_function(self._obj, **inner_kwargs)
    204 if (link_dataset and isinstance(self._obj, Dataset) and
    205     isinstance(new_obj, Dataset) and new_obj._dataset is None):
    206     new_obj._dataset = self._obj.dataset

File [~/projects/holoviz/repos/holoviews/holoviews/core/operation.py:214](http://localhost:8889/home/shh/projects/holoviz/repos/holoviews/holoviews/core/operation.py#line=213), in Operation.__call__(self, element, **kwargs)
    210         return element.clone([(k, self._apply(el, key=k))
    211                               for k, el in element.items()])
    212     elif ((self._per_element and isinstance(element, Element)) or
    213           (not self._per_element and isinstance(element, ViewableElement))):
--> 214         return self._apply(element)
    215 elif 'streams' not in kwargs:
    216     kwargs['streams'] = self.p.streams

File [~/projects/holoviz/repos/holoviews/holoviews/core/operation.py:141](http://localhost:8889/home/shh/projects/holoviz/repos/holoviews/holoviews/core/operation.py#line=140), in Operation._apply(self, element, key)
    139     if not in_method:
    140         element._in_method = True
--> 141 ret = self._process(element, key)
    142 if hasattr(element, '_in_method') and not in_method:
    143     element._in_method = in_method

File [~/projects/holoviz/repos/holoviews/holoviews/operation/element.py:790](http://localhost:8889/home/shh/projects/holoviz/repos/holoviews/holoviews/operation/element.py#line=789), in histogram._process(self, element, key)
    788     grouped = element.groupby(self.p.groupby, group_type=Dataset, container_type=NdOverlay)
    789     self.p.groupby = None
--> 790     return grouped.map(self._process, Dataset)
    792 normed = False if self.p.mean_weighted and self.p.weight_dimension else self.p.normed
    793 if self.p.dimension:

File [~/projects/holoviz/repos/holoviews/holoviews/core/dimension.py:693](http://localhost:8889/home/shh/projects/holoviz/repos/holoviews/holoviews/core/dimension.py#line=692), in LabelledData.map(self, map_fn, specs, clone)
    691 deep_mapped = self.clone(shared_data=False) if clone else self
    692 for k, v in self.items():
--> 693     new_val = v.map(map_fn, specs, clone)
    694     if new_val is not None:
    695         deep_mapped[k] = new_val

File [~/projects/holoviz/repos/holoviews/holoviews/core/data/__init__.py:196](http://localhost:8889/home/shh/projects/holoviz/repos/holoviews/holoviews/core/data/__init__.py#line=195), in PipelineMeta.pipelined.<locals>.pipelined_fn(*args, **kwargs)
    193     inst._in_method = True
    195 try:
--> 196     result = method_fn(*args, **kwargs)
    197     if PipelineMeta.disable:
    198         return result

File [~/projects/holoviz/repos/holoviews/holoviews/core/data/__init__.py:1213](http://localhost:8889/home/shh/projects/holoviz/repos/holoviews/holoviews/core/data/__init__.py#line=1212), in Dataset.map(self, *args, **kwargs)
   1211 @wraps(LabelledData.map)
   1212 def map(self, *args, **kwargs):
-> 1213     return super().map(*args, **kwargs)

File [~/projects/holoviz/repos/holoviews/holoviews/core/dimension.py:699](http://localhost:8889/home/shh/projects/holoviz/repos/holoviews/holoviews/core/dimension.py#line=698), in LabelledData.map(self, map_fn, specs, clone)
    697     return deep_mapped
    698 else:
--> 699     return map_fn(self) if applies else self

File [~/projects/holoviz/repos/holoviews/holoviews/operation/element.py:895](http://localhost:8889/home/shh/projects/holoviz/repos/holoviews/holoviews/operation/element.py#line=894), in histogram._process(self, element, key)
    893 elif hasattr(element, 'interface'):
    894     density = True if normed else False
--> 895     hist, edges = element.interface.histogram(
    896         data, edges, density=density, weights=weights
    897     )
    898     if normed == 'height':
    899         hist [/](http://localhost:8889/)= hist.max()

File [~/projects/holoviz/repos/holoviews/holoviews/core/data/interface.py:476](http://localhost:8889/home/shh/projects/holoviz/repos/holoviews/holoviews/core/data/interface.py#line=475), in Interface.histogram(cls, array, bins, density, weights)
    474 else:
    475     histogram = np.histogram
--> 476 hist, edges = histogram(array, bins=bins, density=density, weights=weights)
    477 if util.is_cupy_array(hist):
    478     edges = cupy.asnumpy(edges)

File [~/miniconda3/envs/holoviz/lib/python3.12/site-packages/numpy/lib/histograms.py:868](http://localhost:8889/home/shh/miniconda3/envs/holoviz/lib/python3.12/site-packages/numpy/lib/histograms.py#line=867), in histogram(a, bins, range, density, weights)
    866     for i in _range(0, len(a), BLOCK):
    867         sa = np.sort(a[i:i+BLOCK])
--> 868         cum_n += _search_sorted_inclusive(sa, bin_edges)
    869 else:
    870     zero = np.zeros(1, dtype=ntype)

File [~/miniconda3/envs/holoviz/lib/python3.12/site-packages/numpy/lib/histograms.py:461](http://localhost:8889/home/shh/miniconda3/envs/holoviz/lib/python3.12/site-packages/numpy/lib/histograms.py#line=460), in _search_sorted_inclusive(a, v)
    454 def _search_sorted_inclusive(a, v):
    455     """
    456     Like `searchsorted`, but where the last item in `v` is placed on the right.
    457 
    458     In the context of a histogram, this makes the last bin edge inclusive
    459     """
    460     return np.concatenate((
--> 461         a.searchsorted(v[:-1], 'left'),
    462         a.searchsorted(v[-1:], 'right')
    463     ))

TypeError: '<' not supported between instances of 'int' and 'datetime.datetime'
```



Discussion:

Resolving patch:
diff --git a/holoviews/operation/element.py b/holoviews/operation/element.py
--- a/holoviews/operation/element.py
+++ b/holoviews/operation/element.py
@@ -3,6 +3,7 @@
 examples.
 """
 import warnings
+from functools import partial
 
 import numpy as np
 import param
@@ -754,6 +755,9 @@ class histogram(Operation):
     groupby = param.ClassSelector(default=None, class_=(str, Dimension), doc="""
       Defines a dimension to group the Histogram returning an NdOverlay of Histograms.""")
 
+    groupby_range = param.Selector(default="shared", objects=["shared", "separated"], doc="""
+        Whether to group the histograms along the same range or separate them.""")
+
     log = param.Boolean(default=False, doc="""
       Whether to use base 10 logarithmic samples for the bin edges.""")
 
@@ -781,15 +785,7 @@ class histogram(Operation):
     style_prefix = param.String(default=None, allow_None=None, doc="""
       Used for setting a common style for histograms in a HoloMap or AdjointLayout.""")
 
-    def _process(self, element, key=None):
-        if self.p.groupby:
-            if not isinstance(element, Dataset):
-                raise ValueError('Cannot use histogram groupby on non-Dataset Element')
-            grouped = element.groupby(self.p.groupby, group_type=Dataset, container_type=NdOverlay)
-            self.p.groupby = None
-            return grouped.map(self._process, Dataset)
-
-        normed = False if self.p.mean_weighted and self.p.weight_dimension else self.p.normed
+    def _get_dim_and_data(self, element):
         if self.p.dimension:
             selected_dim = self.p.dimension
         else:
@@ -800,6 +796,21 @@ def _process(self, element, key=None):
             data = element.interface.values(element, selected_dim, compute=False)
         else:
             data = element.dimension_values(selected_dim)
+        return dim, data
+
+    def _process(self, element, key=None, groupby=False):
+        if self.p.groupby:
+            if not isinstance(element, Dataset):
+                raise ValueError('Cannot use histogram groupby on non-Dataset Element')
+            grouped = element.groupby(self.p.groupby, group_type=Dataset, container_type=NdOverlay)
+            if self.p.groupby_range == 'shared' and not self.p.bin_range:
+                _, data = self._get_dim_and_data(element)
+                self.p.bin_range = (data.min(), data.max())
+            self.p.groupby = None
+            return grouped.map(partial(self._process, groupby=True), Dataset)
+
+        normed = False if self.p.mean_weighted and self.p.weight_dimension else self.p.normed
+        dim, data = self._get_dim_and_data(element)
 
         is_datetime = isdatetime(data)
         if is_datetime:
@@ -859,7 +870,7 @@ def _process(self, element, key=None):
             if isdatetime(edges):
                 edges = edges.astype('datetime64[ns]').astype('int64')
         else:
-            hist_range = self.p.bin_range or element.range(selected_dim)
+            hist_range = self.p.bin_range or element.range(dim)
             # Suppress a warning emitted by Numpy when datetime or timedelta scalars
             # are compared. See https://github.com/numpy/numpy/issues/10095 and
             # https://github.com/numpy/numpy/issues/9210.
@@ -939,8 +950,9 @@ def _process(self, element, key=None):
         # Save off the computed bin edges so that if this operation instance
         # is used to compute another histogram, it will default to the same
         # bin edges.
-        self.bins = list(edges)
-        return Histogram((edges, hist), kdims=[element.get_dimension(selected_dim)],
+        if not groupby:
+            self.bins = list(edges)
+        return Histogram((edges, hist), kdims=[dim],
                          label=element.label, **params)
 
 


Test patch:
diff --git a/holoviews/tests/operation/test_operation.py b/holoviews/tests/operation/test_operation.py
--- a/holoviews/tests/operation/test_operation.py
+++ b/holoviews/tests/operation/test_operation.py
@@ -331,6 +331,49 @@ def test_dataset_histogram_empty_explicit_bins(self):
         hist = Histogram(([0, 1, 2], [0, 0]), vdims=('x_count', 'Count'))
         self.assertEqual(op_hist, hist)
 
+    def test_dataset_histogram_groupby_range_shared(self):
+        x = np.arange(10)
+        y = np.arange(10) + 10
+        xy = np.concatenate([x, y])
+        label = ["x"] * 10 + ["y"] * 10
+
+        ds = Dataset(pd.DataFrame([xy, label], index=["xy", "label"]).T, vdims=["xy", "label"])
+        hist = histogram(ds, groupby="label", groupby_range="shared")
+        exp = np.linspace(0, 19, 21)
+        for k, v in hist.items():
+            np.testing.assert_equal(exp, v.data["xy"])
+            sel = np.asarray(label) == k
+            assert sel.sum() == 10
+            assert (v.data["xy_count"][sel] == 1).all()
+            assert (v.data["xy_count"][~sel] == 0).all()
+
+    def test_dataset_histogram_groupby_range_separated(self):
+        x = np.arange(10)
+        y = np.arange(10) + 10
+        xy = np.concatenate([x, y])
+        label = ["x"] * 10 + ["y"] * 10
+
+        ds = Dataset(pd.DataFrame([xy, label], index=["xy", "label"]).T, vdims=["xy", "label"])
+        hist = histogram(ds, groupby="label", groupby_range="separated")
+
+        for idx, v in enumerate(hist):
+            exp = np.linspace(idx * 10, 10 * idx + 9, 21)
+            np.testing.assert_equal(exp, v.data["xy"])
+            assert v.data["xy_count"].sum() == 10
+
+    def test_dataset_histogram_groupby_datetime(self):
+        x = pd.date_range("2020-01-01", periods=100)
+        y = pd.date_range("2020-01-01", periods=100)
+        xy = np.concatenate([x, y])
+        label = ["x"] * 100 + ["y"] * 100
+        ds = Dataset(pd.DataFrame([xy, label], index=["xy", "label"]).T, vdims=["xy", "label"])
+        hist = histogram(ds, groupby="label")
+
+        exp = pd.date_range("2020-01-01", '2020-04-09', periods=21)
+        for h in hist:
+            np.testing.assert_equal(exp, h.data["xy"])
+            assert (h.data["xy_count"] == 5).all()
+
     @da_skip
     def test_dataset_histogram_dask(self):
         import dask.array as da