Particle filter — 512 particles, systematic resample
Bootstrap particle filter on a 1-D sinusoidal target with Gaussian observation noise. Propagate → weight → ESS → systematic resample when ESS < N/2. Blue: truth. Orange dots: noisy observations. Green: posterior mean. Pink dots on the right edge: the current particle cloud. The deterministic SplitMix64 PRNG keeps the trajectory replayable from the seed.
view the kernel that wrote this receipt crates/mathground-view/src/pfilter.rs
//! Bootstrap particle filter on a 1-D sinusoidal target with Gaussian
//! observation noise.
//!
//! Model:
//! true: x_t = A sin(ω t)
//! obs: y_t = x_t + ε, ε ~ N(0, σ²)
//! prop: x_t = x_{t-1} + step + N(0, q²) (random walk + drift)
//! wt: w ∝ exp(-(x - y)² / 2σ²)
//! ess: resample (systematic) when ESS < N/2.
//!
//! Deterministic given seed. V-class L0-closed.
use serde::{Deserialize, Serialize};
use crate::rng::SplitMix64;
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct PFilterParams {
pub n_particles: u32,
pub amplitude: f32,
pub omega: f32,
pub obs_sigma: f32,
pub proc_sigma: f32,
}
impl Default for PFilterParams {
fn default() -> Self {
Self {
n_particles: 512,
amplitude: 1.5,
omega: 0.15,
obs_sigma: 0.4,
proc_sigma: 0.15,
}
}
}
pub struct PFilterState {
pub rng: SplitMix64,
pub particles: Vec<f32>,
pub weights: Vec<f32>,
pub t: f32,
pub posterior_mean: f32,
pub ess: f32,
}
impl PFilterState {
pub fn new(p: &PFilterParams, seed: u64) -> Self {
let n = p.n_particles as usize;
let mut rng = SplitMix64::new(seed);
let particles: Vec<f32> = (0..n)
.map(|_| p.amplitude * rng.next_normal() * 0.2)
.collect();
let weights = vec![1.0 / n as f32; n];
Self { rng, particles, weights, t: 0.0, posterior_mean: 0.0, ess: n as f32 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PFilterStepReport {
pub bit_ops: u64,
pub ess: f32,
pub resampled: bool,
/// True target state at this step.
pub truth: f32,
/// Observation drawn for this step.
pub obs: f32,
}
/// One filter step. Mutates `state`; returns telemetry.
pub fn pfilter_step(state: &mut PFilterState, p: &PFilterParams, dt: f32) -> PFilterStepReport {
state.t += dt;
let truth = p.amplitude * (p.omega * state.t).sin();
let obs = truth + p.obs_sigma * state.rng.next_normal();
let drift = p.amplitude * p.omega * (p.omega * state.t).cos() * dt;
// ── Propagate ──
for x in state.particles.iter_mut() {
*x += drift + p.proc_sigma * state.rng.next_normal();
}
// ── Weight ──
let inv2s2 = 1.0 / (2.0 * p.obs_sigma * p.obs_sigma);
let mut w_sum = 0.0f32;
for (x, w) in state.particles.iter().zip(state.weights.iter_mut()) {
let d = x - obs;
*w = (-d * d * inv2s2).exp();
w_sum += *w;
}
if w_sum > 0.0 {
for w in state.weights.iter_mut() {
*w /= w_sum;
}
} else {
let n = state.weights.len() as f32;
for w in state.weights.iter_mut() {
*w = 1.0 / n;
}
}
// ── ESS ──
let ess: f32 = 1.0 / state.weights.iter().map(|w| w * w).sum::<f32>();
state.ess = ess;
// ── Posterior mean ──
state.posterior_mean = state
.particles
.iter()
.zip(state.weights.iter())
.map(|(x, w)| x * w)
.sum();
// ── Resample (systematic) when ESS < N/2 ──
let n = state.particles.len();
let mut resampled = false;
if ess < n as f32 * 0.5 {
let mut new_particles = Vec::with_capacity(n);
let u0 = state.rng.next_f32() / n as f32;
let mut c = state.weights[0];
let mut i = 0usize;
for j in 0..n {
let u = u0 + j as f32 / n as f32;
while u > c && i + 1 < n {
i += 1;
c += state.weights[i];
}
new_particles.push(state.particles[i]);
}
state.particles = new_particles;
let inv_n = 1.0 / n as f32;
for w in state.weights.iter_mut() {
*w = inv_n;
}
resampled = true;
}
// Bit-ops: O(N) for propagate, weight, ESS, mean, plus an optional
// O(N) resample. Conservatively 8 f32 ops/particle, possibly doubled.
let mult = if resampled { 16 } else { 8 };
let bit_ops = (n as u64) * mult * 32;
PFilterStepReport { bit_ops, ess, resampled, truth, obs }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn posterior_mean_tracks_truth() {
let p = PFilterParams::default();
let mut s = PFilterState::new(&p, 7);
let mut err = 0.0f32;
let n_steps = 500;
for _ in 0..n_steps {
let r = pfilter_step(&mut s, &p, 0.1);
err += (s.posterior_mean - r.truth).abs();
}
let mae = err / n_steps as f32;
// Should track within roughly one obs-sigma.
assert!(mae < p.obs_sigma * 1.5, "MAE = {mae}, expected < {}", p.obs_sigma * 1.5);
}
#[test]
fn resample_fires_when_ess_drops() {
let p = PFilterParams::default();
let mut s = PFilterState::new(&p, 1);
let mut any_resample = false;
for _ in 0..200 {
let r = pfilter_step(&mut s, &p, 0.1);
if r.resampled { any_resample = true; }
}
assert!(any_resample, "expected at least one resample in 200 steps");
}
#[test]
fn weights_are_normalised() {
let p = PFilterParams::default();
let mut s = PFilterState::new(&p, 3);
for _ in 0..100 {
pfilter_step(&mut s, &p, 0.1);
let sum: f32 = s.weights.iter().sum();
assert!((sum - 1.0).abs() < 1e-3, "weights sum {sum}");
}
}
}
This is the exact Rust file compiled into the WASM module the page just loaded. Every line that runs on your device is here. The receipt is a function of this code, not a bespoke benchmark.