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

Update documentation #16

Merged
merged 18 commits into from
Sep 13, 2023
Binary file added docs/src/images/state_space_model.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
81 changes: 80 additions & 1 deletion docs/src/index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,85 @@
# SSMProblems

### API
### Installation
In the `julia` REPL:
```julia
]add SSMProblems
```

### Documentation

`SSMProblems` defines a generic interface for State Space Problems (SSM). The main objective is to provide a consistent
interface to work with SSMs and their logdensities.

Consider a markovian model from[^Murray]:
yebai marked this conversation as resolved.
Show resolved Hide resolved
![state space model](docs/images/state_space_model.png)
yebai marked this conversation as resolved.
Show resolved Hide resolved

[^Murray]:
> Murray, Lawrence & Lee, Anthony & Jacob, Pierre. (2013). Rethinking resampling in the particle filter on graphics processing units.

The model is fully specified by the following densities:
- __Initialisation__: ``f_0(x)``
- __Transition__: ``f(x)``
- __Emission__: ``g(x)``
Comment on lines +21 to +23
Copy link
Member

Choose a reason for hiding this comment

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

I think it can be a bit confusing here to use x which otherwise only denotes (internal) states. Maybe just define it in/via the model equations below and remove this list?


And the dynamics of the model reduces to:
```math
x_t | x_{t-1} \sim f(x_t | x_{t-1})
```
```math
y_t | x_t \sim g(y_t | x_{t})
FredericWantiez marked this conversation as resolved.
Show resolved Hide resolved
```
assuming ``x_0 \sim f_0(x)``. The joint law follows:
yebai marked this conversation as resolved.
Show resolved Hide resolved

```math
p(x_{0:T}, y_{0:T}) = f_0{x_0} \prod_t g(y_t | x_t) f(x_t | x_{t-1})
yebai marked this conversation as resolved.
Show resolved Hide resolved
yebai marked this conversation as resolved.
Show resolved Hide resolved
```

Model users can define their `SSM` using the following interface:
FredericWantiez marked this conversation as resolved.
Show resolved Hide resolved
```julia

FredericWantiez marked this conversation as resolved.
Show resolved Hide resolved
struct Model <: AbstractStateSpaceModel end

# Define the structure of the latent space
particleof(::Model) = Float64
dimension(::Model) = 2
Comment on lines +45 to +46
Copy link
Member

Choose a reason for hiding this comment

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

IMO these names are quite confusing. It was not clear to me at all that particleof was referring to some type (which type? of the states I assume?) and that dimension only refers to the internal states and does not consider y.


function transition!!(rng::Random.AbstractRNG, step, model::Model, particle::AbstractParticl{<:AbstractStateSpaceModel})
yebai marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

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

Also this seems surprising for readers of the docs - why two exclamation marks? Should it return something or just do something? What is step? Why do we have to consider particles of other models as well? What is AbstractParticle?

IMO it would be better to introduce and explain the interface step by step with detailed explanations instead of adding a single code block with very short comments.

if step == 1
... # Sample from the initial density
end
... # Sample from the transition density
Comment on lines +54 to +57
Copy link
Member

Choose a reason for hiding this comment

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

I think if every user has to write this check for every model, this indicates that the API could be better designed. Maybe the easiest would be to have different functions for whether its the initial transition or not?

Or maybe the example just incorrectly suggestes that the code always takes this structure?

end

function emission_logdensity(step, model::Model, particle::AbstractParticle)
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
function emission_logdensity(step, model::Model, particle::AbstractParticle)
function emission_logdensity(step, model::Model, particle::AbstractParticle, observations)

Copy link
Member

Choose a reason for hiding this comment

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

Again I think this needs more explanations - what does this function do? What are the arguments? Should it return something or modify something?

... # Return log density of the model at *time* `step`
Copy link
Member

Choose a reason for hiding this comment

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

What do you mean with "log density of the model"? Just g(y_t | x_t) I assume based on the function name?

end

isdone(step, model::Model, particle::AbstractParticle) = ... # Stops the state machine
Copy link
Member

Choose a reason for hiding this comment

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

What is a state machine?

More conceptually/design-wise, why do we have to define such a function? The Markovian model above works just fine without any such restrictions - wouldn't it be more natural to just iterate as long as there are observations?

Copy link
Member

Choose a reason for hiding this comment

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

We will consider removing isdone function, and get such information from the size of the observation array.


# Optionally, if the transition density is known, the model can also specify it
function transition_logdensity(step, particle, x)
yebai marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

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

Again more explanations are needed IMO - what are the arguments and their types?

... # Scores the forward transition at `x`
Copy link
Member

Choose a reason for hiding this comment

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

What do you mean here?

end
```

