holoviews icon indicating copy to clipboard operation
holoviews copied to clipboard

Twin axis

Open vascotenner opened this issue 9 years ago • 41 comments

Is it possible to create overlays with two y-axis (a left and a right axis)? With matplotlib this is possible with ax1.twinx().

Twin axes are currently not supported bit yet. For now you could try to write a finalize_hook to do it.

vascotenner avatar Jan 07 '16 10:01 vascotenner

I have a really uggly solution:

%%opts Curve [finalize_hooks=[setglobal_hook], show_frame=True]
a = hv.Curve(([1,2], [3,21]), vdims=['A'])
b = hv.Curve(([1,2], [30,20]), vdims=['B'])

def setglobal_hook(plot, element):
    global fig, el
    fig = plot
    el = element

a
%%opts Curve [finalize_hooks=[overlay], show_frame=True]
b = hv.Curve(([1,2], [30,20]), vdims=['B'])

def overlay(plot, element):
    ax = plot.handles['axis']
    ax2 = ax.twinx()
    ax2.set_yticklabels([t.get_text() for t in fig.handles['axis'].get_yticklabels()])
    ax2.set_ylabel(fig.handles['axis'].get_ylabel())
    ax2.plot(*fig.handles['axis'].lines[0].get_data())
    ax2.lines[-1].set_color('r')

b

Is there a better way to get grips to the plotted version of a?

vascotenner avatar Jan 07 '16 12:01 vascotenner

I see, yes it's problematic because you have to create a new axis to plot on. I'll have to think about it a bit, one quick workaround would be to add support not just for finalize_hooks but also initialize_hooks, that way you could simply replace the axis on the plot ensuring that the Element is drawn on the correct axis.

philippjfr avatar Jan 07 '16 13:01 philippjfr

A slightly cleaner solution:

def overlay(first, plot, element):
    fig = hv.Store.renderers['matplotlib'].get_plot(first)
    ax = plot.handles['axis']
    ax2 = ax.twinx()
    ax2.set_yticks(fig.handles['axis'].get_yticks())
    #ax2.set_yticklabels([t.get_text() for t in fig.handles['axis'].get_yticklabels()])
    ax2.set_ylabel(fig.handles['axis'].get_ylabel())
    for line in fig.handles['axis'].lines:
        ax2.plot(*line.get_data())
        ax2.lines[-1].set_color('k')
        ax2.lines[-1].set_linestyle('--')

def tmp(plot, element):
    overlay(a, plot, element)
%%opts Curve [finalize_hooks=[tmp], show_frame=True]
b = hv.Curve(([1,2], [30,20]), vdims=['B'])

b

A small annoyance is that the x-axis is not set ok. Should go from 0 to 2, but has range 0,2.2.

vascotenner avatar Jan 07 '16 13:01 vascotenner

Here's what it could look like with a init_hooks parameter:

def twinx(plot, element):
    ax = plot.handles['axis']
    twinax = ax.twinx()
    twinax.set_ylabel(str(element.last.get_dimension(1)))
    plot.handles['axis'] = twinax

a = hv.Curve(([1,2], [3,21]), vdims=['A'])
b = hv.Curve(([1,2], [5, 0]), vdims=['B'])(plot=dict(init_hooks=[twinx]), style=dict(color='red'))
a * b

image

Does that seem like a reasonable solution?

philippjfr avatar Jan 07 '16 13:01 philippjfr

This is a nice solution. It demonstrates again how powerfull holoviews is. But it show also how many undocumented gems are available. It might be good to add a cookbook like section on the website, where many of these things are demonstrated. Also on gitter many good solutions come along, but is is very hard to find them back.

Is this already possible with the current version?

vascotenner avatar Jan 07 '16 13:01 vascotenner

No, I'm suggesting to add the init_hooks parameter.

philippjfr avatar Jan 07 '16 14:01 philippjfr

Sounds like a very good idea, to allow people to do more customization themselves.

jbednar avatar Jan 07 '16 14:01 jbednar

I also agree that supporting initial hooks is a good idea.

Just one minor gripe though - it should be initial_hooks and final_hooks for consistency (which would mean renaming finalize_hooks to final_hooks). Easy to do and not a big priority...

