Source code for fpsim.interventions

'''
Specify the core interventions available in FPsim. Other interventions can be
defined by the user by inheriting from these classes.
'''
import numpy as np
import sciris as sc
import starsim as ss
from . import utils as fpu
from . import methods as fpm

_make_repr = fpu.make_recreatable_intervention_repr
#>> Generic intervention classes

__all__ = ['change_par', 'update_methods', 'add_method', 'change_people_state',
           'change_initiation_prob', 'change_initiation', 'method_switching']


##%% Helper functions for JSON serialization

[docs] class change_par(ss.Intervention): ''' Change a parameter at a specified point in time. Args: par (str): the parameter to change years (float/arr): the year(s) at which to apply the change vals (any): a value or list of values to change to (if a list, must have the same length as years); or a dict of year:value entries If any value is ``'reset'``, reset to the original value. **Example**:: ec0 = fp.change_par(par='exposure_factor', years=[2000, 2010], vals=[0.0, 2.0]) # Reduce exposure factor ec0 = fp.change_par(par='exposure_factor', vals={2000:0.0, 2010:2.0}) # Equivalent way of writing sim = fp.Sim(interventions=ec0).run() ''' def __init__(self, par, years=None, vals=None, verbose=False, **kwargs): super().__init__(**kwargs) self.par = par self.verbose = verbose if isinstance(years, dict): # Swap if years is supplied as a dict, so can be supplied first vals = years if vals is None: errormsg = 'Values must be supplied' raise ValueError(errormsg) if isinstance(vals, dict): years = sc.dcp(list(vals.keys())) vals = sc.dcp(list(vals.values())) else: if years is None: errormsg = 'If vals is not supplied as a dict, then year(s) must be supplied' raise ValueError(errormsg) else: years = sc.toarray(sc.dcp(years)) vals = sc.dcp(vals) if sc.isnumber(vals): vals = sc.tolist(vals) # We want to be careful not to take something that might already be an array and interpret different values as years n_years = len(years) n_vals = len(vals) if n_years != n_vals: errormsg = f'Number of years ({n_years}) does not match number of values ({n_vals})' raise ValueError(errormsg) self.years = years self.vals = vals return def init_pre(self, sim): super().init_pre(sim) # Validate parameter name if self.par not in sim.pars.fp: errormsg = f'Parameter "{self.par}" is not a valid sim parameter' raise ValueError(errormsg) # Validate years and values years = self.years min_year = min(years) max_year = max(years) if min_year < sim.pars.start: errormsg = f'Intervention start {min_year} is before the start of the simulation' raise ValueError(errormsg) if max_year > sim.pars.stop: errormsg = f'Intervention end {max_year} is after the end of the simulation' raise ValueError(errormsg) if years != sorted(years): errormsg = f'Years {years} should be monotonic increasing' raise ValueError(errormsg) # Convert intervention years to sim timesteps self.counter = 0 self.inds = sc.autolist() for y in years: self.inds += sc.findnearest(sim.t.yearvec, y) # Store original value self.orig_val = sc.dcp(sim.pars.fp[self.par]) return def step(self): sim = self.sim if len(self.inds) > self.counter: ind = self.inds[self.counter] # Find the current index if sim.ti == ind: # Check if the current timestep matches curr_val = sc.dcp(sim.pars.fp[self.par]) val = self.vals[self.counter] if val == 'reset': val = self.orig_val sim.pars.fp[self.par] = val # Update the parameter value -- that's it! if self.verbose: label = f'Sim "{sim.label}": ' if sim.label else '' print(f'{label}On {sim.t.year}, change {self.counter+1}/{len(self.inds)} applied: "{self.par}" from {curr_val} to {sim.pars.fp[self.par]}') self.counter += 1 return def finalize(self): # Check that all changes were applied n_counter = self.counter n_vals = len(self.vals) if n_counter != n_vals: errormsg = f'Not all values were applied ({n_vals}{n_counter})' raise RuntimeError(errormsg) super().finalize() return
[docs] class add_method(ss.Intervention): """ Intervention to add a new contraceptive method to the simulation at a specified time. Args: year (float): The year at which to activate the new method method (Method, optional): A Method object defining the new contraceptive method. If None, the method will be copied from the source method (specified by ``copy_from``). method_pars (dict, optional): Dictionary of parameters to update the method attributes. If provided, these values will override corresponding attributes in the method object (whether it was provided directly or copied from source). If None, defaults to empty dict. copy_from (str): Name of the existing method to copy switching probabilities from. Also used as the source method when ``method=None``. split_shares (float, optional): If provided, % who would have chosen the 'copy_from' method and now choose the new method verbose (bool): Whether to print messages when method is activated (default True) **Examples**:: # Using a Method object directly new_method = fp.Method(name='new_impl', label='New Implant', efficacy=0.999, dur_use=ss.lognorm_ex(ss.years(3), ss.years(0.5)), modern=True) intv = fp.add_method(year=2010, method=new_method, copy_from='impl') # Copying from source method (method=None, method_pars=None) # Creates a copy of 'impl' with name 'impl_copy' intv = fp.add_method(year=2010, copy_from='impl') # Copying from source and overriding properties intv = fp.add_method(year=2010, method_pars={'name': 'new_inj', 'efficacy': 0.995}, copy_from='inj') # Using method object and overriding properties with method_pars base_method = fp.Method(name='new_method', efficacy=0.90) intv = fp.add_method(year=2010, method=base_method, method_pars={'efficacy': 0.998}, copy_from='impl') """ def __init__(self, year=None, method=None, method_pars=None, copy_from=None, split_shares=None, verbose=True, **kwargs): super().__init__(**kwargs) # Validate inputs if year is None: raise ValueError('Year must be specified for add_method intervention') if copy_from is None: raise ValueError('copy_from must specify an existing method name to copy switching behavior from') if method_pars is not None and not isinstance(method_pars, dict): raise TypeError(f'method_pars must be a dict, not {type(method_pars)}') if split_shares is not None: if not sc.isnumber(split_shares): raise TypeError(f'split_shares must be a number, not {type(split_shares)}') if not (0.0 <= split_shares <= 1.0): raise ValueError(f'split_shares must be between 0 and 1, got {split_shares}') self.year = year self.method = method self.method_pars = method_pars if method_pars is not None else {} self.copy_from = copy_from self.verbose = verbose self.split_shares = split_shares self.activated = False self._method_idx = None # Will be set after method is added return
[docs] def init_pre(self, sim): """ Initialize the intervention before the simulation starts. This registers the new method but does not activate it yet. """ super().init_pre(sim) # Validate year is within simulation range if not (sim.pars.start <= self.year <= sim.pars.stop): raise ValueError(f'Intervention year {self.year} must be between {sim.pars.start} and {sim.pars.stop}') cm = sim.connectors.contraception source_method = cm.get_method(self.copy_from) self._copy_from_name = source_method.name # Store resolved name for use in step() if self.method is None: # If the user didn't provide a Method object, clone the source method # (e.g., copy_from='inj') so the new method inherits all defaults # such as efficacy, modern/traditional flag, and duration-of-use distribution. self.method = sc.dcp(source_method) if 'name' not in self.method_pars: # Ensure the copied method gets a unique internal name unless the # user overrides it via method_pars. self.method.name += '_copy' for mp, mpar in self.method_pars.items(): # Apply user-specified overrides (e.g., rename, label, rel_dur_use, dur_use, etc.) setattr(self.method, mp, mpar) # Extra logic to handle copying of distributions, which isn't currently well-supported self.method._source_dur = source_method.name # Register the method in the contraception connector so it exists in `cm.methods` # and can be referenced by name/index from other interventions. cm.add_method(self.method) # Cache the method index for later use (e.g., reporting and activation). self._method_idx = cm.methods[self.method.name].idx fp_mod = sim.connectors.fp old_mix = fp_mod.method_mix # The FP module tracks method mix as an array of shape (n_methods, n_timepoints). # Adding a method increases the number of options, so we need to resize this array # while preserving historical values. new_mix = np.zeros((cm.n_options, sim.t.npts)) new_mix[:old_mix.shape[0], :] = old_mix fp_mod.method_mix = new_mix if self.verbose: print(f'Registered new method "{self.method.name}" (idx={self._method_idx}), will activate in year {self.year}') return
[docs] def step(self): """ At the specified year, copy switching probabilities to make the method available. """ sim = self.sim # Check if we've reached the activation year and haven't already activated if not self.activated and sim.t.year >= self.year: self.activated = True if self.verbose: print(f'Activating new contraceptive method "{self.method.name}" in year {sim.t.year:.1f}') # Copy switching probabilities cm = sim.connectors.contraception cm.copy_switching_to_method( source_to_method=self._copy_from_name, dest_to_method=self.method.name, split_shares=self.split_shares ) cm.copy_switching_from_method( source_from_method=self._copy_from_name, dest_from_method=self.method.name, ) # Renormalize all probabilities cm.renormalize_method_choice_pars() return
[docs] def finalize(self): """ Report summary statistics about the new method usage. """ super().finalize() if self.verbose: sim = self.sim fp_mod = sim.connectors.fp # Get final method usage for the new method final_usage = fp_mod.method_mix[self._method_idx, -1] if self._method_idx < fp_mod.method_mix.shape[0] else 0 # Count current users n_users = np.sum(fp_mod.method == self._method_idx) print(f'add_method finalized: "{self.method.name}" has {n_users} users ({final_usage*100:.2f}% of method mix)') return
[docs] class change_people_state(ss.Intervention): """ Intervention to modify values of a People's boolean state at one specific point in time. Args: state_name (string): name of the People's state that will be modified new_val (bool, float): the new state value eligible people will have years (list, float): The year we want to start the intervention. if years is None, uses start and end years of sim as defaults if years is a number or a list with a single element, eg, 2000.5, or [2000.5], this is interpreted as the start year of the intervention, and the end year of intervention will be the end of the simulation eligibility (inds/callable): indices OR callable that returns inds prop (float): a value between 0 and 1 indicating the x% of eligible people who will have the new state value annual (bool): whether the increase, prop, represents a "per year" increase, or per time step """ def __init__(self, state_name, new_val, years=None, eligibility=None, prop=1.0, annual=False, **kwargs): super().__init__(**kwargs) self.define_pars( state_name=state_name, new_val=new_val, years=years, eligibility=eligibility, prop=prop, annual=annual ) self.module_name = None if '.' in state_name: self.module_name, self.pars.state_name = state_name.split('.') self.annual_perc = None return def init_pre(self, sim): super().init_pre(sim) self._validate_pars() # Lastly, adjust the probability by the sim's timestep, if it's an annual probability if self.pars.annual: # per timestep/monthly growth rate or perc of eligible women who will be made to choose contraception self.annual_perc = self.pars.prop self.pars.prop = ((1 + self.annual_perc) ** sim.dt)-1 # Validate years and values if self.pars.years is None: # f'Intervention start and end years not provided. Will use sim start an end years' self.pars.years = [sim.pars['start'], sim.pars['stop']] if sc.isnumber(self.pars.years) or len(self.pars.years) == 1: self.pars.years = sc.promotetolist(self.pars.years) # Assumes that start year has been specified, append end of the simulation as end year of the intervention self.pars.years.append(sim.pars['stop']) min_year = min(self.pars.years) max_year = max(self.pars.years) if min_year < sim.pars['start']: errormsg = f'Intervention start {min_year} is before the start of the simulation.' raise ValueError(errormsg) if max_year > sim.pars['stop']: errormsg = f'Intervention end {max_year} is after the end of the simulation.' raise ValueError(errormsg) if self.pars.years != sorted(self.pars.years): errormsg = f'Years {self.pars.years} should be monotonically increasing' raise ValueError(errormsg) return def _validate_pars(self): # Validation if self.pars.state_name is None: errormsg = 'A state name must be supplied.' raise ValueError(errormsg) if self.pars.new_val is None: errormsg = 'A new value must be supplied.' raise ValueError(errormsg) if self.pars.eligibility is None: errormsg = 'Eligibility needs to be provided' raise ValueError(errormsg) return
[docs] def check_eligibility(self): """ Return an array of uids of agents eligible """ if callable(self.pars.eligibility): eligible_uids = self.pars.eligibility(self.sim) elif sc.isarray(self.pars.eligibility): eligible_uids = self.pars.eligibility else: errormsg = 'Eligibility must be a function or an array of uids' raise ValueError(errormsg) return ss.uids(eligible_uids)
def step(self): if self.pars.years[0] <= self.sim.t.year <= self.pars.years[1]: # Inclusive range eligible_uids = self.check_eligibility() if self.module_name is not None: self.sim.people[self.module_name][self.pars.state_name][eligible_uids] = self.pars.new_val else: self.sim.people[self.pars.state_name][eligible_uids] = self.pars.new_val return
[docs] class update_methods(ss.Intervention): """ Intervention to modify method efficacy and/or switching matrix. Args: year (float): The year we want to change the method. eff (dict): An optional key for changing efficacy; its value is a dictionary with the following schema: {method: efficacy} Where method is the name of the contraceptive method to be changed, and efficacy is a number with the efficacy dur_use (dict): Optional key for changing the duration of use; its value is a dictionary with the following schema: {method: dur_use} Where method is the method to be changed, and dur_use is a dict representing a distribution, e.g. dur_use = {'Injectables: dict(dist='lognormal', par1=a, par2=b)} p_use (float): probability of using any form of contraception method_mix (list/arr): probabilities of selecting each form of contraception """ def __init__(self, year, eff=None, dur_use=None, p_use=None, method_mix=None, method_choice_pars=None, verbose=False, **kwargs): super().__init__(**kwargs) self.define_pars( year=year, eff=eff, dur_use=dur_use, p_use=p_use, method_mix=method_mix, method_choice_pars=method_choice_pars, verbose=verbose ) self.applied = False return def init_pre(self, sim): super().init_pre(sim) self._validate() par_name = None if self.pars.p_use is not None and isinstance(sim.connectors.contraception, fpm.SimpleChoice): par_name = 'p_use' if self.pars.method_mix is not None and isinstance(sim.connectors.contraception, fpm.SimpleChoice): par_name = 'method_mix' if par_name is not None: errormsg = ( f"Contraceptive module {type(sim.connectors.contraception)} does not have `{par_name}` parameter. " f"For this type of module, the probability of contraceptive use depends on people attributes and can't be reset using this intervention.") print(errormsg) return def _validate(self): # Validation if self.pars.year is None: errormsg = 'A year must be supplied' raise ValueError(errormsg) has_input = any([ self.pars.eff is not None, self.pars.dur_use is not None, self.pars.p_use is not None, self.pars.method_mix is not None, self.pars.method_choice_pars is not None, ]) if not has_input: errormsg = 'At least one of eff, dur_use, p_use, method_mix, or method_choice_pars must be supplied' raise ValueError(errormsg) return
[docs] def step(self): """ Applies the efficacy or contraceptive uptake changes if it is the specified year based on scenario specifications. """ sim = self.sim cm = sim.connectors.contraception if not self.applied and sim.t.year >= self.pars.year: self.applied = True # Ensure we don't apply this more than once # Implement efficacy if self.pars.eff is not None: for k, rawval in self.pars.eff.items(): cm.update_efficacy(method_label=k, new_efficacy=rawval) # Implement changes in duration of use if self.pars.dur_use is not None: for k, rawval in self.pars.dur_use.items(): cm.update_duration(method_label=k, new_duration=rawval) # Change in probability of use if self.pars.p_use is not None: cm.pars['p_use'].set(self.pars.p_use) # Change in method mix if self.pars.method_mix is not None: this_mix = self.pars.method_mix / np.sum(self.pars.method_mix) # Renormalise in case they are not adding up to 1 cm.pars['method_mix'] = this_mix # Change in switching matrix if self.pars.method_choice_pars is not None: print(f'Changed contraceptive switching matrix in year {sim.t.year}') cm.method_choice_pars = self.pars.method_choice_pars return
[docs] class change_initiation_prob(ss.Intervention): """ Intervention to change the probabilty of contraception use trend parameter in contraceptive choice modules that have a logistic regression model. Args: year (float): The year in which this intervention will be applied prob_use_intercept (float): A number that changes the intercept in the logistic regression model p_use = 1 / (1 + np.exp(-rhs + p_use_time_trend + p_use_intercept)) """ def __init__(self, year=None, prob_use_intercept=0.0, verbose=False, **kwargs): super().__init__(**kwargs) self.year = year self.prob_use_intercept = prob_use_intercept self.verbose = verbose self.applied = False self.par_name = None return def init_pre(self, sim=None): super().init_pre(sim) # self._validate() if isinstance(sim.people.contraception_module, (fpm.SimpleChoice)): self.par_name = 'prob_use_intercept' if self.par_name is None: errormsg = ( f"Contraceptive module {type(sim.people.contraception_module)} does not have `{self.par_name}` parameter.") raise ValueError(errormsg) return
[docs] def step(self): """ Applies the changes to efficacy or contraceptive uptake changes if it is the specified year based on scenario specifications. """ sim = self.sim if not self.applied and sim.t.year >= self.year: self.applied = True # Ensure we don't apply this more than once sim.people.contraception_module.pars[self.par_name] = self.prob_use_intercept return
[docs] class change_initiation(ss.Intervention): """ Intervention that modifies the outcomes of whether women are on contraception or not Select a proportion of women and sets them on a contraception method. Args: years (list, float): The year we want to start the intervention. if years is None, uses start and end years of sim as defaults if years is a number or a list with a single element, eg, 2000.5, or [2000.5], this is interpreted as the start year of the intervention, and the end year of intervention will be the end of the simulation eligibility (callable): callable that returns a filtered version of people eligible to receive the intervention perc (float): a value between 0 and 1 indicating the x% extra of women who will be made to select a contraception method. The proportion or % is with respect to the number of women who were on contraception: - the previous year (12 months earlier)? - at the beginning of the intervention. annual (bool): whether the increase, perc, represents a "per year" increase. age_range (tuple, optional): (min_age, max_age) to restrict eligibility. If provided, only women in this age range are eligible. Use with perc_of_eligible=True. perc_of_eligible (bool): If True, perc is percentage of eligible women (not of current users). Use this with age_range for age-targeted scale-up scenarios. target_method (str/int, optional): Method name or index for new users. If None, uses natural method distribution. final_perc (float, optional): If provided, perc will scale linearly from initial perc to final_perc over the intervention period. Enables scale-up scenarios. """ def __init__(self, years=None, eligibility=None, perc=0.0, annual=True, force_theoretical=False, age_range=None, perc_of_eligible=False, target_method=None, final_perc=None, verbose=False, **kwargs): super().__init__(**kwargs) self.years = years self.eligibility = eligibility self.perc = perc self.annual = annual self.annual_perc = None self.force_theoretical = force_theoretical self.current_women_oncontra = None self.verbose = verbose # New parameters for age-restricted, targeted initiation self.age_range = age_range self.perc_of_eligible = perc_of_eligible self.target_method = target_method self.target_method_idx = None self.final_perc = final_perc self.init_perc = perc # Store initial perc for linear interpolation # Initial value of women on contra at the start of the intervention. Tracked for validation. self.init_women_oncontra = None # Theoretical number of women on contraception we should have by the end of the intervention period, if # nothing else affected the dynamics of the contraception. Tracked for validation. self.expected_women_oncontra = None # Bernoulli distribution for selecting women to initiate on contraception self._p_selected = ss.bernoulli(p=0.0) return def init_pre(self, sim=None): super().init_pre(sim) # Validate years and values if self.years is None: # f'Intervention start and end years not provided. Will use sim start an end years' self.years = [sim.pars['start'], sim.pars['stop']] if sc.isnumber(self.years) or len(self.years) == 1: self.years = sc.promotetolist(self.years) # Assumes that start year has been specified, append end of the simulation as end year of the intervention self.years.append(sim.pars['stop']) min_year = min(self.years) max_year = max(self.years) if min_year < sim.pars['start']: errormsg = f'Intervention start {min_year} is before the start of the simulation.' raise ValueError(errormsg) if max_year > sim.pars['stop']: errormsg = f'Intervention end {max_year} is after the end of the simulation.' raise ValueError(errormsg) if self.years != sorted(self.years): errormsg = f'Years {self.years} should be monotonically increasing' raise ValueError(errormsg) # Resolve target method if specified if self.target_method is not None: cm = sim.connectors.contraception if isinstance(self.target_method, str): if self.target_method not in cm.methods: errormsg = f'Target method "{self.target_method}" not found' raise ValueError(errormsg) self.target_method_idx = cm.methods[self.target_method].idx else: self.target_method_idx = self.target_method # Validate age_range if provided if self.age_range is not None: if len(self.age_range) != 2: errormsg = 'age_range must be a tuple of (min_age, max_age)' raise ValueError(errormsg) # Convert final_perc if needed if self.final_perc is not None and self.annual: self.final_perc = ((1 + self.final_perc) ** float(sim.dt)) - 1 # Adjust the probability by the sim's timestep, if it's an annual probability if self.annual: # per timestep/monthly growth rate or perc of eligible women who will be made to choose contraception self.annual_perc = self.perc self.perc = ((1 + self.annual_perc) ** float(sim.dt))-1 return
[docs] def check_eligibility(self): """ Select eligible who is eligible """ sim = self.sim contra_choosers = [] if self.eligibility is None: contra_choosers = self._default_contra_choosers() return contra_choosers
def _default_contra_choosers(self): # TODO: do we care whether women people have ti_contra > 0? For instance postpartum women could be made to choose earlier? # Though it is trickier because we need to reset many postpartum-related attributes ppl = self.sim.people # Base eligibility criteria eligible = (ppl.female & ppl.alive & # living women (ppl.age < self.sim.pars.fp['age_limit_fecundity']) & # who are fecund ppl.fp.sexual_debut & # who already had their sexual debut (~ppl.fp.pregnant) & # who are not currently pregnant (~ppl.fp.postpartum) & # who are not in postpartum (~ppl.fp.on_contra) # who are not already on contra ) # Add age restriction if specified if self.age_range is not None: min_age, max_age = self.age_range eligible = eligible & (ppl.age >= min_age) & (ppl.age < max_age) return eligible.uids def _get_current_perc(self, year): """Calculate current perc based on linear interpolation if final_perc is specified.""" if self.final_perc is None: return self.perc start_year, end_year = self.years[0], self.years[1] if year <= start_year: return self.perc elif year >= end_year: return self.final_perc else: # Linear interpolation frac = (year - start_year) / (end_year - start_year) return self.perc + frac * (self.final_perc - self.perc) def step(self): sim = self.sim ti = sim.ti year = sim.t.year # Calculate current perc (may vary over time) current_perc = self._get_current_perc(year) # Save theoretical number based on the value of women on contraception at start of intervention if self.years[0] == year: self.expected_women_oncontra = (sim.people.alive & sim.people.fp.on_contra).sum() self.init_women_oncontra = self.expected_women_oncontra # Apply intervention within this time range if self.years[0] <= year <= self.years[1]: # Inclusive range self.current_women_oncontra = (sim.people.alive & sim.people.fp.on_contra).sum() # Eligible population can_choose_contra_uids = self.check_eligibility() n_eligible = len(can_choose_contra_uids) if n_eligible == 0: return # Calculate number of new users if self.perc_of_eligible: # Percentage of eligible women (for age-targeted scenarios) new_on_contra = current_perc * n_eligible else: # Traditional behavior: percentage based on current users nnew_on_contra = current_perc * self.expected_women_oncontra # NOTE: TEMPORARY: force specified increase # how many more women should be added per time step # However, if the current number of women on contraception is >> than the expected value, this # intervention does nothing. The forcing ocurrs in one particular direction, making it incomplete. # If the forcing had to be fully function, when there are more women than the expected value # this intervention should additionaly 'reset' the contraceptive state and related attributes (ie, like the duration on the method) if self.force_theoretical: additional_women_on_contra = self.expected_women_oncontra - self.current_women_oncontra if additional_women_on_contra < 0: additional_women_on_contra = 0 new_on_contra = nnew_on_contra + additional_women_on_contra else: new_on_contra = current_perc * self.current_women_oncontra self.expected_women_oncontra += nnew_on_contra if new_on_contra == 0: return if n_eligible < new_on_contra: if not self.perc_of_eligible: # Only warn for non-eligible mode print(f"There are fewer eligible women ({n_eligible}) than " f"the number of women who should be initiated on contraception ({new_on_contra}).") new_on_contra = n_eligible # Of eligible women, select who will be asked to choose contraception p_selected = new_on_contra * np.ones(n_eligible) / n_eligible selected = np.random.random(n_eligible) < p_selected sim.people.fp.on_contra[can_choose_contra_uids[selected]] = True new_users_uids = can_choose_contra_uids[selected] # Assign method if self.target_method_idx is not None: # Use specified target method sim.people.fp.method[new_users_uids] = self.target_method_idx else: # Use natural method distribution sim.people.fp.method[new_users_uids] = sim.connectors.contraception.init_method_dist(new_users_uids) sim.people.fp.ever_used_contra[new_users_uids] = 1 method_dur = sim.connectors.contraception.set_dur_method(new_users_uids) sim.people.fp.ti_contra[new_users_uids] = ti + method_dur return
# Note: age_restricted_initiation functionality has been integrated into change_initiation # Use change_initiation with age_range, perc_of_eligible, target_method, and final_perc parameters
[docs] class method_switching(ss.Intervention): """ Intervention that causes women to switch from specific contraceptive methods to a target method. This intervention modifies the switching probabilities in the contraception module's switching matrix to induce transitions from one or more source methods to a destination method. Args: year (float): Year when switching intervention begins from_methods (str/list): Method name(s) or index(es) to switch from to_method (str/int): Method name or index to switch to switch_prob (float/dict): Probability of switching. Can be: - float: Same probability for all source methods - dict: {method_name: probability} for different rates per source age_groups (list): Age group names to apply switching. If None, applies to all age groups. Use None for all, or list like ['<18', '18-20', '20-25', '25-35', '>35'] postpartum (int/list): Postpartum states to modify (0, 1, 6). If None, modifies all states. annual (bool): If True, switch_prob is annual and converted to per-timestep verbose (bool): Print detailed information **Example**:: # 10% of injectable users switch to DMPA-SC starting in 2025 intv1 = fp.method_switching( year=2025, from_methods='inj', to_method='dmpasc', switch_prob=0.10, annual=False ) # Different rates for different methods intv2 = fp.method_switching( year=2030, from_methods=['dmpasc3', 'withdrawal', 'other_trad'], to_method='dmpasc6', switch_prob={'dmpasc3': 0.26, 'withdrawal': 0.20, 'other_trad': 0.20}, annual=False ) """ def __init__(self, year=None, from_methods=None, to_method=None, switch_prob=None, age_groups=None, postpartum=None, annual=False, verbose=False, **kwargs): super().__init__(**kwargs) self.year = year self.from_methods = sc.promotetolist(from_methods) self.to_method = to_method self.switch_prob = switch_prob self.age_groups = age_groups self.postpartum = postpartum if postpartum is not None else [0, 1, 6] self.postpartum = sc.promotetolist(self.postpartum) self.annual = annual self.verbose = verbose self.applied = False return def init_pre(self, sim=None): super().init_pre(sim) # Validate year if self.year is None: errormsg = 'year must be specified' raise ValueError(errormsg) if self.year < sim.pars['start'] or self.year > sim.pars['stop']: errormsg = f'Intervention year {self.year} must be within simulation [{sim.pars["start"]}, {sim.pars["stop"]}]' raise ValueError(errormsg) # Validate methods if self.from_methods is None or self.to_method is None: errormsg = 'Both from_methods and to_method must be specified' raise ValueError(errormsg) if self.switch_prob is None: errormsg = 'switch_prob must be specified' raise ValueError(errormsg) # Resolve method names to indices cm = sim.connectors.contraception self.from_method_names = [] self.from_method_indices = [] for method in self.from_methods: if isinstance(method, str): if method not in cm.methods: errormsg = f'Source method "{method}" not found' raise ValueError(errormsg) self.from_method_names.append(method) self.from_method_indices.append(cm.methods[method].idx) else: self.from_method_indices.append(method) # Find name from index for name, m in cm.methods.items(): if m.idx == method: self.from_method_names.append(name) break if isinstance(self.to_method, str): if self.to_method not in cm.methods: errormsg = f'Target method "{self.to_method}" not found' raise ValueError(errormsg) self.to_method_idx = cm.methods[self.to_method].idx self.to_method_name = self.to_method else: self.to_method_idx = self.to_method for name, m in cm.methods.items(): if m.idx == self.to_method: self.to_method_name = name break # Convert switch_prob to dict format if not isinstance(self.switch_prob, dict): # Same probability for all source methods self.switch_prob_dict = {name: self.switch_prob for name in self.from_method_names} else: self.switch_prob_dict = self.switch_prob # Convert annual to per-timestep if needed if self.annual: for key in self.switch_prob_dict: annual_prob = self.switch_prob_dict[key] self.switch_prob_dict[key] = ((1 + annual_prob) ** float(sim.dt)) - 1 # Determine age groups to modify if self.age_groups is None: # Get all age groups from method_choice_pars sample_pp = self.postpartum[0] if sample_pp in cm.pars.method_choice_pars: self.age_groups = [k for k in cm.pars.method_choice_pars[sample_pp].keys() if k != 'method_idx'] if self.verbose: print(f'Method switching intervention initialized:') print(f' Year: {self.year}') print(f' From methods: {self.from_method_names}') print(f' To method: {self.to_method_name}') print(f' Switch probabilities: {self.switch_prob_dict}') print(f' Age groups: {self.age_groups}') print(f' Postpartum states: {self.postpartum}') return def step(self): sim = self.sim # Apply once when year is reached if self.applied or sim.t.year < self.year: return self.applied = True cm = sim.connectors.contraception # Use the existing set_switching_prob method for each source method for from_name in self.from_method_names: switch_prob = self.switch_prob_dict.get(from_name, 0) if switch_prob == 0: continue for pp in self.postpartum: for age_grp in self.age_groups: # Get current probability of switching from source to target try: from_method_key = 'birth' if pp == 1 else from_name current_prob = cm.get_switching_prob(from_method_key, self.to_method_name, pp, age_grp) # Increase the switching probability # This represents: "X% of people who would have stayed on from_method now switch to to_method" new_prob = current_prob + (1.0 - current_prob) * switch_prob # Set the new probability (with renormalization) cm.set_switching_prob(from_method_key, self.to_method_name, new_prob, postpartum=pp, age_grp=age_grp, renormalize=True) except (ValueError, KeyError) as e: # Skip combinations that don't exist in the data if self.verbose: print(f' Skipping {from_name}->{self.to_method_name} for pp={pp}, age={age_grp}: {e}') continue if self.verbose: print(f'Year {sim.t.year}: Applied method switching intervention') return