Theme

Blog · Bayesian filtering ·

How many particles, and how wrong can your first guess be?

Closing the Bayesian-filtering arc with the two questions every particle-filter engineer actually asks, answered by Monte-Carloing them live in the browser: Rust and WASM in a Web Worker, sweeping tens of millions of particle-updates behind a progress bar.

  • Interactive
  • particle-filter
  • extended-kalman-filter
  • monte-carlo
  • rust
  • webassembly
  • bayesian-filtering

Three posts ago I derived the Kalman filter from a voltmeter. Two posts ago its extended cousin locked onto the wrong range and stayed there. Last post a cloud of four thousand weighted guesses held two stories at once (close-and-slow, far-and-fast) for exactly as long as the lighthouse’s one bearing gave it no reason to pick. That is the whole arc: 1-Dnonlinearmulti-modal, each post breaking the previous one’s assumption. This one closes it by asking the two questions nobody answers with a derivation: how many particles is enough, and how wrong can the first guess be before it matters. §5.5–5.7 and §6.5–6.7 of the report (Experiments 5, 6 and 7) answer both, with a third experiment thrown in that answers a question I didn’t expect: what happens to the multi-modality itself when you add a second kind of measurement.

The code for this post is Experement_5.py (and its loader), Experement_6.py (and its loader), Experement_7.py, Problems.py::TestProblem4 and two more functions in Utils.py. The widget is new work, not a port of an experiment script: a Rust crate, pf-wasm, compiled to WebAssembly and run in a Web Worker, because the honest budget for “run the sweep live” is tens of millions of particle-updates: the one place in this four-post arc where that toolchain earns its keep rather than being a distraction.

Question one: how many particles?

§5.6 sweeps NsN_s: the report says “from Ns=100N_s=100 to Ns=10000N_s=10000 using steps of 100”; the script that actually produced the figure, Experement_6.py line 45, loops range(200, 10200, 200), 50 points from 200 to 10000. Small discrepancy, but worth noting since I’m about to lean on the code, not the prose, for the widget’s own point count. Everything else is fixed at dataset B’s usual values (the same Q\mathbf{Q}, R\mathbf{R}, K=0.2K=0.2 as post 19) except the initial guess, which here is the near cluster, not the true start:

initP = np.array([[0.5**2,       0,      0,       0],
                  [0     , 0.005**2,      0,       0],
                  [0     ,        0, 0.3**2,       0],
                  [0     ,        0,      0, 0.01**2]])
initX = [0.0, 0.0, 0.4, -0.05]
...
runs = 20
def evaluateAllFiltersPartial(numberOfParticals, currX):
    return evaluatePFFilterLessMemory(problem, numSamples, X0Real, numberOfParticals, K,
                               R, Q, initP, initX, [0,1,2,3])

Experement_6.py lines 11–33 (trimmed). evaluatePFFilterLessMemory (Utils.py lines 100–114) only ever builds one filter, ParticleFilterSIRWithJitter (SIR, always resampling, jitter on) and runs it 20 times per NsN_s. Fig. 12, page 22:

The report’s own advice, §7 p.24, is the whole reason this post has a live sweep instead of a screenshot of one: picking NsN_s by “however many the CPU can afford” wastes resources, and picking it from a general rule of thumb gets the wrong answer for your ff, hh and dimensionality. The right move is to run the algorithm a few times at increasing NsN_s, plot error against NsN_s, and read the knee off the curve. So: press play below rather than read mine off a report page.

InteractiveRun the sweep yourself
Two stacked log-scale charts, EKF mean squared error above and particle-filter mean squared error below, plotted against the sweep axis with a shaded interquartile band around a median line. A dashed marker on the lower chart flags the smallest particle count within 15 percent of the best error seen.

With JavaScript on, this Monte-Carlos the sweep itself in a Web Worker: pick the axis (particle count, or the initial guess’s offset along one of the four state components), pick SIS, GPF or SIR, toggle jitter and Experiment 7’s range measurement, and press Run. Median and interquartile-range bands fill in left to right as each point finishes; a Stop button is always live.

I ran it myself at NsN_s from 100 to 10000 (GPF with jitter, 20 runs a point, matching Experement_6.py’s own runs = 20): the median MSE drops from about 1.0×1021.0\times10^{-2} at Ns=100N_s=100 to 447×1037\times10^{-3} by Ns1000N_s\approx1000 and stays in that band out to 10000, the same decaying-then-flat shape as Fig. 12, noisier because 20 runs a point is what the report itself budgeted, not because the widget cut corners. The knee marker (dashed, “smallest NsN_s within 15% of the best seen”) lands around 1000–2000 in my runs, a little earlier than the report’s “sufficient by 4000”, both are reading noise off the same shape, and that is rather the point: your curve, for your problem, is the one to trust, and this one visibly moves as more points stream in.

Question two: how wrong can the first guess be?

§5.5 asks the complementary question: fix NsN_s, and instead push the initial estimate away from the truth. Experement_5.py lines 20–46:

X0Real = np.array([-0.05, 0.001, 0.7, -0.055])
weightings = [-15.0, 8.0, 15.0, -8.0]
...
def evaluateAllFiltersPartial(initX, currX):
    initP = np.eye(4)*(1e-6+(X0Real-initX)**2)
    return evaluateImportantAllFiltersLessMemory(problem, numSamples, X0Real, numberOfParticals, K,
                               R, Q, initP, initX, [0,1,2,3])
...
for axis in range(4):
    for percentageOfActual in np.linspace(0,1,100):
        X0RealValue = X0Real.copy()
        X0RealValue[axis] += weightings[axis]*percentageOfActual

Read that carefully, because it is a nice piece of engineering: the true start X0Real never moves: only the guess X0RealValue walks away from it, one state axis at a time, up to ±15\pm15 in position or ±8\pm8 in velocity. And the guess’s own uncertainty, initP, grows to match: P0=106I+(x0realx0kf)2\mathbf{P}_0 = 10^{-6}\mathbf{I} + (\mathbf{x}^{\text{real}}_0-\mathbf{x}^{kf}_0)^2. The filter is told, correctly, “I might be off by this much”, which for a particle cloud means the true state sits about one standard deviation from the sampled mean at every point on the sweep, always recoverable in principle. Whether it is recovered in practice is the experiment.

Figs. 10 and 11, page 21, split the two filters onto separate charts, because their axes don’t share a scale: the EKF’s climbs to 10410^410810^8 while the PF’s stays at 10410^{-4}10210^{-2}, four to ten orders of magnitude apart. My own re-run (widget defaults, GPF with jitter, Ns=2000N_s=2000, 50 runs a point: the plan’s honest budget for a browser sweep, half the report’s Ns=4000N_s=4000 and a twentieth of its 1000 runs) lands on the same shape and very nearly the same scale:

Axis (offset at full throw)EKF MSE at offset 0EKF MSE at full offsetPF MSE at full offset
x0x_0 (xx, ±15\pm15)5.6×1045.6\times10^{-4}5.9×1045.9\times10^46.6×1046.6\times10^{-4}
x1x_1 (x˙\dot x, ±8\pm8)6.0×1046.0\times10^{-4}1.1×1061.1\times10^6see below
x2x_2 (yy, ±15\pm15)7.1×1047.1\times10^{-4}1.3×1051.3\times10^54.7×1044.7\times10^{-4}
x3x_3 (y˙\dot y, ±8\pm8)5.1×1045.1\times10^{-4}3.0×1083.0\times10^83.3×1023.3\times10^{-2}

The particle filter earns its reputation on three of those four columns, barely moving while the EKF detonates. The fourth, x˙\dot x, is the honest exception, and it is worth showing rather than hiding.

Why: the report’s own explanation

§7’s closing paragraphs, rephrased: the further the initial guess sits from the truth, the faster the true object would have to be moving to produce the same sequence of bearings, so distance from the guess, along a velocity axis especially, doesn’t just add error, it manufactures alternative stories for the data to tell. That is literally the source of the bimodality post 19 built its whole argument on; Experiment 5 is the same phenomenon shown as a dial rather than a snapshot. And the flip side, worth remembering before the next section: an object that is close and moving fast has few alternative explanations: it’s already near the edge of what the sensor can distinguish, so the EKF does comparatively better the closer and faster the target is. Distance from truth is what buys the particle filter its advantage, not multi-modality as some intrinsic property of bearings-only tracking.

