Over the past several months the world has seen many different coronavirus models. Almost all of them follow the basic principles of splitting up a population into categories of Susceptible - Infected - Recovered. This type of compartmental model is called a SIR model, and it’s one of the most fundamental tools used by epidemiologists today. Even the most advanced models, guiding decisions worth hundreds of billions, flow from this straightforward approach. Today I’m going to show you how to build a functioning SIR model in 20 minutes or less.
Before starting, we should do a gut check and ask ourselves, “Why?” Why build one? I think there are many reasons, but I’ll highlight just three.
Booting Up
We’re going to use HASH, a platform for building multi-agent models. If this is your first time using HASH, consider poking around the docs and taking the “Hello, HASH” getting started tutorial.
Create a new simulation, and open the init.json file. This is where you can set the initial agents in a simulation. Let’s start by creating a single agent.
{
"agent_name": "foo",
"position": [0,0]
}
Reset your simulation and you'll see our agent “foo” appear.
Let’s add a status property to the agent – this will take one of three string values: “healthy,” “infected,””recovered”. Every agent is going to start healthy. We'll also add in a "search_radius" feature - this will let the agent "see" its neighbors.
[
{
"agent_name": "foo",
"position": [0,0],
"status": "healthy",
"search_radius": 1
}
]
At the moment the agent, representing our person, is just a blob of state. To give them some actions we can add a behavior. On the left sidebar click the add new behavior file, and name the new file health.js.
We're going to add message passing. If the agent receives a message that says “exposed” from a neighbor agent, the agent might get sick. And if the agent is sick and has any neighbors nearby, we’ll send a message telling them they’ve been exposed.
function behavior(state, context) {
// Nearby neighbors
const neighbors = context.neighbors();
// Messages received
const msgs = context.messages();
if (neighbors.length > 0 && state.get("status") == "infected") {
// send message to neighbors
neighbors.map(n => state.addMessage(n.agent_id, "exposed"))
}
if (msgs.some(msg => msg.type == "exposed")) {
// do something
}
}
In globals.json (where we store “global parameters” for the simulation) we can set several parameters that a user can experiment with:
Now modify health.js to check if an agent received an exposed message and whether or not it will get sick. We'll also add a field, called recovery_timestep. If an agent gets sick this field will be the timestep when they recover.
function behavior(state, context) {
if (msgs.some(m => m.type == "exposed") && state.get("status") != "infected"
&& Math.random() <= exposure_risk) {
state.set("status", "infected")
state.set("color", "red")
state.set("recovery_timestep", state.get("timestep") + context.globals().recovery_time)
}
}
We’re going to check to see if enough time has passed that the agent has gotten better and recovered from being sick. Create a behavior called recovery.js.
function behavior(state, context) {
let timestep = state.get("timestep");
if (state.get("status") == "infected" && timestep > state.get("recovery_timestep")) {
state.set("status", "recovered");
state.set("color", "grey");
}
timestep += 1
state.set("timestep", timestep)
}
Then add the timestep field to the agent, and attach the behaviors we’ve made.
[
{
"agent_name": "foo",
"position": [0,0],
"status": "healthy",
"search_radius": 1,
"timestep":0,
"behaviors": ["healthy.js", "recovery.js"]
}
]
Right now nothing happens. That’s because the agent is isolated and alone. We can fix that by changing init.json to create an agent that will create many other agents. We can also import that ability from the HASH Index, which is populated with behaviors that others have created and shared, and add it to the agent. (Read more on dynamically creating agents)
I also also added a movement behavior from the HASH-Index, called @hash/random_movement.rs
Click reset and run - you should see two green blocks running around the screen.
Now the final piece of the puzzle: assign an agent to start off sick.
{
"agent_name": "patient_zero",
"timestep": 0,
"status": "infected",
"position": [10,10],
"color": "red",
"search_radius": 1,
"recovery_timestep": 100,
"behaviors": [
"health.js",
"recovery.js",
"@hash/random_movement.rs"
]
},
You’re ready to start simulating!
Congratulations! You’ve created a SIR model that showcases how disease can spread among agents.
Extensions
Returning to Paul Romer, his series of blog posts on the coronavirus exemplifies using models to ground and think through ideas. We can recreate his examples here, testing out a policy intervention where we isolate individuals. To do that we're going to create one final agent, isolationbot5000.
{
"agent_name": "isolationBot5000",
"behaviors": [
"isolator.js"
],
"position": [
0,
0
],
"search_radius": 100,
"timestep": 0
}
This agent every n timesteps – defined in context.globals(), sends a message to a randomly selected agent to isolate. The only tricky part of this logic is how do we give a list of the agents to the isolation bot? One way is to set the search radius of the isolation bot as 100,* large enough that every agent is its neighbor. Then we can filter for neighbors and send a message to a randomly selected person that tells it to isolate.
function behavior(state, context) {
const { isolation_frequency } = context.globals()
const neighbors = context.neighbors()
let neighbor = neighbors[Math.floor(Math.random() * neighbors.length)]
let timestep = state.get("timestep");
if (timestep % isolation_frequency == 0 && neighbor) {
state.addMessage(
neighbor.agent_id,
"isolate",
{ "time": context.globals().isolation_time }
)
}
timestep += 1
state.set("timestep", timestep)
}
When an agent is isolated, it doesn’t send or receive “infect” messages, and it doesn't move.
//health.js
...
if (isolation_messages.length) {
const index = behaviors.indexOf("@hash/random_movement.rs");
if (index > -1) {
behaviors.splice(index, 1);
}
state.set("behaviors", behaviors)
state.set("isolation_timestep", state.get("timestep") + isolation_time)
state.set("color", "black")
return state
}
//recovery.js
...
if (state.get("isolation") && timestep > isolation_timestep){
state.modify("behaviors", behaviors => behaviors.concat(["@hash/random_movement.rs"]))
state.set("isolation_timestep", null)
state.set("height", 1)
switch (state.get("status")) {
case 'recovered':
state.set("color", "grey")
break;
case 'infected':
state.set("color", "red")
break;
case 'healthy':
state.set("color", "green")
break;
}
state.set("isolation", false)
}
What does the simulation look like now?
As you can see we can get the same impression as Professor Romer, that even arbitrary test and isolate policies can reduce the spread of disease. A potential extension you could implement would be introducing actual tests! i.e. checking if an agent is sick before isolating them
Conclusion
I hope you found this tutorial helpful and exciting. It encapsulates one of the reasons I’m excited about democratizing simulation tech - even without access to data, we can get clarity and make reasonable conclusions about hard complex questions by simulating from “first principle models” and drawing insights from them. For many people – like me! – it’s hard to picture abstract concepts like secondary attack rates, transmission rates, etc. But with tools like this, we can make it concrete and something that anyone can play with to get, interesting and useful conclusions.
I, the author, am a simulation engineer at HASH. We’re building free, open systems and tools that enable inspectable models to be built more quickly, easily and accurately. We’ve published many more simulations and behaviors at hash.ai/index. Take a look and shoot me a message([email protected]) if you have questions or ideas for future simulations.