Publish Prizma — mirror of github.com/nazmiefearmutcu/Prizma (PRISM-Seq §4 bar + continual-learning Prizma)
5066d15 verified | """ | |
| Deterministic synthetic continual-learning benchmarks for Prizma. | |
| Everything is offline and seeded. Two task constructions are provided: | |
| * permuted_tasks -- analog of Permuted-MNIST. A single base classification | |
| problem; task t applies a fixed random *feature permutation* | |
| pi_t to the inputs. Output classes are shared. This is the | |
| canonical *strong* catastrophic-forgetting setup: the same | |
| input coordinates mean something different in each task, so | |
| sequential training overwrites prior tasks. | |
| * split_tasks -- analog of Split-MNIST. The base classes are partitioned into | |
| disjoint groups; task t asks the network to classify only the | |
| classes in group t (re-indexed to 0..g-1). Different tasks live | |
| in different input regions, so a *router* can tell them apart | |
| from input statistics alone -- the regime Prizma's gate exploits. | |
| The base dataset is labelled by a fixed random "teacher" MLP, which guarantees a | |
| non-linear, learnable structure (a linear model cannot solve it) without any download. | |
| """ | |
| from __future__ import annotations | |
| import numpy as np | |
| # --------------------------------------------------------------------------- # | |
| # Base dataset: teacher-MLP-labelled synthetic classification | |
| # --------------------------------------------------------------------------- # | |
| def _teacher_logits(X, params): | |
| """Forward pass of a fixed random 2-hidden-layer tanh teacher network.""" | |
| (W1, b1, W2, b2, W3, b3) = params | |
| h1 = np.tanh(X @ W1 + b1) | |
| h2 = np.tanh(h1 @ W2 + b2) | |
| return h2 @ W3 + b3 | |
| def make_base_dataset(n_samples=6000, d=20, n_classes=10, hidden=64, seed=0): | |
| """Return (X, y) for a non-linearly separable synthetic classification task. | |
| X ~ N(0, I) in R^d, labelled by a fixed random teacher MLP. Returns float32 | |
| inputs in [-1, 1] (squashed) and int labels. Deterministic in `seed`. | |
| """ | |
| rng = np.random.default_rng(seed) | |
| W1 = rng.normal(0, 1.0 / np.sqrt(d), (d, hidden)) | |
| b1 = rng.normal(0, 0.1, hidden) | |
| W2 = rng.normal(0, 1.0 / np.sqrt(hidden), (hidden, hidden)) | |
| b2 = rng.normal(0, 0.1, hidden) | |
| W3 = rng.normal(0, 1.0 / np.sqrt(hidden), (hidden, n_classes)) | |
| b3 = rng.normal(0, 0.1, n_classes) | |
| params = (W1, b1, W2, b2, W3, b3) | |
| X = rng.normal(0, 1.0, (n_samples, d)).astype(np.float32) | |
| logits = _teacher_logits(X, params) | |
| y = np.argmax(logits, axis=1).astype(np.int64) | |
| # squash inputs to a bounded analog-friendly range | |
| X = np.tanh(X).astype(np.float32) | |
| return X, y | |
| def _split_train_test(X, y, test_frac=0.2, seed=0): | |
| rng = np.random.default_rng(seed + 999) | |
| n = len(X) | |
| idx = rng.permutation(n) | |
| n_test = int(n * test_frac) | |
| te, tr = idx[:n_test], idx[n_test:] | |
| return (X[tr], y[tr]), (X[te], y[te]) | |
| # --------------------------------------------------------------------------- # | |
| # Continual-learning task sequences | |
| # --------------------------------------------------------------------------- # | |
| class Task: | |
| """A single continual-learning task: train/test splits + metadata.""" | |
| def __init__(self, name, Xtr, ytr, Xte, yte, n_classes): | |
| self.name = name | |
| self.Xtr, self.ytr = Xtr, ytr | |
| self.Xte, self.yte = Xte, yte | |
| self.n_classes = n_classes | |
| def __repr__(self): | |
| return (f"Task({self.name}, train={len(self.Xtr)}, test={len(self.Xte)}, " | |
| f"classes={self.n_classes})") | |
| def permuted_tasks(n_tasks=5, n_samples=6000, d=20, n_classes=10, seed=0): | |
| """Permuted-MNIST analog. Each task applies a fixed feature permutation.""" | |
| X, y = make_base_dataset(n_samples=n_samples, d=d, n_classes=n_classes, seed=seed) | |
| (Xtr, ytr), (Xte, yte) = _split_train_test(X, y, seed=seed) | |
| rng = np.random.default_rng(seed + 1) | |
| tasks = [] | |
| for t in range(n_tasks): | |
| perm = np.arange(d) if t == 0 else rng.permutation(d) | |
| tasks.append(Task( | |
| name=f"perm{t}", | |
| Xtr=Xtr[:, perm].copy(), ytr=ytr.copy(), | |
| Xte=Xte[:, perm].copy(), yte=yte.copy(), | |
| n_classes=n_classes, | |
| )) | |
| return tasks | |
| def structured_permuted_tasks(n_tasks=5, n_samples=6000, d=24, k_latent=8, | |
| n_classes=8, seed=0, noise_std=0.0): | |
| """Domain-incremental stream that is genuinely INPUT-DISTINGUISHABLE. | |
| Base features are a fixed linear mixing of latent factors: v = latent @ A^T, | |
| latent ~ N(0, I_k), so cov(v) = A A^T != I (correlated features). Labels come from a | |
| teacher applied to the LATENTS (shared across tasks). Task t permutes the observed | |
| features by pi_t, so cov(x_t) = P_t (A A^T) P_t^T differs per task -> an autoencoder / | |
| recognizer CAN tell domains apart from the input alone (unlike permuted iid-Gaussian, | |
| where permutation leaves the distribution invariant). Naive sequential training still | |
| forgets: the input->label map differs per permutation and overwrites shared weights. | |
| """ | |
| rng = np.random.default_rng(seed) | |
| A = rng.normal(0, 1.0, (d, k_latent)).astype(np.float32) # fixed mixing | |
| latent = rng.normal(0, 1.0, (n_samples, k_latent)).astype(np.float32) | |
| v = latent @ A.T # correlated features | |
| v = (v / (v.std(0, keepdims=True) + 1e-6)).astype(np.float32) | |
| if noise_std > 0: | |
| # iid (permutation-invariant) noise dilutes the structured signal -> shrinks the | |
| # recognition margin. A separability knob: noise_std=0 -> cleanly distinguishable; | |
| # large noise_std -> domains indistinguishable (Prizma should degrade to naive). | |
| v = (v + noise_std * rng.normal(0, 1, v.shape)).astype(np.float32) | |
| # teacher labels on the LATENTS (shared rule, domain-invariant) | |
| Wt1 = rng.normal(0, 1.0 / np.sqrt(k_latent), (k_latent, 32)) | |
| Wt2 = rng.normal(0, 1.0 / np.sqrt(32), (32, n_classes)) | |
| y = np.argmax(np.tanh(latent @ Wt1) @ Wt2, axis=1).astype(np.int64) | |
| (vtr, ytr), (vte, yte) = _split_train_test(v, y, seed=seed) | |
| rng2 = np.random.default_rng(seed + 1) | |
| tasks = [] | |
| for t in range(n_tasks): | |
| perm = np.arange(d) if t == 0 else rng2.permutation(d) | |
| tasks.append(Task( | |
| name=f"sperm{t}", | |
| Xtr=vtr[:, perm].copy(), ytr=ytr.copy(), | |
| Xte=vte[:, perm].copy(), yte=yte.copy(), | |
| n_classes=n_classes, | |
| )) | |
| return tasks | |
| def split_tasks(n_tasks=5, classes_per_task=2, n_samples=12000, d=20, seed=0): | |
| """Split-MNIST analog. Disjoint class groups; labels re-indexed per task.""" | |
| n_classes = n_tasks * classes_per_task | |
| X, y = make_base_dataset(n_samples=n_samples, d=d, n_classes=n_classes, seed=seed) | |
| (Xtr, ytr), (Xte, yte) = _split_train_test(X, y, seed=seed) | |
| tasks = [] | |
| for t in range(n_tasks): | |
| lo = t * classes_per_task | |
| hi = lo + classes_per_task | |
| cls = list(range(lo, hi)) | |
| mtr = np.isin(ytr, cls) | |
| mte = np.isin(yte, cls) | |
| tasks.append(Task( | |
| name=f"split{t}:{cls}", | |
| Xtr=Xtr[mtr].copy(), ytr=(ytr[mtr] - lo).copy(), | |
| Xte=Xte[mte].copy(), yte=(yte[mte] - lo).copy(), | |
| n_classes=classes_per_task, | |
| )) | |
| return tasks | |
| if __name__ == "__main__": | |
| for tasks, label in [(permuted_tasks(), "PERMUTED"), (split_tasks(), "SPLIT")]: | |
| print(f"\n=== {label} ===") | |
| for t in tasks: | |
| print(" ", t) | |