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        for p in &mut self.pulls {
271            *p = 0;
272        }
273        for r in &mut self.total_rewards {
274            *r = 0.0;
275        }
276        self.samples_seen = 0;
277        self.current_epsilon = self.config.epsilon;
278    }
279
280    fn arm_stats(&self, arm: usize) -> Result<ArmStats, RillError> {
281        validate_arm(self.arm_count, arm)?;
282        ArmStats::new(self.pulls[arm], self.total_rewards[arm])
283    }
284}
285
286#[cfg(feature = "serde")]
287#[derive(serde::Deserialize)]
288struct EpsilonGreedyState {
289    arm_count: usize,
290    config: EpsilonGreedyConfig,
291    pulls: Vec<u64>,
292    total_rewards: Vec<f64>,
293    samples_seen: u64,
294    current_epsilon: f64,
295}
296
297#[cfg(feature = "serde")]
298impl<'de> serde::Deserialize<'de> for EpsilonGreedy {
299    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
300    where
301        D: serde::Deserializer<'de>,
302    {
303        let state = EpsilonGreedyState::deserialize(deserializer)?;
304        let bandit = Self {
305            arm_count: state.arm_count,
306            config: state.config,
307            pulls: state.pulls,
308            total_rewards: state.total_rewards,
309            samples_seen: state.samples_seen,
310            current_epsilon: state.current_epsilon,
311        };
312        bandit.validate().map_err(serde::de::Error::custom)?;
313        Ok(bandit)
314    }
315}
316
317#[cfg(feature = "serde")]
318impl ValidateState for EpsilonGreedy {
319    fn validate_state(&self) -> Result<(), RillError> {
320        EpsilonGreedy::validate(self)
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use rand::SeedableRng;
328    use rand_chacha::ChaCha8Rng;
329
330    fn make_bandit(epsilon: f64) -> EpsilonGreedy {
331        EpsilonGreedy::new(
332            3,
333            EpsilonGreedyConfig {
334                epsilon,
335                decay: 1.0,
336                min_epsilon: 0.0,
337            },
338        )
339        .unwrap()
340    }
341
342    #[test]
343    fn rejects_zero_arm_count() {
344        let result = EpsilonGreedy::new(0, EpsilonGreedyConfig::default());
345        assert!(matches!(result, Err(RillError::InvalidArmCount(0))));
346    }
347
348    #[test]
349    fn rejects_invalid_epsilon() {
350        let result = EpsilonGreedy::new(
351            3,
352            EpsilonGreedyConfig {
353                epsilon: 1.5,
354                decay: 1.0,
355                min_epsilon: 0.0,
356            },
357        );
358        assert!(matches!(result, Err(RillError::InvalidEpsilon(_))));
359    }
360
361    #[test]
362    fn rejects_invalid_decay() {
363        let result = EpsilonGreedy::new(
364            3,
365            EpsilonGreedyConfig {
366                epsilon: 0.1,
367                decay: 0.0,
368                min_epsilon: 0.0,
369            },
370        );
371        assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
372    }
373
374    #[test]
375    fn rejects_invalid_min_epsilon() {
376        let result = EpsilonGreedy::new(
377            3,
378            EpsilonGreedyConfig {
379                epsilon: 0.1,
380                decay: 1.0,
381                min_epsilon: 0.5, // > epsilon
382            },
383        );
384        assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
385    }
386
387    #[test]
388    fn initial_state() {
389        let b = make_bandit(0.1);
390        assert_eq!(b.arm_count(), 3);
391        assert_eq!(b.samples_seen(), 0);
392        assert!((b.current_epsilon() - 0.1).abs() < 1e-12);
393        for &pulls in b.pulls() {
394            assert_eq!(pulls, 0);
395        }
396    }
397
398    #[test]
399    fn select_with_no_data_returns_valid_arm() {
400        let b = make_bandit(0.1);
401        let mut rng = ChaCha8Rng::seed_from_u64(42);
402        let arm = b.select(&mut rng).unwrap();
403        assert!(arm < 3);
404    }
405
406    #[test]
407    fn update_increments_samples_seen() {
408        let mut b = make_bandit(0.1);
409        b.update(0, 1.0).unwrap();
410        b.update(1, 0.5).unwrap();
411        b.update(2, 0.0).unwrap();
412        assert_eq!(b.samples_seen(), 3);
413    }
414
415    #[test]
416    fn update_rejects_invalid_arm() {
417        let mut b = make_bandit(0.1);
418        assert!(b.update(3, 1.0).is_err());
419    }
420
421    #[test]
422    fn update_rejects_non_finite_reward() {
423        let mut b = make_bandit(0.1);
424        assert!(b.update(0, f64::NAN).is_err());
425        assert!(b.update(0, f64::INFINITY).is_err());
426    }
427
428    #[test]
429    fn update_rejects_overflow_without_mutating_state() {
430        let mut b = make_bandit(0.1);
431        b.update(0, f64::MAX).unwrap();
432        let before = b.clone();
433        assert!(b.update(0, f64::MAX).is_err());
434        assert_eq!(b.pulls(), before.pulls());
435        assert_eq!(b.total_rewards(), before.total_rewards());
436        assert_eq!(b.samples_seen(), before.samples_seen());
437    }
438
439    #[test]
440    fn arm_stats_after_updates() {
441        let mut b = make_bandit(0.1);
442        b.update(0, 1.0).unwrap();
443        b.update(0, 3.0).unwrap();
444        let stats = b.arm_stats(0).unwrap();
445        assert_eq!(stats.pulls, 2);
446        assert!((stats.total_reward - 4.0).abs() < 1e-12);
447        assert!((stats.mean_reward - 2.0).abs() < 1e-12);
448    }
449
450    #[test]
451    fn arm_stats_rejects_invalid_arm() {
452        let b = make_bandit(0.1);
453        assert!(b.arm_stats(5).is_err());
454    }
455
456    #[test]
457    fn exploitation_picks_best_arm() {
458        // With epsilon = 0, the bandit always exploits.
459        let mut b = make_bandit(0.0);
460        // Arm 1 has the highest mean reward.
461        b.update(0, 0.1).unwrap();
462        b.update(1, 0.9).unwrap();
463        b.update(2, 0.5).unwrap();
464
465        let mut rng = ChaCha8Rng::seed_from_u64(0);
466        for _ in 0..20 {
467            let arm = b.select(&mut rng).unwrap();
468            assert_eq!(arm, 1, "pure exploitation should pick arm 1");
469        }
470    }
471
472    #[test]
473    fn exploration_with_epsilon_one() {
474        // With epsilon = 1, the bandit always explores.
475        let mut b = make_bandit(1.0);
476        b.update(0, 0.1).unwrap();
477        b.update(1, 0.9).unwrap();
478        b.update(2, 0.5).unwrap();
479
480        let mut rng = ChaCha8Rng::seed_from_u64(7);
481        let mut arms_seen = std::collections::HashSet::new();
482        for _ in 0..100 {
483            let arm = b.select(&mut rng).unwrap();
484            arms_seen.insert(arm);
485        }
486        // With pure exploration over 100 draws, all 3 arms should appear.
487        assert_eq!(arms_seen.len(), 3);
488    }
489
490    #[test]
491    fn epsilon_decay_reduces_exploration() {
492        let mut b = EpsilonGreedy::new(
493            3,
494            EpsilonGreedyConfig {
495                epsilon: 0.5,
496                decay: 0.9,
497                min_epsilon: 0.01,
498            },
499        )
500        .unwrap();
501        assert!((b.current_epsilon() - 0.5).abs() < 1e-12);
502
503        for _ in 0..10 {
504            b.update(0, 1.0).unwrap();
505        }
506        // After 10 decays: 0.5 * 0.9^10 ≈ 0.174
507        assert!(b.current_epsilon() < 0.5);
508        assert!(b.current_epsilon() > 0.01);
509    }
510
511    #[test]
512    fn epsilon_decay_respects_min_epsilon() {
513        let mut b = EpsilonGreedy::new(
514            2,
515            EpsilonGreedyConfig {
516                epsilon: 0.5,
517                decay: 0.1,
518                min_epsilon: 0.2,
519            },
520        )
521        .unwrap();
522        // After one update: 0.5 * 0.1 = 0.05, but min is 0.2.
523        b.update(0, 1.0).unwrap();
524        assert!((b.current_epsilon() - 0.2).abs() < 1e-12);
525    }
526
527    #[test]
528    fn reset_clears_state() {
529        let mut b = EpsilonGreedy::new(
530            3,
531            EpsilonGreedyConfig {
532                epsilon: 0.5,
533                decay: 0.9,
534                min_epsilon: 0.01,
535            },
536        )
537        .unwrap();
538        b.update(0, 1.0).unwrap();
539        b.update(1, 0.5).unwrap();
540        assert_eq!(b.samples_seen(), 2);
541        assert!(b.current_epsilon() < 0.5);
542
543        b.reset();
544        assert_eq!(b.samples_seen(), 0);
545        assert!((b.current_epsilon() - 0.5).abs() < 1e-12);
546        for &pulls in b.pulls() {
547            assert_eq!(pulls, 0);
548        }
549    }
550
551    #[test]
552    fn best_arm_tie_breaks_by_lowest_index() {
553        let mut b = make_bandit(0.0);
554        b.update(0, 1.0).unwrap();
555        b.update(1, 1.0).unwrap();
556        b.update(2, 0.5).unwrap();
557        // Arms 0 and 1 have the same mean; arm 0 should be selected.
558        let mut rng = ChaCha8Rng::seed_from_u64(0);
559        let arm = b.select(&mut rng).unwrap();
560        assert_eq!(arm, 0);
561    }
562
563    #[test]
564    fn finds_best_arm_in_simulation() {
565        let mut b = make_bandit(0.1);
566        let mut rng = ChaCha8Rng::seed_from_u64(42);
567
568        // Simulate: arm 0 has mean 0.8, arm 1 has mean 0.3, arm 2 has mean 0.5.
569        for _ in 0..500 {
570            let arm = b.select(&mut rng).unwrap();
571            let reward = match arm {
572                0 => 0.8,
573                1 => 0.3,
574                _ => 0.5,
575            };
576            b.update(arm, reward).unwrap();
577        }
578
579        // Arm 0 should have the highest mean reward.
580        let stats0 = b.arm_stats(0).unwrap();
581        let stats1 = b.arm_stats(1).unwrap();
582        let stats2 = b.arm_stats(2).unwrap();
583        assert!(stats0.mean_reward > stats1.mean_reward);
584        assert!(stats0.mean_reward > stats2.mean_reward);
585        // Arm 0 should be pulled most often.
586        assert!(stats0.pulls > stats1.pulls);
587        assert!(stats0.pulls > stats2.pulls);
588    }
589
590    #[cfg(feature = "serde")]
591    #[test]
592    fn serde_roundtrip() {
593        let mut b = EpsilonGreedy::new(
594            3,
595            EpsilonGreedyConfig {
596                epsilon: 0.2,
597                decay: 0.95,
598                min_epsilon: 0.01,
599            },
600        )
601        .unwrap();
602        b.update(0, 1.0).unwrap();
603        b.update(1, 0.5).unwrap();
604
605        let json = serde_json::to_string(&b).unwrap();
606        let restored: EpsilonGreedy = serde_json::from_str(&json).unwrap();
607        assert_eq!(restored.arm_count(), b.arm_count());
608        assert_eq!(restored.samples_seen(), b.samples_seen());
609        assert!((restored.current_epsilon() - b.current_epsilon()).abs() < 1e-12);
610        assert_eq!(restored.pulls(), b.pulls());
611    }
612
613    #[cfg(feature = "serde")]
614    #[test]
615    fn serde_rejects_malformed_state() {
616        let json = r#"{
617            "arm_count": 2,
618            "config": {"epsilon": 0.1, "decay": 1.0, "min_epsilon": 0.01},
619            "pulls": [1],
620            "total_rewards": [1.0],
621            "samples_seen": 1,
622            "current_epsilon": 0.1
623        }"#;
624        assert!(serde_json::from_str::<EpsilonGreedy>(json).is_err());
625    }
626}