Skip to content

Commit 0b5d5d6

Browse files
committed
add legend support for boxplots
1 parent 76eaa96 commit 0b5d5d6

File tree

12 files changed

+271
-72
lines changed

12 files changed

+271
-72
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
``boxplot`` legend labels
2+
~~~~~~~~~~~~~~~~~~~~~~~~~
3+
The tick labels on `~.Axes.boxplot` were previously set with the *labels* parameter.
4+
This has been changed to *tick_labels* to be consistent with `~.Axes.bar` and to
5+
accommodate the newly introduced *label* parameter for the legend labels.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
Legend support for Boxplot
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~
3+
Boxplots now generate legend entries and can be labelled with the
4+
new *label* parameter. If a patch is passed to the box with ``show_patch=True``,
5+
the legend gets its handle from the patch instead of the `.Line2D` object from
6+
the whiskers.
7+
The old *labels* parameter that was used for setting tick labels is deprecated
8+
and replaced with *tick_labels*.
9+
10+
.. plot::
11+
:include-source: true
12+
:alt: Example of creating 3 boxplots and assigning legend labels and tick labels with keywords.
13+
14+
import matplotlib.pyplot as plt
15+
import numpy as np
16+
17+
np.random.seed(19680801)
18+
fruit_weights = [
19+
np.random.normal(130, 10, size=100),
20+
np.random.normal(125, 20, size=100),
21+
np.random.normal(120, 30, size=100),
22+
]
23+
labels = ['peaches', 'oranges', 'tomatoes']
24+
colors = ['peachpuff', 'orange', 'tomato']
25+
tick_lb = ['A', 'B', 'C']
26+
27+
fig, ax = plt.subplots()
28+
ax.set_ylabel('fruit weight (g)')
29+
30+
bplot = ax.boxplot(fruit_weights,
31+
patch_artist=True, # fill with color
32+
tick_labels=tick_lb,
33+
label=labels)
34+
35+
# fill with colors
36+
for patch, color in zip(bplot['boxes'], colors):
37+
patch.set_facecolor(color)
38+
39+
ax.legend()
40+
plt.show()
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Add option to plot only one half of violin plot
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3+
4+
Setting the parameter *side* to 'low' or 'high' allows to only plot one half of the violin plot.

galleries/examples/statistics/violinplot.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,55 +28,73 @@
2828
pos = [1, 2, 4, 5, 7, 8]
2929
data = [np.random.normal(0, std, size=100) for std in pos]
3030

31-
fig, axs = plt.subplots(nrows=2, ncols=5, figsize=(10, 6))
31+
fig, axs = plt.subplots(nrows=2, ncols=6, figsize=(10, 4))
3232

3333
axs[0, 0].violinplot(data, pos, points=20, widths=0.3,
3434
showmeans=True, showextrema=True, showmedians=True)
35-
axs[0, 0].set_title('Custom violinplot 1', fontsize=fs)
35+
axs[0, 0].set_title('Custom violin 1', fontsize=fs)
3636

3737
axs[0, 1].violinplot(data, pos, points=40, widths=0.5,
3838
showmeans=True, showextrema=True, showmedians=True,
3939
bw_method='silverman')
40-
axs[0, 1].set_title('Custom violinplot 2', fontsize=fs)
40+
axs[0, 1].set_title('Custom violin 2', fontsize=fs)
4141

4242
axs[0, 2].violinplot(data, pos, points=60, widths=0.7, showmeans=True,
4343
showextrema=True, showmedians=True, bw_method=0.5)
44-
axs[0, 2].set_title('Custom violinplot 3', fontsize=fs)
44+
axs[0, 2].set_title('Custom violin 3', fontsize=fs)
4545

4646
axs[0, 3].violinplot(data, pos, points=60, widths=0.7, showmeans=True,
4747
showextrema=True, showmedians=True, bw_method=0.5,
4848
quantiles=[[0.1], [], [], [0.175, 0.954], [0.75], [0.25]])
49-
axs[0, 3].set_title('Custom violinplot 4', fontsize=fs)
49+
axs[0, 3].set_title('Custom violin 4', fontsize=fs)
5050

5151
axs[0, 4].violinplot(data[-1:], pos[-1:], points=60, widths=0.7,
5252
showmeans=True, showextrema=True, showmedians=True,
5353
quantiles=[0.05, 0.1, 0.8, 0.9], bw_method=0.5)
54-
axs[0, 4].set_title('Custom violinplot 5', fontsize=fs)
54+
axs[0, 4].set_title('Custom violin 5', fontsize=fs)
55+
56+
axs[0, 5].violinplot(data[-1:], pos[-1:], points=60, widths=0.7,
57+
showmeans=True, showextrema=True, showmedians=True,
58+
quantiles=[0.05, 0.1, 0.8, 0.9], bw_method=0.5, side='low')
59+
60+
axs[0, 5].violinplot(data[-1:], pos[-1:], points=60, widths=0.7,
61+
showmeans=True, showextrema=True, showmedians=True,
62+
quantiles=[0.05, 0.1, 0.8, 0.9], bw_method=0.5, side='high')
63+
axs[0, 5].set_title('Custom violin 6', fontsize=fs)
5564

5665
axs[1, 0].violinplot(data, pos, points=80, vert=False, widths=0.7,
5766
showmeans=True, showextrema=True, showmedians=True)
58-
axs[1, 0].set_title('Custom violinplot 6', fontsize=fs)
67+
axs[1, 0].set_title('Custom violin 7', fontsize=fs)
5968

6069
axs[1, 1].violinplot(data, pos, points=100, vert=False, widths=0.9,
6170
showmeans=True, showextrema=True, showmedians=True,
6271
bw_method='silverman')
63-
axs[1, 1].set_title('Custom violinplot 7', fontsize=fs)
72+
axs[1, 1].set_title('Custom violin 8', fontsize=fs)
6473

6574
axs[1, 2].violinplot(data, pos, points=200, vert=False, widths=1.1,
6675
showmeans=True, showextrema=True, showmedians=True,
6776
bw_method=0.5)
68-
axs[1, 2].set_title('Custom violinplot 8', fontsize=fs)
77+
axs[1, 2].set_title('Custom violin 9', fontsize=fs)
6978

7079
axs[1, 3].violinplot(data, pos, points=200, vert=False, widths=1.1,
7180
showmeans=True, showextrema=True, showmedians=True,
7281
quantiles=[[0.1], [], [], [0.175, 0.954], [0.75], [0.25]],
7382
bw_method=0.5)
74-
axs[1, 3].set_title('Custom violinplot 9', fontsize=fs)
83+
axs[1, 3].set_title('Custom violin 10', fontsize=fs)
7584

7685
axs[1, 4].violinplot(data[-1:], pos[-1:], points=200, vert=False, widths=1.1,
7786
showmeans=True, showextrema=True, showmedians=True,
7887
quantiles=[0.05, 0.1, 0.8, 0.9], bw_method=0.5)
79-
axs[1, 4].set_title('Custom violinplot 10', fontsize=fs)
88+
axs[1, 4].set_title('Custom violin 11', fontsize=fs)
89+
90+
axs[1, 5].violinplot(data[-1:], pos[-1:], points=200, vert=False, widths=1.1,
91+
showmeans=True, showextrema=True, showmedians=True,
92+
quantiles=[0.05, 0.1, 0.8, 0.9], bw_method=0.5, side='low')
93+
94+
axs[1, 5].violinplot(data[-1:], pos[-1:], points=200, vert=False, widths=1.1,
95+
showmeans=True, showextrema=True, showmedians=True,
96+
quantiles=[0.05, 0.1, 0.8, 0.9], bw_method=0.5, side='high')
97+
axs[1, 5].set_title('Custom violin 12', fontsize=fs)
8098

8199

82100
for ax in axs.flat:

lib/matplotlib/axes/_axes.py

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3765,15 +3765,16 @@ def apply_mask(arrays, mask):
37653765
return errorbar_container # (l0, caplines, barcols)
37663766

