@@ -62,40 +62,57 @@ needs the Substrait round-trip, and Substrait has no recursion — so a `grad`
6262marker can't live inside a recursive CTE. Differentiating once to plain SQL
6363sidesteps 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