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
Ma0
  • 15,057
  • 4
  • 35
  • 65
  • Could you please explain how this two things are the same? – Ma0 Feb 09 '18 at 13:42
  • The reason is *exactly* as described in that question. – Ignacio Vazquez-Abrams Feb 09 '18 at 13:42
  • Well, `3602879701896397/18014398509481984` is equal to 0.2... – Arnav Borborah Feb 09 '18 at 13:42
  • @ArnavBorborah I am not saying that the result is wrong; I am just surprised it is not the same – Ma0 Feb 09 '18 at 13:43
  • @ArnavBorborah, technically this is true: `3602879701896397/18014398509481985 = 0.2` – jpp Feb 09 '18 at 13:48
  • @jp_data_analysis I don't get what you are trying to say. Isn't that what I said? – Arnav Borborah Feb 09 '18 at 13:50
  • no, look at the last digit. remember 7*5 = 35, so the denominator "should" end in 5. – jpp Feb 09 '18 at 13:51
  • 1
    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)" https://docs.python.org/3.1/library/fractions.html#fractions.Fraction.from_float – Chris_Rands Feb 09 '18 at 13:57
  • 1
    `Fraction` class treats `float/Decimal` in a different way then `str` argument. Have a look at [souce code](https://github.com/python/cpython/blob/3.6/Lib/fractions.py#L133) of it, specially [this regex](https://github.com/python/cpython/blob/3.6/Lib/fractions.py#L45) – Sohaib Farooqi Feb 09 '18 at 14:01
  • 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. – jpp Feb 09 '18 at 14:55

3 Answers3

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
Patrick Artner
  • 50,409
  • 9
  • 43
  • 69
  • your answer has been very helpful. Thanks a lot! – Ma0 Feb 09 '18 at 14:11
  • 1
    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”. – Eric Postpischil Feb 09 '18 at 15:55
  • @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? – Patrick Artner Feb 09 '18 at 17:36
  • 1
    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. – Eric Postpischil Feb 10 '18 at 15:02
  • 2
    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. – Mark Dickinson Feb 11 '18 at 14:23
  • @MarkDickinson reworded it, could you take another look? – Patrick Artner Feb 11 '18 at 14:52
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.

Eric Postpischil
  • 195,579
  • 13
  • 168
  • 312
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.

jpp
  • 159,742
  • 34
  • 281
  • 339
  • 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-ratio](https://stackoverflow.com/questions/2076290/implementation-limitations-of-float-as-integer-ratio) – Patrick Artner Feb 09 '18 at 14:15
  • thank you, this is an actual explanation. you'd think `fractions` module would align output from equivalent `str()` and `float` inputs though. – jpp Feb 09 '18 at 14:19