Using a computer in order to do rather complex Math is one of the reasons this machine was originally developed. As long as integer numbers and additions, subtractions, and multiplications are exclusively involved in the calculations, everything is fine. As soon as floating point numbers or fractions, as well as divisions, come into play it enormously complicates the whole matter.
As a regular user, we are not fully aware of these issues that happen behind the scenes and may end up with rather surprising, and possibly inaccurate results for our calculations. As developers, we have to ensure that appropriate measures are taken into account in order to instruct the computer to work in the right way.
In our daily life we use the decimal system that is based on the number 10. The computer uses the binary system, which is base 2, and internally it stores and processes the values as a sequence of 1s and 0s. The values we work with have to be constantly transformed between the two representations. As explained in Python's documentation:
...most decimal fractions cannot be represented exactly as binary fractions. A consequence is that, in general, the decimal floating-point numbers you enter are only approximated by the binary floating-point numbers actually stored in the machine.
This behavior leads to surprising results in simple additions, as shown here:
Listing 1: Inaccuracies with floating point numbers
>>> s = 0.3 + 0.3 + 0.3 >>> s 0.8999999999999999
As you can see here, the output is inaccurate, as it should result to 0.9.
Listing 2 shows a similar case for formatting a floating point number for 17 decimal places.
Listing 2: Formatting a floating point number
>>> format(0.1, '.17f') '0.10000000000000001'
As you may have learned from the examples above, dealing with floating point numbers is a bit tricky, and requires additional measures in order to achieve the correct result, and to minimize computing errors. Rounding the value can solve at least some of the problems. One possibility is the built-in
round() function (for more details regarding its usage see below):
Listing 3: Calculating with rounded values
>>> s = 0.3 + 0.3 + 0.3 >>> s 0.8999999999999999 >>> s == 0.9 False >>> round(0.9, 1) == 0.9 True
As an alternative, you can work with the math module, or explicitly work with fractions stored as two values (numerator and denominator) instead of the rounded, rather inexact floating point values.
What is Rounding?
In a few words, the process of rounding means:
...replacing [a value] with a different number that is approximately equal to the original, but has a shorter, simpler, or more explicit representation.
Basically, it adds inaccuracy to a precisely calculated value by shortening it. In most cases this is done by removing digits after the decimal point, for example from 3.73 to 3.7, 16.67 to 16.7, or 999.95 to 1000.
Such a reduction is done for several reasons - for example, saving space when storing the value, or simply for removing unused digits. Furthermore, output devices such as analogue displays or clocks can show the computed value with only a limited precision, and require adjusted input data.
In general, two rather simple rules are applied for rounding, you may remember them from school. The digits 0 to 4 lead to rounding down, and the numbers 5 to 9 lead to rounding up. The table below shows a selection of use cases.
| original value | rounded to | result | |----------------|--------------|--------| | 226 | the ten | 230 | | 226 | the hundred | 200 | | 274 | the hundred | 300 | | 946 | the thousand | 1,000 | | 1,024 | the thousand | 1,000 | | 10h45m50s | the minute | 10h45m |
Mathematicians have developed a variety of different rounding methods in order to address the problem of rounding. This includes simple truncation, rounding up, rounding down, rounding half-up, rounding half-down, as well as rounding half away from zero and rounding half to even.
As an example, rounding half away from zero is applied by the European Commission on Economical and Financial Affairs when converting currencies to the Euro. Several countries, such as Sweden, The Netherlands, New Zealand, and South Africa follow the rule named "cash rounding", "penny rounding", or "Swedish rounding".
[Cash rounding] occurs when the minimum unit of account is smaller than the lowest physical denomination of currency. The amount payable for a cash transaction is rounded to the nearest multiple of the minimum currency unit available, whereas transactions paid in other ways are not rounded.
In South Africa, since 2002 cash rounding is done to the nearest 5 cents. In general, this kind of rounding does not apply to electronic non-cash payments.
In contrast, rounding half to even is the default strategy for Python, Numpy, and Pandas, and is in use by the built-in
round() function that was already mentioned before. It belongs to the category of the round-to-nearest methods, and is also known as convergent rounding, statistician's rounding, Dutch rounding, Gaussian rounding, odd–even rounding, and bankers' rounding. This method is defined in IEEE 754 and works in such a way, that "if the fractional part of
x is 0.5, then
y is the even integer nearest to
x." It is assumed "that the probabilities of a tie in a dataset being rounded down or rounded up are equal" which is usually the case, in practice. Although not fully perfect this strategy leads to appreciable results.
The table below gives practical rounding examples for this method:
| original value | rounded to | |----------------|------------| | 23.3 | 23 | | 23.5 | 24 | | 24.0 | 24 | | 24.5 | 24 | | 24.8 | 25 | | 25.5 | 26 |
Python comes with the built-in function
round() that is quite useful in our case. It accepts two parameters - the original value, and the number of digits after the decimal point. The listing below illustrates the usage of the method for one, two, and four digits after the decimal point.
Listing 4: Rounding with a specified number of digits
>>> round(15.45625, 1) 15.5 >>> round(15.45625, 2) 15.46 >>> round(15.45625, 4) 15.4563
If you call this function without the second parameter the value is rounded to a full integer value.
Listing 5: Rounding without a specified number of digits
>>> round(0.85) 1 >>> round(0.25) 0 >>> round(1.5) 2
Rounded values work fine in case you do not require absolutely precise results. Be aware of the fact that comparing rounded values can also be a nightmare. It will become more obvious in the following example - the comparison of rounded values based on pre-rounding, and post-rounding.
The first calculation of Listing 6 contains pre-rounded values, and describes rounding before adding the values up. The second calculation contains a post-rounded summary which means rounding after the summation. You will notice that the outcome of the comparison is different.
Listing 6: Pre-rounding vs. post-rounding
>>> round(0.3, 10) + round(0.3, 10) + round(0.3, 10) == round(0.9, 10) False >>> round(0.3 + 0.3 + 0.3, 10) == round(0.9, 10) True
Python Modules for Floating Point Calculations
There are four popular modules that can help you properly deal with floating point numbers. This includes the
math module, the
Numpy module, the
decimal module, and the
math module is centered around mathematical constants, floating point operations, and trigonometric methods. The
Numpy module describes itself as "the fundamental package for scientific computing", and is famous for its variety of array methods. The
decimal module covers decimal fixed point and floating point arithmetic, and the
fractions module deals with rational numbers, specifically.
First, we have to try to improve the calculation from Listing 1. As Listing 7 shows, after having imported the
math module we can access the method
fsum() that accepts a list of floating point numbers. For the first calculation there is no difference between the built-in
sum() method, and the
fsum() method from the
math module, but for the second one it is, and returns the correct result we would expect. The precision depends on the underlying IEEE 754 algorithm.
Listing 7: Floating-point calculations with the help of the
>>> import math >>> sum([0.1, 0.1, 0.1]) 0.30000000000000004 >>> math.fsum([0.1, 0.1, 0.1]) 0.30000000000000004 >>> sum([0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]) 0.9999999999999999 >>> math.fsum([0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]) 1.0
Second, let us have a look at the
Numpy module. It comes with the around() method that rounds the values provided as an array. It processes the single values in the same way as the default
In order to compare values
Numpy offers the
equal() method. Similar to
around() it accepts single values as well as lists of values (so-called vectors) to be processed. Listing 8 shows a comparison for single values as well as rounded values. The observed behavior is quite similar to the previously shown methods.
Listing 8: Comparing values using the equal method from the
>>> import numpy >>> print (numpy.equal(0.3, 0.3)) True >>> print (numpy.equal(0.3 + 0.3 + 0.3 , 0.9)) False >>> print (numpy.equal(round(0.3 + 0.3 + 0.3) , round(0.9))) True
Option three is the
decimal module. It offers exact decimal representation, and preserves the significant digits. The default precision is 28 digits, and you can change this value to a number that is as large as needed for your problem. Listing 9 shows how to use a precision of 8 digits.
Listing 9: Creating decimal numbers using the
>>> import decimal >>> decimal.getcontext().prec = 8 >>> a = decimal.Decimal(1) >>> b = decimal.Decimal(7) >>> a / b Decimal('0.14285714')
Now, comparing of float values becomes a lot easier, and leads to the result we were looking for.
Listing 10: Comparisons using the
>>> import decimal >>> decimal.getcontext().prec = 1 >>> a = decimal.Decimal(0.3) >>> b = decimal.Decimal(0.3) >>> c = decimal.Decimal(0.3) >>> a + b + c Decimal('0.9') >>> a + b + c == decimal.Decimal('0.9') True
decimal module also comes with a method to round values - quantize(). The default rounding strategy is set to rounding half to even, and can also be changed to a different method if needed. Listing 11 illustrates the usage of the
quantize() method. Please note that the number of digits is specified using a decimal value as a parameter.
Listing 11: Rounding a value using
>>> d = decimal.Decimal(4.6187) >>> d.quantize(decimal.Decimal("1.00")) Decimal('4.62')
Last but not least, we will have a look at the
fractions module. This module allows you to handle floating point values as fractions, for example
0.3 as 3/10. This simplifies the comparison of floating point values, and completely eliminates rounding of values. Listing 12 shows how to use the fractions module.
Listing 12: Storing and comparing floating point values as fractions
>>> import fractions >>> fractions.Fraction(4, 10) Fraction(2, 5) >>> fractions.Fraction(6, 18) Fraction(1, 3) >>> fractions.Fraction(125) Fraction(125, 1) >>> a = fractions.Fraction(6, 18) >>> b = fractions.Fraction(1, 3) >>> a == b True
Furthermore, the two modules
fractions can be combined, as shown in the next example.
Listing 13: Working with decimals and fractions
>>> import fractions >>> import decimal >>> a = fractions.Fraction(1,10) >>> b = fractions.Fraction(decimal.Decimal(0.1)) >>> a,b (Fraction(1, 10), Fraction(3602879701896397, 36028797018963968)) >>> a == b False
Storing and processing floating point values correctly is a bit of a mission, and requires a lot of attention for programmers. Rounding the values may help, but be sure to check the correct order of rounding, and the method that you use. This is most important when developing things like financial software, so you'll want to check the rules of local law for rounding.
Python gives you all the tools that are needed, and comes with "batteries included". Happy hacking!
The author would like to thank Zoleka Hofmann for her critical comments while preparing this article.