Fading immunity

Immunity to a disease is not always permanent. For some diseases, immunity fades over time, meaning that an individual can be infected multiple times.

We can model this by using go_ward() to move individuals from the R stage back to the S stage after a period of time.

Slowing progress

The Disease.progress parameter controls the rate at which an individual will move through a disease stage. Advancement through the disease stages is controlled by advance_recovery(). This simply samples the fraction, Disease.progress, from the number of individuals who are at that disease stage via a random binomial distribution, and advances them to the next stage.

The key code that does this is summarised here;

# loop from the penultimate disease stage back to the first stage
for i in range(N_INF_CLASSES-2, -1, -1):
    ...

    # get the progress parameter for this disease stage
    disease_progress = params.disease_params.progress[i]

    # loop over all ward-links for this disease stage
    for j in range(1, nlinks_plus_one):
        # get the number of workers in this link at this stage
        inf_ij = infections_i[j]

        if inf_ij > 0:
            # sample l workers from this stage based on disease_progress
            l = _ran_binomial(rng, disease_progress, inf_ij)

            if l > 0:
                # move l workers from this stage to the next stage
                infections_i_plus_one[j] += l
                infections_i[j] -= l

    # loop over all nodes / wards for this disease stage
    for j in range(1, nnodes_plus_one):
        # get the number of players in this ward at this stage
        inf_ij = play_infections_i[j]

        if inf_ij > 0:
            # sample l players from this stage based on disease_progress
            l = _ran_binomial(rng, disease_progress, inf_ij)

            if l > 0:
                # move l players from this stage to the next stage
                play_infections_i_plus_one[j] += l
                play_infections_i[j] -= l

Time since recovery

To measure time since recovery, we can add extra “post-recovery” stages. Individuals will be set to move slowly through those “post-recovery” stages, until, when a particular post-recovery stage is reached, a fraction of individuals are deemed to have lost immunity to the disease, and are moved back to the S stage.

To do this, we will create a new version of the lurgy with these extra post-recovery stages, which we will call R1 to R10. To do this in Python, open ipython or jupyter and type;

>>> from metawards import Disease
>>> lurgy = Disease("lurgy6")
>>> lurgy.add("E", beta=0.0, progress=1.0)
>>> lurgy.add("I1", beta=0.4, progress=0.2)
>>> lurgy.add("I2", beta=0.5, progress=0.5, too_ill_to_move=0.5)
>>> lurgy.add("I3", beta=0.5, progress=0.8, too_ill_to_move=0.8)
>>> R_progress = 0.5
>>> lurgy.add("R", progress=R_progress)
>>> for i in range(1, 11):
...    lurgy.add(f"R{i}", beta=0.0, progress=R_progress)
>>> lurgy.to_json("lurgy6.json", indent=2, auto_bzip=False)

or, in R/RStudio you could type;

> library(metawards)
> lurgy <- metawards$Disease("lurgy6")
> lurgy$add("E", beta=0.0, progress=1.0)
> lurgy$add("I1", beta=0.4, progress=0.2)
> lurgy$add("I2", beta=0.5, progress=0.5, too_ill_to_move=0.5)
> lurgy$add("I3", beta=0.5, progress=0.8, too_ill_to_move=0.8)
> R_progress <- 0.5
> lurgy$add("R", progress=R_progress)
> for(i in 1:10) {
      stage <- sprintf("R%d", i)
      lurgy$add(stage, beta=0.0, progress=R_progress)
  }
> lurgy$to_json("lurgy6.json", indent=2, auto_bzip=False)

or simply copy the below into lurgy6.json;

{
  "name": "lurgy6",
  "stage": ["E", "I1", "I2", "I3", "R", "R1", "R2",
              "R3", "R4", "R5", "R6", "R7", "R8",
              "R9", "R10"],
  "mapping": ["E", "I", "I", "I", "R", "R", "R",
              "R", "R", "R", "R", "R", "R", "R",
              "R"],
  "beta": [0.0, 0.4, 0.5, 0.5, 0.0, 0.0, 0.0, 0.0,
          0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
  "progress": [1.0, 0.2, 0.5, 0.8, 0.5, 0.5, 0.5,
              0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5,
              0.5],
  "too_ill_to_move": [0.0, 0.0, 0.5, 0.8, 0.0, 0.0,
                      0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
                      0.0, 0.0],
  "contrib_foi": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
                  1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
  "start_symptom": 2,
  "is_infected": [true, true, true, true, false, false,
                  false, false, false, false, false, false,
                  false, false, false]
}

This creates 10 post-recovery stages, with a progress parameter for these stages of 0.5. This means, at quickest, an individual would take 10 days to progress from R to R10, but on average, this will take much longer (as 50% move from one stage to the next each day).

Moving from R to S

Next, we need to add a move function that will move a fraction of individuals from R10 back to S, to represent that fraction losing immunity.

Create a mover called move_immunity.py and copy in the below;

from metawards.movers import MoveGenerator, go_ward, MoveRecord
from metawards.utils import Console


def move_immunity(**kwargs):
    def go_immunity(**kwargs):
        record = MoveRecord()
        gen = MoveGenerator(from_stage="R10", to_stage="S",
                            fraction=0.5)

        go_ward(generator=gen, record=record, **kwargs)

        if len(record) > 0:
            nlost = record[0][-1]
            Console.print(f"{nlost} individual(s) lost immunity today")

    return [go_immunity]

This will move 50% of R10 individuals back to S each day.

Note

We have used MoveRecord to record the moves performed by go_ward(). This keeps a complete record of exactly how many individuals were moved, and the full details of that move. In this case, there will only be a single move (record[0]), and the number of individuals who were moved is the last value in the record (record[0][-1]).

You can run the model using;

metawards -d lurgy6.json -m single -a 5 --move move_immunity.py

(using the single-ward model, seeding with 5 initial infection).

You should see that the outbreak oscillates as individuals who have lost immunity are re-infected. For example, the graph I get (from metawards-plot) are shown below;

Outbreak trajectory when individuals can lose immunity

Note

This is just an illustrative example. Individuals lose immunity in this model far more quickly than would be expected for a real disease. You could modify this example to use custom user variables to scan through different values of progress for each of the post-recovery stages, to better model a more realistic disease.

Vaccination and boosters

You can apply the same method to model fading immunity after a vaccination. This could be used to best plan how often booster doses should be deployed.

To do this, we will modify our lurgy to model to include vaccination and post-vaccination stages. For example, in Python (in ipython/Jupyter);

>>> from metawards import Disease
>>> lurgy = Disease("lurgy7")
>>> lurgy.add("E", beta=0.0, progress=1.0)
>>> lurgy.add("I1", beta=0.4, progress=0.2)
>>> lurgy.add("I2", beta=0.5, progress=0.5, too_ill_to_move=0.5)
>>> lurgy.add("I3", beta=0.5, progress=0.8, too_ill_to_move=0.8)
>>> R_progress = 0.5
>>> V_progress = 0.5
>>> lurgy.add("R", progress=R_progress)
>>> for i in range(1, 10):
...    lurgy.add(f"R{i}", beta=0.0, progress=R_progress)
>>> lurgy.add("R10", beta=0.0, progress=0.0)
>>> lurgy.add("V", progress=V_progress, is_infected=False)
>>> for i in range(1, 11):
...     lurgy.add(f"V{i}", beta=0.0, progress=V_progress,
...               is_infected=False)
>>> lurgy.to_json("lurgy7.json", auto_bzip=False)
> library(metawards)
> lurgy <- metawards$Disease("lurgy7")
> lurgy$add("E", beta=0.0, progress=1.0)
> lurgy$add("I1", beta=0.4, progress=0.2)
> lurgy$add("I2", beta=0.5, progress=0.5, too_ill_to_move=0.5)
> lurgy$add("I3", beta=0.5, progress=0.8, too_ill_to_move=0.8)
> R_progress <- 0.5
> V_progress <- 0.5
> lurgy$add("R", progress=R_progress)
> for(i in 1:9) {
      stage <- sprintf("R%d", i)
      lurgy$add(stage, beta=0.0, progress=R_progress)
  }
> lurgy.add("R10", beta=0.0, progress=0.0)
> lurgy.add("V", progress=V_progress, is_infected=False)
> for(i in 1:10) {
      stage <- sprintf("V%d", i)
      lurgy$add(stage, beta=0.0, progress=V_progress)
  }
> lurgy.to_json("lurgy7.json", auto_bzip=False)

or copy the below into lurgy7.json

{
  "name": "lurgy7",
  "stage": ["E", "I1", "I2", "I3", "R", "R1",
            "R2", "R3", "R4", "R5", "R6", "R7",
            "R8", "R9", "R10", "V", "V1", "V2",
            "V3", "V4", "V5", "V6", "V7", "V8",
            "V9", "V10"],
  "mapping": ["E", "I", "I", "I", "R", "R", "R",
              "R", "R", "R", "R", "R", "R", "R",
              "R", "V", "V", "V", "V", "V", "V",
              "V", "V", "V", "V", "V"],
  "beta": [0.0, 0.4, 0.5, 0.5, 0.0, 0.0, 0.0,
           0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
           0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
           0.0, 0.0, 0.0, 0.0, 0.0],
  "progress": [1.0, 0.2, 0.5, 0.8, 0.5, 0.5,
               0.5, 0.5, 0.5, 0.5, 0.5, 0.5,
               0.5, 0.5, 0.0, 0.5, 0.5, 0.5,
               0.5, 0.5, 0.5, 0.5, 0.5, 0.5,
               0.5, 0.5],
  "too_ill_to_move": [0.0, 0.0, 0.5, 0.8, 0.0, 0.0,
                      0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
                      0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
                      0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
                      0.0, 0.0],
  "contrib_foi": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
                  1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
                  1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
                  1.0, 1.0, 1.0, 1.0, 1.0],
  "start_symptom": 2,
  "is_infected": [true, true, true, true, false, false,
                  false, false, false, false, false, false,
                  false, false, false, false, false, false,
                  false, false, false, false, false, false,
                  false, false]
}

