Skip to content

Commit f2126da

Browse files
committed
Add MNIST MLP trained in SQL (benchmarks/mnist_mlp.py)
A one-hidden-layer MLP (196->32 tanh->10 softmax, on 2x2-pooled 14x14 MNIST) trained by gradient descent with every gradient computed in SQL. The images are registered as xarray (the library's core); the model weights and per-step intermediates are DataFusion in-memory tables (register_record_batches), so a matmul is a join over them and there's no xarray pivot per step. Reverse-mode autodiff as relational algebra: matmul = join + GROUP BY SUM; the hidden activation's local Jacobian = grad(tanh(z), z); cotangent propagation = join; parameter gradients = join + GROUP BY AVG. The only hand-written gradient is softmax + cross-entropy's delta = softmax - onehot. ~83% test accuracy in ~20s. Adds a benchmarks README entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017mDoFJgsm9kS7SicGoCVF6
1 parent 8f97173 commit f2126da

2 files changed

Lines changed: 332 additions & 0 deletions

File tree

benchmarks/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,24 @@ needs the Substrait round-trip, and Substrait has no recursion — so a `grad`
6262
marker can't live inside a recursive CTE. Differentiating once to plain SQL
6363
sidesteps that.)
6464

65+
## `mnist_mlp.py` — train an MNIST MLP classifier in SQL
66+
67+
A one-hidden-layer neural network (196 -> 32 tanh -> 10 softmax, on 2x2-pooled
68+
14x14 MNIST) trained by gradient descent where **every gradient is computed in
69+
SQL**; the optimisation loop is plain Python. It is reverse-mode autodiff
70+
expressed as relational algebra:
71+
72+
- **matmul = join + `GROUP BY SUM`** — a layer's pre-activation is
73+
`SUM(input * weight)` grouped by (sample, unit).
74+
- **local derivatives = `grad()`** — the hidden activation's Jacobian is
75+
`grad(tanh(z), z)`, the autograd feature doing the calculus per (sample, unit).
76+
- **cotangent propagation = join**, **parameter gradients = join + `GROUP BY
77+
AVG`**.
78+
79+
The MNIST images are registered as xarray (the library's core); the model
80+
weights and per-step intermediates are DataFusion in-memory tables (a matmul is
81+
a join over them). The only hand-written gradient is softmax + cross-entropy's
82+
`delta = softmax - onehot` (softmax couples classes through a per-sample
83+
normaliser, an aggregate `grad` does not cross). Reaches ~83% test accuracy in
84+
~20s. Downloads MNIST on first run.
85+

benchmarks/mnist_mlp.py

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
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

Comments
 (0)