Skip to content

BUG: Contours with LogNorm #19856

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

Open
neutrinoceros opened this issue Apr 3, 2021 · 15 comments
Open

BUG: Contours with LogNorm #19856

neutrinoceros opened this issue Apr 3, 2021 · 15 comments

Comments

@neutrinoceros
Copy link
Contributor

neutrinoceros commented Apr 3, 2021

Bug report

Bug summary
Contour plots don't interact nicely with matplotlib.colors.LogNorm.
Likely related to #19748

Code for reproduction

import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm
import numpy as np

# BUG: setting levels as a scalar has no effect and no warning or error is printed
fig, ax = plt.subplots()
im = ax.contourf(x, y, data, norm=LogNorm(), levels=4)
fig.colorbar(im, ax=ax)
plt.show()

mpl_logcontours_bug1

# kinda buggy too: setting levels as an array does have some of the desired effects
# but ticks and ticklabels are incorrect
fig, ax = plt.subplots()
levels = np.logspace(np.log10(data.min()), np.log10(data.max()), 5)
im = ax.contourf(x, y, data, norm=LogNorm(), levels=levels)
fig.colorbar(im, ax=ax)
plt.show()

mpl_logcontours_bug2

Expected outcome

The experience should be comparable with what happens when the norm isn't specified: the levels kwarg should be usable in both cases.
I note that the output from the first snippet is consistent with the case where neither levels or norm are specified, and is correct in that case, so it's definetely not completely broken and I hope the fix is somewhat straightforward.

I'm happy to take a look at the source and see if I can come up with an easy patch, but I'm not sure when I'll have time to dive in there myself. Any piece advice from maintainers or contributors is more than welcome !

Matplotlib version

  • Operating system: MacOS
  • Matplotlib version (import matplotlib; print(matplotlib.__version__)): 3.4.1
  • Matplotlib backend (print(matplotlib.get_backend())): module://ipykernel.pylab.backend_inline
  • Python version: 3.9.1
  • Jupyter version (if applicable): NA
  • Other libraries:

I installed matplotlib using pip.

@jklymak
Copy link
Member

jklymak commented Apr 3, 2021

I'm not sure what you feel the bug is. It would be better if you provided some data. Thanks!

@jklymak
Copy link
Member

jklymak commented Apr 3, 2021

Oh I see you specified the bug in your code.

I believe we use a LogLocator to determine the levels if you use a log norm. It's possible/likely this doesn't interact well with the nlevels argument. But it does provide equal decade levels, which is probably worth it.

I am still not clear what you feel is wrong with the second plot because you didn't specify what levels you set.

@neutrinoceros
Copy link
Contributor Author

I am still not clear what you feel is wrong with the second plot because you didn't specify what levels you set.

the levels are defined as an array, supposedly I'm defining 4 (= n-1, with n=5) of them

levels = np.logspace(np.log10(data.min()), np.log10(data.max()), 5)

I think the plot itself is what I want, but the colorbar is broken in that I only get on tick label, and the minor ticks don't make any sense to me.

@jklymak
Copy link
Member

jklymak commented Apr 3, 2021

What do you think should happen? I guess the other labels not being labeled is bad, but otherwise the ticking seems ok to me. You have put five log-spaced levels with an arbitrary starting point. The minor ticks are just the normal minor ticks.

Note that much of this will change in 3.5 with the colorbar axis overhaul; colorbar axes will behave like normal axes as far as possible.

@neutrinoceros
Copy link
Contributor Author

What do you think should happen? I guess the other labels not being labeled is bad, but otherwise the ticking seems ok to me.

The ticking itself seems off to me because the pattern seen in minor ticks is offset from one level to an other. I'm not sure I'm making myself clear (second language, sorry), so I'll try an alternative phrasing as well:
In logscale, I expect the visual interval between two consecutive minor ticks to be smaller and smaller (in a given decade, or more generally between two major ticks). It's obviously not the case in the first (purple, bottom) level in my second example.

Note that much of this will change in 3.5 with the colorbar axis overhaul; colorbar axes will behave like normal axes as far as possible.

Sounds like fixing this for the 3.4 branch only might be a waste of time then. I'm curious, what's the status of this overhaul ?

@jklymak
Copy link
Member

jklymak commented Apr 3, 2021

The minor ticks are ticking out tenths of decades. Your major ticks are ticking out arbitrary spacing in log space. They are definitely not going to line up.

@neutrinoceros
Copy link
Contributor Author

Oh right, I guess I was expecting the minor ticks to magically retro-engineer my arbitrary spacing and line up somehow (not even sure how actually, there's no obvious generalization of decades that would work with arbitrary spacing)...
I didn't realize how deeply rooted the base 10 log was. Thanks for this !

What I actually want is to have more than one color per decade, maybe there's just something I'm missing here ?

@jklymak
Copy link
Member

jklymak commented Apr 3, 2021

I would just manually set the level, but I'd start with integer decades for the min and max.

@neutrinoceros
Copy link
Contributor Author

neutrinoceros commented Apr 4, 2021

So I tried to do just that and, for the specific example I used in the report, I find that this works as intended

levels = np.concatenate(
    [
        10**n * np.arange(1, 10, dtype="float64")
        for n in range(-2, 7)
    ]
)

mpl_logcontours_bug3

Thought this doesn't (labels in the colorbar are still missing)

levels = np.concatenate(
    [
        10**n * np.arange(1, 10, dtype="float64")
        for n in range(-2, 6) # <--- changed the upper boundary here
    ]
)

mpl_logcontours_bug4

Note that in both cases I'm reaching over the actual maximal value in the data (which is a little under 2e4) by at least one full decade, and I apparently need to add another one to get a sensible result, leading unused colors at the end of the cmap.

@jklymak
Copy link
Member

jklymak commented Apr 5, 2021

Please provide a self-contained runnable script. Thanks!

@neutrinoceros
Copy link
Contributor Author

Here you go !
(I realised while compiling this that I didn't include the data generation in the initial report, sorry !)

import matplotlib.pyplot as plt
import matplotlib.colors import LogNorm
import numpy as np

x, y = np.mgrid[1:10:0.1, 1:10:0.1]
data = np.abs(np.sin(x)*np.exp(y))


# CASE 0,a: leave norm unspecified, pass an int value to the `levels` keyword argument
# RESULT: works fine
fig, ax = plt.subplots()
im = ax.contourf(x, y, data, levels=4)
fig.colorbar(im, ax=ax)
plt.savefig("/tmp/mpl_logcontours_bug0a.png")


# CASE 0,b: specify a log norm, leave levels unspecified
# RESULT: works fine, but only one level is used per decade
fig, ax = plt.subplots()
im = ax.contourf(x, y, data, norm=LogNorm())
fig.colorbar(im, ax=ax)
plt.savefig("/tmp/mpl_logcontours_bug0b.png")


# CASE 1: pass an int value to the `levels` keyword argument
# RESULT: the argument is ignored
fig, ax = plt.subplots()
im = ax.contourf(x, y, data, norm=LogNorm(), levels=4)
fig.colorbar(im, ax=ax)
plt.savefig("/tmp/mpl_logcontours_bug1.png")


# CASE 2: construct arbitrary levels that don't match whole decades
# RESULT: major ticks' labels are not displayed, except for the second one 
fig, ax = plt.subplots()
levels = np.logspace(np.log10(data.min()), np.log10(data.max()), 5)
im = ax.contourf(x, y, data, norm=LogNorm(), levels=levels)
fig.colorbar(im, ax=ax)
plt.savefig("/tmp/mpl_logcontours_bug2.png")


# CASE 3: construct arbitrary levels, matching whole decades
# RESULT: this one works fine, but the max value is more than one decade above the
# actual max value in the data, so the extreme colors in the colormap are not used.
levels = np.concatenate(
    [
        10**n * np.arange(1, 10, dtype="float64")
        for n in range(-2, 7)
    ]
)

fig, ax = plt.subplots()
im = ax.contourf(x, y, data, norm=LogNorm(), levels=levels)
fig.colorbar(im, ax=ax)
plt.savefig("/tmp/mpl_logcontours_bug3.png")


# CASE 4: same as case 3, but max level is closer to global max in the data
# RESULT: colorbar labels are missing (except for the min value, 1e-2)
levels = np.concatenate(
    [
        10**n * np.arange(1, 10, dtype="float64")
        for n in range(-2, 6) # <--- changed the upper boundary here
    ]
)

fig, ax = plt.subplots()
im = ax.contourf(x, y, data, norm=LogNorm(), levels=levels)
fig.colorbar(im, ax=ax)
plt.savefig("/tmp/mpl_logcontours_bug4.png")

@jklymak
Copy link
Member

jklymak commented Apr 6, 2021

Thanks, thats super helpful.

You are kind of reporting a couple of issues here:

  1. levels=4 doesn't work for LogNorm. That seems a bug to me. If you do
im = ax.contourf(x, y, data, norm=LogNorm(), locator=mticker.LogLocator(numticks=4))

it works fine, so I wonder if in def _autolev(self, N): we should be calling the LogLocator with numticks=N? Right now it is called with no arguments.

  1. the labelling doesn't work. I think that will be closed by Enh better colorbar axes #18900, though I note that Enh better colorbar axes #18900 doesn't properly make the formatter scientific, so that seems a bug.

@jklymak
Copy link
Member

jklymak commented Apr 6, 2021

I'll make 1 as a good first issue, though it may have some back-combat issues that will need to be tested.

@jklymak jklymak added the Good first issue Open a pull request against these issues if there are no active ones! label Apr 6, 2021
@jklymak jklymak added this to the v3.5.0 milestone Apr 6, 2021
@hp77-creator
Copy link

@jklymak Shall I work on the 1st? Seems easy enough for me to begin!

@oscargus
Copy link
Member

oscargus commented Jan 19, 2022

I didn't realize how deeply rooted the base 10 log was.

You can provide an arbitrary base to LogFormatter (and subclasses):

def __init__(self, base=10.0, labelOnlyBase=False,
minor_thresholds=None,
linthresh=None):

This will (probably) also show the ticks, which seems to be removed by:

# only label the decades
fx = math.log(x) / math.log(b)
is_x_decade = is_close_to_int(fx)

and
if self.labelOnlyBase and not is_x_decade:
return ''

@QuLogic QuLogic modified the milestones: v3.6.0, v3.7.0 Aug 24, 2022
@ksunden ksunden modified the milestones: v3.7.0, v3.7.1 Feb 14, 2023
@QuLogic QuLogic removed this from the v3.7.1 milestone Mar 4, 2023
@QuLogic QuLogic added this to the v3.7.2 milestone Mar 4, 2023
@QuLogic QuLogic modified the milestones: v3.7.2, v3.7.3 Jul 5, 2023
@QuLogic QuLogic modified the milestones: v3.7.3, v3.8.0 Sep 9, 2023
@ksunden ksunden removed this from the v3.8.0 milestone Sep 15, 2023
@dstansby dstansby removed the Good first issue Open a pull request against these issues if there are no active ones! label Jan 14, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
7 participants