tinydiffeq 0.1.0__py3-none-any.whl
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/__init__.py +37 -0
- tinydiffeq/controllers.py +72 -0
- tinydiffeq/interpolation.py +47 -0
- tinydiffeq/ode.py +176 -0
- tinydiffeq/quadrature.py +31 -0
- tinydiffeq/saveat.py +39 -0
- tinydiffeq/sde.py +86 -0
- tinydiffeq/solution.py +28 -0
- tinydiffeq/solvers.py +129 -0
- tinydiffeq-0.1.0.dist-info/METADATA +116 -0
- tinydiffeq-0.1.0.dist-info/RECORD +13 -0
- tinydiffeq-0.1.0.dist-info/WHEEL +4 -0
- tinydiffeq-0.1.0.dist-info/licenses/LICENSE +21 -0
tinydiffeq/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Tiny differentiable ODE/SDE solvers for JAX.
|
|
2
|
+
|
|
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 inside one
|
|
5
|
+
bounded lax.scan of exactly max_steps iterations, so shapes are static and
|
|
6
|
+
solves are differentiable in BOTH forward and reverse mode (including
|
|
7
|
+
reverse-over-forward) with O(max_steps) memory. SaveAt picks the output:
|
|
8
|
+
the endpoint, cubic-Hermite interpolation onto a fixed grid, or the raw
|
|
9
|
+
padded step rows. solve_sde is fixed-step Euler-Maruyama with presampled
|
|
10
|
+
diagonal noise. States are arrays (scalar or vector); pytree states,
|
|
11
|
+
implicit/stiff solvers, PID control, events, dense output, and adjoint
|
|
12
|
+
methods are non-goals — use diffrax for those.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from tinydiffeq.controllers import ConstantStepSize, IController
|
|
16
|
+
from tinydiffeq.interpolation import hermite_interpolate
|
|
17
|
+
from tinydiffeq.ode import solve_ode
|
|
18
|
+
from tinydiffeq.quadrature import cumulative_trapezoid
|
|
19
|
+
from tinydiffeq.saveat import SaveAt
|
|
20
|
+
from tinydiffeq.sde import solve_sde
|
|
21
|
+
from tinydiffeq.solution import Solution
|
|
22
|
+
from tinydiffeq.solvers import RK4, Euler, EulerMaruyama, Tsit5
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"solve_ode",
|
|
26
|
+
"solve_sde",
|
|
27
|
+
"Euler",
|
|
28
|
+
"RK4",
|
|
29
|
+
"Tsit5",
|
|
30
|
+
"EulerMaruyama",
|
|
31
|
+
"ConstantStepSize",
|
|
32
|
+
"IController",
|
|
33
|
+
"SaveAt",
|
|
34
|
+
"Solution",
|
|
35
|
+
"hermite_interpolate",
|
|
36
|
+
"cumulative_trapezoid",
|
|
37
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import jax
|
|
4
|
+
import jax.numpy as jnp
|
|
5
|
+
|
|
6
|
+
# Controllers decide accept/reject and the next step size from the embedded
|
|
7
|
+
# error estimate. Contract: adapt(x0, x1, err, dt_used, dt_prev, order) ->
|
|
8
|
+
# (accept, dt_next), where dt_used is the horizon-clipped step the solver
|
|
9
|
+
# actually took and dt_prev is the unclipped carried step size. All numeric
|
|
10
|
+
# fields are pytree data leaves, so changing tolerances never recompiles.
|
|
11
|
+
|
|
12
|
+
SAFETY, MIN_FACTOR, MAX_FACTOR = 0.9, 0.2, 5.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@jax.tree_util.register_dataclass
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ConstantStepSize:
|
|
18
|
+
"""Accept every step and keep the carried step size unchanged."""
|
|
19
|
+
|
|
20
|
+
uses_error_estimate = False
|
|
21
|
+
|
|
22
|
+
def adapt(self, x0, x1, err, dt_used, dt_prev, order):
|
|
23
|
+
return jnp.asarray(True), dt_prev
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@jax.tree_util.register_dataclass
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class IController:
|
|
29
|
+
"""Integral step-size controller with max-norm error.
|
|
30
|
+
|
|
31
|
+
Accept iff ``E = max(|err| / (atol + rtol * max(|x0|, |x1|))) <= 1``
|
|
32
|
+
(forced accept once the step reaches ``dtmin``), and propose
|
|
33
|
+
``dt_next = dt_used * clip(safety * E**(-1/order), factormin, factormax)``
|
|
34
|
+
clipped to ``[dtmin, dtmax]``. This is the classic integral controller —
|
|
35
|
+
equal to diffrax's ``PIDController`` at its default coefficients
|
|
36
|
+
(pcoeff=0, dcoeff=0); there is no proportional term, hence the name.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
rtol: float
|
|
40
|
+
atol: float
|
|
41
|
+
dtmin: float = 1e-10
|
|
42
|
+
dtmax: float = float("inf")
|
|
43
|
+
safety: float = SAFETY
|
|
44
|
+
factormin: float = MIN_FACTOR
|
|
45
|
+
factormax: float = MAX_FACTOR
|
|
46
|
+
|
|
47
|
+
uses_error_estimate = True
|
|
48
|
+
|
|
49
|
+
def adapt(self, x0, x1, err, dt_used, dt_prev, order):
|
|
50
|
+
dtype = jnp.result_type(x0, float)
|
|
51
|
+
rtol = jnp.asarray(self.rtol, dtype)
|
|
52
|
+
atol = jnp.asarray(self.atol, dtype)
|
|
53
|
+
dtmin = jnp.asarray(self.dtmin, jnp.result_type(dt_used))
|
|
54
|
+
dtmax = jnp.asarray(self.dtmax, jnp.result_type(dt_used))
|
|
55
|
+
safety = jnp.asarray(self.safety, dtype)
|
|
56
|
+
factormin = jnp.asarray(self.factormin, dtype)
|
|
57
|
+
factormax = jnp.asarray(self.factormax, dtype)
|
|
58
|
+
scale = atol + rtol * jnp.maximum(jnp.abs(x0), jnp.abs(x1))
|
|
59
|
+
# The controller is wrapped in stop_gradient: accept/reject is a
|
|
60
|
+
# non-differentiable branch either way, the gradient of E**(-1/order)
|
|
61
|
+
# blows up at the exact-zero error of a flat-start policy, and the
|
|
62
|
+
# d(dt)/dtheta term only slides sample points along the visited
|
|
63
|
+
# trajectory -- irrelevant to a residual that must vanish at every
|
|
64
|
+
# state. The states themselves remain fully differentiable through
|
|
65
|
+
# the RK stages.
|
|
66
|
+
E = jax.lax.stop_gradient(jnp.max(jnp.abs(err) / scale))
|
|
67
|
+
accept = (E <= 1.0) | (dt_used <= dtmin)
|
|
68
|
+
factor = jnp.clip(
|
|
69
|
+
safety * jnp.maximum(E, 1e-12) ** (-1.0 / order), factormin, factormax
|
|
70
|
+
)
|
|
71
|
+
dt_next = jnp.clip(jax.lax.stop_gradient(dt_used) * factor, dtmin, dtmax)
|
|
72
|
+
return accept, dt_next
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import jax.numpy as jnp
|
|
2
|
+
|
|
3
|
+
# Cubic Hermite interpolation over the raw attempt rows of the bounded scan.
|
|
4
|
+
# The knot times are nondecreasing with exact duplicates (rejected attempts
|
|
5
|
+
# and the post-horizon frozen tail repeat the previous row), so no compaction
|
|
6
|
+
# is needed: searchsorted(side="right") - 1 lands on the LAST duplicate at or
|
|
7
|
+
# before each query, giving a positive-width bracket everywhere except the
|
|
8
|
+
# frozen tail. Degenerate (zero-width) brackets return the left knot -- flat
|
|
9
|
+
# extrapolation beyond the reached time when the budget was exhausted. The
|
|
10
|
+
# bracketing indices are integer and non-differentiable, consistent with the
|
|
11
|
+
# stop-gradiented step controller (the sliding-knot d(dt)/dtheta term is
|
|
12
|
+
# deliberately excluded); values differentiate fully through xL, xR, fL, fR.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def hermite_interpolate(ts_query, knot_ts, knot_xs, knot_fs):
|
|
16
|
+
"""Cubic Hermite interpolation of ``(knot_ts, knot_xs, knot_fs)`` at
|
|
17
|
+
``ts_query``, where ``knot_fs`` holds the time derivatives at the knots.
|
|
18
|
+
|
|
19
|
+
``knot_ts`` must be nondecreasing; duplicate knots are allowed and
|
|
20
|
+
queries falling on a zero-width bracket return the left knot value.
|
|
21
|
+
Queries outside the knot span clamp to the boundary knot values (flat
|
|
22
|
+
extrapolation) rather than evaluating the cubic outside its bracket.
|
|
23
|
+
"""
|
|
24
|
+
n = knot_ts.shape[0]
|
|
25
|
+
idx = jnp.clip(jnp.searchsorted(knot_ts, ts_query, side="right") - 1, 0, n - 2)
|
|
26
|
+
t_left, t_right = knot_ts[idx], knot_ts[idx + 1]
|
|
27
|
+
x_left, x_right = knot_xs[idx], knot_xs[idx + 1]
|
|
28
|
+
f_left, f_right = knot_fs[idx], knot_fs[idx + 1]
|
|
29
|
+
width = t_right - t_left
|
|
30
|
+
degenerate = width <= 0.0
|
|
31
|
+
# double-where: divide by the safe width BEFORE branching so neither the
|
|
32
|
+
# primal nor its jvp/vjp ever sees a 0/0.
|
|
33
|
+
width_safe = jnp.where(degenerate, 1.0, width)
|
|
34
|
+
s = jnp.clip((ts_query - t_left) / width_safe, 0.0, 1.0)
|
|
35
|
+
|
|
36
|
+
extra = knot_xs.ndim - 1
|
|
37
|
+
|
|
38
|
+
def bc(a):
|
|
39
|
+
return a.reshape(a.shape + (1,) * extra)
|
|
40
|
+
|
|
41
|
+
s_, w_, deg_ = bc(s), bc(width_safe), bc(degenerate)
|
|
42
|
+
h00 = (1.0 + 2.0 * s_) * (1.0 - s_) ** 2
|
|
43
|
+
h10 = s_ * (1.0 - s_) ** 2
|
|
44
|
+
h01 = s_**2 * (3.0 - 2.0 * s_)
|
|
45
|
+
h11 = s_**2 * (s_ - 1.0)
|
|
46
|
+
value = h00 * x_left + h10 * w_ * f_left + h01 * x_right + h11 * w_ * f_right
|
|
47
|
+
return jnp.where(deg_, x_left, value)
|
tinydiffeq/ode.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
import jax
|
|
4
|
+
import jax.numpy as jnp
|
|
5
|
+
|
|
6
|
+
from tinydiffeq.controllers import ConstantStepSize
|
|
7
|
+
from tinydiffeq.interpolation import hermite_interpolate
|
|
8
|
+
from tinydiffeq.saveat import SaveAt
|
|
9
|
+
from tinydiffeq.solution import Solution
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def identity_project(x):
|
|
13
|
+
return x
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def canonicalize_field(f, name="f"):
|
|
17
|
+
# The vector field may take (x), (x, t), (x, t, args), or (x, t, args, p),
|
|
18
|
+
# always in that order; it is wrapped into the canonical 4-arg form here
|
|
19
|
+
# so the compiled code is identical for all four. Uninspectable
|
|
20
|
+
# signatures (or *args) are assumed 4-arg.
|
|
21
|
+
try:
|
|
22
|
+
signature = inspect.signature(f)
|
|
23
|
+
except (TypeError, ValueError):
|
|
24
|
+
arity = 4
|
|
25
|
+
else:
|
|
26
|
+
arity = 0
|
|
27
|
+
for parameter in signature.parameters.values():
|
|
28
|
+
if parameter.kind in (
|
|
29
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
30
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
31
|
+
):
|
|
32
|
+
arity += 1
|
|
33
|
+
elif parameter.kind == inspect.Parameter.VAR_POSITIONAL:
|
|
34
|
+
arity = 4
|
|
35
|
+
break
|
|
36
|
+
if arity < 1 or arity > 4:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"{name} must take 1 to 4 positional arguments: "
|
|
39
|
+
"(x), (x, t), (x, t, args), or (x, t, args, p)"
|
|
40
|
+
)
|
|
41
|
+
if arity == 1:
|
|
42
|
+
return lambda x, t, args, p: f(x)
|
|
43
|
+
if arity == 2:
|
|
44
|
+
return lambda x, t, args, p: f(x, t)
|
|
45
|
+
if arity == 3:
|
|
46
|
+
return lambda x, t, args, p: f(x, t, args)
|
|
47
|
+
return f
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def solve_ode(
|
|
51
|
+
f,
|
|
52
|
+
solver,
|
|
53
|
+
t0,
|
|
54
|
+
t1,
|
|
55
|
+
x0,
|
|
56
|
+
*,
|
|
57
|
+
p=None,
|
|
58
|
+
args=None,
|
|
59
|
+
dt0=None,
|
|
60
|
+
saveat=None,
|
|
61
|
+
controller=None,
|
|
62
|
+
max_steps=4096,
|
|
63
|
+
project=None,
|
|
64
|
+
):
|
|
65
|
+
"""Integrate ``dx/dt = f(x, t, args, p)`` from ``t0`` to ``t1 > t0``.
|
|
66
|
+
|
|
67
|
+
The vector field may be declared ``f(x)``, ``f(x, t)``, ``f(x, t, args)``,
|
|
68
|
+
or ``f(x, t, args, p)`` — always in that order. ``x`` is an array state
|
|
69
|
+
(scalar or vector), ``args`` is pass-through data that is by convention
|
|
70
|
+
not an AD target, and ``p`` holds differentiable parameters (any pytree);
|
|
71
|
+
jvp/vjp with respect to ``p`` and ``x0`` are first-class.
|
|
72
|
+
|
|
73
|
+
Fixed and adaptive stepping share one bounded ``lax.scan`` of exactly
|
|
74
|
+
``max_steps`` iterations, so shapes are static and curvature-dependent
|
|
75
|
+
step counts never retrace. ``dt0`` is required (no auto-initial-step
|
|
76
|
+
heuristic). Each attempt is clipped to the remaining horizon; the clipped
|
|
77
|
+
step also feeds the controller's next-step proposal, which doubles as the
|
|
78
|
+
growth guard — near-flat fields otherwise grow steps into quarter-horizon
|
|
79
|
+
leaps. ``project`` (e.g. a positivity clamp, assumed idempotent) is
|
|
80
|
+
applied at every point where ``f`` is evaluated and to every accepted
|
|
81
|
+
state. Returns a :class:`Solution`; ``sol.ok`` is False if the budget ran
|
|
82
|
+
out before ``t1`` (outputs are then the reached prefix, never poisoned).
|
|
83
|
+
|
|
84
|
+
The time dtype follows ``jnp.result_type(x0, float)``; the library never
|
|
85
|
+
sets ``jax_enable_x64`` — do that in your application.
|
|
86
|
+
"""
|
|
87
|
+
if dt0 is None:
|
|
88
|
+
raise ValueError("dt0 is required (tinydiffeq has no initial-step heuristic)")
|
|
89
|
+
if saveat is None:
|
|
90
|
+
saveat = SaveAt(t1=True)
|
|
91
|
+
if controller is None:
|
|
92
|
+
controller = ConstantStepSize()
|
|
93
|
+
if project is None:
|
|
94
|
+
project = identity_project
|
|
95
|
+
if controller.uses_error_estimate and not solver.has_error_estimate:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"{type(controller).__name__} needs an embedded error estimate, "
|
|
98
|
+
f"which {type(solver).__name__} does not provide"
|
|
99
|
+
)
|
|
100
|
+
f = canonicalize_field(f)
|
|
101
|
+
|
|
102
|
+
x0 = jnp.asarray(x0)
|
|
103
|
+
tdt = jnp.result_type(x0, float)
|
|
104
|
+
t0 = jnp.asarray(t0, tdt)
|
|
105
|
+
t1 = jnp.asarray(t1, tdt)
|
|
106
|
+
dt0 = jnp.asarray(dt0, tdt)
|
|
107
|
+
t_eps = 4.0 * jnp.finfo(tdt).eps * jnp.maximum(1.0, jnp.abs(t1))
|
|
108
|
+
# Summing ~max_steps rounded steps can leave t short of t1 by up to
|
|
109
|
+
# ~max_steps * eps, so a step whose remaining horizon is within that
|
|
110
|
+
# slack of the desired dt is stretched to land on t1 exactly; otherwise
|
|
111
|
+
# dt0 = (t1 - t0)/n with max_steps = n would strand a one-ulp sliver.
|
|
112
|
+
t_slack = max_steps * t_eps
|
|
113
|
+
|
|
114
|
+
def g(x, t):
|
|
115
|
+
return f(project(x), t, args, p)
|
|
116
|
+
|
|
117
|
+
need_f = solver.fsal or (saveat.ts is not None)
|
|
118
|
+
f_init = g(x0, t0) if need_f else jnp.zeros_like(x0)
|
|
119
|
+
|
|
120
|
+
def body(carry, _):
|
|
121
|
+
t, x, dt, f_cur, done, num_accepted = carry
|
|
122
|
+
remaining = t1 - t
|
|
123
|
+
h = jnp.where(remaining <= dt + t_slack, jnp.maximum(remaining, 1e-12), dt)
|
|
124
|
+
x1, f1, err = solver.step(g, t, x, h, f_cur if need_f else None, project)
|
|
125
|
+
if need_f and f1 is None:
|
|
126
|
+
f1 = g(x1, t + h)
|
|
127
|
+
accept, dt_next = controller.adapt(x, x1, err, h, dt, solver.order)
|
|
128
|
+
advance = accept & ~done
|
|
129
|
+
x_new = jnp.where(advance, x1, x)
|
|
130
|
+
t_new = jnp.where(advance, t + h, t)
|
|
131
|
+
f_new = jnp.where(advance, f1, f_cur) if need_f else f_cur
|
|
132
|
+
dt_new = jnp.where(done, dt, dt_next)
|
|
133
|
+
done_new = done | (t_new >= t1 - t_eps)
|
|
134
|
+
num_new = num_accepted + advance.astype(jnp.int32)
|
|
135
|
+
carry_new = (t_new, x_new, dt_new, f_new, done_new, num_new)
|
|
136
|
+
if saveat.t1:
|
|
137
|
+
out = None
|
|
138
|
+
elif saveat.steps:
|
|
139
|
+
out = (t_new, x_new, advance)
|
|
140
|
+
else:
|
|
141
|
+
out = (t_new, x_new, f_new, advance)
|
|
142
|
+
return carry_new, out
|
|
143
|
+
|
|
144
|
+
carry0 = (t0, x0, dt0, f_init, jnp.asarray(False), jnp.asarray(0, jnp.int32))
|
|
145
|
+
(t_final, x_final, _, _, done, num_accepted), rows = jax.lax.scan(
|
|
146
|
+
body, carry0, None, length=max_steps
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if saveat.t1:
|
|
150
|
+
return Solution(ts=t_final, xs=x_final, ok=done, num_accepted=num_accepted)
|
|
151
|
+
|
|
152
|
+
if saveat.steps:
|
|
153
|
+
ts_s, xs_s, adv_s = rows
|
|
154
|
+
else:
|
|
155
|
+
ts_s, xs_s, fs_s, adv_s = rows
|
|
156
|
+
ts_all = jnp.concatenate([t0[None], ts_s])
|
|
157
|
+
xs_all = jnp.concatenate([x0[None], xs_s])
|
|
158
|
+
accepted = jnp.concatenate([jnp.ones((1,), bool), adv_s])
|
|
159
|
+
|
|
160
|
+
if saveat.steps:
|
|
161
|
+
if saveat.fill == "inf":
|
|
162
|
+
ts_all = jnp.where(accepted, ts_all, jnp.inf)
|
|
163
|
+
keep = accepted.reshape(accepted.shape + (1,) * (xs_all.ndim - 1))
|
|
164
|
+
xs_all = jnp.where(keep, xs_all, jnp.inf)
|
|
165
|
+
return Solution(
|
|
166
|
+
ts=ts_all,
|
|
167
|
+
xs=xs_all,
|
|
168
|
+
ok=done,
|
|
169
|
+
num_accepted=num_accepted,
|
|
170
|
+
accepted=accepted,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
fs_all = jnp.concatenate([f_init[None], fs_s])
|
|
174
|
+
ts_query = jnp.asarray(saveat.ts, tdt)
|
|
175
|
+
xs_query = hermite_interpolate(ts_query, ts_all, xs_all, fs_all)
|
|
176
|
+
return Solution(ts=ts_query, xs=xs_query, ok=done, num_accepted=num_accepted)
|
tinydiffeq/quadrature.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import jax
|
|
2
|
+
import jax.numpy as jnp
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def cumulative_trapezoid(g, ts, *, substeps=1):
|
|
6
|
+
"""Cumulative composite-trapezoid integral of a time-only ``g(t)`` on the
|
|
7
|
+
(possibly nonuniform) sorted grid ``ts``.
|
|
8
|
+
|
|
9
|
+
Each grid interval is subdivided into ``substeps`` uniform panels, so the
|
|
10
|
+
quadrature error shrinks with ``substeps`` without changing the output
|
|
11
|
+
grid. Returns ``(integral, values)`` where ``integral[k]`` approximates
|
|
12
|
+
the integral of ``g`` from ``ts[0]`` to ``ts[k]`` (``integral[0] = 0``)
|
|
13
|
+
and ``values = g(ts)``; ``g`` may return any array shape, which is
|
|
14
|
+
appended to the leading grid axis.
|
|
15
|
+
"""
|
|
16
|
+
if substeps < 1:
|
|
17
|
+
raise ValueError("substeps must be at least 1")
|
|
18
|
+
values = jax.vmap(g)(ts)
|
|
19
|
+
|
|
20
|
+
def interval_increment(left, right):
|
|
21
|
+
nodes = jnp.linspace(left, right, substeps + 1)
|
|
22
|
+
node_values = jax.vmap(g)(nodes)
|
|
23
|
+
widths = nodes[1:] - nodes[:-1]
|
|
24
|
+
widths = widths.reshape(widths.shape + (1,) * (node_values.ndim - 1))
|
|
25
|
+
return jnp.sum(0.5 * widths * (node_values[:-1] + node_values[1:]), axis=0)
|
|
26
|
+
|
|
27
|
+
increments = jax.vmap(interval_increment)(ts[:-1], ts[1:])
|
|
28
|
+
integral = jnp.concatenate(
|
|
29
|
+
[jnp.zeros_like(increments[:1]), jnp.cumsum(increments, axis=0)]
|
|
30
|
+
)
|
|
31
|
+
return integral, values
|
tinydiffeq/saveat.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
import jax
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@jax.tree_util.register_dataclass
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class SaveAt:
|
|
9
|
+
"""What ``solve_ode``/``solve_sde`` return. Exactly one mode must be set.
|
|
10
|
+
|
|
11
|
+
- ``t1=True``: the endpoint only (the default in the solve functions).
|
|
12
|
+
- ``ts=grid``: cubic-Hermite interpolation of the internal steps onto a
|
|
13
|
+
fixed, sorted query grid in ``[t0, t1]``. Output shape is
|
|
14
|
+
``(len(ts), ...)`` regardless of how many internal steps the controller
|
|
15
|
+
takes, so changing curvature never changes shapes or recompiles.
|
|
16
|
+
``ts`` is a data leaf; a different grid of the same length retraces
|
|
17
|
+
nothing. ODE only.
|
|
18
|
+
- ``steps=True``: the raw padded attempt rows, ``max_steps + 1`` of them
|
|
19
|
+
including the initial state. Rejected attempts and post-horizon frozen
|
|
20
|
+
iterations duplicate the previous row. ``fill="last"`` (default) keeps
|
|
21
|
+
those duplicates; ``fill="inf"`` overwrites every non-accepted row
|
|
22
|
+
(including mid-trajectory rejection rows) with ``inf``, diffrax-style.
|
|
23
|
+
|
|
24
|
+
``fill`` only applies to ``steps=True``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
t1: bool = field(default=False, metadata=dict(static=True))
|
|
28
|
+
ts: jax.Array | None = None
|
|
29
|
+
steps: bool = field(default=False, metadata=dict(static=True))
|
|
30
|
+
fill: str = field(default="last", metadata=dict(static=True))
|
|
31
|
+
|
|
32
|
+
def __post_init__(self):
|
|
33
|
+
modes = int(bool(self.t1)) + int(self.ts is not None) + int(bool(self.steps))
|
|
34
|
+
if modes != 1:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
"SaveAt requires exactly one of t1=True, ts=..., steps=True"
|
|
37
|
+
)
|
|
38
|
+
if self.fill not in ("last", "inf"):
|
|
39
|
+
raise ValueError('SaveAt fill must be "last" or "inf"')
|
tinydiffeq/sde.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import jax
|
|
2
|
+
import jax.numpy as jnp
|
|
3
|
+
|
|
4
|
+
from tinydiffeq.ode import canonicalize_field, identity_project
|
|
5
|
+
from tinydiffeq.saveat import SaveAt
|
|
6
|
+
from tinydiffeq.solution import Solution
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def solve_sde(
|
|
10
|
+
drift,
|
|
11
|
+
diffusion,
|
|
12
|
+
solver,
|
|
13
|
+
t0,
|
|
14
|
+
t1,
|
|
15
|
+
x0,
|
|
16
|
+
*,
|
|
17
|
+
key,
|
|
18
|
+
n_steps,
|
|
19
|
+
p=None,
|
|
20
|
+
args=None,
|
|
21
|
+
saveat=None,
|
|
22
|
+
project=None,
|
|
23
|
+
):
|
|
24
|
+
"""Integrate the Ito SDE ``dx = drift dt + diffusion dW`` (diagonal noise)
|
|
25
|
+
on the fixed grid of ``n_steps`` uniform steps from ``t0`` to ``t1 > t0``.
|
|
26
|
+
|
|
27
|
+
``drift`` and ``diffusion`` follow the same signature convention as
|
|
28
|
+
``solve_ode`` — ``(x)``, ``(x, t)``, ``(x, t, args)``, or
|
|
29
|
+
``(x, t, args, p)``. ``n_steps`` must be a static Python int (the honest
|
|
30
|
+
static-shape contract; there is no adaptive SDE stepping in v1). The
|
|
31
|
+
Brownian increments are presampled from ``key`` as
|
|
32
|
+
``sqrt(dt) * normal(key, (n_steps,) + x0.shape)``, so a fixed key gives a
|
|
33
|
+
fixed noise process: reproducible across calls and differentiable with
|
|
34
|
+
respect to ``x0`` and ``p`` (not ``key``).
|
|
35
|
+
|
|
36
|
+
``SaveAt(ts=...)`` raises — cubic Hermite interpolation is wrong for
|
|
37
|
+
rough paths; use ``t1`` (default) or ``steps`` (here ``n_steps + 1``
|
|
38
|
+
rows, all accepted).
|
|
39
|
+
"""
|
|
40
|
+
if not isinstance(n_steps, int):
|
|
41
|
+
raise TypeError("n_steps must be a static Python int")
|
|
42
|
+
if n_steps < 1:
|
|
43
|
+
raise ValueError("n_steps must be at least 1")
|
|
44
|
+
if saveat is None:
|
|
45
|
+
saveat = SaveAt(t1=True)
|
|
46
|
+
if saveat.ts is not None:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"SaveAt(ts=...) is not supported for SDEs: Hermite interpolation "
|
|
49
|
+
"is wrong for rough paths; use SaveAt(t1=True) or SaveAt(steps=True)"
|
|
50
|
+
)
|
|
51
|
+
if project is None:
|
|
52
|
+
project = identity_project
|
|
53
|
+
drift = canonicalize_field(drift, name="drift")
|
|
54
|
+
diffusion = canonicalize_field(diffusion, name="diffusion")
|
|
55
|
+
|
|
56
|
+
x0 = jnp.asarray(x0)
|
|
57
|
+
tdt = jnp.result_type(x0, float)
|
|
58
|
+
t0 = jnp.asarray(t0, tdt)
|
|
59
|
+
t1 = jnp.asarray(t1, tdt)
|
|
60
|
+
dt = (t1 - t0) / n_steps
|
|
61
|
+
dW = jnp.sqrt(dt) * jax.random.normal(key, (n_steps,) + x0.shape, dtype=tdt)
|
|
62
|
+
ts_grid = jnp.linspace(t0, t1, n_steps + 1)
|
|
63
|
+
|
|
64
|
+
def g_drift(x, t):
|
|
65
|
+
return drift(project(x), t, args, p)
|
|
66
|
+
|
|
67
|
+
def g_diffusion(x, t):
|
|
68
|
+
return diffusion(project(x), t, args, p)
|
|
69
|
+
|
|
70
|
+
def body(x, inputs):
|
|
71
|
+
t, dW_step = inputs
|
|
72
|
+
x1 = solver.step(g_drift, g_diffusion, t, x, dt, dW_step, project)
|
|
73
|
+
return x1, x1 if saveat.steps else None
|
|
74
|
+
|
|
75
|
+
x_final, xs_s = jax.lax.scan(body, x0, (ts_grid[:-1], dW))
|
|
76
|
+
num_accepted = jnp.asarray(n_steps, jnp.int32)
|
|
77
|
+
ok = jnp.asarray(True)
|
|
78
|
+
|
|
79
|
+
if saveat.t1:
|
|
80
|
+
return Solution(ts=t1, xs=x_final, ok=ok, num_accepted=num_accepted)
|
|
81
|
+
|
|
82
|
+
xs_all = jnp.concatenate([x0[None], xs_s])
|
|
83
|
+
accepted = jnp.ones((n_steps + 1,), bool)
|
|
84
|
+
return Solution(
|
|
85
|
+
ts=ts_grid, xs=xs_all, ok=ok, num_accepted=num_accepted, accepted=accepted
|
|
86
|
+
)
|
tinydiffeq/solution.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import jax
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@jax.tree_util.register_dataclass
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Solution:
|
|
9
|
+
"""Result of ``solve_ode``/``solve_sde``.
|
|
10
|
+
|
|
11
|
+
- ``ts``/``xs``: times and states in the shape dictated by ``SaveAt`` —
|
|
12
|
+
scalar/endpoint for ``t1``, ``(len(ts), ...)`` for ``ts``,
|
|
13
|
+
``(max_steps + 1, ...)`` for ``steps``.
|
|
14
|
+
- ``ok``: scalar bool, True iff the integration reached ``t1`` within the
|
|
15
|
+
attempt budget. The package never poisons outputs; callers that want
|
|
16
|
+
diverging residuals do ``jnp.where(sol.ok, sol.xs, jnp.inf)``.
|
|
17
|
+
- ``num_accepted``: number of accepted steps (excluding the initial
|
|
18
|
+
state).
|
|
19
|
+
- ``accepted``: ``steps`` mode only (otherwise None): per-row bool mask,
|
|
20
|
+
True for rows holding a newly computed state. Row 0 (the initial state)
|
|
21
|
+
is always True, so ``accepted.sum() == num_accepted + 1``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
ts: jax.Array
|
|
25
|
+
xs: jax.Array
|
|
26
|
+
ok: jax.Array
|
|
27
|
+
num_accepted: jax.Array
|
|
28
|
+
accepted: jax.Array | None = None
|
tinydiffeq/solvers.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import jax
|
|
4
|
+
|
|
5
|
+
# Solvers are stateless frozen dataclasses registered as pytrees so they pass
|
|
6
|
+
# through jit/vmap as ordinary arguments. `step` receives `g(x, t)` -- the
|
|
7
|
+
# user vector field already wrapped so every evaluation goes through
|
|
8
|
+
# `project` -- plus `f0`, the loop-carried value of `g` at (x, t) when the
|
|
9
|
+
# loop guarantees it is current (FSAL, or interpolation output requested);
|
|
10
|
+
# otherwise `f0` is None and the solver evaluates its own first stage.
|
|
11
|
+
# The step contract is `step(g, t, x, dt, f0, project) -> (x1, f1, err)`:
|
|
12
|
+
# `x1` is the projected accepted candidate, `f1` is `g(x1, t + dt)` when the
|
|
13
|
+
# solver produces it for free (FSAL) and None otherwise, and `err` is the
|
|
14
|
+
# embedded error estimate or None. `project` is assumed idempotent (a clamp).
|
|
15
|
+
|
|
16
|
+
# Tsitouras (2011) 5(4) coefficients (FSAL: k7 = f(x1) is the next step's k1).
|
|
17
|
+
A21 = 0.161
|
|
18
|
+
A31, A32 = -0.008480655492356989, 0.335480655492357
|
|
19
|
+
A41, A42, A43 = 2.8971530571054935, -6.359448489975075, 4.3622954328695815
|
|
20
|
+
A51, A52, A53, A54 = (
|
|
21
|
+
5.325864828439257,
|
|
22
|
+
-11.748883564062828,
|
|
23
|
+
7.4955393428898365,
|
|
24
|
+
-0.09249506636175525,
|
|
25
|
+
)
|
|
26
|
+
A61, A62, A63, A64, A65 = (
|
|
27
|
+
5.86145544294642,
|
|
28
|
+
-12.92096931784711,
|
|
29
|
+
8.159367898576159,
|
|
30
|
+
-0.071584973281401,
|
|
31
|
+
-0.028269050394068383,
|
|
32
|
+
)
|
|
33
|
+
B1, B2, B3, B4, B5, B6 = (
|
|
34
|
+
0.09646076681806523,
|
|
35
|
+
0.01,
|
|
36
|
+
0.4798896504144996,
|
|
37
|
+
1.379008574103742,
|
|
38
|
+
-3.290069515436081,
|
|
39
|
+
2.324710524099774,
|
|
40
|
+
)
|
|
41
|
+
# embedded 4th-order error coefficients (b - bhat)
|
|
42
|
+
E1, E2, E3, E4, E5, E6, E7 = (
|
|
43
|
+
-0.00178001105222577714,
|
|
44
|
+
-0.0008164344596567469,
|
|
45
|
+
0.007880878010261995,
|
|
46
|
+
-0.1447110071732629,
|
|
47
|
+
0.5823571654525552,
|
|
48
|
+
-0.45808210592918697,
|
|
49
|
+
1.0 / 66.0,
|
|
50
|
+
)
|
|
51
|
+
# stage times c_i (needed for non-autonomous fields)
|
|
52
|
+
C2, C3, C4, C5, C6, C7 = 0.161, 0.327, 0.9, 0.9800255409045097, 1.0, 1.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@jax.tree_util.register_dataclass
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Euler:
|
|
58
|
+
"""Explicit Euler. Fixed-step only: no embedded error estimate."""
|
|
59
|
+
|
|
60
|
+
order = 1
|
|
61
|
+
fsal = False
|
|
62
|
+
has_error_estimate = False
|
|
63
|
+
|
|
64
|
+
def step(self, g, t, x, dt, f0, project):
|
|
65
|
+
k1 = g(x, t) if f0 is None else f0
|
|
66
|
+
x1 = project(x + dt * k1)
|
|
67
|
+
return x1, None, None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@jax.tree_util.register_dataclass
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class RK4:
|
|
73
|
+
"""Classic fourth-order Runge-Kutta. Fixed-step only: no error estimate."""
|
|
74
|
+
|
|
75
|
+
order = 4
|
|
76
|
+
fsal = False
|
|
77
|
+
has_error_estimate = False
|
|
78
|
+
|
|
79
|
+
def step(self, g, t, x, dt, f0, project):
|
|
80
|
+
k1 = g(x, t) if f0 is None else f0
|
|
81
|
+
k2 = g(x + 0.5 * dt * k1, t + 0.5 * dt)
|
|
82
|
+
k3 = g(x + 0.5 * dt * k2, t + 0.5 * dt)
|
|
83
|
+
k4 = g(x + dt * k3, t + dt)
|
|
84
|
+
x1 = project(x + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4))
|
|
85
|
+
return x1, None, None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@jax.tree_util.register_dataclass
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Tsit5:
|
|
91
|
+
"""Tsitouras 5(4) explicit Runge-Kutta with embedded error estimate.
|
|
92
|
+
|
|
93
|
+
FSAL: the last stage k7 = g(x1, t + dt) is the next step's first stage,
|
|
94
|
+
so an accepted adaptive step costs six fresh evaluations. Note k7 is
|
|
95
|
+
evaluated at the *projected* accepted state, so the FSAL cache stays
|
|
96
|
+
consistent with the state actually carried forward when `project` binds.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
order = 5
|
|
100
|
+
fsal = True
|
|
101
|
+
has_error_estimate = True
|
|
102
|
+
|
|
103
|
+
def step(self, g, t, x, dt, f0, project):
|
|
104
|
+
k1 = g(x, t) if f0 is None else f0
|
|
105
|
+
k2 = g(x + dt * (A21 * k1), t + C2 * dt)
|
|
106
|
+
k3 = g(x + dt * (A31 * k1 + A32 * k2), t + C3 * dt)
|
|
107
|
+
k4 = g(x + dt * (A41 * k1 + A42 * k2 + A43 * k3), t + C4 * dt)
|
|
108
|
+
k5 = g(x + dt * (A51 * k1 + A52 * k2 + A53 * k3 + A54 * k4), t + C5 * dt)
|
|
109
|
+
k6 = g(
|
|
110
|
+
x + dt * (A61 * k1 + A62 * k2 + A63 * k3 + A64 * k4 + A65 * k5),
|
|
111
|
+
t + C6 * dt,
|
|
112
|
+
)
|
|
113
|
+
x1 = project(
|
|
114
|
+
x + dt * (B1 * k1 + B2 * k2 + B3 * k3 + B4 * k4 + B5 * k5 + B6 * k6)
|
|
115
|
+
)
|
|
116
|
+
k7 = g(x1, t + C7 * dt)
|
|
117
|
+
err = dt * (E1 * k1 + E2 * k2 + E3 * k3 + E4 * k4 + E5 * k5 + E6 * k6 + E7 * k7)
|
|
118
|
+
return x1, k7, err
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@jax.tree_util.register_dataclass
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class EulerMaruyama:
|
|
124
|
+
"""Euler-Maruyama for Ito SDEs with diagonal noise. Fixed-step only."""
|
|
125
|
+
|
|
126
|
+
order = 1
|
|
127
|
+
|
|
128
|
+
def step(self, g_drift, g_diffusion, t, x, dt, dW, project):
|
|
129
|
+
return project(x + dt * g_drift(x, t) + g_diffusion(x, t) * dW)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tinydiffeq
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tiny differentiable ODE/SDE solvers for JAX — bounded-scan adaptive stepping, static shapes, jvp/vjp-safe
|
|
5
|
+
Project-URL: Homepage, https://github.com/HighDimensionalEconLab/tinydiffeq
|
|
6
|
+
Project-URL: Repository, https://github.com/HighDimensionalEconLab/tinydiffeq
|
|
7
|
+
Project-URL: Documentation, https://highdimensionaleconlab.github.io/tinydiffeq/
|
|
8
|
+
Project-URL: Issues, https://github.com/HighDimensionalEconLab/tinydiffeq/issues
|
|
9
|
+
Author-email: Jesse Perla <jesseperla@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: autodiff,differential-equations,jax,ode,runge-kutta,sde
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: jax>=0.7.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# tinydiffeq
|
|
24
|
+
|
|
25
|
+
[](https://github.com/HighDimensionalEconLab/tinydiffeq/actions/workflows/ci.yml)
|
|
26
|
+
[](https://highdimensionaleconlab.github.io/tinydiffeq/)
|
|
27
|
+
[](https://pypi.org/project/tinydiffeq/)
|
|
28
|
+
[](https://pypi.org/project/tinydiffeq/)
|
|
29
|
+
[](https://github.com/HighDimensionalEconLab/tinydiffeq/blob/main/LICENSE)
|
|
30
|
+
[](https://github.com/astral-sh/ruff)
|
|
31
|
+
|
|
32
|
+
Tiny differentiable ODE/SDE solvers for JAX: fixed-step Euler/RK4, adaptive
|
|
33
|
+
Tsit5 with an integral step-size controller, and Euler–Maruyama for Itô SDEs.
|
|
34
|
+
One bounded `lax.scan` of exactly `max_steps` iterations serves fixed and
|
|
35
|
+
adaptive stepping, so shapes are static, nothing recompiles as tolerances or
|
|
36
|
+
curvature change, and every solve is differentiable in **both** forward and
|
|
37
|
+
reverse mode — including reverse-over-forward, the pattern a
|
|
38
|
+
Levenberg–Marquardt optimizer with geodesic acceleration needs when it
|
|
39
|
+
differentiates through a rollout.
|
|
40
|
+
|
|
41
|
+
This is a deliberately small, jvp/vjp-friendly subset of
|
|
42
|
+
[diffrax](https://docs.kidger.site/diffrax/). Use diffrax if you need pytree
|
|
43
|
+
states, stiff/implicit solvers, PID control, events, dense output, or
|
|
44
|
+
checkpointed/backsolve adjoints. tinydiffeq's single runtime dependency is
|
|
45
|
+
`jax`.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv add tinydiffeq
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For GPU use, install the JAX accelerator build that matches your hardware,
|
|
54
|
+
for example:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv add tinydiffeq "jax[cuda13]"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Minimal example
|
|
61
|
+
|
|
62
|
+
The vector field may take `(x)`, `(x, t)`, `(x, t, args)`, or
|
|
63
|
+
`(x, t, args, p)` — always in that order. `args` is pass-through data (not an
|
|
64
|
+
AD target by convention); `p` holds differentiable parameters (any pytree).
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import jax
|
|
68
|
+
import jax.numpy as jnp
|
|
69
|
+
from tinydiffeq import solve_ode, Tsit5, IController, SaveAt
|
|
70
|
+
|
|
71
|
+
jax.config.update("jax_enable_x64", True) # your call — the library never sets it
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def f(x, t, args, p):
|
|
75
|
+
return -p * x
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
sol = solve_ode(
|
|
79
|
+
f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0),
|
|
80
|
+
p=jnp.asarray(1.3),
|
|
81
|
+
dt0=0.1,
|
|
82
|
+
controller=IController(rtol=1e-8, atol=1e-10),
|
|
83
|
+
max_steps=512,
|
|
84
|
+
saveat=SaveAt(ts=jnp.linspace(0.0, 2.0, 21)), # fixed output shape,
|
|
85
|
+
) # however many steps adapt
|
|
86
|
+
print(sol.xs) # states on the grid
|
|
87
|
+
print(sol.ok) # reached t1 within the max_steps budget?
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Gradients through the solve
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
def endpoint(p):
|
|
94
|
+
return solve_ode(
|
|
95
|
+
f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0), p=p,
|
|
96
|
+
dt0=0.1, controller=IController(rtol=1e-10, atol=1e-12),
|
|
97
|
+
max_steps=512,
|
|
98
|
+
).xs
|
|
99
|
+
|
|
100
|
+
jax.grad(endpoint)(jnp.asarray(1.3)) # reverse mode
|
|
101
|
+
jax.jvp(endpoint, (jnp.asarray(1.3),), (jnp.asarray(1.0),)) # forward mode
|
|
102
|
+
jax.grad(lambda p: jax.jvp(endpoint, (p,), (jnp.asarray(1.0),))[1])(
|
|
103
|
+
jnp.asarray(1.3)
|
|
104
|
+
) # reverse-over-forward
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The step-size controller is wrapped in `stop_gradient` (accept/reject is
|
|
108
|
+
non-differentiable either way, and the error-ratio power blows up at exactly
|
|
109
|
+
zero error); the states differentiate fully through the RK stages. See the
|
|
110
|
+
[docs](https://highdimensionaleconlab.github.io/tinydiffeq/) for the design
|
|
111
|
+
contracts: static shapes and `SaveAt`, AD through adaptive stepping, SDE key
|
|
112
|
+
semantics, and migration recipes from hand-rolled RK4/Tsit5 loops.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
tinydiffeq/__init__.py,sha256=OhMHS5DJX26rPrnTBIM4p6vCPYg8rrlB9aGXyw-e_7w,1383
|
|
2
|
+
tinydiffeq/controllers.py,sha256=Umq0d3hku0TeKhqsOgxMgdAa_bDstx-o5E-fb8jx10k,2956
|
|
3
|
+
tinydiffeq/interpolation.py,sha256=uYHO58eAIfw32ueG6SYGtPqv3p9LvWibzpTtvOhBpgk,2280
|
|
4
|
+
tinydiffeq/ode.py,sha256=ruG2EGs-8HjPZQn5ogGAvvTWr-Kp_2l0BDPNuJT68X0,6584
|
|
5
|
+
tinydiffeq/quadrature.py,sha256=0YglgE1bjhMfeXsIAGUzQAQKsOsHbHoBxtxu_bm42Vw,1283
|
|
6
|
+
tinydiffeq/saveat.py,sha256=iFh04a8J9qJy8FY8Dvu4iJfcO8Q-e52l9Uekxo2siUs,1707
|
|
7
|
+
tinydiffeq/sde.py,sha256=-pkSvcte1VxagUhoyfh1sJb_Gx1Lf2z3U3Qs_D7Z6ck,2912
|
|
8
|
+
tinydiffeq/solution.py,sha256=9i2hV_s4Mhc4hk7K2pEWACMvAXxsIoQlFjcScY4wPFw,1015
|
|
9
|
+
tinydiffeq/solvers.py,sha256=a8BJymEoHAcihl6J9KtB1Du2PjzpGq75GCVwRwU_zZQ,4291
|
|
10
|
+
tinydiffeq-0.1.0.dist-info/METADATA,sha256=DoxN0K77HZyTDMVWEffde585ev9cc12IHaQkCgGGz_0,4851
|
|
11
|
+
tinydiffeq-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
+
tinydiffeq-0.1.0.dist-info/licenses/LICENSE,sha256=6ecMv6jswIubE8SkuH00m6qcbn45ZDuvG-fzsAYNFek,1079
|
|
13
|
+
tinydiffeq-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HighDimensionalEconLab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|