Repository: holoviz/hvplot

Issue:
datashade kwarg for scatter_matrix
It would be nifty to have `datashade=True` work for the `scatter_matrix` function.  I'm happy to contribute this if someone can point me in the right direction.


Discussion:
In theory you should be able to append ``.map(datashade, hv.Scatter)`` to the gridmatrix call but it would indeed by nice to integrate this.
The latest implementation might just look like this: 

```python
grid = grid.apply(datashade,  per_element=True)
```

Resolving patch:
diff --git a/hvplot/plotting/scatter_matrix.py b/hvplot/plotting/scatter_matrix.py
--- a/hvplot/plotting/scatter_matrix.py
+++ b/hvplot/plotting/scatter_matrix.py
@@ -1,4 +1,6 @@
 from __future__ import absolute_import
+from functools import partial
+import warnings
 
 import holoviews as _hv
 
@@ -11,6 +13,7 @@ def scatter_matrix(data, c=None, chart='scatter', diagonal='hist',
                    alpha=0.5, nonselection_alpha=0.1,
                    tools=None, cmap=None, colormap=None,
                    diagonal_kwds=None, hist_kwds=None, density_kwds=None,
+                   datashade=False, rasterize=False, dynspread=False, spread=False,
                    **kwds):
     """
     Scatter matrix of numeric columns.
@@ -43,7 +46,25 @@ def scatter_matrix(data, c=None, chart='scatter', diagonal='hist',
         Default is `Category10 <https://github.com/d3/d3-3.x-api-reference/blob/master/Ordinal-Scales.md#category10>`.
     diagonal_kwds/hist_kwds/density_kwds: dict, optional
         Keyword options for the diagonal plots
-    kwds: Keyword options for the off-diagonal plots, optional
+    datashade (default=False):
+        Whether to apply rasterization and shading (colormapping) using
+        the Datashader library, returning an RGB object instead of 
+        individual points
+    rasterize (default=False):
+        Whether to apply rasterization using the Datashader library,
+        returning an aggregated Image (to be colormapped by the 
+        plotting backend) instead of individual points
+    dynspread (default=False):
+        For plots generated with datashade=True or rasterize=True, 
+        automatically increase the point size when the data is sparse
+        so that individual points become more visible.
+        kwds supported include ``max_px``, ``threshold``,  ``shape``, ``how`` and ``mask``.
+    spread (default=False):
+        Make plots generated with datashade=True or rasterize=True 
+        increase the point size to make points more visible, by
+        applying a fixed spreading of a certain number of cells/pixels. kwds
+        supported include: ``px``, ``shape``, ``how`` and ``mask``.
+    kwds: Keyword options for the off-diagonal plots and datashader's spreading , optional
 
     Returns:
     --------
@@ -54,6 +75,7 @@ def scatter_matrix(data, c=None, chart='scatter', diagonal='hist',
     --------
         :func:`pandas.plotting.scatter_matrix` : Equivalent pandas function.
     """
+    
     data = _hv.Dataset(data)
     supported = list(HoloViewsConverter._kind_mapping)
     if diagonal not in supported:
