Floating Point Math is Hard (i)

I’m working a piece of legacy software, and in performing a code review came across what appeared to be some extraneous parentheses. I’ve renamed the terms as generically as possible because my employer would prefer it that way, but we have a calculation that in its original form looks like this, where every term is a floating point value:

calculated_value = a * (b * (c * d + e * f)) + g

Since multiplication is associative, the outermost set of parentheses should be redundant. Thinking that would be the case, a coworker and I rewrote the calculation so:

calculated_value = a * b * (c * d + e * f) + g

This does not always return the same results, though. I have tested this on both 2.7.4 and 3.3.1, and for a certain set of values, these two forms of the calculation definitely return different results. Here is a small, complete program that produces different results between the two forms of the calculation on both 2.7.4 and 3.3.1:

# test.py
def with_parens(a, b, c, d, e, f, g):
    return (a * (b * (c * d + e * f)) + g)

def without_parens(a, b, c, d, e, f, g):
    return (a * b * (c * d + e * f) + g)

the_values = (1.1523070658790489, 1.7320508075688772, 0.14068856789641426, 0.5950026782638391, 0.028734293347820326, 21.523030539704976, 2.282302370324546)

result_with = with_parens(*the_values)
result_without = without_parens(*the_values)
print((result_with == result_without), result_with, result_without)
The results:
» python test.py
(False, 3.68370978406535, 3.6837097840653494)

» python3 test.py
False 3.68370978406535 3.6837097840653494

It is fairly obvious that there is a precision difference here. I am familiar with the ways that floating point math can be odd, but I would not expect the loss of associativity to be one of them. And of course, it isn’t. It just took me this long in writing up what was originally going to be a post on comp.lang.python to see it.

What is actually going on here is that Python respects order of operations (as well it should!), and floating point math is imprecise. In the first case, a * b * (<everything else>), the first thing that happens is a is multiplied by b and the result is then multiplied by everything else. In the second case, a * (b * <everything else>), b is multiplied by everything else and the result is multiplied by a at the end. Many times, this doesn’t matter, but sometimes there is a slight difference in the results because of the loss of precision when performing floating point operations.

Lesson learned (again): floating point math is hard, and will trick you. What happens here is functionally a loss of the associative property of multiplication. The two calculations are in a pure mathematical sense equivalent. But floating point math is not the same as pure math. Not even close.

Discussion is closed at this time.