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