feat(distributions): add logweibull, ricegamma, and logricegamma
Add three new continuous random variables for log-domain and linear-domain clutter modeling with compound Gamma-Rice structure. Fix numerical stability of k_dist._logpdf and logk._log_kve via a three-regime log(kve) asymptotic (direct / large-z Hankel / large-order Gamma); replace quad-based k_dist._cdf with Gauss-Laguerre quadrature. Fix fitter: use np.asarray instead of np.abs in fit(), pass fit_params to goodness_of_fit so the observed-data statistic reuses fitted params. Skip non-finite quantiles in QQ plots. Add plot_qq_plots_sns(); rename histogram_with_fits_seaborn() to histogram_with_fits_sns(). Add unit tests for logweibull and logricegamma.
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, logk, lognakagami, loggamma_dist, lograyleigh, logrice
|
||||
from tools.distributions import k_dist, logk, lognakagami, loggamma_dist, lograyleigh, logrice, logweibull, logricegamma, ricegamma
|
||||
|
||||
|
||||
X = np.linspace(0.01, 10.0, 500)
|
||||
@@ -677,6 +677,237 @@ class TestLogK:
|
||||
np.testing.assert_allclose(pdf_ab, pdf_ba, rtol=1e-6)
|
||||
|
||||
|
||||
# ── logweibull unit tests ─────────────────────────────────────────────────────
|
||||
|
||||
Y_LOGWEIBULL = np.linspace(-10.0, 10.0, 500)
|
||||
|
||||
|
||||
class TestLogWeibull:
|
||||
def test_logpdf_is_finite_on_real_line(self):
|
||||
"""logpdf must be finite for all real y."""
|
||||
vals = logweibull.logpdf(Y_LOGWEIBULL, k=2.0, lam=1.0)
|
||||
assert np.all(np.isfinite(vals))
|
||||
|
||||
def test_pdf_integrates_to_one(self):
|
||||
"""Numerical integral of PDF over the real line should be ≈ 1."""
|
||||
y_fine = np.linspace(-20.0, 20.0, 200_000)
|
||||
integral = np.trapezoid(logweibull.pdf(y_fine, k=2.0, lam=1.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 to zero."""
|
||||
y_bulk = np.linspace(-10.0, 20.0, 100)
|
||||
k, lam = 2.0, np.exp(12.0)
|
||||
pdf_vals = logweibull.pdf(y_bulk, k=k, lam=lam)
|
||||
mask = pdf_vals > 0
|
||||
np.testing.assert_allclose(
|
||||
logweibull.logpdf(y_bulk[mask], k=k, lam=lam),
|
||||
np.log(pdf_vals[mask]),
|
||||
rtol=1e-6,
|
||||
)
|
||||
|
||||
def test_change_of_variable_matches_weibull(self):
|
||||
"""logweibull.pdf(y) must equal weibull_min.pdf(exp(y)) * exp(y)."""
|
||||
from scipy.stats import weibull_min
|
||||
y_test = np.linspace(-3.0, 3.0, 20)
|
||||
k, lam = 2.0, 1.5
|
||||
direct = logweibull.pdf(y_test, k=k, lam=lam)
|
||||
via_w = weibull_min.pdf(np.exp(y_test), c=k, scale=lam) * np.exp(y_test)
|
||||
np.testing.assert_allclose(direct, via_w, rtol=1e-6)
|
||||
|
||||
def test_cdf_is_monotone_increasing(self):
|
||||
"""CDF must be strictly non-decreasing."""
|
||||
y_grid = np.linspace(-5.0, 5.0, 50)
|
||||
cdf_vals = logweibull.cdf(y_grid, k=2.0, lam=1.0)
|
||||
assert np.all(np.diff(cdf_vals) >= -1e-12)
|
||||
|
||||
def test_cdf_matches_weibull(self):
|
||||
"""logweibull.cdf(y) must equal weibull_min.cdf(exp(y))."""
|
||||
from scipy.stats import weibull_min
|
||||
y_test = np.array([-2.0, 0.0, 1.0, 2.0])
|
||||
k, lam = 1.5, 2.0
|
||||
np.testing.assert_allclose(
|
||||
logweibull.cdf(y_test, k=k, lam=lam),
|
||||
weibull_min.cdf(np.exp(y_test), c=k, scale=lam),
|
||||
rtol=1e-6,
|
||||
)
|
||||
|
||||
def test_sf_plus_cdf_equals_one(self):
|
||||
"""sf + cdf must equal 1 everywhere."""
|
||||
y_test = np.linspace(-3.0, 3.0, 20)
|
||||
k, lam = 2.0, 1.0
|
||||
np.testing.assert_allclose(
|
||||
logweibull.cdf(y_test, k=k, lam=lam) + logweibull.sf(y_test, k=k, lam=lam),
|
||||
1.0,
|
||||
rtol=1e-12,
|
||||
)
|
||||
|
||||
def test_ppf_inverts_cdf(self):
|
||||
"""ppf must be the exact inverse of cdf: cdf(ppf(q)) == q."""
|
||||
# Round-trip over quantiles to avoid CDF saturation at extreme y values
|
||||
q_test = np.array([0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95])
|
||||
k, lam = 2.0, np.exp(12.0)
|
||||
np.testing.assert_allclose(
|
||||
logweibull.cdf(logweibull.ppf(q_test, k=k, lam=lam), k=k, lam=lam),
|
||||
q_test,
|
||||
rtol=1e-8,
|
||||
)
|
||||
|
||||
def test_stats_mean_shifts_by_log_lam(self):
|
||||
"""Doubling lam shifts the mean by ln(2), leaving variance unchanged."""
|
||||
k = 2.0
|
||||
mean1 = float(logweibull.stats(k=k, lam=1.0, moments="m"))
|
||||
mean2 = float(logweibull.stats(k=k, lam=2.0, moments="m"))
|
||||
assert pytest.approx(mean2 - mean1, rel=1e-10) == np.log(2.0)
|
||||
|
||||
def test_stats_variance_scales_with_k(self):
|
||||
"""Variance must equal psi_1(1) / k^2."""
|
||||
for k in [0.5, 1.0, 2.0]:
|
||||
_, var, *_ = logweibull.stats(k=k, lam=1.0, moments="mv")
|
||||
expected = sc.polygamma(1, 1) / k ** 2
|
||||
assert pytest.approx(float(var), rel=1e-10) == expected
|
||||
|
||||
def test_stats_variance_is_lam_independent(self):
|
||||
"""Variance must not depend on lam."""
|
||||
k = 2.0
|
||||
_, var1, *_ = logweibull.stats(k=k, lam=1.0, moments="mv")
|
||||
_, var2, *_ = logweibull.stats(k=k, lam=5.0, moments="mv")
|
||||
assert pytest.approx(float(var1), rel=1e-10) == float(var2)
|
||||
|
||||
def test_argcheck_rejects_non_positive_k(self):
|
||||
"""k <= 0 must not produce a valid PDF value."""
|
||||
val = logweibull.pdf(0.0, k=-1.0, lam=1.0)
|
||||
assert not (np.isfinite(val) and val > 0)
|
||||
|
||||
def test_argcheck_rejects_non_positive_lam(self):
|
||||
"""lam <= 0 must not produce a valid PDF value."""
|
||||
val = logweibull.pdf(0.0, k=1.0, lam=-1.0)
|
||||
assert not (np.isfinite(val) and val > 0)
|
||||
|
||||
def test_rvs_are_finite(self):
|
||||
"""Random samples must be finite real numbers."""
|
||||
rng = np.random.default_rng(42)
|
||||
samples = logweibull.rvs(k=2.0, lam=1.0, size=500, random_state=rng)
|
||||
assert samples.shape == (500,)
|
||||
assert np.all(np.isfinite(samples))
|
||||
|
||||
def test_rvs_sample_mean_near_analytical(self):
|
||||
"""Sample mean must be close to the analytical mean."""
|
||||
k, lam = 2.0, 1.5
|
||||
rng = np.random.default_rng(0)
|
||||
samples = logweibull.rvs(k=k, lam=lam, size=100_000, random_state=rng)
|
||||
expected_mean = float(logweibull.stats(k=k, lam=lam, moments="m"))
|
||||
assert pytest.approx(float(samples.mean()), rel=5e-2) == expected_mean
|
||||
|
||||
def test_rvs_sample_variance_near_analytical(self):
|
||||
"""Sample variance must be close to the analytical variance."""
|
||||
k, lam = 2.0, 1.5
|
||||
rng = np.random.default_rng(1)
|
||||
samples = logweibull.rvs(k=k, lam=lam, size=100_000, random_state=rng)
|
||||
_, expected_var, *_ = logweibull.stats(k=k, lam=lam, moments="mv")
|
||||
assert pytest.approx(float(np.var(samples)), rel=5e-2) == float(expected_var)
|
||||
|
||||
|
||||
# ── logricegamma unit tests ───────────────────────────────────────────────────
|
||||
# PDF is expensive (numerical integration per point), so grids are kept small.
|
||||
|
||||
Y_LRICEGAMMA = np.linspace(-5.0, 5.0, 30)
|
||||
|
||||
|
||||
class TestLogRiceGamma:
|
||||
def test_pdf_is_positive_for_valid_params(self):
|
||||
"""PDF must be strictly positive for finite y and valid parameters."""
|
||||
vals = logricegamma.pdf(Y_LRICEGAMMA, alpha=2.0, beta=1.0, K=1.0)
|
||||
assert np.all(vals > 0)
|
||||
|
||||
def test_pdf_integrates_to_one(self):
|
||||
"""Numerical integral of PDF over a wide domain should be ≈ 1."""
|
||||
y_fine = np.linspace(-15.0, 10.0, 1000)
|
||||
integral = np.trapezoid(
|
||||
logricegamma.pdf(y_fine, alpha=2.0, beta=1.0, K=1.0), y_fine
|
||||
)
|
||||
assert pytest.approx(integral, abs=1e-2) == 1.0
|
||||
|
||||
def test_pdf_integrates_to_one_k_zero(self):
|
||||
"""Normalisation must hold for K=0 (Rice collapses to Rayleigh)."""
|
||||
y_fine = np.linspace(-15.0, 10.0, 1000)
|
||||
integral = np.trapezoid(
|
||||
logricegamma.pdf(y_fine, alpha=2.0, beta=1.0, K=0.0), y_fine
|
||||
)
|
||||
assert pytest.approx(integral, abs=1e-2) == 1.0
|
||||
|
||||
def test_pdf_integrates_to_one_large_K(self):
|
||||
"""Normalisation must hold for large K (highly specular regime)."""
|
||||
y_fine = np.linspace(-10.0, 15.0, 1000)
|
||||
integral = np.trapezoid(
|
||||
logricegamma.pdf(y_fine, alpha=2.0, beta=1.0, K=10.0), y_fine
|
||||
)
|
||||
assert pytest.approx(integral, abs=1e-2) == 1.0
|
||||
|
||||
def test_logpdf_equals_log_pdf(self):
|
||||
"""logpdf must equal log(pdf) where pdf does not underflow."""
|
||||
y_bulk = np.linspace(-3.0, 3.0, 15)
|
||||
pdf_vals = logricegamma.pdf(y_bulk, alpha=2.0, beta=1.0, K=1.0)
|
||||
mask = pdf_vals > 0
|
||||
np.testing.assert_allclose(
|
||||
logricegamma.logpdf(y_bulk[mask], alpha=2.0, beta=1.0, K=1.0),
|
||||
np.log(pdf_vals[mask]),
|
||||
rtol=1e-6,
|
||||
)
|
||||
|
||||
def test_argcheck_rejects_non_positive_alpha(self):
|
||||
"""alpha <= 0 must not produce a valid PDF value."""
|
||||
val = logricegamma.pdf(0.0, alpha=-1.0, beta=1.0, K=1.0)
|
||||
assert not (np.isfinite(val) and val > 0)
|
||||
|
||||
def test_argcheck_rejects_non_positive_beta(self):
|
||||
"""beta <= 0 must not produce a valid PDF value."""
|
||||
val = logricegamma.pdf(0.0, alpha=2.0, beta=-1.0, K=1.0)
|
||||
assert not (np.isfinite(val) and val > 0)
|
||||
|
||||
def test_argcheck_rejects_negative_K(self):
|
||||
"""K < 0 must not produce a valid PDF value."""
|
||||
val = logricegamma.pdf(0.0, alpha=2.0, beta=1.0, K=-1.0)
|
||||
assert not (np.isfinite(val) and val > 0)
|
||||
|
||||
def test_cdf_is_monotone_increasing(self):
|
||||
"""CDF must be strictly non-decreasing."""
|
||||
y_grid = np.linspace(-4.0, 4.0, 15)
|
||||
cdf_vals = logricegamma.cdf(y_grid, alpha=2.0, beta=1.0, K=1.0)
|
||||
assert np.all(np.diff(cdf_vals) >= -1e-10)
|
||||
|
||||
def test_rvs_samples_are_finite(self):
|
||||
"""Random samples must be finite real numbers."""
|
||||
rng = np.random.default_rng(42)
|
||||
samples = logricegamma.rvs(alpha=2.0, beta=1.0, K=1.0, size=500, random_state=rng)
|
||||
assert samples.shape == (500,)
|
||||
assert np.all(np.isfinite(samples))
|
||||
|
||||
def test_rvs_second_moment_equals_alpha_times_beta(self):
|
||||
"""E[exp(2Y)] = E[X²] must equal alpha*beta (total average power from docstring)."""
|
||||
alpha, beta, K = 2.0, 1.5, 2.0
|
||||
rng = np.random.default_rng(0)
|
||||
samples = logricegamma.rvs(alpha=alpha, beta=beta, K=K, size=100_000, random_state=rng)
|
||||
assert pytest.approx(float(np.mean(np.exp(2.0 * samples))), rel=5e-2) == alpha * beta
|
||||
|
||||
def test_rvs_second_moment_k_zero(self):
|
||||
"""E[X²] = alpha*beta must hold for K=0."""
|
||||
alpha, beta, K = 3.0, 0.5, 0.0
|
||||
rng = np.random.default_rng(1)
|
||||
samples = logricegamma.rvs(alpha=alpha, beta=beta, K=K, size=100_000, random_state=rng)
|
||||
assert pytest.approx(float(np.mean(np.exp(2.0 * samples))), rel=5e-2) == alpha * beta
|
||||
|
||||
def test_rvs_sample_mean_consistent_with_pdf(self):
|
||||
"""Sample mean from RVS should match the numerically integrated mean from the PDF."""
|
||||
alpha, beta, K = 2.0, 1.0, 1.0
|
||||
rng = np.random.default_rng(2)
|
||||
samples = logricegamma.rvs(alpha=alpha, beta=beta, K=K, size=50_000, random_state=rng)
|
||||
y_fine = np.linspace(-15.0, 10.0, 1000)
|
||||
pdf_vals = logricegamma.pdf(y_fine, alpha=alpha, beta=beta, K=K)
|
||||
numerical_mean = float(np.trapezoid(y_fine * pdf_vals, y_fine))
|
||||
assert pytest.approx(float(samples.mean()), rel=1e-1) == numerical_mean
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
plot_k_dist_varying_alpha()
|
||||
plot_k_dist_varying_mu()
|
||||
|
||||
Reference in New Issue
Block a user