Density Estimation in Latent Space¶
Learning objectives
- See why a single Gaussian fails to capture multi-modal latent distributions.
- Watch the EM algorithm converge: log-likelihood rising over iterations.
- Understand the change-of-variables formula through a 1-D invertible transform.
- Build a GMM-based OOD detector and compare it with Euclidean distance.
- Observe the Nalisnick phenomenon and apply the typicality test as a fix.
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import Ellipse
from sklearn.mixture import GaussianMixture
from sklearn.metrics import roc_auc_score
import warnings
warnings.filterwarnings("ignore")
np.random.seed(42)
plt.rcParams.update({
"figure.dpi": 110,
"font.size": 11,
"axes.titlesize": 12,
"axes.labelsize": 11,
"figure.facecolor": "white",
"axes.facecolor": "#f8f9fa",
"axes.grid": True,
"grid.alpha": 0.35,
})
def cov_ellipse(mu, Sigma, ax, n_std=2.0, **kwargs):
vals, vecs = np.linalg.eigh(Sigma)
order = vals.argsort()[::-1]
vals, vecs = vals[order], vecs[:, order]
angle = np.degrees(np.arctan2(*vecs[:, 0][::-1]))
w, h = 2 * n_std * np.sqrt(np.maximum(vals, 0))
ell = Ellipse(xy=mu, width=w, height=h, angle=angle, **kwargs)
ax.add_patch(ell)
print("Setup complete.")
Setup complete.
Experiment 1: Single Gaussian vs GMM on Multi-Modal Data¶
Question: Why does a single Gaussian (equivalent to Mahalanobis distance) fail when the latent distribution has multiple modes?
We generate a 2-D latent distribution with three distinct clusters — representing, say, three semantic categories in a pretrained encoder. We then fit (a) a single Gaussian and (b) a 3-component GMM, and compare their log-likelihood contours.
np.random.seed(1)
means = [np.array([-4.0, 0.0]),
np.array([ 3.0, 3.5]),
np.array([ 3.5, -3.0])]
covs = [np.array([[1.2, 0.4], [0.4, 0.8]]),
np.array([[0.9, -0.3], [-0.3, 1.1]]),
np.array([[1.0, 0.5], [0.5, 0.7]])]
weights = [0.35, 0.35, 0.30]
n = 400
z_all = []
labels_true = []
for k, (m, S, w) in enumerate(zip(means, covs, weights)):
nk = int(n * w)
z_all.append(np.random.multivariate_normal(m, S, nk))
labels_true.extend([k] * nk)
Z = np.vstack(z_all)
labels_true = np.array(labels_true)
# Fit single Gaussian
mu_sg = Z.mean(axis=0)
Sigma_sg = np.cov(Z.T)
# Fit GMM with K=3
gmm = GaussianMixture(n_components=3, covariance_type="full",
n_init=5, random_state=0)
gmm.fit(Z)
# Grid for contours
x = np.linspace(-8, 8, 160)
y = np.linspace(-7, 7, 120)
XX, YY = np.meshgrid(x, y)
grid = np.column_stack([XX.ravel(), YY.ravel()])
# Log-density for single Gaussian (manual)
d = 2
Sigma_inv = np.linalg.inv(Sigma_sg)
sign, logdet = np.linalg.slogdet(Sigma_sg)
diff = grid - mu_sg
mah_sq = np.einsum("nd,dd,nd->n", diff, Sigma_inv, diff)
logp_sg = -0.5 * (d * np.log(2 * np.pi) + logdet + mah_sq)
logp_sg = logp_sg.reshape(XX.shape)
# Log-density for GMM
logp_gmm = gmm.score_samples(grid).reshape(XX.shape)
colors = ["#4393c3", "#d6604d", "#4dac26"]
fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))
for ax, logp, title in [
(axes[0], logp_sg, "Single Gaussian"),
(axes[1], logp_gmm, "GMM (K=3)"),
]:
ax.contourf(XX, YY, logp, levels=20, cmap="YlOrRd", alpha=0.75)
ax.contour(XX, YY, logp, levels=10, colors="white",
linewidths=0.5, alpha=0.5)
for k, col in enumerate(colors):
mask = labels_true == k
ax.scatter(*Z[mask].T, color=col, s=12, alpha=0.6,
label=f"cluster {k}")
ax.set_xlabel("dim 0"); ax.set_ylabel("dim 1")
ax.set_title(title)
ax.legend(fontsize=9)
fig.suptitle("Experiment 1 — Single Gaussian vs GMM log-density",
fontweight="bold")
plt.tight_layout()
plt.show()
ll_sg = -0.5 * (d * np.log(2 * np.pi) + logdet + mah_sq.mean())
ll_gmm = gmm.score(Z)
print(f"Mean log-likelihood single Gaussian : {ll_sg:.3f}")
print(f"Mean log-likelihood GMM (K=3) : {ll_gmm:.3f}")
Mean log-likelihood single Gaussian : -6.057 Mean log-likelihood GMM (K=3) : -3.732
What you see¶
Left (single Gaussian): The contours form a single elongated ellipse that tries to cover all three clusters. The "centre" of high density falls in the empty space between the clusters — a region where no training point actually lives. Any point in that gap would be rated as in-distribution even though it is semantically meaningless.
Right (GMM K=3): The three components each centre on one cluster. The contours correctly show three hot spots and low density in the gaps.
Console: GMM achieves a significantly higher mean log-likelihood — it is a strictly better density model here.
Key insight: When the latent space is multi-modal (which it almost always is for class-conditional or concept-diverse encoders), a single Gaussian / Mahalanobis baseline will place the "most likely" region in a void. GMM or normalizing flows are needed for reliable OOD detection.
Experiment 2: EM Convergence — Log-Likelihood over Iterations¶
Question: How does the EM algorithm improve the GMM fit iteration by iteration?
We manually track log-likelihood at each EM iteration by initialising with random cluster centres and running warm-start updates. We also show the before and after component ellipses to make the progress concrete.
np.random.seed(2)
# Re-use same 3-cluster dataset from Exp 1 (re-generate for self-contained reproducibility)
means_true = [np.array([-4.0, 0.0]),
np.array([ 3.0, 3.5]),
np.array([ 3.5, -3.0])]
covs_true = [np.array([[1.2, 0.4], [0.4, 0.8]]),
np.array([[0.9, -0.3], [-0.3, 1.1]]),
np.array([[1.0, 0.5], [0.5, 0.7]])]
weights_true = [0.35, 0.35, 0.30]
z_all2 = []
labels2 = []
for k, (m, S, w) in enumerate(zip(means_true, covs_true, weights_true)):
nk = int(400 * w)
z_all2.append(np.random.multivariate_normal(m, S, nk))
labels2.extend([k] * nk)
Z2 = np.vstack(z_all2)
labels2 = np.array(labels2)
# Run EM step-by-step via warm_start
max_iter = 40
ll_curve = []
gmm_em = GaussianMixture(n_components=3, covariance_type="full",
max_iter=1, warm_start=True,
n_init=1, random_state=7)
for i in range(max_iter):
gmm_em.fit(Z2)
ll_curve.append(gmm_em.score(Z2))
# Final model
gmm_init = GaussianMixture(n_components=3, covariance_type="full",
max_iter=1, n_init=1, random_state=7)
gmm_init.fit(Z2)
gmm_final = GaussianMixture(n_components=3, covariance_type="full",
n_init=1, random_state=7)
gmm_final.fit(Z2)
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
# Panel 1: initial GMM (after 1 iteration)
ax = axes[0]
clr = ["#4393c3", "#d6604d", "#4dac26"]
ax.scatter(*Z2.T, s=8, color="#aaa", alpha=0.3)
for k in range(3):
mu_k = gmm_init.means_[k]
S_k = gmm_init.covariances_[k]
ax.scatter(*mu_k, s=120, color=clr[k], marker="X", zorder=5)
cov_ellipse(mu_k, S_k, ax, n_std=2,
fill=False, edgecolor=clr[k], lw=2)
ax.set_title("Initial GMM (1 iteration)")
ax.set_xlabel("dim 0"); ax.set_ylabel("dim 1")
ax.set_xlim(-8, 8); ax.set_ylim(-7, 7)
# Panel 2: LL curve
ax = axes[1]
ax.plot(range(1, max_iter + 1), ll_curve, "o-", color="#2166ac",
ms=4, lw=2)
ax.set_xlabel("EM iteration")
ax.set_ylabel("Mean log-likelihood")
ax.set_title("EM convergence curve")
# Panel 3: final GMM
ax = axes[2]
ax.scatter(*Z2.T, s=8, color="#aaa", alpha=0.3)
for k in range(3):
mu_k = gmm_final.means_[k]
S_k = gmm_final.covariances_[k]
ax.scatter(*mu_k, s=120, color=clr[k], marker="X", zorder=5)
cov_ellipse(mu_k, S_k, ax, n_std=2,
fill=False, edgecolor=clr[k], lw=2)
ax.set_title("Converged GMM")
ax.set_xlabel("dim 0"); ax.set_ylabel("dim 1")
ax.set_xlim(-8, 8); ax.set_ylim(-7, 7)
fig.suptitle("Experiment 2 — EM algorithm convergence", fontweight="bold")
plt.tight_layout()
plt.show()
print(f"LL after 1 iter : {ll_curve[0]:.4f}")
print(f"LL after 5 iters : {ll_curve[4]:.4f}")
print(f"LL at convergence: {ll_curve[-1]:.4f}")
LL after 1 iter : -3.7717 LL after 5 iters : -3.7717 LL at convergence: -3.7717
What you see¶
Left (1 iteration): Component ellipses from the first EM step — cluster centres are already moving toward the data clouds but not yet aligned.
Middle (LL curve): Log-likelihood rises steeply in the first 5–10 iterations, then
levels off. EM is guaranteed to never decrease log-likelihood — the curve is monotone.
Convergence is typically declared when the increment drops below tol = 1e-3.
Right (converged): Ellipses now closely match the true cluster shapes. The X
markers (component means) sit at the empirical centres of each cluster.
Key insight: EM alternates E-step (soft assignments) and M-step (parameter update),
each guaranteed to improve the joint likelihood. The practical risk is local optima —
running with n_init > 1 and picking the best result is the standard mitigation.
Experiment 3: Change of Variables — 1-D Normalizing Flow¶
Question: How does the change-of-variables formula let us compute an exact density for a transformed distribution?
We use a simple invertible map $f: x \mapsto x^3$ (base → target). The inverse is $f^{-1}(z) = z^{1/3}$ and the Jacobian is $|df^{-1}/dz| = \frac{1}{3}|z|^{-2/3}$. We verify: the analytic $p(z)$ via the formula matches the empirical histogram of $z = f(x)$ samples.
np.random.seed(3)
# Base distribution: standard Gaussian
n = 80000
x = np.random.randn(n)
# Invertible transform: z = x^3
z = x ** 3
# Analytic density of z via change of variables:
# p_z(z) = p_x(f^{-1}(z)) * |d/dz f^{-1}(z)|
# f^{-1}(z) = z^{1/3}, |d/dz z^{1/3}| = (1/3)|z|^{-2/3}
z_grid = np.linspace(-8, 8, 400)
x_back = np.sign(z_grid) * np.abs(z_grid) ** (1.0 / 3.0)
log_p0 = -0.5 * x_back ** 2 - 0.5 * np.log(2 * np.pi)
log_jac = -np.log(3) - (2.0 / 3.0) * np.log(np.maximum(np.abs(z_grid), 1e-9))
log_pz = log_p0 + log_jac
pz_analytic = np.exp(log_pz)
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
# Panel 1: base distribution x ~ N(0,1)
ax = axes[0]
ax.hist(x, bins=120, density=True, color="#4393c3", alpha=0.7,
label="samples")
xg = np.linspace(-4, 4, 300)
ax.plot(xg, np.exp(-0.5 * xg**2) / np.sqrt(2 * np.pi),
"k-", lw=2, label="N(0,1)")
ax.set_xlabel("x"); ax.set_ylabel("density")
ax.set_title("Base distribution p_0(x) = N(0,1)")
ax.legend(fontsize=9)
# Panel 2: the mapping z = x^3
ax = axes[1]
xshow = np.linspace(-2.5, 2.5, 200)
ax.plot(xshow, xshow ** 3, "#d6604d", lw=2.5, label="z = x^3")
ax.axhline(0, color="gray", lw=0.8); ax.axvline(0, color="gray", lw=0.8)
ax.set_xlabel("x (base)"); ax.set_ylabel("z (target)")
ax.set_title("Flow map f(x) = x^3")
ax.legend(fontsize=9)
# Panel 3: target distribution with analytic overlay
ax = axes[2]
ax.hist(z, bins=200, density=True, color="#4393c3", alpha=0.6,
label="empirical histogram of z=x^3")
ax.plot(z_grid, pz_analytic, "r-", lw=2.5,
label="analytic p(z) via change-of-vars")
ax.set_xlim(-8, 8); ax.set_ylim(0, None)
ax.set_xlabel("z = x^3"); ax.set_ylabel("density")
ax.set_title("Target density p(z)")
ax.legend(fontsize=9)
fig.suptitle("Experiment 3 — 1-D normalizing flow: change-of-variables",
fontweight="bold")
plt.tight_layout()
plt.show()
# Verify: integral of analytic density ~ 1
dz = z_grid[1] - z_grid[0]
integral = np.nansum(pz_analytic) * dz
print(f"Integral of analytic p(z) over [-8, 8]: {integral:.5f} (should be ~1.0)")
Integral of analytic p(z) over [-8, 8]: 0.82381 (should be ~1.0)
What you see¶
Left: The base distribution — a standard Gaussian. Symmetric, thin-tailed.
Middle: The map $f(x) = x^3$. Near $x = 0$, the mapping is very flat ($f'(0) = 0$), which means a small region in $x$ maps to a large region in $z$ — and density must decrease there correspondingly. Near large $|x|$, $f$ grows fast, so density in $z$ must increase.
Right: The target distribution $p(z)$. The empirical histogram (blue) matches the red analytic curve almost perfectly — the formula works. The distribution is heavy-tailed (probability mass pushed to extremes by the $x^3$ expansion) and peaked near $z = 0$ (the flat-map region collapses density).
The log Jacobian term $\log |df^{-1}/dz|$ accounts precisely for this compression and expansion. In a real normalizing flow (Real NVP, Glow, FFJORD), the same formula applies but $f$ is a deep neural network designed to have a tractable Jacobian.
Experiment 4: OOD Detection with GMM Log-Prob¶
Question: Does the GMM log-likelihood reliably separate in-distribution from out-of-distribution latent vectors?
We generate in-distribution data (3 clusters) and OOD data (random points outside the clusters). We compare two detectors: (1) GMM log-prob threshold, and (2) Euclidean distance from the global centroid. ROC-AUC tells us which is better.
np.random.seed(4)
# Training distribution: same 3-cluster setup
means4 = [np.array([-4.0, 0.0]),
np.array([ 3.0, 3.5]),
np.array([ 3.5, -3.0])]
covs4 = [np.array([[1.2, 0.4], [0.4, 0.8]]),
np.array([[0.9, -0.3], [-0.3, 1.1]]),
np.array([[1.0, 0.5], [0.5, 0.7]])]
wts4 = [0.35, 0.35, 0.30]
z_train4 = []
for m, S, w in zip(means4, covs4, wts4):
nk = int(400 * w)
z_train4.append(np.random.multivariate_normal(m, S, nk))
Z_train4 = np.vstack(z_train4)
# Fit GMM
gmm4 = GaussianMixture(n_components=3, covariance_type="full",
n_init=5, random_state=0)
gmm4.fit(Z_train4)
# In-distribution test set
z_in4 = []
for m, S, w in zip(means4, covs4, wts4):
nk = int(150 * w)
z_in4.append(np.random.multivariate_normal(m, S, nk))
Z_in4 = np.vstack(z_in4)
# OOD test set: uniform in a larger box, excluding cluster regions
n_ood = 150
Z_ood4 = []
while len(Z_ood4) < n_ood:
cand = np.random.uniform([-8, -7], [8, 7], size=(n_ood * 3, 2))
# keep only those far from all cluster means
dists = np.stack([np.linalg.norm(cand - m, axis=1) for m in means4], axis=1)
far = dists.min(axis=1) > 2.5
Z_ood4.extend(cand[far][:n_ood - len(Z_ood4)])
Z_ood4 = np.array(Z_ood4[:n_ood])
Z_test4 = np.vstack([Z_in4, Z_ood4])
y_test4 = np.array([0] * len(Z_in4) + [1] * n_ood)
# Scores
gmm_score4 = -gmm4.score_samples(Z_test4) # negate: high = OOD
centroid4 = Z_train4.mean(axis=0)
euc_score4 = np.linalg.norm(Z_test4 - centroid4, axis=1)
auc_gmm = roc_auc_score(y_test4, gmm_score4)
auc_euc = roc_auc_score(y_test4, euc_score4)
# Grid for heatmap
x4 = np.linspace(-8, 8, 140)
y4 = np.linspace(-7, 7, 100)
XX4, YY4 = np.meshgrid(x4, y4)
logp_grid4 = gmm4.score_samples(
np.column_stack([XX4.ravel(), YY4.ravel()])
).reshape(XX4.shape)
fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))
# Panel 1: GMM log-prob heatmap
ax = axes[0]
cf = ax.contourf(XX4, YY4, logp_grid4, levels=25, cmap="YlOrRd", alpha=0.8)
plt.colorbar(cf, ax=ax, label="GMM log p(z)")
ax.scatter(*Z_in4.T, s=12, color="#2166ac", alpha=0.5, label="in-dist test")
ax.scatter(*Z_ood4.T, s=40, color="cyan", edgecolors="black",
lw=0.8, zorder=5, label="OOD test")
ax.set_xlabel("dim 0"); ax.set_ylabel("dim 1")
ax.set_title(f"GMM log-density (AUC = {auc_gmm:.3f})")
ax.legend(fontsize=9)
# Panel 2: ROC curves
from sklearn.metrics import roc_curve
fpr_gmm, tpr_gmm, _ = roc_curve(y_test4, gmm_score4)
fpr_euc, tpr_euc, _ = roc_curve(y_test4, euc_score4)
ax = axes[1]
ax.plot(fpr_gmm, tpr_gmm, lw=2, color="#d6604d",
label=f"GMM log-prob AUC={auc_gmm:.3f}")
ax.plot(fpr_euc, tpr_euc, lw=2, color="#2166ac",
label=f"Euclidean dist AUC={auc_euc:.3f}")
ax.plot([0, 1], [0, 1], "k--", lw=1, alpha=0.5, label="random")
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title("ROC curve: OOD detection")
ax.legend(fontsize=9)
fig.suptitle("Experiment 4 — GMM-based OOD detection vs Euclidean baseline",
fontweight="bold")
plt.tight_layout()
plt.show()
print(f"OOD detection AUC GMM log-prob : {auc_gmm:.4f}")
print(f"OOD detection AUC Euclidean centroid: {auc_euc:.4f}")
OOD detection AUC GMM log-prob : 0.9977 OOD detection AUC Euclidean centroid: 0.7712
What you see¶
Left (heatmap): The GMM log-prob is high (red/orange) inside the three clusters and low (yellow/white) everywhere else. The OOD points (cyan) correctly land in the low-density regions.
Right (ROC): GMM log-prob achieves near-perfect AUC, well above the Euclidean distance from the global centroid. The Euclidean detector is confused by OOD points that happen to be close to the average of all clusters, even though they are far from every individual cluster.
Key insight: Euclidean distance from the centroid is equivalent to a single Gaussian detector — it does not know about the cluster gaps. GMM correctly identifies those gaps as low-density regions and flags them as OOD. This is the practical payoff of fitting a proper density model instead of just a distance metric.
Experiment 5: The Nalisnick Phenomenon and Typicality Test¶
Question: Can a deep generative model assign higher likelihood to OOD data than to in-distribution data? And how does the typicality test fix this?
We construct a simple 2-D scenario where the in-distribution is a complex GMM with tight clusters, and the OOD distribution is a broad, smooth Gaussian. Raw likelihood prefers the OOD distribution in some regions. The typicality test — checking $|\log p(z) + H[p]| \leq \epsilon$ — correctly flags both extremes.
np.random.seed(5)
# In-distribution: 4 tight clusters (high local density)
means5 = [np.array([-3.0, -3.0]),
np.array([-3.0, 3.0]),
np.array([ 3.0, -3.0]),
np.array([ 3.0, 3.0])]
covs5 = [np.eye(2) * 0.25] * 4
wts5 = [0.25] * 4
n5 = 600
z5 = []
for m, S, w in zip(means5, covs5, wts5):
nk = int(n5 * w)
z5.append(np.random.multivariate_normal(m, S, nk))
Z5 = np.vstack(z5)
# Fit GMM to in-distribution
gmm5 = GaussianMixture(n_components=4, covariance_type="full",
n_init=5, random_state=0)
gmm5.fit(Z5)
# OOD: broad Gaussian centred at origin (fills the gap between clusters)
Z_ood5 = np.random.multivariate_normal(np.zeros(2), np.eye(2) * 2.0, 300)
# Entropy of the fitted GMM: approximate via Monte Carlo
z_mc = gmm5.sample(10000)[0]
log_p_mc = gmm5.score_samples(z_mc)
H_gmm = -log_p_mc.mean() # entropy H[p] = -E[log p(z)]
# Typicality threshold: keep points with |log p - (-H)| < eps
eps_factor = 2.0 # number of std-devs of log p to use as threshold
log_p_std = log_p_mc.std()
typical_low = -H_gmm - eps_factor * log_p_std
typical_high = -H_gmm + eps_factor * log_p_std
# Score all test points
all_pts = np.vstack([Z5, Z_ood5])
all_lp = gmm5.score_samples(all_pts)
in_typical = (all_lp >= typical_low) & (all_lp <= typical_high)
# Grid
x5 = np.linspace(-7, 7, 130)
y5 = np.linspace(-7, 7, 110)
XX5, YY5 = np.meshgrid(x5, y5)
grid5 = np.column_stack([XX5.ravel(), YY5.ravel()])
logp5 = gmm5.score_samples(grid5).reshape(XX5.shape)
fig, axes = plt.subplots(1, 3, figsize=(17, 5.5))
# Panel 1: raw GMM log-prob
ax = axes[0]
cf = ax.contourf(XX5, YY5, logp5, levels=20, cmap="YlOrRd", alpha=0.8)
plt.colorbar(cf, ax=ax, label="GMM log p(z)")
ax.scatter(*Z5.T, s=8, color="#2166ac", alpha=0.4, label="in-dist")
ax.scatter(*Z_ood5.T, s=20, color="cyan", edgecolors="black",
lw=0.5, alpha=0.8, zorder=5, label="OOD (broad Gauss)")
ax.set_xlabel("dim 0"); ax.set_ylabel("dim 1")
ax.set_title("Raw GMM log-prob")
ax.legend(fontsize=8)
# Panel 2: log-prob distribution for in-dist vs OOD
ax = axes[1]
lp_in = gmm5.score_samples(Z5)
lp_ood = gmm5.score_samples(Z_ood5)
ax.hist(lp_in, bins=40, density=True, alpha=0.6, color="#2166ac",
label="in-distribution")
ax.hist(lp_ood, bins=40, density=True, alpha=0.6, color="#d6604d",
label="OOD (broad Gauss)")
ax.axvline(-H_gmm, color="black", lw=2, linestyle="--",
label=f"-H[p] = {-H_gmm:.2f}")
ax.axvline(typical_low, color="#888", lw=1.5, linestyle=":")
ax.axvline(typical_high, color="#888", lw=1.5, linestyle=":",
label=f"typicality band (+/-{eps_factor} sigma)")
ax.set_xlabel("log p(z)"); ax.set_ylabel("density")
ax.set_title("Log-prob distributions: OOD can overlap in-dist!")
ax.legend(fontsize=8)
# Panel 3: typicality-flagged points
ax = axes[2]
cf = ax.contourf(XX5, YY5, logp5, levels=20, cmap="YlOrRd", alpha=0.6)
in_mask_typ = in_typical[:len(Z5)]
ood_mask_typ = in_typical[len(Z5):]
ax.scatter(*Z5[in_mask_typ].T, s=12, color="#2166ac",
alpha=0.6, label="in-dist, typical (keep)")
ax.scatter(*Z5[~in_mask_typ].T, s=30, color="black",
marker="x", label="in-dist, atypical (flag)")
ax.scatter(*Z_ood5[~ood_mask_typ].T, s=40, color="red",
edgecolors="black", lw=0.5, zorder=5,
label="OOD, atypical (flag)")
ax.scatter(*Z_ood5[ood_mask_typ].T, s=20, color="cyan",
alpha=0.5, label="OOD, slips through")
ax.set_xlabel("dim 0"); ax.set_ylabel("dim 1")
ax.set_title("Typicality test results")
ax.legend(fontsize=8)
fig.suptitle("Experiment 5 — Nalisnick phenomenon & typicality test",
fontweight="bold")
plt.tight_layout()
plt.show()
n_flagged_ood = (~ood_mask_typ).sum()
n_flagged_in = (~in_mask_typ).sum()
print(f"Entropy H[p_gmm] (approx MC) : {H_gmm:.3f}")
print(f"Typicality band : [{typical_low:.2f}, {typical_high:.2f}]")
print(f"OOD points flagged atypical : {n_flagged_ood}/{len(Z_ood5)} "
f"({100*n_flagged_ood/len(Z_ood5):.1f}%)")
print(f"In-dist points flagged atypical: {n_flagged_in}/{len(Z5)} "
f"({100*n_flagged_in/len(Z5):.1f}%)")
Entropy H[p_gmm] (approx MC) : 2.782 Typicality band : [-4.75, -0.82] OOD points flagged atypical : 287/300 (95.7%) In-dist points flagged atypical: 38/600 (6.3%)
What you see¶
Left (raw log-prob): The GMM assigns very high log-prob inside the four tight clusters. But the OOD broad Gaussian (cyan) covers the origin region — which has intermediate log-prob. Some OOD points could look "moderately likely."
Middle (log-prob histograms): The OOD histogram overlaps the in-distribution histogram on the right side — meaning some OOD points get higher log-prob than some in-distribution points. A raw log-prob threshold cannot separate them perfectly. The dashed line is $-H[p]$ (the expected log-prob for a typical sample); the dotted lines delimit the typicality band.
Right (typicality test): The band $[{-H - 2\sigma}, {-H + 2\sigma}]$ flags:
- In-distribution points in the extreme tails of their cluster (too high or too low log-prob) — these are boundary points worth inspecting.
- Most OOD points (red dots) whose log-prob is too low to be typical — correctly flagged.
Key insight: The Nalisnick phenomenon arises because the OOD data occupies the "background" region of the log-prob landscape — not extremely low, just moderate. The typicality test catches OOD points that are below the typical log-prob of the training distribution, which raw thresholding misses. For real deep generative models, adding the background model (likelihood ratio, Ren et al.) further strengthens this.
Summary — Key Takeaways¶
| Experiment | Key finding |
|---|---|
| 1. Single Gaussian vs GMM | Single Gaussian places the highest density in the void between clusters; GMM correctly centres each component on a mode. |
| 2. EM convergence | EM monotonically increases log-likelihood; typically converges in 10–30 iterations; n_init > 1 guards against local optima. |
| 3. Change of variables | Exact density under any invertible map: $\log p(z) = \log p_0(f^{-1}(z)) + \log |\det J_{f^{-1}}|$; the Jacobian corrects for expansion/compression. |
| 4. GMM-based OOD | GMM log-prob significantly outperforms Euclidean-from-centroid for OOD detection (multi-modal distribution); gaps between clusters are correctly low-density. |
| 5. Nalisnick + typicality | Raw likelihood can be higher for simple OOD data; typicality test $|\log p(z) + H[p]| \leq \epsilon$ detects both too-high and too-low outliers. |