Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A latency dashboard reports a mean of 60 milliseconds across production traffic. The operations dashboard displays a green status, but when you inspect the raw request logs [28, 30, 32, 86, 90, 94], not a single request actually took 60 ms. Three queries finished in roughly 30 ms (in-memory cache hits), while the remaining three stalled near 90 ms (unindexed disk queries).
Fitting a single standard Gaussian to this sample produces a bell curve centered at 60 ms: the exact region where the probability of observing an actual request is near zero. A hard clustering algorithm like K-means assigns each point to a nearest centroid, but it can't quantify how confident it feels about boundary observations. A Gaussian mixture model resolves this by modeling the data as a combination of multiple hidden sub-populations, keeping uncertain membership visible through probability distributions.[1]

Generative versus discriminative modeling
Machine learning systems answer two distinct modeling questions depending on whether they estimate conditional or joint distributions.
Discriminative models estimate the conditional distribution . Given observed features (like request latency or query size), a discriminative classifier determines the probability of class label (such as cache hit versus database fallback). Discriminative models focus strictly on the decision boundary separating classes. If you feed them corrupted inputs or impossible latencies like -500 ms, they still output confident class probabilities because they never modeled what valid inputs look like. They can't generate new data points, evaluate input plausibility, or identify missing features.
Generative models estimate how data came to exist in the world. They learn either the joint distribution or, in the unsupervised setting, the marginal data distribution . By capturing the data-generating mechanism, a generative model evaluates whether a new observation looks probable under normal traffic, generates synthetic samples by sampling from the learned distribution, and accounts for unobserved mechanisms.
In real-world request logs, we don't have labeled class tags telling us which infrastructure component served each query. We only observe raw latencies . To explain multi-modal observations without human labels, generative models introduce unobserved hidden variables: latent variables.
Latent variables and the intractable log-sum
Start with the six observed request latencies: [28, 30, 32, 86, 90, 94]. Their arithmetic mean is 60 ms, but the numbers naturally cluster into two distinct groups.
| Request | Latency | Plausible hidden mechanism |
|---|---|---|
| A | 28 ms | In-memory cache hit |
| B | 30 ms | In-memory cache hit |
| C | 32 ms | In-memory cache hit |
| D | 86 ms | Database disk scan |
| E | 90 ms | Database disk scan |
| F | 94 ms | Database disk scan |
A probabilistic generative model formulates a two-stage story for each observation:

