Skip to content

Commit 49596b2

Browse files
alexnodelandclaude
andcommitted
feat: add MLE, uncertainty quantification, and active learning to IGA
- Add proper Bradley-Terry MLE with Newton-Raphson and MM algorithms - Newton-Raphson provides analytical covariance from Fisher Information - MM algorithm uses bootstrap resampling for variance estimation - Both support convergence checking and regularization - Add uncertainty quantification throughout the system - FitnessEstimate struct with mean, variance, and 95% CIs - WelfordVariance for numerically stable online variance calculation - Candidates now track fitness_with_uncertainty - Add active learning selection strategies - Sequential: original round-robin behavior - UncertaintySampling: prioritizes high-variance candidates - ExpectedInformationGain: maximizes entropy reduction - CoverageAware: ensures minimum coverage before exploring - Integrate uncertainty into session and algorithm - sync_fitness_estimates() to update candidates from aggregator - candidates_by_uncertainty() for identifying under-evaluated candidates - Selection strategy configurable via builder pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 795d6e9 commit 49596b2

8 files changed

Lines changed: 2455 additions & 43 deletions

File tree

src/interactive/aggregation.rs

Lines changed: 217 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,18 @@
99
//! - **Elo**: Classic Elo rating system from pairwise comparisons
1010
//! - **BradleyTerry**: Maximum likelihood estimation for pairwise data
1111
//! - **ImplicitRanking**: Bonus/penalty system from batch selections
12+
//!
13+
//! # Uncertainty Quantification
14+
//!
15+
//! All models support uncertainty estimation via `get_fitness_estimate()`,
16+
//! which returns a `FitnessEstimate` with variance and confidence intervals.
1217
1318
use serde::{Deserialize, Serialize};
1419
use std::collections::HashMap;
1520

21+
use super::bradley_terry::{BradleyTerryModel, BradleyTerryOptimizer};
1622
use super::evaluator::{CandidateId, EvaluationResponse};
23+
use super::uncertainty::FitnessEstimate;
1724

1825
/// Aggregation model for converting user feedback to fitness
1926
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -42,12 +49,25 @@ pub enum AggregationModel {
4249
///
4350
/// Maximum likelihood estimation for pairwise comparison data.
4451
/// Provides more statistically principled estimates than Elo.
52+
/// Now supports proper MLE with Newton-Raphson or MM algorithms.
4553
BradleyTerry {
54+
/// Initial strength parameter
55+
initial_strength: f64,
56+
/// Optimizer configuration (Newton-Raphson or MM)
57+
#[serde(default)]
58+
optimizer: BradleyTerryOptimizer,
59+
},
60+
61+
/// Legacy Bradley-Terry model (for backward compatibility)
62+
///
63+
/// Uses the simplified iterative MM approach from earlier versions.
64+
#[serde(alias = "BradleyTerryLegacy")]
65+
BradleyTerrySimple {
4666
/// Initial strength parameter
4767
initial_strength: f64,
4868
/// Learning rate for iterative updates
4969
learning_rate: f64,
50-
/// Number of iterations for MLE
70+
/// Number of iterations
5171
iterations: usize,
5272
},
5373

@@ -78,10 +98,16 @@ impl Default for AggregationModel {
7898
pub struct CandidateStats {
7999
/// Sum of all ratings received
80100
pub rating_sum: f64,
101+
/// Sum of squared ratings (for variance calculation)
102+
#[serde(default)]
103+
pub rating_sum_squares: f64,
81104
/// Count of ratings received
82105
pub rating_count: usize,
83106
/// Current model-based score (Elo, Bradley-Terry strength, etc.)
84107
pub model_score: f64,
108+
/// Variance of the model score (for uncertainty quantification)
109+
#[serde(default = "default_variance")]
110+
pub model_variance: f64,
85111
/// Number of wins in pairwise comparisons
86112
pub wins: usize,
87113
/// Number of losses in pairwise comparisons
@@ -94,11 +120,16 @@ pub struct CandidateStats {
94120
pub times_passed: usize,
95121
}
96122

123+
fn default_variance() -> f64 {
124+
f64::INFINITY
125+
}
126+
97127
impl CandidateStats {
98128
/// Create new stats with the given initial model score
99129
pub fn new(initial_score: f64) -> Self {
100130
Self {
101131
model_score: initial_score,
132+
model_variance: f64::INFINITY,
102133
..Default::default()
103134
}
104135
}
@@ -112,6 +143,25 @@ impl CandidateStats {
112143
}
113144
}
114145

146+
/// Get the sample variance of ratings
147+
pub fn rating_variance(&self) -> Option<f64> {
148+
if self.rating_count < 2 {
149+
return None;
150+
}
151+
let n = self.rating_count as f64;
152+
let mean = self.rating_sum / n;
153+
// Var = E[X²] - E[X]²
154+
let var = (self.rating_sum_squares / n) - (mean * mean);
155+
// Convert to sample variance (Bessel's correction)
156+
Some(var * n / (n - 1.0))
157+
}
158+
159+
/// Get the variance of the mean (standard error squared)
160+
pub fn rating_variance_of_mean(&self) -> Option<f64> {
161+
self.rating_variance()
162+
.map(|var| var / self.rating_count as f64)
163+
}
164+
115165
/// Get total number of comparisons
116166
pub fn total_comparisons(&self) -> usize {
117167
self.wins + self.losses + self.ties
@@ -192,6 +242,9 @@ impl FitnessAggregator {
192242
AggregationModel::BradleyTerry {
193243
initial_strength, ..
194244
} => *initial_strength,
245+
AggregationModel::BradleyTerrySimple {
246+
initial_strength, ..
247+
} => *initial_strength,
195248
AggregationModel::ImplicitRanking { base_fitness, .. } => *base_fitness,
196249
};
197250
self.candidate_stats
@@ -204,7 +257,9 @@ impl FitnessAggregator {
204257
self.candidate_stats.get(id)
205258
}
206259

207-
/// Get current fitness estimate for a candidate
260+
/// Get current fitness estimate for a candidate (point estimate only)
261+
///
262+
/// For uncertainty information, use `get_fitness_estimate()` instead.
208263
pub fn get_fitness(&self, id: &CandidateId) -> Option<f64> {
209264
let stats = self.candidate_stats.get(id)?;
210265

@@ -214,18 +269,81 @@ impl FitnessAggregator {
214269
}
215270
AggregationModel::Elo { .. } => stats.model_score,
216271
AggregationModel::BradleyTerry { .. } => stats.model_score,
272+
AggregationModel::BradleyTerrySimple { .. } => stats.model_score,
217273
AggregationModel::ImplicitRanking { .. } => {
218274
// Score is base + cumulative bonuses/penalties
219275
stats.model_score
220276
}
221277
})
222278
}
223279

280+
/// Get fitness estimate with uncertainty quantification
281+
///
282+
/// Returns a `FitnessEstimate` containing the point estimate, variance,
283+
/// and confidence intervals.
284+
pub fn get_fitness_estimate(&self, id: &CandidateId) -> Option<FitnessEstimate> {
285+
let stats = self.candidate_stats.get(id)?;
286+
287+
Some(match &self.model {
288+
AggregationModel::DirectRating { default_rating } => {
289+
if stats.rating_count == 0 {
290+
FitnessEstimate::uninformative(*default_rating)
291+
} else {
292+
let mean = stats.rating_sum / stats.rating_count as f64;
293+
let variance = stats.rating_variance_of_mean().unwrap_or(f64::INFINITY);
294+
FitnessEstimate::new(mean, variance, stats.rating_count)
295+
}
296+
}
297+
AggregationModel::Elo { k_factor, .. } => {
298+
// Elo variance approximation based on K-factor and game count
299+
let n_games = stats.total_comparisons();
300+
let variance = if n_games == 0 {
301+
f64::INFINITY
302+
} else {
303+
// Approximate variance: decreases with games, proportional to K²
304+
let base_var = k_factor * k_factor * 0.25; // Bernoulli variance factor
305+
base_var / n_games as f64
306+
};
307+
FitnessEstimate::new(stats.model_score, variance, n_games)
308+
}
309+
AggregationModel::BradleyTerry { .. } | AggregationModel::BradleyTerrySimple { .. } => {
310+
// Use stored variance from MLE computation
311+
let n_comparisons = stats.total_comparisons();
312+
let variance = if stats.model_variance.is_finite() {
313+
stats.model_variance
314+
} else if n_comparisons == 0 {
315+
f64::INFINITY
316+
} else {
317+
// Fallback: approximate variance
318+
1.0 / n_comparisons as f64
319+
};
320+
FitnessEstimate::new(stats.model_score, variance, n_comparisons)
321+
}
322+
AggregationModel::ImplicitRanking { .. } => {
323+
// Binomial variance on selection rate
324+
let n = stats.times_selected + stats.times_passed;
325+
if n == 0 {
326+
FitnessEstimate::uninformative(stats.model_score)
327+
} else {
328+
let p = stats.times_selected as f64 / n as f64;
329+
let variance = p * (1.0 - p) / n as f64;
330+
FitnessEstimate::new(stats.model_score, variance, n)
331+
}
332+
}
333+
})
334+
}
335+
336+
/// Get access to comparison records (for Bradley-Terry MLE)
337+
pub fn comparisons(&self) -> &[ComparisonRecord] {
338+
&self.comparisons
339+
}
340+
224341
/// Record a rating for a candidate
225342
pub fn record_rating(&mut self, id: CandidateId, rating: f64) {
226343
self.ensure_stats(id);
227344
if let Some(stats) = self.candidate_stats.get_mut(&id) {
228345
stats.rating_sum += rating;
346+
stats.rating_sum_squares += rating * rating;
229347
stats.rating_count += 1;
230348
}
231349
}
@@ -384,13 +502,18 @@ impl FitnessAggregator {
384502
/// This is useful for Bradley-Terry model which uses batch MLE,
385503
/// or after loading a session from checkpoint.
386504
pub fn recompute_all(&mut self) -> HashMap<CandidateId, f64> {
387-
if let AggregationModel::BradleyTerry {
388-
initial_strength,
389-
learning_rate,
390-
iterations,
391-
} = &self.model
392-
{
393-
self.recompute_bradley_terry(*initial_strength, *learning_rate, *iterations);
505+
match &self.model {
506+
AggregationModel::BradleyTerry { optimizer, .. } => {
507+
self.recompute_bradley_terry_mle(optimizer.clone());
508+
}
509+
AggregationModel::BradleyTerrySimple {
510+
initial_strength,
511+
learning_rate,
512+
iterations,
513+
} => {
514+
self.recompute_bradley_terry_simple(*initial_strength, *learning_rate, *iterations);
515+
}
516+
_ => {}
394517
}
395518

396519
// Return current fitness estimates
@@ -400,8 +523,33 @@ impl FitnessAggregator {
400523
.collect()
401524
}
402525

403-
/// Recompute Bradley-Terry strength parameters using MLE
404-
fn recompute_bradley_terry(
526+
/// Recompute Bradley-Terry using proper MLE (Newton-Raphson or MM)
527+
fn recompute_bradley_terry_mle(&mut self, optimizer: BradleyTerryOptimizer) {
528+
let ids: Vec<CandidateId> = self.candidate_stats.keys().copied().collect();
529+
if ids.is_empty() || self.comparisons.is_empty() {
530+
return;
531+
}
532+
533+
let model = BradleyTerryModel::new(optimizer);
534+
let result = model.fit(&self.comparisons, &ids);
535+
536+
// Update stats with MLE results
537+
for (&id, &strength) in &result.strengths {
538+
if let Some(stats) = self.candidate_stats.get_mut(&id) {
539+
stats.model_score = strength;
540+
541+
// Update variance from covariance matrix
542+
if let Some(&idx) = result.id_to_index.get(&id) {
543+
if idx < result.covariance.nrows() {
544+
stats.model_variance = result.covariance[(idx, idx)];
545+
}
546+
}
547+
}
548+
}
549+
}
550+
551+
/// Recompute Bradley-Terry using simplified iterative MM (legacy)
552+
fn recompute_bradley_terry_simple(
405553
&mut self,
406554
initial_strength: f64,
407555
learning_rate: f64,
@@ -687,8 +835,8 @@ mod tests {
687835
}
688836

689837
#[test]
690-
fn test_bradley_terry_recompute() {
691-
let mut agg = FitnessAggregator::new(AggregationModel::BradleyTerry {
838+
fn test_bradley_terry_simple_recompute() {
839+
let mut agg = FitnessAggregator::new(AggregationModel::BradleyTerrySimple {
692840
initial_strength: 1.0,
693841
learning_rate: 0.5,
694842
iterations: 10,
@@ -711,6 +859,62 @@ mod tests {
711859
assert!(fitness[&CandidateId(1)] > fitness[&CandidateId(2)]);
712860
}
713861

862+
#[test]
863+
fn test_bradley_terry_mle_recompute() {
864+
use crate::interactive::bradley_terry::BradleyTerryOptimizer;
865+
866+
let mut agg = FitnessAggregator::new(AggregationModel::BradleyTerry {
867+
initial_strength: 1.0,
868+
optimizer: BradleyTerryOptimizer::default(),
869+
});
870+
871+
// A beats B multiple times, B beats C
872+
agg.ensure_stats(CandidateId(0));
873+
agg.ensure_stats(CandidateId(1));
874+
agg.ensure_stats(CandidateId(2));
875+
876+
agg.record_comparison(CandidateId(0), CandidateId(1));
877+
agg.record_comparison(CandidateId(0), CandidateId(1));
878+
agg.record_comparison(CandidateId(1), CandidateId(2));
879+
880+
let fitness = agg.recompute_all();
881+
882+
// A should have highest strength
883+
assert!(fitness[&CandidateId(0)] > fitness[&CandidateId(1)]);
884+
// B should beat C
885+
assert!(fitness[&CandidateId(1)] > fitness[&CandidateId(2)]);
886+
887+
// MLE should also provide variance estimates
888+
let estimate_a = agg.get_fitness_estimate(&CandidateId(0)).unwrap();
889+
assert!(estimate_a.variance.is_finite());
890+
assert!(estimate_a.observation_count > 0);
891+
}
892+
893+
#[test]
894+
fn test_fitness_estimate_direct_rating() {
895+
let mut agg = FitnessAggregator::new(AggregationModel::DirectRating {
896+
default_rating: 5.0,
897+
});
898+
899+
let id = CandidateId(0);
900+
agg.ensure_stats(id);
901+
902+
// Initially should be uninformative
903+
let estimate = agg.get_fitness_estimate(&id).unwrap();
904+
assert_eq!(estimate.mean, 5.0);
905+
assert!(estimate.variance.is_infinite());
906+
907+
// After ratings, should have finite variance
908+
agg.record_rating(id, 8.0);
909+
agg.record_rating(id, 6.0);
910+
agg.record_rating(id, 7.0);
911+
912+
let estimate = agg.get_fitness_estimate(&id).unwrap();
913+
assert_eq!(estimate.mean, 7.0);
914+
assert!(estimate.variance.is_finite());
915+
assert_eq!(estimate.observation_count, 3);
916+
}
917+
714918
#[test]
715919
fn test_candidate_stats() {
716920
let mut stats = CandidateStats::new(1500.0);

0 commit comments

Comments
 (0)