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

Skip to content

Enh arbitrary scale #12818

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

Merged
merged 1 commit into from
Jan 21, 2019
Merged

Enh arbitrary scale #12818

merged 1 commit into from
Jan 21, 2019

Conversation

jklymak
Copy link
Member

@jklymak jklymak commented Nov 15, 2018

PR Summary

As pointed out by @ImportanceOfBeingErnest in the SecondaryAxes PR (#11859 (comment)), there is probably call for an arbitrary axis scale, so, here you go.

import matplotlib.pyplot as plt
import matplotlib.scale as mscale
import numpy as np

fig, ax = plt.subplots()
ax.plot(range(1, 1000))

def forward(x):
    return x**2

def inverse(x):
    return x**(1/2)

ax.set_xscale('function', functions=(forward, inverse))
ax.set_xlim(1, 1000)
plt.show()

arbscale

PR Checklist

  • Has Pytest style unit tests
  • Code is Flake 8 compliant
  • New features are documented, with examples if plot related
  • Documentation is sphinx and numpydoc compliant
  • Added an entry to doc/users/next_whats_new/ if major new feature (follow instructions in README.rst there)
  • Documented in doc/api/api_changes.rst if API changed in a backward-incompatible way

@jklymak jklymak added this to the v3.1 milestone Nov 15, 2018
@jklymak jklymak mentioned this pull request Nov 15, 2018
5 tasks
@jklymak
Copy link
Member Author

jklymak commented Nov 15, 2018

Note this is also a first-step in solving issues like #12665 and #12808 for colorbars with non-standard norms. Though we will have a bit of an issue getting from a norm to a transform. My guess is that the API here will need to expand to allow a Transform with an inverse as the argument as well as the two-tuple. But I'll let others comment on the basic idea first.

self._inverse = inverse
else:
raise ValueError('arguments to ArbitraryTransform must '
'be functions.')
Copy link
Contributor

Choose a reason for hiding this comment

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

generally, exception messages have no final dot (applies throughout)

'be functions.')

def transform_non_affine(self, values):
with np.errstate(divide='ignore', invalid='ignore'):
Copy link
Contributor

Choose a reason for hiding this comment

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

I think setting the errstate should be the job of the function itself? (aka. let's assume that whoever passes arbitrary functions in knows what they're doing, or document that they should)

Copy link
Member Author

Choose a reason for hiding this comment

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

Sounds good to me. That was leftover from LogScale (I think), and I didn't quite grok what it was doing... I'll need to make an example that does the error checking though....

Copy link
Contributor

Choose a reason for hiding this comment

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

well, in logscale it effectively belongs to the user-defined (meaning by us) function


name = 'arbitrary'

def __init__(self, axis, functions=None):
Copy link
Contributor

@anntzer anntzer Nov 15, 2018

Choose a reason for hiding this comment

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

Just don't set a default to functions, as None isn't a valid value anyways?

Wonder whether the locator and formatter classes should be arguments too, i.e. I'd write something like

def __init__(self, axis, functions, *,
             major_locator_class=AutoLocator,
             minor_locator_class=NullLocator,
             major_formatter_class=ScalarFormatter,
             minor_formatter_class=NullFormatter):

I think you're also going to run into "interesting" issues in shared-axis handling in the implementation of cla(), which assumes that you can "copy" a scale by doing

            self.xaxis._scale = mscale.scale_factory(
                    self._sharex.xaxis.get_scale(), self.xaxis)

although it's not clear why the code doesn't just do

self.xaxis._scale = self._sharex.xaxis._scale

...

Copy link
Member Author

Choose a reason for hiding this comment

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

Thats a good idea!

Copy link
Member Author

Choose a reason for hiding this comment

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

So the problem w/ passing the class instead of an instance of the class is that the Locators and Formatters need to be passed arguments, so I think it makes more sense to do

     def __init__(self, axis, functions, *,
                  major_locator=AutoLocator(),
                  minor_locator=NullLocator(),
                  major_formatter=ScalarFormatter(),
                  minor_formatter=NullFormatter()):

Copy link
Contributor

Choose a reason for hiding this comment

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

But that won't work because you can't share a locator/formatter across multiple axes right now (as they keep a reference to their axis -- they probably shouldn't, but that's another in depth refactoring...).

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmmm, well, I guess folks will just have to change the locators and formatters manually

TODO

