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 componentsOSPA 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
Multi-Target Tracking - Tracker to evaluate
Assignment Algorithms - Assignment for track-truth matching