4

First of all I would like to mention that this question is not a duplicate of:

Python Rounding Inconsistently

Python 3.x rounding behavior

I know about IEEE 754 and I know that:

The simple "always round 0.5 up" technique results in a slight bias toward the higher number. With large numbers of calculations, this can be significant. The Python 3.0 approach eliminates this issue.

I agree that ROUND_HALF_UP is inferior method to the one implemented by default in Python. Nevertheless there are people who do not know that and one needs to use that method if the specs require that. Easy way to make this work is:

def round_my(num, precission):
    exp  = 2*10**(-precission)
    temp = num * exp
    if temp%2 < 1:
        return int(temp - temp%2)/exp
    else:
        return int(temp - temp%2 + 2)/exp

But my consideration is that this is not Pythonic... According to the docs I should use something like:

def round_my(num, pricission):
    N_PLACES = Decimal(10) ** pricission       # same as Decimal('0.01')
    # Round to n places
    Decimal(num).quantize(N_PLACES)

The problem is that this would not pass all test cases:

class myRound(unittest.TestCase):
    def test_1(self):
        self.assertEqual(piotrSQL.round_my(1.53, -1), 1.5)
        self.assertEqual(piotrSQL.round_my(1.55, -1), 1.6)
        self.assertEqual(piotrSQL.round_my(1.63, -1), 1.6)
        self.assertEqual(piotrSQL.round_my(1.65, -1), 1.7)
        self.assertEqual(piotrSQL.round_my(1.53, -2), 1.53)
        self.assertEqual(piotrSQL.round_my(1.53, -3), 1.53)
        self.assertEqual(piotrSQL.round_my(1.53,  0), 2)
        self.assertEqual(piotrSQL.round_my(1.53,  1), 0)
        self.assertEqual(piotrSQL.round_my(15.3,  1), 20)
        self.assertEqual(piotrSQL.round_my(157.3,  2), 200)

Because of the nature of conversion between float and decimal and because quantize does not seem to be working for exponents like 10 or 100. Is there a Pythonic way to do this?

And I know that I could just add infinitesimally small number and round(num+10**(precission-20),-pricission) would work but this is so wrong that "the puppies would die"...

Cœur
  • 37,241
  • 25
  • 195
  • 267
Piotr Siejda
  • 197
  • 8
  • 1
    You know that for example `1.65` wouldn't qualify for "rounding up" because it's really `1.649999999999999911182158029987...`? – MSeifert Jul 13 '17 at 14:30
  • I know - that is why I have written: "Because of the nature of converssion between float and decimal" - but you are right I should have been more precise. On the other hand I am now wondering that I probably should have just omitted the floats and performed all the calculations on decimal numbers... – Piotr Siejda Jul 13 '17 at 14:31
  • 3
    No, that's not the problem. The problem is that you have `float`s at all. There is no way to recover the string that created a float. Keep them as strings or Decimals throughout your code to avoid that "one" trap – MSeifert Jul 13 '17 at 14:33
  • Sorry - I have eddited my reply before you have posed yours... Yes - you are correct... Your answear closes the issue. – Piotr Siejda Jul 13 '17 at 14:34

2 Answers2

3

As you said that doesn't work if you try to quantize with numbers greater than 1:

>>> Decimal('1.5').quantize(Decimal('10'))
Decimal('2')
>>> Decimal('1.5').quantize(Decimal('100'))
Decimal('2')

But you can simply divide, quantize and multiply:

from decimal import Decimal, ROUND_HALF_UP

def round_my(num, precision):
    N_PLACES = Decimal(10) ** precision
    # Round to n places
    return (Decimal(num) / N_PLACES).quantize(1, ROUND_HALF_UP) * N_PLACES

However that only passes the tests if you input Decimal and compare to Decimal:

assert round_my('1.53', -1) == Decimal('1.5')
assert round_my('1.55', -1) == Decimal('1.6')
assert round_my('1.63', -1) == Decimal('1.6')
assert round_my('1.65', -1) == Decimal('1.7')
assert round_my('1.53', -2) == Decimal('1.53')
assert round_my('1.53', -3) == Decimal('1.53')
assert round_my('1.53',  0) == Decimal('2')
assert round_my('1.53',  1) == Decimal('0')
assert round_my('15.3',  1) == Decimal('20')
assert round_my('157.3',  2) == Decimal('200')

As noted in the comments it's possible to use scientific notation decimals as "working" quantize arguments, which simplifies the function:

def round_my(num, precision):
    quant_level = Decimal('1e{}'.format(precision))
    return Decimal(num).quantize(quant_level, ROUND_HALF_UP) 

This also passes the test cases mentioned above.

MSeifert
  • 145,886
  • 38
  • 333
  • 352
  • 2
    Quantizing with `Decimal('1e1')` rather than `Decimal('10')` should work as expected. – Mark Dickinson Jul 13 '17 at 17:53
  • @MarkDickinson That seems to work. I'm a bit surprised, do you know why that works? – MSeifert Jul 13 '17 at 19:50
  • 3
    The `quantize` method uses the exponent of the second argument to determine how to quantize. It's a bit of a strange design, but it's what's in the specification that the `decimal` module is based on. (So quantizing with `Decimal('2e1')` or `Decimal('13e1')` would also work.) – Mark Dickinson Jul 13 '17 at 20:15
  • Your methods always return a `Decimal`, when they should probably return the input type. – Boris Verkhovskiy Nov 05 '20 at 19:40
  • @Boris You can always convert it to another type again. However the conversion decimal -> float may result in loss of precision so you should consider if you really want that. – MSeifert Nov 05 '20 at 20:50
  • Rather than show the edge case of quantizing with Decimals that don't have an exponent, then showing how to do it with an exponent, how about just editing your answer to show (objectively?) the proper way up front? – Zach Young Mar 30 '22 at 17:29
0

Here's a version that matches the behavior and API of the builtin round() function:

from decimal import Decimal, ROUND_HALF_UP

def round_half_up(number, ndigits=None):
    return_type = type(number)
    if ndigits is None:
        ndigits = 0
        return_type = int
    if not isinstance(ndigits, int):
        msg = f"'{type(ndigits).__name__}' object cannot be interpreted as an integer"
        raise TypeError(msg)
    quant_level = Decimal(f"10E{-ndigits}")
    return return_type(Decimal(number).quantize(quant_level, ROUND_HALF_UP))
Boris Verkhovskiy
  • 14,854
  • 11
  • 100
  • 103