Calibration

STIsim provides a streamlined calibration framework designed for the practical realities of fitting HIV and STI models to data. It’s built on Starsim’s calibration tools and Optuna, but makes a number of opinionated choices so you can go from data to calibrated model with minimal boilerplate.

How it works (and what choices we’ve made)

STIsim’s calibration uses approximate Bayesian computation (ABC) – the idea is to find parameters that minimize the difference between model output and observed data. For each trial, Optuna proposes a set of parameters, the model runs, and we compute a goodness-of-fit (GOF) score. After many trials, the best-scoring parameter sets approximate the posterior distribution.

Specifically, sti.Calibration makes these choices for you:

  • GOF metric: Normalized absolute error between model and data, summed across all targets. You can weight targets differently (e.g., weight syphilis prevalence 10x higher than HIV incidence).
  • Parameter routing: Dot notation ('hiv.beta_m2f') automatically finds and sets the right module parameter – no custom build_fn needed.
  • Data format: A simple CSV/DataFrame with time (year) and columns matching sim result names.

This approach has been used extensively in HPVsim and Covasim and tends to perform well for STI/HIV fitting. But it’s not the only approach:

  • Want a different GOF metric? Pass a custom eval_fn to sti.Calibration (e.g., mean squared error, log-likelihood).
  • Want statistically rigorous components? Use Starsim’s CalibComponent system directly (Normal, BetaBinomial, etc.) – see the Starsim calibration tutorial.
  • Want full Bayesian inference? Starsim supports sampling-importance resampling – see the Starsim calibration tutorial.

The rest of this tutorial covers the streamlined STIsim workflow.

Setup: generate synthetic data

To demonstrate calibration without external data files, we’ll generate synthetic targets from a simulation with known “true” parameters. In practice, you’d load survey data (e.g., DHS, PHIA) instead.

import numpy as np
import pandas as pd
import sciris as sc
import starsim as ss
import stisim as sti

# "True" parameters we'll try to recover
true_beta = 0.08
true_condom = 0.6

# Create and run a sim with the true parameters
true_sim = sti.Sim(
    diseases=sti.Gonorrhea(beta_m2f=true_beta, eff_condom=true_condom),
    n_agents=2000, start=2010, stop=2030,
)
true_sim.run(verbose=0)

# Extract yearly prevalence and incidence as our "data"
df = true_sim.to_df(resample='year', use_years=True, sep='.')
data = df[['timevec', 'ng.prevalence', 'ng.new_infections']].dropna()
data = data.rename(columns={'timevec': 'time'})
data['time'] = data['time'].astype(int)
print(f'Generated {len(data)} data points')
data.head()
Initializing sim with 2000 agents
Generated 21 data points
time ng.prevalence ng.new_infections
0 2010 0.006135 29.0
1 2011 0.000875 6.0
2 2012 0.000967 5.0
3 2013 0.000636 1.0
4 2014 0.000000 0.0

Defining calibration parameters

STIsim uses dot notation to route parameters to the right module: 'module_name.par_name'. The module name is whatever sim.get_module() would find – typically the disease name (ng, hiv, syph), network name (structuredsexual), connector name (hiv_syph), or intervention name (art, fsw_testing).

You can write parameters in two equivalent formats:

# Nested format -- group parameters by module (Pythonic, uses keyword syntax)
calib_pars = dict(
    ng=dict(
        beta_m2f=  dict(low=0.03, high=0.15, guess=0.06),
        eff_condom=dict(low=0.3,  high=0.9,  guess=0.5),
    ),
)

# Flat format -- equivalent, uses string keys
calib_pars_flat = {
    'ng.beta_m2f':   dict(low=0.03, high=0.15, guess=0.06),
    'ng.eff_condom':  dict(low=0.3,  high=0.9,  guess=0.5),
}

# Both produce the same result after flattening
print(sti.flatten_calib_pars(calib_pars))
print(calib_pars_flat)
{'ng.beta_m2f': {'low': 0.03, 'high': 0.15, 'guess': 0.06}, 'ng.eff_condom': {'low': 0.3, 'high': 0.9, 'guess': 0.5}}
{'ng.beta_m2f': {'low': 0.03, 'high': 0.15, 'guess': 0.06}, 'ng.eff_condom': {'low': 0.3, 'high': 0.9, 'guess': 0.5}}

Each parameter spec requires low and high bounds. The optional guess is used as the starting point for the “before” comparison in check_fit().

The nested format is particularly convenient when calibrating multiple modules – parameters are visually grouped:

# Multi-module example (not run here -- just to show the pattern)
multi_pars = dict(
    hiv=dict(
        beta_m2f=dict(low=0.002, high=0.014, guess=0.006),
        eff_condom=dict(low=0.5, high=0.9, guess=0.75),
    ),
    structuredsexual=dict(
        prop_f0=dict(low=0.55, high=0.9, guess=0.7),
        m1_conc=dict(low=0.05, high=0.3, guess=0.15),
    ),
    syph=dict(
        beta_m2f=dict(low=0.15, high=0.35, guess=0.2),
    ),
)
print(f'{len(sti.flatten_calib_pars(multi_pars))} parameters across 3 modules')
5 parameters across 3 modules

Running a calibration

Create a Calibration with the sim, parameters, and data. No custom build_fn is needed – STIsim’s default automatically routes parameters using dot notation.

sim = sti.Sim(diseases=sti.Gonorrhea(), n_agents=500, start=2010, stop=2030, verbose=-1)

calib = sti.Calibration(
    sim=sim,
    calib_pars=calib_pars,
    data=data,
    total_trials=10,
    n_workers=1,
)
calib.calibrate()
print(f'Best parameters: {calib.best_pars}')
Removed existing calibration file /tmp/tmpl9d24xsn/starsim_calibration.db
sqlite:////tmp/tmpl9d24xsn/starsim_calibration.db
[I 2026-06-02 23:32:16,862] A new study created in RDB with name: starsim_calibration
Initializing sim with 500 agents

  Running 2010.01.01 ( 0/241) (0.00 s)  ———————————————————— 0%

  Running 2011.01.01 (12/241) (0.10 s)  •——————————————————— 5%

  Running 2012.01.01 (24/241) (0.18 s)  ••—————————————————— 10%

  Running 2013.01.01 (36/241) (0.27 s)  •••————————————————— 15%

  Running 2014.01.01 (48/241) (0.35 s)  ••••———————————————— 20%

  Running 2015.01.01 (60/241) (0.44 s)  •••••——————————————— 25%

  Running 2016.01.01 (72/241) (0.53 s)  ••••••—————————————— 30%

  Running 2017.01.01 (84/241) (0.62 s)  •••••••————————————— 35%

  Running 2018.01.01 (96/241) (0.70 s)  ••••••••———————————— 40%

  Running 2019.01.01 (108/241) (0.79 s)  •••••••••——————————— 45%

  Running 2020.01.01 (120/241) (0.87 s)  ••••••••••—————————— 50%

  Running 2021.01.01 (132/241) (0.96 s)  •••••••••••————————— 55%

  Running 2022.01.01 (144/241) (1.05 s)  ••••••••••••———————— 60%

  Running 2023.01.01 (156/241) (1.13 s)  •••••••••••••——————— 65%

  Running 2024.01.01 (168/241) (1.21 s)  ••••••••••••••—————— 70%

  Running 2025.01.01 (180/241) (1.30 s)  •••••••••••••••————— 75%

  Running 2026.01.01 (192/241) (1.39 s)  ••••••••••••••••———— 80%

  Running 2027.01.01 (204/241) (1.48 s)  •••••••••••••••••——— 85%

  Running 2028.01.01 (216/241) (1.57 s)  ••••••••••••••••••—— 90%

  Running 2029.01.01 (228/241) (1.65 s)  •••••••••••••••••••— 95%

  Running 2030.01.01 (240/241) (1.74 s)  •••••••••••••••••••• 100%
[I 2026-06-02 23:32:19,123] Trial 0 finished with value: 1.4910465267865964 and parameters: {'ng.beta_m2f': 0.10251816059421855, 'ng.eff_condom': 0.5607185744940981, 'rand_seed': 961016}. Best is trial 0 with value: 1.4910465267865964.
Initializing sim with 500 agents

  Running 2010.01.01 ( 0/241) (0.00 s)  ———————————————————— 0%

  Running 2011.01.01 (12/241) (0.09 s)  •——————————————————— 5%

  Running 2012.01.01 (24/241) (0.18 s)  ••—————————————————— 10%

  Running 2013.01.01 (36/241) (0.27 s)  •••————————————————— 15%

  Running 2014.01.01 (48/241) (0.35 s)  ••••———————————————— 20%

  Running 2015.01.01 (60/241) (0.44 s)  •••••——————————————— 25%

  Running 2016.01.01 (72/241) (0.53 s)  ••••••—————————————— 30%

  Running 2017.01.01 (84/241) (0.60 s)  •••••••————————————— 35%

  Running 2018.01.01 (96/241) (0.68 s)  ••••••••———————————— 40%

  Running 2019.01.01 (108/241) (0.77 s)  •••••••••——————————— 45%

  Running 2020.01.01 (120/241) (0.85 s)  ••••••••••—————————— 50%

  Running 2021.01.01 (132/241) (0.94 s)  •••••••••••————————— 55%

  Running 2022.01.01 (144/241) (1.02 s)  ••••••••••••———————— 60%

  Running 2023.01.01 (156/241) (1.11 s)  •••••••••••••——————— 65%

  Running 2024.01.01 (168/241) (1.20 s)  ••••••••••••••—————— 70%

  Running 2025.01.01 (180/241) (1.28 s)  •••••••••••••••————— 75%

  Running 2026.01.01 (192/241) (1.37 s)  ••••••••••••••••———— 80%

  Running 2027.01.01 (204/241) (1.46 s)  •••••••••••••••••——— 85%

  Running 2028.01.01 (216/241) (1.54 s)  ••••••••••••••••••—— 90%

  Running 2029.01.01 (228/241) (1.63 s)  •••••••••••••••••••— 95%

  Running 2030.01.01 (240/241) (1.71 s)  •••••••••••••••••••• 100%
[I 2026-06-02 23:32:21,308] Trial 1 finished with value: 1.863906498706706 and parameters: {'ng.beta_m2f': 0.09368343369406675, 'ng.eff_condom': 0.75635708097584, 'rand_seed': 590244}. Best is trial 0 with value: 1.4910465267865964.
Initializing sim with 500 agents

  Running 2010.01.01 ( 0/241) (0.00 s)  ———————————————————— 0%

  Running 2011.01.01 (12/241) (0.09 s)  •——————————————————— 5%

  Running 2012.01.01 (24/241) (0.18 s)  ••—————————————————— 10%

  Running 2013.01.01 (36/241) (0.28 s)  •••————————————————— 15%

  Running 2014.01.01 (48/241) (0.37 s)  ••••———————————————— 20%

  Running 2015.01.01 (60/241) (0.46 s)  •••••——————————————— 25%

  Running 2016.01.01 (72/241) (0.55 s)  ••••••—————————————— 30%

  Running 2017.01.01 (84/241) (0.63 s)  •••••••————————————— 35%

  Running 2018.01.01 (96/241) (0.72 s)  ••••••••———————————— 40%

  Running 2019.01.01 (108/241) (0.81 s)  •••••••••——————————— 45%

  Running 2020.01.01 (120/241) (0.89 s)  ••••••••••—————————— 50%

  Running 2021.01.01 (132/241) (0.98 s)  •••••••••••————————— 55%

  Running 2022.01.01 (144/241) (1.07 s)  ••••••••••••———————— 60%

  Running 2023.01.01 (156/241) (1.15 s)  •••••••••••••——————— 65%

  Running 2024.01.01 (168/241) (1.24 s)  ••••••••••••••—————— 70%

  Running 2025.01.01 (180/241) (1.33 s)  •••••••••••••••————— 75%

  Running 2026.01.01 (192/241) (1.41 s)  ••••••••••••••••———— 80%

  Running 2027.01.01 (204/241) (1.50 s)  •••••••••••••••••——— 85%

  Running 2028.01.01 (216/241) (1.59 s)  ••••••••••••••••••—— 90%

  Running 2029.01.01 (228/241) (1.67 s)  •••••••••••••••••••— 95%

  Running 2030.01.01 (240/241) (1.76 s)  •••••••••••••••••••• 100%
