An epidemic over a changing population¶
Problem: You want to simulate an epidemic in a population that has a “background” level of birth and death, unconnected to the disease.
Solution: epydemic
has the building blocks we need for this
scenario: a compartmented model and an addition-deletion process. We
just have to make them work together.
epydemic
provides a ProcessSequence
for this, in the same
way as we can include monitoring to any
process. The basic structure will therefore be to create a new
process sequence that combines the compartmented model we want with
the addition-deletion process, and then set all the parameters that
the two processes need. Suppose we choose our favourite SIR
model. We’d then have code that lools something like:
ps = ProcessSequence([SIR(), AddDelete()])
So far so good.
However, let’s think about the processes involved here. The compartmented SIR
model starts by putting nodes into the SIR.SUSCEPTIBLE
compartment, and
then changing the compartment as the epidemic spreads. This setting of the initial
compartment happens in SIR.build()
when we set the initial probabilities
of nodes being in the SIR.SUSCEPTIBLE
or SIR.INFECTED
compartments.
(The actual assignment of nodes to compartments happens in CompartmentedModel.initialCompartments()
which is called as part of the setup procedure.)
Now let’s turn to the addition-deletion process. AddDelete
builds
fixed-rate events that add and delete nodes, independently of any other
process running at the same time. But while these events are independent in terms
of their occurrence, they’re not independent in terms of their interaction with
the compartmented model. When we add a node we need to decide what compartment
it’s going to be placed in; when wee delete a node we need to take account of what
effects that has on the infection of other nodes. This suggests that we can’t simply
combine the two classes together and expect them to work out of the box: there’s
some missing functionality.
(Another way to think about this is that the base processes provide the independent parts of the scenario, and we have to encode the parts of the scenario in which they communicate with each other.)
The issue is what happens when nodes are added and removed. These functions are
provided by two methods, Process.addNode()
and Process.removeNode()
. by
default these methods just do what you’d expect, affecting the underlying network the
process is running over. AddDelete
overrides both these methods
(AddDelete:addNode()
and AddDelete.removeNode()
), and also
defines another, AddDelete.addNewNode()
, that creates a new node with a new
name.
Making processes work together¶
To define how SIR
and AddDelete
work together, then,
we need to extend the methods where the interaction occurs. Let’s
suppose we’ve decided that all added nodes will be marked as
SIR.SUSCEPTIBLE
, and that all deleted nodes will just
disappear.
We need the addition-deletion process to be able to access to disease process. We can do this by creating a process sequence with named components. We’ll call the disease process ‘disease’ and the addition-deletion process ‘population’. Now, if the addition-deletion process is combined with a disease process, it can make use of that process as part of its definition:
class CompartmentedAddDelete(AddDelete):
def addNewNode(self, **kwds):
'''Mark new nodes as susceptible.
:param kwds: (optional) node attributes'''
# add the node, capturing its name
n = super().addNewNode(*kwds)
# set the compartment of this node to susceptible
self.container()['disease'].setCompartment(n, epydemic.SIR.SUSCEPTIBLE)
# return the name of the new node
return n
def removeNode(self, n):
'''Mark any node as removed before deleting.
:param n: the node'''
# change the node's compartment to removed
self.container()['disease'].changeCompartment(n, epydemic.SIR.REMOVED)
# delete the node
super().removeNode(n)
The addition and removal of nodes now affects the components of the
nodes added or deleted, by acquiring the ‘disease’ component of the
container process (the sequence of which the addition-deletion process
is part) and using it to change compartment. (Note that when adding a
node we used CompartmentedModel.setCompartment()
because the
newly-added node didn’t have a compartment, whereas when deleting a
node we used and not CompartmentedModel.changeCompartment()
because it did.)
We now need to build a process sequence with these names defined:
comps = {'disease': SIR()
'population': ComponentAddDelete()}
ps = ProcessSequence(comps)
The AddDelete.add()
and AddDelete.delete()
events will now use these
overridden methods, and will inform the compartmented model when new susceptible
nodes appear and when nodes are deleted. Note that we never actually
use the name of the addition-deletion process anywhere, as the SIR
process doesn’t need to make use of it. If it did – if we defined a
variant disease that needed to work with a changing population
directly, we could do so.
Keeping the numbers straight¶
You might have noticed that we’ve been a bit fast and loose with how we delete
nodes in this solution. We communicate to the SIR
process that the node is
no longer part of the process by setting its compartment to SIR.REMOVED
, just
as would happen if it recovered (or died) as part of the epidemic. But if you’re interested
in the actual numbers who die naturally versus those who die (or whatever) from
the disease, then this muddles these numbers up by aggregating the two causes together.
The easiest way to deal with this is with an extended model that has an extra compartment (“D” for “died”?) into which to place deleted nodes. We can do this in one of three ways:
Extend
SIR
and add a new compartment in the sub-class;Write another compartmented model that only has this compartment, and no events, and add it into the mix using multiple inheritance; or
Extend
CompartmentedAddDelete.build()
to add the new compartment.
Which of these is the “correct” approach depends on context. In any case there’s an interaction between the addition-deletion process and the compartmented model process that’s requiring extra code. At one level the solution you choose doesn’t matter, because the interactions all go through an API and any extra results that you obtain land in the results dict alongside the other values.
Composition and sub-classing¶
We’ve used both process composition and sub-classing in this example, and it’s perhaps useful to explore how and why.
Process composition using ProcessSequence
takes two (or more)
processes and runs them together. The individual processes themselves
are unchanged, but their events and other functions are merged during
the simulation.
If the two processes need to interact in more complicated ways,
requiring code to be written, then we need to add that code somehow in
the right place for it to be called by the right process. Sub-classing
is often the simplest way to do this. It has the disadvantage of
creating a new class that’s in some sense specific to the composition
– CompartmentedAddDelete
in our case here – but it equally
remains somewhat independent of the other process and so can
potentially be re-used. One could swqp-out SIR
and replace it
with SEIR
, for example, without changing
CompartmentedAddDelete
.
(If you’re determined to avoid an extra class you can adopt a more
dynamic approach and “monkey-patch” the definitions of methods in
just the one instance of AddDelete
that you’re using. If
you know how to do this, and are happy with the consequences, work
away: if you don’t know, our advice is not to find out as the
resulting can of worms is enormous.)
A third possibility is to use multiple inheritance, and for
example define a class that sub-classes both SIR
and
AddDelete
and then overrides the methods. This sometimes
works well, but sacrifices quite a lot of control and opportunities
for re-use.
Warning
We no longer recommend using
multiple inheritance in epydemic
simulations.
Complicated process sequences¶
If you find yourself using ProcessSequence
with named
components frequently, it’s probably worth sub-classing it to add code
that builds the components with the right names, so that you know that
they’ll always be available. The resulting process is “just” a
process, and can be combined with other processes (and indeed other
process sequences) freely.