Spectral Graph Theory studies graphs using associated matrices such as the adjacency matrix and graph Laplacian. Let be a graph. We’ll let denote the number of vertices/nodes, and denote the number of edges. We’ll assume that vertices are indexed by , and edges are indexed by .
The adjacency matrix is a matrix with if is an edge, and if . If is an undirected graph, then is symmetric. If is directed, then need not be symmetric.
The degree of a node , is the number of neighbors of , meaning the number of edges which participates in. You can calculate the vector of degrees (a vector of length , where ), using matrix-vector mulpilication:
where 1 is the vector containing all 1s of length . You could also just sum the row entries of . We will also use - a diagonal matrix with .
The incidence matrix is a matrix which encodes how edges and vertices are related. Let be an edge. Then the -th column of is all zeros except , and (for undirected graphs, it doesn’t matter which of and is +1 and which is -1 as long as they have opposite signs). Note that acts as a sort of difference operator on functions of vertices, meaning is a vector of length which encodes the difference in fuction value over each edge.
You can check that , where is a connected component indicator ( if , and otherwise). is a connected component of the graph if all vertices in have a path between them, and there are no vertices in that are connected to which are not in . This implies .
The graph laplacian is an matrix . If the graph lies on a regular grid, then up to scaling by a finite difference width , but the graph laplacian is defined for all graphs.
Note that the nullspace of is the same as the nullspace of (the span of indicators on connected components).
In most cases, it makes sense to store all these matrices in sparse format.
import numpy as np
import scipy.linalg as la
import scipy.sparse as sparse
import scipy.sparse.linalg as sla
import matplotlib.pyplot as plt
import networkx as nx
from sklearn import metrics
from sklearn.cluster import KMeansG = nx.gnp_random_graph(100, 0.1)
nx.draw(G)
nx.adjacency_matrix(G)<100x100 sparse matrix of type '<class 'numpy.int64'>'
with 1006 stored elements in Compressed Sparse Row format>nx.incidence_matrix(G)<100x503 sparse matrix of type '<class 'numpy.float64'>'
with 1006 stored elements in Compressed Sparse Column format>nx.laplacian_matrix(G)<100x100 sparse matrix of type '<class 'numpy.int64'>'
with 1106 stored elements in Compressed Sparse Row format>Exercise¶
For an undirected graph , let and . Give an expression for the number of non-zeros in each of , , and in terms of and .
and both have non-zeros. has non-zeros.
Random Walks on Graphs¶
In a random walk on a graph, we consider an agent who starts at a vertex , and then will chose a random neighbor of and “walk” along the connecting edge. Typically, we will consider taking a walk where a neighbor is chosen uniformly at random (i.e. with probability ). We’ll assume that every vertex of the graph has at least one neighbor so makes sense.
This defines a Markov Chain with transition matrix (columns are scaled to 1). Note that even if is symmetric (for undirected graphs) that need not be symmetric because of the scaling by .
The stationary distribution of the random walk is the top eigenvector of , is guaranteed to have eigenvalue 1, and is guaranteed to have non-negative entries. If we scale so , The entry can be interpreted as the probability that a random walker which has walked for a very large number of steps is at vertex .
Page Rank¶
PageRank is an early algorithm that was used to rank websites for search engines. The internet can be viewed as a directed graph of websites where there is a directed edge if webpage links to webpage . In this case, we compute the degree vector using the out-degree (counting the number of links out of a webpage). Then the transition matrix on the directed adjacency matrix defines a random walk on webpages where a user randomly clicks links to get from webpage to webpage. The idea is that more authoritative websites will have more links to them, so a random web surfer will be more likely to end up with them.
One of the issues with this model is that it is easy for a random walker to get “stuck” at a webpage with no out-going links. The idea of PageRank is to add a probability that a web surfer will randomly go to another webpage which is not linked to by their current page. In this case, we can write the transition matrix
We then calculate the stationary vector of this matrix. Websites with a larger entry in are deemed more authoritative.
Note that because is sparse, you’ll typically want to encode as a linear operator (this takes the average of a vector, and broadcasts it to the appropriate shape). For internet-sized graphs this is a necessity.
Let’s look at the Les Miserables graph, which encodes interactions between characters in the novel Les Miserables by Victor Hugo.
G = nx.les_miserables_graph()
nx.draw_kamada_kawai(G)
G.nodes(data=True)NodeDataView({'Napoleon': {}, 'Myriel': {}, 'MlleBaptistine': {}, 'MmeMagloire': {}, 'CountessDeLo': {}, 'Geborand': {}, 'Champtercier': {}, 'Cravatte': {}, 'Count': {}, 'OldMan': {}, 'Valjean': {}, 'Labarre': {}, 'Marguerite': {}, 'MmeDeR': {}, 'Isabeau': {}, 'Gervais': {}, 'Listolier': {}, 'Tholomyes': {}, 'Fameuil': {}, 'Blacheville': {}, 'Favourite': {}, 'Dahlia': {}, 'Zephine': {}, 'Fantine': {}, 'MmeThenardier': {}, 'Thenardier': {}, 'Cosette': {}, 'Javert': {}, 'Fauchelevent': {}, 'Bamatabois': {}, 'Perpetue': {}, 'Simplice': {}, 'Scaufflaire': {}, 'Woman1': {}, 'Judge': {}, 'Champmathieu': {}, 'Brevet': {}, 'Chenildieu': {}, 'Cochepaille': {}, 'Pontmercy': {}, 'Boulatruelle': {}, 'Eponine': {}, 'Anzelma': {}, 'Woman2': {}, 'MotherInnocent': {}, 'Gribier': {}, 'MmeBurgon': {}, 'Jondrette': {}, 'Gavroche': {}, 'Gillenormand': {}, 'Magnon': {}, 'MlleGillenormand': {}, 'MmePontmercy': {}, 'MlleVaubois': {}, 'LtGillenormand': {}, 'Marius': {}, 'BaronessT': {}, 'Mabeuf': {}, 'Enjolras': {}, 'Combeferre': {}, 'Prouvaire': {}, 'Feuilly': {}, 'Courfeyrac': {}, 'Bahorel': {}, 'Bossuet': {}, 'Joly': {}, 'Grantaire': {}, 'MotherPlutarch': {}, 'Gueulemer': {}, 'Babet': {}, 'Claquesous': {}, 'Montparnasse': {}, 'Toussaint': {}, 'Child1': {}, 'Child2': {}, 'Brujon': {}, 'MmeHucheloup': {}})A = nx.adjacency_matrix(G)
A<77x77 sparse matrix of type '<class 'numpy.int64'>'
with 508 stored elements in Compressed Sparse Row format>n = A.shape[0]
d = A.sum(axis=1).reshape(1,-1) # compute degrees
Dinv = sparse.dia_matrix((1 / d, 0), shape=(n, n))
Dinv<77x77 sparse matrix of type '<class 'numpy.float64'>'
with 77 stored elements (1 diagonals) in DIAgonal format># works on square matrices or vectors
Onefun = lambda X : np.mean(X, axis=0).reshape(1,-1).repeat(X.shape[0], axis=0)
m = A.shape[0] # linear operator of shape of Adjacency matrix
OneOneT = sla.LinearOperator(
shape = (m,m),
matvec = Onefun,
rmatvec = Onefun
)
Let’s now construct the PageRank matrix and compute the top eigenpairs
alpha = 0.1
P = (1 - alpha) * sla.aslinearoperator(A @ Dinv) + alpha * OneOneT
lam, V = sla.eigs(P, k=5, which='LM')
lamarray([1. +0.j, 0.83936036+0.j, 0.79746166+0.j, 0.74936377+0.j,
0.70144813+0.j])1j**2 == -1Truex = np.abs(np.real(V[:,0])) # PageRank Vector
xarray([0.0133386 , 0.20399851, 0.0949893 , 0.10668613, 0.0133386 ,
0.0133386 , 0.0133386 , 0.0133386 , 0.01926114, 0.0133386 ,
0.57767244, 0.0107066 , 0.01679025, 0.0107066 , 0.0107066 ,
0.0107066 , 0.07701289, 0.08277018, 0.07701289, 0.07992839,
0.08300515, 0.08007966, 0.07716337, 0.15885074, 0.11720935,
0.20909641, 0.22214954, 0.1583424 , 0.06719718, 0.04831427,
0.01895798, 0.0377782 , 0.0107066 , 0.01702923, 0.06181196,
0.06181196, 0.05030959, 0.05030959, 0.05030959, 0.02101082,
0.01050109, 0.06584516, 0.02292668, 0.02325998, 0.02366606,
0.0160557 , 0.02666986, 0.01541702, 0.1673304 , 0.104165 ,
0.01375137, 0.09145499, 0.01729798, 0.01099474, 0.02347196,
0.31496208, 0.0133744 , 0.05356893, 0.23233548, 0.16932592,
0.05145195, 0.09653403, 0.21000629, 0.10143451, 0.16574335,
0.1099175 , 0.04528474, 0.01645582, 0.08526568, 0.09150015,
0.06900207, 0.04435378, 0.01961912, 0.02781421, 0.02781421,
0.0472697 , 0.02410199])i = np.argmax(x)
names = np.array([k for k, _ in G.nodes(data=True)])
names[i]'Valjean'perm = np.argsort(x)[::-1] # reverse sort
names[perm[:4]]array(['Valjean', 'Marius', 'Enjolras', 'Cosette'], dtype='<U16')The Graph Laplacian¶
Spectral Embeddings¶
Spectral embeddings are one way of obtaining locations of vertices of a graph for visualization. One way is to pretend that all edges are Hooke’s law springs, and to minimize the potential energy of a configuration of vertex locations subject to the constraint that we can’t have all points in the same location.
In one dimension:
Note that the objective function is a quadratic form on the embedding vector :
Because the vector 1 is in the nullspace of , this is equivalent to finding the eigenvector with second-smallest eigenvalue.
For a higher-dimensional embedding, we can use the eigenvectors for the next-largest eigenvalues.
Attention: the first formula is not shown in the current notebook!
G = nx.grid_2d_graph(10,10)
L = nx.laplacian_matrix(G)
L = sparse.csr_matrix(L, dtype=np.float64)lam, V = sla.eigsh(L, which='SM')
plt.scatter(V[:,1], V[:,2])
plt.show()
nx.draw_spectral(G)
Spectral Clustering¶
Spectral clustering refers to using a spectral embedding to cluster nodes in a graph. Let with . We will denote
One way to try to find clusters is to attempt to find a set of nodes with , so that we minimize the cut objective
The Cheeger inequality bounds the second-smallest eigenvalue of in terms of the optimal value of . In fact, the way to construct a partition of the graph which is close to the optimal clustering minimizing is to look at the eigenvector associated with the second smallest eigenvalue, and let .
As an example, let’s look at a graph generated by a stochastic block model with two clusters. The “ground-truth” clusters are the ground-truth communities in the model.
ns = [25, 25] # size of clusters
ps = [[0.3, 0.1], [0.1, 0.3]] # probability of edge
G = nx.stochastic_block_model(ns, ps)
true_clusters = [c for _, c in nx.get_node_attributes(G, 'block').items()]
nx.draw_kamada_kawai(G, with_labels=False, node_color=true_clusters)
plt.title("SBM with ground-truth clusters")
plt.show()
Now, let’s use spectral clustering to partition into two clusters
lam, V = sla.eigsh(nx.laplacian_matrix(G).astype(np.float64), which='SM')
x = V[:,1]
cs = x < 0 # get clusters
nx.draw_kamada_kawai(G, with_labels=False, node_color=cs)
plt.title("SBM with spectral clusters")
plt.show()
We’ll use the adjusted rand index to measure the quality of the clustering we obtained. A value of 1 means that we found the true clusters.
from sklearn import metrics
metrics.adjusted_rand_score(true_clusters, cs)-0.014331210191082872In general, you should use a dimension embedding when looking for clusters (so we used a dimension 1 embedding for 2 clusters). Let’s look at 4 clusters in a SBM
nclusters = 4
ns = [25 for i in range(nclusters)] # size of clusters
ps = 0.02 * np.ones((nclusters, nclusters)) + 0.25 * np.eye(nclusters)
G = nx.stochastic_block_model(ns, ps)
true_clusters = [c for _, c in nx.get_node_attributes(G, 'block').items()]
nx.draw_circular(G, with_labels=False, node_color=true_clusters)
plt.title("SBM with ground-truth clusters")
plt.show()
we’ll use K-means clustering in scikit learn to assign clusters.
from sklearn.cluster import KMeans
lam, V = sla.eigsh(nx.laplacian_matrix(G).astype(np.float64), k=nclusters+1, which='SM')
X = V[:,1:]
cs = KMeans(n_clusters=nclusters).fit_predict(X) # get clusters
nx.draw_circular(G, with_labels=False, node_color=cs)
plt.title("SBM with spectral clusters")
plt.show()
metrics.adjusted_rand_score(true_clusters, cs)0.97305763367046Exercise¶
We’ll consider the stochastic block model with clusters and nodes per cluster.
Let be denote the probability of an edge between nodes in the same cluster, and denote the probability of an edge between nodes in different clusters.
Plot a phase diagram of the adjusted rand index (ARI) score as and both vary in the range