Skip to content

Commit 07a7ff2

Browse files
committed
Differentiation through aggregates: gradient descent in SQL
Differentiating through SUM/AVG needs no new engine code -- it is linearity: d/dθ AVG(loss) = AVG(d(loss)/dθ) = AVG(grad(loss, θ)) Writing the grad *inside* the aggregate (SUM(grad(f, x)), AVG(grad(loss, θ))) composes with SQL scoping: the marker is rewritten to plain SQL before the aggregate runs, where the columns are still live. The transposed form grad(SUM(f), x) is rejected by SQL itself (x is gone after aggregation), so we stay faithful to SQL rather than hacking grad back across the aggregate node. This is enough to run gradient descent in SQL. Adds: * benchmarks/grad_descent.py: fits y ~= a*x + b by minimising MSE, gradients w.r.t. the parameters computed via AVG(grad(loss, param)); parameters live in a one-row table cross-joined to the data and updated each step. The fit matches numpy's least-squares solution. * tests: SUM/AVG(grad(...)) equals the aggregate of the derivative, and an end-to-end gradient-descent convergence test. * docs: module overview + benchmarks README note the aggregate pattern. No Rust code change -- only a doc comment; the capability already followed from the existing rewrite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017mDoFJgsm9kS7SicGoCVF6
1 parent 89bc2bb commit 07a7ff2

4 files changed

Lines changed: 183 additions & 0 deletions

File tree

benchmarks/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,20 @@ Two worked cases, each checked against an analytic reference:
2929
| Saturation vapour pressure | `A*exp(B*tc/(tc+C))` | `grad(e_s, T)` | closed-form Clausius-Clapeyron slope |
3030

3131
Each query round-trips back to an `xarray.Dataset` via `.to_dataset(...)`.
32+
33+
## `grad_descent.py` — gradient descent in SQL
34+
35+
Fits a line `y ~= a*x + b` by minimising the mean squared error, with the
36+
gradients w.r.t. the parameters computed in SQL. Differentiating through an
37+
aggregate is just linearity:
38+
39+
```
40+
d/dθ AVG(loss) = AVG(grad(loss, θ))
41+
```
42+
43+
so the gradient is an ordinary aggregate of a `grad` expression — no special
44+
"differentiate through GROUP BY" machinery, since `grad` becomes plain SQL
45+
before the aggregate runs. The parameters live in a one-row table cross-joined
46+
to the data; the optimisation loop is plain Python. The fit matches numpy's
47+
least-squares solution. Self-contained (no network).
48+

benchmarks/grad_descent.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# /// script
2+
# requires-python = ">=3.10"
3+
# dependencies = [
4+
# "xarray_sql",
5+
# "xarray",
6+
# "numpy",
7+
# ]
8+
#
9+
# [tool.uv.sources]
10+
# xarray_sql = { path = "..", editable = true }
11+
# ///
12+
"""Gradient descent in SQL.
13+
14+
Fits a line ``y ~= a*x + b`` to noisy data by minimising the mean squared
15+
error, computing the gradients entirely in SQL with ``grad(...)``.
16+
17+
The key idea is that differentiating through an aggregate is just linearity:
18+
19+
d/dθ AVG( loss ) = AVG( d(loss)/dθ ) = AVG( grad(loss, θ) )
20+
21+
so the gradient of the loss w.r.t. a parameter is an ordinary aggregate of a
22+
``grad`` expression. This composes with SQL's scoping (the ``grad`` sits inside
23+
the aggregate, where the columns are live), which is why no special
24+
"differentiate through GROUP BY" machinery is needed — it falls out of the
25+
existing rewrite, which turns ``grad`` into plain SQL before the aggregate runs.
26+
27+
The parameters ``a`` and ``b`` live in a one-row ``params`` table that is
28+
cross-joined to the data and re-registered with the updated values after each
29+
step; the optimisation loop itself is plain Python.
30+
31+
Run standalone:
32+
33+
uv run benchmarks/grad_descent.py
34+
"""
35+
36+
from __future__ import annotations
37+
38+
import numpy as np
39+
import xarray as xr
40+
41+
import xarray_sql as xql
42+
43+
# A residual r = y - (a*x + b); the per-row loss is r^2.
44+
RESIDUAL = "(y - (a * x + b))"
45+
LOSS = f"{RESIDUAL} * {RESIDUAL}"
46+
47+
48+
def set_params(ctx: xql.XarrayContext, a: float, b: float) -> None:
49+
"""(Re)register the one-row params table holding the current a, b."""
50+
if "params" in ctx._registered_datasets:
51+
ctx.deregister_table("params")
52+
del ctx._registered_datasets["params"]
53+
params = xr.Dataset(
54+
{"a": (("p",), [a]), "b": (("p",), [b])}, coords={"p": [0]}
55+
)
56+
ctx.from_dataset("params", params, chunks={"p": 1})
57+
58+
59+
def loss_and_grads(ctx: xql.XarrayContext) -> tuple[float, float, float]:
60+
"""Compute (loss, dL/da, dL/db) over the cross-joined data in one query."""
61+
row = ctx.sql(
62+
f"""
63+
SELECT
64+
AVG({LOSS}) AS loss,
65+
AVG(grad({LOSS}, a)) AS dl_da,
66+
AVG(grad({LOSS}, b)) AS dl_db
67+
FROM d CROSS JOIN params
68+
"""
69+
).to_pandas()
70+
return float(row["loss"][0]), float(row["dl_da"][0]), float(row["dl_db"][0])
71+
72+
73+
def main() -> None:
74+
rng = np.random.default_rng(0)
75+
n = 500
76+
x = rng.uniform(0.0, 1.0, n)
77+
a_true, b_true = 2.0, -1.0
78+
y = a_true * x + b_true + rng.normal(0.0, 0.01, n)
79+
80+
data = xr.Dataset(
81+
{"x": (("i",), x), "y": (("i",), y)}, coords={"i": np.arange(n)}
82+
)
83+
ctx = xql.XarrayContext()
84+
ctx.from_dataset("d", data, chunks={"i": n})
85+
86+
a, b, lr, steps = 0.0, 0.0, 0.4, 200
87+
for step in range(steps):
88+
set_params(ctx, a, b)
89+
loss, dl_da, dl_db = loss_and_grads(ctx)
90+
a -= lr * dl_da
91+
b -= lr * dl_db
92+
if step % 40 == 0 or step == steps - 1:
93+
print(f"step {step:3d}: loss={loss:.6f} a={a:.4f} b={b:.4f}")
94+
95+
# Reference: the ordinary least-squares solution.
96+
a_ols, b_ols = np.polyfit(x, y, 1)
97+
print(f"\nSQL gradient descent: a={a:.4f} b={b:.4f}")
98+
print(f"least-squares (numpy): a={a_ols:.4f} b={b_ols:.4f}")
99+
assert abs(a - a_ols) < 1e-2 and abs(b - b_ols) < 1e-2
100+
print(
101+
"\nOK: SQL-computed gradients fit the line to the least-squares solution."
102+
)
103+
104+
105+
if __name__ == "__main__":
106+
main()

