#[derive(Debug, Clone, Copy)]
pub struct ScalarSkovgaardInput {
pub theta_hat: f64,
pub theta_null: f64,
pub lr_statistic: f64,
pub observed_info: f64,
pub expected_info: f64,
pub score_cov: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct ScalarSkovgaardResult {
pub r: f64,
pub u: f64,
pub r_star: f64,
pub p_value_first_order: f64,
pub p_value_corrected: f64,
pub u_empirical: f64,
pub r_star_empirical: f64,
pub p_value_corrected_empirical: f64,
pub material: bool,
}
fn normal_cdf(z: f64) -> f64 {
0.5 * statrs::function::erf::erfc(-z / std::f64::consts::SQRT_2)
}
fn two_sided_p(z: f64) -> f64 {
(2.0 * normal_cdf(-z.abs())).clamp(0.0, 1.0)
}
pub const SKOVGAARD_MATERIAL_THRESHOLD: f64 = 0.10;
pub fn scalar_skovgaard_r_star(input: &ScalarSkovgaardInput) -> Option<ScalarSkovgaardResult> {
let ScalarSkovgaardInput {
theta_hat,
theta_null,
lr_statistic,
observed_info,
expected_info,
score_cov,
} = *input;
if !(lr_statistic.is_finite() && lr_statistic > 0.0) {
return None;
}
if !(observed_info.is_finite() && observed_info > 0.0) {
return None;
}
if !(expected_info.is_finite() && expected_info > 0.0) {
return None;
}
if !(score_cov.is_finite() && score_cov > 0.0) {
return None;
}
if !(theta_hat.is_finite() && theta_null.is_finite()) {
return None;
}
if theta_hat == theta_null {
return Some(ScalarSkovgaardResult {
r: 0.0,
u: 0.0,
r_star: 0.0,
p_value_first_order: 1.0,
p_value_corrected: 1.0,
u_empirical: 0.0,
r_star_empirical: 0.0,
p_value_corrected_empirical: 1.0,
material: false,
});
}
let sign = (theta_hat - theta_null).signum();
let p_first = two_sided_p(sign * lr_statistic.sqrt());
let r = sign * lr_statistic.sqrt();
let dtheta = theta_hat - theta_null;
let sqrt_obs = observed_info.sqrt();
let u = dtheta * expected_info / sqrt_obs;
let u_empirical = dtheta * score_cov / sqrt_obs;
let modified_root = |u_val: f64| -> f64 {
let ratio = u_val / r;
if !(ratio.is_finite() && ratio > 0.0) {
return r;
}
let rs = r + ratio.ln() / r;
if rs.is_finite() { rs } else { r }
};
let r_star = modified_root(u);
let r_star_empirical = modified_root(u_empirical);
let p_corr = two_sided_p(r_star);
let p_corr_empirical = two_sided_p(r_star_empirical);
let p_denom = p_first.max(p_corr).max(f64::MIN_POSITIVE);
let p_move = (p_corr - p_first).abs() / p_denom;
let r_move = (r_star - r).abs() / r.abs().max(f64::MIN_POSITIVE);
let material = p_move > SKOVGAARD_MATERIAL_THRESHOLD || r_move > SKOVGAARD_MATERIAL_THRESHOLD;
Some(ScalarSkovgaardResult {
r,
u,
r_star,
p_value_first_order: p_first,
p_value_corrected: p_corr,
u_empirical,
r_star_empirical,
p_value_corrected_empirical: p_corr_empirical,
material,
})
}
pub fn scalar_skovgaard_from_matrices(
contrast: ndarray::ArrayView1<'_, f64>,
beta: ndarray::ArrayView1<'_, f64>,
penalized_hessian: ndarray::ArrayView2<'_, f64>,
fisher_information: Option<ndarray::ArrayView2<'_, f64>>,
row_scores: ndarray::ArrayView2<'_, f64>,
lr_statistic: f64,
theta_null: f64,
) -> Option<ScalarSkovgaardResult> {
use crate::faer_ndarray::FaerCholesky;
use faer::Side;
let p = beta.len();
if p == 0
|| contrast.len() != p
|| penalized_hessian.nrows() != p
|| penalized_hessian.ncols() != p
|| row_scores.ncols() != p
{
return None;
}
if contrast.iter().any(|v| !v.is_finite()) || beta.iter().any(|v| !v.is_finite()) {
return None;
}
let theta_hat = contrast.dot(&beta);
let h_obs = penalized_hessian.to_owned();
let chol_obs = h_obs.cholesky(Side::Lower).ok()?;
let c_owned = contrast.to_owned();
let hinv_c = chol_obs.solvevec(&c_owned);
if hinv_c.iter().any(|v| !v.is_finite()) {
return None;
}
let var_obs = contrast.dot(&hinv_c);
if !(var_obs.is_finite() && var_obs > 0.0) {
return None;
}
let observed_info = 1.0 / var_obs;
let expected_info = match fisher_information {
Some(fisher) => {
if fisher.nrows() != p || fisher.ncols() != p {
return None;
}
let f_owned = fisher.to_owned();
let chol_f = f_owned.cholesky(Side::Lower).ok()?;
let finv_c = chol_f.solvevec(&c_owned);
let var_exp = contrast.dot(&finv_c);
if !(var_exp.is_finite() && var_exp > 0.0) {
return None;
}
1.0 / var_exp
}
None => observed_info,
};
let mut sandwich = 0.0;
for srow in row_scores.rows() {
if srow.iter().any(|v| !v.is_finite()) {
return None;
}
let proj = srow.dot(&hinv_c);
sandwich += proj * proj;
}
if !(sandwich.is_finite() && sandwich > 0.0) {
return None;
}
let score_cov = 1.0 / sandwich;
scalar_skovgaard_r_star(&ScalarSkovgaardInput {
theta_hat,
theta_null,
lr_statistic,
observed_info,
expected_info,
score_cov,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn regular_case_collapses_to_first_order() {
let info = 100.0;
let dtheta = 0.3;
let lr = dtheta * dtheta * info; let res = scalar_skovgaard_r_star(&ScalarSkovgaardInput {
theta_hat: 0.3,
theta_null: 0.0,
lr_statistic: lr,
observed_info: info,
expected_info: info,
score_cov: info,
})
.expect("r*");
assert!((res.r - 3.0).abs() < 1e-12, "r = {}", res.r);
assert!((res.u - 3.0).abs() < 1e-12, "u = {}", res.u);
assert!((res.r_star - res.r).abs() < 1e-12, "r* = {}", res.r_star);
assert!(
(res.u_empirical - res.u).abs() < 1e-12,
"u_emp = {}",
res.u_empirical
);
assert!((res.r_star_empirical - res.r_star).abs() < 1e-12);
assert!(!res.material, "regular case must not be material");
}
#[test]
fn expected_observed_ratio_shifts_r_star_and_p() {
let dtheta = 0.2;
let observed = 100.0; let expected = 196.0; let score_cov = 121.0; let lr = dtheta * dtheta * observed; let res = scalar_skovgaard_r_star(&ScalarSkovgaardInput {
theta_hat: 0.2,
theta_null: 0.0,
lr_statistic: lr,
observed_info: observed,
expected_info: expected,
score_cov,
})
.expect("r*");
assert!((res.r - 2.0).abs() < 1e-12);
let expected_u = dtheta * expected / observed.sqrt();
assert!((res.u - expected_u).abs() < 1e-12, "u = {}", res.u);
assert!(
(res.r_star - 2.336_472_236_6).abs() < 1e-9,
"r* = {}",
res.r_star
);
assert!(res.r_star > res.r, "î > ĵ must lift r*");
assert!(
res.p_value_corrected < res.p_value_first_order,
"larger root ⇒ smaller two-sided p"
);
assert!(
res.material,
"96% information discrepancy must flag material"
);
assert!((res.u_empirical - dtheta * score_cov / observed.sqrt()).abs() < 1e-12);
assert!((res.r_star_empirical - 2.095_310_179_8).abs() < 1e-9);
}
#[test]
fn exponential_rate_scalar_skovgaard_closed_form() {
let n = 25.0_f64;
let sum_y = 20.0_f64;
let theta_hat = n / sum_y; let theta0 = 1.0_f64;
let ll = |t: f64| n * t.ln() - t * sum_y;
let lr = (2.0 * (ll(theta_hat) - ll(theta0))).max(0.0);
let observed = n / (theta_hat * theta_hat);
let expected = n / (theta_hat * theta_hat);
let score_cov = n / (theta_hat * theta_hat);
let res = scalar_skovgaard_r_star(&ScalarSkovgaardInput {
theta_hat,
theta_null: theta0,
lr_statistic: lr,
observed_info: observed,
expected_info: expected,
score_cov,
})
.expect("r*");
let r_expected = (theta_hat - theta0).signum() * lr.sqrt();
assert!((res.r - r_expected).abs() < 1e-12, "r = {}", res.r);
let u_expected = (theta_hat - theta0) * expected / observed.sqrt();
assert!((res.u - u_expected).abs() < 1e-12, "u = {}", res.u);
assert!(
(res.u_empirical - res.u).abs() < 1e-12,
"u_emp = {}",
res.u_empirical
);
assert!(res.r_star.is_finite());
assert!(res.r_star.signum() == res.r.signum());
let q = (theta_hat - theta0) * observed.sqrt();
assert!(
(res.u - q).abs() < 1e-12,
"u = {} should equal Wald root q = {q}",
res.u
);
assert!(
q < res.r_star && res.r_star < r_expected,
"need q < r* < r: q={q} r*={} r={r_expected}",
res.r_star
);
assert!((0.0..=1.0).contains(&res.p_value_first_order));
assert!((0.0..=1.0).contains(&res.p_value_corrected));
}
#[test]
fn matrix_assembler_reduces_diagonal_case() {
use ndarray::{Array2, array};
let contrast = array![1.0_f64];
let beta = array![0.25_f64];
let h = Array2::from_shape_vec((1, 1), vec![50.0]).unwrap();
let fisher = Array2::from_shape_vec((1, 1), vec![40.0]).unwrap();
let row_scores = Array2::from_shape_vec((2, 1), vec![3.0, 4.0]).unwrap();
let lr = 4.0;
let res = scalar_skovgaard_from_matrices(
contrast.view(),
beta.view(),
h.view(),
Some(fisher.view()),
row_scores.view(),
lr,
0.0,
)
.expect("assembled r*");
assert!((res.r - 2.0).abs() < 1e-12, "r = {}", res.r);
assert!(
(res.u - std::f64::consts::SQRT_2).abs() < 1e-9,
"u = {}",
res.u
);
assert!(
(res.r_star - 1.826_713_204_9).abs() < 1e-9,
"r* = {}",
res.r_star
);
assert!(res.p_value_corrected > res.p_value_first_order);
assert!(
(res.u_empirical - 3.535_533_905_9).abs() < 1e-9,
"u_emp = {}",
res.u_empirical
);
assert!((res.r_star_empirical - 2.284_858_570_8).abs() < 1e-9);
let bad_c = array![1.0_f64, 0.0];
assert!(
scalar_skovgaard_from_matrices(
bad_c.view(),
beta.view(),
h.view(),
None,
row_scores.view(),
lr,
0.0,
)
.is_none()
);
}
#[test]
fn non_canonical_link_expected_info_enters_u() {
let cdf = |x: f64| 1.0 / (1.0 + (-x).exp());
let pdf = |x: f64| {
let e = (-x).exp();
e / ((1.0 + e) * (1.0 + e))
};
let gp = |x: f64| 1.0 - 2.0 * cdf(x); let g = |x: f64| -x - 2.0 * (1.0 + (-x).exp()).ln();
let y = [-0.4_f64, 0.1, 0.7, 1.3, 2.1, 2.8, 3.9, 5.5];
let n = y.len() as f64;
let score = |t: f64| y.iter().map(|&yi| -gp(yi - t)).sum::<f64>(); let obs_info = |t: f64| 2.0 * y.iter().map(|&yi| pdf(yi - t)).sum::<f64>(); let mut theta_hat = y.iter().sum::<f64>() / n;
for _ in 0..100 {
let step = score(theta_hat) / obs_info(theta_hat);
theta_hat += step;
if step.abs() < 1e-14 {
break;
}
}
let theta0 = 1.0_f64;
let observed = obs_info(theta_hat); let expected = n / 3.0; let meat = y.iter().map(|&yi| gp(yi - theta_hat).powi(2)).sum::<f64>();
let score_cov = observed * observed / meat;
let lr = (2.0
* (y.iter().map(|&yi| g(yi - theta_hat)).sum::<f64>()
- y.iter().map(|&yi| g(yi - theta0)).sum::<f64>()))
.max(0.0);
assert!(
expected > observed,
"fixture must have î > ĵ: î={expected} ĵ={observed}"
);
let input = ScalarSkovgaardInput {
theta_hat,
theta_null: theta0,
lr_statistic: lr,
observed_info: observed,
expected_info: expected,
score_cov,
};
let res = scalar_skovgaard_r_star(&input).expect("r*");
let u_expected = (theta_hat - theta0) * expected / observed.sqrt();
assert!(
(res.u - u_expected).abs() < 1e-12,
"u = {} expected {u_expected}",
res.u
);
let canonical = scalar_skovgaard_r_star(&ScalarSkovgaardInput {
expected_info: observed,
..input
})
.expect("r* canonical");
assert!(
(res.u - canonical.u).abs() > 1e-3,
"expected_info must change u: non-canonical u={} canonical u={}",
res.u,
canonical.u
);
assert!(
(canonical.u - (theta_hat - theta0) * observed.sqrt()).abs() < 1e-12,
"canonical u must be (θ̂−θ₀)√ĵ, got {}",
canonical.u
);
let u_emp_expected = (theta_hat - theta0) * score_cov / observed.sqrt();
assert!((res.u_empirical - u_emp_expected).abs() < 1e-12);
assert!(res.r_star.is_finite() && res.r_star_empirical.is_finite());
}
#[test]
fn rejects_degenerate_inputs() {
let base = ScalarSkovgaardInput {
theta_hat: 0.3,
theta_null: 0.0,
lr_statistic: 4.0,
observed_info: 50.0,
expected_info: 50.0,
score_cov: 50.0,
};
assert!(
scalar_skovgaard_r_star(&ScalarSkovgaardInput {
lr_statistic: 0.0,
..base
})
.is_none()
);
assert!(
scalar_skovgaard_r_star(&ScalarSkovgaardInput {
observed_info: 0.0,
..base
})
.is_none()
);
assert!(
scalar_skovgaard_r_star(&ScalarSkovgaardInput {
score_cov: -1.0,
..base
})
.is_none()
);
let eq = scalar_skovgaard_r_star(&ScalarSkovgaardInput {
theta_hat: 0.0,
..base
})
.expect("equal");
assert_eq!(eq.r, 0.0);
assert_eq!(eq.p_value_first_order, 1.0);
assert!(!eq.material);
}
fn normal_pdf(z: f64) -> f64 {
(-0.5 * z * z).exp() / (2.0 * std::f64::consts::PI).sqrt()
}
fn poisson_tails(n: f64, mu_hat: f64, mu0: f64) -> (f64, f64, f64) {
let theta_hat = mu_hat.ln();
let theta0 = mu0.ln();
let s = n * mu_hat; let w = 2.0 * (s * (s / (n * mu0)).ln() - (s - n * mu0));
if theta_hat == theta0 || !(w > 0.0) {
return (0.5, 0.5, 0.5);
}
let info = s;
let res = scalar_skovgaard_r_star(&ScalarSkovgaardInput {
theta_hat,
theta_null: theta0,
lr_statistic: w,
observed_info: info,
expected_info: info,
score_cov: info,
})
.expect("poisson r*");
let r = res.r;
let u = res.u; let p_rstar = 1.0 - normal_cdf(res.r_star);
let p_lr = (1.0 - normal_cdf(r)) - normal_pdf(r) * (1.0 / r - 1.0 / u);
let p_first = 1.0 - normal_cdf(r);
(p_rstar, p_lr, p_first)
}
#[test]
fn poisson_r_star_tail_matches_lugannani_rice_saddlepoint() {
for &(n, mu_hat, mu0) in &[
(12.0_f64, 1.6_f64, 1.0_f64),
(20.0, 2.3, 1.7),
(8.0, 3.1, 2.0),
(30.0, 0.9, 0.6),
] {
let (p_rstar, p_lr, p_first) = poisson_tails(n, mu_hat, mu0);
assert!(
(0.0..=1.0).contains(&p_rstar) && (0.0..=1.0).contains(&p_lr),
"n={n} μ̂={mu_hat}: tails must be probabilities (r*={p_rstar}, LR={p_lr})"
);
let rel = (p_rstar - p_lr).abs() / p_lr.max(1e-12);
assert!(
(p_rstar - p_lr).abs() < 5e-3 || rel < 0.05,
"n={n} μ̂={mu_hat} μ₀={mu0}: r* tail {p_rstar:.6} must match \
Lugannani–Rice {p_lr:.6} (|Δ|={:.2e}, rel={rel:.3})",
(p_rstar - p_lr).abs()
);
assert!(
(p_rstar - p_first).abs() > 1e-4,
"n={n} μ̂={mu_hat}: r* must move the tail off the first-order value \
(r*={p_rstar:.6}, first-order={p_first:.6})"
);
}
}
#[test]
fn poisson_r_star_improves_small_n_size_over_first_order() {
let mu0 = 1.3_f64;
let alphas = [0.05_f64, 0.10];
let mut total_err_first = 0.0_f64;
let mut total_err_star = 0.0_f64;
let mut total_mae_first = 0.0_f64;
let mut total_mae_star = 0.0_f64;
for &n in &[10.0_f64, 16.0, 25.0] {
let rate = n * mu0; let s_max = (rate + 50.0 * rate.sqrt()).ceil() as usize;
let mut pmf_vec = vec![0.0_f64; s_max + 1];
let mut pmf = (-rate).exp();
pmf_vec[0] = pmf;
for s in 1..=s_max {
pmf *= rate / s as f64;
pmf_vec[s] = pmf;
}
let mut exact_upper = vec![0.0_f64; s_max + 2];
for s in (0..=s_max).rev() {
exact_upper[s] = exact_upper[s + 1] + pmf_vec[s];
}
let mut size_first = [0.0_f64; 2];
let mut size_star = [0.0_f64; 2];
let mut tail_err_first = 0.0_f64;
let mut tail_err_star = 0.0_f64;
let mut mass_upper = 0.0_f64;
for s in 1..=s_max {
let p = pmf_vec[s];
let mu_hat = s as f64 / n;
let (p_rstar, _p_lr, p_first) = poisson_tails(n, mu_hat, mu0);
for (j, &a) in alphas.iter().enumerate() {
if p_first <= a {
size_first[j] += p;
}
if p_rstar <= a {
size_star[j] += p;
}
}
if mu_hat >= mu0 {
let exact = 0.5 * (exact_upper[s] + exact_upper[s + 1]);
tail_err_first += p * (p_first - exact).abs();
tail_err_star += p * (p_rstar - exact).abs();
mass_upper += p;
}
}
for (j, &a) in alphas.iter().enumerate() {
let err_first = (size_first[j] - a).abs();
let err_star = (size_star[j] - a).abs();
assert!(
err_star <= err_first + 1e-9,
"n={n} α={a}: r* size {:.4} (|Δ|={err_star:.4}) must be no worse than \
first-order size {:.4} (|Δ|={err_first:.4})",
size_star[j],
size_first[j]
);
total_err_first += err_first;
total_err_star += err_star;
}
let mae_first = tail_err_first / mass_upper;
let mae_star = tail_err_star / mass_upper;
assert!(
mae_star <= mae_first + 1e-12,
"n={n}: r* tail must approximate the exact Poisson upper tail no worse \
than first-order: MAE*={mae_star:.6} must be ≤ MAE={mae_first:.6}"
);
total_mae_first += mae_first;
total_mae_star += mae_star;
}
assert!(
total_mae_star < total_mae_first - 1e-9,
"r* must strictly improve the aggregate exact-tail MAE over first-order: \
Σ MAE*={total_mae_star:.6} must be < Σ MAE={total_mae_first:.6}"
);
assert!(
total_err_star <= total_err_first + 1e-9,
"r* must not worsen the aggregate discrete size over first-order: \
Σ|size_r*−α|={total_err_star:.5} must be ≤ Σ|size_r−α|={total_err_first:.5}"
);
}
}