Repository: sdv-dev/Copulas

Issue:
`BetaUnivariate` should reject fitted parameters that obviously don’t match the data
### Background
As described in #455, it is possible for the `beta` to fail the fitting process. In this case, the expected behavior is:
1. The `BetaUnivariate` will throw a `FitError`
2. When using this univariate, we implement a try/catch logic and [switch to a fallback distribution](https://github.com/sdv-dev/Copulas/blob/a5f1c5ade7f2d909cc3d8aec36123a6fa6d58784/copulas/multivariate/gaussian.py#L129) instead

This works when `scipy` throws an error. However, there are cases where `scipy` _does not throw an error_ and returns a bad fit. We should catch such cases and throw an error for them.

### Replication
The data below came from [SDV #2542](https://github.com/sdv-dev/SDV/issues/2542). I've pulled out the particular column into this new CSV below.

[example_data.csv](https://github.com/user-attachments/files/20350073/example_data.csv)

The column contains data roughly in the range [1, 7]. When fitting this data, there is no error, but the fitted  parameters clearly don’t make sense:

```python
import pandas as pd
from copulas.univariate import BetaUnivariate

data = pd.read_csv('example_data.csv')
beta = BetaUnivariate()
beta.fit(data)
print(beta._params)
```
```
{
 'loc': 2.1833080938648672,
 'scale': 9.555402199071863e-29,
 'a': 26439403.24259588,
 'b': 28.274298502064756
}
```

Most notably: The `scale` parameter is _so small, it is essentially 0_. This means that distribution is essentially only capable of creating a constant value of ~2.1833. There have been many such examples of this for the Beta distribution specifically.

### Expected Behavior
The `BetaUnivariate` distribution should to check itself after fitting to make sure the parameters make sense. For now, these are two obvious checks we should add:

- The `scale` value should not be close to zero, as in the example above. (Perhaps we can check that it is greater than [EPSILON](https://github.com/sdv-dev/Copulas/blob/main/copulas/utils.py#L11)?)
- The range `[loc, loc+scale]` should have at least _some, non-zero_ overlap with the actual `[data_min, data_max]` range. That is to say:
    - `loc + scale` should be `> data_min` and
    - `loc` should be `< data_max`

If any one of these checks fail, BetaUnivariate should throw a descriptive error explaining that the fitting failed. 
```
Error: Converged parameters for beta distribution have a near-zero range.
```
```
Error: Converged parameters for beta distribution are outside the min/max range of the data.
```

The rest of the code will then adapt to using a fallback.

### Future Work
(Not in scope for now) In the future, we can keep adding/refining the checks for the failure modes that we find. 

In the future, we can also start to investigate how to “fix” the Beta’s fit rather than error out immediately. (For example: Does supplying `floc` and `fscale` help? We’d need to investigate).

Hints from issue comments:

Solution patch:
diff --git a/copulas/univariate/beta.py b/copulas/univariate/beta.py
index d836b82..a4d57bb 100644
--- a/copulas/univariate/beta.py
+++ b/copulas/univariate/beta.py
@@ -4,6 +4,7 @@ import numpy as np
 from scipy.stats import beta
 
 from copulas.univariate.base import BoundedType, ParametricType, ScipyModel
+from copulas.utils import EPSILON
 
 
 class BetaUnivariate(ScipyModel):
@@ -25,9 +26,19 @@ class BetaUnivariate(ScipyModel):
         }
 
     def _fit(self, X):
-        loc = np.min(X)
-        scale = np.max(X) - loc
-        a, b, loc, scale = beta.fit(X, loc=loc, scale=scale)
+        min_x = np.min(X)
+        max_x = np.max(X)
+        a, b, loc, scale = beta.fit(X, loc=min_x, scale=max_x - min_x)
+
+        if loc > max_x or scale + loc < min_x:
+            raise ValueError(
+                'Converged parameters for beta distribution are '
+                'outside the min/max range of the data.'
+            )
+
+        if scale < EPSILON:
+            raise ValueError('Converged parameters for beta distribution have a near-zero range.')
+
         self._params = {'loc': loc, 'scale': scale, 'a': a, 'b': b}
 
     def _is_constant(self):


Test patch:
diff --git a/tests/end-to-end/univariate/test_beta.py b/tests/end-to-end/univariate/test_beta.py
index 1f45300..fd85b2e 100644
--- a/tests/end-to-end/univariate/test_beta.py
+++ b/tests/end-to-end/univariate/test_beta.py
@@ -3,6 +3,7 @@ import tempfile
 from unittest import TestCase
 
 import numpy as np
+import pytest
 from scipy.stats import beta
 
 from copulas.univariate import BetaUnivariate
@@ -43,6 +44,67 @@ class TestGaussian(TestCase):
         assert model._constant_value == 5
         np.testing.assert_equal(np.full(50, 5), model.sample(50))
 
+    def test_fit_raises(self):
+        """Test it for dataset that fails."""
+        model = BetaUnivariate()
+        data = np.array([  # From GH #472
+            3.337169,
+            6.461266,
+            4.871053,
+            4.206772,
+            5.157541,
+            3.437069,
+            6.712143,
+            5.135402,
+            6.453203,
+            4.623059,
+            5.827161,
+            5.291858,
+            5.571134,
+            5.441359,
+            4.816826,
+            3.277817,
+            4.215228,
+            4.48338,
+            4.345968,
+            6.125759,
+            4.860464,
+            6.511877,
+            3.959057,
+            4.882996,
+            6.058503,
+            3.337436,
+            5.06921,
+            4.414371,
+            4.564768,
+            5.1014,
+            4.161663,
+            5.757317,
+            4.032375,
+            3.907653,
+            4.269559,
+            4.08505,
+            6.811531,
+            5.02597,
+            5.438358,
+            3.44442,
+            3.462209,
+            4.871354,
+            5.947369,
+            4.167546,
+            4.692054,
+            5.542011,
+            4.926634,
+            4.491286,
+            5.344663,
+            4.526089,
+            1.645776,
+        ])
+
+        err_msg = 'Converged parameters for beta distribution have a near-zero range.'
+        with pytest.raises(ValueError, match=err_msg):
+            model.fit(data)
+
     def test_pdf(self):
         model = BetaUnivariate()
         model.fit(self.data)
diff --git a/tests/unit/univariate/test_beta.py b/tests/unit/univariate/test_beta.py
index 4bf30d3..2f0b74a 100644
--- a/tests/unit/univariate/test_beta.py
+++ b/tests/unit/univariate/test_beta.py
@@ -2,6 +2,7 @@ from unittest import TestCase
 from unittest.mock import patch
 
 import numpy as np
+import pytest
 from scipy.stats import beta
 
 from copulas.univariate import BetaUnivariate
@@ -25,6 +26,46 @@ class TestBetaUnivariate(TestCase):
         for key, value in distribution._params.items():
             np.testing.assert_allclose(value, expected[key], atol=0.3)
 
+    @patch('copulas.univariate.beta.beta')
+    def test__fit_raises_value_error_if_scale_is_near_zero(self, mock_beta):
+        mock_beta.fit.return_value = (1, 0, 1, 1e-8)
+        distribution = BetaUnivariate()
+
+        data = np.array([1, 2, 3, 4])
+        err_msg = 'Converged parameters for beta distribution have a near-zero range.'
+        with pytest.raises(ValueError, match=err_msg):
+            distribution._fit(data)
+
+        mock_beta.fit.assert_called_once_with(data, loc=1, scale=3)
+
+    @patch('copulas.univariate.beta.beta')
+    def test__fit_raises_value_error_if_parameters_are_higher_than_range(self, mock_beta):
+        mock_beta.fit.return_value = (5, 1, 5, 1)
+        distribution = BetaUnivariate()
+
+        data = np.array([7, 8, 9, 10])
+        err_msg = (
+            'Converged parameters for beta distribution are outside the min/max range of the data.'
+        )
+        with pytest.raises(ValueError, match=err_msg):
+            distribution._fit(data)
+
+        mock_beta.fit.assert_called_once_with(data, loc=7, scale=3)
+
+    @patch('copulas.univariate.beta.beta')
+    def test__fit_raises_value_error_if_parameters_are_lower_than_range(self, mock_beta):
+        mock_beta.fit.return_value = (5, 1, 5, 1)
+        distribution = BetaUnivariate()
+
+        data = np.array([1, 2, 3, 4])
+        err_msg = (
+            'Converged parameters for beta distribution are outside the min/max range of the data.'
+        )
+        with pytest.raises(ValueError, match=err_msg):
+            distribution._fit(data)
+
+        mock_beta.fit.assert_called_once_with(data, loc=1, scale=3)
+
     def test__is_constant_true(self):
         distribution = BetaUnivariate()