import numpy as np
import sciris as sc
import starsim as ss
import stisim as sti
import hivsim
import matplotlib.pyplot as plt
sc.options(dpi=110)Partner notification
New in v1.5.5
Partner notification (PN) — also called contact tracing or partner services — identifies sexual partners of newly diagnosed agents and offers them follow-up testing. STIsim’s :class:~stisim.PartnerNotification intervention does this through two channels:
- Current partners: anyone with an active edge in the sexual network.
- Previous partners: anyone with an edge in the :class:
~stisim.PriorPartnersrecall network — dissolved partnerships within a configurable lookback window.
Each contact is independently subject to a notification step (probability p_notify_<scope>) and an attendance step (probability p_attends_<scope>). Attendees are scheduled for the supplied test intervention on the next timestep.
This example builds a small HIV sim and compares HIV diagnoses with and without PN.
Setup
Index cases: who triggers PN?
PN runs every timestep against an eligibility callback that returns the UIDs of “index cases” — typically agents who just tested positive. Here we use the standard HIV testing pipeline and treat any newly-diagnosed agent as an index case.
def newly_diagnosed(sim):
"""Index cases = agents whose HIV diagnosis fires this timestep."""
hiv = sim.diseases.hiv
return (hiv.ti_diagnosed == hiv.ti).uidsBuilding the sim
The active sexual network needs recall_prior=True so dissolved partnerships are pushed into the :class:~stisim.PriorPartners recall network. We set the lookback to 6 months — typical of real-world PN programs that ask about partners from the past 3–12 months.
def make_sim(use_pn=False, rand_seed=1):
hiv_test = sti.HIVTest(name='hiv_test', test_prob_data=0.3)
interventions = [hiv_test]
if use_pn:
pn = sti.PartnerNotification(
eligibility=newly_diagnosed,
test=hiv_test, # Schedule HIV test for partners
p_notify_current=ss.bernoulli(p=0.7), # 70% of current partners notified
p_attends_current=ss.bernoulli(p=0.7), # 70% attend follow-up
p_notify_previous=ss.bernoulli(p=0.3), # 30% of prior partners notified
p_attends_previous=ss.bernoulli(p=0.5), # 50% attend
name='pn',
)
interventions.append(pn)
networks = [
sti.StructuredSexual(recall_prior=True),
sti.PriorPartners(dur_recall=ss.years(0.5)),
]
sim = hivsim.demo(
'simple', run=False, plot=False,
n_agents=2_000, dur=10, rand_seed=rand_seed, verbose=-1,
networks=networks,
interventions=interventions,
)
sim.label = 'With PN' if use_pn else 'No PN'
return simRunning scenarios
sims = ss.parallel([make_sim(use_pn=False), make_sim(use_pn=True)]).sims
results = {sim.label: sim for sim in sims}
for label, sim in results.items():
n_dx = int(sim.results.hiv_test.new_diagnoses.sum())
print(f'{label:>10s}: {n_dx:5d} HIV diagnoses')Initializing sim "No PN" with 2000 agents
Initializing sim "With PN" with 2000 agents
Running "No PN": 2000 ( 0/121) (0.00 s) ———————————————————— 1%
Running "With PN": 2000 ( 0/121) (0.00 s) ———————————————————— 1%
Running "No PN": 2001 (12/121) (0.30 s) ••—————————————————— 11%
Running "With PN": 2001 (12/121) (0.31 s) ••—————————————————— 11%
Running "No PN": 2002 (24/121) (0.58 s) ••••———————————————— 21%
Running "With PN": 2002 (24/121) (0.60 s) ••••———————————————— 21%
Running "No PN": 2003 (36/121) (0.86 s) ••••••—————————————— 31%
Running "With PN": 2003 (36/121) (0.90 s) ••••••—————————————— 31%
Running "No PN": 2004 (48/121) (1.14 s) ••••••••———————————— 40%
Running "With PN": 2004 (48/121) (1.20 s) ••••••••———————————— 40%
Running "No PN": 2005 (60/121) (1.42 s) ••••••••••—————————— 50%
Running "With PN": 2005 (60/121) (1.49 s) ••••••••••—————————— 50%
Running "No PN": 2006 (72/121) (1.70 s) ••••••••••••———————— 60%
Running "With PN": 2006 (72/121) (1.78 s) ••••••••••••———————— 60%
Running "No PN": 2007 (84/121) (1.97 s) ••••••••••••••—————— 70%
Running "With PN": 2007 (84/121) (2.06 s) ••••••••••••••—————— 70%
Running "No PN": 2008 (96/121) (2.24 s) ••••••••••••••••———— 80%
Running "With PN": 2008 (96/121) (2.34 s) ••••••••••••••••———— 80%
Running "No PN": 2009 (108/121) (2.51 s) ••••••••••••••••••—— 90%
Running "With PN": 2009 (108/121) (2.63 s) ••••••••••••••••••—— 90%
Running "No PN": 2010 (120/121) (2.78 s) •••••••••••••••••••• 100%
Running "With PN": 2010 (120/121) (2.91 s) •••••••••••••••••••• 100%
No PN: 264 HIV diagnoses
With PN: 472 HIV diagnoses
Inspecting PN outputs
The PN intervention exposes per-timestep counts of partners notified and attending, broken down by channel.
pn_res = results['With PN'].results.pn
total_notified = int(pn_res.new_notified.sum())
total_attending = int(pn_res.new_attending.sum())
total_current = int(pn_res.new_notified_current.sum())
total_previous = int(pn_res.new_notified_previous.sum())
print(f'Notified: {total_notified} ({total_current} current, {total_previous} prior)')
print(f'Attending: {total_attending}')Notified: 421 (421 current, 0 prior)
Attending: 288
Comparing outcomes
fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))
# Left: HIV diagnoses over time
for label, sim in results.items():
axes[0].plot(sim.t.yearvec, sim.results.hiv_test.new_diagnoses,
label=label, lw=2)
axes[0].set_xlabel('Year')
axes[0].set_ylabel('New HIV diagnoses per timestep')
axes[0].set_title('HIV diagnoses over time')
axes[0].legend()
# Right: cumulative diagnoses
for label, sim in results.items():
axes[1].plot(sim.t.yearvec,
np.cumsum(sim.results.hiv_test.new_diagnoses),
label=label, lw=2)
axes[1].set_xlabel('Year')
axes[1].set_ylabel('Cumulative HIV diagnoses')
axes[1].set_title('Cumulative diagnoses')
axes[1].legend()
sc.figlayout()
plt.show()
Key takeaways
- PN runs through both a current-partner channel (active sexual network) and an optional prior-partner channel (PriorPartners recall network).
- The notification and attendance probabilities are separate (
p_notify_<scope>×p_attends_<scope>), reflecting real-world attrition between contact tracing and clinic attendance. - Set
p_notify_previous=ss.bernoulli(p=0)to disable the prior-partner channel; the active sexual network must haverecall_prior=Truewhenever the prior channel is enabled. - For more elaborate downstream actions — for example, applying a specific treatment regimen to partners by sex or by network channel — subclass :class:
~stisim.PartnerNotificationand override :meth:~stisim.PartnerNotification.notify_attendees. The default implementation schedulesself.testfor attendees; you can replace it with anything (set treatment eligibility, route by sex, send to a registry).