"""
if functions is None or len(functions) < 2:
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd probably just write forward, inverse = functions and get whatever error message you have when functions is not an iterable of len 2, rather than supplying your own error message. (After all, as of this commit passing a non-interable will also give you the builtin message.)

@anntzer
Copy link
Contributor

anntzer commented Nov 15, 2018

looks like you got an extra commit in there

Parameters
----------

forward: The forward function for the transform
Copy link
Member

Choose a reason for hiding this comment

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

Should be something like

forward : callable
    The forward function for the transform. It must have the signature::

        def forward(values: array-like) -> array-like

Are there any additional constraints? E.g. must the function be monotonic? Do we need more precise type descriptions?

Copy link
Member Author

Choose a reason for hiding this comment

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

Changed to

         forward: callable
             The forward function for the transform.  This function must have
             an inverse and, for best behavior, be monotonic. 
             It must have the signature::

                def forward(values: array-like) ->
                        array-likeThe forward function for the transform

         inverse: callable
            The inverse of the forward function.  Signature as ``forward``.

axis: the axis for the scale

functions: (forward, inverse)
two-tuple of the forward and inverse functions for the scale.
Copy link
Member

Choose a reason for hiding this comment

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

See ArbitraryTransform for details on the functions.

Copy link
Member Author

Choose a reason for hiding this comment

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

Actually this is more user facing so I changed this to have the more complete info as well. A bit repetitive, but...

@@ -545,6 +623,7 @@ def limit_range_for_scale(self, vmin, vmax, minpos):
'log': LogScale,
'symlog': SymmetricalLogScale,
'logit': LogitScale,
'arbitrary': ArbitraryScale,
Copy link
Member

Choose a reason for hiding this comment

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

It's a bit misleading that LogTransformBase can have an ArbitraryScale.

Copy link
Member Author

Choose a reason for hiding this comment

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

Not following this comment...

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks for the suggestions @timhoffm This was the only one I didn't understand...

Copy link
Member

@timhoffm timhoffm Nov 18, 2018

Choose a reason for hiding this comment

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

Sorry, I misread the code. Never mind.

@jklymak jklymak force-pushed the enh-arbitrary-scale branch from 1e2d23e to 8548e47 Compare November 18, 2018 16:20
@tacaswell
Copy link
Member

Only did a quick read, but 👍 to the functionality.

@anntzer
Copy link
Contributor

anntzer commented Nov 19, 2018

Minor preference for naming this FuncScale/set_scale("func", ...) consistently with FuncFormatter, and also quite a bit shorter than Arbitrary :)

@jklymak
Copy link
Member Author

jklymak commented Nov 25, 2018

OK< name changed to FuncScale and FuncTransform. We also now call axis.set_scale('function') rather than axis.set_scale('arbitrary').

This needs to be squashed before merging because I have an extra image in there, but want to keep the old commit in case someone wants me to go back to Arbitrary....

is_separable = True
has_inverse = True

def __init__(self, forward, inverse):
Copy link
Contributor

@anntzer anntzer Nov 26, 2018

Choose a reason for hiding this comment

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

Is there a reason why FuncTransform takes the two functions as separate args but FuncScale takes the pair as single arg? (Perhaps there is, didn't think more than that about it.)

Copy link
Member Author

Choose a reason for hiding this comment

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

Just because the kwarg gets passed in as a tuple, whereas to me it makes more sense for the transform to take them separately. But I don't feel very strongly about it.

axis.set_minor_formatter(NullFormatter())
# update the minor locator for x and y axis based on rcParams
if rcParams['xtick.minor.visible']:
axis.set_minor_locator(AutoMinorLocator())

Choose a reason for hiding this comment

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

Can you incoorporate the fix from #12938 here as well?

Copy link
Member Author

Choose a reason for hiding this comment

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

Sorry for the delay - Done!

@jklymak jklymak force-pushed the enh-arbitrary-scale branch from 99055d4 to c9984fe Compare January 3, 2019 17:49
return np.rad2deg(
np.ma.log(np.abs(np.ma.tan(masked) + 1.0 / np.ma.cos(masked))))
else:
return np.rad2deg(np.log(np.abs(np.tan(a) + 1.0 / np.cos(a))))
Copy link
Contributor

@anntzer anntzer Jan 3, 2019

Choose a reason for hiding this comment

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

Can you just rely on this returning nan for invalid values? (possibly wrap in with np.errstate(invalid="ignore") to silence warnings)
At least the test below returns nan when square-rooting negative values.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is just an example, right? More sophisticated users (than me) can add any error catching they want if they want to implement this...

Copy link
Contributor

Choose a reason for hiding this comment

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

My point is that

# Function Mercator transform
def forward(a):
    a = np.deg2rad(a)
    return np.rad2deg(np.log(np.abs(np.tan(a) + 1 / np.cos(a))))

is much shorter, and basically works just as well (AFAICT...)

Copy link
Member Author

Choose a reason for hiding this comment

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

Sure, probably. I just stole the code from the custom_scale.py example to show that it was directly transferable. But I simplified in the latest commit.

@jklymak jklymak force-pushed the enh-arbitrary-scale branch from 5ce3637 to 2ca2865 Compare January 16, 2019 17:07
good = x >= 0
y = np.full_like(x, np.NaN)
y[good] = x[good]**(1/2)
return y
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess in the same vein something as simple as

def forward(x): return x**(1/2)

also works (apparently it does in the scales.py example above...)? In which case you can probably even just pass them as lambdas below...

Copy link
Member Author

Choose a reason for hiding this comment

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

Sure, they can be lambdas, but I don't find that easier to read than just defining the functions.... But main point taken...

@dstansby
Copy link
Member

My immediate thought on this is what happens if inverse isn't actually the inverse of forward?

@jklymak
Copy link
Member Author

jklymak commented Jan 16, 2019

My immediate thought on this is what happens if inverse isn't actually the inverse of forward?

Garbage in, garbage out?

@jklymak jklymak force-pushed the enh-arbitrary-scale branch from 2ca2865 to 6220a18 Compare January 16, 2019 21:23
@@ -1,6 +1,8 @@
from matplotlib.testing.decorators import image_comparison
import matplotlib.pyplot as plt
from matplotlib.scale import Log10Transform, InvertedLog10Transform
import matplotlib.ticker as mticker
Copy link
Contributor

Choose a reason for hiding this comment

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

the import is not actually needed?

Both functions must have the signature::

def forward(values: array-like) ->
array-likeThe forward function for the transform
Copy link
Contributor

Choose a reason for hiding this comment

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

something wrong with the formatting

Copy link
Member Author

Choose a reason for hiding this comment

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

oooops. Should be

            Both functions must have the signature::

               def forward(values: array-like) returns array-like

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually

def forward(values: array-like) -> array-like

looks best to me (it's nearly normal type annotation syntax), but not insisting on that.

@jklymak jklymak force-pushed the enh-arbitrary-scale branch 2 times, most recently from 19a2475 to bb16ae4 Compare January 17, 2019 00:36
It must have the signature::

def forward(values: array-like) ->
array-likeThe forward function for the transform
Copy link
Contributor

Choose a reason for hiding this comment

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

here too

Copy link
Member Author

Choose a reason for hiding this comment

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

Goops, sorry, should have checked the whole thing

@jklymak jklymak force-pushed the enh-arbitrary-scale branch from bb16ae4 to bc91f3e Compare January 17, 2019 00:49
def forward(values: array-like) -> array-like

inverse: callable
The inverse of the forward function. Signature as ``forward``.
Copy link
Contributor

Choose a reason for hiding this comment

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

This paragraph is indented one character less than the one for forward.

Also the type annotations actually also use a space before the colon (see rst syntax for definition lists, and numpy/numpydoc#78).

Likewise for the docstring below.

API: add ability to set formatter and locators from scale init
FIX: rename to FuncScale
DOC: add whats new
FIX: simplify Mercator transform
TST: simplify test
@jklymak jklymak force-pushed the enh-arbitrary-scale branch from bc91f3e to 9c74f77 Compare January 17, 2019 17:29
@jklymak
Copy link
Member Author

jklymak commented Jan 21, 2019

ping on this one - I think I've addressed all review suggestions.... Thanks!

@anntzer
Copy link
Contributor

anntzer commented Jan 21, 2019

The 3.7 failure is spurious.

@anntzer anntzer merged commit c22847b into matplotlib:master Jan 21, 2019
@jklymak
Copy link
Member Author

jklymak commented Jan 22, 2019

Thanks a lot for reviewing and merging!

@jklymak jklymak deleted the enh-arbitrary-scale branch January 22, 2019 18:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants