"""
This module contains the Gaussia Process Regressor class. For now, the only implemented
one is a modified version of the ``sklearn`` implementation, with some tweaks to allow
disabling input validation and implementing more control at the hyperparameter
optimization stage.
At the moment, the GPR kernel is a product of a constant kernel and an anisotropic
length-correlation one. Noise is treated as uncorrelated standard deviations for inputs,
and thus simply added to the kernel matrix diagonal.
"""
# Builtin
import warnings
from operator import itemgetter
from typing import Mapping
# External
import numpy as np
from scipy.linalg import cholesky, solve_triangular, cho_solve # type: ignore
from scipy.linalg.blas import dtrmm as tri_mul # type: ignore
from sklearn.base import clone # type: ignore
from sklearn.gaussian_process import GaussianProcessRegressor as sk_GPR # type: ignore
from sklearn.utils.validation import validate_data # type: ignore
# Local
from gpry.kernels import RBF, Matern, WhiteKernel, ConstantKernel as C
from gpry.tools import check_random_state
GPR_CHOLESKY_LOWER = True
EPS_SQ_NOISE = 1e-6 # diagonal term to be added when WhiteKernel used as noise
[docs]
class GaussianProcessRegressor(sk_GPR):
r"""
Modified version of the GaussianProcessRegressor of sklearn.
The implementation is based on Algorithm 2.1 of Gaussian Processes
for Machine Learning (GPML) by Rasmussen and Williams.
This modified interface provides, in addition to the sklearn-GPR:
* Re-implements the ``fit`` method to allow for more control in the noise (alpha)
update and the hyperparameter optimization.
* Implements derivative return values in the ``predict`` method, as well as a
``predict_std`` method to return the standard deviation of the target only
(useful for acquisition).
* In the relevant methods, exposes flags to disable input data validation, for
an additional speed boost.
Parameters
----------
kernel : kernel object, string, dict, optional (default: "RBF")
The kernel specifying the covariance function of the GP. If
"RBF"/"Matern" is passed, the asymmetric kernel::
ConstantKernel() * RBF/Matern()
is used as default, where the length correlation kernel is assumed anisotropic
if a list of bounds is passed using the ``length_scale_prior`` argument.
To pass different arguments to the kernel, e.g. ``nu=5/2`` for Matern, pass a
single-key dict as ``{"Matern": {"nu": 2.5}}"``. Note that the kernel's
hyperparameters are optimized during fitting.
output_scale_prior : tuple as (min, max), optional (default: [1e-2, 1e3])
Prior for the (non-squared) scale parameter, in normalised logp units.
length_scale_prior : tuple as (min, max), optional (default: [1e-3, 1e1])
Prior for the length parameters, as a fraction of the parameter priors sizes.
noise_level : float or array-like, optional (default: 1e-2)
Square-root of the value added to the diagonal of the kernel matrix
during fitting. Larger values correspond to increased noise level in the
observations and reduce potential numerical issue during fitting.
If an array is passed, it must have the same number of entries as the
data used for fitting and is used as datapoint-dependent noise level.
Note that this is equivalent to adding a WhiteKernel with c=noise_level.
noise_fixed : bool (default: True)
Whether the noise is treated as a fixed diagonal offset in the kernel matrix, or
an additive white noise term in the kernel. In the latter case, the value given
as ``noise_level`` is set as the upper bound.
optimizer : str or callable, optional (default: "fmin_l_bfgs_b")
Can either be one of the internally supported optimizers for optimizing
the kernel's parameters, specified by a string, or an externally
defined optimizer passed as a callable. If a callable is passed, it
must have the signature::
def optimizer(obj_func, initial_theta, bounds):
# * 'obj_func' is the objective function to be maximized, which
# takes the hyperparameters theta as parameter and an
# optional flag eval_gradient, which determines if the
# gradient is returned additionally to the function value
# * 'initial_theta': the initial value for theta, which can be
# used by local optimizers
# * 'bounds': the bounds on the values of theta
....
# Returned are the best found hyperparameters theta and
# the corresponding value of the target function.
return theta_opt, func_min
Per default, the 'fmin_l_bfgs_b' algorithm from scipy.optimize
is used. If None is passed, the kernel's parameters are kept fixed.
Available internal optimizers are::
'fmin_l_bfgs_b'
n_restarts_optimizer : int, optional (default: 0)
The number of restarts of the optimizer for finding the kernel's
parameters which maximize the log-marginal likelihood. The first run
of the optimizer is performed from the kernel's initial parameters,
the remaining ones (if any) from thetas sampled log-uniform randomly
from the space of allowed theta-values. If greater than 0, all bounds
must be finite. Note that n_restarts_optimizer == 0 implies that one
run is performed.
random_state : int or numpy.random.Generator, optional
The generator used to perform random operations of the GPR. If an integer is
given, it is used as a seed for the default global numpy random number generator.
Attributes
----------
X_train_ : array-like, shape = (n_samples, n_features)
(Possibly transformed) feature values in training data of the GPR (also required
for prediction). Mostly intended for internal use.
y_train_ : array-like, shape = (n_samples, [n_output_dims])
(Possibly transformed) target values in training data of the GPR (also required
for prediction). Mostly intended for internal use.
alpha : array-like, shape = (n_samples, [n_output_dims]) or scalar
The value which is added to the diagonal of the kernel. This is the
square of `noise_level`.
kernel_ : :mod:`kernels` object
The kernel used for prediction. The structure of the kernel is the
same as the one passed as parameter but with optimized hyperparameters.
alpha_ : array-like, shape = (n_samples, n_samples)
**Not to be confused with alpha!** The inverse Kernel matrix of the
training points multiplied with ``y_train_`` (Dual coefficients of
training data points in kernel space). Needed at prediction.
V_ : array-like, shape = (n_samples, n_samples)
Lower-triangular Cholesky decomposition of the inverse kernel in ``X_train_``
log_marginal_likelihood_value_ : float
The log-marginal-likelihood of ``self.kernel_.theta``
scales : tuple
Kernel scales as ``(output_scale, (length_scale_1, ...))``
"""
def __init__(
self,
kernel="RBF",
output_scale_prior=[1e-2, 1e3],
length_scale_prior=[1e-3, 1e1],
noise_level=1e-2,
noise_fixed=True,
optimizer="fmin_l_bfgs_b",
n_restarts_optimizer=0,
random_state=None,
):
self.n_eval = 0
self.n_eval_loglike = 0
self._fitted = False
self.kernel_ = None
# Auto-construct inbuilt kernels
if isinstance(kernel, str):
kernel = {kernel: {}}
if isinstance(kernel, Mapping):
if len(kernel) != 1:
raise ValueError("'kernel' must be a single-key dict.")
kernel_name = list(kernel)[0]
kernel_args = kernel[kernel_name] or {}
# Check if it's a supported kernel
try:
length_corr_kernel = {"rbf": RBF, "matern": Matern}[kernel_name.lower()]
except KeyError as excpt:
raise ValueError(
"Currently only 'RBF' and 'Matern' are "
f"supported as standard kernels. Got '{kernel_name}'."
) from excpt
# Build kernel
output_scale_init = np.sqrt(output_scale_prior[0] * output_scale_prior[1])
# Guaranteed to be n-dimensional, if initialised from SurrogateModel
length_scale_init = np.sqrt(
length_scale_prior[:, 0] * length_scale_prior[:, 1]
)
# Noise treatment
self.is_noise_in_kernel = not noise_fixed
if hasattr(noise_level, "__len__"):
raise TypeError(
"If noise is passed per training point, it needs to be fixed. i.e. "
"`noise_fixed=True`."
)
kernel = C(
output_scale_init**2,
[output_scale_prior[0] ** 2, output_scale_prior[1] ** 2],
) * length_corr_kernel(
length_scale_init,
prior_bounds=length_scale_prior,
**kernel_args,
)
# Use noise level as upper bound if added as additive kernel
if self.is_noise_in_kernel:
kernel += WhiteKernel(
noise_level=noise_level**2,
noise_level_bounds=(EPS_SQ_NOISE, noise_level**2),
)
sk_GPR.__init__(
self,
kernel=kernel,
alpha=noise_level**2 if not self.is_noise_in_kernel else EPS_SQ_NOISE,
optimizer=optimizer,
n_restarts_optimizer=n_restarts_optimizer,
normalize_y=False,
copy_X_train=True,
random_state=random_state,
)
def __repr__(self):
"""Print the current kernel."""
return str(self.kernel_)
@property
def scales(self):
"""
Kernel scales as ``(output_scale, (length_scale_1, ...))``.
"""
if self.kernel_ is None: # not fitted yet
length_kernel = self.kernel
else:
length_kernel = self.kernel_
if hasattr(length_kernel.k1, "k1"): # there is a noise term
length_kernel = length_kernel.k1
return (
np.sqrt(length_kernel.k1.constant_value),
np.array(length_kernel.k2.length_scale),
)
@property
def noise_level(self):
"""
Kernel noise level (not squared).
"""
if self.is_noise_in_kernel:
if self.kernel_ is None: # not fitted yet
kernel = self.kernel
else:
kernel = self.kernel_
return np.sqrt(kernel.k2.noise_level)
else:
return np.sqrt(self.alpha)
[docs]
def fit(self, X, y, noise_level=None, fit_hyperparameters=True, validate=True):
r"""
Re-implementation of the sk GPR fit method, that allows for updating the noise
level (as alpha), and exposes flags for input validation and hyperparameter
fitting.
If hyperparameters are kept constant, fitting here refers to the re-calculation of
the GPR inverse matrix :math:`(K(X,X)+\sigma_n^2 I)^{-1}` which is needed for
predictions.
The highest cost incurred by this method is the refitting of the GPR kernel
hyperparameters :math:`\theta`. It can be useful to disable it
(``fit_hyperparameters=False``) in cases where it is worth saving the
computational expense in exchange for a loss of information, such as when
performing parallelized active sampling (NB: this is only possible when the GPR
hyperparameters have been fit at least once).
If called with ``X=None, y=None``, it re-fits the model without adding new points.
Parameters
----------
X : array-like, shape = (n_samples, n_features), or None
Training data to append to the model.
y : array-like, shape = (n_samples, [n_output_dims]), or None
Target values to append to the data
noise_level : number, array-like, shape = (n_samples, [n_output_dims])
Uncorrelated standard deviation(s) to add to the diagonal part of the
covariance matrix. If an array is passed, it needs to have the same number of
entries as y. If None, the noise_level set in the instance is used (notice
that passing None is not recommended if a preprocessor for the GPR training
samples may have been refit after initialization). If a non-None value is
passed, it is advisable to refit the hyperparameters of the kernel.
fit_hyperparameters : Bool or dict (default: True)
Whether the GPR :math:`\theta`-parameters should be optimised. It can be
passed a dict, which is always interpreted as ``True``, containing arguments
for the ``_fit_hyperparameters`` method, namely ``n_restarts`` of the
optimizer, ``start_from_current`` if the first optimizer run should start
from the last optimum, or different ``hyperparameters_bounds``. E.g. to
perform a single GPR optimization run starting at the last hyperparameter
optimum:
``fit_hyperparameters={'start_from_current': True, 'n_restarts': 1}``.
validate : bool, default: True
If False, ``X`` and ``y`` are assumed to be correctly formatted, and no
checks are performed on them. Reduces overhead. Use only for repeated calls
when the input is programmatically generated to be correct at each stage.
Returns
-------
self : object
GaussianProcessRegressor class instance.
"""
if validate:
if self.kernel_.requires_vector_input:
dtype, ensure_2d = "numeric", True
else:
dtype, ensure_2d = None, False
X, y = validate_data(
self,
X,
y,
multi_output=True,
y_numeric=True,
ensure_2d=ensure_2d,
dtype=dtype,
)
if (X is None and y is not None) or (X is not None and y is None):
raise ValueError("Pass neither or both of X, y, but not just one of them.")
# If neither new points nor new noise nor hyperams fit, return
if X is None and noise_level is None and fit_hyperparameters is None:
return self
if X is not None:
self.X_train_ = np.copy(X) if self.copy_X_train else X
self.y_train_ = np.copy(y) if self.copy_X_train else y
if noise_level is not None:
if validate:
if (
np.iterable(noise_level)
and len(noise_level.shape[0]) != self.y_train_.shape[0]
):
if noise_level.shape[0] == 1:
noise_level = noise_level[0]
else:
raise ValueError(
"noise_level must be a scalar or an array with same number of"
f" entries as y. ({noise_level.shape[0]} != "
f"{self.y_train_.shape[0]})"
)
if hasattr(noise_level, "__len__") and self.is_noise_in_kernel:
raise TypeError(
"This GPR was initialized with a white noise kernel term. That is "
"incompatible with passing noise level per training point."
)
elif self.is_noise_in_kernel:
self.alpha = EPS_SQ_NOISE
else:
# The following line causes sometimes noise larger than the one passed.
self.alpha = max(np.array(noise_level) ** 2, EPS_SQ_NOISE)
if fit_hyperparameters is not False:
if self.is_noise_in_kernel:
# Used passed noise as an upper bound
k = self.kernel if self.kernel_ is None else self.kernel_
k.k2.noise_level_bounds = (
min(noise_level**2, EPS_SQ_NOISE * 0.99),
noise_level**2,
)
# Also lower the current noise if needed, in case it's over the bound
# after preprocessor is refit
k.noise_level = min(k.k2.noise_level, noise_level**2)
if fit_hyperparameters is True:
fit_hyperparameters = {}
elif not isinstance(fit_hyperparameters, Mapping):
raise TypeError(
"'fit_hyperparameters' kwarg must be bool|dict, but was "
f"{fit_hyperparameters}"
)
self.log_marginal_likelihood_value_ = self._fit_hyperparameters(
**fit_hyperparameters
)
else:
self.log_marginal_likelihood_value_ = self.log_marginal_likelihood(
self.kernel_.theta, clone_kernel=False
)
# Precompute quantities required for predictions which are independent
# of actual query points
# Alg. 2.1, page 19, line 2 -> L = cholesky(K + sigma^2 I)
# NB: if we got here before returning, we *need* to do this.
K = self.kernel_(self.X_train_)
K[np.diag_indices_from(K)] += self.alpha
try:
self.L_ = cholesky(K, lower=GPR_CHOLESKY_LOWER, check_finite=False)
self.V_ = solve_triangular(self.L_, np.eye(self.L_.shape[0]), lower=True)
except np.linalg.LinAlgError as exc:
exc.args = (
(
f"The kernel, {self.kernel_}, is not returning a positive "
"definite matrix. Try gradually increasing the 'alpha' "
"parameter of your GaussianProcessRegressor estimator."
),
) + exc.args
raise
# Alg 2.1, page 19, line 3 -> alpha = L^T \ (L \ y)
self.alpha_ = cho_solve(
(self.L_, GPR_CHOLESKY_LOWER),
self.y_train_,
check_finite=False,
)
return self
# Wrapper around log_marginal_likelihood to count the number of evaluations
[docs]
def log_marginal_likelihood(self, *args, **kwargs):
"""
Log-marginal likelihood of the kernel hyperparameters given the training data.
"""
self.n_eval_loglike += 1
return super().log_marginal_likelihood(*args, **kwargs)
def _fit_hyperparameters(
self,
start_from_current=True,
n_restarts=None,
hyperparameter_bounds=None,
):
r"""Optimizes the hyperparameters :math:`\theta` for the current training data.
The algorithm used to perform the optimization is very similar to the one provided
by Scikit-learn. The only major difference is, that gradient information is used
in addition to the values of the marginalized log-likelihood.
NB: This function does *NOT* update the precomputed kernel matrices. Do not call
outside self.fit
Parameters
----------
start_from_current : bool, default: True
Starts the first optimization run from the current hyperparameters (ignored
if not previously fitted).
n_restarts : int, default None
Number of restarts of the optimizer. If not defined, uses the one set at
instantiation. ``1`` means a single optimizer run.
hyperparameter_bounds : array-like, default: None
Bounds for the hyperparameters, if different from those declared at init.
Returns
-------
self
"""
if not self._fitted:
start_from_current = False
if n_restarts is None:
n_restarts = self.n_restarts_optimizer
no_optimizer = self.optimizer is None
no_hyperparams = self.kernel.n_dims == 0
no_restarts = n_restarts <= 0
if no_optimizer or no_hyperparams or no_restarts:
msg_reasons = []
if no_optimizer:
msg_reasons += ["no optimizer has been specified"]
if no_hyperparams:
msg_reasons += ["the kernel has no hyperparamenters"]
if no_restarts:
msg_reasons += ["the number of optimizer restarts requested is 0."]
warnings.warn(
f"Hyper-parameters not (re)fit. Reason(s): {'; '.join(msg_reasons)}."
)
self.log_marginal_likelihood_value_ = self.log_marginal_likelihood(
self.kernel_.theta, clone_kernel=False
)
self._update_model()
return self
# Choose hyperparameters based on maximizing the log-marginal
# likelihood (potentially starting from several initial values)
# We don't need to clone the kernel here, even if overwritten during optimization,
# because it will be recomputed in the final `log_marginal_likelihood` call.
def obj_func(theta, eval_gradient=True):
if eval_gradient:
lml, grad = self.log_marginal_likelihood(
theta, eval_gradient=True, clone_kernel=False
)
return -lml, -grad
else:
return -self.log_marginal_likelihood(theta, clone_kernel=False)
if self.kernel_ is None:
self.kernel_ = clone(self.kernel)
if hyperparameter_bounds is None:
hyperparameter_bounds = self.kernel_.bounds
else:
# TODO: validate dimensions!
pass
# If at least one run will be sampled from the prior, is has to be finite
if n_restarts - int(start_from_current):
if not np.isfinite(hyperparameter_bounds).all():
raise ValueError(
"There is at least one optimizer run the requires sampling from the "
"hyperparameters' prior, but it has not finite density, because not "
"all bounds are finite. You can pass some finite bounds manually "
"using ``hyperparameter_bounds``."
)
optima = []
self._rng = check_random_state(self.random_state)
for iteration in range(n_restarts):
if iteration == 0 and start_from_current:
# self.kernel_ guaranteed to exist because self.fitted checked above
theta_initial = self.kernel_.theta
else:
# Additional runs are performed from log-uniform chosen initial theta
k = 1
while k < 1e6: # if not, just give up silently
k += 1
theta_initial = self._rng.uniform(
hyperparameter_bounds[:, 0], hyperparameter_bounds[:, 1]
)
eval_value = obj_func(theta_initial, eval_gradient=False)
if np.isfinite(eval_value):
break
# Run the optimizer!
with warnings.catch_warnings():
warnings.simplefilter("ignore")
optima.append(
self._constrained_optimization(
obj_func, theta_initial, hyperparameter_bounds
)
)
# Select result from run with minimal (negative) log-marginal likelihood.
lml_values = list(map(itemgetter(1), optima))
self.kernel_.theta = optima[np.argmin(lml_values)][0]
self._fitted = True
# Reset pre-computed matrices
self.L_, self.V_, self.alpha_ = None, None, None
return -np.min(lml_values)
[docs]
def predict(
self,
X,
return_std=False,
return_mean_grad=False,
return_std_grad=False,
validate=True,
):
"""
Predict output for X.
Reimplementation of the sk-learn GPR method: in addition to the mean of the
predictive distribution, also its standard deviation (return_std=True), the
gradient of the mean and the standard-deviation with respect to X can be
optionally provided.
Parameters
----------
X : array-like, shape = (n_samples, n_features)
Query points where the GP is evaluated.
return_std : bool, default: False
If True, the standard-deviation of the predictive distribution at
the query points is returned along with the mean.
return_mean_grad : bool, default: False
Whether or not to return the gradient of the mean.
Only valid when X is a single point.
return_std_grad : bool, default: False
Whether or not to return the gradient of the std.
Only valid when X is a single point.
validate : bool, default: True
If False, ``X`` and ``y`` are assumed to be correctly formatted, and no
checks are performed on them. Reduces overhead. Use only for repeated calls
when the input is programmatically generated to be correct at each stage.
.. note::
Note that in contrast to the sklearn GP Regressor our implementation
cannot return the full covariance matrix. This is to save on some
complexity and since the full covariance cannot be calculated if
either values are infinite or a y-preprocessor is used.
Returns
-------
y_mean : array, shape = (n_samples, [n_output_dims])
Mean of predictive distribution a query points
y_std : array, shape = (n_samples,), optional
Standard deviation of predictive distribution at query points.
Only returned when return_std is True.
y_mean_grad : shape = (n_samples, n_features), optional
The gradient of the predicted mean.
y_std_grad : shape = (n_samples, n_features), optional
The gradient of the predicted std.
"""
self.n_eval += len(X)
if return_std_grad and not (return_std and return_mean_grad):
raise ValueError(
"Not returning std_gradient without returning "
"the std and the mean grad."
)
if X.shape[0] != 1 and (return_mean_grad or return_std_grad):
raise ValueError(
"Mean grad and std grad not implemented \
for n_samples > 1"
)
if validate:
if self.kernel is None or self.kernel.requires_vector_input:
dtype, ensure_2d = "numeric", True
else:
dtype, ensure_2d = None, False
X = validate_data(self, X, ensure_2d=ensure_2d, dtype=dtype, reset=False)
# If not fit yet, predict based on GP prior
if not hasattr(self, "X_train_"):
y_mean = np.zeros(X.shape[0])
if return_std:
y_var = self.kernel.diag(X)
y_std = np.sqrt(y_var)
if not return_mean_grad and not return_std_grad:
return y_mean, y_std
if return_mean_grad:
mean_grad = np.zeros_like(X)
if return_std:
if return_std_grad:
std_grad = np.zeros_like(X)
return y_mean, y_std, mean_grad, std_grad
else:
return y_mean, y_std, mean_grad
else:
return y_mean, mean_grad
else:
return y_mean
# If already fit, use GP posterior to predict
# Alg 2.1, page 19, line 4 -> f*_bar = K(X_test, X_train) . alpha
# NB: there is no y-normalisation here: the data are passed normalised to the GPR
K_trans = self.kernel_(X, self.X_train_)
y_mean = K_trans.dot(self.alpha_)
return_values = [y_mean]
if return_std:
# Compute variance of predictive distribution
# Use einsum to avoid explicitly forming the large matrix
# V^T @ V just to extract its diagonal afterward.
M = tri_mul(1.0, self.V_, K_trans.T, lower=True)
y_var = self.kernel_.diag(X).copy()
y_var -= np.einsum("ji,ji->i", M, M, optimize=True)
# Check if any of the variances is negative because of
# numerical issues. If yes: set the variance to 0.
y_var_negative = y_var < 0
if np.any(y_var_negative):
warnings.warn(
"Predicted variances smaller than 0. Setting those variances to 0."
)
y_var[y_var_negative] = 0.0
y_std = np.sqrt(y_var)
return_values.append(y_std)
if return_mean_grad:
grad = self.kernel_.gradient_x(X[0], self.X_train_)
grad_mean = np.dot(grad.T, self.alpha_)
return_values.append(grad_mean)
if return_std_grad:
if np.any(y_std): # do not compute if all stds null
grad_std = np.zeros(X.shape[1])
else:
# TODO: This can be made much more efficient,
# but I don't think it's used currently
grad_std = (
-np.dot(K_trans, np.dot(self.V_.T.dot(self.V_), grad))[0]
/ y_std
)
return_values.append(grad_std)
return return_values
[docs]
def predict_std(self, X, validate=True):
"""
Predict output standart deviation for X.
Parameters
----------
X : array-like, shape = (n_samples, n_features)
Query points where the GP is evaluated.
validate : bool, default: True
If False, ``X`` and ``y`` are assumed to be correctly formatted, and no
checks are performed on them. Reduces overhead. Use only for repeated calls
when the input is programmatically generated to be correct at each stage.
Returns
-------
y_std : array, shape = (n_samples,), optional
Standard deviation of predictive distribution at query points.
Only returned when return_std is True.
"""
self.n_eval += len(X)
if validate:
if self.kernel is None or self.kernel.requires_vector_input:
dtype, ensure_2d = "numeric", True
else:
dtype, ensure_2d = None, False
X = validate_data(self, X, ensure_2d=ensure_2d, dtype=dtype, reset=False)
# If not fit yet, predict based on GP prior
if not hasattr(self, "X_train_"): # Not fit; predict based on GP prior
return np.sqrt(self.kernel.diag(X))
# If already fit, use GP posterior to predict
# Alg 2.1, page 19, line 4 -> f*_bar = K(X_test, X_train) . alpha
# NB: there is no y-normalisation here: the data are passed normalised to the GPR
K_trans = self.kernel_(X, self.X_train_)
# Use einsum to avoid explicitly forming the large matrix
# V^T @ V just to extract its diagonal afterward.
M = tri_mul(1.0, self.V_, K_trans.T, lower=True)
y_var = self.kernel_.diag(X).copy()
y_var -= np.einsum("ji,ji->i", M, M, optimize=True)
y_var_negative = y_var < 0
if np.any(y_var_negative):
warnings.warn(
"Predicted variances smaller than 0. Setting those variances to 0."
)
y_var[y_var_negative] = 0.0
return np.sqrt(y_var)