Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sympy/core: Basic and Expr __eq__ are merged #22651

Merged
merged 6 commits into from Dec 20, 2021

Conversation

ThePauliPrinciple
Copy link
Contributor

References to other Issues or PRs

Helps with #22607

Brief description of what is fixed or changed

Currently, Expr ensures that Expr(S(1)) != Expr(S(1.0)). However, this does not work for
Basic : Basic(S(1)) == Basic(S(1.0)). Since this is the only difference currently between
Basic and Expr's __eq__ method, this PR attempts to merge these two so that
__eq__ only has to be defined in Basic.

This causes backwards compatibility issues, but I believe Basic(S(1)) == Basic(S(1.0))
should be considered a bug.

Other comments

Release Notes

  • core
    • Basic now behaves the same as Expr when comparing structural equality with ==.

@sympy-bot
Copy link

sympy-bot commented Dec 11, 2021

Hi, I am the SymPy bot (v162). I'm here to help you write a release notes entry. Please read the guide on how to write release notes.

Your release notes are in good order.

Here is what the release notes will look like:

This will be added to https://github.com/sympy/sympy/wiki/Release-Notes-for-1.10.

Click here to see the pull request description that was parsed.
<!-- Your title above should be a short description of what
was changed. Do not include the issue number in the title. -->

#### References to other Issues or PRs
<!-- If this pull request fixes an issue, write "Fixes #NNNN" in that exact
format, e.g. "Fixes #1234" (see
https://tinyurl.com/auto-closing for more information). Also, please
write a comment on that issue linking back to this pull request once it is
open. -->
Helps with https://github.com/sympy/sympy/pull/22607

#### Brief description of what is fixed or changed
Currently, `Expr` ensures that `Expr(S(1)) != Expr(S(1.0))`. However, this does not work for
`Basic` : `Basic(S(1)) == Basic(S(1.0))`. Since this is the only difference currently between
`Basic` and `Expr`'s `__eq__` method, this PR attempts to merge these two so that
`__eq__` only has to be defined in `Basic`.

This causes backwards compatibility issues, but I believe `Basic(S(1)) == Basic(S(1.0))`
should be considered a bug.

#### Other comments


#### Release Notes

<!-- Write the release notes for this release below between the BEGIN and END
statements. The basic format is a bulleted list with the name of the subpackage
and the release note for this PR. For example:

* solvers
  * Added a new solver for logarithmic equations.

* functions
  * Fixed a bug with log of integers.

or if no release note(s) should be included use:

NO ENTRY

See https://github.com/sympy/sympy/wiki/Writing-Release-Notes for more
information on how to write release notes. The bot will check your release
notes automatically to see if they are formatted correctly. -->

<!-- BEGIN RELEASE NOTES -->
* core
  * `Basic` now behaves the same as `Expr` when comparing structural equality with `==`.
<!-- END RELEASE NOTES -->

Update

The release notes on the wiki have been updated.

@ThePauliPrinciple
Copy link
Contributor Author

Not really sure why this makes is_decreasing not return anything anymore.

@oscarbenjamin
Copy link
Contributor

Not really sure why this makes is_decreasing not return anything anymore.

Maybe add something that detects when Basic.__eq__ is about to return something different from before so you can get into it with a debugger.

@ThePauliPrinciple
Copy link
Contributor Author

Alright, looks like this comparison

  File "/mnt/c/Users/paul/Documents/PythonScripts/Libs/SymPy/sympy/sympy/calculus/tests/test_singularities.py", line 83, in test_is_strictly_decreasing
    assert is_strictly_decreasing(1/(x**2 - 3*x), Interval.open(1.5, 3))
  File "/mnt/c/Users/paul/Documents/PythonScripts/Libs/SymPy/sympy/sympy/calculus/singularities.py", line 328, in is_strictly_decreasing
    return monotonicity_helper(expression, lambda x: x < 0, interval, symbol)
  File "/mnt/c/Users/paul/Documents/PythonScripts/Libs/SymPy/sympy/sympy/calculus/singularities.py", line 158, in monotonicity_helper
    return interval.is_subset(predicate_interval)
  File "/mnt/c/Users/paul/Documents/PythonScripts/Libs/SymPy/sympy/sympy/sets/sets.py", line 399, in is_subset
    if self.intersect(other) == self:
  File "/mnt/c/Users/paul/Documents/PythonScripts/Libs/SymPy/sympy/sympy/core/basic.py", line 376, in __eq__
    raise TypeError

in is_subset is the problem, since the two intervals here are not the same anymore:
<class 'sympy.sets.sets.Interval'> <class 'sympy.sets.sets.Interval'> (3/2, 3, True, True) (1.50000000000000, 3, True, True)
as 3/2 and 1.5000000000000000 are not structurally equivalent.

@oscarbenjamin
Copy link
Contributor

I thought I had removed that line but apparently I just left a comment:

sympy/sympy/sets/sets.py

Lines 395 to 399 in 81cd263

# Fall back on computing the intersection
# XXX: We shouldn't do this. A query like this should be handled
# without evaluating new Set objects. It should be the other way round
# so that the intersect method uses is_subset for evaluation.
if self.intersect(other) == self:

For the problem at hand though I guess trace it back a bit. Why is this comparing a set with float boundaries against one with rational boundaries?

@ThePauliPrinciple
Copy link
Contributor Author

Because it obtains a rational interval from solveset which it compares to a float interval from the input.

@oscarbenjamin
Copy link
Contributor

Because it obtains a rational interval from solveset which it compares to a float interval from the input.

Maybe it shouldn't be expected to work then. Where is the test?

@ThePauliPrinciple
Copy link
Contributor Author

I thought I had removed that line but apparently I just left a comment:

This method should probably be made to either return a default value or raise an error if no if branch returns anything.

@ThePauliPrinciple
Copy link
Contributor Author

ThePauliPrinciple commented Dec 12, 2021

Because it obtains a rational interval from solveset which it compares to a float interval from the input.

Maybe it shouldn't be expected to work then. Where is the test?

Yes, this shouldn't be expected to work. It is similar to the other failures, I got confused due to the None being returned as a consequence.

@oscarbenjamin
Copy link
Contributor

If there are tests that only fail on PyPy then that implies that we have a difference of behaviour somehow between SymPy running on PyPy and SymPy running on CPython. That's a bug in one of SymPy, PyPy or CPython that should be reported or fixed.

How exactly can the difference in behaviour be reproduced?

@ThePauliPrinciple
Copy link
Contributor Author

I can't get any useful output on this unfortunately.

@ThePauliPrinciple
Copy link
Contributor Author

Looks like the problem in optional-dependencies is not due to this PR, which explains why I couldn't figure out why these changes cause that.

@ThePauliPrinciple
Copy link
Contributor Author

There really seems to be something buggy about this. Now it fails in 3.10, but doesn't fail in in the other tests (for as far as they run). Locally it fails for me:

assert is_strictly_decreasing(1/(x**2 - 3*x), Interval.open(1.5, 3))

It returns None.
And I would expect this to fail, not sure why it passes on any of the tests.

@github-actions
Copy link

github-actions bot commented Dec 14, 2021

Benchmark results from GitHub Actions

Lower numbers are good, higher numbers are bad. A ratio less than 1
means a speed up and greater than 1 means a slowdown. Green lines
beginning with + are slowdowns (the PR is slower then master or
master is slower than the previous release). Red lines beginning
with - are speedups.

Significantly changed benchmark results (PR vs master)

       before           after         ratio
     [467f7455]       [8da64011]
-       205±0.8ms        135±0.7ms     0.66  large_exprs.TimeLargeExpressionOperations.time_subs
-       210±0.2μs        104±0.4μs     0.50  matrices.TimeMatrixExpression.time_MatMul
-     13.3±0.01ms       7.87±0.3ms     0.59  matrices.TimeMatrixExpression.time_MatMul_doit

Significantly changed benchmark results (master vs previous release)

       before           after         ratio
     [907895ac]       [467f7455]
+     6.79±0.04ms       10.2±0.3ms     1.51  matrices.TimeMatrixPower.time_Case1
-      4.16±0.01s          316±2ms     0.08  polygon.PolygonArbitraryPoint.time_bench01
+        3.29±0ms      5.70±0.06ms     1.73  solve.TimeMatrixOperations.time_det(4, 2)
+     3.29±0.02ms      5.76±0.04ms     1.75  solve.TimeMatrixOperations.time_det_bareiss(4, 2)
+      37.3±0.2ms       68.9±0.2ms     1.85  solve.TimeMatrixSolvePyDySlow.time_linsolve(1)
+      37.6±0.1ms       69.3±0.4ms     1.84  solve.TimeMatrixSolvePyDySlow.time_solve(1)

Full benchmark results can be found as artifacts in GitHub Actions
(click on checks at the top of the PR).

@ThePauliPrinciple
Copy link
Contributor Author

Surely the reported speedups aren't caused by this PR?

@ThePauliPrinciple
Copy link
Contributor Author

Looks like the result is actually non-deterministic:

============================================================================================= tests finished: 5 passed, 1 failed, in 1.60 seconds =============================================================================================
DO *NOT* COMMIT!
(sympy-dev) thepauli@DESKTOP-QN36B9B:/mnt/c/Users/paul/Documents/PythonScripts/Libs/SymPy/sympy$ vi sympy/calculus/tests/test_singularities.py
(sympy-dev) thepauli@DESKTOP-QN36B9B:/mnt/c/Users/paul/Documents/PythonScripts/Libs/SymPy/sympy$ bin/test sympy/calculus/tests/test_singularities.py
============================================================================================================= test process starts =============================================================================================================
executable:         /home/thepauli/anaconda3/envs/sympy-dev/bin/python  (3.9.7-final-0) [CPython]
architecture:       64-bit
cache:              yes
ground types:       python
numpy:              None
random seed:        63826900
hash randomization: on (PYTHONHASHSEED=2410301442)

sympy/calculus/tests/test_singularities.py[6] ....True
F.                                                                                                                                                                                     [FAIL]

_______________________________________________________________________________________________________________________________________________________________________________________________________________________________________________
___________________________________________________________________________________ sympy/calculus/tests/test_singularities.py:test_is_strictly_decreasing ____________________________________________________________________________________
Traceback (most recent call last):
  File "/mnt/c/Users/paul/Documents/PythonScripts/Libs/SymPy/sympy/sympy/calculus/tests/test_singularities.py", line 85, in test_is_strictly_decreasing
    assert is_strictly_decreasing(1/(x**2 - 3*x), Interval.open(1.5, 3)) is None
AssertionError

============================================================================================= tests finished: 5 passed, 1 failed, in 1.68 seconds =============================================================================================
DO *NOT* COMMIT!
(sympy-dev) thepauli@DESKTOP-QN36B9B:/mnt/c/Users/paul/Documents/PythonScripts/Libs/SymPy/sympy$ bin/test sympy/calculus/tests/test_singularities.py
============================================================================================================= test process starts =============================================================================================================
executable:         /home/thepauli/anaconda3/envs/sympy-dev/bin/python  (3.9.7-final-0) [CPython]
architecture:       64-bit
cache:              yes
ground types:       python
numpy:              None
random seed:        13438830
hash randomization: on (PYTHONHASHSEED=2112566269)

sympy/calculus/tests/test_singularities.py[6] ....None

Note that during one run it is printing True while in another it is printing False.

@ThePauliPrinciple
Copy link
Contributor Author

ThePauliPrinciple commented Dec 14, 2021

The bug seems to boil down to

list(ordered(set([Interval.open(Rational(3,2), 3), Interval.open(1.50000000000000, 3)])))

being underterministic. Then apparently one order leads to a different result compared to the other order when those two are not equal (because 3/2 is now not equal to 1.5 but this problem is not there when they are equal).

This happens in the Intersection class, which is used in the intersect method which is used in

sympy/sympy/sets/sets.py

Lines 395 to 400 in 81cd263

# Fall back on computing the intersection
# XXX: We shouldn't do this. A query like this should be handled
# without evaluating new Set objects. It should be the other way round
# so that the intersect method uses is_subset for evaluation.
if self.intersect(other) == self:
return True

is_subset.

Not yet sure how to fix this or what is actually the reason why this ordering is important.

@oscarbenjamin
Copy link
Contributor

I've seen this before. The ordered function does not work properly with float vs rational:

In [1]: list(ordered([S.Half, 0.5]))
Out[1]: [1/2, 0.5]

In [2]: list(ordered([0.5, S.Half]))
Out[2]: [0.5, 1/2]

The ultimate fix for that is #20033 but it could be possible to add a workaround for in the mean time.


def test_is_monotonic():
"""Test whether is_monotonic returns correct value."""
assert is_monotonic(1/(x**2 - 3*x), Interval.open(1.5, 3))
assert is_monotonic(1/(x**2 - 3*x), Interval.open(Rational(3,2), 3))
assert is_monotonic(1/(x**2 - 3*x), Interval.open(1.5, 3)) is None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes like this make me hesitant to accept that making rationals and floats unequal is actually a good idea (see this comment). 1.5 is exactly equal to 3/2 as a floating point number. The answer here is fortunately not wrong, just not as helpful anymore, but one could imagine scenarios where this breaks things in a more serious way.

This is obviously more of a question for #20033 than here. This PR just makes things consistent, which is good, and the changes are pretty minimal that it wouldn't be a big deal to revert them if we decide to go the other way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we can get the best of both worlds in this case. The mathematical question here is whether the expr is "monotonic" on a certain interval and it most certainly is monotonic along the given Float interval. So this function should deal with comparing based on "value equivalence" rather than "structural equivalence". So I think in is_subset

sympy/sympy/sets/sets.py

Lines 399 to 400 in 7c0fa07

if self.intersect(other) == self:
return True

might be a wrong use of == and should use a comparison based on value.

@ThePauliPrinciple
Copy link
Contributor Author

If this is the prefered solution, I do believe that the tests should reflect this with checks for both rational and float intervals.

@ThePauliPrinciple
Copy link
Contributor Author

Alright looks like this idea doesn't actually work and I was passing tests by coincidence.

Considering that currently this PR does not yield consistent results and I don't know how to make it do so, I think I want to close this, unless there are any suggestions to make this update work with is_subset.

@ThePauliPrinciple
Copy link
Contributor Author

This surely is a problem:

>>> fi=Interval(3/2,oo)
>>> ri=Interval(Rational(3,2),oo)
>>> for i in (fi, ri):
...     for u in ((fi, ri), (ri, fi)):
...         print(i, u)
...         i.is_subset(Union(*u))
...
Interval(1.50000000000000, oo) (Interval(1.50000000000000, oo), Interval(3/2, oo))
True
Interval(1.50000000000000, oo) (Interval(3/2, oo), Interval(1.50000000000000, oo))
True
Interval(3/2, oo) (Interval(1.50000000000000, oo), Interval(3/2, oo))
Interval(3/2, oo) (Interval(3/2, oo), Interval(1.50000000000000, oo))

how can a be a subset of the union of a and b, but b not be a subset of the union of a and b?

@oscarbenjamin
Copy link
Contributor

The issue with floats is basically that we don't know the origin of the floats so e.g.:

In [25]: a = Rational(3, 2) + Rational(1, 10**20)

In [26]: a
Out[26]: 
150000000000000000001
─────────────────────
100000000000000000000

In [27]: i = Interval(a, 2)

In [28]: ie = i.evalf()

In [29]: i
Out[29]: 
⎡150000000000000000001   ⎤
⎢─────────────────────, 2⎥
⎣100000000000000000000In [30]: ie
Out[30]: [1.5, 2.0]

In [31]: Rational(3, 2) in i
Out[31]: False

In [32]: Rational(3, 2) in ie
Out[32]: True

The way I think about this is that where I have a float that is equal to some exact rational number I imagine that the float represents a range of values around that number (an open set). If the statement isn't true for an arbitrarily small open set then SymPy shouldn't give True. With that interpretation I am not sure if the test results that you are currently worrying about should be expected to pass.

@ThePauliPrinciple
Copy link
Contributor Author

ThePauliPrinciple commented Dec 16, 2021

I managed to break down the issue more fundamentally, with Float and Rational not being equal, this no longer holds:

>>> Interval(1.5,oo).intersect(Interval(Rational(3,2),oo)) == Interval(Rational(3,2),oo).intersect(Interval(3/2,oo))
False

I do believe it should not matter for intersect what the order is, but it does now (it chooses Rational(3,2) vs 3/2 based on order).

>>> Interval(Rational(3,2),oo).intersect(Interval(3/2,oo))
Interval(3/2, oo)
>>> Interval(3/2,oo).intersect(Interval(Rational(3,2),oo))
Interval(1.50000000000000, oo)

@ThePauliPrinciple
Copy link
Contributor Author

Should those intersects return None since they don't have a well defined meaning?

@ThePauliPrinciple
Copy link
Contributor Author

Still doesn't solve it. Since I can't get a deterministic result I'm closing this.

@ThePauliPrinciple ThePauliPrinciple deleted the basic_expr_eq_merge branch December 16, 2021 18:41
@oscarbenjamin
Copy link
Contributor

Still doesn't solve it. Since I can't get a deterministic result I'm closing this.

I'll give this some more thought...

@oscarbenjamin
Copy link
Contributor

Something like this seems to work:

diff --git a/sympy/core/sorting.py b/sympy/core/sorting.py
index f255bbf..6ba314e 100644
--- a/sympy/core/sorting.py
+++ b/sympy/core/sorting.py
@@ -172,6 +172,8 @@ def _node_count(e):
     # some object has a non-Basic arg, it needs to be
     # fixed since it is intended that all Basic args
     # are of Basic type (though this is not easy to enforce).
+    if e.is_Float:
+        return 0.5
     return 1 + sum(map(_node_count, e.args))
 
 
diff --git a/sympy/sets/sets.py b/sympy/sets/sets.py
index 4e88ebd..d17658f 100644
--- a/sympy/sets/sets.py
+++ b/sympy/sets/sets.py
@@ -2463,7 +2463,7 @@ def simplify_intersection(args):
     args = set(args)
     new_args = True
     while new_args:
-        for s in args:
+        for s in ordered(args):
             new_args = False
             for t in args - {s}:
                 new_set = intersection_sets(s, t)

@ThePauliPrinciple
Copy link
Contributor Author

Do I understand correctly that this would change ordered such that it always sorts Float and Rational that have the same value in the same order? That would indeed be helpful.
I think I would prefer putting it here:

diff --git a/sympy/sets/handlers/intersection.py b/sympy/sets/handlers/intersection.py
index 1980251c5d..fc31545494 100644
--- a/sympy/sets/handlers/intersection.py
+++ b/sympy/sets/handlers/intersection.py
@@ -426,7 +426,7 @@ def intersection_sets(a, b): # noqa:F811
             start = a.start
             left_open = a.left_open
         else:
-            start = a.start
+            start = ordered([a,b])[0].start
             left_open = a.left_open or b.left_open

         if a.end < b.end:
@@ -436,7 +436,7 @@ def intersection_sets(a, b): # noqa:F811
             end = b.end
             right_open = b.right_open
         else:
-            end = a.end
+            end = ordered([a,b])[0].end
             right_open = a.right_open or b.right_open

         if end - start == 0 and (left_open or right_open):

so that intersection_sets(a,b)=intersection_sets(b,a) always holds. Or is there a reason not to do this?

@ThePauliPrinciple ThePauliPrinciple restored the basic_expr_eq_merge branch December 17, 2021 20:43
@oscarbenjamin
Copy link
Contributor

Or is there a reason not to do this?

We could do both. I was just looking at the source of the nondeterminism in this particular example.

@ThePauliPrinciple
Copy link
Contributor Author

This still needs cleanup

@ThePauliPrinciple
Copy link
Contributor Author

This is ready for review again.

@oscarbenjamin
Copy link
Contributor

Looks good.

@oscarbenjamin oscarbenjamin merged commit b18fdcf into sympy:master Dec 20, 2021
@oscarbenjamin
Copy link
Contributor

Actually the release note needs clarification. Can you edit it here?
https://github.com/sympy/sympy/wiki/Release-Notes-for-1.10

If we say that Basic now behaves the same as Expr then what does that mean? How were they different and what is changed to make them the same?

@ThePauliPrinciple ThePauliPrinciple deleted the basic_expr_eq_merge branch December 22, 2021 15:38
@ThePauliPrinciple
Copy link
Contributor Author

Actually the release note needs clarification. Can you edit it here? https://github.com/sympy/sympy/wiki/Release-Notes-for-1.10

If we say that Basic now behaves the same as Expr then what does that mean? How were they different and what is changed to make them the same?

Is it acceptable now?

@oscarbenjamin
Copy link
Contributor

Yes, looks good.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants