Skip to content

Commit

Permalink
more docs, take unitintegral argument more consistently
Browse files Browse the repository at this point in the history
  • Loading branch information
stevengj committed Jul 21, 2023
1 parent 036a4bf commit 10e678a
Show file tree
Hide file tree
Showing 4 changed files with 226 additions and 27 deletions.
186 changes: 185 additions & 1 deletion docs/src/weighted-gauss.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,189 @@
# Gaussian quadrature and arbitrary weight functions

The manual chapter on [Gauss and Gauss–Kronrod quadrature rules](@ref) explains the fundamentals
of numerical integration ("quadrature") to approximate $\int_a^b f(x) dx$ by a weighted sum of
$f(x_i)$ values at quadrature points $x_i$. Make sure you understand that chapter before reading
this one!

More generally, one can compute quadrature rules for
a **weighted** integral:
```math
\int_a^b w(x) f(x) dx \approx \sum_{i=1}^n w_i f(x_i) \, ,
```
where the effect of **weight function** $w(x)$ (usually required to be $≥ 0$ in $(a,b)) is
included in the quadrature weights $w_i$ and points $x_i$. The main motivation
for weighted quadrature rules is to handle *poorly behaved* integrands — singular, discontinuous, highly oscillatory, and so on — where the "bad" behavior is *known*
and can be *factored out* into $w(x)$. By designing a quadrature rule with $w(x)$
taken into account, one can obtain fast convergence as long as the remaining
factor $f(x)$ is smooth, regardless of how "bad" $w(x)$ is. Moreover, the rule
can be re-used for many different $f(x)$ as long as $w(x)$ remains the same.

Gaussian quadrature is ideally suited to designing weighted quadrature rules, and QuadGK
includes functions to construct the points $x_i$ and weights $w_i$ for nearly any desired weight
function $w(x) \ge 0$, in principle, to any desired precision. The case of Gauss–Kronrod rules (if you
want an error estimate) is a bit trickier: it turns out that Gauss–Kronrod rules may not exist
for arbitrary weight functions \[see the review in [Notaris (2016)](https://etna.ricam.oeaw.ac.at/vol.45.2016/pp371-404.dir/pp371-404.pdf)\], but if a (real-valued) rule *does* exist then QuadGK can compute it for you (to arbitrary precision) using an algorithm by [Laurie (1997)](https://www.ams.org/journals/mcom/1997-66-219/S0025-5718-97-00861-2/S0025-5718-97-00861-2.pdf). You can specify the weight function $w(x)$ and the interval $(a,b)$ in one of two
ways:

* Via the [Jacobi matrix](https://en.wikipedia.org/wiki/Jacobi_operator) of the [orthogonal polynomials](https://en.wikipedia.org/wiki/Orthogonal_polynomials) associated with this weighted integral. That may sound complicated, but it turns out that these are tabulated for many important weighted integrals. For example, all of the weighted integrals in the [FastGaussQuadrature.jl](https://github.com/JuliaApproximation/FastGaussQuadrature.jl) package are based on well-known recurrences that you can look up easily.
* By explicitly providing the weight function $w(x)$, in which case QuadGK can perform a sequence of numerical integrals of $w(x)$ against polynomials (using `quadgk`) to numerically construct the Jacobi matrix and hence the Gauss or Gauss–Kronrod quadrature rule. (This can be computationally expensive, especially to attain high accuracy, but it can still be worthwhile if you re-use the quadrature rule for many different $f(x)$ and/or $f(x)$ is extremely computationally expensive.)

## Weight functions and Jacobi matrices

For any weighted integral $I[f] = \int_a^b w(x) f(x)$ with non-negative $w(x)$, there is an associated set of [orthogonal polynomials](https://en.wikipedia.org/wiki/Orthogonal_polynomials) $p_k(x)$ of degrees $k = 0,1,\ldots$, such that $I[p_j p_k] = 0$ for $j \ne k$. Amazingly,
the $n$-point Gaussian quadrature points $x_i$ are simply the roots of $p_n(x)$, and in general there is a
deep relationship between quadrature and the theory of orthogonal polynomials. A key part of this theory
ends up being the [Jacobi matrix](https://en.wikipedia.org/wiki/Jacobi_operator) describing the three-term
recurrence of these polynomials for a given weighted integral.

It turns out that orthogonal polynomials always obey a three-term recurrence relationship
```math
p_{k+1}(x) = (a_k x + b_k)p_k(x) - c_k q_{k-1}(x)
```
for some coefficients $a_k > 0$, $b_k$, and $c_k>0$ that depend on the integral $I$. By a rescaling
$p_k = q_k \prod_{j<k} a_k$, this simplifies to:
```math
q_{k+1}(x) = (x - \alpha_k)q_k(x) - \beta_k q_{k-1}(x)
```
for coefficients $\alpha_k = -b_k/a_k$ and $\beta_k = c_k/a_k a_{k-1} > 0$. (Once you know these coefficients,
in fact, you can obtain all of the orthogonal polynomials by $q_{-1}=0, q_0=1, q_1=(x-\alpha_0), q_2=(x-\alpha_1)(x-\alpha_0) - \beta_1,\ldots$.)
The coefficients are also associated with an infinite real-symmetric tridiagonal ["Jacobi" matrix](https://en.wikipedia.org/wiki/Jacobi_operator):
```math
J = \begin{pmatrix}
\alpha_0 & \sqrt{\beta_1} & & & \\
\sqrt{\beta_1} & \alpha_1 & \sqrt{\beta_2} & & \\
& \sqrt{\beta_2} & \alpha_2 & \sqrt{\beta_3} & \\
& & \ddots & \ddots & \ddots
\end{pmatrix} .
```
Let $J_n$ be the $n \times n$ upper-left corner of $J$. As another amazing fact, the
quadrature points $x_i$ turn out to be exactly the eigenvalues of $J$, and the quadrature weights $w_i$
are the first component² of the corresponding normalized eigenvectors, scaled by $I[1]$ [(Golub & Welch, 1968)](https://www.ams.org/journals/mcom/1969-23-106/S0025-5718-69-99647-1/S0025-5718-69-99647-1.pdf)!

Given the $n \times n$ matrix $J_n$ (represented by a [`LinearAlgebra.SymTridiagonal`](https://docs.julialang.org/en/v1/stdlib/LinearAlgebra/#LinearAlgebra.SymTridiagonal) object, which only stores the $\alpha_k$ and $\sqrt{\beta_k}$ coefficients) and the integral `unitintegral` $= I[1]$, you can construct the points $x_i$ and
weights $w_i$ of the $n$-point Gaussian quadrature rule in QuadGK via `x, w = gauss(Jₙ, unitintegral)`. To construct
the $(2n+1)$-point Kronrod rule, then you need the $m \times m$ matrix $J_m$ where `m ≥ div(3n+3,2)` ($m \ge \lfloor (3n+3)/2 \rfloor$), and then obtain the points `x` and weights `w` (along with embedded Gauss weights `gw`) via `x, w, gw = kronrod(Jₘ, n, unitintegral)`. Much of the time, you can simply look up formulas for the recurrence relations
for weight functions of common interest. Hopefully, this will be clearer with some examples below.

### Gauss–Legendre quadrature

The common case of integrals $I[f] = \int_{-1}^{+1} f(x) dx$, corresponding to the weight function $w(x) = 1$ over
the interval $(-1,1)$, leads to the [Legendre polynomials](https://en.wikipedia.org/wiki/Legendre_polynomials)
```math
p_0(x) = 1, \; p_1(x) = x, \; p_2(x) = (3x^2 - 1)/2, \; \ldots
```
which satisfy the recurrence ([found on Wikipedia](https://en.wikipedia.org/wiki/Legendre_polynomials#Recurrence_relations) and in many other sources):
```math
(k+1)p_{k+1}(x) = (2k+1)x p_k(x) - k p_{k-1}(x) \, .
```
In the notation given above, that corresponds to coefficients $a_k = (2k+1)/(k+1)$, $b_k = 0$, and $c_k = k/(k+1)$,
or equivalently $\alpha_k = 0$ and $\beta_k = c_k/a_k a_{k-1} = k^2 / (4k^2 - 1)$, giving a Jacobi matrix:
```math
J = \begin{pmatrix}
0 & \sqrt{1/3} & & & \\
\sqrt{1/3} & 0 & \sqrt{4/15} & & \\
& \sqrt{4/15} & 0 & \sqrt{9/35} & \\
& & \ddots & \ddots & \ddots
\end{pmatrix} .
```
which can be constructed in Julia by
```
julia> using LinearAlgebra # for SymTridiagonal
julia> J(n) = SymTridiagonal(zeros(n), [sqrt(k^2/(4k^2-1)) for k=1:n-1]) # the n×n matrix Jₙ
J (generic function with 1 method)
julia> J(5)
5×5 SymTridiagonal{Float64, Vector{Float64}}:
0.0 0.57735 ⋅ ⋅ ⋅
0.57735 0.0 0.516398 ⋅ ⋅
⋅ 0.516398 0.0 0.507093 ⋅
⋅ ⋅ 0.507093 0.0 0.503953
⋅ ⋅ ⋅ 0.503953 0.0
```
The unit integral is simply $I[1] = \int_{-1}^{+1} dx = 2$, so we can construct our $n$-point Gauss rule with, for example:
```
julia> x, w = gauss(J(5), 2); [x w]
5×2 Matrix{Float64}:
-0.90618 0.236927
-0.538469 0.478629
0.0 0.568889
0.538469 0.478629
0.90618 0.236927
```
This is, of course, the same as the "standard" Gaussian quadrature rule, returned by `gauss(n)`:
```
julia> x, w = gauss(5); [x w]
5×2 Matrix{Float64}:
-0.90618 0.236927
-0.538469 0.478629
0.0 0.568889
0.538469 0.478629
0.90618 0.236927
```
Similarly, the 5-point Gauss–Kronrod rule can be constructed from the $9\times 9$ Jacobi matrix ($9 = (3n+3)/2$):
```
julia> x, w, gw = kronrod(J(9), 5, 2); [x w]
11×2 Matrix{Float64}:
-0.984085 0.042582
-0.90618 0.115233
-0.754167 0.186801
-0.538469 0.24104
-0.27963 0.27285
0.0 0.282987
0.27963 0.27285
0.538469 0.24104
0.754167 0.186801
0.90618 0.115233
0.984085 0.042582
```
which is the same as the "standard" Gauss–Kronrod rule returned by `kronrod(n)` (returning only the $x_i \le 0$ points) or `kronrod(n, -1, +1)` (returning all the points):
```
julia> x, w, gw = kronrod(5); [x w]
6×2 Matrix{Float64}:
-0.984085 0.042582
-0.90618 0.115233
-0.754167 0.186801
-0.538469 0.24104
-0.27963 0.27285
0.0 0.282987
```

Notice that, in this case, our Jacobi matrix had zero diagonal entries $\alpha_k = 0$. It turns out that this *always* happens for a weight function $w(x)$ that is *symmetric* in the integration interval $(a,b)$, in this case meaning $w(x)=w(-x)$. This is called a "hollow" tridiagonal matrix, and its eigenvalues always come in $\pm x_j$ pairs: the quadrature rule is has *symmetric points and weights*. In this case QuadGK can do its computations a bit more efficiently, and only compute the non-redundant $x_i \le 0$ half of of the quadrature rule, if you represent $J_n$ with a special type [`QuadGK.HollowSymTridiagonal`](@ref) whose constructor only requires you to supply the off-diagonal elements $\sqrt{\beta_k}$:
```
julia> Jhollow(n) = QuadGK.HollowSymTridiagonal([sqrt(k^2/(4k^2-1)) for k=1:n-1])
Jhollow (generic function with 1 method)
julia> Jhollow(5)
5×5 QuadGK.HollowSymTridiagonal{Float64, Vector{Float64}}:
⋅ 0.57735 ⋅ ⋅ ⋅
0.57735 ⋅ 0.516398 ⋅ ⋅
⋅ 0.516398 ⋅ 0.507093 ⋅
⋅ ⋅ 0.507093 ⋅ 0.503953
⋅ ⋅ ⋅ 0.503953 ⋅
julia> x, w = gauss(Jhollow(5), 2); [x w]
5×2 Matrix{Float64}:
-0.90618 0.236927
-0.538469 0.478629
0.0 0.568889
0.538469 0.478629
0.90618 0.236927
julia> x, w, gw = kronrod(Jhollow(9), 5, 2); [x w]
6×2 Matrix{Float64}:
-0.984085 0.042582
-0.90618 0.115233
-0.754167 0.186801
-0.538469 0.24104
-0.27963 0.27285
0.0 0.282987
```

### Gauss–Jacobi quadrature

## Arbitrary weight functions

If you are computing many similar integrals of smooth functions, you may not need an adaptive
integration — with a little experimentation, you may be able to decide on an appropriate number
`N` of integration points in advance, and re-use this for all of your integrals. In this case
Expand All @@ -21,4 +205,4 @@ accuracy for the same integral from `quadgk` requires nearly 300 function evalua
by polynomials), so this is only more efficient if your `f(x)` is very expensive or if you need
to compute a large number of integrals with the same `W`.

See the [`gauss`](@ref) documentation for more information. See also our example using a [weight function interpolated from tabulated data](https://nbviewer.jupyter.org/urls/math.mit.edu/~stevenj/Solar-Quadrature.ipynb).
See the [`gauss`](@ref) documentation for more information. See also our example using a [weight function interpolated from tabulated data](https://nbviewer.jupyter.org/urls/math.mit.edu/~stevenj/Solar-Quadrature.ipynb).
52 changes: 35 additions & 17 deletions src/gausskronrod.jl
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ function gauss(::Type{T}, N::Integer) where T<:AbstractFloat
end
o = one(T)
b = T[ n / sqrt(4n^2 - o) for n = 1:N-1 ]
return gauss(HollowSymTridiagonal(b))
return gauss(HollowSymTridiagonal(b), 2)
end

gauss(N::Integer) = gauss(Float64, N) # integration on the standard interval (-1,1)
Expand All @@ -227,14 +227,27 @@ function gauss(::Type{T}, N::Integer, a::Real, b::Real) where T<:AbstractFloat
end

# Gauss rules for an arbitrary Jacobi matrix J
function gauss(J::AbstractSymTri{<:Real})
function gauss(J::AbstractSymTri{<:Real}, unitintegral::Real=1)
# Golub–Welch algorithm
x = eignewt(J, size(J,1))
v = Vector{promote_type(eltype(J),eltype(x))}(undef, size(J,1))
w = [ 2abs2(eigvec1!(v,J,x[i])[1]) for i = 1:size(J,1) ]
w = [ unitintegral * abs2(eigvec1!(v,J,x[i])[1]) for i = 1:size(J,1) ]
return (x, w)
end

# as above but rescaled to an arbitrary interval and unit integral
function gauss(J::AbstractSymTri{<:Real}, xrescale::Pair{<:Tuple{Real,Real}, <:Tuple{Real,Real}}, unitintegral::Real=1)
x, w = gauss(J, unitintegral)
T = eltype(x)
a0, b0 = xrescale.first
a, b = xrescale.second
xscale = (T(b) - T(a)) / (T(b0) - T(a0))
x .= (x .- a0) .* xscale .+ a
return x, w
end
gauss(J::AbstractSymTri{<:Real}, a::Real, b::Real, unitintegral::Real=1) =
gauss(J, (-1,1) => (a,b), unitintegral)

"""
kronrod([T,] n)
Expand Down Expand Up @@ -270,7 +283,7 @@ function kronrod(::Type{T}, n::Integer) where T<:AbstractFloat
for j = 1:div(3n+1,2)
b[j] = j^2 / (4j^2 - o)
end
x, w, v = _kronrod(HollowSymTridiagonal(b), b, Int(n))
x, w, v = _kronrod(HollowSymTridiagonal(b), b, Int(n), 2)

# Get embedded Gauss rule from even-indexed points, using
# the Golub–Welch method as described in Trefethen and Bau.
Expand All @@ -286,16 +299,16 @@ end
kronrod(n::Integer) = kronrod(Float64, n)

# as above, but generalized to an arbitrary Jacobi matrix
function kronrod(J::AbstractSymTri{<:Real}, n::Integer)
x, w, v = _kronrod(J, _kronrod_b(J, n), Int(n))
function kronrod(J::AbstractSymTri{<:Real}, n::Integer, unitintegral::Real=1)
x, w, v = _kronrod(J, _kronrod_b(J, n), Int(n), unitintegral)

# Get embedded Gauss rule from even-indexed points
Jsmall = if J isa SymTridiagonal
@views SymTridiagonal(J.dv[1:n], J.ev[1:n-1])
else
@views HollowSymTridiagonal(J.ev[1:n-1])
end
@views gw = [ 2abs2(eigvec1!(v[1:n],Jsmall,x[i])[1]) for i = 2:2:length(x) ]
@views gw = [ unitintegral*abs2(eigvec1!(v[1:n],Jsmall,x[i])[1]) for i = 2:2:length(x) ]

return x, w, gw
end
Expand All @@ -307,27 +320,32 @@ function kronrod(n::Integer, a::Real, b::Real)
x = [x; rmul!(reverse!(x[1:end-1]), -1)]
w = [w; reverse!(w[1:end-1])]
gw = [gw; reverse!(gw[1:end-isodd(n)])]
xscale = eltype(x)(b - a) / 2
T = eltype(x)
xscale = (T(b) - T(a)) / 2
x .= (x .+ 1) .* xscale .+ a
w .*= xscale
gw .*= xscale
return x, w, gw
end

function kronrod(J::AbstractSymTri{<:Real}, n::Integer, a::Real, b::Real, unitintegral::Real=1)
x, w, gw = kronrod(J, n)
function kronrod(J::AbstractSymTri{<:Real}, n::Integer, xrescale::Pair{<:Tuple{Real,Real}, <:Tuple{Real,Real}}, unitintegral::Real=1)
x, w, gw = kronrod(J, n, unitintegral)
if J isa HollowSymTridiagonal
x = [x; rmul!(reverse!(x[1:end-1]), -1)]
w = [w; reverse!(w[1:end-1])]
gw = [gw; reverse!(gw[1:end-isodd(n)])]
end
xscale = eltype(x)(b - a) / 2
x .= (x .+ 1) .* xscale .+ a
w .= (w .* unitintegral) ./ 2
gw .= (gw .* unitintegral) ./ 2
a0, b0 = xrescale.first
a, b = xrescale.second
T = eltype(x)
xscale = (T(b) - T(a)) / (T(b0) - T(a0))
x .= (x .- a0) .* xscale .+ a
return x, w, gw
end

kronrod(J::AbstractSymTri{<:Real}, n::Integer, a::Real, b::Real, unitintegral::Real=1) =
kronrod(J, n, (-1,1) => (a, b), unitintegral)

"""
kronrodjacobi(J::Union{SymTridiagonal, QuadGK.HollowSymTridiagonal}, n::Integer)
Expand Down Expand Up @@ -424,8 +442,8 @@ function _kronrodjacobi(J::AbstractSymTri{<:Real}, b::AbstractVector{T}, n::Int)
return J isa SymTridiagonal ? SymTridiagonal(a, b) : HollowSymTridiagonal(b)
end

# return the Kronrod weights and rule
function _kronrod(J::AbstractSymTri{<:Real}, b::AbstractVector{T}, n::Int) where {T<:AbstractFloat}
# return the Kronrod weights and rule. unitintegral should be the integral of the weight function
function _kronrod(J::AbstractSymTri{<:Real}, b::AbstractVector{T}, n::Int, unitintegral::Real=1) where {T<:AbstractFloat}
# the Jacobi–Kronrod matrix:
KJ = _kronrodjacobi(J, b, n)

Expand All @@ -437,7 +455,7 @@ function _kronrod(J::AbstractSymTri{<:Real}, b::AbstractVector{T}, n::Int) where
v = Vector{promote_type(eltype(b),eltype(x))}(undef, 2n+1)

# get quadrature weights
w = T[ 2abs2(eigvec1!(v,KJ,λ)[1]) for λ in x ]
w = T[ unitintegral * abs2(eigvec1!(v,KJ,λ)[1]) for λ in x ]

return (x, w, v)
end
Expand Down
7 changes: 2 additions & 5 deletions src/weightedgauss.jl
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,8 @@ function gauss(W, N, a::Real,b::Real; rtol::Real=sqrt(eps(typeof(float(b-a)))),

# find the Jacobi matrix and apply the Golub–Welsh algorithm:
J, xscale, wint = _jacobi(W, N, a, b, rtol, quad)
x, w = gauss(J)
x, w = gauss(J, wint)
@. x = (x + 1) / xscale + a
w .*= wint * 0.5
return (x, w)
end

Expand All @@ -91,10 +90,8 @@ function kronrod(W, N, a::Real,b::Real; rtol::Real=sqrt(eps(typeof(float(b-a))))

# find the Jacobi matrix and apply the Golub–Welsh algorithm:
J, xscale, wint = _jacobi(W, div(3N+3,2), a, b, rtol, quad)
x, w, wg = kronrod(J, N)
x, w, wg = kronrod(J, N, wint)
@. x = (x + 1) / xscale + a
w .*= wint * 0.5
wg .*= wint * 0.5
return (x, w, wg)
end

Expand Down
8 changes: 4 additions & 4 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ end
# test generic Kronrod algorithm that doesn't
# assume a Jacobi matrix with zero diagonals
J = SymTridiagonal(zeros(BigFloat, m), b)
x,w,gw = kronrod(J, n)
x,w,gw = kronrod(J, n, 2)
@test kronrod(J, n, -1, 1, 2) (x,w,gw) atol=1e-55
@test kronrod(QuadGK.HollowSymTridiagonal(b), n, -1, 1, 2) (x,w,gw) atol=1e-55
@test kronrod(n, -big"1.", big"1.") (x,w,gw) atol=1e-55
Expand All @@ -129,7 +129,7 @@ end
else
# test generic HollowSymTridiagonal method
J = QuadGK.HollowSymTridiagonal(b)
x,w,gw = kronrod(J, n)
x,w,gw = kronrod(J, n, 2)
end
else
x,w,gw = kronrod(BigFloat, n)
Expand Down Expand Up @@ -180,8 +180,8 @@ end
x0 = [-0.9864058663156023, -0.9165946746181465, -0.7905606636553969, -0.6160030510532921, -0.40362885238758006, -0.16646844386286386, 0.08092631663971639, 0.3233754136167141, 0.5460022311541176, 0.7351464193955097, 0.8792021083194245, 0.9693300504217204]
w0 = [0.1332843914263806, 0.22507385576322209, 0.27777923749595884, 0.3009074312028019, 0.2983522399648924, 0.2741818901904956, 0.23357001605691405, 0.1827277942958943, 0.12846478443650275, 0.07758611879734549, 0.036243907354385235, 0.00933245021077474]

x, w = gauss(J)
@test (x, w * wint/2) (x0, w0) atol=2e-14
x, w = gauss(J, 0, 1, wint)
@test (x .* 2 .- 1, w) (x0, w0) atol=2e-14

x, w, wg = kronrod(J, 7)
@test (x, w) gauss(QuadGK.kronrodjacobi(J, 7)) atol=1e-14
Expand Down

0 comments on commit 10e678a

Please sign in to comment.