MachineSex/figures/plot_figS6_grounding_rnn.py
Giorgio Gilestro ab3dc10587 Restructure: descriptive tier and experiment names, paper/manuscript
- paper/pnas -> paper/manuscript (venue-neutral)
- configs/layer1 -> configs/inheritance, src/knowledge -> src/inheritance
  (imported as `inheritance`), make layer1 -> make inheritance; layer2 alias dropped
- inheritance and trained-network bundles named after the manuscript figure
  they feed (fig2_grounding_sweep, figS3_rebaselining, ...), or descriptively
  where they feed none; configs keep their `experiment:` value so parquet
  hashes are unchanged, only output.dir moves
- figure scripts, SI figure sources, notebooks, REPRODUCING.md, README and the
  SI Methods/tables updated; make clean no longer deletes tracked manifests;
  reproduce.sh hashes the s{seed}/ layouts too

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y64o8FKP7rCuXzC48pxpMm
2026-09-13 17:00:40 +01:00

140 lines
6.5 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_figS6_grounding_rnn.py [results/figS6_grounding_rnn]
"""
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, letter_axes # noqa: E402
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
from inheritance.analysis import reduce_to_stationary # noqa: E402
from inheritance.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/figS6_grounding_rnn") -> 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="Trained RNN: KL falls monotonically with grounding\n"
"(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"analytic $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.tight_layout()
letter_axes(fig)
savefig(fig, results_dir, "figS6_grounding_rnn")
if __name__ == "__main__":
main(*sys.argv[1:])