```python
import random

%matplotlib inline
import networkx as nx
```

# Chapter 6 Tutorial

Contents:

1. Partitions
2. Modularity
3. Zachary's Karate Club
4. Girvan-Newman clustering algorithm

## 1. Partitions

A **partition** of a graph is a separation of its nodes into disjoint groups. Consider the following graph:


```python
G = nx.Graph()
G.add_cycle([0, 1, 2, 3])
G.add_cycle([4, 5, 6, 7])
G.add_edge(0, 7)

nx.draw(G, with_labels=True)
```

The following is an example of a partition of these nodes:


```python
partition = [
    {1, 2, 3},
    {4, 5, 6},
    {0, 7},
]
```

Observe that every node in the graph is in exactly one of the sets in the partition. Formally, a partition is a list of sets such that every node is in exactly one set. NetworkX can verify that our partition is valid:


```python
nx.community.is_partition(G, partition)
```




    True



When developing community detection algorithms, we often make use of a *partition map*, which is a dictionary mapping node names to a partition index. This is useful for quickly comparing if two nodes are in the same cluster in the partition:


```python
partition_map = {}
for idx, cluster_nodes in enumerate(partition):
    for node in cluster_nodes:
        partition_map[node] = idx

partition_map
```




    {1: 0, 2: 0, 3: 0, 4: 1, 5: 1, 6: 1, 0: 2, 7: 2}



In this dictionary, the keys are the node names and two nodes will have the same value if they are in the same partition:


```python
partition_map[0] == partition_map[7]
```




    True



We can visualize our partition by drawing the graph with nodes colored by their partition membership:


```python
node_colors = [partition_map[n] for n in G.nodes]
        
nx.draw(G, node_color=node_colors, with_labels=True)
```

There are two trivial partitions:

1. The partition with one set containing every node;
2. The partition with N sets, each containing a single node.

A valid partition thus contains between 1 and N sets.

Feel free to experiment by changing the partition above and running the subsequent cells.

## 2. Modularity

At a high level, network community detection consists of finding a partition that achieves good separation between the groups of nodes. Before we get into how to find good partitions of a graph, we need an objective -- a way to measure how good the partition is. Modularity is one such objective function.

The modularity of a graph partition compares the number of intra-group edges with a random baseline. Higher modularity scores correspond to a higher proportion of intra-group edges, therefore fewer inter-group edges and better separation of groups.

For weighted undirected networks, as described in the text, we have
\begin{equation}
    Q_w=\frac{1}{W}\sum_C \left(W_C-\frac{s_C^2}{4W}\right),
    \label{eq:wmodul}
\end{equation}
where 
* $W$ is the total weight of the links of the network,
* $W_C$ the total weight of the internal links of cluster $C$, and
* $s_C$ the total strength of the nodes of $C$.

The total weight $W$ is half the total strength for the same reason that the number of edges $L$ is half the total degree. While this formula may look a bit complicated, it's straightforward to write code to compute the sum:


```python
def modularity(G, partition):
    W = sum(G.edges[v, w].get('weight', 1) for v, w in G.edges)
    summation = 0
    for cluster_nodes in partition:
        s_c = sum(G.degree(n, weight='weight') for n in cluster_nodes)
        # Use subgraph to count only internal links
        C = G.subgraph(cluster_nodes)
        W_c = sum(C.edges[v, w].get('weight', 1) for v, w in C.edges)
        summation += W_c - s_c ** 2 / (4 * W)
    
    return summation / W
```


```python
modularity(G, partition)
```




    0.2222222222222222



Let's compare this to a partition we would suspect to have higher modularity:


```python
partition_2 = [
    {0, 1, 2, 3},
    {4, 5, 6, 7},
]
modularity(G, partition_2)
```




    0.38888888888888884



### NetworkX function

NetworkX provides a modularity function that is more efficient than ours:


```python
nx.community.quality.modularity(G, partition_2)
```




    0.3888888888888889



## 3. Zachary's Karate Club

When writing and testing community-detection algorithms, it helps to make use of benchmark networks: graphs with a known, "natural" community structure. Perhaps the most famous benchmark graph is Zachary's Karate Club. It contains 34 nodes, representing members of a karate club whose interactions were monitored over a period of three years by researchers. Links in this graph connect individuals interacting outside club activities, a proxy for social ties.

During the course of the study, a conflict between the instructor Mr. Hi (node 0) and the president, or Officer (node 33) led to a split of the club into separate groups led by Mr. Hi and Officer. In this case we know whom each member of the group followed after the split, providing empirical community labels: those members who followed Mr. Hi are said to be one community and those following the Officer make up the other.

For this graph, we assume that the post-split group composition was largely driven by the social ties: members of the same friend groups would want to be part of the same club after the split. We thus expect a good community-detection algorithm to predict the post-split group composition with high accuracy.

Zachary's karate club is such a popular benchmark graph that it has its own function in NetworkX:


```python
K = nx.karate_club_graph()
nx.draw(K, with_labels=True)
```

Each node in a NetworkX graph has a dictionary of *attributes* associated with it. This dictionary can hold arbitrary data about a node. We can get the attributes for a single node by giving the node name to the `nodes` object.

Each node in this graph has a `'club'` attribute, indicating whether the member followed the instructor or the president after the split:


```python
K.nodes[0]
```




    {'club': 'Mr. Hi'}




```python
K.node[9]
```




    {'club': 'Officer'}



We can visualize these labels by coloring each node according to its `'club'` attribute:


```python
K = nx.karate_club_graph()
club_color = {
    'Mr. Hi': 'orange',
    'Officer': 'lightblue',
}
node_colors = [club_color[K.node[n]['club']] for n in K.nodes]
nx.draw(K, node_color=node_colors, with_labels=True)
```

This separation looks good, in that there are relatively few inter-community links as opposed to intra-community links. Let's create a graph partition based on these labels and measure its modularity.

We can do this by creating a dictionary of two sets, one for each value of the nodes' `'club'` attribute, then assigning the nodes to the corresponding set. 


```python
groups = {
    'Mr. Hi': set(),
    'Officer': set(),
}

for n in K.nodes:
    club = K.node[n]['club']
    groups[club].add(n)
    
groups
```




    {'Mr. Hi': {0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 16, 17, 19, 21},
     'Officer': {9, 14, 15, 18, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33}}



By using the dictionary's `.values()` method, we can get a list of sets that define our partition:


```python
empirical_partition = list(groups.values())
empirical_partition
```




    [{0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 16, 17, 19, 21},
     {9, 14, 15, 18, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33}]




```python
nx.community.is_partition(K, empirical_partition)
```




    True



Since our partition is indeed a valid partition, we can get the modularity of this partition:


```python
nx.community.quality.modularity(K, empirical_partition)
```




    0.35823471400394263



This is a relatively high modularity, which is what we expect.

### Comparison to a random partition

For the sake of comparison, let's generate a random partition of this network and check its modularity. We would expect a modularity close to zero in this case.

First we generate a sample of 17 nodes, half the total number of nodes, and assign them to one community. Our second community then includes the nodes in the graph not in the first community. We can use some set arithmetic to do this concisely:


```python
random_nodes = random.sample(K.nodes, 17)
random_partition = [set(random_nodes),
                    set(K.nodes) - set(random_nodes)]
random_partition
```




    [{0, 1, 4, 5, 7, 9, 12, 15, 16, 17, 21, 24, 26, 27, 28, 31, 32},
     {2, 3, 6, 8, 10, 11, 13, 14, 18, 19, 20, 22, 23, 25, 29, 30, 33}]



We can visualize this partition and observe that the communities are much less natural-looking, as we would expect from a random assignment.


```python
random_node_colors = ['orange' if n in random_nodes else 'lightblue' for n in K.nodes]
nx.draw(K, node_color=random_node_colors)
```

