[Python-ideas] numerical type combining integer and float/decimal properties [Was: Re: Python Numbers as Human Concept Decimal System]

Wolfgang Maier wolfgang.maier at biologie.uni-freiburg.de
Wed Mar 12 16:33:49 CET 2014


Wolfgang Maier <wolfgang.maier at ...> writes:

> 
> Am Montag, 10. März 2014 14:53:42 UTC+1 schrieb Stefan Krah:
> 
> > [My apologies for being terse, I don't have much time to follow this
> discussion right now.]
> 
> > Nick Coghlan <ncoghlan <at> gmail.com> wrote:
> >> I think users of decimal literals will just need to deal with the risk of
> unexpected rounding, as the alternatives are even more problematic.
> 
> > That is why I think we should seriously consider moving to IEEE semantics
> for a decimal literal.  Among other things:
> > - Always round the literal inputs.
> > - Supply IEEE contexts.
> > - Make Decimal64 the default.
> > - Add the missing rounding modes to sqrt() and exp().
> > - Keep the ability to create exact Decimals through the constructor when
> no context is passed.
> > - Make the Decimal constructor use the context for rounding if it is passed.
> > - ...
> 
> While I find this discussion about decimal literals extremely interesting,
> in my opinion, such a literal should have an underlying completely new
> numerical type, if it is really supposed to be for inexperienced users.
> 
> Most of the discussions right now concern rounding issues that occur after
> the decimal point, but I think an at least equally big problem is rounding
> *to the left* of it as in (using current float):
> 
> >>> 1e50 + 1000
> 1e+50
> 
> Importantly, Decimal is no cure here:
> 
> >>> Decimal(10**50) + Decimal(100)
> Decimal('1.000000000000000000000000000E+50')
> 
> (of course, you can debate context settings to make this particular example
> work, but, in general, it happens with big enough numbers.)
> 
> The solution for this example is using ints of course:
> 
> >>> 10**50 + 100
> 100000000000000000000000000000000000000000000000100
> 
> , but obviously this works only for whole numbers, so there currently is no
> built-in way to make this example work correctly:
> 
> >>> 10**50 - 9999999999999999.5
> 1e+50
> 
> (or with Decimal:
> >>> Decimal(10**50) - Decimal('9999999999999999.5')
> Decimal('1.000000000000000000000000000E+50')
> ).
> 
> If we are discussing a new number literal I would like to have it cope with
> this and my suggestion is a new numeric type that's sort of a hybrid between
> int and either Decimal or float, i.e., a type that behaves like int for
> digits left of the decimal point, but may truncate to the right.
> In pure Python, something similar (but not a literal form of course) could
> be implemented as a class that stores the left digits as an int and the
> right digits as a float/Decimal internally. Calculations involving this
> class would be slow due to the constant need of shifting digits between the
> integer´and the float part, and might be too slow for many users even when
> written in C, but its behavior would meet the expectation of inexperienced
> people better than the existing types.
> Going back to Mark Harris' initial proposal of unifying numeric types (which
> I am not trying to support in any way here), such a type would even allow to
> unify int and float since an ints could be considered a subset of the new
> type with a fractional part of zero.
> 

So, I've tried my hands on the pure Python implementation of this (code
below) to make it easier for people to play around with this idea. I found
it easier in the end to represent both the integral and the fractional part
of the new type as Python ints (and restricting the precision for the
fractional component).
In the compound object the integral part has arbitrary precision, the
fractional part a fixed precision of 28 digits by default (and in response
to Guido: this is constant even with leading zeros), but this can be changed
temporarily through a context manager (a very simple one though).

This is a very preliminary version that is incomplete still and could
certainly be optimized in many places. It's also slow, though maybe not as
slow as I thought (it's definitely fun to work with it on simple problems in
interactive mode).

Feedback is welcome,
Wolfgang

Here's the code:

"""
Test of a new numeric class PyNumber.

Usage:

Generate a new PyNumber with a fractional component of 0 (equal to an int):

>>> PyNumber(3)
PyNumber('3.0')

Generate a PyNumber with a fractional component Decimal('0.1'):

>>> PyNumber(3, Decimal('0.1'))
PyNumber('3.1')

Generate the same PyNumber by specifying fractional as int and exponent:

>>> PyNumber(3, 1, exp = 1)
PyNumber('3.1')

Generate the same PyNumber again using a string:

>>> PyNumber('3.1')
PyNumber('3.1')

Control decimal precision through context manager (default precision is 28):

>>> with Prec(3):
    PyNumber('0.333333333333333')
PyNumber('0.333')

>>> with Prec(7):
    PyNumber('1') / PyNumber('3')
PyNumber('0.3333333')

Addition, subtraction, multiplication and division work as expected, but
currently only between PyNumbers.

"""

from decimal import *
from math import log, floor

max_exp = 28
    
class Prec (object):
    def __init__ (self, prec):
        self.prec = prec

    def __enter__ (self):
        global max_exp
        self.old_prec = max_exp
        max_exp = self.prec

    def __exit__ (self, *_):
        global max_exp
        max_exp = self.old_prec
        
class PyNumber (object):
    """Numeric type for intuitive calculations.

    Supports arbitrary precision of integral component, i.e., left of the
    decimal point, and provides fixed precision (module default: 28 digits)
    for the fractional component, i.e. right of the decimal point.

    Behaves well with calculations involving huge and small numbers, for which
    float and Decimal produce rounding errors.

    Modified sum example from statistics module:
    
    >>> sum([1e50, 1.1, -1e50] * 1000) # floating point calculation gives zero.
    0.0

    >>> sum([PyNumber(10**50), PyNumber('1.1'), PyNumber(-10**50)] * 1000,
PyNumber(0))
    PyNumber('1100.0')

    """
    
    def __init__ (self, integral, fractional = 0, exp = 0):
        # PyNumber objects are composed of two integers, one unbounded for
        # representing the integral part of a number, the other restricted to
        # max_exp digits for representing the fractional component.
        
        if isinstance(integral, int):
            if integral >= 0:
                self.integral = integral
                self.sign = 1
            else:
                self.integral = -integral
                self.sign = -1
            if fractional < 0:
                raise TypeError ('negative fraction not allowed')
            if isinstance(fractional, int):
                self.fractional = fractional
                if fractional > 0:
                    if exp == 0:
                        self.exp = floor(log(fractional, 10)+1)
                    else:
                        self.exp = exp
                elif fractional == 0:
                    self.exp = 0
                else:
                    raise TypeError ('Unknown error caused by fractional.')
            elif isinstance(fractional, Decimal):
                if fractional >= 1:
                    raise ValueError ('need a fraction < 1.')
                sign, digits, exp = fractional.as_tuple()
                self.fractional = 0
                for digit in digits:
                    self.fractional = self.fractional*10 + digit
                self.exp = -exp
            else:
                raise TypeError ('Expected int or Decimal for fractional part.')
        elif isinstance(integral, str):
            x = integral.split('.')
            if len(x) > 2:
                raise ValueError('Float format string expected.')
            self.integral = int(x[0])
            if self.integral >= 0:
                self.sign = 1
            else:
                self.integral = -self.integral
                self.sign = -1
            if len(x) == 2:
                self.fractional = int(x[1])
            else:
                self.fractional = 0
            if self.fractional > 0:
                self.exp = len(x[1])
            else:
                self.exp = 0
        else:
            raise TypeError('Expected int or str as first argument.')
        if self.exp > max_exp:
            self.fractional //= 10**(self.exp-max_exp)
            self.exp = max_exp

    def __add__ (self, other):
        if self.sign == -1:
            if other.sign == 1:
                # -1 + 2 = 2 - 1
                return PyNumber(*other._sub_absolute(self))
        if other.sign == -1:
            if self.sign == 1:
                # 1 + (-2) = 1 - 2
                return PyNumber(*self._sub_absolute(other))
        if self.exp >= other.exp:
            integral, fractional = self._add_absolute(other)
        else:
            integral, fractional = other._add_absolute(self)
        return PyNumber(self.sign * integral, fractional, self.exp)

    def _add_absolute(self, other):
        # add integral components.
        integral = self.integral + other.integral
        # adjust and add fractional components
        fractional = self.fractional + other.fractional *
10**(self.exp-other.exp)
        # determine overflow of fractional sum into integral part 
        overflow = fractional // 10**self.exp
        if overflow:
            integral += overflow
            fractional -= overflow * 10**self.exp
        return integral, fractional
    
    def __sub__ (self, other):
        if self.sign == -1 and other.sign == 1:
            # -1 - 2 = -(1 + 2) 
            integral, fractional = self._add_absolute(other)
            return PyNumber(-integral, fractional, self.exp)
        if other.sign == -1 and self.sign == 1:
            # 1 - (-2) = 1 + 2
            return PyNumber(*self._add_absolute(other), exp = self.exp)
        if other.sign == -1 and self.sign == -1:
            # -1 - (-2) = 2 - 1
            return PyNumber(*other._sub_absolute(self))
        return PyNumber(*self._sub_absolute(other))

    def _sub_absolute (self, other):
        sign = 1
        # merge integral and fractional components into big ints.
        a = self.integral * 10**self.exp + self.fractional
        b = other.integral * 10**other.exp + other.fractional
        # adjust the two representations.
        if self.exp > other.exp:
            b *= 10**(self.exp-other.exp)
        elif other.exp > self.exp:
            a *= 10**(other.exp-self.exp)
        # subtract.
        diff = a-b
        if diff < 0:
            diff = -diff
            sign = -1
        # split the big int back into integral and fractional.
        shift = max(self.exp, other.exp)
        integral, fractional = divmod(diff, 10**shift)
        return sign * integral, fractional, shift
    
    def __mul__ (self, other):
        if self.exp < other.exp:
            return other * self
        # multiply the integral components.
        p1 = self.integral * other.integral
        # adjust the fractional parts.
        f1 = self.fractional
        f2 = other.fractional * 10**(self.exp-other.exp)
        # cross-multiply integral and fractional components.
        p2 = (
            self.integral * f2 * 10**self.exp +
            other.integral * f1 * 10**self.exp +
            f1 * f2)
        # determine overflow of cross-multiplication into integral part.
        shift = 2 * self.exp
        overflow = p2 // 10**shift
        if overflow > 0:
            p1 += overflow
            p2 -= overflow * 10**shift
        # truncate trailing zeros.
        while not p2 % 10:
            p2 //= 10
            shift -= 1
        sign = self.sign * other.sign
        return PyNumber(sign*p1, p2, shift)

    def __truediv__ (self, other):
        # merge integral and fractional components into big ints.
        n = self.integral * 10**self.exp + self.fractional
        d = other.integral * 10**other.exp + other.fractional
        # adjust the two representations.
        if self.exp > other.exp:
            d *= 10**(self.exp-other.exp)
        elif other.exp > self.exp:
            n *= 10**(other.exp-self.exp)
        # divide.
        integral, remainder = divmod(n, d)
        # turn remainder into fractional component.
        remainder *= 10
        shift = 1
        while remainder % d and shift < max_exp:
            remainder *= 10
            shift += 1
        fractional = remainder // d
        sign = self.sign * other.sign
        return PyNumber(sign * integral, fractional, shift)

    def __int__ (self):
        return self.integral
    
    def __float__ (self):
        return self.sign * (self.integral + self.fractional / 10**self.exp)

    def __repr__ (self):
        return "PyNumber('{0}.{1}{2}')".format(self.sign * self.integral,
'0'*(self.exp-len(str(self.fractional))), self.fractional)




More information about the Python-ideas mailing list