src/autograd.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@
4040
//! Calls nest, giving higher-order derivatives for free: the rewrite walks
4141
//! bottom-up, so the inner call in `grad(grad(f, x), x)` is differentiated
4242
//! first and the outer call differentiates that result.
43+
//!
44+
//! Differentiation through an aggregate is just linearity and needs no special
45+
//! handling: write the `grad` *inside* the aggregate, e.g. `SUM(grad(f, x))` or
46+
//! `AVG(grad(loss, theta))`. Because the marker is rewritten to plain SQL
47+
//! before the aggregate runs (and the column is in scope there), this is the
48+
//! relational `d/dθ Σ f = Σ ∂f/∂θ` — enough to run gradient descent in SQL.
49+
//! (The transposed form `grad(SUM(f), x)` is rejected by SQL's own scoping,
50+
//! since `x` is gone after aggregation.)
4351
4452
#![allow(dead_code)]
4553

tests/test_autograd.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,58 @@ def test_unsupported_function_raises(ctx):
117117
ctx.sql("SELECT grad(atan2(val, val), val) AS d FROM t").to_pandas()
118118

119119

120+
def test_grad_inside_aggregate(ctx):
121+
# Differentiation through an aggregate is just linearity:
122+
# AGG(grad(f, x)) == d/dx AGG(f). grad rewrites to plain SQL before the
123+
# aggregate runs, so this composes with no special machinery.
124+
val = np.linspace(0.1, 3.0, 16)
125+
res = ctx.sql(
126+
"SELECT SUM(grad(val * val, val)) AS s, "
127+
"AVG(grad(sin(val), val)) AS a FROM t"
128+
).to_pandas()
129+
np.testing.assert_allclose(res["s"][0], np.sum(2 * val))
130+
np.testing.assert_allclose(res["a"][0], np.mean(np.cos(val)))
131+
132+
133+
def test_gradient_descent_in_sql():
134+
# End to end: fit y ~= a*x + b by minimising MSE, with the gradients
135+
# w.r.t. the parameters computed in SQL via AVG(grad(loss, param)).
136+
rng = np.random.default_rng(0)
137+
n = 200
138+
x = rng.uniform(0.0, 1.0, n)
139+
a_true, b_true = 2.0, -1.0
140+
y = a_true * x + b_true + rng.normal(0.0, 0.01, n)
141+
data = xr.Dataset(
142+
{"x": (("i",), x), "y": (("i",), y)}, coords={"i": np.arange(n)}
143+
)
144+
ctx = xql.XarrayContext()
145+
ctx.from_dataset("d", data, chunks={"i": n})
146+
147+
resid = "(y - (a * x + b))"
148+
loss = f"{resid} * {resid}"
149+
a, b, lr = 0.0, 0.0, 0.4
150+
losses = []
151+
for _ in range(120):
152+
if "params" in ctx._registered_datasets:
153+
ctx.deregister_table("params")
154+
del ctx._registered_datasets["params"]
155+
params = xr.Dataset(
156+
{"a": (("p",), [a]), "b": (("p",), [b])}, coords={"p": [0]}
157+
)
158+
ctx.from_dataset("params", params, chunks={"p": 1})
159+
row = ctx.sql(
160+
f"SELECT AVG({loss}) AS loss, "
161+
f"AVG(grad({loss}, a)) AS dl_da, "
162+
f"AVG(grad({loss}, b)) AS dl_db FROM d CROSS JOIN params"
163+
).to_pandas()
164+
losses.append(float(row["loss"][0]))
165+
a -= lr * float(row["dl_da"][0])
166+
b -= lr * float(row["dl_db"][0])
167+
168+
assert losses[-1] < losses[0] # loss decreased
169+
np.testing.assert_allclose([a, b], [a_true, b_true], atol=0.05)
170+
171+
120172
def test_multi_input_grad_columns(ctx_xy):
121173
# A full Jacobian written as separate scalar grad() columns:
122174
# f = x*y -> df/dx = y, df/dy = x.

0 commit comments

Comments
 (0)