Repository: JuBiotech/calibr8 (language: python)

Issue:
Add `BasePolynomialModelN` and `BaseAsymmetricLogisticN` counterparts
We have contrib-models with `StudentTNoise`, but models with normally distributed noise are much more common.

I have these implementations already, just need to write docstrings and tests.

ToDo:
+ [ ] Refactor the t-models into their own file
+ [ ] Add a corresponding file with `NormalNoise` models for all three.


Discussion / hints:

Fix patch:
diff --git a/calibr8/__init__.py b/calibr8/__init__.py
index 6914cd4..e2be378 100644
--- a/calibr8/__init__.py
+++ b/calibr8/__init__.py
@@ -6,7 +6,12 @@
     StudentTNoise,
     PoissonNoise,
 )
-from .contrib.base import (
+from .contrib.normal import (
+    BaseAsymmetricLogisticN,
+    BaseLogIndependentAsymmetricLogisticN,
+    BasePolynomialModelN,
+)
+from .contrib.studentt import (
     BaseAsymmetricLogisticT,
     BaseLogIndependentAsymmetricLogisticT,
     BaseModelT,
diff --git a/calibr8/contrib/normal.py b/calibr8/contrib/normal.py
new file mode 100644
index 0000000..b88b9c3
--- /dev/null
+++ b/calibr8/contrib/normal.py
@@ -0,0 +1,257 @@
+"""
+This module implements reusable calibration models
+with Normal distributions for the dependent variable.
+"""
+from typing import Optional, Tuple
+
+from . import noise
+from .. import core
+
+
+class BasePolynomialModelN(core.ContinuousUnivariateModel, noise.NormalNoise):
+    def __init__(
+        self, *,
+        independent_key: str, dependent_key: str,
+        mu_degree: int, sigma_degree: int=0,
+        theta_names: Optional[Tuple[str]]=None,
+    ):
+        """ Template for a model with polynomial trend (mu) and sigma (as a function of mu)
+        with a normally distributed observation noise.
+
+        Parameters
+        ----------
+        independent_key : str
+            name of the independent variable
+        dependent_key : str
+            name of the dependent variable
+        mu_degree : int
+            degree of the polynomial model describing the trend (mu)
+        sigma_degree : optional, int
+            degree of the polynomial model describing the sigma as a function of mu
+        theta_names : optional, tuple of str
+            may be used to set the names of the model parameters
+        """
+        if mu_degree == 0:
+            raise ValueError("0-degree (constant) mu calibration models are useless.")
+        self.mu_degree = mu_degree
+        self.sigma_degree = sigma_degree
+        if theta_names is None:
+            theta_names = tuple(
+                f'mu_{d}'
+                for d in range(mu_degree + 1)
+            ) + tuple(
+                f'sigma_{d}'
+                for d in range(sigma_degree + 1)
+            )
+        super().__init__(independent_key=independent_key, dependent_key=dependent_key, theta_names=theta_names)
+
+    def predict_dependent(self, x, *, theta=None):
+        """Predicts the parameters mu and sigma of a normal distribution which
+        characterizes the dependent variable given values of the independent variable.
+
+        Parameters
+        ----------
+        x : array-like
+            values of the independent variable
+        theta : optional, array-like
+            parameter vector of the calibration model:
+                [mu_degree] parameters for mu (lowest degree first)
+                [sigma_degree] parameters for sigma (lowest degree first)
+
+        Returns
+        -------
+        mu : array-like
+            values for the mu parameter of a normal distribution describing the dependent variable
+        sigma : array-like or float
+            values for the sigma parameter of a normal distribution describing the dependent variable
+        """
+        if theta is None:
+            theta = self.theta_fitted
+        mu = core.polynomial(x, theta=theta[:self.mu_degree+1])
+        if self.sigma_degree == 0:
+            sigma = theta[-1]
+        else:
+            sigma = core.polynomial(mu, theta=theta[self.mu_degree+1:])
+        return mu, sigma
+
+    def predict_independent(self, y, *, theta=None):
+        """Predict the independent variable using the inverse trend model.
+
+        Parameters
+        ----------
+        y : array-like
+            observations
+        theta : optional, array-like
+            parameter vector of the calibration model:
+                [mu_degree] parameters for mu (lowest degree first)
+                [sigma_degree] parameters for sigma (lowest degree first)
+
+        Returns
+        -------
+        x : array-like
+            predicted independent values given the observations
+        """
+        if theta is None:
+            theta = self.theta_fitted
+        if self.mu_degree > 1:
+            raise NotImplementedError('Inverse prediction of higher order polynomials are not implemented.')      
+        a, b = theta[:2]
+        return (y - a) / b
+
+
+class BaseAsymmetricLogisticN(core.ContinuousUnivariateModel, noise.NormalNoise):
+    def __init__(
+        self, *,
+        independent_key:str, dependent_key:str,
+        sigma_degree:int=0,
+        theta_names: Optional[Tuple[str]]=None,
+    ):
+        """ Template for a model with asymmetric logistic trend (mu) and polynomial sigma (as a function of mu).
+
+        Parameters
+        ----------
+        independent_key : str
+            name of the independent variable
+        dependent_key : str
+            name of the dependent variable
+        sigma_degree : optional, int
+            degree of the polynomial model describing the sigma as a function of mu
+        theta_names : optional, tuple of str
+            may be used to set the names of the model parameters
+        """
+        self.sigma_degree = sigma_degree
+        if theta_names is None:
+            theta_names = tuple('L_L,L_U,I_x,S,c'.split(',')) + tuple(
+                f'sigma_{d}'
+                for d in range(sigma_degree + 1)
+            )
+        super().__init__(independent_key, dependent_key, theta_names=theta_names)
+
+    def predict_dependent(self, x, *, theta=None):
+        """Predicts the parameters mu and sigma of a normal distribution which
+        characterizes the dependent variable given values of the independent variable.
+
+        Parameters
+        ----------
+        x : array-like
+            values of the independent variable
+        theta : optional, array-like
+            parameter vector of the calibration model:
+                5 parameters of asymmetric logistic model for mu
+                [sigma_degree] parameters for sigma (lowest degree first)
+
+        Returns
+        -------
+        mu : array-like
+            values for the mu parameter of a normal distribution describing the dependent variable
+        sigma : array-like or float
+            values for the sigma parameter of a normal distribution describing the dependent variable
+        """
+        if theta is None:
+            theta = self.theta_fitted
+        mu = core.asymmetric_logistic(x, theta[:5])
+        if self.sigma_degree == 0:
+            sigma = theta[-1]
+        else:
+            sigma = core.polynomial(mu, theta[5:])
+        return mu, sigma
+
+    def predict_independent(self, y, *, theta=None):
+        """Predict the independent variable using the inverse trend model.
+
+        Parameters
+        ----------
+        y : array-like
+            observations
+        theta : optional, array-like
+            parameter vector of the calibration model:
+                5 parameters of asymmetric logistic model for mu
+                [sigma_degree] parameters for sigma (lowest degree first)
+
+        Returns
+        -------
+        x : array-like
+            predicted independent values given the observations
+        """
+        if theta is None:
+            theta = self.theta_fitted
+        return core.inverse_asymmetric_logistic(y, theta[:5])
+
+
+class BaseLogIndependentAsymmetricLogisticN(core.ContinuousUnivariateModel, noise.NormalNoise):
+    def __init__(
+        self, *,
+        independent_key:str, dependent_key:str,
+        sigma_degree:int=0,
+        theta_names: Optional[Tuple[str]]=None,
+    ):
+        """ Template for a model with asymmetric logistic trend (mu) and polynomial sigma (as a function of mu).
+
+        Parameters
+        ----------
+        independent_key : str
+            name of the independent variable
+        dependent_key : str
+            name of the dependent variable
+        sigma_degree : optional, int
+            degree of the polynomial model describing the sigma as a function of mu
+        theta_names : optional, tuple of str
+            may be used to set the names of the model parameters
+        """
+        self.sigma_degree = sigma_degree
+        if theta_names is None:
+            theta_names = tuple('L_L,L_U,log_I_x,S,c'.split(',')) + tuple(
+                f'sigma_{d}'
+                for d in range(sigma_degree + 1)
+            )
+        super().__init__(independent_key, dependent_key, theta_names=theta_names)
+
+    def predict_dependent(self, x, *, theta=None):
+        """Predicts the parameters mu and sigma of a normal distribution which
+        characterizes the dependent variable given values of the independent variable.
+
+        Parameters
+        ----------
+        x : array-like
+            values of the independent variable
+        theta : optional, array-like
+            parameter vector of the calibration model:
+                5 parameters of log-independent asymmetric logistic model for mu
+                [sigma_degree] parameters for sigma (lowest degree first)
+
+        Returns
+        -------
+        mu : array-like
+            values for the mu parameter of a normal distribution describing the dependent variable
+        sigma : array-like or float
+            values for the sigma parameter of a normal distribution describing the dependent variable
+        """
+        if theta is None:
+            theta = self.theta_fitted
+        mu = core.xlog_asymmetric_logistic(x, theta[:5])
+        if self.sigma_degree == 0:
+            sigma = theta[-1]
+        else:
+            sigma = core.polynomial(mu, theta[5:-1])
+        return mu, sigma
+
+    def predict_independent(self, y, *, theta=None):
+        """Predict the independent variable using the inverse trend model.
+
+        Parameters
+        ----------
+        y : array-like
+            observations
+        theta : optional, array-like
+            parameter vector of the calibration model:
+                5 parameters of log-independent asymmetric logistic model for mu
+                [sigma_degree] parameters for sigma (lowest degree first)
+
+        Returns
+        -------
+        x : array-like
+            predicted independent values given the observations
+        """
+        if theta is None:
+            theta = self.theta_fitted
+        return core.inverse_xlog_asymmetric_logistic(y, theta[:5])
diff --git a/calibr8/contrib/base.py b/calibr8/contrib/studentt.py
similarity index 91%
rename from calibr8/contrib/base.py
rename to calibr8/contrib/studentt.py
index 5c55874..ea0c208 100644
--- a/calibr8/contrib/base.py
+++ b/calibr8/contrib/studentt.py
@@ -1,25 +1,37 @@
 """
-This module implements generic, reusable calibration models that can be subclassed to
-implement custom calibration models.
+This module implements reusable calibration models
+with Students-t distributions for the dependent variable.
 """
 import typing
+import warnings
 
 from . import noise
 from .. import core
 
 
 class BaseModelT(core.ContinuousUnivariateModel, noise.StudentTNoise):
-    pass
+    def __init__(self, independent_key: str, dependent_key: str, *, theta_names: typing.Tuple[str]):
+        warnings.warn(
+            "The`BaseModelT` class is deprecated. "
+            "Inherit `core.ContinuousUnivariateModel, noise.StudentTNoise` directly.",
+            FutureWarning
+        )
+        super().__init__(
+            independent_key=independent_key,
+            dependent_key=dependent_key,
+            theta_names=theta_names
+        )
 
 
-class BasePolynomialModelT(BaseModelT):
+class BasePolynomialModelT(core.ContinuousUnivariateModel, noise.StudentTNoise):
     def __init__(
         self, *,
         independent_key: str, dependent_key: str,
         mu_degree: int, scale_degree: int=0,
         theta_names: typing.Optional[typing.Tuple[str]]=None,
     ):
-        """ Template for a model with polynomial trend (mu) and scale (as a function of mu).
+        """ Template for a model with polynomial trend (mu) and scale (as a function of mu)
+        with a Student-t distributed observation noise.
 
         Parameters
         ----------
@@ -107,7 +119,7 @@ def predict_independent(self, y, *, theta=None):
         return (y - a) / b
 
 
-class BaseAsymmetricLogisticT(BaseModelT):
+class BaseAsymmetricLogisticT(core.ContinuousUnivariateModel, noise.StudentTNoise):
     def __init__(
         self, *,
         independent_key:str, dependent_key:str,
@@ -191,7 +203,7 @@ def predict_independent(self, y, *, theta=None):
         return core.inverse_asymmetric_logistic(y, theta[:5])
 
 
-class BaseLogIndependentAsymmetricLogisticT(BaseModelT):
+class BaseLogIndependentAsymmetricLogisticT(core.ContinuousUnivariateModel, noise.StudentTNoise):
     def __init__(
         self, *,
         independent_key:str, dependent_key:str,
diff --git a/calibr8/core.py b/calibr8/core.py
index bbbfcb4..d318695 100644
--- a/calibr8/core.py
+++ b/calibr8/core.py
@@ -19,7 +19,7 @@
 from . utils import pm
 
 
-__version__ = '6.3.0'
+__version__ = '6.4.0'
 _log = logging.getLogger('calibr8')
 
 


Test patch:
diff --git a/calibr8/tests.py b/calibr8/tests.py
index f49c59a..a61cb16 100644
--- a/calibr8/tests.py
+++ b/calibr8/tests.py
@@ -1284,7 +1284,7 @@ def test_fit_pygmo(self, caplog):
         pass
 
 
-class TestContribBase:
+class TestContribStudentT:
     def test_cant_instantiate_base_models(self):
         with pytest.raises(TypeError, match=r"constructor must not have any required \(kw\)arguments"):
             calibr8.BaseModelT(independent_key='I', dependent_key='D', theta_names=["a", "b"])
@@ -1294,6 +1294,21 @@ def test_cant_instantiate_base_models(self):
             calibr8.BasePolynomialModelT(independent_key='I', dependent_key='D', mu_degree=1, scale_degree=1)
         pass
 
+    def test_warns_about_basemodelt(self):
+        class UnmigratedModel(calibr8.BaseModelT):
+            def __init__(self, independent_key="I", dependent_key="D"):
+                super().__init__(
+                    independent_key=independent_key,
+                    dependent_key=dependent_key,
+                    theta_names=[],
+                )
+
+        with pytest.warns(FutureWarning, match="is deprecated"):
+            cm = UnmigratedModel()
+        assert isinstance(cm, calibr8.CalibrationModel)
+        assert isinstance(cm, calibr8.BaseModelT)
+        pass
+
 
 class TestBasePolynomialModelT:
     def test_exceptions(self):
@@ -1425,3 +1440,63 @@ def test_predict_independent(self, scale_degree):
         x_inverse = em.predict_independent(mu)
         numpy.testing.assert_array_almost_equal(x_inverse, x)
         pass
+
+
+class TestBaseModels:
+    @pytest.mark.parametrize(
+        "cls,init_kwargs,theta,invertible", [
+            (calibr8.BasePolynomialModelN, dict(mu_degree=1, sigma_degree=0), [1, 0.5, 0.1], True),
+            (calibr8.BasePolynomialModelN, dict(mu_degree=1, sigma_degree=1), [1, 0.5, 0.1, 0.2], True),
+            (calibr8.BasePolynomialModelN, dict(mu_degree=2, sigma_degree=0), [1, 0.5, 0.3, 0.1], False),
+            (calibr8.BasePolynomialModelT, dict(mu_degree=1, scale_degree=0), [1, 0.5, 0.1, 3], True),
+            (calibr8.BasePolynomialModelT, dict(mu_degree=1, scale_degree=1), [1, 0.5, 0.1, 0.2, 3], True),
+            (calibr8.BasePolynomialModelT, dict(mu_degree=2, scale_degree=0), [1, 0.5, 0.3, 0.1, 3], False),
+            (calibr8.BaseAsymmetricLogisticN, dict(sigma_degree=0), [-1, 2, 0.5, 0.2, 1, 0.2], True),
+            (calibr8.BaseAsymmetricLogisticN, dict(sigma_degree=1), [-1, 2, 0.5, 0.2, 1, 0.2, 0.1], True),
+            (calibr8.BaseAsymmetricLogisticT, dict(scale_degree=0), [-1, 2, 0.5, 0.2, 1, 0.2, 5], True),
+            (calibr8.BaseAsymmetricLogisticT, dict(scale_degree=1), [-1, 2, 0.5, 0.2, 1, 0.2, 0.1, 5], True),
+            (calibr8.BaseLogIndependentAsymmetricLogisticN, dict(sigma_degree=0), [-1, 2, 0.5, 0.2, 1, 0.2], True),
+            (calibr8.BaseLogIndependentAsymmetricLogisticN, dict(sigma_degree=1), [-1, 2, 0.5, 0.2, 1, 0.2, 0.1], True),
+            (calibr8.BaseLogIndependentAsymmetricLogisticT, dict(scale_degree=0), [-1, 2, 0.5, 0.2, 1, 0.2, 5], True),
+            (calibr8.BaseLogIndependentAsymmetricLogisticT, dict(scale_degree=1), [-1, 2, 0.5, 0.2, 1, 0.2, 0.1, 5], True),
+        ]
+    )
+    def test_basic_features(self, cls, init_kwargs, theta, invertible):
+        # Create a derived class that doesn't have default kwargs
+        class _TestModel(cls):
+            def __init__(self, **kwargs) -> None:
+                super().__init__(**kwargs)
+
+        cm = _TestModel(independent_key="I", dependent_key="D", **init_kwargs)
+        assert len(cm.theta_names) == len(theta)
+
+        # Can it predict?
+        x = numpy.linspace(1, 10)
+        params = cm.predict_dependent(x, theta=theta)
+        assert isinstance(params, tuple)
+
+        # Does it default to the fitted theta?
+        cm.theta_fitted = theta
+        params = cm.predict_dependent(x)
+        assert isinstance(params, tuple)
+
+        # Draw some observations
+        scipy_kwargs = cm.to_scipy(*params)
+        obs = cm.scipy_dist.rvs(**scipy_kwargs)
+
+        if invertible:
+            xhat = cm.predict_independent(obs)
+            assert len(xhat) == len(x)
+        else:
+            with pytest.raises(Exception):
+                cm.predict_independent(obs)
+        pass
+
+    @pytest.mark.parametrize("cls", [calibr8.BasePolynomialModelN, calibr8.BasePolynomialModelT])
+    def test_mu_degree_0_exception(self, cls):
+        class _TestModel(cls):
+            def __init__(self, **kwargs) -> None:
+                super().__init__(independent_key="I", dependent_key="D", **kwargs)
+        with pytest.raises(ValueError, match="are useless"):
+            _TestModel(mu_degree=0)
+        pass