And finally we can test the modularity of this partition:


```python
nx.community.quality.modularity(K, random_partition)
```




    -0.06410256410256385



Since this is a random process the modularity won't be exactly zero, but it should be fairly close. Go ahead and repeat the process of generating a random partition and testing its modularity -- it will fluctuate around its mean value of zero.

# 4. Girvan-Newman clustering

Our task in this part will be to implement the Girvan-Newman clustering algorithm. Since NetworkX can do the heavy lifting for us -- computing betweenness centrality -- the code part of the task is relatively straightforward. Most of our effort here is spent interpreting and explaining intermediate results.

Recall from the text the Girvan-Newman clustering algorithm:

1. Create a partition sequence
  1. Calculate the betweenness centrality for all links.
  2. Remove the link with largest betweenness and create a partition using connected components.
  3. Recalculate the betweenness centrality of the links of the resulting graph.
  4. Repeat from step B until no links remain.
2. Evaluate each partition in the sequence and choose the one with the highest modularity.

During this process, the number of connected components in the graph will increase monotonically as clusters are broken up. Since we are removing one link at a time, the number of connected components can increase by at most one between steps in the sequence -- it's not possible for a single edge to connect more than two nodes, and thus components.

We hope that the resulting partition of the graph will approximate its underlying community structure. We'll use the Karate Club graph here because we know the ground-truth community labels and can compare the result obtained from the algorithm.


```python
G = nx.karate_club_graph()
nx.draw(G)
```

## 4.1 Create a partition sequence
### A. Calculate the betweenness centrality for all links

NetworkX does the heavy lifting here. All we need to do is understand the output.


```python
nx.edge_betweenness_centrality(G)
```




    {(0, 1): 0.025252525252525245,
     (0, 2): 0.0777876807288572,
     (0, 3): 0.02049910873440285,
     (0, 4): 0.0522875816993464,
     (0, 5): 0.07813428401663694,
     (0, 6): 0.07813428401663695,
     (0, 7): 0.0228206434088787,
     (0, 8): 0.07423959482783014,
     (0, 10): 0.0522875816993464,
     (0, 11): 0.058823529411764705,
     (0, 12): 0.04652406417112298,
     (0, 13): 0.04237189825425121,
     (0, 17): 0.04012392835922248,
     (0, 19): 0.045936960642843,
     (0, 21): 0.040123928359222474,
     (0, 31): 0.1272599949070537,
     (1, 2): 0.023232323232323233,
     (1, 3): 0.0077243018419489,
     (1, 7): 0.007422969187675069,
     (1, 13): 0.01240556828792123,
     (1, 17): 0.01869960105254222,
     (1, 19): 0.014633732280791102,
     (1, 21): 0.01869960105254222,
     (1, 30): 0.032280791104320514,
     (2, 3): 0.022430184194890075,
     (2, 7): 0.025214328155504617,
     (2, 8): 0.009175791528732704,
     (2, 9): 0.030803836686189627,
     (2, 13): 0.007630931160342923,
     (2, 27): 0.04119203236850296,
     (2, 28): 0.02278244631185807,
     (2, 32): 0.06898678663384543,
     (3, 7): 0.003365588659706307,
     (3, 12): 0.012299465240641705,
     (3, 13): 0.01492233256939139,
     (4, 6): 0.0047534165181224,
     (4, 10): 0.0029708853238265,
     (5, 6): 0.0029708853238265003,
     (5, 10): 0.0047534165181224,
     (5, 16): 0.029411764705882353,
     (6, 16): 0.029411764705882353,
     (8, 30): 0.00980392156862745,
     (8, 32): 0.0304416716181422,
     (8, 33): 0.04043657867187279,
     (9, 33): 0.029615482556659026,
     (13, 33): 0.06782389723566191,
     (14, 32): 0.024083977025153497,
     (14, 33): 0.03473955238661121,
     (15, 32): 0.024083977025153497,
     (15, 33): 0.03473955238661121,
     (18, 32): 0.024083977025153497,
     (18, 33): 0.03473955238661121,
     (19, 33): 0.05938233879410351,
     (20, 32): 0.024083977025153497,
     (20, 33): 0.03473955238661121,
     (22, 32): 0.024083977025153493,
     (22, 33): 0.03473955238661121,
     (23, 25): 0.019776193305605066,
     (23, 27): 0.010536739948504653,
     (23, 29): 0.00665478312537136,
     (23, 32): 0.022341057635175278,
     (23, 33): 0.03266983561101209,
     (24, 25): 0.0042186571598336305,
     (24, 27): 0.018657159833630418,
     (24, 31): 0.040106951871657755,
     (25, 31): 0.04205783323430383,
     (26, 29): 0.004532722179781003,
     (26, 33): 0.0542908072319837,
     (27, 33): 0.030477039300568713,
     (28, 31): 0.0148544266191325,
     (28, 33): 0.024564977506153975,
     (29, 32): 0.023328523328523323,
     (29, 33): 0.029807882749059215,
     (30, 32): 0.01705288175876411,
     (30, 33): 0.02681436210847975,
     (31, 32): 0.04143394731630026,
     (31, 33): 0.05339388280564752,
     (32, 33): 0.008225108225108224}