Note

Note how progress for the R10 stage is set to 0 to prevent R10 automatic progression from R10 to V.

Note

Note also that we have to manually set is_infected to false for the V stages. This is set automatically to false only for R stages.

Next, modify move_immunity.py to read;

from metawards.movers import MoveGenerator, go_ward, MoveRecord
from metawards.utils import Console


def move_immunity(**kwargs):
    def go_vaccinate(population, **kwargs):
        if population.day <= 10:
            gen = MoveGenerator(from_stage="S", to_stage="V",
                                number=100)
            go_ward(generator=gen, population=population, **kwargs)

    def go_immunity(**kwargs):
        record = MoveRecord()
        gen = MoveGenerator(from_stage="R10", to_stage="S",
                            fraction=0.5)

        go_ward(generator=gen, record=record, **kwargs)

        if len(record) > 0:
            Console.print(f"{record[0][-1]} individual(s) lost immunity today")

    def go_booster(**kwargs):
        gen = MoveGenerator(from_stage="V10", to_stage="V",
                            fraction=0.2)
        go_ward(generator=gen, **kwargs)

        record = MoveRecord()
        gen = MoveGenerator(from_stage="V10", to_stage="S")
        go_ward(generator=gen, record=record, **kwargs)

        if len(record) > 0:
            Console.print(f"{record[0][-1]} individual(s) didn't get their booster")

    return [go_vaccinate, go_booster, go_immunity]

Here, we’ve added a go_vaccinate function that, for the first 10 days of the outbreak, moves up to 100 individuals per day from S to V. This will, in effect, vaccinate all of the population of 1000 individuals who are not infected.

Next, we’ve added a go_booster function that samples 20% of the V10 stage to give them a booster vaccine that returns them to V. The remaining individuals in V10 miss their booster dose, and are returned to S.

You can run the model using;

metawards -d lurgy7.json -m single -a 5 --move move_immunity.py

You should see that the infection nearly dies out, as nearly everyone is vaccinated. However, a small number of lingering infections spark a second outbreak amongst individuals who miss their booster shot, leading then to a cycle of infection and losing immunity, e.g.

─────────────────────────────────────────────── Day 9 ────────────────────────────────────────────────
S: 89  E: 0  I: 6  V: 900  R: 5  IW: 0  POPULATION: 1000
Number of infections: 6

