Interval Arithmetic

Mathematical topics covered:

  • Operations on sets
  • Idea of interval arithmetic
  • Error bounds using interval arithmetic

Programming concepts covered:

  • Interval arithmetic with mpmath
  • comparisons in interval arithmetic
  • Rigorous comparisons using increasing precision
  • Rigorous computation of approximations to desired accuracy

Note: This material is not covered in the various textbooks.

Mathematical underpinnings

If $A$ is a set of real numbers, we write $a \in A$ to denote the statement "$a$ is in $A$".

In mathematics, we can extend the usual operations on real numbers to sets of real numbers.

For example, the addition operation is $$A+B = \{a+b:~\text{$a \in A$ and $b \in B$}\}.$$ So, for instance $$\{1,3\}+\{5,7\}= \{6,8,10\}.$$

Basically interval arithmetic implements these operations for intervals. Most operations between intervals yield new intervals. For example, $$[a,b] + [c,d] = [a+c, b+d].$$

The one operation that is an issue is division. Since for example, $$[1,2] \div [-1,1]$$ is not an interval. But then one of the numbers in the resulting set should be $1 \div 0$ which is undefined. So, when implemented as a computer program, this operation results in an error instead of a strange set.

Programming basic ideas

Recall that most numbers you want to work with can't be stored exactly in Python. When we do arithmetic, we round at every step. As we do more operations, the value stored slowly diverges from the result of the true computation.

Interval arithmetic gives a partial resolution to this. If $x$ is a real number, we use a (hopefully small) interval containing $x$ to represent $x$. When we do computations involving $x$, we round outward (making the intervals bigger) to be sure that the real value is somewhere inside. This guarantees that the true value that would result from the computation must be inside the interval.

An interval arithmetic package

We will demonstrate the Arbitrary-precision interval arithmetic built into the mpmath package. (Warning: The package states that interval arithmetic support is still experimental.)

The inteval arithmetic package can be loaded with:

In [1]:
from mpmath import iv

We will work with $5$ digits of precision just so we can see what is going on easily. You would normally want to set this higher.

In [2]:
iv.dps = 5         # Use 5 digits of precision

The following repesents the interval $[1, 1]$. That is $1$ is the only point in the interval.

In [3]:
x = iv.mpf(1)
x
Out[3]:
mpi('1.0', '1.0')

Recall that $1/3$ can not be stored as a floating point number. Here is $1/3$ as an interval:

In [4]:
y = x/3
y
Out[4]:
mpi('0.33333302', '0.33333349')

Observe that $1/3$ is in the interval above. Also if we add $y$ three times, we get an interval containing one:

In [5]:
y + y + y
Out[5]:
mpi('0.99999905', '1.0000019')

The number $\pi$ can't be stored exactly and so is represented as an interval.

In [6]:
pi = iv.pi
pi
Out[6]:
mpi('3.1415901', '3.1415939')

The standard functions are included and are applied to intervals with rounding outwards. For example, computing $cos(\frac{\pi}{2})$ results in a interval containing $0$.

In [7]:
iv.cos(pi/2)
Out[7]:
mpi('-6.3975858e-7', '1.2675919e-6')

Observe that this rigorously guarantees that the true value has absolute value less than $1.3 \times 10^{-6}$. This isn't the best example, because we know the answer is zero. But we don't know the true value of $\cos(1)$.

In [8]:
iv.cos(1)
Out[8]:
mpi('0.54030228', '0.54030323')

The above guarantees that $$\cos(1)=0.54030\ldots$$ is correct to the decimal places displayed (and the next digit is either a $2$ or a $3$).

Manually constructing an interval

The following command constructs an interval with endpoints $2$ and $3$.

In [9]:
iv.mpf([2,3])
Out[9]:
mpi('2.0', '3.0')

Comparisons

Intervals are only equal if they have the same endpoints. (Overlapping is not good enough.) This does create some issues. For example $$\frac{2}{7}+\frac{1}{5} = \frac{17}{35}$$ but:

In [10]:
x = iv.mpf("2/7") + iv.mpf("1/5")   # 2/7 + 1/5
print(x)
[0.4857139587, 0.4857149124]
In [11]:
y = iv.mpf("17/35")
print(y)
[0.4857139587, 0.4857144356]
In [12]:
x==y
Out[12]:
False

(Above we made use of conversion from string to an interval.)

Less than and greater than comparisons return True, False, or None. Here None is reserved for the case that the answer can not be determined (usually because the intervals overlap.)

In [13]:
print( iv.mpf([2,3]) < iv.mpf([4,5]) )
True
In [14]:
print( iv.mpf([2,3]) < iv.mpf([0,1]) )
False
In [15]:
print( iv.mpf([2,3]) < iv.mpf([3,4]) )
None
In [16]:
print( iv.mpf([2,3]) <= iv.mpf([3,4]) )
True

Rigorous comparisons through increasing precision

