Advanced Filters Comparison

This example compares advanced filtering techniques for challenging nonlinear problems.

Overview

When standard Kalman filters are insufficient, advanced techniques provide better performance:

  1. Constrained EKF - Enforces state constraints during estimation

  2. Gaussian Sum Filter - Represents multi-modal distributions

  3. Rao-Blackwellized Particle Filter - Combines analytic and Monte Carlo methods

Key Concepts

  • State constraints: Physical bounds on state variables

  • Multi-modality: Distributions with multiple peaks

  • Hybrid filters: Combining different estimation techniques

  • Marginalization: Analytically integrating out linear states

Code Highlights

The example demonstrates:

  • Implementing state constraints in EKF updates

  • Gaussian mixture representation and merging

  • Rao-Blackwellization for linear substructure

  • Performance comparison metrics

Source Code

  1"""Advanced filters comparison demonstration.
  2
  3Demonstrates three advanced filtering techniques:
  41. Constrained Extended Kalman Filter (CEKF): Enforces state constraints
  52. Gaussian Sum Filter (GSF): Models multi-modal posterior distributions
  63. Rao-Blackwellized Particle Filter (RBPF): Combines particles with Kalman filters
  7
  8Scenario: Nonlinear target tracking with constraints on valid state region.
  9"""
 10
 11import os
 12from pathlib import Path
 13
 14import numpy as np
 15import plotly.graph_objects as go
 16from plotly.subplots import make_subplots
 17
 18from pytcl.dynamic_estimation.gaussian_sum_filter import (
 19    GaussianComponent,
 20    GaussianSumFilter,
 21)
 22from pytcl.dynamic_estimation.kalman.constrained import (
 23    ConstrainedEKF,
 24    ConstraintFunction,
 25)
 26from pytcl.dynamic_estimation.rbpf import RBPFFilter
 27
 28SHOW_PLOTS = os.environ.get("PYTCL_SHOW_PLOTS", "1") != "0"
 29OUTPUT_DIR = Path("docs/_static/images/examples")
 30OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
 31
 32
 33class TargetTrackingScenario:
 34    """Nonlinear target tracking scenario.
 35
 36    Target moves in 2D with nonlinear dynamics. Measurements are range and
 37    bearing from a fixed observer.
 38    """
 39
 40    def __init__(self, seed: int = 42):
 41        """Initialize scenario.
 42
 43        Parameters
 44        ----------
 45        seed : int
 46            Random seed for reproducibility
 47        """
 48        np.random.seed(seed)
 49
 50        # State: [x, y, vx, vy] (position and velocity in Cartesian coords)
 51        self.state_dim = 4
 52        self.measurement_dim = 2  # range and bearing
 53
 54        # System matrices
 55        self.dt = 0.1
 56        self.F = np.array(
 57            [
 58                [1, 0, self.dt, 0],
 59                [0, 1, 0, self.dt],
 60                [0, 0, 1, 0],
 61                [0, 0, 0, 1],
 62            ]
 63        )
 64
 65        self.Q = np.diag([0, 0, 0.001, 0.001])  # Process noise
 66
 67        # Measurement observer position
 68        self.observer = np.array([0.0, 0.0])
 69
 70        # Measurement noise, as variances: range in units^2, bearing in rad^2.
 71        # The bearing term used to be 0.01 rad^2, i.e. a 5.7 degree standard
 72        # deviation. At this target's ~14 unit range that is 1.4 units of
 73        # cross-range error on a trajectory only 2.5 units long, so the plotted
 74        # tracks looked like noise even though the filters were behaving. 1e-4
 75        # is a 0.57 degree sensor, which is still modest but legible.
 76        self.R = np.diag([0.01, 1e-4])
 77
 78        # Initial state
 79        self.x0 = np.array([10.0, 10.0, -1.0, -0.5])
 80        self.P0 = np.diag([1.0, 1.0, 0.5, 0.5])
 81
 82    def f(self, x: np.ndarray) -> np.ndarray:
 83        """Nonlinear state transition with friction.
 84
 85        Parameters
 86        ----------
 87        x : ndarray
 88            State vector [x, y, vx, vy]
 89
 90        Returns
 91        -------
 92        ndarray
 93            Next state with velocity friction
 94        """
 95        x_next = self.F @ x
 96        # Add friction to velocity
 97        x_next[2] *= 0.95
 98        x_next[3] *= 0.95
 99        return x_next
