From c4e2fcfc50d76373890d2a6be49de53ee8288b66 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 builtins --- .../builtins/continuous/exponential.py | 18 ++++-- .../families/builtins/continuous/normal.py | 10 +++- .../families/builtins/continuous/uniform.py | 22 +++++-- src/pysatl_core/families/parametric_family.py | 7 +++ .../builtins/continuous/test_exponential.py | 15 +++++ .../builtins/continuous/test_normal.py | 8 +++ .../builtins/continuous/test_uniform.py | 57 +++++++++++++------ 7 files changed, 106 insertions(+), 31 deletions(-) diff --git a/src/pysatl_core/families/builtins/continuous/exponential.py b/src/pysatl_core/families/builtins/continuous/exponential.py index b876def6..013baf08 100644 --- a/src/pysatl_core/families/builtins/continuous/exponential.py +++ b/src/pysatl_core/families/builtins/continuous/exponential.py @@ -233,9 +233,6 @@ 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). - Parameters ---------- parameters : Parametrization @@ -248,12 +245,21 @@ def _base_score(parameters: Parametrization, x: NumericArray) -> NumericArray: NumericArray Gradient array of shape (..., 1) where the last axis corresponds to d(log f)/d(lambda_). + + Raises + ------ + ValueError + If any element of x is negative or non‑finite (inf or nan), + i.e., outside the support [0, ∞). """ 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) or np.any(~np.isfinite(x)): + raise ValueError( + f"Score is undefined for x < 0 or non‑finite x (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/normal.py b/src/pysatl_core/families/builtins/continuous/normal.py index 12a21df4..9bb73650 100644 --- a/src/pysatl_core/families/builtins/continuous/normal.py +++ b/src/pysatl_core/families/builtins/continuous/normal.py @@ -251,13 +251,21 @@ def _base_score(parameters: Parametrization, x: NumericArray) -> NumericArray: ------- NumericArray Gradient array of shape (..., 2). + + Raises + ------ + ValueError + If any element of x is non‑finite (inf or nan), i.e., outside the + real support of the normal distribution. """ params = cast(_MeanStd, parameters) mu = params.mu sigma = params.sigma - z = (x - mu) / sigma + if np.any(~np.isfinite(x)): + raise ValueError(f"Score is undefined for non‑finite x (outside support). Got x = {x}") + z = (x - mu) / sigma grad_mu = z / sigma grad_sigma = (z * z - 1) / sigma return np.stack([grad_mu, grad_sigma], axis=-1) diff --git a/src/pysatl_core/families/builtins/continuous/uniform.py b/src/pysatl_core/families/builtins/continuous/uniform.py index bced9108..95bf0ae9 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 or non‑finite (inf, nan), raises ValueError. Parameters ---------- @@ -287,15 +286,26 @@ def _base_score(parameters: Parametrization, x: NumericArray) -> NumericArray: NumericArray Gradient array of shape (..., 2) where last axis corresponds to [d(log f)/d(lower_bound), d(log f)/d(upper_bound)]. + + Raises + ------ + ValueError + If any element of x lies outside [lower_bound, upper_bound] + or is non‑finite (inf or nan). """ params = cast(_Standard, parameters) a = params.lower_bound b = params.upper_bound - width = b - a - 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) + # Reject non‑finite values before support check + if np.any(~np.isfinite(x)): + raise ValueError(f"Score is undefined for non‑finite x (outside support). Got x = {x}") + if np.any((x < a) | (x > b)): + raise ValueError(f"Score is undefined for x outside support [{a}, {b}]. Got x = {x}") + + width = b - a + grad_a = np.full_like(x, 1.0 / width, dtype=np.float64) + grad_b = np.full_like(x, -1.0 / width, dtype=np.float64) 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 1ed66e50..4eb03127 100644 --- a/src/pysatl_core/families/parametric_family.py +++ b/src/pysatl_core/families/parametric_family.py @@ -438,6 +438,13 @@ 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. + + Raises + ------ + ValueError + If this family does not provide a score implementation. Family-specific + score implementations may also raise ValueError when the score is undefined + for the supplied evaluation points, such as points outside 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 6363c47b..138c487f 100644 --- a/tests/unit/families/builtins/continuous/test_exponential.py +++ b/tests/unit/families/builtins/continuous/test_exponential.py @@ -273,6 +273,21 @@ def test_score_shape(self, parametrization_name, params): assert grad.shape == (len(x), 1) assert grad.dtype == float + @pytest.mark.parametrize("bad_x", [np.inf, -np.inf, np.nan]) + def test_score_raises_for_nonfinite_x(self, bad_x): + """Test that SCORE raises ValueError for non-finite x (inf, nan).""" + lam = 0.5 + dist = self.exponential_family(lambda_=lam) + with pytest.raises(ValueError, match="Score is undefined for x < 0 or non‑finite x"): + dist.family.score(dist.parametrization, np.array([bad_x])) + + 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_normal.py b/tests/unit/families/builtins/continuous/test_normal.py index ca593495..90a80f48 100644 --- a/tests/unit/families/builtins/continuous/test_normal.py +++ b/tests/unit/families/builtins/continuous/test_normal.py @@ -352,6 +352,14 @@ def test_score_shape(self, parametrization_name, params): assert grad.dtype == float assert not np.any(np.isnan(grad)) + @pytest.mark.parametrize("bad_x", [np.inf, -np.inf, np.nan]) + def test_score_raises_for_nonfinite_x(self, bad_x): + """Test that SCORE raises ValueError for non-finite x (inf, nan).""" + mu, sigma = 2.0, 1.5 + dist = self.normal_family(mu=mu, sigma=sigma) + with pytest.raises(ValueError, match="Score is undefined for non‑finite x"): + dist.family.score(dist.parametrization, np.array([bad_x])) + class TestNormalFamilyEdgeCases(BaseDistributionTest): """Test edge cases and error conditions.""" diff --git a/tests/unit/families/builtins/continuous/test_uniform.py b/tests/unit/families/builtins/continuous/test_uniform.py index 8d63f7fd..6c4eb6e0 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) @@ -317,6 +312,17 @@ def logpdf_b(b_val: float) -> float: np.testing.assert_allclose(grad[0, 0], grad_a_num, rtol=1e-5) np.testing.assert_allclose(grad[0, 1], grad_b_num, rtol=1e-5) + def test_score_with_integer_x(self): + """Test that SCORE returns float gradient even for integer x.""" + a, b = 2.0, 5.0 + dist = self.uniform_family(lower_bound=a, upper_bound=b) + x_int = np.array([2, 3, 4, 5], dtype=int) + grad = dist.family.score(dist.parametrization, x_int) + assert grad.dtype == np.float64 + width = b - a + expected = np.full((len(x_int), 2), [1.0 / width, -1.0 / width], dtype=np.float64) + np.testing.assert_allclose(grad, expected, rtol=self.CALCULATION_PRECISION) + @pytest.mark.parametrize( "parametrization_name, params", [ @@ -327,12 +333,27 @@ 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 + @pytest.mark.parametrize("bad_x", [np.inf, -np.inf, np.nan]) + def test_score_raises_for_nonfinite_x(self, bad_x): + """Test that SCORE raises ValueError for non-finite x (inf, nan).""" + a, b = 2.0, 5.0 + dist = self.uniform_family(lower_bound=a, upper_bound=b) + with pytest.raises(ValueError, match="Score is undefined for non‑finite x"): + dist.family.score(dist.parametrization, np.array([bad_x])) + + 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."""