Expectation Maximization and Gaussian Mixture Models from First Principles and Python
Introduction
Expectation Maximization (EM) is one of the most elegant optimization algorithms in machine learning.
It appears everywhere:
-
Gaussian Mixture Models (GMMs)
-
Hidden Markov Models
-
Latent Class Analysis
-
Missing data estimation
-
Topic models
-
Variational inference foundations
In this tutorial, we will:
-
Build EM for Gaussian Mixture Models from scratch
-
Derive the mathematics carefully
-
Implement every step manually in NumPy
-
Reinforce everything using scikit-learn
-
Analyze likelihoods, AIC, BIC, responsibilities, and latent profiles
-
Visualize hidden structure in physiological sleep data
The tutorial uses WHOOP biometric sleep data (Yoo et al., 2021) and demonstrates real probabilistic clustering.
Why EM Exists
Suppose your data comes from multiple hidden populations.
For example:
-
athletes recovering well
-
stressed individuals
-
sleep-deprived individuals
But you do not know which row belongs to which group.
Instead of hard labels, we assume:
\[z_i \in {1,2,\dots,K}\]where:
-
\(z_i\) is a hidden latent variable
-
\(K\) is the number of hidden clusters
We only observe:
\[x_i\]The hidden labels are missing.
That is precisely where EM shines.
Gaussian Mixture Models
A Gaussian Mixture Model assumes data is generated from:
\[p(x_i) = \sum_{k=1}^{K} \pi_k \mathcal{N}(x_i \mid \mu_k, \Sigma_k)\]Where:
-
\(\pi_k\) = mixing weight
-
\(\mu_k\) = cluster mean
-
\(\Sigma_k\) = covariance matrix
The constraints are:
\[\sum_{k=1}^{K} \pi_k = 1\]and
\[0 \le \pi_k \le 1\]The Gaussian Density
The multivariate Gaussian density is:
\[\mathcal{N}(x\mid\mu,\Sigma)=\frac{1}{\sqrt{(2\pi)^d|\Sigma|}}\exp\left(-\frac12(x-\mu)^T\Sigma^{-1}(x-\mu)\right)\]This equation defines the probability density of a point under a Gaussian distribution.
Key pieces:
-
determinant $$ \Sigma $$ -
inverse covariance \(\Sigma^{-1}\)
- Mahalanobis distance
Dataset
We use biometric sleep features:
features = [
"rhr",
"hrv",
"hours.of.sleep",
"sleep.disturbances",
"latency.min",
]
These represent:
-
resting heart rate
-
heart rate variability
-
total sleep duration
-
disturbances
-
sleep latency
Loading the Data
import pandas as pd
url = "https://raw.githubusercontent.com/OpenNeuroDatasets/ds003823/main/phenotype/Whoop_Summary.tsv"
df = pd.read_csv(url, sep="\t")
display(df.head())
Preparing the Matrix
We define:
\[X \in \mathbb{R}^{n \times d}\]where:
-
\(n\) is the number of observations
-
\(d\) is the number of features
import numpy as np
X = df[
[
"rhr",
"hrv",
"hours.of.sleep",
"sleep.disturbances",
"latency.min"
]
].dropna().to_numpy()
Why Standardization Matters
Gaussian mixtures are sensitive to scale.
Without normalization:
-
HRV may dominate
-
sleep latency may become irrelevant
-
covariance estimation becomes unstable
We standardize:
\[x_{scaled} = \frac{x - \mu}{\sigma}\]X_scaled = (X - X.mean(axis=0)) / X.std(axis=0)
EM Step 1 – Initialization
We initialize:
-
means
-
covariance matrices
-
mixing weights
K = 3
n, d = X_scaled.shape
random_rows = np.random.choice(n, K, replace=False)
mu = X_scaled[random_rows]
Sigma = np.array([np.eye(d) for _ in range(K)])
pi = np.ones(K) / K
Initially:
\[\Sigma_k = I\]and
\[\pi_k = \frac1K\]Building the Gaussian PDF
def gaussian_pdf(x, mu, Sigma):
d = len(mu)
Sigma = Sigma + 1e-6 * np.eye(d)
det = np.linalg.det(Sigma)
inv = np.linalg.inv(Sigma)
diff = x - mu
return (
1 / np.sqrt((2 * np.pi) ** d * det)
* np.exp(-0.5 * diff.T @ inv @ diff)
)
Notice the stabilization trick:
Sigma + 1e-6 * np.eye(d)
This prevents singular covariance matrices.
The E-Step
The E-step computes responsibilities:
\[r_{ik} = P(z_i = k \mid x_i)\]Using Bayes theorem:
\[r_{ik}=\frac{\pi_k\mathcal{N}(x_i\mid\mu_k,\Sigma_k)}{\sum_{j=1}^K\pi_j\mathcal{N}(x_i\mid\mu_j,\Sigma_j)}\]Responsibilities are soft cluster assignments.
E-Step in Code
r = np.zeros((n, K))
for i in range(n):
for k in range(K):
r[i, k] = (
pi[k]
* gaussian_pdf(X_scaled[i], mu[k], Sigma[k])
)
r[i, :] = r[i, :] / r[i, :].sum()
Each row sums to 1:
\[\sum_k r_{ik} = 1\]The M-Step
Now we update parameters using weighted statistics.
Updating Means
\[\mu_k = \frac{ \sum_i r_{ik}x_i }{ \sum_i r_{ik} }\]mu[k] = np.sum(
r[:, k].reshape(-1, 1) * X_scaled,
axis=0
) / N_k[k]
Updating Covariances
\[\Sigma_k = \frac{ \sum_i r_{ik}(x_i-\mu_k)(x_i-\mu_k)^T }{ N_k }\]Sigma[k] = np.zeros((d, d))
for i in range(n):
diff = (X_scaled[i] - mu[k]).reshape(-1, 1)
Sigma[k] += r[i, k] * (diff @ diff.T)
Sigma[k] = Sigma[k] / N_k[k]
Updating Mixing Weights
\[\pi_k = \frac{N_k}{n}\]where
\[N_k = \sum_i r_{ik}\]pi[k] = N_k[k] / n
Log Likelihood
EM maximizes the log likelihood:
\[\log L=\sum_{i=1}^n\log\left(\sum_{k=1}^K\pi_k\mathcal{N}(x_i\mid\mu_k,\Sigma_k)\right)\]Why EM Monotonically Improves Likelihood
EM maximizes a lower bound on the log-likelihood using latent-variable expectations.
The complete-data likelihood is:
\[\log p(X, Z \mid \theta)\]Since \(Z\) is unobserved, EM optimizes the expected complete-data log likelihood:
\[Q(\theta \mid \theta^{old}) \mathbb{E}_{Z \mid X,\theta^{old}} \left[ \log p(X,Z \mid \theta) \right]\]The E-step computes the expectation.
The M-step maximizes this expectation with respect to \(\theta\).
This guarantees non-decreasing likelihood at every iteration.
Computing It
log_likelihood = 0
for i in range(n):
total = 0
for k in range(K):
total += (
pi[k]
* gaussian_pdf(X_scaled[i], mu[k], Sigma[k])
)
log_likelihood += np.log(total)
Full EM Loop
for iteration in range(max_iter):
# E-step
...
# M-step
...
# log likelihood
...
if abs(log_likelihood - old_log_likelihood) < tol:
break
EM alternates between:
-
estimating hidden variables
-
optimizing parameters
until convergence.
Final Cluster Assignments
clusters = np.argmax(r, axis=1)
This converts soft assignments into hard labels.
Geometric Interpretation
K-Means partitions space using hard Voronoi boundaries.
Gaussian Mixture Models instead learn probability densities with full covariance structure.
This allows:
-
elliptical clusters
-
rotated clusters
-
anisotropic variance
-
uncertainty estimation
The covariance matrix determines:
-
cluster orientation
-
cluster spread
-
feature correlation
Why Scikit-Learn Matters
Writing EM manually teaches the mathematics.
But production systems need:
-
numerical stability
-
vectorization
-
optimized covariance estimation
-
convergence diagnostics
-
multiple random restarts
That is where scikit-learn GaussianMixture becomes invaluable.
Using Scikit-Learn GaussianMixture
from sklearn.mixture import GaussianMixture
gmm = GaussianMixture(
n_components=3,
covariance_type="full",
random_state=42,
n_init=20
)
gmm.fit(X_scaled)
This internally performs EM.
Reinforcing the Mathematics
Scikit-learn exposes the exact quantities we derived manually.
Means
gmm.means_
Corresponds to:
\[\mu_k\]Covariances
gmm.covariances_
Corresponds to:
\[\Sigma_k\]Mixing Weights
gmm.weights_
Corresponds to:
\[\pi_k\]Responsibilities
gmm.predict_proba(X_scaled)
Corresponds to:
\[r_{ik}\]Choosing the Number of Clusters
A huge issue in latent-variable modeling is selecting \(K\).
Too small:
- underfitting
Too large:
- overfitting
We use:
-
AIC
-
BIC
Akaike Information Criterion
\[AIC = 2p - 2\log L\]Where:
-
\(p\) = number of parameters
-
\(L\) = likelihood
Bayesian Information Criterion
\[BIC = p\log(n) - 2\log L\]BIC penalizes complexity more aggressively.
Testing Different Numbers of Clusters
results = []
for k in range(1,10):
gmm = GaussianMixture(
n_components=k,
covariance_type="full",
random_state=42,
n_init=20
)
gmm.fit(X_scaled)
results.append({
"k": k,
"bic": gmm.bic(X_scaled),
"aic": gmm.aic(X_scaled)
})
Plotting BIC
results_df.plot(x='k', y='bic')
The optimal model often appears near the minimum BIC.
Manual Likelihood Verification
One of the best ways to truly understand sklearn is to reconstruct its calculations manually.
Computing Component Densities
from scipy.stats import multivariate_normal
component_densities = np.column_stack([
multivariate_normal.pdf(
X_scaled,
mean=means[j],
cov=covariances[j],
allow_singular=True
)
for j in range(k)
])
Weighted Densities
\[\pi_k \mathcal{N}(x_i \mid \mu_k,\Sigma_k)\]weighted_densities = component_densities * weights
Marginal Likelihood
\[p(x_i)=\sum_k \pi_k \mathcal{N}(x_i|\mu_k,\Sigma_k)\]likelihoods = component_densities @ weights
Recovering Responsibilities
probas_manual = (
weighted_densities
/ likelihoods[:, None]
)
This should match:
gmm.predict_proba(X_scaled)
exactly.
Parameter Calculations, AIC, and BIC
A Gaussian Mixture Model does not just estimate clusters. It also estimates a large number of parameters. That matters because model selection criteria like AIC and BIC penalize models that become too flexible.
For a GMM with:
\[K = \text{number of mixture components}\] \[d = \text{number of features}\] \[n = \text{number of observations}\]the total number of estimated parameters is:
\[p = (K - 1) + Kd + K\frac{d(d+1)}{2}\]This comes from three parts.
First, the mixture weights:
\[K - 1\]There are \(K\) weights, but they must sum to one:
\[\sum_{k=1}^{K}\pi_k = 1\]So only \(K - 1\) of them are freely estimated.
Second, the means:
\[Kd\]Each component has one mean vector:
\[\mu_k \in \mathbb{R}^d\]So across \(K\) components, there are \(Kd\) mean parameters.
Third, the covariance matrices:
\[K\frac{d(d+1)}{2}\]Each full covariance matrix is symmetric, so instead of estimating \(d^2\) entries, only the diagonal plus the upper or lower triangle must be estimated.
That gives:
\[\frac{d(d+1)}{2}\]covariance parameters per component.
So the full parameter count is:
\[p = (K - 1) + Kd + K\frac{d(d+1)}{2}\]In code, this appears as:
p = k - 1 + k*d + k*((d**2 - d)/2 + d)
The covariance part:
((d**2 - d)/2 + d)
is equivalent to:
\[\frac{d(d+1)}{2}\]because:
\[\frac{d^2-d}{2}+d = \frac{d^2-d+2d}{2} = \frac{d^2+d}{2} = \frac{d(d+1)}{2}\]So the parameter formula is correct for a GMM with full covariance matrices.
Once \(p\) is known, AIC can be computed as:
\[AIC = 2p - 2\log L\]and BIC as:
\[BIC = p\log(n) - 2\log L\]In code:
aic = 2 * p - 2 * log_likelihoods.sum()
bic = p * np.log(n) - 2 * log_likelihoods.sum()
Here:
log_likelihoods.sum()
is the total log likelihood:
\[\log L = \sum_{i=1}^{n}\log p(x_i)\]where:
\[p(x_i) = \sum_{k=1}^{K} \pi_k \mathcal{N}(x_i \mid \mu_k, \Sigma_k)\]The likelihood can be computed manually with:
component_densities = np.column_stack([
multivariate_normal.pdf(
X,
mean=means[j],
cov=covariances[j],
allow_singular=True
)
for j in range(k)
])
weighted_densities = component_densities * weights
likelihoods = component_densities @ weights
log_likelihoods = np.log(likelihoods)
The quantity:
np.mean(log_likelihoods)
matches:
final_gmm.score(X_scaled)
because score returns the average log likelihood per observation.
Meanwhile:
log_likelihoods.sum()
is the quantity used inside AIC and BIC.
Thus, the manual calculations reproduce sklearn’s implementations:
final_gmm.aic(X_scaled)
final_gmm.bic(X_scaled)
AIC and BIC both reward better fit through larger log likelihood, but penalize complexity through \(p\). The difference is that BIC usually penalizes additional parameters more strongly because it uses:
\[p\log(n)\]instead of:
\[2p\]So in practice:
-
lower AIC indicates better expected predictive fit
-
lower BIC indicates better fit after a stronger simplicity penalty
-
if AIC and BIC disagree, BIC often selects fewer mixture components
-
comparing AIC and BIC across different values of \(K\) helps justify the selected number of hidden latent profiles or clusters
Visualizing Hidden Profiles with PCA
from sklearn.decomposition import PCA
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X_scaled)
Scatter Plot
plt.scatter(
X_pca[:,0],
X_pca[:,1],
c=df["sleep_profile"]
)
This projects high-dimensional latent structure into 2D.
Interpreting Profiles
We can summarize cluster means:
profile_summary = (
df.groupby("sleep_profile")[features]
.mean()
)
This reveals latent physiological states.
For example:
| Profile | Characteristics |
|---|---|
| High Recovery | low RHR, high HRV |
| Sleep Debt | low sleep duration |
| Fragmented Sleep | many disturbances |
Heatmaps for Interpretation
plt.imshow(profile_z, aspect="auto")
Heatmaps make latent-variable interpretation dramatically easier.
Why EM Is Powerful
EM solves problems where:
-
labels are missing
-
latent structure exists
-
probabilistic assignment matters
Unlike K-Means:
| K-Means | GMM |
|---|---|
| hard clusters | soft clusters |
| Euclidean distance | probabilistic density |
| spherical assumption | full covariance |
| no uncertainty | uncertainty modeling |
K-Means as a Special Case
K-Means can actually be derived from GMMs when:
\[\Sigma_k = \sigma^2 I\]and
\[\sigma^2 \to 0\]Then soft assignments collapse into hard assignments.
Why Responsibilities Matter
Suppose a datapoint has:
[0.10, 0.85, 0.05]
This means:
-
10% chance profile 0
-
85% chance profile 1
-
5% chance profile 2
That uncertainty is valuable.
Real biology is rarely binary.
Numerical Stability Problems
Real EM implementations face issues:
-
singular covariance matrices
-
underflow
-
exploding likelihoods
-
local optima
Scikit-learn handles these robustly.
That is why reinforcing theory with sklearn is critical.
Complete Libraries Used
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.stats import multivariate_normal
from sklearn.mixture import GaussianMixture
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.decomposition import PCA
Reference
- Yoo, H. J., Nashiro, K., Min, J., Cho, C., Mercer, N., Bachman, S. L., Nasseri, P., Dutt, S., Porat, S., Choi, P., Zhang, Y., Grigoryan, V., Feng, T., Thayer, J. F., Lehrer, P., Chang, C., Stanley, J. A., Head, E., Rouanet, J., … Mather, M. (2021). Heart Rate Variability Biofeedback Training and Emotion Regulation (Version 1.3.5) [Dataset]. OpenNeuro. https://doi.org/10.18112/openneuro.ds003823.v1.3.5