'''
Handle sim parameters
'''
import numpy as np
import sciris as sc
from . import utils as fpu
from . import defaults as fpd
__all__ = ['Pars', 'pars', 'default_pars']
# %% Pars (parameters) class
def getval(v):
""" Handle different ways of supplying a value -- number, distribution, function """
if sc.isnumber(v):
return v
elif isinstance(v, dict):
return fpu.sample(**v)[0] # [0] since returns an array
elif callable(v):
return v()
[docs]
class Pars(dict):
'''
Class to hold a dictionary of parameters, and associated methods.
Usually not called by the user directly -- use ``fp.pars()`` instead.
Args:
pars (dict): dictionary of parameters
'''
def __init__(self, pars=None, *args, **kwargs):
if pars is None:
pars = {}
super().__init__(*args, **kwargs)
self.update(pars)
return
def __repr__(self, *args, **kwargs):
''' Use odict repr, but with a custom class name and no quotes '''
return sc.odict.__repr__(self, quote='', numsep='.', classname='fp.Parameters()', *args, **kwargs)
[docs]
def copy(self):
''' Shortcut for deep copying '''
return sc.dcp(self)
[docs]
def to_dict(self):
''' Return parameters as a new dictionary '''
return {k: v for k, v in self.items()}
[docs]
def to_json(self, filename, **kwargs):
'''
Export parameters to a JSON file.
Args:
filename (str): filename to save to
kwargs (dict): passed to ``sc.savejson``
**Example**::
sim.pars.to_json('my_pars.json')
'''
return sc.savejson(filename=filename, obj=self.to_dict(), **kwargs)
[docs]
def from_json(self, filename, **kwargs):
'''
Import parameters from a JSON file.
Args:
filename (str): filename to load from
kwargs (dict): passed to ``sc.loadjson``
**Example**::
sim.pars.from_json('my_pars.json')
'''
pars = sc.loadjson(filename=filename, **kwargs)
self.update(pars)
return self
[docs]
def validate(self, die=True, update=True):
'''
Perform validation on the parameters
Args:
die (bool): whether to raise an exception if an error is encountered
update (bool): whether to update the method and age maps
'''
# Check that keys are correct
valid_keys = set(default_pars.keys())
keys = set(self.keys())
if keys != valid_keys:
diff1 = valid_keys - keys
diff2 = keys - valid_keys
errormsg = ''
if diff1:
errormsg += 'The parameter set is not valid since the following keys are missing:\n'
errormsg += f'{sc.strjoin(diff1)}\n'
if diff2:
errormsg += 'The parameter set is not valid since the following keys are not recognized:\n'
errormsg += f'{sc.strjoin(diff2)}\n'
if die:
raise ValueError(errormsg)
else:
print(errormsg)
# Validate method properties
methods = self['methods']
new_method_map = methods['map']
new_method_age_map = methods['age_map']
n = len(new_method_map)
method_keys = list(new_method_map.keys())
modern_keys = list(methods['modern'].keys())
eff_keys = list(methods['eff'].keys())
if not (method_keys == eff_keys == modern_keys):
errormsg = f'Mismatch between method mapping keys:\n{method_keys}",\nmodern keys\n"{modern_keys}", and efficacy keys\n"{eff_keys}"'
if die:
raise ValueError(errormsg)
else:
print(errormsg)
# Validate method matrices
raw = methods['raw']
age_keys = set(new_method_age_map.keys())
for mkey in ['annual', 'pp0to1', 'pp1to6']:
m_age_keys = set(raw[mkey].keys())
if age_keys != m_age_keys:
errormsg = f'Matrix "{mkey}" has inconsistent keys: "{sc.strjoin(age_keys)}" ≠ "{sc.strjoin(m_age_keys)}"'
if die:
raise ValueError(errormsg)
else:
print(errormsg)
for k in age_keys:
shape = raw['pp0to1'][k].shape
if shape != (n,):
errormsg = f'Postpartum method initiation matrix for ages {k} has unexpected shape: should be ({n},), not {shape}'
if die:
raise ValueError(errormsg)
else:
print(errormsg)
for mkey in ['annual', 'pp1to6']:
shape = raw[mkey][k].shape
if shape != (n, n):
errormsg = f'Method matrix {mkey} for ages {k} has unexpected shape: should be ({n},{n}), not {shape}'
if die:
raise ValueError(errormsg)
else:
print(errormsg)
# Copy to defaults, making use of mutable objects to preserve original object ID
if update:
for k in list(fpd.method_map.keys()): fpd.method_map.pop(k) # Remove all items
for k in list(fpd.method_age_map.keys()): fpd.method_age_map.pop(k) # Remove all items
for k, v in new_method_map.items():
fpd.method_map[k] = v
for k, v in new_method_age_map.items():
fpd.method_age_map[k] = v
return self
def _as_ind(self, key, allow_none=True):
'''
Take a method key and convert to an int, e.g. 'Condoms' → 7.
If already an int, do validation.
'''
mapping = self['methods']['map']
keys = list(mapping.keys())
# Validation
if key is None and not allow_none:
errormsg = "No key supplied; did you mean 'None' instead of None?"
raise ValueError(errormsg)
# Handle options
if key in fpd.none_all_keys:
ind = slice(None) # This is equivalent to ":" in matrix[:,:]
elif isinstance(key, str): # Normal case, convert from key to index
try:
ind = mapping[key]
except KeyError as E:
errormsg = f'Key "{key}" is not a valid method'
raise sc.KeyNotFoundError(errormsg) from E
elif isinstance(key, int): # Already an int, do nothing
ind = key
if ind < len(keys):
key = keys[ind]
else:
errormsg = f'Method index {ind} is out of bounds for methods {sc.strjoin(keys)}'
raise IndexError(errormsg)
else:
errormsg = f'Could not process key of type {type(key)}: must be str or int'
raise TypeError(errormsg)
return ind
def _as_key(self, ind):
'''
Convert ind to key, e.g. 7 → 'Condoms'.
If already a key, do validation.
'''
keys = list(self['methods']['map'].keys())
if isinstance(ind, int): # Normal case, convert to string
if ind < len(keys):
key = keys[ind]
else:
errormsg = f'Method index {ind} is out of bounds for methods {sc.strjoin(keys)}'
raise IndexError(errormsg)
elif isinstance(ind, str): # Already a string, do nothing
key = ind
if key not in keys:
errormsg = f'Name "{key}" is not a valid method: choices are {sc.strjoin(keys)}'
raise sc.KeyNotFoundError(errormsg)
else:
errormsg = f'Could not process index of type {type(ind)}: must be int or str'
raise TypeError(errormsg)
return key
[docs]
def update_method_eff(self, method, eff=None, verbose=False):
'''
Update efficacy of one or more contraceptive methods.
Args:
method (str/dict): method to update, or dict of method:value pairs
eff (float): new value of contraceptive efficacy (not required if method is a dict)
**Examples**::
pars.update_method_eff('Injectables', 0.99)
pars.update_method_eff({'Injectables':0.99, 'Condoms':0.50})
'''
# Validation
if not isinstance(method, dict):
if eff is None:
errormsg = 'Must supply a value to update the contraceptive efficacy to'
raise ValueError(errormsg)
else:
method = {method: eff}
# Perform updates
for k, rawval in method.items():
k = self._as_key(k)
v = getval(rawval)
effs = self['methods']['eff']
orig = effs[k]
effs[k] = v
if verbose:
print(f'Efficacy for method {k} was changed from {orig:0.3f} to {v:0.3f}')
return self
[docs]
def update_method_prob(self, source=None, dest=None, factor=None, value=None,
ages=None, matrix=None, copy_from=None, verbose=False):
'''
Updates the probability matrices with a new value. Usually used via the
intervention ``fp.update_methods()``.
Args:
source (str/int): the method to switch from
dest (str/int): the method to switch to
factor (float): if supplied, multiply the probability by this factor
value (float): if supplied, change the probability to this value
ages (str/list): the ages to modify (default: all)
matrix (str): which switching matrix to modify (default: annual)
copy_from (str): the existing method to copy the probability vectors from (optional)
verbose (bool): how much detail to print
'''
raw = self['methods']['raw'] # We adjust the raw matrices, so the effects are persistent
# Convert from strings to indices
if copy_from:
copy_from = self._as_ind(copy_from, allow_none=False)
if source is None: # We need a source, but it's not always used
source = copy_from
source = self._as_ind(source, allow_none=False)
dest = self._as_ind(dest, allow_none=False)
# Replace age keys with all ages if so asked
if ages in fpd.none_all_keys:
ages = raw['annual'].keys()
else:
ages = sc.tolist(ages)
# Check matrix is valid
if matrix not in raw:
errormsg = f'Invalid matrix "{matrix}"; valid choices are: {sc.strjoin(raw.keys())}'
raise sc.KeyNotFoundError(errormsg)
# Actually loop over the matrices and apply the changes
for k in ages:
arr = raw[matrix][k]
if matrix == 'pp0to1': # Handle the postpartum initialization *vector*
orig = arr[dest] # Pull out before being overwritten
# Handle copy from
if copy_from is not None:
arr[dest] = arr[copy_from]
# Handle everything else
if factor is not None:
arr[dest] *= getval(factor)
elif value is not None:
val = getval(value)
arr[dest] = 0
arr *= (1 - val) / arr.sum()
arr[dest] = val
assert np.isclose(arr.sum(), 1, atol=1e-3), f'Matrix should sum to 1, not {arr.sum()}'
if verbose:
print(f'Matrix {matrix} for age group {k} was changed from:\n{orig}\nto\n{arr[dest]}')
else: # Handle annual switching *matrices*
orig = sc.dcp(arr[source, dest])
# Handle copy from
if copy_from is not None:
arr[:, dest] = arr[:, copy_from]
arr[dest, :] = arr[copy_from, :]
median_init = np.median(arr[:, copy_from])
median_discont = np.median(arr[copy_from, :])
arr[dest, dest] = arr[copy_from, copy_from] # Replace diagonal element with correct version
arr[copy_from, dest] = median_init
arr[dest, copy_from] = median_discont
# Handle modifications
if factor is not None:
arr[source, dest] *= getval(factor)
elif value is not None:
val = getval(value)
arr[source, dest] = 0
arr[source, :] *= (1 - val) / arr[source, :].sum()
arr[source, dest] = val
assert np.isclose(arr[source, :].sum(), 1, atol=1e-3), f'Matrix should sum to 1, not {arr.sum()}'
if verbose:
print(f'Matrix {matrix} for age group {k} was changed from:\n{orig}\nto\n{arr[source, dest]}')
return self
[docs]
def reset_methods_map(self):
''' Refresh the methods map to be self-consistent '''
methods = self['methods']
methods['map'] = {k: i for i, k in enumerate(methods['map'].keys())} # Reset numbering
return self
[docs]
def add_method(self, name, eff, modern=True):
'''
Add a new contraceptive method to the switching matrices.
A new method should only be added before the sim is run, not during.
Note: the matrices are stored in ``pars['methods']['raw']``; this method
is a helper function for modifying those. For more flexibility, modify
them directly. The ``fp.update_methods()`` intervention can be used to
modify the switching probabilities later.
Args:
name (str): the name of the new method
eff (float): the efficacy of the new method
modern (bool): whether it's a modern method (default: yes)
**Examples**::
pars = fp.pars()
pars.add_method('New method', 0.90)
pars.add_method(name='Male pill', eff=0.98, modern=True)
'''
# Remove from mapping and efficacy
methods = self['methods']
n = len(methods['map'])
methods['map'][name] = n # Can't use reset_methods_map since need to define the new entry
methods['modern'][name] = modern
methods['eff'][name] = eff
# Modify method matrices
raw = methods['raw']
age_keys = methods['age_map'].keys()
for k in age_keys:
# Handle the initiation matrix
pp0to1 = raw['pp0to1']
pp0to1[k] = np.append(pp0to1[k], 0) # Append a zero to the end
# Handle the other matrices
for mkey in ['annual', 'pp1to6']:
matrix = raw[mkey]
zeros_row = np.zeros((1, n))
zeros_col = np.zeros((n + 1, 1))
matrix[k] = np.append(matrix[k], zeros_row, axis=0) # Append row to bottom
matrix[k] = np.append(matrix[k], zeros_col, axis=1) # Append column to right
matrix[k][n, n] = 1.0 # Set everything to zero except continuation
# Validate
self.validate()
return self
[docs]
def rm_method(self, name):
'''
Removes a contraceptive method from the switching matrices.
A method should only be removed before the sim is run, not during, since
the method associated with each person in the sim will point to the wrong
index.
Args:
name (str/ind): the name or index of the method to remove
**Example**::
pars = fp.pars()
pars.rm_method('Other modern')
'''
# Get index of method to remove
ind = self._as_ind(name, allow_none=False)
key = self._as_key(name)
# Store a copy for debugging
methods = self['methods']
methods['map_orig'] = sc.dcp(methods['map'])
# Remove from mapping and efficacy
for parkey in ['map', 'modern', 'eff']:
methods[parkey].pop(key)
self.reset_methods_map()
# Modify method matrices
raw = methods['raw']
age_keys = methods['age_map'].keys()
for k in age_keys:
# Handle the initiation matrix
pp0to1 = raw['pp0to1']
pp0to1[k] = np.delete(pp0to1[k], ind)
# Handle the other matrices
for mkey in ['annual', 'pp1to6']:
matrix = raw[mkey]
for axis in [0, 1]:
matrix[k] = np.delete(matrix[k], ind, axis=axis)
# Validate
self.validate()
return self
[docs]
def reorder_methods(self, order):
'''
Reorder the contraceptive method matrices.
Method reordering should be done before the sim is created (or at least before it's run).
Args:
order (arr): the new order of methods, either ints or strings
sim (Sim): if supplied, also reorder
**Exampls**::
pars = fp.pars()
pars.reorder_methods([2, 6, 4, 7, 0, 8, 5, 1, 3])
'''
# Store a copy for debugging
methods = self['methods']
orig = sc.dcp(methods['map'])
orig_keys = list(orig.keys())
methods['map_orig'] = orig
# Reorder mapping and efficacy
if isinstance(order[0], str): # If strings are supplied, convert to ints
order = [orig_keys.index(k) for k in order]
order_set = sorted(set(order))
orig_set = sorted(set(np.arange(len(orig_keys))))
# Validation
if order_set != orig_set:
errormsg = f'Reordering "{order}" does not match indices of methods "{orig_set}"'
raise ValueError(errormsg)
# Reorder map and efficacy -- TODO: think about how to implement rename as well
new_keys = [orig_keys[k] for k in order]
for parkey in ['map', 'modern', 'eff']:
methods[parkey] = {k: methods[parkey][k] for k in new_keys}
self.reset_methods_map() # Restore ordering
# Modify method matrices
raw = methods['raw']
age_keys = methods['age_map'].keys()
for k in age_keys:
# Handle the initiation matrix
pp0to1 = raw['pp0to1']
pp0to1[k] = pp0to1[k][order]
# Handle the other matrices
for mkey in ['annual', 'pp1to6']:
matrix = raw[mkey]
matrix[k] = matrix[k][:, order][order]
# Validate
self.validate()
return self
# %% Parameter creation functions
# Dictionary with all parameters used within an FPsim.
# All parameters that don't vary across geographies are defined explicitly here.
# Keys for all location-specific parameters are also defined here with None values.
default_pars = {
# Basic parameters
'location': None, # CONTEXT-SPECIFIC ####
'n_agents': 1_000, # Number of agents
'scaled_pop': None, # Scaled population / total population size
'start_year': 1960, # Start year of simulation
'end_year': 2020, # End year of simulation
'timestep': 1, # The simulation timestep in months
'method_timestep': 1, # How many simulation timesteps to go for every method update step
'seed': 1, # Random seed
'verbose': 1, # How much detail to print during the simulation
# Settings - what aspects are being modeled
'track_switching': 0, # Whether to track method switching
'track_as': 0, # Whether to track age-specific channels
'use_urban': 0, # Whether to model urban setting state - will need to add context-specific data if using
'use_partnership': 0, # Whether to model partnered states- will need to add context-specific data if using
'use_empowerment': 0, # Whether to model empowerment - will need to add context-specific data if using
'use_education': 0, # Whether to model education, requires use_urban==True for kenya - will need to add context-specific data if using
'use_subnational': 0, # Whether to model subnational dynamics (only modeled for ethiopia currently) - will need to add context-specific data if using
# Age limits (in years)
'method_age': 15,
'age_limit_fecundity': 50,
'max_age': 99,
# Durations (in months)
'switch_frequency': 12, # How frequently to check for changes to contraception
'end_first_tri': 3,
'preg_dur_low': 9,
'preg_dur_high': 9,
'max_lam_dur': 5, # Duration of lactational amenorrhea
'short_int': 24, # Duration of a short birth interval between live births in months
'low_age_short_int': 0, # age limit for tracking the age-specific short birth interval
'high_age_short_int': 20, # age limit for tracking the age-specific short birth interval
'postpartum_dur': 35,
'breastfeeding_dur_mu': None, # CONTEXT-SPECIFIC #### - Location parameter of gumbel distribution
'breastfeeding_dur_beta': None, # CONTEXT-SPECIFIC #### - Scale parameter of gumbel distribution
# Pregnancy outcomes
'abortion_prob': None, # CONTEXT-SPECIFIC ####
'twins_prob': None, # CONTEXT-SPECIFIC ####
'LAM_efficacy': 0.98, # From Cochrane review: https://www.ncbi.nlm.nih.gov/pmc/articles/PMC6823189/
'maternal_mortality_factor': 1,
# Fecundity and exposure
'fecundity_var_low': 0.7,
'fecundity_var_high': 1.1,
'high_parity': 4,
'high_parity_nonuse': 0.6,
'primary_infertility': 0.05,
'exposure_factor': 1.0, # Overall exposure correction factor
'restrict_method_use': 0, # If 1, only allows agents to select methods when sexually active within 1 year
# and at fated debut age. Contraceptive matrix probs must be changed to turn on
# MCPR
'mcpr_growth_rate': 0.02, # Year-on-year change in MCPR after the end of the data
'mcpr_max': 0.9, # Do not allow MCPR to increase beyond this
'mcpr_norm_year': None, # CONTEXT-SPECIFIC #### - year to normalize MCPR trend to 1
# Other sim parameters
'mortality_probs': {},
'interventions': [],
'analyzers': [],
###################################
# Context-specific data-dervied parameters, all defined within location files
###################################
'filenames': None,
'age_pyramid': None,
'age_mortality': None,
'maternal_mortality': None,
'infant_mortality': None,
'miscarriage_rates': None,
'stillbirth_rate': None,
'age_fecundity': None,
'fecundity_ratio_nullip': None,
'lactational_amenorrhea': None,
'sexual_activity': None,
'sexual_activity_pp': None,
'debut_age': None,
'exposure_age': None,
'exposure_parity': None,
'spacing_pref': None,
'methods': None,
'barriers': None,
# Empowerment (if using, set use_empowerment to True in the pars dict or location file and provide these)
'urban_prop': None,
'empowerment': None,
'education': None,
'age_partnership': None,
'region': None,
'lactational_amenorrhea_region': None,
'sexual_activity_region': None,
'sexual_activity_pp_region': None,
'debut_age_region': None,
'barriers_region': None,
}
# Shortcut for accessing default keys
par_keys = default_pars.keys()
[docs]
def pars(location=None, validate=True, die=True, update=True, **kwargs):
'''
Function for updating parameters.
Args:
location (str): the location to use for the parameters; use 'test' for a simple test set of parameters
validate (bool): whether to perform validation on the parameters
die (bool): whether to raise an exception if validation fails
update (bool): whether to update values during validation
kwargs (dict): custom parameter values
**Example**::
pars = fp.pars(location='senegal')
'''
from . import locations as fplocs # Here to avoid circular import
if not location:
location = 'default'
location = location.lower() # Ensure it's lowercase
# Set test parameters
if location == 'test':
location = 'default'
kwargs.setdefault('n_agents', 100)
kwargs.setdefault('verbose', 0)
kwargs.setdefault('start_year', 2000)
kwargs.setdefault('end_year', 2010)
# Initialize parameter dict, which will be updated with location data
pars = sc.mergedicts(default_pars, kwargs, _copy=True) # Merge all pars with kwargs and copy
# Pull out values needed for the location-specific make_pars functions
loc_kwargs = dict(use_empowerment = pars['use_empowerment'],
use_education = pars['use_education'],
use_partnership = pars['use_partnership'],
seed = pars['seed'])
# Define valid locations
if location == 'default':
location = 'senegal'
valid_country_locs = dir(fplocs)
valid_ethiopia_regional_locs = dir(fplocs.ethiopia.regions)
# Get parameters for this location
if location in valid_country_locs:
location_pars = getattr(fplocs, location).make_pars(**loc_kwargs)
elif location in valid_ethiopia_regional_locs:
location_pars = getattr(fplocs.ethiopia.regions, location).make_pars(**loc_kwargs)
else: # Else, error
errormsg = f'Location "{location}" is not currently supported'
raise NotImplementedError(errormsg)
pars = sc.mergedicts(pars, location_pars)
# Merge again, so that we ensure the user-defined values overwrite any location defaults
pars = sc.mergedicts(pars, kwargs, _copy=True)
# Convert to the class
pars = Pars(pars)
# Perform validation
if validate:
pars.validate(die=die, update=update)
return pars