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 crate::domains::optimize::{RootOpts, bisect};
use super::common::{
check_confidence, check_sample, ex_usize, invalid, q_to_f64, qu, standard_normal, t_two_sided,
z_two_sided,
};
use super::data::{self, Ddof, Q};
use super::family::{Distribution, fresh_symbol};
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 = ctx.int(data.len() as i64);
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 * k as i64) - ctx.int(2) * log_lik).simplify()
}
pub fn bic(log_lik: &Ex, k: usize, n: usize) -> Ex {
let ctx = log_lik.context();
(ctx.int(k as i64) * ctx.int(n as i64).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.quantile_f64(1.0 - 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 denom = rising_factorial(&(prior_alpha + prior_beta), n);
let table: Vec<(Ex, Ex)> = (0..=n)
.map(|k| {
let mass = data::binomial_q(n, k)
* rising_factorial(prior_alpha, k)
* rising_factorial(prior_beta, n - k)
/ &denom;
(ex_usize(ctx, k), ctx.from_ratio(mass))
})
.collect();
Distribution::try_finite(ctx, table)
}
fn rising_factorial(a: &Q, m: usize) -> Q {
(0..m).fold(Q::one(), |acc, i| acc * (a + qu(i)))
}
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 = t_two_sided(OP, (n - 1) as f64, confidence)?;
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,
}
fn binomial_tail(n: usize, k: usize, p: f64, upper: bool) -> f64 {
let indicator = |c: bool| if c { 1.0 } else { 0.0 };
if p <= 0.0 {
return if upper { indicator(k == 0) } else { 1.0 };
}
if p >= 1.0 {
return if upper { 1.0 } else { indicator(k == n) };
}
let (lp, lq) = (p.ln(), (1.0 - p).ln());
let mut log_c = 0.0; let mut tail = 0.0;
for i in 0..=n {
if i > 0 {
log_c += ((n - i + 1) as f64).ln() - (i as f64).ln();
}
if if upper { i >= k } else { i <= k } {
tail += (log_c + i as f64 * lp + (n - i) as f64 * lq).exp();
}
}
tail.min(1.0)
}
fn bisect_unit(op: &'static str, f: impl Fn(f64) -> f64) -> Result<f64, SymplexError> {
let tight = RootOpts {
xtol: 0.0,
max_iter: 200,
..RootOpts::default()
};
bisect(f, 0.0, 1.0, &tight).map_err(|e| SymplexError::computation_failed(op, e.to_string()))
}
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 alpha = 1.0 - confidence;
let (k, n) = (successes as f64, trials as f64);
let unit = Interval::closed(0.0, 1.0);
let clip = |ci: Interval<f64>| ci.map(|v| unit.clamp_to_closure(v));
if method == IntervalMethod::ClopperPearson {
let half = alpha / 2.0;
let lo = if successes == 0 {
0.0
} else {
bisect_unit(OP, |p| binomial_tail(trials, successes, p, true) - half)?
};
let hi = if successes == trials {
1.0
} else {
bisect_unit(OP, |p| binomial_tail(trials, successes, p, false) - half)?
};
return Ok(Interval::closed(lo, hi));
}
let z = standard_normal().quantile_f64(1.0 - alpha / 2.0)?;
let p = k / n;
Ok(clip(match method {
IntervalMethod::Wald => {
let half = z * (p * (1.0 - p) / n).sqrt();
Interval::closed(p - half, p + half)
}
IntervalMethod::Wilson => {
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;
Interval::closed(centre - half, centre + half)
}
IntervalMethod::AgrestiCoull => {
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();
Interval::closed(p_t - half, p_t + half)
}
IntervalMethod::ClopperPearson => Interval::closed(0.0, 1.0),
}))
}
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",
));
}
})
}
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)?;
if method != IntervalMethod::ClopperPearson {
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()))
}