Set absolute size of matplotlib subplots - python

I know how to set the relative size of subplots within a figure using gridspec or subplots_adjust, and I know how to set the size of a figure using figsize. My problem is setting the absolute size of the subplots.
Use case: I am making two separate plots which will be saved as pdfs for an academic paper. One has two subplots and one has three subplots (in both cases in 1 row). I need each of the 5 subplots to be the exact same size with the exact same font sizes (axis labels, tick labels, etc) in the resulting PDFs. In the example below the fonts are the same size but the subplots are not. If I make the height of the resulting PDFs the same (and thus the axes), the font on 3-subplots.pdf is smaller than that of 2-subplots.pdf.
MWE:
import matplotlib.pyplot as plt
subplots = [2, 3]
for i, cols in enumerate(subplots):
fig, ax = plt.subplots(1, cols, sharey=True, subplot_kw=dict(box_aspect=1))
for j in range(cols):
ax[j].set_title(f'plot {j*cols}')
ax[j].set_xlabel('My x label')
ax[0].set_ylabel('My y label')
plt.tight_layout()
plt.savefig(f'{cols}-subplots.pdf', bbox_inches='tight', pad_inches=0)
plt.show()
Output:

I prefer to use fig.add_axes([left, bottom, width, height]) which let you control the size and location of each subplot precisely. left and bottom decide the location of your subplots, while width and height decide the size. All quantities are in fractions of figure width and height, thus they are all float between 0 and 1.
An example:
fig = plt.figure(figsize=(8.3, 11.7))
axs = {
"ax1": fig.add_axes([0.2, 0.7, 0.6, 0.2], xticklabels=[]),
"ax2": fig.add_axes([0.2, 0.49, 0.6, 0.2], xticklabels=[]),
"ax3": fig.add_axes([0.2, 0.28, 0.6, 0.2]),
}
With this I created 3 subplots in an A4 size figure, each of them are 0.6x8.3 width and 0.2x11.7 height. The spacing between them is 0.1x11.7. "ax1" and "ax2" do not show xticklabels so that I can set shared x ticks for them later.
You can see matplotlib API refenrence for more information https://matplotlib.org/stable/api/figure_api.html

I ended up solving this by:
setting explicit absolute lengths for subplot width/height, the space between subplots and the space outside subplots,
adding them up to get an absolute figure size,
setting the subplot box_aspect to 1 to keep them square.
import matplotlib.pyplot as plt
num_subplots = [2, 3]
scale = 1 # scaling factor for the plot
subplot_abs_width = 2*scale # Both the width and height of each subplot
subplot_abs_spacing_width = 0.2*scale # The width of the spacing between subplots
subplot_abs_excess_width = 0.3*scale # The width of the excess space on the left and right of the subplots
subplot_abs_excess_height = 0.3*scale # The height of the excess space on the top and bottom of the subplots
for i, cols in enumerate(num_subplots):
fig_width = (cols * subplot_abs_width) + ((cols-1) * subplot_abs_spacing_width) + subplot_abs_excess_width
fig_height = subplot_abs_width+subplot_abs_excess_height
fig, ax = plt.subplots(1, cols, sharey=True, figsize=(fig_width, fig_height), subplot_kw=dict(box_aspect=1))
for j in range(cols):
ax[j].set_title(f'plot {j}')
ax[j].set_xlabel('My x label')
ax[0].set_ylabel('My y label')
plt.tight_layout()
plt.savefig(f'{cols}-subplots.pdf', bbox_inches='tight', pad_inches=0)
plt.show()

