Skip to content

Fix implementations of eigen and eigvals #757

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
wants to merge 1 commit into
base: master
Choose a base branch
from
Open

Conversation

devmotion
Copy link
Member

The PR adds missing definitions of eigvals and eigen for Hermitian{<:Dual} and of eigen for Hermitian{<:Complex{<:Dual}}.
Additionally, the PR adds more tests of eigen and eigvals, unifies the existing implementations, and removes duplicate calculations in the definitions of eigen.

Fixes #756 without GenericLinearAlgebra.


For the example in #756, I get on ForwardDiff@0.10 without GenericLinearAlgebra:

julia> test_hessian(Float64)
ERROR: MethodError: no method matching eigvals!(::Hermitian{ForwardDiff.Dual{…}, Matrix{…}}; sortby::Nothing)
The function `eigvals!` exists, but no method is defined for this combination of argument types.
...

julia> test_hessian(ComplexF64)
ERROR: MethodError: no method matching eigen!(::Hermitian{Complex{ForwardDiff.Dual{…}}, Matrix{Complex{…}}}; sortby::Nothing)
The function `eigen!` exists, but no method is defined for this combination of argument types.
...

And on ForwardDiff@0.10 with import GenericLinearAlgebra: eigen:

julia> @time test_hessian(Float64)
  0.000071 seconds (38 allocations: 33.750 KiB)
9×9 Matrix{Float64}:
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0          0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0          0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0          0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0          0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  4.44089e-16  0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0          0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0          0.0
 0.0  0.0  0.0  0.0  4.44089e-16  0.0  0.0  2.66454e-15  0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0          0.0

julia> @time test_hessian(ComplexF64)
  0.000556 seconds (1.97 k allocations: 1.387 MiB)
18×18 Matrix{Float64}:
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0  -6.93889e-18  0.0     0.0  0.0  0.0  -1.38778e-16  0.0
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   4.33681e-19  0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   1.38778e-17  0.0     0.0  0.0  0.0   2.77556e-17  0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0

On this PR I get without GenericLinearAlgebra:

julia> @time test_hessian(Float64)
  0.000071 seconds (215 allocations: 55.547 KiB)
9×9 Matrix{Float64}:
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0           0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0           0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0           0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0           0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  2.77556e-17   0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0           0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0           0.0
 0.0  0.0  0.0  0.0  2.77556e-17  0.0  0.0  0.0          -2.77556e-17
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  2.77556e-17   0.0

julia> @time test_hessian(ComplexF64)
  0.000491 seconds (1.00 k allocations: 477.500 KiB)
18×18 Matrix{Float64}:
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   6.93889e-18  0.0     0.0  0.0  0.0  0.0  -1.38778e-17  0.0
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0  -6.93889e-18  0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   2.77556e-17  0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0

Some benchmarks against master:

master

julia> using ForwardDiff, Chairmarks, LinearAlgebra

## Symmetric{<:Real}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(Symmetric(x * x'))), _)
11.312 μs (110 allocs: 49.297 KiB)

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(Symmetric(x * x')).values), _)
29.083 μs (273 allocs: 115.469 KiB)

## Hermitian{<:Complex}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(Hermitian(complex.(x * x', x' * x)))), _)
71.625 μs (4136 allocs: 542.594 KiB)

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(Hermitian(complex.(x * x', x' * x))).values), _)
ERROR: MethodError: no method matching eigen!(::Hermitian{Complex{ForwardDiff.Dual{…}}, Matrix{Complex{…}}}; sortby::Nothing)
...

## SymTridiagonal{<:Real}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(SymTridiagonal(x, x[begin:(end-1)]))), _)
14.500 μs (128 allocs: 37.625 KiB)

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(SymTridiagonal(x, x[begin:(end-1)])).values), _)
32.625 μs (314 allocs: 101.422 KiB)

## Hermitian{<:Real}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(Hermitian(x * x'))), _)
ERROR: MethodError: no method matching eigvals!(::Hermitian{ForwardDiff.Dual{ForwardDiff.Tag{…}, Float64, 10}, Matrix{ForwardDiff.Dual{…}}}; sortby::Nothing)
The function `eigvals!` exists, but no method is defined for this combination of argument types.
...

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(Hermitian(x * x')).values), _)
ERROR: MethodError: no method matching eigen!(::Hermitian{ForwardDiff.Dual{ForwardDiff.Tag{…}, Float64, 10}, Matrix{ForwardDiff.Dual{…}}}; sortby::Nothing)
The function `eigen!` exists, but no method is defined for this combination of argument types.
...

This PR

julia> using ForwardDiff, Chairmarks, LinearAlgebra

## Symmetric{<:Real}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(Symmetric(x * x'))), _)
15.834 μs (110 allocs: 49.297 KiB)

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(Symmetric(x * x')).values), _)
19.500 μs (133 allocs: 67.594 KiB)

## Hermitian{<:Complex}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(Hermitian(complex.(x * x', x' * x)))), _)
32.625 μs (136 allocs: 98.062 KiB)

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(Hermitian(complex.(x * x', x' * x))).values), _)
39.875 μs (159 allocs: 132.047 KiB)

## Symtridiagonal{<:Real}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(SymTridiagonal(x, x[begin:(end-1)]))), _)
10.938 μs (148 allocs: 31.062 KiB)

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(SymTridiagonal(x, x[begin:(end-1)])).values), _)
14.125 μs (171 allocs: 49.359 KiB)

## Hermitian{<:Real}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(Hermitian(x * x'))), _)
15.917 μs (110 allocs: 49.297 KiB)

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(Hermitian(x * x')).values), _)
19.250 μs (133 allocs: 67.594 KiB)

@araujoms
Copy link

araujoms commented Jul 25, 2025

Amazing. I can confirm it fixes not only the MWE I posted, but also the real code I extracted it from.

Note that there is still a regression if the matrix is not wrapped in Hermitian, that is, if we change the function to

    function barrier(point)
        d2 = div(d, 2)
        M = mat(point, T)
        M[1:d2, d2+1:end] .= 0
        M[d2+1:end, 1:d2] .= 0
        return sum(real(eigvals(M)))
    end

It doesn't matter to me, I never need a non-Hermitian matrix, I'm remarking just in case you wanted to know.

Comment on lines +266 to +271
# analytic solutions
@test ForwardDiff.jacobian(eigvals_symreal, x) ≈ [0 0; 2 4]
@test ForwardDiff.jacobian(eigvals_hermreal, x) ≈ [0 0; 2 4]
if !(x isa StaticArrays.StaticArray)
@test ForwardDiff.jacobian(eigvals_symtridiag, x) ≈ [(1 - 3/sqrt(5))/2 (1 - 1/sqrt(5))/2 ; (1 + 3/sqrt(5))/2 (1 + 1/sqrt(5))/2]
end
Copy link
Member

Choose a reason for hiding this comment

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

I haven't looked very closely, but are there more tests somewhere that check the values?

In what's changed in this PR, I see one more line with Calculus.finite_difference_jacobian(ev, float.(x0)) where I think x0 = [1.0, 2.0].

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, there are more in the lines below. Much more exhaustive than on master.

Copy link
Member

Choose a reason for hiding this comment

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

Sorry that wasn't clear. I see the many tests of different paths against each other, which is good.

But what I meant is tests of any ForwardDiff path against something completely independent -- a known answer, or finite differences. To catch things like making the same logic error in writing both _eigvals_hermitian and _eigen_hermitian here.

Copy link
Member Author

Choose a reason for hiding this comment

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

In the tests below, Jacobians of every test function with ForwardDiff are checked against finite differencing based Jacobians. The latter doesn't involve any ForwardDiff paths.

Copy link
Member

Choose a reason for hiding this comment

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

Sorry to be dense but where are you looking? Besides line 293, as mentioned:

I see one more line with Calculus.finite_difference_jacobian(ev, float.(x0)) where I think x0 = [1.0, 2.0].

Copy link
Member Author

@devmotion devmotion Jul 26, 2025

Choose a reason for hiding this comment

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

It is line 293. Calculus doesn't support all vector types, hence the ForwardDiff results with x (the possibly static version of x0: lines 259 and 262) is compared with the finite differencing result using the regular array x0.

Copy link
Member

Choose a reason for hiding this comment

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

Ok.

So the matrices being tested are these. Does it seem OK to test e.g. no negative eigenvalue for Symmetric{Real}, no zero eigenvalues for Hermitian{Complex}? Haven't thought hard but it just seemed a small sample.

julia> x
2-element Vector{Float64}:
 1.0
 2.0

julia> Symmetric(x*x')  # also wrapped in Hermitian
2×2 Symmetric{Float64, Matrix{Float64}}:
 1.0  2.0
 2.0  4.0

julia> eigvals(ans)
2-element Vector{Float64}:
 0.0
 5.0

julia> Hermitian(complex.(x*x', x'*x))
2×2 Hermitian{ComplexF64, Matrix{ComplexF64}}:
 1.0+0.0im  2.0+5.0im
 2.0-5.0im  4.0+0.0im

julia> eigvals(ans)
2-element Vector{Float64}:
 -3.0901699437494754
  8.090169943749475

Copy link
Member

Choose a reason for hiding this comment

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

(That said, when I tried various tests locally, I did not manage to break this.)

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

Successfully merging this pull request may close these issues.

Regression in 1.0, asking for Hessian of a complex matrix function results in infinite loop
3 participants