Gaussian Mixture Models & EM Algorithm
Derive the Expectation-Maximization algorithm for Gaussian Mixture Models from first principles, Jensen's inequality on the evidence lower bound, and zero-dependency implementations in Python and Rust.
While k-Means forces hard binary assignments () and assumes isotropic spherical clusters, Gaussian Mixture Models (GMMs) provide a soft, probabilistic framework.
Each cluster is represented by a multivariate Gaussian density characterized by its own mean and covariance matrix , allowing clusters to capture arbitrary elliptical orientations and variances.
Optimal Gaussian mixture weights equal the normalized sum of sample responsibilities.
1. Probability Density Formulation#
A Gaussian Mixture Model represents the probability distribution of continuous feature vector as a convex combination of Gaussian components:
Where:
- is the mixture weight (prior probability) of component , subject to .
- is the multivariate Gaussian probability density function:
The Incomplete-Data Log-Likelihood Dilemma#
For independent observations , the log-likelihood function is:
Notice that the summation over components appears inside the natural logarithm. Because , setting the gradient of to zero yields no closed-form analytical solution.
2. Derivation of the Expectation-Maximization (EM) Algorithm#
To circumvent the log-sum intractability, Dempster, Laird, and Rubin introduced the Expectation-Maximization (EM) algorithm by positing unobserved latent categorical variables , where indicates that sample was generated by component .
The complete-data log-likelihood (if latent assignments were known) is:
Because is unobserved, we optimize the expected complete log-likelihood with respect to the posterior distribution of .
Step 1: The Expectation Step (E-Step)
Compute the posterior probability (responsibility) that Gaussian component generated observation , given current parameter estimates :
Applying Bayes' Theorem:
Step 2: Formulate the Auxiliary Q-Function
The expected value of the complete-data log-likelihood is:
Step 3: The Maximization Step (M-Step) for Means $\mu_k$
Differentiate with respect to vector :
Multiply by and isolate :
Let denote the effective number of data points assigned to cluster :
Step 4: The Maximization Step for Covariance $\Sigma_k$
Similarly, setting the matrix derivative with respect to to zero:
Step 5: The Maximization Step for Mixture Weights $\pi_k$
To optimize subject to , we introduce Lagrange multiplier :
Differentiating with respect to :
Summing over all components:
Therefore:
3. From-Scratch Implementation: Zero Dependencies#
The following implementations construct an Expectation-Maximization Gaussian Mixture Model (with diagonal covariance matrices) from scratch in pure Python and pure Rust.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798import math import random from typing import List, Tuple class GaussianMixtureFromScratch: def __init__(self, k: int = 2, max_iter: int = 100, tol: float = 1e-4): self.k = k self.max_iter = max_iter self.tol = tol self.weights: List[float] = [] self.means: List[List[float]] = [] self.variances: List[List[float]] = [] @staticmethod def _gaussian_pdf(x: List[float], mean: List[float], var: List[float]) -> float: # Diagonal covariance multivariate Gaussian prob = 1.0 for x_j, mu_j, v_j in zip(x, mean, var): v_safe = max(v_j, 1e-6) coeff = 1.0 / math.sqrt(2.0 * math.pi * v_safe) exp_term = math.exp(-((x_j - mu_j) ** 2) / (2.0 * v_safe)) prob *= (coeff * exp_term) return max(prob, 1e-12) def fit(self, X: List[List[float]]) -> None: num_samples = len(X) num_features = len(X[0]) random.seed(42) # 1. Initialize weights, means, and variances self.weights = [1.0 / self.k] * self.k self.means = [list(sample) for sample in random.sample(X, self.k)] # Initialize variances to global feature variance global_var = [ sum((row[j] - sum(r[j] for r in X) / num_samples) ** 2 for row in X) / num_samples for j in range(num_features) ] self.variances = [[max(v, 0.1) for v in global_var] for _ in range(self.k)] prev_ll = -float("inf") for _ in range(self.max_iter): # E-Step: calculate responsibilities gamma[i][k] gamma: List[List[float]] = [] log_likelihood = 0.0 for i in range(num_samples): responsibilities_unnorm = [ self.weights[k] * self._gaussian_pdf(X[i], self.means[k], self.variances[k]) for k in range(self.k) ] total = sum(responsibilities_unnorm) log_likelihood += math.log(max(total, 1e-12)) gamma.append([val / total for val in responsibilities_unnorm]) # Check log-likelihood convergence if abs(log_likelihood - prev_ll) < self.tol: break prev_ll = log_likelihood # M-Step: update parameters for k in range(self.k): N_k = sum(gamma[i][k] for i in range(num_samples)) self.weights[k] = N_k / num_samples # Update mean for j in range(num_features): self.means[k][j] = sum(gamma[i][k] * X[i][j] for i in range(num_samples)) / N_k # Update diagonal variance for j in range(num_features): self.variances[k][j] = sum( gamma[i][k] * ((X[i][j] - self.means[k][j]) ** 2) for i in range(num_samples) ) / N_k def predict_proba(self, X: List[List[float]]) -> List[List[float]]: probs = [] for row in X: unnorm = [ self.weights[k] * self._gaussian_pdf(row, self.means[k], self.variances[k]) for k in range(self.k) ] total = sum(unnorm) probs.append([u / total for u in unnorm]) return probs if __name__ == "__main__": X = [ [0.8, 1.0], [1.2, 0.9], [1.0, 1.1], [5.0, 5.2], [5.2, 4.9], [4.8, 5.0] ] gmm = GaussianMixtureFromScratch(k=2) gmm.fit(X) print("Learned Means:", gmm.means) print("Cluster Probabilities for first sample:", gmm.predict_proba([X[0]]))
4. Active Knowledge Checks#
Why can the incomplete-data log-likelihood of a Gaussian Mixture Model not be optimized directly using analytical derivatives?
In the Expectation-Maximization algorithm, how does the E-step calculate the responsibility gamma_ik?