Algorithms & Methods
Expectation Maximization (EM) algorithms
Currently, only the classic EM algorithm and the Stochastic EM are implemented for Distributions.MixtureModel. Look at the Bibliography section for references.
ExpectationMaximization.ClassicEM — Type
ClassicEM<:AbstractEMThe EM algorithm was introduced by A. P. Dempster, N. M. Laird and D. B. Rubin in 1977 in the reference paper Maximum Likelihood from Incomplete Data Via the EM Algorithm.
ExpectationMaximization.StochasticEM — Type
Base.@kwdef struct StochasticEM<:AbstractEM
rng::AbstractRNG = Random.GLOBAL_RNG
endThe Stochastic EM algorithm was introduced by G. Celeux, and J. Diebolt. in 1985 in The SEM Algorithm: A probabilistic teacher algorithm derived from the EM algorithm for the mixture problem.
The default random number generator is Random.GLOBAL_RNG. Pass another one with StochasticEM(rng) or StochasticEM(; rng = rng), e.g. StochasticEM(MersenneTwister(0)), to make a run reproducible. The argument must be an AbstractRNG: an integer seed such as StochasticEM(0) is a MethodError.
Main function
To fit the mixture, use the “instance” version of fit_mle(mix::MixtureModel, ...) as described below and NOT the “Type” version, i.e., fit_mle(Type{MixtureModel}, ...). The provided mix is used as the starting point of the EM algorithm. See Instance vs Type version section for more context.
Distributions.fit_mle — Method
fit_mle(mix::MixtureModel, y::AbstractVecOrMat, weights...; method = ClassicEM(), display=:none, maxiter=1000, atol=1e-3, rtol=nothing, robust=false, infos=false)Use an Expectation Maximization (EM) algorithm to maximize the Loglikelihood (fit) the mixture with an i.i.d sample y. The mix input is a mixture that is used to initialize the EM algorithm. When y is an AbstractMatrix, each column is one observation, i.e. there are size(y, 2) observations of dimension size(y, 1).
weightsat most one positional weight vectorw, of lengthsize_sample(y), may be given; it then computes a weighted version of the EM. (Useful for fitting mixture of mixtures)methoddetermines the algorithm used.infos = truereturns the tuple(mix_fitted, history)instead of justmix_fitted, wherehistory::Dict{String,Any}holds"converged"::Bool,"iterations"::Int(number of EM iterations actually performed) and"logtots"::Vector(the loglikelihood after each of those iterations). The iteration-0 loglikelihood is not stored, solength(history["logtots"]) == history["iterations"], and it is empty whenmaxiter = 0.robust = truewill prevent the (log)likelihood to overflow to-∞or∞.atolcriteria determining the convergence of the algorithm. If the Loglikelihood difference between two iterationiandi+1is smaller thanatoli.e.|ℓ⁽ⁱ⁺¹⁾ - ℓ⁽ⁱ⁾|<atol, the algorithm stops.rtolrelative tolerance for convergence,|ℓ⁽ⁱ⁺¹⁾ - ℓ⁽ⁱ⁾|<rtol*(|ℓ⁽ⁱ⁺¹⁾| + |ℓ⁽ⁱ⁾|)/2(does not check ifrtolisnothing)displayvalue can be:none,:iter,:finalto display Loglikelihood evolution at each iterations:iteror just the final one:final
Distributions.fit_mle — Method
fit_mle(mix::AbstractArray{<:MixtureModel}, y::AbstractVecOrMat, weights...; method = ClassicEM(), display=:none, maxiter=1000, atol=1e-3, rtol=nothing, robust=false, infos=false)Do the same as fit_mle for each (initial) mixture in the mix array, then keep the fit with the largest final loglikelihood history["logtots"][end] (taken as -Inf when logtots is empty, e.g. maxiter = 0); ties keep the earliest initial condition.
Every initial condition runs inside a try/catch, so one singular solution does not abort the whole sweep (using robust = true should be enough to avoid most errors in the first place). A failing initial condition is reported with @debug and skipped.
- An
InterruptExceptionis never swallowed: it is rethrown immediately, soCtrl-Cstill stops the sweep. - If every initial condition fails, the error raised by the first failing one is rethrown.
- All keywords are forwarded unchanged to
fit_mle(mix[j], y, weights...); see its docstring for their meaning. infos = truereturns(mix_best, history_best)for the selected fit instead of justmix_best.
Utilities
ExpectationMaximization.predict — Function
predict(mix::MixtureModel, y::AbstractVecOrMat; robust=false)Evaluate the most likely category for each observation given a MixtureModel, i.e. the argmax over the row of predict_proba belonging to that observation. Returns a length-N Vector{Int} of component indices in 1:ncomponents(mix); ties go to the lowest index. When y is an AbstractMatrix, each column is one observation, so N = size(y, 2).
robust = truewill prevent the (log)likelihood to overflow to-∞or∞.
ExpectationMaximization.predict_proba — Function
predict_proba(mix::MixtureModel, y::AbstractVecOrMat; robust=false)Evaluate the probability for each observation to belong to a category given a MixtureModel. Returns a fresh N × K matrix whose row n is the posterior distribution of the component label of observation n, with K = ncomponents(mix) and N = size_sample(y) (length(y) for a vector, size(y, 2) for a matrix, where each column is one observation). Every row sums to 1 unless it is degenerate, i.e. the observation has zero density under every component (see robust).
robust = truewill prevent the (log)likelihood to under(overflow)flow to-∞(or∞).
fit_mle methods that should be in Distribution.jl
I opened two PRs, PR#1670 and PR#1676 to add these methods.
The "instance" version of fit_mle allows passing a distribution instance (e.g., Normal(0,1)) instead of a type (e.g., Normal). This is required for MixtureModel and ProductDistribution support.
Distributions.fit_mle — Method
fit_mle(g::D, args...) where {D<:Distribution}In ExpectationMaximization.jl the "instance" version of fit_mle is supported (in addition of the current "type" version). Note this is not supported in Distributions.jl. Example: fit_mle(Bernoulli(0.2), x) is accepted in addition of fit_mle(Bernoulli, x) this allows compatibility with how fit_mle(g::Product) and fit_mle(g::MixtureModel) are written.
By default the instance is simply dropped in favour of typeof(g).name.wrapper. More specific methods are provided wherever the instance carries information the type does not: DiagNormal/IsoNormal/FullNormal (the covariance structure), Binomial (ntrials) and Categorical (ncategories).
Distributions.fit_mle — Method
fit_mle(g::Product, x::AbstractMatrix, args...)The fit_mle for a multivariate Product distribution g is the product_distribution of the fit_mle of each of its components, marginal s being fitted on row s of x — i.e. each column of x is one observation and length(g) == size(x, 1) is required. args... is forwarded to every marginal fit_mle, so it is either empty or a single weight vector γ of length size(x, 2). Product is meant to be deprecated in the next versions of Distributions.jl. Use the analog VectorOfUnivariateDistribution type instead.
Distributions.fit_mle — Method
fit_mle(dists::VectorOfUnivariateDistribution, x::AbstractMatrix{<:Real}, args...)The fit_mle for a VectorOfUnivariateDistribution dists is the product_distribution of the fit_mle of each of its components, marginal s being fitted on view(x, s, :) — i.e. each column of x is one observation and size(x, 1) == length(dists) is required. Because the row is passed as a view, component fit_mle methods must accept AbstractVector rather than Vector. args... is forwarded to every marginal fit_mle, so it is either empty or a single weight vector γ of length size(x, 2). VectorOfUnivariateDistribution should act like the old Product, while the sibling fit_mle(dists::ArrayOfUnivariateDistribution, x::AbstractArray, args...) (same idea, but x an array of arrays) is not really tested yet and is deliberately left undocumented.
Distributions.fit_mle — Method
fit_mle(::Type{<:Dirac}, x::AbstractArray{<:Real})
fit_mle(::Type{<:Dirac}, x::AbstractArray{<:Real}, w::AbstractArray{Float64})fit_mle for Dirac distribution (weighted or not) data sets. Returns Dirac(first(x)) when all the observations carrying a non-zero weight are equal, and Dirac(NaN) otherwise. Note that the weighted method requires w::AbstractArray{Float64} exactly: another real element type (e.g. Vector{Float32}) is a MethodError, since there is no other weighted Dirac method to fall back on.
Distributions.fit_mle — Method
fit_mle(::Type{<:Laplace}, x::AbstractArray{<:Real}, w::AbstractArray{<:Real})fit_mle for Laplace distribution weighted data sets.
Distributions.fit_mle — Method
fit_mle(::Type{<:Uniform}, x::AbstractArray{<:Real}, w::AbstractArray{<:Real})fit_mle for Uniform distribution weighted data sets. It is the same as the unweighted fit applied to the observations carrying a non-zero weight: the MLE only depends on the extrema of the support, so the non-zero weight values themselves are irrelevant. Requires size(x) == size(w).
How the implementation is organised
The math
A mixture of $K$ components with weights $\alpha$ has density $p(y) = \sum_k \alpha_k f_k(y)$. Introducing the latent label $z_n \in \{1, \cdots,K\}$ of observation $n$, the EM algorithm alternates between the posterior of that label (E-step) and a refit of every component with those posteriors as weights (M-step):
\[\gamma_{nk} = \mathbb{P}(z_n = k \mid y_n) = \frac{\alpha_k f_k(y_n)}{\sum_j \alpha_j f_j(y_n)}, \qquad \alpha_k \leftarrow \frac{1}{N}\sum_n \gamma_{nk}, \qquad \theta_k \leftarrow \arg\max_{\theta} \sum_n \gamma_{nk} \log f(y_n; \theta),\]
each iteration increasing the loglikelihood
\[\ell = \sum_n \log p(y_n) = \sum_n \log \sum_k \alpha_k f_k(y_n) = \sum_n c_n .\]
StochasticEM inserts an S-step: instead of the soft weights $\gamma_n$ it draws one hard label $\hat{z}_n \sim \mathrm{Categorical}(\gamma_n)$ per observation, and refits component k on the observations that drew it — an unweighted fit_mle on a subsample.
The same thing in code
Nothing here maximises anything by itself: the update of θₖ is delegated to Distributions.jl, where fit_mle(dists[k], y, γₖ) is the weighted maximum-likelihood estimate of component k. Everything is computed in the log domain.
N, K = size_sample(y), length(dists)LL = zeros(N, K)c = zeros(N)γ = LL # in practice γ aliases LL: the posteriors overwrite the log-likelihoodsℓ = -Inffor it in 1:maxiter # E-step for k in 1:K LL[:, k] .= log(α[k]) .+ logpdf.(dists[k], y) # log αₖ + log fₖ(yₙ); for a matrix sample end # each column is one observation c .= logsumexp.(eachrow(LL)) # cₙ = log p(yₙ) γ .= exp.(LL .- c) # in practice these two lines are a single fused, # allocation-free, column-major kernel that # subtracts the row maximum before `exp` # Convergence: ℓ is free, cₙ is already the log density of yₙ ℓ_new = sum(c) # `sum(w .* c)` for a weighted fit abs(ℓ_new - ℓ) < atol && break # `rtol` adds a relative test ℓ = ℓ_new # M-step for k in 1:K α[k] = sum(γ[:, k]) / N # length(cat[k])/N for StochasticEM dists[k] = fit_mle(dists[k], y, γ[:, k]) # StochasticEM instead fits each component on a end # view of the observations that drew itendOne N × K matrix is allocated per fit. It first holds the log-likelihoods log αₖ + log fₖ(yₙ), and the normalisation then overwrites every entry with the corresponding posterior γₙₖ. So once the E-step has run the log-likelihoods are gone, and anything added after it must not read that matrix expecting to find them. The low-level functions still take LL and γ as two separate arguments, so pass two different matrices if you need the log-likelihoods and the posteriors at the same time.
Extension points
- A faster per-component likelihood: for a matrix sample, implementing
Distributions._logpdf!for your component is already enough, since that is what the E-step calls. Otherwise add aExpectationMaximization.loglikelihoods!method. All it has to do is fillLL[n, k]withlog αₖ + log fₖ(yₙ), as the E-step above does; how it computes that value is up to you. - A different parameter update: add a
ExpectationMaximization.M_step!method for your method type (both the weighted and the unweighted signature). - A different algorithm:
struct MyEM <: AbstractEM endplus afit_mle!(α, dists, y, w, ::MyEM; kwargs...)returning theDict{String,Any}with"converged","iterations"and"logtots".
Low-level API
The following functions implement the inner loop of the EM algorithms. They can be extended to support custom behavior.
EM loop entry points
ExpectationMaximization.fit_mle! — Method
fit_mle!(α::AbstractVector, dists::AbstractVector{F} where {F<:Distribution}, y::AbstractVecOrMat, method::ClassicEM; display=:none, maxiter=1000, atol=1e-3, rtol=nothing, robust=false)
fit_mle!(α::AbstractVector, dists::AbstractVector{F} where {F<:Distribution}, y::AbstractVecOrMat, w::Union{Nothing,AbstractVector}, method::ClassicEM; display=:none, maxiter=1000, atol=1e-3, rtol=nothing, robust=false)Use the EM algorithm to update in place the Distribution dists and weights α composing a mixture distribution. When y is an AbstractMatrix, each column is one observation. w, when given and not nothing, is a weight vector of length size_sample(y). Returns history::Dict{String,Any} with keys "converged"::Bool, "iterations"::Int and "logtots"::Vector (loglikelihood after each performed iteration, empty when maxiter = 0).
robust = truewill prevent the (log)likelihood to overflow to-∞or∞.atolcriteria determining the convergence of the algorithm. If the Loglikelihood difference between two iterationiandi+1is smaller thanatoli.e.|ℓ⁽ⁱ⁺¹⁾ - ℓ⁽ⁱ⁾|<atol, the algorithm stops.rtolrelative tolerance for convergence,|ℓ⁽ⁱ⁺¹⁾ - ℓ⁽ⁱ⁾|<rtol*(|ℓ⁽ⁱ⁺¹⁾| + |ℓ⁽ⁱ⁾|)/2(does not check ifrtolisnothing)displayvalue can be:none,:iter,:finalto display Loglikelihood evolution at each iterations:iteror just the final one:final
ExpectationMaximization.fit_mle! — Method
fit_mle!(α::AbstractVector, dists::AbstractVector{F} where {F<:Distribution}, y::AbstractVecOrMat, method::StochasticEM; display=:none, maxiter=1000, atol=1e-3, rtol=nothing, robust=false)
fit_mle!(α::AbstractVector, dists::AbstractVector{F} where {F<:Distribution}, y::AbstractVecOrMat, w::Union{Nothing,AbstractVector}, method::StochasticEM; display=:none, maxiter=1000, atol=1e-3, rtol=nothing, robust=false)Use the stochastic EM algorithm to update in place the Distribution dists and weights α composing a mixture distribution. When y is an AbstractMatrix, each column is one observation. w, when given and not nothing, is a weight vector of length size_sample(y). Returns history::Dict{String,Any} with keys "converged"::Bool, "iterations"::Int and "logtots"::Vector (loglikelihood after each performed iteration, empty when maxiter = 0).
- Throws a
DomainErrorif the loglikelihood is not finite: some observation then has zero density under every component, its posterior row isNaN, and the S-step would silently assign it to component1. Tryrobust = trueor another initial condition. robust = truewill prevent the (log)likelihood to overflow to-∞or∞.atolcriteria determining the convergence of the algorithm. If the Loglikelihood difference between two iterationiandi+1is smaller thanatoli.e.|ℓ⁽ⁱ⁺¹⁾ - ℓ⁽ⁱ⁾|<atol, the algorithm stops.rtolrelative tolerance for convergence,|ℓ⁽ⁱ⁺¹⁾ - ℓ⁽ⁱ⁾|<rtol*(|ℓ⁽ⁱ⁺¹⁾| + |ℓ⁽ⁱ⁾|)/2(does not check ifrtolisnothing)displayvalue can be:none,:iter,:finalto display Loglikelihood evolution at each iterations:iteror just the final one:final
E-step
ExpectationMaximization.E_step! — Function
E_step!(LL, c, γ, s, dists, α, y; robust=false)E-step, in two stages: loglikelihoods! fills LL[n, k] = log(α[k]) + logpdf(dists[k], y[n]) (y[:, n] for multivariate samples), then _softmax_rows! turns every row into a posterior. Returns γ.
LLtheN × Klog-likelihood matrix. Entirely overwritten, first with the log-likelihoods and then with the posteriors.ca length-Nvector filled withc[n] = logsumexp(LL[n, :]) = log ℙ(y[n]). Thefit_mle!drivers sum (or weight-sum) it to get the loglikelihood, so no extra pass overyis needed.γtheN × Kposterior matrix,γ[n, k] = ℙ(zₙ = k ∣ yₙ). It may aliasLL, and every caller in this package passesγ === LL: the log-likelihoods are not needed once the posteriors have been formed, so the posteriors are written over them. Aliasing is therefore only safe as long as nothing afterwards readsLLexpecting log-likelihoods; pass a distinctγif you need both matrices at once.sa length-Nscratch vector. Its contents are meaningless on entry and on exit.dists,αthe current components and mixing weights;ythe sample (a vector, or aD × Nmatrix).robust = trueclamps±Inflog-likelihoods before normalizing, which is what prevents a degenerate, unnormalized row ofγ.
ExpectationMaximization.loglikelihoods! — Function
loglikelihoods!(LL::AbstractMatrix, dists, α, y::AbstractVector)
loglikelihoods!(LL::AbstractMatrix, dists, α, y::AbstractMatrix)Fill LL[n, k] = log(α[k]) + logpdf(dists[k], y[n]) and return LL. For the AbstractMatrix method each column of y is one observation, so the entry is log(α[k]) + logpdf(dists[k], y[:, n]).
This is the extension hook of the E-step: add a method for your component or sample type if it can score a whole sample at once. All such a method has to do is give LL[n, k] the value above; how it computes it is up to you.
M-step
ExpectationMaximization.M_step! — Function
M_step!(α, dists, y, γ, method::ClassicEM)
M_step!(α, dists, y, γ, w, method::ClassicEM)For the ClassicEM the weights γ computed at the E-step for each observation in y are used to update α and dists in place: α[k] = mean(γ[:, k]) and dists[k] = fit_mle(dists[k], y, γ[:, k]).
The weighted variant folds the observation weights into the posteriors once (γ .*= w) and normalizes α by sum(w), rather than materializing w .* γ[:, k] for each of the K components. It therefore overwrites γ, which is sound inside fit_mle! only because the next E-step rewrites γ entirely — so γ must not be read after the M-step.
M_step!(α, dists, y, cat, method::StochasticEM)
M_step!(α, dists, y, cat, w, method::StochasticEM)For the StochasticEM the cat drawn at S-step for each observation in y is used to update α and dists in place. cat[k] indexes the observations assigned to component k, so the subsample is passed as a view rather than copied — component fit_mle methods must therefore accept SubArrays. A component fit_mle that slices such a view by rows should gather it once first, as fit_mle(::Product, ::AbstractMatrix, args...) does through _gather_rows: row-slicing a view(y, :, cat[k]) re-copies its vector column index once per row. The weighted variant sets α[k] = sum(w[cat[k]]) / sum(w) and forwards view(w, cat[k]) to each component fit.
Internals
These helpers are implementation details: they start with an underscore, they are not exported, and their signatures can change in any patch release.
ExpectationMaximization._loglikelihood_col! — Function
_loglikelihood_col!(LLₖ, d::MvNormal, logα, y::AbstractMatrix)Distributions.sqmahal!, which the generic path reaches through logpdf!, materializes a D × N centered copy of the sample and then still solves one triangular system per observation. These methods evaluate the entire sample in one pass instead:
- isotropic or diagonal
Σ: a weighted sum of squares, with no temporary at all; - full
Σ: one blocked in-placePDMats.whiten!(a BLAS-3trsm) perMVNORMAL_BLOCKSIZEobservations, instead of one BLAS-2trsvper observation.
Measured 3.5x-11.4x faster than the generic path for D from 2 to 100 at N = 1e5, agreeing to a few units in the last place, with an allocation bounded by MVNORMAL_BLOCKSIZE rather than growing with N.
ExpectationMaximization._softmax_rows! — Function
_softmax_rows!(c, γ, LL, s)Fused allocation-free row-wise log-sum-exp and softmax: writes c[n] = logsumexp(LL[n, :]) and γ[n, :] = softmax(LL[n, :]) in a single set of column-major passes, where logsumexp! needed a 16N-byte temporary plus a second pass over LL.
γmay aliasLL: every element is a same-index read-then-write and no later pass re-readsLL.sis a length-Nscratch vector.- Rows whose maximum is not finite are left unnormalized so that
candγreproduce the previouslogsumexp!based implementation bit for bit (this is whatrobust = trueis for).
Specialized components
For a matrix sample the generic E-step hands each component to Distributions.logpdf!, the batched entry point of Distributions.jl. Its own fallback is one logpdf call per observation — what this package used to do by hand — but a component that implements Distributions._logpdf! scores the whole sample in a single call, at no cost in the generic code. A nested MixtureModel component gets its speed from exactly that: 2.8× on the E-step of a mixture of multivariate mixtures and 1.5× on the whole fit, with a bit-identical loglikelihood. Product distributions have no batched method, so for them the delegation is a measured no-op.
MvNormal is the family where that is not enough. logpdf! reaches Distributions.sqmahal!, which materialises a D × N centred copy of the sample and then still solves one triangular system per observation; for an isotropic covariance it is even slower than the per-observation path (measured 1.8× at D = 50, which is why the delegation and these kernels belong together). src/specialized.jl adds fast paths for MvNormal as extra ExpectationMaximization._loglikelihood_col! methods, selected by dispatch on the component type, so nothing in the generic path changes and any component they do not match keeps the delegated logpdf! path; deleting the file would only make the package slower.
Two kernels cover the three covariance shapes:
- isotropic or diagonal
Σ: the Mahalanobis form is a plain weighted sum of squares, evaluated in one pass with no temporary at all. - full
Σ: the sample is processed in blocks ofMVNORMAL_BLOCKSIZEobservations, each block centred into a cache-residentD × MVNORMAL_BLOCKSIZEbuffer and whitened with one in-placePDMats.whiten!— a BLAS-3trsm— instead of the one BLAS-2trsvper observation thatlogpdfperforms.
Measured speedup over the delegated logpdf! path, for one component, at N = 10⁵, single-threaded:
D | 2 | 10 | 50 | 100 |
|---|---|---|---|---|
FullNormal | 11.4× | 5.6× | 4.2× | 3.5× |
DiagNormal | 3.9× | 5.2× | 7.7× | 7.4× |
IsoNormal | 4.4× | 4.0× | 5.6× | 5.4× |
Over that same range the allocation of one component's E-step goes from 1.5 MB (D = 2) to 76.3 MB (D = 100) down to 206 KB for a full covariance and under 2 KB for the other two — and, unlike the generic path, it does not grow with N. The results agree with the generic path to a few units in the last place, and the ZeroMean* variants are covered for free because the IsoNormal/DiagNormal/FullNormal aliases only constrain the covariance and element types.
The same file also adds a Distributions.fit_mle(::FullNormal, y, w) method for the M-step. It computes exactly the same weighted maximum-likelihood estimate as Distributions.jl — the mean is bit-identical and the covariance agrees to about 1e-14, the difference being the order of accumulation — but builds the scatter matrix blockwise with syrk! into a reused buffer instead of allocating a fresh D × N array on every call. DiagNormal and IsoNormal deliberately keep their own fits, which are already cheap and, more importantly, preserve the covariance type of the component.
If your component can score a whole sample at once, implementing Distributions._logpdf!(out, d, y) is enough and nothing here needs to change. Write a _loglikelihood_col! method (and, if the maximum-likelihood estimate can reuse a buffer, a fit_mle method) only when you also want what logpdf! cannot express: folding log α into the same pass, or reusing a scratch buffer across calls, as the kernels above do. The contract is then only the value of LL[n, k] given in How the implementation is organised; everything else, including the γ-aliases-LL convention, is handled by the generic E-step.
Index
ExpectationMaximization.ClassicEMExpectationMaximization.StochasticEMDistributions.fit_mleDistributions.fit_mleDistributions.fit_mleDistributions.fit_mleDistributions.fit_mleDistributions.fit_mleDistributions.fit_mleDistributions.fit_mleExpectationMaximization.E_step!ExpectationMaximization.M_step!ExpectationMaximization._loglikelihood_col!ExpectationMaximization._softmax_rows!ExpectationMaximization.fit_mle!ExpectationMaximization.fit_mle!ExpectationMaximization.loglikelihoods!ExpectationMaximization.predictExpectationMaximization.predict_proba