diff --git a/BLcourse2.3/01_one_dim.py b/BLcourse2.3/01_one_dim.py
index 0fbf7eeea9548f42b6bd2711f6989fc8577ee841..2d0a90394fe5aca797dae5843176f401c3b3418b 100644
--- a/BLcourse2.3/01_one_dim.py
+++ b/BLcourse2.3/01_one_dim.py
@@ -34,8 +34,10 @@
 # # Imports, helpers, setup
 
 # +
-# %matplotlib inline
+# %matplotlib widget
+# -
 
+# -
 import math
 from collections import defaultdict
 from pprint import pprint
@@ -45,43 +47,7 @@ import gpytorch
 from matplotlib import pyplot as plt
 from matplotlib import is_interactive
 
-
-def extract_model_params(model, raw=False) -> dict:
-    """Helper to convert model.named_parameters() to dict.
-
-    With raw=True, use
-        foo.bar.raw_param
-    else
-        foo.bar.param
-
-    See https://docs.gpytorch.ai/en/stable/examples/00_Basic_Usage/Hyperparameters.html#Raw-vs-Actual-Parameters
-    """
-    if raw:
-        return dict(
-            (p_name, p_val.item())
-            for p_name, p_val in model.named_parameters()
-        )
-    else:
-        out = dict()
-        # p_name = 'covar_module.base_kernel.raw_lengthscale'. Access
-        # model.covar_module.base_kernel.lengthscale (w/o the raw_)
-        for p_name, p_val in model.named_parameters():
-            # Yes, eval() hack. Sorry.
-            p_name = p_name.replace(".raw_", ".")
-            p_val = eval(f"model.{p_name}")
-            out[p_name] = p_val.item()
-        return out
-
-
-def plot_samples(ax, X_pred, samples, label=None, **kwds):
-    plot_kwds = dict(color="tab:green", alpha=0.3)
-    plot_kwds.update(kwds)
-
-    if label is None:
-        ax.plot(X_pred, samples.T, **plot_kwds)
-    else:
-        ax.plot(X_pred, samples[0, :], **plot_kwds, label=label)
-        ax.plot(X_pred, samples[1:, :].T, **plot_kwds, label="_")
+from utils import extract_model_params, plot_samples, ExactGPModel
 
 
 # Default float32 results in slightly noisy prior samples. Less so with
@@ -155,29 +121,6 @@ plt.scatter(X_train, y_train, marker="o", color="tab:blue")
 
 
 # +
-class ExactGPModel(gpytorch.models.ExactGP):
-    """API:
-
-    model.forward()             prior                   f_pred
-    model()                     posterior               f_pred
-
-    likelihood(model.forward()) prior with noise        y_pred
-    likelihood(model())         posterior with noise    y_pred
-    """
-
-    def __init__(self, X_train, y_train, likelihood):
-        super().__init__(X_train, y_train, likelihood)
-        self.mean_module = gpytorch.means.ConstantMean()
-        self.covar_module = gpytorch.kernels.ScaleKernel(
-            gpytorch.kernels.RBFKernel()
-        )
-
-    def forward(self, x):
-        mean_x = self.mean_module(x)
-        covar_x = self.covar_module(x)
-        return gpytorch.distributions.MultivariateNormal(mean_x, covar_x)
-
-
 likelihood = gpytorch.likelihoods.GaussianLikelihood()
 model = ExactGPModel(X_train, y_train, likelihood)
 # -
@@ -291,7 +234,9 @@ for ii in range(n_iter):
     for p_name, p_val in extract_model_params(model).items():
         history[p_name].append(p_val)
     history["loss"].append(loss.item())
+# -
 
+# +
 # Plot hyper params and loss (neg. log marginal likelihood) convergence
 ncols = len(history)
 fig, axs = plt.subplots(ncols=ncols, nrows=1, figsize=(ncols * 5, 5))
@@ -301,6 +246,10 @@ for ax, (p_name, p_lst) in zip(axs, history.items()):
     ax.set_xlabel("iterations")
 # -
 
+# +
+# Values of optimized hyper params
+pprint(extract_model_params(model, raw=False))
+# -
 
 # # Run prediction
 #
diff --git a/BLcourse2.3/02_two_dim.py b/BLcourse2.3/02_two_dim.py
index 4c0b1c5177cbe0968eae7600381774552b8db293..b8c9f2c23cd3e18cadcdff10d6c4449f3d309cf0 100644
--- a/BLcourse2.3/02_two_dim.py
+++ b/BLcourse2.3/02_two_dim.py
@@ -13,10 +13,9 @@
 #     name: python3
 # ---
 
-# +
-# %matplotlib inline
+# %matplotlib notebook
 
-import math
+# +
 from collections import defaultdict
 from pprint import pprint
 
@@ -24,59 +23,17 @@ import torch
 import gpytorch
 from matplotlib import pyplot as plt
 from matplotlib import is_interactive
+##from mpl_toolkits.mplot3d import Axes3D
 
 
-def extract_model_params(model, raw=False) -> dict:
-    """Helper to convert model.named_parameters() to dict.
-
-    With raw=True, use
-        foo.bar.raw_param
-    else
-        foo.bar.param
-
-    See https://docs.gpytorch.ai/en/stable/examples/00_Basic_Usage/Hyperparameters.html#Raw-vs-Actual-Parameters
-    """
-    if raw:
-        return dict(
-            (p_name, p_val.item())
-            for p_name, p_val in model.named_parameters()
-        )
-    else:
-        out = dict()
-        # p_name = 'covar_module.base_kernel.raw_lengthscale'. Access
-        # model.covar_module.base_kernel.lengthscale (w/o the raw_)
-        for p_name, p_val in model.named_parameters():
-            # Yes, eval() hack. Sorry.
-            p_name = p_name.replace(".raw_", ".")
-            p_val = eval(f"model.{p_name}")
-            out[p_name] = p_val.item()
-        return out
-
-
-# Default float32 results in slightly noisy prior samples. Less so with
-# float64. We get a warning with both
-#   .../lib/python3.11/site-packages/linear_operator/utils/cholesky.py:40:
-#       NumericalWarning: A not p.d., added jitter of 1.0e-08 to the diagonal
-# but the noise is smaller w/ float64. Reason must be that the `sample()`
-# method [1] calls `rsample()` [2] which performs a Cholesky decomposition of
-# the covariance matrix. The default in
-# np.random.default_rng().multivariate_normal() is method="svd", which is
-# slower but seemingly a bit more stable.
-#
-# [1] https://docs.gpytorch.ai/en/stable/distributions.html#gpytorch.distributions.MultivariateNormal.sample
-# [2] https://docs.gpytorch.ai/en/stable/distributions.html#gpytorch.distributions.MultivariateNormal.rsample
-torch.set_default_dtype(torch.float64)
+from utils import extract_model_params, plot_samples, ExactGPModel
+# -
+
 
+torch.set_default_dtype(torch.float64)
 torch.manual_seed(123)
-# -
 
 # # Generate toy 2D data
-#
-# Here we generate noisy 1D data `X_train`, `y_train` as well as an extended
-# x-axis `X_pred` which we use later for prediction also outside of the data
-# range (extrapolation). The data has a constant offset `const` which we use to
-# test learning a GP mean function $m(\ve x)$. We create a gap in the data to
-# show how the model uncertainty will behave there.
 
 
 # +
@@ -140,37 +97,6 @@ class MexicanHat(SurfaceData):
         return y * torch.cos(r) / r**2 - y * torch.sin(r) / r**3.0
 
 