The number $x = \sqrt[3]{3}$ is very close to $y=26639450/18470763$. Suppose we want to know for sure which is bigger. Below we use floats, but what if there is a round off error issue?

In [17]:
x = 3.0**(1/3)
x
Out[17]:
1.4422495703074083
In [18]:
y = 26639450/18470763
y
Out[18]:
1.4422495703074096

The following makes the check with intervals:

In [19]:
x = iv.mpf(3)**iv.mpf("1/3")
x
Out[19]:
mpi('1.4422474', '1.4422512')
In [20]:
y = iv.mpf("26639450/18470763")
y
Out[20]:
mpi('1.4422493', '1.4422512')
In [21]:
print(x < y)
None

The intervals overlap so the above check is inconclusive. We can figure out for sure by checking higher and higher precision. (Quantities computed at higher precision will have smaller interval lengths.)

In [22]:
result = (x < y)
while result is None:
    iv.prec = 2 * iv.prec           # Double the bits of precision.
    x = iv.mpf(3)**iv.mpf("1/3")    # Compute x to the new precision.
    y = iv.mpf("26639450/18470763") # Compute y to the new precision.
    result = (x < y)
print("The statement x<y is {}.".format(result))
print("We had to use {} bits of precision.".format(iv.prec))
The statement x<y is True.
We had to use 80 bits of precision.
In [23]:
iv.dps = 5 # set the precision back to 5 digits

Computation of exact decimal representations

Lets say we really wanted to know $$x = \cos\big(\sqrt{3}-\sqrt{2}\big)$$ to $30$ significant digits.

To get a rough idea of what is going on, we can compute the quantity using the math library:

In [24]:
import math as m
m.sqrt(3) - m.sqrt(2)
Out[24]:
0.31783724519578205

Observe that the most significant digit is the tenths place (value $10^{-1}$). The 30th most significant digit then has value $10^{-30}$, so we want to find an approximation within $10^{-30}$ of the true value.

The following computes an interval containing the true value at $5$ digits of precision.

In [25]:
iv.dps = 5
x = iv.cos(iv.sqrt(3) - iv.sqrt(2))
x
Out[25]:
mpi('0.94991302', '0.94991493')

The .delta property of an interval gives the width of the interval (as an interval).

In [26]:
x.delta
Out[26]:
mpi('1.9073486e-6', '1.9073486e-6')

So we can see that we do not have enough precision. Below we increase the precision to $53$ bits of precision (double arithemetic) and try again.

In [27]:
iv.prec = 53
x = iv.cos(iv.sqrt(3) - iv.sqrt(2))
x.delta
Out[27]:
mpi('3.3306690738754696e-16', '3.3306690738754696e-16')

We still only have 16 digits of precision or so. We need around $2$ times more precision.

In [28]:
iv.prec = 120
x = iv.cos(iv.sqrt(3) - iv.sqrt(2))
x.delta
Out[28]:
mpi('1.5046327690525280101999827676444744676e-36', '1.5046327690525280101999827676444744676e-36')

This means that $x$ should be correct to 36 digits of precision. Perfect.

Here we convert the interval to a string with 30 digits of precision. This rounds each of the endpoints.

In [29]:
s = iv.nstr(x,30)
s
Out[29]:
'[0.949913527864856110460603033416, 0.949913527864856110460603033416]'

We can extract the number with s[1:33]. This will be the substring starting in position $1$, up to and not including the $32$nd position. (The $0$th position is [, so the number begins at index $1$. The first character in the number 0 is not a significant digit, and neither is the decimal point. So, we chose $33$ since it is $1 + 2 + 30$, where $1$ is the index where the number starts, $2$ is the number of characters that aren't significant digits, and $30$ is the number of significant digits.)

In [30]:
string_representation = s[1:33]
string_representation
Out[30]:
'0.949913527864856110460603033416'

The endpoints can be accessed as intervals using x.a and x.b:

In [36]:
print("Left:  {}".format(x.a))
print("Right: {}".format(x.b))
Left:  [0.9499135278648561104606030334158839414574, 0.9499135278648561104606030334158839414574]
Right: [0.9499135278648561104606030334158839429621, 0.9499135278648561104606030334158839429621]

You can access the endpoints as floating point numbers at the same precision using mpmath's mp module. You should be careful to set it to use the same precision.

In [39]:
from mpmath import mp
mp.prec = iv.prec
In [40]:
left = mp.mpf(x.a)
left
Out[40]:
mpf('0.94991352786485611046060303341588394146')
In [42]:
right = mp.mpf(x.b)
right
Out[42]:
mpf('0.94991352786485611046060303341588394296')

Then we can print 30 significant digits for the average of left and right to get a best approximating value. For this we use mp.nstr.

In [43]:
mp.nstr( (left+right)/2, n=30)
Out[43]:
'0.949913527864856110460603033416'

Conclusion

Mainly, interval arithmetic is useful to rule out the possibility of accumulation of errors due to rounding in operations. It also is useful for computing mathematical quantities to a desired accuracy.