Core Starsim’s networks (random contacts, partnerships, mother and child, mixing pools, and contact matrices), and how to write a network of your own, are on Mixing. The Starsim library (import starsim.library as ssl; see Library) has more, for households and other clusters, for space, and for theoretical work:
Each person in contact only with themselves, with no transmission, as a placeholder
none
Like every network, these carry agents, so a model with one runs under the agent-based solver; Mixing says how each would be approximated by a compartmental one. The tutorial T4 – Mixing introduces networks with a worked example.
Households and other clusters
Much transmission happens within households, schools, and workplaces: small groups whose members meet each other again and again. ssl.ClusterNet divides people (optionally only those who= gives) into clusters of sizes drawn from cluster_size (by default Poisson with a mean of 4), with everyone in a cluster in contact with everyone else. Its contacts are made once, at the start (or redrawn every step, with dynamic=True).
Here the same SIR disease spreads with about 6 contacts per person: all of them random, or 1 random and the rest in households of about 6, with each scenario run 3 times:
import numpy as npimport matplotlib.pyplot as pltimport starsim as ssimport starsim.library as sslsir =lambda: ss.SIR(beta=ss.perday(0.05), dur_inf=ss.days(10), init_prev=0.005)kw =dict(n_agents=2000, start='2000-01-01', dur=ss.days(200), dt=ss.days(1))scenarios = {'Random contacts only': ss.Sim(diseases=sir(), networks=ss.RandomNet(n_contacts=6), **kw),'Households and random contacts': ss.Sim(diseases=sir(), networks=[ssl.ClusterNet(cluster_size=ss.poisson(6)), ss.RandomNet(n_contacts=1)], **kw),}msim = ss.parallel(scenarios, n_runs=3, workers=1)msim.plot(['sir.I', 'sir.R'])
MultiSim: 6 runs of 2 scenarios (Random contacts only, Households and random contacts)
Done in 2.6 s
ssl.HybridNet is the population of Covasim: households (h, a ClusterNet with sizes drawn from a Poisson distribution with a mean of 2, as in Covasim, which gives about 2 contacts per person), schools for ages 6 to 22 (s), workplaces for ages 22 to 65 (w), and the community (c). Schools and workplaces are clusters too, and the community is a random network, with a Poisson number of contacts in each (n_contacts, by default ss.poisson(20), ss.poisson(16), and ss.poisson(20); a number is the same for everyone, as in ss.RandomNet); clustered=False makes schools and workplaces random networks, as in Covasim. When added to a sim, it becomes four networks, each reached by name. How much a contact in each transmits is the disease’s, with one β per network: Covasim’s relative weights are ssl.HybridNet.rel_betas, 3, 0.6, 0.6, and 0.3, so that a contact at home counts for 10 times one in the community. ssl.HybridNet.betas() multiplies them by a β per contact (by default Covasim’s, 0.016 a day), giving a dict to use as a disease’s β: for a β of 0.02 a day, ss.SIR(beta=ssl.HybridNet.betas(ss.perday(0.02))) is ss.SIR(beta=dict(h=ss.perday(0.06), s=ss.perday(0.012), w=ss.perday(0.012), c=ss.perday(0.006))).
The layers make questions about settings answerable. Here an influenza-like infection spreads with schools open, and with schools closed for the whole outbreak (a β of 0 on the school layer):
flu =lambda**beta: ss.SIR(beta=ssl.HybridNet.betas(ss.perday(0.02)) | beta, dur_inf=ss.days(5), init_prev=0.005) # Covasim's weights: 10 times as much at home as in the communitykw =dict(n_agents=2000, start='2000', dur=ss.days(120), dt=ss.days(1))scenarios = {'Schools open': ss.Sim(diseases=flu(), networks=ssl.HybridNet(), **kw),'Schools closed': ss.Sim(diseases=flu(s=ss.perday(0)), networks=ssl.HybridNet(), **kw),}msim = ss.parallel(scenarios, n_runs=5, workers=1)sim = msim.sims[0]for name, net in sim.networks.items():print(name, 'contacts per person:', round(2*len(net) / sim.people.alive.count(), 1))msim.plot('sir.I')
MultiSim: 10 runs of 2 scenarios (Schools open, Schools closed)
Done in 3.5 s
h contacts per person: 2.1
s contacts per person: 5.7
w contacts per person: 8.7
c contacts per person: 19.9
A closure for a while, rather than the whole outbreak, is a modifier on the school layer, ss.modify(sir.pars.beta, multiply=0, network='s') with the times it is closed (see Interventions).
Households from survey data
ssl.HouseholdNet builds households from DHS household survey data, setting each agent’s age and sex to match a randomly drawn real household, so that the ages of the people living together are realistic (children with their parents, grandparents with both). In a sim with ss.Pregnancy, households also change (dynamic=True, which needs it; without it, they are fixed): one woman is the head of each household, pregnant women other than the head may move out to form new households (with probability prob_move_out), and babies join their mother’s household. To change the households less often than every step, give the network a step of its own, e.g. dt=ss.years(1).
Real DHS data requires registration, so dhs_data='default' makes synthetic data with the same structure, for demonstrations and testing:
sim = ss.Sim(networks=ssl.HouseholdNet(dhs_data='default'), demographics=ss.Pregnancy(), start='2000', stop='2010', dt=ss.months(1), n_agents=2000)sim.run()net = sim.networks.householdnet # The sim's own copy of the networkhh_ids = net.household_ids[net.household_ids.notnan] # Unborn agents have no householdsizes = np.bincount(hh_ids.astype(int))sizes = sizes[sizes >0]print('Households:', len(sizes))print('Mean household size:', round(sizes.mean(), 1))print('Largest household:', sizes.max())
Sim: pregnancy, householdnet, as 2,000 agents, 2000-01-01 to 2010-01-01
Running as agents: n_agents was given
Note: pregnancy: women pregnant at the start, conceived over the 9 steps before it (burnin=True)
Done in 0.56 s
Households: 761
Mean household size: 3.3
Largest household: 9
To use real data, download a Household Recode (HR) dataset in Stata format, and load it with HouseholdNet.load_dhs(); any table with a household ID (hh_id) and the ages of its members as text (ages, such as '72, 17, 30'), and optionally their sexes (sexes, 1 for male and 2 for female), will also do:
HouseholdNet sets the age and sex of every agent (other than unborn agents) when it starts. Take care when using it with other modules that set or depend on age and sex, such as ss.population('Kenya') (see People and demographics).
Space
ssl.DiskNet is a spatial network: each agent has a position (x, y) in a unit square and a heading, moves at speed v each step (by default 0.05 a day), bounces off the walls, and is in contact with every agent within radius r (by default 0.1). Because contacts depend on position, an epidemic spreads outwards from each case as a wave. The positions are states of the network, so they can be plotted at any point in a run; here the sim runs in stages, and plots who is susceptible, infected, and recovered at each:
sir =lambda: ss.SIR(beta=ss.perday(0.1), dur_inf=ss.days(10), init_n=3)kw =dict(n_agents=2000, start='2000-01-01', dur=ss.days(150), dt=ss.days(1))disk = ss.Sim(diseases=sir(), networks=ssl.DiskNet(r=0.04, v=ss.freqperday(0.001)), **kw)states =dict(S=('Susceptible', 'lightgray'), I=('Infected', 'tab:red'), R=('Recovered', 'tab:blue'))fig, axes = plt.subplots(1, 3, figsize=(12, 4))for ax, day inzip(axes, ['2000-01-31', '2000-03-01', '2000-04-15']): disk.run(until=day) # Run up to this day, then continue from here the next time net, sir_states = disk.networks.disknet, disk.diseases.sirfor state, (label, color) in states.items(): uids =getattr(sir_states, state).uids ax.scatter(net.x[uids], net.y[uids], s=4, color=color, label=label) ax.set_title(day) ax.set_aspect('equal') ax.set_xticks([]) ax.set_yticks([])axes[0].legend(loc='lower left')plt.show()
Sim: sir, as 2,000 agents, 2000-01-01 to 2000-05-30
Running as agents: n_agents was given
━━━━━━──────────────────────── 19% 2000-01-29 0.8 s
━━━━━━━━━━━━────────────────── 38% 2000-02-27 0.7 s
━━━━━━━━━━━━━━━━━━━━────────── 68% 2000-04-12 1.5 s
With a radius of 0.04, each agent has about 10 contacts (π × 0.04² × 2000); a random network with as many gives a much faster, higher epidemic, since every contact can be anywhere.
For movement between distinct places (villages, districts, countries) rather than continuous space, see Places and data.
Theoretical networks
ssl.StaticNet makes a fixed network from a NetworkX graph: either a graph made beforehand, with one node for each agent, or a function that makes one, given with its arguments (n, the number of nodes, is filled in, and seed=True uses the sim’s random numbers). It is the way to use any of the many graphs that NetworkX can make: small-world, scale-free, regular, and so on. The most common have classes of their own: ssl.SmallWorldNet (Watts–Strogatz), ssl.ScaleFreeNet, and ssl.GridNet. Here a small-world graph (each person in contact with their 10 nearest neighbors on a ring, with 5% of contacts rewired) is compared with a random regular graph, made by StaticNet from NetworkX’s function, in which each person also has exactly 10 contacts but anywhere:
MultiSim: 2 runs of 2 scenarios (Small world, Random regular)
Done in 0.64 s
With no graph, ssl.StaticNet is the classic random (Erdős–Rényi) graph: each possible pair of people is connected with probability p (or n_contacts over the population, by default 10 contacts each), so each person’s number of contacts is binomial; it is useful for comparison with analytic results. For random contacts drawn again on every step, ss.RandomNet is the one to use. ssl.NullNet connects each agent only to themselves, with a weight of 0, so it never transmits: a placeholder while developing a model, or for testing a disease’s natural history alone.
Advanced: contacts when people are added
ss.RandomNet(method='safe') is a random network in which each agent’s contacts depend only on that agent’s own random numbers (see Random numbers). Here the same network is made for 1,000 and 1,001 agents, counting how many of the contacts among the first 1,000 survive the extra person:
defcontacts(net, n_agents):""" The contacts among the first 1,000 agents after one step, as a set of pairs """ sim = ss.Sim(networks=net, n_agents=n_agents, start='2000-01-01', dur=ss.days(1), dt=ss.days(1), verbose=0) sim.run() edges = sim.networks[0].to_df() edges = edges[(edges.p1 <1000) & (edges.p2 <1000)]returnset(zip(np.minimum(edges.p1, edges.p2), np.maximum(edges.p1, edges.p2)))networks = {'ss.RandomNet': lambda: ss.RandomNet(n_contacts=10),'ss.RandomNet (exact)': lambda: ss.RandomNet(n_contacts=10, method='exact'),'ss.RandomNet (safe)': lambda: ss.RandomNet(n_contacts=10, method='safe'),}for label, net in networks.items(): before =contacts(net(), 1000) after =contacts(net(), 1001)print(label, 'contacts kept (%):', round(100*len(before & after)/len(before)))
For comparing scenarios that add or remove people (births, deaths, migration), the safe network keeps the comparison sharp; for everything else, ss.RandomNet is faster. The appendix says which of the library’s networks keep their random numbers in this way.
Every network in ssl.networks, generated from the classes themselves: the class it builds on, its parameters and arguments with their defaults, and what its documentation says about common random numbers (“CRN safe”: whether adding or removing a person leaves everyone else’s contacts unchanged; see Random numbers). The networks built on ssl.StaticNet are fixed when the sim starts, so people born later have no contacts in them.
import reimport inspectimport pandas as pddefcrn_note(cls):""" The sentence of the docstring that says whether the network keeps common random numbers """ sentences = re.split(r'(?<=\.)\s+', ' '.join(inspect.getdoc(cls).split())) found = [s for s in sentences if'CRN safe'in s]return found[0].replace('Note: ', '').rstrip('.') if found else''defdefaults(cls):""" The network's own parameters and arguments, with their defaults """ net =cls(dhs_data='default') if cls is ssl.HouseholdNet elsecls() # HouseholdNet needs data: here, synthetic pars =dict(net.pars)for key, arg in inspect.signature(cls).parameters.items():if arg.default isnot inspect.Parameter.empty and key notin ['pars', 'dhs_data'] and key notin pars: pars[key] = arg.defaultreturn', '.join(f'{k}={v}'for k,v in pars.items())rows = []for name, cls invars(ssl.networks).items():if inspect.isclass(cls): rows.append(dict(network=f'ssl.{name}', based_on=cls.__bases__[0].__name__, parameters=defaults(cls), random_numbers=crn_note(cls)))pd.set_option('display.max_colwidth', None) # Show each entry in fullpd.DataFrame(rows).set_index('network')
based_on
parameters
random_numbers
network
ssl.HouseholdNet
Network
prob_move_out=ss.bernoulli(p=0.7), dynamic=None
not CRN safe unless `dynamic=False` (the partners who move out are drawn together)