Gaussian Mixtures and Clustering

This example demonstrates Gaussian mixture operations and clustering algorithms.

Overview

Gaussian mixtures and clustering are essential for:

  • Multi-hypothesis tracking: Representing multiple target hypotheses

  • Mixture reduction: Limiting computational complexity

  • Data clustering: Grouping measurements or tracks

Clustering Algorithms

K-Means
  • Partitions data into k clusters

  • Minimizes within-cluster variance

  • Fast and scalable

DBSCAN
  • Density-based clustering

  • Handles arbitrary cluster shapes

  • Identifies outliers automatically

Hierarchical
  • Builds cluster tree (dendrogram)

  • Multiple linkage options

  • Flexible number of clusters

Gaussian Mixture Operations

  • Moment matching: Reducing mixture to single Gaussian

  • Runnalls’ algorithm: Optimal mixture reduction

  • West’s algorithm: Alternative reduction method

  • Splitting/merging: Dynamic mixture management

Code Highlights

The example demonstrates:

  • K-means clustering with kmeans()

  • DBSCAN with dbscan()

  • Hierarchical clustering with agglomerative_clustering()

  • Gaussian mixture reduction with reduce_mixture_runnalls()

Source Code

  1"""
  2Gaussian Mixtures Example
  3=========================
  4
  5This example demonstrates Gaussian mixture operations and clustering
  6algorithms in PyTCL:
  7
  8Gaussian Mixture Operations:
  9- Component representation and manipulation
 10- Moment matching (computing mean and covariance)
 11- Mixture merging and reduction
 12- Runnalls' and West's reduction algorithms
 13
 14Clustering Algorithms:
 15- K-means with K-means++ initialization
 16- DBSCAN (density-based clustering)
 17- Hierarchical/agglomerative clustering
 18- Elbow method for K selection
 19
 20These algorithms are essential for multi-target tracking (PHD filters),
 21hypothesis reduction in MHT, and general density estimation.
 22"""
 23
 24from pathlib import Path
 25
 26import numpy as np
 27import plotly.graph_objects as go
 28
 29# Output directory for generated plots
 30OUTPUT_DIR = Path(__file__).parent.parent / "docs" / "_static" / "images" / "examples"
 31OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
 32from plotly.subplots import make_subplots
 33
 34# Global flag to control plotting
 35SHOW_PLOTS = True
 36
 37
 38def create_ellipse_trace(mean, cov, color="blue", opacity=0.3, n_std=2, name=None):
 39    """Create a plotly trace for an ellipse representing a 2D Gaussian covariance."""
 40    eigvals, eigvecs = np.linalg.eigh(cov)
 41    # Angle in radians
 42    angle = np.arctan2(eigvecs[1, 0], eigvecs[0, 0])
 43
 44    # Semi-axes lengths
 45    a = n_std * np.sqrt(eigvals[0])
 46    b = n_std * np.sqrt(eigvals[1])
 47
 48    # Parametric ellipse
 49    t = np.linspace(0, 2 * np.pi, 100)
 50    x = a * np.cos(t)
 51    y = b * np.sin(t)
 52
 53    # Rotate
 54    x_rot = x * np.cos(angle) - y * np.sin(angle) + mean[0]
 55    y_rot = x * np.sin(angle) + y * np.cos(angle) + mean[1]
 56
 57    return go.Scatter(
 58        x=x_rot,
 59        y=y_rot,
 60        mode="lines",
 61        fill="toself",
 62        fillcolor=color,
 63        opacity=opacity,
 64        line=dict(color=color, width=2),
 65        name=name,
 66        showlegend=name is not None,
 67    )
 68
 69
 70from pytcl.clustering import (  # Gaussian mixture operations; K-means; DBSCAN; Hierarchical clustering
 71    DBSCANResult,
 72    GaussianComponent,
 73    GaussianMixture,
 74    HierarchicalResult,
 75    KMeansResult,
 76    agglomerative_clustering,
 77    compute_distance_matrix,
 78    cut_dendrogram,
 79    dbscan,
 80    dbscan_predict,
 81    kmeans,
 82    kmeans_elbow,
 83    kmeans_plusplus_init,
 84    merge_gaussians,
 85    moment_match,
 86    prune_mixture,
 87    reduce_mixture_runnalls,
 88    reduce_mixture_west,
 89    runnalls_merge_cost,
 90    west_merge_cost,
 91)
 92
 93
 94def _rng(seed: int = 42) -> np.random.Generator:
 95    """A seeded Generator for library calls that take one.
 96
 97    ``np.random.seed`` seeds the legacy global RNG; ``kmeans`` and friends
 98    default to ``np.random.default_rng()``, a separate stream that ignores it.
 99    Without passing this the figures under docs/_static change every rebuild.
100    """
101    return np.random.default_rng(seed)
102
103
104def demo_gaussian_components():
105    """Demonstrate Gaussian component operations."""
106    print("=" * 70)
107    print("Gaussian Component Operations Demo")
108    print("=" * 70)
109
110    # Create individual Gaussian components
111    comp1 = GaussianComponent(
112        weight=0.4,
113        mean=np.array([0.0, 0.0]),
114        covariance=np.array([[1.0, 0.0], [0.0, 1.0]]),
115    )
116
117    comp2 = GaussianComponent(
118        weight=0.6,
119        mean=np.array([3.0, 3.0]),
120        covariance=np.array([[2.0, 0.5], [0.5, 2.0]]),
121    )
122
123    print("\nComponent 1:")
124    print(f"  Weight: {comp1.weight}")
125    print(f"  Mean: {comp1.mean}")
126    print(f"  Covariance diagonal: {np.diag(comp1.covariance)}")
127
128    print("\nComponent 2:")
129    print(f"  Weight: {comp2.weight}")
130    print(f"  Mean: {comp2.mean}")
131    print(f"  Covariance diagonal: {np.diag(comp2.covariance)}")
132
133    # Create a mixture
134    mixture = GaussianMixture([comp1, comp2])
135    print(f"\nMixture has {len(mixture)} components")
136    print(f"Total weight: {sum(c.weight for c in mixture.components)}")
137
138
139def demo_moment_matching():
140    """Demonstrate moment matching for mixture approximation."""
141    print("\n" + "=" * 70)
142    print("Moment Matching Demo")
143    print("=" * 70)
144
145    # Create a mixture of 3 components
146    weights = np.array([0.3, 0.5, 0.2])
147    means = [np.array([0.0, 0.0]), np.array([2.0, 1.0]), np.array([1.0, 3.0])]
148    covariances = [np.eye(2) * 0.5, np.eye(2) * 0.8, np.eye(2) * 0.3]
149
150    print("\nOriginal mixture (3 components):")
151    for i, (w, m) in enumerate(zip(weights, means)):
152        print(f"  Component {i + 1}: weight={w:.1f}, mean={m}")
153
154    # Moment match to single Gaussian - takes (weights, means, covariances)
155    mean, cov = moment_match(weights, means, covariances)
156
157    print("\nMoment-matched single Gaussian:")
158    print(f"  Mean: ({mean[0]:.3f}, {mean[1]:.3f})")
159    print(f"  Covariance:\n{cov}")
160
161    # The mean should be the weighted average
162    weighted_mean = sum(w * m for w, m in zip(weights, means))
163    print(
164        f"\nVerification - weighted mean: ({weighted_mean[0]:.3f}, "
165        f"{weighted_mean[1]:.3f})"
166    )
167
168
169def demo_mixture_merging():
170    """Demonstrate merging Gaussian components."""
171    print("\n" + "=" * 70)
172    print("Mixture Merging Demo")
173    print("=" * 70)
174
175    # Two nearby components
176    comp1 = GaussianComponent(0.4, np.array([0.0, 0.0]), np.eye(2) * 1.0)
177    comp2 = GaussianComponent(0.3, np.array([0.5, 0.5]), np.eye(2) * 1.0)
178
179    # Two far apart components
180    comp3 = GaussianComponent(0.3, np.array([5.0, 5.0]), np.eye(2) * 1.0)
181
182    print("\nThree components:")
183    print(f"  1: mean={comp1.mean}, weight={comp1.weight}")
184    print(f"  2: mean={comp2.mean}, weight={comp2.weight}")
185    print(f"  3: mean={comp3.mean}, weight={comp3.weight}")
186
187    # Merge costs
188    cost_12 = runnalls_merge_cost(comp1, comp2)
189    cost_13 = runnalls_merge_cost(comp1, comp3)
190    cost_23 = runnalls_merge_cost(comp2, comp3)
191
192    print("\nRunnalls merge costs (lower = better candidates for merging):")
193    print(f"  Cost(1,2): {cost_12:.4f}")
194    print(f"  Cost(1,3): {cost_13:.4f}")
195    print(f"  Cost(2,3): {cost_23:.4f}")
196
197    # Merge the closest pair
198    merge_result = merge_gaussians(comp1, comp2)
199    merged = merge_result.component
200
201    print("\nMerged component (1+2):")
202    print(f"  Weight: {merged.weight:.2f}")
203    print(f"  Mean: ({merged.mean[0]:.3f}, {merged.mean[1]:.3f})")
204    print(f"  Merge cost: {merge_result.cost:.4f}")
205
206
207def demo_mixture_reduction():
208    """Demonstrate mixture reduction algorithms."""
209    print("\n" + "=" * 70)
210    print("Mixture Reduction Demo")
211    print("=" * 70)
212
213    np.random.seed(42)
214
215    # Create a large mixture (e.g., from PHD filter output)
216    n_components = 20
217    components = []
218
219    # Cluster 1: around (0, 0)
220    for _ in range(8):
221        mean = np.array([0, 0]) + np.random.randn(2) * 0.5
222        cov = np.eye(2) * (0.3 + np.random.rand() * 0.2)
223        weight = 0.3 + np.random.rand() * 0.4
224        components.append(GaussianComponent(weight, mean, cov))
225
226    # Cluster 2: around (5, 3)
227    for _ in range(7):
228        mean = np.array([5, 3]) + np.random.randn(2) * 0.5
229        cov = np.eye(2) * (0.3 + np.random.rand() * 0.2)
230        weight = 0.2 + np.random.rand() * 0.3
231        components.append(GaussianComponent(weight, mean, cov))
232
233    # Scattered components
234    for _ in range(5):
235        mean = np.random.rand(2) * 10
236        cov = np.eye(2) * 0.5
237        weight = 0.05 + np.random.rand() * 0.1
238        components.append(GaussianComponent(weight, mean, cov))
239
240    print(f"\nOriginal mixture: {len(components)} components")
241    print(f"Total weight: {sum(c.weight for c in components):.2f}")
242
243    # Prune low-weight components
244    pruned = prune_mixture(components, weight_threshold=0.1)
245    print(f"\nAfter pruning (weight_threshold=0.1): {len(pruned)} components")
246
247    # Reduce using Runnalls' algorithm
248    n_target = 5
249    result_runnalls = reduce_mixture_runnalls(components, n_target)
250    print(f"\nRunnalls reduction to {n_target} components:")
251    print(f"  Final components: {result_runnalls.n_reduced}")
252    print(f"  Total merge cost: {result_runnalls.total_cost:.4f}")
253    for i, c in enumerate(result_runnalls.components):
254        print(
255            f"    {i + 1}: weight={c.weight:.3f}, "
256            f"mean=({c.mean[0]:.2f}, {c.mean[1]:.2f})"
257        )
258
259    # Reduce using West's algorithm
260    result_west = reduce_mixture_west(components, n_target)
261    print(f"\nWest reduction to {n_target} components:")
262    print(f"  Final components: {result_west.n_reduced}")
263    print(f"  Total merge cost: {result_west.total_cost:.4f}")
264
265
266def demo_kmeans():
267    """Demonstrate K-means clustering."""
268    print("\n" + "=" * 70)
269    print("K-Means Clustering Demo")
270    print("=" * 70)
271
272    np.random.seed(42)
273
274    # Generate clustered data
275    n_per_cluster = 50
276    centers_true = np.array(
277        [
278            [0, 0],
279            [5, 5],
280            [0, 5],
281        ]
282    )
283
284    data = []
285    for center in centers_true:
286        cluster = center + np.random.randn(n_per_cluster, 2) * 0.8
287        data.append(cluster)
288    data = np.vstack(data)
289
290    print(f"\nGenerated {len(data)} points in 3 clusters")
291    print(f"True centers:\n{centers_true}")
292
293    # K-means clustering
294    result = kmeans(data, n_clusters=3, max_iter=100, rng=_rng())
295
296    print(f"\nK-means result:")
297    print(f"  Iterations: {result.n_iter}")
298    print(f"  Inertia (within-cluster sum of squares): {result.inertia:.2f}")
299    print(f"  Found centers:\n{result.centers}")
300
301    # Cluster sizes
302    unique, counts = np.unique(result.labels, return_counts=True)
303    print("\n  Cluster sizes:", dict(zip(unique, counts)))
304
305    # Plot K-means result
306    if SHOW_PLOTS:
307        fig = make_subplots(
308            rows=1,
309            cols=2,
310            subplot_titles=["Ground Truth Clusters", "K-means Clustering Result"],
311        )
312
313        # True clusters
314        colors = ["blue", "green", "orange"]
315        for i, center in enumerate(centers_true):
316            mask = np.arange(len(data)) // n_per_cluster == i
317            fig.add_trace(
318                go.Scatter(
319                    x=data[mask, 0],
320                    y=data[mask, 1],
321                    mode="markers",
322                    marker=dict(color=colors[i], size=6, opacity=0.6),
323                    name=f"True cluster {i}",
324                ),
325                row=1,
326                col=1,
327            )
328
329        fig.add_trace(
330            go.Scatter(
331                x=centers_true[:, 0],
332                y=centers_true[:, 1],
333                mode="markers",
334                marker=dict(color="black", size=15, symbol="x", line=dict(width=3)),
335                name="True centers",
336            ),
337            row=1,
338            col=1,
339        )
340
341        # K-means result
342        colors_km = ["red", "green", "blue"]
343        for i in range(3):
344            mask = result.labels == i
345            fig.add_trace(
346                go.Scatter(
347                    x=data[mask, 0],
348                    y=data[mask, 1],
349                    mode="markers",
350                    marker=dict(color=colors_km[i], size=6, opacity=0.6),
351                    name=f"Cluster {i}",
352                    showlegend=True,
353                ),
354                row=1,
355                col=2,
356            )
357
358        fig.add_trace(
359            go.Scatter(
360                x=result.centers[:, 0],
361                y=result.centers[:, 1],
362                mode="markers",
363                marker=dict(color="black", size=15, symbol="x", line=dict(width=3)),
364                name="K-means centers",
365            ),
366            row=1,
367            col=2,
368        )
369
370        fig.update_xaxes(title_text="x")
371        fig.update_yaxes(title_text="y")
372        fig.update_layout(height=500, width=1000, showlegend=True)
373        fig.write_html(
374            str(OUTPUT_DIR / "gaussian_kmeans.html"),
375            include_plotlyjs="cdn",
376            div_id="gaussian_kmeans",
377        )
378        print("\n  [Plot saved to gaussian_kmeans.html]")
379
380
381def demo_kmeans_plusplus():
382    """Demonstrate K-means++ initialization."""
383    print("\n" + "=" * 70)
384    print("K-Means++ Initialization Demo")
385    print("=" * 70)
386
387    np.random.seed(42)
388
389    # Generate data with 4 well-separated clusters
390    data = np.vstack(
391        [
392            np.random.randn(30, 2) + [0, 0],
393            np.random.randn(30, 2) + [10, 0],
394            np.random.randn(30, 2) + [0, 10],
395            np.random.randn(30, 2) + [10, 10],
396        ]
397    )
398
399    n_clusters = 4
400
401    # Random initialization
402    random_centers = data[np.random.choice(len(data), n_clusters, replace=False)]
403
404    # K-means++ initialization
405    plusplus_centers = kmeans_plusplus_init(data, n_clusters)
406
407    print(f"\nComparing initialization methods for n_clusters={n_clusters}:")
408
409    # Run K-means with each initialization
410    # For random, run multiple times (use init='random' for random init)
411    results_random = []
412    for _ in range(10):
413        idx = np.random.choice(len(data), n_clusters, replace=False)
414        result = kmeans(
415            data, n_clusters=n_clusters, init=data[idx], n_init=1, rng=_rng()
416        )
417        results_random.append(result.inertia)
418
419    result_plusplus = kmeans(
420        data, n_clusters=n_clusters, init=plusplus_centers, n_init=1, rng=_rng()
421    )
422
423    print(f"\n  Random initialization (10 runs):")
424    print(f"    Mean inertia: {np.mean(results_random):.2f}")
425    print(f"    Std inertia: {np.std(results_random):.2f}")
426    print(f"    Best inertia: {np.min(results_random):.2f}")
427
428    print(f"\n  K-means++ initialization:")
429    print(f"    Inertia: {result_plusplus.inertia:.2f}")
430
431    print("\nNote: K-means++ typically provides better, more consistent results.")
432
433
434def demo_elbow_method():
435    """Demonstrate elbow method for K selection."""
436    print("\n" + "=" * 70)
437    print("Elbow Method Demo")
438    print("=" * 70)
439
440    np.random.seed(42)
441
442    # Generate data with 3 true clusters
443    data = np.vstack(
444        [
445            np.random.randn(40, 2) + [0, 0],
446            np.random.randn(40, 2) + [4, 4],
447            np.random.randn(40, 2) + [8, 0],
448        ]
449    )
450
451    print(f"\nData: 120 points from 3 true clusters")
452    print("\nInertia for different K values:")
453    print("-" * 40)
454
455    elbow_result = kmeans_elbow(data, k_range=range(1, 8), rng=_rng())
456    k_values = elbow_result["k_values"]
457    inertias = elbow_result["inertias"]
458
459    for k, inertia in zip(k_values, inertias):
460        bar = "#" * int(inertia / max(inertias) * 30)
461        print(f"  K={k}: {inertia:>8.1f} {bar}")
462
463    print("\nThe 'elbow' should appear around K=3")
464    print("(where adding more clusters gives diminishing returns)")
465
466    # Plot elbow method
467    if SHOW_PLOTS:
468        fig = go.Figure()
469
470        fig.add_trace(
471            go.Scatter(
472                x=list(k_values),
473                y=inertias,
474                mode="lines+markers",
475                line=dict(color="blue", width=2),
476                marker=dict(size=10),
477                name="Inertia",
478            )
479        )
480
481        fig.add_vline(
482            x=3, line_dash="dash", line_color="red", annotation_text="True K=3"
483        )
484
485        fig.update_layout(
486            title="Elbow Method for K Selection",
487            xaxis_title="Number of Clusters (K)",
488            yaxis_title="Inertia (Within-cluster Sum of Squares)",
489            height=500,
490            width=700,
491            showlegend=True,
492        )
493        fig.write_html(
494            str(OUTPUT_DIR / "gaussian_elbow.html"),
495            include_plotlyjs="cdn",
496            div_id="gaussian_elbow",
497        )
498        print("\n  [Plot saved to gaussian_elbow.html]")
499
500
501def demo_dbscan():
502    """Demonstrate DBSCAN clustering."""
503    print("\n" + "=" * 70)
504    print("DBSCAN Clustering Demo")
505    print("=" * 70)
506
507    np.random.seed(42)
508
509    # Generate data: two dense clusters + noise
510    cluster1 = np.random.randn(50, 2) * 0.5 + [0, 0]
511    cluster2 = np.random.randn(50, 2) * 0.5 + [4, 4]
512    noise = np.random.uniform(-2, 8, (20, 2))  # Scattered noise points
513
514    data = np.vstack([cluster1, cluster2, noise])
515
516    print(f"\nData: 100 cluster points + 20 noise points")
517
518    # DBSCAN clustering
519    result = dbscan(data, eps=0.8, min_samples=5)
520
521    print(f"\nDBSCAN result (eps=0.8, min_samples=5):")
522    print(f"  Clusters found: {result.n_clusters}")
523    print(f"  Core sample indices: {len(result.core_sample_indices)}")
524    print(f"  Noise points: {result.n_noise}")
525
526    # Cluster sizes
527    unique_labels = np.unique(result.labels)
528    for label in unique_labels:
529        count = np.sum(result.labels == label)
530        if label == -1:
531            print(f"  Noise points: {count}")
532        else:
533            print(f"  Cluster {label}: {count} points")
534
535    print("\nNote: DBSCAN identifies noise points (label=-1)")
536    print("and doesn't require specifying the number of clusters.")
537
538    # Plot DBSCAN result
539    if SHOW_PLOTS:
540        fig = make_subplots(
541            rows=1,
542            cols=2,
543            subplot_titles=[
544                "Ground Truth",
545                f"DBSCAN Result ({result.n_clusters} clusters)",
546            ],
547        )
548
549        # Ground truth
550        fig.add_trace(
551            go.Scatter(
552                x=cluster1[:, 0],
553                y=cluster1[:, 1],
554                mode="markers",
555                marker=dict(color="blue", size=8, opacity=0.6),
556                name="Cluster 1",
557            ),
558            row=1,
559            col=1,
560        )
561
562        fig.add_trace(
563            go.Scatter(
564                x=cluster2[:, 0],
565                y=cluster2[:, 1],
566                mode="markers",
567                marker=dict(color="green", size=8, opacity=0.6),
568                name="Cluster 2",
569            ),
570            row=1,
571            col=1,
572        )
573
574        fig.add_trace(
575            go.Scatter(
576                x=noise[:, 0],
577                y=noise[:, 1],
578                mode="markers",
579                marker=dict(color="red", size=8, opacity=0.6),
580                name="Noise",
581            ),
582            row=1,
583            col=1,
584        )
585
586        # DBSCAN result
587        colors = ["blue", "green", "purple", "orange"]
588        for label in unique_labels:
589            mask = result.labels == label
590            if label == -1:
591                fig.add_trace(
592                    go.Scatter(
593                        x=data[mask, 0],
594                        y=data[mask, 1],
595                        mode="markers",
596                        marker=dict(color="red", size=8, opacity=0.6, symbol="x"),
597                        name="Noise",
598                        showlegend=True,
599                    ),
600                    row=1,
601                    col=2,
602                )
603            else:
604                fig.add_trace(
605                    go.Scatter(
606                        x=data[mask, 0],
607                        y=data[mask, 1],
608                        mode="markers",
609                        marker=dict(
610                            color=colors[label % len(colors)], size=8, opacity=0.6
611                        ),
612                        name=f"Cluster {label}",
613                        showlegend=True,
614                    ),
615                    row=1,
616                    col=2,
617                )
618
619        # Mark core samples
620        core_mask = np.zeros(len(data), dtype=bool)
621        core_mask[result.core_sample_indices] = True
622        fig.add_trace(
623            go.Scatter(
624                x=data[core_mask, 0],
625                y=data[core_mask, 1],
626                mode="markers",
627                marker=dict(
628                    color="rgba(0,0,0,0)", size=15, line=dict(color="black", width=1)
629                ),
630                name="Core samples",
631                showlegend=True,
632            ),
633            row=1,
634            col=2,
635        )
636
637        fig.update_xaxes(title_text="x")
638        fig.update_yaxes(title_text="y")
639        fig.update_layout(height=500, width=1000, showlegend=True)
640        fig.write_html(
641            str(OUTPUT_DIR / "gaussian_dbscan.html"),
642            include_plotlyjs="cdn",
643            div_id="gaussian_dbscan",
644        )
645        print("\n  [Plot saved to gaussian_dbscan.html]")
646
647
648def demo_hierarchical():
649    """Demonstrate hierarchical clustering."""
650    print("\n" + "=" * 70)
651    print("Hierarchical Clustering Demo")
652    print("=" * 70)
653
654    np.random.seed(42)
655
656    # Generate small dataset for visualization
657    data = np.array(
658        [
659            [0, 0],
660            [0.5, 0.5],
661            [1, 0],  # Cluster A
662            [5, 5],
663            [5.5, 5.5],
664            [5, 6],  # Cluster B
665            [2.5, 2.5],  # Between clusters
666        ]
667    )
668
669    print(f"\nData points:\n{data}")
670
671    # Compute distance matrix
672    dist_matrix = compute_distance_matrix(data)
673    print(f"\nDistance matrix:\n{np.round(dist_matrix, 2)}")
674
675    # Agglomerative clustering
676    result = agglomerative_clustering(data, linkage="average")
677
678    print("\nHierarchical clustering (average linkage):")
679    print(f"  Labels: {result.labels}")
680    print(f"  Number of clusters: {result.n_clusters}")
681
682    # Cut at different thresholds
683    n_samples = len(data)
684    for threshold in [1.0, 3.0, 5.0]:
685        labels = cut_dendrogram(
686            result.linkage_matrix, n_samples, distance_threshold=threshold
687        )
688        n_clusters = len(set(labels))
689        print(
690            f"  Threshold {threshold:.1f}: {n_clusters} clusters, labels={list(labels)}"
691        )
692
693
694def demo_tracking_application():
695    """Demonstrate mixture reduction in tracking context."""
696    print("\n" + "=" * 70)
697    print("Tracking Application Demo")
698    print("=" * 70)
699
700    np.random.seed(42)
701
702    # Simulated PHD filter output: mixture representing target density
703    # After several updates, the mixture can have many components
704
705    # True targets at these locations
706    true_targets = np.array(
707        [
708            [10.0, 20.0],
709            [30.0, 40.0],
710            [25.0, 25.0],
711        ]
712    )
713
714    print(f"\nTrue target positions: {len(true_targets)} targets")
715    for i, t in enumerate(true_targets):
716        print(f"  Target {i + 1}: ({t[0]:.1f}, {t[1]:.1f})")
717
718    # Create mixture with components clustered around true targets
719    # Plus some spurious components (false alarms, etc.)
720    components = []
721
722    # Components near true targets (higher weight)
723    for target in true_targets:
724        for _ in range(4):
725            mean = target + np.random.randn(2) * 1.0
726            cov = np.eye(2) * (0.5 + np.random.rand() * 0.5)
727            weight = 0.6 + np.random.rand() * 0.4
728            components.append(GaussianComponent(weight, mean, cov))
729
730    # Spurious components (lower weight)
731    for _ in range(8):
732        mean = np.random.uniform(5, 45, 2)
733        cov = np.eye(2) * 2.0
734        weight = 0.05 + np.random.rand() * 0.1
735        components.append(GaussianComponent(weight, mean, cov))
736
737    print(f"\nPHD mixture: {len(components)} components")
738    total_weight = sum(c.weight for c in components)
739    print(f"  Total weight (expected target count): {total_weight:.2f}")
740
741    # Reduce to extract target estimates
742    n_expected = int(round(total_weight))
743    reduced = reduce_mixture_runnalls(components, n_expected)
744
745    print(f"\nAfter reduction to {n_expected} components:")
746    for i, c in enumerate(reduced.components):
747        # Find closest true target
748        dists = [np.linalg.norm(c.mean - t) for t in true_targets]
749        closest = np.argmin(dists)
750        error = min(dists)
751        print(
752            f"  Estimate {i + 1}: ({c.mean[0]:.1f}, {c.mean[1]:.1f}), "
753            f"weight={c.weight:.2f}, error to target {closest + 1}={error:.2f}"
754        )
755
756
757def main():
758    """Run all demonstrations."""
759    # Seed once for the whole run. Individual demos below reseed, but
760    # not all of them do, and the figures are committed under
761    # docs/_static -- an unseeded draw makes every rebuild produce a
762    # different file and a spurious diff.
763    np.random.seed(42)
764    print("\n" + "#" * 70)
765    print("# PyTCL Gaussian Mixtures and Clustering Example")
766    print("#" * 70)
767
768    # Gaussian mixture operations
769    demo_gaussian_components()
770    demo_moment_matching()
771    demo_mixture_merging()
772    demo_mixture_reduction()
773
774    # Clustering algorithms
775    demo_kmeans()
776    demo_kmeans_plusplus()
777    demo_elbow_method()
778    demo_dbscan()
779    demo_hierarchical()
780
781    # Application
782    demo_tracking_application()
783
784    print("\n" + "=" * 70)
785    print("Example complete!")
786    if SHOW_PLOTS:
787        print(
788            "Plots saved: gaussian_kmeans.html, gaussian_elbow.html, gaussian_dbscan.html"
789        )
790    print("=" * 70)
791
792
793if __name__ == "__main__":
794    main()

Running the Example

python examples/gaussian_mixtures.py

See Also