1use super::{evaluate_ner_model, GoldEntity, MetricWithVariance, NEREvaluationResults};
36use anno::{Error, Model, Result};
37use serde::{Deserialize, Serialize};
38use std::collections::HashMap;
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct MultiRunConfig {
47 pub num_runs: usize,
49 pub shuffle: bool,
51 pub seed_base: u64,
53 pub parallel: bool,
55 pub min_runs_for_ci: usize,
57}
58
59impl Default for MultiRunConfig {
60 fn default() -> Self {
61 Self {
62 num_runs: 5,
63 shuffle: true,
64 seed_base: 42,
65 parallel: false,
66 min_runs_for_ci: 3,
67 }
68 }
69}
70
71impl MultiRunConfig {
72 pub fn new() -> Self {
74 Self::default()
75 }
76
77 pub fn with_runs(mut self, n: usize) -> Self {
79 self.num_runs = n.max(1);
80 self
81 }
82
83 pub fn with_shuffle(mut self, shuffle: bool) -> Self {
85 self.shuffle = shuffle;
86 self
87 }
88
89 pub fn with_seed_base(mut self, seed: u64) -> Self {
91 self.seed_base = seed;
92 self
93 }
94
95 pub fn with_parallel(mut self, parallel: bool) -> Self {
97 self.parallel = parallel;
98 self
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct MultiRunResults {
111 pub precision: MetricWithVariance,
113 pub recall: MetricWithVariance,
115 pub f1: MetricWithVariance,
117 pub macro_f1: Option<MetricWithVariance>,
119 pub per_type_f1: HashMap<String, MetricWithVariance>,
121 pub throughput: MetricWithVariance,
123 pub individual_runs: Vec<NEREvaluationResults>,
125 pub config: MultiRunConfig,
127 pub seeds: Vec<u64>,
129}
130
131impl MultiRunResults {
132 pub fn format_summary(&self) -> String {
134 let mut s = String::new();
135
136 s.push_str(&format!("Multi-Run Evaluation (n={})\n", self.f1.n));
137 s.push_str(&format!("{:=<50}\n", ""));
138 s.push_str(&format!(
139 "{:<12} {:<20} {:<15}\n",
140 "Metric", "Mean ± CI95", "Range"
141 ));
142 s.push_str(&format!("{:-<50}\n", ""));
143
144 s.push_str(&format!(
145 "{:<12} {:<20} {:<15}\n",
146 "Precision",
147 self.precision.format_with_ci(),
148 self.precision.format_with_range()
149 ));
150 s.push_str(&format!(
151 "{:<12} {:<20} {:<15}\n",
152 "Recall",
153 self.recall.format_with_ci(),
154 self.recall.format_with_range()
155 ));
156 s.push_str(&format!(
157 "{:<12} {:<20} {:<15}\n",
158 "F1",
159 self.f1.format_with_ci(),
160 self.f1.format_with_range()
161 ));
162
163 if let Some(ref macro_f1) = self.macro_f1 {
164 s.push_str(&format!(
165 "{:<12} {:<20} {:<15}\n",
166 "Macro F1",
167 macro_f1.format_with_ci(),
168 macro_f1.format_with_range()
169 ));
170 }
171
172 s.push_str(&format!("{:-<50}\n", ""));
173
174 if !self.per_type_f1.is_empty() {
176 s.push_str("\nPer-Type F1:\n");
177 let mut types: Vec<_> = self.per_type_f1.keys().collect();
178 types.sort();
179 for entity_type in types {
180 if let Some(metric) = self.per_type_f1.get(entity_type) {
181 s.push_str(&format!(
182 " {:<10} {}\n",
183 entity_type,
184 metric.format_with_ci()
185 ));
186 }
187 }
188 }
189
190 let cv = self.f1.coefficient_of_variation();
192 s.push_str(&format!("\nStability: CV = {:.2}% ", cv * 100.0));
193 if cv < 0.02 {
194 s.push_str("(excellent)");
195 } else if cv < 0.05 {
196 s.push_str("(good)");
197 } else if cv < 0.10 {
198 s.push_str("(moderate)");
199 } else {
200 s.push_str("(high variance - investigate)");
201 }
202 s.push('\n');
203
204 s
205 }
206
207 pub fn is_stable(&self, threshold: f64) -> bool {
211 self.f1.coefficient_of_variation() < threshold
212 }
213}
214
215#[derive(Debug, Clone)]
223pub struct MultiRunEvaluator {
224 config: MultiRunConfig,
225}
226
227impl MultiRunEvaluator {
228 pub fn new(config: MultiRunConfig) -> Self {
230 Self { config }
231 }
232
233 pub fn default_config() -> Self {
235 Self::new(MultiRunConfig::default())
236 }
237
238 pub fn evaluate(
240 &self,
241 model: &dyn Model,
242 test_cases: &[(String, Vec<GoldEntity>)],
243 ) -> Result<MultiRunResults> {
244 if test_cases.is_empty() {
245 return Err(Error::InvalidInput("Empty test cases".to_string()));
246 }
247
248 let mut all_results = Vec::with_capacity(self.config.num_runs);
249 let mut seeds = Vec::with_capacity(self.config.num_runs);
250
251 for run in 0..self.config.num_runs {
252 let seed = self.config.seed_base + run as u64;
253 seeds.push(seed);
254
255 let data = if self.config.shuffle {
257 shuffle_with_seed(test_cases, seed)
258 } else {
259 test_cases.to_vec()
260 };
261
262 let result = evaluate_ner_model(model, &data)?;
264 all_results.push(result);
265 }
266
267 let precision_samples: Vec<f64> = all_results.iter().map(|r| r.precision).collect();
269 let recall_samples: Vec<f64> = all_results.iter().map(|r| r.recall).collect();
270 let f1_samples: Vec<f64> = all_results.iter().map(|r| r.f1).collect();
271 let throughput_samples: Vec<f64> =
272 all_results.iter().map(|r| r.tokens_per_second).collect();
273
274 let macro_f1_samples: Vec<f64> = all_results.iter().filter_map(|r| r.macro_f1).collect();
276 let macro_f1 = if macro_f1_samples.len() >= self.config.min_runs_for_ci {
277 Some(MetricWithVariance::from_samples(¯o_f1_samples))
278 } else {
279 None
280 };
281
282 let mut per_type_f1 = HashMap::new();
284 if let Some(first) = all_results.first() {
285 for entity_type in first.per_type.keys() {
286 let type_f1s: Vec<f64> = all_results
287 .iter()
288 .filter_map(|r| r.per_type.get(entity_type).map(|m| m.f1))
289 .collect();
290 if type_f1s.len() >= self.config.min_runs_for_ci {
291 per_type_f1.insert(
292 entity_type.clone(),
293 MetricWithVariance::from_samples(&type_f1s),
294 );
295 }
296 }
297 }
298
299 Ok(MultiRunResults {
300 precision: MetricWithVariance::from_samples(&precision_samples),
301 recall: MetricWithVariance::from_samples(&recall_samples),
302 f1: MetricWithVariance::from_samples(&f1_samples),
303 macro_f1,
304 per_type_f1,
305 throughput: MetricWithVariance::from_samples(&throughput_samples),
306 individual_runs: all_results,
307 config: self.config.clone(),
308 seeds,
309 })
310 }
311
312 pub fn quick_eval(
314 model: &dyn Model,
315 test_cases: &[(String, Vec<GoldEntity>)],
316 ) -> Result<MultiRunResults> {
317 let evaluator = Self::new(MultiRunConfig::new().with_runs(3));
318 evaluator.evaluate(model, test_cases)
319 }
320
321 pub fn thorough_eval(
323 model: &dyn Model,
324 test_cases: &[(String, Vec<GoldEntity>)],
325 ) -> Result<MultiRunResults> {
326 let evaluator = Self::new(MultiRunConfig::new().with_runs(10));
327 evaluator.evaluate(model, test_cases)
328 }
329}
330
331fn shuffle_with_seed<T: Clone>(data: &[T], seed: u64) -> Vec<T> {
337 let mut indices: Vec<usize> = (0..data.len()).collect();
338
339 let mut rng_state = seed;
341 for i in (1..indices.len()).rev() {
342 rng_state = rng_state
344 .wrapping_mul(6364136223846793005)
345 .wrapping_add(1442695040888963407);
346 let j = (rng_state % (i as u64 + 1)) as usize;
347 indices.swap(i, j);
348 }
349
350 indices.into_iter().map(|i| data[i].clone()).collect()
351}
352
353pub fn compare_models_multi_run(
355 model_a: (&str, &dyn Model),
356 model_b: (&str, &dyn Model),
357 test_cases: &[(String, Vec<GoldEntity>)],
358 config: MultiRunConfig,
359) -> Result<ModelComparison> {
360 let evaluator = MultiRunEvaluator::new(config);
361
362 let results_a = evaluator.evaluate(model_a.1, test_cases)?;
363 let results_b = evaluator.evaluate(model_b.1, test_cases)?;
364
365 let (t_stat, p_value) = paired_t_test(
367 &results_a
368 .individual_runs
369 .iter()
370 .map(|r| r.f1)
371 .collect::<Vec<_>>(),
372 &results_b
373 .individual_runs
374 .iter()
375 .map(|r| r.f1)
376 .collect::<Vec<_>>(),
377 );
378
379 let difference = results_a.f1.mean - results_b.f1.mean;
380 let significant = p_value < 0.05;
381
382 Ok(ModelComparison {
383 model_a_name: model_a.0.to_string(),
384 model_b_name: model_b.0.to_string(),
385 model_a_f1: results_a.f1,
386 model_b_f1: results_b.f1,
387 f1_difference: difference,
388 t_statistic: t_stat,
389 p_value,
390 significant_at_05: significant,
391 winner: if significant {
392 if difference > 0.0 {
393 Some(model_a.0.to_string())
394 } else {
395 Some(model_b.0.to_string())
396 }
397 } else {
398 None
399 },
400 })
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct ModelComparison {
406 pub model_a_name: String,
408 pub model_b_name: String,
410 pub model_a_f1: MetricWithVariance,
412 pub model_b_f1: MetricWithVariance,
414 pub f1_difference: f64,
416 pub t_statistic: f64,
418 pub p_value: f64,
420 pub significant_at_05: bool,
422 pub winner: Option<String>,
424}
425
426impl std::fmt::Display for ModelComparison {
427 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428 writeln!(
429 f,
430 "Model Comparison: {} vs {}",
431 self.model_a_name, self.model_b_name
432 )?;
433 writeln!(f, "{:-<50}", "")?;
434 writeln!(
435 f,
436 "{}: {}",
437 self.model_a_name,
438 self.model_a_f1.format_with_ci()
439 )?;
440 writeln!(
441 f,
442 "{}: {}",
443 self.model_b_name,
444 self.model_b_f1.format_with_ci()
445 )?;
446 writeln!(f, "Difference: {:+.2}%", self.f1_difference * 100.0)?;
447 writeln!(f, "p-value: {:.4}", self.p_value)?;
448 if self.significant_at_05 {
449 writeln!(
450 f,
451 "Result: {} significantly better (p < 0.05)",
452 self.winner.as_deref().unwrap_or("?")
453 )?;
454 } else {
455 writeln!(f, "Result: No significant difference")?;
456 }
457 Ok(())
458 }
459}
460
461fn paired_t_test(a: &[f64], b: &[f64]) -> (f64, f64) {
465 if a.len() != b.len() || a.is_empty() {
466 return (0.0, 1.0);
467 }
468
469 let n = a.len() as f64;
470 let diffs: Vec<f64> = a.iter().zip(b.iter()).map(|(x, y)| x - y).collect();
471
472 let mean_diff: f64 = diffs.iter().sum::<f64>() / n;
473 let var_diff: f64 = if a.len() > 1 {
474 diffs.iter().map(|d| (d - mean_diff).powi(2)).sum::<f64>() / (n - 1.0)
475 } else {
476 0.0
477 };
478
479 let std_err = (var_diff / n).sqrt();
480
481 let t_stat = if std_err > 1e-10 {
482 mean_diff / std_err
483 } else {
484 if mean_diff.abs() > 1e-10 {
487 mean_diff.signum() * 100.0
489 } else {
490 0.0
491 }
492 };
493
494 let p_value = 2.0 * (1.0 - normal_cdf(t_stat.abs()));
498
499 (t_stat, p_value)
500}
501
502fn normal_cdf(x: f64) -> f64 {
504 let a1 = 0.254829592;
506 let a2 = -0.284496736;
507 let a3 = 1.421413741;
508 let a4 = -1.453152027;
509 let a5 = 1.061405429;
510 let p = 0.3275911;
511
512 let sign = if x < 0.0 { -1.0 } else { 1.0 };
513 let x = x.abs() / std::f64::consts::SQRT_2;
514
515 let t = 1.0 / (1.0 + p * x);
516 let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
517
518 0.5 * (1.0 + sign * y)
519}
520
521#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn test_shuffle_deterministic() {
531 let data: Vec<i32> = (0..10).collect();
532
533 let shuffled1 = shuffle_with_seed(&data, 42);
534 let shuffled2 = shuffle_with_seed(&data, 42);
535 let shuffled3 = shuffle_with_seed(&data, 43);
536
537 assert_eq!(
538 shuffled1, shuffled2,
539 "Same seed should produce same shuffle"
540 );
541 assert_ne!(
542 shuffled1, shuffled3,
543 "Different seeds should produce different shuffles"
544 );
545 assert_ne!(shuffled1, data, "Shuffle should change order");
546 }
547
548 #[test]
549 fn test_shuffle_preserves_elements() {
550 let data: Vec<i32> = (0..20).collect();
551 let shuffled = shuffle_with_seed(&data, 12345);
552
553 let mut sorted = shuffled.clone();
554 sorted.sort();
555 assert_eq!(sorted, data, "Shuffle should preserve all elements");
556 }
557
558 #[test]
559 fn test_metric_with_variance_from_samples() {
560 let samples = vec![0.80, 0.82, 0.85, 0.83, 0.80];
561 let metric = MetricWithVariance::from_samples(&samples);
562
563 assert!((metric.mean - 0.82).abs() < 0.01);
564 assert!(metric.std_dev > 0.0);
565 assert!(metric.ci_95 > 0.0);
566 assert_eq!(metric.n, 5);
567 assert!((metric.min - 0.80).abs() < 0.01);
568 assert!((metric.max - 0.85).abs() < 0.01);
569 }
570
571 #[test]
572 fn test_metric_with_variance_empty() {
573 let samples: Vec<f64> = vec![];
574 let metric = MetricWithVariance::from_samples(&samples);
575
576 assert_eq!(metric.n, 0);
577 assert!((metric.mean - 0.0).abs() < 0.001);
578 }
579
580 #[test]
581 fn test_metric_with_variance_single() {
582 let samples = vec![0.85];
583 let metric = MetricWithVariance::from_samples(&samples);
584
585 assert_eq!(metric.n, 1);
586 assert!((metric.mean - 0.85).abs() < 0.001);
587 assert!((metric.std_dev - 0.0).abs() < 0.001);
588 }
589
590 #[test]
591 fn test_coefficient_of_variation() {
592 let stable = MetricWithVariance::from_samples(&[0.85, 0.85, 0.85, 0.85, 0.85]);
593 let variable = MetricWithVariance::from_samples(&[0.60, 0.70, 0.80, 0.90, 1.00]);
594
595 assert!(stable.coefficient_of_variation() < 0.01);
596 assert!(variable.coefficient_of_variation() > 0.1);
597 }
598
599 #[test]
600 fn test_paired_t_test_identical() {
601 let a = vec![0.80, 0.82, 0.85, 0.83, 0.80];
602 let b = vec![0.80, 0.82, 0.85, 0.83, 0.80];
603
604 let (t_stat, p_value) = paired_t_test(&a, &b);
605
606 assert!((t_stat - 0.0).abs() < 0.001);
607 assert!((p_value - 1.0).abs() < 0.001);
608 }
609
610 #[test]
611 fn test_paired_t_test_different() {
612 let a = vec![0.90, 0.92, 0.88, 0.91, 0.94];
614 let b = vec![0.80, 0.78, 0.79, 0.81, 0.82];
615
616 let (t_stat, p_value) = paired_t_test(&a, &b);
617
618 assert!(t_stat > 0.0, "t_stat should be positive: {}", t_stat);
620 assert!(
622 p_value < 0.05,
623 "p-value should indicate significance: {}",
624 p_value
625 );
626 }
627
628 #[test]
629 fn test_multi_run_config_builder() {
630 let config = MultiRunConfig::new()
631 .with_runs(10)
632 .with_shuffle(false)
633 .with_seed_base(123);
634
635 assert_eq!(config.num_runs, 10);
636 assert!(!config.shuffle);
637 assert_eq!(config.seed_base, 123);
638 }
639
640 #[test]
641 fn test_normal_cdf() {
642 assert!((normal_cdf(0.0) - 0.5).abs() < 0.01);
644
645 assert!((normal_cdf(2.0) - 0.977).abs() < 0.01);
647
648 assert!((normal_cdf(-2.0) - 0.023).abs() < 0.01);
650 }
651}
652
653#[cfg(test)]
658mod proptests {
659 use super::*;
660 use proptest::prelude::*;
661
662 proptest! {
667 #[test]
669 fn prop_shuffle_deterministic(seed in any::<u64>(), len in 0usize..100) {
670 let data: Vec<usize> = (0..len).collect();
671 let s1 = shuffle_with_seed(&data, seed);
672 let s2 = shuffle_with_seed(&data, seed);
673 prop_assert_eq!(s1, s2, "Same seed should produce same shuffle");
674 }
675
676 #[test]
678 fn prop_shuffle_preserves_elements(seed in any::<u64>(), len in 0usize..50) {
679 let data: Vec<usize> = (0..len).collect();
680 let mut shuffled = shuffle_with_seed(&data, seed);
681 shuffled.sort();
682 prop_assert_eq!(shuffled, data, "Shuffle should preserve all elements");
683 }
684
685 #[test]
687 fn prop_different_seeds_differ(seed1 in any::<u64>(), seed2 in any::<u64>()) {
688 let data: Vec<usize> = (0..20).collect();
690 let s1 = shuffle_with_seed(&data, seed1);
691 let s2 = shuffle_with_seed(&data, seed2);
692
693 if seed1 != seed2 {
696 let mut sorted1 = s1.clone();
698 let mut sorted2 = s2.clone();
699 sorted1.sort();
700 sorted2.sort();
701 prop_assert_eq!(sorted1, data.clone());
702 prop_assert_eq!(sorted2, data);
703 }
704 }
705 }
706
707 proptest! {
712 #[test]
714 fn prop_mean_within_range(samples in prop::collection::vec(0.0f64..1.0, 1..20)) {
715 let metric = MetricWithVariance::from_samples(&samples);
716 prop_assert!(metric.mean >= metric.min - 1e-10);
717 prop_assert!(metric.mean <= metric.max + 1e-10);
718 }
719
720 #[test]
722 fn prop_std_dev_non_negative(samples in prop::collection::vec(0.0f64..1.0, 1..20)) {
723 let metric = MetricWithVariance::from_samples(&samples);
724 prop_assert!(metric.std_dev >= 0.0);
725 }
726
727 #[test]
729 fn prop_ci95_non_negative(samples in prop::collection::vec(0.0f64..1.0, 1..20)) {
730 let metric = MetricWithVariance::from_samples(&samples);
731 prop_assert!(metric.ci_95 >= 0.0);
732 }
733
734 #[test]
736 fn prop_n_matches_length(samples in prop::collection::vec(0.0f64..1.0, 0..20)) {
737 let metric = MetricWithVariance::from_samples(&samples);
738 prop_assert_eq!(metric.n, samples.len());
739 }
740
741 #[test]
743 fn prop_cv_non_negative(samples in prop::collection::vec(0.0f64..1.0, 1..20)) {
744 let metric = MetricWithVariance::from_samples(&samples);
745 let cv = metric.coefficient_of_variation();
746 prop_assert!(cv >= 0.0 || cv.is_nan(), "CV should be non-negative: {}", cv);
747 }
748
749 #[test]
751 fn prop_identical_zero_variance(value in 0.0f64..1.0, n in 2usize..10) {
752 let samples: Vec<f64> = vec![value; n];
753 let metric = MetricWithVariance::from_samples(&samples);
754 prop_assert!((metric.std_dev - 0.0).abs() < 1e-10, "Identical samples should have 0 std dev");
755 prop_assert!((metric.mean - value).abs() < 1e-10);
756 }
757 }
758
759 proptest! {
764 #[test]
766 fn prop_ttest_identical_no_difference(samples in prop::collection::vec(0.0f64..1.0, 2..10)) {
767 let (t_stat, p_value) = paired_t_test(&samples, &samples);
768 prop_assert!((t_stat - 0.0).abs() < 1e-10, "t-stat should be 0 for identical samples");
769 prop_assert!((p_value - 1.0).abs() < 0.01, "p-value should be ~1 for identical samples");
770 }
771
772 #[test]
774 fn prop_ttest_p_value_bounds(
775 a in prop::collection::vec(0.0f64..1.0, 2..10),
776 b in prop::collection::vec(0.0f64..1.0, 2..10)
777 ) {
778 let min_len = a.len().min(b.len());
780 let a = &a[..min_len];
781 let b = &b[..min_len];
782
783 let (_, p_value) = paired_t_test(a, b);
784 prop_assert!((0.0..=1.0).contains(&p_value), "p-value {} out of [0,1]", p_value);
785 }
786 }
787
788 proptest! {
793 #[test]
795 fn prop_cdf_monotonic(x1 in -5.0f64..5.0, x2 in -5.0f64..5.0) {
796 if x1 < x2 {
797 prop_assert!(normal_cdf(x1) <= normal_cdf(x2) + 1e-10);
798 }
799 }
800
801 #[test]
803 fn prop_cdf_bounds(x in -10.0f64..10.0) {
804 let cdf = normal_cdf(x);
805 prop_assert!((0.0..=1.0).contains(&cdf), "CDF {} out of bounds for x={}", cdf, x);
806 }
807
808 #[test]
810 fn prop_cdf_symmetric_at_zero(_unused in Just(())) {
811 prop_assert!((normal_cdf(0.0) - 0.5).abs() < 0.01);
812 }
813 }
814
815 proptest! {
820 #[test]
822 fn prop_config_builder(runs in 1usize..100, seed in any::<u64>(), shuffle in any::<bool>()) {
823 let config = MultiRunConfig::new()
824 .with_runs(runs)
825 .with_seed_base(seed)
826 .with_shuffle(shuffle);
827
828 prop_assert_eq!(config.num_runs, runs);
829 prop_assert_eq!(config.seed_base, seed);
830 prop_assert_eq!(config.shuffle, shuffle);
831 }
832
833 #[test]
835 fn prop_config_min_runs(_unused in Just(())) {
836 let config = MultiRunConfig::new().with_runs(0);
837 prop_assert!(config.num_runs >= 1);
838 }
839 }
840}