Solutions

These are the solutions to the exercises from each of the tutorials. Many of these exercises are open-ended, so these are just example solutions — there are often many equally valid approaches. If you get stuck or want to discuss alternatives, email us!

An interactive version of this notebook is available on Google Colab or Binder.

Let’s start with the simplest version of a Starsim model. We’ll make a version of a classic SIR model. Here’s how our code would look:

T1 Solutions

Question 1

Q: To simulate a susceptible-infectious-susceptible (SIS) model instead of SIR, what would we change in the example above?

A: We would simply change 'sir' to 'sis':

import starsim as ss
ss.options(jupyter=True)
import sciris as sc

# Define the parameters
pars = sc.objdict( # We use objdict to allow "." access
    n_agents = 10_000,
    networks = sc.objdict(
        type = 'random',
        n_contacts = 10,
    ),
    diseases = sc.objdict(
        type = 'sis', # <-- change this
        init_prev = 0.01,
        beta = 0.05,
    )
)

# Make the sim, run and plot
sim = ss.Sim(pars)
sim.run()
sim.plot()
sim.diseases.sis.plot() # <-- change this
Initializing sim with 10000 agents

  Running 2000 ( 0/51) (0.00 s)  ———————————————————— 2%

  Running 2010 (10/51) (0.06 s)  ••••———————————————— 22%

  Running 2020 (20/51) (0.10 s)  ••••••••———————————— 41%

  Running 2030 (30/51) (0.15 s)  ••••••••••••———————— 61%

  Running 2040 (40/51) (0.19 s)  ••••••••••••••••———— 80%

  Running 2050 (50/51) (0.24 s)  •••••••••••••••••••• 100%

Figure(768x576)

Figure(672x480)

Question 2

Q: How do the results change if we increase/decrease beta?

Increasing beta makes the curves steeper:

pars.diseases.type = 'sir' # Switch back to SIR
pars2 = sc.dcp(pars) # copy to new dictionary
pars2.diseases.beta = 0.10
sim2 = ss.Sim(pars2).run()
sim2.diseases.sir.plot()
Initializing sim with 10000 agents

  Running 2000 ( 0/51) (0.00 s)  ———————————————————— 2%

  Running 2010 (10/51) (0.05 s)  ••••———————————————— 22%

  Running 2020 (20/51) (0.10 s)  ••••••••———————————— 41%

  Running 2030 (30/51) (0.15 s)  ••••••••••••———————— 61%

  Running 2040 (40/51) (0.19 s)  ••••••••••••••••———— 80%

  Running 2050 (50/51) (0.23 s)  •••••••••••••••••••• 100%

Figure(672x480)

Decreasing beta makes the curves shallower:

pars3 = sc.dcp(pars)
pars3.diseases.beta = 0.02
sim3 = ss.Sim(pars3).run()
sim3.diseases.sir.plot()
Initializing sim with 10000 agents

  Running 2000 ( 0/51) (0.00 s)  ———————————————————— 2%

  Running 2010 (10/51) (0.04 s)  ••••———————————————— 22%

  Running 2020 (20/51) (0.09 s)  ••••••••———————————— 41%

  Running 2030 (30/51) (0.14 s)  ••••••••••••———————— 61%

  Running 2040 (40/51) (0.19 s)  ••••••••••••••••———— 80%

  Running 2050 (50/51) (0.24 s)  •••••••••••••••••••• 100%

Figure(672x480)

Question 3

Q: How do the results change if we reduce the number of agents to 200?

We get a similar result as before, except less smooth, since random effects are more important with small numbers of agents:

pars4 = sc.dcp(pars)
pars4.n_agents = 200
sim4 = ss.Sim(pars4).run()
sim4.diseases.sir.plot()
Initializing sim with 200 agents

  Running 2000 ( 0/51) (0.00 s)  ———————————————————— 2%

  Running 2010 (10/51) (0.02 s)  ••••———————————————— 22%

  Running 2020 (20/51) (0.04 s)  ••••••••———————————— 41%

  Running 2030 (30/51) (0.05 s)  ••••••••••••———————— 61%

  Running 2040 (40/51) (0.07 s)  ••••••••••••••••———— 80%

  Running 2050 (50/51) (0.09 s)  •••••••••••••••••••• 100%

Figure(672x480)

T2 Solutions

Question 1

Q: How would you model an outbreak of an SIR-like disease within a refugee camp of 2,000 people? Suppose you were interested in the cumulative number of people who got infected over 1 year - how would you find this out?

The answer obviously depends on the disease parameters. However, we can make some simple assumptions and use cum_infections to determine the total number of infections:

import starsim as ss
import sciris as sc

pars = sc.objdict(
    n_agents = 2_000,
    start = '2025-01-01',
    dur = 365,
    dt = 'day',
    verbose = 1/30, # Print every month
)
sir = ss.SIR(
    dur_inf = ss.days(14),
    beta = ss.perday(0.02),
    init_prev = 0.001,
)
net = ss.RandomNet(n_contacts=4)

sim = ss.Sim(pars, diseases=sir, networks=net)
sim.init()
sim.run()
sim.plot()

answer = sim.results.sir.cum_infections[-1]
print(f'Cumulative infections over one year: {answer}')
Initializing sim with 2000 agents

  Running 2025.01.01 ( 0/366) (0.00 s)  ———————————————————— 0%

  Running 2025.01.31 (30/366) (0.06 s)  •——————————————————— 8%

  Running 2025.03.02 (60/366) (0.12 s)  •••————————————————— 17%

  Running 2025.04.01 (90/366) (0.18 s)  ••••———————————————— 25%

  Running 2025.05.01 (120/366) (0.23 s)  ••••••—————————————— 33%

  Running 2025.05.31 (150/366) (0.29 s)  ••••••••———————————— 41%

  Running 2025.06.30 (180/366) (0.35 s)  •••••••••——————————— 49%

  Running 2025.07.30 (210/366) (0.41 s)  •••••••••••————————— 58%

  Running 2025.08.29 (240/366) (0.46 s)  •••••••••••••——————— 66%

  Running 2025.09.28 (270/366) (0.52 s)  ••••••••••••••—————— 74%

  Running 2025.10.28 (300/366) (0.58 s)  ••••••••••••••••———— 82%

  Running 2025.11.27 (330/366) (0.64 s)  ••••••••••••••••••—— 90%

  Running 2025.12.27 (360/366) (0.70 s)  •••••••••••••••••••— 99%
Figure(768x576)

Cumulative infections over one year: 0.0

Question 2

Q: Whether an epidemic ‘takes off’ depends to a large extent on the basic reproduction number, which in this kind of model depends on beta, n_contacts, and dur_inf. Experiment with different values for each and compare the trajectory of sim.results.sir.n_infected.

A: All three parameters increase transmission, so increasing any of them makes the epidemic grow faster and larger. The cleanest way to see this is to write a small helper that builds a sim for a given set of parameters, then compare them:

import starsim as ss
import sciris as sc

def make_sim(beta=0.05, n_contacts=10, dur_inf=10):
    pars = sc.objdict(
        n_agents = 5_000,
        networks = sc.objdict(type='random', n_contacts=n_contacts),
        diseases = sc.objdict(type='sir', init_prev=0.01, beta=beta, dur_inf=dur_inf),
        verbose = 0,
    )
    return ss.Sim(pars, label=f'beta={beta}, n_contacts={n_contacts}, dur_inf={dur_inf}')

# Vary each parameter in turn, relative to the baseline
sims = [
    make_sim(),               # Baseline
    make_sim(beta=0.10),      # Higher transmissibility
    make_sim(n_contacts=20),  # More contacts
    make_sim(dur_inf=20),     # Longer infectious period
]
msim = ss.parallel(sims)
msim.plot('sir_n_infected')
Figure(768x576)

Each of the three modified scenarios produces a larger, faster epidemic than the baseline, illustrating that they all push the reproduction number in the same direction.

T3 Solutions

Question 1

Q: In Niger, the crude birth rate is 45 and the crude death rate is 9. Assuming these rates stay constant, and starting with a total population of 24 million in 2020, how many people will there be in 2040? (You do not need to include any diseases in your model.)

A: We can build our simple demographic model with these parameters, then run it and plot the results:

import starsim as ss
import sciris as sc

pars = sc.objdict(
    start = 2020,
    stop = 2040,
    total_pop = 24e6,
    birth_rate = 45,
    death_rate = 9,
)
sim = ss.Sim(pars)
sim.run()
sim.plot('n_alive')

answer = sim.results.n_alive[-1]/1e6
print(f'Population size in year {pars.stop}: {answer} million')
Initializing sim with 10000 agents

  Running 2020 ( 0/21) (0.00 s)  ———————————————————— 5%

  Running 2030 (10/21) (0.02 s)  ••••••••••—————————— 52%

  Running 2040 (20/21) (0.04 s)  •••••••••••••••••••• 100%

Figure(768x576)

Population size in year 2040: 49.788 million

T4 Solutions

These solutions build on the SEIR class from the tutorial, so we define it again here:

import numpy as np
import starsim as ss

class SEIR(ss.SIR):
    def __init__(self, pars=None, *args, **kwargs):
        super().__init__()
        self.define_pars(dur_exp=ss.lognorm_ex(0.5))
        self.update_pars(pars, **kwargs)
        self.define_states(
            ss.BoolState('exposed', label='Exposed'),
            ss.FloatArr('ti_exposed', label='Time of exposure'),
        )
        return

    @property
    def infectious(self):
        return self.infected | self.exposed

    def step_state(self):
        super().step_state()
        infected = self.exposed & (self.ti_infected <= self.ti)
        self.exposed[infected] = False
        self.infected[infected] = True
        return

    def step_die(self, uids):
        super().step_die(uids)
        self.exposed[uids] = False
        return

    def set_prognoses(self, uids, sources=None):
        super().set_prognoses(uids, sources)
        ti = self.ti
        self.susceptible[uids] = False
        self.exposed[uids] = True
        self.ti_exposed[uids] = ti
        p = self.pars
        dur_exp = p.dur_exp.rvs(uids)
        self.ti_infected[uids] = ti + dur_exp
        dur_inf = p.dur_inf.rvs(uids)
        will_die = p.p_death.rvs(uids)
        self.ti_recovered[uids[~will_die]] = ti + dur_inf[~will_die]
        self.ti_dead[uids[will_die]] = ti + dur_inf[will_die]
        return

Question 1

Q: Try different values of dur_exp in the SEIR model — how does it affect the epidemic curve?

A: A longer exposed (latent) period delays the onset of infectiousness, so the epidemic peak comes later and is somewhat lower and broader:

sims = []
for dur_exp in [0.5, 2, 5]:
    seir = SEIR(dur_exp=ss.lognorm_ex(dur_exp))
    sims.append(ss.Sim(diseases=seir, networks='random', verbose=0, label=f'dur_exp={dur_exp}'))
msim = ss.parallel(sims)
msim.plot('seir_n_infected')
Figure(768x576)

Question 2

Q: Adapt the SEIR example to be SEIRS (where recovered people can become susceptible again).

A: We add a duration of immunity (dur_imm) and, in step_state(), move recovered agents back to susceptible once their immunity has waned. We schedule the waning time in set_prognoses() for everyone who is going to recover:

class SEIRS(SEIR):
    def __init__(self, pars=None, *args, **kwargs):
        super().__init__()
        self.define_pars(dur_imm=ss.lognorm_ex(10))  # Duration of immunity after recovery
        self.update_pars(pars, **kwargs)
        self.define_states(ss.FloatArr('ti_susceptible'))
        return

    def step_state(self):
        super().step_state()
        # Recovered agents whose immunity has waned return to susceptible
        waning = self.recovered & (self.ti_susceptible <= self.ti)
        self.recovered[waning] = False
        self.susceptible[waning] = True
        return

    def set_prognoses(self, uids, sources=None):
        super().set_prognoses(uids, sources)
        # Schedule waning of immunity for those who will recover
        recovering = uids[~np.isnan(self.ti_recovered[uids])]
        dur_imm = self.pars.dur_imm.rvs(recovering)
        self.ti_susceptible[recovering] = self.ti_recovered[recovering] + dur_imm
        return

seirs = SEIRS()
sim = ss.Sim(diseases=seirs, networks='random', dur=100, verbose=0)
sim.run()
sim.plot('seirs')
Figure(768x576)

Unlike the SEIR model, the SEIRS model can sustain endemic transmission, because the pool of susceptibles is continually replenished as immunity wanes.

Question 3

Q: Can you create a model with two strains of the same disease that provide partial cross-immunity?

A: One clean way to do this is to model the two strains as two separate diseases, and use a connector to couple them: recovery from one strain reduces susceptibility to the other. Here we reset and re-apply the cross-protection each timestep so it always reflects the current recovered population:

# Two independent SIR "strains" (strain 2 is seeded slightly later)
strain1 = ss.SIR(name='strain1', beta=0.1, init_prev=0.01)
strain2 = ss.SIR(name='strain2', beta=0.1, init_prev=0.0)

class CrossImmunity(ss.Connector):
    """ Recovery from one strain reduces susceptibility to the other """
    def __init__(self, protection=0.5, **kwargs):
        super().__init__()
        self.define_pars(protection=protection)
        self.update_pars(**kwargs)
        return

    def step(self):
        s1 = self.sim.diseases.strain1
        s2 = self.sim.diseases.strain2
        factor = 1 - self.pars.protection
        s1.rel_sus[:] = 1.0  # Reset each step...
        s2.rel_sus[:] = 1.0
        s1.rel_sus[s2.recovered.uids] = factor  # ...then apply cross-protection
        s2.rel_sus[s1.recovered.uids] = factor
        return

sim = ss.Sim(
    diseases = [strain1, strain2],
    networks = 'random',
    connectors = CrossImmunity(protection=0.5),
    verbose = 0,
)
sim.run()
sim.plot('strain1')
Figure(768x576)

Higher values of protection make it harder for the second strain to spread through people who have already recovered from the first.

T5 Solutions

Question 1

Q: Adapt the HIV example to include both MF and MSM transmission.

A: We add an ss.MSMNet alongside the ss.MFNet, and specify a beta for each network in the disease’s beta dictionary:

import starsim as ss
import starsim.library as ssl

# HIV transmitting on both networks
hiv = ssl.diseases.HIV(beta={'mf': [0.05, 0.025], 'msm': [0.08, 0.08]})

# Heterosexual and MSM networks
mf  = ss.MFNet(duration=1/24, acts=80)
msm = ss.MSMNet(duration=1/24, acts=80)

pars = dict(start=2000, dur=20, dt=1/12, verbose=0)
sim = ss.Sim(pars=pars, diseases=hiv, networks=[mf, msm])
sim.run()
sim.plot('hiv')
Figure(768x576)

Question 2

Q: Modify the age_mf network to have different age bins and mixing probabilities.

A: This exercise is open-ended and depends on the assortativity you want to model. The general approach is to override add_pairs() in a subclass of ss.MFNet (as sketched in the tutorial), and within it, choose partners using your own age-bin logic — for example, by binning agents by age and drawing partners preferentially from nearby bins. The Networks user guide shows worked examples of age-structured mixing, which is the most robust starting point.

Question 3

Q: Compare random vs age-structured networks — how do they affect epidemic dynamics?

A: We can run the same SIR disease on a structureless RandomNet and on the age-structured MFNet, and compare the infection trajectories:

s_rand = ss.Sim(diseases=ss.SIR(beta=ss.peryear(0.1)), networks=ss.RandomNet(n_contacts=10),
                n_agents=5000, verbose=0, label='Random')
s_age  = ss.Sim(diseases=ss.SIR(beta=ss.peryear(0.1)), networks=ss.MFNet(),
                n_agents=5000, verbose=0, label='Age-structured (MF)')
msim = ss.parallel(s_rand, s_age)
msim.plot('sir_n_infected')
Figure(768x576)

The age-structured network restricts who can contact whom (and the MFNet only connects agents in partnerships), so it typically produces slower, smaller epidemics than a random network where every agent can mix freely.

T6 Solutions

Question 1

Q: If we change the disease from SIR to SIS and set coverage to 100%, what minimum efficacy of vaccine is required to eradicate the disease by 2050?

A: There are many ways we could solve this, including with formal numerical optimization packages. However, since we are only varying a single parameter, we can also just use a simple binay search or grid search. This solution illustrates both approaches.

import numpy as np
import sciris as sc
import starsim as ss

pars = dict(
    n_agents = 5_000,
    birth_rate = 20,
    death_rate = 15,
    networks = dict(
        type = 'random',
        n_contacts = 4
    ),
    diseases = dict(
        type = 'sis',
        dur_inf = 10,
        beta = 0.1,
    ),
    verbose = False,
)

class sis_vaccine(ss.Vx):
    """ A simple vaccine against "SIS" """
    def __init__(self, efficacy=1.0, **kwargs):
        super().__init__()
        self.define_pars(efficacy=efficacy)
        self.update_pars(**kwargs)
        return

    def administer(self, people, uids):
        people.sis.rel_sus[uids] *= 1-self.pars.efficacy
        return
    
def run_sim(efficacy):
    """ Run a simulation with a given vaccine efficacy """
    # Create the vaccine product
    product = sis_vaccine(efficacy=efficacy)

    # Create the intervention
    intervention = ss.routine_vx(
        start_year=2015, # Begin vaccination in 2015
        prob=1.0,        # 100% coverage
        product=product  # Use the SIS vaccine
    )

    # Now create two sims: a baseline sim and one with the intervention
    sim = ss.Sim(pars=pars, interventions=intervention)
    sim.run()
    return sim

def objective(efficacy, penalty=10, boolean=False, verbose=False):
    """ Calculate the objective from the simulation """
    sim = run_sim(efficacy=efficacy)
    transmission = sim.results.sis.new_infections[-1] > 0
    if boolean:
        return not transmission
    else:
        loss = efficacy + penalty*transmission
        if verbose:
            print(f'Trial: {efficacy=}, {transmission=}, {loss=}')
        return loss

def grid_search(n=5, reps=2):
    """ Perform a grid search over the objective function """
    sc.heading('Performing grid search ...')
    lb = 0 # Lower bound for efficacy
    ub = 1 # Upper bound for efficacy
    for rep in range(reps):
        print(f'Grid search {rep+1} of {reps}...')
        efficacy = np.linspace(lb, ub, n)
        transmission = sc.parallelize(objective, efficacy, boolean=True)
        lb = efficacy[sc.findlast(transmission, False)]
        ub = efficacy[sc.findfirst(transmission, True)]
        print(f'  Trials: {dict(zip(efficacy, transmission))}')
        print(f'  Results: lower={lb}, upper={ub}')
    mid = (lb+ub)/2
    print(sc.ansi.bold(f'Result: {mid}'))
    return mid, lb, ub

def auto_search(efficacy=1.0):
    """ Perform automatic search """
    sc.heading('Performing automatic search...')
    out = sc.asd(objective, x=efficacy, xmin=0, xmax=1, maxiters=10, verbose=True)
    print(sc.ansi.bold(f'Result: {out.x}'))
    return out

# Run both optimizations
mid, lb, ub = grid_search()
out = auto_search()




——————————————————————————

Performing grid search ...

——————————————————————————



Grid search 1 of 2...

  Trials: {0.0: False, 0.25: False, 0.5: True, 0.75: True, 1.0: True}

  Results: lower=0.25, upper=0.5

Grid search 2 of 2...

  Trials: {0.25: False, 0.3125: False, 0.375: False, 0.4375: False, 0.5: True}

  Results: lower=0.4375, upper=0.5

Result: 0.46875





——————————————————————————————

Performing automatic search...

——————————————————————————————



     step 1 (0.4 s) ++ (orig:1.000 | best:1.000 | new:0.9000 | diff:-0.1000)

     step 2 (0.9 s) ++ (orig:1.000 | best:0.9000 | new:0.7000 | diff:-0.2000)

     step 3 (1.3 s) -- (orig:1.000 | best:0.7000 | new:10.30 | diff:9.600)

     step 4 (1.6 s) -- (orig:1.000 | best:0.7000 | new:0.8000 | diff:0.1000)

     step 5 (2.0 s) ++ (orig:1.000 | best:0.7000 | new:0.5000 | diff:-0.2000)

     step 6 (2.3 s) -- (orig:1.000 | best:0.5000 | new:0.5500 | diff:0.05000)

     step 7 (2.7 s) -- (orig:1.000 | best:0.5000 | new:10.10 | diff:9.600)

     step 8 (3.0 s) -- (orig:1.000 | best:0.5000 | new:10.30 | diff:9.800)

     step 9 (3.4 s) -- (orig:1.000 | best:0.5000 | new:10.40 | diff:9.900)

     step 10 (3.8 s) -- (orig:1.000 | best:0.5000 | new:10.45 | diff:9.950)

===  Maximum iterations reached (10 steps, orig: 1.000 | best: 0.5000 | ratio: 2.0000000000000004) ===

Result: 0.49999999999999994