Computing roots of polynomials with real algebraic coefficients#26945
Computing roots of polynomials with real algebraic coefficients#26945oscarbenjamin merged 40 commits intosympy:masterfrom
Conversation
Previously, SymPy could not solve for the roots of polynomials with real
algebraic coefficients, e.g. radicals or root objects. This change computes
the "rational lift" of the polynomial, i.e., a polynomial with rational
coefficients whose roots are a superset of the original polynomial's, and
then numerically filters them.
Example:
Poly(x**3 + sqrt(2)*x**2 - 1, x).real_roots() == [
rootof(x**6 - 2*x**4 - 2*x**3 + 1, 0)
]
Previously, this would fail.
It uses the new functionality in sympy#26813 and sympy#26816 for constructing the
extension and correct root counting. As well, it adapts code from sympy#22943.
|
✅ Hi, I am the SymPy bot. 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.14. Click here to see the pull request description that was parsed.
Update The release notes on the wiki have been updated. |
|
Had to change the |
|
Benchmark results from GitHub Actions Lower numbers are good, higher numbers are bad. A ratio less than 1 Significantly changed benchmark results (PR vs master) Significantly changed benchmark results (master vs previous release) Full benchmark results can be found as artifacts in GitHub Actions |
|
In [12]: p = Poly(x**5 + x**3 - 2**Rational(1, 3), x, extension=True)
In [13]: p
Out[13]: Poly(x**5 + x**3 - 2**(1/3), x, domain='QQ<2**(1/3)>')
In [14]: p.lift()
---------------------------------------------------------------------------
CoercionFailed
...
CoercionFailed: Cannot convert ANP([-1, 0, 0], [1, 0, 0, -2], QQ) of type <class 'sympy.polys.polyclasses.ANP'> from QQ<2**(1/3)> to QQAlso In [15]: Poly(x**2 + I).lift()
---------------------------------------------------------------------------
DomainError
...
DomainError: computation can be done only in an algebraic domain |
Forget that. I was testing the wrong branch. |
Co-authored-by: Christopher Smith <smichr@gmail.com>
Co-authored-by: Christopher Smith <smichr@gmail.com>
In the previous commits, when solving for roots of polynomial with algebraic coeffs, both rational and algebraic coeffs were handled in the function _get_root(). Now, _get_root() checks for the domain and then dispatches to the two new functions _get_root_qq() or _get_root_alg(). The former is basically the same as the master branch, the latter includes the lifting and numerical filtering procedures. As well, it now assumes that the polynomial already has an algebraic extension field. This way, whether or not the extension is constructed can be handled "higher up" in the chain. Lastly, in _numerically_filter_roots(), it checks whether the same root has already been processed to not loop over them again.
|
I've changed the implementation. There are now two functions Since the extension has to be set before calling the root, I changed the Basically, this won't work: But this will: Not sure if you think this is good. I'm not sure what the best way is for users to interact with this. |
sympy/polys/rootoftools.py
Outdated
There was a problem hiding this comment.
I don't think that this fallback should be used. It cannot reliably order the roots. If we get here then it is almost certainly because two or more roots have identical real part but calling re(root.eval()) will make them seem not equal leading to an arbitrary order.
Is it not possible to continue using the existing code that sorts the roots?
I realise everything here is that the code here is quite convoluted so making it difficult to do these things.
Here is one way to do this:
roots = p.lift().real_roots() # Existing QQ code can sort the roots
subroots = set()
for f, m in p.sqf_list()[1]:
subroots.update(p.which_roots(roots, real=True))
roots_filtered = [r for r in roots if r not in subroots]We only need to make this square-free for the real_roots case and only because otherwise count_roots does not give us the multiplicities. For the all_roots case it is just:
roots_filtered = p.which_roots(p.lift().all_roots(), real=False)
Given that functions like real_roots and all_roots are separate functions rather than being separated by a keyword argument I think it would make sense to have which_real_roots_sqf and which_roots as separate methods. The which_real_roots_sqf function requires the polynomial to be square free. The which_roots function does not. A function which_real_roots() could do the dance with sqf_list that I showed above before calling which_real_roots_sqf().
There was a problem hiding this comment.
This is great! Thanks for the help.
After some experimenting, I think that even in the all_roots case, we need to get square-free factors. Eg if f= Poly((x-1) * (x-sqrt(2))**2 * (x-I) * (x+I), x, extension=True) (which has roots [1,sqrt(2),sqrt(2), I, -I]).
Bu it's lift has roots [-sqrt(2), -sqrt(2), 1, 1, sqrt(2), sqrt(2), -I, -I, I, I]. The -sqrt(2) are filtered out easily. However, in which_roots, if we just compare the sum of counts to the degree (as in the all_roots setting), this will loop forever. Thus, I think it makes sense to always return unique roots in which_roots(). And then handle multiplicity at the level of the square-free factorization.
In light of that, I think both cases are similar enough that we can keep which_roots one function, separated by the real argument? The only difference is the root counting. Otherwise, both should return unique elements.
There was a problem hiding this comment.
I think that even in the all_roots case, we need to get square-free factors.
Yes, you are right. The multiplicities might not be equal so we can't count the roots with multiplicities except in the square free case where the multiplicity is always 1.
_get_roots_alg() now first calls _get_roots_qq on the lift to get sorted polynomials. It then calls which_roots on each square free factor, in order to get the correct multiplicity. The method ComplexRootOf._sort_roots() is removed.
sympy/polys/rootoftools.py
Outdated
There was a problem hiding this comment.
We can write this as just subroots[r] = m.
Is it possible for distinct square-free factors to have any roots in common?
I don't think it is. Let's suppose that fi and fj are factors with multiplicity mi and mj. If some number r is a root of both fi and fj then it must be a root of some fij that is a factor of both fi and fj and then the multiplicity of fij would be at least mi + mj.
To put it another way the distinct square-free factorisation is defined so that the factors are pairwise coprime (the algorithm literally divides out the gcds). That means that they cannot have any roots in common.
There was a problem hiding this comment.
Ah great point, I put that in case, but from your explanation I can see it wouldn't happen.
sympy/polys/rootoftools.py
Outdated
There was a problem hiding this comment.
We can make this simpler:
roots_flat = []
for r in roots:
if r in subroots:
m = subroots[r]
roots_flat.extend([r] * m)There was a problem hiding this comment.
Sounds good, although we have to add a way to not add the same root again to roots_flat. Eg with this code we get
In [5]: Poly((x-1) * (x-sqrt(2))**2, x, extension=True).real_roots()
Out[5]: [1, 1, sqrt(2), sqrt(2), sqrt(2), sqrt(2)]
Bc the lift has roots [-sqrt(2), -sqrt(2), 1, 1, sqrt(2), sqrt(2)], so the sqrt(2) gets looped over twice (and mult is 2, so we get 4 of them in the final output).
I've added a check for this to your code.
roots_seen = set()
roots_flat = []
for r in roots:
if r in subroots and r not in roots_seen:
m = subroots[r]
roots_flat.extend([r] * m)
roots_seen.add(r)| try: | ||
| F = Poly(f, greedy=False) | ||
| if isinstance(f, Poly): | ||
| if extension and not f.domain.is_AlgebraicField: | ||
| F = Poly(f.expr, extension=True) | ||
| else: | ||
| F = f | ||
| else: | ||
| if extension: | ||
| F = Poly(f, extension=True) | ||
| else: | ||
| F = Poly(f, greedy=False) |
There was a problem hiding this comment.
There should be a simpler way of writing this. One problem with the above is that we call Poly(f.expr, extension=True) if we have a Poly with domain QQ which is unnecessary.
It seems like something is wrong if we can't do all of this in one call. But I can't think what the call should be...
There was a problem hiding this comment.
One reason I have all these checks is because if we just did something like:
if extension:
F = Poly(f, extension=True)
else:
F = Poly(f, greedy=False)the issue is that if specify extension=True if the Poly is already made, it won't change it. E.g.
In [3]: f = x - sqrt(2)
In [4]: f
Out[4]: x - sqrt(2)
In [5]: Poly(f, x)
Out[5]: Poly(x - sqrt(2), x, domain='EX')
In [6]: Poly(f, x, extension=True)
Out[6]: Poly(x - sqrt(2), x, domain='QQ<sqrt(2)>')
In [7]: Poly(Poly(f, x), extension=True)
Out[7]: Poly(x - sqrt(2), x, domain='EX')
In [8]: Poly(Poly(f, x).expr, extension=True)
Out[8]: Poly(x - sqrt(2), x, domain='QQ<sqrt(2)>')
But as the last output shows, if you use the expr then you can construct the extension. It seems important to me to handle the case that extension=True and the user passes a Poly. I'm not sure if there is a better way to construct the domain in this case.
There was a problem hiding this comment.
Maybe if the input is a Poly it is reasonable to just ignore the other options like extension=True.
Either way I was more complaining that there isn't a simple way to do what is being done here that does not require handling 4 separate cases.
Split Poly.which_roots() into Poly.which_real_roots() and Poly.which_all_roots(). Added new documentation for them about the requirement for the polynomial to be square free. Added new tests to test edge cases where candidates is not a superset or when the polynomial is not square free. Lastly, changed the calls in CRootOf._get_roots_alg() to these two methods.
|
Hmm looks like the PR is failing bc of Ruff (mentioned in #26964) |
| while len(root_counts) > num_roots: | ||
| for r in list(root_counts.keys()): | ||
| # If f(r) != 0 then f(r).evalf() gives a float/complex with precision. | ||
| f_r = f(r).evalf(prec, maxn=2*prec) | ||
| if abs(f_r)._prec >= 2: | ||
| root_counts.pop(r) |
There was a problem hiding this comment.
A lot of the code in which_real_roots and which_all_roots is duplicated. It would be better factor it out into a separate method that could be called by both public methods.
There was a problem hiding this comment.
Apart from this it all looks good though so let's fix this and get it merged.
I suggest having a private method like
def _which_roots(self, num_roots, candidates):
...
Then both which_all_roots and which_real_roots can call that method and it can house all the otherwise repeated code.
|
I changed the release notes entry since we also handle gaussian domains now |
|
Okay, looks good. Thanks! |
|
This is a nice addition and I think it was good that we went through the full review process on a smaller PR so that there is a clear idea of what it is that is needed to the final product into the codebase. Looking forward to seeing the next steps for CAD! |
|
Thanks! I learned a lot during the process :) |
References to other Issues or PRs
Addresses a challenge brought up in the discussion in #26785. #23077 was also previously opened to do the same thing but was not merged. This was initially brought up in #22943 I believe.
Brief description of what is fixed or changed
We can now compute roots of polynomials with algebraic coefficients. This would previously fail. It computes the "rational lift" of the polynomial, i.e., a polynomial with rational coefficients whose roots are a superset of the original polynomial's, and then uses numerical comparison to find which roots are also roots of the original polynomial. This leverages the recent fixes in #26813 and #26816. The numerical comparison code is taken nearly verbatim from #22943 (comment).
Examples:
The fix is in ComplexRootOf._get_roots(), which is ultimately what Poly.real_roots() or Poly.all_roots() call (and hence ultimately what functions like solve() call I think). I think this makes logical sense in terms of integrating well into how the root functions already work. But, happy to hear feedback on this.
Other comments
This change breaks a test in
test_quintics_3()in solvers/tests/test_solvers.py. The test asserted:But now that this polynomial can be solved the roots should not be an empty list, but
solve(y) == solve(-y)should still be correct. I changed the test accordingly. As well, the computation for complex roots can be somewhat slow, eg this test takes 210 seconds on my computer, hence I added a slow decorator.Release Notes
real_roots(p, extension=True)orall_roots(p, extension=True).