-
Notifications
You must be signed in to change notification settings - Fork 2
Instantiate infections upon exposure #80
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
base: main
Are you sure you want to change the base?
Changes from all commits
eec0985
c244512
8c65581
d4de34c
cf8ce54
23070aa
4ff6cab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,12 +6,15 @@ | |
|
|
||
|
|
||
| class Simulation: | ||
| PROPERTIES = { | ||
| INIT_PROPERTIES = { | ||
| "id", | ||
| "infector", | ||
| "infectees", | ||
| "generation", | ||
| "t_exposed", | ||
| "simulated", | ||
| } | ||
| SIM_PROPERTIES = { | ||
| "infectees", | ||
| "t_infectious", | ||
| "t_recovered", | ||
| "infection_rate", | ||
|
|
@@ -20,6 +23,7 @@ class Simulation: | |
| "t_detected", | ||
| "infection_times", | ||
| } | ||
| PROPERTIES = INIT_PROPERTIES | SIM_PROPERTIES | ||
|
|
||
| def __init__( | ||
| self, params: dict[str, Any], rng: Optional[numpy.random.Generator] = None | ||
|
|
@@ -29,10 +33,18 @@ def __init__( | |
| self.infections = {} | ||
| self.termination: Optional[str] = None | ||
|
|
||
| def create_person(self) -> str: | ||
| def create_person( | ||
| self, infector: Optional[str], t_exposed: float, generation: int | ||
| ) -> str: | ||
| """Add a new person to the data""" | ||
| id = str(len(self.infections)) | ||
| self.infections[id] = {x: None for x in self.PROPERTIES} | ||
| self.infections[id] = { | ||
| "id": id, | ||
| "infector": infector, | ||
| "t_exposed": t_exposed, | ||
| "generation": generation, | ||
| "simulated": False, | ||
| } | {x: None for x in self.SIM_PROPERTIES} | ||
| return id | ||
|
Comment on lines
+36
to
48
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because this is a small, ad hoc simulation, this doesn't matter, but I liked having I guess we would have wanted to draw a distinction between a person-as-dictionary vs. a simulated-person, who has some properties. But again this doesn't matter here
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we want to immediately instantiate people, it seemed to me like it no longer made sense to draw that distinction. Otherwise we'd just be adding functions to the pile that we have to call every time a new infection arises, no?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, there would be an extra function, because the "create empty database entry" and "fill in defaults for this person" would be separate Again, that might be good if we had a bigger simulation where wanted more clear separation between data mgmt and simulation, but here it doesn't matter so much |
||
|
|
||
| def update_person(self, id: str, content: dict[str, Any]) -> None: | ||
|
|
@@ -67,18 +79,11 @@ def query_people(self, query: Optional[dict[str, Any]] = None) -> List[str]: | |
| if all(person[k] == v for k, v in query.items()) | ||
| ] | ||
|
|
||
| def register_infectee(self, infector, infectee) -> None: | ||
| infectees = self.get_person_property(infector, "infectees") | ||
| if infectees is None: | ||
| self.update_person(infector, {"infectees": []}) | ||
| infectees = self.get_person_property(infector, "infectees") | ||
| infectees.append(infectee) | ||
|
|
||
| def run(self) -> None: | ||
| """Run simulation""" | ||
| # queue is pairs (t_exposed, infector) | ||
| # queue is of infection ids | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a nice improvement
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Although now it means that methods like
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm sure I'm missing something, but that feels like a line we crossed somewhere between "we should instantiate infections earlier" and "this will always be a branching process" Or, put another way, the reason I saw "this will always be a BP" as necessary scope for "infections should instantiate earlier" was because I don't see how to instantiate infections when their time of infection is drawn without doing it inside the infection simulation routine. Anything else would just land us back where we were, wouldn't it?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm just talking about class/static methods vs. instance methods, not necessarily about the ordering of anything Like, the "resolve infection" function (i.e., figure out the natural history and no. of onward infections for some person) could be a class method that doesn't touch the database, but returns the natural history information back to I'm saying: I think there's a way to write code so that |
||
| # start with the index infection | ||
| infection_queue: List[tuple[float, Optional[str]]] = [(0.0, None)] | ||
| infection_queue: List[str] = [self.create_person(None, 0.0, 0)] | ||
|
|
||
| passed_max_generations = False | ||
|
|
||
|
|
@@ -98,24 +103,17 @@ def run(self) -> None: | |
| ) | ||
| # exit the loop | ||
| break | ||
| elif n_infections == self.params["max_infections"]: | ||
| elif n_infections >= self.params["max_infections"]: | ||
| # we are at maximum number of infections | ||
| self.termination = "max_infections" | ||
| # exit the loop | ||
| break | ||
| elif n_infections > self.params["max_infections"]: | ||
| # this loop instantiates infections one at a time. we should | ||
| # exactly hit the maximum and not exceed it. | ||
| raise RuntimeError("Maximum number of infections exceeded") | ||
|
|
||
| # find the person who is infected next | ||
| # (the queue is time-sorted, so this is the temporally next infection) | ||
| t_exposed, infector = infection_queue.pop(0) | ||
| id = infection_queue.pop(0) | ||
|
|
||
| # otherwise, instantiate this infection, draw who they in turn infect, | ||
| # draw who they in turn infect, | ||
| # and add the infections they cause to the queue, in time order | ||
| id = self.create_person() | ||
| self.generate_infection(id=id, t_exposed=t_exposed, infector=infector) | ||
| offspring = self.generate_infection(id=id) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think "infection" is now used to refer to too many things Like, maybe we want to rename And now I guess
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, the names are... not great at the moment. I feel like the less-descriptive The
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We've replaced the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cf. my note above. I think "generate" might have made more sense if/when that function was a static method that just returned information. But now that it's interacting with the database, I see that "simulate" or "process" or whatever makes more sense |
||
|
|
||
| # if the infector is in the final generation, do not add their | ||
| # infectees to the queue | ||
|
|
@@ -129,29 +127,26 @@ def run(self) -> None: | |
| else: | ||
| # only add infectees to the queue if we are not yet at maximum | ||
| # number of generations | ||
| for t in self.get_person_property(id, "infection_times"): | ||
| bisect.insort_right(infection_queue, (t, id), key=lambda x: x[0]) | ||
| for child in offspring: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And now "child" is essentially the same species too |
||
| bisect.insort_right( | ||
| infection_queue, | ||
| child, | ||
| key=lambda x: self.get_person_property(x, "t_exposed"), | ||
| ) | ||
|
|
||
| def generate_infection( | ||
| self, id: str, t_exposed: float, infector: Optional[str] | ||
| ) -> None: | ||
| self, | ||
| id: str, | ||
| ) -> List[str]: | ||
| """ | ||
| Generate a single infected person's biological disease history, detection | ||
| history and transmission history | ||
| """ | ||
| # keep track of generations | ||
| if infector is None: | ||
| generation = 0 | ||
| else: | ||
| generation = self.get_person_property(infector, "generation") + 1 | ||
| self.register_infectee(infector, id) | ||
|
|
||
| self.update_person( | ||
| id, {"id": id, "infector": infector, "generation": generation} | ||
| ) | ||
|
|
||
| # disease state history in this individual | ||
| disease_history = self.generate_disease_history(t_exposed=t_exposed) | ||
| disease_history = self.generate_disease_history( | ||
| self.get_person_property(id, "t_exposed") | ||
| ) | ||
| self.update_person(id, disease_history) | ||
|
|
||
| # whether this person was detected | ||
|
|
@@ -182,7 +177,18 @@ def generate_infection( | |
| assert (infection_times >= disease_history["t_infectious"]).all() | ||
| assert (infection_times <= t_end_infectious).all() | ||
|
|
||
| self.update_person(id, {"infection_times": infection_times}) | ||
| infectees = [ | ||
| self.create_person(id, time, self.get_person_property(id, "generation") + 1) | ||
| for time in infection_times | ||
| ] | ||
| self.update_person( | ||
| id, {"infection_times": infection_times, "infectees": infectees} | ||
| ) | ||
|
|
||
| # mark this person as simulated | ||
| self.update_person(id, {"simulated": True}) | ||
|
|
||
| return infectees | ||
|
|
||
| def generate_disease_history(self, t_exposed: float) -> dict[str, Any]: | ||
| """Generate infection history for a single infected person""" | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -46,8 +46,15 @@ def run_simulations(n: int, params: dict, seed: int) -> List[Simulation]: | |||||
| for i in range(n): | ||||||
| progress_bar.progress(i / n, text=progress_text) | ||||||
| sim = Simulation(params=params, rng=rngs[i]) | ||||||
| sim.run() | ||||||
| sims.append(sim) | ||||||
| try: | ||||||
| sim.run() | ||||||
| sims.append(sim) | ||||||
| except Exception as e: | ||||||
| if not ( | ||||||
| isinstance(e, RuntimeError) | ||||||
| and str(e) == "Maximum number of infections exceeded" | ||||||
| ): | ||||||
| raise (e) | ||||||
|
|
||||||
| progress_bar.empty() | ||||||
| toc = time.perf_counter() | ||||||
|
|
@@ -284,13 +291,6 @@ def infectiousness_callback(): | |||||
| max_value=n_generations + 1, | ||||||
| help="Successful control is defined as no infections in contacts at this degree. Set to 1 for contacts of the index case, 2 for contacts of contacts, etc. Equivalent to checking for extinction in the specified generation.", | ||||||
| ) | ||||||
| max_infections = st.number_input( | ||||||
| "Maximum number of infections", | ||||||
| value=1000, | ||||||
| step=10, | ||||||
| min_value=100, | ||||||
| help="", | ||||||
| ) | ||||||
| seed = st.number_input("Random seed", value=1234, step=1) | ||||||
| nsim = st.number_input("Number of simulations", value=250, step=1) | ||||||
| plot_gen = st.toggle("Show infection's generation", value=False) | ||||||
|
|
@@ -313,6 +313,7 @@ def infectiousness_callback(): | |||||
| == "Cumulative" | ||||||
| ) | ||||||
|
|
||||||
| max_infections = 1000000 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
or |
||||||
| params = { | ||||||
| "n_generations": n_generations, | ||||||
| "latent_duration": latent_duration, | ||||||
|
|
@@ -329,13 +330,13 @@ def infectiousness_callback(): | |||||
|
|
||||||
| sims = run_simulations(n=nsim, params=params, seed=seed) | ||||||
|
|
||||||
| n_at_max = sum(1 for sim in sims if sim.termination == "max_infections") | ||||||
| n_at_max = nsim - len(sims) | ||||||
|
|
||||||
| show = True if n_at_max == 0 else False | ||||||
| if not show: | ||||||
| st.warning( | ||||||
| body=( | ||||||
| f"{n_at_max} simulations hit the specified maximum number of infections ({max_infections})." | ||||||
| f"{n_at_max} simulations hit the maximum number of infections ({max_infections})." | ||||||
| ), | ||||||
| icon="🚨", | ||||||
| ) | ||||||
|
|
||||||
Large diffs are not rendered by default.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's docstring these? I wasn't immediately clear to me what "init" vs. "sim" meant here