Edit: Or would setup_hooks and final_hooks make more sense?

jlstevens avatar Jan 07 '16 14:01 jlstevens

I think setup_hooks is too easily read as a verb, i.e. to set up the hooks, when it is meant here as a noun. finalize_hooks has the same problem; it sounds like a request to finalize the hooks, not a declaration that these hooks should be run at the finalizing stage. initial_hooks and final_hooks makes good sense.

In any case, aren't these specifically meant for user extensions, and thus something that we expect and encourage people to specify in their own code? If so I'm not sure it's ok to change finalize_hooks at this point, unless we left an alias to it.

jbednar avatar Jan 07 '16 14:01 jbednar

I would consider having an alias and eventually deprecating finalize_hooks as a name. We have a mechanism for this we used to specify kdims and vdims instead of key_dimensions and value_dimensions (are those ready to remove yet?).

jlstevens avatar Jan 07 '16 16:01 jlstevens

We have a mechanism for this we used to specify kdims and vdims instead of key_dimensions and value_dimensions

Not sure it's worth using that mechanism, it's mostly to do with __setstate__ and since plots aren't usually pickled I don't think that's too important. This seems fairly straightforward:

if not self.final_hooks:
   if self.finalize_hooks:
      self.warning('Using deprecated finalize_hooks options, use final_hooks instead')
      self.final_hooks = self.finalize_hooks
elif self.finalize_hooks:
   raise ValueError('Set either final_hooks or deprecated finalize_hooks, not both.')

(are those ready to remove yet?).

Let's do that for v1.5.

philippjfr avatar Jan 07 '16 16:01 philippjfr

The suggested code looks good and I'll open an issue about removing key_dimensions and value_dimensions for 1.5. I don't think we really want inconsistent usage and kdims and vdims are so much shorter than the old names.

jlstevens avatar Jan 07 '16 16:01 jlstevens

Sounds good.

jbednar avatar Jan 07 '16 16:01 jbednar

It is really super that you have such a fast response on feature request!

Today I tried this with elements with a group, but that does not work yet:

def twinx(plot, element):
    ax = plot.handles['axis']
    twinax = ax.twinx()
    twinax.set_ylabel(str(element.last.get_dimension(1)))
    plot.handles['axis'] = twinax

a = hv.Curve(([1,2], [3,21]), kdims=[dim_pos], vdims=[dim_itensity], group='test')
b = hv.Curve(([1,2], [5, 0]), kdims=[dim_pos], vdims=[dim_phase], group='test'
     )plot=dict(initial_hooks=[twinx]))
a * b
Traceback (most recent call last):
  File "/home/a/src/holoviews/holoviews/ipython/display_hooks.py", line 101, in wrapped
    max_branches = OutputMagic.options['max_branches'])
  File "/home/a/src/holoviews/holoviews/ipython/display_hooks.py", line 150, in element_display
    return renderer.html(element, fmt=renderer.fig)
  File "/home/a/src/holoviews/holoviews/plotting/renderer.py", line 211, in html
    plot, fmt =  self._validate(obj, fmt)
  File "/home/a/src/holoviews/holoviews/plotting/renderer.py", line 171, in _validate
    plot = self.get_plot(obj)
  File "/home/a/src/holoviews/holoviews/plotting/renderer.py", line 158, in get_plot
    plot.update(0)
  File "/home/a/src/holoviews/holoviews/plotting/mpl/plot.py", line 208, in update
    return self.initialize_plot()
  File "/home/a/src/holoviews/holoviews/plotting/mpl/element.py", line 671, in initialize_plot
    plot.initialize_plot(ranges=ranges)
  File "/home/a/src/holoviews/holoviews/plotting/mpl/chart.py", line 124, in initialize_plot
    style = self.style[self.cyclic_index]
  File "/home/a/src/holoviews/holoviews/core/options.py", line 306, in __getitem__
    return dict(self._options[index % len(self._options)])
ZeroDivisionError: integer division or modulo by zero

vascotenner avatar Jan 14 '16 15:01 vascotenner

Odd seems to work fine for me. If you just copy and paste what you put there into a new notebook do you still get an error?

philippjfr avatar Jan 14 '16 15:01 philippjfr

Another point is that the range of the original axis is adjusted to fit to data of the second axis in. This is different from plt.twinx and unwanted behaviour.

def twinx(plot, element):
    ax = plot.handles['axis']
    twinax = ax.twinx()
    twinax.set_ylabel(str(element.last.get_dimension(1)))
    plot.handles['axis'] = twinax

a = hv.Curve(([1,2], [3,21]), vdims=['A'])
b = hv.Curve(([1,2], [50, -10]), vdims=['B'])(plot=dict(initial_hooks=[twinx]), style=dict(color='red'))
a * b

download

(Note that both axis have the same range)

One can somehow overcome this by setting the extents for the first figure manually. These extents should be set by both the first and second element. Changing the extents for the second axis is not possible yet.

vascotenner avatar Jan 14 '16 15:01 vascotenner

Another point is that the range of the original axis is adjusted to fit to data of the second axis in. This is different from plt.twinx and unwanted behaviour.

You can disable HoloViews handling of ranges by setting apply_ranges=False as a plot option.

philippjfr avatar Jan 14 '16 16:01 philippjfr

Setting apply_ranges=False works fine!

Now I try to put this figure with two axis to a layout. Once of a sudden it is elongated:

a * b + a * b

download 1

vascotenner avatar Jan 14 '16 16:01 vascotenner

The same works for the y-axis

%%opts Curve [apply_ranges=False]
def twiny(plot, element):
    ax = plot.handles['axis']
    twinax = ax.twiny()
    twinax.set_xlabel(str(element.last.get_dimension(0)))
    plot.handles['axis'] = twinax

a = hv.Curve(([1,2], [3,21]), kdims=[dim_radius], vdims=['A'])
b = hv.Curve(([1,2,3], [50, -10,0]), kdims=[dim_radius], vdims=['B'])(plot=dict(initial_hooks=[twiny]), style=dict(color='red'))
a * b

vascotenner avatar Feb 27 '17 14:02 vascotenner

This works great for the matplotlib backend.

Is it possible to do the same with bokeh backend?

I cannot quite translate to the initial_hooks function as above using the info here:

https://stackoverflow.com/questions/25199665/one-chart-with-two-different-y-axis-ranges-in-bokeh

Specifically, in my hook function, how do I get the s1 object used in the answer on that page:

https://stackoverflow.com/a/30914348/1638996

That is, what is the bokeh equivalent of ax = plot.handles['axis']? I think that would give me what is needed.

Thank you.

timehaven avatar Aug 23 '17 15:08 timehaven

@timehaven This works for me with bokeh backend.

import numpy as np
import pandas as pd
import holoviews as hv
hv.extension('bokeh')

def apply_formatter(plot, element):
    p = plot.state
    p.extra_y_ranges = {"twiny": Range1d(start=0, end=35)}
    p.add_layout(LinearAxis(y_range_name="twiny"), 'right')

dts = pd.date_range('2015-01-01', end='2015-01-10').values
c = hv.Curve((dts, np.arange(10))).options(finalize_hooks=[apply_formatter])
c

bokeh_plot 1

ahuang11 avatar May 29 '18 16:05 ahuang11

@ahuang11 I'm confused. The technique you show, using finalize_hooks=[apply_formatter] with the bokeh backend, seems to display the second axis, but it doesn't actually plot the curve on it. The curve you show seems to still be plotting on the left axis even though you have added the scale on the right. I'm getting a similar result with my attempts:

import numpy as np
import pandas as pd
import holoviews as hv
hv.extension('bokeh')

def apply_formatter(plot, element):
    p = plot.state
    p.extra_y_ranges = {"twiny": Range1d(start=0, end=35)}
    p.add_layout(LinearAxis(y_range_name="twiny"), 'right')

a = hv.Curve(([1,2], [3,21]), vdims=['A'])
b = hv.Curve(([1,2,3], [50, -10,0]), vdims=['B'])
bb = hv.Curve(([1,2,3], [50, -10,0]), vdims=['B']).options(finalize_hooks=[apply_formatter])
display(a * b)
display(a * bb)

image

See that both curves are still plotting on the left axis, even after inserting the right axis on the bottom plot. Any ideas how to actually scale the figure to match the right axis?

