1use sklears_core::error::{Result as SklResult, SklearsError};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum AggregationStrategy {
25 Mean,
27 Median,
29 Weighted,
31 Min,
33 Max,
35}
36
37#[derive(Debug, Clone)]
39pub struct MetricsAggregator;
40
41impl MetricsAggregator {
42 pub fn aggregate_metrics(metrics: &[f64], weights: Option<&[f64]>) -> SklResult<f64> {
49 match weights {
50 Some(w) => WeightedAveraging::weighted_average(metrics, w),
51 None => Self::aggregate_with(metrics, AggregationStrategy::Mean, None),
52 }
53 }
54
55 pub fn aggregate_with(
61 metrics: &[f64],
62 strategy: AggregationStrategy,
63 weights: Option<&[f64]>,
64 ) -> SklResult<f64> {
65 if metrics.is_empty() {
66 return Err(SklearsError::InvalidInput(
67 "aggregate_metrics requires at least one metric value".to_string(),
68 ));
69 }
70
71 match strategy {
72 AggregationStrategy::Mean => Ok(metrics.iter().sum::<f64>() / metrics.len() as f64),
73 AggregationStrategy::Median => Ok(median(metrics)),
74 AggregationStrategy::Min => Ok(metrics.iter().copied().fold(f64::INFINITY, f64::min)),
75 AggregationStrategy::Max => {
76 Ok(metrics.iter().copied().fold(f64::NEG_INFINITY, f64::max))
77 }
78 AggregationStrategy::Weighted => {
79 let w = weights.ok_or_else(|| {
80 SklearsError::InvalidInput("weighted aggregation requires weights".to_string())
81 })?;
82 WeightedAveraging::weighted_average(metrics, w)
83 }
84 }
85 }
86}
87
88fn median(values: &[f64]) -> f64 {
90 let mut sorted: Vec<f64> = values.to_vec();
91 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
92 let n = sorted.len();
93 if n % 2 == 1 {
94 sorted[n / 2]
95 } else {
96 0.5 * (sorted[n / 2 - 1] + sorted[n / 2])
97 }
98}
99
100#[derive(Debug, Clone)]
102pub struct WeightedAveraging;
103
104impl WeightedAveraging {
105 pub fn weighted_average(values: &[f64], weights: &[f64]) -> SklResult<f64> {
112 if values.is_empty() {
113 return Err(SklearsError::InvalidInput(
114 "weighted_average requires at least one value".to_string(),
115 ));
116 }
117 if values.len() != weights.len() {
118 return Err(SklearsError::InvalidInput(format!(
119 "weighted_average: values length ({}) must match weights length ({})",
120 values.len(),
121 weights.len()
122 )));
123 }
124
125 let mut weight_sum = 0.0;
126 for &w in weights {
127 if !w.is_finite() {
128 return Err(SklearsError::InvalidInput(
129 "weighted_average: weights must be finite".to_string(),
130 ));
131 }
132 if w < 0.0 {
133 return Err(SklearsError::InvalidInput(
134 "weighted_average: weights must be non-negative".to_string(),
135 ));
136 }
137 weight_sum += w;
138 }
139
140 if weight_sum <= 0.0 {
141 return Err(SklearsError::InvalidInput(
142 "weighted_average: sum of weights must be positive".to_string(),
143 ));
144 }
145
146 let weighted_sum: f64 = values
147 .iter()
148 .zip(weights.iter())
149 .map(|(&v, &w)| v * w)
150 .sum();
151
152 Ok(weighted_sum / weight_sum)
153 }
154}
155
156#[derive(Debug, Clone)]
158pub struct RankAggregation;
159
160impl RankAggregation {
161 pub fn borda_count(rankings: &[Vec<usize>]) -> SklResult<Vec<usize>> {
171 let n = validate_rankings(rankings)?;
172
173 let mut scores = vec![0usize; n];
174 for ranking in rankings {
175 for (position, &item) in ranking.iter().enumerate() {
176 scores[item] += n - position;
178 }
179 }
180
181 let mut order: Vec<usize> = (0..n).collect();
182 order.sort_by(|&a, &b| scores[b].cmp(&scores[a]).then(a.cmp(&b)));
184 Ok(order)
185 }
186
187 pub fn kemeny_optimal(rankings: &[Vec<usize>]) -> SklResult<Vec<usize>> {
199 let n = validate_rankings(rankings)?;
200 if n == 0 {
201 return Ok(Vec::new());
202 }
203
204 let prefer = pairwise_preference_matrix(rankings, n);
206
207 const EXACT_LIMIT: usize = 8;
208 if n <= EXACT_LIMIT {
209 Self::kemeny_exact(&prefer, n)
210 } else {
211 Self::kemeny_local_search(&prefer, n, rankings)
212 }
213 }
214
215 fn kemeny_exact(prefer: &[Vec<usize>], n: usize) -> SklResult<Vec<usize>> {
217 let mut items: Vec<usize> = (0..n).collect();
218 let mut best_order = items.clone();
219 let mut best_cost = kemeny_cost(&best_order, prefer);
220
221 let mut c = vec![0usize; n];
223 let mut i = 0;
225 while i < n {
226 if c[i] < i {
227 if i % 2 == 0 {
228 items.swap(0, i);
229 } else {
230 items.swap(c[i], i);
231 }
232 let cost = kemeny_cost(&items, prefer);
233 if cost < best_cost
234 || (cost == best_cost && lexicographically_smaller(&items, &best_order))
235 {
236 best_cost = cost;
237 best_order = items.clone();
238 }
239 c[i] += 1;
240 i = 0;
241 } else {
242 c[i] = 0;
243 i += 1;
244 }
245 }
246
247 Ok(best_order)
248 }
249
250 fn kemeny_local_search(
252 prefer: &[Vec<usize>],
253 n: usize,
254 rankings: &[Vec<usize>],
255 ) -> SklResult<Vec<usize>> {
256 let mut order = Self::borda_count(rankings)?;
258
259 let mut improved = true;
260 while improved {
261 improved = false;
262 for pos in 0..n.saturating_sub(1) {
263 let a = order[pos];
264 let b = order[pos + 1];
265 let cost_ab = prefer[b][a];
268 let cost_ba = prefer[a][b];
269 if cost_ba < cost_ab {
270 order.swap(pos, pos + 1);
271 improved = true;
272 }
273 }
274 }
275
276 Ok(order)
277 }
278}
279
280#[derive(Debug, Clone)]
282pub struct ConsensusMetrics;
283
284impl ConsensusMetrics {
285 pub fn compute_consensus(rankings: &[Vec<usize>]) -> SklResult<f64> {
294 let n = validate_rankings(rankings)?;
295 let m = rankings.len();
296 if m == 1 || n <= 1 {
297 return Ok(1.0);
298 }
299
300 let mut rank_of = vec![vec![0usize; n]; m];
302 for (r, ranking) in rankings.iter().enumerate() {
303 for (position, &item) in ranking.iter().enumerate() {
304 rank_of[r][item] = position;
305 }
306 }
307
308 let total_pairs = (n * (n - 1) / 2) as f64;
309 let mut tau_sum = 0.0;
310 let mut comparisons = 0usize;
311
312 for r1 in 0..m {
313 for r2 in (r1 + 1)..m {
314 let mut concordant: i64 = 0;
315 let mut discordant: i64 = 0;
316 for i in 0..n {
317 for j in (i + 1)..n {
318 let a = rank_of[r1][i].cmp(&rank_of[r1][j]);
319 let b = rank_of[r2][i].cmp(&rank_of[r2][j]);
320 if a == b {
321 concordant += 1;
322 } else {
323 discordant += 1;
324 }
325 }
326 }
327 tau_sum += (concordant - discordant) as f64 / total_pairs;
328 comparisons += 1;
329 }
330 }
331
332 Ok(tau_sum / comparisons as f64)
333 }
334}
335
336#[derive(Debug, Clone)]
338pub struct MultiCriteriaEvaluation;
339
340impl MultiCriteriaEvaluation {
341 pub fn evaluate_multi_criteria(
356 criteria_scores: &[Vec<f64>],
357 criteria_weights: &[f64],
358 ) -> SklResult<Vec<f64>> {
359 let n_criteria = criteria_scores.len();
360 if n_criteria == 0 {
361 return Err(SklearsError::InvalidInput(
362 "evaluate_multi_criteria requires at least one criterion".to_string(),
363 ));
364 }
365 if criteria_weights.len() != n_criteria {
366 return Err(SklearsError::InvalidInput(format!(
367 "evaluate_multi_criteria: number of weights ({}) must equal number of criteria ({})",
368 criteria_weights.len(),
369 n_criteria
370 )));
371 }
372
373 let n_alternatives = criteria_scores[0].len();
374 if n_alternatives == 0 {
375 return Err(SklearsError::InvalidInput(
376 "evaluate_multi_criteria requires at least one alternative".to_string(),
377 ));
378 }
379 for (c, column) in criteria_scores.iter().enumerate() {
380 if column.len() != n_alternatives {
381 return Err(SklearsError::InvalidInput(format!(
382 "evaluate_multi_criteria: criterion {} has {} alternatives, expected {}",
383 c,
384 column.len(),
385 n_alternatives
386 )));
387 }
388 }
389
390 let mut weight_sum = 0.0;
392 for &w in criteria_weights {
393 if !w.is_finite() || w < 0.0 {
394 return Err(SklearsError::InvalidInput(
395 "evaluate_multi_criteria: weights must be finite and non-negative".to_string(),
396 ));
397 }
398 weight_sum += w;
399 }
400 if weight_sum <= 0.0 {
401 return Err(SklearsError::InvalidInput(
402 "evaluate_multi_criteria: sum of weights must be positive".to_string(),
403 ));
404 }
405 let norm_weights: Vec<f64> = criteria_weights.iter().map(|&w| w / weight_sum).collect();
406
407 let mut weighted = vec![vec![0.0f64; n_alternatives]; n_criteria];
410 for c in 0..n_criteria {
411 let column_norm = criteria_scores[c]
412 .iter()
413 .map(|&v| v * v)
414 .sum::<f64>()
415 .sqrt();
416 for i in 0..n_alternatives {
417 let normalised = if column_norm > 0.0 {
418 criteria_scores[c][i] / column_norm
419 } else {
420 0.0
422 };
423 weighted[c][i] = normalised * norm_weights[c];
424 }
425 }
426
427 let mut ideal_best = vec![0.0f64; n_criteria];
429 let mut ideal_worst = vec![0.0f64; n_criteria];
430 for c in 0..n_criteria {
431 ideal_best[c] = weighted[c]
432 .iter()
433 .copied()
434 .fold(f64::NEG_INFINITY, f64::max);
435 ideal_worst[c] = weighted[c].iter().copied().fold(f64::INFINITY, f64::min);
436 }
437
438 let mut closeness = vec![0.0f64; n_alternatives];
440 for i in 0..n_alternatives {
441 let mut d_pos = 0.0;
442 let mut d_neg = 0.0;
443 for c in 0..n_criteria {
444 let v = weighted[c][i];
445 d_pos += (v - ideal_best[c]).powi(2);
446 d_neg += (v - ideal_worst[c]).powi(2);
447 }
448 d_pos = d_pos.sqrt();
449 d_neg = d_neg.sqrt();
450 let denom = d_pos + d_neg;
451 closeness[i] = if denom > 0.0 {
452 d_neg / denom
453 } else {
454 1.0
456 };
457 }
458
459 Ok(closeness)
460 }
461}
462
463fn validate_rankings(rankings: &[Vec<usize>]) -> SklResult<usize> {
466 if rankings.is_empty() {
467 return Err(SklearsError::InvalidInput(
468 "rank aggregation requires at least one ranking".to_string(),
469 ));
470 }
471
472 let n = rankings[0].len();
473 for (idx, ranking) in rankings.iter().enumerate() {
474 if ranking.len() != n {
475 return Err(SklearsError::InvalidInput(format!(
476 "ranking {} has length {} but expected {}",
477 idx,
478 ranking.len(),
479 n
480 )));
481 }
482 let mut seen = vec![false; n];
483 for &item in ranking {
484 if item >= n {
485 return Err(SklearsError::InvalidInput(format!(
486 "ranking {idx} contains item index {item} out of range 0..{n}"
487 )));
488 }
489 if seen[item] {
490 return Err(SklearsError::InvalidInput(format!(
491 "ranking {idx} contains duplicate item index {item}"
492 )));
493 }
494 seen[item] = true;
495 }
496 }
497
498 Ok(n)
499}
500
501fn pairwise_preference_matrix(rankings: &[Vec<usize>], n: usize) -> Vec<Vec<usize>> {
504 let mut prefer = vec![vec![0usize; n]; n];
505 for ranking in rankings {
506 let mut rank_of = vec![0usize; n];
507 for (position, &item) in ranking.iter().enumerate() {
508 rank_of[item] = position;
509 }
510 for i in 0..n {
511 for j in 0..n {
512 if i != j && rank_of[i] < rank_of[j] {
513 prefer[i][j] += 1;
514 }
515 }
516 }
517 }
518 prefer
519}
520
521fn kemeny_cost(order: &[usize], prefer: &[Vec<usize>]) -> usize {
525 let n = order.len();
526 let mut cost = 0usize;
527 for a in 0..n {
528 for b in (a + 1)..n {
529 let i = order[a];
530 let j = order[b];
531 cost += prefer[j][i];
533 }
534 }
535 cost
536}
537
538fn lexicographically_smaller(candidate: &[usize], current_best: &[usize]) -> bool {
540 for (c, b) in candidate.iter().zip(current_best.iter()) {
541 match c.cmp(b) {
542 std::cmp::Ordering::Less => return true,
543 std::cmp::Ordering::Greater => return false,
544 std::cmp::Ordering::Equal => {}
545 }
546 }
547 false
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn weighted_average_basic() {
556 let result =
558 WeightedAveraging::weighted_average(&[1.0, 2.0, 3.0], &[1.0, 1.0, 2.0]).unwrap();
559 assert!((result - 2.25).abs() < 1e-12);
560 }
561
562 #[test]
563 fn weighted_average_normalises_weights() {
564 let a = WeightedAveraging::weighted_average(&[4.0, 8.0], &[1.0, 3.0]).unwrap();
566 let b = WeightedAveraging::weighted_average(&[4.0, 8.0], &[10.0, 30.0]).unwrap();
567 assert!((a - 7.0).abs() < 1e-12);
569 assert!((a - b).abs() < 1e-12);
570 }
571
572 #[test]
573 fn weighted_average_rejects_zero_weights() {
574 assert!(WeightedAveraging::weighted_average(&[1.0, 2.0], &[0.0, 0.0]).is_err());
575 assert!(WeightedAveraging::weighted_average(&[1.0, 2.0], &[-1.0, 1.0]).is_err());
576 assert!(WeightedAveraging::weighted_average(&[], &[]).is_err());
577 assert!(WeightedAveraging::weighted_average(&[1.0], &[1.0, 2.0]).is_err());
578 }
579
580 #[test]
581 fn aggregate_metrics_mean_and_weighted() {
582 let mean = MetricsAggregator::aggregate_metrics(&[2.0, 4.0, 6.0], None).unwrap();
583 assert!((mean - 4.0).abs() < 1e-12);
584
585 let weighted =
586 MetricsAggregator::aggregate_metrics(&[2.0, 4.0, 6.0], Some(&[1.0, 0.0, 0.0])).unwrap();
587 assert!((weighted - 2.0).abs() < 1e-12);
588 }
589
590 #[test]
591 fn aggregate_with_strategies() {
592 let v = [1.0, 2.0, 3.0, 4.0];
593 assert!(
594 (MetricsAggregator::aggregate_with(&v, AggregationStrategy::Median, None).unwrap()
595 - 2.5)
596 .abs()
597 < 1e-12
598 );
599 assert!(
600 (MetricsAggregator::aggregate_with(&v, AggregationStrategy::Min, None).unwrap() - 1.0)
601 .abs()
602 < 1e-12
603 );
604 assert!(
605 (MetricsAggregator::aggregate_with(&v, AggregationStrategy::Max, None).unwrap() - 4.0)
606 .abs()
607 < 1e-12
608 );
609 assert!(
610 MetricsAggregator::aggregate_with(&v, AggregationStrategy::Weighted, None).is_err()
611 );
612 assert!(MetricsAggregator::aggregate_with(&[], AggregationStrategy::Mean, None).is_err());
613 }
614
615 #[test]
616 fn median_odd_and_even() {
617 assert!((median(&[3.0, 1.0, 2.0]) - 2.0).abs() < 1e-12);
618 assert!((median(&[3.0, 1.0, 2.0, 4.0]) - 2.5).abs() < 1e-12);
619 }
620
621 #[test]
622 fn borda_count_hand_computed() {
623 let rankings = vec![vec![0, 1, 2], vec![1, 0, 2], vec![0, 2, 1]];
630 let consensus = RankAggregation::borda_count(&rankings).unwrap();
631 assert_eq!(consensus, vec![0, 1, 2]);
632 }
633
634 #[test]
635 fn borda_count_tie_breaks_by_index() {
636 let rankings = vec![vec![0, 1, 2], vec![2, 1, 0]];
639 let consensus = RankAggregation::borda_count(&rankings).unwrap();
640 assert_eq!(consensus, vec![0, 1, 2]);
641 }
642
643 #[test]
644 fn borda_count_rejects_bad_input() {
645 assert!(RankAggregation::borda_count(&[]).is_err());
646 assert!(RankAggregation::borda_count(&[vec![0, 0, 1]]).is_err());
648 assert!(RankAggregation::borda_count(&[vec![0, 1, 5]]).is_err());
650 assert!(RankAggregation::borda_count(&[vec![0, 1, 2], vec![0, 1]]).is_err());
652 }
653
654 #[test]
655 fn kemeny_optimal_three_items_exact() {
656 let rankings = vec![vec![0, 1, 2], vec![1, 2, 0], vec![2, 0, 1]];
662 let consensus = RankAggregation::kemeny_optimal(&rankings).unwrap();
663 assert_eq!(consensus, vec![0, 1, 2]);
664 }
665
666 #[test]
667 fn kemeny_optimal_clear_majority() {
668 let rankings = vec![vec![0, 1, 2], vec![0, 1, 2], vec![0, 2, 1]];
672 let consensus = RankAggregation::kemeny_optimal(&rankings).unwrap();
673 assert_eq!(consensus, vec![0, 1, 2]);
674 }
675
676 #[test]
677 fn kemeny_optimal_recovers_unanimous() {
678 let rankings = vec![vec![2, 0, 3, 1], vec![2, 0, 3, 1], vec![2, 0, 3, 1]];
680 let consensus = RankAggregation::kemeny_optimal(&rankings).unwrap();
681 assert_eq!(consensus, vec![2, 0, 3, 1]);
682 let prefer = pairwise_preference_matrix(&rankings, 4);
684 assert_eq!(kemeny_cost(&consensus, &prefer), 0);
685 }
686
687 #[test]
688 fn kemeny_local_search_large_n_unanimous() {
689 let truth: Vec<usize> = vec![3, 7, 1, 9, 0, 5, 2, 8, 4, 6];
691 let rankings = vec![truth.clone(), truth.clone(), truth.clone()];
692 let consensus = RankAggregation::kemeny_optimal(&rankings).unwrap();
693 assert_eq!(consensus, truth);
694 }
695
696 #[test]
697 fn compute_consensus_identical_is_one() {
698 let rankings = vec![vec![0, 1, 2, 3], vec![0, 1, 2, 3]];
699 let c = ConsensusMetrics::compute_consensus(&rankings).unwrap();
700 assert!((c - 1.0).abs() < 1e-12);
701 }
702
703 #[test]
704 fn compute_consensus_reversed_is_minus_one() {
705 let rankings = vec![vec![0, 1, 2, 3], vec![3, 2, 1, 0]];
706 let c = ConsensusMetrics::compute_consensus(&rankings).unwrap();
707 assert!((c + 1.0).abs() < 1e-12);
708 }
709
710 #[test]
711 fn compute_consensus_single_ranking() {
712 let c = ConsensusMetrics::compute_consensus(&[vec![0, 1, 2]]).unwrap();
713 assert!((c - 1.0).abs() < 1e-12);
714 }
715
716 #[test]
717 fn compute_consensus_one_swap() {
718 let rankings = vec![vec![0, 1, 2, 3], vec![1, 0, 2, 3]];
721 let c = ConsensusMetrics::compute_consensus(&rankings).unwrap();
722 assert!((c - (4.0 / 6.0)).abs() < 1e-12);
723 }
724
725 #[test]
726 fn topsis_prefers_dominant_alternative() {
727 let criteria = vec![vec![10.0, 5.0, 1.0], vec![10.0, 6.0, 2.0]];
730 let weights = vec![0.5, 0.5];
731 let scores = MultiCriteriaEvaluation::evaluate_multi_criteria(&criteria, &weights).unwrap();
732 assert_eq!(scores.len(), 3);
733 assert!((scores[0] - 1.0).abs() < 1e-9);
735 assert!(scores[2].abs() < 1e-9);
737 assert!(scores[0] > scores[1] && scores[1] > scores[2]);
739 }
740
741 #[test]
742 fn topsis_validates_inputs() {
743 assert!(MultiCriteriaEvaluation::evaluate_multi_criteria(&[], &[]).is_err());
744 assert!(
746 MultiCriteriaEvaluation::evaluate_multi_criteria(&[vec![1.0, 2.0]], &[1.0, 1.0])
747 .is_err()
748 );
749 assert!(MultiCriteriaEvaluation::evaluate_multi_criteria(
751 &[vec![1.0, 2.0], vec![1.0]],
752 &[1.0, 1.0]
753 )
754 .is_err());
755 assert!(
757 MultiCriteriaEvaluation::evaluate_multi_criteria(&[vec![1.0, 2.0]], &[0.0]).is_err()
758 );
759 }
760}