From 95890c2f82db570220c797e865c5747ba1ae1f94 Mon Sep 17 00:00:00 2001 From: saranti Date: Sat, 9 Mar 2024 21:28:53 +1100 Subject: [PATCH 1/5] add legend to boxplot --- .../next_whats_new/boxplot_legend_support.rst | 60 +++++++++++++++++++ lib/matplotlib/axes/_axes.py | 45 ++++++++++++-- lib/matplotlib/axes/_axes.pyi | 2 + lib/matplotlib/pyplot.py | 2 + lib/matplotlib/tests/test_legend.py | 47 +++++++++++++++ 5 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 doc/users/next_whats_new/boxplot_legend_support.rst diff --git a/doc/users/next_whats_new/boxplot_legend_support.rst b/doc/users/next_whats_new/boxplot_legend_support.rst new file mode 100644 index 000000000000..fa6069ebb958 --- /dev/null +++ b/doc/users/next_whats_new/boxplot_legend_support.rst @@ -0,0 +1,60 @@ +Legend support for Boxplot +~~~~~~~~~~~~~~~~~~~~~~~~~~ +Boxplots now support a *label* parameter to create legend entries. + +Legend labels can be passed as a list of strings to label multiple boxes in a single +boxplot call: + + +.. plot:: + :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() + + +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() + + 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() diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 627dd2c36d40..14f575a5b4db 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -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. @@ -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 DATA_PARAMETER_PLACEHOLDER @@ -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, @@ -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): """ Draw a box and whisker plot from pre-computed statistics. @@ -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`` The zorder of the resulting boxplot. @@ -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)) @@ -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: @@ -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.") + 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}") diff --git a/lib/matplotlib/axes/_axes.pyi b/lib/matplotlib/axes/_axes.pyi index 18c10c1452ba..d00008623633 100644 --- a/lib/matplotlib/axes/_axes.pyi +++ b/lib/matplotlib/axes/_axes.pyi @@ -372,6 +372,7 @@ class Axes(_AxesBase): autorange: bool = ..., zorder: float | None = ..., capwidths: float | ArrayLike | None = ..., + label: Sequence[str] | None = ..., *, data=..., ) -> dict[str, Any]: ... @@ -397,6 +398,7 @@ class Axes(_AxesBase): manage_ticks: bool = ..., zorder: float | None = ..., capwidths: float | ArrayLike | None = ..., + label: Sequence[str] | None = ..., ) -> dict[str, Any]: ... def scatter( self, diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 04d889ec0616..81a6622db77e 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -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]: @@ -2902,6 +2903,7 @@ def boxplot( autorange=autorange, zorder=zorder, capwidths=capwidths, + label=label, **({"data": data} if data is not None else {}), ) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 3b3145006427..4b8fc1b12f60 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1435,3 +1435,50 @@ 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_labels(): + # Test that boxplot(..., labels=) sets the tick labels but not legend entries + # This is not consistent with other plot types but is the current behavior. + fig, ax = plt.subplots() + np.random.seed(19680801) + data = np.random.random((10, 3)) + bp = ax.boxplot(data, labels=['A', 'B', 'C']) + # Check that labels set the tick labels ... + assert [l.get_text() for l in ax.get_xticklabels()] == ['A', 'B', 'C'] + # ... but not legend entries + handles, labels = ax.get_legend_handles_labels() + assert len(handles) == 0 + assert len(labels) == 0 + + +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) + + # 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:]) From 7b397558eb43a90cca8a16dcfa32652a954271dc Mon Sep 17 00:00:00 2001 From: saranti Date: Sat, 16 Mar 2024 15:38:42 +1100 Subject: [PATCH 2/5] delete old function --- lib/matplotlib/tests/test_legend.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 4b8fc1b12f60..195e65c883e1 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1437,21 +1437,6 @@ def test_legend_text(): assert_allclose(leg_bboxes[1].bounds, leg_bboxes[0].bounds) -def test_boxplot_labels(): - # Test that boxplot(..., labels=) sets the tick labels but not legend entries - # This is not consistent with other plot types but is the current behavior. - fig, ax = plt.subplots() - np.random.seed(19680801) - data = np.random.random((10, 3)) - bp = ax.boxplot(data, labels=['A', 'B', 'C']) - # Check that labels set the tick labels ... - assert [l.get_text() for l in ax.get_xticklabels()] == ['A', 'B', 'C'] - # ... but not legend entries - handles, labels = ax.get_legend_handles_labels() - assert len(handles) == 0 - assert len(labels) == 0 - - def test_boxplot_legend_labels(): # Test that legend entries are generated when passing `label`. np.random.seed(19680801) From cb2d3d16c6730e2aa8ca26f803a75d68009f80d9 Mon Sep 17 00:00:00 2001 From: saranti Date: Tue, 19 Mar 2024 11:34:27 +1100 Subject: [PATCH 3/5] fix doc build --- lib/matplotlib/axes/_axes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 14f575a5b4db..52804e38cfae 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -3995,7 +3995,7 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None, 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. + will show the box `.Patch` artists (``result["boxes"]``) instead. data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -4219,7 +4219,7 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True, 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. + will show the box `.Patch` artists (``result["boxes"]``) instead. zorder : float, default: ``Line2D.zorder = 2`` The zorder of the resulting boxplot. From 2e41cb5e925e23b93b8b7108628d7818fb904b78 Mon Sep 17 00:00:00 2001 From: saranti Date: Tue, 19 Mar 2024 23:28:58 +1100 Subject: [PATCH 4/5] update and fix docs --- doc/users/next_whats_new/boxplot_legend_support.rst | 4 ++-- lib/matplotlib/axes/_axes.py | 7 +++++-- lib/matplotlib/axes/_axes.pyi | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/doc/users/next_whats_new/boxplot_legend_support.rst b/doc/users/next_whats_new/boxplot_legend_support.rst index fa6069ebb958..32322440249a 100644 --- a/doc/users/next_whats_new/boxplot_legend_support.rst +++ b/doc/users/next_whats_new/boxplot_legend_support.rst @@ -3,7 +3,7 @@ Legend support for Boxplot Boxplots now support a *label* parameter to create legend entries. Legend labels can be passed as a list of strings to label multiple boxes in a single -boxplot call: +`.boxplot` call: .. plot:: @@ -37,7 +37,7 @@ boxplot call: ax.legend() -Or as a single string to each individual boxplot: +Or as a single string to each individual `.boxplot`: .. plot:: :include-source: true diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 52804e38cfae..44d82184c3c8 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -3997,6 +3997,8 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None, median line (``result["medians"]``); if *patch_artist* is True, the legend will show the box `.Patch` artists (``result["boxes"]``) instead. + .. versionadded:: 3.9 + data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -4221,6 +4223,8 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True, median line (``result["medians"]``); if *patch_artist* is True, the legend will show the box `.Patch` artists (``result["boxes"]``) instead. + .. versionadded:: 3.9 + zorder : float, default: ``Line2D.zorder = 2`` The zorder of the resulting boxplot. @@ -4421,8 +4425,7 @@ def do_patch(xs, ys, **kwargs): 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.") + raise ValueError(datashape_message.format("label")) for artist, lbl in zip(box_or_med, label): artist.set_label(lbl) diff --git a/lib/matplotlib/axes/_axes.pyi b/lib/matplotlib/axes/_axes.pyi index d00008623633..b70d330aa442 100644 --- a/lib/matplotlib/axes/_axes.pyi +++ b/lib/matplotlib/axes/_axes.pyi @@ -372,7 +372,7 @@ class Axes(_AxesBase): autorange: bool = ..., zorder: float | None = ..., capwidths: float | ArrayLike | None = ..., - label: Sequence[str] | None = ..., + label: Sequence[str] | None = ..., *, data=..., ) -> dict[str, Any]: ... @@ -398,7 +398,7 @@ class Axes(_AxesBase): manage_ticks: bool = ..., zorder: float | None = ..., capwidths: float | ArrayLike | None = ..., - label: Sequence[str] | None = ..., + label: Sequence[str] | None = ..., ) -> dict[str, Any]: ... def scatter( self, From c96334d163c53323986728625865b5552c9bc66d Mon Sep 17 00:00:00 2001 From: saranti Date: Tue, 19 Mar 2024 23:55:01 +1100 Subject: [PATCH 5/5] fix failing test --- doc/users/next_whats_new/boxplot_legend_support.rst | 4 ++-- lib/matplotlib/tests/test_legend.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/users/next_whats_new/boxplot_legend_support.rst b/doc/users/next_whats_new/boxplot_legend_support.rst index 32322440249a..44802960d9bb 100644 --- a/doc/users/next_whats_new/boxplot_legend_support.rst +++ b/doc/users/next_whats_new/boxplot_legend_support.rst @@ -3,7 +3,7 @@ Legend support for Boxplot Boxplots now support a *label* parameter to create legend entries. Legend labels can be passed as a list of strings to label multiple boxes in a single -`.boxplot` call: +`.Axes.boxplot` call: .. plot:: @@ -37,7 +37,7 @@ Legend labels can be passed as a list of strings to label multiple boxes in a si ax.legend() -Or as a single string to each individual `.boxplot`: +Or as a single string to each individual `.Axes.boxplot`: .. plot:: :include-source: true diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 195e65c883e1..9b7a6dfd10c9 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1460,7 +1460,7 @@ def test_boxplot_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'): + with pytest.raises(ValueError, match='values must have same the length'): bp3 = axs[2].boxplot(data, label=legend_labels[:-1]) # Test that for a string label, only the first box gets a label.