[I 2026-06-02 23:32:23,541] Trial 2 finished with value: 1.7280241695281262 and parameters: {'ng.beta_m2f': 0.14913953559381637, 'ng.eff_condom': 0.434773467322249, 'rand_seed': 128231}. Best is trial 0 with value: 1.4910465267865964.
Initializing sim with 500 agents

  Running 2010.01.01 ( 0/241) (0.00 s)  ———————————————————— 0%

  Running 2011.01.01 (12/241) (0.09 s)  •——————————————————— 5%

  Running 2012.01.01 (24/241) (0.18 s)  ••—————————————————— 10%

  Running 2013.01.01 (36/241) (0.27 s)  •••————————————————— 15%

  Running 2014.01.01 (48/241) (0.35 s)  ••••———————————————— 20%

  Running 2015.01.01 (60/241) (0.44 s)  •••••——————————————— 25%

  Running 2016.01.01 (72/241) (0.52 s)  ••••••—————————————— 30%

  Running 2017.01.01 (84/241) (0.60 s)  •••••••————————————— 35%

  Running 2018.01.01 (96/241) (0.69 s)  ••••••••———————————— 40%

  Running 2019.01.01 (108/241) (0.77 s)  •••••••••——————————— 45%

  Running 2020.01.01 (120/241) (0.85 s)  ••••••••••—————————— 50%

  Running 2021.01.01 (132/241) (0.93 s)  •••••••••••————————— 55%

  Running 2022.01.01 (144/241) (1.02 s)  ••••••••••••———————— 60%

  Running 2023.01.01 (156/241) (1.11 s)  •••••••••••••——————— 65%

  Running 2024.01.01 (168/241) (1.20 s)  ••••••••••••••—————— 70%

  Running 2025.01.01 (180/241) (1.28 s)  •••••••••••••••————— 75%

  Running 2026.01.01 (192/241) (1.36 s)  ••••••••••••••••———— 80%

  Running 2027.01.01 (204/241) (1.45 s)  •••••••••••••••••——— 85%

  Running 2028.01.01 (216/241) (1.54 s)  ••••••••••••••••••—— 90%

  Running 2029.01.01 (228/241) (1.62 s)  •••••••••••••••••••— 95%

  Running 2030.01.01 (240/241) (1.71 s)  •••••••••••••••••••• 100%
[I 2026-06-02 23:32:25,898] Trial 3 finished with value: 1.8635611644369936 and parameters: {'ng.beta_m2f': 0.11123781436987834, 'ng.eff_condom': 0.5037641310352641, 'rand_seed': 731342}. Best is trial 0 with value: 1.4910465267865964.
Initializing sim with 500 agents

  Running 2010.01.01 ( 0/241) (0.00 s)  ———————————————————— 0%

  Running 2011.01.01 (12/241) (0.10 s)  •——————————————————— 5%

  Running 2012.01.01 (24/241) (0.19 s)  ••—————————————————— 10%

  Running 2013.01.01 (36/241) (0.27 s)  •••————————————————— 15%

  Running 2014.01.01 (48/241) (0.35 s)  ••••———————————————— 20%

  Running 2015.01.01 (60/241) (0.43 s)  •••••——————————————— 25%

  Running 2016.01.01 (72/241) (0.51 s)  ••••••—————————————— 30%

  Running 2017.01.01 (84/241) (0.59 s)  •••••••————————————— 35%

  Running 2018.01.01 (96/241) (0.66 s)  ••••••••———————————— 40%

  Running 2019.01.01 (108/241) (0.74 s)  •••••••••——————————— 45%

  Running 2020.01.01 (120/241) (0.82 s)  ••••••••••—————————— 50%

  Running 2021.01.01 (132/241) (0.90 s)  •••••••••••————————— 55%

  Running 2022.01.01 (144/241) (0.98 s)  ••••••••••••———————— 60%

  Running 2023.01.01 (156/241) (1.06 s)  •••••••••••••——————— 65%

  Running 2024.01.01 (168/241) (1.15 s)  ••••••••••••••—————— 70%

  Running 2025.01.01 (180/241) (1.23 s)  •••••••••••••••————— 75%

  Running 2026.01.01 (192/241) (1.32 s)  ••••••••••••••••———— 80%

  Running 2027.01.01 (204/241) (1.40 s)  •••••••••••••••••——— 85%

  Running 2028.01.01 (216/241) (1.49 s)  ••••••••••••••••••—— 90%

  Running 2029.01.01 (228/241) (1.57 s)  •••••••••••••••••••— 95%

  Running 2030.01.01 (240/241) (1.65 s)  •••••••••••••••••••• 100%
[I 2026-06-02 23:32:28,026] Trial 4 finished with value: 3.0828251643035047 and parameters: {'ng.beta_m2f': 0.1165736002138217, 'ng.eff_condom': 0.5663437268090492, 'rand_seed': 541772}. Best is trial 0 with value: 1.4910465267865964.
Initializing sim with 500 agents

  Running 2010.01.01 ( 0/241) (0.00 s)  ———————————————————— 0%

  Running 2011.01.01 (12/241) (0.09 s)  •——————————————————— 5%

  Running 2012.01.01 (24/241) (0.17 s)  ••—————————————————— 10%

  Running 2013.01.01 (36/241) (0.26 s)  •••————————————————— 15%

  Running 2014.01.01 (48/241) (0.34 s)  ••••———————————————— 20%

  Running 2015.01.01 (60/241) (0.42 s)  •••••——————————————— 25%

  Running 2016.01.01 (72/241) (0.51 s)  ••••••—————————————— 30%

  Running 2017.01.01 (84/241) (0.59 s)  •••••••————————————— 35%

  Running 2018.01.01 (96/241) (0.68 s)  ••••••••———————————— 40%

  Running 2019.01.01 (108/241) (0.76 s)  •••••••••——————————— 45%

  Running 2020.01.01 (120/241) (0.85 s)  ••••••••••—————————— 50%

  Running 2021.01.01 (132/241) (0.93 s)  •••••••••••————————— 55%

  Running 2022.01.01 (144/241) (1.02 s)  ••••••••••••———————— 60%

  Running 2023.01.01 (156/241) (1.10 s)  •••••••••••••——————— 65%

  Running 2024.01.01 (168/241) (1.19 s)  ••••••••••••••—————— 70%

  Running 2025.01.01 (180/241) (1.27 s)  •••••••••••••••————— 75%

  Running 2026.01.01 (192/241) (1.36 s)  ••••••••••••••••———— 80%

  Running 2027.01.01 (204/241) (1.44 s)  •••••••••••••••••——— 85%
[I 2026-06-02 23:32:30,189] Trial 5 finished with value: 2.128740391530909 and parameters: {'ng.beta_m2f': 0.045684950163676505, 'ng.eff_condom': 0.47036775974681844, 'rand_seed': 389564}. Best is trial 0 with value: 1.4910465267865964.

  Running 2028.01.01 (216/241) (1.53 s)  ••••••••••••••••••—— 90%

  Running 2029.01.01 (228/241) (1.61 s)  •••••••••••••••••••— 95%

  Running 2030.01.01 (240/241) (1.69 s)  •••••••••••••••••••• 100%

Initializing sim with 500 agents

  Running 2010.01.01 ( 0/241) (0.00 s)  ———————————————————— 0%

  Running 2011.01.01 (12/241) (0.10 s)  •——————————————————— 5%

  Running 2012.01.01 (24/241) (0.18 s)  ••—————————————————— 10%

  Running 2013.01.01 (36/241) (0.27 s)  •••————————————————— 15%

  Running 2014.01.01 (48/241) (0.35 s)  ••••———————————————— 20%

  Running 2015.01.01 (60/241) (0.44 s)  •••••——————————————— 25%

  Running 2016.01.01 (72/241) (0.53 s)  ••••••—————————————— 30%

  Running 2017.01.01 (84/241) (0.61 s)  •••••••————————————— 35%

  Running 2018.01.01 (96/241) (0.69 s)  ••••••••———————————— 40%

  Running 2019.01.01 (108/241) (0.77 s)  •••••••••——————————— 45%

  Running 2020.01.01 (120/241) (0.86 s)  ••••••••••—————————— 50%

  Running 2021.01.01 (132/241) (0.94 s)  •••••••••••————————— 55%

  Running 2022.01.01 (144/241) (1.03 s)  ••••••••••••———————— 60%

  Running 2023.01.01 (156/241) (1.11 s)  •••••••••••••——————— 65%

  Running 2024.01.01 (168/241) (1.19 s)  ••••••••••••••—————— 70%

  Running 2025.01.01 (180/241) (1.28 s)  •••••••••••••••————— 75%

  Running 2026.01.01 (192/241) (1.36 s)  ••••••••••••••••———— 80%

  Running 2027.01.01 (204/241) (1.45 s)  •••••••••••••••••——— 85%
[I 2026-06-02 23:32:32,364] Trial 6 finished with value: 1.5660903117423164 and parameters: {'ng.beta_m2f': 0.12201806169360886, 'ng.eff_condom': 0.38270601140373056, 'rand_seed': 554254}. Best is trial 0 with value: 1.4910465267865964.

  Running 2028.01.01 (216/241) (1.54 s)  ••••••••••••••••••—— 90%

  Running 2029.01.01 (228/241) (1.63 s)  •••••••••••••••••••— 95%

  Running 2030.01.01 (240/241) (1.71 s)  •••••••••••••••••••• 100%

Initializing sim with 500 agents

  Running 2010.01.01 ( 0/241) (0.00 s)  ———————————————————— 0%

  Running 2011.01.01 (12/241) (0.11 s)  •——————————————————— 5%

  Running 2012.01.01 (24/241) (0.21 s)  ••—————————————————— 10%

  Running 2013.01.01 (36/241) (0.31 s)  •••————————————————— 15%

  Running 2014.01.01 (48/241) (0.42 s)  ••••———————————————— 20%

  Running 2015.01.01 (60/241) (0.52 s)  •••••——————————————— 25%

  Running 2016.01.01 (72/241) (0.62 s)  ••••••—————————————— 30%

  Running 2017.01.01 (84/241) (0.71 s)  •••••••————————————— 35%

  Running 2018.01.01 (96/241) (0.81 s)  ••••••••———————————— 40%

  Running 2019.01.01 (108/241) (0.91 s)  •••••••••——————————— 45%

  Running 2020.01.01 (120/241) (0.99 s)  ••••••••••—————————— 50%

  Running 2021.01.01 (132/241) (1.08 s)  •••••••••••————————— 55%

  Running 2022.01.01 (144/241) (1.16 s)  ••••••••••••———————— 60%

  Running 2023.01.01 (156/241) (1.24 s)  •••••••••••••——————— 65%

  Running 2024.01.01 (168/241) (1.33 s)  ••••••••••••••—————— 70%

  Running 2025.01.01 (180/241) (1.42 s)  •••••••••••••••————— 75%

  Running 2026.01.01 (192/241) (1.50 s)  ••••••••••••••••———— 80%

  Running 2027.01.01 (204/241) (1.59 s)  •••••••••••••••••——— 85%

  Running 2028.01.01 (216/241) (1.68 s)  ••••••••••••••••••—— 90%

  Running 2029.01.01 (228/241) (1.76 s)  •••••••••••••••••••— 95%
