Rounding properly in Python
If you want to see code, skip down to the solution.
Some time ago, Python changed its rounding algorithmAnd by “some time ago“ I mean 17 years at the time of writing. Instead of always rounding up at 0.5 values, it would round towards the even number, so 0.5 would be rounded down to 0, while 1.5 would be rounded up to 2:
>>> round(0.5)
0
>>> round(1.5)
2
The reasoning behind this is that it's conforming to the IEEE754 standard on rounding numbers, or, well, at least the specific default rule of it. The better reason is that it takes away an aspect of rounding bias. The method called “round half to even” is unbiased both on positive/negative numbers and towards/away from zero or infinity. Mathematically, it's nice.
It's just not what most people expect when rounding numbers, what they expect is “rounding as we learned it in school”, also called “commercial rounding” or “round half away from zero”. And that's what I'd like to have. Well, as one commenter put it, simply implement it by hand. Ah yes, that's exactly what I had intended for a quiet evening, re-implementing basic math functions that should be available everywhere.
Unfortunately, things aren't that simple, and for this specific operation, we cannot stay with floating point numbers, since they are encoded in binary and mixing terms like “round to the nearest decimal digit” really don't make sense in that context. So we'll have to switch to Decimal
s, because those can properly encode decimal numbers.
Unfortunately, it's not that easy either. Reading through the documentation in the standard library on Decimals, you might come away with this idea:
>>> from decimal import *
>>> getcontext().prec = 6
>>> Decimal(1) / Decimal(7)
Decimal('0.142857')
>>> getcontext().prec = 28
>>> Decimal(1) / Decimal(7)
Decimal('0.1428571428571428571428571429')
And you'll think to that you'll simply get the current context, set its precision to 3 and be done. But unfortunately:
>>> getcontext().prec = 3
>>> Decimal('0.12345')
Decimal('0.12345')
>>> +Decimal('0.12345') # ok, we have to force a precision adjustment, 𝓯𝓲𝓷𝓮
Decimal('0.123')
>>> +Decimal('0.1235')
Decimal('0.124')
>>> # nice!
>>> # ... some time later
>>> Decimal('12345') + Decimal(1)
Decimal('1.23E+4')
>>> # WTF?!
>>> Decimal('12345') + Decimal(5)
Decimal('1.24E+4')
>>> Decimal('12355') + Decimal(5)
Decimal('1.24E+4')
>>> # WT actual F?
You see, that beautiful prec
parameter sets the precision of the decimal type and is set to 28 by default. It specifies how many decimal digits a number carries in total. So the result of 12345 + 1 --> 12300
. Also, per the last line, rounding towards even is still active. Nice!
Things I tried out
Here are six variations of how to do proper rounding that I thought up from reading the documentation. Not all of these are correct. Try to guess which ones work!
Variant 1
getcontext().rounding = mode
round(number, 0)
Variant 2
ctx = Context(rounding=mode)
dec = ctx.create_decimal(number)
round(dec, 0)
Variant 3
ctx = Context(rounding=mode)
dec = ctx.create_decimal(number)
dec.quantize(Decimal('0')))
Variant 4
ctx = Context(rounding=mode)
ctx.quantize(number, Decimal('0')))
Variant 5
with localcontext(rounding=mode):
ctx.quantize(number, Decimal('1'))
Variant 6
with localcontext(rounding=mode):
round(number, 0)
Solutions
Variant 1
getcontext().rounding = mode
round(number, 0)
This one works, but it sets the rounding mode globally, for all decimal processing in your program. Depending on your needs, this might be what you want. Set your rounding mode once, be done.
Variant 2
ctx = Context(rounding=mode)
dec = ctx.create_decimal(number)
round(dec, 0)
This one does not work. The function create_decimal
only does its thing once, the created number does not have an association with the context.
Variant 3
ctx = Context(rounding=mode)
dec = ctx.create_decimal(number)
dec.quantize(Decimal('0')))
This one also does not work. The function create_decimal
still only does its thing once, the created number does not have an association with the context, even if you use fancy words like quantize
.
Variant 4
ctx = Context(rounding=mode)
ctx.quantize(number, Decimal('0')))
This one works, and it's actually my preferred solution, because we don't have to change any global state. We simply apply the context to the operation, even if it is through unnecessarily complex verbiage.
But beware of that second parameter. It does not refer to the number of digits, instead it refers to the exponent. If you want to round to one digit, that parameter needs to be Decimal('0.1')
or Decimal('1e-1')
.
Variant 5
with localcontext(rounding=mode):
ctx.quantize(number, Decimal('1'))
This one works. Its effects are localised to a specific context and will reset it back afterwards. Still uses fancy words, though!
Variant 6
with localcontext(rounding=mode):
round(number, 0)
Nice, clean, localised, readable, and works. I like it. But not as much as the other one.
Rounding modes
You can read up on the available rounding modes, or you can use the little table I made. You can also check out the code I used to make this table, just in case you don't trust me.
0.4 | 0.5 | 0.6 | 1.4 | 1.5 | 1.6 | -0.4 | -0.5 | -0.6 | -1.4 | -1.5 | -1.6 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
ROUND_CEILING | 1 | 1 | 1 | 2 | 2 | 2 | -0 | -0 | -0 | -1 | -1 | -1 |
ROUND_DOWN | 0 | 0 | 0 | 1 | 1 | 1 | -0 | -0 | -0 | -1 | -1 | -1 |
ROUND_FLOOR | 0 | 0 | 0 | 1 | 1 | 1 | -1 | -1 | -1 | -2 | -2 | -2 |
ROUND_HALF_DOWN | 0 | 0 | 1 | 1 | 1 | 2 | -0 | -0 | -1 | -1 | -1 | -2 |
ROUND_HALF_EVEN | 0 | 0 | 1 | 1 | 2 | 2 | -0 | -0 | -1 | -1 | -2 | -2 |
ROUND_HALF_UP | 0 | 1 | 1 | 1 | 2 | 2 | -0 | -1 | -1 | -1 | -2 | -2 |
ROUND_UP | 1 | 1 | 1 | 2 | 2 | 2 | -1 | -1 | -1 | -2 | -2 | -2 |
ROUND_05UP | 1 | 1 | 1 | 1 | 1 | 1 | -1 | -1 | -1 | -1 | -1 | -1 |
No, I have no idea why ROUND_05UP
would be a good idea.
The Solution
So, here is how you round like you learnt in school, localised to a specific context:
from decimal import Decimal, Context, ROUND_HALF_UP
ctx = Context(rounding=ROUND_HALF_UP)
rounding_exponent = Decimal('1E-4')
number = ctx.create_decimal_from_float(f)
result = ctx.quantize(number, rounding_exponent)
Change the 4
to however many digits you need!