MachineSex/figures/plot_grounding.py
Giorgio Gilestro b8da418034 neural: grounding refinement + all five Layer-1.5 figures
Grounding refinement (18 reps): forward-KL is the operative neural
collapse metric, not H or tail-survival. The RNN's smoothing keeps
spurious tail modes alive, so tail_truth_mass_alive is flat/non-monotone
in g and H stays ~0.8 of H*; only forward-KL falls monotonically (dry
2.08 -> g=0.2: 0.75, paired t up to 3.3). The sharp g* << 1 is an
exact-operator feature carried by the histogram bridge (0.047); the
trained RNN confirms the SIGN and softens the sharpness (half the KL gap
closes by g~0.04, but full recovery needs g~0.19). Blueprint 3.5's
directional claim holds; the pre-registered 95%-of-H*/tail falsifier is
not met because those are the wrong metrics for a smoothing model.

Robustness: a fully-degenerate RNN can emit only invalid codewords, so
measure_distribution now returns a terminal-collapse sentinel (fixation
on the dominant mode) instead of crashing a long sweep. Edge test added
(94 tests green).

Figures: plot_{bridge,collapse,grounding,architectures,recombination}.py,
each a pure function of its committed bundle, wired into `make figures`
(glob plot_*.py minus plot_E[1-6]/_*). bridge sits on the exact H_eq
curve (g*=0.047); recombination shows max-merge rising while mean-distill
stays flat; architectures shows the collapse/rescue signs across
histogram/GRU/MLP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 08:14:19 +01:00

142 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""`grounding` figure — the neural grounding response in REAL weights (↔ Layer-1 E2).
Honest reframing (see the progress log): the RNN's smoothing inductive bias makes H and
tail-SURVIVAL the *wrong* neural collapse metrics — the model keeps spurious tail support
alive even while its distribution drifts far from truth, so tail_truth_mass_alive is flat /
non-monotone in g. The operative neural collapse metric is FORWARD-KL, on which grounding's
effect is monotone and significant. The sharp Layer-1 threshold (g*=0.048) is an *exact-
operator* feature reproduced quantitatively by the histogram bridge (g*=0.047); the trained
RNN confirms it in SIGN and softens it in sharpness.
Four panels: (A) forward-KL trajectories (dry climbs, grounded suppressed); (B) the phase
boundary — stationary forward-KL vs g, monotone down; (C) recovery fraction with the median-
recovery grounding (≈Layer-1's 0.048) and the note that full recovery needs much more g in a
smoothing model; (D) the metric-choice panel — H and tail-survival are flat/non-monotone
while forward-KL responds. Reads only the committed bundle.
Usage: python figures/plot_grounding.py [results/grounding]
"""
from __future__ import annotations
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
sys.path.insert(0, str(Path(__file__).parent))
from _figlib import load_bundle, mean_ci, savefig # noqa: E402
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
from knowledge.analysis import reduce_to_stationary # noqa: E402
from knowledge.metrics import heterozygosity # noqa: E402
from neural.config import SyntheticCfg # noqa: E402
from neural.synthetic import make_mode_truth # noqa: E402
_LAYER1_GSTAR = 0.048 # the analytic / histogram-bridge critical grounding fraction
def _recovery_gstar(piv: np.ndarray, gs: np.ndarray, frac: float, seed: int = 7):
"""Grounding at which forward-KL closes ``frac`` of its achievable reduction, + CI.
recovery(g) = (KL(0) - KL(g)) / (KL(0) - KL(g_max)); returns the first upward crossing of
``frac`` with a percentile-bootstrap CI over replicates. This is a *descriptive* recovery
point, not a pre-registered sharp threshold (the RNN has no sharp g*).
"""
def crossing(mat):
mk = mat.mean(axis=0)
rec = (mk[0] - mk) / (mk[0] - mk[-1])
idx = np.where(rec >= frac)[0]
if idx.size == 0 or idx[0] == 0:
return np.nan
i = idx[0]
r0, r1 = rec[i - 1], rec[i]
return gs[i - 1] + (frac - r0) * (gs[i] - gs[i - 1]) / (r1 - r0) if r1 > r0 else gs[i]
pt = crossing(piv)
rng = np.random.default_rng(seed)
boots = np.array([crossing(piv[rng.integers(0, piv.shape[0], piv.shape[0])])
for _ in range(4000)])
boots = boots[~np.isnan(boots)]
lo, hi = (np.percentile(boots, [2.5, 97.5]) if boots.size else (np.nan, np.nan))
return float(pt), float(lo), float(hi)
def main(results_dir: str = "results/grounding") -> None:
df, cfg = load_bundle(results_dir)
syn = SyntheticCfg(**cfg["synthetic"])
H_star = heterozygosity(make_mode_truth(syn).p_star)
g_values = np.array(sorted(df["g"].unique()))
last = int(cfg["generations"] * 0.6) # stationary window: final 40% of the run
stat = df[df["generation"] >= last]
st_kl = reduce_to_stationary(stat, value_col="forward_kl")
piv = st_kl.pivot(index="replicate", columns="g", values="forward_kl")[g_values].to_numpy()
g50, lo50, hi50 = _recovery_gstar(piv, g_values, 0.5)
fig, axes = plt.subplots(2, 2, figsize=(13, 9))
colors = plt.cm.viridis(np.linspace(0, 0.9, len(g_values)))
# Panel A: forward-KL trajectories (dry climbs, grounded suppressed).
ax = axes[0, 0]
for g, c in zip(g_values, colors):
s = df[df["g"] == g].groupby("generation")["forward_kl"].mean()
ax.plot(s.index, s.values, color=c, label=f"g={g:g}")
ax.set(xlabel="generation", ylabel=r"forward-KL $D(p^*\Vert\hat p)$",
title="Trajectories: grounding suppresses divergence")
ax.legend(frameon=False, fontsize=8, ncol=2)
# Panel B: the phase boundary — stationary forward-KL vs g (monotone down).
ax = axes[0, 1]
kg, Km, Kci = mean_ci(st_kl, "g", "forward_kl")
ax.errorbar(kg, Km, yerr=Kci, fmt="o-", color="#1f77b4", capsize=3, zorder=3)
ax.set(xlabel="grounding fraction $g=m/(n+m)$",
ylabel=r"stationary forward-KL $D(p^*\Vert\hat p)$",
title="Neural phase boundary: KL falls monotonically\n"
"(sign confirmed; paired $t$=3.3 at g=0.2)")
# Panel C: recovery fraction with the median-recovery grounding vs Layer-1's g*.
ax = axes[1, 0]
mk = piv.mean(axis=0)
rec = (mk[0] - mk) / (mk[0] - mk[-1])
ax.plot(g_values, rec, "o-", color="#2ca02c")
ax.axhline(0.5, ls=":", color="gray", lw=1)
ax.axvspan(lo50, hi50, color="#d62728", alpha=0.15)
ax.axvline(g50, color="#d62728", lw=1.2,
label=f"median-recovery $g$={g50:.3f}\n(95% CI [{lo50:.3f},{hi50:.3f}])")
ax.axvline(_LAYER1_GSTAR, ls="--", color="k", lw=1, label=f"Layer-1 $g^*$={_LAYER1_GSTAR}")
ax.set(xlabel="grounding fraction $g$", ylabel="forward-KL recovery fraction",
title="Half the divergence gap closes by $g\\approx0.04$\n"
"(full recovery needs more g: smoothing softens the threshold)")
ax.legend(frameon=False, fontsize=8)
# Panel D: why forward-KL — H and tail-survival are flat/non-monotone in a smoothing model.
ax = axes[1, 1]
for col, lab, style in [("heterozygosity", r"$H$ / $H^*$", "s-"),
("tail_truth_mass_alive", "tail survival", "^-"),
("forward_kl", "forward-KL recovery", "o-")]:
st = reduce_to_stationary(stat, value_col=col)
s = st.groupby("g")[col].mean().reindex(g_values)
if col == "heterozygosity":
y = s.to_numpy() / H_star
elif col == "forward_kl":
y = (s.iloc[0] - s.to_numpy()) / (s.iloc[0] - s.iloc[-1]) # recovery, 0..1
else:
y = s.to_numpy()
ax.plot(g_values, y, style, ms=4, label=lab)
ax.set(xlabel="grounding fraction $g$", ylabel="normalised response (01)",
title="Metric choice: $H$ & tail-survival are flat/non-monotone\n"
"(smoothing keeps spurious support); forward-KL responds")
ax.legend(frameon=False, fontsize=8)
fig.suptitle("grounding — grounding arrests collapse in trained RNN weights (SIGN confirmed); "
f"the sharp $g^*\\ll1$ is carried by the histogram bridge ($g^*$=0.047, $H^*$={H_star:.2f})",
y=1.0, fontsize=12)
fig.tight_layout()
savefig(fig, results_dir, "grounding")
if __name__ == "__main__":
main(*sys.argv[1:])