From 76840ea91d4d8a6e09b4fa1ad6fd36b2b179971e Mon Sep 17 00:00:00 2001 From: Fabio Zanini Date: Mon, 3 Nov 2014 16:02:32 +0100 Subject: [PATCH 1/2] Logit scale --- doc/conf.py | 1 + doc/pyplots/pyplot_scales.py | 43 +++++++ doc/users/pyplot_tutorial.rst | 19 ++++ doc/users/whats_new/updated_scale.rst | 4 + examples/scales/scales.py | 47 ++++++++ lib/matplotlib/scale.py | 107 +++++++++++++++++- .../test_scale/logit_scales.png | Bin 0 -> 16157 bytes lib/matplotlib/tests/test_scale.py | 14 +++ lib/matplotlib/ticker.py | 102 +++++++++++++++++ 9 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 doc/pyplots/pyplot_scales.py create mode 100644 doc/users/whats_new/updated_scale.rst create mode 100644 examples/scales/scales.py create mode 100644 lib/matplotlib/tests/baseline_images/test_scale/logit_scales.png diff --git a/doc/conf.py b/doc/conf.py index 4a84e85b9ed0..571def2e02c1 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -126,6 +126,7 @@ ('color', 'Color'), ('text_labels_and_annotations', 'Text, labels, and annotations'), ('ticks_and_spines', 'Ticks and spines'), + ('scales', 'Axis scales'), ('subplots_axes_and_figures', 'Subplots, axes, and figures'), ('style_sheets', 'Style sheets'), ('specialty_plots', 'Specialty plots'), diff --git a/doc/pyplots/pyplot_scales.py b/doc/pyplots/pyplot_scales.py new file mode 100644 index 000000000000..18b6c7065892 --- /dev/null +++ b/doc/pyplots/pyplot_scales.py @@ -0,0 +1,43 @@ +import numpy as np +import matplotlib.pyplot as plt + +# make up some data in the interval ]0, 1[ +y = np.random.normal(loc=0.5, scale=0.4, size=1000) +y = y[(y > 0) & (y < 1)] +y.sort() +x = np.arange(len(y)) + +# plot with various axes scales +plt.figure(1) + +# linear +plt.subplot(221) +plt.plot(x, y) +plt.yscale('linear') +plt.title('linear') +plt.grid(True) + + +# log +plt.subplot(222) +plt.plot(x, y) +plt.yscale('log') +plt.title('log') +plt.grid(True) + + +# symmetric log +plt.subplot(223) +plt.plot(x, y - y.mean()) +plt.yscale('symlog', linthreshy=0.05) +plt.title('symlog') +plt.grid(True) + +# logit +plt.subplot(223) +plt.plot(x, y) +plt.yscale('logit') +plt.title('logit') +plt.grid(True) + +plt.show() diff --git a/doc/users/pyplot_tutorial.rst b/doc/users/pyplot_tutorial.rst index b9db95e56ec8..f13ca6d37621 100644 --- a/doc/users/pyplot_tutorial.rst +++ b/doc/users/pyplot_tutorial.rst @@ -280,3 +280,22 @@ variety of other coordinate systems one can choose -- see :ref:`annotations-tutorial` and :ref:`plotting-guide-annotation` for details. More examples can be found in :ref:`pylab_examples-annotation_demo`. + + +Logarithmic and other nonlinear axis +==================================== + +:mod:`matplotlib.pyplot` supports not only linear axis scales, but also +logarithmic and logit scales. This is commonly used if data spans many orders +of magnitude. Changing the scale of an axis is easy: + + plt.xscale('log') + +An example of four plots with the same data and different scales for the y axis +is shown below. + +.. plot:: pyplots/pyplot_scales.py + :include-source: + +It is also possible to add your own scale, see :ref:`adding-new-scales` for +details. diff --git a/doc/users/whats_new/updated_scale.rst b/doc/users/whats_new/updated_scale.rst new file mode 100644 index 000000000000..df1a5bc2be80 --- /dev/null +++ b/doc/users/whats_new/updated_scale.rst @@ -0,0 +1,4 @@ +Logit Scale +----------- +Added support for the 'logit' axis scale, a nonlinear transformation +`x -> log10(x / (1-x))` for data between 0 and 1 excluded. diff --git a/examples/scales/scales.py b/examples/scales/scales.py new file mode 100644 index 000000000000..f19359c0770f --- /dev/null +++ b/examples/scales/scales.py @@ -0,0 +1,47 @@ +""" +Illustrate the scale transformations applied to axes, e.g. log, symlog, logit. +""" +import numpy as np +import matplotlib.pyplot as plt + +# make up some data in the interval ]0, 1[ +y = np.random.normal(loc=0.5, scale=0.4, size=1000) +y = y[(y > 0) & (y < 1)] +y.sort() +x = np.arange(len(y)) + +# plot with various axes scales +fig, axs = plt.subplots(2, 2) + +# linear +ax = axs[0, 0] +ax.plot(x, y) +ax.set_yscale('linear') +ax.set_title('linear') +ax.grid(True) + + +# log +ax = axs[0, 1] +ax.plot(x, y) +ax.set_yscale('log') +ax.set_title('log') +ax.grid(True) + + +# symmetric log +ax = axs[1, 0] +ax.plot(x, y - y.mean()) +ax.set_yscale('symlog', linthreshy=0.05) +ax.set_title('symlog') +ax.grid(True) + +# logit +ax = axs[1, 1] +ax.plot(x, y) +ax.set_yscale('logit') +ax.set_title('logit') +ax.grid(True) + + +plt.show() diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index d541aa86d484..85d50461dcd3 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -8,9 +8,9 @@ from matplotlib.cbook import dedent from matplotlib.ticker import (NullFormatter, ScalarFormatter, - LogFormatterMathtext) + LogFormatterMathtext, LogitFormatter) from matplotlib.ticker import (NullLocator, LogLocator, AutoLocator, - SymmetricalLogLocator) + SymmetricalLogLocator, LogitLocator) from matplotlib.transforms import Transform, IdentityTransform from matplotlib import docstring @@ -478,10 +478,111 @@ def get_transform(self): return self._transform +def _mask_non_logit(a): + """ + Return a Numpy masked array where all values outside ]0, 1[ are + masked. If all values are inside ]0, 1[, the original array is + returned. + """ + a = a.copy() + mask = (a <= 0.0) | (a >= 1.0) + a[mask] = np.nan + return a + + +def _clip_non_logit(a): + a = a.copy() + a[a <= 0.0] = 1e-300 + a[a >= 1.0] = 1 - 1e-300 + return a + + +class LogitTransform(Transform): + input_dims = 1 + output_dims = 1 + is_separable = True + has_inverse = True + + def __init__(self, nonpos): + Transform.__init__(self) + if nonpos == 'mask': + self._handle_nonpos = _mask_non_logit + else: + self._handle_nonpos = _clip_non_logit + self._nonpos = nonpos + + def transform_non_affine(self, a): + """logit transform (base 10), masked or clipped""" + a = self._handle_nonpos(a) + if isinstance(a, ma.MaskedArray): + return ma.log10(1.0 * a / (1.0 - a)) + return np.log10(1.0 * a / (1.0 - a)) + + def inverted(self): + return LogisticTransform(self._nonpos) + + +class LogisticTransform(Transform): + input_dims = 1 + output_dims = 1 + is_separable = True + has_inverse = True + + def __init__(self, nonpos='mask'): + Transform.__init__(self) + self._nonpos = nonpos + + def transform_non_affine(self, a): + """logistic transform (base 10)""" + return 1.0 / (1 + 10**(-a)) + + def inverted(self): + return LogitTransform(self._nonpos) + + +class LogitScale(ScaleBase): + """ + Logit scale for data between zero and one, both excluded. + + This scale is similar to a log scale close to zero and to one, and almost + linear around 0.5. It maps the interval ]0, 1[ onto ]-infty, +infty[. + """ + name = 'logit' + + def __init__(self, axis, nonpos='mask'): + """ + *nonpos*: ['mask' | 'clip' ] + values beyond ]0, 1[ can be masked as invalid, or clipped to a number + very close to 0 or 1 + """ + if nonpos not in ['mask', 'clip']: + raise ValueError("nonposx, nonposy kwarg must be 'mask' or 'clip'") + + self._transform = LogitTransform(nonpos) + + def get_transform(self): + """ + Return a :class:`LogitTransform` instance. + """ + return self._transform + + def set_default_locators_and_formatters(self, axis): + # ..., 0.01, 0.1, 0.5, 0.9, 0.99, ... + axis.set_major_locator(LogitLocator()) + axis.set_major_formatter(LogitFormatter()) + axis.set_minor_locator(LogitLocator(minor=True)) + axis.set_minor_formatter(LogitFormatter()) + + def limit_range_for_scale(self, vmin, vmax, minpos): + return (vmin <= 0 and minpos or vmin, + vmax >= 1 and (1 - minpos) or vmax) + + _scale_mapping = { 'linear': LinearScale, 'log': LogScale, - 'symlog': SymmetricalLogScale + 'symlog': SymmetricalLogScale, + 'logit': LogitScale, } diff --git a/lib/matplotlib/tests/baseline_images/test_scale/logit_scales.png b/lib/matplotlib/tests/baseline_images/test_scale/logit_scales.png new file mode 100644 index 0000000000000000000000000000000000000000..191e671f6c146fa9dff2bf4494b482d13bdd1cd9 GIT binary patch literal 16157 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%0DX!K=r*7(lt+~@gyG`g6W7)C=wqtbq? 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 GfBzp?k=)t< literal 0 HcmV?d00001 diff --git a/lib/matplotlib/tests/test_scale.py b/lib/matplotlib/tests/test_scale.py index 4a582bfedeae..f848a3e70e65 100644 --- a/lib/matplotlib/tests/test_scale.py +++ b/lib/matplotlib/tests/test_scale.py @@ -14,6 +14,20 @@ def test_log_scales(): ax.axhline(24.1) +@image_comparison(baseline_images=['logit_scales'], remove_text=True, + extensions=['png']) +def test_logit_scales(): + ax = plt.subplot(111, xscale='logit') + + # Typical exctinction curve for logit + x = np.array([0.001, 0.003, 0.01, 0.03, 0.1, 0.2, 0.3, 0.4, 0.5, + 0.6, 0.7, 0.8, 0.9, 0.97, 0.99, 0.997, 0.999]) + y = 1.0 / x + + ax.plot(x, y) + ax.grid(True) + + @cleanup def test_log_scatter(): """Issue #1799""" diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index b8f4b9a9c97a..23ba3aa5d244 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -807,6 +807,26 @@ def __call__(self, x, pos=None): nearest_long(fx)) +class LogitFormatter(Formatter): + '''Probability formatter (using Math text)''' + def __call__(self, x, pos=None): + s = '' + if 0.01 <= x <= 0.99: + if x in [.01, 0.1, 0.5, 0.9, 0.99]: + s = '{:.2f}'.format(x) + elif x < 0.01: + if is_decade(x): + s = '$10^{%.0f}$' % np.log10(x) + elif x > 0.99: + if is_decade(1-x): + s = '$1-10^{%.0f}$' % np.log10(1-x) + return s + + def format_data_short(self, value): + 'return a short formatted string representation of a number' + return '%-12g' % value + + class EngFormatter(Formatter): """ Formats axis values using engineering prefixes to represent powers of 1000, @@ -1694,6 +1714,88 @@ def view_limits(self, vmin, vmax): return result +class LogitLocator(Locator): + """ + Determine the tick locations for logit axes + """ + + def __init__(self, minor=False): + """ + place ticks on the logit locations + """ + self.minor = minor + + def __call__(self): + 'Return the locations of the ticks' + vmin, vmax = self.axis.get_view_interval() + return self.tick_values(vmin, vmax) + + 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') + + # what to do if a window beyond ]0, 1[ is chosen + if vmin <= 0.0: + if self.axis is not None: + vmin = self.axis.get_minpos() + + if (vmin <= 0.0) or (not np.isfinite(vmin)): + raise ValueError( + "Data has no values in ]0, 1[ and therefore can not be " + "logit-scaled.") + + # NOTE: for vmax, we should query a property similar to get_minpos, but + # related to the maximal, less-than-one data point. Unfortunately, + # get_minpos is defined very deep in the BBox and updated with data, + # so for now we use the trick below. + if vmax >= 1.0: + if self.axis is not None: + vmax = 1 - self.axis.get_minpos() + + if (vmax >= 1.0) or (not np.isfinite(vmax)): + raise ValueError( + "Data has no values in ]0, 1[ and therefore can not be " + "logit-scaled.") + + if vmax < vmin: + vmin, vmax = vmax, vmin + + 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(list(10**expo)) + if (decade_min <= 0) and (decade_max >= 0): + ticklocs.append(0.5) + if (decade_max >= 1): + expo = -np.arange(max(1, decade_min), decade_max + 1) + ticklocs.extend(list(1 - 10**expo)) + + # minor ticks + 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(list(newticks)) + if (decade_min <= 0) and (decade_max >= 0): + 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(list(newticks)) + + return self.raise_if_exceeds(np.array(ticklocs)) + + class AutoLocator(MaxNLocator): def __init__(self): MaxNLocator.__init__(self, nbins=9, steps=[1, 2, 5, 10]) From c640f77688bd1732f774eaad81352305ecfb95d9 Mon Sep 17 00:00:00 2001 From: Fabio Zanini Date: Tue, 3 Mar 2015 09:28:34 +0100 Subject: [PATCH 2/2] Various fixes suggested by Eric --- lib/matplotlib/scale.py | 30 ++++++++++++------------------ lib/matplotlib/tests/test_scale.py | 2 +- lib/matplotlib/ticker.py | 13 ++++++++----- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index 85d50461dcd3..69158ad8b23f 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -86,8 +86,8 @@ def get_transform(self): def _mask_non_positives(a): """ - Return a Numpy masked array where all non-positive values are - replaced with NaNs. If there are no non-positive values, the + Return a Numpy array where all non-positive values are + replaced with NaNs. If there are no non-positive values, the original array is returned. """ mask = a <= 0.0 @@ -97,6 +97,7 @@ def _mask_non_positives(a): def _clip_non_positives(a): + a = np.array(a, float) a[a <= 0.0] = 1e-300 return a @@ -120,8 +121,6 @@ class Log10Transform(LogTransformBase): def transform_non_affine(self, a): a = self._handle_nonpos(a * 10.0) - if isinstance(a, ma.MaskedArray): - return ma.log10(a) return np.log10(a) def inverted(self): @@ -147,8 +146,6 @@ class Log2Transform(LogTransformBase): def transform_non_affine(self, a): a = self._handle_nonpos(a * 2.0) - if isinstance(a, ma.MaskedArray): - return ma.log(a) / np.log(2) return np.log2(a) def inverted(self): @@ -174,8 +171,6 @@ class NaturalLogTransform(LogTransformBase): def transform_non_affine(self, a): a = self._handle_nonpos(a * np.e) - if isinstance(a, ma.MaskedArray): - return ma.log(a) return np.log(a) def inverted(self): @@ -212,8 +207,6 @@ def __init__(self, base, nonpos): def transform_non_affine(self, a): a = self._handle_nonpos(a * self.base) - if isinstance(a, ma.MaskedArray): - return ma.log(a) / np.log(self.base) return np.log(a) / np.log(self.base) def inverted(self): @@ -480,18 +473,18 @@ def get_transform(self): def _mask_non_logit(a): """ - Return a Numpy masked array where all values outside ]0, 1[ are - masked. If all values are inside ]0, 1[, the original array is - returned. + Return a Numpy array where all values outside ]0, 1[ are + replaced with NaNs. If all values are inside ]0, 1[, the original + array is returned. """ - a = a.copy() mask = (a <= 0.0) | (a >= 1.0) - a[mask] = np.nan + if mask.any(): + return np.where(mask, np.nan, a) return a def _clip_non_logit(a): - a = a.copy() + a = np.array(a, float) a[a <= 0.0] = 1e-300 a[a >= 1.0] = 1 - 1e-300 return a @@ -514,8 +507,6 @@ def __init__(self, nonpos): def transform_non_affine(self, a): """logit transform (base 10), masked or clipped""" a = self._handle_nonpos(a) - if isinstance(a, ma.MaskedArray): - return ma.log10(1.0 * a / (1.0 - a)) return np.log10(1.0 * a / (1.0 - a)) def inverted(self): @@ -574,6 +565,9 @@ def set_default_locators_and_formatters(self, axis): axis.set_minor_formatter(LogitFormatter()) def limit_range_for_scale(self, vmin, vmax, minpos): + """ + Limit the domain to values between 0 and 1 (excluded). + """ return (vmin <= 0 and minpos or vmin, vmax >= 1 and (1 - minpos) or vmax) diff --git a/lib/matplotlib/tests/test_scale.py b/lib/matplotlib/tests/test_scale.py index f848a3e70e65..e864372fe346 100644 --- a/lib/matplotlib/tests/test_scale.py +++ b/lib/matplotlib/tests/test_scale.py @@ -19,7 +19,7 @@ def test_log_scales(): def test_logit_scales(): ax = plt.subplot(111, xscale='logit') - # Typical exctinction curve for logit + # Typical extinction curve for logit x = np.array([0.001, 0.003, 0.01, 0.03, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.97, 0.99, 0.997, 0.999]) y = 1.0 / x diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 23ba3aa5d244..6daed99b006f 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -812,14 +812,17 @@ class LogitFormatter(Formatter): def __call__(self, x, pos=None): s = '' if 0.01 <= x <= 0.99: - if x in [.01, 0.1, 0.5, 0.9, 0.99]: - s = '{:.2f}'.format(x) + s = '{:.2f}'.format(x) elif x < 0.01: if is_decade(x): - s = '$10^{%.0f}$' % np.log10(x) - elif x > 0.99: + 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}$' % np.log10(1-x) + s = '$1-10^{{{:.0f}}}$'.format(np.log10(1-x)) + else: + s = '$1-{:.5f}$'.format(1-x) return s def format_data_short(self, value):