From e7365001e86d212c4b142d300878c8171d5cce91 Mon Sep 17 00:00:00 2001 From: Jean-Benoist Leger Date: Mon, 24 Jun 2019 16:51:07 +0200 Subject: [PATCH 01/14] ticker: introduce atol/rtol for comparisons --- lib/matplotlib/ticker.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index b0fd40fd2c93..9cb9f39f6bae 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -2079,13 +2079,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 +2138,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): From 5847a840676af95c1728cc9af8cd7146c06c9d50 Mon Sep 17 00:00:00 2001 From: Jean-Benoist Leger Date: Mon, 24 Jun 2019 17:50:36 +0200 Subject: [PATCH 02/14] logitscale: rewrite of LogitLocator --- lib/matplotlib/ticker.py | 140 +++++++++++++++++++++++++++------------ 1 file changed, 96 insertions(+), 44 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 9cb9f39f6bae..d59c2a2ed870 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -2502,64 +2502,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') + 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)) - - 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) From adc30eddd69a2225cb351dce92b7d07b211d7937 Mon Sep 17 00:00:00 2001 From: Jean-Benoist Leger Date: Mon, 24 Jun 2019 17:51:03 +0200 Subject: [PATCH 03/14] logitscale: rewrite of LogitFormatter --- lib/matplotlib/ticker.py | 207 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 192 insertions(+), 15 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index d59c2a2ed870..8479e5dc81d3 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, the most espaced ticks are labelled. See others + 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 locs 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): From 4891fbf9127c554822bc141dec73678cec247cd6 Mon Sep 17 00:00:00 2001 From: Jean-Benoist Leger Date: Mon, 24 Jun 2019 17:52:18 +0200 Subject: [PATCH 04/14] logitscale: tests, helpers for TestLogitLocator TestLogitFormatter --- lib/matplotlib/tests/test_ticker.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index 6408d231159d..11671ed328db 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -238,6 +238,24 @@ 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): """ From 584705e18e5a5546480cb632aa5579d5439bdaab Mon Sep 17 00:00:00 2001 From: Jean-Benoist Leger Date: Mon, 24 Jun 2019 17:53:17 +0200 Subject: [PATCH 05/14] logitscale: tests, rewrite of TestLogitLocator --- lib/matplotlib/tests/test_ticker.py | 109 ++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index 11671ed328db..85b23c87a4da 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -257,14 +257,113 @@ def assert_almost_equal(x, y): 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. + """ + 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): """ - loc = mticker.LogitLocator() # Defaults to false. - loc.set_params(minor=True) + 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: From 98e670d8c9a0b19b82c74634028c95f9e2f4ab02 Mon Sep 17 00:00:00 2001 From: Jean-Benoist Leger Date: Mon, 24 Jun 2019 17:53:53 +0200 Subject: [PATCH 06/14] logitscale: tests, creation of TestLogitFormatter --- lib/matplotlib/tests/test_ticker.py | 179 ++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index 85b23c87a4da..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 @@ -788,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 From 851fa4a7adf07c28b7a628021a8ca38b47e2d19e Mon Sep 17 00:00:00 2001 From: Jean-Benoist Leger Date: Mon, 24 Jun 2019 17:54:52 +0200 Subject: [PATCH 07/14] logitscale: add option to LogitScale, passed to LogitFormatter --- lib/matplotlib/scale.py | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index 4df3f2af7a4c..46ea66dd916f 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): """ From c1360bba15c9bf3c7ea7edb81fb1ebe6cb246e9c Mon Sep 17 00:00:00 2001 From: Jean-Benoist Leger Date: Mon, 24 Jun 2019 17:55:57 +0200 Subject: [PATCH 08/14] logitscale: remove hack in doc to workround the formatter, it is not necessary anymore --- examples/scales/scales.py | 3 --- tutorials/introductory/pyplot.py | 3 --- 2 files changed, 6 deletions(-) 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/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, From 4717b46d4aa89d2b98a1debde7faf6baa6b12642 Mon Sep 17 00:00:00 2001 From: Jean-Benoist Leger Date: Mon, 24 Jun 2019 17:58:16 +0200 Subject: [PATCH 09/14] logitscale: add new example to illustrate logitscale --- examples/scales/logit_demo.py | 61 +++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 examples/scales/logit_demo.py 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() From 6524de2b3ee88a938663c6e83a9fe7bb451f3ac5 Mon Sep 17 00:00:00 2001 From: Jean-Benoist Leger Date: Mon, 24 Jun 2019 17:57:33 +0200 Subject: [PATCH 10/14] logitscale: add whatsnew entry --- doc/users/next_whats_new/logit_scale_stuff.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 doc/users/next_whats_new/logit_scale_stuff.rst 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..128c59d34303 --- /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 appropriate ticker and +formatter. Previously, location of ticks was not zoom dependent, too many label +was displayed implying overlapping which break readability, and label formatting +was not precision adaptive. + +Starting from this version, the locator have near the same behavior as the +locator for the log scale or the same behavior as the locator for 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 adaptive depending on the scale. From e3c8e2521d8d4c943119fe890318637930ffbaf1 Mon Sep 17 00:00:00 2001 From: jb-leger Date: Sun, 21 Jul 2019 17:41:08 +0200 Subject: [PATCH 11/14] logitscale: typos in whats news entry. Co-Authored-By: Elliott Sales de Andrade --- doc/users/next_whats_new/logit_scale_stuff.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/doc/users/next_whats_new/logit_scale_stuff.rst b/doc/users/next_whats_new/logit_scale_stuff.rst index 128c59d34303..aeb44a2e4c15 100644 --- a/doc/users/next_whats_new/logit_scale_stuff.rst +++ b/doc/users/next_whats_new/logit_scale_stuff.rst @@ -1,13 +1,13 @@ Improvements in Logit scale ticker and formatter ------------------------------------------------ -Introduced in version 1.5, the logit scale didn't have appropriate ticker and -formatter. Previously, location of ticks was not zoom dependent, too many label -was displayed implying overlapping which break readability, and label formatting -was not precision adaptive. +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 locator have near the same behavior as the -locator for the log scale or the same behavior as the locator for the linear +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 adaptive depending on the scale. +for probabilities and the precision is adapts to the scale. From eb955d7de145b4a3e5590114478faa702b91afd0 Mon Sep 17 00:00:00 2001 From: jb-leger Date: Sun, 21 Jul 2019 17:43:44 +0200 Subject: [PATCH 12/14] logitscale: typos and documentation convention in LogitScale Co-Authored-By: Elliott Sales de Andrade --- lib/matplotlib/scale.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index 46ea66dd916f..89707d1609ec 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -671,11 +671,11 @@ def __init__( 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 + 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. + 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 From f68f90399d4065e48fc9a5b265b254a7771dae9c Mon Sep 17 00:00:00 2001 From: jb-leger Date: Sun, 21 Jul 2019 17:46:25 +0200 Subject: [PATCH 13/14] logitscale: typos and documentation convention in LogitFormatter Co-Authored-By: Elliott Sales de Andrade --- lib/matplotlib/ticker.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 8479e5dc81d3..c31d8462901b 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1183,25 +1183,25 @@ def __init__( r""" Parameters ---------- - use_overline : bool (default: False) - if x > 1/2, with x = 1-v, indicate if x should be displayed as + 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. + 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. + 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, the most espaced ticks are labelled. See others parameters to change the default behavior. - minor_threshold : int (default: 25) - maximum number of locs for labelling some minor ticks. This + 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 + 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 @@ -1217,8 +1217,8 @@ def use_overline(self, use_overline): Parameters ---------- - use_overline : bool (default: False) - if x > 1/2, with x = 1-v, indicate if x should be displayed as + 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 @@ -1227,19 +1227,19 @@ 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. + 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 + Set the threshold for labelling minors ticks. Parameters ---------- minor_threshold : int - maximum number of locs for labelling some minor ticks. This + Maximum number of locations for labelling some minor ticks. This parameter have no effect if minor is False. """ self._minor_threshold = minor_threshold @@ -1252,7 +1252,7 @@ def set_minor_number(self, minor_number): Parameters ---------- minor_number : int - number of ticks which are labelled when the number of ticks is + Number of ticks which are labelled when the number of ticks is below the threshold. """ self._minor_number = minor_number From e113aff04046daa42c3ab8709caaa44c8580e54a Mon Sep 17 00:00:00 2001 From: Jean-Benoist Leger Date: Sun, 21 Jul 2019 17:51:13 +0200 Subject: [PATCH 14/14] logitscale: documentation reformulation and typo in LogitFormatter --- lib/matplotlib/ticker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index c31d8462901b..dfe9a85b0cdd 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1193,8 +1193,8 @@ def __init__( 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, the most espaced ticks are labelled. See others - parameters to change the default behavior. + 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