Skip to main content

anno_eval/eval/
bias_config.rs

1//! Configuration and statistical utilities for bias evaluation.
2//!
3//! Provides configuration structures and statistical reporting for bias evaluation
4//! datasets, including confidence intervals, effect sizes, and frequency weighting.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9// =============================================================================
10// Bias Dataset Configuration
11// =============================================================================
12
13/// Configuration for bias evaluation datasets.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct BiasDatasetConfig {
16    /// Minimum samples per category for statistical validity
17    pub min_samples_per_category: usize,
18    /// Use frequency-weighted sampling from real distributions
19    pub frequency_weighted: bool,
20    /// Validate against reference distributions
21    pub validate_distributions: bool,
22    /// Multiple seeds for variance estimation
23    pub evaluation_seeds: Vec<u64>,
24    /// Confidence level for intervals (default: 0.95)
25    pub confidence_level: f64,
26    /// Include detailed per-category metrics
27    pub detailed: bool,
28}
29
30impl Default for BiasDatasetConfig {
31    fn default() -> Self {
32        Self {
33            min_samples_per_category: 30,
34            frequency_weighted: false,
35            validate_distributions: false,
36            evaluation_seeds: vec![42, 123, 456, 789, 999],
37            confidence_level: 0.95,
38            detailed: false,
39        }
40    }
41}
42
43impl BiasDatasetConfig {
44    /// Create a new configuration with recommended settings.
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Create configuration with frequency weighting enabled.
50    pub fn with_frequency_weighting(mut self) -> Self {
51        self.frequency_weighted = true;
52        self
53    }
54
55    /// Create configuration with validation enabled.
56    pub fn with_validation(mut self) -> Self {
57        self.validate_distributions = true;
58        self
59    }
60
61    /// Set minimum samples per category.
62    pub fn with_min_samples(mut self, min: usize) -> Self {
63        self.min_samples_per_category = min;
64        self
65    }
66
67    /// Set evaluation seeds for variance estimation.
68    pub fn with_seeds(mut self, seeds: Vec<u64>) -> Self {
69        self.evaluation_seeds = seeds;
70        self
71    }
72
73    /// Enable detailed reporting.
74    pub fn with_detailed(mut self, detailed: bool) -> Self {
75        self.detailed = detailed;
76        self
77    }
78}
79
80// =============================================================================
81// Statistical Results
82// =============================================================================
83
84/// Statistical results with confidence intervals and effect sizes.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct StatisticalBiasResults {
87    /// Mean bias gap or recognition rate
88    pub mean: f64,
89    /// Standard deviation across seeds/runs
90    pub std_dev: f64,
91    /// 95% confidence interval (lower, upper)
92    pub ci_95: (f64, f64),
93    /// Minimum value observed
94    pub min: f64,
95    /// Maximum value observed
96    pub max: f64,
97    /// Effect size (Cohen's d) if comparing two groups
98    pub effect_size: Option<f64>,
99    /// Number of samples
100    pub n: usize,
101    /// Standard error
102    pub std_error: f64,
103}
104
105impl StatisticalBiasResults {
106    /// Create from a vector of values (e.g., across multiple seeds).
107    pub fn from_values(values: &[f64], confidence_level: f64) -> Self {
108        if values.is_empty() {
109            return Self {
110                mean: 0.0,
111                std_dev: 0.0,
112                ci_95: (0.0, 0.0),
113                min: 0.0,
114                max: 0.0,
115                effect_size: None,
116                n: 0,
117                std_error: 0.0,
118            };
119        }
120
121        let n = values.len();
122        let mean = values.iter().sum::<f64>() / n as f64;
123        let variance = if n > 1 {
124            values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1) as f64
125        } else {
126            0.0
127        };
128        let std_dev = variance.sqrt();
129        let std_error = std_dev / (n as f64).sqrt();
130
131        let min = values.iter().copied().fold(f64::INFINITY, f64::min);
132        let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
133
134        // Approximate 95% CI using t-distribution (simplified: using z-score for n>=30)
135        let z_score = if confidence_level == 0.95 {
136            1.96
137        } else if confidence_level == 0.99 {
138            2.576
139        } else {
140            // Approximate for other levels
141            1.96 * (confidence_level / 0.95)
142        };
143        let margin = z_score * std_error;
144        let ci_95 = (mean - margin, mean + margin);
145
146        Self {
147            mean,
148            std_dev,
149            ci_95,
150            min,
151            max,
152            effect_size: None,
153            n,
154            std_error,
155        }
156    }
157
158    /// Compute effect size (Cohen's d) between two groups.
159    pub fn compute_effect_size(group1: &[f64], group2: &[f64]) -> f64 {
160        if group1.is_empty() || group2.is_empty() {
161            return 0.0;
162        }
163
164        let mean1 = group1.iter().sum::<f64>() / group1.len() as f64;
165        let mean2 = group2.iter().sum::<f64>() / group2.len() as f64;
166
167        let var1 = if group1.len() > 1 {
168            group1.iter().map(|x| (x - mean1).powi(2)).sum::<f64>() / (group1.len() - 1) as f64
169        } else {
170            0.0
171        };
172
173        let var2 = if group2.len() > 1 {
174            group2.iter().map(|x| (x - mean2).powi(2)).sum::<f64>() / (group2.len() - 1) as f64
175        } else {
176            0.0
177        };
178
179        let pooled_std = ((var1 + var2) / 2.0).sqrt();
180        if pooled_std == 0.0 {
181            return 0.0;
182        }
183
184        (mean1 - mean2) / pooled_std
185    }
186
187    /// Format as string with confidence interval.
188    pub fn format_with_ci(&self) -> String {
189        format!(
190            "{:.3} (95% CI: {:.3} - {:.3}, n={}, SD={:.3})",
191            self.mean, self.ci_95.0, self.ci_95.1, self.n, self.std_dev
192        )
193    }
194}
195
196// =============================================================================
197// Frequency-Weighted Results
198// =============================================================================
199
200/// Results with both unweighted and frequency-weighted metrics.
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct FrequencyWeightedResults {
203    /// Unweighted recognition rate
204    pub unweighted_rate: f64,
205    /// Frequency-weighted recognition rate
206    pub weighted_rate: f64,
207    /// Frequency distribution used (name -> frequency)
208    pub frequency_distribution: HashMap<String, f64>,
209    /// Number of samples
210    pub n: usize,
211}
212
213impl FrequencyWeightedResults {
214    /// Create from recognition results and frequencies.
215    pub fn new(recognized: &[bool], frequencies: &HashMap<String, f64>, names: &[String]) -> Self {
216        if recognized.is_empty() {
217            return Self {
218                unweighted_rate: 0.0,
219                weighted_rate: 0.0,
220                frequency_distribution: frequencies.clone(),
221                n: 0,
222            };
223        }
224
225        let unweighted_rate =
226            recognized.iter().filter(|&&r| r).count() as f64 / recognized.len() as f64;
227
228        // Weighted rate: sum(recognized[i] * frequency[i]) / sum(frequency[i])
229        let mut weighted_sum = 0.0;
230        let mut total_weight = 0.0;
231
232        for (i, &rec) in recognized.iter().enumerate() {
233            if i < names.len() {
234                let freq = frequencies
235                    .get(&names[i])
236                    .copied()
237                    .unwrap_or(1.0 / names.len() as f64);
238                if rec {
239                    weighted_sum += freq;
240                }
241                total_weight += freq;
242            }
243        }
244
245        let weighted_rate = if total_weight > 0.0 {
246            weighted_sum / total_weight
247        } else {
248            unweighted_rate
249        };
250
251        Self {
252            unweighted_rate,
253            weighted_rate,
254            frequency_distribution: frequencies.clone(),
255            n: recognized.len(),
256        }
257    }
258}
259
260// =============================================================================
261// Distribution Validation
262// =============================================================================
263
264/// Validation results comparing dataset distribution to reference.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct DistributionValidation {
267    /// Whether distribution matches reference (within tolerance)
268    pub is_valid: bool,
269    /// Maximum deviation from reference
270    pub max_deviation: f64,
271    /// Per-category deviations
272    pub category_deviations: HashMap<String, f64>,
273    /// Tolerance used for validation
274    pub tolerance: f64,
275}
276
277impl DistributionValidation {
278    /// Validate distribution against reference.
279    pub fn validate(
280        observed: &HashMap<String, f64>,
281        reference: &HashMap<String, f64>,
282        tolerance: f64,
283    ) -> Self {
284        let mut max_deviation: f64 = 0.0;
285        let mut category_deviations = HashMap::new();
286
287        for (category, &ref_value) in reference {
288            let obs_value = observed.get(category).copied().unwrap_or(0.0);
289            let deviation = (obs_value - ref_value).abs();
290            category_deviations.insert(category.clone(), deviation);
291            max_deviation = max_deviation.max(deviation);
292        }
293
294        // Check for categories in observed but not in reference
295        for category in observed.keys() {
296            if !reference.contains_key(category) {
297                let deviation = observed[category];
298                category_deviations.insert(category.clone(), deviation);
299                max_deviation = max_deviation.max(deviation);
300            }
301        }
302
303        let is_valid = max_deviation <= tolerance;
304
305        Self {
306            is_valid,
307            max_deviation,
308            category_deviations,
309            tolerance,
310        }
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_statistical_results() {
320        let values = vec![0.8, 0.82, 0.79, 0.81, 0.83];
321        let results = StatisticalBiasResults::from_values(&values, 0.95);
322
323        assert!((results.mean - 0.81).abs() < 0.01);
324        assert!(results.n == 5);
325        assert!(results.ci_95.0 < results.mean);
326        assert!(results.ci_95.1 > results.mean);
327    }
328
329    #[test]
330    fn test_effect_size() {
331        let group1 = vec![0.9, 0.91, 0.89, 0.92, 0.88];
332        let group2 = vec![0.7, 0.71, 0.69, 0.72, 0.68];
333
334        let d = StatisticalBiasResults::compute_effect_size(&group1, &group2);
335        assert!(d > 0.0); // Should be positive (group1 > group2)
336                          // Effect size should be large (groups are 0.2 apart with small variance)
337                          // Cohen's d = (0.9 - 0.7) / pooled_std, which can be > 10 for very small std
338        assert!(d < 100.0); // Should be reasonable (allowing for small variance case)
339    }
340
341    #[test]
342    fn test_frequency_weighted() {
343        let recognized = vec![true, false, true, true, false];
344        let mut frequencies = HashMap::new();
345        frequencies.insert("Name1".to_string(), 0.5);
346        frequencies.insert("Name2".to_string(), 0.3);
347        frequencies.insert("Name3".to_string(), 0.2);
348        let names = vec![
349            "Name1".to_string(),
350            "Name2".to_string(),
351            "Name3".to_string(),
352            "Name1".to_string(),
353            "Name2".to_string(),
354        ];
355
356        let results = FrequencyWeightedResults::new(&recognized, &frequencies, &names);
357        assert!(results.unweighted_rate > 0.0);
358        assert!(results.weighted_rate > 0.0);
359    }
360
361    #[test]
362    fn test_distribution_validation() {
363        let mut observed = HashMap::new();
364        observed.insert("A".to_string(), 0.5);
365        observed.insert("B".to_string(), 0.5);
366
367        let mut reference = HashMap::new();
368        reference.insert("A".to_string(), 0.48);
369        reference.insert("B".to_string(), 0.52);
370
371        let validation = DistributionValidation::validate(&observed, &reference, 0.1);
372        assert!(validation.is_valid); // Within 10% tolerance
373        assert!(validation.max_deviation < 0.1);
374    }
375}