Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Bug]: Nondeterministic behavior with subplot spacing and constrained layout #28574

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
zachjweiner opened this issue Jul 14, 2024 · 9 comments · Fixed by #28577
Closed

[Bug]: Nondeterministic behavior with subplot spacing and constrained layout #28574

zachjweiner opened this issue Jul 14, 2024 · 9 comments · Fixed by #28577
Milestone

Comments

@zachjweiner
Copy link
Contributor

zachjweiner commented Jul 14, 2024

Bug summary

I'm trying to get constrained layout to remove all space between subplots when no artists should be preventing it. In this example I've set all necessary rc params accordingly and changed the axes limits to ensure the x-axis tick labels don't spill beyond the axis extent. The resulting space between the axes is nondeterministic.

Code for reproduction

%matplotlib inline

import matplotlib.pyplot as plt
rc = {
    "xtick.direction": "in", 
    "ytick.direction": "in",
    "figure.constrained_layout.use": True,
    "figure.constrained_layout.h_pad": 0,
    "figure.constrained_layout.w_pad": 0,
    "figure.constrained_layout.wspace": 0,
    "figure.constrained_layout.hspace": 0,
    "axes.labelpad": 0,
}

for _ in range(2):
    with plt.style.context(rc):
        fig, axes = plt.subplots(1, 2, figsize=(6, 2), sharey=True)
        
        for ax in axes.flat:
            ax.set_xlim(-0.1, 1.1)

Actual outcome

Example output (ran a handful of times until one of each result showed):
image

Expected outcome

Zero spacing between the axes, deterministically.

Additional information

I noticed that the results became deterministic (with zero space between panels) when saving the figure (that is, the result that displayed in the notebook, not just the saved figure). Inserting a fig.canvas.draw() at the end likewise ensures zero space deterministically.

Operating system

Rocky Linux

Matplotlib Version

3.9.1

Matplotlib Backend

inline

Python version

3.11.9

Jupyter version

4.2.3

Installation

conda

@jklymak
Copy link
Member

jklymak commented Jul 15, 2024

I think this is expected behaviour and I'm not sure if we really can do anything about it. This happens because when the constraints are first computed the xtick on the right hand side of the axis horizontally overspills the axis bounding box, so constrained layout adds a bit of space. The axes gets redrawn, the axis is larger, and now the tick label fits.

I don't think there is anything to directly be done about this. I think you could take the tick label out of the layout, but that is a bit fiddily. If you want the axes to always exactly touch, then I'm not sure I'd bother using constrained layout.

@jklymak
Copy link
Member

jklymak commented Jul 15, 2024

Btw it maybe could be more clear, but this is the case covered by caveat 2 here https://matplotlib.org/stable/users/explain/axes/constrainedlayout_guide.html#other-caveats

@zachjweiner
Copy link
Contributor Author

This happens because when the constraints are first computed the xtick on the right hand side of the axis horizontally overspills the axis bounding box, so constrained layout adds a bit of space. The axes gets redrawn, the axis is larger, and now the tick label fits.

I'm not sure I follow why this should be nondeterministic between identical executions. I do think the zero-space result is the correct one (following from what I stated under "additional information"), but even if it isn't I don't see why the result should be nondeterministic.

I think you could take the tick label out of the layout, but that is a bit fiddily.

As an aside, I would like to know how to do this (using set_in_layout on the tick labels' Text instances didn't work) - but I asked about it on discourse because it's a separate question that I'm not yet sure is a bug.

@QuLogic
Copy link
Member

QuLogic commented Jul 15, 2024

This is a complicated interplay of our tick system being too customizable, being too lazy, and IMO, some weird cross-ownership of the calculation of tight bounding boxes. In order to calculate the bounding box of the Axes, the Spine is the one that handles the tick lines (which is odd to me, since the Axis owns the Ticks):

major_tick = next(iter({*drawn_ticks} & {*self.axis.majorTicks}), None)
minor_tick = next(iter({*drawn_ticks} & {*self.axis.minorTicks}), None)
for tick in [major_tick, minor_tick]:
if tick is None:
continue
bb0 = bb.frozen()
tickl = tick._size
tickdir = tick._tickdir
if tickdir == 'out':
padout = 1
padin = 0
elif tickdir == 'in':
padout = 0
padin = 1
else:
padout = 0.5
padin = 0.5
padout = padout * tickl / 72 * self.figure.dpi
padin = padin * tickl / 72 * self.figure.dpi

In doing so, it picks a major tick, and a minor tick, and adds padding based on the direction in the tick's _tickdir attribute. However, it does this by passing the tick lists through some sets, meaning it will pick a random one depending on the set.

Ticks are generated lazily, and copy properties from the initial tick:

def _copy_tick_props(self, src, dest):
"""Copy the properties from *src* tick to *dest* tick."""
if src is None or dest is None:
return
dest.label1.update_from(src.label1)
dest.label2.update_from(src.label2)
dest.tick1line.update_from(src.tick1line)
dest.tick2line.update_from(src.tick2line)
dest.gridline.update_from(src.gridline)

This copies the properties from sub-Artist to sub-Artist, but not the _tickdir attribute (or any others for that matter). So it will be whatever the default rcParam is at the time of the copy. If you run a draw in the context, the copies happen inside, and are all _tickdir='in'. If you don't, then some ticks will have the right value, and some won't.

This means that the Spine will sometimes calculate a bounding box for a inward tick, and sometimes one for an outward tick, depending on which random tick it used.

@zachjweiner
Copy link
Contributor Author

Indeed, setting the rcParams in my example globally rather than as a context appears to ensure determinism. Also helps explain the inconsistent behavior I was observing more generally (since I was using rc contexts to facilitate testing, whereas I normally set a style globally).

Many thanks for digging into it (and addressing it so quickly)!

@zachjweiner
Copy link
Contributor Author

@QuLogic, would your observations explain why calling set_in_layout(False) on all tick labels has no effect on the constrained layout result?

I'm actually struggling to get set_in_layout to have any effect on any artist at all. Even ham-fistedly applying it to all axes' children has no effect, e.g.,

%matplotlib inline

import matplotlib.pyplot as plt
rc = {
    "xtick.direction": "in", 
    "ytick.direction": "in",
    "figure.constrained_layout.use": True,
    "figure.constrained_layout.h_pad": 0,
    "figure.constrained_layout.w_pad": 0,
    "figure.constrained_layout.wspace": 0,
    "figure.constrained_layout.hspace": 0,
}
plt.style.use(rc)

fig, axes = plt.subplots(2, 2, figsize=(6, 4), sharey=True, sharex="col")
for ax in axes.flat:
    ax.set_title("title")

for ax in axes.flat:
    for artist in ax.get_children():
        artist.set_in_layout(False)

Let me know whether to open a new issue. I'm really struggling to study the issue in more detail because, e.g., I can't figure out how to properly draw bboxes on figures. Copying this color_boxes method and using it as in those tests draws boxes that only cover ~the lower-left quadrant of the axes, e.g.,

import matplotlib.pyplot as plt
fig, ax = plt.subplots()
bbaxis, bbspines, bbax, bbtb = color_boxes(fig, ax)

yields
image

@jklymak
Copy link
Member

jklymak commented Jul 15, 2024

The bad bboxes are because you are drawing in pixel co-ordinates, but Matplotlib doubles the pixels for retina displays. I wouldn't dig code out of the tests and expect it to work on arbitrary code. In particular transform=None is not going to work for that situation.

As for taking everything out of layout - that may not work as not everything checks the in_layout setting when calculating the tightbboxes. One would have to track all the elements down and then decide if it makes sense to allow them to be excluded. It is possible that the axes elements have never been excluded.

@zachjweiner
Copy link
Contributor Author

The bad bboxes are because you are drawing in pixel co-ordinates, but Matplotlib doubles the pixels for retina displays.

Argh, thanks, I set that in my jupyter configuration so many years ago I completely forgot about it.

I wouldn't dig code out of the tests and expect it to work on arbitrary code. In particular transform=None is not going to work for that situation.

Do the tests set some global configuration that's not evident in the files themselves, or is just my own config to worry about? I scoured the repo, issues, discourse, and google for other up-to-date solutions before realizing the tests should be as authoritative as it gets.

As for taking everything out of layout - that may not work as not everything checks the in_layout setting when calculating the tightbboxes. One would have to track all the elements down and then decide if it makes sense to allow them to be excluded. It is possible that the axes elements have never been excluded.

Does the docstring for get_tightbbox (and this line in get_default_bbox_extra_artists) not suggest that they are meant to be excluded?

@jklymak
Copy link
Member

jklymak commented Jul 15, 2024

Someone would have to check the code paths for getting bounding boxes, which are somewhat convoluted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants