Skip to content

Commit ac9f225

Browse files
author
Daniel Precioso
committed
Refactor SIS model documentation: improve clarity, enhance reproducibility, and update code examples
1 parent 0b216a6 commit ac9f225

1 file changed

Lines changed: 62 additions & 28 deletions

File tree

modules/networks/sis.qmd

Lines changed: 62 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ subtitle: "Guided Exercise"
44
format: html
55
---
66

7-
The SIS (Susceptible–Infected–Susceptible) is a simple epidemic model of disease spread where individuals can be infected multiple times. In this module, we will implement the SIS model on networks using the NetworkX library in Python. Our goal is to explore how **network structure** (random vs small-world vs scale-free) changes spreading.
7+
The SIS (Susceptible–Infected–Susceptible) model is a simple epidemic model where individuals can be infected multiple times. In this module, we implement SIS on networks using NetworkX and explore how **network structure** (random vs small-world vs scale-free) changes spreading.
88

99
## What is the SIS Model?
1010

11-
- **States:** Each node can be Susceptible (S) or Infected (I).
11+
- **States:** each node is Susceptible (S) or Infected (I).
1212
- **Transitions:**
13-
- $S \to I$: a susceptible node becomes infected through contacts (infection probability $\beta$).
14-
- $I \to S$: an infected node recovers and becomes susceptible again (recovery probability $\mu$).
15-
- **No permanent immunity:** Nodes can be reinfected after recovery.
13+
- $S \to I$: infection through contacts (controlled by $\beta$).
14+
- $I \to S$: recovery (controlled by $\mu$).
15+
- **No permanent immunity:** recovered nodes can be reinfected.
1616

1717
### Discrete-Time Update Rule
1818

@@ -72,6 +72,19 @@ initial_infected = 5 # should always be < N
7272
N = 100 # number of nodes in the graph
7373
```
7474

75+
::: {.callout-note}
76+
## About `SEED` (why we fix it)
77+
78+
There are **two** sources of randomness in this activity:
79+
80+
1. the **graph** you generate (e.g., which edges appear in Erdős–Rényi), and
81+
2. the **stochastic SIS dynamics** (who gets infected / recovers at each step).
82+
83+
We fix `SEED` so results are **reproducible**: rerunning the page gives the same plots, which makes debugging and reporting possible.
84+
85+
When we do many repetitions, we use `SEED + r` so repetition `r` is a **different random trial** (new graph + new random infection events) but still reproducible. If you reused the same `SEED` in every repetition, you would repeat the *same* realization and your “average over runs” would be meaningless.
86+
:::
87+
7588
## Choose a Network
7689

7790
For your project you will compare several models. Start by picking one. You can see a sample of the three models in the page: [Network Models](models.qmd).
@@ -172,7 +185,7 @@ def initialize_state(G, initial_infected=5, seed=SEED):
172185
return G
173186
```
174187

175-
Remember that you can use `np.random.choice` to pick random nodes from the graph. Make sure to set the seed for reproducibility!
188+
Remember that you can use a seeded random number generator (via `np.random.default_rng(seed)`) to pick random nodes from the graph.
176189

177190
::: {.callout-tip collapse="true"}
178191
## Solved: `initialize_state()`
@@ -181,16 +194,18 @@ Remember that you can use `np.random.choice` to pick random nodes from the graph
181194
#| echo: true
182195
#| message: false
183196
#| warning: false
184-
def initialize_state(G, initial_infected=5, seed=SEED):
185-
# Choose random nodes to be initially infected
186-
ls_infected = np.random.choice(
187-
G.nodes(), # list of all nodes
188-
size=initial_infected, # amount to choose
189-
replace=False, # avoid duplicates
190-
random_state=seed) # for reproducibility
191-
# Set all nodes to 'S' and then set the chosen ones to 'I'
192-
state = {node: "I" if node in ls_infected else "S" for node in G.nodes()}
193-
nx.set_node_attributes(G, state, "state")
197+
def initialize_state(G, initial_infected=5, seed=SEED, rng=None):
198+
if rng is None:
199+
rng = np.random.default_rng(seed)
200+
201+
# Start everyone susceptible
202+
nx.set_node_attributes(G, "S", "state")
203+
204+
# Infect a random subset
205+
infected = rng.choice(list(G.nodes()), size=initial_infected, replace=False)
206+
for node in infected:
207+
G.nodes[node]["state"] = "I"
208+
194209
return G
195210
```
196211
:::
@@ -219,7 +234,7 @@ def step_sis(G, beta, mu):
219234
return G
220235
```
221236

222-
Remember that you can loop through all nodes and their neighbors using `G.nodes()` and `G.neighbors(node)`. You can also use `np.random.random()` to generate random numbers for the probabilistic transitions.
237+
Remember that you can loop through all nodes and their neighbors using `G.nodes()` and `G.neighbors(node)`. Use `np.random.random()` for the probabilistic transitions.
223238

