“Know how to solve every problem that has been solved.” “What I cannot create, I do not understand.” — Richard Feynman

Project: Probabilistic Learning on Manifolds

Projects

The goal. Build a manifold-respecting density estimator from scratch. The training data is 300 points on a noisy unit circle in 2D. The naive approach — put a Gaussian on each data point and sample from the mixture (kernel density estimate) — works if you know the right bandwidth, and falls off the manifold if you don't. PLOM (Soize & Ghanem, 2016) uses the data itself to estimate the manifold geometry, then samples along the manifold. This page implements the central idea in about 100 lines of NumPy and compares it head-to-head with the naive baseline.

Why bother. In real applications the manifold is not a 1D circle in 2D — it's a 30-dimensional surface in a 200-dimensional ambient space, and the actual noise is small compared to the ambient bandwidth you'd need for a naive KDE. PLOM is one of the cleanest ways to generate new samples that stay on the data manifold without explicitly parameterizing it. The original method uses an Itô SDE projected onto a diffusion-map basis; the version below uses a simpler chord-interpolation scheme that demonstrates the same principle.

The math

Step 1 — diffusion map. Define a Gaussian similarity kernel

and a row-stochastic transition matrix where . The eigenvectors of (or equivalently the eigenvectors of the symmetric matrix , which is what we actually solve numerically) form a basis on the data. The first eigenvector is trivial (constant). The next non-trivial eigenvectors give intrinsic coordinates on the manifold: two points that are close along the manifold land close in this embedding, even when their ambient-space distance would jump (e.g., across the gap on a "C"-shaped curve).

For a noisy circle, the top two non-trivial diffusion-map eigenvectors are and — the embedding lays out the circle as itself. The eigenvalues are close to 1 (in our experiment 0.967 and 0.936), reflecting that the data is genuinely on a 1D manifold.

Step 2 — sample along the manifold. The simplest manifold-respecting sampler is chord interpolation. For each new sample:

  1. Pick a base point uniformly at random from the training data.
  2. Find its nearest neighbors in diffusion-map space, . These are the points closest to along the manifold.
  3. Pick one neighbor uniformly from those .
  4. Output with .

The chord vector is approximately tangent to the manifold for nearby neighbors. Sliding along it stays close to the manifold. The width of controls how much new samples extrapolate; with , samples fill the chord between adjacent training points.

The original PLOM paper (Soize & Ghanem 2016) replaces step 2 with an Itô SDE

running in the diffusion-map basis with the drift projected onto the local manifold tangent space. The chord interpolation here is a simpler stand-in that captures the essential idea — sample in directions that respect the local manifold geometry, not in directions of arbitrary ambient noise. Extension 1 below is to implement the full SDE.

The prototype

"""
Probabilistic Learning on Manifolds (PLOM), simplified version.

Problem: you have a small dataset on a low-dim manifold embedded in a
higher-dim ambient space. Naive KDE in ambient space blurs samples off
the manifold (the noise directions get the same bandwidth as the signal
directions). PLOM (Soize & Ghanem 2016) uses the data itself to define
the manifold geometry, then samples on that manifold.

Workflow:
  1. Build a diffusion-map embedding from a Gaussian kernel.
  2. The top non-trivial eigenvectors give intrinsic manifold coordinates.
  3. Sample: pick a base point x_i, find its k nearest neighbors in the
     diffusion-map space (not ambient space), slide along the chord to
     a random neighbor.

The trick: 'nearest in diffusion-map space' means nearest ALONG the
manifold, even when ambient-space distance would jump across folds.
"""
import numpy as np

rng = np.random.default_rng(0)
N        = 300
R_TRUE   = 1.0
NOISE_SD = 0.05

# 1. Synthetic data on a noisy circle in 2D
theta = rng.uniform(0, 2 * np.pi, N)
X = np.stack([R_TRUE * np.cos(theta) + NOISE_SD * rng.standard_normal(N),
              R_TRUE * np.sin(theta) + NOISE_SD * rng.standard_normal(N)],
             axis=1)


# 2. Diffusion-map embedding
def pairwise_sq(A, B):
    return (np.sum(A ** 2, axis=1)[:, None]
          + np.sum(B ** 2, axis=1)[None, :]
          - 2.0 * A @ B.T)

def diffusion_map(X, sigma, m_eigs):
    K = np.exp(-pairwise_sq(X, X) / (2.0 * sigma ** 2))
    d = K.sum(axis=1)
    Dsq_inv = 1.0 / np.sqrt(d)
    S = Dsq_inv[:, None] * K * Dsq_inv[None, :]
    w, V = np.linalg.eigh(S)
    idx = np.argsort(w)[::-1]
    return (Dsq_inv[:, None] * V[:, idx])[:, 1:1 + m_eigs], w[idx][1:1 + m_eigs]

phi, lam = diffusion_map(X, sigma=0.30, m_eigs=2)
print(f"top diffusion-map eigenvalues: {lam[0]:.4f}, {lam[1]:.4f}")


# 3. PLOM-style sampling: chord interpolation along the manifold
def manifold_sample(X, phi, n_new, k_neighbors, alpha_range, rng):
    new = np.empty((n_new, X.shape[1]))
    for i in range(n_new):
        idx = rng.integers(len(X))
        d2  = np.sum((phi - phi[idx]) ** 2, axis=1)
        knn = np.argsort(d2)[1:k_neighbors + 1]
        j   = knn[rng.integers(k_neighbors)]
        alpha = rng.uniform(-alpha_range, alpha_range)
        new[i] = X[idx] + alpha * (X[j] - X[idx])
    return new

X_plom = manifold_sample(X, phi, n_new=500, k_neighbors=6, alpha_range=1.0, rng=rng)


# 4. Baseline: naive ambient KDE
def kde_sample(X, n_new, bandwidth, rng):
    idx = rng.integers(len(X), size=n_new)
    return X[idx] + bandwidth * rng.standard_normal((n_new, X.shape[1]))

X_kde      = kde_sample(X, n_new=500, bandwidth=NOISE_SD, rng=rng)
X_kde_wide = kde_sample(X, n_new=500, bandwidth=0.15, rng=rng)


# 5. Measure how on-manifold each population is
def dist(P, R=R_TRUE):
    return np.sqrt(np.sum(P ** 2, axis=1)) - R

def stats(d):
    return d.mean(), np.sqrt(np.mean(d ** 2)), np.max(np.abs(d))

print(f"\nTrue manifold: unit circle (R = {R_TRUE}, noise sd = {NOISE_SD})\n")
print(f"{'population':<28} {'mean dist':>12} {'rms dist':>12} {'max dist':>12}")
print("-" * 68)
for name, P in [("training data",            X),
                ("PLOM samples",              X_plom),
                ("naive KDE  bw = 0.05",      X_kde),
                ("naive KDE  bw = 0.15",      X_kde_wide)]:
    m, rms, mx = stats(dist(P))
    print(f"{name:<28} {m:>+12.4f} {rms:>12.4f} {mx:>12.4f}")
top diffusion-map eigenvalues: 0.9673, 0.9362

True manifold: unit circle (R = 1.0, noise sd = 0.05)

population                      mean dist     rms dist     max dist
--------------------------------------------------------------------
training data                     +0.0005       0.0480       0.1619
PLOM samples                      +0.0015       0.0628       0.2637
naive KDE  bw = 0.05              +0.0038       0.0658       0.1804
naive KDE  bw = 0.15              +0.0198       0.1548       0.5098

The radial-distance statistics (mean distance from the unit circle, root-mean-square distance, max distance) are the headline number. Training data has rms 0.048 (the actual noise scale). PLOM samples have rms 0.063 — within a few percent of the training noise. Naive KDE at the right bandwidth (0.05) is similar at 0.066. Naive KDE at a slightly wider bandwidth (0.15) is at 0.155 — three times worse. The wider-bandwidth column is the case PLOM handles automatically: the manifold structure encoded in the diffusion-map embedding prevents the sampler from drifting off the circle even though ranges over .

The output

Eight-panel figure: 2x4 grid. Top row: scatter plots of each of the four populations against the analytic unit circle (gray outline). Training data: 300 blue dots tightly hugging the circle. PLOM samples: 500 green dots also tightly on the circle. Naive KDE bw=0.05: 500 orange dots also on the circle. Naive KDE bw=0.15: 500 red dots forming a visibly blurred annulus extending well off the circle. Bottom row: histograms of signed distance from the circle for each population, with rms labels. Training rms 0.048, PLOM rms 0.063, KDE-narrow rms 0.066, KDE-wide rms 0.155.

The comparison is visible at a glance. The first three populations (training data, PLOM, KDE-narrow) all visibly hug the unit circle. The fourth — naive KDE with bandwidth 0.15 — produces a thick blurry annulus. The radial-distance histograms in the bottom row tell you that the bandwidth tuning required to get KDE on-manifold is sensitive; PLOM isn't sensitive in the same way because the sampling direction is constrained to the manifold's chord structure.

Extensions

  1. The full Soize-Ghanem Itô SDE. Replace the chord interpolation in step 2 with the actual PLOM update. Sample new points by running Euler-Maruyama on in the diffusion-map basis, where is a KDE on the -values. Then lift each sampled back to ambient space via the Nystrom out-of-sample formula with . Compare to the chord scheme — the SDE version should give smoother coverage with calibrated diffusion.
  2. A harder manifold. Replace the noisy circle with the Swiss roll: for with small radial noise. The Swiss roll is a 1D curve embedded in 2D where ambient distance routinely jumps across loops — naive KDE would couple distant arms. The diffusion map naturally unrolls it; PLOM samples should respect the curve.
  3. Conditional sampling. Suppose you've observed and want to draw consistently. In the manifold-aware sampler, restrict step 1 (the base-point draw) to points with . The conditional samples should track the manifold over the conditioning region.
  4. Higher-dimensional ambient space. Embed the 1D manifold into by padding with eight Gaussian-noise coordinates. A naive 10D KDE will spread samples isotropically across all 10 dimensions, blurring orders of magnitude off the manifold. PLOM still uses the data-defined kernel and stays on the embedded 1D curve. The radial-distance metric in the original 2 dimensions should look essentially the same.
  5. Sparse-data regime. Re-run with . KDE samples degenerate to copies of training points at small (with whatever bandwidth you pick); PLOM should interpolate between training points along the manifold, producing dense coverage even when training data is sparse.
  6. Direct comparison to a normalizing flow. Train a small RealNVP or MAF on the same dataset and sample from it. Normalizing flows learn a full ambient density and can also stay on-manifold given enough data. Compare per-sample compute cost (PLOM: O(N) chord lookup; flow: forward pass through the network) and fit-vs-data quality.
  7. Eigenvalue gap diagnostic. The number of non-trivial diffusion-map eigenvalues close to 1 estimates the intrinsic dimension of the data manifold. For the circle, two eigenvalues are close to 1 (the 2D embedding of a 1D loop). For a sphere in 3D, three would be close to 1. Verify by generating data on a 2-sphere and reading off the eigenvalue gap.

References

Related on this site

MCMC from scratch is the standard sampling alternative when you can evaluate the density. When a linear model stops being linear covers why high-dimensional fitting is hard — PLOM is one answer for the generative version of the same problem. Normalizing flows are an alternative generative approach that learns a full ambient density.