|
| 1 | +# /// script |
| 2 | +# requires-python = ">=3.10" |
| 3 | +# dependencies = [ |
| 4 | +# "xarray_sql", |
| 5 | +# "xarray", |
| 6 | +# "numpy", |
| 7 | +# "pyarrow", |
| 8 | +# ] |
| 9 | +# |
| 10 | +# [tool.uv.sources] |
| 11 | +# xarray_sql = { path = "..", editable = true } |
| 12 | +# /// |
| 13 | +"""Train an MNIST MLP classifier in SQL. |
| 14 | +
|
| 15 | +A one-hidden-layer neural network (196->32 tanh->10 softmax, on 2x2-pooled |
| 16 | +14x14 = 196-pixel images) trained by gradient descent where **every gradient is |
| 17 | +computed in SQL**. The MNIST images are registered as xarray (the library's |
| 18 | +core); the model weights and per-step intermediates live in DataFusion |
| 19 | +in-memory tables. The optimisation loop is plain Python; all the math is |
| 20 | +relational. |
| 21 | +
|
| 22 | +The design is reverse-mode autodiff expressed in relational algebra: |
| 23 | +
|
| 24 | +* **matmul = join + GROUP BY SUM.** A layer's pre-activation is |
| 25 | + ``SUM(input * weight)`` grouped by (sample, unit), joining the data table to a |
| 26 | + weight table on the shared index. |
| 27 | +* **local derivatives = grad().** The hidden activation's Jacobian is |
| 28 | + ``grad(tanh(z), z)`` — the engine differentiates the nonlinearity for us, |
| 29 | + evaluated per (sample, unit). This is where the autograd feature does its |
| 30 | + work; the rest is ordinary SQL. |
| 31 | +* **cotangent propagation = join.** The output error is pushed back through the |
| 32 | + second weight matrix by another join + SUM, then multiplied by the local |
| 33 | + ``grad`` factor to get the hidden-layer error. |
| 34 | +* **parameter gradients = join + GROUP BY AVG.** ``dW = AVG(input * delta)`` |
| 35 | + grouped by the weight's indices. |
| 36 | +
|
| 37 | +The only hand-written gradient is softmax + cross-entropy's ``delta = softmax - |
| 38 | +onehot`` (softmax couples classes through a per-sample normaliser, an aggregate |
| 39 | +``grad`` does not cross — staying faithful to SQL). Everything else is grad and |
| 40 | +joins. |
| 41 | +
|
| 42 | +Run standalone (builds the local extension on first use): |
| 43 | +
|
| 44 | + uv run benchmarks/mnist_mlp.py |
| 45 | +""" |
| 46 | + |
| 47 | +from __future__ import annotations |
| 48 | + |
| 49 | +import gzip |
| 50 | +import struct |
| 51 | +import tempfile |
| 52 | +import time |
| 53 | +import urllib.request |
| 54 | +from pathlib import Path |
| 55 | + |
| 56 | +import numpy as np |
| 57 | +import pyarrow as pa |
| 58 | +import xarray as xr |
| 59 | + |
| 60 | +import xarray_sql as xql |
| 61 | + |
| 62 | +MIRROR = "https://storage.googleapis.com/cvdf-datasets/mnist" |
| 63 | +CACHE = Path(tempfile.gettempdir()) / "mnist-xql" |
| 64 | + |
| 65 | +# Network dimensions: 14x14 pooled pixels -> 32 hidden (tanh) -> 10 classes. |
| 66 | +N_TRAIN, N_TEST, N_PIX, N_HID, N_CLS = 1000, 500, 196, 32, 10 |
| 67 | + |
| 68 | + |
| 69 | +def _download(url: str, dest: Path, tries: int = 5) -> None: |
| 70 | + """Fetch a URL to dest, reading the whole body (retries on truncation).""" |
| 71 | + last = None |
| 72 | + for attempt in range(tries): |
| 73 | + try: |
| 74 | + with urllib.request.urlopen(url, timeout=120) as resp: |
| 75 | + data = resp.read() |
| 76 | + if len(data) < 1024: |
| 77 | + raise OSError(f"suspiciously small download: {len(data)} bytes") |
| 78 | + dest.write_bytes(data) |
| 79 | + return |
| 80 | + except Exception as exc: # noqa: BLE001 - retry any transient failure |
| 81 | + last = exc |
| 82 | + raise OSError(f"failed to download {url}: {last}") |
| 83 | + |
| 84 | + |
| 85 | +def _read_idx(path: Path) -> np.ndarray: |
| 86 | + with gzip.open(path, "rb") as f: |
| 87 | + (magic,) = struct.unpack(">I", f.read(4)) |
| 88 | + if magic == 2051: # images |
| 89 | + n, r, c = struct.unpack(">III", f.read(12)) |
| 90 | + return np.frombuffer(f.read(), np.uint8).reshape(n, r, c) |
| 91 | + (n,) = struct.unpack(">I", f.read(4)) # labels |
| 92 | + return np.frombuffer(f.read(), np.uint8) |
| 93 | + |
| 94 | + |
| 95 | +def load_mnist(): |
| 96 | + """Download (and cache) MNIST, 2x2 mean-pool to 14x14, subsample.""" |
| 97 | + CACHE.mkdir(exist_ok=True) |
| 98 | + files = { |
| 99 | + "images": "train-images-idx3-ubyte.gz", |
| 100 | + "labels": "train-labels-idx1-ubyte.gz", |
| 101 | + } |
| 102 | + paths = {} |
| 103 | + for key, name in files.items(): |
| 104 | + dest = CACHE / name |
| 105 | + if not dest.exists(): |
| 106 | + _download(f"{MIRROR}/{name}", dest) |
| 107 | + paths[key] = dest |
| 108 | + |
| 109 | + imgs = _read_idx(paths["images"]).astype(np.float32) / 255.0 |
| 110 | + labs = _read_idx(paths["labels"]).astype(np.int64) |
| 111 | + pooled = imgs.reshape(-1, 14, 2, 14, 2).mean(axis=(2, 4)).reshape(-1, N_PIX) |
| 112 | + |
| 113 | + rng = np.random.default_rng(0) |
| 114 | + idx = rng.permutation(len(pooled)) |
| 115 | + tr, te = idx[:N_TRAIN], idx[N_TRAIN : N_TRAIN + N_TEST] |
| 116 | + return pooled[tr], labs[tr], pooled[te], labs[te] |
| 117 | + |
| 118 | + |
| 119 | +class SqlTables: |
| 120 | + """Model parameters and intermediates as DataFusion in-memory tables. |
| 121 | +
|
| 122 | + The MNIST data stays registered as xarray (the library's core); the model |
| 123 | + weights and the per-step intermediate results (hidden activations, errors) |
| 124 | + are plain in-memory tables, rebuilt from Arrow each step. Matrices are stored |
| 125 | + in long form — a weight ``W[i, j]`` is a row ``(i, j, w)`` — so a matmul is a |
| 126 | + join + ``GROUP BY``. |
| 127 | + """ |
| 128 | + |
| 129 | + def __init__(self, ctx: xql.XarrayContext): |
| 130 | + self.ctx = ctx |
| 131 | + |
| 132 | + def _replace(self, name: str, batches: list[pa.RecordBatch]) -> None: |
| 133 | + if self.ctx.table_exist(name): |
| 134 | + self.ctx.deregister_table(name) |
| 135 | + self.ctx.register_record_batches(name, [batches]) |
| 136 | + |
| 137 | + def matrix( |
| 138 | + self, name: str, var: str, arr: np.ndarray, di: str, dj: str |
| 139 | + ) -> None: |
| 140 | + """Register a 2-D array as a long ``(di, dj, var)`` in-memory table.""" |
| 141 | + ni, nj = arr.shape |
| 142 | + ii, jj = np.meshgrid(np.arange(ni), np.arange(nj), indexing="ij") |
| 143 | + batch = pa.RecordBatch.from_pydict( |
| 144 | + {di: ii.ravel(), dj: jj.ravel(), var: arr.ravel()} |
| 145 | + ) |
| 146 | + self._replace(name, [batch]) |
| 147 | + |
| 148 | + def vector(self, name: str, var: str, arr: np.ndarray, d0: str) -> None: |
| 149 | + """Register a 1-D array as a ``(d0, var)`` in-memory table.""" |
| 150 | + batch = pa.RecordBatch.from_pydict( |
| 151 | + {d0: np.arange(len(arr)), var: np.asarray(arr, dtype=np.float64)} |
| 152 | + ) |
| 153 | + self._replace(name, [batch]) |
| 154 | + |
| 155 | + def materialize(self, name: str, sql: str) -> None: |
| 156 | + """Run a query and register its Arrow result as the next stage's table.""" |
| 157 | + self._replace(name, self.ctx.sql(sql).collect()) |
| 158 | + |
| 159 | + |
| 160 | +def main() -> None: |
| 161 | + Xtr, ytr, Xte, yte = load_mnist() |
| 162 | + print( |
| 163 | + f"MNIST: train {Xtr.shape}, test {Xte.shape} ({N_PIX} pix, {N_HID} hidden)" |
| 164 | + ) |
| 165 | + |
| 166 | + ctx = xql.XarrayContext() |
| 167 | + # The data is registered as xarray (the library's core); model state below |
| 168 | + # lives in DataFusion in-memory tables. |
| 169 | + ctx.from_dataset( |
| 170 | + "imgs", |
| 171 | + xr.Dataset( |
| 172 | + {"val": (("sample", "pix"), Xtr)}, |
| 173 | + coords={"sample": np.arange(N_TRAIN), "pix": np.arange(N_PIX)}, |
| 174 | + ), |
| 175 | + chunks={"sample": N_TRAIN}, |
| 176 | + ) |
| 177 | + ctx.from_dataset( |
| 178 | + "labels", |
| 179 | + xr.Dataset( |
| 180 | + {"label": (("sample",), ytr.astype(np.float64))}, |
| 181 | + coords={"sample": np.arange(N_TRAIN)}, |
| 182 | + ), |
| 183 | + chunks={"sample": N_TRAIN}, |
| 184 | + ) |
| 185 | + t = SqlTables(ctx) |
| 186 | + |
| 187 | + rng = np.random.default_rng(1) |
| 188 | + W1 = rng.standard_normal((N_PIX, N_HID)) * 0.1 |
| 189 | + b1 = np.zeros(N_HID) |
| 190 | + W2 = rng.standard_normal((N_HID, N_CLS)) * 0.1 |
| 191 | + b2 = np.zeros(N_CLS) |
| 192 | + |
| 193 | + def dense_to(df, ni, nj, ci, cj): |
| 194 | + g = np.zeros((ni, nj)) |
| 195 | + g[df[ci].to_numpy(), df[cj].to_numpy()] = df["g"].to_numpy() |
| 196 | + return g |
| 197 | + |
| 198 | + def step(lr: float) -> None: |
| 199 | + nonlocal W1, b1, W2, b2 |
| 200 | + t.matrix("w1", "w", W1, "pix", "hid") |
| 201 | + t.vector("b1", "b", b1, "hid") |
| 202 | + t.matrix("w2", "w", W2, "hid", "cls") |
| 203 | + t.vector("b2", "b", b2, "cls") |
| 204 | + |
| 205 | + # Forward: hidden pre-activation z and activation a = tanh(z). |
| 206 | + t.materialize( |
| 207 | + "h", |
| 208 | + """ |
| 209 | + WITH z AS ( |
| 210 | + SELECT i.sample, w.hid, SUM(i.val * w.w) + MAX(bb.b) AS z |
| 211 | + FROM imgs i JOIN w1 w ON i.pix = w.pix |
| 212 | + JOIN b1 bb ON w.hid = bb.hid |
| 213 | + GROUP BY i.sample, w.hid) |
| 214 | + SELECT sample, hid, z, tanh(z) AS a FROM z |
| 215 | + """, |
| 216 | + ) |
| 217 | + # Output softmax, then output error delta2 = softmax - onehot(label). |
| 218 | + t.materialize( |
| 219 | + "delta2", |
| 220 | + """ |
| 221 | + WITH logit AS ( |
| 222 | + SELECT h.sample, w.cls, SUM(h.a * w.w) + MAX(bb.b) AS z |
| 223 | + FROM h JOIN w2 w ON h.hid = w.hid |
| 224 | + JOIN b2 bb ON w.cls = bb.cls |
| 225 | + GROUP BY h.sample, w.cls), |
| 226 | + mx AS (SELECT sample, MAX(z) AS m FROM logit GROUP BY sample), |
| 227 | + ex AS (SELECT l.sample, l.cls, exp(l.z - mx.m) AS e |
| 228 | + FROM logit l JOIN mx ON l.sample = mx.sample), |
| 229 | + zsum AS (SELECT sample, SUM(e) AS z FROM ex GROUP BY sample) |
| 230 | + SELECT ex.sample, ex.cls, |
| 231 | + ex.e / zsum.z |
| 232 | + - CASE WHEN ex.cls = lb.label THEN 1.0 ELSE 0.0 END AS d |
| 233 | + FROM ex JOIN zsum ON ex.sample = zsum.sample |
| 234 | + JOIN labels lb ON ex.sample = lb.sample |
| 235 | + """, |
| 236 | + ) |
| 237 | + # Backprop to the hidden layer: push delta2 back through W2 (join + SUM), |
| 238 | + # then multiply by the LOCAL activation derivative grad(tanh(z), z). |
| 239 | + t.materialize( |
| 240 | + "delta1", |
| 241 | + """ |
| 242 | + WITH da AS ( |
| 243 | + SELECT d.sample, w.hid, SUM(d.d * w.w) AS da |
| 244 | + FROM delta2 d JOIN w2 w ON d.cls = w.cls |
| 245 | + GROUP BY d.sample, w.hid) |
| 246 | + SELECT da.sample, da.hid, da.da * grad(tanh(h.z), h.z) AS d |
| 247 | + FROM da JOIN h ON da.sample = h.sample AND da.hid = h.hid |
| 248 | + """, |
| 249 | + ) |
| 250 | + |
| 251 | + # Parameter gradients: dW = AVG(input * delta) over the batch. |
| 252 | + gW2 = dense_to( |
| 253 | + ctx.sql( |
| 254 | + f"SELECT h.hid, d.cls, SUM(h.a * d.d) / {N_TRAIN}.0 AS g " |
| 255 | + "FROM h JOIN delta2 d ON h.sample = d.sample " |
| 256 | + "GROUP BY h.hid, d.cls" |
| 257 | + ).to_pandas(), |
| 258 | + N_HID, |
| 259 | + N_CLS, |
| 260 | + "hid", |
| 261 | + "cls", |
| 262 | + ) |
| 263 | + gW1 = dense_to( |
| 264 | + ctx.sql( |
| 265 | + f"SELECT i.pix, d.hid, SUM(i.val * d.d) / {N_TRAIN}.0 AS g " |
| 266 | + "FROM imgs i JOIN delta1 d ON i.sample = d.sample " |
| 267 | + "GROUP BY i.pix, d.hid" |
| 268 | + ).to_pandas(), |
| 269 | + N_PIX, |
| 270 | + N_HID, |
| 271 | + "pix", |
| 272 | + "hid", |
| 273 | + ) |
| 274 | + gb2 = ctx.sql( |
| 275 | + f"SELECT cls, SUM(d) / {N_TRAIN}.0 AS g FROM delta2 GROUP BY cls" |
| 276 | + ).to_pandas() |
| 277 | + gb1 = ctx.sql( |
| 278 | + f"SELECT hid, SUM(d) / {N_TRAIN}.0 AS g FROM delta1 GROUP BY hid" |
| 279 | + ).to_pandas() |
| 280 | + vb2 = np.zeros(N_CLS) |
| 281 | + vb2[gb2["cls"].to_numpy()] = gb2["g"].to_numpy() |
| 282 | + vb1 = np.zeros(N_HID) |
| 283 | + vb1[gb1["hid"].to_numpy()] = gb1["g"].to_numpy() |
| 284 | + |
| 285 | + W2 -= lr * gW2 |
| 286 | + b2 -= lr * vb2 |
| 287 | + W1 -= lr * gW1 |
| 288 | + b1 -= lr * vb1 |
| 289 | + |
| 290 | + def accuracy(X, y) -> float: |
| 291 | + a = np.tanh(X @ W1 + b1) |
| 292 | + return float(((a @ W2 + b2).argmax(1) == y).mean()) |
| 293 | + |
| 294 | + print(f"init: test acc {accuracy(Xte, yte):.3f}") |
| 295 | + t0 = time.time() |
| 296 | + steps = 60 |
| 297 | + for s in range(steps): |
| 298 | + step(lr=0.5) |
| 299 | + if s % 10 == 0 or s == steps - 1: |
| 300 | + print( |
| 301 | + f"step {s:2d}: train {accuracy(Xtr, ytr):.3f} " |
| 302 | + f"test {accuracy(Xte, yte):.3f}" |
| 303 | + ) |
| 304 | + print( |
| 305 | + f"\ntrained an MNIST MLP in SQL: test accuracy " |
| 306 | + f"{accuracy(Xte, yte):.3f} in {time.time() - t0:.0f}s" |
| 307 | + ) |
| 308 | + |
| 309 | + |
| 310 | +if __name__ == "__main__": |
| 311 | + main() |
0 commit comments