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 mospa_demo() -> None:
 99    """Demonstrate MOSPA/MMOSPA estimation over labeling-ambiguous hypotheses."""
100    import numpy as np
101
102    from pytcl.performance_evaluation import calc_mospa_error, mmospa_approx
103
104    print("\n" + "=" * 60)
105    print("2. MOSPA / MMOSPA: ESTIMATION UNDER LABELING AMBIGUITY")
106    print("=" * 60)
107    print("\nWhen a tracker's hypotheses disagree about which target is")
108    print("which, the naive weighted mean smears the targets together.")
109    print("The MMOSPA estimate re-orders each hypothesis first.")
110
111    rng = np.random.default_rng(7)
112    n_hyp = 60
113    x = np.zeros((2, 2, n_hyp))
114    x[:, 0, :] = rng.normal(0, 0.3, (2, n_hyp)) + np.array([[2.0], [0.0]])
115    x[:, 1, :] = rng.normal(0, 0.3, (2, n_hyp)) - np.array([[2.0], [0.0]])
116    swap = rng.random(n_hyp) < 0.5  # half the hypotheses use swapped labels
117    x[:, :, swap] = x[:, ::-1, swap]
118    w = np.full(n_hyp, 1.0 / n_hyp)
119
120    naive = (x * w).sum(axis=2)
121    mmospa, _ = mmospa_approx(x, w, num_scans=3)
122
123    print(
124        f"\n  Naive weighted mean:  target 1 at ({naive[0, 0]:+.2f}, "
125        f"{naive[1, 0]:+.2f}), target 2 at ({naive[0, 1]:+.2f}, {naive[1, 1]:+.2f})"
126    )
127    print(
128        f"  MMOSPA estimate:      target 1 at ({mmospa[0, 0]:+.2f}, "
129        f"{mmospa[1, 0]:+.2f}), target 2 at ({mmospa[0, 1]:+.2f}, {mmospa[1, 1]:+.2f})"
130    )
131    print(f"\n  MOSPA error, naive mean: {calc_mospa_error(naive, x, w):.3f}")
132    print(f"  MOSPA error, MMOSPA:     {calc_mospa_error(mmospa, x, w):.3f}")
133    print("  (the naive mean collapses both targets toward the origin)")
134
135
136def nees_consistency_demo() -> None:
137    """Demonstrate NEES for filter consistency evaluation."""
138    print("\n" + "=" * 60)
139    print("3. NEES FOR FILTER CONSISTENCY")
140    print("=" * 60)
141
142    print("\nNEES (Normalized Estimation Error Squared) tests if the filter's")
143    print("covariance estimate matches the actual estimation errors.")
144    print("For a consistent filter, NEES should average to the state dimension.")
145
146    np.random.seed(42)
147
148    # Simulate a simple 2D tracking scenario
149    n_steps = 100
150    dt = 1.0
151
152    # System matrices
153    F = f_constant_velocity(dt, 2)  # 4-state CV model
154    Q = q_constant_velocity(dt, 0.1, 2)
155    H = np.array([[1, 0, 0, 0], [0, 0, 1, 0]])  # Measure position
156    R = np.diag([5.0**2, 5.0**2])
157
158    # True initial state
159    x_true = np.array([0.0, 2.0, 0.0, 1.0])
160
161    # Run filter with correct process noise (consistent)
162    print("\n--- Correctly Tuned Filter ---")
163    nees_correct = run_filter_get_nees(x_true, F, Q, H, R, Q, n_steps)
164
165    # Run filter with underestimated process noise (optimistic)
166    print("\n--- Underestimated Process Noise (Optimistic) ---")
167    Q_low = q_constant_velocity(dt, 0.01, 2)  # Too low
168    nees_optimistic = run_filter_get_nees(x_true, F, Q, H, R, Q_low, n_steps)
169
170    # Run filter with overestimated process noise (conservative)
171    print("\n--- Overestimated Process Noise (Conservative) ---")
172    Q_high = q_constant_velocity(dt, 1.0, 2)  # Too high
173    nees_conservative = run_filter_get_nees(x_true, F, Q, H, R, Q_high, n_steps)
174
175    # Statistical test
176    state_dim = 4
177    print(f"\n{'Filter Type':<30} {'Mean NEES':<12} {'Expected':<12} {'Consistent?'}")
178    print("-" * 70)
179
180    for name, nees_vals in [
181        ("Correctly tuned", nees_correct),
182        ("Optimistic (low Q)", nees_optimistic),
183        ("Conservative (high Q)", nees_conservative),
184    ]:
185        mean_nees = np.mean(nees_vals)
186        # Chi-squared bounds (95% confidence)
187        lower = state_dim * 0.5  # Rough approximation
188        upper = state_dim * 1.5
189        consistent = lower <= mean_nees <= upper
190        status = "Yes" if consistent else "No"
191
192        print(f"{name:<30} {mean_nees:<12.2f} {state_dim:<12} {status}")
193
194
195def run_filter_get_nees(
196    x_true_init: np.ndarray,
197    F: np.ndarray,
198    Q_true: np.ndarray,
199    H: np.ndarray,
200    R: np.ndarray,
201    Q_filter: np.ndarray,
202    n_steps: int,
203) -> List[float]:
204    """Run Kalman filter and compute NEES at each step."""
205    # Generate true trajectory
206    x_true = x_true_init.copy()
207    true_states = [x_true.copy()]
208
209    for _ in range(n_steps - 1):
210        # Process noise
211        w = np.random.multivariate_normal(np.zeros(4), Q_true)
212        x_true = F @ x_true + w
213        true_states.append(x_true.copy())
214
215    # Generate measurements
216    measurements = []
217    for x in true_states:
218        v = np.random.multivariate_normal(np.zeros(2), R)
219        z = H @ x + v
220        measurements.append(z)
221
222    # Run filter
223    x = np.array([measurements[0][0], 0.0, measurements[0][1], 0.0])
224    P = np.diag([25.0, 10.0, 25.0, 10.0])
225
226    nees_values = []
227
228    for k in range(n_steps):
229        if k > 0:
230            x, P = kf_predict(x, P, F, Q_filter)
231            result = kf_update(x, P, measurements[k], H, R)
232            x, P = result.x, result.P
233
234        # Compute NEES
235        err = x - true_states[k]
236        nees_val = float(err.T @ np.linalg.solve(P, err))
237        nees_values.append(nees_val)
238
239    return nees_values
240
241
242def monte_carlo_demo() -> Tuple[List[float], List[float], List[float]]:
243    """Demonstrate Monte Carlo evaluation of tracker performance."""
244    print("\n" + "=" * 60)
245    print("4. MONTE CARLO TRACKER EVALUATION")
246    print("=" * 60)
247
248    print("\nMonte Carlo simulation runs multiple trials to get")
249    print("statistically meaningful performance metrics.")
250
251    np.random.seed(123)
252
253    n_runs = 50
254    n_steps = 100
255    dt = 1.0
256
257    # System setup
258    F = f_constant_velocity(dt, 2)
259    Q = q_constant_velocity(dt, 0.1, 2)
260    H = np.array([[1, 0, 0, 0], [0, 0, 1, 0]])
261    R = np.diag([5.0**2, 5.0**2])
262
263    print(f"\nRunning {n_runs} Monte Carlo trials...")
264    print(f"  {n_steps} time steps per trial")
265
266    # Collect metrics across runs
267    all_rmse = []
268    all_nees = []
269    all_nis = []
270
271    for run in range(n_runs):
272        # Random initial state
273        x_true = np.array(
274            [
275                np.random.uniform(-50, 50),  # x
276                np.random.uniform(1, 3),  # vx
277                np.random.uniform(-50, 50),  # y
278                np.random.uniform(1, 3),  # vy
279            ]
280        )
281
282        # Generate trajectory and measurements
283        true_states = [x_true.copy()]
284        measurements = []
285
286        for _ in range(n_steps):
287            # Measurement
288            v = np.random.multivariate_normal(np.zeros(2), R)
289            z = H @ x_true + v
290            measurements.append(z)
291
292            # Propagate
293            w = np.random.multivariate_normal(np.zeros(4), Q)
294            x_true = F @ x_true + w
295            true_states.append(x_true.copy())
296
297        true_states = true_states[:-1]  # Match measurement count
298
299        # Run filter
300        x = np.array([measurements[0][0], 0.0, measurements[0][1], 0.0])
301        P = np.diag([25.0, 10.0, 25.0, 10.0])
302
303        run_errors = []
304        run_nees = []
305        run_nis = []
306
307        for k in range(n_steps):
308            if k > 0:
309                x, P = kf_predict(x, P, F, Q)
310                z_pred = H @ x
311                S = H @ P @ H.T + R
312                innovation = measurements[k] - z_pred
313
314                # NIS
315                nis_val = float(innovation.T @ np.linalg.solve(S, innovation))
316                run_nis.append(nis_val)
317
318                result = kf_update(x, P, measurements[k], H, R)
319                x, P = result.x, result.P
320
321            # Position error
322            err = np.sqrt(
323                (x[0] - true_states[k][0]) ** 2 + (x[2] - true_states[k][2]) ** 2
324            )
325            run_errors.append(err)
326
327            # NEES
328            state_err = x - true_states[k]
329            nees_val = float(state_err.T @ np.linalg.solve(P, state_err))
330            run_nees.append(nees_val)
331
332        all_rmse.append(np.sqrt(np.mean(np.array(run_errors) ** 2)))
333        all_nees.append(np.mean(run_nees))
334        all_nis.append(np.mean(run_nis))
335
336    # Report statistics
337    print("\nResults across all Monte Carlo runs:")
338    print("-" * 60)
339
340    print("\nPosition RMSE (m):")
341    print(f"  Mean:   {np.mean(all_rmse):.2f}")
342    print(f"  Std:    {np.std(all_rmse):.2f}")
343    print(f"  Min:    {np.min(all_rmse):.2f}")
344    print(f"  Max:    {np.max(all_rmse):.2f}")
345
346    print("\nNEES (expected: 4.0 for 4-state filter):")
347    print(f"  Mean:   {np.mean(all_nees):.2f}")
348    print(f"  Std:    {np.std(all_nees):.2f}")
349
350    print("\nNIS (expected: 2.0 for 2-measurement filter):")
351    print(f"  Mean:   {np.mean(all_nis):.2f}")
352    print(f"  Std:    {np.std(all_nis):.2f}")
353
354    # Chi-squared test
355    print("\nConsistency check:")
356    nees_pass = 3.0 <= np.mean(all_nees) <= 5.0
357    nis_pass = 1.5 <= np.mean(all_nis) <= 2.5
358    print(f"  NEES within bounds: {'PASS' if nees_pass else 'FAIL'}")
359    print(f"  NIS within bounds:  {'PASS' if nis_pass else 'FAIL'}")
360
361    return all_rmse, all_nees, all_nis
362
363
364def ospa_over_time_demo() -> Tuple[List[float], List[float], List[float]]:
365    """Demonstrate OSPA metric evolution over time."""
366    print("\n" + "=" * 60)
367    print("5. OSPA OVER TIME FOR TRACKER EVALUATION")
368    print("=" * 60)
369
370    np.random.seed(456)
371
372    n_steps = 50
373
374    # Simulate ground truth: 2 targets, one appears at t=10, one disappears at t=40
375    print("\nSimulating scenario with target birth/death:")
376    print("  - Target 1: present throughout")
377    print("  - Target 2: appears at t=10, disappears at t=40")
378
379    # Generate trajectories
380    truth_history = []
381    for t in range(n_steps):
382        targets = []
383        # Target 1: always present, moving right
384        targets.append(np.array([10.0 + t * 2, 30.0 + t * 0.5]))
385
386        # Target 2: appears at t=10, disappears at t=40
387        if 10 <= t < 40:
388            targets.append(np.array([80.0 - t * 1.5, 20.0 + t * 1.0]))
389
390        truth_history.append(
391            np.array(targets) if targets else np.array([]).reshape(0, 2)
392        )
393
394    # Simulate tracker estimates (with some noise and occasional misses)
395    estimate_history = []
396    for t, truth in enumerate(truth_history):
397        estimates = []
398        for target in truth:
399            # 90% detection probability
400            if np.random.rand() < 0.9:
401                noise = np.random.randn(2) * 5.0
402                estimates.append(target + noise)
403
404        # 10% false alarm probability
405        if np.random.rand() < 0.1:
406            false_alarm = np.array(
407                [np.random.uniform(0, 100), np.random.uniform(0, 80)]
408            )
409            estimates.append(false_alarm)
410
411        estimate_history.append(
412            np.array(estimates) if estimates else np.array([]).reshape(0, 2)
413        )
414
415    # Compute OSPA over time
416    c = 50.0
417    p = 2
418    ospa_history = []
419    loc_history = []
420    card_history = []
421
422    for truth, estimates in zip(truth_history, estimate_history):
423        if len(truth) == 0 and len(estimates) == 0:
424            ospa_history.append(0.0)
425            loc_history.append(0.0)
426            card_history.append(0.0)
427        else:
428            result = ospa(truth, estimates, c=c, p=p)
429            ospa_history.append(result.ospa)
430            loc_history.append(result.localization)
431            card_history.append(result.cardinality)
432
433    # Summary
434    print(f"\nOSPA statistics over {n_steps} time steps:")
435    print(f"  Mean OSPA:         {np.mean(ospa_history):.2f}")
436    print(f"  Mean Localization: {np.mean(loc_history):.2f}")
437    print(f"  Mean Cardinality:  {np.mean(card_history):.2f}")
438
439    return ospa_history, loc_history, card_history
440
441
442def plot_results(
443    mc_rmse: List[float],
444    mc_nees: List[float],
445    mc_nis: List[float],
446    ospa_hist: List[float],
447    loc_hist: List[float],
448    card_hist: List[float],
449) -> None:
450    """Create performance evaluation plots."""
451    fig = make_subplots(
452        rows=2,
453        cols=2,
454        subplot_titles=(
455            "Monte Carlo RMSE Distribution",
456            "NEES/NIS Distribution",
457            "OSPA Over Time",
458            "OSPA Components",
459        ),
460    )
461
462    # RMSE histogram
463    fig.add_trace(
464        go.Histogram(x=mc_rmse, nbinsx=20, name="Position RMSE", marker_color="blue"),
465        row=1,
466        col=1,
467    )
468
469    # NEES/NIS histograms
470    fig.add_trace(
471        go.Histogram(
472            x=mc_nees,
473            nbinsx=20,
474            name="NEES",
475            marker_color="green",
476            opacity=0.7,
477        ),
478        row=1,
479        col=2,
480    )
481    fig.add_trace(
482        go.Histogram(
483            x=mc_nis,
484            nbinsx=20,
485            name="NIS",
486            marker_color="orange",
487            opacity=0.7,
488        ),
489        row=1,
490        col=2,
491    )
492
493    # OSPA over time
494    time = list(range(len(ospa_hist)))
495    fig.add_trace(
496        go.Scatter(x=time, y=ospa_hist, name="OSPA", line=dict(color="red", width=2)),
497        row=2,
498        col=1,
499    )
500
501    # OSPA components
502    fig.add_trace(
503        go.Scatter(
504            x=time,
505            y=loc_hist,
506            name="Localization",
507            line=dict(color="blue", width=1.5),
508        ),
509        row=2,
510        col=2,
511    )
512    fig.add_trace(
513        go.Scatter(
514            x=time,
515            y=card_hist,
516            name="Cardinality",
517            line=dict(color="green", width=1.5),
518        ),
519        row=2,
520        col=2,
521    )
522
523    fig.update_layout(
524        title="Performance Evaluation Metrics",
525        height=800,
526        width=1200,
527        showlegend=True,
528        barmode="overlay",
529    )
530
531    fig.update_xaxes(title_text="RMSE (m)", row=1, col=1)
532    fig.update_yaxes(title_text="Count", row=1, col=1)
533    fig.update_xaxes(title_text="Value", row=1, col=2)
534    fig.update_yaxes(title_text="Count", row=1, col=2)
535    fig.update_xaxes(title_text="Time Step", row=2, col=1)
536    fig.update_yaxes(title_text="OSPA", row=2, col=1)
537    fig.update_xaxes(title_text="Time Step", row=2, col=2)
538    fig.update_yaxes(title_text="Component Value", row=2, col=2)
539
540    fig.write_html(
541        str(OUTPUT_DIR / "performance_evaluation.html"),
542        include_plotlyjs="cdn",
543        div_id="performance_evaluation",
544    )
545    print("\nInteractive plot saved to performance_evaluation.html")
546    if SHOW_PLOTS:
547        fig.show()
548    else:
549        OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
550        fig.write_html(
551            str(OUTPUT_DIR / "performance_evaluation.html"),
552            include_plotlyjs="cdn",
553            div_id="performance_evaluation",
554        )
555
556
557def main() -> None:
558    """Run performance evaluation demonstrations."""
559    print("\nPerformance Evaluation Examples")
560    print("=" * 60)
561    print("Demonstrating pytcl tracker and filter evaluation metrics")
562
563    ospa_demo()
564    mospa_demo()
565    nees_consistency_demo()
566    mc_rmse, mc_nees, mc_nis = monte_carlo_demo()
567    ospa_hist, loc_hist, card_hist = ospa_over_time_demo()
568
569    plot_results(mc_rmse, mc_nees, mc_nis, ospa_hist, loc_hist, card_hist)
570
571    print("\n" + "=" * 60)
572    print("Done!")
573    print("=" * 60)
574
575
576if __name__ == "__main__":
577    main()

Running the Example

python examples/performance_evaluation.py

See Also