A Laplace library of clustering and mixture-model building blocks for Stan — soft k-means, Gaussian mixtures from spherical to full covariance, Poisson and negative-binomial mixtures, latent class analysis, Naive Bayes / LDA document clustering, mixtures of regressions, and the generated-quantities utilities that go with all of them (responsibilities, assignment, entropy, co-clustering). Import it into any .laplace model and call it with namespaced calls (cluster::function_name(...)).
Like all Laplace libraries, cluster compiles down to plain, readable Stan functions. Nothing about how you use it hides what actually ends up in your .stan file.
The library is layered. Every family function returns the same shape — an N x K matrix of component log-densities — so any one of them can be dropped into any of the functions above it without changes.
- Core (
mixture_pointwise,mixture_lpdf,responsibilities,log_responsibilities) is the family-agnostic mixing step: given anN x Kmatrix of component log-densitiesLand log-weights, it marginalizes over clusters and recovers posterior membership. This is the only layer every model in the library goes through. - Components (
sq_dist,kmeans_loglik,spherical_loglik,diag_loglik,mvn_chol_loglik,pois_lik,negbin_loglik,latent_class_loglik,multinomial_loglik,regression_loglik) each compute one family'sL. Swapping the component family is a one-line change; the core layer doesn't change. - Weights (
stick_breaking_log_weights,softmax_log_weights) build the log-weights that Core needs, for a truncated Dirichlet-process prior or for covariate-dependent mixing proportions. - Utilities (
assign_rng,map_assign,assignment_entropy,complete_loglik,coclustering) turn responsibilities into labels, uncertainty measures, and diagnostics, forgenerated quantities. - Models (
mixture_ss_lpdf,lda_lpmf) are named, complete likelihoods built on top of the layers above: semi-supervised mixtures and Latent Dirichlet Allocation.
Every family and every weight function in this library returns log-scale quantities and stays on the log scale until the last possible moment — that's what makes them safe to mix, sum, and reuse across layers without underflow.
| Symbol | Meaning |
|---|---|
| observations, features, clusters | |
| data matrix | |
| mixing weights (a simplex) | |
|
|
component log-densities — what every function in Layer 2 returns |
|
|
responsibilities — posterior probability that observation |
Each function returns an N x K matrix L. Normalizing constants are kept (except kmeans_loglik, noted below), so these are safe to compare across families with loo.
| Function | Arguments after y |
Models |
|---|---|---|
kmeans_loglik |
mu, beta |
Soft k-means: unit-shape Gaussian, constants dropped |
spherical_loglik |
mu, sd |
Gaussian, shared shape, one scale per cluster |
diag_loglik |
mu, S |
Gaussian, independent scale per cluster and per feature |
mvn_chol_loglik |
mu, C |
Gaussian, full covariance per cluster (Cholesky factor) |
pois_lik |
lambda |
Poisson counts, features conditionally independent |
negbin_loglik |
mu, psi |
Negative binomial (mean/precision) counts |
latent_class_loglik |
p, m |
Independent Bernoulli items, with a missingness mask |
multinomial_loglik |
phi |
Bag-of-words / count vectors (Naive Bayes document clustering) |
regression_loglik |
X, B, sd |
A separate linear regression per cluster (mixture of regressions) |
sq_dist(y, mu) is the shared building block behind the Gaussian families — the pairwise squared-distance matrix, computed with one matrix product instead of an N * K * D loop:
kmeans_loglik is not normalized. It's loo. Use spherical_loglik with sd fixed if you need the constant kept.
| Function | Returns | What it does |
|---|---|---|
stick_breaking_log_weights(v) |
vector[K] |
Stick-breaking weights from K-1 break proportions; base of the truncated Dirichlet-process prior |
softmax_log_weights(X, G) |
matrix[N, K] |
Covariate-dependent weights via softmax regression, last class fixed as the zero-logit reference |
Every Core, Utility, and Model function that takes log_theta has two overloads: a vector for weights shared across all observations, and a matrix for observation-specific weights (what softmax_log_weights returns). Pick whichever matches how you built your weights.
With
| Component family | |
|---|---|
| Spherical Gaussian in |
|
| Diagonal Gaussian | |
| Full-covariance Gaussian | |
| Latent class ( |
For a univariate normal mixture,
| Function | Returns | What it does |
|---|---|---|
assign_rng(R) |
array[N] int |
One sampled cluster label per observation, z_n ~ Categorical(r_n.)
|
map_assign(R) |
array[N] int |
Most probable cluster per observation, argmax_k r_nk
|
assignment_entropy(R) |
vector[N] |
Per-observation uncertainty, |
complete_loglik(R, L, log_theta) |
real |
ICL-style criterion: |
coclustering(R) |
matrix[N, N] |
|
coclustering and assignment_entropy are invariant to relabeling components; assign_rng and map_assign are not — see Label switching below before summarizing labels across posterior draws.
| Form | What it does |
|---|---|
mixture_ss_lpdf(L, log_theta, z) |
Semi-supervised mixture: known labels in z contribute directly, z[n] = 0 marginalizes as usual |
lda_lpmf(Y, Theta, Phi) |
Latent Dirichlet Allocation, marginalized to word counts |
mixture_ss_lpdf with every entry of z at 0 is exactly mixture_lpdf; with every entry known it's supervised Naive Bayes classification.
Every function carries @brief, @param, @return, @math, and @example documentation, so you can read it from the terminal without leaving your model:
laplace doc cluster::spherical_loglik
cluster is distributed as a git-hosted Laplace library — there's no published registry entry yet, so it's added by pointing laplace (or cmdlaplacer, if you're working from R) directly at the repository. The package lives in the repository's laplace/ subdirectory, so pass it as the subdir.
From inside a Laplace project (a directory with its own laplace.toml):
laplace add cluster --git https://github.com/mlatinov/laplace-cluster --tag 0.1.0 --subdir laplace
library(cmdlaplacer)
laplace_install_git(
"cluster",
"https://github.com/mlatinov/laplace-cluster",
tag = "0.1.0",
subdir = "laplace"
)Either way, this pins the dependency in your project's laplace.toml/laplace.lock at tag 0.1.0. Check the tags for newer versions as they become available.
Import the library in a library { } block and call its functions with the cluster:: namespace prefix.
The simplest model in the library: uniform weights, fixed spherical variance, beta as the inverse temperature. beta = 1 recovers plain soft k-means; larger beta sharpens the assignment toward hard k-means.
library {
import cluster
}
data {
int<lower=1> N;
int<lower=1> D;
int<lower=1> K;
matrix[N, D] y;
real<lower=0> beta;
}
parameters {
matrix[K, D] mu;
}
model {
to_vector(mu) ~ normal(0, 5);
matrix[N, K] L = cluster::kmeans_loglik(y, mu, beta);
vector[K] log_theta = rep_vector(-log(K), K); // uniform weights
target += cluster::mixture_lpdf(L, log_theta);
}
generated quantities {
matrix[N, K] L = cluster::kmeans_loglik(y, mu, beta);
vector[K] log_theta = rep_vector(-log(K), K);
matrix[N, K] R = cluster::responsibilities(L, log_theta);
array[N] int z_hat = cluster::map_assign(R);
vector[N] H = cluster::assignment_entropy(R);
}
Learned weights, learned per-cluster scale, an ordered constraint on one coordinate to keep label switching in check for a 1-D-separable problem.
library {
import cluster
}
data {
int<lower=1> N;
int<lower=1> D;
int<lower=1> K;
matrix[N, D] y;
real<lower=0> alpha; // Dirichlet concentration; alpha < (D+1)/2 for a sparse prior
}
parameters {
ordered[K] mu_1; // first coordinate only, to fix labeling
matrix[K, D - 1] mu_rest;
vector<lower=0>[K] sd;
simplex[K] theta;
}
transformed parameters {
matrix[K, D] mu = append_col(mu_1, mu_rest);
}
model {
mu_1 ~ normal(0, 5);
to_vector(mu_rest) ~ normal(0, 5);
sd ~ exponential(1);
theta ~ dirichlet(rep_vector(alpha, K));
matrix[N, K] L = cluster::spherical_loglik(y, mu, sd);
target += cluster::mixture_lpdf(L, log(theta));
}
generated quantities {
matrix[N, K] L = cluster::spherical_loglik(y, mu, sd);
matrix[N, K] R = cluster::responsibilities(L, log(theta));
array[N] int z_hat = cluster::map_assign(R);
real cll = cluster::complete_loglik(R, L, log(theta));
vector[N] log_lik = cluster::mixture_pointwise(L, log(theta)); // for loo
}
multinomial_loglik gives you unsupervised document clustering (one topic per document) in a couple of lines; lda_lpmf is the same idea generalized to a topic per token.
library {
import cluster
}
data {
int<lower=1> M; // documents
int<lower=1> V; // vocabulary size
int<lower=1> K; // topics
array[M, V] int<lower=0> Y; // word counts
real<lower=0> alpha;
real<lower=0> beta;
}
parameters {
simplex[V] phi[K]; // topic-word distributions
}
transformed parameters {
matrix[K, V] Phi;
for (k in 1:K) Phi[k] = phi[k]';
}
model {
for (k in 1:K) phi[k] ~ dirichlet(rep_vector(beta, V));
// treat Y as real for multinomial_loglik's matrix signature
matrix[N, K] L = cluster::multinomial_loglik(to_matrix(Y), Phi);
vector[K] log_theta = rep_vector(-log(K), K);
target += cluster::mixture_lpdf(L, log_theta);
}
Switching to full LDA (a topic per token, not per document) means adding a Theta per-document simplex and calling cluster::lda_lpmf(Y, Theta, Phi) directly in model instead — see lda_lpmf's @example via laplace doc cluster::lda_lpmf.
With cmdlaplacer, the .laplace file compiles straight to a cmdstanr model, and the generated .stan file stays on disk next to it:
library(cmdlaplacer)
mod <- laplace_model("spherical_mixture.laplace")
fit <- mod$sample(data = list(N = N, D = D, K = 3, y = y, alpha = 0.5))
fit$draws("z_hat")-
Lis a log-likelihood, not the log-likelihood.L[n, k]is how plausible pointnis under clusterkalone. The model's actual log-likelihood only exists aftermixture_lpdf/mixture_pointwisesums overk. Don't call a family function andtarget +=it directly — it always goes through Core first. -
kmeans_loglikdrops constants; the other families don't. Comparekmeans_loglikfits against each other, never againstspherical_loglikordiag_loglikfits withloo— the additive constant differs. -
Vectorized Stan
_lpdf/_lpmfcalls sum.normal_lpdf(y | mu, sigma)on a vector returns one scalar. Every family function in this library loops explicitly (or uses a matrix-product identity) specifically to keep theN x Kshape — don't try to shortcut them with Stan's built-in vectorized forms, you'll lose the per-observation structure the rest of the library needs. -
Use
target +=, not~. As withlaplace-ts,cluster::mixture_lpdf(L, log_theta)is atarget +=call, not a sampling statement. -
Unbounded likelihood spikes.
spherical_loglik,diag_loglik, andmvn_chol_loglikall sendL[n,k] -> +infif a component's scale shrinks to zero around a single point. Put a weakly informative prior (or a lower bound) on every scale parameter —exponential(1)onsdis a reasonable default. -
Standardize before using distance-based families.
sq_dist,kmeans_loglik, andspherical_loglikare Euclidean and unit-sensitive. Center and scale continuous features first, or clusters will be dominated by whichever feature happens to have the largest numeric range. -
Label switching. The mixture likelihood is invariant to permuting components, so
mu_k,sd_k,theta_kfor a specifick, and anything fromassign_rng/map_assign, are not safe to average across posterior draws without a fix.mixture_pointwise(forloo),coclustering, andassignment_entropyare safe as-is. Anorderedconstraint on one coordinate ofmu(shown above) is the cheapest fix when clusters separate on that coordinate; post-hoc relabeling is the general one. -
complete_loglikandmixture_ss_lpdfneed the matchinglog_theta. Both come in avectorandmatrixoverload — use whichever one built theR/Lyou're passing in, or the ICL identity ($\ell_n - \sum_k r_{nk}a_{nk} = H_n$ ) won't hold. -
_lpmfrequires an integer first argument. Stan's naming convention enforces it at compile time.multinomial_loglikandlatent_class_logliktake counts asmatrix(so they aren't_lpmf-suffixed) specifically so their data can flow into matrix products without a cast;lda_lpmftakesarray[,] int Ybecause LDA's likelihood only needs elementwise indexing, not a product withYitself. -
multinomial_loglikandlatent_class_loglikare Naive Bayes. Feed either one'sLintomixture_ss_lpdfwith partially knownzfor semi-supervised classification instead of pure clustering. -
No Hidden Markov Model wrapper in 0.1.0. Stan's
hmm_hidden_state_probandhmm_latent_rngrequire their inputs to be provably data-only (no autodiff support), which rules out passing them anyLbuilt from parameters — the usual case here.hmm_marginal(differentiable, usable fortarget +=) works fine with any of this library'sLtransposed toK x N, but per-draw state decoding has to be done outside Stan, from savedL, transition matrix, and initial-state draws. Not shipped as a wrapper here; do it ingenerated quantitiesby saving those and post-processing in R. -
No unknown-K / infinite mixtures beyond stick-breaking.
stick_breaking_log_weightsgives you a truncated Dirichlet-process prior at aKyou still choose. A fully nonparametric sampler (reversible-jump, or a marginalized DP) isn't in this library.
See LICENSE.