tinydiffeq 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/PKG-INFO +31 -11
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/README.md +30 -10
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/docs/adaptive_ad.md +48 -13
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/docs/api.md +2 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/docs/index.md +26 -15
- tinydiffeq-0.2.0/docs/llms.txt +11 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/docs/sde.md +6 -6
- tinydiffeq-0.2.0/docs/static_shapes.md +124 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/mkdocs.yml +0 -1
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/pyproject.toml +1 -1
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/src/tinydiffeq/__init__.py +12 -10
- tinydiffeq-0.2.0/src/tinydiffeq/controllers.py +182 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/src/tinydiffeq/interpolation.py +7 -6
- tinydiffeq-0.2.0/src/tinydiffeq/ode.py +230 -0
- tinydiffeq-0.1.0/src/tinydiffeq/saveat.py → tinydiffeq-0.2.0/src/tinydiffeq/save_at.py +12 -11
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/src/tinydiffeq/sde.py +35 -29
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/src/tinydiffeq/solution.py +5 -5
- tinydiffeq-0.2.0/src/tinydiffeq/solvers.py +141 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/tests/kernels_reference.py +56 -45
- tinydiffeq-0.2.0/tests/test_ad.py +238 -0
- tinydiffeq-0.2.0/tests/test_adaptive.py +371 -0
- tinydiffeq-0.2.0/tests/test_float64_subprocess.py +244 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/tests/test_gpu.py +7 -7
- tinydiffeq-0.2.0/tests/test_recompile.py +118 -0
- tinydiffeq-0.2.0/tests/test_save_at.py +139 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/tests/test_sde.py +25 -25
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/tests/test_solvers_fixed.py +58 -30
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/uv.lock +1 -1
- tinydiffeq-0.1.0/docs/llms.txt +0 -12
- tinydiffeq-0.1.0/docs/migration.md +0 -95
- tinydiffeq-0.1.0/docs/static_shapes.md +0 -96
- tinydiffeq-0.1.0/src/tinydiffeq/controllers.py +0 -72
- tinydiffeq-0.1.0/src/tinydiffeq/ode.py +0 -176
- tinydiffeq-0.1.0/src/tinydiffeq/solvers.py +0 -129
- tinydiffeq-0.1.0/tests/test_ad.py +0 -205
- tinydiffeq-0.1.0/tests/test_adaptive.py +0 -233
- tinydiffeq-0.1.0/tests/test_float64_subprocess.py +0 -158
- tinydiffeq-0.1.0/tests/test_recompile.py +0 -73
- tinydiffeq-0.1.0/tests/test_saveat.py +0 -86
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/.githooks/pre-commit +0 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/.github/workflows/ci.yml +0 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/.github/workflows/docs.yml +0 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/.github/workflows/publish.yml +0 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/.gitignore +0 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/.python-version +0 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/LICENSE +0 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/src/tinydiffeq/quadrature.py +0 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/tests/conftest.py +0 -0
- {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/tests/test_quadrature.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tinydiffeq
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Tiny differentiable ODE/SDE solvers for JAX — bounded-scan adaptive stepping, static shapes, jvp/vjp-safe
|
|
5
5
|
Project-URL: Homepage, https://github.com/HighDimensionalEconLab/tinydiffeq
|
|
6
6
|
Project-URL: Repository, https://github.com/HighDimensionalEconLab/tinydiffeq
|
|
@@ -30,19 +30,21 @@ Description-Content-Type: text/markdown
|
|
|
30
30
|
[](https://github.com/astral-sh/ruff)
|
|
31
31
|
|
|
32
32
|
Tiny differentiable ODE/SDE solvers for JAX: fixed-step Euler/RK4, adaptive
|
|
33
|
-
Tsit5 with
|
|
33
|
+
Tsit5 with integral or proportional-integral step-size control, and
|
|
34
|
+
Euler–Maruyama for Itô SDEs.
|
|
34
35
|
One bounded `lax.scan` of exactly `max_steps` iterations serves fixed and
|
|
35
36
|
adaptive stepping, so shapes are static, nothing recompiles as tolerances or
|
|
36
37
|
curvature change, and every solve is differentiable in **both** forward and
|
|
37
38
|
reverse mode — including reverse-over-forward, the pattern a
|
|
38
39
|
Levenberg–Marquardt optimizer with geodesic acceleration needs when it
|
|
39
|
-
differentiates through a rollout.
|
|
40
|
+
differentiates through a rollout. After a solve reaches its horizon, a
|
|
41
|
+
`lax.cond` skips solver and controller work during the padded scan tail.
|
|
40
42
|
|
|
41
43
|
This is a deliberately small, jvp/vjp-friendly subset of
|
|
42
44
|
[diffrax](https://docs.kidger.site/diffrax/). Use diffrax if you need pytree
|
|
43
|
-
states, stiff/implicit solvers, PID control, events,
|
|
44
|
-
checkpointed/backsolve adjoints. tinydiffeq's single runtime
|
|
45
|
-
`jax`.
|
|
45
|
+
states, stiff/implicit solvers, full derivative-term PID control, events,
|
|
46
|
+
dense output, or checkpointed/backsolve adjoints. tinydiffeq's single runtime
|
|
47
|
+
dependency is `jax`.
|
|
46
48
|
|
|
47
49
|
## Install
|
|
48
50
|
|
|
@@ -78,22 +80,40 @@ def f(x, t, args, p):
|
|
|
78
80
|
sol = solve_ode(
|
|
79
81
|
f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0),
|
|
80
82
|
p=jnp.asarray(1.3),
|
|
81
|
-
|
|
83
|
+
dt_0=0.1,
|
|
82
84
|
controller=IController(rtol=1e-8, atol=1e-10),
|
|
83
85
|
max_steps=512,
|
|
84
|
-
|
|
86
|
+
save_at=SaveAt(ts=jnp.linspace(0.0, 2.0, 21)), # fixed output shape,
|
|
85
87
|
) # however many steps adapt
|
|
86
88
|
print(sol.xs) # states on the grid
|
|
87
|
-
print(sol.ok) # reached
|
|
89
|
+
print(sol.ok) # reached t_1 within the max_steps budget?
|
|
88
90
|
```
|
|
89
91
|
|
|
92
|
+
`IController()` and `PIController()` choose tolerances from `x_0.dtype`:
|
|
93
|
+
`rtol=1e-4, atol=1e-6` for float32 and `rtol=1e-7, atol=1e-9` for
|
|
94
|
+
float64. Pass explicit values when tolerances are part of your model's
|
|
95
|
+
scientific specification. The default `dt_min` is
|
|
96
|
+
`10 * finfo(dtype).eps * max(1, abs(t_1))`.
|
|
97
|
+
|
|
98
|
+
`max_steps` is the total internal **attempt budget**: accepted steps plus
|
|
99
|
+
rejections. It is not normally the number of returned times. Endpoint mode
|
|
100
|
+
returns one time/state, `SaveAt(ts=...)` returns the requested grid, and
|
|
101
|
+
`SaveAt(steps=True)` returns the initial state and accepted internal steps as
|
|
102
|
+
a contiguous prefix of `max_steps + 1` rows. The remaining rows repeat the
|
|
103
|
+
last accepted state by default; `sol.accepted` distinguishes data from
|
|
104
|
+
padding. Rejected attempts never appear in the returned trajectory.
|
|
105
|
+
|
|
106
|
+
`SaveAt(ts=...)` also accepts a Python sequence. These are observation times:
|
|
107
|
+
the adaptive controller still chooses its own internal mesh, and cubic
|
|
108
|
+
Hermite interpolation evaluates the solution at every requested point.
|
|
109
|
+
|
|
90
110
|
## Gradients through the solve
|
|
91
111
|
|
|
92
112
|
```python
|
|
93
113
|
def endpoint(p):
|
|
94
114
|
return solve_ode(
|
|
95
115
|
f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0), p=p,
|
|
96
|
-
|
|
116
|
+
dt_0=0.1, controller=IController(rtol=1e-10, atol=1e-12),
|
|
97
117
|
max_steps=512,
|
|
98
118
|
).xs
|
|
99
119
|
|
|
@@ -109,7 +129,7 @@ non-differentiable either way, and the error-ratio power blows up at exactly
|
|
|
109
129
|
zero error); the states differentiate fully through the RK stages. See the
|
|
110
130
|
[docs](https://highdimensionaleconlab.github.io/tinydiffeq/) for the design
|
|
111
131
|
contracts: static shapes and `SaveAt`, AD through adaptive stepping, SDE key
|
|
112
|
-
semantics, and
|
|
132
|
+
semantics, and the package API.
|
|
113
133
|
|
|
114
134
|
## License
|
|
115
135
|
|
|
@@ -8,19 +8,21 @@
|
|
|
8
8
|
[](https://github.com/astral-sh/ruff)
|
|
9
9
|
|
|
10
10
|
Tiny differentiable ODE/SDE solvers for JAX: fixed-step Euler/RK4, adaptive
|
|
11
|
-
Tsit5 with
|
|
11
|
+
Tsit5 with integral or proportional-integral step-size control, and
|
|
12
|
+
Euler–Maruyama for Itô SDEs.
|
|
12
13
|
One bounded `lax.scan` of exactly `max_steps` iterations serves fixed and
|
|
13
14
|
adaptive stepping, so shapes are static, nothing recompiles as tolerances or
|
|
14
15
|
curvature change, and every solve is differentiable in **both** forward and
|
|
15
16
|
reverse mode — including reverse-over-forward, the pattern a
|
|
16
17
|
Levenberg–Marquardt optimizer with geodesic acceleration needs when it
|
|
17
|
-
differentiates through a rollout.
|
|
18
|
+
differentiates through a rollout. After a solve reaches its horizon, a
|
|
19
|
+
`lax.cond` skips solver and controller work during the padded scan tail.
|
|
18
20
|
|
|
19
21
|
This is a deliberately small, jvp/vjp-friendly subset of
|
|
20
22
|
[diffrax](https://docs.kidger.site/diffrax/). Use diffrax if you need pytree
|
|
21
|
-
states, stiff/implicit solvers, PID control, events,
|
|
22
|
-
checkpointed/backsolve adjoints. tinydiffeq's single runtime
|
|
23
|
-
`jax`.
|
|
23
|
+
states, stiff/implicit solvers, full derivative-term PID control, events,
|
|
24
|
+
dense output, or checkpointed/backsolve adjoints. tinydiffeq's single runtime
|
|
25
|
+
dependency is `jax`.
|
|
24
26
|
|
|
25
27
|
## Install
|
|
26
28
|
|
|
@@ -56,22 +58,40 @@ def f(x, t, args, p):
|
|
|
56
58
|
sol = solve_ode(
|
|
57
59
|
f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0),
|
|
58
60
|
p=jnp.asarray(1.3),
|
|
59
|
-
|
|
61
|
+
dt_0=0.1,
|
|
60
62
|
controller=IController(rtol=1e-8, atol=1e-10),
|
|
61
63
|
max_steps=512,
|
|
62
|
-
|
|
64
|
+
save_at=SaveAt(ts=jnp.linspace(0.0, 2.0, 21)), # fixed output shape,
|
|
63
65
|
) # however many steps adapt
|
|
64
66
|
print(sol.xs) # states on the grid
|
|
65
|
-
print(sol.ok) # reached
|
|
67
|
+
print(sol.ok) # reached t_1 within the max_steps budget?
|
|
66
68
|
```
|
|
67
69
|
|
|
70
|
+
`IController()` and `PIController()` choose tolerances from `x_0.dtype`:
|
|
71
|
+
`rtol=1e-4, atol=1e-6` for float32 and `rtol=1e-7, atol=1e-9` for
|
|
72
|
+
float64. Pass explicit values when tolerances are part of your model's
|
|
73
|
+
scientific specification. The default `dt_min` is
|
|
74
|
+
`10 * finfo(dtype).eps * max(1, abs(t_1))`.
|
|
75
|
+
|
|
76
|
+
`max_steps` is the total internal **attempt budget**: accepted steps plus
|
|
77
|
+
rejections. It is not normally the number of returned times. Endpoint mode
|
|
78
|
+
returns one time/state, `SaveAt(ts=...)` returns the requested grid, and
|
|
79
|
+
`SaveAt(steps=True)` returns the initial state and accepted internal steps as
|
|
80
|
+
a contiguous prefix of `max_steps + 1` rows. The remaining rows repeat the
|
|
81
|
+
last accepted state by default; `sol.accepted` distinguishes data from
|
|
82
|
+
padding. Rejected attempts never appear in the returned trajectory.
|
|
83
|
+
|
|
84
|
+
`SaveAt(ts=...)` also accepts a Python sequence. These are observation times:
|
|
85
|
+
the adaptive controller still chooses its own internal mesh, and cubic
|
|
86
|
+
Hermite interpolation evaluates the solution at every requested point.
|
|
87
|
+
|
|
68
88
|
## Gradients through the solve
|
|
69
89
|
|
|
70
90
|
```python
|
|
71
91
|
def endpoint(p):
|
|
72
92
|
return solve_ode(
|
|
73
93
|
f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0), p=p,
|
|
74
|
-
|
|
94
|
+
dt_0=0.1, controller=IController(rtol=1e-10, atol=1e-12),
|
|
75
95
|
max_steps=512,
|
|
76
96
|
).xs
|
|
77
97
|
|
|
@@ -87,7 +107,7 @@ non-differentiable either way, and the error-ratio power blows up at exactly
|
|
|
87
107
|
zero error); the states differentiate fully through the RK stages. See the
|
|
88
108
|
[docs](https://highdimensionaleconlab.github.io/tinydiffeq/) for the design
|
|
89
109
|
contracts: static shapes and `SaveAt`, AD through adaptive stepping, SDE key
|
|
90
|
-
semantics, and
|
|
110
|
+
semantics, and the package API.
|
|
91
111
|
|
|
92
112
|
## License
|
|
93
113
|
|
|
@@ -5,10 +5,31 @@ differentiable in forward mode, reverse mode, and reverse-over-forward. That
|
|
|
5
5
|
holds because of three deliberate choices about **what is not
|
|
6
6
|
differentiated**.
|
|
7
7
|
|
|
8
|
+
## Default tolerances follow the state precision
|
|
9
|
+
|
|
10
|
+
`IController()` and `PIController()` resolve omitted tolerances from the
|
|
11
|
+
state dtype at trace time:
|
|
12
|
+
|
|
13
|
+
| State/time dtype | `rtol` | `atol` | unit-scale `dt_min` |
|
|
14
|
+
|---|---:|---:|---:|
|
|
15
|
+
| float32 | `1e-4` | `1e-6` | `10 * eps` ≈ `1.19e-6` |
|
|
16
|
+
| float64 | `1e-7` | `1e-9` | `10 * eps` ≈ `2.22e-15` |
|
|
17
|
+
|
|
18
|
+
The policy never changes `jax_enable_x64`: a float32 `x_0` stays float32 even
|
|
19
|
+
when x64 is enabled globally. Explicit tolerances override the defaults and
|
|
20
|
+
are cast to the state dtype. Use explicit values whenever tolerances are part
|
|
21
|
+
of a reproducibility or accuracy contract.
|
|
22
|
+
|
|
23
|
+
Automatic `dt_min` is `10 * eps * max(1, abs(t_1))`; set it explicitly when
|
|
24
|
+
the relevant time scale differs materially from the absolute horizon. The
|
|
25
|
+
controller also floors exact-zero error ratios at machine epsilon before
|
|
26
|
+
applying a negative power; the growth-factor clip then selects maximal step
|
|
27
|
+
growth without introducing infinities.
|
|
28
|
+
|
|
8
29
|
## The controller is stop-gradiented
|
|
9
30
|
|
|
10
|
-
The step-size
|
|
11
|
-
next-step
|
|
31
|
+
The adaptive step-size controllers (`IController` and `PIController`) compute
|
|
32
|
+
their scaled error norms and next-step factors inside `stop_gradient`:
|
|
12
33
|
|
|
13
34
|
> accept/reject is a non-differentiable branch either way, the gradient of
|
|
14
35
|
> `E**(-1/order)` blows up at the exact-zero error of a flat-start policy,
|
|
@@ -21,13 +42,27 @@ pattern actually taken*. This is the same convention diffrax uses.
|
|
|
21
42
|
|
|
22
43
|
The `E**(-1/5)` blow-up is not hypothetical: a policy initialized flat gives
|
|
23
44
|
an exactly-zero error estimate on the first step, and without the
|
|
24
|
-
`stop_gradient` (plus the
|
|
45
|
+
`stop_gradient` (plus the machine-epsilon error floor) the backward pass is NaN
|
|
25
46
|
from iteration one. `tests/test_ad.py::test_grad_finite_on_flat_field` pins
|
|
26
47
|
this.
|
|
27
48
|
|
|
49
|
+
`PIController` additionally carries the previous accepted error ratio. Its
|
|
50
|
+
step-size factor is
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
safety * E_n**(-(p_coeff + i_coeff) / order)
|
|
54
|
+
* E_prev**(p_coeff / order)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
and `E_prev` changes only after acceptance. The whole recurrence is
|
|
58
|
+
controller-internal and stop-gradiented. Setting `p_coeff=0, i_coeff=1`
|
|
59
|
+
reproduces `IController` bit for bit; the default `p_coeff=0.4, i_coeff=0.3`
|
|
60
|
+
is less sensitive to oscillatory error estimates and typically rejects fewer
|
|
61
|
+
attempts on harder problems.
|
|
62
|
+
|
|
28
63
|
## The horizon clip is the growth guard
|
|
29
64
|
|
|
30
|
-
Every attempt is clipped so it cannot step past `
|
|
65
|
+
Every attempt is clipped so it cannot step past `t_1`, and — deliberately
|
|
31
66
|
unlike diffrax — the controller's next-step proposal is computed from the
|
|
32
67
|
**clipped** step:
|
|
33
68
|
|
|
@@ -36,15 +71,15 @@ unlike diffrax — the controller's next-step proposal is computed from the
|
|
|
36
71
|
> whose Gauss–Newton linearization stalls a trust-region optimizer
|
|
37
72
|
> differentiating through the rollout.
|
|
38
73
|
|
|
39
|
-
With `
|
|
74
|
+
With `factor_max = 5`, a flat field would otherwise reach `dt ≈ t_1/4` within
|
|
40
75
|
a few accepted steps; residuals sampled from three or four giant steps make
|
|
41
76
|
the optimizer's linear model useless. Clipping first means the proposal can
|
|
42
|
-
never exceed `
|
|
77
|
+
never exceed `factor_max × remaining horizon`.
|
|
43
78
|
|
|
44
79
|
One refinement over a bare `min(dt, remaining)`: when the remaining horizon
|
|
45
80
|
is within `max_steps × eps` of the desired step, the step is stretched to
|
|
46
|
-
land on `
|
|
47
|
-
`t` one accumulated ulp short of `
|
|
81
|
+
land on `t_1` exactly. Summing `n` rounded steps of `(t_1 - t_0)/n` can leave
|
|
82
|
+
`t` one accumulated ulp short of `t_1`, and without the stretch that sliver
|
|
48
83
|
would cost an extra iteration a `max_steps = n` budget doesn't have.
|
|
49
84
|
|
|
50
85
|
## Interpolation knots are non-differentiable
|
|
@@ -74,15 +109,15 @@ on the output is not enough — reverse mode differentiates both branches, and
|
|
|
74
109
|
Levenberg–Marquardt geodesic-acceleration pattern) all work through
|
|
75
110
|
adaptive solves and interpolated output, verified against closed forms in
|
|
76
111
|
`tests/test_ad.py`.
|
|
77
|
-
- `jax.vmap` over `
|
|
112
|
+
- `jax.vmap` over `x_0` or `p` gives genuinely per-lane adaptivity: each lane
|
|
78
113
|
accepts/rejects independently through the masked scan, and batched results
|
|
79
114
|
equal the individual solves exactly.
|
|
80
115
|
|
|
81
116
|
## What to watch
|
|
82
117
|
|
|
83
|
-
- Reductions over `SaveAt(steps=True)`
|
|
84
|
-
|
|
85
|
-
reject.
|
|
86
|
-
|
|
118
|
+
- Reductions over the valid prefix of `SaveAt(steps=True)` are
|
|
119
|
+
**discontinuous** in the inputs: its length changes when an accept flips to
|
|
120
|
+
a reject. Use `sol.accepted` when padding must not contribute. Because the
|
|
121
|
+
default tail repeats the endpoint, `xs[-1]` remains the reached final state.
|
|
87
122
|
- Finite-difference checks of adaptive solves are noisy for the same reason;
|
|
88
123
|
compare AD against closed forms or use fixed-step solvers for FD tests.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# tinydiffeq
|
|
2
2
|
|
|
3
3
|
`tinydiffeq` is a deliberately tiny set of differentiable ODE/SDE integrators
|
|
4
|
-
for JAX: fixed-step Euler and RK4, adaptive Tsit5 with
|
|
5
|
-
|
|
4
|
+
for JAX: fixed-step Euler and RK4, adaptive Tsit5 with integral or
|
|
5
|
+
proportional-integral step-size control, and fixed-step Euler–Maruyama for
|
|
6
|
+
Itô SDEs. Everything runs
|
|
6
7
|
inside one bounded `lax.scan` with static shapes, and every solve is
|
|
7
8
|
differentiable in **both** forward and reverse mode — including
|
|
8
9
|
reverse-over-forward, the pattern a Levenberg–Marquardt optimizer with
|
|
@@ -14,7 +15,7 @@ need any of:**
|
|
|
14
15
|
|
|
15
16
|
- pytree states (tinydiffeq states are single arrays, scalar or vector)
|
|
16
17
|
- stiff or implicit solvers
|
|
17
|
-
- PID step-size control
|
|
18
|
+
- full derivative-term PID step-size control
|
|
18
19
|
- events, root-finding, or backward-time integration
|
|
19
20
|
- dense output / continuous interpolation objects
|
|
20
21
|
- checkpointed or backsolve adjoints for long horizons
|
|
@@ -54,7 +55,7 @@ f(x, t, args, p)
|
|
|
54
55
|
nothing stops you differentiating with respect to it, but the library's
|
|
55
56
|
contracts and tests treat it as constants.
|
|
56
57
|
- `p` holds differentiable parameters — any pytree, e.g. neural-network
|
|
57
|
-
weights. jvp/vjp with respect to `p` and `
|
|
58
|
+
weights. jvp/vjp with respect to `p` and `x_0` are first-class and tested.
|
|
58
59
|
|
|
59
60
|
The arity is inspected once and the function is wrapped into the canonical
|
|
60
61
|
four-argument form, so the compiled code is identical for all four. There is
|
|
@@ -78,22 +79,28 @@ def f(x, t, args, p):
|
|
|
78
79
|
sol = solve_ode(
|
|
79
80
|
f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0),
|
|
80
81
|
p=jnp.asarray(1.3),
|
|
81
|
-
|
|
82
|
+
dt_0=0.1,
|
|
82
83
|
controller=IController(rtol=1e-8, atol=1e-10),
|
|
83
84
|
max_steps=512,
|
|
84
|
-
|
|
85
|
+
save_at=SaveAt(ts=jnp.linspace(0.0, 2.0, 21)),
|
|
85
86
|
)
|
|
86
87
|
sol.xs # (21,) states on the grid, however many internal steps were taken
|
|
87
|
-
sol.ok # False if max_steps ran out before
|
|
88
|
+
sol.ok # False if max_steps ran out before t_1
|
|
88
89
|
```
|
|
89
90
|
|
|
91
|
+
With no arguments, `IController()` and `PIController()` use precision-aware
|
|
92
|
+
tolerances: `rtol=1e-4, atol=1e-6` for float32 states and
|
|
93
|
+
`rtol=1e-7, atol=1e-9` for float64 states. Explicit values override the
|
|
94
|
+
policy and are cast to the state dtype. Automatic `dt_min` is
|
|
95
|
+
`10 * eps * max(1, abs(t_1))` in the time dtype.
|
|
96
|
+
|
|
90
97
|
Gradients go straight through the solve:
|
|
91
98
|
|
|
92
99
|
```python
|
|
93
100
|
def endpoint(p):
|
|
94
101
|
return solve_ode(
|
|
95
102
|
f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0), p=p,
|
|
96
|
-
|
|
103
|
+
dt_0=0.1, controller=IController(rtol=1e-10, atol=1e-12),
|
|
97
104
|
max_steps=512,
|
|
98
105
|
).xs
|
|
99
106
|
|
|
@@ -106,20 +113,24 @@ jax.grad(lambda p: jax.jvp(endpoint, (p,), (jnp.asarray(1.0),))[1])(
|
|
|
106
113
|
|
|
107
114
|
## Design contracts at a glance
|
|
108
115
|
|
|
109
|
-
- **`
|
|
110
|
-
-
|
|
111
|
-
|
|
116
|
+
- **`dt_0` is required.** There is no initial-step heuristic.
|
|
117
|
+
- **`max_steps` counts attempted internal steps**, including rejections. It
|
|
118
|
+
controls the bounded scan and only becomes an output-row count in
|
|
119
|
+
`SaveAt(steps=True)`, which returns `max_steps + 1` padded rows including
|
|
120
|
+
the initial state. Accepted steps form a contiguous prefix; rejected
|
|
121
|
+
attempts are omitted and the tail repeats the last accepted state.
|
|
122
|
+
- **Forward time only**: `t_1 > t_0`.
|
|
123
|
+
- **Never poisons.** `sol.ok` reports whether `t_1` was reached; callers that
|
|
112
124
|
want diverging residuals do `jnp.where(sol.ok, sol.xs, jnp.inf)`.
|
|
113
125
|
- **`project`** (an idempotent clamp, e.g. positivity) is applied at every
|
|
114
126
|
point where the vector field is evaluated and to every accepted state.
|
|
115
127
|
- **Never sets `jax_enable_x64`.** The time dtype follows
|
|
116
|
-
`jnp.result_type(
|
|
128
|
+
`jnp.result_type(x_0, float)`; float32 problems stay float32 even under
|
|
117
129
|
x64.
|
|
118
130
|
- Solvers, controllers, `SaveAt`, and `Solution` are frozen dataclasses
|
|
119
|
-
registered as pytrees: numeric fields (tolerances, grids, `
|
|
131
|
+
registered as pytrees: numeric fields (tolerances, grids, `dt_0`, `x_0`) are
|
|
120
132
|
data leaves, so changing them never recompiles.
|
|
121
133
|
|
|
122
134
|
Read next: [Static Shapes](static_shapes.md) for the bounded-scan design and
|
|
123
135
|
`SaveAt`, [Adaptive Stepping and AD](adaptive_ad.md) for what is and is not
|
|
124
|
-
differentiated, [SDEs](sde.md), and
|
|
125
|
-
[Migration](migration.md) if you are replacing hand-rolled RK4/Tsit5 loops.
|
|
136
|
+
differentiated, [SDEs](sde.md), and the [API Reference](api.md).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# tinydiffeq
|
|
2
|
+
|
|
3
|
+
> Tiny differentiable ODE/SDE solvers for JAX. Fixed-step (Euler, RK4) and adaptive (Tsit5 + integral or proportional-integral controller) explicit Runge-Kutta inside one bounded lax.scan of exactly max_steps attempted steps: static shapes, one compilation across explicit controller values/curvature/initial conditions, and solves differentiable in both forward and reverse mode (including reverse-over-forward) with O(max_steps) memory. Adaptive-controller defaults are precision-aware: rtol=1e-4, atol=1e-6 for float32 and rtol=1e-7, atol=1e-9 for float64; dt_min is 10*eps*max(1, abs(t_1)) in the time dtype. Expensive solver work is skipped after the horizon via lax.cond while the scan emits its padded tail. Array states only (scalar or vector). SaveAt selects endpoint, cubic-Hermite interpolation onto a requested grid, or a compact accepted-step prefix padded to max_steps + 1. solve_sde is fixed-step Euler-Maruyama with presampled diagonal noise keyed on a PRNG key. A deliberately small subset of diffrax: use diffrax for pytree states, stiff/implicit solvers, full derivative-term PID control, events, dense output, and adjoint methods.
|
|
4
|
+
|
|
5
|
+
## Docs
|
|
6
|
+
|
|
7
|
+
- [Home — positioning, vector-field signature convention f(x, t, args, p), minimal examples](https://highdimensionaleconlab.github.io/tinydiffeq/)
|
|
8
|
+
- [Static shapes — the bounded-scan design, SaveAt observation grids, compact accepted steps and padding, why nothing recompiles](https://highdimensionaleconlab.github.io/tinydiffeq/static_shapes/)
|
|
9
|
+
- [Adaptive stepping and AD — stop-gradiented controller rationale, horizon-clip growth guard, non-differentiable interpolation knots, double-where NaN safety](https://highdimensionaleconlab.github.io/tinydiffeq/adaptive_ad/)
|
|
10
|
+
- [SDEs — Euler-Maruyama orders, fixed-noise key semantics, shared-path strong-convergence testing, why SaveAt(ts) raises](https://highdimensionaleconlab.github.io/tinydiffeq/sde/)
|
|
11
|
+
- [API reference — solve_ode, solve_sde, solvers, controllers, SaveAt, Solution, hermite_interpolate, cumulative_trapezoid](https://highdimensionaleconlab.github.io/tinydiffeq/api/)
|
|
@@ -14,11 +14,11 @@ increments.
|
|
|
14
14
|
from tinydiffeq import solve_sde, EulerMaruyama, SaveAt
|
|
15
15
|
|
|
16
16
|
sol = solve_sde(
|
|
17
|
-
drift, diffusion, EulerMaruyama(), 0.0, 1.0,
|
|
17
|
+
drift, diffusion, EulerMaruyama(), 0.0, 1.0, x_0,
|
|
18
18
|
key=jax.random.PRNGKey(0),
|
|
19
19
|
n_steps=256,
|
|
20
20
|
p=(mu, sigma),
|
|
21
|
-
|
|
21
|
+
save_at=SaveAt(steps=True),
|
|
22
22
|
)
|
|
23
23
|
```
|
|
24
24
|
|
|
@@ -38,13 +38,13 @@ for now; a roadmap issue sketches what it would take here.
|
|
|
38
38
|
The Brownian increments are presampled once,
|
|
39
39
|
|
|
40
40
|
```python
|
|
41
|
-
|
|
41
|
+
d_w = jnp.sqrt(dt) * jax.random.normal(key, (n_steps,) + x_0.shape)
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
so a fixed `key` pins the entire noise path:
|
|
45
45
|
|
|
46
46
|
- **Reproducible** — the same key gives the same path, every call.
|
|
47
|
-
- **Differentiable with respect to `
|
|
47
|
+
- **Differentiable with respect to `x_0` and `p`** (not `key`): with the path
|
|
48
48
|
held fixed, the solution map is smooth, and jvp/vjp against finite
|
|
49
49
|
differences are tested in `tests/test_sde.py`. This is exactly the "common
|
|
50
50
|
random numbers" setup simulation-based estimators want.
|
|
@@ -61,7 +61,7 @@ X_T = X_0 \exp\left((\mu - \tfrac{\sigma^2}{2})T + \sigma W_T\right),
|
|
|
61
61
|
\qquad W_T = \textstyle\sum_k \Delta W_k .
|
|
62
62
|
$$
|
|
63
63
|
|
|
64
|
-
Because the increments `
|
|
64
|
+
Because the increments `d_w` are exactly reproducible from the key, the test
|
|
65
65
|
regenerates them, computes the exact endpoint on the same path, and checks
|
|
66
66
|
the mean absolute error slope across `dt` levels lands in `[0.35, 0.65]`.
|
|
67
67
|
That shared-path construction is the right way to measure strong error —
|
|
@@ -69,7 +69,7 @@ comparing against an independently sampled exact solution measures nothing.
|
|
|
69
69
|
|
|
70
70
|
## SaveAt for SDEs
|
|
71
71
|
|
|
72
|
-
`SaveAt(
|
|
72
|
+
`SaveAt(t_1=True)` (default) and `SaveAt(steps=True)` (`n_steps + 1` rows,
|
|
73
73
|
all accepted) are supported. `SaveAt(ts=...)` **raises**: cubic Hermite
|
|
74
74
|
interpolation assumes smooth trajectories and is simply wrong between the
|
|
75
75
|
points of a rough path. Land your grid on the step boundaries instead by
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Static Shapes
|
|
2
|
+
|
|
3
|
+
JAX jits fixed-shape programs. An adaptive integrator is naturally
|
|
4
|
+
dynamic — the number of steps depends on the data — so something must give.
|
|
5
|
+
tinydiffeq's answer is a **bounded scan**: `solve_ode` always runs one
|
|
6
|
+
`lax.scan` of exactly `max_steps` iterations, whatever the controller does.
|
|
7
|
+
|
|
8
|
+
Each iteration attempts one step:
|
|
9
|
+
|
|
10
|
+
- an **accepted** attempt advances `(t, x)` and (for FSAL solvers) reuses the
|
|
11
|
+
last stage as the next first stage;
|
|
12
|
+
- a **rejected** attempt leaves the state in place and retries with a smaller
|
|
13
|
+
step;
|
|
14
|
+
- once `t_1` is reached, the remaining iterations **freeze**.
|
|
15
|
+
|
|
16
|
+
The raw internal scan buffer contains repeated rows for rejected and frozen
|
|
17
|
+
iterations. That buffer is an implementation detail used by interpolation;
|
|
18
|
+
step output compacts it into accepted rows plus tail padding.
|
|
19
|
+
|
|
20
|
+
The scan loop itself always has `max_steps` iterations, preserving static
|
|
21
|
+
shapes and reverse-mode AD. A scalar `lax.cond` nevertheless keeps the
|
|
22
|
+
expensive vector-field, solver-stage, and controller computations out of the
|
|
23
|
+
frozen tail, so a scalar solve's compute cost follows its attempted steps plus
|
|
24
|
+
cheap loop overhead. Under `vmap`, JAX may turn each lane's conditional into
|
|
25
|
+
selection; batched work can therefore continue until the slowest lane
|
|
26
|
+
finishes.
|
|
27
|
+
|
|
28
|
+
Fixed-step and adaptive integration share this single code path:
|
|
29
|
+
`ConstantStepSize` accepts every attempt, so `dt_0 = (t_1 - t_0)/n` with
|
|
30
|
+
`max_steps = n` reproduces a fixed grid exactly. The conditional has modest
|
|
31
|
+
overhead for very cheap solves whose budget is already exact; it pays off
|
|
32
|
+
when the budget has a padded tail or the vector field is expensive.
|
|
33
|
+
|
|
34
|
+
If the budget runs out before `t_1`, `sol.ok` is `False` and the outputs hold
|
|
35
|
+
the reached prefix. The package never poisons values; the caller decides:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
xs = jnp.where(sol.ok, sol.xs, jnp.inf) # kernels-style rejection
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## SaveAt is the shape contract
|
|
42
|
+
|
|
43
|
+
Exactly one of three modes:
|
|
44
|
+
|
|
45
|
+
### `SaveAt(t_1=True)` — endpoint only (default)
|
|
46
|
+
|
|
47
|
+
`sol.ts` is the reached time (equals `t_1` when `ok`), `sol.xs` the final
|
|
48
|
+
state.
|
|
49
|
+
|
|
50
|
+
### `SaveAt(ts=grid)` — interpolation onto a fixed grid
|
|
51
|
+
|
|
52
|
+
This is the answer to "adaptive steps vs static shapes". Internal steps
|
|
53
|
+
adapt freely; the output is cubic-Hermite interpolation onto your fixed
|
|
54
|
+
query grid, so `sol.xs.shape == (len(grid),) + x_0.shape` **regardless of how
|
|
55
|
+
many steps the controller took**. Changing tolerances, initial conditions,
|
|
56
|
+
or curvature changes the internal knots but never the output shape — no
|
|
57
|
+
recompilation. A one-dimensional JAX/NumPy array or Python sequence is
|
|
58
|
+
accepted; times must be nondecreasing and within `[t_0, t_1]`, while repeated
|
|
59
|
+
times and omitted endpoints are allowed. Changing values without changing
|
|
60
|
+
the grid length does not recompile.
|
|
61
|
+
|
|
62
|
+
These are observation times, not mandatory internal stops. The adaptive
|
|
63
|
+
controller chooses exactly the same mesh regardless of the requested grid,
|
|
64
|
+
then the solver evaluates every requested point through dense interpolation.
|
|
65
|
+
Forcing exact internal landing times is a distinct feature and is not part of
|
|
66
|
+
this API.
|
|
67
|
+
|
|
68
|
+
The interpolation runs directly over the raw padded rows: duplicate knots
|
|
69
|
+
from rejections or the frozen tail form zero-width brackets, and the
|
|
70
|
+
bracketing `searchsorted` lands on the last duplicate at-or-before each
|
|
71
|
+
query, so no compaction pass is needed. Queries outside the knot span clamp
|
|
72
|
+
to the boundary values — in particular, when `ok` is `False`, queries beyond
|
|
73
|
+
the reached time return the last state (flat extrapolation) rather than
|
|
74
|
+
evaluating a cubic outside its bracket.
|
|
75
|
+
|
|
76
|
+
The interpolant is 4th-order accurate between 5th-order-accurate knots:
|
|
77
|
+
expect grid values slightly less accurate than the knots themselves, which
|
|
78
|
+
is the standard dense-output trade-off.
|
|
79
|
+
|
|
80
|
+
### `SaveAt(steps=True)` — accepted steps with padding
|
|
81
|
+
|
|
82
|
+
`max_steps + 1` rows including the initial state. Accepted internal steps are
|
|
83
|
+
gathered chronologically into a contiguous prefix; rejected attempts are not
|
|
84
|
+
returned. `sol.accepted` is the validity mask (`accepted[0]` is always
|
|
85
|
+
`True`, so `accepted.sum() == num_accepted + 1`). On a successful solve, the
|
|
86
|
+
endpoint is at index `num_accepted`.
|
|
87
|
+
|
|
88
|
+
- `fill="last"` (default) repeats the last valid time and state through the
|
|
89
|
+
padded tail.
|
|
90
|
+
- `fill="inf"` fills only the invalid tail with `inf`.
|
|
91
|
+
|
|
92
|
+
If the attempt budget is exhausted, the same contract holds for the reached
|
|
93
|
+
accepted prefix, the tail repeats or masks its last state, and `sol.ok` is
|
|
94
|
+
`False`. No fake endpoint is inserted. Compaction is a stable fixed-size
|
|
95
|
+
gather; it performs no sorting.
|
|
96
|
+
|
|
97
|
+
## Why one compilation, precisely
|
|
98
|
+
|
|
99
|
+
- The scan length `max_steps` is static; nothing else about the loop depends
|
|
100
|
+
on data shapes.
|
|
101
|
+
- Tolerances and PI coefficients (`IController(...)` / `PIController(...)`),
|
|
102
|
+
`dt_0`, `t_0`, `t_1`, `x_0`,
|
|
103
|
+
`args`, `p`, and `SaveAt.ts` are pytree **data leaves**. Only genuine
|
|
104
|
+
structure — the solver type, `SaveAt` mode, `fill`, `max_steps`, the
|
|
105
|
+
functions themselves — is static.
|
|
106
|
+
|
|
107
|
+
An omitted tolerance or `dt_min` is represented by `None`, so switching a
|
|
108
|
+
jitted call between automatic and explicit values changes the controller
|
|
109
|
+
pytree structure and compiles once for each policy. Changing the numeric
|
|
110
|
+
values of already-explicit controller fields does not recompile.
|
|
111
|
+
|
|
112
|
+
So this compiles once:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
@jax.jit
|
|
116
|
+
def run(x_0, dt_0, controller, args):
|
|
117
|
+
return solve_ode(f, Tsit5(), 0.0, 1.0, x_0, args=args, dt_0=dt_0,
|
|
118
|
+
controller=controller, max_steps=128,
|
|
119
|
+
save_at=SaveAt(steps=True))
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
across different curvatures (different accepted counts), tolerances, initial
|
|
123
|
+
steps, and initial conditions — pinned by `tests/test_recompile.py` with
|
|
124
|
+
`_cache_size() == 1` assertions.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tinydiffeq"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Tiny differentiable ODE/SDE solvers for JAX — bounded-scan adaptive stepping, static shapes, jvp/vjp-safe"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
"""Tiny differentiable ODE/SDE solvers for JAX.
|
|
2
2
|
|
|
3
3
|
solve_ode integrates dx/dt = f(x, t, args, p) with fixed-step (Euler, RK4)
|
|
4
|
-
or adaptive (Tsit5 + IController) explicit Runge-Kutta methods
|
|
5
|
-
bounded lax.scan of exactly max_steps iterations, so shapes are
|
|
6
|
-
solves are differentiable in BOTH forward and reverse mode
|
|
7
|
-
reverse-over-forward) with O(max_steps) memory. SaveAt picks the
|
|
8
|
-
the endpoint, cubic-Hermite interpolation onto a fixed grid, or
|
|
9
|
-
|
|
4
|
+
or adaptive (Tsit5 + IController/PIController) explicit Runge-Kutta methods
|
|
5
|
+
inside one bounded lax.scan of exactly max_steps iterations, so shapes are
|
|
6
|
+
static and solves are differentiable in BOTH forward and reverse mode
|
|
7
|
+
(including reverse-over-forward) with O(max_steps) memory. SaveAt picks the
|
|
8
|
+
output: the endpoint, cubic-Hermite interpolation onto a fixed grid, or
|
|
9
|
+
accepted internal steps with fixed-shape padding. solve_sde is fixed-step
|
|
10
|
+
Euler-Maruyama with presampled
|
|
10
11
|
diagonal noise. States are arrays (scalar or vector); pytree states,
|
|
11
|
-
implicit/stiff solvers, PID control, events, dense
|
|
12
|
-
methods are non-goals — use diffrax for those.
|
|
12
|
+
implicit/stiff solvers, full derivative-term PID control, events, dense
|
|
13
|
+
output, and adjoint methods are non-goals — use diffrax for those.
|
|
13
14
|
"""
|
|
14
15
|
|
|
15
|
-
from tinydiffeq.controllers import ConstantStepSize, IController
|
|
16
|
+
from tinydiffeq.controllers import ConstantStepSize, IController, PIController
|
|
16
17
|
from tinydiffeq.interpolation import hermite_interpolate
|
|
17
18
|
from tinydiffeq.ode import solve_ode
|
|
18
19
|
from tinydiffeq.quadrature import cumulative_trapezoid
|
|
19
|
-
from tinydiffeq.
|
|
20
|
+
from tinydiffeq.save_at import SaveAt
|
|
20
21
|
from tinydiffeq.sde import solve_sde
|
|
21
22
|
from tinydiffeq.solution import Solution
|
|
22
23
|
from tinydiffeq.solvers import RK4, Euler, EulerMaruyama, Tsit5
|
|
@@ -30,6 +31,7 @@ __all__ = [
|
|
|
30
31
|
"EulerMaruyama",
|
|
31
32
|
"ConstantStepSize",
|
|
32
33
|
"IController",
|
|
34
|
+
"PIController",
|
|
33
35
|
"SaveAt",
|
|
34
36
|
"Solution",
|
|
35
37
|
"hermite_interpolate",
|