Gaussian Mixture Models and Expectation-Maximization
Gaussian mixture models cluster with soft assignments. EM derivations and NumPy code explain covariance geometry, stable fitting, and component selection.
Machine Learning from ScratchAll chapters Gaussian Mixture Models and Expectation-MaximizationFull chapter · Sections in this chapter1 / 10 ↓
Reading tools Definitions, links & notes
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Gaussian Mixture Models and Expectation-Maximization
In K-means Clustering every observation belonged to exactly one cluster, and the algorithm committed to that answer the moment it assigned the point. That commitment is convenient, but it hides two things we often care about. The first is doubt: a point sitting between two clusters is not really "80% cluster A and 20% cluster B" to k-means; it is entirely cluster A or entirely cluster B depending on which side of the boundary it happens to fall. The second is shape: k-means measures similarity with a plain Euclidean distance, so its nearest-centroid decision regions have straight boundaries. The squared-distance objective favours compact, roughly isotropic groups, but the regions themselves need not be spherical or equally sized.
A Gaussian mixture model (GMM) replaces that hard machinery with a probability model. We imagine that nature first picked one of hidden groups for each observation, then drew the observation from a Gaussian distribution belonging to that group. Because the group labels are never observed, they are latent variables. The model does not need to be told the labels; it infers a probability distribution over them, which is exactly the soft assignment that k-means refused to give us.
The price of that richer model is that the likelihood we want to maximize contains the log of a sum, so the general multi-component maximum likelihood equations do not yield a direct closed-form joint parameter estimate like the simpler examples in Maximum Likelihood Estimation. The Expectation-Maximization (EM) algorithm solves this by repeatedly pretending the latent labels are known, taking their expected values under the current parameters, and then re-estimating the parameters as if those expectations were counts. An exact E-step followed by an M-step that increases the auxiliary objective cannot decrease the observed-data likelihood. That guarantee requires the stated updates and valid parameters; the diagonal-addition safeguard used in our practical code does not automatically inherit it. Even exact nondecrease is weaker than finding a global maximum.
By the end of this chapter we will have derived the E-step and M-step from scratch, worked a four-point example entirely by hand, built a numerically stable implementation that handles the underflow problems a naive version would hit, and compared its soft assignments against k-means on clusters that are deliberately overlapping and elliptical.
Advantages
The most immediate benefit is that the model reports uncertainty. The fitted quantity , called the responsibility of component for observation , is a posterior probability under the fitted mixture: it is the model's chance that this observation was generated by component , conditional on its features and the current parameter estimates. We can threshold it to obtain clusters, keep it as a soft feature for a downstream model, or use it to flag observations that sit between groups and therefore deserve a human look. K-means supplies distances and hard assignments rather than these model-based posterior probabilities. A responsibility is not, by itself, evidence of calibration against real-world groups or a measure of uncertainty in the estimated parameters.
The second benefit is geometric flexibility. Each component carries its own full covariance matrix, so it can be elongated, rotated, and scaled differently from its neighbours. It can also carry its own mixing weight, so one component can be large and diffuse while another is small and tight. Data that k-means would either split in the wrong place or merge into one blob (think of a long diagonal ridge next to a compact ball) is handled naturally. Restricted covariance structures (shared, diagonal, or spherical) let us dial that flexibility back down when data are scarce, which gives us a principled continuum from k-means-like fits to fully general ones.
The third benefit is the fitted density itself. Once fitted, is a proper probability distribution we can evaluate, sample from, and compare across models. That makes GMMs useful far beyond clustering: for density estimation, for anomaly detection (a point with very low mixture density is unusual), for generative sampling, and as a flexible component inside larger probabilistic models. Because the likelihood is a well-defined criterion, we can select the number of components with information criteria such as BIC rather than eyeballing a scree plot.
For a specified mixture, we can marginalize unobserved coordinates: each component's marginal is Gaussian, and their weighted sum is still a Gaussian mixture. Evaluating this marginal does not require filling in those coordinates. Our code and scikit-learn's GaussianMixture expect complete feature rows; fitting mixtures to incomplete training observations needs an additional missing-data procedure.
Disadvantages
The likelihood surface is difficult to optimize. With multiple components the log-likelihood is not concave, so EM can settle near different stationary points. Convergence of parameters requires additional regularity; likelihoods can also approach singular boundary configurations. Different initializations can produce visibly different answers with different likelihoods, and there is no certificate telling us that the best one was found. Multiple restarts and a structured initialization such as k-means are useful safeguards, although neither guarantees the best fit.
For an unrestricted mixture with at least two components, the sample likelihood is unbounded above. Put one component mean on an observation and shrink its covariance to zero while another nondegenerate component gives the remaining observations positive density. The first contribution diverges, even though this is a degenerate density fit. This is not a numerical quirk that better code removes; it is a property of the objective. The standard remedy is to add a small constant to the diagonal of every covariance matrix, that is, replace with , where is a positive regularization strength (often called reg_covar in code) and is the identity matrix. For a positive-semidefinite scatter matrix, this imposes a positive covariance eigenvalue floor and caps the density. It prevents singular factorization but does not establish global optimality or exact EM monotonicity. The size of is therefore a real modelling decision, not a rounding detail.
There are further costs. The method assumes each component is Gaussian. Within a fixed feature space, approximating strongly skewed or heavy-tailed groups may require extra Gaussian components, which can make interpretation harder. Components are not the same thing as real-world groups: a single natural group with a non-Gaussian shape may be split across two fitted components, and two natural groups may share one. Component labels are arbitrary: permuting the component indices gives exactly the same likelihood, so comparisons across runs require matching components before comparing parameters. Finally, the computational cost grows with the square of the dimension: evaluating the full-covariance densities costs after factorizations, and forming the weighted covariance scatters costs . Here is the observation count, the feature count, and the number of components. For a thousand well-populated clusters in high dimensions, k-means remains far cheaper.
Formula
This section derives the mixture density, introduces the latent-variable formulation, and develops the E- and M-step updates.
Mixture densities and latent assignments
The derivation starts with the smallest object we need: a single Gaussian bump. In one dimension, with mean , positive standard deviation and variance , its density is
The fraction in front is not decoration. It is the exact constant that makes the area under the curve equal one, so the result can be treated as a probability density. The exponent measures how far sits from in units of standard deviations, then squares that distance; the negative sign turns distance into a penalty, so points near the mean get high density and points far away get low density. A single Gaussian therefore encodes one centre and one spread. That is useful, but it cannot describe data with two separated modes.
A mixture removes that limitation by averaging several Gaussian densities together:
where:
- : the full parameter set collecting all means , variances , and mixing weights
- : the mixing weight of component , satisfying and , so the weights behave like probabilities
- : the mean (centre) of component
- : the variance of component (one-dimensional case; the multivariate case replaces it with a covariance matrix below)
- : the Gaussian density of component evaluated at
The mixing weights describe prior component membership within the model: before seeing an observation's features, is its chance of coming from component . These are mixture probabilities, not a Bayesian prior on the fitted parameters. The sum says that the overall density at is a weighted average of the component densities at : each component contributes in proportion to how much prior mass it owns.
This weighted sum is also a generative recipe. To produce a point, first choose a component with probability , then draw the point from that component's Gaussian. The recipe contains a hidden step, and naming that step is what makes inference possible. Let record which component produced observation . We never observe , which is why it is called a latent variable. Writing the model jointly over the observation and its hidden label gives
and the law of total probability recovers the marginal density we use:
That identity is the reason the latent variable earns its place. The marginal likelihood contains a sum over hidden states, and that sum is exactly what blocks a direct closed-form maximum. If we could condition on , the sum would disappear; the latent variable lets us separate the difficult marginal problem into a sequence of easier complete-data problems.
Bayes' rule then turns the generative model into an inference procedure. Given the current parameters, the posterior probability that observation belongs to component is
The denominator is the total density at contributed by all components, and the numerator is the piece of that total contributed by component . Their ratio is the share of the density that component explains, which is precisely a probability. These are the soft assignments. They replace the missing labels with graded beliefs instead of forcing each point into one cluster.
Before generalizing, it is worth seeing why the obvious optimization strategy fails. The log-likelihood of independent observations is
We take logs because the likelihood itself is a product of densities, and products of many small numbers underflow quickly on a computer; logs turn products into sums without moving the maximum, since is strictly increasing. The obstacle is the outer logarithm sitting on top of a sum. Differentiating with respect to and setting the result to zero produces
because the ratio is precisely . Solving for the mean gives
This looks like a closed-form answer, but it is not: the responsibilities on the right-hand side are themselves functions of the whole parameter set , including . We have a fixed-point equation, not a formula. EM's insight is to freeze the right-hand side at its current value, recompute the left-hand side, and repeat. The E-step supplies the current beliefs about the hidden labels; the M-step turns those beliefs into new parameters.
Now the multivariate case, which is what we will implement. For an observation the component density is
where is a -vector, is a symmetric positive definite covariance matrix, the superscript means transpose (turn the column vector into a row vector so the matrix product is defined), is the matrix inverse, and is the determinant. The quadratic form is the squared Mahalanobis distance: it rescales and de-rotates space so that distance is measured in units of the component's own spread. The factor does the same job the factor did in one dimension: it divides by the volume the Gaussian occupies, so the density integrates to one.
Expectation and maximization steps
EM alternates between two problems, each of which is easy on its own. The first problem is inference: given parameters, what do we believe about the hidden labels? The second is estimation: given beliefs about the labels, what parameters are most likely?
Both problems are phrased in terms of the complete-data log-likelihood, the quantity we would maximize if the labels were observed. With a one-hot indicator equal to one when observation comes from component ,
where:
- : the complete-data log-likelihood, the log-likelihood we would maximize if the latent labels were observed
- : a one-hot indicator equal to when observation came from component , and otherwise
- : the mixing weight of component
- : the Gaussian density of component evaluated at
- : the number of observations, : the number of components
Because the indicators select exactly one term per observation, each component's mean and covariance can be fitted from its assigned observations. The mixing weights still share their sum-to-one constraint and are estimated from the component counts. That is the structure the marginal likelihood destroyed.
Since is unknown, we replace it with its conditional expectation, which is the responsibility: . This produces the auxiliary function
where denotes the parameters at iteration . The E-step consists of computing the from the current parameters using Bayes' rule above. Each depends on all components through the denominator. Holding responsibilities fixed separates the mean and covariance estimation by component, while the mixing weights retain their shared normalization.
The M-step maximizes over . For the means, only the quadratic term depends on :
Differentiating the quadratic form with respect to the vector uses the chain rule on the squared Mahalanobis distance,
where the minus sign appears because enters with a negative sign inside the parenthesis. Setting gives
Because is invertible, we may multiply both sides on the left by to remove it, leaving . Splitting the sum and solving,
where:
- : the effective number of observations assigned to component , formed by summing the responsibilities
- : the responsibility of component for observation , its posterior probability of membership
- : the -th observation
The mean update requires . When a component has zero effective count, this weighted-mean formula is undefined. We can stop with an error, reinitialize or remove the component, or retain its parameters as an inactive zero-weight component and skip its update. Our implementation below stops with an error. The effective count need not be an integer, because responsibilities are fractional: a point with contributes to the weighted feature sum, then division by converts that sum to the mean. This is the soft-count idea in its simplest form.
For the covariance, we need two matrix-calculus facts. First, for a symmetric positive definite matrix, which is the matrix analogue of . Second, , which follows from differentiating the inverse. Applying both,
Multiply on the left and right by (which is legal since it is invertible) and use :
where:
- : the covariance matrix of component
- : the responsibility of component for observation
- : the centered observation, its deviation from component 's mean
- : the effective count for component
For this to be a valid nondegenerate Gaussian covariance we need and a positive-definite scatter. This is the weighted scatter matrix of component , with each observation's outer product counted times. It is the same estimator we would use with known labels, except that the counts are fractional.
The mixing weights carry a constraint, , so we cannot differentiate and set to zero naively; we add the constraint times a Lagrange multiplier and differentiate the augmented expression:
The first term's derivative is because , and the Lagrange multiplier is a constant; note that although itself depends on through the Bayes formula, in the responsibilities are held fixed at their current E-step values, so within this maximization they act as constants on the derivative. Rearranging gives . Summing both sides over and using and (each observation's responsibilities sum to one),
where:
- : the mixing weight of component
- : the effective count for component
- : the number of observations
- : the Lagrange multiplier enforcing the constraint
So each mixing weight is just the share of the effective count that component holds.
Collecting everything, one full EM iteration is:
The matrix form of the M-step makes the computation obvious. Gather the responsibilities into an matrix whose entry is , and the data into an matrix whose rows are the observations. Let be a column vector of ones and the column vector of effective counts. Then
where:
- : the responsibility matrix with entry
- : the data matrix whose rows are the observations
- : a column vector of ones of length
- : the column vector of effective counts whose -th entry is
- : the -th entry of (same as ), the effective count of component
- : the -th column of (the responsibilities for component )
- : the matrix whose -th row is
- : the mean of component
- : turns a vector into a diagonal matrix
Multiplying by sums the rows of weighted by the responsibilities (each column accumulates a weighted sum), and dividing by turns those weighted sums into weighted averages, giving the matrix whose -th row is . The covariance expression is the same operation applied to the centered data: subtracting centers every row, the diagonal matrix reweights the rows, and the product accumulates the weighted outer products. In code this is a handful of matrix multiplications rather than nested loops, which matters because the E-step is repeated until convergence.
The reason EM works at all is that it is coordinate ascent on a lower bound. For a probability distribution that is positive on the joint model's positive support, multiplying and dividing inside the log by and applying Jensen's inequality (which says that for a concave function like , the average of the function is at most the function of the average) gives
The gap between the two sides is the Kullback-Leibler divergence from to the posterior , which is non-negative and equals zero exactly when is the posterior. The E-step sets to that posterior, closing the gap so the bound touches the log-likelihood at the current parameters. The M-step then pushes the bound up by maximizing it over . Since the log-likelihood is at least the bound, and the bound has increased, the log-likelihood cannot decrease. This proves likelihood nondecrease for the exact bound-increasing updates. It does not prove parameter convergence, a finite maximum, or a global optimum, and it does not apply automatically after an arbitrary covariance modification.
Mathematical Properties
A few properties follow directly from the derivation and shape how the method is used.
- Monotone under exact updates. With valid parameters and an exact E-step followed by a bound-increasing M-step, . The likelihood is non-concave; finite interior limits under suitable regularity are stationary, and they need not be global maxima. Singular boundary behaviour remains possible.
- Unbounded above for unrestricted mixtures with . A component can collapse onto one observation while another covers the remaining points, driving the sample likelihood to . A single Gaussian whose empirical centered covariance is positive definite does have a finite maximum-likelihood fit; the collapse claim is not a statement about every Gaussian model.
- Label switching. Any permutation of the component indices leaves the likelihood unchanged, so its component labels are arbitrary. Comparing parameter estimates across runs requires matching components first.
- Affine transformation of full-covariance models. For with invertible , transform each mean to and covariance to . The density divides by , so responsibilities remain unchanged and total log-likelihood shifts by . This relates corresponding unrestricted fits, not arbitrary numerical runs with fixed regularization. Diagonal models are closed under axis scaling but not general rotations; spherical models permit common uniform scaling and rotations, not arbitrary axis scaling.
- Asymptotic relation to k-means. For fixed observations and centroids, suppose all components have equal fixed weights and common spherical covariance . As the externally specified common variance tends to zero, responsibilities at observations with a unique nearest centroid approach nearest-centroid indicators. This varies a prescribed variance across models rather than estimating it in the M-step. For each component with a positive limiting hard-assignment count, its mean update approaches the k-means update. Empty limiting clusters require a separate handling rule. This is the small-variance connection between the mixture and k-means.
- Parameter counts. A full-covariance mixture has free parameters: means, covariance matrices with distinct entries each, and free mixing weights. Diagonal covariances cost , spherical ones cost , and a tied covariance uses parameters by sharing one covariance across components.
Example
We can carry a full EM iteration by hand on four one-dimensional observations, , with two components. To keep the arithmetic transparent we fix the variances at and the weights at for the first E-step; the M-step then updates everything.
The normal density at is , where is the mean and is the variance, here and with . With the leading constant is . Starting means are and . For the squared distances are and ; dividing by gives approximately and . Evaluating the densities with the unrounded constant and exponents gives approximately and . The responsibilities follow from the ratio:
Repeating for all four points gives the E-step below. The two outer points are almost certain, while has a mixed posterior assignment, sitting at (that is, is close to but below one half, so component 2 is slightly favoured). That contested point is useful because a nearest-centroid method still assigns it deterministically, while the mixture retains both possibilities under its fitted probability model.
| 1.0 | 0.21297 | 0.00760 | 0.9656 | 0.0344 |
| 2.5 | 0.25159 | 0.06632 | 0.7914 | 0.2086 |
| 4.0 | 0.10934 | 0.21297 | 0.3392 | 0.6608 |
| 6.0 | 0.00760 | 0.21297 | 0.0344 | 0.9656 |
The log-likelihood at this parameter value is the sum of the logs of the mixture densities. For the mixture density is using unrounded densities, whose natural logarithm is approximately . Summing the four contributions gives . That is our baseline. The exact M-step cannot lower it; equality is possible at a stationary configuration. In this example we can check the strict increase numerically.
Now the M-step. Effective counts are the column sums: and . These are not integer counts. They are the total fractional membership each component has earned and provide the denominators for the M-step's weighted averages. The new mean of component 1 is the responsibility-weighted average of the observations,
and similarly , where we used the fact that the two weighted sums must add to the total . The variances use the same weights applied to squared deviations:
giving , and the same calculation for component 2 gives , so . The weights become and .
The test of the whole procedure is whether the likelihood improved. Recomputing the mixture density at the new parameters gives , which is larger than . The log-likelihood increases by about in this step, consistent with the nondecrease guarantee for this exact update. The contested point at has also moved: its responsibility for component 2 increases from about to . Both means move closer to this point. The posterior change depends on the updated weights and variances as well as the means; distances to the means alone do not determine it.
The cell below reproduces the hand calculation with the same formulas, so we can confirm the arithmetic and inspect the responsibilities directly.
PythonIn [3]Expand
x_ex = np.array([1.0, 2.5, 4.0, 6.0])
mu_ex = np.array([2.0, 5.0])
var_ex = np.array([2.25, 2.25])
pi_ex = np.array([0.5, 0.5])
# log component density for every (point, component) pair
log_dens = -0.5 * np.log(2 * np.pi * var_ex)[None, :] - (
x_ex[:, None] - mu_ex[None, :]
) ** 2 / (2 * var_ex[None, :])
log_joint = log_dens + np.log(pi_ex)[None, :]
ll_ex = logsumexp(log_joint, axis=1)
resp_ex = np.exp(log_joint - ll_ex[:, None])ConsoleOut [4]Expand
responsibilities (rows = observations): [[0.9656 0.0344] [0.7914 0.2086] [0.3392 0.6608] [0.0344 0.9656]] total log-likelihood before the M-step: -8.074
PythonIn [5]Expand
# one M-step
Nk = resp_ex.sum(axis=0)
mu_new = (resp_ex * x_ex[:, None]).sum(axis=0) / Nk
var_new = (resp_ex * (x_ex[:, None] - mu_new) ** 2).sum(axis=0) / Nk
pi_new = Nk / x_ex.size
log_dens_new = -0.5 * np.log(2 * np.pi * var_new)[None, :] - (
x_ex[:, None] - mu_new[None, :]
) ** 2 / (2 * var_new[None, :])
ll_new = logsumexp(log_dens_new + np.log(pi_new)[None, :], axis=1).sum()ConsoleOut [6]Expand
effective counts: [2.1306 1.8694] updated means: [2.116 4.81 ] updated variances: [1.428 1.826] updated weights: [0.533 0.467] log-likelihood after: -7.897 improvement: +0.177
The printed numbers agree with the rounded hand calculation to three decimal places, and the log-likelihood strictly increased. Further exact EM steps cannot lower it, but that alone does not guarantee a finite limiting fit. Our later implementation uses a small-change stopping rule and explicitly checks for a material likelihood decrease. The example therefore demonstrates the full rhythm of EM: infer soft labels, turn them into weighted estimates, and check that the objective moved in the promised direction.
Implementation in NumPy and SciPy
We now build the algorithm from primitives, keeping every step visible. We use NumPy for a Cholesky factorization (to evaluate multivariate normal densities without explicitly forming an inverse) and logsumexp from SciPy (to compute the normalizing constant of the responsibilities in a numerically safe way). The linear algebra functions np.linalg.cholesky and np.linalg.solve are standard NumPy.
PythonIn [7]Expand
def log_normal_pdf(X, mean, cov):
"""Log density of a multivariate normal, evaluated row-wise on X."""
n, d = X.shape
L = np.linalg.cholesky(cov) # cov = L @ L.T
diff = X - mean # (n, d)
sol = np.linalg.solve(L, diff.T) # (d, n), solves L @ w = diff_i
maha = np.sum(sol**2, axis=0) # squared Mahalanobis distance
log_det = 2.0 * np.sum(
np.log(np.diag(L))
) # log|cov| from the Cholesky factor
return -0.5 * (d * np.log(2.0 * np.pi) + log_det + maha)
def log_component_density(X, weights, means, covs, reg_covar):
"""log(pi_k) + log N(x_i | mu_k, Sigma_k) for every point and component."""
n, d = X.shape
K = means.shape[0]
out = np.empty((n, K))
for k in range(K):
cov = covs[k] + reg_covar * np.eye(d) # regularize before factorizing
out[:, k] = np.log(weights[k]) + log_normal_pdf(X, means[k], cov)
return out
def gmm_e_step(X, weights, means, covs, reg_covar):
"""Return responsibilities and the total log-likelihood."""
log_prob = log_component_density(X, weights, means, covs, reg_covar)
log_norm = logsumexp(
log_prob, axis=1
) # log of the mixture density per point
if not np.isfinite(log_norm).all():
raise FloatingPointError(
"Non-finite log density; inspect scale, weights and covariances."
)
resp = np.exp(log_prob - log_norm[:, None])
return resp, float(log_norm.sum())
def gmm_m_step(X, resp):
"""Weighted mean, weighted scatter, and weight updates."""
n, d = X.shape
K = resp.shape[1]
Nk = resp.sum(axis=0) # effective counts
if np.any(Nk <= np.finfo(float).tiny):
raise FloatingPointError(
"A component lost its effective weight; reinitialize or reduce K."
)
means = (resp.T @ X) / Nk[:, None] # weighted averages
covs = np.empty((K, d, d))
for k in range(K):
diff = X - means[k]
covs[k] = (
(resp[:, [k]] * diff).T @ diff / Nk[k]
) # weighted outer products
weights = Nk / n
return weights, means, covsThe log_normal_pdf function is worth reading line by line. Factorizing avoids explicitly computing its inverse, and it gives both quantities we need at once: , and the squared Mahalanobis distance as , which we obtain by solving the triangular system for every point at once. The transpose inside np.linalg.solve exists because solve expects the right-hand side as columns; each column of sol is one point's whitened residual.
PythonIn [8]Expand
def gmm_init(X, K, rng, method="kmeans", reg_covar=1e-6):
"""Initial parameters from k-means, or from random observations."""
n, d = X.shape
if method == "kmeans":
km = KMeans(
n_clusters=K, n_init=10, random_state=int(rng.integers(1_000_000))
)
labels = km.fit_predict(X)
means = km.cluster_centers_.copy()
else:
means = X[rng.choice(n, size=K, replace=False)].copy()
dist = ((X[:, None, :] - means[None, :, :]) ** 2).sum(axis=2)
labels = dist.argmin(axis=1)
counts = np.bincount(labels, minlength=K).astype(float)
weights = (
np.maximum(counts, 1.0) / n
) # floor avoids log(0) for empty clusters
weights = weights / weights.sum()
global_cov = np.atleast_2d(np.cov(X.T, bias=True)) + reg_covar * np.eye(d)
covs = np.empty((K, d, d))
for k in range(K):
mask = labels == k
if mask.sum() > d:
covs[k] = np.atleast_2d(np.cov(X[mask].T, bias=True))
else:
covs[k] = global_cov.copy()
return weights, means, covs
def fit_gmm(X, K, init="kmeans", n_iter=300, tol=1e-6, reg_covar=1e-6, seed=0):
"""Alternate E and M steps until the log-likelihood stops improving."""
X = np.asarray(X, dtype=float)
if X.ndim != 2 or not X.shape[1] or not np.isfinite(X).all():
raise ValueError("X must be a finite two-dimensional feature matrix.")
if (
not isinstance(K, (int, np.integer))
or isinstance(K, bool)
or not 1 <= K <= len(X)
):
raise ValueError(
"K must be an integer between one and the number of rows."
)
if (
not isinstance(n_iter, (int, np.integer))
or isinstance(n_iter, bool)
or n_iter < 1
):
raise ValueError("n_iter must be a positive integer.")
if (
init not in {"kmeans", "random"}
or not np.isfinite([tol, reg_covar]).all()
or tol <= 0
or reg_covar <= 0
):
raise ValueError(
"Use a supported initialization and finite positive tolerance and covariance floor."
)
rng = np.random.default_rng(seed)
weights, means, covs = gmm_init(X, K, rng, method=init, reg_covar=reg_covar)
history, prev_ll, converged = [], -np.inf, False
for _ in range(n_iter):
resp, ll = gmm_e_step(X, weights, means, covs, reg_covar)
history.append(ll)
if np.isfinite(prev_ll):
gain = ll - prev_ll
if gain < -1e-8 * (1.0 + abs(prev_ll)):
raise FloatingPointError(
"Log-likelihood decreased materially; inspect regularization and initialization."
)
if abs(gain) < tol:
converged = True
break
prev_ll = ll
weights, means, covs = gmm_m_step(X, resp)
if not converged: # the loop ran out of iterations
resp, ll = gmm_e_step(X, weights, means, covs, reg_covar)
if ll - prev_ll < -1e-8 * (1.0 + abs(prev_ll)):
raise FloatingPointError(
"Log-likelihood decreased materially; inspect regularization and initialization."
)
history.append(ll)
return {
"weights": weights,
"means": means,
"covs": covs,
"resp": resp,
"log_likelihood": ll,
"history": np.array(history),
"n_iter": len(history),
"converged": converged,
"reg_covar": reg_covar,
}The convergence test uses the absolute change in total log-likelihood, not a relative change and not distance to an optimum. A material decrease raises an error instead of being labelled convergence; changes within the stated roundoff allowance can still be small. The diagonal-addition update is a practical safeguard, not the exact unconstrained M-step proved earlier. A small change or an exhausted iteration budget does not certify a stationary point or a global optimum.
Now we generate two Gaussian components with different spreads and orientations. Their overlap makes component membership uncertain from the observed features alone, so we can compare hard assignments with posterior responsibilities.
PythonIn [9]Expand
rng = np.random.default_rng(7)
def rotated_cov(sd, angle_deg):
"""Covariance matrix with given axis standard deviations, rotated by angle."""
t = np.deg2rad(angle_deg)
R = np.array([[np.cos(t), -np.sin(t)], [np.sin(t), np.cos(t)]])
return R @ np.diag(np.square(sd)) @ R.T
true_means = np.array([[1.6, 1.2], [4.1, 2.4]])
true_covs = np.stack(
[rotated_cov([1.55, 0.55], 35.0), rotated_cov([1.25, 0.85], -25.0)]
)
true_weights = np.array([0.55, 0.45])
n_obs = 400
z_true = rng.choice(2, size=n_obs, p=true_weights)
X = np.empty((n_obs, 2))
for i in range(n_obs):
X[i] = rng.multivariate_normal(true_means[z_true[i]], true_covs[z_true[i]])
x_lo, x_hi = X[:, 0].min() - 0.6, X[:, 0].max() + 0.6
y_lo, y_hi = X[:, 1].min() - 0.6, X[:, 1].max() + 0.6PythonIn [10]Expand
def fit_gmm_best(X, K, n_init=5, **kwargs):
"""Run EM from several starting points and keep the best log-likelihood."""
if (
not isinstance(n_init, (int, np.integer))
or isinstance(n_init, bool)
or n_init < 1
):
raise ValueError("n_init must be a positive integer.")
runs = [fit_gmm(X, K, seed=s, **kwargs) for s in range(n_init)]
return max(runs, key=lambda m: m["log_likelihood"]), runs
model, runs = fit_gmm_best(X, 2, n_init=5, reg_covar=1e-6)ConsoleOut [11]Expand
converged: True, iterations: 46 log-likelihood: -1231.566 weights: [0.535 0.465] means: [[1.865 1.455] [4.224 2.276]] covariances: [[[ 2.174 1.161] [ 1.161 0.931]] [[ 1.227 -0.348] [-0.348 0.784]]]
The recovered means sit close to the generating values, and the covariance matrices show the differing orientations we built in. Sort the components by their first coordinate before printing if you want to compare runs directly, since the fitting order is arbitrary.
To summarize ambiguity under the fitted model, we can count the observations whose responsibility falls in the middle of the range. K-means still assigns these points by their nearest centroid; it does not return a posterior ambiguity measure.
PythonIn [12]Expand
amb = ((model["resp"][:, 0] > 0.3) & (model["resp"][:, 0] < 0.7)).sum()
km = KMeans(n_clusters=2, n_init=10, random_state=0).fit(X)
ours = model["resp"].argmax(axis=1)
agreement = max(np.mean(ours == km.labels_), np.mean(ours != km.labels_))ConsoleOut [13]Expand
observations with responsibility between 0.3 and 0.7: 91 of 400 hard-label agreement between GMM and k-means: 0.863
The printed agreement summarizes the two hard partitions after allowing their binary labels to swap. It does not measure accuracy against the generating labels, and agreement alone does not locate the disagreements; the plots let us inspect their geometry.
A reference implementation is the natural cross-check. Scikit-learn's GaussianMixture also uses posterior responsibilities and diagonal covariance regularization. We compare log densities on the same coordinates, but initialization, stopping conventions and covariance bookkeeping can differ; close results are a useful check rather than a guarantee.
PythonIn [14]Expand
sk_model = GaussianMixture(
n_components=2,
covariance_type="full",
reg_covar=1e-6,
n_init=5,
max_iter=300,
tol=1e-9,
random_state=0,
).fit(X)
sk_ll = sk_model.score(X) * len(X)
sk_labels = sk_model.predict(X)
sk_agree = max(np.mean(ours == sk_labels), np.mean(ours != sk_labels))ConsoleOut [15]Expand
our log-likelihood: -1231.566 sklearn log-likelihood: -1231.566 assignment agreement: 1.000 sklearn BIC: 2529.039
Small differences are expected: the two implementations initialize differently, stop at slightly different points, and handle the regularization term in slightly different places. A large gap could reflect different local fits, stopping behaviour, regularization, or an implementation error, which is a reminder to use several restarts whichever library is in play.
Stable implementation and initialization
The formulas above are mathematically correct and numerically fragile if taken literally. Two things must be changed before they will survive real data.
The first is the responsibility calculation. Forming directly and then dividing produces underflow for points far from every component: an exponential of is exactly zero in double precision, and dividing zero by zero yields nan. The fix is to compute everything in log space and use the log-sum-exp identity,
where are finite real log-joint values and .
Factoring out the largest term is a legal algebraic rearrangement: we divide each term by and multiply the total back by . It guarantees that the largest exponent is zero, so no term overflows and at least one term is exactly . The responsibility then becomes , which is always between zero and one even when the raw densities are unrepresentable. SciPy's logsumexp supplies this stable finite-input calculation. An all-infinite or nan input still needs explicit handling; the identity does not make invalid model parameters meaningful.
The second issue is covariance singularity. A weighted scatter is positive definite when its positive-weight observations span all feature directions after centring. For a hard-assigned component that requires at least affinely independent points. The responsibility sum is not a rank test: many tiny positive weights can still give a full-rank scatter. Adding with bounds the eigenvalues away from zero and makes factorization possible. It also bounds the density of these regularized fits, although adding it after an unconstrained M-step does not guarantee ordinary-likelihood monotonicity.
Initialization deserves as much care as the numerics. Exact bound-increasing updates guarantee likelihood nondecrease, not global optimality. Choose starting values and a restart budget together:
- Random means chosen from the data are simple; some runs land in poor fits.
- K-means partitioning often gives a useful structured start. Fit k-means, use its centroids as the means, its labels to compute initial covariances, and its cluster proportions as initial weights. It can still miss an elongated or overlapping component.
- Multiple restarts with the best final likelihood retained are the pragmatic default;
n_initcontrols the number of starts in scikit-learn.
A useful diagnostic is to track the log-likelihood of every restart: if converged runs have substantially different final values, initialization matters. If some runs have not converged, more iterations may explain part of the spread.
Two further safeguards are worth knowing. Component weights can collapse toward zero when a component becomes responsible for almost nothing, which can reflect a redundant component or a poor initialization but produces if it reaches exactly zero; flooring the weights at a small positive value keeps the arithmetic finite. And the convergence threshold should be applied to the log-likelihood, not to the parameters, since parameters can drift while the likelihood is flat along a ridge.
Key Parameters
The parameters of a Gaussian mixture fit control capacity, shape, and computational effort, and each has a practical consequence.
n_components(): the number of mixture components. The best attainable fit in nested model families cannot worsen as components are added, but finite EM runs may return worse local fits. Training likelihood alone does not penalize complexity.covariance_type:"full"gives each component its own unrestricted matrix ( covariance parameters),"tied"shares one matrix across components,"diag"restricts each component to axis-aligned spreads, and"spherical"gives each a single variance. Constraining covariance is the main defence against overfitting when the data are limited, and a shared spherical variance with equal weights has the nearest-centroid hard-assignment rule. Finite-variance soft EM remains a different procedure.reg_covar: the added to the diagonal. Scikit-learn defaults to , the starting value used in this example. Inspect that setting relative to feature scales and degeneracy warnings; a larger value may be needed for an unstable fit.init_paramsandn_init: how the starting point is chosen and how many restarts are attempted. K-means initialization with several restarts is the sensible default.max_iterandtol: the iteration cap and the stopping threshold on the log-likelihood. Scikit-learn appliestolto the average lower-bound gain, whereas our teaching implementation checks total log-likelihood change. Inspect the convergence flag and increase the budget if it is exhausted.weights_init,means_init,precisions_init: optional user-supplied starting values, useful when there is prior knowledge about where the components should be, or when a specific initialization must be reproduced.
Key Methods
Once fitted, a GaussianMixture object exposes the different ways of using the model, and the distinction between them matters.
fit(X)estimates the parameters;fit_predict(X)additionally returns the hard labels.predict(X)returns the component index with the highest responsibility, which is the hard clustering. It discards the uncertainty that the model went to the trouble of computing.predict_proba(X)returns the full responsibility matrix, one row per observation and one column per component. This is usually the more useful output.score_samples(X)returns the log mixture density at each observation, which is the natural anomaly score.score(X)returns the average log density per observation, the quantity we compared against our own implementation after multiplying by .bic(X)andaic(X)return penalized likelihood criteria that can be compared across different values ofn_components, since a lower value indicates a better trade-off between fit and complexity.sample(n_samples)draws new observations from the fitted density, which is the generative use of the model.
The distinction between predict and predict_proba is central to using the model in practice: the first produces hard GMM labels, which can differ from k-means labels, while the second gives the information that made the mixture model worth fitting.
Visualizing Gaussian Mixture Models
We begin with the data as generated, coloured by the true component. In a real problem those colours would be unavailable (the whole difficulty is that they are), but seeing them establishes what the model is trying to recover.
VisualizationOut [16]Expand

