Changing individual susceptibility

We now turn to countermeasures that we can take to reduce the size of an epidemic.

There are several ways we can approach this. In this chapter we’ll look at ways in which we can change an individual’s response to potential infection. By making individuals less likely to be infected, we reduce the chances that an encounter will result in secondary infection and therefor reduce the chance of the disease spreading widely. We’ll see that it matters how many individuals’ susceptibility we modify, and often which individuals.

(In a later chapter we’ll look at an alternative approach which leaves individual susceptibilities alone but changes the topology of encounters at the population level.)

Vaccination changes susceptibility at a biological level, by changing an individual’s immune response, and in this chapter we’ll talk about the ways in which vaccines affect epidemic spreading in a population. But it’s important to remember that the same effect can be achieved at a physical level, with no vaccine and in fact no biological interventions at all, as we’ll see later. From the perspective of epidemic spreading both biological and physical approaches behave in largely the same way.

Vaccines

For most of history we have been unable to affect the progress of diseases by biological means. Instead, we’ve been limited to using topology – isolation and quarantine – to reduce the spread of a disease, or slow down its progress. For many diseases these approaches were ineffective given the dynamics of the disease and the conditions of daily life for the majority of the population, and even being sufficiently rich to lock oneself away was not guaranteed to spare one from infection.

This changed at the turn of the nineteenth century with the introduction of vaccines. Vaccination was first tried at scale by Sir Edward Jenner, who realised the similarities between smallpox – a ravaging disease and a cause of immense suffering – and cowpox, a far milder complaint commonly encountered in milkmaids who picked it up from cattle. This proved to be the first in a long line of innovations that have now erradicated smallpox entirely.

Vaccines work by priming a person’s immune system so that, if they are later infected, they already have the immunological machinery needed to fight the pathogen off. Critically, this reduces the time between infection happening and the immune response starting, meaning that there is less pathogen to fight off and therefore a better chance of preventing the infection taking hold in that individual. Sometimes this can be so effective that the individual is unaware they were even infected; more commonly they suffer a milder version of the disease, with less severe symptoms from which they recover more quickly.

There are lots of ways to build a vaccine. One can do as Jenner did and use a mild variant of the disease one is interested in. One can take the actual disease and produce a denatured version that cannot cause infection but does nevertheless prime the immune system. Modern vaccines are often even more specific than this, identifying some of the surface proteins that characterise the pathogen and introducing only them as a primer.

Immunology is an immense subject, but fortunately we don’t need to understand its mechanics – how it works in individuals – to understand its epidemiology – how it works in populations. In fact, as we’ll see, we don’t need a vaccine in order to still get the effects of vaccination.

Epidemics on human contact networks

Let’s revisit human contact networks as a substrate for an epidemic. Such a contact network resembles a powerlaw-with-cutoff topology rather than the “normal” topology of an ER network: there are nodes that have degrees (contacts) substantially larger than the mean of the network overall. We made the point that such networks are very good at spreading diseases.

How good? Human contact networks have different cutoffs, the point at which the probability of having nodes with higher degrees reduces dramatically. We can explore what this means by picking the dynamics of a disease and varying the cutoff to see how the same disease propagates on networks with different topologies.

# network parameters
N = 10000
alpha = 2

# simulation time
T = 1000

# disease dynamic parameters
pInfected = 0.001
pInfect = 0.01
pRemove = 0.002
# set up the experiment
lab = epyc.Lab()
lab[epydemic.SIR.P_INFECTED] = pInfected
lab[epydemic.SIR.P_INFECT] = pInfect
lab[epydemic.SIR.P_REMOVE] = pRemove
lab[PLCNetworkDynamics.N] = N
lab[PLCNetworkDynamics.ALPHA] = alpha
lab[PLCNetworkDynamics.CUTOFF] = numpy.linspace(10, 80,
                                                num=4)
lab[epydemic.Monitor.DELTA] = T / 50

# perform one monitoried epidemic
m = MonitoredSIR()
e = PLCNetworkDynamics(m)
lab.runExperiment(e)

Plotting the results for the different cutoff values yields the following.

df = lab.dataframe()
cutoffs = df[PLCNetworkDynamics.CUTOFF].unique()
    
(fig, axs) = plt.subplots(2, 2, sharex=True, sharey=True,
                          figsize=(10, 10))

for (ax, cutoff) in [ (axs[0][0], cutoffs[0]),
                      (axs[0][1], cutoffs[1]),
                      (axs[1][0], cutoffs[2]), 
                      (axs[1][1], cutoffs[3]) ]:
    rc = df[df[PLCNetworkDynamics.CUTOFF] == cutoff]
    timeseries = rc[MonitoredSIR.TIMESERIES].iloc[0]
    ts = timeseries[MonitoredSIR.OBSERVATIONS]
    sss = timeseries[epydemic.SIR.SUSCEPTIBLE]
    iss = timeseries[epydemic.SIR.INFECTED]
    rss = timeseries[epydemic.SIR.REMOVED]

    ax.plot(ts, sss, 'r.', label='suceptible')
    ax.plot(ts, iss, 'g.', label='infected')
    #ax.plot(ts, rss, 'ks', label='removed')

    ax.set_title('$\\kappa = {kappa:.0f}$'.format(kappa=cutoff))
    ax.set_xlim([0, T])
    ax.set_ylim([0, N])
    ax.legend(loc='upper right')

# fine-tune the diagram
plt.suptitle('SIR over powerlaw networks for different cutoffs ($N = {n}, \\alpha={a}$)'.format(n=N, a=alpha))
for y in range(2):
    axs[y][0].set_ylabel('population that is...')
for x in range(2):
    axs[1][x].set_xlabel('$t$')

plt.show()
_images/vaccination_12_0.png

This is telling us that networks with a small maximum number of contacts (\(\kappa = 10\)) have relative small epidemics that appear quite slowly: the “peak” of the infections occurs farther into the outbreak. As we increase \(\kappa\) we see larger epidemics happening faster (closer to the start of the outbreak), until the results seem to stabilise and not change much as we continue to increase \(\kappa\): a maximum of about 30 contacts seems to be enough to infect about half the popuation at the peak.

Vaccination in SIR

What does vaccination look like in the SIR model? A vaccinated individual is one who cannot catch the disease. In model terms it means that an individual who exhibits that characteristics of having already had the disease, and having been “removed” into the R compartment. In fact this is what’s happening biologically as well: a vaccinated individual has been exposed to a substance that renders them the same as if they’d had the disease, without actually requiring them to have had it. The effect we’re looking for is herd immunity, where there are insufficient susceptible individuals in a population to let the disease establish itelf. But critically we’re looking for herd immunity without having the disease pass through the population first, with all the suffering and (possibly) death that this might entail.

We could model vaccination using a new compartment, leading to a model that might be called SIVR capturing the vaccinated individuals V. But in conditions of total vaccinated immunity the V individuals will behave identically to the R individuals, so we may as well simply treat them identically too. (If we were wanting to explore partial immunity through vaccination then SIVR would let us have, for example, different values of \(p_{\mathit{infect}}\) depending on whether it’s an S or a V individual being potentially infected: V becomes a halfway-house between S (fully susceptible) and R (fully removed).

Vaccinating the population at random

Most vaccines are applied broadly to a population, typically in childhood for a range of common diseases which most people will face. Ideally everyone is vaccinated; in practice some are missed for various reasons, in some the vaccine will not “take”, some must avoid it for unrelated medical reasons, and so forth.

We could try to model the ways in which this process happens in detail, but the overall effect is very similar to the case where we take a population and randomly vaccinate some percentage of the individuals before starting the infection. Since this is SIR, this means that we randomly assign some fraction \(p_{vaccinated}\) of nodes to the R compartment.

class MonitoredVaccinatedSIR(epydemic.SIR, epydemic.Monitor):
    
    P_VACCINATED = 'pVaccinated'   #: Probability that an
                                   # individual is initially removed.
    
    def __init__(self):
        super(MonitoredVaccinatedSIR, self).__init__()
        
    def build(self, params):
        '''Build the observation process.
        
        :param params: the experimental parameters'''
        super(MonitoredVaccinatedSIR, self).build(params)

        # change the initial compartment probabilities to vaccinate (remove) some fraction
        pInfected = params[epydemic.SIR.P_INFECTED]
        pVaccinated = params[self.P_VACCINATED]
        self.changeCompartmentInitialOccupancy(epydemic.SIR.INFECTED, pInfected)
        self.changeCompartmentInitialOccupancy(epydemic.SIR.REMOVED, pVaccinated)
        self.changeCompartmentInitialOccupancy(epydemic.SIR.SUSCEPTIBLE, 1.0 - pInfected - pVaccinated)

        # also monitor other compartments
        self.trackNodesInCompartment(epydemic.SIR.SUSCEPTIBLE)
        self.trackNodesInCompartment(epydemic.SIR.REMOVED)

We can choose any number we like for \(p_{vaccinated}\), with 60% being a typical target for immunisation campaigns.

pVaccinated = 0.6

Leaving all other experimental parameters the same from above, let’s choose a value of \(\kappa=57\) as a cutoff that we saw created an epidemic in a unvaccinated population, and run an experiment where we first vaccinate (remove) a fraction of nodes at random.

lab[MonitoredVaccinatedSIR.P_VACCINATED] = pVaccinated
lab[PLCNetworkDynamics.CUTOFF] = 57

m = MonitoredVaccinatedSIR()
e = PLCNetworkDynamics(m)
lab.runExperiment(e)

df = lab.dataframe()

We can then see the progress of the same epidemic on the same network topology, but in the presence of an effective vaccine applied to a fraction of the population.

fig = plt.figure(figsize=(8, 8))
ax = fig.gca()

rc = df[df[MonitoredVaccinatedSIR.P_VACCINATED] == 0.6]
results = rc.iloc[0]
timeseries = results[epydemic.Monitor.TIMESERIES]
ts = timeseries[MonitoredVaccinatedSIR.OBSERVATIONS]
sss = timeseries[epydemic.SIR.SUSCEPTIBLE]
iss = timeseries[epydemic.SIR.INFECTED]
rss = timeseries[epydemic.SIR.REMOVED]
ax.plot(ts, sss, 'r.', label='suceptible')
ax.plot(ts, iss, 'g.', label='infected')
#ax.plot(ts, rss, 'ks', label='removed')

ax.set_xlim([0, T])    
ax.set_xlabel('$t$')
ax.set_ylim([0, N * (1.0 - pVaccinated - pInfected)])
ax.set_ylabel('population that is...')
ax.set_title('SIR epidemic in the presence of {v:.0f}% vaccination ($\\kappa = {k}$)'.format(v=results[MonitoredVaccinatedSIR.P_VACCINATED] * 100, k=results[PLCNetworkDynamics.CUTOFF]), y=1.05)
ax.legend(loc='upper right')

plt.show()
_images/vaccination_24_0.png

Comparing this to the figure above shows quite a dramatic reduction in the outbreak size.

But wait! – there might be a problem Look at the y-axis in this graph. Notice that the maximum susceptible population is about 4000, even though the network has 10000 nodes. A moment’s thought shows why: we modelled vaccination as being pre-emptively removed, leaving fewer susceptibles. Could it be that this result is what we’d expect on a smaller network? In other words, is there a size effect coming into play as we move from 10000 down to 4000 individuals?

We should be careful and check this possibility. We can do so by working out the size of the unvaccinated population and creating a network with the same topology of this size, and then running our epidemic over it.

Nsmall = int(N * (1.0 - pVaccinated - pInfected))
lab[PLCNetworkDynamics.N] = Nsmall

m = MonitoredSIR()
e = PLCNetworkDynamics(m)
lab.runExperiment(e)
df = lab.dataframe()
fig = plt.figure(figsize=(8, 8))
ax = fig.gca()

# plot epidemic on unvaccinated network
rc = df[df[PLCNetworkDynamics.N] == Nsmall]
results = rc.iloc[0]
timeseries = results[epydemic.Monitor.TIMESERIES]
ts = timeseries[MonitoredVaccinatedSIR.OBSERVATIONS]
sss = timeseries[epydemic.SIR.SUSCEPTIBLE]
iss = timeseries[epydemic.SIR.INFECTED]
rss = timeseries[epydemic.SIR.REMOVED]
ax.plot(ts, sss, 'r.', label='suceptible (no vaccination)')
ax.plot(ts, iss, 'g.', label='infected (no vaccination)')
#ax.plot(ts, rss, 'ks', label='removed')

# plot results on same-sized network reduced in
# size by vaccination
rc = df[df[MonitoredVaccinatedSIR.P_VACCINATED] == 0.6]
results = rc.iloc[0]
timeseries = results[epydemic.Monitor.TIMESERIES]
ts = timeseries[MonitoredVaccinatedSIR.OBSERVATIONS]
sss = timeseries[epydemic.SIR.SUSCEPTIBLE]
iss = timeseries[epydemic.SIR.INFECTED]
rss = timeseries[epydemic.SIR.REMOVED]
ax.plot(ts, sss, 'ro', label='suceptible (post vaccination)')
ax.plot(ts, iss, 'go', label='infected (post vaccination)')
#ax.plot(ts, rss, 'ks', label='removed')
ax.set_xlim([0, T])    
ax.set_xlabel('$t$')
ax.set_ylim([0, Nsmall])
ax.set_ylabel('population that is...')
ax.set_title('SIR epidemic with and without vaccination ($N = {n}, \\kappa = {k}$)'.format(n=Nsmall, k=results[PLCNetworkDynamics.CUTOFF]), y=1.05)
ax.legend(loc='center right')

plt.show()
_images/vaccination_27_0.png

The first thing to see is that the two graphs are different: it’s not just the size of the network that affects things. In the small-but-unvaccinated network we see a larger epidemic; in the vaccinated network we see a much smaller and slower outbreak. What is different between the two cases, since the networks are the same size?

A moment’s thought may suggest the answer. We’ve created two networks with the same topology, one where a fraction of nodes are removed by vaccination, and one where a number of nodes really had been removed (or rather, were never present in the network in the first place). Both networks have high-degree nodes, as we’d expect for powerlaw-with-cutoff networks. But in the latter (vaccinated) case, some of those high-degree nodes will have been vaccinated and so are not able to spread the disease. And since the disease spreads through contact between S and I nodes, we lose the opportunity to infect a high-degree node that could act as super-spreaders able to infect a large number of nodes. And that reduction in super-spreading is enough to change the dynamics of the disease.

How super are super-spreaders?

Let’s look at some numbers. Firstly, how many contacts does the most highly-connected node have?

g = m.network()
ks = sorted(list(dict(networkx.degree(g)).values()))
print('Maximum degree = {kmax}'.format(kmax=max(ks)))
Maximum degree = 62

That’s a high number. What about the number of contacts for an averagely-connected node?

print('Mean node degree = {kmean:.2f}'.format(kmean=numpy.mean(ks)))
Mean node degree = 2.59

Very different, and it’s this feature that differentiates a human contact network from an ER network: the existence of nodes with degrees that are much higher than the average. In fact such networks have a long tail of nodes with high degrees: only a small number relative to the size of the network overall, but nonetheless able to pass infection.

h = 10
print('Highest {h} nodes by degree {l}'.format(h=h, l=ks[-h:]))
Highest 10 nodes by degree [43, 44, 45, 46, 46, 50, 54, 54, 56, 62]

How important are these individuals in the spread of the disease? We can study that by excluding them from our model vaccination programme. Instead of vaccinating some fraction of the network, after vaccination we will make sure that some fraction of the highest-degree nodes are susceptible. Essentially we swap high-degree nodes for lower-degree nodes in our vaccination programme.

class MonitoredVaccinatedLowDegreeSIR(MonitoredVaccinatedSIR):

    K_HIGH_FRACTION = 'k_high_fraction'
    
    def __init__(self):
        super(MonitoredVaccinatedLowDegreeSIR, self).__init__()
        
    def setUp(self, params):
        super(MonitoredVaccinatedLowDegreeSIR, self).setUp(params)
        
        # look through the fraction of high-degree nodes and
        # make them susceptible again, replacing them with
        # another node chosen at random
        rng = numpy.random.default_rng()
        g = self.network()
        ns = list(g.nodes())
        h = int(len(ns) * params[self.K_HIGH_FRACTION])
        degrees = dict(networkx.degree(g))
        ks = sorted(list(degrees.values()))
        ks_high = set(ks[-h:])
        ns_high = [n for n in ns if degrees[n] in ks_high]
        for n in ns_high:
            if self.getCompartment(n) == self.REMOVED:
                # node is removed, make it susceptible again
                self.setCompartment(n, self.SUSCEPTIBLE)
                
                # choose another node and remove it in
                # place of the node we just forced to
                # be susceptible
                while True:
                    i = rng.integers(0, len(ns) - 1)
                    m = ns[i]
                    if self.getCompartment(m) == self.SUSCEPTIBLE:
                        # found a susceptible node, remove it
                        self.setCompartment(m, self.REMOVED)
                        break

Running the experiment, again with the same disease parameters and network topology as before, shows us the effects of this failure in vaccination.

kHighFraction = 0.08  # highest-degree 8% 

lab[MonitoredVaccinatedLowDegreeSIR.K_HIGH_FRACTION] = kHighFraction
lab[PLCNetworkDynamics.N] = N

m = MonitoredVaccinatedLowDegreeSIR()
e = PLCNetworkDynamics(m)
lab.runExperiment(e)
df = lab.dataframe()
fig = plt.figure(figsize=(8, 8))
ax = fig.gca()

rc = df[df[MonitoredVaccinatedLowDegreeSIR.K_HIGH_FRACTION] == kHighFraction]
results = rc.iloc[0]
timeseries = results[epydemic.Monitor.TIMESERIES]
ts = timeseries[MonitoredVaccinatedSIR.OBSERVATIONS]
sss = timeseries[epydemic.SIR.SUSCEPTIBLE]
iss = timeseries[epydemic.SIR.INFECTED]
rss = timeseries[epydemic.SIR.REMOVED]
ax.plot(ts, sss, 'r.', label='suceptible')
ax.plot(ts, iss, 'g.', label='infected')
#ax.plot(ts, rss, 'ks', label='removed')

ax.set_xlim([0, T])    
ax.set_xlabel('$t$')
ax.set_ylim([0, N * (1.0 - pInfected - pVaccinated) + N * kHighFraction])
ax.set_ylabel('population that is...')
ax.set_title('SIR epidemic without vaccination of {khigh:.0f}% highest-degree nodes ($N = {n}, \\kappa = {k:.0f}$)'.format(khigh=kHighFraction * 100, n=N, k=results[PLCNetworkDynamics.CUTOFF]), y=1.05)
ax.legend(loc='upper right')

plt.show()
_images/vaccination_40_0.png

Letting a small fraction of the high-degree nodes – i.e., the most connected individuals – remain susceptible changes the epidemic again, making it larger and faster. It’s not only the size of the vaccinated population that counts: it’s who we vaccinate (or, in this case, don’t vaccinate) that really matters. Missing even a small fraction of the highly connected will radically reduce the effectiveness of a vaccination programme.

Targetted vaccination

So the existence of high-degree nodes offers an opportunity for the disease to infect far more individuals if those nodes are not protected by vaccination.

But this also offers opportunities for further countermeasures. If high-degree nodes are important in spreading the disease, what if – instead of vaccinating at random – we instead explicitly target those nodes that we believe are the most important in spreading the disease? That might make our programme more effective. It might also mean that we could perform a smaller, more focused, programme, where instead of vaccinating widely at random we vaccinate narrowly but in a focused, “smart” way.

We can explore this too. Rather than perform random vaccination, we instead target a specific fraction of the highest-degree nodes.

class MonitoredVaccinatedHighDegreeSIR(MonitoredSIR):

    K_VACCINATED_FRACTION = 'k_vaccinated_fraction'
    
    def __init__(self):
        super(MonitoredVaccinatedHighDegreeSIR, self).__init__()
        
    def setUp(self, params):
        super(MonitoredVaccinatedHighDegreeSIR, self).setUp(params)

        # look for the fraction of highest-degree nodes
        # and vaccinate (remove) them
        g = self.network()
        ns = list(g.nodes())
        h = int(len(ns) * params[self.K_VACCINATED_FRACTION])
        degrees = dict(networkx.degree(g))
        ks = sorted(list(degrees.values()))
        ks_high = set(ks[-h:])
        ns_high = [n for n in ns if degrees[n] in ks_high]
        for n in ns_high:
            # remove (vaccinate) the node
            self.setCompartment(n, self.REMOVED)

How large a fraction do we need to target? Let’s be ambitious and start small, vaccinating only 2% of nodes – thirty times fewer than before.

kVaccinatedFraction = 0.02    # top 2% highest-degree nodes
lab[MonitoredVaccinatedHighDegreeSIR.K_VACCINATED_FRACTION] = kVaccinatedFraction

m = MonitoredVaccinatedHighDegreeSIR()
e = PLCNetworkDynamics(m)
lab.runExperiment(e)
df = lab.dataframe()
fig = plt.figure(figsize=(8, 8))
ax = fig.gca()

rc = df[df[MonitoredVaccinatedHighDegreeSIR.K_VACCINATED_FRACTION] == kVaccinatedFraction]
results = rc.iloc[0]
timeseries = results[epydemic.Monitor.TIMESERIES]
ts = timeseries[MonitoredVaccinatedSIR.OBSERVATIONS]
sss = timeseries[epydemic.SIR.SUSCEPTIBLE]
iss = timeseries[epydemic.SIR.INFECTED]
rss = timeseries[epydemic.SIR.REMOVED]
ax.plot(ts, sss, 'r.', label='suceptible')
ax.plot(ts, iss, 'g.', label='infected')
#ax.plot(ts, rss, 'ks', label='removed')

ax.set_xlim([0, T])    
ax.set_xlabel('$t$')
ax.set_ylim([0, N])
ax.set_ylabel('population that is...')
ax.set_title('SIR epidemic vaccinating only the {kvac:.0f}% highest-degree nodes ($N = {n}, \\kappa = {k:.0f}$)'.format(kvac=kVaccinatedFraction * 100, n=N, k=results[PLCNetworkDynamics.CUTOFF]), y=1.05)
ax.legend(loc='upper right')

plt.show()
_images/vaccination_47_0.png

That’s quite amazing! With almost no-one vaccinated – 200 in a network of 10000 – we both reduce and slow the epidemic. Both these effects are important. The total number of people infected is smaller, but so too is the “ramp-up” at the start of the epidemic, which means less stress is placed on health systems dealing with the influx of sick peoiple.

When people talk of flattening the curve, this is the effect they’re aiming at – achieved in this case through targeted vaccination of a tiny fraction of the population.

This reduction in vaccination effort makes it faster, cheaper, and more reliable – if we can identify and target the super-spreaders. But this might be possible, because we know that the super-spreaders are the highest-degree nodes, who are simply the ones with the most exposure to other people. In the modern world a person’s contact degree is often at least partially a function of their job, and so by targeting those whose jobs bring them into contact with the most people – and especially into contact with the most infected people – we can create a very effective vaccination strategy and roll it out quickly.

“Vaccination” without vaccines

We said at the beginning of this chapter that immunology was an enormously complicated topic but one whose details didn’t matter for population-level modelling. The experiments we conducted above have hopefully convinced you of this.

But if this is the case, then it’s not vaccination that’s the important feature for our purposes. Any technology that behaves like a vaccine preventing the infection of those we treat, will have the same effect.

What technologies might these be? An obvious example is personal protective equipment such as face masks, surgical gloves, and the like, issued to those whom we identify as being in high-contact professions such as care workers, medical workers, bus drivers, and the like – anyone who, if they were to become infected, would have the opportunity to spread the disease to a disproportionate number of others. Most importantly this doesn’t require that we protect everyone, just that we protect the most important people in the contact network, whom we identify by their contact degree.

Questions for discussion

  • What groups in society would you choose for targetted vaccination?

  • Would you be happy just doing targetted vaccination, or would you want “general” vaccination too?

  • If we never have a vaccine for a disease, how can we protect ourselves against it?