How to align a legend relative to a GridSpec cell? - matplotlib

I am creating a figure like this:
fig = plt.figure(figsize = (7, 8))
outer_grid = gridspec.GridSpec(2, 1, height_ratios = [2, 1])
inner_grid1 = gridspec.GridSpecFromSubplotSpec(4, 3, subplot_spec=outer_grid[0])
inner_grid2 = gridspec.GridSpecFromSubplotSpec(2, 3, subplot_spec=outer_grid[1])
Now I would like to have one legend for all plots in inner_grid1 and a separate legend for all plots in inner_grid2. And I would like those legends to be placed nicely, even though they are higher than a single plot, and cannot have more than one column to not make the figure too wide.
Here is an example where I tried to align the legends with trial and error with method 2 below, however this took ages to make.
So I see three options to achieve this, none of which work:
Place the legend as part of an Axes object, but manually move it outside of the actual plot using axes.legend([...], bbox_to_anchor=(x, y)). This does not work when the legend is higher as a single plot, because it rescales the plots to fit the legend into its grid cell.
Place the legend globally on the Figure object. This works, but makes the correct placement really hard. I cannot use loc = "center right", since it centers it for the full figure instead of just the inner_grid1 or inner_grid2 plots.
Place the legend locally on the GridSpecFromSubplotSpec object. This would be perfect. However there is no method to create a legend on a GridSpecFromSubplotSpec or related classes, and the pyplot.legend method misses parameters to restrict the loc to parts of a grid.
Is there a way to place a legend as described?
As requested, a small code example generating something similar as desired.
This example uses method 2:
#!/usr/bin/env python3
import pandas as pd, seaborn as sns
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
GENOMES = ["spneumoniae", "ecoliK12", "scerevisiae", "celegans", "bmori", "hg38"]
fig = plt.figure(figsize = (7, 8))
outer_grid = gridspec.GridSpec(2, 1, height_ratios = [2, 1])
inner_grid1 = gridspec.GridSpecFromSubplotSpec(4, 3, subplot_spec=outer_grid[0])
inner_grid2 = gridspec.GridSpecFromSubplotSpec(2, 3, subplot_spec=outer_grid[1])
# plots are in sets of six, 2 rows by 3 columns each
for index, genome in enumerate(GENOMES):
data = pd.DataFrame({"x": [0, 1, 2, 3, 0, 1, 2, 3], "y": [1, 0, 3, 2, 1, 0, 3, 2], "hue": ["a", "a", "a", "a", "b", "b", "b", "b"]})
# first set of six
ax1 = plt.Subplot(fig, inner_grid1[index])
ax1 = sns.lineplot(data = data, x = "x", y = "y", hue = "hue", ax = ax1)
ax1.set_xlabel("")
ax1.set_ylabel("")
if index == 2:
ax1.legend()
handles, labels = ax1.get_legend_handles_labels()
fig.legend(handles, labels, loc = "center left", title = "", bbox_to_anchor=(0.9, 2/3 - 0.03))
ax1.legend([], [], loc = "lower center", title = f"{genome}")
fig.add_subplot(ax1)
# second set of six
ax2 = plt.Subplot(fig, inner_grid1[index + 6])
ax2 = sns.lineplot(data = data, x = "x", y = "y", hue = "hue", ax = ax2)
ax2.set_xlabel("")
ax2.set_ylabel("")
ax2.legend([], [], loc = "upper center", title = f"{genome}")
fig.add_subplot(ax2)
#third set of six
ax3 = plt.Subplot(fig, inner_grid2[index])
ax3 = sns.lineplot(data = data, x = "x", y = "y", hue = "hue", ax = ax3)
ax3.set_xlabel("")
ax3.set_ylabel("")
if index == 2:
ax3.legend(["#unitigs", "avg. unitig len."])
handles, labels = ax3.get_legend_handles_labels()
fig.legend(handles, labels, loc = "center left", title = "", bbox_to_anchor=(0.9, 1/6 + 0.05))
ax3.legend([], [], loc = "upper center", title = f"{genome}")
fig.add_subplot(ax3)
plt.savefig("stackoverflow_test.pdf", bbox_inches="tight")

Related

create legend for markercolor and size

I've created the following figure:
With following code:
matplotlib.rcParams.update({'font.size': 10})
fig = plt.figure(figsize=(16, 9), dpi=300, facecolor='white')
ax = plt.subplot(111, projection=ccrs.PlateCarree())
ax.set_extent(extent)
# cartopy layers
country_10m = cartopy.feature.NaturalEarthFeature('cultural', 'admin_0_countries', '10m')
ax.add_feature(country_10m, edgecolor='w', linewidth=0.75, facecolor='#EEEFEE', label='country border')
ax.coastlines(resolution='10m', color='#EEEFEE', linewidth=0.75)
ax.imshow(np.tile(np.array([[[191, 210, 217]]], dtype=np.uint8), [2, 2, 1]), origin='lower', transform=cartopy.crs.PlateCarree(), extent=extent)
ax.scatter(gdf_ldb.x, gdf_ldb.y, c= gdf_ldb.Color, s= gdf_ldb.Markersize, zorder=30)
# ax.scatter(gdf_ports_filt.longitude, gdf_ports_filt.latitude, s= 10, color= 'k', zorder= 30)
ax.gridlines(crs=ccrs.PlateCarree(), draw_labels=True, linewidth=1, color='gray', alpha=0.5, linestyle='-')
ax.text(-0.08, 0.5, 'latitude [°]', va='bottom', ha='center',rotation='vertical', rotation_mode='anchor',transform=ax.transAxes);
ax.text(0.5, -0.09, 'longitude [°]', va='bottom', ha='center', rotation='horizontal', rotation_mode='anchor', transform=ax.transAxes);
How do I create a legend for the markersize as well for the color, so like this:
With x, x1, and x2 representing the values of the markersizes.
gdf_ldb looks like:
x y Type Color Markersize geometry
prograding_feature_polygon_29 12.857701 56.648035 Updrift grey 3.0 POINT (12.85770 56.64804)
prograding_feature_polygon_57 17.781445 54.808079 Updrift grey 3.0 POINT (17.78144 54.80808)
prograding_feature_polygon_58 17.438390 54.754518 Updrift grey 3.0 POINT (17.43839 54.75452)
prograding_feature_polygon_63 4.708077 52.880322 Updrift grey 3.0 POINT (4.70808 52.88032)
prograding_feature_polygon_72 3.953364 51.842299 Updrift grey 3.0 POINT (3.95336 51.84230)
... ... ... ... ... ... ...
retreating_feature_polygon_2018 -10.148432 53.415224 Double Updrift grey 3.0 POINT (-10.14843 53.41522)
retreating_feature_polygon_2019 -9.954510 54.197329 Double Updrift grey 3.0 POINT (-9.95451 54.19733)
retreating_feature_polygon_2119 15.095564 37.389535 Double Updrift grey 3.0 POINT (15.09556 37.38953)
retreating_feature_polygon_2120 14.317893 37.025026 Double Updrift grey 3.0 POINT (14.31789 37.02503)
retreating_feature_polygon_2121 13.952111 37.101009 Updrift grey 3.0 POINT (13.95211 37.10101)
Thanks in advance,
Dante
The key is to capture the artist (PathCollection in this case) returned by the scatter command. That has a method to retrieve the legend items manually, and it has keywords to distinguish between size and color (default). The num keyword can be used to reduce the amount of items returned, which is useful in the case of a (semi)continuous property as the size can be.
The example below plots two separate legends for both properties. You can also combine the handles and labels of both and plot them in a single legend if needed.
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
import cartopy.crs as ccrs
import cartopy
import numpy as np
lons = np.random.randint(-170, 170, 100)
lats = np.random.randint(-80, 80, 100)
sizes = np.random.rand(100) * 100 + 5
colors = np.random.randint(0, 3, 100)
fig, ax = plt.subplots(
figsize=(8,4), dpi=86, facecolor='w',
subplot_kw=dict(projection=ccrs.PlateCarree()),
)
fig.subplots_adjust(left=0, right=1, bottom=0, top=1)
ax.add_feature(cartopy.feature.LAND, ec='none', fc='#EEEFEE', label='country border')
ax.add_feature(cartopy.feature.BORDERS, ec='w', fc='k', lw=0.75, label='country border')
ax.coastlines(resolution='10m', color='#EEEFEE', lw=0.75)
m = ax.scatter(lons, lats, s=sizes, c=colors, zorder=5, label="points")
l1 = ax.legend(
*m.legend_elements(prop="colors", num="auto"), title="Colors", framealpha=1,
loc="upper right", bbox_to_anchor=(0.88, 0.8, 0.12, 0.2), mode="expand",
)
ax.add_artist(l1) # prevent overwriting with second legend
l2 = ax.legend(
*m.legend_elements(prop="sizes", num=5), title="Sizes", framealpha=1,
loc="upper right", bbox_to_anchor=(0.88, 0.55, 0.12, 0.2), mode="expand",
)
The documentation about this shows some variations on this:
https://matplotlib.org/stable/gallery/lines_bars_and_markers/scatter_with_legend.html#automated-legend-creation
The answer by Rutger Kassies is excellent for many use cases. However, he mentions that One can also combine the handles and labels of both and plot them in a single legend if needed.
Here I offer another answer that shows the steps to create the single legend manually. Inside the single legend, 2 groups of sub legends are created and arranged as needed.
With single legend, you don't need to find the values of bbox_to_anchor for the second (or third and so on) to position them properly.
With manual creation of items into a single legend, you have full control of the items' you need in the legend. However, it need some extra coding to achieve the goal.
import matplotlib.pyplot as plt
import matplotlib.lines as mlines
import cartopy.crs as ccrs
import cartopy.feature as cfeature
# For `Categories` symbol
# Each item of legends requires 3 properties: color/text/marker_shape
color_V = ["green", "orange", "purple", "red", "cyan", "magenta"]
text_V = ["cat_4", "cat_9", "cat_13", "cat_15", "cat_19", "cat_33"]
marker_V = ["o", "o", "o", "o", "o", "o"]
len_V = len(color_V)
# For `Size/values` symbol
color_S = ["gray", "gray", "gray", "gray"]
sizes_S = [4, 8, 12, 16] #increasing values ...
text_S = ["4", "8", "12", "16"] #cover `sizes1` below
marker_S = ["o", "o", "o", "o"] #use disk shape
len_S = len(color_S)
# Demo data locations and attributes
xs = [23,12,4,25,24,52,17,33]
ys = [41,12,32,15,35,21,23,43]
colors1 = ["green", "orange", "purple", "red", "cyan", "magenta", "green", "orange"]
#texts1 = ["4", "9", "13", "15", "19", "33", "4", "9"]
markers1 = ["o", "o", "o", "o", "o", "o", "o", "o"]
sizes1 = [10,16,9,12,7,4,2,6]
len1 = len(xs)
all_patches = [] #for items in a single legend
# Create figure and `ax` for map plotting
# This form can create a single axes or an array of axes
fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(8,6), subplot_kw={'projection': ccrs.PlateCarree()})
# All steps of plots will be done on `ax`
# [1] Add an invisible object as a spacer in the legend box
#rect = mpatches.Rectangle([0, 0], 0.01, 0.01, ec="none", color="lightgray")
all_patches.append(mlines.Line2D([0, 0], [1, 0], color="none"))
# Explicitly defining the elements in the legend
# [2] Add proxied text: 'Categories' to the legend
line = mlines.Line2D([0, 0], [1, 0], lw=.5, alpha=0.9, color="none")
line.set_label('Categories') # Title for 1st group of symbols in the legend
all_patches.append(line)
# [3] Plot (on the axes) `none` data point and
# save the output patches for `Categories` group
patches_V = [ ax.plot([],[], marker=marker_V[i], ms=8, ls="", color=color_V[i], \
label="{:s}".format(text_V[i]) )[0] \
for i in range(len_V) ]
all_patches += patches_V
# [4] Add an invisible object as a spacer in the legend box
all_patches.append(mlines.Line2D([0, 0], [1, 0], color="none"))
# [5] Add proxied text: 'Sizes' to the legend
x, y = ([0, 1], [0, 0])
line = mlines.Line2D([0, 0], [1, 0], lw=.5, alpha=0.9, color="none")
line.set_label('Sizes') # Title for 2nd group of symbols in the legend
all_patches.append(line)
# [6] Create patches for `Sizes` group
patches_S = [ ax.plot([],[], marker=marker_S[i], ms=sizes_S[i], ls="", \
color=color_S[i], \
label="{:s}".format(text_S[i]) )[0] for i in range(len_S) ]
all_patches += patches_S
# Plot point data using the demo data
for i in range(len1):
ax.plot(xs[i], ys[i], marker=markers1[i], ms=sizes1[i], color=colors1[i])
ax.set_extent([0, 80, 0, 60])
# Plot the legend in the upper-right corner
combined_legend = ax.legend(handles=all_patches,
bbox_to_anchor=(1, 1),
title="The Legend",
loc='upper right',
ncol=1,
numpoints=1,
facecolor="lightgray",
fontsize = 10,
title_fontsize= 12,
labelspacing = 0.55,
shadow=True)
# Draw some basemap features
ax.coastlines(lw=0.3, color="k")
ax.add_feature(cfeature.LAND)
ax.add_feature(cfeature.OCEAN)
plt.title("Legend for Categories and Sizes")
plt.show()
The output map:

Changing the Matplotlib GridSpec properties after generating the subplots

Suppose something comes up in my plot that mandates that I change the height ratio between two subplots that I've generated within my plot. I've tried changing GridSpec's height ratio to no avail.
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
fig = plt.figure()
gs = GridSpec(2, 1, height_ratios=[2, 1])
ax1 = fig.add_subplot(gs[0])
ax1 = fig.axes[0]
ax2 = fig.add_subplot(gs[1])
ax2 = fig.axes[1]
ax1.plot([0, 1], [0, 1])
ax2.plot([0, 1], [1, 0])
gs.height_ratios = [2, 5]
The last line has no effect on the plot ratio.
In my actual code, it is not feasible without major reworking to set the height_ratios to 2:5 ahead of time.
How do I get this to update like I want?
The axes of relevant subplots can be manipulated and adjusted to get new height ratios.
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
fig = plt.figure()
gs = GridSpec(2, 1, height_ratios=[2, 1]) #nrows, ncols
ax1 = fig.add_subplot(gs[0])
ax1 = fig.axes[0]
ax2 = fig.add_subplot(gs[1])
ax2 = fig.axes[1]
ax1.plot([0, 1], [0, 1])
ax2.plot([0, 1], [1, 0])
# new height ratio: 2:5 is required for the 2 subplots
rw, rh = 2, 5
# get dimensions of the 2 axes
box1 = ax1.get_position()
box2 = ax2.get_position()
# current dimensions
w1,h1 = box1.x1-box1.x0, box1.y1-box1.y0
w2,h2 = box2.x1-box2.x0, box2.y1-box2.y0
top1 = box1.y0+h1
#top2 = box2.y0+h2
full_h = h1+h2 #total height
# compute new heights for each axes
new_h1 = full_h*rw/(rw + rh)
new_h2 = full_h*rh/(rw + rh)
#btm1,btm2 = box1.y0, box2.y0
new_bottom1 = top1-new_h1
# finally, set new location/dimensions of the axes
ax1.set_position([box1.x0, new_bottom1, w1, new_h1])
ax2.set_position([box2.x0, box2.y0, w2, new_h2])
plt.show()
The output for ratio: (2, 5):
The output for (2, 10):