The two clouds overlap in the middle and their axes point in different directions, so no single distance threshold separates them cleanly. A circular cluster boundary is visibly the wrong shape for either cloud.
The fitted model replaces the truth with a density. The contours below show the total mixture density; the dashed contours show how each component alone would be shaped.
PythonIn [17]Expand
xx, yy = np.meshgrid(np.linspace(x_lo, x_hi, 300), np.linspace(y_lo, y_hi, 300))
grid = np.column_stack([xx.ravel(), yy.ravel()])
log_joint_grid = log_component_density(
grid, model["weights"], model["means"], model["covs"], model["reg_covar"]
)
dens = np.exp(logsumexp(log_joint_grid, axis=1)).reshape(xx.shape)
levels = np.exp(
np.linspace(np.log(dens.max() * 0.02), np.log(dens.max() * 0.9), 8)
)VisualizationOut [18]Expand

Each dashed ellipse is a radius-one Mahalanobis contour, not a universal 68% probability region. In two dimensions it encloses about 39% of its Gaussian component's probability. It has the orientation and aspect ratio of that component's covariance matrix: one is long and tilted steeply, the other is rounder and tilted the other way. Those shapes are estimated from the data, not supplied, and they are what a diagonal or spherical covariance type would be unable to represent.
The real output of the fit is not the contours but the responsibilities. Colouring each point by its posterior probability of belonging to component 1 produces a gradual transition through the overlap region instead of a step.
VisualizationOut [19]Expand

The colour gradient is broad around the overlap, while observations strongly favoured by either component have saturated colours. Points in the pale region have ambiguous component assignments under this fitted model; a downstream decision that treats them as firmly assigned is throwing away information the model has already computed.
K-means, fitted to the same data, has no such region. It produces a boundary and commits.
VisualizationOut [20]Expand

Comparing this with the previous figure shows where the two methods diverge: near the interface, k-means draws a straight line through points that the mixture model declines to commit on. The pictures summarize different fitted models. The mixture displays its conditional uncertainty; its visual smoothness does not establish that the model is correct.
Finally, inspect the optimization itself. This seeded run records increasing log-likelihood values that flatten; we check that observed behaviour rather than assuming it from the diagonal-addition update.
VisualizationOut [21]Expand

The exact EM proof explains why likelihood monitoring matters. Here diagonal regularization means we must also check for decreases; a flat right-hand end is a practical stopping signal. Note what the curve does not show: whether this plateau is the highest one available.
Different starting points give a practical check on that question, although finitely many starts cannot settle it. The figure below runs the same algorithm from five random initializations and overlays the trajectories.
PythonIn [22]Expand
random_runs = [
fit_gmm(X, 2, init="random", seed=s, reg_covar=1e-6) for s in range(5)
]VisualizationOut [23]Expand

The legend reports each run's final log-likelihood. Where the converged curves separate, the lower plateaus are poorer fitted solutions. The finite trajectories do not establish whether a plateau is an exact local optimum. When the curves do coincide, the data happened to be forgiving, and nothing in the algorithm guarantees that it will stay that way. This is the practical argument for n_init.
Practical Implications
Gaussian mixtures are a reasonable choice when observations are continuous and the groups you expect are roughly elliptical (elongated, rotated, or differently scaled), and when graded membership is more useful than a single label. For a segmentation task we can retain overlapping membership probabilities. The fitted density also supplies log-density scores for anomaly detection, supports generative sampling, and can serve as a density component in a larger probabilistic model. Full covariance is the version that can use correlations between features and can let each component have its own orientation and aspect ratio, which is the flexibility k-means lacks. When the scientific question only needs a partition, Euclidean feature distance is meaningful, and the clusters are compact, roughly spherical, and of comparable spread, k-means remains the better default.
Cost and sample size determine whether that flexibility is affordable. A GMM iteration costs plus for covariance factorizations, compared with for k-means, and a full-covariance model has means and covariance entries to estimate. Each component therefore needs a meaningful number of effective observations: a small is a warning about statistical stability, but nonsingularity depends on the affine span of positive-weight observations, not on . Full covariance estimation can still be unstable with few well-supported observations, especially under overlap or noisy features. When counts fall short, constrain the covariance, reduce dimensionality before fitting (the subject of Dimensionality Reduction), or reduce . Large and high with nearly spherical groups are the regime where k-means is usually preferable. The cost of a full-covariance fit also grows quickly with dimension, so a full model in dozens of dimensions should be justified by both the sample size and evidence that correlated, non-spherical components are present.
The data requirements concern the conditional distributions within components. Features should be continuous or transformed to a scale on which a Gaussian is a plausible local approximation; strong skew, heavy tails, or discrete counts are better handled by transforming the features, adding components, or choosing a different model. Outliers can pull means and inflate covariances, and near-duplicates can encourage degenerate components, so inspect fitted responsibilities and effective counts rather than trusting a single likelihood value. Scaling has a precise role. For corresponding full-covariance parameters under an invertible affine transformation, responsibilities are unchanged but density values change by the Jacobian factor. Diagonal models remain closed under feature-wise rescaling; spherical models generally do not. Fixed isotropic regularization, initialization and stopping can change even a full-covariance numerical fit. Scaling is therefore a modelling and numerical choice. Fit any learned preprocessing on training observations and apply it unchanged to held-out observations; compare density scores only in the same coordinates.
A practical workflow starts from a structured initialization and several restarts. K-means centroids are one useful starting point, and trying n_init between 5 and 10 is a practical initial budget rather than a guarantee; keep the run with the highest final log-likelihood. Our example uses reg_covar=1e-6, allows 300 iterations, and uses a total-log-likelihood tolerance of 1e-6. Other data and the scikit-learn average-gain convention may require different settings. After fitting, check that:
- no component has a vanishing weight or an effective count near zero;
- no covariance eigenvalue is sitting on the regularization floor;
- the log-likelihood history is monotone and has plateaued;
- the responsibilities reflect the expected component overlap; nearly hard assignments can be appropriate for well-separated components;
- independent restarts produce similar parameters after matching components.
These checks are diagnostic, not proof of correctness: EM can converge cleanly to a poor local optimum, and a well-behaved finite history does not establish stationarity or global optimality.
Deployment adds concerns that are easy to overlook during exploratory fitting. Store the fitted weights, means, covariances, the preprocessing used for the features, and the random seed, because a later refit without them may produce a different labelling of the components. Monitor held-out average log density rather than training likelihood, and refit or warm-start when the distribution drifts. Component labels are not stable across fits (permuting them leaves the likelihood unchanged), so compare components only after matching them by location, shape, or predictive role. If the model is used for anomaly detection, set the threshold from a validation sample and recheck it when the model is updated. Missing values deserve a note of caution: the Gaussian marginal over observed coordinates is well defined, but not every software implementation accepts incomplete rows, so a separate imputation or marginalization step may be required.
Covariance choices, model selection, and degeneracy
The covariance type is the first structural decision because it controls both flexibility and the number of parameters that must be estimated. Full covariance is the most flexible and the most expensive; it can represent any elliptical shape, at parameters per component. Tied covariance shares a single shape across all components, which imposes the same covariance size, shape and orientation on every component while allowing different means and weights, and it reduces the number of covariance parameters. Diagonal covariance restricts ellipses to be axis-aligned, a strong assumption that a global principal component rotation does not automatically justify: removing marginal correlation need not remove correlation within every component. Spherical covariance gives every component a single variance and is the closest mixture analogue to k-means. Choose the most constrained structure that captures the visible structure, and relax it only when the constrained fit leaves systematic correlation or curvature in the residuals or when held-out density clearly improves.
Choosing the number of components is a model selection problem, and the training likelihood cannot do it: the best attainable likelihood within nested model families cannot decrease when components are added, but a finite EM fit can return a worse local solution. The standard criteria penalize complexity. The Bayesian information criterion is
where:
- : the maximized log-likelihood when a finite maximum-likelihood fit exists
- : the number of free parameters
- : the number of observations
The numerical comparison below evaluates this expression at the finite parameters returned by EM; it does not certify that the likelihood was maximized. The penalty per parameter grows with sample size. The Akaike criterion replaces with . Here , so BIC penalizes each parameter more heavily because ; that ordering does not hold for every sample size. These conventional criteria are useful model-selection heuristics, and their regular asymptotic justification needs care for singular mixture models; neither is a guarantee. A practical approach is to use these criteria as a guide across a manageable grid of plausible candidate values. Cross-validated log-likelihood is a reasonable alternative, provided it is computed on held-out data with the density evaluated there rather than on the training set. Because each candidate fit can land in a different local optimum, compare models only after giving each several restarts and retaining its best likelihood.
PythonIn [24]Expand
selection = []
for k in range(1, 7):
gm = GaussianMixture(
n_components=k,
covariance_type="full",
reg_covar=1e-6,
n_init=5,
random_state=0,
).fit(X)
selection.append((k, gm.bic(X), gm.aic(X), gm.score(X) * len(X)))
k_best = min(selection, key=lambda row: row[1])[0]ConsoleOut [25]Expand
K log-lik BIC AIC 1 -1304.1 2638.2 2618.3 2 -1232.2 2530.3 2486.4 3 -1225.4 2552.7 2484.8 4 -1224.8 2587.4 2495.5 5 -1222.1 2618.0 2502.3 6 -1219.3 2648.2 2508.5 BIC selects K = 2
The table separates fit from complexity. In this seeded comparison the fitted log-likelihood rises as components are added, while the penalized criteria eventually increase. That finite result is observed, not guaranteed by local EM, and the curves alone cannot distinguish real structure from noise. Read the minimum as a guide rather than a theorem: if BIC and AIC disagree and held-out log density is similar, prefer the more parsimonious model; if the selected changes across seeds or resamples, report that instability instead of presenting one value as definitive. The printed value is the criterion's answer for this sample, and refitting with different random states checks how sensitive the selected component count is to initialization.
The same selection result is clearer as a curve: the fitted log-likelihood trend improves, while the parameter penalties can make the criteria turn upward.
PythonIn [26]Expand
k_vals = np.array([row[0] for row in selection])
bic_vals = np.array([row[1] for row in selection])
aic_vals = np.array([row[2] for row in selection])VisualizationOut [27]Expand

Degeneracy is the failure mode to watch for. The log-likelihood is unbounded above: if one component keeps a positive weight and collapses its covariance onto an observation, while another covers the rest, the total sample log-likelihood can grow without limit. The figure below shows this directly, on a deliberately tiny dataset with three coincident observations.
PythonIn [28]Expand
x_deg = np.array([[0.0], [0.0], [0.0], [3.8], [4.0], [4.2]])
means_deg = np.array([[0.0], [4.0]])
covs_deg = np.stack([np.eye(1), np.array([[0.04]])])
weights_deg = np.array([0.5, 0.5])
sig_grid = np.logspace(-4, 0, 60)
deg_ll = []
for s in sig_grid:
covs_deg[0] = np.array([[s**2]])
_, ll_k = gmm_e_step(x_deg, weights_deg, means_deg, covs_deg, reg_covar=0.0)
deg_ll.append(ll_k)VisualizationOut [29]Expand

The three coincident observations at zero contribute a term proportional to , which diverges as ; the other three observations are covered by the second component and do not resist the collapse. The example holds both weights at : covariance collapse does not require a vanishing weight. Exact duplicates make the plot easy to read, but one observation is enough for the unrestricted-mixture collapse construction when other components cover the remaining data. Near-singular component scatters can also make numerical fitting unstable. Our fitting routine requires a positive and adds before factorization to maintain a covariance eigenvalue floor. Its magnitude is a modelling choice: too small and the fit remains unstable, too large and the covariances are pulled toward spherical and the density is biased. When degeneracy persists, increase reg_covar, remove exact duplicates if they are artifacts, constrain the covariance type, or reduce the number of components rather than accepting a component that exists only to fit a handful of coincident points.
Summary
A Gaussian mixture model describes data as a weighted sum of Gaussian densities and treats the component membership of each observation as a latent variable. The model is fitted by maximizing a likelihood that contains the log of a sum, which has no closed-form solution, so we use Expectation-Maximization: compute the posterior probability of each component for each observation (the E-step), then re-estimate the means, covariances, and weights as if those posterior probabilities were fractional counts (the M-step). The updates are weighted versions of the ordinary maximum likelihood estimators, with responsibilities standing in for the unknown indicators, and exact bound-increasing EM updates cannot decrease the objective because the E-step makes the lower bound tight. Our practical diagonal-addition safeguard requires a separate decrease check.
The soft assignments are the point of the exercise. Where k-means commits, the mixture reports a probability, and that probability carries information about ambiguity that a hard boundary discards. The same posterior drives the covariance updates, which is what allows each component to take on its own orientation and aspect ratio, and the resulting density can be evaluated, sampled, and compared across models.
For exact EM the likelihood guarantee is nondecrease, not global optimality or parameter convergence. The likelihood is non-concave, so initialization and restarts determine the answer, and unrestricted mixtures with have an unbounded sample likelihood, so a covariance constraint or regularization is needed to prevent collapse. Components are terms in a density, not discovered truths about the world, and the number of components is a model selection decision to be made with penalized criteria rather than training likelihood. For continuous data with roughly elliptical components, a Gaussian mixture gives us a probability density and model-based soft assignments alongside a hard partition.
Quiz
Ready to test your understanding of Gaussian mixtures and Expectation-Maximization? Take this quick quiz to reinforce what you've learned about the model, the EM updates, and their practical pitfalls.
Join the discussionAsk a question or share what you learned.
Keep exploring
Reference
Citation details
Cite or share this article.


Comments
Add to the conversation
A question, a correction, or an example from your own work.
No comments yet. Be the first to share your thoughts!