[I 2026-06-02 23:32:34,684] Trial 7 finished with value: 37.3880360747542 and parameters: {'ng.beta_m2f': 0.1180075156208644, 'ng.eff_condom': 0.5652517579087708, 'rand_seed': 48288}. Best is trial 0 with value: 1.4910465267865964.

  Running 2030.01.01 (240/241) (1.85 s)  •••••••••••••••••••• 100%

Initializing sim with 500 agents

  Running 2010.01.01 ( 0/241) (0.00 s)  ———————————————————— 0%

  Running 2011.01.01 (12/241) (0.09 s)  •——————————————————— 5%

  Running 2012.01.01 (24/241) (0.17 s)  ••—————————————————— 10%

  Running 2013.01.01 (36/241) (0.26 s)  •••————————————————— 15%

  Running 2014.01.01 (48/241) (0.34 s)  ••••———————————————— 20%

  Running 2015.01.01 (60/241) (0.42 s)  •••••——————————————— 25%

  Running 2016.01.01 (72/241) (0.50 s)  ••••••—————————————— 30%

  Running 2017.01.01 (84/241) (0.59 s)  •••••••————————————— 35%

  Running 2018.01.01 (96/241) (0.67 s)  ••••••••———————————— 40%

  Running 2019.01.01 (108/241) (0.76 s)  •••••••••——————————— 45%

  Running 2020.01.01 (120/241) (0.84 s)  ••••••••••—————————— 50%

  Running 2021.01.01 (132/241) (0.92 s)  •••••••••••————————— 55%

  Running 2022.01.01 (144/241) (1.01 s)  ••••••••••••———————— 60%

  Running 2023.01.01 (156/241) (1.09 s)  •••••••••••••——————— 65%

  Running 2024.01.01 (168/241) (1.17 s)  ••••••••••••••—————— 70%

  Running 2025.01.01 (180/241) (1.26 s)  •••••••••••••••————— 75%

  Running 2026.01.01 (192/241) (1.34 s)  ••••••••••••••••———— 80%

  Running 2027.01.01 (204/241) (1.42 s)  •••••••••••••••••——— 85%

  Running 2028.01.01 (216/241) (1.50 s)  ••••••••••••••••••—— 90%

  Running 2029.01.01 (228/241) (1.58 s)  •••••••••••••••••••— 95%

  Running 2030.01.01 (240/241) (1.67 s)  •••••••••••••••••••• 100%
[I 2026-06-02 23:32:36,819] Trial 8 finished with value: 2.530819646729296 and parameters: {'ng.beta_m2f': 0.09438421528387364, 'ng.eff_condom': 0.6437289674206381, 'rand_seed': 888645}. Best is trial 0 with value: 1.4910465267865964.
Initializing sim with 500 agents

  Running 2010.01.01 ( 0/241) (0.00 s)  ———————————————————— 0%

  Running 2011.01.01 (12/241) (0.09 s)  •——————————————————— 5%

  Running 2012.01.01 (24/241) (0.18 s)  ••—————————————————— 10%

  Running 2013.01.01 (36/241) (0.27 s)  •••————————————————— 15%

  Running 2014.01.01 (48/241) (0.36 s)  ••••———————————————— 20%

  Running 2015.01.01 (60/241) (0.44 s)  •••••——————————————— 25%

  Running 2016.01.01 (72/241) (0.52 s)  ••••••—————————————— 30%

  Running 2017.01.01 (84/241) (0.59 s)  •••••••————————————— 35%

  Running 2018.01.01 (96/241) (0.67 s)  ••••••••———————————— 40%

  Running 2019.01.01 (108/241) (0.75 s)  •••••••••——————————— 45%

  Running 2020.01.01 (120/241) (0.83 s)  ••••••••••—————————— 50%

  Running 2021.01.01 (132/241) (0.91 s)  •••••••••••————————— 55%

  Running 2022.01.01 (144/241) (0.99 s)  ••••••••••••———————— 60%

  Running 2023.01.01 (156/241) (1.08 s)  •••••••••••••——————— 65%

  Running 2024.01.01 (168/241) (1.16 s)  ••••••••••••••—————— 70%

  Running 2025.01.01 (180/241) (1.24 s)  •••••••••••••••————— 75%

  Running 2026.01.01 (192/241) (1.32 s)  ••••••••••••••••———— 80%

  Running 2027.01.01 (204/241) (1.40 s)  •••••••••••••••••——— 85%

  Running 2028.01.01 (216/241) (1.49 s)  ••••••••••••••••••—— 90%

  Running 2029.01.01 (228/241) (1.58 s)  •••••••••••••••••••— 95%

  Running 2030.01.01 (240/241) (1.66 s)  •••••••••••••••••••• 100%
[I 2026-06-02 23:32:38,943] Trial 9 finished with value: 3.3847240334404916 and parameters: {'ng.beta_m2f': 0.11655917334234814, 'ng.eff_condom': 0.5088756830982373, 'rand_seed': 969242}. Best is trial 0 with value: 1.4910465267865964.
Making results structure...
Processed 10 trials; 0 failed
Best pars: {'ng.beta_m2f': 0.10251816059421855, 'ng.eff_condom': 0.5607185744940981, 'rand_seed': 961016}
Removed existing calibration file /tmp/tmpl9d24xsn/starsim_calibration.db
Best parameters: {'ng.beta_m2f': 0.10251816059421855, 'ng.eff_condom': 0.5607185744940981, 'rand_seed': 961016}

The calibration found parameters that minimize the mismatch between model output and data. Let’s see how they compare to the true values:

true_pars = {'ng.beta_m2f': true_beta, 'ng.eff_condom': true_condom}
for par, true_val in true_pars.items():
    best_val = calib.best_pars[par]
    print(f'{par}: true={true_val:.3f}, calibrated={best_val:.3f}')
ng.beta_m2f: true=0.080, calibrated=0.103
ng.eff_condom: true=0.600, calibrated=0.561

Visualizing the calibration

STIsim inherits several diagnostic tools from Starsim for inspecting calibration results:

  • check_fit() runs sims with the guess parameters and the best-fit parameters side by side, comparing GOF scores
  • plot_final() runs the best-fit parameters and plots the resulting sim
  • plot_optuna() shows Optuna diagnostic plots (optimization history, parameter importance, etc.)

Note: plot_param_importances requires scikit-learn (pip install scikit-learn). If it’s not installed, the plot will be skipped with a warning.

# Compare GOF scores: guess parameters vs best-fit parameters
calib.check_fit(do_plot=False)


Checking fit...

Initializing sim with 500 agents

Initializing sim with 500 agents


  Running "Sim 0": 2010.01.01 ( 0/241) (0.00 s)  ———————————————————— 0%



  Running "Sim 0": 2011.01.01 (12/241) (0.11 s)  •——————————————————— 5%



  Running "Sim 0": 2012.01.01 (24/241) (0.20 s)  ••—————————————————— 10%



  Running "Sim 0": 2013.01.01 (36/241) (0.29 s)  •••————————————————— 15%



  Running "Sim 0": 2010.01.01 ( 0/241) (0.00 s)  ———————————————————— 0%



  Running "Sim 0": 2014.01.01 (48/241) (0.37 s)  ••••———————————————— 20%



  Running "Sim 0": 2011.01.01 (12/241) (0.11 s)  •——————————————————— 5%



  Running "Sim 0": 2015.01.01 (60/241) (0.47 s)  •••••——————————————— 25%



  Running "Sim 0": 2012.01.01 (24/241) (0.20 s)  ••—————————————————— 10%



  Running "Sim 0": 2016.01.01 (72/241) (0.55 s)  ••••••—————————————— 30%



  Running "Sim 0": 2013.01.01 (36/241) (0.29 s)  •••————————————————— 15%



  Running "Sim 0": 2017.01.01 (84/241) (0.64 s)  •••••••————————————— 35%



  Running "Sim 0": 2014.01.01 (48/241) (0.39 s)  ••••———————————————— 20%



  Running "Sim 0": 2018.01.01 (96/241) (0.73 s)  ••••••••———————————— 40%



  Running "Sim 0": 2015.01.01 (60/241) (0.47 s)  •••••——————————————— 25%



  Running "Sim 0": 2019.01.01 (108/241) (0.83 s)  •••••••••——————————— 45%



  Running "Sim 0": 2016.01.01 (72/241) (0.56 s)  ••••••—————————————— 30%



  Running "Sim 0": 2020.01.01 (120/241) (0.92 s)  ••••••••••—————————— 50%



  Running "Sim 0": 2017.01.01 (84/241) (0.64 s)  •••••••————————————— 35%



  Running "Sim 0": 2021.01.01 (132/241) (1.01 s)  •••••••••••————————— 55%



  Running "Sim 0": 2018.01.01 (96/241) (0.73 s)  ••••••••———————————— 40%



  Running "Sim 0": 2022.01.01 (144/241) (1.11 s)  ••••••••••••———————— 60%



  Running "Sim 0": 2019.01.01 (108/241) (0.84 s)  •••••••••——————————— 45%



  Running "Sim 0": 2023.01.01 (156/241) (1.22 s)  •••••••••••••——————— 65%



  Running "Sim 0": 2020.01.01 (120/241) (0.93 s)  ••••••••••—————————— 50%



  Running "Sim 0": 2024.01.01 (168/241) (1.31 s)  ••••••••••••••—————— 70%



  Running "Sim 0": 2021.01.01 (132/241) (1.03 s)  •••••••••••————————— 55%



  Running "Sim 0": 2025.01.01 (180/241) (1.40 s)  •••••••••••••••————— 75%



  Running "Sim 0": 2022.01.01 (144/241) (1.12 s)  ••••••••••••———————— 60%



  Running "Sim 0": 2026.01.01 (192/241) (1.50 s)  ••••••••••••••••———— 80%



  Running "Sim 0": 2023.01.01 (156/241) (1.22 s)  •••••••••••••——————— 65%



  Running "Sim 0": 2027.01.01 (204/241) (1.58 s)  •••••••••••••••••——— 85%



  Running "Sim 0": 2024.01.01 (168/241) (1.31 s)  ••••••••••••••—————— 70%



  Running "Sim 0": 2028.01.01 (216/241) (1.67 s)  ••••••••••••••••••—— 90%



  Running "Sim 0": 2025.01.01 (180/241) (1.40 s)  •••••••••••••••————— 75%



  Running "Sim 0": 2029.01.01 (228/241) (1.76 s)  •••••••••••••••••••— 95%



  Running "Sim 0": 2026.01.01 (192/241) (1.49 s)  ••••••••••••••••———— 80%



  Running "Sim 0": 2030.01.01 (240/241) (1.85 s)  •••••••••••••••••••• 100%





  Running "Sim 0": 2027.01.01 (204/241) (1.58 s)  •••••••••••••••••——— 85%



  Running "Sim 0": 2028.01.01 (216/241) (1.67 s)  ••••••••••••••••••—— 90%



  Running "Sim 0": 2029.01.01 (228/241) (1.77 s)  •••••••••••••••••••— 95%



  Running "Sim 0": 2030.01.01 (240/241) (1.87 s)  •••••••••••••••••••• 100%




Fit with original pars: 2.1779832305223517

Fit with best-fit pars: 2.2511581725027923

