1use nalgebra::{DMatrix, DVector};
40use rand::prelude::*;
41use serde::{Deserialize, Serialize};
42use std::collections::HashMap;
43
44use super::aggregation::ComparisonRecord;
45use super::evaluator::CandidateId;
46use super::uncertainty::FitnessEstimate;
47
48struct FitContext<'a> {
52 comparisons: &'a [ComparisonRecord],
53 candidate_ids: &'a [CandidateId],
54 id_to_index: HashMap<CandidateId, usize>,
55 n: usize,
56}
57
58impl<'a> FitContext<'a> {
59 fn new(comparisons: &'a [ComparisonRecord], candidate_ids: &'a [CandidateId]) -> Self {
60 let id_to_index: HashMap<CandidateId, usize> = candidate_ids
61 .iter()
62 .enumerate()
63 .map(|(i, &id)| (id, i))
64 .collect();
65 let n = candidate_ids.len();
66 Self {
67 comparisons,
68 candidate_ids,
69 id_to_index,
70 n,
71 }
72 }
73}
74
75#[derive(Clone, Debug, Serialize, Deserialize)]
77pub enum BradleyTerryOptimizer {
78 NewtonRaphson {
83 max_iterations: usize,
85 tolerance: f64,
87 #[serde(alias = "regularization")]
97 prior_lambda: f64,
98 },
99
100 MM {
105 max_iterations: usize,
107 tolerance: f64,
109 bootstrap_samples: usize,
111 },
112}
113
114impl Default for BradleyTerryOptimizer {
115 fn default() -> Self {
116 Self::NewtonRaphson {
117 max_iterations: 100,
118 tolerance: 1e-6, prior_lambda: 0.1,
120 }
121 }
122}
123
124impl BradleyTerryOptimizer {
125 pub fn newton_raphson(max_iterations: usize, tolerance: f64, prior_lambda: f64) -> Self {
132 Self::NewtonRaphson {
133 max_iterations,
134 tolerance,
135 prior_lambda,
136 }
137 }
138
139 pub fn mm(max_iterations: usize, tolerance: f64, bootstrap_samples: usize) -> Self {
141 Self::MM {
142 max_iterations,
143 tolerance,
144 bootstrap_samples,
145 }
146 }
147}
148
149#[derive(Clone, Debug)]
165pub struct BradleyTerryResult {
166 pub strengths: HashMap<CandidateId, f64>,
169 pub covariance: DMatrix<f64>,
171 pub id_to_index: HashMap<CandidateId, usize>,
173 pub log_likelihood: f64,
175 pub iterations: usize,
177 pub converged: bool,
179 pub convergence_metric: f64,
181}
182
183impl BradleyTerryResult {
184 pub fn get_estimate(&self, id: CandidateId) -> Option<FitnessEstimate> {
186 let strength = *self.strengths.get(&id)?;
187 let idx = *self.id_to_index.get(&id)?;
188
189 let variance = if idx < self.covariance.nrows() {
191 self.covariance[(idx, idx)]
192 } else {
193 f64::INFINITY
194 };
195
196 let observation_count = self.strengths.len(); Some(FitnessEstimate::new(strength, variance, observation_count))
200 }
201
202 pub fn all_estimates(&self) -> HashMap<CandidateId, FitnessEstimate> {
204 self.strengths
205 .keys()
206 .filter_map(|&id| self.get_estimate(id).map(|e| (id, e)))
207 .collect()
208 }
209
210 pub fn predict_win_probability(&self, a: CandidateId, b: CandidateId) -> Option<f64> {
212 let pa = self.strengths.get(&a)?;
213 let pb = self.strengths.get(&b)?;
214 Some(pa / (pa + pb))
215 }
216}
217
218pub struct BradleyTerryModel {
220 optimizer: BradleyTerryOptimizer,
221}
222
223impl BradleyTerryModel {
224 pub fn new(optimizer: BradleyTerryOptimizer) -> Self {
226 Self { optimizer }
227 }
228
229 pub fn fit(
240 &self,
241 comparisons: &[ComparisonRecord],
242 candidate_ids: &[CandidateId],
243 ) -> BradleyTerryResult {
244 if candidate_ids.is_empty() || comparisons.is_empty() {
245 return self.empty_result(candidate_ids);
246 }
247
248 let ctx = FitContext::new(comparisons, candidate_ids);
249
250 match &self.optimizer {
251 BradleyTerryOptimizer::NewtonRaphson {
252 max_iterations,
253 tolerance,
254 prior_lambda,
255 } => self.fit_newton_raphson(&ctx, *max_iterations, *tolerance, *prior_lambda),
256 BradleyTerryOptimizer::MM {
257 max_iterations,
258 tolerance,
259 bootstrap_samples,
260 } => self.fit_mm(&ctx, *max_iterations, *tolerance, *bootstrap_samples),
261 }
262 }
263
264 fn empty_result(&self, candidate_ids: &[CandidateId]) -> BradleyTerryResult {
266 let n = candidate_ids.len();
267 let strengths: HashMap<CandidateId, f64> =
268 candidate_ids.iter().map(|&id| (id, 1.0)).collect();
269 let id_to_index: HashMap<CandidateId, usize> = candidate_ids
270 .iter()
271 .enumerate()
272 .map(|(i, &id)| (id, i))
273 .collect();
274
275 BradleyTerryResult {
276 strengths,
277 covariance: DMatrix::from_diagonal_element(n, n, f64::INFINITY),
278 id_to_index,
279 log_likelihood: 0.0,
280 iterations: 0,
281 converged: true,
282 convergence_metric: 0.0,
283 }
284 }
285
286 fn fit_newton_raphson(
296 &self,
297 ctx: &FitContext,
298 max_iterations: usize,
299 tolerance: f64,
300 prior_lambda: f64,
301 ) -> BradleyTerryResult {
302 let n = ctx.n;
303 let comparisons = ctx.comparisons;
304 let candidate_ids = ctx.candidate_ids;
305 let id_to_index = &ctx.id_to_index;
306 let mut theta = DVector::zeros(n);
308
309 let mut converged = false;
310 let mut iterations = 0;
311 let mut gradient_norm = f64::INFINITY;
312
313 for iter in 0..max_iterations {
314 iterations = iter + 1;
315
316 let mut gradient = DVector::zeros(n);
318 let mut hessian = DMatrix::zeros(n, n);
319
320 for comp in comparisons {
321 let i = match id_to_index.get(&comp.winner) {
322 Some(&idx) => idx,
323 None => continue,
324 };
325 let j = match id_to_index.get(&comp.loser) {
326 Some(&idx) => idx,
327 None => continue,
328 };
329
330 let diff = theta[i] - theta[j];
332 let p = sigmoid(diff);
333 let q = 1.0 - p; gradient[i] += q; gradient[j] -= q; let h = p * q;
341 hessian[(i, i)] -= h;
342 hessian[(j, j)] -= h;
343 hessian[(i, j)] += h;
344 hessian[(j, i)] += h;
345 }
346
347 if prior_lambda > 0.0 {
351 for i in 0..n {
352 gradient[i] -= prior_lambda * theta[i];
353 hessian[(i, i)] -= prior_lambda;
354 }
355 }
356
357 gradient_norm = gradient.norm();
359 if gradient_norm < tolerance {
360 converged = true;
361 break;
362 }
363
364 let neg_hessian = -&hessian;
367 let delta = match neg_hessian.clone().lu().solve(&gradient) {
368 Some(d) => d,
369 None => {
370 let mut reg_hessian = neg_hessian;
372 let nudge = if prior_lambda > 0.0 {
373 prior_lambda
374 } else {
375 1e-6
376 };
377 for i in 0..n {
378 reg_hessian[(i, i)] += nudge;
379 }
380 match reg_hessian.lu().solve(&gradient) {
381 Some(d) => d,
382 None => break, }
384 }
385 };
386
387 let (new_theta, _backtracks) = self.backtracking_line_search(
391 &theta,
392 &delta,
393 &gradient,
394 comparisons,
395 id_to_index,
396 prior_lambda,
397 );
398 theta = new_theta;
399
400 let mean_theta = theta.mean();
403 theta -= DVector::from_element(n, mean_theta);
404 }
405
406 let strengths: HashMap<CandidateId, f64> = candidate_ids
408 .iter()
409 .enumerate()
410 .map(|(i, &id)| (id, theta[i].exp()))
411 .collect();
412
413 let covariance = self.strength_covariance(&theta, comparisons, id_to_index, n);
416
417 let log_likelihood = self.log_likelihood(&theta, comparisons, id_to_index);
418
419 BradleyTerryResult {
420 strengths,
421 covariance,
422 id_to_index: id_to_index.clone(),
423 log_likelihood,
424 iterations,
425 converged,
426 convergence_metric: gradient_norm,
427 }
428 }
429
430 fn backtracking_line_search(
445 &self,
446 theta: &DVector<f64>,
447 delta: &DVector<f64>,
448 gradient: &DVector<f64>,
449 comparisons: &[ComparisonRecord],
450 id_to_index: &HashMap<CandidateId, usize>,
451 prior_lambda: f64,
452 ) -> (DVector<f64>, usize) {
453 const C1: f64 = 1e-4;
454 const MAX_BACKTRACKS: usize = 30;
455
456 let dir_deriv = gradient.dot(delta);
457 let current = self.penalized_log_likelihood(theta, comparisons, id_to_index, prior_lambda);
458
459 let mut step_size = 1.0;
460 for backtracks in 0..MAX_BACKTRACKS {
461 let candidate = theta + step_size * delta;
462 let candidate_ll =
463 self.penalized_log_likelihood(&candidate, comparisons, id_to_index, prior_lambda);
464
465 if armijo_sufficient_increase(current, candidate_ll, step_size, dir_deriv, C1) {
466 return (candidate, backtracks);
467 }
468 step_size *= 0.5;
469 }
470
471 (theta.clone(), MAX_BACKTRACKS)
473 }
474
475 fn penalized_log_likelihood(
477 &self,
478 theta: &DVector<f64>,
479 comparisons: &[ComparisonRecord],
480 id_to_index: &HashMap<CandidateId, usize>,
481 prior_lambda: f64,
482 ) -> f64 {
483 self.log_likelihood(theta, comparisons, id_to_index) - 0.5 * prior_lambda * theta.dot(theta)
484 }
485
486 fn strength_covariance(
505 &self,
506 theta: &DVector<f64>,
507 comparisons: &[ComparisonRecord],
508 id_to_index: &HashMap<CandidateId, usize>,
509 n: usize,
510 ) -> DMatrix<f64> {
511 let mut m = DMatrix::<f64>::zeros(n, n);
513 for comp in comparisons {
514 let i = match id_to_index.get(&comp.winner) {
515 Some(&idx) => idx,
516 None => continue,
517 };
518 let j = match id_to_index.get(&comp.loser) {
519 Some(&idx) => idx,
520 None => continue,
521 };
522
523 let p = sigmoid(theta[i] - theta[j]);
524 let h = p * (1.0 - p);
525
526 m[(i, i)] += h;
527 m[(j, j)] += h;
528 m[(i, j)] -= h;
529 m[(j, i)] -= h;
530 }
531
532 let cov_theta = match m.pseudo_inverse(1e-9) {
534 Ok(inv) => inv,
535 Err(_) => return DMatrix::from_diagonal_element(n, n, f64::INFINITY),
536 };
537
538 let pi: Vec<f64> = (0..n).map(|i| theta[i].exp()).collect();
540 let mut cov = DMatrix::<f64>::zeros(n, n);
541 for i in 0..n {
542 for j in 0..n {
543 cov[(i, j)] = pi[i] * pi[j] * cov_theta[(i, j)];
544 }
545 }
546 cov
547 }
548
549 fn fit_mm(
551 &self,
552 ctx: &FitContext,
553 max_iterations: usize,
554 tolerance: f64,
555 bootstrap_samples: usize,
556 ) -> BradleyTerryResult {
557 let n = ctx.n;
558 let comparisons = ctx.comparisons;
559 let candidate_ids = ctx.candidate_ids;
560 let id_to_index = &ctx.id_to_index;
561
562 let (pi, iterations, converged, max_change) =
564 self.mm_core(comparisons, id_to_index, n, max_iterations, tolerance);
565
566 let covariance =
568 self.bootstrap_covariance(ctx, max_iterations, tolerance, bootstrap_samples, &pi);
569
570 let strengths: HashMap<CandidateId, f64> = candidate_ids
572 .iter()
573 .enumerate()
574 .map(|(i, &id)| (id, pi[i]))
575 .collect();
576
577 let theta: DVector<f64> = pi.iter().map(|&p| p.ln()).collect::<Vec<_>>().into();
579 let log_likelihood = self.log_likelihood(&theta, comparisons, id_to_index);
580
581 BradleyTerryResult {
582 strengths,
583 covariance,
584 id_to_index: id_to_index.clone(),
585 log_likelihood,
586 iterations,
587 converged,
588 convergence_metric: max_change,
589 }
590 }
591
592 fn mm_core(
594 &self,
595 comparisons: &[ComparisonRecord],
596 id_to_index: &HashMap<CandidateId, usize>,
597 n: usize,
598 max_iterations: usize,
599 tolerance: f64,
600 ) -> (Vec<f64>, usize, bool, f64) {
601 let mut pi = vec![1.0; n];
603
604 let mut wins = vec![0usize; n];
606 for comp in comparisons {
607 if let Some(&idx) = id_to_index.get(&comp.winner) {
608 wins[idx] += 1;
609 }
610 }
611
612 let mut converged = false;
613 let mut iterations = 0;
614 let mut max_change = f64::INFINITY;
615
616 for iter in 0..max_iterations {
617 iterations = iter + 1;
618 let mut pi_new = vec![0.0; n];
619
620 for i in 0..n {
621 let mut denom = 0.0;
623 for comp in comparisons {
624 let w_idx = id_to_index.get(&comp.winner).copied();
625 let l_idx = id_to_index.get(&comp.loser).copied();
626
627 match (w_idx, l_idx) {
628 (Some(wi), Some(li)) if wi == i || li == i => {
629 let other = if wi == i { li } else { wi };
630 denom += 1.0 / (pi[i] + pi[other]);
631 }
632 _ => {}
633 }
634 }
635
636 let numerator = wins[i] as f64 + MM_PRIOR_PSEUDOCOUNT;
648 let denom = denom + MM_PRIOR_PSEUDOCOUNT;
649 pi_new[i] = if denom > 0.0 {
650 numerator / denom
651 } else {
652 pi[i]
653 };
654 }
655
656 let sum: f64 = pi_new.iter().sum();
658 if sum > 0.0 {
659 for p in &mut pi_new {
660 *p *= n as f64 / sum;
661 }
662 }
663
664 max_change = pi
666 .iter()
667 .zip(pi_new.iter())
668 .map(|(a, b)| (a - b).abs())
669 .fold(0.0, f64::max);
670
671 if max_change < tolerance {
672 converged = true;
673 pi = pi_new;
674 break;
675 }
676
677 pi = pi_new;
678 }
679
680 (pi, iterations, converged, max_change)
681 }
682
683 fn bootstrap_covariance(
685 &self,
686 ctx: &FitContext,
687 max_iterations: usize,
688 tolerance: f64,
689 bootstrap_samples: usize,
690 point_estimate: &[f64],
691 ) -> DMatrix<f64> {
692 let n = ctx.n;
693 let comparisons = ctx.comparisons;
694 let id_to_index = &ctx.id_to_index;
695
696 if bootstrap_samples == 0 || comparisons.is_empty() {
697 return DMatrix::from_diagonal_element(n, n, f64::INFINITY);
698 }
699
700 let mut rng = rand::thread_rng();
701 let mut bootstrap_estimates: Vec<Vec<f64>> = Vec::with_capacity(bootstrap_samples);
702
703 for _ in 0..bootstrap_samples {
704 let resampled: Vec<ComparisonRecord> = (0..comparisons.len())
706 .map(|_| comparisons[rng.gen_range(0..comparisons.len())].clone())
707 .collect();
708
709 let (pi, _, _, _) = self.mm_core(&resampled, id_to_index, n, max_iterations, tolerance);
711 bootstrap_estimates.push(pi);
712 }
713
714 let mut covariance = DMatrix::zeros(n, n);
716
717 for i in 0..n {
718 for j in 0..n {
719 let mean_i = point_estimate[i];
720 let mean_j = point_estimate[j];
721
722 let cov: f64 = bootstrap_estimates
723 .iter()
724 .map(|est| (est[i] - mean_i) * (est[j] - mean_j))
725 .sum::<f64>()
726 / (bootstrap_samples - 1).max(1) as f64;
727
728 covariance[(i, j)] = cov;
729 }
730 }
731
732 covariance
733 }
734
735 fn log_likelihood(
737 &self,
738 theta: &DVector<f64>,
739 comparisons: &[ComparisonRecord],
740 id_to_index: &HashMap<CandidateId, usize>,
741 ) -> f64 {
742 let mut ll = 0.0;
743
744 for comp in comparisons {
745 let i = match id_to_index.get(&comp.winner) {
746 Some(&idx) => idx,
747 None => continue,
748 };
749 let j = match id_to_index.get(&comp.loser) {
750 Some(&idx) => idx,
751 None => continue,
752 };
753
754 let diff = theta[i] - theta[j];
756 ll += log_sigmoid(diff);
757 }
758
759 ll
760 }
761}
762
763const MM_PRIOR_PSEUDOCOUNT: f64 = 0.1;
768
769fn armijo_sufficient_increase(
778 current: f64,
779 candidate: f64,
780 step: f64,
781 dir_deriv: f64,
782 c: f64,
783) -> bool {
784 candidate >= current + c * step * dir_deriv
785}
786
787fn sigmoid(x: f64) -> f64 {
789 if x >= 0.0 {
790 1.0 / (1.0 + (-x).exp())
791 } else {
792 let ex = x.exp();
793 ex / (1.0 + ex)
794 }
795}
796
797fn log_sigmoid(x: f64) -> f64 {
799 if x >= 0.0 {
800 -(-x).exp().ln_1p()
801 } else {
802 x - x.exp().ln_1p()
803 }
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809
810 fn make_comparisons(pairs: &[(usize, usize)]) -> Vec<ComparisonRecord> {
811 pairs
812 .iter()
813 .map(|&(w, l)| ComparisonRecord {
814 winner: CandidateId(w),
815 loser: CandidateId(l),
816 generation: 0,
817 })
818 .collect()
819 }
820
821 #[test]
822 fn test_newton_raphson_basic() {
823 let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
825 let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
826
827 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
828 let result = model.fit(&comparisons, &candidate_ids);
829
830 assert!(result.converged);
831
832 let pa = result.strengths[&CandidateId(0)];
834 let pb = result.strengths[&CandidateId(1)];
835 let pc = result.strengths[&CandidateId(2)];
836
837 assert!(pa > pb);
838 assert!(pb > pc);
839 }
840
841 #[test]
842 fn test_mm_basic() {
843 let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
844 let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
845
846 let model = BradleyTerryModel::new(BradleyTerryOptimizer::mm(100, 1e-8, 50));
847 let result = model.fit(&comparisons, &candidate_ids);
848
849 assert!(result.converged);
850
851 let pa = result.strengths[&CandidateId(0)];
852 let pb = result.strengths[&CandidateId(1)];
853 let pc = result.strengths[&CandidateId(2)];
854
855 assert!(pa > pb);
856 assert!(pb > pc);
857 }
858
859 #[test]
860 fn test_newton_raphson_and_mm_agree() {
861 let comparisons = make_comparisons(&[
862 (0, 1),
863 (0, 2),
864 (1, 2),
865 (0, 1),
866 (1, 0),
867 (2, 1),
868 (0, 2),
869 (0, 2),
870 ]);
871 let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
872
873 let nr_model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
874 let mm_model = BradleyTerryModel::new(BradleyTerryOptimizer::mm(100, 1e-8, 0));
875
876 let nr_result = nr_model.fit(&comparisons, &candidate_ids);
877 let mm_result = mm_model.fit(&comparisons, &candidate_ids);
878
879 let nr_ranking: Vec<_> = {
881 let mut r: Vec<_> = candidate_ids
882 .iter()
883 .map(|&id| (id, nr_result.strengths[&id]))
884 .collect();
885 r.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
886 r.into_iter().map(|(id, _)| id).collect()
887 };
888
889 let mm_ranking: Vec<_> = {
890 let mut r: Vec<_> = candidate_ids
891 .iter()
892 .map(|&id| (id, mm_result.strengths[&id]))
893 .collect();
894 r.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
895 r.into_iter().map(|(id, _)| id).collect()
896 };
897
898 assert_eq!(nr_ranking, mm_ranking);
899 }
900
901 #[test]
902 fn test_get_estimate() {
903 let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1)]);
904 let candidate_ids = vec![CandidateId(0), CandidateId(1)];
905
906 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
907 let result = model.fit(&comparisons, &candidate_ids);
908
909 let estimate = result.get_estimate(CandidateId(0)).unwrap();
910 assert!(estimate.variance < f64::INFINITY);
911 assert!(estimate.variance > 0.0);
912 }
913
914 #[test]
915 fn test_predict_win_probability() {
916 let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1), (0, 1)]);
917 let candidate_ids = vec![CandidateId(0), CandidateId(1)];
918
919 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
920 let result = model.fit(&comparisons, &candidate_ids);
921
922 let p = result
923 .predict_win_probability(CandidateId(0), CandidateId(1))
924 .unwrap();
925 assert!(p > 0.5); assert!(p < 1.0);
927 }
928
929 #[test]
930 fn test_empty_comparisons() {
931 let comparisons: Vec<ComparisonRecord> = vec![];
932 let candidate_ids = vec![CandidateId(0), CandidateId(1)];
933
934 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
935 let result = model.fit(&comparisons, &candidate_ids);
936
937 assert!(result.converged);
939 assert!(result.covariance[(0, 0)].is_infinite());
940 }
941
942 #[test]
943 fn test_sigmoid() {
944 assert!((sigmoid(0.0) - 0.5).abs() < 1e-9);
945 assert!(sigmoid(100.0) > 0.999);
946 assert!(sigmoid(-100.0) < 0.001);
947
948 for x in [-5.0, -1.0, 0.0, 1.0, 5.0] {
950 assert!((sigmoid(-x) - (1.0 - sigmoid(x))).abs() < 1e-9);
951 }
952 }
953
954 #[test]
955 fn test_log_sigmoid() {
956 for x in [-5.0, -1.0, 0.0, 1.0, 5.0] {
958 assert!(log_sigmoid(x) <= 0.0);
959 assert!((log_sigmoid(x).exp() - sigmoid(x)).abs() < 1e-9);
960 }
961 }
962
963 #[test]
964 fn test_covariance_positive_semidefinite() {
965 let comparisons = make_comparisons(&[(0, 1), (0, 2), (1, 2), (0, 1), (1, 2), (0, 2)]);
966 let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
967
968 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
969 let result = model.fit(&comparisons, &candidate_ids);
970
971 for i in 0..3 {
973 assert!(result.covariance[(i, i)] >= 0.0);
974 }
975 }
976
977 #[test]
978 fn test_constrained_fisher_covariance_matches_analytic() {
979 let comparisons = make_comparisons(&[(0, 1), (1, 0), (0, 2), (2, 0), (1, 2), (2, 1)]);
985 let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
986
987 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
988 let result = model.fit(&comparisons, &candidate_ids);
989
990 let n = 3;
993 let mut m = DMatrix::<f64>::zeros(n, n);
994 for comp in &comparisons {
995 let i = comp.winner.0;
996 let j = comp.loser.0;
997 let h = 0.25;
998 m[(i, i)] += h;
999 m[(j, j)] += h;
1000 m[(i, j)] -= h;
1001 m[(j, i)] -= h;
1002 }
1003 let analytic = m.pseudo_inverse(1e-9).unwrap();
1004 for i in 0..n {
1005 assert!(
1006 (result.covariance[(i, i)] - analytic[(i, i)]).abs() < 1e-6,
1007 "diag {}: got {}, analytic {}",
1008 i,
1009 result.covariance[(i, i)],
1010 analytic[(i, i)]
1011 );
1012 assert!((result.covariance[(i, i)] - 4.0 / 9.0).abs() < 1e-6);
1013 }
1014 assert!(result.covariance[(0, 0)] < 1.0);
1016 }
1017
1018 #[test]
1019 fn test_prior_keeps_all_win_all_loss_finite() {
1020 let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1), (0, 1), (0, 1)]);
1023 let ids = vec![CandidateId(0), CandidateId(1)];
1024
1025 let nr = BradleyTerryModel::new(BradleyTerryOptimizer::default());
1027 let r = nr.fit(&comparisons, &ids);
1028 let s0 = r.strengths[&CandidateId(0)];
1029 let s1 = r.strengths[&CandidateId(1)];
1030 assert!(s0.is_finite() && s1.is_finite());
1031 assert!(s0 > s1);
1032 assert!(s0 < 50.0, "NR strength diverged: {}", s0);
1033 assert!(s1 > 0.0, "NR loser strength collapsed: {}", s1);
1034
1035 let mm = BradleyTerryModel::new(BradleyTerryOptimizer::mm(200, 1e-9, 0));
1037 let rm = mm.fit(&comparisons, &ids);
1038 let m0 = rm.strengths[&CandidateId(0)];
1039 let m1 = rm.strengths[&CandidateId(1)];
1040 assert!(m0.is_finite() && m0 < 50.0, "MM strength diverged: {}", m0);
1041 assert!(m0 > m1);
1042 assert!(m1 > 0.0);
1043 }
1044
1045 #[test]
1046 fn test_armijo_sign_rejects_small_decrease() {
1047 let current = 10.0;
1051 let candidate = 9.99995; let step = 1.0;
1053 let dir_deriv = 1.0; let c = 1e-4;
1055
1056 assert!(!armijo_sufficient_increase(
1058 current, candidate, step, dir_deriv, c
1059 ));
1060 assert!(armijo_sufficient_increase(
1062 current, 10.5, step, dir_deriv, c
1063 ));
1064 let buggy_threshold = current - c * step * dir_deriv;
1067 assert!(candidate > buggy_threshold);
1068 }
1069
1070 #[test]
1071 fn test_backtracking_triggers_on_overshoot() {
1072 let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
1076 let ids = [CandidateId(0), CandidateId(1), CandidateId(2)];
1077 let id_to_index: HashMap<CandidateId, usize> =
1078 ids.iter().enumerate().map(|(i, &id)| (id, i)).collect();
1079 let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
1080 let lambda = 0.1;
1081
1082 let theta = DVector::from_element(3, 0.0);
1083 let mut gradient = DVector::zeros(3);
1084 let mut hessian = DMatrix::zeros(3, 3);
1085 for comp in &comparisons {
1086 let i = id_to_index[&comp.winner];
1087 let j = id_to_index[&comp.loser];
1088 let p = sigmoid(theta[i] - theta[j]);
1089 let q = 1.0 - p;
1090 let h = p * q;
1091 gradient[i] += q;
1092 gradient[j] -= q;
1093 hessian[(i, i)] -= h;
1094 hessian[(j, j)] -= h;
1095 hessian[(i, j)] += h;
1096 hessian[(j, i)] += h;
1097 }
1098 for i in 0..3 {
1099 gradient[i] -= lambda * theta[i];
1100 hessian[(i, i)] -= lambda;
1101 }
1102 let newton = (-&hessian).lu().solve(&gradient).unwrap();
1103 let big_delta = 50.0 * &newton; let before = model.penalized_log_likelihood(&theta, &comparisons, &id_to_index, lambda);
1106 let (new_theta, backtracks) = model.backtracking_line_search(
1107 &theta,
1108 &big_delta,
1109 &gradient,
1110 &comparisons,
1111 &id_to_index,
1112 lambda,
1113 );
1114 let after = model.penalized_log_likelihood(&new_theta, &comparisons, &id_to_index, lambda);
1115
1116 assert!(backtracks >= 1, "expected backtracking to trigger");
1117 assert!(
1118 after >= before,
1119 "line search must not decrease the objective"
1120 );
1121 }
1122}