6

I would like to create a square plot using multiple axes using make_axes_locateable as demonstrated in the matplotlib documentation. However, while this works on plots where the x and y data have the same range, it does not work when the ranges are orders of magnitude different.

import numpy as np
import matplotlib.pyplot as plt

from mpl_toolkits.axes_grid1 import make_axes_locatable

x = np.random.normal(512, 112, 240)
y = np.random.normal(0.5, 0.1, 240)

_, ax = plt.subplots()
divider = make_axes_locatable(ax)

xhax = divider.append_axes("top", size=1, pad=0.1, sharex=ax)
yhax = divider.append_axes("right", size=1, pad=0.1, sharey=ax)

ax.scatter(x, y)
xhax.hist(x)
yhax.hist(y, orientation="horizontal")

x0,x1 = ax.get_xlim()
y0,y1 = ax.get_ylim()
ax.set_aspect(abs(x1-x0)/abs(y1-y0))

plt.show()

Although this code uses the set_aspect answer as in How do I make a matplotlib scatter plot square? the axes are not modified correctly as shown here:

enter image description here

I attempted to repair this with:

ax.set_aspect(abs(x1-x0)/abs(y1-y0), share=True)

But this resulted in the following:

enter image description here

Setting the aspect after calling scatter and before creating the two histogram axes seemed to have no effect, even though it appears that was done in the documentation example. This code does work when the data range is the same:

enter image description here

Update: One of the key constraints of this question is to use make_axes_locateable and not GridSpec as discussed in the comments below. The problem I'm working on involves creating plotting functions that accept an Axes object to work on and modify it without having knowledge of the figure or any other Axes in the plot as in the following code:

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as grid

from mpl_toolkits.axes_grid1 import make_axes_locatable, axes_size


def joint_plot(x, y, ax=None):
    """
    Create a square joint plot of x and y.
    """
    if ax is None:
        ax = plt.gca()

    divider = make_axes_locatable(ax)
    xhax = divider.append_axes("top", size=1, pad=0.1, sharex=ax)
    yhax = divider.append_axes("right", size=1, pad=0.1, sharey=ax)

    ax.scatter(x, y)
    xhax.hist(x)
    yhax.hist(y, orientation="horizontal")

    x0,x1 = ax.get_xlim()
    y0,y1 = ax.get_ylim()
    ax.set_aspect(abs(x1-x0)/abs(y1-y0))

    plt.sca(ax)
    return ax, xhax, yhax


def color_plot(x, y, colors, ax=None):
    if ax is None:
        ax = plt.gca()

    divider = make_axes_locatable(ax)
    cbax = divider.append_axes("right", size="5%", pad=0.1)

    sc = ax.scatter(x, y, marker='o', c=colors, cmap='RdBu')
    plt.colorbar(sc, cax=cbax)

    ax.set_aspect("equal")

    plt.sca(ax)
    return ax, cbax


if __name__ == "__main__":
    _, axes = plt.subplots(nrows=2, ncols=2, figsize=(9,6))

    # Plot 1
    x = np.random.normal(100, 17, 120)
    y = np.random.normal(0.5, 0.1, 120)
    joint_plot(x, y, axes[0,0])

    # Plot 2
    x = np.random.normal(100, 17, 120)
    y = np.random.normal(100, 17, 120)
    c = np.random.normal(100, 17, 120)
    color_plot(x, y, c, axes[0,1])

    # Plot 3
    x = np.random.normal(100, 17, 120)
    y = np.random.normal(0.5, 0.1, 120)
    c = np.random.uniform(0.0, 1.0, 120)
    color_plot(x, y, c, axes[1,0])

    # Plot 4
    x = np.random.normal(0.5, 0.1, 120)
    y = np.random.normal(0.5, 0.1, 120)
    joint_plot(x, y, axes[1,1])

    plt.tight_layout()
    plt.show()

enter image description here

This question extends questions such as Set equal aspect in plot with colorbar and python interplay between axis('square') and set_xlim because of the Axes-only constraint.

bbengfort
  • 5,254
  • 4
  • 44
  • 57
  • As I understood the question, it's about making the axes square, independent of the data units. That would be shown in the duplicate. If that would not solve the problem, please explain clearly what the desired outcome would be and what "square aspect ratio" would mean. – ImportanceOfBeingErnest Feb 06 '19 at 03:08
  • A more intricate solution would be [this one](https://stackoverflow.com/questions/51474842/python-interplay-between-axissquare-and-set-xlim/51483579#51483579) which may have advantages or disadvantages, depending on what you want to do. – ImportanceOfBeingErnest Feb 06 '19 at 03:10
  • @ImportanceOfBeingErnest I've updated the question with why this is not a duplicate - this is specifically the case where the data limits are not equal, and I'm using the methodology suggested in the question you mentioned. Thank you for helping me research this! – bbengfort Feb 06 '19 at 03:13
  • Maybe you misunderstood the answer to that question? Of course you need to remove the line `ax.set_aspect("equal")` for the previous line to take effect. – ImportanceOfBeingErnest Feb 06 '19 at 03:16
  • That was a copy and paste typo, and you're right that does solve this simple example; unfortunately, it does not work in the case when using `make_axes_locateable` - I'll update the question to include the new parameters. – bbengfort Feb 06 '19 at 03:25
  • 1
    @ImportanceOfBeingErnest I've updated the question to reflect the more complex scenario. I was hoping that the simple case would solve the problem, but it looks like the solution will have to be specific to make_axes_locatable. – bbengfort Feb 06 '19 at 03:44

2 Answers2

4

One way to deal with the problem is to keep the data limits of the x and y axis equal. This can be done by normalising the values to be between, say, 0 and 1. This way the command ax.set_aspect('equal') works as expected. Of course, if one only does this, the tick labels will only range from 0 to 1, so one has to apply a little matplotlib magic to adjust the tick labels to the original data range. The answer here shows how this can be accomplished using a FuncFormatter. However, as the original ticks are chosen with respect to the interval [0,1], using a FuncFormatter alone will result in odd ticks e.g. if the factor is 635 an original tick of 0.2 would become 127. To get 'nice' ticks, one can additionally use a AutoLocator, which can compute ticks for the original data range with the tick_values() function. These ticks can then again be scaled to the interval [0,1] and then FuncFormatter can compute the tick labels. It is a bit involved, but in the end it only requires about 10 lines of extra code:

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker

from mpl_toolkits.axes_grid1 import make_axes_locatable

x = np.random.normal(512, 112, 240)
y = np.random.normal(0.5, 0.1, 240)


fig,ax=plt.subplots()

divider = make_axes_locatable(ax)


##increased pad from 0.1 to 0.2 so that tick labels don't overlap
xhax = divider.append_axes("top", size=1, pad=0.2, sharex=ax)
yhax = divider.append_axes("right", size=1, pad=0.2, sharey=ax)

##'normalizing' x and y values to be between 0 and 1:
xn = (x-min(x))/(max(x)-min(x))
yn = (y-min(y))/(max(y)-min(y))

##producinc the plots
ax.scatter(xn, yn)
xhax.hist(xn)
yhax.hist(yn, orientation="horizontal")

##turning off duplicate ticks (if needed):
plt.setp(xhax.get_xticklabels(), visible=False)
plt.setp(yhax.get_yticklabels(), visible=False)

ax.set_aspect('equal')


##setting up ticks and labels to simulate real data:
locator = mticker.AutoLocator()

xticks = (locator.tick_values(min(x),max(x))-min(x))/(max(x)-min(x))
ax.set_xticks(xticks)
ax.xaxis.set_major_formatter(mticker.FuncFormatter(
    lambda t, pos: '{0:g}'.format(t*(max(x)-min(x))+min(x))
))

yticks = (locator.tick_values(min(y),max(y))-min(y))/(max(y)-min(y))
ax.set_yticks(yticks)
ax.yaxis.set_major_formatter(mticker.FuncFormatter(
    lambda t, pos: '{0:g}'.format(t*(max(y)-min(y))+min(y))
))

fig.tight_layout()
plt.show()

The resulting picture looks as expected and stays square-shaped also upon resizing of the image.

Old Answer:

This is more a workaround than a solution:

Instead of using ax.set_aspect(), you can set up your figure such that it is a square by providing figsize=(n,n) to plt.subplots, where n would be the width and height in inches. As the height of xhax and the width of yhax are both 1 inch, this means that ax becomes square as well.

import numpy as np
import matplotlib.pyplot as plt

from mpl_toolkits.axes_grid1 import make_axes_locatable

x = np.random.normal(512, 112, 240)
y = np.random.normal(0.5, 0.1, 240)

fig, ax = plt.subplots(figsize=(5,5))

divider = make_axes_locatable(ax)

xhax = divider.append_axes("top", size=1, pad=0.1, sharex=ax)
yhax = divider.append_axes("right", size=1, pad=0.1, sharey=ax)

ax.scatter(x, y)
xhax.hist(x)
yhax.hist(y, orientation="horizontal")

##turning off duplicate ticks:
plt.setp(xhax.get_xticklabels(), visible=False)
plt.setp(yhax.get_yticklabels(), visible=False)

plt.show()

The result looks like this:

enter image description here

Of course, as soon as you resize your figure, the square aspect will be gone. But if you already know the final size of your figure and just want to save it for further use, this should be a good enough quick fix.

Thomas Kühn
  • 9,412
  • 3
  • 47
  • 63
  • I think you would also need to take the subplot parameters into account. Then that is essentially [this solution](https://stackoverflow.com/questions/51474842/python-interplay-between-axissquare-and-set-xlim/51483579#51483579) (to which I already linked in the comments). Also see that answer for a way to make it responsive to figure size changes. – ImportanceOfBeingErnest Feb 06 '19 at 11:46
  • Part of the reason I'm using `make_axes_locatable` instead of `GridSpec` is that I'm trying to avoid messing with the figure since this plot may already be part of a multi-axes figure. E.g. if I wanted to add a plot with a color bar or this plot to an already defined grid spec. I do appreciate this workaround, and if it simply can't be done using only axes modifications then I may have to rethink our whole approach. – bbengfort Feb 06 '19 at 12:06
  • @ImportanceOfBeingErnest I agree that it's not perfect and probably not a perfect square either. Your solution in the other question looks very solid indeed. I was also wondering whether forcing the axes sizes with `add_axes()` would be a way to do it, but I think it's more trouble than it's worth ... – Thomas Kühn Feb 06 '19 at 12:08
  • 1
    @bbengfort I see. One thing that still comes to mind is to 'normalize' your distributions with respect to the argument and then adjust the ticklabels afterwards. This way you wouldn't have the problem with the different data limits. I can generate an example of what I mean if you're interested. – Thomas Kühn Feb 06 '19 at 12:10
  • Given how complicated the solution I provided now has become, `add_axes` may indeed be a viable workaround, depending on the use case. – ImportanceOfBeingErnest Feb 06 '19 at 13:59
  • @bbengfort I updated my answer -- please have a look if this solves your problem. I didn't try it as part of a bigger figure with more subplots, but at least the figure layout you posted in your question behaves as expected. – Thomas Kühn Feb 07 '19 at 13:39
  • This is an extremely clever solution, thank you and @ImportanceOfBeingErnest so much for your help. I updated with the multiplot code if you're interested, but both solutions worked for it. I chose this solution because it resized with the figure rather than being fixed. I really appreciate it! – bbengfort Feb 08 '19 at 01:21
  • My solution also resizes with the figure. What do you not like about it? The problem with the solution in this answer is that it fixes the limits of the plot, which is usually undesired, e.g. you cannot pan or zoom interactively. – ImportanceOfBeingErnest Feb 08 '19 at 01:24
2

The axes_grid1's Divider works a bit differently than usual subplots. It cannot directly cope with aspects, because the size of the axes is determined at draw time either in relative or absolute coordinates.

If you want, you can manually specify the axes size in absolute coordinates to obtain a square subplot.

import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.axes_grid1 import make_axes_locatable, axes_size

x = np.random.normal(512, 112, 240)
y = np.random.normal(0.5, 0.1, 240)

_, ax = plt.subplots()
divider = make_axes_locatable(ax)

xhax = divider.append_axes("top", size=1, pad=0.1, sharex=ax)
yhax = divider.append_axes("right", size=1, pad=0.1, sharey=ax)

horiz = [axes_size.Fixed(2.8), axes_size.Fixed(.1), axes_size.Fixed(1)]
vert = [axes_size.Fixed(2.8), axes_size.Fixed(.1), axes_size.Fixed(1)]
divider.set_horizontal(horiz)
divider.set_vertical(vert)

ax.scatter(x, y)
xhax.hist(x)
yhax.hist(y, orientation="horizontal")

plt.setp(xhax.get_xticklabels(), visible=False)
plt.setp(yhax.get_yticklabels(), visible=False)

plt.show()

enter image description here

This solution is robust against figure size changes in the sense that the grid is always 2.8 + 0.1 + 1 = 3.9 inches wide and heigh. So one just needs to make sure the figure size is always large enough to host the grid. Else it might crop the marginal plots and look like this:

enter image description here

To have an adaptive solution that would still scale with the figure size, one could define a custom Size, which takes the remainder of the absolutely sizes padding and marginal axes and returns the minimum of those in absolute coordinates (inches), for both directions such that the axes is always square.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.transforms import Bbox
from mpl_toolkits.axes_grid1 import make_axes_locatable, axes_size

class RemainderFixed(axes_size.Scaled):
    def __init__(self, xsizes, ysizes, divider):
        self.xsizes =xsizes
        self.ysizes =ysizes
        self.div = divider

    def get_size(self, renderer):
        xrel, xabs = axes_size.AddList(self.xsizes).get_size(renderer)
        yrel, yabs = axes_size.AddList(self.ysizes).get_size(renderer)
        bb = Bbox.from_bounds(*self.div.get_position()).transformed(self.div._fig.transFigure)
        w = bb.width/self.div._fig.dpi - xabs
        h = bb.height/self.div._fig.dpi - yabs
        return 0, min([w,h])

x = np.random.normal(512, 112, 240)
y = np.random.normal(0.5, 0.1, 240)

fig, ax = plt.subplots()
divider = make_axes_locatable(ax)

margin_size = axes_size.Fixed(1)
pad_size = axes_size.Fixed(.1)
xsizes = [pad_size, margin_size]
ysizes = xsizes

xhax = divider.append_axes("top", size=margin_size, pad=pad_size, sharex=ax)
yhax = divider.append_axes("right", size=margin_size, pad=pad_size, sharey=ax)

divider.set_horizontal([RemainderFixed(xsizes, ysizes, divider)] + xsizes)
divider.set_vertical([RemainderFixed(xsizes, ysizes, divider)] + ysizes)

ax.scatter(x, y)
xhax.hist(x)
yhax.hist(y, orientation="horizontal")

plt.setp(xhax.get_xticklabels(), visible=False)
plt.setp(yhax.get_yticklabels(), visible=False)

plt.show()

enter image description here enter image description here

Note how the sizes of the marginals is always 1 inch, independent of the figure size how the scatter axes adjusts to the remaining space and stays square.

ImportanceOfBeingErnest
  • 321,279
  • 53
  • 665
  • 712
  • 1
    Note that this would comply to the changed question scope (via the comment "this plot may already be part of a multi-axes figure"); I just tested and it works surprisingly well. – ImportanceOfBeingErnest Feb 08 '19 at 01:21