Python 3 Decimal rounding half down with ROUND_HALF_UP context
Asked Answered
Z

4

15

Can anybody explain or propose a fix for why when I round a decimal in Python 3 with the context set to round half up, it rounds 2.5 to 2, whereas in Python 2 it rounds correctly to 3:

Python 3.4.3 and 3.5.2:

>>> import decimal
>>> context = decimal.getcontext()
>>> context.rounding = decimal.ROUND_HALF_UP
>>> round(decimal.Decimal('2.5'))
2
>>> decimal.Decimal('2.5').__round__()
2
>>> decimal.Decimal('2.5').quantize(decimal.Decimal('1'), rounding=decimal.ROUND_HALF_UP)
Decimal('3')

Python 2.7.6:

>>> import decimal
>>> context = decimal.getcontext()
>>> context.rounding = decimal.ROUND_HALF_UP
>>> round(decimal.Decimal('2.5'))
3.0
>>> decimal.Decimal('2.5').quantize(decimal.Decimal('1'), rounding=decimal.ROUND_HALF_UP)
Decimal('3')
Zeitler answered 16/11, 2016 at 11:54 Comment(0)
C
16

Notice that when you call round you are getting a float value as a result, not a Decimal. round is coercing the value to a float and then rounding that according to the rules for rounding a float.

If you use the optional ndigits parameter when you call round() you will get back a Decimal result and in this case it will round the way you expected.

Python 3.4.1 (default, Sep 24 2015, 20:41:10) 
[GCC 4.9.2 20150212 (Red Hat 4.9.2-6)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import decimal
>>> context = decimal.getcontext()
>>> context.rounding = decimal.ROUND_HALF_UP
>>> round(decimal.Decimal('2.5'), 0)
Decimal('3')

I haven't found where it is documented that round(someDecimal) returns an int but round(someDecimal, ndigits) returns a decimal, but that seems to be what happens in Python 3.3 and later. In Python 2.7 you always get a float back when you call round() but Python 3.3 improved the integration of Decimal with the Python builtins.

As noted in a comment, round() delegates to Decimal.__round__() and that indeed shows the same behaviour:

>>> Decimal('2.5').__round__()
2
>>> Decimal('2.5').__round__(0)
Decimal('3')

I note that the documentation for Fraction says:

__round__()
__round__(ndigits)
The first version returns the nearest int to self, rounding half to even.
The second version rounds self to the nearest multiple of Fraction(1, 10**ndigits)
(logically, if ndigits is negative), again rounding half toward even. 
This method can also be accessed through the round() function.

Thus the behaviour is consistent in that with no argument it changes the type of the result and rounds half to even, however it seems that Decimal fails to document the behaviour of its __round__ method.

Edit to note as Barry Hurley says in the comments, round() is documented as returning a int if called without the optional arguments and a "floating point value" if given the optional argument. https://docs.python.org/3/library/functions.html#round

Circumlocution answered 16/11, 2016 at 12:12 Comment(4)
Thanks @Circumlocution for the explanation, it is always good to try understand these things. It seems very odd that a different type is returned depending on whether you specify ndigits or not.Zeitler
As an add-on, python 3 docs state that it delegates to number.__round__(ndigits) and the decimal module implements has it's own __round__ methodRepentant
@Dunca, it looks to be an int, rather than float though: "If ndigits is omitted or is None, it returns the nearest integer to its input." docs.python.org/3/library/functions.html#roundZeitler
@BarryHurley right you are, so that's the documentation link that I didn't spot (though 'floating point value' isn't perhaps the best way to express it). I've updated my answer to include this.Circumlocution
D
4

Expanding on @Duncan's answer, the round builtin function changed between python 2 and python 3 to round to the nearest even number (which is the norm in statistics).

Python2 docs:

...if two multiples are equally close, rounding is done away from 0 (so, for example, round(0.5) is 1.0 and round(-0.5) is -1.0).

Python3 docs:

...if two multiples are equally close, rounding is done toward the even choice (so, for example, both round(0.5) and round(-0.5) are 0, and round(1.5) is 2)

Since round converts to float if no argument is given for ndigits (credit to @Duncan's answer), round behaves the same way as it would for floats.

Examples (in python3):

>>> round(2.5)
2
>>> round(2.500000001)
3
>>> round(3.5)
4
Dierdredieresis answered 16/11, 2016 at 12:27 Comment(2)
Thanks @Billy, but both round(1.5) and round(2.5) give 2 in Python 3, which I presume is due to how those 1.5 and 2.5 floats are represented internally. round in Python 2 returns floats for these, versus ints int Python 3.Zeitler
It has nothing to do with internal representations. Python's floating point implementation can represent 1.5 and 2.5 precisely (i.e. no floating-point rounding errors like you would get with the number 1.1). Since 1.5 is exactly equal in distance from 1 and 2, python 3 rounds to the nearest even number, which is 2. Similarly, 2.5 is exactly between 2 and 3, so it is rounded to the nearest even number, which is again 2.Dierdredieresis
H
4

This is a combination of changes between the rounding mode of round in Python 2 vs 3 and the re-implementation of Decimal from Python to C (See "Other final large-scale changes" in the Features for 3.3 section PEP 398).

For round, the rounding strategy changed as can be seen in What's New In Python 3.0 [Also see Python 3.x rounding behavior ]. Additionally, round in Python 3 first tries to find an appropriate __round__ method defined for the object passed:

>>> round('1')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: type str doesn't define __round__ method

While in Python 2.x it first tries to coerce it specifically to a float and then round it:

>>> round('1')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: a float is required

For Decimal, in Python 2, the implementation even lacked a __round__ method to be called:

>>> Decimal.__round__
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: type object 'Decimal' has no attribute '__round__'

So, calling round on a Decimal object coerced it to a float which got rounded using _Py_double_round; this resulted in a float always getting returned irregardless of if a value for ndigits was supplied. decimal is implemented in pure Python for 2.x and (was?) for Python 3 until 3.2.

In Python 3.3 it got shinny new __round__ method as it was re-implemented in C:

>>> Decimal.__round__
<method '__round__' of 'decimal.Decimal' objects>

and now, it gets picked up by round when round(<Decimal_object>) is invoked.

This, mapped to PyDec_Round in C, now returns a PyLong (an integer) using the default context (ROUND_HALF_EVEN) if the argument ndigits is not supplied and, if it is, calls quantize on it and returns a new rounded Decimal object.

Hallee answered 16/11, 2016 at 13:22 Comment(0)
P
1

German and Swiss laws require you to round up VAT from 0.005 to 0.01 ("kaufmännisches Runden"). I played around with Decimal and this what I ended up with:

from decimal import *

class Number(object):
    '''generic class for rounding:
        Banking:     ROUND_HALF_EVEN  # default
        Germany:     ROUND_HALF_UP    # for VAT
        Switzerland: ROUND_DOWN       # for some taxes
                     ROUND_HALF_UP    # for some taxes like VAT
    '''
    def __init__(self, digits=2, rounding=ROUND_HALF_UP):
        getcontext().rounding=rounding        
        self.base = Decimal(10) ** -digits
        
    def round(self, value, digits=None):
        if digits:
            base = Decimal(10) ** - digits
        else:
            base = self.base
        return float(Decimal(str(value)).quantize(base))  

Example:

>>> a = Number()
>>> a.round(1301.685)
1301.69
Pectin answered 7/12, 2022 at 17:59 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.