Differentiate general eigen and eigvals - #788
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #788 +/- ##
==========================================
+ Coverage 90.68% 91.05% +0.37%
==========================================
Files 11 11
Lines 1052 1118 +66
==========================================
+ Hits 954 1018 +64
- Misses 98 100 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
I added some tests. I would be happy for a review. |
|
@devmotion, could you do a review of this PR? |
|
Another gentle bump on this PR. Is there anything I can do to speed up the review process? |
|
Sorry for the long delay. I went through this with the help of Claude, since the interaction between the eigen rules and nested The formula itself matches Giles and is transcribed correctly, and the general approach is fine. What goes wrong is specific to how the computation is arranged. The failing CI jobs on Second derivatives are wrong
using ForwardDiff, LinearAlgebra
B(w) = [3.0+w[1] 1.0+w[2]; 0.4+w[2] 2.0-2*w[1]] # tr(B(w)) == 5 - w[1]
julia> ForwardDiff.hessian(w -> sum(eigvals(B(w))), [0.11, -0.07])
2×2 Matrix{Float64}:
1.50425 -0.467918
-2.27002 0.767292
julia> ForwardDiff.hessian(w -> tr(B(w)), [0.11, -0.07])
2×2 Matrix{Float64}:
0.0 0.0
0.0 0.0One degree up, julia> ForwardDiff.hessian(w -> sum(eigvals(B(w)) .^ 2), [0.11, -0.07])
2×2 Matrix{Float64}:
16.434 0.0194723
-11.7302 7.28187It isn't even symmetric. Computing the same thing as This is the case in #111, which asks for a Hessian: using FiniteDifferences
const fdm = central_fdm(5, 1)
fd_hessian(f, w) = FiniteDifferences.jacobian(fdm, x -> FiniteDifferences.grad(fdm, f, x)[1], w)[1]
S(w) = [w[1]^2 w[1]*w[2]*w[3]; w[1]*w[2]*w[3] w[2]^2]
g(w) = sum(log, eigvals(S(w)))
julia> maximum(abs, ForwardDiff.hessian(g, [0.9,1.4,0.3]) .- fd_hessian(g, [0.9,1.4,0.3]))
1.9378524327776427versus The cause is that A_values = map(d -> d.value, A)
A_values_eig = eigen(A_values)
UinvAU = A_values_eig.vectors \ A * A_values_eig.vectors
julia> u # Dual{T,Float64,2}, from eigen(A_values)
Dual{T}(-0.5192863992964954, 0.7957065830762764, -0.2753367363366924)
julia> a # Dual{T,Dual{T,Float64,2},2}, an entry of A
Dual{T}(Dual{T}(3.11,1.0,0.0), Dual{T}(1.0,0.0,0.0), Dual{T}(0.0,0.0,0.0))
julia> value(partials(a * u, 1))
1.9553610740707241 # should be value(partials(a,1)) * value(u) == -0.5192863992964954
The tag ordering can't catch this, because both levels carry the same tag by construction. The existing parts = ntuple(j -> diag(Q' * getindex.(partials.(A), j) * Q), N)
Dual{Tg}.(λ, tuple.(parts...))
function _eigvals_general(A::StridedMatrix{Dual{Tg,T,N}}) where {Tg,T<:Real,N}
A0 = value.(A)
λ, U = eigen(A0)
luU = lu(U)
parts = ntuple(j -> diag(luU \ (getindex.(partials.(A), j) * U)), N)
return Dual{Tg}.(λ, tuple.(parts...))
endThis agrees with the current implementation to Eigenvector derivatives use a different normalization than
|
`eigen(A::StridedMatrix{<:Dual})` built the derivative from
`A_values_eig.vectors \ A * A_values_eig.vectors`, which mixes the
eigenvectors of `value.(A)` with `A` itself. `ForwardDiff.hessian` seeds
`Dual{T,Dual{T,V,N},N}` with the same tag on both levels, so the scalar
`*` in the generic matmul treats the two as one level and applies the
product rule at the outer level. First derivatives were unaffected;
second and higher ones were silently wrong.
Compute the derivatives entirely from `value.(A)` and assemble the
`Dual`s only at the end, the way the `Symmetric` and `SymTridiagonal`
methods already do, so the two levels never meet.
Along the way:
- `eigvals` no longer computes the eigenvector derivatives and throws
them away; it is a separate method rather than `eigen(A).values`.
- reuse `_lyap_div!!` instead of building `F` by hand, which drops an
n^2 allocation and a row-major traversal of a column-major array.
- share one `lu` factorization across the partials.
- rename `make_eigen_dual` to `_make_eigen_dual`, carry the tag as a
type parameter, and give the section its `#---#` header line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`U̇ = U * (F .* M)` leaves the diagonal of `F` free; setting it to zero normalizes the eigenvectors by `diag(inv(U) * U̇) == 0`. LAPACK's `geevx` instead returns eigenvectors of unit 2-norm whose largest entry is real. For a non-normal matrix those are different conventions, so `.vectors` carried LAPACK values with partials belonging to a different parameterization -- e.g. `dot(v, v̇)` came out as 0.5 rather than 0. Pick the free diagonal such that the derivatives satisfy the two constraints LAPACK's convention imposes, differentiated at `t = 0`. This mirrors `_eigen_norm_phase_fwd!` in ChainRules, so the two systems agree on `eigen`. The eigenvector tests no longer renormalize; `v / norm(v)` was a no-op on the values but removed exactly the scalar the two conventions differ by, which is why they passed before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`eigen(A; sortby=nothing)`, `eigvals(A; sortby=nothing)` and `eigen(A; permute=false)` threw a `MethodError` for a `Dual` matrix, while the internal decomposition of `value.(A)` silently applied the default `sortby`. Forward `kwargs...` to that decomposition. The derivatives are assembled in whatever order it returns, so they follow the requested ordering, and for nested `Dual`s every level is decomposed with the same keywords. `eigvecs` picks this up through its generic fallback. `eigen!` and `eigvals!` remain undefined. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_lyap_div!!` divides by `λ[j] - λ[i]`, so a repeated eigenvalue made `.vectors` come out as `NaN` with no indication of what went wrong. The eigenvectors of a repeated eigenvalue are not unique and hence not differentiable, so throw an `ArgumentError` naming the pair instead. The check compares the primal values rather than the `Dual`s: `==` and `iszero` on a `Dual` take the partials into account, but the quotient already breaks down when only the values coincide, which is what happens one level up in a `hessian`. This covers the `Symmetric` and `SymTridiagonal` methods as well, which share the same weakness, and the `MMatrix` method in the StaticArrays extension through `_lyap_div!`. `eigvals` never divides by the gaps and keeps working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`LinearAlgebra.eigen!` and `eigvals!` check `issymmetric`/`ishermitian`
and route to the symmetric algorithm, but a plain `Matrix{<:Dual}` went
through the general path regardless -- which is the situation in JuliaDiff#111.
Do the same check on the `Dual` matrix. `==` on a `Dual` compares the
partials as well, so `ishermitian` only accepts a matrix whose values
*and* partials are Hermitian; a Hermitian value with a non-Hermitian
perturbation keeps using the general path, since symmetrizing it would
change the derivative.
The eigenvalues are then real by construction rather than by whether
`geevx!` produced a nonzero imaginary part, and the derivative avoids the
LU factorization in favour of `Q'`.
The `Symmetric` methods take no keyword arguments. `permute` and `scale`
are balancing options the symmetric algorithm does not use and are
ignored the same way `eigen!` ignores them, but any `sortby` other than
the ascending order it returns anyway stays on the general path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`value.(A)` and `Ȧ * U` are allocated by these methods and used nowhere else, so the decomposition and the solve may overwrite them: `eigen!` instead of `eigen` for the former, `ldiv!` instead of `\` for the latter. Both `eigen` and `\` copy their argument, so each was costing an extra n^2 matrix -- once for the decomposition and once per partial. `eigen!` only exists for BLAS element types, which is the innermost level of a nested `Dual`; `_eigen!!` falls back to the copying `eigen` above it and for value types such as `Float16`. Results are unchanged bit for bit. Measured at n = 40 with 8 partials, allocations drop by 27.9% for `eigvals` (416 KiB to 300 KiB, 1.04 ms to 0.70 ms) and by 13.8% for `eigen` (841 KiB to 725 KiB). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks for the review! I added some commits developed together with my coding agent that should fix the issues. The commits also add more tests. |
| # Strip every `Dual` level, so that eigenvalues can be compared by their primal value alone | ||
| _primalvalue(x::Real) = x | ||
| _primalvalue(d::Dual) = _primalvalue(value(d)) | ||
| _primalvalue(z::Complex) = complex(_primalvalue(real(z)), _primalvalue(imag(z))) |
There was a problem hiding this comment.
One should basically never unconditionally extract primal values of Duals - it should basically always be based on a tag to avoid stripping partials from dual numbers that actually are primal values themselves in the context of the current differentiation.
There was a problem hiding this comment.
It seems extracting the primal values was not necessary.
| throw(ArgumentError( | ||
| "eigenvector derivatives are not defined for repeated eigenvalues, but " * | ||
| "λ[$i] == λ[$j] == $λ" | ||
| )) |
There was a problem hiding this comment.
I'd suggest changing this to a lazy error message.
| # eigenvectors are not unique and hence not differentiable. Comparing the primal values | ||
| # catches the cases in which the quotient would silently come out as `NaN`. |
There was a problem hiding this comment.
Why NaN? _lyap_div!! explicitly handles the case of zero denominator in the code below.
There was a problem hiding this comment.
I adapted the comment.
Review feedback on the repeated-eigenvalue check: - Do not extract primal values unconditionally. `_primalvalue` stripped every `Dual` level, including levels belonging to an independent, enclosing differentiation. It turns out no stripping is needed at all: when two eigenvalues are `!=` but share a primal, the decomposition of the values -- which every method computes first, one `Dual` level down -- has already seen them as exact duplicates and thrown. Each level of the nesting runs its own check, so `==` is enough. - Make the error message lazy, as in `throw_cannot_dual`. - The comment claimed `_lyap_div!!` did not handle a zero denominator. It does, but only on the diagonal, where the difference vanishes by construction; what is unhandled is an off-diagonal denominator that vanishes because two eigenvalues coincide. Say that, and say `Inf` or `NaN` rather than just `NaN`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
devmotion
left a comment
There was a problem hiding this comment.
Thanks for the updates. Another pass, again with the help of Claude. Everything from the last round is fixed: the levels are no longer mixed, the eigenvector normalization matches eigen, keyword arguments are forwarded, repeated eigenvalues error instead of returning NaN, and _lyap_div!! is reused. I checked tag safety and the nesting specifically and they hold up.
One problem is left, in the normalization commit. First derivatives are correct everywhere I tried (2.5e-9 or better against finite differences). Second derivatives through complex eigenvectors are wrong from n = 4 on.
The failing pre jobs are the pre-existing JET failures in test/QATest.jl, not this PR.
The phase gauge is fixed at the wrong index one level up
eigen returns eigenvectors of unit 2-norm whose largest-magnitude entry is real, so the derivative has to satisfy imag(u̇[k]) == 0 for that entry. Taking the maximum of |imag(u̇[k])| over all columns and seed directions of
A = [0.5 -1.3 0.2 0.9; 1.1 0.4 -0.6 0.2; 0.3 0.7 2.0 -1.1; 0.4 -0.2 1.3 0.8]gives 5.551115123125783e-17 for a Dual matrix, and 9.165426697038974 once a second level is added the way ForwardDiff.hessian does.
The cause is isreal. _findrealmaxabs2 filters with isreal(vi), which for a Complex{<:Dual} is iszero(imag(vi)), and iszero(::Dual) requires the partials to vanish too (src/dual.jl:417). The 5.55e-17 is not zero, so at the second level no entry passes the filter at all and the function falls through to imax = firstindex(v). With V2 the eigenvectors of the doubly-nested matrix:
julia> [any(isreal, view(V2, :, i)) for i in 1:4]
4-element Vector{Bool}:
0
0
0
0
julia> [ForwardDiff._findrealmaxabs2(view(V2, :, i)) for i in 1:4]
4-element Vector{Int64}:
1
1
1
1The largest-magnitude entry per column, i.e. the one LAPACK actually made real, is [2, 2, 4, 4].
n = 2 and n = 3 pass by accident: at n = 2 the residue comes out exactly zero, and at n = 3 the firstindex fallback happens to equal the right index. Against sign-aligned central differences the Hessian error is 5.2e-6 at n = 2, 1.03e-5 at n = 3, then 83.6, 134 and 145 at n = 4, 5, 6. Third derivatives are garbage as well.
The 5.55e-17 is itself avoidable. imag(u[k]) == 0 holds identically along the curve, so imag(u̇[k]) is exactly zero, but it is currently computed as the cancellation q + fl(r * fl(-q/r)) with q = imag(u̇[k]) and r = real(u[k]). Asserting it instead fixes the residue and the gauge at every level:
u̇ .+= u .* ċ
# `imag(u[k]) == 0` holds identically, so `imag(u̇[k])` is exactly zero; computing it as a
# cancellation leaves a rounding residue that breaks `isreal` -- and hence `_findrealmaxabs2`
# -- one differentiation level up
u̇[k] = complex(real(u̇[k]), zero(real(u̇[k])))With that, the residual is 0.0 at both levels for n = 2 through 6, and the Hessian errors drop to 2.5e-5. Being exact rather than approximate it also fixes third and higher order. Taking k = argmax(abs2, u) and dropping the isreal filter works too, since LAPACK makes the largest-magnitude entry the real one, but it leaves 1e-16 residues, so I'd prefer the above.
This is not inherited from ChainRules. _eigen_norm_phase_fwd! is transcribed faithfully, but ChainRules only ever applies it to StridedMatrix{<:BlasFloat}, where isreal is exact. _findrealmaxabs2 here is a reimplementation and is in fact slightly more careful than the original, which seeds amax = abs2(first(x)) and can therefore return a non-real index. What both share is the silent fallback, and that is what breaks once the eltype is a Dual.
While you're in there, the imax = firstindex(v) fallback shouldn't be silent. Reaching it means the assumed LAPACK convention doesn't hold.
Finite differences on eigenvectors need sign alignment
Not a problem with the PR, but it cost me time and will cost whoever writes the regression test. eigen(::Matrix{Float64}).vectors is sign-discontinuous: LAPACK makes the largest-magnitude entry real but doesn't pin its sign. For the A above, column 3 flips between A[1] ± 1e-6, so plain central differences are off by O(1) and look like an AD bug.
Two baselines that work: assert the gauge invariants directly (imag(u̇[k]) ≈ 0 and real(u' * u̇) ≈ 0), which needs no FD and is sign-invariant; or flip each FD column so that real(V[k,i]) keeps its sign.
The test for #111 can't fail
S(w) = [w[1]^2 w[1]*w[2]*w[3]; w[1]*w[2]*w[3] w[2]^2] is Hermitian in its values and its partials, so g and gsym both dispatch to _eigvals(Symmetric(...)). With a counter in _eigvals_general it is called 0 times for either, and the two Hessians are bitwise identical. The test checks the Hermitian dispatch, not the general path that #111 needs.
A non-symmetric log-det Hessian does exercise it and matches central differences to 1.8e-9:
h(w) = sum(log, eigvals([w[1]^2 w[1]*w[2]; 0.5*w[1]*w[2] w[2]^2+1]))HessianTest.jl nests through eigenvectors only for a real spectrum, and the complex eigenvector tests in JacobianTest.jl are all first-order and 2×2/3×3, i.e. exactly the sizes that pass by accident.
Smaller things
_check_distinct_eigvalsruns once per partial direction,8times pereigenatN = 8, and in_eigen_generalit runs afterlu(U), so a singularUcan surface asSingularExceptionrather than the intendedArgumentError. Latent in practice, every Jordan block and repeated-diagonal case I tried still threwArgumentError. Hoisting it into the_eigen*methods right after the decomposition fixes the count and the ordering, and keeps the_lyap_divkernel purely numeric.- The comment that
eigvals"never divides by the gaps and keeps working" is too generous. It keeps running, butdiag(U⁻¹ȦU)gives the diagonal of the compressed perturbation on the degenerate eigenspace, not its eigenvalues. ForA(t) = [2 t; t 2]it reports[0.0, 0.0]where the analytic branches2 ∓ thave derivatives∓1, and the sorted vectoreigvalsreturns,2-|t|, 2+|t|, isn't differentiable at all. ForA(t) = [2 1; t 2]the eigenvalues are2 ± √t, so the derivative is infinite and±2.25e15comes back. Not a regression, ChainRules computes the same formula and returns the same numbers and tracks it as an open TODO (factorization.jl:302), but the PR is now inconsistent, witheigenerroring loudly andeigvalsstaying quiet. - The check makes
eigen(::Symmetric{<:Dual}),eigen(::SymTridiagonal{<:Dual})and the StaticArraysMMatrixpath throw where they previously returnedNaNvectors next to good.values. Worth a release note. - For
eigvals, ChainRules gets the diagonal without the secondn³matmul:U \ Ȧ, then row-column dot products (factorization.jl:424-431).-24%time and-36%allocations atn = 40, N = 8,-44%allocations atn = 150, N = 12. getindex.(partials.(A), j)is allocation-identical topartials.(A, j), since the broadcast fuses and noMatrix{Partials}is materialized. Still worth switching for readability, but not for speed.
Things I checked that are fine
value.(A)strips exactly the level named byTg, because the signature pins the eltype invariantly asDual{Tg,T,N}. Independent nesting (derivative ∘ derivativewith two different tags) is correct foreigvals,eigenand products and powers of them, including a nonzero mixed second derivative, wheredet²gives-1.6.- The
ishermitiandispatch is right, and right because==on aDualcompares partials (src/dual.jl:419-425): Hermitian values with non-Hermitian partials correctly stay on the general path,A_asymgives[-0.5, 0.5]rather than the symmetrized[-1.0, 1.0]. - Keyword forwarding matches Base, which also ignores
permute/scaleonce it routes to the symmetric algorithm and forwardssortby.sortby = nothingon the shortcut agrees with Base numerically. - Both gauge constraint derivations are correct,
_eigen!!'s fallback works forFloat16and for nestedDualvalue types,0×0and1×1work, andeigvecs/eigmax/eigminpick the new methods up.
This should go in after #757
#757 renames four internals this PR calls: _lyap_div!! and _lyap_div!, which this PR inserts _check_distinct_eigvals into, and _eigvals/_eigen, which _use_symmetric forwards to. Also the _lyap_div!!(::MMatrix) override in the StaticArrays extension that this PR relies on. A trial merge conflicts in src/dual.jl and test/JacobianTest.jl.
Rebasing on #757 makes this PR smaller:
_make_eigen_dualand its fourmap((val, p) -> ..., ...)call sites can go. #757's_to_dualsdoes the same job array-at-a-time and already handles all three shapes needed here: realλtoVector{Dual}, complexλtoVector{Complex{Dual}}, complexUtoMatrix{Complex{Dual}}.M[j] - Diagonal(λ_parts[j])can go. It only zeroes the diagonal, which #757's_lyap_div_zero_diag!!does itself, and the results are identical. That's ann²allocation per direction.- The four renamed call sites, plus
partials.(A, j).
It goes the other way in one respect: since the check sits in the shared _lyap_div kernel, #757's new Hermitian{<:Dual} and Hermitian{<:Complex{<:Dual}} methods inherit it once this lands, and _use_symmetric's shortcut picks up #757's single decomposition and BLAS reassociation for free. Nothing in #757 fixes anything above, since it is Hermitian-only and the phase convention never arises there.
`eigen` returns eigenvectors whose largest-magnitude entry is real, so
`imag(u[k]) == 0` holds identically along the curve and `imag(u̇[k])` is
exactly zero. It was left as the cancellation `q + fl(r * fl(-q / r))`
instead, which keeps a rounding residue of about `5.6e-17`.
At first order the residue is harmless. One differentiation level up it
sits in the partials, where `isreal` sees it -- `isreal` on a
`Complex{<:Dual}` is `iszero(imag(...))`, and `iszero` on a `Dual`
requires the partials to vanish too. `_findrealmaxabs2` then finds no
real entry in the column at all and silently returned `firstindex(v)`, so
the phase was fixed at the wrong entry and second and higher derivatives
through complex eigenvectors were wrong from `n = 4` on.
Assert the imaginary part instead of computing it, which is exact and so
fixes every order, and make the fallback in `_findrealmaxabs2` throw:
reaching it means the assumed normalization does not hold.
This is not inherited from ChainRules. `_eigen_norm_phase_fwd!` only ever
runs on `StridedMatrix{<:BlasFloat}` there, where `isreal` is exact.
Tested by asserting the two gauge constraints, `imag(u[k]) == 0` and
`u' * u == 1`, at nesting depths 1, 2 and 3 for n = 2 to 6, and by
comparing second derivatives of the eigenvectors against central
differences of the first derivatives. `eigen` does not pin the sign of
the real entry, so the columns are realigned before differencing; plain
central differences are off by O(1) and look like an AD bug. Before this
commit those tests fail 29 times, with the second derivative off by 55.6
at n = 4 and 7.0 at n = 5; n = 2 passes either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ond 3x3 `S(w) = [w[1]^2 w[1]*w[2]*w[3]; w[1]*w[2]*w[3] w[2]^2]` is Hermitian in its values *and* its partials, so `g` and `gsym` both take the `Symmetric` shortcut and the two Hessians are bitwise identical. The test pinned the Hermitian dispatch, not the general path that JuliaDiff#111 needs. Keep it, as the dispatch test it actually is, and add the same log-determinant Hessian on a matrix that is not Hermitian. `det(Bgen(w)) == w[1]^2 * (1 + w[2]^2 / 2)`, so there is a closed form to compare against that never goes through `eigen`; the analytic Hessian is matched to 3.6e-15, rather than to finite-difference accuracy. The complex eigenvector tests were all 2x2 and 3x3, sizes at which the entry carrying the phase convention can come out right by accident, so add a 4x4 with two complex conjugate pairs. That matrix is also chosen so that plain central differences are stable: `eigen` does not pin the sign of the real entry, and on the 4x4 of the review the sign of column 3 flips under a 1e-6 perturbation, which makes the finite-difference baseline wrong by 1e5 on a correct implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_check_distinct_eigvals` sat inside `_lyap_div!!`, which the `eigen` methods call once per partial direction, so it ran `N` times per decomposition instead of once. Hoist it into `_eigen`, `eigen` for `SymTridiagonal` and `_eigen_general`, right after the decomposition, which also leaves the `_lyap_div` kernel purely numeric. In `_eigen_general` it now runs before `lu(U)`, so a repeated eigenvalue cannot surface as a `SingularException` from factorizing a defective `U`. That ordering is defensive rather than an observed fix: for every Jordan block and repeated-diagonal case tried, `lu(U)` succeeds, because the near-duplicate eigenvector columns LAPACK returns differ by about 1e-16 rather than being exactly equal. Hoisting `value.(λ)` out of the `ntuple` in the two Hermitian methods saves the `N - 1` redundant copies as well: 2800 bytes of 1060144 at n = 40 with 8 partials. All seven degenerate paths still throw the same `ArgumentError`: the general one for a diagonalizable and for a defective matrix, `Symmetric`, `SymTridiagonal`, a nested `Dual`, and both the `MMatrix` and `SMatrix` paths of the StaticArrays extension. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Currently, ForwardDiff.jl only supports differentiating through
eigenandeigvalsofSymmetricorHermitianmatrices, but not for a generalStridedMatrix. In JuliaManifolds/Manifolds.jl#27 (comment), there is a working version for differentiating througheigenandeigvalsfor general matrices, which also works for complex eigenvalues (e.g., DifferentiableEigen.jl does not support complex eigenvalues). I just copied that version here for higher visibility, more convenient usage, and to avoid type piracy in my (and maybe other's) code. So credit goes to @mateuszbaran. The algorithm is from https://people.maths.ox.ac.uk/gilesm/files/NA-08-01.pdf.I open this as a draft PR because I am not sure if there is something additional that needs to be considered. If this PR is wanted, I can add tests and do a bit of cleanup.cc @dlfivefifty since he added support for
eigenfor symmetric and Hermitian matrices.Closes #111.