chuard avatar Feb 06 '19 15:02 chuard

@chuard you could try running them through an "initialize_hooks" first to directly plot them into new axes at creation.

poplarShift avatar Feb 24 '19 15:02 poplarShift

The above example is almost complete. The only thing you need to do is to set y_range_name bokeh prop of your line glyph to the twiny Range you have created in your hook.

Please find a modified snippet below:

import pandas as pd
import holoviews as hv
from bokeh.models.renderers import GlyphRenderer
from bokeh.models import Range1d, LinearAxis

hv.extension('bokeh')

def apply_formatter(plot, element):

    p = plot.state
    
    # create secondary range and axis
    p.extra_y_ranges = {"twiny": Range1d(start=0, end=35)}
    p.add_layout(LinearAxis(y_range_name="twiny"), 'right')

    # set glyph y_range_name to the one we've just created
    glyph = p.select(dict(type=GlyphRenderer))[0]
    glyph.y_range_name = 'twiny'

dts = pd.date_range('2015-01-01', end='2015-01-10').values

c_def = hv.Curve((dts, np.arange(10)), name='default_axis').options(color='red', width=300)
c_sec = hv.Curve((dts, np.arange(10)), name='secondary_axis').options(color='blue',width=300, hooks=[apply_formatter])
c_def + c_def * c_sec + c_sec

adamlansky avatar Jun 30 '19 12:06 adamlansky

@adamlansky took the liberty to add from bokeh.models import Range1d, LinearAxis in your code snippet.

poplarShift avatar Jul 01 '19 13:07 poplarShift

@poplarShift thank you, much appreciated!

I also feel like this issue could probably be closed, as the hooks solution should be general enough to handle any secondary-axis related tasks. Please let me know if i should cross-post it to https://github.com/pyviz/holoviews/issues/3011 to make sure similar issues are solved as well.

adamlansky avatar Jul 01 '19 13:07 adamlansky

I don't think it fully works, or at least I'm missing something. Somehow the left axis gets modified if you have something like this:

import pandas as pd
import holoviews as hv
from bokeh.models.renderers import GlyphRenderer
from bokeh.models import Range1d, LinearAxis

hv.extension('bokeh')

def apply_formatter(plot, element):

    p = plot.state
    
    # create secondary range and axis
    p.extra_y_ranges = {"twiny": Range1d(start=0, end=35)}
    p.add_layout(LinearAxis(y_range_name="twiny"), 'right')

    # set glyph y_range_name to the one we've just created
    glyph = p.select(dict(type=GlyphRenderer))[0]
    glyph.y_range_name = 'twiny'

dts = pd.date_range('2015-01-01', end='2015-01-10').values

c_def = hv.Curve((dts, np.arange(1, step=0.1)), name='default_axis').options(color='red', width=300)
c_sec = hv.Curve((dts, np.arange(10)), name='secondary_axis').options(color='blue',width=300, hooks=[apply_formatter])
c_def + c_def * c_sec + c_sec

You can see that the c_def axis is not correct, and somehow it's autocalculated using the data in s_dec. If you just do c_def at the end you will see how the y range is properly calculated. Am I missing something?

apuignav avatar Sep 05 '19 19:09 apuignav

Also for the above, in bokeh, how would you add an overlay to the second axis

import pandas as pd
import holoviews as hv
from bokeh.models.renderers import GlyphRenderer
from bokeh.models import Range1d, LinearAxis

hv.extension('bokeh')

def apply_formatter(plot, element):

    p = plot.state
    
    # create secondary range and axis
    p.extra_y_ranges = {"twiny": Range1d(start=0, end=35)}
    p.add_layout(LinearAxis(y_range_name="twiny"), 'right')

    # set glyph y_range_name to the one we've just created
    glyph = p.select(dict(type=GlyphRenderer))[0]
    glyph.y_range_name = 'twiny'

dts = pd.date_range('2015-01-01', end='2015-01-10').values

c_def = hv.Curve((dts, np.arange(10)), name='default_axis').options(color='red', width=300)
c_sec = hv.Curve((dts, np.arange(10)), name='secondary_axis').options(color='blue',width=300, hooks=[apply_formatter])
c_ToAddToSecond = hv.Curve((dts, np.arange(10)*0.4), name='secondary_axis_to_add').options(color='blue',width=300, hooks=[apply_formatter])

