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
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue