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

3

9

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
Kalakalaazar answered 9/2, 2018 at 13:34 Comment(10)
Could you please explain how this two things are the same?Kalakalaazar
The reason is exactly as described in that question.Prevail
Well, 3602879701896397/18014398509481984 is equal to 0.2...Sequential
@ArnavBorborah I am not saying that the result is wrong; I am just surprised it is not the sameKalakalaazar
@ArnavBorborah, technically this is true: 3602879701896397/18014398509481985 = 0.2Dartboard
@jp_data_analysis I don't get what you are trying to say. Isn't that what I said?Sequential
no, look at the last digit. remember 7*5 = 35, so the denominator "should" end in 5.Dartboard
try 0.2.as_integer_ratio(), I think this is the relevant part from the docs, but it's rather brief: "Beware that Fraction.from_float(0.3) is not the same value as Fraction(3, 10)" docs.python.org/3.1/library/…Godspeed
Fraction class treats float/Decimal in a different way then str argument. Have a look at souce code of it, specially this regexInterference
perhaps it's not counterintuitive that fractions module is more accurate with a string than with a float input. because floats are represented non-exactly in python, while strings need specific interpretation which can be made more accurate.Dartboard
W
6

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
Whitehot answered 9/2, 2018 at 13:59 Comment(6)
your answer has been very helpful. Thanks a lot!Kalakalaazar
This answer describes part of why print(Fraction(str(0.2))) prints “1/5” (it omits the fact that str(0.2) produces “0.2”) but does not address why print(Fraction(0.2)) prints “3602879701896397/18014398509481984”.Synergist
@EricPostpischil - it prints 3602879701896397/18014398509481984 because that is what float(0.2).as_integer_ratio() returns - wich is handled in the last 3 lines of this answer? should I state it better?Whitehot
I see. It is not clear that the last sentence discusses print(Fraction(0.2)). Generally, why take so much text and code to explain that “0.2” is analyzed to produce 1/5? That is a normally expected result; it does not really require explanation. There are really just two complications in the OP’s question: In Fraction(0.2), the source text 0.2 does not produce a floating-pont value that is exactly 0.2, and, in str(0.2), it does produce a string that is “0.2” even though the argument to str is not exactly 0.2. Once those are explained, everything else follows.Synergist
The float.as_integer_ratio() method is perfectly accurate. It returns the numerator and denominator of a fraction whose value is precisely equal to the float that it's given. The inaccuracy that you're referring to is introduced when parsing the 0.2 literal in the source code to the corresponding binary64 float. as_integer_ratio has nothing to do with it. Please read the comments on the top answer to the question that you link to.Englishry
@MarkDickinson reworded it, could you take another look?Whitehot
S
3

In print(Fraction(0.2)), the source text 0.2 is converted to a floating-point value. The result of this conversion is exactly 0.200000000000000011102230246251565404236316680908203125, or 3602879701896397/18014398509481984. This value is then passed to Fraction, which produces the same value represented as a rational number.

In print(Fraction(str(0.2))), 0.2 is again converted to a floating-point value, yielding the number above. Then str converts it to a string. In current Python versions, when a floating-point value is converted to a string, Python does not generally produce the exact mathematical value. Instead, it produces the just enough digits so that converting the string back to floating-point produces the input number. In this case, that results in “0.2”. So the string “0.2” is passed to Fraction. Then Fraction analyzes “0.2” and determines it is 1/5.

Synergist answered 9/2, 2018 at 14:41 Comment(0)
D
1

Notice the last digit in the denominator. It appears the fractions module takes this into consideration when storing the object internally, but when used in operations python can round.

from fractions import Fraction

Fraction(3602879701896397, 18014398509481985)  == Fraction(1, 5)   # True
Fraction(3602879701896397, 18014398509481984) == Fraction(1, 5)    # False
3602879701896397 / 18014398509481985 == 0.2  # True
3602879701896397 / 18014398509481984 == 0.2  # True

Now the question of why the fractions module chooses an approximation (i.e. 18014398509481984 instead of correct 18014398509481985) is not one I can answer.

Dartboard answered 9/2, 2018 at 13:45 Comment(2)
When you give a float or decimals as constructor argument fractions.py uses the build in .as_integer_ratio() to get the nominator/denominator wich is an approximation according to implementation-limitations-of-float-as-integer-ratioWhitehot
thank you, this is an actual explanation. you'd think fractions module would align output from equivalent str() and float inputs though.Dartboard

© 2022 - 2024 — McMap. All rights reserved.