diff --git a/doc/ref/api/manual/hvplot.hvPlot.heatmap.ipynb b/doc/ref/api/manual/hvplot.hvPlot.heatmap.ipynb index 4515ef177..dae52260c 100644 --- a/doc/ref/api/manual/hvplot.hvPlot.heatmap.ipynb +++ b/doc/ref/api/manual/hvplot.hvPlot.heatmap.ipynb @@ -51,6 +51,28 @@ "df.hvplot.heatmap(x=\"cat1\", y=\"cat2\", C=\"values\", reduce_function=np.mean)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Counting occurrences\n", + "\n", + "Leaving `C` unset counts the number of rows falling in each `(x, y)` cell, so no value field or `reduce_function` is needed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import hvplot.pandas # noqa\n", + "\n", + "df = hvplot.sampledata.earthquakes('pandas')\n", + "\n", + "df.hvplot.heatmap(x='mag_class', y='depth_class')" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/hvplot/converter.py b/hvplot/converter.py index f6918b7a6..fea9d99bc 100644 --- a/hvplot/converter.py +++ b/hvplot/converter.py @@ -2988,20 +2988,49 @@ def heatmap(self, x=None, y=None, data=None): data = self.data if data is None else data cur_opts, compat_opts = self._get_compat_opts('HeatMap') + counted = False if not (x or y) or (x == 'columns' and y in ('index', data.index.name)): cur_opts['labelled'] = [] x, y = 'columns', 'index' data = (data.columns, data.index, data.values) z = ['value'] else: - z = self.kwds.get('C', next(c for c in data.columns if c not in (x, y))) - z = [z, *self.hover_cols] + C = self.kwds.get('C') self.use_index = False + # Derived dimensions such as 'time.hour' only become real columns here, + # so any aggregation has to happen afterwards. data, x, y = self._process_chart_args(data, x, y, single_y=True) + if C is None: + ignored = [ + name + for name, given in ( + ('hover_cols', self.hover_cols), + ('reduce_function', 'reduce_function' in self.kwds), + ) + if given + ] + if ignored: + warnings.warn( + f'{" and ".join(ignored)} ignored because C is not set, as each ' + 'cell holds a row count rather than the values behind it. ' + 'Set C to use them.', + stacklevel=_find_stack_level(), + ) + # observed=True keeps categorical x/y from expanding to the full + # product of categories, which is mostly empty cells and can dwarf + # the data. Passing it explicitly also avoids relying on the pandas + # default, which is due to change. + data = data.groupby([x, y], observed=True).size().to_frame('Count').reset_index() + z = ['Count'] + counted = True + else: + z = [C, *self.hover_cols] redim = self._merge_redim({z[0]: self._dim_ranges['c']}) hmap = HeatMap(data, [x, y], z, **self._relabel) - if 'reduce_function' in self.kwds: + # Counting already reduced each cell to one row, so reducing again would + # just measure that single row (np.size would flatten every count to 1). + if 'reduce_function' in self.kwds and not counted: hmap = hmap.aggregate(function=self.kwds['reduce_function']) return redim_(hmap, **redim).apply( self._set_backends_opts, cur_opts=cur_opts, compat_opts=compat_opts diff --git a/hvplot/plotting/core.py b/hvplot/plotting/core.py index 3a601d4a6..3a99d6855 100644 --- a/hvplot/plotting/core.py +++ b/hvplot/plotting/core.py @@ -637,14 +637,17 @@ def heatmap(self, x=None, y=None, C=None, colorbar=True, logz=False, **kwds): which can be explicitly declared by setting y to ``'index'`` or to the index name. Can refer to continuous and categorical data. C : str, optional - Field to draw heatmap color from. If not specified a simple count will be used. + Field to draw heatmap color from. If not specified, the number of rows + falling in each ``(x, y)`` cell is counted and shown as ``'Count'``. colorbar : bool, default True Whether to display a colorbar. logz : bool, default False Whether to apply log scaling to the z-axis. reduce_function : function, optional Function to compute statistics for heatmap, for example ``np.mean``. - If omitted, no aggregation is applied and duplicate values are dropped. + If omitted, no aggregation is applied to ``C`` and duplicate values are + dropped. Ignored when ``C`` is not set, since each cell then holds a row + count rather than the values behind it. **kwds : optional Additional keyword arguments are documented in :ref:`plot-options`. Run ``hvplot.help('heatmap')`` for the full method documentation. diff --git a/hvplot/tests/testcharts.py b/hvplot/tests/testcharts.py index 9c6a57027..ba821db80 100644 --- a/hvplot/tests/testcharts.py +++ b/hvplot/tests/testcharts.py @@ -27,6 +27,14 @@ def setUp(self): 'temp': np.sin(np.linspace(0, 5 * 2 * np.pi, 5 * 24)).cumsum(), } ) + # Repeated (x, y) pairs with deliberately uneven counts per cell. + self.dup_df = pd.DataFrame( + { + 'x': ['a', 'a', 'a', 'b', 'b', 'c'], + 'y': ['p', 'p', 'q', 'q', 'q', 'p'], + 'value': [1, 2, 3, 4, 5, 6], + } + ) @parameterized.expand([('points', Points), ('paths', Path)]) def test_2d_defaults(self, kind, element): @@ -74,6 +82,42 @@ def test_heatmap_2d_derived_x_and_y(self): assert plot.kdims == ['time.hour', 'time.day'] assert plot.vdims == ['temp'] + def test_heatmap_counts_when_C_not_set(self): + plot = self.dup_df.hvplot.heatmap(x='x', y='y') + assert plot.vdims == ['Count'] + counts = {(r['x'], r['y']): r['Count'] for r in plot.dframe().to_dict('records')} + assert counts == {('a', 'p'): 2, ('a', 'q'): 1, ('b', 'q'): 2, ('c', 'p'): 1} + + def test_heatmap_counts_match_between_call_styles(self): + self.assertEqual( + self.dup_df.hvplot.heatmap(x='x', y='y'), + self.dup_df.hvplot(x='x', y='y', kind='heatmap'), + ) + + def test_heatmap_counts_categorical_x_and_y(self): + categorical_df = self.dup_df.astype({'x': 'category', 'y': 'category'}) + plot = categorical_df.hvplot.heatmap(x='x', y='y') + counts = {(r['x'], r['y']): r['Count'] for r in plot.dframe().to_dict('records')} + # Only the pairs present in the data, not the full product of categories, + # which would be mostly empty cells for high cardinality columns. + assert counts == {('a', 'p'): 2, ('a', 'q'): 1, ('b', 'q'): 2, ('c', 'p'): 1} + + def test_heatmap_warns_options_ignored_when_C_not_set(self): + with pytest.warns(UserWarning, match='hover_cols'): + self.dup_df.hvplot.heatmap(x='x', y='y', hover_cols=['value']) + with pytest.warns(UserWarning, match='reduce_function'): + self.dup_df.hvplot.heatmap(x='x', y='y', reduce_function=np.size) + + def test_heatmap_counts_ignore_reduce_function(self): + # np.size would otherwise flatten every count to 1. + plot = self.dup_df.hvplot.heatmap(x='x', y='y', reduce_function=np.size) + counts = {(r['x'], r['y']): r['Count'] for r in plot.dframe().to_dict('records')} + assert counts == {('a', 'p'): 2, ('a', 'q'): 1, ('b', 'q'): 2, ('c', 'p'): 1} + + def test_heatmap_C_field_still_takes_precedence(self): + plot = self.dup_df.hvplot.heatmap(x='x', y='y', C='value') + assert plot.vdims == ['value'] + def test_xarray_dataset_with_attrs(self): try: import xarray as xr @@ -102,6 +146,7 @@ def setUp(self): self.df = dd.from_pandas(self.df, npartitions=2) self.cat_df = dd.from_pandas(self.cat_df, npartitions=3) + self.dup_df = dd.from_pandas(self.dup_df, npartitions=2) @expectedFailure def test_heatmap_2d_index_columns(self):