-# -
-
-# # Define GP model
-
-
-# +
-class ExactGPModel(gpytorch.models.ExactGP):
-    """API:
-
-    model.forward()             prior                   f_pred
-    model()                     posterior               f_pred
-
-    likelihood(model.forward()) prior with noise        y_pred
-    likelihood(model())         posterior with noise    y_pred
-    """
-
-    def __init__(self, X_train, y_train, likelihood):
-        super().__init__(X_train, y_train, likelihood)
-        self.mean_module = gpytorch.means.ConstantMean()
-        self.covar_module = gpytorch.kernels.ScaleKernel(
-            gpytorch.kernels.RBFKernel()
-        )
-
-    def forward(self, x):
-        mean_x = self.mean_module(x)
-        covar_x = self.covar_module(x)
-        return gpytorch.distributions.MultivariateNormal(mean_x, covar_x)
-
-
-# -
-
 # +
 data_train = MexicanHat(
     xlim=[-15, 5], ylim=[-15, 25], nx=20, ny=20, mode="rand"
@@ -179,19 +105,25 @@ data_pred = MexicanHat(
     xlim=[-15, 5], ylim=[-15, 25], nx=100, ny=100, mode="grid"
 )
 
-
+# train inputs
 X_train = data_train.X
-f_train = data_train.z
-##y_train = f_train + torch.randn(size=f_train.shape) / 20
-y_train = f_train
-
-mask = (X_train[:, 0] < -5) & (X_train[:, 1] < 0)
-# apply mask
-X_train = X_train[~mask, :]
-y_train = y_train[~mask]
 
+# inputs for prediction and plotting
 X_pred = data_pred.X
 
+# noise-free train data
+##y_train = data_train.z
+
+# noisy train data
+y_train = data_train.z + torch.randn(size=data_train.z.shape) / 20
+
+# +
+# Cut out part of the train data to create out-of-distribution predictions
+##mask = (X_train[:, 0] < -5) & (X_train[:, 1] < 0)
+##X_train = X_train[~mask, :]
+##y_train = y_train[~mask]
+# -
+
 fig = plt.figure()
 ax = fig.add_subplot(projection="3d")
 ax.plot_surface(
@@ -206,12 +138,11 @@ ax.scatter(
 )
 ax.set_xlabel("X_0")
 ax.set_ylabel("X_1")
-# -
 
-# +
+# # Define GP model
+
 likelihood = gpytorch.likelihoods.GaussianLikelihood()
 model = ExactGPModel(X_train, y_train, likelihood)
-# -
 
 
 # Inspect the model
@@ -228,8 +159,6 @@ model.covar_module.outputscale = 1.0
 model.likelihood.noise_covar.noise = 0.1
 
 pprint(extract_model_params(model, raw=False))
-# -
-
 
 # +
 # Train mode
@@ -251,6 +180,7 @@ for ii in range(n_iter):
     for p_name, p_val in extract_model_params(model).items():
         history[p_name].append(p_val)
     history["loss"].append(loss.item())
+# -
 
 ncols = len(history)
 fig, axs = plt.subplots(ncols=ncols, nrows=1, figsize=(ncols * 5, 5))
@@ -258,8 +188,9 @@ for ax, (p_name, p_lst) in zip(axs, history.items()):
     ax.plot(p_lst)
     ax.set_title(p_name)
     ax.set_xlabel("iterations")
-# -
 
+# Values of optimized hyper params
+pprint(extract_model_params(model, raw=False))
 
 # # Run prediction
 
@@ -316,7 +247,6 @@ for ax, c in zip(axs, [c0, c1]):
     ax.set_ylabel("X_1")
     ax.scatter(x=X_train[:, 0], y=X_train[:, 1], color="white", alpha=0.2)
     fig.colorbar(c, ax=ax)
-# -
 
 # When running as script
 if not is_interactive():
diff --git a/BLcourse2.3/utils.py b/BLcourse2.3/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..e46ecafe5f3ff494ba332cb94e6216eefa2a48ff
--- /dev/null
+++ b/BLcourse2.3/utils.py
@@ -0,0 +1,62 @@
+import gpytorch
+
+
+def extract_model_params(model, raw=False) -> dict:
+    """Helper to convert model.named_parameters() to dict.
+
+    With raw=True, use
+        foo.bar.raw_param
+    else
+        foo.bar.param
+
+    See https://docs.gpytorch.ai/en/stable/examples/00_Basic_Usage/Hyperparameters.html#Raw-vs-Actual-Parameters
+    """
+    if raw:
+        return dict(
+            (p_name, p_val.item())
+            for p_name, p_val in model.named_parameters()
+        )
+    else:
+        out = dict()
+        # p_name = 'covar_module.base_kernel.raw_lengthscale'. Access
+        # model.covar_module.base_kernel.lengthscale (w/o the raw_)
+        for p_name, p_val in model.named_parameters():
+            # Yes, eval() hack. Sorry.
+            p_name = p_name.replace(".raw_", ".")
+            p_val = eval(f"model.{p_name}")
+            out[p_name] = p_val.item()
+        return out
+
+
+def plot_samples(ax, X_pred, samples, label=None, **kwds):
+    plot_kwds = dict(color="tab:green", alpha=0.3)
+    plot_kwds.update(kwds)
+
+    if label is None:
+        ax.plot(X_pred, samples.T, **plot_kwds)
+    else:
+        ax.plot(X_pred, samples[0, :], **plot_kwds, label=label)
+        ax.plot(X_pred, samples[1:, :].T, **plot_kwds, label="_")
+
+
+class ExactGPModel(gpytorch.models.ExactGP):
+    """API:
+
+    model.forward()             prior                   f_pred
+    model()                     posterior               f_pred
+
+    likelihood(model.forward()) prior with noise        y_pred
+    likelihood(model())         posterior with noise    y_pred
+    """
+
+    def __init__(self, X_train, y_train, likelihood):
+        super().__init__(X_train, y_train, likelihood)
+        self.mean_module = gpytorch.means.ConstantMean()
+        self.covar_module = gpytorch.kernels.ScaleKernel(
+            gpytorch.kernels.RBFKernel()
+        )
+
+    def forward(self, x):
+        mean_x = self.mean_module(x)
+        covar_x = self.covar_module(x)
+        return gpytorch.distributions.MultivariateNormal(mean_x, covar_x)