I created a function that creates axes with absolute sizes and acts in most ways like plt.subplots(...), for example by allowing shared y- or x-axes and returning the axes as a shaped numpy array. It centers the axes inside their grid areas, giving them as much space as possible between themselves and the edges of the figure, assuming you set figsize large enough.
The arguments include absolute height and width for the figure (see the matplotlib documentation for details) and absolute height and width for the axes, as requested in the original question.
from typing import Tuple
from matplotlib import pyplot as plt
import numpy as np
def subplots_with_absolute_sized_axes(
nrows: int, ncols: int,
figsize: Tuple[float, float],
axis_width: float, axis_height: float,
sharex: bool=False, sharey: bool=False) -> Tuple[plt.Figure, numpy.ndarray]:
''' Create axes with exact sizes.
Spaces axes as far from each other and the figure edges as possible
within the grid defined by nrows, ncols, and figsize.
Allows you to share y and x axes, if desired.
'''
fig = plt.figure(figsize=figsize)
figwidth, figheight = figsize
# spacing on each left and right side of the figure
h_margin = (figwidth - (ncols * axis_width)) / figwidth / ncols / 2
# spacing on each top and bottom of the figure
v_margin = (figheight - (nrows * axis_height)) / figheight / nrows / 2
row_addend = 1 / nrows
col_addend = 1 / ncols
inner_ax_width = axis_width / figwidth
inner_ax_height = axis_height / figheight
axes = []
sharex_ax = None
sharey_ax = None
for row in range(nrows):
bottom = (row * row_addend) + v_margin
for col in range(ncols):
left = (col * col_addend) + h_margin
if not axes:
axes.append(fig.add_axes(
[left, bottom, inner_ax_width, inner_ax_height]))
if sharex:
sharex_ax = axes[0]
if sharey:
sharey_ax = axes[0]
else:
axes.append(fig.add_axes(
[left, bottom, inner_ax_width, inner_ax_height],
sharex=sharex_ax, sharey=sharey_ax))
return fig, np.flip(np.asarray(list(axes)).reshape((nrows, ncols)), axis=0)

Related

2nd scale with ticks being a function of first ticks at same position in python/matplotlib

I am using matplotlib in Python and want to use the same plot but with several different axes that are all functions of the first one, but that do not linearly depend on the first y value.
As an example, let's assume a plot that shows a simple line y=x.
Now I have a random function like f(y)=5y^2 + 2.
My ideal output graph should now still be a line, but the equidistant ticks should not be y=1, 2, 3, 4, but f(y)=7, 22, 47, 82, so that I can overlay the two graphs with 2 different axes.
Is this even possible, as the distance between the ticks is not even nor can it be expressed in a log plot? Therefore I simply want to put a function on each tick value, without changing the graph nor the ticks' positions.
In a graphics program this would be straightforward, by simply using the same plot and manually rewriting each tick.
https://drive.google.com/file/d/1fp2vrFvlz-9xdJPmqdQjyMQK7gzPX24G/view?usp=sharing
Thank you in advance! The example code is not really helpful, as it is just the standard matplotlib code but the most important scaling part is missing.
I know that I can set the ticks manually with yticks, but this does not solve the scaling problem and all ticks would appear very close together.
plt.plot(["time_max_axis"], ["position_max_axis"])
plt.xlabel("Time (ms)")
plt.ylabel("Max position (mm)")
plt.ylim(0, z0_mm)
plt.show()
plt.plot(["time_max_axis"], ["frequency_axis"])
plt.xlabel("Oscillation frequency (kHz)")
plt.ylabel("Max position (mm)")
plt.ylim(fion_kHz, fion_kHz * (1 + (f_shift4 + f_shift6) / 100))
plt.show()
import matplotlib.pyplot as plt
from matplotlib.ticker import (MultipleLocator, AutoMinorLocator)
x = np.arange(50)
y = x/10 + np.random.rand(50)
fig, axs = plt.subplots(1,2, gridspec_kw={'width_ratios': [1, 20]})
plt.subplots_adjust(wspace=0, hspace=0)
axs[1].plot(x, y)
axs[1].plot(x, 2*y)
axs[1].plot(x, 3*y)
axs[1].grid()
axs[1].set_ylim(0)
axs[1].set_xlim(0)
axs[1].set_ylabel('max displacement $z_{max}$ (mm)')
ymin, ymax = axs[1].get_ylim()
majorlocator = ymax // 8 # 8 horizontal grid lines
ytickloc = np.arange(0, int(ymax), majorlocator)
axs[1].yaxis.set_major_locator(MultipleLocator(majorlocator))
ax1 = axs[1].twinx() # ghost axis of axs[1]
ax1.yaxis.set_ticks_position('left')
ax1.set_yticks([ymin, ymax])
ax1.set_yticklabels(['', f'$z_0$ = {round(ymax,2)}'])
axs[0].spines['top'].set_visible(False)
axs[0].spines['right'].set_visible(False)
axs[0].spines['bottom'].set_visible(False)
axs[0].spines['left'].set_visible(False)
axs[0].set_xticks([])
axs[0].set_yticks(ytickloc)
ytick2 = 5 * ytickloc**2 + 2 # f = 5y^2 + 2
ytick2 = list(ytick2)
ymin2 = ytick2[0]
ytick2[0] = ''
axs[0].set_yticklabels(ytick2)
axs[0].set_ylim(ymin, ymax)
axs[0].set_ylim(0)
axs[0].set_ylabel('Oscillation frequency $f_{osc}$ (kHz)')
ymax2 = 5 * ymax**2 + 2 # f = 5y^2 + 2
ax0 = axs[0].twinx() # ghost axis of axs[0]
ax0.yaxis.set_ticks_position('left')
ax0.spines['top'].set_visible(False)
ax0.spines['right'].set_visible(False)
ax0.spines['bottom'].set_visible(False)
ax0.spines['left'].set_visible(False)
ax0.set_yticks([ymin, ymax])
ax0.set_yticklabels([f'$\\bf{{f_{{ion}}}} = {round(ymin2, 2)}$', f'$f_{{max}}$ = {round(ymax2,2)}'])
plt.tight_layout()
Output:

