Skip to content

Commit e9301ae

Browse files
feat(clustering): add random_state to KMeans and MiniBatchKMeans
Replaces the hardcoded random_state=42 with a parameter. Default stays at 42 for reproducibility; pass random_state=None for non-deterministic seeding. Adds 3 tests per class covering default-seed reproducibility, explicit-seed reproducibility (labels and centers exact, inertia with rel=1e-5 for float32 BLAS ULPs), and None opt-in. docs/guide/analysis.md gets a short note. 588 CPU tests pass; 81 GPU tests pass under serial pytest-xdist.
1 parent 446f45f commit e9301ae

3 files changed

Lines changed: 67 additions & 2 deletions

File tree

docs/guide/analysis.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,10 @@ result = KMeans(n_clusters=10)(pca.projections)
356356
result = MiniBatchKMeans(n_clusters=10, batch_size=1024)(pca.projections)
357357
result = RegularSpace(dmin=0.5)(pca.projections)
358358

359+
# Seed is configurable -- defaults to 42 for reproducible runs. Pass
360+
# random_state=None to let scikit-learn pick a non-deterministic seed.
361+
result = KMeans(n_clusters=10, random_state=2024)(pca.projections)
362+
359363
# result.labels, result.n_clusters, result.cluster_centers,
360364
# result.medoid_frames, result.inertia
361365
```

src/mdpp/analysis/clustering.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -606,14 +606,19 @@ class KMeans:
606606
607607
Args:
608608
n_clusters: Number of clusters.
609+
random_state: Seed for centroid initialisation. Defaults to 42 for
610+
reproducible runs across sessions. Pass ``None`` to let
611+
scikit-learn pick a non-deterministic seed.
609612
dtype: Output float dtype for *cluster_centers*.
610613
611614
Example::
612615
613616
result = KMeans(n_clusters=10)(pca.projections)
617+
result = KMeans(n_clusters=10, random_state=None)(pca.projections)
614618
"""
615619

616620
n_clusters: int = 10
621+
random_state: int | None = 42
617622
dtype: DtypeArg = None
618623

619624
def __call__(self, features: ArrayLike) -> FeatureClusteringResult:
@@ -627,7 +632,11 @@ def __call__(self, features: ArrayLike) -> FeatureClusteringResult:
627632
if self.n_clusters < 1:
628633
raise ValueError(f"n_clusters must be >= 1, got {self.n_clusters!r}")
629634

630-
km = _SklearnKMeans(n_clusters=self.n_clusters, n_init="auto", random_state=42)
635+
km = _SklearnKMeans(
636+
n_clusters=self.n_clusters,
637+
n_init="auto",
638+
random_state=self.random_state,
639+
)
631640
labels = km.fit_predict(feature_matrix)
632641
centers = np.asarray(km.cluster_centers_)
633642
inertia = float(km.inertia_) # type: ignore[arg-type] # set after fit
@@ -643,15 +652,21 @@ class MiniBatchKMeans:
643652
Args:
644653
n_clusters: Number of clusters.
645654
batch_size: Mini-batch size.
655+
random_state: Seed for centroid initialisation and mini-batch
656+
sampling. Defaults to 42 for reproducible runs across
657+
sessions. Pass ``None`` to let scikit-learn pick a
658+
non-deterministic seed.
646659
dtype: Output float dtype for *cluster_centers*.
647660
648661
Example::
649662
650663
result = MiniBatchKMeans(n_clusters=10, batch_size=1024)(pca.projections)
664+
result = MiniBatchKMeans(n_clusters=10, random_state=None)(pca.projections)
651665
"""
652666

653667
n_clusters: int = 10
654668
batch_size: int = 1024
669+
random_state: int | None = 42
655670
dtype: DtypeArg = None
656671

657672
def __call__(self, features: ArrayLike) -> FeatureClusteringResult:
@@ -671,7 +686,7 @@ def __call__(self, features: ArrayLike) -> FeatureClusteringResult:
671686
n_clusters=self.n_clusters,
672687
batch_size=self.batch_size,
673688
n_init="auto",
674-
random_state=42,
689+
random_state=self.random_state,
675690
)
676691
labels = mbk.fit_predict(feature_matrix)
677692
centers = np.asarray(mbk.cluster_centers_)

tests/analysis/test_clustering.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,33 @@ def test_invalid_n_clusters_raises(self, clustered_features: NDArray[np.floating
637637
with pytest.raises(ValueError, match="n_clusters must be >= 1"):
638638
KMeans(n_clusters=0)(clustered_features)
639639

640+
def test_random_state_default_is_reproducible(
641+
self, clustered_features: NDArray[np.floating]
642+
) -> None:
643+
"""Two runs with the default random_state must produce identical labels."""
644+
a = KMeans(n_clusters=3)(clustered_features)
645+
b = KMeans(n_clusters=3)(clustered_features)
646+
np.testing.assert_array_equal(a.labels, b.labels)
647+
648+
def test_random_state_explicit_seed_is_reproducible(
649+
self, clustered_features: NDArray[np.floating]
650+
) -> None:
651+
"""The same explicit seed must produce identical cluster assignments."""
652+
# Labels and centers must match exactly; inertia is allowed to drift
653+
# by a few ULPs because BLAS reduction order varies across runs even
654+
# at fixed input + seed in float32.
655+
a = KMeans(n_clusters=3, random_state=7)(clustered_features)
656+
b = KMeans(n_clusters=3, random_state=7)(clustered_features)
657+
np.testing.assert_array_equal(a.labels, b.labels)
658+
np.testing.assert_array_equal(a.cluster_centers, b.cluster_centers)
659+
assert a.inertia == pytest.approx(b.inertia, rel=1e-5)
660+
661+
def test_random_state_none_runs(self, clustered_features: NDArray[np.floating]) -> None:
662+
"""random_state=None must not raise and must still recover all clusters."""
663+
result = KMeans(n_clusters=3, random_state=None)(clustered_features)
664+
assert isinstance(result, FeatureClusteringResult)
665+
assert result.n_clusters == 3
666+
640667

641668
# ---------------------------------------------------------------------------
642669
# Feature-vector clustering tests: MiniBatchKMeans
@@ -668,6 +695,25 @@ def test_invalid_batch_size_raises(self, clustered_features: NDArray[np.floating
668695
with pytest.raises(ValueError, match="batch_size must be >= 1"):
669696
MiniBatchKMeans(batch_size=0)(clustered_features)
670697

698+
def test_random_state_explicit_seed_is_reproducible(
699+
self, clustered_features: NDArray[np.floating]
700+
) -> None:
701+
"""The same explicit seed must produce identical cluster assignments."""
702+
# Labels and centers must match exactly; inertia is allowed to drift
703+
# by a few ULPs because BLAS reduction order varies across runs even
704+
# at fixed input + seed in float32.
705+
a = MiniBatchKMeans(n_clusters=3, batch_size=16, random_state=11)(clustered_features)
706+
b = MiniBatchKMeans(n_clusters=3, batch_size=16, random_state=11)(clustered_features)
707+
np.testing.assert_array_equal(a.labels, b.labels)
708+
np.testing.assert_array_equal(a.cluster_centers, b.cluster_centers)
709+
assert a.inertia == pytest.approx(b.inertia, rel=1e-5)
710+
711+
def test_random_state_none_runs(self, clustered_features: NDArray[np.floating]) -> None:
712+
"""random_state=None must not raise and must still recover all clusters."""
713+
result = MiniBatchKMeans(n_clusters=3, random_state=None)(clustered_features)
714+
assert isinstance(result, FeatureClusteringResult)
715+
assert result.n_clusters == 3
716+
671717

672718
# ---------------------------------------------------------------------------
673719
# Feature-vector clustering tests: RegularSpace

0 commit comments

Comments
 (0)