Skip to main content

rill_ml/bandit/
ucb1.rs

1//! UCB1 (Upper Confidence Bound 1) bandit algorithm.
2//!
3//! UCB1 balances exploration and exploitation by selecting the arm that
4//! maximizes:
5//!
6//! ```text
7//! mean_reward + c * sqrt(2 * ln(total_pulls) / arm_pulls)
8//! ```
9//!
10//! The first term is the exploitation term (estimated reward), and the second
11//! term is the exploration bonus that decreases as an arm is pulled more often.
12//! Arms that have never been pulled are selected first.
13//!
14//! ## Complexity
15//!
16//! - `select`: `O(arm_count)`.
17//! - `update`: `O(1)`.
18//! - Space: `O(arm_count)`.
19//!
20//! ## Reference
21//!
22//! Auer, Cesa-Bianchi, and Fischer. "Finite-time Analysis of the Multiarmed
23//! Bandit Problem." Machine Learning, 2002.
24
25use crate::bandit::stats::ArmStats;
26use crate::bandit::{
27    Bandit, checked_finite_add, checked_increment, validate_arm, validate_reward_01,
28    validate_sample_count,
29};
30use crate::error::RillError;
31#[cfg(feature = "serde")]
32use crate::persistence::ValidateState;
33use rand::Rng;
34
35/// Configuration for [`Ucb1`].
36///
37/// # Examples
38///
39/// ```
40/// use rill_ml::bandit::Ucb1Config;
41///
42/// let mut config = Ucb1Config::default();
43/// config.exploration_constant = 2.0;
44/// assert!((config.exploration_constant - 2.0).abs() < 1e-12);
45/// ```
46#[derive(Debug, Clone, PartialEq)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48#[non_exhaustive]
49pub struct Ucb1Config {
50    /// Exploration constant `c` in the UCB formula. Controls the
51    /// exploration/exploitation trade-off. Higher values favor exploration.
52    ///
53    /// Must be finite and positive. With the formula used by this type, the
54    /// classic UCB1 value is `1.0`.
55    pub exploration_constant: f64,
56}
57
58impl Default for Ucb1Config {
59    fn default() -> Self {
60        Self {
61            exploration_constant: 1.0,
62        }
63    }
64}
65
66impl Ucb1Config {
67    /// Validate the configuration without constructing a bandit.
68    pub fn validate(&self) -> Result<(), RillError> {
69        if !self.exploration_constant.is_finite() || self.exploration_constant <= 0.0 {
70            return Err(RillError::InvalidParameter {
71                name: "exploration_constant",
72                value: self.exploration_constant,
73            });
74        }
75        Ok(())
76    }
77}
78
79/// UCB1 multi-armed bandit.
80///
81/// Selects arms using the upper confidence bound formula, which automatically
82/// balances exploration and exploitation. Unpulled arms are always selected
83/// first. Rewards passed to [`Bandit::update`] must be normalized to `[0, 1]`.
84///
85/// # Examples
86///
87/// ```
88/// use rill_ml::bandit::{Bandit, Ucb1, Ucb1Config};
89/// use rand::SeedableRng;
90/// use rand_chacha::ChaCha8Rng;
91///
92/// let mut rng = ChaCha8Rng::seed_from_u64(0);
93/// let mut bandit = Ucb1::new(3, Ucb1Config::default()).unwrap();
94///
95/// let arm = bandit.select(&mut rng).unwrap();
96/// bandit.update(arm, 1.0).unwrap();
97/// assert_eq!(bandit.samples_seen(), 1);
98/// ```
99#[derive(Debug, Clone)]
100#[cfg_attr(feature = "serde", derive(serde::Serialize))]
101pub struct Ucb1 {
102    arm_count: usize,
103    config: Ucb1Config,
104    /// Per-arm pull counts.
105    pulls: Vec<u64>,
106    /// Per-arm total rewards.
107    total_rewards: Vec<f64>,
108    /// Total number of updates.
109    samples_seen: u64,
110}
111
112impl Ucb1 {
113    /// Create a new UCB1 bandit.
114    ///
115    /// # Errors
116    ///
117    /// Returns `RillError::InvalidArmCount` if `arm_count` is zero.
118    /// Returns `RillError::InvalidParameter` if `exploration_constant` is not
119    /// finite and positive.
120    pub fn new(arm_count: usize, config: Ucb1Config) -> Result<Self, RillError> {
121        if arm_count == 0 {
122            return Err(RillError::InvalidArmCount(arm_count));
123        }
124        config.validate()?;
125
126        Ok(Self {
127            arm_count,
128            config,
129            pulls: vec![0; arm_count],
130            total_rewards: vec![0.0; arm_count],
131            samples_seen: 0,
132        })
133    }
134
135    /// Per-arm pull counts (diagnostic).
136    pub fn pulls(&self) -> &[u64] {
137        &self.pulls
138    }
139
140    /// Per-arm total rewards (diagnostic).
141    pub fn total_rewards(&self) -> &[f64] {
142        &self.total_rewards
143    }
144
145    /// Validate all persisted state invariants.
146    ///
147    /// This is also run automatically during deserialization.
148    pub fn validate(&self) -> Result<(), RillError> {
149        if self.arm_count == 0 {
150            return Err(RillError::InvalidArmCount(self.arm_count));
151        }
152        self.config.validate()?;
153        if self.pulls.len() != self.arm_count || self.total_rewards.len() != self.arm_count {
154            return Err(RillError::InvalidState(
155                "arm_count does not match per-arm state lengths".to_owned(),
156            ));
157        }
158        validate_sample_count(&self.pulls, self.samples_seen)?;
159        for (arm, (&pulls, &reward)) in self.pulls.iter().zip(self.total_rewards.iter()).enumerate()
160        {
161            if !reward.is_finite() || reward < 0.0 || reward > pulls as f64 {
162                return Err(RillError::InvalidState(format!(
163                    "total reward for arm {arm} is inconsistent with [0, 1] rewards"
164                )));
165            }
166        }
167        Ok(())
168    }
169
170    /// Compute the UCB value for a specific arm.
171    ///
172    /// Returns `f64::INFINITY` for unpulled arms (they are always selected first).
173    fn ucb_value(&self, arm: usize) -> f64 {
174        let pulls = self.pulls[arm];
175        if pulls == 0 {
176            return f64::INFINITY;
177        }
178        let mean = self.total_rewards[arm] / pulls as f64;
179        // Exploration bonus: c * sqrt(2 * ln(N) / n_i)
180        // where N = total pulls, n_i = arm pulls.
181        let log_total = (self.samples_seen as f64).ln();
182        let exploration =
183            self.config.exploration_constant * (2.0 * log_total / pulls as f64).sqrt();
184        mean + exploration
185    }
186}
187
188impl Bandit for Ucb1 {
189    fn arm_count(&self) -> usize {
190        self.arm_count
191    }
192
193    fn samples_seen(&self) -> u64 {
194        self.samples_seen
195    }
196
197    fn select(&self, rng: &mut impl Rng) -> Result<usize, RillError> {
198        // Compute UCB values for all arms.
199        let mut best_arm = 0usize;
200        let mut best_value = f64::NEG_INFINITY;
201        let mut unexplored: Vec<usize> = Vec::new();
202
203        for arm in 0..self.arm_count {
204            if self.pulls[arm] == 0 {
205                unexplored.push(arm);
206                continue;
207            }
208            let value = self.ucb_value(arm);
209            if value > best_value {
210                best_value = value;
211                best_arm = arm;
212            }
213        }
214
215        // If there are unexplored arms, pick one at random.
216        if !unexplored.is_empty() {
217            let idx = rng.gen_range(0..unexplored.len());
218            return Ok(unexplored[idx]);
219        }
220
221        Ok(best_arm)
222    }
223
224    fn update(&mut self, arm: usize, reward: f64) -> Result<(), RillError> {
225        validate_arm(self.arm_count, arm)?;
226        validate_reward_01(reward)?;
227
228        let next_pulls = checked_increment(self.pulls[arm], "pulls")?;
229        let next_total = checked_finite_add(self.total_rewards[arm], reward, "total_rewards")?;
230        let next_samples = checked_increment(self.samples_seen, "samples_seen")?;
231        self.pulls[arm] = next_pulls;
232        self.total_rewards[arm] = next_total;
233        self.samples_seen = next_samples;
234        Ok(())
235    }
236
237    fn reset(&mut self) {
238        self.pulls.fill(0);
239        self.total_rewards.fill(0.0);
240        self.samples_seen = 0;
241    }
242
243    fn arm_stats(&self, arm: usize) -> Result<ArmStats, RillError> {
244        validate_arm(self.arm_count, arm)?;
245        ArmStats::new(self.pulls[arm], self.total_rewards[arm])
246    }
247}
248
249#[cfg(feature = "serde")]
250#[derive(serde::Deserialize)]
251struct Ucb1State {
252    arm_count: usize,
253    config: Ucb1Config,
254    pulls: Vec<u64>,
255    total_rewards: Vec<f64>,
256    samples_seen: u64,
257}
258
259#[cfg(feature = "serde")]
260impl<'de> serde::Deserialize<'de> for Ucb1 {
261    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
262    where
263        D: serde::Deserializer<'de>,
264    {
265        let state = Ucb1State::deserialize(deserializer)?;
266        let bandit = Self {
267            arm_count: state.arm_count,
268            config: state.config,
269            pulls: state.pulls,
270            total_rewards: state.total_rewards,
271            samples_seen: state.samples_seen,
272        };
273        bandit.validate().map_err(serde::de::Error::custom)?;
274        Ok(bandit)
275    }
276}
277
278#[cfg(feature = "serde")]
279impl ValidateState for Ucb1 {
280    fn validate_state(&self) -> Result<(), RillError> {
281        Ucb1::validate(self)
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use rand::SeedableRng;
289    use rand_chacha::ChaCha8Rng;
290
291    fn make_bandit() -> Ucb1 {
292        Ucb1::new(3, Ucb1Config::default()).unwrap()
293    }
294
295    #[test]
296    fn rejects_zero_arm_count() {
297        let result = Ucb1::new(0, Ucb1Config::default());
298        assert!(matches!(result, Err(RillError::InvalidArmCount(0))));
299    }
300
301    #[test]
302    fn rejects_invalid_exploration_constant() {
303        for &bad in &[0.0, -1.0, f64::NAN, f64::INFINITY] {
304            let result = Ucb1::new(
305                3,
306                Ucb1Config {
307                    exploration_constant: bad,
308                },
309            );
310            assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
311        }
312    }
313
314    #[test]
315    fn initial_state() {
316        let b = make_bandit();
317        assert_eq!(b.arm_count(), 3);
318        assert_eq!(b.samples_seen(), 0);
319    }
320
321    #[test]
322    fn unpulled_arms_selected_first() {
323        let b = make_bandit();
324        let mut rng = ChaCha8Rng::seed_from_u64(42);
325        // All arms unpulled — select should return a valid arm.
326        let arm = b.select(&mut rng).unwrap();
327        assert!(arm < 3);
328    }
329
330    #[test]
331    fn unexplored_arms_prioritized() {
332        let mut b = make_bandit();
333        // Pull arm 0 and arm 1, leaving arm 2 unexplored.
334        b.update(0, 1.0).unwrap();
335        b.update(1, 0.5).unwrap();
336
337        let mut rng = ChaCha8Rng::seed_from_u64(0);
338        // Arm 2 should be selected (it's unexplored).
339        let arm = b.select(&mut rng).unwrap();
340        assert_eq!(arm, 2);
341    }
342
343    #[test]
344    fn all_arms_explored_uses_ucb_formula() {
345        let mut b = make_bandit();
346        // Pull all arms at least once.
347        b.update(0, 0.9).unwrap();
348        b.update(1, 0.3).unwrap();
349        b.update(2, 0.5).unwrap();
350
351        // Arm 0 has the highest mean reward, and with equal pulls the
352        // exploration bonus is the same, so arm 0 should be selected.
353        let mut rng = ChaCha8Rng::seed_from_u64(0);
354        let arm = b.select(&mut rng).unwrap();
355        assert_eq!(arm, 0);
356    }
357
358    #[test]
359    fn ucb_value_for_unpulled_arm_is_infinity() {
360        let b = make_bandit();
361        assert!(b.ucb_value(0).is_infinite());
362    }
363
364    #[test]
365    fn ucb_value_decreases_with_more_pulls() {
366        let mut b = make_bandit();
367        // Pull all arms once first to establish a baseline (ln(total) > 0).
368        b.update(0, 1.0).unwrap();
369        b.update(1, 0.5).unwrap();
370        b.update(2, 0.5).unwrap();
371        let v1 = b.ucb_value(0);
372        // Pull arm 0 several more times with the same reward.
373        for _ in 0..10 {
374            b.update(0, 1.0).unwrap();
375        }
376        let v2 = b.ucb_value(0);
377        // More pulls on arm 0 → lower exploration bonus → lower UCB value.
378        assert!(v2 < v1);
379    }
380
381    #[test]
382    fn update_rejects_invalid_arm() {
383        let mut b = make_bandit();
384        assert!(b.update(3, 1.0).is_err());
385    }
386
387    #[test]
388    fn update_rejects_reward_outside_unit_interval() {
389        let mut b = make_bandit();
390        assert!(b.update(0, f64::NAN).is_err());
391        assert!(b.update(0, -0.1).is_err());
392        assert!(b.update(0, 1.1).is_err());
393    }
394
395    #[test]
396    fn reset_clears_state() {
397        let mut b = make_bandit();
398        b.update(0, 1.0).unwrap();
399        b.update(1, 0.5).unwrap();
400        assert_eq!(b.samples_seen(), 2);
401
402        b.reset();
403        assert_eq!(b.samples_seen(), 0);
404        for &pulls in b.pulls() {
405            assert_eq!(pulls, 0);
406        }
407    }
408
409    #[test]
410    fn finds_best_arm_in_simulation() {
411        let mut b = make_bandit();
412        let mut rng = ChaCha8Rng::seed_from_u64(42);
413
414        // Simulate: arm 0 has mean 0.8, arm 1 has mean 0.3, arm 2 has mean 0.5.
415        for _ in 0..500 {
416            let arm = b.select(&mut rng).unwrap();
417            let reward = match arm {
418                0 => 0.8,
419                1 => 0.3,
420                _ => 0.5,
421            };
422            b.update(arm, reward).unwrap();
423        }
424
425        // Arm 0 should be pulled most often.
426        let stats0 = b.arm_stats(0).unwrap();
427        let stats1 = b.arm_stats(1).unwrap();
428        let stats2 = b.arm_stats(2).unwrap();
429        assert!(stats0.pulls > stats1.pulls);
430        assert!(stats0.pulls > stats2.pulls);
431        assert!(stats0.mean_reward > stats1.mean_reward);
432    }
433
434    #[test]
435    fn arm_stats_rejects_invalid_arm() {
436        let b = make_bandit();
437        assert!(b.arm_stats(5).is_err());
438    }
439
440    #[cfg(feature = "serde")]
441    #[test]
442    fn serde_roundtrip() {
443        let mut b = Ucb1::new(
444            3,
445            Ucb1Config {
446                exploration_constant: 2.0,
447            },
448        )
449        .unwrap();
450        b.update(0, 1.0).unwrap();
451        b.update(1, 0.5).unwrap();
452
453        let json = serde_json::to_string(&b).unwrap();
454        let restored: Ucb1 = serde_json::from_str(&json).unwrap();
455        assert_eq!(restored.arm_count(), b.arm_count());
456        assert_eq!(restored.samples_seen(), b.samples_seen());
457        assert_eq!(restored.pulls(), b.pulls());
458    }
459
460    #[cfg(feature = "serde")]
461    #[test]
462    fn serde_rejects_malformed_state() {
463        let json = r#"{
464            "arm_count": 2,
465            "config": {"exploration_constant": 1.0},
466            "pulls": [1],
467            "total_rewards": [1.0],
468            "samples_seen": 1
469        }"#;
470        assert!(serde_json::from_str::<Ucb1>(json).is_err());
471    }
472}