Skip to content

Commit fdb17fb

Browse files
committed
Add differentiable-SQL demos: ARCO-ERA5 and gradient descent
Stacked demo branch (on the autograd feature) holding the runnable benchmark scripts, kept out of the core branch so it stays reviewable. * grad_era5.py: symbolic grad over real ARCO-ERA5 data (wind-speed sensitivity checked exactly; saturation vapour pressure checked against the closed-form Clausius-Clapeyron slope). * grad_descent.py: gradient descent in SQL. The parameters live in a DataFusion in-memory table; each step appends the next generation with a SQL INSERT whose values are the SQL-computed update (a - lr*AVG(grad(loss, a))). The whole loss curve is one GROUP BY over the history. Fit matches numpy least-squares. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017mDoFJgsm9kS7SicGoCVF6
1 parent e2784c3 commit fdb17fb

3 files changed

Lines changed: 354 additions & 0 deletions

File tree

benchmarks/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Benchmarks & demos
2+
3+
Standalone scripts that exercise xarray-sql against real data. Each declares its
4+
own dependencies inline (PEP 723) and points `xarray_sql` at this checkout, so
5+
they run with no setup:
6+
7+
```bash
8+
uv run benchmarks/grad_era5.py
9+
```
10+
11+
## `grad_era5.py` — differentiable SQL over ARCO-ERA5
12+
13+
Demonstrates the autograd feature on a real climate archive
14+
([ARCO-ERA5](https://github.com/google-research/arco-era5), read anonymously
15+
from GCS — needs `gcsfs` and network access).
16+
17+
The key idea: a physical quantity is written as an **analytic SQL formula** over
18+
ERA5 variables, and `grad(...)` differentiates that formula **symbolically**,
19+
evaluated at every grid cell. Because each row is an independent point, this is
20+
the relational equivalent of `jax.vmap(jax.grad(f))`. It is *not* a finite-
21+
difference spatial gradient — `grad(f(u, v), u)` is the exact partial derivative
22+
of `f`.
23+
24+
Two worked cases, each checked against an analytic reference:
25+
26+
| Quantity | SQL | Derivative | Check |
27+
| --- | --- | --- | --- |
28+
| Wind speed | `sqrt(power(u,2) + power(v,2))` | `grad(speed, u) = u/speed` | exact |
29+
| Saturation vapour pressure | `A*exp(B*tc/(tc+C))` | `grad(e_s, T)` | closed-form Clausius-Clapeyron slope |
30+
31+
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.
46+
47+
The optimiser trajectory is itself a relation: `params(step, a, b)` is a
48+
DataFusion **in-memory table**, and each step **appends the next generation with
49+
a SQL `INSERT`** computed by descending from the current row
50+
(`new_a = a - lr * AVG(grad(loss, a))`). A training step is literally "add a row
51+
to a table", and the whole loss curve is a single `GROUP BY` over the history
52+
joined to the data. The Python loop only sequences the steps. The fit matches
53+
numpy's least-squares solution. Self-contained (no network).
54+

benchmarks/grad_descent.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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+
"""Gradient descent in SQL, with the optimiser trajectory as a table.
14+
15+
Fits a line ``y ~= a*x + b`` by minimising the mean squared error. Two ideas:
16+
17+
1. **The gradient is a SQL aggregate.** Differentiating through ``AVG`` is just
18+
linearity, so ``d/dθ AVG(loss) = AVG(grad(loss, θ))`` — an ordinary aggregate
19+
of a ``grad`` expression.
20+
21+
2. **The parameters are a real (mutable) SQL table.** ``params(step, a, b)`` is
22+
a DataFusion in-memory table, and each gradient step **appends the next
23+
generation with a SQL ``INSERT``** computed by descending from the current
24+
row:
25+
26+
INSERT INTO params
27+
SELECT step+1, a - lr*AVG(grad(loss, a)), b - lr*AVG(grad(loss, b)) ...
28+
29+
So a training step is literally "add a row to a table", and the whole
30+
optimisation history is a relation you can query — e.g. the loss curve over
31+
every generation in a single ``GROUP BY`` over the history joined to the
32+
data. The Python ``for`` loop only sequences the steps; the gradient, the
33+
update, and the append all happen in SQL. (A fully in-engine loop would be a
34+
recursive CTE — see issue #194 — which doesn't round-trip Substrait yet.)
35+
36+
Run standalone:
37+
38+
uv run benchmarks/grad_descent.py
39+
"""
40+
41+
from __future__ import annotations
42+
43+
import numpy as np
44+
import pyarrow as pa
45+
import xarray as xr
46+
47+
import xarray_sql as xql
48+
49+
# Per-row loss r^2 with residual r = y - (a*x + b).
50+
RESIDUAL = "(y - (a * x + b))"
51+
LOSS = f"{RESIDUAL} * {RESIDUAL}"
52+
LR = 0.4
53+
STEPS = 200
54+
55+
56+
def main() -> None:
57+
rng = np.random.default_rng(0)
58+
n = 500
59+
x = rng.uniform(0.0, 1.0, n)
60+
a_true, b_true = 2.0, -1.0
61+
y = a_true * x + b_true + rng.normal(0.0, 0.01, n)
62+
63+
ctx = xql.XarrayContext()
64+
ctx.from_dataset(
65+
"d",
66+
xr.Dataset(
67+
{"x": (("i",), x), "y": (("i",), y)}, coords={"i": np.arange(n)}
68+
),
69+
chunks={"i": n},
70+
)
71+
72+
# The parameters live in a mutable DataFusion in-memory table, seeded with
73+
# generation 0 at (a, b) = (0, 0).
74+
ctx.register_record_batches(
75+
"params",
76+
[[pa.RecordBatch.from_pydict({"step": [0], "a": [0.0], "b": [0.0]})]],
77+
)
78+
79+
for k in range(STEPS):
80+
# Compute the next generation in SQL: descend from the current row along
81+
# the SQL-computed gradient...
82+
row = ctx.sql(
83+
f"""
84+
WITH cur AS (SELECT a, b FROM params WHERE step = {k})
85+
SELECT cur.a - {LR} * AVG(grad({LOSS}, a)) AS a,
86+
cur.b - {LR} * AVG(grad({LOSS}, b)) AS b
87+
FROM d CROSS JOIN cur
88+
GROUP BY cur.a, cur.b
89+
"""
90+
).to_pandas()
91+
# ...and append it to the parameter table with a SQL INSERT.
92+
na, nb = float(row["a"][0]), float(row["b"][0])
93+
ctx.sql(
94+
f"INSERT INTO params VALUES ({k + 1}, {na!r}, {nb!r})"
95+
).collect()
96+
97+
# The optimiser trajectory is now a table: compute the loss at every step in
98+
# a single query over the parameter history joined to the data.
99+
curve = ctx.sql(
100+
f"SELECT p.step AS step, AVG({LOSS}) AS loss "
101+
f"FROM d CROSS JOIN params p GROUP BY p.step ORDER BY p.step"
102+
).to_pandas()
103+
print("loss curve (every 40th generation):")
104+
print(curve.iloc[::40].to_string(index=False))
105+
106+
final = ctx.sql(f"SELECT a, b FROM params WHERE step = {STEPS}").to_pandas()
107+
a, b = float(final["a"][0]), float(final["b"][0])
108+
a_ols, b_ols = np.polyfit(x, y, 1)
109+
print(
110+
f"\nSQL gradient descent: a={a:.4f} b={b:.4f} ({STEPS + 1} generations)"
111+
)
112+
print(f"least-squares (numpy): a={a_ols:.4f} b={b_ols:.4f}")
113+
assert abs(a - a_ols) < 1e-2 and abs(b - b_ols) < 1e-2
114+
print(
115+
"\nOK: SQL-computed gradients fit the line to the least-squares solution."
116+
)
117+
118+
119+
if __name__ == "__main__":
120+
main()

benchmarks/grad_era5.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# /// script
2+
# requires-python = ">=3.10"
3+
# dependencies = [
4+
# "xarray_sql",
5+
# "xarray[io]",
6+
# "gcsfs",
7+
# "numpy",
8+
# ]
9+
#
10+
# [tool.uv.sources]
11+
# xarray_sql = { path = "..", editable = true }
12+
# ///
13+
"""Differentiable SQL over ARCO-ERA5.
14+
15+
A minimal demonstration of xarray-sql's autograd: take a real climate archive
16+
(ARCO-ERA5, read anonymously from GCS), express a physical quantity as an
17+
*analytic* SQL formula over its variables, and let ``grad(...)`` differentiate
18+
that formula symbolically — evaluated per grid cell, which is the relational
19+
equivalent of ``jax.vmap(jax.grad(f))`` (each row is an independent point).
20+
21+
Note this is *symbolic* differentiation of an expression, not a finite-
22+
difference spatial gradient: ``grad(f(u, v), u)`` is the exact partial
23+
derivative of the formula ``f``, evaluated at every cell's values.
24+
25+
Two cases:
26+
27+
1. Wind-speed magnitude ``speed = sqrt(u^2 + v^2)``. Its sensitivity to the
28+
eastward wind is ``d(speed)/du = u / speed`` — checked exactly.
29+
30+
2. Saturation vapour pressure ``e_s(T)`` (August-Roche-Magnus form of the
31+
Clausius-Clapeyron relation). ``d(e_s)/dT`` governs how fast the atmosphere's
32+
moisture capacity grows with temperature — checked against the closed-form
33+
slope.
34+
35+
Run standalone (builds the local extension on first use):
36+
37+
uv run benchmarks/grad_era5.py
38+
"""
39+
40+
from __future__ import annotations
41+
42+
import time
43+
44+
import numpy as np
45+
import xarray as xr
46+
47+
import xarray_sql as xql
48+
49+
ARCO_ERA5 = (
50+
"gs://gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3"
51+
)
52+
53+
# The SQL result comes back with ascending coordinates; ERA5's native latitude
54+
# is descending. Sort both sides before comparing so equality is by label.
55+
_SORT = ["latitude", "longitude"]
56+
57+
# ERA5 variable names start with a digit, so they must be double-quoted in SQL.
58+
U = '"10m_u_component_of_wind"'
59+
V = '"10m_v_component_of_wind"'
60+
T = '"2m_temperature"'
61+
62+
63+
def load_era5_block() -> xr.Dataset:
64+
"""Open ARCO-ERA5 and pull one timestamp over a small region.
65+
66+
Lazy open of the whole archive; only the requested block is read. We keep
67+
it to a few thousand cells so the demo runs in seconds.
68+
"""
69+
full = xr.open_zarr(
70+
ARCO_ERA5, chunks=None, storage_options={"token": "anon"}
71+
)
72+
block = (
73+
full[
74+
[
75+
"10m_u_component_of_wind",
76+
"10m_v_component_of_wind",
77+
"2m_temperature",
78+
]
79+
]
80+
.sel(time="2020-01-01T00")
81+
# A ~North-America box (index-based to avoid lat-orientation pitfalls).
82+
.isel(latitude=slice(120, 200), longitude=slice(900, 1000))
83+
.load()
84+
)
85+
return block.chunk({"latitude": 40})
86+
87+
88+
def wind_speed_sensitivity(ctx: xql.XarrayContext, ref: xr.Dataset) -> None:
89+
"""grad(sqrt(u^2 + v^2)) checked against the exact u / speed, v / speed."""
90+
speed = f"sqrt(power({U}, 2) + power({V}, 2))"
91+
out = (
92+
ctx.sql(
93+
f"""
94+
SELECT
95+
latitude,
96+
longitude,
97+
{speed} AS wind_speed,
98+
grad({speed}, {U}) AS d_speed_d_u,
99+
grad({speed}, {V}) AS d_speed_d_v
100+
FROM era5
101+
"""
102+
)
103+
.to_dataset(dims=["latitude", "longitude"])
104+
.sortby(_SORT)
105+
)
106+
107+
u = ref["10m_u_component_of_wind"]
108+
v = ref["10m_v_component_of_wind"]
109+
speed_ref = np.sqrt(u**2 + v**2).sortby(_SORT)
110+
111+
xr.testing.assert_allclose(
112+
out["wind_speed"], speed_ref.rename("wind_speed")
113+
)
114+
xr.testing.assert_allclose(
115+
out["d_speed_d_u"], (u / speed_ref).sortby(_SORT).rename("d_speed_d_u")
116+
)
117+
xr.testing.assert_allclose(
118+
out["d_speed_d_v"], (v / speed_ref).sortby(_SORT).rename("d_speed_d_v")
119+
)
120+
print(" wind-speed sensitivity matches u/|w|, v/|w| exactly")
121+
print(out)
122+
123+
124+
def clausius_clapeyron(ctx: xql.XarrayContext, ref: xr.Dataset) -> None:
125+
"""grad(e_s(T)) checked against the closed-form Clausius-Clapeyron slope."""
126+
# August-Roche-Magnus: e_s(T) = A * exp(B * tc / (tc + C)), tc = T - 273.15.
127+
a, b, c = 6.1094, 17.625, 243.04
128+
tc = f"({T} - 273.15)"
129+
es = f"{a} * exp({b} * {tc} / ({tc} + {c}))"
130+
out = (
131+
ctx.sql(
132+
f"""
133+
SELECT
134+
latitude,
135+
longitude,
136+
{es} AS e_s,
137+
grad({es}, {T}) AS de_s_dt
138+
FROM era5
139+
"""
140+
)
141+
.to_dataset(dims=["latitude", "longitude"])
142+
.sortby(_SORT)
143+
)
144+
145+
# Reference in float64 (the columns are float32): the exact derivative is
146+
# d(e_s)/dT = e_s * B*C / (tc + C)^2.
147+
temp = ref["2m_temperature"].astype("float64")
148+
tc_ref = temp - 273.15
149+
es_ref = a * np.exp(b * tc_ref / (tc_ref + c))
150+
des_dt_ref = es_ref * (b * c) / (tc_ref + c) ** 2
151+
152+
xr.testing.assert_allclose(
153+
out["e_s"], es_ref.sortby(_SORT).rename("e_s"), rtol=1e-5
154+
)
155+
xr.testing.assert_allclose(
156+
out["de_s_dt"], des_dt_ref.sortby(_SORT).rename("de_s_dt"), rtol=1e-5
157+
)
158+
print(" d(e_s)/dT matches the closed-form Clausius-Clapeyron slope")
159+
print(out)
160+
161+
162+
def main() -> None:
163+
t0 = time.time()
164+
ds = load_era5_block()
165+
print(f"loaded ERA5 block {dict(ds.sizes)} in {time.time() - t0:.1f}s")
166+
167+
ctx = xql.XarrayContext()
168+
ctx.from_dataset("era5", ds)
169+
170+
print("\n== wind-speed sensitivity: grad(sqrt(u^2 + v^2)) ==")
171+
wind_speed_sensitivity(ctx, ds)
172+
173+
print("\n== Clausius-Clapeyron: grad(e_s(T)) ==")
174+
clausius_clapeyron(ctx, ds)
175+
176+
print("\nOK: symbolic SQL gradients match the analytic references.")
177+
178+
179+
if __name__ == "__main__":
180+
main()

0 commit comments

Comments
 (0)