Skip to content

Commit a7fc169

Browse files
google-labs-jules[bot]maedoc
authored andcommitted
Add implicit Theta scheme and Jacobi solver to vbjax
- Added `vbjax/implicit.py` implementing `make_implicit_sde` (Theta method) and `jacobi` (linear solver). - Exposed new functions in `vbjax/__init__.py`. - Added tests in `vbjax/tests/test_implicit.py` covering solver accuracy and integrator convergence. - Added `benchmark_implicit.py` to demonstrate speedup on stiff systems compared to explicit Heun integration.
1 parent 01acfd4 commit a7fc169

4 files changed

Lines changed: 160 additions & 93 deletions

File tree

benchmark_implicit.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
2+
import jax
3+
import jax.numpy as np
4+
import vbjax
5+
import time
6+
7+
def benchmark():
8+
# Stiff system example: Robertson problem or simple stiff linear
9+
# Let's use a stiff linear system: dy/dt = -1000 y
10+
k = 1000.0
11+
dt = 0.001 # 1/k. Explicit stability limit is 2/k = 0.002.
12+
# If we use dt = 0.01, explicit should explode, implicit should work.
13+
14+
def f(y, k):
15+
return -k * y
16+
17+
def j_fn(y, k):
18+
return -k * np.eye(y.size)
19+
20+
def g(y, k):
21+
return 0.1
22+
23+
y0 = np.ones(100) # 100 dimensions
24+
tf = 1.0
25+
26+
# 1. Heun (Explicit)
27+
# We use a small dt for stability or just measure speed
28+
dt_explicit = 0.0001
29+
n_explicit = int(tf / dt_explicit)
30+
zs_explicit = jax.random.normal(jax.random.PRNGKey(0), (n_explicit, 100))
31+
32+
_, loop_heun = vbjax.make_sde(dt_explicit, f, g)
33+
34+
# Compile
35+
loop_heun(y0, zs_explicit, k).block_until_ready()
36+
37+
t0 = time.time()
38+
for _ in range(10):
39+
loop_heun(y0, zs_explicit, k).block_until_ready()
40+
t_heun = (time.time() - t0) / 10
41+
print(f"Heun (dt={dt_explicit}, steps={n_explicit}): {t_heun*1000:.2f} ms")
42+
43+
# 2. Implicit (Theta=0.5)
44+
# Use larger dt
45+
dt_implicit = 0.01
46+
n_implicit = int(tf / dt_implicit)
47+
zs_implicit = jax.random.normal(jax.random.PRNGKey(0), (n_implicit, 100))
48+
49+
step_imp, loop_imp = vbjax.make_implicit_sde(dt_implicit, f, j_fn, g, th=0.5)
50+
51+
# Compile
52+
loop_imp(y0, zs_implicit, k).block_until_ready()
53+
54+
t0 = time.time()
55+
for _ in range(10):
56+
loop_imp(y0, zs_implicit, k).block_until_ready()
57+
t_imp = (time.time() - t0) / 10
58+
print(f"Implicit (dt={dt_implicit}, steps={n_implicit}): {t_imp*1000:.2f} ms")
59+
60+
print(f"Speedup factor (Total Time): {t_heun / t_imp:.2f}x")
61+
62+
if __name__ == "__main__":
63+
benchmark()

vbjax/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def _use_many_cores():
2121

2222
# import stuff
2323
from .loops import make_sde, make_ode, make_dde, make_sdde, heun_step, make_continuation
24-
from .implicit import theta, jacobi
24+
from .implicit import jacobi, make_implicit_sde
2525
from .shtlc import make_shtdiff
2626
from .neural_mass import (
2727
JRState, JRTheta, jr_dfun, jr_default_theta,

vbjax/implicit.py

Lines changed: 49 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -52,94 +52,92 @@ def body(state):
5252

5353

5454
def _theta_dy(f, j, h, th, y0, y1, f_y0, pars, tol=1e-4):
55-
J = np.eye(y1.size) - h * th * j(y1, *pars)
56-
F = y0 + h * (th * f(y1, *pars) + f_y0) - y1
55+
J = np.eye(y1.size) - h * th * j(y1, pars)
56+
F = y0 + h * (th * f(y1, pars) + f_y0) - y1
5757

5858
dy, _ = jacobi(J, F, w=2.0/3.0, tol=tol, max_iters=10)
5959
return dy
6060

61+
def _compute_noise(gfun, x, p, sqrt_dt, z_t):
62+
g = gfun(x, p)
63+
return g * sqrt_dt * z_t
6164

62-
def theta(f, j, y0, h, tf, *pars, th=0.5, sigma=0.0, tol=1e-4, key=None):
65+
66+
def make_implicit_sde(dt, dfun, jfun, gfun, th=0.5, tol=1e-4, max_iters=10):
6367
"""
64-
Implicit Theta integration scheme.
68+
Construct an implicit SDE integrator using the Theta method.
6569
6670
Parameters
6771
----------
68-
f : callable
69-
Drift function f(y, *pars).
70-
j : callable
71-
Jacobian function j(y, *pars).
72-
y0 : array
73-
Initial state.
74-
h : float
72+
dt : float
7573
Time step.
76-
tf : float
77-
Final time.
78-
*pars : list
79-
Parameters passed to f and j.
74+
dfun : callable
75+
Drift function dfun(x, p).
76+
jfun : callable
77+
Jacobian of drift function jfun(x, p).
78+
gfun : callable or float
79+
Diffusion function gfun(x, p) or constant sigma.
8080
th : float
81-
Theta parameter (0.5 for trapezoidal/Crank-Nicolson, 1.0 for backward Euler).
82-
sigma : float or array
83-
Noise standard deviation.
81+
Theta parameter (0.5 for Crank-Nicolson, 1.0 for backward Euler).
8482
tol : float
8583
Tolerance for the Newton-Jacobi iteration.
86-
key : jax.random.PRNGKey, optional
87-
Random key for noise generation. If None, a default key is used.
84+
max_iters : int
85+
Maximum iterations for the Newton loop.
8886
8987
Returns
9088
-------
91-
ts : array
92-
Time points.
93-
ys : array
94-
Trajectory.
89+
step : callable
90+
Step function step(x, z_t, p).
91+
loop : callable
92+
Loop function loop(x0, zs, p).
9593
"""
96-
if key is None:
97-
key = jax.random.PRNGKey(42)
9894

99-
num_steps = int(tf / h)
100-
add_noise = np.any(sigma > 0.0)
95+
if not hasattr(gfun, '__call__'):
96+
sig = gfun
97+
gfun = lambda *_: sig
10198

102-
def scan_body(carry, _):
103-
y0, rng_key = carry
99+
sqrt_dt = np.sqrt(dt)
104100

105-
f_y0 = (1 - th) * f(y0, *pars)
106-
y1 = y0 + h * f_y0
101+
def step(x, z_t, p):
102+
# Euler guess
103+
f_val = dfun(x, p)
104+
f_y0 = (1 - th) * f_val
105+
y1_euler = x + dt * f_val # Initial guess using explicit Euler
107106

107+
# Refine if implicit
108108
def refine(y1):
109-
dy = _theta_dy(f, j, h, th, y0, y0, f_y0, pars, tol=tol)
110-
y1 = y0 + dy
109+
# First Newton step
110+
# We use y1 (Euler guess) to compute Jacobian and Residual
111+
dy = _theta_dy(dfun, jfun, dt, th, x, y1, f_y0, p, tol=tol)
112+
y1 = y1 + dy
111113

112114
def refinement_cond(state):
113115
y1, dy, n_iter = state
114-
return (n_iter < 10) & (np.linalg.norm(dy) > tol)
116+
return (n_iter < max_iters) & (np.linalg.norm(dy) > tol)
115117

116118
def refinement_body(state):
117119
y1, _, n_iter = state
118-
dy = _theta_dy(f, j, h, th, y0, y1, f_y0, pars, tol=tol)
120+
dy = _theta_dy(dfun, jfun, dt, th, x, y1, f_y0, p, tol=tol)
119121
y1 = y1 + dy
120122
return y1, dy, n_iter + 1
121123

122124
init_state = (y1, dy, 1)
123125
final_state = jax.lax.while_loop(refinement_cond, refinement_body, init_state)
124126
return final_state[0]
125127

126-
y1 = jax.lax.cond(th > 0.0, refine, lambda x: x, y1)
128+
y1 = jax.lax.cond(th > 0.0, refine, lambda x: x, y1_euler)
127129

128-
rng_key, step_key = jax.random.split(rng_key)
129-
noise = jax.lax.cond(
130-
add_noise,
131-
lambda k: np.sqrt(sigma) * jax.random.normal(k, y0.shape),
132-
lambda k: np.zeros_like(y0),
133-
step_key
134-
)
130+
noise = _compute_noise(gfun, x, p, sqrt_dt, z_t)
135131
y1 = y1 + noise
136132

137-
return (y1, rng_key), y1
138-
139-
_, ys = jax.lax.scan(scan_body, (y0, key), None, length=num_steps)
133+
return y1
140134

141-
# Prepend y0
142-
ys = np.concatenate([y0[None, ...], ys], axis=0)
143-
ts = np.arange(num_steps + 1) * h
135+
@jax.jit
136+
def loop(x0, zs, p):
137+
def op(x, z):
138+
x = step(x, z, p)
139+
return x, x
140+
_, xs = jax.lax.scan(op, x0, zs)
141+
return xs
144142

145-
return ts, ys
143+
return step, loop

vbjax/tests/test_implicit.py

Lines changed: 47 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,74 +5,80 @@
55
import pytest
66

77
def test_jacobi():
8-
# Solve Ax = b
9-
# A = [[4, 1], [1, 3]]
10-
# b = [1, 2]
11-
# Exact solution:
12-
# 4x + y = 1
13-
# x + 3y = 2
14-
# x = 2 - 3y
15-
# 4(2-3y) + y = 1 => 8 - 12y + y = 1 => -11y = -7 => y = 7/11
16-
# x = 2 - 21/11 = 1/11
17-
188
A = np.array([[4.0, 1.0], [1.0, 3.0]])
199
b = np.array([1.0, 2.0])
20-
2110
x, n_iter = vbjax.jacobi(A, b, tol=1e-6)
22-
2311
expected = np.linalg.solve(A, b)
2412
assert np.allclose(x, expected, atol=1e-5)
25-
print(f"Jacobi solved in {n_iter} iterations. x={x}")
26-
27-
28-
def test_theta_linear():
29-
# Test implicit scheme on a simple linear ODE: y' = -y
30-
# Analytical solution: y(t) = y0 * exp(-t)
3113

14+
def test_make_implicit_sde_linear():
15+
# dy = -k * y * dt
3216
k = 1.0
3317
def f(y, k):
3418
return -k * y
3519

3620
def j_fn(y, k):
37-
# Jacobian is scalar -k
38-
# If y is vector, Jacobian is diagonal -k * I
3921
return -k * np.eye(y.size)
4022

4123
y0 = np.array([1.0])
42-
h = 0.1
24+
dt = 0.1
4325
tf = 1.0
4426

45-
# Run with Theta=0.5 (Crank-Nicolson)
46-
ts, ys = vbjax.theta(f, j_fn, y0, h, tf, k, th=0.5)
27+
# 0.5 = Crank-Nicolson -> trapezoidal rule
28+
step, loop = vbjax.make_implicit_sde(dt, f, j_fn, 0.0, th=0.5)
4729

48-
exact = y0 * np.exp(-ts)
49-
error = np.abs(ys.flatten() - exact)
30+
# Create noise (zeros since deterministic test)
31+
n_steps = int(tf / dt)
32+
zs = np.zeros((n_steps, 1))
5033

51-
print(f"Max error (Theta=0.5): {np.max(error)}")
52-
assert np.max(error) < 1e-2
34+
ys = loop(y0, zs, k)
5335

36+
ts = np.arange(1, n_steps + 1) * dt
37+
exact = y0 * np.exp(-ts) # Actually Trapezoidal rule approximation will differ slightly from exact exp
38+
# Trapezoidal: y_{n+1} = y_n + h/2 (f_n + f_{n+1}) = y_n - k*h/2 (y_n + y_{n+1})
39+
# y_{n+1} (1 + kh/2) = y_n (1 - kh/2)
40+
# y_{n+1} = y_n * (1 - kh/2) / (1 + kh/2)
5441

55-
def test_theta_auto_jacobian():
56-
# Same as above but using jax.jacfwd
57-
k = 1.0
58-
def f(y, k):
59-
return -k * y
42+
factor = (1 - k*dt/2) / (1 + k*dt/2)
43+
expected_numeric = y0 * (factor ** np.arange(1, n_steps + 1))
44+
45+
error = np.abs(ys.flatten() - expected_numeric)
46+
assert np.max(error) < 1e-5
47+
48+
# Also check against exact just to be sure it's close
49+
error_exact = np.abs(ys.flatten() - exact)
50+
assert np.max(error_exact) < 1e-2
6051

61-
j_fn = jax.jacfwd(f, argnums=0)
52+
53+
def test_make_implicit_sde_autodiff():
54+
# dy = -y^3
55+
def f(y, p):
56+
return -y**3
57+
58+
# Auto-diff Jacobian
59+
j_fn = jax.jacfwd(f)
6260

6361
y0 = np.array([1.0])
64-
h = 0.1
62+
dt = 0.1
6563
tf = 1.0
6664

67-
ts, ys = vbjax.theta(f, j_fn, y0, h, tf, k, th=0.5)
65+
step, loop = vbjax.make_implicit_sde(dt, f, j_fn, 0.0, th=1.0) # Backward Euler
66+
67+
n_steps = int(tf / dt)
68+
zs = np.zeros((n_steps, 1))
6869

69-
exact = y0 * np.exp(-ts)
70-
error = np.abs(ys.flatten() - exact)
70+
ys = loop(y0, zs, None)
7171

72-
print(f"Max error (Theta=0.5, AutoDiff): {np.max(error)}")
73-
assert np.max(error) < 1e-2
72+
# Backward Euler: y_{n+1} = y_n - h * y_{n+1}^3
73+
# Solve y_{n+1} + h * y_{n+1}^3 - y_n = 0
74+
# Check consistency
75+
for i in range(n_steps):
76+
prev = y0 if i == 0 else ys[i-1]
77+
curr = ys[i]
78+
res = curr + dt * curr**3 - prev
79+
assert np.abs(res) < 1e-4
7480

7581
if __name__ == "__main__":
7682
test_jacobi()
77-
test_theta_linear()
78-
test_theta_auto_jacobian()
83+
test_make_implicit_sde_linear()
84+
test_make_implicit_sde_autodiff()

0 commit comments

Comments
 (0)