Multiple titles in legend in matplotlib

Question:

Is it possible to put multiple “titles” in a legend in matplotlib?
What I want is something like:

Title 1
x label 1
o label2

Title 2
^ label 3
v label 4

...

in case I have 4 curves or more. Because if I use more than 1 legend, it is difficult to get them aligned properly setting the positions manually.

Asked By: PerroNoob

||

Answers:

the closest i got to it, was creating an empty proxy artist. Problem in my opinion
is that they are not left aligned but the space for the (empty) marker is still there.

from matplotlib.patches import Rectangle
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 1, 100)

# the comma is to get just the first element of the list returned
plot1, = plt.plot(x, x**2) 
plot2, = plt.plot(x, x**3)

title_proxy = Rectangle((0,0), 0, 0, color='w')

plt.legend([title_proxy, plot1, title_proxy, plot2], 
           ["$textbf{title1}$", "label1","$textbf{title2}$", "label2"])
plt.show()
Answered By: MaxNoe

Ok so I needed this answered and the current answer didn’t work for me. In my case I don’t know beforehand how "often" I will need a title in the legend. It depends on some input variables so I needed something more flexible than manually setting where the titles would be. After visiting dozens of questions here I came to this solution that works perfectly for me, but maybe there was a better way of doing it.

## this is what changes sometimes for me depending on how the user decided to input
parameters=[2, 5]


## Titles of each section
title_2 = "n$\bf{Title , parameter , 2}$"
title_4 = "n$\bf{Title , parameter , 4}$"
title_5 = "n$\bf{Title , parameter , 5}$"



def reorderLegend(ax=None, order=None):
    handles, labels = ax.get_legend_handles_labels()
    info = dict(zip(labels, handles))

    new_handles = [info[l] for l in order]
    return new_handles, order


#########
### Plots
fig, ax = plt.subplots(figsize=(10, 10))
ax.set_axis_off()

## Order of labels
all_labels=[]
if 2 in parameters:
    ax.add_line(Line2D([], [], color="none", label=title_2)) 
    all_labels.append(title_2)
    #### Plot your stuff below header 2
    #### Append corresponding label to all_labels



if 4 in parameters:
    ax.add_line(Line2D([], [], color="none", label=title_4))
    all_labels.append(title_4)
    #### Plot your stuff below header 4
    #### Append corresponding label to all_labels

if 5 in parameters:
    ax.add_line(Line2D([], [], color="none", label=title_5))
    all_labels.append(title_5)
    #### Plot your stuff below header 5
    #### Append corresponding label to all_labels

## Make Legend in correct order
handles, labels = reorderLegend(ax=ax, order=all_labels)
leg = ax.legend(handles=handles, labels=labels, fontsize=12, loc='upper left', bbox_to_anchor=(1.05, 1), ncol=1, fancybox=True, framealpha=1, frameon=False)

## Move titles to the left 
for item, label in zip(leg.legendHandles, leg.texts):
    if label._text  in [title_2, title_4, title_5]:
        width=item.get_window_extent(fig.canvas.get_renderer()).width
        label.set_ha('left')
        label.set_position((-2*width,0))

And as an example I get to the following legend (cropped out the rest of the image).
enter image description here

Answered By: M.O.

Matplotlib only supports a single legend title, but I sometimes want multiple titles as well, and aligning the labels with the left edge of the legend box makes them look title-like when compared to the other labels with handles. I’ve found a few approaches to this:

Move labels using Text.set_position()

This is similar to @M.O.’s answer. The tricky part is that Text.set_position() uses display coordinates, so we need to figure out exactly how many pixels to offset the label and can’t just use fontsize units like we’d give to plt.legend(). By reading through matplotlib.legend.Legend._init_legend_box() we can get a sense of what classes and parameters are involved. Matplotlib makes a tree of HPacker and VPacker objects:

VPacker
    Text (title, invisible if unused)
    HPacker (columns)
        VPacker (column)
            HPacker (row of (handle, label) pairs)
                DrawingArea
                    Artist (handle)
                TextArea
                    Text (label)
            ...
        ...

Legend kwargs/rcParams like handletextpad, handlelength, columnspacing, etc, are given to the HPacker, VPacker, and DrawingArea objects to control the spacing. The spacing params we’re interested in are in "fontsize units", so if handlelength=2, then handles will be 2 * fontsize points wide. And a "point" is an old typographic unit that equals 1/72 of an inch. By looking at the DPI of the figure we can convert points to pixels, but some matplotlib backends like SVG don’t use pixels, so we want to use Renderer.points_to_pixels() instead of doing the calculation ourselves.

Going back to _init_legend_box(), it looks like labels get moved over by handlelength + handletextpad, but if we dig into HPacker, we see that it unconditionally adds a single pixel of padding around each of its children, so we need to add 2 more pixels: one for each side of the handle.

Finally, we need some way to mark legend entries as being titles, and setting visible=False on the handle seems good since handles must be an Artist (or subclass) instance, and every Artist has the visible property.

As code:

import matplotlib as mpl

def style_legend_titles_by_setting_position(leg: mpl.legend.Legend, bold: bool = False) -> None:
    """ Style legend "titles"

    A legend entry can be marked as a title by setting visible=False. Titles
    get left-aligned and optionally bolded.
    """
    # matplotlib.offsetbox.HPacker unconditionally adds a pixel of padding
    # around each child.
    hpacker_padding = 2

    for handle, label in zip(leg.legendHandles, leg.texts):
        if not handle.get_visible():
            # See matplotlib.legend.Legend._init_legend_box()
            widths = [leg.handlelength, leg.handletextpad]
            offset_points = sum(leg._fontsize * w for w in widths)
            offset_pixels = leg.figure.canvas.get_renderer().points_to_pixels(offset_points) + hpacker_padding
            label.set_position((-offset_pixels, 0))
            if bold:
                label.set_fontweight('bold')

And in use:

import matplotlib as mpl
from matplotlib.patches import Patch
import matplotlib.pyplot as plt

def make_legend_with_subtitles() -> mpl.legend.Legend:
    legend_contents = [
        (Patch(visible=False), 'Colors'),
        (Patch(color='red'), 'red'),
        (Patch(color='blue'), 'blue'),

        (Patch(visible=False), ''),  # spacer

        (Patch(visible=False), 'Marks'),
        (plt.Line2D([], [], linestyle='', marker='.'), 'circle'),
        (plt.Line2D([], [], linestyle='', marker='*'), 'star'),
    ]
    fig = plt.figure(figsize=(2, 2))
    leg = fig.legend(*zip(*legend_contents))
    return leg

leg = make_legend_with_subtitles()
style_legend_titles_by_setting_position(leg)
leg.figure.savefig('set_position.png')

multi-title legend using set_position

Modify legend contents and remove invisible handles

Another approach is to replace any legend entry HPackers that have invisible handles with just the label:

def style_legend_titles_by_removing_handles(leg: mpl.legend.Legend) -> None:
    for col in leg._legend_handle_box.get_children():
        row = col.get_children()
        new_children: list[plt.Artist] = []
        for hpacker in row:
            if not isinstance(hpacker, mpl.offsetbox.HPacker):
                new_children.append(hpacker)
                continue
            drawing_area, text_area = hpacker.get_children()
            handle_artists = drawing_area.get_children()
            if not all(a.get_visible() for a in handle_artists):
                new_children.append(text_area)
            else:
                new_children.append(hpacker)
        col._children = new_children

leg = make_legend_with_subtitles()
style_legend_titles_by_removing_handles(leg)
leg.figure.savefig('remove_handles.png')

multi-title legend by removing handles

Modifying the legend contents feels brittle, though. Seaborn has a function, adjust_legend_subtitles() that instead sets the DrawingArea width to 0, and if you also set handletextpad=0 the labels almost get left-aligned, except that there’s still the HPacker padding around the DrawingArea that keeps the label right 2 pixels.

Make multiple legends and concatenate the contents together

Seaborn’s latest approach is to make multiple legend objects, using the title param for each, combining the contents into one primary legend, and then registering only that primary legend with the figure. I like this approach since it keeps matplotlib in control of styling the titles, and you could specify a cleaner interface to it than adding invisible handles, but I feel like it’d be more work to adapt to a non-seaborn context than using the other approaches.

Answered By: torbiak