Repository: holoviz/holoviews

Issue:
Histogram doesn't accept bins="auto"

#### All software version info
* hvplot 0.5.2, holoviews 1.13.2
* Pandas 1.0.2
* Numpy 1.18.1

#### Description of expected behavior and the observed behavior

NumPy's histogram function accepts the keyword argument `bins="auto"`. I would expect the same from Holoviews (especially because it looks like Holoviews relies on `np.histogram`).

However, Holoviews throws an error when `df.plot(..., kind="hist", bins="auto")` is called.


#### Complete, minimal, self-contained example code that reproduces the issue

``` python
import pandas as pd
import hvplot.pandas
import numpy as np
df = pd.DataFrame({"x": np.random.uniform(size=200)})
df["x"].hvplot(kind="hist", bins=40)  # works
df["x"].hvplot(kind="hist", bins="auto")  # throws the error
```

#### Stack traceback and/or browser JavaScript console output

Here's the error:

``` 
~/anaconda3/lib/python3.7/site-packages/numpy/lib/histograms.py in _get_bin_edges(a, bins, range, weights)
    423         except TypeError:
    424             raise TypeError(
--> 425                 '`bins` must be an integer, a string, or an array')
    426         if n_equal_bins < 1:
    427             raise ValueError('`bins` must be positive, when an integer')

TypeError: `bins` must be an integer, a string, or an array
```

Here's the complete traceback:

<details>

``` 
---------------------------------------------------------------------
TypeError                           Traceback (most recent call last)
~/anaconda3/lib/python3.7/site-packages/numpy/lib/histograms.py in _get_bin_edges(a, bins, range, weights)
    421         try:
--> 422             n_equal_bins = operator.index(bins)
    423         except TypeError:

TypeError: only integer scalar arrays can be converted to a scalar index

During handling of the above exception, another exception occurred:

TypeError                           Traceback (most recent call last)
<ipython-input-51-108281d3bae9> in <module>
----> 1 df["x"].hvplot(kind="hist", bins="auto")  # fails

~/anaconda3/lib/python3.7/site-packages/hvplot/plotting/core.py in __call__(self, x, y, kind, **kwds)
     70                 return pn.panel(plot, **panel_dict)
     71 
---> 72         return self._get_converter(x, y, kind, **kwds)(kind, x, y)
     73 
     74     def _get_converter(self, x=None, y=None, kind=None, **kwds):

~/anaconda3/lib/python3.7/site-packages/hvplot/converter.py in __call__(self, kind, x, y)
    942                 obj = DynamicMap(cbcallable, streams=[self.stream])
    943             else:
--> 944                 obj = method(x, y)
    945 
    946         if self.crs and self.project:

~/anaconda3/lib/python3.7/site-packages/hvplot/converter.py in hist(self, x, y, data)
   1388                 hists = hists.layout() if self.subplots else hists.overlay()
   1389             else:
-> 1390                 hists = histogram(ds, dimension=y, **hist_opts)
   1391 
   1392             return hists.opts(opts).redim(**self._redim)

~/anaconda3/lib/python3.7/site-packages/param/parameterized.py in __new__(class_, *args, **params)
   2810         inst = class_.instance()
   2811         inst.param._set_name(class_.__name__)
-> 2812         return inst.__call__(*args,**params)
   2813 
   2814     def __call__(self,*args,**kw):

~/anaconda3/lib/python3.7/site-packages/holoviews/core/operation.py in __call__(self, element, **kwargs)
    194             kwargs['streams'] = self.p.streams
    195         kwargs['per_element'] = self._per_element
--> 196         return element.apply(self, **kwargs)
    197 
    198 

~/anaconda3/lib/python3.7/site-packages/holoviews/core/accessors.py in pipelined_call(*args, **kwargs)
     43 
     44             try:
---> 45                 result = __call__(*args, **kwargs)
     46 
     47                 if not in_method:

~/anaconda3/lib/python3.7/site-packages/holoviews/core/accessors.py in __call__(self, apply_function, streams, link_inputs, dynamic, per_element, **kwargs)
    196             if hasattr(apply_function, 'dynamic'):
    197                 inner_kwargs['dynamic'] = False
--> 198             return apply_function(self._obj, **inner_kwargs)
    199         elif self._obj._deep_indexable:
    200             mapped = []

~/anaconda3/lib/python3.7/site-packages/holoviews/core/operation.py in __call__(self, element, **kwargs)
    190             elif ((self._per_element and isinstance(element, Element)) or
    191                   (not self._per_element and isinstance(element, ViewableElement))):
--> 192                 return self._apply(element)
    193         elif 'streams' not in kwargs:
    194             kwargs['streams'] = self.p.streams

~/anaconda3/lib/python3.7/site-packages/holoviews/core/operation.py in _apply(self, element, key)
    130         element_pipeline = getattr(element, '_pipeline', None)
    131 
--> 132         ret = self._process(element, key)
    133         for hook in self._postprocess_hooks:
    134             ret = hook(self, ret, **kwargs)

~/anaconda3/lib/python3.7/site-packages/holoviews/operation/element.py in _process(self, element, key)
    753                     hist /= hist.max()
    754             else:
--> 755                 hist, edges = histogram(data, normed=normed, weights=weights, bins=edges)
    756                 if self.p.weight_dimension and self.p.mean_weighted:
    757                     hist_mean, _ = histogram(data, density=False, bins=self.p.num_bins)

<__array_function__ internals> in histogram(*args, **kwargs)

~/anaconda3/lib/python3.7/site-packages/numpy/lib/histograms.py in histogram(a, bins, range, normed, weights, density)
    793     a, weights = _ravel_and_check_weights(a, weights)
    794 
--> 795     bin_edges, uniform_bins = _get_bin_edges(a, bins, range, weights)
    796 
    797     # Histogram is an integer or a float array depending on the weights.

~/anaconda3/lib/python3.7/site-packages/numpy/lib/histograms.py in _get_bin_edges(a, bins, range, weights)
    423         except TypeError:
    424             raise TypeError(
--> 425                 '`bins` must be an integer, a string, or an array')
    426         if n_equal_bins < 1:
    427             raise ValueError('`bins` must be positive, when an integer')

TypeError: `bins` must be an integer, a string, or an array
```

</details>



Discussion:

Resolving patch:
diff --git a/holoviews/core/util.py b/holoviews/core/util.py
--- a/holoviews/core/util.py
+++ b/holoviews/core/util.py
@@ -1491,6 +1491,13 @@ def is_dask_array(data):
     return (da is not None and isinstance(data, da.Array))
 
 
+def is_cupy_array(data):
+    if 'cupy' in sys.modules:
+        import cupy
+        return isinstance(data, cupy.ndarray)
+    return False
+
+
 def get_param_values(data):
     params = dict(kdims=data.kdims, vdims=data.vdims,
                   label=data.label)
diff --git a/holoviews/operation/element.py b/holoviews/operation/element.py
--- a/holoviews/operation/element.py
+++ b/holoviews/operation/element.py
@@ -4,9 +4,11 @@
 """
 from __future__ import division
 
-import numpy as np
+from distutils.version import LooseVersion
 
+import numpy as np
 import param
+
 from param import _is_number
 
 from ..core import (Operation, NdOverlay, Overlay, GridMatrix,
@@ -14,7 +16,7 @@
 from ..core.data import ArrayInterface, DictInterface, default_datatype
 from ..core.util import (group_sanitizer, label_sanitizer, pd,
                          basestring, datetime_types, isfinite, dt_to_int,
-                         isdatetime, is_dask_array)
+                         isdatetime, is_dask_array, is_cupy_array)
 from ..element.chart import Histogram, Scatter
 from ..element.raster import Image, RGB
 from ..element.path import Contours, Polygons
@@ -641,7 +643,10 @@ class histogram(Operation):
       Specifies the range within which to compute the bins.""")
 
     bins = param.ClassSelector(default=None, class_=(np.ndarray, list, tuple, str), doc="""
-      An explicit set of bin edges.""")
+      An explicit set of bin edges or a method to find the optimal
+      set of bin edges, e.g. 'auto', 'fd', 'scott' etc. For more
+      documentation on these approaches see the np.histogram_bin_edges
+      documentation.""")
 
     cumulative = param.Boolean(default=False, doc="""
       Whether to compute the cumulative histogram""")
@@ -690,6 +695,7 @@ def _process(self, element, key=None):
             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
         if self.p.dimension:
             selected_dim = self.p.dimension
         else:
@@ -701,16 +707,36 @@ def _process(self, element, key=None):
         else:
             data = element.dimension_values(selected_dim)
 
+        is_datetime = isdatetime(data)
+        if is_datetime:
+            data = data.astype('datetime64[ns]').astype('int64')
+
+        # Handle different datatypes
+        is_finite = isfinite
+        is_cupy = is_cupy_array(data)
+        if is_cupy:
+            import cupy
+            full_cupy_support = LooseVersion(cupy.__version__) > '8.0'
+            if not full_cupy_support and (normed or self.p.weight_dimension): 
+                data = cupy.asnumpy(data)
+                is_cupy = False
         if is_dask_array(data):
             import dask.array as da
             histogram = da.histogram
+        elif is_cupy:
+            import cupy
+            histogram = cupy.histogram
+            is_finite = cupy.isfinite
         else:
             histogram = np.histogram
 
-        mask = isfinite(data)
+        # Mask data
+        mask = is_finite(data)
         if self.p.nonzero:
             mask = mask & (data > 0)
         data = data[mask]
+
+        # Compute weights
         if self.p.weight_dimension:
             if hasattr(element, 'interface'):
                 weights = element.interface.values(element, self.p.weight_dimension, compute=False)
@@ -721,35 +747,36 @@ def _process(self, element, key=None):
         else:
             weights = None
 
-        hist_range = self.p.bin_range or element.range(selected_dim)
-        # Avoids range issues including zero bin range and empty bins
-        if hist_range == (0, 0) or any(not isfinite(r) for r in hist_range):
-            hist_range = (0, 1)
-
-        datetimes = False
-        bins = None if self.p.bins is None else np.asarray(self.p.bins)
-        steps = self.p.num_bins + 1
-        start, end = hist_range
-        if isdatetime(data):
-            start, end = dt_to_int(start, 'ns'), dt_to_int(end, 'ns')
-            datetimes = True
-            data = data.astype('datetime64[ns]').astype('int64')
-            if bins is not None:
-                bins = bins.astype('datetime64[ns]').astype('int64')
+        # Compute bins
+        if isinstance(self.p.bins, str):
+            bin_data = cupy.asnumpy(data) if is_cupy else data
+            edges = np.histogram_bin_edges(bin_data, bins=self.p.bins)
+        elif isinstance(self.p.bins, (list, np.ndarray)):
+            edges = self.p.bins
+            if isdatetime(edges):
+                edges = edges.astype('datetime64[ns]').astype('int64')
+        else:
+            hist_range = self.p.bin_range or element.range(selected_dim)
+            # Avoids range issues including zero bin range and empty bins
+            if hist_range == (0, 0) or any(not isfinite(r) for r in hist_range):
+                hist_range = (0, 1)
+            steps = self.p.num_bins + 1
+            start, end = hist_range
+            if is_datetime:
+                start, end = dt_to_int(start, 'ns'), dt_to_int(end, 'ns')
+            if self.p.log:
+                bin_min = max([abs(start), data[data>0].min()])
+                edges = np.logspace(np.log10(bin_min), np.log10(end), steps)
             else:
-                hist_range = start, end
+                edges = np.linspace(start, end, steps)
 
-        if self.p.bins:
-            edges = bins
-        elif self.p.log:
-            bin_min = max([abs(start), data[data>0].min()])
-            edges = np.logspace(np.log10(bin_min), np.log10(end), steps)
-        else:
-            edges = np.linspace(start, end, steps)
-        normed = False if self.p.mean_weighted and self.p.weight_dimension else self.p.normed
+        if is_cupy:
+            edges = cupy.asarray(edges)
 
         if is_dask_array(data) or len(data):
-            if normed:
+            if is_cupy and not full_cupy_support:
+               hist, _ = histogram(data, bins=edges)
+            elif normed:
                 # This covers True, 'height', 'integral'
                 hist, edges = histogram(data, density=True,
                                         weights=weights, bins=edges)
@@ -763,8 +790,13 @@ def _process(self, element, key=None):
         else:
             nbins = self.p.num_bins if self.p.bins is None else len(self.p.bins)-1
             hist = np.zeros(nbins)
+
+        if is_cupy_array(hist):
+            edges = cupy.asnumpy(edges) 
+            hist = cupy.asnumpy(hist)
+
         hist[np.isnan(hist)] = 0
-        if datetimes:
+        if is_datetime:
             edges = (edges/1e3).astype('datetime64[us]')
 
         params = {}
diff --git a/holoviews/plotting/bokeh/stats.py b/holoviews/plotting/bokeh/stats.py
--- a/holoviews/plotting/bokeh/stats.py
+++ b/holoviews/plotting/bokeh/stats.py
@@ -13,7 +13,8 @@
 from ...core.dimension import Dimension, Dimensioned
 from ...core.ndmapping import sorted_context
 from ...core.util import (basestring, dimension_sanitizer, wrap_tuple,
-                          unique_iterator, unique_array, isfinite)
+                          unique_iterator, unique_array, isfinite,
+                          is_dask_array, is_cupy_array)
 from ...operation.stats import univariate_kde
 from ...util.transform import dim
 from .chart import AreaPlot
@@ -139,11 +140,23 @@ def _postprocess_hover(self, renderer, source):
         super(BoxWhiskerPlot, self)._postprocess_hover(renderer, source)
 
     def _box_stats(self, vals):
-        vals = vals[isfinite(vals)]
+        is_finite = isfinite
+        is_dask = is_dask_array(vals)
+        is_cupy = is_cupy_array(vals)
+        if is_cupy:
+            import cupy
+            percentile = cupy.percentile
+            is_finite = cupy.isfinite
+        elif is_dask:
+            import dask.array as da
+            percentile = da.percentile
+        else:
+            percentile = np.percentile
+
+        vals = vals[is_finite(vals)]
 
         if len(vals):
-            q1, q2, q3 = (np.percentile(vals, q=q)
-                          for q in range(25, 100, 25))
+            q1, q2, q3 = (percentile(vals, q=q) for q in range(25, 100, 25))
             iqr = q3 - q1
             upper = vals[vals <= q3 + 1.5*iqr].max()
             lower = vals[vals >= q1 - 1.5*iqr].min()
@@ -151,7 +164,14 @@ def _box_stats(self, vals):
             q1, q2, q3 = 0, 0, 0
             upper, lower = 0, 0
         outliers = vals[(vals > upper) | (vals < lower)]
-        return q1, q2, q3, upper, lower, outliers
+
+        if is_cupy:
+            return (q1.item(), q2.item(), q3.item(), upper.item(),
+                    lower.item(), cupy.asnumpy(outliers))
+        elif is_dask:
+            return da.compute(q1, q2, q3, upper, lower, outliers)
+        else:
+            return q1, q2, q3, upper, lower, outliers
 
     def get_data(self, element, ranges, style):
         if element.kdims:
@@ -191,6 +211,7 @@ def get_data(self, element, ranges, style):
             cdim, cidx = None, None
 
         factors = []
+        vdim = element.vdims[0].name
         for key, g in groups.items():
             # Compute group label
             if element.kdims:
@@ -208,7 +229,7 @@ def get_data(self, element, ranges, style):
                 factors.append(label)
 
             # Compute statistics
-            vals = g.dimension_values(g.vdims[0])
+            vals = g.interface.values(g, vdim, compute=False)
             q1, q2, q3, upper, lower, outliers = self._box_stats(vals)
 
             # Add to CDS data


Test patch: