388 lines
15 KiB
Python
388 lines
15 KiB
Python
![]() |
import numpy as np
|
||
|
|
||
|
from scipy._lib._array_api import (
|
||
|
array_namespace, xp_ravel, xp_copy, xp_promote
|
||
|
)
|
||
|
import scipy._lib._elementwise_iterative_method as eim
|
||
|
from scipy._lib._util import _RichResult
|
||
|
from scipy import special
|
||
|
|
||
|
# Todo:
|
||
|
# Avoid special-casing key 'n' in _lib._elementwise_iterative_method::_check_termination
|
||
|
# Rearrange termination condition to allow absolute and relative tolerances?
|
||
|
# Interpret/return |f_n - f_{n-1}| as an error estimate?
|
||
|
# Return gracefully for size=0 arrays
|
||
|
|
||
|
def _logaddexp(x, y, xp=None):
|
||
|
# logaddexp that supports complex numbers
|
||
|
xp = array_namespace(x, y) if xp is None else xp
|
||
|
x, y = xp.broadcast_arrays(x, y)
|
||
|
xy = xp.stack((x, y), axis=0)
|
||
|
return special.logsumexp(xy, axis=0)
|
||
|
|
||
|
|
||
|
def _continued_fraction_iv(a, b, args, tolerances, maxiter, log):
|
||
|
# Input validation for `_continued_fraction`
|
||
|
|
||
|
if not callable(a) or not callable(b):
|
||
|
raise ValueError('`a` and `b` must be callable.')
|
||
|
|
||
|
if not np.iterable(args):
|
||
|
args = (args,)
|
||
|
|
||
|
# Call each callable once to determine namespace and dtypes
|
||
|
a0, b0 = a(0, *args), b(0, *args)
|
||
|
xp = array_namespace(a0, b0, *args)
|
||
|
a0, b0, *args = xp_promote(a0, b0, *args, force_floating=True, broadcast=True,
|
||
|
xp=xp)
|
||
|
shape, dtype = a0.shape, a0.dtype
|
||
|
a0, b0, *args = (xp_ravel(arg) for arg in (a0, b0) + tuple(args))
|
||
|
|
||
|
tolerances = {} if tolerances is None else tolerances
|
||
|
eps = tolerances.get('eps', None)
|
||
|
tiny = tolerances.get('tiny', None)
|
||
|
|
||
|
# tolerances are floats, not arrays, so it's OK to use NumPy
|
||
|
message = ('`eps` and `tiny` must be (or represent the logarithm of) '
|
||
|
'finite, positive, real scalars.')
|
||
|
tols = np.asarray([eps if eps is not None else 1,
|
||
|
tiny if tiny is not None else 1])
|
||
|
not_real = (not np.issubdtype(tols.dtype, np.number)
|
||
|
or np.issubdtype(tols.dtype, np.complexfloating))
|
||
|
not_positive = np.any(tols <= 0) if not log else False
|
||
|
not_finite = not np.all(np.isfinite(tols))
|
||
|
not_scalar = tols.shape != (2,)
|
||
|
if not_real or not_positive or not_finite or not_scalar:
|
||
|
raise ValueError(message)
|
||
|
|
||
|
maxiter_int = int(maxiter)
|
||
|
if maxiter != maxiter_int or maxiter < 0:
|
||
|
raise ValueError('`maxiter` must be a non-negative integer.')
|
||
|
|
||
|
if not isinstance(log, bool):
|
||
|
raise ValueError('`log` must be boolean.')
|
||
|
|
||
|
return a, b, args, eps, tiny, maxiter, log, a0, b0, shape, dtype, xp
|
||
|
|
||
|
|
||
|
def _continued_fraction(a, b, *, args=(), tolerances=None, maxiter=100, log=False):
|
||
|
r"""Evaluate a generalized continued fraction numerically.
|
||
|
|
||
|
`_continued_fraction` iteratively evaluates convergents of a continued fraction
|
||
|
given coefficients returned by callables `a` and `b`. Iteration terminates when
|
||
|
`maxiter` terms have been evaluated or a termination criterion controlled by
|
||
|
`tolerances` is satisfied, and the final convergent is returned as the ``f``
|
||
|
attribute of the result object.
|
||
|
|
||
|
This function works elementwise when `args` contains (broadcastable) arrays.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
a, b: callable
|
||
|
Functions that provide the *numerator* and *denominator* coefficients of
|
||
|
the continued fraction, respectively.
|
||
|
|
||
|
The signature of each must be::
|
||
|
|
||
|
a(n: int, *argsj) -> ndarray
|
||
|
|
||
|
where ``n`` is the coefficient number and ``argsj`` is a tuple, which may
|
||
|
contain an arbitrary number of arrays of any shape. `a` and `b` must be
|
||
|
elementwise functions: each scalar element ``a(n, *argsj)[i]`` must equal
|
||
|
``a(n, *[argj[i] for argj in argsj])`` for valid indices ``i``.
|
||
|
`a` and `b` must not mutate the arrays in ``argsj``.
|
||
|
|
||
|
The result shape is the broadcasted shape of ``a(0, *args)`` and
|
||
|
``b(0, *args)``. The dtype used throughout computation is the result dtype
|
||
|
of these terms if it is a float, and the default float of the array library
|
||
|
otherwise. The numerical value of ``a(0, *args)`` is ignored, and
|
||
|
the value of the leading term ``b(0, *args)`` is the so-called "integer"
|
||
|
part of the continued fraction (although it need not be integral).
|
||
|
|
||
|
args : tuple of array_like, optional
|
||
|
Additional positional *array* arguments to be passed to `a` and `b`. Arrays
|
||
|
must be broadcastable with one another. If the coefficient callables
|
||
|
require additional arguments that are not broadcastable with one
|
||
|
another, wrap them with callables `a` and `b` such that `a` and `b` accept
|
||
|
only ``n`` and broadcastable array arguments.
|
||
|
tolerances : dictionary of floats, optional
|
||
|
Tolerances and numerical thresholds used by the algorithm. Currently,
|
||
|
valid keys of the dictionary are:
|
||
|
|
||
|
- ``eps`` - the convergence threshold of Lentz' algorithm
|
||
|
- ``tiny`` - not strictly a "tolerance", but a very small positive number
|
||
|
used to avoid division by zero
|
||
|
|
||
|
The default `eps` is the precision of the appropriate dtype, and the default
|
||
|
`tiny` is the precision squared. [1]_ advises that ``eps`` is "as small as
|
||
|
you like", but for most purposes, it should not be set smaller than the default
|
||
|
because it may prevent convergence of the algorithm. [1]_ also advises that
|
||
|
``tiny`` should be less than typical values of ``eps * b(n)``, so the default
|
||
|
is a good choice unless the :math:`b_n` are very small. See [1]_ for details.
|
||
|
maxiter : int, default: 100
|
||
|
The maximum number of iterations of the algorithm to perform.
|
||
|
log : bool, default: False
|
||
|
If True, `a` and `b` return the (natural) logarithm of the terms, `tolerances`
|
||
|
contains the logarithm of the tolerances, and the result object reports the
|
||
|
logarithm of the convergent.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
res : _RichResult
|
||
|
An object similar to an instance of `scipy.optimize.OptimizeResult` with the
|
||
|
following attributes. The descriptions are written as though the values will
|
||
|
be scalars; however, if `f` returns an array, the outputs will be
|
||
|
arrays of the same shape.
|
||
|
|
||
|
success : bool array
|
||
|
``True`` where the algorithm terminated successfully (status ``0``);
|
||
|
``False`` otherwise.
|
||
|
status : int array
|
||
|
An integer representing the exit status of the algorithm.
|
||
|
|
||
|
- ``0`` : The algorithm converged to the specified tolerances.
|
||
|
- ``-2`` : The maximum number of iterations was reached.
|
||
|
- ``-3`` : A non-finite value was encountered.
|
||
|
|
||
|
f : float array
|
||
|
The convergent which satisfied a termination criterion.
|
||
|
nit : int array
|
||
|
The number of iterations of the algorithm that were performed.
|
||
|
nfev : int array
|
||
|
The number of terms that were evaluated.
|
||
|
|
||
|
Notes
|
||
|
-----
|
||
|
A generalized continued fraction is an expression of the form
|
||
|
|
||
|
.. math::
|
||
|
|
||
|
b_0 + \frac{a_1}{b_1 + \frac{a_2}{b_2 + \frac{a_3}{b_3 + \cdots}}}
|
||
|
|
||
|
Successive "convergents" approximate the infinitely recursive continued fraction
|
||
|
with a finite number of terms :math:`a_n` and :math:`b_n`, which are provided
|
||
|
by callables `a` and `b`, respectively. This implementation follows the modified
|
||
|
Lentz algorithm ([1]_, [2]_) to evaluate successive convergents until a
|
||
|
termination condition is satisfied.
|
||
|
|
||
|
References
|
||
|
----------
|
||
|
.. [1] Press, William H., and Saul A. Teukolsky. "Evaluating continued fractions
|
||
|
and computing exponential integrals." Computers in Physics 2.5 (1988): 88-89.
|
||
|
.. [2] Lentz's algorithm. Wikipedia.
|
||
|
https://en.wikipedia.org/wiki/Lentz%27s_algorithm
|
||
|
.. [3] Continued fraction. Wikipedia.
|
||
|
https://en.wikipedia.org/wiki/Continued_fraction
|
||
|
.. [4] Generalized continued fraction. Wikipedia.
|
||
|
https://en.wikipedia.org/wiki/Generalized_continued_fraction
|
||
|
|
||
|
Examples
|
||
|
--------
|
||
|
The "simple continued fraction" of :math:`\pi` is given in [3]_ as
|
||
|
|
||
|
.. math::
|
||
|
|
||
|
3 + \frac{1}{7 + \frac{1}{15 + \frac{1}{1 + \cdots}}}
|
||
|
|
||
|
where the :math:`b_n` terms follow no obvious pattern:
|
||
|
|
||
|
>>> b = [3, 7, 15, 1, 292, 1, 1, 1, 2, 1, 3, 1]
|
||
|
|
||
|
and the :math:`a_n` terms are all :math:`1`.
|
||
|
In this case, all the terms have been precomputed, so we call `_continued_fraction`
|
||
|
with simple callables which simply return the precomputed coefficients:
|
||
|
|
||
|
>>> import numpy as np
|
||
|
>>> from scipy.special._continued_fraction import _continued_fraction
|
||
|
>>> res = _continued_fraction(a=lambda n: 1, b=lambda n: b[n], maxiter=len(b) - 1)
|
||
|
>>> (res.f - np.pi) / np.pi
|
||
|
np.float64(7.067899292141148e-15)
|
||
|
|
||
|
A generalized continued fraction for :math:`\pi` is given by:
|
||
|
|
||
|
.. math::
|
||
|
|
||
|
3 + \frac{1^2}{6 + \frac{3^2}{6 + \frac{5^2}{6 + \cdots}}}
|
||
|
|
||
|
We define the coefficient callables as:
|
||
|
|
||
|
>>> def a(n):
|
||
|
... return (2*n - 1)**2
|
||
|
>>>
|
||
|
>>> def b(n):
|
||
|
... if n == 0:
|
||
|
... return 3
|
||
|
... else:
|
||
|
... return 6
|
||
|
|
||
|
Then the continued fraction can be evaluated as:
|
||
|
|
||
|
>>> res = _continued_fraction(a, b)
|
||
|
>>> res
|
||
|
success: False
|
||
|
status: -2
|
||
|
f: 3.1415924109719846
|
||
|
nit: 100
|
||
|
nfev: 101
|
||
|
|
||
|
Note that the requested tolerance was not reached within the (default)
|
||
|
maximum number of iterations because it converges very slowly.
|
||
|
An expression that converges more rapidly is expressed as the difference
|
||
|
between two continued fractions. We will compute both of them in one
|
||
|
vectorized call to `_continued_fraction`.
|
||
|
|
||
|
>>> u, v = 5, 239
|
||
|
>>>
|
||
|
>>> def a(n, a1, _):
|
||
|
... # The shape of the output must be the shape of the arguments
|
||
|
... shape = a1.shape
|
||
|
... if n == 0:
|
||
|
... return np.zeros(shape)
|
||
|
... elif n == 1:
|
||
|
... return a1
|
||
|
... else:
|
||
|
... return np.full(shape, (n-1)**2)
|
||
|
>>>
|
||
|
>>> def b(n, _, uv):
|
||
|
... shape = uv.shape
|
||
|
... if n == 0:
|
||
|
... return np.zeros(shape)
|
||
|
... return np.full(shape, (2*n - 1)*uv)
|
||
|
>>>
|
||
|
>>> res = _continued_fraction(a, b, args=([16, 4], [u, v]))
|
||
|
>>> res
|
||
|
success: [ True True]
|
||
|
status: [0 0]
|
||
|
f: [ 3.158e+00 1.674e-02]
|
||
|
nit: [10 4]
|
||
|
nfev: [11 5]
|
||
|
|
||
|
Note that the second term converged in fewer than half the number of iterations
|
||
|
as the first. The approximation of :math:`\pi` is the difference between the two:
|
||
|
|
||
|
>>> pi = res.f[0] - res.f[1]
|
||
|
>>> (pi - np.pi) / np.pi
|
||
|
np.float64(2.8271597168564594e-16)
|
||
|
|
||
|
If it is more efficient to compute the :math:`a_n` and :math:`b_n` terms together,
|
||
|
consider instantiating a class with a method that computes both terms and stores
|
||
|
the results in an attribute. Separate methods of the class retrieve the
|
||
|
coefficients, and these methods are passed to `_continued_fraction` as arguments
|
||
|
`a` and `b`. Similarly,if the coefficients can be computed recursively in terms of
|
||
|
previous coefficients, use a class to maintain state between callable evaluations.
|
||
|
|
||
|
"""
|
||
|
|
||
|
res = _continued_fraction_iv(a, b, args, tolerances, maxiter, log)
|
||
|
a, b, args, eps, tiny, maxiter, log, a0, b0, shape, dtype, xp = res
|
||
|
callback = None # don't want to test it, but easy to add later
|
||
|
|
||
|
# The EIM framework was designed for the case in where there would
|
||
|
# be only one callable, and all arguments of the callable would be
|
||
|
# arrays. We're going a bit beyond that here, since we have two callables,
|
||
|
# and the first argument is an integer (the number of the term). Rather
|
||
|
# than complicate the framework, we wrap the user-provided callables to
|
||
|
# make this problem fit within the existing framework.
|
||
|
|
||
|
def a(n, *args, a=a):
|
||
|
n = int(xp.real(xp_ravel(n))[0])
|
||
|
return a(n, *args)
|
||
|
|
||
|
def b(n, *args, b=b):
|
||
|
n = int(xp.real(xp_ravel(n))[0])
|
||
|
return b(n, *args)
|
||
|
|
||
|
def func(n, *args):
|
||
|
return xp.stack((a(n, *args), b(n, *args)), axis=-1)
|
||
|
|
||
|
status = xp.full_like(a0, eim._EINPROGRESS, dtype=xp.int32) # in progress
|
||
|
nit, nfev = 0, 1 # one function evaluation (per function) performed above
|
||
|
maxiter = 100 if maxiter is None else maxiter
|
||
|
|
||
|
# Quotations describing the algorithm are from [1]_
|
||
|
# "... as small as you like, say eps"
|
||
|
if eps is None:
|
||
|
eps = xp.finfo(dtype).eps if not log else np.log(xp.finfo(dtype).eps)
|
||
|
|
||
|
# "The parameter tiny should be less than typical values of eps |b_n|"
|
||
|
if tiny is None:
|
||
|
tiny = xp.finfo(dtype).eps**2 if not log else 2*np.log(xp.finfo(dtype).eps)
|
||
|
|
||
|
# "Set f0 and C0 to the value b0 or to tiny if b0=0. Set D0 = 0.
|
||
|
zero = -xp.inf if log else 0
|
||
|
fn = xp.where(b0 == zero, tiny, b0)
|
||
|
Cnm1 = xp_copy(fn)
|
||
|
Dnm1 = xp.full_like(fn, zero)
|
||
|
|
||
|
CnDn = xp.full_like(fn, xp.inf)
|
||
|
|
||
|
work = _RichResult(n=0, fn=fn, Cnm1=Cnm1, Dnm1=Dnm1, CnDn=CnDn,
|
||
|
eps=eps, tiny=tiny,
|
||
|
nit=nit, nfev=nfev, status=status)
|
||
|
res_work_pairs = [('status', 'status'), ('f', 'fn'),
|
||
|
('nit', 'nit'), ('nfev', 'nfev')]
|
||
|
|
||
|
def pre_func_eval(work):
|
||
|
work.n = xp.reshape(xp.asarray(work.n + 1), (-1,))
|
||
|
return work.n
|
||
|
|
||
|
def post_func_eval(n, ab, work):
|
||
|
an, bn = ab[..., 0], ab[..., 1]
|
||
|
|
||
|
zero = 0 if not log else -xp.inf
|
||
|
|
||
|
# "Set D_n = 1/(b_n + a_n D_{n-1}) or 1/tiny, if the denominator vanishes"
|
||
|
denominator = (bn + an*work.Dnm1 if not log
|
||
|
else _logaddexp(bn, an + work.Dnm1, xp=xp))
|
||
|
denominator[denominator == zero] = tiny
|
||
|
Dn = (1/denominator if not log
|
||
|
else -denominator)
|
||
|
|
||
|
# "Set C_n = b_n + a_n / C_{n-1} (or =tiny, if the expression vanishes)"
|
||
|
Cn = (bn + an / work.Cnm1 if not log
|
||
|
else _logaddexp(bn, an - work.Cnm1, xp=xp))
|
||
|
Cn[Cn == zero] = tiny
|
||
|
|
||
|
# "and set f_n = f_{n-1} C_n D_n"
|
||
|
work.CnDn = (Cn * Dn if not log
|
||
|
else Cn + Dn)
|
||
|
work.fn = (work.fn * work.CnDn if not log
|
||
|
else work.fn + work.CnDn)
|
||
|
|
||
|
|
||
|
work.Cnm1, work.Dnm1 = Cn, Dn
|
||
|
|
||
|
def check_termination(work):
|
||
|
# Check for all terminal conditions and record statuses.
|
||
|
stop = xp.zeros_like(work.CnDn, dtype=xp.bool)
|
||
|
|
||
|
# "You quit when |D_n C_n - 1| is as small as you like, say eps"
|
||
|
pij = xp.full_like(work.CnDn, xp.pi*1j) if log else None
|
||
|
residual = (xp.abs(work.CnDn - 1) if not log
|
||
|
else xp.real(_logaddexp(work.CnDn, pij, xp=xp)))
|
||
|
i = residual < work.eps
|
||
|
work.status[i] = eim._ECONVERGED
|
||
|
stop[i] = True
|
||
|
|
||
|
# If function value is NaN, report failure.
|
||
|
i = (~xp.isfinite(work.fn) if not log
|
||
|
else ~(xp.isfinite(work.fn) | (work.fn == -xp.inf)))
|
||
|
work.status[i] = eim._EVALUEERR
|
||
|
stop[i] = True
|
||
|
|
||
|
return stop
|
||
|
|
||
|
def post_termination_check(work):
|
||
|
pass
|
||
|
|
||
|
def customize_result(res, shape):
|
||
|
# Only needed pre-NEP 50
|
||
|
res['f'] = xp.asarray(res['f'], dtype=dtype)
|
||
|
res['f'] = res['f'][()] if res['f'].ndim == 0 else res['f']
|
||
|
return shape
|
||
|
|
||
|
return eim._loop(work, callback, shape, maxiter, func, args, dtype,
|
||
|
pre_func_eval, post_func_eval, check_termination,
|
||
|
post_termination_check, customize_result, res_work_pairs,
|
||
|
xp=xp)
|