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
10 changes: 5 additions & 5 deletions src/pysatl_core/families/builtins/continuous/exponential.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand All @@ -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,
Expand Down
12 changes: 6 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, there is ValueError.

Parameters
----------
Expand All @@ -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(
Expand Down
3 changes: 3 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,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(
Expand Down
7 changes: 7 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,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."""
Expand Down
38 changes: 20 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 Down Expand Up @@ -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."""
Expand Down
Loading