1use serde::{Deserialize, Serialize};
32use std::collections::HashMap;
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct DataPoint {
41 pub train_size: usize,
43 pub f1: f64,
45 pub precision: f64,
47 pub recall: f64,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct LearningCurveAnalysis {
54 pub data_points: Vec<DataPoint>,
56 pub per_entity_curves: HashMap<String, Vec<DataPoint>>,
58 pub efficiency: SampleEfficiencyMetrics,
60 pub curve_fit: Option<CurveFitParams>,
62 pub recommendations: Vec<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct SampleEfficiencyMetrics {
69 pub f1_per_100_samples: f64,
71 pub samples_for_targets: HashMap<String, Option<usize>>,
73 pub diminishing_returns_threshold: Option<usize>,
75 pub saturation_level: f64,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct CurveFitParams {
82 pub a: f64,
84 pub b: f64,
86 pub c: f64,
88 pub r_squared: f64,
90}
91
92#[derive(Debug, Clone)]
98pub struct LearningCurveAnalyzer {
99 data_points: Vec<DataPoint>,
100 per_entity_curves: HashMap<String, Vec<DataPoint>>,
101}
102
103impl LearningCurveAnalyzer {
104 pub fn new(data_points: Vec<DataPoint>) -> Self {
106 Self {
107 data_points,
108 per_entity_curves: HashMap::new(),
109 }
110 }
111
112 pub fn with_entity_curves(mut self, curves: HashMap<String, Vec<DataPoint>>) -> Self {
114 self.per_entity_curves = curves;
115 self
116 }
117
118 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 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 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 let diminishing_threshold = self.find_diminishing_returns(&sorted_points);
163
164 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 for point in &sorted {
181 if point.f1 >= target_f1 {
182 return Some(point.train_size);
183 }
184 }
185
186 if sorted.len() >= 3 {
188 if let Some(fit) = self.fit_power_law() {
189 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 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 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 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; }
245
246 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 let mut sorted = self.data_points.clone();
261 sorted.sort_by_key(|p| p.train_size);
262
263 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 let c = sorted.last().map(|p| p.f1 * 1.05).unwrap_or(1.0).min(1.0);
286
287 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 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 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 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 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 pub fn more_data_would_help(&self) -> bool {
374 self.efficiency.saturation_level < 0.7
375 }
376}
377
378pub fn suggested_train_sizes(max_size: usize) -> Vec<usize> {
386 let mut sizes = Vec::new();
387
388 let mut size = 10;
390 while size <= max_size {
391 sizes.push(size);
392 size = (size as f64 * 2.5) as usize;
394 }
395
396 if sizes.last() != Some(&max_size) {
398 sizes.push(max_size);
399 }
400
401 sizes
402}
403
404#[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 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 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 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 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 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}