1use serde::{Deserialize, Serialize};
28
29#[derive(Debug, Clone)]
35pub struct EntityFrequency {
36 pub entity_type: String,
38 pub count: usize,
40}
41
42impl EntityFrequency {
43 pub fn new(entity_type: impl Into<String>, count: usize) -> Self {
45 Self {
46 entity_type: entity_type.into(),
47 count,
48 }
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct TypePerformance {
55 pub entity_type: String,
57 pub count: usize,
59 pub precision: f64,
61 pub recall: f64,
63 pub f1: f64,
65 pub bucket: FrequencyBucket,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71pub enum FrequencyBucket {
72 Head,
74 Mid,
76 Tail,
78}
79
80impl std::fmt::Display for FrequencyBucket {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 match self {
83 FrequencyBucket::Head => write!(f, "Head"),
84 FrequencyBucket::Mid => write!(f, "Mid"),
85 FrequencyBucket::Tail => write!(f, "Tail"),
86 }
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct FrequencySplit {
93 pub head: Vec<String>,
95 pub mid: Vec<String>,
97 pub tail: Vec<String>,
99 pub head_coverage: f64,
101 pub mid_coverage: f64,
103 pub tail_coverage: f64,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct LongTailResults {
110 pub per_type: Vec<TypePerformance>,
112 pub head_f1: f64,
114 pub mid_f1: f64,
116 pub tail_f1: f64,
118 pub head_tail_gap: f64,
120 pub gini_coefficient: f64,
122 pub struggling_types: usize,
124 pub failed_types: usize,
126 pub insights: Vec<String>,
128}
129
130#[derive(Debug, Clone)]
136pub struct LongTailAnalyzer {
137 pub tail_percentile: f64,
139}
140
141impl Default for LongTailAnalyzer {
142 fn default() -> Self {
143 Self {
144 tail_percentile: 0.2,
145 }
146 }
147}
148
149impl LongTailAnalyzer {
150 pub fn new(tail_percentile: f64) -> Self {
152 Self {
153 tail_percentile: tail_percentile.clamp(0.05, 0.4),
154 }
155 }
156
157 pub fn split_by_frequency(&self, frequencies: &[EntityFrequency]) -> FrequencySplit {
159 if frequencies.is_empty() {
160 return FrequencySplit {
161 head: vec![],
162 mid: vec![],
163 tail: vec![],
164 head_coverage: 0.0,
165 mid_coverage: 0.0,
166 tail_coverage: 0.0,
167 };
168 }
169
170 let mut sorted: Vec<_> = frequencies.to_vec();
172 sorted.sort_by_key(|b| std::cmp::Reverse(b.count));
173
174 let total: usize = sorted.iter().map(|f| f.count).sum();
175 let n = sorted.len();
176
177 let head_cutoff = (n as f64 * self.tail_percentile).ceil() as usize;
179 let tail_cutoff = n - head_cutoff;
180
181 let mut head = Vec::new();
182 let mut mid = Vec::new();
183 let mut tail = Vec::new();
184
185 let mut head_count = 0usize;
186 let mut mid_count = 0usize;
187 let mut tail_count = 0usize;
188
189 for (i, f) in sorted.iter().enumerate() {
190 if i < head_cutoff {
191 head.push(f.entity_type.clone());
192 head_count += f.count;
193 } else if i >= tail_cutoff {
194 tail.push(f.entity_type.clone());
195 tail_count += f.count;
196 } else {
197 mid.push(f.entity_type.clone());
198 mid_count += f.count;
199 }
200 }
201
202 let total_f64 = total as f64;
203 FrequencySplit {
204 head,
205 mid,
206 tail,
207 head_coverage: if total > 0 {
208 head_count as f64 / total_f64
209 } else {
210 0.0
211 },
212 mid_coverage: if total > 0 {
213 mid_count as f64 / total_f64
214 } else {
215 0.0
216 },
217 tail_coverage: if total > 0 {
218 tail_count as f64 / total_f64
219 } else {
220 0.0
221 },
222 }
223 }
224
225 pub fn classify_type(
227 &self,
228 entity_type: &str,
229 frequencies: &[EntityFrequency],
230 ) -> FrequencyBucket {
231 let split = self.split_by_frequency(frequencies);
232
233 if split.head.contains(&entity_type.to_string()) {
234 FrequencyBucket::Head
235 } else if split.tail.contains(&entity_type.to_string()) {
236 FrequencyBucket::Tail
237 } else {
238 FrequencyBucket::Mid
239 }
240 }
241
242 pub fn analyze(&self, type_metrics: &[(String, usize, f64, f64, f64)]) -> LongTailResults {
244 if type_metrics.is_empty() {
247 return LongTailResults {
248 per_type: vec![],
249 head_f1: 0.0,
250 mid_f1: 0.0,
251 tail_f1: 0.0,
252 head_tail_gap: 0.0,
253 gini_coefficient: 0.0,
254 struggling_types: 0,
255 failed_types: 0,
256 insights: vec!["No entity types to analyze".into()],
257 };
258 }
259
260 let frequencies: Vec<_> = type_metrics
262 .iter()
263 .map(|(name, count, _, _, _)| EntityFrequency::new(name.clone(), *count))
264 .collect();
265
266 let split = self.split_by_frequency(&frequencies);
267
268 let per_type: Vec<_> = type_metrics
270 .iter()
271 .map(|(name, count, prec, rec, f1)| {
272 let bucket = self.classify_type(name, &frequencies);
273 TypePerformance {
274 entity_type: name.clone(),
275 count: *count,
276 precision: *prec,
277 recall: *rec,
278 f1: *f1,
279 bucket,
280 }
281 })
282 .collect();
283
284 let head_types: Vec<_> = per_type
286 .iter()
287 .filter(|t| t.bucket == FrequencyBucket::Head)
288 .collect();
289 let mid_types: Vec<_> = per_type
290 .iter()
291 .filter(|t| t.bucket == FrequencyBucket::Mid)
292 .collect();
293 let tail_types: Vec<_> = per_type
294 .iter()
295 .filter(|t| t.bucket == FrequencyBucket::Tail)
296 .collect();
297
298 let head_f1 = if head_types.is_empty() {
299 0.0
300 } else {
301 head_types.iter().map(|t| t.f1).sum::<f64>() / head_types.len() as f64
302 };
303
304 let mid_f1 = if mid_types.is_empty() {
305 0.0
306 } else {
307 mid_types.iter().map(|t| t.f1).sum::<f64>() / mid_types.len() as f64
308 };
309
310 let tail_f1 = if tail_types.is_empty() {
311 0.0
312 } else {
313 tail_types.iter().map(|t| t.f1).sum::<f64>() / tail_types.len() as f64
314 };
315
316 let head_tail_gap = head_f1 - tail_f1;
317
318 let gini_coefficient = compute_gini(&per_type.iter().map(|t| t.f1).collect::<Vec<_>>());
320
321 let struggling_types = per_type.iter().filter(|t| t.f1 < 0.5).count();
323 let failed_types = per_type.iter().filter(|t| t.f1 < 0.01).count();
324
325 let mut insights = Vec::new();
327
328 if head_tail_gap > 0.3 {
329 insights.push(format!(
330 "Large head-tail gap ({:.0}%): tail types severely underperforming",
331 head_tail_gap * 100.0
332 ));
333 } else if head_tail_gap < 0.1 {
334 insights.push("Low head-tail gap: relatively uniform performance across types".into());
335 }
336
337 if gini_coefficient > 0.4 {
338 insights.push(format!(
339 "High inequality (Gini={:.2}): performance very uneven across types",
340 gini_coefficient
341 ));
342 }
343
344 if failed_types > 0 {
345 insights.push(format!(
346 "{} entity types completely failed (F1=0%)",
347 failed_types
348 ));
349 }
350
351 if !tail_types.is_empty() && tail_f1 < 0.3 {
352 let tail_names: Vec<_> = tail_types.iter().map(|t| t.entity_type.as_str()).collect();
353 insights.push(format!(
354 "Tail types struggling: {:?}",
355 &tail_names[..tail_names.len().min(3)]
356 ));
357 }
358
359 if split.tail_coverage > 0.0 && split.tail_coverage < 0.1 {
361 insights.push(format!(
362 "Tail types represent only {:.1}% of data - may need upsampling",
363 split.tail_coverage * 100.0
364 ));
365 }
366
367 LongTailResults {
368 per_type,
369 head_f1,
370 mid_f1,
371 tail_f1,
372 head_tail_gap,
373 gini_coefficient,
374 struggling_types,
375 failed_types,
376 insights,
377 }
378 }
379}
380
381fn compute_gini(values: &[f64]) -> f64 {
383 if values.is_empty() {
384 return 0.0;
385 }
386
387 let n = values.len() as f64;
388 let mean = values.iter().sum::<f64>() / n;
389
390 if mean < 1e-10 {
391 return 0.0; }
393
394 let mut sum_abs_diff = 0.0;
395 for v1 in values {
396 for v2 in values {
397 sum_abs_diff += (v1 - v2).abs();
398 }
399 }
400
401 sum_abs_diff / (2.0 * n * n * mean)
402}
403
404pub fn format_long_tail_results(results: &LongTailResults) -> String {
406 let mut output = String::new();
407
408 output.push_str("Long-Tail Analysis:\n");
409 output.push_str(&format!(" Head F1: {:.1}%\n", results.head_f1 * 100.0));
410 output.push_str(&format!(" Mid F1: {:.1}%\n", results.mid_f1 * 100.0));
411 output.push_str(&format!(" Tail F1: {:.1}%\n", results.tail_f1 * 100.0));
412 output.push_str(&format!(
413 " Head-Tail Gap: {:.1}%\n",
414 results.head_tail_gap * 100.0
415 ));
416 output.push_str(&format!(
417 " Gini Coefficient: {:.3}\n",
418 results.gini_coefficient
419 ));
420 output.push_str(&format!(
421 " Struggling types (F1<50%): {}\n",
422 results.struggling_types
423 ));
424 output.push_str(&format!(
425 " Failed types (F1=0%): {}\n",
426 results.failed_types
427 ));
428
429 if !results.insights.is_empty() {
430 output.push_str("\nInsights:\n");
431 for insight in &results.insights {
432 output.push_str(&format!(" - {}\n", insight));
433 }
434 }
435
436 output
437}
438
439#[cfg(test)]
444mod tests {
445 use super::*;
446
447 #[test]
448 fn test_frequency_split() {
449 let frequencies = vec![
450 EntityFrequency::new("A", 100),
451 EntityFrequency::new("B", 80),
452 EntityFrequency::new("C", 60),
453 EntityFrequency::new("D", 40),
454 EntityFrequency::new("E", 20),
455 ];
456
457 let analyzer = LongTailAnalyzer::new(0.2);
458 let split = analyzer.split_by_frequency(&frequencies);
459
460 assert_eq!(split.head.len(), 1);
462 assert!(split.head.contains(&"A".to_string()));
463 assert_eq!(split.tail.len(), 1);
464 assert!(split.tail.contains(&"E".to_string()));
465 }
466
467 #[test]
468 fn test_gini_coefficient() {
469 let equal = vec![0.5, 0.5, 0.5, 0.5];
471 assert!(compute_gini(&equal) < 0.01);
472
473 let unequal = vec![1.0, 0.0, 0.0, 0.0];
475 assert!(compute_gini(&unequal) > 0.5);
476 }
477
478 #[test]
479 fn test_analyze_long_tail() {
480 let type_metrics = vec![
481 ("PER".to_string(), 100, 0.9, 0.85, 0.87),
482 ("ORG".to_string(), 80, 0.8, 0.75, 0.77),
483 ("LOC".to_string(), 60, 0.7, 0.65, 0.67),
484 ("DATE".to_string(), 40, 0.6, 0.55, 0.57),
485 ("DISEASE".to_string(), 20, 0.3, 0.25, 0.27),
486 ];
487
488 let analyzer = LongTailAnalyzer::new(0.2);
489 let results = analyzer.analyze(&type_metrics);
490
491 assert!(results.head_f1 > results.tail_f1);
493 assert!(results.tail_f1 < 0.5);
495 assert!(results.head_tail_gap > 0.3);
497 }
498
499 #[test]
500 fn test_empty_input() {
501 let analyzer = LongTailAnalyzer::default();
502 let results = analyzer.analyze(&[]);
503
504 assert_eq!(results.per_type.len(), 0);
505 assert!(!results.insights.is_empty());
506 }
507
508 #[test]
509 fn test_bucket_assignment() {
510 let frequencies = vec![
511 EntityFrequency::new("A", 100),
512 EntityFrequency::new("B", 50),
513 EntityFrequency::new("C", 10),
514 ];
515
516 let analyzer = LongTailAnalyzer::new(0.33);
517
518 assert_eq!(
519 analyzer.classify_type("A", &frequencies),
520 FrequencyBucket::Head
521 );
522 assert_eq!(
523 analyzer.classify_type("C", &frequencies),
524 FrequencyBucket::Tail
525 );
526 }
527}