Graph Kernels#

Download this notebook: graph_kernels.ipynb

This notebook demonstrates how regression models can be constructed on the vertices of a graph using a Gaussian process with a Matérn kernel presented in Borovitskiy et al. [2021]. For a general discussion of the kernels supported within GPJax, see the kernels notebook.

import random

from utils import use_mpl_style

# Enable Float64 for more stable matrix inversions.
from jax import config
import jax.numpy as jnp
import jax.random as jr
from jaxtyping import install_import_hook
import matplotlib as mpl
import matplotlib.pyplot as plt
try:
    from myst_nb import glue
except ImportError:  # notebook downloaded and run outside the docs build
    def glue(*args, **kwargs):
        """No-op stand-in: gluing only matters when Sphinx renders this page."""
import networkx as nx

config.update("jax_enable_x64", True)


with install_import_hook("gpjax", "beartype.beartype"):
    import gpjax as gpx


# set the default style for plotting
use_mpl_style()

key = jr.key(42)

cols = mpl.rcParams["axes.prop_cycle"].by_key()["color"]

Graph construction#

Our graph \(\mathcal{G}=\lbrace V, E \rbrace\) comprises a set of vertices \(V = \lbrace v_1, v_2, \ldots, v_n\rbrace\) and edges \(E=\lbrace (v_i, v_j)\in V \ : \ i \neq j\rbrace\). In particular, we will consider a barbell graph that is an undirected graph containing two clusters of vertices with a single shared edge between the two clusters.

Contrary to the typical barbell graph, we’ll randomly remove a subset of 30 edges within each of the two clusters. Given the 40 vertices within the graph, this results in 351 edges as shown in Fig. 26.

vertex_per_side = 20
n_edges_to_remove = 30
p = 0.8

G = nx.barbell_graph(vertex_per_side, 0)

random.seed(123)
[G.remove_edge(*i) for i in random.sample(list(G.edges), n_edges_to_remove)]

pos = nx.spring_layout(G, seed=123)  # positions for all nodes

nx.draw(
    G, pos, node_size=100, node_color=cols[1], edge_color="black", with_labels=False
)
plt.show()
/home/runner/work/GPJax/GPJax/.venv/lib/python3.11/site-packages/IPython/core/pylabtools.py:170: UserWarning: There are no gridspecs with layoutgrids. Possibly did not call parent GridSpec with the "figure" keyword
  fig.canvas.print_figure(bytes_io, **kw)
../_images/04229fc8a1b5b209f5cebcb63946030a18ae2519fd1bd0df70fdc6c9d53fbb40.png

Fig. 26 A barbell graph on 40 vertices, after 30 randomly chosen edges have been removed from within its two clusters.#

Computing the graph Laplacian#

Graph kernels use the Laplacian matrix \(L\) to quantify the smoothness of a signal (or function) on a graph

(1)#\[ L=D-A, \]

where \(D\) is the diagonal degree matrix containing each vertices’ degree and \(A\) is the adjacency matrix that has an \((i,j)^{\text{th}}\) entry of 1 if \(v_i, v_j\) are connected and 0 otherwise. Networkx gives us an easy way to compute this.

L = nx.laplacian_matrix(G).toarray()

glue("graph-kernels-num-edges", f"{G.number_of_edges()}", display=False)

Simulating a signal on the graph#

Our task is to construct a Gaussian process \(f(\cdot)\) that maps from the graph’s vertex set \(V\) onto the real line. To that end, we begin by simulating a signal on the graph’s vertices that we will go on to try and predict. We use a single draw from a Gaussian process prior (Prior) to draw our response values \(\boldsymbol{y}\) where we hardcode parameter values. The prior’s covariance is supplied by GraphKernel, which takes the graph Laplacian in place of the usual Euclidean distance. The corresponding input value set for this model, denoted \(\boldsymbol{x}\), is the index set of the graph’s vertices.

x = jnp.arange(G.number_of_nodes()).reshape(-1, 1)

true_kernel = gpx.kernels.GraphKernel(
    laplacian=L,
    lengthscale=2.3,
    variance=3.2,
    smoothness=6.1,
)
prior = gpx.gps.Prior(mean_function=gpx.mean_functions.Zero(), kernel=true_kernel)

fx = prior(x)
y = fx.sample(key=key, sample_shape=(1,)).reshape(-1, 1)

D = gpx.Dataset(X=x, y=y)
/home/runner/work/GPJax/GPJax/.venv/lib/python3.11/site-packages/jaxtyping/_decorator.py:465: UserWarning: X is not of type float64. Got X.dtype=int64. This may lead to numerical instability. 
  out = fn(*args, **kwargs)

We can visualise this signal in Fig. 27.

nx.draw(G, pos, node_color=y, with_labels=False, alpha=0.5)

vmin, vmax = y.min(), y.max()
sm = plt.cm.ScalarMappable(
    cmap=plt.cm.inferno, norm=plt.Normalize(vmin=vmin, vmax=vmax)
)
sm.set_array([])
ax = plt.gca()
cbar = plt.colorbar(sm, ax=ax)
plt.show()
/home/runner/work/GPJax/GPJax/.venv/lib/python3.11/site-packages/IPython/core/pylabtools.py:170: UserWarning: There are no gridspecs with layoutgrids. Possibly did not call parent GridSpec with the "figure" keyword
  fig.canvas.print_figure(bytes_io, **kw)
