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.
@@ -0,0 +1,7 @@
1
+ from ._bdf import BDF as BDF, ErrorWithOrder as ErrorWithOrder
2
+ from ._controller import BDFController as BDFController
3
+ from ._coeffs import MAX_ORDER as MAX_ORDER
4
+ from ._term import SemiExplicitDAETerm as SemiExplicitDAETerm
5
+
6
+
7
+ __version__ = "0.1.0"
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.8
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any