use std::collections::HashSet;
use std::path::Path;
use anyhow::{Context, Result};
use candle_core::{DType, Device, Tensor};
use candle_nn::{linear, Linear, Module, Optimizer, VarBuilder, VarMap};
use crate::sense::{EMBED_DIM, N_ENTITY};
use crate::training::{
compute_accuracy, shuffled_batches, vec2_to_tensor, CosineScheduler, EarlyStopping,
EpochMetrics, TrainingSummary,
};
pub const N_STAT_FEATURES: usize = 44;
pub const INPUT_DIM: usize = 2 * EMBED_DIM + N_STAT_FEATURES;
pub const HIDDEN_DIM: usize = 256;
pub const DROPOUT_RATE: f64 = 0.2;
pub const ENTITY_LABELS: [&str; N_ENTITY] = ["person", "place", "organization", "creative_work"];
pub const STAT_FEATURE_NAMES: [&str; N_STAT_FEATURES] = [
"mean_len",
"std_len",
"median_len",
"p25_len",
"p75_len",
"mean_words",
"std_words",
"single_word_ratio",
"two_word_ratio",
"three_plus_ratio",
"mean_alpha_ratio",
"mean_digit_ratio",
"mean_space_ratio",
"mean_punct_ratio",
"has_digits_ratio",
"title_case_ratio",
"all_caps_ratio",
"has_comma_ratio",
"has_parens_ratio",
"has_ampersand_ratio",
"has_apostrophe_ratio",
"has_hyphen_ratio",
"has_dot_ratio",
"org_suffix_ratio",
"person_title_ratio",
"place_indicator_ratio",
"creative_indicator_ratio",
"the_prefix_ratio",
"numeric_prefix_ratio",
"uniqueness",
"token_diversity",
"avg_word_len",
"cap_words_mean",
"cap_word_ratio",
"word_density",
"short_value_ratio",
"long_value_ratio",
"cv_length",
"preposition_ratio",
"contains_number_ratio",
"has_quotes_ratio",
"column_size",
"max_value_len",
"max_word_count",
];
pub struct EntityClassifierTrainable {
fc1: Linear,
fc2: Linear,
fc3: Linear,
fc4: Linear,
dropout_rate: f64,
training: bool,
}
impl EntityClassifierTrainable {
pub fn new(varmap: &VarMap, device: &Device) -> Result<Self> {
let vb = VarBuilder::from_varmap(varmap, DType::F32, device);
let fc1 = linear(INPUT_DIM, HIDDEN_DIM, vb.pp("ec_fc1"))?;
let fc2 = linear(HIDDEN_DIM, HIDDEN_DIM, vb.pp("ec_fc2"))?;
let fc3 = linear(HIDDEN_DIM, HIDDEN_DIM / 2, vb.pp("ec_fc3"))?;
let fc4 = linear(HIDDEN_DIM / 2, N_ENTITY, vb.pp("ec_fc4"))?;
Ok(Self {
fc1,
fc2,
fc3,
fc4,
dropout_rate: DROPOUT_RATE,
training: true,
})
}
pub fn set_training(&mut self, training: bool) {
self.training = training;
}
pub fn forward(&self, features: &Tensor) -> Result<Tensor> {
let x = self.fc1.forward(features)?.relu()?;
let x = self.maybe_dropout(&x)?;
let x = self.fc2.forward(&x)?.relu()?;
let x = self.maybe_dropout(&x)?;
let x = self.fc3.forward(&x)?.relu()?;
let x = self.maybe_dropout(&x)?;
Ok(self.fc4.forward(&x)?)
}
fn maybe_dropout(&self, tensor: &Tensor) -> Result<Tensor> {
if !self.training || self.dropout_rate == 0.0 {
return Ok(tensor.clone());
}
Ok(candle_nn::ops::dropout(tensor, self.dropout_rate as f32)?)
}
}
const ORG_SUFFIXES: &[&str] = &[
"inc",
"llc",
"ltd",
"corp",
"co",
"company",
"group",
"foundation",
"association",
"university",
"institute",
"hospital",
"church",
"school",
"bank",
"restaurant",
"hotel",
"salon",
"clinic",
"studio",
"studios",
"records",
"entertainment",
"gmbh",
"ag",
"sa",
"pty",
"pvt",
"plc",
"llp",
];
const PERSON_TITLES: &[&str] = &["mr", "mrs", "ms", "dr", "jr", "sr", "iii", "ii", "iv"];
const PLACE_INDICATORS: &[&str] = &[
"street",
"st",
"avenue",
"ave",
"road",
"rd",
"boulevard",
"blvd",
"drive",
"lane",
"court",
"place",
"square",
"park",
"bridge",
"hill",
"valley",
"lake",
"river",
"mountain",
"beach",
"island",
"bay",
"county",
"province",
"state",
"region",
"district",
"city",
"town",
"village",
];
const CREATIVE_INDICATORS: &[&str] = &[
"album",
"song",
"track",
"episode",
"season",
"chapter",
"vol",
"volume",
"remix",
"live",
"feat",
"ft",
"edition",
"deluxe",
"remaster",
"ost",
"soundtrack",
"tour",
"concert",
"festival",
];
const PREPOSITIONS: &[&str] = &["of", "in", "at", "for", "the", "and", "by", "on", "to"];
fn contains_word(text: &str, keywords: &[&str]) -> bool {
for word in text.split(|c: char| c.is_whitespace() || c == ',' || c == ';') {
let trimmed = word.trim_matches(|c: char| !c.is_alphanumeric());
if trimmed.is_empty() {
continue;
}
let lower = trimmed.to_lowercase();
let lower_no_dot = lower.trim_end_matches('.');
for &kw in keywords {
if lower == kw || lower_no_dot == kw {
return true;
}
}
}
false
}
fn contains_digit(text: &str) -> bool {
text.bytes().any(|b| b.is_ascii_digit())
}
pub fn compute_entity_features(
values: &[String],
model2vec: &finetype_model::Model2VecResources,
) -> Result<Vec<f32>> {
let embed_dim = model2vec.embed_dim().context("embed_dim")?;
let device = model2vec.device();
if values.is_empty() {
return Ok(vec![0.0; INPUT_DIM]);
}
let mut value_embeddings: Vec<Vec<f32>> = Vec::with_capacity(values.len());
let tokenizer = model2vec.tokenizer();
let embeddings = model2vec.embeddings();
for value in values {
let encoding = tokenizer
.encode(value.as_str(), false)
.map_err(|e| anyhow::anyhow!("Tokenizer encode failed: {}", e))?;
let ids = encoding.get_ids();
let valid_ids: Vec<u32> = ids.iter().copied().filter(|&id| id != 0).collect();
if valid_ids.is_empty() {
value_embeddings.push(vec![0.0; embed_dim]);
continue;
}
let id_tensor = Tensor::new(valid_ids.as_slice(), device)?;
let token_embeds = embeddings.index_select(&id_tensor, 0)?;
let mean_embed = token_embeds.mean(0)?;
value_embeddings.push(mean_embed.to_vec1()?);
}
let n = value_embeddings.len() as f32;
let mut emb_mean = vec![0.0f32; embed_dim];
for emb in &value_embeddings {
for (i, v) in emb.iter().enumerate() {
emb_mean[i] += v;
}
}
for v in &mut emb_mean {
*v /= n;
}
let mut emb_std = vec![0.0f32; embed_dim];
if value_embeddings.len() > 1 {
for emb in &value_embeddings {
for (i, v) in emb.iter().enumerate() {
let diff = v - emb_mean[i];
emb_std[i] += diff * diff;
}
}
for v in &mut emb_std {
*v = (*v / n).sqrt();
}
}
let stat_features = compute_stat_features(values);
let mut feature_vec = Vec::with_capacity(INPUT_DIM);
feature_vec.extend_from_slice(&emb_mean);
feature_vec.extend_from_slice(&emb_std);
feature_vec.extend_from_slice(&stat_features);
Ok(feature_vec)
}
fn compute_stat_features(values: &[String]) -> Vec<f32> {
let n = values.len() as f32;
if values.is_empty() {
return vec![0.0; N_STAT_FEATURES];
}
let lengths: Vec<f32> = values.iter().map(|v| v.len() as f32).collect();
let word_counts: Vec<f32> = values
.iter()
.map(|v| v.split_whitespace().count() as f32)
.collect();
let mean_len = stat_mean(&lengths);
let std_len = stat_std_dev(&lengths, mean_len);
let median_len = stat_median(&lengths);
let p25_len = stat_percentile(&lengths, 25.0);
let p75_len = stat_percentile(&lengths, 75.0);
let mean_words = stat_mean(&word_counts);
let std_words = stat_std_dev(&word_counts, mean_words);
let single_word_ratio = word_counts.iter().filter(|&&w| w == 1.0).count() as f32 / n;
let two_word_ratio = word_counts.iter().filter(|&&w| w == 2.0).count() as f32 / n;
let three_plus_ratio = word_counts.iter().filter(|&&w| w >= 3.0).count() as f32 / n;
let mut alpha_ratios = Vec::with_capacity(values.len());
let mut digit_ratios = Vec::with_capacity(values.len());
let mut space_ratios = Vec::with_capacity(values.len());
let mut punct_ratios = Vec::with_capacity(values.len());
let mut has_digits_count = 0usize;
for v in values {
let len = v.len() as f32;
if len == 0.0 {
alpha_ratios.push(0.0);
digit_ratios.push(0.0);
space_ratios.push(0.0);
punct_ratios.push(0.0);
continue;
}
let mut alpha = 0usize;
let mut digit = 0usize;
let mut space = 0usize;
let mut has_digit = false;
for c in v.chars() {
if c.is_alphabetic() {
alpha += 1;
} else if c.is_ascii_digit() {
digit += 1;
has_digit = true;
} else if c.is_whitespace() {
space += 1;
}
}
let a = alpha as f32 / len;
let d = digit as f32 / len;
let s = space as f32 / len;
alpha_ratios.push(a);
digit_ratios.push(d);
space_ratios.push(s);
punct_ratios.push(1.0 - a - d - s);
if has_digit {
has_digits_count += 1;
}
}
let mean_alpha_ratio = stat_mean(&alpha_ratios);
let mean_digit_ratio = stat_mean(&digit_ratios);
let mean_space_ratio = stat_mean(&space_ratios);
let mean_punct_ratio = stat_mean(&punct_ratios);
let has_digits_ratio = has_digits_count as f32 / n;
let title_case_ratio = values.iter().filter(|v| is_title_case(v)).count() as f32 / n;
let all_caps_ratio = values
.iter()
.filter(|v| {
let upper = v.to_uppercase();
let lower = v.to_lowercase();
v.as_str() == upper && v.as_str() != lower
})
.count() as f32
/ n;
let has_comma_ratio = values.iter().filter(|v| v.contains(',')).count() as f32 / n;
let has_parens_ratio = values
.iter()
.filter(|v| v.contains('(') || v.contains(')'))
.count() as f32
/ n;
let has_ampersand_ratio = values.iter().filter(|v| v.contains('&')).count() as f32 / n;
let has_apostrophe_ratio = values
.iter()
.filter(|v| v.contains('\'') || v.contains('\u{2019}'))
.count() as f32
/ n;
let has_hyphen_ratio = values.iter().filter(|v| v.contains('-')).count() as f32 / n;
let has_dot_ratio = values.iter().filter(|v| v.contains('.')).count() as f32 / n;
let org_suffix_ratio = values
.iter()
.filter(|v| contains_word(v, ORG_SUFFIXES))
.count() as f32
/ n;
let person_title_ratio = values
.iter()
.filter(|v| contains_word(v, PERSON_TITLES))
.count() as f32
/ n;
let place_indicator_ratio = values
.iter()
.filter(|v| contains_word(v, PLACE_INDICATORS))
.count() as f32
/ n;
let creative_indicator_ratio = values
.iter()
.filter(|v| contains_word(v, CREATIVE_INDICATORS))
.count() as f32
/ n;
let the_prefix_ratio = values
.iter()
.filter(|v| v.to_lowercase().starts_with("the "))
.count() as f32
/ n;
let numeric_prefix_ratio = values
.iter()
.filter(|v| v.starts_with(|c: char| c.is_ascii_digit()))
.count() as f32
/ n;
let unique_count = {
let mut seen = HashSet::new();
for v in values {
seen.insert(v.as_str());
}
seen.len()
};
let uniqueness = unique_count as f32 / n;
let all_words: Vec<&str> = values.iter().flat_map(|v| v.split_whitespace()).collect();
let token_diversity = if all_words.is_empty() {
0.0
} else {
let unique_words: HashSet<&str> = all_words.iter().copied().collect();
unique_words.len() as f32 / all_words.len() as f32
};
let avg_word_len = if all_words.is_empty() {
0.0
} else {
all_words.iter().map(|w| w.len() as f32).sum::<f32>() / all_words.len() as f32
};
let cap_words_per_value: Vec<f32> = values
.iter()
.map(|v| {
v.split_whitespace()
.filter(|w| w.starts_with(|c: char| c.is_uppercase()))
.count() as f32
})
.collect();
let cap_words_mean = stat_mean(&cap_words_per_value);
let cap_word_ratio_values: Vec<f32> = values
.iter()
.map(|v| {
let words: Vec<&str> = v.split_whitespace().collect();
if words.is_empty() {
return 0.0;
}
let cap = words
.iter()
.filter(|w| w.starts_with(|c: char| c.is_uppercase()))
.count() as f32;
cap / words.len() as f32
})
.collect();
let cap_word_ratio = stat_mean(&cap_word_ratio_values);
let word_density = if mean_len > 0.0 {
mean_words / mean_len
} else {
0.0
};
let short_value_ratio = lengths.iter().filter(|&&l| l <= 3.0).count() as f32 / n;
let long_value_ratio = lengths.iter().filter(|&&l| l > 50.0).count() as f32 / n;
let cv_length = if mean_len > 0.0 {
std_len / mean_len
} else {
0.0
};
let preposition_ratio = values
.iter()
.filter(|v| contains_word(v, PREPOSITIONS))
.count() as f32
/ n;
let contains_number_ratio = values.iter().filter(|v| contains_digit(v)).count() as f32 / n;
let has_quotes_ratio = values
.iter()
.filter(|v| {
v.contains('"') || v.contains('\'') || v.contains('\u{00ab}') || v.contains('\u{00bb}')
})
.count() as f32
/ n;
let column_size = n;
let max_value_len = lengths.iter().cloned().fold(0.0f32, f32::max);
let max_word_count = word_counts.iter().cloned().fold(0.0f32, f32::max);
vec![
mean_len,
std_len,
median_len,
p25_len,
p75_len,
mean_words,
std_words,
single_word_ratio,
two_word_ratio,
three_plus_ratio,
mean_alpha_ratio,
mean_digit_ratio,
mean_space_ratio,
mean_punct_ratio,
has_digits_ratio,
title_case_ratio,
all_caps_ratio,
has_comma_ratio,
has_parens_ratio,
has_ampersand_ratio,
has_apostrophe_ratio,
has_hyphen_ratio,
has_dot_ratio,
org_suffix_ratio,
person_title_ratio,
place_indicator_ratio,
creative_indicator_ratio,
the_prefix_ratio,
numeric_prefix_ratio,
uniqueness,
token_diversity,
avg_word_len,
cap_words_mean,
cap_word_ratio,
word_density,
short_value_ratio,
long_value_ratio,
cv_length,
preposition_ratio,
contains_number_ratio,
has_quotes_ratio,
column_size,
max_value_len,
max_word_count,
]
}
fn stat_mean(v: &[f32]) -> f32 {
if v.is_empty() {
return 0.0;
}
v.iter().sum::<f32>() / v.len() as f32
}
fn stat_std_dev(v: &[f32], mean: f32) -> f32 {
if v.is_empty() {
return 0.0;
}
let variance = v.iter().map(|x| (x - mean).powi(2)).sum::<f32>() / v.len() as f32;
variance.sqrt()
}
fn stat_median(v: &[f32]) -> f32 {
if v.is_empty() {
return 0.0;
}
let mut sorted = v.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = sorted.len();
if n.is_multiple_of(2) {
(sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
} else {
sorted[n / 2]
}
}
fn stat_percentile(v: &[f32], pct: f32) -> f32 {
if v.is_empty() {
return 0.0;
}
let mut sorted = v.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let rank = pct / 100.0 * (n - 1) as f32;
let lower = rank.floor() as usize;
let upper = rank.ceil() as usize;
if lower == upper {
sorted[lower]
} else {
let frac = rank - lower as f32;
sorted[lower] * (1.0 - frac) + sorted[upper] * frac
}
}
fn is_title_case(s: &str) -> bool {
let words: Vec<&str> = s
.split_whitespace()
.filter(|w| w.starts_with(|c: char| c.is_alphabetic()))
.collect();
if words.is_empty() {
return false;
}
let upper_count = words
.iter()
.filter(|w| w.starts_with(|c: char| c.is_uppercase()))
.count();
upper_count as f32 / words.len() as f32 > 0.7
}
pub struct EntityTrainConfig {
pub epochs: usize,
pub batch_size: usize,
pub lr: f64,
pub min_lr: f64,
pub patience: usize,
pub demotion_threshold: f64,
pub seed: u64,
}
impl Default for EntityTrainConfig {
fn default() -> Self {
Self {
epochs: 100,
batch_size: 64,
lr: 5e-4,
min_lr: 1e-6,
patience: 15,
demotion_threshold: 0.6,
seed: 42,
}
}
}
pub fn train_entity(
config: &EntityTrainConfig,
train_features: &[Vec<f32>],
train_labels: &[usize],
val_features: &[Vec<f32>],
val_labels: &[usize],
) -> Result<(TrainingSummary, VarMap)> {
let (device, device_name) = crate::get_device();
eprintln!("Using {device_name} device");
let varmap = VarMap::new();
let mut model = EntityClassifierTrainable::new(&varmap, &device)?;
let n_train = train_features.len();
let n_val = val_features.len();
tracing::info!(
"Training entity classifier: {} train, {} val samples",
n_train,
n_val
);
let class_weights = compute_class_weights(train_labels, N_ENTITY, &device)?;
tracing::info!(
"Class weights: {:?}",
class_weights.to_vec1::<f32>().unwrap_or_default()
);
let val_feat_tensor = vec2_to_tensor(val_features, &device)?;
let val_label_u32: Vec<u32> = val_labels.iter().map(|&x| x as u32).collect();
let val_label_tensor = Tensor::new(val_label_u32.as_slice(), &device)?;
let all_vars = varmap.all_vars();
let adamw_params = candle_nn::ParamsAdamW {
lr: config.lr,
weight_decay: 0.01,
..Default::default()
};
let mut optimizer = candle_nn::AdamW::new(all_vars, adamw_params)?;
let scheduler = CosineScheduler::new(config.lr, config.min_lr, config.epochs);
let mut early_stopping = EarlyStopping::new(config.patience, true);
let mut epoch_metrics = Vec::new();
let mut rng = {
use rand::SeedableRng;
rand::rngs::StdRng::seed_from_u64(config.seed)
};
let start_time = std::time::Instant::now();
for epoch in 0..config.epochs {
let epoch_start = std::time::Instant::now();
let lr = scheduler.lr(epoch);
optimizer.set_learning_rate(lr);
model.set_training(true);
let batches = shuffled_batches(n_train, config.batch_size, &mut rng);
let mut train_loss_sum = 0.0f32;
let mut train_correct = 0.0f32;
let mut train_batches = 0usize;
for batch_indices in &batches {
let batch_features: Vec<Vec<f32>> = batch_indices
.iter()
.map(|&i| train_features[i].clone())
.collect();
let batch_labels: Vec<u32> = batch_indices
.iter()
.map(|&i| train_labels[i] as u32)
.collect();
let feat_tensor = vec2_to_tensor(&batch_features, &device)?;
let label_tensor = Tensor::new(batch_labels.as_slice(), &device)?;
let logits = model.forward(&feat_tensor)?;
let loss = entity_weighted_ce_loss(&logits, &label_tensor, &class_weights)?;
optimizer.backward_step(&loss)?;
train_loss_sum += loss.to_scalar::<f32>()?;
train_correct += compute_accuracy(&logits, &label_tensor)? * batch_indices.len() as f32;
train_batches += 1;
}
let train_loss = train_loss_sum / train_batches as f32;
let train_accuracy = train_correct / n_train as f32;
model.set_training(false);
let val_logits = model.forward(&val_feat_tensor)?;
let val_loss = entity_weighted_ce_loss(&val_logits, &val_label_tensor, &class_weights)?
.to_scalar::<f32>()?;
let val_accuracy = compute_accuracy(&val_logits, &val_label_tensor)?;
let epoch_time = epoch_start.elapsed().as_secs_f32();
let metrics = EpochMetrics {
epoch,
train_loss,
val_loss,
train_accuracy,
val_accuracy,
learning_rate: lr,
epoch_time_secs: epoch_time,
branch_gradient_norms: None,
};
if epoch % 10 == 0 || epoch == config.epochs - 1 {
tracing::info!(
"Epoch {:3}/{} — train_loss: {:.4}, val_loss: {:.4}, \
train_acc: {:.3}, val_acc: {:.3}, lr: {:.2e}",
epoch + 1,
config.epochs,
train_loss,
val_loss,
train_accuracy,
val_accuracy,
lr,
);
}
epoch_metrics.push(metrics);
if early_stopping.step(epoch, val_accuracy) {
tracing::info!(
"Early stopping at epoch {} (best: epoch {} with val_acc {:.4})",
epoch + 1,
early_stopping.best_epoch() + 1,
early_stopping.best_metric(),
);
break;
}
}
let total_time = start_time.elapsed().as_secs_f32();
let summary = TrainingSummary {
best_epoch: early_stopping.best_epoch(),
best_val_accuracy: early_stopping.best_metric(),
total_epochs: epoch_metrics.len(),
total_time_secs: total_time,
epoch_metrics,
};
Ok((summary, varmap))
}
fn entity_weighted_ce_loss(
logits: &Tensor,
targets: &Tensor,
class_weights: &Tensor,
) -> Result<Tensor> {
let log_probs = candle_nn::ops::log_softmax(logits, candle_core::D::Minus1)?;
let target_log_probs = log_probs.gather(&targets.unsqueeze(1)?, 1)?.squeeze(1)?;
let batch_size = targets.dims()[0];
let weights_2d = class_weights
.unsqueeze(0)?
.expand((batch_size, class_weights.dims()[0]))?
.contiguous()?;
let sample_weights = weights_2d.gather(&targets.unsqueeze(1)?, 1)?.squeeze(1)?;
let weighted_loss = (target_log_probs.neg()? * sample_weights)?;
let loss = weighted_loss.mean_all()?;
Ok(loss)
}
fn compute_class_weights(labels: &[usize], n_classes: usize, device: &Device) -> Result<Tensor> {
let n = labels.len() as f32;
let mut counts = vec![0f32; n_classes];
for &l in labels {
if l < n_classes {
counts[l] += 1.0;
}
}
let weights: Vec<f32> = counts
.iter()
.map(|&c| {
if c > 0.0 {
n / (n_classes as f32 * c)
} else {
1.0
}
})
.collect();
Ok(Tensor::new(weights.as_slice(), device)?)
}
pub fn save_entity_model(
output_dir: &Path,
varmap: &VarMap,
summary: &TrainingSummary,
demotion_threshold: f64,
train_labels: &[usize],
val_labels: &[usize],
) -> Result<()> {
std::fs::create_dir_all(output_dir)?;
let model_path = output_dir.join("model.safetensors");
varmap.save(&model_path)?;
tracing::info!("Saved model weights to {}", model_path.display());
let demotion_analysis = compute_demotion_analysis(val_labels, summary);
let n_train = train_labels.len();
let n_val = val_labels.len();
let stat_names_vec: Vec<&str> = STAT_FEATURE_NAMES.to_vec();
let class_labels_vec: Vec<&str> = ENTITY_LABELS.to_vec();
let config = serde_json::json!({
"architecture": "deep_sets_mlp",
"input_dim": INPUT_DIM,
"hidden_dim": HIDDEN_DIM,
"n_classes": N_ENTITY,
"dropout": DROPOUT_RATE,
"demotion_threshold": demotion_threshold,
"test_accuracy": summary.best_val_accuracy,
"class_labels": class_labels_vec,
"stat_feature_names": stat_names_vec,
"feature_layout": {
"emb_mean": { "start": 0, "end": EMBED_DIM },
"emb_std": { "start": EMBED_DIM, "end": 2 * EMBED_DIM },
"stat_features": { "start": 2 * EMBED_DIM, "end": INPUT_DIM }
},
"train_size": n_train,
"test_size": n_val,
"best_epoch": summary.best_epoch,
"total_epochs": summary.total_epochs,
"training_time_secs": summary.total_time_secs,
"demotion_analysis": demotion_analysis,
});
let config_path = output_dir.join("config.json");
std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
tracing::info!("Saved config to {}", config_path.display());
let label_index: serde_json::Value = ENTITY_LABELS
.iter()
.enumerate()
.map(|(i, label)| (label.to_string(), serde_json::json!(i)))
.collect::<serde_json::Map<String, serde_json::Value>>()
.into();
let label_path = output_dir.join("label_index.json");
std::fs::write(&label_path, serde_json::to_string_pretty(&label_index)?)?;
tracing::info!("Saved label index to {}", label_path.display());
Ok(())
}
fn compute_demotion_analysis(
val_labels: &[usize],
_summary: &TrainingSummary,
) -> serde_json::Value {
let mut counts = [0usize; N_ENTITY];
for &l in val_labels {
if l < N_ENTITY {
counts[l] += 1;
}
}
let n_person = counts[0];
let n_nonperson: usize = counts[1..].iter().sum();
serde_json::json!({
"val_person_count": n_person,
"val_nonperson_count": n_nonperson,
"val_class_distribution": {
"person": counts[0],
"place": counts[1],
"organization": counts[2],
"creative_work": counts[3],
},
"note": "Full threshold analysis requires inference pass (see binary output)"
})
}
pub fn sotab_to_entity_class(gt_label: &str) -> Option<usize> {
match gt_label {
"Person" => Some(0),
"Country" | "City" | "State" | "AdministrativeArea" | "Place" | "Continent" => Some(1),
"Organization"
| "MusicGroup"
| "SportsClub"
| "SportsTeam"
| "LocalBus"
| "Corporation"
| "EducationalOrganization" => Some(2),
"CreativeWork" | "Movie" | "MusicAlbum" | "MusicRecording" | "TVSeries" | "Book"
| "Product" => Some(3),
_ => None,
}
}
pub const SOTAB_ENTITY_LABELS: &[&str] = &[
"Person",
"Organization",
"MusicGroup",
"SportsClub",
"SportsTeam",
"LocalBus",
"Corporation",
"EducationalOrganization",
"CreativeWork",
"Movie",
"MusicAlbum",
"MusicRecording",
"TVSeries",
"Book",
"Product",
"Country",
"City",
"State",
"AdministrativeArea",
"Place",
"Continent",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_stat_helpers() {
assert!((stat_mean(&[1.0, 2.0, 3.0]) - 2.0).abs() < 1e-5);
assert!((stat_std_dev(&[1.0, 2.0, 3.0], 2.0) - 0.8165).abs() < 0.001);
assert!((stat_median(&[1.0, 2.0, 3.0]) - 2.0).abs() < 1e-5);
assert!((stat_median(&[1.0, 2.0, 3.0, 4.0]) - 2.5).abs() < 1e-5);
assert!((stat_percentile(&[1.0, 2.0, 3.0, 4.0], 25.0) - 1.75).abs() < 1e-5);
assert!((stat_percentile(&[1.0, 2.0, 3.0, 4.0], 75.0) - 3.25).abs() < 1e-5);
}
#[test]
fn test_stat_feature_count() {
let values = vec![
"John Smith".to_string(),
"Jane Doe".to_string(),
"Robert Johnson".to_string(),
];
let features = compute_stat_features(&values);
assert_eq!(
features.len(),
N_STAT_FEATURES,
"Expected {} stat features, got {}",
N_STAT_FEATURES,
features.len()
);
}
#[test]
fn test_contains_word_patterns() {
assert!(contains_word("Acme Inc", ORG_SUFFIXES));
assert!(contains_word("MIT University", ORG_SUFFIXES));
assert!(contains_word("Royal Hotel", ORG_SUFFIXES));
assert!(contains_word("Smith & Co GmbH", ORG_SUFFIXES));
assert!(!contains_word("John Smith", ORG_SUFFIXES));
assert!(contains_word("Dr. Smith", PERSON_TITLES));
assert!(contains_word("John Jr.", PERSON_TITLES));
assert!(!contains_word("John Smith", PERSON_TITLES));
assert!(contains_word("123 Main Street", PLACE_INDICATORS));
assert!(contains_word("Hyde Park", PLACE_INDICATORS));
assert!(!contains_word("Acme Inc", PLACE_INDICATORS));
assert!(contains_word("Greatest Hits Album", CREATIVE_INDICATORS));
assert!(contains_word("Live at the Apollo", CREATIVE_INDICATORS));
assert!(!contains_word("John Smith", CREATIVE_INDICATORS));
assert!(contains_word("King of the Hill", PREPOSITIONS));
}
#[test]
fn test_contains_digit() {
assert!(contains_digit("abc123"));
assert!(contains_digit("42"));
assert!(!contains_digit("hello world"));
assert!(!contains_digit(""));
}
#[test]
fn test_sotab_label_mapping() {
assert_eq!(sotab_to_entity_class("Person"), Some(0));
assert_eq!(sotab_to_entity_class("Organization"), Some(2));
assert_eq!(sotab_to_entity_class("Movie"), Some(3));
assert_eq!(sotab_to_entity_class("Country"), Some(1));
assert_eq!(sotab_to_entity_class("SomeOtherType"), None);
}
#[test]
fn test_class_weights_balanced() {
let device = Device::Cpu;
let labels = vec![0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3];
let weights = compute_class_weights(&labels, 4, &device).unwrap();
let w: Vec<f32> = weights.to_vec1().unwrap();
for &weight in &w {
assert!(
(weight - 1.0).abs() < 0.01,
"Expected ~1.0 for balanced classes, got {}",
weight
);
}
}
#[test]
fn test_class_weights_imbalanced() {
let device = Device::Cpu;
let labels = vec![0, 0, 0, 0, 0, 0, 2, 2];
let weights = compute_class_weights(&labels, 4, &device).unwrap();
let w: Vec<f32> = weights.to_vec1().unwrap();
assert!(
(w[0] - 8.0 / 24.0).abs() < 0.01,
"Person weight: expected {}, got {}",
8.0 / 24.0,
w[0]
);
assert!(
(w[2] - 1.0).abs() < 0.01,
"Org weight: expected 1.0, got {}",
w[2]
);
assert!(
(w[1] - 1.0).abs() < 0.01,
"Empty class weight: expected 1.0, got {}",
w[1]
);
}
#[test]
fn test_train_entity_synthetic() {
use rand::{Rng, SeedableRng};
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let n_per_class = 13; let n_total = n_per_class * N_ENTITY;
let mut features = Vec::with_capacity(n_total);
let mut labels = Vec::with_capacity(n_total);
for class_idx in 0..N_ENTITY {
for _ in 0..n_per_class {
let mut feat = vec![0.0f32; INPUT_DIM];
for f in feat.iter_mut() {
*f = rng.gen::<f32>() * 0.5 + class_idx as f32;
}
features.push(feat);
labels.push(class_idx);
}
}
let split = (n_total as f32 * 0.9) as usize;
let train_features = &features[..split];
let train_labels = &labels[..split];
let val_features = &features[split..];
let val_labels = &labels[split..];
let config = EntityTrainConfig {
epochs: 10,
batch_size: 16,
lr: 1e-3,
min_lr: 1e-5,
patience: 15,
demotion_threshold: 0.6,
seed: 42,
};
let (summary, _varmap) = train_entity(
&config,
train_features,
train_labels,
val_features,
val_labels,
)
.expect("Training should succeed");
let first_loss = summary.epoch_metrics[0].train_loss;
let last_loss = summary.epoch_metrics.last().unwrap().train_loss;
assert!(
last_loss < first_loss,
"Loss should decrease: first={:.4}, last={:.4}",
first_loss,
last_loss,
);
assert_eq!(summary.total_epochs, 10);
}
#[test]
fn test_train_entity_save() {
use rand::{Rng, SeedableRng};
let mut rng = rand::rngs::StdRng::seed_from_u64(123);
let n_per_class = 10;
let n_total = n_per_class * N_ENTITY;
let mut features = Vec::with_capacity(n_total);
let mut labels = Vec::with_capacity(n_total);
for class_idx in 0..N_ENTITY {
for _ in 0..n_per_class {
let mut feat = vec![0.0f32; INPUT_DIM];
for f in feat.iter_mut() {
*f = rng.gen::<f32>() + class_idx as f32 * 2.0;
}
features.push(feat);
labels.push(class_idx);
}
}
let split = 32;
let config = EntityTrainConfig {
epochs: 5,
batch_size: 8,
lr: 1e-3,
min_lr: 1e-5,
patience: 15,
demotion_threshold: 0.6,
seed: 123,
};
let (summary, varmap) = train_entity(
&config,
&features[..split],
&labels[..split],
&features[split..],
&labels[split..],
)
.expect("Training should succeed");
let tmp_dir = tempfile::tempdir().expect("tempdir");
save_entity_model(
tmp_dir.path(),
&varmap,
&summary,
config.demotion_threshold,
&labels[..split],
&labels[split..],
)
.expect("Save should succeed");
assert!(
tmp_dir.path().join("model.safetensors").exists(),
"model.safetensors should exist"
);
assert!(
tmp_dir.path().join("config.json").exists(),
"config.json should exist"
);
assert!(
tmp_dir.path().join("label_index.json").exists(),
"label_index.json should exist"
);
let config_str = std::fs::read_to_string(tmp_dir.path().join("config.json")).unwrap();
let config_json: serde_json::Value = serde_json::from_str(&config_str).unwrap();
assert_eq!(config_json["architecture"], "deep_sets_mlp");
assert_eq!(config_json["input_dim"], INPUT_DIM);
assert_eq!(config_json["n_classes"], N_ENTITY);
let label_str = std::fs::read_to_string(tmp_dir.path().join("label_index.json")).unwrap();
let label_json: serde_json::Value = serde_json::from_str(&label_str).unwrap();
assert_eq!(label_json["person"], 0);
assert_eq!(label_json["place"], 1);
assert_eq!(label_json["organization"], 2);
assert_eq!(label_json["creative_work"], 3);
}
}