feat(distributions): add lograyleigh and logrice distributions
Add Log-Rayleigh and Log-Rice continuous distributions as scipy rv_continuous subclasses with PDF, CDF, SF, PPF, ISF, moments, entropy, and RVS methods. Log-Rice reduces to Log-Rayleigh when nu=0. Both are derived via the change-of-variable Y = ln X on their respective parent distributions. Includes unit tests verifying numerical correctness and the change-of-variable identity.
This commit is contained in:
@@ -7,7 +7,7 @@ import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from tools.distributions import k_dist, lognakagami, loggamma_dist
|
||||
from tools.distributions import k_dist, lognakagami, loggamma_dist, lograyleigh, logrice
|
||||
|
||||
|
||||
X = np.linspace(0.01, 10.0, 500)
|
||||
@@ -340,6 +340,180 @@ class TestLogGamma:
|
||||
assert pytest.approx(samples.mean(), rel=5e-2) == expected_mean
|
||||
|
||||
|
||||
# ── lograyleigh unit tests ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLogRayleigh:
|
||||
def test_logpdf_is_finite_on_real_line(self):
|
||||
"""logpdf must be finite for all real y — tests positivity without float64 underflow."""
|
||||
log_vals = lograyleigh.logpdf(Y, sigma=2.0)
|
||||
assert np.all(np.isfinite(log_vals))
|
||||
|
||||
def test_pdf_integrates_to_one(self):
|
||||
"""Numerical integral of PDF over the real line should be ≈ 1."""
|
||||
y_fine = np.linspace(-20, 10, 200_000)
|
||||
integral = np.trapezoid(lograyleigh.pdf(y_fine, sigma=2.0), y_fine)
|
||||
assert pytest.approx(integral, abs=1e-3) == 1.0
|
||||
|
||||
def test_logpdf_equals_log_pdf(self):
|
||||
"""logpdf must equal log(pdf) at points where pdf does not underflow."""
|
||||
y_bulk = np.linspace(-5.0, 2.0, 50)
|
||||
log_via_pdf = np.log(lograyleigh.pdf(y_bulk, sigma=2.0))
|
||||
log_direct = lograyleigh.logpdf(y_bulk, sigma=2.0)
|
||||
np.testing.assert_allclose(log_direct, log_via_pdf, rtol=1e-6)
|
||||
|
||||
def test_cdf_is_monotone_increasing(self):
|
||||
"""CDF must be strictly non-decreasing."""
|
||||
cdf_vals = lograyleigh.cdf(Y, sigma=2.0)
|
||||
assert np.all(np.diff(cdf_vals) >= 0)
|
||||
|
||||
def test_cdf_and_sf_sum_to_one(self):
|
||||
"""CDF + SF must equal 1 at every point."""
|
||||
cdf_vals = lograyleigh.cdf(Y, sigma=2.0)
|
||||
sf_vals = lograyleigh.sf(Y, sigma=2.0)
|
||||
np.testing.assert_allclose(cdf_vals + sf_vals, 1.0, atol=1e-12)
|
||||
|
||||
def test_ppf_inverts_cdf(self):
|
||||
"""ppf(cdf(y)) must recover y."""
|
||||
y_test = np.array([-2.0, 0.0, 1.0])
|
||||
cdf_vals = lograyleigh.cdf(y_test, sigma=2.0)
|
||||
np.testing.assert_allclose(lograyleigh.ppf(cdf_vals, sigma=2.0), y_test, atol=1e-8)
|
||||
|
||||
def test_isf_inverts_sf(self):
|
||||
"""isf(sf(y)) must recover y."""
|
||||
y_test = np.array([-1.0, 0.5, 1.5])
|
||||
sf_vals = lograyleigh.sf(y_test, sigma=2.0)
|
||||
np.testing.assert_allclose(lograyleigh.isf(sf_vals, sigma=2.0), y_test, atol=1e-8)
|
||||
|
||||
def test_argcheck_rejects_non_positive_sigma(self):
|
||||
"""sigma <= 0 must not produce a valid (positive-finite) PDF value."""
|
||||
val = lograyleigh.pdf(0.0, sigma=-1.0)
|
||||
assert not (np.isfinite(val) and val > 0)
|
||||
|
||||
def test_stats_mean(self):
|
||||
"""Analytical mean must equal 0.5 * (log(2*sigma^2) + digamma(1))."""
|
||||
sigma = 2.0
|
||||
expected_mean = 0.5 * (np.log(2.0 * sigma**2) + sc.digamma(1))
|
||||
dist_mean = float(lograyleigh.stats(sigma=sigma, moments="m"))
|
||||
assert pytest.approx(dist_mean, rel=1e-10) == expected_mean
|
||||
|
||||
def test_stats_mean_shifts_with_sigma(self):
|
||||
"""Changing sigma shifts the mean by log(sigma2/sigma1) and leaves variance unchanged."""
|
||||
mean1 = float(lograyleigh.stats(sigma=1.0, moments="m"))
|
||||
mean4 = float(lograyleigh.stats(sigma=4.0, moments="m"))
|
||||
assert pytest.approx(mean4 - mean1, rel=1e-10) == np.log(4.0)
|
||||
|
||||
def test_stats_variance_equals_pi_squared_over_24(self):
|
||||
"""Variance must equal pi^2/24 and be independent of sigma."""
|
||||
expected_var = np.pi**2 / 24.0
|
||||
for sigma in [0.5, 1.0, 3.0]:
|
||||
_, dist_var, *_ = lograyleigh.stats(sigma=sigma, moments="mv")
|
||||
assert pytest.approx(float(dist_var), rel=1e-10) == expected_var
|
||||
|
||||
def test_log_transform_relation_to_rayleigh(self):
|
||||
"""lograyleigh.pdf(y) must equal rayleigh.pdf(exp(y)) * exp(y) (change-of-variable)."""
|
||||
from scipy.stats import rayleigh
|
||||
|
||||
y_test = np.linspace(-3.0, 3.0, 20)
|
||||
sigma = 2.0
|
||||
direct = lograyleigh.pdf(y_test, sigma=sigma)
|
||||
via_rayleigh = rayleigh.pdf(np.exp(y_test), scale=sigma) * np.exp(y_test)
|
||||
np.testing.assert_allclose(direct, via_rayleigh, rtol=1e-6)
|
||||
|
||||
def test_rvs_samples_are_finite(self):
|
||||
"""Random samples must be finite real numbers."""
|
||||
rng = np.random.default_rng(42)
|
||||
samples = lograyleigh.rvs(sigma=2.0, size=200, random_state=rng)
|
||||
assert samples.shape == (200,)
|
||||
assert np.all(np.isfinite(samples))
|
||||
|
||||
def test_rvs_sample_mean_near_expected(self):
|
||||
"""Sample mean of many RVS should be close to the distribution mean."""
|
||||
sigma = 2.0
|
||||
rng = np.random.default_rng(0)
|
||||
samples = lograyleigh.rvs(sigma=sigma, size=50_000, random_state=rng)
|
||||
expected_mean = float(lograyleigh.stats(sigma=sigma, moments="m"))
|
||||
assert pytest.approx(samples.mean(), rel=5e-2) == expected_mean
|
||||
|
||||
|
||||
# ── logrice unit tests ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLogRice:
|
||||
def test_logpdf_is_finite_on_real_line(self):
|
||||
"""logpdf must be finite for all real y — tests positivity without float64 underflow."""
|
||||
log_vals = logrice.logpdf(Y, nu=1.0, sigma=2.0)
|
||||
assert np.all(np.isfinite(log_vals))
|
||||
|
||||
def test_pdf_integrates_to_one(self):
|
||||
"""Numerical integral of PDF over the real line should be ≈ 1."""
|
||||
y_fine = np.linspace(-20, 10, 200_000)
|
||||
integral = np.trapezoid(logrice.pdf(y_fine, nu=1.0, sigma=2.0), y_fine)
|
||||
assert pytest.approx(integral, abs=1e-3) == 1.0
|
||||
|
||||
def test_logpdf_equals_log_pdf(self):
|
||||
"""logpdf must equal log(pdf) at points where pdf does not underflow."""
|
||||
y_bulk = np.linspace(-5.0, 2.0, 50)
|
||||
log_via_pdf = np.log(logrice.pdf(y_bulk, nu=1.0, sigma=2.0))
|
||||
log_direct = logrice.logpdf(y_bulk, nu=1.0, sigma=2.0)
|
||||
np.testing.assert_allclose(log_direct, log_via_pdf, rtol=1e-6)
|
||||
|
||||
def test_cdf_is_monotone_increasing(self):
|
||||
"""CDF must be strictly non-decreasing."""
|
||||
cdf_vals = logrice.cdf(Y, nu=1.0, sigma=2.0)
|
||||
assert np.all(np.diff(cdf_vals) >= 0)
|
||||
|
||||
def test_cdf_and_sf_sum_to_one(self):
|
||||
"""CDF + SF must equal 1 at every point."""
|
||||
cdf_vals = logrice.cdf(Y, nu=1.0, sigma=2.0)
|
||||
sf_vals = logrice.sf(Y, nu=1.0, sigma=2.0)
|
||||
np.testing.assert_allclose(cdf_vals + sf_vals, 1.0, atol=1e-12)
|
||||
|
||||
def test_argcheck_rejects_negative_nu(self):
|
||||
"""nu < 0 must not produce a valid (positive-finite) PDF value."""
|
||||
val = logrice.pdf(0.0, nu=-1.0, sigma=2.0)
|
||||
assert not (np.isfinite(val) and val > 0)
|
||||
|
||||
def test_argcheck_rejects_non_positive_sigma(self):
|
||||
"""sigma <= 0 must not produce a valid (positive-finite) PDF value."""
|
||||
val = logrice.pdf(0.0, nu=1.0, sigma=-1.0)
|
||||
assert not (np.isfinite(val) and val > 0)
|
||||
|
||||
def test_nu_zero_matches_lograyleigh(self):
|
||||
"""logrice with nu=0 must match lograyleigh exactly."""
|
||||
sigma = 2.0
|
||||
pdf_rice = logrice.pdf(Y, nu=0.0, sigma=sigma)
|
||||
pdf_rayleigh = lograyleigh.pdf(Y, sigma=sigma)
|
||||
np.testing.assert_allclose(pdf_rice, pdf_rayleigh, rtol=1e-6)
|
||||
|
||||
def test_log_transform_relation_to_rice(self):
|
||||
"""logrice.pdf(y) must equal rice.pdf(exp(y)) * exp(y) (change-of-variable)."""
|
||||
from scipy.stats import rice
|
||||
|
||||
y_test = np.linspace(-2.0, 3.0, 20)
|
||||
nu, sigma = 1.0, 2.0
|
||||
direct = logrice.pdf(y_test, nu=nu, sigma=sigma)
|
||||
via_rice = rice.pdf(np.exp(y_test), b=nu / sigma, scale=sigma) * np.exp(y_test)
|
||||
np.testing.assert_allclose(direct, via_rice, rtol=1e-6)
|
||||
|
||||
def test_rvs_samples_are_finite(self):
|
||||
"""Random samples must be finite real numbers."""
|
||||
rng = np.random.default_rng(42)
|
||||
samples = logrice.rvs(nu=1.0, sigma=2.0, size=200, random_state=rng)
|
||||
assert samples.shape == (200,)
|
||||
assert np.all(np.isfinite(samples))
|
||||
|
||||
def test_rvs_sample_mean_near_numerical_mean(self):
|
||||
"""Sample mean of many RVS should be close to the numerically integrated mean."""
|
||||
nu, sigma = 1.0, 2.0
|
||||
rng = np.random.default_rng(0)
|
||||
samples = logrice.rvs(nu=nu, sigma=sigma, size=50_000, random_state=rng)
|
||||
y_fine = np.linspace(-20, 10, 200_000)
|
||||
pdf_vals = logrice.pdf(y_fine, nu=nu, sigma=sigma)
|
||||
numerical_mean = np.trapezoid(y_fine * pdf_vals, y_fine)
|
||||
assert pytest.approx(samples.mean(), rel=5e-2) == numerical_mean
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
plot_k_dist_varying_alpha()
|
||||
plot_k_dist_varying_mu()
|
||||
|
||||
Reference in New Issue
Block a user