Skip to content

fitting

Gradient-based parameter estimation through the ODE solve, with any optax optimiser. For a task-oriented introduction, see Fit model parameters to data.

from jbubble import FitResult, fit_parameters
from jbubble.fitting import Parameter, unwrap

fit_parameters

fit_parameters(
    make_model: Callable[..., tuple[EquationOfMotion, Pulse]],
    params0: Any,
    *,
    loss_fn: Callable[..., ArrayLike],
    optimizer: GradientTransformation,
    n_steps: int = 200,
    conditions: Sequence[Any] | None = None,
    save_spec: SaveSpec | None = None,
    t_max: ArrayLike | None = None,
    config: SolverConfig | None = None,
    adjoint: AbstractAdjoint | None = None,
    max_backtracks: int = 5,
    step_callback: Callable[[int, Any, float], None] | None = None,
    log_every: int = 25,
) -> FitResult

Fit model parameters by differentiating through the ODE solve.

fit_parameters minimises the mean loss over the conditions,

\[ L(\theta) = \frac{1}{N} \sum_{c=1}^{N} \ell\big(\mathrm{simulate}(\mathrm{make\_model}(p, c)),\, c\big), \qquad p = \mathrm{unwrap}(\theta), \]

where \(\theta\) holds the optimiser's coordinates (see Parameter), \(\ell\) is loss_fn, and \(\mathrm{simulate}\) is run_simulation with save_spec, t_max, config, and adjoint. Without conditions, \(N = 1\) and neither make_model nor loss_fn receives a condition. Each step computes the update \(\Delta\theta\) with optimizer and tries

\[ \theta_k = \theta + 2^{-k}\,\Delta\theta, \qquad k = 0, 1, \ldots, \texttt{max\_backtracks}, \]

accepting the first \(\theta_k\) at which every solve converges and both \(L\) and its gradient are finite.

fit_parameters runs a Python loop around compiled functions, so you can't call it under jax.jit or jax.vmap, and each call compiles again. For many fits, such as multi-start fits, write the training loop yourself with unwrap.

Parameters:

Name Type Description Default
make_model callable

make_model(params) -> (eom, pulse), or make_model(params, condition) -> (eom, pulse) when you pass conditions. params has the structure of params0, in physical units. Must be JAX-traceable.

required
params0 Any

Initial parameters: a float, a Parameter, a dict, list, or tuple of them, or an Equinox module such as a NeuralProperty. What gets fitted:

  • A Parameter, on its scaled coordinate, unless it's fixed.
  • A Python float (or np.float64) in params0 itself or in a dict, list, or tuple: 1e-9 is shorthand for Parameter(1e-9).
  • A floating-point JAX or NumPy array, including a NumPy scalar other than np.float64, in its own units, for example neural network weights.

Everything else is held fixed: integers, booleans, strings, callables, and Python floats inside an Equinox module. If you set a Python float inside a module, fit_parameters warns and names it.

required
loss_fn callable

loss_fn(result) -> scalar, or loss_fn(result, condition) -> scalar when you pass conditions. result is a SimulationResult.

required
optimizer GradientTransformation

For example optax.adam(0.05). For Parameter and float values, the learning rate is a relative step size: 0.05 changes a value by about 5 % per step. optimizer.update receives the current coordinates as params, so transformations such as optax.adamw work, but act on the coordinates: weight decay pulls a lower-bounded Parameter toward its initial value, not toward zero.

required
n_steps int

Number of accepted steps. Default: 200.

200
conditions list or tuple

One entry per experimental condition, for example per driving pressure or per recording. Each entry can be any pytree, and fit_parameters passes it to make_model and loss_fn. All conditions share params. Conditions with the same structure and leaf shapes run in parallel under jax.vmap; others run one after another. In parallel, each number in a condition, including a Python int, reaches make_model and loss_fn as a traced array: use it in jnp.where or jax.lax.cond, not in an if or as a slice bound. A condition that holds a Python bool or a string runs one after another, so these stay Python values that you can use as flags.

None
save_spec SaveSpec

Output sampling. None uses SaveSpec().

None
t_max float

Integration end time [s]. None uses each pulse's t_end.

None
config SolverConfig

ODE solver settings. None uses SolverConfig(), the same as run_simulation, so the model you fit is the model you simulate.

None
adjoint AbstractAdjoint

How the gradient is computed through the solve. None uses diffrax.RecursiveCheckpointAdjoint(). fit_parameters differentiates in reverse mode, so it can't use diffrax.ForwardMode().

None
max_backtracks int

How many times to halve a step that fails before stopping. If every halved step fails, the fit warns and returns the last accepted parameters. Default: 5.

5
step_callback callable

step_callback(step, params, loss), called outside JIT for params0 (step == 0) and after each accepted step. params is in physical units, and loss is the loss at params. Raise StopIteration to end the fit. Any other exception propagates and discards the fit, so save checkpoints from the callback if you need them.

None
log_every int

Print progress every log_every steps. 0 turns off logging. Default: 25.

25

Returns:

Type Description
FitResult

Fitted parameters, loss history, the simulation at the fitted parameters, and how the fit ended.

Raises:

Type Description
ValueError

If params0 has nothing to fit, a Python float in params0 is zero, or conditions is empty.

TypeError

If adjoint is diffrax.ForwardMode(), conditions isn't a list or tuple, make_model or loss_fn takes the wrong number of arguments, make_model doesn't return an (eom, pulse) tuple, or loss_fn doesn't return a scalar.

RuntimeError

If a solve fails, or the loss or its gradient isn't finite, at params0.

Warns:

Type Description
UserWarning

If you set a Python float inside an Equinox module in params0, which stays fixed, or if the first step is larger than a coordinate's scale or smaller than a millionth of it.

RuntimeWarning

If every halved step fails and the fit stops early.

Examples:

import optax
from jbubble import fit_parameters
from jbubble.fitting import Parameter
from jbubble.metrics import normalised_mse_radius

fit = fit_parameters(
    lambda p: (make_eom(kappa_s=p["kappa_s"], chi=p["chi"]), pulse),
    {
        "kappa_s": Parameter(1e-9, lower=0.0),  # [N s/m]
        "chi": Parameter(0.3, lower=0.0, upper=1.5),  # [N/m]
    },
    loss_fn=lambda r: normalised_mse_radius(r.radius, measured, R0),
    optimizer=optax.adam(0.05),
)
Source code in jbubble/fitting.py
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
def fit_parameters(
    make_model: Callable[..., tuple[EquationOfMotion, Pulse]],
    params0: PyTree,
    *,
    loss_fn: Callable[..., ArrayLike],
    optimizer: optax.GradientTransformation,
    n_steps: int = 200,
    conditions: Sequence[PyTree] | None = None,
    save_spec: SaveSpec | None = None,
    t_max: ArrayLike | None = None,
    config: SolverConfig | None = None,
    adjoint: diffrax.AbstractAdjoint | None = None,
    max_backtracks: int = 5,
    step_callback: Callable[[int, PyTree, float], None] | None = None,
    log_every: int = 25,
) -> FitResult:
    r"""Fit model parameters by differentiating through the ODE solve.

    `fit_parameters` minimises the mean loss over the conditions,

    $$
    L(\theta) = \frac{1}{N} \sum_{c=1}^{N}
    \ell\big(\mathrm{simulate}(\mathrm{make\_model}(p, c)),\, c\big),
    \qquad p = \mathrm{unwrap}(\theta),
    $$

    where $\theta$ holds the optimiser's coordinates (see
    [`Parameter`][jbubble.fitting.Parameter]), $\ell$ is `loss_fn`, and
    $\mathrm{simulate}$ is
    [`run_simulation`][jbubble.simulation.run_simulation] with `save_spec`,
    `t_max`, `config`, and `adjoint`. Without `conditions`, $N = 1$ and
    neither `make_model` nor `loss_fn` receives a condition. Each step
    computes the update $\Delta\theta$ with `optimizer` and tries

    $$
    \theta_k = \theta + 2^{-k}\,\Delta\theta, \qquad k = 0, 1, \ldots,
    \texttt{max\_backtracks},
    $$

    accepting the first $\theta_k$ at which every solve converges and both
    $L$ and its gradient are finite.

    `fit_parameters` runs a Python loop around compiled functions, so you
    can't call it under `jax.jit` or `jax.vmap`, and each call compiles
    again. For many fits, such as multi-start fits, write the training loop
    yourself with [`unwrap`][jbubble.fitting.unwrap].

    Parameters
    ----------
    make_model : callable
        `make_model(params) -> (eom, pulse)`, or
        `make_model(params, condition) -> (eom, pulse)` when you pass
        `conditions`. `params` has the structure of `params0`, in physical
        units. Must be JAX-traceable.
    params0 : PyTree
        Initial parameters: a float, a `Parameter`, a dict, list, or tuple
        of them, or an Equinox module such as a
        [`NeuralProperty`][jbubble.bubble.property.NeuralProperty]. What
        gets fitted:

        - A `Parameter`, on its scaled coordinate, unless it's `fixed`.
        - A Python float (or `np.float64`) in `params0` itself or in a dict,
          list, or tuple: `1e-9` is shorthand for `Parameter(1e-9)`.
        - A floating-point JAX or NumPy array, including a NumPy scalar
          other than `np.float64`, in its own units, for example neural
          network weights.

        Everything else is held fixed: integers, booleans, strings,
        callables, and Python floats inside an Equinox module. If you set a
        Python float inside a module, `fit_parameters` warns and names it.
    loss_fn : callable
        `loss_fn(result) -> scalar`, or `loss_fn(result, condition) ->
        scalar` when you pass `conditions`. `result` is a
        [`SimulationResult`][jbubble.simulation.SimulationResult].
    optimizer : optax.GradientTransformation
        For example `optax.adam(0.05)`. For `Parameter` and float values,
        the learning rate is a relative step size: 0.05 changes a value by
        about 5 % per step. `optimizer.update` receives the current
        coordinates as `params`, so transformations such as `optax.adamw`
        work, but act on the coordinates: weight decay pulls a lower-bounded
        `Parameter` toward its initial value, not toward zero.
    n_steps : int
        Number of accepted steps. Default: `200`.
    conditions : list or tuple, optional
        One entry per experimental condition, for example per driving
        pressure or per recording. Each entry can be any pytree, and
        `fit_parameters` passes it to `make_model` and `loss_fn`. All
        conditions share `params`. Conditions with the same structure and
        leaf shapes run in parallel under `jax.vmap`; others run one after
        another. In parallel, each number in a condition, including a
        Python int, reaches `make_model` and `loss_fn` as a traced array:
        use it in `jnp.where` or `jax.lax.cond`, not in an `if` or as a
        slice bound. A condition that holds a Python bool or a string runs
        one after another, so these stay Python values that you can use as
        flags.
    save_spec : SaveSpec, optional
        Output sampling. `None` uses [`SaveSpec()`][jbubble.solver.SaveSpec].
    t_max : float, optional
        Integration end time [s]. `None` uses each pulse's `t_end`.
    config : SolverConfig, optional
        ODE solver settings. `None` uses
        [`SolverConfig()`][jbubble.solver.SolverConfig], the same as
        [`run_simulation`][jbubble.simulation.run_simulation], so the model
        you fit is the model you simulate.
    adjoint : diffrax.AbstractAdjoint, optional
        How the gradient is computed through the solve. `None` uses
        `diffrax.RecursiveCheckpointAdjoint()`. `fit_parameters`
        differentiates in reverse mode, so it can't use
        `diffrax.ForwardMode()`.
    max_backtracks : int
        How many times to halve a step that fails before stopping. If every
        halved step fails, the fit warns and returns the last accepted
        parameters. Default: `5`.
    step_callback : callable, optional
        `step_callback(step, params, loss)`, called outside JIT for
        `params0` (`step == 0`) and after each accepted step. `params` is in
        physical units, and `loss` is the loss at `params`. Raise
        `StopIteration` to end the fit. Any other exception propagates and
        discards the fit, so save checkpoints from the callback if you need
        them.
    log_every : int
        Print progress every `log_every` steps. `0` turns off logging.
        Default: `25`.

    Returns
    -------
    FitResult
        Fitted parameters, loss history, the simulation at the fitted
        parameters, and how the fit ended.

    Raises
    ------
    ValueError
        If `params0` has nothing to fit, a Python float in `params0` is zero,
        or `conditions` is empty.
    TypeError
        If `adjoint` is `diffrax.ForwardMode()`, `conditions` isn't a list
        or tuple, `make_model` or `loss_fn` takes the wrong number of
        arguments, `make_model` doesn't return an `(eom, pulse)` tuple, or
        `loss_fn` doesn't return a scalar.
    RuntimeError
        If a solve fails, or the loss or its gradient isn't finite, at
        `params0`.

    Warns
    -----
    UserWarning
        If you set a Python float inside an Equinox module in `params0`,
        which stays fixed, or if the first step is larger than a
        coordinate's scale or smaller than a millionth of it.
    RuntimeWarning
        If every halved step fails and the fit stops early.

    Examples
    --------
    ```python
    import optax
    from jbubble import fit_parameters
    from jbubble.fitting import Parameter
    from jbubble.metrics import normalised_mse_radius

    fit = fit_parameters(
        lambda p: (make_eom(kappa_s=p["kappa_s"], chi=p["chi"]), pulse),
        {
            "kappa_s": Parameter(1e-9, lower=0.0),  # [N s/m]
            "chi": Parameter(0.3, lower=0.0, upper=1.5),  # [N/m]
        },
        loss_fn=lambda r: normalised_mse_radius(r.radius, measured, R0),
        optimizer=optax.adam(0.05),
    )
    ```
    """
    config = SolverConfig() if config is None else config
    adjoint = diffrax.RecursiveCheckpointAdjoint() if adjoint is None else adjoint
    if isinstance(adjoint, diffrax.ForwardMode):
        raise TypeError(
            "fit_parameters: diffrax.ForwardMode() gives only forward-mode "
            "derivatives, and fit_parameters differentiates in reverse mode. Keep "
            "the default diffrax.RecursiveCheckpointAdjoint(). For a least-squares "
            "fit with forward-mode Jacobians, pass adjoint=diffrax.ForwardMode() "
            "to run_simulation instead, as the fitting guide shows."
        )

    if conditions is not None:
        if not isinstance(conditions, (list, tuple)):
            raise TypeError(
                "fit_parameters: conditions must be a list or tuple with one entry "
                "per condition, for example [{'pressure': 50e3, 'radius': r50}, ...]; "
                f"got {type(conditions).__name__}."
            )
        if not conditions:
            raise ValueError("fit_parameters: conditions is empty.")
        conditions = list(conditions)
    for fn, name, first in (
        (make_model, "make_model", "params"),
        (loss_fn, "loss_fn", "result"),
    ):
        _check_arity(fn, name, (first,) if conditions is None else (first, "condition"))
    stacked = None if conditions is None else _stack(conditions)

    spec = _canonicalise(params0)
    _warn_held_floats(spec)
    trainable, static = eqx.partition(spec, _trainable_filter(spec))
    # Strong dtypes: a weakly typed array, such as jnp.array(1e-9), comes back
    # strongly typed from the first update and would make `evaluate` compile
    # a second time.
    trainable = jtu.tree_map(
        lambda x: jnp.asarray(x, dtype=jnp.result_type(x)), trainable
    )
    if not jtu.tree_leaves(trainable):
        raise ValueError(
            "fit_parameters: params0 has nothing to fit. Use Parameter or Python "
            "floats for physical values, and floating-point arrays for network "
            "weights. Integers, booleans, fixed Parameters, and Python floats "
            "inside an Equinox module are held fixed."
        )

    def one(params: PyTree, condition: Any) -> tuple[jax.Array, SimulationResult]:
        model = (
            make_model(params) if conditions is None else make_model(params, condition)
        )
        if not _is_model(model):
            raise TypeError(
                "fit_parameters: make_model must return an (EquationOfMotion, Pulse) "
                f"tuple; got {type(model).__name__}."
            )
        result = _simulate(
            model[0],
            model[1],
            save_spec=save_spec,
            t_max=t_max,
            config=config,
            adjoint=adjoint,
        )
        loss = jnp.asarray(
            loss_fn(result) if conditions is None else loss_fn(result, condition)
        )
        if loss.ndim != 0:
            raise TypeError(
                f"fit_parameters: loss_fn must return a scalar; got shape {loss.shape}."
            )
        return loss, result

    def objective(tr: PyTree, st: PyTree, conds: Any):
        params = unwrap(eqx.combine(tr, st))
        if conditions is None:
            loss, result = one(params, None)
            return loss, (result, result.converged[None], loss[None])
        if stacked is not None:
            losses, results = eqx.filter_vmap(lambda c: one(params, c))(conds)
            return jnp.mean(losses), (results, results.converged, losses)
        outs = [one(params, c) for c in conds]
        losses = jnp.stack([loss for loss, _ in outs])
        results = [r for _, r in outs]
        converged = jnp.stack([r.converged for r in results])
        return jnp.mean(losses), (results, converged, losses)

    @eqx.filter_jit
    def evaluate(tr: PyTree, st: PyTree, conds: Any):
        (loss, (results, converged, losses)), grads = jax.value_and_grad(
            objective, has_aux=True
        )(tr, st, conds)
        grads_finite = _all_finite(grads)
        ok = jnp.all(converged) & jnp.isfinite(loss) & grads_finite
        return loss, grads, ok, (converged, losses, grads_finite), results

    @eqx.filter_jit
    def propose(tr: PyTree, opt_state: Any, grads: PyTree):
        return optimizer.update(grads, opt_state, tr)

    @eqx.filter_jit
    def apply(tr: PyTree, updates: PyTree, factor: jax.Array):
        return eqx.apply_updates(tr, jtu.tree_map(lambda u: factor * u, updates))

    conds_arg = stacked if stacked is not None else conditions
    loss, grads, ok, diagnostics, results = evaluate(trainable, static, conds_arg)
    if not bool(ok):
        converged, losses, grads_finite = diagnostics
        problems = []
        for i, (ok_i, loss_i) in enumerate(
            zip(converged.tolist(), losses.tolist(), strict=True)
        ):
            where = "" if conditions is None else f"condition {i}: "
            if not ok_i:
                problems.append(f"{where}the ODE solve did not converge")
            elif not jnp.isfinite(loss_i):
                problems.append(f"{where}the loss is {loss_i}")
        if not problems and not bool(grads_finite):
            problems.append("the gradient is not finite")
        if not all(converged.tolist()):
            hint = (
                "Check params0 and the driving pressure, raise "
                "SolverConfig.max_steps, or tighten the solver tolerances."
            )
        else:
            hint = (
                "The solve converged, so check that loss_fn returns a finite "
                "value with a finite gradient at params0."
            )
        raise RuntimeError(
            "fit_parameters: can't start at params0, because "
            + "; ".join(problems)
            + ". "
            + hint
        )

    def physical(tr: PyTree) -> PyTree:
        return unwrap(eqx.combine(tr, static))

    # Typical magnitude of each coordinate, for the first-step check.
    reference = eqx.partition(
        jtu.tree_map(
            lambda x: (
                eqx.tree_at(lambda q: q.raw, x, jnp.ones_like(x.raw))
                if _is_parameter(x)
                else x
            ),
            spec,
            is_leaf=_is_parameter,
        ),
        _trainable_filter(spec),
    )[0]
    opt_state = optimizer.init(trainable)
    history = [float(loss)]
    num_rejected = 0
    stopped_early = False
    message = f"completed {n_steps} steps"

    def report(step: int) -> None:
        if log_every > 0 and (step % log_every == 0 or step == n_steps):
            print(
                f"  step {step:>4} / {n_steps}  loss = {history[-1]:.4e}  "
                f"{_format_params(physical(trainable))}"
                + (f"  (rejected {num_rejected})" if num_rejected else "")
            )

    def callback(step: int) -> bool:
        if step_callback is None:
            return False
        try:
            step_callback(step, physical(trainable), history[-1])
        except StopIteration:
            return True
        return False

    report(0)
    stop = callback(0)
    if stop:
        stopped_early = n_steps > 0
        message = "step_callback raised StopIteration at step 0"
    step = 0
    while not stop and step < n_steps:
        updates, new_opt_state = propose(trainable, opt_state, grads)
        if step == 0:
            _warn_on_first_step(reference, updates)
        for k in range(max_backtracks + 1):
            candidate = apply(trainable, updates, jnp.asarray(0.5**k))
            c_loss, c_grads, c_ok, _, c_results = evaluate(candidate, static, conds_arg)
            if bool(c_ok):
                break
            num_rejected += 1
        else:
            stopped_early = True
            message = (
                f"stopped after {step} of {n_steps} steps: {max_backtracks + 1} "
                "successively halved steps all made a solve fail or gave a non-finite "
                "loss or gradient"
            )
            warnings.warn(
                f"fit_parameters: {message}. Returning the last accepted parameters. "
                "Lower the learning rate, bound the parameters with Parameter, or "
                "raise SolverConfig.max_steps.",
                RuntimeWarning,
                stacklevel=2,
            )
            break
        trainable, opt_state = candidate, new_opt_state
        loss, grads, results = c_loss, c_grads, c_results
        step += 1
        history.append(float(loss))
        report(step)
        stop = callback(step)
        if stop:
            stopped_early = step < n_steps
            message = f"step_callback raised StopIteration after step {step}"

    if stacked is not None:
        num_conditions = len(jtu.tree_leaves(stacked)[0])
        results = [
            jtu.tree_map(lambda x, i=i: x[i], results) for i in range(num_conditions)
        ]

    return FitResult(
        params=physical(trainable),
        loss_history=jnp.asarray(history),
        result=results,
        num_rejected=num_rejected,
        stopped_early=stopped_early,
        message=message,
    )

FitResult

FitResult(
    params: Any,
    loss_history: Array,
    result: SimulationResult | list[SimulationResult],
    num_rejected: int = 0,
    stopped_early: bool = False,
    message: str = "",
)

Output of fit_parameters.

Attributes:

Name Type Description
params Any

Fitted parameters in physical units, with the structure of params0. Each Parameter and each fitted Python float is replaced by its fitted value, a jax.Array.

loss_history (Array, shape(n_accepted + 1))

loss_history[0] is the loss at params0, and loss_history[k] the loss after k accepted steps. loss_history[-1] is the loss at params.

result SimulationResult or list of SimulationResult

The simulation at params. With conditions, a list with one SimulationResult per condition, in the same order.

num_rejected int

Number of candidate steps rejected because a solve failed or the loss or gradient wasn't finite. Each rejection halves the step.

stopped_early bool

True if the fit ended before n_steps accepted steps.

message str

Why the fit ended.

Parameter

Parameter(
    value: ArrayLike,
    lower: float | None = None,
    upper: float | None = None,
    *,
    scale: float | None = None,
    fixed: bool = False,
)

Bases: Module

A fitted quantity, with optional bounds, a scale, or a fixed value.

The optimiser never updates the physical value \(x\) directly. It updates an unconstrained coordinate \(u\) of order one, and \(x\) follows from \(u\):

\[ x = \begin{cases} s\,u & \text{no bounds} \\ a + s\,e^{u} & \text{lower bound } a \\ b - s\,e^{u} & \text{upper bound } b \\ a + (b - a)\,\operatorname{sigmoid}(u) & \text{both bounds} \end{cases} \]

where \(s\) is scale. The default scale is \(|x_0|\) without bounds and the distance from the initial value \(x_0\) to the bound with one bound. So an optimiser step of 0.05 in \(u\) changes \(x\) by about 5 %, whatever its units, and one learning rate suits kappa_s near \(10^{-9}\) N s/m and chi near 0.5 N/m alike. With bounds, \(x\) stays strictly inside them.

Build a Parameter outside jax.jit and jax.vmap: the constructor checks the value and bounds on concrete numbers.

Parameters:

Name Type Description Default
value float or array

Initial physical value, in SI units. An array is fitted element by element.

required
lower float

Lower bound. value must be greater than it. A scalar: one bound applies to every element of an array value. -inf, as in SciPy, means no lower bound, the same as None.

None
upper float

Upper bound. value must be less than it. A scalar, like lower. inf means no upper bound, the same as None.

None
scale float

Typical magnitude of \(x\) (no bounds) or of its distance from the bound (one bound). Required when value is zero, there are no bounds, and the parameter isn't fixed, for example Parameter(0.0, scale=1e-7) for a trigger delay [s]. Ignored with two bounds. scale is a static field, so Parameters with different scales have different pytree structures, and a compiled function compiles again for each one. By default, it comes from value: to reuse a compiled function for several initial values, pass the same scale to each.

