From f606d10367d48d3b30a8a27899bb01492c693702 Mon Sep 17 00:00:00 2001 From: Bill Engels Date: Mon, 22 Jan 2024 22:29:24 -0800 Subject: [PATCH 1/6] start adding options for dropping hsgp basis vectors --- pymc/gp/cov.py | 2 +- pymc/gp/hsgp_approx.py | 76 +++++++++++++++++++++++++----------- tests/gp/test_hsgp_approx.py | 26 +++++++----- 3 files changed, 72 insertions(+), 32 deletions(-) diff --git a/pymc/gp/cov.py b/pymc/gp/cov.py index 60f8ec06f4..4f0b3d2923 100644 --- a/pymc/gp/cov.py +++ b/pymc/gp/cov.py @@ -185,7 +185,7 @@ def n_dims(self) -> int: def _slice(self, X, Xs=None): xdims = X.shape[-1] if isinstance(xdims, Variable): - [xdims] = constant_fold([xdims]) + [xdims] = constant_fold([xdims], raise_not_constant=False) if self.input_dim != xdims: warnings.warn( f"Only {self.input_dim} column(s) out of {xdims} are" diff --git a/pymc/gp/hsgp_approx.py b/pymc/gp/hsgp_approx.py index 40231b027c..53ddd70131 100644 --- a/pymc/gp/hsgp_approx.py +++ b/pymc/gp/hsgp_approx.py @@ -70,18 +70,17 @@ def calc_basis_periodic( Xs: TensorLike, period: TensorLike, m: int, - tl: ModuleType = np, ): """ Calculate basis vectors for the cosine series expansion of the periodic covariance function. These are derived from the Taylor series representation of the covariance. """ w0 = (2 * np.pi) / period # angular frequency defining the periodicity - m1 = tl.tile(w0 * Xs, m) - m2 = tl.diag(tl.arange(0, m, 1)) + m1 = np.tile(w0 * Xs, m) + m2 = np.diag(np.arange(0, m, 1)) mw0x = m1 @ m2 - phi_cos = tl.cos(mw0x) - phi_sin = tl.sin(mw0x) + phi_cos = np.cos(mw0x) + phi_sin = np.sin(mw0x) return phi_cos, phi_sin @@ -473,11 +472,15 @@ def __init__( self, m: int, scale: Optional[Union[float, TensorLike]] = 1.0, + drop_intercept=True, *, mean_func: Mean = Zero(), cov_func: Periodic, ): - arg_err_msg = "`m` must be a positive integer as the `Periodic` kernel approximation is only implemented for 1-dimensional case." + arg_err_msg = ( + "`m` must be a positive integer as the `Periodic` kernel approximation is " + "only implemented for 1-dimensional case." + ) if not isinstance(m, int): raise ValueError(arg_err_msg) @@ -487,7 +490,8 @@ def __init__( if not isinstance(cov_func, Periodic): raise ValueError( - "`cov_func` must be an instance of a `Periodic` kernel only. Use the `scale` parameter to control the variance." + "`cov_func` must be an instance of a `Periodic` kernel only. Use the `scale` " + "parameter to control the variance." ) if cov_func.n_dims > 1: @@ -497,6 +501,8 @@ def __init__( self._m = m self.scale = scale + self.drop_intercept = drop_intercept + self.drop_first_sin = False super().__init__(mean_func=mean_func, cov_func=cov_func) @@ -576,8 +582,7 @@ def prior_linearized(self, Xs: TensorLike): ppc = pm.sample_posterior_predictive(idata, var_names=["f"]) """ Xs, _ = self.cov_func._slice(Xs) - - phi_cos, phi_sin = calc_basis_periodic(Xs, self.cov_func.period, self._m, tl=pt) + phi_cos, phi_sin = calc_basis_periodic(Xs, self.cov_func.period, self._m) J = pt.arange(0, self._m, 1) # rescale basis coefficients by the sqrt variance term psd = self.scale * self.cov_func.power_spectral_density_approx(J) @@ -602,21 +607,47 @@ def prior(self, name: str, X: TensorLike, dims: Optional[str] = None): # type: (phi_cos, phi_sin), psd = self.prior_linearized(X - self._X_mean) m = self._m - self._beta = pm.Normal(f"{name}_hsgp_coeffs_", size=(m * 2 - 1)) - # The first eigenfunction for the sine component is zero - # and so does not contribute to the approximation. - f = ( - self.mean_func(X) - + phi_cos @ (psd * self._beta[:m]) # type: ignore - + phi_sin[..., 1:] @ (psd[1:] * self._beta[m:]) # type: ignore - ) - self.f = pm.Deterministic(name, f, dims=dims) + if self.drop_intercept and not np.all(phi_cos[:, 0] == 1.0): + warnings.warn("Dropping the first cosine term, but its values are not all one.") + + if not self.drop_intercept and np.all(phi_cos[:, 0] == 1.0): + warnings.warn( + "First cosine term is all ones. If an additional intercept is added to the model " + "it will be overparameterized and more difficult to fit." + ) + + # Check if first sine is all zeros. If so, drop because it doesn't contribute to the model + # and leaves an extra beta parameter that can't be constrained. + if np.sum(phi_sin[:, 0]) == 0.0: + self.drop_first_sin = True + + if self.drop_first_sin and self.drop_intercept: + # Drop first sine term (all zeros), drop first cos term (all ones) + beta = pm.Normal(f"{name}_hsgp_coeffs_", size=(m * 2) - 2) + self._beta_cos = pt.concatenate(([0.0], beta[: m - 1])) + self._beta_sin = pt.concatenate(([0.0], beta[m - 1 :])) + + elif self.drop_first_sin: + # Drop first sine term, keep cosine term (keep intercept) + beta = pm.Normal(f"{name}_hsgp_coeffs_", size=(m * 2) - 1) + self._beta_cos = beta[:m] + self._beta_sin = pt.concatenate(([0.0], beta[m:])) + + else: + # Keep all terms + beta = pm.Normal(f"{name}_hsgp_coeffs_", size=m * 2) + self._beta_cos = beta[:m] + self._beta_sin = beta[m:] + + cos_term = phi_cos @ (psd * self._beta_cos) + sin_term = phi_sin @ (psd * self._beta_sin) + self.f = pm.Deterministic(name, self.mean_func(X) + cos_term + sin_term, dims=dims) return self.f def _build_conditional(self, Xnew): try: - beta, X_mean = self._beta, self._X_mean + X_mean = self._X_mean except AttributeError: raise ValueError( @@ -625,14 +656,15 @@ def _build_conditional(self, Xnew): Xnew, _ = self.cov_func._slice(Xnew) - phi_cos, phi_sin = calc_basis_periodic(Xnew - X_mean, self.cov_func.period, self._m, tl=pt) + phi_cos, phi_sin = calc_basis_periodic(Xnew - X_mean, self.cov_func.period, self._m) m = self._m J = pt.arange(0, m, 1) # rescale basis coefficients by the sqrt variance term psd = self.scale * self.cov_func.power_spectral_density_approx(J) - phi = phi_cos @ (psd * beta[:m]) + phi_sin[..., 1:] @ (psd[1:] * beta[m:]) - return self.mean_func(Xnew) + phi + cos_term = phi_cos @ (psd * self._beta_cos) + sin_term = phi_sin @ (psd * self._beta_sin) + return self.mean_func(Xnew) + cos_term + sin_term def conditional(self, name: str, Xnew: TensorLike, dims: Optional[str] = None): # type: ignore R""" diff --git a/tests/gp/test_hsgp_approx.py b/tests/gp/test_hsgp_approx.py index 0474899dbf..0c2c36fcd0 100644 --- a/tests/gp/test_hsgp_approx.py +++ b/tests/gp/test_hsgp_approx.py @@ -229,21 +229,27 @@ def test_conditional(self, model, cov_func, X1, parameterization): class TestHSGPPeriodic(_BaseFixtures): def test_parametrization(self): - err_msg = "`m` must be a positive integer as the `Periodic` kernel approximation is only implemented for 1-dimensional case." + err_msg = ( + "`m` must be a positive integer as the `Periodic` kernel approximation is only " + "implemented for 1-dimensional case." + ) with pytest.raises(ValueError, match=err_msg): # `m` must be a positive integer, not a list - cov_func = pm.gp.cov.Periodic(1, period=1, ls=0.1) + cov_func = pm.gp.cov.Periodic(1, period=1, ls=1.0) pm.gp.HSGPPeriodic(m=[500], cov_func=cov_func) with pytest.raises(ValueError, match=err_msg): # `m`` must be a positive integer - cov_func = pm.gp.cov.Periodic(1, period=1, ls=0.1) + cov_func = pm.gp.cov.Periodic(1, period=1, ls=1.0) pm.gp.HSGPPeriodic(m=-1, cov_func=cov_func) with pytest.raises( ValueError, - match="`cov_func` must be an instance of a `Periodic` kernel only. Use the `scale` parameter to control the variance.", + match=( + "`cov_func` must be an instance of a `Periodic` kernel only. Use the `scale` " + "parameter to control the variance." + ), ): # `cov_func` must be `Periodic` only cov_func = 5.0 * pm.gp.cov.Periodic(1, period=1, ls=0.1) @@ -251,7 +257,9 @@ def test_parametrization(self): with pytest.raises( ValueError, - match="HSGP approximation for `Periodic` kernel only implemented for 1-dimensional case.", + match=( + "HSGP approximation for `Periodic` kernel only implemented for 1-dimensional case." + ), ): cov_func = pm.gp.cov.Periodic(2, period=1, ls=[1, 2]) pm.gp.HSGPPeriodic(m=500, scale=0.5, cov_func=cov_func) @@ -278,8 +286,8 @@ def test_prior(self, model, cov_func, eta, X1, rng): idata = pm.sample_prior_predictive(samples=1000, random_seed=rng) - samples1 = az.extract(idata.prior["f1"])["f1"].values.T - samples2 = az.extract(idata.prior["f2"])["f2"].values.T + samples1 = az.extract(idata.prior, var_names="f1").values.T + samples2 = az.extract(idata.prior, var_names="f2").values.T h0, mmd, critical_value, reject = two_sample_test( samples1, samples2, n_sims=500, alpha=0.01 @@ -299,8 +307,8 @@ def test_conditional_periodic(self, model, cov_func, X1): idata = pm.sample_prior_predictive(samples=1000) - samples1 = az.extract(idata.prior["f"])["f"].values.T - samples2 = az.extract(idata.prior["fc"])["fc"].values.T + samples1 = az.extract(idata.prior, var_names="f").values.T + samples2 = az.extract(idata.prior, var_names="fc").values.T h0, mmd, critical_value, reject = two_sample_test( samples1, samples2, n_sims=500, alpha=0.01 From 47732dbead628b586888662e367d1ea4620f340d Mon Sep 17 00:00:00 2001 From: Bill Engels Date: Tue, 23 Jan 2024 14:49:25 -0800 Subject: [PATCH 2/6] add drop_first arg to HSGPPeriodic --- pymc/gp/hsgp_approx.py | 37 ++++++++----------------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/pymc/gp/hsgp_approx.py b/pymc/gp/hsgp_approx.py index 53ddd70131..3f5691923a 100644 --- a/pymc/gp/hsgp_approx.py +++ b/pymc/gp/hsgp_approx.py @@ -75,12 +75,12 @@ def calc_basis_periodic( Calculate basis vectors for the cosine series expansion of the periodic covariance function. These are derived from the Taylor series representation of the covariance. """ - w0 = (2 * np.pi) / period # angular frequency defining the periodicity - m1 = np.tile(w0 * Xs, m) - m2 = np.diag(np.arange(0, m, 1)) + w0 = (2 * pt.pi) / period # angular frequency defining the periodicity + m1 = pt.tile(w0 * Xs, m) + m2 = pt.diag(pt.arange(0, m, 1)) mw0x = m1 @ m2 - phi_cos = np.cos(mw0x) - phi_sin = np.sin(mw0x) + phi_cos = pt.cos(mw0x) + phi_sin = pt.sin(mw0x) return phi_cos, phi_sin @@ -472,7 +472,7 @@ def __init__( self, m: int, scale: Optional[Union[float, TensorLike]] = 1.0, - drop_intercept=True, + drop_first=False, *, mean_func: Mean = Zero(), cov_func: Periodic, @@ -501,8 +501,7 @@ def __init__( self._m = m self.scale = scale - self.drop_intercept = drop_intercept - self.drop_first_sin = False + self.drop_first = drop_first super().__init__(mean_func=mean_func, cov_func=cov_func) @@ -608,32 +607,12 @@ def prior(self, name: str, X: TensorLike, dims: Optional[str] = None): # type: m = self._m - if self.drop_intercept and not np.all(phi_cos[:, 0] == 1.0): - warnings.warn("Dropping the first cosine term, but its values are not all one.") - - if not self.drop_intercept and np.all(phi_cos[:, 0] == 1.0): - warnings.warn( - "First cosine term is all ones. If an additional intercept is added to the model " - "it will be overparameterized and more difficult to fit." - ) - - # Check if first sine is all zeros. If so, drop because it doesn't contribute to the model - # and leaves an extra beta parameter that can't be constrained. - if np.sum(phi_sin[:, 0]) == 0.0: - self.drop_first_sin = True - - if self.drop_first_sin and self.drop_intercept: + if self.drop_first: # Drop first sine term (all zeros), drop first cos term (all ones) beta = pm.Normal(f"{name}_hsgp_coeffs_", size=(m * 2) - 2) self._beta_cos = pt.concatenate(([0.0], beta[: m - 1])) self._beta_sin = pt.concatenate(([0.0], beta[m - 1 :])) - elif self.drop_first_sin: - # Drop first sine term, keep cosine term (keep intercept) - beta = pm.Normal(f"{name}_hsgp_coeffs_", size=(m * 2) - 1) - self._beta_cos = beta[:m] - self._beta_sin = pt.concatenate(([0.0], beta[m:])) - else: # Keep all terms beta = pm.Normal(f"{name}_hsgp_coeffs_", size=m * 2) From f40aea8142cc2359b279741fc9bc5cb8c9d8bc78 Mon Sep 17 00:00:00 2001 From: Bill Engels Date: Tue, 23 Jan 2024 14:49:57 -0800 Subject: [PATCH 3/6] see if this fixes xfailed test --- tests/gp/test_hsgp_approx.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/tests/gp/test_hsgp_approx.py b/tests/gp/test_hsgp_approx.py index 0c2c36fcd0..fa14b757ab 100644 --- a/tests/gp/test_hsgp_approx.py +++ b/tests/gp/test_hsgp_approx.py @@ -252,7 +252,7 @@ def test_parametrization(self): ), ): # `cov_func` must be `Periodic` only - cov_func = 5.0 * pm.gp.cov.Periodic(1, period=1, ls=0.1) + cov_func = 5.0 * pm.gp.cov.Periodic(1, period=1, ls=1.0) pm.gp.HSGPPeriodic(m=500, cov_func=cov_func) with pytest.raises( @@ -265,20 +265,20 @@ def test_parametrization(self): pm.gp.HSGPPeriodic(m=500, scale=0.5, cov_func=cov_func) @pytest.mark.parametrize("cov_func", [pm.gp.cov.Periodic(1, period=1, ls=1)]) - @pytest.mark.parametrize("eta", [100.0]) - @pytest.mark.xfail( - reason="For `pm.gp.cov.Periodic`, this test does not pass.\ - The mmd is around `0.0468`.\ - The test passes more often when subtracting the mean from the mean from the samples.\ - It might be that the period is slightly off for the approximate power spectral density.\ - See https://github.com/pymc-devs/pymc/pull/6877/ for the full discussion." - ) + @pytest.mark.parametrize("eta", [2.0]) + # @pytest.mark.xfail( + # reason="For `pm.gp.cov.Periodic`, this test does not pass.\ + # The mmd is around `0.0468`.\ + # The test passes more often when subtracting the mean from the mean from the samples.\ + # It might be that the period is slightly off for the approximate power spectral density.\ + # See https://github.com/pymc-devs/pymc/pull/6877/ for the full discussion." + # ) def test_prior(self, model, cov_func, eta, X1, rng): - """Compare HSGPPeriodic prior to unapproximated GP prior, pm.gp.Latent. Draw samples from the - prior and compare them using MMD two sample test. + """Compare HSGPPeriodic prior to unapproximated GP prior, pm.gp.Latent. Draw samples from + the prior and compare them using MMD two sample test. """ with model: - hsgp = pm.gp.HSGPPeriodic(m=200, scale=eta, cov_func=cov_func) + hsgp = pm.gp.HSGPPeriodic(m=200, scale=eta, drop_first=False, cov_func=cov_func) f1 = hsgp.prior("f1", X=X1) gp = pm.gp.Latent(cov_func=eta**2 * cov_func) @@ -292,7 +292,10 @@ def test_prior(self, model, cov_func, eta, X1, rng): h0, mmd, critical_value, reject = two_sample_test( samples1, samples2, n_sims=500, alpha=0.01 ) - assert not reject, f"H0 was rejected, {mmd} even though HSGP and GP priors should match." + assert not reject, ( + f"H0 was rejected, MMD {mmd:.3f} > {critical_value:.3f} even though HSGP and GP priors " + "should match." + ) @pytest.mark.parametrize("cov_func", [pm.gp.cov.Periodic(1, period=1, ls=1)]) def test_conditional_periodic(self, model, cov_func, X1): From c0b6890ca7628af9d2133b2a289f025ac733e3c0 Mon Sep 17 00:00:00 2001 From: Bill Engels Date: Wed, 31 Jan 2024 23:34:46 -0800 Subject: [PATCH 4/6] drop first removes first cos term, first sine term always removed --- pymc/gp/hsgp_approx.py | 18 ++++++------------ tests/gp/test_hsgp_approx.py | 29 ++++++++++++++++++++++------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/pymc/gp/hsgp_approx.py b/pymc/gp/hsgp_approx.py index 3f5691923a..ac97f16334 100644 --- a/pymc/gp/hsgp_approx.py +++ b/pymc/gp/hsgp_approx.py @@ -119,8 +119,8 @@ class HSGP(Base): provided. Further information can be found in Ruitort-Mayol et al. drop_first: bool Default `False`. Sometimes the first basis vector is quite "flat" and very similar to - the intercept term. When there is an intercept in the model, ignoring the first basis - vector may improve sampling. This argument will be deprecated in future versions. + the intercept term. When there is an intercept in the model, removing the first basis + vector may improve sampling. parameterization: str Whether to use `centred` or `noncentered` parameterization when multiplying the basis by the coefficients. @@ -208,13 +208,6 @@ def __init__( if parameterization not in ["centered", "noncentered"]: raise ValueError("`parameterization` must be either 'centered' or 'noncentered'.") - if drop_first: - warnings.warn( - "The drop_first argument will be deprecated in future versions." - " See https://github.com/pymc-devs/pymc/pull/6877", - DeprecationWarning, - ) - self._drop_first = drop_first self._m = m self._m_star = int(np.prod(self._m)) @@ -614,10 +607,11 @@ def prior(self, name: str, X: TensorLike, dims: Optional[str] = None): # type: self._beta_sin = pt.concatenate(([0.0], beta[m - 1 :])) else: - # Keep all terms - beta = pm.Normal(f"{name}_hsgp_coeffs_", size=m * 2) + # The first eigenfunction for the sine component is zero + # and so does not contribute to the approximation. + beta = pm.Normal(f"{name}_hsgp_coeffs_", size=(m * 2) - 1) self._beta_cos = beta[:m] - self._beta_sin = beta[m:] + self._beta_sin = pt.concatenate(([0.0], beta[m:])) cos_term = phi_cos @ (psd * self._beta_cos) sin_term = phi_sin @ (psd * self._beta_sin) diff --git a/tests/gp/test_hsgp_approx.py b/tests/gp/test_hsgp_approx.py index fa14b757ab..020c61f19a 100644 --- a/tests/gp/test_hsgp_approx.py +++ b/tests/gp/test_hsgp_approx.py @@ -264,15 +264,30 @@ def test_parametrization(self): cov_func = pm.gp.cov.Periodic(2, period=1, ls=[1, 2]) pm.gp.HSGPPeriodic(m=500, scale=0.5, cov_func=cov_func) + @pytest.mark.parametrize("drop_first", [True, False]) + def test_parametrization_drop_first(self, model, cov_func, X1, drop_first): + n_basis = 101 + with model: + gp = pm.gp.HSGPPeriodic(m=n_basis, scale=1.0, drop_first=drop_first, cov_func=cov_func) + gp.prior("f1", X1) + + n_coeffs = model.f1_hsgp_coeffs_.type.shape[0] + if drop_first: + assert ( + n_coeffs == n_basis - 1 + ), f"one basis vector should have been dropped, {n_coeffs}" + else: + assert n_coeffs == n_basis, "one was dropped when it shouldn't have been" + @pytest.mark.parametrize("cov_func", [pm.gp.cov.Periodic(1, period=1, ls=1)]) @pytest.mark.parametrize("eta", [2.0]) - # @pytest.mark.xfail( - # reason="For `pm.gp.cov.Periodic`, this test does not pass.\ - # The mmd is around `0.0468`.\ - # The test passes more often when subtracting the mean from the mean from the samples.\ - # It might be that the period is slightly off for the approximate power spectral density.\ - # See https://github.com/pymc-devs/pymc/pull/6877/ for the full discussion." - # ) + @pytest.mark.xfail( + reason="For `pm.gp.cov.Periodic`, this test does not pass.\ + The mmd is around `0.0468`.\ + The test passes more often when subtracting the mean from the mean from the samples.\ + It might be that the period is slightly off for the approximate power spectral density.\ + See https://github.com/pymc-devs/pymc/pull/6877/ for the full discussion." + ) def test_prior(self, model, cov_func, eta, X1, rng): """Compare HSGPPeriodic prior to unapproximated GP prior, pm.gp.Latent. Draw samples from the prior and compare them using MMD two sample test. From f68313d20ec6dbd2e4434e9e4601d2ab8c612e68 Mon Sep 17 00:00:00 2001 From: Bill Engels Date: Thu, 1 Feb 2024 00:52:25 -0800 Subject: [PATCH 5/6] fix tests --- tests/gp/test_hsgp_approx.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/gp/test_hsgp_approx.py b/tests/gp/test_hsgp_approx.py index 020c61f19a..a6be5c1263 100644 --- a/tests/gp/test_hsgp_approx.py +++ b/tests/gp/test_hsgp_approx.py @@ -264,20 +264,23 @@ def test_parametrization(self): cov_func = pm.gp.cov.Periodic(2, period=1, ls=[1, 2]) pm.gp.HSGPPeriodic(m=500, scale=0.5, cov_func=cov_func) + @pytest.mark.parametrize("cov_func", [pm.gp.cov.Periodic(1, period=1, ls=1)]) @pytest.mark.parametrize("drop_first", [True, False]) def test_parametrization_drop_first(self, model, cov_func, X1, drop_first): n_basis = 101 with model: gp = pm.gp.HSGPPeriodic(m=n_basis, scale=1.0, drop_first=drop_first, cov_func=cov_func) gp.prior("f1", X1) - - n_coeffs = model.f1_hsgp_coeffs_.type.shape[0] + first_cos_zero = gp._beta_cos[0].eval() == 0.0 + first_sin_zero = gp._beta_sin[0].eval() == 0.0 if drop_first: assert ( - n_coeffs == n_basis - 1 - ), f"one basis vector should have been dropped, {n_coeffs}" + first_cos_zero and first_sin_zero + ), "First element of both coefficient vectors should be zero." else: - assert n_coeffs == n_basis, "one was dropped when it shouldn't have been" + assert ( + not first_cos_zero and first_sin_zero + ), "Only the first element of the sine coefficent vectors should be zero." @pytest.mark.parametrize("cov_func", [pm.gp.cov.Periodic(1, period=1, ls=1)]) @pytest.mark.parametrize("eta", [2.0]) From 8edd05f532aa49713effb2d7f2f4efde040a843c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 1 Feb 2024 17:42:47 +0000 Subject: [PATCH 6/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index b21437de01..8aff7d60c9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -30,4 +30,4 @@ sphinx>=1.5 sphinxext-rediraffe types-cachetools typing-extensions>=3.7.4 -watermark \ No newline at end of file +watermark