iris-vector-rag 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. adapters/rag_templates_bridge.py +578 -0
  2. common/__init__.py +1 -0
  3. common/batch_utils.py +294 -0
  4. common/config.py +58 -0
  5. common/connection_manager.py +242 -0
  6. common/connection_pool.py +445 -0
  7. common/database.py +141 -0
  8. common/database_schema_manager.py +289 -0
  9. common/db_init_with_indexes.py +181 -0
  10. common/db_vector_utils.py +181 -0
  11. common/environment_manager.py +220 -0
  12. common/huggingface_utils.py +206 -0
  13. common/iris_client.py +92 -0
  14. common/iris_connection_manager.py +411 -0
  15. common/iris_connection_pool.py +360 -0
  16. common/iris_connector.py +71 -0
  17. common/iris_dbapi_connector.py +323 -0
  18. common/iris_index_utils.py +195 -0
  19. common/iris_sql_utils.py +704 -0
  20. common/iris_stream_reader.py +166 -0
  21. common/llm_cache_config.py +308 -0
  22. common/llm_cache_iris.py +671 -0
  23. common/llm_cache_manager.py +911 -0
  24. common/utils.py +559 -0
  25. common/vector_sql_utils.py +584 -0
  26. evaluation_framework/__init__.py +66 -0
  27. evaluation_framework/biomedical_question_generator.py +826 -0
  28. evaluation_framework/comparative_analysis_system.py +1173 -0
  29. evaluation_framework/empirical_reporting.py +1298 -0
  30. evaluation_framework/evaluation_orchestrator.py +1123 -0
  31. evaluation_framework/execute_production_evaluation.py +382 -0
  32. evaluation_framework/pmc_data_pipeline.py +1332 -0
  33. evaluation_framework/production_evaluation_config.py +353 -0
  34. evaluation_framework/ragas_metrics_framework.py +1013 -0
  35. evaluation_framework/real_production_evaluation.py +544 -0
  36. evaluation_framework/run_10k_evaluation.py +175 -0
  37. evaluation_framework/simple_test.py +364 -0
  38. evaluation_framework/statistical_evaluation_methodology.py +1016 -0
  39. evaluation_framework/test_framework.py +281 -0
  40. evaluation_framework/test_iris_connectivity.py +244 -0
  41. evaluation_framework/test_small_scale_evaluation.py +432 -0
  42. evaluation_framework/true_e2e_evaluation.py +639 -0
  43. evaluation_framework/visualization_engine.py +585 -0
  44. iris_rag/__init__.py +284 -0
  45. iris_rag/config/default_config.yaml +295 -0
  46. iris_vector_rag-0.2.1.dist-info/METADATA +499 -0
  47. iris_vector_rag-0.2.1.dist-info/RECORD +50 -0
  48. iris_vector_rag-0.2.1.dist-info/WHEEL +5 -0
  49. iris_vector_rag-0.2.1.dist-info/entry_points.txt +2 -0
  50. iris_vector_rag-0.2.1.dist-info/top_level.txt +4 -0
@@ -0,0 +1,1016 @@
1
+ """
2
+ Statistical Evaluation Methodology for Biomedical RAG Pipeline Comparison
3
+
4
+ This module provides comprehensive statistical analysis capabilities for rigorous
5
+ comparison of RAG pipeline performance using RAGAS metrics with proper statistical
6
+ inference, power analysis, and multiple comparison corrections.
7
+
8
+ Key Features:
9
+ 1. Power analysis and sample size determination
10
+ 2. Statistical test selection and validation
11
+ 3. Multiple comparison corrections
12
+ 4. Effect size calculations and interpretation
13
+ 5. Bootstrap confidence intervals
14
+ 6. Bayesian statistical analysis
15
+ 7. Experimental design validation
16
+ 8. Comprehensive statistical reporting
17
+ """
18
+
19
+ import json
20
+ import logging
21
+ import warnings
22
+ from dataclasses import dataclass, field
23
+ from enum import Enum
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional, Tuple, Union
26
+
27
+ import numpy as np
28
+ import pandas as pd
29
+
30
+ warnings.filterwarnings("ignore")
31
+
32
+ import pingouin as pg
33
+
34
+ # Statistical libraries
35
+ from scipy import stats
36
+ from scipy.stats import (
37
+ bootstrap,
38
+ chi2_contingency,
39
+ combine_pvalues,
40
+ f_oneway,
41
+ friedmanchisquare,
42
+ kruskal,
43
+ mannwhitneyu,
44
+ permutation_test,
45
+ ttest_ind,
46
+ ttest_rel,
47
+ wilcoxon,
48
+ )
49
+
50
+ # Machine learning for effect size
51
+ from sklearn.metrics import cohen_kappa_score
52
+ from sklearn.preprocessing import StandardScaler
53
+ from statsmodels.stats.contingency_tables import mcnemar
54
+ from statsmodels.stats.descriptivestats import describe
55
+ from statsmodels.stats.multitest import multipletests
56
+ from statsmodels.stats.power import ttest_power
57
+
58
+ # Bayesian analysis
59
+ try:
60
+ import arviz as az
61
+ import pymc as pm
62
+
63
+ BAYESIAN_AVAILABLE = True
64
+ except ImportError:
65
+ BAYESIAN_AVAILABLE = False
66
+ logging.warning("PyMC not available, Bayesian analysis disabled")
67
+
68
+ logger = logging.getLogger(__name__)
69
+
70
+
71
+ class StatTestType(Enum):
72
+ """Statistical test types for different scenarios."""
73
+
74
+ PARAMETRIC_INDEPENDENT = "parametric_independent"
75
+ PARAMETRIC_PAIRED = "parametric_paired"
76
+ NONPARAMETRIC_INDEPENDENT = "nonparametric_independent"
77
+ NONPARAMETRIC_PAIRED = "nonparametric_paired"
78
+ ANOVA = "anova"
79
+ KRUSKAL_WALLIS = "kruskal_wallis"
80
+ FRIEDMAN = "friedman"
81
+
82
+
83
+ class EffectSizeType(Enum):
84
+ """Effect size measures for different contexts."""
85
+
86
+ COHENS_D = "cohens_d"
87
+ HEDGES_G = "hedges_g"
88
+ GLASS_DELTA = "glass_delta"
89
+ ETA_SQUARED = "eta_squared"
90
+ OMEGA_SQUARED = "omega_squared"
91
+ CLIFF_DELTA = "cliff_delta"
92
+ COMMON_LANGUAGE_ES = "common_language_effect_size"
93
+
94
+
95
+ @dataclass
96
+ class StatisticalTestResult:
97
+ """Comprehensive statistical test result."""
98
+
99
+ test_name: str
100
+ test_statistic: float
101
+ p_value: float
102
+ p_value_adjusted: Optional[float]
103
+ effect_size: float
104
+ effect_size_type: str
105
+ effect_size_interpretation: str
106
+ confidence_interval: Tuple[float, float]
107
+ power: Optional[float]
108
+ sample_sizes: List[int]
109
+ assumptions_met: Dict[str, bool]
110
+ interpretation: str
111
+ raw_data: Dict[str, Any] = field(default_factory=dict)
112
+
113
+
114
+ @dataclass
115
+ class PowerAnalysisResult:
116
+ """Power analysis results for study design."""
117
+
118
+ effect_size: float
119
+ power: float
120
+ sample_size: int
121
+ alpha: float
122
+ test_type: str
123
+ recommendations: List[str]
124
+ sensitivity_analysis: Dict[str, float]
125
+
126
+
127
+ @dataclass
128
+ class MultipleComparisonResult:
129
+ """Results from multiple comparison procedures."""
130
+
131
+ method: str
132
+ original_p_values: List[float]
133
+ adjusted_p_values: List[float]
134
+ rejected_hypotheses: List[bool]
135
+ alpha_corrected: float
136
+ family_wise_error_rate: float
137
+ false_discovery_rate: float
138
+
139
+
140
+ class StatisticalEvaluationFramework:
141
+ """
142
+ Comprehensive statistical evaluation framework for biomedical RAG pipeline comparison.
143
+
144
+ Provides rigorous statistical analysis with proper experimental design,
145
+ power analysis, and multiple comparison corrections.
146
+ """
147
+
148
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
149
+ """
150
+ Initialize statistical evaluation framework.
151
+
152
+ Args:
153
+ config: Configuration dictionary for statistical parameters
154
+ """
155
+ self.config = config or self._get_default_config()
156
+
157
+ # Statistical parameters
158
+ self.alpha = self.config.get("alpha", 0.05)
159
+ self.power_target = self.config.get("power_target", 0.8)
160
+ self.effect_size_threshold = self.config.get("effect_size_threshold", 0.5)
161
+ self.bootstrap_samples = self.config.get("bootstrap_samples", 1000)
162
+ self.multiple_correction_method = self.config.get(
163
+ "multiple_correction_method", "fdr_bh"
164
+ )
165
+
166
+ logger.info("Statistical Evaluation Framework initialized")
167
+
168
+ def _get_default_config(self) -> Dict[str, Any]:
169
+ """Get default statistical configuration."""
170
+ return {
171
+ "alpha": 0.05,
172
+ "power_target": 0.8,
173
+ "effect_size_threshold": 0.5,
174
+ "bootstrap_samples": 1000,
175
+ "multiple_correction_method": "fdr_bh",
176
+ "normality_test": "shapiro",
177
+ "homoscedasticity_test": "levene",
178
+ "outlier_detection": "iqr",
179
+ "missing_data_threshold": 0.1,
180
+ "bayesian_chains": 4,
181
+ "bayesian_draws": 2000,
182
+ "bayesian_tune": 1000,
183
+ }
184
+
185
+ def conduct_power_analysis(
186
+ self, pipelines_data: Dict[str, List[float]], metric_name: str
187
+ ) -> PowerAnalysisResult:
188
+ """
189
+ Conduct comprehensive power analysis for pipeline comparison.
190
+
191
+ Args:
192
+ pipelines_data: Dictionary mapping pipeline names to metric scores
193
+ metric_name: Name of the metric being analyzed
194
+
195
+ Returns:
196
+ Power analysis results with sample size recommendations
197
+ """
198
+ logger.info(f"Conducting power analysis for {metric_name}")
199
+
200
+ # Calculate effect size from pilot data
201
+ pipeline_names = list(pipelines_data.keys())
202
+ if len(pipeline_names) < 2:
203
+ raise ValueError("Need at least 2 pipelines for comparison")
204
+
205
+ # Use first two pipelines for effect size estimation
206
+ group1_data = pipelines_data[pipeline_names[0]]
207
+ group2_data = pipelines_data[pipeline_names[1]]
208
+
209
+ # Calculate Cohen's d
210
+ effect_size = self._calculate_cohens_d(group1_data, group2_data)
211
+
212
+ # Determine test type
213
+ if len(pipeline_names) == 2:
214
+ test_type = "two_sample_ttest"
215
+ else:
216
+ test_type = "one_way_anova"
217
+
218
+ # Calculate required sample size
219
+ if test_type == "two_sample_ttest":
220
+ sample_size = ttest_power(
221
+ effect_size=abs(effect_size),
222
+ power=self.power_target,
223
+ alpha=self.alpha,
224
+ alternative="two-sided",
225
+ )
226
+ else:
227
+ # For ANOVA, approximate with pairwise t-test sample size
228
+ # This is a conservative approximation
229
+ sample_size = ttest_power(
230
+ effect_size=abs(effect_size),
231
+ nobs=None,
232
+ alpha=self.alpha / len(pipeline_names), # Bonferroni correction
233
+ power=self.power_target,
234
+ alternative="two-sided",
235
+ )
236
+
237
+ # Calculate achieved power with current sample sizes
238
+ current_min_n = min(len(data) for data in pipelines_data.values())
239
+ if test_type == "two_sample_ttest":
240
+ achieved_power = ttest_power(
241
+ effect_size=abs(effect_size), nobs=current_min_n, alpha=self.alpha
242
+ )
243
+ else:
244
+ achieved_power = 0.8 # Placeholder for ANOVA power calculation
245
+
246
+ # Generate recommendations
247
+ recommendations = []
248
+ if current_min_n < sample_size:
249
+ recommendations.append(
250
+ f"Increase sample size to {int(np.ceil(sample_size))} per group"
251
+ )
252
+ if abs(effect_size) < 0.2:
253
+ recommendations.append(
254
+ "Small effect size detected - consider larger sample or different metrics"
255
+ )
256
+ if achieved_power < self.power_target:
257
+ recommendations.append(
258
+ f"Current power ({achieved_power:.3f}) below target ({self.power_target})"
259
+ )
260
+
261
+ # Sensitivity analysis
262
+ sensitivity_analysis = {}
263
+ for alpha_sens in [0.01, 0.05, 0.10]:
264
+ if test_type == "two_sample_ttest":
265
+ n_sens = ttest_power(
266
+ effect_size=abs(effect_size),
267
+ power=self.power_target,
268
+ alpha=alpha_sens,
269
+ alternative="two-sided",
270
+ )
271
+ else:
272
+ n_sens = sample_size # Simplified for ANOVA
273
+ sensitivity_analysis[f"alpha_{alpha_sens}"] = n_sens
274
+
275
+ return PowerAnalysisResult(
276
+ effect_size=effect_size,
277
+ power=achieved_power,
278
+ sample_size=(
279
+ int(np.ceil(sample_size)) if np.isfinite(sample_size) else current_min_n
280
+ ),
281
+ alpha=self.alpha,
282
+ test_type=test_type,
283
+ recommendations=recommendations,
284
+ sensitivity_analysis=sensitivity_analysis,
285
+ )
286
+
287
+ def compare_pipelines_statistical(
288
+ self,
289
+ pipelines_data: Dict[str, List[float]],
290
+ metric_name: str,
291
+ paired: bool = False,
292
+ ) -> List[StatisticalTestResult]:
293
+ """
294
+ Comprehensive statistical comparison of pipeline performance.
295
+
296
+ Args:
297
+ pipelines_data: Dictionary mapping pipeline names to metric scores
298
+ metric_name: Name of the metric being analyzed
299
+ paired: Whether the data represents paired comparisons
300
+
301
+ Returns:
302
+ List of statistical test results for all comparisons
303
+ """
304
+ logger.info(
305
+ f"Statistical comparison of {len(pipelines_data)} pipelines for {metric_name}"
306
+ )
307
+
308
+ # Validate data
309
+ self._validate_statistical_data(pipelines_data)
310
+
311
+ pipeline_names = list(pipelines_data.keys())
312
+ results = []
313
+
314
+ if len(pipeline_names) == 2:
315
+ # Two-group comparison
316
+ result = self._compare_two_groups(
317
+ pipelines_data[pipeline_names[0]],
318
+ pipelines_data[pipeline_names[1]],
319
+ pipeline_names[0],
320
+ pipeline_names[1],
321
+ metric_name,
322
+ paired,
323
+ )
324
+ results.append(result)
325
+
326
+ elif len(pipeline_names) > 2:
327
+ # Multiple group comparison
328
+ # First, overall test
329
+ overall_result = self._compare_multiple_groups(
330
+ pipelines_data, metric_name, paired
331
+ )
332
+ results.append(overall_result)
333
+
334
+ # Then, pairwise comparisons if overall test is significant
335
+ if overall_result.p_value < self.alpha:
336
+ pairwise_results = self._pairwise_comparisons(
337
+ pipelines_data, metric_name, paired
338
+ )
339
+ results.extend(pairwise_results)
340
+
341
+ # Apply multiple comparison correction
342
+ if len(results) > 1:
343
+ results = self._apply_multiple_comparison_correction(results)
344
+
345
+ return results
346
+
347
+ def _validate_statistical_data(self, pipelines_data: Dict[str, List[float]]):
348
+ """Validate data for statistical analysis."""
349
+ for pipeline_name, data in pipelines_data.items():
350
+ if len(data) == 0:
351
+ raise ValueError(f"No data for pipeline {pipeline_name}")
352
+
353
+ # Check for missing values
354
+ valid_data = [x for x in data if np.isfinite(x)]
355
+ missing_rate = 1 - len(valid_data) / len(data)
356
+
357
+ if missing_rate > self.config["missing_data_threshold"]:
358
+ logger.warning(
359
+ f"High missing data rate ({missing_rate:.2%}) for {pipeline_name}"
360
+ )
361
+
362
+ # Check for outliers
363
+ outliers = self._detect_outliers(valid_data)
364
+ if len(outliers) > 0:
365
+ logger.info(f"Detected {len(outliers)} outliers in {pipeline_name}")
366
+
367
+ def _detect_outliers(self, data: List[float]) -> List[int]:
368
+ """Detect outliers using IQR method."""
369
+ if len(data) < 4:
370
+ return []
371
+
372
+ Q1 = np.percentile(data, 25)
373
+ Q3 = np.percentile(data, 75)
374
+ IQR = Q3 - Q1
375
+
376
+ lower_bound = Q1 - 1.5 * IQR
377
+ upper_bound = Q3 + 1.5 * IQR
378
+
379
+ outliers = [i for i, x in enumerate(data) if x < lower_bound or x > upper_bound]
380
+ return outliers
381
+
382
+ def _compare_two_groups(
383
+ self,
384
+ group1_data: List[float],
385
+ group2_data: List[float],
386
+ group1_name: str,
387
+ group2_name: str,
388
+ metric_name: str,
389
+ paired: bool,
390
+ ) -> StatisticalTestResult:
391
+ """Compare two groups statistically."""
392
+
393
+ # Clean data
394
+ g1_clean = [x for x in group1_data if np.isfinite(x)]
395
+ g2_clean = [x for x in group2_data if np.isfinite(x)]
396
+
397
+ # Check assumptions
398
+ assumptions = self._check_two_sample_assumptions(g1_clean, g2_clean, paired)
399
+
400
+ # Select appropriate test
401
+ if paired:
402
+ if assumptions["normality"] and assumptions["homoscedasticity"]:
403
+ test_statistic, p_value = stats.ttest_rel(g1_clean, g2_clean)
404
+ test_name = "Paired t-test"
405
+ else:
406
+ test_statistic, p_value = stats.wilcoxon(g1_clean, g2_clean)
407
+ test_name = "Wilcoxon signed-rank test"
408
+ else:
409
+ if assumptions["normality"] and assumptions["homoscedasticity"]:
410
+ test_statistic, p_value = stats.ttest_ind(g1_clean, g2_clean)
411
+ test_name = "Independent t-test"
412
+ else:
413
+ test_statistic, p_value = stats.mannwhitneyu(
414
+ g1_clean, g2_clean, alternative="two-sided"
415
+ )
416
+ test_name = "Mann-Whitney U test"
417
+
418
+ # Calculate effect size
419
+ effect_size = self._calculate_cohens_d(g1_clean, g2_clean)
420
+ effect_size_interpretation = self._interpret_effect_size(
421
+ effect_size, EffectSizeType.COHENS_D
422
+ )
423
+
424
+ # Bootstrap confidence interval
425
+ confidence_interval = self._bootstrap_confidence_interval(g1_clean, g2_clean)
426
+
427
+ # Calculate power
428
+ power = self._calculate_post_hoc_power(g1_clean, g2_clean, effect_size)
429
+
430
+ # Generate interpretation
431
+ interpretation = self._generate_interpretation(
432
+ test_name,
433
+ p_value,
434
+ effect_size,
435
+ effect_size_interpretation,
436
+ group1_name,
437
+ group2_name,
438
+ metric_name,
439
+ )
440
+
441
+ return StatisticalTestResult(
442
+ test_name=test_name,
443
+ test_statistic=test_statistic,
444
+ p_value=p_value,
445
+ p_value_adjusted=None, # Will be set during multiple comparison correction
446
+ effect_size=effect_size,
447
+ effect_size_type=EffectSizeType.COHENS_D.value,
448
+ effect_size_interpretation=effect_size_interpretation,
449
+ confidence_interval=confidence_interval,
450
+ power=power,
451
+ sample_sizes=[len(g1_clean), len(g2_clean)],
452
+ assumptions_met=assumptions,
453
+ interpretation=interpretation,
454
+ raw_data={
455
+ "group1_name": group1_name,
456
+ "group2_name": group2_name,
457
+ "group1_data": g1_clean,
458
+ "group2_data": g2_clean,
459
+ "metric_name": metric_name,
460
+ },
461
+ )
462
+
463
+ def _compare_multiple_groups(
464
+ self, pipelines_data: Dict[str, List[float]], metric_name: str, paired: bool
465
+ ) -> StatisticalTestResult:
466
+ """Compare multiple groups using ANOVA or Kruskal-Wallis."""
467
+
468
+ # Prepare data
469
+ all_data = []
470
+ group_labels = []
471
+
472
+ for pipeline_name, data in pipelines_data.items():
473
+ clean_data = [x for x in data if np.isfinite(x)]
474
+ all_data.extend(clean_data)
475
+ group_labels.extend([pipeline_name] * len(clean_data))
476
+
477
+ # Check assumptions
478
+ assumptions = self._check_multiple_group_assumptions(pipelines_data, paired)
479
+
480
+ # Select appropriate test
481
+ if paired:
482
+ if assumptions["normality"]:
483
+ # Repeated measures ANOVA (simplified as regular ANOVA for now)
484
+ pipeline_values = list(pipelines_data.values())
485
+ test_statistic, p_value = stats.f_oneway(*pipeline_values)
486
+ test_name = "Repeated measures ANOVA"
487
+ else:
488
+ # Friedman test
489
+ pipeline_values = list(pipelines_data.values())
490
+ test_statistic, p_value = stats.friedmanchisquare(*pipeline_values)
491
+ test_name = "Friedman test"
492
+ else:
493
+ if assumptions["normality"] and assumptions["homoscedasticity"]:
494
+ pipeline_values = [data for data in pipelines_data.values()]
495
+ test_statistic, p_value = stats.f_oneway(*pipeline_values)
496
+ test_name = "One-way ANOVA"
497
+ else:
498
+ pipeline_values = [data for data in pipelines_data.values()]
499
+ test_statistic, p_value = stats.kruskal(*pipeline_values)
500
+ test_name = "Kruskal-Wallis test"
501
+
502
+ # Calculate effect size (eta-squared for ANOVA)
503
+ effect_size = self._calculate_eta_squared(pipelines_data)
504
+ effect_size_interpretation = self._interpret_effect_size(
505
+ effect_size, EffectSizeType.ETA_SQUARED
506
+ )
507
+
508
+ # Confidence interval (approximate)
509
+ confidence_interval = (0.0, 1.0) # Placeholder for overall tests
510
+
511
+ # Sample sizes
512
+ sample_sizes = [
513
+ len([x for x in data if np.isfinite(x)]) for data in pipelines_data.values()
514
+ ]
515
+
516
+ # Interpretation
517
+ pipeline_names = list(pipelines_data.keys())
518
+ interpretation = (
519
+ f"{test_name} comparing {len(pipeline_names)} pipelines on {metric_name}. "
520
+ )
521
+ if p_value < self.alpha:
522
+ interpretation += f"Significant difference detected (p={p_value:.4f}). "
523
+ else:
524
+ interpretation += f"No significant difference detected (p={p_value:.4f}). "
525
+ interpretation += f"Effect size ({EffectSizeType.ETA_SQUARED.value}): {effect_size:.4f} ({effect_size_interpretation})"
526
+
527
+ return StatisticalTestResult(
528
+ test_name=test_name,
529
+ test_statistic=test_statistic,
530
+ p_value=p_value,
531
+ p_value_adjusted=None,
532
+ effect_size=effect_size,
533
+ effect_size_type=EffectSizeType.ETA_SQUARED.value,
534
+ effect_size_interpretation=effect_size_interpretation,
535
+ confidence_interval=confidence_interval,
536
+ power=None, # Not easily calculated for multi-group tests
537
+ sample_sizes=sample_sizes,
538
+ assumptions_met=assumptions,
539
+ interpretation=interpretation,
540
+ raw_data={
541
+ "pipelines_data": pipelines_data,
542
+ "metric_name": metric_name,
543
+ "all_data": all_data,
544
+ "group_labels": group_labels,
545
+ },
546
+ )
547
+
548
+ def _pairwise_comparisons(
549
+ self, pipelines_data: Dict[str, List[float]], metric_name: str, paired: bool
550
+ ) -> List[StatisticalTestResult]:
551
+ """Conduct all pairwise comparisons between pipelines."""
552
+
553
+ pipeline_names = list(pipelines_data.keys())
554
+ results = []
555
+
556
+ for i in range(len(pipeline_names)):
557
+ for j in range(i + 1, len(pipeline_names)):
558
+ pipeline1 = pipeline_names[i]
559
+ pipeline2 = pipeline_names[j]
560
+
561
+ result = self._compare_two_groups(
562
+ pipelines_data[pipeline1],
563
+ pipelines_data[pipeline2],
564
+ pipeline1,
565
+ pipeline2,
566
+ metric_name,
567
+ paired,
568
+ )
569
+ results.append(result)
570
+
571
+ return results
572
+
573
+ def _check_two_sample_assumptions(
574
+ self, group1: List[float], group2: List[float], paired: bool
575
+ ) -> Dict[str, bool]:
576
+ """Check statistical assumptions for two-sample tests."""
577
+ assumptions = {}
578
+
579
+ # Normality test
580
+ if len(group1) >= 3 and len(group2) >= 3:
581
+ _, p1 = stats.shapiro(group1)
582
+ _, p2 = stats.shapiro(group2)
583
+ assumptions["normality"] = p1 > 0.05 and p2 > 0.05
584
+ else:
585
+ assumptions["normality"] = False
586
+
587
+ # Homoscedasticity test (only for independent samples)
588
+ if not paired and len(group1) >= 3 and len(group2) >= 3:
589
+ _, p_levene = stats.levene(group1, group2)
590
+ assumptions["homoscedasticity"] = p_levene > 0.05
591
+ else:
592
+ assumptions["homoscedasticity"] = True # Not applicable for paired data
593
+
594
+ return assumptions
595
+
596
+ def _check_multiple_group_assumptions(
597
+ self, pipelines_data: Dict[str, List[float]], paired: bool
598
+ ) -> Dict[str, bool]:
599
+ """Check statistical assumptions for multiple group tests."""
600
+ assumptions = {}
601
+
602
+ # Normality test for each group
603
+ normality_results = []
604
+ for data in pipelines_data.values():
605
+ clean_data = [x for x in data if np.isfinite(x)]
606
+ if len(clean_data) >= 3:
607
+ _, p_val = stats.shapiro(clean_data)
608
+ normality_results.append(p_val > 0.05)
609
+ else:
610
+ normality_results.append(False)
611
+
612
+ assumptions["normality"] = all(normality_results)
613
+
614
+ # Homoscedasticity test (only for independent samples)
615
+ if not paired:
616
+ clean_data_groups = []
617
+ for data in pipelines_data.values():
618
+ clean_data = [x for x in data if np.isfinite(x)]
619
+ if len(clean_data) >= 3:
620
+ clean_data_groups.append(clean_data)
621
+
622
+ if len(clean_data_groups) >= 2:
623
+ _, p_levene = stats.levene(*clean_data_groups)
624
+ assumptions["homoscedasticity"] = p_levene > 0.05
625
+ else:
626
+ assumptions["homoscedasticity"] = False
627
+ else:
628
+ assumptions["homoscedasticity"] = True
629
+
630
+ return assumptions
631
+
632
+ def _calculate_cohens_d(self, group1: List[float], group2: List[float]) -> float:
633
+ """Calculate Cohen's d effect size."""
634
+ if len(group1) == 0 or len(group2) == 0:
635
+ return 0.0
636
+
637
+ mean1, mean2 = np.mean(group1), np.mean(group2)
638
+
639
+ # Pooled standard deviation
640
+ n1, n2 = len(group1), len(group2)
641
+ var1, var2 = np.var(group1, ddof=1), np.var(group2, ddof=1)
642
+
643
+ if n1 <= 1 or n2 <= 1:
644
+ return 0.0
645
+
646
+ pooled_std = np.sqrt(((n1 - 1) * var1 + (n2 - 1) * var2) / (n1 + n2 - 2))
647
+
648
+ if pooled_std == 0:
649
+ return 0.0
650
+
651
+ return (mean1 - mean2) / pooled_std
652
+
653
+ def _calculate_eta_squared(self, pipelines_data: Dict[str, List[float]]) -> float:
654
+ """Calculate eta-squared effect size for multiple group comparison."""
655
+ try:
656
+ # Prepare data for ANOVA
657
+ all_data = []
658
+ group_labels = []
659
+
660
+ for pipeline_name, data in pipelines_data.items():
661
+ clean_data = [x for x in data if np.isfinite(x)]
662
+ all_data.extend(clean_data)
663
+ group_labels.extend([pipeline_name] * len(clean_data))
664
+
665
+ if len(set(group_labels)) < 2:
666
+ return 0.0
667
+
668
+ # Calculate eta-squared
669
+ df = pd.DataFrame({"score": all_data, "group": group_labels})
670
+
671
+ # Between-group sum of squares
672
+ group_means = df.groupby("group")["score"].mean()
673
+ overall_mean = df["score"].mean()
674
+
675
+ ss_between = sum(
676
+ len(pipelines_data[group]) * (group_means[group] - overall_mean) ** 2
677
+ for group in group_means.index
678
+ )
679
+
680
+ # Total sum of squares
681
+ ss_total = sum((x - overall_mean) ** 2 for x in all_data)
682
+
683
+ if ss_total == 0:
684
+ return 0.0
685
+
686
+ return ss_between / ss_total
687
+
688
+ except Exception as e:
689
+ logger.warning(f"Failed to calculate eta-squared: {e}")
690
+ return 0.0
691
+
692
+ def _interpret_effect_size(
693
+ self, effect_size: float, effect_type: EffectSizeType
694
+ ) -> str:
695
+ """Interpret effect size magnitude."""
696
+ abs_effect = abs(effect_size)
697
+
698
+ if effect_type == EffectSizeType.COHENS_D:
699
+ if abs_effect < 0.2:
700
+ return "negligible"
701
+ elif abs_effect < 0.5:
702
+ return "small"
703
+ elif abs_effect < 0.8:
704
+ return "medium"
705
+ else:
706
+ return "large"
707
+
708
+ elif effect_type == EffectSizeType.ETA_SQUARED:
709
+ if abs_effect < 0.01:
710
+ return "negligible"
711
+ elif abs_effect < 0.06:
712
+ return "small"
713
+ elif abs_effect < 0.14:
714
+ return "medium"
715
+ else:
716
+ return "large"
717
+
718
+ else:
719
+ return "unknown"
720
+
721
+ def _bootstrap_confidence_interval(
722
+ self, group1: List[float], group2: List[float]
723
+ ) -> Tuple[float, float]:
724
+ """Calculate bootstrap confidence interval for mean difference."""
725
+ try:
726
+
727
+ def mean_difference(x, y):
728
+ return np.mean(x) - np.mean(y)
729
+
730
+ # Bootstrap resampling
731
+ n_bootstrap = self.bootstrap_samples
732
+ bootstrap_diffs = []
733
+
734
+ for _ in range(n_bootstrap):
735
+ bootstrap_g1 = np.random.choice(group1, size=len(group1), replace=True)
736
+ bootstrap_g2 = np.random.choice(group2, size=len(group2), replace=True)
737
+ diff = mean_difference(bootstrap_g1, bootstrap_g2)
738
+ bootstrap_diffs.append(diff)
739
+
740
+ # Calculate confidence interval
741
+ alpha = 1 - self.config.get("confidence_level", 0.95)
742
+ lower_percentile = (alpha / 2) * 100
743
+ upper_percentile = (1 - alpha / 2) * 100
744
+
745
+ ci_lower = np.percentile(bootstrap_diffs, lower_percentile)
746
+ ci_upper = np.percentile(bootstrap_diffs, upper_percentile)
747
+
748
+ return (ci_lower, ci_upper)
749
+
750
+ except Exception as e:
751
+ logger.warning(f"Bootstrap confidence interval failed: {e}")
752
+ return (0.0, 0.0)
753
+
754
+ def _calculate_post_hoc_power(
755
+ self, group1: List[float], group2: List[float], effect_size: float
756
+ ) -> Optional[float]:
757
+ """Calculate post-hoc statistical power."""
758
+ try:
759
+ min_n = min(len(group1), len(group2))
760
+ power = ttest_power(
761
+ effect_size=abs(effect_size),
762
+ nobs=min_n,
763
+ alpha=self.alpha,
764
+ alternative="two-sided",
765
+ )
766
+ return power
767
+ except Exception:
768
+ return None
769
+
770
+ def _generate_interpretation(
771
+ self,
772
+ test_name: str,
773
+ p_value: float,
774
+ effect_size: float,
775
+ effect_interpretation: str,
776
+ group1_name: str,
777
+ group2_name: str,
778
+ metric_name: str,
779
+ ) -> str:
780
+ """Generate human-readable interpretation of statistical results."""
781
+
782
+ interpretation = (
783
+ f"{test_name} comparing {group1_name} vs {group2_name} on {metric_name}. "
784
+ )
785
+
786
+ if p_value < self.alpha:
787
+ interpretation += f"Significant difference detected (p={p_value:.4f}). "
788
+
789
+ if effect_size > 0:
790
+ interpretation += f"{group1_name} performs better than {group2_name}. "
791
+ else:
792
+ interpretation += f"{group2_name} performs better than {group1_name}. "
793
+ else:
794
+ interpretation += f"No significant difference detected (p={p_value:.4f}). "
795
+
796
+ interpretation += f"Effect size: {effect_size:.4f} ({effect_interpretation})"
797
+
798
+ return interpretation
799
+
800
+ def _apply_multiple_comparison_correction(
801
+ self, results: List[StatisticalTestResult]
802
+ ) -> List[StatisticalTestResult]:
803
+ """Apply multiple comparison correction to p-values."""
804
+
805
+ p_values = [result.p_value for result in results]
806
+
807
+ # Apply correction
808
+ rejected, p_adjusted, alpha_sidak, alpha_bonf = multipletests(
809
+ p_values, alpha=self.alpha, method=self.multiple_correction_method
810
+ )
811
+
812
+ # Update results with adjusted p-values
813
+ for i, result in enumerate(results):
814
+ result.p_value_adjusted = p_adjusted[i]
815
+
816
+ logger.info(
817
+ f"Applied {self.multiple_correction_method} correction to {len(results)} tests"
818
+ )
819
+
820
+ return results
821
+
822
+ def conduct_bayesian_analysis(
823
+ self, pipelines_data: Dict[str, List[float]], metric_name: str
824
+ ) -> Dict[str, Any]:
825
+ """
826
+ Conduct Bayesian analysis for pipeline comparison.
827
+
828
+ Args:
829
+ pipelines_data: Dictionary mapping pipeline names to metric scores
830
+ metric_name: Name of the metric being analyzed
831
+
832
+ Returns:
833
+ Bayesian analysis results including credible intervals and probabilities
834
+ """
835
+ if not BAYESIAN_AVAILABLE:
836
+ logger.warning("Bayesian analysis not available - PyMC not installed")
837
+ return {}
838
+
839
+ logger.info(f"Conducting Bayesian analysis for {metric_name}")
840
+
841
+ try:
842
+ pipeline_names = list(pipelines_data.keys())
843
+
844
+ # Prepare data
845
+ all_scores = []
846
+ group_indices = []
847
+
848
+ for i, (pipeline_name, scores) in enumerate(pipelines_data.items()):
849
+ clean_scores = [x for x in scores if np.isfinite(x)]
850
+ all_scores.extend(clean_scores)
851
+ group_indices.extend([i] * len(clean_scores))
852
+
853
+ all_scores = np.array(all_scores)
854
+ group_indices = np.array(group_indices)
855
+ n_groups = len(pipeline_names)
856
+
857
+ # Bayesian model
858
+ with pm.Model() as model:
859
+ # Priors
860
+ mu = pm.Normal(
861
+ "mu",
862
+ mu=np.mean(all_scores),
863
+ sigma=np.std(all_scores),
864
+ shape=n_groups,
865
+ )
866
+ sigma = pm.HalfNormal("sigma", sigma=np.std(all_scores))
867
+
868
+ # Likelihood
869
+ likelihood = pm.Normal(
870
+ "likelihood", mu=mu[group_indices], sigma=sigma, observed=all_scores
871
+ )
872
+
873
+ # Sample from posterior
874
+ trace = pm.sample(
875
+ draws=self.config["bayesian_draws"],
876
+ tune=self.config["bayesian_tune"],
877
+ chains=self.config["bayesian_chains"],
878
+ return_inferencedata=True,
879
+ )
880
+
881
+ # Extract results
882
+ posterior_means = az.summary(trace, var_names=["mu"])
883
+
884
+ # Calculate probabilities of superiority
885
+ mu_samples = trace.posterior["mu"].values.reshape(-1, n_groups)
886
+
887
+ superiority_probs = {}
888
+ for i, pipeline1 in enumerate(pipeline_names):
889
+ for j, pipeline2 in enumerate(pipeline_names):
890
+ if i != j:
891
+ prob = np.mean(mu_samples[:, i] > mu_samples[:, j])
892
+ superiority_probs[f"{pipeline1}_better_than_{pipeline2}"] = prob
893
+
894
+ # Credible intervals
895
+ credible_intervals = {}
896
+ for i, pipeline_name in enumerate(pipeline_names):
897
+ hdi = az.hdi(trace, var_names=["mu"], hdi_prob=0.95)
898
+ credible_intervals[pipeline_name] = (
899
+ float(hdi["mu"].values[i, 0]),
900
+ float(hdi["mu"].values[i, 1]),
901
+ )
902
+
903
+ return {
904
+ "pipeline_names": pipeline_names,
905
+ "posterior_means": posterior_means.to_dict(),
906
+ "superiority_probabilities": superiority_probs,
907
+ "credible_intervals": credible_intervals,
908
+ "model_summary": str(az.summary(trace)),
909
+ "convergence_diagnostics": {
910
+ "r_hat": az.rhat(trace).to_dict(),
911
+ "ess": az.ess(trace).to_dict(),
912
+ },
913
+ }
914
+
915
+ except Exception as e:
916
+ logger.error(f"Bayesian analysis failed: {e}")
917
+ return {"error": str(e)}
918
+
919
+ def generate_statistical_report(
920
+ self,
921
+ results: List[StatisticalTestResult],
922
+ power_analysis: Optional[PowerAnalysisResult] = None,
923
+ bayesian_results: Optional[Dict[str, Any]] = None,
924
+ ) -> Dict[str, Any]:
925
+ """Generate comprehensive statistical report."""
926
+
927
+ report = {
928
+ "timestamp": pd.Timestamp.now().isoformat(),
929
+ "configuration": self.config,
930
+ "summary": {
931
+ "total_tests": len(results),
932
+ "significant_tests": sum(
933
+ 1 for r in results if (r.p_value_adjusted or r.p_value) < self.alpha
934
+ ),
935
+ "large_effects": sum(1 for r in results if abs(r.effect_size) > 0.8),
936
+ },
937
+ "test_results": [
938
+ {
939
+ "test_name": r.test_name,
940
+ "test_statistic": r.test_statistic,
941
+ "p_value": r.p_value,
942
+ "p_value_adjusted": r.p_value_adjusted,
943
+ "effect_size": r.effect_size,
944
+ "effect_size_type": r.effect_size_type,
945
+ "effect_size_interpretation": r.effect_size_interpretation,
946
+ "confidence_interval": r.confidence_interval,
947
+ "power": r.power,
948
+ "sample_sizes": r.sample_sizes,
949
+ "assumptions_met": r.assumptions_met,
950
+ "interpretation": r.interpretation,
951
+ }
952
+ for r in results
953
+ ],
954
+ }
955
+
956
+ if power_analysis:
957
+ report["power_analysis"] = {
958
+ "effect_size": power_analysis.effect_size,
959
+ "power": power_analysis.power,
960
+ "sample_size": power_analysis.sample_size,
961
+ "alpha": power_analysis.alpha,
962
+ "test_type": power_analysis.test_type,
963
+ "recommendations": power_analysis.recommendations,
964
+ "sensitivity_analysis": power_analysis.sensitivity_analysis,
965
+ }
966
+
967
+ if bayesian_results:
968
+ report["bayesian_analysis"] = bayesian_results
969
+
970
+ return report
971
+
972
+ def save_statistical_results(self, report: Dict[str, Any], output_path: str):
973
+ """Save statistical analysis results."""
974
+ output_dir = Path(output_path)
975
+ output_dir.mkdir(parents=True, exist_ok=True)
976
+
977
+ timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S")
978
+ filename = f"statistical_analysis_report_{timestamp}.json"
979
+
980
+ filepath = output_dir / filename
981
+ with open(filepath, "w") as f:
982
+ json.dump(report, f, indent=2, default=str)
983
+
984
+ logger.info(f"Statistical analysis report saved to {filepath}")
985
+
986
+
987
+ def create_statistical_framework(
988
+ config: Optional[Dict[str, Any]] = None,
989
+ ) -> StatisticalEvaluationFramework:
990
+ """Factory function to create a configured statistical evaluation framework."""
991
+ return StatisticalEvaluationFramework(config)
992
+
993
+
994
+ if __name__ == "__main__":
995
+ # Example usage
996
+ framework = create_statistical_framework()
997
+
998
+ # Mock pipeline data
999
+ sample_data = {
1000
+ "BasicRAG": [0.7, 0.8, 0.75, 0.82, 0.78],
1001
+ "CRAG": [0.85, 0.87, 0.83, 0.89, 0.86],
1002
+ "GraphRAG": [0.72, 0.74, 0.71, 0.76, 0.73],
1003
+ "BasicRAGReranking": [0.81, 0.84, 0.80, 0.83, 0.82],
1004
+ }
1005
+
1006
+ # Power analysis
1007
+ power_result = framework.conduct_power_analysis(sample_data, "faithfulness")
1008
+
1009
+ # Statistical comparison
1010
+ comparison_results = framework.compare_pipelines_statistical(
1011
+ sample_data, "faithfulness"
1012
+ )
1013
+
1014
+ # Generate report
1015
+ report = framework.generate_statistical_report(comparison_results, power_result)
1016
+ framework.save_statistical_results(report, "output/statistical_analysis")