| #![deny(missing_docs)] |
| |
|
|
| use std::error::Error; |
| use std::fmt::{Display, Formatter}; |
| use std::fs::{self, File}; |
| use std::path::{Path, PathBuf}; |
| use std::process::ExitCode; |
|
|
| use dadoes::datasets::{ |
| DatasetParseError, parse_dadoes_jsonl, parse_empathetic_dialogues_csv, |
| parse_fig_loneliness_jsonl, parse_loneliness_causes_csv, parse_prajwal_text_emotion_csv, |
| parse_text_emotion_csv, parse_um1neko_text_emotion_jsonl, |
| }; |
| use dadoes::goemotions::{GoEmotionsError, parse_split}; |
| use dadoes::{ |
| EvaluationMetrics, LinearMoodModel, ModelError, ModelIoError, OwnedTrainingExample, |
| TrainConfig, owned_seed_training_examples, |
| }; |
|
|
| const DEFAULT_DATA_DIR: &str = "data/raw/goemotions"; |
| const DEFAULT_EXTERNAL_DATA_DIR: &str = "data/raw/external"; |
| const DEFAULT_MODEL_PATH: &str = "models/goemotions-linear.dadoes"; |
| const DEFAULT_REPORT_PATH: &str = "models/goemotions-linear.report.json"; |
| const EXTERNAL_DATA_DIR_ENV: &str = "DADOES_EXTERNAL_DATA_DIR"; |
| const INCLUDE_NON_COMMERCIAL_ENV: &str = "DADOES_INCLUDE_NON_COMMERCIAL"; |
| const SEED_AUGMENTATION_REPEATS: usize = 4; |
|
|
| |
| fn main() -> ExitCode { |
| match run() { |
| Ok(()) => ExitCode::SUCCESS, |
| Err(error) => { |
| eprintln!("{error}"); |
| ExitCode::from(1) |
| } |
| } |
| } |
|
|
| fn run() -> Result<(), TrainCliError> { |
| let args = TrainArgs::from_env()?; |
| let mut train = load_split(&args.data_dir, "train.tsv")?; |
| let dev = load_split(&args.data_dir, "dev.tsv")?; |
| let test = load_split(&args.data_dir, "test.tsv")?; |
| let mut dev = dev; |
| let mut test = test; |
| let external = load_external_dataset_mix( |
| &args.external_data_dir, |
| args.include_non_commercial, |
| &mut train, |
| &mut dev, |
| &mut test, |
| )?; |
| let seed_examples = owned_seed_training_examples()?; |
| let seed_count = seed_examples.len(); |
| for _ in 0..SEED_AUGMENTATION_REPEATS { |
| train.extend(seed_examples.iter().cloned()); |
| } |
| let config = TrainConfig::default(); |
|
|
| println!( |
| "loaded splits: train={} dev={} test={} seed_examples={} seed_repeats={}", |
| train.len(), |
| dev.len(), |
| test.len(), |
| seed_count, |
| SEED_AUGMENTATION_REPEATS |
| ); |
| println!( |
| "external_data_dir={} include_non_commercial={}", |
| args.external_data_dir.display(), |
| args.include_non_commercial |
| ); |
| if external.is_empty() { |
| println!("external datasets: no optional files found"); |
| } else { |
| println!("external datasets:"); |
| for summary in &external { |
| println!( |
| "- {} {} examples={} path={}", |
| summary.name, |
| summary.split.as_str(), |
| summary.examples, |
| summary.path.display() |
| ); |
| } |
| } |
| println!( |
| "training: features={} epochs={} lr={} l2={} patience={} threshold={}", |
| config.feature_count, |
| config.epochs, |
| config.learning_rate, |
| config.l2, |
| config.patience, |
| config.threshold |
| ); |
|
|
| let outcome = LinearMoodModel::train_with_validation(&train, &dev, config)?; |
| let test_metrics = outcome.model.evaluate(&test, config.threshold)?; |
| create_parent_dir(&args.model_path)?; |
| let mut model_file = |
| File::create(&args.model_path).map_err(|source| TrainCliError::WriteFile { |
| path: args.model_path.clone(), |
| source, |
| })?; |
| outcome.model.save_to_writer(&mut model_file)?; |
|
|
| let report = report_json( |
| &outcome.validation, |
| &test_metrics, |
| outcome.epochs_trained, |
| outcome.best_epoch, |
| outcome.best_validation_loss, |
| ); |
| create_parent_dir(&args.report_path)?; |
| fs::write(&args.report_path, report).map_err(|source| TrainCliError::WriteFile { |
| path: args.report_path.clone(), |
| source, |
| })?; |
|
|
| println!( |
| "best_epoch={} epochs_trained={} dev_loss={:.6} dev_micro_f1={:.4}", |
| outcome.best_epoch, |
| outcome.epochs_trained, |
| outcome.validation.loss, |
| outcome.validation.micro_f1 |
| ); |
| println!( |
| "test_loss={:.6} test_micro_f1={:.4} test_exact_match={:.4}", |
| test_metrics.loss, test_metrics.micro_f1, test_metrics.exact_match |
| ); |
| println!("saved model: {}", args.model_path.display()); |
| println!("saved report: {}", args.report_path.display()); |
|
|
| Ok(()) |
| } |
|
|
| fn load_external_dataset_mix( |
| root: &Path, |
| include_non_commercial: bool, |
| train: &mut Vec<OwnedTrainingExample>, |
| dev: &mut Vec<OwnedTrainingExample>, |
| test: &mut Vec<OwnedTrainingExample>, |
| ) -> Result<Vec<ExternalDatasetSummary>, TrainCliError> { |
| let mut summaries = Vec::new(); |
| for file in EXTERNAL_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| TrainCliError::ReadFile { |
| path: path.clone(), |
| source, |
| })?; |
| let examples = (file.parser)(&input).map_err(|source| TrainCliError::ParseExternal { |
| path: path.clone(), |
| source, |
| })?; |
| let examples_count = examples.len(); |
| match file.split { |
| DatasetSplit::Train => train.extend(examples), |
| DatasetSplit::Dev => dev.extend(examples), |
| DatasetSplit::Test => test.extend(examples), |
| } |
| summaries.push(ExternalDatasetSummary { |
| name: file.name, |
| split: file.split, |
| path, |
| examples: examples_count, |
| }); |
| } |
|
|
| Ok(summaries) |
| } |
|
|
| fn load_split( |
| data_dir: &Path, |
| file_name: &'static str, |
| ) -> Result<Vec<OwnedTrainingExample>, TrainCliError> { |
| let path = data_dir.join(file_name); |
| let input = fs::read_to_string(&path).map_err(|source| TrainCliError::ReadFile { |
| path: path.clone(), |
| source, |
| })?; |
| parse_split(&input).map_err(|source| TrainCliError::ParseSplit { |
| split: file_name, |
| source, |
| }) |
| } |
|
|
| fn create_parent_dir(path: &Path) -> Result<(), TrainCliError> { |
| let Some(parent) = path.parent() else { |
| return Ok(()); |
| }; |
| if parent.as_os_str().is_empty() { |
| return Ok(()); |
| } |
|
|
| fs::create_dir_all(parent).map_err(|source| TrainCliError::WriteFile { |
| path: parent.to_path_buf(), |
| source, |
| }) |
| } |
|
|
| fn report_json( |
| validation: &EvaluationMetrics, |
| test: &EvaluationMetrics, |
| epochs_trained: usize, |
| best_epoch: usize, |
| best_validation_loss: f32, |
| ) -> String { |
| format!( |
| concat!( |
| "{{\n", |
| " \"epochs_trained\": {},\n", |
| " \"best_epoch\": {},\n", |
| " \"best_validation_loss\": {:.6},\n", |
| " \"validation\": {},\n", |
| " \"test\": {}\n", |
| "}}\n" |
| ), |
| epochs_trained, |
| best_epoch, |
| best_validation_loss, |
| metrics_json(validation), |
| metrics_json(test) |
| ) |
| } |
|
|
| fn metrics_json(metrics: &EvaluationMetrics) -> String { |
| format!( |
| concat!( |
| "{{", |
| "\"examples\":{},", |
| "\"loss\":{:.6},", |
| "\"micro_precision\":{:.6},", |
| "\"micro_recall\":{:.6},", |
| "\"micro_f1\":{:.6},", |
| "\"exact_match\":{:.6}", |
| "}}" |
| ), |
| metrics.examples, |
| metrics.loss, |
| metrics.micro_precision, |
| metrics.micro_recall, |
| metrics.micro_f1, |
| metrics.exact_match |
| ) |
| } |
|
|
| #[derive(Debug, Clone, Eq, PartialEq)] |
| struct TrainArgs { |
| data_dir: PathBuf, |
| external_data_dir: PathBuf, |
| include_non_commercial: bool, |
| model_path: PathBuf, |
| report_path: PathBuf, |
| } |
|
|
| impl TrainArgs { |
| fn from_env() -> Result<Self, TrainCliError> { |
| let args: Vec<String> = 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 { |
| data_dir: PathBuf::from(DEFAULT_DATA_DIR), |
| external_data_dir, |
| include_non_commercial, |
| model_path: PathBuf::from(DEFAULT_MODEL_PATH), |
| report_path: PathBuf::from(DEFAULT_REPORT_PATH), |
| }), |
| [data_dir, model_path] => Ok(Self { |
| data_dir: PathBuf::from(data_dir), |
| external_data_dir, |
| include_non_commercial, |
| model_path: PathBuf::from(model_path), |
| report_path: PathBuf::from(DEFAULT_REPORT_PATH), |
| }), |
| [data_dir, model_path, report_path] => Ok(Self { |
| data_dir: PathBuf::from(data_dir), |
| external_data_dir, |
| include_non_commercial, |
| model_path: PathBuf::from(model_path), |
| report_path: PathBuf::from(report_path), |
| }), |
| _ => Err(TrainCliError::Usage), |
| } |
| } |
| } |
|
|
| 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, |
| } |
| } |
|
|
| type ExternalParser = fn(&str) -> Result<Vec<OwnedTrainingExample>, DatasetParseError>; |
|
|
| #[derive(Debug, Copy, Clone, Eq, PartialEq)] |
| enum DatasetSplit { |
| Train, |
| Dev, |
| Test, |
| } |
|
|
| impl DatasetSplit { |
| fn as_str(self) -> &'static str { |
| match self { |
| DatasetSplit::Train => "train", |
| DatasetSplit::Dev => "dev", |
| DatasetSplit::Test => "test", |
| } |
| } |
| } |
|
|
| #[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, |
| split: DatasetSplit, |
| relative_path: &'static str, |
| parser: ExternalParser, |
| } |
|
|
| #[derive(Debug, Clone)] |
| struct ExternalDatasetSummary { |
| name: &'static str, |
| split: DatasetSplit, |
| path: PathBuf, |
| examples: usize, |
| } |
|
|
| const EXTERNAL_DATASET_FILES: &[ExternalDatasetFile] = &[ |
| ExternalDatasetFile { |
| name: "dadoes-domain", |
| license: LicenseClass::Public, |
| split: DatasetSplit::Train, |
| relative_path: "dadoes-domain/train.jsonl", |
| parser: parse_dadoes_jsonl, |
| }, |
| ExternalDatasetFile { |
| name: "dadoes-domain", |
| license: LicenseClass::Public, |
| split: DatasetSplit::Dev, |
| relative_path: "dadoes-domain/dev.jsonl", |
| parser: parse_dadoes_jsonl, |
| }, |
| ExternalDatasetFile { |
| name: "dadoes-domain", |
| license: LicenseClass::Public, |
| split: DatasetSplit::Test, |
| relative_path: "dadoes-domain/test.jsonl", |
| parser: parse_dadoes_jsonl, |
| }, |
| ExternalDatasetFile { |
| name: "facebook/empathetic_dialogues", |
| license: LicenseClass::NonCommercial, |
| split: DatasetSplit::Train, |
| relative_path: "empathetic_dialogues/train.csv", |
| parser: parse_empathetic_dialogues_csv, |
| }, |
| ExternalDatasetFile { |
| name: "facebook/empathetic_dialogues", |
| license: LicenseClass::NonCommercial, |
| split: DatasetSplit::Dev, |
| relative_path: "empathetic_dialogues/validation.csv", |
| parser: parse_empathetic_dialogues_csv, |
| }, |
| ExternalDatasetFile { |
| name: "facebook/empathetic_dialogues", |
| license: LicenseClass::NonCommercial, |
| split: DatasetSplit::Test, |
| relative_path: "empathetic_dialogues/test.csv", |
| parser: parse_empathetic_dialogues_csv, |
| }, |
| ExternalDatasetFile { |
| name: "FIG-Loneliness/FIG-Loneliness", |
| license: LicenseClass::NonCommercial, |
| split: DatasetSplit::Train, |
| relative_path: "fig_loneliness/train.jsonl", |
| parser: parse_fig_loneliness_jsonl, |
| }, |
| ExternalDatasetFile { |
| name: "FIG-Loneliness/FIG-Loneliness", |
| license: LicenseClass::NonCommercial, |
| split: DatasetSplit::Dev, |
| relative_path: "fig_loneliness/dev.jsonl", |
| parser: parse_fig_loneliness_jsonl, |
| }, |
| ExternalDatasetFile { |
| name: "FIG-Loneliness/FIG-Loneliness", |
| license: LicenseClass::NonCommercial, |
| split: DatasetSplit::Test, |
| relative_path: "fig_loneliness/test.jsonl", |
| parser: parse_fig_loneliness_jsonl, |
| }, |
| ExternalDatasetFile { |
| name: "yael-katsman/Loneliness-Causes-and-Intensity", |
| license: LicenseClass::Public, |
| split: DatasetSplit::Train, |
| relative_path: "loneliness_causes/train_data.csv", |
| parser: parse_loneliness_causes_csv, |
| }, |
| ExternalDatasetFile { |
| name: "yael-katsman/Loneliness-Causes-and-Intensity", |
| license: LicenseClass::Public, |
| split: DatasetSplit::Dev, |
| relative_path: "loneliness_causes/validation_data.csv", |
| parser: parse_loneliness_causes_csv, |
| }, |
| ExternalDatasetFile { |
| name: "yael-katsman/Loneliness-Causes-and-Intensity", |
| license: LicenseClass::Public, |
| split: DatasetSplit::Test, |
| relative_path: "loneliness_causes/test_data.csv", |
| parser: parse_loneliness_causes_csv, |
| }, |
| ExternalDatasetFile { |
| name: "smit18/text_emotion", |
| license: LicenseClass::Public, |
| split: DatasetSplit::Train, |
| relative_path: "text_emotion/text_emotion.csv", |
| parser: parse_text_emotion_csv, |
| }, |
| ExternalDatasetFile { |
| name: "PrajwalNayaka/Text-Emotion", |
| license: LicenseClass::Public, |
| split: DatasetSplit::Train, |
| relative_path: "prajwal_text_emotion/final_dataset.csv", |
| parser: parse_prajwal_text_emotion_csv, |
| }, |
| ExternalDatasetFile { |
| name: "Um1neko/text_emotion", |
| license: LicenseClass::Public, |
| split: DatasetSplit::Train, |
| relative_path: "um1neko/train.json", |
| parser: parse_um1neko_text_emotion_jsonl, |
| }, |
| ExternalDatasetFile { |
| name: "Um1neko/text_emotion", |
| license: LicenseClass::Public, |
| split: DatasetSplit::Dev, |
| relative_path: "um1neko/dev.json", |
| parser: parse_um1neko_text_emotion_jsonl, |
| }, |
| ExternalDatasetFile { |
| name: "Um1neko/text_emotion", |
| license: LicenseClass::Public, |
| split: DatasetSplit::Test, |
| relative_path: "um1neko/test.json", |
| parser: parse_um1neko_text_emotion_jsonl, |
| }, |
| ]; |
|
|
| #[derive(Debug)] |
| enum TrainCliError { |
| Usage, |
| ReadFile { |
| path: PathBuf, |
| source: std::io::Error, |
| }, |
| WriteFile { |
| path: PathBuf, |
| source: std::io::Error, |
| }, |
| ParseSplit { |
| split: &'static str, |
| source: GoEmotionsError, |
| }, |
| ParseExternal { |
| path: PathBuf, |
| source: DatasetParseError, |
| }, |
| Model(ModelError), |
| Checkpoint(ModelIoError), |
| } |
|
|
| impl Display for TrainCliError { |
| fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { |
| match self { |
| TrainCliError::Usage => write!( |
| formatter, |
| "usage: cargo run --release --bin train -- [data_dir model_path [report_path]]" |
| ), |
| TrainCliError::ReadFile { path, source } => { |
| write!(formatter, "failed to read {}: {source}", path.display()) |
| } |
| TrainCliError::WriteFile { path, source } => { |
| write!(formatter, "failed to write {}: {source}", path.display()) |
| } |
| TrainCliError::ParseSplit { split, source } => { |
| write!(formatter, "failed to parse {split}: {source}") |
| } |
| TrainCliError::ParseExternal { path, source } => { |
| write!( |
| formatter, |
| "failed to parse external dataset {}: {source}", |
| path.display() |
| ) |
| } |
| TrainCliError::Model(error) => write!(formatter, "training failed: {error}"), |
| TrainCliError::Checkpoint(error) => write!(formatter, "checkpoint failed: {error}"), |
| } |
| } |
| } |
|
|
| impl Error for TrainCliError {} |
|
|
| impl From<ModelError> for TrainCliError { |
| fn from(error: ModelError) -> Self { |
| TrainCliError::Model(error) |
| } |
| } |
|
|
| impl From<ModelIoError> for TrainCliError { |
| fn from(error: ModelIoError) -> Self { |
| TrainCliError::Checkpoint(error) |
| } |
| } |
|
|