1use nalgebra::{DMatrix, DVector};
37use rand::prelude::*;
38use serde::{Deserialize, Serialize};
39use std::collections::HashMap;
40
41use super::aggregation::ComparisonRecord;
42use super::evaluator::CandidateId;
43use super::uncertainty::FitnessEstimate;
44
45struct FitContext<'a> {
49 comparisons: &'a [ComparisonRecord],
50 candidate_ids: &'a [CandidateId],
51 id_to_index: HashMap<CandidateId, usize>,
52 n: usize,
53}
54
55impl<'a> FitContext<'a> {
56 fn new(comparisons: &'a [ComparisonRecord], candidate_ids: &'a [CandidateId]) -> Self {
57 let id_to_index: HashMap<CandidateId, usize> = candidate_ids
58 .iter()
59 .enumerate()
60 .map(|(i, &id)| (id, i))
61 .collect();
62 let n = candidate_ids.len();
63 Self {
64 comparisons,
65 candidate_ids,
66 id_to_index,
67 n,
68 }
69 }
70}
71
72#[derive(Clone, Debug, Serialize, Deserialize)]
74pub enum BradleyTerryOptimizer {
75 NewtonRaphson {
80 max_iterations: usize,
82 tolerance: f64,
84 regularization: f64,
86 },
87
88 MM {
93 max_iterations: usize,
95 tolerance: f64,
97 bootstrap_samples: usize,
99 },
100}
101
102impl Default for BradleyTerryOptimizer {
103 fn default() -> Self {
104 Self::NewtonRaphson {
105 max_iterations: 100,
106 tolerance: 1e-6, regularization: 1e-6,
108 }
109 }
110}
111
112impl BradleyTerryOptimizer {
113 pub fn newton_raphson(max_iterations: usize, tolerance: f64, regularization: f64) -> Self {
115 Self::NewtonRaphson {
116 max_iterations,
117 tolerance,
118 regularization,
119 }
120 }
121
122 pub fn mm(max_iterations: usize, tolerance: f64, bootstrap_samples: usize) -> Self {
124 Self::MM {
125 max_iterations,
126 tolerance,
127 bootstrap_samples,
128 }
129 }
130}
131
132#[derive(Clone, Debug)]
134pub struct BradleyTerryResult {
135 pub strengths: HashMap<CandidateId, f64>,
137 pub covariance: DMatrix<f64>,
139 pub id_to_index: HashMap<CandidateId, usize>,
141 pub log_likelihood: f64,
143 pub iterations: usize,
145 pub converged: bool,
147 pub convergence_metric: f64,
149}
150
151impl BradleyTerryResult {
152 pub fn get_estimate(&self, id: CandidateId) -> Option<FitnessEstimate> {
154 let strength = *self.strengths.get(&id)?;
155 let idx = *self.id_to_index.get(&id)?;
156
157 let variance = if idx < self.covariance.nrows() {
159 self.covariance[(idx, idx)]
160 } else {
161 f64::INFINITY
162 };
163
164 let observation_count = self.strengths.len(); Some(FitnessEstimate::new(strength, variance, observation_count))
168 }
169
170 pub fn all_estimates(&self) -> HashMap<CandidateId, FitnessEstimate> {
172 self.strengths
173 .keys()
174 .filter_map(|&id| self.get_estimate(id).map(|e| (id, e)))
175 .collect()
176 }
177
178 pub fn predict_win_probability(&self, a: CandidateId, b: CandidateId) -> Option<f64> {
180 let pa = self.strengths.get(&a)?;
181 let pb = self.strengths.get(&b)?;
182 Some(pa / (pa + pb))
183 }
184}
185
186pub struct BradleyTerryModel {
188 optimizer: BradleyTerryOptimizer,
189}
190
191impl BradleyTerryModel {
192 pub fn new(optimizer: BradleyTerryOptimizer) -> Self {
194 Self { optimizer }
195 }
196
197 pub fn fit(
208 &self,
209 comparisons: &[ComparisonRecord],
210 candidate_ids: &[CandidateId],
211 ) -> BradleyTerryResult {
212 if candidate_ids.is_empty() || comparisons.is_empty() {
213 return self.empty_result(candidate_ids);
214 }
215
216 let ctx = FitContext::new(comparisons, candidate_ids);
217
218 match &self.optimizer {
219 BradleyTerryOptimizer::NewtonRaphson {
220 max_iterations,
221 tolerance,
222 regularization,
223 } => self.fit_newton_raphson(&ctx, *max_iterations, *tolerance, *regularization),
224 BradleyTerryOptimizer::MM {
225 max_iterations,
226 tolerance,
227 bootstrap_samples,
228 } => self.fit_mm(&ctx, *max_iterations, *tolerance, *bootstrap_samples),
229 }
230 }
231
232 fn empty_result(&self, candidate_ids: &[CandidateId]) -> BradleyTerryResult {
234 let n = candidate_ids.len();
235 let strengths: HashMap<CandidateId, f64> =
236 candidate_ids.iter().map(|&id| (id, 1.0)).collect();
237 let id_to_index: HashMap<CandidateId, usize> = candidate_ids
238 .iter()
239 .enumerate()
240 .map(|(i, &id)| (id, i))
241 .collect();
242
243 BradleyTerryResult {
244 strengths,
245 covariance: DMatrix::from_diagonal_element(n, n, f64::INFINITY),
246 id_to_index,
247 log_likelihood: 0.0,
248 iterations: 0,
249 converged: true,
250 convergence_metric: 0.0,
251 }
252 }
253
254 fn fit_newton_raphson(
259 &self,
260 ctx: &FitContext,
261 max_iterations: usize,
262 tolerance: f64,
263 regularization: f64,
264 ) -> BradleyTerryResult {
265 let n = ctx.n;
266 let comparisons = ctx.comparisons;
267 let candidate_ids = ctx.candidate_ids;
268 let id_to_index = &ctx.id_to_index;
269 let mut theta = DVector::zeros(n);
271
272 let mut wins = vec![0usize; n];
274 for comp in comparisons {
275 if let Some(&idx) = id_to_index.get(&comp.winner) {
276 wins[idx] += 1;
277 }
278 }
279
280 let mut converged = false;
281 let mut iterations = 0;
282 let mut gradient_norm = f64::INFINITY;
283
284 for iter in 0..max_iterations {
285 iterations = iter + 1;
286
287 let mut gradient = DVector::zeros(n);
289 let mut hessian = DMatrix::zeros(n, n);
290
291 for comp in comparisons {
296 let i = match id_to_index.get(&comp.winner) {
297 Some(&idx) => idx,
298 None => continue,
299 };
300 let j = match id_to_index.get(&comp.loser) {
301 Some(&idx) => idx,
302 None => continue,
303 };
304
305 let diff = theta[i] - theta[j];
307 let p = sigmoid(diff);
308 let q = 1.0 - p; gradient[i] += q; gradient[j] -= q; let h = p * q;
316 hessian[(i, i)] -= h;
317 hessian[(j, j)] -= h;
318 hessian[(i, j)] += h;
319 hessian[(j, i)] += h;
320 }
321
322 for i in 0..n {
324 hessian[(i, i)] -= regularization;
325 }
326
327 gradient_norm = gradient.norm();
329 if gradient_norm < tolerance {
330 converged = true;
331 break;
332 }
333
334 let neg_hessian = -&hessian;
337 let delta = match neg_hessian.clone().lu().solve(&gradient) {
338 Some(d) => d,
339 None => {
340 let mut reg_hessian = neg_hessian;
342 for i in 0..n {
343 reg_hessian[(i, i)] += regularization * 10.0;
344 }
345 match reg_hessian.lu().solve(&gradient) {
346 Some(d) => d,
347 None => break, }
349 }
350 };
351
352 let mut step_size = 1.0;
354 let current_ll = self.log_likelihood(&theta, comparisons, id_to_index);
355
356 for _ in 0..10 {
357 let new_theta = &theta + step_size * δ
358 let new_ll = self.log_likelihood(&new_theta, comparisons, id_to_index);
359
360 if new_ll > current_ll - 1e-4 * step_size * gradient.dot(&delta) {
361 theta = new_theta;
362 break;
363 }
364 step_size *= 0.5;
365 }
366
367 let mean_theta = theta.mean();
369 theta -= DVector::from_element(n, mean_theta);
370 }
371
372 let strengths: HashMap<CandidateId, f64> = candidate_ids
374 .iter()
375 .enumerate()
376 .map(|(i, &id)| (id, theta[i].exp()))
377 .collect();
378
379 let mut final_hessian = DMatrix::zeros(n, n);
381 for comp in comparisons {
382 let i = match id_to_index.get(&comp.winner) {
383 Some(&idx) => idx,
384 None => continue,
385 };
386 let j = match id_to_index.get(&comp.loser) {
387 Some(&idx) => idx,
388 None => continue,
389 };
390
391 let p = sigmoid(theta[i] - theta[j]);
392 let h = p * (1.0 - p);
393
394 final_hessian[(i, i)] -= h;
395 final_hessian[(j, j)] -= h;
396 final_hessian[(i, j)] += h;
397 final_hessian[(j, i)] += h;
398 }
399
400 for i in 0..n {
402 final_hessian[(i, i)] -= regularization;
403 }
404
405 let covariance = match (-&final_hessian).clone().try_inverse() {
407 Some(inv) => inv,
408 None => DMatrix::from_diagonal_element(n, n, f64::INFINITY),
409 };
410
411 let log_likelihood = self.log_likelihood(&theta, comparisons, id_to_index);
412
413 BradleyTerryResult {
414 strengths,
415 covariance,
416 id_to_index: id_to_index.clone(),
417 log_likelihood,
418 iterations,
419 converged,
420 convergence_metric: gradient_norm,
421 }
422 }
423
424 fn fit_mm(
426 &self,
427 ctx: &FitContext,
428 max_iterations: usize,
429 tolerance: f64,
430 bootstrap_samples: usize,
431 ) -> BradleyTerryResult {
432 let n = ctx.n;
433 let comparisons = ctx.comparisons;
434 let candidate_ids = ctx.candidate_ids;
435 let id_to_index = &ctx.id_to_index;
436
437 let (pi, iterations, converged, max_change) =
439 self.mm_core(comparisons, id_to_index, n, max_iterations, tolerance);
440
441 let covariance =
443 self.bootstrap_covariance(ctx, max_iterations, tolerance, bootstrap_samples, &pi);
444
445 let strengths: HashMap<CandidateId, f64> = candidate_ids
447 .iter()
448 .enumerate()
449 .map(|(i, &id)| (id, pi[i]))
450 .collect();
451
452 let theta: DVector<f64> = pi.iter().map(|&p| p.ln()).collect::<Vec<_>>().into();
454 let log_likelihood = self.log_likelihood(&theta, comparisons, id_to_index);
455
456 BradleyTerryResult {
457 strengths,
458 covariance,
459 id_to_index: id_to_index.clone(),
460 log_likelihood,
461 iterations,
462 converged,
463 convergence_metric: max_change,
464 }
465 }
466
467 fn mm_core(
469 &self,
470 comparisons: &[ComparisonRecord],
471 id_to_index: &HashMap<CandidateId, usize>,
472 n: usize,
473 max_iterations: usize,
474 tolerance: f64,
475 ) -> (Vec<f64>, usize, bool, f64) {
476 let mut pi = vec![1.0; n];
478
479 let mut wins = vec![0usize; n];
481 for comp in comparisons {
482 if let Some(&idx) = id_to_index.get(&comp.winner) {
483 wins[idx] += 1;
484 }
485 }
486
487 let mut converged = false;
488 let mut iterations = 0;
489 let mut max_change = f64::INFINITY;
490
491 for iter in 0..max_iterations {
492 iterations = iter + 1;
493 let mut pi_new = vec![0.0; n];
494
495 for i in 0..n {
496 if wins[i] == 0 {
497 pi_new[i] = 0.01;
499 continue;
500 }
501
502 let mut denom = 0.0;
504 for comp in comparisons {
505 let w_idx = id_to_index.get(&comp.winner).copied();
506 let l_idx = id_to_index.get(&comp.loser).copied();
507
508 match (w_idx, l_idx) {
509 (Some(wi), Some(li)) if wi == i || li == i => {
510 let other = if wi == i { li } else { wi };
511 denom += 1.0 / (pi[i] + pi[other]);
512 }
513 _ => {}
514 }
515 }
516
517 if denom > 0.0 {
518 pi_new[i] = wins[i] as f64 / denom;
519 } else {
520 pi_new[i] = pi[i]; }
522 }
523
524 let sum: f64 = pi_new.iter().sum();
526 if sum > 0.0 {
527 for p in &mut pi_new {
528 *p *= n as f64 / sum;
529 }
530 }
531
532 max_change = pi
534 .iter()
535 .zip(pi_new.iter())
536 .map(|(a, b)| (a - b).abs())
537 .fold(0.0, f64::max);
538
539 if max_change < tolerance {
540 converged = true;
541 pi = pi_new;
542 break;
543 }
544
545 pi = pi_new;
546 }
547
548 (pi, iterations, converged, max_change)
549 }
550
551 fn bootstrap_covariance(
553 &self,
554 ctx: &FitContext,
555 max_iterations: usize,
556 tolerance: f64,
557 bootstrap_samples: usize,
558 point_estimate: &[f64],
559 ) -> DMatrix<f64> {
560 let n = ctx.n;
561 let comparisons = ctx.comparisons;
562 let id_to_index = &ctx.id_to_index;
563
564 if bootstrap_samples == 0 || comparisons.is_empty() {
565 return DMatrix::from_diagonal_element(n, n, f64::INFINITY);
566 }
567
568 let mut rng = rand::thread_rng();
569 let mut bootstrap_estimates: Vec<Vec<f64>> = Vec::with_capacity(bootstrap_samples);
570
571 for _ in 0..bootstrap_samples {
572 let resampled: Vec<ComparisonRecord> = (0..comparisons.len())
574 .map(|_| comparisons[rng.gen_range(0..comparisons.len())].clone())
575 .collect();
576
577 let (pi, _, _, _) = self.mm_core(&resampled, id_to_index, n, max_iterations, tolerance);
579 bootstrap_estimates.push(pi);
580 }
581
582 let mut covariance = DMatrix::zeros(n, n);
584
585 for i in 0..n {
586 for j in 0..n {
587 let mean_i = point_estimate[i];
588 let mean_j = point_estimate[j];
589
590 let cov: f64 = bootstrap_estimates
591 .iter()
592 .map(|est| (est[i] - mean_i) * (est[j] - mean_j))
593 .sum::<f64>()
594 / (bootstrap_samples - 1).max(1) as f64;
595
596 covariance[(i, j)] = cov;
597 }
598 }
599
600 covariance
601 }
602
603 fn log_likelihood(
605 &self,
606 theta: &DVector<f64>,
607 comparisons: &[ComparisonRecord],
608 id_to_index: &HashMap<CandidateId, usize>,
609 ) -> f64 {
610 let mut ll = 0.0;
611
612 for comp in comparisons {
613 let i = match id_to_index.get(&comp.winner) {
614 Some(&idx) => idx,
615 None => continue,
616 };
617 let j = match id_to_index.get(&comp.loser) {
618 Some(&idx) => idx,
619 None => continue,
620 };
621
622 let diff = theta[i] - theta[j];
624 ll += log_sigmoid(diff);
625 }
626
627 ll
628 }
629}
630
631fn sigmoid(x: f64) -> f64 {
633 if x >= 0.0 {
634 1.0 / (1.0 + (-x).exp())
635 } else {
636 let ex = x.exp();
637 ex / (1.0 + ex)
638 }
639}
640
641fn log_sigmoid(x: f64) -> f64 {
643 if x >= 0.0 {
644 -(-x).exp().ln_1p()
645 } else {
646 x - x.exp().ln_1p()
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653
654 fn make_comparisons(pairs: &[(usize, usize)]) -> Vec<ComparisonRecord> {
655 pairs
656 .iter()
657 .map(|&(w, l)| ComparisonRecord {
658 winner: CandidateId(w),
659 loser: CandidateId(l),
660 generation: 0,
661 })
662 .collect()
663 }
664
665 #[test]
666 fn test_newton_raphson_basic() {
667 let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
669 let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
670
671 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
672 let result = model.fit(&comparisons, &candidate_ids);
673
674 assert!(result.converged);
675
676 let pa = result.strengths[&CandidateId(0)];
678 let pb = result.strengths[&CandidateId(1)];
679 let pc = result.strengths[&CandidateId(2)];
680
681 assert!(pa > pb);
682 assert!(pb > pc);
683 }
684
685 #[test]
686 fn test_mm_basic() {
687 let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
688 let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
689
690 let model = BradleyTerryModel::new(BradleyTerryOptimizer::mm(100, 1e-8, 50));
691 let result = model.fit(&comparisons, &candidate_ids);
692
693 assert!(result.converged);
694
695 let pa = result.strengths[&CandidateId(0)];
696 let pb = result.strengths[&CandidateId(1)];
697 let pc = result.strengths[&CandidateId(2)];
698
699 assert!(pa > pb);
700 assert!(pb > pc);
701 }
702
703 #[test]
704 fn test_newton_raphson_and_mm_agree() {
705 let comparisons = make_comparisons(&[
706 (0, 1),
707 (0, 2),
708 (1, 2),
709 (0, 1),
710 (1, 0),
711 (2, 1),
712 (0, 2),
713 (0, 2),
714 ]);
715 let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
716
717 let nr_model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
718 let mm_model = BradleyTerryModel::new(BradleyTerryOptimizer::mm(100, 1e-8, 0));
719
720 let nr_result = nr_model.fit(&comparisons, &candidate_ids);
721 let mm_result = mm_model.fit(&comparisons, &candidate_ids);
722
723 let nr_ranking: Vec<_> = {
725 let mut r: Vec<_> = candidate_ids
726 .iter()
727 .map(|&id| (id, nr_result.strengths[&id]))
728 .collect();
729 r.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
730 r.into_iter().map(|(id, _)| id).collect()
731 };
732
733 let mm_ranking: Vec<_> = {
734 let mut r: Vec<_> = candidate_ids
735 .iter()
736 .map(|&id| (id, mm_result.strengths[&id]))
737 .collect();
738 r.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
739 r.into_iter().map(|(id, _)| id).collect()
740 };
741
742 assert_eq!(nr_ranking, mm_ranking);
743 }
744
745 #[test]
746 fn test_get_estimate() {
747 let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1)]);
748 let candidate_ids = vec![CandidateId(0), CandidateId(1)];
749
750 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
751 let result = model.fit(&comparisons, &candidate_ids);
752
753 let estimate = result.get_estimate(CandidateId(0)).unwrap();
754 assert!(estimate.variance < f64::INFINITY);
755 assert!(estimate.variance > 0.0);
756 }
757
758 #[test]
759 fn test_predict_win_probability() {
760 let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1), (0, 1)]);
761 let candidate_ids = vec![CandidateId(0), CandidateId(1)];
762
763 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
764 let result = model.fit(&comparisons, &candidate_ids);
765
766 let p = result
767 .predict_win_probability(CandidateId(0), CandidateId(1))
768 .unwrap();
769 assert!(p > 0.5); assert!(p < 1.0);
771 }
772
773 #[test]
774 fn test_empty_comparisons() {
775 let comparisons: Vec<ComparisonRecord> = vec![];
776 let candidate_ids = vec![CandidateId(0), CandidateId(1)];
777
778 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
779 let result = model.fit(&comparisons, &candidate_ids);
780
781 assert!(result.converged);
783 assert!(result.covariance[(0, 0)].is_infinite());
784 }
785
786 #[test]
787 fn test_sigmoid() {
788 assert!((sigmoid(0.0) - 0.5).abs() < 1e-9);
789 assert!(sigmoid(100.0) > 0.999);
790 assert!(sigmoid(-100.0) < 0.001);
791
792 for x in [-5.0, -1.0, 0.0, 1.0, 5.0] {
794 assert!((sigmoid(-x) - (1.0 - sigmoid(x))).abs() < 1e-9);
795 }
796 }
797
798 #[test]
799 fn test_log_sigmoid() {
800 for x in [-5.0, -1.0, 0.0, 1.0, 5.0] {
802 assert!(log_sigmoid(x) <= 0.0);
803 assert!((log_sigmoid(x).exp() - sigmoid(x)).abs() < 1e-9);
804 }
805 }
806
807 #[test]
808 fn test_covariance_positive_semidefinite() {
809 let comparisons = make_comparisons(&[(0, 1), (0, 2), (1, 2), (0, 1), (1, 2), (0, 2)]);
810 let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
811
812 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
813 let result = model.fit(&comparisons, &candidate_ids);
814
815 for i in 0..3 {
817 assert!(result.covariance[(i, i)] >= 0.0);
818 }
819 }
820}