Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Imagine a dashboard built from six request latencies: [28, 30, 32, 86, 90, 94] milliseconds. Its mean is 60 ms, yet no measured request took 60 ms. Would you plan capacity around that average, or ask why the observations form two groups?
These are invented measurements, and we haven't recorded cache status, query plans, or routing. A single fitted Gaussian has its highest density at 60 ms and spreads broadly enough to cover both groups. A Gaussian mixture model can instead fit separate peaks and assign fractional membership. Neither fit proves that the groups correspond to two infrastructure causes, and six observations aren't enough to establish the population's shape.[1]

Generative versus discriminative modeling
Machine learning systems answer two distinct modeling questions depending on whether they estimate conditional or joint distributions.
In the statistical classification distinction, discriminative modeling learns to predict from , often through or a decision score. With labeled logs, a classifier might estimate cache-hit probability from latency and query size. A conditional probability alone doesn't specify the input distribution : many different input distributions can share the same . Consequently, a large class probability isn't evidence that an input is typical. Confidence on corrupted inputs depends on the model; overconfidence isn't inevitable.
Generative modeling specifies a distribution from which we can sample observations. A generative classifier might model and derive by Bayes' rule. An unlabeled density model instead models , possibly through hidden variables. It can score observations under its assumptions, but density isn't a guaranteed anomaly detector and a sampling story isn't automatically a causal explanation.[1]
The terminology also depends on the task. An LLM models a distribution over text conditioned on a prompt, so a conditional distribution can support generation. Here we'll use the classification distinction to ask a narrower question: how could a probability model describe our unlabeled latency sample?
Our hidden component index is a latent variable, an unobserved variable included in the statistical model. Some request logs do contain cache or routing labels. This example deliberately doesn't.
Latent variables and the coupled 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 | Observed group |
|---|---|---|
| A | 28 ms | Lower latencies |
| B | 30 ms | Lower latencies |
| C | 32 ms | Lower latencies |
| D | 86 ms | Higher latencies |
| E | 90 ms | Higher latencies |
| F | 94 ms | Higher latencies |
A probabilistic generative model formulates a two-stage story for each observation:

In the model, first select component with probability , where and . Then sample from with . The weight describes component frequency, not confidence in a fitted mean.
Because the log records but not , calculate an observation's density by summing the contributions from its possible components:
Assume observations are independent and identically distributed under this model. Real traffic can violate that assumption through time, tenant, or queue dependence. Maximum likelihood estimation chooses parameters to maximize the product of the observed densities. Its logarithm is:
Notice the mathematical barrier: the summation across components sits inside the logarithm.
For a single Gaussian with nonzero empirical variance, the mean and variance maximizing likelihood have simple formulas. For a general mixture, the log of a sum couples component parameters, so those single-Gaussian formulas don't solve the whole fit in one step. The objective is non-convex, and gradient-based optimization is possible too.[1]
Keep evaluation separate from optimization. With two Gaussian components, summing two densities and computing their posterior probabilities is easy and exact. The challenge here is fitting the parameters. Continuous latent variables replace sums with integrals; some models, including linear Gaussian models, still allow exact integration. Nonlinear neural decoders usually don't.
Soft responsibilities in Gaussian mixtures
If component labels were observed and each group had positive empirical variance, we'd fit separate Gaussian means and variances. With parameters already fixed, we'd instead calculate posterior membership using 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 new request at 50 ms, between the two means. The fast Gaussian's density there is roughly 0.01210 per ms; the slow Gaussian's density is roughly 0.00270 per ms. Weighting each by 0.5 gives contributions 0.00605 and 0.00135. The equal-responsibility boundary for this model is 60 ms.
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:
from math import exp, pi, sqrt
def gaussian_density(value: float, mean: float, deviation: float) -> float:
exponent = -((value - mean) ** 2) / (2 * deviation**2)
return exp(exponent) / (sqrt(2 * pi) * deviation)
latency = 50.0
weighted_fast = 0.5 * gaussian_density(latency, mean=30.0, deviation=20.0)
weighted_slow = 0.5 * gaussian_density(latency, mean=90.0, deviation=20.0)
total = weighted_fast + weighted_slow
fast = weighted_fast / total
slow = weighted_slow / total
assert abs(fast + slow - 1.0) < 1e-12
print(f"fast responsibility: {fast:.3f}")
print(f"slow responsibility: {slow:.3f}")fast responsibility: 0.818
slow 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.
Keep the observation, means, and spreads fixed, but suppose the fast component's prior weight is 0.1 instead of 0.5. Reuse the density function to isolate that change:
weighted_fast = 0.1 * gaussian_density(latency, mean=30.0, deviation=20.0)
weighted_slow = 0.9 * gaussian_density(latency, mean=90.0, deviation=20.0)
fast = weighted_fast / (weighted_fast + weighted_slow)
print(f"fast responsibility with prior 0.1: {fast:.3f}")
print(f"slow responsibility with prior 0.9: {1 - fast:.3f}")fast responsibility with prior 0.1: 0.332
slow responsibility with prior 0.9: 0.668The likelihood ratio favors the closer fast component by about 4.48 to 1, but its prior odds are 1 to 9. Multiplying them gives posterior odds about 0.498 to 1, so the slow component wins. This is the same Bayes update used in Bayesian Inference. A responsibility combines component frequency with fit to the observation; it isn't just a softened nearest-center rule.
Compare this with K-means, which assigns each point to a nearest center. With shared spherical covariance , responsibilities satisfy
Hold centers and positive priors fixed while . A uniquely closest center receives responsibility approaching one. At an exact distance tie, responsibilities stay proportional to the tied components' priors; shrinking variance doesn't break the tie. The weighted mean update then approaches K-means' mean update when assignments are unique and clusters are nonempty. This limiting argument uses a shared variance; it isn't an instruction to shrink each fitted GMM variance toward zero.
The 0.818/0.182 split expresses membership under the provisional model, not measured physical uncertainty or verified cache-versus-database probabilities.
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 alternates between the two. Dempster, Laird, and Rubin's classic 1977 paper formalized the general algorithm and its likelihood-improvement argument.[2]
Introduce a probability distribution over the latent variables and an evidence lower bound (ELBO) on the log likelihood. Whether the bound is practical to calculate depends on the model and the choice of . In our independent-observation GMM, both the joint model and its posterior factorize by observation, avoiding enumeration of all assignment vectors.
For a concave function , Jensen's inequality gives when the expectations exist. To derive the bound below, take wherever , so multiplying and dividing by doesn't discard part of the sum. The logarithm is concave:
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 :
For finite terms, this decomposition identifies the gap exactly. KL divergence is nonnegative and is zero when equals the posterior (almost everywhere for continuous variables). Therefore . A bound can be valid yet loose; a restricted or approximate needn't make the gap zero.

The algorithm climbs this geometry through coordinate ascent:
-
E-step: Hold fixed and compute the exact posterior . For the GMM, this means computing responsibilities. The gap is zero at the current parameters:
-
M-step: Freeze and maximize the bound over the allowed parameter space:
The entropy is constant with respect to . The remaining objective is the expected complete-data log likelihood, often called . For our GMM it separates into weighted Gaussian fits. Other models can still require numerical optimization.
-
Monotonicity guarantee: Combining both steps confirms that likelihood never decreases:[2]
Read the three comparisons from right to left: the old bound touches old likelihood, the parameter update doesn't lower that bound, and new likelihood is at least the new bound. Exact E-steps plus an M-step that merely doesn't decrease are sufficient for this argument; that variant is generalized EM.[2]
Monotonic means non-decreasing, not strictly improving. If likelihood is bounded above, its values converge, but that fact alone doesn't prove the parameters converge or reach a local maximum. Stationary-point results require additional regularity conditions, and unconstrained Gaussian mixtures can have unbounded likelihood. No part of the inequality certifies a global optimum.
Before fitting all parameters, reproduce the figure's restricted example. Keep means [m, 90], weights [0.5, 0.5], and both variances at 400 ms². The only free parameter is m. For each observation, q[i][k] holds its component responsibility; independence lets us sum over observations instead of all assignment vectors:
from math import exp, log, pi
observations = [28.0, 30.0, 32.0, 86.0, 90.0, 94.0]
def joint_log_scores(mean):
return [
[log(0.5) - 0.5 * (log(2 * pi * 400.0) + (x - mu)**2 / 400.0)
for mu in (mean, 90.0)]
for x in observations
]
def logsumexp(row):
largest = max(row)
return largest + log(sum(exp(value - largest) for value in row))
def likelihood(mean):
return sum(logsumexp(row) for row in joint_log_scores(mean))
def posterior(mean):
return [[exp(value - logsumexp(row)) for value in row]
for row in joint_log_scores(mean)]
def elbo(q, mean):
return sum(r * (score - log(r))
for row, scores in zip(q, joint_log_scores(mean))
for r, score in zip(row, scores))
old_mean = 70.0
q_old = posterior(old_mean)
new_mean = sum(row[0] * x for row, x in zip(q_old, observations)) / sum(
row[0] for row in q_old
)
q_new = posterior(new_mean)
assert abs(elbo(q_old, old_mean) - likelihood(old_mean)) < 1e-10
assert abs(elbo(q_new, new_mean) - likelihood(new_mean)) < 1e-10
assert likelihood(new_mean) >= elbo(q_old, new_mean) >= likelihood(old_mean)
for candidate in range(20, 81):
assert elbo(q_old, candidate) <= likelihood(candidate) + 1e-10
assert elbo(q_new, candidate) <= likelihood(candidate) + 1e-10
print(f"first mean: {old_mean:.2f} -> {new_mean:.2f} ms")
print(f"old likelihood / touching bound: {likelihood(old_mean):.3f}")
print(f"old bound after M-step: {elbo(q_old, new_mean):.3f}")
print(f"new likelihood / touching bound: {likelihood(new_mean):.3f}")
print(f"KL gap before next E-step: {likelihood(new_mean) - elbo(q_old, new_mean):.3f}")first mean: 70.00 -> 47.33 ms
old likelihood / touching bound: -32.028
old bound after M-step: -29.517
new likelihood / touching bound: -28.465
KL gap before next E-step: 1.052The M-step changes the mean and increases likelihood. The next E-step changes responsibilities and makes the bound touch that unchanged new likelihood. Our two bounds can cross elsewhere: tightening at the current parameter doesn't lift the whole curve everywhere.
Does an E-step improve the model's marginal likelihood by changing its parameters?
Answer
No. It holds the parameters fixed and updates the latent-variable posterior. The bound becomes tight at those parameters, but marginal likelihood itself stays the same. The subsequent M-step changes the model.
Closed-form updates and numerical stability
For Gaussian mixture models, maximizing produces intuitive weighted updates for each component :
Here is an effective count, not necessarily an integer. Divide by for the maximum-likelihood variance; an unbiased weighted-variance correction is a different problem. With an empty component, the mean and variance formulas divide by zero. In this example we'll stop and ask for another initialization if numerical underflow leaves negligible membership.
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:
Here and . For finite log scores, the largest shifted exponential is one, so the denominator doesn't underflow to zero. Smaller responsibilities can still round to zero. Invalid variances, non-finite inputs, or an all-negative-infinity score vector require separate handling; the subtraction doesn't repair an invalid model.
The next snippet fits the six latencies from provisional means at 25 and 95 ms, initial variances of 900 ms², and a fixed minimum variance of 1 ms². It runs eight updates and checks likelihood non-decrease within floating-point tolerance. This is a small teaching implementation with positive initial weights, not a general-purpose estimator:
from math import exp, log, pi
values = [28.0, 30.0, 32.0, 86.0, 90.0, 94.0]
def logsumexp(scores):
largest = max(scores)
return largest + log(sum(exp(score - largest) for score in scores))
def log_scores(value, model):
weights, means, variances = model
return [
log(weight) - 0.5 * (log(2 * pi * variance) + (value - mean)**2 / variance)
for weight, mean, variance in zip(weights, means, variances)
]
def log_likelihood(data, model):
return sum(logsumexp(log_scores(value, model)) for value in data)
def fit_mixture(data, start_means, steps=8, variance_floor=1.0):
assert data and start_means and 0 < variance_floor <= 900.0
count_components = len(start_means)
model = ([1.0 / count_components] * count_components,
list(start_means), [900.0] * count_components)
history = [log_likelihood(data, model)]
for _ in range(steps):
responsibilities = []
for value in data:
scores = log_scores(value, model)
normalizer = logsumexp(scores)
row = [exp(score - normalizer) for score in scores]
assert abs(sum(row) - 1.0) < 1e-10
responsibilities.append(row)
weights, means, variances = [], [], []
for component in range(count_components):
memberships = [row[component] for row in responsibilities]
effective_count = sum(memberships)
if effective_count < 1e-12:
raise ValueError("Numerically empty component: try another initialization")
mean = sum(r * x for r, x in zip(memberships, data)) / effective_count
variance = sum(r * (x - mean)**2 for r, x in zip(memberships, data)) / effective_count
weights.append(effective_count / len(data))
means.append(mean)
variances.append(max(variance, variance_floor))
model = (weights, means, variances)
history.append(log_likelihood(data, model))
assert all(new >= old - 1e-10 for old, new in zip(history, history[1:]))
return model, history
model, history = fit_mixture(values, [25.0, 95.0])
print("log likelihood:", [round(value, 3) for value in history])
for index, (weight, mean, variance) in enumerate(zip(*model), start=1):
print(f"component {index}: weight={weight:.3f}, mean={mean:.1f} ms, variance={variance:.3f} ms²")log likelihood: [-29.62, -27.056, -20.641, -17.694, -17.694, -17.694, -17.694, -17.694, -17.694]
component 1: weight=0.500, mean=30.0 ms, variance=2.667 ms²
component 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 floor prevents covariance collapse by keeping variance positive. It also makes this particular fit a constrained maximum-likelihood problem. For fixed responsibilities and a new weighted mean, write the weighted squared deviation as . The variance-dependent part of is
Its unconstrained maximizer is . Over , its exact maximizer is , which is the code's clamp. The initial variances satisfy the same constraint, so the EM inequality still applies to this fixed feasible space. Every mixture density is at most , giving a finite upper bound on training log likelihood. A different safeguard isn't automatically the same constrained M-step.
The printed trace rounds to -17.694 for several updates. Has the code proved convergence or found the global maximum?
Answer
No. The loop runs a fixed eight updates, and rounded output can hide small changes. Its monotonicity assertion checks this trace, not optimality. Different initializations can end at different fits; even a fixed point needn't be the best mode.
Diagnosing failure modes: singularities and symmetric traps
Production systems encounter two classic failure modes when fitting probabilistic mixture models.
The first is covariance collapse. In a mixture with at least two components, put one positive-weight component's mean on a training observation and let its variance approach zero. Its density there grows as . Keep another positive-variance component covering the other points, and the training log likelihood becomes unbounded. Variance exactly zero isn't a valid Gaussian density. This limiting spike is an optimization pathology, not evidence of a useful population.[1]
A single univariate Gaussian has bounded maximum likelihood when the sample's empirical variance is positive. If all values are identical, even that single-Gaussian fit has a collapse problem. Multivariate singular covariances introduce further cases.
Choose a safeguard appropriate to the objective: a positive variance/eigenvalue constraint, suitable priors, or covariance regularization. Scikit-learn exposes reg_covar for a diagonal ridge, with default 1e-6 in version 1.9.1. Adding a ridge and clamping at a floor are different operations. A ridge needn't maximize the unregularized , so don't transfer our exact constrained proof to it without checking the update and objective.[4]
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.
Compare both starting points on [29, 31, 88, 92]. These four validation values are another invented fixture, not evidence from independent production traffic:
validation = [29.0, 31.0, 88.0, 92.0]
for start in ([25.0, 95.0], [60.0, 60.0]):
fitted, trace = fit_mixture(values, start)
validation_score = log_likelihood(validation, fitted) / len(validation)
print(f"start={start}: means={[round(mean, 1) for mean in fitted[1]]}, "
f"train={trace[-1]:.3f}, validation/point={validation_score:.3f}")start=[25.0, 95.0]: means=[30.0, 90.0], train=-17.694, validation/point=-2.637
start=[60.0, 60.0]: means=[60.0, 60.0], train=-28.943, validation/point=-4.822The separated start scores better on both fixtures. Break exact component symmetry and compare multiple starts, retaining the best training objective for a fixed model configuration. Scikit-learn 1.9.1 defaults to init_params="kmeans" and n_init=1; setting n_init=10 explicitly requests ten starts. Additional starts cost computation and still don't certify a global optimum.[4]
For different component counts , use held-out log likelihood and inspect model assumptions. AIC and BIC are complexity-penalized model-selection criteria, not guarantees that a selected component count describes real causes. With the same variance floor, the best possible training likelihood can't decrease when an extra component can duplicate or have zero weight. Separate EM runs can nevertheless return worse local fits. Unconstrained mixture maxima may not exist at all.[5]
To check what the fit predicts, sample component with probability , then sample . Compare simulated and held-out shapes, tails, and group frequencies. Gaussian components allow negative latencies, even when such draws are rare. Positive-support distributions such as log-normal or Gamma components may better match that constraint.
This is simulation from fitted point estimates. A Bayesian posterior predictive check additionally averages over uncertainty in the parameters, as Stan's definition makes explicit.[6]
Component labels also have no inherent identity: exchanging the two components leaves the mixture density unchanged. Calling component 1 "cache" doesn't make it a cache measurement.
From exact responsibilities to an encoder
Our GMM has a small discrete latent index and an exactly computable posterior. Now let and use a nonlinear neural decoder . The integral and the posterior generally aren't available in closed form. This difficulty comes from the model, not merely from being continuous.
A variational autoencoder (VAE) uses an encoder to approximate that posterior. Amortized inference shares the encoder parameters across observations rather than optimizing a separate distribution for each one. The connection to variational EM is the shared lower-bound objective, not a guarantee that ordinary VAE training performs exact alternating E- and M-steps.[3]
| Operation | This GMM | Typical Gaussian-latent VAE |
|---|---|---|
| Generative model | Select a component, then sample a Gaussian | Sample a latent vector, then a decoder's output distribution |
| Latent inference | Exact component posterior per observation | Shared encoder outputs approximate posterior parameters |
| Learning | Alternate exact responsibilities and weighted fits | Usually update encoder and decoder together using sampled gradients |
| Bound gap | Zero after an exact E-step | May remain positive because inference is restricted and approximate |
The same ELBO identity applies:
The expected decoder log likelihood is the reconstruction term. With a fixed prior, both encoder and decoder receive its gradients. The prior KL term also affects the encoder; it isn't a separate E-step. Don't confuse that prior KL penalty with the posterior KL gap .[7]
For a diagonal Gaussian encoder and a standard normal prior, sample and write
Here is standard deviation, not variance. The reparameterization trick makes the sampled decoder objective differentiable through the encoder. Kingma and Welling's method uses stochastic gradients to learn both parameter sets jointly; it doesn't require an exact posterior or an exact M-step.[7]
Think through a scalar draw: hold and set . Changing changes the decoder's reconstruction term through , with . Changing changes it too, with . This explains why reconstruction trains the encoder as well as the decoder.
A VAE's estimated minibatch ELBO increases. Does exact EM's likelihood non-decrease guarantee apply to that optimizer step?
Answer
No. The approximate encoder needn't make the old bound touch likelihood, and the sampled minibatch objective needn't increase the full-data ELBO. Even an increase in the exact ELBO alone doesn't ensure likelihood increases when the posterior KL gap changes.