Source code for simpar.groups
"""Heterogeneous population manager.
This module defines a [MetaGroup] class which describes a group of people
for which most parameters are similar but who have varying degrees of social
contact. Furthermore, it defines a [Population] which is comprised of multiple
[MetaGroup]s. Testing strategies can be set at granularity of [MetaGroup]s.
"""
__author__ = "Sam Tan (samstan) and Xiangyu Zhang (xiangyu-zhang-95)"
from typing import Dict
import numpy as np
from scipy.stats import pareto
[docs]
class MetaGroup:
"""
Initialize a meta-group.
A "meta-group" is a collection of groups. Each group within the meta-group
is associated with a level of social contact.
The population is assumed to be well-mixed meaning that group i's
amount of interaction with group j is proportional to group j's fraction
of the population and both groups contact levels.
In a school setting, example meta-groups could be undergraduates,
professional students, graduate students, and faculty/staff.
"""
def __init__(self, name, pop, contact_units):
assert (len(pop) == len(contact_units))
self.name = name
self.contact_units = contact_units
self.K = len(contact_units)
self.pop = pop
[docs]
@staticmethod
def from_truncated_pareto(name: str, population: float, a: float, ub: int):
"""Initialize a meta-group from a truncated Pareto distribution.
Args:
name (str): Name of the metagroup.
population (float): Total population in this metagroup.
a (float): Shape parameter (alpha) for the Pareto distribution.
ub (int): Truncation point for the Pareto distribution.
"""
pop_frac = np.array([pareto.pdf(k,a) for k in range(1, ub+1)])
pop_frac = pop_frac / np.sum(pop_frac) # normalize
pop = population * pop_frac
contact_units = np.arange(1, len(pop) + 1)
return MetaGroup(name, pop, contact_units)
[docs]
def infection_matrix(self, infections_per_contact_unit: float):
"""Return the infection matrix."""
# Infection matrix among these groups assumes population is well-mixed.
marginal_contact = self.pop * self.contact_units / \
np.sum(self.pop * self.contact_units)
return infections_per_contact_unit * \
np.outer(self.contact_units, marginal_contact)
[docs]
def outside_rate(self, outside_rate: float):
"""Return the outside rate."""
# Outside rate assumes well-mixed with external population
return outside_rate * (self.pop * self.contact_units
/ np.sum(self.pop * self.contact_units))
[docs]
def get_init_SIR_and_DH(self, init_infections: float,
init_recovered: float,
init_discovered: float = None,
init_hidden: float = None,
weight: str = "population"):
"""Return initial SIR and DH vectors.
Given [init_infections] and [init_recovered] and optionally
[init_discovered] and [init_hidden] aggregated at the meta-group level,
the [weight] parameter specifies how these counts should be distributed
across the groups within this metagroup. The available options for
[weight] are:
- "population": Each person is equally likely to be infected.
- "population x contacts": A person's probability of being infected is
proportional to their amount of contact.
- "most social": The initial infections are in the most social group.
If [init_discovered] and [init_hidden] are not provided, assume that
everyone is discovered.
Args:
init_infections (float): Initial infections count in meta-group.
init_recovered (float): Initial recovered count in meta-group.
init_discovered (float): Initial discovered count in meta-group.
init_hidden (float): Initial hidden count in meta-group.
weight (str): {population, population x contacts, most_social}
"""
if init_discovered is None:
init_discovered = init_infections + init_recovered
init_hidden = 0
if weight == "population":
w = self.pop
elif weight == "population x contacts":
w = self.contact_units * self.pop
elif weight == "most_social":
w = np.zeros(self.K)
w[-1] = 1
else:
raise ValueError("The provided weight is not supported.")
w = w / np.sum(w) # normalize
R0 = init_recovered * w
I0 = init_infections * w
S0 = np.maximum(self.pop - R0 - I0, 0)
D0 = init_discovered * w
H0 = init_hidden * w
return S0, I0, R0, D0, H0
[docs]
class Population:
"""
A population is a collection of meta-groups.
"""
def __init__(self, meta_groups_dict: Dict[str, MetaGroup],
meta_group_contact_matrix: np.ndarray):
"""
Initialize a population.
A population is defined by a dictionary of meta-groups:
[meta_groups_dict]. Meta-group interactions are not assumed to be
well-mixed. The matrix [meta_group_contact_matrix] indicates how
metagroups interact with one another. This is encoded in the
[infection_matrix] which is normalized to be in "contact units."
Args:
meta_groups_dict (Dict[str, MetaGroup]): Dictionary of meta-groups.
meta_group_contact_matrix (np.ndarray): Interactions between \
meta-groups where entry (i,j) is the conditional probability \
that the exposed is in meta-group j, given that the source is \
in meta-group i.
"""
self.meta_groups_dict = meta_groups_dict
self.meta_group_names = list(self.meta_groups_dict.keys())
self.meta_group_list = list(self.meta_groups_dict.values())
self.meta_group_contact_matrix = meta_group_contact_matrix
cum_tot = np.cumsum([mg.K for _, mg in meta_groups_dict.items()])
self._meta_group2idx = {}
for i, (name, mg) in enumerate(meta_groups_dict.items()):
self._meta_group2idx[name] = \
list(range(cum_tot[i] - mg.K, cum_tot[i]))
[docs]
@staticmethod
def from_truncated_paretos_dictionary(d: Dict):
"""Return a [Population] initialized from the given dictionary."""
meta_groups = {}
for key in d["meta_groups"]:
name = d["meta_group_names"][key]
pop = d["population_counts"][key]
a = d["pop_pareto_shapes"][key]
ub = d["pop_pareto_ubs"][key]
meta_group = MetaGroup.from_truncated_pareto(name, pop, a, ub)
meta_groups[key] = meta_group
meta_group_contact_matrix = np.array(d["meta_group_contact_matrix"])
return Population(meta_groups, meta_group_contact_matrix)
[docs]
def meta_group_ids(self, meta_group):
"""Return the group ids of the groups in the given meta-group."""
return self._meta_group2idx[meta_group]
[docs]
def infection_matrix(self, infections_per_contact_unit: float):
"""Return the infection matrix."""
Ks = [mg.K for mg in self.meta_group_list]
dim_tot = np.sum(Ks)
cum_tot = np.hstack((0, np.cumsum(Ks)))
res = np.zeros((dim_tot, dim_tot))
for a, source in enumerate(self.meta_group_list):
for b, exposed in enumerate(self.meta_group_list):
for i in range(source.K):
for j in range(exposed.K):
q = exposed.pop[j] * exposed.contact_units[j] / \
np.sum(exposed.pop * exposed.contact_units)
res[cum_tot[a]+i, cum_tot[b]+j] = \
source.contact_units[i] * \
infections_per_contact_unit[a] * \
self.meta_group_contact_matrix[a,b] * q
return res
[docs]
def infection_discovery_frac(self, infection_discovery_frac):
"""Return the fraction of infections that are discovered."""
res = []
for i, mg in enumerate(self.meta_group_list):
for _ in range(mg.K):
res.append(infection_discovery_frac[i])
return np.array(res)
[docs]
def recovered_discovery_frac(self, recovered_discovery_frac):
"""Return the fraction of recovered that are discovered."""
res = []
for i, mg in enumerate(self.meta_group_list):
for _ in range(mg.K):
res.append(recovered_discovery_frac[i])
return np.array(res)
[docs]
def outside_rate(self, outside_rates: np.ndarray):
"""Return the outside rate."""
Ks = [mg.K for mg in self.meta_group_list]
dim_tot = np.sum(Ks)
cum_tot = np.hstack((0, np.cumsum(Ks)))
res = np.zeros(dim_tot)
for i, meta_group in enumerate(self.meta_group_list):
for j in range(self.meta_group_list[i].K):
q = meta_group.pop[j] / np.sum(meta_group.pop)
res[cum_tot[i]+j] = outside_rates[i] * q
return res
[docs]
def get_init_SIR_and_DH(self, init_infections: np.ndarray,
init_recovered: np.ndarray,
init_discovered: np.ndarray = None,
init_hidden: np.ndarray = None,
weight: str = "population"):
"""Return initial SIR and DH vectors.
Given [init_infections] and [init_recovered] and optionally
[init_discovered] and [init_hidden] at the meta-group level, the
[weight] parameter specifies how these counts should be distributed
across the groups within each metagroup. The available options for
[weight] are:
- "population": Each person is equally likely to be infected.
- "population x contacts": A person's probability of being infected is
proportional to their amount of contact.
- "most social": The initial infections are in the most social group.
If [init_discovered] and [init_hidden] are not provided, assume that
everyone is discovered.
Args:
init_infections (np.ndarray): Initial infections per meta-group.
init_recovered (np.ndarray): Initial recovered per meta-group.
init_discovered (np.ndarray): Initial discovered per meta-group.
init_hidden (np.ndarray): Initial hidden per meta-group.
weight (str): {population, population x contacts, most_social}
"""
if init_discovered is None:
init_discovered = init_infections + init_recovered
init_hidden = np.zeros(len(init_discovered))
SIRDH = [[],[],[],[],[]]
for i in range(len(self.meta_group_list)):
group = self.meta_group_list[i]
S0, I0, R0, D0, H0 = \
group.get_init_SIR_and_DH(init_infections[i],
init_recovered[i],
init_discovered[i],
init_hidden[i], weight=weight)
SIRDH[0] += list(S0)
SIRDH[1] += list(I0)
SIRDH[2] += list(R0)
SIRDH[3] += list(D0)
SIRDH[4] += list(H0)
SIRDH = np.array(SIRDH)
return SIRDH[0], SIRDH[1], SIRDH[2], SIRDH[3], SIRDH[4]