Skip to main content

rill_ml/bandit/
thompson.rs

1//! Thompson Sampling bandit algorithm (Bernoulli rewards).
2//!
3//! Thompson Sampling maintains a Beta distribution for each arm and selects
4//! the arm with the highest sampled value. For Bernoulli rewards (0 or 1),
5//! each arm's posterior is `Beta(alpha, beta)` where `alpha = successes + prior`
6//! and `beta = failures + prior`.
7//!
8//! This implementation uses the Marsaglia-Tsang method for Gamma distribution
9//! sampling, combined via `Beta(a, b) = Gamma(a) / (Gamma(a) + Gamma(b))`.
10//! No external statistics crate is required.
11//!
12//! ## Complexity
13//!
14//! - `select`: `O(arm_count)` — samples one Beta value per arm.
15//! - `update`: `O(1)`.
16//! - Space: `O(arm_count)`.
17//!
18//! ## Reference
19//!
20//! Russo, Van Roy, Kazerouni, Osband, Wen. "A Tutorial on Thompson Sampling."
21//! Foundations and Trends in Machine Learning, 2018.
22
23use crate::bandit::stats::ArmStats;
24use crate::bandit::{
25    Bandit, checked_finite_add, checked_increment, validate_arm, validate_reward_01,
26    validate_sample_count,
27};
28use crate::error::RillError;
29#[cfg(feature = "serde")]
30use crate::persistence::ValidateState;
31use rand::Rng;
32
33/// Configuration for [`ThompsonSampling`].
34///
35/// # Examples
36///
37/// ```
38/// use rill_ml::bandit::ThompsonConfig;
39///
40/// let mut config = ThompsonConfig::default();
41/// config.alpha_prior = 1.0;
42/// config.beta_prior = 1.0;
43/// assert!((config.alpha_prior - 1.0).abs() < 1e-12);
44/// ```
45#[derive(Debug, Clone, PartialEq)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47#[non_exhaustive]
48pub struct ThompsonConfig {
49    /// Prior alpha (success) parameter for the Beta distribution.
50    ///
51    /// Must be finite and positive. The default `1.0` gives a uniform prior
52    /// `Beta(1, 1)`.
53    pub alpha_prior: f64,
54
55    /// Prior beta (failure) parameter for the Beta distribution.
56    ///
57    /// Must be finite and positive. The default `1.0` gives a uniform prior
58    /// `Beta(1, 1)`.
59    pub beta_prior: f64,
60}
61
62impl Default for ThompsonConfig {
63    fn default() -> Self {
64        Self {
65            alpha_prior: 1.0,
66            beta_prior: 1.0,
67        }
68    }
69}
70
71impl ThompsonConfig {
72    /// Validate the configuration without constructing a bandit.
73    pub fn validate(&self) -> Result<(), RillError> {
74        if !self.alpha_prior.is_finite() || self.alpha_prior <= 0.0 {
75            return Err(RillError::InvalidParameter {
76                name: "alpha_prior",
77                value: self.alpha_prior,
78            });
79        }
80        if !self.beta_prior.is_finite() || self.beta_prior <= 0.0 {
81            return Err(RillError::InvalidParameter {
82                name: "beta_prior",
83                value: self.beta_prior,
84            });
85        }
86        Ok(())
87    }
88}
89
90/// Thompson Sampling multi-armed bandit (Bernoulli rewards).
91///
92/// Maintains a Beta posterior for each arm. On `select`, samples from each
93/// arm's posterior and returns the arm with the highest sample. On `update`,
94/// applies a soft update to the arm's alpha (success) and beta (failure)
95/// parameters.
96///
97/// Rewards must be in `[0, 1]`. The update is `alpha += reward` and
98/// `beta += 1 - reward`; for strict Bernoulli rewards this is the standard
99/// success/failure update, while fractional rewards produce a weighted update.
100///
101/// # Examples
102///
103/// ```
104/// use rill_ml::bandit::{Bandit, ThompsonSampling, ThompsonConfig};
105/// use rand::SeedableRng;
106/// use rand_chacha::ChaCha8Rng;
107///
108/// let mut rng = ChaCha8Rng::seed_from_u64(0);
109/// let mut bandit = ThompsonSampling::new(3, ThompsonConfig::default()).unwrap();
110///
111/// let arm = bandit.select(&mut rng).unwrap();
112/// bandit.update(arm, 1.0).unwrap();
113/// assert_eq!(bandit.samples_seen(), 1);
114/// ```
115#[derive(Debug, Clone)]
116#[cfg_attr(feature = "serde", derive(serde::Serialize))]
117pub struct ThompsonSampling {
118    arm_count: usize,
119    config: ThompsonConfig,
120    /// Per-arm alpha (successes + prior).
121    alphas: Vec<f64>,
122    /// Per-arm beta (failures + prior).
123    betas: Vec<f64>,
124    /// Per-arm pull counts.
125    pulls: Vec<u64>,
126    /// Per-arm total rewards (for diagnostics).
127    total_rewards: Vec<f64>,
128    /// Total number of updates.
129    samples_seen: u64,
130}
131
132impl ThompsonSampling {
133    /// Create a new Thompson Sampling bandit.
134    ///
135    /// # Errors
136    ///
137    /// Returns `RillError::InvalidArmCount` if `arm_count` is zero.
138    /// Returns `RillError::InvalidParameter` if priors are not finite and positive.
139    pub fn new(arm_count: usize, config: ThompsonConfig) -> Result<Self, RillError> {
140        if arm_count == 0 {
141            return Err(RillError::InvalidArmCount(arm_count));
142        }
143        config.validate()?;
144
145        let alpha_prior = config.alpha_prior;
146        let beta_prior = config.beta_prior;
147        Ok(Self {
148            arm_count,
149            config,
150            alphas: vec![alpha_prior; arm_count],
151            betas: vec![beta_prior; arm_count],
152            pulls: vec![0; arm_count],
153            total_rewards: vec![0.0; arm_count],
154            samples_seen: 0,
155        })
156    }
157
158    /// Per-arm alpha parameters (diagnostic).
159    pub fn alphas(&self) -> &[f64] {
160        &self.alphas
161    }
162
163    /// Per-arm beta parameters (diagnostic).
164    pub fn betas(&self) -> &[f64] {
165        &self.betas
166    }
167
168    /// Per-arm pull counts (diagnostic).
169    pub fn pulls(&self) -> &[u64] {
170        &self.pulls
171    }
172
173    /// Validate all persisted state invariants.
174    ///
175    /// This is also run automatically during deserialization.
176    pub fn validate(&self) -> Result<(), RillError> {
177        if self.arm_count == 0 {
178            return Err(RillError::InvalidArmCount(self.arm_count));
179        }
180        self.config.validate()?;
181        if self.alphas.len() != self.arm_count
182            || self.betas.len() != self.arm_count
183            || self.pulls.len() != self.arm_count
184            || self.total_rewards.len() != self.arm_count
185        {
186            return Err(RillError::InvalidState(
187                "arm_count does not match per-arm state lengths".to_owned(),
188            ));
189        }
190        validate_sample_count(&self.pulls, self.samples_seen)?;
191
192        for arm in 0..self.arm_count {
193            let pulls = self.pulls[arm] as f64;
194            let total = self.total_rewards[arm];
195            let alpha = self.alphas[arm];
196            let beta = self.betas[arm];
197            if !total.is_finite() || total < 0.0 || total > pulls {
198                return Err(RillError::InvalidState(format!(
199                    "total reward for arm {arm} is inconsistent with [0, 1] rewards"
200                )));
201            }
202            let expected_alpha = self.config.alpha_prior + total;
203            let expected_beta = self.config.beta_prior + pulls - total;
204            let alpha_tolerance = 1e-9 * expected_alpha.abs().max(1.0);
205            let beta_tolerance = 1e-9 * expected_beta.abs().max(1.0);
206            if !alpha.is_finite()
207                || !beta.is_finite()
208                || (alpha - expected_alpha).abs() > alpha_tolerance
209                || (beta - expected_beta).abs() > beta_tolerance
210            {
211                return Err(RillError::InvalidState(format!(
212                    "posterior parameters for arm {arm} are inconsistent with observations"
213                )));
214            }
215        }
216        Ok(())
217    }
218
219    /// Sample from a Beta(alpha, beta) distribution using the
220    /// Gamma ratio method.
221    ///
222    /// Beta(a, b) = Gamma(a) / (Gamma(a) + Gamma(b))
223    fn sample_beta(rng: &mut impl Rng, alpha: f64, beta: f64) -> f64 {
224        let x = Self::sample_gamma(rng, alpha);
225        let y = Self::sample_gamma(rng, beta);
226        // Handle degenerate case where both samples are 0.
227        let denom = x + y;
228        if denom <= 0.0 {
229            // Fall back to 0.5 for the degenerate case.
230            0.5
231        } else {
232            x / denom
233        }
234    }
235
236    /// Sample from a Gamma(shape, scale=1) distribution using the
237    /// Marsaglia-Tsang method.
238    ///
239    /// For shape >= 1, uses the standard acceptance-rejection method.
240    /// For shape < 1, uses the boosting trick: sample Gamma(shape+1) then
241    /// multiply by U^(1/shape).
242    fn sample_gamma(rng: &mut impl Rng, shape: f64) -> f64 {
243        if shape < 1.0 {
244            // Boosting: Gamma(shape) = Gamma(shape + 1) * U^(1/shape)
245            let u: f64 = rng.gen_range(1e-10..1.0);
246            let g = Self::sample_gamma(rng, shape + 1.0);
247            return g * u.powf(1.0 / shape);
248        }
249
250        // Marsaglia-Tsang for shape >= 1.
251        let d = shape - 1.0 / 3.0;
252        let c = 1.0 / (9.0 * d).sqrt();
253
254        loop {
255            // Sample from Normal(0, 1) using Box-Muller.
256            let (x, _unused) = Self::box_muller(rng);
257            let v = (1.0 + c * x).powi(3);
258            if v <= 0.0 {
259                continue;
260            }
261            let u: f64 = rng.gen_range(0.0..1.0);
262            if u < 1.0 - 0.0331 * x.powi(4) {
263                return d * v;
264            }
265            if u.ln() < 0.5 * x * x + d * (1.0 - v + v.ln()) {
266                return d * v;
267            }
268        }
269    }
270
271    /// Generate a pair of standard normal random variables using the
272    /// Box-Muller transform. Returns (z0, z1).
273    fn box_muller(rng: &mut impl Rng) -> (f64, f64) {
274        let u1: f64 = rng.gen_range(1e-10..1.0);
275        let u2: f64 = rng.gen_range(0.0..1.0);
276        let mag = (-2.0 * u1.ln()).sqrt();
277        let z0 = mag * (2.0 * std::f64::consts::PI * u2).cos();
278        let z1 = mag * (2.0 * std::f64::consts::PI * u2).sin();
279        (z0, z1)
280    }
281}
282
283impl Bandit for ThompsonSampling {
284    fn arm_count(&self) -> usize {
285        self.arm_count
286    }
287
288    fn samples_seen(&self) -> u64 {
289        self.samples_seen
290    }
291
292    fn select(&self, rng: &mut impl Rng) -> Result<usize, RillError> {
293        let mut best_arm = 0usize;
294        let mut best_sample = f64::NEG_INFINITY;
295
296        for arm in 0..self.arm_count {
297            let sample = Self::sample_beta(rng, self.alphas[arm], self.betas[arm]);
298            if sample > best_sample {
299                best_sample = sample;
300                best_arm = arm;
301            }
302        }
303
304        Ok(best_arm)
305    }
306
307    fn update(&mut self, arm: usize, reward: f64) -> Result<(), RillError> {
308        validate_arm(self.arm_count, arm)?;
309        validate_reward_01(reward)?;
310
311        // Soft update: alpha += reward, beta += (1 - reward).
312        // For strict Bernoulli (0 or 1), this is equivalent to the standard
313        // success/failure counting. For continuous rewards in [0, 1], this
314        // provides a weighted update.
315        let next_alpha = checked_finite_add(self.alphas[arm], reward, "alpha")?;
316        let next_beta = checked_finite_add(self.betas[arm], 1.0 - reward, "beta")?;
317        let next_pulls = checked_increment(self.pulls[arm], "pulls")?;
318        let next_total = checked_finite_add(self.total_rewards[arm], reward, "total_rewards")?;
319        let next_samples = checked_increment(self.samples_seen, "samples_seen")?;
320
321        self.alphas[arm] = next_alpha;
322        self.betas[arm] = next_beta;
323        self.pulls[arm] = next_pulls;
324        self.total_rewards[arm] = next_total;
325        self.samples_seen = next_samples;
326        Ok(())
327    }
328
329    fn reset(&mut self) {
330        for a in &mut self.alphas {
331            *a = self.config.alpha_prior;
332        }
333        for b in &mut self.betas {
334            *b = self.config.beta_prior;
335        }
336        for p in &mut self.pulls {
337            *p = 0;
338        }
339        for r in &mut self.total_rewards {
340            *r = 0.0;
341        }
342        self.samples_seen = 0;
343    }
344
345    fn arm_stats(&self, arm: usize) -> Result<ArmStats, RillError> {
346        validate_arm(self.arm_count, arm)?;
347        ArmStats::new(self.pulls[arm], self.total_rewards[arm])
348    }
349}
350
351#[cfg(feature = "serde")]
352#[derive(serde::Deserialize)]
353struct ThompsonSamplingState {
354    arm_count: usize,
355    config: ThompsonConfig,
356    alphas: Vec<f64>,
357    betas: Vec<f64>,
358    pulls: Vec<u64>,
359    total_rewards: Vec<f64>,
360    samples_seen: u64,
361}
362
363#[cfg(feature = "serde")]
364impl<'de> serde::Deserialize<'de> for ThompsonSampling {
365    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
366    where
367        D: serde::Deserializer<'de>,
368    {
369        let state = ThompsonSamplingState::deserialize(deserializer)?;
370        let bandit = Self {
371            arm_count: state.arm_count,
372            config: state.config,
373            alphas: state.alphas,
374            betas: state.betas,
375            pulls: state.pulls,
376            total_rewards: state.total_rewards,
377            samples_seen: state.samples_seen,
378        };
379        bandit.validate().map_err(serde::de::Error::custom)?;
380        Ok(bandit)
381    }
382}
383
384#[cfg(feature = "serde")]
385impl ValidateState for ThompsonSampling {
386    fn validate_state(&self) -> Result<(), RillError> {
387        ThompsonSampling::validate(self)
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use rand::SeedableRng;
395    use rand_chacha::ChaCha8Rng;
396
397    fn make_bandit() -> ThompsonSampling {
398        ThompsonSampling::new(3, ThompsonConfig::default()).unwrap()
399    }
400
401    #[test]
402    fn rejects_zero_arm_count() {
403        let result = ThompsonSampling::new(0, ThompsonConfig::default());
404        assert!(matches!(result, Err(RillError::InvalidArmCount(0))));
405    }
406
407    #[test]
408    fn rejects_invalid_priors() {
409        for &bad in &[0.0, -1.0, f64::NAN, f64::INFINITY] {
410            let result = ThompsonSampling::new(
411                3,
412                ThompsonConfig {
413                    alpha_prior: bad,
414                    beta_prior: 1.0,
415                },
416            );
417            assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
418
419            let result = ThompsonSampling::new(
420                3,
421                ThompsonConfig {
422                    alpha_prior: 1.0,
423                    beta_prior: bad,
424                },
425            );
426            assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
427        }
428    }
429
430    #[test]
431    fn initial_state() {
432        let b = make_bandit();
433        assert_eq!(b.arm_count(), 3);
434        assert_eq!(b.samples_seen(), 0);
435        // Alpha and beta should be initialized to priors.
436        for &a in b.alphas() {
437            assert!((a - 1.0).abs() < 1e-12);
438        }
439        for &be in b.betas() {
440            assert!((be - 1.0).abs() < 1e-12);
441        }
442    }
443
444    #[test]
445    fn select_returns_valid_arm() {
446        let b = make_bandit();
447        let mut rng = ChaCha8Rng::seed_from_u64(42);
448        let arm = b.select(&mut rng).unwrap();
449        assert!(arm < 3);
450    }
451
452    #[test]
453    fn update_with_success_increases_alpha() {
454        let mut b = make_bandit();
455        b.update(0, 1.0).unwrap();
456        // alpha += 1.0, beta += 0.0
457        assert!((b.alphas()[0] - 2.0).abs() < 1e-12);
458        assert!((b.betas()[0] - 1.0).abs() < 1e-12);
459    }
460
461    #[test]
462    fn update_with_failure_increases_beta() {
463        let mut b = make_bandit();
464        b.update(0, 0.0).unwrap();
465        // alpha += 0.0, beta += 1.0
466        assert!((b.alphas()[0] - 1.0).abs() < 1e-12);
467        assert!((b.betas()[0] - 2.0).abs() < 1e-12);
468    }
469
470    #[test]
471    fn update_with_continuous_reward() {
472        let mut b = make_bandit();
473        b.update(0, 0.7).unwrap();
474        // alpha += 0.7, beta += 0.3
475        assert!((b.alphas()[0] - 1.7).abs() < 1e-12);
476        assert!((b.betas()[0] - 1.3).abs() < 1e-12);
477    }
478
479    #[test]
480    fn update_rejects_invalid_arm() {
481        let mut b = make_bandit();
482        assert!(b.update(3, 1.0).is_err());
483    }
484
485    #[test]
486    fn update_rejects_reward_out_of_range() {
487        let mut b = make_bandit();
488        assert!(b.update(0, 1.5).is_err());
489        assert!(b.update(0, -0.1).is_err());
490        assert!(b.update(0, f64::NAN).is_err());
491    }
492
493    #[test]
494    fn reset_clears_state() {
495        let mut b = make_bandit();
496        b.update(0, 1.0).unwrap();
497        b.update(1, 0.0).unwrap();
498        assert_eq!(b.samples_seen(), 2);
499
500        b.reset();
501        assert_eq!(b.samples_seen(), 0);
502        for &a in b.alphas() {
503            assert!((a - 1.0).abs() < 1e-12);
504        }
505        for &be in b.betas() {
506            assert!((be - 1.0).abs() < 1e-12);
507        }
508        for &p in b.pulls() {
509            assert_eq!(p, 0);
510        }
511    }
512
513    #[test]
514    fn arm_stats_after_updates() {
515        let mut b = make_bandit();
516        b.update(0, 1.0).unwrap();
517        b.update(0, 0.0).unwrap();
518        b.update(0, 1.0).unwrap();
519        let stats = b.arm_stats(0).unwrap();
520        assert_eq!(stats.pulls, 3);
521        assert!((stats.total_reward - 2.0).abs() < 1e-12);
522    }
523
524    #[test]
525    fn arm_stats_rejects_invalid_arm() {
526        let b = make_bandit();
527        assert!(b.arm_stats(5).is_err());
528    }
529
530    #[test]
531    fn finds_best_arm_in_simulation() {
532        let mut b = make_bandit();
533        let mut rng = ChaCha8Rng::seed_from_u64(42);
534
535        // Simulate Bernoulli rewards:
536        // arm 0: p=0.8, arm 1: p=0.2, arm 2: p=0.5
537        for _ in 0..1000 {
538            let arm = b.select(&mut rng).unwrap();
539            let p = match arm {
540                0 => 0.8,
541                1 => 0.2,
542                _ => 0.5,
543            };
544            let reward = if rng.gen_range(0.0..1.0) < p {
545                1.0
546            } else {
547                0.0
548            };
549            b.update(arm, reward).unwrap();
550        }
551
552        // Arm 0 should be pulled most often.
553        let stats0 = b.arm_stats(0).unwrap();
554        let stats1 = b.arm_stats(1).unwrap();
555        let stats2 = b.arm_stats(2).unwrap();
556        assert!(stats0.pulls > stats1.pulls);
557        assert!(stats0.pulls > stats2.pulls);
558        // Arm 0's mean reward should be close to 0.8.
559        assert!(stats0.mean_reward > 0.6);
560    }
561
562    #[test]
563    fn sample_beta_returns_value_in_unit_interval() {
564        let mut rng = ChaCha8Rng::seed_from_u64(99);
565        for _ in 0..1000 {
566            let v = ThompsonSampling::sample_beta(&mut rng, 2.0, 5.0);
567            assert!((0.0..=1.0).contains(&v), "Beta sample {v} out of [0, 1]");
568        }
569    }
570
571    #[test]
572    fn sample_gamma_returns_positive_value() {
573        let mut rng = ChaCha8Rng::seed_from_u64(7);
574        for shape in &[0.5, 1.0, 2.0, 5.0, 10.0] {
575            for _ in 0..100 {
576                let v = ThompsonSampling::sample_gamma(&mut rng, *shape);
577                assert!(v > 0.0, "Gamma sample {v} not positive for shape {shape}");
578            }
579        }
580    }
581
582    #[test]
583    fn sample_gamma_mean_converges() {
584        // Gamma(shape, 1) has mean = shape.
585        let mut rng = ChaCha8Rng::seed_from_u64(42);
586        let shape = 5.0;
587        let n = 10000;
588        let mut sum = 0.0;
589        for _ in 0..n {
590            sum += ThompsonSampling::sample_gamma(&mut rng, shape);
591        }
592        let mean = sum / n as f64;
593        // Allow 10% tolerance.
594        assert!(
595            (mean - shape).abs() / shape < 0.1,
596            "Gamma mean {mean} too far from {shape}"
597        );
598    }
599
600    #[test]
601    fn sample_beta_mean_converges() {
602        // Beta(2, 5) has mean = 2 / (2 + 5) ≈ 0.2857.
603        let mut rng = ChaCha8Rng::seed_from_u64(42);
604        let alpha = 2.0;
605        let beta = 5.0;
606        let n = 10000;
607        let mut sum = 0.0;
608        for _ in 0..n {
609            sum += ThompsonSampling::sample_beta(&mut rng, alpha, beta);
610        }
611        let mean = sum / n as f64;
612        let expected = alpha / (alpha + beta);
613        // Allow 10% tolerance.
614        assert!(
615            (mean - expected).abs() / expected < 0.1,
616            "Beta mean {mean} too far from {expected}"
617        );
618    }
619
620    #[cfg(feature = "serde")]
621    #[test]
622    fn serde_roundtrip() {
623        let mut b = ThompsonSampling::new(
624            3,
625            ThompsonConfig {
626                alpha_prior: 2.0,
627                beta_prior: 3.0,
628            },
629        )
630        .unwrap();
631        b.update(0, 1.0).unwrap();
632        b.update(1, 0.0).unwrap();
633
634        let json = serde_json::to_string(&b).unwrap();
635        let restored: ThompsonSampling = serde_json::from_str(&json).unwrap();
636        assert_eq!(restored.arm_count(), b.arm_count());
637        assert_eq!(restored.samples_seen(), b.samples_seen());
638        assert_eq!(restored.alphas(), b.alphas());
639        assert_eq!(restored.betas(), b.betas());
640    }
641
642    #[cfg(feature = "serde")]
643    #[test]
644    fn serde_rejects_malformed_state() {
645        let json = r#"{
646            "arm_count": 2,
647            "config": {"alpha_prior": 1.0, "beta_prior": 1.0},
648            "alphas": [2.0],
649            "betas": [1.0],
650            "pulls": [1],
651            "total_rewards": [1.0],
652            "samples_seen": 1
653        }"#;
654        assert!(serde_json::from_str::<ThompsonSampling>(json).is_err());
655    }
656}