classification
Title: Fraction modulo infinity should behave consistently with other numbers
Type: Stage: resolved
Components: Interpreter Core Versions: Python 3.8
process
Status: closed Resolution: fixed
Dependencies: Superseder:
Assigned To: mark.dickinson Nosy List: elias, jyasskin, mark.dickinson, rhettinger, serhiy.storchaka
Priority: normal Keywords: patch

Created on 2018-02-28 03:10 by elias, last changed 2018-08-27 07:13 by mark.dickinson. This issue is now closed.

Pull Requests
URL Status Linked Edit
PR 5956 merged elias, 2018-03-01 22:10
Messages (23)
msg313045 - (view) Author: Elias Zamaria (elias) * Date: 2018-02-28 03:10
Usually, a positive finite number modulo infinity is itself. But modding a positive fraction by infinity produces nan:

>>> from fractions import Fraction
>>> from math import inf
>>> 3 % inf
3.0
>>> 3.5 % inf
3.5
>>> Fraction('1/3') % inf
nan

Likewise, a positive number modulo negative infinity is usually negative infinity, a negative number modulo infinity is usually infinity, and a negative number modulo negative infinity is usually itself, unless the number doing the modding is a fraction, in which case it produces nan.

I think fractions should behave like other numbers in cases like these. I don't think this comes up very often in practical situations, but it is inconsistent behavior that may surprise people.

I looked at the fractions module. It seems like this can be fixed by putting the following lines at the top of the __mod__ method of the Fraction class:

    if b == math.inf:
        if a >= 0:
            return a
        else:
            return math.inf
    elif b == -math.inf:
        if a >= 0:
            return -math.inf
        else:
            return a


If that is too verbose, it can also be fixed with these lines, although this is less understandable IMO:

    if math.isinf(b):
        return a if (a >= 0) == (b > 0) else math.copysign(math.inf, b)


I noticed this in Python 3.6.4 on OS X 10.12.6. If anyone wants, I can come up with a patch with some tests.
msg313059 - (view) Author: Mark Dickinson (mark.dickinson) * (Python committer) Date: 2018-02-28 16:12
I'm not quite sure why `Fraction_instance % float_instance` wouldn't simply convert the `Fraction` to a `float` and then use the usual floating-point mod. There's the issue that you want to maintain consistency with the floordiv operation, but if `Fraction_instance // float_instance` _also_ converts its first argument to float, then that consistency should follow automatically.

That's the pattern that's followed for most of the other binary arithmetic operators. It looks like the inconsistency is caused by using `_operator_fallbacks` for most of the operations, but doing something more ad-hoc for modulo.

I agree that the current behaviour is surprising.
msg313081 - (view) Author: Elias Zamaria (elias) * Date: 2018-03-01 05:17
Mark, you have some good ideas. A fraction modulo a float is a float, and an integer modulo infinity produces itself as a float, so it seems reasonable that a fraction modulo infinity should be itself converted to a float.

I tried assigning __mod__ and __rmod__ using _operator_fallbacks, like most of the binary operators, and calculations involving infinity behaved as I expected, but one test failed (1.0 % Fraction(1, 10) was no longer 0 because of rounding error).

But then I tried assigning only __mod__ using _operator_fallbacks, and leaving __rmod__ alone, and all the tests passed (including some ones I added to make sure that modulo calculations involving infinity behave like both of us think they should).

As for the floordiv operator, I'm not sure what to think. It would be a bit strange if mod with a float returns a float and floordiv with a float returns an int. It wouldn't be a big deal IMO but it may be easy to change. I tried a simple change to make floordiv with a float to return a float, and changed one test to make sure that it works, and all of the tests passed.

I can make a pull request if anyone wants.
msg313088 - (view) Author: Mark Dickinson (mark.dickinson) * (Python committer) Date: 2018-03-01 09:50
> It would be a bit strange if mod with a float returns a float and floordiv with a float returns an int.

