Skip to content

Commit 67286db

Browse files
claudealxmrs
authored andcommitted
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). The queries ORDER BY latitude DESC, longitude to match ERA5's native order, so results line up with the xarray reference with no sorting on either side (single partition, so the order survives to_dataset). * grad_descent.py: gradient descent as ONE declarative recursive-CTE query. differentiate_sql compiles the per-row update rule to SQL once; a recursive CTE then iterates it. No Python loop. 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 7b1e530 commit 67286db

3 files changed

Lines changed: 350 additions & 0 deletions

File tree

benchmarks/README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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 as one declarative SQL query
34+
35+
Fits a line `y ~= a*x + b` by minimising the mean squared error, with the
36+
**entire training loop expressed as a single recursive CTE** — no Python
37+
iteration. Two pieces:
38+
39+
- **`grad` compiles the update rule.** `xql.differentiate_sql(loss, "a", cols)`
40+
turns the per-row loss into its symbolic derivative *as SQL text* — the
41+
autograd engine as a calculus compiler.
42+
- **A recursive CTE is the optimiser.** `params(step, a, b)` starts at one row
43+
and each recursion appends the next generation, descending along the gradient
44+
(`AVG` of the compiled rule over the data):
45+
46+
```sql
47+
WITH RECURSIVE params(step, a, b) AS (
48+
SELECT 0, 0.0, 0.0
49+
UNION ALL
50+
SELECT params.step + 1, params.a - lr*AVG(da), params.b - lr*AVG(db)
51+
FROM params CROSS JOIN d WHERE params.step < STEPS
52+
GROUP BY params.step, params.a, params.b)
53+
SELECT * FROM params ORDER BY step
54+
```
55+
56+
So gradient, update, and iteration are all declarative SQL; the trajectory is
57+
the rows of one query. The fit matches numpy's least-squares solution.
58+
Self-contained (no network).
59+
60+
(Why differentiate to text instead of `grad(...)` inside the recursion? `grad`
61+
needs the Substrait round-trip, and Substrait has no recursion — so a `grad`
62+
marker can't live inside a recursive CTE. Differentiating once to plain SQL
63+
sidesteps that.)
64+

benchmarks/grad_descent.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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 as a single declarative SQL query.
13+
14+
Fits a line ``y ~= a*x + b`` by minimising the mean squared error — with the
15+
**entire training loop expressed as one recursive CTE**, no Python iteration.
16+
17+
Two pieces:
18+
19+
1. **grad compiles the update rule.** ``differentiate_sql`` turns the per-row
20+
loss into the symbolic derivative *as SQL text* — the autograd engine acting
21+
as a calculus compiler:
22+
23+
da = differentiate_sql("(y-(a*x+b))^2", "a") # -> "-2*((a*x+b)-y)*x", etc.
24+
25+
2. **A recursive CTE is the optimiser.** ``params(step, a, b)`` starts at one
26+
row and each recursion appends the next generation, descending along the
27+
gradient (``AVG`` of the compiled rule over the data):
28+
29+
params.a - lr * AVG(da)
30+
31+
So the whole loop — gradient, update, and iteration — is declarative SQL;
32+
the optimisation trajectory is the rows of one query.
33+
34+
Why two pieces instead of ``grad(...)`` directly inside the recursion? ``grad``
35+
needs the Substrait round-trip, and Substrait has no recursion — so ``grad``
36+
can't live inside a recursive CTE (tracked in #194 / a follow-up). Differentiating
37+
once to plain SQL sidesteps that: the recursive query contains no ``grad`` marker.
38+
39+
Run standalone:
40+
41+
uv run benchmarks/grad_descent.py
42+
"""
43+
44+
from __future__ import annotations
45+
46+
import numpy as np
47+
import xarray as xr
48+
49+
import xarray_sql as xql
50+
51+
# Per-row loss r^2 with residual r = y - (a*x + b), over columns a, b, x, y.
52+
RESIDUAL = "(y - (a * x + b))"
53+
LOSS = f"{RESIDUAL} * {RESIDUAL}"
54+
COLUMNS = ["a", "b", "x", "y"]
55+
LR = 0.4
56+
STEPS = 200
57+
58+
59+
def main() -> None:
60+
rng = np.random.default_rng(0)
61+
n = 500
62+
x = rng.uniform(0.0, 1.0, n)
63+
a_true, b_true = 2.0, -1.0
64+
y = a_true * x + b_true + rng.normal(0.0, 0.01, n)
65+
66+
ctx = xql.XarrayContext()
67+
ctx.from_dataset(
68+
"d",
69+
xr.Dataset(
70+
{"x": (("i",), x), "y": (("i",), y)}, coords={"i": np.arange(n)}
71+
),
72+
chunks={"i": n},
73+
)
74+
75+
# grad compiles the per-row update rule to SQL, once.
76+
da = xql.differentiate_sql(LOSS, "a", COLUMNS)
77+
db = xql.differentiate_sql(LOSS, "b", COLUMNS)
78+
print(f"d(loss)/da = {da}")
79+
print(f"d(loss)/db = {db}\n")
80+
81+
# The entire training loop is one declarative recursive query: each step
82+
# appends the next generation, descending along the SQL-computed gradient.
83+
trajectory = ctx.sql(
84+
f"""
85+
WITH RECURSIVE params(step, a, b) AS (
86+
SELECT 0 AS step, CAST(0.0 AS DOUBLE) AS a, CAST(0.0 AS DOUBLE) AS b
87+
UNION ALL
88+
SELECT params.step + 1 AS step,
89+
params.a - {LR} * AVG({da}) AS a,
90+
params.b - {LR} * AVG({db}) AS b
91+
FROM params CROSS JOIN d
92+
WHERE params.step < {STEPS}
93+
GROUP BY params.step, params.a, params.b
94+
)
95+
SELECT step, a, b FROM params ORDER BY step
96+
"""
97+
).to_pandas()
98+
99+
print("trajectory (every 40th generation):")
100+
print(trajectory.iloc[::40].to_string(index=False))
101+
102+
a, b = float(trajectory["a"].iloc[-1]), float(trajectory["b"].iloc[-1])
103+
a_ols, b_ols = np.polyfit(x, y, 1)
104+
print(
105+
f"\nSQL gradient descent: a={a:.4f} b={b:.4f} ({len(trajectory)} generations)"
106+
)
107+
print(f"least-squares (numpy): a={a_ols:.4f} b={b_ols:.4f}")
108+
assert abs(a - a_ols) < 1e-2 and abs(b - b_ols) < 1e-2
109+
print(
110+
"\nOK: a single recursive-CTE query fit the line to the OLS solution."
111+
)
112+
113+
114+
if __name__ == "__main__":
115+
main()

benchmarks/grad_era5.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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+
# ERA5 variable names start with a digit, so they must be double-quoted in SQL.
54+
U = '"10m_u_component_of_wind"'
55+
V = '"10m_v_component_of_wind"'
56+
T = '"2m_temperature"'
57+
58+
59+
def load_era5_block() -> xr.Dataset:
60+
"""Open ARCO-ERA5 and pull one timestamp over a small region.
61+
62+
Lazy open of the whole archive; only the requested block is read. We keep
63+
it to a few thousand cells so the demo runs in seconds.
64+
"""
65+
full = xr.open_zarr(
66+
ARCO_ERA5, chunks=None, storage_options={"token": "anon"}
67+
)
68+
block = (
69+
full[
70+
[
71+
"10m_u_component_of_wind",
72+
"10m_v_component_of_wind",
73+
"2m_temperature",
74+
]
75+
]
76+
.sel(time="2020-01-01T00")
77+
# A ~North-America box (index-based to avoid lat-orientation pitfalls).
78+
.isel(latitude=slice(120, 200), longitude=slice(900, 1000))
79+
.load()
80+
)
81+
# One partition, so a SQL `ORDER BY latitude DESC` survives the round-trip
82+
# back to xarray (across multiple partitions, to_dataset reconstructs
83+
# coordinates in ascending order regardless of ORDER BY).
84+
return block.chunk()
85+
86+
87+
def wind_speed_sensitivity(ctx: xql.XarrayContext, ref: xr.Dataset) -> None:
88+
"""grad(sqrt(u^2 + v^2)) checked against the exact u / speed, v / speed."""
89+
speed = f"sqrt(power({U}, 2) + power({V}, 2))"
90+
out = ctx.sql(
91+
f"""
92+
SELECT
93+
latitude,
94+
longitude,
95+
{speed} AS wind_speed,
96+
grad({speed}, {U}) AS d_speed_d_u,
97+
grad({speed}, {V}) AS d_speed_d_v
98+
FROM era5
99+
ORDER BY latitude DESC, longitude
100+
"""
101+
).to_dataset(dims=["latitude", "longitude"])
102+
103+
u = ref["10m_u_component_of_wind"]
104+
v = ref["10m_v_component_of_wind"]
105+
speed_ref = np.sqrt(u**2 + v**2)
106+
107+
xr.testing.assert_allclose(
108+
out["wind_speed"], speed_ref.rename("wind_speed")
109+
)
110+
xr.testing.assert_allclose(
111+
out["d_speed_d_u"], (u / speed_ref).rename("d_speed_d_u")
112+
)
113+
xr.testing.assert_allclose(
114+
out["d_speed_d_v"], (v / speed_ref).rename("d_speed_d_v")
115+
)
116+
print(" wind-speed sensitivity matches u/|w|, v/|w| exactly")
117+
print(out)
118+
119+
120+
def clausius_clapeyron(ctx: xql.XarrayContext, ref: xr.Dataset) -> None:
121+
"""grad(e_s(T)) checked against the closed-form Clausius-Clapeyron slope."""
122+
# August-Roche-Magnus: e_s(T) = A * exp(B * tc / (tc + C)), tc = T - 273.15.
123+
a, b, c = 6.1094, 17.625, 243.04
124+
tc = f"({T} - 273.15)"
125+
es = f"{a} * exp({b} * {tc} / ({tc} + {c}))"
126+
out = ctx.sql(
127+
f"""
128+
SELECT
129+
latitude,
130+
longitude,
131+
{es} AS e_s,
132+
grad({es}, {T}) AS de_s_dt
133+
FROM era5
134+
ORDER BY latitude DESC, longitude
135+
"""
136+
).to_dataset(dims=["latitude", "longitude"])
137+
138+
# Reference in float64 (the columns are float32): the exact derivative is
139+
# d(e_s)/dT = e_s * B*C / (tc + C)^2.
140+
temp = ref["2m_temperature"].astype("float64")
141+
tc_ref = temp - 273.15
142+
es_ref = a * np.exp(b * tc_ref / (tc_ref + c))
143+
des_dt_ref = es_ref * (b * c) / (tc_ref + c) ** 2
144+
145+
xr.testing.assert_allclose(out["e_s"], es_ref.rename("e_s"), rtol=1e-5)
146+
xr.testing.assert_allclose(
147+
out["de_s_dt"], des_dt_ref.rename("de_s_dt"), rtol=1e-5
148+
)
149+
print(" d(e_s)/dT matches the closed-form Clausius-Clapeyron slope")
150+
print(out)
151+
152+
153+
def main() -> None:
154+
t0 = time.time()
155+
ds = load_era5_block()
156+
print(f"loaded ERA5 block {dict(ds.sizes)} in {time.time() - t0:.1f}s")
157+
158+
ctx = xql.XarrayContext()
159+
ctx.from_dataset("era5", ds)
160+
161+
print("\n== wind-speed sensitivity: grad(sqrt(u^2 + v^2)) ==")
162+
wind_speed_sensitivity(ctx, ds)
163+
164+
print("\n== Clausius-Clapeyron: grad(e_s(T)) ==")
165+
clausius_clapeyron(ctx, ds)
166+
167+
print("\nOK: symbolic SQL gradients match the analytic references.")
168+
169+
170+
if __name__ == "__main__":
171+
main()

0 commit comments

Comments
 (0)