224239
::: {.callout-tip collapse="true"}
225240
## Solved: `step_sis()`
@@ -228,7 +243,10 @@ Remember that you can loop through all nodes and their neighbors using `G.nodes(
228243
#| echo: true
229244
#| message: false
230245
#| warning: false
231-
def step_sis(G, beta, mu):
246+
def step_sis(G, beta, mu, rng=None):
247+
if rng is None:
248+
rng = np.random.default_rng()
249+
232250
# Retrieve current states of all nodes
233251
current = {node: G.nodes[node]["state"] for node in G.nodes()}
234252
new_state = current.copy() # start with the same state for synchronous update
@@ -240,15 +258,17 @@ def step_sis(G, beta, mu):
240258
if m > 0:
241259
# Compute infection probability
242260
p_infect = 1 - (1 - beta) ** m
243-
if np.random.random() < p_infect:
261+
if rng.random() < p_infect:
244262
new_state[node] = "I"
245263
else: # current[node] == "I"
246-
if np.random.random() < mu:
264+
if rng.random() < mu:
247265
new_state[node] = "S"
248266
nx.set_node_attributes(G, new_state, "state")
249267
return G
250268
```
251269

270+
:::
271+
252272
Now wrap it into a simulator that records the number of infected nodes over time. The function `run_sis()` should take a graph and the parameters, run the simulation for a given number of steps, and return a list of the number of infected nodes at each time step.
253273

254274
```python
@@ -267,13 +287,17 @@ def run_sis(G, beta, mu, steps=100, initial_infected=5, seed=SEED):
267287
def run_sis(G, beta, mu, steps=100, initial_infected=5, seed=SEED):
268288
# Copy the graph to avoid mutating the original
269289
H = G.copy()
290+
291+
# One seeded RNG controls all randomness for this run
292+
rng = np.random.default_rng(seed)
293+
270294
# Initialize states
271-
initialize_state(H, initial_infected=initial_infected, seed=seed)
295+
initialize_state(H, initial_infected=initial_infected, seed=seed, rng=rng)
272296
# Count initial number of infected nodes and initialize the list
273297
infected_counts = [sum(H.nodes[node]["state"] == "I" for node in H.nodes())]
274298
# Apply SIS steps and record infected counts
275299
for _ in range(steps):
276-
step_sis(H, beta=beta, mu=mu)
300+
step_sis(H, beta=beta, mu=mu, rng=rng)
277301
infected_counts.append(sum(H.nodes[node]["state"] == "I" for node in H.nodes()))
278302
return infected_counts
279303
```
@@ -288,10 +312,11 @@ print("Infected counts over time:", counts)
288312

289313
## Run and Visualize
290314

291-
Use `matplotlib` to plot the fraction of infected nodes over time. Try to get a graph similar to @sis-plot below.
315+
Use `matplotlib` to plot the fraction of infected nodes over time. Try to get a graph similar to @fig-sis-plot below.
292316

293317
```{python}
294-
#| label: sis-plot
318+
#| label: fig-sis-plot
319+
#| fig-cap: "Fraction of infected nodes over time for a single SIS run on a random graph."
295320
#| fig-alt: 'Fraction of infected nodes over time for a single run of the SIS model on a random graph.'
296321
#| fig-width: 6
297322
#| fig-height: 4
@@ -308,10 +333,11 @@ plt.show()
308333

309334
## Compare Network Types
310335

311-
Finally, run multiple simulations for each graph type and plot the average infected fraction over time for each type on the same graph. @sis-compare is an example of what this could look like.
336+
Finally, run multiple simulations for each graph type and plot the average infected fraction over time for each type on the same graph. @fig-sis-compare is an example of what this could look like.
312337

313338
```{python}
314-
#| label: sis-compare
339+
#| label: fig-sis-compare
340+
#| fig-cap: "Mean infected fraction over time for different network models (random, small-world, scale-free)."
315341
#| fig-alt: 'Average infected fraction over time for different network models (random, small-world, scale-free).'
316342
#| fig-width: 6
317343
#| fig-height: 4
@@ -327,8 +353,16 @@ plt.figure(figsize=(6, 4))
327353
for model in models:
328354
trajectories = []
329355
for r in range(runs):
330-
Gm = make_graph(model, n=N, seed=SEED + r)
331-
counts = run_sis(Gm, beta=beta, mu=mu, steps=steps, initial_infected=initial_infected, seed=SEED + r)
356+
seed_run = SEED + r
357+
Gm = make_graph(model, n=N, seed=seed_run)
358+
counts = run_sis(
359+
Gm,
360+
beta=beta,
361+
mu=mu,
362+
steps=steps,
363+
initial_infected=initial_infected,
364+
seed=seed_run,
365+
)
332366
trajectories.append(np.array(counts) / N)
333367
334368
mean_traj = np.mean(trajectories, axis=0)

0 commit comments

Comments
 (0)