Nonparametric Latent Class Analysis¶

The Dirichlet-process mixture: inferring the number of classes instead of choosing it¶

The nonparametric project of the Latent Class Analysis arc. Project 2 spent a whole notebook choosing the number of latent classes $C$ with six different criteria. This notebook removes the choice: a Dirichlet-process latent class model (DP-LCA) puts a prior over a countably infinite set of classes, of which only finitely many are occupied by any finite sample, so the number of occupied classes $K$ becomes an ordinary posterior quantity — inferred, not selected.

From a finite mixture to an infinite one¶

LCA is a finite mixture with a $\text{Dirichlet}(\alpha/C,\dots,\alpha/C)$ prior on the $C$ class weights. Let $C\to\infty$ and that prior converges to the stick-breaking (GEM) weights of a Dirichlet process; the class profiles $\delta_k$ are drawn from a base measure $G_0$ (here independent $\text{Beta}(a,b)$ per item). The generative model is $$\text{partition of subjects}\sim\text{CRP}(\alpha),\qquad \delta_{kj}\sim\text{Beta}(a,b),\qquad x_{ij}\mid\text{class }k\sim\text{Bernoulli}(\delta_{kj}),$$ where the Chinese Restaurant Process governs how subjects cluster: a subject joins an occupied class with probability proportional to its size, or starts a new one with probability proportional to the concentration $\alpha$. (The stick-breaking weights and the CRP are exactly the objects built in the Compound & Nonparametric folder of the distributions catalog.)

The collapsed sampler¶

Because the base measure is conjugate, we integrate out the class profiles $\delta_k$ and sample the partition directly — Neal's (2000) Algorithm 3. Each subject is reassigned using the Beta-Bernoulli posterior predictive: $$\Pr(\text{join class }k)\propto n_k\prod_j\text{Pred}\big(x_{ij}\mid a+s_{kj},\,b+n_k-s_{kj}\big),\qquad \Pr(\text{new class})\propto\alpha\prod_j\text{Pred}\big(x_{ij}\mid a,b\big),$$ with $\text{Pred}(x{=}1\mid A,B)=A/(A+B)$. Since class labels are arbitrary, everything is summarised by label-invariant quantities: the posterior of $K$, and the co-clustering matrix $\Pr(\text{subjects }i,j\text{ share a class})$.

Sections¶

  1. The Chinese Restaurant Process — the mechanism behind the prior
  2. Validation — recovering a known number of classes
  3. Carcinoma — inferring $K$ with no model-selection step
  4. The concentration $\alpha$ — the granularity dial
  5. The over-clustering caveat — Dirichlet-process inconsistency for $K$
  6. PyMC — the truncated stick-breaking view
  7. Summary
In [1]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt
from scipy.special import digamma
import dplca as D, warnings; warnings.filterwarnings("ignore")

plt.rcParams.update({"figure.figsize": (9,4), "axes.grid": True, "grid.alpha": .22,
                     "axes.spines.top": False, "axes.spines.right": False, "font.size": 10.5})
BLUE, RED, GREEN, ORANGE, GREY, PURP = "#2b6cb0","#c53030","#2f855a","#dd6b20","#718096","#6b46c1"
rng = np.random.default_rng(0)

def K_posterior(Ktrace):
    v = np.bincount(Ktrace); v = v / v.sum()
    return {k: v[k] for k in range(len(v)) if v[k] > 0.005}
def expected_K(alpha, N):
    return alpha * (digamma(alpha + N) - digamma(alpha))
print("ready")
ready

1. The Chinese Restaurant Process — the mechanism behind the prior¶

The prior over how subjects group into classes is the Chinese Restaurant Process (CRP), and it is worth understanding on its own — because, as we will see, it is the sampler. Picture a restaurant with infinitely many tables. Customers (our subjects) enter one at a time and pick a table:

  • the first customer sits at the first table;
  • customer $i$, finding $i-1$ people already seated at $K$ occupied tables of sizes $n_1,\dots,n_K$, sits down according to $$\Pr(\text{occupied table }k)=\frac{n_k}{\,i-1+\alpha\,},\qquad \Pr(\text{a new, empty table})=\frac{\alpha}{\,i-1+\alpha\,}.$$ The tables are the latent classes; who ends up sitting together is the clustering. Three properties make this exactly the right prior.

