use crate::models::laplace::dist::Gaussian;
use crate::models::laplace::leaf::Leaf;
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BetaLeaf {
alpha: f64,
mu_ema: f64,
initialized: bool,
n: usize,
m2: f64,
m1: f64,
}
impl BetaLeaf {
pub fn new(alpha: f64) -> Self {
Self {
alpha: alpha.clamp(1e-3, 1.0 - 1e-3),
mu_ema: 0.5,
initialized: false,
n: 0,
m2: 0.0,
m1: 0.0,
}
}
fn variance(&self) -> f64 {
if self.n < 2 {
return 0.01;
}
(self.m2 / (self.n as f64 - 1.0)).max(1e-9)
}
}
impl Leaf for BetaLeaf {
fn name(&self) -> &'static str {
"beta"
}
fn predict(&self, horizon: usize) -> Vec<Gaussian> {
let mu = self.mu_ema.clamp(1e-6, 1.0 - 1e-6);
let var_cap = mu * (1.0 - mu);
let var = self.variance().min(var_cap * 0.99);
(1..=horizon)
.map(|h| Gaussian::new(mu, (var * h as f64).sqrt()))
.collect()
}
fn observe(&mut self, y: f64) {
let y = y.clamp(0.0, 1.0);
if !self.initialized {
self.mu_ema = y;
self.initialized = true;
} else {
self.mu_ema = self.alpha * y + (1.0 - self.alpha) * self.mu_ema;
}
self.n += 1;
let delta = y - self.m1;
self.m1 += delta / self.n as f64;
self.m2 += delta * (y - self.m1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn locks_on_to_bounded_rate() {
let mut leaf = BetaLeaf::new(0.05);
for i in 0..500 {
let y = 0.3 + ((i as f64 * 0.1).sin() * 0.1);
leaf.observe(y);
}
let preds = leaf.predict(3);
assert!(
(preds[0].mean - 0.3).abs() < 0.05,
"expected ~0.3, got {}",
preds[0].mean
);
assert!(preds[0].mean >= 0.0 && preds[0].mean <= 1.0);
assert!(preds[0].std > 0.0);
}
#[test]
fn observations_outside_unit_interval_are_clamped() {
let mut leaf = BetaLeaf::new(0.1);
leaf.observe(-0.5);
leaf.observe(1.5);
leaf.observe(0.3);
let preds = leaf.predict(3);
for p in preds {
assert!(p.mean.is_finite() && (0.0..=1.0).contains(&p.mean));
assert!(p.std.is_finite() && p.std > 0.0);
}
}
}