Skip to main content

rill_ml/bandit/
epsilon_greedy.rs

1//! Epsilon-Greedy bandit algorithm.
2//!
3//! The simplest multi-armed bandit strategy: with probability `epsilon`, select
4//! a random arm (exploration); otherwise, select the arm with the highest
5//! observed mean reward (exploitation).
6//!
7//! Epsilon can be fixed or decayed over time using exponential decay, which
8//! reduces exploration as more data is collected.
9//!
10//! ## Complexity
11//!
12//! - `select`: `O(arm_count)` — must scan all arms to find the best.
13//! - `update`: `O(1)`.
14//! - Space: `O(arm_count)`.
15
16use crate::bandit::stats::ArmStats;
17use crate::bandit::{
18    Bandit, checked_finite_add, checked_increment, validate_arm, validate_reward_finite,
19    validate_sample_count,
20};
21use crate::error::RillError;
22#[cfg(feature = "serde")]
23use crate::persistence::ValidateState;
24use rand::Rng;
25
26/// Configuration for [`EpsilonGreedy`].
27///
28/// # Examples
29///
30/// ```
31/// use rill_ml::bandit::EpsilonGreedyConfig;
32///
33/// let mut config = EpsilonGreedyConfig::default();
34/// config.epsilon = 0.1;
35/// config.decay = 0.999;
36/// config.min_epsilon = 0.01;
37/// assert!((config.epsilon - 0.1).abs() < 1e-12);
38/// ```
39#[derive(Debug, Clone, PartialEq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41#[non_exhaustive]
42pub struct EpsilonGreedyConfig {
43    /// Initial exploration probability. Must be in `[0, 1]`.
44    ///
45    /// `0.0` means pure exploitation, `1.0` means pure exploration.
46    pub epsilon: f64,
47
48    /// Exponential decay factor applied to epsilon after each update.
49    ///
50    /// Set to `1.0` for no decay (fixed epsilon). Must be in `(0, 1]`.
51    /// After each `update`, epsilon becomes `max(min_epsilon, epsilon * decay)`.
52    pub decay: f64,
53
54    /// Lower bound for epsilon after decay. Must be in `[0, epsilon]`.
55    pub min_epsilon: f64,
56}
57
58impl Default for EpsilonGreedyConfig {
59    fn default() -> Self {
60        Self {
61            epsilon: 0.1,
62            decay: 1.0,
63            min_epsilon: 0.01,
64        }
65    }
66}
67
68impl EpsilonGreedyConfig {
69    /// Validate the configuration without constructing a bandit.
70    pub fn validate(&self) -> Result<(), RillError> {
71        if !(0.0..=1.0).contains(&self.epsilon) {
72            return Err(RillError::InvalidEpsilon(self.epsilon));
73        }
74        if !(0.0 < self.decay && self.decay <= 1.0) {
75            return Err(RillError::InvalidParameter {
76                name: "decay",
77                value: self.decay,
78            });
79        }
80        if !(0.0..=self.epsilon).contains(&self.min_epsilon) {
81            return Err(RillError::InvalidParameter {
82                name: "min_epsilon",
83                value: self.min_epsilon,
84            });
85        }
86        Ok(())
87    }
88}
89
90/// Epsilon-Greedy multi-armed bandit.
91///
92/// With probability `epsilon`, selects a random arm; otherwise selects the
93/// arm with the highest observed mean reward. Supports optional epsilon decay.
94///
95/// # Examples
96///
97/// ```
98/// use rill_ml::bandit::{Bandit, EpsilonGreedy, EpsilonGreedyConfig};
99/// use rand::SeedableRng;
100/// use rand_chacha::ChaCha8Rng;
101///
102/// let mut rng = ChaCha8Rng::seed_from_u64(0);
103/// let mut bandit = EpsilonGreedy::new(3, EpsilonGreedyConfig::default()).unwrap();
104///
105/// let arm = bandit.select(&mut rng).unwrap();
106/// bandit.update(arm, 1.0).unwrap();
107/// assert_eq!(bandit.samples_seen(), 1);
108/// ```
109#[derive(Debug, Clone)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize))]
111pub struct EpsilonGreedy {
112    arm_count: usize,
113    config: EpsilonGreedyConfig,
114    /// Per-arm pull counts.
115    pulls: Vec<u64>,
116    /// Per-arm total rewards.
117    total_rewards: Vec<f64>,
118    /// Total number of updates.
119    samples_seen: u64,
120    /// Current epsilon (may differ from config.epsilon after decay).
121    current_epsilon: f64,
122}
123
124impl EpsilonGreedy {
125    /// Create a new epsilon-greedy bandit.
126    ///
127    /// # Errors
128    ///
129    /// Returns `RillError::InvalidArmCount` if `arm_count` is zero.
130    /// Returns `RillError::InvalidEpsilon` if epsilon is not in `[0, 1]`.
131    /// Returns `RillError::InvalidParameter` if decay is not in `(0, 1]`.
132    pub fn new(arm_count: usize, config: EpsilonGreedyConfig) -> Result<Self, RillError> {
133        if arm_count == 0 {
134            return Err(RillError::InvalidArmCount(arm_count));
135        }
136        config.validate()?;
137
138        Ok(Self {
139            arm_count,
140            current_epsilon: config.epsilon,
141            config,
142            pulls: vec![0; arm_count],
143            total_rewards: vec![0.0; arm_count],
144            samples_seen: 0,
145        })
146    }
147
148    /// The current (possibly decayed) epsilon value.
149    pub const fn current_epsilon(&self) -> f64 {
150        self.current_epsilon
151    }
152
153    /// Per-arm pull counts (diagnostic).
154    pub fn pulls(&self) -> &[u64] {
155        &self.pulls
156    }
157
158    /// Per-arm total rewards (diagnostic).
159    pub fn total_rewards(&self) -> &[f64] {
160        &self.total_rewards
161    }
162
163    /// Validate all persisted state invariants.
164    ///
165    /// This is also run automatically during deserialization.
166    pub fn validate(&self) -> Result<(), RillError> {
167        if self.arm_count == 0 {
168            return Err(RillError::InvalidArmCount(self.arm_count));
169        }
170        self.config.validate()?;
171        if self.pulls.len() != self.arm_count || self.total_rewards.len() != self.arm_count {
172            return Err(RillError::InvalidState(
173                "arm_count does not match per-arm state lengths".to_owned(),
174            ));
175        }
176        if self.total_rewards.iter().any(|value| !value.is_finite()) {
177            return Err(RillError::InvalidState(
178                "total_rewards must contain only finite values".to_owned(),
179            ));
180        }
181        validate_sample_count(&self.pulls, self.samples_seen)?;
182        if !self.current_epsilon.is_finite()
183            || self.current_epsilon < self.config.min_epsilon
184            || self.current_epsilon > self.config.epsilon
185        {
186            return Err(RillError::InvalidState(
187                "current_epsilon is outside the configured bounds".to_owned(),
188            ));
189        }
190        Ok(())
191    }
192
193    /// Find the arm with the highest mean reward.
194    /// Ties are broken by choosing the lowest index.
195    fn best_arm(&self) -> usize {
196        let mut best = 0usize;
197        let mut best_mean = f64::NEG_INFINITY;
198        for (i, &pulls) in self.pulls.iter().enumerate() {
199            let mean = if pulls > 0 {
200                self.total_rewards[i] / pulls as f64
201            } else {
202                // Unpulled arms have unknown reward — treat as -inf so they
203                // are only selected if all arms are unpulled.
204                f64::NEG_INFINITY
205            };
206            if mean > best_mean {
207                best_mean = mean;
208                best = i;
209            }
210        }
211        // If all arms are unpulled, best_arm returns 0.
212        best
213    }
214}
215
216impl Bandit for EpsilonGreedy {
217    fn arm_count(&self) -> usize {
218        self.arm_count
219    }
220
221    fn samples_seen(&self) -> u64 {
222        self.samples_seen
223    }
224
225    fn select(&self, rng: &mut impl Rng) -> Result<usize, RillError> {
226        // If no arm has been pulled yet, select randomly to ensure exploration.
227        if self.samples_seen == 0 || self.pulls.iter().all(|&p| p == 0) {
228            let arm = rng.gen_range(0..self.arm_count);
229            return Ok(arm);
230        }
231
232        // Exploration vs exploitation.
233        let r: f64 = rng.gen_range(0.0..1.0);
234        if r < self.current_epsilon {
235            // Explore: pick a random arm.
236            let arm = rng.gen_range(0..self.arm_count);
237            Ok(arm)
238        } else {
239            // Exploit: pick the best arm.
240            Ok(self.best_arm())
241        }
242    }
243
244    fn update(&mut self, arm: usize, reward: f64) -> Result<(), RillError> {
245        validate_arm(self.arm_count, arm)?;
246        validate_reward_finite(reward)?;
247
248        // Compute every fallible change before mutating state so an error never
249        // leaves a partially updated model.
250        let next_pulls = checked_increment(self.pulls[arm], "pulls")?;
251        let next_total = checked_finite_add(self.total_rewards[arm], reward, "total_rewards")?;
252        let next_samples = checked_increment(self.samples_seen, "samples_seen")?;
253
254        // Apply epsilon decay.
255        let next_epsilon = if self.config.decay < 1.0 {
256            (self.current_epsilon * self.config.decay).max(self.config.min_epsilon)
257        } else {
258            self.current_epsilon
259        };
260
261        self.pulls[arm] = next_pulls;
262        self.total_rewards[arm] = next_total;
263        self.samples_seen = next_samples;
264        self.current_epsilon = next_epsilon;
265
266        Ok(())
267    }
268
269    fn reset(&mut self) {
270        self.pulls.fill(0);
271        self.total_rewards.fill(0.0);
272        self.samples_seen = 0;
273        self.current_epsilon = self.config.epsilon;
274    }
275
276    fn arm_stats(&self, arm: usize) -> Result<ArmStats, RillError> {
277        validate_arm(self.arm_count, arm)?;
278        ArmStats::new(self.pulls[arm], self.total_rewards[arm])
279    }
280}
281
282#[cfg(feature = "serde")]
283#[derive(serde::Deserialize)]
284struct EpsilonGreedyState {
285    arm_count: usize,
286    config: EpsilonGreedyConfig,
287    pulls: Vec<u64>,
288    total_rewards: Vec<f64>,
289    samples_seen: u64,
290    current_epsilon: f64,
291}
292
293#[cfg(feature = "serde")]
294impl<'de> serde::Deserialize<'de> for EpsilonGreedy {
295    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
296    where
297        D: serde::Deserializer<'de>,
298    {
299        let state = EpsilonGreedyState::deserialize(deserializer)?;
300        let bandit = Self {
301            arm_count: state.arm_count,
302            config: state.config,
303            pulls: state.pulls,
304            total_rewards: state.total_rewards,
305            samples_seen: state.samples_seen,
306            current_epsilon: state.current_epsilon,
307        };
308        bandit.validate().map_err(serde::de::Error::custom)?;
309        Ok(bandit)
310    }
311}
312
313#[cfg(feature = "serde")]
314impl ValidateState for EpsilonGreedy {
315    fn validate_state(&self) -> Result<(), RillError> {
316        EpsilonGreedy::validate(self)
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use rand::SeedableRng;
324    use rand_chacha::ChaCha8Rng;
325
326    fn make_bandit(epsilon: f64) -> EpsilonGreedy {
327        EpsilonGreedy::new(
328            3,
329            EpsilonGreedyConfig {
330                epsilon,
331                decay: 1.0,
332                min_epsilon: 0.0,
333            },
334        )
335        .unwrap()
336    }
337
338    #[test]
339    fn rejects_zero_arm_count() {
340        let result = EpsilonGreedy::new(0, EpsilonGreedyConfig::default());
341        assert!(matches!(result, Err(RillError::InvalidArmCount(0))));
342    }
343
344    #[test]
345    fn rejects_invalid_epsilon() {
346        let result = EpsilonGreedy::new(
347            3,
348            EpsilonGreedyConfig {
349                epsilon: 1.5,
350                decay: 1.0,
351                min_epsilon: 0.0,
352            },
353        );
354        assert!(matches!(result, Err(RillError::InvalidEpsilon(_))));
355    }
356
357    #[test]
358    fn rejects_invalid_decay() {
359        let result = EpsilonGreedy::new(
360            3,
361            EpsilonGreedyConfig {
362                epsilon: 0.1,
363                decay: 0.0,
364                min_epsilon: 0.0,
365            },
366        );
367        assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
368    }
369
370    #[test]
371    fn rejects_invalid_min_epsilon() {
372        let result = EpsilonGreedy::new(
373            3,
374            EpsilonGreedyConfig {
375                epsilon: 0.1,
376                decay: 1.0,
377                min_epsilon: 0.5, // > epsilon
378            },
379        );
380        assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
381    }
382
383    #[test]
384    fn initial_state() {
385        let b = make_bandit(0.1);
386        assert_eq!(b.arm_count(), 3);
387        assert_eq!(b.samples_seen(), 0);
388        assert!((b.current_epsilon() - 0.1).abs() < 1e-12);
389        for &pulls in b.pulls() {
390            assert_eq!(pulls, 0);
391        }
392    }
393
394    #[test]
395    fn select_with_no_data_returns_valid_arm() {
396        let b = make_bandit(0.1);
397        let mut rng = ChaCha8Rng::seed_from_u64(42);
398        let arm = b.select(&mut rng).unwrap();
399        assert!(arm < 3);
400    }
401
402    #[test]
403    fn update_increments_samples_seen() {
404        let mut b = make_bandit(0.1);
405        b.update(0, 1.0).unwrap();
406        b.update(1, 0.5).unwrap();
407        b.update(2, 0.0).unwrap();
408        assert_eq!(b.samples_seen(), 3);
409    }
410
411    #[test]
412    fn update_rejects_invalid_arm() {
413        let mut b = make_bandit(0.1);
414        assert!(b.update(3, 1.0).is_err());
415    }
416
417    #[test]
418    fn update_rejects_non_finite_reward() {
419        let mut b = make_bandit(0.1);
420        assert!(b.update(0, f64::NAN).is_err());
421        assert!(b.update(0, f64::INFINITY).is_err());
422    }
423
424    #[test]
425    fn update_rejects_overflow_without_mutating_state() {
426        let mut b = make_bandit(0.1);
427        b.update(0, f64::MAX).unwrap();
428        let before = b.clone();
429        assert!(b.update(0, f64::MAX).is_err());
430        assert_eq!(b.pulls(), before.pulls());
431        assert_eq!(b.total_rewards(), before.total_rewards());
432        assert_eq!(b.samples_seen(), before.samples_seen());
433    }
434
435    #[test]
436    fn arm_stats_after_updates() {
437        let mut b = make_bandit(0.1);
438        b.update(0, 1.0).unwrap();
439        b.update(0, 3.0).unwrap();
440        let stats = b.arm_stats(0).unwrap();
441        assert_eq!(stats.pulls, 2);
442        assert!((stats.total_reward - 4.0).abs() < 1e-12);
443        assert!((stats.mean_reward - 2.0).abs() < 1e-12);
444    }
445
446    #[test]
447    fn arm_stats_rejects_invalid_arm() {
448        let b = make_bandit(0.1);
449        assert!(b.arm_stats(5).is_err());
450    }
451
452    #[test]
453    fn exploitation_picks_best_arm() {
454        // With epsilon = 0, the bandit always exploits.
455        let mut b = make_bandit(0.0);
456        // Arm 1 has the highest mean reward.
457        b.update(0, 0.1).unwrap();
458        b.update(1, 0.9).unwrap();
459        b.update(2, 0.5).unwrap();
460
461        let mut rng = ChaCha8Rng::seed_from_u64(0);
462        for _ in 0..20 {
463            let arm = b.select(&mut rng).unwrap();
464            assert_eq!(arm, 1, "pure exploitation should pick arm 1");
465        }
466    }
467
468    #[test]
469    fn exploration_with_epsilon_one() {
470        // With epsilon = 1, the bandit always explores.
471        let mut b = make_bandit(1.0);
472        b.update(0, 0.1).unwrap();
473        b.update(1, 0.9).unwrap();
474        b.update(2, 0.5).unwrap();
475
476        let mut rng = ChaCha8Rng::seed_from_u64(7);
477        let mut arms_seen = std::collections::HashSet::new();
478        for _ in 0..100 {
479            let arm = b.select(&mut rng).unwrap();
480            arms_seen.insert(arm);
481        }
482        // With pure exploration over 100 draws, all 3 arms should appear.
483        assert_eq!(arms_seen.len(), 3);
484    }
485
486    #[test]
487    fn epsilon_decay_reduces_exploration() {
488        let mut b = EpsilonGreedy::new(
489            3,
490            EpsilonGreedyConfig {
491                epsilon: 0.5,
492                decay: 0.9,
493                min_epsilon: 0.01,
494            },
495        )
496        .unwrap();
497        assert!((b.current_epsilon() - 0.5).abs() < 1e-12);
498
499        for _ in 0..10 {
500            b.update(0, 1.0).unwrap();
501        }
502        // After 10 decays: 0.5 * 0.9^10 ≈ 0.174
503        assert!(b.current_epsilon() < 0.5);
504        assert!(b.current_epsilon() > 0.01);
505    }
506
507    #[test]
508    fn epsilon_decay_respects_min_epsilon() {
509        let mut b = EpsilonGreedy::new(
510            2,
511            EpsilonGreedyConfig {
512                epsilon: 0.5,
513                decay: 0.1,
514                min_epsilon: 0.2,
515            },
516        )
517        .unwrap();
518        // After one update: 0.5 * 0.1 = 0.05, but min is 0.2.
519        b.update(0, 1.0).unwrap();
520        assert!((b.current_epsilon() - 0.2).abs() < 1e-12);
521    }
522
523    #[test]
524    fn reset_clears_state() {
525        let mut b = EpsilonGreedy::new(
526            3,
527            EpsilonGreedyConfig {
528                epsilon: 0.5,
529                decay: 0.9,
530                min_epsilon: 0.01,
531            },
532        )
533        .unwrap();
534        b.update(0, 1.0).unwrap();
535        b.update(1, 0.5).unwrap();
536        assert_eq!(b.samples_seen(), 2);
537        assert!(b.current_epsilon() < 0.5);
538
539        b.reset();
540        assert_eq!(b.samples_seen(), 0);
541        assert!((b.current_epsilon() - 0.5).abs() < 1e-12);
542        for &pulls in b.pulls() {
543            assert_eq!(pulls, 0);
544        }
545    }
546
547    #[test]
548    fn best_arm_tie_breaks_by_lowest_index() {
549        let mut b = make_bandit(0.0);
550        b.update(0, 1.0).unwrap();
551        b.update(1, 1.0).unwrap();
552        b.update(2, 0.5).unwrap();
553        // Arms 0 and 1 have the same mean; arm 0 should be selected.
554        let mut rng = ChaCha8Rng::seed_from_u64(0);
555        let arm = b.select(&mut rng).unwrap();
556        assert_eq!(arm, 0);
557    }
558
559    #[test]
560    fn finds_best_arm_in_simulation() {
561        let mut b = make_bandit(0.1);
562        let mut rng = ChaCha8Rng::seed_from_u64(42);
563
564        // Simulate: arm 0 has mean 0.8, arm 1 has mean 0.3, arm 2 has mean 0.5.
565        for _ in 0..500 {
566            let arm = b.select(&mut rng).unwrap();
567            let reward = match arm {
568                0 => 0.8,
569                1 => 0.3,
570                _ => 0.5,
571            };
572            b.update(arm, reward).unwrap();
573        }
574
575        // Arm 0 should have the highest mean reward.
576        let stats0 = b.arm_stats(0).unwrap();
577        let stats1 = b.arm_stats(1).unwrap();
578        let stats2 = b.arm_stats(2).unwrap();
579        assert!(stats0.mean_reward > stats1.mean_reward);
580        assert!(stats0.mean_reward > stats2.mean_reward);
581        // Arm 0 should be pulled most often.
582        assert!(stats0.pulls > stats1.pulls);
583        assert!(stats0.pulls > stats2.pulls);
584    }
585
586    #[cfg(feature = "serde")]
587    #[test]
588    fn serde_roundtrip() {
589        let mut b = EpsilonGreedy::new(
590            3,
591            EpsilonGreedyConfig {
592                epsilon: 0.2,
593                decay: 0.95,
594                min_epsilon: 0.01,
595            },
596        )
597        .unwrap();
598        b.update(0, 1.0).unwrap();
599        b.update(1, 0.5).unwrap();
600
601        let json = serde_json::to_string(&b).unwrap();
602        let restored: EpsilonGreedy = serde_json::from_str(&json).unwrap();
603        assert_eq!(restored.arm_count(), b.arm_count());
604        assert_eq!(restored.samples_seen(), b.samples_seen());
605        assert!((restored.current_epsilon() - b.current_epsilon()).abs() < 1e-12);
606        assert_eq!(restored.pulls(), b.pulls());
607    }
608
609    #[cfg(feature = "serde")]
610    #[test]
611    fn serde_rejects_malformed_state() {
612        let json = r#"{
613            "arm_count": 2,
614            "config": {"epsilon": 0.1, "decay": 1.0, "min_epsilon": 0.01},
615            "pulls": [1],
616            "total_rewards": [1.0],
617            "samples_seen": 1,
618            "current_epsilon": 0.1
619        }"#;
620        assert!(serde_json::from_str::<EpsilonGreedy>(json).is_err());
621    }
622}