Search code examples
pythonfloating-pointfractions

fractions.Fraction() returns different nom., denom. pair when parsing a float or its string representation


I am aware of the nature of floating point math but I still find the following surprising:

from fractions import Fraction

print(Fraction(0.2))       # -> 3602879701896397/18014398509481984
print(Fraction(str(0.2)))  # -> 1/5

print(Fraction(0.2)==Fraction(str(0.2)))  # returns False
print(0.2 == float(str(0.2)))             # but this returns True!

From the documentation I could not find anything that would explain that. It does state:

...In addition, any string that represents a finite value and is accepted by the float constructor is also accepted by the Fraction constructor...

but to me this implies a similar behavior to float() which I just do not see as shown above.

Is there any explanation for this?


It is important to note that the behavior shown above is not specific to the value (0.2) but rather general; everything I tried behaved the same way.


Interestingly enough:

from fractions import Fraction


for x in range(1, 257):
    if Fraction(str(1/x))==Fraction(1/x):
        print(x)

prints only the powers of 2 that are smaller than the selected upper bound:

1
2
4
8
16
32
64
128
256

Solution

  • Have a look at the def __new__(): implementation in fractions.py, if a string is given:

    The regex _RATIONAL_FORMAT ( see link if you are interested in the parsing part) puts out numerator as 0 and decimal as 2

    Start quote from fractions.py source, with comments by me

    elif isinstance(numerator, str):
        # Handle construction from strings.
        m = _RATIONAL_FORMAT.match(numerator)
        if m is None:
            raise ValueError('Invalid literal for Fraction: %r' %
                             numerator)
        numerator = int(m.group('num') or '0')       # 0
        denom = m.group('denom')                     
        if denom:                                    # not true for your case
            denominator = int(denom)
        else:                                        # we are here
            denominator = 1
            decimal = m.group('decimal')             # yep: 2
            if decimal:
                scale = 10**len(decimal)             # thats 10^1
                numerator = numerator * scale + int(decimal)    # thats 0 * 10^1+0 = 10
                denominator *= scale                 # thats 1*2
            exp = m.group('exp')  
            if exp:                                  # false
                exp = int(exp)
                if exp >= 0:
                    numerator *= 10**exp
                else:
                    denominator *= 10**-exp
        if m.group('sign') == '-':                   # false
            numerator = -numerator
    
    else:
        raise TypeError("argument should be a string "
                        "or a Rational instance")
    

    end quote from source

    So '0.2' is parsed to 2 / 10 = 0.2 exactly, not its nearest float approximation wich my calculater puts out at 0,20000000000000001110223024625157

    Quintessential: they are not simply using float( yourstring ) but are parsing and calculating the string itself, that is why both differ.

    If you use the same constructor and provide a float or decimal the constructor uses the builtin as_integer_ratio() to get numerator and denominator as representation of that number.

    The closest the float representation comes to 0.2 is 0,20000000000000001110223024625157 which is exactly what the as_integer_ratio() method returns nominator and denominator for.

    As eric-postpischil and mark-dickinson pointed out, this float value is limited by its binary representations to "close to 0.2". When put into str() will be truncated to exact '0.2' - hence the differences between

    print(Fraction(0.2))       # -> 3602879701896397/18014398509481984
    print(Fraction(str(0.2)))  # -> 1/5