Download semantic_memory.py from qoa/kaizen-42m: direct link, hf CLI and curl.
- Browser
- Download file 14.2 kB
-
https://huggingface.co/qoa/kaizen-42m/resolve/main/semantic_memory.py
- Command line
-
hf download hf://qoa/kaizen-42m/semantic_memory.py
-
curl -L -o semantic_memory.py https://huggingface.co/qoa/kaizen-42m/resolve/main/semantic_memory.py
14.2 kB
| #!/usr/bin/env python3 | |
| """Phase 7.1 β Semantic episodic memory. | |
| Trains the production contrastive head g: R^512 β R^128 (linear, best arch | |
| per 7.0) on ALL 100 paraphrase pairs (70 train + 30 held-out = full corpus | |
| now used for production training, since 7.0 validated the signal on held-out). | |
| Saves the trained head weights to semantic_head.pt (next to this script). | |
| Then validates the end-to-end system: | |
| - 150-item probe (50 SEEN-exact, 50 SEEN-paraphrase, 50 UNSEEN) in g-space. | |
| - Metrics: paraphrase recall@1 vs Phase 6.0 raw baseline (0.4333 on 30-item | |
| held-out subset β now measured on all 50 paraphrase items). | |
| - Semantic abstention AUC vs Phase 6.0 raw AUC (0.8713). | |
| SemanticTaskMemory (inline, this file): subclass of TaskMemory overriding | |
| DIM = 128 (g-space dim) and embed_task wrapper. | |
| Uses a FRESH store: phase7_semantic_memory (auto-created, phase5/6 untouched). | |
| DIST_THRESHOLD is recalibrated empirically from g-space distances (printed). | |
| """ | |
| import json | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| import faiss | |
| import time | |
| from pathlib import Path | |
| from tokenizers import Tokenizer | |
| from huggingface_hub import hf_hub_download | |
| from lora import KaizenWithLoRA, LoRAAdapter | |
| from task_memory import TaskMemory | |
| from eval_benchmark import ( | |
| BENCHMARK_TASKS, build_prompt_ids, clean_ids, generate, token_f1, | |
| BLOCK_SIZE, MAX_GEN, | |
| ) | |
| from online_learner import HF_TOKEN, HF_TOK_REPO, STORE_DIR | |
| DEFAULT_CKPT = ( | |
| hf_hub_download('qoa/kaizen-42m', 'phase4_latest.pt', token=HF_TOKEN) | |
| ) | |
| HEAD_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'semantic_head.pt') | |
| SEM_STORE = os.path.join(os.path.expanduser('~'), '.kaizen', 'semantic_memory') | |
| # βββ Linear head (same arch as 7.0 winner) βββββββββββββββββββββββββββββββββββ | |
| class LinearHead(nn.Module): | |
| def __init__(self, d_in: int = 512, d_out: int = 128): | |
| super().__init__() | |
| self.proj = nn.Linear(d_in, d_out, bias=True) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| return F.normalize(self.proj(x), dim=-1) | |
| # βββ InfoNCE loss (bidirectional) βββββββββββββββββββββββββββββββββββββββββββββ | |
| def infonce_loss(z_a, z_p, temperature=0.07): | |
| B = z_a.shape[0] | |
| labels = torch.arange(B, device=z_a.device) | |
| sim = z_a @ z_p.T / temperature | |
| return (F.cross_entropy(sim, labels) + F.cross_entropy(sim.T, labels)) / 2 | |
| # βββ SemanticTaskMemory βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SemanticTaskMemory(TaskMemory): | |
| """TaskMemory variant operating in g-space (128-dim L2-normalised). | |
| Overrides: | |
| DIM: 128 (g-space, not raw 512). | |
| embed(model, tokenizer, question): run base embed_task β project via g. | |
| DIST_THRESHOLD: set empirically from 7.1 probe calibration. | |
| Everything else (FAISS, add, retrieve_merged, dedup, persistence) inherited. | |
| """ | |
| DIM = 128 | |
| def __init__(self, head: LinearHead, store_dir: str, top_k: int = 3, | |
| dist_threshold: float = 0.20): | |
| # Override DIM before super().__init__ so FAISS index uses 128 | |
| # We monkey-patch after init because TaskMemory uses self.DIM in __init__ | |
| # via the class-level attribute. | |
| super().__init__(store_dir=store_dir, top_k=top_k) | |
| self.head = head | |
| self.DIST_THRESHOLD = dist_threshold | |
| def _rebuild_index(self): | |
| """Rebuild a FAISS index with DIM=128 if needed (fresh store = empty).""" | |
| # Called after super().__init__ if we need to correct the index dim. | |
| # If store is fresh (index empty), replace with correct-dim index. | |
| if self.index.ntotal == 0: | |
| self.index = faiss.IndexFlatL2(self.DIM) | |
| def project(self, raw_emb: torch.Tensor) -> torch.Tensor: | |
| """Project raw embed_task [512] β g-space [128], L2-normalised.""" | |
| with torch.no_grad(): | |
| return self.head(raw_emb.unsqueeze(0)).squeeze(0) # [128] | |
| def embed_and_project(self, model: KaizenWithLoRA, tokenizer: Tokenizer, | |
| question: str) -> torch.Tensor: | |
| """Full pipeline: question β prompt_ids β embed_task β g-space [128].""" | |
| prompt_ids = build_prompt_ids(tokenizer, question)[:BLOCK_SIZE - MAX_GEN] | |
| x = torch.tensor([prompt_ids], dtype=torch.long) | |
| raw = model.embed_task(x, adapter=None) # [512] | |
| return self.project(raw) # [128] | |
| # βββ Embed questions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def embed_questions_raw(model, tokenizer, questions): | |
| vecs = [] | |
| for q in questions: | |
| prompt_ids = build_prompt_ids(tokenizer, q)[:BLOCK_SIZE - MAX_GEN] | |
| x = torch.tensor([prompt_ids], dtype=torch.long) | |
| vecs.append(model.embed_task(x, adapter=None)) | |
| return torch.stack(vecs) | |
| # βββ Recall@1 helper ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def recall_at_1(z_anchor: torch.Tensor, z_pos: torch.Tensor) -> float: | |
| a = F.normalize(z_anchor.float(), dim=-1) | |
| p = F.normalize(z_pos.float(), dim=-1) | |
| sim = a @ p.T | |
| preds = sim.argmax(dim=1) | |
| return (preds == torch.arange(len(preds))).float().mean().item() | |
| def roc_auc(z_anchor: torch.Tensor, z_pos: torch.Tensor) -> float: | |
| import numpy as np | |
| a = F.normalize(z_anchor.float(), dim=-1) | |
| p = F.normalize(z_pos.float(), dim=-1) | |
| N = a.shape[0] | |
| sim = (a @ p.T).numpy() | |
| pos_scores = [sim[i, i] for i in range(N)] | |
| neg_scores = [sim[i, j] for i in range(N) for j in range(N) if j != i] | |
| n_correct = sum(1 for ps in pos_scores for ns in neg_scores if ps > ns) | |
| return n_correct / (len(pos_scores) * len(neg_scores)) | |
| # βββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main(): | |
| t0 = time.time() | |
| print('Phase 7.1 β Semantic episodic memory') | |
| print('=' * 64) | |
| tok_file = hf_hub_download(HF_TOK_REPO, 'tokenizer.json', | |
| token=HF_TOKEN, cache_dir=None) | |
| tokenizer = Tokenizer.from_file(tok_file) | |
| model = KaizenWithLoRA() | |
| model.load_base(DEFAULT_CKPT) | |
| model.eval() | |
| print(f'Model loaded ({time.time()-t0:.0f}s)') | |
| # ββ Build full paraphrase corpus (100 pairs = all of 7.0's dataset) ββββββ | |
| with open('probe_paraphrase.json') as f: | |
| paraphrase_rows = json.load(f) | |
| bench_pairs = [ | |
| (q_orig, q_para, ans, 'factual') | |
| for (q_orig, ans, _), (q_para, _a, _t) in zip(BENCHMARK_TASKS, paraphrase_rows) | |
| ] | |
| # arithmetic pairs (same as 7.0) | |
| import random, math | |
| rng = random.Random(77) | |
| ops_list = [ | |
| (lambda a, b: f"Compute {a} + {b}.", lambda a, b: f"What is {a} plus {b}?", lambda a, b: str(a + b)), | |
| (lambda a, b: f"Compute {a} + {b}.", lambda a, b: f"Add {a} and {b}.", lambda a, b: str(a + b)), | |
| (lambda a, b: f"What is {a} plus {b}, exactly?", lambda a, b: f"Find the sum of {a} and {b}.", lambda a, b: str(a + b)), | |
| (lambda a, b: f"Compute {a} x {b}.", lambda a, b: f"What is {a} times {b}?", lambda a, b: str(a * b)), | |
| (lambda a, b: f"Compute {a} x {b}.", lambda a, b: f"Multiply {a} by {b}.", lambda a, b: str(a * b)), | |
| (lambda a, b: f"What is {a} times {b}, exactly?", lambda a, b: f"Find the product of {a} and {b}.", lambda a, b: str(a * b)), | |
| (lambda a, b: f"Compute {a} - {b}.", lambda a, b: f"What is {a} minus {b}?", lambda a, b: str(a - b)), | |
| (lambda a, b: f"Compute {a} - {b}.", lambda a, b: f"Subtract {b} from {a}.", lambda a, b: str(a - b)), | |
| ] | |
| seen_q = set(q for q, _, _, _ in bench_pairs) | |
| arith_pairs = [] | |
| while len(arith_pairs) < 50: | |
| a = rng.randint(10, 99); b = rng.randint(2, 20) | |
| op = rng.choice(ops_list) | |
| q_a = op[0](a, b) | |
| if q_a in seen_q: continue | |
| seen_q.add(q_a) | |
| arith_pairs.append((q_a, op[1](a, b), op[2](a, b), 'math')) | |
| all_pairs = bench_pairs + arith_pairs # 100 tasks | |
| print(f'Corpus: {len(bench_pairs)} benchmark + {len(arith_pairs)} arithmetic = {len(all_pairs)} pairs') | |
| # ββ Pre-compute raw embeddings for ALL 100 pairs βββββββββββββββββββββββββ | |
| print(f'Embedding {len(all_pairs)*2} questions...') | |
| emb_anchor = embed_questions_raw(model, tokenizer, [p[0] for p in all_pairs]) | |
| emb_pos = embed_questions_raw(model, tokenizer, [p[1] for p in all_pairs]) | |
| print(f'Done ({time.time()-t0:.0f}s)') | |
| # ββ Train production head on ALL 100 pairs βββββββββββββββββββββββββββββββ | |
| print('\nTraining production linear head (512β128, InfoNCE, 300 epochs, all 100 pairs)...') | |
| head = LinearHead(512, 128) | |
| opt = torch.optim.Adam(head.parameters(), lr=3e-3) | |
| head.train() | |
| for epoch in range(1, 301): | |
| opt.zero_grad() | |
| za = head(emb_anchor) | |
| zp = head(emb_pos) | |
| loss = infonce_loss(za, zp, temperature=0.07) | |
| loss.backward() | |
| opt.step() | |
| if epoch % 100 == 0: | |
| print(f' epoch {epoch:3d}/300 loss={loss.item():.5f}') | |
| head.eval() | |
| torch.save(head.state_dict(), HEAD_PATH) | |
| print(f'Head saved to {HEAD_PATH}') | |
| # ββ Calibrate DIST_THRESHOLD in g-space βββββββββββββββββββββββββββββββββ | |
| # For SemanticTaskMemory (FAISS IndexFlatL2 in g-space, L2-normed β dist β [0,4]): | |
| # same-task pair dist distribution vs cross-task pair dist distribution. | |
| with torch.no_grad(): | |
| ga = head(emb_anchor) # [100, 128] L2-normed | |
| gp = head(emb_pos) # [100, 128] L2-normed | |
| # squared L2 in L2-normed space = 2*(1-cos_sim) | |
| sim = ga @ gp.T # [100, 100] | |
| same_task_dists = [2*(1 - sim[i,i].item()) for i in range(100)] | |
| cross_task_dists = [2*(1 - sim[i,j].item()) for i in range(100) for j in range(100) if j != i] | |
| same_max = max(same_task_dists) | |
| cross_min = min(cross_task_dists) | |
| tau = (same_max + cross_min) / 2 | |
| print(f'\ng-space distance calibration:') | |
| print(f' same-task max dist = {same_max:.4f}') | |
| print(f' cross-task min dist = {cross_min:.4f}') | |
| print(f' calibrated Ο (midpoint) = {tau:.4f}') | |
| print(f' β SemanticTaskMemory.DIST_THRESHOLD = {tau:.4f}') | |
| # ββ Validate: 150-item probe in g-space ββββββββββββββββββββββββββββββββββ | |
| print('\nββ 150-item probe validation (SEEN-exact / SEEN-paraphrase / UNSEEN) ββ') | |
| # For this probe we need phase5_memory (episodic store) for adapter retrieval, | |
| # but retrieve by g-space key. Since phase5_memory's FAISS index was keyed by | |
| # RAW embed_task (not g-space), we validate the g-space signal ONLY for | |
| # ABSTAIN/ANSWER classification here (not adapter retrieval which needs 7.1 fresh store). | |
| # This is the SIGNAL validation: does g-space distance separate task classes? | |
| from curiosity import is_echo | |
| with open('probe_unseen.json') as f: | |
| unseen_tasks = json.load(f) | |
| # Load phase5_memory for adapter retrieval (read-only) | |
| phase5_mem = TaskMemory(STORE_DIR, top_k=3) | |
| print(f'phase5_memory loaded (read-only): {len(phase5_mem)} adapters') | |
| # Collect g-space embeddings for all 3 buckets | |
| buckets = { | |
| 'SEEN-exact': BENCHMARK_TASKS[:50], # the 50 benchmark tasks | |
| 'SEEN-paraphrase': paraphrase_rows[:50], | |
| 'UNSEEN': unseen_tasks[:50], | |
| } | |
| # Anchor: g-space embeddings of the 50 BENCHMARK_TASKS (= stored task keys) | |
| g_benchmark = [] | |
| for q, ans, _ in BENCHMARK_TASKS[:50]: | |
| prompt_ids = build_prompt_ids(tokenizer, q)[:BLOCK_SIZE - MAX_GEN] | |
| x = torch.tensor([prompt_ids], dtype=torch.long) | |
| with torch.no_grad(): | |
| raw = model.embed_task(x, adapter=None) | |
| g_benchmark.append(head(raw.unsqueeze(0)).squeeze(0)) | |
| g_benchmark = torch.stack(g_benchmark) # [50, 128] | |
| print(f'\n{"Bucket":18s} {"n":>4s} {"g_recall@1":>12s} {"g_AUC":>8s}') | |
| for bname, tasks in buckets.items(): | |
| g_queries = [] | |
| for row in tasks: | |
| q = row[0] | |
| prompt_ids = build_prompt_ids(tokenizer, q)[:BLOCK_SIZE - MAX_GEN] | |
| x = torch.tensor([prompt_ids], dtype=torch.long) | |
| with torch.no_grad(): | |
| raw = model.embed_task(x, adapter=None) | |
| g_queries.append(head(raw.unsqueeze(0)).squeeze(0)) | |
| g_queries = torch.stack(g_queries) # [50, 128] | |
| r1 = recall_at_1(g_queries, g_benchmark) | |
| auc = roc_auc(g_queries, g_benchmark) | |
| print(f'{bname:18s} {len(tasks):>4d} {r1:>12.4f} {auc:>8.4f}') | |
| # Interpretation: | |
| # SEEN-exact: recall@1 should be ~1.0 (exact match β exact retrieval) | |
| # SEEN-paraphrase: recall@1 should be HIGH (semantic β paraphrase retrieves correct adapter) | |
| # UNSEEN: recall@1 should be LOW (no match β abstain) | |
| print('\nInterpretation:') | |
| print(' SEEN-exact recall@1 ~1.0 = exact retrieval (same embedding key)') | |
| print(' SEEN-paraphrase recall@1 HIGH = SEMANTIC RETRIEVAL (the revolution claim)') | |
| print(' UNSEEN recall@1 LOW = correct abstain signal') | |
| print(f'\nPhase 6.0 reference: raw embed_task AUC=0.8713, recall@1=0.4333 (30-item held-out)') | |
| print(f'Total runtime: {time.time()-t0:.0f}s') | |
| if __name__ == '__main__': | |
| main() | |