#![deny(missing_docs)] //! Evaluate the default DADOES checkpoint and benchmark classification speed. use std::error::Error; use std::fmt::{Display, Formatter}; use std::fs; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::{Duration, Instant}; use dadoes::datasets::{ DatasetParseError, parse_dadoes_jsonl, parse_empathetic_dialogues_csv, parse_fig_loneliness_jsonl, parse_loneliness_causes_csv, parse_um1neko_text_emotion_jsonl, }; use dadoes::goemotions::{GoEmotionsError, parse_split}; use dadoes::{DadoesClassifier, EmotionClassifier, Mood, OwnedTrainingExample}; const DEFAULT_TEST_SPLIT: &str = "data/raw/goemotions/test.tsv"; const DEFAULT_EXTERNAL_DATA_DIR: &str = "data/raw/external"; const EXTERNAL_DATA_DIR_ENV: &str = "DADOES_EXTERNAL_DATA_DIR"; const INCLUDE_NON_COMMERCIAL_ENV: &str = "DADOES_INCLUDE_NON_COMMERCIAL"; const THRESHOLD: f32 = 0.35; const BENCHMARK_REPEATS: usize = 50; /// Runs default checkpoint evaluation and benchmark reporting. fn main() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, Err(error) => { eprintln!("{error}"); ExitCode::from(1) } } } fn run() -> Result<(), EvaluateCliError> { let args = EvaluateArgs::from_env()?; let mut examples = load_split(&args.test_split)?; let external = load_external_test_mix( &args.external_data_dir, args.include_non_commercial, &mut examples, )?; let load_started = Instant::now(); let classifier = DadoesClassifier::from_default_model()?; let load_duration = load_started.elapsed(); let label_metrics = per_label_metrics(&classifier, &examples); let benchmark = benchmark_classifier(&classifier, &examples, BENCHMARK_REPEATS); println!("## Per-mood test metrics"); println!(); println!( "Loaded mixed test examples: {} (external files: {})", examples.len(), external.len() ); for summary in &external { println!( "- {} examples={} path={}", summary.name, summary.examples, summary.path.display() ); } println!(); println!( "| Mood | Supervised Examples | Positives | Accuracy | Precision | Recall | F1 | Coverage |" ); println!("|---|---:|---:|---:|---:|---:|---:|---|"); for metrics in &label_metrics { if metrics.has_supervision() { println!("{}", metrics.markdown_row()); } } let missing_labels = labels_without_supervision(&label_metrics); if !missing_labels.is_empty() { println!(); println!( "Labels without supervised examples in the loaded mixed test: {}", missing_labels.join(", ") ); } println!(); println!("## Recognition benchmark"); println!(); println!("| Benchmark | Value |"); println!("|---|---:|"); println!("| Model load time | {:.3} ms |", duration_ms(load_duration)); println!("| Test examples | {} |", examples.len()); println!("| Repeats | {} |", benchmark.repeats); println!("| Total classifications | {} |", benchmark.classifications); println!( "| Total classification time | {:.3} ms |", duration_ms(benchmark.duration) ); println!( "| Throughput | {:.1} texts/s |", benchmark.texts_per_second() ); println!( "| Mean classification latency | {:.3} us/text |", benchmark.micros_per_text() ); println!("| Score checksum | {:.6} |", benchmark.score_checksum); Ok(()) } fn load_external_test_mix( root: &Path, include_non_commercial: bool, test: &mut Vec, ) -> Result, EvaluateCliError> { let mut summaries = Vec::new(); for file in EXTERNAL_TEST_DATASET_FILES { if !file.license.allowed_by(include_non_commercial) { continue; } let path = root.join(file.relative_path); if !path.exists() { continue; } let input = fs::read_to_string(&path).map_err(|source| EvaluateCliError::ReadFile { path: path.clone(), source, })?; let examples = (file.parser)(&input).map_err(|source| EvaluateCliError::ParseExternal { path: path.clone(), source, })?; let examples_count = examples.len(); test.extend(examples); summaries.push(ExternalDatasetSummary { name: file.name, path, examples: examples_count, }); } Ok(summaries) } fn load_split(path: &Path) -> Result, EvaluateCliError> { let input = fs::read_to_string(path).map_err(|source| EvaluateCliError::ReadFile { path: path.to_path_buf(), source, })?; parse_split(&input).map_err(EvaluateCliError::ParseSplit) } fn per_label_metrics( classifier: &DadoesClassifier, examples: &[OwnedTrainingExample], ) -> Vec { let mut counters = Mood::ALL .iter() .copied() .map(LabelCounter::new) .collect::>(); for example in examples { let analysis = classifier.classify(example.text()); for score in analysis.scores() { if !example.supervised_labels().contains(&score.mood) { continue; } if let Some(counter) = counters .iter_mut() .find(|counter| counter.mood == score.mood) { let target = example .labels() .iter() .copied() .any(|label| label == score.mood); let predicted = score.score >= THRESHOLD; counter.observe(predicted, target); } } } counters.into_iter().map(LabelCounter::metrics).collect() } fn benchmark_classifier( classifier: &DadoesClassifier, examples: &[OwnedTrainingExample], repeats: usize, ) -> BenchmarkMetrics { let started = Instant::now(); let mut classifications = 0_usize; let mut score_checksum = 0.0_f32; for _ in 0..repeats { for example in examples { let analysis = classifier.classify(example.text()); classifications += 1; if let Some(primary) = analysis.primary_mood() { score_checksum += primary.score; } } } BenchmarkMetrics { repeats, classifications, duration: started.elapsed(), score_checksum, } } #[derive(Debug, Copy, Clone)] struct LabelCounter { mood: Mood, supervised: usize, positives: usize, true_positive: usize, false_positive: usize, true_negative: usize, false_negative: usize, } impl LabelCounter { fn new(mood: Mood) -> Self { Self { mood, supervised: 0, positives: 0, true_positive: 0, false_positive: 0, true_negative: 0, false_negative: 0, } } fn observe(&mut self, predicted: bool, target: bool) { self.supervised += 1; if target { self.positives += 1; } match (predicted, target) { (true, true) => self.true_positive += 1, (true, false) => self.false_positive += 1, (false, true) => self.false_negative += 1, (false, false) => self.true_negative += 1, } } fn metrics(self) -> LabelMetrics { let accuracy = ratio(self.true_positive + self.true_negative, self.supervised); let precision = ratio(self.true_positive, self.true_positive + self.false_positive); let recall = ratio(self.true_positive, self.true_positive + self.false_negative); let f1 = harmonic_mean(precision, recall); LabelMetrics { mood: self.mood, supervised: self.supervised, positives: self.positives, accuracy, precision, recall, f1, } } } #[derive(Debug, Copy, Clone)] struct LabelMetrics { mood: Mood, supervised: usize, positives: usize, accuracy: Option, precision: Option, recall: Option, f1: Option, } impl LabelMetrics { fn has_supervision(&self) -> bool { self.supervised > 0 } fn markdown_row(&self) -> String { format!( "| {} | {} | {} | {} | {} | {} | {} | {} |", self.mood.as_str(), self.supervised, self.positives, format_metric(self.accuracy), format_metric(self.precision), format_metric(self.recall), format_metric(self.f1), "loaded mixed test" ) } } #[derive(Debug, Copy, Clone)] struct BenchmarkMetrics { repeats: usize, classifications: usize, duration: Duration, score_checksum: f32, } impl BenchmarkMetrics { fn texts_per_second(&self) -> f64 { usize_to_f64(self.classifications) / self.duration.as_secs_f64() } fn micros_per_text(&self) -> f64 { duration_micros(self.duration) / usize_to_f64(self.classifications) } } #[derive(Debug, Clone, Eq, PartialEq)] struct EvaluateArgs { test_split: PathBuf, external_data_dir: PathBuf, include_non_commercial: bool, } impl EvaluateArgs { fn from_env() -> Result { let args = std::env::args().skip(1).collect::>(); let external_data_dir = std::env::var(EXTERNAL_DATA_DIR_ENV) .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(DEFAULT_EXTERNAL_DATA_DIR)); let include_non_commercial = env_flag(INCLUDE_NON_COMMERCIAL_ENV); match args.as_slice() { [] => Ok(Self { test_split: PathBuf::from(DEFAULT_TEST_SPLIT), external_data_dir, include_non_commercial, }), [test_split] => Ok(Self { test_split: PathBuf::from(test_split), external_data_dir, include_non_commercial, }), _ => Err(EvaluateCliError::Usage), } } } #[derive(Debug)] enum EvaluateCliError { Usage, ReadFile { path: PathBuf, source: std::io::Error, }, ParseSplit(GoEmotionsError), ParseExternal { path: PathBuf, source: DatasetParseError, }, Model(dadoes::ModelIoError), } impl Display for EvaluateCliError { fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { match self { EvaluateCliError::Usage => write!( formatter, "usage: cargo run --release --bin evaluate -- [test_split]" ), EvaluateCliError::ReadFile { path, source } => { write!(formatter, "failed to read {}: {source}", path.display()) } EvaluateCliError::ParseSplit(error) => { write!(formatter, "failed to parse split: {error}") } EvaluateCliError::ParseExternal { path, source } => { write!( formatter, "failed to parse external dataset {}: {source}", path.display() ) } EvaluateCliError::Model(error) => write!(formatter, "failed to load model: {error}"), } } } impl Error for EvaluateCliError {} impl From for EvaluateCliError { fn from(error: dadoes::ModelIoError) -> Self { EvaluateCliError::Model(error) } } fn ratio(numerator: usize, denominator: usize) -> Option { if denominator == 0 { return None; } Some(usize_to_f32(numerator) / usize_to_f32(denominator)) } fn harmonic_mean(left: Option, right: Option) -> Option { let left = left?; let right = right?; let denominator = left + right; if denominator <= f32::EPSILON { return Some(0.0); } Some(2.0 * left * right / denominator) } fn format_metric(metric: Option) -> String { match metric { Some(value) => format!("{value:.4}"), None => "n/a".to_owned(), } } fn labels_without_supervision(metrics: &[LabelMetrics]) -> Vec<&'static str> { metrics .iter() .filter(|metrics| !metrics.has_supervision()) .map(|metrics| metrics.mood.as_str()) .collect() } fn env_flag(name: &str) -> bool { match std::env::var(name) { Ok(value) => matches!( value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on" ), Err(_) => false, } } fn duration_ms(duration: Duration) -> f64 { duration.as_secs_f64() * 1_000.0 } fn duration_micros(duration: Duration) -> f64 { duration.as_secs_f64() * 1_000_000.0 } fn usize_to_f32(value: usize) -> f32 { value.to_string().parse::().unwrap_or(f32::INFINITY) } fn usize_to_f64(value: usize) -> f64 { value.to_string().parse::().unwrap_or(f64::INFINITY) } type ExternalParser = fn(&str) -> Result, DatasetParseError>; #[derive(Debug, Copy, Clone, Eq, PartialEq)] enum LicenseClass { Public, NonCommercial, } impl LicenseClass { fn allowed_by(self, include_non_commercial: bool) -> bool { match self { LicenseClass::Public => true, LicenseClass::NonCommercial => include_non_commercial, } } } #[derive(Debug, Copy, Clone)] struct ExternalDatasetFile { name: &'static str, license: LicenseClass, relative_path: &'static str, parser: ExternalParser, } #[derive(Debug, Clone)] struct ExternalDatasetSummary { name: &'static str, path: PathBuf, examples: usize, } const EXTERNAL_TEST_DATASET_FILES: &[ExternalDatasetFile] = &[ ExternalDatasetFile { name: "dadoes-domain", license: LicenseClass::Public, relative_path: "dadoes-domain/test.jsonl", parser: parse_dadoes_jsonl, }, ExternalDatasetFile { name: "facebook/empathetic_dialogues", license: LicenseClass::NonCommercial, relative_path: "empathetic_dialogues/test.csv", parser: parse_empathetic_dialogues_csv, }, ExternalDatasetFile { name: "FIG-Loneliness/FIG-Loneliness", license: LicenseClass::NonCommercial, relative_path: "fig_loneliness/test.jsonl", parser: parse_fig_loneliness_jsonl, }, ExternalDatasetFile { name: "yael-katsman/Loneliness-Causes-and-Intensity", license: LicenseClass::Public, relative_path: "loneliness_causes/test_data.csv", parser: parse_loneliness_causes_csv, }, ExternalDatasetFile { name: "Um1neko/text_emotion", license: LicenseClass::Public, relative_path: "um1neko/test.json", parser: parse_um1neko_text_emotion_jsonl, }, ];