Modeling Epidemics

Due: Friday, October 14th at 4:30pm CT

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 in the “Working with upstream repositories” section of Git Part II ):

cd ~/capp30121
mkdir pa1-$GITHUB_USERNAME
cd pa1-$GITHUB_USERNAME
git init
git remote add origin git@github.com:uchicago-CAPP30121-aut-2022/pa1-$GITHUB_USERNAME.git
git remote add upstream git@github.com:uchicago-CAPP30121-aut-2022/pa1-initial-code.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 behavior 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 behavior of our simulation will be governed by a series of simulation rules that model the behavior 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 Part 2 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 behavior 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 are true for 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.

Part 1: Basic SIR

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 and

  • location will contain an integer between 0 and len(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:

  1. Susceptible: Given a susceptible person (that is, ds is 'S'), you need to determine whether they have an infected neighbor (by using the has_an_infected_neighbor function) and, if so, advance them to the first infected state (('I', 0)). Otherwise, advance them to ('S', d+1).

  2. 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).

  3. 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).

Note: There is an additional parameter to this function and some other functions below called infection_probability. You do not need to use this parameter or understand the its purpose until Part 2 of this assignment. When you test your functions by calling them in IPython, you can pass the value 0 (or any other value) for infection_probability.

Here are some example calls to advance_person_at_location:

In [20]: sir.advance_person_at_location([('I', 0), ('I', 1), ('R', 0)], 0, 2, 0)
Out[20]: ('I', 1)

In [21]: sir.advance_person_at_location([('I', 0), ('I', 1), ('R', 0)], 1, 2, 0)
Out[21]: ('R', 0)

In [22]: sir.advance_person_at_location([('I', 0), ('I', 1), ('R', 0)], 2, 2, 0)
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. Be careful to call advance_person_at_location with the correct parameters in the correct order.

Here are some sample calls to this function (again, letting infection_probability be 0):

In [30]: city = [('S', 0), ('I', 0), ('R', 2), ('S', 0), ('R', 1)]

In [31]: sir.simulate_one_day(city, 2, 0)
Out[31]: [('I', 0), ('I', 1), ('R', 3), ('S', 1), ('R', 2)]

In [32]: sir.simulate_one_day(city, 1, 0)
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 finally

  • the 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: Move the simulation forward a single 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, 0)
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, 0)
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 the 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.

Part 2: Adding vaccinated people

In this part, we are going to introduce a new disease state for vaccinated people: "V". A vaccinated person is not completely immune to the disease, but they will have a smaller chance of becoming infected than a susceptible person in state "S", who always becomes infected if they have an infected neighbor. 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)).

In Part 2, you will not write any new functions. Instead, you will return to the functions for the first five tasks and add to your code so that vaccinated people are supported in our model.

Task 6: Has an infected neighbor

Start by modifying the has_an_infected_neighbor function so that it can be called on a location that contains a susceptible person or a vaccinated person.

In Task 1, we included an assertion statement that prevents has_an_infected_neighbor from being called on a location that contains anything other than a susceptible person. Update the assertion statement so that this function can be called on a susceptible person (state "S") or vaccinated person (state "V"). You may or may not need to make other changes to your code in this function, depending on your implementation choices.

To run new tests that include vaccinated people, you can add --runvax to the earlier commands:

$ py.test -xvk has --runvax

This command runs both the original tests and the new tests for has_an_infected_neighbor. 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.

You can find descriptions of the specific tests for each task on the corresponding testing page here: Tests for Task 6, Part 2: Adding vaccinated people.

Task 7: Advance person at location

The vaccine in our model does not make a vaccinated individuals in state "V" completely immune to infection. Instead, it reduces the likelihood of vaccinated individuals becoming infected. That is, if a vaccinated person in state "V" has an infected neighbor, there will be some chance that they will become infected. Contrast this with a susceptible person in state "S", who will always become infected if they have an infected neighbor. In this task, we will talk about how a vaccinated person becomes infected with the disease.

The likelihood that a vaccinated person will become infected will be modeled by a floating point number p. In the context of this simulation, you can think about the likelihood p as being similar to flipping a weighted coin. This means that, if p 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 infected” instead of “heads”, and “does not get infected” instead of “tails”.

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 p, then the vaccinated person gets infected.

  • If the random number is greater than or equal to p, the vaccinated person does not get infected.

The advance_person_at_location function will advance the state of a person in a city from one day to the next as follows:

For a vaccinated resident represented by the person tuple ("V", d) with an infected neighbor, flip this weighted coin to determine whether they get infected (i.e., the person will advance with the person tuple ('I', 0)) or not (i.e., the person will be represented with the person tuple ("V", d+1). If a vaccinated person does not have an infected neighbor, they cannot become infected and will advance to the state ("V", d+1). The rules for susceptible, infected, and recovered people are the same as they were in Task 3.

The value of the likelihood p is the infection_probability parameter, which we have been ignoring up until now. You will need to use this parameter when you implement the rules above.

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 advance_person_at_location) 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 some 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 advance_person_at_location calls random.random() only when you encounter a vaccinated person with an infected neighbor. If you call the random number generator for every person (including people not in the "V" 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 advance_person_at_location that you could try out:

In [70]: vax_city = [('V', 2), ('S', 5), ('V', 0), ('I', 0), ('R', 10)]

In [71]: random.seed(sir.TEST_SEED)

In [72]: sir.advance_person_at_location(vax_city, 2, 2, 0.5)
Out[72]: ('I', 0)

In [73]: random.seed(sir.TEST_SEED)

In [74]: sir.advance_person_at_location(vax_city, 2, 2, 0.1)
Out[74]: ('V', 1)

In [75]: random.seed(sir.TEST_SEED)

In [76]: sir.advance_person_at_location(vax_city, 0, 2, 0.5)
Out[76]: ('V', 3)

The location used in the first sample call (location 2, resident ('V', 0)) represents a vaccinated resident who could become infected, since they have an infected neighbor. 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 infection probability 0.5, the vaccinated resident will become infected and the function will return ('I', 0).

Contrast this with the second call, which is called on the same resident but with an infection probability of 0.1 (a much lower likelihood of becoming infected). 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.1, and so, the individual will be not become infected and the function will return a person who has been vaccinated for one day ('V', 1).

In the third sample call, the resident does not have an infected neighbor, so advance_person_at_location advances the person tuple by one day. You might notice that since the resident cannot become infected, the call to random.seed before the third sample call is not strictly necessary.

You can find detailed information about the automated tests for this task here: Tests for Task 7, Part 2: Adding vaccinated people.

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 vaccinated person with an infected neighbor).

  • Make sure you are carrying over the correct number of days in the state for people who do not become infected.

  • When testing in ipython3, ensure that you have reset the seed for the random number generator before each test call to advance_person_at_location.

Task 8: Is transmission possible

Revisit the is_transmission_possible function and add support for vaccinated individuals. is_transmission_possible takes the current state of a city and returns True if it is possible for the disease to spread, and False otherwise. This time, the city may contain vaccinated people (people in state "V"). Transmission is possible if the city contains at least one susceptible or vaccinated person with an infected neighbor.

To run tests that include vaccinated people, you can add --runvax to the earlier commands:

$ py.test -xvk possible --runvax

You can find detailed information about the automated tests for this task here: Tests for Task 8, Part 2: Adding vaccinated people.

Before moving on, we recommend pausing here and running tests for Tasks 3 and 5 with vaccinated individuals. Tasks 3 and 5 had no corresponding task in Part 2. Still, you may need to update your code to support vaccinated individuals, depending on your implementation choices.

You can run these tests using the commands:

$ py.test -xvk one --runvax

$ py.test -xvk run --runvax

You can find detailed information about the automated tests for these tasks here: Tests for Task 3, Part 2: Adding vaccinated people, part2_task5.

Part 3: Adding vaccine clinics

Task 9: Vaccinate a person

In our simulated city, some people are willing to be vaccinated and others are not. We will model this behavior by introducing a new way to represent a person at the start of the simulation. A person and their vaccine willingness will be represented by a vax tuple that has three values: a disease state, a number of days in the state, and a boolean representing that person’s willingness 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 are eligible to get 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. For a susceptible resident with vax tuple ('S', d, w), we will vaccinate them if the boolean w is True (so the person will be represented with the person tuple ('V', 0)) or, if w is False, the person will be represented with the person tuple ('S', d).

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.

Tests for Task 9: Vaccinate a person

Task 10: 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 then run all the residents in the city through the vaccine clinic and return a list of the resulting person tuples.

Your implementation for this task should use the function vaccinate_person to handle the details of whether a specific individual gets vaccinated.

Here are some sample calls you might try out in ipython3:

In [80]: vax_city = [('S', 0, True), ('S', 5, True), ('S', 0, False), ('I', 0, False), ('R', 10, True)]

In [81]: sir.vaccinate_city(vax_city)
Out[81]: [('V', 0), ('V', 0), ('S', 0), ('I', 0), ('R', 10)]

In [82]: sir.vaccinate_city([('I', 3, True), ('V', 5, False)])
Out[82]: [('I', 3), ('V', 5)]

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 10: Vaccinate a city.

Task 11: Vaccinate and simulate

Your last task is to write a function, vaccinate_and_simulate, that takes a city represented with vax tuples, an infection probability, and a seed for the random number generator.

This function should set the seed for the random number generator and then vaccinate the residents and run a simulation on the vaccinated city. The function should return a tuple with the resulting city and the number of days simulated.

Keep in mind that random.seed should be called exactly once (and not once per resident or once per call to vaccinate_city) when executing a call to vaccinate_and_simulate.

The implementation of this function should be very simple and should leverage functions from earlier tasks.

Testing for Task 11

Here’s a sample run in ipython3:

In [90]: vax_city = [('S', 0, True), ('S', 5, True), ('S', 0, False), ('I', 0, False), ('R', 10, True)]

In [91]: sir.vaccinate_and_simulate(vax_city, 3, 0.2, sir.TEST_SEED)
Out[91]: ([('V', 4), ('V', 4), ('R', 0), ('R', 1), ('R', 14)], 4)

In [92]: sir.vaccinate_and_simulate(vax_city, 3, 0.2, sir.TEST_SEED+5)
Out[92]: ([('I', 0), ('R', 0), ('R', 1), ('R', 2), ('R', 15)], 5)

Notice that we used the same city in both examples, but got different results because we used different seeds.

You can find detailed information about the automated tests for this task here: Tests for Task 11: Vaccinate and simulate.

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]
  --infection-probability FLOAT
  --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 --infection-probability 0.5 --random-seed 20170217 --task-type vax
Running one vax clinic and simulation ...
Final city: [('I', 2), ('I', 1), ('I', 0), ('R', 0), ('R', 1), ('R', 2), ('R', 1)]
Days simulated: 7

$ python3 sir.py sample_cities/vax_city_0.txt --days-contagious 5 --infection-probability 0.1 --random-seed 20170217 --task-type vax
  Running one vax clinic and simulation ...
  Final city: [('V', 16), ('V', 16), ('R', 0), ('R', 3), ('R', 6), ('R', 11), ('V', 16)]
  Days simulated: 16

The two simulations use the same city but with infection likelihood for vaccinated individuals. As a result, different vaccinated people became infected and thus the outcomes are different.

Here are two sample simulations with a different random seeds:

$ python3 sir.py sample_cities/vax_city_0.txt --days-contagious 5 --infection-probability 0.5 --random-seed 20170217 --task-type vax
Running one vax clinic and simulation ...
Final city: [('I', 2), ('I', 1), ('I', 0), ('R', 0), ('R', 1), ('R', 2), ('R', 1)]
Days simulated: 7

$ python3 sir.py sample_cities/vax_city_0.txt --days-contagious 5 --infection-probability 0.5 --random-seed 20170221 --task-type vax
Running one vax clinic and simulation ...
Final city: [('I', 3), ('I', 2), ('I', 0), ('I', 1), ('I', 2), ('R', 1), ('R', 0)]
Days simulated: 6

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 --infection-probability 0.2 --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: 20

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 a for 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 of 1’s in the list. You could construct a new list that just contained the 1’s, and then using the len() function to get the length of that list. A better way would be to figure out a way to count the number of 1’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 a for loop would be more appropriate: When iterating over a list or a defined range of values, you should always use a for loop. This is not to say you won’t have to use a while 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 and

  • all 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-CAPP30121-aut-2022/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.