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:

  1. Multinomial - Standard random resampling

  2. Systematic - Evenly spaced samples on CDF

  3. Stratified - Stratified random sampling

  4. 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