qualia_core_db/solvers/learning/experiment/
bandit.rs1#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum Policy {
10 EpsilonGreedy(f64),
12 Ucb1,
14 ThompsonBernoulli,
16}
17
18#[derive(Debug, Clone)]
20pub struct Bandit {
21 policy: Policy,
22 counts: Vec<u64>,
23 values: Vec<f64>, alpha: Vec<f64>, 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: vec![1.0; k],
85 t: 0,
86 rng: Lcg(seed ^ 0x9E3779B97F4A7C15),
87 }
88 }
89
90 pub fn select(&mut self) -> usize {
92 let k = self.counts.len();
93 match self.policy {
94 Policy::EpsilonGreedy(eps) => {
95 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 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; 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 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 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 assert!(
238 b.counts()[2] > b.counts()[0] + b.counts()[1],
239 "counts {:?}",
240 b.counts()
241 );
242 assert!((b.values()[2] - 0.8).abs() < 0.1, "value {}", b.values()[2]);
244 }
245}