In its simplest form, a SIR model is typically written in continuoustime as:
\[\frac{dS}{dt} = - \beta \frac{S_t I_t}{N_t}\]
\[\frac{dI}{dt} = \beta \frac{S_t I_t}{N_t} - \gamma I_t\]
\[\frac{dR}{dt} = \gamma I_t\]
Where\(\beta\) is an infection rateand\(\gamma\) a removal rate, assuming‘R’ stands for ‘recovered’, which can mean recovery or death.
For discrete time equivalent, we take a small time step\(t\) (typically a day), and write thechanges of individuals in each compartment as:
\[S_{t+1} = S_t - \beta \frac{S_t I_t}{N_t}\]
\[I_{t+1} = I_t + \beta \frac{S_t I_t}{N_t} - \gamma I_t\]
\[R_{t+1} = R_t + \gamma I_t\]
The discrete model above remains deterministic: for given values ofthe rates\(\beta\) and\(\gamma\), dynamics will be fixed. It isfairly straightforward to convert this discrete model into a stochasticone: one merely needs to uses appropriate probability distributions tomodel the transfer of individuals across compartments. There are atleast 3 types of such distributions which will be useful toconsider.
This distribution will be used to determine numbers of individualsleaving a given compartment. While we may be tempted touse a Poisson distribution with the rates specified in the equationsabove, this could lead to over-shooting, i.e. more individuals leaving acompartment than there actually are. To avoid infecting more people thanthere are susceptibles, we use a binomial distribution, with one drawfor each individual in the compartment of interest. The workflow willbe:
determine aper-capita probability of leaving thecompartment, based on the original rates specified in the equations; ifthe rate at which each individual leaves a compartment is\(\lambda\), then the correspondingprobability of this individual leaving the compartment in one time unitis\(p = 1 - e^{- \lambda}\).
determine the number of individuals leaving the compartment usingaBinomial distribution, with one draw per individual and aprobability\(p\)
As an example, let us consider transition\(S \rightarrow I\) in the SIR model. Theoverall rate at which this change happens is\(\beta\frac{S_t I_t}{N_t}\). The correspondingper susceptiblerate is\(\beta \frac{I_t}{N_t}\).Therefore, the probability for an individual to move fromS toI at time\(t\) is\(p_{(S\rightarrow I), t} = 1 - e^{- \beta \frac{I_t}{N_t}}\).
Poisson distributions will be useful when individuals enter acompartment at a given rate, from ‘the outside’. This could be birth ormigration (for\(S\)), or introductionof infections from an external reservoir (for\(I\)), etc. The essential distinction withthe previous process is that individuals arenot leaving anexisting compartment.
This case is simple to handle: one just needs to draw new individualsentering the compartment from a Poisson distribution with the ratedirectly coming from the equations.
For instance, let us now consider a variant of the SIR model wherenew infectious cases are imported at a constant rate\(\epsilon\). The only change to the equationis for the infected compartment:
\[I_{t+1} = I_t + \beta \frac{S_t I_t}{N_t} + \epsilon - \gamma I_t\]
where:
individuals move from\(S\) to\(I\) according to a Binomialdistribution\(\mathcal{B}(S_t, 1 - e^{- \beta\frac{I_t}{N_t}})\)
new infected individuals are imported according to a Poissondistribution\(\mathcal{P}(\epsilon)\)
individual move from\(I\) to\(R\) according to a Binomialdistribution\(\mathcal{B}(I_t, 1 - e^{-\gamma})\)
This distribution will be useful when individuals leaving acompartment are distributed over several compartments. The Multinomialdistribution will be used to determine how many individuals end up ineach compartment. Let us assume that individuals move from a compartment\(X\) to compartments\(A\),\(B\), and\(C\), at rates\(\lambda_A\),\(\lambda_B\) and\(\lambda_C\). The workflow to handle thesetransitions will be:
determine the total number of individuals leaving\(X\); this is done by summing the rates(\(\lambda = \lambda_A + \lambda_B +\lambda_C\)) to compute theper capita probability ofleaving\(X\)\((p_(X \rightarrow ...) = 1 - e^{-\lambda})\), and drawing the number of individuals leaving\(X\) (\(n_{_(X\rightarrow ...)}\)) from a binomial distribution\(n_{(X \rightarrow ...)} \sim B(X, p_(X\rightarrow ...))\)
compute relative probabilities of moving to the differentcompartments (using\(i\) as aplaceholder for\(A\),\(B\),\(C\)):\(p_i =\frac{\lambda_i}{\sum_i \lambda_i}\)
determine the numbers of individuals moving to\(A\),\(B\)and\(C\) using a Multinomialdistribution:\(n_{(X \rightarrow A, B, C)}\sim\mathcal{M}(n_{(X \rightarrow ...)}, p_A, p_B, p_C)\)
odinWe start by loading theodin code for a discrete,stochastic SIR model:
## Core equations for transitions between compartments:update(S)<- S- beta* S* I/ Nupdate(I)<- I+ beta* S* I/ N- gamma* Iupdate(R)<- R+ gamma* I## Total population size (odin will recompute this at each timestep:## automatically)N<- S+ I+ R## Initial states:initial(S)<- S_ini# will be user-definedinitial(I)<- I_ini# will be user-definedinitial(R)<-0## User defined parameters - default in parentheses:S_ini<-user(1000)I_ini<-user(1)beta<-user(0.2)gamma<-user(0.1)As said in the previous vignette, remember this looks and parses likeR code, but is not actually R code. Copy-pasting this in a R sessionwill trigger errors.
We then useodin to compile this model:
## Loading required namespace: pkgbuild## <odin_model> object generator## Public:## initialize: function (..., user = list(...), use_dde = FALSE, unused_user_action = NULL) ## ir: function () ## set_user: function (..., user = list(...), unused_user_action = NULL) ## initial: function (step) ## rhs: function (step, y) ## update: function (step, y) ## contents: function () ## transform_variables: function (y) ## run: function (step, y = NULL, ..., use_names = TRUE) ## Private:## ptr: NULL## use_dde: NULL## odin: NULL## variable_order: NULL## output_order: NULL## n_out: NULL## ynames: NULL## interpolate_t: NULL## cfuns: list## dll: discrete.deterministic.sir4de329d6## user: I_ini S_ini beta gamma## registration: function () ## set_initial: function (step, y, use_dde) ## update_metadata: function () ## Parent env: <environment: namespace:discrete.deterministic.sir4de329d6>## Locked objects: TRUE## Locked class: FALSE## Portable: TRUENote: this is the slow part (generation and thencompilation of C code)! Which means for computer-intensive work, thenumber of times this is done should be minimized.
The returned objectsir_generatoris an R6 generator thatcan be used to create an instance of the model: generate an instance ofthe model:
## <odin_model>## Public:## contents: function () ## initial: function (step) ## initialize: function (..., user = list(...), use_dde = FALSE, unused_user_action = NULL) ## ir: function () ## rhs: function (step, y) ## run: function (step, y = NULL, ..., use_names = TRUE) ## set_user: function (..., user = list(...), unused_user_action = NULL) ## transform_variables: function (y) ## update: function (step, y) ## Private:## cfuns: list## dll: discrete.deterministic.sir4de329d6## interpolate_t: NULL## n_out: 0## odin: environment## output_order: NULL## ptr: externalptr## registration: function () ## set_initial: function (step, y, use_dde) ## update_metadata: function () ## use_dde: FALSE## user: I_ini S_ini beta gamma## variable_order: list## ynames: step S I Rx is anode_model object which can be usedto generate dynamics of a discrete-time, deterministic SIR model. Thisis achieved using the functionx$run(), providing timesteps as single argument, e.g.:
## step S I R## [1,] 0 1000.0000 1.000000 0.0000000## [2,] 1 999.8002 1.099800 0.1000000## [3,] 2 999.5805 1.209517 0.2099800## [4,] 3 999.3389 1.330125 0.3309317## [5,] 4 999.0734 1.462696 0.4639442## [6,] 5 998.7814 1.608403 0.6102138## [7,] 6 998.4604 1.768530 0.7710541## [8,] 7 998.1076 1.944486 0.9479071## [9,] 8 997.7198 2.137811 1.1423557## [10,] 9 997.2937 2.350191 1.3561368## [11,] 10 996.8254 2.583469 1.5911558x_res<- x$run(0:200)par(mar =c(4.1,5.1,0.5,0.5),las =1)matplot(x_res[,1], x_res[,-1],xlab ="Time",ylab ="Number of individuals",type ="l",col = sir_col,lty =1)legend("topright",lwd =1,col = sir_col,legend =c("S","I","R"),bty ="n")The stochastic equivalent of the previous model can be formulated inodin as follows:
## Core equations for transitions between compartments:update(S)<- S- n_SIupdate(I)<- I+ n_SI- n_IRupdate(R)<- R+ n_IR## Individual probabilities of transition:p_SI<-1-exp(-beta* I/ N)# S to Ip_IR<-1-exp(-gamma)# I to R## Draws from binomial distributions for numbers changing between## compartments:n_SI<-rbinom(S, p_SI)n_IR<-rbinom(I, p_IR)## Total population sizeN<- S+ I+ R## Initial states:initial(S)<- S_iniinitial(I)<- I_iniinitial(R)<-0## User defined parameters - default in parentheses:S_ini<-user(1000)I_ini<-user(1)beta<-user(0.2)gamma<-user(0.1)We can use the same workflow as before to run the model, using 10initial infected individuals (I_ini = 10):
## <odin_model> object generator## Public:## initialize: function (..., user = list(...), use_dde = FALSE, unused_user_action = NULL) ## ir: function () ## set_user: function (..., user = list(...), unused_user_action = NULL) ## initial: function (step) ## rhs: function (step, y) ## update: function (step, y) ## contents: function () ## transform_variables: function (y) ## run: function (step, y = NULL, ..., use_names = TRUE) ## Private:## ptr: NULL## use_dde: NULL## odin: NULL## variable_order: NULL## output_order: NULL## n_out: NULL## ynames: NULL## interpolate_t: NULL## cfuns: list## dll: discrete.stochastic.sir09143884## user: I_ini S_ini beta gamma## registration: function () ## set_initial: function (step, y, use_dde) ## update_metadata: function () ## Parent env: <environment: namespace:discrete.stochastic.sir09143884>## Locked objects: TRUE## Locked class: FALSE## Portable: TRUEset.seed(1)x_res<- x$run(0:100)par(mar =c(4.1,5.1,0.5,0.5),las =1)matplot(x_res[,1], x_res[,-1],xlab ="Time",ylab ="Number of individuals",type ="l",col = sir_col,lty =1)legend("topright",lwd =1,col = sir_col,legend =c("S","I","R"),bty ="n")This gives us a single stochastic realisation of the model, which isof limited interest. As an alternative, we can generate a large numberof replicates using arrays for each compartment:
## Core equations for transitions between compartments:update(S[])<- S[i]- n_SI[i]update(I[])<- I[i]+ n_SI[i]- n_IR[i]update(R[])<- R[i]+ n_IR[i]## Individual probabilities of transition:p_SI[]<-1-exp(-beta* I[i]/ N[i])p_IR<-1-exp(-gamma)## Draws from binomial distributions for numbers changing between## compartments:n_SI[]<-rbinom(S[i], p_SI[i])n_IR[]<-rbinom(I[i], p_IR)## Total population sizeN[]<- S[i]+ I[i]+ R[i]## Initial states:initial(S[])<- S_iniinitial(I[])<- I_iniinitial(R[])<-0## User defined parameters - default in parentheses:S_ini<-user(1000)I_ini<-user(1)beta<-user(0.2)gamma<-user(0.1)## Number of replicatesnsim<-user(100)dim(N)<- nsimdim(S)<- nsimdim(I)<- nsimdim(R)<- nsimdim(p_SI)<- nsimdim(n_SI)<- nsimdim(n_IR)<- nsim## <odin_model> object generator## Public:## initialize: function (..., user = list(...), use_dde = FALSE, unused_user_action = NULL) ## ir: function () ## set_user: function (..., user = list(...), unused_user_action = NULL) ## initial: function (step) ## rhs: function (step, y) ## update: function (step, y) ## contents: function () ## transform_variables: function (y) ## run: function (step, y = NULL, ..., use_names = TRUE) ## Private:## ptr: NULL## use_dde: NULL## odin: NULL## variable_order: NULL## output_order: NULL## n_out: NULL## ynames: NULL## interpolate_t: NULL## cfuns: list## dll: discrete.stochastic.sir.arraysb818bd3a## user: I_ini S_ini beta gamma nsim## registration: function () ## set_initial: function (step, y, use_dde) ## update_metadata: function () ## Parent env: <environment: namespace:discrete.stochastic.sir.arraysb818bd3a>## Locked objects: TRUE## Locked class: FALSE## Portable: TRUEset.seed(1)sir_col_transp<-paste0(sir_col,"66")x_res<- x$run(0:100)par(mar =c(4.1,5.1,0.5,0.5),las =1)matplot(x_res[,1], x_res[,-1],xlab ="Time",ylab ="Number of individuals",type ="l",col =rep(sir_col_transp,each =100),lty =1)legend("left",lwd =1,col = sir_col,legend =c("S","I","R"),bty ="n")This model is a more complex version of the previous one, which wewill use to illustrate the use of all distributions mentioned in thefirst part: Binomial, Poisson and Multinomial.
The model is contains the following compartments:
There are no birth of natural death processes in this model.Parameters are:
The model will be written as:
\[S_{t+1} = S_t - \beta \frac{S_t (I_{R,t} + I_{D,t})}{N_t} + \omega R_t\]
\[E_{t+1} = E_t + \beta \frac{S_t (I_{R,t} + I_{D,t})}{N_t} - \delta E_t +\epsilon\]
\[I_{R,t+1} = I_{R,t} + \delta (1 - \mu) E_t - \gamma_R I_{R,t} + \epsilon\]
\[I_{D,t+1} = I_{D,t} + \delta \mu E_t - \gamma_D I_{D,t} + \epsilon\]
\[R_{t+1} = R_t + \gamma_R I_{R,t} - \omega R_t\]
\[D_{t+1} = D_t + \gamma_D I_{D,t}\]
The formulation of the model inodin is:
## Core equations for transitions between compartments:update(S)<- S- n_SE+ n_RSupdate(E)<- E+ n_SE- n_EI+ n_import_Eupdate(Ir)<- Ir+ n_EIr- n_IrRupdate(Id)<- Id+ n_EId- n_IdDupdate(R)<- R+ n_IrR- n_RSupdate(D)<- D+ n_IdD## Individual probabilities of transition:p_SE<-1-exp(-beta* I/ N)p_EI<-1-exp(-delta)p_IrR<-1-exp(-gamma_R)# Ir to Rp_IdD<-1-exp(-gamma_D)# Id to dp_RS<-1-exp(-omega)# R to S## Draws from binomial distributions for numbers changing between## compartments:n_SE<-rbinom(S, p_SE)n_EI<-rbinom(E, p_EI)n_EIrId[]<-rmultinom(n_EI, p)p[1]<-1- mup[2]<- mudim(p)<-2dim(n_EIrId)<-2n_EIr<- n_EIrId[1]n_EId<- n_EIrId[2]n_IrR<-rbinom(Ir, p_IrR)n_IdD<-rbinom(Id, p_IdD)n_RS<-rbinom(R, p_RS)n_import_E<-rpois(epsilon)## Total population size, and number of infectedsI<- Ir+ IdN<- S+ E+ I+ R+ D## Initial statesinitial(S)<- S_iniinitial(E)<- E_iniinitial(Id)<-0initial(Ir)<-0initial(R)<-0initial(D)<-0## User defined parameters - default in parentheses:S_ini<-user(1000)# susceptiblesE_ini<-user(1)# infectedbeta<-user(0.3)# infection ratedelta<-user(0.3)# inverse incubationgamma_R<-user(0.08)# recovery rategamma_D<-user(0.12)# death ratemu<-user(0.7)# CFRomega<-user(0.01)# rate of waning immunityepsilon<-user(0.1)# import case rate## <odin_model> object generator## Public:## initialize: function (..., user = list(...), use_dde = FALSE, unused_user_action = NULL) ## ir: function () ## set_user: function (..., user = list(...), unused_user_action = NULL) ## initial: function (step) ## rhs: function (step, y) ## update: function (step, y) ## contents: function () ## transform_variables: function (y) ## run: function (step, y = NULL, ..., use_names = TRUE) ## Private:## ptr: NULL## use_dde: NULL## odin: NULL## variable_order: NULL## output_order: NULL## n_out: NULL## ynames: NULL## interpolate_t: NULL## cfuns: list## dll: discrete.stochastic.seirds7393a829## user: E_ini S_ini beta delta epsilon gamma_D gamma_R mu omega## registration: function () ## set_initial: function (step, y, use_dde) ## update_metadata: function () ## Parent env: <environment: namespace:discrete.stochastic.seirds7393a829>## Locked objects: TRUE## Locked class: FALSE## Portable: TRUEx<- seirds_generator$new()seirds_col<-c("#8c8cd9","#e67300","#d279a6","#ff4d4d","#999966","#660000")set.seed(1)x_res<- x$run(0:365)par(mar =c(4.1,5.1,0.5,0.5),las =1)matplot(x_res[,1], x_res[,-1],xlab ="Time",ylab ="Number of individuals",type ="l",col = seirds_col,lty =1)legend("left",lwd =1,col = seirds_col,legend =c("S","E","Ir","Id","R","D"),bty ="n")Several runs can be obtained without rewriting the model, forinstance, to get 100 replicates:
## [1] 366 600## S.1 E.1 Id.1 Ir.1 R.1 D.1 S.2 E.2 Id.2 Ir.2## 1 1000 1 0 0 0 0 1000 1 0 0## 2 1000 1 0 0 0 0 1000 1 0 0## 3 1000 0 0 1 0 0 1000 2 0 0## 4 1000 0 0 1 0 0 1000 1 1 0## 5 1000 0 0 1 0 0 1000 1 1 0## 6 1000 0 0 1 0 0 1000 0 2 0seirds_col_transp<-paste0(seirds_col,"1A")par(mar =c(4.1,5.1,0.5,0.5),las =1)matplot(0:365, x_res,xlab ="Time",ylab ="Number of individuals",type ="l",col =rep(seirds_col_transp,100),lty =1)legend("right",lwd =1,col = seirds_col,legend =c("S","E","Ir","Id","R","D"),bty ="n")It is then possible to explore the behaviour of the model using asimple function:
check_model<-function(n =50,t =0:365,alpha =0.2, ...,legend_pos ="topright") { model<- seirds_generator$new(...) col<-paste0(seirds_col,"33") res<-as.data.frame(replicate(n, model$run(t)[,-1])) opar<-par(no.readonly =TRUE)on.exit(par(opar))par(mar =c(4.1,5.1,0.5,0.5),las =1)matplot(t, res,xlab ="Time",ylab ="",type ="l",col =rep(col, n),lty =1)mtext("Number of individuals",side =2,line =3.5,las =3,cex =1.2)legend(legend_pos,lwd =1,col = seirds_col,legend =c("S","E","Ir","Id","R","D"),bty ="n")}This is a sanity check with a null infection rate and no importedcase:
Another easy case: no importation, no waning immunity:
A more nuanced case: persistence of the disease with limited import,waning immunity, low severity, larger population: