Skip to content

Optimizers

Back to Model Calibration

The class of optimization_config chooses the optimizer: NelderMeadConfig for a fast local search, ParticleSwarmConfig for a global one. Both run in Rust on the argmin library, and both keep every unknown inside its bounds.

Quick path

from commol import (
    Aggregate,
    CalibrationParameter,
    CalibrationProblem,
    Calibrator,
    ModelBuilder,
    NelderMeadConfig,
    ObservedDataPoint,
    ParticleSwarmConfig,
    Simulation,
)

def model(k1: float | None, k2: float | None):
    return (
        ModelBuilder(name="Optimizers")
        .add_bin(id="A", name="State A")
        .add_bin(id="B", name="State B")
        .add_bin(id="C", name="State C")
        .add_parameter(id="k1", value=k1)
        .add_parameter(id="k2", value=k2)
        .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total")
        .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2 * B")
        .build(
            Aggregate(
                bin_amounts={"A": 990, "B": 10, "C": 0},
            ),
            engine="DifferenceEquations",
        )
    )

truth = Simulation(model(0.4, 0.1)).run(num_steps=50)
observed_data = [
    ObservedDataPoint(at=step, output="B", value=truth["B"][step])
    for step in range(5, 51, 5)
]

def calibrate(optimization_config) -> dict[str, float]:
    problem = CalibrationProblem(
        observed_data=observed_data,
        parameters=[
            CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0),
            CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.0, max_bound=1.0),
        ],
        loss_function="sse",
        optimization_config=optimization_config,
        seed=7,
    )
    result = Calibrator(Simulation(model(None, None)), problem).run()
    return {name: round(value, 2) for name, value in sorted(result.best_parameters.items())}

print(calibrate(NelderMeadConfig(max_iterations=2000)))                    # {'k1': 0.4, 'k2': 0.1}
print(calibrate(ParticleSwarmConfig(particles=30, max_iterations=300)))  # {'k1': 0.4, 'k2': 0.1}

Choosing

Optimizer Strength Weakness Use when
Nelder-Mead Few evaluations; precise near the answer Local: can stop at a bound or a local minimum Few unknowns, a good initial_guess
Particle swarm Searches the whole box; robust to local minima Many evaluations Several unknowns, no good starting point

A common pattern: run particle swarm first, then Nelder-Mead from its result with initial_guess.

Nelder-Mead

nelder_mead = NelderMeadConfig(
    max_iterations=1000,
    sd_tolerance=1e-6,
    initial_simplex_size=0.1,
    alpha=1.0,
    gamma=2.0,
    rho=0.5,
    sigma=0.5,
    verbose=False,
    header_interval=100,
)
print(calibrate(nelder_mead))  # {'k1': 0.4, 'k2': 0.1}
Field Default Meaning
max_iterations 1000 The iteration limit.
sd_tolerance 1e-6 Stops when the spread of the simplex values falls below it.
initial_simplex_size 0.1 The size of the first simplex: each vertex moves one unknown by this fraction of its bound width, towards the farther bound. In (0, 1].
alpha, gamma, rho, sigma None (argmin defaults) Reflection, expansion, contraction and shrink coefficients.
verbose False Writes a progress table to sys.stdout (a notebook shows it). It shows one start, so it needs run(starts=1).
header_interval 100 With verbose, repeats the table header every this many iterations.

The search starts at initial_guess, or at the midpoint of the bounds; a start of 0 works. Nelder-Mead moves in an unbounded coordinate u, and the value it scores is lo + (hi − lo)·(sin u + 1)/2 (the transform of MINUIT and lmfit), so every value it tries is inside the bounds (SineTransform of the argmin fork). An optimum exactly at a bound is reached only asymptotically, so it can take more iterations. Nelder-Mead needs finite bounds: a run with an infinite bound fails with the message Nelder-Mead needs finite bounds.

converged and termination_reason

termination_reason converged Meaning
Terminated(SolverConverged) True Nelder-Mead: the spread of the simplex values fell below sd_tolerance. Particle swarm: a stop rule held
Terminated(MaxItersReached) False The run used all max_iterations
Any other reason False The run stopped for another reason that argmin reports

converged comes from converged in crates/commol-calibration/src/optimization.rs. A particle swarm without a stop rule always stops at max_iterations, so it reports converged=False; judge it by final_loss.

Particle swarm

swarm = (
    ParticleSwarmConfig(particles=40, max_iterations=300, initialization="latin_hypercube")
    .inertia("chaotic", w_min=0.4, w_max=0.9)
    .acceleration("time_varying", c1_initial=2.5, c1_final=0.5, c2_initial=0.5, c2_final=2.5)
    .velocity(clamp_factor=0.2, mutation_threshold=0.005)
    .mutation("gaussian", scale=0.1, probability=0.05, application="global_best")
)
print(calibrate(swarm))  # {'k1': 0.4, 'k2': 0.1}
Field Default Meaning
particles 20 The swarm size. More particles search more widely and cost more evaluations.
max_iterations 1000 The iteration limit.
initialization "uniform" Where particles start: "uniform", "latin_hypercube" (spread evenly) or "opposition_based" (each random point with its mirror).
verbose False Writes progress to sys.stdout; needs run(starts=1).

Each method returns the configuration, so they chain:

Method Arguments Effect
.inertia("constant", factor=0.72135) factor A fixed inertia weight. The default is the weight of canonical PSO, 1/(2 ln 2), which a swarm with no .inertia also uses.
.inertia("chaotic", w_min=…, w_max=…) w_min < w_max An inertia weight that varies chaotically between the two.
.acceleration("constant", cognitive=1.19315, social=1.19315) cognitive, social Fixed pulls towards each particle's best and the swarm's best. The default of each is the coefficient of canonical PSO, 0.5 + ln 2, which a swarm with no .acceleration also uses.
.acceleration("time_varying", c1_initial=…, c1_final=…, c2_initial=…, c2_final=…) four factors Pulls that change from explore (high c1) to exploit (high c2).
.velocity(clamp_factor=None, mutation_threshold=None) fractions clamp_factor limits speed to a share of the search box (0.1–0.2 is typical); mutation_threshold restarts particles slower than it (0.001–0.01).
.mutation(strategy, *, scale, probability, application) see right Random jumps to escape local minima: strategy "gaussian" or "cauchy" (larger jumps), probability per iteration, application "global_best", "all_particles" or "below_average".
.improvement_stop(*, window, tol_abs, tol_rel) see Stop rules Stops when the best loss stops improving.
.radius_stop(tol) see Stop rules Stops when the swarm has gathered around its best position.

Stop rules

A swarm stops at max_iterations unless a stop rule holds first. The two rules come from the argmin fork; both are off until you set them, and neither has a default value. A run that stops by a rule reports converged=True and termination_reason Terminated(SolverConverged).

Rule Stops when Arguments
.improvement_stop(window=…, tol_abs=…, tol_rel=…) The best loss improved by at most tol_abs + tol_rel·|best loss| over the last window iterations window > 0; tol_abs, tol_rel ≥ 0
.radius_stop(tol) Every particle is within tol of the best position, as a fraction of the size of the search box (the length of its diagonal) (Zielinski and Laur, 2007) tol ≥ 0
stopping = ParticleSwarmConfig(particles=30, max_iterations=2000).improvement_stop(
    window=30, tol_abs=1e-12, tol_rel=1e-9
)
problem = CalibrationProblem(
    observed_data=observed_data,
    parameters=[
        CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0),
        CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.0, max_bound=1.0),
    ],
    loss_function="sse",
    optimization_config=stopping,
    seed=7,
)
result = Calibrator(Simulation(model(None, None)), problem).run()
print(result.converged, result.iterations < 2000)  # True True

A swarm can stall for some iterations and then improve again, so a short window can stop it early. Mutation and velocity mutation scatter the particles again, and unknowns that the data cannot tell apart keep them spread, so with them the radius can stay above tol. Neither rule draws random numbers: with the same seed, a run with a rule is the run without it up to the iteration where it stops. In a profile, the improvement rule also sets the tolerance of a swarm.

Several starts

Calibrator.run(starts=n) runs the optimizer from n start points in parallel and returns the best result. Start 0 is the initial guess of each parameter (its initial_guess, or the midpoint of its bounds). Start i > 0 is a row of a Latin hypercube over the bounds, drawn from the problem's seed: with n − 1 rows, each parameter has one start in each of n − 1 equal strata of its bounds, so the starts cover the parameter space (start_points in crates/commol-calibration/src/starts.rs). A particle swarm draws its own population; each start takes its own seed, also drawn from the problem's seed, so start 0 of run(starts=n) is the result of run(), with or without verbose.

A particle swarm also scores the particles of each iteration in parallel, in the order of the particles, so the result does not depend on the number of threads. commol.threads chooses the threads and whether the particles run in parallel: see Threads and Parallelism.

problem = CalibrationProblem(
    observed_data=observed_data,
    parameters=[
        CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0),
        CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.0, max_bound=1.0),
    ],
    loss_function="sse",
    optimization_config=NelderMeadConfig(max_iterations=2000),
    seed=7,
)
result = Calibrator(Simulation(model(None, None)), problem).run(starts=4)
print(len(result.starts), result.failed)  # 4 []
print(result.starts[0].start)  # {'k1': 0.5, 'k2': 0.5}
print({name: round(value, 2) for name, value in result.best_parameters.items()})  # {'k1': 0.4, 'k2': 0.1}
CalibrationResult field Meaning
starts One StartSummary per start that finished, in order: start, end, loss, converged
failed Each start that failed, "Start <index> failed: <reason>"; each is also logged as a warning (logging, logger commol.api.calibrator)

The result is the start of least loss, the first of them on a tie; if every start fails, run raises a RuntimeError with the reason of each start. Starts that end at distinct points with equal losses are a sign that the data do not identify the parameters; StartsIdentifiability names the parameters that differ (see Identifiability).

Reproducibility

Set CalibrationProblem(seed=...): the starts, a particle swarm and the replicates of a stochastic engine then make the same random choices on every run. Without a seed, the engine draws one when the calibrator is built; Calibrator.seed gives it and the calibrator logs it. The result keeps it in CalibrationResult.seed, and the sample of each uncertainty method in sample.method.settings["seed"]: set it as problem.seed to repeat the run, also from a result read back from JSON.

again = problem.model_copy(update={"seed": result.seed})
print(Calibrator(Simulation(model(None, None)), again).run(starts=4) == result)  # True

History of evaluations

Calibrator.run_with_history() optimizes from the initial guess and returns a CalibrationHistory: the result, and every evaluation of a feasible point the optimizer made (CalibrationEvaluation: its parameters and loss), in order. A point the objective penalizes is not kept.

Troubleshooting

Symptom Try
A result sits at a bound Check the bounds, give a better initial_guess, or use particle swarm
converged=False Check termination_reason: with MaxItersReached, raise max_iterations; a particle swarm needs a stop rule to converge
Different results on each run Set seed; use more particles or iterations
A local minimum run(starts=n) from several points
A good loss but implausible values Unknowns that the data cannot tell apart; fix one, or add observations
Slow Fewer observation steps or particles; Nelder-Mead from a good start; check commol.get_threads() (Threads and Parallelism)

Next steps

To learn Read
Relations the result must respect Constraints
Uncertainty Uncertainty