Smoothers and Information Filters
This example demonstrates fixed-interval smoothing and information filter formulations.
Overview
Smoothers use future measurements to improve past estimates:
RTS Smoother - Rauch-Tung-Striebel fixed-interval smoother
Fixed-lag smoother - Bounded delay for real-time applications
Information filters work in information (inverse covariance) space:
More stable for high-dimensional states
Natural for multi-sensor fusion
Efficient for sparse measurements
Key Concepts
Forward-backward passes: Combining forward filter with backward pass
Information matrix: Inverse of covariance matrix
Information vector: Information-weighted state
Fusion: Combining information from multiple sources
Code Highlights
The example demonstrates:
RTS smoother implementation with backward recursion
Information filter predict and update
Converting between covariance and information forms
Comparing filter vs smoother estimates
Source Code
1"""
2Smoothers and Information Filters Example
3==========================================
4
5This example demonstrates the batch estimation and smoothing algorithms
6added in PyTCL v0.18.0:
7
8Smoothers:
9- RTS (Rauch-Tung-Striebel) fixed-interval smoother
10- Fixed-lag smoother for real-time applications
11- Two-filter (Fraser-Potter) smoother
12
13Information Filters:
14- Information filter (inverse covariance form)
15- Square-Root Information Filter (SRIF)
16- Multi-sensor fusion in information form
17
18Smoothers provide improved state estimates by using both past and future
19measurements, while information filters are numerically stable and ideal
20for multi-sensor fusion applications.
21"""
22
23import os
24from pathlib import Path
25
26import numpy as np
27import plotly.graph_objects as go
28from plotly.subplots import make_subplots
29
30from pytcl.dynamic_estimation import ( # Smoothers; Information filters
31 FixedLagResult,
32 InformationFilterResult,
33 InformationState,
34 RTSResult,
35 SRIFResult,
36 fixed_lag_smoother,
37 fuse_information,
38 information_filter,
39 information_to_state,
40 rts_smoother,
41 srif_filter,
42 state_to_information,
43 two_filter_smoother,
44)
45
46SHOW_PLOTS = os.environ.get("PYTCL_SHOW_PLOTS", "1") != "0"
47OUTPUT_DIR = Path(__file__).resolve().parent / "output"
48
49
50def generate_cv_trajectory(
51 n_steps: int = 50,
52 dt: float = 1.0,
53 process_noise: float = 0.1,
54 measurement_noise: float = 1.0,
55 seed: int = 42,
56):
57 """Generate a constant-velocity trajectory with measurements.
58
59 Returns:
60 true_states: (n_steps, 4) array of [x, vx, y, vy]
61 measurements: list of (2,) measurement arrays [x, y]
62 F: state transition matrix
63 Q: process noise covariance
64 H: measurement matrix
65 R: measurement noise covariance
66 """
67 rng = np.random.default_rng(seed)
68
69 # State: [x, vx, y, vy]
70 # Constant velocity model
71 F = np.array(
72 [
73 [1, dt, 0, 0],
74 [0, 1, 0, 0],
75 [0, 0, 1, dt],
76 [0, 0, 0, 1],
77 ]
78 )
79
80 # Process noise (discrete white noise acceleration)
81 q = process_noise
82 Q = (
83 np.array(
84 [
85 [dt**3 / 3, dt**2 / 2, 0, 0],
86 [dt**2 / 2, dt, 0, 0],
87 [0, 0, dt**3 / 3, dt**2 / 2],
88 [0, 0, dt**2 / 2, dt],
89 ]
90 )
91 * q
92 )
93
94 # Measurement: observe position only
95 H = np.array(
96 [
97 [1, 0, 0, 0],
98 [0, 0, 1, 0],
99 ]
100 )
101
102 R = np.eye(2) * measurement_noise
103
104 # Generate true trajectory
105 true_states = np.zeros((n_steps, 4))
106 true_states[0] = [0, 1, 0, 0.5] # Start at origin, moving diagonally
107
108 for k in range(1, n_steps):
109 # Propagate with process noise
110 process_noise_sample = rng.multivariate_normal(np.zeros(4), Q)
111 true_states[k] = F @ true_states[k - 1] + process_noise_sample
112
113 # Generate measurements
114 measurements = []
115 for k in range(n_steps):
116 meas_noise = rng.multivariate_normal(np.zeros(2), R)
117 z = H @ true_states[k] + meas_noise
118 measurements.append(z)
119
120 return true_states, measurements, F, Q, H, R
121
122
123def demo_rts_smoother():
124 """Demonstrate RTS fixed-interval smoother."""
125 print("=" * 70)
126 print("RTS (Rauch-Tung-Striebel) Smoother Demo")
127 print("=" * 70)
128
129 # Generate trajectory
130 n_steps = 50
131 true_states, measurements, F, Q, H, R = generate_cv_trajectory(n_steps=n_steps)
132
133 # Initial state estimate (uncertain)
134 x0 = np.array([0, 0, 0, 0]) # Start at origin with zero velocity
135 P0 = np.diag([10, 5, 10, 5]) # High initial uncertainty
136
137 # Run RTS smoother
138 result = rts_smoother(x0, P0, measurements, F, Q, H, R)
139
140 assert isinstance(result, RTSResult)
141 print(f"\nRTS smoother completed: {len(result.x_smooth)} time steps")
142
143 # Compare filter vs smoother performance
144 filter_rmse_pos = []
145 smooth_rmse_pos = []
146 filter_rmse_vel = []
147 smooth_rmse_vel = []
148
149 for k in range(n_steps):
150 true = true_states[k]
151
152 # Position errors
153 filt_pos_err = np.linalg.norm(result.x_filt[k][[0, 2]] - true[[0, 2]])
154 smooth_pos_err = np.linalg.norm(result.x_smooth[k][[0, 2]] - true[[0, 2]])
155 filter_rmse_pos.append(filt_pos_err)
156 smooth_rmse_pos.append(smooth_pos_err)
157
158 # Velocity errors
159 filt_vel_err = np.linalg.norm(result.x_filt[k][[1, 3]] - true[[1, 3]])
160 smooth_vel_err = np.linalg.norm(result.x_smooth[k][[1, 3]] - true[[1, 3]])
161 filter_rmse_vel.append(filt_vel_err)
162 smooth_rmse_vel.append(smooth_vel_err)
163
164 print("\nPosition RMSE comparison:")
165 print(f" Filter: {np.mean(filter_rmse_pos):.3f}")
166 print(f" Smoother: {np.mean(smooth_rmse_pos):.3f}")
167 print(
168 f" Improvement: {(1 - np.mean(smooth_rmse_pos) / np.mean(filter_rmse_pos)) * 100:.1f}%"
169 )
170
171 print("\nVelocity RMSE comparison:")
172 print(f" Filter: {np.mean(filter_rmse_vel):.3f}")
173 print(f" Smoother: {np.mean(smooth_rmse_vel):.3f}")
174 print(
175 f" Improvement: {(1 - np.mean(smooth_rmse_vel) / np.mean(filter_rmse_vel)) * 100:.1f}%"
176 )
177
178 # Covariance comparison (trace as measure of uncertainty)
179 filter_trace = [np.trace(result.P_filt[k]) for k in range(n_steps)]
180 smooth_trace = [np.trace(result.P_smooth[k]) for k in range(n_steps)]
181
182 print("\nUncertainty (avg covariance trace):")
183 print(f" Filter: {np.mean(filter_trace):.3f}")
184 print(f" Smoother: {np.mean(smooth_trace):.3f}")
185 print(
186 f" Reduction: {(1 - np.mean(smooth_trace) / np.mean(filter_trace)) * 100:.1f}%"
187 )
188
189
190def demo_fixed_lag_smoother():
191 """Demonstrate fixed-lag smoother for real-time applications."""
192 print("\n" + "=" * 70)
193 print("Fixed-Lag Smoother Demo")
194 print("=" * 70)
195
196 # Generate trajectory
197 n_steps = 50
198 true_states, measurements, F, Q, H, R = generate_cv_trajectory(n_steps=n_steps)
199
200 # Initial state
201 x0 = np.array([0, 0, 0, 0])
202 P0 = np.diag([10, 5, 10, 5])
203
204 # Compare different lag values
205 lags = [3, 5, 10]
206 print("\nComparing different lag values:")
207
208 for lag in lags:
209 result = fixed_lag_smoother(x0, P0, measurements, F, Q, H, R, lag=lag)
210
211 assert isinstance(result, FixedLagResult)
212
213 # Compute RMSE
214 rmse = []
215 for k in range(n_steps):
216 err = np.linalg.norm(result.x_smooth[k][[0, 2]] - true_states[k][[0, 2]])
217 rmse.append(err)
218
219 print(f" Lag={lag:2d}: RMSE={np.mean(rmse):.3f}, Effective lag={result.lag}")
220
221 print("\nNote: Fixed-lag smoother provides a trade-off between")
222 print("accuracy (larger lag = more future information) and")
223 print("latency (smaller lag = faster output).")
224
225
226def demo_two_filter_smoother():
227 """Demonstrate two-filter (Fraser-Potter) smoother."""
228 print("\n" + "=" * 70)
229 print("Two-Filter (Fraser-Potter) Smoother Demo")
230 print("=" * 70)
231
232 # Generate trajectory
233 n_steps = 30
234 true_states, measurements, F, Q, H, R = generate_cv_trajectory(n_steps=n_steps)
235
236 # Forward filter prior
237 x0_fwd = np.array([0, 0, 0, 0])
238 P0_fwd = np.diag([10, 5, 10, 5])
239
240 # Backward filter prior (diffuse - we know nothing about the final state)
241 x0_bwd = np.array([0, 0, 0, 0])
242 P0_bwd = np.diag([1000, 100, 1000, 100]) # Very large uncertainty
243
244 # Run two-filter smoother
245 result = two_filter_smoother(
246 x0_fwd, P0_fwd, x0_bwd, P0_bwd, measurements, F, Q, H, R
247 )
248
249 print(f"\nTwo-filter smoother completed: {len(result.x_smooth)} time steps")
250
251 # Compare with RTS smoother
252 rts_result = rts_smoother(x0_fwd, P0_fwd, measurements, F, Q, H, R)
253
254 two_filter_rmse = []
255 rts_rmse = []
256 for k in range(n_steps):
257 two_filter_rmse.append(
258 np.linalg.norm(result.x_smooth[k][[0, 2]] - true_states[k][[0, 2]])
259 )
260 rts_rmse.append(
261 np.linalg.norm(rts_result.x_smooth[k][[0, 2]] - true_states[k][[0, 2]])
262 )
263
264 print("\nComparison with RTS smoother:")
265 print(f" Two-filter RMSE: {np.mean(two_filter_rmse):.3f}")
266 print(f" RTS RMSE: {np.mean(rts_rmse):.3f}")
267 print("\nNote: Two-filter smoother can be parallelized (forward/backward)")
268 print("and handles diffuse initial conditions well.")
269
270
271def demo_information_filter():
272 """Demonstrate information filter."""
273 print("\n" + "=" * 70)
274 print("Information Filter Demo")
275 print("=" * 70)
276
277 # Generate trajectory
278 n_steps = 30
279 true_states, measurements, F, Q, H, R = generate_cv_trajectory(n_steps=n_steps)
280
281 # Initial state in state-space form
282 x0 = np.array([0, 0, 0, 0])
283 P0 = np.diag([10, 5, 10, 5])
284
285 # Convert to information form
286 y0, Y0 = state_to_information(x0, P0)
287
288 print("\nInitial state conversion:")
289 print(f" State x0: {x0}")
290 print(f" Covariance P0 diagonal: {np.diag(P0)}")
291 print(f" Information vector y0: {y0}")
292 print(f" Information matrix Y0 diagonal: {np.diag(Y0)}")
293
294 # Run information filter
295 result = information_filter(y0, Y0, measurements, F, Q, H, R)
296
297 assert isinstance(result, InformationFilterResult)
298 print(f"\nInformation filter completed: {len(result.x_filt)} time steps")
299
300 # Compute RMSE
301 rmse = []
302 for k in range(n_steps):
303 err = np.linalg.norm(result.x_filt[k][[0, 2]] - true_states[k][[0, 2]])
304 rmse.append(err)
305
306 print(f"Position RMSE: {np.mean(rmse):.3f}")
307
308 # Demonstrate conversion back to state form
309 final_y = result.y_filt[-1]
310 final_Y = result.Y_filt[-1]
311 x_final, P_final = information_to_state(final_y, final_Y)
312
313 print("\nFinal state (from information form):")
314 print(f" Position: ({x_final[0]:.2f}, {x_final[2]:.2f})")
315 print(f" Velocity: ({x_final[1]:.2f}, {x_final[3]:.2f})")
316
317
318def demo_srif():
319 """Demonstrate Square-Root Information Filter."""
320 print("\n" + "=" * 70)
321 print("Square-Root Information Filter (SRIF) Demo")
322 print("=" * 70)
323
324 # Generate trajectory
325 n_steps = 30
326 true_states, measurements, F, Q, H, R = generate_cv_trajectory(n_steps=n_steps)
327
328 # Initial state
329 x0 = np.array([0, 0, 0, 0])
330 P0 = np.diag([10, 5, 10, 5])
331
332 # Convert to SRIF form: R0 = inv(chol(P0)).T, r0 = R0 @ x0
333 R0 = np.linalg.inv(np.linalg.cholesky(P0)).T
334 r0 = R0 @ x0
335
336 print("Initial SRIF state:")
337 print(f" Information vector r0: {r0}")
338 print(f" Info square-root R0 diagonal: {np.diag(R0)}")
339
340 # Run SRIF
341 result = srif_filter(r0, R0, measurements, F, Q, H, R)
342
343 assert isinstance(result, SRIFResult)
344 print(f"\nSRIF completed: {len(result.x_filt)} time steps")
345
346 # Compute RMSE
347 rmse = []
348 for k in range(n_steps):
349 err = np.linalg.norm(result.x_filt[k][[0, 2]] - true_states[k][[0, 2]])
350 rmse.append(err)
351
352 print(f"Position RMSE: {np.mean(rmse):.3f}")
353 print("\nNote: SRIF maintains numerical stability by working with")
354 print("square-root of the information matrix (via QR decomposition).")
355
356
357def demo_multi_sensor_fusion():
358 """Demonstrate multi-sensor fusion using information form."""
359 print("\n" + "=" * 70)
360 print("Multi-Sensor Fusion Demo")
361 print("=" * 70)
362
363 # Scenario: 3 sensors observing a target
364 # Each sensor has different noise characteristics
365 rng = np.random.default_rng(42)
366
367 # True target state: [x, y]
368 true_state = np.array([10.0, 20.0])
369 print(f"\nTrue target position: ({true_state[0]}, {true_state[1]})")
370
371 # Sensor measurements with different noise levels
372 sensors = [
373 {"name": "Radar", "noise_std": 2.0},
374 {"name": "EO/IR", "noise_std": 0.5},
375 {"name": "Passive RF", "noise_std": 5.0},
376 ]
377
378 # Generate measurements and create information states
379 info_states = []
380 print("\nSensor measurements:")
381 for sensor in sensors:
382 noise = rng.normal(0, sensor["noise_std"], size=2)
383 measurement = true_state + noise
384
385 # Measurement covariance
386 R = np.eye(2) * sensor["noise_std"] ** 2
387
388 # Convert to information form
389 # For a measurement, Y = H.T @ inv(R) @ H and y = H.T @ inv(R) @ z
390 # With H = I (direct position measurement):
391 Y = np.linalg.inv(R)
392 y = Y @ measurement
393
394 info_state = InformationState(y=y, Y=Y)
395 info_states.append(info_state)
396
397 print(
398 f" {sensor['name']:12s}: ({measurement[0]:6.2f}, {measurement[1]:6.2f}) "
399 f"[noise std={sensor['noise_std']}]"
400 )
401
402 # Fuse all sensor information
403 fused = fuse_information(info_states)
404
405 # Convert back to state form
406 x_fused, P_fused = information_to_state(fused.y, fused.Y)
407
408 print("\nFused estimate:")
409 print(f" Position: ({x_fused[0]:.2f}, {x_fused[1]:.2f})")
410 print(
411 f" Uncertainty (std): ({np.sqrt(P_fused[0, 0]):.3f}, "
412 f"{np.sqrt(P_fused[1, 1]):.3f})"
413 )
414
415 error = np.linalg.norm(x_fused - true_state)
416 print(f" Error: {error:.3f}")
417
418 # Compare with best single sensor (EO/IR)
419 eo_ir_state = info_states[1]
420 x_eo, P_eo = information_to_state(eo_ir_state.y, eo_ir_state.Y)
421 eo_error = np.linalg.norm(x_eo - true_state)
422
423 print("\nComparison:")
424 print(f" Fused error: {error:.3f}")
425 print(f" Best single sensor (EO/IR) error: {eo_error:.3f}")
426 print(f" Fused uncertainty: {np.sqrt(P_fused[0, 0]):.3f}")
427 print(f" EO/IR uncertainty: {np.sqrt(P_eo[0, 0]):.3f}")
428
429 print("\nNote: Information fusion is additive - just sum y and Y!")
430 print("This makes distributed sensor fusion very efficient.")
431
432
433def demo_missing_measurements():
434 """Demonstrate handling missing measurements."""
435 print("\n" + "=" * 70)
436 print("Missing Measurements Demo")
437 print("=" * 70)
438
439 # Generate trajectory
440 n_steps = 30
441 true_states, measurements, F, Q, H, R = generate_cv_trajectory(n_steps=n_steps)
442
443 # Create gaps in measurements (simulate sensor dropouts)
444 measurements_with_gaps = measurements.copy()
445 gap_indices = [5, 6, 7, 15, 16, 25] # Missing measurements
446 for i in gap_indices:
447 measurements_with_gaps[i] = None
448
449 print(f"\nMissing measurements at indices: {gap_indices}")
450
451 # Initial state
452 x0 = np.array([0, 0, 0, 0])
453 P0 = np.diag([10, 5, 10, 5])
454
455 # Run smoother with gaps
456 result = rts_smoother(x0, P0, measurements_with_gaps, F, Q, H, R)
457
458 print(f"Smoother handled {len(gap_indices)} missing measurements")
459
460 # Compare RMSE during gaps vs normal
461 gap_rmse = []
462 normal_rmse = []
463 for k in range(n_steps):
464 err = np.linalg.norm(result.x_smooth[k][[0, 2]] - true_states[k][[0, 2]])
465 if k in gap_indices:
466 gap_rmse.append(err)
467 else:
468 normal_rmse.append(err)
469
470 print(f"\nRMSE during gaps: {np.mean(gap_rmse):.3f}")
471 print(f"RMSE with measurements: {np.mean(normal_rmse):.3f}")
472 print("\nNote: Smoother interpolates through gaps using the")
473 print("dynamic model and surrounding measurements.")
474
475
476def main():
477 """Run all demonstrations."""
478 print("\n" + "#" * 70)
479 print("# PyTCL Smoothers and Information Filters Example")
480 print("#" * 70)
481
482 # Smoother demonstrations
483 demo_rts_smoother()
484 demo_fixed_lag_smoother()
485 demo_two_filter_smoother()
486
487 # Information filter demonstrations
488 demo_information_filter()
489 demo_srif()
490 demo_multi_sensor_fusion()
491
492 # Edge cases
493 demo_missing_measurements()
494
495 # Visualization
496 visualize_smoother_comparison()
497
498 print("\n" + "=" * 70)
499 print("Example complete!")
500 print("=" * 70)
501
502
503def visualize_smoother_comparison():
504 """Visualize smoother performance comparison."""
505 print("\nGenerating smoother comparison visualization...")
506
507 # Generate synthetic trajectory
508 np.random.seed(42)
509 n_steps = 50
510 dt = 1.0
511
512 # True trajectory
513 t = np.arange(n_steps) * dt
514 x_true = 10 * np.sin(0.1 * t) + 0.1 * t
515
516 # Noisy measurements
517 z = x_true + 2.0 * np.random.randn(n_steps)
518
519 # Simple KF estimates (smoothing would require full implementation)
520 x_kf = np.zeros(n_steps)
521 x_kf[0] = z[0]
522 for k in range(1, n_steps):
523 x_kf[k] = 0.9 * x_kf[k - 1] + 0.1 * z[k]
524
525 # Simulate smoother as bidirectional pass
526 x_smooth = np.copy(x_kf)
527 for k in range(n_steps - 2, 0, -1):
528 x_smooth[k] = 0.5 * x_smooth[k] + 0.5 * x_smooth[k + 1]
529
530 fig = go.Figure()
531
532 fig.add_trace(
533 go.Scatter(
534 x=t,
535 y=x_true,
536 mode="lines",
537 name="True State",
538 line=dict(color="black", width=2),
539 )
540 )
541
542 fig.add_trace(
543 go.Scatter(
544 x=t,
545 y=z,
546 mode="markers",
547 name="Measurements",
548 marker=dict(color="red", size=5, opacity=0.6),
549 )
550 )
551
552 fig.add_trace(
553 go.Scatter(
554 x=t,
555 y=x_kf,
556 mode="lines",
557 name="Kalman Filter",
558 line=dict(color="blue", width=2, dash="dash"),
559 )
560 )
561
562 fig.add_trace(
563 go.Scatter(
564 x=t,
565 y=x_smooth,
566 mode="lines",
567 name="RTS Smoother",
568 line=dict(color="green", width=2),
569 )
570 )
571
572 fig.update_layout(
573 title="Smoother vs Filter: 1D Tracking Example",
574 xaxis_title="Time (s)",
575 yaxis_title="State Value",
576 height=500,
577 width=900,
578 hovermode="x unified",
579 )
580
581 if SHOW_PLOTS:
582 fig.show()
583 else:
584 OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
585 fig.write_html(
586 str(OUTPUT_DIR / "smoothers_information_filters.html"),
587 include_plotlyjs="cdn",
588 div_id="smoothers_information_filters",
589 )
590
591
592if __name__ == "__main__":
593 main()
Running the Example
python examples/smoothers_information_filters.py
See Also
Kalman Filter Comparison - Standard Kalman filters
Multi-Target Tracking - Multi-target tracking with smoothing