team-10/env/Lib/site-packages/scipy/stats/_continued_fraction.py

388 lines
15 KiB
Python
Raw Normal View History

2025-08-02 07:34:44 +02:00
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)