8000 ENH: add Wright's generalized Bessel function by lorentzenchr · Pull Request #11313 · scipy/scipy · GitHub
[go: up one dir, main page]

Skip to content

ENH: add Wright's generalized Bessel function #11313

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 52 commits into from
Feb 6, 2021

Conversation

lorentzenchr
Copy link
Contributor
@lorentzenchr lorentzenchr commented Jan 3, 2020

Reference issue

This function is one crucial step towards #11291. See also mailing list thread https://mail.python.org/pipermail/scipy-dev/2020-January/023918.html.

What does this implement/fix?

This PR adds Wright's generalized Bessel function as scipy.special.wright_bessel, see https://dlmf.nist.gov/10.46.E1.

Additional information.

This is a pure Python implementation that aims for correctness and not for speed. I did not find any public implementation as a reference.
As it is an entire function, the series representation is always valid. I tried asymptotic expansions for large argument z, without any luck.

Edit:
So far, only non-negative values of rho=a, beta=b and z=x are implemented. There are 5 different approaches depending on the ranges of the arguments.

  1. Taylor series expansion in x=0 [1], for x <= 1.
    Involves gamma funtions in each term.
  2. Taylor series expansion in x=0 [2], for large a.
  3. Taylor series in a=0, for tiny a and not too large x.
  4. Asymptotic expansion for large x [3, 4].
    Suitable for large x while still small a and b.
  5. Integral representation [5], in principle for all arguments.

References:

@rgommers rgommers added enhancement A new feature or improvement scipy.special labels Jan 3, 2020
@person142
Copy link
Member

I will note that a pure Python implementation is basically a nonstarter in special. Probably best to discuss this on the mailing list before proceeding further here.

@rlucas7
Copy link
Member
rlucas7 commented Jan 4, 2020

@lorentzenchr this will need unit tests. Given that this calls scipy.special.iv() it might help to take a look at the file here:
https://github.com/scipy/scipy/blob/fdf4cd83e0a80a083da3790453a12862b3460793/scipy/special/tests/test_basic.py
and follow a similar format, in fact it probably makes sense to add your test(s) to that file.

You'll want to consider typical case inputs as well as Nan and inf inputs to the function in your tests (and code).

@rlucas7
Copy link
Member
rlucas7 commented Jan 4, 2020

I will note that a pure Python implementation is basically a nonstarter in special. Probably best to discuss this on the mailing list before proceeding further here.

I Agree.

@lorentzenchr
Copy link
Contributor Author

@rlucas7 I'm happy to add tests once it is settled that this PR is not for nothing.

@person142 @rlucas7 Why is a pure python implementation a nonstarter?

My plan:

  • Start discussion on scipy dev mailing list
  • Make pure python implementation sound and ready
  • Cythonize (maybe someone can assist me here, i.e. needs to call scipy.special.rgamma in loop)

@person142
Copy link
Member
person142 commented Jan 4, 2020

Why is a pure python implementation a nonstarter?

Main reasons are:

  • It's too slow

  • Lack of typing forces too much boilerplate, e.g. I'm seeing things like this in the
    implementation:

    b = 1. * b  # make it at least a float
    
  • Conditional logic is very painful to express when operating on arrays, writing scalar kernels is much clearer

  • With all the ufunc machinery we have it's not significantly harder to write it in C/Cython/Fortran (I might even claim it's easier.)

@lorentzenchr
Copy link
Contributor Author

@person142 Thank you very much for your explanations!
I have one good reason against a scalar kernel: speed. The kth element of the series expansion has rgamma(a*k+b). This is independent of z. I expect the input a and b to be scalar, but z may be an array. Therefore, one can reuse the values of rgamma for all z. As rgamma is the heaviest element per term, this gives a great speedup for array input z.

@person142
Copy link
Member

I expect the input a and b to be scalar, but z may be an array.

So this is where we might want to keep the end goal in mind-if we add this as a public function in special then it's going to have to be a ufunc (for API consistency reasons), so a and b could also be arrays.

On the other hand, if we say "this is just for getting at the Tweedie distributions, where a and b will be scalars", then we can think about instead making this a private function in special (we do this pretty frequently already), which frees us from the constraints of the public special API.

I was trying to get at something similar in my response on the mailing list:

  • public function in special: lots of extra work
  • private function in special: much more flexibility, hopefully leading to less work.

@lorentzenchr
Copy link
Contributor Author

🤔 Right now, I like the private special function.

@lorentzenchr
Copy link
Contributor Author

@person142 Meanwhile, I think this could start as a private function with limited range for arguments, e.g. all greater equal 0, but still Cython so that it would be easier to make it public.

Currently, I'm working on a stable form for 0<z<=1. Large z seems to be challenging.

8000

@rlucas7
Copy link
Member
rlucas7 commented Jan 11, 2020 via email

@lorentzenchr
Copy link
Contributor Author

