diffrax-bdf 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.
- diffrax_bdf/__init__.py +7 -0
- diffrax_bdf/_bdf.py +656 -0
- diffrax_bdf/_coeffs.py +101 -0
- diffrax_bdf/_controller.py +73 -0
- diffrax_bdf/_newton.py +96 -0
- diffrax_bdf/_term.py +72 -0
- diffrax_bdf-0.1.0.dist-info/METADATA +201 -0
- diffrax_bdf-0.1.0.dist-info/RECORD +9 -0
- diffrax_bdf-0.1.0.dist-info/WHEEL +4 -0
diffrax_bdf/__init__.py
ADDED
diffrax_bdf/_bdf.py
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
"""A fixed-order BDF/NDF solver for diffrax.
|
|
2
|
+
|
|
3
|
+
The method is the one used by `scipy.integrate.BDF` and MATLAB's `ode15s`: a
|
|
4
|
+
backward differentiation formula applied to a quasi-constant-step history stored
|
|
5
|
+
as backward differences, with the Shampine & Reichelt NDF correction available.
|
|
6
|
+
|
|
7
|
+
On top of that sits SUNDIALS CVODE's linear-algebra reuse policy, which is what
|
|
8
|
+
makes a BDF worth using over an ESDIRK. `Kvaerno5` already computes only one
|
|
9
|
+
Jacobian per step and reuses it across its five stages, so "one Newton solve per
|
|
10
|
+
step instead of five stage solves" is worth only a few linear solves. The real
|
|
11
|
+
saving is keeping `J` and the factorisation of `I - c * J` across *steps*, which
|
|
12
|
+
CVODE gates on two separate counters (see `_should_update`).
|
|
13
|
+
|
|
14
|
+
Three properties of `diffrax`'s integration loop shape the implementation:
|
|
15
|
+
|
|
16
|
+
* `solver_state` is a `lax.while_loop` carry, so it must have a fixed pytree
|
|
17
|
+
structure, shape and dtype on every step. The BDF order is therefore a *traced*
|
|
18
|
+
integer used to mask fixed-size buffers, never a Python-level dimension.
|
|
19
|
+
* `solver_state` is rolled back when a step is rejected (`_integrate.py:423-425`).
|
|
20
|
+
This is why the reuse counters live here rather than in the controller: they must
|
|
21
|
+
stay consistent with the stored factorisation, which is also rolled back. It also
|
|
22
|
+
matches CVODE, whose `nst`-based counters likewise advance only on accepted steps.
|
|
23
|
+
* `t0` and `t1` are only ever passed to `terms.contr`/`terms.vf_prod` and never
|
|
24
|
+
subtracted. Under backwards integration `diffrax` wraps the terms so that `contr`
|
|
25
|
+
returns a *negative* step, and every formula below is consistent under that sign
|
|
26
|
+
provided the step is never recomputed as `t1 - t0`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from typing import ClassVar
|
|
31
|
+
|
|
32
|
+
import equinox as eqx
|
|
33
|
+
import jax
|
|
34
|
+
import jax.numpy as jnp
|
|
35
|
+
import jax.scipy.linalg as jsl
|
|
36
|
+
import jax.tree_util as jtu
|
|
37
|
+
import optimistix as optx
|
|
38
|
+
from diffrax import (
|
|
39
|
+
AbstractAdaptiveSolver,
|
|
40
|
+
AbstractImplicitSolver,
|
|
41
|
+
AbstractTerm,
|
|
42
|
+
LocalLinearInterpolation,
|
|
43
|
+
RESULTS,
|
|
44
|
+
VeryChord,
|
|
45
|
+
with_stepsize_controller_tols,
|
|
46
|
+
)
|
|
47
|
+
from equinox.internal import ω
|
|
48
|
+
from jax.flatten_util import ravel_pytree
|
|
49
|
+
from jaxtyping import Array, Bool, Float, Int, PyTree
|
|
50
|
+
|
|
51
|
+
from ._coeffs import D_ROWS, MAX_ORDER, change_D, make_tables, weighted_row_sum
|
|
52
|
+
from ._newton import chord_solve, newton_tolerance, rms_norm
|
|
53
|
+
from ._term import flat_algebraic_mask, split_dae_term
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ErrorWithOrder(eqx.Module):
|
|
57
|
+
"""An error estimate together with the BDF order that produced it.
|
|
58
|
+
|
|
59
|
+
`diffrax` asks the solver for its error order once, outside the loop
|
|
60
|
+
(`_integrate.py:388` calls `solver.error_order(terms)`, which sees neither the
|
|
61
|
+
state nor the time), so a variable-order solver has no way to tell the
|
|
62
|
+
controller which order the current error estimate belongs to. The step-size
|
|
63
|
+
controller's only other input from the solver is `y_error`, and the integration
|
|
64
|
+
loop treats that opaquely -- it only does a structure-agnostic `tree_map` over it
|
|
65
|
+
(`_integrate.py:386`) -- so the order travels in here alongside the estimate.
|
|
66
|
+
|
|
67
|
+
[`diffrax_bdf.BDFController`][] unpacks this. Stock `diffrax.PIDController`
|
|
68
|
+
cannot: it tree-maps `y_error` against `y0` and will raise on the extra leaf.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
error: PyTree
|
|
72
|
+
order: Float[Array, ""]
|
|
73
|
+
|
|
74
|
+
def __truediv__(self, other):
|
|
75
|
+
# `PIDController` tree-maps its scaling function over `(y0, y1, y_error)`,
|
|
76
|
+
# taking its structure from `y0`, so this whole object arrives where a leaf
|
|
77
|
+
# was expected and the first thing done to it is a division. Intercepting
|
|
78
|
+
# that turns an unreadable TypeError into an actionable one.
|
|
79
|
+
del other
|
|
80
|
+
raise TypeError(
|
|
81
|
+
"A variable-order `BDF` reports its error estimate together with the "
|
|
82
|
+
"order that produced it, and `diffrax.PIDController` cannot read that. "
|
|
83
|
+
"Either pass `stepsize_controller=diffrax_bdf.BDFController(...)`, which "
|
|
84
|
+
"can, or pin the order with `BDF(bdf_order=...)` to get a plain error "
|
|
85
|
+
"estimate that any adaptive controller accepts."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _BDFState(eqx.Module):
|
|
90
|
+
"""The multistep history, plus the cached linear algebra and its age."""
|
|
91
|
+
|
|
92
|
+
d_array: PyTree[Float[Array, "8 ..."]]
|
|
93
|
+
order: Int[Array, ""]
|
|
94
|
+
n_equal_steps: Int[Array, ""]
|
|
95
|
+
h_prev: Float[Array, ""]
|
|
96
|
+
jac: Float[Array, "n n"]
|
|
97
|
+
lu: Float[Array, "n n"]
|
|
98
|
+
piv: Int[Array, " n"]
|
|
99
|
+
c_at_last_lu: Float[Array, ""]
|
|
100
|
+
steps_since_lu: Int[Array, ""]
|
|
101
|
+
steps_since_jac: Int[Array, ""]
|
|
102
|
+
initial_failed: Bool[Array, ""]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _bdf_residual(d, nonlinear_args):
|
|
106
|
+
"""Residual whose root is the total Newton correction from the predictor.
|
|
107
|
+
|
|
108
|
+
Solving for the correction rather than for `y1` avoids forming `y1 - y_pred`,
|
|
109
|
+
a cancellation between two `O(|y|)` quantities whose difference is
|
|
110
|
+
`O(h ** (k + 1))`; that difference feeds straight into the error estimate.
|
|
111
|
+
"""
|
|
112
|
+
vf_prod, t1, y_pred, args, control, inv_alpha, psi = nonlinear_args
|
|
113
|
+
y = (y_pred**ω + d**ω).ω
|
|
114
|
+
# `vf_prod(t1, y, args, control)` is `h * f(t1, y)`, so scaling by
|
|
115
|
+
# `1 / alpha[order]` gives scipy's `c * f` with `c = h / alpha[order]`.
|
|
116
|
+
c_f = (inv_alpha * vf_prod(t1, y, args, control) ** ω).ω
|
|
117
|
+
return (c_f**ω - psi**ω - d**ω).ω
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _zero_algebraic(x, mask):
|
|
121
|
+
if mask is None:
|
|
122
|
+
return x
|
|
123
|
+
flat, unravel = ravel_pytree(x)
|
|
124
|
+
return unravel(jnp.where(flat_algebraic_mask(mask, x), 0, flat))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _suppress(x, error_mask):
|
|
128
|
+
if error_mask is None:
|
|
129
|
+
return x
|
|
130
|
+
return jnp.where(error_mask, 0, x)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _row_mask(mask, x):
|
|
134
|
+
"""Broadcast a length-`D_ROWS` row mask against a leaf of the difference array."""
|
|
135
|
+
return jnp.reshape(mask, (-1,) + (1,) * (jnp.ndim(x) - 1))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _update_difference_array(d_array, order, correction):
|
|
139
|
+
"""Fold an accepted Newton correction into the difference array.
|
|
140
|
+
|
|
141
|
+
scipy writes this as
|
|
142
|
+
|
|
143
|
+
D[order + 2] = d - D[order + 1]
|
|
144
|
+
D[order + 1] = d
|
|
145
|
+
for i in reversed(range(order + 1)): D[i] += D[i + 1]
|
|
146
|
+
|
|
147
|
+
The loop is a reverse cumulative sum over rows `0..order + 1`, which is done
|
|
148
|
+
here by masking rather than slicing so that the shapes stay static.
|
|
149
|
+
"""
|
|
150
|
+
idx = jnp.arange(D_ROWS)
|
|
151
|
+
|
|
152
|
+
def update(x, d):
|
|
153
|
+
x = x.at[order + 2].set(d - x[order + 1])
|
|
154
|
+
x = x.at[order + 1].set(d)
|
|
155
|
+
summable = jnp.where(_row_mask(idx <= order + 1, x), x, 0)
|
|
156
|
+
reverse_cumsum = jnp.cumsum(summable[::-1], axis=0)[::-1]
|
|
157
|
+
return jnp.where(_row_mask(idx <= order, x), reverse_cumsum, x)
|
|
158
|
+
|
|
159
|
+
return jtu.tree_map(update, d_array, correction)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class BDF(AbstractImplicitSolver, AbstractAdaptiveSolver):
|
|
163
|
+
"""Fixed-order backward differentiation formula with Jacobian reuse.
|
|
164
|
+
|
|
165
|
+
The order ramps from 1 up to `bdf_order` over the opening steps, since a
|
|
166
|
+
`k`-step formula needs `k` points of history. Because a rejected step rolls the
|
|
167
|
+
solver state back, the ramp is automatically correct: a step that did not
|
|
168
|
+
happen does not advance the order.
|
|
169
|
+
|
|
170
|
+
Pair this with [`diffrax_bdf.BDFController`][]. With diffrax's stock
|
|
171
|
+
`PIDController` the step size, and hence `c`, changes on every step, so the
|
|
172
|
+
factorisation is rebuilt every step and the reuse below never engages.
|
|
173
|
+
|
|
174
|
+
It also solves semi-explicit index-1 differential algebraic equations given as
|
|
175
|
+
a [`diffrax_bdf.SemiExplicitDAETerm`][].
|
|
176
|
+
|
|
177
|
+
!!! warning
|
|
178
|
+
|
|
179
|
+
The Jacobian is formed densely, so this is intended for small to medium
|
|
180
|
+
systems. The backward differences also suffer heavy cancellation at high
|
|
181
|
+
order: orders above 2 are effectively noise in float32, so enable
|
|
182
|
+
`jax_enable_x64`.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
term_structure: ClassVar = AbstractTerm
|
|
186
|
+
interpolation_cls: ClassVar[Callable[..., LocalLinearInterpolation]] = (
|
|
187
|
+
LocalLinearInterpolation
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
bdf_order: int | None = eqx.field(static=True, default=None)
|
|
191
|
+
max_order: int = eqx.field(static=True, default=MAX_ORDER)
|
|
192
|
+
use_ndf: bool = eqx.field(static=True, default=True)
|
|
193
|
+
root_finder: optx.AbstractRootFinder = with_stepsize_controller_tols(VeryChord)()
|
|
194
|
+
root_find_max_steps: int = 4
|
|
195
|
+
# CVODE's `MSBP` and `MSBJ`, and its `DGMAX_LSETUP`.
|
|
196
|
+
lu_reuse_steps: int = eqx.field(static=True, default=20)
|
|
197
|
+
jac_reuse_steps: int = eqx.field(static=True, default=51)
|
|
198
|
+
max_gamma_change: float = 0.3
|
|
199
|
+
suppress_algebraic_error: bool = eqx.field(static=True, default=False)
|
|
200
|
+
correct_initial_algebraic: bool = eqx.field(static=True, default=True)
|
|
201
|
+
initial_algebraic_max_steps: int = eqx.field(static=True, default=50)
|
|
202
|
+
|
|
203
|
+
def __check_init__(self):
|
|
204
|
+
if self.bdf_order is not None and not 1 <= self.bdf_order <= MAX_ORDER:
|
|
205
|
+
raise ValueError(f"`bdf_order` must be between 1 and {MAX_ORDER}, or None.")
|
|
206
|
+
if not 1 <= self.max_order <= MAX_ORDER:
|
|
207
|
+
raise ValueError(f"`max_order` must be between 1 and {MAX_ORDER}.")
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def _variable_order(self) -> bool:
|
|
211
|
+
return self.bdf_order is None
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def _order_cap(self) -> int:
|
|
215
|
+
return self.max_order if self._variable_order else self.bdf_order
|
|
216
|
+
|
|
217
|
+
def order(self, terms):
|
|
218
|
+
del terms
|
|
219
|
+
return self._order_cap
|
|
220
|
+
|
|
221
|
+
def error_order(self, terms):
|
|
222
|
+
del terms
|
|
223
|
+
# The local error of a k-th order BDF step is O(h ** (k + 1)). At variable
|
|
224
|
+
# order this is only the value used to pick the very first step size; the
|
|
225
|
+
# per-step order reaches the controller through `ErrorWithOrder`.
|
|
226
|
+
return self._order_cap + 1
|
|
227
|
+
|
|
228
|
+
def func(self, terms, t0, y0, args):
|
|
229
|
+
terms, _ = split_dae_term(terms)
|
|
230
|
+
return terms.vf(t0, y0, args)
|
|
231
|
+
|
|
232
|
+
def _tolerances(self):
|
|
233
|
+
rtol = getattr(self.root_finder, "rtol", None)
|
|
234
|
+
atol = getattr(self.root_finder, "atol", None)
|
|
235
|
+
if not isinstance(rtol, (int, float)) or not isinstance(atol, (int, float)):
|
|
236
|
+
raise ValueError(
|
|
237
|
+
"`BDF` needs concrete tolerances for its Newton iteration. Either use "
|
|
238
|
+
"an adaptive step size controller, which supplies them, or construct "
|
|
239
|
+
"the solver with e.g. "
|
|
240
|
+
"`BDF(root_finder=diffrax.VeryChord(rtol=..., atol=...))`."
|
|
241
|
+
)
|
|
242
|
+
return rtol, atol
|
|
243
|
+
|
|
244
|
+
def init(self, terms, t0, t1, y0, args) -> _BDFState:
|
|
245
|
+
terms, mask = split_dae_term(terms)
|
|
246
|
+
control = terms.contr(t0, t1)
|
|
247
|
+
if jnp.shape(control) != ():
|
|
248
|
+
raise ValueError(
|
|
249
|
+
"`BDF` requires a term whose control is a scalar (e.g. `ODETerm`); "
|
|
250
|
+
f"got a control of shape {jnp.shape(control)}. The step-size ratio "
|
|
251
|
+
"used to rescale the difference array is not defined otherwise."
|
|
252
|
+
)
|
|
253
|
+
if mask is not None and self.correct_initial_algebraic:
|
|
254
|
+
y0, initial_failed = self._correct_algebraic(terms, t0, y0, args, mask)
|
|
255
|
+
else:
|
|
256
|
+
initial_failed = jnp.asarray(False)
|
|
257
|
+
# Start at order 1 with `D = [y0, h * f0]`, matching scipy, except that the
|
|
258
|
+
# algebraic components of `h * f0` are zero: there `f0` is a constraint
|
|
259
|
+
# residual, not a derivative. The remaining rows are zero rather than
|
|
260
|
+
# uninitialised so that a high-order formula applied before enough history
|
|
261
|
+
# exists degenerates gracefully.
|
|
262
|
+
f0 = _zero_algebraic(terms.vf_prod(t0, y0, args, control), mask)
|
|
263
|
+
d_array = jtu.tree_map(
|
|
264
|
+
lambda y, f: jnp.zeros((D_ROWS,) + jnp.shape(y), jnp.result_type(y))
|
|
265
|
+
.at[0]
|
|
266
|
+
.set(y)
|
|
267
|
+
.at[1]
|
|
268
|
+
.set(f),
|
|
269
|
+
y0,
|
|
270
|
+
f0,
|
|
271
|
+
)
|
|
272
|
+
flat, _ = ravel_pytree(y0)
|
|
273
|
+
size = flat.size
|
|
274
|
+
dtype = flat.dtype
|
|
275
|
+
return _BDFState(
|
|
276
|
+
d_array=d_array,
|
|
277
|
+
order=jnp.asarray(1, dtype=jnp.int32),
|
|
278
|
+
n_equal_steps=jnp.asarray(0, dtype=jnp.int32),
|
|
279
|
+
h_prev=control,
|
|
280
|
+
jac=jnp.zeros((size, size), dtype),
|
|
281
|
+
lu=jnp.zeros((size, size), dtype),
|
|
282
|
+
piv=jnp.zeros((size,), jnp.int32),
|
|
283
|
+
c_at_last_lu=jnp.asarray(0.0, dtype),
|
|
284
|
+
# Start both counters expired so the first step builds everything.
|
|
285
|
+
steps_since_lu=jnp.asarray(self.lu_reuse_steps, jnp.int32),
|
|
286
|
+
steps_since_jac=jnp.asarray(self.jac_reuse_steps, jnp.int32),
|
|
287
|
+
initial_failed=initial_failed,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _correct_algebraic(self, terms, t0, y0, args, mask):
|
|
291
|
+
flat_y0, unravel = ravel_pytree(y0)
|
|
292
|
+
flat_mask = flat_algebraic_mask(mask, y0)
|
|
293
|
+
|
|
294
|
+
def residual(flat, _):
|
|
295
|
+
f = ravel_pytree(terms.vf(t0, unravel(flat), args))[0]
|
|
296
|
+
return jnp.where(flat_mask, f, flat - flat_y0)
|
|
297
|
+
|
|
298
|
+
rtol, atol = self._tolerances()
|
|
299
|
+
solution = optx.root_find(
|
|
300
|
+
residual,
|
|
301
|
+
optx.Newton(rtol=rtol, atol=atol),
|
|
302
|
+
flat_y0,
|
|
303
|
+
max_steps=self.initial_algebraic_max_steps,
|
|
304
|
+
throw=False,
|
|
305
|
+
)
|
|
306
|
+
failed = (solution.result != optx.RESULTS.successful) | jnp.invert(
|
|
307
|
+
jnp.all(jnp.isfinite(solution.value))
|
|
308
|
+
)
|
|
309
|
+
corrected = jnp.where(failed, flat_y0, solution.value)
|
|
310
|
+
return unravel(corrected), failed
|
|
311
|
+
|
|
312
|
+
def _should_update(self, state, c):
|
|
313
|
+
"""CVODE's two-tier gate on the Jacobian and its factorisation.
|
|
314
|
+
|
|
315
|
+
The asymmetry is the point. `J` costs a full automatic-differentiation pass
|
|
316
|
+
through the vector field and survives ~51 steps; the factorisation costs an
|
|
317
|
+
LU and is redone whenever `c` has moved appreciably or 20 steps have passed,
|
|
318
|
+
reassembling `I - c * J` from the *stored* `J` without touching the vector
|
|
319
|
+
field. So the expensive quantity is refreshed far less often than the cheap
|
|
320
|
+
one. See `cvLsSetup` and `cvNls` in SUNDIALS.
|
|
321
|
+
"""
|
|
322
|
+
need_jac = state.steps_since_jac >= self.jac_reuse_steps
|
|
323
|
+
safe_previous = jnp.where(state.c_at_last_lu == 0, jnp.inf, state.c_at_last_lu)
|
|
324
|
+
gamma_change = jnp.abs(c / safe_previous - 1)
|
|
325
|
+
need_lu = (
|
|
326
|
+
need_jac
|
|
327
|
+
| (gamma_change > self.max_gamma_change)
|
|
328
|
+
| (state.steps_since_lu >= self.lu_reuse_steps)
|
|
329
|
+
)
|
|
330
|
+
return need_jac, need_lu, gamma_change
|
|
331
|
+
|
|
332
|
+
def step(self, terms, t0, t1, y0, args, solver_state, made_jump):
|
|
333
|
+
terms, mask = split_dae_term(terms)
|
|
334
|
+
control = terms.contr(t0, t1)
|
|
335
|
+
gamma_table, alpha_table, error_const = make_tables(self.use_ndf)
|
|
336
|
+
|
|
337
|
+
d_array = solver_state.d_array
|
|
338
|
+
order = solver_state.order
|
|
339
|
+
# The difference array is scaled for the previous step size; rescale it
|
|
340
|
+
# before use, or the history polynomial is evaluated at the wrong times.
|
|
341
|
+
factor = control / solver_state.h_prev
|
|
342
|
+
d_array = change_D(d_array, order, factor)
|
|
343
|
+
n_equal_steps = jnp.where(factor == 1, solver_state.n_equal_steps + 1, 0)
|
|
344
|
+
steps_since_jac = solver_state.steps_since_jac
|
|
345
|
+
|
|
346
|
+
# A jump means the vector field was discontinuous at `t0`, so the history
|
|
347
|
+
# polynomial is meaningless and we restart at order 1 with a fresh Jacobian.
|
|
348
|
+
# `PIDController` always reports `False` and `diffrax` keeps it a static bool
|
|
349
|
+
# where it can, so in the common case this costs nothing at all.
|
|
350
|
+
if not (isinstance(made_jump, bool) and not made_jump):
|
|
351
|
+
d_array, order, n_equal_steps, steps_since_jac = self._restart(
|
|
352
|
+
terms,
|
|
353
|
+
t0,
|
|
354
|
+
y0,
|
|
355
|
+
args,
|
|
356
|
+
control,
|
|
357
|
+
d_array,
|
|
358
|
+
order,
|
|
359
|
+
n_equal_steps,
|
|
360
|
+
steps_since_jac,
|
|
361
|
+
made_jump,
|
|
362
|
+
mask,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
flat_y0, unravel = ravel_pytree(y0)
|
|
366
|
+
dtype = flat_y0.dtype
|
|
367
|
+
size = flat_y0.size
|
|
368
|
+
indices = jnp.arange(MAX_ORDER + 1)
|
|
369
|
+
active = indices <= order
|
|
370
|
+
inv_alpha = 1 / jnp.asarray(alpha_table, dtype)[order]
|
|
371
|
+
c = control * inv_alpha
|
|
372
|
+
|
|
373
|
+
y_pred = weighted_row_sum(active.astype(dtype), d_array)
|
|
374
|
+
psi_coeffs = jnp.where(active, jnp.asarray(gamma_table, dtype), 0.0)
|
|
375
|
+
psi = (inv_alpha * weighted_row_sum(psi_coeffs, d_array) ** ω).ω
|
|
376
|
+
flat_pred, _ = ravel_pytree(y_pred)
|
|
377
|
+
flat_psi, _ = ravel_pytree(psi)
|
|
378
|
+
|
|
379
|
+
def vector_field(flat):
|
|
380
|
+
return ravel_pytree(terms.vf(t1, unravel(flat), args))[0]
|
|
381
|
+
|
|
382
|
+
if mask is None:
|
|
383
|
+
flat_mask = None
|
|
384
|
+
mass = jnp.eye(size, dtype=dtype)
|
|
385
|
+
else:
|
|
386
|
+
flat_mask = flat_algebraic_mask(mask, y0)
|
|
387
|
+
mass = jnp.diag(jnp.where(flat_mask, 0, 1).astype(dtype))
|
|
388
|
+
|
|
389
|
+
def residual(correction):
|
|
390
|
+
y = unravel(flat_pred + correction)
|
|
391
|
+
scaled = ravel_pytree(terms.vf_prod(t1, y, args, control))[0] * inv_alpha
|
|
392
|
+
if flat_mask is None:
|
|
393
|
+
return scaled - flat_psi - correction
|
|
394
|
+
return (
|
|
395
|
+
scaled
|
|
396
|
+
- jnp.where(flat_mask, 0, flat_psi)
|
|
397
|
+
- jnp.where(flat_mask, 0, correction)
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
state = eqx.tree_at(
|
|
401
|
+
lambda s: s.steps_since_jac, solver_state, steps_since_jac
|
|
402
|
+
)
|
|
403
|
+
need_jac, need_lu, gamma_change = self._should_update(state, c)
|
|
404
|
+
|
|
405
|
+
rtol, atol = self._tolerances()
|
|
406
|
+
scale = atol + rtol * jnp.abs(flat_pred)
|
|
407
|
+
tol = newton_tolerance(rtol, dtype)
|
|
408
|
+
|
|
409
|
+
def factorise(jacobian):
|
|
410
|
+
return jsl.lu_factor(mass - c * jacobian)
|
|
411
|
+
|
|
412
|
+
jac = jax.lax.cond(
|
|
413
|
+
need_jac, lambda: jax.jacfwd(vector_field)(flat_pred), lambda: state.jac
|
|
414
|
+
)
|
|
415
|
+
lu, piv = jax.lax.cond(
|
|
416
|
+
need_lu, lambda: factorise(jac), lambda: (state.lu, state.piv)
|
|
417
|
+
)
|
|
418
|
+
guess = jnp.zeros_like(flat_pred)
|
|
419
|
+
correction, converged, _ = chord_solve(
|
|
420
|
+
residual, guess, lu, piv, scale, tol, self.root_find_max_steps
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# A Newton failure against reused linear algebra is not evidence that the step
|
|
424
|
+
# is too large, so rejecting would be the wrong response -- and it would not
|
|
425
|
+
# even help, since a rejected step rolls the solver state back and the retry
|
|
426
|
+
# would arrive with the same stale matrices. scipy and CVODE both refresh and
|
|
427
|
+
# retry within the step instead.
|
|
428
|
+
#
|
|
429
|
+
# Which part to refresh matters. If the factorisation was stale with respect to
|
|
430
|
+
# `c` -- because `c` moved less than `max_gamma_change`, or because the order
|
|
431
|
+
# ramped and changed `alpha[order]` -- then rebuilding it from the *stored*
|
|
432
|
+
# Jacobian is enough, and costs no vector-field evaluations. Only when the
|
|
433
|
+
# factorisation was already current at this `c` is the Jacobian itself the
|
|
434
|
+
# suspect. This is CVODE's `convfail` distinction.
|
|
435
|
+
retry = jnp.invert(converged)
|
|
436
|
+
# Is the factorisation we just used already built at this `c`? If so the
|
|
437
|
+
# Jacobian inside it is the only thing left to blame. If not -- `c` drifted
|
|
438
|
+
# within the deadband, or the order ramped and changed `alpha[order]` --
|
|
439
|
+
# rebuilding the factorisation from the stored Jacobian is the cheap fix.
|
|
440
|
+
lu_is_current = need_lu | (gamma_change < 1e-10)
|
|
441
|
+
blame_jacobian = retry & lu_is_current
|
|
442
|
+
|
|
443
|
+
def refresh():
|
|
444
|
+
fresh_jac = jax.lax.cond(
|
|
445
|
+
blame_jacobian,
|
|
446
|
+
lambda: jax.jacfwd(vector_field)(flat_pred),
|
|
447
|
+
lambda: jac,
|
|
448
|
+
)
|
|
449
|
+
fresh_lu, fresh_piv = factorise(fresh_jac)
|
|
450
|
+
retried, retried_converged, _ = chord_solve(
|
|
451
|
+
residual, guess, fresh_lu, fresh_piv, scale, tol, self.root_find_max_steps
|
|
452
|
+
)
|
|
453
|
+
return fresh_jac, fresh_lu, fresh_piv, retried, retried_converged
|
|
454
|
+
|
|
455
|
+
jac, lu, piv, correction, converged = jax.lax.cond(
|
|
456
|
+
retry,
|
|
457
|
+
refresh,
|
|
458
|
+
lambda: (jac, lu, piv, correction, converged),
|
|
459
|
+
)
|
|
460
|
+
did_jac = need_jac | blame_jacobian
|
|
461
|
+
did_lu = need_lu | retry
|
|
462
|
+
|
|
463
|
+
flat_y1 = flat_pred + correction
|
|
464
|
+
y1 = unravel(flat_y1)
|
|
465
|
+
if self.suppress_algebraic_error:
|
|
466
|
+
error_mask = flat_mask
|
|
467
|
+
else:
|
|
468
|
+
error_mask = None
|
|
469
|
+
y_error = unravel(
|
|
470
|
+
_suppress(jnp.asarray(error_const, dtype)[order] * correction, error_mask)
|
|
471
|
+
)
|
|
472
|
+
# A failed Newton solve is reported as an infinite error estimate so that
|
|
473
|
+
# the controller rejects the step, rather than as a non-successful
|
|
474
|
+
# `RESULTS`, which `diffrax` would treat as a failure of the whole solve.
|
|
475
|
+
y_error = jtu.tree_map(lambda e: jnp.where(converged, e, jnp.inf), y_error)
|
|
476
|
+
# If `init` failed, accept this step so that diffrax sees its result;
|
|
477
|
+
# diffrax ignores the result of a rejected step.
|
|
478
|
+
y_error = jtu.tree_map(
|
|
479
|
+
lambda e: jnp.where(state.initial_failed, 0, e), y_error
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
d_array = _update_difference_array(d_array, order, unravel(correction))
|
|
483
|
+
|
|
484
|
+
if self._variable_order:
|
|
485
|
+
next_order, n_equal_steps = self._select_order(
|
|
486
|
+
d_array,
|
|
487
|
+
order,
|
|
488
|
+
correction,
|
|
489
|
+
flat_y1,
|
|
490
|
+
error_const,
|
|
491
|
+
rtol,
|
|
492
|
+
atol,
|
|
493
|
+
n_equal_steps,
|
|
494
|
+
dtype,
|
|
495
|
+
error_mask,
|
|
496
|
+
)
|
|
497
|
+
# The order that produced this error estimate is the one the controller
|
|
498
|
+
# needs, not the one we have just chosen for the next step.
|
|
499
|
+
y_error = ErrorWithOrder(error=y_error, order=order.astype(dtype))
|
|
500
|
+
else:
|
|
501
|
+
# At fixed order the only movement is the opening ramp: a k-step formula
|
|
502
|
+
# needs k points of history. A rejected step rolls the state back, so the
|
|
503
|
+
# ramp is automatically correct -- a step that did not happen does not
|
|
504
|
+
# advance the order.
|
|
505
|
+
next_order = jnp.minimum(order + 1, self.bdf_order)
|
|
506
|
+
|
|
507
|
+
new_state = _BDFState(
|
|
508
|
+
d_array=d_array,
|
|
509
|
+
order=next_order,
|
|
510
|
+
n_equal_steps=n_equal_steps,
|
|
511
|
+
h_prev=control,
|
|
512
|
+
jac=jac,
|
|
513
|
+
lu=lu,
|
|
514
|
+
piv=piv,
|
|
515
|
+
c_at_last_lu=jnp.where(did_lu, c, state.c_at_last_lu),
|
|
516
|
+
steps_since_lu=jnp.where(did_lu, 0, state.steps_since_lu + 1).astype(
|
|
517
|
+
jnp.int32
|
|
518
|
+
),
|
|
519
|
+
steps_since_jac=jnp.where(did_jac, 0, state.steps_since_jac + 1).astype(
|
|
520
|
+
jnp.int32
|
|
521
|
+
),
|
|
522
|
+
initial_failed=state.initial_failed,
|
|
523
|
+
)
|
|
524
|
+
dense_info = dict(y0=y0, y1=y1)
|
|
525
|
+
result = RESULTS.where(
|
|
526
|
+
state.initial_failed, RESULTS.nonlinear_divergence, RESULTS.successful
|
|
527
|
+
)
|
|
528
|
+
return y1, y_error, dense_info, new_state, result
|
|
529
|
+
|
|
530
|
+
def _select_order(
|
|
531
|
+
self,
|
|
532
|
+
d_array,
|
|
533
|
+
order,
|
|
534
|
+
correction,
|
|
535
|
+
flat_y1,
|
|
536
|
+
error_const,
|
|
537
|
+
rtol,
|
|
538
|
+
atol,
|
|
539
|
+
n_equal_steps,
|
|
540
|
+
dtype,
|
|
541
|
+
error_mask,
|
|
542
|
+
):
|
|
543
|
+
"""scipy's order heuristic: compare the error at this order, one below, one above.
|
|
544
|
+
|
|
545
|
+
The two rows of the difference array beyond `order` exist for exactly this.
|
|
546
|
+
After `_update_difference_array`, row `order` carries what the error would
|
|
547
|
+
have been one order lower and row `order + 2` what it would be one order
|
|
548
|
+
higher, so all three candidates are available without re-solving anything.
|
|
549
|
+
"""
|
|
550
|
+
table = jnp.asarray(error_const, dtype)
|
|
551
|
+
scale = atol + rtol * jnp.abs(flat_y1)
|
|
552
|
+
|
|
553
|
+
def row(index):
|
|
554
|
+
flat_row = ravel_pytree(jtu.tree_map(lambda x: x[index], d_array))[0]
|
|
555
|
+
return _suppress(flat_row, error_mask)
|
|
556
|
+
|
|
557
|
+
correction = _suppress(correction, error_mask)
|
|
558
|
+
error = rms_norm(table[order] * correction / scale)
|
|
559
|
+
error_lower = rms_norm(table[order - 1] * row(order) / scale)
|
|
560
|
+
error_higher = rms_norm(table[order + 1] * row(order + 2) / scale)
|
|
561
|
+
# Candidates outside 1..cap are ruled out with an infinite error, which maps
|
|
562
|
+
# to a zero factor and so can never win the argmax below.
|
|
563
|
+
error_lower = jnp.where(order > 1, error_lower, jnp.inf)
|
|
564
|
+
error_higher = jnp.where(order < self._order_cap, error_higher, jnp.inf)
|
|
565
|
+
|
|
566
|
+
exponents = jnp.stack([order, order + 1, order + 2]).astype(dtype)
|
|
567
|
+
candidates = jnp.stack([error_lower, error, error_higher])
|
|
568
|
+
factors = candidates ** (-1 / exponents)
|
|
569
|
+
delta = jnp.argmax(factors) - 1
|
|
570
|
+
|
|
571
|
+
# scipy holds the order still until `order + 1` steps of equal size have been
|
|
572
|
+
# taken. Both the error constants and the difference array assume an equally
|
|
573
|
+
# spaced history, so changing order before that compares invalid estimates.
|
|
574
|
+
#
|
|
575
|
+
# scipy additionally freezes the step size between order changes, so that the
|
|
576
|
+
# count reliably accumulates. That was tried here and measured worse than
|
|
577
|
+
# letting `BDFController`'s deadband govern the step size (160ms against 124ms
|
|
578
|
+
# on enzax's methionine model from a bad guess), so the two stay decoupled: the
|
|
579
|
+
# deadband already holds the step size still often enough for the gate to open.
|
|
580
|
+
allowed = n_equal_steps >= order + 1
|
|
581
|
+
next_order = jnp.where(
|
|
582
|
+
allowed, jnp.clip(order + delta, 1, self._order_cap), order
|
|
583
|
+
)
|
|
584
|
+
# Restart the count whenever the gate opens, whether or not the order actually
|
|
585
|
+
# moved. This is scipy's behaviour and it measured slightly better than only
|
|
586
|
+
# restarting on a real change (423 steps against 454 on enzax's methionine
|
|
587
|
+
# model from a bad guess).
|
|
588
|
+
n_equal_steps = jnp.where(allowed, 0, n_equal_steps)
|
|
589
|
+
return next_order.astype(jnp.int32), n_equal_steps.astype(jnp.int32)
|
|
590
|
+
|
|
591
|
+
def _restart(
|
|
592
|
+
self,
|
|
593
|
+
terms,
|
|
594
|
+
t0,
|
|
595
|
+
y0,
|
|
596
|
+
args,
|
|
597
|
+
control,
|
|
598
|
+
d_array,
|
|
599
|
+
order,
|
|
600
|
+
n_equal_steps,
|
|
601
|
+
steps_since_jac,
|
|
602
|
+
made_jump,
|
|
603
|
+
mask,
|
|
604
|
+
):
|
|
605
|
+
"""Rebuild the history from scratch after a discontinuity."""
|
|
606
|
+
f0 = _zero_algebraic(terms.vf_prod(t0, y0, args, control), mask)
|
|
607
|
+
|
|
608
|
+
def reset(x, y, f):
|
|
609
|
+
fresh = jnp.zeros_like(x).at[0].set(y).at[1].set(f)
|
|
610
|
+
return jnp.where(made_jump, fresh, x)
|
|
611
|
+
|
|
612
|
+
d_array = jtu.tree_map(reset, d_array, y0, f0)
|
|
613
|
+
order = jnp.where(made_jump, 1, order)
|
|
614
|
+
n_equal_steps = jnp.where(made_jump, 0, n_equal_steps)
|
|
615
|
+
# The vector field changed, so the cached Jacobian is suspect too.
|
|
616
|
+
steps_since_jac = jnp.where(
|
|
617
|
+
made_jump, self.jac_reuse_steps, steps_since_jac
|
|
618
|
+
).astype(jnp.int32)
|
|
619
|
+
return d_array, order, n_equal_steps, steps_since_jac
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
BDF.__init__.__doc__ = """**Arguments:**
|
|
623
|
+
|
|
624
|
+
- `bdf_order`: pin the formula to a fixed order between 1 and 5, or leave it as
|
|
625
|
+
`None` (the default) to vary the order between 1 and `max_order` as scipy and
|
|
626
|
+
CVODE do. Variable order needs [`diffrax_bdf.BDFController`][]; a fixed order
|
|
627
|
+
works with any adaptive step size controller. Note that a fixed high order is
|
|
628
|
+
less stable: BDF4 is A(73.35 degrees)-stable and BDF5 only
|
|
629
|
+
A(51.84 degrees)-stable, so both can go unstable on strongly oscillatory systems.
|
|
630
|
+
- `max_order`: the highest order the variable-order heuristic may select.
|
|
631
|
+
- `use_ndf`: whether to apply the Shampine & Reichelt NDF correction. `False`
|
|
632
|
+
gives the classical BDF coefficients, which is the family SUNDIALS CVODE
|
|
633
|
+
implements.
|
|
634
|
+
- `root_finder`: only its `rtol` and `atol` are used, to set the Newton convergence
|
|
635
|
+
tolerance. The iteration itself is a chord iteration against a stored
|
|
636
|
+
factorisation, which is what allows that factorisation to be reused between
|
|
637
|
+
steps.
|
|
638
|
+
- `root_find_max_steps`: maximum chord iterations per step. scipy uses 4, CVODE 3.
|
|
639
|
+
- `lu_reuse_steps`: refactorise `I - c * J` after this many accepted steps even if
|
|
640
|
+
`c` has not moved. CVODE's `MSBP`.
|
|
641
|
+
- `jac_reuse_steps`: re-evaluate the Jacobian after this many accepted steps.
|
|
642
|
+
CVODE's `MSBJ`. Much larger than `lu_reuse_steps` because a Jacobian costs an
|
|
643
|
+
automatic-differentiation pass where a refactorisation does not.
|
|
644
|
+
- `max_gamma_change`: refactorise when `c` has moved by more than this relative
|
|
645
|
+
amount since the last factorisation. CVODE's `DGMAX_LSETUP`.
|
|
646
|
+
- `suppress_algebraic_error`: leave the algebraic components of a
|
|
647
|
+
`SemiExplicitDAETerm` out of the local error estimate and the order
|
|
648
|
+
selection, as SUNDIALS IDA's `suppressalg` does. Each step's Newton
|
|
649
|
+
iteration still has to converge on them.
|
|
650
|
+
- `correct_initial_algebraic`: before the first step, solve the constraints of
|
|
651
|
+
a `SemiExplicitDAETerm` for its algebraic components, holding the others
|
|
652
|
+
fixed. If this fails, the solve stops with `RESULTS.nonlinear_divergence`.
|
|
653
|
+
Turn it off when `y0` is known to be consistent.
|
|
654
|
+
- `initial_algebraic_max_steps`: the maximum number of Newton steps in that
|
|
655
|
+
correction.
|
|
656
|
+
"""
|
diffrax_bdf/_coeffs.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""BDF/NDF coefficient tables and backward-difference array manipulation.
|
|
2
|
+
|
|
3
|
+
The representation follows `scipy.integrate.BDF` (in turn following the MATLAB
|
|
4
|
+
`ode15s` of Shampine & Reichelt 1997): the solution history is stored as an array
|
|
5
|
+
of backward differences `D`, with `D[j]` the `j`-th backward difference of `y`.
|
|
6
|
+
|
|
7
|
+
Everything here is written for static shapes: arrays are always sized for
|
|
8
|
+
`MAX_ORDER` and the active block is selected by masking against a *traced* order,
|
|
9
|
+
never by slicing with it. This is what makes the solver usable inside the
|
|
10
|
+
`lax.while_loop` that `diffrax` runs its integration in.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import jax.numpy as jnp
|
|
14
|
+
import jax.tree_util as jtu
|
|
15
|
+
import numpy as np
|
|
16
|
+
from jaxtyping import Array, Float, PyTree
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
MAX_ORDER = 5
|
|
20
|
+
|
|
21
|
+
# Rows of the difference array. Rows `0..order` hold the differences themselves;
|
|
22
|
+
# row `order + 1` holds the accepted Newton correction `d`, and row `order + 2`
|
|
23
|
+
# holds `d - D_prev[order + 1]`. The last two feed the order-up error estimate.
|
|
24
|
+
D_ROWS = MAX_ORDER + 3
|
|
25
|
+
|
|
26
|
+
# Shampine & Reichelt's NDF correction. `kappa = 0` recovers the classical BDF
|
|
27
|
+
# formulae (which is what SUNDIALS CVODE uses); see `make_tables`.
|
|
28
|
+
KAPPA_NDF = np.array([0.0, -0.1850, -1 / 9, -0.0823, -0.0415, 0.0])
|
|
29
|
+
KAPPA_BDF = np.zeros(MAX_ORDER + 1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def make_tables(use_ndf: bool = True):
|
|
33
|
+
"""Return `(gamma, alpha, error_const)` as float64 numpy arrays.
|
|
34
|
+
|
|
35
|
+
With `use_ndf=False` this reduces to the classical BDF coefficients,
|
|
36
|
+
`alpha[k] == sum_{j=1..k} 1/j` and `error_const[k] == 1/(k + 1)`, which is the
|
|
37
|
+
formula family CVODE implements (its `gamma` is our `h / alpha[order]`).
|
|
38
|
+
"""
|
|
39
|
+
kappa = KAPPA_NDF if use_ndf else KAPPA_BDF
|
|
40
|
+
gamma = np.hstack((0.0, np.cumsum(1 / np.arange(1, MAX_ORDER + 1))))
|
|
41
|
+
alpha = (1 - kappa) * gamma
|
|
42
|
+
error_const = kappa * gamma + 1 / np.arange(1, MAX_ORDER + 2)
|
|
43
|
+
return gamma, alpha, error_const
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def compute_R(factor, order) -> Float[Array, "6 6"]:
|
|
47
|
+
"""The `(MAX_ORDER + 1, MAX_ORDER + 1)` difference-rescaling matrix.
|
|
48
|
+
|
|
49
|
+
The top-left `(order + 1, order + 1)` block equals scipy's
|
|
50
|
+
`compute_R(order, factor)`; outside that block the result is the identity, so
|
|
51
|
+
that multiplying by it leaves the inactive rows of `D` untouched.
|
|
52
|
+
|
|
53
|
+
Note the unmasked entries `M[i, j] = (i - 1 - factor * j) / i` do not depend on
|
|
54
|
+
`order` at all, and `cumprod` along axis 0 only ever reads rows `<= i`. That is
|
|
55
|
+
why building the full matrix and masking gives exactly the same active block as
|
|
56
|
+
scipy's order-sized construction.
|
|
57
|
+
"""
|
|
58
|
+
idx = jnp.arange(MAX_ORDER + 1)
|
|
59
|
+
i = jnp.arange(1, MAX_ORDER + 1)[:, None]
|
|
60
|
+
j = jnp.arange(1, MAX_ORDER + 1)[None, :]
|
|
61
|
+
# `jnp.result_type(float)` respects the x64 config, so this is float64 when
|
|
62
|
+
# x64 is enabled rather than being dragged down to float32 by a literal.
|
|
63
|
+
dtype = jnp.result_type(factor, jnp.result_type(float))
|
|
64
|
+
body = (i - 1 - factor * j) / i
|
|
65
|
+
m = jnp.zeros((MAX_ORDER + 1, MAX_ORDER + 1), dtype=dtype)
|
|
66
|
+
m = m.at[1:, 1:].set(body.astype(dtype))
|
|
67
|
+
m = m.at[0].set(1.0)
|
|
68
|
+
r = jnp.cumprod(m, axis=0)
|
|
69
|
+
active = (idx[:, None] <= order) & (idx[None, :] <= order)
|
|
70
|
+
return jnp.where(active, r, jnp.eye(MAX_ORDER + 1, dtype=dtype))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def change_D(
|
|
74
|
+
d_array: PyTree[Float[Array, "8 ..."]], order, factor
|
|
75
|
+
) -> PyTree[Float[Array, "8 ..."]]:
|
|
76
|
+
"""Rescale the difference array for a change of step size by `factor`.
|
|
77
|
+
|
|
78
|
+
This is scipy's `change_D`, and is exactly SUNDIALS' `cvRescale` expressed in
|
|
79
|
+
the backward-difference basis rather than the Nordsieck one: writing `S` for the
|
|
80
|
+
(fixed, order-only) change of basis, `(R @ U).T == inv(S) @ diag(factor**j) @ S`.
|
|
81
|
+
"""
|
|
82
|
+
ru = compute_R(factor, order) @ compute_R(1.0, order)
|
|
83
|
+
return jtu.tree_map(lambda x: _apply_rows(ru.T, x), d_array)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _apply_rows(mat: Float[Array, "6 6"], x: Float[Array, "8 ..."]):
|
|
87
|
+
"""Left-multiply the first `MAX_ORDER + 1` rows of `x` by `mat`."""
|
|
88
|
+
x = jnp.asarray(x)
|
|
89
|
+
head = jnp.tensordot(mat.astype(x.dtype), x[: MAX_ORDER + 1], axes=1)
|
|
90
|
+
return x.at[: MAX_ORDER + 1].set(head)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def weighted_row_sum(coeffs: Float[Array, " 6"], d_array: PyTree) -> PyTree:
|
|
94
|
+
"""Contract `coeffs` against rows `0..MAX_ORDER` of each leaf of `d_array`."""
|
|
95
|
+
def contract(x):
|
|
96
|
+
x = jnp.asarray(x)
|
|
97
|
+
return jnp.tensordot(
|
|
98
|
+
jnp.asarray(coeffs).astype(x.dtype), x[: MAX_ORDER + 1], axes=1
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return jtu.tree_map(contract, d_array)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Step-size control with CVODE's deadband.
|
|
2
|
+
|
|
3
|
+
A BDF only pays for itself if the factorisation of `I - c * J` can be reused across
|
|
4
|
+
steps, and that requires `c`, and hence the step size, to stay put. diffrax's stock
|
|
5
|
+
`PIDController` adjusts the step on every single step, so `c` changes every step and
|
|
6
|
+
the factorisation has to be redone every step -- at which point a BDF is doing the
|
|
7
|
+
same amount of linear algebra as an ESDIRK and has given up its advantage.
|
|
8
|
+
|
|
9
|
+
CVODE solves this with a deadband (`cvSetEta`): a successful step never changes the
|
|
10
|
+
step size unless the proposed growth factor is at least `eta_max_fx` (1.5 by
|
|
11
|
+
default), and never shrinks it at all. Rejected steps are unaffected. This gives up
|
|
12
|
+
a little step-size optimality and buys long runs at constant `c`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import diffrax
|
|
16
|
+
import jax.numpy as jnp
|
|
17
|
+
from jaxtyping import PyTree
|
|
18
|
+
|
|
19
|
+
from ._bdf import ErrorWithOrder
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BDFController(diffrax.PIDController):
|
|
23
|
+
"""`diffrax.PIDController` with CVODE's step-size deadband and variable-order support.
|
|
24
|
+
|
|
25
|
+
All of `PIDController`'s arguments apply. The added `eta_max_fx` is the growth
|
|
26
|
+
factor below which an accepted step keeps its step size unchanged.
|
|
27
|
+
|
|
28
|
+
This controller is also required by a variable-order [`diffrax_bdf.BDF`][].
|
|
29
|
+
`diffrax` asks a solver for its error order only once, so a solver whose order
|
|
30
|
+
changes from step to step has to send the current one along with the error
|
|
31
|
+
estimate; this controller knows how to read that.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
eta_max_fx: float = 1.5
|
|
35
|
+
|
|
36
|
+
def adapt_step_size(
|
|
37
|
+
self,
|
|
38
|
+
t0,
|
|
39
|
+
t1,
|
|
40
|
+
y0: PyTree,
|
|
41
|
+
y1_candidate: PyTree,
|
|
42
|
+
args: PyTree,
|
|
43
|
+
y_error: PyTree,
|
|
44
|
+
error_order,
|
|
45
|
+
controller_state,
|
|
46
|
+
):
|
|
47
|
+
if isinstance(y_error, ErrorWithOrder):
|
|
48
|
+
# The step-size exponent is `1 / (p + 1)` for a method of order `p`, so at
|
|
49
|
+
# variable order it has to follow the order that actually produced this
|
|
50
|
+
# estimate rather than a value fixed before the solve began.
|
|
51
|
+
error_order = y_error.order + 1
|
|
52
|
+
y_error = y_error.error
|
|
53
|
+
keep_step, next_t0, next_t1, made_jump, state, result = super().adapt_step_size(
|
|
54
|
+
t0, t1, y0, y1_candidate, args, y_error, error_order, controller_state
|
|
55
|
+
)
|
|
56
|
+
# Work in signed step sizes throughout so that backwards integration, where
|
|
57
|
+
# `diffrax` hands us a negative step, needs no special case.
|
|
58
|
+
step = t1 - t0
|
|
59
|
+
proposed = next_t1 - next_t0
|
|
60
|
+
growth = proposed / jnp.where(step == 0, 1.0, step)
|
|
61
|
+
frozen = keep_step & (growth < self.eta_max_fx)
|
|
62
|
+
next_t1 = jnp.where(frozen, next_t0 + step, next_t1)
|
|
63
|
+
return keep_step, next_t0, next_t1, made_jump, state, result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
BDFController.__init__.__doc__ = """**Arguments:**
|
|
67
|
+
|
|
68
|
+
As [`diffrax.PIDController`][], plus:
|
|
69
|
+
|
|
70
|
+
- `eta_max_fx`: an accepted step keeps its step size unless the controller wants to
|
|
71
|
+
grow it by at least this factor. Raising it reuses factorisations for longer at
|
|
72
|
+
the cost of less optimal steps; setting it to 1 recovers `PIDController`.
|
|
73
|
+
"""
|
diffrax_bdf/_newton.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Chord iteration against a stored LU factorisation.
|
|
2
|
+
|
|
3
|
+
This replaces the Optimistix root find used elsewhere in diffrax for one reason: we
|
|
4
|
+
need to keep the factorisation of `I - c * J` in the solver state and reuse it
|
|
5
|
+
across *steps*. Optimistix's root finders build their linearisation internally and
|
|
6
|
+
hold it as a `lineax` operator with a captured jaxpr, which cannot be carried
|
|
7
|
+
through a `lax.while_loop`. A pair of plain `(lu, piv)` arrays can.
|
|
8
|
+
|
|
9
|
+
The iteration and its convergence test follow `scipy.integrate.BDF`'s
|
|
10
|
+
`solve_bdf_system` closely, including the divergence test, so that behaviour is
|
|
11
|
+
comparable step by step.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import equinox.internal as eqxi
|
|
15
|
+
import jax.numpy as jnp
|
|
16
|
+
import jax.scipy.linalg as jsl
|
|
17
|
+
from jaxtyping import Array, Float, Int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def rms_norm(x: Float[Array, " n"]) -> Float[Array, ""]:
|
|
21
|
+
"""scipy's `norm`: the 2-norm scaled by the square root of the size."""
|
|
22
|
+
return jnp.linalg.norm(x) / jnp.sqrt(x.size)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def newton_tolerance(rtol, dtype) -> Float[Array, ""]:
|
|
26
|
+
"""scipy's `newton_tol`.
|
|
27
|
+
|
|
28
|
+
The `eps` must come from the *runtime* dtype. Hard-coding the float64 value
|
|
29
|
+
would, in float32 at `rtol=1e-6`, give a tolerance above 1 and make the
|
|
30
|
+
iteration "converge" immediately on its first step.
|
|
31
|
+
"""
|
|
32
|
+
eps = jnp.finfo(dtype).eps
|
|
33
|
+
return jnp.maximum(10 * eps / rtol, jnp.minimum(0.03, jnp.sqrt(rtol)))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def chord_solve(
|
|
37
|
+
residual,
|
|
38
|
+
guess: Float[Array, " n"],
|
|
39
|
+
lu: Float[Array, "n n"],
|
|
40
|
+
piv: Int[Array, " n"],
|
|
41
|
+
scale: Float[Array, " n"],
|
|
42
|
+
tol,
|
|
43
|
+
max_iter: int,
|
|
44
|
+
):
|
|
45
|
+
"""Solve `residual(d) == 0` by chord iteration, holding `lu` fixed.
|
|
46
|
+
|
|
47
|
+
Returns `(correction, converged, num_iterations)`.
|
|
48
|
+
"""
|
|
49
|
+
zero = jnp.zeros((), dtype=guess.dtype)
|
|
50
|
+
|
|
51
|
+
def cond(carry):
|
|
52
|
+
_, _, _, converged, diverged = carry
|
|
53
|
+
return jnp.invert(converged | diverged)
|
|
54
|
+
|
|
55
|
+
def body(carry):
|
|
56
|
+
step, correction, previous_norm, _, _ = carry
|
|
57
|
+
residual_value = residual(correction)
|
|
58
|
+
delta = jsl.lu_solve((lu, piv), residual_value)
|
|
59
|
+
delta_norm = rms_norm(delta / scale)
|
|
60
|
+
|
|
61
|
+
# On the first iteration there is no previous norm to compare against, so
|
|
62
|
+
# neither the divergence nor the convergence rate test applies.
|
|
63
|
+
has_history = step > 0
|
|
64
|
+
safe_previous = jnp.where(previous_norm > 0, previous_norm, 1.0)
|
|
65
|
+
rate = jnp.where(has_history, delta_norm / safe_previous, zero)
|
|
66
|
+
# Guard the `1 - rate` denominators: they are only meaningful when the
|
|
67
|
+
# iteration is contracting, and the `rate >= 1` test below catches the rest.
|
|
68
|
+
safe_rate = jnp.minimum(rate, 0.99)
|
|
69
|
+
remaining = jnp.maximum(max_iter - step, 1)
|
|
70
|
+
|
|
71
|
+
diverging = has_history & (
|
|
72
|
+
(rate >= 1)
|
|
73
|
+
| (safe_rate**remaining / (1 - safe_rate) * delta_norm > tol)
|
|
74
|
+
)
|
|
75
|
+
diverging = diverging | jnp.invert(jnp.all(jnp.isfinite(residual_value)))
|
|
76
|
+
# scipy breaks out *before* applying a diverging update.
|
|
77
|
+
correction = jnp.where(diverging, correction, correction + delta)
|
|
78
|
+
converged = jnp.invert(diverging) & (
|
|
79
|
+
(delta_norm == 0)
|
|
80
|
+
| (has_history & (safe_rate / (1 - safe_rate) * delta_norm < tol))
|
|
81
|
+
)
|
|
82
|
+
return step + 1, correction, delta_norm, converged, diverging
|
|
83
|
+
|
|
84
|
+
init = (
|
|
85
|
+
jnp.asarray(0, jnp.int32),
|
|
86
|
+
guess,
|
|
87
|
+
zero,
|
|
88
|
+
jnp.asarray(False),
|
|
89
|
+
jnp.asarray(False),
|
|
90
|
+
)
|
|
91
|
+
# `kind="bounded"` keeps this reverse-mode differentiable, which a plain
|
|
92
|
+
# `lax.while_loop` would not be.
|
|
93
|
+
steps, correction, _, converged, _ = eqxi.while_loop(
|
|
94
|
+
cond, body, init, max_steps=max_iter, kind="bounded"
|
|
95
|
+
)
|
|
96
|
+
return correction, converged, steps
|
diffrax_bdf/_term.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
|
|
3
|
+
import jax.numpy as jnp
|
|
4
|
+
import jax.tree_util as jtu
|
|
5
|
+
from diffrax import AbstractTerm, ODETerm
|
|
6
|
+
from diffrax._term import WrapTerm
|
|
7
|
+
from jax.flatten_util import ravel_pytree
|
|
8
|
+
from jaxtyping import Array, Bool, PyTree
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SemiExplicitDAETerm(AbstractTerm):
|
|
12
|
+
"""A semi-explicit differential algebraic equation, for
|
|
13
|
+
[`diffrax_bdf.BDF`][].
|
|
14
|
+
|
|
15
|
+
Where `algebraic_mask` is false, the vector field gives derivatives,
|
|
16
|
+
`dy/dt = f(t, y)`; where it is true, it gives constraints, `0 = g(t, y)`.
|
|
17
|
+
The constraints must have index 1: their Jacobian with respect to the
|
|
18
|
+
algebraic components must be nonsingular.
|
|
19
|
+
|
|
20
|
+
Only `BDF` can solve this term. Other diffrax solvers would integrate the
|
|
21
|
+
constraints as if they were derivatives, so they raise an error instead.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
vector_field: Callable
|
|
25
|
+
algebraic_mask: PyTree
|
|
26
|
+
|
|
27
|
+
def _unsupported(self, *args, **kwargs):
|
|
28
|
+
raise TypeError(
|
|
29
|
+
"A `SemiExplicitDAETerm` can only be solved with `diffrax_bdf.BDF`, "
|
|
30
|
+
"which treats its algebraic components as constraints. Other solvers "
|
|
31
|
+
"would integrate them as if they were derivatives."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def vf(self, t, y, args):
|
|
35
|
+
return self.vector_field(t, y, args)
|
|
36
|
+
|
|
37
|
+
vf_prod = _unsupported
|
|
38
|
+
prod = _unsupported
|
|
39
|
+
|
|
40
|
+
def contr(self, t0, t1, **kwargs):
|
|
41
|
+
return t1 - t0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
SemiExplicitDAETerm.__init__.__doc__ = """**Arguments:**
|
|
45
|
+
|
|
46
|
+
- `vector_field`: a function `(t, y, args) -> f` returning a pytree with the
|
|
47
|
+
structure of `y`.
|
|
48
|
+
- `algebraic_mask`: which components of `y` are algebraic. Each leaf is
|
|
49
|
+
broadcast against the matching leaf of `y`, so `(False, True)` marks the
|
|
50
|
+
second element of `y = (z, u)` as algebraic.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def split_dae_term(terms) -> tuple[AbstractTerm, PyTree | None]:
|
|
55
|
+
"""Return an ODE view of `terms` and its algebraic mask, or `None` for an ODE
|
|
56
|
+
term."""
|
|
57
|
+
if isinstance(terms, SemiExplicitDAETerm):
|
|
58
|
+
return ODETerm(terms.vector_field), terms.algebraic_mask
|
|
59
|
+
if isinstance(terms, WrapTerm) and isinstance(terms.term, SemiExplicitDAETerm):
|
|
60
|
+
ode_term = ODETerm(terms.term.vector_field)
|
|
61
|
+
return WrapTerm(ode_term, terms.direction), terms.term.algebraic_mask
|
|
62
|
+
return terms, None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def flat_algebraic_mask(mask: PyTree, y: PyTree) -> Bool[Array, " n"]:
|
|
66
|
+
"""Broadcast the mask against `y` and flatten it in `ravel_pytree`'s order."""
|
|
67
|
+
broadcast = jtu.tree_map(
|
|
68
|
+
lambda m, leaf: jnp.broadcast_to(jnp.asarray(m, bool), jnp.shape(leaf)),
|
|
69
|
+
mask,
|
|
70
|
+
y,
|
|
71
|
+
)
|
|
72
|
+
return ravel_pytree(broadcast)[0]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: diffrax-bdf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Diffrax-compatible backward differentiation formula ODE solver
|
|
5
|
+
Author-email: groves.teddy@gmail.com
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Dist: diffrax>=0.7.2
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# diffrax-bdf
|
|
12
|
+
|
|
13
|
+
A [backward differentiation formula](https://en.wikipedia.org/wiki/Backward_differentiation_formula)
|
|
14
|
+
solver for [diffrax](https://github.com/patrick-kidger/diffrax), aimed at stiff
|
|
15
|
+
steady-state problems — in particular the kinetic reaction networks in
|
|
16
|
+
[enzax](https://github.com/dtu-qmcm/enzax).
|
|
17
|
+
|
|
18
|
+
diffrax has no BDF ([#8](https://github.com/patrick-kidger/diffrax/issues/8)). This
|
|
19
|
+
package supplies one as an ordinary `AbstractSolver` subclass, with no patching of
|
|
20
|
+
diffrax itself.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import diffrax
|
|
24
|
+
import jax.numpy as jnp
|
|
25
|
+
from diffrax_bdf import BDF, BDFController
|
|
26
|
+
|
|
27
|
+
sol = diffrax.diffeqsolve(
|
|
28
|
+
diffrax.ODETerm(vector_field),
|
|
29
|
+
BDF(), # variable order 1-5, as scipy and CVODE do
|
|
30
|
+
t0=0.0,
|
|
31
|
+
t1=jnp.inf,
|
|
32
|
+
dt0=1e-6,
|
|
33
|
+
y0=guess,
|
|
34
|
+
args=parameters,
|
|
35
|
+
stepsize_controller=BDFController(rtol=1e-8, atol=1e-10, dtmax=1e6),
|
|
36
|
+
event=diffrax.Event(diffrax.steady_state_event()),
|
|
37
|
+
adjoint=diffrax.ImplicitAdjoint(),
|
|
38
|
+
saveat=diffrax.SaveAt(t1=True),
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Pin the order with `BDF(bdf_order=3)` to use any adaptive step size controller,
|
|
43
|
+
including diffrax's own `PIDController`.
|
|
44
|
+
|
|
45
|
+
## Status
|
|
46
|
+
|
|
47
|
+
Variable order 1-5, adaptive step size, reuse of the Jacobian and its
|
|
48
|
+
factorisation across steps, and semi-explicit index-1 DAEs.
|
|
49
|
+
|
|
50
|
+
## Method
|
|
51
|
+
|
|
52
|
+
The formulation follows `scipy.integrate.BDF` and MATLAB's `ode15s`: the history is
|
|
53
|
+
a backward-difference array rescaled on each change of step size, with the Shampine
|
|
54
|
+
& Reichelt NDF correction available via `use_ndf` (`False` gives the classical BDF
|
|
55
|
+
coefficients, the family SUNDIALS CVODE implements). The order is chosen each step
|
|
56
|
+
by scipy's heuristic, comparing the error estimate at the current order against what
|
|
57
|
+
it would have been one order lower and one order higher.
|
|
58
|
+
|
|
59
|
+
The linear-algebra reuse policy is CVODE's, because that is where CVODE's advantage
|
|
60
|
+
over scipy actually lives. `Kvaerno5` already computes one Jacobian per step and
|
|
61
|
+
reuses it across its five stages, so "one Newton solve per step instead of five
|
|
62
|
+
stage solves" buys only a few linear solves. The real saving is keeping `J` and the
|
|
63
|
+
factorisation of `I - c * J` across *steps*, gated on two separate counters:
|
|
64
|
+
|
|
65
|
+
- the factorisation is rebuilt when `c` has moved by more than `max_gamma_change`
|
|
66
|
+
(0.3), or after `lu_reuse_steps` (20) accepted steps;
|
|
67
|
+
- the Jacobian itself is rebuilt only after `jac_reuse_steps` (51) accepted steps,
|
|
68
|
+
so a refactorisation reassembles `I - c * J` from the stored `J` without touching
|
|
69
|
+
the vector field.
|
|
70
|
+
|
|
71
|
+
`BDFController` adds CVODE's step-size deadband on top of `diffrax.PIDController`:
|
|
72
|
+
an accepted step keeps its step size unless the controller wants to grow it by at
|
|
73
|
+
least 1.5x, and never shrinks it. Holding `h` still holds `c` still, which is what
|
|
74
|
+
lets the factorisation survive.
|
|
75
|
+
|
|
76
|
+
It also carries the order. diffrax asks a solver for its error order once, before
|
|
77
|
+
the solve (`solver.error_order(terms)` sees neither state nor time), so a
|
|
78
|
+
variable-order solver cannot report a per-step order through that route. The only
|
|
79
|
+
other channel to the controller is `y_error`, which the integration loop treats
|
|
80
|
+
opaquely, so the order travels inside it.
|
|
81
|
+
|
|
82
|
+
Everything is written for static shapes -- the order is a *traced* integer used to
|
|
83
|
+
mask fixed-size buffers -- because diffrax carries the solver state through a
|
|
84
|
+
`lax.while_loop`.
|
|
85
|
+
|
|
86
|
+
## Order selection on a steady-state solve
|
|
87
|
+
|
|
88
|
+
Integrating the `n=3` problem below to steady state, the order climbs through the
|
|
89
|
+
transient and then falls back as the residual collapses and the step grows:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
order: 1 2 2 3 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 2 1 1 1 1 1 1 1
|
|
93
|
+
step: 1.2e-07 ------------------------------------------------> 7.9e+05
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The tail is the useful part: BDF1 with an enormous step *is* Newton's method on
|
|
97
|
+
`f(y) = 0`, so a correct variable-order BDF turns into a steady-state solver by
|
|
98
|
+
itself as it converges.
|
|
99
|
+
|
|
100
|
+
## Differential algebraic equations
|
|
101
|
+
|
|
102
|
+
`BDF` also solves semi-explicit index-1 DAEs, where some components of `y` obey differential equations and the rest obey algebraic constraints:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
term = SemiExplicitDAETerm(vector_field, algebraic_mask=(False, True))
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Here `vector_field` returns `(f, g)` for `y = (z, u)`, meaning `dz/dt = f` and `0 = g`. The corrector solves the constraints in the same chord iteration as the differential equations, with iteration matrix `M − cJ` where `M` is 1 on differential components and 0 on algebraic ones, and reuses its factorisation across steps as for an ODE.
|
|
109
|
+
|
|
110
|
+
- By default `init` corrects an inconsistent initial value by a Newton solve on the constraints. diffrax keeps its own copy of `y0`, so `SaveAt(t0=True)` shows the value you passed, not the corrected one.
|
|
111
|
+
- `suppress_algebraic_error=True` drops the algebraic components from the error estimate, which suits problems where only `z` is of interest. On a rapid-equilibrium steady state it saved 29% of steps.
|
|
112
|
+
- The steady-state event and `ImplicitAdjoint` work unchanged: the steady state is the root of `(f, g)`.
|
|
113
|
+
|
|
114
|
+
## Caveats
|
|
115
|
+
|
|
116
|
+
- **Enable `jax_enable_x64`.** High-order backward differences suffer heavy
|
|
117
|
+
cancellation; above order 2 they are noise in float32.
|
|
118
|
+
- **Set `dtmax` when integrating to `t1=inf`.** As the residual collapses the
|
|
119
|
+
controller's growth factor saturates and the step grows without bound until it
|
|
120
|
+
overflows the time variable.
|
|
121
|
+
- Variable order requires `BDFController`; a fixed `bdf_order` works with any
|
|
122
|
+
adaptive controller.
|
|
123
|
+
- The Jacobian is formed densely, so this suits small to medium systems.
|
|
124
|
+
- Requires a term whose control is a scalar, i.e. `ODETerm` or `SemiExplicitDAETerm`.
|
|
125
|
+
|
|
126
|
+
## Performance
|
|
127
|
+
|
|
128
|
+
Steady-state solves to `rtol=1e-8`, CPU, float64, medians of interleaved rounds.
|
|
129
|
+
Compare within a block, not across them -- absolute timings drift between runs.
|
|
130
|
+
|
|
131
|
+
**Whether BDF beats `Kvaerno5` depends entirely on the problem.** Where `Kvaerno5`'s
|
|
132
|
+
step control struggles, BDF wins by a wide margin; where it does not, `Kvaerno5`
|
|
133
|
+
wins on step count.
|
|
134
|
+
|
|
135
|
+
| problem | solver | steps | rejected | median |
|
|
136
|
+
|---|---|---|---|---|
|
|
137
|
+
| fast equilibrium, n=3 | `Kvaerno5` | 7784 | 4563 | 192.4 ms |
|
|
138
|
+
| fast equilibrium, n=3 | `BDF(bdf_order=5)` | 276 | 14 | 3.5 ms |
|
|
139
|
+
| fast equilibrium, n=3 | `BDF()` variable | 231 | 6 | **3.3 ms** |
|
|
140
|
+
| stiff random network, n=18 | `Kvaerno5` | 212 | 14 | **23.6 ms** |
|
|
141
|
+
| stiff random network, n=18 | `BDF(bdf_order=5)` | 593 | 11 | 28.8 ms |
|
|
142
|
+
| stiff random network, n=18 | `BDF()` variable | 589 | 12 | 31.7 ms |
|
|
143
|
+
| stiff random network, n=60 | `Kvaerno5` | 193 | 7 | **73.6 ms** |
|
|
144
|
+
| stiff random network, n=60 | `BDF(bdf_order=5)` | 586 | 6 | **145.2 ms** |
|
|
145
|
+
| stiff random network, n=60 | `BDF()` variable | 580 | 7 | 153.7 ms |
|
|
146
|
+
|
|
147
|
+
The first problem is the interesting one: `Kvaerno5` rejects 59% of its steps, where
|
|
148
|
+
BDF rejects 2-5%.
|
|
149
|
+
|
|
150
|
+
Variable order helps where the order genuinely needs to move -- 231 steps against
|
|
151
|
+
276 for the best fixed order on the steady-state problem. On the random networks,
|
|
152
|
+
where order 5 is right throughout, it matches fixed order 5 on step count and pays
|
|
153
|
+
about 8% per step for the heuristic. Reuse is worth a further 16-18%.
|
|
154
|
+
|
|
155
|
+
## On enzax's models
|
|
156
|
+
|
|
157
|
+
The motivating case. Measured against
|
|
158
|
+
[enzax](https://github.com/dtu-qmcm/enzax)'s own steady-state configuration
|
|
159
|
+
(`t1=inf`, `steady_state_event`, `ImplicitAdjoint`, `rtol=atol=1e-9`), each solver
|
|
160
|
+
with the step size controller that suits it. Medians of interleaved rounds.
|
|
161
|
+
|
|
162
|
+
| model | n | regime | `Kvaerno5` (enzax today) | `BDF()` | speedup |
|
|
163
|
+
|---|---|---|---|---|---|
|
|
164
|
+
| methionine | 5 | bad guess | 555 ms (649 steps, 145 rejected) | 158 ms (423, 52) | **3.5x** |
|
|
165
|
+
| methionine | 5 | warm guess, 2% parameter jitter | 30.7 ms (38 steps) | 23.3 ms (88) | 1.3x |
|
|
166
|
+
| methionine | 5 | `jacrev` through the solve | 27.9 ms | 22.4 ms | 1.25x |
|
|
167
|
+
| conserved moiety | 5 | bad guess | 191 ms (225, 30) | 53.7 ms (293, 4) | **3.6x** |
|
|
168
|
+
| conserved moiety | 5 | warm guess, 2% jitter | 58.3 ms (68) | 31.4 ms (165) | 1.9x |
|
|
169
|
+
| conserved moiety | 5 | `jacrev` | 53.0 ms | 27.6 ms | 1.9x |
|
|
170
|
+
| glycolysis | 18 | bad guess | 2302 ms (1007, 247) | 271 ms (821, 70) | **8.5x** |
|
|
171
|
+
| glycolysis | 18 | guess perturbed 20% | 491 ms (212, 38) | 108 ms (277, 18) | **4.6x** |
|
|
172
|
+
| glycolysis | 18 | `jacrev` | 427 ms | 104 ms | **4.1x** |
|
|
173
|
+
|
|
174
|
+
"bad guess" is enzax's own `BAD_GUESS = full(shape, 0.01)`. Steady states agree with
|
|
175
|
+
`Kvaerno5` to 2e-7 relative or better and gradients to 1e-5 or better.
|
|
176
|
+
|
|
177
|
+
Note the step counts: BDF often takes *more* steps and is still several times
|
|
178
|
+
faster, because a step is one Newton solve against a reused factorisation rather
|
|
179
|
+
than five stage solves. It also rejects far fewer steps -- 70 against 247 on
|
|
180
|
+
glycolysis from a bad guess. Compile time is 30-45% lower throughout, which matters
|
|
181
|
+
when every leapfrog step of a NUTS chain pays it.
|
|
182
|
+
|
|
183
|
+
The gap widens with model size, which is the encouraging part: 1.3x on the 5-state
|
|
184
|
+
methionine model against 4-8x on the 18-state glycolysis one.
|
|
185
|
+
|
|
186
|
+
### Tuning
|
|
187
|
+
|
|
188
|
+
enzax's `PIDController(pcoeff=0.1, icoeff=0.3)` is tuned for `Kvaerno5`, which
|
|
189
|
+
rejects heavily; it damps step size growth to `0.4 / error_order` where the default
|
|
190
|
+
pure-I controller uses `1 / error_order`. BDF rejects very few steps and does not
|
|
191
|
+
need the damping, so it should be left on `BDFController`'s defaults. Using enzax's
|
|
192
|
+
tuning costs BDF roughly a factor of two -- 174 steps and 45.6 ms against 88 steps
|
|
193
|
+
and 26.9 ms on methionine.
|
|
194
|
+
|
|
195
|
+
## Testing
|
|
196
|
+
|
|
197
|
+
`uv run pytest`. scipy's BDF is pure Python and is used as a white-box oracle: the
|
|
198
|
+
coefficient tables, the difference-array rescaling, and a single pinned step are all
|
|
199
|
+
asserted against it directly. The reuse policy, the deadband and the order-selection
|
|
200
|
+
heuristic are tested directly as well, since without them the solver has no
|
|
201
|
+
advantage over `Kvaerno5` at all.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
diffrax_bdf/__init__.py,sha256=FTsR3R3gYwjI6-uNlDMyM69pPDJ-o0DTsIaow5zNizU,249
|
|
2
|
+
diffrax_bdf/_bdf.py,sha256=otW-MNeGdaTPgoUYK9F6hIYYJvY0XAvih2hJsuWbM4w,28757
|
|
3
|
+
diffrax_bdf/_coeffs.py,sha256=1Zpm0S5mEa2QmHoLIPhmPbYzAxr_iC9r7HeQxrD2624,4406
|
|
4
|
+
diffrax_bdf/_controller.py,sha256=yMm4WLsYpOo7Uc9FzVFTFnKTXRg6HuFxSOjU_TdXTaE,3072
|
|
5
|
+
diffrax_bdf/_newton.py,sha256=0NFOwtZB2TePNNomm7aMBJTEpiwJkQbLwZ4rKyA7Jcc,3663
|
|
6
|
+
diffrax_bdf/_term.py,sha256=IBqSvXnpbgt_albZizeB_pOqzsDkSRro7n0IdwVoV5A,2581
|
|
7
|
+
diffrax_bdf-0.1.0.dist-info/WHEEL,sha256=3OmCxFA0ffBIsX4x0o8CdfSvZAOvO3mzEG8U_z5TvV4,80
|
|
8
|
+
diffrax_bdf-0.1.0.dist-info/METADATA,sha256=iif1JYi7Hx18wypw6t4EGr_AX8DXb_vnl6k8sor284M,9985
|
|
9
|
+
diffrax_bdf-0.1.0.dist-info/RECORD,,
|