Package users can then consume the model `logdensity` through calls to `emission_logdensity`.
Copy link
Member

Choose a reason for hiding this comment

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

Again, I'm not sure about the term "model logdensity" here due to the multitude of different log densities that could be considered in this setting.


For example, a bootstrap filter targeting the filtering distribution ``p(x_t | y_{0:t})`` using `N` particles would roughly follow:
```julia
struct Particle{T<:AbstractStateSpaceModel} <: AbstractParticle{T} 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 refers back to a question above - what is the interface and interpretation of AbstractParticle? And why are particles coupled with the model? Intuitively, to me they seem to be a separate entity.


while !all(map(particle -> isdone(t, model, particles), particles)):
Copy link
Member

Choose a reason for hiding this comment

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

I assume this should be

Suggested change
while !all(map(particle -> isdone(t, model, particles), particles)):
while !all(map(particle -> isdone(t, model, particle), particles)):

Conceptually, again I think the size of this loop should be governed rather by the observations than the model or particles.

ancestors = resample(rng, logweigths)
Copy link
Member

Choose a reason for hiding this comment

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

We need to reset all log weights to zero after the resampling step.

Suggested change
ancestors = resample(rng, logweigths)
ancestors = resample(rng, logweigths)
logweights = zero(logweights)

Copy link
Member

Choose a reason for hiding this comment

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

What is resample, also a part of the interface? I think more explanations would make this example much clearer.

particles = particles[ancestors]
Copy link
Member

Choose a reason for hiding this comment

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

Shadowing the variable makes it more difficult to follow the code, I assume. Maybe just save the particles at each time point for illustration purposes to make the sequential nature and the time-dependency of the particles clearer.

for i in 1:N
particles[i] = transition!!(rng, t, model, particles[i])
logweights[i] += emission_logdensity(t, model, particles[i])
Copy link
Member

Choose a reason for hiding this comment

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

Can't/shouldn't you just set

Suggested change
logweights[i] += emission_logdensity(t, model, particles[i])
logweights[i] = emission_logdensity(t, model, particles[i])

end
end
```

### Interface
```@autodocs
Modules = [SSMProblems]
Order = [:type, :function]
Expand Down
124 changes: 82 additions & 42 deletions examples/smc.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,45 @@ using Distributions
using Plots
using StatsFuns

# Particle Filter implementation
struct Particle{T} # Here we just need a tree
# Particle Filter
Copy link
Member

Choose a reason for hiding this comment

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

Do we want to keep this code block in the module?

struct Particle{T<:AbstractStateSpaceModel,V} <: AbstractParticle{T}
Copy link
Member

Choose a reason for hiding this comment

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

As already mentioned above, it is not clear to me why it is a good idea to add a type parameter of the model here. Also, are there any examples of other particle types that justify the abstract supertype?

parent::Union{Particle,Nothing}
Copy link
Member

Choose a reason for hiding this comment

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

The compiler has many tricks and optimizations for this specific type of Union{Nothing,...} but it means that the type of parent can't be inferred.

state::T
state::V
Copy link
Member

Choose a reason for hiding this comment

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

What is state?

Copy link
Member

Choose a reason for hiding this comment

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

Ah, is it x? That was not clear to me at all.

end

Particle(state::T) where {T} = Particle(nothing, state)
Particle() = Particle(nothing, nothing)
Base.show(io::IO, p::Particle) = print(io, "Particle($(p.state))")
function Particle(parent, state, model::T) where {T<:AbstractStateSpaceModel}
return Particle{T,particleof(model)}(parent, state)
end

function Particle(model::T) where {T<:AbstractStateSpaceModel}
N = dimension(model)
V = particleof(model)
state = N == 1 ? zero(V) : zeros(V, N)
Copy link
Member

Choose a reason for hiding this comment

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

This can't be inferred, or rather only works if constant propagation kicks in. Maybe this constructor should be removed and users should just define their desired particles?

return Particle{T,V}(nothing, state)
end

Base.show(io::IO, p::Particle{T,V}) where {T,V} = print(io, "Particle{$T, $V}($(p.state))")
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
Base.show(io::IO, p::Particle{T,V}) where {T,V} = print(io, "Particle{$T, $V}($(p.state))")
Base.show(io::IO, p::Particle{T,V}) where {T,V} = print(io, "Particle{", T, V, "}(", p.state, ")")

This suggests their is a constructor of this form - but it seems there isn't?


const ParticleContainer{T} = AbstractVector{<:Particle{T}}

"""
linearize(particle)

Return the trace of a particle, i.e. the sequence of states from the root to the particle.
"""
function linearize(particle::Particle{T}) where {T}
trace = T[]
parent = particle.parent
while !isnothing(parent)
push!(trace, parent.state)
parent = parent.parent
function linearize(particle::Particle{T,V}) where {T,V}
trace = V[]
current = particle
while !isnothing(current)
Copy link
Member

Choose a reason for hiding this comment

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

In older Julia versions,

Suggested change
while !isnothing(current)
while current !== nothing

was more performant.

push!(trace, current.state)
current = current.parent
end
return trace
return trace[1:(end - 1)] # Discard the root node
Copy link
Member

Choose a reason for hiding this comment

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

Just don't add this node in the first place? The indexing operation here actually creates a copy.

end

ParticleContainer = AbstractVector{<:Particle}
function isdone(t, model::AbstractStateSpaceModel, particles::ParticleContainer)
Copy link
Member

Choose a reason for hiding this comment

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

I have my doubts about this function, as mentioned above.

return all(map(particle -> isdone(t, model, particle), particles))
Copy link
Member

Choose a reason for hiding this comment

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

Without intermediate allocations1:

Suggested change
return all(map(particle -> isdone(t, model, particle), particles))
return all(particle -> isdone(t, model, particle), particles)

end

ess(weights) = inv(sum(abs2, weights))
get_weights(logweights::T) where {T<:AbstractVector{<:Real}} = StatsFuns.softmax(logweights)
Expand All @@ -40,11 +53,17 @@ function systematic_resampling(
return rand(rng, Distributions.Categorical(weights), n)
end

function sweep!(rng::AbstractRNG, particles::ParticleContainer, resampling, threshold=0.5)
function sweep!(
rng::AbstractRNG,
model::AbstractStateSpaceModel,
particles::ParticleContainer,
resampling,
threshold=0.5,
)
t = 1
N = length(particles)
logweights = zeros(length(particles))
while !isdone(t, particles[1].state)
while !isdone(t, model, particles)

# Resample step
weights = get_weights(logweights)
Expand All @@ -56,10 +75,8 @@ function sweep!(rng::AbstractRNG, particles::ParticleContainer, resampling, thre

# Mutation step
for i in eachindex(particles)
parent = particles[i]
mutated = transition!!(rng, t, parent.state)
particles[i] = Particle(parent, mutated)
logweights[i] += emission_logdensity(t, particles[i].state)
particles[i] = transition!!(rng, t, model, particles[i])
logweights[i] += emission_logdensity(t, model, particles[i])
end

t += 1
Expand All @@ -70,49 +87,72 @@ function sweep!(rng::AbstractRNG, particles::ParticleContainer, resampling, thre
return particles[idx]
end

function sweep!(rng::AbstractRNG, n::Int, resampling, threshold=0.5)
particles = [Particle(0.0) for _ in 1:n]
return sweep!(rng, particles, resampling, threshold)
# Turing style sample method
function sample(
rng::AbstractRNG,
n::Int,
model::AbstractStateSpaceModel;
resampling=systematic_resampling,
threshold=0.5,
)
particles = fill(Particle(model), N)
samples = sweep!(rng, model, particles, resampling, threshold)
return samples
end

# Inference code
Base.@kwdef struct Parameters
Base.@kwdef struct LinearSSM <: AbstractStateSpaceModel
v::Float64 = 0.2 # Transition noise stdev
u::Float64 = 0.7 # Observation noise stdev
end

# Structure of the latents space
particleof(::LinearSSM) = Float64
dimension(::LinearSSM) = 1

# Simulation
T = 250
seed = 1
N = 1000
N = 1_000
rng = MersenneTwister(seed)
params = Parameters(; v=0.2, u=0.7)

function transition!!(rng::AbstractRNG, t::Int, state=nothing)
if isnothing(state)
return rand(rng, Normal(0, 1))
end
return rand(rng, Normal(state, params.v))
end

function emission_logdensity(t, state)
return logpdf(Normal(state, params.u), observations[t])
end
model = LinearSSM(0.2, 0.7)

isdone(t, state) = t > T
isdone(t, ::Nothing) = false
f0(t) = Normal(0, 1)
f(t, x) = Normal(x, model.v)
g(t, x) = Normal(x, model.u)

# Generate synthtetic data
x, observations = zeros(T), zeros(T)
x[1] = rand(rng, Normal(0, 1))
x[1] = rand(rng, f0(1))
for t in 1:T
observations[t] = rand(rng, Normal(x[t], params.u))
observations[t] = rand(rng, g(t, x[t]))
if t < T
x[t + 1] = rand(rng, Normal(x[t], params.v))
x[t + 1] = rand(rng, f(t, x[t]))
end
end

# Model dynamics
function transition!!(
rng::AbstractRNG, t::Int, model::LinearSSM, particle::AbstractParticle
)
if t == 1
return Particle(particle, rand(rng, f0(t)), model)
else
return Particle(particle, rand(rng, f(t, particle.state)), model)
end
end

samples = sweep!(rng, fill(Particle(x[1]), N), systematic_resampling)
function emission_logdensity(t, model::LinearSSM, particle::AbstractParticle)
Copy link
Member

Choose a reason for hiding this comment

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

We should probably make observation an explicit argument:

Suggested change
function emission_logdensity(t, model::LinearSSM, particle::AbstractParticle)
function emission_logdensity(t, model::LinearSSM, particle::AbstractParticle, observations=observations)

return logpdf(g(t, particle.state), observations[t])
end

isdone(t, ::LinearSSM, ::AbstractParticle) = t > T
Copy link
Member

Choose a reason for hiding this comment

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

This is an example of a function that closes over a non-const global. Often this leads to very bad performance.


# Sample latent state trajectories
samples = sample(rng, N, LinearSSM())
traces = reverse(hcat(map(linearize, samples)...))

scatter(traces; color=:black, opacity=0.3, label=false)
plot!(x; label="True state")
plot!(mean(traces; dims=2); label="Posterior mean")
36 changes: 30 additions & 6 deletions src/SSMProblems.jl
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,61 @@ module SSMProblems

"""
"""
abstract type AbstractParticle end
abstract type AbstractStateSpaceModel end
abstract type AbstractParticle{T<:AbstractStateSpaceModel} end
abstract type AbstractParticleCache end

"""
transition!!(rng, step, particle[, cache])
transition!!(rng, step, model, particle[, cache])
yebai marked this conversation as resolved.
Show resolved Hide resolved

Simulate the particle for the next time step from the forward dynamics.
"""
function transition!! end

"""
transition_logdensity(step, particle, x[, cache])
transition_logdensity(step, model, prev_particle, next_particle[, cache])
yebai marked this conversation as resolved.
Show resolved Hide resolved

(Optional) Computes the log-density of the forward transition if the density is available.
"""
function transition_logdensity end

"""
emission_logdensity(step, particle[, cache])
emission_logdensity(step, model, particle[, cache])
Copy link
Member

Choose a reason for hiding this comment

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

We should probably make observation an explicit argument.

Suggested change
emission_logdensity(step, model, particle[, cache])
emission_logdensity(step, model, particle, observation [, cache])

Copy link
Member

Choose a reason for hiding this comment

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

Maybe add an optional API for sampling from the emission process, so we can easily generate data from the SSM model.


Compute the log potential of current particle. This effectively "reweight" each particle.
"""
function emission_logdensity end

"""
isdone(step, particle[, cache])
isdone(step, model, particle[, cache])

Determine whether we have reached the last time step of the Markov process. Return `true` if yes, otherwise return `false`.
"""
function isdone end

export transition!!, transition_logdensity, emission_logdensity, isdone, AbstractParticle
"""
particleof(::Type{AbstractStateSpaceModel})
yebai marked this conversation as resolved.
Show resolved Hide resolved

Returns the type of the latent state.
yebai marked this conversation as resolved.
Show resolved Hide resolved
"""
particleof(::Type{AbstractStateSpaceModel}) = Nothing
particleof(model::AbstractStateSpaceModel) = particleof(typeof(model))
yebai marked this conversation as resolved.
Show resolved Hide resolved

"""
dimension(::Type{AbstractStateSpaceModel})

Returns the dimension of the latent state.
"""
dimension(::Type{AbstractStateSpaceModel}) = Nothing
dimension(model::AbstractStateSpaceModel) = dimension(typeof(model))
yebai marked this conversation as resolved.
Show resolved Hide resolved

export transition!!,
transition_logdensity,
emission_logdensity,
isdone,
AbstractParticle,
AbstractStateSpaceModel,
dimension,
particleof
yebai marked this conversation as resolved.
Show resolved Hide resolved

end
Loading