Relaxing Local Independence
Python · R · Download conditional-dependence module
Model
The closing project of the Latent Class Analysis arc, and the one that audits everything before it. Every earlier model rested on local independence — that items are independent given the latent class. It is the load-bearing assumption of LCA, and it is often wrong: two assays reacting to the same antibody, or two pathologists trained in the same tradition, stay correlated even within a class. When they do the naive model is biased, and it can manufacture a spurious extra class — which is exactly what the ambiguous third class found earlier in the arc turns out to be.
The fix gives each subject a continuous latent severity that nudges all their responses together. The loading says how strongly item responds to that shared effect, and for every recovers ordinary LCA exactly. Because is shared, items become correlated within a class — precisely the dependence local independence forbids. Integrating the random effect out returns the marginal accuracies in closed form through the probit-normal integral, which is what makes the corrected sensitivities directly comparable to the naive ones.
The sampler
It is fitted from scratch by an Albert–Chib data-augmentation Gibbs — the same truncated-normal device behind probit and Tobit models. Introducing latent truncated by the sign of makes the intercepts, loadings, random effects and class labels all conjugate. The model is a latent class and a latent trait at once, a factor mixture, which puts it at the crossroads of the portfolio: Albert–Chib augmentation, hierarchical random effects, and the continuous-latent structure of factor analysis.
Results
The demonstration runs on simulated data where three of five tests are correlated within class and two are not. The naive model over-states exactly the correlated tests — sensitivity 0.938 against a true 0.90, 0.889 against 0.85 — because it credits shared correlation to individual accuracy. The loadings then recover the structure correctly: large for the three dependent tests, near zero for the two independent ones. That is the diagnostic the project is really selling, and it works.
What is reliable, and what is not
Point estimates are a different matter, and the notebooks are explicit about it. The factor-mixture posterior is multimodal: a run can collapse toward a single dominant factor, inflating every loading and deflating every sensitivity. The R fit recovers the true accuracies closely (mean absolute error ); the Python run shown lands in a partially-collapsed mode where the mean absolute error, , is no better than the naive model's — it shifts the bias downward rather than removing it. Rather than re-seed until the picture flattered the method, the notebook reports both errors and explains the collapse. The restart-and-select machinery in the module exists for exactly this reason, and the honest reading is that the loadings, not the point estimates, are the dependable output.
The carcinoma readers
On the carcinoma ratings every loading is clearly positive — mean , none near zero — so the seven pathologists genuinely share a "slide-difficulty" trait. R's randomLCA agrees on the model comparison, with BIC dropping from 706.1 under local independence to 668.9 with the random effect. Correcting for the dependence pulls the sensitivities down and firms up the specificities: a more honest accuracy audit than the local-independence Hui–Walter model gives, and the resolution of the puzzle that project ended on.
| Question | Local independence | Random effects |
|---|---|---|
| Correlated tests' accuracy | over-stated | corrected downward |
| Which items are dependent? | cannot say | the loadings identify them |
Carcinoma BIC (randomLCA) | 706.1 | 668.9 |
| The extra latent class | appears, and looks real | explained away as dependence |
Notebooks
Downloads
Conditional-Dependence Module — Source Code
"""
condlca.py -- LATENT CLASS ANALYSIS with CONDITIONAL DEPENDENCE (from scratch).
Backs the notebooks in "Relaxing Local Independence".
Basic LCA / Hui-Walter assumes LOCAL INDEPENDENCE -- the tests (items) are independent
given the true class. When that fails (correlated tests), the naive model biases the
sensitivities/specificities and can invent spurious extra classes. We relax the
assumption with a RANDOM-EFFECTS (probit factor) model (Qu, Tan & Kutner 1996): each
subject carries a continuous latent 'severity' b_i that shifts all their test
probabilities together, inducing within-class correlation:
T_i ~ Categorical(pi) (latent class / disease status)
b_i ~ Normal(0, 1) (subject random effect = a latent trait)
x_ij | T_i=c, b_i ~ Bernoulli( Phi( a_{cj} + beta_j b_i ) ) (probit)
beta_j is test j's LOADING on the shared effect: beta_j = 0 for all j recovers ordinary
LCA (local independence). The marginal test accuracies integrate the random effect out
in closed form (the probit-normal integral):
Se_j = Phi( a_{1j} / sqrt(1 + beta_j^2) ), 1 - Sp_j = Phi( a_{0j} / sqrt(1 + beta_j^2) ).
The from-scratch sampler is an ALBERT-CHIB data-augmentation Gibbs (the same truncated-
normal augmentation used for probit / Tobit): introduce z_ij ~ N(a_{T_i,j}+beta_j b_i, 1)
truncated by the sign of x_ij, after which a, beta, b and T all have conjugate Gaussian /
categorical full conditionals.
"""
import numpy as np
from scipy.special import ndtr as Phi # standard normal CDF
from scipy.special import ndtri as Phinv # inverse
# --------------------------------------------------------------------------- #
# Truncated-normal helper (Albert-Chib latent z) #
# --------------------------------------------------------------------------- #
def _rtruncnorm_sign(mean, positive, rng):
"""Draw N(mean,1) truncated to (0, inf) if positive else (-inf, 0), by inverse-CDF."""
lo = np.where(positive, Phi(-mean), 0.0)
hi = np.where(positive, 1.0, Phi(-mean))
u = lo + rng.random(mean.shape) * (hi - lo)
return mean + Phinv(np.clip(u, 1e-12, 1 - 1e-12))
# --------------------------------------------------------------------------- #
# Random-effects (probit factor) LCA -- the conditional-dependence model #
# --------------------------------------------------------------------------- #
def re_lca_gibbs(X, rng, draws=4000, burn=1500, a_sd=3.0, beta_sd=0.4, anchor=True):
"""Albert-Chib Gibbs for the 2-class probit random-effects LCA.
Returns posterior draws of prevalence, marginal Se, Sp, and the loadings beta.
NOTE: beta_sd is a half-normal-scale REGULARISER on the loadings. A diffuse prior
(beta_sd>=1) lets the sampler collapse into a degenerate 'one big random effect'
mode (a factor-mixture identifiability trap), inflating every loading and deflating
Se; beta_sd=0.4 anchors it and recovers the truth while still detecting real
dependence (large true loadings overcome the mild shrinkage)."""
X = np.asarray(X, float); N, J = X.shape
# init
T = (X.mean(1) > X.mean()).astype(int)
alpha = np.zeros((2, J)); beta = rng.uniform(0.2, 0.6, J); b = rng.standard_normal(N)
pi = 0.5
A_prec = 1.0 / a_sd ** 2; B_prec = 1.0 / beta_sd ** 2
PREV = np.empty(draws); SE = np.empty((draws, J)); SP = np.empty((draws, J)); BETA = np.empty((draws, J))
for it in range(draws + burn):
mu = alpha[T] + beta[None, :] * b[:, None] # (N,J)
z = _rtruncnorm_sign(mu, X > 0.5, rng) # augment latent z
# b_i | z, alpha, beta, T
r = z - alpha[T] # (N,J)
prec_b = 1.0 + np.sum(beta ** 2)
mean_b = (r @ beta) / prec_b
b = mean_b + rng.standard_normal(N) / np.sqrt(prec_b)
# alpha[c,j] | z, b, T, beta
for c in (0, 1):
m = T == c; nc = int(m.sum())
resid = z[m] - beta[None, :] * b[m, None] # (nc,J)
prec = A_prec + nc
mean = resid.sum(0) / prec
alpha[c] = mean + rng.standard_normal(J) / np.sqrt(prec)
# beta_j | z, alpha, b, T
rj = z - alpha[T] # (N,J)
prec = B_prec + np.sum(b ** 2)
mean = (b[:, None] * rj).sum(0) / prec
beta = mean + rng.standard_normal(J) / np.sqrt(prec)
# T_i | z, alpha, beta, b, pi (Gaussian likelihood of z given class)
d1 = z - (alpha[1] + beta[None, :] * b[:, None]); d0 = z - (alpha[0] + beta[None, :] * b[:, None])
ll1 = np.log(pi) - 0.5 * (d1 ** 2).sum(1); ll0 = np.log1p(-pi) - 0.5 * (d0 ** 2).sum(1)
p1 = 1.0 / (1.0 + np.exp(np.clip(ll0 - ll1, -700, 700)))
T = (rng.random(N) < p1).astype(int)
pi = rng.beta(1 + int(T.sum()), 1 + N - int(T.sum()))
# identify sign of the shared effect: keep sum(beta) >= 0
if beta.sum() < 0:
beta = -beta; b = -b
# marginal accuracies (integrate out b)
s = np.sqrt(1 + beta ** 2)
Se = Phi(alpha[1] / s); Sp = 1 - Phi(alpha[0] / s)
if anchor and (Se.mean() + Sp.mean() < 1): # anchor disease/healthy labelling
alpha = alpha[::-1].copy(); T = 1 - T; pi = 1 - pi
Se = Phi(alpha[1] / s); Sp = 1 - Phi(alpha[0] / s)
if it >= burn:
i = it - burn; PREV[i] = pi; SE[i] = Se; SP[i] = Sp; BETA[i] = beta
return dict(prev=PREV, Se=SE, Sp=SP, beta=BETA)
# --------------------------------------------------------------------------- #
# Marginal likelihood + multi-restart (the factor-mixture posterior is multimodal)#
# --------------------------------------------------------------------------- #
def marginal_loglik(X, prev, Se, Sp, beta, n_quad=20):
"""Observed-data log-likelihood of the random-effects model, integrating the random
effect out by Gauss-Hermite quadrature. Used to compare restarts."""
X = np.asarray(X, float); gx, gw = np.polynomial.hermite_e.hermegauss(n_quad)
gw = gw / np.sqrt(2 * np.pi)
s = np.sqrt(1 + np.asarray(beta) ** 2)
a1 = Phinv(np.clip(Se, 1e-6, 1 - 1e-6)) * s; a0 = Phinv(np.clip(1 - np.asarray(Sp), 1e-6, 1 - 1e-6)) * s
p1 = Phi(a1[None, :] + beta[None, :] * gx[:, None]); p0 = Phi(a0[None, :] + beta[None, :] * gx[:, None])
Li = np.zeros(len(X))
for q in range(len(gx)):
c1 = np.prod(np.where(X == 1, p1[q], 1 - p1[q]), 1); c0 = np.prod(np.where(X == 1, p0[q], 1 - p0[q]), 1)
Li += gw[q] * (prev * c1 + (1 - prev) * c0)
return float(np.log(Li + 1e-300).sum())
def re_lca_gibbs_ms(X, rng, restarts=5, draws=2000, burn=1200, beta_sd=0.4):
"""Random-effects LCA with MULTIPLE RESTARTS, returning the run with the best marginal
fit. The factor-mixture posterior is multimodal -- a diffuse or unlucky run collapses
toward a single dominant factor -- so restarts with marginal-likelihood selection are
needed for reliable recovery. Returns the best run's full posterior draws."""
best = None
for _ in range(restarts):
res = re_lca_gibbs(X, rng, draws=draws, burn=burn, beta_sd=beta_sd)
ll = marginal_loglik(X, res["prev"].mean(), res["Se"].mean(0), res["Sp"].mean(0), res["beta"].mean(0))
if best is None or ll > best[0]:
best = (ll, res)
out = best[1]; out["marginal_loglik"] = best[0]
return out
# --------------------------------------------------------------------------- #
# Naive LOCAL-INDEPENDENCE 2-class model (Hui-Walter), for comparison #
# --------------------------------------------------------------------------- #
def naive_lca_gibbs(X, rng, draws=4000, burn=1500, ab=(1.0, 1.0), anchor=True):
"""Ordinary 2-class LCA / Hui-Walter (conditional independence assumed)."""
X = np.asarray(X, float); N, J = X.shape
Se = rng.uniform(0.5, 0.9, J); Sp = rng.uniform(0.5, 0.9, J); pi = 0.4
PREV = np.empty(draws); SE = np.empty((draws, J)); SP = np.empty((draws, J))
for it in range(draws + burn):
l1 = np.log(pi) + X @ np.log(Se) + (1 - X) @ np.log1p(-Se)
l0 = np.log1p(-pi) + X @ np.log1p(-Sp) + (1 - X) @ np.log(Sp)
p1 = 1.0 / (1.0 + np.exp(np.clip(l0 - l1, -700, 700)))
T = (rng.random(N) < p1).astype(int); dis = T == 1; hea = ~dis
pi = rng.beta(1 + int(dis.sum()), 1 + int(hea.sum()))
sd = X[dis].sum(0); nd = int(dis.sum()); Se = rng.beta(ab[0] + sd, ab[1] + nd - sd)
sh = X[hea].sum(0); nh = int(hea.sum()); Sp = rng.beta(ab[0] + (nh - sh), ab[1] + sh)
if anchor and (Se.mean() + Sp.mean() < 1): Se, Sp, pi = 1 - Sp, 1 - Se, 1 - pi
if it >= burn:
i = it - burn; PREV[i] = pi; SE[i] = Se; SP[i] = Sp
return dict(prev=PREV, Se=SE, Sp=SP)
# --------------------------------------------------------------------------- #
# Simulation with known conditional dependence #
# --------------------------------------------------------------------------- #
def simulate_dep(N, prev, Se, Sp, beta, rng):
"""Simulate 2-class data with a shared random effect of loadings `beta`
(beta=0 -> conditionally independent). Target marginal Se, Sp are hit exactly."""
Se = np.asarray(Se, float); Sp = np.asarray(Sp, float); beta = np.asarray(beta, float); J = len(Se)
s = np.sqrt(1 + beta ** 2)
a1 = Phinv(Se) * s; a0 = Phinv(1 - Sp) * s # back out probit intercepts
T = (rng.random(N) < prev).astype(int); b = rng.standard_normal(N)
A = np.where(T[:, None] == 1, a1[None, :], a0[None, :])
p = Phi(A + beta[None, :] * b[:, None])
X = (rng.random((N, J)) < p).astype(int)
return X, T, b
def conditional_corr(X, T):
"""Empirical within-class correlation between tests (averaged over the two classes):
the diagnostic of conditional dependence that local independence assumes is zero."""
X = np.asarray(X, float); J = X.shape[1]
cc = np.zeros((J, J))
for c in (0, 1):
Xc = X[T == c]
if len(Xc) > 2: cc += np.corrcoef(Xc.T)
return cc / 2
References
- Qu, Y., Tan, M. & Kutner, M. H. (1996). Random effects models in latent class analysis for evaluating accuracy of diagnostic tests. Biometrics 52(3), 797–810. — the probit random-effects model implemented here
- Albert, J. H. & Chib, S. (1993). Bayesian analysis of binary and polychotomous response data. Journal of the American Statistical Association 88(422), 669–679. — the truncated-normal data augmentation the Gibbs sampler is built on
- Dendukuri, N. & Joseph, L. (2001). Bayesian approaches to modeling the conditional dependence between diagnostic tests. Biometrics 57(1), 158–167. — the fixed conditional-covariance alternative for a few known correlated pairs
- Vacek, P. M. (1985). The effect of conditional dependence on the evaluation of diagnostic tests. Biometrics 41(4), 959–968. — the original demonstration that dependence biases accuracy estimates upward
- Beath, K. J. (2017). randomLCA: An R package for latent class with random effects analysis. Journal of Statistical Software 81(13), 1–25. — the reference implementation and the BIC comparison used here
- Hagenaars, J. A. (1988). Latent structure models with direct effects between indicators. Sociological Methods & Research 16(3), 379–405. — local dependence as direct effects between items, the other classical formulation