holoviz / holoviews

With Holoviews, your data visualizes itself.
https://holoviews.org
BSD 3-Clause "New" or "Revised" License
2.69k stars 402 forks source link

Labels does not work with categorical axis on matplotlib #4992

Open douglas-raillard-arm opened 3 years ago

douglas-raillard-arm commented 3 years ago

ALL software version info

holoviews 1.14.4 jupyterlab 2.3.1 matplotlib 3.4.2 bokeh 2.3.2

Description of expected behavior and the observed behavior

Labels element raises an exception on matplotlib backend when the X axis is categorical

Complete, minimal, self-contained example code that reproduces the issue

import holoviews as hv
hv.extension('matplotlib')

hv.Labels([("hello", 12, "text")])

# This is particularly problematic for this use case
hv.Bars([('hello', 10), ('world', 20)]) * hv.Labels([("hello", 12, "text")])

Stack traceback and/or browser JavaScript console output

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
~/venv-3.9/lib/python3.9/site-packages/matplotlib/axis.py in convert_units(self, x)
   1499         try:
-> 1500             ret = self.converter.convert(x, self.units, self)
   1501         except Exception as e:

~/venv-3.9/lib/python3.9/site-packages/matplotlib/category.py in convert(value, unit, axis)
     48         if unit is None:
---> 49             raise ValueError(
     50                 'Missing category information for StrCategoryConverter; '

ValueError: Missing category information for StrCategoryConverter; this might be caused by unintendedly mixing categorical and numeric data

The above exception was the direct cause of the following exception:

ConversionError                           Traceback (most recent call last)
~/venv-3.9/lib/python3.9/site-packages/IPython/core/formatters.py in __call__(self, obj, include, exclude)
    968 
    969             if method is not None:
--> 970                 return method(include=include, exclude=exclude)
    971             return None
    972         else:

~/venv-3.9/lib/python3.9/site-packages/holoviews/core/dimension.py in _repr_mimebundle_(self, include, exclude)
   1315         combined and returned.
   1316         """
-> 1317         return Store.render(self)
   1318 
   1319 

~/venv-3.9/lib/python3.9/site-packages/holoviews/core/options.py in render(cls, obj)
   1403         data, metadata = {}, {}
   1404         for hook in hooks:
-> 1405             ret = hook(obj)
   1406             if ret is None:
   1407                 continue

~/venv-3.9/lib/python3.9/site-packages/holoviews/ipython/display_hooks.py in pprint_display(obj)
    280     if not ip.display_formatter.formatters['text/plain'].pprint:
    281         return None
--> 282     return display(obj, raw_output=True)
    283 
    284 

~/venv-3.9/lib/python3.9/site-packages/holoviews/ipython/display_hooks.py in display(obj, raw_output, **kwargs)
    250     elif isinstance(obj, (CompositeOverlay, ViewableElement)):
    251         with option_state(obj):
--> 252             output = element_display(obj)
    253     elif isinstance(obj, (Layout, NdLayout, AdjointLayout)):
    254         with option_state(obj):

~/venv-3.9/lib/python3.9/site-packages/holoviews/ipython/display_hooks.py in wrapped(element)
    144         try:
    145             max_frames = OutputSettings.options['max_frames']
--> 146             mimebundle = fn(element, max_frames=max_frames)
    147             if mimebundle is None:
    148                 return {}, {}

~/venv-3.9/lib/python3.9/site-packages/holoviews/ipython/display_hooks.py in element_display(element, max_frames)
    190         return None
    191 
--> 192     return render(element)
    193 
    194 

~/venv-3.9/lib/python3.9/site-packages/holoviews/ipython/display_hooks.py in render(obj, **kwargs)
     66         renderer = renderer.instance(fig='png')
     67 
---> 68     return renderer.components(obj, **kwargs)
     69 
     70 

~/venv-3.9/lib/python3.9/site-packages/holoviews/plotting/renderer.py in components(self, obj, fmt, comm, **kwargs)
    448             return data, {}
    449         else:
--> 450             html = self._figure_data(plot, fmt, as_script=True, **kwargs)
    451         data['text/html'] = html
    452 

~/venv-3.9/lib/python3.9/site-packages/holoviews/plotting/mpl/renderer.py in _figure_data(self, plot, fmt, bbox_inches, as_script, **kwargs)
    173                 pass
    174             bytes_io = BytesIO()
--> 175             fig.canvas.print_figure(bytes_io, **kw)
    176             data = bytes_io.getvalue()
    177 

~/venv-3.9/lib/python3.9/site-packages/matplotlib/backend_bases.py in print_figure(self, filename, dpi, facecolor, edgecolor, orientation, format, bbox_inches, pad_inches, bbox_extra_artists, backend, **kwargs)
   2228                        else suppress())
   2229                 with ctx:
-> 2230                     self.figure.draw(renderer)
   2231 
   2232             if bbox_inches:

~/venv-3.9/lib/python3.9/site-packages/matplotlib/artist.py in draw_wrapper(artist, renderer, *args, **kwargs)
     72     @wraps(draw)
     73     def draw_wrapper(artist, renderer, *args, **kwargs):
---> 74         result = draw(artist, renderer, *args, **kwargs)
     75         if renderer._rasterizing:
     76             renderer.stop_rasterizing()

~/venv-3.9/lib/python3.9/site-packages/matplotlib/artist.py in draw_wrapper(artist, renderer, *args, **kwargs)
     49                 renderer.start_filter()
     50 
---> 51             return draw(artist, renderer, *args, **kwargs)
     52         finally:
     53             if artist.get_agg_filter() is not None:

~/venv-3.9/lib/python3.9/site-packages/matplotlib/figure.py in draw(self, renderer)
   2778 
   2779             self.patch.draw(renderer)
-> 2780             mimage._draw_list_compositing_images(
   2781                 renderer, self, artists, self.suppressComposite)
   2782 

~/venv-3.9/lib/python3.9/site-packages/matplotlib/image.py in _draw_list_compositing_images(renderer, parent, artists, suppress_composite)
    130     if not_composite or not has_images:
    131         for a in artists:
--> 132             a.draw(renderer)
    133     else:
    134         # Composite any adjacent images together

~/venv-3.9/lib/python3.9/site-packages/matplotlib/artist.py in draw_wrapper(artist, renderer, *args, **kwargs)
     49                 renderer.start_filter()
     50 
---> 51             return draw(artist, renderer, *args, **kwargs)
     52         finally:
     53             if artist.get_agg_filter() is not None:

~/venv-3.9/lib/python3.9/site-packages/matplotlib/_api/deprecation.py in wrapper(*inner_args, **inner_kwargs)
    429                          else deprecation_addendum,
    430                 **kwargs)
--> 431         return func(*inner_args, **inner_kwargs)
    432 
    433     return wrapper

~/venv-3.9/lib/python3.9/site-packages/matplotlib/axes/_base.py in draw(self, renderer, inframe)
   2919             renderer.stop_rasterizing()
   2920 
-> 2921         mimage._draw_list_compositing_images(renderer, self, artists)
   2922 
   2923         renderer.close_group('axes')

~/venv-3.9/lib/python3.9/site-packages/matplotlib/image.py in _draw_list_compositing_images(renderer, parent, artists, suppress_composite)
    130     if not_composite or not has_images:
    131         for a in artists:
--> 132             a.draw(renderer)
    133     else:
    134         # Composite any adjacent images together

~/venv-3.9/lib/python3.9/site-packages/matplotlib/artist.py in draw_wrapper(artist, renderer, *args, **kwargs)
     49                 renderer.start_filter()
     50 
---> 51             return draw(artist, renderer, *args, **kwargs)
     52         finally:
     53             if artist.get_agg_filter() is not None:

~/venv-3.9/lib/python3.9/site-packages/matplotlib/text.py in draw(self, renderer)
    677 
    678         with _wrap_text(self) as textobj:
--> 679             bbox, info, descent = textobj._get_layout(renderer)
    680             trans = textobj.get_transform()
    681 

~/venv-3.9/lib/python3.9/site-packages/matplotlib/text.py in _get_layout(self, renderer)
    291         of a rotated text when necessary.
    292         """
--> 293         key = self.get_prop_tup(renderer=renderer)
    294         if key in self._cached:
    295             return self._cached[key]

~/venv-3.9/lib/python3.9/site-packages/matplotlib/text.py in get_prop_tup(self, renderer)
    840         need to know if the text has changed.
    841         """
--> 842         x, y = self.get_unitless_position()
    843         renderer = renderer or self._renderer
    844         return (x, y, self.get_text(), self._color,

~/venv-3.9/lib/python3.9/site-packages/matplotlib/text.py in get_unitless_position(self)
    822         # This will get the position with all unit information stripped away.
    823         # This is here for convenience since it is done in several locations.
--> 824         x = float(self.convert_xunits(self._x))
    825         y = float(self.convert_yunits(self._y))
    826         return x, y

~/venv-3.9/lib/python3.9/site-packages/matplotlib/artist.py in convert_xunits(self, x)
    201         if ax is None or ax.xaxis is None:
    202             return x
--> 203         return ax.xaxis.convert_units(x)
    204 
    205     def convert_yunits(self, y):

~/venv-3.9/lib/python3.9/site-packages/matplotlib/axis.py in convert_units(self, x)
   1500             ret = self.converter.convert(x, self.units, self)
   1501         except Exception as e:
-> 1502             raise munits.ConversionError('Failed to convert value(s) to axis '
   1503                                          f'units: {x!r}') from e
   1504         return ret

ConversionError: Failed to convert value(s) to axis units: 'hello'

Screenshots or screencasts of the bug in action

Works on bokeh as following, raises an exception on matplotlib:

import holoviews as hv
hv.extension('bokeh')

hv.Bars([('hello', 10), ('world', 20)]) * hv.Labels([("hello", 12, "text")])

image

douglas-raillard-arm commented 3 years ago

Not that it also fails with Text

douglas-raillard-arm commented 3 years ago

Tracked down to being a matplotlib issue. When calling ax.plot(), matplotlib is kind enough to initialize the units of the xaxis and yaxis for us. When calling ax.text(), matplotlib does not do it and crashes instead, as this example shows.

import matplotlib as mpl
import matplotlib.pyplot as plt

ax = plt.axes()

# ax.plot indirectly initializes the units of the axis, so matplotlib knows that a==0 and b==0
# If this ax.plot() is commented-out, ax.xaxis.units is no longer initialized and matplotlib barfs
ax.plot(['a','b'], [2,3])
print(ax.xaxis.units)
ax.text('a', 2.5, "hello")

ax.xaxis.set_units() can sort of be used, be the result is quite broken (the xtick stays numeric and the autoscaling breaks) so I'm not sure what is the best approach to that problem.

EDIT: There is a similar bug opened on matplotlib https://github.com/matplotlib/matplotlib/issues/16666