"""SIR-style model simulation.
The module defines a [Sim] class. The class is initialized with base parameters
for the simulation. The [step] method allows one to step through the
simulation one or many time periods at a time. Infection spread and discovery
parameters can be specified and the base parameters are used by default.
"""
__author__ = "Peter Frazier (peter-i-frazier)"
import numpy as np
from typing import Union, Dict
[docs]
class Sim:
"""
Maintains an SIR-style model simulation on a heterogeneous population.
Infections last a single generation, after which people recover and are
immune for the remainder of the simulation. Infections may be discovered
immediately (e.g., through symptomatic self-reporting or surveillance) or
may be discovered later (e.g., because of an asymptomatic test applied to
the whole population).
During execution, S, I, and R are TxK matrices that track the number of
susceptible, infectious, and recovered people in each of the K groups over
T time periods. Additionally, D and H track the number of discovered and
hidden non-susceptible people.
"""
def __init__(self, max_T: int, init_susceptible: np.ndarray,
init_infected: np.ndarray, init_recovered: np.ndarray,
infection_rate: np.ndarray,
init_discovered: np.ndarray = None,
init_hidden: np.ndarray = None,
infection_discovery_frac: Union[float,np.ndarray] = 1,
recovered_discovery_frac: Union[float,np.ndarray] = 1,
outside_rate: np.ndarray = 0):
"""Initialize an SIR-style model simulation.
The initial state of the simulation is passed through the
[init_susceptible], [init_infected], and [init_recovered] parameters.
The [init_discovered] and [init_hidden] are optional parameters. If
they are not set, the initial discovered and hidden assumes all
recovered are discovered and [infection_discovery_frac] of infected
are discovered.
Internal infection spread is determined by the [infection_rate] matrix
parameter which indicates the number new infections that an infected
person in one group creates in another. Additionally, the
[outside_rate] parameter specifies the outside rate of infection.
To model infection discovery, the [infection_discovery_frac]
parameter determines what fraction of infected are discovered in the
generation they are infected. Otherwise, they become "hidden
recovered." Furthermore, the [recovered_discovery_frac] parameter
determines what fraction of hidden recovered infections are discovered
in each generation.
Args:
max_T (int): Maximum number of time periods to simulate.
init_susceptible (np.ndarray): Vector of the initial number of \
people in each group that are susceptible.
init_infected (np.ndarray): Vector of the initial number of \
people in each group that are infected.
init_recovered (np.ndarray): Vector of the initial number of \
people in each group that are recovered.
infection_rate (np.ndarray): Matrix where infection_rate[i,j] is \
the number of new infections that an infected person in \
group i creates in group j
init_discovered (np.ndarray): Vector of the initial number of \
people in each group that are discovered.
init_hidden (np.ndarray): Vector of the initial number of \
people in each group that are hidden.
infection_discovery_frac (float or np.ndarray): Fraction of \
infections discovered in the generation they are infected.
Can be specified globally (as a float) or separately for \
each group (as a np.ndarray). Defaults to 1.
recovered_discovery_frac (float or np.ndarray): Fraction of \
hidden recovered discovered in each generation. Can be \
specified globally (as a float) or separately for each group \
(as a np.ndarray). Defaults to 1.
outside_rate (np.ndarray): infections per time period, weighed by \
population of each group in a meta-group.
"""
assert (max_T > 0)
self.max_T = max_T # number of periods to simulate
self._t = 0
self.K = len(init_susceptible) # number of groups
assert (len(init_infected) == self.K)
assert (len(init_recovered) == self.K)
self.infection_discovery_frac = \
self._validate_discovery_frac(infection_discovery_frac, self.K)
self.recovered_discovery_frac = \
self._validate_discovery_frac(recovered_discovery_frac, self.K)
assert ((init_susceptible >= 0).all())
assert ((init_infected >= 0).all())
assert ((init_recovered >= 0).all())
self._S = np.zeros((self.max_T+1, self.K)) # susceptible
self._I = np.zeros((self.max_T+1, self.K)) # infected
self._R = np.zeros((self.max_T+1, self.K)) # recovered
self._D = np.zeros((self.max_T+1, self.K)) # discovered
self._H = np.zeros((self.max_T+1, self.K)) # hidden
self._S[0] = init_susceptible
self._I[0] = init_infected
self._R[0] = init_recovered
if init_discovered is None:
self._D[0] = self._R[0] + \
(self._I[0] * self.infection_discovery_frac)
else:
self._D[0] = init_discovered
if init_hidden is None:
self._H[0] = self._I[0] * (1 - self.infection_discovery_frac)
else:
self._H[0] = init_hidden
initial_IR = self._I[0] + self._R[0]
initial_DH = self._D[0] + self._H[0]
assert np.isclose(initial_IR, initial_DH).all()
self.infection_rate = infection_rate
self.outside_rate = outside_rate
[docs]
@staticmethod
def from_dictionary(d: Dict):
"""Initialize an instance of Sim from a dictionary."""
T = d["T"]
S0 = np.array(d["S0"])
I0 = np.array(d["I0"])
R0 = np.array(d["R0"])
D0 = None if d["D0"] is None else np.array(d["D0"])
H0 = None if d["H0"] is None else np.array(d["H0"])
infection_rate = np.array(d["infection_rate"])
infection_discovery_frac = np.array(d["infection_discovery_frac"])
recovered_discovery_frac = np.array(d["recovered_discovery_frac"])
outside_rate = np.array(d["outside_rate"])
return Sim(max_T=T, init_susceptible=S0, init_infected=I0,
init_recovered=R0, infection_rate=infection_rate,
init_discovered=D0, init_hidden=H0,
infection_discovery_frac=infection_discovery_frac,
recovered_discovery_frac=recovered_discovery_frac,
outside_rate=outside_rate)
@property
def S(self):
return self._S.copy()
@S.setter
def S(self, value):
self._S = value
@property
def I(self):
return self._I.copy()
@I.setter
def I(self, value):
self._I = value
@property
def R(self):
return self._R.copy()
@R.setter
def R(self, value):
self._R = value
@property
def D(self):
return self._D.copy()
@D.setter
def D(self, value):
self._D = value
@property
def H(self):
return self._H.copy()
@H.setter
def H(self, value):
self._H = value
[docs]
def step(self, n: int = 1, infection_rate: np.ndarray = None,
infection_discovery_frac: Union[float,np.ndarray] = None,
recovered_discovery_frac: Union[float,np.ndarray] = None,
outside_rate: np.ndarray = None):
"""Take [n] steps forward in the simulation.
To model change in infection and discovery parameters over time, these
parameters can be provided to be applied to the next [n] time periods.
Args:
infection_rate (np.ndarray): Matrix where infection_rate[i,j] is \
the number of new infections that an infected person in \
group i creates in group j
infection_discovery_frac (float or np.ndarray): Fraction of \
infections discovered in the generation they are infected.
Can be specified globally (as a float) or separately for \
each group (as a np.ndarray). Defaults to 1.
recovered_discovery_frac (float or np.ndarray): Fraction of \
hidden recovered discovered in each generation. Can be \
specified globally (as a float) or separately for each group \
(as a np.ndarray). Defaults to 1.
outside_rate (np.ndarray): infections per time period, weighed by \
population of each group in a meta-group.
"""
assert (n >= 1)
if n > 1:
for _ in range(n):
self.step(infection_rate=infection_rate,
infection_discovery_frac=infection_discovery_frac,
recovered_discovery_frac=recovered_discovery_frac,
outside_rate=outside_rate)
return
if infection_rate is None:
infection_rate = self.infection_rate
if infection_discovery_frac is None:
infection_discovery_frac = self.infection_discovery_frac
else:
infection_discovery_frac = \
self._validate_discovery_frac(infection_discovery_frac, self.K)
if recovered_discovery_frac is None:
recovered_discovery_frac = self.recovered_discovery_frac
else:
recovered_discovery_frac = \
self._validate_discovery_frac(recovered_discovery_frac, self.K)
if outside_rate is None:
outside_rate = self.outside_rate
t = self._t
assert (t < self.max_T) # enforce max generation
# Fraction susceptible in each group
# self._S[t] / (self._S[t] + self._I[t] + self._R[t])
frac_susceptible = \
np.divide(self._S[t], (self._S[t] + self._I[t] + self._R[t]),
out=np.zeros_like(self._S[t]),
where=(self._S[t] + self._I[t] + self._R[t]) != 0)
# Infected from internal spread and outside rate
self._I[t+1] = np.matmul(self._I[t], infection_rate) * frac_susceptible
self._I[t+1] += frac_susceptible * outside_rate
# Can not infect more than the infected number of people
self._I[t+1] = np.minimum(self._I[t+1], self._S[t])
# Set susceptible to reflect those that were infected and set
# recovered to be the number of people previously infected
self._S[t+1] = self._S[t] - self._I[t+1]
self._R[t+1] = self._R[t] + self._I[t]
# Discover some fraction of hidden recoveries
self._D[t+1] = \
self._D[t] + np.multiply(self._H[t], recovered_discovery_frac)
self._H[t+1] = np.multiply(self._H[t], 1 - recovered_discovery_frac)
# Discover some fraction of those infected in this time period
self._D[t+1] += np.multiply(self._I[t+1], infection_discovery_frac)
self._H[t+1] += np.multiply(self._I[t+1], 1-infection_discovery_frac)
self._t = self._t + 1 # move time forward by one step
return self
@staticmethod
def _validate_discovery_frac(x, K):
"""Return validated discovery fraction vector."""
if np.isscalar(x):
x = x * np.ones(K)
assert (x >= 0).all()
assert (x <= 1).all()
return x