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