Skip to content

[ENH]: Allow override of contour level autoscaling #23778

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
brettjrob opened this issue Aug 30, 2022 · 6 comments · Fixed by #24912
Closed

[ENH]: Allow override of contour level autoscaling #23778

brettjrob opened this issue Aug 30, 2022 · 6 comments · Fixed by #24912
Milestone

Comments

@brettjrob
Copy link

Problem

In Matplotlib 3, when using a list of values for the levels argument in contour(), the list of values is overridden in the case that all requested levels fall outside the data range. While this may be desirable for casually browsing data when the user is unfamiliar with the data range, it causes serious problems for batch applications where the user legitimately intends to use their list of levels but does not know whether every input array will produce contours.

Example:

myplot = plt.contour( x , y , data , levels = [100] )
print( myplot.levels )

The above prints [0.0] when data is an array of values ranging from 0 to 50 (i.e., the requested contour level of 100 is outside the data range). As a result, the plot contains erroneous contours around near-zero values, presumably due to floating point precision.

This is a consequence of the change described here (https://matplotlib.org/stable/api/prev_api_changes/api_changes_3.0.0.html?highlight=contour%20levels):

Selection of contour levels is now the same for contour and contourf; previously, for contour, levels outside the data range were deleted. (Exception: if no contour levels are found within the data range, the levels attribute is replaced with a list holding only the minimum of the data range.)

Proposed solution

Add a kwarg to contour() that overrides the autoscaling behavior. When the kwarg is set, it would trigger a flag in _process_contour_level_args() (https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/contour.py):

if not self.filled:
            inside = (self.levels > self.zmin) & (self.levels < self.zmax)
            levels_in = self.levels[inside]
            if len(levels_in) == 0 and not(OVERRIDE_AUTOSCALE_FLAG):
                self.levels = [self.zmin]
                _api.warn_external(
                    "No contour levels were found within the data range.")
@jklymak
Copy link
Member

jklymak commented Aug 30, 2022

I agree that at the very least this should be an option. I think the original 3.0 "feature" to provide a contour that wasn't asked for is of pretty dubious value

@tacaswell
Copy link
Member

What ever we do to contour we need to do the same thing for contourf.

Looking at the code around

if not self.filled:
inside = (self.levels > self.zmin) & (self.levels < self.zmax)
levels_in = self.levels[inside]
if len(levels_in) == 0:
self.levels = [self.zmin]
_api.warn_external(
"No contour levels were found within the data range.")
my suspicion is that we did not add this feature for 3.0, but preserved the behavior from previous versions.

Looking at the blame, this originally came in via #8719 (mpl2.1) to fix #7486 which crashed rather than continuing on if there was no data in the levels.

It is probably worth revisiting given subsequent work on the Python side management around contouring and pulling contourpy out.

I'm nominally in favor of changing this warning to say "and we will plot to contours in the future" and changing the default behavior, but we can not trade the current behavior for bringing back a crash ;)

The user-side work around is to in your batch processing check the limits and do not call contour if nothing is in range.

attn @ianthomas23

@brettjrob
Copy link
Author

brettjrob commented Aug 30, 2022

I can confirm that the "unwanted" contours were not present in 2.X (2.0.2, at least). My code has been running on python 2.7 (matplotlib 2.0.2) for years without any unwanted contours, but they appeared today when testing my code with python 3.10 (matplotlib 3.5.3).

After struggling a bit, my attempted workaround was something close to what you said:

DO_CONTOURS = False
data_min = np.nanmin(data)
data_max = np.nanmax(data)

for level in levels:
    if level > data_min and level < data_max:
        DO_CONTOURS = True
        break

if DO_CONTOURS:
    plt.contour(...)

However, this workaround fails when the axes are only displaying a subset of the full array. For example, my levels may fall within the range of data, but I may be plotting a region where no contour will be needed. In that case, matplotlib once again overrides levels and I end up with a mess of unwanted contours.

@tacaswell
Copy link
Member

That does track as we added it is 2.1.

I do not think we are doing any clipping in x/y internally and are always considering the full data passed in so I assume you are doing the sub-selection? I would do something like

def contour_safe(data, levels):
    return np.any(data.max() > levels) & np.any(data.min() < levels)

if contour_safe(trimmed_data, levels):
    ax.contour(..., trimmed_data, levels, ...)

rather than trying to cache it.

If you have this through out your code base, it might be worth writing a helper like

def fixed_contour(...):
    if contour_safe(...):
        return plt.contour(...)

(but that does require absorbing the type instability). Hopefully you can find-and-replace to victory of plt.contour -> fixed_contour. This approach should also be back and forward compatible.


Did you have any other big surprises jumping from 2.0 -> 3.5 (effectively 6 feature releases!)?


https://www.youtube.com/watch?v=LTMguK-XJEo might be of interest as well....

@ianthomas23
Copy link
Member

I'll take a look. I can't offhand think of any reason why the contouring itself needs it to be this way, and I have a vague recollection that it is the interaction with the colorbar which makes it more complicated. But that is a recollection from n years ago where n > 4!

@ianthomas23
Copy link
Member

Looking into this, I think we are absolutely fine to remove the overriding of self.levels = [self.zmin]. contour handles this without any problem. It seems that both matplotlib and contourpy are more robust to strange inputs than they used to be.

We should probably add a check for no levels specified by the user for a contour call, as already happens for contourf.

There are problems with the use of colorbar with this fix, but the error is IndexError: index 0 is out of bounds for axis 0 with size 0 which is exactly the same as in issue #23817 and occurs regardless of overriding self.levels. I conclude that we need to make colorbar more robust to corner cases.

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

Successfully merging a pull request may close this issue.

5 participants