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 #[serde(alias = "regularization")]
94 prior_lambda: f64,
95 },
96
97 MM {
102 max_iterations: usize,
104 tolerance: f64,
106 bootstrap_samples: usize,
108 },
109}
110
111impl Default for BradleyTerryOptimizer {
112 fn default() -> Self {
113 Self::NewtonRaphson {
114 max_iterations: 100,
115 tolerance: 1e-6, prior_lambda: 0.1,
117 }
118 }
119}
120
121impl BradleyTerryOptimizer {
122 pub fn newton_raphson(max_iterations: usize, tolerance: f64, prior_lambda: f64) -> Self {
129 Self::NewtonRaphson {
130 max_iterations,
131 tolerance,
132 prior_lambda,
133 }
134 }
135
136 pub fn mm(max_iterations: usize, tolerance: f64, bootstrap_samples: usize) -> Self {
138 Self::MM {
139 max_iterations,
140 tolerance,
141 bootstrap_samples,
142 }
143 }
144}
145
146#[derive(Clone, Debug)]
162pub struct BradleyTerryResult {
163 pub strengths: HashMap<CandidateId, f64>,
166 pub covariance: DMatrix<f64>,
168 pub id_to_index: HashMap<CandidateId, usize>,
170 pub log_likelihood: f64,
172 pub iterations: usize,
174 pub converged: bool,
176 pub convergence_metric: f64,
178}
179
180impl BradleyTerryResult {
181 pub fn get_estimate(&self, id: CandidateId) -> Option<FitnessEstimate> {
183 let strength = *self.strengths.get(&id)?;
184 let idx = *self.id_to_index.get(&id)?;
185
186 let variance = if idx < self.covariance.nrows() {
188 self.covariance[(idx, idx)]
189 } else {
190 f64::INFINITY
191 };
192
193 let observation_count = self.strengths.len(); Some(FitnessEstimate::new(strength, variance, observation_count))
197 }
198
199 pub fn all_estimates(&self) -> HashMap<CandidateId, FitnessEstimate> {
201 self.strengths
202 .keys()
203 .filter_map(|&id| self.get_estimate(id).map(|e| (id, e)))
204 .collect()
205 }
206
207 pub fn predict_win_probability(&self, a: CandidateId, b: CandidateId) -> Option<f64> {
209 let pa = self.strengths.get(&a)?;
210 let pb = self.strengths.get(&b)?;
211 Some(pa / (pa + pb))
212 }
213}
214
215pub struct BradleyTerryModel {
217 optimizer: BradleyTerryOptimizer,
218}
219
220impl BradleyTerryModel {
221 pub fn new(optimizer: BradleyTerryOptimizer) -> Self {
223 Self { optimizer }
224 }
225
226 pub fn fit(
237 &self,
238 comparisons: &[ComparisonRecord],
239 candidate_ids: &[CandidateId],
240 ) -> BradleyTerryResult {
241 if candidate_ids.is_empty() || comparisons.is_empty() {
242 return self.empty_result(candidate_ids);
243 }
244
245 let ctx = FitContext::new(comparisons, candidate_ids);
246
247 match &self.optimizer {
248 BradleyTerryOptimizer::NewtonRaphson {
249 max_iterations,
250 tolerance,
251 prior_lambda,
252 } => self.fit_newton_raphson(&ctx, *max_iterations, *tolerance, *prior_lambda),
253 BradleyTerryOptimizer::MM {
254 max_iterations,
255 tolerance,
256 bootstrap_samples,
257 } => self.fit_mm(&ctx, *max_iterations, *tolerance, *bootstrap_samples),
258 }
259 }
260
261 fn empty_result(&self, candidate_ids: &[CandidateId]) -> BradleyTerryResult {
263 let n = candidate_ids.len();
264 let strengths: HashMap<CandidateId, f64> =
265 candidate_ids.iter().map(|&id| (id, 1.0)).collect();
266 let id_to_index: HashMap<CandidateId, usize> = candidate_ids
267 .iter()
268 .enumerate()
269 .map(|(i, &id)| (id, i))
270 .collect();
271
272 BradleyTerryResult {
273 strengths,
274 covariance: DMatrix::from_diagonal_element(n, n, f64::INFINITY),
275 id_to_index,
276 log_likelihood: 0.0,
277 iterations: 0,
278 converged: true,
279 convergence_metric: 0.0,
280 }
281 }
282
283 fn fit_newton_raphson(
293 &self,
294 ctx: &FitContext,
295 max_iterations: usize,
296 tolerance: f64,
297 prior_lambda: f64,
298 ) -> BradleyTerryResult {
299 let n = ctx.n;
300 let comparisons = ctx.comparisons;
301 let candidate_ids = ctx.candidate_ids;
302 let id_to_index = &ctx.id_to_index;
303 let mut theta = DVector::zeros(n);
305
306 let mut converged = false;
307 let mut iterations = 0;
308 let mut gradient_norm = f64::INFINITY;
309
310 for iter in 0..max_iterations {
311 iterations = iter + 1;
312
313 let mut gradient = DVector::zeros(n);
315 let mut hessian = DMatrix::zeros(n, n);
316
317 for comp in comparisons {
318 let i = match id_to_index.get(&comp.winner) {
319 Some(&idx) => idx,
320 None => continue,
321 };
322 let j = match id_to_index.get(&comp.loser) {
323 Some(&idx) => idx,
324 None => continue,
325 };
326
327 let diff = theta[i] - theta[j];
329 let p = sigmoid(diff);
330 let q = 1.0 - p; gradient[i] += q; gradient[j] -= q; let h = p * q;
338 hessian[(i, i)] -= h;
339 hessian[(j, j)] -= h;
340 hessian[(i, j)] += h;
341 hessian[(j, i)] += h;
342 }
343
344 if prior_lambda > 0.0 {
348 for i in 0..n {
349 gradient[i] -= prior_lambda * theta[i];
350 hessian[(i, i)] -= prior_lambda;
351 }
352 }
353
354 gradient_norm = gradient.norm();
356 if gradient_norm < tolerance {
357 converged = true;
358 break;
359 }
360
361 let neg_hessian = -&hessian;
364 let delta = match neg_hessian.clone().lu().solve(&gradient) {
365 Some(d) => d,
366 None => {
367 let mut reg_hessian = neg_hessian;
369 let nudge = if prior_lambda > 0.0 {
370 prior_lambda
371 } else {
372 1e-6
373 };
374 for i in 0..n {
375 reg_hessian[(i, i)] += nudge;
376 }
377 match reg_hessian.lu().solve(&gradient) {
378 Some(d) => d,
379 None => break, }
381 }
382 };
383
384 let (new_theta, _backtracks) = self.backtracking_line_search(
388 &theta,
389 &delta,
390 &gradient,
391 comparisons,
392 id_to_index,
393 prior_lambda,
394 );
395 theta = new_theta;
396
397 let mean_theta = theta.mean();
400 theta -= DVector::from_element(n, mean_theta);
401 }
402
403 let strengths: HashMap<CandidateId, f64> = candidate_ids
405 .iter()
406 .enumerate()
407 .map(|(i, &id)| (id, theta[i].exp()))
408 .collect();
409
410 let covariance = self.strength_covariance(&theta, comparisons, id_to_index, n);
413
414 let log_likelihood = self.log_likelihood(&theta, comparisons, id_to_index);
415
416 BradleyTerryResult {
417 strengths,
418 covariance,
419 id_to_index: id_to_index.clone(),
420 log_likelihood,
421 iterations,
422 converged,
423 convergence_metric: gradient_norm,
424 }
425 }
426
427 fn backtracking_line_search(
442 &self,
443 theta: &DVector<f64>,
444 delta: &DVector<f64>,
445 gradient: &DVector<f64>,
446 comparisons: &[ComparisonRecord],
447 id_to_index: &HashMap<CandidateId, usize>,
448 prior_lambda: f64,
449 ) -> (DVector<f64>, usize) {
450 const C1: f64 = 1e-4;
451 const MAX_BACKTRACKS: usize = 30;
452
453 let dir_deriv = gradient.dot(delta);
454 let current = self.penalized_log_likelihood(theta, comparisons, id_to_index, prior_lambda);
455
456 let mut step_size = 1.0;
457 for backtracks in 0..MAX_BACKTRACKS {
458 let candidate = theta + step_size * delta;
459 let candidate_ll =
460 self.penalized_log_likelihood(&candidate, comparisons, id_to_index, prior_lambda);
461
462 if armijo_sufficient_increase(current, candidate_ll, step_size, dir_deriv, C1) {
463 return (candidate, backtracks);
464 }
465 step_size *= 0.5;
466 }
467
468 (theta.clone(), MAX_BACKTRACKS)
470 }
471
472 fn penalized_log_likelihood(
474 &self,
475 theta: &DVector<f64>,
476 comparisons: &[ComparisonRecord],
477 id_to_index: &HashMap<CandidateId, usize>,
478 prior_lambda: f64,
479 ) -> f64 {
480 self.log_likelihood(theta, comparisons, id_to_index) - 0.5 * prior_lambda * theta.dot(theta)
481 }
482
483 fn strength_covariance(
502 &self,
503 theta: &DVector<f64>,
504 comparisons: &[ComparisonRecord],
505 id_to_index: &HashMap<CandidateId, usize>,
506 n: usize,
507 ) -> DMatrix<f64> {
508 let mut m = DMatrix::<f64>::zeros(n, n);
510 for comp in comparisons {
511 let i = match id_to_index.get(&comp.winner) {
512 Some(&idx) => idx,
513 None => continue,
514 };
515 let j = match id_to_index.get(&comp.loser) {
516 Some(&idx) => idx,
517 None => continue,
518 };
519
520 let p = sigmoid(theta[i] - theta[j]);
521 let h = p * (1.0 - p);
522
523 m[(i, i)] += h;
524 m[(j, j)] += h;
525 m[(i, j)] -= h;
526 m[(j, i)] -= h;
527 }
528
529 let cov_theta = match m.pseudo_inverse(1e-9) {
531 Ok(inv) => inv,
532 Err(_) => return DMatrix::from_diagonal_element(n, n, f64::INFINITY),
533 };
534
535 let pi: Vec<f64> = (0..n).map(|i| theta[i].exp()).collect();
537 let mut cov = DMatrix::<f64>::zeros(n, n);
538 for i in 0..n {
539 for j in 0..n {
540 cov[(i, j)] = pi[i] * pi[j] * cov_theta[(i, j)];
541 }
542 }
543 cov
544 }
545
546 fn fit_mm(
548 &self,
549 ctx: &FitContext,
550 max_iterations: usize,
551 tolerance: f64,
552 bootstrap_samples: usize,
553 ) -> BradleyTerryResult {
554 let n = ctx.n;
555 let comparisons = ctx.comparisons;
556 let candidate_ids = ctx.candidate_ids;
557 let id_to_index = &ctx.id_to_index;
558
559 let (pi, iterations, converged, max_change) =
561 self.mm_core(comparisons, id_to_index, n, max_iterations, tolerance);
562
563 let covariance =
565 self.bootstrap_covariance(ctx, max_iterations, tolerance, bootstrap_samples, &pi);
566
567 let strengths: HashMap<CandidateId, f64> = candidate_ids
569 .iter()
570 .enumerate()
571 .map(|(i, &id)| (id, pi[i]))
572 .collect();
573
574 let theta: DVector<f64> = pi.iter().map(|&p| p.ln()).collect::<Vec<_>>().into();
576 let log_likelihood = self.log_likelihood(&theta, comparisons, id_to_index);
577
578 BradleyTerryResult {
579 strengths,
580 covariance,
581 id_to_index: id_to_index.clone(),
582 log_likelihood,
583 iterations,
584 converged,
585 convergence_metric: max_change,
586 }
587 }
588
589 fn mm_core(
591 &self,
592 comparisons: &[ComparisonRecord],
593 id_to_index: &HashMap<CandidateId, usize>,
594 n: usize,
595 max_iterations: usize,
596 tolerance: f64,
597 ) -> (Vec<f64>, usize, bool, f64) {
598 let mut pi = vec![1.0; n];
600
601 let mut wins = vec![0usize; n];
603 for comp in comparisons {
604 if let Some(&idx) = id_to_index.get(&comp.winner) {
605 wins[idx] += 1;
606 }
607 }
608
609 let mut converged = false;
610 let mut iterations = 0;
611 let mut max_change = f64::INFINITY;
612
613 for iter in 0..max_iterations {
614 iterations = iter + 1;
615 let mut pi_new = vec![0.0; n];
616
617 for i in 0..n {
618 let mut denom = 0.0;
620 for comp in comparisons {
621 let w_idx = id_to_index.get(&comp.winner).copied();
622 let l_idx = id_to_index.get(&comp.loser).copied();
623
624 match (w_idx, l_idx) {
625 (Some(wi), Some(li)) if wi == i || li == i => {
626 let other = if wi == i { li } else { wi };
627 denom += 1.0 / (pi[i] + pi[other]);
628 }
629 _ => {}
630 }
631 }
632
633 let numerator = wins[i] as f64 + MM_PRIOR_PSEUDOCOUNT;
645 let denom = denom + MM_PRIOR_PSEUDOCOUNT;
646 pi_new[i] = if denom > 0.0 {
647 numerator / denom
648 } else {
649 pi[i]
650 };
651 }
652
653 let sum: f64 = pi_new.iter().sum();
655 if sum > 0.0 {
656 for p in &mut pi_new {
657 *p *= n as f64 / sum;
658 }
659 }
660
661 max_change = pi
663 .iter()
664 .zip(pi_new.iter())
665 .map(|(a, b)| (a - b).abs())
666 .fold(0.0, f64::max);
667
668 if max_change < tolerance {
669 converged = true;
670 pi = pi_new;
671 break;
672 }
673
674 pi = pi_new;
675 }
676
677 (pi, iterations, converged, max_change)
678 }
679
680 fn bootstrap_covariance(
682 &self,
683 ctx: &FitContext,
684 max_iterations: usize,
685 tolerance: f64,
686 bootstrap_samples: usize,
687 point_estimate: &[f64],
688 ) -> DMatrix<f64> {
689 let n = ctx.n;
690 let comparisons = ctx.comparisons;
691 let id_to_index = &ctx.id_to_index;
692
693 if bootstrap_samples == 0 || comparisons.is_empty() {
694 return DMatrix::from_diagonal_element(n, n, f64::INFINITY);
695 }
696
697 let mut rng = rand::thread_rng();
698 let mut bootstrap_estimates: Vec<Vec<f64>> = Vec::with_capacity(bootstrap_samples);
699
700 for _ in 0..bootstrap_samples {
701 let resampled: Vec<ComparisonRecord> = (0..comparisons.len())
703 .map(|_| comparisons[rng.gen_range(0..comparisons.len())].clone())
704 .collect();
705
706 let (pi, _, _, _) = self.mm_core(&resampled, id_to_index, n, max_iterations, tolerance);
708 bootstrap_estimates.push(pi);
709 }
710
711 let mut covariance = DMatrix::zeros(n, n);
713
714 for i in 0..n {
715 for j in 0..n {
716 let mean_i = point_estimate[i];
717 let mean_j = point_estimate[j];
718
719 let cov: f64 = bootstrap_estimates
720 .iter()
721 .map(|est| (est[i] - mean_i) * (est[j] - mean_j))
722 .sum::<f64>()
723 / (bootstrap_samples - 1).max(1) as f64;
724
725 covariance[(i, j)] = cov;
726 }
727 }
728
729 covariance
730 }
731
732 fn log_likelihood(
734 &self,
735 theta: &DVector<f64>,
736 comparisons: &[ComparisonRecord],
737 id_to_index: &HashMap<CandidateId, usize>,
738 ) -> f64 {
739 let mut ll = 0.0;
740
741 for comp in comparisons {
742 let i = match id_to_index.get(&comp.winner) {
743 Some(&idx) => idx,
744 None => continue,
745 };
746 let j = match id_to_index.get(&comp.loser) {
747 Some(&idx) => idx,
748 None => continue,
749 };
750
751 let diff = theta[i] - theta[j];
753 ll += log_sigmoid(diff);
754 }
755
756 ll
757 }
758}
759
760const MM_PRIOR_PSEUDOCOUNT: f64 = 0.1;
765
766fn armijo_sufficient_increase(
775 current: f64,
776 candidate: f64,
777 step: f64,
778 dir_deriv: f64,
779 c: f64,
780) -> bool {
781 candidate >= current + c * step * dir_deriv
782}
783
784fn sigmoid(x: f64) -> f64 {
786 if x >= 0.0 {
787 1.0 / (1.0 + (-x).exp())
788 } else {
789 let ex = x.exp();
790 ex / (1.0 + ex)
791 }
792}
793
794fn log_sigmoid(x: f64) -> f64 {
796 if x >= 0.0 {
797 -(-x).exp().ln_1p()
798 } else {
799 x - x.exp().ln_1p()
800 }
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806
807 fn make_comparisons(pairs: &[(usize, usize)]) -> Vec<ComparisonRecord> {
808 pairs
809 .iter()
810 .map(|&(w, l)| ComparisonRecord {
811 winner: CandidateId(w),
812 loser: CandidateId(l),
813 generation: 0,
814 })
815 .collect()
816 }
817
818 #[test]
819 fn test_newton_raphson_basic() {
820 let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
822 let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
823
824 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
825 let result = model.fit(&comparisons, &candidate_ids);
826
827 assert!(result.converged);
828
829 let pa = result.strengths[&CandidateId(0)];
831 let pb = result.strengths[&CandidateId(1)];
832 let pc = result.strengths[&CandidateId(2)];
833
834 assert!(pa > pb);
835 assert!(pb > pc);
836 }
837
838 #[test]
839 fn test_mm_basic() {
840 let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
841 let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
842
843 let model = BradleyTerryModel::new(BradleyTerryOptimizer::mm(100, 1e-8, 50));
844 let result = model.fit(&comparisons, &candidate_ids);
845
846 assert!(result.converged);
847
848 let pa = result.strengths[&CandidateId(0)];
849 let pb = result.strengths[&CandidateId(1)];
850 let pc = result.strengths[&CandidateId(2)];
851
852 assert!(pa > pb);
853 assert!(pb > pc);
854 }
855
856 #[test]
857 fn test_newton_raphson_and_mm_agree() {
858 let comparisons = make_comparisons(&[
859 (0, 1),
860 (0, 2),
861 (1, 2),
862 (0, 1),
863 (1, 0),
864 (2, 1),
865 (0, 2),
866 (0, 2),
867 ]);
868 let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
869
870 let nr_model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
871 let mm_model = BradleyTerryModel::new(BradleyTerryOptimizer::mm(100, 1e-8, 0));
872
873 let nr_result = nr_model.fit(&comparisons, &candidate_ids);
874 let mm_result = mm_model.fit(&comparisons, &candidate_ids);
875
876 let nr_ranking: Vec<_> = {
878 let mut r: Vec<_> = candidate_ids
879 .iter()
880 .map(|&id| (id, nr_result.strengths[&id]))
881 .collect();
882 r.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
883 r.into_iter().map(|(id, _)| id).collect()
884 };
885
886 let mm_ranking: Vec<_> = {
887 let mut r: Vec<_> = candidate_ids
888 .iter()
889 .map(|&id| (id, mm_result.strengths[&id]))
890 .collect();
891 r.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
892 r.into_iter().map(|(id, _)| id).collect()
893 };
894
895 assert_eq!(nr_ranking, mm_ranking);
896 }
897
898 #[test]
899 fn test_get_estimate() {
900 let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1)]);
901 let candidate_ids = vec![CandidateId(0), CandidateId(1)];
902
903 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
904 let result = model.fit(&comparisons, &candidate_ids);
905
906 let estimate = result.get_estimate(CandidateId(0)).unwrap();
907 assert!(estimate.variance < f64::INFINITY);
908 assert!(estimate.variance > 0.0);
909 }
910
911 #[test]
912 fn test_predict_win_probability() {
913 let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1), (0, 1)]);
914 let candidate_ids = vec![CandidateId(0), CandidateId(1)];
915
916 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
917 let result = model.fit(&comparisons, &candidate_ids);
918
919 let p = result
920 .predict_win_probability(CandidateId(0), CandidateId(1))
921 .unwrap();
922 assert!(p > 0.5); assert!(p < 1.0);
924 }
925
926 #[test]
927 fn test_empty_comparisons() {
928 let comparisons: Vec<ComparisonRecord> = vec![];
929 let candidate_ids = vec![CandidateId(0), CandidateId(1)];
930
931 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
932 let result = model.fit(&comparisons, &candidate_ids);
933
934 assert!(result.converged);
936 assert!(result.covariance[(0, 0)].is_infinite());
937 }
938
939 #[test]
940 fn test_sigmoid() {
941 assert!((sigmoid(0.0) - 0.5).abs() < 1e-9);
942 assert!(sigmoid(100.0) > 0.999);
943 assert!(sigmoid(-100.0) < 0.001);
944
945 for x in [-5.0, -1.0, 0.0, 1.0, 5.0] {
947 assert!((sigmoid(-x) - (1.0 - sigmoid(x))).abs() < 1e-9);
948 }
949 }
950
951 #[test]
952 fn test_log_sigmoid() {
953 for x in [-5.0, -1.0, 0.0, 1.0, 5.0] {
955 assert!(log_sigmoid(x) <= 0.0);
956 assert!((log_sigmoid(x).exp() - sigmoid(x)).abs() < 1e-9);
957 }
958 }
959
960 #[test]
961 fn test_covariance_positive_semidefinite() {
962 let comparisons = make_comparisons(&[(0, 1), (0, 2), (1, 2), (0, 1), (1, 2), (0, 2)]);
963 let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
964
965 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
966 let result = model.fit(&comparisons, &candidate_ids);
967
968 for i in 0..3 {
970 assert!(result.covariance[(i, i)] >= 0.0);
971 }
972 }
973
974 #[test]
975 fn test_constrained_fisher_covariance_matches_analytic() {
976 let comparisons = make_comparisons(&[(0, 1), (1, 0), (0, 2), (2, 0), (1, 2), (2, 1)]);
982 let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
983
984 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
985 let result = model.fit(&comparisons, &candidate_ids);
986
987 let n = 3;
990 let mut m = DMatrix::<f64>::zeros(n, n);
991 for comp in &comparisons {
992 let i = comp.winner.0;
993 let j = comp.loser.0;
994 let h = 0.25;
995 m[(i, i)] += h;
996 m[(j, j)] += h;
997 m[(i, j)] -= h;
998 m[(j, i)] -= h;
999 }
1000 let analytic = m.pseudo_inverse(1e-9).unwrap();
1001 for i in 0..n {
1002 assert!(
1003 (result.covariance[(i, i)] - analytic[(i, i)]).abs() < 1e-6,
1004 "diag {}: got {}, analytic {}",
1005 i,
1006 result.covariance[(i, i)],
1007 analytic[(i, i)]
1008 );
1009 assert!((result.covariance[(i, i)] - 4.0 / 9.0).abs() < 1e-6);
1010 }
1011 assert!(result.covariance[(0, 0)] < 1.0);
1013 }
1014
1015 #[test]
1016 fn test_prior_keeps_all_win_all_loss_finite() {
1017 let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1), (0, 1), (0, 1)]);
1020 let ids = vec![CandidateId(0), CandidateId(1)];
1021
1022 let nr = BradleyTerryModel::new(BradleyTerryOptimizer::default());
1024 let r = nr.fit(&comparisons, &ids);
1025 let s0 = r.strengths[&CandidateId(0)];
1026 let s1 = r.strengths[&CandidateId(1)];
1027 assert!(s0.is_finite() && s1.is_finite());
1028 assert!(s0 > s1);
1029 assert!(s0 < 50.0, "NR strength diverged: {}", s0);
1030 assert!(s1 > 0.0, "NR loser strength collapsed: {}", s1);
1031
1032 let mm = BradleyTerryModel::new(BradleyTerryOptimizer::mm(200, 1e-9, 0));
1034 let rm = mm.fit(&comparisons, &ids);
1035 let m0 = rm.strengths[&CandidateId(0)];
1036 let m1 = rm.strengths[&CandidateId(1)];
1037 assert!(m0.is_finite() && m0 < 50.0, "MM strength diverged: {}", m0);
1038 assert!(m0 > m1);
1039 assert!(m1 > 0.0);
1040 }
1041
1042 #[test]
1043 fn test_armijo_sign_rejects_small_decrease() {
1044 let current = 10.0;
1048 let candidate = 9.99995; let step = 1.0;
1050 let dir_deriv = 1.0; let c = 1e-4;
1052
1053 assert!(!armijo_sufficient_increase(
1055 current, candidate, step, dir_deriv, c
1056 ));
1057 assert!(armijo_sufficient_increase(
1059 current, 10.5, step, dir_deriv, c
1060 ));
1061 let buggy_threshold = current - c * step * dir_deriv;
1064 assert!(candidate > buggy_threshold);
1065 }
1066
1067 #[test]
1068 fn test_backtracking_triggers_on_overshoot() {
1069 let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
1073 let ids = [CandidateId(0), CandidateId(1), CandidateId(2)];
1074 let id_to_index: HashMap<CandidateId, usize> =
1075 ids.iter().enumerate().map(|(i, &id)| (id, i)).collect();
1076 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
1077 let lambda = 0.1;
1078
1079 let theta = DVector::from_element(3, 0.0);
1080 let mut gradient = DVector::zeros(3);
1081 let mut hessian = DMatrix::zeros(3, 3);
1082 for comp in &comparisons {
1083 let i = id_to_index[&comp.winner];
1084 let j = id_to_index[&comp.loser];
1085 let p = sigmoid(theta[i] - theta[j]);
1086 let q = 1.0 - p;
1087 let h = p * q;
1088 gradient[i] += q;
1089 gradient[j] -= q;
1090 hessian[(i, i)] -= h;
1091 hessian[(j, j)] -= h;
1092 hessian[(i, j)] += h;
1093 hessian[(j, i)] += h;
1094 }
1095 for i in 0..3 {
1096 gradient[i] -= lambda * theta[i];
1097 hessian[(i, i)] -= lambda;
1098 }
1099 let newton = (-&hessian).lu().solve(&gradient).unwrap();
1100 let big_delta = 50.0 * &newton; let before = model.penalized_log_likelihood(&theta, &comparisons, &id_to_index, lambda);
1103 let (new_theta, backtracks) = model.backtracking_line_search(
1104 &theta,
1105 &big_delta,
1106 &gradient,
1107 &comparisons,
1108 &id_to_index,
1109 lambda,
1110 );
1111 let after = model.penalized_log_likelihood(&new_theta, &comparisons, &id_to_index, lambda);
1112
1113 assert!(backtracks >= 1, "expected backtracking to trigger");
1114 assert!(
1115 after >= before,
1116 "line search must not decrease the objective"
1117 );
1118 }
1119}