-
Notifications
You must be signed in to change notification settings - Fork 116
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
[CR] matplotlib modernization #380
Conversation
…n old builds before moving forward
Status update on this PR (still working at it when I can):
|
This comment summarizes some thoughts on the display module design after I sank way more time and thought into it yesterday than I really should have. TLDR: I'm becoming convinced that we need to maintain our own independent state objects for managing multiple calls to display on the same axes object. I also think I have a viable way to do this, and it's only a little ugly. Problem 1: managing styles and cyclesMatplotlib axes are stateful in how they allocate styling for (some) artists. Calling This is behavior that I believe we should retain for most annotation displays, with the exception of Originally, I'd coded our display module to hijack the appropriate cyclers from the axes object, but this no longer works due to internal changes in the axes structure. We really should never have been stealing these cyclers in the first place, alas. Presently, this PR replaces hijacking by pickpocketing: instead of accessing the cyclers directly, it creates temporary artists of an appropriate type, borrows their styling parameters, and then removes them from the axes afterward. This is dirty, but it works, and technically it plays by the rules of the API so it's unlikely to break. Still, I think we might want to replace it by our own state management due to problem 2... Problem 2: managing tick positions and labelsLabeled interval plots map the label set onto the vertical axis, with tick locations and labels set up accordingly on For example, uses this subsequent plot idea to show where two annotations agree or disagree (three subplots for three different tracks). Of course, this relies on detecting whether labeled interval plots have already been drawn on the axes object, gathering the existing label set, etc. As originally implemented, this was done by probing Now, in principle, this is exactly what matplotlib categorical axes are supposed to do for us. However, the API for that is a bit underpowered for how we would need to use it (eg expanding an existing vocabulary set, doing math on positioning information, etc...). As a result, I think we need a more explicit state management scheme. Potential solutionsThere are two ways I could imagine going about managing state: burying it in the Option 1: axes._mir_evalThis is in some ways the simplest thing to do: stuff whatever information we need inside a container of some kind (dict?) and add it as an attribute to the axes object. I expect this would work, but it's phenomenally bad style, and may cause headaches down the road e.g. if axes objects are serialized and deserialized, having to go through a marshall that rejects unknown attributes. (It could happen..) I don't think we're likely to bump into namespace conflicts with this. Anyway, I asked about this idea on the matplotlib discourse, and so far it's 🦗... Option 2: mir_eval.display.axes_stateInstead of stuffing our state dict into the axes object, we can maintain our own module-level mapping of This preserves cleanliness of the axes object, so there's no question of us playing by the API rules. It's extremely unlikely to break on us down the road, unless axes objects become unhashable. I see two downsides here:
(2) I think can be mitigated by using a (1) is trickier, and subject to the same problems as option 1 re: serialization. For the record, I'm okay with saying that cross-session support is out of scope (it feels very unmatplotlibish anyway). |
... nevermind, weakrefdictionary isn't going to work on axes objects. |
Thanks for this wildly in-depth sleuthing. How bad will the memory issues get, really? My guess is that it would only affect a very small percentage of uses, and probably if it did cause an issue, the user should just implement things differently/start a new session... but maybe that's wrong. Could we do something where whenever the mapping is accessed, we do a quick loop through the existing handles to check for and remove references to closed figures, or something? This would result in a small amount of computational overhead, I think, but I would guess it was quite small... |
Ehhh, most of the time it would be fine, but in some cases it could be quite bad. For example, I sometimes find myself in a long-running jupyter session, doing tons of interactive plots (paging through model outputs, etc). Without a proper handle on garbage collection, this could get quite bad, especially for the more intensive viz like multipitch. Starting a new session isn't a great option there, IMO, because these things sneak up silently in the background.
It's tricky because we'd need to access matplotlib global state to find all open handles, and I don't think that's very well supported. Additionally, since axes handles are not hashable, we'd have to go through some kind of proxy indexer anyway, and that has a host of annoying problems as well. TLDR: I don't think there's any kind of elegant solution right now. Sandboxing our own data in the axes object might be short-term viable, but I'd like to get some input from a matplotlib export (dev?) on how bad this could go before i start thinking about it in depth. |
Back on this for a bit today, came across this issue, which if resolved, would almost cover half of what we need here: matplotlib/matplotlib#19479 (making cycler's accessible / sharable across axes) It wouldn't help with the categorical axis tracking problem though. Either way, it doesn't seem likely to be resolved soon, so I don't think we can wait for it to get this PR unstuck and 0.8 out the door. |
OOOoookay, I think I have a clumsy prototype with weakkeydict that actually works. It turns out that it does actually work fine with axes objects directly, but the problem is that triggering a purge requires us to locate (and remove) all existing handles to an axes object. This is exactly as it should be, and the only challenge is actually identifying the refs. Here's what works: import matplotlib.pyplot as plt
import weakref
import gc
D = weakref.WeakKeyDict()
fig, ax = plt.subplots()
D[ax] = "some stuff"
print("Before deletion: ", dict(D))
... # do stuff
# Trigger a cleanup
fig.clear()
del ax
gc.collect()
print("After deletion: ", dict(D)) The result is now as we should hope:
It turns out that both steps are necessary. (GC collection is necessary only insofar as we shouldn't expect cleanup to be immediate, and this forces things to update.) It's a bit cumbersome to expect users to explicitly clear figures, but this at least verifies that the approach I have in mind is possible. We might be able to use weakref's |
Thanks for your work on this. The new version seems reasonable, but I do prefer the truncated y range - the new version isn't actually covering the full y range, is it? MIDI would be 0->127, unless I'm misunderstanding. |
Yeah, good catch. This is from Removing that line keeps the plot on the full midi range. I think the reason it's now stretching down to the bottom is from the phantom bar plot that I'm currently using to hijack the styling - there doesn't seem to be a clean way to keep it out of the auto limit calculation. (The phantom bar plot lives at Let's revisit this after I rewrite the styling code to use fresh cyclers and we can remove the hijacking kludge. |
Codecov ReportAttention: Patch coverage is
❗ Your organization needs to install the Codecov GitHub app to enable full functionality. Additional details and impacted files@@ Coverage Diff @@
## main #380 +/- ##
==========================================
+ Coverage 88.32% 95.67% +7.34%
==========================================
Files 19 19
Lines 2878 2936 +58
==========================================
+ Hits 2542 2809 +267
+ Misses 336 127 -209
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. |
Got this part done, and it's definitely much cleaner: no more temp artists, and we replicate the kind of logic that matplotlib uses under the hood for managing property cyclers. Users can also specify a custom cycler, eg: import cycler
prop_cycler = cycler.cycler(color=["#ff00ff", "#ffff00", "#00ffff"])
mir_eval.display.separation([x0, x1, x2], fs=fs, prop_cycle=prop_cycler)
plt.legend() The default behavior respects whatever your matplotlib theme is, so you can also do: with mpl.style.context('ggplot'):
mir_eval.display.separation([x0, x1, x2], fs=fs)
plt.legend() I was able to remove almost all code that relates to fudging axes margins around our artists, which really should not be our business anyway. This will break backward compatibility, but I'm fine with it. The lingering exception here is |
So nice! |
Alright, I'm sticking a fork in this. 😅 All deprecated functionality is purged. Property cyclers are used everywhere, limiting and scaling behaviors are all compliant with matplotlib idioms. Generated figures should, I believe, be as "unscented" as possible now, at the cost of a bit of backward compatibility. There are probably some untested code paths here, but I'll wait til the dust settles to start digging into that. At this point, it'd be worth looking through the replacement images in the PR to see how things look by default now. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if this is just due to plot ranges, but there's now a little blip on "z" at time zero. Also a nit but it's a bit odd that the upper plot has some whitespace at the top.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, the whitespace is due to matplotlib margin handling, and it's on all edges. This is configurable at the rcParams level or by calling ax.margins()
, but it's not something that artist constructors should be styling directly.
The blip on "z" is correct if you look at the source annotations: https://github.com/craffel/mir_eval/blob/main/tests/data/hierarchy/ref00.lab and https://github.com/craffel/mir_eval/blob/main/tests/data/hierarchy/ref01.lab - they were always in the plots, but the old styling made them difficult to see due to axis spines.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same comment on the whitespace on the top here, so is a common feature
Thanks for this epic effort. I wonder how many people are aware of this incredible micro-library. I left some comments re: axis scaling - it leaves some funny whitespace only on the top in some cases, and I think it also revealed something that is not a bug but was just invisible before. I'm ok to ignore these if you're tired of chasing demons. |
Heh. The "some cases" here is doing a lot of work 😁 What's actually happening is that margins are added for any artists that live entirely in data space - this is a matplotlib thing. For some artists, eg events or segments, they actually live in a blended coordinate space where the horizontal extent is data and the vertical is axis; think "axvspan" or "axvline" and you've got the idea. It does make for a somewhat incongruous set of figures, but I think the behavior here is correct. It makes it possible, for example, to easily overlap events or segment plots on another plot where the vertical extent has some other meaning (eg a spectrogram). This kind of use case doesn't make sense for labeled intervals, or if it does, it should be using twinned axes because we need the vertical dimension for label data.
At least 2? 🤷 It now appears that CI is going to be a real pain. From a quick glance of the logs, here's what I'm seeing:
This is the only failure, and I suspect it might have something to do with how clipping paths work in annotation objects with transformed bboxes. May need to boost the minimal mpl to get this working, but I'll dive through the changelogs to figure that out.
OSX is also giving separation heisenbugs again 🤦 ... |
Confirmed locally: mpl 3.3 does not clip annotations properly with tranformed bboxes. I can live with this, but bumping to a more recent matplotlib would mean dropping python 3.7 support. Bisecting out a minimal mpl version that replicates current behavior is proving to be challening... the figure as generated on mpl 3.3 looks "okay" though, so I think the failure here is not a deal breaker. Perhaps we just allow this test to fail on the minimal environment with an xfail marker. |
It looks like the failing python 3.12 builds are likely due to an old version of freetype (2.6.1) ; the older python versions (3.11, 10) have the more recent freetype 2.12.1. This kind of thing has caused headaches over in librosa land in the past, where we hacked around it as follows: @craffel if you're cool with this, I can hack it in here and we can get this show on the road. |
Implemented the freetype workaround and the only remaining failure here is in source separation (osx) and is unrelated. |
Works for me! Thanks so much again! |
This PR will fix #364
This is still in-progress, but the basic idea is to use temporary artists to probe the axes style cycler indirectly, rather than accessing private / undocumented axes attributes.
So far, all the patch-based artists (segments etc) and spectrogram displays seem to work on my local environment, as well as
events
. I haven't started on pitch yet, as these need a rewrite IMO.A couple of things to think about:
BrokenBarHCollection
. We could rewrite this asPolyCollection
, but will need to explicitly construct our own polies.freq_to_voicing
to handle nans, as was done in Pitch contour sonification supports nans #379. To prevent some unintended consequences, this behavior might need to be disabled by default.