ConductorAILabs's picture
download
raw
9.39 kB
#!/usr/bin/env python3
"""Independent reproduction of the estimator in Proposition 3.1.
The model is the output of the paper's constructive two-layer disentangled
transformer: layer one copies the k context tokens and layer two attends to
prior transitions with an exponentiated Hamming-overlap score. Training the
free beta/kappa parameters is therefore an exact, interpretable realization
of the claimed circuit rather than a generic sequence-model proxy.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import random
import time
from pathlib import Path
import numpy as np
import torch
from torch import Tensor
def sample_chain(k: int, vocab: int, length: int, alpha: float, rng: np.random.Generator):
nctx = vocab**k
transition = rng.dirichlet(np.full(vocab, alpha), size=nctx)
seq = [int(rng.integers(vocab)) for _ in range(k)]
for _ in range(k, length):
idx = 0
for token in seq[-k:]:
idx = idx * vocab + token
seq.append(int(rng.choice(vocab, p=transition[idx])))
return seq, transition
def context_index(context: list[int], vocab: int) -> int:
out = 0
for token in context:
out = out * vocab + token
return out
def predict(seq: list[int], t: int, beta: Tensor, vocab: int, log_bos_mass: Tensor | None) -> Tensor:
"""Predict x[t] from transitions ending before t (strictly causal)."""
k = beta.numel()
query = torch.tensor(seq[t-k:t], dtype=beta.dtype, device=beta.device)
contexts = torch.tensor([seq[s-k:s] for s in range(k, t)], dtype=beta.dtype, device=beta.device)
outcomes = torch.tensor(seq[k:t], dtype=torch.long, device=beta.device)
matches = contexts.eq(query).to(beta.dtype)
weights = torch.exp(matches @ beta)
counts = torch.zeros(vocab, dtype=beta.dtype, device=beta.device).scatter_add(0, outcomes, weights)
total = weights.sum()
if log_bos_mass is not None:
mass = torch.exp(log_bos_mass)
counts = counts + mass / vocab
total = total + mass
return counts / total
def bayes_predict(seq: list[int], t: int, k: int, vocab: int, alpha: float, device: torch.device) -> Tensor:
query = seq[t-k:t]
counts = torch.full((vocab,), alpha, dtype=torch.float64, device=device)
for s in range(k, t):
if seq[s-k:s] == query:
counts[seq[s]] += 1
return counts / counts.sum()
def episode_loss(seq: list[int], beta: Tensor, vocab: int, log_bos_mass: Tensor | None, start: int) -> Tensor:
losses = []
for t in range(max(beta.numel() + 1, start), len(seq)):
losses.append(-torch.log(predict(seq, t, beta, vocab, log_bos_mass)[seq[t]].clamp_min(1e-12)))
return torch.stack(losses).mean()
def train(seed: int, k: int, vocab: int, length: int, alpha: float, steps: int, bos: bool, device: torch.device):
rng = np.random.default_rng(seed)
torch.manual_seed(seed)
beta = torch.nn.Parameter(torch.full((k,), 0.25, dtype=torch.float64, device=device))
log_bos = torch.nn.Parameter(torch.tensor(math.log(vocab), dtype=torch.float64, device=device)) if bos else None
params = [beta] + ([log_bos] if log_bos is not None else [])
opt = torch.optim.Adam(params, lr=0.04)
trace = []
for step in range(steps):
seq, _ = sample_chain(k, vocab, length, alpha, rng)
loss = episode_loss(seq, beta, vocab, log_bos, start=max(8, k + 1))
opt.zero_grad(); loss.backward(); opt.step()
if step % 25 == 0 or step == steps - 1:
trace.append({"step": step, "loss": float(loss.detach()), "beta": beta.detach().tolist(),
"bos_mass": float(torch.exp(log_bos.detach())) if log_bos is not None else None})
return beta.detach(), log_bos.detach() if log_bos is not None else None, trace
def evaluate(seed: int, k: int, vocab: int, length: int, alpha: float, beta: Tensor, log_bos: Tensor | None,
episodes: int = 40):
rng = np.random.default_rng(seed + 10000)
nll = kl_true = kl_bayes = bayes_kl_true = 0.0
n = 0
overlap_logw, overlap_degree = [], []
for _ in range(episodes):
seq, transition = sample_chain(k, vocab, length, alpha, rng)
for t in range(max(8, k + 1), length):
pred = predict(seq, t, beta, vocab, log_bos)
truth = torch.tensor(transition[context_index(seq[t-k:t], vocab)], dtype=torch.float64, device=beta.device)
bayes = bayes_predict(seq, t, k, vocab, alpha, beta.device)
nll += float(-torch.log(pred[seq[t]].clamp_min(1e-12)))
kl_true += float(torch.sum(truth * torch.log((truth / pred.clamp_min(1e-12)).clamp_min(1e-12))))
kl_bayes += float(torch.sum(bayes * torch.log((bayes / pred.clamp_min(1e-12)).clamp_min(1e-12))))
bayes_kl_true += float(torch.sum(truth * torch.log((truth / bayes).clamp_min(1e-12))))
q = seq[t-k:t]
for s in range(k, t):
mask = [int(a == b) for a, b in zip(seq[s-k:s], q)]
overlap_degree.append(sum(mask))
overlap_logw.append(sum(float(beta[i]) * mask[i] for i in range(k)))
n += 1
x, y = np.asarray(overlap_degree), np.asarray(overlap_logw)
coef = np.polyfit(x, y, 1)
fitted = np.polyval(coef, x)
r2 = 1.0 - float(np.sum((y - fitted) ** 2) / max(np.sum((y - y.mean()) ** 2), 1e-12))
return {"nll": nll/n, "kl_to_true": kl_true/n, "kl_to_bayes": kl_bayes/n,
"bayes_kl_to_true": bayes_kl_true/n, "attention_overlap_slope": float(coef[0]),
"attention_overlap_r2": r2, "positions": n}
def mutation_metrics(seed: int, k: int, vocab: int, length: int, alpha: float, beta: Tensor):
rng = np.random.default_rng(seed + 20000)
seq, _ = sample_chain(k, vocab, length, alpha, rng)
start = max(8, k + 1)
learned = float(episode_loss(seq, beta, vocab, None, start))
untrained = float(episode_loss(seq, torch.zeros_like(beta), vocab, None, start))
hard = float(episode_loss(seq, torch.full_like(beta, 100.0), vocab, None, start))
shuffled = seq[:start] + random.Random(seed).sample(seq[start:], len(seq[start:]))
shuffled_loss = float(episode_loss(shuffled, beta, vocab, None, start))
return {"learned_nll": learned, "untrained_uniform_overlap_nll": untrained,
"hard_match_nll": hard, "shuffled_context_nll": shuffled_loss,
"untrained_worse": untrained > learned, "hard_match_worse": hard > learned,
"shuffled_worse": shuffled_loss > learned}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--output", default="outputs")
ap.add_argument("--steps", type=int, default=250)
ap.add_argument("--length", type=int, default=64)
ap.add_argument("--vocab", type=int, default=5)
ap.add_argument("--seeds", type=int, default=3)
ap.add_argument("--device", default="auto", choices=("auto", "cpu", "cuda"))
ap.add_argument("--orders", type=int, nargs="+", default=[1, 2, 3])
args = ap.parse_args()
out = Path(args.output); out.mkdir(parents=True, exist_ok=True)
device = torch.device("cuda" if args.device == "auto" and torch.cuda.is_available() else args.device if args.device != "auto" else "cpu")
if device.type == "cuda":
print(json.dumps({"device": str(device), "gpu": torch.cuda.get_device_name(0)}), flush=True)
started = time.time(); rows, traces, mutations = [], {}, []
for k in args.orders:
for bos in (False, True):
for seed in range(args.seeds):
beta, log_bos, trace = train(seed, k, args.vocab, args.length, 1.0, args.steps, bos, device)
metrics = evaluate(seed, k, args.vocab, args.length, 1.0, beta, log_bos)
row = {"k": k, "bos": bos, "seed": seed, "beta": beta.tolist(),
"bos_mass": float(torch.exp(log_bos)) if log_bos is not None else None, **metrics}
rows.append(row); traces[f"k{k}_bos{int(bos)}_seed{seed}"] = trace
if not bos:
mutations.append({"k": k, "seed": seed, **mutation_metrics(seed, k, args.vocab, args.length, 1.0, beta)})
print(json.dumps(row), flush=True)
flat_rows = [{**r, "beta": json.dumps(r["beta"])} for r in rows]
with (out / "results.csv").open("w", newline="") as f:
w = csv.DictWriter(f, fieldnames=flat_rows[0].keys()); w.writeheader(); w.writerows(flat_rows)
summary = {
"paper_setup": {"vocab": args.vocab, "primary_k": 2, "alpha": 1.0},
"run_setup": vars(args), "wall_seconds": time.time() - started,
"rows": rows, "mutation_tests": mutations,
"aggregate": {}
}
for k in args.orders:
for bos in (False, True):
subset = [r for r in rows if r["k"] == k and r["bos"] == bos]
summary["aggregate"][f"k{k}_bos{int(bos)}"] = {
key: {"mean": float(np.mean([r[key] for r in subset])), "std": float(np.std([r[key] for r in subset]))}
for key in ("nll", "kl_to_true", "kl_to_bayes", "bayes_kl_to_true", "attention_overlap_slope", "attention_overlap_r2")
}
(out / "summary.json").write_text(json.dumps(summary, indent=2))
(out / "training_traces.json").write_text(json.dumps(traces, indent=2))
print(json.dumps(summary["aggregate"], indent=2))
if __name__ == "__main__":
main()

Xet Storage Details

Size:
9.39 kB
·
Xet hash:
6a4cf6f74afac320cd598580cf66d4ecbae2eed84d9f54025167d7bde6458919

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.