Skip to content

Commit 5c4af5f

Browse files
committed
fix(families): raise ValueError for x outside support in exponential and uniform
1 parent 866ef2c commit 5c4af5f

5 files changed

Lines changed: 41 additions & 29 deletions

File tree

src/pysatl_core/families/builtins/continuous/exponential.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,7 @@ def _base_score(parameters: Parametrization, x: NumericArray) -> NumericArray:
233233
The derivative with respect to λ is:
234234
∂/∂λ log f = 1/λ - x (for x ≥ 0).
235235
236-
For points x < 0 the density is zero; we return 0 for numerical stability
237-
(though the score is technically undefined there).
236+
For points x < 0 the density is zero; we return ValueError.
238237
239238
Parameters
240239
----------
@@ -251,9 +250,10 @@ def _base_score(parameters: Parametrization, x: NumericArray) -> NumericArray:
251250
"""
252251
params = cast(_Rate, parameters)
253252
lam = params.lambda_
254-
inside = x >= 0
255-
grad = np.where(inside, 1.0 / lam - x, 0.0)
256-
return grad[..., np.newaxis] # shape (..., 1)
253+
if np.any(x < 0):
254+
raise ValueError(f"Score is undefined for x < 0 (outside support). Got x = {x}")
255+
grad = 1.0 / lam - x
256+
return grad[..., np.newaxis]
257257

258258
Exponential = ParametricFamily(
259259
name=FamilyName.EXPONENTIAL,

src/pysatl_core/families/builtins/continuous/uniform.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -272,8 +272,7 @@ def _base_score(parameters: Parametrization, x: NumericArray) -> NumericArray:
272272
∂/∂a log f = 1/(b - a)
273273
∂/∂b log f = -1/(b - a)
274274
275-
For points outside the support, the gradient is set to 0 (since density is zero,
276-
but the score is typically considered undefined; we return 0 for numerical safety).
275+
For points outside the support, there is ValueError.
277276
278277
Parameters
279278
----------
@@ -291,11 +290,12 @@ def _base_score(parameters: Parametrization, x: NumericArray) -> NumericArray:
291290
params = cast(_Standard, parameters)
292291
a = params.lower_bound
293292
b = params.upper_bound
294-
width = b - a
293+
if np.any((x < a) | (x > b)):
294+
raise ValueError(f"Score is undefined for x outside support [{a}, {b}]. Got x = {x}")
295295

296-
inside = (x >= a) & (x <= b)
297-
grad_a = np.where(inside, 1.0 / width, 0.0)
298-
grad_b = np.where(inside, -1.0 / width, 0.0)
296+
width = b - a
297+
grad_a = np.full_like(x, 1.0 / width)
298+
grad_b = np.full_like(x, -1.0 / width)
299299
return np.stack([grad_a, grad_b], axis=-1)
300300

301301
Uniform = ParametricFamily(

src/pysatl_core/families/parametric_family.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,9 @@ def score(self, parameters: Parametrization, x: NumericArray) -> NumericArray:
438438
NumericArray
439439
Gradient with respect to the parameters of the given parametrization.
440440
Shape is (..., d), where d is the number of parameters of the parametrization.
441+
442+
ValueError
443+
If any value in `x` lies outside the distribution's support.
441444
"""
442445
if self._base_score is None:
443446
raise ValueError(

tests/unit/families/builtins/continuous/test_exponential.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,13 @@ def test_score_shape(self, parametrization_name, params):
273273
assert grad.shape == (len(x), 1)
274274
assert grad.dtype == float
275275

276+
def test_score_raises_for_x_outside_support(self):
277+
lam = 0.5
278+
dist = self.exponential_family(lambda_=lam)
279+
x_bad = np.array([-0.1, -1.0])
280+
with pytest.raises(ValueError, match="Score is undefined for x < 0"):
281+
dist.family.score(dist.parametrization, x_bad)
282+
276283

277284
class TestExponentialFamilyEdgeCases(BaseDistributionTest):
278285
"""Test edge cases and error conditions for exponential distribution."""

tests/unit/families/builtins/continuous/test_uniform.py

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -248,32 +248,28 @@ def test_score_standard_parametrization(self):
248248
"""Test SCORE for standard parametrization against analytical formula."""
249249
a, b = 2.0, 5.0
250250
dist = self.uniform_family(lower_bound=a, upper_bound=b)
251-
x = np.array([1.0, 2.0, 3.5, 5.0, 6.0])
251+
x = np.array([2.0, 3.5, 5.0])
252252
grad = dist.family.score(dist.parametrization, x)
253253

254254
width = b - a
255-
inside = (x >= a) & (x <= b)
256-
expected = np.stack(
257-
[np.where(inside, 1.0 / width, 0.0), np.where(inside, -1.0 / width, 0.0)], axis=-1
258-
)
255+
expected = np.tile([1.0 / width, -1.0 / width], (len(x), 1))
259256

260257
np.testing.assert_allclose(grad, expected, rtol=self.CALCULATION_PRECISION)
261258

262259
def test_score_meanWidth_parametrization(self):
263260
"""Test SCORE for meanWidth parametrization via chain rule."""
264261
mean, width = 3.5, 3.0 # corresponds to a=2, b=5
265262
dist = self.uniform_family(parametrization_name="meanWidth", mean=mean, width=width)
266-
x = np.array([1.0, 2.0, 3.5, 5.0, 6.0])
263+
x = np.array([2.0, 3.5, 5.0])
267264
grad = dist.family.score(dist.parametrization, x)
268265

269-
a, b = 2.0, 5.0
270-
inside = (x >= a) & (x <= b)
271-
base_grad_a = np.where(inside, 1.0 / 3.0, 0.0)
272-
base_grad_b = np.where(inside, -1.0 / 3.0, 0.0)
266+
_a, _b = 2.0, 5.0
267+
base_grad_a = 1.0 / 3.0
268+
base_grad_b = -1.0 / 3.0
273269
# Transform to (mean, width)
274270
expected_mean = 0.5 * (base_grad_a + base_grad_b)
275271
expected_width = -base_grad_a + base_grad_b
276-
expected = np.stack([expected_mean, expected_width], axis=-1)
272+
expected = np.tile([expected_mean, expected_width], (len(x), 1))
277273

278274
np.testing.assert_allclose(grad, expected, rtol=self.CALCULATION_PRECISION)
279275

@@ -283,17 +279,16 @@ def test_score_minRange_parametrization(self):
283279
dist = self.uniform_family(
284280
parametrization_name="minRange", minimum=minimum, range_val=range_val
285281
)
286-
x = np.array([1.0, 2.0, 3.5, 5.0, 6.0])
282+
x = np.array([2.0, 3.5, 5.0])
287283
grad = dist.family.score(dist.parametrization, x)
288284

289-
a, b = 2.0, 5.0
290-
inside = (x >= a) & (x <= b)
291-
base_grad_a = np.where(inside, 1.0 / 3.0, 0.0)
292-
base_grad_b = np.where(inside, -1.0 / 3.0, 0.0)
285+
_a, _b = 2.0, 5.0
286+
base_grad_a = 1.0 / 3.0
287+
base_grad_b = -1.0 / 3.0
293288
# Transform to (minimum, range_val)
294289
expected_min = base_grad_a
295290
expected_range = -base_grad_a + base_grad_b
296-
expected = np.stack([expected_min, expected_range], axis=-1)
291+
expected = np.tile([expected_min, expected_range], (len(x), 1))
297292

298293
np.testing.assert_allclose(grad, expected, rtol=self.CALCULATION_PRECISION)
299294

@@ -327,12 +322,19 @@ def logpdf_b(b_val: float) -> float:
327322
)
328323
def test_score_shape(self, parametrization_name, params):
329324
"""Test SCORE shape for all uniform parametrizations."""
330-
x = np.array([1.0, 2.0, 3.5, 5.0, 6.0])
325+
x = np.array([2.0, 3.5, 5.0])
331326
dist = self.uniform_family(parametrization_name=parametrization_name, **params)
332327
grad = dist.family.score(dist.parametrization, x)
333328
assert grad.shape == (len(x), 2)
334329
assert grad.dtype == float
335330

331+
def test_score_raises_for_x_outside_support(self):
332+
a, b = 2.0, 5.0
333+
dist = self.uniform_family(lower_bound=a, upper_bound=b)
334+
x_bad = np.array([1.0, 6.0])
335+
with pytest.raises(ValueError, match="Score is undefined for x outside support"):
336+
dist.family.score(dist.parametrization, x_bad)
337+
336338

337339
class TestUniformFamilyEdgeCases(BaseDistributionTest):
338340
"""Test edge cases and error conditions for uniform distribution."""

0 commit comments

Comments
 (0)