c_def*c_sec*c_ToAddToSecond

This creates another secondary axis (resulting in two secondary axis, instead of 1)

zeneofa avatar Dec 06 '19 21:12 zeneofa

Here is a modified example that works around both issues highlighted by @apuignav and @zeneofa This example is meant to be run in a jupyter notebook and it is using streaming dataframes to show how changing data also modifies axis ranges.

import pandas as pd
import streamz
import streamz.dataframe
import holoviews as hv
from holoviews import opts
from holoviews.streams import Buffer
from bokeh.models import Range1d, LinearAxis

hv.extension('bokeh')

def plot_secondary(plot, element):
    ''' 
    A hook to put data on secondary axis
    '''
    p = plot.state
    
    # create secondary range and axis
    if 'twiny' not in [t for t in p.extra_y_ranges]:
        # you need to manually recreate primary axis to avoid weird behavior if you are going to 
        # use secondary_axis in your plots. From what i know this also relates to the way axis
        # behave in bokeh and unfortunately cannot be modified from hv unless you are 
        # willing to rewrite quite a bit of code
        p.y_range = Range1d(start=0, end=10)
        p.y_range.name = 'default'
        p.extra_y_ranges = {"twiny": Range1d(start=0, end=10)}
        p.add_layout(LinearAxis(y_range_name="twiny"), 'right')

    # set glyph y_range_name to the one we've just created
    glyph = p.renderers[-1]
    glyph.y_range_name = 'twiny'

    # set proper range
    glyph = p.renderers[-1]
    vals = glyph.data_source.data['y'] # ugly hardcoded solution, see notes below
    p.extra_y_ranges["twiny"].start = vals.min()* 0.99
    p.extra_y_ranges["twiny"].end = vals.max()* 1.01

# define two streamz random dfs to sim data for primary and secondary plots
simple_sdf = streamz.dataframe.Random(freq='10ms', interval='100ms')
secondary_sdf = streamz.dataframe.Random(freq='10ms', interval='100ms')

# do some transformation
pdf = (simple_sdf-0.5).cumsum()
sdf = (secondary_sdf-0.5).cumsum()

# create streams for holoviews from these dfs
prim_stream = Buffer(pdf.y)
sec_stream = Buffer(sdf.y)

# create dynamic maps to plot streaming data
primary = hv.DynamicMap(hv.Curve, streams=[prim_stream]).opts(width=400, show_grid=True, framewise=True)
secondary = hv.DynamicMap(hv.Curve, streams=[sec_stream]).opts(width=400, color='red', show_grid=True, framewise=True, hooks=[plot_secondary])
secondary_2 = hv.DynamicMap(hv.Curve, streams=[prim_stream]).opts(width=400, color='yellow', show_grid=True, framewise=True, hooks=[plot_secondary])

# plot these maps on the same figure
primary * secondary * secondary_2

If you want to stop streaming dataframes, run this:

# stop streaming objects
simple_sdf.stop()
secondary_sdf.stop()

Please note that this code is hardcoded for specific case when your data column name is 'y'. This allows to modify the range for secondary axis, as it is not handled automatically by bokeh.

As a bottom line, plots utilizing multiple axis from holoviews currently stay in "advanced territory" where you need to know bokeh object model good enough to work your way through it and there is no "plug and play" way to do it for N axis like you do in matplotlib.

adamlansky avatar Jan 07 '20 12:01 adamlansky

One way to go around the non supported twin axes is having 2 plots one above, second under sharing the same x-axis:

plot_first = df_first.hvplot.line(height=800, width=3500, legend=False, value_label="first_y_label").opts(bgcolor="black")
plot_second = df_second.hvplot.scatter(height=800, width=3500, legend=False, value_label="second_y_label").opts(bgcolor="black")
(plot_first + plot_second).cols(1)

IMHO this kind of example should be documented, especially since value_label, which apparently behaves the same as vidm or value dimension, is undocumented. Note that without value_label the 2 plots would share not only the x, but also the y-axis.

jmakov avatar Feb 21 '20 10:02 jmakov