Pyplot: How to add mirrored second y-axis instead of negative values?

In pyplot, how could I add a second y-axis where normally the negative values would be? The y-values should be increasing away from the shared x-axis.
Above the x-axis I want to plot time consumption and below the x-axis space consumption. The x-axis is basically the current iteration.
This can be achieved using sharex=True and invert_yaxis():
from matplotlib import pyplot as plt
fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True, figsize=(5, 10))
xdata = [2, 1, 5]
data1 = [1, 3, 5]
data2 = [1, 4, 7]
l1 = ax1.plot(xdata, data1, "ro")
l2 = ax2.plot(xdata, data2, "bx")
l3 = ax2.plot(xdata, data1, "g")
#make the appearance more coherent
#remove space between subplots
plt.subplots_adjust(hspace=0)
#invert the y-axis
ax2.invert_yaxis()
#set both plots to the same y-limit
lim = max(max(ax1.get_ylim()), max(ax2.get_ylim()))
ax1.set_ylim(0, lim)
ax2.set_ylim(lim, 0)
#move the x-axis label to the center
ax2.xaxis.tick_top()
#remove the double zero label
yticks2 = ax2.yaxis.get_major_ticks()
yticks2[0].set_visible(False)
#create a common legend
ax1.legend(l1+l2+l3, ["data1", "data2", "again data1"])
plt.show()
Sample output:

Colormap is not categorizing the data properly

Here is my script to plot data from a Geogtiff file using basemap. The data is categorical and there are 13 categories within this domain. The problem is that some categories get bunched up into one colour and thus some resolution is lost.
Unfortunately, I do not know how to fix this. I read that plt.cm.get_cmp is better for discrete datasets but I have not gotten it to work unfortunately.
gtif = 'some_dir'
ds = gdal.Open(gtif)
data = ds.ReadAsArray()
gt = ds.GetGeoTransform()
proj = ds.GetProjection()
xres = gt[1]
yres = gt[5]
xmin = gt[0] + xres
xmax = gt[0] + (xres * ds.RasterXSize) - xres
ymin = gt[3] + (yres * ds.RasterYSize) + yres
ymax = gt[3] - yres
xy_source = np.mgrid[xmin:xmax+xres:xres, ymax+yres:ymin:yres]
ds = None
fig2 = plt.figure(figsize=[12, 11])
ax2 = fig2.add_subplot(111)
ax2.set_title("Land use plot")
bm2 = Basemap(ax=ax2,projection='cyl',llcrnrlat=ymin,urcrnrlat=ymax,llcrnrlon=xmin,urcrnrlon=xmax,resolution='l')
bm2.drawcoastlines(linewidth=0.2)
bm2.drawcountries(linewidth=0.2)
data_new=np.copy(data)
data_new[data_new==255] = 0
nbins = np.unique(data_new).size
cb =plt.cm.get_cmap('jet', nbins+1)
img2 =bm2.imshow(np.flipud(data_new), cmap=cb)
ax2.set_xlim(3, 6)
ax2.set_ylim(50,53)
plt.show()
labels = [str(i) for i in np.unique(data_new)]
cb2=bm2.colorbar(img2, "right", size="5%", pad='3%', label='NOAH Land Use Category')
cb2.set_ticklabels(labels)
cb2.set_ticks(np.unique(data_new))
Here are the categories that are found within the domain (numbered classes):
np.unique(data_new)
array([ 0, 1, 4, 5, 7, 10, 11, 12, 13, 14, 15, 16, 17], dtype=uint8)
Thanks so much for any help here. I have also attached the output image that shows the mismatch. (not working)
First, this colormap problem is independent of the use of basemap. The following is therefore applicable to any matplotlib plot.
The problem here is that creating a colormap from n values distributes those values equally over the colormap range. Some values from the image therefore fall into the same colorrange within the colormap.
To prevent this, one can generate a colormap with the initial number of categories as shown below.
import matplotlib.pyplot as plt
import numpy as np
import matplotlib.colors
# generate some data
data = np.array( [ 0, 1, 4, 5, 7, 10]*8 )
np.random.shuffle(data)
data = data.reshape((8,6))
# generate colormap and norm
unique = np.unique(data)
vals = np.arange(int(unique.max()+1))/float(unique.max())
cols = plt.cm.jet(vals)
cmap = matplotlib.colors.ListedColormap(cols, int(unique.max())+1)
norm=matplotlib.colors.Normalize(vmin=-0.5, vmax=unique.max()+0.5)
fig, ax = plt.subplots(figsize=(5,5))
im = ax.imshow(data, cmap=cmap, norm=norm)
for i in range(data.shape[0]):
for j in range(data.shape[1]):
ax.text(j,i,data[i,j], color="w", ha="center", va="center")
cb = fig.colorbar(im, ax=ax, norm=norm)
cb.set_ticks(unique)
plt.show()
This can be extended to exclude the colors not present in the image as follows:
import matplotlib.pyplot as plt
import numpy as np
import matplotlib.colors
# generate some data
data = np.array( [ 0, 1, 4, 5, 7, 10]*8 )
np.random.shuffle(data)
data = data.reshape((8,6))
unique, newdata = np.unique(data, return_inverse=1)
newdata = newdata.reshape(data.shape)
# generate colormap and norm
new_unique = np.unique(newdata)
vals = np.arange(int(new_unique.max()+1))/float(new_unique.max())
cols = plt.cm.jet(vals)
cmap = matplotlib.colors.ListedColormap(cols, int(new_unique.max())+1)
norm=matplotlib.colors.Normalize(vmin=-0.5, vmax=new_unique.max()+0.5)
fig, ax = plt.subplots(figsize=(5,5))
im = ax.imshow(newdata, cmap=cmap, norm=norm)
for i in range(newdata.shape[0]):
for j in range(newdata.shape[1]):
ax.text(j,i,data[i,j], color="w", ha="center", va="center")
cb = fig.colorbar(im, ax=ax, norm=norm)
cb.ax.set_yticklabels(unique)
plt.show()

