Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rc0.0.2 #17

Merged
merged 92 commits into from
Jul 5, 2023
Merged

Rc0.0.2 #17

merged 92 commits into from
Jul 5, 2023

Conversation

robynstuart
Copy link
Contributor

@robynstuart robynstuart commented Jun 27, 2023

Adding in basic *sim functionality, with a few changes/exceptions:

  • people.networks stores instances of the Network class (previously, this was called people.contacts and Networks were called Layers)
  • population.py is now exclusively home to types of Network. The idea is that we would provide a few defaults but people could easily create their own
  • the construction of sims vs people vs networks is all more modular, in keeping with the spirit of @RomeshA's vision. For example, users can make the people and networks first and then feed them into a sim, or they can get the sim to construct the people but feed in their own networks.
  • modules.py operates as before
  • parameters.py -- much of this is still TBC
  • location functionality is not here yet -- whilst this is not particularly tricky to add, it's also data-intensive and maybe not needed just yet, especially since it's still TBD what data we need and what should have responsibility for getting these data -- e.g. if we really just need the hpvsim data then perhaps we could call hpv.data to get it? This would introduce a dependency between stisim and hpvsim that we probably don't want, so perhaps we need to move it all to starsim?

Tests are in test_base.py.

import sciris as sc
from .settings import options as sso # For setting global options
from . import misc as ssm
from .data import loaders as ssdata
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know data loading is not set up yet -- perhaps comment this import until this functionality is enabled to avoid import errors.

stisim/people.py Outdated
from . import utils as ssu
from . import settings as sss
from .data import loaders as ssdata
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps comment this import until this functionality is enabled to avoid import errors -- original comment was about this import line in people.py, thought it probably applies to import in parameters.py too.

Copy link
Contributor

@cliffckerr cliffckerr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Lots of opportunity for tidying, but I think we should merge this and do that tidying incrementally


class Analyzer:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think eventually we want this to be a class instance like it is in hpvsim and covasim?

@jamiecohen
Copy link
Contributor

Looks great to me!

Copy link
Contributor

@luojun-yang luojun-yang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great!!
Need to think about how to allow customizable dimensions of population heterogeneity.

Copy link
Contributor

@pausz pausz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More comments and suggestions

# %% Helper functions to create popdicts

def set_static(new_n, existing_n=0, pars=None, f_ratio=0.5):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested docstring:

 """
    Set static population characteristics that do not change over time.
    This function can be used when adding new births, in which case the existing popsize can be given as `existing_n`.
    Arguments:
        new_n (int): Number of new individuals to add to the population.
        existing_n (int, optional): Number of existing individuals in the population. Default is 0.
        pars (dict, optional): Dictionary of parameters. Default is None.
        f_ratio (float, optional): Female ratio in the population. Default is 0.5.
    
    Returns:
        uid (ndarray, int): unique identifiers for the individuals.
        female (ndarray, bool): array indicating whether an individual is female or not.
        debut (ndarray, bool): array indicating the debut value for each individual.
 """

uid = np.arange(existing_n, existing_n+new_n, dtype=sss.default_int)
female = np.random.choice([True, False], size=new_n, p=f_ratio)
n_females = len(ssu.true(female))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think np.count_zeros may be slightly faster than len(...). This is tiny detail, may not make a difference if this function is not called multiple times.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, seems like np.random.choice([True, False], size=new_n, p=f_ratio), is equivalent to but probably slower than np.random.rand(new_n)>f_ratio

age_data_prob /= age_data_prob.sum() # Ensure it sums to 1
age_bins = ssu.n_multinomial(age_data_prob, n) # Choose age bins
if dt_round_age:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if this age rounding/correction may be moved to a different place (a standalone function perhaps?), one where dt has already passed validation checks and fulfills the condition that age_data_range[age_bins] / dt are integer numbers, which is the implicit assumption I think.

pars = sc.objdict()

# Population parameters
pars['n_agents'] = 10e3 # Number of agents
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on the discussion about parameters. Cliff is right that a nested dictionary is a good way to represent parameters and provides a lot of flexibility. The nested dict can be exported as json (sim.export_pars), or parsed and exported in a different way (eg, to excel) via a user-defined function. However what I think we need is a richer description of a parameter, rather than only the value, we could also consider a short, human readable description, units if applicable, valid range (may come handy for calibration later). All these additional parameter attributes can be accommodated in a nested dict, but what would need to change how the rest of the classes access parameter values.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import stisim 
import sciris as sc

pars = sc.objdict()
pars['population'] = sc.objdict()

# Define parameter
pars['population']['n_agents'] = stisim.Parameter(name="n_agents", default_value=10000, dtype=int, description="Number of agents, that is, the size of the agent population.")

# Access value
pars['population']['n_agents']()
# or 
pars['population']['n_agents'].value  

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love this idea and think it could be very useful for calibration

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, strongly in favor of a richer parameter description!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have created issue #22 to track this enhancement.

