Skip to content

Make eig/eigvals always return complex eigenvalues #29000

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

Open
ev-br opened this issue May 18, 2025 · 7 comments
Open

Make eig/eigvals always return complex eigenvalues #29000

ev-br opened this issue May 18, 2025 · 7 comments

Comments

@ev-br
Copy link
Contributor

ev-br commented May 18, 2025

For a real-valued input, NumPy's eigenvalue routines currently may return either real or complex eigenvalues: both eig and eigvals check for imaginary parts being exactly zero, and downcast the output if they are [1].

Other array libraries skip this handholding and always return complex eigenvalues. For instance,

In [10]: torch.linalg.eig(torch.arange(4).reshape(2, 2)*1.0).eigenvalues
Out[10]: tensor([-0.5616+0.j, 3.5616+0.j])

A recent array-api discussion [2], had a question if NumPy would consider changing this value-dependent behavior and always return a complex array for eigenvalues?

What is the downstream effect. Were we starting from scratch, I'd expect that doing real_if_close is not that taxing for a user---and in fact in the vast majority of use cases it's just not needed.

Since we're not starting from scratch, and to roughly assess the blast radius, I applied the following patch (hidden under the fold), and ran tests for scipy, scikit-learn and scikit-image.

Here's the summary:

scipy:

SciPy: 4 failures in scipy.signal, all look trivial to fix

FAILED signal/tests/test_dltisys.py::TestStateSpaceDisc::test_properties - AssertionError: dtypes do not match.
FAILED signal/tests/test_dltisys.py::TestTransferFunction::test_properties - AssertionError: dtypes do not match.
FAILED signal/tests/test_ltisys.py::TestStateSpace::test_properties - AssertionError: dtypes do not match.
FAILED signal/tests/test_ltisys.py::TestTransferFunction::test_properties - AssertionError: dtypes do not match.

scikit-learn:

all tests pass

scikit-image:

FAILED measure/tests/test_fit.py::test_ellipse_model_estimate - TypeError: unsupported operand type(s) for %=: 'numpy.complex128' and 'float'
FAILED measure/tests/test_fit.py::test_ellipse_parameter_stability - TypeError: unsupported operand type(s) for %=: 'numpy.complex128' and 'float'
FAILED measure/tests/test_fit.py::test_ellipse_model_estimate_from_data - TypeError: unsupported operand type(s) for %=: 'numpy.complex128' and 'float'
FAILED measure/tests/test_fit.py::test_ellipse_model_estimate_from_far_shifted_data - TypeError: unsupported operand type(s) for %=: 'numpy.complex128' and 'float'

[1] https://github.com/numpy/numpy/blob/v2.1.0/numpy/linalg/_linalg.py#L1226
[2] data-apis/array-api#935 (comment)

$ git diff
diff --git a/numpy/linalg/_linalg.py b/numpy/linalg/_linalg.py
index d7850c4a02..7b44f73588 100644
--- a/numpy/linalg/_linalg.py
+++ b/numpy/linalg/_linalg.py
@@ -1269,14 +1269,15 @@ def eigvals(a):
                   under='ignore'):
         w = _umath_linalg.eigvals(a, signature=signature)
 
-    if not isComplexType(t):
-        if all(w.imag == 0):
-            w = w.real
-            result_t = _realType(result_t)
-        else:
-            result_t = _complexType(result_t)
 
+    result_t = _complexType(result_t)
    return w.astype(result_t, copy=False)
 
 
 def _eigvalsh_dispatcher(a, UPLO=None):
@@ -1522,13 +1523,14 @@ def eig(a):
                   under='ignore'):
         w, vt = _umath_linalg.eig(a, signature=signature)
 
-    if not isComplexType(t) and all(w.imag == 0.0):
-        w = w.real
-        vt = vt.real
-        result_t = _realType(result_t)
-    else:
-        result_t = _complexType(result_t)
 
+    result_t = _complexType(result_t)
     vt = vt.astype(result_t, copy=False)
     return EigResult(w.astype(result_t, copy=False), wrap(vt))
@ngoldbaum
Copy link
Member

I think we'd have to come up with a new function that has a different return type. A few versions later, we could deprecate eig and eigvals and point people to the new function.

We can't just change the return types though IMO because then it becomes annoying to simultaneously support several NumPy versions. We also can't generate new deprecation warnings for eig and eigvals without a replacement, because then there's nothing people can do to fix it, particularly when users trigger this in libraries.

@ilayn
Copy link
Contributor

ilayn commented May 21, 2025

real_if_close is not that taxing for a user---and in fact in the vast majority of use cases it's just not needed.

Quite the other way around. I think this is an intrusive change and make things really complicated for linear algebra code. Because it will require checking the imaginary part every time you have an eigvals code. I would argue that this is a change not due to usability improvement but to regularize type system that I don't find sufficiently strong reason about.

I am aware of the array api work, but this should not come at the expense of giving up the convenience of a dynamic language.

eigvals(x, /) should return an array of eigenvalues in an unspecified order;

This one is also very problematic. Tons of code relies on this behavior and the behavior is pretty much written in stone at this point. Instead of changing established conventions, the other libraries should look for ways to mimic this behavior.

@ev-br
Copy link
Contributor Author

ev-br commented May 21, 2025

I am aware of the array api work, but this should not come at the expense of giving up the convenience of a dynamic language.

Array API discussion is a for context only, and is not a motivation for this request. So let's not tangle this in. [1]

it will require checking the imaginary part every time you have an eigvals code.

Will it really? To me, it's a pure math thing: eigenvalues of a real matrix are complex-valued. They can --- by accident --- have zero imaginary parts. These zero imaginary parts can --- again, by accident --- become identically zero in floating point, but in general one needs some tolerance to decide if .imag is zero or not. And that tolerance is problem-dependent. Just like a matrix rank, which is ill-defined in inexact arithmetic.

And I stand by the OP statement that the very need to decide if eigenvalues are or are not exactly on the real axis of the complex plane is rare.

That a bunch of code has possibly already been written to expect handholding from numpy is a separate matter. A small experiment running scipy and scikit-{learn,image} test suites above seems to hint that the amount of such code is not that large. We can of course question how representative that exercise is, but by all means, if we reject the change because of potential backwards compatibility concerns, let's be clear about it and not mix up any other reasons.

[1] For the record (and I'm already on record elsewhere): IMO the array API movement is in itself not a reason to change anything, full stop. It can serve as a motivation, yes, but any change initiated by array API should be weighted on its own merits, not only consistency between array backends.

@ngoldbaum
Copy link
Member

IMO this is worth doing. Having consistent output types is also better for JITs like numba or AoT compilers.

Maybe it could be done by adding a new keyword argument that forces imaginary output? In a future version we could deprecate cast_to_float=True to force people to update their code if we detect a case where a cast happens by generating a warning. Later, we'd update the default for the keyword and library authors can delete the kwarg. That would allow the array API compat to work eventually.

Either way we need a story for library authors that ideally allows them to avoid adding code that depends on NumPy version in all cases. Library authors that want to jump the gun and write version-dependent code can do so, but we shouldn't generate any new deprecation warnings that force library authors to have version-dependent code.

@ilayn
Copy link
Contributor

ilayn commented May 21, 2025

A recent array-api discussion [2], had a question if NumPy would consider changing this value-dependent behavior and always return a complex array for eigenvalues?

I am assuming this is the reason but otherwise what's the reason to change it?

That a bunch of code has possibly already been written to expect handholding from numpy is a separate matter.

Yes and that's how Python took off with hand-holding everywhere meaning providing a convenient interface. Otherwise we would be still coding in braces or implicit nones. So trying to reshape a dynamic language to a typed thing should not be the originator of lots of changes. I can also make the counter argument if you need type safety then you are coding in the wrong place. But that won't solve the discussion at hand so let's not get in there.

The type safe way of doing this is the classical return-two-arrays in LAPACK or other compiled sources that you get one array wr for real parts and wi for imaginary parts. It is as unnecessary as writing if any(eigs.imag() != 0.0): ... everywhere but not the end of the world. Probably, we can make it work but then again what is the reasoning. Did we get complaints, is this hindering anything if not array api?

I also want to be clear about our intentions and not make up reasons to avoid the fact that this is coming from array api. So it should go both ways.

Independent from the dtype, the eigenvalue ordering, that's a major battle altogether.

@ev-br
Copy link
Contributor Author

ev-br commented May 21, 2025

I am assuming this is the reason but otherwise what's the reason to change it?

The array API discussion linked above is a context where this issue originates, yes.

NumPy uses one convention and PyTorch uses a different convention. FTR, I'm suggesting that CuPy (which is the original motivation for the whole story) follows pytorch not numpy in cupy/cupy#8980.

that's how Python took off with hand-holding everywhere

That might be, but I'd argue that everywhere should be where reasonable, as with time we've seen both kinds of interfaces.

So for me, the reason is just the math.

With my comp phys / applied maths hat on, every time I see fp_value == 0, I freak out start thinking whether it should have a finite tolerance instead.
Which begs the question, does LAPACK really guarantee exactly zero imaginary parts for real eigenvalues?

Also, what are the applications/use cases where returning complex eigenvalues with zero imaginary parts breaks something? I don't mean existing implementations (where the scipy/scikit-learn/scikit-image test suites seem to hint at an answer), I mean computational problems. I can only think of np.roots.

I also want to be clear about our intentions and not make up reasons to avoid the fact that this is coming from array api.

Sure, this is in the OP :-).

Maybe it could be done by adding a new keyword argument that forces imaginary output? In a future version we could deprecate cast_to_float=True to force people to update their code if we detect a case where a cast happens by generating a warning. Later, we'd update the default for the keyword and library authors can delete the kwarg. That would allow the array API compat to work eventually.

Either way we need a story for library authors that ideally allows them to avoid adding code that depends on NumPy version in all cases. Library authors that want to jump the gun and write version-dependent code can do so, but we shouldn't generate any new deprecation warnings that force library authors to have version-dependent code.

With a downstream library maintainer hat on, I don't see how a keyword helps. Downstream, we'll need to first add a keyword (with a version check), then silence the deprecation warnings, then remove it. It's way simpler to just add an if np.__version__ < 2.X: ... once.

@ngoldbaum
Copy link
Member

With a downstream library maintainer hat on, I don't see how a keyword helps. Downstream, we'll need to first add a keyword (with a version check), then silence the deprecation warnings, then remove it. It's way simpler to just add an if np.version < 2.X: ... once.

SciPy is a very engaged downstream. I'm thinking of people with less resources, who would prefer to have a one-liner they could use, or even retain their old code - which after all isn't broken.

Another advantage of the keyword argument approach is that we can make it so the deprecation warning only triggers if you don't specify the keyword. Anyone who wants to make their code always handle imaginaries can choose to make their code depend on numpy version and use the keyword instead of casting if it's available. Anyone who wants to keep their code as-is just explicitly sets e.g. cast_to_float=True and leaves their code alone. Anyone who doesn't care about fixing deprecations also doesn't have to do anything.

In a few numpy versions we expire the deprecation. At that point the lazy people who don't care about deprecations can just set the keyword or do the migration to always handle imaginaries. Either way it's not much work and they don't need to figure out the correct way to make their code depend on numpy version.

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

No branches or pull requests

4 participants
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy