Layer 1.5: architecture-general neural existence proof
Re-scopes Layer 2 into a cheaper, architecture-general neural collapse proof
before the LLM rung. Realises the same Wright–Fisher abstractions in real trained
generative models on a fully-synthetic sandbox with an exact oracle, reusing
knowledge.metrics/truth/seeding and the output contract so neural curves overlay
the Layer-1 analytic curves.
- src/neural/: synthetic token-grammar sandbox (lossless identity + stochastic
style), ExactOracle, HistogramModel bridge, generation loop, experiment runner
- HARD GATE passed: histogram lineage reproduces Layer 1 exactly (neutral decay,
exact H_eq, tracks run_lineage) — tests/test_neural_validation.py
- torch models: autoregressive RNN + MLP (VAE implemented, not yet fidelity-
passing); determinism seeding derived from the SeedSequence stream
- N0 bridge (neural g*=0.047 ≈ Layer-1 0.048), N1 collapse-in-weights, N2 phase
boundary, N5 architecture-generality (collapse + grounding-rescue in histogram
+ RNN + MLP). Manifests/configs committed; parquet gitignored, hashes tracked
- additive backward-compatible save_artifacts extension; Makefile neural targets
Finding: neural smoothing partially resists H-collapse, so forward-KL and tail
survival are the sharp neural collapse metrics (H is smooth, per Layer 1).
92 tests green. Remaining (tasks/todo.md): N4 merge, N2 refine, N3/N6, VAE
fidelity, MNIST tier, figures. LLM/LoRA rung and C3 deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1721d047fa
commit
840b6b00b3
35 changed files with 3679 additions and 23 deletions
|
|
@ -211,11 +211,28 @@ def _content_hash(path: Path) -> str:
|
|||
return h.hexdigest()
|
||||
|
||||
|
||||
def save_artifacts(cfg: dict, df: pd.DataFrame, out_dir: Path) -> None:
|
||||
def save_artifacts(cfg: dict, df: pd.DataFrame, out_dir: Path,
|
||||
extra_libs: tuple[str, ...] = (),
|
||||
extra_manifest: dict | None = None,
|
||||
grid: list | None = None) -> None:
|
||||
"""Write the reproducibility output contract (blueprint 2.7 / 4).
|
||||
|
||||
Writes ``results.parquet``, ``resolved_config.yaml`` (the fully-expanded config), and
|
||||
``manifest.json`` (library versions, master seed, git commit, content hash).
|
||||
|
||||
Args:
|
||||
cfg (dict): The parsed experiment config.
|
||||
df (pd.DataFrame): The long-form results.
|
||||
out_dir (Path): Output directory.
|
||||
extra_libs (tuple[str, ...]): Extra library names to record versions for (e.g.
|
||||
``torch``, ``torchvision`` for Layer 1.5). Missing libraries are skipped, so a
|
||||
caller can pass optional deps unconditionally.
|
||||
extra_manifest (dict | None): Extra key/value pairs to merge into the manifest
|
||||
(e.g. model architecture, oracle checkpoint hash, determinism flags).
|
||||
grid (list | None): Pre-expanded ``[{label, lineage_cfg}, ...]`` to record in the
|
||||
resolved config. If None, it is computed via ``expand_sweeps`` for the Layer-1
|
||||
``lineage`` kind (a caller with a different schema, e.g. Layer 1.5, passes its
|
||||
own expanded grid here).
|
||||
"""
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
results_path = out_dir / "results.parquet"
|
||||
|
|
@ -227,24 +244,32 @@ def save_artifacts(cfg: dict, df: pd.DataFrame, out_dir: Path) -> None:
|
|||
"n_replicates": cfg["n_replicates"],
|
||||
"source_config": cfg,
|
||||
}
|
||||
if cfg.get("kind", "lineage") == "lineage":
|
||||
if grid is not None:
|
||||
resolved["grid"] = grid
|
||||
elif 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))
|
||||
|
||||
libraries: dict[str, str] = {}
|
||||
for lib in ("numpy", "scipy", "pandas", "pyarrow") + tuple(extra_libs):
|
||||
try:
|
||||
libraries[lib] = version(lib)
|
||||
except Exception: # optional dep not installed -> omit rather than crash
|
||||
pass
|
||||
manifest = {
|
||||
"experiment": cfg["experiment"],
|
||||
"master_seed": cfg["seed"],
|
||||
"git_commit": _git_commit(),
|
||||
"python": sys.version.split()[0],
|
||||
"libraries": {
|
||||
lib: version(lib) for lib in ("numpy", "scipy", "pandas", "pyarrow")
|
||||
},
|
||||
"libraries": libraries,
|
||||
"rows": int(len(df)),
|
||||
"results_sha256": _content_hash(results_path),
|
||||
}
|
||||
if extra_manifest:
|
||||
manifest.update(extra_manifest)
|
||||
(out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
|
||||
|
||||
|
||||
|
|
|
|||
13
src/neural/__init__.py
Normal file
13
src/neural/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Layer 1.5 — the architecture-general neural existence proof.
|
||||
|
||||
Realises the Layer-1 (``knowledge``) Wright-Fisher abstractions in *real trained
|
||||
generative models* on a fully-synthetic sandbox whose ground-truth ``p*`` is known
|
||||
exactly. A model's knowledge is measured as its output distribution over ``K`` discrete
|
||||
*modes* (via an oracle), so the same metrics (``knowledge.metrics``), the same closed
|
||||
forms, and the same experiments carry over — a neural collapse curve can be overlaid on
|
||||
a Layer-1 analytic curve.
|
||||
|
||||
The package is staged by cost: the histogram model (pure NumPy) reduces this layer
|
||||
*exactly* to Layer 1 and is the validation bridge; the RNN/VAE/MLP models (torch, added
|
||||
from Stage C) show that collapse is architecture-general.
|
||||
"""
|
||||
124
src/neural/config.py
Normal file
124
src/neural/config.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Resolved run configuration for a neural (Layer 1.5) lineage.
|
||||
|
||||
Mirrors the ``knowledge.config`` idiom exactly: frozen dataclasses with a ``from_dict``
|
||||
that fills defaults and rejects unknown keys via ``knowledge.config._sub``. The grounding,
|
||||
re-mint, and metrics blocks are *reused verbatim* from ``knowledge.config`` so the neural
|
||||
runner speaks the same schema as Layer 1 (grounding ``m``, the ``g -> m`` conversion, the
|
||||
re-mint gate, and the KL/support floors are all identical). Only the data source
|
||||
(``synthetic``) and the model (``model``) are neural-specific.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, Mapping
|
||||
|
||||
from knowledge.config import GroundingCfg, MetricsCfg, RemintCfg, _sub
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SyntheticCfg:
|
||||
"""The fully-synthetic mode-truth and observation grammar.
|
||||
|
||||
The first seven fields are the Layer-1 ``TruthCfg`` knobs (they build ``p*`` over the
|
||||
``K`` modes via ``knowledge.truth.make_true_distribution``). The remaining fields
|
||||
define how a mode is rendered to a categorical token sequence: an *identity* segment
|
||||
that encodes the mode losslessly (read by the exact oracle) followed by a *style*
|
||||
segment of within-mode stochastic tokens (so a real generative model has a
|
||||
distribution to learn, not just a lookup table).
|
||||
"""
|
||||
|
||||
K: int
|
||||
R: int = 1
|
||||
tail: str = "zipf"
|
||||
zipf_s: float = 1.1
|
||||
tail_frac: float = 0.5
|
||||
tail_threshold: float = 1e-3
|
||||
init: str = "uniform" # initial p_0 over modes: {uniform, truth}
|
||||
style_len: int = 4 # style-segment length (within-mode entropy)
|
||||
style_vocab: int = 6 # style token alphabet size
|
||||
id_base: int = 2 # identity segment encodes the mode in this base
|
||||
|
||||
@property
|
||||
def id_len(self) -> int:
|
||||
"""Identity-segment length: fewest base-``id_base`` digits to index ``K`` modes."""
|
||||
if self.K <= 1:
|
||||
return 1
|
||||
return max(1, math.ceil(math.log(self.K, self.id_base)))
|
||||
|
||||
@property
|
||||
def vocab(self) -> int:
|
||||
"""Token alphabet size (shared by identity and style segments)."""
|
||||
return max(self.id_base, self.style_vocab)
|
||||
|
||||
@property
|
||||
def seq_len(self) -> int:
|
||||
"""Total observation length in tokens."""
|
||||
return self.id_len + self.style_len
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelCfg:
|
||||
"""The generative learner. ``kind`` selects the architecture behind a thin adapter.
|
||||
|
||||
Neural hyperparameters are ignored by the ``histogram`` bridge model.
|
||||
"""
|
||||
|
||||
kind: str = "histogram" # {histogram, rnn, vae, mlp}
|
||||
hidden: int = 64
|
||||
embed: int = 16
|
||||
epochs: int = 30
|
||||
lr: float = 1.0e-3
|
||||
batch_size: int = 256
|
||||
device: str = "auto" # {auto, cpu, cuda}
|
||||
latent: int = 16 # VAE latent dimension (VAE only)
|
||||
beta: float = 1.0 # VAE KL weight (VAE only)
|
||||
# Samples used to estimate a neural model's mode distribution by generate-and-classify
|
||||
# (ignored by the exact histogram bridge). Larger -> less measurement noise on p_hat.
|
||||
n_eval: int = 8000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NeuralDynamicsCfg:
|
||||
"""Generational dynamics: drift strength ``n`` + reused grounding/re-mint blocks."""
|
||||
|
||||
n: int = 4000 # pupil training-sample size (drift strength ~ 1/n)
|
||||
grounding: GroundingCfg = field(default_factory=GroundingCfg)
|
||||
remint: RemintCfg = field(default_factory=RemintCfg)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NeuralLineageCfg:
|
||||
"""A fully-resolved neural lineage configuration."""
|
||||
|
||||
synthetic: SyntheticCfg
|
||||
model: ModelCfg = field(default_factory=ModelCfg)
|
||||
dynamics: NeuralDynamicsCfg = field(default_factory=NeuralDynamicsCfg)
|
||||
generations: int = 30
|
||||
metrics: MetricsCfg = field(default_factory=MetricsCfg)
|
||||
|
||||
@staticmethod
|
||||
def from_dict(cfg: Mapping[str, Any]) -> "NeuralLineageCfg":
|
||||
"""Build a validated NeuralLineageCfg from a nested mapping, filling defaults."""
|
||||
if isinstance(cfg, NeuralLineageCfg):
|
||||
return cfg
|
||||
synthetic = _sub(cfg.get("synthetic", {}), SyntheticCfg)
|
||||
model = _sub(cfg.get("model", {}), ModelCfg)
|
||||
dyn_raw = dict(cfg.get("dynamics", {}))
|
||||
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)
|
||||
return NeuralLineageCfg(
|
||||
synthetic=synthetic,
|
||||
model=model,
|
||||
dynamics=dynamics,
|
||||
generations=int(cfg.get("generations", NeuralLineageCfg.generations)),
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
def replace(self, **kw) -> "NeuralLineageCfg":
|
||||
return replace(self, **kw)
|
||||
77
src/neural/evaluate.py
Normal file
77
src/neural/evaluate.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Metrics for a neural lineage — the *same* row schema as ``knowledge.lineage``.
|
||||
|
||||
``measure_metrics`` takes a model's oracle-measured mode distribution ``p_hat`` and emits a
|
||||
row with exactly the columns Layer 1 logs per generation (``knowledge.lineage.record``),
|
||||
computed with the *same* ``knowledge.metrics`` functions. Identical columns are what let a
|
||||
neural collapse curve be plotted on top of an analytic one, and let the same figure and
|
||||
analysis code (``knowledge.analysis``) run unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from knowledge.config import MetricsCfg
|
||||
from knowledge.lineage import N_BANDS
|
||||
from knowledge.metrics import (
|
||||
forward_kl,
|
||||
heterozygosity,
|
||||
per_region,
|
||||
support_size,
|
||||
tail_band_metrics,
|
||||
tail_mass,
|
||||
)
|
||||
|
||||
|
||||
def measure_metrics(p: np.ndarray, p_star_orig: np.ndarray, tail_mask: np.ndarray,
|
||||
regions: np.ndarray, R: int, metrics_cfg: MetricsCfg) -> dict:
|
||||
"""Compute every per-generation metric for a measured mode distribution.
|
||||
|
||||
Mirrors ``knowledge.lineage.record`` field-for-field. ``forward_kl`` and the tail set
|
||||
are always measured against the *original* truth, so a re-minted lineage that has lost
|
||||
tails is penalised exactly as in Layer 1's E6.
|
||||
|
||||
Args:
|
||||
p (np.ndarray): The model's measured mode distribution ``p_hat`` (length ``K``).
|
||||
p_star_orig (np.ndarray): The original true distribution over modes.
|
||||
tail_mask (np.ndarray): Boolean tail mask on the original truth.
|
||||
regions (np.ndarray): Length-``K`` region index per mode.
|
||||
R (int): Number of regions.
|
||||
metrics_cfg (MetricsCfg): KL floor and support epsilon.
|
||||
|
||||
Returns:
|
||||
dict: One row of metrics (no ``generation``/label columns; the runner adds those).
|
||||
"""
|
||||
eps = metrics_cfg.support_eps
|
||||
kl_floor = metrics_cfg.kl_floor
|
||||
head_mask = ~tail_mask
|
||||
n_tail = int(tail_mask.sum())
|
||||
n_head = int(head_mask.sum())
|
||||
|
||||
row = {
|
||||
"heterozygosity": heterozygosity(p),
|
||||
"forward_kl": forward_kl(p_star_orig, p, kl_floor),
|
||||
"tail_mass": tail_mass(p, tail_mask),
|
||||
"support_size": support_size(p, eps),
|
||||
"tail_support": int(np.sum(p[tail_mask] > eps)),
|
||||
"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),
|
||||
"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:
|
||||
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 R > 1:
|
||||
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
|
||||
for r in range(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)
|
||||
return row
|
||||
124
src/neural/experiment.py
Normal file
124
src/neural/experiment.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Neural (Layer 1.5) experiment runner: sweep a grid x replicates, write artifacts.
|
||||
|
||||
Mirrors ``knowledge.experiment`` and reuses its sweep-expansion primitives
|
||||
(``_apply_param`` — including the ``g -> m`` conversion — and ``_set_by_path``), its
|
||||
provenance helpers, and its output contract (``save_artifacts``). Only the per-run call and
|
||||
the config key set differ: a neural run trains generative models rather than resampling a
|
||||
frequency vector, and its config groups are ``synthetic``/``model``/``dynamics``/... .
|
||||
|
||||
CLI: python -m neural.experiment configs/neural/N0.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import itertools
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
import yaml
|
||||
|
||||
from knowledge.experiment import _apply_param, save_artifacts
|
||||
from knowledge.seeding import spawn_seeds
|
||||
|
||||
from .generation_loop import run_generative_lineage
|
||||
|
||||
# Config groups that make up a single neural lineage (everything else is experiment-level).
|
||||
_NEURAL_KEYS = ("synthetic", "model", "dynamics", "generations", "metrics", "n_eval")
|
||||
|
||||
# Libraries recorded in the manifest on top of the Layer-1 core set (skipped if absent).
|
||||
_EXTRA_LIBS = ("torch", "torchvision")
|
||||
|
||||
|
||||
def expand_sweeps(cfg: dict) -> list[tuple[dict, dict]]:
|
||||
"""Expand the sweep grid into (label, resolved_neural_cfg) pairs.
|
||||
|
||||
Identical semantics to ``knowledge.experiment.expand_sweeps`` (Cartesian product of the
|
||||
declared ``{param, values}`` entries, reusing ``_apply_param`` for the ``g -> m`` and
|
||||
``arm`` special cases) but assembling the base from the neural config groups.
|
||||
|
||||
Returns:
|
||||
list[tuple[dict, dict]]: One (label-columns, neural-config) pair per grid point.
|
||||
"""
|
||||
base = {k: copy.deepcopy(cfg[k]) for k in _NEURAL_KEYS if k in cfg}
|
||||
sweeps = cfg.get("sweep", [])
|
||||
if isinstance(sweeps, dict):
|
||||
sweeps = [sweeps]
|
||||
if not sweeps:
|
||||
return [({}, base)]
|
||||
params = [s["param"] for s in sweeps]
|
||||
value_lists = [list(s["values"]) for s in sweeps]
|
||||
combos: list[tuple[dict, dict]] = []
|
||||
for values in itertools.product(*value_lists):
|
||||
lin = copy.deepcopy(base)
|
||||
label: dict = {}
|
||||
for param, val in zip(params, values):
|
||||
label.update(_apply_param(lin, param, val))
|
||||
combos.append((label, lin))
|
||||
return combos
|
||||
|
||||
|
||||
def run_experiment(cfg: dict) -> pd.DataFrame:
|
||||
"""Run every grid point x every replicate; return long-form results.
|
||||
|
||||
Replicate seeds are derived once from the master seed and reused across grid points, so
|
||||
comparisons across sweep values are paired (shared drift noise) — as in Layer 1.
|
||||
|
||||
Args:
|
||||
cfg (dict): Parsed experiment YAML.
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: One row per (combo, replicate, generation).
|
||||
"""
|
||||
name = cfg["experiment"]
|
||||
master = int(cfg["seed"])
|
||||
n_rep = int(cfg["n_replicates"])
|
||||
combos = expand_sweeps(cfg)
|
||||
seeds = spawn_seeds(master, n_rep)
|
||||
|
||||
frames: list[pd.DataFrame] = []
|
||||
for label, neural_cfg in combos:
|
||||
for rep, ss in enumerate(seeds):
|
||||
df = run_generative_lineage(neural_cfg, int(ss.generate_state(1)[0]))
|
||||
for col, val in label.items():
|
||||
df[col] = val
|
||||
df["replicate"] = rep
|
||||
frames.append(df)
|
||||
out = pd.concat(frames, ignore_index=True)
|
||||
out.insert(0, "experiment", name)
|
||||
return out
|
||||
|
||||
|
||||
def run_and_save(config_path: str | Path) -> Path:
|
||||
"""Load a neural experiment YAML, run it, and write artifacts. Returns the output dir."""
|
||||
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']}"))
|
||||
kind = cfg.get("kind", "gen_lineage")
|
||||
if kind == "recombination":
|
||||
from .recombine import run_recombination # Stage C (N4); imported lazily
|
||||
df = run_recombination(cfg)
|
||||
grid = None
|
||||
elif kind == "gen_lineage":
|
||||
df = run_experiment(cfg)
|
||||
grid = [{"label": label, "neural_cfg": c} for label, c in expand_sweeps(cfg)]
|
||||
else:
|
||||
raise ValueError(f"unknown neural experiment kind {kind!r}")
|
||||
model_kind = cfg.get("model", {}).get("kind", "histogram")
|
||||
save_artifacts(cfg, df, out_dir, extra_libs=_EXTRA_LIBS,
|
||||
extra_manifest={"layer": "1.5", "model_kind": model_kind}, grid=grid)
|
||||
return out_dir
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = argparse.ArgumentParser(description="Run a Layer-1.5 neural experiment from a YAML config.")
|
||||
parser.add_argument("config", help="Path to configs/neural/NX.yaml")
|
||||
args = parser.parse_args(argv)
|
||||
out_dir = run_and_save(args.config)
|
||||
print(f"wrote artifacts to {out_dir}/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
113
src/neural/generation_loop.py
Normal file
113
src/neural/generation_loop.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""The neural analogue of ``knowledge.lineage.run_lineage``.
|
||||
|
||||
Runs ``T`` generations of *train-a-model-on-the-previous-model's-samples*, the neural
|
||||
image of the Wright-Fisher generational step. Each generation the pupil is trained on a
|
||||
pool of (i) ``n`` observations drawn from the parent model (drift) and (ii) ``m`` fresh
|
||||
observations drawn from the grounding reference (immigration, ``g = m/(n+m)``), then its
|
||||
oracle-measured mode distribution is logged with the *same* metric schema Layer 1 uses.
|
||||
Grounding structure (proportional / uniform / matched over regions) and the re-mint gate
|
||||
reuse ``knowledge.step`` and mirror ``run_lineage`` exactly, so a histogram-model lineage
|
||||
reproduces the analytic core and a neural-model lineage tests whether the same signs hold
|
||||
in real weights.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from knowledge.metrics import heterozygosity
|
||||
from knowledge.step import allocate_m, structured_multinomial
|
||||
from knowledge.truth import uniform_init
|
||||
|
||||
from .config import NeuralLineageCfg
|
||||
from .evaluate import measure_metrics
|
||||
from .models import make_model
|
||||
from .oracle import ExactOracle
|
||||
from .synthetic import id_codewords, make_mode_truth, render_modes
|
||||
|
||||
|
||||
def _counts_to_observations(counts: np.ndarray, cfg, rng, codewords) -> np.ndarray:
|
||||
"""Expand a per-mode count vector into rendered token sequences."""
|
||||
modes = np.repeat(np.arange(counts.size), counts)
|
||||
return render_modes(modes, cfg, rng, codewords)
|
||||
|
||||
|
||||
def run_generative_lineage(cfg: Mapping[str, Any] | NeuralLineageCfg,
|
||||
seed: int) -> pd.DataFrame:
|
||||
"""Run one neural lineage and return per-generation metrics.
|
||||
|
||||
Args:
|
||||
cfg (Mapping | NeuralLineageCfg): Resolved neural-lineage configuration.
|
||||
seed (int): Seed for this replicate; the run is a pure function of (cfg, seed) for
|
||||
the histogram model (statistically reproducible for torch models).
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: One row per generation 0..T with the same metric columns as
|
||||
``knowledge.lineage.run_lineage``.
|
||||
"""
|
||||
cfg = NeuralLineageCfg.from_dict(cfg)
|
||||
syn = cfg.synthetic
|
||||
td = make_mode_truth(syn)
|
||||
p_star_orig = td.p_star # forward_kl is always vs the original truth
|
||||
regions = td.regions
|
||||
tail_mask = td.tail_mask
|
||||
R = syn.R
|
||||
|
||||
rng = np.random.default_rng(seed)
|
||||
oracle = ExactOracle(syn)
|
||||
codewords = id_codewords(syn)
|
||||
|
||||
# Initial distribution over modes (exact, like Layer 1).
|
||||
if syn.init == "uniform":
|
||||
p0 = uniform_init(syn.K)
|
||||
elif syn.init == "truth":
|
||||
p0 = p_star_orig.copy()
|
||||
else:
|
||||
raise ValueError(f"unknown init {syn.init!r} (expected uniform|truth)")
|
||||
|
||||
# Grounding wiring (reused verbatim from Layer 1).
|
||||
grounding = cfg.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)
|
||||
p_star_eff = p_star_orig.copy() # grounding reference; may be re-minted (N6)
|
||||
remint = cfg.dynamics.remint
|
||||
n = cfg.dynamics.n
|
||||
|
||||
model = make_model(cfg.model, syn, oracle)
|
||||
model.initialise(p0, rng)
|
||||
|
||||
rows: list[dict] = []
|
||||
|
||||
def record(t: int, p: np.ndarray) -> None:
|
||||
row = {"generation": t}
|
||||
row.update(measure_metrics(p, p_star_orig, tail_mask, regions, R, cfg.metrics))
|
||||
rows.append(row)
|
||||
|
||||
record(0, model.mode_distribution(rng))
|
||||
|
||||
for t in range(1, cfg.generations + 1):
|
||||
X_syn = model.sample(n, rng) # drift: n from the parent
|
||||
if m_vector is not None: # immigration: m grounded samples
|
||||
counts_real = structured_multinomial(
|
||||
m_vector, p_star_eff, regions, grounding.policy, rng)
|
||||
X_real = _counts_to_observations(counts_real, syn, rng, codewords)
|
||||
pool = np.concatenate([X_syn, X_real], axis=0)
|
||||
else:
|
||||
pool = X_syn
|
||||
|
||||
pupil = make_model(cfg.model, syn, oracle)
|
||||
pupil.fit(pool, rng)
|
||||
model = pupil
|
||||
p = model.mode_distribution(rng)
|
||||
|
||||
if remint.enabled and remint.period and t % remint.period == 0:
|
||||
# Founder event: current distribution becomes the new grounding reference and
|
||||
# the original truth is discarded for grounding. Gated on diversity (N6).
|
||||
if remint.H_gate is None or heterozygosity(p) >= remint.H_gate:
|
||||
p_star_eff = p.copy()
|
||||
record(t, p)
|
||||
|
||||
return pd.DataFrame(rows)
|
||||
119
src/neural/models.py
Normal file
119
src/neural/models.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""Generative models behind a thin adapter, so architecture is a config switch.
|
||||
|
||||
Every model implements the same three-method protocol: ``fit`` on a batch of token
|
||||
sequences, ``sample`` fresh token sequences, and report its ``mode_distribution`` (the
|
||||
model's ``p_t``). Keeping the interface identical is what makes "collapse is
|
||||
architecture-general" (experiment N5) a single sweep over ``model.kind``.
|
||||
|
||||
``HistogramModel`` is the bridge: its ``fit`` is a maximum-likelihood mode histogram and
|
||||
its ``sample`` is a multinomial draw, so a lineage of histogram models is *exactly*
|
||||
neutral Wright-Fisher drift with immigration — the analytic core in disguise. The
|
||||
torch-backed RNN/VAE/MLP models are added in Stage C and reuse this same protocol.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .config import ModelCfg, SyntheticCfg
|
||||
from .oracle import Oracle, measure_distribution
|
||||
from .synthetic import id_codewords, render_modes
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class GenerativeModel(Protocol):
|
||||
"""A learner of ``p(x)`` over the synthetic observation space."""
|
||||
|
||||
def initialise(self, p0: np.ndarray, rng: np.random.Generator) -> None:
|
||||
"""Initialise generation 0 to represent the mode distribution ``p0``.
|
||||
|
||||
The histogram bridge sets ``p0`` exactly (matching Layer 1's exact ``p_0`` start);
|
||||
a neural model trains on a sample drawn from ``p0`` (its gen-0 fidelity is checked
|
||||
by the Stage-C fidelity gate).
|
||||
"""
|
||||
...
|
||||
|
||||
def fit(self, X: np.ndarray, rng: np.random.Generator) -> None:
|
||||
"""Train (from scratch) on a batch of token sequences ``X``."""
|
||||
...
|
||||
|
||||
def sample(self, n: int, rng: np.random.Generator) -> np.ndarray:
|
||||
"""Draw ``n`` fresh token sequences of shape ``(n, seq_len)``."""
|
||||
...
|
||||
|
||||
def mode_distribution(self, rng: np.random.Generator) -> np.ndarray:
|
||||
"""Return the model's length-``K`` distribution over modes (its ``p_t``)."""
|
||||
...
|
||||
|
||||
|
||||
class HistogramModel:
|
||||
"""MLE mode-histogram generator — reduces Layer 1.5 exactly to Layer 1.
|
||||
|
||||
``fit`` counts oracle-labelled modes in the training pool and stores the empirical
|
||||
distribution; ``sample`` draws modes multinomially and renders them; the stored
|
||||
distribution *is* the model's ``p_t`` (read exactly, no eval-sampling noise). Composed
|
||||
over generations this is neutral Wright-Fisher drift with immigration.
|
||||
|
||||
Args:
|
||||
cfg (SyntheticCfg): The synthetic grammar (for ``K`` and rendering).
|
||||
oracle (Oracle): The mode adjudicator used to label the training pool.
|
||||
model_cfg (ModelCfg): Present for interface symmetry; unused by the histogram.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: SyntheticCfg, oracle: Oracle,
|
||||
model_cfg: ModelCfg | None = None) -> None:
|
||||
self.cfg = cfg
|
||||
self.oracle = oracle
|
||||
self._codewords = id_codewords(cfg)
|
||||
self._p: np.ndarray | None = None
|
||||
|
||||
def initialise(self, p0: np.ndarray, rng: np.random.Generator) -> None:
|
||||
"""Set the stored distribution to ``p0`` exactly (no gen-0 sampling noise)."""
|
||||
p0 = np.asarray(p0, dtype=float)
|
||||
self._p = p0 / p0.sum()
|
||||
|
||||
def fit(self, X: np.ndarray, rng: np.random.Generator) -> None:
|
||||
"""Store the empirical mode distribution of the (oracle-labelled) pool ``X``."""
|
||||
self._p = measure_distribution(X, self.oracle, self.cfg.K)
|
||||
|
||||
def sample(self, n: int, rng: np.random.Generator) -> np.ndarray:
|
||||
"""Draw ``n`` observations whose modes follow the stored distribution."""
|
||||
if self._p is None:
|
||||
raise RuntimeError("HistogramModel.sample called before fit")
|
||||
counts = rng.multinomial(n, self._p)
|
||||
modes = np.repeat(np.arange(self.cfg.K), counts)
|
||||
return render_modes(modes, self.cfg, rng, self._codewords)
|
||||
|
||||
def mode_distribution(self, rng: np.random.Generator) -> np.ndarray:
|
||||
"""Return the stored mode distribution (exact; no eval sampling)."""
|
||||
if self._p is None:
|
||||
raise RuntimeError("HistogramModel.mode_distribution called before fit")
|
||||
return self._p.copy()
|
||||
|
||||
|
||||
def make_model(model_cfg: ModelCfg, cfg: SyntheticCfg, oracle: Oracle) -> GenerativeModel:
|
||||
"""Construct a generative model of the requested ``kind``.
|
||||
|
||||
Args:
|
||||
model_cfg (ModelCfg): Selects the architecture and its hyperparameters.
|
||||
cfg (SyntheticCfg): The synthetic grammar.
|
||||
oracle (Oracle): The mode adjudicator (needed by the histogram bridge; the neural
|
||||
models estimate their mode distribution by generate-and-classify).
|
||||
|
||||
Returns:
|
||||
GenerativeModel: A fresh, untrained model.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``kind`` is unknown.
|
||||
"""
|
||||
kind = model_cfg.kind
|
||||
if kind == "histogram":
|
||||
return HistogramModel(cfg, oracle, model_cfg)
|
||||
if kind in ("rnn", "vae", "mlp"):
|
||||
# Torch-backed models arrive in Stage C; imported lazily so Stages A-B need no GPU.
|
||||
from .torch_models import make_torch_model # noqa: PLC0415
|
||||
|
||||
return make_torch_model(model_cfg, cfg, oracle)
|
||||
raise ValueError(f"unknown model kind {kind!r} (expected histogram|rnn|vae|mlp)")
|
||||
82
src/neural/oracle.py
Normal file
82
src/neural/oracle.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""The oracle — the neural analogue of Layer 1's "reality's no".
|
||||
|
||||
An oracle maps an observation to the mode it belongs to. For the fully-synthetic sandbox
|
||||
the oracle is **exact** (it decodes the lossless identity segment), so the measured mode
|
||||
distribution ``p_hat`` carries zero measurement noise — this is what lets a trained model's
|
||||
collapse be read directly against the known ``p*``. (The MNIST tier will add a
|
||||
``ClassifierOracle`` wrapping a frozen network plus its confusion matrix; that arrives with
|
||||
Stage C and is confirmation-only.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .config import SyntheticCfg
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Oracle(Protocol):
|
||||
"""Adjudicates which mode an observation belongs to."""
|
||||
|
||||
def classify(self, X: np.ndarray) -> np.ndarray:
|
||||
"""Return the length-``n`` mode index for each row of ``X``."""
|
||||
...
|
||||
|
||||
|
||||
class ExactOracle:
|
||||
"""Zero-error oracle for the synthetic sandbox: decodes the identity segment.
|
||||
|
||||
Args:
|
||||
cfg (SyntheticCfg): The synthetic configuration whose grammar produced ``X``.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: SyntheticCfg) -> None:
|
||||
self.cfg = cfg
|
||||
self.id_len = cfg.id_len
|
||||
self.base = cfg.id_base
|
||||
self.K = cfg.K
|
||||
# Positional weights for base-`base` decoding, most-significant digit first.
|
||||
self._weights = self.base ** np.arange(self.id_len - 1, -1, -1, dtype=np.int64)
|
||||
|
||||
def classify(self, X: np.ndarray) -> np.ndarray:
|
||||
"""Decode mode indices from the identity segment of each observation.
|
||||
|
||||
Args:
|
||||
X (np.ndarray): Token sequences of shape ``(n, seq_len)``.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Length-``n`` decoded indices. Grammar-valid data always decodes into
|
||||
``[0, K)``; a *neural* model may emit an invalid codeword that decodes to
|
||||
``>= K`` — such samples are dropped by :func:`measure_distribution` rather than
|
||||
being clipped onto a real mode (which would bias ``p_hat``).
|
||||
"""
|
||||
X = np.asarray(X, dtype=np.int64)
|
||||
ident = X[:, : self.id_len]
|
||||
return ident @ self._weights
|
||||
|
||||
|
||||
def measure_distribution(X: np.ndarray, oracle: Oracle, K: int) -> np.ndarray:
|
||||
"""Measure the empirical mode distribution ``p_hat`` of a sample.
|
||||
|
||||
This is the neural readout of ``p_t``: classify every observation and normalise the
|
||||
mode histogram. Modes absent from ``X`` receive zero mass (support shrinks exactly as
|
||||
in Layer 1). Invalid codewords (decoded index ``>= K``, only producible by a neural
|
||||
model) are dropped, so ``p_hat`` is renormalised over grammar-valid samples.
|
||||
|
||||
Args:
|
||||
X (np.ndarray): Token sequences of shape ``(n, seq_len)``.
|
||||
oracle (Oracle): The mode adjudicator.
|
||||
K (int): Number of modes.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Length-``K`` probability vector summing to 1.
|
||||
"""
|
||||
modes = oracle.classify(X)
|
||||
counts = np.bincount(modes, minlength=K)[:K].astype(float)
|
||||
total = counts.sum()
|
||||
if total <= 0:
|
||||
raise ValueError("measure_distribution received an empty sample")
|
||||
return counts / total
|
||||
109
src/neural/synthetic.py
Normal file
109
src/neural/synthetic.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""The fully-synthetic sandbox: a known ``p*`` over modes + a lossless observation grammar.
|
||||
|
||||
The mode-truth (``p*``, regions, tail mask) comes straight from Layer 1's
|
||||
``knowledge.truth.make_true_distribution`` — so "mode", "region", and "tail" are *the same
|
||||
objects* as in the analytic core. Each mode is rendered to a categorical token sequence:
|
||||
|
||||
* an **identity** segment of ``id_len`` base-``id_base`` digits that encodes the mode
|
||||
index exactly (the exact oracle reads these back with zero error), and
|
||||
* a **style** segment of ``style_len`` tokens drawn uniformly at random, giving genuine
|
||||
within-mode entropy so a real generative model must learn a *distribution* ``p(x|mode)``
|
||||
rather than memorise ``K`` fixed strings.
|
||||
|
||||
Because the identity segment is lossless, the measured mode distribution ``p_hat`` is a
|
||||
noise-free readout of the model's output — the property that lets the histogram model
|
||||
reduce this layer exactly to Wright-Fisher drift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from knowledge.truth import TrueDist, make_true_distribution
|
||||
|
||||
from .config import SyntheticCfg
|
||||
|
||||
|
||||
def make_mode_truth(cfg: SyntheticCfg) -> TrueDist:
|
||||
"""Build the true distribution over modes (thin wrapper over Layer 1's truth).
|
||||
|
||||
Args:
|
||||
cfg (SyntheticCfg): The synthetic configuration (its first seven fields are the
|
||||
Layer-1 ``TruthCfg`` knobs).
|
||||
|
||||
Returns:
|
||||
TrueDist: ``p_star`` (length ``K``), ``regions``, and ``tail_mask`` over modes.
|
||||
"""
|
||||
return make_true_distribution(
|
||||
cfg.K, cfg.R, cfg.tail, cfg.tail_frac, cfg.zipf_s, 0,
|
||||
tail_threshold=cfg.tail_threshold,
|
||||
)
|
||||
|
||||
|
||||
def id_codewords(cfg: SyntheticCfg) -> np.ndarray:
|
||||
"""Return the ``(K, id_len)`` matrix of base-``id_base`` identity codewords.
|
||||
|
||||
Codeword of mode ``k`` is ``k`` written in base ``id_base``, most-significant digit
|
||||
first, zero-padded to ``id_len``. Deterministic and invertible.
|
||||
|
||||
Args:
|
||||
cfg (SyntheticCfg): The synthetic configuration.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Integer array of shape ``(K, id_len)`` with tokens in
|
||||
``[0, id_base)``.
|
||||
"""
|
||||
k = np.arange(cfg.K, dtype=np.int64)
|
||||
id_len, base = cfg.id_len, cfg.id_base
|
||||
digits = np.empty((cfg.K, id_len), dtype=np.int64)
|
||||
for pos in range(id_len - 1, -1, -1): # least-significant digit last
|
||||
digits[:, pos] = k % base
|
||||
k //= base
|
||||
return digits
|
||||
|
||||
|
||||
def render_modes(modes: np.ndarray, cfg: SyntheticCfg, rng: np.random.Generator,
|
||||
codewords: np.ndarray | None = None) -> np.ndarray:
|
||||
"""Render an array of mode indices to token sequences.
|
||||
|
||||
Args:
|
||||
modes (np.ndarray): Length-``n`` integer array of mode indices in ``[0, K)``.
|
||||
cfg (SyntheticCfg): The synthetic configuration.
|
||||
rng (np.random.Generator): Random source for the style segment.
|
||||
codewords (np.ndarray | None): Optional precomputed identity codewords.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Integer array of shape ``(n, seq_len)`` — identity segment followed by
|
||||
a freshly-sampled style segment.
|
||||
"""
|
||||
modes = np.asarray(modes, dtype=np.int64)
|
||||
if codewords is None:
|
||||
codewords = id_codewords(cfg)
|
||||
ident = codewords[modes] # (n, id_len)
|
||||
style = rng.integers(0, cfg.style_vocab, size=(modes.shape[0], cfg.style_len))
|
||||
return np.concatenate([ident, style], axis=1)
|
||||
|
||||
|
||||
def sample_synthetic(p_over_modes: np.ndarray, n: int, cfg: SyntheticCfg,
|
||||
rng: np.random.Generator,
|
||||
codewords: np.ndarray | None = None) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Draw ``n`` observations whose modes follow ``p_over_modes``.
|
||||
|
||||
This is the *grounding* generator (draw from a fixed distribution and render) and the
|
||||
reference sampler used to seed generation 0.
|
||||
|
||||
Args:
|
||||
p_over_modes (np.ndarray): Distribution over the ``K`` modes to sample from.
|
||||
n (int): Number of observations.
|
||||
cfg (SyntheticCfg): The synthetic configuration.
|
||||
rng (np.random.Generator): Random source.
|
||||
codewords (np.ndarray | None): Optional precomputed identity codewords.
|
||||
|
||||
Returns:
|
||||
tuple[np.ndarray, np.ndarray]: ``(X, modes)`` — token sequences of shape
|
||||
``(n, seq_len)`` and the length-``n`` true mode indices.
|
||||
"""
|
||||
p = np.asarray(p_over_modes, dtype=float)
|
||||
modes = rng.choice(cfg.K, size=n, p=p / p.sum())
|
||||
X = render_modes(modes, cfg, rng, codewords)
|
||||
return X, modes
|
||||
109
src/neural/torch_mlp.py
Normal file
109
src/neural/torch_mlp.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Autoregressive MLP generative model (Stage C, for the N5 architecture-generality axis).
|
||||
|
||||
A causal feed-forward next-token model: token ``i`` is predicted from the concatenated
|
||||
(causally-masked) embeddings of all earlier tokens. Deliberately a *different* inductive
|
||||
bias from the GRU — if collapse appears here too, it is a property of the transmission
|
||||
operator, not of any one architecture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .config import ModelCfg, SyntheticCfg
|
||||
from .oracle import Oracle
|
||||
from .torch_models import _BaseTorchGenerator
|
||||
from .train import device_generator, seed_everything
|
||||
|
||||
|
||||
def _make_mlp_net(V: int, L: int, embed: int, hidden: int):
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
class MLPNet(nn.Module):
|
||||
"""Predict every position from a causally-masked flatten of prior embeddings."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.V, self.L, self.E = V, L, embed
|
||||
self.bos = V
|
||||
self.embed = nn.Embedding(V + 1, embed)
|
||||
self.net = nn.Sequential(
|
||||
nn.Linear(L * embed, hidden), nn.ReLU(),
|
||||
nn.Linear(hidden, hidden), nn.ReLU(),
|
||||
nn.Linear(hidden, V),
|
||||
)
|
||||
# lower-triangular INCLUSIVE mask over input positions: position i sees inputs
|
||||
# 0..i (the input is already shifted by one, so this is strictly causal on x).
|
||||
mask = torch.tril(torch.ones(L, L))
|
||||
self.register_buffer("mask", mask)
|
||||
|
||||
def _context(self, inp): # inp: (B, L) input tokens
|
||||
B = inp.shape[0]
|
||||
emb = self.embed(inp) # (B, L, E)
|
||||
m = self.mask.to(emb.dtype) # (L, L)
|
||||
# ctx[b, i] = concat_j ( emb[b, j] * mask[i, j] ) -> (B, L, L*E)
|
||||
ctx = emb.unsqueeze(1) * m.unsqueeze(0).unsqueeze(-1) # (B, L, L, E)
|
||||
return ctx.reshape(B, self.L, self.L * self.E)
|
||||
|
||||
def forward(self, x): # x: (B, L) targets
|
||||
B = x.shape[0]
|
||||
bos = torch.full((B, 1), self.bos, dtype=torch.long, device=x.device)
|
||||
inp = torch.cat([bos, x[:, :-1]], dim=1)
|
||||
ctx = self._context(inp)
|
||||
return self.net(ctx) # (B, L, V)
|
||||
|
||||
def step_logits(self, prefix): # prefix: (B, pos) tokens so far
|
||||
"""Logits for the next token given the tokens generated so far."""
|
||||
B, pos = prefix.shape
|
||||
bos = torch.full((B, 1), self.bos, dtype=torch.long, device=prefix.device)
|
||||
inp = torch.cat([bos, prefix], dim=1)[:, : self.L] # (B, <=L)
|
||||
if inp.shape[1] < self.L:
|
||||
pad = torch.zeros((B, self.L - inp.shape[1]), dtype=torch.long,
|
||||
device=prefix.device)
|
||||
inp = torch.cat([inp, pad], dim=1)
|
||||
ctx = self._context(inp) # (B, L, L*E)
|
||||
return self.net(ctx[:, pos, :]) # logits at position `pos`
|
||||
|
||||
return MLPNet()
|
||||
|
||||
|
||||
class MLPGenerator(_BaseTorchGenerator):
|
||||
"""Autoregressive feed-forward generative model over token sequences."""
|
||||
|
||||
def fit(self, X: np.ndarray, rng: np.random.Generator) -> None:
|
||||
import torch
|
||||
|
||||
g = seed_everything(int(rng.integers(2 ** 31)))
|
||||
net = _make_mlp_net(self.V, self.L, self.mcfg.embed, self.mcfg.hidden).to(self.device)
|
||||
net.train()
|
||||
opt = torch.optim.Adam(net.parameters(), lr=self.mcfg.lr)
|
||||
loss_fn = torch.nn.CrossEntropyLoss()
|
||||
data = torch.as_tensor(np.asarray(X), dtype=torch.long, device=self.device)
|
||||
n, bs = data.shape[0], self.mcfg.batch_size
|
||||
for _ in range(self.mcfg.epochs):
|
||||
perm = torch.randperm(n, generator=g).to(self.device)
|
||||
for i in range(0, n, bs):
|
||||
batch = data[perm[i:i + bs]]
|
||||
logits = net(batch)
|
||||
loss = loss_fn(logits.reshape(-1, self.V), batch.reshape(-1))
|
||||
opt.zero_grad()
|
||||
loss.backward()
|
||||
opt.step()
|
||||
net.eval()
|
||||
self.net = net
|
||||
|
||||
def sample(self, n: int, rng: np.random.Generator) -> np.ndarray:
|
||||
import torch
|
||||
|
||||
if self.net is None:
|
||||
raise RuntimeError("MLPGenerator.sample called before fit")
|
||||
g = device_generator(int(rng.integers(2 ** 31)), self.device)
|
||||
prefix = torch.empty((n, 0), dtype=torch.long, device=self.device)
|
||||
with torch.no_grad():
|
||||
for pos in range(self.L):
|
||||
logits = self.net.step_logits(prefix)
|
||||
probs = torch.softmax(logits, dim=-1)
|
||||
tok = torch.multinomial(probs, 1, generator=g)
|
||||
prefix = torch.cat([prefix, tok], dim=1)
|
||||
return prefix.cpu().numpy()
|
||||
146
src/neural/torch_models.py
Normal file
146
src/neural/torch_models.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Torch-backed generative models over the synthetic token grammar (Stage C).
|
||||
|
||||
Each model implements the same ``GenerativeModel`` protocol as the histogram bridge
|
||||
(``initialise`` / ``fit`` / ``sample`` / ``mode_distribution``), so a lineage is
|
||||
architecture-agnostic and N5 is a single sweep over ``model.kind``. Unlike the histogram
|
||||
model, a neural model's ``mode_distribution`` is *estimated* by generate-and-classify
|
||||
(``n_eval`` samples), which is the honest, slightly-noisy neural readout of ``p_t``.
|
||||
|
||||
Implemented so far: ``RNNGenerator`` (autoregressive GRU). ``VAEGenerator`` and
|
||||
``MLPGenerator`` follow and reuse the shared measure/initialise helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .config import ModelCfg, SyntheticCfg
|
||||
from .oracle import Oracle, measure_distribution
|
||||
from .synthetic import sample_synthetic
|
||||
from .train import device_generator, resolve_device, seed_everything, set_determinism
|
||||
|
||||
|
||||
def _measure(model, rng: np.random.Generator, oracle: Oracle, K: int, n_eval: int) -> np.ndarray:
|
||||
"""Estimate a model's mode distribution by generate-and-classify."""
|
||||
X = model.sample(n_eval, rng)
|
||||
return measure_distribution(X, oracle, K)
|
||||
|
||||
|
||||
class _BaseTorchGenerator:
|
||||
"""Shared plumbing: device, gen-0 initialisation, and mode measurement."""
|
||||
|
||||
def __init__(self, cfg: SyntheticCfg, model_cfg: ModelCfg, oracle: Oracle) -> None:
|
||||
self.cfg = cfg
|
||||
self.mcfg = model_cfg
|
||||
self.oracle = oracle
|
||||
self.device = resolve_device(model_cfg.device)
|
||||
self.V = cfg.vocab
|
||||
self.L = cfg.seq_len
|
||||
self.net = None
|
||||
set_determinism()
|
||||
|
||||
def initialise(self, p0: np.ndarray, rng: np.random.Generator) -> None:
|
||||
"""Train generation 0 on a sample drawn from ``p0`` (fidelity-gated in Stage C)."""
|
||||
n_init = max(self.mcfg.n_eval, 4000)
|
||||
X, _ = sample_synthetic(p0, n_init, self.cfg, rng)
|
||||
self.fit(X, rng)
|
||||
|
||||
def mode_distribution(self, rng: np.random.Generator) -> np.ndarray:
|
||||
return _measure(self, rng, self.oracle, self.cfg.K, self.mcfg.n_eval)
|
||||
|
||||
|
||||
# --- autoregressive GRU -----------------------------------------------------------------
|
||||
|
||||
def _make_ar_net(V: int, embed: int, hidden: int):
|
||||
"""Build an autoregressive GRU next-token network (built lazily to avoid a torch import
|
||||
at module load)."""
|
||||
import torch.nn as nn
|
||||
|
||||
class ARNet(nn.Module):
|
||||
"""Predict token ``i`` from tokens ``0..i-1`` via a GRU (BOS-prefixed)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.bos = V # extra input id for the start token
|
||||
self.embed = nn.Embedding(V + 1, embed)
|
||||
self.gru = nn.GRU(embed, hidden, batch_first=True)
|
||||
self.out = nn.Linear(hidden, V)
|
||||
|
||||
def forward(self, x): # x: (B, L) target tokens
|
||||
import torch
|
||||
|
||||
B = x.shape[0]
|
||||
bos = torch.full((B, 1), self.bos, dtype=torch.long, device=x.device)
|
||||
inp = torch.cat([bos, x[:, :-1]], dim=1) # teacher forcing
|
||||
h, _ = self.gru(self.embed(inp))
|
||||
return self.out(h) # (B, L, V)
|
||||
|
||||
return ARNet()
|
||||
|
||||
|
||||
class RNNGenerator(_BaseTorchGenerator):
|
||||
"""Autoregressive GRU generative model over token sequences."""
|
||||
|
||||
def _train_net(self, X, rng: np.random.Generator):
|
||||
import torch
|
||||
|
||||
g = seed_everything(int(rng.integers(2 ** 31)))
|
||||
net = _make_ar_net(self.V, self.mcfg.embed, self.mcfg.hidden).to(self.device)
|
||||
net.train()
|
||||
opt = torch.optim.Adam(net.parameters(), lr=self.mcfg.lr)
|
||||
loss_fn = torch.nn.CrossEntropyLoss()
|
||||
data = torch.as_tensor(np.asarray(X), dtype=torch.long, device=self.device)
|
||||
n = data.shape[0]
|
||||
bs = self.mcfg.batch_size
|
||||
for _ in range(self.mcfg.epochs):
|
||||
perm = torch.randperm(n, generator=g).to(self.device)
|
||||
for i in range(0, n, bs):
|
||||
idx = perm[i:i + bs]
|
||||
batch = data[idx]
|
||||
logits = net(batch) # (b, L, V)
|
||||
loss = loss_fn(logits.reshape(-1, self.V), batch.reshape(-1))
|
||||
opt.zero_grad()
|
||||
loss.backward()
|
||||
opt.step()
|
||||
net.eval()
|
||||
return net
|
||||
|
||||
def fit(self, X: np.ndarray, rng: np.random.Generator) -> None:
|
||||
self.net = self._train_net(X, rng)
|
||||
|
||||
def sample(self, n: int, rng: np.random.Generator) -> np.ndarray:
|
||||
import torch
|
||||
|
||||
if self.net is None:
|
||||
raise RuntimeError("RNNGenerator.sample called before fit")
|
||||
g = device_generator(int(rng.integers(2 ** 31)), self.device)
|
||||
out = torch.empty((n, self.L), dtype=torch.long, device=self.device)
|
||||
tok = torch.full((n, 1), self.V, dtype=torch.long, device=self.device) # BOS
|
||||
h = None
|
||||
with torch.no_grad():
|
||||
for pos in range(self.L):
|
||||
emb = self.net.embed(tok)
|
||||
hid, h = self.net.gru(emb, h)
|
||||
logits = self.net.out(hid[:, -1, :]) # (n, V)
|
||||
probs = torch.softmax(logits, dim=-1)
|
||||
tok = torch.multinomial(probs, 1, generator=g)
|
||||
out[:, pos] = tok[:, 0]
|
||||
return out.cpu().numpy()
|
||||
|
||||
|
||||
# --- dispatch ---------------------------------------------------------------------------
|
||||
|
||||
def make_torch_model(model_cfg: ModelCfg, cfg: SyntheticCfg, oracle: Oracle):
|
||||
"""Construct a torch generative model of the requested ``kind``."""
|
||||
kind = model_cfg.kind
|
||||
if kind == "rnn":
|
||||
return RNNGenerator(cfg, model_cfg, oracle)
|
||||
if kind == "vae":
|
||||
from .torch_vae import VAEGenerator # noqa: PLC0415
|
||||
|
||||
return VAEGenerator(cfg, model_cfg, oracle)
|
||||
if kind == "mlp":
|
||||
from .torch_mlp import MLPGenerator # noqa: PLC0415
|
||||
|
||||
return MLPGenerator(cfg, model_cfg, oracle)
|
||||
raise ValueError(f"unknown torch model kind {kind!r}")
|
||||
104
src/neural/torch_vae.py
Normal file
104
src/neural/torch_vae.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Sequence VAE generative model (Stage C, for the N5 architecture-generality axis).
|
||||
|
||||
A GRU encoder maps a token sequence to a Gaussian latent ``z``; a GRU decoder (its initial
|
||||
hidden state projected from ``z``) reconstructs the sequence. Trained by the ELBO
|
||||
(reconstruction CE + ``beta`` * KL). A latent-variable generator is a third, distinct
|
||||
inductive bias — and the canonical model in which generative collapse was first studied — so
|
||||
its collapse under dry self-training is strong evidence the effect is operator-driven.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .config import ModelCfg, SyntheticCfg
|
||||
from .oracle import Oracle
|
||||
from .torch_models import _BaseTorchGenerator
|
||||
from .train import device_generator, seed_everything
|
||||
|
||||
|
||||
def _make_vae(V: int, L: int, embed: int, hidden: int, latent: int):
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
class SeqVAE(nn.Module):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.V, self.L, self.bos = V, L, V
|
||||
self.embed = nn.Embedding(V + 1, embed)
|
||||
self.enc = nn.GRU(embed, hidden, batch_first=True)
|
||||
self.to_mu = nn.Linear(hidden, latent)
|
||||
self.to_lv = nn.Linear(hidden, latent)
|
||||
self.z_to_h = nn.Linear(latent, hidden)
|
||||
self.dec = nn.GRU(embed, hidden, batch_first=True)
|
||||
self.out = nn.Linear(hidden, V)
|
||||
|
||||
def encode(self, x):
|
||||
_, h = self.enc(self.embed(x)) # h: (1, B, H)
|
||||
h = h[-1]
|
||||
return self.to_mu(h), self.to_lv(h)
|
||||
|
||||
def decode_logits(self, z, x): # teacher forcing
|
||||
B = x.shape[0]
|
||||
bos = torch.full((B, 1), self.bos, dtype=torch.long, device=x.device)
|
||||
inp = torch.cat([bos, x[:, :-1]], dim=1)
|
||||
h0 = torch.tanh(self.z_to_h(z)).unsqueeze(0) # (1, B, H)
|
||||
out, _ = self.dec(self.embed(inp), h0)
|
||||
return self.out(out)
|
||||
|
||||
def forward(self, x):
|
||||
mu, lv = self.encode(x)
|
||||
std = torch.exp(0.5 * lv)
|
||||
z = mu + std * torch.randn_like(std)
|
||||
logits = self.decode_logits(z, x)
|
||||
kl = -0.5 * torch.sum(1 + lv - mu.pow(2) - lv.exp(), dim=1).mean()
|
||||
return logits, kl
|
||||
|
||||
return SeqVAE()
|
||||
|
||||
|
||||
class VAEGenerator(_BaseTorchGenerator):
|
||||
"""Sequence VAE generative model over token sequences."""
|
||||
|
||||
def fit(self, X: np.ndarray, rng: np.random.Generator) -> None:
|
||||
import torch
|
||||
|
||||
g = seed_everything(int(rng.integers(2 ** 31)))
|
||||
net = _make_vae(self.V, self.L, self.mcfg.embed, self.mcfg.hidden,
|
||||
self.mcfg.latent).to(self.device)
|
||||
net.train()
|
||||
opt = torch.optim.Adam(net.parameters(), lr=self.mcfg.lr)
|
||||
ce = torch.nn.CrossEntropyLoss()
|
||||
data = torch.as_tensor(np.asarray(X), dtype=torch.long, device=self.device)
|
||||
n, bs, beta = data.shape[0], self.mcfg.batch_size, self.mcfg.beta
|
||||
for _ in range(self.mcfg.epochs):
|
||||
perm = torch.randperm(n, generator=g).to(self.device)
|
||||
for i in range(0, n, bs):
|
||||
batch = data[perm[i:i + bs]]
|
||||
logits, kl = net(batch)
|
||||
recon = ce(logits.reshape(-1, self.V), batch.reshape(-1))
|
||||
loss = recon + beta * kl / batch.shape[0]
|
||||
opt.zero_grad()
|
||||
loss.backward()
|
||||
opt.step()
|
||||
net.eval()
|
||||
self.net = net
|
||||
|
||||
def sample(self, n: int, rng: np.random.Generator) -> np.ndarray:
|
||||
import torch
|
||||
|
||||
if self.net is None:
|
||||
raise RuntimeError("VAEGenerator.sample called before fit")
|
||||
g = device_generator(int(rng.integers(2 ** 31)), self.device)
|
||||
z = torch.randn(n, self.mcfg.latent, generator=g, device=self.device)
|
||||
h = torch.tanh(self.net.z_to_h(z)).unsqueeze(0) # (1, n, H)
|
||||
tok = torch.full((n, 1), self.net.bos, dtype=torch.long, device=self.device)
|
||||
out = torch.empty((n, self.L), dtype=torch.long, device=self.device)
|
||||
with torch.no_grad():
|
||||
for pos in range(self.L):
|
||||
dec_out, h = self.net.dec(self.net.embed(tok), h)
|
||||
logits = self.net.out(dec_out[:, -1, :])
|
||||
probs = torch.softmax(logits, dim=-1)
|
||||
tok = torch.multinomial(probs, 1, generator=g)
|
||||
out[:, pos] = tok[:, 0]
|
||||
return out.cpu().numpy()
|
||||
79
src/neural/train.py
Normal file
79
src/neural/train.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Torch determinism, seeding, and device helpers for the neural (Stage C) models.
|
||||
|
||||
Layer 1.5's neural tiers are *statistically* reproducible, not bitwise (blueprint 4 rider):
|
||||
we derive every torch seed from the same ``SeedSequence`` stream the rest of the study uses,
|
||||
set all available determinism flags, and report per-seed points. ``CUBLAS_WORKSPACE_CONFIG``
|
||||
must be set before the first CUDA op, so it is set at import time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
|
||||
|
||||
import numpy as np
|
||||
|
||||
_DETERMINISM_SET = False
|
||||
|
||||
|
||||
def torch_seed_from(seed) -> int:
|
||||
"""Reduce an int or ``np.random.SeedSequence`` to a 32-bit torch seed."""
|
||||
if isinstance(seed, np.random.SeedSequence):
|
||||
return int(seed.generate_state(1)[0])
|
||||
return int(seed) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def resolve_device(device: str):
|
||||
"""Resolve ``{auto, cpu, cuda}`` to a concrete ``torch.device``."""
|
||||
import torch
|
||||
|
||||
if device == "auto":
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
return torch.device(device)
|
||||
|
||||
|
||||
def set_determinism() -> None:
|
||||
"""Set torch/cuDNN determinism flags once per process (best-effort)."""
|
||||
global _DETERMINISM_SET
|
||||
if _DETERMINISM_SET:
|
||||
return
|
||||
import torch
|
||||
|
||||
torch.use_deterministic_algorithms(True, warn_only=True)
|
||||
torch.backends.cudnn.deterministic = True
|
||||
torch.backends.cudnn.benchmark = False
|
||||
_DETERMINISM_SET = True
|
||||
|
||||
|
||||
def seed_everything(seed) -> "object":
|
||||
"""Seed torch (CPU+CUDA) from ``seed`` and return a seeded CPU ``torch.Generator``.
|
||||
|
||||
Args:
|
||||
seed: An int or ``np.random.SeedSequence`` from the study's seed stream.
|
||||
|
||||
Returns:
|
||||
torch.Generator: A CPU generator seeded for sampling ops (e.g. ``randperm``).
|
||||
"""
|
||||
import torch
|
||||
|
||||
s = torch_seed_from(seed)
|
||||
torch.manual_seed(s)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed_all(s)
|
||||
g = torch.Generator()
|
||||
g.manual_seed(s)
|
||||
return g
|
||||
|
||||
|
||||
def device_generator(seed, device) -> "object":
|
||||
"""Return a ``torch.Generator`` on ``device`` seeded from ``seed``.
|
||||
|
||||
``torch.multinomial`` requires the generator to live on the same device as the
|
||||
probabilities, so sampling ops use this rather than the CPU generator.
|
||||
"""
|
||||
import torch
|
||||
|
||||
g = torch.Generator(device=device)
|
||||
g.manual_seed(torch_seed_from(seed))
|
||||
return g
|
||||
Loading…
Add table
Add a link
Reference in a new issue