Skip to content

SymLog scale has too few ticks #17402

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
psarka opened this issue May 13, 2020 · 7 comments · May be fixed by #27310
Open

SymLog scale has too few ticks #17402

psarka opened this issue May 13, 2020 · 7 comments · May be fixed by #27310
Labels
Difficulty: Medium https://matplotlib.org/devdocs/devel/contribute.html#good-first-issues Good first issue Open a pull request against these issues if there are no active ones!

Comments

@psarka
Copy link

psarka commented May 13, 2020

Bug report

Bug summary

SymLog scale is exactly what I need to display various reinforcement learning related metrics as they can be both positive and negative, interesting mainly in the -10 to 10 range, and frequently explode to very large values only to come back "to sanity" later.

Unfortunately, if things go well and metrics don't go insane, current SymLog graphs have too few ticks and is not possible to understand what am I looking at. Here is a synthetic example that illustrates the issue quite well:

Code for reproduction

import matplotlib.pyplot as plt
import numpy as np

st = np.random.standard_cauchy(size=25)
en = np.random.standard_cauchy(size=25)

fig, axen = plt.subplots(5, 5, figsize=(20, 20))
fig.tight_layout()
for ax, s, e in zip(np.ravel(axen), st, en):
    ax.plot(np.linspace(s, e, 5))
    ax.set_yscale('symlog')

Actual outcome

image

Expected outcome

It would be awesome to get more ticks and nicer labels!

Matplotlib version

matplotlib 3.1.1

I think the versions of all the remaining stuff are irrelevant, as this is an issue in the SymmetricalLogLocator class, which is built on an assumption that does not hold in my case:

        # b) has a tick at 0 and only 0 (we assume t is a small
        # number, and the linear segment is just an implementation
        # detail and not interesting.)
@psarka
Copy link
Author

psarka commented May 13, 2020

I imagine this will be quite tricky to fix, so in case someone is in urgent need of extra ticks, here is a quick and dirty code that works quite well for me:

class MajorSymLogLocator(SymmetricalLogLocator):

    def __init__(self):
        super().__init__(base=10., linthresh=1.)

    @staticmethod
    def orders_magnitude(vmin, vmax):

        max_size = np.log10(max(abs(vmax), 1))
        min_size = np.log10(max(abs(vmin), 1))

        if vmax > 1 and vmin > 1:
            return max_size - min_size
        elif vmax < -1 and vmin < -1:
            return min_size - max_size
        else:
            return max(min_size, max_size)

    def tick_values(self, vmin, vmax):

        if vmax < vmin:
            vmin, vmax = vmax, vmin

        orders_magnitude = self.orders_magnitude(vmin, vmax)

        if orders_magnitude <= 1:
            spread = vmax - vmin
            exp = np.floor(np.log10(spread))
            rest = spread * 10 ** (-exp)

            stride = 10 ** exp * (0.25 if rest < 2. else
                                  0.5 if rest < 4 else
                                  1. if rest < 6 else
                                  2.)

            vmin = np.floor(vmin / stride) * stride
            return np.arange(vmin, vmax, stride)

        if orders_magnitude <= 2:
            pos_a, pos_b = np.floor(np.log10(max(vmin, 1))), np.ceil(np.log10(max(vmax, 1)))
            positive_powers = 10 ** np.linspace(pos_a, pos_b, int(pos_b - pos_a) + 1)
            positive = np.ravel(np.outer(positive_powers, [1., 5.]))

            linear = np.array([0.]) if vmin < 1 and vmax > -1 else np.array([])

            neg_a, neg_b = np.floor(np.log10(-min(vmin, -1))), np.ceil(np.log10(-min(vmax, -1)))
            negative_powers = - 10 ** np.linspace(neg_b, neg_a, int(neg_a - neg_b) + 1)[::-1]
            negative = np.ravel(np.outer(negative_powers, [1., 5.]))

            return np.concatenate([negative, linear, positive])

        else:

            pos_a, pos_b = np.floor(np.log10(max(vmin, 1))), np.ceil(np.log10(max(vmax, 1)))
            positive = 10 ** np.linspace(pos_a, pos_b, int(pos_b - pos_a) + 1)

            linear = np.array([0.]) if vmin < 1 and vmax > -1 else np.array([])

            neg_a, neg_b = np.floor(np.log10(-min(vmin, -1))), np.ceil(np.log10(-min(vmax, -1)))
            negative = - 10 ** np.linspace(neg_b, neg_a, int(neg_a - neg_b) + 1)[::-1]

            return np.concatenate([negative, linear, positive])

def symlogfmt(x, pos):
    return f'{x:.6f}'.rstrip('0')

And then when you do the plot, add this:

ax.yaxis.set_major_locator(MajorSymLogLocator())
ax.yaxis.set_major_formatter(FuncFormatter(symlogfmt))

This solution is still broken in some cases, hardcoded to base 10 and threshold 1 (doesn't it make more sense than 2 as a default?), but works better than default:

image

@tacaswell tacaswell added this to the v3.4.0 milestone May 14, 2020
@tacaswell
Copy link
Member

This behaves slightly better (I think? it is hard to tell with the randomness) on master.

There was recently a bunch of work with the log formatters and locators to make sure we always had a reasonable number of ticks on the screen, the fix here would likely be to see if any of those code patterns / logic can be adapted.

@tacaswell tacaswell added Difficulty: Medium https://matplotlib.org/devdocs/devel/contribute.html#good-first-issues Good first issue Open a pull request against these issues if there are no active ones! labels May 14, 2020
@tacaswell
Copy link
Member

I labeled this a "good first issue" because the changes required are likely to be isolated to the locator / formatter for symlog, but medium difficulty as it will involve reaching a consensus on what the "right" behavior is.

@Jaroza727
Copy link
Contributor

Doesn't look like anyone is working on this, so I'd like to take this up.

@Schmutsi
Copy link

Schmutsi commented Nov 30, 2020

Hello,

In fact, we are working on this issue for few weeks. And we assume having fixed the issue. Here is the code that we have changed (in the class SymetricalLogLocator() - function tick_values() ) illustrated by an example with 16 graphs. We just had a condition at the end of the function.

Thus, we are going to do a pull request.

def tick_values(self, vmin, vmax):
        base = self._base
        linthresh = self._linthresh

        if vmax < vmin:
            vmin, vmax = vmax, vmin

        # The domain is divided into three sections, only some of
        # which may actually be present.
        #
        # <======== -t ==0== t ========>
        # aaaaaaaaa    bbbbb   ccccccccc
        #
        # a) and c) will have ticks at integral log positions.  The
        # number of ticks needs to be reduced if there are more
        # than self.numticks of them.
        #
        # b) has a tick at 0 and only 0 (we assume t is a small
        # number, and the linear segment is just an implementation
        # detail and not interesting.)
        #
        # We could also add ticks at t, but that seems to usually be
        # uninteresting.
        #
        # "simple" mode is when the range falls entirely within (-t,
        # t) -- it should just display (vmin, 0, vmax)
        if -linthresh < vmin < vmax < linthresh:
            # only the linear range is present
            return [vmin, vmax]

        # Lower log range is present
        has_a = (vmin < -linthresh)
        # Upper log range is present
        has_c = (vmax > linthresh)

        # Check if linear range is present
        has_b = (has_a and vmax > -linthresh) or (has_c and vmin < linthresh)

        def get_log_range(lo, hi):
            lo = np.floor(np.log(lo) / np.log(base))
            hi = np.ceil(np.log(hi) / np.log(base))
            return lo, hi

        # Calculate all the ranges, so we can determine striding
        a_lo, a_hi = (0, 0)
        if has_a:
            a_upper_lim = min(-linthresh, vmax)
            a_lo, a_hi = get_log_range(abs(a_upper_lim), abs(vmin) + 1)

        c_lo, c_hi = (0, 0)
        if has_c:
            c_lower_lim = max(linthresh, vmin)
            c_lo, c_hi = get_log_range(c_lower_lim, vmax + 1)

        # Calculate the total number of integer exponents in a and c ranges
        total_ticks = (a_hi - a_lo) + (c_hi - c_lo)
        if has_b:
            total_ticks += 1
        stride = max(total_ticks // (self.numticks - 1), 1)

        decades = []
        if has_a:
            decades.extend(-1 * (base ** (np.arange(a_lo, a_hi,
                                                    stride)[::-1])))

        if has_b:
            decades.append(0.0)

        if has_c:
            decades.extend(base ** (np.arange(c_lo, c_hi, stride)))

        # Add the subticks if requested
        if self._subs is None:
            subs = np.arange(2.0, base)
        else:
            subs = np.asarray(self._subs)

        if len(subs) > 1 or subs[0] != 1.0:
            ticklocs = []
            for decade in decades:
                if decade == 0:
                    ticklocs.append(decade)
                else:
                    ticklocs.extend(subs * decade)
        else:
            ticklocs = decades
            # if there is not enough ticks to show on the graph
            if len(ticklocs) <= 3:
                ticklocs.extend(subs)
                subs= np.linspace(vmin, vmax, 7) # 7 can be changed, it will just change the number of value displayed
            

        return self.raise_if_exceeds(np.array(ticklocs))

proofIllustrated

@QuLogic QuLogic modified the milestones: v3.4.0, v3.5.0 Jan 27, 2021
@QuLogic QuLogic modified the milestones: v3.5.0, v3.6.0 Sep 25, 2021
@AxelMKlein
Copy link

Hello,

May I ask when this will be available?

Sorry for my lack of knowledge, can I make this somehow work locally in my project? If so, how?

@stanleyjs
Copy link
Contributor

@AxelMKlein This seems to be dead. Maybe @QuLogic has it in mind for v3.6. In the meantime, I think you can take @Schmutsi 's code and set your ticks using it. Something like ax.set_yticks(tick_values(vmin,vmax)) appears to be the trick to set the yticks. Or follow @psarka 's answer.

@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 modified the milestones: v3.7.1, v3.7.2 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
@schtandard schtandard linked a pull request Nov 12, 2023 that will close this issue
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Difficulty: Medium https://matplotlib.org/devdocs/devel/contribute.html#good-first-issues Good first issue Open a pull request against these issues if there are no active ones!
Projects
None yet
Development

Successfully merging a pull request may close this issue.

8 participants