Skip to main content

anno_eval/eval/
sampling.rs

1//! Sampling strategies for NER evaluation.
2//!
3//! # Research Context
4//!
5//! Random sampling introduces bias when entity types are imbalanced.
6//!
7//! | Problem | Effect | Solution |
8//! |---------|--------|----------|
9//! | Type skew | High F1 on frequent types, low on rare | Stratified sampling |
10//! | Seed sensitivity | Results vary wildly across seeds | Multiple seeds + variance |
11//! | Dataset size | Small samples → high variance | Report confidence intervals |
12//!
13//! # Stratified Sampling (Recommended)
14//!
15//! Maintains proportional entity type distribution from the full dataset.
16//! Critical for domain-specific datasets where some types are rare.
17//!
18//! ```text
19//! Full dataset:    PER (60%), ORG (30%), LOC (10%)
20//! Random sample:   PER (75%), ORG (20%), LOC (5%)   ← Biased!
21//! Stratified:      PER (60%), ORG (30%), LOC (10%)  ← Representative
22//! ```
23//!
24//! # Example
25//!
26//! ```rust
27//! use anno_eval::eval::sampling::stratified_sample;
28//! use anno_eval::eval::datasets::GoldEntity;
29//! use anno::EntityType;
30//!
31//! let cases: Vec<(String, Vec<GoldEntity>)> = vec![
32//!     ("John works at Apple".into(), vec![
33//!         GoldEntity::new("John", EntityType::Person, 0),
34//!         GoldEntity::new("Apple", EntityType::Organization, 14),
35//!     ]),
36//!     // ... more cases
37//! ];
38//!
39//! // Sample 100 cases, maintaining entity type proportions
40//! let sample = stratified_sample(&cases, 100, 42);
41//! ```
42
43use crate::eval::datasets::GoldEntity;
44use anno::TypeMapper;
45use std::collections::hash_map::DefaultHasher;
46use std::hash::{Hash, Hasher};
47
48/// Stratified sampling maintaining entity type proportions.
49///
50/// # Arguments
51/// * `cases` - Input test cases (text, gold entities)
52/// * `target_size` - Maximum number of cases to return
53/// * `seed` - Random seed for reproducibility
54///
55/// # Returns
56/// A subset of cases with proportional entity type distribution.
57///
58/// # Note on True Stratification
59///
60/// This is a simplified version using hash-based pseudo-random ordering.
61/// For true stratified sampling by entity type, use `stratified_sample_ner`
62/// which groups by type first.
63pub fn stratified_sample(
64    cases: &[(String, Vec<GoldEntity>)],
65    target_size: usize,
66    seed: u64,
67) -> Vec<(String, Vec<GoldEntity>)> {
68    if cases.len() <= target_size {
69        return cases.to_vec();
70    }
71
72    // Hash-based deterministic shuffle
73    let mut indexed: Vec<(usize, u64)> = cases
74        .iter()
75        .enumerate()
76        .map(|(i, (text, _))| {
77            let mut hasher = DefaultHasher::new();
78            seed.hash(&mut hasher);
79            i.hash(&mut hasher);
80            text.hash(&mut hasher);
81            (i, hasher.finish())
82        })
83        .collect();
84
85    indexed.sort_by_key(|(_, hash)| *hash);
86    indexed.truncate(target_size);
87    indexed.sort_by_key(|(i, _)| *i); // Preserve relative order
88
89    indexed.iter().map(|(i, _)| cases[*i].clone()).collect()
90}
91
92/// Stratified sampling with entity type awareness.
93///
94/// Groups cases by their primary entity type and samples proportionally
95/// from each group to maintain the original type distribution.
96///
97/// # Arguments
98/// * `cases` - Input test cases
99/// * `target_size` - Maximum cases to return
100/// * `seed` - Random seed for reproducibility
101/// * `type_mapper` - Optional mapper for normalizing domain-specific types
102///
103/// # Example
104///
105/// ```rust
106/// use anno_eval::eval::sampling::stratified_sample_ner;
107/// use anno_eval::eval::datasets::GoldEntity;
108/// use anno::EntityType;
109///
110/// let cases = vec![
111///     ("John works at Apple".into(), vec![
112///         GoldEntity::new("John", EntityType::Person, 0),
113///     ]),
114/// ];
115///
116/// let sample = stratified_sample_ner(&cases, 100, 42, None);
117/// ```
118pub fn stratified_sample_ner(
119    cases: &[(String, Vec<GoldEntity>)],
120    target_size: usize,
121    seed: u64,
122    type_mapper: Option<&TypeMapper>,
123) -> Vec<(String, Vec<GoldEntity>)> {
124    use std::collections::HashMap;
125
126    if cases.len() <= target_size {
127        return cases.to_vec();
128    }
129
130    // Group cases by dominant entity type
131    let mut by_type: HashMap<String, Vec<usize>> = HashMap::new();
132
133    for (idx, (_, entities)) in cases.iter().enumerate() {
134        // Use the first entity's type as the "dominant" type for grouping
135        let type_key = if let Some(e) = entities.first() {
136            let mapped = if let Some(mapper) = type_mapper {
137                mapper.normalize(&e.original_label)
138            } else {
139                e.entity_type.clone()
140            };
141            format!("{:?}", mapped)
142        } else {
143            "EMPTY".to_string()
144        };
145
146        by_type.entry(type_key).or_default().push(idx);
147    }
148
149    // Calculate proportional allocation
150    let total_cases = cases.len();
151    let mut result_indices = Vec::with_capacity(target_size);
152
153    for indices in by_type.values_mut() {
154        let proportion = indices.len() as f64 / total_cases as f64;
155        let allocation = (proportion * target_size as f64).ceil() as usize;
156
157        // Shuffle this group's indices using hash-based ordering
158        hash_shuffle(indices, seed);
159
160        // Take allocation from this group
161        result_indices.extend(indices.iter().take(allocation.min(indices.len())).copied());
162    }
163
164    // If we over-allocated, trim with hash-based ordering
165    if result_indices.len() > target_size {
166        hash_shuffle(&mut result_indices, seed);
167        result_indices.truncate(target_size);
168    }
169
170    // Sort to preserve relative order (better for debugging)
171    result_indices.sort();
172
173    result_indices.iter().map(|&i| cases[i].clone()).collect()
174}
175
176/// Hash-based deterministic shuffle (no external crate needed).
177///
178/// Sorts indices by their hash value, which produces a deterministic
179/// pseudo-random ordering for the given seed.
180fn hash_shuffle(indices: &mut [usize], seed: u64) {
181    if indices.len() <= 1 {
182        return;
183    }
184
185    // Compute hash for each index and sort by hash
186    let mut hashed: Vec<(usize, u64)> = indices
187        .iter()
188        .map(|&idx| {
189            let mut hasher = DefaultHasher::new();
190            seed.hash(&mut hasher);
191            idx.hash(&mut hasher);
192            (idx, hasher.finish())
193        })
194        .collect();
195
196    hashed.sort_by_key(|(_, hash)| *hash);
197
198    // Copy back shuffled indices
199    for (i, (idx, _)) in hashed.into_iter().enumerate() {
200        indices[i] = idx;
201    }
202}
203
204/// Run evaluation with multiple seeds and aggregate variance.
205///
206/// # Research Context
207///
208/// Single-seed evaluations are unreliable:
209/// - F1 can vary ±5% across seeds on small datasets
210/// - Always report mean ± CI, not point estimates
211///
212/// # Arguments
213/// * `eval_fn` - Evaluation function that takes a seed and returns F1 score
214/// * `seeds` - Seeds to run (recommend 5+)
215///
216/// # Returns
217/// (mean, std_dev, min, max) of F1 scores
218pub fn multi_seed_eval<F>(eval_fn: F, seeds: &[u64]) -> (f64, f64, f64, f64)
219where
220    F: Fn(u64) -> f64,
221{
222    if seeds.is_empty() {
223        return (0.0, 0.0, 0.0, 0.0);
224    }
225
226    let scores: Vec<f64> = seeds.iter().map(|&s| eval_fn(s)).collect();
227
228    let mean = scores.iter().sum::<f64>() / scores.len() as f64;
229    let min = scores.iter().cloned().fold(f64::INFINITY, f64::min);
230    let max = scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
231
232    let variance = if scores.len() > 1 {
233        scores.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (scores.len() - 1) as f64
234    } else {
235        0.0
236    };
237    let std_dev = variance.sqrt();
238
239    (mean, std_dev, min, max)
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use anno::EntityType;
246
247    fn make_test_cases() -> Vec<(String, Vec<GoldEntity>)> {
248        vec![
249            (
250                "John works at Apple".into(),
251                vec![
252                    GoldEntity::new("John", EntityType::Person, 0),
253                    GoldEntity::new("Apple", EntityType::Organization, 14),
254                ],
255            ),
256            (
257                "Meeting on January 15".into(),
258                vec![GoldEntity::new("January 15", EntityType::Date, 11)],
259            ),
260            (
261                "Price is $500".into(),
262                vec![GoldEntity::new("$500", EntityType::Money, 9)],
263            ),
264        ]
265    }
266
267    #[test]
268    fn test_stratified_sample_smaller() {
269        let cases = make_test_cases();
270        let sample = stratified_sample(&cases, 10, 42);
271        assert_eq!(sample.len(), cases.len()); // All returned if target > len
272    }
273
274    #[test]
275    fn test_stratified_sample_deterministic() {
276        let cases = make_test_cases();
277        let s1 = stratified_sample(&cases, 2, 42);
278        let s2 = stratified_sample(&cases, 2, 42);
279        assert_eq!(s1.len(), s2.len());
280        assert_eq!(s1[0].0, s2[0].0); // Same results for same seed
281    }
282
283    #[test]
284    fn test_stratified_sample_different_seeds() {
285        let cases: Vec<_> = (0..100)
286            .map(|i| {
287                (
288                    format!("Text {}", i),
289                    vec![GoldEntity::new("entity", EntityType::Person, 0)],
290                )
291            })
292            .collect();
293
294        let s1 = stratified_sample(&cases, 10, 42);
295        let s2 = stratified_sample(&cases, 10, 123);
296
297        // Different seeds should (usually) produce different orderings
298        let texts1: Vec<_> = s1.iter().map(|(t, _)| t.clone()).collect();
299        let texts2: Vec<_> = s2.iter().map(|(t, _)| t.clone()).collect();
300        assert_ne!(texts1, texts2);
301    }
302
303    #[test]
304    fn test_multi_seed_eval() {
305        let (mean, std, min, max) =
306            multi_seed_eval(|seed| 0.8 + (seed as f64 % 10.0) / 100.0, &[1, 2, 3, 4, 5]);
307
308        assert!(mean > 0.8);
309        assert!(mean < 0.9);
310        assert!(std >= 0.0);
311        assert!(min <= mean);
312        assert!(max >= mean);
313    }
314}