diffrax-bdf 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffrax_bdf-0.1.0/PKG-INFO +201 -0
- diffrax_bdf-0.1.0/README.md +191 -0
- diffrax_bdf-0.1.0/pyproject.toml +24 -0
- diffrax_bdf-0.1.0/pyproject.toml.orig +26 -0
- diffrax_bdf-0.1.0/src/diffrax_bdf/__init__.py +7 -0
- diffrax_bdf-0.1.0/src/diffrax_bdf/_bdf.py +656 -0
- diffrax_bdf-0.1.0/src/diffrax_bdf/_coeffs.py +101 -0
- diffrax_bdf-0.1.0/src/diffrax_bdf/_controller.py +73 -0
- diffrax_bdf-0.1.0/src/diffrax_bdf/_newton.py +96 -0
- diffrax_bdf-0.1.0/src/diffrax_bdf/_term.py +72 -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,191 @@
|
|
|
1
|
+
# diffrax-bdf
|
|
2
|
+
|
|
3
|
+
A [backward differentiation formula](https://en.wikipedia.org/wiki/Backward_differentiation_formula)
|
|
4
|
+
solver for [diffrax](https://github.com/patrick-kidger/diffrax), aimed at stiff
|
|
5
|
+
steady-state problems — in particular the kinetic reaction networks in
|
|
6
|
+
[enzax](https://github.com/dtu-qmcm/enzax).
|
|
7
|
+
|
|
8
|
+
diffrax has no BDF ([#8](https://github.com/patrick-kidger/diffrax/issues/8)). This
|
|
9
|
+
package supplies one as an ordinary `AbstractSolver` subclass, with no patching of
|
|
10
|
+
diffrax itself.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
import diffrax
|
|
14
|
+
import jax.numpy as jnp
|
|
15
|
+
from diffrax_bdf import BDF, BDFController
|
|
16
|
+
|
|
17
|
+
sol = diffrax.diffeqsolve(
|
|
18
|
+
diffrax.ODETerm(vector_field),
|
|
19
|
+
BDF(), # variable order 1-5, as scipy and CVODE do
|
|
20
|
+
t0=0.0,
|
|
21
|
+
t1=jnp.inf,
|
|
22
|
+
dt0=1e-6,
|
|
23
|
+
y0=guess,
|
|
24
|
+
args=parameters,
|
|
25
|
+
stepsize_controller=BDFController(rtol=1e-8, atol=1e-10, dtmax=1e6),
|
|
26
|
+
event=diffrax.Event(diffrax.steady_state_event()),
|
|
27
|
+
adjoint=diffrax.ImplicitAdjoint(),
|
|
28
|
+
saveat=diffrax.SaveAt(t1=True),
|
|
29
|
+
)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Pin the order with `BDF(bdf_order=3)` to use any adaptive step size controller,
|
|
33
|
+
including diffrax's own `PIDController`.
|
|
34
|
+
|
|
35
|
+
## Status
|
|
36
|
+
|
|
37
|
+
Variable order 1-5, adaptive step size, reuse of the Jacobian and its
|
|
38
|
+
factorisation across steps, and semi-explicit index-1 DAEs.
|
|
39
|
+
|
|
40
|
+
## Method
|
|
41
|
+
|
|
42
|
+
The formulation follows `scipy.integrate.BDF` and MATLAB's `ode15s`: the history is
|
|
43
|
+
a backward-difference array rescaled on each change of step size, with the Shampine
|
|
44
|
+
& Reichelt NDF correction available via `use_ndf` (`False` gives the classical BDF
|
|
45
|
+
coefficients, the family SUNDIALS CVODE implements). The order is chosen each step
|
|
46
|
+
by scipy's heuristic, comparing the error estimate at the current order against what
|
|
47
|
+
it would have been one order lower and one order higher.
|
|
48
|
+
|
|
49
|
+
The linear-algebra reuse policy is CVODE's, because that is where CVODE's advantage
|
|
50
|
+
over scipy actually lives. `Kvaerno5` already computes one Jacobian per step and
|
|
51
|
+
reuses it across its five stages, so "one Newton solve per step instead of five
|
|
52
|
+
stage solves" buys only a few linear solves. The real saving is keeping `J` and the
|
|
53
|
+
factorisation of `I - c * J` across *steps*, gated on two separate counters:
|
|
54
|
+
|
|
55
|
+
- the factorisation is rebuilt when `c` has moved by more than `max_gamma_change`
|
|
56
|
+
(0.3), or after `lu_reuse_steps` (20) accepted steps;
|
|
57
|
+
- the Jacobian itself is rebuilt only after `jac_reuse_steps` (51) accepted steps,
|
|
58
|
+
so a refactorisation reassembles `I - c * J` from the stored `J` without touching
|
|
59
|
+
the vector field.
|
|
60
|
+
|
|
61
|
+
`BDFController` adds CVODE's step-size deadband on top of `diffrax.PIDController`:
|
|
62
|
+
an accepted step keeps its step size unless the controller wants to grow it by at
|
|
63
|
+
least 1.5x, and never shrinks it. Holding `h` still holds `c` still, which is what
|
|
64
|
+
lets the factorisation survive.
|
|
65
|
+
|
|
66
|
+
It also carries the order. diffrax asks a solver for its error order once, before
|
|
67
|
+
the solve (`solver.error_order(terms)` sees neither state nor time), so a
|
|
68
|
+
variable-order solver cannot report a per-step order through that route. The only
|
|
69
|
+
other channel to the controller is `y_error`, which the integration loop treats
|
|
70
|
+
opaquely, so the order travels inside it.
|
|
71
|
+
|
|
72
|
+
Everything is written for static shapes -- the order is a *traced* integer used to
|
|
73
|
+
mask fixed-size buffers -- because diffrax carries the solver state through a
|
|
74
|
+
`lax.while_loop`.
|
|
75
|
+
|
|
76
|
+
## Order selection on a steady-state solve
|
|
77
|
+
|
|
78
|
+
Integrating the `n=3` problem below to steady state, the order climbs through the
|
|
79
|
+
transient and then falls back as the residual collapses and the step grows:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
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
|
|
83
|
+
step: 1.2e-07 ------------------------------------------------> 7.9e+05
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The tail is the useful part: BDF1 with an enormous step *is* Newton's method on
|
|
87
|
+
`f(y) = 0`, so a correct variable-order BDF turns into a steady-state solver by
|
|
88
|
+
itself as it converges.
|
|
89
|
+
|
|
90
|
+
## Differential algebraic equations
|
|
91
|
+
|
|
92
|
+
`BDF` also solves semi-explicit index-1 DAEs, where some components of `y` obey differential equations and the rest obey algebraic constraints:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
term = SemiExplicitDAETerm(vector_field, algebraic_mask=(False, True))
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
- 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.
|
|
101
|
+
- `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.
|
|
102
|
+
- The steady-state event and `ImplicitAdjoint` work unchanged: the steady state is the root of `(f, g)`.
|
|
103
|
+
|
|
104
|
+
## Caveats
|
|
105
|
+
|
|
106
|
+
- **Enable `jax_enable_x64`.** High-order backward differences suffer heavy
|
|
107
|
+
cancellation; above order 2 they are noise in float32.
|
|
108
|
+
- **Set `dtmax` when integrating to `t1=inf`.** As the residual collapses the
|
|
109
|
+
controller's growth factor saturates and the step grows without bound until it
|
|
110
|
+
overflows the time variable.
|
|
111
|
+
- Variable order requires `BDFController`; a fixed `bdf_order` works with any
|
|
112
|
+
adaptive controller.
|
|
113
|
+
- The Jacobian is formed densely, so this suits small to medium systems.
|
|
114
|
+
- Requires a term whose control is a scalar, i.e. `ODETerm` or `SemiExplicitDAETerm`.
|
|
115
|
+
|
|
116
|
+
## Performance
|
|
117
|
+
|
|
118
|
+
Steady-state solves to `rtol=1e-8`, CPU, float64, medians of interleaved rounds.
|
|
119
|
+
Compare within a block, not across them -- absolute timings drift between runs.
|
|
120
|
+
|
|
121
|
+
**Whether BDF beats `Kvaerno5` depends entirely on the problem.** Where `Kvaerno5`'s
|
|
122
|
+
step control struggles, BDF wins by a wide margin; where it does not, `Kvaerno5`
|
|
123
|
+
wins on step count.
|
|
124
|
+
|
|
125
|
+
| problem | solver | steps | rejected | median |
|
|
126
|
+
|---|---|---|---|---|
|
|
127
|
+
| fast equilibrium, n=3 | `Kvaerno5` | 7784 | 4563 | 192.4 ms |
|
|
128
|
+
| fast equilibrium, n=3 | `BDF(bdf_order=5)` | 276 | 14 | 3.5 ms |
|
|
129
|
+
| fast equilibrium, n=3 | `BDF()` variable | 231 | 6 | **3.3 ms** |
|
|
130
|
+
| stiff random network, n=18 | `Kvaerno5` | 212 | 14 | **23.6 ms** |
|
|
131
|
+
| stiff random network, n=18 | `BDF(bdf_order=5)` | 593 | 11 | 28.8 ms |
|
|
132
|
+
| stiff random network, n=18 | `BDF()` variable | 589 | 12 | 31.7 ms |
|
|
133
|
+
| stiff random network, n=60 | `Kvaerno5` | 193 | 7 | **73.6 ms** |
|
|
134
|
+
| stiff random network, n=60 | `BDF(bdf_order=5)` | 586 | 6 | **145.2 ms** |
|
|
135
|
+
| stiff random network, n=60 | `BDF()` variable | 580 | 7 | 153.7 ms |
|
|
136
|
+
|
|
137
|
+
The first problem is the interesting one: `Kvaerno5` rejects 59% of its steps, where
|
|
138
|
+
BDF rejects 2-5%.
|
|
139
|
+
|
|
140
|
+
Variable order helps where the order genuinely needs to move -- 231 steps against
|
|
141
|
+
276 for the best fixed order on the steady-state problem. On the random networks,
|
|
142
|
+
where order 5 is right throughout, it matches fixed order 5 on step count and pays
|
|
143
|
+
about 8% per step for the heuristic. Reuse is worth a further 16-18%.
|
|
144
|
+
|
|
145
|
+
## On enzax's models
|
|
146
|
+
|
|
147
|
+
The motivating case. Measured against
|
|
148
|
+
[enzax](https://github.com/dtu-qmcm/enzax)'s own steady-state configuration
|
|
149
|
+
(`t1=inf`, `steady_state_event`, `ImplicitAdjoint`, `rtol=atol=1e-9`), each solver
|
|
150
|
+
with the step size controller that suits it. Medians of interleaved rounds.
|
|
151
|
+
|
|
152
|
+
| model | n | regime | `Kvaerno5` (enzax today) | `BDF()` | speedup |
|
|
153
|
+
|---|---|---|---|---|---|
|
|
154
|
+
| methionine | 5 | bad guess | 555 ms (649 steps, 145 rejected) | 158 ms (423, 52) | **3.5x** |
|
|
155
|
+
| methionine | 5 | warm guess, 2% parameter jitter | 30.7 ms (38 steps) | 23.3 ms (88) | 1.3x |
|
|
156
|
+
| methionine | 5 | `jacrev` through the solve | 27.9 ms | 22.4 ms | 1.25x |
|
|
157
|
+
| conserved moiety | 5 | bad guess | 191 ms (225, 30) | 53.7 ms (293, 4) | **3.6x** |
|
|
158
|
+
| conserved moiety | 5 | warm guess, 2% jitter | 58.3 ms (68) | 31.4 ms (165) | 1.9x |
|
|
159
|
+
| conserved moiety | 5 | `jacrev` | 53.0 ms | 27.6 ms | 1.9x |
|
|
160
|
+
| glycolysis | 18 | bad guess | 2302 ms (1007, 247) | 271 ms (821, 70) | **8.5x** |
|
|
161
|
+
| glycolysis | 18 | guess perturbed 20% | 491 ms (212, 38) | 108 ms (277, 18) | **4.6x** |
|
|
162
|
+
| glycolysis | 18 | `jacrev` | 427 ms | 104 ms | **4.1x** |
|
|
163
|
+
|
|
164
|
+
"bad guess" is enzax's own `BAD_GUESS = full(shape, 0.01)`. Steady states agree with
|
|
165
|
+
`Kvaerno5` to 2e-7 relative or better and gradients to 1e-5 or better.
|
|
166
|
+
|
|
167
|
+
Note the step counts: BDF often takes *more* steps and is still several times
|
|
168
|
+
faster, because a step is one Newton solve against a reused factorisation rather
|
|
169
|
+
than five stage solves. It also rejects far fewer steps -- 70 against 247 on
|
|
170
|
+
glycolysis from a bad guess. Compile time is 30-45% lower throughout, which matters
|
|
171
|
+
when every leapfrog step of a NUTS chain pays it.
|
|
172
|
+
|
|
173
|
+
The gap widens with model size, which is the encouraging part: 1.3x on the 5-state
|
|
174
|
+
methionine model against 4-8x on the 18-state glycolysis one.
|
|
175
|
+
|
|
176
|
+
### Tuning
|
|
177
|
+
|
|
178
|
+
enzax's `PIDController(pcoeff=0.1, icoeff=0.3)` is tuned for `Kvaerno5`, which
|
|
179
|
+
rejects heavily; it damps step size growth to `0.4 / error_order` where the default
|
|
180
|
+
pure-I controller uses `1 / error_order`. BDF rejects very few steps and does not
|
|
181
|
+
need the damping, so it should be left on `BDFController`'s defaults. Using enzax's
|
|
182
|
+
tuning costs BDF roughly a factor of two -- 174 steps and 45.6 ms against 88 steps
|
|
183
|
+
and 26.9 ms on methionine.
|
|
184
|
+
|
|
185
|
+
## Testing
|
|
186
|
+
|
|
187
|
+
`uv run pytest`. scipy's BDF is pure Python and is used as a white-box oracle: the
|
|
188
|
+
coefficient tables, the difference-array rescaling, and a single pinned step are all
|
|
189
|
+
asserted against it directly. The reuse policy, the deadband and the order-selection
|
|
190
|
+
heuristic are tested directly as well, since without them the solver has no
|
|
191
|
+
advantage over `Kvaerno5` at all.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "diffrax-bdf"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Diffrax-compatible backward differentiation formula ODE solver"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = ["diffrax>=0.7.2"]
|
|
8
|
+
|
|
9
|
+
[[project.authors]]
|
|
10
|
+
email = "groves.teddy@gmail.com"
|
|
11
|
+
|
|
12
|
+
[project.license]
|
|
13
|
+
text = "MIT"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.12.7,<0.13.0"]
|
|
17
|
+
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = ["pytest>=9.1.1"]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
pythonpath = ["tests"]
|
|
24
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "diffrax-bdf"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Diffrax-compatible backward differentiation formula ODE solver"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ email = "groves.teddy@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"diffrax>=0.7.2",
|
|
12
|
+
]
|
|
13
|
+
license = {text = "MIT"}
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.12.7,<0.13.0"]
|
|
17
|
+
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=9.1.1",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
pythonpath = ["tests"]
|
|
26
|
+
testpaths = ["tests"]
|