Summary
cc.tl.GaussianMixture accepts random_state, stores it, and never uses it. The attribute is assigned at tl/_gmm.py:97 but is not among the arguments forwarded to super().__init__() at lines 86–95:
super().__init__(
num_components=n_clusters,
covariance_type=covariance_type,
init_strategy=init_strategy,
init_means=init_means,
convergence_tolerance=convergence_tolerance,
covariance_regularization=covariance_regularization,
batch_size=batch_size,
trainer_params=trainer_params,
)
self.n_clusters = n_clusters
self.random_state = random_state # <- stored, then unused
Nothing else in the package reads it (grep -rn random_state src/cellcharter/ finds only the assignment, the Cluster pass-through at _gmm.py:279, and ClusterAutoK computing i + random_state at tl/_autok.py:123 before handing it to this constructor).
This means ClusterAutoK's per-run variation is not seeded either — runs differ only through ambient global RNG state, so best_k is not reproducible.
Why it can't simply be forwarded
torchgmm.bayes.GaussianMixture.__init__ takes no seed argument at all:
num_components, covariance_type, init_strategy, init_means,
convergence_tolerance, covariance_regularization, batch_size, trainer_params
and grep -rn "seed_everything\|manual_seed\|random_state" torchgmm/bayes/gmm/ torchgmm/base/ is empty. So the fix needs explicit seeding of the torch global RNG (kmeans init is what consumes it), not a passthrough.
Reproduction
Self-contained, no data download:
import numpy as np, anndata as ad
from sklearn.datasets import make_blobs
import cellcharter as cc
TRAINER = {"accelerator": "cpu", "enable_progress_bar": False, "enable_model_summary": False}
X, _ = make_blobs(n_samples=1500, n_features=10, centers=8, cluster_std=3.0, random_state=0)
X = X.astype(np.float32)
def fit_labels(seed):
m = cc.tl.GaussianMixture(n_clusters=8, random_state=seed, trainer_params=dict(TRAINER))
m.fit(X)
return np.asarray(m.predict(X))
a, b = fit_labels(0), fit_labels(0)
print("identical:", np.array_equal(a, b), "| differing cells:", int((a != b).sum()), "/", len(a))
adata = ad.AnnData(X=np.zeros((X.shape[0], 2), dtype=np.float32))
adata.obsm["X_rep"] = X
for _ in range(3):
autok = cc.tl.ClusterAutoK(
n_clusters=(2, 10), model_class=cc.tl.GaussianMixture,
model_params={"random_state": 0, "trainer_params": dict(TRAINER)}, max_runs=3,
)
autok.fit(adata, use_rep="X_rep")
print("best_k:", autok.best_k)
Output:
identical: False | differing cells: 1184 / 1500
best_k: 6
best_k: 9
best_k: 9
Two fits with the same random_state disagree on 79% of cells, and ClusterAutoK reports a different best_k on identical repeated runs.
Candidate fix
Seeding before the fit does restore determinism — verified with the same script:
from pytorch_lightning import seed_everything
def fit_seeded(seed):
seed_everything(seed, workers=True, verbose=False)
m = cc.tl.GaussianMixture(n_clusters=8, random_state=seed, trainer_params=dict(TRAINER))
m.fit(X)
return np.asarray(m.predict(X))
identical labels (same seed): True
identical labels (different seed): False
i.e. same seed reproduces exactly, different seeds still explore — which is what ClusterAutoK's repeated runs need.
Calling seed_everything(self.random_state) inside GaussianMixture.fit would fix both classes at once. Worth noting it mutates global RNG state as a side effect; a narrower alternative is a torch.random.fork_rng() block around the fit, seeded from random_state.
One knock-on: ClusterAutoK derives each run's seed as i + random_state (_autok.py:123), so once seeding is live, two ClusterAutoK instances whose random_state differ by less than max_runs would share fits. Deriving per-run seeds from numpy.random.SeedSequence([random_state, i]) avoids that overlap.
Happy to open a PR if the seed_everything-in-fit direction looks right to you.
Environment
cellcharter 0.3.7
torchgmm 0.1.4
torch 2.13.0+cpu
python 3.12.13
Summary
cc.tl.GaussianMixtureacceptsrandom_state, stores it, and never uses it. The attribute is assigned attl/_gmm.py:97but is not among the arguments forwarded tosuper().__init__()at lines 86–95:Nothing else in the package reads it (
grep -rn random_state src/cellcharter/finds only the assignment, theClusterpass-through at_gmm.py:279, andClusterAutoKcomputingi + random_stateattl/_autok.py:123before handing it to this constructor).This means
ClusterAutoK's per-run variation is not seeded either — runs differ only through ambient global RNG state, sobest_kis not reproducible.Why it can't simply be forwarded
torchgmm.bayes.GaussianMixture.__init__takes no seed argument at all:and
grep -rn "seed_everything\|manual_seed\|random_state" torchgmm/bayes/gmm/ torchgmm/base/is empty. So the fix needs explicit seeding of the torch global RNG (kmeans init is what consumes it), not a passthrough.Reproduction
Self-contained, no data download:
Output:
Two fits with the same
random_statedisagree on 79% of cells, andClusterAutoKreports a differentbest_kon identical repeated runs.Candidate fix
Seeding before the fit does restore determinism — verified with the same script:
i.e. same seed reproduces exactly, different seeds still explore — which is what
ClusterAutoK's repeated runs need.Calling
seed_everything(self.random_state)insideGaussianMixture.fitwould fix both classes at once. Worth noting it mutates global RNG state as a side effect; a narrower alternative is atorch.random.fork_rng()block around the fit, seeded fromrandom_state.One knock-on:
ClusterAutoKderives each run's seed asi + random_state(_autok.py:123), so once seeding is live, twoClusterAutoKinstances whoserandom_statediffer by less thanmax_runswould share fits. Deriving per-run seeds fromnumpy.random.SeedSequence([random_state, i])avoids that overlap.Happy to open a PR if the
seed_everything-in-fitdirection looks right to you.Environment