Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add operation for group-wise normalisation #6124

Merged
merged 12 commits into from
Apr 23, 2024
62 changes: 61 additions & 1 deletion holoviews/operation/normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
normalizations result in transformations to the stored data within
each element.
"""
from collections import defaultdict

import numpy as np
import param

from ..core import Overlay
from ..core.operation import Operation
from ..core.util import match_spec
from ..element import Raster
from ..element import Chart, Raster


class Normalization(Operation):
Expand Down Expand Up @@ -175,3 +177,61 @@ def _normalize_raster(self, raster, key):
if range:
norm_raster.data[:,:,depth] /= range
return norm_raster


class subcoordinate_group_ranges(Operation):
"""
Compute the data range group-wise in a subcoordinate_y overlay,
and set the dimension range of each Chart element based on the
value computed for its group.

This operation is useful to visually apply a group-wise min-max
normalisation.
"""

def _process(self, overlay, key=None):
# If there are groups AND there are subcoordinate_y elements without a group.
if any(el.group != type(el).__name__ for el in overlay) and any(
el.opts.get('plot').kwargs.get('subcoordinate_y', False)
and el.group == type(el).__name__
for el in overlay
):
self.param.warning(
'The subcoordinate_y overlay contains elements with a defined group, each '
'subcoordinate_y element in the overlay must have a defined group.'
)

vmins = defaultdict(list)
vmaxs = defaultdict(list)
include_chart = False
for el in overlay:
# Only applies to Charts.
# `group` is the Element type per default (e.g. Curve, Spike).
if not isinstance(el, Chart) or el.group == type(el).__name__:
continue
if not el.opts.get('plot').kwargs.get('subcoordinate_y', False):
self.param.warning(
f"All elements in group {el.group!r} must set the option "
f"'subcoordinate_y=True'. Not found for: {el}"
)
vmin, vmax = el.range(1)
vmins[el.group].append(vmin)
vmaxs[el.group].append(vmax)
include_chart = True

if not include_chart or not vmins:
return overlay

minmax = {
group: (np.min(vmins[group]), np.max(vmaxs[group]))
for group in vmins
}
new = []
for el in overlay:
if not isinstance(el, Chart):
new.append(el)
continue
y_dimension = el.vdims[0]
philippjfr marked this conversation as resolved.
Show resolved Hide resolved
y_dimension = y_dimension.clone(range=minmax[el.group])
new.append(el.redim(**{y_dimension.name: y_dimension}))
return overlay.clone(data=new)
philippjfr marked this conversation as resolved.
Show resolved Hide resolved
7 changes: 6 additions & 1 deletion holoviews/plotting/bokeh/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -632,10 +632,15 @@ def _axis_props(self, plots, subplots, element, ranges, pos, *, dim=None,

range_el = el if self.batched and not isinstance(self, OverlayPlot) else element

if pos == 1 and 'subcoordinate_y' in range_tags_extras and dim and dim.range != (None, None):
dims = [dim]
v0, v1 = dim.range
axis_label = str(dim)
specs = ((dim.name, dim.label, dim.unit),)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically ylim should be supported as well...I'll open an issue about this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heya, have you opened an issue about it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An issue about this bit of code once this PR is merged ;-p

# For y-axes check if we explicitly passed in a dimension.
# This is used by certain plot types to create an axis from
# a synthetic dimension and exclusively supported for y-axes.
if pos == 1 and dim:
elif pos == 1 and dim:
dims = [dim]
v0, v1 = util.max_range([
elrange.get(dim.name, {'combined': (None, None)})['combined']
Expand Down
31 changes: 31 additions & 0 deletions holoviews/tests/plotting/bokeh/test_subcoordy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from holoviews.core import Overlay
from holoviews.element import Curve
from holoviews.element.annotation import VSpan
from holoviews.operation.normalization import subcoordinate_group_ranges

from .test_plot import TestBokehPlot, bokeh_renderer

Expand Down Expand Up @@ -391,3 +392,33 @@ def test_missing_group_error(self):
)
):
bokeh_renderer.get_plot(Overlay(curves))

def test_norm_subcoordinate_group_ranges(self):
x = np.linspace(0, 10 * np.pi, 21)
curves = []
j = 0
for group in ['A', 'B']:
for i in range(2):
yvals = j * np.sin(x)
curves.append(
Curve((x + np.pi/2, yvals), label=f'{group}{i}', group=group).opts(subcoordinate_y=True)
)
j += 1

overlay = Overlay(curves)
noverlay = subcoordinate_group_ranges(overlay)

expected = [
(-1.0, 1.0),
(-1.0, 1.0),
(-3.0, 3.0),
(-3.0, 3.0),
]
for i, el in enumerate(noverlay):
assert el.get_dimension('y').range == expected[i]

plot = bokeh_renderer.get_plot(noverlay)

for i, sp in enumerate(plot.subplots.values()):
y_source = sp.handles['glyph_renderer'].coordinates.y_source
assert (y_source.start, y_source.end) == expected[i]