Modeling Epidemics¶
Due: Friday, October 15 at 4:30pm CDT
The goal of this assignment is to give you practice with the basics of Python and to get you to think about how to translate a few simple algorithms into code. You will be allowed to work in pairs on some of the later assignments, but you must work alone on this assignment.
Epidemics and contagion are incredibly complex phenomena, involving both biological and social factors. Computer models, though imperfect, can offer insight into disease spread and can represent infection with varying degrees of complexity.
SIR is a simple, but commonly used, model for epidemics. In the SIR model, a person can be in one of three states: Susceptible to the disease, Infected with the disease, or Recovered from the disease after infection (the model is named after these three states: S-I-R). In this model, we focus on a network of people, such as a community experiencing an epidemic. Although simple, the SIR model captures both social factors (like the shape of the network, e.g., how often people in the network interact with each other) and biological factors (like the duration of infection) that mediate disease spread.
In this assignment, you will write code to simulate a simplified version of the SIR epidemic model. Your code will model how infection spreads through a city from residents to their neighbors. At a high level, your code will iteratively calculate the disease states in a city day-by-day, keeping track of the state of each person until the end of the simulation. In addition, you will see how to use functions that build on one another to simplify the implementation of a complex modeling process.
Getting started¶
Using the invitation URL provided on Ed Discussion, create a repository for your PA #1 files.
Next, make sure you’ve
set the GITHUB_USERNAME
variable by running the following (replacing replace_me
with your GitHub username):
GITHUB_USERNAME=replace_me
(remember you can double-check whether the variable is properly set by
running echo $GITHUB_USERNAME
)
And finally, run these commands (if you don’t recall what some of these commands do, they are explained at the end of the Git Tutorial):
cd ~/cmsc12100
mkdir pa1-$GITHUB_USERNAME
cd pa1-$GITHUB_USERNAME
git init
git remote add origin git@github.com:uchicago-cmsc12100-aut-21/pa1-$GITHUB_USERNAME.git
git remote add upstream git@github.com:uchicago-cmsc12100-aut-21/pa1.git
git pull upstream main
git branch -M main
git push -u origin main
You will find the files you need for the programming assignment directly in the root of your
repository, including a README.txt
file that explains what each file is. Make sure you read that file.
Next, make sure to review the Coursework Basics page.
As described in that page, we strongly encourage you to start an
IPython session to experiment with your code. Again, the steps are
similar to those for the short exercises: open up a new terminal
window and navigate to your pa1-$GITHUB_USERNAME
directory. Then, fire up
ipython3
from the Linux command-line, set up autoreload, and
import your code as follows:
$ ipython3
In [1]: %load_ext autoreload
In [2]: %autoreload 2
In [3]: import sir
Finally, for this assignment, you may assume that the input passed to your functions has the correct format. You may not alter any of the input that is passed to your functions. In general, it is bad style to modify a data structure passed as input to a function, unless that is the explicit purpose of the function. The user or client of your function may have other uses for the data structure and should not be surprised by unexpected changes.
The Model¶
To begin building our SIR model, we must specify the model’s details. In particular, we must decide what data we will model as part of our simulation (including the exact Python types and data structures we will use) as well as how to model the behaviour of a disease.
In our simulation, the data we will be modeling is the following:
Disease states: The ways of describing the health of each person in the simulation.
Person: How an individual is represented.
Structure of a city: How a city is represented, and the neighbors of each individual in the city.
The behaviour of our simulation will be governed by a series of simulation rules that model the behaviour of the disease, and specify how the state of the simulation (in this case, the city and the people inside it) is updated as the simulation progresses, including when to stop the simulation.
We specify each of these details below.
Disease states: all people in the simulation can exist in one of three disease states, Susceptible, Infected, or Recovered.
Susceptible: the individual is healthy but may become infected in the future. We will represent this disease state with the string
'S'
.Infected: the individual has an infection currently. We will represent this disease state with the string
'I'
.Recovered: the individual has recovered from an infection and will be immune to the infection for the rest of the simulation. We represent this disease state with the string
'R'
. (Some versions of the SIR model remove recovered people from the model. In our model, recovered people will remain in the city.)
Please note that, in Task 6 of the assignment, we will introduce an
additional state: Vaccinated (represented with the string 'V'
). You do not
need to worry about this state, or any references to vaccinations,
until you get to those final tasks.
Person: a person is represented with a tuple containing a disease
state and the number of days the person has been in that state.
For example, a person who has been infected for ten days would be
represented as ('I', 10)
. Notice that we count days
starting from zero. For example, on the first day that a person is in
the recovered state, that person would be represented as ('R', 0)
.
Structure of a city: a city in this simulation is represented as a
list of people, each represented by a tuple. For example, a
city of [('S', 2), ('I', 1), ('R', 2)]
is composed of three
people, the first of whom is susceptible and has been in that state
for two days, the second of whom is infected and is one day into the
infection, and the third of whom has been recovered for two days.
You can assume that every city has at least three people.
We are going to model the city as a ring. A person in our simplified model has two neighbors: a left neighbor and a right neighbor. For someone in the middle of the list, their left neighbor is the person immediately before them in the list and their right neighbor is the person immediately after them in the list. For the first person in the list, their left neighbor is the last person in the list and the right neighbor is the person immediately after them in the list. For the last person in the list, the left neighbor is the person immediately before them in the list and their right neighbor is the first person in the list.
For example, consider the following list of people:
['Mark', 'Sarah', 'Lorraine', 'Marshall']
:
Marshall is Mark’s left neighbor and Sarah is his right neighbor.
Mark is Sarah’s left neighbor and Lorraine is her right neighbor.
Sarah is Lorraine’s left neighbor and Marshall is her right neighbor.
Finally, Lorraine is Marshall’s left neighbor and Mark is his right neighbor.
Simulation rules: While the above details model the data in our simulation, the simulation rules model the behaviour of a disease in our city. More specifically, these rules tell us how people in the city are updated as the simulation progresses. Our rules will be the following:
Disease transmission: A susceptible person with at least one infected neighbor will always get infected the next day. There is no other way for a person in our simulation to become infected.
Contagiousness of infected people: The number of days a person is infected and remains contagious is a parameter to the simulation. We will track the number of days a person has been infected as part of their state. People who become infected start out in state
('I', 0)
. For each day a person is infected, we increment the counter by one:('I', 0)
becomes('I', 1)
,('I', 1)
becomes('I', 2)
, etc. When the counter reaches the specified number of days contagious, we will declare them to be recovered (('R', 0)
) and no longer contagious. At that point, they are immune to the disease and cannot become re-infected. For example, if we are simulating an infection in which people are contagious for three days, a newly infected person will start in state('I', 0)
, move to('I', 1)
after one day, to('I', 2)
after two days, and to state('R', 0)
after three days.Update rule: To update the state of the simulation, we process all the people in the city and, if the above two rules cover a person, we update that person as described above. Otherwise, we increment the number of days. For example, the new representation for a person who has been recovered for two days (
('R', 2)
) is('R', 3)
.Stopping condition: the simulation should stop when no infection transmissions are possible. An infection can be transmitted when the city has at least one susceptible person with an infected neighbor; if no such person exists in the city, the simulation ends.
Your tasks¶
Your job in this assignment is to write code to simulate this model. We will specify a set of functions that you must implement and provide instructions for how to put these functions together to construct the simulation. Like the first Short Exercises, understanding functions is not essential to completing this assignment, and we have specified exactly where in the file you need to add your code.
You will start with basic functions and work your way up to more complex tasks. We will also supply extensive test code. Over the course of the term, we will provide less and less guidance on the appropriate structure for your code.
Task 1: Has an infected neighbor¶
In Python, it is common to write helper functions that encapsulate key
definitions and are only a few lines long. Your first task is to
complete one such function: has_an_infected_neighbor
. This
function will determine whether a susceptible person at a given
location in a list has at least one neighbor who is infected.
More specifically, given the city and the person’s location, your code will compute the locations of the specified person’s left and right neighbors in the city and determine whether either one is in an infected state.
Recall that the city is being modelled as a ring. The last person in the city is the left neighbor of the first person in the city and the first person in the city is the right neighbor of the last person in the city. Your code will need to handle these special cases.
It only makes sense to call this function on a location that contains a susceptible person. To verify that the function is called appropriately, we this line to verify that the location is valid:
assert 0 <= location < len(city)
and these two lines to verify that the location contains a susceptible person:
disease_state, _ = city[location]
assert disease_state == "S"
In general, assertions have the following form:
assert <boolean expression>
Assertions are a useful way to check that your code receives valid
inputs: if the boolean expression specified as the assertion’s
condition evaluates to False
, the assertion statement will make
the function fail. Simple assertions can greatly simplify the
debugging process by highlighting cases where a function is being
called incorrectly.
Notice that we unpacked the tuple at city[location]
before using
the disease state in the second assertion to improve the readability of the
code. Since we don’t need the number of days that the person has been
in their current disease state, we supplied _
as the name for that
part of the tuple (this convention is commonly used in Python to refer
to “a value that I don’t intend to use”).
Here is the code you will see in the sir.py
file:
def has_an_infected_neighbor(city, location):
'''
Determine whether a person at a specific location has an infected
neighbor in a city modelled as a ring.
Inputs:
city (list of tuples): the state of all people in the simulation
at the start of the day
location (int): the location of the person to check
Returns:
True, if the person has an infected neighbor, False otherwise.
'''
# The location needs to be a valid index for the city list.
assert 0 <= location < len(city)
# This function should only be called when the person at location
# is susceptible to infection.
disease_state, _ = city[location]
assert disease_state == "S"
# YOUR CODE HERE
# REPLACE False WITH THE APPROPRIATE BOOLEAN
return False
The function docstring (between triple quotes) specifies the inputs to
the function. You will be learning more about docstrings as we cover
functions in class but, for this assignment, it is enough to assume
that city
and location
will contain the values specified in
the docstring and, more specifically:
city
will be a list of tuples with the state of all people in the simulation at the start of the day andlocation
will contain an integer between 0 andlen(city)-1
.
You must write code that determines the locations of the left and right neighbors of the person at the specified location and then determines whether either neighbor is infected.
For example, given city [('S', 0), ('I', 0), ('I', 2), ('S', 0),
('R', 0), ('S', 2)]
and location 3, the function would return
True
, because the left neighbor of the person at location 3 is
infected. Given the same city and location 5, the function
would yield False
, because neither the left neighbor (the person
at location 4) nor the right neighbor (the person at location
0) are infected.
Testing Task 1
Like the Short Exercises, we have provided a suite of automated tests for this assignment. You should take a moment to review the Testing Your Code page to understand how to run these tests, as well as the importance of doing some manual testing before you jump to the automated tests.
In particular, we suggest you start with some manual testing
from IPython. Here, for example, are some sample calls to
has_an_infected_neighbor
:
In [10]: city = [('S', 0), ('I', 0), ('I', 2), ('S', 0), ('R', 0), ('S', 2)]
In [11]: sir.has_an_infected_neighbor(city, 3)
Out[11]: True
In [12]: sir.has_an_infected_neighbor(city, 5)
Out[12]: False
In [13]: sir.has_an_infected_neighbor([('S', 0), ('I', 20), ('R', 3)], 0)
Out[13]: True
If you get a ModuleNotFound
error, make sure you remembered to run
import sir
in IPython, so you can run the code contained in
sir.py
.
In the first sample call, we check whether the susceptible person at
location 3 has an infected neighbor. Since their left neighbor
(location 2) is infected, the result is True
.
In the second sample call, we check whether the susceptible
person in the last location in the list (location 5) has an
infected neighbor. Their left neighbor (location 4) is recovered,
and their right neighbor (location 0) is susceptible, so the
result is False
.
Finally, the last sample call checks whether the person in location
0 of the specified city has an infected neighbor. Since the
person’s right neighbor (location 1) is infected, the result is
True
.
Once you have tried out your function in ipython3
and are
reasonably confident that it works properly, you are ready to run the
automated tests. You can find detailed information about the automated
tests for this task here: Tests for Task 1: Has an infected neighbor.
Debugging suggestions and hints for Task 1
Remember to save any changes you make to your code in your editor as
you are debugging. Skipping this step is a common error.
Fortunately, we’ve eliminated another common error – forgetting to
reload code after it changes – by using the autoreload
package.
(If you skipped the Getting started section, please restart ipython3
and then go back and
follow the instructions to set up autoreload
and import sir
)
There is a lot going on in this function and, when you are debugging,
it can be helpful to know exactly what is happening inside the
function. print
statements are among the most intuitive ways to
identify what your code is actually doing and will become your go-to
debugging method. If you are struggling to get started or to return
the correct values from your function, consider the following
debugging suggestions:
Print the location of the left neighbor.
Print the location of the right neighbor.
Print the disease states that you extracted for those neighbors.
Are you generating the correct values? If so, is your code behaving as expected given these values?
Also, make sure that you are returning, not printing, the desired value from your function.
Don’t forget to remove your debugging code (i.e., the print statements) before you submit your solution. While you are at it, make sure to remove our directives to you (e.g. # YOUR CODE HERE
) as well.
Task 2: Advance person at location¶
Your second task is to complete the function
advance_person_at_location
. The goal of this function is to
advance the state of a person from one day to the next. Given a city,
a person’s location within that city, and the number of days c the
infection is contagious, your function should determine the
tuple that represents the next state of the person.
Specifically, if the person (ds, d) is:
Susceptible: Given a susceptible person (that is, ds is
'S'
), you need to determine whether they have an infected neighbor (by using thehas_an_infected_neighbor
function) and, if so, advance them to the first infected state (('I', 0)
). Otherwise, advance them to ('S'
, d+1).Infected: Given an infected person (that is, ds is
'I'
), determine whether the person remains infected (that is, d + 1 < c) and moves to the next infected state (e.g.('I', 0)
becomes('I', 1)
,('I', 1)
becomes('I', 2)
, etc) or switches to the initial recovered state, i.e.,('R', 0)
.Recovered: Given a recovered person (that is ds is
'R'
), construct a new tuple with'R'
and the number of days they have been recovered increase by one, i.e., ('R'
, d+1).
As an example, consider the following calls to advance_person_at_location
:
In [20]: sir.advance_person_at_location([('I', 0), ('I', 1), ('R', 0)], 0, 2)
Out[20]: ('I', 1)
In [21]: sir.advance_person_at_location([('I', 0), ('I', 1), ('R', 0)], 1, 2)
Out[21]: ('R', 0)
In [22]: sir.advance_person_at_location([('I', 0), ('I', 1), ('R', 0)], 2, 2)
Out[22]: ('R', 1)
The first call determines that the person at location 0 moves from
state ('I', 0)
to ('I', 1)
. Since 1
is less than the
number of days the infection is contagious (2
), the person remains
infected.
The second call determines that the person at location 1 shifts to
state ('R', 0)
. Unlike the previous example, this person does not
remain infected (that is, in state ('I', 2)
), because they have
reached the number of days the infection is contagious. More
formally, c is 2
and d for this person is 1
, which means d+1 (2
)
is not strictly less than c
and so, the person recovers.
And finally, the third call returns ('R', 1)
because the person at
location 2 is initially in state ('R', 0)
.
Once you have tried out your function in ipython3
and are
reasonably confident that it works properly, you are ready to run the
automated tests. You can detailed information about the automated
tests for this task here: Tests for Task 2: Advance person at location.
Debugging suggestions for Task 2
If you are struggling to return the correct values from your function, consider printing out the person’s location, their old state, and their new state and then try to identify cases where your function is not behaving properly.
Task 3: Move the simulation forward a single day¶
Your third task is to complete the function simulate_one_day
.
This function will model one day in a simulation and will act as a
helper function to run_simulation
. More concretely, given the
city’s state at the start of the day and the number of days a person
is contagious c, the function should return a new list of states for
the people (i.e., the state of the city after one day).
Your implementation for this function must use
advance_person_at_location
to determine the new state of each
person in the city.
Here are some sample calls to this function:
In [30]: city = [('S', 0), ('I', 0), ('R', 2), ('S', 0), ('R', 1)]
In [31]: sir.simulate_one_day(city, 2)
Out[31]: [('I', 0), ('I', 1), ('R', 3), ('S', 1), ('R', 2)]
In [32]: sir.simulate_one_day(city, 1)
Out[32]: [('I', 0), ('R', 0), ('R', 3), ('S', 1), ('R', 2)]
In the first sample call notice how:
the susceptible person at location 0 becomes infected (they have an infected neighbor),
the person at location 1 advances to the next state of their infection (
('I', 0)
to('I', 1)
),the recovered people in locations 2 and 4 stay in the same state (
'R'
) and their number of days in that state increases by 1, and finallythe susceptible person at location 3 stays in the susceptible state, because they do not have an infected neighbor.
The output for the second sample call, which uses the same city as the
first call, is similar. The only difference is that the person in
location 1 advances to the initial recovered state (('R', 0)
),
because the infection is contagious for fewer days.
You can find detailed information about the automated tests for this task here: Tests for Task 3: Simulate one day.
Task 4: Is transmission possible¶
Your fourth task is to complete the function
is_transmission_possible
, which takes the current state of a city
and returns True
if the city contains at least one susceptible
person with an infected neighbor and False
otherwise.
Notice that this function can (and should) take advantage of the
has_an_infected_neighbor
function from Task 1.
Here are a few sample calls to this function:
In [40]: sir.is_transmission_possible([('S', 1), ('S', 0), ('R', 27)])
Out[40]: False
In [41]: sir.is_transmission_possible([('I', 1), ('R', 0), ('S', 27), ('R', 3)])
Out[41]: False
In [42]: sir.is_transmission_possible([('I', 1), ('R', 0), ('S', 27), ('S', 2)])
Out[42]: True
You can find detailed information about the automated tests for this task here: Tests for Task 4: Is transmission possible.
Debugging suggestions for Task 4
If you are struggling to return the correct value from your function, consider printing a message when you identify a susceptible person. Do they have an infected neighbor? Are you stopping your loop too soon or too late?
Task 5: Run the simulation¶
Your fifth task is to complete the function run_simulation
, which
takes the starting state of the city and the number of days a person
is contagious, and returns both the final state of the city and the
number of days simulated as a tuple.
The function must run one whole simulation, repeatedly simulating one
day until the stopping condition–the infection is no longer
transmissible in the city–is reached. As you do this work, the
function must also count the number of days simulated. Note that two
functions–is_transmission_possible
and simulate_one_day
–that
you wrote for earlier tasks should do most of the heavy lifting for
this task.
Take into account that, if the stopping condition is true at the start of the simulation, then the number of days simulated will be zero.
Here are two example uses of this function:
In [50]: sir.run_simulation([('S', 0), ('S', 0), ('I', 0)], 3)
Out[50]: ([('I', 0), ('I', 0), ('I', 1)], 1)
In [51]: sir.run_simulation([('I', 0), ('S', 0), ('S', 0), ('S', 0), ('R', 0)], 3)
Out[51]: ([('R', 0), ('I', 2), ('I', 1), ('I', 0), ('R', 3)], 3)
You can find detailed information about the automated tests for this task here: Tests for Task 5: Run simulation.
Debugging hints for Task 5
If you are generating the wrong final state for the city, try printing
the day (0
, 1
, 2
, etc.), the city’s state before the
call to simulate_one_day
, and city’s new state after the call to
simulate_one_day
.
Task 6: Adding vaccinated people¶
In this task, we are going to introduce a new disease state for
vaccinated people: 'V'
. For our purposes, people in this new
state will behave in exactly the same way as recovered people: a
vaccinated person is immune to the disease, cannot become infected,
and cannot cause other people to become infected. As with everyone
else in our city, vaccinated people will be represented with their
disease state ('V'
) and the number of days they have been in that
state (e.g., ('V', 10)
).
You will not write any new functions for this task. Instead, you will return to the functions for the first five tasks and test them one at a time with sample inputs that include vaccinated people. These new tests may pass without any changes to your functions, or they may require you to rethink some of your code depending on your implementation choices.
To run these tests, you can simply add --runvax
to the earlier
commands:
$ py.test -xvk has --runvax
$ py.test -xvk advance --runvax
$ py.test -xvk one --runvax
$ py.test -xvk possible --runvax
$ py.test -xvk run --runvax
Each command runs both the original tests and the new tests for a specific function. It is important to run the original tests along with the new tests when you make changes to your functions to verify that your changes haven’t broken previously working code. The process of rerunning tests after a change is known as regression testing.
Since some of the later functions call the earlier functions, we
encourage you to run the first command (for
has_an_infected_neighbor
), fix any problems that arise, and only
then move on to running the second command, etc.
You can find descriptions of the specific tests for each task on the corresponding testing page. For example, for Task 1, see the second half of Tests for Task 1: Has an infected neighbor.
Task 7: Vaccinate a person¶
Now that you have verified that your solutions to the earlier tasks work for cities with vaccinated people, we can move on to talking about the vaccination process.
In our simulated city, some people are very very eager to be vaccinated, others not so much. We will model this behaviour by associating a floating point eagerness value with each person and by introducing a new way to represent a person at the start of the simulation. A person and their vaccine eagerness will be represented by a vax tuple that has three values: a disease state, a number of days in the state, and an eagerness to be vaccinated.
Each resident of the city will enter the vaccine clinic represented as a vax tuple and will exit the clinic represented as a person tuple. Only susceptible residents have a shot at getting vaccinated. For infected and recovered residents, the vaccine clinic will convert their vax tuple into the corresponding person tuple (that is, a tuple with their disease state and the number of days they have been in that state).
Susceptible people, on the other hand, can get vaccinated when they visit the clinic. In the context of this exercise, you can think about vaccine eagerness e as being similar to flipping a weighted coin. This means that, if e is 0.8, the coin will land on “heads” 80% of the time, and on “tails” 20% of the time. Now, imagine that the coin has “gets vaccinated” instead of “heads”, and “does not get vaccinated” instead of “tails”.
So, for a susceptible resident with vax tuple ('S'
, d, e), we
flip this weighted coin to determine whether they get vaccinated
(i.e., the person will be represented with the person tuple ('V',
0)
) or does not (i.e., the person will be represented with the
person tuple ('S'
, d).
To “flip a coin” in Python, we will use a random number generator
and, more specifically, you will call random.random()
, a function
that returns a random floating point number between 0.0 and 1.0. We
will interpret the returned value as follows:
If the random number is strictly less than e, then the person gets vaccinated.
If the random number is greater than or equal to e, the person does not get vaccinated.
So, vaccinate_person
will take a vax tuple and will return a new
person tuple. If the vax tuple represents a susceptible person, the
person be will vaccinated (or not) according to the above rule. If
the vax tuple represents an infected or recovered person, the function
will convert the vax tuple to a person tuple.
Now, there’s a small twist to using random numbers. Let’s give
random.random()
a try; to do so, you will first have to import the
random
module:
In [60]: import random
Now, try calling the function a few times:
In [61]: random.random()
Out[61]: 0.595299247755262
In [62]: random.random()
Out[62]: 0.8159606343474648
In [63]: random.random()
Out[63]: 0.30061626031208444
Here’s the tricky thing about random numbers: you will almost
certainly see different numbers when you try out random.random()
(which makes sense: the function is meant to return a random number!)
This property can complicate debugging and testing, because you can call a
function that relies on random.random()
(like
vaccinate_person
) and get different results every time.
Fortunately, we can ensure that random.random()
returns the same
sequence of numbers when it is called by initializing it with a seed
value. It is common to set the seed value for a random number
generator when debugging. If we do not actively set the seed, random
number generators will usually derive one from the system clock.
Since many of our tests use the same seed (20170217
), we have
defined a constant, TEST_SEED
, with this value in sir.py
for
your convenience. This value should be used for testing only; it
should not appear anywhere in the code you write.
Let’s try out setting the seed using the value of sir.TEST_SEED
and then making some calls to the random number generator in ipython3
:
In [64]: sir.TEST_SEED
Out[64]: 20170217
In [65]: random.seed(sir.TEST_SEED)
In [66]: random.random()
Out[66]: 0.48971492504609215
In [67]: random.random()
Out[67]: 0.23010566619210782
In [68]: random.seed(sir.TEST_SEED)
In [69]: random.random()
Out[69]: 0.48971492504609215
In [70]: random.random()
Out[70]: 0.23010566619210782
Notice that the third and fourth calls to random.random()
generate
exactly the same values as the first two calls. Why? Because we set
the seed to the exact same value before the first and third calls.
The behavior of random
has another implication: it is crucial
that vaccinate_person
calls random.random()
only when you
encounter a susceptible person. If you call the random number
generator for every person (including people not in the 'S'
state), your code may generate different answers than ours on
subsequent tasks.
Testing for Task 7
As for the earlier tasks, we encourage you to try your code in
ipython3
before moving on to the automated test suite. Here are a
few sample calls to vaccinate_person
that you could try out:
In [71]: random.seed(sir.TEST_SEED)
In [72]: sir.vaccinate_person(('R', 0, 1.0))
Out[72]: ('R', 0)
In [73]: random.seed(sir.TEST_SEED)
In [74]: sir.vaccinate_person(('I', 10, 1.0))
Out[74]: ('I', 10)
In [75]: random.seed(sir.TEST_SEED)
In [76]: sir.vaccinate_person(('S', 0, 0.8))
Out[76]: ('V', 0)
In [77]: random.seed(sir.TEST_SEED)
In [78]: sir.vaccinate_person(('S', 5, 0.2))
Out[78]: ('S', 5)
In the first two sample calls, neither resident is eligible for
vaccination, so vaccinate_person
converts each vax tuple to a
person tuple. You might notice that since neither vax tuple
represents a susceptible resident, the calls to random.seed
before
the first two sample calls are not strictly necessary.
The vax tuple used in the third sample call (('S', 0, 0.8)
)
represents a resident who is eager to be vaccinated. Before we run
the test, we set the seed for the random number generator to the value
of sir.TEST_SEED
, which ensures that the necessary call
random.random()
will return a specific value
(0.48971492504609215
). Since this value is strictly less than
0.8
, the resident will be vaccinated and the function will return
('V', 0)
.
In contrast, the vax tuple that is passed to the fourth sample call
(('S', 5, 0.2)
) represents someone who is not so eager to be
vaccinated. Since we reset the seed to sir.TEST_SEED
before the
fourth call, the necessary call to random.random()
will again
generate 0.48971492504609215
. This value is greater than 0.2
,
and so, the individual will be not be vaccinated and the function will
return a person who has been susceptible for five days ('S', 5)
.
You can find detailed information about the automated tests for this task here: Tests for Task 7: Vaccinate a person.
Debugging suggestions and hints for Task 7
If you are struggling to return the correct values in your function, consider the following suggestions to debug your code:
Print the value returned by
random.random()
.Make sure that you are making the right number of calls to
random.random()
(you should only call it when you encounter a susceptible person).Make sure you are carrying over the correct number of days in the state for people who do not get vaccinated.
When testing in
ipython3
, ensure that you have reset the seed for the random number generator before each test call tovaccinate_person
.
Task 8: Vaccinating a city¶
The next task is to write a function to vaccinate a whole city. The
function vaccinate_city
will take a list of vax tuples and seed
for the random number generator as parameters. It should set the seed
for the random number generator and then run all the residents in the
city through the vaccine clinic (i.e., call vaccinate_person
) and
return a list of the resulting person tuples.
Keep in mind that random.seed
should be called exactly once (and
not once per resident) when executing a call to vaccinate_city
.
Here are some sample calls you might try out in ipython3
:
In [80]: vax_city = [('S', 0, 0.8), ('S', 5, 0.2), ('S', 0, 0.8), ('I', 0, 1.0), ('R', 10, 0.9)]
In [81]: sir.vaccinate_city(vax_city, sir.TEST_SEED)
Out[81]: [('V', 0), ('S', 5), ('V', 0), ('I', 0), ('R', 10)]
In [82]: sir.vaccinate_city(vax_city, sir.TEST_SEED+5)
Out[82]: [('V', 0), ('S', 5), ('S', 0), ('I', 0), ('R', 10)]
Notice that we do not need to call random.seed
before we call
vaccinate_city
, because the function itself is responsible for
handling the task of initializing the random number generator. Also,
notice that we used the same city in both examples, but got different
results because we used different seeds.
Your implementation for this task should use the function
vaccinate_person
to handle the details of whether a specific
individual gets vaccinated.
Once you have had a chance to try out your code in ipython3
, you
can move on to the automated tests. You can find detailed information
about the automated tests for this task here: Tests for Task 8: Vaccinate city.
Task 9: Vaccinate and simulate¶
Your last task is to write a function, vaccinate_and_simulate
,
that takes a city represented with vax tuples and a seed for the
random number generator, vaccinates the residents, and then runs a
simulation on the vaccinated city. The function should return a tuple
with the resulting city and the number of days simulated.
The implementation of this function should be very simple and should leverage functions from earlier tasks.
Testing for Task 9
Here’s a sample run in ipython3
:
In [90]: vax_city = [('S', 0, 0.8), ('S', 5, 0.2), ('S', 0, 0.8), ('I', 0, 1.0), ('R', 10, 0.9)]
In [91]: sir.vaccinate_and_simulate(vax_city, 3, sir.TEST_SEED)
Out[91]: ([('V', 0), ('S', 5), ('V', 0), ('I', 0), ('R', 10)], 0)
In [92]: sir.vaccinate_and_simulate(vax_city, 3, sir.TEST_SEED+5)
Out[92]: ([('V', 2), ('I', 0), ('I', 1), ('I', 2), ('R', 12)], 2)
The calls use the same city and seeds as the sample calls in the previous task. The different seeds result in different vaccination patterns and different final results.
You can find detailed information about the automated tests for this task here: Tests for Task 9: Vaccinate and run simulation.
Putting it all together¶
We have included code in sir.py
that calls your functions to run a
simulation on a city with or without vaccinations. For simulations
that include vaccinations, you can run one simulation and see how the
citizens fared or you can run many simulations to get a sense of the
median number of days it takes for infection transmission to stop for
a given configuration.
Running this program with the --help
flag shows the flags to use
for different arguments.
$ python3 sir.py --help
Usage: sir.py [OPTIONS] FILENAME
Process the command-line arguments and do the work.
Options:
--days-contagious INTEGER
--task-type [no_vax|vax]
--random-seed INTEGER
--num-trials INTEGER
--help Show this message and exit.
Cities are specified with a file. Each line in the file represents one
person tuple (S 0
) or one vax tuple (S 0 0.3
). A file may
contain only one type of tuple: either every line in the file contains
a person tuple or every line contains in the file a vax tuple. The file
sample_cities/person_city_0.txt
contains a sample city represented
with person tuples (a disease state and a number of days). The file
sample_cities/vax_city_0.txt
contains a sample city represented
with vax tuples (a disease state, a number of days, and a floating
point vaccine eagerness value).
Here is a sample use of this program that runs a simulation without a vaccine clinic:
$ python3 sir.py sample_cities/person_city_0.txt --days-contagious 5
and here is the output that it should print:
Running simulation ...
Final city: [('I', 3), ('R', 2), ('I', 0), ('I', 1)]
Days simulated: 2
Here are two sample simulations with vaccine clinics:
$ python3 sir.py sample_cities/vax_city_0.txt --days-contagious 5 --random-seed 20170217 --task-type vax
Running vax clinic and simulation ...
Final city: [('S', 2), ('V', 2), ('V', 2), ('I', 0), ('I', 1), ('I', 2), ('V', 2)]
Days simulated: 2
$ python3 sir.py sample_cities/vax_city_0.txt --days-contagious 5 --random-seed 20170221 --task-type vax
Running one vax clinic and simulation ...
Final city: [('S', 0), ('S', 0), ('V', 0), ('S', 0), ('V', 0), ('I', 0), ('V', 0)]
Days simulated: 0
The two simulations use the same city but with different seeds. As a result, the vaccine clinics vaccinate different people and thus the outcomes are different.
Since different seeds yield different results, we cannot conclude very
much from one simulation. One way to mitigate this problem is to do
many trial runs of the simulation and then aggregate the results. We
have included a function that does just that: it runs the
vaccinate_and_simulate
function multiple times using a different
seed for each trial and then returns the median number of days until
the infection stops over all the trials.
Here’s a sample run that uses a larger starting city than the previous examples and runs 100 trials:
$ python3 sir.py sample_cities/vax_city_1.txt --days-contagious 5 --random-seed 20170217 --task-type vax --num-trials 100
Running multiple trials of the vax clinic and simulation ...
Median number of days until infection transmission stops: 7
Grading¶
The assignment will be graded according to the Rubric for Programming Assignments. Make sure to read that page carefully; the remainder of this section explains aspects of the grading that are specific to this assignment.
In particular, your completeness score will be determined solely on the basis of the automated tests, which provide a measure of how many of the tasks you have completed:
Grade |
Percent tests passed |
---|---|
Exemplary |
at least 95% |
Satisfactory |
at least 80% |
Needs Improvement |
at least 50% |
Ungradable |
less than 50% |
For example, if your implementation passes 85% of the tests, you will earn a S (Satisfactory) score for completeness.
The code quality score will be based on the general criteria in the Rubric for Programming Assignments but, in this programming assignment, we will also be paying special attention to whether your code suffers from any of the following:
The use of unnecessary loops: Are you looping through a list more than once, when a single pass through the list is enough? Careful with using the
count
method! That uses afor
loop internally, so each call to that method counts as a full pass through the list.Constructing unnecessary lists: Are you constructing a list that is not actually needed to perform a certain computation? For example, given the list
[0, 1, 1, 1, 0, 1, 1]
suppose you wanted to count the number of1
’s in the list. You could construct a new list that just contained the 1’s, and then using thelen()
function to get the length of that list. A better way would be to figure out a way to count the number of1
’s without having to construct an entirely separate list.Creating unnecessary copies of the city: You should not need to create copies of the city list anywhere in your code (although there are a few places where you’ll need to construct a new list starting from the existing city). If you find yourself making an exact copy of the city at any point, you may want to re-think your approach.
Keeping track of unnecessary information: When running a simulation, you will be progressing through the state of the city across several days. We really only care about the final state of the city (at the end of the simulation). If you find yourself producing a list of cities (one for each day of the simulation), you should ask yourself whether you actually need to keep track of that information.
Repeating code instead of re-using existing functions: There are various points in the assignment where part of a task requires performing the same computation as in a prior task. In these cases, you shouldn’t just copy-paste the code from the prior task into another task. Think about how you would use the existing functions in your code.
Using
while
loops when afor
loop would be more appropriate: When iterating over a list or a defined range of values, you should always use afor
loop. This is not to say you won’t have to use awhile
loop at some point in this assignment, but be absolutely certain that’s the type of loop you need to use.
While these are the main things we care about in this assignment, please remember that it is not possible for us to give you an exhaustive list of every single thing that could affect your code quality score (and that thinking in those terms is generally counterproductive to learning how to program; see our How to Learn in this Class page for more details).
In general, avoiding all of the above issues will increase the likelihood of getting an E; if your code has a few (but not most) of the above issues, that will likely result in an S; if your code suffers from most of those issues, it will likely get an N. That said, to reiterate, we could also find other issues in your code that we did not anticipate, but that end up affecting your code quality score. When that happens, we encourage you to see these as opportunities to improve your code in future assignments (or as specific things to change if you decide to resubmit the assignment).
And don’t forget that style also matters! You could avoid all of the above issues, and still not get an E because of style issues in your code. Make sure to review the general guidelines in the Rubric for Programming Assignments, as well as our Style Guide.
Cleaning up¶
Before you submit your final solution, you should, remove
any
print
statements that you added for debugging purposes andall in-line comments of the form: “YOUR CODE HERE” and “REPLACE …”
Also, check your code against the style guide. Did you use good variable names? Do you have any lines that are too long, etc.
Do not remove header comments, that is, the triple-quote strings that describe the purpose, inputs, and return values of each function.
As you clean up, you should periodically save your file and run your code through the tests to make sure that you have not broken it in the process.
Submission¶
You will be submitting your work through Gradescope (linked from our Canvas site). The process will be the same as with the short exercises: Gradescope will fetch your files directly from your PA1 repository on GitHub, so it is important that you remember to commit and push your work! You should also get into the habit of making partial submissions as you make progress on the assignment; remember that you’re allowed to make as many submissions as you want before the deadline.
To submit your work, go to the “Gradescope” section on our Canvas site. Then, click on “Programming Assignment #1”. If you completed Short Exercises #1, Gradescope should already be connected to your GitHub account. If it isn’t, you will see a “Connect to GitHub” button button. Pressing that button will take you to a GitHub page asking you to confirm that you want to authorize Gradescope to access your GitHub repositories. Just click on the green “Authorize gradescope” button.
Then, under “Repository”, make sure to select your uchicago-cmsc12100-aut-21/pa1-$GITHUB_USERNAME.git
repository.
Under “Branch”, just select “main”.
Finally, click on “Upload”. An autograder will run, and will report back a score. Please note that this autograder runs the exact same tests (and the exact same grading script) described in Testing Your Code. If there is a discrepancy between the tests when you run them on your computer, and when you submit your code to Gradescope, please let us know.
Acknowledgments: This assignment was inspired by a discussion of the SIR model in the book Networks, Crowds, and Markets by Easley and Kleinberg. Emma Nechamkin wrote the original version of this assignment.