|
10 | 10 |
|
11 | 11 | Right-unbounded tests use a truncated geometric-like PMF on {0, 1, 2, ...} |
12 | 12 | with P(X=k) = 0.5^(k+1) for k >= 0. |
| 13 | +
|
| 14 | +Right-open CDF convention (right_closed=False) tests verify: |
| 15 | +- F⁻(x) = P(ξ < x) excludes mass at x (differs from standard F(x) = P(ξ ≤ x) at support points). |
| 16 | +- The identity 1 - F⁻(x) = P(ξ ≥ x), needed for computing the CDF of -ξ. |
13 | 17 | """ |
14 | 18 |
|
15 | 19 | from __future__ import annotations |
|
32 | 36 | _fit_cdf_to_pmf_1D, |
33 | 37 | _fit_cdf_to_ppf_1D, |
34 | 38 | _fit_pmf_to_cdf_1D, |
| 39 | + _fit_ppf_to_cdf_1D, |
35 | 40 | ) |
36 | 41 | from pysatl_core.distributions.support import ( |
37 | 42 | ExplicitTableDiscreteSupport, |
@@ -209,7 +214,8 @@ def test_descriptor_metadata(self) -> None: |
209 | 214 | desc = _build_ppf_to_cdf_1D() |
210 | 215 | assert desc.target == CharacteristicName.CDF |
211 | 216 | assert desc.sources == [CharacteristicName.PPF] |
212 | | - assert desc.option_names() == ("n_q_grid",) |
| 217 | + # right_closed is a CharacteristicOption; n_q_grid is a ComputationOption |
| 218 | + assert desc.option_names() == ("right_closed", "n_q_grid") |
213 | 219 |
|
214 | 220 |
|
215 | 221 | def _make_right_unbounded_pmf_distribution() -> StandaloneEuclideanUnivariateDistribution: |
@@ -318,3 +324,186 @@ def test_cdf_ppf_roundtrip(self) -> None: |
318 | 324 | cdf_at_xs = np.asarray(cdf_fitted.func(xs), dtype=float) # type: ignore[call-arg,arg-type,type-var] |
319 | 325 | # For discrete distributions, CDF(PPF(q)) >= q |
320 | 326 | assert np.all(cdf_at_xs >= qs - 1e-10) # type: ignore[operator] |
| 327 | + |
| 328 | + |
| 329 | +# --------------------------------------------------------------------------- |
| 330 | +# Right-open CDF convention (right_closed=False) |
| 331 | +# --------------------------------------------------------------------------- |
| 332 | +# Support {0, 1, 2}, PMF {0: 0.2, 1: 0.5, 2: 0.3} |
| 333 | +# Right-closed CDF: F(x) = P(ξ ≤ x): 0 for x<0, 0.2 at x=0, 0.7 at x=1, 1.0 at x=2 |
| 334 | +# Right-open CDF: F⁻(x) = P(ξ < x): 0 for x≤0, 0.2 for 0<x≤1, 0.7 for 1<x≤2, 1.0 for x>2 |
| 335 | +# |
| 336 | +# Key identity: 1 - F⁻(x) = P(ξ ≥ x), useful for computing CDF of -ξ. |
| 337 | + |
| 338 | + |
| 339 | +class TestPmfToCdfRightOpen(DistributionTestBase): |
| 340 | + """Tests for _fit_pmf_to_cdf_1D with right_closed=False (right-open convention).""" |
| 341 | + |
| 342 | + def test_right_open_at_support_points(self) -> None: |
| 343 | + """F⁻(x) at support points should exclude the mass at x.""" |
| 344 | + distr = self.make_discrete_point_pmf_distribution() |
| 345 | + fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False) |
| 346 | + |
| 347 | + # F⁻(0) = P(ξ < 0) = 0 |
| 348 | + assert float(fitted.func(np.float64(0.0))[0]) == pytest.approx(0.0, abs=1e-10) # type: ignore[call-arg,arg-type] |
| 349 | + # F⁻(1) = P(ξ < 1) = P(ξ = 0) = 0.2 |
| 350 | + assert float(fitted.func(np.float64(1.0))[0]) == pytest.approx(0.2, abs=1e-6) # type: ignore[call-arg,arg-type] |
| 351 | + # F⁻(2) = P(ξ < 2) = P(ξ = 0) + P(ξ = 1) = 0.7 |
| 352 | + assert float(fitted.func(np.float64(2.0))[0]) == pytest.approx(0.7, abs=1e-6) # type: ignore[call-arg,arg-type] |
| 353 | + |
| 354 | + def test_right_open_between_support_points(self) -> None: |
| 355 | + """F⁻(x) between support points equals F(x) (no mass there).""" |
| 356 | + distr = self.make_discrete_point_pmf_distribution() |
| 357 | + fitted_open = _fit_pmf_to_cdf_1D(distr, right_closed=False) |
| 358 | + fitted_closed = _fit_pmf_to_cdf_1D(distr, right_closed=True) |
| 359 | + |
| 360 | + # Between 0 and 1: both conventions agree |
| 361 | + x_between = np.array([0.5]) |
| 362 | + np.testing.assert_allclose( |
| 363 | + np.asarray(fitted_open.func(x_between), dtype=np.float64), # type: ignore[call-arg,arg-type] |
| 364 | + np.asarray(fitted_closed.func(x_between), dtype=np.float64), # type: ignore[call-arg,arg-type] |
| 365 | + atol=1e-10, |
| 366 | + ) |
| 367 | + |
| 368 | + def test_right_open_before_support(self) -> None: |
| 369 | + """F⁻(x) = 0 for x <= first support point.""" |
| 370 | + distr = self.make_discrete_point_pmf_distribution() |
| 371 | + fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False) |
| 372 | + |
| 373 | + assert float(fitted.func(np.float64(-1.0))[0]) == pytest.approx(0.0) # type: ignore[call-arg,arg-type] |
| 374 | + assert float(fitted.func(np.float64(0.0))[0]) == pytest.approx(0.0) # type: ignore[call-arg,arg-type] |
| 375 | + |
| 376 | + def test_right_open_after_last_support(self) -> None: |
| 377 | + """F⁻(x) = 1 for x > last support point.""" |
| 378 | + distr = self.make_discrete_point_pmf_distribution() |
| 379 | + fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False) |
| 380 | + |
| 381 | + assert float(fitted.func(np.float64(3.0))[0]) == pytest.approx(1.0) # type: ignore[call-arg,arg-type] |
| 382 | + |
| 383 | + def test_right_open_negation_identity(self) -> None: |
| 384 | + """1 - F⁻(x) = P(ξ ≥ x) for all support points.""" |
| 385 | + # Support {0, 1, 2}, PMF {0: 0.2, 1: 0.5, 2: 0.3} |
| 386 | + # P(ξ ≥ 0) = 1.0, P(ξ ≥ 1) = 0.8, P(ξ ≥ 2) = 0.3 |
| 387 | + distr = self.make_discrete_point_pmf_distribution() |
| 388 | + fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False) |
| 389 | + |
| 390 | + xs = np.array([0.0, 1.0, 2.0]) |
| 391 | + f_open = np.asarray(fitted.func(xs), dtype=np.float64) # type: ignore[call-arg] |
| 392 | + p_geq = np.float64(1.0) - f_open |
| 393 | + np.testing.assert_allclose(p_geq, [1.0, 0.8, 0.3], atol=1e-6) |
| 394 | + |
| 395 | + def test_right_open_monotonicity(self) -> None: |
| 396 | + """F⁻(x) must be non-decreasing.""" |
| 397 | + distr = self.make_discrete_point_pmf_distribution() |
| 398 | + fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False) |
| 399 | + xs = np.linspace(-1.0, 3.0, 50) |
| 400 | + result = np.asarray(fitted.func(xs), dtype=float) # type: ignore[call-arg,type-var] |
| 401 | + assert np.all(np.diff(result) >= -1e-10) |
| 402 | + |
| 403 | + def test_right_open_bounds(self) -> None: |
| 404 | + """F⁻(x) must lie in [0, 1].""" |
| 405 | + distr = self.make_discrete_point_pmf_distribution() |
| 406 | + fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False) |
| 407 | + xs = np.linspace(-2.0, 4.0, 50) |
| 408 | + result = np.asarray(fitted.func(xs), dtype=float) # type: ignore[call-arg,type-var] |
| 409 | + assert np.all(result >= 0.0) # type: ignore[operator] |
| 410 | + assert np.all(result <= 1.0) # type: ignore[operator] |
| 411 | + |
| 412 | + def test_descriptor_has_right_closed_option(self) -> None: |
| 413 | + """pmf_to_cdf_1D descriptor must declare right_closed as a CharacteristicOption.""" |
| 414 | + desc = _build_pmf_to_cdf_1D() |
| 415 | + char_names = [o.name for o in desc.characteristic_options] |
| 416 | + assert "right_closed" in char_names |
| 417 | + |
| 418 | + |
| 419 | +class TestPmfToCdfRightOpenRightUnbounded: |
| 420 | + """Tests for _fit_pmf_to_cdf_1D right-open convention on a right-unbounded lattice.""" |
| 421 | + |
| 422 | + def test_right_open_at_support_points(self) -> None: |
| 423 | + """F⁻(k) = P(ξ < k) = 1 - 0.5^k for geometric(0.5) on {0,1,2,...}.""" |
| 424 | + distr = _make_right_unbounded_pmf_distribution() |
| 425 | + fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False) |
| 426 | + |
| 427 | + # F⁻(0) = P(ξ < 0) = 0 |
| 428 | + assert float(fitted.func(np.array([0.0]))[0]) == pytest.approx(0.0, abs=1e-6) # type: ignore[call-arg] |
| 429 | + # F⁻(1) = P(ξ < 1) = P(ξ = 0) = 0.5 |
| 430 | + assert float(fitted.func(np.array([1.0]))[0]) == pytest.approx(0.5, abs=1e-6) # type: ignore[call-arg] |
| 431 | + # F⁻(2) = P(ξ < 2) = P(ξ=0) + P(ξ=1) = 0.75 |
| 432 | + assert float(fitted.func(np.array([2.0]))[0]) == pytest.approx(0.75, abs=1e-6) # type: ignore[call-arg] |
| 433 | + |
| 434 | + def test_right_open_negation_identity(self) -> None: |
| 435 | + """1 - F⁻(k) = P(ξ ≥ k) = 0.5^k for geometric(0.5).""" |
| 436 | + distr = _make_right_unbounded_pmf_distribution() |
| 437 | + fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False) |
| 438 | + |
| 439 | + for k in range(5): |
| 440 | + f_open = float(fitted.func(np.array([float(k)]))[0]) # type: ignore[call-arg] |
| 441 | + p_geq = 1.0 - f_open |
| 442 | + expected = 0.5**k # P(ξ ≥ k) = 0.5^k for geometric(0.5) |
| 443 | + assert p_geq == pytest.approx(expected, abs=1e-5), f"P(ξ ≥ {k}) mismatch" |
| 444 | + |
| 445 | + |
| 446 | +class TestPpfToCdfRightOpen: |
| 447 | + """Tests for _fit_ppf_to_cdf_1D with right_closed=False (right-open convention).""" |
| 448 | + |
| 449 | + def _make_ppf_distribution(self) -> StandaloneEuclideanUnivariateDistribution: |
| 450 | + """Discrete distribution with support {0,1,2}, PMF {0:0.2, 1:0.5, 2:0.3}.""" |
| 451 | + |
| 452 | + # PPF: Q(q) = 0 for q<=0.2, 1 for 0.2<q<=0.7, 2 for q>0.7 |
| 453 | + def ppf(q: NumericArray, **_: Any) -> NumericArray: |
| 454 | + q_arr = np.atleast_1d(np.asarray(q, dtype=float)) |
| 455 | + return np.where(q_arr <= 0.2, 0.0, np.where(q_arr <= 0.7, 1.0, 2.0)) |
| 456 | + |
| 457 | + return StandaloneEuclideanUnivariateDistribution( |
| 458 | + kind=Kind.DISCRETE, |
| 459 | + analytical_computations={ |
| 460 | + CharacteristicName.PPF: { |
| 461 | + DEFAULT_ANALYTICAL_LABEL: AnalyticalComputation[NumericArray, NumericArray]( |
| 462 | + target=CharacteristicName.PPF, |
| 463 | + func=ppf, # type: ignore[arg-type] |
| 464 | + ) |
| 465 | + } |
| 466 | + }, |
| 467 | + support=ExplicitTableDiscreteSupport([0, 1, 2]), |
| 468 | + ) |
| 469 | + |
| 470 | + def test_right_open_at_support_points(self) -> None: |
| 471 | + """F⁻(x) at support points should exclude the mass at x.""" |
| 472 | + distr = self._make_ppf_distribution() |
| 473 | + fitted = _fit_ppf_to_cdf_1D(distr, right_closed=False) |
| 474 | + |
| 475 | + # F⁻(0) = P(ξ < 0) = 0 |
| 476 | + assert float(fitted.func(np.float64(0.0))[0]) == pytest.approx(0.0, abs=0.01) # type: ignore[call-arg,arg-type] |
| 477 | + # F⁻(1) = P(ξ < 1) = 0.2 |
| 478 | + assert float(fitted.func(np.float64(1.0))[0]) == pytest.approx(0.2, abs=0.01) # type: ignore[call-arg,arg-type] |
| 479 | + # F⁻(2) = P(ξ < 2) = 0.7 |
| 480 | + assert float(fitted.func(np.float64(2.0))[0]) == pytest.approx(0.7, abs=0.01) # type: ignore[call-arg,arg-type] |
| 481 | + |
| 482 | + def test_right_open_negation_identity(self) -> None: |
| 483 | + """1 - F⁻(x) = P(ξ ≥ x) for support points.""" |
| 484 | + distr = self._make_ppf_distribution() |
| 485 | + fitted = _fit_ppf_to_cdf_1D(distr, right_closed=False) |
| 486 | + |
| 487 | + xs = np.array([0.0, 1.0, 2.0]) |
| 488 | + f_open = np.asarray(fitted.func(xs), dtype=np.float64) # type: ignore[call-arg] |
| 489 | + p_geq = np.float64(1.0) - f_open |
| 490 | + # P(ξ ≥ 0) = 1.0, P(ξ ≥ 1) = 0.8, P(ξ ≥ 2) = 0.3 |
| 491 | + np.testing.assert_allclose(p_geq, [1.0, 0.8, 0.3], atol=0.01) |
| 492 | + |
| 493 | + def test_right_open_vs_closed_differ_at_support(self) -> None: |
| 494 | + """Right-open and right-closed CDFs must differ at support points.""" |
| 495 | + distr = self._make_ppf_distribution() |
| 496 | + fitted_open = _fit_ppf_to_cdf_1D(distr, right_closed=False) |
| 497 | + fitted_closed = _fit_ppf_to_cdf_1D(distr, right_closed=True) |
| 498 | + |
| 499 | + xs = np.array([0.0, 1.0, 2.0]) |
| 500 | + open_vals = np.asarray(fitted_open.func(xs), dtype=float) # type: ignore[call-arg,type-var] |
| 501 | + closed_vals = np.asarray(fitted_closed.func(xs), dtype=float) # type: ignore[call-arg,type-var] |
| 502 | + # At support points the two conventions differ by the PMF mass |
| 503 | + assert not np.allclose(open_vals, closed_vals, atol=0.05) |
| 504 | + |
| 505 | + def test_descriptor_has_right_closed_option(self) -> None: |
| 506 | + """ppf_to_cdf_1D descriptor must declare right_closed as a CharacteristicOption.""" |
| 507 | + desc = _build_ppf_to_cdf_1D() |
| 508 | + char_names = [o.name for o in desc.characteristic_options] |
| 509 | + assert "right_closed" in char_names |
0 commit comments