How to create a horizontal histogram the other way around?

The gallery of matplotlib has a 2D scatter plot with two adjacent histograms for the marginal distribution of x and y values at the top and right, respectively. I want to modify that to show the histogram of y values on the left (instead of the right) but also oriented towards the scatter plot.
All I managed so far was to merely move it from the right to the left (see below), but not re-orientate it towards the scatter plot. How can I achieve that?
Here is my code:
import numpy as np
import matplotlib.pyplot as plt
# Fixing random state for reproducibility
np.random.seed(19680801)
# some random data
x = np.random.randn(1000)
y = np.random.randn(1000)
def scatter_hist(x, y, ax, ax_histx, ax_histy):
# no labels
ax_histx.tick_params(axis="x", labelbottom=False)
ax_histy.tick_params(axis="y", labelleft=True,labelright=False)
ax.tick_params(axis="y", left=False,labelleft=False,right=True,labelright=True)
# the scatter plot:
ax.scatter(x, y)
# now determine nice limits by hand:
binwidth = 0.25
xymax = max(np.max(np.abs(x)), np.max(np.abs(y)))
lim = (int(xymax/binwidth) + 1) * binwidth
bins = np.arange(-lim, lim + binwidth, binwidth)
ax_histx.hist(x, bins=bins)
ax_histy.hist(y, bins=bins, orientation='horizontal')
# definitions for the axes
left, width = 0.3, 0.65
bottom, height = 0.1, 0.65
spacing = 0.005
rect_scatter = [left, bottom, width, height]
rect_histx = [left, bottom + height + spacing, width, 0.2]
rect_histy = [left-spacing-0.2, bottom, 0.2, height]
# start with a square Figure
fig = plt.figure(figsize=(8, 8))
ax = fig.add_axes(rect_scatter)
ax_histx = fig.add_axes(rect_histx, sharex=ax)
ax_histy = fig.add_axes(rect_histy, sharey=ax)
# use the previously defined function
scatter_hist(x, y, ax, ax_histx, ax_histy)
plt.show()
and here the result:
This can be achieved by setting the y-axis limit in the opposite direction.
ax_histy.hist(y, bins=bins, orientation='horizontal')
ax_histy.set_xlim(100,0) # add

Matplotlib legend makes the image too large

I'm plotting this figure with matplotlib, the for loop just color the background:
fig, ax = plt.subplots()
ax.set_ylabel('Number of contacts')
ax.set_xlabel('Time [s]')
for m in range(len(data[node])):
if data[node][m] == -1:
ax.axvline(m,color='r',linewidth=5,alpha=0.2,label="OUT")
if data[node][m] == 0:
ax.axvline(m,color='g',linewidth=5,alpha=0.2,label="RZ0")
if data[node][m] == 1:
ax.axvline(m,color='y',linewidth=5,alpha=0.2,label="RZ1")
ax.plot(x, y, 'b+')
# ax.legend() # HERE is the problem
plt.show()
Which plots the following:
What I want now is a legend to indicate each color of the background meaning, but when I include ax.legend() I get the following error:
ValueError: Image size of 392x648007 pixels is too large. It must be less than 2^16 in each
direction.
<Figure size 432x288 with 1 Axes>
<Figure size 432x288 with 0 Axes>
How am I supposed to name each color of the background, there are 43200 vertical lines but only 3 colors, does it have anything to do with the number of lines?
The trick is to set the label only once. You can add a variable for each label and replace it with None once it's used. Note that using axvline to draw a background has the problem that the line width is measured in pixel space, so neighboring lines will either overlap or have a small white space inbetween. Better to use axvspan. To avoid the white space at the left and at the right, you can explicitly set the x-limits.
The code can be simplified somewhat using a loop.
Updated code:
group consecutive spans together for drawing
precalculate the effect of alpha so the background can be drawn without the need for transparency
from matplotlib import pyplot as plt
from matplotlib import colors as mcolors
import numpy as np
import pandas as pd
import itertools
fig, ax = plt.subplots()
# create some random data
x = np.arange(100)
y = np.sinh(x/20)
indicators = [-1, 0, 1]
node = 0
data = [np.random.choice(indicators, len(x), p=[10/16,1/16,5/16])]
labels = ["OUT", "RZ0", "RZ1"]
colors = ['lime', 'purple', 'gold']
alpha = 0.4
# precalculate the effect of alpha so the colors can be applied with alpha=1
colors = [[1 + (x - 1) * alpha for x in mcolors.to_rgb(c)] for c in colors]
m = 0
for val, group in itertools.groupby(data[node]):
width = len(list(group))
ind = indicators.index(val)
ax.axvspan(m, m + width, color=colors[ind], linewidth=0, alpha=1, label=labels[ind])
labels[ind] = None # reset the label to make sure it is only used once
m += width
ax.plot(x, y, 'b+')
ax.set_xlim(0, len(data[node]))
ax.legend(framealpha=1) # to make the legend background opaque
plt.show()
Do something like hrz1 = ax.axvline(m,color='y',linewidth=5,alpha=0.2) for each of your classes, and then ax.legend((hrz1, hrz0, hout), ('RZ1', 'RZ0', 'OUT'). The hrz1 pointer will be rewritten for each line you make, and then legend will only make one label for each of the handles.

How to create multiple 1D axes showing intervals with colored lines?

I want to visualise mathematical domains, or intervals. Equivalently, I want to visualise a boolean array. There are multiple such arrays, that ideally are plotted one above the other.
What I have is some data: several recordings, over a period of, say, 100 min. Each recording satisfies a given condition only part of the time. I want to visualise the times at which each recording is "True". Some simpler variant of:
In my case, each recording can be the union of multiple intervals. For example:
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sb
sb.set_context("paper")
times = np.arange(0, 100)
mask1 = (times >= 0) * (times <= 30) + (times >= 70) * (times <= 100)
mask2 = (times >= 20) * (times <= 80)
I can plot each recording separately, with these two functions I have written:
def bool2extreme(mask, times) :
"""return xmins and xmaxs for intervals in times"""
binary = 1*mask
slope = np.diff(binary)
extr = (slope != 0)
signs = slope[extr]
mins = list(times[1:][slope==1])
maxs = list(times[:-1][slope==-1])
if signs[0]==-1:
mins = [times[0]] + mins
if signs[-1]==1:
maxs = maxs + [times[-1]]
return mins, maxs
def plot_interval(mask, times, y=0, color='k', ax=None) :
if ax==None:
print('None')
ax = plt.gca()
xmins, xmaxs = bool2extreme(mask, times)
for xmin, xmax in zip(xmins, xmaxs):
ax.plot([xmin, xmax], [y,y], lw=6, color=color)
return ax
My problem is to control the vertical spacing between the various intervals. Indeed, when I plot one of them, there is a vertical axis which I don't want. Even if I set its visibility to False, it exists and takes space. So, when I put each recording on a different subplot, the vertical spacing between them is much too big:
masks = [mask1, mask2]
labels = ['domain1', 'domain2']
n_plots = len(masks)
fig, axs = plt.subplots(n_plots, sharex=True)
for i, mask in enumerate(masks) :
axs[i] = plot_interval(mask, times, ax=axs[i])
axs[-1].set_xlabel('Time (min)')
sb.despine()
Another option I tried: have all the intervals in the same axis, but at different y values. But the problem of the vertical spacing between the intervals remains the same.
masks = [mask1, mask2]
labels = ['domain1', 'domain2']
n_plots = len(masks)
fig, ax = plt.subplots(sharex=True)
for i, mask in enumerate(masks) :
ax = plot_interval(mask, times, y=i, ax=ax)
ax.set_xlabel('Time (min)')
ax.set_yticks(range(n_plots))
ax.set_yticklabels(labels)
ax.grid(axis="x")
sb.despine(left=True)
How can I control the vertical spacing between these intervals?
Some ideas:
figsize with a small height when creating the subplots; the height of figsize controls the distance between the horizontal axes: they will be height/num_axes separated when measured in inches
ax.yaxis.set_visible(False) to hide the ticks from the y-axis
ax.spines['left'].set_color('None') to make the spine of the y-axis invisible
ax.spines['bottom'].set_position(('data', 0)) to place the x-axis at the y=0 height
(optionally) ax.tick_params(labelbottom=True) to have labels for the xticks on all subplots (instead of only on the last)
use a rectangle instead of a thick line to better control the exact start and end of the line as well as the thickness above and under the axis
to control the height of the rectangle, the ylims need to be fixed; I propose (-1.5, .5) so a thickness can be chosen appropriately; there is more space below making room for the labels of the xticks
as drawing a rectangle doesn't automatically update the xlims, they need to be set explicitly
(optionally) ax.tick_params(which='both', direction='in') to get tick marks above instead below (both mayor and minor ticks)
To have labels on the left, the following worked for me:
# ax.yaxis.set_visible(False) # removed, as it also hides the ylabel
ax.set_ylabel('my ylabel', rotation=0, ha='right', labelpad=10)
ax.set_yticks([]) # to remove the ticks, the spine was already removed
In the demo code, more xticks and some type of arrow at the ends are added. There are 7 masks in the demo, to better see the effect of distance between the axes. Trying to get the axes as close as possible, a distance of 0.4 inches seems doable. (The bool2extreme function is untouched, as it is closely related to the format used as input.)
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle, Polygon
import matplotlib.ticker as plticker
import seaborn as sbs
sbs.set_context("paper")
times = np.arange(0, 101)
num_masks = 7
masks = [np.zeros_like(times, dtype=bool) for _ in range(num_masks)]
for i in range(num_masks):
for j in range(50):
masks[i] += (times >= (i+3)*j) * (times <= (i+3)*j+i+1)
masks = masks[::-1] # reverse to get the masks plotted from bottom to top
def bool2extreme(mask, times) :
"""return xmins and xmaxs for intervals in times"""
binary = 1*mask
slope = np.diff(binary)
extr = (slope != 0)
signs = slope[extr]
mins = list(times[1:][slope==1])
maxs = list(times[:-1][slope==-1])
if signs[0]==-1:
mins = [times[0]] + mins
if signs[-1]==1:
maxs = maxs + [times[-1]]
return mins, maxs
def plot_interval(mask, times, xlim=None, y=0, thickness=0.4, color='k', ax=None):
if ax is None:
ax = plt.gca()
ax.yaxis.set_visible(False)
ax.spines['left'].set_color('None')
ax.spines['right'].set_color('None')
ax.spines['top'].set_color('None')
ax.spines['bottom'].set_position(('data', 0))
ax.tick_params(labelbottom=True) # to get tick labels on all axes
# ax.tick_params(which='both', direction='in')` # tick marks above instead below the axis
ax.xaxis.set_major_locator(plticker.MultipleLocator(base=10)) # major ticks in steps of 10
ax.xaxis.set_minor_locator(plticker.MultipleLocator(base=1)) # minor ticks in steps of 1
ax.set_ylim(-1.5,.5)
if xlim is None:
xlim = (times[0]-0.9, times[-1]+0.9)
ax.set_xlim(xlim)
xmins, xmaxs = bool2extreme(mask, times)
for xmin, xmax in zip(xmins, xmaxs):
#ax.add_patch(Rectangle((xmin, y-thickness), xmax-xmin, 2*thickness, linewidth=0, color=color))
ax.add_patch(Rectangle((xmin, y), xmax-xmin, thickness, linewidth=0, color=color))
triangle1 = [(xlim[0]-0.5, y), (xlim[0], y-thickness), (xlim[0], y+thickness)]
ax.add_patch(Polygon(triangle1, linewidth=0, color='black', clip_on=False))
triangle2 = [(xlim[1]+0.5, y), (xlim[1], y-thickness), (xlim[1], y+thickness)]
ax.add_patch(Polygon(triangle2, linewidth=0, color='black', clip_on=False))
return ax
n_plots = len(masks)
dist_between_axis_in_inches = 0.4
fig, axs = plt.subplots(n_plots, sharex=True, figsize=(10, dist_between_axis_in_inches*len(masks)))
for i, mask in enumerate(masks) :
axs[i] = plot_interval(mask, times, xlim=(times[0]-0.5, times[-1]+0.5), ax=axs[i], color='lime')
axs[-1].set_xlabel('Time (min)')
plt.show()
Result with axes close together:
PS: This post contains more proposals about adding arrows.

Matplotlib automatically scale vertical height of subplots for shared x-axis figure

I want to automatically scale the vertical height of subplots for shared x-axis figures based on their data span! I want to compare the relative intensity of the displayed data. If i use the sharey=True kwarg for the subbplots the data is displayed in a way that the relative intensity is recognizable:
import matplotlib.pyplot as plt
from matplotlib import gridspec
import numpy as np
SIZE = (12, 8) #desired overall figure size
# Simple data to display in various forms
x = np.linspace(0, 2 * np.pi, 400)
y = np.sin(x ** 2)
y2 = 2*(np.sin(x ** 2))
y3 = 3*(np.sin(x ** 2))
fig, ax = plt.subplots(3,ncols=1, sharex=True, sharey=True)
fig.set_size_inches(SIZE[1], SIZE[0])
fig.subplots_adjust(hspace=0.001)
ax[0].plot(x, y)
ax[1].plot(x, y2)
ax[2].plot(x, y3)
plt.show()
All subplots have the same height now and the data span in the y-Axis is recognizable as the data is displayed with the correct relative proportion.
What i would like to achieve is that the scales of each plot end where the data ends. Essentially eliminating the not used white space. The size of the subplot would than represent the relative height ratios of the data. They should still have the same scaling on the Y axis in order for the viewer to estimate the relative data height ( which cold be a countrate for example).
I found the following links to similar problems but none really helped me to solve my issue:
Link1 Link2
Here an example that determines the ratio for you and creates the subplots accordingly:
import matplotlib.pyplot as plt
from matplotlib import gridspec
import numpy as np
SIZE = (12, 8) #desired overall figure size
# Simple data to display in various forms
x = np.linspace(0, 2 * np.pi, 400)
# the maximum multiplier for the function
N = 3
# the y-ranges:
ys = [i * np.sin(x**2) for i in range(1,N+1)]
# the maximum extent of the plot in y-direction (cast as int)
hs = [int(np.ceil(np.max(np.abs(y)))) for y in ys]
# determining the size of the GridSpec:
gs_size = np.sum(hs)
gs = gridspec.GridSpec(gs_size,1)
# the figure
fig = plt.figure(figsize = SIZE)
# creating the subplots
base = 0
ax = []
for y,h in zip(ys,hs):
ax.append(fig.add_subplot(gs[base:h+base,:]))
base += h
ax[-1].plot(x,y)
##fig, ax = plt.subplots(3,ncols=1, sharex=True, sharey=True)
##fig.set_size_inches(SIZE[1], SIZE[0])
fig.subplots_adjust(hspace=0.001)
##ax[0].plot(x, ys[0])
##ax[1].plot(x, ys[1])
##ax[2].plot(x, ys[2])
plt.show()
The code determines the maximum y-extend for each set of data, casts it into an integer and then divides the figure into subplots using the sum of these extends as scale for the GridSpec.
The resulting figure looks like this:
Tested on Python 3.5
EDIT:
If the maximum and minimum extents of your data are not comparable, it may be better to change the way hs is calculated into
hs = [int(np.ceil(np.max(y))) - int(np.floor(np.min(y))) for y in ys]

Categories

Resources