From 73ae1d3af718ac932f00ab5576132741dc3d51d9 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 20 Jan 2020 20:19:36 +0000 Subject: [PATCH 1/8] Re-write sym-log-norm --- lib/matplotlib/colors.py | 66 ++++++++++++++--------------- lib/matplotlib/tests/test_colors.py | 12 ++++-- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 9f58a03a9f90..ed956b282dd6 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1231,9 +1231,10 @@ def __init__(self, linthresh, linscale=1.0, """ Normalize.__init__(self, vmin, vmax, clip) self.linthresh = float(linthresh) - self._linscale_adj = (linscale / (1.0 - np.e ** -1)) - if vmin is not None and vmax is not None: - self._transform_vmin_vmax() + # Number of decades in the log region + ndec = np.log10(vmax / linthresh) + # Size of the linear region from 0 to linthresh in the transformed space + self.linear_size = 1 / (1 + linscale / ndec) def __call__(self, value, clip=None): if clip is None: @@ -1254,57 +1255,52 @@ def __call__(self, value, clip=None): mask=mask) # in-place equivalent of above can be much faster resdat = self._transform(result.data) - resdat -= self._lower - resdat /= (self._upper - self._lower) if is_scalar: result = result[0] return result def _transform(self, a): - """Inplace transformation.""" + """In-place mapping from *a* to [0, 1]""" with np.errstate(invalid="ignore"): - masked = np.abs(a) > self.linthresh - sign = np.sign(a[masked]) - log = (self._linscale_adj + np.log(np.abs(a[masked]) / self.linthresh)) - log *= sign * self.linthresh - a[masked] = log - a[~masked] *= self._linscale_adj + logregion = np.abs(a) > self.linthresh + + # Transform log value + sign = np.sign(a[logregion]) + log = (1 - self.linear_size) * np.log10(np.abs(a[logregion])) + self.linear_size + a[logregion] = log * sign + + # Transform linear values + a[~logregion] *= self.linear_size / self.linthresh + + # Transform from [-1, 1] to [0, 1] + a += 1 + a /= 2 return a def _inv_transform(self, a): """Inverse inplace Transformation.""" - masked = np.abs(a) > (self.linthresh * self._linscale_adj) - sign = np.sign(a[masked]) - exp = np.exp(sign * a[masked] / self.linthresh - self._linscale_adj) - exp *= sign * self.linthresh - a[masked] = exp - a[~masked] /= self._linscale_adj + # Transform from [0, 1] to [-1, 1] + a *= 2 + a -= 1 + + # Transform back log values + logregion = np.abs(a) > self.linear_size + sign = np.sign(a[logregion]) + exp = 10**((np.abs(a[logregion]) - self.linear_size) / + (1 - self.linear_size)) + a[logregion] = exp * sign + + # Transform back linear values + a[~logregion] /= self.linear_size / self.linthresh return a - def _transform_vmin_vmax(self): - """Calculates vmin and vmax in the transformed system.""" - vmin, vmax = self.vmin, self.vmax - arr = np.array([vmax, vmin]).astype(float) - self._upper, self._lower = self._transform(arr) - def inverse(self, value): if not self.scaled(): raise ValueError("Not invertible until scaled") val = np.ma.asarray(value) - val = val * (self._upper - self._lower) + self._lower return self._inv_transform(val) - def autoscale(self, A): - # docstring inherited. - super().autoscale(A) - self._transform_vmin_vmax() - - def autoscale_None(self, A): - # docstring inherited. - super().autoscale_None(A) - self._transform_vmin_vmax() - class PowerNorm(Normalize): """ diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 05a81e26def3..d7d854b8abfc 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -395,9 +395,7 @@ def test_TwoSlopeNorm_premature_scaling(): def test_SymLogNorm(): - """ - Test SymLogNorm behavior - """ + # Test SymLogNorm behavior norm = mcolors.SymLogNorm(3, vmax=5, linscale=1.2) vals = np.array([-30, -1, 2, 6], dtype=float) normed_vals = norm(vals) @@ -413,6 +411,14 @@ def test_SymLogNorm(): assert_array_almost_equal(normed_vals, expected) +@pytest.mark.parametrize('val,normed', + ([10, 1], [1, 0.75], [0, 0.5], [-1, 0.25], [-10, 0])) +def test_symlognorm_vals(val, normed): + norm = mcolors.SymLogNorm(linthresh=1, vmin=-10, vmax=10, linscale=1) + assert_array_almost_equal(norm(val), normed) + assert_array_almost_equal(norm.inverse(norm(val)), val) + + def test_SymLogNorm_colorbar(): """ Test un-called SymLogNorm in a colorbar. From 943a8485c42d58e954456c852d8687b9297beeae Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 3 Feb 2020 10:18:37 +0000 Subject: [PATCH 2/8] Allow specifying base --- lib/matplotlib/colors.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index ed956b282dd6..f156e73d2d35 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1214,7 +1214,7 @@ class SymLogNorm(Normalize): (-*linthresh*, *linthresh*). """ def __init__(self, linthresh, linscale=1.0, - vmin=None, vmax=None, clip=False): + vmin=None, vmax=None, clip=False, base=10): """ Parameters ---------- @@ -1228,13 +1228,23 @@ def __init__(self, linthresh, linscale=1.0, example, when *linscale* == 1.0 (the default), the space used for the positive and negative halves of the linear range will be equal to one decade in the logarithmic range. + base : float + The base... """ Normalize.__init__(self, vmin, vmax, clip) self.linthresh = float(linthresh) - # Number of decades in the log region - ndec = np.log10(vmax / linthresh) + self.base = float(base) + self.linscale = float(linscale) + + @property + def _linear_size(self): # Size of the linear region from 0 to linthresh in the transformed space - self.linear_size = 1 / (1 + linscale / ndec) + ndec = self._logbase(self.vmax / self.linthresh) + return 1 / (1 + self.linscale / ndec) + + def _logbase(self, val): + 'Take the log of val in the base `self.base`' + return np.log(val) / np.log(self.base) def __call__(self, value, clip=None): if clip is None: @@ -1267,11 +1277,11 @@ def _transform(self, a): # Transform log value sign = np.sign(a[logregion]) - log = (1 - self.linear_size) * np.log10(np.abs(a[logregion])) + self.linear_size + log = (1 - self._linear_size) * self._logbase(np.abs(a[logregion])) + self._linear_size a[logregion] = log * sign # Transform linear values - a[~logregion] *= self.linear_size / self.linthresh + a[~logregion] *= self._linear_size / self.linthresh # Transform from [-1, 1] to [0, 1] a += 1 @@ -1285,14 +1295,14 @@ def _inv_transform(self, a): a -= 1 # Transform back log values - logregion = np.abs(a) > self.linear_size + logregion = np.abs(a) > self._linear_size sign = np.sign(a[logregion]) - exp = 10**((np.abs(a[logregion]) - self.linear_size) / - (1 - self.linear_size)) + exp = self.base**((np.abs(a[logregion]) - self._linear_size) / + (1 - self._linear_size)) a[logregion] = exp * sign # Transform back linear values - a[~logregion] /= self.linear_size / self.linthresh + a[~logregion] /= self._linear_size / self.linthresh return a def inverse(self, value): From 60f0d69427655921e751c8191abeeccde0c0db6c Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 3 Feb 2020 13:33:45 +0000 Subject: [PATCH 3/8] Disallow vmin!=vmax --- lib/matplotlib/colors.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index f156e73d2d35..de3d4b205c5f 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1231,6 +1231,9 @@ def __init__(self, linthresh, linscale=1.0, base : float The base... """ + if vmin is not None and vmax is not None and vmin != -vmax: + raise ValueError('SymLogNorm only works for vmin = -vmax ' + f'(got vmin={vmin}, vmax={vmax})') Normalize.__init__(self, vmin, vmax, clip) self.linthresh = float(linthresh) self.base = float(base) @@ -1238,7 +1241,8 @@ def __init__(self, linthresh, linscale=1.0, @property def _linear_size(self): - # Size of the linear region from 0 to linthresh in the transformed space + 'Return the size of the linear portion in transformed space' + # Number of decades in the logarithmic range ndec = self._logbase(self.vmax / self.linthresh) return 1 / (1 + self.linscale / ndec) From 6fdd2e066dcf04de405773d44a94798ac09cfa1c Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 3 Feb 2020 13:33:53 +0000 Subject: [PATCH 4/8] Update tests --- lib/matplotlib/tests/test_colors.py | 34 +++++++++++++++++++---------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index d7d854b8abfc..a3a87274685e 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -396,27 +396,39 @@ def test_TwoSlopeNorm_premature_scaling(): def test_SymLogNorm(): # Test SymLogNorm behavior - norm = mcolors.SymLogNorm(3, vmax=5, linscale=1.2) + norm = mcolors.SymLogNorm(linthresh=3, vmax=5, linscale=1.2, base=np.e) vals = np.array([-30, -1, 2, 6], dtype=float) normed_vals = norm(vals) - expected = [0., 0.53980074, 0.826991, 1.02758204] + expected = [-0.842119, 0.450236, 0.599528, 1.277676] assert_array_almost_equal(normed_vals, expected) _inverse_tester(norm, vals) _scalar_tester(norm, vals) _mask_tester(norm, vals) - # Ensure that specifying vmin returns the same result as above - norm = mcolors.SymLogNorm(3, vmin=-30, vmax=5, linscale=1.2) - normed_vals = norm(vals) - assert_array_almost_equal(normed_vals, expected) +def test_symlognorm_vals(): + vals = [-10, -1, 0, 1, 10] -@pytest.mark.parametrize('val,normed', - ([10, 1], [1, 0.75], [0, 0.5], [-1, 0.25], [-10, 0])) -def test_symlognorm_vals(val, normed): norm = mcolors.SymLogNorm(linthresh=1, vmin=-10, vmax=10, linscale=1) - assert_array_almost_equal(norm(val), normed) - assert_array_almost_equal(norm.inverse(norm(val)), val) + normed_vals = norm(vals) + expected = [0, 0.25, 0.5, 0.75, 1] + assert_array_almost_equal(norm(vals), normed_vals) + assert_array_almost_equal(norm.inverse(norm(vals)), vals) + + # If we increase linscale to 2, the space for the linear range [0, 1] + # should be twice as large as the space for the logarithmic range [1, 10] + norm = mcolors.SymLogNorm(linthresh=1, vmin=-10, vmax=10, linscale=2) + normed_vals = norm(vals) + expected = [0, 1/6, 0.5, 5/6, 1] + assert_array_almost_equal(norm(vals), normed_vals) + assert_array_almost_equal(norm.inverse(norm(vals)), vals) + + # Similarly, going the other way means the linear range should shrink + norm = mcolors.SymLogNorm(linthresh=1, vmin=-10, vmax=10, linscale=0.5) + normed_vals = norm(vals) + expected = [0, 2/6, 0.5, 4/6, 1] + assert_array_almost_equal(norm(vals), normed_vals) + assert_array_almost_equal(norm.inverse(norm(vals)), vals) def test_SymLogNorm_colorbar(): From cc1fa93ecb7c002a98f1527b3d13fd4c73045964 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 3 Feb 2020 13:38:55 +0000 Subject: [PATCH 5/8] Add base docs --- lib/matplotlib/colors.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index de3d4b205c5f..d541604d3c4e 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1215,7 +1215,7 @@ class SymLogNorm(Normalize): """ def __init__(self, linthresh, linscale=1.0, vmin=None, vmax=None, clip=False, base=10): - """ + r""" Parameters ---------- linthresh : float @@ -1228,8 +1228,15 @@ def __init__(self, linthresh, linscale=1.0, example, when *linscale* == 1.0 (the default), the space used for the positive and negative halves of the linear range will be equal to one decade in the logarithmic range. - base : float - The base... + base : float, default: 10 + The base in which the number of decades in the log range is + calculated. For example, if ``base=2``, ``linthresh=2`` and + ``vmax=6``, the number of decades is :math:`\log_{2} (6 - 2) = 4`. + + Notes + ----- + Currently SymLogNorm only works with ``vmin == -vmax``, such that an + input of 0 always maps to half-way in the scale. """ if vmin is not None and vmax is not None and vmin != -vmax: raise ValueError('SymLogNorm only works for vmin = -vmax ' @@ -1281,7 +1288,8 @@ def _transform(self, a): # Transform log value sign = np.sign(a[logregion]) - log = (1 - self._linear_size) * self._logbase(np.abs(a[logregion])) + self._linear_size + log = ((1 - self._linear_size) * self._logbase(np.abs(a[logregion])) + + self._linear_size) a[logregion] = log * sign # Transform linear values From 890465499101391fcebe063dbabb14f79c132b7b Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 3 Feb 2020 13:45:03 +0000 Subject: [PATCH 6/8] Add API change --- .../prev_api_changes/api_changes_3.2.0/behavior.rst | 12 +++++++++++- lib/matplotlib/colors.py | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst b/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst index 0d8933fb363b..58528fe6dd4d 100644 --- a/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst +++ b/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst @@ -2,9 +2,19 @@ Behavior changes ---------------- +Fixed calculations in `.SymLogNorm` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The symmetrical log normalizer was previously returning erroneous values, so has +be re-written to give the correct behaviour. Aside from returning different +values: + +- The base in which the number of decades in the log range is calculated can + now be specified by the ``base`` keyword argument, which defaults to 10. +- The behaviour when passing both ``vmin`` and ``vmax`` which were not negative + of each other is ill-defined, so this has been disallowed. + Reduced default value of :rc:`axes.formatter.limits` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Changed the default value of :rc:`axes.formatter.limits` from -7, 7 to -5, 6 for better readability. diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index d541604d3c4e..cd58bd4f3537 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1231,7 +1231,7 @@ def __init__(self, linthresh, linscale=1.0, base : float, default: 10 The base in which the number of decades in the log range is calculated. For example, if ``base=2``, ``linthresh=2`` and - ``vmax=6``, the number of decades is :math:`\log_{2} (6 - 2) = 4`. + ``vmax=6``, the number of decades is :math:`\log_{2} (6 - 2) = 2`. Notes ----- From 3fb1dcff1f5f008169c56a95cc7ec866aa55577a Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 3 Feb 2020 13:56:20 +0000 Subject: [PATCH 7/8] Check a different base --- lib/matplotlib/colors.py | 2 +- lib/matplotlib/tests/test_colors.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index cd58bd4f3537..7906d21cda74 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1231,7 +1231,7 @@ def __init__(self, linthresh, linscale=1.0, base : float, default: 10 The base in which the number of decades in the log range is calculated. For example, if ``base=2``, ``linthresh=2`` and - ``vmax=6``, the number of decades is :math:`\log_{2} (6 - 2) = 2`. + ``vmax=8``, the number of decades is :math:`\log_{2} (8 / 2) = 2`. Notes ----- diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index a3a87274685e..75bddd496ec6 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -430,6 +430,14 @@ def test_symlognorm_vals(): assert_array_almost_equal(norm(vals), normed_vals) assert_array_almost_equal(norm.inverse(norm(vals)), vals) + # Now check a different base to base 10 + vals = [-8, 4, -2, 0, 2, 4, 8] + norm = mcolors.SymLogNorm(linthresh=2, vmax=8, linscale=1, base=2) + normed_vals = norm(vals) + expected = [0, 1/8, 2/8, 3/8, 0.5, 5/8, 6/8, 7/8, 1] + assert_array_almost_equal(norm(vals), normed_vals) + assert_array_almost_equal(norm.inverse(norm(vals)), vals) + def test_SymLogNorm_colorbar(): """ From 82052d8b83d349367146886528e6077fadad9dbb Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 3 Feb 2020 15:00:09 +0000 Subject: [PATCH 8/8] Fix color test --- lib/matplotlib/tests/test_colors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 75bddd496ec6..062f1dc9ae38 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -933,7 +933,7 @@ def __add__(self, other): for norm in [mcolors.Normalize(), mcolors.LogNorm(), mcolors.SymLogNorm(3, vmax=5, linscale=1), mcolors.Normalize(vmin=mydata.min(), vmax=mydata.max()), - mcolors.SymLogNorm(3, vmin=mydata.min(), vmax=mydata.max()), + mcolors.SymLogNorm(3, vmin=-10, vmax=10), mcolors.PowerNorm(1)]: assert_array_equal(norm(mydata), norm(data)) fig, ax = plt.subplots()