Rich get richer. The chance of joining a table is proportional to how many people are already seated there, so popular classes grow faster — a self-reinforcing (preferential-attachment) dynamic that yields a few large classes and a tail of small ones, with none of the sizes fixed in advance.

The number of classes grows slowly. A new table opens with probability $\alpha/(i-1+\alpha)$, which shrinks as the room fills, so the expected number of occupied tables after $n$ customers is $$\mathbb E[K_n]=\sum_{i=1}^{n}\frac{\alpha}{\alpha+i-1}=\alpha\big(\psi(\alpha+n)-\psi(\alpha)\big)\ \approx\ \alpha\log n.$$ The concentration $\alpha$ is the only dial: larger $\alpha$ opens tables more eagerly (more, smaller classes), smaller $\alpha$ keeps everyone crowded onto a few.

Exchangeability. Although the customers are seated in sequence, the probability of the final partition — who sits with whom — does not depend on the order in which they arrived; it depends only on the table sizes. This is the deep property. It makes the CRP a coherent prior over partitions of any number of subjects, and it is precisely the partition law induced by a Dirichlet process (the stick-breaking weights of §6 are the complementary "explicit-weights" view of the very same process).

Why the CRP is the sampler. Exchangeability lets us treat any subject as though it were the last to arrive. So each Gibbs step removes subject $i$ from its table and reseats it by the CRP rule — join table $k$ with weight $n_{-i,k}$, or open a new one with weight $\alpha$ — multiplied by how well subject $i$'s responses fit each table (the Beta-Bernoulli predictive). That product is exactly Neal's Algorithm 3: the collapsed sampler is the CRP with the data attached.

In [2]:
# Simulate the CRP to see its two signatures: logarithmic growth of K, and rich-get-richer sizes
def crp_sim(alpha, n, rng):
    sizes = []; K_running = np.empty(n, int)
    for i in range(n):
        p = np.array(sizes + [alpha], float); p /= p.sum()
        j = rng.choice(len(sizes) + 1, p=p)
        if j == len(sizes): sizes.append(1)
        else: sizes[j] += 1
        K_running[i] = len(sizes)
    return np.array(sizes), K_running
fig, ax = plt.subplots(1, 2, figsize=(13, 4.2))
ii = np.arange(1, 2001)
for alpha, c in [(0.5, BLUE), (2.0, GREEN), (5.0, RED)]:
    runs = np.mean([crp_sim(alpha, 2000, rng)[1] for _ in range(20)], axis=0)
    ax[0].plot(ii, runs, color=c, lw=2, label=f"α={alpha}  (E[K]≈α·log n)")
    ax[0].plot(ii, expected_K(alpha, ii), color=c, ls=":", lw=1.2)
ax[0].set_xscale("log"); ax[0].set_title("Number of occupied classes vs customers seated"); ax[0].set_xlabel("customers (log)"); ax[0].set_ylabel("occupied tables K"); ax[0].legend(fontsize=8.5)
sizes, _ = crp_sim(2.0, 2000, rng); sizes = np.sort(sizes)[::-1]
ax[1].bar(np.arange(1, len(sizes)+1), sizes, color=GREY)
ax[1].set_title(f"Rich-get-richer table sizes (α=2, n=2000 → {len(sizes)} tables)"); ax[1].set_xlabel("table (sorted by size)"); ax[1].set_ylabel("# customers")
plt.tight_layout(); plt.show()
print("Left: simulated table counts (solid) track the analytic E[K]=α(ψ(α+n)−ψ(α)) (dotted); α alone sets the growth rate.")
print("Right: a handful of tables hold most customers while a long tail hold just one or two — the self-reinforcing law.")
No description has been provided for this image
Left: simulated table counts (solid) track the analytic E[K]=α(ψ(α+n)−ψ(α)) (dotted); α alone sets the growth rate.
Right: a handful of tables hold most customers while a long tail hold just one or two — the self-reinforcing law.

2. Validation — recovering a known number of classes¶

We first run DP-LCA on data simulated with three true classes ($N=800$, 8 items), giving the concentration $\alpha$ a $\text{Gamma}(2,4)$ prior. A well-behaved sampler should concentrate the posterior of $K$ around the truth and cluster the subjects the way they were generated — the latter measured label-invariantly by the adjusted Rand index. The Dirichlet process tends to add a few extra tiny classes (a right tail on $K$, so the mode can sit at 3 or 4; §5 explains why), which is why the clustering agreement, not the exact modal $K$, is the reliable check.

In [3]:
lam_t = np.array([0.5, 0.3, 0.2])
delta_t = np.array([[0.9,0.9,0.8,0.8,0.2,0.2,0.1,0.1],
                    [0.1,0.2,0.1,0.2,0.9,0.8,0.9,0.8],
                    [0.8,0.2,0.8,0.2,0.8,0.2,0.8,0.2]])
Xs, Ttrue = D.simulate_lca(800, lam_t, delta_t, rng)
sim = D.dp_lca_gibbs(Xs, rng, alpha=1.0, draws=2000, burn=1000, sample_alpha=True)
Kp = K_posterior(sim["K"]); modalK = int(np.bincount(sim["K"]).argmax())
zrep = D.representative_partition(sim["Z"], target_K=modalK)
print("posterior P(K):", {k: round(v,3) for k,v in Kp.items()})
print(f"posterior mean K = {sim['K'].mean():.2f}, modal K = {modalK}, mean α = {sim['alpha'].mean():.3f}")
print(f"adjusted Rand index of the modal-K clustering vs the true labels: {D.adjusted_rand(Ttrue, zrep):.3f}")
fig, ax = plt.subplots(1, 2, figsize=(13, 4))
ax[0].bar(list(Kp.keys()), list(Kp.values()), color=BLUE); ax[0].axvline(3, color=RED, ls=":", lw=1.5)
ax[0].set_title("Posterior of K — simulation (true K=3)"); ax[0].set_xlabel("number of occupied classes K"); ax[0].set_ylabel("posterior probability")
# co-clustering, reordered by the representative partition
P = D.coclustering(sim["Z"]); order = np.argsort(zrep)
im = ax[1].imshow(P[np.ix_(order, order)], cmap="magma", vmin=0, vmax=1)
ax[1].set_title("Co-clustering matrix (subjects reordered)"); ax[1].set_xlabel("subject"); ax[1].set_ylabel("subject"); plt.colorbar(im, ax=ax[1], shrink=.8)
plt.tight_layout(); plt.show()
print(f"The K-posterior sits ABOVE the truth: P(K=3)={Kp.get(3,0.0):.2f}, mode {modalK}, mean {sim['K'].mean():.1f}.")
print(f"The PARTITION, though, is recovered — the co-clustering matrix shows three clean blocks, with an")
print(f"adjusted Rand index of {D.adjusted_rand(Ttrue, zrep):.2f} against the true labels. So the DP finds the right structure")
print("while sprinkling a few extra tiny classes on top of it: a systematic effect, quantified in §5.")
posterior P(K): {3: np.float64(0.092), 4: np.float64(0.277), 5: np.float64(0.256), 6: np.float64(0.174), 7: np.float64(0.102), 8: np.float64(0.057), 9: np.float64(0.025), 10: np.float64(0.01)}
posterior mean K = 5.28, modal K = 4, mean α = 0.605
adjusted Rand index of the modal-K clustering vs the true labels: 0.827
No description has been provided for this image
The K-posterior sits ABOVE the truth: P(K=3)=0.09, mode 4, mean 5.3.
The PARTITION, though, is recovered — the co-clustering matrix shows three clean blocks, with an
adjusted Rand index of 0.83 against the true labels. So the DP finds the right structure
while sprinkling a few extra tiny classes on top of it: a systematic effect, quantified in §5.

3. Carcinoma — inferring $K$ with no model-selection step¶

Now the real test. In Project 2, six criteria and a bootstrap test were needed to conclude that the carcinoma ratings support three classes. DP-LCA reaches the same answer as a single posterior — no fitting of separate models, no information criteria. We simply read off the posterior of $K$ and the resulting clusters.

In [4]:
df = pd.read_csv("carcinoma.csv"); Xc = (df.values - 1).astype(int); raters = list(df.columns)
carc = D.dp_lca_gibbs(Xc, rng, alpha=1.0, draws=3000, burn=1200, sample_alpha=True)
Kc = K_posterior(carc["K"]); modalKc = int(np.bincount(carc["K"]).argmax())
print("posterior P(K):", {k: round(v,3) for k,v in Kc.items()})
print(f"posterior mean K = {carc['K'].mean():.2f}, modal K = {modalKc} (P={Kc[modalKc]:.2f}), mean α = {carc['alpha'].mean():.3f}")
zc = D.representative_partition(carc["Z"], target_K=modalKc); lam_c, del_c = D.cluster_profiles(Xc, zc)
fig, ax = plt.subplots(1, 3, figsize=(15, 4.1))
ax[0].bar(list(Kc.keys()), list(Kc.values()), color=BLUE); ax[0].axvline(3, color=RED, ls=":", lw=1.5)
ax[0].set_title(f"Posterior of K (carcinoma): mode = {modalKc}"); ax[0].set_xlabel("K"); ax[0].set_ylabel("posterior prob.")
Pc = D.coclustering(carc["Z"]); order = np.argsort(zc)
im = ax[1].imshow(Pc[np.ix_(order, order)], cmap="magma", vmin=0, vmax=1)
ax[1].set_title("Co-clustering of the 118 slides"); ax[1].set_xlabel("slide"); ax[1].set_ylabel("slide"); plt.colorbar(im, ax=ax[1], shrink=.8)
names = ["clear carcinoma","clear benign","ambiguous / disputed"]
for k,(col,nm) in enumerate(zip([RED,GREEN,ORANGE], names)):
    if k < len(lam_c): ax[2].plot(range(len(raters)), del_c[k], "o-", color=col, lw=2, ms=7, label=f"{nm} (λ={lam_c[k]:.2f})")
ax[2].set_xticks(range(len(raters))); ax[2].set_xticklabels(raters); ax[2].set_ylim(0,1); ax[2].set_ylabel("P(carcinoma call)")
ax[2].set_title("Profiles of the modal-K classes"); ax[2].legend(fontsize=8)
plt.tight_layout(); plt.show()
print(f"The posterior of K concentrates on THREE classes (P≈{Kc.get(3,0):.2f}) — the very answer Project 2 reached by model")
print("selection, here obtained directly. The three profiles match: clear carcinoma, clear benign, and an ambiguous class.")
posterior P(K): {3: np.float64(0.841), 4: np.float64(0.141), 5: np.float64(0.017)}
posterior mean K = 3.18, modal K = 3 (P=0.84), mean α = 0.483
No description has been provided for this image
The posterior of K concentrates on THREE classes (P≈0.84) — the very answer Project 2 reached by model
selection, here obtained directly. The three profiles match: clear carcinoma, clear benign, and an ambiguous class.

4. The concentration $\alpha$ — the granularity dial¶

The concentration $\alpha$ controls how readily the process opens new classes: its prior mean number of occupied classes is $$\mathbb E[K\mid\alpha,N]=\sum_{i=1}^N\frac{\alpha}{\alpha+i-1}=\alpha\big(\psi(\alpha+N)-\psi(\alpha)\big)\approx\alpha\log N.$$ Rather than fix it, we learned it with a $\text{Gamma}(2,4)$ prior and the Escobar–West update; its posterior tells us how much clustering the data want. Below, the posterior of $\alpha$, and the $\mathbb E[K]$–vs–$\alpha$ curve with the data's inferred operating point.

In [5]:
fig, ax = plt.subplots(1, 2, figsize=(13, 4))
ax[0].hist(carc["alpha"], bins=40, density=True, color="#cfe3f6", edgecolor="white", lw=.3)
ax[0].axvline(carc["alpha"].mean(), color=RED, lw=2, label=f"posterior mean α={carc['alpha'].mean():.2f}")
ax[0].set_title("Posterior of the concentration α (carcinoma)"); ax[0].set_xlabel("α"); ax[0].legend(fontsize=8.5)
al = np.linspace(0.05, 5, 200); N = len(Xc)
ax[1].plot(al, expected_K(al, N), color=BLUE, lw=2, label="E[K | α, N]")
am = carc["alpha"].mean(); ax[1].plot(am, expected_K(am, N), "o", color=RED, ms=9, label=f"inferred α={am:.2f} → E[K]={expected_K(am,N):.1f}")
ax[1].axhline(3, color=GREY, ls=":", lw=1); ax[1].set_title("Prior expected #classes grows like α·log N"); ax[1].set_xlabel("concentration α"); ax[1].set_ylabel("E[K]"); ax[1].legend(fontsize=8.5)
plt.tight_layout(); plt.show()
print(f"The data pull α down to ≈{am:.2f}, an operating point whose prior expectation is ~3 classes — parsimony learned, not imposed.")
No description has been provided for this image
The data pull α down to ≈0.48, an operating point whose prior expectation is ~3 classes — parsimony learned, not imposed.