None
fixed bool

Whether to hold \(x\) at value. Use this to switch a parameter off without editing make_model, or to hold a value inside an Equinox module fixed without a warning from fit_parameters. Default: False.

False

Attributes:

Name Type Description
raw Array

The coordinate \(u\) that the optimiser updates. For a fixed parameter, the value itself.

Raises:

Type Description
ValueError

If value isn't finite, lies outside the bounds, or is zero without bounds, scale, or fixed, or if a bound is NaN, lower >= upper, or scale isn't positive and finite.

Examples:

kappa_s = Parameter(1e-9, lower=0.0)  # positive, log-scaled [N s/m]
chi = Parameter(0.3, lower=0.0, upper=1.5)  # bounded [N/m]
R0 = Parameter(2e-6, fixed=True)  # held fixed [m]
Source code in jbubble/fitting.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def __init__(
    self,
    value: ArrayLike,
    lower: float | None = None,
    upper: float | None = None,
    *,
    scale: float | None = None,
    fixed: bool = False,
):
    v = jnp.asarray(value, dtype=float)
    if not bool(jnp.all(jnp.isfinite(v))):
        raise ValueError(f"Parameter: value must be finite, got {value!r}.")
    lower = None if lower is None else float(lower)
    upper = None if upper is None else float(upper)
    if (lower is not None and lower != lower) or (
        upper is not None and upper != upper
    ):
        raise ValueError(
            f"Parameter: lower and upper can't be NaN, got lower={lower}, "
            f"upper={upper}. Omit a bound, or pass None, for no bound."
        )
    # An infinite bound on its own side is no bound, as in SciPy.
    self.lower = None if lower == float("-inf") else lower
    self.upper = None if upper == float("inf") else upper
    self.fixed = bool(fixed)
    lo, hi = self.lower, self.upper
    if lo is not None and hi is not None and not lo < hi:
        raise ValueError(f"Parameter: lower={lo} must be less than upper={hi}.")
    if lo is not None and not bool(jnp.all(v > lo)):
        raise ValueError(
            f"Parameter: value {value!r} must be greater than lower={lo}."
        )
    if hi is not None and not bool(jnp.all(v < hi)):
        raise ValueError(
            f"Parameter: value {value!r} must be less than upper={hi}."
        )
    if scale is not None and not (0.0 < float(scale) < float("inf")):
        raise ValueError(
            f"Parameter: scale must be positive and finite, got {scale!r}."
        )

    if lo is not None and hi is not None:
        self.scale = hi - lo
    elif scale is not None:
        self.scale = float(scale)
    elif lo is not None:
        self.scale = float(jnp.exp(jnp.mean(jnp.log(v - lo))))
    elif hi is not None:
        self.scale = float(jnp.exp(jnp.mean(jnp.log(hi - v))))
    else:
        magnitude = float(jnp.max(jnp.abs(v)))
        if magnitude == 0.0 and self.fixed:
            magnitude = 1.0  # a fixed parameter never uses its scale
        elif magnitude == 0.0:
            raise ValueError(
                "Parameter: can't infer a scale for a value of zero. Pass "
                "scale=<typical magnitude>, for example Parameter(0.0, scale=1e-7)."
            )
        self.scale = magnitude

    if self.fixed:
        self.raw = v
    elif lo is not None and hi is not None:
        z = (v - lo) / (hi - lo)
        self.raw = jnp.log(z) - jnp.log1p(-z)
    elif lo is not None:
        self.raw = jnp.log((v - lo) / self.scale)
    elif hi is not None:
        self.raw = jnp.log((hi - v) / self.scale)
    else:
        self.raw = v / self.scale

value property

value

Physical value \(x\) of the parameter, in SI units.

unwrap

unwrap(tree: Any) -> Any

Replace every Parameter in tree by its value.

fit_parameters calls this for you before it calls make_model. Call it yourself in a hand-written training loop.

Parameters:

Name Type Description Default
tree Any

Any pytree, such as a dict of Parameters or an Equinox module with Parameter fields.

required

Returns:

Type Description
Any

tree, with each Parameter replaced by its physical value, a jax.Array.

Source code in jbubble/fitting.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def unwrap(tree: PyTree) -> PyTree:
    """Replace every [`Parameter`][jbubble.fitting.Parameter] in `tree` by its value.

    [`fit_parameters`][jbubble.fitting.fit_parameters] calls this for you
    before it calls `make_model`. Call it yourself in a hand-written training
    loop.

    Parameters
    ----------
    tree : PyTree
        Any pytree, such as a dict of `Parameter`s or an Equinox module with
        `Parameter` fields.

    Returns
    -------
    PyTree
        `tree`, with each `Parameter` replaced by its physical value, a
        `jax.Array`.
    """
    return jtu.tree_map(
        lambda x: x.value if _is_parameter(x) else x, tree, is_leaf=_is_parameter
    )

How a step works

fit_parameters evaluates the loss and its gradient in one compiled call, with reverse-mode differentiation through the ODE solve. Each step then does the following:

  1. Computes an update with optimizer.update(grads, opt_state, params).
  2. Tries the candidate params + update. If any solve fails to converge, or the loss or gradient isn't finite, it halves the update and tries again, up to max_backtracks times.
  3. Accepts the first candidate that succeeds. Its loss goes into loss_history, and its gradient drives the next step.

If every halved update fails, the fit warns, stops, and returns the last accepted parameters. A failed solve during the fit never raises. Only a failure at params0 raises RuntimeError, before the first step.

What gets fitted

Leaf of params0 Fitted Coordinate that the optimiser updates
Parameter(x) Yes x / scale
Parameter(x, lower=a) Yes log((x - a) / scale)
Parameter(x, upper=b) Yes log((b - x) / scale)
Parameter(x, lower=a, upper=b) Yes logit((x - a) / (b - a))
Parameter(x, fixed=True) No
Python float or np.float64 in a dict, list, or tuple, or as params0 Yes as Parameter(x)
Floating-point JAX or NumPy array, or another NumPy scalar Yes the value itself
Python float inside an Equinox module No, with a warning if you set it
Integer, boolean, string, or callable No

make_model, loss_fn, step_callback, and FitResult.params all see physical values.

Gradients through the ODE solve

solve_eom evaluates the equation of motion through a guard, so a trial step that the adaptive solver rejects, for example one that overshoots a strong collapse to R <= 0, can't make the gradient NaN. With an explicit solver, such as the default Dopri5, the guard leaves the solution unchanged, bit for bit.