Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Add legend support for boxplots #27840

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 5 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
60 changes: 60 additions & 0 deletions 60 doc/users/next_whats_new/boxplot_legend_support.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
Legend support for Boxplot
~~~~~~~~~~~~~~~~~~~~~~~~~~
Boxplots now support a *label* parameter to create legend entries.
QuLogic marked this conversation as resolved.
Show resolved Hide resolved

Legend labels can be passed as a list of strings to label multiple boxes in a single
boxplot call:


.. plot::
Copy link
Member

Choose a reason for hiding this comment

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

I suggest to also add a plot for the single-string case. Something like #27840 (comment) - you may switch to patch_artist=True and color the boxes so that the distinction between the two sets is more obvious than just the color of the medians.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do we want this behaviour when boxplots with patches are mixed with ones without?

2box

Copy link
Member

@timhoffm timhoffm Mar 12, 2024

Choose a reason for hiding this comment

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

There’s no way around this. Tge individual boxplots do not know about each other and a non-patch_artist, boxplot can only have a line as legend entry, whereas the patch_artist boxplots should have the box. It’s the responsibility of the user to create a consistent plot. So, this behavior is ok.

Copy link
Member

Choose a reason for hiding this comment

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

Just wondering: Shouldn’t the color of the line for data A not be the orange of the median line?

Copy link
Contributor Author

@saranti saranti Mar 12, 2024

Choose a reason for hiding this comment

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

Yes it should! For some reason it only does if showbox=False. I didn't notice it before

Edit: box_or_med needed another condition to check if patch_artist was on before it was assigned either a box or median object. If there is no patch then box_or_med should always be a median and the legend handles should get the median line and its kwargs.

:include-source: true
:alt: Example of creating 3 boxplots and assigning legend labels as a sequence.

import matplotlib.pyplot as plt
import numpy as np

np.random.seed(19680801)
fruit_weights = [
np.random.normal(130, 10, size=100),
np.random.normal(125, 20, size=100),
np.random.normal(120, 30, size=100),
]
labels = ['peaches', 'oranges', 'tomatoes']
colors = ['peachpuff', 'orange', 'tomato']

fig, ax = plt.subplots()
ax.set_ylabel('fruit weight (g)')

bplot = ax.boxplot(fruit_weights,
patch_artist=True, # fill with color
label=labels)

# fill with colors
for patch, color in zip(bplot['boxes'], colors):
patch.set_facecolor(color)

ax.set_xticks([])
ax.legend()
timhoffm marked this conversation as resolved.
Show resolved Hide resolved


Or as a single string to each individual boxplot:

.. plot::
:include-source: true
:alt: Example of creating 2 boxplots and assigning each legend label as a string.

import matplotlib.pyplot as plt
import numpy as np

fig, ax = plt.subplots()
timhoffm marked this conversation as resolved.
Show resolved Hide resolved

data_A = np.random.random((100, 3))
data_B = np.random.random((100, 3)) + 0.2
pos = np.arange(3)

ax.boxplot(data_A, positions=pos - 0.2, patch_artist=True, label='Box A',
boxprops={'facecolor': 'steelblue'})
ax.boxplot(data_B, positions=pos + 0.2, patch_artist=True, label='Box B',
boxprops={'facecolor': 'lightblue'})

ax.legend()
45 changes: 41 additions & 4 deletions 45 lib/matplotlib/axes/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3798,7 +3798,7 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
tick_labels=None, flierprops=None, medianprops=None,
meanprops=None, capprops=None, whiskerprops=None,
manage_ticks=True, autorange=False, zorder=None,
capwidths=None):
capwidths=None, label=None):
"""
Draw a box and whisker plot.

Expand Down Expand Up @@ -3985,6 +3985,18 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
The style of the median.
meanprops : dict, default: None
The style of the mean.
label : str or list of str, optional
Legend labels. Use a single string when all boxes have the same style and
you only want a single legend entry for them. Use a list of strings to
label all boxes individually. To be distinguishable, the boxes should be
styled individually, which is currently only possible by modifying the
returned artists, see e.g. :doc:`/gallery/statistics/boxplot_demo`.

In the case of a single string, the legend entry will technically be
associated with the first box only. By default, the legend will show the
median line (``result["medians"]``); if *patch_artist* is True, the legend
will show the box `.Patch` artists (``result["boxes"]``) instead.

data : indexable object, optional
QuLogic marked this conversation as resolved.
Show resolved Hide resolved
DATA_PARAMETER_PLACEHOLDER

Expand Down Expand Up @@ -4105,7 +4117,7 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
meanline=meanline, showfliers=showfliers,
capprops=capprops, whiskerprops=whiskerprops,
manage_ticks=manage_ticks, zorder=zorder,
capwidths=capwidths)
capwidths=capwidths, label=label)
return artists

def bxp(self, bxpstats, positions=None, widths=None, vert=True,
Expand All @@ -4114,7 +4126,7 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True,
boxprops=None, whiskerprops=None, flierprops=None,
medianprops=None, capprops=None, meanprops=None,
meanline=False, manage_ticks=True, zorder=None,
capwidths=None):
capwidths=None, label=None):
timhoffm marked this conversation as resolved.
Show resolved Hide resolved
"""
Draw a box and whisker plot from pre-computed statistics.

Expand Down Expand Up @@ -4197,6 +4209,18 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True,
If True, the tick locations and labels will be adjusted to match the
boxplot positions.

label : str or list of str, optional
Legend labels. Use a single string when all boxes have the same style and
you only want a single legend entry for them. Use a list of strings to
label all boxes individually. To be distinguishable, the boxes should be
styled individually, which is currently only possible by modifying the
returned artists, see e.g. :doc:`/gallery/statistics/boxplot_demo`.

In the case of a single string, the legend entry will technically be
associated with the first box only. By default, the legend will show the
median line (``result["medians"]``); if *patch_artist* is True, the legend
will show the box `.Patch` artists (``result["boxes"]``) instead.

zorder : float, default: ``Line2D.zorder = 2``
QuLogic marked this conversation as resolved.
Show resolved Hide resolved
The zorder of the resulting boxplot.

Expand Down Expand Up @@ -4361,6 +4385,7 @@ def do_patch(xs, ys, **kwargs):
if showbox:
do_box = do_patch if patch_artist else do_plot
boxes.append(do_box(box_x, box_y, **box_kw))
median_kw.setdefault('label', '_nolegend_')
# draw the whiskers
whisker_kw.setdefault('label', '_nolegend_')
whiskers.append(do_plot(whis_x, whislo_y, **whisker_kw))
Expand All @@ -4371,7 +4396,6 @@ def do_patch(xs, ys, **kwargs):
caps.append(do_plot(cap_x, cap_lo, **cap_kw))
caps.append(do_plot(cap_x, cap_hi, **cap_kw))
# draw the medians
median_kw.setdefault('label', '_nolegend_')
medians.append(do_plot(med_x, med_y, **median_kw))
# maybe draw the means
if showmeans:
Expand All @@ -4389,6 +4413,19 @@ def do_patch(xs, ys, **kwargs):
flier_y = stats['fliers']
fliers.append(do_plot(flier_x, flier_y, **flier_kw))

# Set legend labels
if label:
box_or_med = boxes if showbox and patch_artist else medians
if cbook.is_scalar_or_string(label):
# assign the label only to the first box
box_or_med[0].set_label(label)
else: # label is a sequence
if len(box_or_med) != len(label):
raise ValueError("There must be an equal number of legend"
" labels and boxplots.")
Copy link
Member

Choose a reason for hiding this comment

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

This check should probably be in the "input validation" section, and reuse datashape_message.

for artist, lbl in zip(box_or_med, label):
artist.set_label(lbl)

if manage_ticks:
axis_name = "x" if vert else "y"
interval = getattr(self.dataLim, f"interval{axis_name}")
Expand Down
2 changes: 2 additions & 0 deletions 2 lib/matplotlib/axes/_axes.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ class Axes(_AxesBase):
autorange: bool = ...,
zorder: float | None = ...,
capwidths: float | ArrayLike | None = ...,
label: Sequence[str] | None = ...,
*,
QuLogic marked this conversation as resolved.
Show resolved Hide resolved
data=...,
) -> dict[str, Any]: ...
Expand All @@ -397,6 +398,7 @@ class Axes(_AxesBase):
manage_ticks: bool = ...,
zorder: float | None = ...,
capwidths: float | ArrayLike | None = ...,
label: Sequence[str] | None = ...,
) -> dict[str, Any]: ...
QuLogic marked this conversation as resolved.
Show resolved Hide resolved
def scatter(
self,
Expand Down
2 changes: 2 additions & 0 deletions 2 lib/matplotlib/pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -2871,6 +2871,7 @@ def boxplot(
autorange: bool = False,
zorder: float | None = None,
capwidths: float | ArrayLike | None = None,
label: Sequence[str] | None = None,
*,
data=None,
) -> dict[str, Any]:
Expand Down Expand Up @@ -2902,6 +2903,7 @@ def boxplot(
autorange=autorange,
zorder=zorder,
capwidths=capwidths,
label=label,
**({"data": data} if data is not None else {}),
)

Expand Down
32 changes: 32 additions & 0 deletions 32 lib/matplotlib/tests/test_legend.py
Original file line number Diff line number Diff line change
Expand Up @@ -1435,3 +1435,35 @@ def test_legend_text():
leg_bboxes.append(
leg.get_window_extent().transformed(ax.transAxes.inverted()))
assert_allclose(leg_bboxes[1].bounds, leg_bboxes[0].bounds)


def test_boxplot_legend_labels():
# Test that legend entries are generated when passing `label`.
np.random.seed(19680801)
data = np.random.random((10, 4))
fig, axs = plt.subplots(nrows=1, ncols=4)
legend_labels = ['box A', 'box B', 'box C', 'box D']

# Testing legend labels and patch passed to legend.
bp1 = axs[0].boxplot(data, patch_artist=True, label=legend_labels)
assert [v.get_label() for v in bp1['boxes']] == legend_labels
handles, labels = axs[0].get_legend_handles_labels()
assert labels == legend_labels
assert all(isinstance(h, mpl.patches.PathPatch) for h in handles)

saranti marked this conversation as resolved.
Show resolved Hide resolved
# Testing legend without `box`.
bp2 = axs[1].boxplot(data, label=legend_labels, showbox=False)
# Without a box, The legend entries should be passed from the medians.
assert [v.get_label() for v in bp2['medians']] == legend_labels
handles, labels = axs[1].get_legend_handles_labels()
assert labels == legend_labels
assert all(isinstance(h, mpl.lines.Line2D) for h in handles)

# Testing legend with number of labels different from number of boxes.
with pytest.raises(ValueError, match='There must be an equal number'):
bp3 = axs[2].boxplot(data, label=legend_labels[:-1])

# Test that for a string label, only the first box gets a label.
bp4 = axs[3].boxplot(data, label='box A')
assert bp4['medians'][0].get_label() == 'box A'
assert all(x.get_label().startswith("_") for x in bp4['medians'][1:])
Morty Proxy This is a proxified and sanitized view of the page, visit original site.