100
101    def h(self, x: np.ndarray) -> np.ndarray:
102        """Measurement function: range and bearing.
103
104        Parameters
105        ----------
106        x : ndarray
107            State vector [x, y, vx, vy]
108
109        Returns
110        -------
111        ndarray
112            Measurement [range, bearing]
113        """
114        pos = x[:2]
115        delta = pos - self.observer
116
117        # Range
118        r = np.linalg.norm(delta)
119
120        # Bearing (angle from East)
121        bearing = np.arctan2(delta[1], delta[0])
122
123        return np.array([r, bearing])
124
125    def h_jacobian(self, x: np.ndarray) -> np.ndarray:
126        """Jacobian of measurement function.
127
128        Parameters
129        ----------
130        x : ndarray
131            State vector
132
133        Returns
134        -------
135        ndarray
136            Jacobian dh/dx
137        """
138        pos = x[:2]
139        delta = pos - self.observer
140        r = np.linalg.norm(delta)
141
142        if r < 0.01:
143            # Avoid singularity
144            return np.array(
145                [
146                    [0, 0, 0, 0],
147                    [0, 0, 0, 0],
148                ]
149            )
150
151        H = np.zeros((2, 4))
152
153        # dr/dx = delta[0] / r
154        H[0, 0] = delta[0] / r
155        H[0, 1] = delta[1] / r
156
157        # dbearing/dx = -delta[1] / r^2, dbearing/dy = delta[0] / r^2
158        H[1, 0] = -delta[1] / r**2
159        H[1, 1] = delta[0] / r**2
160
161        return H
162
163    def generate_trajectory(self, steps: int = 50):
164        """Generate synthetic true trajectory and measurements.
165
166        Parameters
167        ----------
168        steps : int
169            Number of time steps
170
171        Returns
172        -------
173        x_true : ndarray (steps, 4)
174            True state trajectory
175        measurements : ndarray (steps, 2)
176            Noisy range/bearing measurements
177        """
178        x_true = np.zeros((steps, 4))
179        measurements = np.zeros((steps, 2))
180
181        x_true[0] = self.x0
182        measurements[0] = self.h(x_true[0]) + np.random.multivariate_normal(
183            np.zeros(2), self.R
184        )
185
186        for k in range(1, steps):
187            # True dynamics
188            x_true[k] = self.f(x_true[k - 1])
189            x_true[k] += np.random.multivariate_normal(np.zeros(4), self.Q)
190
191            # Measurement
192            z_true = self.h(x_true[k])
193            measurements[k] = z_true + np.random.multivariate_normal(
194                np.zeros(2), self.R
195            )
196
197        return x_true, measurements
198
199
200def run_cekf_filter(
201    scenario: TargetTrackingScenario,
202    measurements: np.ndarray,
203) -> tuple[np.ndarray, np.ndarray]:
204    """Run Constrained EKF with position constraint.
205
206    Parameters
207    ----------
208    scenario : TargetTrackingScenario
209        Tracking scenario
210    measurements : ndarray
211        Measurements
212
213    Returns
214    -------
215    x_est : ndarray
216        State estimates
217    P_est : ndarray
218        Covariance estimates
219    """
220    cekf = ConstrainedEKF()
221
222    # Operating-area constraint: the target is known to stay inside a circle
223    # centered at (5, 5).
224    #
225    # The radius has to be chosen against the actual trajectory. It was 10.0,
226    # but the true track never gets further than 7.07 from the center, so the
227    # constraint was inactive at every step and the "constrained" EKF returned
228    # exactly what a plain EKF would -- which is why its curve sat invisibly
229    # underneath the GSF's.
230    #
231    # 7.10 sits just above that 7.07 maximum: the true track stays feasible,
232    # so the constraint is honest, while estimates that wander outward get
233    # pulled back. Tightening it further (6.5, say) would exclude the real
234    # target and make the estimate worse, which is the correct behavior for
235    # a constraint that is simply wrong.
236    CENTER = np.array([5.0, 5.0])
237    RADIUS = 7.1
238
239    def g_circle(x):
240        # Negative means inside the region.
241        return (x[0] - CENTER[0]) ** 2 + (x[1] - CENTER[1]) ** 2 - RADIUS**2
242
243    # Jacobian
244    def G_circle(x):
245        jac = np.zeros((1, 4))
246        jac[0, 0] = 2 * (x[0] - CENTER[0])
247        jac[0, 1] = 2 * (x[1] - CENTER[1])
248        return jac
249
250    cekf.add_constraint(ConstraintFunction(g_circle, G=G_circle))
251
252    # Initialize
253    x = scenario.x0.copy()
254    P = scenario.P0.copy()
255
256    x_est = np.zeros((len(measurements), 4))
257    P_est = np.zeros((len(measurements), 4, 4))
258
259    for k, z in enumerate(measurements):
260        # Predict
261        def f_wrapper(x_):
262            return scenario.f(x_)
263
264        pred = cekf.predict(x, P, f_wrapper, scenario.F, scenario.Q)
265        x = pred.x
266        P = pred.P
267
268        # Update
269        def h_wrapper(x_):
270            return scenario.h(x_)
271
272        upd = cekf.update(x, P, z, h_wrapper, scenario.h_jacobian(x), scenario.R)
273        x = upd.x
274        P = upd.P
275
276        x_est[k] = x
277        P_est[k] = P
278
279    # Report how often the constraint actually did something. A "constrained"
280    # filter whose constraint never activates is just an EKF, and saying so is
281    # more useful than quietly plotting a third identical curve.
282    dist = np.linalg.norm(x_est[:, :2] - CENTER, axis=1)
283    n_active = int(np.sum(dist >= RADIUS - 1e-9))
284    print(f"  Constraint |x-(5,5)| <= {RADIUS} active on {n_active}/{len(x_est)} steps")
285
286    return x_est, P_est
287
288
289def run_gsf_filter(
290    scenario: TargetTrackingScenario,
291    measurements: np.ndarray,
292) -> tuple[np.ndarray, np.ndarray]:
293    """Run Gaussian Sum Filter.
294
295    Parameters
296    ----------
297    scenario : TargetTrackingScenario
298        Tracking scenario
299    measurements : ndarray
300        Measurements
301
302    Returns
303    -------
304    x_est : ndarray
305        State estimates
306    P_est : ndarray
307        Covariance estimates
308    """
309    gsf = GaussianSumFilter(max_components=5)
310
311    # Initialize with multiple modes
312    gsf.initialize(scenario.x0, scenario.P0, num_components=3)
313
314    x_est = np.zeros((len(measurements), 4))
315    P_est = np.zeros((len(measurements), 4, 4))
316
317    for k, z in enumerate(measurements):
318        # Predict
319        def f_wrapper(x_):
320            return scenario.f(x_)
321
322        gsf.predict(f_wrapper, scenario.F, scenario.Q)
323
324        # Update
325        def h_wrapper(x_):
326            return scenario.h(x_)
327
328        # Get current estimate for Jacobian
329        x_pred, _ = gsf.estimate()
330        gsf.update(z, h_wrapper, scenario.h_jacobian(x_pred), scenario.R)
331
332        # Estimate
333        x, P = gsf.estimate()
334        x_est[k] = x
335        P_est[k] = P
336
337    return x_est, P_est
338
339
340def run_rbpf_filter(
341    scenario: TargetTrackingScenario,
342    measurements: np.ndarray,
343) -> tuple[np.ndarray, np.ndarray]:
344    """Run Rao-Blackwellized Particle Filter.
345
346    Parameters
347    ----------
348    scenario : TargetTrackingScenario
349        Tracking scenario
350    measurements : ndarray
351        Measurements
352
353    Returns
354    -------
355    x_est : ndarray
356        State estimates
357    P_est : ndarray
358        Covariance estimates
359    """
360    rbpf = RBPFFilter(max_particles=50)
361
362    # Partition: nonlinear (position), linear (velocity)
363    y0 = scenario.x0[:2]  # position
364    x0 = scenario.x0[2:]  # velocity
365    P0 = scenario.P0[2:, 2:]
366
367    rbpf.initialize(y0, x0, P0, num_particles=30)
368
369    x_est = np.zeros((len(measurements), 4))
370    P_est = np.zeros((len(measurements), 4, 4))
371
372    # The RBPF API propagates the nonlinear state as y[k+1] = g(y[k]) + noise,
373    # with no velocity-to-position coupling, so position uses a random-walk
374    # proposal whose Qy must cover the per-step target motion (~|v0|*dt).
375    def g(y):
376        return y
377
378    Qy = np.eye(2) * 0.02
379
380    # Linear (Kalman) block: velocity with friction
381    F_v = np.eye(2) * 0.95
382    Qx = np.eye(2) * 0.001
383
384    def f_linear(v, y):
385        return F_v @ v
386
387    def h_rbpf(v, y):
388        # Full state from position and velocity
389        x_full = np.concatenate([y, v])
390        return scenario.h(x_full)
391
392    # Range/bearing depend only on position (the particle state), so the
393    # measurement has zero sensitivity to the linear velocity block; the
394    # particle weights carry all the position information.
395    H_v = np.zeros((2, 2))
396
397    for k, z in enumerate(measurements):
398        rbpf.predict(g, Qy, f_linear, F_v, Qx)
399        rbpf.update(z, h_rbpf, H_v, scenario.R)
400
401        # Estimate
402        y_est, v_est, P_v = rbpf.estimate()
403        x_est[k] = np.concatenate([y_est, v_est])
404
405        # Full covariance (approximate)
406        P_est[k, :2, :2] = np.eye(2) * 0.1
407        P_est[k, 2:, 2:] = P_v
408        P_est[k, :2, 2:] = 0
409        P_est[k, 2:, :2] = 0
410
411    return x_est, P_est
412
413
414def plot_filter_comparison(
415    x_true: np.ndarray,
416    x_cekf: np.ndarray,
417    x_gsf: np.ndarray,
418    x_rbpf: np.ndarray,
419    P_cekf: np.ndarray,
420    P_gsf: np.ndarray,
421    P_rbpf: np.ndarray,
422) -> None:
423    """Create interactive Plotly visualizations for filter comparison."""
424    # Compute errors
425    err_cekf = np.linalg.norm(x_cekf - x_true, axis=1)
426    err_gsf = np.linalg.norm(x_gsf - x_true, axis=1)
427    err_rbpf = np.linalg.norm(x_rbpf - x_true, axis=1)
428
429    # Compute uncertainties
430    unc_cekf = np.array([np.trace(P_cekf[k]) for k in range(len(x_true))])
431    unc_gsf = np.array([np.trace(P_gsf[k]) for k in range(len(x_true))])
432    unc_rbpf = np.array([np.trace(P_rbpf[k]) for k in range(len(x_true))])
433
434    time = np.arange(len(x_true))
435
436    # Create subplot figure
437    fig = make_subplots(
438        rows=2,
439        cols=2,
440        subplot_titles=(
441            "Estimated Trajectories",
442            "State Estimation Error",
443            "Estimated Uncertainty",
444            "Error Distribution",
445        ),
446        specs=[
447            [{"type": "scatter"}, {"type": "scatter"}],
448            [{"type": "scatter"}, {"type": "box"}],
449        ],
450    )
451
452    # Plot 1: Trajectories
453    fig.add_trace(
454        go.Scatter(
455            x=x_true[:, 0],
456            y=x_true[:, 1],
457            mode="lines+markers",
458            name="True Trajectory",
459            line=dict(color="black", width=3, dash="dash"),
460            marker=dict(size=5),
461            hovertemplate="<b>True Path</b><br>X: %{x:.2f}<br>Y: %{y:.2f}<extra></extra>",
462        ),
463        row=1,
464        col=1,
465    )
466
467    fig.add_trace(
468        go.Scatter(
469            x=x_cekf[:, 0],
470            y=x_cekf[:, 1],
471            mode="lines",
472            name="CEKF Estimate",
473            line=dict(color="blue", width=2),
474            hovertemplate="<b>CEKF</b><br>X: %{x:.2f}<br>Y: %{y:.2f}<extra></extra>",
475        ),
476        row=1,
477        col=1,
478    )
479
480    fig.add_trace(
481        go.Scatter(
482            x=x_gsf[:, 0],
483            y=x_gsf[:, 1],
484            mode="lines",
485            name="GSF Estimate",
486            line=dict(color="green", width=2),
487            hovertemplate="<b>GSF</b><br>X: %{x:.2f}<br>Y: %{y:.2f}<extra></extra>",
488        ),
489        row=1,
490        col=1,
491    )
492
493    fig.add_trace(
494        go.Scatter(
495            x=x_rbpf[:, 0],
496            y=x_rbpf[:, 1],
497            mode="lines",
498            name="RBPF Estimate",
499            line=dict(color="red", width=2),
500            hovertemplate="<b>RBPF</b><br>X: %{x:.2f}<br>Y: %{y:.2f}<extra></extra>",
501        ),
502        row=1,
503        col=1,
504    )
505
506    # Plot 2: Position errors
507    fig.add_trace(
508        go.Scatter(
509            x=time,
510            y=err_cekf,
511            mode="lines",
512            name="CEKF Error",
513            line=dict(color="blue", width=2),
514            hovertemplate="<b>Time:</b> %{x}<br><b>CEKF Error:</b> %{y:.4f}<extra></extra>",
515        ),
516        row=1,
517        col=2,
518    )
519
520    fig.add_trace(
521        go.Scatter(
522            x=time,
523            y=err_gsf,
524            mode="lines",
525            name="GSF Error",
526            line=dict(color="green", width=2),
527            hovertemplate="<b>Time:</b> %{x}<br><b>GSF Error:</b> %{y:.4f}<extra></extra>",
528        ),
529        row=1,
530        col=2,
531    )
532
533    fig.add_trace(
534        go.Scatter(
535            x=time,
536            y=err_rbpf,
537            mode="lines",
538            name="RBPF Error",
539            line=dict(color="red", width=2),
540            hovertemplate="<b>Time:</b> %{x}<br><b>RBPF Error:</b> %{y:.4f}<extra></extra>",
541        ),
542        row=1,
543        col=2,
544    )
545
546    # Plot 3: Uncertainty estimates
547    fig.add_trace(
548        go.Scatter(
549            x=time,
550            y=unc_cekf,
551            mode="lines",
552            name="CEKF Uncertainty",
553            line=dict(color="blue", width=2),
554            hovertemplate="<b>Time:</b> %{x}<br><b>CEKF Covariance Trace:</b> %{y:.4f}<extra></extra>",
555        ),
556        row=2,
557        col=1,
558    )
559
560    fig.add_trace(
561        go.Scatter(
562            x=time,
563            y=unc_gsf,
564            mode="lines",
565            name="GSF Uncertainty",
566            line=dict(color="green", width=2),
567            hovertemplate="<b>Time:</b> %{x}<br><b>GSF Covariance Trace:</b> %{y:.4f}<extra></extra>",
568        ),
569        row=2,
570        col=1,
571    )
572
573    fig.add_trace(
574        go.Scatter(
575            x=time,
576            y=unc_rbpf,
577            mode="lines",
578            name="RBPF Uncertainty",
579            line=dict(color="red", width=2),
580            hovertemplate="<b>Time:</b> %{x}<br><b>RBPF Covariance Trace:</b> %{y:.4f}<extra></extra>",
581        ),
582        row=2,
583        col=1,
584    )
585
586    # Plot 4: Error distribution (box plot)
587    fig.add_trace(
588        go.Box(
589            y=err_cekf,
590            name="CEKF",
591            marker_color="blue",
592            hovertemplate="<b>CEKF</b><br>Error: %{y:.4f}<extra></extra>",
593        ),
594        row=2,
595        col=2,
596    )
597
598    fig.add_trace(
599        go.Box(
600            y=err_gsf,
601            name="GSF",
602            marker_color="green",
603            hovertemplate="<b>GSF</b><br>Error: %{y:.4f}<extra></extra>",
604        ),
605        row=2,
606        col=2,
607    )
608
609    fig.add_trace(
610        go.Box(
611            y=err_rbpf,
612            name="RBPF",
613            marker_color="red",
614            hovertemplate="<b>RBPF</b><br>Error: %{y:.4f}<extra></extra>",
615        ),
616        row=2,
617        col=2,
618    )
619
620    # Update layout
621    fig.update_xaxes(title_text="X Position", row=1, col=1)
622    fig.update_yaxes(title_text="Y Position", row=1, col=1)
623
624    fig.update_xaxes(title_text="Time Step", row=1, col=2)
625    fig.update_yaxes(title_text="Position Error (Norm)", row=1, col=2)
626
627    fig.update_xaxes(title_text="Time Step", row=2, col=1)
628    fig.update_yaxes(title_text="Covariance Trace", row=2, col=1)
629
630    fig.update_xaxes(title_text="Filter Algorithm", row=2, col=2)
631    fig.update_yaxes(title_text="Position Error", row=2, col=2)
632
633    fig.update_layout(
634        title_text="Advanced Filter Comparison: CEKF vs GSF vs RBPF",
635        height=900,
636        showlegend=True,
637        hovermode="closest",
638        plot_bgcolor="rgba(240,240,240,0.5)",
639    )
640
641    if SHOW_PLOTS:
642        fig.show()
643    else:
644        fig.write_html(
645            str(OUTPUT_DIR / "advanced_filters_comparison.html"),
646            include_plotlyjs="cdn",
647            div_id="advanced_filters_comparison",
648        )
649
650
651def main():
652    """Run comparison and generate plots."""
653    # Create scenario
654    scenario = TargetTrackingScenario()
655
656    # Generate data
657    print("Generating synthetic trajectory...")
658    x_true, measurements = scenario.generate_trajectory(steps=50)
659
660    # Run filters
661    print("Running CEKF...")
662    x_cekf, P_cekf = run_cekf_filter(scenario, measurements)
663
664    print("Running GSF...")
665    x_gsf, P_gsf = run_gsf_filter(scenario, measurements)
666
667    print("Running RBPF...")
668    x_rbpf, P_rbpf = run_rbpf_filter(scenario, measurements)
669
670    # Print statistics
671    err_cekf = np.linalg.norm(x_cekf - x_true, axis=1)
672    err_gsf = np.linalg.norm(x_gsf - x_true, axis=1)
673    err_rbpf = np.linalg.norm(x_rbpf - x_true, axis=1)
674
675    unc_cekf = np.array([np.trace(P_cekf[k]) for k in range(len(x_true))])
676    unc_gsf = np.array([np.trace(P_gsf[k]) for k in range(len(x_true))])
677    unc_rbpf = np.array([np.trace(P_rbpf[k]) for k in range(len(x_true))])
678
679    print("\n" + "=" * 60)
680    print("FILTER COMPARISON RESULTS")
681    print("=" * 60)
682    print(
683        f"CEKF - Mean Error: {np.mean(err_cekf):.4f}, Mean Uncertainty: {np.mean(unc_cekf):.4f}"
684    )
685    print(
686        f"GSF  - Mean Error: {np.mean(err_gsf):.4f}, Mean Uncertainty: {np.mean(unc_gsf):.4f}"
687    )
688    print(
689        f"RBPF - Mean Error: {np.mean(err_rbpf):.4f}, Mean Uncertainty: {np.mean(unc_rbpf):.4f}"
690    )
691    print("=" * 60)
692
693    # Generate interactive Plotly visualizations
694    plot_filter_comparison(x_true, x_cekf, x_gsf, x_rbpf, P_cekf, P_gsf, P_rbpf)
695
696
697OUTPUT_DIR = Path("docs/_static/images/examples")
698OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
699
700if __name__ == "__main__":
701    main()

Running the Example

python examples/advanced_filters_comparison.py

See Also