Performance Evaluation

This example demonstrates tracking performance metrics and evaluation.

Overview

Evaluating tracker performance requires multiple metrics:

  • OSPA: Optimal Sub-Pattern Assignment distance

  • RMSE: Root Mean Square Error for localization

  • Consistency: NEES and NIS statistics for filter tuning

  • Monte Carlo: Averaging performance over repeated runs

OSPA Metric

OSPA combines localization error and cardinality error:

  • Localization: Distance between matched targets

  • Cardinality: Penalty for missed/false targets

  • Order parameter (p): Controls metric sensitivity

  • Cutoff (c): Maximum localization error

Key Concepts

  • Localization vs cardinality: OSPA separates position error from missed/false target penalties

  • Filter consistency: NEES compares state error against the filter’s own covariance

  • Innovation consistency: NIS checks measurement residuals

  • Tuning diagnosis: Optimistic and conservative filters show up as NEES above or below the chi-squared bounds

Code Highlights

The example demonstrates:

  • Computing OSPA with ospa(), including its localization and cardinality components

  • OSPA history over a scenario, computed scan by scan

  • NEES consistency for correctly, optimistically, and conservatively tuned filters

  • Monte Carlo evaluation of RMSE, NEES, and NIS

Source Code

  1"""
  2Performance Evaluation Example.
  3
  4This example demonstrates:
  51. OSPA (Optimal Sub-Pattern Assignment) metric for multi-target tracking
  62. NEES (Normalized Estimation Error Squared) for filter consistency
  73. NIS (Normalized Innovation Squared) for measurement consistency
  84. Monte Carlo simulation for tracker evaluation
  95. Track quality metrics (purity, fragmentation)
 10
 11Run with: python examples/performance_evaluation.py
 12"""
 13
 14import sys
 15from pathlib import Path
 16
 17sys.path.insert(0, str(Path(__file__).parent.parent))
 18
 19# Output directory for generated plots
 20OUTPUT_DIR = Path(__file__).parent.parent / "docs" / "_static" / "images" / "examples"
 21OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
 22
 23import os
 24from typing import List, Tuple  # noqa: E402
 25
 26import numpy as np  # noqa: E402
 27import plotly.graph_objects as go  # noqa: E402
 28from plotly.subplots import make_subplots  # noqa: E402
 29
 30from pytcl.dynamic_estimation import (  # noqa: E402
 31    kf_predict,
 32    kf_update,
 33)
 34from pytcl.dynamic_models import (  # noqa: E402
 35    f_constant_velocity,
 36    q_constant_velocity,
 37)
 38from pytcl.performance_evaluation import (
 39    ospa,
 40)
 41
 42SHOW_PLOTS = os.environ.get("PYTCL_SHOW_PLOTS", "1") != "0"
 43
 44
 45def ospa_demo() -> None:
 46    """Demonstrate OSPA metric for multi-target tracking evaluation."""
 47    print("=" * 60)
 48    print("1. OSPA METRIC FOR MULTI-TARGET TRACKING")
 49    print("=" * 60)
 50
 51    print("\nOSPA (Optimal Sub-Pattern Assignment) measures the distance")
 52    print("between two sets of targets, accounting for:")
 53    print("  - Localization errors (how far are matched targets?)")
 54    print("  - Cardinality errors (how many targets are missed/false?)")
 55
 56    # Ground truth targets (2D positions)
 57    truth = np.array(
 58        [
 59            [10.0, 20.0],
 60            [50.0, 30.0],
 61            [80.0, 60.0],
 62        ]
 63    )
 64
 65    print(f"\nGround truth: {len(truth)} targets")
 66    for i, pos in enumerate(truth):
 67        print(f"  Target {i + 1}: ({pos[0]:.1f}, {pos[1]:.1f})")
 68
 69    # Different estimate scenarios
 70    scenarios = [
 71        ("Perfect tracking", np.array([[10.0, 20.0], [50.0, 30.0], [80.0, 60.0]])),
 72        ("Small errors", np.array([[12.0, 22.0], [48.0, 32.0], [82.0, 58.0]])),
 73        ("One missed target", np.array([[10.0, 20.0], [50.0, 30.0]])),
 74        (
 75            "One false target",
 76            np.array([[10.0, 20.0], [50.0, 30.0], [80.0, 60.0], [100.0, 100.0]]),
 77        ),
 78        ("Wrong positions", np.array([[0.0, 0.0], [100.0, 100.0], [50.0, 50.0]])),
 79    ]
 80
 81    # OSPA parameters
 82    c = 50.0  # Cutoff distance (max penalty per target)
 83    p = 2  # Order parameter
 84
 85    print(f"\nOSPA parameters: c={c}, p={p}")
 86    print("-" * 60)
 87
 88    for name, estimates in scenarios:
 89        result = ospa(truth, estimates, c=c, p=p)
 90
 91        print(f"\n{name}:")
 92        print(f"  Estimates: {len(estimates)} targets")
 93        print(f"  OSPA distance: {result.ospa:.2f}")
 94        print(f"  Localization: {result.localization:.2f}")
 95        print(f"  Cardinality:  {result.cardinality:.2f}")
 96
 97
 98def nees_consistency_demo() -> None:
 99    """Demonstrate NEES for filter consistency evaluation."""
