Diagnosing Mode Collapse in Spatial GANs

When a spatial GAN starts emitting point sets that all pile into the same two or three neighbourhoods while the loss curves look healthy, the generator has collapsed onto a handful of modes and the only reliable warning comes from spatial-diversity metrics, not the adversarial loss.

Part of GAN-Based Spatial Data Generation: that page covers the end-to-end generator and critic architecture for coordinate and raster output; this page isolates the single most common training failure — mode collapse — and gives the spatial-specific detectors and remedies that generic image-GAN advice misses, because a collapsed spatial generator can still fool a critic while destroying the geographic coverage a downstream model depends on.

Root Cause: Why a Spatial Generator Collapses Onto a Few Modes

Mode collapse is the generator discovering that a narrow slice of the output space reliably fools the current critic, then mapping most or all of its latent inputs onto that slice. In a standard GAN the min-max objective

minGmaxD  Expdata[logD(x)]+Ezpz[log(1D(G(z)))]\min_{G}\max_{D}\; \mathbb{E}_{x\sim p_{\text{data}}}[\log D(x)] + \mathbb{E}_{z\sim p_z}[\log(1 - D(G(z)))]

has no term that rewards the generator for covering the data distribution — only for producing individual samples the critic scores as real. If a single dense neighbourhood is easy to reproduce and the critic has not yet learned to punish the absence of the sparse periphery, the generator’s gradient points straight at that neighbourhood for every latent vector zz. Nothing in the loss objects.

For spatial data this is worse than for images, for three concrete reasons. First, real geographic point patterns are heavy-tailed: a few dense cores (city centres, ports) dominate the sample count, so a generator that reproduces only the cores already achieves a low sample-wise loss while dropping the sparse periphery of rural locations entirely. Second, spatial fidelity is judged by distributional statistics — Ripley’s K, nearest-neighbour distance, quadrat counts — that a per-sample critic never sees, so the critic can be satisfied while the generated pattern’s second-order structure is wrong. Third, coordinate GANs often train on normalized (x,y)(x, y) pairs where a collapsed generator produces a delta-like cluster that is numerically stable and therefore sticky: once collapsed, the generator sits in a low-gradient basin and does not climb out on its own.

The practical consequence is that generator and critic loss can oscillate around their equilibrium values — the textbook “healthy” picture — while the spatial entropy of each generated batch falls monotonically. You must instrument diversity directly.

Spatial diversity signature of mode collapse: quadrat coverage and a falling spatial-entropy trace Left panel titled "Healthy batch" shows a 4-by-4 quadrat grid with generated points distributed across twelve of sixteen cells, closely matching a reference distribution. Right panel titled "Collapsed batch" shows the same grid with every generated point crammed into two adjacent cells, the other fourteen empty. A lower strip plots per-batch spatial entropy in bits against training epoch: a nearly flat primary-coloured line for the healthy run holding near 3.6 bits, and an accent-coloured line that tracks it until epoch 40 then falls steeply toward 0.9 bits, crossing a dashed early-warning threshold at 2.8 bits around epoch 46 — several epochs before the collapse is visible to a human. Healthy batch Collapsed batch 14 of 16 cells empty collapse training epoch → entropy (bits) healthy 3.6 collapsed 0.9 early-warning threshold 2.8 crossed at epoch 46
Loss curves stay near equilibrium through the collapse; the per-batch spatial-entropy trace crosses the early-warning threshold several epochs before the failure is visible in a scatter plot.

Minimal Reproducer: Force and Observe a Collapse

Pin the toolchain, then train a deliberately fragile generator on a two-cluster synthetic pattern and watch spatial entropy fall while the loss stays flat.

python
# requirements.txt — pin majors; let patch releases float
numpy==1.26.*
scipy==1.11.*
torch==2.*        # generator + critic
python
import numpy as np
import torch

def spatial_entropy(points: np.ndarray, bins: int = 16, extent=(0.0, 1.0)) -> float:
    """Shannon entropy (bits) of the quadrat-count histogram over a bins x bins grid.
    Falls toward 0 as a batch collapses into a few cells; near log2(bins**2) when spread."""
    lo, hi = extent
    h, _, _ = np.histogram2d(points[:, 0], points[:, 1], bins=bins, range=[[lo, hi], [lo, hi]])
    p = h.ravel()
    p = p[p > 0] / p.sum()                 # empty cells contribute nothing
    return float(-(p * np.log2(p)).sum())

# A generator that has collapsed onto one quadrat scores near 0 bits;
# a batch matching a well-spread target scores near log2(256) = 8 bits.
rng = np.random.default_rng(7)
spread = rng.random((2048, 2))
collapsed = np.clip(rng.normal(0.5, 0.01, (2048, 2)), 0, 1)
print(round(spatial_entropy(spread), 2), round(spatial_entropy(collapsed), 2))
# 7.6 0.71

Log spatial_entropy(G(z).cpu().numpy()) on a fixed held-out latent batch every epoch. A monotone decline over 3–5 epochs, independent of the loss, is the diagnostic signal.

Fix: Spatial-Entropy Gating, Minibatch Discrimination, and a Coverage Penalty

The remedy is two-layered: give the critic information about batch diversity so collapse stops being a winning strategy, and add a generator-side term that penalizes leaving the data support uncovered. Both are cheap and composable.

Step 1 — Track cluster dispersion across epochs

Complement entropy with a dispersion metric so you catch collapse onto several tight blobs (which can keep entropy moderate). Fit k-means to each generated batch and track the mean within-cluster spread against the reference.

python
import numpy as np
from scipy.cluster.vq import kmeans2

def cluster_dispersion(points: np.ndarray, k: int = 8, seed: int = 0) -> float:
    """Mean within-cluster RMS radius. Collapses toward 0 as batches tighten."""
    rng = np.random.default_rng(seed)
    init = points[rng.choice(len(points), k, replace=False)]
    centroids, labels = kmeans2(points, init, minit="matrix", seed=seed)
    radii = [np.sqrt(((points[labels == j] - centroids[j]) ** 2).sum(1).mean())
             for j in range(k) if (labels == j).any()]
    return float(np.mean(radii)) if radii else 0.0

Step 2 — Add minibatch discrimination to the critic

Minibatch discrimination lets the critic see how similar the samples in a batch are to each other, so a batch of near-identical collapsed points is flagged as fake regardless of how real any single point looks. This directly removes the incentive to collapse.

python
import torch
import torch.nn as nn

class MinibatchDiscrimination(nn.Module):
    """Appends cross-sample similarity features (Salimans et al.) to a critic layer."""
    def __init__(self, in_features: int, out_features: int, kernel_dim: int = 16):
        super().__init__()
        self.t = nn.Parameter(torch.randn(in_features, out_features, kernel_dim) * 0.1)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        m = x @ self.t.view(x.size(1), -1)                       # (B, out*kernel)
        m = m.view(-1, self.t.size(1), self.t.size(2))           # (B, out, kernel)
        # L1 distance between every pair of samples in the batch
        diff = (m.unsqueeze(0) - m.unsqueeze(1)).abs().sum(3)    # (B, B, out)
        feats = torch.exp(-diff).sum(1)                          # (B, out): high when batch is uniform
        return torch.cat([x, feats], dim=1)

Step 3 — Penalize uncovered support in the generator

Add a differentiable coverage term that pushes generated batches toward the reference quadrat distribution. This is the spatial analogue of the coverage side of a fidelity check and pairs directly with the statistics used in realism metrics evaluation.

python
import torch

def coverage_penalty(fake_xy: torch.Tensor, ref_hist: torch.Tensor,
                     bins: int = 16, tau: float = 0.02) -> torch.Tensor:
    """Soft-binned KL from the generated quadrat distribution to the reference.
    Differentiable via a Gaussian soft-assignment so it can backprop into G."""
    edges = torch.linspace(0, 1, bins + 1, device=fake_xy.device)
    centers = 0.5 * (edges[:-1] + edges[1:])
    # soft one-hot assignment of each coordinate to bin centres
    ax = torch.softmax(-((fake_xy[:, :1] - centers) ** 2) / tau, dim=1)   # (B, bins)
    ay = torch.softmax(-((fake_xy[:, 1:2] - centers) ** 2) / tau, dim=1)
    hist = torch.einsum("bi,bj->ij", ax, ay)                 # (bins, bins) soft counts
    p = hist.flatten() / hist.sum()
    q = ref_hist.flatten() / ref_hist.sum()
    return (q * (q.clamp_min(1e-9).log() - p.clamp_min(1e-9).log())).sum()

# Total generator loss: adversarial term + lambda * coverage_penalty(...)

