| """Image classification over a 2-D lattice — the N-D machinery under load. |
| |
| python -m examples.repro.image_nd --model mamba_nd --epochs 5 |
| python -m examples.repro.image_nd --dataset cifar10 --model s4d_nd --epochs 10 |
| |
| Where `smnist.py` deliberately removes the lattice (a sequence is a lattice |
| with no spatial axes), this puts it back: an image is a 2-D lattice, the model |
| sweeps rows and columns with the schedule Mamba-ND describes, and no pixel is |
| ever flattened into a sequence. Same mixer, same library, one argument |
| different — which is the claim the whole project rests on. |
| |
| **On scale.** The papers this echoes train for hundreds of epochs on datacenter |
| GPUs. These runs are small and are reported as small: RESULTS.md carries the |
| epoch count, the wall clock, and the hardware, and the number is this |
| configuration's number. What is being reproduced here is the *construction* — |
| that the paired schedule over a 2-D lattice learns images — not a leaderboard |
| entry. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
|
|
| import torch |
| import torch.nn as nn |
|
|
| import torch_dimensions as td |
|
|
| from .data import cifar10, mnist |
| from .harness import Config, record, train_classifier |
|
|
| DATASETS = { |
| "mnist": (mnist, (28, 28), 1), |
| "cifar10": (cifar10, (32, 32), 3), |
| } |
|
|
|
|
| class LatticeClassifier(nn.Module): |
| """An N-D body, mean-pooled over the lattice, classified. |
| |
| Pooling over *present* cells only — on a dense image that is every cell, |
| but writing it the other way would silently break the moment someone hands |
| this a sparse lattice, and that is the case the library exists for. |
| """ |
|
|
| def __init__(self, body: nn.Module, lattice: td.Lattice, d_model: int, n_classes: int): |
| super().__init__() |
| self.body = body |
| self.lattice = lattice |
| self.register_buffer("cell_mask", lattice.mask(torch.float32), persistent=False) |
| self.norm = nn.LayerNorm(d_model) |
| self.head = nn.Linear(d_model, n_classes) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| h = self.body(x) * self.cell_mask |
| dims = tuple(range(1, h.ndim - 1)) |
| pooled = h.sum(dims) / self.cell_mask.sum().clamp_min(1.0) |
| return self.head(self.norm(pooled)) |
|
|
|
|
| def build_model(name: str, shape, d_input: int, d_model: int, n_layers: int, n_classes: int): |
| lattice = td.Lattice(shape=shape, names=("row", "col")) |
| kw = dict(d_model=d_model, lattice=lattice, d_input=d_input) |
| if name == "mamba_nd": |
| |
| |
| plan = td.ScanPlan.paired(("row", "col"), n_layers=n_layers, bidirectional=True) |
| body = td.Mamba(plan=plan, d_state=16, **kw) |
| elif name == "s4d_nd": |
| plan = td.ScanPlan.cyclic(("row", "col"), n_layers=n_layers, bidirectional=True) |
| body = td.S4D(plan=plan, **kw) |
| elif name == "lstm": |
| plan = td.ScanPlan.cyclic(("row", "col"), n_layers=n_layers, bidirectional=True) |
| body = td.LSTM(plan=plan, **kw) |
| else: |
| raise ValueError(f"unknown model {name!r}") |
| return LatticeClassifier(body, lattice, d_model, n_classes) |
|
|
|
|
| def build_data(dataset: str) -> dict[str, torch.Tensor]: |
| load, shape, channels = DATASETS[dataset] |
| raw = load() |
| out = {} |
| for split in ("train", "test"): |
| x = raw[f"{split}_x"].float() / 255.0 |
| x = x.reshape(-1, *shape, channels) |
| |
| if split == "train": |
| mean, std = x.mean((0, 1, 2)), x.std((0, 1, 2)).clamp_min(1e-6) |
| out[f"{split}_x"] = (x - mean) / std |
| out[f"{split}_y"] = raw[f"{split}_y"].long() |
| return out |
|
|
|
|
| def main() -> None: |
| ap = argparse.ArgumentParser(description=__doc__) |
| ap.add_argument("--dataset", choices=sorted(DATASETS), default="mnist") |
| ap.add_argument("--model", choices=["mamba_nd", "s4d_nd", "lstm"], default="mamba_nd") |
| ap.add_argument("--epochs", type=int, default=5) |
| ap.add_argument("--batch", type=int, default=64) |
| ap.add_argument("--d-model", type=int, default=64) |
| ap.add_argument("--n-layers", type=int, default=4) |
| ap.add_argument("--lr", type=float, default=3e-3) |
| ap.add_argument("--seed", type=int, default=0) |
| ap.add_argument("--device", default="auto") |
| ap.add_argument("--limit-train", type=int, default=None) |
| ap.add_argument("--dry-run", action="store_true") |
| args = ap.parse_args() |
|
|
| _, shape, channels = DATASETS[args.dataset] |
| data = build_data(args.dataset) |
|
|
| def build() -> nn.Module: |
| return build_model(args.model, shape, channels, args.d_model, args.n_layers, 10) |
|
|
| cfg = Config( |
| name=f"{args.dataset} 2-D · {args.model}", |
| task=f"{args.dataset} (2-D lattice {shape[0]}×{shape[1]})", |
| model=f"td.{args.model} d_model={args.d_model} n_layers={args.n_layers}", |
| epochs=args.epochs, |
| batch=args.batch, |
| lr=args.lr, |
| seed=args.seed, |
| device=args.device, |
| limit_train=args.limit_train, |
| extra={"dataset": args.dataset, "arch": args.model, "n_layers": args.n_layers}, |
| ) |
|
|
| if args.dry_run: |
| import time |
|
|
| from .harness import pick_device, sync |
|
|
| device = pick_device(args.device) |
| model = build().to(device) |
| xb = data["train_x"][: args.batch].to(device) |
| yb = data["train_y"][: args.batch].to(device) |
| for _ in range(2): |
| nn.functional.cross_entropy(model(xb), yb).backward() |
| sync(device) |
| t0 = time.time() |
| nn.functional.cross_entropy(model(xb), yb).backward() |
| sync(device) |
| per_step = time.time() - t0 |
| n = sum(p.numel() for p in model.parameters()) |
| steps = (data["train_x"].shape[0] + args.batch - 1) // args.batch |
| print( |
| f"{n:,} params on {device}; {per_step:.2f}s/step " |
| f"-> {per_step * steps / 60:.1f} min/epoch" |
| ) |
| return |
|
|
| record(train_classifier(build, data, cfg)) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|