../_images/416c39131b3d7ded32e2a009843f69fd0e9a11076e647be7c42f71fe855dfdda.png

Fig. 27 A single draw from the graph Matern prior, with each vertex coloured by its simulated response value.#

Constructing a graph Gaussian process#

With our dataset created, we proceed to define our posterior Gaussian process and optimise the model’s hyperparameters. Whilst our underlying space is the graph’s vertex set and is therefore non-Euclidean, our likelihood is still Gaussian and the model is still conjugate. For this reason, we simply perform gradient descent on the GP’s marginal log-likelihood term (conjugate_mll) as in the regression notebook. We do this using the L-BFGS-B optimiser exposed by fit_scipy.

likelihood = gpx.likelihoods.Gaussian()
kernel = gpx.kernels.GraphKernel(laplacian=L)
prior = gpx.gps.Prior(mean_function=gpx.mean_functions.Zero(), kernel=kernel)
posterior = prior * likelihood

For researchers and the curious reader, GPJax provides the ability to print the bibtex citation for objects such as the graph kernel through the cite() function.

print(gpx.cite(kernel))
@inproceedings{borovitskiy2021matern,
authors = {Borovitskiy, Viacheslav and Azangulov, Iskander and Terenin, Alexander and Mostowsky, Peter and Deisenroth, Marc and Durrande, Nicolas},
title = {Matérn Gaussian Processes on Graphs},
year = {2021},
booktitle = {International Conference on Artificial Intelligence and Statistics},
}

With a posterior defined, we can now optimise the model’s hyperparameters.

opt_posterior, training_history = gpx.fit_scipy(
    model=posterior,
    objective=lambda p, d: -gpx.objectives.conjugate_mll(p, d),
    train_data=D,
)
         Current function value: -113.647542
         Iterations: 89
         Function evaluations: 130
         Gradient evaluations: 120
/home/runner/work/GPJax/GPJax/.venv/lib/python3.11/site-packages/scipy/optimize/_minimize.py:779: OptimizeWarning: Desired error not necessarily achieved due to precision loss.
  res = _minimize_bfgs(fun, x0, args, jac, callback, **options)

Making predictions#

Having optimised our hyperparameters, we can now make predictions on the graph. Though we haven’t defined a training and testing dataset here, we’ll simply query the predictive posterior for the full graph to compare the root-mean-squared error (RMSE) of the model for the initialised parameters vs the optimised set.

initial_dist = likelihood(posterior(x, D))
predictive_dist = opt_posterior.likelihood(opt_posterior(x, D))

initial_mean = initial_dist.mean
learned_mean = predictive_dist.mean

rmse = lambda ytrue, ypred: jnp.sum(jnp.sqrt(jnp.square(ytrue - ypred)))

initial_rmse = jnp.sum(jnp.sqrt(jnp.square(y.squeeze() - initial_mean)))
learned_rmse = jnp.sum(jnp.sqrt(jnp.square(y.squeeze() - learned_mean)))
print(
    f"RMSE with initial parameters: {initial_rmse: .2f}\nRMSE with learned parameters:"
    f" {learned_rmse: .2f}"
)
RMSE with initial parameters:  7.53
RMSE with learned parameters:  0.20

We can also plot the source of error in our model’s predictions on the graph in Fig. 28.

error = jnp.abs(learned_mean - y.squeeze())

nx.draw(G, pos, node_color=error, with_labels=False, alpha=0.5)

vmin, vmax = error.min(), error.max()
sm = plt.cm.ScalarMappable(
    cmap=plt.cm.inferno, norm=plt.Normalize(vmin=vmin, vmax=vmax)
)
ax = plt.gca()
cbar = plt.colorbar(sm, ax=ax)
plt.show()
/home/runner/work/GPJax/GPJax/.venv/lib/python3.11/site-packages/IPython/core/pylabtools.py:170: UserWarning: There are no gridspecs with layoutgrids. Possibly did not call parent GridSpec with the "figure" keyword
  fig.canvas.print_figure(bytes_io, **kw)
../_images/6a5452cf29e4559d700f8611fa4956dcca488300243cb75cab06d14df90c5d74.png

Fig. 28 Absolute error between the optimised posterior mean and the simulated signal at each vertex of the graph.#

Reassuringly, our model seems to provide equally good predictions in each cluster.

System configuration#

%reload_ext watermark
%watermark -n -u -v -iv -w -a 'Thomas Pinder'
Author: Thomas Pinder

Last updated: Fri, 07 Aug 2026

Python implementation: CPython
Python version       : 3.11.15
IPython version      : 9.15.0

gpjax     : 0.18.0
jax       : 0.10.2
jaxtyping : 0.3.11
matplotlib: 3.11.1
myst_nb   : 1.4.0
networkx  : 3.6.1

Watermark: 2.6.0