Skip to content
Merged
4 changes: 3 additions & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ uuid = "cf66c380-9a80-432c-aff8-4f9c79c0bdde"
version = "1.2.0"

[deps]
ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4"
FFTW = "7a1cc6ca-52ef-59f5-83cd-3a7055c09341"
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4"

[compat]
ChainRulesCore = "1"
ChainRulesTestUtils = "1"
FFTW = "1.0"
LinearAlgebra = "<0.0.1, 1"
StaticArrays = "0.12, 1.0"
julia = "1.3"

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ The FastChebInterp package also supports complex and vector-valued functions `f`
the latter case, `c(y)` returns a vector of interpolants, and one can use `chebjacobian(c,y)`
to compute the tuple `(c(y), J(y))` of the interpolant and its Jacobian matrix at `y`.

There is also a function `roots(c)` to return an array of the real roots of `c` on the interval `[lb, ub]`,
computed efficiently by a ["colleague"-matrix method (combined with recursive domain decomposition)](https://epubs.siam.org/doi/10.1137/1.9781611975949.ch18). One can also obtain the colleague matrix (whose eigenvalues are the roots)
directly via `colleague_matrix(c)` (which can be useful to examine complex roots and roots slightly outside `[lb, ub]`).

### Regression from arbitrary points

We also have a function `chebregression(x, y, [lb, ub,], order)` that
Expand Down
1 change: 1 addition & 0 deletions src/FastChebInterp.jl
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Base.zero(c::ChebPoly{N,T,Td}) where {N,T,Td} = ChebPoly{N,T,Td}(zero(c.coefs),
include("interp.jl")
include("regression.jl")
include("eval.jl")
include("roots.jl")
include("chainrules.jl")

end # module
109 changes: 109 additions & 0 deletions src/roots.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Computing roots of Chebyshev real-valued polynomials in 1d via colleague matrices on
# recursively subdivided intervals, as described in Trefethen, "Approximation
# Theory and Approximation Practice", chapter 18. https://epubs.siam.org/doi/10.1137/1.9781611975949
#
# Some additional implementation tricks were inspired by the chebfun source
# code: https://github.com/chebfun/chebfun/blob/7574c77680d7e82b79626300bf255498271a72df/%40chebtech/roots.m

using LinearAlgebra
export colleague_matrix, roots

# colleague matrix for 1d array of Chebyshev coefficients, assuming
# trailing zero coefficients have already been dropped.
function _colleague_matrix(coefs::AbstractVector{<:Number})
n = length(coefs)
n <= 1 && return Matrix{float(eltype(coefs))}(undef,0,0) # 0×0 case (no roots)
iszero(coefs[end]) && throw(ArgumentError("trailing coefficient must be nonzero"))

if n == 2 # trivial 1×1 (degree 1) case
C = Matrix{float(eltype(coefs))}(undef,1,1)
C[1,1] = float(-coefs[1])/coefs[2]
return C
else
# colleague matrix, transposed to make it upper-Hessenberg (which doesn't change eigenvalues)
halves = fill(one(float(eltype(coefs)))/2, n-2)
C = diagm(1 => halves, -1 => halves)
C[2,1] = 1
@views C[:,end] .-= coefs[1:end-1] ./ 2coefs[end]
return C
end
end


if !isdefined(LinearAlgebra, :diagview) # added in Julia 1.12
if !isdefined(LinearAlgebra, :diagind)
diagind(A::Matrix) = range(1, step=size(A,1)+1, length=min(size(A)...))
end
diagview(A::Matrix) = @view A[diagind(A)]
end

function _colleague_matrix(coefs::AbstractVector{<:Number}, lb::Real, ub::Real)
C = _colleague_matrix(coefs)
# scale and shift from [-1,1] to [lb,ub] via C * (ub-lb)/2 + (ub+lb)/2 * I
C .*= (ub - lb)/2
diagview(C) .+= (ub + lb)/2
return C
end

"""
colleague_matrix(c::ChebPoly{1,<:Number}; tol=5eps)

Return the "colleague matrix" whose eigenvalues are the roots of the 1d real-valued
Chebyshev polynomial `c`, dropping trailing coefficients whose relative contributions
are `< tol` (defaulting to 5 × floating-point `eps`).
"""
function colleague_matrix(c::ChebPoly{1,<:Number}; tol::Real=5*epsvals(c.coefs))
abstol = sum(abs, c.coefs) * tol # absolute tolerance = L1 norm * tol
n = something(findlast(c -> abs(c) ≥ abstol, c.coefs), 1)
return UpperHessenberg(_colleague_matrix(@view(c.coefs[1:n]), c.lb[1], c.ub[1]))
end

function filter_roots(c::ChebPoly{1,<:Real}, roots::AbstractVector{<:Number})
@inbounds lb, ub = c.lb[1], c.ub[1]
htol = eps(float(ub - lb)) * 100 # similar to chebfun
return [ clamp(real(r), lb, ub) for r in roots if abs(imag(r)) < htol && lb - htol <= real(r) <= ub + htol ]
end

if isdefined(LinearAlgebra.LAPACK, :hseqr!) # added in Julia 1.10
# see also LinearAlgebra.jl#1557 - optimized in-place eigenvalues for upper-Hessenberg colleague matrix
function hesseneigvals!(C::UpperHessenberg{T,Matrix{T}}) where {T<:Union{LinearAlgebra.BlasReal, LinearAlgebra.BlasComplex}}
ilo, ihi, _ = LinearAlgebra.LAPACK.gebal!('S', triu!(C.data, -1))
return sort!(LinearAlgebra.LAPACK.hseqr!('E', 'N', 1, size(C,1), C.data, C.data)[3], by=reim)
end
end
hesseneigvals!(C::UpperHessenberg{T,Matrix{T}}) where {T} = eigvals!(triu!(C.data, -1))

"""
roots(c::ChebPoly{1,<:Real}; tol=5eps, maxsize::Integer=50)

Returns a sorted array of the real roots of `c` on the interval `[lb,ub]` (the lower and
upper bounds of the interpolation domain).

Uses a colleague-matrix method combined with recursive subdivision of the domain
to keep the maximum matrix size `≤ maxsize`, following an algorithm described by
Trefethen (*Approximation Theory and Approximation Practice*, ch. 18). The `tol`
argument is a relative tolerance for dropping small polynomial coefficients, defaulting
to `5eps` where `eps` is the precision of `c`.
"""
function roots(c::ChebPoly{1,<:Real}; tol::Real=5*epsvals(c.coefs), maxsize::Integer=50)
tol > 0 || throw(ArgumentError("tolerance $tol for truncating coefficients must be > 0"))
abstol = sum(abs, c.coefs) * tol # absolute tolerance = L1 norm * tol
n = something(findlast(c -> abs(c) ≥ abstol, c.coefs), 1)
if n <= maxsize
λ = hesseneigvals!(UpperHessenberg(_colleague_matrix(@view c.coefs[1:n])))
@inbounds λ .= (λ .+ 1) .* ((c.ub[1] - c.lb[1])/2) .+ c.lb[1] # scale and shift to [lb,ub]
return filter_roots(c, λ)
else
# roughly halve the domain, constructing new Chebyshev polynomials on each half, and
# call roots recursively. Following chebfun, we split at an arbitrary point 0.004849834917525
# on [-1,1] rather than at 0 to avoid introducing additional spurious roots (since 0 is
# often a special point by symmetry).
@inbounds split = oftype(float(c.lb[1]), 1.004849834917525) * ((c.ub[1] - c.lb[1])/2) + c.lb[1]

# pick a fast order for the recursive DCT, should be highly composite, say 2^m
order = nextpow(2, n-1)
c1 = chebinterp(c, order, c.lb[1], split; tol=2tol)
c2 = chebinterp(c, order, split, c.ub[1]; tol=2tol)
return vcat(roots(c1; tol=2tol, maxsize=maxsize), roots(c2; tol=2tol, maxsize=maxsize))
end
end
6 changes: 6 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ Random.seed!(314159) # make chainrules tests deterministic
@test chebgradient(interp, x1) ≈′ (f(x1), f′(x1))
test_frule(interp, x1, rtol=sqrt(eps(T)), atol=sqrt(eps(T)))
test_rrule(interp, x1, rtol=sqrt(eps(T)), atol=sqrt(eps(T)))

@test roots(chebinterp(x -> 1.1, 10, 0,1)) ≈ Float64[]
@test roots(chebinterp(x -> x - 0.1, 10, 0,1)) ≈ [0.1]
@test roots(chebinterp(x -> (x - 0.1)*(x-0.2), 10, 0,1)) ≈ [0.1, 0.2]
@test roots(chebinterp(cos, 10000, 0, 1000))/pi ≈ (0:317) .+ T(0.5)
@test colleague_matrix(chebinterp(x -> (x - 0.1)*(x-0.2), 10, 0,1)) ≈ T[0.5 -0.24; 0.5 -0.2]
end
end

Expand Down
Loading