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:
Constrained EKF - Enforces state constraints during estimation
Gaussian Sum Filter - Represents multi-modal distributions
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
Kalman Filter Comparison - Basic Kalman filter variants
Particle Filters - Standard particle filters
Gaussian Mixtures and Clustering - Gaussian mixture operations