Prizma / seq /tasks.py
AylinMaylinn's picture
Publish Prizma — mirror of github.com/nazmiefearmutcu/Prizma (PRISM-Seq §4 bar + continual-learning Prizma)
5066d15 verified
Raw
History Blame Contribute Delete
10.6 kB
"""
Canonical attention-diagnostic synthetic tasks — the suite the field uses to decide whether a
non-attention architecture can really stand in for a Transformer.
All tasks share one causal next-token interface:
sample(batch, device) -> (inputs[B,T] long, targets[B,T] long, mask[B,T] {0,1} float)
Loss/accuracy are computed only on masked positions. Token id 0 is a reserved filler/pad.
* MQAR Multi-Query Associative Recall (Arora et al. 2023, Zoology/Based). THE test that
separates real attention-alternatives from impostors: in-context key->value
lookup for many queries, over a gap. Linear models with fixed state struggle as
#pairs grows; attention solves it trivially.
* AssocRecall single-query AR (Ba et al.; Hyena) — easier sanity version of MQAR.
* SelectiveCopy Mamba's selective-copy: copy the data tokens in order, ignoring interspersed
fillers. Requires INPUT-DEPENDENT gating (content-selective memory).
* Induction in-context induction-head probe: [.. A B .. A] -> predict B. The ICL primitive.
"""
from __future__ import annotations
import torch
def _distinct_per_row(B, n, lo, hi, device):
"""n distinct ints in [lo,hi) for each of B rows -> (B,n) long. hi-lo must be >= n."""
span = hi - lo
perm = torch.argsort(torch.rand(B, span, device=device), dim=1)[:, :n]
return perm + lo
class MQAR:
"""Multi-Query Associative Recall (Zoology/Based standard, dense). vocab in [1,V); 0 filler.
Sequence = [k0 v0 ... k_{D-1} v_{D-1}] (+optional filler gap) [q_1 q_2 ... q_M], where each
query q is one of the bound keys. The TARGET at each query position is that key's bound value
(NOT a next token — the answer never appears in the input), masked to query positions only.
Dense queries give rich supervision; capacity is set by the #bindings D (the B1b axis)."""
def __init__(self, vocab=64, num_pairs=8, num_queries=None, gap=0, key_frac=0.5):
# DISJOINT token ranges: keys in [1, n_key), values in [n_key, vocab). This removes
# key/value collisions so recall is unambiguous and the capacity ceiling (B1b) is clean.
self.n_key = max(num_pairs + 1, int(vocab * key_frac))
assert self.n_key - 1 >= num_pairs, "need >= num_pairs distinct keys in the key range"
assert vocab - self.n_key >= 2, "need a value range"
self.vocab = vocab
self.D = num_pairs
self.M = num_queries if num_queries is not None else max(2 * num_pairs, 8)
self.gap = gap
self.seq_len = 2 * num_pairs + gap + self.M
self.name = f"MQAR(V={vocab},keys<{self.n_key},pairs={num_pairs},q={self.M},gap={gap})"
def sample(self, B, device):
D, M, V = self.D, self.M, self.vocab
keys = _distinct_per_row(B, D, 1, self.n_key, device) # (B,D) distinct keys
vals = torch.randint(self.n_key, V, (B, D), device=device) # (B,D) values (disjoint range)
ctx = torch.empty(B, 2 * D, dtype=torch.long, device=device)
ctx[:, 0::2] = keys
ctx[:, 1::2] = vals
qi = torch.randint(0, D, (B, M), device=device) # which binding each query hits
qk = torch.gather(keys, 1, qi) # query keys (the input)
qa = torch.gather(vals, 1, qi) # bound values (the target)
if self.gap > 0:
filler = torch.zeros(B, self.gap, dtype=torch.long, device=device)
inp = torch.cat([ctx, filler, qk], dim=1)
else:
inp = torch.cat([ctx, qk], dim=1)
T = inp.shape[1]
tgt = torch.zeros(B, T, dtype=torch.long, device=device)
mask = torch.zeros(B, T, device=device)
qstart = 2 * D + self.gap
tgt[:, qstart:] = qa # recall target at each query pos
mask[:, qstart:] = 1.0
return inp, tgt, mask
class AssocRecall:
"""Single-query associative recall: many KV pairs, one query at the end."""
def __init__(self, vocab=64, num_pairs=16, gap=0):
self.inner = MQAR(vocab=vocab, num_pairs=num_pairs, num_queries=1, gap=gap)
self.vocab = vocab
self.seq_len = self.inner.seq_len
self.name = f"AssocRecall(V={vocab},pairs={num_pairs},gap={gap})"
def sample(self, B, device):
return self.inner.sample(B, device)
class SelectiveCopy:
"""Copy the K data tokens (in order) out of an L-slot memory region full of fillers.
vocab: filler=0, data in [1, vocab-1), marker=vocab-1.
input = [memory(L) with K data tokens scattered, marker, d1, d2, ..., dK]
predict d1..dK at positions marker..d_{K-1} (next-token), masked there."""
def __init__(self, vocab=32, mem_len=64, n_data=16, fixed=False):
assert n_data <= mem_len
self.vocab = vocab
self.L = mem_len
self.K = n_data
self.fixed = fixed # True -> data at fixed evenly-spaced positions (control variant)
self.marker = vocab - 1
self.seq_len = mem_len + 1 + n_data
self.name = f"SelectiveCopy(V={vocab},mem={mem_len},k={n_data},{'fixed' if fixed else 'selective'})"
def sample(self, B, device):
L, K, V = self.L, self.K, self.vocab
mem = torch.zeros(B, L, dtype=torch.long, device=device) # fillers
if self.fixed:
base = torch.linspace(0, L - 1, K, device=device).long() # fixed spacing (control)
pos = base[None].expand(B, K).clone()
else:
pos = torch.argsort(torch.rand(B, L, device=device), dim=1)[:, :K] # K random slots/row
pos, _ = torch.sort(pos, dim=1) # left-to-right order
data = torch.randint(1, V - 1, (B, K), device=device) # data tokens
mem.scatter_(1, pos, data)
marker = torch.full((B, 1), self.marker, dtype=torch.long, device=device)
inp = torch.cat([mem, marker, data], dim=1) # teacher-forced output
T = inp.shape[1]
tgt = torch.zeros_like(inp)
tgt[:, :-1] = inp[:, 1:]
mask = torch.zeros(B, T, device=device)
mask[:, L:L + K] = 1.0 # positions: marker (predict d1) .. d_{K-1} (predict dK)
return inp, tgt, mask
class Induction:
"""In-context induction: prefix contains a unique bigram [A,B]; the final token is A; predict B.
vocab tokens in [1, vocab); 0 filler. mask=1 only at the final position."""
def __init__(self, vocab=32, seq_len=64):
self.vocab = vocab
self.seq_len = seq_len + 1 # +1 for the trailing query A
self.name = f"Induction(V={vocab},len={seq_len})"
self._L = seq_len
def sample(self, B, device):
L, V = self._L, self.vocab
seq = torch.randint(1, V, (B, L), device=device)
A = torch.randint(1, V, (B,), device=device)
B_tok = torch.randint(1, V, (B,), device=device)
# ensure B != A so the answer is informative
clash = B_tok == A
B_tok[clash] = (B_tok[clash] % (V - 1)) + 1
B_tok[B_tok == A] = (A[B_tok == A] % (V - 1)) + 1
# remove any existing A from the prefix (so A is unique once we place the bigram)
seq[seq == A[:, None]] = 0
# also keep the bigram-following slot clean: place [A,B] at a random early position
ppos = torch.randint(0, L - 2, (B,), device=device)
ar = torch.arange(B, device=device)
seq[ar, ppos] = A
seq[ar, ppos + 1] = B_tok
# any filler 0 left from removal -> replace with a token guaranteed != A
filler = (A % (V - 1)) + 1
zero = seq == 0
seq = torch.where(zero, filler[:, None].expand_as(seq), seq)
# but that replacement might have re-introduced A if filler==A (filler!=A by construction)
# re-place bigram in case a filler overwrote it (positions fixed, so re-assert)
seq[ar, ppos] = A
seq[ar, ppos + 1] = B_tok
query = A[:, None]
inp = torch.cat([seq, query], dim=1) # (B, L+1)
T = inp.shape[1]
tgt = torch.zeros(B, T, dtype=torch.long, device=device)
tgt[:, -1] = B_tok
mask = torch.zeros(B, T, device=device)
mask[:, -1] = 1.0
return inp, tgt, mask
class MixedMQAR:
"""MQAR trained over a SPECTRUM of difficulties: each training batch samples the number of KV
pairs d ~ U[min_pairs, max_pairs] (the standard Zoology training distribution). The easy
instances supply the gradient that BOOTSTRAPS the recall circuit, so high-D recall becomes
learnable in feasible compute — where FIXED-high-D training stalls at chance (no foothold for the
sharp phase transition). EVALUATION is fixed at the target (max_pairs), so the reported number is
target-D recall. Identical for both models -> fair. n_key is stable across d (= vocab*key_frac
while d < that), so key/value ranges don't shift with difficulty."""
def __init__(self, vocab=256, max_pairs=64, num_queries=128, gap=0, min_pairs=1):
self.vocab = vocab
self.max_pairs = max_pairs
self.M = num_queries
self.gap = gap
self.min_pairs = min_pairs
self.seq_len = 2 * max_pairs + gap + num_queries # max layout (for max_len sizing)
self.name = f"MixedMQAR(V={vocab},pairs={min_pairs}-{max_pairs},q={num_queries},gap={gap})"
def _mqar(self, d):
return MQAR(vocab=self.vocab, num_pairs=d, num_queries=self.M, gap=self.gap)
def sample(self, B, device): # TRAINING: a random difficulty per batch
# Sample the scalar difficulty on CPU: distribution-identical (U[min,max]) but avoids a
# per-step GPU->CPU sync that serializes the (tiny) model on CUDA. Batch tensors below
# are still generated on `device`.
d = int(torch.randint(self.min_pairs, self.max_pairs + 1, (1,)).item())
return self._mqar(d).sample(B, device)
def eval_sample(self, B, device): # EVAL: fixed at the TARGET difficulty (max_pairs)
return self._mqar(self.max_pairs).sample(B, device)
TASK_REGISTRY = {
"mqar": MQAR,
"mixed_mqar": MixedMQAR,
"assoc": AssocRecall,
"selcopy": SelectiveCopy,
"induction": Induction,
}
if __name__ == "__main__":
dev = torch.device("cpu")
for name, cls in TASK_REGISTRY.items():
t = cls()
x, y, m = t.sample(4, dev)
print(f"{t.name:<40} x{tuple(x.shape)} masked={int(m.sum().item())}")
# sanity: an oracle that knows the mapping would score 1.0; random ~1/vocab