─────────────────────────────────────────────── Day 10 ───────────────────────────────────────────────
S: 0  E: 0  I: 6  V: 989  R: 5  IW: 0  POPULATION: 1000
Number of infections: 6

─────────────────────────────────────────────── Day 11 ───────────────────────────────────────────────
S: 0  E: 0  I: 6  V: 989  R: 5  IW: 0  POPULATION: 1000
Number of infections: 6

─────────────────────────────────────────────── Day 12 ───────────────────────────────────────────────
1 individual(s) didn't get their booster
S: 1  E: 0  I: 6  V: 988  R: 5  IW: 0  POPULATION: 1000
Number of infections: 6

...

─────────────────────────────────────────────── Day 23 ───────────────────────────────────────────────
54 individual(s) didn't get their booster
S: 309  E: 0  I: 2  V: 680  R: 9  IW: 0  POPULATION: 1000
Number of infections: 2

─────────────────────────────────────────────── Day 24 ───────────────────────────────────────────────
72 individual(s) didn't get their booster
2 individual(s) lost immunity today
S: 383  E: 0  I: 2  V: 608  R: 7  IW: 0  POPULATION: 1000
Number of infections: 2

─────────────────────────────────────────────── Day 25 ───────────────────────────────────────────────
60 individual(s) didn't get their booster
S: 443  E: 0  I: 2  V: 548  R: 7  IW: 0  POPULATION: 1000
Number of infections: 2

─────────────────────────────────────────────── Day 26 ───────────────────────────────────────────────
64 individual(s) didn't get their booster
1 individual(s) lost immunity today
S: 507  E: 1  I: 2  V: 484  R: 6  IW: 1  POPULATION: 1000
Number of infections: 3

─────────────────────────────────────────────── Day 27 ───────────────────────────────────────────────
49 individual(s) didn't get their booster
1 individual(s) lost immunity today
S: 557  E: 0  I: 3  V: 435  R: 5  IW: 0  POPULATION: 1000
Number of infections: 3

─────────────────────────────────────────────── Day 28 ───────────────────────────────────────────────
44 individual(s) didn't get their booster
S: 599  E: 2  I: 3  V: 391  R: 5  IW: 1  POPULATION: 1000
Number of infections: 5

...

─────────────────────────────────────────────── Day 40 ───────────────────────────────────────────────
8 individual(s) didn't get their booster
S: 776  E: 7  I: 39  V: 162  R: 16  IW: 1  POPULATION: 1000
Number of infections: 46

─────────────────────────────────────────────── Day 41 ───────────────────────────────────────────────
8 individual(s) didn't get their booster
S: 776  E: 8  I: 45  V: 154  R: 17  IW: 1  POPULATION: 1000
Number of infections: 53

...

────────────────────────────────────────────── Day 103 ───────────────────────────────────────────────
11 individual(s) lost immunity today
S: 289  E: 32  I: 329  V: 2  R: 348  IW: 1  POPULATION: 1000
Number of infections: 361

────────────────────────────────────────────── Day 104 ───────────────────────────────────────────────
12 individual(s) lost immunity today
S: 258  E: 43  I: 319  V: 2  R: 378  IW: 1  POPULATION: 1000
Number of infections: 362

────────────────────────────────────────────── Day 105 ───────────────────────────────────────────────
1 individual(s) didn't get their booster
11 individual(s) lost immunity today
S: 233  E: 37  I: 325  V: 1  R: 404  IW: 1  POPULATION: 1000
Number of infections: 362

Note

Again, this is just an illustrative example. Immunity from vaccination would be expected to last for much longer than a couple of weeks. You could use adjustable variables (and custom user-adjustable variables) to scan through the progress along the post-vaccination stages, the numbers vaccinated each day, and different percentages of individuals who take a booster, to better model a real situation.

Note

We have used Disease.progress to slow movement along the post-recovery and post-vaccinated stages. An alternative method would be to write a custom iterator to replace advance_recovery(). This could slow down movement programmatically, e.g. by only testing for advancement along the R and V stages every 10 days, as opposed to every day. We’ve designed metawards to be very flexible, so that you have many choices for how you want to model different scenarios.