Skip to content

Commit c92447a

Browse files
alxmrsclaude
andcommitted
demo: data-driven deep MLP with the model and metrics as relations
Make the architecture itself data. The whole model is one xr.Dataset: each layer's weight is a data_var w{L} over its boundary dims (u{L}, u{L+1}), sharing the dims that connect adjacent layers (the join keys). The dim sizes are the layer widths and the number of weights is the depth, so differing neuron counts are just differing dim sizes — no padding, because the relational long form is naturally ragged. from_dataset splits the one Dataset into a table per weight; changing WIDTHS trains a different network with the same code. One generic contract()-based loop trains a net of any depth: forward contracts each layer, backward is the same contraction transposed (VJP of a contraction is a contraction) with grad(tanh(z), z) for the local derivative. Validated exact against numpy at depth 3. Training metrics are a relation too: each logged step appends a (step, loss, train_acc, test_acc) row to a metrics table rather than a Python list. The trained model, predictions, and metrics all come back out as xarray via to_dataset. ~83% test accuracy in ~13s. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 06fabbc commit c92447a

2 files changed

Lines changed: 398 additions & 300 deletions

File tree

benchmarks/README.md

Lines changed: 54 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -62,40 +62,57 @@ 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) where **every gradient is computed in SQL** and the whole model —
69-
with its entire training history — lives in a single table.
70-
71-
The model is one append-only table `model(step, layer, i, j, val)`: every
72-
parameter is a row, tagged by which generation (`step`) it belongs to. **A
73-
training step never mutates anything; it appends the next generation's rows.**
74-
`WHERE step = N` is the model at iteration N, and the full trajectory is the
75-
table. Each step is a *single* SQL statement that reads the current generation
76-
and writes the next — reverse-mode autodiff as relational algebra:
77-
78-
- **matmul = join + `GROUP BY SUM`** — a layer's pre-activation is
79-
`SUM(input * weight)` grouped by (sample, unit).
80-
- **local derivatives = `grad()`** — the hidden activation's Jacobian is
81-
`grad(tanh(z), z)`, the autograd feature doing the calculus per (sample, unit).
82-
- **cotangent propagation = join**, **parameter gradients = join + `GROUP BY
83-
AVG`**, and the update `w - lr*g` is emitted as the next generation's rows.
84-
85-
The images are registered as xarray (the library's core); evaluation is SQL too
86-
(a forward pass with `ROW_NUMBER()` for the argmax). The only hand-written
87-
gradient is softmax + cross-entropy's `delta = softmax - onehot` (softmax couples
88-
classes through a per-sample normaliser, which an aggregate `grad` does not
89-
cross). Reaches ~83% test accuracy over 60 steps (~140s on a laptop — the
90-
parameter updates run in SQL and every generation is kept as rows, so it trades
91-
speed for a fully relational, fully inspectable training history). Downloads
92-
MNIST on first run.
93-
94-
Why is the *outer* loop still Python rather than one recursive query (like
95-
`grad_descent.py`)? A recursive CTE may reference the recursive relation only
96-
once, but a 2-layer net uses the current weights several times per step (W1 and
97-
W2 forward, W2 again in backprop), so it can't be a single recursive statement.
98-
Training is also sequential and reuses each step's result, so steps must be
99-
*materialised* between iterations — which is exactly what the thin loop does
100-
(append a generation, then query it). All the maths stays in SQL; Python only
101-
sequences the steps.
65+
## `mnist_mlp.py` — an MNIST MLP as relational tensor algebra
66+
67+
An MLP (196 -> 32 tanh -> 10 softmax on 2x2-pooled 14x14 MNIST) built on one
68+
idea: **a neural net is a chain of tensor contractions (einsums), and an einsum
69+
over coordinate-indexed arrays *is* relational algebra.**
70+
71+
```
72+
C[i,k] = sum_j A[i,j] * B[j,k] <=> JOIN A, B ON A.j = B.j
73+
GROUP BY i, k -> SUM(A.val * B.val)
74+
```
75+
76+
Contracting a shared index is a join on it followed by a grouped `SUM` over the
77+
indices that survive. In xarray-sql an array indexed by named dims is a table
78+
keyed by those dims, so **the dimension names are the join keys**.
79+
80+
**The architecture is data.** The whole model is *one* `xr.Dataset`: each layer's
81+
weight is a data variable `w{L}` over dims `(u{L}, u{L+1})`, the widths it
82+
connects, sharing the boundary dims (`u1` is layer 0's output and layer 1's
83+
input, so it is the join key between them). The dim sizes *are* the layer widths,
84+
and the number of weights is the depth — differing neuron counts per layer are
85+
just differing dim sizes, no padding, because the relational (long) form is
86+
naturally ragged. `from_dataset` splits that one Dataset into a table per weight
87+
automatically. Change `WIDTHS` (e.g. `196, 64, 32, 10`) and the same code trains
88+
the deeper net.
89+
90+
A small `contract()` helper turns an einsum spec into one query, and a single
91+
generic loop trains a net of any shape:
92+
93+
- **forward** contracts the activation with each layer's weight, `+ bias`,
94+
`tanh` (softmax on the last layer).
95+
- **backward is the *same* operator with indices transposed** — the VJP of a
96+
contraction is a contraction — and `grad(tanh(z), z)` supplies the only
97+
genuinely-calculus part. Linear algebra is joins; the derivatives of the
98+
nonlinearities are `grad`.
99+
100+
Everything stays relational: every stage is an inspectable table (`a1`, `delta2`,
101+
`gw0`, …); the only hand-written gradient is softmax + cross-entropy's `delta =
102+
softmax - onehot`. Even the training metrics are a table — each logged step
103+
appends a `(step, loss, train_acc, test_acc)` row to a `metrics` relation rather
104+
than a Python list (NN training produces a lot of such data; it belongs in
105+
rows). Evaluation is SQL too (a forward pass + `ROW_NUMBER()` argmax), and the
106+
trained model, predictions, and metrics all come **back out as xarray** via
107+
`to_dataset`. Reaches ~83% test accuracy over 60 steps. Downloads MNIST on first
108+
run.
109+
110+
This is not a numpy replacement — relational matmul carries join overhead a BLAS
111+
inner product doesn't. What it buys is a fully declarative, inspectable pipeline
112+
whose data side is chunked xarray (parallel over the batch, larger-than-memory).
113+
The *outer* training loop stays in Python because the steps must be materialised
114+
between iterations: a multi-layer net can't be one recursive CTE (the recursive
115+
relation may be referenced only once, but the weights are used several times per
116+
step), and unrolling the steps as non-recursive CTEs blows up exponentially
117+
(DataFusion inlines CTEs). The thin loop does exactly that materialisation; all
118+
the maths stays in SQL.

0 commit comments

Comments
 (0)