A practical guide for researchers using TBsim’s drug-resistance and multi-strain extension (tbsim.resistance). It focuses on how to set up, run, and interpret simulations: defining strains, transmission and competition, de-novo and acquired resistance, drug-susceptibility testing and regimen routing, treatment monitoring, and strain-aware preventive therapy.
This guide is task-oriented and recipe-based. For a narrative, plotted walkthrough of the same features, see the drug resistance tutorial; for implementation internals, see the tbsim.resistance README.
2 drugs → 4 strains
id 0: pan profile=[0 0] fitness=1.00
id 1: RIF profile=[1 0] fitness=0.50
id 2: BDQ profile=[0 1] fitness=0.80
id 3: RIF+BDQ profile=[1 1] fitness=0.40
Expected layout for two drugs:
id
label
profile
fitness (example)
0
pan
[0 0]
1.00
1
RIF
[1 0]
0.50
2
BDQ
[0 1]
0.80
3
RIF+BDQ
[1 1]
0.40
Adding a third drug (e.g. 'FQ') only requires appending to drugs; m becomes 8. You usually do not construct Strains yourself for a sim — TBResistant builds it and exposes it as tb.strains for products (TxR, DST, TPTRx).
Creating TBResistant
TBResistant is a drop-in replacement for tbsim.TB:
import starsim as ssimport tbsimtb = tbsim.TBResistant( drugs=['RIF', 'BDQ'], rel_fitness={'RIF': 0.9, 'BDQ': 0.85}, beta=ss.permonth(0.35), init_prev=ss.bernoulli(0.10),# Superinfection susceptibility (σ); defaults couple to rr_reinfection_rec rr_reinfection_inf=1.0, rr_reinfection_non=1.0, rr_reinfection_asy=0.0, # no superinfection in active disease (default) rr_reinfection_sym=0.0, p_multi=1.0, # keep all strains when progressing to ASYMPTOMATIC)print(tb.strains.labels)
['pan', 'RIF', 'BDQ', 'RIF+BDQ']
The module name defaults to 'tb', so existing interventions that look up disease 'tb' continue to work. To get single-strain tbsim.TB behavior from the resistance machinery (no resistance ever arises), use the convenience factory tbsim.TBResistant.agnostic(pars=...).
Seeding the epidemic
init_strains is a probability vector over strain ids for seeded infections (length m, need not be normalized — it is renormalized internally):
Implication for research: superinfection does not dilute a source’s overall transmission risk relative to mono-infection with its fittest strain, but it does split which strain is passed.
Superinfection and competition
Already-infected agents can acquire a second (distinct) strain. Relative risk vs a fully susceptible person depends on disease state:
State
Parameter
Default behavior
INFECTION
rr_reinfection_inf
Defaults to rr_reinfection_rec
NON_INFECTIOUS
rr_reinfection_non
Defaults to rr_reinfection_inf
ASYMPTOMATIC
rr_reinfection_asy
0 (closed)
SYMPTOMATIC
rr_reinfection_sym
0 (closed)
Protection is strain-agnostic (a third distinct strain is not harder to acquire than a second). Re-exposure to an already-carried strain is allowed: rather than being blocked, it increments that agent’s per-strain count (tracked in tb.strain_counts, one array per strain id) and is tallied in new_identical_superinf. The count feeds only two consumers — the transmission multinomial (which strain is passed ∝ count × fitness) and the progression bottleneck under p_multi < 1 (which strain survives ∝ count) — and leaves transition rates, DST, treatment efficacy, and the probability of resistance acquisition count-agnostic (all copies of a strain behave as one).
You can inspect the count-weighting directly on the Strains registry:
import numpy as npimport tbsims = tbsim.Strains(['TX'], rel_fitness=None) # neutral fitness# A source carrying {pan:2, resistant:1} passes pan 2/3 of the time; infectiousness is count-independent.print(s.transmit_probs(np.array([0b11]), counts=np.array([[2, 1]]))[0]) # → [0.667, 0.333]print(s.max_fitness(np.array([0b11]))[0]) # → 1.0
[0.66666667 0.33333333]
1.0
Fitness costs drive competition. Without treatment, a less-fit resistant strain tends to decline:
Resistance can arise endogenously at progression out of INFECTION (→ NON_INFECTIOUS or → ASYMPTOMATIC), as a one-time per-drug probability — not a per-timestep rate.
Parameter
Role
p_rand
Dict {drug: probability} per not-yet-resistant drug
prog_resist_mode
'mixed' (add resistant variant → superinfection; default) or 'replacement'
Cumulative de-novo events: 77
Final resistant fraction: 0.030660377358490566
p_rand is per drug, so RIF’s rate can be 0 while another drug’s is positive, and each carried strain of a multi-strain agent mutates independently. Both modes create resistance; only 'mixed' produces lasting superinfected (AB) agents from de-novo events.
TxDeliveryR — who starts treatment and when (rates from ASYMPTOMATIC/SYMPTOMATIC, or a custom eligibility callable)
Efficacy for strain j is base_efficacy × product of resist_penalty over regimen drugs that strain resists. If that constrained form is too restrictive, pass an explicit per-strain efficacy vector efficacy_by_strain (length m) — it is used verbatim and overrides base_efficacy/resist_penalty. Failed courses can acquire resistance to regimen drugs by replacement: each surviving drug-susceptible strain rolls independently, once per regimen drug it is susceptible to, scaled by TB-state RR (acq_state_rr; default 1 for ASYMPTOMATIC/SYMPTOMATIC, 0 elsewhere).
adherence is a per-course completion probability that correlates all of an agent’s strains through a single draw (a non-completer clears nothing that course). Pass a float for one regimen-level probability shared by every agent, or a callableuids -> per-agent probability to make adherence a distribution that varies by agent:
import numpy as np# First half of agents fully adherent, second half never — a per-agent adherence distribution.adherence =lambda uids: np.where(np.asarray(uids) <1000, 1.0, 0.0)
A worked treatment run that selects for resistance (susceptible strain cured well, resistant strain poorly):
Courses started: 611
Acquisitions on failure: 5
Final resistant fraction: 0.646551724137931
Research tip: lower resist_penalty values (stronger efficacy loss against resistant strains) and higher q_acq both tend to raise the resistant share of active TB — useful for sensitivity analysis. Partial cure is supported: if only some strains clear, the agent returns to the pre-treatment TB state carrying the survivors.
Treating latent (INFECTION) agents.TxDeliveryR(treat_latent=...) controls what happens when a latent agent is selected for treatment (only reachable via a custom/DST-routed eligibility). With treat_latent=False (default) the agent undergoes strain-aware sterilization: every strain susceptible to all regimen drugs is cleared with certainty, any regimen-resistant strain is kept, and the agent moves to CLEARED only if no strain remains — no course is run and these agents are not counted in n_treated. Set treat_latent=True to instead run latent agents through a full failable course.
Drug-susceptibility testing (DST)
DST produces an observed n-drug profile (not strain identities). Sensitivity/specificity are applied at the strain level; p_strain_obs (default = strain fitness) can drop strains from the sample. Sensitivity/specificity errors are drawn independently per (strain, drug), so a multi-drug DST behaves like independent per-drug tests. DSTDelivery.matches(...) turns the observed profile into eligibility callables for regimen routing.
A key point about regimen_drugs: it names the drugs the regimen acts on. A realistic second-line for RIF-resistant TB is built from a different drug the resistant strain is still susceptible to — not RIF. The example uses a two-drug space (RIF, BDQ): first-line is a RIF regimen routed to observed RIF-susceptible cases, and second-line is a BDQ regimen routed to observed RIF-resistant cases (which are BDQ-susceptible here).
Also available: dst.observed_resistant('RIF') for a single-drug eligibility callable. Both matches(...) and observed_resistant(...) accept max_age=<ss.dur> to require a fresh DST result, and DSTDelivery(result_validity=<ss.dur>) wipes stored results older than the window so agents must be re-tested.
Retreatment vs new case. Every TxDeliveryR stamps a durable, cross-regimen tb.ti_last_treatment at each initiation, so a later presentation can be classified by time since last treatment. TxDeliveryR.failure_case_eligibility(within=<ss.dur>) returns a sim -> uids callable selecting active-TB agents whose most recent treatment was within within (manage as retreatment); pass new_case=True for the complement, and base=<callable> to restrict the candidate pool.
First-line initiations: 13
Switched to second-line: 13
after_steps is in simulation timesteps (with dt=ss.days(30), after_steps=2 ≈ 2 months). Richer routing is available via the composable eligibility helpers tbsim.eligibility_all / tbsim.eligibility_any (intersection / union of sim → uids callables) and tbsim.will_fail(tx_name) (agents whose pre-rolled course outcome is a failure).
Strain-aware preventive therapy (TPT)
TPTRx sterilizes per strain: only strains susceptible to every drug in the TPT regimen are cleared. A resistant strain in a co-infected agent can survive and later progress/transmit — the classic “TPT unmasks resistance” dynamic. Ineffective TPT can also select resistance (p_tpt_acq), scaled by TB state.
Set p_sterilize > 0 so the strain-aware clearance path runs. With p_multi=1 (default), the main harm pathway is transmission unmasking, not progression bottlenecking.
Analyzing where resistance comes from
ResistanceStats decomposes new resistance into de-novo, treatment-acquired, transmitted, and TPT-acquired fluxes. StrainResults records per-strain active-TB counts.