Skip to content

Commit bd7de3b

Browse files
Add Gaussian Mixture Model (GMM) Algorithm for Clustering (#13637)
* Add Gaussian Mixture Model (GMM) algorithm * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add trailing newline and finalize Gaussian Mixture Model implementation * Fix Mypy type alias issue for FloatArray * Use modern 'type' syntax for FloatArray to satisfy Ruff UP040 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 167c924 commit bd7de3b

1 file changed

Lines changed: 297 additions & 0 deletions

File tree

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
"""
2+
README, Author - Md Ruman Islam (mailto:ruman23.github.io)
3+
Requirements:
4+
- numpy
5+
- matplotlib
6+
Python:
7+
- 3.8+
8+
Inputs:
9+
- data : a 2D numpy array of features.
10+
- n_components : number of Gaussian distributions (clusters) to fit.
11+
- max_iter : maximum number of EM iterations.
12+
- tol : convergence tolerance.
13+
Usage:
14+
1. define 'n_components' value and 'data' features array
15+
2. initialize model:
16+
gmm = GaussianMixture(n_components=3, max_iter=100)
17+
3. fit model to data:
18+
gmm.fit(data)
19+
4. get cluster predictions:
20+
labels = gmm.predict(data)
21+
5. visualize results:
22+
gmm.plot_results(data)
23+
"""
24+
25+
import warnings
26+
27+
import matplotlib.pyplot as plt
28+
import numpy as np
29+
from numpy.typing import NDArray
30+
from scipy.stats import multivariate_normal
31+
32+
warnings.filterwarnings("ignore")
33+
34+
TAG = "GAUSSIAN-MIXTURE/ "
35+
36+
37+
class GaussianMixture:
38+
"""
39+
Gaussian Mixture Model implemented using the Expectation-Maximization algorithm.
40+
"""
41+
42+
def __init__(
43+
self,
44+
n_components: int = 2,
45+
max_iter: int = 100,
46+
tol: float = 1e-4,
47+
seed: int | None = None,
48+
) -> None:
49+
self.n_components: int = n_components
50+
self.max_iter: int = max_iter
51+
self.tol: float = tol
52+
self.seed: int | None = seed
53+
54+
# parameters
55+
self.weights_: NDArray[np.float64] | None = None
56+
self.means_: NDArray[np.float64] | None = None
57+
self.covariances_: NDArray[np.float64] | None = None
58+
self.log_likelihoods_: list[float] = []
59+
60+
def _initialize_parameters(self, data: NDArray[np.float64]) -> None:
61+
"""Randomly initialize means, covariances, and mixture weights.
62+
63+
Examples
64+
--------
65+
>>> sample = np.array(
66+
... [[0.0, 0.5], [1.0, 1.5], [2.0, 2.5], [3.0, 3.5]]
67+
... )
68+
>>> model = GaussianMixture(n_components=2, seed=0)
69+
>>> model._initialize_parameters(sample)
70+
>>> model.means_.shape
71+
(2, 2)
72+
>>> bool(np.isclose(model.weights_.sum(), 1.0))
73+
True
74+
"""
75+
rng = np.random.default_rng(self.seed)
76+
n_samples, _ = data.shape
77+
78+
indices = rng.choice(n_samples, self.n_components, replace=False)
79+
self.means_ = data[indices]
80+
81+
identity = np.eye(data.shape[1]) * 1e-6
82+
self.covariances_ = np.array(
83+
[np.cov(data, rowvar=False) + identity for _ in range(self.n_components)]
84+
)
85+
self.weights_ = np.ones(self.n_components) / self.n_components
86+
87+
def _e_step(self, data: NDArray[np.float64]) -> NDArray[np.float64]:
88+
"""Compute responsibilities (posterior probabilities).
89+
90+
Examples
91+
--------
92+
>>> sample = np.array(
93+
... [[0.0, 0.5], [1.0, 1.5], [2.0, 2.5], [3.0, 3.5]]
94+
... )
95+
>>> model = GaussianMixture(n_components=2, seed=0)
96+
>>> model._initialize_parameters(sample)
97+
>>> resp = model._e_step(sample)
98+
>>> resp.shape
99+
(4, 2)
100+
>>> bool(np.allclose(resp.sum(axis=1), 1.0))
101+
True
102+
"""
103+
if self.weights_ is None or self.means_ is None or self.covariances_ is None:
104+
raise ValueError(
105+
"Model parameters must be initialized before running the E-step."
106+
)
107+
108+
n_samples = data.shape[0]
109+
responsibilities = np.zeros((n_samples, self.n_components))
110+
weights = self.weights_
111+
means = self.means_
112+
covariances = self.covariances_
113+
114+
for k in range(self.n_components):
115+
rv = multivariate_normal(
116+
mean=means[k], cov=covariances[k], allow_singular=True
117+
)
118+
responsibilities[:, k] = weights[k] * rv.pdf(data)
119+
120+
# Normalize to get probabilities
121+
responsibilities /= responsibilities.sum(axis=1, keepdims=True)
122+
return responsibilities
123+
124+
def _m_step(
125+
self,
126+
data: NDArray[np.float64],
127+
responsibilities: NDArray[np.float64],
128+
) -> None:
129+
"""Update weights, means, and covariances.
130+
131+
Note: assumes the model parameters are already initialized.
132+
133+
Examples
134+
--------
135+
>>> sample = np.array(
136+
... [[0.0, 0.5], [1.0, 1.5], [2.0, 2.5], [3.0, 3.5]]
137+
... )
138+
>>> model = GaussianMixture(n_components=2, seed=0)
139+
>>> model._initialize_parameters(sample)
140+
>>> resp = model._e_step(sample)
141+
>>> model._m_step(sample, resp)
142+
>>> bool(np.isclose(model.weights_.sum(), 1.0))
143+
True
144+
"""
145+
n_samples, n_features = data.shape
146+
component_counts = responsibilities.sum(axis=0)
147+
148+
self.weights_ = component_counts / n_samples
149+
self.means_ = (responsibilities.T @ data) / component_counts[:, np.newaxis]
150+
151+
if self.covariances_ is None or self.means_ is None:
152+
raise ValueError(
153+
"Model parameters must be initialized before running the M-step."
154+
)
155+
156+
covariances = self.covariances_
157+
means = self.means_
158+
159+
for k in range(self.n_components):
160+
diff = data - means[k]
161+
covariances[k] = (responsibilities[:, k][:, np.newaxis] * diff).T @ diff
162+
covariances[k] /= component_counts[k]
163+
# Add small regularization term for numerical stability
164+
covariances[k] += np.eye(n_features) * 1e-6
165+
166+
def _compute_log_likelihood(self, data: NDArray[np.float64]) -> float:
167+
"""Compute total log-likelihood of the model.
168+
169+
Note: assumes the model parameters are already initialized.
170+
171+
Examples
172+
--------
173+
>>> sample = np.array(
174+
... [[0.0, 0.5], [1.0, 1.5], [2.0, 2.5], [3.0, 3.5]]
175+
... )
176+
>>> model = GaussianMixture(n_components=2, seed=0)
177+
>>> model._initialize_parameters(sample)
178+
>>> bool(np.isfinite(model._compute_log_likelihood(sample)))
179+
True
180+
"""
181+
if self.weights_ is None or self.means_ is None or self.covariances_ is None:
182+
raise ValueError(
183+
"Model parameters must be initialized before computing likelihood."
184+
)
185+
186+
n_samples = data.shape[0]
187+
total_pdf = np.zeros((n_samples, self.n_components))
188+
weights = self.weights_
189+
means = self.means_
190+
covariances = self.covariances_
191+
192+
for k in range(self.n_components):
193+
rv = multivariate_normal(
194+
mean=means[k], cov=covariances[k], allow_singular=True
195+
)
196+
total_pdf[:, k] = weights[k] * rv.pdf(data)
197+
198+
log_likelihood = np.sum(np.log(np.sum(total_pdf, axis=1) + 1e-12))
199+
return log_likelihood
200+
201+
def fit(self, data: NDArray[np.float64]) -> None:
202+
"""Fit the Gaussian Mixture Model to data using the EM algorithm.
203+
204+
Examples
205+
--------
206+
>>> sample = np.array(
207+
... [[0.0, 0.5], [1.0, 1.5], [2.0, 2.5], [3.0, 3.5]]
208+
... )
209+
>>> model = GaussianMixture(n_components=2, max_iter=5, tol=1e-3, seed=0)
210+
>>> model.fit(sample) # doctest: +ELLIPSIS
211+
GAUSSIAN-MIXTURE/ ...
212+
>>> len(model.log_likelihoods_) > 0
213+
True
214+
"""
215+
self._initialize_parameters(data)
216+
217+
prev_log_likelihood = None
218+
219+
for i in range(self.max_iter):
220+
# E-step
221+
responsibilities = self._e_step(data)
222+
223+
# M-step
224+
self._m_step(data, responsibilities)
225+
226+
# Log-likelihood
227+
log_likelihood = self._compute_log_likelihood(data)
228+
self.log_likelihoods_.append(log_likelihood)
229+
230+
if (
231+
prev_log_likelihood is not None
232+
and abs(log_likelihood - prev_log_likelihood) < self.tol
233+
):
234+
print(f"{TAG}Converged at iteration {i}.")
235+
break
236+
prev_log_likelihood = log_likelihood
237+
238+
print(f"{TAG}Training complete. Final log-likelihood: {log_likelihood:.4f}")
239+
240+
def predict(self, data: NDArray[np.float64]) -> NDArray[np.int_]:
241+
"""Predict cluster assignment for each data point.
242+
243+
Note: assumes the model parameters are already initialized.
244+
245+
Examples
246+
--------
247+
>>> sample = np.array(
248+
... [[0.0, 0.5], [1.0, 1.5], [2.0, 2.5], [3.0, 3.5]]
249+
... )
250+
>>> model = GaussianMixture(n_components=2, max_iter=5, tol=1e-3, seed=0)
251+
>>> model.fit(sample) # doctest: +ELLIPSIS
252+
GAUSSIAN-MIXTURE/ ...
253+
>>> labels = model.predict(sample)
254+
>>> labels.shape
255+
(4,)
256+
"""
257+
responsibilities = self._e_step(data)
258+
return np.argmax(responsibilities, axis=1)
259+
260+
def plot_results(self, data: NDArray[np.float64]) -> None:
261+
"""Visualize GMM clustering results (2D only).
262+
263+
Note: This method assumes self.means_ is initialized.
264+
265+
Examples
266+
--------
267+
>>> sample = np.ones((3, 3))
268+
>>> model = GaussianMixture()
269+
>>> model.plot_results(sample)
270+
GAUSSIAN-MIXTURE/ Plotting only supported for 2D data.
271+
"""
272+
if data.shape[1] != 2:
273+
print(f"{TAG}Plotting only supported for 2D data.")
274+
return
275+
276+
labels = self.predict(data)
277+
if self.means_ is None:
278+
raise ValueError("Model means must be initialized before plotting.")
279+
plt.scatter(data[:, 0], data[:, 1], c=labels, cmap="viridis", s=30)
280+
plt.scatter(self.means_[:, 0], self.means_[:, 1], c="red", s=100, marker="x")
281+
plt.title("Gaussian Mixture Model Clustering")
282+
plt.xlabel("Feature 1")
283+
plt.ylabel("Feature 2")
284+
plt.show()
285+
286+
287+
# Mock test
288+
if __name__ == "__main__":
289+
from sklearn.datasets import make_blobs
290+
291+
sample_data, _ = make_blobs(
292+
n_samples=300, centers=3, cluster_std=1.2, random_state=42
293+
)
294+
gmm = GaussianMixture(n_components=3, max_iter=100, seed=42)
295+
gmm.fit(sample_data)
296+
labels = gmm.predict(sample_data)
297+
gmm.plot_results(sample_data)

0 commit comments

Comments
 (0)