how to get ticks on both sides at same tick locations

I want y-ticks on both sides(left & right), but with different labels at the same y points. I tried following, but I'm not able to position ticks at same location.
I'm newbie to matplotlib. I have gone through the matplotlib example, but couldn't figure it out the solution to my problem.
http://matplotlib.org/examples/pylab_examples/barchart_demo2.html
Greatly appreciate any suggestions.
Here is the code:
import numpy as np
import matplotlib.pyplot as plt
groups = [ 1, 2, 3, 4, 5 ]
members = [ 1, 2, 3, 4 ]
colors = [ 'r', 'y', 'b', 'k']
#store score of members for the groups
scores = {member: 100*np.random.rand(len(groups)) for member in members}
group_cnt = group_cnt = sum([scores[member] for member in members])
print scores
fig = plt.figure(figsize=(8,6))
ax = fig.add_subplot(111)
width_bar = 0.5
width_space = 0.2
#position of barh
total_space = len(groups)*(len(members)*width_bar)+(len(groups)-1)*width_space
ind_space = len(members)*width_bar
step = ind_space/2.
#pos for labels
pos = np.arange(step, total_space+width_space, ind_space+width_space)
#pos for grin lines
minor_pos = np.arange(ind_space, total_space+width_space, ind_space+width_space)
for idx in xrange(len(members)):
ax.barh(pos-step+idx*width_bar, scores[members[idx]], width_bar, edgecolor='k', color=colors[idx], linewidth=3)
ax.invert_yaxis()
ax.set_yticks(pos)
ax.set_yticklabels(groups)
ax.set_yticks(minor_pos, minor=True)
ax.grid(True, which='minor')
ax.set_ylabel('Groups')
ax2 = ax.twinx()
ax2.set_ylabel('Group totals')
ax2.set_yticks(pos)
ax2.set_yticklabels(group_cnt)
ax2.invert_yaxis()
plt.show()
I think you got caught by a bit of trickery in that example. There is a plot([100, 100], [0, 5]) in the demo code which is doing a lot of non-obvious work (I am working on submitting a PR to improve the demo) in making sure that the ylimits are the same for both yaxis.
You just need to add a
ax2.set_ylim(ax.get_ylim())
before you call show.
You also have an un-related error ax2.set_yticklabels(group_cnt) -> ax2.set_yticklabels(groups).
[side note, generated PR #2327]