Recording filtering results
filter returns the final filtering state and the total log likelihood. To keep intermediate results, write a loop with initialise and step, and append the results you need to a container. There is no callback interface to configure.
For particle filters, choose between two containers:
ParticleTreekeeps the ancestry of the current particles and removes branches with no surviving descendants. It is useful when you need trajectories without retaining every past particle population.DenseParticleContainerkeeps every population, its log weights and its ancestor indices. It uses more memory but lets you inspect individual filtering steps.
Both retain the initial states at time zero and the ancestor links from time one back to those states. Particle counts stay fixed throughout a stored history.
A manual particle-filtering loop
This example records both forms of history for a scalar autoregressive model. In practice, choose the container that suits your application.
using GeneralisedFilters, Distributions, Random
model = StateSpaceModel(
DistributionPrior(Normal()),
DistributionDynamics((t, x) -> Normal(0.9x, 0.2)),
DistributionObservation((t, x) -> Normal(x, 0.3)),
)
observations = [0.2, -0.1, 0.3]
function record_filter(rng, model, algorithm, observations)
isempty(observations) && throw(ArgumentError("observations must be nonempty"))
initial = initialise(rng, model.prior, algorithm)
tree = ParticleTree(initial)
state, ll = GeneralisedFilters.step(rng, model, algorithm, 1, initial, observations[1])
push!(tree, state)
history = DenseParticleContainer(initial, state)
for t in 2:length(observations)
state, increment = GeneralisedFilters.step(rng, model, algorithm, t, state, observations[t])
ll += increment
push!(tree, state)
push!(history, state)
end
return state, ll, tree, history
end
state, ll, tree, history = record_filter(MersenneTwister(42), model, BF(100), observations)
_, reference_ll = GeneralisedFilters.filter(MersenneTwister(42), model, BF(100), observations)
@assert ll ≈ reference_ll
@assert collect(get_ancestry(tree)[1]) == collect(get_ancestry(history, 1))
get_ancestry(tree)[1]ReferenceTrajectory(x0=-0.07899860438858432, xs=[-0.1609856404230711, -0.3462891968257587, -0.6188352755993028])get_ancestry(tree) returns one ReferenceTrajectory for each current particle. get_ancestry(history, i) follows the final particle at position i back through dense history. Each trajectory has indices 0:T. These are particle genealogies, not an additional backward-smoothing calculation. For a Rao–Blackwellised filter, stored inner states are the filtering distributions that accompanied those particles.
When the initial state has a different type
ParticleTree(initial) assumes that later particle states have the same type as the initial states. This is convenient for the example above. If the first transition or observation changes the state representation, wait until the first completed step:
initial = initialise(rng, model.prior, algorithm)
first_state, ll = GeneralisedFilters.step(rng, model, algorithm, 1, initial, observations[1])
tree = ParticleTree(initial, first_state)
history = DenseParticleContainer(initial, first_state)These constructors already store the first step, so start appending at time two. They infer the later state type from first_state. The dense container also infers the numeric weight type there, since an initial particle population may not yet have numeric weights.
A mismatched append raises an error before changing the stored history. If a one-argument tree constructor chose the wrong type, the error points you to the two-argument constructor. After the first step, state and weight types must stay consistent with the chosen container. Invalid particle counts or ancestor indices are also rejected before pruning or appending.
Ownership of mutable states
Containers copy the collection buffers used to store states, weights and ancestor indices. Replacing an entry in an input vector will therefore not replace a stored entry. They do not recursively copy each state object. A mutable vector or matrix inside a state remains shared, including when you retrieve it through get_ancestry.
Once stored, treat a state object as read-only. A custom transition or update used with history storage must leave earlier states unchanged. If your code reuses mutable state buffers, explicitly store a snapshot instead:
push!(tree, deepcopy(state))Similarly, use a copied initial population when constructing a history that must remain independent of mutable inputs. Copy a retrieved trajectory before editing its mutable states if the stored history must be preserved. Immutable states built from numbers and StaticArrays need no such deep copies.
For analytical filters, a manual loop can collect the returned filtering distributions in an ordinary vector. The same ownership rule applies to any mutable arrays they contain.
GPU sparse histories
With CUDA and BatchedKernels loaded, the same ParticleTree(initial, first_state) and push! interface stores batched GPU populations in a sparse device pool. Supported payloads include scalar, vector and matrix batches and nested BatchedStructs, including full RBState values. Numerical array leaves must reside on the active CUDA device; shared scalar values are also supported. Initial states have a separate fixed buffer and may have a different type or structure from later states. Later populations must retain compatible scalar types, inner dimensions and composite structure. Ancestry may use device vectors of Int32 or Int64; tree metadata uses Int64.
GPU sparse history owns its numerical payloads. Shared numerical fields are copied into per-slot storage, so a shared covariance can change between steps without altering earlier states. Non-numerical shared literals, such as a factorisation's triangle flag, must remain constant within each storage buffer. This snapshot rule is stronger than the CPU container's shallow ownership described above.
get_ancestry(tree, i) returns one compact ReferenceTrajectory indexed from zero. Its vector and matrix states stay on the device, backed only by the selected path's allocation. Later tree mutation cannot alter the returned path. Scalar metadata and selected scalar fields may transfer to the host during reconstruction; complete populations are never downloaded. get_ancestry(tree) explicitly constructs all leaf paths and consequently allocates storage for all requested output paths.
GPU vanilla conditional SMC and ancestor sampling use this tree with outer states only. Public ParticleTree histories still retain complete states, including RB beliefs. These retained filtering beliefs can be inspected along a surviving path or used as cached forward inputs when implementing conditional Gaussian smoothing along that path, under the same model parameters. Extracting the path does not itself turn filtering beliefs into smoothed beliefs. Backward simulation continues to use dense history because it needs past candidates that have no surviving forward descendants. Sparse storage removes dead branches; with no coalescence its worst-case memory still grows with particles times time steps, and allocated capacity is reused rather than shrunk.