Multiple shooting (pybnf.shooting)¶
pybnf.transcription (ADR-0109) is the reusable half of issue #563 — an augmented
variable layout, an equality residual/Jacobian interface, and an optimizer-agnostic
augmented-Lagrangian outer loop with its homotopy and its certification. It makes no
simulator call and defines no job_type. This package is the other half: what it takes to
state this fit as that kind of problem.
Split each scored experiment’s time course at m - 1 knots. Segment j is integrated
from its own start state — segment 0’s is the model’s own initial conditions, which are
often fitted parameters, and each interior knot carries an auxiliary state z_j that is
searched, bounded and differentiated but is never a fit result. Continuity is imposed as
c_j = Phi_j(z_j, theta) - z_{j+1} = 0 and enforced by the augmented Lagrangian, whose
subproblem is solved by gntr’s own Gauss-Newton trust-region step machine. Every
reported score comes from discarding z, re-simulating theta with ordinary single
shooting, and scoring that, so a run that leaves continuity unconverged scores as what it
actually is.
The fit type that runs all of it is job_type = ms
(pybnf.algorithms.optimizers.multiple_shooting). The design, its measurements, and
what this cut deliberately leaves out are recorded in ADR-0110.
Knot placement¶
Knot placement: where a time course is cut, and which data point lands in which piece (#563).
Multiple shooting’s first decision is where to break the horizon. SegmentGrid owns
that decision and nothing else – no simulator, no objective, no optimizer – so the two
things that are easy to get subtly wrong are testable in isolation: which segment a data
point belongs to, and whether a coarser grid’s knots are recognisably the same knots as a
finer grid’s.
Three placements, and one of them is the default for a measured reason¶
The issue asks for “a segment count or explicit knots; default to generic equal-time or equal-observation segments”, and all three are here:
equal_time(the default)[start, horizon]cut intomequal spans. This is what the #563 prototype solved the motivating problem with, and it is the default because it places the knots using only the experiment’s own time axis – a fact rather than a guess. The obvious refinements that read a trajectory (knots at a burst, at a peak) are start-point dependent: they place the transcription’s structure using dynamics the fit has not established, and on the motivating problem those dynamics are exactly what is in question (an oscillator whose period is wrong everywhere except a 3 % window).equal_observationsCut so each segment owns the same number of measurements. It reads the data’s own time axis and nothing else, so it is not start-point dependent either – what it uses is the sampling, not the dynamics. It is the right placement when a time course is sampled unevenly, where equal spans leave some segments with nothing to fit and the auxiliary states of those segments are determined by continuity alone (ADR-0109 finding 5.2’s under-determination, arrived at through the sampling rather than through
m). It needs at least two measurements per segment, so its own segment ceiling is halfequal_time’s.- explicit knots
The caller supplies the times. Nothing here second-guesses them; they are validated for order and for lying strictly inside the horizon, and the run reports them.
Names, because the homotopy transfers by name¶
carry_over() moves an auxiliary block from
one stage to the next iff the next stage declares a block of the same name and size –
the layer never learns what a knot is, which is what keeps the rule generic. So the naming
here is load-bearing: a knot must get the same name at every segment count that has it, or
the 4 -> 2 -> 1 ladder reseeds instead of continuing and the coarsening (the mechanism,
ADR-0109 finding 5.2) buys nothing.
Naming a knot by its exact fraction of the segment count does that: knot i of m
is Fraction(i, m). At m = 4 the knots are 1/4, 1/2, 3/4; at m = 2, 1/2 –
and Fraction(2, 4) == Fraction(1, 2), so the surviving knot carries its solved state
down the ladder while 1/4 and 3/4 are discarded, which is what coarsening is.
Exact rational arithmetic rather than a rounded float, so 1/3 and 0.333333 can never
be two names for one knot (or one name for two).
The fraction is an ordinal, not a claim about where the knot sits. Under equal_time
the two coincide, which is why the naming reads so naturally there; under the other two
placements exp1@1/2 is “the knot halfway along this grid’s knot list”, which is exactly
what carry-over needs to match on and is independent of what time it landed at. Every
placement therefore maps the same fraction to the same knot, at every rung – which is
the whole property the ladder rests on.
Which segment owns a data point¶
Half-open [start_j, start_{j+1}), with the final segment owning the horizon endpoint –
the same convention tests/test_transcription.py::ShootingProblem pins offline. A point
lying exactly on a knot is therefore read at dt = 0 from that knot’s own auxiliary state,
so its prediction is the auxiliary variable itself: the one row where the data sees a
segment-start state directly rather than through an integration. The alternative (a knot
point belonging to the segment that ends there) reads it through the previous segment’s
whole span, which is the same number only when continuity has already converged.
- pybnf.shooting.grid.EQUAL_OBSERVATIONS = 'equal_observations'¶
Knots placed so every segment owns the same number of measurements.
- pybnf.shooting.grid.EQUAL_TIME = 'equal_time'¶
Knots at equal spans of the experiment’s own time axis. The default, and what the #563 prototype solved the motivating problem with.
- pybnf.shooting.grid.EXPLICIT = 'explicit'¶
Knots supplied by the caller (
ms_knots), rather than derived from a rule.
- pybnf.shooting.grid.KNOT = '@'¶
Separator between an experiment’s label and a knot’s fraction in a block name (
'exp1@1/2'). A block name may not contain the layout’s'::'qualifier, and this one does not.
- pybnf.shooting.grid.OBSERVATIONS_PER_SEGMENT = 2¶
Measurements a segment needs under
EQUAL_OBSERVATIONS. Two rather than one, because a knot is placed at a measurement (that point then belongs to the later segment, read atdt = 0), so a one-observation-per-segment grid would put the last knot on the horizon itself and leave a zero-length final segment.
- class pybnf.shooting.grid.SegmentGrid(times, n_segments, label='exp', start=None, horizon=None, placement='equal_time', knots=None)[source]¶
The knots of one experiment’s time course at one segment count.
- Parameters:
times – The experiment’s measurement times, in the independent variable’s units. Need not be sorted; duplicates are allowed (repeat measurements at one time).
n_segments –
m, the number of segments.m = 1is the ordinary unsegmented problem: no knots, no auxiliary variables, no continuity constraints.label – The experiment’s label, used to build block names. Two experiments in one fit get different labels so their knots never collide in one layout.
start – The horizon’s start. Defaults to
min(times), but a time course whose first measurement is after the model’st = 0still integrates from0, so a caller that knows the simulation’s own start passes it.horizon – The horizon’s end. Defaults to
max(times).placement – One of
PLACEMENTS. See the module docstring.knots – Explicit knot times, which force
placement = 'explicit'. These are the finest rung’s knots: a coarser rung of the same ladder keeps the sublist its own fractions select, so a4 -> 2 -> 1ladder over three explicit knots keeps the middle one atm = 2– by the same fraction identity every other placement uses.
- property block_names¶
One auxiliary-block name per interior knot, in segment order.
'<label>@<fraction>'–('exp1@1/4', 'exp1@1/2', 'exp1@3/4')atm = 4. Them = 2grid of the same experiment declares('exp1@1/2',), which is a subset by name, so the ladder carries that knot’s solved state over.
- ends¶
Each segment’s end time.
- fractions¶
Each interior knot’s exact fraction of the segment count – the identity
carry_over()matches on. An ordinal, not a claim about where the knot sits (see the module docstring); underEQUAL_TIMEthe two coincide.
- knot_times¶
Interior knot times (
m - 1of them; empty atm = 1).
- row_positions(segment)[source]¶
Where each of segment
segment’s data points lands in its output grid.Returned alongside the row indices as
(output rows, data row indices)so a caller reads the simulated trajectory at exactly the measured times without re-searching the grid per point.
- sample_times(segment)[source]¶
(output times, data row indices)for one segment.The output grid is this segment’s start knot, its own data times, and its end knot, de-duplicated and sorted. The start knot is present because integration begins there; the end knot because the continuity defect is read off the same run that produced the data rows, rather than from a second simulation of the same span.
- seed_times()[source]¶
One output grid covering the whole horizon: every knot and every data time.
What a stage’s auxiliary variables are seeded from – one ordinary single-shoot simulation whose state is read off at each knot. Seeding this way makes the transcription feasible at iteration zero, so every discontinuity the run subsequently holds is the optimizer’s own choice rather than an artifact of the start (the prototype’s
seed_aux).
- segment_of¶
Which segment each entry of
timesbelongs to.
- starts¶
Each segment’s start time: the horizon’s start, then every interior knot.
- pybnf.shooting.grid.max_segments(times, placement='equal_time', knots=None)[source]¶
The finest segment count
timescan support underplacement.Above it a rung is not the method: knots fall between observations everywhere and the auxiliary states of segments with no data are determined by continuity alone.
feasible_ladder()drops rungs above this and reports it.
The segment-simulation seam¶
The segment-simulation seam: the one place multiple shooting touches a simulator (#563).
Everything else in pybnf.shooting is arithmetic over what this seam returns – knot
placement, the IC-routed objective assembly, the continuity block, the inner solver, the
homotopy. Narrowing the simulator coupling to a single method is what lets all of that be
verified offline against a closed-form backend, exactly as
pybnf.transcription is verified against a closed-form transcription: an offline
implementation of SegmentBackend exercises the same code path bngsim does.
What a segment simulation is¶
One span [t_j, t_{j+1}] of one experiment, integrated from a state the transcription
supplies rather than from the model’s own initial conditions, with output at the
segment’s own data times and at its end knot. Two properties make it different from an
ordinary PyBNF simulation, and both are why this cannot ride the propose/score loop:
the initial state is an argument, not a property of the parameter set – segment
j’s start is the auxiliary variablez_j, which is never a fit result and never enters aPSet; andthe run must come back carrying its forward sensitivities on both axes. The parameter axis gives
dPhi/dtheta; the initial-condition axis givesdPhi/dz_j, which is the block the continuity Jacobian is built from and the reason #563 neededsensitivity_icto exist at all.
The prototype’s structural finding is what keeps the objective half cheap: a segment-start
state enters the data fit as an IC route with chain-rule factor 1, so the same
tensor this seam already returns feeds
assemble_gradient_and_fisher_hessian() with no new residual
math. This module therefore returns the tensor as-is, in the native units the assembly
expects, and does no differentiation of its own.
Segment 0 is not special-cased¶
The first segment starts from the model’s own initial conditions – which are frequently
fitted (init_Z_state and friends), so they are reported free parameters rather than
auxiliary ones. A caller passes initial_state = None for it, and the backend simulates
the model as configured. Everything downstream reads that back as “this segment has no
auxiliary block”, which is the same shape as the m = 1 stage having none at all.
- class pybnf.shooting.backend.SegmentBackend[source]¶
What multiple shooting needs from a simulator, and nothing else.
One instance per scored experiment (one
(model, condition)pair), built once per fit and reused across every evaluation – the #563 prototype measured that constructing a sensitivity-bearing simulator costs ~17 ms warm against ~50 ms for the integration itself, so atmsegments per evaluation construction would dominate.- n_simulations = 0¶
Segment integrations this backend has performed. The cost the #563 acceptance benchmark reports and the prototype’s paired sweeps measured multiple shooting’s 2-7x overhead in – and the number a run reports as its completed simulations, which is deliberately not the count of augmented-model evaluations: one evaluation is
mintegrations, so reporting evaluations would understate the cost by the very factor the method is being judged on. An implementation increments it persimulate().
- abstract property nominal_state¶
A representative magnitude for each state, in
state_namesorder.Used for two things a fit cannot do without: the strictly positive
EqualityModelscales – a continuity defect is a difference of states, so a model whose species span six orders of magnitude would otherwise hand the penalty term a condition number for free – and the floor under an auxiliary variable’s box.
- open_lanes(pset, n_lanes)[source]¶
Prepare up to
n_lanesindependent simulation contexts atpset; return how many are actually available (at least 1).A lane is whatever state
simulate()restarts to run one span – for the bngsim backends, a warm engine model andSimulator. Two segments cannot share one, because a segment is run by resetting that state to its own start knot, so a second segment on the same object would integrate from the first’s start. Lanes are what makesSegmentPoolable to runLsegments of one experiment at once.Called on the calling thread, before any worker starts, because preparing a lane is where a backend touches the model it owns. The default implementation offers one lane, which is the serial behaviour and needs no preparation of its own.
- abstractmethod simulate(pset, sample_times, initial_state=None, lane=0)[source]¶
Integrate one span and return its
Data.- Parameters:
pset – The reported parameter set, exactly as an ordinary evaluation would apply it (conditions, bind-by-id, initial assignments and all).
sample_times – Strictly increasing output times.
sample_times[0]is where integration starts andsample_times[-1]is the segment’s end knot.initial_state –
{state name: value}overriding the model’s own initial conditions, orNonefor the first segment, which starts from the model as configured.lane – Which of the contexts
open_lanes()prepared to run in.0is the only one a serial pass ever uses, and the only one a backend that offers no lanes has to honour. A caller must never run two segments in one lane at once.
Raises
SegmentSimulationFailedfor a point that does not integrate.
- abstract property state_names¶
The model state carried across a knot, in a fixed order.
For an ODE model this is its species. It is the vector an auxiliary block holds, the vector a continuity row is written on, and the axis of
d_end_ic.
- exception pybnf.shooting.backend.SegmentSimulationFailed[source]¶
One segment did not integrate.
Raised by a backend rather than returned, because a non-integrable segment is a property of the point, not of the run: the caller converts it into a non-finite local model, the inner solver’s trust region shrinks, and the search backs off – the same way a failed simulation is handled everywhere else on the gradient path (#492). It is not a fit-ending error, which is exactly the robustness the #563 prototype measured (a segment that fails does not kill the whole trajectory: median
-166.95against single shooting’s-105.50from an uninformed box draw).
- class pybnf.shooting.backend.SegmentTrace(data, end_state, d_end_param=None, d_end_ic=None, param_axis=(), ic_axis=())[source]¶
One simulated segment: its scored rows, and its end-knot state with derivatives.
- Parameters:
data – The segment’s
Dataover its own output grid, carrying theOutputSensitivitiespayload. This is the object the objective assembly consumes, unchanged.end_state – The model state at the segment’s end knot, in the order of
state_names. The left half of the continuity defectc_j = Phi_j(z_j, theta) - z_{j+1}.d_end_param –
d(end state)/d(native parameter), shape(n_state, len(param_axis)), orNonewhen no parameter axis was requested.d_end_ic –
d(end state)/d(initial state), shape(n_state, len(ic_axis)), orNone. For an interior segment this is thedPhi_j/dz_jblock; for segment 0 it is the derivative with respect to the model’s own initials, which is how a fitted initial condition reaches the continuity rows.param_axis – Native parameter ids labelling
d_end_param’s columns.ic_axis – Species labelling
d_end_ic’s columns.
- pybnf.shooting.backend.trace_from_data(data, state_names, selector_prefix='species')[source]¶
Read a
SegmentTraceoff a simulatedData.The end-knot state is the last row of the state columns, and its derivatives are the last row of the sensitivity tensor – which is the point of putting the end knot in the segment’s own output grid rather than simulating the span twice.
A
Datawith nooutput_sensitivitiesyields a value-only trace, which is what the certification path (an ordinary single-shoot score, no derivatives) and a scalar evaluation need.
The bngsim SBML/Antimony segment backend (#563).
The one place pybnf.shooting touches a simulator. It implements
SegmentBackend against the same private seams an ordinary
SBML evaluation uses – _engine_model_for_action to build the point’s engine model,
_run_simulation to integrate one span at explicit output times, _result_to_data to
convert the result – so a segment is simulated by exactly the machinery a whole experiment
is, with two differences: it starts at a knot instead of at t = 0, and its initial state
is supplied rather than read off the model.
Why the SBML/Antimony path, and what the .net path would need¶
Multiple shooting is written on the model’s state: a knot carries the ODE state vector,
a continuity row is a difference of states, and the continuity Jacobian is
d(state)/d(state). The bngsim SBML/Antimony path hands all of that over on one run –
its trajectory’s columns are the species, and its forward-sensitivity selectors are
species:<name> on both axes.
A .net model is a reaction network with exactly the same kind of state, and bngsim
returns both its species trajectory and its d(species)/d(species_0) when asked. What is
missing is on PyBNF’s side: the net backend’s Data carries time + observables +
expressions (_build_data()) and its
sensitivity request names observable: / expression: selectors, so neither the state
at a knot nor its derivative is in what a segment simulation would return. Closing that is
an adapter change rather than a modelling obstacle – and it is not free: an experiment
scores observables, so a net segment needs both selector families on one run, and on a
combinatorially expanded network the auxiliary block is (m - 1) x n_species wide and the
initial-condition sensitivity system is n_species wide, so the transcription’s cost
scales with the expanded species count rather than with the number of fitted parameters.
This cut does not close it. job_type = ms refuses the net backend up front, naming the
gap, rather than failing later at a missing selector.
Simulator reuse¶
The #563 prototype measured that constructing a sensitivity-bearing bngsim.Simulator
costs ~230 ms cold and ~17 ms warm against ~50 ms for the integration itself, so at m
segments per evaluation construction would dominate. It also verified the fix: mutating the
model behind an existing Simulator and save_concentrations() + reset()-ing gives
bit-identical states and sensitivities to a freshly built one. This backend therefore
keeps one engine model and one Simulator per parameter point and restarts them at each
knot. The restart is what distinguishes this from the pre-equilibration protocol (ADR-0052),
which runs a second phase on the same simulator without a reset precisely so the state
carries over.
It is also why running two segments at once needs two of them: the restart is a mutation
of the simulator’s own state, so a second segment sharing it would integrate from the
first’s start knot. open_lanes builds one engine+simulator pair per lane at a point, on
the calling thread, and each lane is then claimed by one segment for the whole of its
integration (pybnf.shooting.parallel, which also carries the cost model that decides
whether extra lanes are worth their preparation).
- class pybnf.shooting.bngsim_backend.BngsimSegmentBackend(model, action, mutant, suffix, timeout=None, method='ode')[source]¶
One scored
(model, condition)pair’s segment simulator.- Parameters:
model – The PyBNF model object, already built and with its sensitivity request applied (
enable_output_sensitivities). This backend owns it: it assigns the parameter set in place rather than deep-copying per evaluation, which is sound because the multiple-shooting driver runs on the master (a segment is not aPSetevaluation and never reaches a worker) and because the two places that write to the model – the parameter set and the action suffix – are done once per point under this backend’s lock, before any segment runs. The integration path itself only reads the model, which is what lets several segments of one point run at once on separate lanes.action – The
TimeCourseaction this experiment is measured by.mutant – The
MutationSet(condition) it is measured under.suffix – The full output suffix,
action.suffix + mutant.suffix.timeout – Per-segment wall-clock bound, from
wall_time_sim. A pathological parameter point can otherwise spend minutes inside CVODE’s analytical-Jacobian failure and finite-difference retry, and a multi-start sweep is then dominated by points that were never going to score.
- property nominal_state¶
A representative magnitude for each state, in
state_namesorder.Used for two things a fit cannot do without: the strictly positive
EqualityModelscales – a continuity defect is a difference of states, so a model whose species span six orders of magnitude would otherwise hand the penalty term a condition number for free – and the floor under an auxiliary variable’s box.
- open_lanes(pset, n_lanes)[source]¶
Build up to
n_lanesengine+simulator pairs atpset; return how many.Called on the master before a parallel pass, so every lane a worker can claim is already built and no thread ever enters the construction path. A lane that cannot be built is not an error here – the pass simply runs at the width that succeeded, and the failure resurfaces as a failed segment if it was going to.
- simulate(pset, sample_times, initial_state=None, lane=0)[source]¶
Integrate one span and return its
Data.- Parameters:
pset – The reported parameter set, exactly as an ordinary evaluation would apply it (conditions, bind-by-id, initial assignments and all).
sample_times – Strictly increasing output times.
sample_times[0]is where integration starts andsample_times[-1]is the segment’s end knot.initial_state –
{state name: value}overriding the model’s own initial conditions, orNonefor the first segment, which starts from the model as configured.lane – Which of the contexts
open_lanes()prepared to run in.0is the only one a serial pass ever uses, and the only one a backend that offers no lanes has to honour. A caller must never run two segments in one lane at once.
Raises
SegmentSimulationFailedfor a point that does not integrate.
- property state_names¶
The model state carried across a knot, in a fixed order.
For an ODE model this is its species. It is the vector an auxiliary block holds, the vector a continuity row is written on, and the axis of
d_end_ic.
The bngsim .net segment backend – multiple shooting on a reaction network (#577).
The SBML/Antimony backend (pybnf.shooting.bngsim_backend) got the state for free: that
path reports its species as the trajectory’s columns and labels its forward-sensitivity
selectors species:<name> on both axes, so one run hands over everything a knot needs. The
.net path does not, and for a while job_type = ms refused it on that basis – wrongly
framed as a property of the model. It is not one. A .net file is a fully expanded reaction
network with exactly the same kind of ODE state, and bngsim returns both its species
trajectory and its d(species)/d(species_0) when asked. What was missing was that nobody
asked: _build_data() assembles
time + observables + expressions, and the net backend’s sensitivity request names
observable: / expression: selectors.
This module asks. It is the one place in pybnf.shooting that needs both selector
families on one run, and that is the whole of what makes it different from its SBML peer:
an experiment scores observables, a continuity row is a difference of species. On the SBML path those are the same columns; here they are not.
OutputSensitivities.selectorsis a plain list, so one tensor carries the observable/expression rows the data terms read and the species rows the continuity block reads – requested together, off one integration, because asking twice would mean integrating twice.the segment’s ``Data`` carries both too, the ordinary observable/expression columns the objective scores plus the species columns
trace_from_data()reads the end-knot state from. Species names carry parentheses (A(b!1).B(a!1)) and observables do not, so a collision is vanishingly unlikely – and is refused rather than silently overwritten, because a species column quietly shadowing an observable would change what the fit scores.
Nothing in the net backend is modified: this composes its existing pieces (_build_data,
the engine clone, the mutant copy, the species-initializer sync) from outside, so every
ordinary .net fit is untouched.
The cost, which is real and is the model’s, not the method’s¶
The auxiliary block is (m - 1) x n_species wide and the initial-condition sensitivity
system is n_species wide, so the transcription scales with the expanded species count
rather than with the number of fitted parameters. On a small network that is nothing; on a
combinatorially expanded one it is the dominant term – egfr_ground.net (356 species) at
m = 4 adds ~1068 auxiliary variables. That is a property of writing multiple shooting on
the state of a rule-based model, not something this backend can arrange away, so
job_type = ms reports the added width when it starts rather than letting a user discover
it from the run time.
- class pybnf.shooting.net_backend.NetSegmentBackend(model, sim_params, mutant, suffix, timeout=None)[source]¶
One scored
(model, condition)pair’s segment simulator, on the.netpath.- Parameters:
model – The
BngsimModel. This backend owns it, assigning the parameter set in place rather than deep-copying per evaluation – sound because the multiple-shooting driver runs on the master (a segment is not aPSetevaluation and never reaches a worker), and because the writes to it happen once per point under this backend’s lock, before any segment of that point runs. A lane is one such per-point clone plus its engine andSimulator, and two segments cannot share one (seepybnf.shooting.parallel).sim_params – The parsed
simulate()action this experiment is measured by, as_parse_simulate_action()returns it.mutant – The
MutationSet(condition) it is measured under.suffix – The full output suffix,
action suffix + mutant suffix.timeout – Per-segment wall-clock bound, from
wall_time_sim.
- property nominal_state¶
A representative magnitude for each state, in
state_namesorder.Used for two things a fit cannot do without: the strictly positive
EqualityModelscales – a continuity defect is a difference of states, so a model whose species span six orders of magnitude would otherwise hand the penalty term a condition number for free – and the floor under an auxiliary variable’s box.
- open_lanes(pset, n_lanes)[source]¶
Build up to
n_lanesmodel-copy + engine + simulator triples atpset.The
.netpeer ofopen_lanes(), and the more expensive of the two: a lane here is a whole cloned PyBNF model, so the cost model inpybnf.shooting.paralleltilts further toward serial on a small network and further toward parallel on a large one, where then_species-wide initial-condition sensitivity system dominates a segment.
- simulate(pset, sample_times, initial_state=None, lane=0)[source]¶
Integrate one span and return its
Data.- Parameters:
pset – The reported parameter set, exactly as an ordinary evaluation would apply it (conditions, bind-by-id, initial assignments and all).
sample_times – Strictly increasing output times.
sample_times[0]is where integration starts andsample_times[-1]is the segment’s end knot.initial_state –
{state name: value}overriding the model’s own initial conditions, orNonefor the first segment, which starts from the model as configured.lane – Which of the contexts
open_lanes()prepared to run in.0is the only one a serial pass ever uses, and the only one a backend that offers no lanes has to honour. A caller must never run two segments in one lane at once.
Raises
SegmentSimulationFailedfor a point that does not integrate.
- property state_names¶
The model state carried across a knot, in a fixed order.
For an ODE model this is its species. It is the vector an auxiliary block holds, the vector a continuity row is written on, and the axis of
d_end_ic.
The segment pass¶
Running one point’s segments: serially, or across a pool of lanes (#563).
Issue #563’s implementation proposal ends “Segment simulations can run in parallel”, and
they are the one embarrassingly parallel thing in the method: one augmented-model
evaluation is m spans of one trajectory integrated from m states the transcription
already knows, with no data flowing between them. This module is that scheduler.
Threads, because the integration releases the GIL¶
The choice between threads and processes is not a matter of taste here, and it was
measured rather than assumed. On the motivating model (Borghans_BiophysChem1997: 3
species, 21 sensitivity parameters, both axes requested), four warm engine+simulator
replicas driven from four threads integrated 160 segments in 0.057 s against 0.153 s
serially – a 2.7x speedup on 4 workers – and every trajectory column and every entry
of d(y)/d(theta) came back bit-identical to the serial run. So bngsim drops the GIL
inside CVODE, and the arithmetic above this seam does not have to change to accommodate a
scheduler.
Processes were never a real option: a segment costs one integration, and the #563
prototype measured that constructing a sensitivity-bearing simulator costs ~230 ms cold
against ~50 ms for an integration. A process pool would pay that per worker per point (or
pay to pickle an engine model, which does not pickle), which is why
SegmentBackend keeps warm state per parameter point in
the first place.
A lane is a warm engine + simulator, and it is not free¶
Two segments cannot share one Simulator: the backend restarts it from the knot’s state
with save_concentrations() + reset(), so a second segment on the same object would
be integrating from the first’s start state. A lane is therefore an independent
engine+simulator pair, and running L segments at once needs L of them at that
parameter point – so the point pays L preparations instead of one.
That is the whole cost model, and it decides the default:
parallel wins when (m - 1) * t_integrate > (L - 1) * t_prepare
On Borghans, measured, t_prepare is ~4.1 ms and t_integrate is ~1-2.3 ms, so at
m = L = 4 parallel segments would lose – the model is small enough that preparing
the extra lanes costs more than the integrations they save. On a model where a segment is
the expensive thing, it wins nearly linearly, and both cost terms move with the model
rather than with the fit: the initial-condition sensitivity system is n_species wide,
so a segment’s integration grows with the state while a lane’s preparation does not grow
as fast.
So ms_parallel_segments defaults to 1 (serial), which is the measured right answer
for the model this feature was built for, and a run that sets it reports what it measured
(SegmentPool.describe()) rather than leaving a user to guess whether it helped.
What parallel gives up¶
The serial pass stops at the first segment that fails to integrate – the rest of that
point’s segments are work whose answer is already decided, which is what keeps a search
that has wandered into a non-integrable corner from paying m simulations per rejected
trial. A submitted future cannot be un-run, so the parallel pass pays all m. The
answer is identical; the simulation count a run reports is not, and on a multi-start
sweep over an uninformed box (where most points do not integrate) that difference is the
dominant cost. Stated here rather than discovered from a benchmark table.
- pybnf.shooting.parallel.SERIAL = SegmentPool(lanes=1)¶
The pool a problem built without one uses – serial, so nothing changes for a caller that does not ask for lanes.
- class pybnf.shooting.parallel.SegmentPool(n_lanes=1)[source]¶
The segment pass, at one degree of parallelism.
- Parameters:
n_lanes – How many segments to integrate at once.
1(the default) runs them on the calling thread, in order, short-circuiting at the first failure – byte-for- byte the behaviour that shipped with ADR-0110.
One pool is built per fit and shared by every rung of the ladder and every start, so a thread pool is created at most once per run.
- run(pset, tasks)[source]¶
Integrate every task at
pset.Returns
(traces, ok): the per-taskSegmentTracelist and whether every one integrated to a finite trajectory. A pass withok = FalsereturnsNonefor the traces, because the caller turns any failure into a non-finite local model and never reads the partial result – and returning a half-filled list would invite someone to.
- class pybnf.shooting.parallel.SegmentTask(backend, times, initial_state, state_names)[source]¶
One span to integrate: everything
SegmentPool.run()needs and nothing else.
The transcription¶
The multiple-shooting transcription: #563’s first consumer of ADR-0109’s layer.
MultipleShootingProblem implements the two abstract methods
TranscriptionProblem declares, plus certify,
and gets the augmented-Lagrangian outer loop, the penalty schedule, the homotopy, the
best-iterate certification, and the reporting for free. Everything specific to multiple
shooting is here:
which data point belongs to which segment (
pybnf.shooting.grid);how a segment is simulated from a state the transcription supplies (
pybnf.shooting.backend);how the fit’s own objective is assembled over the pieces; and
the continuity constraints
c_j = Phi_j(z_j, theta) - z_{j+1}and their Jacobian.
The objective half needs no new residual math¶
This is the structural finding the #563 prototype established, and it is what keeps this
module small. A segment-start state enters the data fit as an IC route with chain-rule
factor 1 – sensitivity_ic is dy(t)/dy0, and the transcription sets y0
directly – so an auxiliary variable is, to
assemble_gradient_and_fisher_hessian(), an ordinary free
parameter with an ordinary route. Each segment is presented to the assembly as its own
experiment: its own simulated Data, its own slice of the observations, and its own
ExperimentRouting. The assembly already sums across
experiments, so the segmented data fit is the unsegmented one rearranged, and its gradient
and Fisher block come out over the augmented column list in one pass.
Two things that rearrangement has to get right, and both are structural rather than cosmetic:
Segment ``j > 0`` does not read the model’s own initial conditions. They were
overridden by z_j. A reported free parameter that is a fitted initial condition
therefore has no effect on that segment, and its IC contribution is dropped from that
segment’s routing. Keeping it would credit init_Z_state with a derivative it does not
have on m - 1 of the m segments – a wrong column the fit cannot detect from its own
objective. Segment 0 keeps it, which is exactly how a fitted initial condition reaches the
first continuity row.
A quantity profiled or normalised across a whole series cannot be cut. An analytic
per-series scale (ADR-0066) and a Data-level normalization (ADR-0053/0102) are functions
of the series they are computed over, so splitting the series changes them; a
cumulative-to-incident transform (ADR-0051) is a difference between neighbouring rows, and
the row before a knot is in another piece. Those are refused by the ms fit type’s gates
rather than silently rearranged. An analytically profiled noise scale (ADR-0108) is the
opposite case and needs no gate: it is profiled over the pooled residuals of every supplied
experiment, so cutting one series into m pieces pools exactly the same residuals and
gives exactly the same sigma_hat.
The constraint terms never enter f¶
objective_value is the fit’s own
objective, scored by the fit’s own objective function on the segmented trajectory. The
penalty lives strictly outside it (ADR-0109), which is what keeps an estimated noise scale
from absorbing continuity violation as measurement noise – and 13 of the 23 slugs in the
motivating benchmark corpus estimate at least one.
- pybnf.shooting.problem.AUX_DECADES = 6.0¶
Half-width, in decades, of an auxiliary state’s box around its own magnitude. Wide enough that the box is not a constraint on the search (the #563 prototype used a fixed
1e-6 .. 1e3window and never reported a knot pinned at a bound), finite because the inner optimizers this layer feeds are bound-constrained and a segment-start concentration that is allowed to go negative is not a state the simulator can restart from.
- class pybnf.shooting.problem.MultipleShootingProblem(experiments, objective, variables, pset_from_u, blocks, scales, name=None, pool=None)[source]¶
One rung of the segment ladder: the fit, transcribed at a fixed segment count.
- Parameters:
experiments – The
SegmentedExperiments, all at the same segment count.objective – The fit’s own objective function.
variables – The fit’s reported free parameters, in
Configuration.variablesorder – the same order every existing PyBNF seam uses.pset_from_u –
(u_reported) -> PSet: the algorithm’s own sampling-space to parameter-set bridge (_pset_from_u()), so this module never re-derives thetheta <-> utransform.blocks – The auxiliary
VariableBlocks, one per knot of every experiment, already seeded (seeseed_stage()).scales –
{experiment key: per-state constraint scale}– the magnitude each continuity defect is measured against.name – The stage label for the trace (
'm=4').pool – The
SegmentPoolthat runs this stage’s segment passes. Defaults to the serial one, so a caller that does not ask for lanes gets exactly the behaviour that shipped with ADR-0110.
- certify(reported)[source]¶
Reconstruct
reportedthrough the fit’s ordinary unsegmented path.Every auxiliary state is discarded and each experiment is simulated once over its whole horizon from the model’s own initial conditions – which is the single-shoot problem – and scored by the fit’s own objective. That score is the only one comparable with an ordinary PyBNF fit’s, and under ADR-0109 it is the only one this run may report: the augmented objective at an infeasible point is computed on trajectories that do not join up.
- property constraint_names¶
One label per constraint, in residual order.
- equality_at(u)[source]¶
The
EqualityModelatu.
- property layout¶
This stage’s
AugmentedLayout.
- property name¶
A short label for the stage trace (multiple shooting uses
'm=4').
- objective_at(u)[source]¶
The fit’s objective and its derivatives at
u, memoised on the point.The memo is not an optimisation first — it is what keeps this method scoring each simulated trajectory exactly once (#578). Scoring goes through
objective.evaluate_multiple, which asks the measurement layer to materialise everyobservable: <id>, formula: ...column into the trajectory in place (ADR-0036), and that materialisation deliberately refuses a column that already exists. Every ordinary fit satisfies it for free: the propose/score loop scores a freshly simulatedDataeach time. Multiple shooting does not, because_traces()caches the segment trajectories per point so that one augmented-model evaluation costs one pass of segment simulations rather than two – and the outer loop then re-evaluates at the point the inner solver finished at, which is a cache hit on the very same objects.Memoising the assembled model rather than copying the trajectories fixes that at the cause: the second call returns the first call’s answer instead of re-scoring anything. It also removes a redundant gradient/Fisher assembly per outer iteration, which is the larger of the two costs. Sound because this method does not depend on the multipliers – only
AugmentedModelcombines them – so the objective at a point is a property of the point alone.
- pybnf.shooting.problem.STATE_FLOOR = 1e-12¶
Floor under a state magnitude, so a species that is identically zero over the whole horizon still gets a strictly positive constraint scale and a finite auxiliary box. The layer requires strictly positive scales precisely so this decision is made once, here, rather than being discovered as a divide-by-zero inside the penalty term.
- class pybnf.shooting.problem.SegmentedExperiment(spec, grid)[source]¶
One
ShootingExperimentcut at the knots of one stage.- certification_grid¶
The
m = 1grid of the same experiment – whatcertify()simulates. Not a special case: the unsegmented problem is the one-segment transcription.
- class pybnf.shooting.problem.ShootingExperiment(key, backend, exp_data, routing, times=None, label=None, start=None, horizon=None, placement='equal_time', knots=None)[source]¶
One scored
(model, condition)pair, at the fit level: everything about it that does not depend on the segment count.Built once per fit and shared by every rung of the ladder, which is what makes a stage cheap to construct: a homotopy builds one
SegmentedExperimentper rung around the same backend, observations, and routing.- Parameters:
key –
(model name, suffix)– the identity this experiment has in the simulated/experimental data dictionaries the objective consumes.backend – Its
SegmentBackend.exp_data – Its observations, as an ordinary PyBNF
Data.routing – The experiment’s prebuilt
ExperimentRoutingover the reported free parameters, exactly as the gradient optimizers build it once per fit.times – Its measurement times. Defaults to
exp_data’s independent-variable column.label – The label its knots are named under. Defaults to the suffix.
start – Where the simulation starts. Defaults to the first measurement time; a time course whose first measurement is after
t = 0should pass0.0.placement – How this experiment’s knots are placed – one of
PLACEMENTS. Fixed for the whole fit, so every rung of the ladder places its knots by the same rule and their fractions mean one thing.knots – Explicit knot times for the finest rung, which force
placement = 'explicit'.
- property max_segments¶
The finest rung this experiment’s data supports under its own placement.
- pybnf.shooting.problem.seed_stage(specs, n_segments, objective, variables, pset_from_u, reported, aux_decades=6.0, name=None, pool=None)[source]¶
Build one rung of the ladder, with its auxiliary states seeded from
reported.Each experiment is simulated once, unsegmented, at the incoming parameters, and the state is read off at each knot. Seeding this way makes the transcription feasible at iteration zero: every continuity defect is exactly zero at the start point, so any discontinuity the run subsequently holds is the optimizer’s own choice rather than an artifact of how the stage was built. It is also why a homotopy stage is a callable in
run_homotopy()– the seeds are not knowable until the previous stage has finished movingtheta.A start point that does not simulate is not a failure: the knots fall back to the model’s own declared state magnitudes, the first inner solve sees a large defect, and the run proceeds (or stops with a stated reason) rather than dying at construction.
aux_decadessets each auxiliary variable’s box:+/- aux_decadesaround the state’s own magnitude, in log10. Wide enough not to constrain the search, finite because the inner optimizers are bound-constrained and a segment-start concentration that is allowed to go negative is not a state a simulator can restart from.
The inner solver¶
The inner solver: gntr’s step machine, driven against an augmented subproblem (#563).
ADR-0109’s layer ships no inner solver, deliberately – shipping one would state a
preference the optimizer-agnostic contract exists to avoid. A consumer has to choose one,
and for multiple shooting the choice is measured rather than assumed. Over 30 data seeds x 2
starts on the layer’s offline shooting problem (120 runs), a trust-region least-squares
solver converged 60/60 while a quasi-Newton one converged 36/60 and stalled out on the
rest. The reason is structural: the KKT stop needs the scaled defect and the first-order
optimality below tolerance in one iterate, and a method built from gradient differences
handles an augmented Lagrangian whose penalty term carries a large rho far less well
than one that sees rho J_c^T J_c explicitly. So the MVP steps from the Gauss-Newton
form, and a consumer should not treat the inner optimizer as free.
Reusing the fit type rather than reimplementing it¶
The Gauss-Newton form is (gradient, PSD hessian) – exactly what job_type = gntr
already consumes, and _GNTRRunner is already a
headless, backend-free step machine over that pair: ridge-regularise H, eigen-factor it
into the pseudo-Jacobian that reproduces the Coleman-Li-scaled Newton step, and run trf’s
bound-constrained trust-region-reflective accept/reject state machine unchanged. So this
module is a driver, not a method: it feeds that runner the augmented Lagrangian instead of
the fit’s own objective, and nothing about the step math is new or separately tuned.
Driving it synchronously is the one difference from gntr’s own use, and it is forced by
the interface above rather than chosen: ADR-0109’s contract is
solve(subproblem, u0, tolerance) -> InnerOutcome, a blocking call, because an inner
solver “never calls back into the outer loop”. Since a segment simulation is not a
PSet evaluation either (pybnf.shooting.backend), nothing is lost:
the propose/score loop was never available to this path.
Two tolerances, and why the outer loop’s is only a floor away from being obeyed¶
tolerance is omega_k, the outer loop’s inner-optimality target: loose at first and
tightening as the penalty rises. It becomes the runner’s grad_tol, so an early
subproblem is solved roughly and a late one tightly – which is the entire economic argument
for the augmented-Lagrangian frame. It is floored (GaussNewtonSolver.grad_tol_floor)
because omega decays geometrically and will eventually pass below what any solver can
demonstrate on a finite-precision Hessian; past that point the runner would simply spend its
whole iteration budget every outer iteration, which is the same waste ADR-0109 finding 5.1
measured on a too-loose penalty, arrived at from the other side.
- class pybnf.shooting.solver.GaussNewtonSolver(max_iterations=50, ridge=1e-10, step_tol=1e-10, grad_tol_floor=1e-10, stop_check=None)[source]¶
An inner solver on ADR-0109’s contract, stepping from the Gauss-Newton form.
- Parameters:
max_iterations – Trust-region iterations per inner solve. Bounded per outer iteration rather than per run: an approximate inner minimisation is what the augmented-Lagrangian method is designed around, and the outer loop’s stall detector is what notices a solver that stops achieving anything.
ridge – The relative Levenberg ridge added to the curvature before the pseudo-Jacobian factorisation, as in
gntr_ridge. It matters more here than in an ordinary fit: with one observed state of three, the auxiliary states of the unobserved two carry no data term at all and their data-fit curvature is exactly zero, so the constraint blockrho J_c^T J_cis the only curvature they have.step_tol – Negligible-step tolerance, as in
gntr_step_tol.grad_tol_floor – Floor under the outer loop’s
omega_k(see the module docstring).stop_check – Zero-argument callable;
Truetruncates the inner solve at whichever iterate it has reached. The wall-clock-budget seam (ADR-0093/0107) – a truncated inner solve is a normal outcome here, not a failure, and the outer loop keeps the iterate.
- n_evaluations¶
Model evaluations spent across every inner solve this object has driven – the cost accounting the #563 acceptance benchmark reports, and the quantity the prototype’s paired sweep measured multiple shooting’s 2-7x overhead in.
The ladder¶
Driving the ladder: one fit, transcribed at several segment counts in turn (#563).
The whole of multiple shooting’s outer control flow is
run_homotopy(); this module supplies the three things it
needs that are specific to segments – the ladder of segment counts, a stage factory per
rung, and the defaults ADR-0109’s findings fixed.
Why the stages are callables¶
run_homotopy accepts a stage as a TranscriptionProblem
or as a (reported) -> TranscriptionProblem callable, and multiple shooting needs the
second form: a stage’s auxiliary variables are seeded by reading a nominal trajectory at the
incoming parameters at each knot, and the incoming parameters are whatever the previous rung
finished at. Seeding that way makes each stage feasible at its own iteration zero, so every
discontinuity a run holds is the optimizer’s choice rather than an artifact of how the stage
was built.
The ladder starts in the middle¶
(4, 2, 1), from coarsening_ladder(), and the middle
is the measured part. Starting at many short segments – the easiest landscape, and what the
original formulation proposed – is the wrong end on the motivating problem: with one
observed state of three and ~14 points per segment, m = 8 is under-determined, the data
term is satisfiable without correct dynamics, and the stage routinely certifies worse than
its own start. Over eight paired starts, 4-2-1 had the best tail at moderate cost and
8-4-2-1 did not. The ladder always ends at m = 1, which is the ordinary unsegmented
fit – so a multiple-shooting run finishes by solving the problem the user actually asked
about, and its last rung is the one whose objective needs no certification because it is
the certificate.
- pybnf.shooting.driver.coarsening_stages(specs, rungs, objective, variables, pset_from_u, aux_decades=6.0, pool=None)[source]¶
One stage factory per rung, in the order
run_homotopywill run them.
- pybnf.shooting.driver.feasible_ladder(specs, ladder=None)[source]¶
The requested ladder, clamped to what the data can support.
A segment count above what an experiment’s data supports places knots between observations everywhere and leaves segments with nothing to fit – a transcription whose auxiliary states are determined by continuity alone in every segment, which is not the method, it is an ODE solver with extra variables. Rungs above that are dropped and the caller is told which (a silent cap would read as “we ran the ladder you asked for”).
The ceiling is the experiment’s own, because it depends on the knot placement: equal time needs one measurement per segment, equal observations needs two, and an explicit knot list is the finest rung (
max_segments()).Returns
(rungs, dropped).
- pybnf.shooting.driver.run_multiple_shooting(specs, objective, variables, pset_from_u, reported_start, ladder=None, schedule=None, inner_solver=None, max_outer=25, aux_decades=6.0, stop_check=None, on_iterate=None, on_stage=None, pool=None)[source]¶
Solve one fit by multiple shooting, down the coarsening ladder.
- Parameters:
specs – The
ShootingExperiments – one per scored(model, condition)pair, built once for the whole fit.objective – The fit’s own objective function.
variables – Its reported free parameters, in
Configuration.variablesorder.pset_from_u – The algorithm’s sampling-space to
PSetbridge.reported_start – The fit’s start point in sampling space.
ladder – Segment counts, finest first. Defaults to
(4, 2, 1).schedule – The
PenaltySchedule. Its defaults carry ADR-0109 finding 5.1 – the penalty starts tight (rho_0 = 10,gamma = 5), because on the motivating problem a loose start was both worse and twice as expensive: the inner solve on a nearly-unconstrained subproblem never converges and burns its whole budget every outer iteration.inner_solver – Defaults to a
GaussNewtonSolver.stop_check – The wall-clock-budget seam, passed to the outer loop and to the default inner solver, so a deadline lands inside a long inner solve rather than after it.
pool – The
SegmentPoolevery rung’s segment passes run through. Defaults to the serial one.
Returns the
HomotopyResult, whosebestis the best certified iterate over the whole ladder – not its last, which on one prototype start held-147.0while an earlier iterate certified at-196.3.