diff --git a/doc/users/next_whats_new/logit_scale_stuff.rst b/doc/users/next_whats_new/logit_scale_stuff.rst new file mode 100644 index 000000000000..aeb44a2e4c15 --- /dev/null +++ b/doc/users/next_whats_new/logit_scale_stuff.rst @@ -0,0 +1,13 @@ +Improvements in Logit scale ticker and formatter +------------------------------------------------ + +Introduced in version 1.5, the logit scale didn't have an appropriate ticker and +formatter. Previously, the location of ticks was not zoom dependent, too many labels +were displayed causing overlapping which broke readability, and label formatting +did not adapt to precision. + +Starting from this version, the logit locator has nearly the same behavior as the +locator for the log scale or the linear +scale, depending on used zoom. The number of ticks is controlled. Some minor +labels are displayed adaptively as sublabels in log scale. Formatting is adapted +for probabilities and the precision is adapts to the scale. diff --git a/examples/scales/logit_demo.py b/examples/scales/logit_demo.py new file mode 100644 index 000000000000..a79060f5ab47 --- /dev/null +++ b/examples/scales/logit_demo.py @@ -0,0 +1,61 @@ +""" +================ +Logit Demo +================ + +Examples of plots with logit axes. +""" + +import numpy as np +import matplotlib.pyplot as plt + +xmax = 10 +x = np.linspace(-xmax, xmax, 10000) +cdf_norm = np.array([np.math.erf(w / np.sqrt(2)) / 2 + 1 / 2 for w in x]) +cdf_laplacian = np.array( + [1 / 2 * np.exp(w) if w < 0 else 1 - 1 / 2 * np.exp(-w) for w in x] +) +cdf_cauchy = 1 / np.pi * np.arctan(x) + 1 / 2 + +fig, axs = plt.subplots(nrows=3, ncols=2, figsize=(6.4, 8.5)) + +# Common part, for the example, we will do the same plots on all graphs +for i in range(3): + for j in range(2): + axs[i, j].plot(x, cdf_norm, label=r"$\mathcal{N}$") + axs[i, j].plot(x, cdf_laplacian, label=r"$\mathcal{L}$") + axs[i, j].plot(x, cdf_cauchy, label="Cauchy") + axs[i, j].legend() + axs[i, j].grid() + +# First line, logitscale, with standard notation +axs[0, 0].set(title="logit scale") +axs[0, 0].set_yscale("logit") +axs[0, 0].set_ylim(1e-5, 1 - 1e-5) + +axs[0, 1].set(title="logit scale") +axs[0, 1].set_yscale("logit") +axs[0, 1].set_xlim(0, xmax) +axs[0, 1].set_ylim(0.8, 1 - 5e-3) + +# Second line, logitscale, with survival notation (with `use_overline`), and +# other format display 1/2 +axs[1, 0].set(title="logit scale") +axs[1, 0].set_yscale("logit", one_half="1/2", use_overline=True) +axs[1, 0].set_ylim(1e-5, 1 - 1e-5) + +axs[1, 1].set(title="logit scale") +axs[1, 1].set_yscale("logit", one_half="1/2", use_overline=True) +axs[1, 1].set_xlim(0, xmax) +axs[1, 1].set_ylim(0.8, 1 - 5e-3) + +# Third line, linear scale +axs[2, 0].set(title="linear scale") +axs[2, 0].set_ylim(0, 1) + +axs[2, 1].set(title="linear scale") +axs[2, 1].set_xlim(0, xmax) +axs[2, 1].set_ylim(0.8, 1) + +fig.tight_layout() +plt.show() diff --git a/examples/scales/scales.py b/examples/scales/scales.py index 50094aa670dd..fc3b3a7daf41 100644 --- a/examples/scales/scales.py +++ b/examples/scales/scales.py @@ -55,9 +55,6 @@ ax.set_yscale('logit') ax.set_title('logit') ax.grid(True) -# Format the minor tick labels of the y-axis into empty strings with -# `NullFormatter`, to avoid cumbering the axis with too many labels. -ax.yaxis.set_minor_formatter(NullFormatter()) # Function x**(1/2) diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index 4df3f2af7a4c..89707d1609ec 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -654,8 +654,15 @@ class LogitScale(ScaleBase): """ name = 'logit' - def __init__(self, axis, nonpos='mask'): - """ + def __init__( + self, + axis, + nonpos='mask', + *, + one_half=r"\frac{1}{2}", + use_overline=False, + ): + r""" Parameters ---------- axis : `matplotlib.axis.Axis` @@ -664,8 +671,15 @@ def __init__(self, axis, nonpos='mask'): Determines the behavior for values beyond the open interval ]0, 1[. They can either be masked as invalid, or clipped to a number very close to 0 or 1. + use_overline : bool, default: False + Indicate the usage of survival notation (\overline{x}) in place of + standard notation (1-x) for probability close to one. + one_half : str, default: r"\frac{1}{2}" + The string used for ticks formatter to represent 1/2. """ self._transform = LogitTransform(nonpos) + self._use_overline = use_overline + self._one_half = one_half def get_transform(self): """Return the `.LogitTransform` associated with this scale.""" @@ -675,9 +689,20 @@ def set_default_locators_and_formatters(self, axis): # docstring inherited # ..., 0.01, 0.1, 0.5, 0.9, 0.99, ... axis.set_major_locator(LogitLocator()) - axis.set_major_formatter(LogitFormatter()) + axis.set_major_formatter( + LogitFormatter( + one_half=self._one_half, + use_overline=self._use_overline + ) + ) axis.set_minor_locator(LogitLocator(minor=True)) - axis.set_minor_formatter(LogitFormatter()) + axis.set_minor_formatter( + LogitFormatter( + minor=True, + one_half=self._one_half, + use_overline=self._use_overline + ) + ) def limit_range_for_scale(self, vmin, vmax, minpos): """ diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index 6408d231159d..191afa0b9950 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -1,4 +1,5 @@ import warnings +import re import numpy as np from numpy.testing import assert_almost_equal, assert_array_equal @@ -238,15 +239,132 @@ def test_set_params(self): loc.set_params() +class _LogitHelper: + @staticmethod + def isclose(x, y): + if x >= 1 or x <= 0 or y >= 1 or y <= 0: + return False + return np.isclose(-np.log(1/x-1), -np.log(1/y-1)) + + @staticmethod + def assert_almost_equal(x, y): + ax = np.array(x) + ay = np.array(y) + assert np.all(ax > 0) and np.all(ax < 1) + assert np.all(ay > 0) and np.all(ay < 1) + lx = -np.log(1/ax-1) + ly = -np.log(1/ay-1) + assert_almost_equal(lx, ly) + + class TestLogitLocator: - def test_set_params(self): + ref_basic_limits = [ + (5e-2, 1 - 5e-2), + (5e-3, 1 - 5e-3), + (5e-4, 1 - 5e-4), + (5e-5, 1 - 5e-5), + (5e-6, 1 - 5e-6), + (5e-7, 1 - 5e-7), + (5e-8, 1 - 5e-8), + (5e-9, 1 - 5e-9), + ] + + ref_basic_major_ticks = [ + 1 / (10 ** np.arange(1, 3)), + 1 / (10 ** np.arange(1, 4)), + 1 / (10 ** np.arange(1, 5)), + 1 / (10 ** np.arange(1, 6)), + 1 / (10 ** np.arange(1, 7)), + 1 / (10 ** np.arange(1, 8)), + 1 / (10 ** np.arange(1, 9)), + 1 / (10 ** np.arange(1, 10)), + ] + + ref_maxn_limits = [(0.4, 0.6), (5e-2, 2e-1), (1 - 2e-1, 1 - 5e-2)] + + @pytest.mark.parametrize( + "lims, expected_low_ticks", + zip(ref_basic_limits, ref_basic_major_ticks), + ) + def test_basic_major(self, lims, expected_low_ticks): """ - Create logit locator with default minor=False, and change it to - something else. See if change was successful. Should not exception. + Create logit locator with huge number of major, and tests ticks. """ - loc = mticker.LogitLocator() # Defaults to false. - loc.set_params(minor=True) + expected_ticks = sorted( + [*expected_low_ticks, 0.5, *(1 - expected_low_ticks)] + ) + loc = mticker.LogitLocator(nbins=100) + _LogitHelper.assert_almost_equal( + loc.tick_values(*lims), + expected_ticks + ) + + @pytest.mark.parametrize("lims", ref_maxn_limits) + def test_maxn_major(self, lims): + """ + When the axis is zoomed, the locator must have the same behavior as + MaxNLocator. + """ + loc = mticker.LogitLocator(nbins=100) + maxn_loc = mticker.MaxNLocator(nbins=100, steps=[1, 2, 5, 10]) + for nbins in (4, 8, 16): + loc.set_params(nbins=nbins) + maxn_loc.set_params(nbins=nbins) + ticks = loc.tick_values(*lims) + maxn_ticks = maxn_loc.tick_values(*lims) + assert ticks.shape == maxn_ticks.shape + assert (ticks == maxn_ticks).all() + + @pytest.mark.parametrize("lims", ref_basic_limits + ref_maxn_limits) + def test_nbins_major(self, lims): + """ + Assert logit locator for respecting nbins param. + """ + + basic_needed = int(-np.floor(np.log10(lims[0]))) * 2 + 1 + loc = mticker.LogitLocator(nbins=100) + for nbins in range(basic_needed, 2, -1): + loc.set_params(nbins=nbins) + assert len(loc.tick_values(*lims)) <= nbins + 2 + + @pytest.mark.parametrize( + "lims, expected_low_ticks", + zip(ref_basic_limits, ref_basic_major_ticks), + ) + def test_minor(self, lims, expected_low_ticks): + """ + In large scale, test the presence of minor, + and assert no minor when major are subsampled. + """ + + expected_ticks = sorted( + [*expected_low_ticks, 0.5, *(1 - expected_low_ticks)] + ) + basic_needed = len(expected_ticks) + loc = mticker.LogitLocator(nbins=100) + minor_loc = mticker.LogitLocator(nbins=100, minor=True) + for nbins in range(basic_needed, 2, -1): + loc.set_params(nbins=nbins) + minor_loc.set_params(nbins=nbins) + major_ticks = loc.tick_values(*lims) + minor_ticks = minor_loc.tick_values(*lims) + if len(major_ticks) >= len(expected_ticks): + # no subsample, we must have a lot of minors ticks + assert (len(major_ticks) - 1) * 5 < len(minor_ticks) + else: + # subsample + _LogitHelper.assert_almost_equal( + np.sort(np.concatenate((major_ticks, minor_ticks))), + expected_ticks, + ) + + def test_minor_attr(self): + loc = mticker.LogitLocator(nbins=100) + assert not loc.minor + loc.minor = True assert loc.minor + loc.set_params(minor=False) + assert not loc.minor class TestFixedLocator: @@ -671,6 +789,184 @@ def test_LogFormatter_call(self, val): assert temp_lf(val) == str(val) +class TestLogitFormatter: + @staticmethod + def logit_deformatter(string): + r""" + Parser to convert string as r'$\mathdefault{1.41\cdot10^{-4}}$' in + float 1.41e-4, as '0.5' or as r'$\mathdefault{\frac{1}{2}}$' in float + 0.5, + """ + match = re.match( + r"[^\d]*" + r"(?P1-)?" + r"(?P\d*\.?\d*)?" + r"(?:\\cdot)?" + r"(?:10\^\{(?P-?\d*)})?" + r"[^\d]*$", + string, + ) + if match: + comp = match["comp"] is not None + mantissa = float(match["mant"]) if match["mant"] else 1 + expo = int(match["expo"]) if match["expo"] is not None else 0 + value = mantissa * 10 ** expo + if match["mant"] or match["expo"] is not None: + if comp: + return 1 - value + return value + match = re.match( + r"[^\d]*\\frac\{(?P\d+)\}\{(?P\d+)\}[^\d]*$", string + ) + if match: + num, deno = float(match["num"]), float(match["deno"]) + return num / deno + raise ValueError("not formatted by LogitFormatter") + + @pytest.mark.parametrize( + "fx, x", + [ + (r"STUFF0.41OTHERSTUFF", 0.41), + (r"STUFF1.41\cdot10^{-2}OTHERSTUFF", 1.41e-2), + (r"STUFF1-0.41OTHERSTUFF", 1 - 0.41), + (r"STUFF1-1.41\cdot10^{-2}OTHERSTUFF", 1 - 1.41e-2), + (r"STUFF", None), + (r"STUFF12.4e-3OTHERSTUFF", None), + ], + ) + def test_logit_deformater(self, fx, x): + if x is None: + with pytest.raises(ValueError): + TestLogitFormatter.logit_deformatter(fx) + else: + y = TestLogitFormatter.logit_deformatter(fx) + assert _LogitHelper.isclose(x, y) + + decade_test = sorted( + [10 ** (-i) for i in range(1, 10)] + + [1 - 10 ** (-i) for i in range(1, 10)] + + [1 / 2] + ) + + @pytest.mark.parametrize("x", decade_test) + def test_basic(self, x): + """ + Test the formatted value correspond to the value for ideal ticks in + logit space. + """ + formatter = mticker.LogitFormatter(use_overline=False) + formatter.set_locs(self.decade_test) + s = formatter(x) + x2 = TestLogitFormatter.logit_deformatter(s) + assert _LogitHelper.isclose(x, x2) + + @pytest.mark.parametrize("x", (-1, -0.5, -0.1, 1.1, 1.5, 2)) + def test_invalid(self, x): + """ + Test that invalid value are formatted with empty string without + raising exception. + """ + formatter = mticker.LogitFormatter(use_overline=False) + formatter.set_locs(self.decade_test) + s = formatter(x) + assert s == "" + + @pytest.mark.parametrize("x", 1 / (1 + np.exp(-np.linspace(-7, 7, 10)))) + def test_variablelength(self, x): + """ + The format length should change depending on the neighbor labels. + """ + formatter = mticker.LogitFormatter(use_overline=False) + for N in (10, 20, 50, 100, 200, 1000, 2000, 5000, 10000): + if x + 1 / N < 1: + formatter.set_locs([x - 1 / N, x, x + 1 / N]) + sx = formatter(x) + sx1 = formatter(x + 1 / N) + d = ( + TestLogitFormatter.logit_deformatter(sx1) + - TestLogitFormatter.logit_deformatter(sx) + ) + assert 0 < d < 2 / N + + lims_minor_major = [ + (True, (5e-8, 1 - 5e-8), ((25, False), (75, False))), + (True, (5e-5, 1 - 5e-5), ((25, False), (75, True))), + (True, (5e-2, 1 - 5e-2), ((25, True), (75, True))), + (False, (0.75, 0.76, 0.77), ((7, True), (25, True), (75, True))), + ] + + @pytest.mark.parametrize("method, lims, cases", lims_minor_major) + def test_minor_vs_major(self, method, lims, cases): + """ + Test minor/major displays. + """ + + if method: + min_loc = mticker.LogitLocator(minor=True) + ticks = min_loc.tick_values(*lims) + else: + ticks = np.array(lims) + min_form = mticker.LogitFormatter(minor=True) + for threshold, has_minor in cases: + min_form.set_minor_threshold(threshold) + formatted = min_form.format_ticks(ticks) + labelled = [f for f in formatted if len(f) > 0] + if has_minor: + assert len(labelled) > 0, (threshold, has_minor) + else: + assert len(labelled) == 0, (threshold, has_minor) + + def test_minor_number(self): + """ + Test the parameter minor_number + """ + min_loc = mticker.LogitLocator(minor=True) + min_form = mticker.LogitFormatter(minor=True) + ticks = min_loc.tick_values(5e-2, 1 - 5e-2) + for minor_number in (2, 4, 8, 16): + min_form.set_minor_number(minor_number) + formatted = min_form.format_ticks(ticks) + labelled = [f for f in formatted if len(f) > 0] + assert len(labelled) == minor_number + + def test_use_overline(self): + """ + Test the parameter use_overline + """ + x = 1 - 1e-2 + fx1 = r"$\mathdefault{1-10^{-2}}$" + fx2 = r"$\mathdefault{\overline{10^{-2}}}$" + form = mticker.LogitFormatter(use_overline=False) + assert form(x) == fx1 + form.use_overline(True) + assert form(x) == fx2 + form.use_overline(False) + assert form(x) == fx1 + + def test_one_half(self): + """ + Test the parameter one_half + """ + form = mticker.LogitFormatter() + assert r"\frac{1}{2}" in form(1/2) + form.set_one_half("1/2") + assert "1/2" in form(1/2) + form.set_one_half("one half") + assert "one half" in form(1/2) + + @pytest.mark.parametrize("N", (100, 253, 754)) + def test_format_data_short(self, N): + locs = np.linspace(0, 1, N)[1:-1] + form = mticker.LogitFormatter() + for x in locs: + fx = form.format_data_short(x) + if fx.startswith("1-"): + x2 = 1 - float(fx[2:]) + else: + x2 = float(fx) + assert np.abs(x - x2) < 1 / N + + class TestFormatStrFormatter: def test_basic(self): # test % style formatter diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index b0fd40fd2c93..dfe9a85b0cdd 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1170,25 +1170,202 @@ class LogitFormatter(Formatter): """ Probability formatter (using Math text). """ - def __call__(self, x, pos=None): - s = '' - if 0.01 <= x <= 0.99: - s = '{:.2f}'.format(x) - elif x < 0.01: - if is_decade(x): - s = '$10^{{{:.0f}}}$'.format(np.log10(x)) - else: - s = '${:.5f}$'.format(x) - else: # x > 0.99 - if is_decade(1-x): - s = '$1-10^{{{:.0f}}}$'.format(np.log10(1-x)) + + def __init__( + self, + *, + use_overline=False, + one_half=r"\frac{1}{2}", + minor=False, + minor_threshold=25, + minor_number=6, + ): + r""" + Parameters + ---------- + use_overline : bool, default: False + If x > 1/2, with x = 1-v, indicate if x should be displayed as + $\overline{v}$. The default is to display $1-v$. + + one_half : str, default: r"\frac{1}{2}" + The string used to represent 1/2. + + minor : bool, default: False + Indicate if the formatter is formatting minor ticks or not. + Basically minor ticks are not labelled, except when only few ticks + are provided, ticks with most space with neighbor ticks are + labelled. See other parameters to change the default behavior. + + minor_threshold : int, default: 25 + Maximum number of locs for labelling some minor ticks. This + parameter have no effect if minor is False. + + minor_number : int, default: 6 + Number of ticks which are labelled when the number of ticks is + below the threshold. + """ + self._use_overline = use_overline + self._one_half = one_half + self._minor = minor + self._labelled = set() + self._minor_threshold = minor_threshold + self._minor_number = minor_number + + def use_overline(self, use_overline): + r""" + Switch display mode with overline for labelling p>1/2. + + Parameters + ---------- + use_overline : bool, default: False + If x > 1/2, with x = 1-v, indicate if x should be displayed as + $\overline{v}$. The default is to display $1-v$. + """ + self._use_overline = use_overline + + def set_one_half(self, one_half): + r""" + Set the way one half is displayed. + + one_half : str, default: r"\frac{1}{2}" + The string used to represent 1/2. + """ + self._one_half = one_half + + def set_minor_threshold(self, minor_threshold): + """ + Set the threshold for labelling minors ticks. + + Parameters + ---------- + minor_threshold : int + Maximum number of locations for labelling some minor ticks. This + parameter have no effect if minor is False. + """ + self._minor_threshold = minor_threshold + + def set_minor_number(self, minor_number): + """ + Set the number of minor ticks to label when some minor ticks are + labelled. + + Parameters + ---------- + minor_number : int + Number of ticks which are labelled when the number of ticks is + below the threshold. + """ + self._minor_number = minor_number + + def set_locs(self, locs): + self.locs = np.array(locs) + self._labelled.clear() + + if not self._minor: + return None + if all( + is_decade(x, rtol=1e-7) + or is_decade(1 - x, rtol=1e-7) + or (is_close_to_int(2 * x) and int(np.round(2 * x)) == 1) + for x in locs + ): + # minor ticks are subsample from ideal, so no label + return None + if len(locs) < self._minor_threshold: + if len(locs) < self._minor_number: + self._labelled.update(locs) else: - s = '$1-{:.5f}$'.format(1-x) + # we do not have a lot of minor ticks, so only few decades are + # displayed, then we choose some (spaced) minor ticks to label. + # Only minor ticks are known, we assume it is sufficient to + # choice which ticks are displayed. + # For each ticks we compute the distance between the ticks and + # the previous, and between the ticks and the next one. Ticks + # with smallest minimum are chosen. As tiebreak, the ticks + # with smallest sum is chosen. + diff = np.diff(-np.log(1 / self.locs - 1)) + space_pessimistic = np.minimum( + np.concatenate(((np.inf,), diff)), + np.concatenate((diff, (np.inf,))), + ) + space_sum = ( + np.concatenate(((0,), diff)) + + np.concatenate((diff, (0,))) + ) + good_minor = sorted( + range(len(self.locs)), + key=lambda i: (space_pessimistic[i], space_sum[i]), + )[-self._minor_number:] + self._labelled.update(locs[i] for i in good_minor) + + def _format_value(self, x, locs, sci_notation=True): + if sci_notation: + exponent = math.floor(np.log10(x)) + min_precision = 0 + else: + exponent = 0 + min_precision = 1 + value = x * 10 ** (-exponent) + if len(locs) < 2: + precision = min_precision + else: + diff = np.sort(np.abs(locs - x))[1] + precision = -np.log10(diff) + exponent + precision = ( + int(np.round(precision)) + if is_close_to_int(precision) + else math.ceil(precision) + ) + if precision < min_precision: + precision = min_precision + mantissa = r"%.*f" % (precision, value) + if not sci_notation: + return mantissa + s = r"%s\cdot10^{%d}" % (mantissa, exponent) return s + def _one_minus(self, s): + if self._use_overline: + return r"\overline{%s}" % s + else: + return "1-{}".format(s) + + def __call__(self, x, pos=None): + if self._minor and x not in self._labelled: + return "" + if x <= 0 or x >= 1: + return "" + usetex = rcParams["text.usetex"] + + if is_close_to_int(2 * x) and round(2 * x) == 1: + s = self._one_half + elif x < 0.5 and is_decade(x, rtol=1e-7): + exponent = round(np.log10(x)) + s = "10^{%d}" % exponent + elif x > 0.5 and is_decade(1 - x, rtol=1e-7): + exponent = round(np.log10(1 - x)) + s = self._one_minus("10^{%d}" % exponent) + elif x < 0.1: + s = self._format_value(x, self.locs) + elif x > 0.9: + s = self._one_minus(self._format_value(1-x, 1-self.locs)) + else: + s = self._format_value(x, self.locs, sci_notation=False) + if usetex: + return "$%s$" % s + return "$%s$" % _mathdefault(s) + def format_data_short(self, value): - """Return a short formatted string representation of a number.""" - return '%-12g' % value + """ + Return a short formatted string representation of a number. + """ + # thresholds choosen for use scienfic notation if and only if exponent + # is less or equal than -2. + if value < 0.1: + return "{:e}".format(value) + if value < 0.9: + return "{:f}".format(value) + return "1-{:e}".format(1 - value) class EngFormatter(Formatter): @@ -2079,13 +2256,13 @@ def decade_up(x, base=10): return base ** lx -def is_decade(x, base=10): +def is_decade(x, base=10, *, rtol=1e-10): if not np.isfinite(x): return False if x == 0.0: return True lx = np.log(np.abs(x)) / np.log(base) - return is_close_to_int(lx) + return is_close_to_int(lx, atol=rtol) def _decade_less_equal(x, base): @@ -2138,8 +2315,8 @@ def _decade_greater(x, base): return greater -def is_close_to_int(x): - return abs(x - np.round(x)) < 1e-10 +def is_close_to_int(x, *, atol=1e-10): + return abs(x - np.round(x)) < atol class LogLocator(Locator): @@ -2502,64 +2679,116 @@ def view_limits(self, vmin, vmax): return result -class LogitLocator(Locator): +class LogitLocator(MaxNLocator): """ Determine the tick locations for logit axes """ - def __init__(self, minor=False): - """Place ticks on the logit locations.""" - self.minor = minor + def __init__(self, minor=False, *, nbins="auto"): + """ + Place ticks on the logit locations + + Parameters + ---------- + nbins : int or 'auto', optional + Number of ticks. Only used if minor is False. + minor : bool, default: False + Indicate if this locator is for minor ticks or not. + """ + + self._minor = minor + MaxNLocator.__init__(self, nbins=nbins, steps=[1, 2, 5, 10]) - def set_params(self, minor=None): + def set_params(self, minor=None, **kwargs): """Set parameters within this locator.""" if minor is not None: - self.minor = minor + self._minor = minor + MaxNLocator.set_params(self, **kwargs) - def __call__(self): - """Return the locations of the ticks.""" - vmin, vmax = self.axis.get_view_interval() - return self.tick_values(vmin, vmax) + @property + def minor(self): + return self._minor + + @minor.setter + def minor(self, value): + self.set_params(minor=value) def tick_values(self, vmin, vmax): # dummy axis has no axes attribute - if hasattr(self.axis, 'axes') and self.axis.axes.name == 'polar': - raise NotImplementedError('Polar axis cannot be logit scaled yet') - - vmin, vmax = self.nonsingular(vmin, vmax) - vmin = np.log10(vmin / (1 - vmin)) - vmax = np.log10(vmax / (1 - vmax)) + if hasattr(self.axis, "axes") and self.axis.axes.name == "polar": + raise NotImplementedError("Polar axis cannot be logit scaled yet") - decade_min = np.floor(vmin) - decade_max = np.ceil(vmax) - - # major ticks - if not self.minor: - ticklocs = [] - if decade_min <= -1: - expo = np.arange(decade_min, min(0, decade_max + 1)) - ticklocs.extend(10**expo) - if decade_min <= 0 <= decade_max: - ticklocs.append(0.5) - if decade_max >= 1: - expo = -np.arange(max(1, decade_min), decade_max + 1) - ticklocs.extend(1 - 10**expo) - - # minor ticks + if self._nbins == "auto": + if self.axis is not None: + nbins = self.axis.get_tick_space() + if nbins < 2: + nbins = 2 + else: + nbins = 9 else: - ticklocs = [] - if decade_min <= -2: - expo = np.arange(decade_min, min(-1, decade_max)) - newticks = np.outer(np.arange(2, 10), 10**expo).ravel() - ticklocs.extend(newticks) - if decade_min <= 0 <= decade_max: - ticklocs.extend([0.2, 0.3, 0.4, 0.6, 0.7, 0.8]) - if decade_max >= 2: - expo = -np.arange(max(2, decade_min), decade_max + 1) - newticks = 1 - np.outer(np.arange(2, 10), 10**expo).ravel() - ticklocs.extend(newticks) + nbins = self._nbins - return self.raise_if_exceeds(np.array(ticklocs)) + # We define ideal ticks with their index: + # linscale: ... 1e-3 1e-2 1e-1 1/2 1-1e-1 1-1e-2 1-1e-3 ... + # b-scale : ... -3 -2 -1 0 1 2 3 ... + def ideal_ticks(x): + return 10 ** x if x < 0 else 1 - (10 ** (-x)) if x > 0 else 1 / 2 + + vmin, vmax = self.nonsingular(vmin, vmax) + binf = int( + np.floor(np.log10(vmin)) + if vmin < 0.5 + else 0 + if vmin < 0.9 + else -np.ceil(np.log10(1 - vmin)) + ) + bsup = int( + np.ceil(np.log10(vmax)) + if vmax <= 0.5 + else 1 + if vmax <= 0.9 + else -np.floor(np.log10(1 - vmax)) + ) + numideal = bsup - binf - 1 + if numideal >= 2: + # have 2 or more wanted ideal ticks, so use them as major ticks + if numideal > nbins: + # to many ideal ticks, subsampling ideals for major ticks, and + # take others for minor ticks + subsampling_factor = math.ceil(numideal / nbins) + if self._minor: + ticklocs = [ + ideal_ticks(b) + for b in range(binf, bsup + 1) + if (b % subsampling_factor) != 0 + ] + else: + ticklocs = [ + ideal_ticks(b) + for b in range(binf, bsup + 1) + if (b % subsampling_factor) == 0 + ] + return self.raise_if_exceeds(np.array(ticklocs)) + if self._minor: + ticklocs = [] + for b in range(binf, bsup): + if b < -1: + ticklocs.extend(np.arange(2, 10) * 10 ** b) + elif b == -1: + ticklocs.extend(np.arange(2, 5) / 10) + elif b == 0: + ticklocs.extend(np.arange(6, 9) / 10) + else: + ticklocs.extend( + 1 - np.arange(2, 10)[::-1] * 10 ** (-b - 1) + ) + return self.raise_if_exceeds(np.array(ticklocs)) + ticklocs = [ideal_ticks(b) for b in range(binf, bsup + 1)] + return self.raise_if_exceeds(np.array(ticklocs)) + # the scale is zoomed so same ticks as linear scale can be used + if self._minor: + return [] + return MaxNLocator.tick_values(self, vmin, vmax) def nonsingular(self, vmin, vmax): initial_range = (1e-7, 1 - 1e-7) diff --git a/tutorials/introductory/pyplot.py b/tutorials/introductory/pyplot.py index 7fa2744361e9..eda9a1354821 100644 --- a/tutorials/introductory/pyplot.py +++ b/tutorials/introductory/pyplot.py @@ -463,9 +463,6 @@ def f(t): plt.yscale('logit') plt.title('logit') plt.grid(True) -# Format the minor tick labels of the y-axis into empty strings with -# `NullFormatter`, to avoid cumbering the axis with too many labels. -plt.gca().yaxis.set_minor_formatter(NullFormatter()) # Adjust the subplot layout, because the logit one may take more space # than usual, due to y-tick labels like "1 - 10^{-3}" plt.subplots_adjust(top=0.92, bottom=0.08, left=0.10, right=0.95, hspace=0.25,