Moving matplotlib legend outside of the axis makes it cutoff by the figure box
Asked Answered
S

7

294

I'm familiar with the following questions:

Matplotlib savefig with a legend outside the plot

How to put the legend out of the plot

It seems that the answers in these questions have the luxury of being able to fiddle with the exact shrinking of the axis so that the legend fits.

Shrinking the axes, however, is not an ideal solution because it makes the data smaller making it actually more difficult to interpret; particularly when its complex and there are lots of things going on ... hence needing a large legend

The example of a complex legend in the documentation demonstrates the need for this because the legend in their plot actually completely obscures multiple data points.

http://matplotlib.sourceforge.net/users/legend_guide.html#legend-of-complex-plots

What I would like to be able to do is dynamically expand the size of the figure box to accommodate the expanding figure legend.

import matplotlib.pyplot as plt
import numpy as np

x = np.arange(-2*np.pi, 2*np.pi, 0.1)
fig = plt.figure(1)
ax = fig.add_subplot(111)
ax.plot(x, np.sin(x), label='Sine')
ax.plot(x, np.cos(x), label='Cosine')
ax.plot(x, np.arctan(x), label='Inverse tan')
lgd = ax.legend(loc=9, bbox_to_anchor=(0.5,0))
ax.grid('on')

Notice how the final label 'Inverse tan' is actually outside the figure box (and looks badly cutoff - not publication quality!) enter image description here

Finally, I've been told that this is normal behaviour in R and LaTeX, so I'm a little confused why this is so difficult in python... Is there a historical reason? Is Matlab equally poor on this matter?

I have the (only slightly) longer version of this code on pastebin http://pastebin.com/grVjc007

Salamander answered 11/4, 2012 at 7:32 Comment(3)
As far as the why's it's because matplotlib is geared towards interactive plots, while R, etc, aren't. (And yes, Matlab is "equally poor" in this particular case.) To do it properly, you need to worry about resizing the axes every time the figure is resized, zoomed, or the legend's position is updated. (Effectively, this means checking every time the plot is drawn, which leads to slowdowns.) Ggplot, etc, are static, so that's why they tend to do this by default, whereas matplotlib and matlab don't. That having been said, tight_layout() should be changed to take legends into account.Doucet
I'm also discussing this question on the matplotlib users mailing list. So I have the suggestions of adjusting the savefig line to: fig.savefig('samplefigure', bbox_extra_artists=(lgd,), bbox='tight')Salamander
I know matplotlib likes to tout that everything is under the control of the user, but this entire thing with the legends is too much of a good thing. If I put the legend outside, I obviously want it to still be visible. The window should just scale itself to fit instead of creating this huge rescaling hassle. At the very least there should be a default True option to control this autoscaling behavior. Forcing users to go through a ridiculous number of re-renders to try and get the scale numbers right in the name of control accomplishes the opposite.Splat
S
398

Sorry EMS, but I actually just got another response from the matplotlib mailling list (Thanks goes out to Benjamin Root).

The code I am looking for is adjusting the savefig call to:

fig.savefig('samplefigure', bbox_extra_artists=(lgd,), bbox_inches='tight')
#Note that the bbox_extra_artists must be an iterable

This is apparently similar to calling tight_layout, but instead you allow savefig to consider extra artists in the calculation. This did in fact resize the figure box as desired.

import matplotlib.pyplot as plt
import numpy as np

plt.gcf().clear()
x = np.arange(-2*np.pi, 2*np.pi, 0.1)
fig = plt.figure(1)
ax = fig.add_subplot(111)
ax.plot(x, np.sin(x), label='Sine')
ax.plot(x, np.cos(x), label='Cosine')
ax.plot(x, np.arctan(x), label='Inverse tan')
handles, labels = ax.get_legend_handles_labels()
lgd = ax.legend(handles, labels, loc='upper center', bbox_to_anchor=(0.5,-0.1))
text = ax.text(-0.2,1.05, "Aribitrary text", transform=ax.transAxes)
ax.set_title("Trigonometry")
ax.grid('on')
fig.savefig('samplefigure', bbox_extra_artists=(lgd,text), bbox_inches='tight')

This produces:

[edit] The intent of this question was to completely avoid the use of arbitrary coordinate placements of arbitrary text as was the traditional solution to these problems. Despite this, numerous edits recently have insisted on putting these in, often in ways that led to the code raising an error. I have now fixed the issues and tidied the arbitrary text to show how these are also considered within the bbox_extra_artists algorithm.

[edit] Some of the comments below note that since 2019, the command has been simplified. plt.savefig('x.png', bbox_inches='tight') was sufficient. Thanks for sharing. – mateuszb Jun 27, 2019