Agreed: if there are floats involved (Fraction // float or float // Fraction), I think the rule should be that we simply fall back to float behaviour, and do whatever float // float does - i.e., return a float. (I'd actually prefer that a mixed Fraction-float operation be a TypeError, but that ship sailed long ago.)

OTOH, if there's a test that's explicitly checking that `my_fraction // my_float` returns something of type `int`, then the behaviour is clearly intentional. That means that we should be cautious about changing it, and also means that we probably can't change it in 3.6 or 3.7; it'll have to wait for 3.8. (But I think that's fine; this seems like a minor consistency cleanup that I would expect to affect very few people.)

It looks as though this was a part of PEP 3141 that was never fully implemented: the "Real" type there has a __floordiv__ method that looks like this:

    @abstractmethod
    def __floordiv__(self, other):
        """The floor() of self/other. Integral."""
        raise NotImplementedError

But float // float does *not* currently return an Integral:

    >>> import numbers
    >>> x = 2.3
    >>> isinstance(x, numbers.Real)
    True
    >>> isinstance(x // x, numbers.Integral)
    False

And that definitely shouldn't change: I'd argue against such a change in any case, but backwards compatibility considerations alone mean that we shouldn't change this now to return an integer. Given that, I think it's acceptable to have a mixed fraction-float floor division return a float.

A pull request would be great. Yes, please!
msg313089 - (view) Author: Mark Dickinson (mark.dickinson) * (Python committer) Date: 2018-03-01 10:01
> I'd argue against such a change in any case

And apparently I already did: see issue 22444 for previous discussion on the topic.
msg313092 - (view) Author: Serhiy Storchaka (serhiy.storchaka) * (Python committer) Date: 2018-03-01 11:03
Not all Fractions can be converted to float.

>>> Fraction(2**2000, 3) // 1.0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/serhiy/py/cpython/Lib/fractions.py", line 432, in __floordiv__
    return math.floor(a / b)
  File "/home/serhiy/py/cpython/Lib/fractions.py", line 378, in forward
    return fallback_operator(float(a), b)
  File "/home/serhiy/py/cpython/Lib/numbers.py", line 291, in __float__
    return self.numerator / self.denominator
OverflowError: integer division result too large for a float

What is surprising that the modulo operation can fail even if the end result could be converted to float.

>>> Fraction(2**2000, 3) % 1
Fraction(1, 3)
>>> Fraction(2**2000, 3) % 1.0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/serhiy/py/cpython/Lib/fractions.py", line 440, in __mod__
    div = a // b
  File "/home/serhiy/py/cpython/Lib/fractions.py", line 432, in __floordiv__
    return math.floor(a / b)
  File "/home/serhiy/py/cpython/Lib/fractions.py", line 378, in forward
    return fallback_operator(float(a), b)
  File "/home/serhiy/py/cpython/Lib/numbers.py", line 291, in __float__
    return self.numerator / self.denominator
OverflowError: integer division result too large for a float
msg313103 - (view) Author: Mark Dickinson (mark.dickinson) * (Python committer) Date: 2018-03-01 18:16
Serhiy: I think both those results are reasonable, and not too surprising. It should be easy to understand that the mixed-type operation converts one of the arguments to float, and if that conversion overflows we get an exception. A similar thing happens when doing something like `math.sqrt(2**1024)`, even though the result is representable.
msg313113 - (view) Author: Elias Zamaria (elias) * Date: 2018-03-01 22:00
Mark, you said "if there's a test that's explicitly checking that `my_fraction // my_float` returns something of type `int`, then the behaviour is clearly intentional". I see exactly that. See https://github.com/python/cpython/blob/bf63e8d55fd2853df3bb99d66de7f428107aadb3/Lib/test/test_fractions.py#L404  In fact, when I said I made floordiv return a float, and "changed one test to make sure that it works", it was that test. So maybe that means we should be more cautious about changing the behavior of floordiv, if we decide to change it.
msg313114 - (view) Author: Elias Zamaria (elias) * Date: 2018-03-01 22:16
I added the pull request. It includes only my changes to modulo, and not the ones to floordiv.
msg313135 - (view) Author: Mark Dickinson (mark.dickinson) * (Python committer) Date: 2018-03-02 15:47
[Elias Zamaria]

> I see exactly that.

Understood; that's how I interpreted your "changed one test to make sure that it works". It seems we understand each other. :-)

So yes, one always needs to be very cautious about changing deliberate, tested behaviour. But in this case I'm satisfied that I understand what the intent was when that test was written, namely that `Real.__floordiv__` returns an `Integral`. And since that part of PEP 3141 didn't actually get implemented for float // float (and the agreement on #22444 was that the PEP should be changed rather than the behaviour), that intent is now a little bogus, and I think it's reasonable to change the behaviour in this case. The change would make the overall behaviour of mixed-type operations simple to state and understand, and fix a consistency bug. It's also sufficiently much of a corner case that the change seems unlikely to break existing code.

In short, I'm +1 on making your suggested change to __floordiv__ as well as to __mod__.

I'm adding Jeffrey Yasskin (the original PEP 3141 author) to the nosy list, in case he has any comment. (I don't believe Jeffrey is still watching Python development, but would be happy to be proved wrong.)
msg313154 - (view) Author: Elias Zamaria (elias) * Date: 2018-03-02 18:32
Any suggestions as to what I should do? I can either update my pull request with my floordiv change, or create a new pull request, or wait a while to see if anyone else has any opinion on changing the behavior.
msg313239 - (view) Author: Mark Dickinson (mark.dickinson) * (Python committer) Date: 2018-03-05 08:29
Elias: please do go ahead and update your PR.
msg313271 - (view) Author: Elias Zamaria (elias) * Date: 2018-03-05 17:18
Done.
msg313833 - (view) Author: Mark Dickinson (mark.dickinson) * (Python committer) Date: 2018-03-14 18:14
Thanks for the PR (and apologies for being slow to look at it). I think we want to use the operator_fallbacks approach for both __rfloordiv__ and __floordiv__ (and similarly for __rmod__ and __mod__), else we'll get inconsistent results:

>>> Fraction(3) // 5.0  # get float, as expected
0.0
>>> 3.0 // Fraction(5)  # expect a float, get an integer
0

Please could you make that change and add a couple more tests that cover this case?
msg313854 - (view) Author: Elias Zamaria (elias) * Date: 2018-03-15 05:38
Mark, what you described (operator_fallbacks for both __rfloordiv__ and __floordiv__, and for both __rmod__ and __mod__) was my initial approach. But that broke one test (which floor-divides 1.0 by 1/10 and expects the result to be an integer). I thought about fixing it, to make the behavior more consistent, but I thought that was a bit risky.

Just now, I tried the change again, as you suggested, but I fixed the test to expect a result of 10.0 (a float) instead of 10 (an integer). I got a strange result from that test, saying the result was 9.0.

It seems like this is caused by rounding error, since operator_fallbacks converts both numbers to floats if one of them is a float, and 1/10 can't be represented exactly as a float, so it gets rounded to slightly more than 1/10:

>>> float(Fraction(1, 10)).as_integer_ratio()
(3602879701896397, 36028797018963968)
>>> Decimal.from_float(float(Fraction(1, 10)))
Decimal('0.1000000000000000055511151231257827021181583404541015625')

So yes, I can make that change, but I'm not sure if it would be a good idea. Do you have any thoughts?
msg313864 - (view) Author: Mark Dickinson (mark.dickinson) * (Python committer) Date: 2018-03-15 09:52
Yes, that sort of thing is going to happen as soon as floating-point enters the mix. There will be surprises from Fraction % float as well as float % Fraction:

>>> from fractions import Fraction
>>> Fraction(10**23) // 1e22
9.0

And that's again surprising, because 1e22 is exactly equal to 10**22:

>>> 1e22 == 10**22
True

This isn't special to Fractions: the same is true for mixed-type int-float arithmetic:

>>> 10**23 // 1e22
9.0


As you say, this is the result of rounding error.

There's not much we can do about that except for make sure that people are aware that precision can be lost when a Fraction is converted to a float. (One could conceivably outlawed mixed-type Fraction-float operations altogether, but rightly or wrongly that's not the decision that was made when the Fraction type was introduced, and changing that now would amount to gratuitous breakage.)

So yes, modifying both __floordiv__ and __rfloordiv__ together (and similarly for __mod__) is the right thing to do: we definitely don't want Fraction % float and float % Fraction to return different types.

On the testing front, testing the result value of something like 1.0 // Fraction(1, 10) is a little bit dodgy, because the result is only predictable under assumptions that we're using IEEE 754 arithmetic, and that's (at the moment) not an assumption that the Python core makes. I'd suggest using a safer case like 1.0 // Fraction(3, 10).

> I thought about fixing it, to make the behavior more consistent, but I thought that was a bit risky.

I think you have excellent instincts here. :-) But in this case, I do think it's better to be consistent, and to have a easy-to-learn and simple-to-remember rule (mixed-type Fraction-float arithmetic operations convert the Fraction to a float, regardless of the operation or the ordering of the operands). The floating-point surprises are a fact of life anyway, regardless of what Fraction does.
msg313932 - (view) Author: Elias Zamaria (elias) * Date: 2018-03-16 04:12
Mark, I tried `Fraction(10**23) // 1e22`, and I got 10.

Your `10**23 // 1e22` example was strange, but then `int(1e23)` and got 99999999999999991611392. That is kind of unexpected but I think it is rare for anyone to do something like that with numbers that big.

I think the fact that floating-point rounding error sometimes causes strange results is not a reason to do really unexpected things like making 1.0 // 1/10 equal 9.0, if that can be reasonably avoided.

I updated my pull request with my change, which you suggested, to make __rfloordiv__ and __rmod__ return a float, but with a small change to _operator_fallbacks to avoid the rounding error, so 1.0 // 1/10 is 10.0. You can see it at https://github.com/python/cpython/pull/5956/commits/1020bb219c1a4fad575ee2309c930ce82a4777fb#diff-14d03bfb59581367725b00781e6f802fL391. What do you think?
msg314108 - (view) Author: Mark Dickinson (mark.dickinson) * (Python committer) Date: 2018-03-19 18:09
> Mark, I tried `Fraction(10**23) // 1e22`, and I got 10.

Sorry: that result was with your PR (as it was at the time I wrote that comment). On master, you do indeed get 10.

> I think the fact that floating-point rounding error sometimes causes
> strange results is not a reason to do really unexpected things like
> making 1.0 // 1/10 equal 9.0, if that can be reasonably avoided.

I understand, but I think in this case, the cure is worse than the disease. I don't think converting the float to a Fraction in mixed-type operations is going to work, for a number of reasons:

- it's changing the behaviour for _all_ the arithmetic operations, not just // and %; that widens the scope for accidental breakage. For example, with the latest commit 038664e6a6288f6113ab96103e57d3b25b39a8c2 on your PR:

>>> Fraction(1) + math.inf
inf
>>> math.inf + Fraction(1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/mdickinson/Python/cpython/Lib/fractions.py", line 391, in reverse
    return float(fallback_operator(Fraction(a), b))
  File "/Users/mdickinson/Python/cpython/Lib/fractions.py", line 130, in __new__
    self._numerator, self._denominator = numerator.as_integer_ratio()
OverflowError: cannot convert Infinity to integer ratio

But on master, we have:

>>> import math
>>> from fractions import Fraction
>>> math.inf + Fraction(1)
inf
>>> Fraction(1) + math.inf
inf

- it's counter to the spirit of the numeric tower, where for most arithmetic operations, values are propagated _up_ the tower (from Integral -> Rational -> Real -> Complex) to the first common type before the operation is performed. That keeps things simple; having some cases where values are converted down the tower is going to cause confusion. (Comparisons are necessarily a special case: the need to preserve transitivity of equality and trichotomy means that using the same rules as for arithmetic operations won't fly)

- it introduces a non-obvious difference between mixed-mode Fraction-float and int-float operations. For an int x and a float y, I'd expect the result of `x <some_op> y` to be identical to the result of `Fraction(x) <some_op> y`.

So I'm sorry, but I do think having 1.0 // Fraction(1, 10) give 9.0 rather than 10.0 is the least worst option here. It's a surprise, but it's not a _new_ surprise: it's a surprise that's already there in the world of floating-point arithmetic.

>>> 1.0 // 0.1
9.0

You're evaluating a discontinuous function _at_ a discontinuity, using a type that's known for its inexactness. In that situation, getting the value for _either_ side of that discontinuity is reasonable.
msg314353 - (view) Author: Elias Zamaria (elias) * Date: 2018-03-24 03:11
Mark, you have some good points. I didn't fully think about the implications of my change. I undid the change to _operator_fallbacks.

I updated the tests to expect 1.0 // 1/10 to equal 9.0 and 1.0 % 1/10 to equal 0.09999999999999995. That latter number seems a bit awkward though. Can I expect the result to always come out like that, or could it depend on the hardware the test is run on? If we can't depend on that result, do you have any suggestions?
msg324080 - (view) Author: Mark Dickinson (mark.dickinson) * (Python committer) Date: 2018-08-25 16:51
Sorry that I've taken so long to get back to this. I've just updated the PR, and I think it's ready to go. Looks like it does need you to update your GitHub username here in the "Your Details" section of the bugtracker, though; sorry about that.

> If we can't depend on that result, do you have any suggestions?

Yes: check that 1.0 // 1/10 equals 1.0 // 0.1, and similarly for %. I've made that change in the PR.
msg324142 - (view) Author: Elias Zamaria (elias) * Date: 2018-08-27 03:16
I updated my GitHub username. For the record, it used to be mikez302, and now it is elias6.
msg324151 - (view) Author: Mark Dickinson (mark.dickinson) * (Python committer) Date: 2018-08-27 06:59
New changeset 393f1ff62e032f20adaed2613a9e2d2d6bcb1eb3 by Mark Dickinson (Elias Zamaria) in branch 'master':
bpo-32968: Make modulo and floor division involving Fraction and float consistent with other operations (#5956)
https://github.com/python/cpython/commit/393f1ff62e032f20adaed2613a9e2d2d6bcb1eb3
msg324152 - (view) Author: Mark Dickinson (mark.dickinson) * (Python committer) Date: 2018-08-27 07:13
Fixed for Python 3.8. Thank you!
History
Date User Action Args
2018-08-27 07:13:21mark.dickinsonsetstatus: open -> closed
resolution: fixed
messages: + msg324152

stage: patch review -> resolved
2018-08-27 06:59:32mark.dickinsonsetmessages: + msg324151
2018-08-27 03:16:43eliassetmessages: + msg324142
2018-08-25 17:34:20mark.dickinsonsetassignee: mark.dickinson
2018-08-25 16:52:07mark.dickinsonsetversions: - Python 3.6, Python 3.7
2018-08-25 16:51:48mark.dickinsonsetmessages: + msg324080
2018-03-24 03:11:42eliassetmessages: + msg314353
2018-03-19 18:09:57mark.dickinsonsetmessages: + msg314108
2018-03-16 04:12:13eliassetmessages: + msg313932
2018-03-15 09:52:58mark.dickinsonsetmessages: + msg313864
2018-03-15 05:38:37eliassetmessages: + msg313854
2018-03-14 18:14:57mark.dickinsonsetmessages: + msg313833
2018-03-05 17:18:35eliassetmessages: + msg313271
2018-03-05 08:29:24mark.dickinsonsetmessages: + msg313239
2018-03-02 18:32:50eliassetmessages: + msg313154
2018-03-02 15:47:45mark.dickinsonsetnosy: + jyasskin
messages: + msg313135
2018-03-01 22:16:47eliassetmessages: + msg313114
2018-03-01 22:10:32eliassetkeywords: + patch
stage: patch review
pull_requests: + pull_request5721
2018-03-01 22:00:13eliassetmessages: + msg313113
2018-03-01 18:16:16mark.dickinsonsetmessages: + msg313103
2018-03-01 11:03:33serhiy.storchakasetnosy: + serhiy.storchaka
messages: + msg313092
2018-03-01 10:01:22mark.dickinsonsetmessages: + msg313089
2018-03-01 09:50:28mark.dickinsonsetmessages: + msg313088
2018-03-01 05:17:50eliassetmessages: + msg313081
2018-02-28 16:12:20mark.dickinsonsetmessages: + msg313059
2018-02-28 04:24:26ned.deilysetnosy: + rhettinger, mark.dickinson
2018-02-28 03:10:51eliascreate