import numpy as np
import pandas as pd
import sciris as sc
import starsim as ss
import stisim as sti
import hivsim
import matplotlib.pyplot as plt
sc.options(dpi=110)
np.random.seed(0)Time-varying age at sexual debut
New in v1.5.4
Age at first sex (AFS) has changed markedly in many settings — in Sub-Saharan Africa, for example, DHS surveys consistently show a downward drift in median AFS over recent decades. This example shows how to model that drift in STIsim using callable parameters: instead of a static mean, we pass a function that returns the mean at each simulation time.
This works because the underlying Starsim distributions accept any parameter as either a scalar, an array, or a callable of sim / uids. STIsim plumbs the debut_f and debut_m distributions straight through, so anything you can express as a distribution parameter works here — linear trends, sigmoids, DHS-fitted splines, you name it.
Setup
We’ll use a simple HIV sim spanning 1985-2025 with 10,000 agents, and compare two scenarios: a static debut distribution (default) vs a dynamic one whose mean declines linearly from 20 to 17 over the simulation period.
Baseline: static debut
In the default StructuredSexual network, debut_f and debut_m are lognormal distributions with a fixed mean (20 for women, 21 for men). Everyone who enters the simulation — at init or via births — draws their age of first sex from the same distribution.
For simple overrides, you can pass a scalar (e.g. debut_f=18) and the default standard deviation is preserved. For anything more flexible (custom std, a different distribution family, or a callable mean), pass a full ss.lognorm_ex(...) instead — see the next cell.
def make_sim(debut_f, debut_m, label):
sim = hivsim.demo(
'simple', run=False,
n_agents=10_000, start=1985, stop=2025, rand_seed=1, verbose=0,
debut_f=debut_f, debut_m=debut_m,
)
sim.label = label
return sim
sim_static = make_sim(debut_f=20, debut_m=21, label='Static')Time-varying debut
Now we define a callable that returns the mean age at debut as a function of the current simulation year. The function can take any of sim, self / module, uids, size, pars, states as arguments — Starsim introspects the signature and passes what’s asked for.
Here we keep it simple: linear decline from 20 in 1985 down to 17 by 2025, floored at 17 for any time outside the window.
def mean_afs_f(sim):
# Linear decline: 20 in 1985, 17 in 2025, clipped outside
return float(np.clip(20 - 0.075 * (sim.now.years - 1985), 17, 20))
def mean_afs_m(sim):
return float(np.clip(21 - 0.075 * (sim.now.years - 1985), 18, 21))
sim_dynamic = make_sim(
debut_f=ss.lognorm_ex(mean=mean_afs_f, std=3),
debut_m=ss.lognorm_ex(mean=mean_afs_m, std=3),
label='Dynamic',
)Run and compare
We run both sims in parallel, then compute mean debut age by birth-year cohort. In the static sim, cohorts should look identical. In the dynamic sim, later cohorts should show progressively lower mean AFS.
msim = ss.parallel(sim_static, sim_dynamic)
def afs_by_cohort(sim, cohort_bin=5):
ppl = sim.people
net = sim.networks.mfnetwork
# .values gives numpy arrays for active agents only
debut = net.debut.values
age = ppl.age.values
female = ppl.female.values
born = sim.now.years - age
# Keep only agents who have debuted
has_debut = debut > 0
born = born[has_debut]
debut = debut[has_debut]
female = female[has_debut]
bin_edges = np.arange(1960, 2011, cohort_bin)
bin_centers = bin_edges[:-1] + cohort_bin / 2
out = {}
for sex_name, mask in [('Female', female), ('Male', ~female)]:
means = []
for lo, hi in zip(bin_edges[:-1], bin_edges[1:]):
in_bin = mask & (born >= lo) & (born < hi)
means.append(np.nan if not in_bin.any() else debut[in_bin].mean())
out[sex_name] = np.array(means)
return bin_centers, out
cohorts_static, means_static = afs_by_cohort(msim.sims[0])
cohorts_dynamic, means_dynamic = afs_by_cohort(msim.sims[1])Initializing sim "Static" with 10000 agents
Initializing sim "Dynamic" with 10000 agents
Running "Static": 1985 ( 0/481) (0.00 s) ———————————————————— 0%
Running "Dynamic": 1985 ( 0/481) (0.00 s) ———————————————————— 0%
Running "Static": 1986 (12/481) (0.38 s) ———————————————————— 3%
Running "Dynamic": 1986 (12/481) (0.38 s) ———————————————————— 3%
Running "Static": 1987 (24/481) (0.76 s) •——————————————————— 5%
Running "Dynamic": 1987 (24/481) (0.75 s) •——————————————————— 5%
Running "Static": 1988 (36/481) (1.14 s) •——————————————————— 8%
Running "Dynamic": 1988 (36/481) (1.13 s) •——————————————————— 8%
Running "Static": 1989 (48/481) (1.52 s) ••—————————————————— 10%
Running "Dynamic": 1989 (48/481) (1.51 s) ••—————————————————— 10%
Running "Static": 1990 (60/481) (1.90 s) ••—————————————————— 13%
Running "Dynamic": 1990 (60/481) (1.89 s) ••—————————————————— 13%
Running "Static": 1991 (72/481) (2.29 s) •••————————————————— 15%
Running "Dynamic": 1991 (72/481) (2.27 s) •••————————————————— 15%
Running "Static": 1992 (84/481) (2.67 s) •••————————————————— 18%
Running "Dynamic": 1992 (84/481) (2.66 s) •••————————————————— 18%
Running "Static": 1993 (96/481) (3.06 s) ••••———————————————— 20%
Running "Dynamic": 1993 (96/481) (3.05 s) ••••———————————————— 20%
Running "Static": 1994 (108/481) (3.45 s) ••••———————————————— 23%
Running "Dynamic": 1994 (108/481) (3.44 s) ••••———————————————— 23%
Running "Static": 1995 (120/481) (3.83 s) •••••——————————————— 25%
Running "Dynamic": 1995 (120/481) (3.82 s) •••••——————————————— 25%
Running "Static": 1996 (132/481) (4.22 s) •••••——————————————— 28%
Running "Dynamic": 1996 (132/481) (4.21 s) •••••——————————————— 28%
Running "Static": 1997 (144/481) (4.60 s) ••••••—————————————— 30%
Running "Dynamic": 1997 (144/481) (4.59 s) ••••••—————————————— 30%
Running "Static": 1998 (156/481) (4.98 s) ••••••—————————————— 33%
Running "Dynamic": 1998 (156/481) (4.97 s) ••••••—————————————— 33%
Running "Static": 1999 (168/481) (5.35 s) •••••••————————————— 35%
Running "Dynamic": 1999 (168/481) (5.34 s) •••••••————————————— 35%
Running "Static": 2000 (180/481) (5.73 s) •••••••————————————— 38%
Running "Dynamic": 2000 (180/481) (5.72 s) •••••••————————————— 38%
Running "Static": 2001 (192/481) (6.11 s) ••••••••———————————— 40%
Running "Dynamic": 2001 (192/481) (6.10 s) ••••••••———————————— 40%
Running "Static": 2002 (204/481) (6.49 s) ••••••••———————————— 43%
Running "Dynamic": 2002 (204/481) (6.48 s) ••••••••———————————— 43%
Running "Static": 2003 (216/481) (6.88 s) •••••••••——————————— 45%
Running "Dynamic": 2003 (216/481) (6.87 s) •••••••••——————————— 45%
Running "Static": 2004 (228/481) (7.26 s) •••••••••——————————— 48%
Running "Dynamic": 2004 (228/481) (7.25 s) •••••••••——————————— 48%
Running "Static": 2005 (240/481) (7.65 s) ••••••••••—————————— 50%
Running "Dynamic": 2005 (240/481) (7.64 s) ••••••••••—————————— 50%
Running "Static": 2006 (252/481) (8.03 s) ••••••••••—————————— 53%
Running "Dynamic": 2006 (252/481) (8.02 s) ••••••••••—————————— 53%
Running "Static": 2007 (264/481) (8.42 s) •••••••••••————————— 55%
Running "Dynamic": 2007 (264/481) (8.41 s) •••••••••••————————— 55%
Running "Static": 2008 (276/481) (8.81 s) •••••••••••————————— 58%
Running "Dynamic": 2008 (276/481) (8.80 s) •••••••••••————————— 58%
Running "Static": 2009 (288/481) (9.20 s) ••••••••••••———————— 60%
Running "Dynamic": 2009 (288/481) (9.19 s) ••••••••••••———————— 60%
Running "Static": 2010 (300/481) (9.59 s) ••••••••••••———————— 63%
Running "Dynamic": 2010 (300/481) (9.58 s) ••••••••••••———————— 63%
Running "Static": 2011 (312/481) (9.98 s) •••••••••••••——————— 65%
Running "Dynamic": 2011 (312/481) (9.96 s) •••••••••••••——————— 65%
Running "Static": 2012 (324/481) (10.37 s) •••••••••••••——————— 68%
Running "Dynamic": 2012 (324/481) (10.35 s) •••••••••••••——————— 68%
Running "Static": 2013 (336/481) (10.76 s) ••••••••••••••—————— 70%
Running "Dynamic": 2013 (336/481) (10.74 s) ••••••••••••••—————— 70%
Running "Static": 2014 (348/481) (11.15 s) ••••••••••••••—————— 73%
Running "Dynamic": 2014 (348/481) (11.14 s) ••••••••••••••—————— 73%
Running "Static": 2015 (360/481) (11.54 s) •••••••••••••••————— 75%
Running "Dynamic": 2015 (360/481) (11.53 s) •••••••••••••••————— 75%
Running "Static": 2016 (372/481) (11.94 s) •••••••••••••••————— 78%
Running "Dynamic": 2016 (372/481) (11.93 s) •••••••••••••••————— 78%
Running "Static": 2017 (384/481) (12.34 s) ••••••••••••••••———— 80%
Running "Dynamic": 2017 (384/481) (12.33 s) ••••••••••••••••———— 80%
Running "Static": 2018 (396/481) (12.74 s) ••••••••••••••••———— 83%
Running "Dynamic": 2018 (396/481) (12.73 s) ••••••••••••••••———— 83%
Running "Static": 2019 (408/481) (13.14 s) •••••••••••••••••——— 85%
Running "Dynamic": 2019 (408/481) (13.13 s) •••••••••••••••••——— 85%
Running "Static": 2020 (420/481) (13.55 s) •••••••••••••••••——— 88%
Running "Dynamic": 2020 (420/481) (13.53 s) •••••••••••••••••——— 88%
Running "Static": 2021 (432/481) (13.96 s) ••••••••••••••••••—— 90%
Running "Dynamic": 2021 (432/481) (13.94 s) ••••••••••••••••••—— 90%
Running "Static": 2022 (444/481) (14.36 s) ••••••••••••••••••—— 93%
Running "Dynamic": 2022 (444/481) (14.34 s) ••••••••••••••••••—— 93%
Running "Static": 2023 (456/481) (14.77 s) •••••••••••••••••••— 95%
Running "Dynamic": 2023 (456/481) (14.75 s) •••••••••••••••••••— 95%
Running "Static": 2024 (468/481) (15.17 s) •••••••••••••••••••— 98%
Running "Dynamic": 2024 (468/481) (15.16 s) •••••••••••••••••••— 98%
Running "Dynamic": 2025 (480/481) (15.57 s) •••••••••••••••••••• 100%
Running "Static": 2025 (480/481) (15.59 s) •••••••••••••••••••• 100%
Visualize
Plot mean AFS by birth-year cohort for both scenarios and both sexes.
with sc.options.with_style('fancy'):
fig, axes = plt.subplots(1, 2, figsize=(10, 4), sharey=True)
colors = {'Static': '#888', 'Dynamic': '#d62728'}
for ax, sex in zip(axes, ['Female', 'Male']):
ax.plot(cohorts_static, means_static[sex], 'o-', color=colors['Static'], label='Static')
ax.plot(cohorts_dynamic, means_dynamic[sex], 'o-', color=colors['Dynamic'], label='Dynamic')
ax.set_title(f'{sex} — mean AFS by birth cohort')
ax.set_xlabel('Birth year')
ax.set_ylabel('Mean age at first sex')
ax.grid(alpha=0.3)
ax.legend()
fig.tight_layout()
plt.show()
Key takeaways
- Distribution parameters are callables — anything starsim accepts (scalars, arrays, or functions of
sim/uids) works fordebut_fanddebut_m. - Use what fits your data — a linear trend like the above, a sigmoid, or a spline fit to DHS indicators all plug in identically.
- No subclassing required — time-varying behavior is just a callable passed at network construction. Same pattern extends to other distribution parameters in the network (age differences, concurrency, relationship durations, etc.).
- Be bounded — because callables are unconstrained, use
np.clipor similar to keep the trend physically plausible over your simulation window.