Are you working towards support for complex values ? If so, I’d recommend focus on real values only. Tweedie distribution is real valued and I haven’t seen any references to any complex values applications.

Exactly, for the moment all values real, and even positive: (a, b, x) >= 0. Makes it a lot easier.

@WarrenWeckesser
Copy link
Member

For what it's worth, here are some functions that use mpmath. wright_bessel relies on mpmath.nsum to compute an approximation to the infinite series, so it suffers from whatever limitation mpmath.nsum has. The function wright_bessel_rho1 is an alternative that uses the connection between the function and modified Bessel function when rho is 1.

import mpmath


def _wright_bessel_term(k, z, rho, beta): 
   numer = mpmath.power(z, k)
   denom = mpmath.gamma(k*rho + beta)*mpmath.factorial(k)
   return numer / denom


def wright_bessel(z, rho, beta):
    """
    Wright's generalized Bessel function.

    Also known as the Bessel-Maitland function.

    Parameter convention corresponds to

        https://dlmf.nist.gov/10.46#E1

    but here the `z` parameter is given first.
    """
    if z == 0:
        return 1 / mpmath.gamma(beta)
    return mpmath.nsum(lambda k: _wright_bessel_term(k, z, rho, beta),
                       [0, mpmath.inf])


def wright_bessel_rho1(x, beta):
    """
    Wright's generalized Bessel function.

    Also known as the Bessel-Maitland function.

    Parameter convention corresponds to

        https://dlmf.nist.gov/10.46#E1

    but here the `x` parameter is given first, and rho is fixed at 1.
    `x` is assumed to be real.
    """
    beta = mpmath.mpf(beta)
    nu = beta - 1
    if x > 0:
        r = mpmath.sqrt(x)
        w = mpmath.besseli(nu, 2*r) / mpmath.power(r, nu)
    elif x < 0:
        r = mpmath.sqrt(-x)
        w = mpmath.besselj(nu, 2*r) / mpmath.power(r, nu)
    else:
        # x == 0
        w = 1 / mpmath.gamma(beta)
    return w

For example,

In [123]: import mpmath

In [124]: mpmath.mp.dps = 40

In [125]: z = 0.9

In [126]: wright_bessel(z, 1.0, 0.4)
Out[126]: mpf('1.834786964889907297648075016830869147931931')

In [127]: wright_bessel_rho1(z, 0.4)
Out[127]: mpf('1.834786964889907297648075016830869147931931')

In [128]: z = -19.0

In [129]: wright_bessel(z, 1.0, 0.4)
Out[129]: mpf('-0.5596387413411752048288086838416299778273557')

In [130]: wright_bessel_rho1(z, 0.4)
Out[130]: mpf('-0.5596387413411752048288086838416299778273213')

@lorentzenchr
Copy link
Contributor Author

@WarrenWeckesser Thank you for the MPMATH examples. When I played around with my mpmath implementation, I noticed that nsum tries to find convergence every 10 steps. Therefore, I most often set steps=[100].

@person142
Copy link
Member

Large z seems to be challenging.

@lorentzenchr yeah, an asymptotic series will almost surely be needed. Feel free to ping me about implementation details if it's not something you're familiar with. Other than that, let me know when this is ready for more review. I'm going to add the "needs work" label so that it stays off my review queue until then.

@person142 person142 added the needs-work Items that are pending response from the author label Jan 13, 2020
@lorentzenchr
Copy link
Contributor Author

@person142 Thanks for your guidance.
For my use case, it suffices to implement non-negative arguments only wright_bessel(a, b, x). Going for this I'm making progress. There are the typical 3 main domains for this functions:

  • small x where one can use the Taylor series (so far implemented for x<=1)
  • intermediate x where an integral representation exists, see paper but be aware of misprints. (I have a working implementation, not pushed yet.)
  • large x where an asymptotic series makes sense, see Wright 1935 and Paris 2017 (I have a working implementation, not pushed yet.)

I have 4 questions right now:

  1. We intend to add this function as an internal function. Which precision should I aim for? Only the taylor series gets around 1e-15.
  2. How to best choose the limits of the domains?
  3. Is it ok to use scipy.integrate.quad for the intermediate x-domain? Possibly in Cython?
  4. How to write tests (except for special, known arguments), e.g. against values from a mpmath implementation? This function has 3 arguments, so a data grid gets large very quickly.

Would I better address such questions on the mailing list?

@person142
Copy link
Member

@lorentzenchr

Which precision should I aim for?

Ideally relative error < 1e-13, but we sometimes go as low as 1e-7. After that we tend to intentionally start returning nans. Around the zeros of a function (where the condition number is high) it's ok to lose relative accuracy. It might be sensible here to restrict the parameters to some box where accurate computation is possible and raise an exception on the stats side if those limits are exceeded.

How to best choose the limits of the domains?

That's always a tough question. Sometimes you can prove things about rates of convergence, and that informs what domain you can use. See e.g.

https://github.com/scipy/scipy/blob/master/scipy/special/_hypergeometric.pxd#L65

for an example of that. Sometimes you compute across a wide range of parameters using all methods, plot where the accuracy is high for each method, and use that to figure out your regions. See e.g.

https://github.com/scipy/scipy/blob/master/scipy/special/_precompute/struve_convergence.py

for an example of that. Another technique is computing with multiple methods at runtime and keeping running error estimates, and then returning the result that has the lowest error estimate. Obviously this one really slows down the computation, so it should be more of a last resort.

Is it ok to use scipy.integrate.quad for the intermediate x-domain

Using quad is generally a bad idea because adaptive quadrature is fairly slow. Ideally you pick e.g. a Gaussian quadrature method and precompute the points/weights that will give you the desired precision. (Maybe using a paneling technique where you split the domain into multiple pieces and use Gaussian quadtrature on each piece.) Picking the right quadrature method can be an entire task unto itself.

This function has 3 arguments, so a data grid gets large very quickly.

Some tricks here:

  • Write tests specifically for the boundaries between methods. Things often go wrong in those regions.
  • Precompute the Mpmath values and store the results in a data file (we have a whole setup for this). Since Mpmath is often the limiting factor in the test this lets you make the grid bigger.
  • For when the grid really is just too big: switch to random sampling.

- add 5th order in a

- use polevl

- use Horner's scheme for polynomials in x

- precompute constants C[..] numerically

- better choosen order parameter

- better range of validity

- added tests for new range limits
- test more data points

- print failing data points in  _precompute/wright_bessel_data.py
- fine tuning for choice of eps in integral representation

- higher bound for use of asymptotic expansion meaning the integral 
representation is used more often
@lorentzenchr
Copy link
Contributor Author
lorentzenchr commented Oct 15, 2020

@person142 I addressed all of your comments, extended tests a lot and made improvements for extreme arguments. Open to me is, probably, to return more np.nan for very difficult arguments.

To have automatic Cython interface, as you proposed, the function name is now without leading _. As a consequence, it is now a public special function.

The failing test is about too long lines in special/__init__.py (of which there were already several).
Side question: Where to put it? A separate paragraph or under Bessel functions?

@person142
Copy link
Member

Where to put it? A separate paragraph or under Bessel functions?

I'd put it in the Bessel functions section.

@lorentzenchr
Copy link
Contributor Author

Where to put it? A separate paragraph or under Bessel functions?

I'd put it in the Bessel functions section.

There it is at the moment.

@lorentzenchr
Copy link
Contributor Author

@WarrenWeckesser @person142 May I kindly ping you.

Copy link
Member
@person142 person142 left a comment

Choose a reason for hiding this comment

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

Probably the best thing to do is merge and see what happens in the wild.

I'll leave it open for a few days in case someone else wants to comment; ping me again in a few days if I haven't merged it by then. (Looks like a lint error to fix in the meantime.)

@lorentzenchr
Copy link
Contributor Author

@person142 Gentle ping.

@person142
Copy link
Member
person142 commented Feb 5, 2021

The lint failure is real I think-note that it's running a stricter linting check on just your diff (we added that so that we could converge towards cleaner code while not having big refactoring PRs).

@lorentzenchr
Copy link
Contributor Author

All green again.

@person142 person142 merged commit 8fceb48 into scipy:master Feb 6, 2021
@person142
Copy link
Member

Awesome, in it goes. Thanks for sticking with it @lorentzenchr!

@lorentzenchr
Copy link
Contributor Author

@person142 Hitting the merge button for a +5000 line diff takes some courage/trust:smirk: I hope this function will help in a few places in the ecosytem around scipy.

@rgommers
Copy link
Member
rgommers commented Feb 6, 2021

Awesome! @lorentzenchr I added an entry in https://github.com/scipy/scipy/wiki/Release-note-entries-for-SciPy-1.7.0#scipyspecial-improvements, if there's more to say please feel free to edit.

@tylerjereddy tylerjereddy added this to the 1.7.0 milestone Feb 6, 2021
@WarrenWeckesser
Copy link
Member
WarrenWeckesser commented Mar 22, 2021

Would anyone be interested in adding an enlightening example or two to the wright_bessel docstring? We would like to ensure that the number of functions without examples in their docstrings goes down over time (see #7168).

10000

@lorentzenchr lorentzenchr deleted the wright_function branch March 24, 2021 16:13
@lorentzenchr
Copy link
Contributor Author

@WarrenWeckesser I opened #13738 and hope that this is what you had in mind with enlightening.:smirk:

@lorentzenchr
Copy link
Contributor Author

In case someone is interested, I wrote a blog post about this function with a focus on the integral representation and the magic with the free parameter: https://lorentzen.ch/index.php/2024/06/17/a-tweedie-trilogy-part-iii-from-wrights-generalized-bessel-function-to-tweedies-compound-poisson-distribution/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement A new feature or improvement scipy.special
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants
0