Skip to content

PLOT: Add option to specify the plotting backend #26753

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

Merged
merged 16 commits into from
Jun 21, 2019
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions doc/source/user_guide/options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,12 @@ compute.use_bottleneck True Use the bottleneck library
computation if it is installed.
compute.use_numexpr True Use the numexpr library to accelerate
computation if it is installed.
plotting.backend matplotlib Change the plotting backend to a different
backend than the current matplotlib one.
Backends can be implemented as third-party
libraries implementing the pandas plotting
API. They can use other plotting libraries
like Bokeh, Altair, etc.
plotting.matplotlib.register_converters True Register custom converters with
matplotlib. Set to False to de-register.
======================================= ============ ==================================
Expand Down
2 changes: 1 addition & 1 deletion doc/source/whatsnew/v0.25.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Other Enhancements
- :meth:`DataFrame.query` and :meth:`DataFrame.eval` now supports quoting column names with backticks to refer to names with spaces (:issue:`6508`)
- :func:`merge_asof` now gives a more clear error message when merge keys are categoricals that are not equal (:issue:`26136`)
- :meth:`pandas.core.window.Rolling` supports exponential (or Poisson) window type (:issue:`21303`)
-
- Added new option ``plotting.backend`` to be able to select a plotting backend different that the existing ``matplotlib`` one. Use ``pandas.set_option('plotting.backend', '<backend-module>')`` where ``<backend-module`` is a library implementing the pandas plotting API (:issue:`14130`)
Copy link
Contributor

Choose a reason for hiding this comment

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

"that" -> "than"

Copy link
Contributor

Choose a reason for hiding this comment

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

We don't have any alternative engines to list here yet, right?

Copy link
Member Author

Choose a reason for hiding this comment

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

Not at the moment, but that's a good point. Next week once this is merged I'm planning to work with few people to adapt hvplot. So we can see that everything is working well, and we can fix anything before 0.25. It may make sense to update this and use hvplot as an example when it's ready.

Copy link
Contributor

Choose a reason for hiding this comment

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

Great. I think this should be a prominent new feature if we are able to get either or both of pdvega ready to use it in time for the release.


.. _whatsnew_0250.api_breaking:

Expand Down
53 changes: 53 additions & 0 deletions pandas/core/config_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
module is imported, register them here rather then in the module.

"""
import importlib

import pandas._config.config as cf
from pandas._config.config import (
is_bool, is_callable, is_instance_factory, is_int, is_one_of_factory,
Expand Down Expand Up @@ -460,6 +462,57 @@ def use_inf_as_na_cb(key):
# Plotting
# ---------

plotting_backend_doc = """
Copy link
Contributor

Choose a reason for hiding this comment

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

One question: If I wanted to use the altar backend, I would be more like to use .set_option('plotting.backend', 'altair') than ..., 'pdvega'). @jakevdp what name would you prefer?

I think hvplot will just be hvplot, so that's fine.

Anyway, we might consider adding a dict here like plotting_backend_alias that maps the user-facing name like altair to the backend name like pdvega. When the backend library registers themselves, they can also register their aliases.

Copy link
Member Author

Choose a reason for hiding this comment

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

I see your point, and I think it'd add value to the users, but not sure if I'm in favor of adding the extra complexity it's needed to manage aliases in a dynamic way.

I like the simplicity of the parameter being the name of the module. I guess in some cases will look nicer than others. May be hvplot will use hvplot.pandas, since hvplot contains other things besides our plugin, and the module to use may be hvplot.pandas.

In practice I guess backends will register themselves, and users will rarely switch backends manually. But I guess if they do, it'll be better if they know they need to use the name of the module:

import pandas
import hvplot.pandas

df.plot()

pandas.set_option('backend.plotting', 'matplotlib')
df.plot()

pandas.set_option('backend.plotting', 'hvplot.pandas')
df.plot()

I don't have a strong opinion, but I'd say let's start with the simplest option, and add aliases or something else if we think it's useful once we start using this.

Copy link
Contributor

@TomAugspurger TomAugspurger Jun 20, 2019

Choose a reason for hiding this comment

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

Is it especially complex? I was thinking something like

_plotting_aliases = {}  # or define somewhere else

def register_plotting_backend_cb(key):
    backend_str = cf.get_option(key)
    backend_str = _plotting_aliases.get(backend_str, backend_str)
    ...

Indeed, I think this simplifies things already, since we can use 'matplotlib' as pandas.plotting._matplotlib. Though we may continue to special case matplotlib to provide a nice error message.

Copy link
Member Author

@datapythonista datapythonista Jun 20, 2019

Choose a reason for hiding this comment

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

What I wouldn't do is to have the aliases in pandas itself. May be I'm being too strict, but if feels wrong.

But you're right, it's probably not as complex as I was thinking anyway. An simple option plotting.aliases with a dictionary may not be ideal, but would allow backends create an alias by simply:

pandas.set_option('plotting.aliases', dict(pandas.get_option('plotting.aliases'), hvplot='hvplot.pandas'))

Copy link
Member Author

Choose a reason for hiding this comment

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

but better in a follow up PR I think, so we can focus there on the exact syntax and approach

Copy link
Contributor

Choose a reason for hiding this comment

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

Perfectly fine doing as a followup.

And my thinking may have been a bit muddled here. I was thinking that the backend library would have already been imported, and so would have a chance to register their own aliases. But as you say, it would be pandas managing them, which doesn't feel quite right.

Copy link
Member Author

Choose a reason for hiding this comment

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

another simple option is that backends add an optional attribute alias = 'hvplot', and we simply do:

if hasattr(backend_mod, 'alias'):
    plotting_aliases[alias] = backend_mod.__name__

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes good idea. But still leaving this as a followup?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I prefer to keep the focus, the smaller the PRs, the better the content :)

: str
The plotting backend to use. The default value is "matplotlib", the
backend provided with pandas. Other backends can be specified by
prodiving the name of the module that implements the backend.
"""


def register_plotting_backend_cb(key):
backend_str = cf.get_option(key)
if backend_str == 'matplotlib':
try:
import pandas.plotting._matplotlib # noqa
except ImportError:
raise ImportError('matplotlib is required for plotting when the '
'default backend "matplotlib" is selected.')
else:
return

try:
backend_mod = importlib.import_module(backend_str)
except ImportError:
raise ValueError('"{}" does not seem to be an installed module. '
'A pandas plotting backend must be a module that '
'can be imported'.format(backend_str))

required_objs = ['LinePlot', 'BarPlot', 'BarhPlot', 'HistPlot',
'BoxPlot', 'KdePlot', 'AreaPlot', 'PiePlot',
'ScatterPlot', 'HexBinPlot', 'hist_series',
'hist_frame', 'boxplot', 'boxplot_frame',
'boxplot_frame_groupby']
missing_objs = set(required_objs) - set(dir(backend_mod))
if len(missing_objs) == len(required_objs):
raise ValueError(
'"{}" does not seem to be a valid backend. Valid backends are '
'modules that implement the next objects:\n{}'.format(
backend_str, '\n'.join(required_objs)))
elif missing_objs:
raise ValueError(
'"{}" does not seem to be a complete backend. Valid backends '
'must implement the next objects:\n{}'.format(
backend_str, '\n'.join(missing_objs)))


with cf.config_prefix('plotting'):
cf.register_option('backend', defval='matplotlib',
doc=plotting_backend_doc,
validator=str,
cb=register_plotting_backend_cb)


register_converter_doc = """
: bool
Whether to register converters with matplotlib's units registry for
Expand Down
10 changes: 5 additions & 5 deletions pandas/plotting/_core.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import importlib
from typing import List, Type # noqa

from pandas.util._decorators import Appender
Expand Down Expand Up @@ -625,11 +626,10 @@ def _get_plot_backend():
The backend is imported lazily, as matplotlib is a soft dependency, and
pandas can be used without it being installed.
"""
try:
import pandas.plotting._matplotlib as plot_backend
except ImportError:
raise ImportError("matplotlib is required for plotting.")
return plot_backend
backend_str = pandas.get_option('plotting.backend')
if backend_str == 'matplotlib':
backend_str = 'pandas.plotting._matplotlib'
return importlib.import_module(backend_str)


def _plot_classes():
Expand Down
98 changes: 98 additions & 0 deletions pandas/tests/plotting/test_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import pytest

import pandas


def _mocked_import_module(name):
"""
Mock of ``importlib.import_module``. Depending of the name of the module
received will return:

- 'sample_backend': A mock of a valid plotting backend
- 'module_not_a_backend': A backend (object) with none of the backend
methods
- 'backend_missing_area_plot': A backend (object) with all backend
attributes except ``AreaPlot``
"""
class PlottingBackendModuleMock:
LinePlot = None
BarPlot = None
BarhPlot = None
HistPlot = None
BoxPlot = None
KdePlot = None
AreaPlot = None
PiePlot = None
ScatterPlot = None
HexBinPlot = None
hist_series = None
hist_frame = None
boxplot = None
boxplot_frame = None
boxplot_frame_groupby = None

if name == 'correct_backend':
return PlottingBackendModuleMock
elif name == 'module_not_a_backend':
return object()
elif name == 'backend_missing_area_plot':
mod = PlottingBackendModuleMock
del mod.AreaPlot
return mod

raise ValueError('Unknown mocked backend: {}'.format(name))


def test_matplotlib_backend_error():
msg = ('matplotlib is required for plotting when the default backend '
'"matplotlib" is selected.')
try:
import matplotlib # noqa
except ImportError:
with pytest.raises(ImportError, match=msg):
pandas.set_option('plotting.backend', 'matplotlib')


def test_backend_is_not_module():
msg = ('"not_an_existing_module" does not seem to be an installed module. '
'A pandas plotting backend must be a module that can be imported')
with pytest.raises(ValueError, match=msg):
pandas.set_option('plotting.backend', 'not_an_existing_module')


def test_backend_not_a_backend_module(monkeypatch):
required_objs = ['LinePlot', 'BarPlot', 'BarhPlot', 'HistPlot',
'BoxPlot', 'KdePlot', 'AreaPlot', 'PiePlot',
'ScatterPlot', 'HexBinPlot', 'hist_series',
'hist_frame', 'boxplot', 'boxplot_frame',
'boxplot_frame_groupby']
msg = ('"module_not_a_backend" does not seem to be a valid backend. '
'Valid backends are modules that implement the next '
'objects:\n{}'.format('\n'.join(required_objs)))
monkeypatch.setattr('pandas.core.config_init.importlib.import_module',
_mocked_import_module)
with pytest.raises(ValueError, match=msg):
pandas.set_option('plotting.backend', 'module_not_a_backend')


def test_backend_has_missing_objects(monkeypatch):
msg = ('"backend_missing_area_plot" does not seem to be a complete '
'backend. Valid backends must implement the next objects:\n'
'AreaPlot')
monkeypatch.setattr('pandas.core.config_init.importlib.import_module',
_mocked_import_module)
with pytest.raises(ValueError, match=msg):
pandas.set_option('plotting.backend', 'backend_missing_area_plot')


def test_backend_is_correct(monkeypatch):
monkeypatch.setattr('pandas.core.config_init.importlib.import_module',
_mocked_import_module)
pandas.set_option('plotting.backend', 'correct_backend')
assert pandas.get_option('plotting.backend') == 'correct_backend'

# Restore backend for other tests (matplotlib can be not installed)
try:
pandas.set_option('plotting.backend', 'matplotlib')
except ImportError:
pass