Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Re-write sym-log-norm #16391

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
92 changes: 55 additions & 37 deletions lib/matplotlib/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -1214,8 +1214,8 @@ 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):
r"""
Parameters
----------
linthresh : float
Expand All @@ -1228,12 +1228,34 @@ 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, 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=8``, the number of decades is :math:`\log_{2} (8 / 2) = 2`.

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 '
f'(got vmin={vmin}, vmax={vmax})')
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()
self.base = float(base)
self.linscale = float(linscale)

@property
def _linear_size(self):
'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)

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:
Expand All @@ -1254,57 +1276,53 @@ 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) * self._logbase(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 = 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
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):
"""
Expand Down
44 changes: 35 additions & 9 deletions lib/matplotlib/tests/test_colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,22 +395,48 @@ def test_TwoSlopeNorm_premature_scaling():


def test_SymLogNorm():
"""
Test SymLogNorm behavior
"""
norm = mcolors.SymLogNorm(3, vmax=5, linscale=1.2)
# Test SymLogNorm behavior
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]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these values were just plain wrong before...

  • The first value (-30) is less than vmax (5), so should come out as less than zero
  • The second value (-1) is less than 0, so should come out as less than 0.5

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)

def test_symlognorm_vals():
vals = [-10, -1, 0, 1, 10]

norm = mcolors.SymLogNorm(linthresh=1, vmin=-10, vmax=10, linscale=1)
normed_vals = norm(vals)
assert_array_almost_equal(normed_vals, expected)
expected = [0, 0.25, 0.5, 0.75, 1]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is consistent with SymmetricLogTransform yet. I'm OK if we want to change that as well to be consistent with this code, but they can't be inconsistent!

import matplotlib.scale as mscale

trans = mscale.SymmetricalLogTransform(10, 1, 1)
new = trans.transform([-10, -1, 0, 1, 10])
new = (new - new[0]) / (new[-1] - new[0])
print(new)
[0.0 0.23684210526315788 0.5 0.7631578947368421 1.0]

I'm fine if this implementation is desired, but then we need to change SymmetricLogTransform.

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)

# 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():
Expand Down Expand Up @@ -907,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()
Expand Down