(This is a chapter from Complex networks, complex processes.)

Networks consist of nodes connected by edges. We've already looked at the notion of a path in terms of providing a "rouyute to follow" to get from one node to another. We can look at paths between pairs of nodes to see whether they exist – is it possible to navigate from one node to the other? – and find paths of different lengths, including a possibly unique shortest path. We also considered one way of raising this local property to the global network level in order to find the network's diameter: in the network as a whole, what is the longest shortest path between *any* pair of nodes?

There's another such global question related to paths: is it always possible to find a path between any pair of nodes in the network? Clearly there's a major difference between networks for which the answer is yes, and other networks: in the former case, while it may be *hard* to find a path between tweo nodes, it will always be *possible*; in the latter case, some attempts at navigation are doomed to failure.

A network for which there is always a path between any pair of nodes is called **connected**. Connectivity is the property that says that navigation is always possible.

How do we determine if a network is connected? At some level we need to check that paths exist between all pairs of nodes, but that's going to be extremely expensive for large networks. Fortunately there's a simpler way, and even more fortunately `networkx`

provides it built-in.

In [1]:

```
import networkx
import numpy
import itertools
import cncp
import matplotlib as mpl
%matplotlib inline
%config InlineBackend.figure_format = 'svg'
import matplotlib.pyplot as plt
import matplotlib.cm as cmap
import seaborn
```

`networkx`

's `is_connected()`

function to test the network's connectivity:

In [2]:

```
l = cncp.lattice_graph(10, 10)
print 'Lattice connected? {c}'.format(c = networkx.is_connected(l))
```

In [3]:

```
l.add_node(9999)
print 'Lattice with extra node connected? {c}'.format(c = networkx.is_connected(l))
l.add_edge(9999, 1)
print 'Did the new edge re-connect things? {c}'.format(c = networkx.is_connected(l))
```

**disconnect** it by removing nodes, for example by "snipping off the corner":

In [4]:

```
l.remove_edges_from([ (0, 1), (0, 10) ])
print 'Still connected? {c}'.format(c = networkx.is_connected(l))
```

This works because we happen to know the way the nodes are labelled by `lattice_graph()`

, so we know which edges we need to remove. We could also have removed a band of edges across the centre of the lattice, or on a diagonal: as longf as we interrupt the path between *any one pair* of nodes, the network will no longer be connected.

These ideas work with larger groups of nodes as well. For example, suppose we place two networks "side by side", having edges internally but none between them:

In [5]:

```
# create two lattices
l1 = cncp.lattice_graph(5, 5)
l2 = cncp.lattice_graph(5, 5)
# re-label the second lattice so that the node labels will be unique
l2p = networkx.relabel_nodes(l2, lambda n: n + 1000)
# combine the two lattices together to form a single network
l = networkx.compose(l1, l2p)
print 'Two-lattice network connected? {c}'.format(c = networkx.is_connected(l))
```

Notice what we did to make this work:

- we built the two networks independently ;
- then we re-labelled one of them to make the node labels unique; and
- then composed them together.

Our lattice-creation function always labels nodes in the same way in the networks it creates, so after the first step we have two networks each with a common set of node labels. If we'd simply composed these networks together as-is, `networkx`

would have assumed that two nodes with the same label were *the same node* and would have combined them – and then combined all the edges too. We'd have ended up with a single lattice! By re-labelling the second network's nodes we ensure they're recognised as distinct, and therefore when we combine the two networks we get a a network with two lattices "side by side" and no edges between them.

Adding a single edge between nodes in the two lattices is of course enough to connect the network:

In [6]:

```
l.add_edge(0, 1000)
print 'Two-lattice network connected with extra edge? {c}'.format(c = networkx.is_connected(l))
```

In [7]:

```
l.remove_node(1000)
print 'Is the network still connected after removing a critical node? {c}'.format(c = networkx.is_connected(l))
```

And of course one of the "lattices" is now missing a node.

Let's return to the lattices we used to create the network above. Each of the lattices was itself a network, which we then joined together to firm the overall lattices-side-by-side network. But we can also observe that – in this case, although not necessarily – the two lattices were themselves connected. It was possible to go from any node in one lattice to any node *in the same lattice*; when we put them side-by side, this stopped being the case; and then when we added an edge it because possible to go from any node in one lattice to any node *in either lattice*.

So after we placed the lattices side-by-side we had a network with two **sub-networks**, each of which was connected, but the network taken together was disconnected. This property of being a connected sub-network of a larger structure is called being a **component** (or sometimes a **connected component**, although that's a bit tautologous). When we connected the two components together we created a single connected network, a single component.

Each component is a "island" of connectivity. Navigation is possible "on the island", but impossible "off the island". The number of components in a network is a measure of how many "islands" there are. We can use `networkxz`

to count both their number and their size:

In [8]:

```
print "Newly-split network as {c} components".format(c = networkx.number_connected_components(l))
# compute the sizes of the components
cs = list(networkx.connected_components(l))
for i in range(len(cs)):
print 'Component {i} contains {n} nodes'.format(i = i, n = len(cs[i]))
```

`max()`

or `sorted()`

to explicitly put them into the right order:

In [9]:

```
print 'Largest component has {n} nodes'.format(n = len(max(networkx.connected_components(l), key = len)))
```

The significance of components really becomes clear when we consider different ways of generating networks, especially using random processes. Many such processes don't actually guarantee to generate a connected network: they add edges between nodes randomly, so it's entirely possible that some nodes may be isolated or that two or more components may form. If this is important for an application, we need to be careful to make sure the network is connected *before* we start work on it. There are two basic ways to do this:

- we can check that the network is connected using
`is_connected()`

, and throw it away and start again; or - we can take the largest component from the network as-is.

Neither method is necessarily better. For the first, it might be that we *never* get a connected network because of some combination of parameters to the generator (for example the network has three nodes and we only ever add one edge: extreme, but you get the idea). For the second, we'll necessarily end up with a network that has fewer nodes than we thought: possibly less than half, depending on exactly how many components the generator gives rise to. So which method we adopt depends on the application, and we'll have to think carefully about the constraints of each scenario we explore.

*quite* components?

In [10]:

```
# build left network
l = networkx.Graph()
l.add_edges_from([ (1, 2), (2, 3), (3, 4), (4, 5) ])
# build left network
r = networkx.Graph()
r.add_edges_from([ (1, 2), (1, 3), (1, 4), (1, 5) ])
# create the figure
fig = plt.figure(figsize = (10, 5))
# draw left network
ax1 = fig.add_subplot(1, 2, 1) # one row of two columns, first box
ax1.grid(False) # no grid
ax1.get_xaxis().set_ticks([]) # no ticks on the axes
ax1.get_yaxis().set_ticks([])
networkx.draw_networkx(l, ax = ax1, node_size = 100)
# draw right network
ax2 = fig.add_subplot(1, 2, 2) # one row of two columns, second box
ax2.grid(False) # no grid
ax2.get_xaxis().set_ticks([]) # no ticks on the axes
ax2.get_yaxis().set_ticks([])
networkx.draw_networkx(r, ax = ax2, node_size = 100)
```

In [11]:

```
print 'Left network diameter {ld}.'.format(ld = networkx.diameter(l))
print 'Right network diameter {ld}.'.format(ld = networkx.diameter(r))
```

Clearly it's "quicker to get around" the right-hand network. So what would be the "quickest" network we could imagine? The minimum case is when the diameter of the network is 1. Remembering the definition of diameter as the longest shortest path, this would mean that the shortest path between any pair of nodes was 1 – or, to put it another way, every node was adjacent to every other. Such a network is called a **clique** (which rhymes with "speak", *not* with "click"). In the graph theory literature, the clique of $n$ nodes is referred to as $K_n$.

We can create cliques algorithmically:

In [12]:

```
# create a clique of five nodes
k5 = networkx.Graph()
for (n, m) in itertools.combinations(range(5), 2):
k5.add_edge(n, m)
# draw the clique
fig = plt.figure(figsize = (5, 5))
ax = fig.gca()
ax.grid(False) # no grid
ax.get_xaxis().set_ticks([]) # no ticks on the axes
ax.get_yaxis().set_ticks([])
networkx.draw_networkx(k5, node_size = 100)
plt.title('$K_5$')
_ = plt.show()
```

If you're not familiar with Python's `itertools`

package, it provides a whole suite of useful ways to combine sets of data. `itertools.combinations()`

takes a collection `l`

and a number `i`

and produces all combinations of `i`

objects taken from `l`

– in this case all pairs of nodes, with each pair appearing exactly once.

`networkx`

will, unsurprisingly, create cliques directly:

In [13]:

```
fig = plt.figure(figsize = (5, 5))
ax = fig.gca()
ax.grid(False) # no grid
ax.get_xaxis().set_ticks([]) # no ticks on the axes
ax.get_yaxis().set_ticks([])
networkx.draw_networkx(networkx.complete_graph(10), node_size = 100)
plt.title('$K_{10}$')
_ = plt.show()
```

*could* have is sometimes referred to as its **density**. It isn't a measure of connectivity *per se*, but can provide a useful metric for deciding whether a network is well-connected or sparse – concepts we'll come back to later.

In the lattices-side-by-side example above we had two components that we connected with a single edge. Suppose we scale things up a bit, to a large network with several large components. Suppose we then add a small number of edges between the components, thereby connecting the network. We now have a connected network and a single component: is there anything else to say about the matter?

Well clearly there is. The sub-networks are no longer components, it's true, but they're still recognisibly more connected *within* themselves than *between* themselves. We refer to these almost-components as **communities** or **modules**.

While the idea of being a component is very clear-cut, being a community is a lot more delicate. When is a collection of nodes "connected enough" internally and "not connected enouygh" externally to be termed a community? Can we always identify the communities of a network? As the number of edges increases, and the number of paths between pairs of nodes in two communities increases, at what point do they cease to be two communities and become one?

These are all interesting questions, which we'll return to later: the notion of community-finding is a very active research topic For the time being, it's sufficient to observe that the component (or community) structure of a network might have an influence on its properties, and in particular on how processes operate over it.

(This is a chapter from Complex networks, complex processes.)

So far we've looked at ER networks from a practical perspective, through simulation. This **numerical** approach is typical for computer scientists, and is very powerful. It has the enormous advantage of working for *any* network using the *same* set of techniques (and code). It has the enormous disadvantage, however, of often providing very little insight as to *why* the answer is as it is: why, for example, does an ER network have the bell-shaped degree distribution that it has, and what does this imply?

Often the numerical approach is the best we can hope for, especially in the face of irregular or otherwise "awkward" networks. But the ER network has a very regular construction process: surely we might expect to be able to do better?

An alternative to simulation in such cases is to take an **analytical** approach, to try to find closed-form mathematical expressions that answer the key questions we want to pose. This approach omly works in some cases – although these cases are vitally important and interesting, and it turns out that there are other analytic techniques that work for a still broader class of networks – but it has the advantage of not requiring simulation that may be time-consuming and subject to various statistical constraints: analysis provides precise, uniform answers.

In this chapter we'll look at some properties of ER networks from this perspective and derive mathematical expressions for them. We'll focus only on those properties that are most important from a practical perspective: the dergree distribution and the mean degree. (The Wikipedia page for ER networks describes – but doesn't derive – lots of other properties of largely theoretical interest.) We'll do this from first principles and at some length, to demonstrate the sorts of mathematical arguments that'll be common in what's to come.

We'll start by returning to the degree distribution, the numbers of nodes with given numbers of immediate neighbours in the network. We observed earlier that we can interpret the degree distribution in terms of probability: what is the probability of a node $v$ chosen at random having a given degree $k$? In normal probability notation this would be written $P(deg(v) = k)$, the probability that $deg(v)$, the degree of $v$, is equal to $k$. For brevity we will usually write this as $p_k$. Taken over the whole network, this will yield a degree distribution, where the probability of all possible degrees in the network sum to one: $\sum_k p_k = 1$.

So what is the degree distribution for an ER network? At first acquaintance, many non-mathematicians would argue something like this: the generating process adds an edge between any pair of nodes with a fixed probability $\phi$, with every edge (and every node) treated equally. Therefore, we'd expect every node to have roughly the same degree as every other – a degree distribution that's *uniform* – consistent with the uniformity of the generating process.

Does that sound reasonable? – it did to me when I first made this argument. But we know from the simulation we did earlier that this *isn't* what happens: we actually get a *normal* distribution of degrees, not a uniform one. (If you need more convincing about this, read the rest of this section and then skip to the epilogue at the end of the chapter.) Clearly there must be another way of thinking about the process.

Let's re-phrase the question: in an ER network, how does a node end up having degree $k$? We can answer this by looking back at the construction process, where we iterated through all the pairs of nodes and added an edge between them with a given, fixed, probability $\phi$ (which we denoted `pEdge`

in the code). So each node *could in principle* have been connected to $N - 1$ other nodes: that's the maximum degree it could have, since we've excluded the possibility of self-loops or parallel edges. For each of these potential edges, we essentially tossed a coin to decide whether the edge was included or not – except that the "coin" came down "heads" with a probability $\phi$, and therefore came down "tails" with a probability $(1 - \phi)$ (since there are only two alternatives, and their probabilities have to sum to 1). Let's refer to each such decision – add an edge or don't – as an *action*. For each node we perform $N - 1$ actions, one per potential edge, and for a node to have a degree $k$ we have to perform $k$ "add" actions and $(N - 1 - k)$ "don't-add" actions. We can perform these actions in any order.

How many ways are there to perform this sequence of actions? Suppose we have a bag of $a$ actions: how many ways are there to select $b$ actions from the bag? The answer to this is given by the formula $\frac{a!}{b! (a - b)!}$, a result known as the **binomial theorem**. This value is often denoted $\binom{a}{b}$, so:

So, returning to our original question, we have $\binom{N - 1}{k}$ ways to perform $k$ "add" actions from a possible $N - 1$ actions, with the remainder being "don't-add" actions. This is the number of possible sequences that, for a given node, can result in that node having degree $k$. From elementary probability theory, to work out the probability of a sequence of actions happening we multiply-out the probabilities of the individual actions: "this *and* this *and* this" and so forth. So for each sequence of $k$ add actions and $(N - 1 - k)$ don't-add actions we multiply the probailities of each action together to get the probability of them *all* happening, and then multiply this compound probability by number of ways these actions can happen so as to still give us the $k$ edges we want.

Putting all this together, what is the probability that a node $v$ taken at random from an ER model consisting of $N$ nodes and edge probability $\phi$ will have degree $k$? For a node to have degree $k$ we need to perform a sequence of actions consisting of $k$ add actions (each occurring with probability $\phi$ ); *and* we need $(N - 1 - k)$ don't-add actions (occurring with probability $1 - \phi$); *and* there are $\binom{N - 1}{k}$ ways in which these actions can be arranged. Expressing this as maths, we get:

This is a distribution well known in statistics as the **binomial distribution**. It's important to note that $\phi$ is a constant, and that each add action is independent of each other add action: it doesn't get any easier to add edges over time. (If this seems like an obvious thing to say, we only say it because this turns out to be different to the approach we'll take to BA networks later.)

Given that we are dealing with large graphs, we will simplify the $N - 1$ term to $N$, since it makes very little difference as $N \rightarrow \infty$, yielding:

$$p_k = \binom{N}{k} \, \phi^k \, (1 - \phi)^{N - k}$$What happens as $N$ gets larger and larger? Clearly $\binom{N}{k}$ also gets larger and larger (there are more and more ways to choose the $k$ edges), and $(1 - \phi)^{N - k}$ gets smaller and smaller (since $1 - \phi$ is by definition less than 1), while $\phi^k$ stays the same size. What happens therefore depends on whether the rise term or the falling term dominates in the limit, which isn't blindingly obvious but fortunately the answer *is* known: the binomial distribution converges to another distribution, the **Poisson distribution**, as $N \rightarrow \infty$. The Poisson distribution is basically the normal distribution for systems built from discrete events, and is given by:

While this form is easier to work with, it's a lot less suggestive. The binomial form is probably to be preferred as a way of thinking about the distribution simply because each of the factors within it relates to a real, concrete phenomenon: add actions, don't-add actions, their probabilities (summing to 1), and the number of ways of combining them.

It's also worth noting that, in using an analytical approach, we were able to appeal to lots of known results in mathematics about the number of possible combinations of actios, or the ways functions behave in the limit – and with no need to write any code or burn any computer time.

In [1]:

```
import math
import numpy
import matplotlib
%matplotlib inline
%config InlineBackend.figure_format = 'svg'
import matplotlib.pyplot as plt
import seaborn
```

In [3]:

```
def poisson( n, pEdge ):
'''Return a model function for the Poisson distribution with n nodes and
edge probability pEdge.
:param n: number of nodes
:param pEdge: probabilty of an edge being added betweed a pair of nodes'''
def model( k ):
return (pow(n * pEdge, k) * math.exp(-n * pEdge)) / math.factorial(k)
return model
fig = plt.figure()
plt.xlabel("$k$")
plt.ylabel("$p_k$")
plt.title('Poisson degree distribution, $N = {n}, \phi = {phi}$'.format(n = 1000, phi = 0.05) )
plt.plot(xrange(100), map(poisson(1000, 0.05), xrange(100)))
_ = plt.show()
```

The graph is symmetric around the point $x = 50$, suggesting that this is the mean. Looking at the parameters of the distribution, however, we plotted 1000 nodes with an edge probability of 0.05, which multiplied-out also give 50. That's suggestive, but we need to *prove* that its the case *always*.

First let's re-visit the idea of a mean. The mean of any random variable can be written as the sum of each value the variable can take m,ultiplied by the probability of it taking that value. For the mean degree, we therefore have:

\begin{align} \langle k \rangle &= 1 \times p_1 + 2 \times p_2 + \cdots \\ &= \sum_{k = 1}^N k \, p_k \end{align}(The maximum node degree is actually $N - 1$ since we're looking at simple networks, so we only really need to sum $k$ up to $N-1$ rather than $N$ – but that just means that $p_N = 0$, so the sum works out anyway.) For the Poisson distribution underlying an ER network, we can code-up this definition using the formula above to work out the probability for each $k$. If $N = 1000$ and $\phi = 0.05$ as above, then:

In [4]:

```
sum = 0
p = poisson(1000, 0.05)
for k in xrange(1, 100):
sum = sum + k * p(k)
print 'Computed mean degree = {kmean}'.format(kmean = sum)
```

Close enough. But we can do better: we can obtain an analytic result and compute the formula for the mean degree given $N$ and $\phi$. We can identify the two definitions above to get that:

$$ \langle k \rangle = \sum_{k = 1}^N k \, \binom{N}{k} \, \phi^k \, (1 - \phi)^{N - k} $$So we need to find out the value of the sum on the right-hand side. To do this we need to know another property of the binomial distribution, which is that:

$$ (p + q)^n = \sum_{d = 1}^{n} d \binom{n}{d} \, p^d \, q^{n - d} $$Now, if we differentiate both sides with respect to $p$, we get:

\begin{align*} n(p + q)^{n - 1} &= \sum_{d = 1}^{n} \binom{n}{d} \, d \, p^{d - 1} \, q^{n - d} \\ &= \frac{1}{p} \sum_{d = 0}^{n} d \binom{n}{d} \, p^d \, q^{n - d} \\ np(p + q)^{n - 1} &= \sum_{d = 1}^{n} d \binom{n}{d} \, p^d \, q^{n - d} \end{align*}and the right-hand side starts to look very like the form we're looking for from above. If we now express it in terms of $N$, $\phi$, and $k$ to get the notation straight, and let $q = 1 - p$, then:

\begin{align*} N\phi(\phi + (1 - \phi))^{N - 1} &= \sum_{k = 1}^{N} \binom{N}{k} \, \phi^k \, (1 - \phi)^{N - k} \\ N\phi &= \sum_{k = 1}^{N} \binom{N}{k} \, \phi^k \, (1 - \phi)^{N - k} \\ &= \langle k \rangle \end{align*}So the mean of the binomial degree distribution is given by $N \phi$. Looking at the equations, we can see that $N$ and $\phi$ are the only parameters: we need to know them, *and only them*, to compute the distribution for any value of $k$. We can therefore say that $N$ and $\phi$ *completely characterise* the distribution.

There is another implication of this. Since $\langle k \rangle = N\phi$, for large $N$ we can make use of the fact that the binomial distribution converges to the Poisson distribution and re-write the probability distribution for at ER network in terms of the network's mean degree:

$$ p_k = \frac{\langle k \rangle^k e^{-\langle k \rangle}}{k!} $$This means that given two of $N$, $\phi$, and $\langle k \rangle$, we can compute the other, and we have all we need to completely characterise the degree distribution of an ER network. Put still another way, if we want an ER network with a specific number of nodes and a mean degree, we can compute the link probability $\phi = \frac{\langle k \rangle}{N}$ we need to construct it.

Earlier we asserted that many people, on first seeing the generating process for the ER model, assume that it will result in a uniform degree distribution. I certainly did. Since it's such a common reaction, it's perhaps worth exploring a little why it's also wrong.

The argument for a uniform degree distribution goes roughly as follows: since the edge probability is independent for every edge, we'd expect that, at each node, we select roughly the same number of edges to add, and therefore there's no reason for one node to be preferred over another, so they should all have roughly the same degree.

The problem here is that it takes a statement about *edges* and subtly converts it into a statement about *nodes*. Just because we select edges with a constant probability doesn't imply that we do so uniformly at the node level – so uniformly, in fact, that every node ends up having *exactly* the same number of edges. Put that way, a uniform degree distribution actually sounds rather unlikely! The process only says that, *over the graph as a whole*, edges are added with constant probability: it does not say anything about the *local* behaviour of edge addition around an individual node. It is this that allows for the possibility of non-uniform distrbution.

This observation – that global behaviour, and typically global regularity, doesn't lead to local regularity – is perhaps the single most important thing to bear in mind about complex networks. It's tempting to think that large-scale regularity emerges from lots of small-scale regularity, but that isn't necessarily the case: the small scale could be irregular, but the irregularities could even out. Conversely, it's tempting to think that something that looks regular and well-behaved on the outside has component pieces that are regular and well-behaved – and again that isn't necessarily the case. The lesson here is that things can be more complex than they seem. On the other hand, it also means that we can often ignore local noise and make use of global properties, as long as we're careful.

The description we used for the ER generator is an example of a process that in mathematics is called a Bernoulli process, where we look at the sequence of actions needed to generate a given outcome and compute how many ways there are for those actions to occur at random. Bernoulli processes occur whenever we encounter actions being performed one after the other according to some random driver, and the argument above is completely typical of how one deals with them.

(This is a chapter from Complex networks, complex processes.)

Let's now look at the best-understood complex network. If there's a poster child for network science, it's the "random graph", or more properly, the *Erdős-Rényi* or *ER network*. We mentioned Erdős and Rényi in the introduction as the mathematicians who first gave shape to the idea that large networks with essentially random structure might still show some usefulÂ statistical properties that made them more comprehensible. In this chapter we'll see what these regularities are. The ER networks are complex enough to allow us to demonstrate techniques that will apply in other circumstances, but are simple and well-behaved enough to make this analysis fairly straightforward.

We'll explore the ER network in some detail both through simulation and through mathematical analysis. We'll do it this way for a good reason: in the real world, networks often cannot be guaranteed to have exactly the properties that the mathematical techniques require, but computer simulation really needs to be driven by an understanding of what's going on in network at a fundamental level and how the mathematical features contribute to this behaviour. For these reasons, it's not safe to only understand how to simulate networks: you need to be able at least to follow the mathematical analysis as well. Conversely, understanding real networks and applications requires the techniques of simulation as well as analysis.

We'll start by building ER networks using `networkx`

and explore some of the properties that we developed earlier. We'll then look at the same properties (and more) from a more mathematical perspective, and relate the code to the maths to show how the two views interrelate.

To build an Erdős-Rényi (or ER) network with $N$ vertices, we proceed as follows:

- Build a graph $G = (V, E)$ with $N$ vertices and no edges, so $|V| = N$ and $E = \emptyset$
- For each pair of vertices $v_1, v_2 \in V$ with $v_1 \neq v_2$, add an edge $(v_1, v_2)$ to $E$ with probability $\phi$

That's it! – a very simple process for constructing what turns out ot be a very interesting class of networks. There are a four things to notice here, all of whch turn out to be very important for what follows.

Firstly, the ER model has two parameters: the number of nodes in the network $N$, and the probability $\phi$ of an edge occurring between any given pair of nodes. The combination of these two parameters defines a **class** of networks, depending on exactly which pairs of nodes are connected at random at the connection stage.

Secondly, the probability of an edge appearing between any pair of nodes is an independent event: it doesn't matter whether a node is already heavily connected or not, the chances of its being linked to any other node is just $\phi$ – and this probability doesn't change over time.

Thirdly, we disallow both self-loops and parallel edges, thereby creating a simple network.

Fourthly, we build the network "all at once", with all its nodes and all its edges in place before we do any further analysis.

To build such a network, we need to turn the description into code. We can do this in two ways using `networkx`

:

- by implementing the construction process ourselves; or
- by using the built-in generator function

The latter is clearly entirely adequate in practice, but for demonstration purposes, we'll do both.

In [1]:

```
import networkx
import math
import numpy
import matplotlib as mpl
%matplotlib inline
%config InlineBackend.figure_format = 'svg'
import matplotlib.pyplot as plt
import matplotlib.colors as colors
import matplotlib.cm as cm
import seaborn
from JSAnimation import IPython_display
from matplotlib import animation
```

In [2]:

```
def erdos_renyi_graph_from_scratch( n, pEdge ):
"""Build the graph with n nodes and a probability pEdge of there
being an edge between any pair of nodes.
:param n: number of nodes in the network
:param pEdge: probability that there is an edge between any pair of nodes
:returns: a network"""
g = networkx.empty_graph(n)
# run through all the possible edges
ne = 0
for i in xrange(n):
for j in xrange(i + 1, n):
if numpy.random.random() <= pEdge:
ne = ne + 1
g.add_edge(i, j, { 'added': ne })
return g
```

(We use `n`

for $N$ and `pEdge`

for $\phi$.) Notice the way we run through the pairs of nodes so that we only try to generate an edge once between each pair. This works because the graph we're building is undirected and we want at most one edge between each pair of nodes *in either order*. (There are also directed ER networks: to build on of those we'd want to try each pair *in each order* to allow for directionality.)

The key `networkx`

method here is `add_edge`

, which adds an edge between a pair of nodes. It's optional third parameter is a dictionary of attribute/value pairs that are associated with the edge, and we use this to record the order in which the edge was added so we can visualise the growth of the network below.

We can then use this function to build an ER network, for example with 5000 nodes and a 5% probability of there being an edge between any pair of nodes:

In [3]:

```
g_from_scratch = erdos_renyi_graph_from_scratch(5000, 0.05)
```

`networkn`

's "generator" function for ER networks built-in that we can use to build a graph with the same properties as above:

In [4]:

```
g_from_generator = networkx.erdos_renyi_graph(5000, 0.05)
```

`g_from_scratch`

and `g_from_generator`

are instances of the class of ER networks. They aren't *the same network*, though, even though they have the same parameters, because they've been created by stochastic processes and so will have different connections between their nodes. However, they will both share certain statistical characteristics that we'll come back to after we look at the growth processes ion more detail.

It can sometimes be useful to see how these graphs grow, by means of animation. We can use `matplotlib`

to draw a graph progressively, one node at a time, and show how the edge set grows too. We can then use the `JSAnimation`

plug-in to generate an in-line animation, or save the animation to a file and link to it.

`matplotlib`

's animation functions are quite involved. The core is a function that creates a figure for each frame of the animation, which `matplotlib`

then links together like the pages of a flick-book. There's quite a lot of set-up involved too, though: the following code is heavily commented to (hopefully) show what's going on.

In [26]:

```
def animate_growing_graph( g, edges, fig, ax = None, pos = None, cmap = None, **kwords ):
"""Animate the growth of a network, showing how edges are added and
how node degrees evolve. Slow if done for a large graph. Returns a
matplotlib animation object that can be saved to a file for later
or shown in-line in a notebook.
:param g: the network
:param edges: the edges, in the order they were added
:param fig: the figure to draw into
:param ax: (optional) the axes to draw into (defaults to main figure axes)
:param pos: (optional) layout for the network (default is to use the spring layout)
:returns: an animation object"""
# fill in the defaults
if ax is None:
# figure main axes
ax = fig.gca()
if pos is None:
# layout the network using the spring layout
pos = networkx.spring_layout(g, iterations = 100, k = 2/math.sqrt(g.order()))
if cmap is None:
cmap = cm.hot
if ('frames' not in kwords.keys()) or (kwords['frames'] is None):
# animate at one second per edge
kwords['frames'] = int(len(edges) * (1.0 / kwords['interval']))
# manipulate the axes, since this isn't a data plot
ax.set_xlim([-0.2, 1.2]) # axes bounded around 1
ax.set_ylim([-0.2, 1.2])
ax.grid(False) # no grid
ax.get_xaxis().set_ticks([]) # no ticks on the axes
ax.get_yaxis().set_ticks([])
# work out the colour map for the degrees of the network, picking
# colours linearly from the length of the colour map
ds = g.degree().values()
max_degree = max(ds)
min_degree = min(ds)
norm = colors.Normalize(vmin = min_degree, vmax = max_degree)
mappable = cm.ScalarMappable(norm, cmap)
# We now create all the graphical elements we need for the animation as matplotlib
# lines and patches. Essentially this defines what's in the final frame of the animation.
# We'll then make everything invisible and, as the animation progresses, make the elements
# appear in the right order. It's a lot faster to do it this way rather than re-building
# each frame from nothing as we go -- although that works too.
# generate node markers based on positions
nodeMarkers = dict()
nodeDegrees = dict()
for v in g.nodes_iter():
circ = plt.Circle(pos[v], radius = 0.02, zorder = 2) # place node markers at the top of the z-order
ax.add_patch(circ)
nodeMarkers[v] = circ
nodeDegrees[v] = 0
# build the list of edges as they were added
edgeMarkers = []
edgeEndpoints = []
for (i, j) in edges:
xs = [ pos[i][0], pos[j][0] ]
ys = [ pos[i][1], pos[j][1] ]
line = plt.Line2D(xs, ys, zorder = 1) # place edge markers down the z-order
ax.add_line(line)
edgeMarkers.append(line)
edgeEndpoints.append((i, j))
# work out the "time shape" of the animation
nFrames = kwords['frames'] # frames in the animation
framesPerEdge = max(int(nFrames / len(edges)), 1) # frames per edge
# add colourbar for node degree
kmax = max(g.degree().values())
cax = fig.add_axes([ 0.9, 0.125, 0.05, 0.775 ])
norm = mpl.colors.Normalize(0, kmax)
cb = mpl.colorbar.ColorbarBase(cax, cmap = cmap,
norm = norm,
orientation = 'vertical',
ticks = range(kmax + 1))
# initialisation function hides all the edges, colours all nodes
# as having degree zero
def init():
x = 1
for em in edgeMarkers:
em.set(alpha = 0)
for vm in nodeMarkers.values():
vm.set(color = mappable.to_rgba(0))
# per-frame drawing for animation
def frame( f ):
# frame number boundaries for various transitions in the animation "shape"
atEdge = int((f + 0.0) / framesPerEdge) # the edge we've reached with this frame
if framesPerEdge == 1:
a = 1
else:
a = ((f + 0.0) % framesPerEdge) / framesPerEdge
if atEdge < len(edgeMarkers):
edgeMarkers[atEdge].set(alpha = a)
if(a == 1):
(i, j) = edgeEndpoints[atEdge]
nodeDegrees[i] = nodeDegrees[i] + 1
nodeMarkers[i].set(color = mappable.to_rgba(nodeDegrees[i]))
nodeDegrees[j] = nodeDegrees[j] + 1
nodeMarkers[j].set(color = mappable.to_rgba(nodeDegrees[j]))
# return the animation with the functions etc set up
return animation.FuncAnimation(fig, frame, init_func = init, **kwords)
```

In [6]:

```
# build the network, which annotates the edges with their order of addition
er = erdos_renyi_graph_from_scratch(100, 0.03)
# pull the edges as a dict from edge to order
er_edges_dict = networkx.get_edge_attributes(er, 'added')
# return a list of edges in order of addition
er_edges = sorted(er_edges_dict.keys(),
key = (lambda e: er_edges_dict[e]))
```

We can then generate and show the animation:

In [29]:

```
fig = plt.figure(figsize = (8, 6))
anim = animate_growing_graph(er, er_edges, fig, frames = 100)
IPython_display.display_animation(anim, default_mode = 'once')
```

Out[29]: