Every classification model I had built up to this point started from a column of known labels. Fraud or not fraud, churned or retained, something a human had already decided and recorded. I sat down recently with a pile of short text documents that had no such column, no topic label anywhere, and the instruction was effectively “find the groups yourself.” That is a genuinely different problem, not a smaller version of the same one, and it needed a different kind of learning to solve it.

I had already worked through the EM algorithm for clustering numeric measurements into Gaussian mixture components. The same algorithm, expectation-maximisation, applies here too, with one change that matters: instead of assuming each cluster generates points from a Gaussian, each cluster generates a document by repeatedly drawing words from its own distribution over the vocabulary. Swap the distribution family and the same two-step machinery carries across.

Setting up the model

Represent each document as a vector of word counts over a fixed vocabulary, the standard bag-of-words treatment that throws away order and keeps only frequency. Assume there are k clusters, or topics, and each cluster c has its own multinomial distribution over the vocabulary, a vector phi_c where each entry is the probability of drawing a particular word given that a document belongs to cluster c. Each cluster also has a mixing weight pi_c, the prior probability that a randomly chosen document belongs to it, with all the pi_c values summing to one.

The latent variable in this model is which cluster generated each document, and that is precisely the thing we do not observe. If we did know it, fitting the model would be trivial, count word frequencies within each known group, normalise, done. Not knowing it is what makes this an EM problem rather than a counting exercise, and the algorithm handles the unknown assignment by treating it probabilistically rather than by guessing a hard answer up front.

E-step: computing responsibilities

Given a current guess for phi_c and pi_c for every cluster, the E-step computes, for every document, the probability it belongs to each cluster. This is the responsibility, the same quantity from the Gaussian mixture case, just computed from a different likelihood.

compute_responsibilities <- function(doc_term_matrix, phi, pi_weights) {
  n_docs <- nrow(doc_term_matrix)
  k <- nrow(phi)
  log_probs <- matrix(0, nrow = n_docs, ncol = k)

  for (c in 1:k) {
    log_probs[, c] <- log(pi_weights[c]) +
      doc_term_matrix %*% log(phi[c, ] + 1e-10)
  }

  max_log <- apply(log_probs, 1, max)
  probs <- exp(log_probs - max_log)
  probs / rowSums(probs)
}

Working in log space and subtracting the row maximum before exponentiating is not optional here. Multiplying dozens of small word probabilities together underflows to exactly zero in ordinary floating point well before you get through a realistic vocabulary size, and once every cluster’s likelihood for a document underflows to zero the responsibilities become 0/0. Doing the arithmetic as sums of logs and only exponentiating right at the end, after subtracting off the largest value in each row, keeps the numbers in a range floating point can represent. This tripped me up on the first version of this function, which worked fine on a five-word toy vocabulary and produced NaN for every document the moment I tried it against something closer to real size.

Circular flow diagram of the EM algorithm applied to document clustering: E-step computes document-cluster responsibilities shown as probability bars, M-step updates each cluster's word distribution, a convergence check, then repeat until stable

M-step: updating the word distributions

With responsibilities in hand, the M-step recomputes phi_c and pi_c, weighting every document’s contribution by how responsible each cluster is for it rather than assigning it fully to one cluster.

update_parameters <- function(doc_term_matrix, responsibilities) {
  n_docs <- nrow(doc_term_matrix)
  k <- ncol(responsibilities)
  vocab_size <- ncol(doc_term_matrix)

  phi <- matrix(0, nrow = k, ncol = vocab_size)
  for (c in 1:k) {
    weighted_counts <- colSums(doc_term_matrix * responsibilities[, c])
    phi[c, ] <- weighted_counts / sum(weighted_counts)
  }

  pi_weights <- colSums(responsibilities) / n_docs
  list(phi = phi, pi_weights = pi_weights)
}

The phi[c, ] normalisation, dividing by sum(weighted_counts), is where a Lagrange multiplier is quietly doing its job. Maximising the expected log-likelihood with respect to phi_c on its own would push every word probability towards whatever maximises the fit to the data, with no constraint stopping the values from drifting away from summing to one. Adding a Lagrange multiplier for the constraint sum(phi_c) = 1 and solving the resulting system is what produces this specific, clean update rule, weighted word counts over their own total. I did not need to redo that derivation from scratch to use the result, but seeing why the normalisation in the code is not just a reasonable-looking tidy-up, it is the actual solution to a constrained optimisation problem, made the update rule feel earned rather than assumed.

Running it and watching convergence

em_document_clustering <- function(doc_term_matrix, k, max_iter = 100, tol = 1e-4) {
  n_docs <- nrow(doc_term_matrix)
  vocab_size <- ncol(doc_term_matrix)

  phi <- matrix(runif(k * vocab_size), nrow = k)
  phi <- phi / rowSums(phi)
  pi_weights <- rep(1 / k, k)

  prev_ll <- -Inf
  for (iter in 1:max_iter) {
    resp <- compute_responsibilities(doc_term_matrix, phi, pi_weights)
    updated <- update_parameters(doc_term_matrix, resp)
    phi <- updated$phi
    pi_weights <- updated$pi_weights

    ll <- sum(log(rowSums(sapply(1:k, function(c)
      pi_weights[c] * exp(doc_term_matrix %*% log(phi[c, ] + 1e-10))))))

    if (abs(ll - prev_ll) < tol) break
    prev_ll <- ll
  }
  list(phi = phi, pi_weights = pi_weights, responsibilities = resp, iterations = iter)
}

Random initialisation of phi here carries the same risk it did for the Gaussian case: a poor starting point can settle into a local maximum where two clusters end up capturing near-identical topics while a third splits what should have been one coherent group. Running the fit several times from different random starts and keeping the run with the highest final log-likelihood is the same defence as before, and it is worth the extra runtime given how cheap each individual run is on a modest document collection.

Why the soft assignment matters more here than with numbers

With numeric clustering, the borderline cases were points that sat near a boundary between two well-defined groups. With documents, borderline cases are far more common, because a document rarely belongs to exactly one topic in any real sense. A single article can genuinely be about two things at once, in proportions that a hard cluster label cannot represent no matter how the boundary is drawn.

Two panels showing the same set of documents clustered twice, left panel hard clustering with each document placed in exactly one of two groups, right panel soft clustering with each document shown carrying a fractional membership split between both clusters

That right panel is the entire justification for using EM over a simpler approach like running k-means on term frequency vectors. k-means would force every document into one bucket, the same hard partition it forces on numeric data, and it would do so with no visibility into which documents it was unsure about. EM’s responsibilities matrix keeps that uncertainty on the record. A document with a responsibility close to 0.5 for two different clusters is not a modelling failure, it is a correct and informative answer, and it is one that a hard clustering method has no way to express at all.

What carries over from the earlier version

The mechanics are identical to the Gaussian case in every way that matters: guess parameters, compute responsibilities under those parameters, update the parameters weighted by those responsibilities, repeat until the log-likelihood stops moving. Only the shape of the thing being estimated changes, a mean and covariance for continuous data, a distribution over a vocabulary for text. That portability is what makes EM worth understanding properly rather than as a k-means variant. Once the E-step and M-step structure is genuinely understood rather than memorised for one data type, swapping in a different generative assumption for a different kind of data stops being a research problem and starts being an afternoon’s work.