37673767
@_preprocess_data()
3768+
@_api.rename_parameter("3.9", "labels", "tick_labels")
37683769
def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
37693770
positions=None, widths=None, patch_artist=None,
37703771
bootstrap=None, usermedians=None, conf_intervals=None,
37713772
meanline=None, showmeans=None, showcaps=None,
37723773
showbox=None, showfliers=None, boxprops=None,
3773-
labels=None, flierprops=None, medianprops=None,
3774+
tick_labels=None, flierprops=None, medianprops=None,
37743775
meanprops=None, capprops=None, whiskerprops=None,
37753776
manage_ticks=True, autorange=False, zorder=None,
3776-
capwidths=None):
3777+
capwidths=None, label=None):
37773778
"""
37783779
Draw a box and whisker plot.
37793780
@@ -3884,9 +3885,10 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
38843885
If `False` produces boxes with the Line2D artist. Otherwise,
38853886
boxes are drawn with Patch artists.
38863887
3887-
labels : sequence, optional
3888-
Labels for each dataset (one per dataset). These are used for
3889-
x-tick labels; *not* for legend entries.
3888+
tick_labels : sequence, optional
3889+
Labels for each dataset (one per dataset).
3890+
3891+
.. versionadded:: 3.9
38903892
38913893
manage_ticks : bool, default: True
38923894
If True, the tick locations and labels will be adjusted to match
@@ -3954,6 +3956,11 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
39543956
The style of the median.
39553957
meanprops : dict, default: None
39563958
The style of the mean.
3959+
label : str or list of str, optional
3960+
Legend labels for each boxplot.
3961+
3962+
.. versionadded:: 3.9
3963+
39573964
data : indexable object, optional
39583965
DATA_PARAMETER_PLACEHOLDER
39593966
@@ -3970,7 +3977,7 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
39703977
bootstrap = mpl.rcParams['boxplot.bootstrap']
39713978

39723979
bxpstats = cbook.boxplot_stats(x, whis=whis, bootstrap=bootstrap,
3973-
labels=labels, autorange=autorange)
3980+
tick_labels=tick_labels, autorange=autorange)
39743981
if notch is None:
39753982
notch = mpl.rcParams['boxplot.notch']
39763983
if vert is None:
@@ -4006,6 +4013,9 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
40064013
if 'color' in boxprops:
40074014
boxprops['edgecolor'] = boxprops.pop('color')
40084015

4016+
if label:
4017+
boxprops['label'] = label
4018+
40094019
# if non-default sym value, put it into the flier dictionary
40104020
# the logic for providing the default symbol ('b+') now lives
40114021
# in bxp in the initial value of flierkw
@@ -4074,7 +4084,7 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
40744084
meanline=meanline, showfliers=showfliers,
40754085
capprops=capprops, whiskerprops=whiskerprops,
40764086
manage_ticks=manage_ticks, zorder=zorder,
4077-
capwidths=capwidths)
4087+
capwidths=capwidths, label=label)
40784088
return artists
40794089

40804090
def bxp(self, bxpstats, positions=None, widths=None, vert=True,
@@ -4083,7 +4093,7 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True,
40834093
boxprops=None, whiskerprops=None, flierprops=None,
40844094
medianprops=None, capprops=None, meanprops=None,
40854095
meanline=False, manage_ticks=True, zorder=None,
4086-
capwidths=None):
4096+
capwidths=None, label=None):
40874097
"""
40884098
Draw a box and whisker plot from pre-computed statistics.
40894099
@@ -4169,6 +4179,9 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True,
41694179
zorder : float, default: ``Line2D.zorder = 2``
41704180
The zorder of the resulting boxplot.
41714181
4182+
label : str or list of str, optional
4183+
Legend labels for each boxplot.
4184+
41724185
Returns
41734186
-------
41744187
dict
@@ -4330,8 +4343,8 @@ def do_patch(xs, ys, **kwargs):
43304343
if showbox:
43314344
do_box = do_patch if patch_artist else do_plot
43324345
boxes.append(do_box(box_x, box_y, **box_kw))
4346+
whisker_kw.setdefault('label', '_nolegend_')
43334347
# draw the whiskers
4334-
whisker_kw.setdefault('label', '_nolegend_')
43354348
whiskers.append(do_plot(whis_x, whislo_y, **whisker_kw))
43364349
whiskers.append(do_plot(whis_x, whishi_y, **whisker_kw))
43374350
# maybe draw the caps
@@ -4358,6 +4371,15 @@ def do_patch(xs, ys, **kwargs):
43584371
flier_y = stats['fliers']
43594372
fliers.append(do_plot(flier_x, flier_y, **flier_kw))
43604373

4374+
# Set legend labels
4375+
if label:
4376+
box_or_whis = boxes if showbox else whiskers
4377+
for index, element in enumerate(box_or_whis):
4378+
try:
4379+
element.set_label(label[index])
4380+
except Exception:
4381+
IndexError('list index out of range')
4382+
43614383
if manage_ticks:
43624384
axis_name = "x" if vert else "y"
43634385
interval = getattr(self.dataLim, f"interval{axis_name}")
@@ -8205,7 +8227,7 @@ def matshow(self, Z, **kwargs):
82058227
@_preprocess_data(replace_names=["dataset"])
82068228
def violinplot(self, dataset, positions=None, vert=True, widths=0.5,
82078229
showmeans=False, showextrema=True, showmedians=False,
8208-
quantiles=None, points=100, bw_method=None):
8230+
quantiles=None, points=100, bw_method=None, side='both'):
82098231
"""
82108232
Make a violin plot.
82118233
@@ -8256,6 +8278,10 @@ def violinplot(self, dataset, positions=None, vert=True, widths=0.5,
82568278
callable, it should take a `matplotlib.mlab.GaussianKDE` instance as
82578279
its only parameter and return a float.
82588280
8281+
side : {'both', 'low', 'high'}, default: 'both'
8282+
'both' plots standard violins. 'low'/'high' only
8283+
plots the side below/above the positions value.
8284+
82598285
data : indexable object, optional
82608286
DATA_PARAMETER_PLACEHOLDER
82618287
@@ -8307,10 +8333,10 @@ def _kde_method(X, coords):
83078333
quantiles=quantiles)
83088334
return self.violin(vpstats, positions=positions, vert=vert,
83098335
widths=widths, showmeans=showmeans,
8310-
showextrema=showextrema, showmedians=showmedians)
8336+
showextrema=showextrema, showmedians=showmedians, side=side)
83118337

83128338
def violin(self, vpstats, positions=None, vert=True, widths=0.5,
8313-
showmeans=False, showextrema=True, showmedians=False):
8339+
showmeans=False, showextrema=True, showmedians=False, side='both'):
83148340
"""
83158341
Draw a violin plot from pre-computed statistics.
83168342
@@ -8366,6 +8392,10 @@ def violin(self, vpstats, positions=None, vert=True, widths=0.5,
83668392
showmedians : bool, default: False
83678393
Whether to show the median with a line.
83688394
8395+
side : {'both', 'low', 'high'}, default: 'both'
8396+
'both' plots standard violins. 'low'/'high' only
8397+
plots the side below/above the positions value.
8398+
83698399
Returns
83708400
-------
83718401
dict
@@ -8428,8 +8458,13 @@ def violin(self, vpstats, positions=None, vert=True, widths=0.5,
84288458
elif len(widths) != N:
84298459
raise ValueError(datashape_message.format("widths"))
84308460

8461+
# Validate side
8462+
_api.check_in_list(["both", "low", "high"], side=side)
8463+
84318464
# Calculate ranges for statistics lines (shape (2, N)).
8432-
line_ends = [[-0.25], [0.25]] * np.array(widths) + positions
8465+
line_ends = [[-0.25 if side in ['both', 'low'] else 0],
8466+
[0.25 if side in ['both', 'high'] else 0]] \
8467+
* np.array(widths) + positions
84338468

84348469
# Colors.
84358470
if mpl.rcParams['_internal.classic_mode']:
@@ -8441,20 +8476,34 @@ def violin(self, vpstats, positions=None, vert=True, widths=0.5,
84418476
# Check whether we are rendering vertically or horizontally
84428477
if vert:
84438478
fill = self.fill_betweenx
8444-
perp_lines = functools.partial(self.hlines, colors=linecolor)
8445-
par_lines = functools.partial(self.vlines, colors=linecolor)
8479+
if side in ['low', 'high']:
8480+
perp_lines = functools.partial(self.hlines, colors=linecolor,
8481+
capstyle='projecting')
8482+
par_lines = functools.partial(self.vlines, colors=linecolor,
8483+
capstyle='projecting')
8484+
else:
8485+
perp_lines = functools.partial(self.hlines, colors=linecolor)
8486+
par_lines = functools.partial(self.vlines, colors=linecolor)
84468487
else:
84478488
fill = self.fill_between
8448-
perp_lines = functools.partial(self.vlines, colors=linecolor)
8449-
par_lines = functools.partial(self.hlines, colors=linecolor)
8489+
if side in ['low', 'high']:
8490+
perp_lines = functools.partial(self.vlines, colors=linecolor,
8491+
capstyle='projecting')
8492+
par_lines = functools.partial(self.hlines, colors=linecolor,
8493+
capstyle='projecting')
8494+
else:
8495+
perp_lines = functools.partial(self.vlines, colors=linecolor)
8496+
par_lines = functools.partial(self.hlines, colors=linecolor)
84508497

84518498
# Render violins
84528499
bodies = []
84538500
for stats, pos, width in zip(vpstats, positions, widths):
84548501
# The 0.5 factor reflects the fact that we plot from v-p to v+p.
84558502
vals = np.array(stats['vals'])
84568503
vals = 0.5 * width * vals / vals.max()
8457-
bodies += [fill(stats['coords'], -vals + pos, vals + pos,
8504+
bodies += [fill(stats['coords'],
8505+
-vals + pos if side in ['both', 'low'] else pos,
8506+
vals + pos if side in ['both', 'high'] else pos,
84588507
facecolor=fillcolor, alpha=0.3)]
84598508
means.append(stats['mean'])
84608509
mins.append(stats['min'])

lib/matplotlib/axes/_axes.pyi

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ class Axes(_AxesBase):
360360
showbox: bool | None = ...,
361361
showfliers: bool | None = ...,
362362
boxprops: dict[str, Any] | None = ...,
363-
labels: Sequence[str] | None = ...,
363+
tick_labels: Sequence[str] | None = ...,
364364
flierprops: dict[str, Any] | None = ...,
365365
medianprops: dict[str, Any] | None = ...,
366366
meanprops: dict[str, Any] | None = ...,
@@ -370,6 +370,7 @@ class Axes(_AxesBase):
370370
autorange: bool = ...,
371371
zorder: float | None = ...,
372372
capwidths: float | ArrayLike | None = ...,
373+
label: Sequence[str] | None = ...,
373374
*,
374375
data=...,
375376
) -> dict[str, Any]: ...
@@ -395,6 +396,7 @@ class Axes(_AxesBase):
395396
manage_ticks: bool = ...,
396397
zorder: float | None = ...,
397398
capwidths: float | ArrayLike | None = ...,
399+
label: Sequence[str] | None = ...,
398400
) -> dict[str, Any]: ...
399401
def scatter(
400402
self,
@@ -743,6 +745,7 @@ class Axes(_AxesBase):
743745
| float
744746
| Callable[[GaussianKDE], float]
745747
| None = ...,
748+
side: Literal["both", "low", "high"] = ...,
746749
*,
747750
data=...,
748751
) -> dict[str, Collection]: ...
@@ -755,6 +758,7 @@ class Axes(_AxesBase):
755758
showmeans: bool = ...,
756759
showextrema: bool = ...,
757760
showmedians: bool = ...,
761+
side: Literal["both", "low", "high"] = ...,
758762
) -> dict[str, Collection]: ...
759763

760764
table = mtable.table

0 commit comments

Comments
 (0)