5. The caveat — the Dirichlet process over-estimates $K$¶

DP-LCA is a superb clustering and density-estimation device, but it comes with a warning that matters when $K$ is meant to be a substantive count of "types". The posterior of $K$ under a Dirichlet-process mixture is not a consistent estimator of the true number of components (Miller & Harrison, 2013): as the sample grows, the DP keeps sprinkling extra small classes, so the posterior mean $K$ drifts upward even when the data-generating truth is fixed. We show this directly — the same three-class generator at increasing $N$.

In [6]:
Ns = [200, 800, 3200]
fig, ax = plt.subplots(1, len(Ns), figsize=(14, 3.8), sharey=True)
for k,Nn in enumerate(Ns):
    Xn, _ = D.simulate_lca(Nn, lam_t, delta_t, rng)
    rn = D.dp_lca_gibbs(Xn, rng, alpha=1.0, draws=1200, burn=700, sample_alpha=True)
    kp = K_posterior(rn["K"])
    ax[k].bar(list(kp.keys()), list(kp.values()), color=[BLUE,GREEN,ORANGE][k]); ax[k].axvline(3, color=RED, ls=":", lw=1.5)
    ax[k].set_title(f"N={Nn}:  mean K={rn['K'].mean():.1f},  P(K>3)={np.mean(rn['K']>3):.2f}"); ax[k].set_xlabel("K")
ax[0].set_ylabel("posterior probability")
plt.suptitle("Same true 3 classes, larger N — the DP posterior for K drifts upward (over-clustering)", y=1.02)
plt.tight_layout(); plt.show()
print("The true number of classes is 3 at every N, yet the DP's posterior mass on K>3 GROWS with the sample size — the")
print("Miller–Harrison inconsistency. If a parsimonious, *consistent* count of types is the goal, a mixture-of-finite-mixtures")
print("(MFM) prior — a random C with its own prior, keeping the conjugate machinery — restores consistency; the finite-C")
print("selection of Project 2 is the other principled route. For flexible clustering and prediction, the DP is exactly right.")
No description has been provided for this image
The true number of classes is 3 at every N, yet the DP's posterior mass on K>3 GROWS with the sample size — the
Miller–Harrison inconsistency. If a parsimonious, *consistent* count of types is the goal, a mixture-of-finite-mixtures
(MFM) prior — a random C with its own prior, keeping the conjugate machinery — restores consistency; the finite-C
selection of Project 2 is the other principled route. For flexible clustering and prediction, the DP is exactly right.

6. PyMC — the truncated stick-breaking view¶

The collapsed sampler above integrates out the weights. PyMC instead represents them explicitly through truncated stick-breaking (Blei & Jordan, 2006): cap the number of components at a generous $K_{\max}$, build weights $w_k=\beta_k\prod_{j<k}(1-\beta_j)$ from $\beta_k\sim\text{Beta}(1,\alpha)$, and marginalise the class labels with logsumexp. NUTS then samples the continuous $\alpha,\beta,\delta$. The sorted posterior-mean stick weights are a label-invariant read-out of how many classes the data actually use — mixtures are hard for gradient samplers (expect some divergences), but this summary is robust.

In [7]:
import pymc as pm, pytensor.tensor as pt
Xf = Xc.astype(float); N, J = Xf.shape; Kmax = 8
def stick(b):
    return pt.concatenate([b, pt.ones(1)]) * pt.concatenate([pt.ones(1), pt.cumprod(1 - b)])
with pm.Model() as dp:
    alpha = pm.Gamma("alpha", 2.0, 4.0)
    beta = pm.Beta("beta", 1.0, alpha, shape=Kmax - 1)
    w = pm.Deterministic("w", stick(beta))
    delta = pm.Beta("delta", 1.0, 1.0, shape=(Kmax, J))
    ld = pt.log(delta); l1 = pt.log1p(-delta)
    comp = pt.log(w)[None, :] + (Xf[:, None, :]*ld[None] + (1-Xf[:, None, :])*l1[None]).sum(-1)
    pm.Potential("lik", pt.logsumexp(comp, axis=1).sum())
    idata = pm.sample(1000, tune=1500, chains=4, cores=1, target_accept=0.95, random_seed=1, progressbar=False)