✗ Calibration did not improve fit as the objective got worse (2.1779832305223517 --> 2.2511581725027923), but this sometimes happens stochastically and is not necessarily an error
False
# Plot the sim with best-fit parameters
calib.plot_final()
Initializing sim with 500 agents

  Running 2010.01.01 ( 0/241) (0.00 s)  ———————————————————— 0%

  Running 2011.01.01 (12/241) (0.09 s)  •——————————————————— 5%

  Running 2012.01.01 (24/241) (0.18 s)  ••—————————————————— 10%

  Running 2013.01.01 (36/241) (0.26 s)  •••————————————————— 15%

  Running 2014.01.01 (48/241) (0.34 s)  ••••———————————————— 20%

  Running 2015.01.01 (60/241) (0.42 s)  •••••——————————————— 25%

  Running 2016.01.01 (72/241) (0.50 s)  ••••••—————————————— 30%

  Running 2017.01.01 (84/241) (0.58 s)  •••••••————————————— 35%

  Running 2018.01.01 (96/241) (0.66 s)  ••••••••———————————— 40%

  Running 2019.01.01 (108/241) (0.74 s)  •••••••••——————————— 45%

  Running 2020.01.01 (120/241) (0.82 s)  ••••••••••—————————— 50%

  Running 2021.01.01 (132/241) (0.91 s)  •••••••••••————————— 55%

  Running 2022.01.01 (144/241) (1.00 s)  ••••••••••••———————— 60%

  Running 2023.01.01 (156/241) (1.09 s)  •••••••••••••——————— 65%

  Running 2024.01.01 (168/241) (1.17 s)  ••••••••••••••—————— 70%

  Running 2025.01.01 (180/241) (1.26 s)  •••••••••••••••————— 75%

  Running 2026.01.01 (192/241) (1.35 s)  ••••••••••••••••———— 80%

  Running 2027.01.01 (204/241) (1.43 s)  •••••••••••••••••——— 85%

  Running 2028.01.01 (216/241) (1.51 s)  ••••••••••••••••••—— 90%

  Running 2029.01.01 (228/241) (1.60 s)  •••••••••••••••••••— 95%

  Running 2030.01.01 (240/241) (1.68 s)  •••••••••••••••••••• 100%

# Optuna diagnostics: optimization history and parameter importance
calib.plot_optuna(['plot_optimization_history', 'plot_param_importances'])
Could not run plot_param_importances: Tried to import 'sklearn' but failed. Please make sure that the package is installed correctly to use this feature. Actual error: No module named 'sklearn'.
/opt/hostedtoolcache/Python/3.13.13/x64/lib/python3.13/site-packages/starsim/calibration.py:444: ExperimentalWarning: optuna.visualization.matplotlib._optimization_history.plot_optimization_history is experimental (supported from v2.2.0). The interface can change in the future.
  fig = getattr(vis, method)(self.study)
/opt/hostedtoolcache/Python/3.13.13/x64/lib/python3.13/site-packages/starsim/calibration.py:444: ExperimentalWarning: optuna.visualization.matplotlib._param_importances.plot_param_importances is experimental (supported from v2.2.0). The interface can change in the future.
  fig = getattr(vis, method)(self.study)

With only 10 trials and 500 agents, recovery won’t be perfect. In practice, you’d use 1000-2000 trials with 5000-10000 agents.

Extracting calibrated parameters

After calibration, use get_pars() to extract the top parameter sets as flat dicts ready to feed back into a sim:

# Get top 3 parameter sets (sorted by mismatch)
par_sets = calib.get_pars(n=3)
for i, pars in enumerate(par_sets):
    print(f'Set {i}: {pars}')
Set 0: {'ng.beta_m2f': 0.10251816059421855, 'ng.eff_condom': 0.5607185744940981}
Set 1: {'ng.beta_m2f': 0.12201806169360886, 'ng.eff_condom': 0.38270601140373056}
Set 2: {'ng.beta_m2f': 0.14913953559381637, 'ng.eff_condom': 0.434773467322249}

You can also access the full results DataFrame via calib.df, which includes all parameters plus the mismatch value for every trial.

Running calibrated sims with make_calib_sims

The most common post-calibration task is running the top N parameter sets to generate results with uncertainty. make_calib_sims() handles this in one call:

# Run top 3 parameter sets
msim = sti.make_calib_sims(calib=calib, n_parsets=3, verbose=-1)
print(f'Ran {len(msim.sims)} simulations')
print(f'Each sim has par_idx: {[s.par_idx for s in msim.sims]}')
Initializing sim "Sim 1" with 500 agents
Initializing sim "Sim 0" with 500 agents
Initializing sim "Sim 2" with 500 agents
Ran 3 simulations
Each sim has par_idx: [0, 1, 2]

You can also pass calibration parameters directly – useful when loading saved results:

# From a list of parameter dicts
msim = sti.make_calib_sims(
    calib_pars=par_sets,
    sim=sti.Sim(diseases=sti.Gonorrhea(), n_agents=500, start=2010, stop=2030, verbose=-1),
    verbose=-1,
)

# From a DataFrame (like calib.df)
msim = sti.make_calib_sims(
    calib_pars=calib.df,
    sim=sti.Sim(diseases=sti.Gonorrhea(), n_agents=500, start=2010, stop=2030, verbose=-1),
    n_parsets=3, verbose=-1,
)
Initializing sim "Sim 0" with 500 agents
Initializing sim "Sim 1" with 500 agents
Initializing sim "Sim 2" with 500 agents
Initializing sim "Sim 0" with 500 agents
Initializing sim "Sim 1" with 500 agents
Initializing sim "Sim 2" with 500 agents

Filtering with check_fn

Some parameter combinations may produce epidemiologically implausible results (e.g., disease die-out). Pass a check_fn to filter:

def check_ng_alive(sim):
    """Reject sims where gonorrhea died out."""
    return float(sim.results.ng.new_infections[-12:].sum()) > 0

msim = sti.make_calib_sims(
    calib=calib, n_parsets=3,
    check_fn=check_ng_alive, verbose=-1,
)
print(f'Kept {len(msim.sims)} sims after filtering')
Initializing sim "Sim 1" with 500 agents
Initializing sim "Sim 0" with 500 agents
Initializing sim "Sim 2" with 500 agents
Dropped 3/3 sims via check_fn
Kept 0 sims after filtering

