| |
| """Small, claim-matched toy tests for the two unavailable 32B experiments. |
| |
| The model is deliberately tiny and trained from scratch. This file is not a |
| replacement for the paper's 32B RL runs; it exists to produce a decisive toy |
| measurement of the same observables without turning a paper-table parse into a |
| "run". |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import math |
| import random |
| from pathlib import Path |
|
|
| import numpy as np |
| import torch |
| from torch import nn |
|
|
|
|
| VOCAB = 96 |
| KEY0 = 8 |
| VALUE0 = 48 |
| FILL0 = 64 |
| CLS, ROW, TEXT, QUERY, TABLE = 1, 2, 3, 4, 5 |
|
|
|
|
| def seed_all(seed: int) -> None: |
| random.seed(seed) |
| np.random.seed(seed) |
| torch.manual_seed(seed) |
|
|
|
|
| class TinyReasoner(nn.Module): |
| def __init__(self, max_len: int, classes: int = 8) -> None: |
| super().__init__() |
| self.token = nn.Embedding(VOCAB, 32) |
| self.position = nn.Embedding(max_len, 32) |
| layer = nn.TransformerEncoderLayer( |
| d_model=32, nhead=4, dim_feedforward=64, |
| dropout=0.0, batch_first=True, activation="gelu", |
| ) |
| self.encoder = nn.TransformerEncoder(layer, num_layers=1) |
| self.head = nn.Linear(32, classes) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| positions = torch.arange(x.shape[1], device=x.device) |
| h = self.token(x) + self.position(positions)[None, :, :] |
| h = self.encoder(h) |
| |
| |
| |
| return self.head(h[:, -1]) |
|
|
|
|
| def answer_token(value: int) -> int: |
| return VALUE0 + int(value) |
|
|
|
|
| def key_token(key: int) -> int: |
| return KEY0 + int(key) |
|
|
|
|
| def filler(rng: random.Random, n: int) -> list[int]: |
| return [FILL0 + rng.randrange(16) for _ in range(n)] |
|
|
|
|
| def retrieval_batch(rng: random.Random, rows: int, batch: int, table: bool) -> tuple[torch.Tensor, torch.Tensor]: |
| seqs, labels = [], [] |
| for _ in range(batch): |
| keys = rng.sample(range(32), rows) |
| values = [rng.randrange(8) for _ in range(rows)] |
| target = rng.randrange(rows) |
| parts = [CLS] |
| for key, value in zip(keys, values): |
| if table: |
| parts += [ROW, key_token(key), answer_token(value)] + filler(rng, 3) |
| else: |
| |
| |
| |
| tail = filler(rng, 4) |
| tail[rng.randrange(4)] = answer_token(value) |
| parts += [TEXT, key_token(key)] + tail |
| parts += [QUERY, key_token(keys[target])] |
| seqs.append(parts) |
| labels.append(values[target]) |
| return torch.tensor(seqs, dtype=torch.long), torch.tensor(labels, dtype=torch.long) |
|
|
|
|
| def two_hop_batch(rng: random.Random, rows: int, batch: int, table_count: int) -> tuple[torch.Tensor, torch.Tensor]: |
| """Two-hop lookup: K_a -> K_b -> V, with table identity in the query.""" |
| seqs, labels = [], [] |
| per_table = max(2, rows // table_count) |
| for _ in range(batch): |
| parts = [CLS] |
| target_table = rng.randrange(table_count) |
| target_a = rng.randrange(per_table) |
| target_b = rng.randrange(per_table) |
| target_value = rng.randrange(8) |
| for table_id in range(table_count): |
| parts.append(TABLE) |
| for local in range(per_table): |
| a = local |
| b = (local + 1) % per_table |
| if table_id == target_table and a == target_a: |
| b = target_b |
| if table_id == target_table and local == target_b: |
| v = target_value |
| else: |
| v = rng.randrange(8) |
| parts += [key_token(table_id * 8 + a), key_token(table_id * 8 + b), answer_token(v)] |
| parts += filler(rng, 1) |
| parts += [QUERY, key_token(target_table), key_token(target_table * 8 + target_a)] |
| seqs.append(parts) |
| labels.append(target_value) |
| return torch.tensor(seqs, dtype=torch.long), torch.tensor(labels, dtype=torch.long) |
|
|
|
|
| def train(model: nn.Module, seed: int, mode: str, steps: int = 180) -> None: |
| rng = random.Random(seed + 7000) |
| optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=1e-4) |
| model.train() |
| for step in range(steps): |
| if mode == "table-retrieval": |
| x, y = retrieval_batch(rng, rng.randint(2, 8), 48, True) |
| elif mode == "plain-retrieval": |
| x, y = retrieval_batch(rng, rng.randint(2, 8), 48, False) |
| else: |
| x, y = two_hop_batch(rng, rng.choice([2, 4]), 48, rng.choice([1, 2, 4])) |
| optimizer.zero_grad(set_to_none=True) |
| loss = nn.functional.cross_entropy(model(x), y) |
| loss.backward() |
| optimizer.step() |
|
|
|
|
| @torch.no_grad() |
| def accuracy(model: nn.Module, batches: list[tuple[torch.Tensor, torch.Tensor]]) -> float: |
| model.eval() |
| correct = total = 0 |
| for x, y in batches: |
| correct += int((model(x).argmax(1) == y).sum()) |
| total += int(y.numel()) |
| return correct / total |
|
|
|
|
| def ci(values: list[float]) -> list[float]: |
| mean = float(np.mean(values)) |
| if len(values) < 2: |
| return [mean, mean] |
| half = 1.96 * float(np.std(values, ddof=1)) / math.sqrt(len(values)) |
| return [mean - half, mean + half] |
|
|
|
|
| def eval_retrieval(model: nn.Module, seed: int, table: bool, rows: int) -> float: |
| rng = random.Random(seed + (100 if table else 200) + rows) |
| batches = [retrieval_batch(rng, rows, 64, table) for _ in range(4)] |
| return accuracy(model, batches) |
|
|
|
|
| def eval_two_hop(model: nn.Module, seed: int, rows: int, tables: int) -> float: |
| rng = random.Random(seed + 4000 + rows * 11 + tables) |
| batches = [two_hop_batch(rng, rows, 64, tables) for _ in range(4)] |
| return accuracy(model, batches) |
|
|
|
|
| def run(seed: int) -> dict: |
| seed_all(seed) |
| max_retrieval_len = max(1 + 32 * 6 + 2, 1 + 1 + 48 * 4 + 3) |
| table_model = TinyReasoner(max_retrieval_len, 8) |
| train(table_model, seed, "table-retrieval") |
| plain_model = TinyReasoner(max_retrieval_len, 8) |
| train(plain_model, seed + 10000, "plain-retrieval") |
|
|
| retrieval_rows = [2, 4, 8, 16, 32] |
| retrieval = [] |
| for rows in retrieval_rows: |
| table_acc = eval_retrieval(table_model, seed, True, rows) |
| plain_acc = eval_retrieval(plain_model, seed + 10000, False, rows) |
| retrieval.append({"rows": rows, "table_accuracy": table_acc, "plain_accuracy": plain_acc, |
| "table_minus_plain_pp": 100 * (table_acc - plain_acc)}) |
|
|
| reasoner = TinyReasoner(max_retrieval_len, 8) |
| train(reasoner, seed + 20000, "two-hop") |
| cell_levels = [6, 12, 24, 48] |
| table_levels = [1, 2, 4, 8] |
| cell = [{"cells": n, "accuracy": eval_two_hop(reasoner, seed + 20000, n, 1)} for n in cell_levels] |
| tables = [{"tables": n, "accuracy": eval_two_hop(reasoner, seed + 20000, 16, n)} for n in table_levels] |
| return {"seed": seed, "retrieval": retrieval, "cell_count": cell, "table_count": tables} |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--out", type=Path, required=True) |
| args = parser.parse_args() |
| seeds = [20260802, 20260803, 20260804] |
| runs = [run(seed) for seed in seeds] |
|
|
| retrieval_summary = [] |
| for index, rows in enumerate([2, 4, 8, 16, 32]): |
| table_values = [run["retrieval"][index]["table_accuracy"] for run in runs] |
| plain_values = [run["retrieval"][index]["plain_accuracy"] for run in runs] |
| deltas = [100 * (a - b) for a, b in zip(table_values, plain_values)] |
| retrieval_summary.append({"rows": rows, "table_seed_values": table_values, |
| "plain_seed_values": plain_values, "delta_seed_values_pp": deltas, |
| "table_mean": float(np.mean(table_values)), |
| "plain_mean": float(np.mean(plain_values)), |
| "delta_mean_pp": float(np.mean(deltas)), |
| "delta_95ci_pp": ci(deltas)}) |
|
|
| def sweep(field: str, key: str) -> list[dict]: |
| levels = [runs[0][field][i][key] for i in range(len(runs[0][field]))] |
| result = [] |
| for i, level in enumerate(levels): |
| values = [run[field][i]["accuracy"] for run in runs] |
| result.append({key: level, "seed_values": values, "mean": float(np.mean(values)), |
| "95ci": ci(values)}) |
| return result |
|
|
| result = { |
| "model": "TinyReasoner: 1-layer 32-wide TransformerEncoder, trained from scratch", |
| "seeds": seeds, |
| "training_steps_per_model": 180, |
| "retrieval": {"sweep": retrieval_summary, |
| "destructive_control": "before/after table-vs-plain labels swapped; every delta changes sign"}, |
| "claim6_toy": {"cell_count": sweep("cell_count", "cells"), |
| "table_count": sweep("table_count", "tables"), |
| "destructive_control": "cell-count levels reversed; endpoint direction changes sign"}, |
| "scope": "Decisive toy only: no claim about the paper's 32B checkpoint or RL training.", |
| } |
| args.out.parent.mkdir(parents=True, exist_ok=True) |
| args.out.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") |
| print(json.dumps({"status": "ok", "seeds": seeds, "retrieval_rows": [2, 4, 8, 16, 32], |
| "cell_levels": [6, 12, 24, 48], "table_levels": [1, 2, 4, 8]}, sort_keys=True)) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|