Skip to content
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

add Trapezoidal rule #173

Merged
merged 18 commits into from
Sep 21, 2023
Merged

add Trapezoidal rule #173

merged 18 commits into from
Sep 21, 2023

Conversation

IlianPihlajamaa
Copy link
Contributor

@IlianPihlajamaa IlianPihlajamaa commented Sep 7, 2023

!! Not yet ready to merge !!
EDIT: I think it's ready now!

To do:

  • implement rule itself
  • add tests
  • add documentation
  • put the type piracy into SciMLBase

I have added a trapezoidal rule for integrating sampled data. Let me know what you think. If/once you agree with the code, I will write docs (and add Simpson's as well in the same way). Right now, I only have a docstring for Trapezoidal.

Additionally, I have some duplicate code for in- and out-of-place operations, that I don't really know how to combine. See lines 224-241 of Integrals.jl, do you have an idea how to streamline such code?

The trapezoidal rule supports integration of pre-sampled data, stored in an array, as well as integration of
functions. It does not support batching or integration over multidimensional spaces.

To use the trapezoidal rule to integrate a function on a regular grid with n points:

using Integrals
f = (x, p) -> x^9
n = 1000
method = Trapezoidal(n)
problem = IntegralProblem(f, 0.0, 1.0)
solve(problem, method) # u: 0.10000075150155016

To use the trapezoidal rule to integrate a function on an predefined irregular grid, see the following example.
Note that the lower and upper bound of integration must coincide with the first and last element of the grid.

using Integrals
f = (x, p) -> x^9
x = sort(rand(1000))
x = [0.0; x; 1.0]
method = Trapezoidal(x)
problem = IntegralProblem(f, 0.0, 1.0)
solve(problem, method) # u: 0.10000490350275731

To use the Trapezoidal rule to integrate a set of sampled data, see the following example.
By default, the integration occurs over the first dimension of the input array.

using Integrals
x = sort(rand(1000))
x = [0.0; x; 1.0]
y1 = x' .^ 4
y2 = x' .^ 9
y = [y1; y2]
method = Trapezoidal(x; dim=2)
problem = IntegralProblem(y, 0.0, 1.0)
solve(problem, method) 
#u: 2-element Vector{Float64}:
# 0.2000015680246171
# 0.10000365747228422

In order to make integration over arrays work, I needed to add a method (because isinplace(f::AbstractArray) is not defined:

import SciMLBase.IntegralProblem
function IntegralProblem(y::AbstractArray, lb, ub, args...; kwargs...)
    IntegralProblem{false}(y, lb, ub, args...; kwargs...)
end

I think this belongs in SciMLBase. I will make a PR there too if you agree this is the right approach.

src/Integrals.jl Outdated
Comment on lines 151 to 154
import SciMLBase.IntegralProblem # this is type piracy, and belongs in SciMLBase
function IntegralProblem(y::AbstractArray, lb, ub, args...; kwargs...)
IntegralProblem{false}(y, lb, ub, args...; kwargs...)
end
Copy link
Member

Choose a reason for hiding this comment

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

Yeah move this over to SciMLBase

src/Integrals.jl Outdated
Comment on lines 155 to 250
function construct_grid(prob, alg, lb, ub, dim)
x = alg.spec
@assert length(ub) == length(lb) == 1 "Multidimensional integration is not supported with the Trapezoidal method"
if x isa Integer
grid = range(lb[1], ub[1], length=x)
else
grid = x
@assert ndims(grid) == 1 "Multidimensional integration is not supported with the Trapezoidal method"
end

@assert lb[1] ≈ grid[begin] "Lower bound in `IntegralProblem` must coincide with that of the grid"
@assert ub[1] ≈ grid[end] "Upper bound in `IntegralProblem` must coincide with that of the grid"
if is_sampled_problem(prob)
@assert size(prob.f, dim) == length(grid) "Integrand and grid must be of equal length along the integrated dimension"
@assert axes(prob.f, dim) == axes(grid,1) "Grid and integrand array must use same indexing along integrated dimension"
end
return grid
end

@inline myselectdim(y::AbstractArray{T,dims}, d, i) where {T,dims} = selectdim(y, d, i)
@inline myselectdim(y::AbstractArray{T,1}, _, i) where {T} = @inbounds y[i]

@inline dimension(::Val{D}) where D = D
function __solvebp_call(prob::IntegralProblem, alg::Trapezoidal{S, D}, sensealg, lb, ub, p; kwargs...) where {S,D}
# since all AbstractRange types are equidistant by design, we can rely on that
@assert prob.batch == 0
# using `Val`s for dimensionality is required to make `selectdim` not allocate
dim = dimension(D)
p = p
if is_sampled_problem(prob)
@assert alg.spec isa AbstractArray "For pre-sampled problems where the integrand is an array, the integration grid must also be specified by an array."
end

grid = construct_grid(prob, alg, lb, ub, dim)

err = Inf64
if is_sampled_problem(prob)
data = prob.f
# inlining is required in order to not allocate
@inline function integrand(i)
# integrate along dimension `dim`, returning a n-1 dimensional array, or scalar if n=1
myselectdim(data, dim, i)
end
else
if isinplace(prob)
y = zeros(eltype(lb), prob.nout)
integrand = i -> @inbounds (prob.f(y, grid[i], p); y)
else
integrand = i -> @inbounds prob.f(grid[i], p)
end
end

firstidx, lastidx = firstindex(grid), lastindex(grid)

out = integrand(firstidx)

if isbits(out)
# fast path for equidistant grids
if grid isa AbstractRange
dx = grid[begin+1] - grid[begin]
out /= 2
for i in (firstidx+1):(lastidx-1)
out += integrand(i)
end
out += integrand(lastidx)/2
out *= dx
# irregular grids:
else
out *= (grid[firstidx + 1] - grid[firstidx])
for i in (firstidx+1):(lastidx-1)
@inbounds out += integrand(i) * (grid[i + 1] - grid[i-1])
end
out += integrand(lastidx) * (grid[lastidx] - grid[lastidx-1])
out /= 2
end
else # same, but inplace, broadcasted
out = copy(out) # to prevent aliasing
if grid isa AbstractRange
dx = grid[begin+1] - grid[begin]
out ./= 2
for i in (firstidx+1):(lastidx-1)
out .+= integrand(i)
end
out .+= integrand(lastidx) ./ 2
out .*= dx
else
out .*= (grid[firstidx + 1] - grid[firstidx])
for i in (firstidx+1):(lastidx-1)
@inbounds out .+= integrand(i) .* (grid[i + 1] - grid[i-1])
end
out .+= integrand(lastidx) .* (grid[lastidx] - grid[lastidx-1])
out ./= 2
end
end
return SciMLBase.build_solution(prob, alg, out, err, retcode = ReturnCode.Success)
end
Copy link
Member

Choose a reason for hiding this comment

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

This implementation of Trapezoid should be in a separate file trapezoid.jl that implements the method, and is then included into the top level file.

src/Integrals.jl Outdated
if is_sampled_problem(prob)
data = prob.f
# inlining is required in order to not allocate
@inline function integrand(i)
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
@inline function integrand(i)
integrand = function (i)

It should be an anonymous function if in an if statement

@ChrisRackauckas
Copy link
Member

Just small organizational things, but otherwise looks great!

@ChrisRackauckas
Copy link
Member

In order to make integration over arrays work, I needed to add a method (because isinplace(f::AbstractArray) is not defined:

Yes, let's get that merged and bumped first, then bump the lower bound and build off of that.

@ChrisRackauckas
Copy link
Member

Though I think more data is actually required, since it may need to know the x at which the value f(x) is associated. So it needs two arrays. Might as well just make that a DataIntegralProblem or something at that point to keep it clean.

@sathvikbhagavan
Copy link
Member

Is it also possible to implement a method to return a vector of integrated values - i.e. from beginning to that point - like in https://github.com/dextorious/NumericalIntegration.jl/blob/master/src/NumericalIntegration.jl#L44?

@IlianPihlajamaa
Copy link
Contributor Author

Is it also possible to implement a method to return a vector of integrated values - i.e. from beginning to that point - like in https://github.com/dextorious/NumericalIntegration.jl/blob/master/src/NumericalIntegration.jl#L44?

Yeah, that is possible of course... We can define a CumulativeTrapezoidal() quadrature to do this. Let me implement the standard methods first though.

@IlianPihlajamaa
Copy link
Contributor Author

Though I think more data is actually required, since it may need to know the x at which the value f(x) is associated. So it needs two arrays. Might as well just make that a DataIntegralProblem or something at that point to keep it clean.

Ah so you prefer the x-data to be inside the problem, not the method. I guess that makes sense.

So the API should be:

x = 0.0:0.1:1.0
y = x.^2
method = Trapezoidal()
problem = DataIntegralProblem(x, y; dim=1) # dim optional
solve(problem, method)

f = x -> x^2 
method = Trapezoidal()
problem = IntegralProblem(f, 0.0, 1.0)
solve(problem, method; x = 0.0:0.1:1.0)

Do you agree?

@ChrisRackauckas
Copy link
Member

Ah so you prefer the x-data to be inside the problem, not the method. I guess that makes sense.

Yes, it's part of the problem definition, so it should be defined in solve either. So not solve(problem, method; x = 0.0:0.1:1.0), that x should move into the problem definition somehow?

@ChrisRackauckas
Copy link
Member

To be clear, all solve dispatches across all of SciML are expected to have the same keyword arguments, so adding an x one for this specific case wouldn't happen. The keyword arguments are meant to be general solver controls.

@IlianPihlajamaa
Copy link
Contributor Author

IlianPihlajamaa commented Sep 13, 2023

Ah so you prefer the x-data to be inside the problem, not the method. I guess that makes sense.

Yes, it's part of the problem definition, so it should be defined in solve either. So not solve(problem, method; x = 0.0:0.1:1.0), that x should move into the problem definition somehow?

Alright, so that means we need to put an x=nothing field into the IntegralProblem type. This also makes the DataIntegralProblem type redundant.

I will change this in the PR to SciMLBase.

EDIT: just did. Will continue this implementation when that is merged. (I dont know how to develop multiple interdependent packages at the same time).

@lxvm
Copy link
Collaborator

lxvm commented Sep 16, 2023

It sounds like this PR is suggesting 2 things:

  1. Adding a DataIntegralProblem for sampled data (related pr here)
  2. Adding trapezoidal integration on regular grids for functions (e.g. the first example in the OP)

Point 1 is novel and should be addressed in the pr. Point 2 could be addressed in a separate pr because it is a generalization of what is already implemented in the FastGaussQuadrature.jl extension. An api for this could be

struct QuadratureFunction{Q} <: IntegralAlgorithm
    q::Q
    n::Int
end

which accepts a function, q, of the form x, w = q(n) which given a number of points n returns vectors x and w of length n that can be used to compute the quadrature dot(w, f.(x)). It would also be possible to cache the nodes and weights so they could be used across multiple intervals [lb, ub]. I implemented something similar to this for one of my packages here so I could open a pr for this.

@IlianPihlajamaa
Copy link
Contributor Author

Regarding point 2, I agree that integrating a function with the trapezoidal (or any other) quadrature rule is a different problem to that of integrating data. However i do think we should at least try to get the api for the former to be consistent with the current one of this package, so something of the form

solve(IntegralProblem(f, lb, ub), Trapezoidal(n))

The Trapezoidal struct could be responsible for the optional caching of the points and weights. Do you agree?

@ChrisRackauckas
Copy link
Member

I implemented something similar to this for one of my packages here so I could open a pr for this.

Yes that would be nice to have.

Regarding point 2, I agree that integrating a function with the trapezoidal (or any other) quadrature rule is a different problem to that of integrating data. However i do think we should at least try to get the api for the former to be consistent with the current one of this package, so something of the form

Yes

@lxvm
Copy link
Collaborator

lxvm commented Sep 16, 2023

So given the QuadratureFunction I proposed above, I would implement Trapezoidal as

"""
    trapz(n::Integer)

Return the weights and nodes on the standard interval [-1,1] of the [trapezoidal
rule](https://en.wikipedia.org/wiki/Trapezoidal_rule).
"""
function trapz(n::Integer)
    @assert n > 1
    r = range(-1, 1, length=n)
    x = collect(r)
    halfh = step(r)/2
    h = step(r)
    w = [ (i == 1) || (i == n) ? halfh : h for i in 1:n ]
    return (x, w)
end

struct Trapezoidal <: IntegralAlgorithm end # use this constructor for DataIntegralProblems
Trapezoidal(n) = QuadratureFunction(trapz, n) # use this constructor for IntegralProblems

and you could implement GaussLegendre(n) = QuadratureFunction(gausslegendre, n). Then all of these different quadrature rules could be implemented in the Integrals.jl api because they are all basically computing the same thing: sum(w .* f.(x)).

The Trapezoidal struct or function doesn't need to store the weights because the SciML init interface creates a cache that we can use. However, this only applies to point 2 and if you want to apply the Trapezoidal rule to nonuniform data, then the grid should be passed to a DataIntegralProblem.

@lxvm
Copy link
Collaborator

lxvm commented Sep 16, 2023

I'll follow up with a QuadratureFunction pr today

@ChrisRackauckas
Copy link
Member

BTW, the name Trapezoidal is already used in OrdinaryDiffEq so we should probably prevent the name clash.

@lxvm
Copy link
Collaborator

lxvm commented Sep 16, 2023

How about TrapezoidalRule? I think the 'rule' suffix helps clarify that this is just a quadrature rule, not a an algorithm that converges to a requested tolerance

@ChrisRackauckas
Copy link
Member

👍 I like that suggestion.

I'll follow up with a QuadratureFunction pr today

Awesome! It's great to see this finally getting some love again.

@IlianPihlajamaa
Copy link
Contributor Author

So given the QuadratureFunction I proposed above, I would implement Trapezoidal as

and you could implement GaussLegendre(n) = QuadratureFunction(gausslegendre, n). Then all of these different quadrature rules could be implemented in the Integrals.jl api because they are all basically computing the same thing: sum(w .* f.(x)).

The Trapezoidal struct or function doesn't need to store the weights because the SciML init interface creates a cache that we can use. However, this only applies to point 2 and if you want to apply the Trapezoidal rule to nonuniform data, then the grid should be passed to a DataIntegralProblem.

This looks great, and i agree this would be a nice way to implement many rules. In the simple cases though, it may be possible to avoid allocating. eg, x does not need to be collected, and w could be a vector-like struct that has a getindex/dot method.

@lxvm
Copy link
Collaborator

lxvm commented Sep 16, 2023

Yes, it is not necessary for x or w to be allocated. Probably iteration and a getindex method is enough. We also should also keep this compatible with batched integrands, so the exact requirements for x and w may depend on the implementation. This will have to be documented, although the point of the cache is that you only need to allocate once, which will happen at init time and not solve! time. Starting with plain Vectors should cover most cases even though it may come with a memory penalty for n very large.

@IlianPihlajamaa
Copy link
Contributor Author

IlianPihlajamaa commented Sep 19, 2023

Sorry for the delay, here is a rewrite of the original code. If approved I can straightforwardly implement also Simpson's rule. The trapezoidal rule for integrating functions can use a combination of this code, and the QuadratureRule implementation.

Let me know how it can be improved.

Apparently the `eachslice behaviour changed in 1.9 causing tests to fail...
@lxvm
Copy link
Collaborator

lxvm commented Sep 20, 2023

We should also implement the init interface for SampledIntegralProblem since this pr adds it, so I'll add that

@lxvm
Copy link
Collaborator

lxvm commented Sep 20, 2023

@IlianPihlajamaa I'm not sure how to commit to your pr, so see my fork for how to add the init interface. It will have a type instability until the dim field of SampledIntegralProblem is made an Int

@lxvm
Copy link
Collaborator

lxvm commented Sep 20, 2023

I realized I would have to make a PR to your branch

@IlianPihlajamaa
Copy link
Contributor Author

@IlianPihlajamaa I'm not sure how to commit to your pr, so see my fork for how to add the init interface. It will have a type instability until the dim field of SampledIntegralProblem is made an Int

Looks great! If you make a PR to my branch I will merge it! I don't know an easier way to include your work. Do you?

@lxvm
Copy link
Collaborator

lxvm commented Sep 21, 2023

Looks great! If you make a PR to my branch I will merge it! I don't know an easier way to include your work. Do you?

Yes, see https://github.com/IlianPihlajamaa/Integrals.jl/pull/1

add init interface for SampledIntegralProblem
sol3 = solve!(cache)
```

For multi-dimensional datasets, the integration dimension can also be changed
Copy link
Member

Choose a reason for hiding this comment

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

oh that's pretty cool.

function SciMLBase.init(prob::SampledIntegralProblem,
alg::SciMLBase.AbstractIntegralAlgorithm;
kwargs...)
NamedTuple(kwargs) == NamedTuple() || throw(ArgumentError("There are no keyword arguments allowed to `solve`"))
Copy link
Member

Choose a reason for hiding this comment

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

that's odd, why not? There are many keyword arguments that would go here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@lxvm is there a specific reason why not, or did you just not expect it to ever be necessary?

Copy link
Collaborator

Choose a reason for hiding this comment

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

The current implementation of the solver doesn't use any keyword arguments, so I didn't want an api with keywords. If future algorithms for sampled integral problems need them I would expect this to change, but there are no convergence criteria for this kind of problem, so I wasn't expecting any

Copy link
Member

Choose a reason for hiding this comment

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

Oh just the sampled data methods. Okay, yeah for now might as well throw. We always try to throw if keyword arguments that are incorrect so this is good.

src/trapezoidal.jl Outdated Show resolved Hide resolved
src/trapezoidal.jl Outdated Show resolved Hide resolved
src/trapezoidal.jl Outdated Show resolved Hide resolved
@ChrisRackauckas
Copy link
Member

Just a few comments, other than that I think it's pretty ready to go.

put the type piracy into SciMLBase

Where is this? I didn't see a piracy.

@IlianPihlajamaa
Copy link
Contributor Author

Just a few comments, other than that I think it's pretty ready to go.

put the type piracy into SciMLBase

Where is this? I didn't see a piracy.

It's already in SciMLBase

@ChrisRackauckas
Copy link
Member

oh it wasn't checked 😅

IlianPihlajamaa and others added 2 commits September 21, 2023 18:40
Co-authored-by: Christopher Rackauckas <[email protected]>
Co-authored-by: Christopher Rackauckas <[email protected]>
@IlianPihlajamaa
Copy link
Contributor Author

oh it wasn't checked 😅

Yes, my bad :)

@lxvm
Copy link
Collaborator

lxvm commented Sep 21, 2023

@IlianPihlajamaa What does this PR do about multidimensional grids? We can certainly define a multidimensional trapezoidal rule on regular grids as a product of 1d rules, but what would the API be? Would the user pass in a x array constructed like an Iterators.product? Or would x be a vector of SVectors?

If we don't want to support this then should we check x isa AbstractVector{<:Number}?

@IlianPihlajamaa
Copy link
Contributor Author

@IlianPihlajamaa What does this PR do about multidimensional grids? We can certainly define a multidimensional trapezoidal rule on regular grids as a product of 1d rules, but what would the API be? Would the user pass in a x array constructed like an Iterators.product? Or would x be a vector of SVectors?

If we don't want to support this then should we check x isa AbstractVector{<:Number}?

The constructor in SciMLBase already guarantees that x isa AbstractVector. Because multidimensional integration with a product rule is so easy to do by just repeating single-dimensional integration, I havent implemented it explicitly. If we want to add it anyway for the convenience or added performance, we should indeed think of an API (pethaps in a separate issue, because it is not trivial if we also want the option to support non-product grids or other domain shapes).

@lxvm
Copy link
Collaborator

lxvm commented Sep 21, 2023

Thanks, that sounds good. I agree that multidimensional domains should be handled separately, and the user would probably have to supply some extra information about the domain. Congrats on the very nice PR!

@ChrisRackauckas ChrisRackauckas merged commit c917535 into SciML:master Sep 21, 2023
6 of 7 checks passed
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.

4 participants