First, nature selects an unobserved latent component with prior probability , where and . Second, conditioned on that choice, nature samples the observable latency from that component's density .
Because the request log only records the observed values while the latent choices remain hidden, evaluating the probability of an observed point requires marginalizing over all possible latent assignments:
Assuming requests arrive independently, maximum likelihood estimation aims to find parameters that maximize the joint probability of all observed data. Taking the logarithm converts the product of independent probabilities into a sum:
Notice the mathematical barrier: the summation across components sits inside the logarithm.
In standard maximum likelihood without latent variables, taking the logarithm moves directly onto exponential family densities. The log cancels the exponential term, creating simple linear or quadratic expressions whose derivatives set cleanly to zero. With latent variables, the sum inside the log prevents the logarithm from reaching individual Gaussian terms. All parameters across all components become nonlinearly coupled. Setting yields no closed-form analytical solution. If were continuous rather than discrete, that inner sum would become an intractable high-dimensional integral.
Soft responsibilities in Gaussian mixtures
If we knew which latent component generated each request, fitting each Gaussian would be straightforward: compute the sample mean and variance for each group separately. If we already had the true parameters , assigning requests to components would also be straightforward Bayes' rule.
Because we have neither, we start by evaluating provisional parameters. Consider two provisional components: a fast component at ms, a slow component at ms, equal standard deviations ms, and equal prior weights .
Now evaluate a boundary request that took 50 ms. The fast Gaussian's density at 50 ms is roughly 0.01210 per ms; the slow Gaussian's density is roughly 0.00270 per ms. Weighting each contribution by prior probability 0.5 gives 0.00605 and 0.00135.
Normalizing these two numbers yields the posterior probability that component generated observation , known as the component's responsibility :[1]
For our 50 ms request, the fast responsibility is , and the slow responsibility is . The next code snippet calculates these values without intermediate rounding:
1from math import exp, pi, sqrt
2
3def gaussian_density(value: float, mean: float, deviation: float) -> float:
4 exponent = -((value - mean) ** 2) / (2 * deviation**2)
5 return exp(exponent) / (sqrt(2 * pi) * deviation)
6
7latency = 50.0
8weighted_fast = 0.5 * gaussian_density(latency, mean=30.0, deviation=20.0)
9weighted_slow = 0.5 * gaussian_density(latency, mean=90.0, deviation=20.0)
10total = weighted_fast + weighted_slow
11
12fast = weighted_fast / total
13slow = weighted_slow / total
14assert abs(fast + slow - 1.0) < 1e-12
15print(f"fast responsibility: {fast:.3f}")
16print(f"slow responsibility: {slow:.3f}")1fast responsibility: 0.818
2slow responsibility: 0.182The fast component receives greater responsibility here because its mean is closer and variances are equal. If components had unequal spreads or priors, the closer mean wouldn't automatically win.
Contrast this soft probabilistic assignment with hard K-means clustering from Clustering, PCA, and Representation Learning. K-means forces an absolute 0 or 1 choice based on Euclidean distance, discarding ambiguity. Mathematically, K-means is the limiting case of a Gaussian mixture model where all component covariances are identical and spherical () as variance shrinks to zero (). In that zero-variance limit, the softmax posterior collapses into a hard argmax. By retaining non-zero variance, a Gaussian mixture preserves the real uncertainty: a 50 ms latency leans fast, but still carries an 18.2% probability of being an unusually speedy database query.
Responsibilities are discrete probabilities summing to one for each observation (). In contrast, is a continuous probability density, whose values can exceed 1.0 for narrow spreads and whose integral over an interval gives probability.
A 50-millisecond request receives fast-component responsibility 0.818. What happens to the remaining 0.182, and does the larger number prove that an in-memory cache handled the request?
Answer
The remaining 0.182 belongs to the slow component, so both responsibilities sum to exactly one. The 0.818 score is a conditional probability under the assumed statistical model; no hardware traces or queue states were measured, so it doesn't establish physical causation.
Expectation-Maximization as lower-bound ascent
We need responsibilities to learn parameters, but we need parameters to calculate responsibilities. The Expectation-Maximization (EM) algorithm, introduced by Dempster, Laird, and Rubin (1977)[2], resolves this mutual dependency by alternating two coordinated steps.
Rather than attacking the non-convex log-sum directly, EM introduces an arbitrary probability distribution over the latent variables and constructs a tractable lower bound: the Evidence Lower Bound (ELBO).
Let's derive the lower bound through Jensen's inequality. For any concave function , Jensen's inequality guarantees that the function of an expectation is at least the expectation of the function: . Because the logarithm function is strictly concave, multiplying and dividing by yields:
Expanding that expectation defines the Evidence Lower Bound :[3]
We can also express the relationship as an exact algebraic decomposition:
where is the Kullback-Leibler divergence measuring the divergence between our distribution and the true posterior :
Because KL divergence is always non-negative () and equals zero if and only if , forms a valid lower bound on the true marginal log likelihood .

The algorithm climbs this geometry through coordinate ascent:
-
E-step (Expectation / Bound Tightening): Hold model parameters fixed at . Set , computing the conditional responsibilities . This forces , causing the lower bound to touch the true log likelihood curve tangentially at :
-
M-step (Maximization / Parameter Optimization): Freeze and maximize with respect to :
Because the logarithm now operates directly on the complete-data joint probability , the sum inside the log is gone! For Gaussian components, this yields exact closed-form updates.
-
Monotonicity guarantee: Combining both steps confirms that likelihood never decreases:[2]
Jensen's inequality ensures the ELBO never exceeds true likelihood. Maximizing that bound over in the M-step guarantees progress, while the E-step closes the divergence gap at the current parameter. Together, these properties ensure monotonic non-decrease toward a stationary point, though they don't guarantee reaching the global optimum.
Closed-form updates and numerical stability
For Gaussian mixture models, maximizing produces intuitive weighted updates for each component :
Here represents the effective number of observations assigned to component . The denominator for variance is rather than because this is maximum likelihood estimation, not an unbiased sample variance calculation.
When implementing these updates in software, direct calculation of tiny Gaussian densities leads to floating-point underflow. For points far from a mean, raw evaluation of produces zero. When every component underflows, computing results in NaN.
The log-sum-exp trick protects calculations by operating in log space:
where . Subtracting the maximum log score ensures that the largest term evaluates to , preventing underflow during normalization.
The next snippet fits our six observed latencies [28, 30, 32, 86, 90, 94] starting from provisional means at 25 and 95 ms with initial variances of 900 ms²:
1from math import exp, log, pi
2
3values = [28.0, 30.0, 32.0, 86.0, 90.0, 94.0]
4
5def logsumexp(scores):
6 largest = max(scores)
7 return largest + log(sum(exp(score - largest) for score in scores))
8
9def log_scores(value, model):
10 weights, means, variances = model
11 return [
12 log(weight) - 0.5 * (log(2 * pi * variance) + (value - mean)**2 / variance)
13 for weight, mean, variance in zip(weights, means, variances)
14 ]
15
16def log_likelihood(data, model):
17 return sum(logsumexp(log_scores(value, model)) for value in data)
18
19def fit_mixture(data, start_means, steps=8, variance_floor=1.0):
20 assert data and start_means and 0 < variance_floor <= 900.0
21 count_components = len(start_means)
22 model = ([1.0 / count_components] * count_components,
23 list(start_means), [900.0] * count_components)
24 history = [log_likelihood(data, model)]
25 for _ in range(steps):
26 responsibilities = []
27 for value in data:
28 scores = log_scores(value, model)
29 normalizer = logsumexp(scores)
30 row = [exp(score - normalizer) for score in scores]
31 assert abs(sum(row) - 1.0) < 1e-10
32 responsibilities.append(row)
33
34 weights, means, variances = [], [], []
35 for component in range(count_components):
36 memberships = [row[component] for row in responsibilities]
37 effective_count = sum(memberships)
38 if effective_count < 1e-12:
39 raise ValueError("Numerically empty component: try another initialization")
40 mean = sum(r * x for r, x in zip(memberships, data)) / effective_count
41 variance = sum(r * (x - mean)**2 for r, x in zip(memberships, data)) / effective_count
42 weights.append(effective_count / len(data))
43 means.append(mean)
44 variances.append(max(variance, variance_floor))
45 model = (weights, means, variances)
46 history.append(log_likelihood(data, model))
47
48 assert all(new >= old - 1e-10 for old, new in zip(history, history[1:]))
49 return model, history
50
51model, history = fit_mixture(values, [25.0, 95.0])
52print("log likelihood:", [round(value, 3) for value in history])
53for index, (weight, mean, variance) in enumerate(zip(*model), start=1):
54 print(f"component {index}: weight={weight:.3f}, mean={mean:.1f} ms, variance={variance:.3f} ms²")1log likelihood: [-29.62, -27.056, -20.641, -17.694, -17.694, -17.694, -17.694, -17.694, -17.694]
2component 1: weight=0.500, mean=30.0 ms, variance=2.667 ms²
3component 2: weight=0.500, mean=90.0 ms, variance=10.667 ms²The model settles on component means at 30.0 and 90.0 ms, with variances of 8/3 (2.667 ms²) and 32/3 (10.667 ms²). The slow component's variance is four times larger because observations at [86, 90, 94] spread twice as far from their center as [28, 30, 32] do from theirs.
The variance_floor safeguard prevents an optimization pathology known as covariance collapse. If a component places its mean on a single point with zero variance, its likelihood spikes toward infinity. Imposing a floor keeps the optimization bounded.[4]
The log likelihood stops changing after four iterations. Does that prove the fitted parameters reached the global maximum?
Answer
No. EM guarantees monotonic improvement to a local stationary point, not the global optimum. Another initialization might converge to a distinct mode with higher likelihood.
Diagnosing failure modes: singularities and symmetric traps
Production systems encounter two classic failure modes when fitting probabilistic mixture models.
The first failure mode is covariance collapse (singularities). In standard single-Gaussian maximum likelihood, the likelihood function is strictly bounded. In a Gaussian mixture, however, the likelihood surface contains singularities. If component assigns its center directly to a single observation while its variance shrinks toward zero (), the Gaussian density . The overall log likelihood shoots toward positive infinity. This doesn't reflect a good fit; it's a degenerate singularity where one component collapses into an infinitely sharp spike. In production code, always regularize covariances by adding a diagonal ridge term (such as scikit-learn's reg_covar) or clamping variances with a minimum floor.[5]
The second failure mode is the symmetric initialization trap. If both components start with identical means, variances, and weights (such as ms), every single observation receives identical responsibilities: . The subsequent M-step updates both components to the identical pooled sample mean (60.0 ms) and pooled sample variance (878.7 ms²). Because the responsibilities never differentiate, exact EM steps can't break symmetry.
The next snippet verifies this behavior by testing both starting points against an illustrative validation set [29, 31, 88, 92]:
1validation = [29.0, 31.0, 88.0, 92.0]
2for start in ([25.0, 95.0], [60.0, 60.0]):
3 fitted, trace = fit_mixture(values, start)
4 validation_score = log_likelihood(validation, fitted) / len(validation)
5 print(f"start={start}: means={[round(mean, 1) for mean in fitted[1]]}, "
6 f"train={trace[-1]:.3f}, validation/point={validation_score:.3f}")1start=[25.0, 95.0]: means=[30.0, 90.0], train=-17.694, validation/point=-2.637
2start=[60.0, 60.0]: means=[60.0, 60.0], train=-28.943, validation/point=-4.822The separated initialization reaches a training log likelihood of -17.694 and a validation score of -2.637 per point. The symmetric initialization remains trapped at the pooled mean 60.0 ms, scoring an inferior -28.943 on training data and -4.822 on validation traffic. Always initialize GMM components with dispersed seeds, such as K-means++ centers or multiple random restarts (n_init=10).
Comparing models with different numbers of components () requires held-out validation likelihood or information criteria (AIC/BIC). Adding more components always increases training likelihood, but risks overfitting noise or creating degenerate single-point components.
To evaluate model fidelity beyond scalar likelihood scores, draw synthetic samples from the fitted mixture: choose component with probability , then draw . Compare histograms of synthetic points against holdout traffic. If the simulated data misses the bimodal separation or generates negative latencies, the Gaussian assumption itself may need revision (for instance, adopting log-normal or Gamma component densities).[6]
Bridge to modern deep learning: Variational Autoencoders as amortized EM
In a Gaussian mixture model, the latent variable is a discrete index . Because is small, calculating the exact posterior responsibility in the E-step is fast and exact.
Modern generative AI takes this formulation into high dimensions. What happens when:
- The latent representation is a continuous, multi-dimensional vector (such as a 128-dimensional embedding representing an image or audio clip)?
- The generative process is parameterized by a deep neural network (the decoder)?
Now marginal likelihood faces double intractability:
- Marginalizing over continuous latent space requires an intractable high-dimensional integral: .
- The exact posterior is impossible to compute analytically, ruling out an exact classical E-step.
Kingma and Welling (2014)[7] solved this in the Variational Autoencoder (VAE) framework through amortized variational EM:
| Classical EM (GMM) | Variational Autoencoder (VAE) |
|---|---|
| Discrete latent index | Continuous latent vector |
| Closed-form linear decoder parameters | Deep neural network decoder parameters |
| Exact E-step: analytical posterior | Variational E-step: neural encoder approximates posterior |
| Separate responsibility vector per observation | Amortized inference: single shared encoder network handles any input |
| M-step: closed-form parameter updates | M-step: gradient ascent on ELBO via backpropagation |
Rather than optimizing a separate variational distribution for every data point, an encoder network with weights amortizes inference by predicting posterior parameters and in a single forward pass.
The training objective is the exact same Evidence Lower Bound:
The first term acts as the M-step reconstruction objective, training the decoder to reconstruct input from latent code . The second term acts as the E-step regularizer, penalizing the divergence between the approximate posterior and standard Gaussian prior .
To backpropagate through stochastic latent variables, Kingma and Welling introduced the reparameterization trick: sample external noise and compute . Because randomness enters as an independent input, gradients flow smoothly through and to train encoder and decoder end-to-end. The 1977 principle of optimizing marginal likelihood by tightening and climbing a lower bound directly powers modern latent variable architectures, from VAEs to latent diffusion models.