Skip to content
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

make %matplotlib widget behave like inline? #171

Open
allefeld opened this issue Feb 1, 2020 · 11 comments
Open

make %matplotlib widget behave like inline? #171

allefeld opened this issue Feb 1, 2020 · 11 comments

Comments

@allefeld
Copy link

allefeld commented Feb 1, 2020

I have a notebook in Jupyter Lab, so far using the default matplotlib backend, which in this context I believe is inline. When I have a sequence of plotting commands (pyplot.plt etc.), they combine into a figure that is shown after the respective code cell. Also, @interact works as expected.

I now wanted to zoom in, which inline does not support, so I issued %matplotlib widget. The figures are now zoomable alright, but I don't have that neat behavior where plotting code in one cell contributed to the following figure output cell, and doesn't modify anything else. And @interact, instead of producing new (versions of) a figure, now puts everything on top of each other.

From reading around I realize that this is the intended behavior. The thing is, I don't like it. So my question is: Is there a way to make widget behave like inline?

If there is not, is there a guide that tells me how to modify my code so that plot commands affect only the next output cell, and @interact updates instead of adds?

@tacaswell
Copy link
Member

One of the great things about pyplot from a convenience is that it has a global notion of "the current figure" and one of the very frustrating things about pyplot is that it has a global notion of "the current figure".

With inline when the figure is displayed the python objects backing it is immediately destroyed so every cell starts and ends it's execution life with no open figures (but then you only get static images). On the other hand with widget nothing closes the figures so if you (implicitly) create a figure on one cell, it is still around in the next cell so when you call plt.plot again it adds it to the "current figure" (which is the one you just created). This behavior is identical to the behavior you get when working at the prompt and what lets you have a live figure to zoom/pan around on.

I suggest that you use pyplot primarily to create figures / axes

fig, ax = plt.subplots()

and then use the methods on ax to do your plotting (almost all of pyplot is implemented as pass-throughs to the underlying Axes method). This will let you be explicit about what figure / axes you are plotting to (rather than relying on global state to be just right).

Depending on what you are using interact for you can also update the artists rather than re-creating the figure from scratch which will make it slightly more preformant. For example if you wanted to add a slider that changes the limits you could hook it up to ax.set_xlim(...). If you do want to start from scratch ax.cla() will reset an Axes back to it's initial state.

@allefeld
Copy link
Author

allefeld commented Feb 2, 2020

@tacaswell , thank you for your answer!

I realize you guys have made up your mind, but I cannot resist stating that
<speech> I think in the context of a notebook, a global current figure doesn't make sense. The idea of a notebook, as I understand it, is to go step by step through a computation and to visualize results along the way. A notebook is to be read like a text, and in a text what comes after follows from what comes before, not the other way around. An exception would be LaTeX-style figure floats, but that doesn't exist in a notebook.
Now I can make my code more complicated to work around that, but that again in my mind goes against the spirit of a notebook; the code should be as simple as possible, for graphics focusing on what is shown and how, not fiddling around with internals.</speech>

Of course pyplot is built around the concept of a current figure, there's no way around it. But inline is based on matplotlib, too, so wouldn't it be possible to have a switch / option / configuration setting that enables the same behavior in widget?

@tacaswell
Copy link
Member

Now I can make my code more complicated to work around that,

I agree it is different, but not inherently more complicated. At the cost of being slightly more verbose you get much better clarity about what is going on (and maybe a net reduction in typing as ax is one letter shorter than plt).

A switch that works today is to put fig, ax = plt.subplots() at the top of cells where you want a new figure. Using the OO API into Matplotlib is generally encouraged anyway and it makes it much easier when you want to turn notebook cells into functions in a library.

If you want to orphan a figure from pyplot's internal registry when it is displayed, that is probably possible (put the logic in the display logic), however for panning / zooming we need to make sure that the figure does not get garbage collected (so that we can re-draw it on pan/zoom). If you are holding a reference to it that is ok, but if not (you did plt.plot() in a cell) then it will get gc'd and your figure will be dead. We could solve that by adding a second shadow registry, but then we would also have to add a way to clear that registry (or there would be no way to drop the figure and let it get gc'd which would turn into a memory leak).

Having typed this out, I think I see a path to this which is to add a "quasi-orphan" option to pyplot removes a figure from the gcf() code path, but is still hit by plt.close(fig) and plt.close('all'). @allefeld You want to take a pass at implementing this? The relevant code is in https://github.com/matplotlib/matplotlib/blob/7a2971b1c23661532826e7ff987e47391a1700da/lib/matplotlib/_pylab_helpers.py#L10 and in pyplot.py. The second step would be to add logic to ipympl to use this functionality when it displays a Figure.

@allefeld
Copy link
Author

allefeld commented Feb 2, 2020

Thanks again. I hadn't thought about it in that detail and now understand better what the challenge is.

Better, but not completely. Wouldn't it be enough if at the start of each cell, matplotlib acted as if there was no current figure (yet), analogous to the situation after startup, so that any pyplot statement creates a new figure? That doesn't necessarily mean that figures are orphaned from the registry, only that the existence of one or more figures in the registry doesn't imply gcf() to be defined. It is only defined within a cell, starting from the first pyplot statement.

If you are holding a reference to it that is ok, but if not (you did plt.plot() in a cell) then it will get gc'd and your figure will be dead. We could solve that by adding a second shadow registry, but then we would also have to add a way to clear that registry (or there would be no way to drop the figure and let it get gc'd which would turn into a memory leak).

Wouldn't it be possible that the notebook output cell holds that reference? I.e. the notebook itself plays the role of the "second shadow registry"? – In the view of the role of figures in a notebook that I outlined, close()ing a figure explicitly doesn't make much sense either. The notebook equivalent is removing the output cell. Since it holds the reference, the figure object stays alive as long as the cell remains in the notebook, but gets available for being gc'd as soon as the cell is deleted so there is no memory leak.

Not sure whether any of this makes sense...

@allefeld
Copy link
Author

allefeld commented Feb 3, 2020

@tacaswell, me again.

I tried the workaround you suggested, putting fig, ax = plt.subplots() at the beginning of each cell where I plot, and then plot using ax.plot etc.

Some of the figures are controlled by an @interact slider, e.g.

fig, ax = plt.subplots()
@interact(alpha=(0.01, 1.0, 0.01))
def isotonicPlot(alpha = 0.3):
    ax.clear()
    ax.plot(xs, FsIsotonic(xs, alpha), Xu, FsNaive(Xu, alpha), 'bo')

If I run through all cells and then revisit earlier ones, these remain interactive in the sense that I can zoom and pan, but the slider doesn't do anything anymore.

If instead I put plt.figure() at the beginning of plotting cells (and use plt.plot and plt.cla()), everything including the sliders remains interactive. Which makes me wonder why this is not the recommendation.

I'm afraid I still don't know what's happening. I understand that in each subsequent cell I overwrite fig and ax and therefore there's no global reference to the previous figures anymore. But the interactive function uses ax in its definition, which means a reference should be kept in the function closure. And the figures remain referenced in pyplot's list of figures. 😕

@tacaswell
Copy link
Member

Sorry for the very long delay, I started to write this and then it got lost in my too many open tabs...

Wouldn't it be enough if at the start of each cell, matplotlib acted as if there was no current figure (yet)

It is my understanding that with the execution model of jupyter things on the python side do not know anything about the cell. It is a feature that Matplotlib can not event tell if it is being executed in IPython or in a notebook, and certainly not where the cell boundaries are. There are however hooks that Jupyter calls on the way out (and maybe on the way in?) that could do some of this management.

That is not how scoping works in python, all of the callbacks are walking up to find the global scope and finding the ax from the last cell you executed.

fig, ax = plt.subplots()
@interact(alpha=(0.01, 1.0, 0.01))
def isotonicPlot(alpha = 0.3, *, ax_inner=ax):
    ax_innner.clear()
    ax_inner.plot(xs, FsIsotonic(xs, alpha), Xu, FsNaive(Xu, alpha), 'bo')

should work because it will bind the ax (at function definition time) to the local name ax_inner.

See https://stackoverflow.com/questions/13355233/python-lambda-closure-scoping for a long explanation as to why.

@tacaswell
Copy link
Member

When you select a figure it makes it the "active" figure so plt.gca() will select that one. It is "working" by co-incident.

@allefeld
Copy link
Author

@tacaswell, thanks once more!

You're right, I realized that I had a wrong idea of Python closures. I assumed that if a function references a global object, and that object goes away, the function becomes a closure, but that is not the case. I also found that plt.figure() working was a fluke.

I'm sorry to say that despite your extensive and appreciated efforts to help I've since decided to use Plotly. I believe what makes Plotly easier to use within a notebook is that figures are values, which appear in the output if they are the result of the last evaluation in a cell (or are displayed). This way the figure is bound to the respective code cell, which makes figures follow the standard notebook logic. Moreover, zooming, datatips etc. are implemented in the front-end JavaScript, which makes these features independent of particular Python objects being kept alive. I think Bokeh works similarly. If I use Matplotlib again, I'd probably stick to inline.

@tacaswell
Copy link
Member

With ipympl there is a bunch of javascript bound to the output cell, there is just a mirror back to the python side as well.

Both bokeh and plotly only have js displays. While this does have some major upsides (like the user interaction happens in the browser, not via a round-trip communication with the kernel), you do give up things as well (like being able to move back out of the browser, the whole set of file formats Matplotlib saves to, etc).

@fperez
Copy link
Member

fperez commented Oct 12, 2021

I've been thinking more and more about this issue - the fundamental tension is that this problem makes it very hard for users to mostly work with inline figures, but only occasionally switch to widget ones. Unless they rewrite a lot of the code in a given notebook, chances are it won't work the way they expect it...

There's a side effect from matplotlib's success that is a bit problematic: it's common these days to end up with mpl figures that were auto-created by e.g. pandas or xarray plot calls... It's a larger update to ask the users to always move their pandas code to manual plt.subplots() calls...

It's impossible purely in Python to tell the difference between the user keeping a figure open deliberately or re-running a cell (and thus ending up with "useless" figures open inside of plt._pylab_helpers.Gcf.figs. But I am wondering if we might not be able to improve the UX by adding a bit of manual logic in JupyterLab, so that whenever a cell is executed that had an open figure, we go into that Dict and close it manually for the user...

We might also be able to inject automatic figure creation logic... This would definitely change the experience, and it would interfere with more advance uses where users deliberately modify an open figure from different cells.

But something like a %matplotlib widget-single mode that would simply create a new figure unconditionally on any plot call (with some suitably intrusive logic), in addition to auto-closing all open figures that don't remain visible (by hooking JS dom handlers to the backend somewhat aggressively) could actually be what many users would be most happy with...

Before trying to implement anything specific, curious what folks here think?

@tacaswell
Copy link
Member

I'm 💯 on (fully) closing any Figure that no longer has a view in the DOM (nbagg does this, I have lost track of if ipympl still does this, I think that might have gotten lost when we got the ability to have more than one view...). This is the behavior we have when you ❌ a GUI window (if you still have a reference to it won't get gc'd and you can manually revive it (😱 ) but Matplotlib no longer knows about it).

I think what user want is to have

  1. visible figures still "live"
  2. plt.XYZ to never cross a cell boundary
  3. plt.XYZ to work "as expected" with in a cell

The global state in Gcf.figs is doing two things for us, keeping track of the figure by number/name so it can be re-selected via plt.figure and keeping a hard-ref to the figure so it does not get gc'd out from under the user. I suspect that there is a path through where we use a post-cell-executed hook in jupyter (registered by ipympl at user request ipypml.cell_bound() (?) / by config) to at the end of a cell execution empty Gcf.figs and hold onto references someplace else. Leaving aside how to make plt.close('all') work again and how to drop the new ref when the figure is no longer visible, I think this would get all three things I think the users want! Giving up on plt.close('all') and adding ipypml.close('all') (or monkey patching plt.close 😱 🙀 ) and implementing something to watch for an extra message on the comms (or if there is already a "widget is gone" signal?) ipympl could implement this with no upstream changes.

If you could wait for a mpl release cycle, it is also worth thinking about adding a "selectable" state to the FigureCanvasManager such that mpl still keeps a ref to it (so everything stays alive) but it will never return it to plt.figure() or plt.figure(1). This way they could still be closed via plt.close('all').

While, this proposal would interfere with someone doing plt.plot() from many cells, if they grabbed a fig or ax ref they could safely use that from another cell (ipympl might also have to own a copy of the draw_if_idle post-cell hook to keep this working). If people want to make calls into pandas / seaborn re-use an already abandoned figure, I would expect something like plt.figure(fig) to (for that cell) re-activate an existing figure in another cell. That said, that sort of usage is already pretty incompatible with inline (that said, if the user has a reference to the Figure, the mpl side is all still alive...it should be possible to update the figure and on-draw update the output cell the figure is in?!)


I have also been thinking about if we can re-thing the way Gcf works all together (see https://github.com/tacaswell/mpl-gui), but have not been able to put any time into it in the last ~7 months :(

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

No branches or pull requests

3 participants