Term Paper 1
This work is a submission for the course Modelling Complex Systems(IDC-621) for the year 2020-21 Spring (Even) Semester by Dhruva Sambrani, MS18163
Abstract
This paper looks at the effect of a Lockdown on the spread of a pathogen. This setup is modelled via a Probabilisitic/Non Deterministic 2D cellular automaton.
This paper first introduces some working definitions along with the same definitions in code. Then features are added, and parallelly introduced to the reader, layer upon layer with logical reasoning for their implementations. Following this, the actual topic of the paper is introduced with some results that show the efficiency of Lockdowns. Finally, future scope of this work is also discussed.
Note to Evaluator
The author believes that papers which involve a lot of simulation and code should also be accompanied with the code they use so that readers can gain deeper insight into the processes described in the paper. Hence, the author has attempted to create a paper which interlaces the information with the corresponding code.
The coding language used is Julia
[julia] and following document is made with Pluto.jl
[pluto].
Code Setup
Include packages
CellularBase
[cellularbase] is a package written by the author themselves to abstract out certain repetitive funcitions for all Cellular Automata.
xxxxxxxxxx
begin
push!(LOAD_PATH, ".")
using CellularBase
end
xxxxxxxxxx
begin
using Plots
using Images
using PlutoUI
using Statistics
end
Define Structures
and Functions
Define LifeGrid
This is the basic structure that defines all the parameters of a particular "grid". The name LifeGrid
was given by mistake but was not changed as it would entail too many other changes
mutable struct LifeGrid <: AbstractGrid{Int}
state::Array{Int}
otherstate::Array{Int}
bc::BoundaryCondition
neighborhood::AbstractNeighborhood
τi::Int64
τr::Int64
max::Int64
function LifeGrid(
size::Tuple{Int,Int}, τi::Int, τr::Int;
max::Int64, init=nothing,
nh::AbstractNeighborhood=VonNeumannNeighborhood(1, 2))
z = zeros(size)
if isnothing(init) z[(size .÷ 2)...] = 1
else z[rand(CartesianIndices(z), init)] .= 1
end
new(z, copy(z), FixedMax, nh, τi, τr, max)
end
function LifeGrid(
arr::Array{Int}, τi::Int, τr::Int;
max::Int64,
nh::AbstractNeighborhood=VonNeumannNeighborhood(1, 2)
)
new(arr, copy(arr), FixedMax, nh, τi, τr, max)
end
end
Define method of evolution
The method of evolution follows the following algorithm -
If the agent is susceptible, infect the agent depending on neighborhood and return. The exact dependacy is discussed later in Non Deterministic runs
Otherwise, the state is increased by 1
If the state is recovered, set it to 0
Mathematically this can be written as
x
function (_g::LifeGrid)(neighbors::Array{Int}; kwargs...)
current = neighbors[1]
if susceptible(current, _g)
if infect(count(i -> infectious(i, _g), neighbors), _g)
return 1
else
return 0
end
else
return (current + 1) % (_g.τi + _g.τr)
end
end
Define States
States are defined in the following way
Susceptible
Infected
Refactory
Recovered
recovered (generic function with 1 method)
x
begin
infect(_count::Int64, g::LifeGrid) = if (_count == 0) false else rand() < 1 / g.max * _count end
susceptible(i::Int, g::LifeGrid) = i == 0
infectious(i::Int, g::LifeGrid) = i ∈ 1:g.τi
refactory(i::Int, g::LifeGrid) = i ∈ (g.τi + 1):(g.τr + g.τi)
recovered(i::Int, g::LifeGrid) = i == (g.τr + g.τi) + 1
end
Define helper functions
xxxxxxxxxx
begin
CellularBase.possible_states(l::LifeGrid) = 0:(l.τi + l.τr)
CellularBase.newstate(grid::LifeGrid) = grid.otherstate;
function CellularBase.state!(grid::LifeGrid, newstate)
grid.otherstate = grid.state
grid.state = newstate
end
end
function Base.show(io::IO, ::MIME"text/html", grid::LifeGrid)
final = Markdown.parse(
"---" *
"\n\n**Grid Type:** " * repr(typeof(grid), context=io) *
"\n\n**\$\\tau_i:\$** " * string(grid.τi) *
"\n\n**\$\\tau_r:\$** " * string(grid.τr) *
"\n\n**Max neighbors for probabilistic spread:** " * string(grid.max) *
"\n\n**Possible States:** " * string(possible_states(grid)) *
"\n\n**Boundary Condition:** " * string(boundary_conditions(grid)) *
"\n\n**Size:** " * string(size(grid)) *
"\n\n**Neighborhood:** "
)
show(io, MIME("text/html"), final)
show(io, MIME("text/html"), grid.neighborhood)
show(io, MIME("text/html"), md"---")
end
xxxxxxxxxx
function makegraph(ldgrid, ldresults, ldradii)
p = plot(1:length(ldresults), ldresults .|> frame -> count(frame .|> p -> susceptible(p, ldgrid)) / length(state(ldgrid)), color=:green, label="Sus")
plot!(1:length(ldresults), ldresults .|> frame -> count(frame .|> p -> infectious(p, ldgrid)) / length(state(ldgrid)), color=:red, label="Inf")
plot!(1:length(ldresults), ldresults .|> frame -> count(frame .|> p -> refactory(p, ldgrid)) / length(state(ldgrid)), color=:blue, label="Ref")
q = plot(ldradii, label="Neighbourhood Radius", legend=:bottomright)
plot(p, q, size=(600, 500), layout=(2, 1))
end;
xxxxxxxxxx
function color(i, grid)
if susceptible(i, grid)
RGB(0.84, 0.95, 0.80)
elseif infectious(i, grid)
RGB(0.83, 0.11, 0.31)
else
RGB(0.16, 0.11, 0.42)
end
end;
Deterministic Case - A First Run
Introduction
As a first trial, we can try to run the simulation for the deterministic case, where the infection can spread even if 1 neighbor is infected. We also verify that the results observed are very similar to results obtained in [Moitra2019]
Grid Type: LifeGrid
Max neighbors for probabilistic spread: 1
Possible States: 0:13
Boundary Condition: FixedMax
Size: (100, 100)
Neighborhood:
Type: VonNeumannNeighborhood
Radius: 1
List of Neighbors: [(0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)]
xxxxxxxxxx
LifeGrid((100, 100), 4, 9, max=1, nh=VonNeumannNeighborhood(1, 2))
state(center patient) = 1
xxxxxxxxxx
dgrid, dresults = let
grid = LifeGrid(
(100, 100),
4, 9,
max=1,
nh=VonNeumannNeighborhood(1, 2));
results = simulate!(grid, 150)
grid, results
end;
xxxxxxxxxx
for frame ∈ dresults[5:end]
plot(frame .|> p -> color(p, dgrid))
end
Random states from 0 to
xxxxxxxxxx
randgrid, randresults = let
arr = rand(0:4, 100, 100)
grid = LifeGrid(
arr,
4, 5,
max=1,
nh=VonNeumannNeighborhood(1, 2));
results = simulate!(grid, 50)
grid, results
end;
xxxxxxxxxx
for frame ∈ randresults
plot(frame .|> p -> color(p, randgrid))
end
state(center agent) = τᵢ; τᵢ=τᵣ
xxxxxxxxxx
ϕgrid, ϕresults = let
arr = zeros(Int, 100, 100)
arr[50,50] = 4
grid = LifeGrid(
arr,
4, 4,
max=1,
nh=VonNeumannNeighborhood(1, 2));
results = simulate!(grid, 50)
grid, results
end;
xxxxxxxxxx
for frame ∈ ϕresults
plot(frame .|> p -> color(p, ϕgrid))
end
Observations in the deterministic case
We can see that the infection spreads outward and then dies off.
The refactory agents act as a buffer between the leading edge of infected agents and the recovered(susceptible) agents inside the wave. This is similar to how a batch of burnt material doesn't allow the fire to move back in.
Spread Back
The infection can spread back into the wave only if either -
the radius of the neighborhood is larger than the refactory period
if
and
Non Deterministic Case
Why?
Mainly because determinism is so 1900s.
Seriously though, the reason why we don't see too many interesting things in the deterministic case is because of the refactory buffer separating the infected leading edge and the recovered members.
Allowing some agents to escape the infection wave allows breaks in the refactory buffer allowing the infection to come back into the recovered agents. It seems very sadistic to play for the pathogen, but what's the fun otherwise.
How is it implemented?
In code, obviously. Oh you mean what's the logic behind it?
If the spread is probabilistic, one way to do it would be to set a non zero probability if the number of infected neighbors is non-negative. While this does make the spread probabilistic, it doesn't model real life very well. In real life, one's chances of infection increases with the increase in infected neighbors. While the exact distribution is unknown, we can model the spread by assuming that the chance of spreading follows
where
This definition has the added benefit of being independent on the definition of the neighbourhood, and can be attributed only to the pathogen in question.
Every LifeGrid
has a critical limit of infected neighbors, denoted by max
. For every agent,
High values of max
Hence, it also makes sense to set max
higher than the size of the neighbourhood itself, for that would allow us to set the highest possible probability to be lesser than 1.
After all, what if the neighborhood changes during the run? How? Keep reading
Max = 4
xxxxxxxxxx
ρgrid, ρresults = let
grid = LifeGrid(
(100, 100),
4, 5,
max=4,
nh=VonNeumannNeighborhood(1, 2));
results = simulate!(grid, 400)
grid, results
end;
xxxxxxxxxx
for frame ∈ ρresults
plot(frame .|> p -> color(p, ρgrid))
end every 2
Max = 9
xxxxxxxxxx
ρ7grid, ρ7results = let
grid = LifeGrid(
(100, 100),
4, 5,
max=9,
init=5,
nh=VonNeumannNeighborhood(1, 2));
results = simulate!(grid, 100)
grid, results
end;
xxxxxxxxxx
for frame ∈ ρ7results
plot(frame .|> p -> color(p, ρ7grid))
end
Max = 2
xxxxxxxxxx
ρ2grid, ρ2results = let
grid = LifeGrid(
(100, 100),
4, 5,
max=2,
nh=VonNeumannNeighborhood(1, 2));
results = simulate!(grid, 150)
grid, results
end;
xxxxxxxxxx
for frame ∈ ρ2results
plot(frame .|> p -> color(p, ρ2grid))
end every 2
Observations
Evidently, higher max values imply smaller probability of spreading. This is evident in the run with max = 9
, where very few infected cases are seen at any point in time, and agents often recover before they spread it on, leading to the pathogen dying out.
On the flip side, if max
is too close to 1, then there it acts very similar to the deterministic case most of the time. This is also evident in the fact that the case where max
is 1, it is exactly the deterministic case which we've seen also dies out.
Relevant Quote
It is possible to have too much of a good thing
- Aesop
Lockdown
Introduction
The word lockdown seems to have taken over our lives and completely changed the way that we go about our daily lives. But why are we doing this? And how effective are these lockdowns? We explore this question by looking at how we can simulate a lockdown period for our agents and see how this affects the infection spread.
The logic behind lockdowns
Definition
A lockdown is a restriction policy for people or community to stay where they are, usually due to specific risks to themselves or to others if they can move and interact freely - Wikipedia
From the definition, it is clear that the intent is to prevent people from interacting and hence hopefully reducing the spread of the disease. If an agent interacts with fewer people, they have a lower chance of getting infected.
Neighborhoods, Virulence and Infection Rate
Neighbourhood size and intrinsic virulence of a pathogen together decide the pathogen's spreading capabilities.
Neighbourhood size is a representation of the amount of inter-agent interactions. Due to the way the model is defined, it also shows how far the agent can influence. It defines the number of agents one agent can meet.
Intrinsic virulence of a pathogen is how well it spreads given that two agents meet. There is sufficient discussion regarding this in the above section.
If both these parameters define, in some sense, the same thing, why not just club them together into a single parameter which gives the net spread?
The reason is 3 fold-
The neighborhood is a function of the agents' behavior, the virulence is a function of the pathogen's behaviour. It makes sense to keep them separate to deal with different cases of spread changes.
The model is such that there are geometric differences that arise by changing the neighborhood radius. The buffering action of the refactory line is reduced by a larger neighborhood. It is not possible to increase the infection rate without also reducing this phenomenon if both were clubbed together.
The code would need too many changes.
Relevant quote
Any sufficiently advanced bug is indistinguishable from a feature.
- Rich Kulawiec
Lockdowns and Neighborhoods
From the above discussion, it must be clear that a lockdown can be simulated pretty easily by varying the size of the neighborhood. The lockdown must also start at some predetermined point during the simulation. The size of the neighbourhood is changed by changing grid.neighborhood
and the in-simulation change of the neighborhood can be executed by using the postrunhook
of the simulate!
function
lockdown (generic function with 1 method)
xxxxxxxxxx
function lockdown(grid, step; step_dict, radii)
push!(radii, radius(grid.neighborhood))
if step in keys(step_dict)
grid.neighborhood = VonNeumannNeighborhood(step_dict[step], 2);
end
end
200 → 1
xxxxxxxxxx
ld1 = let
step_dict = Dict(200 => 1) # Step -> radius
radii = []
grid = LifeGrid(
(150, 150),
4, 5,
max=9,
init=5,
nh=VonNeumannNeighborhood(3, 2));
results = simulate!(grid, 400;
postrunhook=lockdown, step_dict=step_dict, radii=radii);
grid, results, radii
end;
xxxxxxxxxx
for frame ∈ ld1[2]
plot(frame .|> p -> color(p, ld1[1]))
end every 2
xxxxxxxxxx
makegraph(ld1...)
50 → 2; 150 → 1; 300 → 3
xxxxxxxxxx
ld2 = let
step_dict = Dict(50 => 2, 150 => 1, 300 => 3) # Step -> radius
radii = []
grid = LifeGrid(
(150, 150),
4, 5,
max=9,
init=5,
nh=VonNeumannNeighborhood(3, 2));
results = simulate!(grid, 400;
postrunhook=lockdown, step_dict=step_dict, radii=radii);
grid, results, radii
end;
xxxxxxxxxx
for frame ∈ ld2[2]
plot(frame .|> p -> color(p, ld2[1]))
end every 2
xxxxxxxxxx
makegraph(ld2...)
100 → 2; 300 → 3
xxxxxxxxxx
ld3 = let
step_dict = Dict(100 => 2, 300 => 3) # Step -> radius
radii = []
grid = LifeGrid(
(150, 150),
4, 5,
max=9,
init=5,
nh=VonNeumannNeighborhood(3, 2));
results = simulate!(grid, 400;
postrunhook=lockdown, step_dict=step_dict, radii=radii);
grid, results, radii
end;
xxxxxxxxxx
for frame ∈ ld3[2]
plot(frame .|> p -> color(p, ld3[1]))
end every 2
xxxxxxxxxx
makegraph(ld3...)
Observations
Apart from being very flashy, we see an interesting phenomenon. When the radius of the neighbourhood is changed, it takes about 25-30 steps for the grid to find a new equilibrium. During this transient phase, the distribution of states can vary wildly.
Further, the equilibrium fractions of the states averaged over a few cycles is nearly equal, but the exact variation may vary on the basis of initial conditions (See 100 → 2; 300 → 3). This can be attributed to the geometry of the neighborhood, the refactory buffer and the distribution of infected agents.
Because most large scale structures don't last for long times, we can assume that the final state distribution is memoryless or has only short-term memory. Thus our study of the effectiveness of a lockdown can be reduced to a simpler study of equilibrium infection as a function of neighborhood radius and grid.max
, which, as you probably guessed it, is our next section.
Equilibrium infection distribution
As a function of Neighbourhood Radius and Virulence of pathogen
Introduction
This section is mainly computational. With the functionalities we have built up in the previous sections, we simply simulate a random 200X200 grid for 200 steps for different values of neighbourhood radius and virulence, find the average(over 5 cycle-lenghts) infected fraction then plot a scatter plot to see the distribution.
ss (generic function with 1 method)
xxxxxxxxxx
function ss(grid, step; kwargs...)
if count(i -> infectious(i, grid), state(grid)) == 0
return :shortcurcuit
end
end
equilibrium_infection (generic function with 1 method)
x
function equilibrium_infection(nhradius, p_max)
arr = rand(1:9, 100, 100)
grid = LifeGrid(arr, 4, 5, max=p_max, nh=VonNeumannNeighborhood(nhradius, 2))
results = simulate!(grid, 200, postrunhook=ss)
mean(count.(p -> infectious(p, grid), results[end - 45:end])) / 10000
end
Verification
We verify that the function is working correctly by checking for the case nhradius = 3, p_max = 3
against the run 100 → 2; 300 → 3
Success!
Long running Code!
The following cell takes a long time (≈ 400 secs) to run. Be careful! You may want to reduce nhs
and maxs
, or skip some elements.
Results
xxxxxxxxxx
begin
nhs = 1:4
maxs = 1:2:25
points = [equilibrium_infection(nhradius, p_max) for nhradius in nhs, p_max in maxs]
end;
xxxxxxxxxx
let
plotlyjs()
p = plot(maxs, nhs, points, st=:surface, ylabel="NH Radius", xlabel="Virulence(max)", zlabel="Frac infected", title="Surface Plot")
gr()
p
end
xxxxxxxxxx
plot(maxs, nhs, points, ylabel="NH Radius", xlabel="Virulence(max)", title="Contour Plot", width=3)
xxxxxxxxxx
begin
plotlyjs()
p = plot(maxs, nhs, points, ylabel="NH Radius", xlabel="Virulence(max)", title="HeatMap", st=:heatmap, hover = string.(reshape(points, :)))
gr()
p
end
Observations
As expected, runs with low virulence and low radius (bottom-right) have very low equilibrium infection levels.
For very virulent pathogens, even very strong lockdowns do not have much of an effect.
Even for mid-level virulence pathogens, weak lockdowns are only able to reduce the infection probability, but are unable to completely kill off the pathogen.
As we have seen before, even very low initial viral load can quickly explode in high interaction neighborhoods, so when the lockdown is removed, the infection quickly increases to pre-lockdown levels
Discussion on the effectiveness of lockdowns
Given that lockdowns don't help in killing off the pathogen, was the whole last year a waste? Was it all just a government hoax to install 5G towers?
While the author can't say much about government plans to install 5G towers, it is clear that lockdowns have a dampening effect in the number of active cases. This decrease in cases allow the society to focus on combating the disease by spending more efforts into research for vaccines, treatments and preventive steps, along with building infrastructure and materials which are necessary to combat the recurrence of the pathogen. It also gives epidemologists and people working in Complex Systems time to make pretty plots and animations.
Further scope of this work
While this work has largely focused on the case of pathogen spread, the same work can be extended within the same topic and to other related topics.
Things within the same topic
Fitting a mathematical model of the effect of lockdown and pathogen spread to data obtained by this work, then fitting it for other real life pathogens
Verification of model reflecting real pathogens via case studies
More extended statistical analysis to quantify equilibrium
Search for multi-equilibrium states
Contact tracing and quarantine simulations and comparing effectiveness of blanket lockdowns with more surgical methods.
Other topics which can gain from this work
While we have called agents as people and the spread as a pathogen, any system which relaxes after some time can be modelled with a similar setup. Accordingly the meaning of lockdown can change. Some examples include -
Spread of radioactivity with lockdown as shielding.
Complex web of processors which heat up after a while. There, the lockdown would be the tradeoff between a less complex web but a less efficient system of workload balancing.
Spread of algae and lockdown being the speed of flow of water.
References
Moitra2019
Moitra, P., Sinha, S. Localized spatial distributions of disease phases yield long-term persistence of infection. Sci Rep 9, 20309 (2019). https://doi.org/10.1038/s41598-019-56616-3
julia
Julia: A Fresh Approach to Numerical Computing. Jeff Bezanson, Alan Edelman, Stefan Karpinski, Viral B. Shah. (2017) SIAM Review, 59: 65–98. doi: 10.1137/141000671
plots
Plots - powerful convenience for visualization in Julia. Thomas Breloff. docs.juliaplots.org
images
JuliaImages: image processing and machine vision for Julia. https://juliaimages.org/stable/
pluto
Pluto - Reactive Notebooks for Julia. Fons van der Plas and Mikołaj Bochenski. https://github.com/fonsp/Pluto.jl
plutoui
PlutoUI. Fons van der Plas. https://github.com/fonsp/PlutoUI.jl
stats
Statistics - Julia stdlib for Statistics. https://docs.julialang.org/en/v1/stdlib/Statistics/
cellularbase
CellularBase.jl: Abstract definitions for Cellular Automaton. Dhruva Sambrani. https://dhruvasambrani.github.io/idc621/CellularBase