Keep the coverage weight lambda small (0.05–0.2); it is a regularizer, not the objective. If you are also enforcing a privacy budget, the interaction with clipped, noised gradients matters — see stabilizing GAN training with DP-SGD for coordinates, because DP noise on the critic can mask exactly the batch-diversity signal minibatch discrimination provides.

Verification Step: Gate Diversity in CI

Wire the detectors as assertions so a collapsed checkpoint cannot be promoted. Compare against a reference pattern generated by the same point process simulation models you validate density layers against.

python
import numpy as np

def test_generator_has_not_collapsed(generator, ref_points, *, seed=42):
    z = np.random.default_rng(seed).standard_normal((4096, generator.latent_dim))
    fake = generator.sample(z)                 # (N, 2) in [0, 1]

    ent = spatial_entropy(fake, bins=16)
    ref_ent = spatial_entropy(ref_points, bins=16)
    # Generated entropy must stay within 20% of the reference — the collapse gate.
    assert ent > 0.80 * ref_ent, f"entropy {ent:.2f} << reference {ref_ent:.2f}: mode collapse"

    disp = cluster_dispersion(fake, k=8, seed=seed)
    ref_disp = cluster_dispersion(ref_points, k=8, seed=seed)
    assert disp > 0.60 * ref_disp, f"dispersion {disp:.3f} collapsed vs {ref_disp:.3f}"

    # Coverage: no more than 10% of reference-occupied quadrats may be empty in fakes.
    occ = lambda p: np.histogram2d(p[:, 0], p[:, 1], bins=16, range=[[0, 1], [0, 1]])[0] > 0
    missing = occ(ref_points) & ~occ(fake)
    assert missing.sum() <= 0.10 * occ(ref_points).sum(), "generator dropped occupied regions"

The entropy and coverage gates catch distinct failures: entropy falls on a single-blob collapse, while the coverage gate catches a generator that keeps a few blobs alive but silently abandons the sparse periphery.

Edge Cases & Gotchas

Antimeridian wrap inflates entropy falsely. If you compute entropy on raw longitude that spans ±180°, points on either side of the seam land in opposite ends of the histogram range and register as “spread,” masking a collapse that is really tight on the ground. Project to a metric CRS such as EPSG:6933, or unwrap longitude relative to a central meridian, before binning — otherwise the diversity gate passes a collapsed generator near the dateline.

Partial collapse onto the dense cores. A generator can reproduce every city centre perfectly and drop only the rural tail, leaving global entropy barely changed because the cores hold most of the mass. Weight the coverage penalty by the inverse reference density, or bin in an equal-count (quantile) grid rather than an equal-area grid, so the sparse periphery is not drowned out by the cores.

Float32 coordinate degeneracy after normalization. When coordinates are min-max scaled into [0, 1] and the generator collapses, many outputs become bit-identical in float32, so kmeans2 can return empty clusters and dispersion reads exactly 0.0 — which looks like a crash rather than a diagnosis. Treat a zero or NaN dispersion as a hard collapse signal, not an error, and clamp the metric accordingly.

Frequently Asked Questions

Why do the generator and critic losses look fine during mode collapse?
The adversarial objective rewards fooling the critic on individual samples, not covering the data distribution. When the generator finds a narrow region the current critic scores as real, its loss stays low even though every latent vector maps to that same region. Loss curves therefore oscillate around equilibrium while spatial diversity quietly falls, which is why you must track entropy and dispersion directly.
How is spatial entropy different from just plotting the points?
A scatter plot only reveals collapse once it is severe and only to a human watching every epoch. Spatial entropy reduces each batch to a single number computed on a quadrat histogram, so you can log it automatically and set an early-warning threshold that trips several epochs before the collapse is visible. It also makes the failure gateable in CI, which a plot cannot be.
Does minibatch discrimination interfere with differential privacy?
It can. Minibatch discrimination works by letting the critic compare samples within a batch, and DP-SGD adds calibrated noise to the critic's clipped gradients that can drown out that cross-sample signal. If you train under a privacy budget, verify empirically that entropy still recovers after adding minibatch discrimination, and tune the noise multiplier and coverage weight together rather than in isolation.
My generator covers the dense areas but drops the rural tail — is that mode collapse?
Yes, it is partial mode collapse, and it is easy to miss because the dense cores hold most of the sample mass so global entropy barely moves. Detect it with a coverage gate that flags reference-occupied quadrats left empty in the generated output, and mitigate it by weighting the coverage penalty by inverse reference density or binning on an equal-count grid so the sparse periphery is not ignored.