Layer 1 complete: E3-E6 + E2 analysis add-ons
Finishes the Layer 1 analytical core. All six experiments run with honest, publication-quality figures; 71 tests green. - E3 region-matched grounding: `grounding.exercised` knob + per-region tail survival. Matched holds the exercised region's tail (0.49) where uniform spreads thin and lets it collapse (0.07). - E4 multi-teacher recombination: `run_coverage` runner. Union coverage matches U(K_T,rho,q) exactly. Finding: mean-mixture distillation shows NO surviving benefit (a conservation law — 1/K_T dilution cancels the union gain); a union-preserving max-merge (M2N2-style) does. E4 reports both operators. - E5 QD vs greedy: greedy drives fixation (H~0.01); QD holds H at 0.48-0.88, rising with the novelty exponent. - E6 re-mint gate: `arm` multi-override sweep. Re-minting a collapsed lineage locks in divergence of KL-to-original; gating on diversity prevents it. - E2 analysis add-ons (from the companion work order, numbers verified): new analysis.py (reduce_to_stationary, critical_grounding with bootstrap CI -> g*=0.048, 95% CI [0.047,0.050]); tail_band_metrics + per-band logging; the E2 figure rebuilt as a 2x2 (defined g*+CI, g=0 flagged as a finite-time artifact, tail item-vs-mass, per-rarity-band panel). Uses truth-mass-weighted tail coverage rather than the raw (martingale) tail_mass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a6eb9b7512
commit
1721d047fa
42 changed files with 1938 additions and 135 deletions
103
src/knowledge/analysis.py
Normal file
103
src/knowledge/analysis.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Post-hoc analysis of experiment results (pure NumPy/pandas, seeded, deterministic).
|
||||
|
||||
Turns a per-generation results frame into stationary summaries and an operational,
|
||||
CI-bearing definition of the critical grounding fraction g*. Nothing here touches the
|
||||
dynamics; it is analysis only, so figures remain a pure function of results.parquet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def reduce_to_stationary(df, value_col="heterozygosity", sweep_col="g",
|
||||
replicate_col="replicate", gen_col="generation", last_frac=0.33):
|
||||
"""Per-generation frame -> one stationary value per (sweep, replicate).
|
||||
|
||||
Averages ``value_col`` over the final ``last_frac`` of generations. Works for any
|
||||
logged metric (heterozygosity, tail_mass, ...).
|
||||
|
||||
Args:
|
||||
df (pd.DataFrame): Long-form results with sweep, replicate, generation, value cols.
|
||||
value_col (str): Metric to reduce.
|
||||
sweep_col (str): Swept parameter column.
|
||||
replicate_col (str): Replicate id column.
|
||||
gen_col (str): Generation column.
|
||||
last_frac (float): Fraction of the tail of the trajectory to average.
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: One row per (sweep_col, replicate_col) with the stationary value.
|
||||
"""
|
||||
rows = []
|
||||
for (gval, rep), sub in df.groupby([sweep_col, replicate_col]):
|
||||
v = sub.sort_values(gen_col)[value_col].to_numpy()
|
||||
k = max(1, int(round(last_frac * v.size)))
|
||||
rows.append({sweep_col: gval, replicate_col: rep, value_col: v[-k:].mean()})
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def _interp_crossing(g, H, target):
|
||||
"""First upward crossing of ``target`` by the (monotone-ish) curve H(g).
|
||||
|
||||
Returns (g_star, status) with status in {'ok', 'below_grid', 'above_grid'}, by linear
|
||||
interpolation between grid points.
|
||||
"""
|
||||
g = np.asarray(g, float)
|
||||
H = np.asarray(H, float)
|
||||
o = np.argsort(g)
|
||||
g, H = g[o], H[o]
|
||||
if H[0] >= target:
|
||||
return g[0], "below_grid" # already above at smallest g swept
|
||||
idx = np.where(H >= target)[0]
|
||||
if idx.size == 0:
|
||||
return g[-1], "above_grid" # never reaches target within swept range
|
||||
i = idx[0]
|
||||
g0, g1, H0, H1 = g[i - 1], g[i], H[i - 1], H[i]
|
||||
if H1 == H0:
|
||||
return g1, "ok"
|
||||
return g0 + (target - H0) * (g1 - g0) / (H1 - H0), "ok"
|
||||
|
||||
|
||||
def critical_grounding(stationary_df, H_star, frac=0.95, sweep_col="g",
|
||||
value_col="heterozygosity", n_boot=2000,
|
||||
ci=(2.5, 97.5), seed=0):
|
||||
"""Operational critical grounding fraction g* with a percentile-bootstrap CI.
|
||||
|
||||
g* is the grounding fraction at which stationary heterozygosity first reaches
|
||||
``frac * H_star``. The point estimate uses the replicate means; the CI resamples
|
||||
replicates within each g.
|
||||
|
||||
Args:
|
||||
stationary_df (pd.DataFrame): One row per (sweep, replicate) with the stationary
|
||||
value (e.g. the output of :func:`reduce_to_stationary`).
|
||||
H_star (float): Heterozygosity of the truth (``metrics.heterozygosity(p_star)``).
|
||||
frac (float): Fraction of H_star that defines "saturated".
|
||||
sweep_col (str): Swept-parameter column.
|
||||
value_col (str): Stationary value column.
|
||||
n_boot (int): Bootstrap resamples.
|
||||
ci (tuple): Percentile CI bounds.
|
||||
seed (int): Bootstrap seed (deterministic).
|
||||
|
||||
Returns:
|
||||
dict: g_star, ci_low, ci_high, status, target_H, frac, n_boot. ``status`` flags
|
||||
right/left censoring; if the sweep does not bracket the target, widen the g grid
|
||||
rather than trusting a censored g*.
|
||||
"""
|
||||
target = frac * H_star
|
||||
gs = np.sort(stationary_df[sweep_col].unique())
|
||||
by_g = {gv: stationary_df.loc[stationary_df[sweep_col] == gv, value_col].to_numpy()
|
||||
for gv in gs}
|
||||
mean_H = np.array([by_g[gv].mean() for gv in gs])
|
||||
g_star, status = _interp_crossing(gs, mean_H, target)
|
||||
|
||||
rng = np.random.default_rng(seed)
|
||||
boots = np.empty(n_boot)
|
||||
for b in range(n_boot):
|
||||
Hb = np.array([rng.choice(by_g[gv], by_g[gv].size, replace=True).mean()
|
||||
for gv in gs])
|
||||
boots[b], _ = _interp_crossing(gs, Hb, target)
|
||||
lo, hi = np.percentile(boots, ci)
|
||||
return {"g_star": float(g_star), "ci_low": float(lo), "ci_high": float(hi),
|
||||
"status": status, "target_H": float(target), "frac": frac,
|
||||
"n_boot": n_boot}
|
||||
|
|
@ -37,6 +37,8 @@ class TeachersCfg:
|
|||
class GroundingCfg:
|
||||
m: int = 0
|
||||
policy: str = "uniform" # {proportional, uniform, matched}
|
||||
# Region indices exercised this passage (matched policy only; None = all regions).
|
||||
exercised: Optional[list] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import yaml
|
|||
|
||||
from .lineage import run_lineage
|
||||
from .seeding import spawn_seeds
|
||||
from .teachers import make_correlated_teachers
|
||||
from .truth import make_true_distribution
|
||||
|
||||
# Keys that make up a single-lineage configuration (everything else is experiment-level).
|
||||
_LINEAGE_KEYS = ("truth", "dynamics", "generations", "metrics")
|
||||
|
|
@ -55,6 +57,12 @@ def _apply_param(lineage_cfg: dict, param: str, value: Any) -> dict:
|
|||
m = 0 if g <= 0.0 else int(round(n * g / (1.0 - g)))
|
||||
lineage_cfg["dynamics"].setdefault("grounding", {})["m"] = m
|
||||
return {"g": g, "m": m}
|
||||
if param == "arm":
|
||||
# A named arm bundling several overrides applied together (e.g. E6 varies
|
||||
# grounding + remint settings jointly). value = {name, set: {dotted.path: v}}.
|
||||
for path, v in value.get("set", {}).items():
|
||||
_set_by_path(lineage_cfg, path, v)
|
||||
return {"arm": value["name"]}
|
||||
_set_by_path(lineage_cfg, param, value)
|
||||
return {param.split(".")[-1]: value}
|
||||
|
||||
|
|
@ -119,6 +127,75 @@ def run_experiment(cfg: dict) -> pd.DataFrame:
|
|||
return out
|
||||
|
||||
|
||||
def run_coverage(cfg: dict) -> pd.DataFrame:
|
||||
"""E4 runner: multi-teacher recombination coverage (blueprint 2.5-E4 / 2.7.1).
|
||||
|
||||
A single distillation step, not a lineage. For each (K_T, rho) grid point and
|
||||
replicate: build K_T correlated teachers (marginal retention q, pairwise
|
||||
correlation rho), then measure two coverages of the tail:
|
||||
|
||||
* ``union_coverage`` — fraction of tail items retained by >=1 teacher (the
|
||||
construction-level union U(K_T, rho, q); must match the closed form). This is the
|
||||
recombination *supply*.
|
||||
* ``surviving_mean`` / ``surviving_max`` — fraction of tail items that survive the
|
||||
pupil's size-n resampling (+ optional grounding m) under two recombination
|
||||
operators: ``mean`` (blueprint mean-mixture distillation) and ``max`` (union-
|
||||
preserving model-merge, à la M2N2). Under ``mean`` the union gain is diluted by
|
||||
1/K_T and (in the rare-tail linear regime) is exactly cancelled — expected pupil
|
||||
tail mass is conserved at q·(tail mass) regardless of K_T, so surviving is flat.
|
||||
Under ``max`` each item keeps its strongest teacher, so surviving rises with K_T
|
||||
and with decorrelation. The gap ``union - surviving`` is the tail recombination
|
||||
supplied but drift/dilution re-erased.
|
||||
|
||||
Matched budget: the pupil draws n samples total from the combined teachers
|
||||
(equivalently n/K_T each), so more teachers != more data.
|
||||
"""
|
||||
truth, cov = cfg["truth"], cfg["coverage"]
|
||||
td = make_true_distribution(
|
||||
truth["K"], truth["R"], truth["tail"], truth["tail_frac"], truth["zipf_s"], 0,
|
||||
tail_threshold=truth["tail_threshold"],
|
||||
)
|
||||
tail_idx = np.flatnonzero(td.tail_mask)
|
||||
n, q = int(cov["n"]), float(cov["q"])
|
||||
retain_thresh = 1e-8 # dropped tails sit at ~tail_floor (1e-9); retained at ~p*_j
|
||||
|
||||
sweeps = cfg["sweep"]
|
||||
if isinstance(sweeps, dict):
|
||||
sweeps = [sweeps]
|
||||
params = [s["param"] for s in sweeps]
|
||||
value_lists = [list(s["values"]) for s in sweeps]
|
||||
seeds = spawn_seeds(int(cfg["seed"]), int(cfg["n_replicates"]))
|
||||
|
||||
rows: list[dict] = []
|
||||
for combo in itertools.product(*value_lists):
|
||||
d = dict(zip(params, combo))
|
||||
K_T, rho = int(d["K_T"]), float(d["rho"])
|
||||
g = float(d.get("g", cov.get("g", 0.0))) # g may be swept or fixed
|
||||
m = 0 if g <= 0.0 else int(round(n * g / (1.0 - g)))
|
||||
for rep, ss in enumerate(seeds):
|
||||
child = int(ss.generate_state(1)[0])
|
||||
teachers = np.asarray(make_correlated_teachers(
|
||||
td.p_star, td.tail_mask, K_T, rho, q, seed=child))
|
||||
retained = teachers[:, tail_idx] > retain_thresh # (K_T, T)
|
||||
union = float(np.mean(retained.any(axis=0)))
|
||||
surviving = {}
|
||||
for offset, combine in ((1, teachers.mean), (2, teachers.max)):
|
||||
p = combine(axis=0)
|
||||
p = p / p.sum()
|
||||
rng = np.random.default_rng(child + offset)
|
||||
counts = rng.multinomial(n, p)
|
||||
if m > 0:
|
||||
counts = counts + rng.multinomial(m, td.p_star)
|
||||
surviving[offset] = float(np.mean(counts[tail_idx] > 0))
|
||||
rows.append({
|
||||
"experiment": cfg["experiment"], "K_T": K_T, "rho": rho,
|
||||
"replicate": rep, "union_coverage": union,
|
||||
"surviving_mean": surviving[1], "surviving_max": surviving[2],
|
||||
"g": g, "q": q,
|
||||
})
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def _git_commit() -> str | None:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
|
|
@ -148,12 +225,13 @@ def save_artifacts(cfg: dict, df: pd.DataFrame, out_dir: Path) -> None:
|
|||
"experiment": cfg["experiment"],
|
||||
"seed": cfg["seed"],
|
||||
"n_replicates": cfg["n_replicates"],
|
||||
"grid": [
|
||||
{"label": label, "lineage_cfg": lineage_cfg}
|
||||
for label, lineage_cfg in expand_sweeps(cfg)
|
||||
],
|
||||
"source_config": cfg,
|
||||
}
|
||||
if cfg.get("kind", "lineage") == "lineage":
|
||||
resolved["grid"] = [
|
||||
{"label": label, "lineage_cfg": lineage_cfg}
|
||||
for label, lineage_cfg in expand_sweeps(cfg)
|
||||
]
|
||||
(out_dir / "resolved_config.yaml").write_text(yaml.safe_dump(resolved, sort_keys=False))
|
||||
|
||||
manifest = {
|
||||
|
|
@ -175,7 +253,7 @@ def run_and_save(config_path: str | Path) -> Path:
|
|||
config_path = Path(config_path)
|
||||
cfg = yaml.safe_load(config_path.read_text())
|
||||
out_dir = Path(cfg.get("output", {}).get("dir", f"results/{cfg['experiment']}"))
|
||||
df = run_experiment(cfg)
|
||||
df = run_coverage(cfg) if cfg.get("kind") == "coverage" else run_experiment(cfg)
|
||||
save_artifacts(cfg, df, out_dir)
|
||||
return out_dir
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,13 @@ import numpy as np
|
|||
import pandas as pd
|
||||
|
||||
from .config import LineageCfg
|
||||
from .metrics import forward_kl, heterozygosity, per_region, support_size, tail_mass
|
||||
from .metrics import (forward_kl, heterozygosity, per_region, support_size,
|
||||
tail_band_metrics, tail_mass)
|
||||
from .step import StepCtx, allocate_m, generation_step
|
||||
from .truth import make_true_distribution, uniform_init
|
||||
|
||||
N_BANDS = 4 # rarity bands for per-band tail-survival logging (blueprint pred. 4)
|
||||
|
||||
|
||||
def run_lineage(cfg: Mapping[str, Any] | LineageCfg, seed: int) -> pd.DataFrame:
|
||||
"""Run one lineage and return per-generation metrics.
|
||||
|
|
@ -52,8 +55,10 @@ def run_lineage(cfg: Mapping[str, Any] | LineageCfg, seed: int) -> pd.DataFrame:
|
|||
raise ValueError(f"unknown init {cfg.truth.init!r} (expected uniform|truth)")
|
||||
|
||||
p_star_eff = p_star_orig.copy() # grounding reference; may be re-minted (E6)
|
||||
exercised = cfg.dynamics.grounding.exercised
|
||||
exercised = np.asarray(exercised) if exercised is not None else None
|
||||
m_vector = allocate_m(cfg.dynamics.grounding.m, cfg.truth.R,
|
||||
cfg.dynamics.grounding.policy)
|
||||
cfg.dynamics.grounding.policy, exercised)
|
||||
step_ctx = StepCtx(
|
||||
n=cfg.dynamics.n,
|
||||
m_vector=m_vector,
|
||||
|
|
@ -83,12 +88,29 @@ def run_lineage(cfg: Mapping[str, Any] | LineageCfg, seed: int) -> pd.DataFrame:
|
|||
"head_support": int(np.sum(p[head_mask] > eps)),
|
||||
"tail_frac_alive": (float(np.mean(p[tail_mask] > eps)) if n_tail else 0.0),
|
||||
"head_frac_alive": (float(np.mean(p[head_mask] > eps)) if n_head else 0.0),
|
||||
# Truth-mass-weighted tail coverage: share of the tail's TRUE mass carried by
|
||||
# still-alive items. Bounded, monotone in grounding, and the honest "mass
|
||||
# rescued" companion to tail_frac_alive (raw tail_mass is a drift martingale).
|
||||
"tail_truth_mass_alive": (
|
||||
float(p_star_orig[tail_mask][p[tail_mask] > eps].sum()
|
||||
/ p_star_orig[tail_mask].sum()) if n_tail else 0.0),
|
||||
}
|
||||
if n_tail >= N_BANDS: # per-rarity-band survival (band 0 = rarest); see metrics
|
||||
fa, _ = tail_band_metrics(p, p_star_orig, tail_mask, n_bands=N_BANDS,
|
||||
alive_eps=eps)
|
||||
for b in range(N_BANDS):
|
||||
row[f"band{b}_alive"] = fa[b]
|
||||
if cfg.truth.R > 1: # per-region columns only when there is >1 region
|
||||
for r, v in per_region(heterozygosity, p, regions).items():
|
||||
row[f"H_region_{r}"] = v
|
||||
for r, v in per_region(tail_mass, p, regions, tail_mask).items():
|
||||
row[f"tail_region_{r}"] = v
|
||||
# per-region tail-item survival (E3's honest metric: how much of each
|
||||
# region's rare tail is kept alive, not just its martingale mass)
|
||||
for r in range(cfg.truth.R):
|
||||
region_tail = (regions == r) & tail_mask
|
||||
row[f"tailalive_region_{r}"] = (
|
||||
float(np.mean(p[region_tail] > eps)) if region_tail.any() else 0.0)
|
||||
rows.append(row)
|
||||
|
||||
record(0, p)
|
||||
|
|
|
|||
|
|
@ -79,6 +79,42 @@ def support_size(p: np.ndarray, eps: float) -> int:
|
|||
return int(np.sum(p > eps))
|
||||
|
||||
|
||||
def tail_band_metrics(p: np.ndarray, p_star: np.ndarray, tail_mask: np.ndarray,
|
||||
n_bands: int = 4, alive_eps: float = 1e-9):
|
||||
"""Stratify the tail into ``n_bands`` equal-count rarity bands (band 0 = rarest).
|
||||
|
||||
Makes the per-item survival threshold ``m·p*_i ≳ 1`` (blueprint prediction 4) visible
|
||||
band-wise: deeper (rarer) bands sit strictly below shallower ones and the gap narrows
|
||||
as grounding rises. Both returned arrays are bounded in [0, 1]; single-run values are
|
||||
noisy, so average over replicates before plotting.
|
||||
|
||||
Args:
|
||||
p (np.ndarray): Current distribution.
|
||||
p_star (np.ndarray): True distribution.
|
||||
tail_mask (np.ndarray): Boolean tail mask.
|
||||
n_bands (int): Number of equal-count rarity bands.
|
||||
alive_eps (float): An item is "alive" if ``p_i > alive_eps``.
|
||||
|
||||
Returns:
|
||||
tuple[np.ndarray, np.ndarray]: ``frac_alive[b]`` (fraction of band-b items alive)
|
||||
and ``truth_mass_alive[b]`` (share of band-b's TRUE mass carried by alive items),
|
||||
each length ``n_bands``.
|
||||
"""
|
||||
p = np.asarray(p, dtype=float)
|
||||
p_star = np.asarray(p_star, dtype=float)
|
||||
idx = np.where(np.asarray(tail_mask, dtype=bool))[0]
|
||||
order = idx[np.argsort(p_star[idx])] # rarest first
|
||||
bands = np.array_split(order, n_bands)
|
||||
frac_alive = np.empty(n_bands)
|
||||
truth_mass_alive = np.empty(n_bands)
|
||||
for b, items in enumerate(bands):
|
||||
alive = p[items] > alive_eps
|
||||
frac_alive[b] = alive.mean()
|
||||
ps = p_star[items]
|
||||
truth_mass_alive[b] = ps[alive].sum() / ps.sum() if ps.sum() > 0 else np.nan
|
||||
return frac_alive, truth_mass_alive
|
||||
|
||||
|
||||
def per_region(func, p: np.ndarray, regions: np.ndarray, *args) -> dict[int, float]:
|
||||
"""Apply a metric independently to each region's sub-vector.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue