use crate::models::laplace::dist::{Gaussian, GaussianMixture};
const DEFAULT_SCALES: [f64; 5] = [0.7, 1.0, 1.6, 3.0, 6.0];
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TerminalScaleMixture {
scales: [f64; 5],
scale_alpha: f64,
gamma: f64,
v: f64,
w: [f64; 5],
n_obs: usize,
phi: f64,
prev_r: f64,
}
impl TerminalScaleMixture {
pub fn new() -> Self {
Self::with_params(0.03, 0.02)
}
pub fn with_params(scale_alpha: f64, gamma: f64) -> Self {
let mut w = [1e-6; 5];
let one_idx = DEFAULT_SCALES
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| (**a - 1.0).abs().partial_cmp(&(**b - 1.0).abs()).unwrap())
.map(|(i, _)| i)
.unwrap_or(1);
w[one_idx] = 1.0;
Self {
scales: DEFAULT_SCALES,
scale_alpha,
gamma,
v: 0.0,
w,
n_obs: 0,
phi: 0.0,
prev_r: 0.0,
}
}
pub fn phi(&self) -> f64 {
self.phi
}
pub fn h_step_std_scale(&self, h: usize) -> f64 {
if h == 0 {
return 0.0;
}
let phi = self.phi.clamp(-0.9, 0.9);
let phi2 = phi * phi;
if phi2 < 1e-6 {
return (h as f64).sqrt();
}
let numer = 1.0 - phi2.powi(h as i32);
let denom = 1.0 - phi2;
(numer / denom).max(0.0).sqrt()
}
pub fn observe(&mut self, r: f64) {
if !r.is_finite() {
return;
}
self.n_obs += 1;
let n = self.n_obs as f64;
let a = self.scale_alpha.max(1.0 / n);
self.v = (1.0 - a) * self.v + a * r * r;
if self.n_obs >= 2 && self.v > 1e-12 {
let rho = (r * self.prev_r) / self.v;
let phi_alpha = a; self.phi = ((1.0 - phi_alpha) * self.phi + phi_alpha * rho).clamp(-0.9, 0.9);
}
self.prev_r = r;
let sigma = if self.v.is_finite() && self.v > 0.0 {
self.v.sqrt()
} else {
r.abs().max(1e-8)
};
let z = r / sigma;
let mut dens = [0.0; 5];
let mut total = 0.0;
for i in 0..5 {
let c = self.scales[i];
let d = self.w[i] * (-0.5 * z * z / (c * c)).exp() / c;
dens[i] = d;
total += d;
}
if total > 0.0 && total.is_finite() {
let g = self.gamma.max(1.0 / n);
for i in 0..5 {
self.w[i] = (1.0 - g) * self.w[i] + g * dens[i] / total;
}
}
}
pub fn predict(&self) -> Vec<(f64, f64)> {
let sigma = if self.v.is_finite() && self.v > 0.0 {
self.v.sqrt()
} else {
1e-6
};
(0..5)
.map(|i| (self.w[i], (self.scales[i] * sigma).max(1e-9)))
.collect()
}
pub fn predict_shifted(&self, mean: f64) -> GaussianMixture {
let comps = self.predict();
GaussianMixture::new(
comps
.into_iter()
.map(|(w, sig)| (w, Gaussian::new(mean, sig))),
)
}
pub fn n_obs(&self) -> usize {
self.n_obs
}
pub fn warm_start(&mut self, sigma: f64, seed_n: usize) {
if sigma.is_finite() && sigma > 0.0 {
self.v = sigma * sigma;
self.n_obs = seed_n.max(1);
}
}
}
impl Default for TerminalScaleMixture {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn gauss_ll(y: f64, mu: f64, sigma: f64) -> f64 {
-0.5 * ((y - mu) / sigma).powi(2) - sigma.ln() - 0.5 * (2.0 * std::f64::consts::PI).ln()
}
#[test]
fn concentrates_on_gaussian_residuals() {
let mut t = TerminalScaleMixture::new();
let sigma_true = 1.5;
let mut rs = Vec::new();
for i in 1..=1000 {
let u1 = ((i as f64 * 3.111).sin() * 43758.5453)
.fract()
.abs()
.max(1e-9);
let u2 = ((i as f64 * 5.777).cos() * 12345.6789)
.fract()
.abs()
.max(1e-9);
let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
rs.push(sigma_true * z);
}
for r in &rs {
t.observe(*r);
}
let mix = t.predict_shifted(0.0);
let ll_mix: f64 = rs[800..].iter().map(|y| mix.logpdf(*y)).sum();
let ll_narrow: f64 = rs[800..].iter().map(|y| gauss_ll(*y, 0.0, 0.5)).sum();
assert!(
ll_mix > ll_narrow,
"mixture LL {ll_mix} did not beat narrow-Gaussian LL {ll_narrow}"
);
}
#[test]
fn beats_single_gaussian_on_heavy_tails() {
let mut t = TerminalScaleMixture::new();
let mut rs = Vec::new();
for i in 1..=2000 {
let u1 = ((i as f64 * 3.111).sin() * 43758.5453)
.fract()
.abs()
.max(1e-9);
let u2 = ((i as f64 * 5.777).cos() * 12345.6789)
.fract()
.abs()
.max(1e-9);
let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
let scale = if i % 5 == 0 { 5.0 } else { 1.0 };
rs.push(scale * z);
}
for r in &rs {
t.observe(*r);
}
let mix = t.predict_shifted(0.0);
let var: f64 = rs.iter().map(|r| r * r).sum::<f64>() / rs.len() as f64;
let sigma_mom = var.sqrt();
let ll_mix: f64 = rs[1500..].iter().map(|y| mix.logpdf(*y)).sum();
let ll_gauss: f64 = rs[1500..]
.iter()
.map(|y| gauss_ll(*y, 0.0, sigma_mom))
.sum();
assert!(
ll_mix > ll_gauss,
"scale-mixture LL {ll_mix} did not beat plain Gaussian LL {ll_gauss} on heavy tails"
);
}
#[test]
fn nan_residual_is_ignored() {
let mut t = TerminalScaleMixture::new();
t.observe(1.0);
t.observe(f64::NAN);
t.observe(f64::INFINITY);
t.observe(-1.0);
assert_eq!(t.n_obs(), 2);
}
}