Skip to content

Commit afb1036

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 update is computed in SQL (new_a = a - lr*AVG(grad(loss, a))) and the optimiser trajectory is a growing params(step, a, b) table; the full loss curve is one GROUP BY over that 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 255413e commit afb1036

3 files changed

Lines changed: 356 additions & 0 deletions

File tree

benchmarks/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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: a `params(step, a, b)` table that
48+
**grows one generation per step**, where the update is computed in SQL by
49+
descending from the current row, `new_a = a - lr * AVG(grad(loss, a))`. The whole
50+
loss curve is then a single `GROUP BY` over the history joined to the data. The
51+
fit matches numpy's least-squares solution. Self-contained (no network).
52+

benchmarks/grad_descent.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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, with the optimiser trajectory as a relation.
13+
14+
Fits a line ``y ~= a*x + b`` by minimising the mean squared error. Two ideas:
15+
16+
1. **The gradient is a SQL aggregate.** Differentiating through ``AVG`` is just
17+
linearity, so ``d/dθ AVG(loss) = AVG(grad(loss, θ))`` — an ordinary aggregate
18+
of a ``grad`` expression.
19+
20+
2. **The parameters are a growing table.** ``params(step, a, b)`` holds one row
21+
per generation; each step appends the next generation, and the update itself
22+
is computed in SQL by descending from the current row along the gradient:
23+
24+
new_a = a - lr * AVG(grad(loss, a))
25+
26+
So the whole optimisation history is a relation you can query — e.g. the loss
27+
curve over every step in a single ``GROUP BY`` over the history joined to the
28+
data. The Python loop only drives iteration and grows the table; xarray-backed
29+
tables are read-only to SQL, so "append a row" is done by re-registering the
30+
grown history (a true in-place ``INSERT`` would need a mutable table provider).
31+
32+
Run standalone:
33+
34+
uv run benchmarks/grad_descent.py
35+
"""
36+
37+
from __future__ import annotations
38+
39+
import numpy as np
40+
import xarray as xr
41+
42+
import xarray_sql as xql
43+
44+
# Per-row loss r^2 with residual r = y - (a*x + b).
45+
RESIDUAL = "(y - (a * x + b))"
46+
LOSS = f"{RESIDUAL} * {RESIDUAL}"
47+
LR = 0.4
48+
STEPS = 200
49+
50+
51+
def main() -> None:
52+
rng = np.random.default_rng(0)
53+
n = 500
54+
x = rng.uniform(0.0, 1.0, n)
55+
a_true, b_true = 2.0, -1.0
56+
y = a_true * x + b_true + rng.normal(0.0, 0.01, n)
57+
58+
ctx = xql.XarrayContext()
59+
ctx.from_dataset(
60+
"d",
61+
xr.Dataset(
62+
{"x": (("i",), x), "y": (("i",), y)}, coords={"i": np.arange(n)}
63+
),
64+
chunks={"i": n},
65+
)
66+
67+
# Parameter history: one row per generation, starting from (a, b) = (0, 0).
68+
steps, a_hist, b_hist = [0], [0.0], [0.0]
69+
70+
def register_params() -> None:
71+
if "params" in ctx._registered_datasets:
72+
ctx.deregister_table("params")
73+
del ctx._registered_datasets["params"]
74+
ds = xr.Dataset(
75+
{
76+
"a": (("step",), np.array(a_hist)),
77+
"b": (("step",), np.array(b_hist)),
78+
},
79+
coords={"step": np.array(steps)},
80+
)
81+
ctx.from_dataset("params", ds, chunks={"step": len(steps)})
82+
83+
# One gradient-descent step, expressed in SQL: read the current generation,
84+
# descend along the SQL-computed gradient, return the next (a, b).
85+
def update_sql(cur_step: int) -> str:
86+
return f"""
87+
WITH cur AS (SELECT a, b FROM params WHERE step = {cur_step})
88+
SELECT cur.a - {LR} * AVG(grad({LOSS}, a)) AS a,
89+
cur.b - {LR} * AVG(grad({LOSS}, b)) AS b
90+
FROM d CROSS JOIN cur
91+
GROUP BY cur.a, cur.b
92+
"""
93+
94+
for k in range(STEPS):
95+
register_params()
96+
row = ctx.sql(update_sql(k)).to_pandas()
97+
steps.append(k + 1)
98+
a_hist.append(float(row["a"][0]))
99+
b_hist.append(float(row["b"][0]))
100+
register_params()
101+
102+
# The optimiser trajectory is now a table: compute the loss at every step in
103+
# a single query over the parameter history joined to the data.
104+
curve = ctx.sql(
105+
f"SELECT p.step AS step, AVG({LOSS}) AS loss "
106+
f"FROM d CROSS JOIN params p GROUP BY p.step ORDER BY p.step"
107+
).to_pandas()
108+
print("loss curve (every 40th generation):")
109+
print(curve.iloc[::40].to_string(index=False))
110+
111+
a, b = a_hist[-1], b_hist[-1]
112+
a_ols, b_ols = np.polyfit(x, y, 1)
113+
print(
114+
f"\nSQL gradient descent: a={a:.4f} b={b:.4f} ({len(steps)} generations)"
115+
)
116+
print(f"least-squares (numpy): a={a_ols:.4f} b={b_ols:.4f}")
117+
assert abs(a - a_ols) < 1e-2 and abs(b - b_ols) < 1e-2
118+
print(
119+
"\nOK: SQL-computed gradients fit the line to the least-squares solution."
120+
)
121+
122+
123+
if __name__ == "__main__":
124+
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)