Files
Clutter_chuva/etc/tests/test_distributions.py
neutonsevero 346d85c4f7 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.
2026-05-07 11:55:33 -03:00

916 lines
41 KiB
Python

import numpy as np
import pytest
import scipy.special as sc
import matplotlib.pyplot as plt
import sys
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, logweibull, logricegamma, ricegamma
X = np.linspace(0.01, 10.0, 500)
# ── k_dist unit tests ────────────────────────────────────────────────────────
class TestKDistPdf:
def test_pdf_is_positive_for_valid_input(self):
"""PDF must be strictly positive for x > 0 and valid parameters."""
vals = k_dist.pdf(X, mu=1.0, alpha=2.0, beta=2.0)
assert np.all(vals > 0)
def test_pdf_integrates_to_one(self):
"""Numerical integral of PDF over a wide domain should be ≈ 1."""
x_fine = np.linspace(1e-4, 200.0, 100_000)
integral = np.trapezoid(k_dist.pdf(x_fine, mu=1.0, alpha=2.0, beta=2.0), x_fine)
assert pytest.approx(integral, abs=1e-3) == 1.0
def test_mean_equals_mu(self):
"""Numerical mean of distribution should match the mu parameter."""
x_grid = np.linspace(1e-4, 500.0, 200_000)
for mu in [0.5, 1.0, 3.0]:
mean_num = np.trapezoid(x_grid * k_dist.pdf(x_grid, mu=mu, alpha=2.0, beta=3.0), x_grid)
assert pytest.approx(mean_num, rel=1e-2) == mu
def test_logpdf_equals_log_pdf(self):
"""logpdf must equal log(pdf) for numerical consistency."""
x_test = np.linspace(0.1, 5.0, 20)
log_via_pdf = np.log(k_dist.pdf(x_test, mu=1.0, alpha=2.0, beta=3.0))
log_direct = k_dist.logpdf(x_test, mu=1.0, alpha=2.0, beta=3.0)
np.testing.assert_allclose(log_direct, log_via_pdf, rtol=1e-6)
def test_argcheck_rejects_non_positive_mu(self):
"""mu <= 0 must not produce a valid (positive-finite) PDF value."""
val = k_dist.pdf(1.0, mu=-1.0, alpha=2.0, beta=2.0)
assert not (np.isfinite(val) and val > 0)
def test_argcheck_rejects_non_positive_alpha(self):
"""alpha <= 0 must not produce a valid (positive-finite) PDF value."""
val = k_dist.pdf(1.0, mu=1.0, alpha=-1.0, beta=2.0)
assert not (np.isfinite(val) and val > 0)
def test_argcheck_rejects_non_positive_beta(self):
"""beta <= 0 must not produce a valid (positive-finite) PDF value."""
val = k_dist.pdf(1.0, mu=1.0, alpha=2.0, beta=-1.0)
assert not (np.isfinite(val) and val > 0)
def test_larger_alpha_shifts_mass_right(self):
"""Increasing alpha (with mu and beta fixed) shifts probability mass to the right."""
x_grid = np.linspace(1e-4, 50.0, 20_000)
mean_low = np.trapezoid(x_grid * k_dist.pdf(x_grid, mu=2.0, alpha=0.5, beta=2.0), x_grid)
mean_high = np.trapezoid(x_grid * k_dist.pdf(x_grid, mu=2.0, alpha=4.0, beta=2.0), x_grid)
# Both should be close to mu=2.0; variance changes but mean is fixed
assert pytest.approx(mean_low, rel=5e-2) == 2.0
assert pytest.approx(mean_high, rel=5e-2) == 2.0
def test_symmetry_in_alpha_beta(self):
"""PDF is symmetric in alpha and beta: swapping them gives the same PDF."""
x_test = np.linspace(0.1, 5.0, 20)
pdf_ab = k_dist.pdf(x_test, mu=1.0, alpha=2.0, beta=3.0)
pdf_ba = k_dist.pdf(x_test, mu=1.0, alpha=3.0, beta=2.0)
np.testing.assert_allclose(pdf_ab, pdf_ba, rtol=1e-6)
def test_stats_mean_equals_mu(self):
"""Analytical mean must equal mu."""
for mu in [0.5, 1.0, 3.0]:
dist_mean = float(k_dist.stats(mu=mu, alpha=2.0, beta=3.0, moments="m"))
assert pytest.approx(dist_mean, rel=1e-10) == mu
def test_stats_variance_formula_eq4(self):
"""Variance must equal mu^2*(alpha+beta+1)/(alpha*beta) (equation 4)."""
mu, alpha, beta = 2.0, 3.0, 2.0
expected_var = mu**2 * (alpha + beta + 1) / (alpha * beta)
_, dist_var, *_ = k_dist.stats(mu=mu, alpha=alpha, beta=beta, moments="mv")
assert pytest.approx(float(dist_var), rel=1e-10) == expected_var
def test_stats_variance_symmetric_in_alpha_beta(self):
"""Variance is symmetric in alpha and beta."""
mu = 2.0
_, var_ab, *_ = k_dist.stats(mu=mu, alpha=2.0, beta=3.0, moments="mv")
_, var_ba, *_ = k_dist.stats(mu=mu, alpha=3.0, beta=2.0, moments="mv")
assert pytest.approx(float(var_ab), rel=1e-10) == float(var_ba)
def test_stats_variance_numerical(self):
"""Analytical variance should match sample variance from rvs."""
mu, alpha, beta = 2.0, 2.0, 3.0
rng = np.random.default_rng(42)
samples = k_dist.rvs(mu=mu, alpha=alpha, beta=beta, size=100_000, random_state=rng)
_, dist_var, *_ = k_dist.stats(mu=mu, alpha=alpha, beta=beta, moments="mv")
assert pytest.approx(float(np.var(samples)), rel=5e-2) == float(dist_var)
def test_rvs_samples_are_positive_and_finite(self):
"""K distribution samples must be positive and finite."""
rng = np.random.default_rng(7)
samples = k_dist.rvs(mu=1.0, alpha=2.0, beta=2.0, size=500, random_state=rng)
assert np.all(samples > 0)
assert np.all(np.isfinite(samples))
def test_rvs_sample_mean_near_mu(self):
"""Sample mean of k_dist rvs should be close to mu."""
mu, alpha, beta = 2.0, 3.0, 2.0
rng = np.random.default_rng(0)
samples = k_dist.rvs(mu=mu, alpha=alpha, beta=beta, size=100_000, random_state=rng)
assert pytest.approx(float(np.mean(samples)), rel=5e-2) == mu
# ── Parametric curve plots ───────────────────────────────────────────────────
def plot_k_dist_varying_alpha(save_path=None):
"""Plot generalized K-distribution PDF curves for several values of alpha."""
alpha_values = [0.5, 1.0, 2.0, 4.0, 8.0]
x = np.linspace(1e-4, 15.0, 1000)
fig, ax = plt.subplots(figsize=(8, 5))
for alpha in alpha_values:
ax.plot(x, k_dist.pdf(x, mu=2.0, alpha=alpha, beta=2.0), label=f"alpha={alpha}")
ax.set_xlabel("x")
ax.set_ylabel("PDF")
ax.set_title("Generalized K distribution — varying alpha (mu=2.0, beta=2.0 fixed)")
ax.legend()
ax.set_ylim(bottom=0)
fig.tight_layout()
if save_path:
fig.savefig(save_path, dpi=150)
return fig
def plot_k_dist_varying_mu(save_path=None):
"""Plot generalized K-distribution PDF curves for several values of mu."""
mu_values = [0.5, 1.0, 2.0, 4.0, 8.0]
x = np.linspace(1e-4, 30.0, 1000)
fig, ax = plt.subplots(figsize=(8, 5))
for mu in mu_values:
ax.plot(x, k_dist.pdf(x, mu=mu, alpha=2.0, beta=2.0), label=f"mu={mu}")
ax.set_xlabel("x")
ax.set_ylabel("PDF")
ax.set_title("Generalized K distribution — varying mu (alpha=2.0, beta=2.0 fixed)")
ax.legend()
ax.set_ylim(bottom=0)
fig.tight_layout()
if save_path:
fig.savefig(save_path, dpi=150)
return fig
def plot_k_dist_varying_beta(save_path=None):
"""Plot generalized K-distribution PDF curves for several values of beta."""
beta_values = [0.5, 1.0, 2.0, 4.0, 8.0]
x = np.linspace(1e-4, 15.0, 1000)
fig, ax = plt.subplots(figsize=(8, 5))
for beta in beta_values:
ax.plot(x, k_dist.pdf(x, mu=2.0, alpha=2.0, beta=beta), label=f"beta={beta}")
ax.set_xlabel("x")
ax.set_ylabel("PDF")
ax.set_title("Generalized K distribution — varying beta (mu=2.0, alpha=2.0 fixed)")
ax.legend()
ax.set_ylim(bottom=0)
fig.tight_layout()
if save_path:
fig.savefig(save_path, dpi=150)
return fig
# ── Test: plots are generated without errors ─────────────────────────────────
class TestKDistPlots:
def test_plot_varying_alpha_runs_without_error(self, tmp_path):
"""Curve plot varying alpha must complete and save a file."""
out = tmp_path / "k_dist_alpha.png"
fig = plot_k_dist_varying_alpha(save_path=str(out))
assert out.exists()
plt.close(fig)
def test_plot_varying_mu_runs_without_error(self, tmp_path):
"""Curve plot varying mu must complete and save a file."""
out = tmp_path / "k_dist_mu.png"
fig = plot_k_dist_varying_mu(save_path=str(out))
assert out.exists()
plt.close(fig)
def test_plot_varying_beta_runs_without_error(self, tmp_path):
"""Curve plot varying beta must complete and save a file."""
out = tmp_path / "k_dist_beta.png"
fig = plot_k_dist_varying_beta(save_path=str(out))
assert out.exists()
plt.close(fig)
# ── Entry-point: run plots interactively ─────────────────────────────────────
Y = np.linspace(-5.0, 5.0, 500)
# ── lognakagami unit tests ────────────────────────────────────────────────────
class TestLogNakagami:
def test_logpdf_is_finite_on_real_line(self):
"""logpdf must be finite for all real y — tests positivity without float64 underflow."""
log_vals = lognakagami.logpdf(Y, m=2.0, Omega=1.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(-30, 10, 200_000)
integral = np.trapezoid(lognakagami.pdf(y_fine, m=2.0, Omega=1.0), y_fine)
assert pytest.approx(integral, abs=1e-3) == 1.0
def test_pdf_integrates_to_one_nonunit_omega(self):
"""Normalisation must hold for Omega != 1."""
y_fine = np.linspace(-30, 15, 200_000)
integral = np.trapezoid(lognakagami.pdf(y_fine, m=2.0, Omega=4.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(-4.0, 2.0, 50)
log_via_pdf = np.log(lognakagami.pdf(y_bulk, m=2.0, Omega=1.0))
log_direct = lognakagami.logpdf(y_bulk, m=2.0, Omega=1.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 = lognakagami.cdf(Y, m=2.0, Omega=1.0)
assert np.all(np.diff(cdf_vals) >= 0)
def test_ppf_inverts_cdf(self):
"""ppf(cdf(y)) must recover y."""
y_test = np.array([-2.0, 0.0, 0.5])
cdf_vals = lognakagami.cdf(y_test, m=2.0, Omega=1.0)
np.testing.assert_allclose(lognakagami.ppf(cdf_vals, m=2.0, Omega=1.0), y_test, atol=1e-8)
def test_argcheck_rejects_m_below_half(self):
"""m < 0.5 must not produce a valid (positive-finite) PDF value."""
val = lognakagami.pdf(0.0, m=0.3, Omega=1.0)
assert not (np.isfinite(val) and val > 0)
def test_argcheck_rejects_non_positive_omega(self):
"""Omega <= 0 must not produce a valid (positive-finite) PDF value."""
val = lognakagami.pdf(0.0, m=2.0, Omega=-1.0)
assert not (np.isfinite(val) and val > 0)
def test_stats_mean(self):
"""Analytical mean must equal 0.5 * (digamma(m) - log(m) + log(Omega))."""
m, Omega = 3.0, 2.0
expected_mean = 0.5 * (sc.digamma(m) - np.log(m) + np.log(Omega))
dist_mean = float(lognakagami.stats(m=m, Omega=Omega, moments="m"))
assert pytest.approx(dist_mean, rel=1e-10) == expected_mean
def test_stats_mean_omega_shifts_by_half_log_omega(self):
"""Changing Omega shifts the mean by 0.5*log(Omega) and leaves variance unchanged."""
m = 2.0
mean1 = float(lognakagami.stats(m=m, Omega=1.0, moments="m"))
mean4 = float(lognakagami.stats(m=m, Omega=4.0, moments="m"))
assert pytest.approx(mean4 - mean1, rel=1e-10) == 0.5 * np.log(4.0)
def test_stats_variance_independent_of_omega(self):
"""Variance must equal 0.25 * polygamma(1, m) and not depend on Omega."""
m = 3.0
expected_var = 0.25 * sc.polygamma(1, m)
for Omega in [0.5, 1.0, 4.0]:
_, dist_var, *_ = lognakagami.stats(m=m, Omega=Omega, moments="mv")
assert pytest.approx(float(dist_var), rel=1e-10) == expected_var
def test_rvs_samples_are_finite(self):
"""Random samples must be finite real numbers."""
rng = np.random.default_rng(42)
samples = lognakagami.rvs(m=2.0, Omega=1.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."""
m, Omega = 2.0, 3.0
rng = np.random.default_rng(0)
samples = lognakagami.rvs(m=m, Omega=Omega, size=50_000, random_state=rng)
expected_mean = float(lognakagami.stats(m=m, Omega=Omega, moments="m"))
assert pytest.approx(samples.mean(), rel=5e-2) == expected_mean
# ── loggamma_dist unit tests ──────────────────────────────────────────────────
class TestLogGamma:
def test_pdf_is_positive_on_real_line(self):
"""PDF must be strictly positive for all real y and a > 0."""
vals = loggamma_dist.pdf(Y, a=2.0)
assert np.all(vals > 0)
def test_pdf_integrates_to_one(self):
"""Numerical integral of PDF over the real line should be ≈ 1."""
y_fine = np.linspace(-30, 10, 200_000)
integral = np.trapezoid(loggamma_dist.pdf(y_fine, a=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) for numerical consistency."""
log_via_pdf = np.log(loggamma_dist.pdf(Y, a=2.0))
log_direct = loggamma_dist.logpdf(Y, a=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 = loggamma_dist.cdf(Y, a=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 = loggamma_dist.cdf(Y, a=2.0)
sf_vals = loggamma_dist.sf(Y, a=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 = loggamma_dist.cdf(y_test, a=2.0)
np.testing.assert_allclose(loggamma_dist.ppf(cdf_vals, a=2.0), y_test, atol=1e-8)
def test_argcheck_rejects_non_positive_a(self):
"""a <= 0 must not produce a valid (positive-finite) PDF value."""
val = loggamma_dist.pdf(0.0, a=-1.0)
assert not (np.isfinite(val) and val > 0)
def test_stats_mean_equals_digamma(self):
"""Analytical mean must equal digamma(a)."""
a = 3.0
expected_mean = sc.digamma(a)
dist_mean = float(loggamma_dist.stats(a=a, moments="m"))
assert pytest.approx(dist_mean, rel=1e-10) == expected_mean
def test_stats_variance_equals_trigamma(self):
"""Analytical variance must equal polygamma(1, a)."""
a = 3.0
expected_var = sc.polygamma(1, a)
_, dist_var, *_ = loggamma_dist.stats(a=a, moments="mv")
assert pytest.approx(float(dist_var), rel=1e-10) == expected_var
def test_log_transform_relation_to_gamma(self):
"""loggamma_dist.pdf(y) must equal gamma.pdf(exp(y)) * exp(y) (change-of-variable)."""
from scipy.stats import gamma as scipy_gamma
y_test = np.linspace(-3.0, 3.0, 20)
direct = loggamma_dist.pdf(y_test, a=2.0)
via_gamma = scipy_gamma.pdf(np.exp(y_test), a=2.0) * np.exp(y_test)
np.testing.assert_allclose(direct, via_gamma, rtol=1e-6)
def test_rvs_samples_are_finite(self):
"""Random samples must be finite real numbers."""
rng = np.random.default_rng(42)
samples = loggamma_dist.rvs(a=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."""
a = 2.0
rng = np.random.default_rng(0)
samples = loggamma_dist.rvs(a=a, size=50_000, random_state=rng)
expected_mean = float(loggamma_dist.stats(a=a, moments="m"))
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
# ── logk unit tests ───────────────────────────────────────────────────────────
Y_LOGK = np.linspace(-10.0, 10.0, 500)
class TestLogK:
def test_logpdf_is_finite_on_real_line(self):
"""logpdf must be finite for all real y."""
log_vals = logk.logpdf(Y_LOGK, mu=2.0, alpha=3.0, beta=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(-30, 20, 200_000)
integral = np.trapezoid(logk.pdf(y_fine, mu=2.0, alpha=3.0, beta=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, 5.0, 30)
log_via_pdf = np.log(logk.pdf(y_bulk, mu=2.0, alpha=3.0, beta=2.0))
log_direct = logk.logpdf(y_bulk, mu=2.0, alpha=3.0, beta=2.0)
np.testing.assert_allclose(log_direct, log_via_pdf, rtol=1e-6)
def test_log_transform_relation_to_k_dist(self):
"""logk.pdf(y) must equal k_dist.pdf(exp(y)) * exp(y) (change-of-variable)."""
y_test = np.linspace(-3.0, 5.0, 20)
mu, alpha, beta = 2.0, 3.0, 2.0
direct = logk.pdf(y_test, mu=mu, alpha=alpha, beta=beta)
via_k = k_dist.pdf(np.exp(y_test), mu=mu, alpha=alpha, beta=beta) * np.exp(y_test)
np.testing.assert_allclose(direct, via_k, rtol=1e-6)
def test_cdf_consistent_with_k_dist(self):
"""logk.cdf(y) must equal k_dist.cdf(exp(y))."""
y_test = np.array([-2.0, 0.0, 1.0, 3.0])
mu, alpha, beta = 2.0, 2.0, 3.0
cdf_logk = logk.cdf(y_test, mu=mu, alpha=alpha, beta=beta)
cdf_k = k_dist.cdf(np.exp(y_test), mu=mu, alpha=alpha, beta=beta)
np.testing.assert_allclose(cdf_logk, cdf_k, rtol=1e-6)
def test_cdf_is_monotone_increasing(self):
"""CDF must be strictly non-decreasing (up to floating-point noise in the saturated tail)."""
y_grid = np.linspace(-5.0, 8.0, 30)
cdf_vals = logk.cdf(y_grid, mu=2.0, alpha=3.0, beta=2.0)
assert np.all(np.diff(cdf_vals) >= -1e-10)
def test_stats_mean_analytical(self):
"""Mean must equal ln(mu) - ln(alpha) - ln(beta) + psi(alpha) + psi(beta)."""
mu, alpha, beta = 2.0, 3.0, 2.0
expected = np.log(mu) - np.log(alpha) - np.log(beta) + sc.digamma(alpha) + sc.digamma(beta)
dist_mean = float(logk.stats(mu=mu, alpha=alpha, beta=beta, moments="m"))
assert pytest.approx(dist_mean, rel=1e-10) == expected
def test_stats_variance_analytical(self):
"""Variance must equal psi_1(alpha) + psi_1(beta)."""
mu, alpha, beta = 2.0, 3.0, 2.0
expected_var = sc.polygamma(1, alpha) + sc.polygamma(1, beta)
_, dist_var, *_ = logk.stats(mu=mu, alpha=alpha, beta=beta, moments="mv")
assert pytest.approx(float(dist_var), rel=1e-10) == expected_var
def test_stats_variance_mu_independent(self):
"""Variance must not depend on mu (mu is a pure shift in log-space)."""
alpha, beta = 3.0, 2.0
_, var1, *_ = logk.stats(mu=1.0, alpha=alpha, beta=beta, moments="mv")
_, var4, *_ = logk.stats(mu=4.0, alpha=alpha, beta=beta, moments="mv")
assert pytest.approx(float(var1), rel=1e-10) == float(var4)
def test_stats_mean_shifts_by_log_mu(self):
"""Doubling mu shifts the mean by ln(2) and leaves variance unchanged."""
alpha, beta = 3.0, 2.0
mean1 = float(logk.stats(mu=1.0, alpha=alpha, beta=beta, moments="m"))
mean2 = float(logk.stats(mu=2.0, alpha=alpha, beta=beta, moments="m"))
assert pytest.approx(mean2 - mean1, rel=1e-10) == np.log(2.0)
def test_argcheck_rejects_non_positive_mu(self):
"""mu <= 0 must not produce a valid PDF value."""
val = logk.pdf(0.0, mu=-1.0, alpha=2.0, beta=2.0)
assert not (np.isfinite(val) and val > 0)
def test_argcheck_rejects_non_positive_alpha(self):
"""alpha <= 0 must not produce a valid PDF value."""
val = logk.pdf(0.0, mu=1.0, alpha=-1.0, beta=2.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 = logk.pdf(0.0, mu=1.0, alpha=2.0, beta=-1.0)
assert not (np.isfinite(val) and val > 0)
def test_rvs_samples_are_finite(self):
"""Random samples must be finite real numbers."""
rng = np.random.default_rng(42)
samples = logk.rvs(mu=2.0, alpha=3.0, beta=2.0, size=200, random_state=rng)
assert samples.shape == (200,)
assert np.all(np.isfinite(samples))
def test_rvs_sample_mean_near_analytical(self):
"""Sample mean of many RVS should be close to the analytical mean."""
mu, alpha, beta = 2.0, 3.0, 2.0
rng = np.random.default_rng(0)
samples = logk.rvs(mu=mu, alpha=alpha, beta=beta, size=100_000, random_state=rng)
expected_mean = float(logk.stats(mu=mu, alpha=alpha, beta=beta, moments="m"))
assert pytest.approx(float(samples.mean()), rel=5e-2) == expected_mean
def test_rvs_sample_variance_near_analytical(self):
"""Sample variance of many RVS should be close to the analytical variance."""
mu, alpha, beta = 2.0, 3.0, 2.0
rng = np.random.default_rng(1)
samples = logk.rvs(mu=mu, alpha=alpha, beta=beta, size=100_000, random_state=rng)
_, expected_var, *_ = logk.stats(mu=mu, alpha=alpha, beta=beta, moments="mv")
assert pytest.approx(float(np.var(samples)), rel=5e-2) == float(expected_var)
def test_symmetry_in_alpha_beta(self):
"""logk PDF is symmetric in alpha and beta."""
y_test = np.linspace(-3.0, 5.0, 20)
mu = 2.0
pdf_ab = logk.pdf(y_test, mu=mu, alpha=2.0, beta=3.0)
pdf_ba = logk.pdf(y_test, mu=mu, alpha=3.0, beta=2.0)
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()
plot_k_dist_varying_beta()
plt.show()