Spaces:
Running
Running
Download scripts/experiments/coordinate_ablation.py from dmontgomery40/plastic: direct link, hf CLI and curl.
- Browser
- Download file 17.1 kB
-
https://huggingface.co/spaces/dmontgomery40/plastic/resolve/main/scripts/experiments/coordinate_ablation.py
- Command line
-
hf download hf://spaces/dmontgomery40/plastic/scripts/experiments/coordinate_ablation.py
-
curl -L -o coordinate_ablation.py https://huggingface.co/spaces/dmontgomery40/plastic/resolve/main/scripts/experiments/coordinate_ablation.py
17.1 kB
| """Coordinate ablation: meta-train the coordinate candidate and its falsifier variants on the | |
| mechanism testbed, then score each under the learning contract with no lasting update. | |
| uv run python -m scripts.experiments.coordinate_ablation --out artifacts/experiments/coordinate-ablation --steps 1500 | |
| uv run python -m scripts.experiments.coordinate_ablation --out artifacts/experiments/coordinate-ablation --collect | |
| This answers the memo's first question (docs/research/2026-09-21-plastic-coordinate-recurrence.md, | |
| "What would falsify the contribution"): do experience-driven changes to the nonlinear transition | |
| improve subsequent performance beyond adaptive decay alone? The score is the contract's | |
| before-stream block: held-out-combination MSE with fast adaptation versus with fast parameters | |
| frozen, under held-out intervention policies, and the adaptation-speed ratio. Nothing here is a | |
| lasting update; that is the slow rule's job (T3). | |
| Training rows hold one 64-step episode by default, so with chunk 16 three fast-update | |
| boundaries fall inside every episode during meta-training as well as during scoring; a | |
| packed row would let fast weights fitted to one world leak into the next. | |
| Variants (each trained from scratch with the same data seed): | |
| full the candidate: fast coordinates W and fast decay theta, meta-gradient through the step | |
| no_fast fast updates off (the same block as a plain selective recurrence) | |
| decay_only frozen coordinates, adaptive decay (the memo's "does timescale adaptation explain it") | |
| coords_only adaptive coordinates, frozen decay | |
| no_meta no meta-gradient through the inner step (is meta-training necessary) | |
| fixed_z the incorrect fixed-latent commit (does state transport matter) | |
| delta_baseline the existing PlasticDynamics block (RG-LRU + delta-rule memory) at the same width/depth | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import subprocess | |
| import time | |
| from dataclasses import asdict | |
| from typing import Any | |
| import torch | |
| from plastic.config import ModelConfig | |
| from plastic.data.mechanisms import mechanism_batch, split_combinations | |
| from plastic.eval.contract import ContractSpec, DynamicsLearner, run_contract, slice_mean | |
| from plastic.eval.coordinate_learner import CoordinateLearner | |
| from plastic.model.coordinate import CoordinateConfig, CoordinateDynamics | |
| from plastic.model.lm import PlasticDynamics | |
| VARIANTS: dict[str, dict[str, Any] | None] = { | |
| "full": {}, | |
| "no_fast": {"fast_updates": False}, | |
| "decay_only": {"adapt_W": False}, | |
| "coords_only": {"adapt_theta": False}, | |
| "no_meta": {"meta_gradient": "none"}, | |
| "fixed_z": {"commit_rule": "fixed_z"}, | |
| "delta_baseline": None, | |
| } | |
| def execution_commit(root: str) -> str | None: | |
| try: | |
| return subprocess.check_output(["git", "-C", root, "rev-parse", "HEAD"], text=True).strip() | |
| except Exception: | |
| return None | |
| def build(variant: str, *, d_model: int, n_heads: int, n_layers: int, chunk: int, seed: int, eta_max: float | None = None, eta_init: float | None = None) -> torch.nn.Module: | |
| torch.manual_seed(seed) | |
| overrides = VARIANTS[variant] | |
| if overrides is None: | |
| return PlasticDynamics(ModelConfig(domain="physics", d_model=d_model, n_heads=n_heads, n_layers=n_layers, chunk=chunk)) | |
| extra: dict[str, Any] = {} | |
| if eta_max is not None: | |
| extra["eta_max"] = float(eta_max) | |
| if eta_init is not None: | |
| extra["eta_init"] = float(eta_init) | |
| return CoordinateDynamics(CoordinateConfig(d_model=d_model, n_heads=n_heads, n_layers=n_layers, chunk=chunk, **overrides, **extra)) | |
| def learner_for(variant: str, model: torch.nn.Module, device: torch.device): | |
| if VARIANTS[variant] is None: | |
| return DynamicsLearner(model, mode="frozen", device=device), "writes disabled (beta_scale=0)" | |
| return CoordinateLearner(model, mode="frozen", device=device), "fast parameters frozen (freeze=True)" | |
| def _etas(model: torch.nn.Module) -> list[float] | None: | |
| blocks = getattr(getattr(model, "core", None), "blocks", None) | |
| if blocks is None: | |
| return None | |
| out = [] | |
| for b in blocks: | |
| logit = getattr(b, "eta_logit", None) | |
| if logit is None: | |
| return None | |
| out.append(float(model.cfg.eta_max * torch.sigmoid(logit.detach()).mean())) | |
| return out | |
| OUTER_LOSSES = ("all", "post_boundary") | |
| def outer_loss_value(model: torch.nn.Module, x: torch.Tensor, y: torch.Tensor, *, outer_loss: str, chunk: int) -> torch.Tensor: | |
| """The meta-training objective. | |
| ``all``: the mean next-observation MSE over every step, the sequence-model reflex. The | |
| first ``chunk`` steps of an episode can never benefit from a fast update (the first | |
| boundary is at step ``chunk``), so the gradient reaching the fast path is diluted by | |
| steps only the slow weights can serve. | |
| ``post_boundary``: the mean over steps after the first boundary only, the MAML-style | |
| query loss: the outer objective is the prediction quality that adaptation could have | |
| improved. Applied with the same cutoff to every variant, so the comparison stays matched. | |
| """ | |
| if isinstance(model, CoordinateDynamics): | |
| pred, _, _ = model(x, target_delta=y) | |
| else: | |
| pred, _, _ = model(x) | |
| per_step = (pred - y).pow(2).mean(-1) # (B, T) | |
| if outer_loss == "all": | |
| return per_step.mean() | |
| if outer_loss == "post_boundary": | |
| if per_step.shape[1] <= chunk: | |
| raise ValueError("post_boundary needs episodes longer than one chunk") | |
| return per_step[:, chunk:].mean() | |
| raise ValueError(f"unknown outer_loss {outer_loss!r}") | |
| def train( | |
| model: torch.nn.Module, | |
| *, | |
| steps: int, | |
| batch: int, | |
| seq_len: int, | |
| episodes: int, | |
| combos: list[tuple[str, ...]], | |
| lr: float, | |
| seed: int, | |
| log_every: int, | |
| device: torch.device, | |
| clip: float = 1.0, | |
| outer_loss: str = "all", | |
| chunk: int = 16, | |
| ) -> list[dict[str, Any]]: | |
| g = torch.Generator().manual_seed(seed) | |
| opt = torch.optim.Adam(model.parameters(), lr=lr) | |
| model.train() | |
| log: list[dict[str, Any]] = [] | |
| t0 = time.time() | |
| for step in range(1, steps + 1): | |
| b = mechanism_batch(batch, seq_len=seq_len, episodes_per_seq=episodes, combos=combos, policy="gaussian", rng=g) | |
| x, y = b.inputs.to(device), b.target_delta.to(device) | |
| opt.zero_grad(set_to_none=True) | |
| loss = outer_loss_value(model, x, y, outer_loss=outer_loss, chunk=chunk) | |
| loss.backward() | |
| gn = float(torch.nn.utils.clip_grad_norm_(model.parameters(), clip)) | |
| opt.step() | |
| if step % log_every == 0 or step == steps or step == 1: | |
| log.append({"step": step, "loss": float(loss.detach()), "grad_norm": gn, "eta": _etas(model), "wall_s": time.time() - t0}) | |
| model.eval() | |
| return log | |
| def run_variant(variant: str, args: argparse.Namespace, out: str, *, spec: ContractSpec) -> dict[str, Any]: | |
| device = torch.device(args.device) | |
| train_combos, _ = split_combinations(k=spec.k, n_heldout=spec.n_heldout, seed=spec.split_seed) | |
| model = build( | |
| variant, d_model=args.d_model, n_heads=args.n_heads, n_layers=args.n_layers, chunk=args.chunk, seed=args.seed, | |
| eta_max=getattr(args, "eta_max", None), eta_init=getattr(args, "eta_init", None), | |
| ).to(device) | |
| params = sum(p.numel() for p in model.parameters()) | |
| t0 = time.time() | |
| outer = getattr(args, "outer_loss", "all") | |
| log = train( | |
| model, steps=args.steps, batch=args.batch, seq_len=args.seq_len, episodes=args.episodes, combos=train_combos, | |
| lr=args.lr, seed=args.seed + 1, log_every=args.log_every, device=device, outer_loss=outer, chunk=args.chunk, | |
| ) | |
| train_s = time.time() - t0 | |
| os.makedirs(out, exist_ok=True) | |
| torch.save(model.state_dict(), os.path.join(out, f"{variant}.pt")) | |
| learner, no_adapt_label = learner_for(variant, model, device) | |
| report = run_contract(learner, spec, seed=args.seed) | |
| # what the fast path proposed on held-out worlds: one adapting pass on the speed batch | |
| fast_signals = None | |
| if hasattr(learner, "fast_signals_summary"): | |
| _, heldout = split_combinations(k=spec.k, n_heldout=spec.n_heldout, seed=spec.split_seed) | |
| probe = mechanism_batch(spec.eval_batch, seq_len=spec.seq_len, episodes_per_seq=1, combos=heldout, policy="gaussian", rng=torch.Generator().manual_seed(args.seed * 10_000 + 200)) | |
| learner.step_mse(probe, adapt=True) | |
| fast_signals = learner.fast_signals_summary() | |
| result = { | |
| "variant": variant, | |
| "config": VARIANTS[variant] if VARIANTS[variant] is not None else "PlasticDynamics", | |
| "size": {"d_model": args.d_model, "n_heads": args.n_heads, "n_layers": args.n_layers, "chunk": args.chunk, "parameters": params, "eta_max": getattr(model.cfg, "eta_max", None), "eta_init": getattr(model.cfg, "eta_init", None)}, | |
| "train": {"steps": args.steps, "batch": args.batch, "seq_len": args.seq_len, "episodes": args.episodes, "lr": args.lr, "seed": args.seed, "outer_loss": outer, "wall_s": train_s, "s_per_step": train_s / max(1, args.steps), "log": log}, | |
| "no_adapt_label": no_adapt_label, | |
| "fast_signals": fast_signals, | |
| "checkpoint": f"{variant}.pt", | |
| "contract": report, | |
| } | |
| with open(os.path.join(out, f"{variant}.json"), "w", encoding="utf-8") as f: | |
| json.dump(result, f, indent=1) | |
| return result | |
| def _fmt(x: float) -> str: | |
| return f"{x:.4f}" | |
| def collect(out: str) -> str: | |
| results: dict[str, dict[str, Any]] = {} | |
| for name in VARIANTS: | |
| p = os.path.join(out, f"{name}.json") | |
| if os.path.exists(p): | |
| with open(p, encoding="utf-8") as f: | |
| results[name] = json.load(f) | |
| if not results: | |
| raise SystemExit(f"no variant results in {out}") | |
| any_r = next(iter(results.values())) | |
| policies = list(any_r["contract"]["transfer"].keys()) | |
| cut = any_r["size"]["chunk"] | |
| head = ["variant", "outer loss", "params", "train loss (last)", "s/step"] + [f"{p}: adapt / no-adapt" for p in policies] + [f"after step {cut}: adapt / no-adapt (held-out mean)", "train dist: adapt / no-adapt", "speed mean", "half at step", "η per layer", "inner loss before → after", "‖ΔW‖, ‖Δθ‖ per layer"] | |
| lines = ["| " + " | ".join(head) + " |", "|" + "---|" * len(head)] | |
| for name, r in results.items(): | |
| c = r["contract"] | |
| last = r["train"]["log"][-1] | |
| row = [name, r["train"].get("outer_loss", "all"), str(r["size"]["parameters"]), _fmt(last["loss"]), f"{r['train']['s_per_step']:.2f}"] | |
| for p in policies: | |
| b = c["transfer"][p]["before"] | |
| row.append(f"{_fmt(b['adapt'])} / {_fmt(b['no_adapt'])}") | |
| # the whole-episode mean includes the first chunk, which a post-boundary objective never | |
| # trained; the slice after the first boundary compares objectives on the same steps | |
| posts = [(slice_mean(c["transfer"][p]["before"], "by_step_adapt", cut), slice_mean(c["transfer"][p]["before"], "by_step_no_adapt", cut)) for p in policies] | |
| if all(a is not None and z is not None for a, z in posts): | |
| row.append(f"{_fmt(sum(a for a, _ in posts) / len(posts))} / {_fmt(sum(z for _, z in posts) / len(posts))}") | |
| else: | |
| row.append("n/a (older report)") | |
| fb = c["forgetting"]["before"] | |
| row.append(f"{_fmt(fb['adapt'])} / {_fmt(fb['no_adapt'])}") | |
| s = c["speed"]["before"] | |
| row += [f"{s['area']:.3f}", str(s["steps_to_half"]), ", ".join(f"{e:.3f}" for e in last["eta"]) if last.get("eta") else "n/a"] | |
| fs = r.get("fast_signals") | |
| if fs and fs.get("inner_loss_before") is not None: | |
| row.append(f"{fs['inner_loss_before']:.4f} → {fs['inner_loss_after']:.4f}") | |
| row.append(", ".join(f"{w:.3g}/{t:.3g}" for w, t in zip(fs["dW_norm_by_layer"], fs["dtheta_norm_by_layer"]))) | |
| else: | |
| row += ["n/a", "n/a"] | |
| lines.append("| " + " | ".join(row) + " |") | |
| table = "\n".join(lines) | |
| labels = {name: r["no_adapt_label"] for name, r in results.items()} | |
| body = [ | |
| "# Coordinate ablation on the mechanism testbed", | |
| "", | |
| "Each variant is trained from scratch on the training combinations (gaussian policy) with the same data seed, " | |
| "then scored under the learning contract with no lasting update. `adapt` is the held-out-combination MSE with " | |
| "fast updates on; `no-adapt` is the same inputs with the fast path off. The off-intervention differs by model and " | |
| "is labelled: " + "; ".join(f"{k}: {v}" for k, v in labels.items()) + ".", | |
| "", | |
| "`speed mean` is the adapting error as a fraction of the no-adapt error over the first probe steps (lower is faster); " | |
| "`half at step` is the first step at which it drops below one half. `η per layer` is the learned inner step size. " | |
| "`inner loss before → after` re-scores the observed chunk under the proposed step (a support diagnostic, not an " | |
| "adaptation score); `‖ΔW‖, ‖Δθ‖` are the proposed changes per layer, averaged over held-out boundaries.", | |
| "", | |
| table, | |
| "", | |
| "`outer loss` names the meta-training objective: `all` is the mean over every step of the episode (a sequence-model " | |
| "objective under which the first chunk can never benefit from a fast update); `post_boundary` is the mean over steps " | |
| "after the first boundary only, the query loss adaptation could have improved. Read `all` rows as a lower bound on " | |
| "what the fast path was asked to do.", | |
| "", | |
| "Reading guide (from the memo's falsification table): if `decay_only` matches `full`, timescale adaptation " | |
| "explains the gain and the nonlinear coordinates are not earning their place. If `no_meta` matches `full`, " | |
| "meta-training is not necessary. If `delta_baseline` matches `full` at matched compute, coupling learning to " | |
| "the dynamics is not useful. Parameters, seconds per step and state memory are reported because equal parameter " | |
| "counts alone are insufficient. One seed unless stated; no lasting-learning claim is made here.", | |
| "", | |
| ] | |
| readme = "\n".join(body) | |
| with open(os.path.join(out, "README.md"), "w", encoding="utf-8") as f: | |
| f.write(readme) | |
| return readme | |
| def main(argv: list[str] | None = None) -> int: | |
| ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) | |
| ap.add_argument("--out", required=True) | |
| ap.add_argument("--variants", nargs="+", default=list(VARIANTS), choices=list(VARIANTS)) | |
| ap.add_argument("--collect", action="store_true", help="only write README.md from existing variant results") | |
| ap.add_argument("--steps", type=int, default=1500) | |
| ap.add_argument("--batch", type=int, default=16) | |
| ap.add_argument("--seq-len", type=int, default=64) | |
| ap.add_argument("--episodes", type=int, default=1, help="episodes per training row; 1 keeps every fast-update boundary inside an episode") | |
| ap.add_argument("--lr", type=float, default=1e-3) | |
| ap.add_argument("--outer-loss", default="post_boundary", choices=OUTER_LOSSES, help="meta-training objective; 'all' is the whole-episode mean") | |
| ap.add_argument("--eta-max", type=float, default=None, help="ceiling of the learned inner step size (coordinate variants; default from CoordinateConfig)") | |
| ap.add_argument("--eta-init", type=float, default=None, help="initial inner step size (coordinate variants; default from CoordinateConfig)") | |
| ap.add_argument("--log-every", type=int, default=50) | |
| ap.add_argument("--d-model", type=int, default=128) | |
| ap.add_argument("--n-heads", type=int, default=4) | |
| ap.add_argument("--n-layers", type=int, default=3) | |
| ap.add_argument("--chunk", type=int, default=16) | |
| ap.add_argument("--seed", type=int, default=0) | |
| ap.add_argument("--device", default="cpu") | |
| ap.add_argument("--eval-batch", type=int, default=8) | |
| args = ap.parse_args(argv) | |
| if args.collect: | |
| print(collect(args.out)) | |
| return 0 | |
| spec = ContractSpec(eval_batch=args.eval_batch) | |
| root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| os.makedirs(args.out, exist_ok=True) | |
| with open(os.path.join(args.out, "manifest.json"), "w", encoding="utf-8") as f: | |
| json.dump({"execution_commit": execution_commit(root), "args": vars(args), "spec": asdict(spec), "started_utc": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())}, f, indent=1) | |
| for v in args.variants: | |
| t0 = time.time() | |
| r = run_variant(v, args, args.out, spec=spec) | |
| c = r["contract"]["transfer"] | |
| print(f"[ablation] {v}: params={r['size']['parameters']} train_loss={r['train']['log'][-1]['loss']:.4f} " | |
| + " ".join(f"{p}={c[p]['before']['adapt']:.4f}/{c[p]['before']['no_adapt']:.4f}" for p in c) | |
| + f" ({time.time() - t0:.0f}s)", flush=True) | |
| print(collect(args.out)) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |