Particle Filters
This example demonstrates bootstrap particle filters with various resampling methods.
Overview
Particle filters (Sequential Monte Carlo) handle:
Nonlinear dynamics - Arbitrary state transition functions
Non-Gaussian noise - Any noise distribution
Multi-modal posteriors - Multiple hypotheses
Key Concepts
Importance sampling: Weighting particles by likelihood
Resampling: Eliminating low-weight particles
Effective sample size: Measuring particle degeneracy
Roughening: Preventing sample impoverishment
Resampling Methods
The example compares different resampling strategies:
Multinomial - Standard random resampling
Systematic - Evenly spaced samples on CDF
Stratified - Stratified random sampling
Residual - Deterministic + random resampling
Code Highlights
The example demonstrates:
Bootstrap particle filter initialization
Weight computation from likelihoods
Different resampling implementations
Effective sample size monitoring
State estimation from weighted particles
Source Code
1"""
2Particle Filters Example.
3
4This example demonstrates particle filtering (Sequential Monte Carlo)
5algorithms in PyTCL:
6
7- Bootstrap particle filter
8- Importance sampling and resampling
9- Different resampling strategies (multinomial, systematic, residual)
10- Effective sample size monitoring
11- Particle statistics computation
12- Comparison with Kalman filter for linear systems
13- Nonlinear system tracking
14
15Particle filters are essential for nonlinear, non-Gaussian state estimation
16where Kalman filters cannot be directly applied.
17
18Run with: python examples/particle_filters.py
19"""
20
21import sys
22from pathlib import Path
23
24sys.path.insert(0, str(Path(__file__).parent.parent))
25
26# Output directory for generated plots
27OUTPUT_DIR = Path(__file__).parent.parent / "docs" / "_static" / "images" / "examples"
28OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
29
30# Global flag to control plotting
31SHOW_PLOTS = True
32
33import numpy as np # noqa: E402
34import plotly.graph_objects as go # noqa: E402
35from plotly.subplots import make_subplots # noqa: E402
36
37from pytcl.dynamic_estimation.kalman.linear import kf_predict, kf_update # noqa: E402
38from pytcl.dynamic_estimation.particle_filters import ( # noqa: E402
39 ParticleState,
40 bootstrap_pf_predict,
41 bootstrap_pf_step,
42 bootstrap_pf_update,
43 effective_sample_size,
44 gaussian_likelihood,
45 initialize_particles,
46 particle_covariance,
47 particle_mean,
48 resample_multinomial,
49 resample_residual,
50 resample_systematic,
51)
52
53
54def _rng(seed: int = 42) -> np.random.Generator:
55 """A seeded Generator for the library's resampling calls.
56
57 ``np.random.seed`` seeds the legacy global RNG. The particle-filter
58 functions default to ``np.random.default_rng()``, which is a separate
59 stream and ignores it -- so without passing this explicitly the figures
60 under docs/_static change on every rebuild.
61 """
62 return np.random.default_rng(seed)
63
64
65def demo_particle_basics():
66 """Demonstrate basic particle filter operations."""
67 print("=" * 70)
68 print("Particle Filter Basics Demo")
69 print("=" * 70)
70
71 np.random.seed(42)
72
73 # Initialize particles for a 2D state [x, y]
74 n_particles = 1000
75 state_dim = 2
76
77 # Initial distribution: Gaussian centered at origin
78 mean = np.array([0.0, 0.0])
79 cov = np.eye(2) * 2.0
80
81 # initialize_particles returns a ParticleState object
82 state = initialize_particles(mean, cov, n_particles, rng=_rng())
83 particles = state.particles
84 weights = state.weights
85
86 print(f"\nInitialized {n_particles} particles")
87 print(f"State dimension: {state_dim}")
88 print(f"Initial mean: {particle_mean(particles, weights)}")
89 print(f"Initial std: {np.sqrt(np.diag(particle_covariance(particles, weights)))}")
90
91 # Effective sample size
92 ess = effective_sample_size(weights)
93 print(f"Initial ESS: {ess:.1f} (should be ~{n_particles})")
94
95 # Demonstrate weight degeneracy
96 print("\n--- Weight Degeneracy Example ---")
97 # Create skewed weights
98 skewed_weights = np.ones(n_particles)
99 skewed_weights[0] = 100.0 # One dominant particle
100 skewed_weights /= skewed_weights.sum()
101
102 ess_skewed = effective_sample_size(skewed_weights)
103 print(f"With one dominant particle, ESS: {ess_skewed:.1f}")
104 print("This indicates severe weight degeneracy - resampling needed!")
105
106
107def demo_resampling_methods():
108 """Demonstrate different resampling strategies."""
109 print("\n" + "=" * 70)
110 print("Resampling Methods Demo")
111 print("=" * 70)
112
113 np.random.seed(42)
114
115 n_particles = 1000
116
117 # Create particles with non-uniform weights
118 particles = np.random.randn(n_particles, 2)
119 weights = np.exp(-np.sum(particles**2, axis=1) / 4) # Higher near origin
120 weights /= weights.sum()
121
122 print(f"\nOriginal particle distribution:")
123 print(f" Mean: {particle_mean(particles, weights)}")
124 print(f" ESS: {effective_sample_size(weights):.1f}")
125
126 # Multinomial resampling - returns resampled particles directly
127 particles_multi = resample_multinomial(particles, weights, rng=_rng())
128 weights_multi = np.ones(n_particles) / n_particles
129
130 print("\n--- Multinomial Resampling ---")
131 print(f" Mean: {particle_mean(particles_multi, weights_multi)}")
132 print(f" ESS: {effective_sample_size(weights_multi):.1f}")
133
134 # Systematic resampling (lower variance)
135 particles_sys = resample_systematic(particles, weights, rng=_rng())
136 weights_sys = np.ones(n_particles) / n_particles
137
138 print("\n--- Systematic Resampling ---")
139 print(f" Mean: {particle_mean(particles_sys, weights_sys)}")
140 print(f" ESS: {effective_sample_size(weights_sys):.1f}")
141
142 # Residual resampling
143 particles_res = resample_residual(particles, weights, rng=_rng())
144 weights_res = np.ones(n_particles) / n_particles
145
146 print("\n--- Residual Resampling ---")
147 print(f" Mean: {particle_mean(particles_res, weights_res)}")
148 print(f" ESS: {effective_sample_size(weights_res):.1f}")
149
150 print("\nNote: Systematic resampling typically preserves more diversity")
151 print("and has lower variance than multinomial resampling.")
152
153 # Plot resampling comparison
154 if SHOW_PLOTS:
155 fig = make_subplots(
156 rows=2,
157 cols=2,
158 subplot_titles=(
159 f"Original Particles (ESS={effective_sample_size(weights):.0f})",
160 "After Multinomial Resampling",
161 "After Systematic Resampling",
162 "After Residual Resampling",
163 ),
164 )
165
166 # Original particles with weights
167 fig.add_trace(
168 go.Scatter(
169 x=particles[:, 0],
170 y=particles[:, 1],
171 mode="markers",
172 marker=dict(size=5, color=weights, colorscale="Viridis", opacity=0.6),
173 name="Original",
174 ),
175 row=1,
176 col=1,
177 )
178
179 # Multinomial resampling
180 fig.add_trace(
181 go.Scatter(
182 x=particles_multi[:, 0],
183 y=particles_multi[:, 1],
184 mode="markers",
185 marker=dict(size=5, color="blue", opacity=0.6),
186 name="Multinomial",
187 ),
188 row=1,
189 col=2,
190 )
191
192 # Systematic resampling
193 fig.add_trace(
194 go.Scatter(
195 x=particles_sys[:, 0],
196 y=particles_sys[:, 1],
197 mode="markers",
198 marker=dict(size=5, color="green", opacity=0.6),
199 name="Systematic",
200 ),
201 row=2,
202 col=1,
203 )
204
205 # Residual resampling
206 fig.add_trace(
207 go.Scatter(
208 x=particles_res[:, 0],
209 y=particles_res[:, 1],
210 mode="markers",
211 marker=dict(size=5, color="red", opacity=0.6),
212 name="Residual",
213 ),
214 row=2,
215 col=2,
216 )
217
218 fig.update_layout(
219 height=800,
220 width=1000,
221 title_text="Comparison of Resampling Methods",
222 showlegend=False,
223 )
224 fig.update_xaxes(title_text="x")
225 fig.update_yaxes(title_text="y")
226
227 fig.write_html(
228 str(OUTPUT_DIR / "particle_resampling_comparison.html"),
229 include_plotlyjs="cdn",
230 div_id="particle_resampling_comparison",
231 )
232 print("\n [Plot saved to particle_resampling_comparison.html]")
233
234
235def demo_linear_tracking():
236 """Compare particle filter to Kalman filter for linear system."""
237 rng = _rng()
238 print("\n" + "=" * 70)
239 print("Linear System Tracking Demo")
240 print("=" * 70)
241
242 np.random.seed(42)
243
244 # Linear constant-velocity model
245 dt = 1.0
246 F = np.array(
247 [
248 [1, dt, 0, 0],
249 [0, 1, 0, 0],
250 [0, 0, 1, dt],
251 [0, 0, 0, 1],
252 ]
253 )
254
255 # Process noise
256 q = 0.1
257 Q = q * np.array(
258 [
259 [dt**3 / 3, dt**2 / 2, 0, 0],
260 [dt**2 / 2, dt, 0, 0],
261 [0, 0, dt**3 / 3, dt**2 / 2],
262 [0, 0, dt**2 / 2, dt],
263 ]
264 )
265
266 # Measurement model (observe position only)
267 H = np.array(
268 [
269 [1, 0, 0, 0],
270 [0, 0, 1, 0],
271 ]
272 )
273 R = np.eye(2) * 1.0
274
275 # True trajectory
276 n_steps = 20
277 true_states = np.zeros((n_steps, 4))
278 true_states[0] = [0, 1, 0, 0.5] # Start at origin, moving diagonally
279
280 for k in range(1, n_steps):
281 true_states[k] = F @ true_states[k - 1] + np.random.multivariate_normal(
282 np.zeros(4), Q * 0.1
283 )
284
285 # Generate measurements
286 measurements = [
287 H @ true_states[k] + np.random.multivariate_normal(np.zeros(2), R)
288 for k in range(n_steps)
289 ]
290
291 print(f"\nSimulating {n_steps} time steps")
292 print("True initial state: [x=0, vx=1, y=0, vy=0.5]")
293
294 # Kalman filter
295 x_kf = np.array([0.0, 0.0, 0.0, 0.0])
296 P_kf = np.eye(4) * 10.0
297 kf_estimates = []
298
299 for z in measurements:
300 pred = kf_predict(x_kf, P_kf, F, Q)
301 upd = kf_update(pred.x, pred.P, z, H, R)
302 x_kf, P_kf = upd.x, upd.P
303 kf_estimates.append(x_kf.copy())
304
305 # Particle filter
306 n_particles = 500
307 state = initialize_particles(np.zeros(4), np.eye(4) * 10.0, n_particles, rng=rng)
308 particles = state.particles
309 weights = state.weights.copy()
310 pf_estimates = []
311
312 def process_fn(x):
313 return F @ x + np.random.multivariate_normal(np.zeros(4), Q)
314
315 def likelihood_fn(z, x):
316 z_pred = H @ x
317 return gaussian_likelihood(z, z_pred, R)
318
319 for z in measurements:
320 # Predict
321 particles = np.array([process_fn(p) for p in particles])
322
323 # Update weights
324 likelihoods = np.array([likelihood_fn(z, p) for p in particles])
325 weights = weights * likelihoods
326 weights /= weights.sum()
327
328 # Estimate
329 pf_estimates.append(particle_mean(particles, weights))
330
331 # Resample if needed
332 ess = effective_sample_size(weights)
333 if ess < n_particles / 2:
334 particles = resample_systematic(particles, weights, rng=rng)
335 weights = np.ones(n_particles) / n_particles
336
337 # Compare RMSE
338 kf_estimates = np.array(kf_estimates)
339 pf_estimates = np.array(pf_estimates)
340
341 kf_rmse = np.sqrt(np.mean((kf_estimates[:, [0, 2]] - true_states[:, [0, 2]]) ** 2))
342 pf_rmse = np.sqrt(np.mean((pf_estimates[:, [0, 2]] - true_states[:, [0, 2]]) ** 2))
343
344 print("\n--- Filter Comparison (Position RMSE) ---")
345 print(f" Kalman Filter: {kf_rmse:.4f}")
346 print(f" Particle Filter ({n_particles} particles): {pf_rmse:.4f}")
347 print("\nNote: For linear Gaussian systems, KF is optimal.")
348 print("PF approaches KF performance as particle count increases.")
349
350 # Plot tracking comparison
351 if SHOW_PLOTS:
352 fig = make_subplots(
353 rows=1,
354 cols=2,
355 subplot_titles=(
356 "Trajectory Tracking: KF vs PF",
357 "Position Error Over Time",
358 ),
359 )
360
361 measurements_arr = np.array(measurements)
362
363 # Trajectory plot
364 fig.add_trace(
365 go.Scatter(
366 x=true_states[:, 0],
367 y=true_states[:, 2],
368 mode="lines",
369 name="True trajectory",
370 line=dict(color="black", width=2),
371 ),
372 row=1,
373 col=1,
374 )
375 fig.add_trace(
376 go.Scatter(
377 x=kf_estimates[:, 0],
378 y=kf_estimates[:, 2],
379 mode="lines",
380 name=f"Kalman Filter (RMSE={kf_rmse:.3f})",
381 line=dict(color="blue", width=1.5, dash="dash"),
382 ),
383 row=1,
384 col=1,
385 )
386 fig.add_trace(
387 go.Scatter(
388 x=pf_estimates[:, 0],
389 y=pf_estimates[:, 2],
390 mode="lines",
391 name=f"Particle Filter (RMSE={pf_rmse:.3f})",
392 line=dict(color="red", width=1.5, dash="dot"),
393 ),
394 row=1,
395 col=1,
396 )
397 fig.add_trace(
398 go.Scatter(
399 x=measurements_arr[:, 0],
400 y=measurements_arr[:, 1],
401 mode="markers",
402 name="Measurements",
403 marker=dict(size=5, color="gray", opacity=0.5),
404 ),
405 row=1,
406 col=1,
407 )
408
409 # Error comparison
410 time = np.arange(n_steps)
411 kf_pos_err = np.sqrt(
412 (kf_estimates[:, 0] - true_states[:, 0]) ** 2
413 + (kf_estimates[:, 2] - true_states[:, 2]) ** 2
414 )
415 pf_pos_err = np.sqrt(
416 (pf_estimates[:, 0] - true_states[:, 0]) ** 2
417 + (pf_estimates[:, 2] - true_states[:, 2]) ** 2
418 )
419 fig.add_trace(
420 go.Scatter(
421 x=time,
422 y=kf_pos_err,
423 mode="lines",
424 name="Kalman Filter",
425 line=dict(color="blue"),
426 ),
427 row=1,
428 col=2,
429 )
430 fig.add_trace(
431 go.Scatter(
432 x=time,
433 y=pf_pos_err,
434 mode="lines",
435 name="Particle Filter",
436 line=dict(color="red"),
437 ),
438 row=1,
439 col=2,
440 )
441
442 fig.update_xaxes(title_text="x position", row=1, col=1)
443 fig.update_yaxes(title_text="y position", row=1, col=1)
444 fig.update_xaxes(title_text="Time step", row=1, col=2)
445 fig.update_yaxes(title_text="Position error", row=1, col=2)
446
447 fig.update_layout(height=500, width=1200)
448 fig.write_html(
449 str(OUTPUT_DIR / "particle_linear_tracking.html"),
450 include_plotlyjs="cdn",
451 div_id="particle_linear_tracking",
452 )
453 print("\n [Plot saved to particle_linear_tracking.html]")
454
455
456def demo_nonlinear_tracking():
457 """Demonstrate particle filter for nonlinear system."""
458 rng = _rng()
459 print("\n" + "=" * 70)
460 print("Nonlinear System Tracking Demo")
461 print("=" * 70)
462
463 np.random.seed(42)
464
465 # Nonlinear dynamics: polar to Cartesian (range-bearing sensor)
466 # State: [x, y, vx, vy]
467 # Measurement: [range, bearing] (nonlinear!)
468
469 dt = 0.1
470 n_steps = 50
471 n_particles = 1000
472
473 # True trajectory: circular motion
474 omega = 0.5 # angular velocity
475 radius = 10.0
476 true_states = np.zeros((n_steps, 4))
477
478 for k in range(n_steps):
479 t = k * dt
480 true_states[k] = [
481 radius * np.cos(omega * t),
482 radius * np.sin(omega * t),
483 -radius * omega * np.sin(omega * t),
484 radius * omega * np.cos(omega * t),
485 ]
486
487 # Measurement noise
488 sigma_range = 0.5
489 sigma_bearing = np.radians(2.0)
490
491 def measurement_model(state):
492 """Nonlinear measurement: range and bearing from origin."""
493 x, y = state[0], state[1]
494 r = np.sqrt(x**2 + y**2)
495 theta = np.arctan2(y, x)
496 return np.array([r, theta])
497
498 # Generate measurements
499 measurements = []
500 for k in range(n_steps):
501 z_true = measurement_model(true_states[k])
502 noise = np.array(
503 [np.random.randn() * sigma_range, np.random.randn() * sigma_bearing]
504 )
505 measurements.append(z_true + noise)
506
507 print(f"\nSimulating circular motion with range-bearing sensor")
508 print(f" Radius: {radius} m, Angular velocity: {omega} rad/s")
509 print(
510 f" Measurement noise: sigma_r={sigma_range} m, "
511 f"sigma_theta={np.degrees(sigma_bearing):.1f} deg"
512 )
513
514 # Initialize particle filter
515 state = initialize_particles(
516 np.array([radius, 0.0, 0.0, radius * omega]), # Near true initial
517 np.diag([1.0, 1.0, 0.5, 0.5]),
518 n_particles,
519 rng=rng,
520 )
521 particles = state.particles
522 weights = state.weights.copy()
523
524 R = np.diag([sigma_range**2, sigma_bearing**2])
525
526 def process_fn(state):
527 """Constant velocity motion model with process noise for maneuvering."""
528 x, y, vx, vy = state
529 # Higher process noise to account for maneuvering (circular motion)
530 q_pos = 0.05 # Position noise
531 q_vel = 2.0 # Velocity noise (high to adapt to turning)
532 return np.array(
533 [
534 x + vx * dt + np.random.randn() * q_pos,
535 y + vy * dt + np.random.randn() * q_pos,
536 vx + np.random.randn() * q_vel * dt,
537 vy + np.random.randn() * q_vel * dt,
538 ]
539 )
540
541 # Run particle filter
542 pf_estimates = []
543 ess_history = []
544
545 for k, z in enumerate(measurements):
546 # Predict
547 particles = np.array([process_fn(p) for p in particles])
548
549 # Update weights using range-bearing likelihood
550 for i in range(n_particles):
551 z_pred = measurement_model(particles[i])
552 # Handle angle wraparound for bearing
553 z_wrapped = z.copy()
554 z_pred_wrapped = z_pred.copy()
555 # Normalize bearing difference
556 bearing_diff = np.arctan2(
557 np.sin(z[1] - z_pred[1]), np.cos(z[1] - z_pred[1])
558 )
559 z_wrapped[1] = z_pred[1] + bearing_diff
560 likelihood = gaussian_likelihood(z_wrapped, z_pred_wrapped, R)
561 weights[i] *= likelihood
562
563 # Normalize
564 if weights.sum() > 0:
565 weights /= weights.sum()
566 else:
567 weights = np.ones(n_particles) / n_particles
568
569 # Estimate
570 pf_estimates.append(particle_mean(particles, weights))
571 ess_history.append(effective_sample_size(weights))
572
573 # Resample
574 if ess_history[-1] < n_particles / 2:
575 particles = resample_systematic(particles, weights, rng=rng)
576 weights = np.ones(n_particles) / n_particles
577
578 pf_estimates = np.array(pf_estimates)
579
580 # Compute errors
581 pos_errors = np.sqrt(
582 (pf_estimates[:, 0] - true_states[:, 0]) ** 2
583 + (pf_estimates[:, 1] - true_states[:, 1]) ** 2
584 )
585
586 print("\n--- Tracking Results ---")
587 print(f" Mean position error: {np.mean(pos_errors):.3f} m")
588 print(f" Max position error: {np.max(pos_errors):.3f} m")
589 print(f" Min ESS: {np.min(ess_history):.1f}")
590 print(f" Mean ESS: {np.mean(ess_history):.1f}")
591
592 # Show trajectory snapshots
593 print("\n--- Trajectory Snapshots ---")
594 times = [0, n_steps // 4, n_steps // 2, 3 * n_steps // 4, n_steps - 1]
595 for t in times:
596 true_pos = true_states[t, :2]
597 est_pos = pf_estimates[t, :2]
598 err = pos_errors[t]
599 print(
600 f" t={t * dt:.1f}s: True=({true_pos[0]:.2f}, {true_pos[1]:.2f}), "
601 f"Est=({est_pos[0]:.2f}, {est_pos[1]:.2f}), Err={err:.3f}m"
602 )
603
604 # Plot nonlinear tracking results
605 if SHOW_PLOTS:
606 fig = make_subplots(
607 rows=2,
608 cols=2,
609 subplot_titles=(
610 "Circular Motion Tracking with Range-Bearing Sensor",
611 "Position Error Over Time",
612 "ESS History (resampling when ESS < N/2)",
613 "Range-Bearing Measurements (color=time)",
614 ),
615 )
616
617 # Trajectory plot
618 fig.add_trace(
619 go.Scatter(
620 x=true_states[:, 0],
621 y=true_states[:, 1],
622 mode="lines",
623 name="True trajectory",
624 line=dict(color="black", width=2),
625 ),
626 row=1,
627 col=1,
628 )
629 fig.add_trace(
630 go.Scatter(
631 x=pf_estimates[:, 0],
632 y=pf_estimates[:, 1],
633 mode="lines",
634 name="PF estimate",
635 line=dict(color="red", width=1.5, dash="dash"),
636 ),
637 row=1,
638 col=1,
639 )
640 fig.add_trace(
641 go.Scatter(
642 x=[true_states[0, 0]],
643 y=[true_states[0, 1]],
644 mode="markers",
645 name="Start",
646 marker=dict(size=15, color="green", symbol="circle"),
647 ),
648 row=1,
649 col=1,
650 )
651 fig.add_trace(
652 go.Scatter(
653 x=[true_states[-1, 0]],
654 y=[true_states[-1, 1]],
655 mode="markers",
656 name="End",
657 marker=dict(size=15, color="blue", symbol="square"),
658 ),
659 row=1,
660 col=1,
661 )
662
663 # Position error over time
664 time_axis = np.arange(n_steps) * dt
665 fig.add_trace(
666 go.Scatter(
667 x=time_axis,
668 y=pos_errors,
669 mode="lines",
670 line=dict(color="blue", width=1.5),
671 ),
672 row=1,
673 col=2,
674 )
675 fig.add_hline(
676 y=np.mean(pos_errors),
677 line_dash="dash",
678 line_color="red",
679 annotation_text=f"Mean={np.mean(pos_errors):.3f}",
680 row=1,
681 col=2,
682 )
683
684 # ESS history
685 fig.add_trace(
686 go.Scatter(
687 x=time_axis,
688 y=ess_history,
689 mode="lines",
690 line=dict(color="green", width=1.5),
691 ),
692 row=2,
693 col=1,
694 )
695 fig.add_hline(
696 y=n_particles / 2,
697 line_dash="dash",
698 line_color="red",
699 annotation_text="Resampling threshold",
700 row=2,
701 col=1,
702 )
703
704 # Measurements in polar form
705 meas_arr = np.array(measurements)
706 fig.add_trace(
707 go.Scatter(
708 x=np.degrees(meas_arr[:, 1]),
709 y=meas_arr[:, 0],
710 mode="markers",
711 marker=dict(
712 size=5,
713 color=time_axis,
714 colorscale="Viridis",
715 colorbar=dict(title="Time (s)", x=1.0),
716 ),
717 ),
718 row=2,
719 col=2,
720 )
721
722 fig.update_xaxes(title_text="x position (m)", row=1, col=1)
723 fig.update_yaxes(title_text="y position (m)", row=1, col=1)
724 fig.update_xaxes(title_text="Time (s)", row=1, col=2)
725 fig.update_yaxes(title_text="Position error (m)", row=1, col=2)
726 fig.update_xaxes(title_text="Time (s)", row=2, col=1)
727 fig.update_yaxes(title_text="Effective Sample Size", row=2, col=1)
728 fig.update_xaxes(title_text="Bearing (degrees)", row=2, col=2)
729 fig.update_yaxes(title_text="Range (m)", row=2, col=2)
730
731 fig.update_layout(height=800, width=1000, showlegend=True)
732 fig.write_html(
733 str(OUTPUT_DIR / "particle_nonlinear_tracking.html"),
734 include_plotlyjs="cdn",
735 div_id="particle_nonlinear_tracking",
736 )
737 print("\n [Plot saved to particle_nonlinear_tracking.html]")
738
739
740def demo_multimodal():
741 """Demonstrate particle filter advantage for multimodal distributions."""
742 print("\n" + "=" * 70)
743 print("Multimodal Distribution Demo")
744 print("=" * 70)
745
746 np.random.seed(42)
747
748 # Scenario: Target could be at one of two locations
749 # This is impossible for a Kalman filter but natural for particle filters
750
751 n_particles = 2000
752
753 # Prior: mixture of two Gaussians
754 mode1 = np.array([5.0, 0.0])
755 mode2 = np.array([-5.0, 0.0])
756 cov = np.eye(2) * 0.5
757
758 # Initialize with bimodal distribution
759 state1 = initialize_particles(mode1, cov, n_particles // 2, rng=_rng())
760 state2 = initialize_particles(mode2, cov, n_particles // 2, rng=_rng(43))
761 particles = np.vstack([state1.particles, state2.particles])
762 weights = np.ones(n_particles) / n_particles
763
764 print("\nBimodal prior distribution:")
765 print(f" Mode 1: {mode1}")
766 print(f" Mode 2: {mode2}")
767 print(f" Mean: {particle_mean(particles, weights)}")
768 print(" (Mean is between modes - not representative!)")
769
770 # Measurement that confirms mode 2
771 z = np.array([-4.8, 0.1])
772 R = np.eye(2) * 0.2
773
774 print(f"\nMeasurement received: {z}")
775
776 # Save prior particles for plotting
777 prior_particles = particles.copy()
778
779 # Update weights
780 for i in range(n_particles):
781 z_pred = particles[i] # Direct position observation
782 weights[i] *= gaussian_likelihood(z, z_pred, R)
783 weights /= weights.sum()
784
785 # After update
786 print("\nAfter measurement update:")
787 print(f" Mean: {particle_mean(particles, weights)}")
788 print(f" ESS: {effective_sample_size(weights):.1f}")
789
790 # Analyze particle distribution
791 near_mode1 = np.sum(particles[:, 0] > 0)
792 near_mode2 = np.sum(particles[:, 0] < 0)
793 weight_mode1 = np.sum(weights[particles[:, 0] > 0])
794 weight_mode2 = np.sum(weights[particles[:, 0] < 0])
795
796 print(f"\n Particles near mode 1: {near_mode1} (weight: {weight_mode1:.4f})")
797 print(f" Particles near mode 2: {near_mode2} (weight: {weight_mode2:.4f})")
798 print("\nNote: PF correctly concentrates probability on mode 2")
799 print("after receiving the confirming measurement.")
800
801 # Plot multimodal distribution
802 if SHOW_PLOTS:
803 fig = make_subplots(
804 rows=1,
805 cols=2,
806 subplot_titles=(
807 "Prior: Bimodal Distribution",
808 "Posterior: After Measurement Update",
809 ),
810 )
811
812 # Prior distribution
813 fig.add_trace(
814 go.Scatter(
815 x=prior_particles[:, 0],
816 y=prior_particles[:, 1],
817 mode="markers",
818 marker=dict(size=3, color="blue", opacity=0.3),
819 name="Prior particles",
820 ),
821 row=1,
822 col=1,
823 )
824 fig.add_trace(
825 go.Scatter(
826 x=[mode1[0], mode2[0]],
827 y=[mode1[1], mode2[1]],
828 mode="markers",
829 marker=dict(size=15, color="green", symbol="x", line=dict(width=3)),
830 name="Modes",
831 ),
832 row=1,
833 col=1,
834 )
835
836 # Posterior distribution
837 fig.add_trace(
838 go.Scatter(
839 x=particles[:, 0],
840 y=particles[:, 1],
841 mode="markers",
842 marker=dict(
843 size=weights * n_particles * 50,
844 color=weights,
845 colorscale="Reds",
846 opacity=0.5,
847 ),
848 name="Posterior particles",
849 ),
850 row=1,
851 col=2,
852 )
853 fig.add_trace(
854 go.Scatter(
855 x=[z[0]],
856 y=[z[1]],
857 mode="markers",
858 marker=dict(size=20, color="blue", symbol="star"),
859 name="Measurement",
860 ),
861 row=1,
862 col=2,
863 )
864
865 fig.update_xaxes(range=[-10, 10], row=1, col=1)
866 fig.update_yaxes(range=[-5, 5], row=1, col=1)
867 fig.update_xaxes(range=[-10, 10], row=1, col=2)
868 fig.update_yaxes(range=[-5, 5], row=1, col=2)
869
870 fig.update_layout(
871 height=500,
872 width=1200,
873 title_text="Particle Filter for Multimodal Distribution (Point size proportional to weight)",
874 )
875 fig.write_html(
876 str(OUTPUT_DIR / "particle_multimodal.html"),
877 include_plotlyjs="cdn",
878 div_id="particle_multimodal",
879 )
880 print("\n [Plot saved to particle_multimodal.html]")
881
882
883def main():
884 """Run all demonstrations."""
885 # Seed once for the whole run. Individual demos below reseed, but
886 # not all of them do, and the figures are committed under
887 # docs/_static -- an unseeded draw makes every rebuild produce a
888 # different file and a spurious diff.
889 np.random.seed(42)
890 print("\n" + "#" * 70)
891 print("# PyTCL Particle Filters Example")
892 print("#" * 70)
893
894 demo_particle_basics()
895 demo_resampling_methods()
896 demo_linear_tracking()
897 demo_nonlinear_tracking()
898 demo_multimodal()
899
900 print("\n" + "=" * 70)
901 print("Example complete!")
902 if SHOW_PLOTS:
903 print("Plots saved: particle_resampling_comparison.html, ")
904 print(" particle_linear_tracking.html,")
905 print(" particle_nonlinear_tracking.html,")
906 print(" particle_multimodal.html")
907 print("=" * 70)
908
909
910if __name__ == "__main__":
911 main()
Running the Example
python examples/particle_filters.py
See Also
Advanced Filters Comparison - Rao-Blackwellized particle filter
Kalman Filter Comparison - Kalman filter alternatives
Multi-Target Tracking - Particle filters for MTT