The field of applied category theory (ACT) aims to put the compositionality inherent to scientific and engineering processes on a firm mathematical footing. In this post, we show how the mathematics of ACT can be operationalized to build complex epidemiological models in a compositional way. In the first two sections, we review the idea of structured cospans, a formalism for turning closed systems into open ones, and we illustrate its use in Catlab through the simple example of open graphs. Finally, we put this machinery to work in the setting of Petri nets and epidemiological models. We construct a portion of the COEXIST model for the COVID-19 pandemic and we simulate the resulting ODEs.

## Petri nets and epidemiological models

Structured cospans of -sets are general machinery that can be used to build compositional modeling tools. Specifically, they provide a simple interface for building domain-specific modeling frameworks that support hierarchically defined models with multiple levels of abtraction. To illustrate this paradigm, we build a Petri net modeling framework and use it to construct complex epidemiology models.

A **Petri net** is a compartmental model defined by a bipartite graph with two types of nodes. There are **state** nodes, drawn as circles, that contain some concentration of objects, and **transition** nodes, drawn as boxes, that decrease the concentration of incoming state nodes and increase the concentration of outgoing state nodes.

To fit in with -set formalism, we work with **whole-grain Petri nets**, a variant of Petri nets introduced by Joachim Kock (Kock 2020). Since we need data attributes like initial concentrations and transition rates, we will actually use *attributed* -sets, as described in a previous blog post. The schema is shown in the following diagram, where and represent the sets of states and transitions, is the set of input edges from some state to some transition , and is the set of output edges from some transition to some state . The attributes on to and represent labels and initial concentrations, respectively, and similarly the attributes on to and represent labels and transition rates.

With this setup, we can extend the (whole-grain) Petri nets to **open Petri nets** (Baez and Master 2020), where we will glue along states of the Petri nets:

```
#| eval: false
const OpenLabelledReactionNetOb, OpenLabelledReactionNet =
OpenACSetTypes(LabelledReactionNet, :S)
```

These definitions are contained in the package AlgebraicPetri.jl, which we will use in the remainder of the post.

We are going to build epidemiology models where the states represent stages of a disease, the tokens in the states represent people, and the transitions represent people moving between stages of the disease. First, we import AlgebraicPetri and define a few type constants that enforce the constraint that rates are real numbers. The state populations must be whole numbers since it does not make sense to have fractions of people.

```
#| output: false
using AlgebraicPetri
const EpiRxnNet = LabelledReactionNet{Number,Int}
const OpenEpiRxnNet = OpenLabelledReactionNet{Number,Int}
const OpenEpiRxnNetOb = OpenLabelledReactionNetOb{Number,Int}
```

AlgebraicPetri provides a straightforward API for defining Petri nets. Here, the first argument is a tuple states and their initial populations, and each following argument specifies a transition in the network with a given label and rate. In this way, we define the classic SIR model of infectious diseases:

```
#| output: false
sir=EpiRxnNet((:S=>100, :I=>1, :R=>0),
(:inf,.03)=>((:S,:I)=>(:I,:I)), (:rec,.25)=>(:I=>:R))
```

```
#| include: false
using Petri
```

```
#| echo: false
Petri.Graph(Petri.Model(sir))
```

To provide a more abstract, hierarchical approach to model building, we can think of epidemiology models as being made up of two basic operations. First, there can be “spontaneous” changes, such as when an infected person recovers from a disease. Second, there are “exposure” transitions where one state causes another state to change, such as when an infected person exposes a susceptible person. We define helper functions to create Petri nets that express these two patterns of interaction.

```
#| output: false
using Catlab
ob(x::Symbol,xn::Int) = codom(Open([x], EpiRxnNet(x=>xn), [x]))
function spontaneous_petri(transition::Symbol, rate::Number,
s::Symbol, s₀::Int,
t::Symbol, t₀::Int)
Open([s], EpiRxnNet((s=>s₀,t=>t₀), (transition,rate)=>(s=>t)), [t])
end
function exposure_petri(transition::Symbol, rate::Number,
s::Symbol, s₀::Int,
e::Symbol, e₀::Int,
t::Symbol, t₀::Int)
Open([s, e], EpiRxnNet((s=>s₀,e=>e₀,t=>t₀), (transition,rate)=>((s,e)=>(t,e))), [t])
end
```

An example of spontaneous change is recovery, where an infected person recovers from an illness and moves to the recovered state.

```
#| output: false
recover = spontaneous_petri(:rec, .25, :I, 1, :R, 0)
```

```
#| echo: false
Petri.Graph(Petri.Model(apex(recover)))
```

An example of the exposure phenomena is when an infected person exposes a susceptible person.

```
#| output: false
expose = exposure_petri(:exp, .1, :S, 100, :I, 1, :E, 1)
```

```
#| echo: false
Petri.Graph(Petri.Model(apex(expose)))
```

We will focus on building part of the COEXIST model that the UK has been using to make policy decisions about COVID-19. To begin, we need to make a simple presentation with the appropriate objects and morphisms. Each of the morphims in the presentation specifies either a spontaneous transition or an exposure transition as described above. The presentation defines a “model space” in which we can build models that contain only the provided interactions.

```
#| output: false
using Catlab
using Catlab.Theories
using AlgebraicPetri
@present Epidemiology(FreeBiproductCategory) begin
(S, E, A, I, I2, R, R2, D)::Ob
exposure_e::Hom(S⊗E,E)
exposure_a::Hom(S⊗A,E)
exposure_i::Hom(S⊗I,E)
exposure_i2::Hom(S⊗I2,E)
illness::Hom(E,I)
illness_progression::Hom(I,I2)
asymptomatic_illness::Hom(E,A)
asymptomatic_recovery::Hom(A,R)
illness_recovery::Hom(I2,R)
recovery_progression::Hom(R,R2)
death::Hom(I2,D)
end
```

```
#| include: false
S,E,A,I,I2,R,R2,D,exposure_e,exposure_a,exposure_i,exposure_i2,illness,illness_progression,asymptomatic_illness,asymptomatic_recovery,illness_recovery,recovery_progression,death = generators(Epidemiology);
pop = [8044056, 7642473, 8558707, 9295024,8604251,9173465,7286777,5830635,3450616] .- (4*1000)
N = sum(pop) + length(pop)*4*1000
social_mixing_rate =
[[5.10316562022642,1.28725377551533,1.30332531065247,2.31497083312315,1.1221598200343,0.606327539457772,0.453266757158743,0.177712174722219,0.0111726265254263],
[1.15949254996891,8.00118824220649,1.24977685411394,1.51298690806342,1.88877951844257,0.835804485358679,0.431371281973645,0.343104864504218,0.0324429672946592],
[1.19314902456243,1.2701954426234,3.55182053724384,1.81286158254244,1.80561825747571,1.29108026766182,0.708613434860661,0.248559044477893,0.0215323291988856],
[1.83125260045684,1.32872195974583,1.56648238384012,2.75491288061819,1.94613663227464,1.2348814962672,0.863177586322153,0.244623623638873,0.0394364256673532],
[0.910395333788561,1.7011898591446,1.60014517035071,1.99593275526656,2.90894801031624,1.37683234043657,0.859519958701156,0.488960115017174,0.110509077357166],
[0.56560186656657,0.865574490657954,1.31557291022074,1.45621698394508,1.58310342861768,1.92835669973181,0.963568493650797,0.463041280007004,0.183483677017087],
[0.544954016221808,0.575775829452094,0.930622416907882,1.31190809759635,1.27375718214796,1.24189546255302,1.32825334016313,0.66235513907445,0.0946971569608397],
[0.319717318035767,0.68528632728864,0.488468642570909,0.556345582530282,1.08429412751444,0.893028152305907,0.991137484161889,1.17651345255182,0.12964732712923],
[0.201086389216809,0.648252461859761,0.423327560644352,0.897268061280577,2.4516024037254,3.54014694719397,1.41761515077768,1.29700599099082,1.0189817510854]]
fatality_rate = [0.00856164, 0.03768844, 0.02321319, 0.04282494, 0.07512237, 0.12550367, 0.167096 , 0.37953452, 0.45757006]
F(ex, n) = functor((OpenEpiRxnNetOb, OpenEpiRxnNet), ex, generators=Dict(
S=>ob(Symbol(:S, n), pop[n]),
E=>ob(Symbol(:E, n), 1000),
A=>ob(Symbol(:A, n), 1000),
I=>ob(Symbol(:I, n), 1000),
I2=>ob(Symbol(:I2, n), 1000),
R=>ob(Symbol(:R, n), 0),
R2=>ob(Symbol(:R2, n), 0),
D=>ob(Symbol(:D, n), 0),
exposure_e=>exposure_petri(Symbol(:exp_e, n), .01*social_mixing_rate[n][n]/pop[n], Symbol(:S,n), pop[n], Symbol(:E,n),1000, Symbol(:E,n),1000),
exposure_a=>exposure_petri(Symbol(:exp_a, n), 5*social_mixing_rate[n][n]/pop[n], Symbol(:S,n), pop[n], Symbol(:A,n),1000, Symbol(:E,n),1000),
exposure_i=>exposure_petri(Symbol(:exp_, n), 1*social_mixing_rate[n][n]/pop[n], Symbol(:S,n), pop[n], Symbol(:I,n), 1000, Symbol(:E,n), 1000),
exposure_i2=>exposure_petri(Symbol(:exp_i2, n), 6*social_mixing_rate[n][n]/pop[n], Symbol(:S,n), pop[n], Symbol(:I2,n), 1000, Symbol(:E,n),1000),
illness=>spontaneous_petri(Symbol(:ill_, n), .2, Symbol(:E,n), 1000, Symbol(:I,n), 1000),
illness_progression=>spontaneous_petri(Symbol(:prog_, n), .25, Symbol(:I,n), 1000, Symbol(:I2,n), 1000),
asymptomatic_illness=>spontaneous_petri(Symbol(:asymp_, n), .86/.14*.2, Symbol(:E,n), 1000, Symbol(:A,n), 1000),
asymptomatic_recovery=>spontaneous_petri(Symbol(:arec_, n), 1/15, Symbol(:A,n), 1000, Symbol(:R,n), 0),
illness_recovery=>spontaneous_petri(Symbol(:rec_, n), 1/6, Symbol(:I2,n), 1000, Symbol(:R,n), 0),
recovery_progression=>spontaneous_petri(Symbol(:rec2_, n), 1/15, Symbol(:R,n), 0, Symbol(:R2,n), 0),
death=>spontaneous_petri(Symbol(:death2_, n), (1/15)*(fatality_rate[n]/(1-fatality_rate[n])), Symbol(:I2,n), 1000, Symbol(:D,n), 0)))
```

Having defined this presentation, we can use Catlab’s `@program`

macro to easily build up a complex model using a Julia-esque syntax. Here, the input variables are objects in our presentation and the “function calls” represent applying morphisms to those objects.

```
#| echo: false
using Catlab.WiringDiagrams
```

```
#| output: false
using Catlab.Programs
coexist = @program Epidemiology (s::S, e::E, i::I, i2::I2, a::A, r::R, r2::R2, d::D) begin
e_2 = exposure_e(s, e)
e_3 = exposure_a(s, a)
e_4 = exposure_i(s, i)
e_5 = exposure_i2(s, i2)
e_all = [e, e_2, e_3, e_4, e_5]
a_2 = asymptomatic_illness(e_all)
a_all = [a, a_2]
r_2 = asymptomatic_recovery(a_all)
i_2 = illness(e_all)
i_all = [i, i_2]
i2_2 = illness_progression(i)
i2_all = [i2, i2_2]
d_2 = death(i2_all)
r_3 = illness_recovery(i2_all)
r_all = [r, r_2, r_3]
r2_2 = recovery_progression(r_all)
r2_all = [r2, r2_2]
d_all = [d, d_2]
return s, e_all, i_all, i2_all, a_all, r_all, r2_all, d_all
end
```

By defining the morphisms as open -sets, we can use structured cospans to functorially construct the Petri net that corresponds to the model defined in the `@program`

macro.

```
#| echo: false
coexist = to_hom_expr(FreeBiproductCategory, coexist)
coexist_petri = Petri.Model(apex(F(coexist, 1)))
Petri.Graph(coexist_petri)
```

So far the presentation assumes that all the people involved in the epidemic are well-mixed. It cannot handle geographically or demographically heterogeneous populations. What if we want to extend the model to two different populations that interact through cross exposure? For example, suppose we have two age groups with different rates of infection and recovery, where the two generations interact through cross exposure.

We can address this problem by defining a new presentation that represents the cross exposure of two separate sets of populations. Then, using the `@program`

macro, we define a new model for cross exposure.

```
@present EpiCrossExposure(FreeBiproductCategory) begin
(S, E, A, I, I2, R, R2, D)::Ob
(S′, E′, A′, I′, I2′, R′, R2′, D′)::Ob
exposure_i::Hom(S⊗I′,E)
exposure_e::Hom(S⊗E′,E)
exposure_a::Hom(S⊗A′,E)
exposure_i2::Hom(S⊗I2′,E)
exposure_i′::Hom(S′⊗I,E′)
exposure_e′::Hom(S′⊗E,E′)
exposure_a′::Hom(S′⊗A,E′)
exposure_i2′::Hom(S′⊗I2,E′)
end;
```

```
#| echo: false
ce_S,ce_E,ce_A,ce_I,ce_I2,ce_R,ce_R2,ce_D, ce_S′,ce_E′,ce_A′,ce_I′,ce_I2′,ce_R′,ce_R2′,ce_D′, ce_exposure_i, ce_exposure_e, ce_exposure_a, ce_exposure_i2, ce_exposure_i′, ce_exposure_e′, ce_exposure_a′, ce_exposure_i2′ = generators(EpiCrossExposure)
F_cx(ex, x,y) = functor((OpenEpiRxnNetOb, OpenEpiRxnNet), ex, generators=Dict(
ce_S=>ob(Symbol(:S, x), pop[x]),
ce_E=>ob(Symbol(:E, x), 1000),
ce_A=>ob(Symbol(:A, x), 1000),
ce_I=>ob(Symbol(:I, x), 1000),
ce_I2=>ob(Symbol(:I2, x), 1000),
ce_R=>ob(Symbol(:R, x), 0),
ce_R2=>ob(Symbol(:R2, x), 0),
ce_D=>ob(Symbol(:D, x), 0),
ce_S′=>ob(Symbol(:S, y), pop[y]),
ce_E′=>ob(Symbol(:E, y), 1000),
ce_A′=>ob(Symbol(:A, y), 1000),
ce_I′=>ob(Symbol(:I, y), 1000),
ce_I2′=>ob(Symbol(:I2, y), 1000),
ce_R′=>ob(Symbol(:R, y), 0),
ce_R2′=>ob(Symbol(:R2, y), 0),
ce_D′=>ob(Symbol(:D, y), 0),
ce_exposure_i=>exposure_petri(Symbol(:exp_, x,y), 1*social_mixing_rate[x][y]/(pop[x]+pop[y]), Symbol(:S,x), pop[x], Symbol(:I,y), 1000, Symbol(:E,x), 1000),
ce_exposure_e=>exposure_petri(Symbol(:exp_e, x,y), .01*social_mixing_rate[x][y]/(pop[x]+pop[y]), Symbol(:S,x), pop[x], Symbol(:E,y),1000, Symbol(:E,x),1000),
ce_exposure_a=>exposure_petri(Symbol(:exp_a, x,y), 5*social_mixing_rate[x][y]/(pop[x]+pop[y]), Symbol(:S,x), pop[x], Symbol(:A,y),1000, Symbol(:E,x),1000),
ce_exposure_i2=>exposure_petri(Symbol(:exp_i2, x,y), 6*social_mixing_rate[x][y]/(pop[x]+pop[y]), Symbol(:S,x), pop[x], Symbol(:I2,y), 1000, Symbol(:E,x),1000),
ce_exposure_i′=>exposure_petri(Symbol(:exp_, y,x), 1*social_mixing_rate[y][x]/(pop[x]+pop[y]), Symbol(:S,y), pop[y], Symbol(:I,x), 1000, Symbol(:E,y), 1000),
ce_exposure_e′=>exposure_petri(Symbol(:exp_e, y,x), .01*social_mixing_rate[y][x]/(pop[x]+pop[y]), Symbol(:S,y), pop[y], Symbol(:E,x),1000, Symbol(:E,y),1000),
ce_exposure_a′=>exposure_petri(Symbol(:exp_a, y,x), 5*social_mixing_rate[y][x]/(pop[x]+pop[y]), Symbol(:S,y), pop[y], Symbol(:A,x),1000, Symbol(:E,y),1000),
ce_exposure_i2′=>exposure_petri(Symbol(:exp_i2, y,x), 6*social_mixing_rate[y][x]/(pop[x]+pop[y]), Symbol(:S,y), pop[y], Symbol(:I2,x), 1000, Symbol(:E,y),1000)
));
```

```
#| output: false
crossexposure = @program EpiCrossExposure (s::S, e::E, i::I, i2::I2, a::A, r::R, r2::R2, d::D,
s′::S′, e′::E′, i′::I′, i2′::I2′, a′::A′, r′::R′, r2′::R2′, d′::D′) begin
e_2 = exposure_i(s, i′)
e_3 = exposure_i2(s, i2′)
e_4 = exposure_a(s, a′)
e_5 = exposure_e(s, e′)
e_all = [e, e_2, e_3, e_4, e_5]
e′_2 = exposure_i′(s′, i)
e′_3 = exposure_i2′(s′, i2)
e′_4 = exposure_a′(s′, a)
e′_5 = exposure_e′(s′, e_all)
e′_all = [e′, e′_2, e′_3, e′_4, e′_5]
return s, e_all, i, i2, a, r, r2, d,
s′, e′_all, i′, i2′, a′, r′, r2′, d′
end
```

```
#| echo: false
crossexposure = to_hom_expr(FreeBiproductCategory, crossexposure)
crossexposure_petri = Petri.Model(apex(F_cx(crossexposure, 1, 2)))
Petri.Graph(crossexposure_petri)
```

Having defined COEXIST and cross exposure models, we can use both as new Petri net building blocks and construct a larger model of two populations interacting through cross exposure, where each goes to their own COEXIST model with unique rates and populations. This method allows us to quickly create complex models, while the hierarchical construction also makes them easier to understand.

```
#| output: false
@present DualCoexist(FreeBiproductCategory) begin
(Pop1,Pop2)::Ob
crossexp::Hom(Pop1⊗Pop2,Pop1⊗Pop2)
coex1::Hom(Pop1,Pop1)
coex2::Hom(Pop2,Pop2)
end
```

```
#| include: false
Pop1, Pop2, crossexp, coex1, coex2 = generators(DualCoexist)
F_tcx(ex) = functor((OpenEpiRxnNetOb, OpenEpiRxnNet), ex, generators=Dict(
Pop1=>F(otimes(S,E,I,I2,A,R,R2,D),3),
Pop2=>F(otimes(S,E,I,I2,A,R,R2,D),4),
crossexp=>F_cx(crossexposure,3,4),
coex1=>F(coexist,3),
coex2=>F(coexist,4)
))
```

```
#| output: false
dualCoexist = @program DualCoexist (pop1::Pop1, pop2::Pop2) begin
pop1′, pop2′ = crossexp(pop1, pop2)
return coex1(pop1′), coex2(pop2′)
end
```

```
#| echo: false
dualCoexist = to_hom_expr(FreeBiproductCategory, dualCoexist)
to_graphviz(dualCoexist, orientation=LeftToRight, labels=true)
```

In this wiring diagram, the boxes no longer represent our basic spontaneous or exposure Petri nets, but the full Petri nets shown above for cross exposure and the COEXIST epidemiology model. Thanks to structured cospans, we can convert this wiring diagram into its corresponding Petri net. We can see that even with just two populations, the number of states and transitions grows rapidly. Without the ability to define our models hierarchically and with user-defined abstractions, a Petri net of this complexity would be difficult to reliably encode by hand.

```
#| echo: false
dualCoexist_rxn = apex(F_tcx(dualCoexist))
dualCoexist_petri = Petri.Model(dualCoexist_rxn)
Petri.Graph(dualCoexist_petri)
```

Since we kept track of all the initial populations and rates during the construction of the model, we can now directly generate the appropriate ordinary differential equations as input to DifferentialEquations.jl, run the simulation, and plot the results.

```
#| echo: false
using OrdinaryDiffEq
using Plots, Plots.PlotMeasures
tspan = (0.0,100.0)
prob = ODEProblem(dualCoexist_petri,concentrations(dualCoexist_rxn),tspan,rates(dualCoexist_rxn))
sol = solve(prob,Tsit5())
plot(sol, lw=2, title = "2 Generation COEXIST Model with No Intervention",
bottom_margin=10mm, left_margin=10mm, legend=:right)
xlabel!("Time")
ylabel!("Number of people")
```

The plot forecasts the expected rate of COVID-19 infection over time. Looking at the plot, we can see that in the absence of any interventions, there is an extremely high rate of infection. Our construction of the model makes it easy to adjust the rates and parameters in response to changes in intervention policy. For instance, we can decrease the rates of exposure to simulate putting in place a stay-at-home order and promoting social distancing. We can then generate a new simulation, compute a solution, and compare the results.

```
#| echo: false
for i in 1:length(social_mixing_rate)
for j in 1:length(social_mixing_rate[1])
if i != j
social_mixing_rate[i][j] = social_mixing_rate[i][j] / 10
else
social_mixing_rate[i][j] = social_mixing_rate[i][j] / 5
end
end
end
dualCoexist_rxn = apex(F_tcx(dualCoexist))
dualCoexist_petri = Petri.Model(dualCoexist_rxn)
tspan = (0.0,100.0)
prob = ODEProblem(dualCoexist_petri,concentrations(dualCoexist_rxn),tspan,rates(dualCoexist_rxn))
sol = solve(prob,Tsit5())
plot(sol, lw=2, title = "2 Generation COEXIST Model with Social Distancing",
bottom_margin=10mm, left_margin=10mm, legend=:right)
xlabel!("Time")
ylabel!("Number of people")
```

In this new system, we see that the simulated interventions decrease the rates of both infection and transmission and also shift the time of peak infection.

This style of rapid modeling can enable policy makers to quickly build new models during an emerging pandemic, simulate several different intervention policy scenarios, and make policy decisions informed by the results of these simulations.

The complete code reproducing these simulations is available in the AlgebraicPetri.jl documentation.