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
1318use serde:: { Deserialize , Serialize } ;
1419use std:: collections:: HashMap ;
1520
21+ use super :: bradley_terry:: { BradleyTerryModel , BradleyTerryOptimizer } ;
1622use 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 {
7898pub 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+
97127impl 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