Skip to content

Commit 95890c2

Browse files
committed
add legend to boxplot
1 parent 72731a5 commit 95890c2

File tree

5 files changed

+152
-4
lines changed

5 files changed

+152
-4
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
Legend support for Boxplot
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~
3+
Boxplots now support a *label* parameter to create legend entries.
4+
5+
Legend labels can be passed as a list of strings to label multiple boxes in a single
6+
boxplot call:
7+
8+
9+
.. plot::
10+
:include-source: true
11+
:alt: Example of creating 3 boxplots and assigning legend labels as a sequence.
12+
13+
import matplotlib.pyplot as plt
14+
import numpy as np
15+
16+
np.random.seed(19680801)
17+
fruit_weights = [
18+
np.random.normal(130, 10, size=100),
19+
np.random.normal(125, 20, size=100),
20+
np.random.normal(120, 30, size=100),
21+
]
22+
labels = ['peaches', 'oranges', 'tomatoes']
23+
colors = ['peachpuff', 'orange', 'tomato']
24+
25+
fig, ax = plt.subplots()
26+
ax.set_ylabel('fruit weight (g)')
27+
28+
bplot = ax.boxplot(fruit_weights,
29+
patch_artist=True, # fill with color
30+
label=labels)
31+
32+
# fill with colors
33+
for patch, color in zip(bplot['boxes'], colors):
34+
patch.set_facecolor(color)
35+
36+
ax.set_xticks([])
37+
ax.legend()
38+
39+
40+
Or as a single string to each individual boxplot:
41+
42+
.. plot::
43+
:include-source: true
44+
:alt: Example of creating 2 boxplots and assigning each legend label as a string.
45+
46+
import matplotlib.pyplot as plt
47+
import numpy as np
48+
49+
fig, ax = plt.subplots()
50+
51+
data_A = np.random.random((100, 3))
52+
data_B = np.random.random((100, 3)) + 0.2
53+
pos = np.arange(3)
54+
55+
ax.boxplot(data_A, positions=pos - 0.2, patch_artist=True, label='Box A',
56+
boxprops={'facecolor': 'steelblue'})
57+
ax.boxplot(data_B, positions=pos + 0.2, patch_artist=True, label='Box B',
58+
boxprops={'facecolor': 'lightblue'})
59+
60+
ax.legend()

lib/matplotlib/axes/_axes.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3798,7 +3798,7 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
37983798
tick_labels=None, flierprops=None, medianprops=None,
37993799
meanprops=None, capprops=None, whiskerprops=None,
38003800
manage_ticks=True, autorange=False, zorder=None,
3801-
capwidths=None):
3801+
capwidths=None, label=None):
38023802
"""
38033803
Draw a box and whisker plot.
38043804
@@ -3985,6 +3985,18 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
39853985
The style of the median.
39863986
meanprops : dict, default: None
39873987
The style of the mean.
3988+
label : str or list of str, optional
3989+
Legend labels. Use a single string when all boxes have the same style and
3990+
you only want a single legend entry for them. Use a list of strings to
3991+
label all boxes individually. To be distinguishable, the boxes should be
3992+
styled individually, which is currently only possible by modifying the
3993+
returned artists, see e.g. :doc:`/gallery/statistics/boxplot_demo`.
3994+
3995+
In the case of a single string, the legend entry will technically be
3996+
associated with the first box only. By default, the legend will show the
3997+
median line (``result["medians"]``); if *patch_artist* is True, the legend
3998+
will show the box `Patch` artists (``result["boxes"]``) instead.
3999+
39884000
data : indexable object, optional
39894001
DATA_PARAMETER_PLACEHOLDER
39904002
@@ -4105,7 +4117,7 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
41054117
meanline=meanline, showfliers=showfliers,
41064118
capprops=capprops, whiskerprops=whiskerprops,
41074119
manage_ticks=manage_ticks, zorder=zorder,
4108-
capwidths=capwidths)
4120+
capwidths=capwidths, label=label)
41094121
return artists
41104122

41114123
def bxp(self, bxpstats, positions=None, widths=None, vert=True,
@@ -4114,7 +4126,7 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True,
41144126
boxprops=None, whiskerprops=None, flierprops=None,
41154127
medianprops=None, capprops=None, meanprops=None,
41164128
meanline=False, manage_ticks=True, zorder=None,
4117-
capwidths=None):
4129+
capwidths=None, label=None):
41184130
"""
41194131
Draw a box and whisker plot from pre-computed statistics.
41204132
@@ -4197,6 +4209,18 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True,
41974209
If True, the tick locations and labels will be adjusted to match the
41984210
boxplot positions.
41994211
4212+
label : str or list of str, optional
4213+
Legend labels. Use a single string when all boxes have the same style and
4214+
you only want a single legend entry for them. Use a list of strings to
4215+
label all boxes individually. To be distinguishable, the boxes should be
4216+
styled individually, which is currently only possible by modifying the
4217+
returned artists, see e.g. :doc:`/gallery/statistics/boxplot_demo`.
4218+
4219+
In the case of a single string, the legend entry will technically be
4220+
associated with the first box only. By default, the legend will show the
4221+
median line (``result["medians"]``); if *patch_artist* is True, the legend
4222+
will show the box `Patch` artists (``result["boxes"]``) instead.
4223+
42004224
zorder : float, default: ``Line2D.zorder = 2``
42014225
The zorder of the resulting boxplot.
42024226
@@ -4361,6 +4385,7 @@ def do_patch(xs, ys, **kwargs):
43614385
if showbox:
43624386
do_box = do_patch if patch_artist else do_plot
43634387
boxes.append(do_box(box_x, box_y, **box_kw))
4388+
median_kw.setdefault('label', '_nolegend_')
43644389
# draw the whiskers
43654390
whisker_kw.setdefault('label', '_nolegend_')
43664391
whiskers.append(do_plot(whis_x, whislo_y, **whisker_kw))
@@ -4371,7 +4396,6 @@ def do_patch(xs, ys, **kwargs):
43714396
caps.append(do_plot(cap_x, cap_lo, **cap_kw))
43724397
caps.append(do_plot(cap_x, cap_hi, **cap_kw))
43734398
# draw the medians
4374-
median_kw.setdefault('label', '_nolegend_')
43754399
medians.append(do_plot(med_x, med_y, **median_kw))
43764400
# maybe draw the means
43774401
if showmeans:
@@ -4389,6 +4413,19 @@ def do_patch(xs, ys, **kwargs):
43894413
flier_y = stats['fliers']
43904414
fliers.append(do_plot(flier_x, flier_y, **flier_kw))
43914415

4416+
# Set legend labels
4417+
if label:
4418+
box_or_med = boxes if showbox and patch_artist else medians
4419+
if cbook.is_scalar_or_string(label):
4420+
# assign the label only to the first box
4421+
box_or_med[0].set_label(label)
4422+
else: # label is a sequence
4423+
if len(box_or_med) != len(label):
4424+
raise ValueError("There must be an equal number of legend"
4425+
" labels and boxplots.")
4426+
for artist, lbl in zip(box_or_med, label):
4427+
artist.set_label(lbl)
4428+
43924429
if manage_ticks:
43934430
axis_name = "x" if vert else "y"
43944431
interval = getattr(self.dataLim, f"interval{axis_name}")

lib/matplotlib/axes/_axes.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ class Axes(_AxesBase):
372372
autorange: bool = ...,
373373
zorder: float | None = ...,
374374
capwidths: float | ArrayLike | None = ...,
375+
label: Sequence[str] | None = ...,
375376
*,
376377
data=...,
377378
) -> dict[str, Any]: ...
@@ -397,6 +398,7 @@ class Axes(_AxesBase):
397398
manage_ticks: bool = ...,
398399
zorder: float | None = ...,
399400
capwidths: float | ArrayLike | None = ...,
401+
label: Sequence[str] | None = ...,
400402
) -> dict[str, Any]: ...
401403
def scatter(
402404
self,

lib/matplotlib/pyplot.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2871,6 +2871,7 @@ def boxplot(
28712871
autorange: bool = False,
28722872
zorder: float | None = None,
28732873
capwidths: float | ArrayLike | None = None,
2874+
label: Sequence[str] | None = None,
28742875
*,
28752876
data=None,
28762877
) -> dict[str, Any]:
@@ -2902,6 +2903,7 @@ def boxplot(
29022903
autorange=autorange,
29032904
zorder=zorder,
29042905
capwidths=capwidths,
2906+
label=label,
29052907
**({"data": data} if data is not None else {}),
29062908
)
29072909

lib/matplotlib/tests/test_legend.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,3 +1435,50 @@ def test_legend_text():
14351435
leg_bboxes.append(
14361436
leg.get_window_extent().transformed(ax.transAxes.inverted()))
14371437
assert_allclose(leg_bboxes[1].bounds, leg_bboxes[0].bounds)
1438+
1439+
1440+
def test_boxplot_labels():
1441+
# Test that boxplot(..., labels=) sets the tick labels but not legend entries
1442+
# This is not consistent with other plot types but is the current behavior.
1443+
fig, ax = plt.subplots()
1444+
np.random.seed(19680801)
1445+
data = np.random.random((10, 3))
1446+
bp = ax.boxplot(data, labels=['A', 'B', 'C'])
1447+
# Check that labels set the tick labels ...
1448+
assert [l.get_text() for l in ax.get_xticklabels()] == ['A', 'B', 'C']
1449+
# ... but not legend entries
1450+
handles, labels = ax.get_legend_handles_labels()
1451+
assert len(handles) == 0
1452+
assert len(labels) == 0
1453+
1454+
1455+
def test_boxplot_legend_labels():
1456+
# Test that legend entries are generated when passing `label`.
1457+
np.random.seed(19680801)
1458+
data = np.random.random((10, 4))
1459+
fig, axs = plt.subplots(nrows=1, ncols=4)
1460+
legend_labels = ['box A', 'box B', 'box C', 'box D']
1461+
1462+
# Testing legend labels and patch passed to legend.
1463+
bp1 = axs[0].boxplot(data, patch_artist=True, label=legend_labels)
1464+
assert [v.get_label() for v in bp1['boxes']] == legend_labels
1465+
handles, labels = axs[0].get_legend_handles_labels()
1466+
assert labels == legend_labels
1467+
assert all(isinstance(h, mpl.patches.PathPatch) for h in handles)
1468+
1469+
# Testing legend without `box`.
1470+
bp2 = axs[1].boxplot(data, label=legend_labels, showbox=False)
1471+
# Without a box, The legend entries should be passed from the medians.
1472+
assert [v.get_label() for v in bp2['medians']] == legend_labels
1473+
handles, labels = axs[1].get_legend_handles_labels()
1474+
assert labels == legend_labels
1475+
assert all(isinstance(h, mpl.lines.Line2D) for h in handles)
1476+
1477+
# Testing legend with number of labels different from number of boxes.
1478+
with pytest.raises(ValueError, match='There must be an equal number'):
1479+
bp3 = axs[2].boxplot(data, label=legend_labels[:-1])
1480+
1481+
# Test that for a string label, only the first box gets a label.
1482+
bp4 = axs[3].boxplot(data, label='box A')
1483+
assert bp4['medians'][0].get_label() == 'box A'
1484+
assert all(x.get_label().startswith("_") for x in bp4['medians'][1:])

0 commit comments

Comments
 (0)