Skip to main content

qualia_core_db/solvers/learning/experiment/
bandit.rs

1//! Multi-armed bandits (Practical Statistics ch 3) — sequential experimentation
2//! that *adapts*: instead of a fixed-split A/B test, allocate more trials to the
3//! arms that look better, trading exploration against exploitation. Three classic
4//! policies: ε-greedy, UCB1, and Thompson sampling (Beta-Bernoulli). Kernel-class
5//! `Divergent` (the sampling/branch logic).
6
7/// Allocation policy.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum Policy {
10    /// With probability ε pick a random arm, else the empirically best.
11    EpsilonGreedy(f64),
12    /// Upper Confidence Bound (UCB1): optimism under uncertainty.
13    Ucb1,
14    /// Thompson sampling with a Beta-Bernoulli posterior per arm.
15    ThompsonBernoulli,
16}
17
18/// A bandit over `k` arms.
19#[derive(Debug, Clone)]
20pub struct Bandit {
21    policy: Policy,
22    counts: Vec<u64>,
23    values: Vec<f64>, // running mean reward
24    alpha: Vec<f64>,  // Beta posterior (Thompson)
25    beta: Vec<f64>,
26    t: u64,
27    rng: Lcg,
28}
29
30#[derive(Debug, Clone)]
31struct Lcg(u64);
32impl Lcg {
33    fn unit(&mut self) -> f64 {
34        self.0 = self
35            .0
36            .wrapping_mul(6364136223846793005)
37            .wrapping_add(1442695040888963407);
38        ((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
39    }
40    fn below(&mut self, b: usize) -> usize {
41        (self.unit() * b as f64) as usize % b.max(1)
42    }
43    fn gaussian(&mut self) -> f64 {
44        let u1 = self.unit().max(1e-12);
45        let u2 = self.unit();
46        (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
47    }
48    fn gamma(&mut self, a: f64) -> f64 {
49        if a < 1.0 {
50            return self.gamma(a + 1.0) * self.unit().max(1e-12).powf(1.0 / a);
51        }
52        let d = a - 1.0 / 3.0;
53        let c = 1.0 / (9.0 * d).sqrt();
54        loop {
55            let x = self.gaussian();
56            let v = (1.0 + c * x).powi(3);
57            if v <= 0.0 {
58                continue;
59            }
60            let u = self.unit();
61            if u.ln() < 0.5 * x * x + d - d * v + d * v.ln() {
62                return d * v;
63            }
64        }
65    }
66    fn beta(&mut self, a: f64, b: f64) -> f64 {
67        let x = self.gamma(a);
68        let y = self.gamma(b);
69        if x + y > 0.0 {
70            x / (x + y)
71        } else {
72            0.5
73        }
74    }
75}
76
77impl Bandit {
78    pub fn new(k: usize, policy: Policy, seed: u64) -> Self {
79        Self {
80            policy,
81            counts: vec![0; k],
82            values: vec![0.0; k],
83            alpha: vec![1.0; k], // Beta(1,1) uniform prior
84            beta: vec![1.0; k],
85            t: 0,
86            rng: Lcg(seed ^ 0x9E3779B97F4A7C15),
87        }
88    }
89
90    /// Choose the next arm to pull.
91    pub fn select(&mut self) -> usize {
92        let k = self.counts.len();
93        match self.policy {
94            Policy::EpsilonGreedy(eps) => {
95                // Try each untried arm first, then ε-greedy.
96                if let Some(u) = (0..k).find(|&i| self.counts[i] == 0) {
97                    return u;
98                }
99                if self.rng.unit() < eps {
100                    self.rng.below(k)
101                } else {
102                    argmax(&self.values)
103                }
104            }
105            Policy::Ucb1 => {
106                if let Some(u) = (0..k).find(|&i| self.counts[i] == 0) {
107                    return u;
108                }
109                let t = self.t.max(1) as f64;
110                let mut best = 0;
111                let mut best_v = f64::NEG_INFINITY;
112                for i in 0..k {
113                    let bonus = (2.0 * t.ln() / self.counts[i] as f64).sqrt();
114                    let v = self.values[i] + bonus;
115                    if v > best_v {
116                        best_v = v;
117                        best = i;
118                    }
119                }
120                best
121            }
122            Policy::ThompsonBernoulli => {
123                let mut best = 0;
124                let mut best_s = f64::NEG_INFINITY;
125                for i in 0..k {
126                    let s = self.rng.beta(self.alpha[i], self.beta[i]);
127                    if s > best_s {
128                        best_s = s;
129                        best = i;
130                    }
131                }
132                best
133            }
134        }
135    }
136
137    /// Record a `reward ∈ [0,1]` for `arm` (1 = success / 0 = failure for Bernoulli).
138    pub fn update(&mut self, arm: usize, reward: f64) {
139        self.t += 1;
140        self.counts[arm] += 1;
141        let n = self.counts[arm] as f64;
142        self.values[arm] += (reward - self.values[arm]) / n; // running mean
143        self.alpha[arm] += reward.clamp(0.0, 1.0);
144        self.beta[arm] += 1.0 - reward.clamp(0.0, 1.0);
145    }
146
147    pub fn counts(&self) -> &[u64] {
148        &self.counts
149    }
150    pub fn values(&self) -> &[f64] {
151        &self.values
152    }
153    /// The arm with the most pulls — the bandit's current best guess.
154    pub fn best_arm(&self) -> usize {
155        argmax_u64(&self.counts)
156    }
157}
158
159fn argmax(v: &[f64]) -> usize {
160    let mut best = 0;
161    for i in 1..v.len() {
162        if v[i] > v[best] {
163            best = i;
164        }
165    }
166    best
167}
168fn argmax_u64(v: &[u64]) -> usize {
169    let mut best = 0;
170    for i in 1..v.len() {
171        if v[i] > v[best] {
172            best = i;
173        }
174    }
175    best
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    /// Deterministic Bernoulli reward source.
183    struct Arms {
184        p: Vec<f64>,
185        rng: Lcg,
186    }
187    impl Arms {
188        fn pull(&mut self, arm: usize) -> f64 {
189            if self.rng.unit() < self.p[arm] {
190                1.0
191            } else {
192                0.0
193            }
194        }
195    }
196
197    fn run(policy: Policy) -> (Bandit, usize) {
198        let mut bandit = Bandit::new(3, policy, 1);
199        let mut arms = Arms {
200            p: vec![0.2, 0.5, 0.8],
201            rng: Lcg(42),
202        };
203        for _ in 0..3000 {
204            let a = bandit.select();
205            let r = arms.pull(a);
206            bandit.update(a, r);
207        }
208        let best = bandit.best_arm();
209        (bandit, best)
210    }
211
212    #[test]
213    fn thompson_converges_to_the_best_arm() {
214        let (b, best) = run(Policy::ThompsonBernoulli);
215        assert_eq!(
216            best,
217            2,
218            "should mostly pull the 0.8 arm; counts {:?}",
219            b.counts()
220        );
221        assert!(
222            b.counts()[2] > b.counts()[0],
223            "best arm pulled more than the worst"
224        );
225    }
226
227    #[test]
228    fn ucb1_converges_to_the_best_arm() {
229        let (b, best) = run(Policy::Ucb1);
230        assert_eq!(best, 2, "counts {:?}", b.counts());
231    }
232
233    #[test]
234    fn epsilon_greedy_favours_the_best_arm() {
235        let (b, _) = run(Policy::EpsilonGreedy(0.1));
236        // The best arm gets the lion's share of the non-exploration pulls.
237        assert!(
238            b.counts()[2] > b.counts()[0] + b.counts()[1],
239            "counts {:?}",
240            b.counts()
241        );
242        // Its estimated value is near the true 0.8.
243        assert!((b.values()[2] - 0.8).abs() < 0.1, "value {}", b.values()[2]);
244    }
245}