Question three (a bonus): what if you add a range measurement?

Experiment 7 changes the problem, not the initial guess. Dataset C (Problems.py::TestProblem4, lines 195–222) keeps the bearing but adds a second measurement, the squared range:

class TestProblem4(ProblemType):
    def __init__(self, r=0.005**2, q=0.001**2):
        self.n,self.m = 4,2
        ...
    def h(self, x,k):
        return np.array([np.nan_to_num(np.arctan2(x[2],x[0]))
        ,x[2]**2+x[0]**2])
    ...
    def Jh(self, x,k):
        x2py2=np.nan_to_num(1.0/(x[0]**2+x[2]**2))
        return np.array([[-x[2]*x2py2, 0, x[0]*x2py2, 0],
                         [2*x[0], 0, 2*x[2], 0]])

Squared range, not range, which is why the Jacobian’s second row is the clean [2x0,0,2x2,0][2x_0,0,2x_2,0] rather than needing a square root anywhere. Experement_7.py sets R=diag(0.0052,0.0001)\mathbf{R}=\operatorname{diag}(0.005^2,0.0001), keeps Ns=4000N_s=4000, and runs all eight algorithms 1000 times. Table 3, page 22:

AlgorithmEKFIEKFSISSISwJGPFGPFwJSIRSIRwJ
Mean “RMSE” (×103\times10^{-3})5.430.310.891.380.140.230.160.44

The point isn’t the table, though: it’s what happened to the EKF compared with dataset B. Bearing-only, its mean MSE on this same starting point is 6.26.2 (my re-run; the report’s own dataset-B baseline, §6.3, is 6.526.52, see post 19). Add the range measurement and it drops to 3.9×1033.9\times10^{-3}, better than a thousand-fold. The particle filter improves too (mean MSE roughly 7×1037×1047\times10^{-3}\to7\times10^{-4} in my re-run, about 10×10\times), but nowhere near as dramatically, because it didn’t need saving in the first place. The moral the plan for this post insists on, and I now believe: it is not that particle filters beat Kalman filters. It’s that a Gaussian can’t describe a multi-modal posterior, and a range measurement is one way to stop the posterior being multi-modal at all. Once it isn’t, the EKF is not just “usable again”: it goes back to being the better tool, because it is cheaper.

Why: cost

§7’s last real paragraph, rephrased, because it is the sentence I’d underline if I were reading this report cold: on a simple posterior, use the Kalman filter, because it is faster: its most expensive step is one matrix inversion (or a closed form, for small state dimensions), while a particle filter’s cost is NsN_s times the per-particle work, run for every one of those particles. The saving grace is that per-particle work is embarrassingly parallel (a GPU or a many-core machine amortises it in a way a single matrix inversion cannot be split) and that particle filters spend their computation where the posterior actually has mass, which for a genuinely multi-modal problem, no fixed Gaussian ever can.

The crate: pf-wasm

The sweep above is real Monte Carlo, not a stored table: pick a point on an axis, and the browser draws 20–50 independent noisy datasets, runs an EKF and a full particle-filter cloud over 24 steps each, and repeats it across up to 40 points. At the top of that range (the offset sweep at Ns=2000N_s=2000, 50 runs, 40 points) that’s on the order of 2000×4×24×50×403.8×1082000\times4\times24\times50\times40 \approx3.8\times10^8 particle-state touches, not counting the per-particle weighing and resampling passes. Plain JS handles this fine at post 19’s scale (a few thousand particles at 60 fps); asked to do it 40 times over inside a progress bar, it is the difference between “a few seconds” and “the better part of a minute”, which is exactly the gap wasm/crates/pf-wasm/ exists to narrow.

The crate is self-contained (calib-wasm is the camera-calibration crate, gp-wasm the Gaussian-process one, nothing existing fit), follows example-wasm’s C-ABI convention (wasm_alloc/wasm_free verbatim, crate-type = ["cdylib"]), and has no dependencies, matching every other crate in this workspace: rng.rs (a bit-exact Rust port of the site’s world-sim seeded PRNG, sfc32 through splitmix32, verified against five next() and five normal() values recorded from the TypeScript class at seed 42), model.rs (dataset B/C, Problems.py::TestProblem3/TestProblem4), ekf.rs (ExtendedKalmanFilter.py, ported faithfully, including a bug I found while doing it, below), pf.rs (ParticalFilter.py’s SIS/GPF/SIR, log-domain weights, systematic resampling and the corrected jitter formula post 19 already established), and sweep.rs, which runs one point of the chart and reports mean/median/LQ/UQ for both filters. One exported function, sweep_point, does the whole inner loop per call so a Web Worker can call it once per sweep point and postMessage the result, which is how the chart streams in.

cargo test -p pf-wasm has seven cases, chosen to catch exactly the classes of bug the sibling posts have found in this codebase: the PRNG matches the TypeScript class bit-for-bit at a known seed, not just itself across two instances; systematic resampling preserves the particle count and concentrates on a single heavy particle (I rig one particle to hold nearly all the weight and check it survives resampling at better than 90%); log-sum-exp agrees with the naive computation on well-scaled values and stays finite where the naive one underflows to zero (the whole reason scaleLogW exists, see post 19); and a full sweep point is bit-for-bit deterministic given a seed, run twice.

#[test]
fn log_sum_exp_matches_naive_when_naive_would_underflow() {
    let mut logw: Vec<f64> = vec![-800.0, -800.5, -799.2, -805.0];
    let naive: Vec<f64> = logw.iter().map(|v| v.exp()).collect();
    assert!(naive.iter().all(|&v| v == 0.0), "test setup: these should underflow");
    scale_log_w(&mut logw);
    let sum: f64 = logw.iter().map(|v| v.exp()).sum();
    assert!((sum - 1.0).abs() < 1e-9, "normalised weights should sum to 1, got {sum}");
}

wasm/crates/pf-wasm/src/pf.rs, trimmed. All seven tests pass.

Performance, measured rather than assumed

pnpm build:wasm pf-wasm produces a 40.9 kB binary, comfortably inside the workspace’s ~200 kB-per-crate guideline, and about as small as a crate with a PRNG, a 4-D EKF and a full particle cloud gets. The plan for this post predicted a 3–10× speedup over plain JS on the resampling and log-pdf inner loop. I measured it, rather than assumed it: a from-scratch JS port of the identical algorithm (same RNG, same model, same log-sum-exp, same systematic resampling), timed against the WASM module on the same 40-point sweep, in Node rather than a browser to remove rendering from the comparison:

SweepWASMPlain JSSpeedup
NsN_s sweep, 100→10 000, runs = 2029.9 s49.4 s1.65×
Offset sweep, Ns=2000N_s=2000, runs = 5028.8 s47.7 s1.66×

I did not need the cheap fallback (a precomputed sweep shipped as JSON, scrubbed rather than run): the live version comes in under a minute at the plan’s own honest budget, on both implementations, so the browser really does Monte Carlo it from a button press.

What I’d say now

§8’s conclusion, and I’d sign every sentence of it: the Kalman filter, in its extended form, is a genuinely useful numerical method that assumes the posterior is Gaussian, an assumption this whole series has spent four posts demolishing under the right conditions and vindicating under others. The particle filter approximates the posterior with samples instead, which works extremely well precisely where the Gaussian assumption fails, at the cost of more computation and no guarantee that any particle ever lands somewhere useful.

What I’d add, after building the sweep rather than reading about it: “how many particles” and “how wrong can the guess be” are not two separate questions with two separate answers. They’re the same question: how much does the true state actually cost to find, given this sensor, asked along two different axes. A cheap, well-observed problem (dataset A, or dataset C once the range measurement kills the ambiguity) needs neither many particles nor a good guess: the Kalman filter’s single Gaussian finds it in one matrix inversion. An expensive, badly-observed one (dataset B, especially along a velocity axis the sensor barely sees) needs both, and even then, as this post’s own x˙\dot x sweep showed, more particles buys you a lower probability of the cloud locking onto the wrong story, never a guarantee. The report’s title for its final section could as well have been the title of this whole series: match the filter, and its budget, to the actual shape of what you don’t know.