Salamander answered 14/4, 2012 at 15:26 Comment(21)
/!\ Seems to work only since matplotlib >= 1.0 (Debian squeeze have 0.99 and this does not work)Exodontist
Can't get this to work :( I pass in lgd to savefig but it still doesn't resize. The problem may be I'm not using a subplot.Paralytic
Ah! I just needed to use bbox_inches = "tight" as you did. Thanks!Paralytic
This is nice, but I still get my figure cut when I try to plt.show() it. Any fix for that?Decile
I know this is old, but is there any way to make this work for version 0.99?Logia
What does ax.grid('on') do?Catachresis
Does the print_xxx variants have this option? i.e. is there a way to get this image without having to write to a file?Shied
@Decile try plt.tight_layout(pad=7)Marx
It does not work if use fig.legend() method , really weird.Timothee
The margins exclude a title. E.g. if I use fig.suptitle('title') and then tight bbox margins, I will not see a title.Pedrick
@Pedrick In future, do not edit my answer to include an error. Your code is wrong. You should use ax.set_title("title") and it works fine.Salamander
The margins exclude text. E.g. if I use ax.text(-2,2, "test", transform=ax.transAxes)Pedrick
This works great for any other object too, such as lgd = plt.xlabel('x'). Thanks!Violation
plt.savefig('x.png', bbox_inches='tight') was sufficient. Thanks for sharing.Quimper
I'm getting the error TypeError: 'Legend' object is not iterable. Any ideas? ThanksGensmer
I got it. I was only passing lgd as a single argument because I didn't want to use arbitrary text, and assumed I didn't need to have in a list or a tuple. I simply enclosed lgd in square brackets and everything is working.Gensmer
This worked for me fig = plt.figure(dpi=100) #plotting code skipped lgd = fig.legend(loc='lower center', bbox_to_anchor=(0.5, -0.2),fancybox=True, shadow=False, frameon=False, ncol=2) fig.savefig('roc_curve.svg', bbox_extra_artists=(lgd,), bbox_inches='tight')Vocalic
Unfortunatly this great solution is now deprecated as per version 3.3: MatplotlibDeprecationWarning: savefig() got unexpected keyword argument "extra_artists" which is no longer supported as of 3.3 and will become an error two minor releases later. Solution based on subplots_adjust method is still valid.Massenet
Has there been any answer for how to make the figure.show() method work without cropping? Even when saving the figure correctly produces the chart with the full legend, figure.show() will still crop the legend - tight_layout will squish the main body of the chart, so is also undesirable.Womack
@FarazMasroor try emailing the matplotlib mailing list. Then write a new question and self-answer it here on SO. Come back and link to your question in a new comment right here 😀😀Salamander
@FarazMasroor, @Agostino, the layout="constrained" version fixes figure.show().Aestival
O
35

Added: I found something that should do the trick right away, but the rest of the code below also offers an alternative.

Use the subplots_adjust() function to move the bottom of the subplot up:

fig.subplots_adjust(bottom=0.2) # <-- Change the 0.02 to work for your plot.

Then play with the offset in the legend bbox_to_anchor part of the legend command, to get the legend box where you want it. Some combination of setting the figsize and using the subplots_adjust(bottom=...) should produce a quality plot for you.

Alternative: I simply changed the line:

fig = plt.figure(1)

to:

fig = plt.figure(num=1, figsize=(13, 13), dpi=80, facecolor='w', edgecolor='k')

and changed

lgd = ax.legend(loc=9, bbox_to_anchor=(0.5,0))

to

lgd = ax.legend(loc=9, bbox_to_anchor=(0.5,-0.02))

and it shows up fine on my screen (a 24-inch CRT monitor).

Here figsize=(M,N) sets the figure window to be M inches by N inches. Just play with this until it looks right for you. Convert it to a more scalable image format and use GIMP to edit if necessary, or just crop with the LaTeX viewport option when including graphics.

Overdo answered 13/4, 2012 at 6:42 Comment(3)
It would seem that this is the best solution at the current time, even though it still requires 'playing until it looks good' which is not a good solution for a autoreport generator. I actually already use this solution, the real problem is that matplotlib doesn't dynamically compensate for the legend being outside the bbox of the axis. As @Joe said, tight_layout should take into account more features than just axis, titles and lables. I might add this as a feature request on the matplotlib.Salamander
also works for me to get a big enough picture to fit the xlabels previously being cut offIts
here is the documentation with example code from matplotlib.orgLiegeman
S
18

Here is another, very manual solution. You can define the size of the axis and paddings are considered accordingly (including legend and tickmarks). Hope it is of use to somebody.

Example (axes size are the same!):

enter image description here

Code:

#==================================================
# Plot table

colmap = [(0,0,1) #blue
         ,(1,0,0) #red
         ,(0,1,0) #green
         ,(1,1,0) #yellow
         ,(1,0,1) #magenta
         ,(1,0.5,0.5) #pink
         ,(0.5,0.5,0.5) #gray
         ,(0.5,0,0) #brown
         ,(1,0.5,0) #orange
         ]


import matplotlib.pyplot as plt
import numpy as np

import collections
df = collections.OrderedDict()
df['labels']        = ['GWP100a\n[kgCO2eq]\n\nasedf\nasdf\nadfs','human\n[pts]','ressource\n[pts]'] 
df['all-petroleum long name'] = [3,5,2]
df['all-electric']  = [5.5, 1, 3]
df['HEV']           = [3.5, 2, 1]
df['PHEV']          = [3.5, 2, 1]

numLabels = len(df.values()[0])
numItems = len(df)-1
posX = np.arange(numLabels)+1
width = 1.0/(numItems+1)

fig = plt.figure(figsize=(2,2))
ax = fig.add_subplot(111)
for iiItem in range(1,numItems+1):
  ax.bar(posX+(iiItem-1)*width, df.values()[iiItem], width, color=colmap[iiItem-1], label=df.keys()[iiItem])
ax.set(xticks=posX+width*(0.5*numItems), xticklabels=df['labels'])

#--------------------------------------------------
# Change padding and margins, insert legend

fig.tight_layout() #tight margins
leg = ax.legend(loc='upper left', bbox_to_anchor=(1.02, 1), borderaxespad=0)
plt.draw() #to know size of legend

padLeft   = ax.get_position().x0 * fig.get_size_inches()[0]
padBottom = ax.get_position().y0 * fig.get_size_inches()[1]
padTop    = ( 1 - ax.get_position().y0 - ax.get_position().height ) * fig.get_size_inches()[1]
padRight  = ( 1 - ax.get_position().x0 - ax.get_position().width ) * fig.get_size_inches()[0]
dpi       = fig.get_dpi()
padLegend = ax.get_legend().get_frame().get_width() / dpi 

widthAx = 3 #inches
heightAx = 3 #inches
widthTot = widthAx+padLeft+padRight+padLegend
heightTot = heightAx+padTop+padBottom

# resize ipython window (optional)
posScreenX = 1366/2-10 #pixel
posScreenY = 0 #pixel
canvasPadding = 6 #pixel
canvasBottom = 40 #pixel
ipythonWindowSize = '{0}x{1}+{2}+{3}'.format(int(round(widthTot*dpi))+2*canvasPadding
                                            ,int(round(heightTot*dpi))+2*canvasPadding+canvasBottom
                                            ,posScreenX,posScreenY)
fig.canvas._tkcanvas.master.geometry(ipythonWindowSize) 
plt.draw() #to resize ipython window. Has to be done BEFORE figure resizing!

# set figure size and ax position
fig.set_size_inches(widthTot,heightTot)
ax.set_position([padLeft/widthTot, padBottom/heightTot, widthAx/widthTot, heightAx/heightTot])
plt.draw()
plt.show()
#--------------------------------------------------
#==================================================
Seow answered 8/4, 2013 at 7:6 Comment(2)
This didn't work for me until I changed the first plt.draw() to ax.figure.canvas.draw(). I'm not sure why, but before this change the legend size was not getting updated.Petrinapetrine
If you are trying to use this on a GUI window, you need to change fig.set_size_inches(widthTot,heightTot) to fig.set_size_inches(widthTot,heightTot, forward=True).Petrinapetrine
J
2

I tried a very simple way, just make the figure a bit wider:

fig, ax = plt.subplots(1, 1, figsize=(a, b))

adjust a and b to a proper value such that the legend is included in the figure

before

after

Jori answered 11/9, 2022 at 11:8 Comment(0)
A
1

Since google lead me here for a similar question it's probably worth noting that now you only need to use plt.savefig('myplot.png', bbox_inches='tight') to expand the canvas to fit a legend.

Most of the answers here are obsolete.

Amylaceous answered 16/5, 2023 at 13:42 Comment(2)
This is the top answer already, from 2019Cholent
This does not really answer the question. If you have a different question, you can ask it by clicking Ask Question. To get notified when this question gets new answers, you can follow this question. Once you have enough reputation, you can also add a bounty to draw more attention to this question. - From ReviewBaudelaire
F
1

i agree with John Thomas, if you are using a plt. based figure then all you probably need is: plt.savefig('myplot.png', bbox_inches='tight')

Forby answered 26/1 at 19:36 Comment(0)
A
1

The layout="constrained" option (available since Matplotlib 3.7.0) allows a pretty straightforward implementation:

import matplotlib.pyplot as plt
import numpy as np

if __name__ == "__main__":
    x = np.arange(-2 * np.pi, 2 * np.pi, 0.1)
    fig, ax = plt.subplots(1, 1, layout="constrained")
    ax.plot(x, np.sin(x), label='Sine')
    ax.plot(x, np.cos(x), label='Cosine')
    ax.plot(x, np.arctan(x), label='Inverse tan')
    handles, labels = ax.get_legend_handles_labels()
    ax.legend(handles, labels, loc='upper center', bbox_to_anchor=(0.5, -0.1))
    text = ax.text(-0.2, 1.05, "Arbitrary text", transform=ax.transAxes)
    ax.set_title("Trigonometry")
    ax.grid('on')
    fig.savefig('samplefigure.png')
    fig.show()

enter image description here

One of the benefits (compared with the original answer by jonathanbsyd) is that fig.show() works just as well.

Aestival answered 17/4 at 23:46 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.