Multiple seeds per parameter set

For stochastic robustness, run each parameter set with multiple random seeds. When combined with check_fn, only the first surviving seed per parameter set is kept:

msim = sti.make_calib_sims(
    calib=calib, n_parsets=3,
    seeds_per_par=2,
    check_fn=check_ng_alive, verbose=-1,
)
print(f'Kept {len(msim.sims)} sims (1 per par set, best surviving seed)')
Initializing sim "Sim 0" with 500 agents
Initializing sim "Sim 1" with 500 agents
Initializing sim "Sim 2" with 500 agents
Initializing sim "Sim 3" with 500 agents
Initializing sim "Sim 4" with 500 agents
Initializing sim "Sim 5" with 500 agents
Dropped 6/6 sims via check_fn
Kept 0 sims (1 per par set, best surviving seed)

Saving and loading

After calibration, use calib.save() to save the calibration object and parameter DataFrame. This handles shrinking (keeping only the top results to reduce file size) automatically:

# Save with shrinking (keeps top 10% by default)
calib.save('tutorial_output/my_calib.obj')

# Load back and use
loaded = sc.load('tutorial_output/my_calib.obj')
print(f'Loaded calibration with {len(loaded.df)} parameter sets')
Saved calibration to tutorial_output/my_calib.obj
Saved parameters to tutorial_output/my_calib_pars.df
Loaded calibration with 10 parameter sets

Fitting to any target: if you can measure it, you can fit to it

The key idea behind STIsim’s calibration is simple: any result that appears in sim.to_df() can be a calibration target. You just need to include a column with the same name in your data file.

Built-in disease results (prevalence, incidence, etc.) are always available. But what if you need something custom – say, HIV-syphilis coinfection prevalence among adolescent girls? Any module (analyzers, interventions, connectors, etc.) can define custom results using define_results – see Custom results for the full explanation. Add the result to your sim, add a matching column to your data, and the calibration fits to it automatically.

Here’s an example using an analyzer:

class CoinfectionPrev(ss.Analyzer):
    """Measure HIV-syphilis coinfection prevalence among 15-24 year old females."""

    def init_pre(self, sim):
        super().init_pre(sim)
        self.define_results(
            ss.Result('coinf_prev_f_15_24', dtype=float, scale=False, label='Coinfection prev (F 15-24)'),
        )

    def step(self):
        ppl = self.sim.people                                                    # Get the people object
        target = ppl.female & (ppl.age >= 15) & (ppl.age < 25)                  # Boolean mask: alive females aged 15-24
        n_target = target.count()                                                # Count how many match
        if n_target > 0:
            hiv = self.sim.diseases.hiv.infected                                 # Boolean: who has HIV
            syph = self.sim.diseases.syph.infected                               # Boolean: who has syphilis
            has_both = hiv & syph & target                                       # Intersection: coinfected in target group
            self.results['coinf_prev_f_15_24'][self.ti] = has_both.count() / n_target  # Store as prevalence

Now include this analyzer when creating your sim, and add the corresponding column to your data file:

# Add the analyzer to your sim
sim = make_sim(analyzers=[CoinfectionPrev()])

# Your data CSV just needs a matching column:
#
# time, hiv.prevalence, syph.active_prevalence, coinfectionprev.coinf_prev_f_15_24
# 2016, 0.12,           0.03,                   0.005
# 2020, 0.11,           0.025,                  0.004

The column name follows the same dot notation: analyzer_name.result_name. When the calibration runs, it will automatically compare the model’s coinfection prevalence against your data, alongside the other targets. No changes to the calibration code are needed – just add the column to the data and the analyzer to the sim.

This pattern works for any custom quantity: age-specific prevalence, risk-group-stratified incidence, treatment coverage by pathway, etc. If you can compute it in an analyzer, you can fit to it.

Data format and weights

The calibration expects a DataFrame (or CSV) with a time column (integer years) and columns matching simulation result names:

time hiv.prevalence syph.active_prevalence hiv.n_on_art
2010 0.12 0.03 50000
2015 0.11 0.025 120000
2020 0.098 0.02 180000

Missing years are fine – the calibration only compares at timepoints where data exists. To see what result names are available, run sim.to_df().columns.

Not all targets are equally informative. Use weights to tell the calibration which data points matter most – for example, if you have high-quality survey data for syphilis but only routine program data for HIV:

weights = {
    'hiv.prevalence': 2.0,              # Routine data -- moderate weight
    'syph.active_prevalence': 10.0,     # PHIA survey -- high weight
    'hiv.n_on_art': 1.0,                # Program data -- default weight
}

Typical production workflow

A complete calibration analysis typically has three scripts:

# 1. run_calibrations.py -- find best parameters
sim = make_sim(verbose=-1)
data = pd.read_csv('data/calibration_targets.csv')

calib = sti.Calibration(
    sim=sim,
    calib_pars=dict(
        hiv=dict(beta_m2f=dict(low=0.002, high=0.014, guess=0.006)),
        structuredsexual=dict(prop_f0=dict(low=0.55, high=0.9, guess=0.7)),
    ),
    data=data,
    weights={'hiv.prevalence': 5.0},
    total_trials=2000,
)
calib.calibrate()
calib.save('results/calib.obj')
# 2. run_msim.py -- run top parameter sets with full results
pars_df = sc.load('results/calib_pars.df')
msim = sti.make_calib_sims(
    calib_pars=pars_df, sim=make_sim(), n_parsets=200,
)
# 3. run_scenarios.py -- compare interventions
for scenario in ['baseline', 'intervention']:
    msim = sti.make_calib_sims(
        calib_pars=pars_df, sim=make_sim(scenario=scenario),
        n_parsets=10, seeds_per_par=5,
    )

Further reading