Using ball arithmetic¶
This section gives an introduction to working with real numbers in Arb (see arb.h – real numbers for the API and technical documentation). The general principles carry over to complex numbers, polynomials and matrices.
Ball semantics¶
Let \(f : A \to B\) be a function. A ball implementation of \(f\) is a function \(F\) that maps sets \(X \subseteq A\) to sets \(F(X) \subseteq B\) subject to the following rule:
For all \(x \in X\), we have \(f(x) \in F(X)\).
In other words, \(F(X)\) is an enclosure for the set \(\{f(x) : x \in X\}\). This rule is sometimes called the inclusion principle.
Throughout the documentation (except where otherwise noted), we will simply write \(f(x)\) instead of \(F(X)\) when describing ball implementations of pointwise-defined mathematical functions, understanding that the input is a set of point values and that the output is an enclosure.
General subsets of \(\mathbb{R}\) are not possible to represent on a computer. Instead, we work with subsets of the form \([m \pm r] = [m-r, m+r]\) where the midpoint m and radius r are binary floating-point numbers, i.e. numbers of the form \(u 2^v\) with \(u, v \in \mathbb{Z}\) (to make this scheme complete, we also need to adjoin the special floating-point values \(-\infty\), \(+\infty\) and \(\operatorname{NaN}\)).
Given a ball \([m \pm r]\) with \(m \in \mathbb{R}\) (not necessarily a
floating-point number),
we can always round m to a nearby floating-point number that has at most
most prec bits in the component u,
and add an upper bound for the rounding error to r.
In Arb, ball functions that take a prec argument as input
(e.g. arb_add()
) always round their output to prec bits.
Some functions are always exact (e.g. arb_neg()
), and thus do not take a prec argument.
The programming interface resembles that of GMP.
Each arb_t
variable must be initialized with arb_init()
before use
(this also sets its value to zero), and deallocated with arb_clear()
after use. Variables have pass-by-reference semantics.
In the list of arguments to a function, output variables come first,
followed by input variables, and finally the precision:
#include "arb.h"
int main()
{
arb_t x, y;
arb_init(x); arb_init(y);
arb_set_ui(x, 3); /* x = 3 */
arb_const_pi(y, 128); /* y = pi, to 128 bits */
arb_sub(y, y, x, 53); /* y = y - x, to 53 bits */
arb_clear(x); arb_clear(y);
}
Binary and decimal¶
While the internal representation uses binary floating-point numbers, it is usually preferable to print numbers in decimal. The binary-to-decimal conversion generally requires rounding. Three different methods are available for printing a number to standard output:
arb_print()
shows the exact internal representation of a ball, with binary exponents.arb_printd()
shows an inexact view of the internal representation, approximated by decimal floating-point numbers.arb_printn()
shows a decimal ball that is guaranteed to be an enclosure of the binary floating-point ball. By default, it only prints digits in the midpoint that are certain to be correct, up to an error of at most one unit in the last place. Converting from binary to decimal is generally inexact, and the output of this method takes this rounding into account when printing the radius.
This snippet computes a 53-bit enclosure of \(\pi\) and prints it in three ways:
arb_const_pi(x, 53);
arb_print(x); printf("\n");
arb_printd(x, 20); printf("\n");
arb_printn(x, 20, 0); printf("\n");
The output is:
(884279719003555 * 2^-48) +/- (536870913 * 2^-80)
3.141592653589793116 +/- 4.4409e-16
[3.141592653589793 +/- 5.61e-16]
The arb_get_str()
and arb_set_str()
methods are useful for
converting rigorously between decimal strings and binary balls
(arb_get_str()
produces the same string as arb_printn()
,
and arb_set_str()
can parse such strings back).
A potential mistake is to create a ball from a double
constant
such as 2.3
, when this actually represents
2.29999999999999982236431605997495353221893310546875
.
To produce a ball containing the rational number
\(23/10\), one of the following can be used:
arb_set_str(x, "2.3", prec)
arb_set_ui(x, 23);
arb_div_ui(x, x, 10, prec)
fmpq_set_si(q, 23, 10); /* q is a FLINT fmpq_t */
arb_set_fmpq(x, q, prec);
Quality of enclosures¶
The main problem when working with ball arithmetic (or interval arithmetic) is overestimation. In general, the enclosure of a value or set of values as computed with ball arithmetic will be larger than the smallest possible enclosure.
Overestimation results naturally from rounding errors and cancellations in the individual steps of a calculation. As a general principle, formula rewriting techniques that make floating-point code more numerically stable also make ball arithmetic code more numerically stable, in the sense of producing tighter enclosures.
As a result of the dependency problem, ball or interval arithmetic can produce error bounds that are much larger than the actual numerical errors resulting from doing floating-point arithmetic. Consider the expression \((x + 1) - x\) as an example. When evaluated in floating-point arithmetic, \(x\) may have a large initial error. However, that error will cancel itself out in the subtraction, so that the result equals 1 (except perhaps for a small rounding error left from the operation \(x + 1\)). In ball arithmetic, dependent errors add up instead of cancelling out. If \(x = [3 \pm 0.1]\), the result will be \([1 \pm 0.2]\), where the error bound has doubled. In unfavorable circumstances, error bounds can grow exponentially with the number of steps.
If all inputs to a calculation are “point values”, i.e. exact numbers and known mathematical constants that can be approximated arbitrarily closely (such as \(\pi\)), then an error of order \(2^n\) can typically be overcome by working with n extra bits of precision, increasing the computation time by an amount that is polynomial in n. In certain situations, however, overestimation leads to exponential slowdown or even failure of an algorithm to converge. For example, root-finding algorithms that refine the result iteratively may fail to converge in ball arithmetic, even if they do converge in plain floating-point arithmetic.
Therefore, ball arithmetic is not a silver bullet: there will always be situations where some amount of numerical or mathematical analysis is required. Some experimentation may be required to find whether (and how) it can be used effectively for a given problem.
Predicates¶
A ball implementation of a predicate \(f : \mathbb{R} \to \{\operatorname{True}, \operatorname{False}\}\) would need to be able to return a third logical value indicating that the result could be either True or False. In most cases, predicates in Arb are implemented as functions that return the int value 1 to indicate that the result certainly is True, and the int value 0 to indicate that the result could be either True or False. To test whether a predicate certainly is False, the user must test whether the negated predicate certainly is True.
For example, the following code would not be correct in general:
if (arb_is_positive(x))
{
... /* do things assuming that x > 0 */
}
else
{
... /* do things assuming that x <= 0 */
}
Instead, the following can be used:
if (arb_is_positive(x))
{
... /* do things assuming that x > 0 */
}
else if (arb_is_nonpositive(x))
{
... /* do things assuming that x <= 0 */
}
else
{
... /* do things assuming that the sign of x is unknown */
}
Likewise, we will write \(x \le y\) in mathematical notation with the meaning that \(x \le y\) holds for all \(x \in X, y \in Y\) where \(X\) and \(Y\) are balls.
Note that some predicates such as arb_overlaps()
and arb_contains()
actually are predicates on balls viewed as sets, and not ball implementations
of pointwise predicates.
Some predicates are also complementary.
For example arb_contains_zero()
tests whether the input ball
contains the point zero.
Negated, it is equivalent to arb_is_nonzero()
,
and complementary to arb_is_zero()
as a pointwise predicate:
if (arb_is_zero(x))
{
... /* do things assuming that x = 0 */
}
#if 1
else if (arb_is_nonzero(x))
#else
else if (!arb_contains_zero(x)) /* equivalent */
#endif
{
... /* do things assuming that x != 0 */
}
else
{
... /* do things assuming that the sign of x is unknown */
}
A worked example: the sine function¶
We implement the function \(\sin(x)\) naively using
the Taylor series \(\sum_{k=0}^{\infty} (-1)^k x^{2k+1} / (2k+1)!\)
and arb_t
arithmetic.
Since there are infinitely many terms, we need to split the series
in two parts: a finite sum that can be evaluated directly, and
a tail that has to be bounded.
We stop as soon as we reach a term \(t\) bounded by \(|t| \le 2^{-prec} < 1\). The terms are alternating and must have decreasing magnitude from that point, so the tail of the series is bounded by \(|t|\). We add this magnitude to the radius of the output. Since ball arithmetic automatically bounds the numerical errors resulting from all arithmetic operations, the output res is a ball guaranteed to contain \(\sin(x)\).
#include "arb.h"
void arb_sin_naive(arb_t res, const arb_t x, slong prec)
{
arb_t s, t, u, tol;
slong k;
arb_init(s); arb_init(t); arb_init(u); arb_init(tol);
arb_one(tol);
arb_mul_2exp_si(tol, tol, -prec); /* tol = 2^-prec */
for (k = 0; ; k++)
{
arb_pow_ui(t, x, 2 * k + 1, prec);
arb_fac_ui(u, 2 * k + 1, prec);
arb_div(t, t, u, prec); /* t = x^(2k+1) / (2k+1)! */
arb_abs(u, t);
if (arb_le(u, tol)) /* if |t| <= 2^-prec */
{
arb_add_error(s, u); /* add |t| to the radius and stop */
break;
}
if (k % 2 == 0)
arb_add(s, s, t, prec);
else
arb_sub(s, s, t, prec);
}
arb_set(res, s);
arb_clear(s); arb_clear(t); arb_clear(u); arb_clear(tol);
}
This algorithm is naive, because the Taylor series is slow to converge and suffers from catastrophic cancellation when \(|x|\) is large (we could also improve the efficiency of the code slightly by computing the terms using recurrence relations instead of computing \(x^k\) and \(k!\) from scratch each iteration).
As a test, we compute \(\sin(2016.1)\). The largest term in the Taylor series for \(\sin(x)\) reaches a magnitude of about \(x^x / x!\), or about \(10^{873}\) in this case. Therefore, we need over 873 digits (about 3000 bits) of precision to overcome the catastrophic cancellation and determine the result with sufficient accuracy to tell whether it is positive or negative.
int main()
{
arb_t x, y;
slong prec;
arb_init(x); arb_init(y);
for (prec = 64; ; prec *= 2)
{
arb_set_str(x, "2016.1", prec);
arb_sin_naive(y, x, prec);
printf("Using %5ld bits, sin(x) = ", prec);
arb_printn(y, 10, 0); printf("\n");
if (!arb_contains_zero(y)) /* stopping condition */
break;
}
arb_clear(x); arb_clear(y);
}
The program produces the following output:
Using 64 bits, sin(x) = [+/- 2.67e+859]
Using 128 bits, sin(x) = [+/- 1.30e+840]
Using 256 bits, sin(x) = [+/- 3.60e+801]
Using 512 bits, sin(x) = [+/- 3.01e+724]
Using 1024 bits, sin(x) = [+/- 2.18e+570]
Using 2048 bits, sin(x) = [+/- 1.22e+262]
Using 4096 bits, sin(x) = [-0.7190842207 +/- 1.20e-11]
As an exercise, the reader may improve the naive algorithm by making it
subtract a well-chosen multiple of \(2 \pi\) from \(x\) before invoking
the Taylor series (hint: use arb_const_pi()
, arb_div()
and arf_get_fmpz()
).
If done correctly, 64 bits of precision should be more than enough to
compute \(\sin(2016.1)\), and with minor adjustments
to the code, the user should be able to compute
\(\sin(\exp(2016.1))\) quite easily as well.
This example illustrates how ball arithmetic can be used to perform
nontrivial calculations. To evaluate an infinite series, the user
needs to know how to bound the tail of the series, but everything
else is automatic.
When evaluating a finite formula that can be expressed
completely using built-in functions, all error bounding is automatic
from the point of view of the user.
In particular, the arb_sin()
method should be used to compute the sine
of a real number; it uses a much more efficient algorithm
than the naive code above.
This example also illustrates the “guess-and-verify” paradigm: instead of determining a priori the floating-point precision necessary to get a correct result, we guess some initial precision, use ball arithmetic to verify that the result is accurate enough, and restart with higher precision (or signal failure) if it is not.
If we think of rounding errors as essentially random processes, then a floating-point computation is analogous to a Monte Carlo algorithm. Using ball arithmetic to get a verified result effectively turns it into the analog of a Las Vegas algorithm, which is a randomized algorithm that always gives a correct result if it terminates, but may fail to terminate (alternatively, instead of actually looping forever, it might signal failure after a certain number of iterations).
The loop will fail to terminate if we attempt to determine the sign of \(\sin(\pi)\):
Using 64 bits, sin(x) = [+/- 3.96e-18]
Using 128 bits, sin(x) = [+/- 2.17e-37]
Using 256 bits, sin(x) = [+/- 6.10e-76]
Using 512 bits, sin(x) = [+/- 5.13e-153]
Using 1024 bits, sin(x) = [+/- 4.01e-307]
Using 2048 bits, sin(x) = [+/- 2.13e-615]
Using 4096 bits, sin(x) = [+/- 6.85e-1232]
Using 8192 bits, sin(x) = [+/- 6.46e-2465]
Using 16384 bits, sin(x) = [+/- 5.09e-4931]
Using 32768 bits, sin(x) = [+/- 5.41e-9863]
...
The sign of a nonzero real number can be
decided by computing it to sufficiently high accuracy, but the sign
of an expression that is exactly equal to zero cannot be decided
by a numerical computation unless the entire computation
happens to be exact (in this example, we could use the arb_sin_pi()
function which computes \(\sin(\pi x)\) in one step, with the input \(x = 1\)).
It is up to the user to implement a stopping criterion appropriate for the circumstances of a given application. For example, breaking when it is clear that \(|\sin(x)| < 10^{-10000}\) would allow the program to terminate and convey some meaningful information about the input \(x = \pi\), though this would not constitute a mathematical proof that \(\sin(\pi) = 0\).
More on precision and accuracy¶
The relation between the working precision and the accuracy of the output is not always easy predict. The following remarks might help to choose prec optimally.
For a ball \([m \pm r]\) it is convenient to define the following notions:
- Absolute error: \(e_{abs} = |r|\)
- Relative error: \(e_{rel} = |r| / \max(0, |m| - |r|)\) (or \(e_{rel} = 0\) if \(r = m = 0\))
- Absolute accuracy: \(a_{abs} = 1 / e_{abs}\)
- Relative accuracy: \(a_{rel} = 1 / e_{rel}\)
Expressed in bits, one takes the corresponding \(\log_2\) values.
Of course, if \(x\) is the exact value being approximated, then the “absolute error” so defined is an upper bound for the actual absolute error \(|x-m|\) and “absolute accuracy” a lower bound for \(1/|x-m|\), etc.
The prec argument in Arb should be thought of as controlling the working precision. Generically, when evaluating a fixed expression (that is, when the sequence of operations does not depend on the precision), the absolute or relative error will be bounded by
where the \(O(1)\) term depends on the expression and implementation details of the ball functions used to evaluate it. Accordingly, for an accuracy of p bits, we need to use a working precision \(O(1) + p\). If the expression is numerically well-behaved, then the \(O(1)\) term will be small, which leads to the heuristic of “adding a few guard bits” (for most basic calculations, 10 or 20 guard bits is enough). If the \(O(1)\) term is unknown, then increasing the number of guard bits in exponential steps until the result is accurate enough is generally a good heuristic.
Sometimes, a partially accurate result can be used to estimate the \(O(1)\) term. For example, if the goal is to achieve 100 bits of accuracy and a precision of 120 bits yields 80 bits of accuracy, then it is plausible that a precision of just over 140 bits yields 100 bits of accuracy.
Built-in functions in Arb can roughly be characterized as belonging to one of two extremes (though there is actually a spectrum):
- Simple operations, including basic arithmetic operations and many elementary functions. In most cases, for an input \(x = [m \pm r]\), \(f(x)\) is evaluated by computing \(f(m)\) and then separately bounding the propagated error \(|f(m) - f(m + \varepsilon)|, |\varepsilon| \le r\). The working precision is automatically increased internally so that \(f(m)\) is computed to prec bits of relative accuracy with an error of at most a few units in the last place (perhaps with rare exceptions). The propagated error can generally be bounded quite tightly as well (see General formulas and bounds). As a result, the enclosure will be close to the best possible at the given precision, and the user can estimate the precision to use accordingly.
- Complex operations, such as certain higher transcendental functions (for example, the Riemann zeta function). The function is evaluated by performing a sequence of simpler operations, each using ball arithmetic with a working precision of roughly prec bits. The sequence of operations might depend on prec; for example, an infinite series might be truncated so that the remainder is smaller than \(2^{-prec}\). The final result can be far from tight, and it is not guaranteed that the error converges to zero as \(prec \to \infty\), though in practice, it should do so in most cases.
In short, the inclusion principle is the fundamental contract in Arb. Enclosures computed by built-in functions may or may not be tight enough to be useful, but the hope is that they will be sufficient for most purposes. Tightening the error bounds for more complex operations is a long term optimization goal, which in many cases will require a fair amount of research. A tradeoff also has to be made for efficiency: tighter error bounds allow the user to work with a lower precision, but they may also be much more expensive to compute.
Polynomial time guarantee¶
Arb provides a soft guarantee that the time used to evaluate a ball function will depend polynomially on prec and the bit size of the input, uniformly regardless of the numerical value of the input.
The idea behind this soft guarantee is to allow Arb to be used as a black box to evaluate expressions numerically without potentially slowing down, hanging indefinitely or crashing because of “bad” input such as nested exponentials. By controlling the precision, the user can cancel a computation before it uses up an unreasonable amount of resources, without having to rely on other timeout or exception mechanisms. A result that is feasible but very expensive to compute can still be forced by setting the precision high enough.
As motivation, consider evaluating \(\sin(x)\) or \(\exp(x)\) with the exact floating-point number \(x = 2^{2^n}\) as input. The time and space required to compute an accurate floating-point approximation of \(\sin(x)\) or \(\exp(x)\) increases as \(2^n\), in the first case because because of the need to subtract an accurate multiple of \(2\pi\) and in the second case due to the size of the output exponent and the internal subtraction of an accurate multiple of \(\log(2)\). This is despite the fact that the size of \(x\) as an object in memory only increases linearly with \(n\). Already \(n = 33\) would require at least 1 GB of memory, and \(n = 100\) would be physically impossible to process. For functions that are computed by direct use of power series expansions, e.g. \(f(x) = \sum_{k=0}^{\infty} c_k x^k\), without having fast argument-reduction techniques like those for elementary functions, the time would be exponential in \(n\) already when \(x = 2^n\).
Therefore, Arb caps internal work parameters
(the internal working precision,
the number terms of an infinite series to add, etc.) by polynomial,
usually linear, functions of prec.
When the limit is exceeded, the output is set to a crude bound.
For example, if \(x\) is too large, arb_sin()
will
simply return \([\pm 1]\), and arb_exp()
will simply return \([\pm \infty]\) if \(x\) is positive
or \([\pm 2^{-m}]\) if \(x\) is negative.
This is not just a failsafe, but occasionally a useful optimization. It is not entirely uncommon to have formulas where one term is modest and another term decreases exponentially, such as:
For example, the reflection formula of the digamma function has a similar structure. When \(x\) is large, the right term would be expensive to compute to high relative accuracy. Doing so is unnecessary, however, since a crude bound of \([\pm 1] \cdot [\pm 2^{-m}]\) is enough to evaluate the expression as a whole accurately.
The polynomial time guarantee is “soft” in that there are a few exceptions. For example, the complexity of computing the Riemann zeta function \(\zeta(\sigma+it)\) increases linearly with the imaginary height \(|t|\) in the current implementation, and all known algorithms have a complexity of \(|t|^{\alpha}\) where the best known value for \(\alpha\) is about \(0.3\). Input with large \(|t|\) is most likely to be given deliberately by users with the explicit intent of evaluating the zeta function itself, so the evaluation is not cut off automatically.