Saturday, June 11, 2022

Nicer arithmetic with Python

Beginning programmers are often surprised by floating point arithmetic inaccuracies. If they use Python, many will often write posts saying that Python is "broken" when the see results as follows:

>>> 0.1 + 0.2
0.30000000000000004

This particular result is not limited to Python. In fact, it is so common that there exists a site with a name inspired by this example (0.30000000000000004.com/), devoted to explaining the origin of this puzzling result, followed by examples from many programming languages.

Python provides some alternatives to standard floating point operations. For example, one can use the decimal module to perform fixed point arithmetic operations. Here's an example.

>>> from decimal import Decimal, getcontext
>>> getcontext().prec = 7
>>> Decimal(0.1) + Decimal(0.2)
Decimal('0.3000000')
>>> print(_)
0.3000000

While one can set the precision (number of decimals) with which operations are performed, printed values can carry extra zeros: 0.3000000 does not look as "nice" as 0.3.

Another alternative included with Python's standard library is the fractions module: it provides support for rational number arithmetic.

>>> from fractions import Fraction
>>> Fraction("0.1") + Fraction("0.2")
Fraction(3, 10)
>>> print(_)
3/10

However, the fractions module can yield some surprising results if one does not use string arguments to represent floats, as was mentioned by Will McGugan (of Rich and Textual fame) in a recent tweet.

>>> from fractions import Fraction as F
>>> F("0.1")
Fraction(1, 10)
>>> F(0.1)
Fraction(3602879701896397, 36028797018963968)

In the second case, 0.1 is a float which means that it carries some intrinsic inaccuracy. For the first case, some parsing is done by Python to determine the number of decimal places to use before converting the result into a rational number. A similar result can be achieved using the limit_denominator method of the Fraction class:

>>> F(0.1).limit_denominator(10)
Fraction(1, 10)

In fact, we do not have to be as restrictive in the limitation imposed on the denominator to achieve the same result

>>> F(0.1).limit_denominator(1_000_000_000)
Fraction(1, 10)

While we can achieve some "more intuitive" results for floating point arithmetic using special modules from Python, the notation that one has to use is not as simple as "0.1 + 0.2". As Raymond Hettinger often says: "There has to be a better way."

Using ideas

As readers of this blog already know, I created a Python package named ideas to facilitate the creation of import hooks and to enable easy experimentation with modified Python syntax. ideas comes with its own console that support modified Python syntax. It can also be used with IPython (and thus with Jupyter notebooks).

Using ideas, one can "instruct" python to perform rational arithmetic.  For example, suppose I have a Python file containing the following:

# simple_math.py

a = 0.2 + 0.1
b = 0.2 + 1/10
c = 2/10 + 1/10
print(a, b, c)

I can run this with Python, getting the expected "unintuitive" result:

> py simple_math.py
0.30000000000000004 0.30000000000000004 0.30000000000000004

Alternatively, using ideas, I can execute this file using rational arithmetic:

> ideas simple_math -a rational_math
3/10 3/10 3/10

Using a different import hook, I can have the result shown with floating point notation.

> ideas simple_math -a nicer_floats
0.3 0.3 0.3

Instead of executing a script, let's use the ideas console instead, starting with "nicer_float"

ideas> 0.1 + 0.2
0.3

ideas> 1/10 + 2/10
0.3
For "nicer_float", I've also adopted the Pyret's notation: floating-point number immediately preceded by "~" are treated as "approximate" floating points i.e. with the regular inaccuracy.
ideas> ~0.1 + 0.2
0.30000000000000004

And, as mentioned before, I can use ideas with IPython. Here's a very brief example

IPython 8.0.0b1 -- An enhanced Interactive Python. Type '?' for help.

In [1]: from ideas.examples import rational_math

In [2]: hook = rational_math.add_hook()
   The following initializing code from ideas is included:

from fractions import Fraction

In [3]: 0.1 + 0.2
Out[3]: Fraction(3, 10)
  

Final thoughts

Given how confusing floating point arithmetic is to beginners, I think it would be nice if Python had an easy built-in way to switch modes and do calculations as done with ideas in the above examples. However, I doubt very much that this will ever happen. Fortunately, as demonstrated above, it is possible to use import hooks and modified interactive console to achieve this result.

No comments: