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        for p in &mut self.pulls {
239            *p = 0;
240        }
241        for r in &mut self.total_rewards {
242            *r = 0.0;
243        }
244        self.samples_seen = 0;
245    }
246
247    fn arm_stats(&self, arm: usize) -> Result<ArmStats, RillError> {
248        validate_arm(self.arm_count, arm)?;
249        ArmStats::new(self.pulls[arm], self.total_rewards[arm])
250    }
251}
252
253#[cfg(feature = "serde")]
254#[derive(serde::Deserialize)]
255struct Ucb1State {
256    arm_count: usize,
257    config: Ucb1Config,
258    pulls: Vec<u64>,
259    total_rewards: Vec<f64>,
260    samples_seen: u64,
261}
262
263#[cfg(feature = "serde")]
264impl<'de> serde::Deserialize<'de> for Ucb1 {
265    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
266    where
267        D: serde::Deserializer<'de>,
268    {
269        let state = Ucb1State::deserialize(deserializer)?;
270        let bandit = Self {
271            arm_count: state.arm_count,
272            config: state.config,
273            pulls: state.pulls,
274            total_rewards: state.total_rewards,
275            samples_seen: state.samples_seen,
276        };
277        bandit.validate().map_err(serde::de::Error::custom)?;
278        Ok(bandit)
279    }
280}
281
282#[cfg(feature = "serde")]
283impl ValidateState for Ucb1 {
284    fn validate_state(&self) -> Result<(), RillError> {
285        Ucb1::validate(self)
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use rand::SeedableRng;
293    use rand_chacha::ChaCha8Rng;
294
295    fn make_bandit() -> Ucb1 {
296        Ucb1::new(3, Ucb1Config::default()).unwrap()
297    }
298
299    #[test]
300    fn rejects_zero_arm_count() {
301        let result = Ucb1::new(0, Ucb1Config::default());
302        assert!(matches!(result, Err(RillError::InvalidArmCount(0))));
303    }
304
305    #[test]
306    fn rejects_invalid_exploration_constant() {
307        for &bad in &[0.0, -1.0, f64::NAN, f64::INFINITY] {
308            let result = Ucb1::new(
309                3,
310                Ucb1Config {
311                    exploration_constant: bad,
312                },
313            );
314            assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
315        }
316    }
317
318    #[test]
319    fn initial_state() {
320        let b = make_bandit();
321        assert_eq!(b.arm_count(), 3);
322        assert_eq!(b.samples_seen(), 0);
323    }
324
325    #[test]
326    fn unpulled_arms_selected_first() {
327        let b = make_bandit();
328        let mut rng = ChaCha8Rng::seed_from_u64(42);
329        // All arms unpulled — select should return a valid arm.
330        let arm = b.select(&mut rng).unwrap();
331        assert!(arm < 3);
332    }
333
334    #[test]
335    fn unexplored_arms_prioritized() {
336        let mut b = make_bandit();
337        // Pull arm 0 and arm 1, leaving arm 2 unexplored.
338        b.update(0, 1.0).unwrap();
339        b.update(1, 0.5).unwrap();
340
341        let mut rng = ChaCha8Rng::seed_from_u64(0);
342        // Arm 2 should be selected (it's unexplored).
343        let arm = b.select(&mut rng).unwrap();
344        assert_eq!(arm, 2);
345    }
346
347    #[test]
348    fn all_arms_explored_uses_ucb_formula() {
349        let mut b = make_bandit();
350        // Pull all arms at least once.
351        b.update(0, 0.9).unwrap();
352        b.update(1, 0.3).unwrap();
353        b.update(2, 0.5).unwrap();
354
355        // Arm 0 has the highest mean reward, and with equal pulls the
356        // exploration bonus is the same, so arm 0 should be selected.
357        let mut rng = ChaCha8Rng::seed_from_u64(0);
358        let arm = b.select(&mut rng).unwrap();
359        assert_eq!(arm, 0);
360    }
361
362    #[test]
363    fn ucb_value_for_unpulled_arm_is_infinity() {
364        let b = make_bandit();
365        assert!(b.ucb_value(0).is_infinite());
366    }
367
368    #[test]
369    fn ucb_value_decreases_with_more_pulls() {
370        let mut b = make_bandit();
371        // Pull all arms once first to establish a baseline (ln(total) > 0).
372        b.update(0, 1.0).unwrap();
373        b.update(1, 0.5).unwrap();
374        b.update(2, 0.5).unwrap();
375        let v1 = b.ucb_value(0);
376        // Pull arm 0 several more times with the same reward.
377        for _ in 0..10 {
378            b.update(0, 1.0).unwrap();
379        }
380        let v2 = b.ucb_value(0);
381        // More pulls on arm 0 → lower exploration bonus → lower UCB value.
382        assert!(v2 < v1);
383    }
384
385    #[test]
386    fn update_rejects_invalid_arm() {
387        let mut b = make_bandit();
388        assert!(b.update(3, 1.0).is_err());
389    }
390
391    #[test]
392    fn update_rejects_reward_outside_unit_interval() {
393        let mut b = make_bandit();
394        assert!(b.update(0, f64::NAN).is_err());
395        assert!(b.update(0, -0.1).is_err());
396        assert!(b.update(0, 1.1).is_err());
397    }
398
399    #[test]
400    fn reset_clears_state() {
401        let mut b = make_bandit();
402        b.update(0, 1.0).unwrap();
403        b.update(1, 0.5).unwrap();
404        assert_eq!(b.samples_seen(), 2);
405
406        b.reset();
407        assert_eq!(b.samples_seen(), 0);
408        for &pulls in b.pulls() {
409            assert_eq!(pulls, 0);
410        }
411    }
412
413    #[test]
414    fn finds_best_arm_in_simulation() {
415        let mut b = make_bandit();
416        let mut rng = ChaCha8Rng::seed_from_u64(42);
417
418        // Simulate: arm 0 has mean 0.8, arm 1 has mean 0.3, arm 2 has mean 0.5.
419        for _ in 0..500 {
420            let arm = b.select(&mut rng).unwrap();
421            let reward = match arm {
422                0 => 0.8,
423                1 => 0.3,
424                _ => 0.5,
425            };
426            b.update(arm, reward).unwrap();
427        }
428
429        // Arm 0 should be pulled most often.
430        let stats0 = b.arm_stats(0).unwrap();
431        let stats1 = b.arm_stats(1).unwrap();
432        let stats2 = b.arm_stats(2).unwrap();
433        assert!(stats0.pulls > stats1.pulls);
434        assert!(stats0.pulls > stats2.pulls);
435        assert!(stats0.mean_reward > stats1.mean_reward);
436    }
437
438    #[test]
439    fn arm_stats_rejects_invalid_arm() {
440        let b = make_bandit();
441        assert!(b.arm_stats(5).is_err());
442    }
443
444    #[cfg(feature = "serde")]
445    #[test]
446    fn serde_roundtrip() {
447        let mut b = Ucb1::new(
448            3,
449            Ucb1Config {
450                exploration_constant: 2.0,
451            },
452        )
453        .unwrap();
454        b.update(0, 1.0).unwrap();
455        b.update(1, 0.5).unwrap();
456
457        let json = serde_json::to_string(&b).unwrap();
458        let restored: Ucb1 = serde_json::from_str(&json).unwrap();
459        assert_eq!(restored.arm_count(), b.arm_count());
460        assert_eq!(restored.samples_seen(), b.samples_seen());
461        assert_eq!(restored.pulls(), b.pulls());
462    }
463
464    #[cfg(feature = "serde")]
465    #[test]
466    fn serde_rejects_malformed_state() {
467        let json = r#"{
468            "arm_count": 2,
469            "config": {"exploration_constant": 1.0},
470            "pulls": [1],
471            "total_rewards": [1.0],
472            "samples_seen": 1
473        }"#;
474        assert!(serde_json::from_str::<Ucb1>(json).is_err());
475    }
476}