use crate::foundation::{AlgoError, Result};
use statrs::distribution::{ContinuousCDF, Normal as StatrsNormal};
const QUAD_NODES: usize = 256;
pub(crate) fn logistic(t: f64) -> f64 {
if t >= 0.0 {
1.0 / (1.0 + (-t).exp())
} else {
let e = t.exp();
e / (1.0 + e)
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct LogitNormal {
a: f64,
b: f64,
}
impl LogitNormal {
pub(crate) fn match_moments(mean: f64, std: f64) -> Result<LogitNormal> {
if !(mean.is_finite() && std.is_finite()) || mean <= 0.0 || mean >= 1.0 || std <= 0.0 {
return Err(AlgoError::InvalidArgument(
"synth transform: need 0 < mean < 1 and std > 0".to_string(),
));
}
let target_var = std * std;
let cap = mean * (1.0 - mean);
if target_var >= cap {
return Err(AlgoError::InvalidArgument(format!(
"synth transform: std {std} too large for mean {mean} — a [0,1] variable \
with this mean cannot exceed the Bernoulli variance {cap:.5} (std < {:.5})",
cap.sqrt()
)));
}
let nodes = quad_nodes();
let mut lo = 1e-6_f64;
let mut hi = 60.0_f64;
let mut a = a_for_mean(0.0, &nodes, mean); let mut b = 0.5 * (lo + hi);
for _ in 0..200 {
b = 0.5 * (lo + hi);
a = a_for_mean(b, &nodes, mean);
let var = variance_at(a, b, &nodes);
if (var - target_var).abs() <= 1e-10 {
break;
}
if var < target_var {
lo = b;
} else {
hi = b;
}
}
Ok(LogitNormal { a, b })
}
pub(crate) fn from_ab(a: f64, b: f64) -> LogitNormal {
LogitNormal { a, b }
}
pub(crate) fn ab(&self) -> (f64, f64) {
(self.a, self.b)
}
pub(crate) fn apply(&self, z: f64) -> f64 {
logistic(self.a + self.b * z)
}
pub(crate) fn lerp(&self, other: &LogitNormal, t: f64) -> LogitNormal {
let t = t.clamp(0.0, 1.0);
LogitNormal {
a: self.a + (other.a - self.a) * t,
b: self.b + (other.b - self.b) * t,
}
}
}
pub(crate) fn quad_nodes() -> Vec<f64> {
let snorm = StatrsNormal::new(0.0, 1.0).expect("standard normal");
(0..QUAD_NODES)
.map(|i| snorm.inverse_cdf((i as f64 + 0.5) / QUAD_NODES as f64))
.collect()
}
fn mean_at(a: f64, b: f64, nodes: &[f64]) -> f64 {
nodes.iter().map(|&z| logistic(a + b * z)).sum::<f64>() / nodes.len() as f64
}
fn variance_at(a: f64, b: f64, nodes: &[f64]) -> f64 {
let n = nodes.len() as f64;
let mut s = 0.0;
let mut s2 = 0.0;
for &z in nodes {
let x = logistic(a + b * z);
s += x;
s2 += x * x;
}
let m = s / n;
(s2 / n - m * m).max(0.0)
}
fn a_for_mean(b: f64, nodes: &[f64], target: f64) -> f64 {
let mut lo = -40.0_f64;
let mut hi = 40.0_f64;
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
let m = mean_at(mid, b, nodes);
if (m - target).abs() <= 1e-12 {
return mid;
}
if m < target {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
#[cfg(test)]
mod tests {
use super::*;
fn realized(t: &LogitNormal) -> (f64, f64) {
let nodes = quad_nodes();
(mean_at(t.a, t.b, &nodes), variance_at(t.a, t.b, &nodes))
}
#[test]
fn hits_target_moments_across_the_feasible_range() {
for &(m, s) in &[
(0.22, 0.03),
(0.70, 0.12),
(0.30, 0.10),
(0.05, 0.02),
(0.95, 0.02),
(0.5, 0.28),
] {
let t = LogitNormal::match_moments(m, s).unwrap();
let (rm, rv) = realized(&t);
assert!((rm - m).abs() < 1e-4, "mean {rm} vs {m}");
assert!((rv.sqrt() - s).abs() < 1e-4, "std {} vs {s}", rv.sqrt());
}
}
#[test]
fn output_is_strictly_in_bounds() {
let t = LogitNormal::match_moments(0.5, 0.28).unwrap();
for z in [-8.0, -3.0, 0.0, 3.0, 8.0] {
let x = t.apply(z);
assert!(x > 0.0 && x < 1.0, "x={x} not strictly in (0,1) at z={z}");
}
for z in [40.0, -40.0, 1e6, -1e6] {
let x = t.apply(z);
assert!((0.0..=1.0).contains(&x), "x={x} left [0,1] at z={z}");
}
}
#[test]
fn rejects_infeasible_and_bad_targets() {
assert!(LogitNormal::match_moments(0.5, 0.5).is_err());
assert!(LogitNormal::match_moments(0.1, 0.31).is_err()); assert!(LogitNormal::match_moments(0.0, 0.1).is_err());
assert!(LogitNormal::match_moments(1.0, 0.1).is_err());
assert!(LogitNormal::match_moments(0.5, 0.0).is_err());
}
#[test]
fn logistic_is_overflow_safe() {
assert_eq!(logistic(1000.0), 1.0);
assert_eq!(logistic(-1000.0), 0.0);
assert!((logistic(0.0) - 0.5).abs() < 1e-15);
}
}