Skip to main content

anno_eval/eval/
learning_curve.rs

1//! Learning curve analysis for NER evaluation.
2//!
3//! Tracks how model performance changes with training data size,
4//! enabling data efficiency analysis and optimal data budgeting.
5//!
6//! # Key Insights
7//!
8//! - **Sample Efficiency**: How many examples needed for target F1?
9//! - **Diminishing Returns**: Where does adding data stop helping?
10//! - **Per-Entity Curves**: Which entity types need more data?
11//!
12//! # Example
13//!
14//! ```rust
15//! use anno_eval::eval::learning_curve::{LearningCurveAnalyzer, DataPoint};
16//!
17//! let points = vec![
18//!     DataPoint { train_size: 100, f1: 0.65, precision: 0.70, recall: 0.60 },
19//!     DataPoint { train_size: 500, f1: 0.80, precision: 0.82, recall: 0.78 },
20//!     DataPoint { train_size: 1000, f1: 0.85, precision: 0.86, recall: 0.84 },
21//! ];
22//!
23//! let analyzer = LearningCurveAnalyzer::new(points);
24//! let analysis = analyzer.analyze();
25//!
26//! if let Some(samples) = analysis.samples_for_target(0.90) {
27//!     println!("Estimated samples for target F1: {}", samples);
28//! }
29//! ```
30
31use serde::{Deserialize, Serialize};
32use std::collections::HashMap;
33
34// =============================================================================
35// Data Structures
36// =============================================================================
37
38/// A single point on the learning curve.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct DataPoint {
41    /// Number of training samples
42    pub train_size: usize,
43    /// F1 score at this training size
44    pub f1: f64,
45    /// Precision at this training size
46    pub precision: f64,
47    /// Recall at this training size
48    pub recall: f64,
49}
50
51/// Learning curve analysis results.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct LearningCurveAnalysis {
54    /// Raw data points
55    pub data_points: Vec<DataPoint>,
56    /// Per-entity-type learning curves (if available)
57    pub per_entity_curves: HashMap<String, Vec<DataPoint>>,
58    /// Sample efficiency metrics
59    pub efficiency: SampleEfficiencyMetrics,
60    /// Fitted curve parameters (power law: y = a * x^b + c)
61    pub curve_fit: Option<CurveFitParams>,
62    /// Recommendations based on curve shape
63    pub recommendations: Vec<String>,
64}
65
66/// Sample efficiency metrics.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct SampleEfficiencyMetrics {
69    /// F1 improvement per 100 samples (average)
70    pub f1_per_100_samples: f64,
71    /// Estimated samples needed for various F1 targets
72    pub samples_for_targets: HashMap<String, Option<usize>>,
73    /// Diminishing returns threshold (where adding data helps very little)
74    pub diminishing_returns_threshold: Option<usize>,
75    /// Current saturation level (0-1, how close to plateau)
76    pub saturation_level: f64,
77}
78
79/// Power law curve fit parameters: y = a * x^b + c
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct CurveFitParams {
82    /// Scaling coefficient
83    pub a: f64,
84    /// Power exponent (controls curve shape)
85    pub b: f64,
86    /// Asymptotic offset (theoretical maximum)
87    pub c: f64,
88    /// R² goodness of fit
89    pub r_squared: f64,
90}
91
92// =============================================================================
93// Learning Curve Analyzer
94// =============================================================================
95
96/// Analyzer for learning curves.
97#[derive(Debug, Clone)]
98pub struct LearningCurveAnalyzer {
99    data_points: Vec<DataPoint>,
100    per_entity_curves: HashMap<String, Vec<DataPoint>>,
101}
102
103impl LearningCurveAnalyzer {
104    /// Create analyzer from aggregate data points.
105    pub fn new(data_points: Vec<DataPoint>) -> Self {
106        Self {
107            data_points,
108            per_entity_curves: HashMap::new(),
109        }
110    }
111
112    /// Add per-entity-type curve data.
113    pub fn with_entity_curves(mut self, curves: HashMap<String, Vec<DataPoint>>) -> Self {
114        self.per_entity_curves = curves;
115        self
116    }
117
118    /// Perform learning curve analysis.
119    pub fn analyze(&self) -> LearningCurveAnalysis {
120        let efficiency = self.compute_efficiency();
121        let curve_fit = self.fit_power_law();
122        let recommendations = self.generate_recommendations(&efficiency, &curve_fit);
123
124        LearningCurveAnalysis {
125            data_points: self.data_points.clone(),
126            per_entity_curves: self.per_entity_curves.clone(),
127            efficiency,
128            curve_fit,
129            recommendations,
130        }
131    }
132
133    fn compute_efficiency(&self) -> SampleEfficiencyMetrics {
134        let mut sorted_points = self.data_points.clone();
135        sorted_points.sort_by_key(|p| p.train_size);
136
137        // Compute average F1 improvement per 100 samples
138        let f1_per_100 = if sorted_points.len() < 2 {
139            0.0
140        } else {
141            let first = &sorted_points[0];
142            let last = &sorted_points[sorted_points.len() - 1];
143            let f1_improvement = last.f1 - first.f1;
144            let sample_diff = last.train_size - first.train_size;
145            if sample_diff == 0 {
146                0.0
147            } else {
148                (f1_improvement / sample_diff as f64) * 100.0
149            }
150        };
151
152        // Estimate samples for various F1 targets
153        let targets = vec![0.80, 0.85, 0.90, 0.95];
154        let mut samples_for_targets = HashMap::new();
155
156        for target in targets {
157            let key = format!("{:.0}%", target * 100.0);
158            samples_for_targets.insert(key, self.estimate_samples_for_f1(target));
159        }
160
161        // Find diminishing returns threshold
162        let diminishing_threshold = self.find_diminishing_returns(&sorted_points);
163
164        // Compute saturation level
165        let saturation = self.compute_saturation(&sorted_points);
166
167        SampleEfficiencyMetrics {
168            f1_per_100_samples: f1_per_100,
169            samples_for_targets,
170            diminishing_returns_threshold: diminishing_threshold,
171            saturation_level: saturation,
172        }
173    }
174
175    fn estimate_samples_for_f1(&self, target_f1: f64) -> Option<usize> {
176        let mut sorted = self.data_points.clone();
177        sorted.sort_by_key(|p| p.train_size);
178
179        // Check if we've already achieved this F1
180        for point in &sorted {
181            if point.f1 >= target_f1 {
182                return Some(point.train_size);
183            }
184        }
185
186        // Extrapolate using power law if we have enough points
187        if sorted.len() >= 3 {
188            if let Some(fit) = self.fit_power_law() {
189                // Solve for x: target_f1 = a * x^b + c
190                // x = ((target_f1 - c) / a)^(1/b)
191                let diff = target_f1 - fit.c;
192                if diff > 0.0 && fit.a > 0.0 && fit.b != 0.0 {
193                    let x = (diff / fit.a).powf(1.0 / fit.b);
194                    if x.is_finite() && x > 0.0 {
195                        return Some(x as usize);
196                    }
197                }
198            }
199        }
200
201        None
202    }
203
204    fn find_diminishing_returns(&self, sorted: &[DataPoint]) -> Option<usize> {
205        if sorted.len() < 3 {
206            return None;
207        }
208
209        // Find where F1 improvement drops below a small threshold per doubling of data
210        for i in 1..sorted.len() {
211            let prev = &sorted[i - 1];
212            let curr = &sorted[i];
213
214            let sample_ratio = curr.train_size as f64 / prev.train_size as f64;
215            let f1_improvement = curr.f1 - prev.f1;
216
217            // If doubling data yields negligible improvement, we hit diminishing returns
218            if sample_ratio >= 1.5 && f1_improvement < 0.01 {
219                return Some(prev.train_size);
220            }
221        }
222
223        None
224    }
225
226    fn compute_saturation(&self, sorted: &[DataPoint]) -> f64 {
227        if sorted.len() < 3 {
228            return 0.0;
229        }
230
231        // Compare recent improvement rate to initial improvement rate
232        let first_third_end = sorted.len() / 3;
233        let last_third_start = sorted.len() * 2 / 3;
234
235        if first_third_end == 0 || last_third_start >= sorted.len() {
236            return 0.0;
237        }
238
239        let initial_improvement = sorted[first_third_end].f1 - sorted[0].f1;
240        let recent_improvement = sorted[sorted.len() - 1].f1 - sorted[last_third_start].f1;
241
242        if initial_improvement <= 0.0 {
243            return 1.0; // Already saturated from start
244        }
245
246        // Saturation = 1 - (recent_rate / initial_rate)
247        let saturation = 1.0 - (recent_improvement / initial_improvement).min(1.0);
248        saturation.clamp(0.0, 1.0)
249    }
250
251    fn fit_power_law(&self) -> Option<CurveFitParams> {
252        if self.data_points.len() < 3 {
253            return None;
254        }
255
256        // Simple power law fit: y = a * x^b + c
257        // Using least squares on log-transformed data for a and b,
258        // then estimate c from residuals
259
260        let mut sorted = self.data_points.clone();
261        sorted.sort_by_key(|p| p.train_size);
262
263        // For simplicity, use a basic heuristic fit
264        // In production, would use proper nonlinear regression
265
266        let x_log: Vec<f64> = sorted.iter().map(|p| (p.train_size as f64).ln()).collect();
267        let y: Vec<f64> = sorted.iter().map(|p| p.f1).collect();
268
269        let n = x_log.len() as f64;
270        let sum_x = x_log.iter().sum::<f64>();
271        let sum_y = y.iter().sum::<f64>();
272        let sum_xy: f64 = x_log.iter().zip(y.iter()).map(|(x, y)| x * y).sum();
273        let sum_x2: f64 = x_log.iter().map(|x| x * x).sum();
274
275        let denom = n * sum_x2 - sum_x * sum_x;
276        if denom.abs() < 1e-10 {
277            return None;
278        }
279
280        let b = (n * sum_xy - sum_x * sum_y) / denom;
281        let a_log = (sum_y - b * sum_x) / n;
282        let a = a_log.exp();
283
284        // Estimate c as the asymptote (use last point's F1 + small buffer)
285        let c = sorted.last().map(|p| p.f1 * 1.05).unwrap_or(1.0).min(1.0);
286
287        // Compute R²
288        let y_mean = sum_y / n;
289        let ss_tot: f64 = y.iter().map(|yi| (yi - y_mean).powi(2)).sum();
290        let ss_res: f64 = sorted
291            .iter()
292            .map(|p| {
293                let predicted = a * (p.train_size as f64).powf(b);
294                (p.f1 - predicted).powi(2)
295            })
296            .sum();
297
298        let r_squared = if ss_tot > 0.0 {
299            1.0 - ss_res / ss_tot
300        } else {
301            0.0
302        };
303
304        Some(CurveFitParams {
305            a,
306            b,
307            c,
308            r_squared: r_squared.max(0.0),
309        })
310    }
311
312    fn generate_recommendations(
313        &self,
314        efficiency: &SampleEfficiencyMetrics,
315        _curve_fit: &Option<CurveFitParams>,
316    ) -> Vec<String> {
317        let mut recs = Vec::new();
318
319        // Saturation-based recommendations
320        if efficiency.saturation_level > 0.8 {
321            recs.push(
322                "Model appears saturated - consider architectural changes rather than more data"
323                    .to_string(),
324            );
325        } else if efficiency.saturation_level > 0.5 {
326            recs.push(
327                "Approaching saturation - additional data will have diminishing returns"
328                    .to_string(),
329            );
330        } else {
331            recs.push(
332                "Model not saturated - more training data likely to improve performance"
333                    .to_string(),
334            );
335        }
336
337        // Efficiency-based recommendations
338        if efficiency.f1_per_100_samples < 0.001 {
339            recs.push(
340                "Very low data efficiency - check for data quality issues or model capacity"
341                    .to_string(),
342            );
343        } else if efficiency.f1_per_100_samples > 0.05 {
344            recs.push(
345                "High data efficiency - model is learning effectively from limited data"
346                    .to_string(),
347            );
348        }
349
350        // Target-based recommendations
351        if let Some(Some(samples_90)) = efficiency.samples_for_targets.get("90%") {
352            recs.push(format!(
353                "Estimated ~{} samples needed to reach target F1",
354                samples_90
355            ));
356        }
357
358        recs
359    }
360}
361
362impl LearningCurveAnalysis {
363    /// Estimate samples needed for a specific F1 target.
364    pub fn samples_for_target(&self, target_f1: f64) -> Option<usize> {
365        let key = format!("{:.0}%", target_f1 * 100.0);
366        self.efficiency
367            .samples_for_targets
368            .get(&key)
369            .and_then(|v| *v)
370    }
371
372    /// Check if more data would likely help.
373    pub fn more_data_would_help(&self) -> bool {
374        self.efficiency.saturation_level < 0.7
375    }
376}
377
378// =============================================================================
379// Utility Functions
380// =============================================================================
381
382/// Generate learning curve data by training at different data sizes.
383///
384/// This is a helper for setting up learning curve experiments.
385pub fn suggested_train_sizes(max_size: usize) -> Vec<usize> {
386    let mut sizes = Vec::new();
387
388    // Exponential spacing: 10, 25, 50, 100, 250, 500, 1000, ...
389    let mut size = 10;
390    while size <= max_size {
391        sizes.push(size);
392        // Roughly double each time (with some intermediate points)
393        size = (size as f64 * 2.5) as usize;
394    }
395
396    // Always include the max
397    if sizes.last() != Some(&max_size) {
398        sizes.push(max_size);
399    }
400
401    sizes
402}
403
404// =============================================================================
405// Tests
406// =============================================================================
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn test_basic_analysis() {
414        let points = vec![
415            DataPoint {
416                train_size: 100,
417                f1: 0.60,
418                precision: 0.65,
419                recall: 0.55,
420            },
421            DataPoint {
422                train_size: 500,
423                f1: 0.75,
424                precision: 0.78,
425                recall: 0.72,
426            },
427            DataPoint {
428                train_size: 1000,
429                f1: 0.82,
430                precision: 0.84,
431                recall: 0.80,
432            },
433            DataPoint {
434                train_size: 2000,
435                f1: 0.85,
436                precision: 0.86,
437                recall: 0.84,
438            },
439        ];
440
441        let analyzer = LearningCurveAnalyzer::new(points);
442        let analysis = analyzer.analyze();
443
444        assert!(analysis.efficiency.f1_per_100_samples > 0.0);
445        assert!(!analysis.recommendations.is_empty());
446    }
447
448    #[test]
449    fn test_saturation_detection() {
450        // Simulated saturated model - big gains early, tiny gains late
451        let points = vec![
452            DataPoint {
453                train_size: 100,
454                f1: 0.50,
455                precision: 0.50,
456                recall: 0.50,
457            },
458            DataPoint {
459                train_size: 200,
460                f1: 0.70,
461                precision: 0.70,
462                recall: 0.70,
463            },
464            DataPoint {
465                train_size: 400,
466                f1: 0.80,
467                precision: 0.80,
468                recall: 0.80,
469            },
470            DataPoint {
471                train_size: 800,
472                f1: 0.82,
473                precision: 0.82,
474                recall: 0.82,
475            },
476            DataPoint {
477                train_size: 1600,
478                f1: 0.83,
479                precision: 0.83,
480                recall: 0.83,
481            },
482            DataPoint {
483                train_size: 3200,
484                f1: 0.835,
485                precision: 0.835,
486                recall: 0.835,
487            },
488        ];
489
490        let analyzer = LearningCurveAnalyzer::new(points);
491        let analysis = analyzer.analyze();
492
493        // Should detect high saturation
494        assert!(analysis.efficiency.saturation_level > 0.5);
495    }
496
497    #[test]
498    fn test_suggested_train_sizes() {
499        let sizes = suggested_train_sizes(10000);
500
501        assert!(!sizes.is_empty());
502        assert_eq!(*sizes.first().unwrap(), 10);
503        assert_eq!(*sizes.last().unwrap(), 10000);
504
505        // Should be roughly exponentially spaced
506        for i in 1..sizes.len() {
507            assert!(sizes[i] > sizes[i - 1]);
508        }
509    }
510
511    #[test]
512    fn test_more_data_would_help() {
513        // Unsaturated model - consistent improvement rate throughout
514        // (first third and last third have similar improvement rates)
515        let unsaturated = vec![
516            DataPoint {
517                train_size: 100,
518                f1: 0.40,
519                precision: 0.40,
520                recall: 0.40,
521            },
522            DataPoint {
523                train_size: 200,
524                f1: 0.48,
525                precision: 0.48,
526                recall: 0.48,
527            },
528            DataPoint {
529                train_size: 400,
530                f1: 0.56,
531                precision: 0.56,
532                recall: 0.56,
533            },
534            DataPoint {
535                train_size: 800,
536                f1: 0.64,
537                precision: 0.64,
538                recall: 0.64,
539            },
540            DataPoint {
541                train_size: 1600,
542                f1: 0.72,
543                precision: 0.72,
544                recall: 0.72,
545            },
546            DataPoint {
547                train_size: 3200,
548                f1: 0.80,
549                precision: 0.80,
550                recall: 0.80,
551            },
552        ];
553
554        let analyzer = LearningCurveAnalyzer::new(unsaturated);
555        let analysis = analyzer.analyze();
556
557        // Linear improvement = low saturation
558        assert!(
559            analysis.efficiency.saturation_level < 0.5,
560            "Saturation level {:.2} should be < 0.5 for linearly improving model",
561            analysis.efficiency.saturation_level
562        );
563        assert!(analysis.more_data_would_help());
564    }
565
566    #[test]
567    fn test_empty_data() {
568        let analyzer = LearningCurveAnalyzer::new(vec![]);
569        let analysis = analyzer.analyze();
570
571        assert_eq!(analysis.efficiency.f1_per_100_samples, 0.0);
572        assert!(analysis.curve_fit.is_none());
573    }
574}