W = idata.posterior["w"].values.reshape(-1, Kmax)
wsort = np.sort(W, axis=1)[:, ::-1].mean(0)                      # sorted (label-invariant) mean weights
n_eff = int((wsort > 0.05).sum())
plt.figure(figsize=(9, 4))
plt.bar(range(1, Kmax+1), wsort, color=np.where(wsort>0.05, BLUE, GREY))
plt.axhline(0.05, color=RED, ls=":", lw=1)
plt.title(f"PyMC truncated stick-breaking: {n_eff} components carry the mass (α≈{float(idata.posterior['alpha'].mean()):.2f})")
plt.xlabel("component (sorted by weight)"); plt.ylabel("posterior-mean weight"); plt.show()
print(f"Only {n_eff} of the {Kmax} truncated components carry appreciable weight — the truncated DP agrees with the collapsed")
print("Gibbs and with Project 2: three latent classes. The unused components simply shrink toward zero.")
print("\nOn the sampler warnings above. A mixture's component labels are not identified, so per-component")
print("R-hat and ESS are expected to look poor here — and they are not what this conclusion rests on: the")
print("weight profile plotted above is a LABEL-INVARIANT summary (weights sorted within each draw). The")
print("divergences are a real caveat, though: truncated stick-breaking has awkward posterior geometry, and")
print("the collapsed Gibbs sampler of §3 — which integrates the weights out entirely — is the more reliable")
print("engine for this model. Read this cell as corroboration of §3, not as the primary evidence.")
g++ not available, if using conda: `conda install gxx`
Initializing NUTS using jitter+adapt_diag...
Sequential sampling (4 chains in 1 job)
NUTS: [alpha, beta, delta]
Sampling 4 chains for 1_500 tune and 1_000 draw iterations (6_000 + 4_000 draws total) took 15 seconds.
There were 17 divergences after tuning. Increase `target_accept` or reparameterize.
The rhat statistic is larger than 1.01 for some parameters. This indicates problems during sampling. See https://arxiv.org/abs/1903.08008 for details
The effective sample size per chain is smaller than 100 for some parameters.  A higher number is needed for reliable rhat and ess computation. See https://arxiv.org/abs/1903.08008 for details
No description has been provided for this image
Only 3 of the 8 truncated components carry appreciable weight — the truncated DP agrees with the collapsed
Gibbs and with Project 2: three latent classes. The unused components simply shrink toward zero.

On the sampler warnings above. A mixture's component labels are not identified, so per-component
R-hat and ESS are expected to look poor here — and they are not what this conclusion rests on: the
weight profile plotted above is a LABEL-INVARIANT summary (weights sorted within each draw). The
divergences are a real caveat, though: truncated stick-breaking has awkward posterior geometry, and
the collapsed Gibbs sampler of §3 — which integrates the weights out entirely — is the more reliable
engine for this model. Read this cell as corroboration of §3, not as the primary evidence.

7. Summary¶

A Dirichlet-process latent class model turns "how many classes?" from a model-selection problem into a posterior. Built from scratch as a collapsed CRP Gibbs sampler (Neal's Algorithm 3, using the Beta-Bernoulli predictive) and cross-checked with PyMC's truncated stick-breaking, it recovered the underlying partition on simulated data (adjusted Rand index $\approx0.83$, though with its modal $K$ one above the truth — the over-extraction quantified in §5) and — crucially — concluded on the carcinoma ratings, in a single fit and with no information criteria, that there are three latent slide types: exactly the answer Project 2 assembled from six criteria and a bootstrap test. The concentration $\alpha$, learned with a Gamma prior, set the granularity, with $\mathbb E[K]\approx\alpha\log N$.

When to use which. The DP is the tool of choice for flexible clustering and density estimation, where a growing, data-adaptive number of components is a feature. But its posterior for $K$ over-estimates the number of components as $N$ grows (Miller–Harrison), so when $K$ is meant to be a small, interpretable count of types, prefer the finite-$C$ selection of Project 2 or a mixture-of-finite-mixtures prior, which restores consistency while keeping the conjugate machinery. The two views are complementary: the nonparametric model proposes the structure; the finite, selected model commits to an interpretable count.

This connects the LCA arc to the Compound & Nonparametric distributions folder (stick-breaking and the CRP) and to the DPM family more broadly — mixed-membership models (Grade-of-Membership, and latent Dirichlet allocation or LDA), latent-feature models (the Indian Buffet Process), and the hierarchical DP for grouped and longitudinal latent-class data. Next in the arc: latent class regression with covariates, and the Hui–Walter diagnostic-testing model.