100    print("\n" + "=" * 60)
101    print("2. NEES FOR FILTER CONSISTENCY")
102    print("=" * 60)
103
104    print("\nNEES (Normalized Estimation Error Squared) tests if the filter's")
105    print("covariance estimate matches the actual estimation errors.")
106    print("For a consistent filter, NEES should average to the state dimension.")
107
108    np.random.seed(42)
109
110    # Simulate a simple 2D tracking scenario
111    n_steps = 100
112    dt = 1.0
113
114    # System matrices
115    F = f_constant_velocity(dt, 2)  # 4-state CV model
116    Q = q_constant_velocity(dt, 0.1, 2)
117    H = np.array([[1, 0, 0, 0], [0, 0, 1, 0]])  # Measure position
118    R = np.diag([5.0**2, 5.0**2])
119
120    # True initial state
121    x_true = np.array([0.0, 2.0, 0.0, 1.0])
122
123    # Run filter with correct process noise (consistent)
124    print("\n--- Correctly Tuned Filter ---")
125    nees_correct = run_filter_get_nees(x_true, F, Q, H, R, Q, n_steps)
126
127    # Run filter with underestimated process noise (optimistic)
128    print("\n--- Underestimated Process Noise (Optimistic) ---")
129    Q_low = q_constant_velocity(dt, 0.01, 2)  # Too low
130    nees_optimistic = run_filter_get_nees(x_true, F, Q, H, R, Q_low, n_steps)
131
132    # Run filter with overestimated process noise (conservative)
133    print("\n--- Overestimated Process Noise (Conservative) ---")
134    Q_high = q_constant_velocity(dt, 1.0, 2)  # Too high
135    nees_conservative = run_filter_get_nees(x_true, F, Q, H, R, Q_high, n_steps)
136
137    # Statistical test
138    state_dim = 4
139    print(f"\n{'Filter Type':<30} {'Mean NEES':<12} {'Expected':<12} {'Consistent?'}")
140    print("-" * 70)
141
142    for name, nees_vals in [
143        ("Correctly tuned", nees_correct),
144        ("Optimistic (low Q)", nees_optimistic),
145        ("Conservative (high Q)", nees_conservative),
146    ]:
147        mean_nees = np.mean(nees_vals)
148        # Chi-squared bounds (95% confidence)
149        lower = state_dim * 0.5  # Rough approximation
150        upper = state_dim * 1.5
151        consistent = lower <= mean_nees <= upper
152        status = "Yes" if consistent else "No"
153
154        print(f"{name:<30} {mean_nees:<12.2f} {state_dim:<12} {status}")
155
156
157def run_filter_get_nees(
158    x_true_init: np.ndarray,
159    F: np.ndarray,
160    Q_true: np.ndarray,
161    H: np.ndarray,
162    R: np.ndarray,
163    Q_filter: np.ndarray,
164    n_steps: int,
165) -> List[float]:
166    """Run Kalman filter and compute NEES at each step."""
167    # Generate true trajectory
168    x_true = x_true_init.copy()
169    true_states = [x_true.copy()]
170
171    for _ in range(n_steps - 1):
172        # Process noise
173        w = np.random.multivariate_normal(np.zeros(4), Q_true)
174        x_true = F @ x_true + w
175        true_states.append(x_true.copy())
176
177    # Generate measurements
178    measurements = []
179    for x in true_states:
180        v = np.random.multivariate_normal(np.zeros(2), R)
181        z = H @ x + v
182        measurements.append(z)
183
184    # Run filter
185    x = np.array([measurements[0][0], 0.0, measurements[0][1], 0.0])
186    P = np.diag([25.0, 10.0, 25.0, 10.0])
187
188    nees_values = []
189
190    for k in range(n_steps):
191        if k > 0:
192            x, P = kf_predict(x, P, F, Q_filter)
193            result = kf_update(x, P, measurements[k], H, R)
194            x, P = result.x, result.P
195
196        # Compute NEES
197        err = x - true_states[k]
198        nees_val = float(err.T @ np.linalg.solve(P, err))
199        nees_values.append(nees_val)
200
201    return nees_values
202
203
204def monte_carlo_demo() -> Tuple[List[float], List[float], List[float]]:
205    """Demonstrate Monte Carlo evaluation of tracker performance."""
206    print("\n" + "=" * 60)
207    print("3. MONTE CARLO TRACKER EVALUATION")
208    print("=" * 60)
209
210    print("\nMonte Carlo simulation runs multiple trials to get")
211    print("statistically meaningful performance metrics.")
212
213    np.random.seed(123)
214
215    n_runs = 50
216    n_steps = 100
217    dt = 1.0
218
219    # System setup
220    F = f_constant_velocity(dt, 2)
221    Q = q_constant_velocity(dt, 0.1, 2)
222    H = np.array([[1, 0, 0, 0], [0, 0, 1, 0]])
223    R = np.diag([5.0**2, 5.0**2])
224
225    print(f"\nRunning {n_runs} Monte Carlo trials...")
226    print(f"  {n_steps} time steps per trial")
227
228    # Collect metrics across runs
229    all_rmse = []
230    all_nees = []
231    all_nis = []
232
233    for run in range(n_runs):
234        # Random initial state
235        x_true = np.array(
236            [
237                np.random.uniform(-50, 50),  # x
238                np.random.uniform(1, 3),  # vx
239                np.random.uniform(-50, 50),  # y
240                np.random.uniform(1, 3),  # vy
241            ]
242        )
243
244        # Generate trajectory and measurements
245        true_states = [x_true.copy()]
246        measurements = []
247
248        for _ in range(n_steps):
249            # Measurement
250            v = np.random.multivariate_normal(np.zeros(2), R)
251            z = H @ x_true + v
252            measurements.append(z)
253
254            # Propagate
255            w = np.random.multivariate_normal(np.zeros(4), Q)
256            x_true = F @ x_true + w
257            true_states.append(x_true.copy())
258
259        true_states = true_states[:-1]  # Match measurement count
260
261        # Run filter
262        x = np.array([measurements[0][0], 0.0, measurements[0][1], 0.0])
263        P = np.diag([25.0, 10.0, 25.0, 10.0])
264
265        run_errors = []
266        run_nees = []
267        run_nis = []
268
269        for k in range(n_steps):
270            if k > 0:
271                x, P = kf_predict(x, P, F, Q)
272                z_pred = H @ x
273                S = H @ P @ H.T + R
274                innovation = measurements[k] - z_pred
275
276                # NIS
277                nis_val = float(innovation.T @ np.linalg.solve(S, innovation))
278                run_nis.append(nis_val)
279
280                result = kf_update(x, P, measurements[k], H, R)
281                x, P = result.x, result.P
282
283            # Position error
284            err = np.sqrt(
285                (x[0] - true_states[k][0]) ** 2 + (x[2] - true_states[k][2]) ** 2
286            )
287            run_errors.append(err)
288
289            # NEES
290            state_err = x - true_states[k]
291            nees_val = float(state_err.T @ np.linalg.solve(P, state_err))
292            run_nees.append(nees_val)
293
294        all_rmse.append(np.sqrt(np.mean(np.array(run_errors) ** 2)))
295        all_nees.append(np.mean(run_nees))
296        all_nis.append(np.mean(run_nis))
297
298    # Report statistics
299    print("\nResults across all Monte Carlo runs:")
300    print("-" * 60)
301
302    print("\nPosition RMSE (m):")
303    print(f"  Mean:   {np.mean(all_rmse):.2f}")
304    print(f"  Std:    {np.std(all_rmse):.2f}")
305    print(f"  Min:    {np.min(all_rmse):.2f}")
306    print(f"  Max:    {np.max(all_rmse):.2f}")
307
308    print("\nNEES (expected: 4.0 for 4-state filter):")
309    print(f"  Mean:   {np.mean(all_nees):.2f}")
310    print(f"  Std:    {np.std(all_nees):.2f}")
311
312    print("\nNIS (expected: 2.0 for 2-measurement filter):")
313    print(f"  Mean:   {np.mean(all_nis):.2f}")
314    print(f"  Std:    {np.std(all_nis):.2f}")
315
316    # Chi-squared test
317    print("\nConsistency check:")
318    nees_pass = 3.0 <= np.mean(all_nees) <= 5.0
319    nis_pass = 1.5 <= np.mean(all_nis) <= 2.5
320    print(f"  NEES within bounds: {'PASS' if nees_pass else 'FAIL'}")
321    print(f"  NIS within bounds:  {'PASS' if nis_pass else 'FAIL'}")
322
323    return all_rmse, all_nees, all_nis
324
325
326def ospa_over_time_demo() -> Tuple[List[float], List[float], List[float]]:
327    """Demonstrate OSPA metric evolution over time."""
328    print("\n" + "=" * 60)
329    print("4. OSPA OVER TIME FOR TRACKER EVALUATION")
330    print("=" * 60)
331
332    np.random.seed(456)
333
334    n_steps = 50
335
336    # Simulate ground truth: 2 targets, one appears at t=10, one disappears at t=40
337    print("\nSimulating scenario with target birth/death:")
338    print("  - Target 1: present throughout")
339    print("  - Target 2: appears at t=10, disappears at t=40")
340
341    # Generate trajectories
342    truth_history = []
343    for t in range(n_steps):
344        targets = []
345        # Target 1: always present, moving right
346        targets.append(np.array([10.0 + t * 2, 30.0 + t * 0.5]))
347
348        # Target 2: appears at t=10, disappears at t=40
349        if 10 <= t < 40:
350            targets.append(np.array([80.0 - t * 1.5, 20.0 + t * 1.0]))
351
352        truth_history.append(
353            np.array(targets) if targets else np.array([]).reshape(0, 2)
354        )
355
356    # Simulate tracker estimates (with some noise and occasional misses)
357    estimate_history = []
358    for t, truth in enumerate(truth_history):
359        estimates = []
360        for target in truth:
361            # 90% detection probability
362            if np.random.rand() < 0.9:
363                noise = np.random.randn(2) * 5.0
364                estimates.append(target + noise)
365
366        # 10% false alarm probability
367        if np.random.rand() < 0.1:
368            false_alarm = np.array(
369                [np.random.uniform(0, 100), np.random.uniform(0, 80)]
370            )
371            estimates.append(false_alarm)
372
373        estimate_history.append(
374            np.array(estimates) if estimates else np.array([]).reshape(0, 2)
375        )
376
377    # Compute OSPA over time
378    c = 50.0
379    p = 2
380    ospa_history = []
381    loc_history = []
382    card_history = []
383
384    for truth, estimates in zip(truth_history, estimate_history):
385        if len(truth) == 0 and len(estimates) == 0:
386            ospa_history.append(0.0)
387            loc_history.append(0.0)
388            card_history.append(0.0)
389        else:
390            result = ospa(truth, estimates, c=c, p=p)
391            ospa_history.append(result.ospa)
392            loc_history.append(result.localization)
393            card_history.append(result.cardinality)
394
395    # Summary
396    print(f"\nOSPA statistics over {n_steps} time steps:")
397    print(f"  Mean OSPA:         {np.mean(ospa_history):.2f}")
398    print(f"  Mean Localization: {np.mean(loc_history):.2f}")
399    print(f"  Mean Cardinality:  {np.mean(card_history):.2f}")
400
401    return ospa_history, loc_history, card_history
402
403
404def plot_results(
405    mc_rmse: List[float],
406    mc_nees: List[float],
407    mc_nis: List[float],
408    ospa_hist: List[float],
409    loc_hist: List[float],
410    card_hist: List[float],
411) -> None:
412    """Create performance evaluation plots."""
413    fig = make_subplots(
414        rows=2,
415        cols=2,
416        subplot_titles=(
417            "Monte Carlo RMSE Distribution",
418            "NEES/NIS Distribution",
419            "OSPA Over Time",
420            "OSPA Components",
421        ),
422    )
423
424    # RMSE histogram
425    fig.add_trace(
426        go.Histogram(x=mc_rmse, nbinsx=20, name="Position RMSE", marker_color="blue"),
427        row=1,
428        col=1,
429    )
430
431    # NEES/NIS histograms
432    fig.add_trace(
433        go.Histogram(
434            x=mc_nees,
435            nbinsx=20,
436            name="NEES",
437            marker_color="green",
438            opacity=0.7,
439        ),
440        row=1,
441        col=2,
442    )
443    fig.add_trace(
444        go.Histogram(
445            x=mc_nis,
446            nbinsx=20,
447            name="NIS",
448            marker_color="orange",
449            opacity=0.7,
450        ),
451        row=1,
452        col=2,
453    )
454
455    # OSPA over time
456    time = list(range(len(ospa_hist)))
457    fig.add_trace(
458        go.Scatter(x=time, y=ospa_hist, name="OSPA", line=dict(color="red", width=2)),
459        row=2,
460        col=1,
461    )
462
463    # OSPA components
464    fig.add_trace(
465        go.Scatter(
466            x=time,
467            y=loc_hist,
468            name="Localization",
469            line=dict(color="blue", width=1.5),
470        ),
471        row=2,
472        col=2,
473    )
474    fig.add_trace(
475        go.Scatter(
476            x=time,
477            y=card_hist,
478            name="Cardinality",
479            line=dict(color="green", width=1.5),
480        ),
481        row=2,
482        col=2,
483    )
484
485    fig.update_layout(
486        title="Performance Evaluation Metrics",
487        height=800,
488        width=1200,
489        showlegend=True,
490        barmode="overlay",
491    )
492
493    fig.update_xaxes(title_text="RMSE (m)", row=1, col=1)
494    fig.update_yaxes(title_text="Count", row=1, col=1)
495    fig.update_xaxes(title_text="Value", row=1, col=2)
496    fig.update_yaxes(title_text="Count", row=1, col=2)
497    fig.update_xaxes(title_text="Time Step", row=2, col=1)
498    fig.update_yaxes(title_text="OSPA", row=2, col=1)
499    fig.update_xaxes(title_text="Time Step", row=2, col=2)
500    fig.update_yaxes(title_text="Component Value", row=2, col=2)
501
502    fig.write_html(
503        str(OUTPUT_DIR / "performance_evaluation.html"),
504        include_plotlyjs="cdn",
505        div_id="performance_evaluation",
506    )
507    print("\nInteractive plot saved to performance_evaluation.html")
508    if SHOW_PLOTS:
509        fig.show()
510    else:
511        OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
512        fig.write_html(
513            str(OUTPUT_DIR / "performance_evaluation.html"),
514            include_plotlyjs="cdn",
515            div_id="performance_evaluation",
516        )
517
518
519def main() -> None:
520    """Run performance evaluation demonstrations."""
521    print("\nPerformance Evaluation Examples")
522    print("=" * 60)
523    print("Demonstrating pytcl tracker and filter evaluation metrics")
524
525    ospa_demo()
526    nees_consistency_demo()
527    mc_rmse, mc_nees, mc_nis = monte_carlo_demo()
528    ospa_hist, loc_hist, card_hist = ospa_over_time_demo()
529
530    plot_results(mc_rmse, mc_nees, mc_nis, ospa_hist, loc_hist, card_hist)
531
532    print("\n" + "=" * 60)
533    print("Done!")
534    print("=" * 60)
535
536
537if __name__ == "__main__":
538    main()

Running the Example

python examples/performance_evaluation.py

See Also