The resulting dictionary has edge tuples as the keys, and each associated value is the betweenness centrality of that edge. The algorithm to compute the edge betweenness of all edges in a graph costs about the same as calculating it for a single edge, so we'll make use of this dictionary with the computed values for every edge.

Once computed for all edges, we can easily get the associated betweenness for a single edge. For example, to get the edge betweenness of the edge between nodes 0 and 1:


```python
my_edge_betweenness = nx.edge_betweenness_centrality(G)
my_edge_betweenness[0, 1]
```




    0.025252525252525245



Recall that dictionaries also have the `.get` method. This will be used in the next step.


```python
my_edge_betweenness.get((0, 1))
```




    0.025252525252525245



### B. Remove the link with largest betweenness...

Given this dictionary of betweenness values for each edge, we can make use of Python's builtin `max` function to give us the key in this dictionary with the greatest value. Since there is a key in the dictionary for each edge in the graph, the following two expressions are equivalent, but the second one is probably more explicit as to what we're doing with this statement.

I'm using the name `my_edge_betweenness` to make clear that this is a dictionary we've named and not a NetworkX function.


```python
max(my_edge_betweenness, key=my_edge_betweenness.get)
```




    (0, 31)




```python
max(G.edges(), key=my_edge_betweenness.get)
```




    (0, 31)



This is then the edge we want to remove at this step in the process:


```python
my_edge_betweenness = nx.edge_betweenness_centrality(G)
most_valuable_edge = max(G.edges(), key=my_edge_betweenness.get)
G.remove_edge(*most_valuable_edge)
```

The "splat" in the last statement above `G.remove_edge(*most_valuable_edge)` performs tuple unpacking into the arguments of a function. For example, if our most valuable edge is `(0, 31)`,

    G.remove_edge(*most_valuable_edge)
is the same as
    
    G.remove_edge(most_valuable_edge[0], most_valuable_edge[1])
or

    G.remove_edge(0, 31)

### B. (cont'd) ...and create a partition using connected components

This one is almost a gimme because the `nx.connected_components()` function gives us almost exactly what we want: 


```python
nx.connected_components(G)
```




    <generator object connected_components at 0x7f2e1ad741a8>



We just have to remember to ask for it in a list:


```python
list(nx.connected_components(G))
```




    [{0,
      1,
      2,
      3,
      4,
      5,
      6,
      7,
      8,
      9,
      10,
      11,
      12,
      13,
      14,
      15,
      16,
      17,
      18,
      19,
      20,
      21,
      22,
      23,
      24,
      25,
      26,
      27,
      28,
      29,
      30,
      31,
      32,
      33}]



Remember: a partition is a list of sets where every node is in exactly one of these sets. This is just what we have here, although it's a bit boring since we've only removed one edge and so there is still one connected component. If you like, you can try running the previous two cells a few times until you have more than one connected component so you can see what that looks like.

Note that this feature whereby the connected components correspond exactly to our putative community labels is particular to the Girvan-Newman algorithm: other clustering algorithms may use different ways of generating their partitions.

### C. Recalculate the betweenness centrality of the links of the resulting graph.
### D. Repeat from step B until no links remain.

This implies that we need a loop to repeat this process $L$ times, once for each edge, and that we should keep track of the partitions generated. Straightforward stuff. We'll start with a fresh Karate Club graph since we removed some edges above:


```python
G = nx.karate_club_graph()
partition_sequence = []
for _ in range(G.number_of_edges()):
    my_edge_betweenness = nx.edge_betweenness_centrality(G)
    most_valuable_edge = max(G.edges(), key=my_edge_betweenness.get)
    G.remove_edge(*most_valuable_edge)
    my_partition = list(nx.connected_components(G))
    partition_sequence.append(my_partition)
```

Note the idiomatic construction of this `for` loop. Using `_` as the name for the loop variable tells the reader that we don't expect to do anything with the loop variable -- we just want to perform a task a specific number of times. One might be tempted to use a `while` loop here, but that way lie dragons: a mistake in a `while` loop can lead to infinite loops which are a headache.

If we've done this right, we should have a partition for each step of the process, *i.e.* one for each edge in the graph:


```python
len(partition_sequence), nx.karate_club_graph().number_of_edges()
```




    (78, 78)



Since we started with a connected graph, removing one edge probably doesn't disconnect the graph, so our first partition probably only has one community:


```python
len(partition_sequence[0])
```




    1



...and the last partition should also be trivial, with each node in its own community:


```python
len(partition_sequence[-1]), nx.karate_club_graph().number_of_nodes()
```




    (34, 34)



## 4.2 Evaluate the modularity of each partition in the sequence

We now have a sequence of partitions and a function to calculate the modularity of a partition. This is a great time to use a list comprehension!


```python
G = nx.karate_club_graph()
modularity_sequence = [modularity(G, p) for p in partition_sequence]
modularity_sequence
```




    [0.0,
     0.0,
     0.0,
     0.0,
     0.0,
     0.0,
     0.0,
     0.0,
     0.0,
     0.0,
     0.3599605522682446,
     0.3599605522682446,
     0.3599605522682446,
     0.348783694937541,
     0.348783694937541,
     0.348783694937541,
     0.348783694937541,
     0.3632478632478632,
     0.3632478632478632,
     0.3632478632478632,
     0.3632478632478632,
     0.3632478632478632,
     0.3632478632478632,
     0.4012984878369493,
     0.3925049309664694,
     0.3925049309664694,
     0.3762327416173569,
     0.3762327416173569,
     0.35831689677843515,
     0.35831689677843515,
     0.34171597633136086,
     0.34171597633136086,
     0.3247863247863247,
     0.3247863247863247,
     0.3159105851413542,
     0.3159105851413542,
     0.2986522024983562,
     0.2986522024983562,
     0.28040762656147256,
     0.28040762656147256,
     0.26282051282051266,
     0.26282051282051266,
     0.24753451676528584,
     0.24753451676528584,
     0.22682445759368833,
     0.22682445759368833,
     0.20890861275476655,
     0.20890861275476655,
     0.1898422090729783,
     0.18129520052596976,
     0.18129520052596976,
     0.18129520052596976,
     0.1600920447074293,
     0.1600920447074293,
     0.1469428007889546,
     0.1469428007889546,
     0.1469428007889546,
     0.12031558185404337,
     0.12031558185404337,
     0.12031558185404337,
     0.1081525312294543,
     0.1081525312294543,
     0.09064760026298488,
     0.08029257067718606,
     0.06993754109138725,
     0.057856673241288625,
     0.057856673241288625,
     0.03418803418803419,
     0.0220249835634451,
     0.0220249835634451,
     0.0220249835634451,
     -0.002876397107166334,
     -0.002876397107166334,
     -0.026298487836949366,
     -0.03763971071663378,
     -0.03763971071663378,
     -0.053747534516765276,
     -0.04980276134122286]



This sequence is then the modularity of the partition at each step in the algorithm. The first several entries in this sequence are effectively zero while there is only one community/component, then it jumps up once there is more than one community. We can use pyplot to visualize this relationship:


```python
import matplotlib.pyplot as plt
plt.plot(modularity_sequence)
plt.ylabel('Modularity')
plt.xlabel('Algorithm step')
```

### Get the partition with highest modularity

Visually, we see a peak in the modularity sequence. This is the partition that maximizes modularity, and thus the output of the algorithm. We can use the `max` function to get the partition with highest modularity. Ideally we want to write the following:


```python
best_partition = max(partition_sequence, key=nx.community.quality.modularity)
```

...but we receive an error. Recall that a key function must take exactly one argument, the item in the sequence being evaluated, but the modularity function takes two arguments: the graph and the partition. We can fix this by writing a single-argument function to use as the key:


```python
def my_modularity(partition):
    return nx.community.quality.modularity(G, partition)
best_partition = max(partition_sequence, key=my_modularity)
```

Advanced Pythonauts will see a differet solution to this using the `zip` function to align the previously-generated partition & modularity sequences, but this solution is more explicit.

So after all that work, what is the best partition?


```python
best_partition
```




    [{0, 1, 3, 7, 11, 12, 13, 17, 19, 21},
     {2, 24, 25, 27, 28, 31},
     {4, 5, 6, 10, 16},
     {8, 14, 15, 18, 20, 22, 23, 26, 29, 30, 32, 33},
     {9}]



Interesting! The partition of the karate club graph with highest modularity actually has five components! Let's visualize them, using our code for partition maps we wrote back at the beginning of this tutorial:


```python
def create_partition_map(partition):
    partition_map = {}
    for idx, cluster_nodes in enumerate(partition):
        for node in cluster_nodes:
            partition_map[node] = idx
    return partition_map
```


```python
best_partition_map = create_partition_map(best_partition)

node_colors = [best_partition_map[n] for n in G.nodes()]
nx.draw(G, with_labels=True, node_color=node_colors)
```

Exactly how good is this five-community clustering?


```python
nx.community.quality.modularity(G, best_partition)
```




    0.40129848783694877



It's higher than the "ground truth" communities we evaluated in section 3, which is a good sign, but for the specific problem of trying to predict the post-split community membership, a clustering into five groups is useless to us.

### Get the best partition with a given number of communities

One of the most useful parts of the Girvan-Newman algorithm is that it is also useful when we have a specific number of clusters we want. In this case, we know the karate club split into two groups, so let's get the partition in the sequence with two components:


```python
for partition in partition_sequence:
    if len(partition) == 2:
        two_cluster_partition = partition
        break

two_cluster_partition
```




    [{0, 1, 3, 4, 5, 6, 7, 10, 11, 12, 13, 16, 17, 19, 21},
     {2, 8, 9, 14, 15, 18, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33}]




```python
two_cluster_partition_map = create_partition_map(two_cluster_partition)

node_colors = [two_cluster_partition_map[n] for n in G.nodes()]
nx.draw(G, with_labels=True, node_color=node_colors)
```

How good is this partition? We can get its modularity:


```python
nx.community.quality.modularity(G, two_cluster_partition)
```




    0.35996055226824225



Pretty good -- comparable to the ground truth community labels. Let's compare these side-by-side:


```python
import matplotlib.pyplot as plt

pos = nx.layout.spring_layout(G)
fig = plt.figure(figsize=(15, 6))

plt.subplot(1, 2, 1)
two_cluster_partition_map = create_partition_map(two_cluster_partition)
node_colors = [two_cluster_partition_map[n] for n in G.nodes()]
nx.draw(G, with_labels=True, node_color=node_colors, pos=pos)
plt.title('Predicted communities')

plt.subplot(1, 2, 2)
node_colors = [G.nodes[n]['club'] == 'Officer' for n in G.nodes()]
nx.draw(G, with_labels=True, node_color=node_colors, pos=pos)
plt.title('Actual communities')
```

We can see that the predicted community labels are pretty accurate, only differing on a couple nodes that, visually, seem like they could plausibly beling to either group. Zachary's original paper even explains the practical considerations of one of these mispredicted nodes: student 8 was very near receiving his black belt from Mr. Hi and thus did not want to leave the group even though several of his friends did.


```python
G.nodes[8]
```




    {'club': 'Mr. Hi'}



#### Aside

The astute reader might note that there may be several two-cluster partitions in the partition sequence we generated. We assert the following to be true:

1. For every integer 1 to N, the number of nodes, there is a partition in the sequence with that number of clusters
2. Every partition in the sequence with the same number of clusters is the same

Proving these is left as an exercise to the reader, but as a consequence of these being true, optimized implementations of Girvan-Newman clustering will only store one partition for each number of clusters. This is how the implementation in NetworkX works, only providing one partition for each number of communities greater than one.

## NetworkX Function

`nx.community.girvan_newman(G)` will generate a sequence containing one partition of each size greater than one. Here we can see the first several are the same as those we generated:


```python
list(nx.community.girvan_newman(G))[:5]
```




    [({0, 1, 3, 4, 5, 6, 7, 10, 11, 12, 13, 16, 17, 19, 21},
      {2, 8, 9, 14, 15, 18, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33}),
     ({0, 1, 3, 4, 5, 6, 7, 10, 11, 12, 13, 16, 17, 19, 21},
      {2, 8, 14, 15, 18, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33},
      {9}),
     ({0, 1, 3, 7, 11, 12, 13, 17, 19, 21},
      {2, 8, 14, 15, 18, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33},
      {4, 5, 6, 10, 16},
      {9}),
     ({0, 1, 3, 7, 11, 12, 13, 17, 19, 21},
      {2, 24, 25, 27, 28, 31},
      {4, 5, 6, 10, 16},
      {8, 14, 15, 18, 20, 22, 23, 26, 29, 30, 32, 33},
      {9}),
     ({0, 1, 3, 7, 12, 13, 17, 19, 21},
      {2, 24, 25, 27, 28, 31},
      {4, 5, 6, 10, 16},
      {8, 14, 15, 18, 20, 22, 23, 26, 29, 30, 32, 33},
      {9},
      {11})]




```python

```

fd9c06d9c6173dda3b4355cb51c69861a2c21eef
ipynb
Jupyter Notebook
tutorials/Chapter 6 Tutorial.ipynb
edwardelson/FirstCourseNetworkScience
392e58e51ab406e6efe0281f696e841625c2cdd3
CC-BY-4.0
2019-03-29T20:52:34.000Z
2022-03-26T20:02:32.000Z
tutorials/Chapter 6 Tutorial.ipynb
SchnecKen/FirstCourseNetworkScience
5c2328bc13c1f2269f8d4b1a23d550980a2473d4
CC-BY-4.0
2019-03-12T18:15:06.000Z
2022-03-26T19:04:05.000Z
tutorials/Chapter 6 Tutorial.ipynb
SchnecKen/FirstCourseNetworkScience
5c2328bc13c1f2269f8d4b1a23d550980a2473d4
CC-BY-4.0
2019-11-16T07:39:25.000Z
2022-03-31T22:33:47.000Z
Qwen/Qwen-72B
1. YES
2. YES