| #![deny(missing_docs)] |
| |
| |
| |
| |
|
|
| use std::collections::BTreeMap; |
| use std::collections::hash_map::DefaultHasher; |
| use std::error::Error; |
| use std::fmt::{Display, Formatter}; |
| use std::hash::{Hash, Hasher}; |
| use std::io::{Read, Write}; |
| use std::num::NonZeroUsize; |
|
|
| |
| pub mod api; |
| |
| pub mod datasets; |
| |
| pub mod goemotions; |
| |
| pub mod seed; |
|
|
| pub use api::{DEFAULT_ACTIVE_MOOD_THRESHOLD, DadoesClassifier, default_checkpoint_bytes}; |
| pub use seed::{owned_seed_training_examples, seed_training_examples, train_seed_model}; |
|
|
| |
| #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] |
| pub enum Mood { |
| |
| Happy, |
| |
| Satisfied, |
| |
| Excited, |
| |
| Curious, |
| |
| Anxious, |
| |
| Frustrated, |
| |
| Sad, |
| |
| Angry, |
| |
| Lonely, |
| |
| Bored, |
| |
| Tired, |
| |
| Hopeful, |
| |
| Neutral, |
| } |
|
|
| impl Mood { |
| |
| pub const ALL: [Mood; 13] = [ |
| Mood::Happy, |
| Mood::Satisfied, |
| Mood::Excited, |
| Mood::Curious, |
| Mood::Anxious, |
| Mood::Frustrated, |
| Mood::Sad, |
| Mood::Angry, |
| Mood::Lonely, |
| Mood::Bored, |
| Mood::Tired, |
| Mood::Hopeful, |
| Mood::Neutral, |
| ]; |
|
|
| |
| #[must_use] |
| pub fn as_str(self) -> &'static str { |
| match self { |
| Mood::Happy => "happy", |
| Mood::Satisfied => "satisfied", |
| Mood::Excited => "excited", |
| Mood::Curious => "curious", |
| Mood::Anxious => "anxious", |
| Mood::Frustrated => "frustrated", |
| Mood::Sad => "sad", |
| Mood::Angry => "angry", |
| Mood::Lonely => "lonely", |
| Mood::Bored => "bored", |
| Mood::Tired => "tired", |
| Mood::Hopeful => "hopeful", |
| Mood::Neutral => "neutral", |
| } |
| } |
|
|
| |
| #[must_use] |
| pub fn from_label(label: &str) -> Option<Self> { |
| match label { |
| "happy" => Some(Mood::Happy), |
| "satisfied" => Some(Mood::Satisfied), |
| "excited" => Some(Mood::Excited), |
| "curious" => Some(Mood::Curious), |
| "anxious" => Some(Mood::Anxious), |
| "frustrated" => Some(Mood::Frustrated), |
| "sad" => Some(Mood::Sad), |
| "angry" => Some(Mood::Angry), |
| "lonely" => Some(Mood::Lonely), |
| "bored" => Some(Mood::Bored), |
| "tired" => Some(Mood::Tired), |
| "hopeful" => Some(Mood::Hopeful), |
| "neutral" => Some(Mood::Neutral), |
| _ => None, |
| } |
| } |
|
|
| fn ordinal(self) -> u8 { |
| match self { |
| Mood::Happy => 0, |
| Mood::Satisfied => 1, |
| Mood::Excited => 2, |
| Mood::Curious => 3, |
| Mood::Anxious => 4, |
| Mood::Frustrated => 5, |
| Mood::Sad => 6, |
| Mood::Angry => 7, |
| Mood::Lonely => 8, |
| Mood::Bored => 9, |
| Mood::Tired => 10, |
| Mood::Hopeful => 11, |
| Mood::Neutral => 12, |
| } |
| } |
|
|
| fn from_ordinal(ordinal: u8) -> Option<Self> { |
| match ordinal { |
| 0 => Some(Mood::Happy), |
| 1 => Some(Mood::Satisfied), |
| 2 => Some(Mood::Excited), |
| 3 => Some(Mood::Curious), |
| 4 => Some(Mood::Anxious), |
| 5 => Some(Mood::Frustrated), |
| 6 => Some(Mood::Sad), |
| 7 => Some(Mood::Angry), |
| 8 => Some(Mood::Lonely), |
| 9 => Some(Mood::Bored), |
| 10 => Some(Mood::Tired), |
| 11 => Some(Mood::Hopeful), |
| 12 => Some(Mood::Neutral), |
| _ => None, |
| } |
| } |
| } |
|
|
| |
| #[derive(Debug, Copy, Clone, PartialEq)] |
| pub struct MoodScore { |
| |
| pub mood: Mood, |
| |
| pub score: f32, |
| } |
|
|
| |
| #[derive(Debug, Clone, PartialEq)] |
| pub struct MoodAnalysis { |
| scores: Vec<MoodScore>, |
| } |
|
|
| impl MoodAnalysis { |
| |
| #[must_use] |
| pub fn new(scores: Vec<MoodScore>) -> Self { |
| Self { scores } |
| } |
|
|
| |
| #[must_use] |
| pub fn scores(&self) -> &[MoodScore] { |
| &self.scores |
| } |
|
|
| |
| #[must_use] |
| pub fn primary_mood(&self) -> Option<MoodScore> { |
| self.scores |
| .iter() |
| .copied() |
| .max_by(|left, right| left.score.total_cmp(&right.score)) |
| } |
|
|
| |
| pub fn active_moods(&self, threshold: f32) -> impl Iterator<Item = MoodScore> + '_ { |
| self.scores |
| .iter() |
| .copied() |
| .filter(move |score| score.score >= threshold) |
| } |
| } |
|
|
| |
| pub trait EmotionClassifier { |
| |
| fn classify(&self, text: &str) -> MoodAnalysis; |
| } |
|
|
| |
| #[derive(Debug, Copy, Clone)] |
| pub struct TrainingExample<'a> { |
| |
| pub text: &'a str, |
| |
| |
| |
| |
| pub labels: &'a [Mood], |
| } |
|
|
| impl TrainingExample<'_> { |
| fn has_label(&self, mood: Mood) -> bool { |
| self.labels.iter().copied().any(|label| label == mood) |
| } |
| } |
|
|
| |
| #[derive(Debug, Clone, Eq, PartialEq)] |
| pub struct OwnedTrainingExample { |
| text: String, |
| labels: Vec<Mood>, |
| supervised_labels: Vec<Mood>, |
| } |
|
|
| impl OwnedTrainingExample { |
| |
| |
| |
| pub fn new(text: String, labels: Vec<Mood>) -> Result<Self, ModelError> { |
| Self::new_with_supervised(text, labels, Mood::ALL.to_vec()) |
| } |
|
|
| |
| pub fn new_with_supervised( |
| text: String, |
| labels: Vec<Mood>, |
| supervised_labels: Vec<Mood>, |
| ) -> Result<Self, ModelError> { |
| if text.trim().is_empty() { |
| return Err(ModelError::EmptyExampleText); |
| } |
|
|
| if supervised_labels.is_empty() { |
| return Err(ModelError::EmptySupervisedLabels); |
| } |
|
|
| let mut unique_labels = Vec::new(); |
| for label in labels { |
| if !unique_labels.contains(&label) { |
| unique_labels.push(label); |
| } |
| } |
|
|
| let mut unique_supervised_labels = Vec::new(); |
| for label in supervised_labels { |
| if !unique_supervised_labels.contains(&label) { |
| unique_supervised_labels.push(label); |
| } |
| } |
|
|
| if unique_labels |
| .iter() |
| .any(|label| !unique_supervised_labels.contains(label)) |
| { |
| return Err(ModelError::LabelOutsideSupervision); |
| } |
|
|
| Ok(Self { |
| text, |
| labels: unique_labels, |
| supervised_labels: unique_supervised_labels, |
| }) |
| } |
|
|
| |
| #[must_use] |
| pub fn text(&self) -> &str { |
| &self.text |
| } |
|
|
| |
| |
| |
| |
| #[must_use] |
| pub fn labels(&self) -> &[Mood] { |
| &self.labels |
| } |
|
|
| |
| #[must_use] |
| pub fn supervised_labels(&self) -> &[Mood] { |
| &self.supervised_labels |
| } |
| } |
|
|
| |
| #[derive(Debug, Copy, Clone)] |
| pub struct TrainConfig { |
| |
| pub feature_count: usize, |
| |
| pub epochs: usize, |
| |
| pub learning_rate: f32, |
| |
| pub l2: f32, |
| |
| pub patience: usize, |
| |
| pub min_delta: f32, |
| |
| pub threshold: f32, |
| } |
|
|
| impl Default for TrainConfig { |
| fn default() -> Self { |
| Self { |
| feature_count: 32768, |
| epochs: 40, |
| learning_rate: 0.18, |
| l2: 0.0002, |
| patience: 4, |
| min_delta: 0.0001, |
| threshold: 0.35, |
| } |
| } |
| } |
|
|
| |
| #[derive(Debug, Copy, Clone, Eq, PartialEq)] |
| pub enum ModelError { |
| |
| ZeroFeatureCount, |
| |
| ZeroEpochs, |
| |
| ZeroPatience, |
| |
| EmptyTrainingSet, |
| |
| EmptyValidationSet, |
| |
| EmptyEvaluationSet, |
| |
| EmptyExampleText, |
| |
| EmptyLabels, |
| |
| EmptySupervisedLabels, |
| |
| LabelOutsideSupervision, |
| } |
|
|
| impl Display for ModelError { |
| fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { |
| match self { |
| ModelError::ZeroFeatureCount => formatter.write_str("feature_count must be positive"), |
| ModelError::ZeroEpochs => formatter.write_str("epochs must be positive"), |
| ModelError::ZeroPatience => formatter.write_str("patience must be positive"), |
| ModelError::EmptyTrainingSet => formatter.write_str("training set must not be empty"), |
| ModelError::EmptyValidationSet => { |
| formatter.write_str("validation set must not be empty") |
| } |
| ModelError::EmptyEvaluationSet => { |
| formatter.write_str("evaluation set must not be empty") |
| } |
| ModelError::EmptyExampleText => formatter.write_str("example text must not be empty"), |
| ModelError::EmptyLabels => formatter.write_str("example labels must not be empty"), |
| ModelError::EmptySupervisedLabels => { |
| formatter.write_str("example supervised labels must not be empty") |
| } |
| ModelError::LabelOutsideSupervision => { |
| formatter.write_str("example label is outside supervised label set") |
| } |
| } |
| } |
| } |
|
|
| impl Error for ModelError {} |
|
|
| |
| #[derive(Debug, Clone, PartialEq)] |
| pub struct FeatureVector { |
| active: Vec<FeatureValue>, |
| } |
|
|
| impl FeatureVector { |
| |
| #[must_use] |
| pub fn active_values(&self) -> &[FeatureValue] { |
| &self.active |
| } |
| } |
|
|
| |
| #[derive(Debug, Copy, Clone, PartialEq)] |
| pub struct FeatureValue { |
| |
| pub index: usize, |
| |
| pub value: f32, |
| } |
|
|
| |
| #[derive(Debug, Copy, Clone, Eq, PartialEq)] |
| pub struct FeatureHasher { |
| dimension: NonZeroUsize, |
| } |
|
|
| impl FeatureHasher { |
| |
| |
| |
| pub fn new(dimension: usize) -> Result<Self, ModelError> { |
| let Some(dimension) = NonZeroUsize::new(dimension) else { |
| return Err(ModelError::ZeroFeatureCount); |
| }; |
|
|
| Ok(Self { dimension }) |
| } |
|
|
| |
| #[must_use] |
| pub fn encode(&self, text: &str) -> FeatureVector { |
| let mut values = BTreeMap::new(); |
| let tokens = tokenize(text); |
| let mut previous: Option<&str> = None; |
|
|
| for token in &tokens { |
| self.add_feature(token, &mut values); |
| if previous.is_some_and(is_negator) { |
| let negated_feature = format!("negated_{token}"); |
| self.add_feature(&negated_feature, &mut values); |
| } |
| previous = Some(token); |
| } |
|
|
| FeatureVector { |
| active: normalized_sparse_values(values), |
| } |
| } |
|
|
| fn add_feature(&self, feature: &str, values: &mut BTreeMap<usize, f32>) { |
| let Some(index) = self.feature_index(feature) else { |
| return; |
| }; |
|
|
| let value = values.entry(index).or_insert(0.0); |
| *value += 1.0; |
| } |
|
|
| fn feature_index(&self, feature: &str) -> Option<usize> { |
| let mut hasher = DefaultHasher::new(); |
| feature.hash(&mut hasher); |
| let dimension = u64::try_from(self.dimension.get()).ok()?; |
| let index = hasher.finish() % dimension; |
| usize::try_from(index).ok() |
| } |
|
|
| fn dimension(&self) -> usize { |
| self.dimension.get() |
| } |
| } |
|
|
| |
| #[derive(Debug, Clone, PartialEq)] |
| pub struct LinearMoodModel { |
| hasher: FeatureHasher, |
| rows: Vec<LabelWeights>, |
| } |
|
|
| impl LinearMoodModel { |
| |
| pub fn train( |
| examples: &[TrainingExample<'_>], |
| config: TrainConfig, |
| ) -> Result<Self, ModelError> { |
| if examples.is_empty() { |
| return Err(ModelError::EmptyTrainingSet); |
| } |
| if config.epochs == 0 { |
| return Err(ModelError::ZeroEpochs); |
| } |
|
|
| let hasher = FeatureHasher::new(config.feature_count)?; |
| let mut rows = initial_rows(config.feature_count); |
|
|
| for _ in 0..config.epochs { |
| for example in examples { |
| let features = hasher.encode(example.text); |
| update_rows( |
| &mut rows, |
| &features, |
| example, |
| config.learning_rate, |
| config.l2, |
| ); |
| } |
| } |
|
|
| Ok(Self { hasher, rows }) |
| } |
|
|
| |
| pub fn train_with_validation( |
| train_examples: &[OwnedTrainingExample], |
| validation_examples: &[OwnedTrainingExample], |
| config: TrainConfig, |
| ) -> Result<TrainingOutcome, ModelError> { |
| if train_examples.is_empty() { |
| return Err(ModelError::EmptyTrainingSet); |
| } |
| if validation_examples.is_empty() { |
| return Err(ModelError::EmptyValidationSet); |
| } |
| if config.epochs == 0 { |
| return Err(ModelError::ZeroEpochs); |
| } |
| if config.patience == 0 { |
| return Err(ModelError::ZeroPatience); |
| } |
|
|
| let hasher = FeatureHasher::new(config.feature_count)?; |
| let mut model = Self { |
| hasher, |
| rows: initial_rows(config.feature_count), |
| }; |
| let mut best_rows = model.rows.clone(); |
| let mut best_validation_loss = f32::INFINITY; |
| let mut best_epoch = 0; |
| let mut epochs_trained = 0; |
| let mut stale_epochs = 0; |
|
|
| for epoch in 1..=config.epochs { |
| for example in train_examples { |
| let features = model.hasher.encode(example.text()); |
| update_rows_owned( |
| &mut model.rows, |
| &features, |
| example, |
| config.learning_rate, |
| config.l2, |
| ); |
| } |
|
|
| epochs_trained = epoch; |
| let metrics = model.evaluate(validation_examples, config.threshold)?; |
| if improved_validation_loss(metrics.loss, best_validation_loss, config.min_delta) { |
| best_validation_loss = metrics.loss; |
| best_epoch = epoch; |
| best_rows = model.rows.clone(); |
| stale_epochs = 0; |
| } else { |
| stale_epochs += 1; |
| if stale_epochs >= config.patience { |
| break; |
| } |
| } |
| } |
|
|
| model.rows = best_rows; |
| let validation = model.evaluate(validation_examples, config.threshold)?; |
| Ok(TrainingOutcome { |
| model, |
| epochs_trained, |
| best_epoch, |
| best_validation_loss, |
| validation, |
| }) |
| } |
|
|
| |
| pub fn evaluate( |
| &self, |
| examples: &[OwnedTrainingExample], |
| threshold: f32, |
| ) -> Result<EvaluationMetrics, ModelError> { |
| if examples.is_empty() { |
| return Err(ModelError::EmptyEvaluationSet); |
| } |
|
|
| let mut loss = 0.0; |
| let mut true_positive = 0_usize; |
| let mut false_positive = 0_usize; |
| let mut false_negative = 0_usize; |
| let mut exact_matches = 0_usize; |
| let mut supervised_label_count = 0_usize; |
|
|
| for example in examples { |
| let analysis = self.classify(example.text()); |
| let mut exact_match = true; |
|
|
| for score in analysis.scores() { |
| if !example.supervised_labels().contains(&score.mood) { |
| continue; |
| } |
|
|
| supervised_label_count += 1; |
| let target = example |
| .labels() |
| .iter() |
| .copied() |
| .any(|label| label == score.mood); |
| loss += binary_cross_entropy(score.score, target); |
| let predicted = score.score >= threshold; |
|
|
| match (predicted, target) { |
| (true, true) => true_positive += 1, |
| (true, false) => { |
| false_positive += 1; |
| exact_match = false; |
| } |
| (false, true) => { |
| false_negative += 1; |
| exact_match = false; |
| } |
| (false, false) => {} |
| } |
| } |
|
|
| if exact_match { |
| exact_matches += 1; |
| } |
| } |
|
|
| let average_loss = loss / usize_to_f32(supervised_label_count); |
| let precision = ratio(true_positive, true_positive + false_positive); |
| let recall = ratio(true_positive, true_positive + false_negative); |
| let micro_f1 = harmonic_mean(precision, recall); |
| let exact_match = ratio(exact_matches, examples.len()); |
|
|
| Ok(EvaluationMetrics { |
| examples: examples.len(), |
| loss: average_loss, |
| micro_precision: precision, |
| micro_recall: recall, |
| micro_f1, |
| exact_match, |
| }) |
| } |
|
|
| |
| pub fn save_to_writer<W: Write>(&self, writer: &mut W) -> Result<(), ModelIoError> { |
| writer.write_all(MODEL_MAGIC)?; |
| write_u64(writer, self.hasher.dimension())?; |
| write_u64(writer, self.rows.len())?; |
|
|
| for row in &self.rows { |
| writer.write_all(&[row.mood.ordinal()])?; |
| writer.write_all(&row.bias.to_le_bytes())?; |
| for weight in &row.weights { |
| writer.write_all(&weight.to_le_bytes())?; |
| } |
| } |
|
|
| Ok(()) |
| } |
|
|
| |
| pub fn load_from_reader<R: Read>(reader: &mut R) -> Result<Self, ModelIoError> { |
| let mut magic = [0_u8; MODEL_MAGIC_LEN]; |
| reader.read_exact(&mut magic)?; |
| if magic != *MODEL_MAGIC { |
| return Err(ModelIoError::InvalidMagic); |
| } |
|
|
| let feature_count = read_usize(reader)?; |
| let row_count = read_usize(reader)?; |
| let hasher = FeatureHasher::new(feature_count)?; |
| let mut rows = Vec::with_capacity(row_count); |
|
|
| for _ in 0..row_count { |
| let mut mood = [0_u8; 1]; |
| reader.read_exact(&mut mood)?; |
| let Some(mood) = Mood::from_ordinal(mood[0]) else { |
| return Err(ModelIoError::UnknownMoodOrdinal(mood[0])); |
| }; |
| let bias = read_f32(reader)?; |
| let mut weights = Vec::with_capacity(feature_count); |
| for _ in 0..feature_count { |
| weights.push(read_f32(reader)?); |
| } |
|
|
| rows.push(LabelWeights { |
| mood, |
| bias, |
| weights, |
| }); |
| } |
|
|
| Ok(Self { hasher, rows }) |
| } |
| } |
|
|
| impl EmotionClassifier for LinearMoodModel { |
| fn classify(&self, text: &str) -> MoodAnalysis { |
| let features = self.hasher.encode(text); |
| let scores = self |
| .rows |
| .iter() |
| .map(|row| MoodScore { |
| mood: row.mood, |
| score: sigmoid(row.logit(&features)), |
| }) |
| .collect(); |
|
|
| MoodAnalysis::new(scores) |
| } |
| } |
|
|
| #[derive(Debug, Clone, PartialEq)] |
| struct LabelWeights { |
| mood: Mood, |
| bias: f32, |
| weights: Vec<f32>, |
| } |
|
|
| impl LabelWeights { |
| fn logit(&self, features: &FeatureVector) -> f32 { |
| let weighted_sum = features |
| .active_values() |
| .iter() |
| .filter_map(|feature| { |
| self.weights |
| .get(feature.index) |
| .map(|weight| weight * feature.value) |
| }) |
| .sum::<f32>(); |
|
|
| weighted_sum + self.bias |
| } |
| } |
|
|
| fn initial_rows(feature_count: usize) -> Vec<LabelWeights> { |
| Mood::ALL |
| .iter() |
| .copied() |
| .map(|mood| LabelWeights { |
| mood, |
| bias: 0.0, |
| weights: vec![0.0; feature_count], |
| }) |
| .collect() |
| } |
|
|
| fn update_rows( |
| rows: &mut [LabelWeights], |
| features: &FeatureVector, |
| example: &TrainingExample<'_>, |
| learning_rate: f32, |
| l2: f32, |
| ) { |
| for row in rows { |
| let target = if example.has_label(row.mood) { |
| 1.0 |
| } else { |
| 0.0 |
| }; |
| let prediction = sigmoid(row.logit(features)); |
| let gradient = prediction - target; |
|
|
| for feature in features.active_values() { |
| if let Some(weight) = row.weights.get_mut(feature.index) { |
| let regularized_gradient = gradient * feature.value + l2 * *weight; |
| *weight -= learning_rate * regularized_gradient; |
| } |
| } |
|
|
| row.bias -= learning_rate * gradient; |
| } |
| } |
|
|
| fn update_rows_owned( |
| rows: &mut [LabelWeights], |
| features: &FeatureVector, |
| example: &OwnedTrainingExample, |
| learning_rate: f32, |
| l2: f32, |
| ) { |
| for row in rows { |
| if !example.supervised_labels().contains(&row.mood) { |
| continue; |
| } |
|
|
| let target = example |
| .labels() |
| .iter() |
| .copied() |
| .any(|label| label == row.mood); |
| let prediction = sigmoid(row.logit(features)); |
| let gradient = prediction - if target { 1.0 } else { 0.0 }; |
|
|
| for feature in features.active_values() { |
| if let Some(weight) = row.weights.get_mut(feature.index) { |
| let regularized_gradient = gradient * feature.value + l2 * *weight; |
| *weight -= learning_rate * regularized_gradient; |
| } |
| } |
|
|
| row.bias -= learning_rate * gradient; |
| } |
| } |
|
|
| fn tokenize(text: &str) -> Vec<String> { |
| let mut tokens = Vec::new(); |
| let mut current = String::new(); |
|
|
| for character in text.chars() { |
| if character.is_alphanumeric() { |
| current.extend(character.to_lowercase()); |
| } else { |
| finish_token(&mut tokens, &mut current); |
| } |
| } |
|
|
| finish_token(&mut tokens, &mut current); |
| tokens |
| } |
|
|
| fn finish_token(tokens: &mut Vec<String>, current: &mut String) { |
| if current.is_empty() { |
| return; |
| } |
|
|
| tokens.push(std::mem::take(current)); |
| } |
|
|
| fn normalized_sparse_values(values: BTreeMap<usize, f32>) -> Vec<FeatureValue> { |
| let norm = values |
| .values() |
| .map(|value| value * value) |
| .sum::<f32>() |
| .sqrt(); |
| if norm <= f32::EPSILON { |
| return Vec::new(); |
| } |
|
|
| values |
| .into_iter() |
| .map(|(index, value)| FeatureValue { |
| index, |
| value: value / norm, |
| }) |
| .collect() |
| } |
|
|
| fn is_negator(token: &str) -> bool { |
| matches!(token, "not" | "never" | "no" | "hardly" | "barely") |
| } |
|
|
| fn sigmoid(value: f32) -> f32 { |
| if value >= 0.0 { |
| 1.0 / (1.0 + (-value).exp()) |
| } else { |
| let exp = value.exp(); |
| exp / (1.0 + exp) |
| } |
| } |
|
|
| |
| #[derive(Debug, Clone, PartialEq)] |
| pub struct TrainingOutcome { |
| |
| pub model: LinearMoodModel, |
| |
| pub epochs_trained: usize, |
| |
| pub best_epoch: usize, |
| |
| pub best_validation_loss: f32, |
| |
| pub validation: EvaluationMetrics, |
| } |
|
|
| |
| #[derive(Debug, Copy, Clone, PartialEq)] |
| pub struct EvaluationMetrics { |
| |
| pub examples: usize, |
| |
| pub loss: f32, |
| |
| pub micro_precision: f32, |
| |
| pub micro_recall: f32, |
| |
| pub micro_f1: f32, |
| |
| pub exact_match: f32, |
| } |
|
|
| |
| #[derive(Debug)] |
| pub enum ModelIoError { |
| |
| Io(std::io::Error), |
| |
| InvalidMagic, |
| |
| CountTooLarge(u64), |
| |
| UnknownMoodOrdinal(u8), |
| |
| Model(ModelError), |
| } |
|
|
| impl Display for ModelIoError { |
| fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { |
| match self { |
| ModelIoError::Io(error) => write!(formatter, "checkpoint IO failed: {error}"), |
| ModelIoError::InvalidMagic => formatter.write_str("invalid DADOES checkpoint header"), |
| ModelIoError::CountTooLarge(count) => { |
| write!(formatter, "checkpoint count does not fit in usize: {count}") |
| } |
| ModelIoError::UnknownMoodOrdinal(ordinal) => { |
| write!(formatter, "unknown mood ordinal in checkpoint: {ordinal}") |
| } |
| ModelIoError::Model(error) => write!(formatter, "invalid checkpoint model: {error}"), |
| } |
| } |
| } |
|
|
| impl Error for ModelIoError {} |
|
|
| impl From<std::io::Error> for ModelIoError { |
| fn from(error: std::io::Error) -> Self { |
| ModelIoError::Io(error) |
| } |
| } |
|
|
| impl From<ModelError> for ModelIoError { |
| fn from(error: ModelError) -> Self { |
| ModelIoError::Model(error) |
| } |
| } |
|
|
| const MODEL_MAGIC: &[u8; MODEL_MAGIC_LEN] = b"DADOES_LINEAR_V1"; |
| const MODEL_MAGIC_LEN: usize = 16; |
|
|
| fn improved_validation_loss(candidate: f32, best: f32, min_delta: f32) -> bool { |
| candidate + min_delta < best |
| } |
|
|
| fn binary_cross_entropy(score: f32, target: bool) -> f32 { |
| let clamped = score.clamp(0.000001, 0.999999); |
| if target { |
| -clamped.ln() |
| } else { |
| -(1.0 - clamped).ln() |
| } |
| } |
|
|
| fn ratio(numerator: usize, denominator: usize) -> f32 { |
| if denominator == 0 { |
| return 0.0; |
| } |
|
|
| usize_to_f32(numerator) / usize_to_f32(denominator) |
| } |
|
|
| fn harmonic_mean(left: f32, right: f32) -> f32 { |
| let denominator = left + right; |
| if denominator <= f32::EPSILON { |
| return 0.0; |
| } |
|
|
| 2.0 * left * right / denominator |
| } |
|
|
| fn write_u64<W: Write>(writer: &mut W, value: usize) -> Result<(), ModelIoError> { |
| let value = u64::try_from(value).map_err(|_| ModelIoError::CountTooLarge(u64::MAX))?; |
| writer.write_all(&value.to_le_bytes())?; |
| Ok(()) |
| } |
|
|
| fn read_usize<R: Read>(reader: &mut R) -> Result<usize, ModelIoError> { |
| let mut bytes = [0_u8; 8]; |
| reader.read_exact(&mut bytes)?; |
| let value = u64::from_le_bytes(bytes); |
| usize::try_from(value).map_err(|_| ModelIoError::CountTooLarge(value)) |
| } |
|
|
| fn read_f32<R: Read>(reader: &mut R) -> Result<f32, ModelIoError> { |
| let mut bytes = [0_u8; 4]; |
| reader.read_exact(&mut bytes)?; |
| Ok(f32::from_le_bytes(bytes)) |
| } |
|
|
| fn usize_to_f32(value: usize) -> f32 { |
| value.to_string().parse::<f32>().unwrap_or(f32::INFINITY) |
| } |
|
|