Skip to main content

anno_eval/eval/
dataset_quality.rs

1//! Dataset quality metrics for NER evaluation.
2//!
3//! Implements metrics from Statistical Dataset Evaluation (Cambridge NLP, 2022):
4//! - Reliability: Redundancy, Accuracy, Leakage Ratio
5//! - Difficulty: Unseen Entity Ratio, Entity Ambiguity, Model Differentiation
6//! - Validity: Entity Imbalance, Entity-Null Rate
7//!
8//! # Research Background
9//!
10//! These metrics apply Classical Test Theory (CTT) to NLP datasets:
11//! - **Reliability**: Is the dataset trustworthy and consistent?
12//! - **Difficulty**: How hard is the task with this dataset?
13//! - **Validity**: Does the dataset measure what we intend?
14//!
15//! # Example
16//!
17//! ```rust
18//! use anno_eval::eval::dataset_quality::{DatasetQualityAnalyzer, QualityReport};
19//!
20//! let analyzer = DatasetQualityAnalyzer::default();
21//!
22//! let train_data = vec![
23//!     ("John works at Google.", vec![("John", "PER"), ("Google", "ORG")]),
24//! ];
25//! let test_data = vec![
26//!     ("Jane joined Microsoft.", vec![("Jane", "PER"), ("Microsoft", "ORG")]),
27//! ];
28//!
29//! let report = analyzer.analyze(&train_data, &test_data);
30//! println!("Leakage ratio: {:.2}%", report.reliability.leakage_ratio * 100.0);
31//! ```
32
33use serde::{Deserialize, Serialize};
34use std::collections::{HashMap, HashSet};
35
36// =============================================================================
37// Quality Report Structure
38// =============================================================================
39
40/// Comprehensive dataset quality report.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct QualityReport {
43    /// Reliability metrics
44    pub reliability: ReliabilityMetrics,
45    /// Difficulty metrics
46    pub difficulty: DifficultyMetrics,
47    /// Validity metrics
48    pub validity: ValidityMetrics,
49    /// Overall quality grade
50    pub overall_grade: String,
51    /// Specific recommendations
52    pub recommendations: Vec<String>,
53}
54
55/// Reliability metrics - dataset trustworthiness.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ReliabilityMetrics {
58    /// Proportion of duplicate samples in training data
59    pub redundancy: f64,
60    /// Number of exact duplicates found
61    pub duplicate_count: usize,
62    /// Proportion of test samples appearing in training
63    pub leakage_ratio: f64,
64    /// Number of leaked samples
65    pub leaked_count: usize,
66}
67
68/// Difficulty metrics - task challenge level.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct DifficultyMetrics {
71    /// Proportion of test entities not seen in training
72    pub unseen_entity_ratio: f64,
73    /// Number of unseen entities
74    pub unseen_entity_count: usize,
75    /// How often same surface form has different labels
76    pub entity_ambiguity: f64,
77    /// Ambiguous entity examples
78    pub ambiguous_examples: Vec<(String, Vec<String>)>,
79    /// Average entity density (entities per 100 tokens)
80    pub entity_density: f64,
81}
82
83/// Validity metrics - measurement appropriateness.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ValidityMetrics {
86    /// Ratio of most common to least common entity type
87    pub entity_imbalance: f64,
88    /// Entity type distribution
89    pub type_distribution: HashMap<String, usize>,
90    /// Proportion of tokens that are not entities
91    pub entity_null_rate: f64,
92    /// Average entities per sample
93    pub avg_entities_per_sample: f64,
94}
95
96// =============================================================================
97// Dataset Quality Analyzer
98// =============================================================================
99
100/// Analyzer for dataset quality metrics.
101#[derive(Debug, Clone, Default)]
102pub struct DatasetQualityAnalyzer {
103    /// Minimum samples for statistical validity
104    pub min_samples: usize,
105}
106
107impl DatasetQualityAnalyzer {
108    /// Create analyzer with custom minimum samples.
109    pub fn new(min_samples: usize) -> Self {
110        Self { min_samples }
111    }
112
113    /// Analyze dataset quality.
114    ///
115    /// # Arguments
116    /// - `train_data`: Training samples as (text, [(entity_text, entity_type)])
117    /// - `test_data`: Test samples in same format
118    pub fn analyze<S: AsRef<str>, T: AsRef<str>>(
119        &self,
120        train_data: &[(S, Vec<(T, T)>)],
121        test_data: &[(S, Vec<(T, T)>)],
122    ) -> QualityReport {
123        let reliability = self.compute_reliability(train_data, test_data);
124        let difficulty = self.compute_difficulty(train_data, test_data);
125        let validity = self.compute_validity(train_data);
126
127        let (grade, recommendations) =
128            self.compute_grade_and_recommendations(&reliability, &difficulty, &validity);
129
130        QualityReport {
131            reliability,
132            difficulty,
133            validity,
134            overall_grade: grade,
135            recommendations,
136        }
137    }
138
139    fn compute_reliability<S: AsRef<str>, T: AsRef<str>>(
140        &self,
141        train_data: &[(S, Vec<(T, T)>)],
142        test_data: &[(S, Vec<(T, T)>)],
143    ) -> ReliabilityMetrics {
144        // Check for duplicates in training
145        let mut seen_texts = HashSet::new();
146        let mut duplicate_count = 0;
147
148        for (text, _) in train_data {
149            let normalized = text.as_ref().to_lowercase();
150            if !seen_texts.insert(normalized) {
151                duplicate_count += 1;
152            }
153        }
154
155        let redundancy = if train_data.is_empty() {
156            0.0
157        } else {
158            duplicate_count as f64 / train_data.len() as f64
159        };
160
161        // Check for train-test leakage
162        let train_texts: HashSet<String> = train_data
163            .iter()
164            .map(|(t, _)| t.as_ref().to_lowercase())
165            .collect();
166
167        let mut leaked_count = 0;
168        for (text, _) in test_data {
169            if train_texts.contains(&text.as_ref().to_lowercase()) {
170                leaked_count += 1;
171            }
172        }
173
174        let leakage_ratio = if test_data.is_empty() {
175            0.0
176        } else {
177            leaked_count as f64 / test_data.len() as f64
178        };
179
180        ReliabilityMetrics {
181            redundancy,
182            duplicate_count,
183            leakage_ratio,
184            leaked_count,
185        }
186    }
187
188    fn compute_difficulty<S: AsRef<str>, T: AsRef<str>>(
189        &self,
190        train_data: &[(S, Vec<(T, T)>)],
191        test_data: &[(S, Vec<(T, T)>)],
192    ) -> DifficultyMetrics {
193        // Collect training entities
194        let train_entities: HashSet<String> = train_data
195            .iter()
196            .flat_map(|(_, entities)| entities.iter().map(|(e, _)| e.as_ref().to_lowercase()))
197            .collect();
198
199        // Count unseen test entities
200        let mut unseen_count = 0;
201        let mut total_test_entities = 0;
202
203        for (_, entities) in test_data {
204            for (entity, _) in entities {
205                total_test_entities += 1;
206                if !train_entities.contains(&entity.as_ref().to_lowercase()) {
207                    unseen_count += 1;
208                }
209            }
210        }
211
212        let unseen_entity_ratio = if total_test_entities == 0 {
213            0.0
214        } else {
215            unseen_count as f64 / total_test_entities as f64
216        };
217
218        // Compute entity ambiguity (same surface form, different labels)
219        let mut entity_labels: HashMap<String, HashSet<String>> = HashMap::new();
220
221        for (_, entities) in train_data.iter().chain(test_data.iter()) {
222            for (entity, label) in entities {
223                entity_labels
224                    .entry(entity.as_ref().to_lowercase())
225                    .or_default()
226                    .insert(label.as_ref().to_string());
227            }
228        }
229
230        let ambiguous: Vec<_> = entity_labels
231            .iter()
232            .filter(|(_, labels)| labels.len() > 1)
233            .map(|(entity, labels)| (entity.clone(), labels.iter().cloned().collect()))
234            .collect();
235
236        let entity_ambiguity = if entity_labels.is_empty() {
237            0.0
238        } else {
239            ambiguous.len() as f64 / entity_labels.len() as f64
240        };
241
242        // Compute entity density
243        let total_tokens: usize = train_data
244            .iter()
245            .map(|(t, _)| t.as_ref().split_whitespace().count())
246            .sum();
247
248        let total_entities: usize = train_data.iter().map(|(_, e)| e.len()).sum();
249
250        let entity_density = if total_tokens == 0 {
251            0.0
252        } else {
253            (total_entities as f64 / total_tokens as f64) * 100.0
254        };
255
256        DifficultyMetrics {
257            unseen_entity_ratio,
258            unseen_entity_count: unseen_count,
259            entity_ambiguity,
260            ambiguous_examples: ambiguous.into_iter().take(5).collect(),
261            entity_density,
262        }
263    }
264
265    fn compute_validity<S: AsRef<str>, T: AsRef<str>>(
266        &self,
267        train_data: &[(S, Vec<(T, T)>)],
268    ) -> ValidityMetrics {
269        // Count entity types
270        let mut type_counts: HashMap<String, usize> = HashMap::new();
271
272        for (_, entities) in train_data {
273            for (_, label) in entities {
274                *type_counts.entry(label.as_ref().to_string()).or_insert(0) += 1;
275            }
276        }
277
278        let (max_count, min_count) = if type_counts.is_empty() {
279            (0, 0)
280        } else {
281            let counts: Vec<_> = type_counts.values().copied().collect();
282            (
283                *counts.iter().max().unwrap_or(&0),
284                *counts.iter().min().unwrap_or(&0),
285            )
286        };
287
288        let entity_imbalance = if min_count == 0 {
289            f64::INFINITY
290        } else {
291            max_count as f64 / min_count as f64
292        };
293
294        // Compute null rate
295        let total_tokens: usize = train_data
296            .iter()
297            .map(|(t, _)| t.as_ref().split_whitespace().count())
298            .sum();
299
300        // Approximate entity tokens (rough estimate)
301        let entity_tokens: usize = train_data
302            .iter()
303            .flat_map(|(_, entities)| {
304                entities
305                    .iter()
306                    .map(|(e, _)| e.as_ref().split_whitespace().count())
307            })
308            .sum();
309
310        let entity_null_rate = if total_tokens == 0 {
311            1.0
312        } else {
313            1.0 - (entity_tokens as f64 / total_tokens as f64)
314        };
315
316        let total_entities: usize = train_data.iter().map(|(_, e)| e.len()).sum();
317        let avg_entities_per_sample = if train_data.is_empty() {
318            0.0
319        } else {
320            total_entities as f64 / train_data.len() as f64
321        };
322
323        ValidityMetrics {
324            entity_imbalance,
325            type_distribution: type_counts,
326            entity_null_rate,
327            avg_entities_per_sample,
328        }
329    }
330
331    fn compute_grade_and_recommendations(
332        &self,
333        reliability: &ReliabilityMetrics,
334        difficulty: &DifficultyMetrics,
335        validity: &ValidityMetrics,
336    ) -> (String, Vec<String>) {
337        let mut issues = Vec::new();
338        let mut score = 100;
339
340        // Check reliability issues
341        if reliability.redundancy > 0.1 {
342            issues.push(format!(
343                "High redundancy ({:.1}%): Remove duplicates from training data",
344                reliability.redundancy * 100.0
345            ));
346            score -= 15;
347        }
348        if reliability.leakage_ratio > 0.01 {
349            issues.push(format!(
350                "Data leakage detected ({:.1}%): {} test samples appear in training",
351                reliability.leakage_ratio * 100.0,
352                reliability.leaked_count
353            ));
354            score -= 25;
355        }
356
357        // Check difficulty issues
358        if difficulty.unseen_entity_ratio > 0.5 {
359            issues.push(format!(
360                "High unseen entity ratio ({:.1}%): Test set may be too different from training",
361                difficulty.unseen_entity_ratio * 100.0
362            ));
363            score -= 10;
364        }
365        if difficulty.entity_ambiguity > 0.1 {
366            issues.push(format!(
367                "Entity ambiguity ({:.1}%): Some entities have multiple labels - review guidelines",
368                difficulty.entity_ambiguity * 100.0
369            ));
370            score -= 10;
371        }
372
373        // Check validity issues
374        if validity.entity_imbalance > 10.0 {
375            issues.push(format!(
376                "Severe class imbalance ({:.1}x): Consider oversampling rare entity types",
377                validity.entity_imbalance
378            ));
379            score -= 15;
380        }
381        if validity.entity_null_rate > 0.95 {
382            issues.push(format!(
383                "Very sparse entities ({:.1}% null): May need more annotated data",
384                validity.entity_null_rate * 100.0
385            ));
386            score -= 10;
387        }
388
389        let grade = match score {
390            90..=100 => "A (Excellent)",
391            80..=89 => "B (Good)",
392            70..=79 => "C (Acceptable)",
393            60..=69 => "D (Needs Improvement)",
394            _ => "F (Critical Issues)",
395        };
396
397        (grade.to_string(), issues)
398    }
399}
400
401// =============================================================================
402// Utility Functions
403// =============================================================================
404
405/// Quick check for data leakage between train and test sets.
406pub fn check_leakage<S: AsRef<str>>(train_texts: &[S], test_texts: &[S]) -> (usize, f64) {
407    let train_set: HashSet<String> = train_texts
408        .iter()
409        .map(|t| t.as_ref().to_lowercase())
410        .collect();
411
412    let leaked = test_texts
413        .iter()
414        .filter(|t| train_set.contains(&t.as_ref().to_lowercase()))
415        .count();
416
417    let ratio = if test_texts.is_empty() {
418        0.0
419    } else {
420        leaked as f64 / test_texts.len() as f64
421    };
422
423    (leaked, ratio)
424}
425
426/// Compute entity type imbalance ratio.
427pub fn entity_imbalance_ratio<S: AsRef<str>>(entity_types: &[S]) -> f64 {
428    let mut counts: HashMap<&str, usize> = HashMap::new();
429    for t in entity_types {
430        *counts.entry(t.as_ref()).or_insert(0) += 1;
431    }
432
433    if counts.is_empty() {
434        return 1.0;
435    }
436
437    let max = *counts.values().max().unwrap_or(&0);
438    let min = *counts.values().min().unwrap_or(&0);
439
440    if min == 0 {
441        f64::INFINITY
442    } else {
443        max as f64 / min as f64
444    }
445}
446
447// =============================================================================
448// Tests
449// =============================================================================
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn test_redundancy_detection() {
457        let train: Vec<(&str, Vec<(&str, &str)>)> = vec![
458            ("John works at Google.", vec![("John", "PER")]),
459            ("John works at Google.", vec![("John", "PER")]), // Duplicate
460            ("Jane joined Microsoft.", vec![("Jane", "PER")]),
461        ];
462        let test: Vec<(&str, Vec<(&str, &str)>)> = vec![];
463
464        let analyzer = DatasetQualityAnalyzer::default();
465        let report = analyzer.analyze(&train, &test);
466
467        assert_eq!(report.reliability.duplicate_count, 1);
468        assert!(report.reliability.redundancy > 0.0);
469    }
470
471    #[test]
472    fn test_leakage_detection() {
473        let train: Vec<(&str, Vec<(&str, &str)>)> =
474            vec![("John works at Google.", vec![("John", "PER")])];
475        let test: Vec<(&str, Vec<(&str, &str)>)> = vec![
476            ("John works at Google.", vec![("John", "PER")]), // Leaked!
477            ("Jane joined Microsoft.", vec![("Jane", "PER")]),
478        ];
479
480        let analyzer = DatasetQualityAnalyzer::default();
481        let report = analyzer.analyze(&train, &test);
482
483        assert_eq!(report.reliability.leaked_count, 1);
484        assert!((report.reliability.leakage_ratio - 0.5).abs() < 0.01);
485    }
486
487    #[test]
488    fn test_unseen_entity_ratio() {
489        let train: Vec<(&str, Vec<(&str, &str)>)> = vec![(
490            "John works at Google.",
491            vec![("John", "PER"), ("Google", "ORG")],
492        )];
493        let test: Vec<(&str, Vec<(&str, &str)>)> = vec![(
494            "Jane joined Microsoft.",
495            vec![("Jane", "PER"), ("Microsoft", "ORG")],
496        )];
497
498        let analyzer = DatasetQualityAnalyzer::default();
499        let report = analyzer.analyze(&train, &test);
500
501        // Both test entities are unseen
502        assert_eq!(report.difficulty.unseen_entity_count, 2);
503        assert!((report.difficulty.unseen_entity_ratio - 1.0).abs() < 0.01);
504    }
505
506    #[test]
507    fn test_entity_ambiguity() {
508        let train: Vec<(&str, Vec<(&str, &str)>)> = vec![
509            ("Washington is a state.", vec![("Washington", "LOC")]),
510            ("Washington was president.", vec![("Washington", "PER")]), // Same entity, different label
511        ];
512        let test: Vec<(&str, Vec<(&str, &str)>)> = vec![];
513
514        let analyzer = DatasetQualityAnalyzer::default();
515        let report = analyzer.analyze(&train, &test);
516
517        assert!(report.difficulty.entity_ambiguity > 0.0);
518        assert!(!report.difficulty.ambiguous_examples.is_empty());
519    }
520
521    #[test]
522    fn test_entity_imbalance() {
523        let train: Vec<(&str, Vec<(&str, &str)>)> = vec![
524            ("Text 1", vec![("e1", "PER"), ("e2", "PER"), ("e3", "PER")]),
525            ("Text 2", vec![("e4", "ORG")]), // Only 1 ORG vs 3 PER
526        ];
527        let test: Vec<(&str, Vec<(&str, &str)>)> = vec![];
528
529        let analyzer = DatasetQualityAnalyzer::default();
530        let report = analyzer.analyze(&train, &test);
531
532        assert!((report.validity.entity_imbalance - 3.0).abs() < 0.01);
533    }
534
535    #[test]
536    fn test_quick_leakage_check() {
537        let train = vec!["text a", "text b", "text c"];
538        let test = vec!["text a", "text d"]; // "text a" is leaked
539
540        let (count, ratio) = check_leakage(&train, &test);
541        assert_eq!(count, 1);
542        assert!((ratio - 0.5).abs() < 0.01);
543    }
544}