Repository navigation
Expand file tree
/
Copy pathregression.py
More file actions
312 lines (268 loc) · 10.8 KB
/
Copy pathregression.py
File metadata and controls
312 lines (268 loc) · 10.8 KB
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# ---
# jupyter:
# jupytext:
# cell_metadata_filter: -all
# custom_cell_magics: kql
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.11.2
# kernelspec:
# display_name: Python 3
# language: python
# name: python3
# ---
# %% [markdown]
# # Regression
#
# Download this notebook: {nb-download}`regression.ipynb`
#
# In this notebook we demonstrate how to fit a Gaussian process regression model. We
# move quickly through the theory; if any of it is unfamiliar, our
# [introduction to Gaussian processes](intro_to_gps.py) derives the same model from
# Bayes' theorem.
# %% tags=["remove-cell"]
import logging
# The build host may not have the ledger fonts (Public Sans, Spectral); hide
# matplotlib's font fallback messages.
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
# %%
# 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
from utils import clean_legend
config.update("jax_enable_x64", True)
with install_import_hook("gpjax", "beartype.beartype"):
import gpjax as gpx
key = jr.key(123)
# set the default style for plotting
gpx.plotting.use_style()
cols = mpl.rcParams["axes.prop_cycle"].by_key()["color"]
# %% [markdown]
# ## Dataset
#
# With the necessary modules imported, we simulate a dataset
# $\mathcal{D} = (\boldsymbol{x}, \boldsymbol{y}) = \{(x_i, y_i)\}_{i=1}^{100}$ with inputs $\boldsymbol{x}$
# sampled uniformly on $(-3., 3)$ and corresponding independent noisy outputs
#
# $$
# \boldsymbol{y} \sim \mathcal{N} \left(\sin(4\boldsymbol{x}) + \cos(2 \boldsymbol{x}), \textbf{I} * 0.3^2 \right).
# $$ (eq-regression-data-generating)
#
# We store our data $\mathcal{D}$ as a GPJax [`Dataset`](#gpjax.dataset.Dataset) and
# create test inputs and labels for later.
# %%
n = 100
noise = 0.3
key, subkey = jr.split(key)
x = jr.uniform(key=key, minval=-3.0, maxval=3.0, shape=(n,)).reshape(-1, 1)
f = lambda x: jnp.sin(4 * x) + jnp.cos(2 * x)
signal = f(x)
y = signal + jr.normal(subkey, shape=signal.shape) * noise
D = gpx.Dataset(X=x, y=y)
xtest = jnp.linspace(-3.5, 3.5, 500).reshape(-1, 1)
ytest = f(xtest)
# %% [markdown]
# To better understand what we have simulated, we plot both the underlying latent
# function and the observed data that is subject to Gaussian noise.
# %% mystnb={"figure": {"caption": "The simulated dataset: 100 noisy observations scattered around the latent function that generated them.", "name": "fig-regression-simulated-data"}}
fig, ax = plt.subplots()
ax.plot(x, y, "o", label="Observations", color=cols[0])
ax.plot(xtest, ytest, label="Latent function", color=cols[1])
ax.legend(loc="best")
plt.show()
# %% [markdown]
# Our aim in this tutorial will be to reconstruct the latent function from our noisy
# observations $\mathcal{D}$ via Gaussian process regression. We begin by defining a
# Gaussian process prior in the next section.
#
# ## Defining the prior
#
# A zero-mean Gaussian process (GP) places a prior distribution over real-valued
# functions $f(\cdot)$ where
# $f(\boldsymbol{x}) \sim \mathcal{N}(0, \mathbf{K}_{\boldsymbol{x}\boldsymbol{x}})$
# for any finite collection of inputs $\boldsymbol{x}$.
#
# Here $\mathbf{K}_{\boldsymbol{x}\boldsymbol{x}}$ is the Gram matrix generated by a
# user-specified symmetric, non-negative definite kernel function $k(\cdot, \cdot')$
# with $[\mathbf{K}_{\boldsymbol{x}\boldsymbol{x}}]_{i, j} = k(x_i, x_j)$.
# The choice of kernel function is critical as, among other things, it governs the
# smoothness of the outputs that our GP can generate. Our
# [introduction to kernels](intro_to_kernels.py) works through what each kernel assumes
# and how to pick between them.
#
# For simplicity, we consider a radial basis function (RBF) kernel:
#
# $$
# k(x, x') = \sigma^2 \exp\left(-\frac{\lVert x - x' \rVert_2^2}{2 \ell^2}\right).
# $$ (eq-regression-rbf-kernel)
#
# On paper a GP is written as $f(\cdot) \sim \mathcal{GP}(\textbf{0}, k(\cdot, \cdot'))$,
# we can reciprocate this process in GPJax via defining a
# [`Prior`](#gpjax.gps.Prior) with our chosen [`RBF`](#gpjax.kernels.RBF) kernel.
# %%
kernel = gpx.kernels.RBF() # 1-dimensional input
meanf = gpx.mean_functions.Zero()
prior = gpx.gps.Prior(mean_function=meanf, kernel=kernel)
# %% [markdown]
#
# The above construction forms the foundation for GPJax's models. Moreover, the GP prior
# we have just defined can be represented by a
# [TensorFlow Probability](https://www.tensorflow.org/probability/api_docs/python/tfp/substrates/jax)
# multivariate Gaussian distribution. Such functionality enables trivial sampling, and
# the evaluation of the GP's mean and covariance.
#
# Since we want to sample from the full posterior, we need to calculate the full covariance matrix.
# We can enforce this by including the `covariance = "dense"` attribute when predicting.
# Note this is what will be defaulted if left blank.
# %% mystnb={"figure": {"caption": "Twenty function samples drawn from the zero-mean RBF prior, shown alongside the prior mean and variance band.", "name": "fig-regression-prior-samples"}}
prior_dist = prior.predict(xtest, covariance="dense")
prior_mean = prior_dist.mean
prior_std = prior_dist.variance
samples = prior_dist.sample(key=key, sample_shape=(20,))
fig, ax = plt.subplots()
ax.plot(xtest, samples.T, alpha=0.5, color=cols[0], label="Prior samples")
ax.plot(xtest, prior_mean, color=cols[1], label="Prior mean")
ax.fill_between(
xtest.flatten(),
prior_mean - prior_std,
prior_mean + prior_std,
alpha=0.3,
color=cols[1],
label="Prior variance",
)
ax.legend(loc="best")
clean_legend(ax)
plt.show()
# %% [markdown]
# ## Constructing the posterior
#
# Having defined our GP, we proceed to define a description of our data
# $\mathcal{D}$ conditional on our knowledge of $f(\cdot)$ - this is exactly the
# notion of a likelihood function $p(\mathcal{D} | f(\cdot))$. Whilst the choice of
# likelihood is a critical in Bayesian modelling, for simplicity we consider a
# Gaussian with noise parameter $\alpha$
#
# $$
# p(\mathcal{D} | f(\cdot)) = \mathcal{N}(\boldsymbol{y}; f(\boldsymbol{x}), \textbf{I} \alpha^2).
# $$ (eq-regression-gaussian-likelihood)
#
# This is defined in GPJax through calling a
# [`Gaussian`](#gpjax.likelihoods.Gaussian) instance. The
# [likelihood guide](likelihoods_guide.py) covers the other likelihoods GPJax provides
# and what each one assumes about the observations.
# %%
likelihood = gpx.likelihoods.Gaussian()
# %% [markdown]
# The prior and likelihood together define the joint model over the latent
# function and the observations,
#
# $$
# p(f(\cdot), \mathcal{D}) = p(\mathcal{D} | f(\cdot))\, p(f(\cdot)).
# $$ (eq-regression-joint)
#
# Mimicking this equation, the joint model is established in GPJax through the
# `*` operator. Conditioning it on the data — `model.condition(D)`, or the
# operator form `model | D` that reads as $p(f \mid \mathcal{D})$ — yields the
# posterior process.
# %%
model = prior * likelihood
# %% [markdown]
# <!-- ## Hyperparameter optimisation
#
# Our kernel is parameterised by a length-scale $\ell^2$ and variance parameter
# $\sigma^2$, whilst our likelihood controls the observation noise with $\alpha^2$.
# Using Jax's automatic differentiation module, we can take derivatives of -->
#
# ## Parameter state
#
# As outlined in the [PyTrees](https://docs.jax.dev/en/latest/pytrees.html)
# documentation, parameters are contained within the model and for the leaves of the
# PyTree. Consequently, in this particular model, we have three parameters: the
# kernel lengthscale, kernel variance and the observation noise variance. Whilst
# we have initialised each of these to 1, we can learn Type 2 MLEs for each of
# these parameters by optimising the marginal log-likelihood (MLL).
# %%
print(-gpx.objectives.conjugate_mll(model, D))
# %% [markdown]
# We can now define an optimiser. For this example we'll use the `bfgs`
# optimiser.
# %%
opt_model, history = gpx.fit_scipy(
model=model,
objective=lambda p, d: -gpx.objectives.conjugate_mll(p, d),
train_data=D,
)
print(-gpx.objectives.conjugate_mll(opt_model, D))
# %% [markdown]
# To inspect the learned hyperparameters, we can render a summary table of the optimised
# posterior with [`gpx.summarise`](#gpjax.summary.summarise). This shows each
# parameter's constrained value, its bijector, and whether it is trainable — a quick
# sanity check after optimisation.
# %%
gpx.summarise(opt_model)
# %% [markdown]
# ## Prediction
#
# Equipped with the posterior and a set of optimised hyperparameter values, we are now
# in a position to query our GP's predictive distribution at novel test inputs. To do
# this, we condition the optimised model on the training data — this factorises
# the training covariance exactly once and caches it — and then query the
# resulting posterior process at the test inputs. Pushing the latent
# distribution through the likelihood yields the predictive distribution, upon
# which `mean` and `stddev` can be used to extract the predictive mean and
# standard deviation.
#
# We are only concerned here with the variance at the test points themselves,
# so we request the diagonal covariance via `covariance="diagonal"`.
# %%
posterior = opt_model.condition(D) # equivalently: opt_model | D
latent_dist = posterior(xtest, covariance="diagonal")
predictive_dist = opt_model.likelihood(latent_dist)
predictive_mean = predictive_dist.mean
predictive_std = jnp.sqrt(predictive_dist.variance)
# %% [markdown]
# With the predictions and their uncertainty acquired, we illustrate the GP's
# performance at explaining the data $\mathcal{D}$ and recovering the underlying
# latent function of interest.
# %% mystnb={"figure": {"caption": "The optimised posterior predictive mean and its two-sigma band, plotted against the observations and the true latent function.", "name": "fig-regression-posterior-predictive"}}
fig, ax = plt.subplots(figsize=(7.5, 2.5))
ax.plot(x, y, "x", label="Observations", color=cols[0], alpha=0.5)
ax.fill_between(
xtest.squeeze(),
predictive_mean - 2 * predictive_std,
predictive_mean + 2 * predictive_std,
alpha=0.2,
label="Two sigma",
color=cols[1],
)
ax.plot(
xtest,
predictive_mean - 2 * predictive_std,
linestyle="--",
linewidth=1,
color=cols[1],
)
ax.plot(
xtest,
predictive_mean + 2 * predictive_std,
linestyle="--",
linewidth=1,
color=cols[1],
)
ax.plot(
xtest, ytest, label="Latent function", color=cols[0], linestyle="--", linewidth=2
)
ax.plot(xtest, predictive_mean, label="Predictive mean", color=cols[1])
ax.legend(loc="center left", bbox_to_anchor=(0.975, 0.5))
plt.show()
# %% [markdown]
# ## System configuration
# %%
# %reload_ext watermark
# %watermark -n -u -v -iv -w -a 'Thomas Pinder & Daniel Dodd'