Skip to content

Commit f94f6bc

Browse files
committed
matrix function tolerances are hard
1 parent 9f527af commit f94f6bc

18 files changed

Lines changed: 323 additions & 116 deletions

File tree

docs/src/changelog.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ When releasing a new version, move the "Unreleased" changes to a new version sec
2323
### Added
2424

2525
- New matrix functions `squareroot`, `logarithm` and `power` (with both integer and fractional exponents), supporting the `MatrixFunctionViaLA`, `MatrixFunctionViaEig`, `MatrixFunctionViaEigh` and `DiagonalAlgorithm` algorithms ([#261](https://github.com/QuantumKitHub/MatrixAlgebraKit.jl/pull/261)).
26-
- The scalar type of the output of these matrix functions matches that of the input, and out-of-domain eigenvalues (e.g. on the negative real axis for a real input) throw a `DomainError`; eigenvalues that violate the domain within a tolerance `domain_atol` (defaulting to `default_domain_atol`) are treated as rounding artifacts and clamped to the domain boundary ([#261](https://github.com/QuantumKitHub/MatrixAlgebraKit.jl/pull/261)).
27-
- `MatrixFunctionViaEig` and `MatrixFunctionViaEigh` accept a `domain_atol` keyword argument to control this tolerance ([#261](https://github.com/QuantumKitHub/MatrixAlgebraKit.jl/pull/261)).
2826

2927
### Changed
3028

docs/src/user_interface/algorithms.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ The following algorithms for matrix functions are available.
103103
| Algorithm | Applicable matrix functions | Key keyword arguments |
104104
|:----------|:--------------------------|:----------------------|
105105
| [`MatrixFunctionViaTaylor`](@ref) | exponential | `tol`, `balance` |
106-
| [`MatrixFunctionViaLA`](@ref) | exponential, squareroot, logarithm, power | |
106+
| [`MatrixFunctionViaLA`](@ref) | exponential, squareroot, logarithm, power | `domain_atol` |
107107
| [`MatrixFunctionViaEig`](@ref) | exponential, squareroot, logarithm, power | `eig_alg`, `domain_atol` |
108108
| [`MatrixFunctionViaEigh`](@ref) | exponential, squareroot, logarithm, power | `eigh_alg`, `domain_atol` |
109109

docs/src/user_interface/matrix_functions.md

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,56 @@ MatrixAlgebraKit.MatrixFunctionViaEig
3838
MatrixAlgebraKit.MatrixFunctionViaEigh
3939
```
4040

41-
## Domain considerations
41+
## [Domain considerations](@id sec_matrixfunction_domain)
4242

4343
The functions below ([`squareroot`](@ref), [`logarithm`](@ref) and [`power`](@ref) with fractional powers) are only defined for matrices whose eigenvalues avoid (part of) the negative real axis, and their principal values are complex whenever eigenvalues on that axis are present.
4444
In MatrixAlgebraKit, we aim to keep type stability, and thus the scalar type of the output always matches that of the input.
4545
As such, a real matrix with eigenvalues on the negative real axis leads to a `DomainError`.
4646
You should pass a complex matrix instead to obtain the complex principal value.
47-
To avoid spurious errors for eigenvalues that lie on the negative real axis only because of rounding errors (e.g. a positive semidefinite matrix with a tiny negative eigenvalue), eigenvalues within an absolute tolerance `domain_atol` of the domain boundary are clamped onto it.
48-
This tolerance defaults to [`default_domain_atol`](@ref) and can be specified explicitly for the algorithms that support it, e.g. `MatrixFunctionViaEigh(eigh_alg; domain_atol=...)`.
47+
To avoid spurious errors for eigenvalues that lie on the negative real axis only because of rounding errors (e.g. a positive semidefinite matrix with a tiny negative eigenvalue), an absolute tolerance `domain_atol` decides which eigenvalues are treated as rounding artifacts.
48+
It can be specified for every algorithm, e.g. `MatrixFunctionViaEigh(eigh_alg; domain_atol=...)` or `squareroot(A; domain_atol=...)`, and defaults to [`default_domain_atol`](@ref).
49+
50+
### The tolerance has two opposite roles
51+
52+
Whether the boundary point `λ = 0` belongs to the domain differs per function, and this flips what raising `domain_atol` does.
53+
54+
| Function | Domain for a real result | `domain_atol` is | Raising it |
55+
|:---------|:-------------------------|:-----------------|:-----------|
56+
| [`squareroot`](@ref) | `λ ∉ ℝ₋`, boundary included | a clamping radius: eigenvalues within `domain_atol` of the negative real axis are moved onto it | accepts more matrices |
57+
| [`power`](@ref), fractional `p > 0` | as `squareroot`, since `0^p = 0` | as `squareroot` | accepts more matrices |
58+
| [`logarithm`](@ref) | `λ ∉ ℝ₋ ∪ {0}`, boundary excluded | a rejection radius: eigenvalues within `domain_atol` of the origin are `DomainError`s, since `log(0)` does not exist | rejects more matrices |
59+
| [`power`](@ref), fractional `p < 0` | as `logarithm`, since `0^p` diverges | as `logarithm` | rejects more matrices |
60+
| [`power`](@ref), integer `p < 0` | invertible matrices | a rejection radius around the origin | rejects more matrices |
61+
| [`power`](@ref), integer `p ≥ 0` | unrestricted | unused ||
62+
| [`exponential`](@ref) | unrestricted | unused ||
63+
64+
So for `logarithm` and negative `power`, raising `domain_atol` never rescues a matrix that was rejected: there is no boundary point to clamp onto, and the tolerance only widens the neighbourhood of the origin that is rejected.
65+
Raising it past a small negative eigenvalue merely turns a "negative eigenvalue" `DomainError` into a "numerically zero eigenvalue" one; *lowering* it is what admits an eigenvalue that is small but genuinely nonzero.
66+
67+
Clamping is backward stable, but only to the size of the eigenvalue that was discarded, and the forward error it incurs is *not* of that size.
68+
For `squareroot`, clamping an eigenvalue at `` perturbs the result by `O(√δ)`, so an accepted result computed at the default tolerance can differ from the exact principal value by considerably more than the tolerance itself.
69+
70+
### What the tolerance is measured on
71+
72+
For the eigenvalue-decomposition-based algorithms, `domain_atol` is the distance of an eigenvalue to the domain boundary, and its default reflects how accurately that algorithm obtains the spectrum.
73+
74+
| Algorithm | `domain_atol` measures | Default |
75+
|:----------|:-----------------------|:--------|
76+
| [`DiagonalAlgorithm`](@ref) | the eigenvalue itself, which is exact input here | `n * eps * maximum(abs, λ)` |
77+
| [`MatrixFunctionViaEigh`](@ref) | the eigenvalue, accurate to the backward error of a stable hermitian eigensolver | `n * eps * maximum(abs, λ)` |
78+
| [`MatrixFunctionViaEig`](@ref) | the eigenvalue, whose accuracy is additionally limited by the conditioning of the eigenvectors | `defaulttol(λ) * maximum(abs, λ)` |
79+
| [`MatrixFunctionViaLA`](@ref) | the imaginary part of the *result* | `defaulttol * norm(f(A), Inf)` |
80+
81+
The first two use the same rule as `LinearAlgebra.sqrt(::Hermitian; rtol = eps(T) * size(A, 1))`, so for hermitian input MatrixAlgebraKit and `LinearAlgebra` accept and reject the same matrices.
82+
`MatrixFunctionViaEig` is deliberately looser, since a defective eigenvalue of multiplicity `k` is only resolved to `eps^(1/k)`.
83+
84+
`MatrixFunctionViaLA` is the exception, because `LinearAlgebra` never exposes the spectrum: it decides internally whether a real result exists and returns a complex matrix when it does not.
85+
The tolerance therefore bounds the imaginary part of the result instead, which is a different quantity on a different scale — for `squareroot`, an eigenvalue at `` shows up as an imaginary part of order `√δ`.
86+
Such a tolerance is not optional there: `LinearAlgebra` casts a fractional power back to a real matrix only when its imaginary part vanishes identically, so `A^p` of a real matrix with complex-conjugate eigenvalues is complex even when the spectrum stays well clear of the negative real axis.
87+
88+
!!! warning "`MatrixFunctionViaLA` cannot detect a singular matrix"
89+
Because it inspects only the result, `MatrixFunctionViaLA` does not enforce the nonzero-eigenvalue condition of `logarithm` and of `power` with a negative exponent: `LinearAlgebra` treats a numerically zero eigenvalue as in-domain and returns a finite result whose entries are merely large.
90+
Use [`MatrixFunctionViaEig`](@ref) or [`MatrixFunctionViaEigh`](@ref) if you need such input rejected.
4991

5092
```@docs; canonical=false
5193
MatrixAlgebraKit.default_domain_atol

ext/MatrixAlgebraKitGenericSchurExt.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ for default_f_algorithm in (
2626
:default_power_algorithm,
2727
)
2828
@eval function MatrixAlgebraKit.$default_f_algorithm(
29-
type::Type{T}; domain_atol::Union{Nothing, Real} = nothing, kwargs...
29+
type::Type{T}; domain_atol::Real = -1.0, kwargs...
3030
) where {T <: StridedMatrix{<:GSFloat}}
3131
eig_alg = MatrixAlgebraKit.default_eig_algorithm(type; kwargs...)
3232
return MatrixFunctionViaEig(eig_alg; domain_atol)

src/common/defaults.jl

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,22 @@ Default tolerance for deciding to warn if the provided `A` is not hermitian.
4444
default_hermitian_tol(A) = eps(norm(A, Inf))^(3 / 4)
4545

4646
"""
47-
default_domain_atol(λ)
47+
default_domain_atol(λ, alg)
4848
4949
Default absolute tolerance for deciding when the eigenvalues `λ` should be considered
5050
to lie outside of the domain of a matrix function, e.g. on the negative real axis for
5151
[`squareroot`](@ref) and [`logarithm`](@ref) of a real matrix.
52+
53+
The tolerance has to absorb the error with which `alg` obtained `λ`, and that error differs by
54+
orders of magnitude between the algorithms, so the default is algorithm-dependent.
55+
See [Domain considerations](@ref sec_matrixfunction_domain) for the resulting values.
5256
"""
53-
default_domain_atol(λ) = defaulttol(λ) * maximum(abs, λ; init = abs(zero(eltype(λ))))
57+
function default_domain_atol end
58+
59+
# roundoff scales only with number of elements and spectrum - e.g. hermitian precision
60+
# conditioning is less strict
61+
_roundoff_domain_atol(λ) = length(λ) * eps(real(float(one(eltype(λ))))) * maximum(abs, λ; init = abs(zero(eltype(λ))))
62+
_conditioning_domain_atol(λ) = defaulttol(λ) * maximum(abs, λ; init = abs(zero(eltype(λ))))
5463

5564

5665
const DEFAULT_FIXGAUGE = Ref(true)

src/implementations/logarithm.jl

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,30 +19,34 @@ initialize_output(::typeof(logarithm!), A::AbstractMatrix, ::AbstractAlgorithm)
1919
# --------------
2020
function logarithm!(A::AbstractMatrix, logA, alg::MatrixFunctionViaLA)
2121
check_input(logarithm!, A, logA, alg)
22-
isempty(alg.kwargs) || throw(ArgumentError("`MatrixFunctionViaLA` does not accept keyword arguments for `logarithm`"))
23-
# `LinearAlgebra.log` of a real matrix is real whenever the principal logarithm is,
24-
# so a complex result with a real output signals a genuine domain violation
22+
domain_atol = _la_domain_atol(alg, logarithm!)
23+
# `LinearAlgebra.log` of a real matrix is real whenever the principal logarithm is. Note that a
24+
# (numerically) zero eigenvalue goes undetected here, as documented in the manual.
2525
logAc = LinearAlgebra.log(A)
2626
if eltype(logAc) <: Complex && !(eltype(logA) <: Complex)
27-
throw(_realness_domainerror(logarithm!))
27+
_la_project_real!(logA, logAc, domain_atol, logarithm!)
28+
else
29+
copy!(logA, logAc)
2830
end
29-
copy!(logA, logAc)
3031
return logA
3132
end
3233

3334
function logarithm!(A::AbstractMatrix, logA, alg::MatrixFunctionViaEigh)
3435
check_input(logarithm!, A, logA, alg)
3536
D, V = eigh_full!(A, alg.eigh_alg)
36-
diag_alg = DiagonalAlgorithm(; domain_atol = alg.domain_atol)
37+
diag_alg = DiagonalAlgorithm(; domain_atol = _resolve_domain_atol(diagview(D), alg))
3738
return _apply_eigh!(logA, V, logarithm!(D, D, diag_alg))
3839
end
3940

4041
function logarithm!(A::AbstractMatrix, logA, alg::MatrixFunctionViaEig)
4142
check_input(logarithm!, A, logA, alg)
4243
D, V = eig_full!(A, alg.eig_alg)
44+
λ = diagview(D)
45+
atol = _resolve_domain_atol(λ, alg)
46+
_check_nonzero_eigenvalues(λ, atol)
4347
# a real result requires the spectrum to stay off the negative real axis
44-
eltype(A) <: Real && _clamp_domain_eigenvalues!(D, alg.domain_atol)
45-
diag_alg = DiagonalAlgorithm(; domain_atol = alg.domain_atol)
48+
eltype(A) <: Real && _check_domain_eigenvalues(λ, atol, false)
49+
diag_alg = DiagonalAlgorithm(; domain_atol = atol)
4650
return _apply_eig!(logA, V, logarithm!(D, D, diag_alg))
4751
end
4852

@@ -52,11 +56,10 @@ function logarithm!(A::AbstractMatrix, logA, alg::DiagonalAlgorithm)
5256
check_input(logarithm!, A, logA, alg)
5357
λ = diagview(logA)
5458
copy!(λ, diagview(A))
55-
atol = something(get(alg.kwargs, :domain_atol, nothing), default_domain_atol(λ))
59+
atol = _resolve_domain_atol(λ, alg)
60+
# `log(0)` does not exist, so the origin is excluded from the domain and nothing is clamped
5661
_check_nonzero_eigenvalues(λ, atol)
57-
if eltype(λ) <: Real
58-
_clamp_domain_eigenvalues!(λ, atol)
59-
end
62+
eltype(λ) <: Real && _check_domain_eigenvalues(λ, atol, false)
6063
λ .= log.(λ)
6164
return logA
6265
end

0 commit comments

Comments
 (0)