Skip to content

Commit

Permalink
Reduce surprise about relational plot legends (#2229)
Browse files Browse the repository at this point in the history
* Fix unknown palette error

* Select better brief ticks

Larger maximum number of ticks and no ticks outside the range of the data

* Add 'auto' legend mode in relational plots

* Use 'auto' when relational legend is 'True'

* Only use dummy-artist 'subtitles' with multiple semantic variables

* Add clarity about numeric semantic mapping in scatterplot/lineplot docstrings

* Update release notes

* Update relplot legend tests

* Update legend locator test

* Add utility function to make subtitles look more like titles

* Delint

* Old matplotlib compat

* Try testing on latest matplotlib

* Test legendd=True
  • Loading branch information
mwaskom committed Aug 28, 2020
1 parent eba6a57 commit 4dd57d6
Show file tree
Hide file tree
Showing 11 changed files with 175 additions and 75 deletions.
2 changes: 1 addition & 1 deletion ci/deps_latest.txt
@@ -1,5 +1,5 @@
numpy
scipy
matplotlib!=3.3.1
matplotlib
pandas
statsmodels
2 changes: 1 addition & 1 deletion ci/deps_minimal.txt
@@ -1,4 +1,4 @@
numpy
scipy
matplotlib!=3.3.1
matplotlib
pandas
2 changes: 1 addition & 1 deletion doc/docstrings/lineplot.ipynb
Expand Up @@ -307,7 +307,7 @@
"sns.lineplot(\n",
" data=dots.query(\"coherence > 0\"),\n",
" x=\"time\", y=\"firing_rate\", hue=\"coherence\", style=\"choice\",\n",
" palette=\"viridis_r\", hue_norm=mpl.colors.LogNorm(),\n",
" palette=\"rocket_r\", hue_norm=mpl.colors.LogNorm(),\n",
")"
]
},
Expand Down
21 changes: 18 additions & 3 deletions doc/docstrings/scatterplot.ipynb
Expand Up @@ -127,9 +127,24 @@
"metadata": {},
"outputs": [],
"source": [
"sns.scatterplot(\n",
" data=tips, x=\"total_bill\", y=\"tip\", hue=\"size\", palette=\"deep\",\n",
")"
"sns.scatterplot(data=tips, x=\"total_bill\", y=\"tip\", hue=\"size\", palette=\"deep\")"
]
},
{
"cell_type": "raw",
"metadata": {},
"source": [
"If there are a large number of unique numeric values, the legend will show a representative, evenly-spaced set:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"tip_rate = tips.eval(\"tip / total_bill\").rename(\"tip_rate\")\n",
"sns.scatterplot(data=tips, x=\"total_bill\", y=\"tip\", hue=tip_rate)"
]
},
{
Expand Down
4 changes: 3 additions & 1 deletion doc/releases/v0.11.0.txt
Expand Up @@ -79,6 +79,8 @@ TODO organize by module.

- Plots with a ``style`` semantic can now generate an infinite number of unique dashes and/or markers by default. Previously, an error would be raised if the ``style`` variable had more levels than could be mapped using the default lists. The existing defaults were slightly modified as part of this change; if you need to exactly reproduce plots from earlier versions, refer to the `old defaults <https://github.com/mwaskom/seaborn/blob/v0.10.1/seaborn/relational.py#L24>`_ (:pr:`2075`).

- Reduced some of the surprising behavior of relational plot legends when using a numeric hue or size mapping. Added an "auto" mode (the new default) that chooses between "brief" and "full" based on the number of unique levels of the variable(s). Modified the ticking algorithm to only show values inside the limits of the data and to show up to 6 values. Changed the approach to the legend title: the normal matplotlib legend title is used when only one variable is assigned a semantic mapping, whereas the old approach of adding an invisible legend artist with a subtitle label is used only when multiple semantic variables are defined. The subtitles are now also left-aligned and set to the legend title font size. (:pr:`2229`).

- Fixed a few issues with :func:`boxenplot` regarding the way the number of boxes are calculated. ``k_depth="tukey"`` is now the default boxes calculation method as the previous default (`"proportion"`) produces too many boxes when datasets are small. Added the option to specify the desired number of boxes as a scalar (e.g. ``k_depth=6``) or just plot boxes that will cover most of the data points (with ``k_depth="full"``). Added a new parameter ``trust_alpha`` to control the number of boxes when ``k_depth="trustworthy"``. Additionally, the visual appearance of :func:`boxenplot` now more closely resembles :func:`boxplot`, and thin boxes will remain visible when the edges are white. Finally, the ``lvplot`` function (the previously-deprecated name for :func:`boxenplot`) has been removed. (:pr:`2086`).

- Added a ``tight_layout`` method to :class:`FacetGrid` and :class:`PairGrid`, which runs the :func:`matplotlib.pyplot.tight_layout` algorithm without interference from the external legend (:pr:`2073`).
Expand All @@ -87,7 +89,7 @@ TODO organize by module.

- Adapted to a change in matplotlib that prevented passing vectors of literal values to ``c`` and ``s`` in :func:`scatterplot` (:pr:`2079`).

- Added an explicit warning in :func:`swarmplot` when more than 2% of the points overlap in the "gutters" of the swarm (:pr:`2045`).
- Added an explicit warning in :func:`swarmplot` when more than 5% of the points overlap in the "gutters" of the swarm (:pr:`2045`).

- Added the ``axes_dict`` attribute to :class:`FacetGrid` for named access to the component axes (:pr:`2046`).

Expand Down
2 changes: 1 addition & 1 deletion seaborn/_core.py
Expand Up @@ -243,7 +243,7 @@ def numeric_mapping(self, data, palette, norm):
try:
cmap = mpl.cm.get_cmap(palette)
except (ValueError, TypeError):
err = "Palette {} not understood"
err = f"Palette {palette} not understood"
raise ValueError(err)

# Now sort out the data normalization
Expand Down
13 changes: 11 additions & 2 deletions seaborn/axisgrid.py
Expand Up @@ -11,7 +11,7 @@

from ._core import VectorPlotter, variable_type, categorical_order
from . import utils
from .utils import _check_argument
from .utils import _check_argument, adjust_legend_subtitles
from .palettes import color_palette, blend_palette
from ._decorators import _deprecate_positional_args
from ._docstrings import (
Expand Down Expand Up @@ -56,7 +56,7 @@ def tight_layout(self, *args, **kwargs):
self.fig.tight_layout(*args, **kwargs)

def add_legend(self, legend_data=None, title=None, label_order=None,
**kwargs):
adjust_subtitles=False, **kwargs):
"""Draw a legend, maybe placing it outside axes and resizing the figure.
Parameters
Expand All @@ -70,6 +70,9 @@ def add_legend(self, legend_data=None, title=None, label_order=None,
label_order : list of labels
The order that the legend entries should appear in. The default
reads from ``self.hue_names``.
adjust_subtitles : bool
If True, modify entries with invisible artists to left-align
the labels and set the font size to that of a title.
kwargs : key, value pairings
Other keyword arguments are passed to the underlying legend methods
on the Figure or Axes object.
Expand Down Expand Up @@ -123,6 +126,9 @@ def add_legend(self, legend_data=None, title=None, label_order=None,
self._legend = figlegend
figlegend.set_title(title, prop={"size": title_size})

if adjust_subtitles:
adjust_legend_subtitles(figlegend)

# Draw the plot to set the bounding boxes correctly
if hasattr(self.fig.canvas, "get_renderer"):
self.fig.draw(self.fig.canvas.get_renderer())
Expand Down Expand Up @@ -152,6 +158,9 @@ def add_legend(self, legend_data=None, title=None, label_order=None,
ax = self.axes.flat[0]
kwargs.setdefault("loc", "best")

if adjust_subtitles:
adjust_legend_subtitles(figlegend)

leg = ax.legend(handles, labels, **kwargs)
leg.set_title(title, prop={"size": title_size})
self._legend = leg
Expand Down
78 changes: 56 additions & 22 deletions seaborn/relational.py
Expand Up @@ -11,6 +11,7 @@
from .utils import (
ci_to_errsize,
locator_to_legend_entries,
adjust_legend_subtitles,
ci as ci_func
)
from .algorithms import bootstrap
Expand Down Expand Up @@ -156,11 +157,12 @@
Seed or random number generator for reproducible bootstrapping.
""",
legend="""
legend : "brief", "full", or False
legend : "auto", "brief", "full", or False
How to draw the legend. If "brief", numeric ``hue`` and ``size``
variables will be represented with a sample of evenly spaced values.
If "full", every group will get an entry in the legend. If ``False``,
no legend data is added and no legend is drawn.
If "full", every group will get an entry in the legend. If "auto",
choose between brief or full representation based on number of levels.
If ``False``, no legend data is added and no legend is drawn.
""",
ax_in="""
ax : matplotlib Axes
Expand Down Expand Up @@ -193,14 +195,31 @@ class _RelationalPlotter(VectorPlotter):
def add_legend_data(self, ax):
"""Add labeled artists to represent the different plot semantics."""
verbosity = self.legend
if verbosity not in ["brief", "full"]:
err = "`legend` must be 'brief', 'full', or False"
if isinstance(verbosity, str) and verbosity not in ["auto", "brief", "full"]:
err = "`legend` must be 'auto', 'brief', 'full', or a boolean."
raise ValueError(err)
elif verbosity is True:
verbosity = "auto"

legend_kwargs = {}
keys = []

title_kws = dict(color="w", s=0, linewidth=0, marker="", dashes="")
# Assign a legend title if there is only going to be one sub-legend,
# otherwise, subtitles will be inserted into the texts list with an
# invisible handle (which is a hack)
titles = {
title for title in
(self.variables.get(v, None) for v in ["hue", "size", "style"])
if title is not None
}
if len(titles) == 1:
legend_title = titles.pop()
else:
legend_title = ""

title_kws = dict(
visible=False, color="w", s=0, linewidth=0, marker="", dashes=""
)

def update(var_name, val_name, **kws):

Expand All @@ -212,12 +231,19 @@ def update(var_name, val_name, **kws):

legend_kwargs[key] = dict(**kws)

# Define the maximum number of ticks to use for "brief" legends
brief_ticks = 6

# -- Add a legend for hue semantics
if verbosity == "brief" and self._hue_map.map_type == "numeric":
brief_hue = self._hue_map.map_type == "numeric" and (
verbosity == "brief"
or (verbosity == "auto" and len(self._hue_map.levels) > brief_ticks)
)
if brief_hue:
if isinstance(self._hue_map.norm, mpl.colors.LogNorm):
locator = mpl.ticker.LogLocator(numticks=3)
locator = mpl.ticker.LogLocator(numticks=brief_ticks)
else:
locator = mpl.ticker.MaxNLocator(nbins=3)
locator = mpl.ticker.MaxNLocator(nbins=brief_ticks)
limits = min(self._hue_map.levels), max(self._hue_map.levels)
hue_levels, hue_formatted_levels = locator_to_legend_entries(
locator, limits, self.plot_data["hue"].infer_objects().dtype
Expand All @@ -228,7 +254,7 @@ def update(var_name, val_name, **kws):
hue_levels = hue_formatted_levels = self._hue_map.levels

# Add the hue semantic subtitle
if "hue" in self.variables and self.variables["hue"] is not None:
if not legend_title and self.variables.get("hue", None) is not None:
update((self.variables["hue"], "title"),
self.variables["hue"], **title_kws)

Expand All @@ -239,13 +265,16 @@ def update(var_name, val_name, **kws):
update(self.variables["hue"], formatted_level, color=color)

# -- Add a legend for size semantics

if verbosity == "brief" and self._size_map.map_type == "numeric":
brief_size = self._size_map.map_type == "numeric" and (
verbosity == "brief"
or (verbosity == "auto" and len(self._size_map.levels) > brief_ticks)
)
if brief_size:
# Define how ticks will interpolate between the min/max data values
if isinstance(self._size_map.norm, mpl.colors.LogNorm):
locator = mpl.ticker.LogLocator(numticks=3)
locator = mpl.ticker.LogLocator(numticks=brief_ticks)
else:
locator = mpl.ticker.MaxNLocator(nbins=3)
locator = mpl.ticker.MaxNLocator(nbins=brief_ticks)
# Define the min/max data values
limits = min(self._size_map.levels), max(self._size_map.levels)
size_levels, size_formatted_levels = locator_to_legend_entries(
Expand All @@ -257,7 +286,7 @@ def update(var_name, val_name, **kws):
size_levels = size_formatted_levels = self._size_map.levels

# Add the size semantic subtitle
if "size" in self.variables and self.variables["size"] is not None:
if not legend_title and self.variables.get("size", None) is not None:
update((self.variables["size"], "title"),
self.variables["size"], **title_kws)

Expand All @@ -275,7 +304,7 @@ def update(var_name, val_name, **kws):
# -- Add a legend for style semantics

# Add the style semantic title
if "style" in self.variables and self.variables["style"] is not None:
if not legend_title and self.variables.get("style", None) is not None:
update((self.variables["style"], "title"),
self.variables["style"], **title_kws)

Expand Down Expand Up @@ -311,6 +340,7 @@ def update(var_name, val_name, **kws):
legend_data[key] = artist
legend_order.append(key)

self.legend_title = legend_title
self.legend_data = legend_data
self.legend_order = legend_order

Expand Down Expand Up @@ -522,7 +552,8 @@ def plot(self, ax, kws):
self.add_legend_data(ax)
handles, _ = ax.get_legend_handles_labels()
if handles:
ax.legend()
legend = ax.legend(title=self.legend_title)
adjust_legend_subtitles(legend)


class _ScatterPlotter(_RelationalPlotter):
Expand Down Expand Up @@ -625,7 +656,8 @@ def plot(self, ax, kws):
self.add_legend_data(ax)
handles, _ = ax.get_legend_handles_labels()
if handles:
ax.legend()
legend = ax.legend(title=self.legend_title)
adjust_legend_subtitles(legend)


@_deprecate_positional_args
Expand All @@ -639,7 +671,7 @@ def lineplot(
dashes=True, markers=None, style_order=None,
units=None, estimator="mean", ci=95, n_boot=1000, seed=None,
sort=True, err_style="band", err_kws=None,
legend="brief", ax=None, **kwargs
legend="auto", ax=None, **kwargs
):

variables = _LinePlotter.get_semantics(locals())
Expand Down Expand Up @@ -755,7 +787,7 @@ def scatterplot(
x_bins=None, y_bins=None,
units=None, estimator=None, ci=95, n_boot=1000,
alpha=None, x_jitter=None, y_jitter=None,
legend="brief", ax=None, **kwargs
legend="auto", ax=None, **kwargs
):

variables = _ScatterPlotter.get_semantics(locals())
Expand Down Expand Up @@ -866,7 +898,7 @@ def relplot(
palette=None, hue_order=None, hue_norm=None,
sizes=None, size_order=None, size_norm=None,
markers=None, dashes=None, style_order=None,
legend="brief", kind="scatter",
legend="auto", kind="scatter",
height=5, aspect=1, facet_kws=None,
units=None,
**kwargs
Expand Down Expand Up @@ -996,7 +1028,9 @@ def relplot(
p.add_legend_data(g.axes.flat[0])
if p.legend_data:
g.add_legend(legend_data=p.legend_data,
label_order=p.legend_order)
label_order=p.legend_order,
title=p.legend_title,
adjust_subtitles=True)

return g

Expand Down

0 comments on commit 4dd57d6

Please sign in to comment.