raise AlreadyRunError('Simulation already complete (call sim.initialize() to re-run)')

# Update states, modules, partnerships
self.people.update_states(sim=self) # This runs modules
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could call this method people.update_living_states`?

I don't think it's necessary to pass the whole sim to people update_states, it only uses the time step size dt and the time index ti.
In people it'd would look like:

    def update_living_states(self, dt, ti):
        """ Perform all state updates at the current timestep """

        self.age[~self.dead] += dt
        self.dead[self.ti_dead <= ti] = True

    
        return

I would also suggest to call the module updates/stepping function directly in sim.step, not inside of people.update ... I think this would help with the goal of keeping code modular. And it may be useful to have all the entities that need updating at each time step, and in which order, directly in the place that orchestrates how everything evolves (the sim class).

In sim it would look something like this:

def step()
    self.people.update_living_states(sim.dt, sim.ti) # People age or eventually die 
    self.update_health_states() 
    self.update_networks()
    self.update_connector()
    self.update_modules()
    
    # Tidy up
        self.ti += 1
        if self.ti == self.npts:
            self.complete = True

              
def update_health_states(self):
    for module in self.modules.values(): # Other health states progress (disease states or pregnancy stages)
             module.update_states(self)
             
def update_networks(self): # could also be called step_networks, etc
    # Update networks -- currently done inside people.update , 
        # Perform network updates
        for lkey, layer in self.people.networks.items():
            layer.update(self.people)     

def update_modules(self): # could also be called step_modules, etc
        for module in self.modules.values():
            module.make_new_cases(self)
            module.update_results(self)

If the logic in any of the update_ or step_ becomes more complex, then it'd be contained to its local scope rather than obscuring the logic of the main simulation step() method.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree with this logic, but think we can do this as a next step after merging?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, but instead of update_living_states could be update_vital_dynamics or similar.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also think we should consider make Demographics a separate module to facilitate additional flexibility.

stisim/population.py Outdated Show resolved Hide resolved
Copy link
Contributor

@daniel-klein daniel-klein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall great, lots of progres here. I didn't see an example of a connector or other way in which one module could influence another, perhaps I just missed it?

State('uid', sss.default_int),
State('age', sss.default_float),
State('female', bool, False),
State('debut', sss.default_float),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should "debut" be a base State or part of the network? If only used for partnerships / network participation, consider moving to the Network class. But if used more broadly, e.g. for exogeneous fertility, okay to keep here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One solution would be for "debut" to be determined by the network itself. For example by the first time index at which an individual forms any network edge. So it would be a State propoerty of the base that would be set by the Network layer(s).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well this begs the question whether debut is an attribute of a person or of the network. would we expect people to have different ages of debut for different networks. and then what does that mean for a network which is not a sexual network?

stisim/modules.py Show resolved Hide resolved
sim.people.add_module(self)

# Pick some initially infected agents
self.set_prognoses(sim, np.random.choice(sim.people.uid, sim.pars[self.name]['initial']))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This "bakes in" the np.random.choice. Why not allow the module to choose the people?

""" Add new cases of module, through transmission, incidence, etc. """
pars = sim.pars[self.name]
for k, layer in sim.people.networks.items():
if k in pars['beta']:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't follow this pars['beta'] thing, what's going on here?

if k in pars['beta']:
rel_trans = (sim.people[self.name].infected & ~sim.people.dead).astype(float)
rel_sus = (sim.people[self.name].susceptible & ~sim.people.dead).astype(float)
for a, b, beta in [[layer['p1'], layer['p2'], pars['beta'][k][0]], [layer['p2'], layer['p1'], pars['beta'][k][1]]]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The asymmetry in beta is nice!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps more efficient to first find module-discordant edges and then only sample transmission one-way?

raise AlreadyRunError('Simulation already complete (call sim.initialize() to re-run)')

# Update states, modules, partnerships
self.people.update_states(sim=self) # This runs modules
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, but instead of update_living_states could be update_vital_dynamics or similar.

# >>> sim0.initialize()
# >>> sim1.initialize()
# >>> sim0.run()
# >>> sim1.run()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But since these are different instances, why would the results not be the same in either case? (Sorry, feels like a dumb question!)

raise AlreadyRunError('Simulation already complete (call sim.initialize() to re-run)')

# Update states, modules, partnerships
self.people.update_states(sim=self) # This runs modules
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also think we should consider make Demographics a separate module to facilitate additional flexibility.


return

def update_connectors(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like this belongs at the sim level.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't it currently at the sim level?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but there is a TODO comment on the following line suggesting to move this to people. I like it where it is ;)

# Set beta. The first entry represents transmission risk from infected p1 -> susceptible p2
# Need to be careful to get the ordering right. The set-up here assumes that in the simple
# sexual network, p1 is male and p2 is female. In the maternal network, p1=mothers, p2=babies.
hiv.pars['beta'] = {'simple_sexual': [0.0008, 0.0004], 'maternal': [0.2, 0]}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that in hpv_network, p1 is female.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants