use num_bigint::BigInt;
use num_traits::{One, Signed, Zero};
use crate::api::context::Context;
use crate::api::expr::Ex;
use crate::base::errors::SymplexError;
use crate::base::interval::Interval;
use super::common::{
check_confidence, check_sample, ex_usize, invalid, q_to_f64, qu, standard_normal, z_two_sided,
};
use super::data::{self, Ddof, Q};
use super::family::{Distribution, fresh_symbol};
use super::numdist;
fn nonempty(op: &'static str, data: &[Q]) -> Result<(), SymplexError> {
if data.is_empty() {
return Err(invalid(op, format!("{op} needs at least one observation")));
}
Ok(())
}
fn require_positive_data(op: &'static str, data: &[Q]) -> Result<(), SymplexError> {
nonempty(op, data)?;
if let Some(x) = data.iter().find(|x| !x.is_positive()) {
return Err(invalid(
op,
format!("{op} needs positive observations, got {x}"),
));
}
Ok(())
}
fn require_counts(op: &'static str, data: &[Q]) -> Result<(), SymplexError> {
nonempty(op, data)?;
if let Some(x) = data.iter().find(|x| !x.is_integer() || x.is_negative()) {
return Err(invalid(
op,
format!("{op} needs non-negative integer observations, got {x}"),
));
}
Ok(())
}
fn require_positive_param(op: &'static str, x: &Q, what: &str) -> Result<(), SymplexError> {
if !x.is_positive() {
return Err(invalid(op, format!("{what} must be positive, got {x}")));
}
Ok(())
}
fn sqrt_q(ctx: &Context, v: Q) -> Ex {
ctx.from_ratio(v).sqrt().simplify()
}
fn positive_root(v: &Ex) -> Ex {
let s = v.sqrt().simplify();
if let [u] = s.args().as_slice()
&& s == u.abs()
&& let Ok(value) = u.eval_f64()
{
if value > 0.0 {
return u.clone();
}
if value < 0.0 {
return (-u).simplify();
}
}
s
}
fn ln_q(ctx: &Context, x: &Q) -> Ex {
let side = |n: &BigInt, sign: i64| -> Ex {
let (factors, cofactor) = crate::domains::ntheory::factorint_bounded(n, 64);
let mut acc = ctx.zero();
for (p, e) in factors {
acc += ctx.int(sign * i64::from(e)) * ctx.from_bigint(p).ln();
}
if cofactor > BigInt::one() {
acc += ctx.int(sign) * ctx.from_bigint(cofactor).ln();
}
acc
};
side(x.numer(), 1) + side(x.denom(), -1)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FamilyKind {
Normal,
Exponential,
Poisson,
Bernoulli,
Binomial {
n: usize,
},
Geometric,
Uniform,
LogNormal,
Gamma,
Beta,
NegativeBinomial,
}
pub fn fit(ctx: &Context, family: FamilyKind, data: &[Q]) -> Result<Distribution, SymplexError> {
match family {
FamilyKind::Normal => fit_normal(ctx, data),
FamilyKind::Exponential => fit_exponential(ctx, data),
FamilyKind::Poisson => fit_poisson(ctx, data),
FamilyKind::Bernoulli => fit_bernoulli(ctx, data),
FamilyKind::Binomial { n } => fit_binomial_p(ctx, n, data),
FamilyKind::Geometric => fit_geometric(ctx, data),
FamilyKind::Uniform => fit_uniform(ctx, data),
FamilyKind::LogNormal => fit_log_normal(ctx, data),
FamilyKind::Gamma | FamilyKind::Beta | FamilyKind::NegativeBinomial => Err(invalid(
"fit",
format!(
"the {family:?} maximum-likelihood estimate has no closed form; use method_of_moments"
),
)),
}
}
pub fn method_of_moments(
ctx: &Context,
family: FamilyKind,
data: &[Q],
) -> Result<Distribution, SymplexError> {
match family {
FamilyKind::Normal => fit_normal(ctx, data),
FamilyKind::Exponential => fit_exponential(ctx, data),
FamilyKind::Poisson => fit_poisson(ctx, data),
FamilyKind::Bernoulli => fit_bernoulli(ctx, data),
FamilyKind::Binomial { n } => fit_binomial_p(ctx, n, data),
FamilyKind::Geometric => fit_geometric(ctx, data),
FamilyKind::Uniform => fit_uniform_moments(ctx, data),
FamilyKind::LogNormal => fit_log_normal_moments(ctx, data),
FamilyKind::Gamma => fit_gamma_moments(ctx, data),
FamilyKind::Beta => fit_beta_moments(ctx, data),
FamilyKind::NegativeBinomial => fit_negative_binomial_moments(ctx, data),
}
}
pub fn fit_normal(ctx: &Context, data: &[Q]) -> Result<Distribution, SymplexError> {
const OP: &str = "fit_normal";
nonempty(OP, data)?;
let mean = data::mean(data)?;
let var = data::variance(data, Ddof::Population)?;
if var.is_zero() {
return Err(invalid(OP, "constant data give σ̂ = 0"));
}
Distribution::try_normal(ctx.from_ratio(mean), sqrt_q(ctx, var))
}
pub fn fit_exponential(ctx: &Context, data: &[Q]) -> Result<Distribution, SymplexError> {
require_positive_data("fit_exponential", data)?;
let rate = data::mean(data)?.recip();
Distribution::try_exponential(ctx.from_ratio(rate))
}
pub fn fit_poisson(ctx: &Context, data: &[Q]) -> Result<Distribution, SymplexError> {
const OP: &str = "fit_poisson";
require_counts(OP, data)?;
let rate = data::mean(data)?;
if rate.is_zero() {
return Err(invalid(OP, "all-zero counts give λ̂ = 0"));
}
Distribution::try_poisson(ctx.from_ratio(rate))
}
pub fn fit_bernoulli(ctx: &Context, data: &[Q]) -> Result<Distribution, SymplexError> {
const OP: &str = "fit_bernoulli";
nonempty(OP, data)?;
if let Some(x) = data.iter().find(|x| !x.is_zero() && !x.is_one()) {
return Err(invalid(
OP,
format!("fit_bernoulli needs observations in {{0, 1}}, got {x}"),
));
}
Distribution::try_bernoulli(ctx.from_ratio(data::mean(data)?))
}
pub fn fit_binomial_p(ctx: &Context, n: usize, data: &[Q]) -> Result<Distribution, SymplexError> {
const OP: &str = "fit_binomial_p";
if n == 0 {
return Err(invalid(OP, "fit_binomial_p needs at least one trial"));
}
require_counts(OP, data)?;
let nq = qu(n);
if let Some(x) = data.iter().find(|x| **x > nq) {
return Err(invalid(
OP,
format!("observation {x} exceeds the number of trials {n}"),
));
}
let p = data::mean(data)? / &nq;
Distribution::try_binomial(ctx.from_ratio(nq), ctx.from_ratio(p))
}
pub fn fit_geometric(ctx: &Context, data: &[Q]) -> Result<Distribution, SymplexError> {
const OP: &str = "fit_geometric";
require_counts(OP, data)?;
if let Some(x) = data.iter().find(|x| x.is_zero()) {
return Err(invalid(
OP,
format!(
"fit_geometric needs observations ≥ 1 (trials up to the first success), got {x}"
),
));
}
Distribution::try_geometric(ctx.from_ratio(data::mean(data)?.recip()))
}
pub fn fit_uniform(ctx: &Context, data: &[Q]) -> Result<Distribution, SymplexError> {
const OP: &str = "fit_uniform";
nonempty(OP, data)?;
let range = data::min_max(data)?;
if range.lower == range.upper {
return Err(invalid(OP, "constant data give a zero-width interval"));
}
Distribution::try_uniform(ctx.from_ratio(range.lower), ctx.from_ratio(range.upper))
}
pub fn fit_log_normal(ctx: &Context, data: &[Q]) -> Result<Distribution, SymplexError> {
const OP: &str = "fit_log_normal";
require_positive_data(OP, data)?;
let range = data::min_max(data)?;
if range.lower == range.upper {
return Err(invalid(OP, "constant data give σ̂ = 0"));
}
let n = ex_usize(ctx, data.len());
let logs: Vec<Ex> = data.iter().map(|x| ln_q(ctx, x)).collect();
let mu = (logs.iter().fold(ctx.zero(), |acc, l| acc + l) / &n).simplify();
let var = (logs
.iter()
.fold(ctx.zero(), |acc, l| acc + (l - &mu).powi(2))
/ &n)
.simplify();
Distribution::try_log_normal(mu, positive_root(&var))
}
fn two_moments(op: &'static str, data: &[Q]) -> Result<(Q, Q), SymplexError> {
nonempty(op, data)?;
let m = data::mean(data)?;
let v = data::variance(data, Ddof::Population)?;
if v.is_zero() {
return Err(invalid(
op,
"constant data have zero variance; the moment equations are degenerate",
));
}
Ok((m, v))
}
pub fn fit_gamma_moments(ctx: &Context, data: &[Q]) -> Result<Distribution, SymplexError> {
const OP: &str = "fit_gamma_moments";
require_positive_data(OP, data)?;
let (m, v) = two_moments(OP, data)?;
let shape = &m * &m / &v;
let scale = &v / &m;
Distribution::try_gamma(ctx.from_ratio(shape), ctx.from_ratio(scale))
}
pub fn fit_beta_moments(ctx: &Context, data: &[Q]) -> Result<Distribution, SymplexError> {
const OP: &str = "fit_beta_moments";
nonempty(OP, data)?;
if let Some(x) = data.iter().find(|x| !x.is_positive() || **x >= Q::one()) {
return Err(invalid(
OP,
format!("fit_beta_moments needs observations in (0, 1), got {x}"),
));
}
let (m, v) = two_moments(OP, data)?;
let one_minus = Q::one() - &m;
let c = &m * &one_minus / &v - Q::one();
if !c.is_positive() {
return Err(invalid(
OP,
"the variance is too large for a Beta law (x̄(1 − x̄)/s² ≤ 1)",
));
}
Distribution::try_beta(ctx.from_ratio(&m * &c), ctx.from_ratio(one_minus * c))
}
pub fn fit_negative_binomial_moments(
ctx: &Context,
data: &[Q],
) -> Result<Distribution, SymplexError> {
const OP: &str = "fit_negative_binomial_moments";
require_counts(OP, data)?;
let (m, v) = two_moments(OP, data)?;
if !m.is_positive() {
return Err(invalid(OP, "all-zero counts"));
}
if v <= m {
return Err(invalid(
OP,
format!(
"fit_negative_binomial_moments needs over-dispersed counts (s² > x̄), got s² = {v}, x̄ = {m}"
),
));
}
let p = &m / &v;
let r = &m * &m / (&v - &m);
Distribution::try_negative_binomial(ctx.from_ratio(r), ctx.from_ratio(p))
}
pub fn fit_uniform_moments(ctx: &Context, data: &[Q]) -> Result<Distribution, SymplexError> {
let (m, v) = two_moments("fit_uniform_moments", data)?;
let half_width = sqrt_q(ctx, v * qu(3));
let m = ctx.from_ratio(m);
Distribution::try_uniform((&m - &half_width).simplify(), (m + half_width).simplify())
}
pub fn fit_log_normal_moments(ctx: &Context, data: &[Q]) -> Result<Distribution, SymplexError> {
const OP: &str = "fit_log_normal_moments";
require_positive_data(OP, data)?;
let (m, v) = two_moments(OP, data)?;
let var = ctx.from_ratio(Q::one() + &v / (&m * &m)).ln();
let mu = (ctx.from_ratio(m).ln() - &var / ctx.int(2)).simplify();
Distribution::try_log_normal(mu, positive_root(&var))
}
pub fn log_likelihood(dist: &Distribution, data: &[Q]) -> Ex {
let ctx = dist.context();
data.iter()
.fold(ctx.zero(), |acc, x| {
acc + dist.density(&ctx.from_ratio(x.clone())).ln()
})
.expand_log()
.simplify()
}
pub fn aic(log_lik: &Ex, k: usize) -> Ex {
let ctx = log_lik.context();
(ctx.int(2) * ex_usize(&ctx, k) - ctx.int(2) * log_lik).simplify()
}
pub fn bic(log_lik: &Ex, k: usize, n: usize) -> Ex {
let ctx = log_lik.context();
(ex_usize(&ctx, k) * ex_usize(&ctx, n).ln() - ctx.int(2) * log_lik).simplify()
}
pub fn beta_binomial_posterior(
ctx: &Context,
prior_alpha: &Q,
prior_beta: &Q,
successes: usize,
failures: usize,
) -> Result<Distribution, SymplexError> {
const OP: &str = "beta_binomial_posterior";
require_positive_param(OP, prior_alpha, "the prior α")?;
require_positive_param(OP, prior_beta, "the prior β")?;
Distribution::try_beta(
ctx.from_ratio(prior_alpha + qu(successes)),
ctx.from_ratio(prior_beta + qu(failures)),
)
}
pub fn gamma_poisson_posterior(
ctx: &Context,
shape: &Q,
scale: &Q,
counts: &[usize],
) -> Result<Distribution, SymplexError> {
const OP: &str = "gamma_poisson_posterior";
require_positive_param(OP, shape, "the prior shape")?;
require_positive_param(OP, scale, "the prior scale")?;
let total: Q = counts.iter().fold(Q::zero(), |acc, &c| acc + qu(c));
let n = qu(counts.len());
let post_scale = scale / (Q::one() + n * scale);
Distribution::try_gamma(ctx.from_ratio(shape + total), ctx.from_ratio(post_scale))
}
pub fn normal_known_variance_posterior(
ctx: &Context,
prior_mean: &Q,
prior_sd: &Q,
sigma: &Q,
data: &[Q],
) -> Result<Distribution, SymplexError> {
const OP: &str = "normal_known_variance_posterior";
let (mu0, sigma0) = (prior_mean, prior_sd);
require_positive_param(OP, sigma0, "the prior standard deviation")?;
require_positive_param(OP, sigma, "the observation standard deviation")?;
nonempty(OP, data)?;
let prior_prec = (sigma0 * sigma0).recip();
let obs_prec = (sigma * sigma).recip();
let n = qu(data.len());
let precision = &prior_prec + &n * &obs_prec;
let mean = (mu0 * &prior_prec + data::sum(data) * &obs_prec) / &precision;
Distribution::try_normal(ctx.from_ratio(mean), sqrt_q(ctx, precision.recip()))
}
pub fn dirichlet_posterior_alphas(prior: &[Q], counts: &[usize]) -> Result<Vec<Q>, SymplexError> {
const OP: &str = "dirichlet_posterior_alphas";
if prior.is_empty() {
return Err(invalid(OP, "a Dirichlet prior needs at least one category"));
}
if prior.len() != counts.len() {
return Err(invalid(
OP,
format!(
"the Dirichlet prior has {} categories but {} counts were given",
prior.len(),
counts.len()
),
));
}
for a in prior {
require_positive_param(OP, a, "every prior α")?;
}
Ok(prior.iter().zip(counts).map(|(a, &c)| a + qu(c)).collect())
}
pub fn dirichlet_multinomial_posterior(
prior: &[Q],
counts: &[usize],
) -> Result<Vec<Q>, SymplexError> {
let alphas = dirichlet_posterior_alphas(prior, counts)?;
let total = data::sum(&alphas);
Ok(alphas.into_iter().map(|a| a / &total).collect())
}
pub fn credible_interval(
dist: &Distribution,
confidence: f64,
) -> Result<Interval<f64>, SymplexError> {
check_confidence("credible_interval", confidence)?;
let tail = (1.0 - confidence) / 2.0;
Ok(Interval::closed(
dist.quantile_f64(tail)?,
dist.isf_f64(tail)?,
))
}
pub fn posterior_predictive_beta_binomial(
ctx: &Context,
prior_alpha: &Q,
prior_beta: &Q,
n: usize,
) -> Result<Distribution, SymplexError> {
const OP: &str = "posterior_predictive_beta_binomial";
require_positive_param(OP, prior_alpha, "α")?;
require_positive_param(OP, prior_beta, "β")?;
let mut mass =
rising_factorial(prior_beta, n) / rising_factorial(&(prior_alpha + prior_beta), n);
let mut table: Vec<(Ex, Ex)> = Vec::new();
for k in 0..n {
let ratio =
qu(n - k) * (prior_alpha + qu(k)) / ((qu(k) + Q::one()) * (prior_beta + qu(n - k - 1)));
let next = &mass * ratio;
table.push((
ex_usize(ctx, k),
ctx.from_ratio(std::mem::replace(&mut mass, next)),
));
}
table.push((ex_usize(ctx, n), ctx.from_ratio(mass)));
Ok(Distribution::finite(ctx, table))
}
fn rising_factorial(a: &Q, m: usize) -> Q {
let (p, q) = (a.numer(), a.denom());
let numer = (0..m).fold(BigInt::one(), |acc, i| acc * (p + q * BigInt::from(i)));
Q::new(numer, num_traits::pow(q.clone(), m))
}
pub fn standard_error_mean(ctx: &Context, sigma: &Q, n: usize) -> Result<Ex, SymplexError> {
const OP: &str = "standard_error_mean";
require_positive_param(OP, sigma, "σ")?;
if n == 0 {
return Err(invalid(
OP,
"the standard error needs at least one observation",
));
}
Ok((ctx.from_ratio(sigma.clone()) / ex_usize(ctx, n).sqrt()).simplify())
}
pub fn confidence_interval_mean(x: &[Q], confidence: f64) -> Result<Interval<f64>, SymplexError> {
const OP: &str = "confidence_interval_mean";
check_sample(OP, "the sample", x, 2)?;
check_confidence(OP, confidence)?;
let n = x.len();
let mean = q_to_f64(&data::mean(x)?);
let sem = q_to_f64(&(data::variance(x, Ddof::Sample)? / qu(n))).sqrt();
let t = numdist::t::isf((1.0 - confidence) / 2.0, (n - 1) as f64).map_err(renamed(OP))?;
Ok(Interval::closed(mean - t * sem, mean + t * sem))
}
pub fn confidence_interval_mean_z(
data: &[Q],
sigma: &Q,
confidence: f64,
) -> Result<Interval<f64>, SymplexError> {
const OP: &str = "confidence_interval_mean_z";
nonempty(OP, data)?;
check_confidence(OP, confidence)?;
require_positive_param(OP, sigma, "σ")?;
let se = q_to_f64(&(sigma * sigma / qu(data.len()))).sqrt();
let mean = q_to_f64(&data::mean(data)?);
let z = z_two_sided(confidence);
Ok(Interval::closed(mean - z * se, mean + z * se))
}
pub fn confidence_interval_mean_z_symbolic(
ctx: &Context,
data: &[Q],
sigma: &Q,
z: &Ex,
) -> Result<Interval<Ex>, SymplexError> {
nonempty("confidence_interval_mean_z_symbolic", data)?;
let se = standard_error_mean(ctx, sigma, data.len())?;
let mean = ctx.from_ratio(data::mean(data)?);
let half = z * se;
Ok(Interval::closed(&mean - &half, &mean + &half))
}
pub fn confidence_interval_mean_z_exact(
ctx: &Context,
data: &[Q],
sigma: &Q,
confidence: &Q,
) -> Result<Interval<Ex>, SymplexError> {
nonempty("confidence_interval_mean_z_exact", data)?;
let z = z_for_confidence(ctx, confidence)?;
confidence_interval_mean_z_symbolic(ctx, data, sigma, &z)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum IntervalMethod {
Wilson,
ClopperPearson,
AgrestiCoull,
Wald,
Jeffreys,
}
fn renamed(op: &'static str) -> impl Fn(SymplexError) -> SymplexError {
move |e| match e {
SymplexError::InvalidArgument { reason, .. } => invalid(op, reason),
SymplexError::ComputationFailed { reason, .. } => {
SymplexError::computation_failed(op, reason)
}
other => other,
}
}
fn check_trials(op: &'static str, successes: usize, trials: usize) -> Result<(), SymplexError> {
if trials == 0 {
return Err(invalid(op, "needs at least one trial"));
}
if successes > trials {
return Err(invalid(op, "more successes than trials"));
}
Ok(())
}
fn check_confidence_q(op: &'static str, confidence: &Q) -> Result<(), SymplexError> {
if !(confidence.is_positive() && *confidence < Q::one()) {
return Err(invalid(
op,
format!("confidence must lie strictly between 0 and 1, got {confidence}"),
));
}
Ok(())
}
pub fn proportion_interval(
successes: usize,
trials: usize,
confidence: f64,
method: IntervalMethod,
) -> Result<Interval<f64>, SymplexError> {
const OP: &str = "proportion_interval";
check_trials(OP, successes, trials)?;
check_confidence(OP, confidence)?;
let tail = (1.0 - confidence) / 2.0;
let (k, fails, n) = (successes as f64, (trials - successes) as f64, trials as f64);
let beta_ppf = |a: f64, b: f64| numdist::beta::ppf(tail, a, b).map_err(renamed(OP));
let beta_isf = |a: f64, b: f64| numdist::beta::isf(tail, a, b).map_err(renamed(OP));
let z = || standard_normal().isf_f64(tail).map_err(renamed(OP));
let p = k / n;
let unit = Interval::closed(0.0, 1.0);
let clip = |ci: Interval<f64>| ci.map(|v| unit.clamp_to_closure(v));
Ok(match method {
IntervalMethod::ClopperPearson => {
let lo = if successes == 0 {
0.0
} else {
beta_ppf(k, fails + 1.0)?
};
let hi = if successes == trials {
1.0
} else {
beta_isf(k + 1.0, fails)?
};
Interval::closed(lo, hi)
}
IntervalMethod::Jeffreys => {
let (a, b) = (k + 0.5, fails + 0.5);
Interval::closed(beta_ppf(a, b)?, beta_isf(a, b)?)
}
IntervalMethod::Wald => {
let half = z()? * (p * (1.0 - p) / n).sqrt();
clip(Interval::closed(p - half, p + half))
}
IntervalMethod::Wilson => {
let z = z()?;
let z2 = z * z;
let denom = 1.0 + z2 / n;
let centre = (p + z2 / (2.0 * n)) / denom;
let half = z * (p * (1.0 - p) / n + z2 / (4.0 * n * n)).sqrt() / denom;
let upper = centre + half;
clip(Interval::closed(p * p / (denom * upper), upper))
}
IntervalMethod::AgrestiCoull => {
let z = z()?;
let z2 = z * z;
let n_t = n + z2;
let p_t = (k + z2 / 2.0) / n_t;
let half = z * (p_t * (1.0 - p_t) / n_t).sqrt();
clip(Interval::closed(p_t - half, p_t + half))
}
})
}
pub fn z_for_confidence(ctx: &Context, confidence: &Q) -> Result<Ex, SymplexError> {
check_confidence_q("z_for_confidence", confidence)?;
Ok(ctx.int(2).sqrt() * ctx.from_ratio(confidence.clone()).erfinv())
}
pub fn proportion_interval_symbolic(
ctx: &Context,
successes: usize,
trials: usize,
z: &Ex,
method: IntervalMethod,
) -> Result<Interval<Ex>, SymplexError> {
const OP: &str = "proportion_interval_symbolic";
check_trials(OP, successes, trials)?;
let n = ex_usize(ctx, trials);
let p_hat = ctx.from_ratio(qu(successes) / qu(trials));
let z2 = z.powi(2);
let var = &p_hat * (ctx.one() - &p_hat) / &n;
Ok(match method {
IntervalMethod::Wald => {
let half = z * var.sqrt();
Interval::closed(&p_hat - &half, &p_hat + &half)
}
IntervalMethod::Wilson => {
let denom = ctx.one() + &z2 / &n;
let centre = (&p_hat + &z2 / (ctx.int(2) * &n)) / &denom;
let half = z * (&var + &z2 / (ctx.int(4) * &n * &n)).sqrt() / &denom;
Interval::closed(¢re - &half, ¢re + &half)
}
IntervalMethod::AgrestiCoull => {
let n_t = &n + &z2;
let p_t = (ex_usize(ctx, successes) + &z2 / ctx.int(2)) / &n_t;
let half = z * (&p_t * (ctx.one() - &p_t) / &n_t).sqrt();
Interval::closed(&p_t - &half, &p_t + &half)
}
IntervalMethod::ClopperPearson => {
return Err(invalid(
OP,
"Clopper–Pearson has no closed form in z; use proportion_interval_exact",
));
}
IntervalMethod::Jeffreys => {
return Err(invalid(
OP,
"the Jeffreys interval has no closed form in z; use proportion_interval",
));
}
})
}
fn binomial_tail_root(
ctx: &Context,
n: usize,
k: usize,
upper_tail: bool,
half_alpha: &Q,
) -> Result<Ex, SymplexError> {
let op = "proportion_interval_exact";
let failed = |reason: String| SymplexError::computation_failed(op, reason);
let p = fresh_symbol(ctx, "p", &[]);
let one_minus_p = ctx.one() - &p;
let range = if upper_tail { k..=n } else { 0..=k };
let tail = range.fold(ctx.zero(), |acc, j| {
acc + ctx.from_ratio(data::binomial_q(n, j))
* p.powi(j as i64)
* one_minus_p.powi((n - j) as i64)
});
let poly = (tail - ctx.from_ratio(half_alpha.clone())).expand();
let (zero, one) = (ctx.zero(), ctx.one());
let in_unit = poly
.count_real_roots_in(&p, &zero, &one)
.ok_or_else(|| failed("the binomial tail did not expand to a polynomial".into()))?;
if in_unit != 1 {
return Err(failed(format!(
"expected exactly one root of the binomial tail in (0, 1) for n = {n}, k = {k}, found {in_unit}"
)));
}
let below = poly
.count_real_roots_in(&p, &ctx.neg_infinity(), &zero)
.ok_or_else(|| failed("the binomial tail did not expand to a polynomial".into()))?;
poly.root_of(&p, below).ok_or_else(|| {
failed(format!(
"could not name the root of the degree-{n} binomial tail polynomial \
(its factorisation over ℤ was not certified or the RootOf index is unstable); \
try a smaller number of trials"
))
})
}
pub fn proportion_interval_exact(
ctx: &Context,
successes: usize,
trials: usize,
confidence: &Q,
method: IntervalMethod,
) -> Result<Interval<Ex>, SymplexError> {
const OP: &str = "proportion_interval_exact";
check_trials(OP, successes, trials)?;
check_confidence_q(OP, confidence)?;
match method {
IntervalMethod::ClopperPearson => {}
IntervalMethod::Jeffreys => {
return Err(invalid(
OP,
"the Jeffreys ends are Beta quantiles at half-integer shapes, not algebraic \
numbers; use proportion_interval",
));
}
IntervalMethod::Wald | IntervalMethod::Wilson | IntervalMethod::AgrestiCoull => {
let z = z_for_confidence(ctx, confidence)?;
return proportion_interval_symbolic(ctx, successes, trials, &z, method);
}
}
let half_alpha = (Q::one() - confidence) / qu(2);
let lower = if successes == 0 {
ctx.zero()
} else {
binomial_tail_root(ctx, trials, successes, true, &half_alpha)?
};
let upper = if successes == trials {
ctx.one()
} else {
binomial_tail_root(ctx, trials, successes, false, &half_alpha)?
};
Ok(Interval::closed(lower, upper))
}
#[must_use]
pub fn fisher_z(r: &Ex) -> Ex {
r.atanh()
}
pub fn pearson_ci(r: f64, n: usize, confidence: f64) -> Result<Interval<f64>, SymplexError> {
const OP: &str = "pearson_ci";
if !r.is_finite() || r.abs() >= 1.0 {
return Err(invalid(
OP,
format!("the correlation must lie strictly between −1 and 1, got {r}"),
));
}
if n < 4 {
return Err(invalid(
OP,
format!("Fisher's z interval needs at least four observations, got {n}"),
));
}
check_confidence(OP, confidence)?;
let z = r.atanh();
let se = 1.0 / ((n - 3) as f64).sqrt();
let zc = z_two_sided(confidence);
Ok(Interval::closed((z - zc * se).tanh(), (z + zc * se).tanh()))
}