@@ -65,6 +87,67 @@ def scatter_matrix(data, c=None, chart='scatter', diagonal='hist',
     diagonal = HoloViewsConverter._kind_mapping[diagonal]
     chart = HoloViewsConverter._kind_mapping[chart]
 
+    if rasterize or datashade:
+        try:
+            import datashader  # noqa
+        except ImportError:
+            raise ImportError("rasterize and datashade require "
+                              "datashader to be installed.")
+        from ..util import hv_version
+        if hv_version <= '1.14.6':
+            warnings.warn(
+                "Versions of holoviews before 1.14.7 did not suppport "
+                "dynamic update of rasterized/datashaded scatter matrix. "
+                "Update holoviews to a newer version."
+            )
+    
+    if rasterize and datashade:
+        raise ValueError("Choose to either rasterize or "
+                         "datashade the scatter matrix, not both.")
+
+    if not rasterize and not datashade and (spread or dynspread):
+        raise ValueError("dynspread or spread need rasterize "
+                         "or datashade to be set to True.")
+
+    if rasterize:
+        import holoviews.operation.datashader as hd
+        if dynspread or spread:
+            if hd.ds_version < '0.12.0':
+                raise RuntimeError(
+                    'Any version of datashader less than 0.12.0 does '
+                    'not support rasterize with dynspread or spread.')
+
+    #remove datashade kwds
+    if datashade or rasterize:
+        import holoviews.operation.datashader as hd 
+
+        ds_kwds = {}
+        if 'aggregator' in kwds:
+            ds_kwds['aggregator'] = kwds.pop('aggregator')
+
+    #remove dynspread kwds
+    sp_kwds = {}
+    if dynspread:
+        if 'max_px' in kwds:
+            sp_kwds['max_px'] = kwds.pop('max_px')
+        if 'threshold' in kwds:
+            sp_kwds['threshold'] = kwds.pop('threshold')
+        if 'shape' in kwds:
+            sp_kwds['shape'] = kwds.pop('shape')
+        if 'how' in kwds:
+            sp_kwds['how'] = kwds.pop('how')
+        if 'mask' in kwds:
+            sp_kwds['mask'] = kwds.pop('mask')
+    if spread:
+        if 'px' in kwds:
+            sp_kwds['px'] = kwds.pop('px')
+        if 'shape' in kwds:
+            sp_kwds['shape'] = kwds.pop('shape')
+        if 'how' in kwds:
+            sp_kwds['how'] = kwds.pop('how')
+        if 'mask' in kwds:
+            sp_kwds['mask'] = kwds.pop('mask')
+
     if cmap and colormap:
         raise TypeError("Only specify one of `cmap` and `colormap`.")
     colors = cmap or colormap or _hv.plotting.util.process_cmap('Category10', categorical=True)
@@ -72,13 +155,20 @@ def scatter_matrix(data, c=None, chart='scatter', diagonal='hist',
     chart_opts = dict(alpha=alpha, cmap=colors, tools=tools,
                       nonselection_alpha=nonselection_alpha, **kwds)
 
+    #get initial scatter matrix.  No color.
     grid = _hv.operation.gridmatrix(data, diagonal_type=diagonal, chart_type=chart)
+
     if c:
-        chart_opts['color_index'] = c
+        #change colors for scatter matrix
+        chart_opts['color'] = c
+        # Add color vdim to each plot.
         grid = grid.map(lambda x: x.clone(vdims=x.vdims+[c]), 'Scatter')
+        # create a new scatter matrix with groups for each catetory, so now the histogram will
+        # show separate colors for each group.
         groups = _hv.operation.gridmatrix(data.groupby(c).overlay(),
                                           chart_type=chart,
                                           diagonal_type=diagonal)
+        # take the correct layer from each Overlay object within the scatter matrix.
         grid = (grid * groups).map(lambda x: x.get(0) if isinstance(x.get(0), chart) else x.get(1),
                                    _hv.Overlay)
 
@@ -89,5 +179,18 @@ def scatter_matrix(data, c=None, chart='scatter', diagonal='hist',
                         '`density_kwds`.')
 
     diagonal_kwds = diagonal_kwds or hist_kwds or density_kwds or {}
+    # set the histogram colors 
     diagonal_opts = dict(fill_color=_hv.Cycle(values=colors), **diagonal_kwds)
-    return grid.options({chart.__name__: chart_opts, diagonal.__name__: diagonal_opts})
+    # actually changing to the same color scheme for both scatter and histogram plots.
+    grid = grid.options({chart.__name__: chart_opts, diagonal.__name__: diagonal_opts})
+    
+    # Perform datashade options after all the coloring is finished.
+    if datashade or rasterize:
+        aggregatefn = hd.datashade if datashade else hd.rasterize
+        grid = grid.map(partial(aggregatefn, **ds_kwds), specs=chart)
+        if spread or dynspread:
+            spreadfn = hd.dynspread if dynspread else (hd.spread if spread else lambda z, **_: z)
+            eltype = _hv.RGB if datashade else _hv.Image
+            grid = grid.map(partial(spreadfn, **sp_kwds), specs=eltype)
+
+    return grid
diff --git a/hvplot/ui.py b/hvplot/ui.py
--- a/hvplot/ui.py
+++ b/hvplot/ui.py
@@ -10,7 +10,7 @@
 
 kinds = set(hvConverter._kind_mapping) - set(hvConverter._gridded_types)
 COLORMAPS = [cm for cm in list_cmaps() if not cm.endswith('_r_r')]
-MAX_ROWS = 10_000
+MAX_ROWS = 10000
 
 
 class Controls(Viewer):


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -19,7 +19,7 @@ jobs:
         exclude:
           - os: windows-latest
             python-version: 2.7
-    timeout-minutes: 30
+    timeout-minutes: 60
     defaults:
       run:
         shell: bash -l {0} 
@@ -57,6 +57,12 @@ jobs:
           conda activate test-environment
           conda list
           doit develop_install -c pyviz/label/dev -o tests
+      - name: Handling the decorator package on ubuntu and python 2.7
+        if: matrix.os == 'ubuntu-latest' && startsWith(matrix.python-version, 2.)
+        run: |
+          eval "$(conda shell.bash hook)"
+          conda activate test-environment
+          conda install -c pyviz/label/dev 'decorator=4.4.2'
       - name: doit develop_install py3
         if: startsWith(matrix.python-version, 3.)
         run: |
diff --git a/hvplot/tests/plotting/__init__.py b/hvplot/tests/plotting/__init__.py
new file mode 100644
diff --git a/hvplot/tests/plotting/testscattermatrix.py b/hvplot/tests/plotting/testscattermatrix.py
new file mode 100644
--- /dev/null
+++ b/hvplot/tests/plotting/testscattermatrix.py
@@ -0,0 +1,139 @@
+from unittest import TestCase, SkipTest
+import sys
+
+from parameterized import parameterized
+import numpy as np
+import pandas as pd
+
+from holoviews.core import GridMatrix, NdOverlay
+from holoviews.element import (
+    Bivariate,
+    Distribution,
+    HexTiles,
+    Histogram,
+    Scatter,
+)
+from hvplot import scatter_matrix
+
+class TestScatterMatrix(TestCase):
+
+    def setUp(self):
+        self.df = pd.DataFrame(np.random.randn(1000, 4), columns=['a', 'b', 'c', 'd'])
+
+    def test_returns_gridmatrix(self):
+        sm = scatter_matrix(self.df)
+        self.assertIsInstance(sm, GridMatrix)
+
+    def test_wrong_diagonal(self):
+        with self.assertRaises(ValueError):
+            scatter_matrix(self.df, diagonal='wrong')
+
+    def test_wrong_chart(self):
+        with self.assertRaises(ValueError):
+            scatter_matrix(self.df, chart='wrong')
+
+    def test_diagonal_default(self):
+        sm = scatter_matrix(self.df)
+        self.assertIsInstance(sm['a', 'a'], Histogram)
+    
+    def test_offdiagonal_default(self):
+        sm = scatter_matrix(self.df)
+        self.assertIsInstance(sm['a', 'b'], Scatter)
+
+    def test_diagonal_kde(self):
+        sm = scatter_matrix(self.df, diagonal='kde')
+        self.assertIsInstance(sm['a', 'a'], Distribution)
+    
+    def test_offdiagonal_bivariate(self):
+        sm = scatter_matrix(self.df, chart='bivariate')
+        self.assertIsInstance(sm['a', 'b'], Bivariate)
+    
+    def test_offdiagonal_hexbin(self):
+        sm = scatter_matrix(self.df, chart='hexbin')
+        self.assertIsInstance(sm['a', 'b'], HexTiles)
+
+    def test_diagonal_kwargs_mutually_exclusive(self):
+        with self.assertRaises(TypeError):
+            scatter_matrix(self.df, diagonal_kwds=dict(a=1), hist_kwds=dict(a=1))
+        with self.assertRaises(TypeError):
+            scatter_matrix(self.df, diagonal_kwds=dict(a=1), density_kwds=dict(a=1))
+        with self.assertRaises(TypeError):
+            scatter_matrix(self.df, density_kwds=dict(a=1), hist_kwds=dict(a=1))
+
+    def test_diagonal_kwargs(self):
+        sm = scatter_matrix(self.df, diagonal_kwds=dict(line_color='red'))
+        self.assertEqual(sm['a', 'a'].opts.get().kwargs['line_color'], 'red')
+
+    def test_c(self):
+        df = self.df.copy(deep=True)
+        df['e'] = np.random.choice(list('xyz'), size=len(df))
+        sm = scatter_matrix(df, c='e')
+        
+        self.assertIsInstance(sm['a', 'a'], NdOverlay)
+        diag_kdims = sm['a', 'a'].kdims
+        self.assertEqual(len(diag_kdims), 1)
+        self.assertEqual(diag_kdims[0].name, 'e')
+        
+        self.assertIsInstance(sm['a', 'b'], Scatter)
+        offdiag_vdims = sm['a', 'b'].vdims
+        self.assertTrue('e' in (d.name for d in offdiag_vdims))
+
+
+class TestDatashader(TestCase):
+
+    def setUp(self):
+        try:
+            import datashader # noqa
+        except:
+            raise SkipTest('Datashader not available')
+        if sys.maxsize < 2**32:
+            raise SkipTest('Datashader does not support 32-bit systems')
+        self.df = pd.DataFrame(np.random.randn(1000, 3), columns=['a', 'b', 'c'])
+
+    def test_rasterize_datashade_mutually_exclusive(self):
+        with self.assertRaises(ValueError):
+            scatter_matrix(self.df, rasterize=True, datashade=True)
+
+    def test_spread_but_no_rasterize_or_datashade(self):
+        with self.assertRaises(ValueError):
+            scatter_matrix(self.df, dynspread=True)
+        with self.assertRaises(ValueError):
+            scatter_matrix(self.df, spread=True)
+        with self.assertRaises(ValueError):
+            scatter_matrix(self.df, dynspread=True, spread=True)
+
+    @parameterized.expand([('rasterize',), ('datashade',)])
+    def test_rasterization(self, operation):
+        sm = scatter_matrix(self.df, **{operation: True})
+        dm = sm['a', 'b']
+        self.assertEqual(dm.callback.operation.name, operation)
+        dm[()]
+        self.assertEqual(len(dm.last.pipeline.operations), 3)
+
+    @parameterized.expand([('rasterize',), ('datashade',)])
+    def test_datashade_aggregator(self, operation):
+        sm = scatter_matrix(self.df, aggregator='mean', **{operation: True})
+        dm = sm['a', 'b']
+        dm[()]
+        self.assertEqual(dm.last.pipeline.operations[-1].aggregator, 'mean')
+
+    @parameterized.expand([('spread',), ('dynspread',)])
+    def test_spread_rasterize(self, operation):
+        sm = scatter_matrix(self.df, rasterize=True, **{operation: True})
+        dm = sm['a', 'b']
+        dm[()]
+        self.assertEqual(len(dm.last.pipeline.operations), 4)
+
+    @parameterized.expand([('spread',), ('dynspread',)])
+    def test_spread_datashade(self, operation):
+        sm = scatter_matrix(self.df, datashade=True, **{operation: True})
+        dm = sm['a', 'b']
+        dm[()]
+        self.assertEqual(len(dm.last.pipeline.operations), 4)
+
+    @parameterized.expand([('spread',), ('dynspread',)])
+    def test_spread_kwargs(self, operation):
+        sm = scatter_matrix(self.df, datashade=True, **{operation: True, 'shape': 'circle'})
+        dm = sm['a', 'b']
+        dm[()]
+        self.assertEqual(dm.last.pipeline.operations[-1].args[0].keywords['shape'], 'circle')