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