neural: real-MNIST external-validity tier (collapse + grounding)

Confirms model collapse and its arrest by grounding on REAL images, not
just the synthetic sandbox. A conv VAE (the canonical generative-collapse
model) is retrained each generation on its own generated digits, with a
fraction g of fresh real MNIST mixed in. Modes = digit class x stroke-
thickness bin (K=30, Zipf, ~18 tail modes); the oracle is a frozen CNN +
deterministic thickness at 98.5% mode accuracy (30x30 confusion matrix
recorded in the manifest as the measurement-noise floor).

Result (4 reps): dry (g=0) collapses to a single mode -- forward-KL
0.5->18, support 30->1, tail 1.0->0.06, H->0 -- while 10% grounding holds
all 30 modes (KL~0.6, full tail, H~0.9). Signs, not magnitudes (blueprint
3.5); the exact synthetic oracle stays the quantitative anchor. The VAE
needs ~10% grounding vs the synthetic histogram's ~5%, consistent with the
grounding finding that trained nets need more than the exact operator.

Plugs into the existing data-agnostic contract (metrics/grounding/output
reused verbatim): mnist_data (thickness bins, class x thickness bijection,
MnistSampler), mnist_oracle (ClassifierOracle + confusion matrix),
mnist_vae (ConvVAEGenerator), mnist_loop (run_mnist_lineage), kind=
mnist_lineage dispatch, MnistCfg/OracleCfg. Figures: plot_mnist (parquet-
only) + mnist_montage (eyeball diagnostic showing digits degenerate to one
blurry mode). make mnist / make env-mnist, kept out of the make neural
loop. 99 tests green (+5 torchvision-gated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-07-05 09:19:36 +01:00
parent 3b9f4f7893
commit 79bbc45f41
21 changed files with 2200 additions and 10 deletions

106
src/neural/mnist_loop.py Normal file
View file

@ -0,0 +1,106 @@
"""The MNIST analogue of ``generation_loop.run_generative_lineage``.
Identical Wright-Fisher generational step drift (``n`` samples from the parent model) +
grounding (``m`` fresh real samples, ``g = m/(n+m)``) + refit but observations are **images**:
the parent VAE *generates* the drift images, and grounding *draws real MNIST images* from the
per-mode pools (``MnistSampler``) instead of rendering token sequences. The frozen classifier
oracle reads each generation's mode distribution, logged with the *same* metric schema as every
other tier (``neural.evaluate.measure_metrics``), so the MNIST curves overlay the synthetic ones.
The oracle, sampler, and ``p*`` are built once by the experiment runner and passed in (training
the classifier and indexing the image pools is expensive and shared across all arms/replicates).
"""
from __future__ import annotations
from typing import Any, Mapping
import numpy as np
import pandas as pd
from knowledge.config import MetricsCfg, _sub
from knowledge.step import allocate_m, structured_multinomial
from knowledge.truth import TrueDist, uniform_init
from .config import MnistCfg, ModelCfg, NeuralDynamicsCfg
from .evaluate import measure_metrics
from .mnist_data import MnistSampler
from .mnist_vae import ConvVAEGenerator
from .oracle import Oracle
def _make_mnist_model(model_cfg: ModelCfg, cfg: MnistCfg, oracle: Oracle):
"""Construct the image generative model for the MNIST tier."""
if model_cfg.kind == "convvae":
return ConvVAEGenerator(cfg, model_cfg, oracle)
raise ValueError(f"unknown MNIST model kind {model_cfg.kind!r} (expected convvae)")
def run_mnist_lineage(cfg: Mapping[str, Any], seed: int, oracle: Oracle,
sampler: MnistSampler, td: TrueDist) -> pd.DataFrame:
"""Run one MNIST lineage and return per-generation metrics (same schema as Layer 1).
Args:
cfg (Mapping): Resolved config with ``mnist``, ``model``, ``dynamics``, ``generations``,
and optional ``metrics`` blocks.
seed (int): Replicate seed (statistically reproducible for the VAE).
oracle (Oracle): Prebuilt frozen classifier oracle.
sampler (MnistSampler): Prebuilt per-mode real-image pools.
td (TrueDist): ``p*``, tail mask, regions over the ``K`` modes.
Returns:
pd.DataFrame: One row per generation 0..T with the standard metric columns.
"""
mcfg = _sub(cfg["mnist"], MnistCfg)
model_cfg = _sub(cfg["model"], ModelCfg)
dyn_raw = dict(cfg.get("dynamics", {}))
from knowledge.config import GroundingCfg, RemintCfg
dynamics = NeuralDynamicsCfg(
n=dyn_raw.get("n", NeuralDynamicsCfg.n),
grounding=_sub(dyn_raw.get("grounding", {}), GroundingCfg),
remint=_sub(dyn_raw.get("remint", {}), RemintCfg),
)
metrics = _sub(cfg.get("metrics", {}), MetricsCfg)
generations = int(cfg.get("generations", 20))
p_star = td.p_star
tail_mask, regions, R = td.tail_mask, td.regions, mcfg.R
n = dynamics.n
grounding = dynamics.grounding
exercised = np.asarray(grounding.exercised) if grounding.exercised is not None else None
m_vector = allocate_m(grounding.m, R, grounding.policy, exercised)
rng = np.random.default_rng(seed)
p0 = uniform_init(mcfg.K) if mcfg.init == "uniform" else p_star.copy()
model = _make_mnist_model(model_cfg, mcfg, oracle)
def real_images(counts: np.ndarray) -> np.ndarray:
return sampler.draw(counts, rng)
# Generation 0: train on n real images drawn from p0 (the loop builds gen-0, not the VAE).
X0 = real_images(rng.multinomial(n, p0))
model.fit(X0, rng)
rows: list[dict] = []
def record(t: int) -> None:
row = {"generation": t}
row.update(measure_metrics(model.mode_distribution(rng), p_star, tail_mask,
regions, R, metrics))
rows.append(row)
record(0)
for t in range(1, generations + 1):
X_syn = model.sample(n, rng) # drift: n images from the parent
if m_vector is not None: # immigration: m real images
counts_real = structured_multinomial(m_vector, p_star, regions, grounding.policy, rng)
pool = np.concatenate([X_syn, real_images(counts_real)], axis=0)
else:
pool = X_syn
pupil = _make_mnist_model(model_cfg, mcfg, oracle)
pupil.fit(pool, rng)
model = pupil
record(t)
return pd.DataFrame(rows)