Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/pysatl_core/families/builtins/continuous/exponential.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion src/pysatl_core/families/builtins/continuous/normal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 16 additions & 6 deletions src/pysatl_core/families/builtins/continuous/uniform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand All @@ -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}")
Comment thread
MyznikovFD marked this conversation as resolved.

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(
Expand Down
7 changes: 7 additions & 0 deletions src/pysatl_core/families/parametric_family.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/families/builtins/continuous/test_exponential.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/families/builtins/continuous/test_normal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
57 changes: 39 additions & 18 deletions tests/unit/families/builtins/continuous/test_uniform.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,32 +248,28 @@ 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)

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)

Expand All @@ -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)

Expand All @@ -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",
[
Expand All @@ -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."""
Expand Down
Loading