From 5c4af5fe56e3d2c850966fd34be33695b3f08bf2 Mon Sep 17 00:00:00 2001 From: MyznikovFD Date: Mon, 25 May 2026 16:05:26 +0300 Subject: [PATCH] fix(families): raise ValueError for x outside support in exponential and uniform --- .../builtins/continuous/exponential.py | 10 ++--- .../families/builtins/continuous/uniform.py | 12 +++--- src/pysatl_core/families/parametric_family.py | 3 ++ .../builtins/continuous/test_exponential.py | 7 ++++ .../builtins/continuous/test_uniform.py | 38 ++++++++++--------- 5 files changed, 41 insertions(+), 29 deletions(-) diff --git a/src/pysatl_core/families/builtins/continuous/exponential.py b/src/pysatl_core/families/builtins/continuous/exponential.py index b876def..1d311a5 100644 --- a/src/pysatl_core/families/builtins/continuous/exponential.py +++ b/src/pysatl_core/families/builtins/continuous/exponential.py @@ -233,8 +233,7 @@ def _base_score(parameters: Parametrization, x: NumericArray) -> NumericArray: The derivative with respect to λ is: ∂/∂λ log f = 1/λ - x (for x ≥ 0). - For points x < 0 the density is zero; we return 0 for numerical stability - (though the score is technically undefined there). + For points x < 0 the density is zero; we return ValueError. Parameters ---------- @@ -251,9 +250,10 @@ def _base_score(parameters: Parametrization, x: NumericArray) -> NumericArray: """ params = cast(_Rate, parameters) lam = params.lambda_ - inside = x >= 0 - grad = np.where(inside, 1.0 / lam - x, 0.0) - return grad[..., np.newaxis] # shape (..., 1) + if np.any(x < 0): + raise ValueError(f"Score is undefined for x < 0 (outside support). Got x = {x}") + grad = 1.0 / lam - x + return grad[..., np.newaxis] Exponential = ParametricFamily( name=FamilyName.EXPONENTIAL, diff --git a/src/pysatl_core/families/builtins/continuous/uniform.py b/src/pysatl_core/families/builtins/continuous/uniform.py index bced910..34b355b 100644 --- a/src/pysatl_core/families/builtins/continuous/uniform.py +++ b/src/pysatl_core/families/builtins/continuous/uniform.py @@ -272,8 +272,7 @@ def _base_score(parameters: Parametrization, x: NumericArray) -> NumericArray: ∂/∂a log f = 1/(b - a) ∂/∂b log f = -1/(b - a) - For points outside the support, the gradient is set to 0 (since density is zero, - but the score is typically considered undefined; we return 0 for numerical safety). + For points outside the support, there is ValueError. Parameters ---------- @@ -291,11 +290,12 @@ def _base_score(parameters: Parametrization, x: NumericArray) -> NumericArray: params = cast(_Standard, parameters) a = params.lower_bound b = params.upper_bound - width = b - a + if np.any((x < a) | (x > b)): + raise ValueError(f"Score is undefined for x outside support [{a}, {b}]. Got x = {x}") - inside = (x >= a) & (x <= b) - grad_a = np.where(inside, 1.0 / width, 0.0) - grad_b = np.where(inside, -1.0 / width, 0.0) + width = b - a + grad_a = np.full_like(x, 1.0 / width) + grad_b = np.full_like(x, -1.0 / width) return np.stack([grad_a, grad_b], axis=-1) Uniform = ParametricFamily( diff --git a/src/pysatl_core/families/parametric_family.py b/src/pysatl_core/families/parametric_family.py index 1ed66e5..6790b18 100644 --- a/src/pysatl_core/families/parametric_family.py +++ b/src/pysatl_core/families/parametric_family.py @@ -438,6 +438,9 @@ def score(self, parameters: Parametrization, x: NumericArray) -> NumericArray: NumericArray Gradient with respect to the parameters of the given parametrization. Shape is (..., d), where d is the number of parameters of the parametrization. + + ValueError + If any value in `x` lies outside the distribution's support. """ if self._base_score is None: raise ValueError( diff --git a/tests/unit/families/builtins/continuous/test_exponential.py b/tests/unit/families/builtins/continuous/test_exponential.py index 6363c47..ae6bc0c 100644 --- a/tests/unit/families/builtins/continuous/test_exponential.py +++ b/tests/unit/families/builtins/continuous/test_exponential.py @@ -273,6 +273,13 @@ def test_score_shape(self, parametrization_name, params): assert grad.shape == (len(x), 1) assert grad.dtype == float + def test_score_raises_for_x_outside_support(self): + lam = 0.5 + dist = self.exponential_family(lambda_=lam) + x_bad = np.array([-0.1, -1.0]) + with pytest.raises(ValueError, match="Score is undefined for x < 0"): + dist.family.score(dist.parametrization, x_bad) + class TestExponentialFamilyEdgeCases(BaseDistributionTest): """Test edge cases and error conditions for exponential distribution.""" diff --git a/tests/unit/families/builtins/continuous/test_uniform.py b/tests/unit/families/builtins/continuous/test_uniform.py index 8d63f7f..8ba6fb4 100644 --- a/tests/unit/families/builtins/continuous/test_uniform.py +++ b/tests/unit/families/builtins/continuous/test_uniform.py @@ -248,14 +248,11 @@ def test_score_standard_parametrization(self): """Test SCORE for standard parametrization against analytical formula.""" a, b = 2.0, 5.0 dist = self.uniform_family(lower_bound=a, upper_bound=b) - x = np.array([1.0, 2.0, 3.5, 5.0, 6.0]) + x = np.array([2.0, 3.5, 5.0]) grad = dist.family.score(dist.parametrization, x) width = b - a - inside = (x >= a) & (x <= b) - expected = np.stack( - [np.where(inside, 1.0 / width, 0.0), np.where(inside, -1.0 / width, 0.0)], axis=-1 - ) + expected = np.tile([1.0 / width, -1.0 / width], (len(x), 1)) np.testing.assert_allclose(grad, expected, rtol=self.CALCULATION_PRECISION) @@ -263,17 +260,16 @@ def test_score_meanWidth_parametrization(self): """Test SCORE for meanWidth parametrization via chain rule.""" mean, width = 3.5, 3.0 # corresponds to a=2, b=5 dist = self.uniform_family(parametrization_name="meanWidth", mean=mean, width=width) - x = np.array([1.0, 2.0, 3.5, 5.0, 6.0]) + x = np.array([2.0, 3.5, 5.0]) grad = dist.family.score(dist.parametrization, x) - a, b = 2.0, 5.0 - inside = (x >= a) & (x <= b) - base_grad_a = np.where(inside, 1.0 / 3.0, 0.0) - base_grad_b = np.where(inside, -1.0 / 3.0, 0.0) + _a, _b = 2.0, 5.0 + base_grad_a = 1.0 / 3.0 + base_grad_b = -1.0 / 3.0 # Transform to (mean, width) expected_mean = 0.5 * (base_grad_a + base_grad_b) expected_width = -base_grad_a + base_grad_b - expected = np.stack([expected_mean, expected_width], axis=-1) + expected = np.tile([expected_mean, expected_width], (len(x), 1)) np.testing.assert_allclose(grad, expected, rtol=self.CALCULATION_PRECISION) @@ -283,17 +279,16 @@ def test_score_minRange_parametrization(self): dist = self.uniform_family( parametrization_name="minRange", minimum=minimum, range_val=range_val ) - x = np.array([1.0, 2.0, 3.5, 5.0, 6.0]) + x = np.array([2.0, 3.5, 5.0]) grad = dist.family.score(dist.parametrization, x) - a, b = 2.0, 5.0 - inside = (x >= a) & (x <= b) - base_grad_a = np.where(inside, 1.0 / 3.0, 0.0) - base_grad_b = np.where(inside, -1.0 / 3.0, 0.0) + _a, _b = 2.0, 5.0 + base_grad_a = 1.0 / 3.0 + base_grad_b = -1.0 / 3.0 # Transform to (minimum, range_val) expected_min = base_grad_a expected_range = -base_grad_a + base_grad_b - expected = np.stack([expected_min, expected_range], axis=-1) + expected = np.tile([expected_min, expected_range], (len(x), 1)) np.testing.assert_allclose(grad, expected, rtol=self.CALCULATION_PRECISION) @@ -327,12 +322,19 @@ def logpdf_b(b_val: float) -> float: ) def test_score_shape(self, parametrization_name, params): """Test SCORE shape for all uniform parametrizations.""" - x = np.array([1.0, 2.0, 3.5, 5.0, 6.0]) + x = np.array([2.0, 3.5, 5.0]) dist = self.uniform_family(parametrization_name=parametrization_name, **params) grad = dist.family.score(dist.parametrization, x) assert grad.shape == (len(x), 2) assert grad.dtype == float + def test_score_raises_for_x_outside_support(self): + a, b = 2.0, 5.0 + dist = self.uniform_family(lower_bound=a, upper_bound=b) + x_bad = np.array([1.0, 6.0]) + with pytest.raises(ValueError, match="Score is undefined for x outside support"): + dist.family.score(dist.parametrization, x_bad) + class TestUniformFamilyEdgeCases(BaseDistributionTest): """Test edge cases and error conditions for uniform distribution."""