Skip to content

Commit b8b7845

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

7 files changed

Lines changed: 104 additions & 31 deletions

File tree

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,6 @@ 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).
238-
239236
Parameters
240237
----------
241238
parameters : Parametrization
@@ -248,12 +245,21 @@ def _base_score(parameters: Parametrization, x: NumericArray) -> NumericArray:
248245
NumericArray
249246
Gradient array of shape (..., 1) where the last axis corresponds to
250247
d(log f)/d(lambda_).
248+
249+
Raises
250+
------
251+
ValueError
252+
If any element of x is negative or non‑finite (inf or nan),
253+
i.e., outside the support [0, ∞).
251254
"""
252255
params = cast(_Rate, parameters)
253256
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)
257+
if np.any(x < 0) or np.any(~np.isfinite(x)):
258+
raise ValueError(
259+
f"Score is undefined for x < 0 or non‑finite x (outside support). Got x = {x}"
260+
)
261+
grad = 1.0 / lam - x
262+
return grad[..., np.newaxis]
257263

258264
Exponential = ParametricFamily(
259265
name=FamilyName.EXPONENTIAL,

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,13 +251,21 @@ def _base_score(parameters: Parametrization, x: NumericArray) -> NumericArray:
251251
-------
252252
NumericArray
253253
Gradient array of shape (..., 2).
254+
255+
Raises
256+
------
257+
ValueError
258+
If any element of x is non‑finite (inf or nan), i.e., outside the
259+
real support of the normal distribution.
254260
"""
255261
params = cast(_MeanStd, parameters)
256262
mu = params.mu
257263
sigma = params.sigma
258264

259-
z = (x - mu) / sigma
265+
if np.any(~np.isfinite(x)):
266+
raise ValueError(f"Score is undefined for non‑finite x (outside support). Got x = {x}")
260267

268+
z = (x - mu) / sigma
261269
grad_mu = z / sigma
262270
grad_sigma = (z * z - 1) / sigma
263271
return np.stack([grad_mu, grad_sigma], axis=-1)

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

Lines changed: 16 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 or non‑finite (inf, nan), raises ValueError.
277276
278277
Parameters
279278
----------
@@ -287,15 +286,26 @@ def _base_score(parameters: Parametrization, x: NumericArray) -> NumericArray:
287286
NumericArray
288287
Gradient array of shape (..., 2) where last axis corresponds to
289288
[d(log f)/d(lower_bound), d(log f)/d(upper_bound)].
289+
290+
Raises
291+
------
292+
ValueError
293+
If any element of x lies outside [lower_bound, upper_bound]
294+
or is non‑finite (inf or nan).
290295
"""
291296
params = cast(_Standard, parameters)
292297
a = params.lower_bound
293298
b = params.upper_bound
294-
width = b - a
295299

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)
300+
# Reject non‑finite values before support check
301+
if np.any(~np.isfinite(x)):
302+
raise ValueError(f"Score is undefined for non‑finite x (outside support). Got x = {x}")
303+
if np.any((x < a) | (x > b)):
304+
raise ValueError(f"Score is undefined for x outside support [{a}, {b}]. Got x = {x}")
305+
306+
width = b - a
307+
grad_a = np.full_like(x, 1.0 / width, dtype=np.float64)
308+
grad_b = np.full_like(x, -1.0 / width, dtype=np.float64)
299309
return np.stack([grad_a, grad_b], axis=-1)
300310

301311
Uniform = ParametricFamily(

src/pysatl_core/families/parametric_family.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,11 @@ 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+
Raises
443+
------
444+
ValueError
445+
If any value in `x` lies outside the distribution's support.
441446
"""
442447
if self._base_score is None:
443448
raise ValueError(

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

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

276+
@pytest.mark.parametrize("bad_x", [np.inf, -np.inf, np.nan])
277+
def test_score_raises_for_nonfinite_x(self, bad_x):
278+
"""Test that SCORE raises ValueError for non-finite x (inf, nan)."""
279+
lam = 0.5
280+
dist = self.exponential_family(lambda_=lam)
281+
with pytest.raises(ValueError, match="Score is undefined for x < 0 or non‑finite x"):
282+
dist.family.score(dist.parametrization, np.array([bad_x]))
283+
284+
def test_score_raises_for_x_outside_support(self):
285+
lam = 0.5
286+
dist = self.exponential_family(lambda_=lam)
287+
x_bad = np.array([-0.1, -1.0])
288+
with pytest.raises(ValueError, match="Score is undefined for x < 0"):
289+
dist.family.score(dist.parametrization, x_bad)
290+
276291

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

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,14 @@ def test_score_shape(self, parametrization_name, params):
352352
assert grad.dtype == float
353353
assert not np.any(np.isnan(grad))
354354

355+
@pytest.mark.parametrize("bad_x", [np.inf, -np.inf, np.nan])
356+
def test_score_raises_for_nonfinite_x(self, bad_x):
357+
"""Test that SCORE raises ValueError for non-finite x (inf, nan)."""
358+
mu, sigma = 2.0, 1.5
359+
dist = self.normal_family(mu=mu, sigma=sigma)
360+
with pytest.raises(ValueError, match="Score is undefined for non‑finite x"):
361+
dist.family.score(dist.parametrization, np.array([bad_x]))
362+
355363

356364
class TestNormalFamilyEdgeCases(BaseDistributionTest):
357365
"""Test edge cases and error conditions."""

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

Lines changed: 39 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

@@ -317,6 +312,17 @@ def logpdf_b(b_val: float) -> float:
317312
np.testing.assert_allclose(grad[0, 0], grad_a_num, rtol=1e-5)
318313
np.testing.assert_allclose(grad[0, 1], grad_b_num, rtol=1e-5)
319314

315+
def test_score_with_integer_x(self):
316+
"""Test that SCORE returns float gradient even for integer x."""
317+
a, b = 2.0, 5.0
318+
dist = self.uniform_family(lower_bound=a, upper_bound=b)
319+
x_int = np.array([2, 3, 4, 5], dtype=int)
320+
grad = dist.family.score(dist.parametrization, x_int)
321+
assert grad.dtype == np.float64
322+
width = b - a
323+
expected = np.full((len(x_int), 2), [1.0 / width, -1.0 / width], dtype=np.float64)
324+
np.testing.assert_allclose(grad, expected, rtol=self.CALCULATION_PRECISION)
325+
320326
@pytest.mark.parametrize(
321327
"parametrization_name, params",
322328
[
@@ -327,12 +333,27 @@ def logpdf_b(b_val: float) -> float:
327333
)
328334
def test_score_shape(self, parametrization_name, params):
329335
"""Test SCORE shape for all uniform parametrizations."""
330-
x = np.array([1.0, 2.0, 3.5, 5.0, 6.0])
336+
x = np.array([2.0, 3.5, 5.0])
331337
dist = self.uniform_family(parametrization_name=parametrization_name, **params)
332338
grad = dist.family.score(dist.parametrization, x)
333339
assert grad.shape == (len(x), 2)
334340
assert grad.dtype == float
335341

342+
@pytest.mark.parametrize("bad_x", [np.inf, -np.inf, np.nan])
343+
def test_score_raises_for_nonfinite_x(self, bad_x):
344+
"""Test that SCORE raises ValueError for non-finite x (inf, nan)."""
345+
a, b = 2.0, 5.0
346+
dist = self.uniform_family(lower_bound=a, upper_bound=b)
347+
with pytest.raises(ValueError, match="Score is undefined for non‑finite x"):
348+
dist.family.score(dist.parametrization, np.array([bad_x]))
349+
350+
def test_score_raises_for_x_outside_support(self):
351+
a, b = 2.0, 5.0
352+
dist = self.uniform_family(lower_bound=a, upper_bound=b)
353+
x_bad = np.array([1.0, 6.0])
354+
with pytest.raises(ValueError, match="Score is undefined for x outside support"):
355+
dist.family.score(dist.parametrization, x_bad)
356+
336357

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

0 commit comments

Comments
 (0)